- 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
200 lines
6.0 KiB
JavaScript
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,
|
|
}
|
|
}
|