diff --git a/backend/scanner.js b/backend/scanner.js
index 181410f..52173fe 100644
--- a/backend/scanner.js
+++ b/backend/scanner.js
@@ -95,31 +95,20 @@ export function scanStorage(storageDir) {
const packages = []
if (!isValidStorage(storageDir)) return packages
- const pkgFiles = listFiles(storageDir).filter(f => f.endsWith('package.json'))
+ const allFiles = listFiles(storageDir)
+ // 包目录 = 含 package.json 的目录 ∪ 含 .tgz 的目录(避免只缓存了 tgz、缺元数据的目录被漏算)
+ const pkgDirs = new Set()
+ for (const f of allFiles) {
+ if (f.endsWith('package.json') || f.endsWith('.tgz')) pkgDirs.add(path.dirname(f))
+ }
const storageRoot = path.resolve(storageDir)
- for (const pkgFile of pkgFiles) {
- const pkgDir = path.dirname(pkgFile)
- const meta = readJson(pkgFile)
- if (!meta) {
- // 元数据损坏:标记异常包,不影响其他包
- packages.push({
- name: pkgNameFromDir(pkgDir, storageRoot),
- status: 'broken',
- versions: 0,
- size: 0,
- lastUpdated: null,
- versionsDetail: [],
- })
- continue
- }
-
- const versionsObj = meta.versions || {}
- const timeObj = meta.time || {}
- const distTags = meta['dist-tags'] || {}
+ for (const pkgDir of pkgDirs) {
+ const pkgFile = path.join(pkgDir, 'package.json')
// 物理 .tgz 文件:先列目录建立文件名 → 大小索引
// (Verdaccio 各版本命名不同:{version}.tgz 或 {pkgname}-{version}.tgz,不猜、直接扫)
+ // 先统计再读元数据:即使元数据损坏也能算出真实占用
const tgzSizes = new Map()
let tgzTotal = 0
try {
@@ -131,8 +120,36 @@ export function scanStorage(storageDir) {
}
} catch {}
+ const meta = readJson(pkgFile)
+ if (!meta) {
+ // 元数据缺失/损坏:标记异常包(真实占用按物理 .tgz 计算),不影响其他包
+ packages.push({
+ name: pkgNameFromDir(pkgDir, storageRoot),
+ status: 'broken',
+ versions: 0,
+ size: fmtMb(tgzTotal),
+ lastUpdated: null,
+ versionsDetail: [],
+ })
+ continue
+ }
+
+ const versionsObj = meta.versions || {}
+ const timeObj = meta.time || {}
+ const distTags = meta['dist-tags'] || {}
+ // 包信息(来自本地元数据,离线可用):介绍/主页/仓库/作者/许可
+ const description = typeof meta.description === 'string' ? meta.description : null
+ const homepage = typeof meta.homepage === 'string' && meta.homepage ? meta.homepage : null
+ const repository = typeof meta.repository === 'string'
+ ? meta.repository
+ : (meta.repository && typeof meta.repository.url === 'string' ? meta.repository.url : null)
+ const author = typeof meta.author === 'string'
+ ? meta.author
+ : (meta.author && typeof meta.author.name === 'string' ? meta.author.name : null)
+ const license = typeof meta.license === 'string' ? meta.license : null
+
const versionsDetail = []
- // 总大小以实际 .tgz 文件为准;unpackedSize 是解压后大小,会数倍虚增,仅作无物理文件时的兜底
+ // 总大小以实际 .tgz 文件为准
const totalBytes = tgzTotal
for (const [v, info] of Object.entries(versionsObj)) {
let size = tgzSizes.get(v)
@@ -145,9 +162,8 @@ export function scanStorage(storageDir) {
if (typeof hit === 'number') size = hit
}
}
- if (typeof size !== 'number') {
- size = (info && info.dist && typeof info.dist.unpackedSize === 'number') ? info.dist.unpackedSize : 0
- }
+ // 无物理 .tgz 文件的版本是元数据残留(实体已被清理),不计数,避免版本数虚高
+ if (typeof size !== 'number') continue
versionsDetail.push({
version: parseVersion(v),
size: fmtMb(size),
@@ -169,6 +185,11 @@ export function scanStorage(storageDir) {
lastUpdated,
visits: 0, // 真实环境无访问统计,界面不依赖真实值
distTags,
+ description,
+ homepage,
+ repository,
+ author,
+ license,
versionsDetail,
})
}
@@ -176,6 +197,115 @@ export function scanStorage(storageDir) {
return packages
}
+/**
+ * 异步扫描(分片让出事件循环 + 进度回调)
+ * 用于后台重扫:扫描期间仍能响应 /api/scan/progress 等请求,前端可实时展示进度
+ */
+export async function scanStorageAsync(storageDir, onProgress) {
+ const packages = []
+ if (!isValidStorage(storageDir)) return packages
+
+ const allFiles = listFiles(storageDir)
+ // 包目录 = 含 package.json 的目录 ∪ 含 .tgz 的目录(避免只缓存了 tgz、缺元数据的目录被漏算)
+ const pkgDirs = new Set()
+ for (const f of allFiles) {
+ if (f.endsWith('package.json') || f.endsWith('.tgz')) pkgDirs.add(path.dirname(f))
+ }
+ const storageRoot = path.resolve(storageDir)
+ let scanned = 0
+ let scannedBytes = 0 // 所有文件字节和(总占用,与 du 同口径)
+ let tgzBytes = 0 // 仅 .tgz 字节和(包粒度用)
+
+ for (const pkgDir of pkgDirs) {
+ // 每处理完一个包目录就让出事件循环,保证进度接口可实时响应
+ await new Promise(r => setImmediate(r))
+ const pkgFile = path.join(pkgDir, 'package.json')
+
+ const tgzSizes = new Map()
+ let tgzTotal = 0
+ try {
+ for (const f of fs.readdirSync(pkgDir)) {
+ const size = fs.statSync(path.join(pkgDir, f)).size
+ scannedBytes += size
+ if (f.endsWith('.tgz')) {
+ tgzTotal += size
+ tgzSizes.set(f.slice(0, -4), size)
+ }
+ }
+ } catch {}
+ tgzBytes += tgzTotal
+
+ const meta = readJson(pkgFile)
+ if (!meta) {
+ packages.push({
+ name: pkgNameFromDir(pkgDir, storageRoot),
+ status: 'broken',
+ versions: 0,
+ size: fmtMb(tgzTotal),
+ lastUpdated: null,
+ versionsDetail: [],
+ })
+ } else {
+ const versionsObj = meta.versions || {}
+ const timeObj = meta.time || {}
+ const distTags = meta['dist-tags'] || {}
+ // 包信息(来自本地元数据,离线可用):介绍/主页/仓库/作者/许可
+ const description = typeof meta.description === 'string' ? meta.description : null
+ const homepage = typeof meta.homepage === 'string' && meta.homepage ? meta.homepage : null
+ const repository = typeof meta.repository === 'string'
+ ? meta.repository
+ : (meta.repository && typeof meta.repository.url === 'string' ? meta.repository.url : null)
+ const author = typeof meta.author === 'string'
+ ? meta.author
+ : (meta.author && typeof meta.author.name === 'string' ? meta.author.name : null)
+ const license = typeof meta.license === 'string' ? meta.license : null
+ const versionsDetail = []
+ const totalBytes = tgzTotal
+ for (const [v, info] of Object.entries(versionsObj)) {
+ let size = tgzSizes.get(v)
+ if (typeof size !== 'number') {
+ 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
+ }
+ }
+ // 无物理 .tgz 文件的版本是元数据残留(实体已被清理),不计数,避免版本数虚高
+ if (typeof size !== 'number') continue
+ versionsDetail.push({
+ version: parseVersion(v),
+ size: fmtMb(size),
+ date: timeObj[v] ? timeObj[v].slice(0, 10) : null,
+ })
+ }
+ versionsDetail.sort((a, b) => a.version.localeCompare(b.version, undefined, { numeric: true }))
+ const lastUpdated = Object.keys(timeObj).length
+ ? Object.entries(timeObj).sort((a, b) => b[1].localeCompare(a[1]))[0][1].slice(0, 10)
+ : null
+ packages.push({
+ name: pkgNameFromDir(pkgDir, storageRoot),
+ versions: versionsDetail.length,
+ size: fmtMb(totalBytes),
+ lastUpdated,
+ visits: 0,
+ distTags,
+ description,
+ homepage,
+ repository,
+ author,
+ license,
+ versionsDetail,
+ })
+ }
+
+ scanned += 1
+ if (onProgress) onProgress({ packages: scanned, bytes: scannedBytes, tgzBytes })
+ }
+
+ return packages
+}
+
// —— demo 模式数据(无真实目录时的演示) ——
const DEMO_SEED = [
['lodash', 25, 4, 12456], ['@antv/g2', 32, 12, 7654], ['axios', 18, 1.2, 9832],
@@ -212,10 +342,29 @@ function genVersions(count) {
return list
}
+// demo 包信息(演示模式展示用,真实环境来自缓存元数据)
+const DEMO_META = {
+ lodash: { description: 'A modern JavaScript utility library delivering modularity, performance & extras.', homepage: 'https://lodash.com', repository: 'git+https://github.com/lodash/lodash.git', license: 'MIT' },
+ react: { description: 'React is a JavaScript library for building user interfaces.', homepage: 'https://react.dev', repository: 'git+https://github.com/facebook/react.git', license: 'MIT' },
+ vue: { description: 'The progressive JavaScript framework for building modern web UI.', homepage: 'https://vuejs.org', repository: 'git+https://github.com/vuejs/core.git', license: 'MIT' },
+ axios: { description: 'Promise based HTTP client for the browser and node.js.', homepage: 'https://axios-http.com', repository: 'git+https://github.com/axios/axios.git', license: 'MIT' },
+ typescript: { description: 'TypeScript is a language for application-scale JavaScript.', homepage: 'https://www.typescriptlang.org', repository: 'git+https://github.com/microsoft/TypeScript.git', license: 'Apache-2.0' },
+ express: { description: 'Fast, unopinionated, minimalist web framework for node.', homepage: 'https://expressjs.com', repository: 'git+https://github.com/expressjs/express.git', license: 'MIT' },
+ vite: { description: 'Native-ESM powered web dev build tool.', homepage: 'https://vitejs.dev', repository: 'git+https://github.com/vitejs/vite.git', license: 'MIT' },
+ webpack: { description: 'Packs CommonJs/AMD modules for the browser.', homepage: 'https://webpack.js.org', repository: 'git+https://github.com/webpack/webpack.git', license: 'MIT' },
+ echarts: { description: 'Apache ECharts is a powerful, interactive charting and visualization library for browser.', homepage: 'https://echarts.apache.org', repository: 'git+https://github.com/apache/echarts.git', license: 'Apache-2.0' },
+ dayjs: { description: 'Day.js is a minimalist JavaScript library that parses, validates, manipulates, and displays dates and times for modern browsers.', homepage: 'https://day.js.org', repository: 'git+https://github.com/iamkun/dayjs.git', license: 'MIT' },
+ prisma: { description: 'Prisma is a next-generation ORM that helps you build and query databases.', homepage: 'https://www.prisma.io', repository: 'git+https://github.com/prisma/prisma.git', license: 'Apache-2.0' },
+ three: { description: 'JavaScript 3D library.', homepage: 'https://threejs.org', repository: 'git+https://github.com/mrdoob/three.js.git', license: 'MIT' },
+ uuid: { description: 'RFC4122 (v1, v4, and v5) UUIDs.', homepage: 'https://github.com/uuidjs/uuid', repository: 'git+https://github.com/uuidjs/uuid.git', license: 'MIT' },
+ zod: { description: 'TypeScript-first schema validation with static type inference.', homepage: 'https://zod.dev', repository: 'git+https://github.com/colinhacks/zod.git', license: 'MIT' },
+}
+
export function demoPackages() {
const seen = new Set()
return DEMO_SEED.filter(([n]) => !seen.has(n) && seen.add(n)).map(([name, verCount, sizeBase, visits]) => {
const detail = genVersions(verCount)
+ const meta = DEMO_META[name] || {}
return {
name,
versions: detail.length,
@@ -223,6 +372,11 @@ export function demoPackages() {
lastUpdated: detail.length ? detail[detail.length - 1].date : null,
visits,
distTags: { latest: detail.length ? detail[detail.length - 1].version : '1.0.0' },
+ description: meta.description || null,
+ homepage: meta.homepage || null,
+ repository: meta.repository || null,
+ author: meta.author || null,
+ license: meta.license || null,
versionsDetail: detail,
}
})
diff --git a/backend/server.js b/backend/server.js
index 6138e77..24863d0 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -10,13 +10,14 @@
import express from 'express'
import path from 'node:path'
import fs from 'node:fs'
+import { execSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import {
getSetting, setSetting, getIgnored, getIgnoredRows, addIgnored, removeIgnored,
addLog, getLogs,
} from './db.js'
import {
- resolveStorageDir, isValidStorage, validateStorage, scanStorage,
+ resolveStorageDir, isValidStorage, validateStorage, scanStorage, scanStorageAsync,
demoPackages, demoSummary,
} from './scanner.js'
import { cleanPackage, deleteSingleVersion } from './clean.js'
@@ -41,14 +42,79 @@ function isDemoMode() {
// —— 扫描结果缓存:真实目录全量扫描 IO 重,缓存避免每次请求/刷新页面重扫 ——
let rawCache = null
let rawCacheAt = null
+
+// —— 异步扫描状态(后台重扫时供前端轮询进度) ——
+const scanState = {
+ scanning: false, done: false, packages: 0, bytes: 0, tgzBytes: 0, diskBytes: null,
+ startedAt: null, finishedAt: null, error: null,
+}
+
+/** 磁盘占用(与 du -sh 同口径,block 计数)——大量小文件时远大于字节和 */
+function diskBytesOf(dir) {
+ try {
+ const out = execSync(`du -sk ${JSON.stringify(dir)}`, {
+ timeout: 120000, stdio: ['ignore', 'pipe', 'ignore'],
+ }).toString()
+ const kb = parseInt(out.trim().split(/\s/)[0], 10)
+ return Number.isFinite(kb) ? kb * 1024 : null
+ } catch (e) {
+ return null
+ }
+}
+
+async function runScanAsync() {
+ scanState.scanning = true
+ scanState.done = false
+ scanState.error = null
+ scanState.packages = 0
+ scanState.bytes = 0
+ scanState.tgzBytes = 0
+ scanState.diskBytes = null
+ scanState.startedAt = new Date().toISOString()
+ const storage = currentStorage()
+ console.log(`[scan] 开始扫描存储目录: ${storage}`)
+ try {
+ // 分片异步扫描:期间 /api/scan/progress 等请求仍可被响应
+ rawCache = await scanStorageAsync(storage, (p) => {
+ scanState.packages = p.packages
+ scanState.bytes = p.bytes // 目录内所有文件字节和
+ scanState.tgzBytes = p.tgzBytes || 0 // 仅 .tgz
+ })
+ rawCacheAt = new Date().toISOString()
+ scanState.done = true
+ scanState.finishedAt = rawCacheAt
+ scanState.diskBytes = diskBytesOf(storage) // 磁盘占用(与用户 du -sh 一致)
+ console.log('[scan] 完成 ' + JSON.stringify({
+ storage,
+ packages: rawCache.length,
+ tgzBytes: scanState.tgzBytes,
+ bytes: scanState.bytes,
+ diskBytes: scanState.diskBytes,
+ }))
+ } catch (e) {
+ scanState.error = e.message
+ console.error('[scan] 扫描失败:', e.message)
+ } finally {
+ scanState.scanning = false
+ }
+}
+
+// 确保扫描已启动(缓存缺失时懒触发,不阻塞当前请求)
+function ensureScan() {
+ if (isDemoMode()) return
+ if (rawCache || scanState.scanning) return
+ runScanAsync()
+}
+
function getRawPackages() {
if (isDemoMode()) return demoPackages()
if (!rawCache) {
- rawCache = scanStorage(currentStorage())
- rawCacheAt = new Date().toISOString()
+ // 懒加载:后台启动扫描,本次请求返回空列表,由前端轮询进度后刷新
+ ensureScan()
}
return rawCache
}
+
function invalidateScan() {
rawCache = null
rawCacheAt = null
@@ -60,7 +126,7 @@ function buildPackages(keyword = '') {
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 raw = getRawPackages()
+ const raw = getRawPackages() || []
let list = raw.map(p => {
let status
@@ -76,18 +142,14 @@ function buildPackages(keyword = '') {
distTags: p.distTags || {},
status,
ignoredAt: ignoredAtMap[p.name] || null,
- versionsDetail: p.versionsDetail || [],
+ // 列表不携带 versionsDetail(包多时响应巨大拖慢加载),详情走 GET /api/packages/:name
}
})
- // 检索:不区分大小写子串,匹配包名 / 版本号 / 文件大小文本
+ // 检索:不区分大小写子串,匹配包名(版本号匹配由前端本地完成,避免列表携带明细)
const kw = keyword.trim().toLowerCase()
if (kw) {
- list = list.filter(p =>
- p.name.toLowerCase().includes(kw) ||
- (p.versionsDetail || []).some(v => v.version.toLowerCase().includes(kw)) ||
- String(p.size).includes(kw),
- )
+ list = list.filter(p => p.name.toLowerCase().includes(kw) || String(p.size).includes(kw))
}
// 排序:预警置顶 → 正常 → 忽略 → 异常;同状态按版本数降序
@@ -164,18 +226,64 @@ app.get('/api/packages', (req, res) => {
storageMessage: validation.message,
threshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)),
scannedAt: demo ? new Date().toISOString() : (rawCacheAt || null),
+ scanning: scanState.scanning,
+ scanPackages: scanState.packages,
+ scanBytes: scanState.bytes,
+ totalBytes: demo ? null : (scanState.diskBytes ?? scanState.bytes), // 磁盘占用(与 du -sh 同口径)
packages,
...(demo ? demoSummary() : {}),
})
})
-// —— API:手动重新扫描缓存目录 ——
+// —— API:单包详情(含版本明细,抽屉打开时按需加载)
+// 只读现有扫描缓存,绝不触发同步扫描(缓存被失效时直接 404,
+// 前端用列表数据兜底 + 提示,避免打开抽屉卡几十秒) ——
+app.get('/api/packages/:pkgName', (req, res) => {
+ const name = decodeURIComponent(req.params.pkgName)
+ const raw = isDemoMode() ? demoPackages() : (rawCache || null)
+ const p = raw ? raw.find(x => x.name === name) : null
+ if (!p) return res.status(404).json({ ok: false, message: '包不存在或正在扫描中' })
+ const threshold = Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD))
+ const ignoredSet = new Set(getIgnoredRows().map(r => r.pkg_name))
+ let status
+ if (p.status === 'broken') status = 'broken'
+ else if (ignoredSet.has(p.name)) status = 'ignored'
+ else status = p.versions > threshold ? 'warn' : 'ok'
+ res.json({ ok: true, pkg: { ...p, status } })
+})
+
+// —— 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 })
+ if (demo) {
+ const scannedAt = new Date().toISOString()
+ return res.json({ ok: true, demo, scanning: false, scannedAt })
+ }
+ if (!scanState.scanning) runScanAsync() // 后台启动,不阻塞响应
+ res.json({
+ ok: true,
+ demo: false,
+ scanning: scanState.scanning,
+ startedAt: scanState.startedAt,
+ packages: scanState.packages,
+ bytes: scanState.bytes,
+ })
+})
+
+// —— API:扫描进度(重扫/首扫期间由前端轮询) ——
+app.get('/api/scan/progress', (req, res) => {
+ res.json({
+ scanning: scanState.scanning,
+ done: scanState.done,
+ packages: scanState.packages,
+ bytes: scanState.bytes,
+ tgzBytes: scanState.tgzBytes,
+ diskBytes: scanState.diskBytes,
+ error: scanState.error,
+ startedAt: scanState.startedAt,
+ finishedAt: scanState.finishedAt,
+ })
})
// —— API:清理 ——
@@ -260,8 +368,27 @@ app.listen(PORT, () => {
console.log(`[verdaccio-manager] listening on :${PORT} (mode=${mode})`)
if (mode === 'storage') console.log(`[verdaccio-manager] storage: ${storage}`)
else console.log('[verdaccio-manager] 未配置 VERDACCIO_STORAGE,运行于演示模式(设置 VCM_DEMO=0 + 真实目录可关闭)')
+ scheduleDailyScan()
})
+// —— 每天 00:00(服务器本地时区)自动重扫缓存,逻辑同 POST /api/scan ——
+function scheduleDailyScan() {
+ const now = new Date()
+ const next = new Date(now)
+ next.setHours(24, 0, 0, 0) // 下个本地零点
+ const wait = next.getTime() - now.getTime()
+ setTimeout(() => {
+ try {
+ invalidateScan()
+ ensureScan() // 后台异步重扫,不阻塞事件循环
+ console.log(`[auto-scan] ${new Date().toISOString()} 每日自动扫描已启动`)
+ } catch (e) {
+ console.error('[auto-scan] 每日自动扫描失败:', e.message)
+ }
+ scheduleDailyScan() // 安排次日零点
+ }, wait)
+}
+
function clampInt(v, min, max) {
const n = Number(v)
if (Number.isNaN(n)) return min
diff --git a/doc/design-spec.md b/doc/design-spec.md
index a1dcd01..e7ae1d2 100644
--- a/doc/design-spec.md
+++ b/doc/design-spec.md
@@ -138,7 +138,7 @@ CREATE TABLE logs (id INTEGER PRIMARY KEY AUTOINCREMENT, pkg_name TEXT,
| # | 页面/视图 | 关键内容 | 路由建议 |
| --- | --- | --- | --- |
-| 1 | 缓存概览 | 4 核心统计卡 + 5 辅助指标、预警横幅卡、Top 30 访问排行、包卡片列表 | `/` |
+| 1 | 缓存概览 | 4 核心统计卡 + 5 辅助指标、预警横幅卡、缓存占用 Top 20 排行、包卡片列表 | `/` |
| 2 | 全局检索 | 48px 大圆角搜索框、快捷筛选 pill、无边框结果表格 | `/search` |
| 3 | 阈值配置 | 全局阈值卡×2(版本数/大小,−/+ 步进器、恢复默认、保存)、单包例外表格、行内添加例外表单 | `/thresholds` |
| 4 | 包详情抽屉 | 600px 右侧滑出,包信息 + 版本列表(单版本删除、清理全部、忽略) | 覆盖层 |
@@ -158,7 +158,7 @@ CREATE TABLE logs (id INTEGER PRIMARY KEY AUTOINCREMENT, pkg_name TEXT,
| 辅助指标 Stat2 | 5 个轻量小卡(访问排行/今日下载/最大包/最老包/版本数分布) |
| 预警卡 Alert | 红色渐变边框 + 呼吸光晕 + 立即清理按钮 |
| 清理卡 Clean | 橙色渐变 + 可清理空间 + 立即清理按钮 |
-| Top30 排行 | 表格:金银铜徽章、包名(scoped 省略+tooltip)、访问次数、热度进度条 |
+| Top20 排行 | 表格:金银铜徽章、包名(scoped 省略+tooltip)、访问次数、热度进度条 |
| 包卡片 | 图标、包名、版本数/占用/最近更新、状态标签、清理/忽略按钮;warn 卡呼吸光晕 |
| 搜索框 | 48px 高、大圆角、放大镜 + 快捷键提示;focus 橙色光圈 |
| Pill 筛选 | 状态过滤 + 排序切换,选中态绿色 |
diff --git a/doc/需求文档.md b/doc/需求文档.md
index 81d29a7..02bed2f 100644
--- a/doc/需求文档.md
+++ b/doc/需求文档.md
@@ -381,18 +381,17 @@ C端体验优先:拒绝千篇一律的后台管理系统风格,打造轻盈
不需要”设置”菜单:阈值配置已经覆盖了核心设置项,其他系统级配置(如 Verdaccio 连接地址)可以在首次启动时通过弹窗配置,或放在用户头像的下拉菜单中。
不需要”用户管理”菜单:本工具定位为个人/小团队内部使用,暂不涉及多用户权限管理。
### 三、缓存概览页
-#### 3.1 统计指标(共 9 个)
+#### 3.1 统计指标(共 8 个)
| 序号 | 指标 | 图标 | 说明 |
|---|---|---|---|
| 1 | 包的数量 | | 缓存包总数 |
| 2 | 占用空间 | | 总占用空间 |
| 3 | 预警数量 | | 触发阈值的包数量 |
-| 4 | 访问排行 | | Top 30 包访问次数 |
-| 5 | 今日/本周下载量 | | 当天或本周下载总次数 |
-| 6 | 可清理空间 | | 预估可释放的空间 |
-| 7 | 最大包 | | 占用空间最大的包 |
-| 8 | 最老包时间 | | 最久未更新的包的时间 |
-| 9 | 版本数分布 | | 单包版本数分布情况 |
+| 4 | 今日更新包数 | | 最近更新时间等于今天的包数量 |
+| 5 | 可清理空间 | | 预估可释放的空间 |
+| 6 | 最大包 | | 占用空间最大的包 |
+| 7 | 最老包时间 | | 最久未更新的包的时间 |
+| 8 | 版本数分布 | | 单包版本数分布情况 |
#### 3.2 布局设计
##### 第一行(核心指标 · 4 个)
@@ -402,27 +401,27 @@ C端体验优先:拒绝千篇一律的后台管理系统风格,打造轻盈
│ 清新绿 │ 活力橙 │ 热情红呼吸 │ 静谧蓝 │
│ │ │ │ [立即清理] │
└──────────────┴──────────────┴──────────────┴──────────────┘
-指标说明: - 包的数量:扫描 storage/ 目录下的包文件夹数量 - 占用空间:递归计算 storage/ 下所有 .tgz 文件总大小 - 预警数量:对比阈值配置,统计触发条件的包数量 - 可清理空间:根据阈值规则,预估可释放的空间,给用户”清理动力” - 访问排行:点击可展开 Top 30 详情
+指标说明: - 包的数量:扫描 storage/ 目录下的包文件夹数量 - 占用空间:递归计算 storage/ 下所有 .tgz 文件总大小 - 预警数量:对比阈值配置,统计触发条件的包数量 - 可清理空间:根据阈值规则,预估可释放的空间,给用户”清理动力” - 今日更新包数:最近更新时间等于今天的包数量
##### 第二行(辅助指标 · 5 个)
┌──────────────┬──────────────┬──────────────┬──────────────┬──────────────┐
-│ 访问排行 │ 今日下载量 │ 最大包 │ 最老包时间 │ 版本数分布 │
-│ Top 30 │ 1,234 │ tensorflow │ 3 年前 │ >50版本: 12个│
-│ [展开] │ 2.1 GB │ 2.1 GB │ legacy-utils │ [查看详情] │
+│ 今日更新包数 │ 缓存版本总数 │ 最大包 │ 最老包时间 │ 版本数分布 │
+│ 12 │ 86.1 万 │ tensorflow │ 3 年前 │ >50版本: 12个│
+│ [查看] │ 所有版本 │ 2.1 GB │ legacy-utils │ [查看详情] │
└──────────────┴──────────────┴──────────────┴──────────────┴──────────────┘
-#### 3.3 访问次数 Top 30 排行
-点击”访问排行”卡片后,下方滑出排行榜,采用表格 + 进度条形式:
+#### 3.3 缓存占用 Top 20 排行
+概览页常驻展示占用空间最大的 20 个包,采用表格形式(真实环境无访问统计时按占用大小排序):
┌────────────────────────────────────────────────────────────────┐
-│ 访问次数 Top 30 │
-├──────┬──────────────────────┬──────────┬───────────────────────┤
-│ 排名 │ 包名 │ 访问次数 │ 热度 │
-├──────┼──────────────────────┼──────────┼───────────────────────┤
-│ │ lodash │ 12,456 │ ████████████░░ 85% │
-│ │ axios │ 9,832 │ █████████░░░ 72% │
-│ │ @antv/g2 │ 7,654 │ ███████░░░░░ 58% │
-│ 4 │ react │ 6,543 │ ██████░░░░░░ 49% │
-│ ... │ ... │ ... │ ... │
-└──────┴──────────────────────┴──────────┴───────────────────────┘
-设计要点: - 前三名用金银铜标识,多巴胺配色高亮 - 热度进度条直观展示相对访问频率 - 包名支持 scoped(如 @antv/g2),超出宽度省略 + tooltip - 点击包名可跳转到该包的详情卡片
+│ 缓存占用 Top 20 │
+├──────┬──────────────────────┬──────────┤
+│ 排名 │ 包名 │ 占用空间 │
+├──────┼──────────────────────┼──────────┤
+│ │ tensorflow │ 2.1 GB │
+│ │ @antv/g2 │ 1.4 GB │
+│ │ lodash │ 854 MB │
+│ 4 │ react │ 621 MB │
+│ ... │ ... │ ... │
+└──────┴──────────────────────┴──────────┘
+设计要点: - 前三名用金银铜标识,多巴胺配色高亮 - 包名支持 scoped(如 @antv/g2),超出宽度省略 + tooltip - 点击行可打开该包的详情抽屉
#### 3.4 缓存包列表(卡片形式)
以圆角卡片形式展示每个缓存包的核心信息,卡片采用多巴胺配色,按状态排序:
┌──────────────────────────────────────────────────────────────┐
diff --git a/docker-compose.yml b/docker-compose.yml
index 86d0b99..5f24c28 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -7,6 +7,7 @@ services:
environment:
- VERDACCIO_STORAGE=/verdaccio/storage # 容器内路径,对应下方只读挂载的右侧
- PORT=3000
+ - TZ=Asia/Shanghai # 每日 00:00 自动扫描按北京时间执行
volumes:
- "/volume2/docker/verdaccio_verdaccio-1/verdaccio_storage:/verdaccio/storage:ro" # Verdaccio 缓存目录(只读)
- "vcm-data:/app/data" # 读写:SQLite 数据(named volume,避免宿主目录权限问题)
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 401d389..d08e3d5 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -1,5 +1,5 @@
@@ -46,10 +65,22 @@ function go(path) {
-
-
👤
+
+
@@ -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(☰ + 品牌) */
diff --git a/frontend/src/components/MobileTabbar.vue b/frontend/src/components/MobileTabbar.vue
index d1328d5..105441f 100644
--- a/frontend/src/components/MobileTabbar.vue
+++ b/frontend/src/components/MobileTabbar.vue
@@ -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) {
diff --git a/frontend/src/components/PackageCard.vue b/frontend/src/components/PackageCard.vue
index c369032..258b0fb 100644
--- a/frontend/src/components/PackageCard.vue
+++ b/frontend/src/components/PackageCard.vue
@@ -1,6 +1,6 @@
+
+
+
+
+
+ {{ letter }}
+
+
+
+
diff --git a/frontend/src/components/StatMini.vue b/frontend/src/components/StatMini.vue
index 83c8095..1ff97b5 100644
--- a/frontend/src/components/StatMini.vue
+++ b/frontend/src/components/StatMini.vue
@@ -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; }
+}
diff --git a/frontend/src/components/Top30Panel.vue b/frontend/src/components/Top30Panel.vue
index 2af9a5f..b3535ca 100644
--- a/frontend/src/components/Top30Panel.vue
+++ b/frontend/src/components/Top30Panel.vue
@@ -1,9 +1,11 @@