refactor: server.js/store 单文件拆分多模块;新增统一错误处理与 /api 404
This commit is contained in:
@@ -6,6 +6,7 @@ frontend/node_modules/
|
||||
frontend/dist/
|
||||
backend/node_modules/
|
||||
.CodeCounter
|
||||
.VSCodeCounter
|
||||
.dumate
|
||||
.tmp
|
||||
# 运行时数据(SQLite)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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` })
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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` })
|
||||
}
|
||||
})
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 的新通知)
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user