fix: 扫描缓存与真实大小计算,解决页面加载慢与数据虚增

- scanner: 大小改为统计实际 .tgz 文件(兼容 {version}.tgz 与 {pkg}-{version}.tgz 命名),
  不再用 unpackedSize 兜底虚增数倍
- server: 扫描结果内存缓存(首次懒扫描,后续请求秒回),新增 POST /api/scan 手动重扫,
  /api/packages 返回 scannedAt 时间戳,clean/删版本后自动失效缓存
- 前端: 概览页新增「重新扫描」按钮 + 「基于 xxx 扫描」时间展示;Top30 在真实环境
  (无访问统计)降级为「缓存占用 Top 30」按大小排序
- compose: 数据卷改 named volume,修复非 root 容器 SQLITE_CANTOPEN
This commit is contained in:
陈银军
2026-09-19 17:57:58 +08:00
parent 72722ab6bf
commit f84f21574f
8 changed files with 147 additions and 41 deletions
+13 -3
View File
@@ -67,11 +67,21 @@ 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 sortPackages(list)
return { packages: sortPackages(list), scannedAt: new Date().toISOString() }
}
const r = await fetch(`/api/packages?keyword=${encodeURIComponent(keyword)}`)
const { packages } = await r.json()
return packages
const data = await r.json()
return { packages: data.packages, scannedAt: data.scannedAt || null }
},
/** 手动触发后端重新扫描缓存目录 */
async scanNow() {
if (USE_MOCK) {
await delay(300)
return { ok: true, scannedAt: new Date().toISOString() }
}
const r = await fetch('/api/scan', { method: 'POST' })
return r.json()
},
async getThresholds() {
+13 -5
View File
@@ -1,12 +1,20 @@
<script setup>
// 访问次数 Top 30 排行
defineProps({ top30: { type: Array, default: () => [] } })
// 排行 Top 30:有访问统计按热度,否则降级为缓存占用(真实环境无访问统计)
const props = defineProps({
top30: { type: Array, default: () => [] },
top30By: { type: String, default: 'visits' },
})
const isVisits = props.top30By === 'visits'
function fmtSize(mb) {
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
return `${mb.toFixed(1)} MB`
}
</script>
<template>
<div class="top30">
<div class="top30-head">
<div class="t"><span class="ico">🏆</span>访问次数 Top 30<span class="tag30">按热度排序</span></div>
<div class="t"><span class="ico">🏆</span>{{ isVisits ? '访问次数 Top 30' : '缓存占用 Top 30' }}<span class="tag30">{{ isVisits ? '按热度排序' : '按占用空间排序' }}</span></div>
</div>
<div class="tbl-scroll">
<table class="rank-tbl">
@@ -14,7 +22,7 @@ defineProps({ top30: { type: Array, default: () => [] } })
<tr>
<th style="width: 70px">排名</th>
<th>包名</th>
<th style="width: 110px">访问次数</th>
<th style="width: 130px">{{ isVisits ? '访问次数' : '占用空间' }}</th>
<th>热度</th>
</tr>
</thead>
@@ -31,7 +39,7 @@ defineProps({ top30: { type: Array, default: () => [] } })
</span>
</span>
</td>
<td><span class="rank-visit">{{ (p.visits || 0).toLocaleString() }}</span></td>
<td><span class="rank-visit">{{ isVisits ? (p.visits || 0).toLocaleString() : fmtSize(p.size) }}</span></td>
<td>
<div class="heat">
<div class="heat-bar"><i :style="{ width: p.heat + '%' }"></i></div>
+25 -3
View File
@@ -12,6 +12,7 @@ const state = reactive({
threshold: { versionThreshold: 15, sizeThreshold: 500, exceptions: [] },
logs: [],
loading: false,
scannedAt: null,
storageDir: localStorage.getItem('vcm-storage-dir') || '',
})
@@ -23,8 +24,13 @@ const stats = computed(() => {
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
const top30 = [...valid].sort((a, b) => (b.visits || 0) - (a.visits || 0)).slice(0, 30)
// 真实环境无访问统计(visits 全 0)时,Top30 降级为「缓存占用 Top 30」
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)
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,
totalSize,
@@ -34,7 +40,13 @@ const stats = computed(() => {
cleanableText: fmtSize(cleanable),
maxPkg,
oldestPkg,
top30: top30.map(p => ({ ...p, heat: Math.round(((p.visits || 0) / maxVisit) * 100) })),
top30: top30.map(p => ({
...p,
heat: hasVisits
? Math.round(((p.visits || 0) / maxVisit) * 100)
: Math.round((p.size / maxSize) * 100),
})),
top30By: hasVisits ? 'visits' : 'size',
}
})
@@ -46,7 +58,16 @@ function fmtSize(mb) {
const fmtSizeFn = fmtSize
async function refreshPackages(keyword = '') {
state.packages = await api.getPackages(keyword)
const { packages, scannedAt } = await api.getPackages(keyword)
state.packages = packages
if (scannedAt) state.scannedAt = scannedAt
}
/** 手动重新扫描缓存目录 */
async function scanNow() {
const res = await api.scanNow()
if (res.ok) await refreshAll()
return res
}
async function refreshThresholds() {
@@ -113,6 +134,7 @@ export function useStore() {
refreshPackages,
refreshThresholds,
refreshLogs,
scanNow,
saveThresholds,
ignorePkg,
unignorePkg,
+1
View File
@@ -111,6 +111,7 @@ html[data-theme="dark"] .ambient::after {
user-select: none;
}
.btn:active { transform: scale(.95); }
.btn:disabled { background: var(--num-bg); color: var(--ink-faint); cursor: not-allowed; transform: none; box-shadow: none; }
.btn.clean {
background: var(--grad-main);
color: #fff;
+29 -3
View File
@@ -11,13 +11,32 @@ import { useStore } from '../store'
import { iconOf } from '../mock/data.js'
const router = useRouter()
const { state, stats, refreshAll, ignorePkg, unignorePkg, cleanPkg } = useStore()
const { state, stats, refreshAll, ignorePkg, unignorePkg, cleanPkg, scanNow } = 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
@@ -94,13 +113,17 @@ function quickClean() {
</div>
<!-- Top 30 排行(可收起) -->
<Top30Panel v-if="showTop30" :top30="top30WithIcon" />
<Top30Panel v-if="showTop30" :top30="top30WithIcon" :top30-by="stats.top30By" />
<!-- 包卡片列表:仅需关注项 -->
<div class="sec-head">
<span class="sec-title"><span class="emo">⚠️</span>需要关注</span>
<span class="sec-sub">共 {{ attentionPackages.length }} 个需关注 · 预警置顶</span>
<button class="link-all" @click="router.push('/search')">查看全部 →</button>
<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>
</div>
<div v-if="state.loading" class="loading-tip">⏳ 加载中…</div>
@@ -151,6 +174,9 @@ 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; }