fix(schedule): 每日自动扫描改为 03:00,避开周一 0 点 Top200 抓榜
存档操作日志改造、Top200 榜单、消息通知三个功能实施规划方案
This commit is contained in:
@@ -0,0 +1,601 @@
|
|||||||
|
# 消息通知功能 — 实施方案
|
||||||
|
|
||||||
|
## Context(背景与目标)
|
||||||
|
|
||||||
|
verdaccio-cache-manager 现有的进度反馈依赖前端轮询(`scanProgress` / `syncProgress`),仅在用户停留在触发页面时可见。一旦切走页面或刷新浏览器,批量任务的最终结果就丢失了上下文。拉取包耗时长(单包 10s+,批量可达分钟级),用户无法实时盯着进度条,需要一个**异步通知中心**留存结果。
|
||||||
|
|
||||||
|
本功能新增 6 类非实时通知:
|
||||||
|
|
||||||
|
1. 包版本过多预警(扫描完成后检查)
|
||||||
|
2. 批量拉取存量包结果(含详情入口→操作日志,显示该批次每条拉取日志:操作时间、包名、版本号、拉取结果)
|
||||||
|
3. 手工拉取指定包结果
|
||||||
|
4. 拉取排行榜包结果
|
||||||
|
5. 榜单发生变化通知
|
||||||
|
6. 定时任务预告(下次扫描/抓榜时间)
|
||||||
|
|
||||||
|
### 关键设计决策
|
||||||
|
|
||||||
|
- **实时性**:用户明确说明"用于非实时消息,拉取包很慢没法做到实时"。采用**轮询**(30s 间隔,复用现有 [pollSyncProgress](file:///e:/gitea/verdaccio-cache-manager/frontend/src/store/index.js) 模式),不引入 SSE。新通知到达时未读数徽章实时更新,`warn`/`error` 级别额外弹一次 `ElMessage` 提示。
|
||||||
|
- **入口形态**:顶栏铃铛 + 下拉面板(复用 [AppTopbar.vue avatar-wrap 下拉](file:///e:/gitea/verdaccio-cache-manager/frontend/src/components/AppTopbar.vue) 模式),不新增独立通知页。通知项点击按类型跳转到对应详情页。
|
||||||
|
- **批次关联**:通知与日志通过 `batch_id` 关联。点击"批量拉取结果"通知跳转 `/logs?action=sync_latest&batchId=xxx`,LogsView 按批次筛选显示该批次的逐包日志。
|
||||||
|
- **依赖关系**:本方案假设「操作日志改造方案」([operation-logs-refactor-plan.md](file:///e:/gitea/verdaccio-cache-manager/.trae/documents/operation-logs-refactor-plan.md))已实施(`addLog` 扩展签名 + logs 表 v2 字段 + action 筛选)。本方案在此基础上新增 `logs.batch_id` 列与 `notifications` 表。
|
||||||
|
|
||||||
|
## 数据库改动 — `backend/db.js`
|
||||||
|
|
||||||
|
### 新增 `notifications` 表
|
||||||
|
|
||||||
|
参照 [db.js L18-36](file:///e:/gitea/verdaccio-cache-manager/backend/db.js) 现有建表语句,在 `logs` 表 CREATE 之后追加:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
type TEXT NOT NULL, -- 6 类之一,见下方
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT, -- 简短描述(一行)
|
||||||
|
level TEXT DEFAULT 'info', -- 'info' | 'warn' | 'error'
|
||||||
|
related_action_id INTEGER, -- 关联的 summary log id(点击详情跳转用)
|
||||||
|
batch_id TEXT, -- 关联批次 ID(与 logs.batch_id 对应)
|
||||||
|
meta TEXT, -- JSON 扩展字段
|
||||||
|
read_at DATETIME, -- NULL = 未读
|
||||||
|
created_at DATETIME
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(read_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_id ON notifications(id DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
`created_at` 写入时显式用 [nowIso()](file:///e:/gitea/verdaccio-cache-manager/backend/db.js)(北京时间),与 `addLog` 一致,避免 `DEFAULT CURRENT_TIMESTAMP` 的 UTC 偏差。
|
||||||
|
|
||||||
|
### logs 表新增 `batch_id` 列(迁移幂等)
|
||||||
|
|
||||||
|
参照 [migrateTimezone](file:///e:/gitea/verdaccio-cache-manager/backend/db.js) 模式,在 `logCols` 检查后追加:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (!logCols.includes('batch_id')) {
|
||||||
|
db.exec("ALTER TABLE logs ADD COLUMN batch_id TEXT")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### addLog 签名扩展(追加 batchId,向后兼容)
|
||||||
|
|
||||||
|
在「操作日志改造方案」已扩展的签名基础上追加 `batchId`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export function addLog({
|
||||||
|
action = 'clean', pkgName, deletedVersions = null, deletedCount = 0, freedSpace = null,
|
||||||
|
// —— 操作日志改造方案字段(已存档)——
|
||||||
|
version = null, result = null, message = null, durationMs = null, meta = null, ip = null,
|
||||||
|
// —— 消息通知方案新增 ——
|
||||||
|
batchId = null,
|
||||||
|
} = {}) {
|
||||||
|
// INSERT 时多写 batch_id 列;其余逻辑不变
|
||||||
|
return info.lastInsertRowid
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### getLogs 扩展(追加 batchId 过滤)
|
||||||
|
|
||||||
|
在「操作日志改造方案」已支持的 `action` 过滤基础上追加 `batchId`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export function getLogs({ action = null, batchId = null, limit = 50 } = {}) {
|
||||||
|
let sql = 'SELECT * FROM logs'
|
||||||
|
const where = [], params = []
|
||||||
|
if (action) { where.push('action = ?'); params.push(action) }
|
||||||
|
if (batchId) { where.push('batch_id = ?'); params.push(batchId) }
|
||||||
|
if (where.length) sql += ' WHERE ' + where.join(' AND ')
|
||||||
|
sql += ' ORDER BY id DESC LIMIT ?'
|
||||||
|
params.push(limit)
|
||||||
|
return db.prepare(sql).all(...params)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
兼容无参 `getLogs(50)`:检测首参为数字时走旧路径。
|
||||||
|
|
||||||
|
### 新增导出函数
|
||||||
|
|
||||||
|
```js
|
||||||
|
/** 插入通知,返回 id */
|
||||||
|
export function addNotification({ type, title, body = '', level = 'info',
|
||||||
|
relatedActionId = null, batchId = null, meta = null }) {
|
||||||
|
const info = db.prepare(
|
||||||
|
`INSERT INTO notifications (type, title, body, level, related_action_id, batch_id, meta, read_at, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)`,
|
||||||
|
).run(type, title, body, level, relatedActionId, batchId,
|
||||||
|
meta ? JSON.stringify(meta) : null, nowIso())
|
||||||
|
return info.lastInsertRowid
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉取 sinceId 之后的通知(sinceId=0 取最新 N 条) */
|
||||||
|
export function getNotifications({ sinceId = 0, limit = 20 } = {}) {
|
||||||
|
const rows = db.prepare(
|
||||||
|
`SELECT * FROM notifications WHERE id > ? ORDER BY id DESC LIMIT ?`,
|
||||||
|
).all(sinceId, limit)
|
||||||
|
// meta 反序列化
|
||||||
|
return rows.map(r => ({ ...r, meta: r.meta ? JSON.parse(r.meta) : null }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUnreadNotificationCount() {
|
||||||
|
return db.prepare('SELECT COUNT(*) AS c FROM notifications WHERE read_at IS NULL').get().c
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markNotificationRead(id) {
|
||||||
|
db.prepare('UPDATE notifications SET read_at = ? WHERE id = ? AND read_at IS NULL')
|
||||||
|
.run(nowIso(), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markAllNotificationsRead() {
|
||||||
|
const r = db.prepare('UPDATE notifications SET read_at = ? WHERE read_at IS NULL').run(nowIso())
|
||||||
|
return r.changes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理旧通知:只保留最近 N 条(用户决定:100) */
|
||||||
|
export function pruneNotifications(keep = 100) {
|
||||||
|
db.prepare('DELETE FROM notifications WHERE id NOT IN (SELECT id FROM notifications ORDER BY id DESC LIMIT ?)').run(keep)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**存留策略(用户确认)**:`notifications` 表只保留最近 100 条,超出的旧通知自动清理。在每次 `addNotification` 后调用 `pruneNotifications(100)`(写放大可控,单条 INSERT 后紧跟一次清理)。
|
||||||
|
|
||||||
|
## 核心模块 — `backend/notifier.js`(新建)
|
||||||
|
|
||||||
|
轻量封装,提供 `notify()` 单一入口供 `server.js` / `sync-latest.js` / `npm-toplist.js` 调用:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { addNotification } from './db.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送通知(写 DB;前端轮询拉取)
|
||||||
|
* @param {Object} opts
|
||||||
|
* @param {string} opts.type - 6 类之一
|
||||||
|
* @param {string} opts.title
|
||||||
|
* @param {string} [opts.body]
|
||||||
|
* @param {'info'|'warn'|'error'} [opts.level]
|
||||||
|
* @param {number} [opts.relatedActionId] - 关联 summary log id
|
||||||
|
* @param {string} [opts.batchId]
|
||||||
|
* @param {Object} [opts.meta]
|
||||||
|
* @returns {number} notification id
|
||||||
|
*/
|
||||||
|
export function notify(opts) {
|
||||||
|
const id = addNotification({
|
||||||
|
type: opts.type,
|
||||||
|
title: opts.title,
|
||||||
|
body: opts.body || '',
|
||||||
|
level: opts.level || 'info',
|
||||||
|
relatedActionId: opts.relatedActionId || null,
|
||||||
|
batchId: opts.batchId || null,
|
||||||
|
meta: opts.meta || null,
|
||||||
|
})
|
||||||
|
console.log(`[notify] #${id} [${opts.type}] ${opts.title}`)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 端点 — `backend/server.js`
|
||||||
|
|
||||||
|
在 [GET /api/logs](file:///e:/gitea/verdaccio-cache-manager/backend/server.js) 之后追加:
|
||||||
|
|
||||||
|
| 方法 | 路径 | 响应 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/notifications?sinceId=0&limit=20` | `{list: [...], unreadCount, lastId}` — sinceId=0 时取最新 N 条;meta 已反序列化 |
|
||||||
|
| POST | `/api/notifications/:id/read` | `{ok: true}` — 标记单条已读 |
|
||||||
|
| POST | `/api/notifications/read-all` | `{ok: true, updated: N}` — 全部标记已读 |
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { notify } from './notifier.js'
|
||||||
|
|
||||||
|
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 })
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6 类通知的触发点
|
||||||
|
|
||||||
|
### 1. 包版本过多预警(type: `version_warning`)
|
||||||
|
|
||||||
|
**触发位置**:[server.js runScanAsync](file:///e:/gitea/verdaccio-cache-manager/backend/server.js) `finally` 块,扫描成功后(操作日志改造方案写 `scan` 日志之后)。
|
||||||
|
|
||||||
|
**去重逻辑**:与上次预警快照比对,仅当版本数超阈值的包集合变化时通知(避免每日扫描重复刷屏)。快照存 `settings.last_warning_snapshot`。**只发超阈值预警,不发「已恢复正常」通知**(用户确认:只报忧不报喜,某包清理后回归正常是静默的)——快照比对只用于"超阈值集合是否变化"来控制是否发新预警,不对"恢复"方向发任何通知。
|
||||||
|
|
||||||
|
```js
|
||||||
|
// runScanAsync finally 块末尾(pendingDailyScan 处理之前)
|
||||||
|
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 })) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**跳转**:通知详情 → `/search?status=warn`。
|
||||||
|
|
||||||
|
### 2. 批量拉取存量包结果(type: `sync_batch`)
|
||||||
|
|
||||||
|
**触发位置**:[sync-latest.js runSyncLatest](file:///e:/gitea/verdaccio-cache-manager/backend/sync-latest.js) 汇总 `addLog` 之后(L156-165 附近)。
|
||||||
|
|
||||||
|
**依赖注入扩展**:`runSyncLatest({ addLog, invalidateScan, notify })` — [server.js /api/sync/latest](file:///e:/gitea/verdaccio-cache-manager/backend/server.js) 调用时传入 `notify`。
|
||||||
|
|
||||||
|
```js
|
||||||
|
export async function runSyncLatest({ addLog, invalidateScan, notify }) {
|
||||||
|
if (syncState.running) return
|
||||||
|
const batchId = randomUUID() // 本批次 ID,传给所有逐包 addLog + 汇总 addLog + notify
|
||||||
|
// ... handleOne 调用时 addLog({ action: 'sync_latest', batchId, ... })
|
||||||
|
const summaryLogId = addLog({ action: 'sync', batchId, meta: { success, skipped, failed }, ... })
|
||||||
|
if (notify) notify({
|
||||||
|
type: 'sync_batch',
|
||||||
|
title: syncState.error
|
||||||
|
? '批量拉取失败'
|
||||||
|
: `批量拉取完成:成功 ${syncState.success} · 失败 ${syncState.failed}`,
|
||||||
|
body: syncState.error || `共 ${syncState.total} 个包,跳过 ${syncState.skipped} 个已最新`,
|
||||||
|
level: syncState.failed > 0 ? 'warn' : 'info',
|
||||||
|
relatedActionId: summaryLogId,
|
||||||
|
batchId,
|
||||||
|
meta: { success: syncState.success, skipped: syncState.skipped,
|
||||||
|
failed: syncState.failed, total: syncState.total },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**跳转**:通知详情 → `/logs?action=sync_latest&batchId=<batchId>` — LogsView 按批次筛选显示该批次的逐包日志(操作时间、包名、版本号、拉取结果)。
|
||||||
|
|
||||||
|
### 3. 手工拉取指定包结果(type: `pull_manual`)
|
||||||
|
|
||||||
|
**触发位置**:[server.js POST /api/pull](file:///e:/gitea/verdaccio-cache-manager/backend/server.js)(操作日志改造方案新增端点)。
|
||||||
|
|
||||||
|
```js
|
||||||
|
app.post('/api/pull', async (req, res) => {
|
||||||
|
const { pkgName, version } = req.body || {}
|
||||||
|
// ... 校验 ...
|
||||||
|
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', 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 logId = addLog({ action: 'pull_manual', pkgName, version, result: 'fail', batchId, message: e.message })
|
||||||
|
notify({ type: 'pull_manual', title: `拉取失败:${pkgName}@${version}`, body: e.message, level: 'error',
|
||||||
|
relatedActionId: logId, batchId, meta: { pkgName, version, result: 'fail', error: e.message } })
|
||||||
|
res.json({ ok: false, message: e.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**跳转**:通知详情 → `/logs?action=pull_manual&batchId=<batchId>`。
|
||||||
|
|
||||||
|
### 4. 拉取排行榜包结果(type: `toplist_pull`)
|
||||||
|
|
||||||
|
**触发位置**:npm-toplist.js `runTopListPull` 汇总 `addLog` 之后(依赖 [npm-top200-cache-plan.md](file:///e:/gitea/verdaccio-cache-manager/.trae/documents/npm-top200-cache-plan.md) 实施)。
|
||||||
|
|
||||||
|
**依赖注入扩展**:`runTopListPull({ addLog, invalidateScan, notify })`。
|
||||||
|
|
||||||
|
逻辑同 `sync_batch`:title=`榜单拉取完成:成功 X · 失败 Y`,`relatedActionId` 指向 `pull_toplist_summary` 日志,`batchId` 关联逐包 `pull_toplist` 日志。
|
||||||
|
|
||||||
|
**跳转**:`/logs?action=pull_toplist&batchId=<batchId>`。
|
||||||
|
|
||||||
|
### 5. 榜单发生变化通知(type: `toplist_change`)
|
||||||
|
|
||||||
|
**触发位置**:npm-toplist.js `runTopListFetch` 比对阶段(`replaceTopList` 之前,旧榜单仍在 DB)。
|
||||||
|
|
||||||
|
```js
|
||||||
|
const prevRows = getTopListRows() // 旧榜单
|
||||||
|
const newSet = new Map(newList.map(e => [e.name, e]))
|
||||||
|
const oldSet = new Map(prevRows.map(e => [e.name, e]))
|
||||||
|
const newEntries = newList.filter(e => !oldSet.has(e.name))
|
||||||
|
const dropped = prevRows.filter(e => !newSet.has(e.name))
|
||||||
|
const versionUpdates = newList.filter(e => {
|
||||||
|
const old = oldSet.get(e.name)
|
||||||
|
return old && old.latest_version !== e.latestVersion
|
||||||
|
})
|
||||||
|
if (newEntries.length || dropped.length || versionUpdates.length) {
|
||||||
|
notify({
|
||||||
|
type: 'toplist_change',
|
||||||
|
title: `榜单更新:新进 ${newEntries.length} · 掉出 ${dropped.length} · 版本更新 ${versionUpdates.length}`,
|
||||||
|
body: newEntries.slice(0, 3).map(e => `▲${e.name}`).join('、') || '无新进包',
|
||||||
|
level: 'info',
|
||||||
|
meta: { newEntries, dropped, versionUpdates },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
首次抓榜(旧榜单为空)不发 `toplist_change` 通知(无变化可比)。
|
||||||
|
|
||||||
|
**跳转**:通知详情 → `/toplist`(Top 榜单页)。
|
||||||
|
|
||||||
|
### 6. 定时任务预告(type: `daily_preview`)
|
||||||
|
|
||||||
|
**触发位置**:[server.js scheduleDailyScan](file:///e:/gitea/verdaccio-cache-manager/backend/server.js) setTimeout 回调末尾,自调度 `scheduleDailyScan()` 之前;`scheduleWeeklyTopList` 同理。
|
||||||
|
|
||||||
|
**去重策略**:仅在定时扫描(`_triggerSource === 'daily'` 或 `'pending'`)完成后发一次预告,手动扫描不触发。预告内容为"下次自动扫描时间",避免刷屏。
|
||||||
|
|
||||||
|
```js
|
||||||
|
// scheduleDailyScan setTimeout 回调内
|
||||||
|
if (scanState._triggerSource === 'daily' || scanState._triggerSource === 'pending') {
|
||||||
|
const next = new Date()
|
||||||
|
next.setHours(3, 0, 0, 0) // 次日 03:00(避开周一 0 点 Top200 抓榜)
|
||||||
|
notify({
|
||||||
|
type: 'daily_preview',
|
||||||
|
title: '下次自动扫描预告',
|
||||||
|
body: `明日 ${String(next.getMonth()+1).padStart(2,'0')}-${String(next.getDate()).padStart(2,'0')} 03:00 将自动扫描缓存目录`,
|
||||||
|
level: 'info',
|
||||||
|
meta: { taskType: 'scan', nextRunAt: next.toISOString() },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`scheduleWeeklyTopList` 同理:抓榜完成后预告"下周一 00:00 将抓取 npm Top200 榜单"(`meta.taskType: 'toplist_fetch'`)。两者错峰 3 小时,不再撞车。
|
||||||
|
|
||||||
|
**跳转**:通知详情 → `/`(缓存概览页,无专门调度页)。
|
||||||
|
|
||||||
|
## 前端改动
|
||||||
|
|
||||||
|
### API — `frontend/src/api/index.js`
|
||||||
|
|
||||||
|
参照 [api/index.js authFetch](file:///e:/gitea/verdaccio-cache-manager/frontend/src/api/index.js) 模式新增:
|
||||||
|
|
||||||
|
```js
|
||||||
|
async getNotifications(sinceId = 0, limit = 20) {
|
||||||
|
const r = await authFetch(`/api/notifications?sinceId=${sinceId}&limit=${limit}`)
|
||||||
|
return r.json()
|
||||||
|
},
|
||||||
|
async markNotificationRead(id) {
|
||||||
|
const r = await authFetch(`/api/notifications/${id}/read`, { method: 'POST' })
|
||||||
|
return r.json()
|
||||||
|
},
|
||||||
|
async markAllNotificationsRead() {
|
||||||
|
const r = await authFetch('/api/notifications/read-all', { method: 'POST' })
|
||||||
|
return r.json()
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
`getLogs` 扩展支持 `action` + `batchId`(与「操作日志改造方案」合并实施):
|
||||||
|
|
||||||
|
```js
|
||||||
|
async getLogs({ action = null, batchId = null, limit = 100 } = {}) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (action) params.set('action', action)
|
||||||
|
if (batchId) params.set('batchId', batchId)
|
||||||
|
params.set('limit', limit)
|
||||||
|
const r = await authFetch(`/api/logs?${params}`)
|
||||||
|
return r.json()
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
### Store — `frontend/src/store/index.js`
|
||||||
|
|
||||||
|
参照 [pollSyncProgress](file:///e:/gitea/verdaccio-cache-manager/frontend/src/store/index.js) 轮询模式新增:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// state 新增字段
|
||||||
|
notifications: [], // 最近 50 条(下拉展示用)
|
||||||
|
unreadCount: 0,
|
||||||
|
lastNotifId: 0, // 轮询游标(仅取 id > lastNotifId 的新通知)
|
||||||
|
|
||||||
|
let notifPollTimer = null
|
||||||
|
function pollNotifications() {
|
||||||
|
clearTimeout(notifPollTimer)
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.getNotifications(state.lastNotifId, 20)
|
||||||
|
if (data.list.length) {
|
||||||
|
// 合并到头部,截断保留 50 条
|
||||||
|
state.notifications = [...data.list, ...state.notifications].slice(0, 50)
|
||||||
|
state.lastNotifId = data.lastId
|
||||||
|
state.unreadCount = data.unreadCount
|
||||||
|
// warn/error 级别弹一次 toast
|
||||||
|
data.list.filter(n => n.level === 'warn' || n.level === 'error').forEach(n => {
|
||||||
|
ElMessage({ message: `🔔 ${n.title}`, type: n.level === 'error' ? 'error' : 'warning', duration: 5000 })
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
state.unreadCount = data.unreadCount
|
||||||
|
}
|
||||||
|
} catch { /* 静默失败,下个 tick 继续 */ }
|
||||||
|
notifPollTimer = setTimeout(tick, 30_000) // 30s 轮询
|
||||||
|
}
|
||||||
|
tick()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshNotifications() {
|
||||||
|
// 下拉打开时手动刷新:重置游标拉最新 20 条
|
||||||
|
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 = nowIso() // 本地立刻标记
|
||||||
|
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 = 'now' })
|
||||||
|
state.unreadCount = 0
|
||||||
|
await api.markAllNotificationsRead()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
在 [doRefresh()](file:///e:/gitea/verdaccio-cache-manager/frontend/src/store/index.js) 末尾启动通知轮询(首次加载拉取最新 20 条 + 启动 30s 轮询);`onBeforeUnmount` 清理 timer(App 根组件卸载时)。
|
||||||
|
|
||||||
|
### AppTopbar.vue — 铃铛 + 下拉面板
|
||||||
|
|
||||||
|
参照 [AppTopbar.vue avatar-wrap 下拉](file:///e:/gitea/verdaccio-cache-manager/frontend/src/components/AppTopbar.vue) 模式,在 `.topbar-right` 内 `theme-toggle` 与 `avatar-wrap` 之间插入铃铛:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div class="notif-wrap" ref="notifWrap">
|
||||||
|
<button class="notif-bell" @click="toggleNotif" :title="'消息通知'">
|
||||||
|
🔔
|
||||||
|
<span v-if="state.unreadCount" class="notif-badge">{{ state.unreadCount > 99 ? '99+' : state.unreadCount }}</span>
|
||||||
|
</button>
|
||||||
|
<transition name="pop">
|
||||||
|
<div v-if="notifOpen" class="notif-panel">
|
||||||
|
<div class="notif-header">
|
||||||
|
<span>🔔 消息通知</span>
|
||||||
|
<button v-if="state.unreadCount" @click="markAllRead">全部已读</button>
|
||||||
|
</div>
|
||||||
|
<div class="notif-list">
|
||||||
|
<div v-for="n in state.notifications" :key="n.id"
|
||||||
|
class="notif-item" :class="{ unread: !n.read_at }"
|
||||||
|
@click="handleNotifClick(n)">
|
||||||
|
<span class="notif-emo">{{ typeMeta(n.type).emo }}</span>
|
||||||
|
<div class="notif-body">
|
||||||
|
<div class="notif-title">{{ n.title }}</div>
|
||||||
|
<div class="notif-sub">{{ n.body }} · {{ fmtTime(n.created_at) }}</div>
|
||||||
|
</div>
|
||||||
|
<span v-if="hasDeepLink(n)" class="notif-go">→</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!state.notifications.length" class="notif-empty">暂无通知</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
`typeMeta(type)` 映射 6 类的 emoji:`version_warning` 🚨 / `sync_batch` 📥 / `pull_manual` ✋ / `toplist_pull` 🏆 / `toplist_change` 📊 / `daily_preview` ⏰。
|
||||||
|
|
||||||
|
`handleNotifClick(n)` 按类型路由跳转:
|
||||||
|
|
||||||
|
| type | 跳转 |
|
||||||
|
|---|---|
|
||||||
|
| `sync_batch` | `/logs?action=sync_latest&batchId=<n.batch_id>` |
|
||||||
|
| `pull_manual` | `/logs?action=pull_manual&batchId=<n.batch_id>` |
|
||||||
|
| `toplist_pull` | `/logs?action=pull_toplist&batchId=<n.batch_id>` |
|
||||||
|
| `toplist_change` | `/toplist` |
|
||||||
|
| `version_warning` | `/search?status=warn` |
|
||||||
|
| `daily_preview` | `/` |
|
||||||
|
|
||||||
|
点击后调用 `markNotifRead(n.id)` 关闭面板。
|
||||||
|
|
||||||
|
下拉打开时调用 `refreshNotifications()` 拉最新 20 条。点击外部关闭(复用 [onDocClick](file:///e:/gitea/verdaccio-cache-manager/frontend/src/components/AppTopbar.vue) 模式)。
|
||||||
|
|
||||||
|
### 移动端 m-topbar(App.vue)+ 独立通知页(用户选定)
|
||||||
|
|
||||||
|
在 [App.vue m-topbar](file:///e:/gitea/verdaccio-cache-manager/frontend/src/App.vue) 右侧 `☰` 旁加铃铛按钮(带角标),点击**跳转独立通知页 `/notifications`**(用户确认:移动端空间小,独立滚动页体验更佳,不做抽屉)。移动端铃铛仅显示角标 + 跳转,不展开下拉。
|
||||||
|
|
||||||
|
**独立通知页 `NotificationsView.vue`**(新建):移动端专用,全屏列表展示 `state.notifications`,支持标记已读、全部已读、点击通知跳转对应详情(复用 `handleNotifClick` 的分发逻辑,抽成共享方法)。路由新增 `{ path: '/notifications', name: 'notifications', component: NotificationsView }`(桌面端铃铛仍用下拉,此页主要覆盖移动端,但桌面端也可通过输入 URL 访问)。
|
||||||
|
|
||||||
|
### LogsView.vue — 接收 query 参数筛选批次
|
||||||
|
|
||||||
|
参照 [LogsView.vue](file:///e:/gitea/verdaccio-cache-manager/frontend/src/views/LogsView.vue) 现有结构(「操作日志改造方案」已加 action 筛选 tab),新增读取 route.query 透传:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const { action, batchId } = route.query
|
||||||
|
// 透传到 refreshLogs,后端按 action + batchId 过滤
|
||||||
|
refreshLogs({ action: action || null, batchId: batchId || null })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换 action tab 时同步更新 URL query(方便分享/书签)
|
||||||
|
watch(() => route.query, (q) => {
|
||||||
|
refreshLogs({ action: q.action || null, batchId: q.batchId || null })
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`refreshLogs({action, batchId})` 透传到 `api.getLogs({action, batchId, limit: 100})`。
|
||||||
|
|
||||||
|
## 实施顺序
|
||||||
|
|
||||||
|
1. **db.js**:新增 `notifications` 表 + `logs.batch_id` 列迁移 + `addLog` 接受 `batchId` + `addNotification`/`getNotifications`/`markNotificationRead`/`markAllNotificationsRead` 导出 + `getLogs` 支持 batchId 过滤
|
||||||
|
2. **notifier.js**:新建 `notify()` 入口
|
||||||
|
3. **server.js**:导入 notify;runScanAsync finally 加预警检查 + daily_preview;POST /api/pull 加通知;新增 `/api/notifications*` 端点;`/api/sync/latest` 调用 runSyncLatest 时传 notify
|
||||||
|
4. **sync-latest.js**:runSyncLatest 入口生成 batchId;handleOne 逐包 addLog 传 batchId;汇总 addLog 传 batchId + notify
|
||||||
|
5. **npm-toplist.js**(待 [Top200 方案](file:///e:/gitea/verdaccio-cache-manager/.trae/documents/npm-top200-cache-plan.md) 实施时一并加):runTopListFetch 加 toplist_change 通知;runTopListPull 加 toplist_pull 通知 + batchId;scheduleWeeklyTopList 加 daily_preview(taskType=toplist_fetch)
|
||||||
|
6. **api/index.js**:新增 getNotifications / markNotificationRead / markAllNotificationsRead;getLogs 支持 action+batchId
|
||||||
|
7. **store/index.js**:新增 notifications/unreadCount/lastNotifId state + pollNotifications(30s)+ refreshNotifications/markNotifRead/markAllNotifsRead;doRefresh 末尾启动轮询
|
||||||
|
8. **AppTopbar.vue**:铃铛 + 角标 + notif-panel 下拉 + handleNotifClick 路由 + 点击外部关闭
|
||||||
|
9. **App.vue m-topbar**:移动端铃铛入口(角标 + 跳转列表页或展开下拉)
|
||||||
|
10. **LogsView.vue**:读取 route.query.action / batchId 透传 refreshLogs + watch 同步
|
||||||
|
11. **验证**
|
||||||
|
|
||||||
|
## 验证步骤
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 启动后端 + 前端
|
||||||
|
cd e:\gitea\verdaccio-cache-manager\backend && node server.js
|
||||||
|
cd e:\gitea\verdaccio-cache-manager\frontend && npm run dev
|
||||||
|
|
||||||
|
# 2. 验证迁移
|
||||||
|
sqlite3 backend/data/manager.db "PRAGMA table_info(logs);" # 应见 batch_id 列
|
||||||
|
sqlite3 backend/data/manager.db "PRAGMA table_info(notifications);" # 应见 9 字段
|
||||||
|
|
||||||
|
# 3. 触发扫描(先调低 version_threshold 让某些包超阈值,验证预警通知)
|
||||||
|
curl -X POST -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
|
||||||
|
-d '{"versionThreshold":3}' http://localhost:3000/api/thresholds
|
||||||
|
curl -X POST -H "Authorization: Bearer <token>" http://localhost:3000/api/scan
|
||||||
|
sleep 5
|
||||||
|
sqlite3 backend/data/manager.db "SELECT type, title, level FROM notifications WHERE type='version_warning' ORDER BY id DESC LIMIT 1;"
|
||||||
|
# 应见 1 条预警,level=warn
|
||||||
|
|
||||||
|
# 4. 触发批量拉取
|
||||||
|
curl -X POST -H "Authorization: Bearer <token>" http://localhost:3000/api/sync/latest
|
||||||
|
sleep 30
|
||||||
|
sqlite3 backend/data/manager.db "SELECT type, title, batch_id FROM notifications WHERE type='sync_batch' ORDER BY id DESC LIMIT 1;"
|
||||||
|
sqlite3 backend/data/manager.db "SELECT batch_id, COUNT(*) FROM logs WHERE action='sync_latest' GROUP BY batch_id ORDER BY 1 DESC LIMIT 1;"
|
||||||
|
# 通知的 batch_id 应与日志的 batch_id 一致
|
||||||
|
|
||||||
|
# 5. 手工拉取
|
||||||
|
curl -X POST -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
|
||||||
|
-d '{"pkgName":"lodash","version":"4.17.21"}' http://localhost:3000/api/pull
|
||||||
|
sqlite3 backend/data/manager.db "SELECT type, title, level, batch_id FROM notifications WHERE type='pull_manual' ORDER BY id DESC LIMIT 1;"
|
||||||
|
|
||||||
|
# 6. 验证未读数 + 列表接口
|
||||||
|
curl -H "Authorization: Bearer <token>" "http://localhost:3000/api/notifications?sinceId=0&limit=20"
|
||||||
|
# 应返回 list + unreadCount + lastId
|
||||||
|
|
||||||
|
# 7. 标记已读
|
||||||
|
curl -X POST -H "Authorization: Bearer <token>" http://localhost:3000/api/notifications/1/read
|
||||||
|
curl -X POST -H "Authorization: Bearer <token>" http://localhost:3000/api/notifications/read-all
|
||||||
|
|
||||||
|
# 8. 浏览器端到端验证
|
||||||
|
# - 登录后顶栏右侧出现铃铛 + 未读角标
|
||||||
|
# - 点击铃铛展开下拉,列出步骤 3-5 的通知
|
||||||
|
# - 点击 sync_batch 通知 → 跳转 /logs?action=sync_latest&batchId=xxx
|
||||||
|
# → 仅显示该批次的逐包日志(时间/包名/版本号/拉取结果)
|
||||||
|
# - 30s 后角标自动更新(轮询拉新通知)
|
||||||
|
# - warn/error 级别通知到达时应有 ElMessage toast 弹出
|
||||||
|
# - 点击"全部已读"按钮 → 角标消失
|
||||||
|
# - 移动端 m-topbar 铃铛可见 + 角标 + 可点开
|
||||||
|
# - 验证去重:再次扫描(不改阈值)→ 不应新增 version_warning 通知
|
||||||
|
```
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
# npm Top200 周榜缓存功能 — 实施方案
|
||||||
|
|
||||||
|
## Context(背景与目标)
|
||||||
|
|
||||||
|
verdaccio-cache-manager 当前只能扫描私服 storage 已缓存的包,缺乏「应该缓存哪些热门包」的外部榜单来源。新功能引入 npm 周下载量 Top200 榜单作为缓存预演依据:
|
||||||
|
|
||||||
|
1. 按既定文档方案抓取 npm 周下载量 Top200(22 组关键词 × Search API 粗排 350 → Downloads API 复核截 200)
|
||||||
|
2. 榜单与本地 storage 扫描结果(`rawCache`)比对,每条标记三态:待拉取 / 需更新 / 已就绪
|
||||||
|
3. 前端展示榜单 + 状态徽章 + 一键拉取待缓存包(经私服代理落盘,与 sync-latest 同机制)
|
||||||
|
4. 每周一 00:00(本地时区)自动触发抓榜任务;提供手动触发按钮以便调试和按需刷新
|
||||||
|
|
||||||
|
不引入新依赖:网络请求复用 Node 内置 `fetch`;落盘机制复用 sync-latest 的 `triggerVerdaccio`;状态机对称 `syncState`;前端复用 `authFetch` + 现有 reactive store。
|
||||||
|
|
||||||
|
## 数据库改动 — `backend/db.js`
|
||||||
|
|
||||||
|
新增 `top_list` 表(在现有 `settings`/`ignored_packages`/`logs` 之后追加 `db.exec`):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS top_list (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
rank INTEGER NOT NULL, -- 1~200
|
||||||
|
pkg_name TEXT NOT NULL UNIQUE, -- 含 @scope
|
||||||
|
latest_version TEXT NOT NULL, -- Search API 返回的最新版本
|
||||||
|
weekly_downloads INTEGER NOT NULL DEFAULT 0, -- Downloads API 复核值
|
||||||
|
search_weekly INTEGER DEFAULT 0, -- Search 快照值(参考)
|
||||||
|
keyword TEXT, -- 命中的关键词
|
||||||
|
fetched_at DATETIME -- 入榜时间(北京时间,复用 nowIso())
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_top_list_rank ON top_list(rank);
|
||||||
|
```
|
||||||
|
|
||||||
|
`settings` 表新增两个键:`toplist_last_run`(最近一次抓榜完成时间)、`toplist_next_run`(下次定时执行时间,前端展示用)。
|
||||||
|
|
||||||
|
新增导出函数:
|
||||||
|
- `replaceTopList(rows)` — 事务内 `DELETE FROM top_list` + 批量 `INSERT`(每轮抓榜完整替换)
|
||||||
|
- `getTopListRows()` — `SELECT * FROM top_list ORDER BY rank`
|
||||||
|
- `getTopListCount()` — 计数
|
||||||
|
|
||||||
|
不动 `addLog`,复用现有签名,新增 action 值:`toplist_fetch` / `toplist_pull`。
|
||||||
|
|
||||||
|
## 核心模块 — `backend/npm-toplist.js`(新建)
|
||||||
|
|
||||||
|
仿照 [sync-latest.js](file:///e:/gitea/verdaccio-cache-manager/backend/sync-latest.js) 结构:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const toplistState = {
|
||||||
|
running: false, done: false,
|
||||||
|
phase: 'idle', // 'idle' | 'search' | 'recheck' | 'compare' | 'done'
|
||||||
|
total: 0, processed: 0,
|
||||||
|
success: 0, skipped: 0, failed: 0,
|
||||||
|
failedList: [], // [{name, reason}]
|
||||||
|
current: null, // 当前处理的关键词或包名
|
||||||
|
error: null,
|
||||||
|
startedAt: null, finishedAt: null,
|
||||||
|
// 拉取子状态(与 sync-latest 对称)
|
||||||
|
pullRunning: false, pullTotal: 0, pullProcessed: 0,
|
||||||
|
pullSuccess: 0, pullSkipped: 0, pullFailed: 0, pullFailedList: [],
|
||||||
|
pullCurrent: null, pullStartedAt: null, pullFinishedAt: null,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常量
|
||||||
|
|
||||||
|
```js
|
||||||
|
const KEYWORDS = ['javascript','node','react','vue','css','util','web','frontend',
|
||||||
|
'angular','typescript','api','test','tool','cli','server','build','http',
|
||||||
|
'data','component','style','message','theme']
|
||||||
|
const SEARCH_API = 'https://registry.npmjs.org/-/v1/search'
|
||||||
|
const DOWNLOADS_API = 'https://api.npmjs.org/downloads/point/last-week'
|
||||||
|
const SEARCH_SIZE = 250
|
||||||
|
const COARSE_TOP = 350
|
||||||
|
const FINAL_TOP = 200
|
||||||
|
const REQ_INTERVAL_MS = 1500 // 请求间隔:调宽至 1.5s,避开 npm 匿名隐性限流(用户允许慢)
|
||||||
|
const RECHECK_CONCURRENCY = 4 // @ 包逐个复核并发:降为 4,避免并发瞬时挤爆
|
||||||
|
const RECHECK_BATCH = 70 // 非 @ 包批量复核每批大小(保持不变,一次请求复核 70 个,天然省请求)
|
||||||
|
const PULL_CONCURRENT = 3 // 拉取并发(与 sync-latest 一致)
|
||||||
|
const RATE_LIMIT_BACKOFF_MAX = 8 // 429 退避最大重试指数(2^n 秒,最高 256s)
|
||||||
|
```
|
||||||
|
|
||||||
|
> **限流说明**:npm Search/Downloads 是公共匿名接口,无 token 标识,有隐性速率限制(超了返回 429 或临时封 IP)。用户明确"更新榜单不急、接口慢慢调",故:
|
||||||
|
> - Search 阶段 22 次 × 1.5s ≈ 33s(可接受)
|
||||||
|
> - Recheck 阶段靠 70 个/批批量复核省请求 + 4 并发控制峰值
|
||||||
|
> - 任何一步被 429 时按 2^n 秒退避重试(最多 8 次),如仍失败则计入 failedList 不中断
|
||||||
|
> 该容忍上限为"单个请求隔 1 分钟",实际按上面更快的间隔跑,若实测仍触发限流再继续放宽。
|
||||||
|
|
||||||
|
### 关键函数
|
||||||
|
|
||||||
|
| 函数 | 签名 | 职责 |
|
||||||
|
|---|---|---|
|
||||||
|
| `runTopListFetch({addLog, getRawPackages, invalidateScan, setSetting})` | async | 主流程:search → recheck → merge → compare → persist |
|
||||||
|
| `runTopListPull({addLog, invalidateScan})` | async | 拉取所有 cached=false 或 latestCached=false 的包 |
|
||||||
|
| `getTopList()` | 同步 | 返回 DB 当前快照 + 实时比对结果(不写 DB) |
|
||||||
|
|
||||||
|
**Search 阶段**:
|
||||||
|
- 22 个关键词顺序调用 `${SEARCH_API}?text=${kw}&size=${SEARCH_SIZE}`
|
||||||
|
- 每个响应解析 `objects[].package` 取 `{name, version}`,`objects[].downloads.weekly` 取快照值
|
||||||
|
- 间隔 ≥400ms,单关键词失败计入 `failedList` 不中断
|
||||||
|
|
||||||
|
**Recheck 阶段**:
|
||||||
|
- 粗排 350 候选 → 拆 @ 包和非 @ 包
|
||||||
|
- 非 @ 包 70 个/批:`GET ${DOWNLOADS_API}/${names.join(',')}` 返回 `{[name]: {downloads: N}}`
|
||||||
|
- @ 包逐个调用,16 并发 worker pool
|
||||||
|
- 用 recheck 值覆盖 search 快照值,按降序截取前 200
|
||||||
|
|
||||||
|
**Compare 阶段**(参考 [scanner.js](file:///e:/gitea/verdaccio-cache-manager/backend/scanner.js) 的 `versionsDetail` 字段):
|
||||||
|
```js
|
||||||
|
function compareWithCache(topList, rawPackages) {
|
||||||
|
const pkgMap = new Map(rawPackages.map(p => [p.name, p]))
|
||||||
|
return topList.map(entry => {
|
||||||
|
const pkg = pkgMap.get(entry.name)
|
||||||
|
const vs = pkg && Array.isArray(pkg.versionsDetail) ? pkg.versionsDetail : []
|
||||||
|
const versionSet = new Set(vs.map(v => v.version))
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
cached: versionSet.size > 0,
|
||||||
|
latestCached: versionSet.has(entry.latestVersion),
|
||||||
|
cachedVersions: [...versionSet],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
三态:`cached=false`(待拉取) / `cached=true && latestCached=false`(需更新) / `cached=true && latestCached=true`(已就绪)
|
||||||
|
|
||||||
|
**Pull 阶段**(复用 [sync-latest.js#triggerVerdaccio](file:///e:/gitea/verdaccio-cache-manager/backend/sync-latest.js) 的实现思路):
|
||||||
|
- 由于 `triggerVerdaccio` 当前未导出,**直接 import 复用**:在 sync-latest.js 中添加 `export { triggerVerdaccio }`,npm-toplist.js 中 `import { triggerVerdaccio } from './sync-latest.js'`
|
||||||
|
- 拉取范围:`cached=false` 的包(待拉取)+ `cached=true && latestCached=false` 的包(需更新)
|
||||||
|
- 并发 3,失败计入 `pullFailedList` 不中断
|
||||||
|
- 完成后调用 `invalidateScan()` 让下一次请求触发重扫,新版本可见
|
||||||
|
|
||||||
|
## API 端点 — `backend/server.js`
|
||||||
|
|
||||||
|
在 `/api/sync/progress` 之后追加:
|
||||||
|
|
||||||
|
| 方法 | 路径 | 响应 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/toplist` | `{list: [...], summary: {total, cached, latestCached, missing, outdated}, state: toplistState, lastRun, nextRun}` |
|
||||||
|
| GET | `/api/toplist?filter=missing` | 仅返回 cached=false 的子集 |
|
||||||
|
| POST | `/api/toplist/fetch` | `{ok, running, startedAt}` — 触发抓榜 |
|
||||||
|
| GET | `/api/toplist/progress` | `toplistState` 全字段(轮询用) |
|
||||||
|
| POST | `/api/toplist/pull` | `{ok, running, total, startedAt}` — 触发拉取 |
|
||||||
|
| GET | `/api/toplist/pull/progress` | `{running, done, total, processed, success, skipped, failed, failedList, current, error, startedAt, finishedAt}` |
|
||||||
|
|
||||||
|
GET `/api/toplist` 内部实时调用 `compareWithCache(topList, rawCache || [])`,前端拿到的 `cached/latestCached` 永远反映私服实况,不依赖落库字段。
|
||||||
|
|
||||||
|
## 定时调度 — `backend/server.js`
|
||||||
|
|
||||||
|
新增 `scheduleWeeklyTopList()`,与现有 [scheduleDailyScan](file:///e:/gitea/verdaccio-cache-manager/backend/server.js) 模式对称:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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: getRawPackages, invalidateScan, setSetting }) }
|
||||||
|
catch (e) { console.error('[toplist] 定时抓榜失败:', e.message) }
|
||||||
|
scheduleWeeklyTopList()
|
||||||
|
}, next.getTime() - now.getTime())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `app.listen` 回调里 `scheduleDailyScan()` 之后并列调用一次。
|
||||||
|
|
||||||
|
## 前端改动
|
||||||
|
|
||||||
|
### 路由 — `frontend/src/router/index.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
import TopListView from '../views/TopListView.vue'
|
||||||
|
// routes 数组新增:
|
||||||
|
{ path: '/toplist', name: 'toplist', component: TopListView },
|
||||||
|
```
|
||||||
|
|
||||||
|
### 顶栏与底栏菜单
|
||||||
|
|
||||||
|
[AppTopbar.vue](file:///e:/gitea/verdaccio-cache-manager/frontend/src/components/AppTopbar.vue) 的 `navs` 数组追加 `{ path: '/toplist', label: 'Top榜单', emo: '🏆' }`,[MobileTabbar.vue](file:///e:/gitea/verdaccio-cache-manager/frontend/src/components/MobileTabbar.vue) 的 `tabs` 同步追加。
|
||||||
|
|
||||||
|
### Store — `frontend/src/store/index.js`
|
||||||
|
|
||||||
|
state 新增字段:
|
||||||
|
```js
|
||||||
|
toplist: [],
|
||||||
|
toplistProgress: { running: false, phase: 'idle', total: 0, processed: 0, ... },
|
||||||
|
toplistPullProgress: { running: false, total: 0, processed: 0, ... },
|
||||||
|
toplistNextRun: null,
|
||||||
|
toplistLastRun: null,
|
||||||
|
```
|
||||||
|
|
||||||
|
新增 actions:`refreshTopList(filter)`、`fetchTopList()`、`pullTopList()`、`pollTopListProgress()`、`pollTopListPullProgress()`,仿照 [syncLatest](file:///e:/gitea/verdaccio-cache-manager/frontend/src/store/index.js) + `pollSyncProgress` 模式实现。
|
||||||
|
|
||||||
|
### API — `frontend/src/api/index.js`
|
||||||
|
|
||||||
|
新增方法:`getTopList(filter)`、`fetchTopList()`、`getTopListProgress()`、`pullTopList()`、`getTopListPullProgress()`,均通过 `authFetch` 调用对应端点。
|
||||||
|
|
||||||
|
### 主页面 — `frontend/src/views/TopListView.vue`(新建)
|
||||||
|
|
||||||
|
参考 [IgnoredView.vue](file:///e:/gitea/verdaccio-cache-manager/frontend/src/views/IgnoredView.vue) 的表格 + [SearchView.vue](file:///e:/gitea/verdaccio-cache-manager/frontend/src/views/SearchView.vue) 的筛选条:
|
||||||
|
|
||||||
|
- **顶部摘要条**:`共 200 · 已缓存 X · 待拉取 Y · 需更新 Z · 下次抓榜 2026-09-28 00:00`
|
||||||
|
- **操作区**:立即抓榜按钮 / 一键拉取按钮 / 筛选 tabs(全部 · 待拉取 · 需更新 · 已就绪)
|
||||||
|
- **进度条**:抓榜期间显示 phase(搜索中 → 重校验 → 对比中)+ processed/total;拉取期间显示 success/skipped/failed
|
||||||
|
- **表格列**:排名 / 包名 / 最新版本 / 周下载(千分位) / 状态徽章 / 已缓存版本 / 操作
|
||||||
|
- **状态徽章**:复用 [StatusTag.vue](file:///e:/gitea/verdaccio-cache-manager/frontend/src/components/StatusTag.vue) 风格,三色(红/黄/绿)
|
||||||
|
- **空态**:DB 无数据时提示「尚无 Top200 数据,点击立即抓榜获取」
|
||||||
|
- **轮询**:触发后 800ms 拉一次 progress,任务结束后刷新 list
|
||||||
|
|
||||||
|
## 验证步骤
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 启动后端
|
||||||
|
cd e:\gitea\verdaccio-cache-manager\backend && node server.js
|
||||||
|
|
||||||
|
# 2. 启动前端
|
||||||
|
cd e:\gitea\verdaccio-cache-manager\frontend && npm run dev
|
||||||
|
|
||||||
|
# 3. 触发抓榜(登录后)
|
||||||
|
curl -X POST -H "Authorization: Bearer <token>" http://localhost:3000/api/toplist/fetch
|
||||||
|
|
||||||
|
# 4. 轮询进度
|
||||||
|
curl -H "Authorization: Bearer <token>" http://localhost:3000/api/toplist/progress
|
||||||
|
|
||||||
|
# 5. 验证 DB
|
||||||
|
sqlite3 backend/data/manager.db "SELECT COUNT(*) FROM top_list;" # 应 = 200
|
||||||
|
sqlite3 backend/data/manager.db "SELECT rank, pkg_name, latest_version, weekly_downloads FROM top_list ORDER BY rank LIMIT 10;"
|
||||||
|
|
||||||
|
# 6. 浏览器打开 http://localhost:5173/toplist
|
||||||
|
# - 表格 200 行,状态徽章符合实际 storage 状态
|
||||||
|
# - 切换筛选 tabs 数量准确
|
||||||
|
|
||||||
|
# 7. 触发拉取
|
||||||
|
curl -X POST -H "Authorization: Bearer <token>" http://localhost:3000/api/toplist/pull
|
||||||
|
|
||||||
|
# 8. 拉取完成后查看 storage
|
||||||
|
ls <VERDACCIO_STORAGE>/<pkg-name>/ # 应有 latest 版本 .tgz
|
||||||
|
|
||||||
|
# 9. 刷新页面,对应行变为已就绪
|
||||||
|
curl -H "Authorization: Bearer <token>" "http://localhost:3000/api/toplist?filter=missing"
|
||||||
|
# 返回 list 长度应减小
|
||||||
|
|
||||||
|
# 10. 验证日志
|
||||||
|
sqlite3 backend/data/manager.db "SELECT action, deleted_versions, created_at FROM logs WHERE action LIKE 'toplist_%' ORDER BY id DESC LIMIT 5;"
|
||||||
|
```
|
||||||
|
|
||||||
|
可选验证:把 `scheduleWeeklyTopList` 内 `next` 改为 5 秒后,验证自调度 + 下一次正确计算到下周一 00:00。
|
||||||
|
|
||||||
|
## 实施顺序
|
||||||
|
|
||||||
|
1. **db.js**:新增 `top_list` 表 + `replaceTopList` / `getTopListRows` 等导出函数
|
||||||
|
2. **sync-latest.js**:导出 `triggerVerdaccio` 供复用
|
||||||
|
3. **npm-toplist.js**:新建核心模块(fetch + compare + pull + state)
|
||||||
|
4. **server.js**:新增 `/api/toplist*` 端点 + `scheduleWeeklyTopList()` + 启动注册
|
||||||
|
5. **router + 顶/底栏菜单**:注入路由和菜单项
|
||||||
|
6. **store + api**:新增 state 字段和 actions
|
||||||
|
7. **TopListView.vue**:新建主页面
|
||||||
|
8. **端到端验证**:按上述 10 步执行
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
# 操作日志功能改造 — 实施方案
|
||||||
|
|
||||||
|
## Context(背景与目标)
|
||||||
|
|
||||||
|
现有 `logs` 表字段(`action/pkg_name/deleted_versions/deleted_count/freed_space/created_at`)专为「清理低版本」设计,无法承载用户要求的 6 类日志。批量拉取目前只写一条汇总(`action='sync'`),手工拉取、扫描、登录均未落日志。本次改造扩展表结构 + 改造 sync-latest 为逐包写入 + 新增 3 类日志写入点 + 新增手工拉取入口。
|
||||||
|
|
||||||
|
### 用户确认的 4 个决策点
|
||||||
|
1. 登录:成功 + 失败均记一条(含 IP、用户名、失败原因)
|
||||||
|
2. 扫描:手动扫描 + 每日定时扫描 + 补扫均记一条
|
||||||
|
3. 批量拉取:逐包 N 条(`action='sync_latest'`)+ 汇总 1 条(保留 `action='sync'`)
|
||||||
|
4. 手工拉取入口:放在 [SearchView.vue](file:///e:/gitea/verdaccio-cache-manager/frontend/src/views/SearchView.vue) 「我的缓存」表格右上角
|
||||||
|
|
||||||
|
## 数据库改动 — `backend/db.js`
|
||||||
|
|
||||||
|
### 表结构扩展(迁移幂等)
|
||||||
|
|
||||||
|
参照 [migrateTimezone](file:///e:/gitea/verdaccio-cache-manager/backend/db.js) 模式,新增 `migrateLogsV2()`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE logs ADD COLUMN version TEXT; -- 单版本操作目标版本
|
||||||
|
ALTER TABLE logs ADD COLUMN result TEXT; -- 'success' | 'fail' | 'skipped'
|
||||||
|
ALTER TABLE logs ADD COLUMN message TEXT; -- 详细信息/失败原因
|
||||||
|
ALTER TABLE logs ADD COLUMN duration_ms INTEGER; -- 任务耗时(扫描/批量拉取汇总)
|
||||||
|
ALTER TABLE logs ADD COLUMN meta TEXT; -- JSON 扩展字段
|
||||||
|
ALTER TABLE logs ADD COLUMN ip TEXT; -- 登录 IP
|
||||||
|
```
|
||||||
|
|
||||||
|
迁移前用 `PRAGMA table_info(logs)` 检查列是否已存在,避免重复 ALTER。迁移完成后写入 `settings.logs_v2_migrated = '1'`。
|
||||||
|
|
||||||
|
### addLog 签名扩展
|
||||||
|
|
||||||
|
```js
|
||||||
|
export function addLog({
|
||||||
|
action = 'clean',
|
||||||
|
pkgName = null,
|
||||||
|
deletedVersions = null, // clean 用:删除的版本号 CSV
|
||||||
|
deletedCount = 0, // clean 用
|
||||||
|
freedSpace = null, // clean 用
|
||||||
|
version = null, // 单版本操作目标版本
|
||||||
|
result = null, // 'success' | 'fail' | 'skipped'
|
||||||
|
message = null,
|
||||||
|
durationMs = null,
|
||||||
|
meta = null, // 对象,写入前 JSON.stringify
|
||||||
|
ip = null,
|
||||||
|
} = {})
|
||||||
|
```
|
||||||
|
|
||||||
|
写入逻辑:插入 7 个新列(NULL 兼容旧库);`meta` 字段 `JSON.stringify` 后存。返回 `lastInsertRowid`。
|
||||||
|
|
||||||
|
### getLogs 扩展
|
||||||
|
|
||||||
|
```js
|
||||||
|
export function getLogs({ action = null, limit = 50 } = {}) {
|
||||||
|
if (action) {
|
||||||
|
return db.prepare('SELECT * FROM logs WHERE action = ? ORDER BY id DESC LIMIT ?').all(action, limit)
|
||||||
|
}
|
||||||
|
return db.prepare('SELECT * FROM logs ORDER BY id DESC LIMIT ?').all(limit)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
兼容旧调用 `getLogs(50)`:检测首参为数字时走旧路径。新增 `getLogActions()` 返回 `SELECT DISTINCT action FROM logs` 供前端筛选 tab 使用。
|
||||||
|
|
||||||
|
## 后端日志写入点
|
||||||
|
|
||||||
|
### 1. 登录日志 — `backend/server.js` /api/login
|
||||||
|
|
||||||
|
参照 [server.js L247-268](file:///e:/gitea/verdaccio-cache-manager/backend/server.js) 现有端点:
|
||||||
|
- 成功分支(生成 token 后):`addLog({ action: 'login', pkgName: username, result: 'success', ip, meta: { userAgent: req.headers['user-agent'] } })`
|
||||||
|
- 失败分支(密码错误):`addLog({ action: 'login', pkgName: String(username || ''), result: 'fail', message: '密码错误', ip })`
|
||||||
|
- 锁定触发(429 响应前):`addLog({ action: 'login', pkgName: String(username || ''), result: 'fail', message: '触发锁定', ip, meta: { retryAfter: guard.retryAfter } })`
|
||||||
|
- 退出登录(/api/logout):可选不记(避免日志膨胀)
|
||||||
|
|
||||||
|
### 2. 扫描日志 — `backend/server.js` runScanAsync
|
||||||
|
|
||||||
|
参照 [server.js L93-138](file:///e:/gitea/verdaccio-cache-manager/backend/server.js) 现有函数,在 `finally` 块末尾、`pendingDailyScan` 处理之前写日志:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const duration = Date.now() - new Date(scanState.startedAt).getTime()
|
||||||
|
addLog({
|
||||||
|
action: 'scan',
|
||||||
|
result: scanState.error ? 'fail' : 'success',
|
||||||
|
message: scanState.error || `扫描完成 ${scanState.packages} 个包`,
|
||||||
|
durationMs: duration,
|
||||||
|
meta: {
|
||||||
|
packages: scanState.packages,
|
||||||
|
bytes: scanState.bytes,
|
||||||
|
tgzBytes: scanState.tgzBytes,
|
||||||
|
diskBytes: scanState.diskBytes,
|
||||||
|
triggeredBy: scanState._triggerSource || 'manual', // 区分手动/定时
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`_triggerSource` 字段在 `runScanAsync` 入口处由调用方设置:手动 `/api/scan` 设 `'manual'`,`scheduleDailyScan` 设 `'daily'`,补扫设 `'pending'`。
|
||||||
|
|
||||||
|
### 3. 批量拉取逐包日志 — `backend/sync-latest.js`
|
||||||
|
|
||||||
|
参照 [sync-latest.js L95-119](file:///e:/gitea/verdaccio-cache-manager/backend/sync-latest.js) handleOne 函数,每个包处理后立即写一条:
|
||||||
|
|
||||||
|
| 情况 | action | pkgName | version | result | message |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 私服无此包 | sync_latest | pkg.name | — | fail | '私服无此包(404)' |
|
||||||
|
| 私服未提供 latest | sync_latest | pkg.name | — | fail | '私服未提供 latest 版本' |
|
||||||
|
| 已是最新 | sync_latest | pkg.name | latest | skipped | '已是最新版本,跳过' |
|
||||||
|
| 拉取成功 | sync_latest | pkg.name | latest | success | `已拉取 ${latest}` |
|
||||||
|
| 拉取失败 | sync_latest | pkg.name | latest | fail | e.message |
|
||||||
|
|
||||||
|
由于 `handleOne` 当前不接收 `addLog`,需扩展 `runSyncLatest` 注入参数签名:`runSyncLatest({ addLog, invalidateScan })` 已有 `addLog`,传递给 `handleOne`。
|
||||||
|
|
||||||
|
### 4. 批量拉取汇总日志(保留)
|
||||||
|
|
||||||
|
参照 [sync-latest.js L156-165](file:///e:/gitea/verdaccio-cache-manager/backend/sync-latest.js) 现有汇总写入,仅调整字段以兼容新表:
|
||||||
|
|
||||||
|
```js
|
||||||
|
addLog({
|
||||||
|
action: 'sync',
|
||||||
|
pkgName: null,
|
||||||
|
result: syncState.error ? 'fail' : 'success',
|
||||||
|
message: `批量拉取完成:成功 ${syncState.success} · 跳过 ${syncState.skipped} · 失败 ${syncState.failed}`,
|
||||||
|
durationMs: Date.now() - new Date(syncState.startedAt).getTime(),
|
||||||
|
meta: { success: syncState.success, skipped: syncState.skipped, failed: syncState.failedList },
|
||||||
|
// 兼容字段:保留 deletedVersions 存 JSON(前端旧逻辑兼容)
|
||||||
|
deletedVersions: JSON.stringify({ success: syncState.success, skipped: syncState.skipped, failed: syncState.failedList }),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 手工拉取 — `backend/server.js` 新增 POST /api/pull
|
||||||
|
|
||||||
|
```js
|
||||||
|
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: '演示模式不支持拉取' })
|
||||||
|
try {
|
||||||
|
const { triggerVerdaccio } = await import('./sync-latest.js')
|
||||||
|
await triggerVerdaccio(pkgName, version)
|
||||||
|
addLog({ action: 'pull_manual', pkgName, version, result: 'success', message: `已拉取 ${pkgName}@${version}` })
|
||||||
|
invalidateScan()
|
||||||
|
res.json({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
addLog({ action: 'pull_manual', pkgName, version, result: 'fail', message: e.message })
|
||||||
|
res.json({ ok: false, message: e.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
前置条件:在 [sync-latest.js](file:///e:/gitea/verdaccio-cache-manager/backend/sync-latest.js) 中 `export { triggerVerdaccio }`。
|
||||||
|
|
||||||
|
### 6. Top200 拉取日志 — 依赖 npm-toplist.js 模块(独立方案已规划)
|
||||||
|
|
||||||
|
`runTopListPull` 内每个包处理写一条 `action='pull_toplist'`,字段同 `pull_manual`;汇总写一条 `action='pull_toplist_summary'`。本方案不重复实现,留待 npm-toplist 模块开发时按本约定写入。
|
||||||
|
|
||||||
|
### 7. 单版本删除日志(已有,仅 action 区分)
|
||||||
|
|
||||||
|
[server.js L485-502](file:///e:/gitea/verdaccio-cache-manager/backend/server.js) `DELETE /api/packages/:pkgName/versions/:version` 当前用默认 `action='clean'`,改为 `action='delete_version'`,并填 `version`、`result: 'success'`、`message: '已删除 ${version}'`。详情弹窗按 action 分支展示。
|
||||||
|
|
||||||
|
## 前端改动
|
||||||
|
|
||||||
|
### SearchView.vue 新增「拉取指定包」表单
|
||||||
|
|
||||||
|
参照 [SearchView.vue](file:///e:/gitea/verdaccio-cache-manager/frontend/src/views/SearchView.vue) 表格右上角位置(与搜索框同行或上方),新增:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<el-popover trigger="click" placement="bottom-end" width="320">
|
||||||
|
<template #reference>
|
||||||
|
<button class="btn primary">📥 拉取指定包</button>
|
||||||
|
</template>
|
||||||
|
<div class="pull-form">
|
||||||
|
<input v-model="pullName" placeholder="包名,如 lodash 或 @antv/g2" />
|
||||||
|
<input v-model="pullVersion" placeholder="版本号,如 4.17.21" />
|
||||||
|
<button :disabled="pulling" @click="submitPull">{{ pulling ? '拉取中…' : '开始拉取' }}</button>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
```
|
||||||
|
|
||||||
|
提交时调用 `api.pullPackage(name, version)` → POST /api/pull → 成功后 ElMessage 提示 + `refreshLogs()`。
|
||||||
|
|
||||||
|
### LogsView.vue 改造
|
||||||
|
|
||||||
|
参照 [LogsView.vue](file:///e:/gitea/verdaccio-cache-manager/frontend/src/views/LogsView.vue) 现有结构:
|
||||||
|
|
||||||
|
#### actionMeta 扩展(L14-29)
|
||||||
|
|
||||||
|
```js
|
||||||
|
function actionMeta(action) {
|
||||||
|
const map = {
|
||||||
|
clean: { emo: '🗑', text: '清理', cls: 'clean' },
|
||||||
|
delete_version: { emo: '✂', text: '删版本', cls: 'clean' },
|
||||||
|
sync: { emo: '📥', text: '批量拉取汇总', cls: 'sync' },
|
||||||
|
sync_latest: { emo: '⬇', text: '拉取最新', cls: 'sync' },
|
||||||
|
pull_manual: { emo: '✋', text: '手工拉取', cls: 'sync' },
|
||||||
|
pull_toplist: { emo: '🏆', text: '榜单拉取', cls: 'sync' },
|
||||||
|
pull_toplist_summary: { emo: '🏆', text: '榜单拉取汇总', cls: 'sync' },
|
||||||
|
scan: { emo: '🔍', text: '扫描', cls: 'scan' },
|
||||||
|
login: { emo: '🔐', text: '登录', cls: 'login' },
|
||||||
|
ignore: { emo: '💤', text: '忽略', cls: 'ignore' },
|
||||||
|
unignore: { emo: '↩️', text: '取消忽略', cls: 'unignore' },
|
||||||
|
}
|
||||||
|
return map[action] || { emo: '•', text: action, cls: 'clean' }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
新增 CSS 类 `.act-badge.scan`(灰色)、`.act-badge.login`(紫色)。
|
||||||
|
|
||||||
|
#### 表格列改造(L40-73)
|
||||||
|
|
||||||
|
- 包名列:`scan` 显示「全目录」、`login` 显示用户名、`sync` 汇总显示「批量拉取」、其他显示 pkgName
|
||||||
|
- 删除数量列 → 改为「版本/详情」列:`clean` 显示 `-N 版`、`pull_*` / `delete_version` / `sync_latest` 显示版本号
|
||||||
|
- 释放空间列 → 改为「结果」列:所有 action 显示 `result` 徽章(success 绿 / fail 红 / skipped 灰)
|
||||||
|
- 操作列:保留「详情」按钮
|
||||||
|
|
||||||
|
#### 筛选 tab(页头新增)
|
||||||
|
|
||||||
|
```
|
||||||
|
[全部] [清理/删除] [拉取] [扫描] [登录] [忽略]
|
||||||
|
```
|
||||||
|
|
||||||
|
点击切换 `filter` 参数,调用 `api.getLogs({ action: filter, limit: 100 })`。`pull_*`、`sync*` 合并为「拉取」标签。
|
||||||
|
|
||||||
|
#### 详情弹窗分支(L77-130)
|
||||||
|
|
||||||
|
按 action 分支展示:
|
||||||
|
- `clean` / `delete_version`:包名 + 版本 + 数量 + 释放空间
|
||||||
|
- `sync` 汇总:批量拉取结果(成功/跳过/失败)+ 失败包列表(旧 `deletedVersions` JSON 兼容)
|
||||||
|
- `sync_latest` / `pull_manual` / `pull_toplist`:包名 + 版本 + 结果 + 失败原因
|
||||||
|
- `scan`:耗时 + 包数 + 字节 + 触发源(meta JSON 解析)
|
||||||
|
- `login`:用户名 + IP + 结果 + 失败原因 + UserAgent(meta)
|
||||||
|
- `ignore` / `unignore`:保留
|
||||||
|
|
||||||
|
### store/api 扩展
|
||||||
|
|
||||||
|
- `api.getLogs(filter)`:`GET /api/logs?action=${filter}&limit=100`,filter 为空时取全部
|
||||||
|
- `api.pullPackage(pkgName, version)`:`POST /api/pull` with JSON body
|
||||||
|
- `store.refreshLogs(filter)`:透传 filter
|
||||||
|
|
||||||
|
## 实施顺序
|
||||||
|
|
||||||
|
1. **db.js**:migrateLogsV2 加 6 字段 + addLog 扩展签名 + getLogs 支持 action 筛选
|
||||||
|
2. **sync-latest.js**:`export { triggerVerdaccio }`;handleOne 逐包写日志(action='sync_latest');汇总日志调整字段
|
||||||
|
3. **server.js**:/api/login 写日志 + runScanAsync 写日志 + 单版本删除改 action='delete_version' + 新增 POST /api/pull
|
||||||
|
4. **api/index.js**:getLogs 支持 filter + 新增 pullPackage
|
||||||
|
5. **store/index.js**:refreshLogs 透传 filter
|
||||||
|
6. **LogsView.vue**:actionMeta 扩展 + 表格列改造 + 筛选 tab + 详情弹窗分支
|
||||||
|
7. **SearchView.vue**:表格右上角新增「拉取指定包」表单
|
||||||
|
8. **验证**
|
||||||
|
|
||||||
|
## 验证步骤
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 启动后端
|
||||||
|
cd e:\gitea\verdaccio-cache-manager\backend && node server.js
|
||||||
|
|
||||||
|
# 2. 验证迁移
|
||||||
|
sqlite3 backend/data/manager.db "PRAGMA table_info(logs);"
|
||||||
|
# 应见 version/result/message/duration_ms/meta/ip 6 个新字段
|
||||||
|
|
||||||
|
# 3. 登录页测试:先输错密码,再输正确密码
|
||||||
|
curl -X POST http://localhost:3000/api/login -H "Content-Type: application/json" -d '{"username":"admin","password":"wrong"}'
|
||||||
|
curl -X POST http://localhost:3000/api/login -H "Content-Type: application/json" -d '{"username":"admin","password":"666666"}'
|
||||||
|
|
||||||
|
# 4. 验证登录日志
|
||||||
|
sqlite3 backend/data/manager.db "SELECT action, pkg_name, result, ip, message, created_at FROM logs WHERE action='login' ORDER BY id DESC LIMIT 5;"
|
||||||
|
# 应见 1 条 fail + 1 条 success
|
||||||
|
|
||||||
|
# 5. 触发手动扫描
|
||||||
|
curl -X POST -H "Authorization: Bearer <token>" http://localhost:3000/api/scan
|
||||||
|
sleep 5
|
||||||
|
sqlite3 backend/data/manager.db "SELECT action, result, duration_ms, meta, created_at FROM logs WHERE action='scan' ORDER BY id DESC LIMIT 1;"
|
||||||
|
# 应见 success + packages 数 + duration
|
||||||
|
|
||||||
|
# 6. 触发批量拉取(确保私服可用),等待完成
|
||||||
|
curl -X POST -H "Authorization: Bearer <token>" http://localhost:3000/api/sync/latest
|
||||||
|
sleep 30
|
||||||
|
sqlite3 backend/data/manager.db "SELECT action, pkg_name, version, result, message FROM logs WHERE action='sync_latest' ORDER BY id DESC LIMIT 10;"
|
||||||
|
sqlite3 backend/data/manager.db "SELECT action, result, message, duration_ms FROM logs WHERE action='sync' ORDER BY id DESC LIMIT 1;"
|
||||||
|
# 应见 N 条逐包 + 1 条汇总
|
||||||
|
|
||||||
|
# 7. 前端手工拉取
|
||||||
|
# 浏览器打开 http://localhost:5173/search,点击「拉取指定包」按钮
|
||||||
|
# 输入 lodash + 4.17.21 → 提交 → 应见 ElMessage 成功提示
|
||||||
|
sqlite3 backend/data/manager.db "SELECT action, pkg_name, version, result, message FROM logs WHERE action='pull_manual' ORDER BY id DESC LIMIT 1;"
|
||||||
|
|
||||||
|
# 8. 切换 LogsView 筛选 tab,验证各 action 类型数量匹配
|
||||||
|
# 浏览器打开 http://localhost:5173/logs
|
||||||
|
# 点 [登录] → 应见 2 条登录日志
|
||||||
|
# 点 [扫描] → 应见 1 条扫描日志
|
||||||
|
# 点 [拉取] → 应见 sync_latest N 条 + sync 汇总 1 条 + pull_manual 1 条
|
||||||
|
|
||||||
|
# 9. 验证旧日志兼容
|
||||||
|
sqlite3 backend/data/manager.db "SELECT id, action, pkg_name, deleted_versions FROM logs WHERE action IN ('clean','ignore','unignore') ORDER BY id DESC LIMIT 5;"
|
||||||
|
# 前端 LogsView 应能正常显示历史日志(action='clean' / 'ignore' / 'unignore' 都保留映射)
|
||||||
|
```
|
||||||
+5
-3
@@ -562,11 +562,13 @@ setInterval(() => {
|
|||||||
}
|
}
|
||||||
}, 10 * 60_000).unref()
|
}, 10 * 60_000).unref()
|
||||||
|
|
||||||
// —— 每天 00:00(服务器本地时区)自动重扫缓存,逻辑同 POST /api/scan ——
|
// —— 每天 03:00(服务器本地时区)自动重扫缓存,逻辑同 POST /api/scan ——
|
||||||
|
// 选 3 点而非 0 点:避开周一 0 点 Top200 抓榜,减少并发撞车;凌晨 3 点用户访问最稀疏
|
||||||
function scheduleDailyScan() {
|
function scheduleDailyScan() {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const next = new Date(now)
|
const next = new Date(now)
|
||||||
next.setHours(24, 0, 0, 0) // 下个本地零点
|
next.setHours(3, 0, 0, 0)
|
||||||
|
if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1) // 今日 3 点已过 → 明日
|
||||||
const wait = next.getTime() - now.getTime()
|
const wait = next.getTime() - now.getTime()
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
@@ -582,7 +584,7 @@ function scheduleDailyScan() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[auto-scan] 每日自动扫描失败:', e.message)
|
console.error('[auto-scan] 每日自动扫描失败:', e.message)
|
||||||
}
|
}
|
||||||
scheduleDailyScan() // 安排次日零点
|
scheduleDailyScan() // 安排次日 03:00
|
||||||
}, wait)
|
}, wait)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user