Files
verdaccio-cache-manager/frontend/src/store/index.js
T
陈银军 8a54fb2d5c feat: 包信息展示与扫描数据修正
- scanner: 版本数只统计实际存在的 .tgz(修复 playwright-core 5113 版本虚高),解析 description/homepage/repository/author/license 元数据字段
- 前端: 新增 PkgLogo 首字母徽标组件,详情抽屉展示包 logo(主页 favicon 加载失败自动回退)、介绍与主页/仓库/许可胶囊,优化抽屉头部布局
- 全量 UI 改进: 毛玻璃扫描遮罩与进度、头像菜单重新扫描、主题切换、移动端 Tabbar/StatMini/Top30Panel 响应式、列表瘦身与详情按需加载
- compose: TZ=Asia/Shanghai + 命名卷 vcm-data 解决 SQLite 权限
- 更新 amd64 镜像 tar
2026-09-19 21:07:27 +08:00

200 lines
6.0 KiB
JavaScript

/**
* 全局数据状态(模块级 reactive 单例,多页面共享)
* - packages:包列表(已排序:预警置顶 → 正常 → 忽略)
* - thresholds:全局阈值 + 单包例外
* - logs:操作日志
*/
import { reactive, computed } from 'vue'
import api from '../api'
const state = reactive({
packages: [],
threshold: { versionThreshold: 15, sizeThreshold: 500, exceptions: [] },
logs: [],
loading: false,
scannedAt: null,
storageDir: localStorage.getItem('vcm-storage-dir') || '',
// 后端目录扫描总字节(所有文件,与 du 同口径;null 表示未知/演示模式)
totalBytes: null,
// 扫描进度(异步扫描期间实时更新,用于遮罩展示)
scanProgress: { packages: 0, bytes: 0 },
})
// —— 派生统计 ——
const stats = computed(() => {
const valid = state.packages.filter(p => p.status !== 'broken')
// 总占用统计全部物理缓存(含元数据异常的包,其大小按实际 .tgz 计算)
const totalSize = state.packages.reduce((s, p) => s + p.size, 0)
const warnCount = valid.filter(p => p.status === 'warn').length
const cleanable = valid.filter(p => p.status === 'warn').reduce((s, p) => s + p.size, 0)
const maxPkg = [...valid].sort((a, b) => b.size - a.size)[0] || null
const oldestPkg = [...valid].sort((a, b) => (a.lastUpdated || '').localeCompare(b.lastUpdated || ''))[0] || null
// 今日更新包数:最近更新时间等于今天的包数量
const d = new Date()
const todayStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const todayUpdated = valid.filter(p => (p.lastUpdated || '') === todayStr).length
// 真实环境无访问统计(visits 全 0)时,Top20 降级为「缓存占用 Top 20」
const hasVisits = valid.some(p => (p.visits || 0) > 0)
const top30 = [...valid]
.sort((a, b) => (hasVisits ? (b.visits || 0) - (a.visits || 0) : b.size - a.size))
.slice(0, 20)
const maxVisit = Math.max(...top30.map(p => p.visits || 0), 1)
const maxSize = Math.max(...top30.map(p => p.size), 1)
return {
pkgCount: valid.length,
totalVersions: state.packages.reduce((s, p) => s + (p.versions || 0), 0),
totalSize,
// 总占用优先用后端「目录所有文件字节和」(与 du 一致),否则回退到包大小之和
totalSizeText: state.totalBytes != null ? fmtBytes(state.totalBytes) : fmtSize(totalSize),
warnCount,
cleanable,
cleanableText: fmtSize(cleanable),
todayUpdated,
maxPkg,
oldestPkg,
top30: top30.map(p => ({
...p,
heat: hasVisits
? Math.round(((p.visits || 0) / maxVisit) * 100)
: Math.round((p.size / maxSize) * 100),
})),
top30By: hasVisits ? 'visits' : 'size',
}
})
function fmtSize(mb) {
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
return `${mb.toFixed(1)} MB`
}
/** 字节 → 可读文本 */
function fmtBytes(b) {
const mb = b / 1024 / 1024
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
return `${mb.toFixed(1)} MB`
}
const fmtSizeFn = fmtSize
async function refreshPackages(keyword = '') {
const { packages, scannedAt, scanning, scanPackages, scanBytes, totalBytes } = await api.getPackages(keyword)
state.packages = packages || []
if (scannedAt) state.scannedAt = scannedAt
if (scanPackages !== undefined) state.scanProgress.packages = scanPackages
if (scanBytes !== undefined) state.scanProgress.bytes = scanBytes
if (totalBytes !== undefined && totalBytes !== null) state.totalBytes = totalBytes
return { scanning: !!scanning }
}
/** 轮询后端扫描进度,直到扫描完成 */
async function pollScanProgress() {
for (;;) {
await new Promise(r => setTimeout(r, 800))
let p
try {
p = await api.getScanProgress()
} catch {
break // 接口异常时退出,避免遮罩永久拦截点击
}
state.scanProgress.packages = p.packages || 0
state.scanProgress.bytes = p.bytes || 0
if (!p.scanning) break
}
}
/** 刷新数据:若后端仍在扫描则先轮询进度,扫完再拉取真实数据 */
async function doRefresh() {
const first = await refreshPackages()
if (first.scanning) {
await pollScanProgress()
await refreshPackages() // 扫描完成后重新拉取完整数据
}
await Promise.all([refreshThresholds(), refreshLogs()])
}
/** 手动重新扫描缓存目录(异步,遮罩实时显示进度) */
async function scanNow() {
state.loading = true
try {
const res = await api.scanNow()
if (!res.ok) return res
await doRefresh()
return res
} finally {
state.loading = false
}
}
async function refreshThresholds() {
const t = await api.getThresholds()
state.threshold.versionThreshold = t.versionThreshold
state.threshold.sizeThreshold = t.sizeThreshold
state.threshold.exceptions = t.exceptions || []
}
async function refreshLogs() {
state.logs = await api.getLogs()
}
async function refreshAll() {
state.loading = true
try {
await doRefresh()
} finally {
state.loading = false
}
}
async function saveThresholds(payload) {
const ok = await api.saveThresholds(payload)
if (ok) {
await refreshThresholds()
await refreshPackages()
}
return ok
}
async function ignorePkg(name) {
await api.ignore(name)
await refreshPackages()
}
async function unignorePkg(name) {
await api.unignore(name)
await refreshPackages()
}
async function cleanPkg(name, keep) {
const res = await api.clean(name, keep)
if (res.ok) {
await Promise.all([refreshPackages(), refreshLogs()])
}
return res
}
async function deleteVersion(name, version) {
const res = await api.deleteVersion(name, version)
if (res.ok) {
await Promise.all([refreshPackages(), refreshLogs()])
}
return res
}
export function useStore() {
return {
state,
stats,
fmtSize: fmtSizeFn,
refreshAll,
refreshPackages,
refreshThresholds,
refreshLogs,
scanNow,
saveThresholds,
ignorePkg,
unignorePkg,
cleanPkg,
deleteVersion,
}
}