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
+2 -1
View File
@@ -4,7 +4,8 @@ frontend/node_modules/
frontend/dist/ frontend/dist/
backend/node_modules/ backend/node_modules/
.CodeCounter .CodeCounter
.dumate
.tmp
# 运行时数据(SQLite) # 运行时数据(SQLite)
backend/data/ backend/data/
manager-data/ manager-data/
+39
View File
@@ -27,6 +27,7 @@ import {
} from './scanner.js' } from './scanner.js'
import { cleanPackage, deleteSingleVersion } from './clean.js' import { cleanPackage, deleteSingleVersion } from './clean.js'
import semverCompare from './semver.js' import semverCompare from './semver.js'
import { syncState, runSyncLatest } from './sync-latest.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const app = express() 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:清理 —— // —— API:清理 ——
app.post('/api/clean', (req, res) => { app.post('/api/clean', (req, res) => {
const { pkgName, keep } = req.body || {} const { pkgName, keep } = req.body || {}
+176
View File
@@ -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()
}
}
+1
View File
@@ -9,6 +9,7 @@ services:
user: "0:0" # root:清理需写 verdaccio 卷;vcm-data 命名卷 root 亦可写 user: "0:0" # root:清理需写 verdaccio 卷;vcm-data 命名卷 root 亦可写
environment: environment:
- VERDACCIO_STORAGE=/verdaccio/storage # 容器内路径,对应下方挂载的右侧 - VERDACCIO_STORAGE=/verdaccio/storage # 容器内路径,对应下方挂载的右侧
- VERDACCIO_BASE_URL=${VERDACCIO_BASE_URL:-http://218.61.196.156:11691} # 私服地址:查询最新版与代拉均走它(容器内不能用 127.0.0.1;上游由 Verdaccio 自身 uplinks 路由)
- PORT=3000 - PORT=3000
- TZ=Asia/Shanghai # 每日 00:00 自动扫描按北京时间执行 - TZ=Asia/Shanghai # 每日 00:00 自动扫描按北京时间执行
- VCM_USERNAME=${VCM_USERNAME:-admin} # 面板登录账号(可在宿主机 .env 覆盖) - VCM_USERNAME=${VCM_USERNAME:-admin} # 面板登录账号(可在宿主机 .env 覆盖)
+28 -1
View File
@@ -178,7 +178,7 @@ async login(username, password, remember = true) {
return r.json() return r.json()
}, },
/** 查询扫描进度(包数 / 字节数 / 是否完成) */ /** 查询扫描进度(包数 / 字节数 / 是否完成) */
async getScanProgress() { async getScanProgress() {
if (USE_MOCK) { if (USE_MOCK) {
return { scanning: false, done: true, packages: 33, bytes: 0 } return { scanning: false, done: true, packages: 33, bytes: 0 }
@@ -187,6 +187,33 @@ async login(username, password, remember = true) {
return r.json() 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() { async getThresholds() {
if (USE_MOCK) { if (USE_MOCK) {
await delay(80) await delay(80)
+69 -2
View File
@@ -17,8 +17,13 @@ const state = reactive({
storageDir: storage.get('vcm-storage-dir') || '', storageDir: storage.get('vcm-storage-dir') || '',
// 后端目录扫描总字节(所有文件,与 du 同口径;null 表示未知/演示模式) // 后端目录扫描总字节(所有文件,与 du 同口径;null 表示未知/演示模式)
totalBytes: null, totalBytes: null,
// 扫描进度(异步扫描期间实时更新,用于遮罩展示) // 扫描进度(异步扫描期间实时更新,用于遮罩展示)
scanProgress: { packages: 0, bytes: 0 }, 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() { async function refreshThresholds() {
const t = await api.getThresholds() const t = await api.getThresholds()
state.threshold.versionThreshold = t.versionThreshold state.threshold.versionThreshold = t.versionThreshold
@@ -201,7 +267,8 @@ export function useStore() {
refreshPackages, refreshPackages,
refreshThresholds, refreshThresholds,
refreshLogs, refreshLogs,
scanNow, scanNow,
syncLatest,
saveThresholds, saveThresholds,
ignorePkg, ignorePkg,
unignorePkg, unignorePkg,
+26 -3
View File
@@ -15,8 +15,18 @@ function fmtTime(t) {
function actionMeta(action) { function actionMeta(action) {
if (action === 'ignore') return { emo: '💤', text: '忽略', cls: 'ignore' } if (action === 'ignore') return { emo: '💤', text: '忽略', cls: 'ignore' }
if (action === 'unignore') return { emo: '↩️', text: '取消忽略', cls: 'unignore' } if (action === 'unignore') return { emo: '↩️', text: '取消忽略', cls: 'unignore' }
if (action === 'sync') return { emo: '📥', text: '拉取最新版', cls: 'sync' }
return { emo: '🗑', text: '清理', cls: 'clean' } return { emo: '🗑', text: '清理', cls: 'clean' }
} }
// sync 日志的汇总明细(addLog 时存于 deletedVersions JSON 字段)
function syncMeta(log) {
try {
return JSON.parse(log.deletedVersions || '{}')
} catch {
return {}
}
}
</script> </script>
<template> <template>
@@ -42,7 +52,7 @@ function actionMeta(action) {
<tbody> <tbody>
<tr v-for="log in state.logs" :key="log.id" @click="detail = log"> <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="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> <td>
<span class="act-badge" :class="actionMeta(log.action).cls"> <span class="act-badge" :class="actionMeta(log.action).cls">
{{ actionMeta(log.action).emo }} {{ actionMeta(log.action).text }} {{ actionMeta(log.action).emo }} {{ actionMeta(log.action).text }}
@@ -72,8 +82,8 @@ function actionMeta(action) {
<Transition name="pop"> <Transition name="pop">
<div v-if="detail" class="detail-dialog"> <div v-if="detail" class="detail-dialog">
<div class="dd-head"> <div class="dd-head">
<span class="dd-title"> <span class="dd-title">
{{ detail.action === 'ignore' ? '💤 忽略' : detail.action === 'unignore' ? '↩️ 取消忽略' : '📋 清理详情' }} {{ detail.action === 'ignore' ? '💤 忽略' : detail.action === 'unignore' ? '↩️ 取消忽略' : detail.action === 'sync' ? '📥 拉取最新版' : '📋 清理详情' }}
</span> </span>
<div class="dd-close" @click="detail = null">✕</div> <div class="dd-close" @click="detail = null">✕</div>
</div> </div>
@@ -102,6 +112,18 @@ function actionMeta(action) {
</div> </div>
</div> </div>
</template> </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"> <div v-else class="dd-row">
<span class="k">说明</span> <span class="k">说明</span>
<span class="v">{{ detail.action === 'ignore' ? '已将包加入忽略列表,不再提示超阈值警告' : '已取消忽略,恢复超阈值提醒' }}</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.clean { color: var(--red); background: var(--red-soft); }
.act-badge.ignore { color: var(--ink-dim); background: var(--num-bg); } .act-badge.ignore { color: var(--ink-dim); background: var(--num-bg); }
.act-badge.unignore { color: var(--green-deep); background: var(--green-soft); } .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 { 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.n { color: var(--red); background: var(--red-soft); }
.chip.s { color: var(--green-deep); background: var(--green-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 { useStore, fmtSize } from '../store'
import { storage } from '../utils/storage' 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 route = useRoute()
const router = useRouter() const router = useRouter()
@@ -173,6 +173,54 @@ async function onCleanConfirmed(res) {
ElMessage.success(`已清理 ${res.deletedCount} 个版本,释放 ${res.freedSpace}`) ElMessage.success(`已清理 ${res.deletedCount} 个版本,释放 ${res.freedSpace}`)
drawerVisible.value = false 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> </script>
<template> <template>
@@ -206,7 +254,13 @@ async function onCleanConfirmed(res) {
<template v-if="keyword">(关键词「{{ keyword }}」)</template> <template v-if="keyword">(关键词「{{ keyword }}」)</template>
<template v-if="todayFilter">(<span class="today-chip">仅今日更新 {{ todayStr }} <i @click="clearTodayFilter" title="清除筛选">✕</i></span>)</template> <template v-if="todayFilter">(<span class="today-chip">仅今日更新 {{ todayStr }} <i @click="clearTodayFilter" title="清除筛选">✕</i></span>)</template>
</div> </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"> <div class="dd">
<button class="dd-btn" :class="{ on: filterOpen }" title="快捷筛选" @click.stop="toggleFilter">🎚️</button> <button class="dd-btn" :class="{ on: filterOpen }" title="快捷筛选" @click.stop="toggleFilter">🎚️</button>
<Transition name="dd"> <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: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); } .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 { .dd-menu {
position: absolute; position: absolute;
top: calc(100% + 8px); top: calc(100% + 8px);