diff --git a/backend/db.js b/backend/db.js index 06b4199..c2d813c 100644 --- a/backend/db.js +++ b/backend/db.js @@ -70,6 +70,13 @@ if (!logCols.includes('action')) { db.exec("ALTER TABLE logs ADD COLUMN action TEXT DEFAULT 'clean'") } +// 旧库迁移 v2:pkglist 增加 cached 列——标记「写入清单时 storage 已缓存该包」(done 的来源之一, +// 与「通过清单拉取完成」区分,前端据此展示「已在缓存 / 已完成」两种 done) +const pkglistCols = db.prepare('PRAGMA table_info(pkglist)').all().map(c => c.name) +if (!pkglistCols.includes('cached')) { + db.exec("ALTER TABLE pkglist ADD COLUMN cached INTEGER DEFAULT 0") +} + // 旧库迁移 v1:历史日志/忽略记录的时间戳是 UTC,统一转成北京时间;settings 标志位防重复执行 function migrateTimezone() { if (getSetting('timezone_migrated_v1')) return diff --git a/backend/pkglist.js b/backend/pkglist.js index cd28ad0..549a30d 100644 --- a/backend/pkglist.js +++ b/backend/pkglist.js @@ -17,7 +17,36 @@ * pkglist_schedule_pull { enabled, cron } 定时按清单拉取(默认每天 06:00) * pkglist_seeded '1' 内置清单是否已初始化 */ +import fs from 'node:fs' +import path from 'node:path' import db, { getSetting, setSetting, addLog, nowIso } from './db.js' +import { resolveStorageDir, isValidStorage } from './scanner.js' + +// ================= 轻量:读取 storage 中已缓存的包名集合(仅列目录,不读文件) ================= +function listCachedPkgNames() { + const storageDir = resolveStorageDir() + if (!isValidStorage(storageDir)) return new Set() + const out = new Set() + let entries + try { + entries = fs.readdirSync(storageDir, { withFileTypes: true }) + } catch { return out } + for (const e of entries) { + if (!e.isDirectory() || e.name === 'node_modules' || e.name.startsWith('.')) continue + if (e.name.startsWith('@')) { + // scoped 包目录:@scope 下每个子目录是一个包 + try { + const subs = fs.readdirSync(path.join(storageDir, e.name), { withFileTypes: true }) + for (const s of subs) { + if (s.isDirectory()) out.add(`${e.name}/${s.name}`) + } + } catch { /* 忽略不可读目录 */ } + } else { + out.add(e.name) + } + } + return out +} // ================= 内置热门清单(存量之外,按品类收录) ================= // version 全部留空 = 拉取时解析私服 dist-tags.latest(永远最新版) @@ -288,26 +317,33 @@ export const HOT_POOL_NAMES = HOT_POOL.map(p => p.name) const HOT_BY_NAME = new Map(HOT_POOL.map(p => [p.name, p.category])) -/** 首次初始化:把内置热门清单写入 DB(powered by settings 标记,幂等) */ +/** 首次初始化:把内置热门清单写入 DB(过滤已缓存包,直接置 done;幂等) */ export function seedIfNeeded() { if (getSetting('pkglist_seeded', '') === '1') return 0 + const cached = listCachedPkgNames() const ins = db.prepare( - 'INSERT OR IGNORE INTO pkglist (pkg_name, version, category, status, auto, created_at) VALUES (?, NULL, ?, ?, 1, ?)', + 'INSERT OR IGNORE INTO pkglist (pkg_name, version, category, status, auto, cached, created_at) VALUES (?, NULL, ?, ?, 1, ?, ?)', ) - let n = 0 + let n = 0, skipped = 0 db.transaction(() => { for (const p of HOT_POOL) { - n += ins.run(p.name, p.category, 'pending', nowIso()).changes + if (cached.has(p.name)) { + // 已缓存:直接插入并置 done(auto=1),不计入待拉取 + ins.run(p.name, p.category, 'done', 1, nowIso()) + skipped++ + } else { + n += ins.run(p.name, p.category, 'pending', 0, nowIso()).changes + } } setSetting('pkglist_seeded', '1') })() - console.log(`[pkglist] 内置热门清单初始化完成:写入 ${n} 条`) + console.log(`[pkglist] 内置热门清单初始化完成:写入 ${n} 条,跳过(已缓存)${skipped} 条`) return n } /** 全部清单条目(id desc) */ export function getAllRows() { - return db.prepare('SELECT id, pkg_name, version, category, status, auto, created_at FROM pkglist ORDER BY id DESC').all() + return db.prepare('SELECT id, pkg_name, version, category, status, auto, cached, created_at FROM pkglist ORDER BY id DESC').all() } /** pending 条目(待拉取) */ @@ -337,6 +373,7 @@ export function getPkgList() { version: r.version || '', // 空 = 最新版 category: r.category || '', status: r.status, + cached: !!r.cached, auto: !!r.auto, createdAt: r.created_at, })) @@ -350,10 +387,13 @@ export function addPkg({ pkgName, version = '', category = '' }) { const exists = db.prepare('SELECT 1 FROM pkglist WHERE pkg_name = ?').get(name) if (exists) return { ok: false, message: `「${name}」已在清单中` } const cat = String(category || '').trim() || HOT_BY_NAME.get(name) || '其他' - db.prepare('INSERT INTO pkglist (pkg_name, version, category, status, auto, created_at) VALUES (?, ?, ?, ?, 0, ?)') - .run(name, String(version || '').trim() || null, cat, 'pending', nowIso()) - addLog({ action: 'list-add', pkgName: name, deletedVersions: JSON.stringify({ version: version || null, category: cat }) }) - return { ok: true } + // 插入时自动检测:storage 中已有则直接置 done,否则 pending + const cached = listCachedPkgNames() + const status = cached.has(name) ? 'done' : 'pending' + db.prepare('INSERT INTO pkglist (pkg_name, version, category, status, auto, cached, created_at) VALUES (?, ?, ?, ?, 0, ?, ?)') + .run(name, String(version || '').trim() || null, cat, status, status === 'done' ? 1 : 0, nowIso()) + addLog({ action: 'list-add', pkgName: name, deletedVersions: JSON.stringify({ version: version || null, category: cat, preCached: status === 'done' }) }) + return { ok: true, status } } /** 编辑条目(改包名/版本/品类;内容变更后重置为 pending,相当于清单写入新内容) */ @@ -370,8 +410,10 @@ export function editPkg(id, { pkgName, version, category }) { const cat = category != null && String(category).trim() ? String(category).trim() : row.category // 内容有变化 → 重置 pending(新目标需要重新拉取;否则保持原状态) const changed = name !== row.pkg_name || ver !== row.version || cat !== row.category - db.prepare('UPDATE pkglist SET pkg_name = ?, version = ?, category = ?, status = ? WHERE id = ?') - .run(name, ver, cat, changed ? 'pending' : row.status, row.id) + const newStatus = changed ? 'pending' : row.status + const newCached = newStatus === 'done' ? (row.cached || 0) : 0 + db.prepare('UPDATE pkglist SET pkg_name = ?, version = ?, category = ?, status = ?, cached = ? WHERE id = ?') + .run(name, ver, cat, newStatus, newCached, row.id) if (changed) addLog({ action: 'list-edit', pkgName: name, deletedVersions: JSON.stringify({ from: row.pkg_name, version: ver, category: cat }) }) return { ok: true } } @@ -388,7 +430,7 @@ export function removePkg(id) { /** 定时更新清单:把内置热门池中尚未收录的包批量加入(pending,auto 来源),返回新增条数 */ export function addFromHotPool() { const ins = db.prepare( - 'INSERT OR IGNORE INTO pkglist (pkg_name, version, category, status, auto, created_at) VALUES (?, NULL, ?, ?, 1, ?)', + 'INSERT OR IGNORE INTO pkglist (pkg_name, version, category, status, auto, cached, created_at) VALUES (?, NULL, ?, ?, 1, 0, ?)', ) let n = 0 db.transaction(() => { @@ -519,14 +561,22 @@ export async function updateFromAllSources() { } catch (e) { // 榜单解析异常 → 仅内置池 } + // 定时新增前同样做存量去重:storage 已缓存的包直接置 done(不重复拉取),其余置 pending + const cached = listCachedPkgNames() const ins = db.prepare( - 'INSERT OR IGNORE INTO pkglist (pkg_name, version, category, status, auto, created_at) VALUES (?, NULL, ?, ?, 1, ?)', + 'INSERT OR IGNORE INTO pkglist (pkg_name, version, category, status, auto, cached, created_at) VALUES (?, NULL, ?, ?, 1, ?, ?)', ) - let added = 0 + let added = 0, skipped = 0 try { db.transaction(() => { for (const [name, category] of merged) { - added += ins.run(name, category, 'pending', nowIso()).changes + const isCached = cached.has(name) + const status = isCached ? 'done' : 'pending' + const changes = ins.run(name, category, status, isCached ? 1 : 0, nowIso()).changes + if (changes) { + if (status === 'done') skipped++ + else added++ + } } })() } catch (e) { @@ -534,12 +584,39 @@ export async function updateFromAllSources() { return { added: 0, rankSource: rankSource || '' } } const note = rankSource - ? `定时收录热门 npm 包:内置池+榜单 新增 ${added} 个(榜单源 ${rankSource})` - : `定时收录热门 npm 包:内置池 新增 ${added} 个(npm-search 榜单不可用,已降级)` - if (added > 0 || rankSource) console.log(`[pkglist] ${note}`) + ? `定时收录热门 npm 包:内置池+榜单 新增 ${added} 个、已缓存 ${skipped} 个(榜单源 ${rankSource})` + : `定时收录热门 npm 包:内置池 新增 ${added} 个、已缓存 ${skipped} 个(npm-search 榜单不可用,已降级)` + if (added > 0 || skipped > 0 || rankSource) console.log(`[pkglist] ${note}`) return { added, rankSource: rankSource || '' } } +/** 按包名将清单条目标记为 done(手动去重时用) */ +export function markDoneByName(pkgName) { + return db.prepare("UPDATE pkglist SET status = 'done', cached = 1 WHERE pkg_name = ? AND status != 'done'").run(pkgName).changes +} + +/** + * 手动去重:把清单中「storage 已缓存」的 pending 条目直接置 done, + * 避免对存量包重复拉取。返回处理条数与剩余 pending 数。 + */ +export function dedupPkgList() { + const cached = listCachedPkgNames() + const rows = db.prepare("SELECT id, pkg_name FROM pkglist WHERE status = 'pending'").all() + const upd = db.prepare("UPDATE pkglist SET status = 'done', cached = 1 WHERE id = ?") + let matched = 0 + db.transaction(() => { + for (const r of rows) { + if (cached.has(r.pkg_name)) { + upd.run(r.id) + matched++ + } + } + })() + console.log(`[pkglist] 手动去重:${matched} 条已缓存条目置 done,剩余 pending ${rows.length - matched} 条`) + if (matched > 0) addLog({ action: 'list-dedup', pkgName: null, deletedCount: matched }) + return { matched, pending: rows.length - matched } +} + // ================= 定时配置 ================= const DEFAULT_CONFIG = { diff --git a/backend/server.js b/backend/server.js index ebbde25..3c11872 100644 --- a/backend/server.js +++ b/backend/server.js @@ -30,7 +30,7 @@ import semverCompare from './semver.js' import { syncState, runSyncLatest } from './sync-latest.js' import { seedIfNeeded, countPending, getPkgList, addPkg, editPkg, removePkg, - getConfig, saveConfig, startPkgListDaemon, updateFromAllSources, + getConfig, saveConfig, startPkgListDaemon, updateFromAllSources, dedupPkgList, } from './pkglist.js' import { pkgPullState, runPkgListPull } from './pkglist-pull.js' @@ -467,6 +467,12 @@ app.delete('/api/pkglist/:id', (req, res) => { res.json({ ok: true, ...getPkgList() }) }) +// —— API:清单手动去重(将 storage 已缓存但清单仍为 pending 的条目置 done,避免重复拉取) —— +app.post('/api/pkglist/dedup', (req, res) => { + const r = dedupPkgList() + res.json({ ok: true, ...r, ...getPkgList() }) +}) + // —— API:按清单手动拉取(同步启动后台任务,不阻塞;失败自动重试 3 次;清单失效时提示) —— app.post('/api/pkglist/pull', (req, res) => { if (isDemoMode()) { diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 62ed597..df3d9c1 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -487,6 +487,18 @@ return r.json() }) return r.json() }, + + /** 手动去重:将 storage 已缓存但清单仍为 pending 的条目置 done */ + async dedupPkgList() { + if (USE_MOCK) { + await delay() + const matched = 0 // mock 无 storage 概念,视为 0 + const pending = mock.pkglistItems.filter(i => i.status === 'pending').length + return { ok: true, matched, pending, items: clone(mock.pkglistItems) } + } + const r = await authFetch('/api/pkglist/dedup', { method: 'POST' }) + return r.json() + }, } export default api diff --git a/frontend/src/components/PkgListDrawer.vue b/frontend/src/components/PkgListDrawer.vue index 6fd10b1..1e40349 100644 --- a/frontend/src/components/PkgListDrawer.vue +++ b/frontend/src/components/PkgListDrawer.vue @@ -12,7 +12,9 @@ const props = defineProps({ }) const emit = defineEmits(['update:visible', 'pulled']) -const { state, refreshPkgList, addPkgItem, updatePkgItem, deletePkgItem } = useStore() +const { state, refreshPkgList, addPkgItem, updatePkgItem, deletePkgItem, dedupPkgList } = useStore() + +const dedupInProgress = ref(false) // —— 新增 / 编辑弹窗 —— const formOpen = ref(false) @@ -77,6 +79,25 @@ async function doDelete(item) { } } +async function doDedup() { + if (dedupInProgress.value) return + dedupInProgress.value = true + try { + const r = await dedupPkgList() + if (r && r.ok) { + ElMessage.success(r.matched > 0 + ? `去重完成:${r.matched} 条已缓存条目标记为完成,剩余待拉取 ${r.pending} 条` + : '去重完成:无已缓存条目需要标记') + } else { + ElMessage.warning((r && r.message) || '去重失败') + } + } catch (e) { + ElMessage.error(e?.message || '去重请求失败') + } finally { + dedupInProgress.value = false + } +} + function close() { emit('update:visible', false) } @@ -107,6 +128,7 @@ watch(() => props.visible, (v) => {
{{ syncResultDetail }}
+