feat: 我的缓存页支持一键拉取存量包最新版本
- 后端新增 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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user