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:
+70
-8
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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> <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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '/' },
|
||||
]
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user