feat(pkglist): 去重 + 状态持久 + UI 弹窗风格统一
后端: - seedIfNeeded/addPkg/updateFromAllSources 插入前检测 storage 是否已缓存同名包,已缓存直接置 done - 新增 markDoneByName + dedupPkgList 函数 - pkglist 表新增 cached 列区分「已在缓存」vs「清单拉取完成」 - 新增 POST /api/pkglist/dedup 接口 前端: - api/index.js 新增 dedupPkgList() - store 暴露 dedupPkgList action,doRefresh 初始化时检测 sync-latest 运行状态并恢复轮询 - PkgListDrawer.vue 增加「去重」按钮,状态列区分「已在缓存」/「已完成」 - SearchView.vue 一键拉取弹窗改为与整体一致的自定义风格(确认+结果弹窗) - 按钮已按 syncProgress.running 显示旋转动画
This commit is contained in:
@@ -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
|
||||
|
||||
+96
-19
@@ -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 = {
|
||||
|
||||
+7
-1
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
<div class="pld-toolbar">
|
||||
<button class="pld-btn primary" @click="openAdd">+ 新增包</button>
|
||||
<button class="pld-btn" title="刷新清单" @click="refreshPkgList">🔄 刷新</button>
|
||||
<button class="pld-btn" :disabled="dedupInProgress" :class="{ primary: true }" title="去重:将 storage 已缓存但清单仍为待拉取的条目置已完成" @click="doDedup">{{ dedupInProgress ? '⏳' : '🔧' }} 去重</button>
|
||||
</div>
|
||||
|
||||
<div class="pld-table">
|
||||
@@ -128,8 +150,8 @@ watch(() => props.visible, (v) => {
|
||||
<td><span class="pld-cat">{{ it.category || '—' }}</span></td>
|
||||
<td><span class="pld-src" :class="{ auto: !!it.auto }">{{ it.auto ? '内置' : '手工' }}</span></td>
|
||||
<td>
|
||||
<span class="pld-status" :class="it.status === 'done' ? 'done' : 'pending'">
|
||||
{{ it.status === 'done' ? '✅ 已完成' : '⏳ 待拉取' }}
|
||||
<span class="pld-status" :class="[it.status, it.cached ? 'cached' : '']">
|
||||
{{ it.status === 'done' ? (it.cached ? '✅ 已在缓存' : '✅ 已完成') : '⏳ 待拉取' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="pld-ops">
|
||||
@@ -282,6 +304,7 @@ watch(() => props.visible, (v) => {
|
||||
.pld-status { font-size: 11.5px; font-weight: 600; }
|
||||
.pld-status.done { color: var(--green-deep); }
|
||||
.pld-status.pending { color: var(--orange); }
|
||||
.pld-status.cached { color: #3b82f6; }
|
||||
.pld-ops { white-space: nowrap; }
|
||||
.pld-mini {
|
||||
padding: 4px 10px;
|
||||
|
||||
@@ -133,6 +133,23 @@ async function doRefresh() {
|
||||
await pollScanProgress()
|
||||
await refreshPackages() // 扫描完成后重新拉取完整数据
|
||||
}
|
||||
|
||||
// 刷新后检查一键拉取是否仍在运行(浏览器刷新后恢复沙漏旋转)
|
||||
const sp = await api.getSyncProgress()
|
||||
if (sp.running) {
|
||||
state.syncProgress.running = true
|
||||
state.syncProgress.done = false
|
||||
state.syncProgress.total = sp.total || 0
|
||||
state.syncProgress.processed = sp.processed || 0
|
||||
state.syncProgress.success = sp.success || 0
|
||||
state.syncProgress.skipped = sp.skipped || 0
|
||||
state.syncProgress.failed = sp.failed || 0
|
||||
state.syncProgress.failedList = sp.failedList || []
|
||||
state.syncProgress.current = sp.current || null
|
||||
state.syncProgress.error = sp.error || null
|
||||
pollSyncProgress()
|
||||
}
|
||||
|
||||
await Promise.all([refreshThresholds(), refreshLogs()])
|
||||
} catch (e) {
|
||||
// 网络/后端异常统一提示一次,避免向上穿透成未处理的 Promise rejection
|
||||
@@ -391,6 +408,16 @@ async function savePkgListConfig(payload) {
|
||||
return r
|
||||
}
|
||||
|
||||
async function dedupPkgList() {
|
||||
const r = await api.dedupPkgList()
|
||||
if (r && r.ok) {
|
||||
state.pkgList.items = r.items || state.pkgList.items
|
||||
state.pkgList.active = countPending() > 0
|
||||
if (r.matched > 0) ElMessage.success(`${r.matched} 个已缓存条目标记为完成`)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
export function useStore() {
|
||||
return {
|
||||
state,
|
||||
@@ -415,5 +442,6 @@ scanNow,
|
||||
pollPkgPullProgress,
|
||||
refreshPkgListConfig,
|
||||
savePkgListConfig,
|
||||
dedupPkgList,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,18 +228,20 @@ async function onCleanConfirmed(res) {
|
||||
// —— 一键拉取存量包最新版本 ——
|
||||
const summaryShown = ref(false)
|
||||
|
||||
async function doSyncLatest() {
|
||||
const sp = state.syncProgress
|
||||
if (sp.running) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'将查询上游 npm registry,为存量包拉取每个包的最新版本(已缓存最新版会自动跳过)。',
|
||||
'拉取最新版本',
|
||||
{ type: 'info', confirmButtonText: '开始拉取', cancelButtonText: '取消', closeOnClickModal: false },
|
||||
)
|
||||
} catch {
|
||||
return // 用户取消
|
||||
}
|
||||
const syncConfirmOpen = ref(false)
|
||||
const syncResultOpen = ref(false)
|
||||
const syncResultDetail = ref('')
|
||||
const syncResultFailed = ref(false)
|
||||
|
||||
function openSyncConfirm() {
|
||||
if (state.syncProgress.running) return
|
||||
syncConfirmOpen.value = true
|
||||
}
|
||||
function closeSyncConfirm() {
|
||||
syncConfirmOpen.value = false
|
||||
}
|
||||
async function confirmSyncLatest() {
|
||||
closeSyncConfirm()
|
||||
summaryShown.value = false
|
||||
const res = await syncLatest()
|
||||
if (!res.ok) return
|
||||
@@ -250,6 +252,10 @@ async function doSyncLatest() {
|
||||
// 任务异步执行,完成后由下方 watch 弹出汇总
|
||||
}
|
||||
|
||||
function closeSyncResult() {
|
||||
syncResultOpen.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => state.syncProgress.running,
|
||||
async (running, prev) => {
|
||||
@@ -261,18 +267,17 @@ watch(
|
||||
detail.push(`❌ 拉取失败 ${sp.failed} 个`)
|
||||
if (sp.failedList.length) detail.push(`失败明细:\n${sp.failedList.map(f => `· ${f}`).join('\n')}`)
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.alert(detail.join('\n'), sp.failed ? '拉取完成(部分失败)' : '拉取完成', {
|
||||
confirmButtonText: '知道了',
|
||||
type: sp.failed ? 'warning' : 'success',
|
||||
})
|
||||
} catch {
|
||||
/* 用户关闭弹窗无需处理 */
|
||||
}
|
||||
syncResultDetail.value = detail.join('\n')
|
||||
syncResultFailed.value = !!sp.failed
|
||||
syncResultOpen.value = true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function doSyncLatest() {
|
||||
openSyncConfirm()
|
||||
}
|
||||
|
||||
// —— npm 包清单:查看/编辑入口 + 按清单手动拉取(后台执行,失败自动重试 3 次) ——
|
||||
const pkgListOpen = ref(false)
|
||||
const pullLoading = ref(null)
|
||||
@@ -512,6 +517,33 @@ const pendingCount = computed(() => state.pkgList.items.filter(i => i.status ===
|
||||
@confirmed="onCleanConfirmed"
|
||||
/>
|
||||
<PkgListDrawer v-model:visible="pkgListOpen" />
|
||||
|
||||
<!-- 一键拉取确认弹窗(统一风格) -->
|
||||
<Transition name="dd">
|
||||
<div v-if="syncConfirmOpen" class="sync-mask" @click.self="closeSyncConfirm">
|
||||
<div class="sync-confirm">
|
||||
<div class="sync-confirm-title">拉取最新版本</div>
|
||||
<div class="sync-confirm-body">将查询上游 npm registry,为存量包拉取每个包的最新版本(已缓存最新版会自动跳过)。</div>
|
||||
<div class="sync-confirm-ops">
|
||||
<button class="pld-btn" @click="closeSyncConfirm">取消</button>
|
||||
<button class="pld-btn primary" @click="confirmSyncLatest">开始拉取</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 拉取结果弹窗 -->
|
||||
<Transition name="dd">
|
||||
<div v-if="syncResultOpen" class="sync-mask" @click.self="closeSyncResult">
|
||||
<div class="sync-result" :class="{ warn: syncResultFailed }">
|
||||
<div class="sync-result-title">{{ syncResultFailed ? '拉取完成(部分失败)' : '拉取完成' }}</div>
|
||||
<pre class="sync-result-body">{{ syncResultDetail }}</pre>
|
||||
<div class="sync-result-ops">
|
||||
<button class="pld-btn primary" @click="closeSyncResult">知道了</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -768,6 +800,54 @@ const pendingCount = computed(() => state.pkgList.items.filter(i => i.status ===
|
||||
.empty-tip { padding: 40px 0; text-align: center; color: var(--ink-faint); font-size: 12.5px; }
|
||||
.loading-tip { text-align: center; padding: 40px 0; color: var(--ink-faint); font-size: 13px; }
|
||||
|
||||
/* —— 一键拉取弹窗(统一风格) —— */
|
||||
.sync-mask {
|
||||
position: fixed; inset: 0; z-index: 500;
|
||||
display: grid; place-items: center;
|
||||
background: rgba(18, 16, 14, .38);
|
||||
backdrop-filter: blur(14px) saturate(1.3);
|
||||
}
|
||||
html[data-theme="bright"] .sync-mask {
|
||||
background: rgba(244, 242, 239, .45);
|
||||
backdrop-filter: blur(14px) saturate(1.3);
|
||||
}
|
||||
.sync-confirm, .sync-result {
|
||||
width: min(420px, 92vw);
|
||||
background: var(--card);
|
||||
border: 1.5px solid var(--line);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 30px 70px -20px rgba(0, 0, 0, .5);
|
||||
padding: 22px 24px;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
}
|
||||
html[data-theme="bright"] .sync-confirm,
|
||||
html[data-theme="bright"] .sync-result {
|
||||
box-shadow: 0 30px 70px -20px rgba(0, 0, 0, .22);
|
||||
}
|
||||
.sync-confirm-title, .sync-result-title { font-size: 15px; font-weight: 800; }
|
||||
.sync-confirm-body { font-size: 13px; color: var(--ink-dim); line-height: 1.6; }
|
||||
.sync-result-body {
|
||||
font-size: 12.5px; color: var(--ink-dim); line-height: 1.7;
|
||||
white-space: pre-wrap; max-height: 50vh; overflow: auto;
|
||||
padding: 10px 12px; border-radius: 10px; background: var(--panel-soft);
|
||||
}
|
||||
.sync-confirm-ops, .sync-result-ops {
|
||||
display: flex; justify-content: flex-end; gap: 10px;
|
||||
}
|
||||
.sync-result.warn .sync-result-title { color: var(--red); }
|
||||
|
||||
/* —— 弹窗按钮(与 PkgListDrawer 统一风格) —— */
|
||||
.pld-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
height: 36px; padding: 0 16px; border-radius: 12px;
|
||||
border: 1.5px solid var(--line); background: var(--card);
|
||||
color: var(--ink-dim); font-size: 12.5px; font-weight: 600;
|
||||
cursor: pointer; transition: .15s;
|
||||
}
|
||||
.pld-btn:hover { border-color: color-mix(in srgb, var(--orange) 60%, transparent); color: var(--orange); }
|
||||
.pld-btn.primary { background: var(--grad-main); border-color: transparent; color: #fff; box-shadow: 0 8px 18px -6px rgba(255, 140, 0, .55); }
|
||||
.pld-btn.primary:hover { transform: translateY(-1px); }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.search-box { height: 48px; }
|
||||
.search-box .s-go { display: none; }
|
||||
|
||||
Reference in New Issue
Block a user