From f84f21574f9a41f9e259f58bc376414366bcb6cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E9=93=B6=E5=86=9B?= Date: Sat, 19 Sep 2026 17:57:58 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=89=AB=E6=8F=8F=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E4=B8=8E=E7=9C=9F=E5=AE=9E=E5=A4=A7=E5=B0=8F=E8=AE=A1=E7=AE=97?= =?UTF-8?q?=EF=BC=8C=E8=A7=A3=E5=86=B3=E9=A1=B5=E9=9D=A2=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E6=85=A2=E4=B8=8E=E6=95=B0=E6=8D=AE=E8=99=9A=E5=A2=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/scanner.js | 35 +++++++++++++++++++------- backend/server.js | 34 ++++++++++++++++++++++--- docker-compose.yml | 24 +++++++----------- frontend/src/api/index.js | 16 +++++++++--- frontend/src/components/Top30Panel.vue | 18 +++++++++---- frontend/src/store/index.js | 28 ++++++++++++++++++--- frontend/src/styles/base.css | 1 + frontend/src/views/OverviewView.vue | 32 ++++++++++++++++++++--- 8 files changed, 147 insertions(+), 41 deletions(-) diff --git a/backend/scanner.js b/backend/scanner.js index 44b6d40..181410f 100644 --- a/backend/scanner.js +++ b/backend/scanner.js @@ -118,19 +118,36 @@ export function scanStorage(storageDir) { const timeObj = meta.time || {} const distTags = meta['dist-tags'] || {} + // 物理 .tgz 文件:先列目录建立文件名 → 大小索引 + // (Verdaccio 各版本命名不同:{version}.tgz 或 {pkgname}-{version}.tgz,不猜、直接扫) + const tgzSizes = new Map() + let tgzTotal = 0 + try { + for (const f of fs.readdirSync(pkgDir)) { + if (!f.endsWith('.tgz')) continue + const size = fs.statSync(path.join(pkgDir, f)).size + tgzTotal += size + tgzSizes.set(f.slice(0, -4), size) + } + } catch {} + const versionsDetail = [] - let totalBytes = 0 + // 总大小以实际 .tgz 文件为准;unpackedSize 是解压后大小,会数倍虚增,仅作无物理文件时的兜底 + const totalBytes = tgzTotal for (const [v, info] of Object.entries(versionsObj)) { - // tgz 物理文件:Verdaccio 约定 {pkgDir}/{version}.tgz - const tgz = path.join(pkgDir, `${v}.tgz`) - let size = 0 - try { - size = fs.statSync(tgz).size - } catch { - // 无物理文件:用元数据 dist.tarball 大小兜底(无则 0) + let size = tgzSizes.get(v) + if (typeof size !== 'number') { + // 兼容 {pkgname}-{version}.tgz 命名:用元数据 dist.tarball 的文件名精确匹配 + const tb = info && info.dist && info.dist.tarball + const bn = tb ? String(tb).split('/').pop() : '' + if (bn.endsWith('.tgz')) { + const hit = tgzSizes.get(bn.slice(0, -4)) + if (typeof hit === 'number') size = hit + } + } + if (typeof size !== 'number') { size = (info && info.dist && typeof info.dist.unpackedSize === 'number') ? info.dist.unpackedSize : 0 } - totalBytes += size versionsDetail.push({ version: parseVersion(v), size: fmtMb(size), diff --git a/backend/server.js b/backend/server.js index e5b3919..6138e77 100644 --- a/backend/server.js +++ b/backend/server.js @@ -38,14 +38,29 @@ function isDemoMode() { return process.env.VCM_DEMO === '1' || !isValidStorage(currentStorage()) } +// —— 扫描结果缓存:真实目录全量扫描 IO 重,缓存避免每次请求/刷新页面重扫 —— +let rawCache = null +let rawCacheAt = null +function getRawPackages() { + if (isDemoMode()) return demoPackages() + if (!rawCache) { + rawCache = scanStorage(currentStorage()) + rawCacheAt = new Date().toISOString() + } + return rawCache +} +function invalidateScan() { + rawCache = null + rawCacheAt = null +} + // 构建包列表(统一状态计算 + 排序 + 忽略标记) function buildPackages(keyword = '') { const threshold = Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)) const ignoredRows = getIgnoredRows() const ignoredSet = new Set(ignoredRows.map(r => r.pkg_name)) const ignoredAtMap = Object.fromEntries(ignoredRows.map(r => [r.pkg_name, (r.ignored_at || '').slice(0, 10)])) - const demo = isDemoMode() - const raw = demo ? demoPackages() : scanStorage(currentStorage()) + const raw = getRawPackages() let list = raw.map(p => { let status @@ -141,17 +156,28 @@ app.get('/api/packages', (req, res) => { const demo = isDemoMode() const storage = currentStorage() const validation = demo ? { ok: true, message: 'demo' } : validateStorage(storage) + const packages = buildPackages(req.query.keyword || '') // 先构建(触发懒扫描并填充缓存) res.json({ demo, storageDir: storage || null, storageValid: validation.ok, storageMessage: validation.message, threshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)), - packages: buildPackages(req.query.keyword || ''), + scannedAt: demo ? new Date().toISOString() : (rawCacheAt || null), + packages, ...(demo ? demoSummary() : {}), }) }) +// —— API:手动重新扫描缓存目录 —— +app.post('/api/scan', (req, res) => { + invalidateScan() + const demo = isDemoMode() + const scannedAt = new Date().toISOString() + if (!demo) getRawPackages() // 同步重扫并填充缓存 + res.json({ ok: true, demo, scannedAt }) +}) + // —— API:清理 —— app.post('/api/clean', (req, res) => { const { pkgName, keep } = req.body || {} @@ -168,6 +194,7 @@ app.post('/api/clean', (req, res) => { deletedCount: result.deletedCount, freedSpace: result.freedSpace, }) + invalidateScan() } res.json(result) }) @@ -187,6 +214,7 @@ app.delete('/api/packages/:pkgName/versions/:version', (req, res) => { deletedCount: 1, freedSpace: result.freedSpace, }) + invalidateScan() } res.json(result) }) diff --git a/docker-compose.yml b/docker-compose.yml index caf3fe1..86d0b99 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,22 +1,16 @@ # Verdaccio 缓存可视化管理面板(§12.2 Docker 部署) -# 与 Verdaccio 同主机:storage 只读挂载,manager-data 读写(SQLite 数据) +# Verdaccio 已独立部署,此处仅运行面板:缓存目录只读挂载,manager-data 读写(SQLite 数据) services: - verdaccio: - image: verdaccio/verdaccio - restart: unless-stopped - volumes: - - "./storage:/verdaccio/storage" # Verdaccio 读写缓存目录 - verdaccio-manager: - build: ./ - restart: unless-stopped + image: verdaccio-cache-manager:latest # 已导入的 amd64 镜像(docker load 后直接可用) + restart: always environment: - - VERDACCIO_STORAGE=/verdaccio/storage # 只读扫描该目录 + - VERDACCIO_STORAGE=/verdaccio/storage # 容器内路径,对应下方只读挂载的右侧 - PORT=3000 volumes: - - "./storage:/verdaccio/storage:ro" # 面板只读挂载 - - "./manager-data:/app/data" # 读写:SQLite 数据 + - "/volume2/docker/verdaccio_verdaccio-1/verdaccio_storage:/verdaccio/storage:ro" # Verdaccio 缓存目录(只读) + - "vcm-data:/app/data" # 读写:SQLite 数据(named volume,避免宿主目录权限问题) ports: - - "3000:3000" - depends_on: - - verdaccio + - "42338:3000" +volumes: + vcm-data: diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 4a9e4f3..0d541e2 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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() { diff --git a/frontend/src/components/Top30Panel.vue b/frontend/src/components/Top30Panel.vue index 398db13..2af9a5f 100644 --- a/frontend/src/components/Top30Panel.vue +++ b/frontend/src/components/Top30Panel.vue @@ -1,12 +1,20 @@