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:
+2
-1
@@ -4,7 +4,8 @@ frontend/node_modules/
|
||||
frontend/dist/
|
||||
backend/node_modules/
|
||||
.CodeCounter
|
||||
|
||||
.dumate
|
||||
.tmp
|
||||
# 运行时数据(SQLite)
|
||||
backend/data/
|
||||
manager-data/
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from './scanner.js'
|
||||
import { cleanPackage, deleteSingleVersion } from './clean.js'
|
||||
import semverCompare from './semver.js'
|
||||
import { syncState, runSyncLatest } from './sync-latest.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const app = express()
|
||||
@@ -402,6 +403,44 @@ app.get('/api/scan/progress', (req, res) => {
|
||||
})
|
||||
})
|
||||
|
||||
// —— API:一键拉取存量包最新版本(异步,期间前端轮询进度) ——
|
||||
app.post('/api/sync/latest', (req, res) => {
|
||||
if (isDemoMode()) {
|
||||
return res.json({ ok: true, demo: true, running: false, message: '演示模式:未配置 Verdaccio 缓存目录,不支持拉取' })
|
||||
}
|
||||
if (!syncState.running) {
|
||||
runSyncLatest({
|
||||
addLog,
|
||||
invalidateScan,
|
||||
})
|
||||
}
|
||||
res.json({
|
||||
ok: true,
|
||||
demo: false,
|
||||
running: syncState.running,
|
||||
total: syncState.total,
|
||||
startedAt: syncState.startedAt,
|
||||
})
|
||||
})
|
||||
|
||||
// —— API:拉取进度(拉取期间由前端轮询) ——
|
||||
app.get('/api/sync/progress', (req, res) => {
|
||||
res.json({
|
||||
running: syncState.running,
|
||||
done: syncState.done,
|
||||
total: syncState.total,
|
||||
processed: syncState.processed,
|
||||
success: syncState.success,
|
||||
skipped: syncState.skipped,
|
||||
failed: syncState.failed,
|
||||
failedList: syncState.failedList,
|
||||
current: syncState.current,
|
||||
error: syncState.error,
|
||||
startedAt: syncState.startedAt,
|
||||
finishedAt: syncState.finishedAt,
|
||||
})
|
||||
})
|
||||
|
||||
// —— API:清理 ——
|
||||
app.post('/api/clean', (req, res) => {
|
||||
const { pkgName, keep } = req.body || {}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 存量包最新版本拉取 —— 一键把私服缓存中已有的包更新到上游最新版
|
||||
*
|
||||
* 实现原理:
|
||||
* 1. 查询:请求 Verdaccio 私服上该包的元数据(GET /:pkg),取 dist-tags.latest;
|
||||
* Verdaccio 按自身 uplinks 配置(npm / 淘宝 / 华为等多源)路由到上游获取;
|
||||
* 2. 比对:本地 storage 已缓存该精确版本则跳过(不重复拉取);
|
||||
* 3. 拉取:未缓存的版本请求 Verdaccio HTTP 端点(GET /:pkg/-/:file.tgz),
|
||||
* Verdaccio 作为代理会从 uplinks 拉取并把 tgz + 元数据写入自身存储,
|
||||
* 元数据由 Verdaccio 自己维护,格式与扫描器读取的完全一致。
|
||||
*
|
||||
* 环境变量:
|
||||
* VERDACCIO_BASE_URL 私服地址(默认 http://127.0.0.1:4873;查询与代拉均走它,上游由 Verdaccio 自行路由)
|
||||
*/
|
||||
import { scanStorageAsync, resolveStorageDir } from './scanner.js'
|
||||
|
||||
const VERDACCIO_BASE = (process.env.VERDACCIO_BASE_URL || 'http://127.0.0.1:4873').replace(/\/+$/, '')
|
||||
const QUERY_TIMEOUT = 15000 // 元数据查询超时(Verdaccio 本地/uplink 转发通常较快)
|
||||
const FETCH_TIMEOUT = 10 * 60_000 // tgz 拉取超时:大包(echarts 等)需要时间
|
||||
const CONCURRENT = 3 // 同时拉取的包数:网络 IO 温和并发,避免打满 NAS 带宽
|
||||
|
||||
// —— 拉取状态(前端轮询进度,与 scanState 模式对称) ——
|
||||
export const syncState = {
|
||||
running: false, done: false,
|
||||
total: 0, processed: 0,
|
||||
success: 0, skipped: 0, failed: 0,
|
||||
failedList: [], // [{ name, reason }]
|
||||
current: null, // 正在处理的包名
|
||||
error: null,
|
||||
startedAt: null, finishedAt: null,
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
syncState.done = false
|
||||
syncState.total = 0
|
||||
syncState.processed = 0
|
||||
syncState.success = 0
|
||||
syncState.skipped = 0
|
||||
syncState.failed = 0
|
||||
syncState.failedList = []
|
||||
syncState.current = null
|
||||
syncState.error = null
|
||||
syncState.startedAt = new Date().toISOString()
|
||||
}
|
||||
|
||||
// 编码包名:scoped 包 @scope/name → %40scope%2fname(registry / verdaccio 均接受该编码)
|
||||
const enc = name => encodeURIComponent(name)
|
||||
// tarball 文件名:@scope/name → name-version.tgz(Verdaccio 落盘命名不携带 scope 前缀)
|
||||
const fileOf = (name, version) => `${name.split('/').pop()}-${version}.tgz`
|
||||
|
||||
/** GET JSON,带超时;404 返回 null(包不存在),其余非 2xx 抛可读错误 */
|
||||
async function fetchJson(url, timeout) {
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), timeout)
|
||||
try {
|
||||
const r = await fetch(url, { signal: ctrl.signal, headers: { Accept: 'application/json' } })
|
||||
if (r.status === 404) return null
|
||||
if (!r.ok) throw new Error(`私服响应 ${r.status}`)
|
||||
return await r.json()
|
||||
} catch (e) {
|
||||
if (e && e.name === 'AbortError') throw new Error('请求超时')
|
||||
throw e
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/** 触发 Verdaccio 代理拉取:走到该端点时 Verdaccio 会从 uplink 拉取并落盘缓存 */
|
||||
async function triggerVerdaccio(name, version) {
|
||||
const url = `${VERDACCIO_BASE}/${enc(name)}/-/${enc(fileOf(name, version))}`
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT)
|
||||
try {
|
||||
const r = await fetch(url, { signal: ctrl.signal, headers: { accept: 'application/octet-stream' } })
|
||||
if (!r.ok) throw new Error(`私服响应 ${r.status}(请确认 VERDACCIO_BASE_URL 配置正确)`)
|
||||
if (r.body) {
|
||||
// 丢弃响应体:Verdaccio 已把包写进存储,本次只需等待传输完成
|
||||
for await (const _ of r.body) { /* drain */ }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e && e.name === 'AbortError') throw new Error('拉取超时')
|
||||
throw e
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/** 本地是否已缓存该精确版本(versionsDetail 缺失时保守视为未缓存,交给 Verdaccio 层幂等判断) */
|
||||
function hasLocalVersion(pkg, version) {
|
||||
const vs = pkg && pkg.versionsDetail
|
||||
if (!Array.isArray(vs)) return false
|
||||
return vs.some(v => String(v.version) === version)
|
||||
}
|
||||
|
||||
async function handleOne(pkg) {
|
||||
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)' })
|
||||
return
|
||||
}
|
||||
if (!latest) {
|
||||
syncState.failed += 1
|
||||
syncState.failedList.push({ name: pkg.name, reason: '私服未提供 latest 版本' })
|
||||
return
|
||||
}
|
||||
if (hasLocalVersion(pkg, latest)) {
|
||||
syncState.skipped += 1 // 已是最新,不重复拉取
|
||||
return
|
||||
}
|
||||
await triggerVerdaccio(pkg.name, latest)
|
||||
syncState.success += 1
|
||||
} catch (e) {
|
||||
syncState.failed += 1
|
||||
syncState.failedList.push({ name: pkg.name, reason: (e && e.message) || '拉取失败' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动拉取任务(异步;running 时重复调用直接返回)
|
||||
* @param {{ addLog: Function, invalidateScan: Function }} services 依赖注入(避免与 server.js 循环引用)
|
||||
*/
|
||||
export async function runSyncLatest({ addLog, invalidateScan }) {
|
||||
if (syncState.running) return
|
||||
resetState()
|
||||
syncState.running = true
|
||||
console.log(`[sync-latest] 开始拉取存量包最新版本(经私服: ${VERDACCIO_BASE},上游按 Verdaccio uplinks 路由)`)
|
||||
try {
|
||||
// 存量包列表:优先直接扫描 storage(与面板列表同源,且不依赖接口层扫描缓存是否就绪)
|
||||
let pkgs = []
|
||||
try {
|
||||
pkgs = await scanStorageAsync(resolveStorageDir())
|
||||
} catch (e) {
|
||||
syncState.error = `扫描缓存目录失败:${e.message}`
|
||||
}
|
||||
// 跳过异常包(元数据读取失败无法判断版本),其余全部纳入(含忽略中的包,它们同样是存量缓存)
|
||||
pkgs = (pkgs || []).filter(p => p && p.name && p.status !== 'broken')
|
||||
syncState.total = pkgs.length
|
||||
if (syncState.total === 0) {
|
||||
if (!syncState.error) syncState.error = '缓存目录为空,无可拉取的包'
|
||||
} else {
|
||||
let next = 0
|
||||
const worker = async () => {
|
||||
while (next < pkgs.length) {
|
||||
const pkg = pkgs[next++]
|
||||
syncState.current = pkg.name
|
||||
await handleOne(pkg)
|
||||
syncState.processed += 1
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENT, pkgs.length) }, worker))
|
||||
}
|
||||
// 汇总写操作日志(失败包名列表存 deletedVersions,前端日志详情展示)
|
||||
addLog({
|
||||
action: 'sync',
|
||||
pkgName: null,
|
||||
deletedCount: syncState.success,
|
||||
deletedVersions: JSON.stringify({
|
||||
success: syncState.success,
|
||||
skipped: syncState.skipped,
|
||||
failed: syncState.failedList,
|
||||
}),
|
||||
})
|
||||
invalidateScan() // 有新版本入库 → 废弃扫描缓存,列表下次请求重扫
|
||||
console.log(`[sync-latest] 完成:成功 ${syncState.success} · 跳过 ${syncState.skipped} · 失败 ${syncState.failed}`)
|
||||
} catch (e) {
|
||||
syncState.error = e.message
|
||||
console.error('[sync-latest] 任务异常:', e.message)
|
||||
} finally {
|
||||
syncState.running = false
|
||||
syncState.done = true
|
||||
syncState.finishedAt = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ services:
|
||||
user: "0:0" # root:清理需写 verdaccio 卷;vcm-data 命名卷 root 亦可写
|
||||
environment:
|
||||
- VERDACCIO_STORAGE=/verdaccio/storage # 容器内路径,对应下方挂载的右侧
|
||||
- VERDACCIO_BASE_URL=${VERDACCIO_BASE_URL:-http://218.61.196.156:11691} # 私服地址:查询最新版与代拉均走它(容器内不能用 127.0.0.1;上游由 Verdaccio 自身 uplinks 路由)
|
||||
- PORT=3000
|
||||
- TZ=Asia/Shanghai # 每日 00:00 自动扫描按北京时间执行
|
||||
- VCM_USERNAME=${VCM_USERNAME:-admin} # 面板登录账号(可在宿主机 .env 覆盖)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -19,6 +19,11 @@ const state = reactive({
|
||||
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
|
||||
@@ -202,6 +268,7 @@ export function useStore() {
|
||||
refreshThresholds,
|
||||
refreshLogs,
|
||||
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 }}
|
||||
@@ -73,7 +83,7 @@ function actionMeta(action) {
|
||||
<div v-if="detail" class="detail-dialog">
|
||||
<div class="dd-head">
|
||||
<span class="dd-title">
|
||||
{{ detail.action === 'ignore' ? '💤 忽略' : detail.action === 'unignore' ? '↩️ 取消忽略' : '📋 清理详情' }}
|
||||
{{ 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>
|
||||
@@ -207,6 +255,12 @@ async function onCleanConfirmed(res) {
|
||||
<template v-if="todayFilter">(<span class="today-chip">仅今日更新 {{ todayStr }} <i @click="clearTodayFilter" title="清除筛选">✕</i></span>)</template>
|
||||
</div>
|
||||
<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