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 @@ @@ -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 @@ + + + + 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 @@