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:
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user