feat(notifications): 新增消息通知中心(6 类非实时通知)

- db.js: notifications 表 + logs.batch_id 列迁移 + addLog 接受 batchId + getLogs 支持 batchId + 通知 CRUD 导出(含 100 条存留清理)
- notifier.js: 新建 notify() 单一入口
- server.js: 6 类通知触发点(version_warning 快照去重 / sync_batch / pull_manual / toplist_pull / toplist_change / daily_preview)+ /api/notifications* 端点 + /api/logs 支持 batchId
- sync-latest.js: 批量拉取生成 batchId,逐包/汇总日志与通知关联
- npm-toplist.js: 榜单变化 + 榜单拉取结果通知 + batchId
- 前端: 顶栏铃铛+角标+下拉面板、移动端铃铛+独立通知页、LogsView 批次筛选横幅、store 30s 轮询

通知与日志通过 batch_id 关联,点击通知跳转 /logs?action=xxx&batchId=yyy 显示该批次逐包日志
This commit is contained in:
yinjun.chen1
2026-09-22 11:01:00 +08:00
parent b0f8a60d6f
commit c13e624628
12 changed files with 749 additions and 48 deletions
+70 -8
View File
@@ -44,6 +44,19 @@ CREATE TABLE IF NOT EXISTS top_list (
fetched_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_top_list_rank ON top_list(rank);
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT,
level TEXT DEFAULT 'info',
related_action_id INTEGER,
batch_id TEXT,
meta TEXT,
read_at DATETIME,
created_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(read_at);
`)
// 北京时间(Asia/Shanghai 固定 UTC+8,无夏令时):SQLite DEFAULT CURRENT_TIMESTAMP 是 UTC,
@@ -71,6 +84,9 @@ const logCols = db.prepare('PRAGMA table_info(logs)').all().map(c => c.name)
if (!logCols.includes('action')) {
db.exec("ALTER TABLE logs ADD COLUMN action TEXT DEFAULT 'clean'")
}
if (!logCols.includes('batch_id')) {
db.exec('ALTER TABLE logs ADD COLUMN batch_id TEXT')
}
// 旧库迁移 v1:历史日志/忽略记录的时间戳是 UTC,统一转成北京时间;settings 标志位防重复执行
function migrateTimezone() {
@@ -149,13 +165,14 @@ function migrateLogsV2() {
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,
} = {}) {
const info = db.prepare(
`INSERT INTO logs (action, pkg_name, deleted_versions, deleted_count, freed_space,
version, result, message, duration_ms, meta, ip, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
version, result, message, duration_ms, meta, ip, batch_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(action, pkgName, deletedVersions, deletedCount, freedSpace,
version, result, message, durationMs, meta ? JSON.stringify(meta) : null, ip, nowIso())
version, result, message, durationMs, meta ? JSON.stringify(meta) : null, ip, batchId, nowIso())
return info.lastInsertRowid
}
@@ -163,15 +180,20 @@ export function getLogActions() {
return db.prepare('SELECT DISTINCT action FROM logs ORDER BY action').all().map(r => r.action)
}
export function getLogs({ action = null, limit = 50 } = {}) {
export function getLogs({ action = null, batchId = null, limit = 50 } = {}) {
// 兼容旧调用 getLogs(50):首参为数字时视为旧版 limit
if (typeof action === 'number') {
return db.prepare('SELECT * FROM logs ORDER BY id DESC LIMIT ?').all(action)
}
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)
let sql = 'SELECT * FROM logs'
const where = []
const 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)
}
// —— top_list ——
@@ -196,6 +218,46 @@ export function getTopListCount() {
return db.prepare('SELECT COUNT(*) AS c FROM top_list').get().c
}
// —— notifications ——
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())
pruneNotifications(100)
return info.lastInsertRowid
}
export function getNotifications({ sinceId = 0, limit = 20 } = {}) {
const rows = db.prepare(
'SELECT * FROM notifications WHERE id > ? ORDER BY id DESC LIMIT ?',
).all(sinceId, limit)
return rows.map(r => {
let m = null
if (r.meta) { try { m = JSON.parse(r.meta) } catch { m = null } }
return { ...r, meta: m }
})
}
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
}
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)
}
// 模块加载时执行时间迁移 + logs v2 字段迁移(均幂等)
migrateTimezone()
migrateLogsV2()
+31
View File
@@ -0,0 +1,31 @@
/**
* 消息通知入口 —— 单一 notify() 供 server.js / sync-latest.js / npm-toplist.js 复用
* 非实时通知:写 DB,前端 30s 轮询拉取(拉取包耗时久,无法实时)
*/
import { addNotification } from './db.js'
/**
* 发送通知(写 DB;前端轮询拉取)
* @param {Object} opts
* @param {string} opts.type - 6 类之一:version_warning / sync_batch / pull_manual / toplist_pull / toplist_change / daily_preview
* @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
}
+60 -16
View File
@@ -14,6 +14,7 @@
*/
import { getSetting, getTopListRows, replaceTopList } from './db.js'
import { triggerVerdaccio } from './sync-latest.js'
import { randomUUID } from 'node:crypto'
// —— 抓榜状态(前端轮询进度,与 syncState 模式对称) ——
export const toplistState = {
@@ -245,9 +246,9 @@ export function getTopList(getRawPackages) {
/**
* 抓榜主流程:search → recheck → compare → persist
* @param {{ addLog, getRawPackages, invalidateScan, setSetting }} services 依赖注入
* @param {{ addLog, getRawPackages, invalidateScan, setSetting, notify }} services 依赖注入
*/
export async function runTopListFetch({ addLog, getRawPackages, invalidateScan, setSetting }) {
export async function runTopListFetch({ addLog, getRawPackages, invalidateScan, setSetting, notify }) {
if (toplistState.running) return { ok: false, running: true }
resetFetchState()
resetPullState()
@@ -262,6 +263,35 @@ export async function runTopListFetch({ addLog, getRawPackages, invalidateScan,
toplistState.phase = 'compare'
// 复核前先算出真实下载,最后统一截取
const ranked = await runRecheck(cands)
// 通知类型 5:榜单发生变化(旧榜单非空且确有变化才发;首次抓榜无变化可比)
if (notify) {
const prevRows = getTopListRows()
if (prevRows.length) {
const newSet = new Map(ranked.map(e => [e.pkg_name, e]))
const oldSet = new Map(prevRows.map(e => [e.pkg_name, e]))
const newEntries = ranked.filter(e => !oldSet.has(e.pkg_name))
const dropped = prevRows.filter(e => !newSet.has(e.pkg_name))
const versionUpdates = ranked.filter(e => {
const old = oldSet.get(e.pkg_name)
return old && old.latest_version !== e.latest_version
})
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.pkg_name}`).join('、') || '无新进包',
level: 'info',
meta: {
newEntries: newEntries.map(e => e.pkg_name),
dropped: dropped.map(e => e.pkg_name),
versionUpdates: versionUpdates.map(e => ({ name: e.pkg_name, from: oldSet.get(e.pkg_name)?.latest_version, to: e.latest_version })),
},
})
}
}
}
replaceTopList(ranked)
const lastRun = new Date().toISOString()
@@ -290,10 +320,11 @@ export async function runTopListFetch({ addLog, getRawPackages, invalidateScan,
/**
* 拉取榜单中待缓存 / 需更新的包(经 Verdaccio ⚙ 代理落盘)
* @param {{ addLog, getRawPackages, invalidateScan }} services
* @param {{ addLog, getRawPackages, invalidateScan, notify }} services
*/
export async function runTopListPull({ addLog, getRawPackages, invalidateScan }) {
export async function runTopListPull({ addLog, getRawPackages, invalidateScan, notify }) {
if (toplistState.pullRunning) return { ok: false, running: true }
const batchId = randomUUID() // 本批次 ID,逐包日志 / 汇总日志 / 通知共用
// 必须存在已入榜数据
const snapshot = getTopList(getRawPackages)
const targets = snapshot.list.filter(e => !e.cached || !e.latestCached)
@@ -318,13 +349,13 @@ export async function runTopListPull({ addLog, getRawPackages, invalidateScan })
toplistState.pullSuccess += 1
addLog({
action: 'toplist_pull', pkgName: t.pkg_name, version: t.latest_version,
result: 'success', message: `已拉取 ${t.latest_version}`,
result: 'success', message: `已拉取 ${t.latest_version}`, batchId,
})
} catch (e) {
const reason = (e && e.message) || '拉取失败'
toplistState.pullFailed += 1
toplistState.pullFailedList.push({ name: t.pkg_name, reason })
addLog({ action: 'toplist_pull', pkgName: t.pkg_name, version: t.latest_version, result: 'fail', message: reason })
addLog({ action: 'toplist_pull', pkgName: t.pkg_name, version: t.latest_version, result: 'fail', message: reason, batchId })
} finally {
toplistState.pullProcessed += 1
}
@@ -338,18 +369,31 @@ export async function runTopListPull({ addLog, getRawPackages, invalidateScan })
toplistState.pullRunning = false
toplistState.pullDone = true
toplistState.pullFinishedAt = new Date().toISOString()
{
// 汇总日志
addLog({
action: 'toplist_pull_summary',
result: toplistState.pullError ? 'fail' : 'success',
message: `Top200 拉取完成:成功 ${toplistState.pullSuccess} · 失败 ${toplistState.pullFailed}`,
durationMs: Date.now() - new Date(toplistState.pullStartedAt).getTime(),
meta: { success: toplistState.pullSuccess, failed: toplistState.pullFailedList },
deletedCount: toplistState.pullSuccess,
// 汇总日志
const summaryLogId = addLog({
action: 'toplist_pull_summary',
result: toplistState.pullError ? 'fail' : 'success',
message: `Top200 拉取完成:成功 ${toplistState.pullSuccess} · 失败 ${toplistState.pullFailed}`,
durationMs: Date.now() - new Date(toplistState.pullStartedAt).getTime(),
meta: { success: toplistState.pullSuccess, failed: toplistState.pullFailedList },
deletedCount: toplistState.pullSuccess,
batchId,
})
// 通知类型 4:拉取排行榜包结果
if (notify) {
notify({
type: 'toplist_pull',
title: toplistState.pullError
? '榜单拉取失败'
: `榜单拉取完成:成功 ${toplistState.pullSuccess} · 失败 ${toplistState.pullFailed}`,
body: toplistState.pullError || `共 ${toplistState.pullTotal} 个待缓存/需更新包`,
level: toplistState.pullFailed > 0 ? 'warn' : 'info',
relatedActionId: summaryLogId,
batchId,
meta: { success: toplistState.pullSuccess, failed: toplistState.pullFailed, total: toplistState.pullTotal },
})
invalidateScan() // 有新版本入库 → 废弃扫描缓存,列表下次请求重扫
}
invalidateScan() // 有新版本入库 → 废弃扫描缓存,列表下次请求重扫
}
return { ok: !toplistState.pullError, running: false, total: targets.length }
}
+96 -6
View File
@@ -20,6 +20,7 @@ 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,
@@ -29,6 +30,7 @@ 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'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const app = express()
@@ -143,6 +145,49 @@ rawCacheAt = new Date().toISOString()
triggeredBy: scanState._triggerSource || 'manual',
},
})
// 记录本次触发源(复位前捕获),用于预警 / 定时预告通知
const triggerSource = scanState._triggerSource || 'manual'
// 通知类型 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) {
@@ -453,6 +498,7 @@ app.post('/api/sync/latest', (req, res) => {
runSyncLatest({
addLog,
invalidateScan,
notify,
})
}
res.json({
@@ -507,6 +553,7 @@ app.post('/api/toplist/fetch', (req, res) => {
getRawPackages,
invalidateScan,
setSetting,
notify,
})
}
res.json({
@@ -526,6 +573,7 @@ app.post('/api/toplist/pull', (req, res) => {
addLog,
getRawPackages,
invalidateScan,
notify,
})
}
res.json({
@@ -599,11 +647,34 @@ app.delete('/api/packages/:pkgName/versions/:version', (req, res) => {
res.json(result)
})
// —— API:日志(支持 action 筛选,默认取最近 100 条) ——
// —— 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, limit }))
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 同机制) ——
@@ -611,15 +682,19 @@ 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)
addLog({ action: 'pull_manual', pkgName, version, result: 'success', message: `已拉取 ${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) {
addLog({ action: 'pull_manual', pkgName, version, result: 'fail', message: (e && e.message) || '拉取失败' })
res.json({ ok: false, message: (e && e.message) || '拉取失败' })
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 })
}
})
@@ -720,7 +795,22 @@ function scheduleWeeklyTopList() {
setSetting('toplist_next_run', nowIso(next))
setTimeout(async () => {
try {
await runTopListFetch({ addLog, getRawPackages, invalidateScan, setSetting })
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)
}
+27 -10
View File
@@ -13,6 +13,7 @@
* VERDACCIO_BASE_URL 私服地址(默认 http://127.0.0.1:4873;查询与代拉均走它,上游由 Verdaccio 自行路由)
*/
import { scanStorageAsync, resolveStorageDir } from './scanner.js'
import { randomUUID } from 'node:crypto'
const VERDACCIO_BASE = (process.env.VERDACCIO_BASE_URL || 'http://127.0.0.1:4873').replace(/\/+$/, '')
const QUERY_TIMEOUT = 15000 // 元数据查询超时(Verdaccio 本地/uplink 转发通常较快)
@@ -93,44 +94,45 @@ function hasLocalVersion(pkg, version) {
return vs.some(v => String(v.version) === version)
}
async function handleOne(pkg, addLog) {
async function handleOne(pkg, addLog, batchId) {
try {
const meta = await fetchJson(`${VERDACCIO_BASE}/${enc(pkg.name)}`, QUERY_TIMEOUT)
const latest = meta && meta['dist-tags'] && meta['dist-tags'].latest
if (!meta) {
syncState.failed += 1
syncState.failedList.push({ name: pkg.name, reason: '私服无此包(404)' })
addLog({ action: 'sync_latest', pkgName: pkg.name, result: 'fail', message: '私服无此包(404)' })
addLog({ action: 'sync_latest', pkgName: pkg.name, result: 'fail', message: '私服无此包(404)', batchId })
return
}
if (!latest) {
syncState.failed += 1
syncState.failedList.push({ name: pkg.name, reason: '私服未提供 latest 版本' })
addLog({ action: 'sync_latest', pkgName: pkg.name, result: 'fail', message: '私服未提供 latest 版本' })
addLog({ action: 'sync_latest', pkgName: pkg.name, result: 'fail', message: '私服未提供 latest 版本', batchId })
return
}
if (hasLocalVersion(pkg, latest)) {
syncState.skipped += 1 // 已是最新,不重复拉取
addLog({ action: 'sync_latest', pkgName: pkg.name, version: latest, result: 'skipped', message: '已是最新版本,跳过' })
addLog({ action: 'sync_latest', pkgName: pkg.name, version: latest, result: 'skipped', message: '已是最新版本,跳过', batchId })
return
}
await triggerVerdaccio(pkg.name, latest)
syncState.success += 1
addLog({ action: 'sync_latest', pkgName: pkg.name, version: latest, result: 'success', message: `已拉取 ${latest}` })
addLog({ action: 'sync_latest', pkgName: pkg.name, version: latest, result: 'success', message: `已拉取 ${latest}`, batchId })
} catch (e) {
const reason = (e && e.message) || '拉取失败'
syncState.failed += 1
syncState.failedList.push({ name: pkg.name, reason })
addLog({ action: 'sync_latest', pkgName: pkg.name, result: 'fail', message: reason })
addLog({ action: 'sync_latest', pkgName: pkg.name, result: 'fail', message: reason, batchId })
}
}
/**
* 启动拉取任务(异步;running 时重复调用直接返回)
* @param {{ addLog: Function, invalidateScan: Function }} services 依赖注入(避免与 server.js 循环引用)
* @param {{ addLog: Function, invalidateScan: Function, notify: Function }} services 依赖注入(避免与 server.js 循环引用)
*/
export async function runSyncLatest({ addLog, invalidateScan }) {
export async function runSyncLatest({ addLog, invalidateScan, notify }) {
if (syncState.running) return
const batchId = randomUUID() // 本批次 ID,逐包日志 / 汇总日志 / 通知共用
resetState()
syncState.running = true
console.log(`[sync-latest] 开始拉取存量包最新版本(经私服: ${VERDACCIO_BASE},上游按 Verdaccio uplinks 路由)`)
@@ -153,14 +155,14 @@ export async function runSyncLatest({ addLog, invalidateScan }) {
while (next < pkgs.length) {
const pkg = pkgs[next++]
syncState.current = pkg.name
await handleOne(pkg, addLog)
await handleOne(pkg, addLog, batchId)
syncState.processed += 1
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENT, pkgs.length) }, worker))
}
// 汇总写操作日志(失败包名列表存 deletedVersions 兼容旧前端 + meta 存结构化结果)
addLog({
const summaryLogId = addLog({
action: 'sync',
pkgName: null,
result: syncState.error ? 'fail' : 'success',
@@ -173,7 +175,22 @@ export async function runSyncLatest({ addLog, invalidateScan }) {
skipped: syncState.skipped,
failed: syncState.failedList,
}),
batchId,
})
// 通知类型 2:批量拉取结果
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 },
})
}
invalidateScan() // 有新版本入库 → 废弃扫描缓存,列表下次请求重扫
console.log(`[sync-latest] 完成:成功 ${syncState.success} · 跳过 ${syncState.skipped} · 失败 ${syncState.failed}`)
} catch (e) {
+41 -2
View File
@@ -65,13 +65,18 @@ const fmtScanBytes = computed(() => {
<div v-if="!isLogin()">
<!-- 桌面/平板顶栏 -->
<AppTopbar class="desktop-topbar" @rescan="doRescan" />
<!-- 移动端顶栏:☰ + 品牌 -->
<!-- 移动端顶栏:☰ + 品牌 + 铃铛 -->
<header class="m-topbar">
<div class="m-brand">
<div class="m-logo">📦</div>
<div><b>Verdaccio</b>&nbsp;<span>Manager</span></div>
</div>
<div class="m-menu" @click="settingsOpen = true">☰</div>
<div class="m-actions">
<button class="m-bell" @click="router.push('/notifications')">🔔
<span v-if="state.unreadCount" class="m-bell-badge">{{ state.unreadCount > 99 ? '99+' : state.unreadCount }}</span>
</button>
<div class="m-menu" @click="settingsOpen = true">☰</div>
</div>
</header>
<!-- 导航栏下方:扫描信息条 -->
@@ -236,5 +241,39 @@ html[data-theme="bright"] .scan-card {
color: var(--ink-dim);
cursor: pointer;
}
.m-actions {
display: flex;
align-items: center;
gap: 8px;
}
.m-bell {
position: relative;
width: 34px;
height: 34px;
border-radius: 11px;
display: grid;
place-items: center;
background: var(--card-2);
border: 1px solid var(--line);
font-size: 16px;
cursor: pointer;
}
.m-bell-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 99px;
background: var(--red);
color: #fff;
font-size: 9px;
font-weight: 700;
font-family: var(--num);
display: grid;
place-items: center;
line-height: 1;
}
}
</style>
+20 -1
View File
@@ -350,7 +350,7 @@ async login(username, password, remember = true) {
return r.json()
},
async getLogs({ action = null, limit = 100 } = {}) {
async getLogs({ action = null, batchId = null, limit = 100 } = {}) {
if (USE_MOCK) {
await delay(80)
let list = clone(mockState.logs)
@@ -359,11 +359,30 @@ return r.json()
}
const qs = new URLSearchParams()
if (action) qs.set('action', action)
if (batchId) qs.set('batchId', batchId)
qs.set('limit', limit)
const r = await authFetch(`/api/logs?${qs}`)
return r.json()
},
// —— 消息通知 ——
async getNotifications(sinceId = 0, limit = 20) {
if (USE_MOCK) {
await delay(60)
return { list: [], unreadCount: 0, lastId: sinceId }
}
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()
},
/** 手工拉取指定包版本(经私服代理落盘) */
async pullPackage(pkgName, version) {
if (USE_MOCK) {
+197 -1
View File
@@ -8,11 +8,54 @@ import api from '../api'
const route = useRoute()
const router = useRouter()
const { theme, toggle } = useTheme()
const { state } = useStore()
const { state, refreshNotifications, markNotifRead, markAllNotifsRead } = useStore()
const emit = defineEmits(['rescan'])
const menuOpen = ref(false)
const notifOpen = ref(false)
const notifWrap = ref(null)
// 通知类型 → emoji 映射
const NOTIF_TYPE_META = {
version_warning: { emo: '🚨' },
sync_batch: { emo: '📥' },
pull_manual: { emo: '✋' },
toplist_pull: { emo: '🏆' },
toplist_change: { emo: '📊' },
daily_preview: { emo: '⏰' },
}
function typeMeta(type) {
return NOTIF_TYPE_META[type] || { emo: '🔔' }
}
function fmtNotifTime(t) {
return (t || '').replace('T', ' ').slice(0, 16)
}
async function toggleNotif() {
notifOpen.value = !notifOpen.value
if (notifOpen.value) await refreshNotifications()
}
async function markAllRead() {
await markAllNotifsRead()
}
/** 点击通知:按类型跳转 + 标记已读 + 关闭面板 */
async function handleNotifClick(n) {
const b = n.batch_id
switch (n.type) {
case 'sync_batch': await router.push({ path: '/logs', query: { action: 'sync_latest', batchId: b } }); break
case 'pull_manual': await router.push({ path: '/logs', query: { action: 'pull_manual', batchId: b } }); break
case 'toplist_pull': await router.push({ path: '/logs', query: { action: 'pull_toplist', batchId: b } }); break
case 'toplist_change': await router.push('/toplist'); break
case 'version_warning': await router.push({ path: '/search', query: { status: 'warn' } }); break
default: await router.push('/')
}
await markNotifRead(n.id)
notifOpen.value = false
}
const navs = [
{ path: '/', label: '缓存概览', emo: '🏠' },
@@ -44,6 +87,7 @@ toggle()
function onDocClick(e) {
if (!e.target.closest('.avatar-wrap')) menuOpen.value = false
if (!e.target.closest('.notif-wrap')) notifOpen.value = false
}
onMounted(() => document.addEventListener('click', onDocClick))
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
@@ -78,6 +122,38 @@ onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
:aria-label="theme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'"
@click="toggle"
>{{ theme === 'dark' ? '☀️' : '🌙' }}</button>
<div class="notif-wrap" ref="notifWrap">
<button class="notif-bell" title="消息通知" @click="toggleNotif">🔔
<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" class="notif-readall" @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 }} · {{ fmtNotifTime(n.created_at) }}</div>
</div>
<span class="notif-go">→</span>
</div>
<div v-if="!state.notifications.length" class="notif-empty">暂无通知</div>
</div>
</div>
</transition>
</div>
<div class="avatar-wrap">
<div class="avatar" title="用户菜单" @click="menuOpen = !menuOpen">👤</div>
<transition name="pop">
@@ -204,6 +280,126 @@ onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
transform: translateY(-1px);
}
/* —— 通知铃铛 + 下拉面板 —— */
.notif-wrap {
position: relative;
}
.notif-bell {
position: relative;
width: 36px;
height: 36px;
border-radius: 50%;
display: grid;
place-items: center;
border: 1.5px solid var(--line);
background: var(--toggle-bg);
font-size: 16px;
cursor: pointer;
transition: .16s;
}
.notif-bell:hover {
border-color: var(--orange);
box-shadow: 0 6px 14px -6px rgba(255, 140, 0, .4);
transform: translateY(-1px);
}
.notif-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 17px;
height: 17px;
padding: 0 4px;
border-radius: 99px;
background: var(--red);
color: #fff;
font-size: 10px;
font-weight: 700;
font-family: var(--num);
display: grid;
place-items: center;
line-height: 1;
}
.notif-panel {
position: absolute;
right: 0;
top: calc(100% + 10px);
width: min(360px, 86vw);
background: var(--card);
border: 1px solid var(--line);
border-radius: 13px;
box-shadow: var(--shadow);
z-index: 300;
overflow: hidden;
}
.notif-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 16px;
font-size: 14px;
font-weight: 700;
border-bottom: 1px solid var(--line);
}
.notif-readall {
border: none;
background: transparent;
color: var(--orange);
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.notif-list {
max-height: 420px;
overflow-y: auto;
}
.notif-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 16px;
cursor: pointer;
border-bottom: 1px solid var(--line-soft, var(--line));
transition: background .15s;
}
.notif-item:hover {
background: var(--panel-soft);
}
.notif-item.unread {
background: var(--blue-soft);
}
.notif-emo {
font-size: 16px;
flex-shrink: 0;
margin-top: 1px;
}
.notif-body {
flex: 1;
min-width: 0;
}
.notif-title {
font-size: 13px;
font-weight: 700;
color: var(--ink);
}
.notif-sub {
font-size: 11.5px;
color: var(--ink-faint);
margin-top: 2px;
word-break: break-all;
}
.notif-go {
color: var(--ink-faint);
font-size: 14px;
flex-shrink: 0;
margin-top: 2px;
}
.notif-empty {
padding: 40px 0;
text-align: center;
color: var(--ink-faint);
font-size: 13px;
}
/* —— 头像下拉菜单 —— */
.avatar-wrap {
position: relative;
+2
View File
@@ -7,6 +7,7 @@ import ThresholdsView from '../views/ThresholdsView.vue'
import IgnoredView from '../views/IgnoredView.vue'
import LogsView from '../views/LogsView.vue'
import TopListView from '../views/TopListView.vue'
import NotificationsView from '../views/NotificationsView.vue'
import { storage } from '../utils/storage'
const routes = [
@@ -17,6 +18,7 @@ const routes = [
{ path: '/ignored', name: 'ignored', component: IgnoredView },
{ path: '/logs', name: 'logs', component: LogsView },
{ path: '/toplist', name: 'toplist', component: TopListView },
{ path: '/notifications', name: 'notifications', component: NotificationsView },
{ path: '/:pathMatch(.*)*', redirect: '/' },
]
+58 -2
View File
@@ -37,6 +37,10 @@ const state = reactive({
},
toplistNextRun: null,
toplistLastRun: null,
// 消息通知
notifications: [], // 最近 50 条(下拉展示用)
unreadCount: 0,
lastNotifId: 0, // 轮询游标(仅取 id > lastNotifId 的新通知)
})
// —— 派生统计 ——
@@ -153,6 +157,7 @@ async function doRefresh() {
}
await Promise.all([refreshThresholds(), refreshLogs()])
pollNotifications() // 启动 30s 通知轮询(首次拉最新 20 条)
} catch (e) {
// 网络/后端异常统一提示一次,避免向上穿透成未处理的 Promise rejection
ElMessage.error(e?.message || '数据加载失败,请稍后重试')
@@ -350,8 +355,56 @@ async function refreshThresholds() {
state.threshold.exceptions = t.exceptions || []
}
async function refreshLogs({ action = null, limit = 100 } = {}) {
state.logs = await api.getLogs({ action, limit })
// —— 消息通知 ——
let notifPollTimer = null
/** 30s 轮询通知:新通知合并到头部,warn/error 级别弹一次 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
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 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() {
@@ -412,6 +465,9 @@ scanNow,
refreshTopList,
fetchTopList,
pullTopList,
refreshNotifications,
markNotifRead,
markAllNotifsRead,
saveThresholds,
ignorePkg,
unignorePkg,
+58 -2
View File
@@ -1,10 +1,14 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useStore } from '../store'
const { state, refreshLogs } = useStore()
const route = useRoute()
const router = useRouter()
const detail = ref(null) // 查看日志详情的当前条
const filter = ref('') // 当前筛选 tab:'' 全部
const batchId = ref(null) // 来自通知跳转的批次筛选
// 筛选 tab:''=全部 + 分组
const FILTERS = [
@@ -30,7 +34,30 @@ function matchFilter(log) {
return log.action === filter.value
}
onMounted(() => refreshLogs({ action: normalizeFilter(filter.value) }))
onMounted(() => {
const { action, batchId: bid } = route.query
if (bid) {
batchId.value = String(bid)
refreshLogs({ action: action || null, batchId: String(bid), limit: 500 })
} else {
refreshLogs({ action: normalizeFilter(filter.value) })
}
})
// 通知跳转带 batchId 时按批次筛选;切换回无 batchId 时恢复 tab 筛选
watch(() => route.query, (q) => {
if (q.batchId) {
batchId.value = String(q.batchId)
refreshLogs({ action: q.action || null, batchId: String(q.batchId), limit: 500 })
} else {
batchId.value = null
refreshLogs({ action: normalizeFilter(filter.value) })
}
})
function clearBatch() {
router.replace('/logs')
}
async function switchFilter(key) {
filter.value = key
@@ -129,6 +156,12 @@ function detailTitle(log) {
>{{ f.label }}</button>
</div>
<!-- 批次筛选横幅(来自通知跳转) -->
<div v-if="batchId" class="batch-bar">
<span>📦 已按批次筛选,共 {{ state.logs.length }} 条</span>
<button class="batch-clear" @click="clearBatch">✕ 清除</button>
</div>
<div class="vtable">
<table>
<thead>
@@ -294,6 +327,29 @@ function detailTitle(log) {
.filter-tab:hover { border-color: var(--orange); color: var(--orange); }
.filter-tab.active { background: var(--grad-main); color: #fff; border-color: transparent; box-shadow: 0 6px 14px -6px rgba(255, 140, 0, .5); }
.batch-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 14px;
padding: 10px 14px;
background: var(--blue-soft);
border: 1px solid var(--blue);
border-radius: 11px;
font-size: 12.5px;
color: var(--blue);
font-weight: 600;
}
.batch-clear {
border: none;
background: transparent;
color: var(--blue);
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
}
.ov { position: fixed; inset: 0; background: rgba(60, 45, 30, .4); z-index: 140; }
.detail-dialog {
position: fixed;
+89
View File
@@ -0,0 +1,89 @@
<script setup>
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from '../store'
const router = useRouter()
const { state, refreshNotifications, markNotifRead, markAllNotifsRead } = useStore()
const NOTIF_TYPE_META = {
version_warning: { emo: '🚨' },
sync_batch: { emo: '📥' },
pull_manual: { emo: '✋' },
toplist_pull: { emo: '🏆' },
toplist_change: { emo: '📊' },
daily_preview: { emo: '⏰' },
}
function typeMeta(type) {
return NOTIF_TYPE_META[type] || { emo: '🔔' }
}
function fmtTime(t) {
return (t || '').replace('T', ' ').slice(0, 16)
}
async function handleNotifClick(n) {
const b = n.batch_id
switch (n.type) {
case 'sync_batch': await router.push({ path: '/logs', query: { action: 'sync_latest', batchId: b } }); break
case 'pull_manual': await router.push({ path: '/logs', query: { action: 'pull_manual', batchId: b } }); break
case 'toplist_pull': await router.push({ path: '/logs', query: { action: 'pull_toplist', batchId: b } }); break
case 'toplist_change': await router.push('/toplist'); break
case 'version_warning': await router.push({ path: '/search', query: { status: 'warn' } }); break
default: await router.push('/')
}
await markNotifRead(n.id)
}
onMounted(() => refreshNotifications())
</script>
<template>
<div class="page">
<div class="head">
<h2>🔔 消息通知</h2>
<button v-if="state.unreadCount" class="readall" @click="markAllNotifsRead">全部已读</button>
</div>
<div class="list">
<div
v-for="n in state.notifications"
:key="n.id"
class="item"
:class="{ unread: !n.read_at }"
@click="handleNotifClick(n)"
>
<span class="emo">{{ typeMeta(n.type).emo }}</span>
<div class="body">
<div class="title">{{ n.title }}</div>
<div class="sub">{{ n.body }}</div>
<div class="time">{{ fmtTime(n.created_at) }}</div>
</div>
<span class="go">→</span>
</div>
<div v-if="!state.notifications.length" class="empty">暂无通知</div>
</div>
</div>
</template>
<style scoped>
.page { padding: 16px; display: flex; flex-direction: column; gap: 14px; }
.head { display: flex; align-items: center; justify-content: space-between; }
.head h2 { font-size: 20px; margin: 0; }
.readall {
border: none; background: transparent; color: var(--orange);
font-size: 13px; font-weight: 700; cursor: pointer;
}
.list { display: flex; flex-direction: column; gap: 10px; }
.item {
display: flex; align-items: flex-start; gap: 12px; padding: 14px 16px;
background: var(--card); border: 1px solid var(--line); border-radius: 14px; cursor: pointer;
}
.item.unread { background: var(--blue-soft); border-color: var(--blue); }
.emo { font-size: 20px; flex-shrink: 0; }
.body { flex: 1; min-width: 0; }
.title { font-size: 14px; font-weight: 700; }
.sub { font-size: 12.5px; color: var(--ink-dim); margin-top: 3px; word-break: break-all; }
.time { font-size: 11px; color: var(--ink-faint); margin-top: 5px; font-family: var(--num); }
.go { color: var(--ink-faint); font-size: 16px; align-self: center; }
.empty { padding: 60px 0; text-align: center; color: var(--ink-faint); }
</style>