feat: 我的缓存页支持一键拉取存量包最新版本
CI / Backend tests (push) Canceled after 0s
CI / Frontend build (push) Canceled after 0s

- 后端新增 sync-latest 模块:经 Verdaccio 查询包元数据取 latest,跳过已缓存版本,未缓存版本走 Verdaccio 代拉落盘;异步状态机 + POST /api/sync/latest + GET /api/sync/progress
- 前端 SearchView 工具栏新增拉取按钮,轮询进度并汇总成功/跳过/失败明细
- 上游路由交由 Verdaccio 自身 uplinks(npm/淘宝/华为)处理,仅需 VERDACCIO_BASE_URL 配置
- docker-compose 补充私服地址环境变量;忽略 .dumate/.tmp 中间产物
This commit is contained in:
yinjun.chen1
2026-09-21 14:06:22 +08:00
parent 6c6a319d70
commit a00a58af30
8 changed files with 403 additions and 9 deletions
+28 -1
View File
@@ -178,7 +178,7 @@ async login(username, password, remember = true) {
return r.json()
},
/** 查询扫描进度(包数 / 字节数 / 是否完成) */
/** 查询扫描进度(包数 / 字节数 / 是否完成) */
async getScanProgress() {
if (USE_MOCK) {
return { scanning: false, done: true, packages: 33, bytes: 0 }
@@ -187,6 +187,33 @@ async login(username, password, remember = true) {
return r.json()
},
/** 一键拉取存量包最新版本(异步,触发后轮询进度) */
async syncLatest() {
if (USE_MOCK) {
await delay(260)
return { ok: true, demo: false, running: false, total: mockState.packages.length, startedAt: new Date().toISOString() }
}
const r = await authFetch('/api/sync/latest', { method: 'POST' })
return r.json()
},
/** 查询拉取进度(成功/跳过/失败汇总 + 失败明细) */
async getSyncProgress() {
if (USE_MOCK) {
await delay(60)
// mock:模拟一次已完成的拉取(少量包有新版本被拉取,其余已是最新)
return {
running: false, done: true,
total: mockState.packages.length, processed: mockState.packages.length,
success: 2, skipped: Math.max(0, mockState.packages.length - 2), failed: 0,
failedList: [], current: null, error: null,
startedAt: null, finishedAt: new Date().toISOString(),
}
}
const r = await authFetch('/api/sync/progress')
return r.json()
},
async getThresholds() {
if (USE_MOCK) {
await delay(80)
+69 -2
View File
@@ -17,8 +17,13 @@ const state = reactive({
storageDir: storage.get('vcm-storage-dir') || '',
// 后端目录扫描总字节(所有文件,与 du 同口径;null 表示未知/演示模式)
totalBytes: null,
// 扫描进度(异步扫描期间实时更新,用于遮罩展示)
// 扫描进度(异步扫描期间实时更新,用于遮罩展示)
scanProgress: { packages: 0, bytes: 0 },
// 拉取进度(一键拉取存量包最新版本期间实时更新)
syncProgress: {
running: false, done: false, total: 0, processed: 0,
success: 0, skipped: 0, failed: 0, failedList: [], error: null,
},
})
// —— 派生统计 ——
@@ -137,6 +142,67 @@ async function scanNow() {
}
}
let syncPollTimer = null
/** 重置拉取进度状态(触发新一轮拉取时调用) */
function resetSyncProgress(res) {
state.syncProgress.running = true
state.syncProgress.done = false
state.syncProgress.total = res.total || 0
state.syncProgress.processed = 0
state.syncProgress.success = 0
state.syncProgress.skipped = 0
state.syncProgress.failed = 0
state.syncProgress.failedList = []
state.syncProgress.error = null
}
/** 一键拉取存量包最新版本:触发后端任务并轮询进度,完成后刷新列表 */
async function syncLatest() {
try {
const res = await api.syncLatest()
if (!res.ok) return res
if (res.demo) return res // 演示模式:后端不支持拉取,由调用方直接提示
resetSyncProgress(res)
pollSyncProgress()
return res
} catch (e) {
ElMessage.error(e?.message || '发起拉取失败,请稍后重试')
return { ok: false }
}
}
/** 轮询拉取进度,任务结束后刷新包列表与操作日志(新版本已入库) */
function pollSyncProgress() {
clearTimeout(syncPollTimer)
const tick = async () => {
let p
try {
p = await api.getSyncProgress()
} catch {
state.syncProgress.running = false
state.syncProgress.error = '拉取进度查询失败'
return
}
state.syncProgress.running = !!p.running
state.syncProgress.done = !!p.done
state.syncProgress.total = p.total || 0
state.syncProgress.processed = p.processed || 0
state.syncProgress.success = p.success || 0
state.syncProgress.skipped = p.skipped || 0
state.syncProgress.failed = p.failed || 0
state.syncProgress.failedList = p.failedList || []
state.syncProgress.error = p.error || null
if (p.running) {
syncPollTimer = setTimeout(tick, 800)
} else {
clearTimeout(syncPollTimer)
await Promise.all([refreshPackages(), refreshLogs()])
}
}
tick()
}
async function refreshThresholds() {
const t = await api.getThresholds()
state.threshold.versionThreshold = t.versionThreshold
@@ -201,7 +267,8 @@ export function useStore() {
refreshPackages,
refreshThresholds,
refreshLogs,
scanNow,
scanNow,
syncLatest,
saveThresholds,
ignorePkg,
unignorePkg,
+26 -3
View File
@@ -15,8 +15,18 @@ function fmtTime(t) {
function actionMeta(action) {
if (action === 'ignore') return { emo: '💤', text: '忽略', cls: 'ignore' }
if (action === 'unignore') return { emo: '↩️', text: '取消忽略', cls: 'unignore' }
if (action === 'sync') return { emo: '📥', text: '拉取最新版', cls: 'sync' }
return { emo: '🗑', text: '清理', cls: 'clean' }
}
// sync 日志的汇总明细(addLog 时存于 deletedVersions JSON 字段)
function syncMeta(log) {
try {
return JSON.parse(log.deletedVersions || '{}')
} catch {
return {}
}
}
</script>
<template>
@@ -42,7 +52,7 @@ function actionMeta(action) {
<tbody>
<tr v-for="log in state.logs" :key="log.id" @click="detail = log">
<td><span class="date-cell">{{ fmtTime(log.created_at) }}</span></td>
<td><span class="pkg-cell">{{ log.pkgName }}</span></td>
<td><span class="pkg-cell">{{ log.action === 'sync' ? '📦 全部存量包' : log.pkgName }}</span></td>
<td>
<span class="act-badge" :class="actionMeta(log.action).cls">
{{ actionMeta(log.action).emo }} {{ actionMeta(log.action).text }}
@@ -72,8 +82,8 @@ function actionMeta(action) {
<Transition name="pop">
<div v-if="detail" class="detail-dialog">
<div class="dd-head">
<span class="dd-title">
{{ detail.action === 'ignore' ? '💤 忽略' : detail.action === 'unignore' ? '↩️ 取消忽略' : '📋 清理详情' }}
<span class="dd-title">
{{ detail.action === 'ignore' ? '💤 忽略' : detail.action === 'unignore' ? '↩️ 取消忽略' : detail.action === 'sync' ? '📥 拉取最新版' : '📋 清理详情' }}
</span>
<div class="dd-close" @click="detail = null">✕</div>
</div>
@@ -102,6 +112,18 @@ function actionMeta(action) {
</div>
</div>
</template>
<template v-else-if="detail.action === 'sync'">
<div class="dd-row">
<span class="k">拉取结果</span>
<span class="v">新增 <b class="green">+{{ syncMeta(detail).success || 0 }}</b> · 已最新 <b>{{ syncMeta(detail).skipped || 0 }}</b> · 失败 <b class="red">{{ (syncMeta(detail).failed || []).length }}</b></span>
</div>
<div v-if="(syncMeta(detail).failed || []).length" class="dd-row">
<span class="k">失败包</span>
<div class="dd-tags">
<span v-for="f in syncMeta(detail).failed" :key="f.name" class="dd-tag" :title="f.reason">{{ f.name }}</span>
</div>
</div>
</template>
<div v-else class="dd-row">
<span class="k">说明</span>
<span class="v">{{ detail.action === 'ignore' ? '已将包加入忽略列表,不再提示超阈值警告' : '已取消忽略,恢复超阈值提醒' }}</span>
@@ -133,6 +155,7 @@ function actionMeta(action) {
.act-badge.clean { color: var(--red); background: var(--red-soft); }
.act-badge.ignore { color: var(--ink-dim); background: var(--num-bg); }
.act-badge.unignore { color: var(--green-deep); background: var(--green-soft); }
.act-badge.sync { color: var(--blue); background: var(--blue-soft); }
.chip { font-family: var(--num); font-size: 12px; font-weight: 700; padding: 4px 12px; border-radius: 9px; display: inline-block; }
.chip.n { color: var(--red); background: var(--red-soft); }
.chip.s { color: var(--green-deep); background: var(--green-soft); }
+62 -2
View File
@@ -8,7 +8,7 @@ import PkgLogo from '../components/PkgLogo.vue'
import { useStore, fmtSize } from '../store'
import { storage } from '../utils/storage'
const { state, stats, refreshPackages, ignorePkg, unignorePkg, cleanPkg, deleteVersion } = useStore()
const { state, stats, refreshPackages, ignorePkg, unignorePkg, cleanPkg, deleteVersion, syncLatest } = useStore()
const route = useRoute()
const router = useRouter()
@@ -173,6 +173,54 @@ async function onCleanConfirmed(res) {
ElMessage.success(`已清理 ${res.deletedCount} 个版本,释放 ${res.freedSpace}`)
drawerVisible.value = false
}
// —— 一键拉取存量包最新版本 ——
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 // 用户取消
}
summaryShown.value = false
const res = await syncLatest()
if (!res.ok) return
if (res.demo) {
ElMessage.info(res.message || '演示模式:不支持拉取最新版本')
return
}
// 任务异步执行,完成后由下方 watch 弹出汇总
}
watch(
() => state.syncProgress.running,
async (running, prev) => {
if (prev === true && running === false && state.syncProgress.done && !summaryShown.value) {
summaryShown.value = true
const sp = state.syncProgress
const detail = [`本次共检查 ${sp.total} 个存量包:`, `✅ 已拉取最新版本 ${sp.success} 个`, `⏭️ 已是最新跳过 ${sp.skipped} 个`]
if (sp.failed) {
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 {
/* 用户关闭弹窗无需处理 */
}
}
},
)
</script>
<template>
@@ -206,7 +254,13 @@ async function onCleanConfirmed(res) {
<template v-if="keyword">(关键词「{{ keyword }}」)</template>
<template v-if="todayFilter">(<span class="today-chip">仅今日更新 {{ todayStr }} <i @click="clearTodayFilter" title="清除筛选">✕</i></span>)</template>
</div>
<div class="tb-actions">
<div class="tb-actions">
<button
class="dd-btn sync-btn"
:class="{ on: state.syncProgress.running }"
:title="state.syncProgress.running ? `正在拉取… ${state.syncProgress.processed}/${state.syncProgress.total}` : '一键拉取存量包最新版本'"
@click.stop="doSyncLatest"
>{{ state.syncProgress.running ? '⏳' : '📥' }}</button>
<div class="dd">
<button class="dd-btn" :class="{ on: filterOpen }" title="快捷筛选" @click.stop="toggleFilter">🎚️</button>
<Transition name="dd">
@@ -443,6 +497,12 @@ async function onCleanConfirmed(res) {
}
.dd-btn:hover { border-color: color-mix(in srgb, var(--orange) 60%, transparent); }
.dd-btn.on { background: var(--green); border-color: var(--green); box-shadow: 0 6px 14px -4px rgba(50, 205, 50, .5); }
.sync-btn { font-size: 17px; }
.sync-btn.on { animation: sync-spin 1.1s linear infinite; }
@keyframes sync-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.dd-menu {
position: absolute;
top: calc(100% + 8px);