27 KiB
消息通知功能 — 实施方案
Context(背景与目标)
verdaccio-cache-manager 现有的进度反馈依赖前端轮询(scanProgress / syncProgress),仅在用户停留在触发页面时可见。一旦切走页面或刷新浏览器,批量任务的最终结果就丢失了上下文。拉取包耗时长(单包 10s+,批量可达分钟级),用户无法实时盯着进度条,需要一个异步通知中心留存结果。
本功能新增 6 类非实时通知:
- 包版本过多预警(扫描完成后检查)
- 批量拉取存量包结果(含详情入口→操作日志,显示该批次每条拉取日志:操作时间、包名、版本号、拉取结果)
- 手工拉取指定包结果
- 拉取排行榜包结果
- 榜单发生变化通知
- 定时任务预告(下次扫描/抓榜时间)
关键设计决策
- 实时性:用户明确说明"用于非实时消息,拉取包很慢没法做到实时"。采用轮询(30s 间隔,复用现有 pollSyncProgress 模式),不引入 SSE。新通知到达时未读数徽章实时更新,
warn/error级别额外弹一次ElMessage提示。 - 入口形态:顶栏铃铛 + 下拉面板(复用 AppTopbar.vue avatar-wrap 下拉 模式),不新增独立通知页。通知项点击按类型跳转到对应详情页。
- 批次关联:通知与日志通过
batch_id关联。点击"批量拉取结果"通知跳转/logs?action=sync_latest&batchId=xxx,LogsView 按批次筛选显示该批次的逐包日志。 - 依赖关系:本方案假设「操作日志改造方案」(operation-logs-refactor-plan.md)已实施(
addLog扩展签名 + logs 表 v2 字段 + action 筛选)。本方案在此基础上新增logs.batch_id列与notifications表。
数据库改动 — backend/db.js
新增 notifications 表
参照 db.js L18-36 现有建表语句,在 logs 表 CREATE 之后追加:
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()(北京时间),与 addLog 一致,避免 DEFAULT CURRENT_TIMESTAMP 的 UTC 偏差。
logs 表新增 batch_id 列(迁移幂等)
参照 migrateTimezone 模式,在 logCols 检查后追加:
if (!logCols.includes('batch_id')) {
db.exec("ALTER TABLE logs ADD COLUMN batch_id TEXT")
}
addLog 签名扩展(追加 batchId,向后兼容)
在「操作日志改造方案」已扩展的签名基础上追加 batchId:
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:
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):检测首参为数字时走旧路径。
新增导出函数
/** 插入通知,返回 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 调用:
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 之后追加:
| 方法 | 路径 | 响应 |
|---|---|---|
| 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} — 全部标记已读 |
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 finally 块,扫描成功后(操作日志改造方案写 scan 日志之后)。
去重逻辑:与上次预警快照比对,仅当版本数超阈值的包集合变化时通知(避免每日扫描重复刷屏)。快照存 settings.last_warning_snapshot。只发超阈值预警,不发「已恢复正常」通知(用户确认:只报忧不报喜,某包清理后回归正常是静默的)——快照比对只用于"超阈值集合是否变化"来控制是否发新预警,不对"恢复"方向发任何通知。
// 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 汇总 addLog 之后(L156-165 附近)。
依赖注入扩展:runSyncLatest({ addLog, invalidateScan, notify }) — server.js /api/sync/latest 调用时传入 notify。
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(操作日志改造方案新增端点)。
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 实施)。
依赖注入扩展: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)。
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 setTimeout 回调末尾,自调度 scheduleDailyScan() 之前;scheduleWeeklyTopList 同理。
去重策略:仅在定时扫描(_triggerSource === 'daily' 或 'pending')完成后发一次预告,手动扫描不触发。预告内容为"下次自动扫描时间",避免刷屏。
// 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 模式新增:
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(与「操作日志改造方案」合并实施):
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 轮询模式新增:
// 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() 末尾启动通知轮询(首次加载拉取最新 20 条 + 启动 30s 轮询);onBeforeUnmount 清理 timer(App 根组件卸载时)。
AppTopbar.vue — 铃铛 + 下拉面板
参照 AppTopbar.vue avatar-wrap 下拉 模式,在 .topbar-right 内 theme-toggle 与 avatar-wrap 之间插入铃铛:
<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 模式)。
移动端 m-topbar(App.vue)+ 独立通知页(用户选定)
在 App.vue m-topbar 右侧 ☰ 旁加铃铛按钮(带角标),点击跳转独立通知页 /notifications(用户确认:移动端空间小,独立滚动页体验更佳,不做抽屉)。移动端铃铛仅显示角标 + 跳转,不展开下拉。
独立通知页 NotificationsView.vue(新建):移动端专用,全屏列表展示 state.notifications,支持标记已读、全部已读、点击通知跳转对应详情(复用 handleNotifClick 的分发逻辑,抽成共享方法)。路由新增 { path: '/notifications', name: 'notifications', component: NotificationsView }(桌面端铃铛仍用下拉,此页主要覆盖移动端,但桌面端也可通过输入 URL 访问)。
LogsView.vue — 接收 query 参数筛选批次
参照 LogsView.vue 现有结构(「操作日志改造方案」已加 action 筛选 tab),新增读取 route.query 透传:
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})。
实施顺序
- db.js:新增
notifications表 +logs.batch_id列迁移 +addLog接受batchId+addNotification/getNotifications/markNotificationRead/markAllNotificationsRead导出 +getLogs支持 batchId 过滤 - notifier.js:新建
notify()入口 - server.js:导入 notify;runScanAsync finally 加预警检查 + daily_preview;POST /api/pull 加通知;新增
/api/notifications*端点;/api/sync/latest调用 runSyncLatest 时传 notify - sync-latest.js:runSyncLatest 入口生成 batchId;handleOne 逐包 addLog 传 batchId;汇总 addLog 传 batchId + notify
- npm-toplist.js(待 Top200 方案 实施时一并加):runTopListFetch 加 toplist_change 通知;runTopListPull 加 toplist_pull 通知 + batchId;scheduleWeeklyTopList 加 daily_preview(taskType=toplist_fetch)
- api/index.js:新增 getNotifications / markNotificationRead / markAllNotificationsRead;getLogs 支持 action+batchId
- store/index.js:新增 notifications/unreadCount/lastNotifId state + pollNotifications(30s)+ refreshNotifications/markNotifRead/markAllNotifsRead;doRefresh 末尾启动轮询
- AppTopbar.vue:铃铛 + 角标 + notif-panel 下拉 + handleNotifClick 路由 + 点击外部关闭
- App.vue m-topbar:移动端铃铛入口(角标 + 跳转列表页或展开下拉)
- LogsView.vue:读取 route.query.action / batchId 透传 refreshLogs + watch 同步
- 验证
验证步骤
# 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 通知