feat: 包信息展示与扫描数据修正

- scanner: 版本数只统计实际存在的 .tgz(修复 playwright-core 5113 版本虚高),解析 description/homepage/repository/author/license 元数据字段
- 前端: 新增 PkgLogo 首字母徽标组件,详情抽屉展示包 logo(主页 favicon 加载失败自动回退)、介绍与主页/仓库/许可胶囊,优化抽屉头部布局
- 全量 UI 改进: 毛玻璃扫描遮罩与进度、头像菜单重新扫描、主题切换、移动端 Tabbar/StatMini/Top30Panel 响应式、列表瘦身与详情按需加载
- compose: TZ=Asia/Shanghai + 命名卷 vcm-data 解决 SQLite 权限
- 更新 amd64 镜像 tar
This commit is contained in:
陈银军
2026-09-19 21:07:27 +08:00
parent f84f21574f
commit 8a54fb2d5c
21 changed files with 944 additions and 209 deletions
+178 -24
View File
@@ -95,31 +95,20 @@ export function scanStorage(storageDir) {
const packages = [] const packages = []
if (!isValidStorage(storageDir)) return 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) const storageRoot = path.resolve(storageDir)
for (const pkgFile of pkgFiles) { for (const pkgDir of pkgDirs) {
const pkgDir = path.dirname(pkgFile) const pkgFile = path.join(pkgDir, 'package.json')
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'] || {}
// 物理 .tgz 文件:先列目录建立文件名 → 大小索引 // 物理 .tgz 文件:先列目录建立文件名 → 大小索引
// (Verdaccio 各版本命名不同:{version}.tgz 或 {pkgname}-{version}.tgz,不猜、直接扫) // (Verdaccio 各版本命名不同:{version}.tgz 或 {pkgname}-{version}.tgz,不猜、直接扫)
// 先统计再读元数据:即使元数据损坏也能算出真实占用
const tgzSizes = new Map() const tgzSizes = new Map()
let tgzTotal = 0 let tgzTotal = 0
try { try {
@@ -131,8 +120,36 @@ export function scanStorage(storageDir) {
} }
} catch {} } 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 = [] const versionsDetail = []
// 总大小以实际 .tgz 文件为准;unpackedSize 是解压后大小,会数倍虚增,仅作无物理文件时的兜底 // 总大小以实际 .tgz 文件为准
const totalBytes = tgzTotal const totalBytes = tgzTotal
for (const [v, info] of Object.entries(versionsObj)) { for (const [v, info] of Object.entries(versionsObj)) {
let size = tgzSizes.get(v) let size = tgzSizes.get(v)
@@ -145,9 +162,8 @@ export function scanStorage(storageDir) {
if (typeof hit === 'number') size = hit if (typeof hit === 'number') size = hit
} }
} }
if (typeof size !== 'number') { // 无物理 .tgz 文件的版本是元数据残留(实体已被清理),不计数,避免版本数虚高
size = (info && info.dist && typeof info.dist.unpackedSize === 'number') ? info.dist.unpackedSize : 0 if (typeof size !== 'number') continue
}
versionsDetail.push({ versionsDetail.push({
version: parseVersion(v), version: parseVersion(v),
size: fmtMb(size), size: fmtMb(size),
@@ -169,6 +185,11 @@ export function scanStorage(storageDir) {
lastUpdated, lastUpdated,
visits: 0, // 真实环境无访问统计,界面不依赖真实值 visits: 0, // 真实环境无访问统计,界面不依赖真实值
distTags, distTags,
description,
homepage,
repository,
author,
license,
versionsDetail, versionsDetail,
}) })
} }
@@ -176,6 +197,115 @@ export function scanStorage(storageDir) {
return packages 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 模式数据(无真实目录时的演示) —— // —— demo 模式数据(无真实目录时的演示) ——
const DEMO_SEED = [ const DEMO_SEED = [
['lodash', 25, 4, 12456], ['@antv/g2', 32, 12, 7654], ['axios', 18, 1.2, 9832], ['lodash', 25, 4, 12456], ['@antv/g2', 32, 12, 7654], ['axios', 18, 1.2, 9832],
@@ -212,10 +342,29 @@ function genVersions(count) {
return list 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() { export function demoPackages() {
const seen = new Set() const seen = new Set()
return DEMO_SEED.filter(([n]) => !seen.has(n) && seen.add(n)).map(([name, verCount, sizeBase, visits]) => { return DEMO_SEED.filter(([n]) => !seen.has(n) && seen.add(n)).map(([name, verCount, sizeBase, visits]) => {
const detail = genVersions(verCount) const detail = genVersions(verCount)
const meta = DEMO_META[name] || {}
return { return {
name, name,
versions: detail.length, versions: detail.length,
@@ -223,6 +372,11 @@ export function demoPackages() {
lastUpdated: detail.length ? detail[detail.length - 1].date : null, lastUpdated: detail.length ? detail[detail.length - 1].date : null,
visits, visits,
distTags: { latest: detail.length ? detail[detail.length - 1].version : '1.0.0' }, 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, versionsDetail: detail,
} }
}) })
+142 -15
View File
@@ -10,13 +10,14 @@
import express from 'express' import express from 'express'
import path from 'node:path' import path from 'node:path'
import fs from 'node:fs' import fs from 'node:fs'
import { execSync } from 'node:child_process'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { import {
getSetting, setSetting, getIgnored, getIgnoredRows, addIgnored, removeIgnored, getSetting, setSetting, getIgnored, getIgnoredRows, addIgnored, removeIgnored,
addLog, getLogs, addLog, getLogs,
} from './db.js' } from './db.js'
import { import {
resolveStorageDir, isValidStorage, validateStorage, scanStorage, resolveStorageDir, isValidStorage, validateStorage, scanStorage, scanStorageAsync,
demoPackages, demoSummary, demoPackages, demoSummary,
} from './scanner.js' } from './scanner.js'
import { cleanPackage, deleteSingleVersion } from './clean.js' import { cleanPackage, deleteSingleVersion } from './clean.js'
@@ -41,14 +42,79 @@ function isDemoMode() {
// —— 扫描结果缓存:真实目录全量扫描 IO 重,缓存避免每次请求/刷新页面重扫 —— // —— 扫描结果缓存:真实目录全量扫描 IO 重,缓存避免每次请求/刷新页面重扫 ——
let rawCache = null let rawCache = null
let rawCacheAt = 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() { function getRawPackages() {
if (isDemoMode()) return demoPackages() if (isDemoMode()) return demoPackages()
if (!rawCache) { if (!rawCache) {
rawCache = scanStorage(currentStorage()) // 懒加载:后台启动扫描,本次请求返回空列表,由前端轮询进度后刷新
rawCacheAt = new Date().toISOString() ensureScan()
} }
return rawCache return rawCache
} }
function invalidateScan() { function invalidateScan() {
rawCache = null rawCache = null
rawCacheAt = null rawCacheAt = null
@@ -60,7 +126,7 @@ function buildPackages(keyword = '') {
const ignoredRows = getIgnoredRows() const ignoredRows = getIgnoredRows()
const ignoredSet = new Set(ignoredRows.map(r => r.pkg_name)) 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 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 list = raw.map(p => {
let status let status
@@ -76,18 +142,14 @@ function buildPackages(keyword = '') {
distTags: p.distTags || {}, distTags: p.distTags || {},
status, status,
ignoredAt: ignoredAtMap[p.name] || null, ignoredAt: ignoredAtMap[p.name] || null,
versionsDetail: p.versionsDetail || [], // 列表不携带 versionsDetail(包多时响应巨大拖慢加载),详情走 GET /api/packages/:name
} }
}) })
// 检索:不区分大小写子串,匹配包名 / 版本号 / 文件大小文本 // 检索:不区分大小写子串,匹配包名(版本号匹配由前端本地完成,避免列表携带明细)
const kw = keyword.trim().toLowerCase() const kw = keyword.trim().toLowerCase()
if (kw) { if (kw) {
list = list.filter(p => list = list.filter(p => p.name.toLowerCase().includes(kw) || String(p.size).includes(kw))
p.name.toLowerCase().includes(kw) ||
(p.versionsDetail || []).some(v => v.version.toLowerCase().includes(kw)) ||
String(p.size).includes(kw),
)
} }
// 排序:预警置顶 → 正常 → 忽略 → 异常;同状态按版本数降序 // 排序:预警置顶 → 正常 → 忽略 → 异常;同状态按版本数降序
@@ -164,18 +226,64 @@ app.get('/api/packages', (req, res) => {
storageMessage: validation.message, storageMessage: validation.message,
threshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)), threshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)),
scannedAt: demo ? new Date().toISOString() : (rawCacheAt || null), 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, packages,
...(demo ? demoSummary() : {}), ...(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) => { app.post('/api/scan', (req, res) => {
invalidateScan() invalidateScan()
const demo = isDemoMode() const demo = isDemoMode()
const scannedAt = new Date().toISOString() if (demo) {
if (!demo) getRawPackages() // 同步重扫并填充缓存 const scannedAt = new Date().toISOString()
res.json({ ok: true, demo, scannedAt }) 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:清理 —— // —— API:清理 ——
@@ -260,8 +368,27 @@ app.listen(PORT, () => {
console.log(`[verdaccio-manager] listening on :${PORT} (mode=${mode})`) console.log(`[verdaccio-manager] listening on :${PORT} (mode=${mode})`)
if (mode === 'storage') console.log(`[verdaccio-manager] storage: ${storage}`) if (mode === 'storage') console.log(`[verdaccio-manager] storage: ${storage}`)
else console.log('[verdaccio-manager] 未配置 VERDACCIO_STORAGE,运行于演示模式(设置 VCM_DEMO=0 + 真实目录可关闭)') 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) { function clampInt(v, min, max) {
const n = Number(v) const n = Number(v)
if (Number.isNaN(n)) return min if (Number.isNaN(n)) return min
+2 -2
View File
@@ -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` | | 2 | 全局检索 | 48px 大圆角搜索框、快捷筛选 pill、无边框结果表格 | `/search` |
| 3 | 阈值配置 | 全局阈值卡×2(版本数/大小,−/+ 步进器、恢复默认、保存)、单包例外表格、行内添加例外表单 | `/thresholds` | | 3 | 阈值配置 | 全局阈值卡×2(版本数/大小,−/+ 步进器、恢复默认、保存)、单包例外表格、行内添加例外表单 | `/thresholds` |
| 4 | 包详情抽屉 | 600px 右侧滑出,包信息 + 版本列表(单版本删除、清理全部、忽略) | 覆盖层 | | 4 | 包详情抽屉 | 600px 右侧滑出,包信息 + 版本列表(单版本删除、清理全部、忽略) | 覆盖层 |
@@ -158,7 +158,7 @@ CREATE TABLE logs (id INTEGER PRIMARY KEY AUTOINCREMENT, pkg_name TEXT,
| 辅助指标 Stat2 | 5 个轻量小卡(访问排行/今日下载/最大包/最老包/版本数分布) | | 辅助指标 Stat2 | 5 个轻量小卡(访问排行/今日下载/最大包/最老包/版本数分布) |
| 预警卡 Alert | 红色渐变边框 + 呼吸光晕 + 立即清理按钮 | | 预警卡 Alert | 红色渐变边框 + 呼吸光晕 + 立即清理按钮 |
| 清理卡 Clean | 橙色渐变 + 可清理空间 + 立即清理按钮 | | 清理卡 Clean | 橙色渐变 + 可清理空间 + 立即清理按钮 |
| Top30 排行 | 表格:金银铜徽章、包名(scoped 省略+tooltip)、访问次数、热度进度条 | | Top20 排行 | 表格:金银铜徽章、包名(scoped 省略+tooltip)、访问次数、热度进度条 |
| 包卡片 | 图标、包名、版本数/占用/最近更新、状态标签、清理/忽略按钮;warn 卡呼吸光晕 | | 包卡片 | 图标、包名、版本数/占用/最近更新、状态标签、清理/忽略按钮;warn 卡呼吸光晕 |
| 搜索框 | 48px 高、大圆角、放大镜 + 快捷键提示;focus 橙色光圈 | | 搜索框 | 48px 高、大圆角、放大镜 + 快捷键提示;focus 橙色光圈 |
| Pill 筛选 | 状态过滤 + 排序切换,选中态绿色 | | Pill 筛选 | 状态过滤 + 排序切换,选中态绿色 |
+23 -24
View File
@@ -381,18 +381,17 @@ C端体验优先:拒绝千篇一律的后台管理系统风格,打造轻盈
不需要”设置”菜单:阈值配置已经覆盖了核心设置项,其他系统级配置(如 Verdaccio 连接地址)可以在首次启动时通过弹窗配置,或放在用户头像的下拉菜单中。 不需要”设置”菜单:阈值配置已经覆盖了核心设置项,其他系统级配置(如 Verdaccio 连接地址)可以在首次启动时通过弹窗配置,或放在用户头像的下拉菜单中。
不需要”用户管理”菜单:本工具定位为个人/小团队内部使用,暂不涉及多用户权限管理。 不需要”用户管理”菜单:本工具定位为个人/小团队内部使用,暂不涉及多用户权限管理。
### 三、缓存概览页 ### 三、缓存概览页
#### 3.1 统计指标(共 9 个) #### 3.1 统计指标(共 8 个)
| 序号 | 指标 | 图标 | 说明 | | 序号 | 指标 | 图标 | 说明 |
|---|---|---|---| |---|---|---|---|
| 1 | 包的数量 | | 缓存包总数 | | 1 | 包的数量 | | 缓存包总数 |
| 2 | 占用空间 | | 总占用空间 | | 2 | 占用空间 | | 总占用空间 |
| 3 | 预警数量 | | 触发阈值的包数量 | | 3 | 预警数量 | | 触发阈值的包数量 |
| 4 | 访问排行 | | Top 30 包访问次数 | | 4 | 今日更新包数 | | 最近更新时间等于今天的包数量 |
| 5 | 今日/本周下载量 | | 当天或本周下载总次数 | | 5 | 可清理空间 | | 预估可释放的空间 |
| 6 | 可清理空间 | | 预估可释放的空间 | | 6 | 最大包 | | 占用空间最大的包 |
| 7 | 最大包 | | 占用空间最大的包 | | 7 | 最老包时间 | | 最久未更新的包的时间 |
| 8 | 最老包时间 | | 最久未更新的包的时间 | | 8 | 版本数分布 | | 单包版本数分布情况 |
| 9 | 版本数分布 | | 单包版本数分布情况 |
#### 3.2 布局设计 #### 3.2 布局设计
##### 第一行(核心指标 · 4 个) ##### 第一行(核心指标 · 4 个)
@@ -402,27 +401,27 @@ C端体验优先:拒绝千篇一律的后台管理系统风格,打造轻盈
│ 清新绿 │ 活力橙 │ 热情红呼吸 │ 静谧蓝 │ │ 清新绿 │ 活力橙 │ 热情红呼吸 │ 静谧蓝 │
│ │ │ │ [立即清理] │ │ │ │ │ [立即清理] │
└──────────────┴──────────────┴──────────────┴──────────────┘ └──────────────┴──────────────┴──────────────┴──────────────┘
指标说明: - 包的数量:扫描 storage/ 目录下的包文件夹数量 - 占用空间:递归计算 storage/ 下所有 .tgz 文件总大小 - 预警数量:对比阈值配置,统计触发条件的包数量 - 可清理空间:根据阈值规则,预估可释放的空间,给用户”清理动力” - 访问排行:点击可展开 Top 30 详情 指标说明: - 包的数量:扫描 storage/ 目录下的包文件夹数量 - 占用空间:递归计算 storage/ 下所有 .tgz 文件总大小 - 预警数量:对比阈值配置,统计触发条件的包数量 - 可清理空间:根据阈值规则,预估可释放的空间,给用户”清理动力” - 今日更新包数:最近更新时间等于今天的包数量
##### 第二行(辅助指标 · 5 个) ##### 第二行(辅助指标 · 5 个)
┌──────────────┬──────────────┬──────────────┬──────────────┬──────────────┐ ┌──────────────┬──────────────┬──────────────┬──────────────┬──────────────┐
│ 访问排行 │ 今日下载量 │ 最大包 │ 最老包时间 │ 版本数分布 │ │ 今日更新包数 │ 缓存版本总数 │ 最大包 │ 最老包时间 │ 版本数分布 │
│ Top 30 │ 1,234 │ tensorflow │ 3 年前 │ >50版本: 12个│ │ 12 │ 86.1 万 │ tensorflow │ 3 年前 │ >50版本: 12个│
│ [展开] │ 2.1 GB │ 2.1 GB │ legacy-utils │ [查看详情] │ │ [查看] │ 所有版本 │ 2.1 GB │ legacy-utils │ [查看详情] │
└──────────────┴──────────────┴──────────────┴──────────────┴──────────────┘ └──────────────┴──────────────┴──────────────┴──────────────┴──────────────┘
#### 3.3 访问次数 Top 30 排行 #### 3.3 缓存占用 Top 20 排行
点击”访问排行”卡片后,下方滑出排行榜,采用表格 + 进度条形式: 概览页常驻展示占用空间最大的 20 个包,采用表格形式(真实环境无访问统计时按占用大小排序):
┌────────────────────────────────────────────────────────────────┐ ┌────────────────────────────────────────────────────────────────┐
│ 访问次数 Top 30 │ │ 缓存占用 Top 20 │
├──────┬──────────────────────┬──────────┬───────────────────────┤ ├──────┬──────────────────────┬──────────┤
│ 排名 │ 包名 │ 访问次数 │ 热度 │ │ 排名 │ 包名 │ 占用空间 │
├──────┼──────────────────────┼──────────┼───────────────────────┤ ├──────┼──────────────────────┼──────────┤
│ │ lodash │ 12,456 │ ████████████░░ 85% │ │ │ tensorflow │ 2.1 GB │
│ │ axios │ 9,832 │ █████████░░░ 72% │ │ │ @antv/g2 │ 1.4 GB │
│ │ @antv/g2 │ 7,654 │ ███████░░░░░ 58% │ │ │ lodash │ 854 MB │
│ 4 │ react │ 6,543 │ ██████░░░░░░ 49% │ │ 4 │ react │ 621 MB │
│ ... │ ... │ ... │ ... │ │ ... │ ... │ ... │
└──────┴──────────────────────┴──────────┴───────────────────────┘ └──────┴──────────────────────┴──────────┘
设计要点: - 前三名用金银铜标识,多巴胺配色高亮 - 热度进度条直观展示相对访问频率 - 包名支持 scoped(如 @antv/g2),超出宽度省略 + tooltip - 点击包名可跳转到该包的详情卡片 设计要点: - 前三名用金银铜标识,多巴胺配色高亮 - 包名支持 scoped(如 @antv/g2),超出宽度省略 + tooltip - 点击行可打开该包的详情抽屉
#### 3.4 缓存包列表(卡片形式) #### 3.4 缓存包列表(卡片形式)
以圆角卡片形式展示每个缓存包的核心信息,卡片采用多巴胺配色,按状态排序: 以圆角卡片形式展示每个缓存包的核心信息,卡片采用多巴胺配色,按状态排序:
┌──────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────┐
+1
View File
@@ -7,6 +7,7 @@ services:
environment: environment:
- VERDACCIO_STORAGE=/verdaccio/storage # 容器内路径,对应下方只读挂载的右侧 - VERDACCIO_STORAGE=/verdaccio/storage # 容器内路径,对应下方只读挂载的右侧
- PORT=3000 - PORT=3000
- TZ=Asia/Shanghai # 每日 00:00 自动扫描按北京时间执行
volumes: volumes:
- "/volume2/docker/verdaccio_verdaccio-1/verdaccio_storage:/verdaccio/storage:ro" # Verdaccio 缓存目录(只读) - "/volume2/docker/verdaccio_verdaccio-1/verdaccio_storage:/verdaccio/storage:ro" # Verdaccio 缓存目录(只读)
- "vcm-data:/app/data" # 读写:SQLite 数据(named volume,避免宿主目录权限问题) - "vcm-data:/app/data" # 读写:SQLite 数据(named volume,避免宿主目录权限问题)
+136 -3
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import AppTopbar from './components/AppTopbar.vue' import AppTopbar from './components/AppTopbar.vue'
import MobileTabbar from './components/MobileTabbar.vue' import MobileTabbar from './components/MobileTabbar.vue'
@@ -7,12 +7,33 @@ import SettingDrawer from './components/SettingDrawer.vue'
import { useStore } from './store' import { useStore } from './store'
const route = useRoute() const route = useRoute()
const { refreshAll } = useStore() const { state, refreshAll, scanNow } = useStore()
const settingsOpen = ref(false) const settingsOpen = ref(false)
const isLogin = () => route.path === '/login' const isLogin = () => route.path === '/login'
const isMobile = () => window.matchMedia('(max-width: 767px)').matches const isMobile = () => window.matchMedia('(max-width: 767px)').matches
// 数据基于何时扫描(后端扫描时间,缓存复用)
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 doRescan() {
await scanNow()
}
// 扫描进度字节数 → 可读文本
const fmtScanBytes = computed(() => {
const b = state.scanProgress.bytes || 0
const mb = b / 1024 / 1024
if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB`
return `${mb.toFixed(1)} MB`
})
onMounted(() => { onMounted(() => {
if (!isLogin()) refreshAll() if (!isLogin()) refreshAll()
}) })
@@ -23,7 +44,7 @@ onMounted(() => {
<div v-if="!isLogin()"> <div v-if="!isLogin()">
<!-- 桌面/平板顶栏 --> <!-- 桌面/平板顶栏 -->
<AppTopbar class="desktop-topbar" @open-settings="settingsOpen = true" /> <AppTopbar class="desktop-topbar" @open-settings="settingsOpen = true" @rescan="doRescan" />
<!-- 移动端顶栏:☰ + 品牌 --> <!-- 移动端顶栏:☰ + 品牌 -->
<header class="m-topbar"> <header class="m-topbar">
<div class="m-brand"> <div class="m-brand">
@@ -32,6 +53,25 @@ onMounted(() => {
</div> </div>
<div class="m-menu" @click="settingsOpen = true">☰</div> <div class="m-menu" @click="settingsOpen = true">☰</div>
</header> </header>
<!-- 导航栏下方:扫描信息条 -->
<div class="scan-banner" v-if="state.loading || state.scannedAt">
<span v-if="state.loading" class="scan-banner-loading">⏳ 正在扫描缓存目录,请稍候…</span>
<span v-else>🕐 数据基于 {{ fmtScanTime }} 扫描</span>
</div>
<!-- 扫描遮罩:毛玻璃 + loading + 实时进度 -->
<div v-if="state.loading" class="scan-mask">
<div class="scan-card">
<div class="scan-spinner"></div>
<div class="scan-title">正在扫描缓存目录…</div>
<div class="scan-info">
已扫描 <b>{{ state.scanProgress.packages.toLocaleString() }}</b> 个包
<span class="dot">·</span> <b>{{ fmtScanBytes }}</b>
</div>
<div class="scan-bar"><i></i></div>
</div>
</div>
</div> </div>
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
@@ -47,6 +87,95 @@ onMounted(() => {
<style scoped> <style scoped>
.m-topbar { display: none; } .m-topbar { display: none; }
/* —— 扫描信息条(顶栏下方) —— */
.scan-banner {
display: flex;
align-items: center;
justify-content: flex-start; /* 靠左对齐 */
gap: 8px;
padding: 6px 20px;
font-size: 12px;
color: var(--ink-faint);
background: var(--panel-soft);
border-bottom: 1px solid var(--line-soft);
font-family: var(--num);
}
.scan-banner-loading {
color: var(--orange);
font-weight: 600;
animation: blink 1.2s ease-in-out infinite;
}
/* —— 扫描遮罩(玻璃拟态:半透明背景 + 卡片磨砂模糊 + 细边框/高光)
遮罩层只做半透明(避免全屏 blur 的性能开销与双倍模糊),
真正的毛玻璃效果做在卡片上:半透明背景 + backdrop-filter 模糊 + 细边框 + 高光 —— */
.scan-mask {
position: fixed;
inset: 0;
z-index: 500;
display: grid;
place-items: center;
background: rgba(18, 16, 14, .38);
}
html[data-theme="bright"] .scan-mask { background: rgba(244, 242, 239, .45); }
.scan-card {
position: relative;
background: rgba(28, 26, 24, .55);
backdrop-filter: blur(18px) saturate(1.35);
-webkit-backdrop-filter: blur(18px) saturate(1.35);
border: 1px solid rgba(255, 255, 255, .14);
border-radius: 20px;
box-shadow:
0 24px 60px -16px rgba(0, 0, 0, .35),
inset 0 1px 0 rgba(255, 255, 255, .18);
padding: 32px 44px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
min-width: 300px;
max-width: 92vw;
}
html[data-theme="bright"] .scan-card {
background: rgba(255, 255, 255, .55);
border-color: rgba(255, 255, 255, .55);
box-shadow:
0 24px 60px -16px rgba(0, 0, 0, .22),
inset 0 1px 0 rgba(255, 255, 255, .85);
}
.scan-spinner {
width: 40px;
height: 40px;
border-radius: 50%;
border: 4px solid var(--line);
border-top-color: var(--orange);
animation: spin360 .8s linear infinite;
}
.scan-title { font-size: 15px; font-weight: 700; }
.scan-info { font-family: var(--num); font-size: 12.5px; color: var(--ink-dim); }
.scan-info b { color: var(--orange); font-weight: 700; }
.scan-info .dot { margin: 0 4px; color: var(--ink-faint); }
.scan-bar {
width: 100%;
height: 6px;
border-radius: 99px;
background: var(--num-bg);
overflow: hidden;
margin-top: 6px;
}
.scan-bar i {
display: block;
height: 100%;
width: 42%;
border-radius: 99px;
background: linear-gradient(90deg, transparent, var(--orange), transparent);
animation: scan-slide 1.1s ease-in-out infinite;
}
@keyframes scan-slide {
from { transform: translateX(-110%); }
to { transform: translateX(340%); }
}
@media (max-width: 767px) { @media (max-width: 767px) {
.desktop-topbar { display: none; } .desktop-topbar { display: none; }
.m-topbar { .m-topbar {
@@ -60,6 +189,10 @@ onMounted(() => {
top: 0; top: 0;
z-index: 100; z-index: 100;
} }
.scan-banner {
padding: 5px 16px;
font-size: 11px;
}
.m-brand { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 700; } .m-brand { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 700; }
.m-logo { .m-logo {
width: 30px; width: 30px;
+32 -4
View File
@@ -67,23 +67,51 @@ const api = {
let list = clone(mockState.packages) let list = clone(mockState.packages)
list = list.map(p => ({ ...p, status: computeStatus(p, mockState.versionThreshold) })) list = list.map(p => ({ ...p, status: computeStatus(p, mockState.versionThreshold) }))
if (kw) list = list.filter(p => p.name.toLowerCase().includes(kw)) if (kw) list = list.filter(p => p.name.toLowerCase().includes(kw))
return { packages: sortPackages(list), scannedAt: new Date().toISOString() } return { packages: sortPackages(list), scannedAt: new Date().toISOString(), scanning: false }
} }
const r = await fetch(`/api/packages?keyword=${encodeURIComponent(keyword)}`) const r = await fetch(`/api/packages?keyword=${encodeURIComponent(keyword)}`)
const data = await r.json() const data = await r.json()
return { packages: data.packages, scannedAt: data.scannedAt || null } return {
packages: data.packages || [],
scannedAt: data.scannedAt || null,
scanning: data.scanning || false,
scanPackages: data.scanPackages || 0,
scanBytes: data.scanBytes || 0,
totalBytes: data.totalBytes || null,
}
}, },
/** 手动触发后端重新扫描缓存目录 */ /** 单包详情(含版本明细),抽屉打开时按需加载 */
async getPackageDetail(name) {
if (USE_MOCK) {
await delay(80)
const p = clone(mockState.packages).find(x => x.name === name) || null
return { ok: !!p, pkg: p }
}
const r = await fetch(`/api/packages/${encodeURIComponent(name)}`)
if (!r.ok) return { ok: false, pkg: null }
return r.json()
},
/** 手动触发后端重新扫描缓存目录(异步,返回后需轮询进度) */
async scanNow() { async scanNow() {
if (USE_MOCK) { if (USE_MOCK) {
await delay(300) await delay(300)
return { ok: true, scannedAt: new Date().toISOString() } return { ok: true, scanning: false, scannedAt: new Date().toISOString() }
} }
const r = await fetch('/api/scan', { method: 'POST' }) const r = await fetch('/api/scan', { method: 'POST' })
return r.json() return r.json()
}, },
/** 查询扫描进度(包数 / 字节数 / 是否完成) */
async getScanProgress() {
if (USE_MOCK) {
return { scanning: false, done: true, packages: 33, bytes: 0 }
}
const r = await fetch('/api/scan/progress')
return r.json()
},
async getThresholds() { async getThresholds() {
if (USE_MOCK) { if (USE_MOCK) {
await delay(80) await delay(80)
+103 -14
View File
@@ -1,4 +1,5 @@
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useTheme } from '../composables/useTheme' import { useTheme } from '../composables/useTheme'
import { useStore } from '../store' import { useStore } from '../store'
@@ -8,7 +9,9 @@ const router = useRouter()
const { theme, toggle } = useTheme() const { theme, toggle } = useTheme()
const { state } = useStore() const { state } = useStore()
defineEmits(['open-settings']) const emit = defineEmits(['open-settings', 'rescan'])
const menuOpen = ref(false)
const navs = [ const navs = [
{ path: '/', label: '概览', emo: '🏠' }, { path: '/', label: '概览', emo: '🏠' },
@@ -21,6 +24,22 @@ const navs = [
function go(path) { function go(path) {
if (route.path !== path) router.push(path) if (route.path !== path) router.push(path)
} }
function pick(action) {
menuOpen.value = false
if (action === 'rescan') emit('rescan')
else if (action === 'settings') emit('open-settings')
else if (action === 'logout') {
localStorage.removeItem('vcm-user')
router.push('/login')
}
}
function onDocClick(e) {
if (!e.target.closest('.avatar-wrap')) menuOpen.value = false
}
onMounted(() => document.addEventListener('click', onDocClick))
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
</script> </script>
<template> <template>
@@ -46,10 +65,22 @@ function go(path) {
</nav> </nav>
<div class="topbar-right"> <div class="topbar-right">
<button class="theme-toggle" :title="theme === 'dark' ? '切换到亮色' : '切换到暗色'" @click="toggle"> <button
{{ theme === 'dark' ? '☀️ 切到亮色' : '🌙 切到暗色' }} class="theme-toggle"
</button> :title="theme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'"
<div class="avatar" title="设置" @click="$emit('open-settings')">👤</div> :aria-label="theme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'"
@click="toggle"
>{{ theme === 'dark' ? '☀️' : '🌙' }}</button>
<div class="avatar-wrap">
<div class="avatar" title="用户菜单" @click="menuOpen = !menuOpen">👤</div>
<transition name="pop">
<div v-if="menuOpen" class="avatar-menu">
<button class="am-item" :disabled="state.loading" @click="pick('rescan')">{{ state.loading ? '⏳ 扫描中…' : '🔄 重新扫描' }}</button>
<button class="am-item" @click="pick('settings')">⚙️ 设置</button>
<button class="am-item danger" @click="pick('logout')">🚪 退出登录</button>
</div>
</transition>
</div>
</div> </div>
</header> </header>
</template> </template>
@@ -145,19 +176,17 @@ function go(path) {
cursor: pointer; cursor: pointer;
} }
.theme-toggle { .theme-toggle {
display: inline-flex; width: 36px;
align-items: center; height: 36px;
gap: 7px; border-radius: 50%;
display: grid;
place-items: center;
border: 1.5px solid var(--line); border: 1.5px solid var(--line);
background: var(--toggle-bg); background: var(--toggle-bg);
color: var(--ink-dim); color: var(--ink-dim);
font-size: 12.5px; font-size: 16px;
font-weight: 600;
padding: 8px 15px;
border-radius: 99px;
cursor: pointer; cursor: pointer;
transition: .16s; transition: .16s;
white-space: nowrap;
} }
.theme-toggle:hover { .theme-toggle:hover {
border-color: var(--orange); border-color: var(--orange);
@@ -166,12 +195,72 @@ function go(path) {
transform: translateY(-1px); 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):压缩导航 */ /* 平板(768~1023):压缩导航 */
@media (max-width: 1023px) { @media (max-width: 1023px) {
.topbar { padding: 0 16px; gap: 12px; } .topbar { padding: 0 16px; gap: 12px; }
.brand-sub { display: none; } .brand-sub { display: none; }
.nav-item { padding: 8px 13px; font-size: 13px; } .nav-item { padding: 8px 13px; font-size: 13px; }
.theme-toggle span { display: none; }
} }
/* 手机 <768:顶栏转为 M-Topbar(☰ + 品牌) */ /* 手机 <768:顶栏转为 M-Topbar(☰ + 品牌) */
+2 -1
View File
@@ -8,7 +8,8 @@ const tabs = [
{ path: '/', label: '概览', emo: '🏠' }, { path: '/', label: '概览', emo: '🏠' },
{ path: '/search', label: '检索', emo: '🔍' }, { path: '/search', label: '检索', emo: '🔍' },
{ path: '/thresholds', label: '阈值', emo: '⚙️' }, { path: '/thresholds', label: '阈值', emo: '⚙️' },
{ path: '/logs', label: '动态', emo: '🕒' }, { path: '/logs', label: '操作日志', emo: '🕒' },
{ path: '/ignored', label: '已忽略', emo: '🚫' },
] ]
function go(path) { function go(path) {
+2 -13
View File
@@ -1,6 +1,6 @@
<script setup> <script setup>
import StatusTag from './StatusTag.vue' import StatusTag from './StatusTag.vue'
import { iconOf } from '../mock/data.js' import PkgLogo from './PkgLogo.vue'
defineProps({ defineProps({
pkg: { type: Object, required: true }, pkg: { type: Object, required: true },
@@ -22,7 +22,7 @@ function splitName(name) {
@click="emit('open', pkg)" @click="emit('open', pkg)"
> >
<div class="p-top"> <div class="p-top">
<div class="pkg-ico">{{ iconOf(pkg.name) }}</div> <PkgLogo :name="pkg.name" :size="44" />
<div style="flex: 1; min-width: 0"> <div style="flex: 1; min-width: 0">
<div class="pkg-name"> <div class="pkg-name">
<span class="nm"> <span class="nm">
@@ -87,17 +87,6 @@ function splitName(name) {
.pkg-card.ignored { background: var(--ignored-card); opacity: .82; } .pkg-card.ignored { background: var(--ignored-card); opacity: .82; }
.pkg-card.broken { border-style: dashed; } .pkg-card.broken { border-style: dashed; }
.p-top { display: flex; align-items: center; gap: 12px; } .p-top { display: flex; align-items: center; gap: 12px; }
.pkg-ico {
width: 44px;
height: 44px;
border-radius: 14px;
display: grid;
place-items: center;
font-size: 20px;
background: var(--grad-pkg-ico);
box-shadow: inset 0 0 0 1.5px rgba(255, 140, 0, .18);
flex-shrink: 0;
}
.pkg-name { .pkg-name {
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
+103 -18
View File
@@ -1,6 +1,8 @@
<script setup> <script setup>
import { ref, computed, watch } from 'vue'
import StatusTag from './StatusTag.vue' import StatusTag from './StatusTag.vue'
import { iconOf } from '../mock/data.js' import PkgLogo from './PkgLogo.vue'
import api from '../api'
const props = defineProps({ const props = defineProps({
visible: Boolean, visible: Boolean,
@@ -9,6 +11,38 @@ const props = defineProps({
}) })
const emit = defineEmits(['update:visible', 'clean', 'ignore', 'unignore', 'delete-version']) const emit = defineEmits(['update:visible', 'clean', 'ignore', 'unignore', 'delete-version'])
// 版本明细按需加载:列表接口不含明细,打开抽屉时异步获取
const loadingVer = ref(false)
const verFail = ref(false)
const detailPkg = ref(null)
const verList = computed(() => detailPkg.value?.versionsDetail || props.pkg?.versionsDetail || [])
// 包信息(介绍/主页/仓库/许可/作者):列表接口不含,来自详情接口或列表已有字段
const desc = computed(() => detailPkg.value?.description || props.pkg?.description || '')
const homepage = computed(() => detailPkg.value?.homepage || props.pkg?.homepage || '')
const repo = computed(() => detailPkg.value?.repository || props.pkg?.repository || '')
const license = computed(() => detailPkg.value?.license || props.pkg?.license || '')
const author = computed(() => detailPkg.value?.author || props.pkg?.author || '')
const hasMeta = computed(() => homepage.value || repo.value || license.value || author.value)
const repoUrl = computed(() => repo.value.replace(/^git\+/, '').replace(/\.git$/, ''))
watch(() => props.pkg, async (p) => {
verFail.value = false
detailPkg.value = null
if (!p) { loadingVer.value = false; return }
if (Array.isArray(p.versionsDetail) && p.versionsDetail.length) { loadingVer.value = false; return }
loadingVer.value = true
try {
const d = await api.getPackageDetail(p.name)
if (d.ok && d.pkg) detailPkg.value = d.pkg
else verFail.value = true
} catch {
verFail.value = true
} finally {
loadingVer.value = false
}
}, { immediate: true })
function splitName(name) { function splitName(name) {
if (!name) return { scope: '', base: '' } if (!name) return { scope: '', base: '' }
const i = name.lastIndexOf('/') const i = name.lastIndexOf('/')
@@ -34,15 +68,15 @@ function delVersion(v) {
<aside v-if="visible && pkg" class="drawer"> <aside v-if="visible && pkg" class="drawer">
<div class="drawer-head"> <div class="drawer-head">
<div class="drawer-title"> <div class="drawer-title">
<div class="pkg-ico">{{ iconOf(pkg.name) }}</div> <PkgLogo :name="pkg.name" :size="46" :homepage="homepage" />
<div> <div style="min-width: 0">
<div style="display:flex;align-items:center;gap:9px;flex-wrap:wrap"> <div style="display:flex;align-items:center;gap:9px;flex-wrap:wrap">
<span style="font-size:17px;font-weight:700"> <span style="font-size:17px;font-weight:700;letter-spacing:-.2px">
<span v-if="splitName(pkg.name).scope" class="scope">{{ splitName(pkg.name).scope }}</span>{{ splitName(pkg.name).base }} <span v-if="splitName(pkg.name).scope" class="scope">{{ splitName(pkg.name).scope }}</span>{{ splitName(pkg.name).base }}
</span> </span>
<StatusTag :status="pkg.status" /> <StatusTag :status="pkg.status" />
</div> </div>
<div style="font-size:11.5px;color:var(--ink-faint);margin-top:2px">{{ pkg.name }}</div> <p v-if="desc" class="drawer-desc" :title="desc">{{ desc }}</p>
</div> </div>
</div> </div>
<div class="drawer-close" @click="emit('update:visible', false)">✕</div> <div class="drawer-close" @click="emit('update:visible', false)">✕</div>
@@ -68,17 +102,30 @@ function delVersion(v) {
</div> </div>
</div> </div>
<div v-if="hasMeta" class="d-meta">
<a v-if="homepage" class="d-meta-item" :href="homepage" target="_blank" rel="noopener">🔗 主页</a>
<a v-if="repoUrl" class="d-meta-item" :href="repoUrl" target="_blank" rel="noopener">🛠 仓库</a>
<span v-if="license" class="d-meta-item lic">📜 {{ license }}</span>
<span v-if="author" class="d-meta-item lic" :title="author">👤 {{ author }}</span>
</div>
<div class="d-sub"> <div class="d-sub">
版本列表 版本列表
<span class="cnt">{{ pkg.versionsDetail?.length || pkg.versions }} 个版本</span> <span class="cnt">{{ verList.length || pkg.versions }} 个版本</span>
<span v-if="pkg.status === 'warn'" class="excess">超阈值 {{ pkg.versions - threshold }} 个</span> <span v-if="pkg.status === 'warn'" class="excess">超阈值 {{ pkg.versions - threshold }} 个</span>
</div> </div>
<div class="ver-list"> <div class="ver-list">
<div v-if="!pkg.versionsDetail?.length" class="empty-tip"> <div v-if="loadingVer" class="empty-tip load">
{{ pkg.status === 'broken' ? '元数据异常,无法读取版本列表' : '暂无版本信息' }} <span class="spin"></span>正在加载版本信息…
</div> </div>
<div v-for="v in pkg.versionsDetail" :key="v.version" class="ver-row"> <div v-else-if="verFail" class="empty-tip">
🗂️ 版本信息加载失败,请稍后重试
</div>
<div v-else-if="!verList.length" class="empty-tip">
{{ pkg.status === 'broken' ? '⚠️ 元数据异常,无法读取版本列表' : '📭 暂无版本信息' }}
</div>
<div v-for="v in verList" :key="v.version" class="ver-row">
<span class="ver-no">{{ v.version }}</span> <span class="ver-no">{{ v.version }}</span>
<span class="ver-size">{{ v.size }} MB</span> <span class="ver-size">{{ v.size }} MB</span>
<span class="ver-date">{{ fmtDate(v.date) }}</span> <span class="ver-date">{{ fmtDate(v.date) }}</span>
@@ -144,16 +191,38 @@ export default {
} }
.drawer-title { display: flex; align-items: center; gap: 12px; } .drawer-title { display: flex; align-items: center; gap: 12px; }
.drawer-title .scope { color: var(--ink-faint); font-weight: 500; } .drawer-title .scope { color: var(--ink-faint); font-weight: 500; }
.pkg-ico { .drawer-desc {
width: 42px; font-size: 12.5px;
height: 42px; color: var(--ink-dim);
border-radius: 13px; margin: 6px 0 0;
display: grid; line-height: 1.55;
place-items: center; max-width: 430px;
font-size: 19px; display: -webkit-box;
background: var(--grad-pkg-ico); -webkit-line-clamp: 2;
flex-shrink: 0; -webkit-box-orient: vertical;
overflow: hidden;
} }
.d-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 18px;
}
.d-meta-item {
font-size: 12px;
color: var(--blue);
background: var(--blue-soft);
padding: 5px 12px;
border-radius: 99px;
text-decoration: none;
font-weight: 600;
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.d-meta-item:hover { filter: brightness(.94); }
.d-meta-item.lic { color: var(--ink-dim); background: var(--num-bg); cursor: default; }
.drawer-close { .drawer-close {
width: 32px; width: 32px;
height: 32px; height: 32px;
@@ -219,6 +288,22 @@ export default {
color: var(--ink-faint); color: var(--ink-faint);
font-size: 12.5px; font-size: 12.5px;
} }
.empty-tip.load {
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
}
.spin {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid var(--line);
border-top-color: var(--orange);
animation: spin360 .8s linear infinite;
display: inline-block;
flex-shrink: 0;
}
.ver-row { .ver-row {
display: flex; display: flex;
align-items: center; align-items: center;
+77
View File
@@ -0,0 +1,77 @@
<script setup>
// 包 logo:优先取主页域名的 favicon(浏览器端加载,离线/加载失败回退首字母徽标)
import { computed, ref, watch } from 'vue'
const props = defineProps({
name: { type: String, default: '' },
size: { type: Number, default: 36 },
homepage: { type: String, default: '' },
})
const letter = computed(() => {
const base = (props.name.split('/').pop() || props.name || '?').trim()
return (base[0] || '?').toUpperCase()
})
const style = computed(() => {
let h = 0
const s = props.name || 'pkg'
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360
const h2 = (h + 45) % 360
return {
width: `${props.size}px`,
height: `${props.size}px`,
fontSize: `${Math.round(props.size * 0.46)}px`,
borderRadius: `${Math.round(props.size * 0.3)}px`,
background: `linear-gradient(135deg, hsl(${h}, 62%, 52%), hsl(${h2}, 68%, 42%))`,
}
})
// favicon 来源:主页域名根路径 /favicon.ico(不依赖第三方服务,站点没有时回退徽标)
const imgOk = ref(false)
const faviconUrl = computed(() => {
if (!props.homepage) return ''
try {
return new URL(props.homepage).origin + '/favicon.ico'
} catch {
return ''
}
})
watch(faviconUrl, () => { imgOk.value = false })
function onImgOk() { imgOk.value = true }
function onImgErr() { imgOk.value = false }
</script>
<template>
<span class="pkg-logo" :style="style" :title="name">
<!-- img 始终挂载以触发 load/error;加载成功后显示,失败回退字母徽标 -->
<img v-if="faviconUrl" class="fav" :class="{ on: imgOk }" :src="faviconUrl" alt="" @load="onImgOk" @error="onImgErr" />
<span v-show="!imgOk" class="lt">{{ letter }}</span>
</span>
</template>
<style scoped>
.pkg-logo {
display: inline-grid;
place-items: center;
color: #fff;
font-family: var(--num);
font-weight: 700;
line-height: 1;
flex-shrink: 0;
user-select: none;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .28);
text-shadow: 0 1px 2px rgba(0, 0, 0, .28);
overflow: hidden;
}
.lt { grid-area: 1 / 1; }
.fav {
grid-area: 1 / 1;
width: 100%;
height: 100%;
object-fit: contain;
background: #fff;
opacity: 0;
}
.fav.on { opacity: 1; }
</style>
+11
View File
@@ -75,4 +75,15 @@ const emit = defineEmits(['link'])
.stat2.c-orange .s2-val { color: var(--orange); } .stat2.c-orange .s2-val { color: var(--orange); }
.stat2.c-purple .s2-val { color: var(--purple); } .stat2.c-purple .s2-val { color: var(--purple); }
.stat2.c-pink .s2-val { color: var(--pink); } .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; }
}
</style> </style>
+24 -16
View File
@@ -1,9 +1,11 @@
<script setup> <script setup>
// 排行 Top 30:有访问统计按热度,否则降级为缓存占用(真实环境无访问统计) // 排行 Top 30:有访问统计按热度,否则降级为缓存占用(真实环境无访问统计)
import PkgLogo from './PkgLogo.vue'
const props = defineProps({ const props = defineProps({
top30: { type: Array, default: () => [] }, top30: { type: Array, default: () => [] },
top30By: { type: String, default: 'visits' }, top30By: { type: String, default: 'visits' },
}) })
const emit = defineEmits(['open'])
const isVisits = props.top30By === 'visits' const isVisits = props.top30By === 'visits'
function fmtSize(mb) { function fmtSize(mb) {
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB` if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
@@ -14,7 +16,7 @@ function fmtSize(mb) {
<template> <template>
<div class="top30"> <div class="top30">
<div class="top30-head"> <div class="top30-head">
<div class="t"><span class="ico">🏆</span>{{ isVisits ? '访问次数 Top 30' : '缓存占用 Top 30' }}<span class="tag30">{{ isVisits ? '按热度排序' : '按占用空间排序' }}</span></div> <div class="t"><span class="ico">🏆</span>{{ isVisits ? '访问次数 Top 20' : '缓存占用 Top 20' }}<span class="tag30">{{ isVisits ? '按热度排序' : '按占用空间排序' }}</span></div>
</div> </div>
<div class="tbl-scroll"> <div class="tbl-scroll">
<table class="rank-tbl"> <table class="rank-tbl">
@@ -23,25 +25,25 @@ function fmtSize(mb) {
<th style="width: 70px">排名</th> <th style="width: 70px">排名</th>
<th>包名</th> <th>包名</th>
<th style="width: 130px">{{ isVisits ? '访问次数' : '占用空间' }}</th> <th style="width: 130px">{{ isVisits ? '访问次数' : '占用空间' }}</th>
<th>热度</th> <th v-if="isVisits">热度</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(p, i) in top30" :key="p.name"> <tr v-for="(p, i) in top30" :key="p.name" class="rank-row" @click="emit('open', p)">
<td> <td>
<span class="rank-no" :class="i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : ''">{{ i + 1 }}</span> <span class="rank-no" :class="i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : ''">{{ i + 1 }}</span>
</td> </td>
<td> <td>
<span class="rank-pkg"> <span class="rank-pkg">
<span class="mini-ico">{{ p.emoji || '📦' }}</span> <PkgLogo :name="p.name" :size="30" />
<span class="nm"> <span class="nm">
<span v-if="p.name.includes('/')" class="scope">{{ p.name.slice(0, p.name.lastIndexOf('/') + 1) }}</span>{{ p.name.split('/').pop() }} <span v-if="p.name.includes('/')" class="scope">{{ p.name.slice(0, p.name.lastIndexOf('/') + 1) }}</span>{{ p.name.split('/').pop() }}
</span> </span>
</span> </span>
</td> </td>
<td><span class="rank-visit">{{ isVisits ? (p.visits || 0).toLocaleString() : fmtSize(p.size) }}</span></td> <td><span class="rank-visit">{{ isVisits ? (p.visits || 0).toLocaleString() : fmtSize(p.size) }}</span></td>
<td> <td v-if="isVisits">
<div class="heat"> <div class="heat" title="访问热度占比">
<div class="heat-bar"><i :style="{ width: p.heat + '%' }"></i></div> <div class="heat-bar"><i :style="{ width: p.heat + '%' }"></i></div>
<span class="heat-pct">{{ p.heat }}%</span> <span class="heat-pct">{{ p.heat }}%</span>
</div> </div>
@@ -103,6 +105,7 @@ function fmtSize(mb) {
.rank-tbl tr:last-child td { border-bottom: none; } .rank-tbl tr:last-child td { border-bottom: none; }
.rank-tbl tr { transition: background .15s; } .rank-tbl tr { transition: background .15s; }
.rank-tbl tr:hover { background: var(--bg); } .rank-tbl tr:hover { background: var(--bg); }
.rank-tbl .rank-row { cursor: pointer; }
.rank-no { .rank-no {
width: 30px; width: 30px;
height: 30px; height: 30px;
@@ -119,16 +122,6 @@ function fmtSize(mb) {
.rank-no.silver { background: linear-gradient(135deg, #E8E8EC, #C9C9CF); color: #5A5A62; box-shadow: 0 4px 10px -3px rgba(200, 200, 210, .5); } .rank-no.silver { background: linear-gradient(135deg, #E8E8EC, #C9C9CF); color: #5A5A62; box-shadow: 0 4px 10px -3px rgba(200, 200, 210, .5); }
.rank-no.bronze { background: linear-gradient(135deg, #F2C9A0, #D9A066); color: #6E4520; box-shadow: 0 4px 10px -3px rgba(217, 160, 102, .5); } .rank-no.bronze { background: linear-gradient(135deg, #F2C9A0, #D9A066); color: #6E4520; box-shadow: 0 4px 10px -3px rgba(217, 160, 102, .5); }
.rank-pkg { display: flex; align-items: center; gap: 10px; font-weight: 600; } .rank-pkg { display: flex; align-items: center; gap: 10px; font-weight: 600; }
.mini-ico {
width: 30px;
height: 30px;
border-radius: 9px;
background: var(--orange-soft);
display: grid;
place-items: center;
font-size: 14px;
flex-shrink: 0;
}
.nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.scope { color: var(--ink-faint); font-weight: 500; } .scope { color: var(--ink-faint); font-weight: 500; }
.rank-visit { font-family: var(--num); font-weight: 700; font-size: 14.5px; color: var(--ink); } .rank-visit { font-family: var(--num); font-weight: 700; font-size: 14.5px; color: var(--ink); }
@@ -143,4 +136,19 @@ function fmtSize(mb) {
transform-origin: left; transform-origin: left;
} }
.heat-pct { font-family: var(--num); font-size: 11.5px; color: var(--ink-faint); font-weight: 600; width: 34px; text-align: right; } .heat-pct { font-family: var(--num); font-size: 11.5px; color: var(--ink-faint); font-weight: 600; width: 34px; text-align: right; }
/* —— 移动端:隐藏「热度」列,缩小 padding —— */
@media (max-width: 767px) {
.top30-head { padding: 12px 14px; }
.t { font-size: 13px; gap: 8px; }
.t .ico { font-size: 16px; }
.tag30 { font-size: 10px; padding: 3px 9px; }
.rank-tbl { min-width: 320px; }
.rank-tbl th, .rank-tbl td { padding: 9px 14px; font-size: 12px; }
.rank-tbl th:nth-child(4), .rank-tbl td:nth-child(4) { display: none; } /* 隐藏热度列 */
.rank-no { width: 26px; height: 26px; font-size: 12px; border-radius: 8px; }
.rank-pkg { gap: 7px; font-size: 12.5px; }
.rank-visit { font-size: 13px; }
.heat { display: none; }
}
</style> </style>
+7 -1
View File
@@ -1,13 +1,19 @@
import { ref } from 'vue' import { ref } from 'vue'
const THEME_KEY = 'vcm-theme' const THEME_KEY = 'vcm-theme'
const theme = ref(document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'bright') const saved = localStorage.getItem(THEME_KEY)
const theme = ref(saved === 'dark' ? 'dark' : 'bright')
function apply(t) { function apply(t) {
document.documentElement.setAttribute('data-theme', t) document.documentElement.setAttribute('data-theme', t)
localStorage.setItem(THEME_KEY, t) localStorage.setItem(THEME_KEY, t)
} }
// 初始化即同步 DOM,确保首次点击时 data-theme 属性与状态一致
if (document.documentElement.getAttribute('data-theme') !== theme.value) {
document.documentElement.setAttribute('data-theme', theme.value)
}
export function useTheme() { export function useTheme() {
function toggle() { function toggle() {
theme.value = theme.value === 'dark' ? 'bright' : 'dark' theme.value = theme.value === 'dark' ? 'bright' : 'dark'
+2 -18
View File
@@ -16,15 +16,6 @@ const PKG_POOL = [
'xlsx', 'csv-parse', 'js-yaml', 'fast-glob', 'execa', 'nanoid', 'clsx', 'xlsx', 'csv-parse', 'js-yaml', 'fast-glob', 'execa', 'nanoid', 'clsx',
] ]
const ICON_MAP = {
lodash: '📚', axios: '🔌', react: '⚛️', vue: '💚', express: '🚀', webpack: '🧱',
typescript: '📘', eslint: '🧹', '@antv/g2': '📊', '@antv/g6': '🕸️', tensorflow: '🧠',
sharp: '🖼️', puppeteer: '🤖', ioredis: '🔴', pg: '🐘', three: '🎮', d3: '📉',
echarts: '📈', xlsx: '📑', 'socket.io': '🔌', prisma: '💎', graphql: '🔗',
dayjs: '📅', moment: '⏰', uuid: '🆔', zod: '🛡️', vite: '⚡', rollup: '🌀',
jest: '🃏', mocha: '☕', prettier: '🎀', semver: '🔢', chalk: '🖍️',
}
const SCOPED = new Set(['@antv', '@ant-design', '@vue', '@vueuse', '@babel', '@types', '@element-plus', '@testing-library']) const SCOPED = new Set(['@antv', '@ant-design', '@vue', '@vueuse', '@babel', '@types', '@element-plus', '@testing-library'])
function pad(n) { function pad(n) {
@@ -65,13 +56,6 @@ function genVersions(name, count, baseVersion) {
return list return list
} }
function iconOf(name) {
if (ICON_MAP[name]) return ICON_MAP[name]
const base = name.split('/').pop() || name
if (ICON_MAP[base]) return ICON_MAP[base]
return '📦'
}
function genPackages() { function genPackages() {
const threshold = 15 const threshold = 15
const list = [] const list = []
@@ -152,7 +136,7 @@ const packages = genPackages()
const top30 = [...packages] const top30 = [...packages]
.filter(p => p.status !== 'broken') .filter(p => p.status !== 'broken')
.sort((a, b) => b.visits - a.visits) .sort((a, b) => b.visits - a.visits)
.slice(0, 30) .slice(0, 20)
.map(p => ({ ...p, heat: Math.round((p.visits / topMax(packages)) * 100) })) .map(p => ({ ...p, heat: Math.round((p.visits / topMax(packages)) * 100) }))
function topMax(list) { function topMax(list) {
@@ -189,4 +173,4 @@ function summary() {
} }
} }
export { packages, top30, logs, summary, fmtSize, iconOf } export { packages, top30, logs, summary, fmtSize }
+66 -11
View File
@@ -14,30 +14,42 @@ const state = reactive({
loading: false, loading: false,
scannedAt: null, scannedAt: null,
storageDir: localStorage.getItem('vcm-storage-dir') || '', storageDir: localStorage.getItem('vcm-storage-dir') || '',
// 后端目录扫描总字节(所有文件,与 du 同口径;null 表示未知/演示模式)
totalBytes: null,
// 扫描进度(异步扫描期间实时更新,用于遮罩展示)
scanProgress: { packages: 0, bytes: 0 },
}) })
// —— 派生统计 —— // —— 派生统计 ——
const stats = computed(() => { const stats = computed(() => {
const valid = state.packages.filter(p => p.status !== 'broken') const valid = state.packages.filter(p => p.status !== 'broken')
const totalSize = valid.reduce((s, p) => s + p.size, 0) // 总占用统计全部物理缓存(含元数据异常的包,其大小按实际 .tgz 计算)
const totalSize = state.packages.reduce((s, p) => s + p.size, 0)
const warnCount = valid.filter(p => p.status === 'warn').length const warnCount = valid.filter(p => p.status === 'warn').length
const cleanable = valid.filter(p => p.status === 'warn').reduce((s, p) => s + p.size, 0) 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 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 oldestPkg = [...valid].sort((a, b) => (a.lastUpdated || '').localeCompare(b.lastUpdated || ''))[0] || null
// 真实环境无访问统计(visits 全 0)时,Top30 降级为「缓存占用 Top 30」 // 今日更新包数:最近更新时间等于今天的包数量
const d = new Date()
const todayStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const todayUpdated = valid.filter(p => (p.lastUpdated || '') === todayStr).length
// 真实环境无访问统计(visits 全 0)时,Top20 降级为「缓存占用 Top 20」
const hasVisits = valid.some(p => (p.visits || 0) > 0) const hasVisits = valid.some(p => (p.visits || 0) > 0)
const top30 = [...valid] const top30 = [...valid]
.sort((a, b) => (hasVisits ? (b.visits || 0) - (a.visits || 0) : b.size - a.size)) .sort((a, b) => (hasVisits ? (b.visits || 0) - (a.visits || 0) : b.size - a.size))
.slice(0, 30) .slice(0, 20)
const maxVisit = Math.max(...top30.map(p => p.visits || 0), 1) const maxVisit = Math.max(...top30.map(p => p.visits || 0), 1)
const maxSize = Math.max(...top30.map(p => p.size), 1) const maxSize = Math.max(...top30.map(p => p.size), 1)
return { return {
pkgCount: valid.length, pkgCount: valid.length,
totalVersions: state.packages.reduce((s, p) => s + (p.versions || 0), 0),
totalSize, totalSize,
totalSizeText: fmtSize(totalSize), // 总占用优先用后端「目录所有文件字节和」(与 du 一致),否则回退到包大小之和
totalSizeText: state.totalBytes != null ? fmtBytes(state.totalBytes) : fmtSize(totalSize),
warnCount, warnCount,
cleanable, cleanable,
cleanableText: fmtSize(cleanable), cleanableText: fmtSize(cleanable),
todayUpdated,
maxPkg, maxPkg,
oldestPkg, oldestPkg,
top30: top30.map(p => ({ top30: top30.map(p => ({
@@ -55,19 +67,62 @@ function fmtSize(mb) {
return `${mb.toFixed(1)} MB` return `${mb.toFixed(1)} MB`
} }
/** 字节 → 可读文本 */
function fmtBytes(b) {
const mb = b / 1024 / 1024
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
return `${mb.toFixed(1)} MB`
}
const fmtSizeFn = fmtSize const fmtSizeFn = fmtSize
async function refreshPackages(keyword = '') { async function refreshPackages(keyword = '') {
const { packages, scannedAt } = await api.getPackages(keyword) const { packages, scannedAt, scanning, scanPackages, scanBytes, totalBytes } = await api.getPackages(keyword)
state.packages = packages state.packages = packages || []
if (scannedAt) state.scannedAt = scannedAt if (scannedAt) state.scannedAt = scannedAt
if (scanPackages !== undefined) state.scanProgress.packages = scanPackages
if (scanBytes !== undefined) state.scanProgress.bytes = scanBytes
if (totalBytes !== undefined && totalBytes !== null) state.totalBytes = totalBytes
return { scanning: !!scanning }
} }
/** 手动重新扫描缓存目录 */ /** 轮询后端扫描进度,直到扫描完成 */
async function pollScanProgress() {
for (;;) {
await new Promise(r => setTimeout(r, 800))
let p
try {
p = await api.getScanProgress()
} catch {
break // 接口异常时退出,避免遮罩永久拦截点击
}
state.scanProgress.packages = p.packages || 0
state.scanProgress.bytes = p.bytes || 0
if (!p.scanning) break
}
}
/** 刷新数据:若后端仍在扫描则先轮询进度,扫完再拉取真实数据 */
async function doRefresh() {
const first = await refreshPackages()
if (first.scanning) {
await pollScanProgress()
await refreshPackages() // 扫描完成后重新拉取完整数据
}
await Promise.all([refreshThresholds(), refreshLogs()])
}
/** 手动重新扫描缓存目录(异步,遮罩实时显示进度) */
async function scanNow() { async function scanNow() {
const res = await api.scanNow() state.loading = true
if (res.ok) await refreshAll() try {
return res const res = await api.scanNow()
if (!res.ok) return res
await doRefresh()
return res
} finally {
state.loading = false
}
} }
async function refreshThresholds() { async function refreshThresholds() {
@@ -84,7 +139,7 @@ async function refreshLogs() {
async function refreshAll() { async function refreshAll() {
state.loading = true state.loading = true
try { try {
await Promise.all([refreshPackages(), refreshThresholds(), refreshLogs()]) await doRefresh()
} finally { } finally {
state.loading = false state.loading = false
} }
+1 -1
View File
@@ -185,7 +185,7 @@ html[data-theme="dark"] .ambient::after {
.vtable tbody tr { cursor: pointer; transition: background .13s; } .vtable tbody tr { cursor: pointer; transition: background .13s; }
.vtable tbody tr:hover { background: var(--bg); } .vtable tbody tr:hover { background: var(--bg); }
.num-cell { font-family: var(--num); font-weight: 600; font-size: 14px; } .num-cell { font-family: var(--num); font-weight: 600; font-size: 14px; }
.date-cell { font-family: var(--num); font-size: 12.5px; color: var(--ink-dim); } .date-cell { font-family: var(--num); font-size: 12.5px; color: var(--ink-dim); white-space: nowrap; }
/* —— 滚动条 —— */ /* —— 滚动条 —— */
::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar { width: 8px; height: 8px; }
+16 -40
View File
@@ -8,35 +8,15 @@ import PackageCard from '../components/PackageCard.vue'
import PackageDrawer from '../components/PackageDrawer.vue' import PackageDrawer from '../components/PackageDrawer.vue'
import CleanDialog from '../components/CleanDialog.vue' import CleanDialog from '../components/CleanDialog.vue'
import { useStore } from '../store' import { useStore } from '../store'
import { iconOf } from '../mock/data.js'
const router = useRouter() const router = useRouter()
const { state, stats, refreshAll, ignorePkg, unignorePkg, cleanPkg, scanNow } = useStore() const { state, stats, refreshAll, ignorePkg, unignorePkg, cleanPkg } = useStore()
const drawerPkg = ref(null) const drawerPkg = ref(null)
const drawerVisible = ref(false) const drawerVisible = ref(false)
const cleanVisible = ref(false) const cleanVisible = ref(false)
const cleanPkgData = ref(null) const cleanPkgData = ref(null)
const showTop30 = ref(true) 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(() => const warningExcess = computed(() =>
stats.value.warnCount stats.value.warnCount
@@ -46,16 +26,15 @@ const warningExcess = computed(() =>
: 0, : 0,
) )
const top30WithIcon = computed(() => const top30WithIcon = computed(() => stats.value.top30)
stats.value.top30.map(p => ({ ...p, emoji: iconOf(p.name) })),
)
// 概览只展示需关注项(预警 + 异常),完整列表去检索页 // 概览只展示需关注项(预警 + 异常),完整列表去检索页
const attentionPackages = computed(() => const attentionPackages = computed(() =>
state.packages.filter(p => p.status === 'warn' || p.status === 'broken'), state.packages.filter(p => p.status === 'warn' || p.status === 'broken'),
) )
async function openDrawer(pkg) { function openDrawer(pkg) {
// 版本明细由 PackageDrawer 打开后按需加载
drawerPkg.value = pkg drawerPkg.value = pkg
drawerVisible.value = true drawerVisible.value = true
} }
@@ -105,25 +84,21 @@ function quickClean() {
<!-- 第二行:辅助指标 5 --> <!-- 第二行:辅助指标 5 -->
<div class="stats-2"> <div class="stats-2">
<StatMini tone="blue" label="访问排行" ico="🏆" main="Top 30" :sub="top30WithIcon.slice(0, 3).map(p => p.name).join(' · ')" link="展开" @link="showTop30 = true" /> <StatMini tone="blue" label="今日更新包数" ico="🆕" :main="String(stats.todayUpdated)" sub="今日有版本更新" link="查看" @link="router.push('/search')" />
<StatMini tone="green" label="今日下载量" ico="📈" main="1,234" sub="较昨日 +18%" /> <StatMini tone="green" label="缓存版本总数" ico="🧩" :main="stats.totalVersions.toLocaleString()" sub="全部缓存包的版本数" link="查看" @link="router.push('/search')" />
<StatMini tone="orange" label="最大包" ico="🐘" :main="stats.maxPkg?.name || '—'" :unit="stats.maxPkg ? stats.maxPkg.size + ' MB' : ''" :sub="`${stats.maxPkg?.versions || 0} 个版本`" @link="stats.maxPkg && openDrawer(stats.maxPkg)" /> <StatMini tone="orange" label="最大包" ico="🐘" :main="stats.maxPkg?.name || '—'" :unit="stats.maxPkg ? stats.maxPkg.size + ' MB' : ''" :sub="`${stats.maxPkg?.versions || 0} 个版本`" link="详情" @link="stats.maxPkg && openDrawer(stats.maxPkg)" />
<StatMini tone="purple" label="最老包时间" ico="⏳" :main="stats.oldestPkg?.lastUpdated || '—'" unit="⏰" :sub="stats.oldestPkg ? `${stats.oldestPkg.name} · 未更新` : ''" @link="stats.oldestPkg && openDrawer(stats.oldestPkg)" /> <StatMini tone="purple" label="最老包时间" ico="⏳" :main="stats.oldestPkg?.lastUpdated || '—'" unit="⏰" :sub="stats.oldestPkg ? `${stats.oldestPkg.name} · 未更新` : ''" link="详情" @link="stats.oldestPkg && openDrawer(stats.oldestPkg)" />
<StatMini tone="pink" label="版本数分布" ico="🧩" main=">15 版本" :unit="`${stats.warnCount} 个`" :sub="`超阈值共 ${warningExcess} 个版本`" /> <StatMini tone="pink" label="版本数分布" ico="🧩" main=">15 版本" :unit="`${stats.warnCount} 个`" :sub="`超阈值共 ${warningExcess} 个版本`" link="查看" @link="router.push({ path: '/search', query: { status: 'warn' } })" />
</div> </div>
<!-- Top 30 排行(可收起) --> <!-- Top 20 排行(可收起,点击行打开详情) -->
<Top30Panel v-if="showTop30" :top30="top30WithIcon" :top30-by="stats.top30By" /> <Top30Panel v-if="showTop30" :top30="top30WithIcon" :top30-by="stats.top30By" @open="openDrawer" />
<!-- 包卡片列表:仅需关注项 --> <!-- 包卡片列表:仅需关注项 -->
<div class="sec-head"> <div class="sec-head">
<span class="sec-title"><span class="emo">⚠️</span>需要关注</span> <span class="sec-title"><span class="emo">⚠️</span>需要关注</span>
<span class="sec-sub">共 {{ attentionPackages.length }} 个需关注 · 预警置顶</span> <span class="sec-sub">共 {{ attentionPackages.length }} 个需关注 · 预警置顶</span>
<span class="sec-actions"> <button class="link-all" @click="router.push('/search')">查看全部 →</button>
<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>
<div v-if="state.loading" class="loading-tip">⏳ 加载中…</div> <div v-if="state.loading" class="loading-tip">⏳ 加载中…</div>
@@ -174,9 +149,6 @@ function quickClean() {
.sec-title { font-size: 17px; font-weight: 700; display: flex; align-items: center; gap: 10px; } .sec-title { font-size: 17px; font-weight: 700; display: flex; align-items: center; gap: 10px; }
.sec-title .emo { font-size: 19px; } .sec-title .emo { font-size: 19px; }
.sec-sub { font-family: var(--num); font-size: 12px; color: var(--ink-faint); font-weight: 600; } .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 { 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); } .link-all:hover { transform: translateY(-1px); box-shadow: var(--shadow); }
.pkg-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; } .pkg-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
@@ -186,13 +158,17 @@ function quickClean() {
@media (max-width: 1180px) { @media (max-width: 1180px) {
.stats-1 { grid-template-columns: repeat(2, 1fr); } .stats-1 { grid-template-columns: repeat(2, 1fr); }
.stats-2 { grid-template-columns: repeat(3, 1fr); } .stats-2 { grid-template-columns: repeat(3, 1fr); }
.stats-2 > :nth-child(5) { grid-column: 1 / -1; } /* 5 卡跨列铺满,避免 2+2+1 */
.pkg-grid { grid-template-columns: repeat(2, 1fr); } .pkg-grid { grid-template-columns: repeat(2, 1fr); }
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.stats-1 { grid-template-columns: repeat(2, 1fr); gap: 10px; } .stats-1 { grid-template-columns: repeat(2, 1fr); gap: 10px; }
.stats-2 { grid-template-columns: repeat(2, 1fr); gap: 10px; } .stats-2 { grid-template-columns: repeat(2, 1fr); gap: 10px; }
.stats-2 > :nth-child(5) { grid-column: 1 / -1; } /* 第 5 张跨整行 */
.pkg-grid { grid-template-columns: 1fr; gap: 12px; } .pkg-grid { grid-template-columns: 1fr; gap: 12px; }
.sec-head { padding: 4px 0 12px; gap: 8px; }
.sec-title { font-size: 15px; } .sec-title { font-size: 15px; }
.sec-sub { font-size: 11px; } .sec-sub { font-size: 11px; }
.link-all { font-size: 12px; padding: 5px 10px; }
} }
</style> </style>
+16 -4
View File
@@ -1,18 +1,25 @@
<script setup> <script setup>
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import PackageDrawer from '../components/PackageDrawer.vue' import PackageDrawer from '../components/PackageDrawer.vue'
import CleanDialog from '../components/CleanDialog.vue' import CleanDialog from '../components/CleanDialog.vue'
import StatusTag from '../components/StatusTag.vue' import StatusTag from '../components/StatusTag.vue'
import { useStore } from '../store' import { useStore } from '../store'
const { state, stats, refreshPackages, ignorePkg, unignorePkg, cleanPkg, deleteVersion } = useStore() const { state, stats, refreshPackages, ignorePkg, unignorePkg, cleanPkg, deleteVersion } = useStore()
const route = useRoute()
const keyword = ref('') const keyword = ref('')
const statusFilter = ref('all') // all | warn | ok | ignored | broken const statusFilter = ref('all') // all | warn | ok | ignored | broken
const sortMode = ref('default') // default | versions-desc | size-desc const sortMode = ref('default') // default | versions-desc | size-desc
let debounceTimer = null let debounceTimer = null
onMounted(() => refreshPackages()) onMounted(() => {
// 数据由 App 初始化时统一加载,切换菜单不再重复拉取全量列表(响应大,避免卡顿)
if (route.query.status && ['all', 'warn', 'ok', 'ignored', 'broken'].includes(route.query.status)) {
statusFilter.value = route.query.status
}
})
watch(keyword, () => { watch(keyword, () => {
clearTimeout(debounceTimer) clearTimeout(debounceTimer)
@@ -46,6 +53,7 @@ const cleanVisible = ref(false)
const cleanPkgData = ref(null) const cleanPkgData = ref(null)
function openDrawer(pkg) { function openDrawer(pkg) {
// 版本明细由 PackageDrawer 打开后按需加载
drawerPkg.value = pkg drawerPkg.value = pkg
drawerVisible.value = true drawerVisible.value = true
} }
@@ -115,9 +123,9 @@ async function onCleanConfirmed(res) {
<th>包名</th> <th>包名</th>
<th style="width: 90px">版本数</th> <th style="width: 90px">版本数</th>
<th style="width: 110px">占用空间</th> <th style="width: 110px">占用空间</th>
<th style="width: 110px">最近更新</th> <th style="width: 120px">最近更新</th>
<th style="width: 110px">状态</th> <th style="width: 100px">状态</th>
<th style="width: 130px">操作</th> <th style="width: 160px">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -262,6 +270,10 @@ async function onCleanConfirmed(res) {
.result-note b { color: var(--orange); } .result-note b { color: var(--orange); }
.pkg-cell { font-weight: 600; word-break: break-all; } .pkg-cell { font-weight: 600; word-break: break-all; }
.num-cell.over { color: var(--red); } .num-cell.over { color: var(--red); }
/* 操作列:按钮横排一行,不换行 */
.vtable td:last-child { white-space: nowrap; }
.vtable td:last-child .btn { padding: 6px 13px; font-size: 12px; }
.vtable td:last-child .btn + .btn { margin-left: 7px; }
.empty-tip { padding: 40px 0; text-align: center; color: var(--ink-faint); font-size: 12.5px; } .empty-tip { padding: 40px 0; text-align: center; color: var(--ink-faint); font-size: 12.5px; }
.loading-tip { text-align: center; padding: 40px 0; color: var(--ink-faint); font-size: 13px; } .loading-tip { text-align: center; padding: 40px 0; color: var(--ink-faint); font-size: 13px; }
Binary file not shown.