feat: 包信息展示与扫描数据修正
- scanner: 版本数只统计实际存在的 .tgz(修复 playwright-core 5113 版本虚高),解析 description/homepage/repository/author/license 元数据字段 - 前端: 新增 PkgLogo 首字母徽标组件,详情抽屉展示包 logo(主页 favicon 加载失败自动回退)、介绍与主页/仓库/许可胶囊,优化抽屉头部布局 - 全量 UI 改进: 毛玻璃扫描遮罩与进度、头像菜单重新扫描、主题切换、移动端 Tabbar/StatMini/Top30Panel 响应式、列表瘦身与详情按需加载 - compose: TZ=Asia/Shanghai + 命名卷 vcm-data 解决 SQLite 权限 - 更新 amd64 镜像 tar
This commit is contained in:
+136
-3
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppTopbar from './components/AppTopbar.vue'
|
||||
import MobileTabbar from './components/MobileTabbar.vue'
|
||||
@@ -7,12 +7,33 @@ import SettingDrawer from './components/SettingDrawer.vue'
|
||||
import { useStore } from './store'
|
||||
|
||||
const route = useRoute()
|
||||
const { refreshAll } = useStore()
|
||||
const { state, refreshAll, scanNow } = useStore()
|
||||
const settingsOpen = ref(false)
|
||||
|
||||
const isLogin = () => route.path === '/login'
|
||||
const isMobile = () => window.matchMedia('(max-width: 767px)').matches
|
||||
|
||||
// 数据基于何时扫描(后端扫描时间,缓存复用)
|
||||
const fmtScanTime = computed(() => {
|
||||
const t = state.scannedAt
|
||||
if (!t) return ''
|
||||
const d = new Date(t)
|
||||
const p = n => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
||||
})
|
||||
|
||||
async function doRescan() {
|
||||
await scanNow()
|
||||
}
|
||||
|
||||
// 扫描进度字节数 → 可读文本
|
||||
const fmtScanBytes = computed(() => {
|
||||
const b = state.scanProgress.bytes || 0
|
||||
const mb = b / 1024 / 1024
|
||||
if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB`
|
||||
return `${mb.toFixed(1)} MB`
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!isLogin()) refreshAll()
|
||||
})
|
||||
@@ -23,7 +44,7 @@ onMounted(() => {
|
||||
|
||||
<div v-if="!isLogin()">
|
||||
<!-- 桌面/平板顶栏 -->
|
||||
<AppTopbar class="desktop-topbar" @open-settings="settingsOpen = true" />
|
||||
<AppTopbar class="desktop-topbar" @open-settings="settingsOpen = true" @rescan="doRescan" />
|
||||
<!-- 移动端顶栏:☰ + 品牌 -->
|
||||
<header class="m-topbar">
|
||||
<div class="m-brand">
|
||||
@@ -32,6 +53,25 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="m-menu" @click="settingsOpen = true">☰</div>
|
||||
</header>
|
||||
|
||||
<!-- 导航栏下方:扫描信息条 -->
|
||||
<div class="scan-banner" v-if="state.loading || state.scannedAt">
|
||||
<span v-if="state.loading" class="scan-banner-loading">⏳ 正在扫描缓存目录,请稍候…</span>
|
||||
<span v-else>🕐 数据基于 {{ fmtScanTime }} 扫描</span>
|
||||
</div>
|
||||
|
||||
<!-- 扫描遮罩:毛玻璃 + loading + 实时进度 -->
|
||||
<div v-if="state.loading" class="scan-mask">
|
||||
<div class="scan-card">
|
||||
<div class="scan-spinner"></div>
|
||||
<div class="scan-title">正在扫描缓存目录…</div>
|
||||
<div class="scan-info">
|
||||
已扫描 <b>{{ state.scanProgress.packages.toLocaleString() }}</b> 个包
|
||||
<span class="dot">·</span> <b>{{ fmtScanBytes }}</b>
|
||||
</div>
|
||||
<div class="scan-bar"><i></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<router-view v-slot="{ Component }">
|
||||
@@ -47,6 +87,95 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.m-topbar { display: none; }
|
||||
|
||||
/* —— 扫描信息条(顶栏下方) —— */
|
||||
.scan-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start; /* 靠左对齐 */
|
||||
gap: 8px;
|
||||
padding: 6px 20px;
|
||||
font-size: 12px;
|
||||
color: var(--ink-faint);
|
||||
background: var(--panel-soft);
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
font-family: var(--num);
|
||||
}
|
||||
.scan-banner-loading {
|
||||
color: var(--orange);
|
||||
font-weight: 600;
|
||||
animation: blink 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* —— 扫描遮罩(玻璃拟态:半透明背景 + 卡片磨砂模糊 + 细边框/高光)
|
||||
遮罩层只做半透明(避免全屏 blur 的性能开销与双倍模糊),
|
||||
真正的毛玻璃效果做在卡片上:半透明背景 + backdrop-filter 模糊 + 细边框 + 高光 —— */
|
||||
.scan-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 500;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(18, 16, 14, .38);
|
||||
}
|
||||
html[data-theme="bright"] .scan-mask { background: rgba(244, 242, 239, .45); }
|
||||
.scan-card {
|
||||
position: relative;
|
||||
background: rgba(28, 26, 24, .55);
|
||||
backdrop-filter: blur(18px) saturate(1.35);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(1.35);
|
||||
border: 1px solid rgba(255, 255, 255, .14);
|
||||
border-radius: 20px;
|
||||
box-shadow:
|
||||
0 24px 60px -16px rgba(0, 0, 0, .35),
|
||||
inset 0 1px 0 rgba(255, 255, 255, .18);
|
||||
padding: 32px 44px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 300px;
|
||||
max-width: 92vw;
|
||||
}
|
||||
html[data-theme="bright"] .scan-card {
|
||||
background: rgba(255, 255, 255, .55);
|
||||
border-color: rgba(255, 255, 255, .55);
|
||||
box-shadow:
|
||||
0 24px 60px -16px rgba(0, 0, 0, .22),
|
||||
inset 0 1px 0 rgba(255, 255, 255, .85);
|
||||
}
|
||||
.scan-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid var(--line);
|
||||
border-top-color: var(--orange);
|
||||
animation: spin360 .8s linear infinite;
|
||||
}
|
||||
.scan-title { font-size: 15px; font-weight: 700; }
|
||||
.scan-info { font-family: var(--num); font-size: 12.5px; color: var(--ink-dim); }
|
||||
.scan-info b { color: var(--orange); font-weight: 700; }
|
||||
.scan-info .dot { margin: 0 4px; color: var(--ink-faint); }
|
||||
.scan-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 99px;
|
||||
background: var(--num-bg);
|
||||
overflow: hidden;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.scan-bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 42%;
|
||||
border-radius: 99px;
|
||||
background: linear-gradient(90deg, transparent, var(--orange), transparent);
|
||||
animation: scan-slide 1.1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes scan-slide {
|
||||
from { transform: translateX(-110%); }
|
||||
to { transform: translateX(340%); }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.desktop-topbar { display: none; }
|
||||
.m-topbar {
|
||||
@@ -60,6 +189,10 @@ onMounted(() => {
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.scan-banner {
|
||||
padding: 5px 16px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.m-brand { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 700; }
|
||||
.m-logo {
|
||||
width: 30px;
|
||||
|
||||
@@ -67,23 +67,51 @@ const api = {
|
||||
let list = clone(mockState.packages)
|
||||
list = list.map(p => ({ ...p, status: computeStatus(p, mockState.versionThreshold) }))
|
||||
if (kw) list = list.filter(p => p.name.toLowerCase().includes(kw))
|
||||
return { packages: sortPackages(list), scannedAt: new Date().toISOString() }
|
||||
return { packages: sortPackages(list), scannedAt: new Date().toISOString(), scanning: false }
|
||||
}
|
||||
const r = await fetch(`/api/packages?keyword=${encodeURIComponent(keyword)}`)
|
||||
const data = await r.json()
|
||||
return { packages: data.packages, scannedAt: data.scannedAt || null }
|
||||
return {
|
||||
packages: data.packages || [],
|
||||
scannedAt: data.scannedAt || null,
|
||||
scanning: data.scanning || false,
|
||||
scanPackages: data.scanPackages || 0,
|
||||
scanBytes: data.scanBytes || 0,
|
||||
totalBytes: data.totalBytes || null,
|
||||
}
|
||||
},
|
||||
|
||||
/** 手动触发后端重新扫描缓存目录 */
|
||||
/** 单包详情(含版本明细),抽屉打开时按需加载 */
|
||||
async getPackageDetail(name) {
|
||||
if (USE_MOCK) {
|
||||
await delay(80)
|
||||
const p = clone(mockState.packages).find(x => x.name === name) || null
|
||||
return { ok: !!p, pkg: p }
|
||||
}
|
||||
const r = await fetch(`/api/packages/${encodeURIComponent(name)}`)
|
||||
if (!r.ok) return { ok: false, pkg: null }
|
||||
return r.json()
|
||||
},
|
||||
|
||||
/** 手动触发后端重新扫描缓存目录(异步,返回后需轮询进度) */
|
||||
async scanNow() {
|
||||
if (USE_MOCK) {
|
||||
await delay(300)
|
||||
return { ok: true, scannedAt: new Date().toISOString() }
|
||||
return { ok: true, scanning: false, scannedAt: new Date().toISOString() }
|
||||
}
|
||||
const r = await fetch('/api/scan', { method: 'POST' })
|
||||
return r.json()
|
||||
},
|
||||
|
||||
/** 查询扫描进度(包数 / 字节数 / 是否完成) */
|
||||
async getScanProgress() {
|
||||
if (USE_MOCK) {
|
||||
return { scanning: false, done: true, packages: 33, bytes: 0 }
|
||||
}
|
||||
const r = await fetch('/api/scan/progress')
|
||||
return r.json()
|
||||
},
|
||||
|
||||
async getThresholds() {
|
||||
if (USE_MOCK) {
|
||||
await delay(80)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import { useStore } from '../store'
|
||||
@@ -8,7 +9,9 @@ const router = useRouter()
|
||||
const { theme, toggle } = useTheme()
|
||||
const { state } = useStore()
|
||||
|
||||
defineEmits(['open-settings'])
|
||||
const emit = defineEmits(['open-settings', 'rescan'])
|
||||
|
||||
const menuOpen = ref(false)
|
||||
|
||||
const navs = [
|
||||
{ path: '/', label: '概览', emo: '🏠' },
|
||||
@@ -21,6 +24,22 @@ const navs = [
|
||||
function go(path) {
|
||||
if (route.path !== path) router.push(path)
|
||||
}
|
||||
|
||||
function pick(action) {
|
||||
menuOpen.value = false
|
||||
if (action === 'rescan') emit('rescan')
|
||||
else if (action === 'settings') emit('open-settings')
|
||||
else if (action === 'logout') {
|
||||
localStorage.removeItem('vcm-user')
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
|
||||
function onDocClick(e) {
|
||||
if (!e.target.closest('.avatar-wrap')) menuOpen.value = false
|
||||
}
|
||||
onMounted(() => document.addEventListener('click', onDocClick))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -46,10 +65,22 @@ function go(path) {
|
||||
</nav>
|
||||
|
||||
<div class="topbar-right">
|
||||
<button class="theme-toggle" :title="theme === 'dark' ? '切换到亮色' : '切换到暗色'" @click="toggle">
|
||||
{{ theme === 'dark' ? '☀️ 切到亮色' : '🌙 切到暗色' }}
|
||||
</button>
|
||||
<div class="avatar" title="设置" @click="$emit('open-settings')">👤</div>
|
||||
<button
|
||||
class="theme-toggle"
|
||||
:title="theme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'"
|
||||
:aria-label="theme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'"
|
||||
@click="toggle"
|
||||
>{{ theme === 'dark' ? '☀️' : '🌙' }}</button>
|
||||
<div class="avatar-wrap">
|
||||
<div class="avatar" title="用户菜单" @click="menuOpen = !menuOpen">👤</div>
|
||||
<transition name="pop">
|
||||
<div v-if="menuOpen" class="avatar-menu">
|
||||
<button class="am-item" :disabled="state.loading" @click="pick('rescan')">{{ state.loading ? '⏳ 扫描中…' : '🔄 重新扫描' }}</button>
|
||||
<button class="am-item" @click="pick('settings')">⚙️ 设置</button>
|
||||
<button class="am-item danger" @click="pick('logout')">🚪 退出登录</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -145,19 +176,17 @@ function go(path) {
|
||||
cursor: pointer;
|
||||
}
|
||||
.theme-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1.5px solid var(--line);
|
||||
background: var(--toggle-bg);
|
||||
color: var(--ink-dim);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
padding: 8px 15px;
|
||||
border-radius: 99px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: .16s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.theme-toggle:hover {
|
||||
border-color: var(--orange);
|
||||
@@ -166,12 +195,72 @@ function go(path) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* —— 头像下拉菜单 —— */
|
||||
.avatar-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.avatar-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 10px);
|
||||
min-width: 158px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 13px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
z-index: 300;
|
||||
}
|
||||
.am-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 10px 13px;
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background .15s, color .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.am-item:hover {
|
||||
background: var(--panel-soft);
|
||||
color: var(--orange);
|
||||
}
|
||||
.am-item:disabled {
|
||||
color: var(--ink-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.am-item.danger {
|
||||
color: var(--red);
|
||||
}
|
||||
.am-item.danger:hover {
|
||||
background: var(--red-soft);
|
||||
color: var(--red);
|
||||
}
|
||||
.pop-enter-active,
|
||||
.pop-leave-active {
|
||||
transition: opacity .16s, transform .16s;
|
||||
}
|
||||
.pop-enter-from,
|
||||
.pop-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(.96);
|
||||
}
|
||||
|
||||
/* 平板(768~1023):压缩导航 */
|
||||
@media (max-width: 1023px) {
|
||||
.topbar { padding: 0 16px; gap: 12px; }
|
||||
.brand-sub { display: none; }
|
||||
.nav-item { padding: 8px 13px; font-size: 13px; }
|
||||
.theme-toggle span { display: none; }
|
||||
}
|
||||
|
||||
/* 手机 <768:顶栏转为 M-Topbar(☰ + 品牌) */
|
||||
|
||||
@@ -8,7 +8,8 @@ const tabs = [
|
||||
{ path: '/', label: '概览', emo: '🏠' },
|
||||
{ path: '/search', label: '检索', emo: '🔍' },
|
||||
{ path: '/thresholds', label: '阈值', emo: '⚙️' },
|
||||
{ path: '/logs', label: '动态', emo: '🕒' },
|
||||
{ path: '/logs', label: '操作日志', emo: '🕒' },
|
||||
{ path: '/ignored', label: '已忽略', emo: '🚫' },
|
||||
]
|
||||
|
||||
function go(path) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import StatusTag from './StatusTag.vue'
|
||||
import { iconOf } from '../mock/data.js'
|
||||
import PkgLogo from './PkgLogo.vue'
|
||||
|
||||
defineProps({
|
||||
pkg: { type: Object, required: true },
|
||||
@@ -22,7 +22,7 @@ function splitName(name) {
|
||||
@click="emit('open', pkg)"
|
||||
>
|
||||
<div class="p-top">
|
||||
<div class="pkg-ico">{{ iconOf(pkg.name) }}</div>
|
||||
<PkgLogo :name="pkg.name" :size="44" />
|
||||
<div style="flex: 1; min-width: 0">
|
||||
<div class="pkg-name">
|
||||
<span class="nm">
|
||||
@@ -87,17 +87,6 @@ function splitName(name) {
|
||||
.pkg-card.ignored { background: var(--ignored-card); opacity: .82; }
|
||||
.pkg-card.broken { border-style: dashed; }
|
||||
.p-top { display: flex; align-items: center; gap: 12px; }
|
||||
.pkg-ico {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 20px;
|
||||
background: var(--grad-pkg-ico);
|
||||
box-shadow: inset 0 0 0 1.5px rgba(255, 140, 0, .18);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pkg-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import StatusTag from './StatusTag.vue'
|
||||
import { iconOf } from '../mock/data.js'
|
||||
import PkgLogo from './PkgLogo.vue'
|
||||
import api from '../api'
|
||||
|
||||
const props = defineProps({
|
||||
visible: Boolean,
|
||||
@@ -9,6 +11,38 @@ const props = defineProps({
|
||||
})
|
||||
const emit = defineEmits(['update:visible', 'clean', 'ignore', 'unignore', 'delete-version'])
|
||||
|
||||
// 版本明细按需加载:列表接口不含明细,打开抽屉时异步获取
|
||||
const loadingVer = ref(false)
|
||||
const verFail = ref(false)
|
||||
const detailPkg = ref(null)
|
||||
const verList = computed(() => detailPkg.value?.versionsDetail || props.pkg?.versionsDetail || [])
|
||||
|
||||
// 包信息(介绍/主页/仓库/许可/作者):列表接口不含,来自详情接口或列表已有字段
|
||||
const desc = computed(() => detailPkg.value?.description || props.pkg?.description || '')
|
||||
const homepage = computed(() => detailPkg.value?.homepage || props.pkg?.homepage || '')
|
||||
const repo = computed(() => detailPkg.value?.repository || props.pkg?.repository || '')
|
||||
const license = computed(() => detailPkg.value?.license || props.pkg?.license || '')
|
||||
const author = computed(() => detailPkg.value?.author || props.pkg?.author || '')
|
||||
const hasMeta = computed(() => homepage.value || repo.value || license.value || author.value)
|
||||
const repoUrl = computed(() => repo.value.replace(/^git\+/, '').replace(/\.git$/, ''))
|
||||
|
||||
watch(() => props.pkg, async (p) => {
|
||||
verFail.value = false
|
||||
detailPkg.value = null
|
||||
if (!p) { loadingVer.value = false; return }
|
||||
if (Array.isArray(p.versionsDetail) && p.versionsDetail.length) { loadingVer.value = false; return }
|
||||
loadingVer.value = true
|
||||
try {
|
||||
const d = await api.getPackageDetail(p.name)
|
||||
if (d.ok && d.pkg) detailPkg.value = d.pkg
|
||||
else verFail.value = true
|
||||
} catch {
|
||||
verFail.value = true
|
||||
} finally {
|
||||
loadingVer.value = false
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function splitName(name) {
|
||||
if (!name) return { scope: '', base: '' }
|
||||
const i = name.lastIndexOf('/')
|
||||
@@ -34,15 +68,15 @@ function delVersion(v) {
|
||||
<aside v-if="visible && pkg" class="drawer">
|
||||
<div class="drawer-head">
|
||||
<div class="drawer-title">
|
||||
<div class="pkg-ico">{{ iconOf(pkg.name) }}</div>
|
||||
<div>
|
||||
<PkgLogo :name="pkg.name" :size="46" :homepage="homepage" />
|
||||
<div style="min-width: 0">
|
||||
<div style="display:flex;align-items:center;gap:9px;flex-wrap:wrap">
|
||||
<span style="font-size:17px;font-weight:700">
|
||||
<span style="font-size:17px;font-weight:700;letter-spacing:-.2px">
|
||||
<span v-if="splitName(pkg.name).scope" class="scope">{{ splitName(pkg.name).scope }}</span>{{ splitName(pkg.name).base }}
|
||||
</span>
|
||||
<StatusTag :status="pkg.status" />
|
||||
</div>
|
||||
<div style="font-size:11.5px;color:var(--ink-faint);margin-top:2px">{{ pkg.name }}</div>
|
||||
<p v-if="desc" class="drawer-desc" :title="desc">{{ desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drawer-close" @click="emit('update:visible', false)">✕</div>
|
||||
@@ -68,17 +102,30 @@ function delVersion(v) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasMeta" class="d-meta">
|
||||
<a v-if="homepage" class="d-meta-item" :href="homepage" target="_blank" rel="noopener">🔗 主页</a>
|
||||
<a v-if="repoUrl" class="d-meta-item" :href="repoUrl" target="_blank" rel="noopener">🛠 仓库</a>
|
||||
<span v-if="license" class="d-meta-item lic">📜 {{ license }}</span>
|
||||
<span v-if="author" class="d-meta-item lic" :title="author">👤 {{ author }}</span>
|
||||
</div>
|
||||
|
||||
<div class="d-sub">
|
||||
版本列表
|
||||
<span class="cnt">{{ pkg.versionsDetail?.length || pkg.versions }} 个版本</span>
|
||||
<span class="cnt">{{ verList.length || pkg.versions }} 个版本</span>
|
||||
<span v-if="pkg.status === 'warn'" class="excess">超阈值 {{ pkg.versions - threshold }} 个</span>
|
||||
</div>
|
||||
|
||||
<div class="ver-list">
|
||||
<div v-if="!pkg.versionsDetail?.length" class="empty-tip">
|
||||
{{ pkg.status === 'broken' ? '元数据异常,无法读取版本列表' : '暂无版本信息' }}
|
||||
<div v-if="loadingVer" class="empty-tip load">
|
||||
<span class="spin"></span>正在加载版本信息…
|
||||
</div>
|
||||
<div v-for="v in pkg.versionsDetail" :key="v.version" class="ver-row">
|
||||
<div v-else-if="verFail" class="empty-tip">
|
||||
🗂️ 版本信息加载失败,请稍后重试
|
||||
</div>
|
||||
<div v-else-if="!verList.length" class="empty-tip">
|
||||
{{ pkg.status === 'broken' ? '⚠️ 元数据异常,无法读取版本列表' : '📭 暂无版本信息' }}
|
||||
</div>
|
||||
<div v-for="v in verList" :key="v.version" class="ver-row">
|
||||
<span class="ver-no">{{ v.version }}</span>
|
||||
<span class="ver-size">{{ v.size }} MB</span>
|
||||
<span class="ver-date">{{ fmtDate(v.date) }}</span>
|
||||
@@ -144,16 +191,38 @@ export default {
|
||||
}
|
||||
.drawer-title { display: flex; align-items: center; gap: 12px; }
|
||||
.drawer-title .scope { color: var(--ink-faint); font-weight: 500; }
|
||||
.pkg-ico {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 13px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 19px;
|
||||
background: var(--grad-pkg-ico);
|
||||
flex-shrink: 0;
|
||||
.drawer-desc {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-dim);
|
||||
margin: 6px 0 0;
|
||||
line-height: 1.55;
|
||||
max-width: 430px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.d-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.d-meta-item {
|
||||
font-size: 12px;
|
||||
color: var(--blue);
|
||||
background: var(--blue-soft);
|
||||
padding: 5px 12px;
|
||||
border-radius: 99px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.d-meta-item:hover { filter: brightness(.94); }
|
||||
.d-meta-item.lic { color: var(--ink-dim); background: var(--num-bg); cursor: default; }
|
||||
.drawer-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -219,6 +288,22 @@ export default {
|
||||
color: var(--ink-faint);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.empty-tip.load {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
}
|
||||
.spin {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--line);
|
||||
border-top-color: var(--orange);
|
||||
animation: spin360 .8s linear infinite;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ver-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup>
|
||||
// 包 logo:优先取主页域名的 favicon(浏览器端加载,离线/加载失败回退首字母徽标)
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, default: '' },
|
||||
size: { type: Number, default: 36 },
|
||||
homepage: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const letter = computed(() => {
|
||||
const base = (props.name.split('/').pop() || props.name || '?').trim()
|
||||
return (base[0] || '?').toUpperCase()
|
||||
})
|
||||
|
||||
const style = computed(() => {
|
||||
let h = 0
|
||||
const s = props.name || 'pkg'
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360
|
||||
const h2 = (h + 45) % 360
|
||||
return {
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
fontSize: `${Math.round(props.size * 0.46)}px`,
|
||||
borderRadius: `${Math.round(props.size * 0.3)}px`,
|
||||
background: `linear-gradient(135deg, hsl(${h}, 62%, 52%), hsl(${h2}, 68%, 42%))`,
|
||||
}
|
||||
})
|
||||
|
||||
// favicon 来源:主页域名根路径 /favicon.ico(不依赖第三方服务,站点没有时回退徽标)
|
||||
const imgOk = ref(false)
|
||||
const faviconUrl = computed(() => {
|
||||
if (!props.homepage) return ''
|
||||
try {
|
||||
return new URL(props.homepage).origin + '/favicon.ico'
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
watch(faviconUrl, () => { imgOk.value = false })
|
||||
function onImgOk() { imgOk.value = true }
|
||||
function onImgErr() { imgOk.value = false }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="pkg-logo" :style="style" :title="name">
|
||||
<!-- img 始终挂载以触发 load/error;加载成功后显示,失败回退字母徽标 -->
|
||||
<img v-if="faviconUrl" class="fav" :class="{ on: imgOk }" :src="faviconUrl" alt="" @load="onImgOk" @error="onImgErr" />
|
||||
<span v-show="!imgOk" class="lt">{{ letter }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pkg-logo {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-family: var(--num);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .28);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, .28);
|
||||
overflow: hidden;
|
||||
}
|
||||
.lt { grid-area: 1 / 1; }
|
||||
.fav {
|
||||
grid-area: 1 / 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
opacity: 0;
|
||||
}
|
||||
.fav.on { opacity: 1; }
|
||||
</style>
|
||||
@@ -75,4 +75,15 @@ const emit = defineEmits(['link'])
|
||||
.stat2.c-orange .s2-val { color: var(--orange); }
|
||||
.stat2.c-purple .s2-val { color: var(--purple); }
|
||||
.stat2.c-pink .s2-val { color: var(--pink); }
|
||||
|
||||
/* —— 移动端:压缩 padding 和字号,防止窄屏溢出 —— */
|
||||
@media (max-width: 767px) {
|
||||
.stat2 { padding: 11px 13px; }
|
||||
.s2-top { font-size: 11.5px; gap: 6px; }
|
||||
.ico2 { font-size: 14px; }
|
||||
.s2-val { font-size: 17px; margin-top: 6px; }
|
||||
.s2-val .u { font-size: 10.5px; }
|
||||
.s2-sub { font-size: 10px; margin-top: 2px; }
|
||||
.link { font-size: 10.5px; padding: 3px 9px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup>
|
||||
// 排行 Top 30:有访问统计按热度,否则降级为缓存占用(真实环境无访问统计)
|
||||
import PkgLogo from './PkgLogo.vue'
|
||||
const props = defineProps({
|
||||
top30: { type: Array, default: () => [] },
|
||||
top30By: { type: String, default: 'visits' },
|
||||
})
|
||||
const emit = defineEmits(['open'])
|
||||
const isVisits = props.top30By === 'visits'
|
||||
function fmtSize(mb) {
|
||||
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
|
||||
@@ -14,7 +16,7 @@ function fmtSize(mb) {
|
||||
<template>
|
||||
<div class="top30">
|
||||
<div class="top30-head">
|
||||
<div class="t"><span class="ico">🏆</span>{{ isVisits ? '访问次数 Top 30' : '缓存占用 Top 30' }}<span class="tag30">{{ isVisits ? '按热度排序' : '按占用空间排序' }}</span></div>
|
||||
<div class="t"><span class="ico">🏆</span>{{ isVisits ? '访问次数 Top 20' : '缓存占用 Top 20' }}<span class="tag30">{{ isVisits ? '按热度排序' : '按占用空间排序' }}</span></div>
|
||||
</div>
|
||||
<div class="tbl-scroll">
|
||||
<table class="rank-tbl">
|
||||
@@ -23,25 +25,25 @@ function fmtSize(mb) {
|
||||
<th style="width: 70px">排名</th>
|
||||
<th>包名</th>
|
||||
<th style="width: 130px">{{ isVisits ? '访问次数' : '占用空间' }}</th>
|
||||
<th>热度</th>
|
||||
<th v-if="isVisits">热度</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(p, i) in top30" :key="p.name">
|
||||
<tr v-for="(p, i) in top30" :key="p.name" class="rank-row" @click="emit('open', p)">
|
||||
<td>
|
||||
<span class="rank-no" :class="i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : ''">{{ i + 1 }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="rank-pkg">
|
||||
<span class="mini-ico">{{ p.emoji || '📦' }}</span>
|
||||
<PkgLogo :name="p.name" :size="30" />
|
||||
<span class="nm">
|
||||
<span v-if="p.name.includes('/')" class="scope">{{ p.name.slice(0, p.name.lastIndexOf('/') + 1) }}</span>{{ p.name.split('/').pop() }}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td><span class="rank-visit">{{ isVisits ? (p.visits || 0).toLocaleString() : fmtSize(p.size) }}</span></td>
|
||||
<td>
|
||||
<div class="heat">
|
||||
<td v-if="isVisits">
|
||||
<div class="heat" title="访问热度占比">
|
||||
<div class="heat-bar"><i :style="{ width: p.heat + '%' }"></i></div>
|
||||
<span class="heat-pct">{{ p.heat }}%</span>
|
||||
</div>
|
||||
@@ -103,6 +105,7 @@ function fmtSize(mb) {
|
||||
.rank-tbl tr:last-child td { border-bottom: none; }
|
||||
.rank-tbl tr { transition: background .15s; }
|
||||
.rank-tbl tr:hover { background: var(--bg); }
|
||||
.rank-tbl .rank-row { cursor: pointer; }
|
||||
.rank-no {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
@@ -119,16 +122,6 @@ function fmtSize(mb) {
|
||||
.rank-no.silver { background: linear-gradient(135deg, #E8E8EC, #C9C9CF); color: #5A5A62; box-shadow: 0 4px 10px -3px rgba(200, 200, 210, .5); }
|
||||
.rank-no.bronze { background: linear-gradient(135deg, #F2C9A0, #D9A066); color: #6E4520; box-shadow: 0 4px 10px -3px rgba(217, 160, 102, .5); }
|
||||
.rank-pkg { display: flex; align-items: center; gap: 10px; font-weight: 600; }
|
||||
.mini-ico {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
background: var(--orange-soft);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.scope { color: var(--ink-faint); font-weight: 500; }
|
||||
.rank-visit { font-family: var(--num); font-weight: 700; font-size: 14.5px; color: var(--ink); }
|
||||
@@ -143,4 +136,19 @@ function fmtSize(mb) {
|
||||
transform-origin: left;
|
||||
}
|
||||
.heat-pct { font-family: var(--num); font-size: 11.5px; color: var(--ink-faint); font-weight: 600; width: 34px; text-align: right; }
|
||||
|
||||
/* —— 移动端:隐藏「热度」列,缩小 padding —— */
|
||||
@media (max-width: 767px) {
|
||||
.top30-head { padding: 12px 14px; }
|
||||
.t { font-size: 13px; gap: 8px; }
|
||||
.t .ico { font-size: 16px; }
|
||||
.tag30 { font-size: 10px; padding: 3px 9px; }
|
||||
.rank-tbl { min-width: 320px; }
|
||||
.rank-tbl th, .rank-tbl td { padding: 9px 14px; font-size: 12px; }
|
||||
.rank-tbl th:nth-child(4), .rank-tbl td:nth-child(4) { display: none; } /* 隐藏热度列 */
|
||||
.rank-no { width: 26px; height: 26px; font-size: 12px; border-radius: 8px; }
|
||||
.rank-pkg { gap: 7px; font-size: 12.5px; }
|
||||
.rank-visit { font-size: 13px; }
|
||||
.heat { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const THEME_KEY = 'vcm-theme'
|
||||
const theme = ref(document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'bright')
|
||||
const saved = localStorage.getItem(THEME_KEY)
|
||||
const theme = ref(saved === 'dark' ? 'dark' : 'bright')
|
||||
|
||||
function apply(t) {
|
||||
document.documentElement.setAttribute('data-theme', t)
|
||||
localStorage.setItem(THEME_KEY, t)
|
||||
}
|
||||
|
||||
// 初始化即同步 DOM,确保首次点击时 data-theme 属性与状态一致
|
||||
if (document.documentElement.getAttribute('data-theme') !== theme.value) {
|
||||
document.documentElement.setAttribute('data-theme', theme.value)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
function toggle() {
|
||||
theme.value = theme.value === 'dark' ? 'bright' : 'dark'
|
||||
|
||||
@@ -16,15 +16,6 @@ const PKG_POOL = [
|
||||
'xlsx', 'csv-parse', 'js-yaml', 'fast-glob', 'execa', 'nanoid', 'clsx',
|
||||
]
|
||||
|
||||
const ICON_MAP = {
|
||||
lodash: '📚', axios: '🔌', react: '⚛️', vue: '💚', express: '🚀', webpack: '🧱',
|
||||
typescript: '📘', eslint: '🧹', '@antv/g2': '📊', '@antv/g6': '🕸️', tensorflow: '🧠',
|
||||
sharp: '🖼️', puppeteer: '🤖', ioredis: '🔴', pg: '🐘', three: '🎮', d3: '📉',
|
||||
echarts: '📈', xlsx: '📑', 'socket.io': '🔌', prisma: '💎', graphql: '🔗',
|
||||
dayjs: '📅', moment: '⏰', uuid: '🆔', zod: '🛡️', vite: '⚡', rollup: '🌀',
|
||||
jest: '🃏', mocha: '☕', prettier: '🎀', semver: '🔢', chalk: '🖍️',
|
||||
}
|
||||
|
||||
const SCOPED = new Set(['@antv', '@ant-design', '@vue', '@vueuse', '@babel', '@types', '@element-plus', '@testing-library'])
|
||||
|
||||
function pad(n) {
|
||||
@@ -65,13 +56,6 @@ function genVersions(name, count, baseVersion) {
|
||||
return list
|
||||
}
|
||||
|
||||
function iconOf(name) {
|
||||
if (ICON_MAP[name]) return ICON_MAP[name]
|
||||
const base = name.split('/').pop() || name
|
||||
if (ICON_MAP[base]) return ICON_MAP[base]
|
||||
return '📦'
|
||||
}
|
||||
|
||||
function genPackages() {
|
||||
const threshold = 15
|
||||
const list = []
|
||||
@@ -152,7 +136,7 @@ const packages = genPackages()
|
||||
const top30 = [...packages]
|
||||
.filter(p => p.status !== 'broken')
|
||||
.sort((a, b) => b.visits - a.visits)
|
||||
.slice(0, 30)
|
||||
.slice(0, 20)
|
||||
.map(p => ({ ...p, heat: Math.round((p.visits / topMax(packages)) * 100) }))
|
||||
|
||||
function topMax(list) {
|
||||
@@ -189,4 +173,4 @@ function summary() {
|
||||
}
|
||||
}
|
||||
|
||||
export { packages, top30, logs, summary, fmtSize, iconOf }
|
||||
export { packages, top30, logs, summary, fmtSize }
|
||||
|
||||
+66
-11
@@ -14,30 +14,42 @@ const state = reactive({
|
||||
loading: false,
|
||||
scannedAt: null,
|
||||
storageDir: localStorage.getItem('vcm-storage-dir') || '',
|
||||
// 后端目录扫描总字节(所有文件,与 du 同口径;null 表示未知/演示模式)
|
||||
totalBytes: null,
|
||||
// 扫描进度(异步扫描期间实时更新,用于遮罩展示)
|
||||
scanProgress: { packages: 0, bytes: 0 },
|
||||
})
|
||||
|
||||
// —— 派生统计 ——
|
||||
const stats = computed(() => {
|
||||
const valid = state.packages.filter(p => p.status !== 'broken')
|
||||
const totalSize = valid.reduce((s, p) => s + p.size, 0)
|
||||
// 总占用统计全部物理缓存(含元数据异常的包,其大小按实际 .tgz 计算)
|
||||
const totalSize = state.packages.reduce((s, p) => s + p.size, 0)
|
||||
const warnCount = valid.filter(p => p.status === 'warn').length
|
||||
const cleanable = valid.filter(p => p.status === 'warn').reduce((s, p) => s + p.size, 0)
|
||||
const maxPkg = [...valid].sort((a, b) => b.size - a.size)[0] || null
|
||||
const oldestPkg = [...valid].sort((a, b) => (a.lastUpdated || '').localeCompare(b.lastUpdated || ''))[0] || null
|
||||
// 真实环境无访问统计(visits 全 0)时,Top30 降级为「缓存占用 Top 30」
|
||||
// 今日更新包数:最近更新时间等于今天的包数量
|
||||
const d = new Date()
|
||||
const todayStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
const todayUpdated = valid.filter(p => (p.lastUpdated || '') === todayStr).length
|
||||
// 真实环境无访问统计(visits 全 0)时,Top20 降级为「缓存占用 Top 20」
|
||||
const hasVisits = valid.some(p => (p.visits || 0) > 0)
|
||||
const top30 = [...valid]
|
||||
.sort((a, b) => (hasVisits ? (b.visits || 0) - (a.visits || 0) : b.size - a.size))
|
||||
.slice(0, 30)
|
||||
.slice(0, 20)
|
||||
const maxVisit = Math.max(...top30.map(p => p.visits || 0), 1)
|
||||
const maxSize = Math.max(...top30.map(p => p.size), 1)
|
||||
return {
|
||||
pkgCount: valid.length,
|
||||
totalVersions: state.packages.reduce((s, p) => s + (p.versions || 0), 0),
|
||||
totalSize,
|
||||
totalSizeText: fmtSize(totalSize),
|
||||
// 总占用优先用后端「目录所有文件字节和」(与 du 一致),否则回退到包大小之和
|
||||
totalSizeText: state.totalBytes != null ? fmtBytes(state.totalBytes) : fmtSize(totalSize),
|
||||
warnCount,
|
||||
cleanable,
|
||||
cleanableText: fmtSize(cleanable),
|
||||
todayUpdated,
|
||||
maxPkg,
|
||||
oldestPkg,
|
||||
top30: top30.map(p => ({
|
||||
@@ -55,19 +67,62 @@ function fmtSize(mb) {
|
||||
return `${mb.toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/** 字节 → 可读文本 */
|
||||
function fmtBytes(b) {
|
||||
const mb = b / 1024 / 1024
|
||||
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
|
||||
return `${mb.toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const fmtSizeFn = fmtSize
|
||||
|
||||
async function refreshPackages(keyword = '') {
|
||||
const { packages, scannedAt } = await api.getPackages(keyword)
|
||||
state.packages = packages
|
||||
const { packages, scannedAt, scanning, scanPackages, scanBytes, totalBytes } = await api.getPackages(keyword)
|
||||
state.packages = packages || []
|
||||
if (scannedAt) state.scannedAt = scannedAt
|
||||
if (scanPackages !== undefined) state.scanProgress.packages = scanPackages
|
||||
if (scanBytes !== undefined) state.scanProgress.bytes = scanBytes
|
||||
if (totalBytes !== undefined && totalBytes !== null) state.totalBytes = totalBytes
|
||||
return { scanning: !!scanning }
|
||||
}
|
||||
|
||||
/** 手动重新扫描缓存目录 */
|
||||
/** 轮询后端扫描进度,直到扫描完成 */
|
||||
async function pollScanProgress() {
|
||||
for (;;) {
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
let p
|
||||
try {
|
||||
p = await api.getScanProgress()
|
||||
} catch {
|
||||
break // 接口异常时退出,避免遮罩永久拦截点击
|
||||
}
|
||||
state.scanProgress.packages = p.packages || 0
|
||||
state.scanProgress.bytes = p.bytes || 0
|
||||
if (!p.scanning) break
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新数据:若后端仍在扫描则先轮询进度,扫完再拉取真实数据 */
|
||||
async function doRefresh() {
|
||||
const first = await refreshPackages()
|
||||
if (first.scanning) {
|
||||
await pollScanProgress()
|
||||
await refreshPackages() // 扫描完成后重新拉取完整数据
|
||||
}
|
||||
await Promise.all([refreshThresholds(), refreshLogs()])
|
||||
}
|
||||
|
||||
/** 手动重新扫描缓存目录(异步,遮罩实时显示进度) */
|
||||
async function scanNow() {
|
||||
const res = await api.scanNow()
|
||||
if (res.ok) await refreshAll()
|
||||
return res
|
||||
state.loading = true
|
||||
try {
|
||||
const res = await api.scanNow()
|
||||
if (!res.ok) return res
|
||||
await doRefresh()
|
||||
return res
|
||||
} finally {
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshThresholds() {
|
||||
@@ -84,7 +139,7 @@ async function refreshLogs() {
|
||||
async function refreshAll() {
|
||||
state.loading = true
|
||||
try {
|
||||
await Promise.all([refreshPackages(), refreshThresholds(), refreshLogs()])
|
||||
await doRefresh()
|
||||
} finally {
|
||||
state.loading = false
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ html[data-theme="dark"] .ambient::after {
|
||||
.vtable tbody tr { cursor: pointer; transition: background .13s; }
|
||||
.vtable tbody tr:hover { background: var(--bg); }
|
||||
.num-cell { font-family: var(--num); font-weight: 600; font-size: 14px; }
|
||||
.date-cell { font-family: var(--num); font-size: 12.5px; color: var(--ink-dim); }
|
||||
.date-cell { font-family: var(--num); font-size: 12.5px; color: var(--ink-dim); white-space: nowrap; }
|
||||
|
||||
/* —— 滚动条 —— */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
|
||||
@@ -8,35 +8,15 @@ import PackageCard from '../components/PackageCard.vue'
|
||||
import PackageDrawer from '../components/PackageDrawer.vue'
|
||||
import CleanDialog from '../components/CleanDialog.vue'
|
||||
import { useStore } from '../store'
|
||||
import { iconOf } from '../mock/data.js'
|
||||
|
||||
const router = useRouter()
|
||||
const { state, stats, refreshAll, ignorePkg, unignorePkg, cleanPkg, scanNow } = useStore()
|
||||
const { state, stats, refreshAll, ignorePkg, unignorePkg, cleanPkg } = useStore()
|
||||
|
||||
const drawerPkg = ref(null)
|
||||
const drawerVisible = ref(false)
|
||||
const cleanVisible = ref(false)
|
||||
const cleanPkgData = ref(null)
|
||||
const showTop30 = ref(true)
|
||||
const scanning = ref(false)
|
||||
|
||||
// 本次数据基于何时扫描(后端扫描时间,缓存复用)
|
||||
const fmtScanTime = computed(() => {
|
||||
const t = state.scannedAt
|
||||
if (!t) return ''
|
||||
const d = new Date(t)
|
||||
const p = n => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
||||
})
|
||||
|
||||
async function rescan() {
|
||||
scanning.value = true
|
||||
try {
|
||||
await scanNow()
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const warningExcess = computed(() =>
|
||||
stats.value.warnCount
|
||||
@@ -46,16 +26,15 @@ const warningExcess = computed(() =>
|
||||
: 0,
|
||||
)
|
||||
|
||||
const top30WithIcon = computed(() =>
|
||||
stats.value.top30.map(p => ({ ...p, emoji: iconOf(p.name) })),
|
||||
)
|
||||
const top30WithIcon = computed(() => stats.value.top30)
|
||||
|
||||
// 概览只展示需关注项(预警 + 异常),完整列表去检索页
|
||||
const attentionPackages = computed(() =>
|
||||
state.packages.filter(p => p.status === 'warn' || p.status === 'broken'),
|
||||
)
|
||||
|
||||
async function openDrawer(pkg) {
|
||||
function openDrawer(pkg) {
|
||||
// 版本明细由 PackageDrawer 打开后按需加载
|
||||
drawerPkg.value = pkg
|
||||
drawerVisible.value = true
|
||||
}
|
||||
@@ -105,25 +84,21 @@ function quickClean() {
|
||||
|
||||
<!-- 第二行:辅助指标 5 -->
|
||||
<div class="stats-2">
|
||||
<StatMini tone="blue" label="访问排行" ico="🏆" main="Top 30" :sub="top30WithIcon.slice(0, 3).map(p => p.name).join(' · ')" link="展开" @link="showTop30 = true" />
|
||||
<StatMini tone="green" label="今日下载量" ico="📈" main="1,234" sub="较昨日 +18%" />
|
||||
<StatMini tone="orange" label="最大包" ico="🐘" :main="stats.maxPkg?.name || '—'" :unit="stats.maxPkg ? stats.maxPkg.size + ' MB' : ''" :sub="`${stats.maxPkg?.versions || 0} 个版本`" @link="stats.maxPkg && openDrawer(stats.maxPkg)" />
|
||||
<StatMini tone="purple" label="最老包时间" ico="⏳" :main="stats.oldestPkg?.lastUpdated || '—'" unit="⏰" :sub="stats.oldestPkg ? `${stats.oldestPkg.name} · 未更新` : ''" @link="stats.oldestPkg && openDrawer(stats.oldestPkg)" />
|
||||
<StatMini tone="pink" label="版本数分布" ico="🧩" main=">15 版本" :unit="`${stats.warnCount} 个`" :sub="`超阈值共 ${warningExcess} 个版本`" />
|
||||
<StatMini tone="blue" label="今日更新包数" ico="🆕" :main="String(stats.todayUpdated)" sub="今日有版本更新" link="查看" @link="router.push('/search')" />
|
||||
<StatMini tone="green" label="缓存版本总数" ico="🧩" :main="stats.totalVersions.toLocaleString()" sub="全部缓存包的版本数" link="查看" @link="router.push('/search')" />
|
||||
<StatMini tone="orange" label="最大包" ico="🐘" :main="stats.maxPkg?.name || '—'" :unit="stats.maxPkg ? stats.maxPkg.size + ' MB' : ''" :sub="`${stats.maxPkg?.versions || 0} 个版本`" link="详情" @link="stats.maxPkg && openDrawer(stats.maxPkg)" />
|
||||
<StatMini tone="purple" label="最老包时间" ico="⏳" :main="stats.oldestPkg?.lastUpdated || '—'" unit="⏰" :sub="stats.oldestPkg ? `${stats.oldestPkg.name} · 未更新` : ''" link="详情" @link="stats.oldestPkg && openDrawer(stats.oldestPkg)" />
|
||||
<StatMini tone="pink" label="版本数分布" ico="🧩" main=">15 版本" :unit="`${stats.warnCount} 个`" :sub="`超阈值共 ${warningExcess} 个版本`" link="查看" @link="router.push({ path: '/search', query: { status: 'warn' } })" />
|
||||
</div>
|
||||
|
||||
<!-- Top 30 排行(可收起) -->
|
||||
<Top30Panel v-if="showTop30" :top30="top30WithIcon" :top30-by="stats.top30By" />
|
||||
<!-- Top 20 排行(可收起,点击行打开详情) -->
|
||||
<Top30Panel v-if="showTop30" :top30="top30WithIcon" :top30-by="stats.top30By" @open="openDrawer" />
|
||||
|
||||
<!-- 包卡片列表:仅需关注项 -->
|
||||
<div class="sec-head">
|
||||
<span class="sec-title"><span class="emo">⚠️</span>需要关注</span>
|
||||
<span class="sec-sub">共 {{ attentionPackages.length }} 个需关注 · 预警置顶</span>
|
||||
<span class="sec-actions">
|
||||
<span v-if="state.scannedAt" class="scan-stamp">🕐 基于 {{ fmtScanTime }} 扫描</span>
|
||||
<button class="btn ghost mini" :disabled="scanning" @click="rescan">{{ scanning ? '⏳ 扫描中…' : '🔄 重新扫描' }}</button>
|
||||
<button class="link-all" @click="router.push('/search')">查看全部 →</button>
|
||||
</span>
|
||||
<button class="link-all" @click="router.push('/search')">查看全部 →</button>
|
||||
</div>
|
||||
|
||||
<div v-if="state.loading" class="loading-tip">⏳ 加载中…</div>
|
||||
@@ -174,9 +149,6 @@ function quickClean() {
|
||||
.sec-title { font-size: 17px; font-weight: 700; display: flex; align-items: center; gap: 10px; }
|
||||
.sec-title .emo { font-size: 19px; }
|
||||
.sec-sub { font-family: var(--num); font-size: 12px; color: var(--ink-faint); font-weight: 600; }
|
||||
.sec-actions { display: flex; align-items: center; gap: 10px; margin-left: auto; }
|
||||
.scan-stamp { font-family: var(--num); font-size: 11.5px; color: var(--ink-faint); font-weight: 600; }
|
||||
.btn.mini { padding: 6px 13px; font-size: 12.5px; border-radius: 999px; }
|
||||
.link-all { padding: 6px 14px; border: none; border-radius: 999px; background: var(--panel-soft); color: var(--orange); font-size: 13px; font-weight: 600; cursor: pointer; transition: transform .2s, box-shadow .2s; }
|
||||
.link-all:hover { transform: translateY(-1px); box-shadow: var(--shadow); }
|
||||
.pkg-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||||
@@ -186,13 +158,17 @@ function quickClean() {
|
||||
@media (max-width: 1180px) {
|
||||
.stats-1 { grid-template-columns: repeat(2, 1fr); }
|
||||
.stats-2 { grid-template-columns: repeat(3, 1fr); }
|
||||
.stats-2 > :nth-child(5) { grid-column: 1 / -1; } /* 5 卡跨列铺满,避免 2+2+1 */
|
||||
.pkg-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.stats-1 { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||
.stats-2 { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||
.stats-2 > :nth-child(5) { grid-column: 1 / -1; } /* 第 5 张跨整行 */
|
||||
.pkg-grid { grid-template-columns: 1fr; gap: 12px; }
|
||||
.sec-head { padding: 4px 0 12px; gap: 8px; }
|
||||
.sec-title { font-size: 15px; }
|
||||
.sec-sub { font-size: 11px; }
|
||||
.link-all { font-size: 12px; padding: 5px 10px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import PackageDrawer from '../components/PackageDrawer.vue'
|
||||
import CleanDialog from '../components/CleanDialog.vue'
|
||||
import StatusTag from '../components/StatusTag.vue'
|
||||
import { useStore } from '../store'
|
||||
|
||||
const { state, stats, refreshPackages, ignorePkg, unignorePkg, cleanPkg, deleteVersion } = useStore()
|
||||
const route = useRoute()
|
||||
|
||||
const keyword = ref('')
|
||||
const statusFilter = ref('all') // all | warn | ok | ignored | broken
|
||||
const sortMode = ref('default') // default | versions-desc | size-desc
|
||||
let debounceTimer = null
|
||||
|
||||
onMounted(() => refreshPackages())
|
||||
onMounted(() => {
|
||||
// 数据由 App 初始化时统一加载,切换菜单不再重复拉取全量列表(响应大,避免卡顿)
|
||||
if (route.query.status && ['all', 'warn', 'ok', 'ignored', 'broken'].includes(route.query.status)) {
|
||||
statusFilter.value = route.query.status
|
||||
}
|
||||
})
|
||||
|
||||
watch(keyword, () => {
|
||||
clearTimeout(debounceTimer)
|
||||
@@ -46,6 +53,7 @@ const cleanVisible = ref(false)
|
||||
const cleanPkgData = ref(null)
|
||||
|
||||
function openDrawer(pkg) {
|
||||
// 版本明细由 PackageDrawer 打开后按需加载
|
||||
drawerPkg.value = pkg
|
||||
drawerVisible.value = true
|
||||
}
|
||||
@@ -115,9 +123,9 @@ async function onCleanConfirmed(res) {
|
||||
<th>包名</th>
|
||||
<th style="width: 90px">版本数</th>
|
||||
<th style="width: 110px">占用空间</th>
|
||||
<th style="width: 110px">最近更新</th>
|
||||
<th style="width: 110px">状态</th>
|
||||
<th style="width: 130px">操作</th>
|
||||
<th style="width: 120px">最近更新</th>
|
||||
<th style="width: 100px">状态</th>
|
||||
<th style="width: 160px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -262,6 +270,10 @@ async function onCleanConfirmed(res) {
|
||||
.result-note b { color: var(--orange); }
|
||||
.pkg-cell { font-weight: 600; word-break: break-all; }
|
||||
.num-cell.over { color: var(--red); }
|
||||
/* 操作列:按钮横排一行,不换行 */
|
||||
.vtable td:last-child { white-space: nowrap; }
|
||||
.vtable td:last-child .btn { padding: 6px 13px; font-size: 12px; }
|
||||
.vtable td:last-child .btn + .btn { margin-left: 7px; }
|
||||
.empty-tip { padding: 40px 0; text-align: center; color: var(--ink-faint); font-size: 12.5px; }
|
||||
.loading-tip { text-align: center; padding: 40px 0; color: var(--ink-faint); font-size: 13px; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user