refactor: server.js/store 单文件拆分多模块;新增统一错误处理与 /api 404

This commit is contained in:
yinjun.chen1
2026-09-22 14:29:18 +08:00
parent 8d14adb97c
commit 0c0d5370f0
23 changed files with 1546 additions and 1307 deletions
+1
View File
@@ -6,6 +6,7 @@ frontend/node_modules/
frontend/dist/
backend/node_modules/
.CodeCounter
.VSCodeCounter
.dumate
.tmp
# 运行时数据(SQLite)
+20
View File
@@ -0,0 +1,20 @@
/**
* Express 统一错误处理 / 404 中间件 —— 抽离自 server.js
* Express4 仅自动捕获「同步 throw」并交给错误中间件;此处确保任何未捕获错误
* 都返回统一 JSON 而非默认 HTML 500,避免前端 JSON 解析失败。
* (所有异步路由业务均在内部自带 try/catch,故无需 async 包装器。)
*/
/** /api 路径未匹配任何路由 → JSON 404(Express 默认对非 GET 返回 HTML) */
export function apiNotFound(req, res) {
res.status(404).json({ ok: false, message: 'not found' })
}
// Express 错误中间件需 4 参签名(next 必须保留,即便未直接使用)
export function errorHandler(err, req, res, next) {
console.error('[api] 未捕获错误:', err)
if (res.headersSent) return next(err)
// err.expose(如 body-parser 400)时透出具体消息,其余统一脱敏
const message = err.expose ? err.message : '服务器内部错误'
res.status(err.statusCode || 500).json({ ok: false, message })
}
+109
View File
@@ -0,0 +1,109 @@
/**
* 登录鉴权 —— server.js 抽离
* - 内置账号(Docker 部署通过 VCM_USERNAME/VCM_PASSWORD 环境变量覆盖)
* - 会话为内存态,进程重启后需重新登录
* - 登录限速:滑动窗口内失败达阈值即锁定(按客户端 IP)
*/
import express from 'express'
import { randomUUID } from 'node:crypto'
import { addLog } from '../db.js'
const AUTH_USERNAME = process.env.VCM_USERNAME || 'admin'
const AUTH_PASSWORD = process.env.VCM_PASSWORD || '666666'
const SESSION_TTL_MS = 12 * 3600_000 // 会话有效期 12 小时,防长期 token 泄漏
const LOGIN_LOCK = { failures: 3, windowMs: 5 * 60_000, lockMs: 15 * 60_000 } // 3 次失败锁 15 分钟
const sessions = new Map() // token -> { name, at, exp };内存态,进程重启后需重新登录
const loginFail = new Map() // ip -> { count, firstAt, lockedUntil }
// 登录限速:滑动窗口内失败达阈值即锁定(按客户端 IP)
function loginGuard(ip) {
const now = Date.now()
const rec = loginFail.get(ip)
if (!rec) return { locked: false }
if (rec.lockedUntil && now < rec.lockedUntil) {
return { locked: true, retryAfter: Math.ceil((rec.lockedUntil - now) / 1000) }
}
if (rec.firstAt && now - rec.firstAt > LOGIN_LOCK.windowMs) {
loginFail.delete(ip) // 窗口过期重置
return { locked: false }
}
return { locked: false }
}
function recordLoginFail(ip) {
const now = Date.now()
let rec = loginFail.get(ip)
if (!rec || now - rec.firstAt > LOGIN_LOCK.windowMs) rec = { count: 0, firstAt: now, lockedUntil: null }
rec.count += 1
if (rec.count >= LOGIN_LOCK.failures) {
rec.lockedUntil = now + LOGIN_LOCK.lockMs
console.warn(`[auth] IP ${ip} 登录失败 ${rec.count} 次,锁定 ${LOGIN_LOCK.lockMs / 60000} 分钟`)
}
loginFail.set(ip, rec)
}
function recordLoginOk(ip) {
loginFail.delete(ip)
}
// 清理登录限速表:超过窗口 + 锁定期的记录不再保留(防 Map 无限增长)
setInterval(() => {
const now = Date.now()
for (const [ip, rec] of loginFail) {
const stale = rec.lockedUntil
? now > rec.lockedUntil
: now - rec.firstAt > LOGIN_LOCK.windowMs
if (stale) loginFail.delete(ip)
}
}, 10 * 60_000).unref()
export const authRouter = express.Router()
authRouter.post('/login', (req, res) => {
const { username, password } = req.body || {}
const ip = req.ip || req.socket.remoteAddress || 'unknown'
const userAgent = req.headers['user-agent']
const guard = loginGuard(ip)
if (guard.locked) {
addLog({ action: 'login', pkgName: String(username || ''), result: 'fail', message: '触发锁定', ip, meta: { retryAfter: guard.retryAfter, userAgent } })
return res.status(429).json({ ok: false, message: `尝试过于频繁,请 ${guard.retryAfter} 秒后再试` })
}
if (String(username || '') !== AUTH_USERNAME || String(password || '') !== AUTH_PASSWORD) {
recordLoginFail(ip)
const rec = loginFail.get(ip)
const left = rec && rec.lockedUntil ? Math.ceil((rec.lockedUntil - Date.now()) / 1000) : 0
addLog({ action: 'login', pkgName: String(username || ''), result: 'fail', message: left > 0 ? `触发锁定,剩余 ${left} 秒` : '密码错误', ip, meta: { userAgent } })
return res.status(401).json({
ok: false,
message: left > 0 ? `用户名或密码错误,已触发 ${left} 秒锁定` : '用户名或密码错误',
})
}
recordLoginOk(ip)
const token = randomUUID()
const now = Date.now()
sessions.set(token, { name: AUTH_USERNAME, at: now, exp: now + SESSION_TTL_MS })
addLog({ action: 'login', pkgName: AUTH_USERNAME, result: 'success', message: '登录成功', ip, meta: { userAgent } })
res.json({ ok: true, token, user: { name: AUTH_USERNAME } })
})
authRouter.post('/logout', (req, res) => {
const auth = req.headers.authorization || ''
if (auth.startsWith('Bearer ')) sessions.delete(auth.slice(7))
res.json({ ok: true })
})
// 保护全部 /api/*(/login、/logout 除外):未登录或会话过期返回 401,前端据此跳转登录页
export function authMiddleware(req, res, next) {
if (req.path === '/login' || req.path === '/logout') return next()
const auth = req.headers.authorization || ''
const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''
const sess = token ? sessions.get(token) : null
if (!sess) {
return res.status(401).json({ ok: false, message: '未登录或会话已失效' })
}
if (Date.now() > sess.exp) {
sessions.delete(token) // 过期 token 立即作废
return res.status(401).json({ ok: false, message: '登录已过期,请重新登录' })
}
next()
}
+88
View File
@@ -0,0 +1,88 @@
/**
* 清理 / 单版本删除 API —— server.js 抽离
* - POST /api/clean:预警批量清理(保留最新 N 个)
* - DELETE /api/packages/:pkgName/versions/:version:单版本删除
* 含 demo 模式下的内存态清理实现
*/
import express from 'express'
import { addLog } from '../db.js'
import { demoPackages } from '../scanner.js'
import { currentStorage, isDemoMode, invalidateScan } from '../scan-manager.js'
import { cleanPackage, deleteSingleVersion } from '../clean.js'
import semverCompare from '../semver.js'
import { clampInt } from '../util.js'
export const cleanRouter = express.Router()
// —— 清理 ——
cleanRouter.post('/clean', (req, res) => {
const { pkgName, keep } = req.body || {}
if (!pkgName) return res.status(400).json({ ok: false, message: '缺少 pkgName' })
if (isDemoMode()) {
return demoClean(res, pkgName, clampInt(keep, 1, 999))
}
const storage = currentStorage()
const result = cleanPackage(storage, pkgName, clampInt(keep, 1, 999))
if (result.ok) {
addLog({
pkgName,
deletedVersions: result.deletedVersions,
deletedCount: result.deletedCount,
freedSpace: result.freedSpace,
})
invalidateScan()
}
res.json(result)
})
// —— 单版本删除 ——
cleanRouter.delete('/packages/:pkgName/versions/:version', (req, res) => {
const { pkgName, version } = req.params
if (isDemoMode()) {
return demoDeleteVersion(res, pkgName, version)
}
const storage = currentStorage()
const result = deleteSingleVersion(storage, pkgName, version)
if (result.ok) {
addLog({
action: 'delete_version',
pkgName,
version,
result: 'success',
message: `已删除 ${version}`,
deletedVersions: version,
deletedCount: 1,
freedSpace: result.freedSpace,
})
invalidateScan()
}
res.json(result)
})
// —— demo 模式下的清理/删除(内存态) ——
function demoClean(res, pkgName, keep) {
const p = demoPackages().find(x => x.name === pkgName)
if (!p) return res.json({ ok: false, message: '包不存在' })
const sorted = [...p.versionsDetail].sort((a, b) => semverCompare(a.version, b.version))
const toDel = sorted.slice(0, Math.max(0, sorted.length - keep))
if (!toDel.length) return res.json({ ok: false, message: '无需清理' })
const freed = toDel.reduce((s, v) => s + v.size, 0)
const result = {
ok: true,
pkgName,
deletedVersions: toDel.map(v => v.version).join(', '),
deletedCount: toDel.length,
freedSpace: `${freed.toFixed(1)} MB`,
}
// demo 日志并入 logs 表,保持一致
addLog({ pkgName, deletedVersions: result.deletedVersions, deletedCount: result.deletedCount, freedSpace: result.freedSpace })
res.json(result)
}
function demoDeleteVersion(res, pkgName, version) {
const p = demoPackages().find(x => x.name === pkgName)
const v = p?.versionsDetail?.find(x => x.version === version)
if (!v) return res.json({ ok: false, message: '版本不存在' })
addLog({ action: 'delete_version', pkgName, version, result: 'success', message: `已删除 ${version}`, deletedVersions: version, deletedCount: 1, freedSpace: `${v.size.toFixed(1)} MB` })
res.json({ ok: true, freedSpace: `${v.size.toFixed(1)} MB` })
}
+27
View File
@@ -0,0 +1,27 @@
/**
* 忽略标记 API —— server.js 抽离
* - GET/POSt/DELETE /api/ignore(d)
*/
import express from 'express'
import { getIgnored, addIgnored, removeIgnored, addLog } from '../db.js'
export const ignoredRouter = express.Router()
ignoredRouter.get('/ignored', (req, res) => {
res.json(getIgnored())
})
ignoredRouter.post('/ignore', (req, res) => {
const { pkgName } = req.body || {}
if (!pkgName) return res.status(400).json({ ok: false, message: '缺少 pkgName' })
addIgnored(String(pkgName))
addLog({ action: 'ignore', pkgName: String(pkgName) })
res.json({ ok: true })
})
ignoredRouter.delete('/ignore/:pkgName', (req, res) => {
const pkgName = decodeURIComponent(req.params.pkgName)
removeIgnored(pkgName)
addLog({ action: 'unignore', pkgName })
res.json({ ok: true })
})
+42
View File
@@ -0,0 +1,42 @@
/**
* 日志与消息通知 API —— server.js 抽离
* - GET /api/logs:操作日志(支持 action / batchId 筛选)
* - GET/POST /api/notifications*:消息通知查询与已读标记
*/
import express from 'express'
import {
getLogs, getNotifications, getUnreadNotificationCount,
markNotificationRead, markAllNotificationsRead,
} from '../db.js'
export const logsRouter = express.Router()
// —— 日志(支持 action / batchId 筛选,默认取最近 100 条) ——
logsRouter.get('/logs', (req, res) => {
const action = req.query.action || null
const batchId = req.query.batchId || null
const limit = Math.min(Number(req.query.limit || 100), 500)
res.json(getLogs({ action, batchId, limit }))
})
// —— 消息通知 ——
logsRouter.get('/notifications', (req, res) => {
const sinceId = Number(req.query.sinceId || 0)
const limit = Math.min(Number(req.query.limit || 20), 100)
const list = getNotifications({ sinceId, limit })
res.json({
list,
unreadCount: getUnreadNotificationCount(),
lastId: list.length ? list[0].id : sinceId,
})
})
logsRouter.post('/notifications/:id/read', (req, res) => {
markNotificationRead(Number(req.params.id))
res.json({ ok: true })
})
logsRouter.post('/notifications/read-all', (req, res) => {
const updated = markAllNotificationsRead()
res.json({ ok: true, updated })
})
+118
View File
@@ -0,0 +1,118 @@
/**
* 包列表 / 扫描 / storage 配置 API —— server.js 抽离
* - GET /api/packages:包列表(含扫描状态,前端据此轮询)
* - GET /api/packages/:pkgName:单包详情(只读缓存,绝不触发同步扫描)
* - POST /api/scan:手动重扫(异步)
* - GET /api/scan/progress:扫描进度
* - GET/POST /api/storage:缓存目录配置
*/
import express from 'express'
import { getSetting, getIgnoredRows, setSetting } from '../db.js'
import { validateStorage, demoPackages, demoSummary } from '../scanner.js'
import {
currentStorage, isDemoMode, ensureScan, buildPackages,
getRawPackages, invalidateScan, runScanAsync, scanState, getScannedAt,
} from '../scan-manager.js'
import { DEFAULT_VERSION_THRESHOLD } from '../util.js'
export const packagesRouter = express.Router()
// —— 包列表(扫描) ——
packagesRouter.get('/packages', (req, res) => {
const demo = isDemoMode()
const storage = currentStorage()
const validation = demo ? { ok: true, message: 'demo' } : validateStorage(storage)
ensureScan() // 懒触发:登录后首次拉包自动启动扫描(rawCache 缺失时),返回 scanning 状态供前端轮询
const packages = buildPackages(req.query.keyword || '') // 先构建(填充缓存)
res.json({
demo,
storageDir: storage || null,
storageValid: validation.ok,
storageMessage: validation.message,
threshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)),
scannedAt: demo ? new Date().toISOString() : (getScannedAt() || null),
scanning: scanState.scanning,
scanPackages: scanState.packages,
scanBytes: scanState.bytes,
totalBytes: demo ? null : (scanState.diskBytes ?? scanState.bytes), // 磁盘占用(跨平台字节和)
packages,
...(demo ? demoSummary() : {}),
})
})
// —— 单包详情(含版本明细,抽屉打开时按需加载)
// 只读现有扫描缓存,绝不触发同步扫描(缓存被失效时直接 404,
// 前端用列表数据兜底 + 提示,避免打开抽屉卡几十秒) ——
packagesRouter.get('/packages/:pkgName', (req, res) => {
const name = decodeURIComponent(req.params.pkgName)
const raw = isDemoMode() ? demoPackages() : (getRawPackages() || 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 } })
})
// —— 手动重新扫描缓存目录(异步,扫描期间前端轮询进度) ——
packagesRouter.post('/scan', (req, res) => {
// 扫描进行中时拒绝,避免 invalidateScan 清空 pkgCache 破坏进行中的扫描
if (scanState.scanning) {
return res.json({ ok: false, code: 'SCANNING', message: '扫描进行中,请稍后重试', scanning: true })
}
invalidateScan()
const demo = isDemoMode()
if (demo) {
const scannedAt = new Date().toISOString()
return res.json({ ok: true, demo, scanning: false, scannedAt })
}
runScanAsync() // 后台启动,不阻塞响应
res.json({
ok: true,
demo: false,
scanning: scanState.scanning,
startedAt: scanState.startedAt,
packages: scanState.packages,
bytes: scanState.bytes,
})
})
// —— 扫描进度(重扫/首扫期间由前端轮询) ——
packagesRouter.get('/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,
})
})
// —— storage 配置(UI 设置页) ——
packagesRouter.get('/storage', (req, res) => {
const storage = currentStorage()
const demo = isDemoMode()
res.json({
storageDir: storage,
envDir: process.env.VERDACCIO_STORAGE || null,
demo,
validation: demo ? { ok: true, message: 'demo' } : validateStorage(storage),
})
})
packagesRouter.post('/storage', (req, res) => {
const { storageDir } = req.body || {}
if (!storageDir) return res.status(400).json({ ok: false, message: '缺少 storageDir' })
const v = validateStorage(String(storageDir))
if (!v.ok) return res.status(400).json(v)
setSetting('storage_dir', String(storageDir))
invalidateScan() // 目录切换 → 废弃旧缓存,下次请求懒扫描重建,避免列表仍显示旧目录
res.json({ ok: true })
})
+73
View File
@@ -0,0 +1,73 @@
/**
* 同步拉取 API —— server.js 抽离
* - POST /api/sync/latest:一键拉取存量包最新版本(异步)
* - GET /api/sync/progress:拉取进度
* - POST /api/pull:手工拉取指定包版本(经私服代理落盘)
*/
import express from 'express'
import { randomUUID } from 'node:crypto'
import { addLog } from '../db.js'
import { notify } from '../notifier.js'
import { isDemoMode, invalidateScan } from '../scan-manager.js'
import { syncState, runSyncLatest, triggerVerdaccio } from '../sync-latest.js'
export const syncRouter = express.Router()
// —— 一键拉取存量包最新版本(异步,期间前端轮询进度) ——
syncRouter.post('/sync/latest', (req, res) => {
if (isDemoMode()) {
return res.json({ ok: true, demo: true, running: false, message: '演示模式:未配置 Verdaccio 缓存目录,不支持拉取' })
}
if (!syncState.running) {
runSyncLatest({
addLog,
invalidateScan,
notify,
})
}
res.json({
ok: true,
demo: false,
running: syncState.running,
total: syncState.total,
startedAt: syncState.startedAt,
})
})
// —— 拉取进度(拉取期间由前端轮询) ——
syncRouter.get('/sync/progress', (req, res) => {
res.json({
running: syncState.running,
done: syncState.done,
total: syncState.total,
processed: syncState.processed,
success: syncState.success,
skipped: syncState.skipped,
failed: syncState.failed,
failedList: syncState.failedList,
current: syncState.current,
error: syncState.error,
startedAt: syncState.startedAt,
finishedAt: syncState.finishedAt,
})
})
// —— 手工拉取指定包版本(经私服代理落盘,与 sync-latest 同机制) ——
syncRouter.post('/pull', async (req, res) => {
const { pkgName, version } = req.body || {}
if (!pkgName || !version) return res.status(400).json({ ok: false, message: '缺少 pkgName 或 version' })
if (isDemoMode()) return res.json({ ok: false, message: '演示模式不支持拉取' })
const batchId = randomUUID()
try {
await triggerVerdaccio(pkgName, version)
const logId = addLog({ action: 'pull_manual', pkgName, version, result: 'success', message: `已拉取 ${pkgName}@${version}`, batchId })
notify({ type: 'pull_manual', title: `已拉取 ${pkgName}@${version}`, level: 'info', relatedActionId: logId, batchId, meta: { pkgName, version, result: 'success' } })
invalidateScan()
res.json({ ok: true })
} catch (e) {
const msg = (e && e.message) || '拉取失败'
const logId = addLog({ action: 'pull_manual', pkgName, version, result: 'fail', message: msg, batchId })
notify({ type: 'pull_manual', title: `拉取失败:${pkgName}@${version}`, body: msg, level: 'error', relatedActionId: logId, batchId, meta: { pkgName, version, result: 'fail', error: msg } })
res.json({ ok: false, message: msg })
}
})
+48
View File
@@ -0,0 +1,48 @@
/**
* 阈值配置 API —— server.js 抽离
* - GET/POST /api/threshold:版本阈值(兼容旧字段)
* - GET/POST /api/thresholds:版本 + 大小阈值 + 单包例外
*/
import express from 'express'
import { getSetting, setSetting } from '../db.js'
import { clampInt, DEFAULT_VERSION_THRESHOLD, DEFAULT_SIZE_THRESHOLD } from '../util.js'
export const thresholdsRouter = express.Router()
// spec §4.2:GET/POST /api/threshold(版本阈值)
thresholdsRouter.get('/threshold', (req, res) => {
res.json({ versionThreshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)) })
})
thresholdsRouter.post('/threshold', (req, res) => {
// 兼容 versionThreshold / value 两种字段名
const v = clampInt(req.body.versionThreshold ?? req.body.value, 1, 100)
setSetting('version_threshold', v)
res.json({ ok: true, versionThreshold: v })
})
// 扩展:GET/POST /api/thresholds(版本 + 大小 + 单包例外)
thresholdsRouter.get('/thresholds', (req, res) => {
let exceptions = []
try { exceptions = JSON.parse(getSetting('exceptions', '[]')) } catch { exceptions = [] }
res.json({
versionThreshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)),
sizeThreshold: Number(getSetting('size_threshold', DEFAULT_SIZE_THRESHOLD)),
exceptions,
})
})
thresholdsRouter.post('/thresholds', (req, res) => {
const { versionThreshold, sizeThreshold, exceptions } = req.body || {}
setSetting('version_threshold', clampInt(versionThreshold, 1, 100))
setSetting('size_threshold', clampInt(sizeThreshold, 1, 10000))
const ex = Array.isArray(exceptions)
? exceptions.map(e => ({
pkgName: String(e.pkgName || '').trim(),
versionThreshold: clampInt(e.versionThreshold, 1, 100),
sizeThreshold: clampInt(e.sizeThreshold, 1, 10000),
})).filter(e => e.pkgName)
: []
setSetting('exceptions', JSON.stringify(ex))
res.json({ ok: true })
})
+91
View File
@@ -0,0 +1,91 @@
/**
* Top200 榜单 API —— server.js 抽离
* - GET /api/toplist:榜单 + 实时缓存比对(支持 filter)
* - GET /api/toplist/progress、POST /api/toplist/fetch:抓榜
* - POST /api/toplist/pull、GET /api/toplist/pull/progress:拉取待缓存包
*/
import express from 'express'
import { addLog, setSetting } from '../db.js'
import { notify } from '../notifier.js'
import { isDemoMode, ensureScan, hasScanCache, getRawPackages, invalidateScan, scanState } from '../scan-manager.js'
import { toplistState, getTopList, runTopListFetch, runTopListPull } from '../npm-toplist.js'
export const toplistRouter = express.Router()
toplistRouter.get('/toplist', (req, res) => {
const { filter } = req.query
// pull/清理后 invalidateScan 会清空 rawCache;此接口也懒触发重建,
// 否则实时比对会拿到空缓存,导致所有包被误判为「待拉取」
if (!isDemoMode() && !hasScanCache()) ensureScan()
const data = getTopList(getRawPackages)
// 透传扫描状态,供前端在扫描结束后再刷新榜单比对结果
data.scanning = scanState.scanning
if (filter === 'missing') {
data.list = data.list.filter(e => !e.cached)
} else if (filter === 'outdated') {
data.list = data.list.filter(e => e.cached && !e.latestCached)
} else if (filter === 'ready') {
data.list = data.list.filter(e => e.cached && e.latestCached)
}
res.json(data)
})
toplistRouter.get('/toplist/progress', (req, res) => {
res.json(toplistState)
})
toplistRouter.post('/toplist/fetch', (req, res) => {
if (!toplistState.running) {
runTopListFetch({
addLog,
getRawPackages,
invalidateScan,
setSetting,
notify,
})
}
res.json({
ok: true,
running: toplistState.running,
phase: toplistState.phase,
startedAt: toplistState.startedAt,
})
})
toplistRouter.post('/toplist/pull', (req, res) => {
if (isDemoMode()) {
return res.json({ ok: true, demo: true, running: false, message: '演示模式:未配置 Verdaccio 缓存目录,不支持拉取' })
}
if (!toplistState.pullRunning) {
runTopListPull({
addLog,
getRawPackages,
invalidateScan,
notify,
})
}
res.json({
ok: true,
demo: false,
running: toplistState.pullRunning,
total: toplistState.pullTotal,
startedAt: toplistState.pullStartedAt,
})
})
toplistRouter.get('/toplist/pull/progress', (req, res) => {
res.json({
running: toplistState.pullRunning,
done: toplistState.pullDone,
total: toplistState.pullTotal,
processed: toplistState.pullProcessed,
success: toplistState.pullSuccess,
skipped: toplistState.pullSkipped,
failed: toplistState.pullFailed,
failedList: toplistState.pullFailedList,
current: toplistState.pullCurrent,
error: toplistState.pullError,
startedAt: toplistState.pullStartedAt,
finishedAt: toplistState.pullFinishedAt,
})
})
+274
View File
@@ -0,0 +1,274 @@
/**
* 扫描状态机与业务编排 —— server.js 抽离
* - 维护内存扫描缓存(rawCache)与异步扫描进度(scanState)
* - 负责扫描触发、状态构建(buildPackages)、缓存失效、demo 模式判断
* - routes/* 通过本模块共享扫描状态,避免每次请求重复扫描
*/
import fs from 'node:fs'
import path from 'node:path'
import {
getSetting, setSetting, getIgnoredRows, addLog,
} from './db.js'
import {
resolveStorageDir, isValidStorage, validateStorage, scanStorageAsync, clearPkgCache,
} from './scanner.js'
import { notify } from './notifier.js'
import { DEFAULT_VERSION_THRESHOLD } from './util.js'
// —— 当前生效的 storage 目录(每次请求动态解析,UI 保存后立即生效) ——
export function currentStorage() {
return resolveStorageDir()
}
export function isDemoMode() {
return process.env.VCM_DEMO === '1' || !isValidStorage(currentStorage())
}
// —— 扫描结果缓存:真实目录全量扫描 IO 重,缓存避免每次请求/刷新页面重扫 ——
let rawCache = null
let rawCacheAt = null
// —— 异步扫描状态(后台重扫时供前端轮询进度) ——
export const scanState = {
scanning: false, done: false, packages: 0, bytes: 0, tgzBytes: 0, diskBytes: null,
startedAt: null, finishedAt: null, error: null, lastErrorAt: null,
pendingDailyScan: false, // 定时扫描被并发扫描挤掉时标记,扫描结束后自动补扫
_triggerSource: 'manual', // 'manual' | 'daily' | 'pending' | 'lazy',扫描日志记录触发源
}
/** 磁盘占用(跨平台):递归统计目录内所有文件字节和
* 原实现 execSync('du -sk') 依赖 Linux du,Windows 下不可用;
* 改为 Node 原生递归统计,语义与「文件字节和」一致,避免外部命令差异 */
async function diskBytesOf(dir) {
let total = 0
async function walk(d) {
let entries
try {
entries = await fs.promises.readdir(d, { withFileTypes: true })
} catch {
return
}
await Promise.all(entries.map(async e => {
if (e.name === 'node_modules' || e.name.startsWith('.') || e.name === '.DS_Store') return
const full = path.join(d, e.name)
if (e.isDirectory()) {
await walk(full)
} else {
try {
const st = await fs.promises.stat(full)
total += st.size
} catch {}
}
}))
}
try {
await walk(dir)
return total
} catch {
return null
}
}
export 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.error = null
scanState.lastErrorAt = null // 成功后清除错误状态,解除冷却
scanState.finishedAt = rawCacheAt
scanState.diskBytes = await diskBytesOf(storage) // 磁盘占用(跨平台字节和)
console.log('[scan] 完成 ' + JSON.stringify({
storage,
packages: rawCache.length,
tgzBytes: scanState.tgzBytes,
bytes: scanState.bytes,
diskBytes: scanState.diskBytes,
}))
} catch (e) {
scanState.error = e.message
scanState.lastErrorAt = new Date().toISOString()
console.error('[scan] 扫描失败:', e.message)
} finally {
scanState.scanning = false
// 写扫描日志(含触发源:manual / daily / pending)
addLog({
action: 'scan',
result: scanState.error ? 'fail' : 'success',
message: scanState.error || `扫描完成 ${scanState.packages} 个包`,
durationMs: Date.now() - new Date(scanState.startedAt).getTime(),
meta: {
packages: scanState.packages,
bytes: scanState.bytes,
tgzBytes: scanState.tgzBytes,
diskBytes: scanState.diskBytes,
triggeredBy: scanState._triggerSource || 'manual',
},
})
// 记录本次触发源(复位前捕获),用于预警 / 定时预告通知
const triggerSource = scanState._triggerSource || 'manual'
// 通知类型:扫描完成(首次懒扫描、手动重扫、写操作后重扫、每日/补扫通用)。
// 前端「首次部署后打开页面」用遮罩呈现;其余后台静默扫描靠此通知在消息中心回执。
notify({
type: 'scan_done',
title: scanState.error ? '缓存扫描失败' : '缓存扫描完成',
body: scanState.error
? `扫描失败:${scanState.error}`
: `已扫描 ${scanState.packages} 个包${scanState.tgzBytes ? ` · ${(scanState.tgzBytes / 1024 / 1024).toFixed(1)} MB` : ''}`,
level: scanState.error ? 'error' : 'info',
meta: {
scan: true,
success: !scanState.error,
packages: scanState.packages,
bytes: scanState.bytes,
tgzBytes: scanState.tgzBytes,
triggeredBy: triggerSource,
finishedAt: scanState.finishedAt || new Date().toISOString(),
},
})
// 通知类型 1:包版本过多预警(仅扫描成功时;快照比对去重,超阈值集合变化才发)
if (!scanState.error && rawCache) {
const threshold = Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD))
const warnPkgs = (rawCache || [])
.filter(p => p.versions > threshold && p.status !== 'broken')
.sort((a, b) => b.versions - a.versions)
if (warnPkgs.length > 0) {
const snapshot = warnPkgs.map(p => `${p.name}:${p.versions}`).sort().join('|')
const last = getSetting('last_warning_snapshot', '')
if (snapshot !== last) {
setSetting('last_warning_snapshot', snapshot)
notify({
type: 'version_warning',
title: `${warnPkgs.length} 个包版本数超阈值(${threshold})`,
body: warnPkgs.slice(0, 3).map(p => `${p.name}(${p.versions})`).join('、') + (warnPkgs.length > 3 ? '…' : ''),
level: 'warn',
meta: {
packageCount: warnPkgs.length, threshold,
topPackages: warnPkgs.slice(0, 10).map(p => ({ name: p.name, versions: p.versions })),
},
})
}
}
}
// 通知类型 6:定时扫描预告(仅自动扫描完成后发一次,手动扫描不触发)
if (triggerSource === 'daily' || triggerSource === 'pending') {
const next = new Date()
next.setHours(3, 0, 0, 0)
if (next.getTime() <= Date.now()) next.setDate(next.getDate() + 1)
const p = n => String(n).padStart(2, '0')
notify({
type: 'daily_preview',
title: '下次自动扫描预告',
body: `明日 ${p(next.getMonth() + 1)}-${p(next.getDate())} 03:00 将自动扫描缓存目录`,
level: 'info',
meta: { taskType: 'scan', nextRunAt: next.toISOString() },
})
}
scanState._triggerSource = 'manual' // 复位,避免下次复用
// 定时扫描被并发扫描挤掉时,扫描结束后立即补扫
if (scanState.pendingDailyScan) {
scanState.pendingDailyScan = false
invalidateScan()
console.log('[auto-scan] 并发扫描结束,开始补扫')
scanState._triggerSource = 'pending'
runScanAsync()
}
}
}
// 扫描失败后冷却期:避免持续异常(目录权限/挂载问题)时每次请求都触发扫描
const SCAN_RETRY_COOLDOWN_MS = 5 * 60_000 // 5 分钟
// 确保扫描已启动(缓存缺失时懒触发,不阻塞当前请求)
export function ensureScan() {
if (isDemoMode()) return
if (rawCache || scanState.scanning) return
// 失败冷却期内不重试,手动 POST /api/scan 不受此限制
if (scanState.lastErrorAt) {
const elapsed = Date.now() - new Date(scanState.lastErrorAt).getTime()
if (elapsed < SCAN_RETRY_COOLDOWN_MS) return
}
scanState._triggerSource = 'lazy' // 懒触发(首次打开 / 写操作后重建缓存),区别于手动、每日、补扫
runScanAsync()
}
export function getRawPackages() {
return rawCache || []
}
/** 是否已有扫描缓存(rawCache 非 null;空目录扫描结果为 [] 也算已扫描) */
export function hasScanCache() {
return rawCache !== null
}
export function invalidateScan() {
rawCache = null
rawCacheAt = null
clearPkgCache() // storage 目录切换 / 清理动作后废弃增量缓存,避免旧目录条目残留
}
export function getScannedAt() {
return rawCacheAt || null
}
// 构建包列表(统一状态计算 + 排序 + 忽略标记)
export function buildPackages(keyword = '') {
const threshold = Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD))
const ignoredRows = getIgnoredRows()
const ignoredSet = new Set(ignoredRows.map(r => r.pkg_name))
const ignoredAtMap = Object.fromEntries(ignoredRows.map(r => [r.pkg_name, (r.ignored_at || '').slice(0, 10)]))
const raw = getRawPackages() || []
let list = raw.map(p => {
let status
if (p.status === 'broken') status = 'broken'
else if (ignoredSet.has(p.name)) status = 'ignored'
else status = p.versions > threshold ? 'warn' : 'ok'
return {
name: p.name,
versions: p.versions,
size: p.size,
lastUpdated: p.lastUpdated,
visits: p.visits || 0,
distTags: p.distTags || {},
status,
ignoredAt: ignoredAtMap[p.name] || null,
description: p.description || null,
homepage: p.homepage || null,
// 列表不携带 versionsDetail(包多时响应巨大拖慢加载),详情走 GET /api/packages/:name
}
})
// 检索:不区分大小写子串,匹配包名(版本号匹配由前端本地完成,避免列表携带明细)
const kw = keyword.trim().toLowerCase()
if (kw) {
list = list.filter(p => p.name.toLowerCase().includes(kw) || String(p.size).includes(kw))
}
// 排序:预警置顶 → 正常 → 忽略 → 异常;同状态按版本数降序
const rank = { warn: 0, ok: 1, ignored: 2, broken: 3 }
list.sort((a, b) => {
if (rank[a.status] !== rank[b.status]) return rank[a.status] - rank[b.status]
return b.versions - a.versions
})
return list
}
+73
View File
@@ -0,0 +1,73 @@
/**
* 定时任务编排 —— server.js 抽离
* - scheduleDailyScan:每天 03:00 自动重扫缓存
* - scheduleWeeklyTopList:每周一 00:00 自动抓取 Top200 榜单
* 二者错峰(扫描 03:00 / 抓榜 00:00),避免并发撞车
*/
import { addLog, setSetting, nowIso } from './db.js'
import { notify } from './notifier.js'
import { scanState, invalidateScan, runScanAsync, getRawPackages } from './scan-manager.js'
import { runTopListFetch } from './npm-toplist.js'
// —— 每天 03:00(服务器本地时区)自动重扫缓存,逻辑同 POST /api/scan ——
// 选 3 点而非 0 点:避开周一 0 点 Top200 抓榜,减少并发撞车;凌晨 3 点用户访问最稀疏
export function scheduleDailyScan() {
const now = new Date()
const next = new Date(now)
next.setHours(3, 0, 0, 0)
if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1) // 今日 3 点已过 → 明日
const wait = next.getTime() - now.getTime()
setTimeout(() => {
try {
if (scanState.scanning) {
// 扫描进行中:标记待扫,扫描结束后自动补扫(见 runScanAsync finally)
scanState.pendingDailyScan = true
console.log('[auto-scan] 扫描进行中,已排队待扫')
} else {
invalidateScan()
scanState._triggerSource = 'daily'
runScanAsync()
console.log(`[auto-scan] ${new Date().toISOString()} 每日自动扫描已启动`)
}
} catch (e) {
console.error('[auto-scan] 每日自动扫描失败:', e.message)
}
scheduleDailyScan() // 安排次日 03:00
}, wait)
}
// —— 每周一 00:00(服务器本地时区)自动抓取 Top200 榜单 ——
// 与每日扫描(03:00)错峰,避免并发冲突
export function scheduleWeeklyTopList() {
const now = new Date()
const next = new Date(now)
next.setHours(0, 0, 0, 0)
const dow = next.getDay() // 0=Sun, 1=Mon
let daysUntilMonday = (1 - dow + 7) % 7
if (daysUntilMonday === 0 && now.getTime() > next.getTime()) daysUntilMonday = 7
next.setDate(next.getDate() + daysUntilMonday)
setSetting('toplist_next_run', nowIso(next))
setTimeout(async () => {
try {
await runTopListFetch({ addLog, getRawPackages, invalidateScan, setSetting, notify })
// 通知类型 6:榜单抓取预告(下次下周一 00:00)
const nextRun = new Date()
nextRun.setHours(0, 0, 0, 0)
const dow = nextRun.getDay()
let d = (1 - dow + 7) % 7
if (d === 0 && nextRun.getTime() <= Date.now()) d = 7
nextRun.setDate(nextRun.getDate() + d)
const p = n => String(n).padStart(2, '0')
notify({
type: 'daily_preview',
title: '下次榜单抓取预告',
body: `下周一 ${p(nextRun.getMonth() + 1)}-${p(nextRun.getDate())} 00:00 将抓取 npm Top200 榜单`,
level: 'info',
meta: { taskType: 'toplist_fetch', nextRunAt: nextRun.toISOString() },
})
} catch (e) {
console.error('[toplist] 定时抓榜失败:', e.message)
}
scheduleWeeklyTopList()
}, next.getTime() - now.getTime())
}
+34 -831
View File
@@ -1,5 +1,5 @@
/**
* Verdaccio 缓存可视化管理面板 — 后端服务
* Verdaccio 缓存可视化管理面板 — 后端服务入口
* Express + better-sqlite3,§4.2 REST API
*
* 环境变量:
@@ -9,740 +9,49 @@
* VCM_USERNAME 面板登录账号(默认 admin)
* VCM_PASSWORD 面板登录密码(默认 666666,Docker 部署务必通过 compose 覆盖)
* UV_THREADPOOL_SIZE libuv IO 线程池大小(默认 4 → 8:扫描/stat/readFile 并发更高,NAS 四核安全)
*
* 模块划分:
* - scan-manager.js 扫描状态机与业务编排(缓存/进度/状态构建)
* - schedules.js 每日扫描 + 每周抓榜定时任务
* - routes/* REST 路由(按领域拆分)
*/
// 必须在 libuv 线程池首次使用前设置:扫描大量目录/文件时默认 4 线程是主要瓶颈
process.env.UV_THREADPOOL_SIZE = process.env.UV_THREADPOOL_SIZE || '8'
import express from 'express'
import path from 'node:path'
import fs from 'node:fs'
import { randomUUID } from 'node:crypto'
import { fileURLToPath } from 'node:url'
import {
getSetting, setSetting, getIgnored, getIgnoredRows, addIgnored, removeIgnored,
addLog, getLogs, nowIso,
getNotifications, getUnreadNotificationCount, markNotificationRead, markAllNotificationsRead,
} from './db.js'
import {
resolveStorageDir, isValidStorage, validateStorage, scanStorage, scanStorageAsync,
demoPackages, demoSummary, clearPkgCache,
} from './scanner.js'
import { cleanPackage, deleteSingleVersion } from './clean.js'
import semverCompare from './semver.js'
import { syncState, runSyncLatest } from './sync-latest.js'
import { toplistState, runTopListFetch, runTopListPull, getTopList } from './npm-toplist.js'
import { notify } from './notifier.js'
import { resolveStorageDir } from './scanner.js'
import { isDemoMode } from './scan-manager.js'
import { scheduleDailyScan, scheduleWeeklyTopList } from './schedules.js'
import { authRouter, authMiddleware } from './routes/auth.js'
import { thresholdsRouter } from './routes/thresholds.js'
import { ignoredRouter } from './routes/ignored.js'
import { packagesRouter } from './routes/packages.js'
import { syncRouter } from './routes/sync.js'
import { toplistRouter } from './routes/toplist.js'
import { cleanRouter } from './routes/clean.js'
import { logsRouter } from './routes/logs.js'
import { apiNotFound, errorHandler } from './http.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const app = express()
app.use(express.json({ limit: '1mb' }))
const PORT = Number(process.env.PORT || 3000)
const DEFAULT_VERSION_THRESHOLD = 15
const DEFAULT_SIZE_THRESHOLD = 500
// —— 当前生效的 storage 目录(每次请求动态解析,UI 保存后立即生效) ——
function currentStorage() {
return resolveStorageDir()
}
function isDemoMode() {
return process.env.VCM_DEMO === '1' || !isValidStorage(currentStorage())
}
// —— 扫描结果缓存:真实目录全量扫描 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, lastErrorAt: null,
pendingDailyScan: false, // 定时扫描被并发扫描挤掉时标记,扫描结束后自动补扫
_triggerSource: 'manual', // 'manual' | 'daily' | 'pending',扫描日志记录触发源
}
/** 磁盘占用(跨平台):递归统计目录内所有文件字节和
* 原实现 execSync('du -sk') 依赖 Linux du,Windows 下不可用;
* 改为 Node 原生递归统计,语义与「文件字节和」一致,避免外部命令差异 */
async function diskBytesOf(dir) {
let total = 0
async function walk(d) {
let entries
try {
entries = await fs.promises.readdir(d, { withFileTypes: true })
} catch {
return
}
await Promise.all(entries.map(async e => {
if (e.name === 'node_modules' || e.name.startsWith('.') || e.name === '.DS_Store') return
const full = path.join(d, e.name)
if (e.isDirectory()) {
await walk(full)
} else {
try {
const st = await fs.promises.stat(full)
total += st.size
} catch {}
}
}))
}
try {
await walk(dir)
return total
} catch {
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.error = null
scanState.lastErrorAt = null // 成功后清除错误状态,解除冷却
scanState.finishedAt = rawCacheAt
scanState.diskBytes = await diskBytesOf(storage) // 磁盘占用(跨平台字节和)
console.log('[scan] 完成 ' + JSON.stringify({
storage,
packages: rawCache.length,
tgzBytes: scanState.tgzBytes,
bytes: scanState.bytes,
diskBytes: scanState.diskBytes,
}))
} catch (e) {
scanState.error = e.message
scanState.lastErrorAt = new Date().toISOString()
console.error('[scan] 扫描失败:', e.message)
} finally {
scanState.scanning = false
// 写扫描日志(含触发源:manual / daily / pending)
addLog({
action: 'scan',
result: scanState.error ? 'fail' : 'success',
message: scanState.error || `扫描完成 ${scanState.packages} 个包`,
durationMs: Date.now() - new Date(scanState.startedAt).getTime(),
meta: {
packages: scanState.packages,
bytes: scanState.bytes,
tgzBytes: scanState.tgzBytes,
diskBytes: scanState.diskBytes,
triggeredBy: scanState._triggerSource || 'manual',
},
})
// 记录本次触发源(复位前捕获),用于预警 / 定时预告通知
const triggerSource = scanState._triggerSource || 'manual'
// 通知类型:扫描完成(首次懒扫描、手动重扫、写操作后重扫、每日/补扫通用)。
// 前端「首次部署后打开页面」用遮罩呈现;其余后台静默扫描靠此通知在消息中心回执。
notify({
type: 'scan_done',
title: scanState.error ? '缓存扫描失败' : '缓存扫描完成',
body: scanState.error
? `扫描失败:${scanState.error}`
: `已扫描 ${scanState.packages} 个包${scanState.tgzBytes ? ` · ${(scanState.tgzBytes / 1024 / 1024).toFixed(1)} MB` : ''}`,
level: scanState.error ? 'error' : 'info',
meta: {
scan: true,
success: !scanState.error,
packages: scanState.packages,
bytes: scanState.bytes,
tgzBytes: scanState.tgzBytes,
triggeredBy: triggerSource,
finishedAt: scanState.finishedAt || new Date().toISOString(),
},
})
// 通知类型 1:包版本过多预警(仅扫描成功时;快照比对去重,超阈值集合变化才发)
if (!scanState.error && rawCache) {
const threshold = Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD))
const warnPkgs = (rawCache || [])
.filter(p => p.versions > threshold && p.status !== 'broken')
.sort((a, b) => b.versions - a.versions)
if (warnPkgs.length > 0) {
const snapshot = warnPkgs.map(p => `${p.name}:${p.versions}`).sort().join('|')
const last = getSetting('last_warning_snapshot', '')
if (snapshot !== last) {
setSetting('last_warning_snapshot', snapshot)
notify({
type: 'version_warning',
title: `${warnPkgs.length} 个包版本数超阈值(${threshold})`,
body: warnPkgs.slice(0, 3).map(p => `${p.name}(${p.versions})`).join('、') + (warnPkgs.length > 3 ? '…' : ''),
level: 'warn',
meta: {
packageCount: warnPkgs.length, threshold,
topPackages: warnPkgs.slice(0, 10).map(p => ({ name: p.name, versions: p.versions })),
},
})
}
}
}
// 通知类型 6:定时扫描预告(仅自动扫描完成后发一次,手动扫描不触发)
if (triggerSource === 'daily' || triggerSource === 'pending') {
const next = new Date()
next.setHours(3, 0, 0, 0)
if (next.getTime() <= Date.now()) next.setDate(next.getDate() + 1)
const p = n => String(n).padStart(2, '0')
notify({
type: 'daily_preview',
title: '下次自动扫描预告',
body: `明日 ${p(next.getMonth() + 1)}-${p(next.getDate())} 03:00 将自动扫描缓存目录`,
level: 'info',
meta: { taskType: 'scan', nextRunAt: next.toISOString() },
})
}
scanState._triggerSource = 'manual' // 复位,避免下次复用
// 定时扫描被并发扫描挤掉时,扫描结束后立即补扫
if (scanState.pendingDailyScan) {
scanState.pendingDailyScan = false
invalidateScan()
console.log('[auto-scan] 并发扫描结束,开始补扫')
scanState._triggerSource = 'pending'
runScanAsync()
}
}
}
// 扫描失败后冷却期:避免持续异常(目录权限/挂载问题)时每次请求都触发扫描
const SCAN_RETRY_COOLDOWN_MS = 5 * 60_000 // 5 分钟
// 确保扫描已启动(缓存缺失时懒触发,不阻塞当前请求)
function ensureScan() {
if (isDemoMode()) return
if (rawCache || scanState.scanning) return
// 失败冷却期内不重试,手动 POST /api/scan 不受此限制
if (scanState.lastErrorAt) {
const elapsed = Date.now() - new Date(scanState.lastErrorAt).getTime()
if (elapsed < SCAN_RETRY_COOLDOWN_MS) return
}
scanState._triggerSource = 'lazy' // 懒触发(首次打开 / 写操作后重建缓存),区别于手动、每日、补扫
runScanAsync()
}
function getRawPackages() {
return rawCache || []
}
function invalidateScan() {
rawCache = null
rawCacheAt = null
clearPkgCache() // storage 目录切换 / 清理动作后废弃增量缓存,避免旧目录条目残留
}
// 构建包列表(统一状态计算 + 排序 + 忽略标记)
function buildPackages(keyword = '') {
const threshold = Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD))
const ignoredRows = getIgnoredRows()
const ignoredSet = new Set(ignoredRows.map(r => r.pkg_name))
const ignoredAtMap = Object.fromEntries(ignoredRows.map(r => [r.pkg_name, (r.ignored_at || '').slice(0, 10)]))
const raw = getRawPackages() || []
let list = raw.map(p => {
let status
if (p.status === 'broken') status = 'broken'
else if (ignoredSet.has(p.name)) status = 'ignored'
else status = p.versions > threshold ? 'warn' : 'ok'
return {
name: p.name,
versions: p.versions,
size: p.size,
lastUpdated: p.lastUpdated,
visits: p.visits || 0,
distTags: p.distTags || {},
status,
ignoredAt: ignoredAtMap[p.name] || null,
description: p.description || null,
homepage: p.homepage || null,
// 列表不携带 versionsDetail(包多时响应巨大拖慢加载),详情走 GET /api/packages/:name
}
})
// 检索:不区分大小写子串,匹配包名(版本号匹配由前端本地完成,避免列表携带明细)
const kw = keyword.trim().toLowerCase()
if (kw) {
list = list.filter(p => p.name.toLowerCase().includes(kw) || String(p.size).includes(kw))
}
// 排序:预警置顶 → 正常 → 忽略 → 异常;同状态按版本数降序
const rank = { warn: 0, ok: 1, ignored: 2, broken: 3 }
list.sort((a, b) => {
if (rank[a.status] !== rank[b.status]) return rank[a.status] - rank[b.status]
return b.versions - a.versions
})
return list
}
// —— 登录鉴权:内置账号(Docker 部署通过 VCM_USERNAME/VCM_PASSWORD 环境变量覆盖) ——
const AUTH_USERNAME = process.env.VCM_USERNAME || 'admin'
const AUTH_PASSWORD = process.env.VCM_PASSWORD || '666666'
const SESSION_TTL_MS = 12 * 3600_000 // 会话有效期 12 小时,防长期 token 泄漏
const LOGIN_LOCK = { failures: 3, windowMs: 5 * 60_000, lockMs: 15 * 60_000 } // 3 次失败锁 15 分钟
const sessions = new Map() // token -> { name, at, exp };内存态,进程重启后需重新登录
const loginFail = new Map() // ip -> { count, firstAt, lockedUntil }
// 登录限速:滑动窗口内失败达阈值即锁定(按客户端 IP)
function loginGuard(ip) {
const now = Date.now()
const rec = loginFail.get(ip)
if (!rec) return { locked: false }
if (rec.lockedUntil && now < rec.lockedUntil) {
return { locked: true, retryAfter: Math.ceil((rec.lockedUntil - now) / 1000) }
}
if (rec.firstAt && now - rec.firstAt > LOGIN_LOCK.windowMs) {
loginFail.delete(ip) // 窗口过期重置
return { locked: false }
}
return { locked: false }
}
function recordLoginFail(ip) {
const now = Date.now()
let rec = loginFail.get(ip)
if (!rec || now - rec.firstAt > LOGIN_LOCK.windowMs) rec = { count: 0, firstAt: now, lockedUntil: null }
rec.count += 1
if (rec.count >= LOGIN_LOCK.failures) {
rec.lockedUntil = now + LOGIN_LOCK.lockMs
console.warn(`[auth] IP ${ip} 登录失败 ${rec.count} 次,锁定 ${LOGIN_LOCK.lockMs / 60000} 分钟`)
}
loginFail.set(ip, rec)
}
function recordLoginOk(ip) {
loginFail.delete(ip)
}
app.post('/api/login', (req, res) => {
const { username, password } = req.body || {}
const ip = req.ip || req.socket.remoteAddress || 'unknown'
const userAgent = req.headers['user-agent']
const guard = loginGuard(ip)
if (guard.locked) {
addLog({ action: 'login', pkgName: String(username || ''), result: 'fail', message: '触发锁定', ip, meta: { retryAfter: guard.retryAfter, userAgent } })
return res.status(429).json({ ok: false, message: `尝试过于频繁,请 ${guard.retryAfter} 秒后再试` })
}
if (String(username || '') !== AUTH_USERNAME || String(password || '') !== AUTH_PASSWORD) {
recordLoginFail(ip)
const rec = loginFail.get(ip)
const left = rec && rec.lockedUntil ? Math.ceil((rec.lockedUntil - Date.now()) / 1000) : 0
addLog({ action: 'login', pkgName: String(username || ''), result: 'fail', message: left > 0 ? `触发锁定,剩余 ${left} 秒` : '密码错误', ip, meta: { userAgent } })
return res.status(401).json({
ok: false,
message: left > 0 ? `用户名或密码错误,已触发 ${left} 秒锁定` : '用户名或密码错误',
})
}
recordLoginOk(ip)
const token = randomUUID()
const now = Date.now()
sessions.set(token, { name: AUTH_USERNAME, at: now, exp: now + SESSION_TTL_MS })
addLog({ action: 'login', pkgName: AUTH_USERNAME, result: 'success', message: '登录成功', ip, meta: { userAgent } })
res.json({ ok: true, token, user: { name: AUTH_USERNAME } })
})
app.post('/api/logout', (req, res) => {
const auth = req.headers.authorization || ''
if (auth.startsWith('Bearer ')) sessions.delete(auth.slice(7))
res.json({ ok: true })
})
// 保护全部 /api/*(/login、/logout 除外):未登录或会话过期返回 401,前端据此跳转登录页
app.use('/api', (req, res, next) => {
if (req.path === '/login' || req.path === '/logout') return next()
const auth = req.headers.authorization || ''
const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''
const sess = token ? sessions.get(token) : null
if (!sess) {
return res.status(401).json({ ok: false, message: '未登录或会话已失效' })
}
if (Date.now() > sess.exp) {
sessions.delete(token) // 过期 token 立即作废
return res.status(401).json({ ok: false, message: '登录已过期,请重新登录' })
}
next()
})
// —— API:阈值 ——
// spec §4.2:GET/POST /api/threshold(版本阈值)
app.get('/api/threshold', (req, res) => {
res.json({ versionThreshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)) })
})
app.post('/api/threshold', (req, res) => {
// 兼容 versionThreshold / value 两种字段名
const v = clampInt(req.body.versionThreshold ?? req.body.value, 1, 100)
setSetting('version_threshold', v)
res.json({ ok: true, versionThreshold: v })
})
// 扩展:GET/POST /api/thresholds(版本 + 大小 + 单包例外)
app.get('/api/thresholds', (req, res) => {
let exceptions = []
try { exceptions = JSON.parse(getSetting('exceptions', '[]')) } catch { exceptions = [] }
res.json({
versionThreshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)),
sizeThreshold: Number(getSetting('size_threshold', DEFAULT_SIZE_THRESHOLD)),
exceptions,
})
})
app.post('/api/thresholds', (req, res) => {
const { versionThreshold, sizeThreshold, exceptions } = req.body || {}
setSetting('version_threshold', clampInt(versionThreshold, 1, 100))
setSetting('size_threshold', clampInt(sizeThreshold, 1, 10000))
const ex = Array.isArray(exceptions)
? exceptions.map(e => ({
pkgName: String(e.pkgName || '').trim(),
versionThreshold: clampInt(e.versionThreshold, 1, 100),
sizeThreshold: clampInt(e.sizeThreshold, 1, 10000),
})).filter(e => e.pkgName)
: []
setSetting('exceptions', JSON.stringify(ex))
res.json({ ok: true })
})
// —— API:忽略 ——
app.get('/api/ignored', (req, res) => {
res.json(getIgnored())
})
app.post('/api/ignore', (req, res) => {
const { pkgName } = req.body || {}
if (!pkgName) return res.status(400).json({ ok: false, message: '缺少 pkgName' })
addIgnored(String(pkgName))
addLog({ action: 'ignore', pkgName: String(pkgName) })
res.json({ ok: true })
})
app.delete('/api/ignore/:pkgName', (req, res) => {
const pkgName = decodeURIComponent(req.params.pkgName)
removeIgnored(pkgName)
addLog({ action: 'unignore', pkgName })
res.json({ ok: true })
})
// —— API:包列表(扫描) ——
app.get('/api/packages', (req, res) => {
const demo = isDemoMode()
const storage = currentStorage()
const validation = demo ? { ok: true, message: 'demo' } : validateStorage(storage)
ensureScan() // 懒触发:登录后首次拉包自动启动扫描(rawCache 缺失时),返回 scanning 状态供前端轮询
const packages = buildPackages(req.query.keyword || '') // 先构建(填充缓存)
res.json({
demo,
storageDir: storage || null,
storageValid: validation.ok,
storageMessage: validation.message,
threshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)),
scannedAt: demo ? new Date().toISOString() : (rawCacheAt || null),
scanning: scanState.scanning,
scanPackages: scanState.packages,
scanBytes: scanState.bytes,
totalBytes: demo ? null : (scanState.diskBytes ?? scanState.bytes), // 磁盘占用(跨平台字节和)
packages,
...(demo ? demoSummary() : {}),
})
})
// —— 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 清空 pkgCache 破坏进行中的扫描
if (scanState.scanning) {
return res.json({ ok: false, code: 'SCANNING', message: '扫描进行中,请稍后重试', scanning: true })
}
invalidateScan()
const demo = isDemoMode()
if (demo) {
const scannedAt = new Date().toISOString()
return res.json({ ok: true, demo, scanning: false, scannedAt })
}
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:一键拉取存量包最新版本(异步,期间前端轮询进度) ——
app.post('/api/sync/latest', (req, res) => {
if (isDemoMode()) {
return res.json({ ok: true, demo: true, running: false, message: '演示模式:未配置 Verdaccio 缓存目录,不支持拉取' })
}
if (!syncState.running) {
runSyncLatest({
addLog,
invalidateScan,
notify,
})
}
res.json({
ok: true,
demo: false,
running: syncState.running,
total: syncState.total,
startedAt: syncState.startedAt,
})
})
// —— API:拉取进度(拉取期间由前端轮询) ——
app.get('/api/sync/progress', (req, res) => {
res.json({
running: syncState.running,
done: syncState.done,
total: syncState.total,
processed: syncState.processed,
success: syncState.success,
skipped: syncState.skipped,
failed: syncState.failed,
failedList: syncState.failedList,
current: syncState.current,
error: syncState.error,
startedAt: syncState.startedAt,
finishedAt: syncState.finishedAt,
})
})
// —— API:Top200 榜单 ——
app.get('/api/toplist', (req, res) => {
const { filter } = req.query
// pull/清理后 invalidateScan 会清空 rawCache;此接口也懒触发重建,
// 否则实时比对会拿到空缓存,导致所有包被误判为「待拉取」
if (!isDemoMode() && !rawCache) ensureScan()
const data = getTopList(getRawPackages)
// 透传扫描状态,供前端在扫描结束后再刷新榜单比对结果
data.scanning = scanState.scanning
if (filter === 'missing') {
data.list = data.list.filter(e => !e.cached)
} else if (filter === 'outdated') {
data.list = data.list.filter(e => e.cached && !e.latestCached)
} else if (filter === 'ready') {
data.list = data.list.filter(e => e.cached && e.latestCached)
}
res.json(data)
})
app.get('/api/toplist/progress', (req, res) => {
res.json(toplistState)
})
app.post('/api/toplist/fetch', (req, res) => {
if (!toplistState.running) {
runTopListFetch({
addLog,
getRawPackages,
invalidateScan,
setSetting,
notify,
})
}
res.json({
ok: true,
running: toplistState.running,
phase: toplistState.phase,
startedAt: toplistState.startedAt,
})
})
app.post('/api/toplist/pull', (req, res) => {
if (isDemoMode()) {
return res.json({ ok: true, demo: true, running: false, message: '演示模式:未配置 Verdaccio 缓存目录,不支持拉取' })
}
if (!toplistState.pullRunning) {
runTopListPull({
addLog,
getRawPackages,
invalidateScan,
notify,
})
}
res.json({
ok: true,
demo: false,
running: toplistState.pullRunning,
total: toplistState.pullTotal,
startedAt: toplistState.pullStartedAt,
})
})
app.get('/api/toplist/pull/progress', (req, res) => {
res.json({
running: toplistState.pullRunning,
done: toplistState.pullDone,
total: toplistState.pullTotal,
processed: toplistState.pullProcessed,
success: toplistState.pullSuccess,
skipped: toplistState.pullSkipped,
failed: toplistState.pullFailed,
failedList: toplistState.pullFailedList,
current: toplistState.pullCurrent,
error: toplistState.pullError,
startedAt: toplistState.pullStartedAt,
finishedAt: toplistState.pullFinishedAt,
})
})
// —— API:清理 ——
app.post('/api/clean', (req, res) => {
const { pkgName, keep } = req.body || {}
if (!pkgName) return res.status(400).json({ ok: false, message: '缺少 pkgName' })
if (isDemoMode()) {
return demoClean(res, pkgName, clampInt(keep, 1, 999))
}
const storage = currentStorage()
const result = cleanPackage(storage, pkgName, clampInt(keep, 1, 999))
if (result.ok) {
addLog({
pkgName,
deletedVersions: result.deletedVersions,
deletedCount: result.deletedCount,
freedSpace: result.freedSpace,
})
invalidateScan()
}
res.json(result)
})
// —— API:单版本删除 ——
app.delete('/api/packages/:pkgName/versions/:version', (req, res) => {
const { pkgName, version } = req.params
if (isDemoMode()) {
return demoDeleteVersion(res, pkgName, version)
}
const storage = currentStorage()
const result = deleteSingleVersion(storage, pkgName, version)
if (result.ok) {
addLog({
action: 'delete_version',
pkgName,
version,
result: 'success',
message: `已删除 ${version}`,
deletedVersions: version,
deletedCount: 1,
freedSpace: result.freedSpace,
})
invalidateScan()
}
res.json(result)
})
// —— API:日志(支持 action / batchId 筛选,默认取最近 100 条) ——
app.get('/api/logs', (req, res) => {
const action = req.query.action || null
const batchId = req.query.batchId || null
const limit = Math.min(Number(req.query.limit || 100), 500)
res.json(getLogs({ action, batchId, limit }))
})
// —— API:消息通知 ——
app.get('/api/notifications', (req, res) => {
const sinceId = Number(req.query.sinceId || 0)
const limit = Math.min(Number(req.query.limit || 20), 100)
const list = getNotifications({ sinceId, limit })
res.json({
list,
unreadCount: getUnreadNotificationCount(),
lastId: list.length ? list[0].id : sinceId,
})
})
app.post('/api/notifications/:id/read', (req, res) => {
markNotificationRead(Number(req.params.id))
res.json({ ok: true })
})
app.post('/api/notifications/read-all', (req, res) => {
const updated = markAllNotificationsRead()
res.json({ ok: true, updated })
})
// —— API:手工拉取指定包版本(经私服代理落盘,与 sync-latest 同机制) ——
app.post('/api/pull', async (req, res) => {
const { pkgName, version } = req.body || {}
if (!pkgName || !version) return res.status(400).json({ ok: false, message: '缺少 pkgName 或 version' })
if (isDemoMode()) return res.json({ ok: false, message: '演示模式不支持拉取' })
const batchId = randomUUID()
try {
const { triggerVerdaccio } = await import('./sync-latest.js')
await triggerVerdaccio(pkgName, version)
const logId = addLog({ action: 'pull_manual', pkgName, version, result: 'success', message: `已拉取 ${pkgName}@${version}`, batchId })
notify({ type: 'pull_manual', title: `已拉取 ${pkgName}@${version}`, level: 'info', relatedActionId: logId, batchId, meta: { pkgName, version, result: 'success' } })
invalidateScan()
res.json({ ok: true })
} catch (e) {
const msg = (e && e.message) || '拉取失败'
const logId = addLog({ action: 'pull_manual', pkgName, version, result: 'fail', message: msg, batchId })
notify({ type: 'pull_manual', title: `拉取失败:${pkgName}@${version}`, body: msg, level: 'error', relatedActionId: logId, batchId, meta: { pkgName, version, result: 'fail', error: msg } })
res.json({ ok: false, message: msg })
}
})
// —— API:storage 配置(UI 设置页) ——
app.get('/api/storage', (req, res) => {
const storage = currentStorage()
const demo = isDemoMode()
res.json({
storageDir: storage,
envDir: process.env.VERDACCIO_STORAGE || null,
demo,
validation: demo ? { ok: true, message: 'demo' } : validateStorage(storage),
})
})
app.post('/api/storage', (req, res) => {
const { storageDir } = req.body || {}
if (!storageDir) return res.status(400).json({ ok: false, message: '缺少 storageDir' })
const v = validateStorage(String(storageDir))
if (!v.ok) return res.status(400).json(v)
setSetting('storage_dir', String(storageDir))
res.json({ ok: true })
})
// —— 路由装配:登录/登出公开,其余 /api/* 需鉴权 ——
app.use('/api', authRouter)
app.use('/api', authMiddleware)
app.use('/api', thresholdsRouter)
app.use('/api', ignoredRouter)
app.use('/api', packagesRouter)
app.use('/api', syncRouter)
app.use('/api', toplistRouter)
app.use('/api', cleanRouter)
app.use('/api', logsRouter)
// —— /api 未匹配任何路由 → JSON 404(Express 默认对非 GET 返回 HTML) ——
app.use('/api', apiNotFound)
// —— 生产:托管前端 dist ——
const distDir = path.join(__dirname, '..', 'frontend', 'dist')
@@ -754,6 +63,9 @@ if (fs.existsSync(distDir)) {
})
}
// —— 统一错误处理:Express4 同步 throw 会跳到这里,返回 JSON 而非 HTML 500 ——
app.use(errorHandler)
app.listen(PORT, () => {
const storage = resolveStorageDir()
const mode = isDemoMode() ? 'demo' : 'storage'
@@ -768,113 +80,4 @@ app.listen(PORT, () => {
else console.log('[verdaccio-manager] 未配置 VERDACCIO_STORAGE,运行于演示模式(设置 VCM_DEMO=0 + 真实目录可关闭)')
scheduleDailyScan()
scheduleWeeklyTopList()
})
// 清理登录限速表:超过窗口 + 锁定期的记录不再保留(防 Map 无限增长)
setInterval(() => {
const now = Date.now()
for (const [ip, rec] of loginFail) {
const stale = rec.lockedUntil
? now > rec.lockedUntil
: now - rec.firstAt > LOGIN_LOCK.windowMs
if (stale) loginFail.delete(ip)
}
}, 10 * 60_000).unref()
// —— 每天 03:00(服务器本地时区)自动重扫缓存,逻辑同 POST /api/scan ——
// 选 3 点而非 0 点:避开周一 0 点 Top200 抓榜,减少并发撞车;凌晨 3 点用户访问最稀疏
function scheduleDailyScan() {
const now = new Date()
const next = new Date(now)
next.setHours(3, 0, 0, 0)
if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1) // 今日 3 点已过 → 明日
const wait = next.getTime() - now.getTime()
setTimeout(() => {
try {
if (scanState.scanning) {
// 扫描进行中:标记待扫,扫描结束后自动补扫(见 runScanAsync finally)
scanState.pendingDailyScan = true
console.log('[auto-scan] 扫描进行中,已排队待扫')
} else {
invalidateScan()
scanState._triggerSource = 'daily'
runScanAsync()
console.log(`[auto-scan] ${new Date().toISOString()} 每日自动扫描已启动`)
}
} catch (e) {
console.error('[auto-scan] 每日自动扫描失败:', e.message)
}
scheduleDailyScan() // 安排次日 03:00
}, wait)
}
// —— 每周一 00:00(服务器本地时区)自动抓取 Top200 榜单 ——
// 与每日扫描(03:00)错峰,避免并发冲突
function scheduleWeeklyTopList() {
const now = new Date()
const next = new Date(now)
next.setHours(0, 0, 0, 0)
const dow = next.getDay() // 0=Sun, 1=Mon
let daysUntilMonday = (1 - dow + 7) % 7
if (daysUntilMonday === 0 && now.getTime() > next.getTime()) daysUntilMonday = 7
next.setDate(next.getDate() + daysUntilMonday)
setSetting('toplist_next_run', nowIso(next))
setTimeout(async () => {
try {
await runTopListFetch({ addLog, getRawPackages, invalidateScan, setSetting, notify })
// 通知类型 6:榜单抓取预告(下次下周一 00:00)
const nextRun = new Date()
nextRun.setHours(0, 0, 0, 0)
const dow = nextRun.getDay()
let d = (1 - dow + 7) % 7
if (d === 0 && nextRun.getTime() <= Date.now()) d = 7
nextRun.setDate(nextRun.getDate() + d)
const p = n => String(n).padStart(2, '0')
notify({
type: 'daily_preview',
title: '下次榜单抓取预告',
body: `下周一 ${p(nextRun.getMonth() + 1)}-${p(nextRun.getDate())} 00:00 将抓取 npm Top200 榜单`,
level: 'info',
meta: { taskType: 'toplist_fetch', nextRunAt: nextRun.toISOString() },
})
} catch (e) {
console.error('[toplist] 定时抓榜失败:', e.message)
}
scheduleWeeklyTopList()
}, next.getTime() - now.getTime())
}
function clampInt(v, min, max) {
const n = Number(v)
if (Number.isNaN(n)) return min
return Math.min(max, Math.max(min, Math.round(n)))
}
// —— demo 模式下的清理/删除(内存态) ——
const demoMemory = { logs: [] }
function demoClean(res, pkgName, keep) {
const p = demoPackages().find(x => x.name === pkgName)
if (!p) return res.json({ ok: false, message: '包不存在' })
const sorted = [...p.versionsDetail].sort((a, b) => semverCompare(a.version, b.version))
const toDel = sorted.slice(0, Math.max(0, sorted.length - keep))
if (!toDel.length) return res.json({ ok: false, message: '无需清理' })
const freed = toDel.reduce((s, v) => s + v.size, 0)
const result = {
ok: true,
pkgName,
deletedVersions: toDel.map(v => v.version).join(', '),
deletedCount: toDel.length,
freedSpace: `${freed.toFixed(1)} MB`,
}
// demo 日志并入 logs 表,保持一致
addLog({ pkgName, deletedVersions: result.deletedVersions, deletedCount: result.deletedCount, freedSpace: result.freedSpace })
res.json(result)
}
function demoDeleteVersion(res, pkgName, version) {
const p = demoPackages().find(x => x.name === pkgName)
const v = p?.versionsDetail?.find(x => x.version === version)
if (!v) return res.json({ ok: false, message: '版本不存在' })
addLog({ pkgName, deletedVersions: version, deletedCount: 1, freedSpace: `${v.size.toFixed(1)} MB` })
res.json({ ok: true, freedSpace: `${v.size.toFixed(1)} MB` })
}
})
+13
View File
@@ -0,0 +1,13 @@
/**
* 通用工具与默认值
*/
export const DEFAULT_VERSION_THRESHOLD = 15
export const DEFAULT_SIZE_THRESHOLD = 500
/** 整数钳制:NaN 回退最小值,越界收敛到 [min, max],四舍五入 */
export function clampInt(v, min, max) {
const n = Number(v)
if (Number.isNaN(n)) return min
return Math.min(max, Math.max(min, Math.round(n)))
}
+49
View File
@@ -0,0 +1,49 @@
/**
* 用户操作动作 —— store/index.js 抽离
* - refreshAll / saveThresholds / ignore / unignore / clean / deleteVersion
*/
import api from '../api'
import { refreshPackages, refreshThresholds, refreshLogs, silentRefresh } from './refreshers.js'
import { doRefresh } from './scan.js'
export async function refreshAll() {
// 遮罩由 doRefresh 内的扫描阶段控制:仅「确实发生扫描」(首次部署/缓存缺失)时显示,
// 后端已有缓存则不遮罩,避免每次进入页面闪现 loading
await doRefresh(true)
}
export async function saveThresholds(payload) {
const ok = await api.saveThresholds(payload)
if (ok) {
await refreshThresholds()
await refreshPackages()
}
return ok
}
export async function ignorePkg(name) {
await api.ignore(name)
await refreshPackages()
}
export async function unignorePkg(name) {
await api.unignore(name)
await refreshPackages()
}
export async function cleanPkg(name, keep) {
const res = await api.clean(name, keep)
if (res.ok) {
// 删除触发 invalidateScan,下一次拉包会懒扫描重建缓存 → 静默提示并等待完成
await Promise.all([silentRefresh({ toast: true }), refreshLogs()])
}
return res
}
export async function deleteVersion(name, version) {
const res = await api.deleteVersion(name, version)
if (res.ok) {
await Promise.all([silentRefresh({ toast: true }), refreshLogs()])
}
return res
}
+20 -476
View File
@@ -1,491 +1,35 @@
/**
* 全局数据状态(模块级 reactive 单例,多页面共享)
* - packages:包列表(已排序:预警置顶 → 正常 → 忽略)
* - thresholds:全局阈值 + 单包例外
* - logs:操作日志
* 全局 store 统一出口 —— 状态 / 统计 / 动作按领域拆分为子模块
* - state.js 全局响应式状态
* - stats.js 派生统计与格式化工具
* - refreshers.js 基础数据刷新(包/阈值/日志/静默刷新)
* - scan.js 扫描与整体刷新编排
* - sync.js 存量包最新版本拉取
* - toplist.js Top200 榜单
* - notifications.js 消息通知
* - actions.js 用户操作动作
*/
import { reactive, computed } from 'vue'
import { ElMessage } from 'element-plus'
import api from '../api'
import { storage } from '../utils/storage'
const state = reactive({
packages: [],
threshold: { versionThreshold: 15, sizeThreshold: 500, exceptions: [] },
logs: [],
loading: false,
scannedAt: null,
storageDir: storage.get('vcm-storage-dir') || '',
// 后端目录扫描总字节(所有文件,与 du 同口径;null 表示未知/演示模式)
totalBytes: null,
// 扫描进度(异步扫描期间实时更新,用于遮罩展示)
scanProgress: { packages: 0, bytes: 0 },
// 拉取进度(一键拉取存量包最新版本期间实时更新)
syncProgress: {
running: false, done: false, total: 0, processed: 0,
success: 0, skipped: 0, failed: 0, failedList: [], error: null,
},
// Top200 榜单数据与进度
toplist: [],
toplistSummary: { total: 0, cached: 0, latestCached: 0, missing: 0, outdated: 0 },
toplistProgress: {
running: false, done: false, phase: 'idle', total: 0, processed: 0,
success: 0, skipped: 0, failed: 0, failedList: [], error: null,
},
toplistPullProgress: {
running: false, done: false, total: 0, processed: 0,
success: 0, skipped: 0, failed: 0, failedList: [], error: null,
},
toplistNextRun: null,
toplistLastRun: null,
// 消息通知
notifications: [], // 最近 50 条(下拉展示用)
unreadCount: 0,
lastNotifId: 0, // 轮询游标(仅取 id > lastNotifId 的新通知)
})
// —— 派生统计 ——
const stats = computed(() => {
const valid = state.packages.filter(p => p.status !== 'broken')
// 总占用统计全部物理缓存(含元数据异常的包,其大小按实际 .tgz 计算)
const totalSize = state.packages.reduce((s, p) => s + p.size, 0)
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 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 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 top30 = [...valid]
.sort((a, b) => (hasVisits ? (b.visits || 0) - (a.visits || 0) : b.size - a.size))
.slice(0, 20)
const maxVisit = Math.max(...top30.map(p => p.visits || 0), 1)
const maxSize = Math.max(...top30.map(p => p.size), 1)
return {
pkgCount: valid.length,
totalVersions: state.packages.reduce((s, p) => s + (p.versions || 0), 0),
totalSize,
// 总占用优先用后端「目录所有文件字节和」(与 du 一致),否则回退到包大小之和
totalSizeText: state.totalBytes != null ? fmtBytes(state.totalBytes) : fmtSize(totalSize),
warnCount,
cleanable,
cleanableText: fmtSize(cleanable),
todayUpdated,
maxPkg,
oldestPkg,
top30: top30.map(p => ({
...p,
heat: hasVisits
? Math.round(((p.visits || 0) / maxVisit) * 100)
: Math.round((p.size / maxSize) * 100),
})),
top30By: hasVisits ? 'visits' : 'size',
}
})
function fmtSize(mb) {
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
if (mb >= 0.05) return `${mb.toFixed(1)} MB`
if (mb > 0) return `${Math.max(1, Math.round(mb * 1024))} KB`
return '0 MB'
}
/** 字节 → 可读文本 */
function fmtBytes(b) {
const mb = b / 1024 / 1024
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
if (mb >= 0.05) return `${mb.toFixed(1)} MB`
if (mb > 0) return `${Math.max(1, Math.round(mb * 1024))} KB`
return '0 MB'
}
import { state } from './state.js'
import { stats, fmtSize, fmtBytes } from './stats.js'
import { refreshPackages, refreshThresholds, refreshLogs } from './refreshers.js'
import { refreshNotifications, markNotifRead, markAllNotifsRead } from './notifications.js'
import { syncLatest } from './sync.js'
import { refreshTopList, fetchTopList, pullTopList } from './toplist.js'
import { scanNow } from './scan.js'
import { refreshAll, saveThresholds, ignorePkg, unignorePkg, cleanPkg, deleteVersion } from './actions.js'
export { fmtSize, fmtBytes }
const fmtSizeFn = fmtSize
async function refreshPackages(keyword = '') {
const { packages, scannedAt, scanning, scanPackages, scanBytes, totalBytes } = await api.getPackages(keyword)
state.packages = packages || []
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
}
}
/** 刷新数据:若后端仍在扫描则先轮询进度,扫完再拉取真实数据。
* mask=true:仅在「确实发生扫描」时才开启 loading 遮罩(首次部署后打开页面、缓存缺失)——
* 后端已有缓存时 scanning=false,不会闪现遮罩。
* mask=false:后台静默扫描,即使发生扫描也不遮罩(手动重扫 / 写操作后重扫)。 */
async function doRefresh(mask = false) {
try {
const first = await refreshPackages()
if (first.scanning) {
if (mask) state.loading = true
await pollScanProgress()
await refreshPackages() // 扫描完成后重新拉取完整数据
}
// 刷新后检查一键拉取是否仍在运行(浏览器刷新后恢复沙漏旋转)
const sp = await api.getSyncProgress()
if (sp.running) {
state.syncProgress.running = true
state.syncProgress.done = false
state.syncProgress.total = sp.total || 0
state.syncProgress.processed = sp.processed || 0
state.syncProgress.success = sp.success || 0
state.syncProgress.skipped = sp.skipped || 0
state.syncProgress.failed = sp.failed || 0
state.syncProgress.failedList = sp.failedList || []
state.syncProgress.current = sp.current || null
state.syncProgress.error = sp.error || null
pollSyncProgress()
}
await Promise.all([refreshThresholds(), refreshLogs()])
pollNotifications() // 启动 30s 通知轮询(首次拉最新 20 条)
} catch (e) {
// 网络/后端异常统一提示一次,避免向上穿透成未处理的 Promise rejection
ElMessage.error(e?.message || '数据加载失败,请稍后重试')
} finally {
if (mask) state.loading = false
}
}
/** 静默刷新:检测到扫描时提示「即将后台扫描」并等待完成后再取真实数据(无遮罩) */
async function silentRefresh({ toast = false } = {}) {
const first = await refreshPackages()
if (first.scanning) {
if (toast) ElMessage.info('缓存目录已变化,正在后台重新扫描,完成前部分数据可能不完整')
await pollScanProgress()
await refreshPackages()
}
return first.scanning
}
/** 手动重新扫描:后台静默执行,给出提示,完成后在消息中心通知(不遮罩) */
async function scanNow() {
try {
const res = await api.scanNow()
if (!res.ok && res.code !== 'SCANNING') return res
ElMessage.info('已开始后台扫描,完成后将在消息中心收到通知')
// 静默等待扫描完成并刷新数据(无论本次是否重新触发,loading 都不被置为 true)
await doRefresh(false)
return res
} catch (e) {
ElMessage.error(e?.message || '发起扫描失败,请稍后重试')
return { ok: false }
}
}
let syncPollTimer = null
/** 重置拉取进度状态(触发新一轮拉取时调用) */
function resetSyncProgress(res) {
state.syncProgress.running = true
state.syncProgress.done = false
state.syncProgress.total = res.total || 0
state.syncProgress.processed = 0
state.syncProgress.success = 0
state.syncProgress.skipped = 0
state.syncProgress.failed = 0
state.syncProgress.failedList = []
state.syncProgress.error = null
}
/** 一键拉取存量包最新版本:触发后端任务并轮询进度,完成后刷新列表 */
async function syncLatest() {
try {
const res = await api.syncLatest()
if (!res.ok) return res
if (res.demo) return res // 演示模式:后端不支持拉取,由调用方直接提示
resetSyncProgress(res)
pollSyncProgress()
return res
} catch (e) {
ElMessage.error(e?.message || '发起拉取失败,请稍后重试')
return { ok: false }
}
}
/** 轮询拉取进度,任务结束后刷新包列表与操作日志(新版本已入库) */
function pollSyncProgress() {
clearTimeout(syncPollTimer)
const tick = async () => {
let p
try {
p = await api.getSyncProgress()
} catch {
state.syncProgress.running = false
state.syncProgress.error = '拉取进度查询失败'
return
}
state.syncProgress.running = !!p.running
state.syncProgress.done = !!p.done
state.syncProgress.total = p.total || 0
state.syncProgress.processed = p.processed || 0
state.syncProgress.success = p.success || 0
state.syncProgress.skipped = p.skipped || 0
state.syncProgress.failed = p.failed || 0
state.syncProgress.failedList = p.failedList || []
state.syncProgress.error = p.error || null
if (p.running) {
syncPollTimer = setTimeout(tick, 800)
} else {
clearTimeout(syncPollTimer)
// 拉取会 invalidateScan → 触发懒扫描重建缓存,静默等待完成后再刷新真实数据
await Promise.all([silentRefresh(), refreshLogs()])
}
}
tick()
}
// —— Top200 榜单 ——
let toplistFetchTimer = null
let toplistPullTimer = null
/** 刷新榜单数据(含实时缓存比对状态),可传 filter 筛选子集 */
async function refreshTopList(filter = '') {
let data = await api.getTopList(filter)
// pull/清理后 rawCache 会被清空并触发懒扫描;此时实时比对拿到的是空缓存,
// 全部包会被误判为「待拉取」。等待扫描结束后再取真实比对结果。
if (data.scanning) {
// 复用包列表的扫描进度轮询(等待后端重建缓存)
const started = Date.now()
for (;;) {
await new Promise(r => setTimeout(r, 800))
let p
try { p = await api.getScanProgress() } catch { break }
if (!p.scanning) break
if (Date.now() - started > 120_000) break // 兜底超时,避免无限等待
}
data = await api.getTopList(filter)
}
state.toplist = data.list || []
state.toplistSummary = data.summary || { total: 0 }
state.toplistNextRun = data.nextRun || null
state.toplistLastRun = data.lastRun || null
state.toplistProgress = data.state || state.toplistProgress
return data
}
/** 立即抓榜:触发后端任务并轮询进度 */
async function fetchTopList() {
try {
const res = await api.fetchTopList()
if (res.ok === false) return res
state.toplistProgress.running = true
state.toplistProgress.phase = res.phase || 'search'
pollTopListProgress()
return res
} catch (e) {
ElMessage.error(e?.message || '发起抓榜失败,请稍后重试')
return { ok: false }
}
}
/** 轮询抓榜进度,任务结束后刷新榜单 */
function pollTopListProgress() {
clearTimeout(toplistFetchTimer)
const tick = async () => {
let p
try {
p = await api.getTopListProgress()
} catch {
state.toplistProgress.running = false
state.toplistProgress.error = '抓榜进度查询失败'
return
}
Object.assign(state.toplistProgress, {
running: !!p.running, done: !!p.done, phase: p.phase || 'idle',
total: p.total || 0, processed: p.processed || 0,
success: p.success || 0, skipped: p.skipped || 0, failed: p.failed || 0,
failedList: p.failedList || [], error: p.error || null,
})
if (p.running) {
toplistFetchTimer = setTimeout(tick, 800)
} else {
clearTimeout(toplistFetchTimer)
await refreshTopList()
}
}
tick()
}
/** 一键拉取待缓存/需更新的榜单包:触发后端任务并轮询进度 */
async function pullTopList() {
try {
const res = await api.pullTopList()
if (!res.ok) return res
if (res.demo) return res
state.toplistPullProgress.running = true
state.toplistPullProgress.done = false
state.toplistPullProgress.total = res.total || 0
pollTopListPullProgress()
return res
} catch (e) {
ElMessage.error(e?.message || '发起拉取失败,请稍后重试')
return { ok: false }
}
}
/** 轮询拉取进度,任务结束后刷新榜单与日志 */
function pollTopListPullProgress() {
clearTimeout(toplistPullTimer)
const tick = async () => {
let p
try {
p = await api.getTopListPullProgress()
} catch {
state.toplistPullProgress.running = false
state.toplistPullProgress.error = '拉取进度查询失败'
return
}
Object.assign(state.toplistPullProgress, {
running: !!p.running, done: !!p.done,
total: p.total || 0, processed: p.processed || 0,
success: p.success || 0, skipped: p.skipped || 0, failed: p.failed || 0,
failedList: p.failedList || [], error: p.error || null,
})
if (p.running) {
toplistPullTimer = setTimeout(tick, 800)
} else {
clearTimeout(toplistPullTimer)
// 拉取会 invalidateScan → 触发懒扫描重建缓存(refreshTopList 内部会等待扫描结束)
ElMessage.info('榜单拉取完成,正在后台重新扫描缓存目录以更新比对结果')
await Promise.all([refreshTopList(), refreshLogs()])
}
}
tick()
}
async function refreshThresholds() {
const t = await api.getThresholds()
state.threshold.versionThreshold = t.versionThreshold
state.threshold.sizeThreshold = t.sizeThreshold
state.threshold.exceptions = t.exceptions || []
}
// —— 消息通知 ——
let notifPollTimer = null
/** 30s 轮询通知:新通知合并到头部并更新未读角标(消息经铃铛下拉展示,不再弹 toast) */
function pollNotifications() {
clearTimeout(notifPollTimer)
const tick = async () => {
try {
const data = await api.getNotifications(state.lastNotifId, 20)
if (data.list && data.list.length) {
state.notifications = [...data.list, ...state.notifications].slice(0, 50)
state.lastNotifId = data.lastId
state.unreadCount = data.unreadCount
} else if (data.unreadCount !== undefined) {
state.unreadCount = data.unreadCount
}
} catch { /* 静默失败,下个 tick 继续 */ }
notifPollTimer = setTimeout(tick, 30_000)
}
tick()
}
/** 下拉打开时手动刷新:重置游标拉最新 20 条 */
async function refreshNotifications() {
const data = await api.getNotifications(0, 20)
state.notifications = data.list || []
state.lastNotifId = data.lastId
state.unreadCount = data.unreadCount
}
async function markNotifRead(id) {
const n = state.notifications.find(x => x.id === id)
if (n && !n.read_at) {
n.read_at = new Date().toISOString()
state.unreadCount = Math.max(0, state.unreadCount - 1)
}
await api.markNotificationRead(id)
}
async function markAllNotifsRead() {
state.notifications.forEach(n => { if (!n.read_at) n.read_at = new Date().toISOString() })
state.unreadCount = 0
await api.markAllNotificationsRead()
}
async function refreshLogs({ action = null, batchId = null, limit = 100 } = {}) {
state.logs = await api.getLogs({ action, batchId, limit })
}
async function refreshAll() {
// 遮罩由 doRefresh 内的扫描阶段控制:仅「确实发生扫描」(首次部署/缓存缺失)时显示,
// 后端已有缓存则不遮罩,避免每次进入页面闪现 loading
await doRefresh(true)
}
async function saveThresholds(payload) {
const ok = await api.saveThresholds(payload)
if (ok) {
await refreshThresholds()
await refreshPackages()
}
return ok
}
async function ignorePkg(name) {
await api.ignore(name)
await refreshPackages()
}
async function unignorePkg(name) {
await api.unignore(name)
await refreshPackages()
}
async function cleanPkg(name, keep) {
const res = await api.clean(name, keep)
if (res.ok) {
// 删除触发 invalidateScan,下一次拉包会懒扫描重建缓存 → 静默提示并等待完成
await Promise.all([silentRefresh({ toast: true }), refreshLogs()])
}
return res
}
async function deleteVersion(name, version) {
const res = await api.deleteVersion(name, version)
if (res.ok) {
await Promise.all([silentRefresh({ toast: true }), refreshLogs()])
}
return res
}
export function useStore() {
return {
state,
stats,
fmtSize: fmtSizeFn,
fmtSize,
refreshAll,
refreshPackages,
refreshThresholds,
refreshLogs,
scanNow,
scanNow,
syncLatest,
refreshTopList,
fetchTopList,
@@ -499,4 +43,4 @@ scanNow,
cleanPkg,
deleteVersion,
}
}
}
+49
View File
@@ -0,0 +1,49 @@
/**
* 消息通知状态与轮询 —— store/index.js 抽离
*/
import api from '../api'
import { state } from './state.js'
let notifPollTimer = null
/** 30s 轮询通知:新通知合并到头部并更新未读角标(消息经铃铛下拉展示,不再弹 toast) */
export function pollNotifications() {
clearTimeout(notifPollTimer)
const tick = async () => {
try {
const data = await api.getNotifications(state.lastNotifId, 20)
if (data.list && data.list.length) {
state.notifications = [...data.list, ...state.notifications].slice(0, 50)
state.lastNotifId = data.lastId
state.unreadCount = data.unreadCount
} else if (data.unreadCount !== undefined) {
state.unreadCount = data.unreadCount
}
} catch { /* 静默失败,下个 tick 继续 */ }
notifPollTimer = setTimeout(tick, 30_000)
}
tick()
}
/** 下拉打开时手动刷新:重置游标拉最新 20 条 */
export async function refreshNotifications() {
const data = await api.getNotifications(0, 20)
state.notifications = data.list || []
state.lastNotifId = data.lastId
state.unreadCount = data.unreadCount
}
export async function markNotifRead(id) {
const n = state.notifications.find(x => x.id === id)
if (n && !n.read_at) {
n.read_at = new Date().toISOString()
state.unreadCount = Math.max(0, state.unreadCount - 1)
}
await api.markNotificationRead(id)
}
export async function markAllNotifsRead() {
state.notifications.forEach(n => { if (!n.read_at) n.read_at = new Date().toISOString() })
state.unreadCount = 0
await api.markAllNotificationsRead()
}
+55
View File
@@ -0,0 +1,55 @@
/**
* 基础数据刷新函数 —— store/index.js 抽离
* 供 scan / sync / toplist / actions 各模块复用,避免循环依赖
*/
import { ElMessage } from 'element-plus'
import api from '../api'
import { state } from './state.js'
export async function refreshPackages(keyword = '') {
const { packages, scannedAt, scanning, scanPackages, scanBytes, totalBytes } = await api.getPackages(keyword)
state.packages = packages || []
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 }
}
/** 轮询后端扫描进度,直到扫描完成 */
export 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
}
}
/** 静默刷新:检测到扫描时提示「即将后台扫描」并等待完成后再取真实数据(无遮罩) */
export async function silentRefresh({ toast = false } = {}) {
const first = await refreshPackages()
if (first.scanning) {
if (toast) ElMessage.info('缓存目录已变化,正在后台重新扫描,完成前部分数据可能不完整')
await pollScanProgress()
await refreshPackages()
}
return first.scanning
}
export async function refreshThresholds() {
const t = await api.getThresholds()
state.threshold.versionThreshold = t.versionThreshold
state.threshold.sizeThreshold = t.sizeThreshold
state.threshold.exceptions = t.exceptions || []
}
export async function refreshLogs({ action = null, batchId = null, limit = 100 } = {}) {
state.logs = await api.getLogs({ action, batchId, limit })
}
+63
View File
@@ -0,0 +1,63 @@
/**
* 扫描与整体刷新编排 —— store/index.js 抽离
*/
import { ElMessage } from 'element-plus'
import api from '../api'
import { state } from './state.js'
import { refreshPackages, pollScanProgress, refreshThresholds, refreshLogs } from './refreshers.js'
import { pollSyncProgress } from './sync.js'
import { pollNotifications } from './notifications.js'
/** 刷新数据:若后端仍在扫描则先轮询进度,扫完再拉取真实数据。
* mask=true:仅在「确实发生扫描」时才开启 loading 遮罩(首次部署后打开页面、缓存缺失)——
* 后端已有缓存时 scanning=false,不会闪现遮罩。
* mask=false:后台静默扫描,即使发生扫描也不遮罩(手动重扫 / 写操作后重扫)。 */
export async function doRefresh(mask = false) {
try {
const first = await refreshPackages()
if (first.scanning) {
if (mask) state.loading = true
await pollScanProgress()
await refreshPackages() // 扫描完成后重新拉取完整数据
}
// 刷新后检查一键拉取是否仍在运行(浏览器刷新后恢复沙漏旋转)
const sp = await api.getSyncProgress()
if (sp.running) {
state.syncProgress.running = true
state.syncProgress.done = false
state.syncProgress.total = sp.total || 0
state.syncProgress.processed = sp.processed || 0
state.syncProgress.success = sp.success || 0
state.syncProgress.skipped = sp.skipped || 0
state.syncProgress.failed = sp.failed || 0
state.syncProgress.failedList = sp.failedList || []
state.syncProgress.current = sp.current || null
state.syncProgress.error = sp.error || null
pollSyncProgress()
}
await Promise.all([refreshThresholds(), refreshLogs()])
pollNotifications() // 启动 30s 通知轮询(首次拉最新 20 条)
} catch (e) {
// 网络/后端异常统一提示一次,避免向上穿透成未处理的 Promise rejection
ElMessage.error(e?.message || '数据加载失败,请稍后重试')
} finally {
if (mask) state.loading = false
}
}
/** 手动重新扫描:后台静默执行,给出提示,完成后在消息中心通知(不遮罩) */
export async function scanNow() {
try {
const res = await api.scanNow()
if (!res.ok && res.code !== 'SCANNING') return res
ElMessage.info('已开始后台扫描,完成后将在消息中心收到通知')
// 静默等待扫描完成并刷新数据(无论本次是否重新触发,loading 都不被置为 true)
await doRefresh(false)
return res
} catch (e) {
ElMessage.error(e?.message || '发起扫描失败,请稍后重试')
return { ok: false }
}
}
+43
View File
@@ -0,0 +1,43 @@
/**
* 全局数据状态(模块级 reactive 单例,多页面共享)
* - packages:包列表(已排序:预警置顶 → 正常 → 忽略)
* - threshold:全局阈值 + 单包例外
* - logs:操作日志
*/
import { reactive } from 'vue'
import { storage } from '../utils/storage'
export const state = reactive({
packages: [],
threshold: { versionThreshold: 15, sizeThreshold: 500, exceptions: [] },
logs: [],
loading: false,
scannedAt: null,
storageDir: storage.get('vcm-storage-dir') || '',
// 后端目录扫描总字节(所有文件,与 du 同口径;null 表示未知/演示模式)
totalBytes: null,
// 扫描进度(异步扫描期间实时更新,用于遮罩展示)
scanProgress: { packages: 0, bytes: 0 },
// 拉取进度(一键拉取存量包最新版本期间实时更新)
syncProgress: {
running: false, done: false, total: 0, processed: 0,
success: 0, skipped: 0, failed: 0, failedList: [], error: null,
},
// Top200 榜单数据与进度
toplist: [],
toplistSummary: { total: 0, cached: 0, latestCached: 0, missing: 0, outdated: 0 },
toplistProgress: {
running: false, done: false, phase: 'idle', total: 0, processed: 0,
success: 0, skipped: 0, failed: 0, failedList: [], error: null,
},
toplistPullProgress: {
running: false, done: false, total: 0, processed: 0,
success: 0, skipped: 0, failed: 0, failedList: [], error: null,
},
toplistNextRun: null,
toplistLastRun: null,
// 消息通知
notifications: [], // 最近 50 条(下拉展示用)
unreadCount: 0,
lastNotifId: 0, // 轮询游标(仅取 id > lastNotifId 的新通知)
})
+62
View File
@@ -0,0 +1,62 @@
/**
* 派生统计与格式化工具 —— store/index.js 抽离
*/
import { computed } from 'vue'
import { state } from './state.js'
export const stats = computed(() => {
const valid = state.packages.filter(p => p.status !== 'broken')
// 总占用统计全部物理缓存(含元数据异常的包,其大小按实际 .tgz 计算)
const totalSize = state.packages.reduce((s, p) => s + p.size, 0)
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 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 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 top30 = [...valid]
.sort((a, b) => (hasVisits ? (b.visits || 0) - (a.visits || 0) : b.size - a.size))
.slice(0, 20)
const maxVisit = Math.max(...top30.map(p => p.visits || 0), 1)
const maxSize = Math.max(...top30.map(p => p.size), 1)
return {
pkgCount: valid.length,
totalVersions: state.packages.reduce((s, p) => s + (p.versions || 0), 0),
totalSize,
// 总占用优先用后端「目录所有文件字节和」(与 du 一致),否则回退到包大小之和
totalSizeText: state.totalBytes != null ? fmtBytes(state.totalBytes) : fmtSize(totalSize),
warnCount,
cleanable,
cleanableText: fmtSize(cleanable),
todayUpdated,
maxPkg,
oldestPkg,
top30: top30.map(p => ({
...p,
heat: hasVisits
? Math.round(((p.visits || 0) / maxVisit) * 100)
: Math.round((p.size / maxSize) * 100),
})),
top30By: hasVisits ? 'visits' : 'size',
}
})
export function fmtSize(mb) {
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
if (mb >= 0.05) return `${mb.toFixed(1)} MB`
if (mb > 0) return `${Math.max(1, Math.round(mb * 1024))} KB`
return '0 MB'
}
/** 字节 → 可读文本 */
export function fmtBytes(b) {
const mb = b / 1024 / 1024
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
if (mb >= 0.05) return `${mb.toFixed(1)} MB`
if (mb > 0) return `${Math.max(1, Math.round(mb * 1024))} KB`
return '0 MB'
}
+69
View File
@@ -0,0 +1,69 @@
/**
* 一键拉取存量包最新版本 —— store/index.js 抽离
*/
import { ElMessage } from 'element-plus'
import api from '../api'
import { state } from './state.js'
import { silentRefresh, refreshLogs } from './refreshers.js'
let syncPollTimer = null
/** 重置拉取进度状态(触发新一轮拉取时调用) */
export function resetSyncProgress(res) {
state.syncProgress.running = true
state.syncProgress.done = false
state.syncProgress.total = res.total || 0
state.syncProgress.processed = 0
state.syncProgress.success = 0
state.syncProgress.skipped = 0
state.syncProgress.failed = 0
state.syncProgress.failedList = []
state.syncProgress.error = null
}
/** 一键拉取存量包最新版本:触发后端任务并轮询进度,完成后刷新列表 */
export async function syncLatest() {
try {
const res = await api.syncLatest()
if (!res.ok) return res
if (res.demo) return res // 演示模式:后端不支持拉取,由调用方直接提示
resetSyncProgress(res)
pollSyncProgress()
return res
} catch (e) {
ElMessage.error(e?.message || '发起拉取失败,请稍后重试')
return { ok: false }
}
}
/** 轮询拉取进度,任务结束后刷新包列表与操作日志(新版本已入库) */
export function pollSyncProgress() {
clearTimeout(syncPollTimer)
const tick = async () => {
let p
try {
p = await api.getSyncProgress()
} catch {
state.syncProgress.running = false
state.syncProgress.error = '拉取进度查询失败'
return
}
state.syncProgress.running = !!p.running
state.syncProgress.done = !!p.done
state.syncProgress.total = p.total || 0
state.syncProgress.processed = p.processed || 0
state.syncProgress.success = p.success || 0
state.syncProgress.skipped = p.skipped || 0
state.syncProgress.failed = p.failed || 0
state.syncProgress.failedList = p.failedList || []
state.syncProgress.error = p.error || null
if (p.running) {
syncPollTimer = setTimeout(tick, 800)
} else {
clearTimeout(syncPollTimer)
// 拉取会 invalidateScan → 触发懒扫描重建缓存,静默等待完成后再刷新真实数据
await Promise.all([silentRefresh(), refreshLogs()])
}
}
tick()
}
+125
View File
@@ -0,0 +1,125 @@
/**
* Top200 榜单状态与轮询 —— store/index.js 抽离
*/
import { ElMessage } from 'element-plus'
import api from '../api'
import { state } from './state.js'
import { refreshLogs } from './refreshers.js'
let toplistFetchTimer = null
let toplistPullTimer = null
/** 刷新榜单数据(含实时缓存比对状态),可传 filter 筛选子集 */
export async function refreshTopList(filter = '') {
let data = await api.getTopList(filter)
// pull/清理后 rawCache 会被清空并触发懒扫描;此时实时比对拿到的是空缓存,
// 全部包会被误判为「待拉取」。等待扫描结束后再取真实比对结果。
if (data.scanning) {
// 复用包列表的扫描进度轮询(等待后端重建缓存)
const started = Date.now()
for (;;) {
await new Promise(r => setTimeout(r, 800))
let p
try { p = await api.getScanProgress() } catch { break }
if (!p.scanning) break
if (Date.now() - started > 120_000) break // 兜底超时,避免无限等待
}
data = await api.getTopList(filter)
}
state.toplist = data.list || []
state.toplistSummary = data.summary || { total: 0 }
state.toplistNextRun = data.nextRun || null
state.toplistLastRun = data.lastRun || null
state.toplistProgress = data.state || state.toplistProgress
return data
}
/** 立即抓榜:触发后端任务并轮询进度 */
export async function fetchTopList() {
try {
const res = await api.fetchTopList()
if (res.ok === false) return res
state.toplistProgress.running = true
state.toplistProgress.phase = res.phase || 'search'
pollTopListProgress()
return res
} catch (e) {
ElMessage.error(e?.message || '发起抓榜失败,请稍后重试')
return { ok: false }
}
}
/** 轮询抓榜进度,任务结束后刷新榜单 */
export function pollTopListProgress() {
clearTimeout(toplistFetchTimer)
const tick = async () => {
let p
try {
p = await api.getTopListProgress()
} catch {
state.toplistProgress.running = false
state.toplistProgress.error = '抓榜进度查询失败'
return
}
Object.assign(state.toplistProgress, {
running: !!p.running, done: !!p.done, phase: p.phase || 'idle',
total: p.total || 0, processed: p.processed || 0,
success: p.success || 0, skipped: p.skipped || 0, failed: p.failed || 0,
failedList: p.failedList || [], error: p.error || null,
})
if (p.running) {
toplistFetchTimer = setTimeout(tick, 800)
} else {
clearTimeout(toplistFetchTimer)
await refreshTopList()
}
}
tick()
}
/** 一键拉取待缓存/需更新的榜单包:触发后端任务并轮询进度 */
export async function pullTopList() {
try {
const res = await api.pullTopList()
if (!res.ok) return res
if (res.demo) return res
state.toplistPullProgress.running = true
state.toplistPullProgress.done = false
state.toplistPullProgress.total = res.total || 0
pollTopListPullProgress()
return res
} catch (e) {
ElMessage.error(e?.message || '发起拉取失败,请稍后重试')
return { ok: false }
}
}
/** 轮询拉取进度,任务结束后刷新榜单与日志 */
export function pollTopListPullProgress() {
clearTimeout(toplistPullTimer)
const tick = async () => {
let p
try {
p = await api.getTopListPullProgress()
} catch {
state.toplistPullProgress.running = false
state.toplistPullProgress.error = '拉取进度查询失败'
return
}
Object.assign(state.toplistPullProgress, {
running: !!p.running, done: !!p.done,
total: p.total || 0, processed: p.processed || 0,
success: p.success || 0, skipped: p.skipped || 0, failed: p.failed || 0,
failedList: p.failedList || [], error: p.error || null,
})
if (p.running) {
toplistPullTimer = setTimeout(tick, 800)
} else {
clearTimeout(toplistPullTimer)
// 拉取会 invalidateScan → 触发懒扫描重建缓存(refreshTopList 内部会等待扫描结束)
ElMessage.info('榜单拉取完成,正在后台重新扫描缓存目录以更新比对结果')
await Promise.all([refreshTopList(), refreshLogs()])
}
}
tick()
}