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
This commit is contained in:
+178
-24
@@ -95,31 +95,20 @@ export function scanStorage(storageDir) {
|
|||||||
const packages = []
|
const packages = []
|
||||||
if (!isValidStorage(storageDir)) return packages
|
if (!isValidStorage(storageDir)) return packages
|
||||||
|
|
||||||
const pkgFiles = listFiles(storageDir).filter(f => f.endsWith('package.json'))
|
const allFiles = listFiles(storageDir)
|
||||||
|
// 包目录 = 含 package.json 的目录 ∪ 含 .tgz 的目录(避免只缓存了 tgz、缺元数据的目录被漏算)
|
||||||
|
const pkgDirs = new Set()
|
||||||
|
for (const f of allFiles) {
|
||||||
|
if (f.endsWith('package.json') || f.endsWith('.tgz')) pkgDirs.add(path.dirname(f))
|
||||||
|
}
|
||||||
const storageRoot = path.resolve(storageDir)
|
const storageRoot = path.resolve(storageDir)
|
||||||
|
|
||||||
for (const pkgFile of pkgFiles) {
|
for (const pkgDir of pkgDirs) {
|
||||||
const pkgDir = path.dirname(pkgFile)
|
const pkgFile = path.join(pkgDir, 'package.json')
|
||||||
const meta = readJson(pkgFile)
|
|
||||||
if (!meta) {
|
|
||||||
// 元数据损坏:标记异常包,不影响其他包
|
|
||||||
packages.push({
|
|
||||||
name: pkgNameFromDir(pkgDir, storageRoot),
|
|
||||||
status: 'broken',
|
|
||||||
versions: 0,
|
|
||||||
size: 0,
|
|
||||||
lastUpdated: null,
|
|
||||||
versionsDetail: [],
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const versionsObj = meta.versions || {}
|
|
||||||
const timeObj = meta.time || {}
|
|
||||||
const distTags = meta['dist-tags'] || {}
|
|
||||||
|
|
||||||
// 物理 .tgz 文件:先列目录建立文件名 → 大小索引
|
// 物理 .tgz 文件:先列目录建立文件名 → 大小索引
|
||||||
// (Verdaccio 各版本命名不同:{version}.tgz 或 {pkgname}-{version}.tgz,不猜、直接扫)
|
// (Verdaccio 各版本命名不同:{version}.tgz 或 {pkgname}-{version}.tgz,不猜、直接扫)
|
||||||
|
// 先统计再读元数据:即使元数据损坏也能算出真实占用
|
||||||
const tgzSizes = new Map()
|
const tgzSizes = new Map()
|
||||||
let tgzTotal = 0
|
let tgzTotal = 0
|
||||||
try {
|
try {
|
||||||
@@ -131,8 +120,36 @@ export function scanStorage(storageDir) {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
const meta = readJson(pkgFile)
|
||||||
|
if (!meta) {
|
||||||
|
// 元数据缺失/损坏:标记异常包(真实占用按物理 .tgz 计算),不影响其他包
|
||||||
|
packages.push({
|
||||||
|
name: pkgNameFromDir(pkgDir, storageRoot),
|
||||||
|
status: 'broken',
|
||||||
|
versions: 0,
|
||||||
|
size: fmtMb(tgzTotal),
|
||||||
|
lastUpdated: null,
|
||||||
|
versionsDetail: [],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionsObj = meta.versions || {}
|
||||||
|
const timeObj = meta.time || {}
|
||||||
|
const distTags = meta['dist-tags'] || {}
|
||||||
|
// 包信息(来自本地元数据,离线可用):介绍/主页/仓库/作者/许可
|
||||||
|
const description = typeof meta.description === 'string' ? meta.description : null
|
||||||
|
const homepage = typeof meta.homepage === 'string' && meta.homepage ? meta.homepage : null
|
||||||
|
const repository = typeof meta.repository === 'string'
|
||||||
|
? meta.repository
|
||||||
|
: (meta.repository && typeof meta.repository.url === 'string' ? meta.repository.url : null)
|
||||||
|
const author = typeof meta.author === 'string'
|
||||||
|
? meta.author
|
||||||
|
: (meta.author && typeof meta.author.name === 'string' ? meta.author.name : null)
|
||||||
|
const license = typeof meta.license === 'string' ? meta.license : null
|
||||||
|
|
||||||
const versionsDetail = []
|
const versionsDetail = []
|
||||||
// 总大小以实际 .tgz 文件为准;unpackedSize 是解压后大小,会数倍虚增,仅作无物理文件时的兜底
|
// 总大小以实际 .tgz 文件为准
|
||||||
const totalBytes = tgzTotal
|
const totalBytes = tgzTotal
|
||||||
for (const [v, info] of Object.entries(versionsObj)) {
|
for (const [v, info] of Object.entries(versionsObj)) {
|
||||||
let size = tgzSizes.get(v)
|
let size = tgzSizes.get(v)
|
||||||
@@ -145,9 +162,8 @@ export function scanStorage(storageDir) {
|
|||||||
if (typeof hit === 'number') size = hit
|
if (typeof hit === 'number') size = hit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (typeof size !== 'number') {
|
// 无物理 .tgz 文件的版本是元数据残留(实体已被清理),不计数,避免版本数虚高
|
||||||
size = (info && info.dist && typeof info.dist.unpackedSize === 'number') ? info.dist.unpackedSize : 0
|
if (typeof size !== 'number') continue
|
||||||
}
|
|
||||||
versionsDetail.push({
|
versionsDetail.push({
|
||||||
version: parseVersion(v),
|
version: parseVersion(v),
|
||||||
size: fmtMb(size),
|
size: fmtMb(size),
|
||||||
@@ -169,6 +185,11 @@ export function scanStorage(storageDir) {
|
|||||||
lastUpdated,
|
lastUpdated,
|
||||||
visits: 0, // 真实环境无访问统计,界面不依赖真实值
|
visits: 0, // 真实环境无访问统计,界面不依赖真实值
|
||||||
distTags,
|
distTags,
|
||||||
|
description,
|
||||||
|
homepage,
|
||||||
|
repository,
|
||||||
|
author,
|
||||||
|
license,
|
||||||
versionsDetail,
|
versionsDetail,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -176,6 +197,115 @@ export function scanStorage(storageDir) {
|
|||||||
return packages
|
return packages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步扫描(分片让出事件循环 + 进度回调)
|
||||||
|
* 用于后台重扫:扫描期间仍能响应 /api/scan/progress 等请求,前端可实时展示进度
|
||||||
|
*/
|
||||||
|
export async function scanStorageAsync(storageDir, onProgress) {
|
||||||
|
const packages = []
|
||||||
|
if (!isValidStorage(storageDir)) return packages
|
||||||
|
|
||||||
|
const allFiles = listFiles(storageDir)
|
||||||
|
// 包目录 = 含 package.json 的目录 ∪ 含 .tgz 的目录(避免只缓存了 tgz、缺元数据的目录被漏算)
|
||||||
|
const pkgDirs = new Set()
|
||||||
|
for (const f of allFiles) {
|
||||||
|
if (f.endsWith('package.json') || f.endsWith('.tgz')) pkgDirs.add(path.dirname(f))
|
||||||
|
}
|
||||||
|
const storageRoot = path.resolve(storageDir)
|
||||||
|
let scanned = 0
|
||||||
|
let scannedBytes = 0 // 所有文件字节和(总占用,与 du 同口径)
|
||||||
|
let tgzBytes = 0 // 仅 .tgz 字节和(包粒度用)
|
||||||
|
|
||||||
|
for (const pkgDir of pkgDirs) {
|
||||||
|
// 每处理完一个包目录就让出事件循环,保证进度接口可实时响应
|
||||||
|
await new Promise(r => setImmediate(r))
|
||||||
|
const pkgFile = path.join(pkgDir, 'package.json')
|
||||||
|
|
||||||
|
const tgzSizes = new Map()
|
||||||
|
let tgzTotal = 0
|
||||||
|
try {
|
||||||
|
for (const f of fs.readdirSync(pkgDir)) {
|
||||||
|
const size = fs.statSync(path.join(pkgDir, f)).size
|
||||||
|
scannedBytes += size
|
||||||
|
if (f.endsWith('.tgz')) {
|
||||||
|
tgzTotal += size
|
||||||
|
tgzSizes.set(f.slice(0, -4), size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
tgzBytes += tgzTotal
|
||||||
|
|
||||||
|
const meta = readJson(pkgFile)
|
||||||
|
if (!meta) {
|
||||||
|
packages.push({
|
||||||
|
name: pkgNameFromDir(pkgDir, storageRoot),
|
||||||
|
status: 'broken',
|
||||||
|
versions: 0,
|
||||||
|
size: fmtMb(tgzTotal),
|
||||||
|
lastUpdated: null,
|
||||||
|
versionsDetail: [],
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const versionsObj = meta.versions || {}
|
||||||
|
const timeObj = meta.time || {}
|
||||||
|
const distTags = meta['dist-tags'] || {}
|
||||||
|
// 包信息(来自本地元数据,离线可用):介绍/主页/仓库/作者/许可
|
||||||
|
const description = typeof meta.description === 'string' ? meta.description : null
|
||||||
|
const homepage = typeof meta.homepage === 'string' && meta.homepage ? meta.homepage : null
|
||||||
|
const repository = typeof meta.repository === 'string'
|
||||||
|
? meta.repository
|
||||||
|
: (meta.repository && typeof meta.repository.url === 'string' ? meta.repository.url : null)
|
||||||
|
const author = typeof meta.author === 'string'
|
||||||
|
? meta.author
|
||||||
|
: (meta.author && typeof meta.author.name === 'string' ? meta.author.name : null)
|
||||||
|
const license = typeof meta.license === 'string' ? meta.license : null
|
||||||
|
const versionsDetail = []
|
||||||
|
const totalBytes = tgzTotal
|
||||||
|
for (const [v, info] of Object.entries(versionsObj)) {
|
||||||
|
let size = tgzSizes.get(v)
|
||||||
|
if (typeof size !== 'number') {
|
||||||
|
const tb = info && info.dist && info.dist.tarball
|
||||||
|
const bn = tb ? String(tb).split('/').pop() : ''
|
||||||
|
if (bn.endsWith('.tgz')) {
|
||||||
|
const hit = tgzSizes.get(bn.slice(0, -4))
|
||||||
|
if (typeof hit === 'number') size = hit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 无物理 .tgz 文件的版本是元数据残留(实体已被清理),不计数,避免版本数虚高
|
||||||
|
if (typeof size !== 'number') continue
|
||||||
|
versionsDetail.push({
|
||||||
|
version: parseVersion(v),
|
||||||
|
size: fmtMb(size),
|
||||||
|
date: timeObj[v] ? timeObj[v].slice(0, 10) : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
versionsDetail.sort((a, b) => a.version.localeCompare(b.version, undefined, { numeric: true }))
|
||||||
|
const lastUpdated = Object.keys(timeObj).length
|
||||||
|
? Object.entries(timeObj).sort((a, b) => b[1].localeCompare(a[1]))[0][1].slice(0, 10)
|
||||||
|
: null
|
||||||
|
packages.push({
|
||||||
|
name: pkgNameFromDir(pkgDir, storageRoot),
|
||||||
|
versions: versionsDetail.length,
|
||||||
|
size: fmtMb(totalBytes),
|
||||||
|
lastUpdated,
|
||||||
|
visits: 0,
|
||||||
|
distTags,
|
||||||
|
description,
|
||||||
|
homepage,
|
||||||
|
repository,
|
||||||
|
author,
|
||||||
|
license,
|
||||||
|
versionsDetail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
scanned += 1
|
||||||
|
if (onProgress) onProgress({ packages: scanned, bytes: scannedBytes, tgzBytes })
|
||||||
|
}
|
||||||
|
|
||||||
|
return packages
|
||||||
|
}
|
||||||
|
|
||||||
// —— demo 模式数据(无真实目录时的演示) ——
|
// —— demo 模式数据(无真实目录时的演示) ——
|
||||||
const DEMO_SEED = [
|
const DEMO_SEED = [
|
||||||
['lodash', 25, 4, 12456], ['@antv/g2', 32, 12, 7654], ['axios', 18, 1.2, 9832],
|
['lodash', 25, 4, 12456], ['@antv/g2', 32, 12, 7654], ['axios', 18, 1.2, 9832],
|
||||||
@@ -212,10 +342,29 @@ function genVersions(count) {
|
|||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// demo 包信息(演示模式展示用,真实环境来自缓存元数据)
|
||||||
|
const DEMO_META = {
|
||||||
|
lodash: { description: 'A modern JavaScript utility library delivering modularity, performance & extras.', homepage: 'https://lodash.com', repository: 'git+https://github.com/lodash/lodash.git', license: 'MIT' },
|
||||||
|
react: { description: 'React is a JavaScript library for building user interfaces.', homepage: 'https://react.dev', repository: 'git+https://github.com/facebook/react.git', license: 'MIT' },
|
||||||
|
vue: { description: 'The progressive JavaScript framework for building modern web UI.', homepage: 'https://vuejs.org', repository: 'git+https://github.com/vuejs/core.git', license: 'MIT' },
|
||||||
|
axios: { description: 'Promise based HTTP client for the browser and node.js.', homepage: 'https://axios-http.com', repository: 'git+https://github.com/axios/axios.git', license: 'MIT' },
|
||||||
|
typescript: { description: 'TypeScript is a language for application-scale JavaScript.', homepage: 'https://www.typescriptlang.org', repository: 'git+https://github.com/microsoft/TypeScript.git', license: 'Apache-2.0' },
|
||||||
|
express: { description: 'Fast, unopinionated, minimalist web framework for node.', homepage: 'https://expressjs.com', repository: 'git+https://github.com/expressjs/express.git', license: 'MIT' },
|
||||||
|
vite: { description: 'Native-ESM powered web dev build tool.', homepage: 'https://vitejs.dev', repository: 'git+https://github.com/vitejs/vite.git', license: 'MIT' },
|
||||||
|
webpack: { description: 'Packs CommonJs/AMD modules for the browser.', homepage: 'https://webpack.js.org', repository: 'git+https://github.com/webpack/webpack.git', license: 'MIT' },
|
||||||
|
echarts: { description: 'Apache ECharts is a powerful, interactive charting and visualization library for browser.', homepage: 'https://echarts.apache.org', repository: 'git+https://github.com/apache/echarts.git', license: 'Apache-2.0' },
|
||||||
|
dayjs: { description: 'Day.js is a minimalist JavaScript library that parses, validates, manipulates, and displays dates and times for modern browsers.', homepage: 'https://day.js.org', repository: 'git+https://github.com/iamkun/dayjs.git', license: 'MIT' },
|
||||||
|
prisma: { description: 'Prisma is a next-generation ORM that helps you build and query databases.', homepage: 'https://www.prisma.io', repository: 'git+https://github.com/prisma/prisma.git', license: 'Apache-2.0' },
|
||||||
|
three: { description: 'JavaScript 3D library.', homepage: 'https://threejs.org', repository: 'git+https://github.com/mrdoob/three.js.git', license: 'MIT' },
|
||||||
|
uuid: { description: 'RFC4122 (v1, v4, and v5) UUIDs.', homepage: 'https://github.com/uuidjs/uuid', repository: 'git+https://github.com/uuidjs/uuid.git', license: 'MIT' },
|
||||||
|
zod: { description: 'TypeScript-first schema validation with static type inference.', homepage: 'https://zod.dev', repository: 'git+https://github.com/colinhacks/zod.git', license: 'MIT' },
|
||||||
|
}
|
||||||
|
|
||||||
export function demoPackages() {
|
export function demoPackages() {
|
||||||
const seen = new Set()
|
const seen = new Set()
|
||||||
return DEMO_SEED.filter(([n]) => !seen.has(n) && seen.add(n)).map(([name, verCount, sizeBase, visits]) => {
|
return DEMO_SEED.filter(([n]) => !seen.has(n) && seen.add(n)).map(([name, verCount, sizeBase, visits]) => {
|
||||||
const detail = genVersions(verCount)
|
const detail = genVersions(verCount)
|
||||||
|
const meta = DEMO_META[name] || {}
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
versions: detail.length,
|
versions: detail.length,
|
||||||
@@ -223,6 +372,11 @@ export function demoPackages() {
|
|||||||
lastUpdated: detail.length ? detail[detail.length - 1].date : null,
|
lastUpdated: detail.length ? detail[detail.length - 1].date : null,
|
||||||
visits,
|
visits,
|
||||||
distTags: { latest: detail.length ? detail[detail.length - 1].version : '1.0.0' },
|
distTags: { latest: detail.length ? detail[detail.length - 1].version : '1.0.0' },
|
||||||
|
description: meta.description || null,
|
||||||
|
homepage: meta.homepage || null,
|
||||||
|
repository: meta.repository || null,
|
||||||
|
author: meta.author || null,
|
||||||
|
license: meta.license || null,
|
||||||
versionsDetail: detail,
|
versionsDetail: detail,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+142
-15
@@ -10,13 +10,14 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
|
import { execSync } from 'node:child_process'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import {
|
import {
|
||||||
getSetting, setSetting, getIgnored, getIgnoredRows, addIgnored, removeIgnored,
|
getSetting, setSetting, getIgnored, getIgnoredRows, addIgnored, removeIgnored,
|
||||||
addLog, getLogs,
|
addLog, getLogs,
|
||||||
} from './db.js'
|
} from './db.js'
|
||||||
import {
|
import {
|
||||||
resolveStorageDir, isValidStorage, validateStorage, scanStorage,
|
resolveStorageDir, isValidStorage, validateStorage, scanStorage, scanStorageAsync,
|
||||||
demoPackages, demoSummary,
|
demoPackages, demoSummary,
|
||||||
} from './scanner.js'
|
} from './scanner.js'
|
||||||
import { cleanPackage, deleteSingleVersion } from './clean.js'
|
import { cleanPackage, deleteSingleVersion } from './clean.js'
|
||||||
@@ -41,14 +42,79 @@ function isDemoMode() {
|
|||||||
// —— 扫描结果缓存:真实目录全量扫描 IO 重,缓存避免每次请求/刷新页面重扫 ——
|
// —— 扫描结果缓存:真实目录全量扫描 IO 重,缓存避免每次请求/刷新页面重扫 ——
|
||||||
let rawCache = null
|
let rawCache = null
|
||||||
let rawCacheAt = null
|
let rawCacheAt = null
|
||||||
|
|
||||||
|
// —— 异步扫描状态(后台重扫时供前端轮询进度) ——
|
||||||
|
const scanState = {
|
||||||
|
scanning: false, done: false, packages: 0, bytes: 0, tgzBytes: 0, diskBytes: null,
|
||||||
|
startedAt: null, finishedAt: null, error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 磁盘占用(与 du -sh 同口径,block 计数)——大量小文件时远大于字节和 */
|
||||||
|
function diskBytesOf(dir) {
|
||||||
|
try {
|
||||||
|
const out = execSync(`du -sk ${JSON.stringify(dir)}`, {
|
||||||
|
timeout: 120000, stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
}).toString()
|
||||||
|
const kb = parseInt(out.trim().split(/\s/)[0], 10)
|
||||||
|
return Number.isFinite(kb) ? kb * 1024 : null
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runScanAsync() {
|
||||||
|
scanState.scanning = true
|
||||||
|
scanState.done = false
|
||||||
|
scanState.error = null
|
||||||
|
scanState.packages = 0
|
||||||
|
scanState.bytes = 0
|
||||||
|
scanState.tgzBytes = 0
|
||||||
|
scanState.diskBytes = null
|
||||||
|
scanState.startedAt = new Date().toISOString()
|
||||||
|
const storage = currentStorage()
|
||||||
|
console.log(`[scan] 开始扫描存储目录: ${storage}`)
|
||||||
|
try {
|
||||||
|
// 分片异步扫描:期间 /api/scan/progress 等请求仍可被响应
|
||||||
|
rawCache = await scanStorageAsync(storage, (p) => {
|
||||||
|
scanState.packages = p.packages
|
||||||
|
scanState.bytes = p.bytes // 目录内所有文件字节和
|
||||||
|
scanState.tgzBytes = p.tgzBytes || 0 // 仅 .tgz
|
||||||
|
})
|
||||||
|
rawCacheAt = new Date().toISOString()
|
||||||
|
scanState.done = true
|
||||||
|
scanState.finishedAt = rawCacheAt
|
||||||
|
scanState.diskBytes = diskBytesOf(storage) // 磁盘占用(与用户 du -sh 一致)
|
||||||
|
console.log('[scan] 完成 ' + JSON.stringify({
|
||||||
|
storage,
|
||||||
|
packages: rawCache.length,
|
||||||
|
tgzBytes: scanState.tgzBytes,
|
||||||
|
bytes: scanState.bytes,
|
||||||
|
diskBytes: scanState.diskBytes,
|
||||||
|
}))
|
||||||
|
} catch (e) {
|
||||||
|
scanState.error = e.message
|
||||||
|
console.error('[scan] 扫描失败:', e.message)
|
||||||
|
} finally {
|
||||||
|
scanState.scanning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保扫描已启动(缓存缺失时懒触发,不阻塞当前请求)
|
||||||
|
function ensureScan() {
|
||||||
|
if (isDemoMode()) return
|
||||||
|
if (rawCache || scanState.scanning) return
|
||||||
|
runScanAsync()
|
||||||
|
}
|
||||||
|
|
||||||
function getRawPackages() {
|
function getRawPackages() {
|
||||||
if (isDemoMode()) return demoPackages()
|
if (isDemoMode()) return demoPackages()
|
||||||
if (!rawCache) {
|
if (!rawCache) {
|
||||||
rawCache = scanStorage(currentStorage())
|
// 懒加载:后台启动扫描,本次请求返回空列表,由前端轮询进度后刷新
|
||||||
rawCacheAt = new Date().toISOString()
|
ensureScan()
|
||||||
}
|
}
|
||||||
return rawCache
|
return rawCache
|
||||||
}
|
}
|
||||||
|
|
||||||
function invalidateScan() {
|
function invalidateScan() {
|
||||||
rawCache = null
|
rawCache = null
|
||||||
rawCacheAt = null
|
rawCacheAt = null
|
||||||
@@ -60,7 +126,7 @@ function buildPackages(keyword = '') {
|
|||||||
const ignoredRows = getIgnoredRows()
|
const ignoredRows = getIgnoredRows()
|
||||||
const ignoredSet = new Set(ignoredRows.map(r => r.pkg_name))
|
const ignoredSet = new Set(ignoredRows.map(r => r.pkg_name))
|
||||||
const ignoredAtMap = Object.fromEntries(ignoredRows.map(r => [r.pkg_name, (r.ignored_at || '').slice(0, 10)]))
|
const ignoredAtMap = Object.fromEntries(ignoredRows.map(r => [r.pkg_name, (r.ignored_at || '').slice(0, 10)]))
|
||||||
const raw = getRawPackages()
|
const raw = getRawPackages() || []
|
||||||
|
|
||||||
let list = raw.map(p => {
|
let list = raw.map(p => {
|
||||||
let status
|
let status
|
||||||
@@ -76,18 +142,14 @@ function buildPackages(keyword = '') {
|
|||||||
distTags: p.distTags || {},
|
distTags: p.distTags || {},
|
||||||
status,
|
status,
|
||||||
ignoredAt: ignoredAtMap[p.name] || null,
|
ignoredAt: ignoredAtMap[p.name] || null,
|
||||||
versionsDetail: p.versionsDetail || [],
|
// 列表不携带 versionsDetail(包多时响应巨大拖慢加载),详情走 GET /api/packages/:name
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 检索:不区分大小写子串,匹配包名 / 版本号 / 文件大小文本
|
// 检索:不区分大小写子串,匹配包名(版本号匹配由前端本地完成,避免列表携带明细)
|
||||||
const kw = keyword.trim().toLowerCase()
|
const kw = keyword.trim().toLowerCase()
|
||||||
if (kw) {
|
if (kw) {
|
||||||
list = list.filter(p =>
|
list = list.filter(p => p.name.toLowerCase().includes(kw) || String(p.size).includes(kw))
|
||||||
p.name.toLowerCase().includes(kw) ||
|
|
||||||
(p.versionsDetail || []).some(v => v.version.toLowerCase().includes(kw)) ||
|
|
||||||
String(p.size).includes(kw),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 排序:预警置顶 → 正常 → 忽略 → 异常;同状态按版本数降序
|
// 排序:预警置顶 → 正常 → 忽略 → 异常;同状态按版本数降序
|
||||||
@@ -164,18 +226,64 @@ app.get('/api/packages', (req, res) => {
|
|||||||
storageMessage: validation.message,
|
storageMessage: validation.message,
|
||||||
threshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)),
|
threshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)),
|
||||||
scannedAt: demo ? new Date().toISOString() : (rawCacheAt || null),
|
scannedAt: demo ? new Date().toISOString() : (rawCacheAt || null),
|
||||||
|
scanning: scanState.scanning,
|
||||||
|
scanPackages: scanState.packages,
|
||||||
|
scanBytes: scanState.bytes,
|
||||||
|
totalBytes: demo ? null : (scanState.diskBytes ?? scanState.bytes), // 磁盘占用(与 du -sh 同口径)
|
||||||
packages,
|
packages,
|
||||||
...(demo ? demoSummary() : {}),
|
...(demo ? demoSummary() : {}),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// —— API:手动重新扫描缓存目录 ——
|
// —— API:单包详情(含版本明细,抽屉打开时按需加载)
|
||||||
|
// 只读现有扫描缓存,绝不触发同步扫描(缓存被失效时直接 404,
|
||||||
|
// 前端用列表数据兜底 + 提示,避免打开抽屉卡几十秒) ——
|
||||||
|
app.get('/api/packages/:pkgName', (req, res) => {
|
||||||
|
const name = decodeURIComponent(req.params.pkgName)
|
||||||
|
const raw = isDemoMode() ? demoPackages() : (rawCache || null)
|
||||||
|
const p = raw ? raw.find(x => x.name === name) : null
|
||||||
|
if (!p) return res.status(404).json({ ok: false, message: '包不存在或正在扫描中' })
|
||||||
|
const threshold = Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD))
|
||||||
|
const ignoredSet = new Set(getIgnoredRows().map(r => r.pkg_name))
|
||||||
|
let status
|
||||||
|
if (p.status === 'broken') status = 'broken'
|
||||||
|
else if (ignoredSet.has(p.name)) status = 'ignored'
|
||||||
|
else status = p.versions > threshold ? 'warn' : 'ok'
|
||||||
|
res.json({ ok: true, pkg: { ...p, status } })
|
||||||
|
})
|
||||||
|
|
||||||
|
// —— API:手动重新扫描缓存目录(异步,扫描期间前端轮询进度) ——
|
||||||
app.post('/api/scan', (req, res) => {
|
app.post('/api/scan', (req, res) => {
|
||||||
invalidateScan()
|
invalidateScan()
|
||||||
const demo = isDemoMode()
|
const demo = isDemoMode()
|
||||||
const scannedAt = new Date().toISOString()
|
if (demo) {
|
||||||
if (!demo) getRawPackages() // 同步重扫并填充缓存
|
const scannedAt = new Date().toISOString()
|
||||||
res.json({ ok: true, demo, scannedAt })
|
return res.json({ ok: true, demo, scanning: false, scannedAt })
|
||||||
|
}
|
||||||
|
if (!scanState.scanning) runScanAsync() // 后台启动,不阻塞响应
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
demo: false,
|
||||||
|
scanning: scanState.scanning,
|
||||||
|
startedAt: scanState.startedAt,
|
||||||
|
packages: scanState.packages,
|
||||||
|
bytes: scanState.bytes,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// —— API:扫描进度(重扫/首扫期间由前端轮询) ——
|
||||||
|
app.get('/api/scan/progress', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
scanning: scanState.scanning,
|
||||||
|
done: scanState.done,
|
||||||
|
packages: scanState.packages,
|
||||||
|
bytes: scanState.bytes,
|
||||||
|
tgzBytes: scanState.tgzBytes,
|
||||||
|
diskBytes: scanState.diskBytes,
|
||||||
|
error: scanState.error,
|
||||||
|
startedAt: scanState.startedAt,
|
||||||
|
finishedAt: scanState.finishedAt,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// —— API:清理 ——
|
// —— API:清理 ——
|
||||||
@@ -260,8 +368,27 @@ app.listen(PORT, () => {
|
|||||||
console.log(`[verdaccio-manager] listening on :${PORT} (mode=${mode})`)
|
console.log(`[verdaccio-manager] listening on :${PORT} (mode=${mode})`)
|
||||||
if (mode === 'storage') console.log(`[verdaccio-manager] storage: ${storage}`)
|
if (mode === 'storage') console.log(`[verdaccio-manager] storage: ${storage}`)
|
||||||
else console.log('[verdaccio-manager] 未配置 VERDACCIO_STORAGE,运行于演示模式(设置 VCM_DEMO=0 + 真实目录可关闭)')
|
else console.log('[verdaccio-manager] 未配置 VERDACCIO_STORAGE,运行于演示模式(设置 VCM_DEMO=0 + 真实目录可关闭)')
|
||||||
|
scheduleDailyScan()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// —— 每天 00:00(服务器本地时区)自动重扫缓存,逻辑同 POST /api/scan ——
|
||||||
|
function scheduleDailyScan() {
|
||||||
|
const now = new Date()
|
||||||
|
const next = new Date(now)
|
||||||
|
next.setHours(24, 0, 0, 0) // 下个本地零点
|
||||||
|
const wait = next.getTime() - now.getTime()
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
invalidateScan()
|
||||||
|
ensureScan() // 后台异步重扫,不阻塞事件循环
|
||||||
|
console.log(`[auto-scan] ${new Date().toISOString()} 每日自动扫描已启动`)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[auto-scan] 每日自动扫描失败:', e.message)
|
||||||
|
}
|
||||||
|
scheduleDailyScan() // 安排次日零点
|
||||||
|
}, wait)
|
||||||
|
}
|
||||||
|
|
||||||
function clampInt(v, min, max) {
|
function clampInt(v, min, max) {
|
||||||
const n = Number(v)
|
const n = Number(v)
|
||||||
if (Number.isNaN(n)) return min
|
if (Number.isNaN(n)) return min
|
||||||
|
|||||||
+2
-2
@@ -138,7 +138,7 @@ CREATE TABLE logs (id INTEGER PRIMARY KEY AUTOINCREMENT, pkg_name TEXT,
|
|||||||
|
|
||||||
| # | 页面/视图 | 关键内容 | 路由建议 |
|
| # | 页面/视图 | 关键内容 | 路由建议 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 1 | 缓存概览 | 4 核心统计卡 + 5 辅助指标、预警横幅卡、Top 30 访问排行、包卡片列表 | `/` |
|
| 1 | 缓存概览 | 4 核心统计卡 + 5 辅助指标、预警横幅卡、缓存占用 Top 20 排行、包卡片列表 | `/` |
|
||||||
| 2 | 全局检索 | 48px 大圆角搜索框、快捷筛选 pill、无边框结果表格 | `/search` |
|
| 2 | 全局检索 | 48px 大圆角搜索框、快捷筛选 pill、无边框结果表格 | `/search` |
|
||||||
| 3 | 阈值配置 | 全局阈值卡×2(版本数/大小,−/+ 步进器、恢复默认、保存)、单包例外表格、行内添加例外表单 | `/thresholds` |
|
| 3 | 阈值配置 | 全局阈值卡×2(版本数/大小,−/+ 步进器、恢复默认、保存)、单包例外表格、行内添加例外表单 | `/thresholds` |
|
||||||
| 4 | 包详情抽屉 | 600px 右侧滑出,包信息 + 版本列表(单版本删除、清理全部、忽略) | 覆盖层 |
|
| 4 | 包详情抽屉 | 600px 右侧滑出,包信息 + 版本列表(单版本删除、清理全部、忽略) | 覆盖层 |
|
||||||
@@ -158,7 +158,7 @@ CREATE TABLE logs (id INTEGER PRIMARY KEY AUTOINCREMENT, pkg_name TEXT,
|
|||||||
| 辅助指标 Stat2 | 5 个轻量小卡(访问排行/今日下载/最大包/最老包/版本数分布) |
|
| 辅助指标 Stat2 | 5 个轻量小卡(访问排行/今日下载/最大包/最老包/版本数分布) |
|
||||||
| 预警卡 Alert | 红色渐变边框 + 呼吸光晕 + 立即清理按钮 |
|
| 预警卡 Alert | 红色渐变边框 + 呼吸光晕 + 立即清理按钮 |
|
||||||
| 清理卡 Clean | 橙色渐变 + 可清理空间 + 立即清理按钮 |
|
| 清理卡 Clean | 橙色渐变 + 可清理空间 + 立即清理按钮 |
|
||||||
| Top30 排行 | 表格:金银铜徽章、包名(scoped 省略+tooltip)、访问次数、热度进度条 |
|
| Top20 排行 | 表格:金银铜徽章、包名(scoped 省略+tooltip)、访问次数、热度进度条 |
|
||||||
| 包卡片 | 图标、包名、版本数/占用/最近更新、状态标签、清理/忽略按钮;warn 卡呼吸光晕 |
|
| 包卡片 | 图标、包名、版本数/占用/最近更新、状态标签、清理/忽略按钮;warn 卡呼吸光晕 |
|
||||||
| 搜索框 | 48px 高、大圆角、放大镜 + 快捷键提示;focus 橙色光圈 |
|
| 搜索框 | 48px 高、大圆角、放大镜 + 快捷键提示;focus 橙色光圈 |
|
||||||
| Pill 筛选 | 状态过滤 + 排序切换,选中态绿色 |
|
| Pill 筛选 | 状态过滤 + 排序切换,选中态绿色 |
|
||||||
|
|||||||
+23
-24
@@ -381,18 +381,17 @@ C端体验优先:拒绝千篇一律的后台管理系统风格,打造轻盈
|
|||||||
不需要”设置”菜单:阈值配置已经覆盖了核心设置项,其他系统级配置(如 Verdaccio 连接地址)可以在首次启动时通过弹窗配置,或放在用户头像的下拉菜单中。
|
不需要”设置”菜单:阈值配置已经覆盖了核心设置项,其他系统级配置(如 Verdaccio 连接地址)可以在首次启动时通过弹窗配置,或放在用户头像的下拉菜单中。
|
||||||
不需要”用户管理”菜单:本工具定位为个人/小团队内部使用,暂不涉及多用户权限管理。
|
不需要”用户管理”菜单:本工具定位为个人/小团队内部使用,暂不涉及多用户权限管理。
|
||||||
### 三、缓存概览页
|
### 三、缓存概览页
|
||||||
#### 3.1 统计指标(共 9 个)
|
#### 3.1 统计指标(共 8 个)
|
||||||
| 序号 | 指标 | 图标 | 说明 |
|
| 序号 | 指标 | 图标 | 说明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1 | 包的数量 | | 缓存包总数 |
|
| 1 | 包的数量 | | 缓存包总数 |
|
||||||
| 2 | 占用空间 | | 总占用空间 |
|
| 2 | 占用空间 | | 总占用空间 |
|
||||||
| 3 | 预警数量 | | 触发阈值的包数量 |
|
| 3 | 预警数量 | | 触发阈值的包数量 |
|
||||||
| 4 | 访问排行 | | Top 30 包访问次数 |
|
| 4 | 今日更新包数 | | 最近更新时间等于今天的包数量 |
|
||||||
| 5 | 今日/本周下载量 | | 当天或本周下载总次数 |
|
| 5 | 可清理空间 | | 预估可释放的空间 |
|
||||||
| 6 | 可清理空间 | | 预估可释放的空间 |
|
| 6 | 最大包 | | 占用空间最大的包 |
|
||||||
| 7 | 最大包 | | 占用空间最大的包 |
|
| 7 | 最老包时间 | | 最久未更新的包的时间 |
|
||||||
| 8 | 最老包时间 | | 最久未更新的包的时间 |
|
| 8 | 版本数分布 | | 单包版本数分布情况 |
|
||||||
| 9 | 版本数分布 | | 单包版本数分布情况 |
|
|
||||||
|
|
||||||
#### 3.2 布局设计
|
#### 3.2 布局设计
|
||||||
##### 第一行(核心指标 · 4 个)
|
##### 第一行(核心指标 · 4 个)
|
||||||
@@ -402,27 +401,27 @@ C端体验优先:拒绝千篇一律的后台管理系统风格,打造轻盈
|
|||||||
│ 清新绿 │ 活力橙 │ 热情红呼吸 │ 静谧蓝 │
|
│ 清新绿 │ 活力橙 │ 热情红呼吸 │ 静谧蓝 │
|
||||||
│ │ │ │ [立即清理] │
|
│ │ │ │ [立即清理] │
|
||||||
└──────────────┴──────────────┴──────────────┴──────────────┘
|
└──────────────┴──────────────┴──────────────┴──────────────┘
|
||||||
指标说明: - 包的数量:扫描 storage/ 目录下的包文件夹数量 - 占用空间:递归计算 storage/ 下所有 .tgz 文件总大小 - 预警数量:对比阈值配置,统计触发条件的包数量 - 可清理空间:根据阈值规则,预估可释放的空间,给用户”清理动力” - 访问排行:点击可展开 Top 30 详情
|
指标说明: - 包的数量:扫描 storage/ 目录下的包文件夹数量 - 占用空间:递归计算 storage/ 下所有 .tgz 文件总大小 - 预警数量:对比阈值配置,统计触发条件的包数量 - 可清理空间:根据阈值规则,预估可释放的空间,给用户”清理动力” - 今日更新包数:最近更新时间等于今天的包数量
|
||||||
##### 第二行(辅助指标 · 5 个)
|
##### 第二行(辅助指标 · 5 个)
|
||||||
┌──────────────┬──────────────┬──────────────┬──────────────┬──────────────┐
|
┌──────────────┬──────────────┬──────────────┬──────────────┬──────────────┐
|
||||||
│ 访问排行 │ 今日下载量 │ 最大包 │ 最老包时间 │ 版本数分布 │
|
│ 今日更新包数 │ 缓存版本总数 │ 最大包 │ 最老包时间 │ 版本数分布 │
|
||||||
│ Top 30 │ 1,234 │ tensorflow │ 3 年前 │ >50版本: 12个│
|
│ 12 │ 86.1 万 │ tensorflow │ 3 年前 │ >50版本: 12个│
|
||||||
│ [展开] │ 2.1 GB │ 2.1 GB │ legacy-utils │ [查看详情] │
|
│ [查看] │ 所有版本 │ 2.1 GB │ legacy-utils │ [查看详情] │
|
||||||
└──────────────┴──────────────┴──────────────┴──────────────┴──────────────┘
|
└──────────────┴──────────────┴──────────────┴──────────────┴──────────────┘
|
||||||
#### 3.3 访问次数 Top 30 排行
|
#### 3.3 缓存占用 Top 20 排行
|
||||||
点击”访问排行”卡片后,下方滑出排行榜,采用表格 + 进度条形式:
|
概览页常驻展示占用空间最大的 20 个包,采用表格形式(真实环境无访问统计时按占用大小排序):
|
||||||
┌────────────────────────────────────────────────────────────────┐
|
┌────────────────────────────────────────────────────────────────┐
|
||||||
│ 访问次数 Top 30 │
|
│ 缓存占用 Top 20 │
|
||||||
├──────┬──────────────────────┬──────────┬───────────────────────┤
|
├──────┬──────────────────────┬──────────┤
|
||||||
│ 排名 │ 包名 │ 访问次数 │ 热度 │
|
│ 排名 │ 包名 │ 占用空间 │
|
||||||
├──────┼──────────────────────┼──────────┼───────────────────────┤
|
├──────┼──────────────────────┼──────────┤
|
||||||
│ │ lodash │ 12,456 │ ████████████░░ 85% │
|
│ │ tensorflow │ 2.1 GB │
|
||||||
│ │ axios │ 9,832 │ █████████░░░ 72% │
|
│ │ @antv/g2 │ 1.4 GB │
|
||||||
│ │ @antv/g2 │ 7,654 │ ███████░░░░░ 58% │
|
│ │ lodash │ 854 MB │
|
||||||
│ 4 │ react │ 6,543 │ ██████░░░░░░ 49% │
|
│ 4 │ react │ 621 MB │
|
||||||
│ ... │ ... │ ... │ ... │
|
│ ... │ ... │ ... │
|
||||||
└──────┴──────────────────────┴──────────┴───────────────────────┘
|
└──────┴──────────────────────┴──────────┘
|
||||||
设计要点: - 前三名用金银铜标识,多巴胺配色高亮 - 热度进度条直观展示相对访问频率 - 包名支持 scoped(如 @antv/g2),超出宽度省略 + tooltip - 点击包名可跳转到该包的详情卡片
|
设计要点: - 前三名用金银铜标识,多巴胺配色高亮 - 包名支持 scoped(如 @antv/g2),超出宽度省略 + tooltip - 点击行可打开该包的详情抽屉
|
||||||
#### 3.4 缓存包列表(卡片形式)
|
#### 3.4 缓存包列表(卡片形式)
|
||||||
以圆角卡片形式展示每个缓存包的核心信息,卡片采用多巴胺配色,按状态排序:
|
以圆角卡片形式展示每个缓存包的核心信息,卡片采用多巴胺配色,按状态排序:
|
||||||
┌──────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- VERDACCIO_STORAGE=/verdaccio/storage # 容器内路径,对应下方只读挂载的右侧
|
- VERDACCIO_STORAGE=/verdaccio/storage # 容器内路径,对应下方只读挂载的右侧
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
|
- TZ=Asia/Shanghai # 每日 00:00 自动扫描按北京时间执行
|
||||||
volumes:
|
volumes:
|
||||||
- "/volume2/docker/verdaccio_verdaccio-1/verdaccio_storage:/verdaccio/storage:ro" # Verdaccio 缓存目录(只读)
|
- "/volume2/docker/verdaccio_verdaccio-1/verdaccio_storage:/verdaccio/storage:ro" # Verdaccio 缓存目录(只读)
|
||||||
- "vcm-data:/app/data" # 读写:SQLite 数据(named volume,避免宿主目录权限问题)
|
- "vcm-data:/app/data" # 读写:SQLite 数据(named volume,避免宿主目录权限问题)
|
||||||
|
|||||||
+136
-3
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import AppTopbar from './components/AppTopbar.vue'
|
import AppTopbar from './components/AppTopbar.vue'
|
||||||
import MobileTabbar from './components/MobileTabbar.vue'
|
import MobileTabbar from './components/MobileTabbar.vue'
|
||||||
@@ -7,12 +7,33 @@ import SettingDrawer from './components/SettingDrawer.vue'
|
|||||||
import { useStore } from './store'
|
import { useStore } from './store'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { refreshAll } = useStore()
|
const { state, refreshAll, scanNow } = useStore()
|
||||||
const settingsOpen = ref(false)
|
const settingsOpen = ref(false)
|
||||||
|
|
||||||
const isLogin = () => route.path === '/login'
|
const isLogin = () => route.path === '/login'
|
||||||
const isMobile = () => window.matchMedia('(max-width: 767px)').matches
|
const isMobile = () => window.matchMedia('(max-width: 767px)').matches
|
||||||
|
|
||||||
|
// 数据基于何时扫描(后端扫描时间,缓存复用)
|
||||||
|
const fmtScanTime = computed(() => {
|
||||||
|
const t = state.scannedAt
|
||||||
|
if (!t) return ''
|
||||||
|
const d = new Date(t)
|
||||||
|
const p = n => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
||||||
|
})
|
||||||
|
|
||||||
|
async function doRescan() {
|
||||||
|
await scanNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扫描进度字节数 → 可读文本
|
||||||
|
const fmtScanBytes = computed(() => {
|
||||||
|
const b = state.scanProgress.bytes || 0
|
||||||
|
const mb = b / 1024 / 1024
|
||||||
|
if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB`
|
||||||
|
return `${mb.toFixed(1)} MB`
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!isLogin()) refreshAll()
|
if (!isLogin()) refreshAll()
|
||||||
})
|
})
|
||||||
@@ -23,7 +44,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div v-if="!isLogin()">
|
<div v-if="!isLogin()">
|
||||||
<!-- 桌面/平板顶栏 -->
|
<!-- 桌面/平板顶栏 -->
|
||||||
<AppTopbar class="desktop-topbar" @open-settings="settingsOpen = true" />
|
<AppTopbar class="desktop-topbar" @open-settings="settingsOpen = true" @rescan="doRescan" />
|
||||||
<!-- 移动端顶栏:☰ + 品牌 -->
|
<!-- 移动端顶栏:☰ + 品牌 -->
|
||||||
<header class="m-topbar">
|
<header class="m-topbar">
|
||||||
<div class="m-brand">
|
<div class="m-brand">
|
||||||
@@ -32,6 +53,25 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="m-menu" @click="settingsOpen = true">☰</div>
|
<div class="m-menu" @click="settingsOpen = true">☰</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- 导航栏下方:扫描信息条 -->
|
||||||
|
<div class="scan-banner" v-if="state.loading || state.scannedAt">
|
||||||
|
<span v-if="state.loading" class="scan-banner-loading">⏳ 正在扫描缓存目录,请稍候…</span>
|
||||||
|
<span v-else>🕐 数据基于 {{ fmtScanTime }} 扫描</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 扫描遮罩:毛玻璃 + loading + 实时进度 -->
|
||||||
|
<div v-if="state.loading" class="scan-mask">
|
||||||
|
<div class="scan-card">
|
||||||
|
<div class="scan-spinner"></div>
|
||||||
|
<div class="scan-title">正在扫描缓存目录…</div>
|
||||||
|
<div class="scan-info">
|
||||||
|
已扫描 <b>{{ state.scanProgress.packages.toLocaleString() }}</b> 个包
|
||||||
|
<span class="dot">·</span> <b>{{ fmtScanBytes }}</b>
|
||||||
|
</div>
|
||||||
|
<div class="scan-bar"><i></i></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
@@ -47,6 +87,95 @@ onMounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.m-topbar { display: none; }
|
.m-topbar { display: none; }
|
||||||
|
|
||||||
|
/* —— 扫描信息条(顶栏下方) —— */
|
||||||
|
.scan-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start; /* 靠左对齐 */
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border-bottom: 1px solid var(--line-soft);
|
||||||
|
font-family: var(--num);
|
||||||
|
}
|
||||||
|
.scan-banner-loading {
|
||||||
|
color: var(--orange);
|
||||||
|
font-weight: 600;
|
||||||
|
animation: blink 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 扫描遮罩(玻璃拟态:半透明背景 + 卡片磨砂模糊 + 细边框/高光)
|
||||||
|
遮罩层只做半透明(避免全屏 blur 的性能开销与双倍模糊),
|
||||||
|
真正的毛玻璃效果做在卡片上:半透明背景 + backdrop-filter 模糊 + 细边框 + 高光 —— */
|
||||||
|
.scan-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 500;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(18, 16, 14, .38);
|
||||||
|
}
|
||||||
|
html[data-theme="bright"] .scan-mask { background: rgba(244, 242, 239, .45); }
|
||||||
|
.scan-card {
|
||||||
|
position: relative;
|
||||||
|
background: rgba(28, 26, 24, .55);
|
||||||
|
backdrop-filter: blur(18px) saturate(1.35);
|
||||||
|
-webkit-backdrop-filter: blur(18px) saturate(1.35);
|
||||||
|
border: 1px solid rgba(255, 255, 255, .14);
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow:
|
||||||
|
0 24px 60px -16px rgba(0, 0, 0, .35),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, .18);
|
||||||
|
padding: 32px 44px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 92vw;
|
||||||
|
}
|
||||||
|
html[data-theme="bright"] .scan-card {
|
||||||
|
background: rgba(255, 255, 255, .55);
|
||||||
|
border-color: rgba(255, 255, 255, .55);
|
||||||
|
box-shadow:
|
||||||
|
0 24px 60px -16px rgba(0, 0, 0, .22),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, .85);
|
||||||
|
}
|
||||||
|
.scan-spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 4px solid var(--line);
|
||||||
|
border-top-color: var(--orange);
|
||||||
|
animation: spin360 .8s linear infinite;
|
||||||
|
}
|
||||||
|
.scan-title { font-size: 15px; font-weight: 700; }
|
||||||
|
.scan-info { font-family: var(--num); font-size: 12.5px; color: var(--ink-dim); }
|
||||||
|
.scan-info b { color: var(--orange); font-weight: 700; }
|
||||||
|
.scan-info .dot { margin: 0 4px; color: var(--ink-faint); }
|
||||||
|
.scan-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: var(--num-bg);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.scan-bar i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: 42%;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--orange), transparent);
|
||||||
|
animation: scan-slide 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes scan-slide {
|
||||||
|
from { transform: translateX(-110%); }
|
||||||
|
to { transform: translateX(340%); }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.desktop-topbar { display: none; }
|
.desktop-topbar { display: none; }
|
||||||
.m-topbar {
|
.m-topbar {
|
||||||
@@ -60,6 +189,10 @@ onMounted(() => {
|
|||||||
top: 0;
|
top: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
.scan-banner {
|
||||||
|
padding: 5px 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
.m-brand { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 700; }
|
.m-brand { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 700; }
|
||||||
.m-logo {
|
.m-logo {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
|
|||||||
@@ -67,23 +67,51 @@ const api = {
|
|||||||
let list = clone(mockState.packages)
|
let list = clone(mockState.packages)
|
||||||
list = list.map(p => ({ ...p, status: computeStatus(p, mockState.versionThreshold) }))
|
list = list.map(p => ({ ...p, status: computeStatus(p, mockState.versionThreshold) }))
|
||||||
if (kw) list = list.filter(p => p.name.toLowerCase().includes(kw))
|
if (kw) list = list.filter(p => p.name.toLowerCase().includes(kw))
|
||||||
return { packages: sortPackages(list), scannedAt: new Date().toISOString() }
|
return { packages: sortPackages(list), scannedAt: new Date().toISOString(), scanning: false }
|
||||||
}
|
}
|
||||||
const r = await fetch(`/api/packages?keyword=${encodeURIComponent(keyword)}`)
|
const r = await fetch(`/api/packages?keyword=${encodeURIComponent(keyword)}`)
|
||||||
const data = await r.json()
|
const data = await r.json()
|
||||||
return { packages: data.packages, scannedAt: data.scannedAt || null }
|
return {
|
||||||
|
packages: data.packages || [],
|
||||||
|
scannedAt: data.scannedAt || null,
|
||||||
|
scanning: data.scanning || false,
|
||||||
|
scanPackages: data.scanPackages || 0,
|
||||||
|
scanBytes: data.scanBytes || 0,
|
||||||
|
totalBytes: data.totalBytes || null,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 手动触发后端重新扫描缓存目录 */
|
/** 单包详情(含版本明细),抽屉打开时按需加载 */
|
||||||
|
async getPackageDetail(name) {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
await delay(80)
|
||||||
|
const p = clone(mockState.packages).find(x => x.name === name) || null
|
||||||
|
return { ok: !!p, pkg: p }
|
||||||
|
}
|
||||||
|
const r = await fetch(`/api/packages/${encodeURIComponent(name)}`)
|
||||||
|
if (!r.ok) return { ok: false, pkg: null }
|
||||||
|
return r.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 手动触发后端重新扫描缓存目录(异步,返回后需轮询进度) */
|
||||||
async scanNow() {
|
async scanNow() {
|
||||||
if (USE_MOCK) {
|
if (USE_MOCK) {
|
||||||
await delay(300)
|
await delay(300)
|
||||||
return { ok: true, scannedAt: new Date().toISOString() }
|
return { ok: true, scanning: false, scannedAt: new Date().toISOString() }
|
||||||
}
|
}
|
||||||
const r = await fetch('/api/scan', { method: 'POST' })
|
const r = await fetch('/api/scan', { method: 'POST' })
|
||||||
return r.json()
|
return r.json()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 查询扫描进度(包数 / 字节数 / 是否完成) */
|
||||||
|
async getScanProgress() {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
return { scanning: false, done: true, packages: 33, bytes: 0 }
|
||||||
|
}
|
||||||
|
const r = await fetch('/api/scan/progress')
|
||||||
|
return r.json()
|
||||||
|
},
|
||||||
|
|
||||||
async getThresholds() {
|
async getThresholds() {
|
||||||
if (USE_MOCK) {
|
if (USE_MOCK) {
|
||||||
await delay(80)
|
await delay(80)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useTheme } from '../composables/useTheme'
|
import { useTheme } from '../composables/useTheme'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
@@ -8,7 +9,9 @@ const router = useRouter()
|
|||||||
const { theme, toggle } = useTheme()
|
const { theme, toggle } = useTheme()
|
||||||
const { state } = useStore()
|
const { state } = useStore()
|
||||||
|
|
||||||
defineEmits(['open-settings'])
|
const emit = defineEmits(['open-settings', 'rescan'])
|
||||||
|
|
||||||
|
const menuOpen = ref(false)
|
||||||
|
|
||||||
const navs = [
|
const navs = [
|
||||||
{ path: '/', label: '概览', emo: '🏠' },
|
{ path: '/', label: '概览', emo: '🏠' },
|
||||||
@@ -21,6 +24,22 @@ const navs = [
|
|||||||
function go(path) {
|
function go(path) {
|
||||||
if (route.path !== path) router.push(path)
|
if (route.path !== path) router.push(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pick(action) {
|
||||||
|
menuOpen.value = false
|
||||||
|
if (action === 'rescan') emit('rescan')
|
||||||
|
else if (action === 'settings') emit('open-settings')
|
||||||
|
else if (action === 'logout') {
|
||||||
|
localStorage.removeItem('vcm-user')
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocClick(e) {
|
||||||
|
if (!e.target.closest('.avatar-wrap')) menuOpen.value = false
|
||||||
|
}
|
||||||
|
onMounted(() => document.addEventListener('click', onDocClick))
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -46,10 +65,22 @@ function go(path) {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
<button class="theme-toggle" :title="theme === 'dark' ? '切换到亮色' : '切换到暗色'" @click="toggle">
|
<button
|
||||||
{{ theme === 'dark' ? '☀️ 切到亮色' : '🌙 切到暗色' }}
|
class="theme-toggle"
|
||||||
</button>
|
:title="theme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'"
|
||||||
<div class="avatar" title="设置" @click="$emit('open-settings')">👤</div>
|
:aria-label="theme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'"
|
||||||
|
@click="toggle"
|
||||||
|
>{{ theme === 'dark' ? '☀️' : '🌙' }}</button>
|
||||||
|
<div class="avatar-wrap">
|
||||||
|
<div class="avatar" title="用户菜单" @click="menuOpen = !menuOpen">👤</div>
|
||||||
|
<transition name="pop">
|
||||||
|
<div v-if="menuOpen" class="avatar-menu">
|
||||||
|
<button class="am-item" :disabled="state.loading" @click="pick('rescan')">{{ state.loading ? '⏳ 扫描中…' : '🔄 重新扫描' }}</button>
|
||||||
|
<button class="am-item" @click="pick('settings')">⚙️ 设置</button>
|
||||||
|
<button class="am-item danger" @click="pick('logout')">🚪 退出登录</button>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
@@ -145,19 +176,17 @@ function go(path) {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
display: inline-flex;
|
width: 36px;
|
||||||
align-items: center;
|
height: 36px;
|
||||||
gap: 7px;
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
border: 1.5px solid var(--line);
|
border: 1.5px solid var(--line);
|
||||||
background: var(--toggle-bg);
|
background: var(--toggle-bg);
|
||||||
color: var(--ink-dim);
|
color: var(--ink-dim);
|
||||||
font-size: 12.5px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
|
||||||
padding: 8px 15px;
|
|
||||||
border-radius: 99px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: .16s;
|
transition: .16s;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.theme-toggle:hover {
|
.theme-toggle:hover {
|
||||||
border-color: var(--orange);
|
border-color: var(--orange);
|
||||||
@@ -166,12 +195,72 @@ function go(path) {
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* —— 头像下拉菜单 —— */
|
||||||
|
.avatar-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.avatar-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 10px);
|
||||||
|
min-width: 158px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 13px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
z-index: 300;
|
||||||
|
}
|
||||||
|
.am-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 13px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.am-item:hover {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
color: var(--orange);
|
||||||
|
}
|
||||||
|
.am-item:disabled {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.am-item.danger {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
.am-item.danger:hover {
|
||||||
|
background: var(--red-soft);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
.pop-enter-active,
|
||||||
|
.pop-leave-active {
|
||||||
|
transition: opacity .16s, transform .16s;
|
||||||
|
}
|
||||||
|
.pop-enter-from,
|
||||||
|
.pop-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px) scale(.96);
|
||||||
|
}
|
||||||
|
|
||||||
/* 平板(768~1023):压缩导航 */
|
/* 平板(768~1023):压缩导航 */
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
.topbar { padding: 0 16px; gap: 12px; }
|
.topbar { padding: 0 16px; gap: 12px; }
|
||||||
.brand-sub { display: none; }
|
.brand-sub { display: none; }
|
||||||
.nav-item { padding: 8px 13px; font-size: 13px; }
|
.nav-item { padding: 8px 13px; font-size: 13px; }
|
||||||
.theme-toggle span { display: none; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 手机 <768:顶栏转为 M-Topbar(☰ + 品牌) */
|
/* 手机 <768:顶栏转为 M-Topbar(☰ + 品牌) */
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ const tabs = [
|
|||||||
{ path: '/', label: '概览', emo: '🏠' },
|
{ path: '/', label: '概览', emo: '🏠' },
|
||||||
{ path: '/search', label: '检索', emo: '🔍' },
|
{ path: '/search', label: '检索', emo: '🔍' },
|
||||||
{ path: '/thresholds', label: '阈值', emo: '⚙️' },
|
{ path: '/thresholds', label: '阈值', emo: '⚙️' },
|
||||||
{ path: '/logs', label: '动态', emo: '🕒' },
|
{ path: '/logs', label: '操作日志', emo: '🕒' },
|
||||||
|
{ path: '/ignored', label: '已忽略', emo: '🚫' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function go(path) {
|
function go(path) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import StatusTag from './StatusTag.vue'
|
import StatusTag from './StatusTag.vue'
|
||||||
import { iconOf } from '../mock/data.js'
|
import PkgLogo from './PkgLogo.vue'
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
pkg: { type: Object, required: true },
|
pkg: { type: Object, required: true },
|
||||||
@@ -22,7 +22,7 @@ function splitName(name) {
|
|||||||
@click="emit('open', pkg)"
|
@click="emit('open', pkg)"
|
||||||
>
|
>
|
||||||
<div class="p-top">
|
<div class="p-top">
|
||||||
<div class="pkg-ico">{{ iconOf(pkg.name) }}</div>
|
<PkgLogo :name="pkg.name" :size="44" />
|
||||||
<div style="flex: 1; min-width: 0">
|
<div style="flex: 1; min-width: 0">
|
||||||
<div class="pkg-name">
|
<div class="pkg-name">
|
||||||
<span class="nm">
|
<span class="nm">
|
||||||
@@ -87,17 +87,6 @@ function splitName(name) {
|
|||||||
.pkg-card.ignored { background: var(--ignored-card); opacity: .82; }
|
.pkg-card.ignored { background: var(--ignored-card); opacity: .82; }
|
||||||
.pkg-card.broken { border-style: dashed; }
|
.pkg-card.broken { border-style: dashed; }
|
||||||
.p-top { display: flex; align-items: center; gap: 12px; }
|
.p-top { display: flex; align-items: center; gap: 12px; }
|
||||||
.pkg-ico {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 14px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-size: 20px;
|
|
||||||
background: var(--grad-pkg-ico);
|
|
||||||
box-shadow: inset 0 0 0 1.5px rgba(255, 140, 0, .18);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.pkg-name {
|
.pkg-name {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
import StatusTag from './StatusTag.vue'
|
import StatusTag from './StatusTag.vue'
|
||||||
import { iconOf } from '../mock/data.js'
|
import PkgLogo from './PkgLogo.vue'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
@@ -9,6 +11,38 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
const emit = defineEmits(['update:visible', 'clean', 'ignore', 'unignore', 'delete-version'])
|
const emit = defineEmits(['update:visible', 'clean', 'ignore', 'unignore', 'delete-version'])
|
||||||
|
|
||||||
|
// 版本明细按需加载:列表接口不含明细,打开抽屉时异步获取
|
||||||
|
const loadingVer = ref(false)
|
||||||
|
const verFail = ref(false)
|
||||||
|
const detailPkg = ref(null)
|
||||||
|
const verList = computed(() => detailPkg.value?.versionsDetail || props.pkg?.versionsDetail || [])
|
||||||
|
|
||||||
|
// 包信息(介绍/主页/仓库/许可/作者):列表接口不含,来自详情接口或列表已有字段
|
||||||
|
const desc = computed(() => detailPkg.value?.description || props.pkg?.description || '')
|
||||||
|
const homepage = computed(() => detailPkg.value?.homepage || props.pkg?.homepage || '')
|
||||||
|
const repo = computed(() => detailPkg.value?.repository || props.pkg?.repository || '')
|
||||||
|
const license = computed(() => detailPkg.value?.license || props.pkg?.license || '')
|
||||||
|
const author = computed(() => detailPkg.value?.author || props.pkg?.author || '')
|
||||||
|
const hasMeta = computed(() => homepage.value || repo.value || license.value || author.value)
|
||||||
|
const repoUrl = computed(() => repo.value.replace(/^git\+/, '').replace(/\.git$/, ''))
|
||||||
|
|
||||||
|
watch(() => props.pkg, async (p) => {
|
||||||
|
verFail.value = false
|
||||||
|
detailPkg.value = null
|
||||||
|
if (!p) { loadingVer.value = false; return }
|
||||||
|
if (Array.isArray(p.versionsDetail) && p.versionsDetail.length) { loadingVer.value = false; return }
|
||||||
|
loadingVer.value = true
|
||||||
|
try {
|
||||||
|
const d = await api.getPackageDetail(p.name)
|
||||||
|
if (d.ok && d.pkg) detailPkg.value = d.pkg
|
||||||
|
else verFail.value = true
|
||||||
|
} catch {
|
||||||
|
verFail.value = true
|
||||||
|
} finally {
|
||||||
|
loadingVer.value = false
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
function splitName(name) {
|
function splitName(name) {
|
||||||
if (!name) return { scope: '', base: '' }
|
if (!name) return { scope: '', base: '' }
|
||||||
const i = name.lastIndexOf('/')
|
const i = name.lastIndexOf('/')
|
||||||
@@ -34,15 +68,15 @@ function delVersion(v) {
|
|||||||
<aside v-if="visible && pkg" class="drawer">
|
<aside v-if="visible && pkg" class="drawer">
|
||||||
<div class="drawer-head">
|
<div class="drawer-head">
|
||||||
<div class="drawer-title">
|
<div class="drawer-title">
|
||||||
<div class="pkg-ico">{{ iconOf(pkg.name) }}</div>
|
<PkgLogo :name="pkg.name" :size="46" :homepage="homepage" />
|
||||||
<div>
|
<div style="min-width: 0">
|
||||||
<div style="display:flex;align-items:center;gap:9px;flex-wrap:wrap">
|
<div style="display:flex;align-items:center;gap:9px;flex-wrap:wrap">
|
||||||
<span style="font-size:17px;font-weight:700">
|
<span style="font-size:17px;font-weight:700;letter-spacing:-.2px">
|
||||||
<span v-if="splitName(pkg.name).scope" class="scope">{{ splitName(pkg.name).scope }}</span>{{ splitName(pkg.name).base }}
|
<span v-if="splitName(pkg.name).scope" class="scope">{{ splitName(pkg.name).scope }}</span>{{ splitName(pkg.name).base }}
|
||||||
</span>
|
</span>
|
||||||
<StatusTag :status="pkg.status" />
|
<StatusTag :status="pkg.status" />
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:11.5px;color:var(--ink-faint);margin-top:2px">{{ pkg.name }}</div>
|
<p v-if="desc" class="drawer-desc" :title="desc">{{ desc }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="drawer-close" @click="emit('update:visible', false)">✕</div>
|
<div class="drawer-close" @click="emit('update:visible', false)">✕</div>
|
||||||
@@ -68,17 +102,30 @@ function delVersion(v) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasMeta" class="d-meta">
|
||||||
|
<a v-if="homepage" class="d-meta-item" :href="homepage" target="_blank" rel="noopener">🔗 主页</a>
|
||||||
|
<a v-if="repoUrl" class="d-meta-item" :href="repoUrl" target="_blank" rel="noopener">🛠 仓库</a>
|
||||||
|
<span v-if="license" class="d-meta-item lic">📜 {{ license }}</span>
|
||||||
|
<span v-if="author" class="d-meta-item lic" :title="author">👤 {{ author }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="d-sub">
|
<div class="d-sub">
|
||||||
版本列表
|
版本列表
|
||||||
<span class="cnt">{{ pkg.versionsDetail?.length || pkg.versions }} 个版本</span>
|
<span class="cnt">{{ verList.length || pkg.versions }} 个版本</span>
|
||||||
<span v-if="pkg.status === 'warn'" class="excess">超阈值 {{ pkg.versions - threshold }} 个</span>
|
<span v-if="pkg.status === 'warn'" class="excess">超阈值 {{ pkg.versions - threshold }} 个</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ver-list">
|
<div class="ver-list">
|
||||||
<div v-if="!pkg.versionsDetail?.length" class="empty-tip">
|
<div v-if="loadingVer" class="empty-tip load">
|
||||||
{{ pkg.status === 'broken' ? '元数据异常,无法读取版本列表' : '暂无版本信息' }}
|
<span class="spin"></span>正在加载版本信息…
|
||||||
</div>
|
</div>
|
||||||
<div v-for="v in pkg.versionsDetail" :key="v.version" class="ver-row">
|
<div v-else-if="verFail" class="empty-tip">
|
||||||
|
🗂️ 版本信息加载失败,请稍后重试
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!verList.length" class="empty-tip">
|
||||||
|
{{ pkg.status === 'broken' ? '⚠️ 元数据异常,无法读取版本列表' : '📭 暂无版本信息' }}
|
||||||
|
</div>
|
||||||
|
<div v-for="v in verList" :key="v.version" class="ver-row">
|
||||||
<span class="ver-no">{{ v.version }}</span>
|
<span class="ver-no">{{ v.version }}</span>
|
||||||
<span class="ver-size">{{ v.size }} MB</span>
|
<span class="ver-size">{{ v.size }} MB</span>
|
||||||
<span class="ver-date">{{ fmtDate(v.date) }}</span>
|
<span class="ver-date">{{ fmtDate(v.date) }}</span>
|
||||||
@@ -144,16 +191,38 @@ export default {
|
|||||||
}
|
}
|
||||||
.drawer-title { display: flex; align-items: center; gap: 12px; }
|
.drawer-title { display: flex; align-items: center; gap: 12px; }
|
||||||
.drawer-title .scope { color: var(--ink-faint); font-weight: 500; }
|
.drawer-title .scope { color: var(--ink-faint); font-weight: 500; }
|
||||||
.pkg-ico {
|
.drawer-desc {
|
||||||
width: 42px;
|
font-size: 12.5px;
|
||||||
height: 42px;
|
color: var(--ink-dim);
|
||||||
border-radius: 13px;
|
margin: 6px 0 0;
|
||||||
display: grid;
|
line-height: 1.55;
|
||||||
place-items: center;
|
max-width: 430px;
|
||||||
font-size: 19px;
|
display: -webkit-box;
|
||||||
background: var(--grad-pkg-ico);
|
-webkit-line-clamp: 2;
|
||||||
flex-shrink: 0;
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.d-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.d-meta-item {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--blue);
|
||||||
|
background: var(--blue-soft);
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 99px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
max-width: 260px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.d-meta-item:hover { filter: brightness(.94); }
|
||||||
|
.d-meta-item.lic { color: var(--ink-dim); background: var(--num-bg); cursor: default; }
|
||||||
.drawer-close {
|
.drawer-close {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -219,6 +288,22 @@ export default {
|
|||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
}
|
}
|
||||||
|
.empty-tip.load {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
.spin {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--line);
|
||||||
|
border-top-color: var(--orange);
|
||||||
|
animation: spin360 .8s linear infinite;
|
||||||
|
display: inline-block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.ver-row {
|
.ver-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup>
|
||||||
|
// 包 logo:优先取主页域名的 favicon(浏览器端加载,离线/加载失败回退首字母徽标)
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
name: { type: String, default: '' },
|
||||||
|
size: { type: Number, default: 36 },
|
||||||
|
homepage: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const letter = computed(() => {
|
||||||
|
const base = (props.name.split('/').pop() || props.name || '?').trim()
|
||||||
|
return (base[0] || '?').toUpperCase()
|
||||||
|
})
|
||||||
|
|
||||||
|
const style = computed(() => {
|
||||||
|
let h = 0
|
||||||
|
const s = props.name || 'pkg'
|
||||||
|
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360
|
||||||
|
const h2 = (h + 45) % 360
|
||||||
|
return {
|
||||||
|
width: `${props.size}px`,
|
||||||
|
height: `${props.size}px`,
|
||||||
|
fontSize: `${Math.round(props.size * 0.46)}px`,
|
||||||
|
borderRadius: `${Math.round(props.size * 0.3)}px`,
|
||||||
|
background: `linear-gradient(135deg, hsl(${h}, 62%, 52%), hsl(${h2}, 68%, 42%))`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// favicon 来源:主页域名根路径 /favicon.ico(不依赖第三方服务,站点没有时回退徽标)
|
||||||
|
const imgOk = ref(false)
|
||||||
|
const faviconUrl = computed(() => {
|
||||||
|
if (!props.homepage) return ''
|
||||||
|
try {
|
||||||
|
return new URL(props.homepage).origin + '/favicon.ico'
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
watch(faviconUrl, () => { imgOk.value = false })
|
||||||
|
function onImgOk() { imgOk.value = true }
|
||||||
|
function onImgErr() { imgOk.value = false }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="pkg-logo" :style="style" :title="name">
|
||||||
|
<!-- img 始终挂载以触发 load/error;加载成功后显示,失败回退字母徽标 -->
|
||||||
|
<img v-if="faviconUrl" class="fav" :class="{ on: imgOk }" :src="faviconUrl" alt="" @load="onImgOk" @error="onImgErr" />
|
||||||
|
<span v-show="!imgOk" class="lt">{{ letter }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pkg-logo {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #fff;
|
||||||
|
font-family: var(--num);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .28);
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, .28);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.lt { grid-area: 1 / 1; }
|
||||||
|
.fav {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #fff;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.fav.on { opacity: 1; }
|
||||||
|
</style>
|
||||||
@@ -75,4 +75,15 @@ const emit = defineEmits(['link'])
|
|||||||
.stat2.c-orange .s2-val { color: var(--orange); }
|
.stat2.c-orange .s2-val { color: var(--orange); }
|
||||||
.stat2.c-purple .s2-val { color: var(--purple); }
|
.stat2.c-purple .s2-val { color: var(--purple); }
|
||||||
.stat2.c-pink .s2-val { color: var(--pink); }
|
.stat2.c-pink .s2-val { color: var(--pink); }
|
||||||
|
|
||||||
|
/* —— 移动端:压缩 padding 和字号,防止窄屏溢出 —— */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.stat2 { padding: 11px 13px; }
|
||||||
|
.s2-top { font-size: 11.5px; gap: 6px; }
|
||||||
|
.ico2 { font-size: 14px; }
|
||||||
|
.s2-val { font-size: 17px; margin-top: 6px; }
|
||||||
|
.s2-val .u { font-size: 10.5px; }
|
||||||
|
.s2-sub { font-size: 10px; margin-top: 2px; }
|
||||||
|
.link { font-size: 10.5px; padding: 3px 9px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
// 排行 Top 30:有访问统计按热度,否则降级为缓存占用(真实环境无访问统计)
|
// 排行 Top 30:有访问统计按热度,否则降级为缓存占用(真实环境无访问统计)
|
||||||
|
import PkgLogo from './PkgLogo.vue'
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
top30: { type: Array, default: () => [] },
|
top30: { type: Array, default: () => [] },
|
||||||
top30By: { type: String, default: 'visits' },
|
top30By: { type: String, default: 'visits' },
|
||||||
})
|
})
|
||||||
|
const emit = defineEmits(['open'])
|
||||||
const isVisits = props.top30By === 'visits'
|
const isVisits = props.top30By === 'visits'
|
||||||
function fmtSize(mb) {
|
function fmtSize(mb) {
|
||||||
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
|
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
|
||||||
@@ -14,7 +16,7 @@ function fmtSize(mb) {
|
|||||||
<template>
|
<template>
|
||||||
<div class="top30">
|
<div class="top30">
|
||||||
<div class="top30-head">
|
<div class="top30-head">
|
||||||
<div class="t"><span class="ico">🏆</span>{{ isVisits ? '访问次数 Top 30' : '缓存占用 Top 30' }}<span class="tag30">{{ isVisits ? '按热度排序' : '按占用空间排序' }}</span></div>
|
<div class="t"><span class="ico">🏆</span>{{ isVisits ? '访问次数 Top 20' : '缓存占用 Top 20' }}<span class="tag30">{{ isVisits ? '按热度排序' : '按占用空间排序' }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tbl-scroll">
|
<div class="tbl-scroll">
|
||||||
<table class="rank-tbl">
|
<table class="rank-tbl">
|
||||||
@@ -23,25 +25,25 @@ function fmtSize(mb) {
|
|||||||
<th style="width: 70px">排名</th>
|
<th style="width: 70px">排名</th>
|
||||||
<th>包名</th>
|
<th>包名</th>
|
||||||
<th style="width: 130px">{{ isVisits ? '访问次数' : '占用空间' }}</th>
|
<th style="width: 130px">{{ isVisits ? '访问次数' : '占用空间' }}</th>
|
||||||
<th>热度</th>
|
<th v-if="isVisits">热度</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(p, i) in top30" :key="p.name">
|
<tr v-for="(p, i) in top30" :key="p.name" class="rank-row" @click="emit('open', p)">
|
||||||
<td>
|
<td>
|
||||||
<span class="rank-no" :class="i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : ''">{{ i + 1 }}</span>
|
<span class="rank-no" :class="i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : ''">{{ i + 1 }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="rank-pkg">
|
<span class="rank-pkg">
|
||||||
<span class="mini-ico">{{ p.emoji || '📦' }}</span>
|
<PkgLogo :name="p.name" :size="30" />
|
||||||
<span class="nm">
|
<span class="nm">
|
||||||
<span v-if="p.name.includes('/')" class="scope">{{ p.name.slice(0, p.name.lastIndexOf('/') + 1) }}</span>{{ p.name.split('/').pop() }}
|
<span v-if="p.name.includes('/')" class="scope">{{ p.name.slice(0, p.name.lastIndexOf('/') + 1) }}</span>{{ p.name.split('/').pop() }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td><span class="rank-visit">{{ isVisits ? (p.visits || 0).toLocaleString() : fmtSize(p.size) }}</span></td>
|
<td><span class="rank-visit">{{ isVisits ? (p.visits || 0).toLocaleString() : fmtSize(p.size) }}</span></td>
|
||||||
<td>
|
<td v-if="isVisits">
|
||||||
<div class="heat">
|
<div class="heat" title="访问热度占比">
|
||||||
<div class="heat-bar"><i :style="{ width: p.heat + '%' }"></i></div>
|
<div class="heat-bar"><i :style="{ width: p.heat + '%' }"></i></div>
|
||||||
<span class="heat-pct">{{ p.heat }}%</span>
|
<span class="heat-pct">{{ p.heat }}%</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,6 +105,7 @@ function fmtSize(mb) {
|
|||||||
.rank-tbl tr:last-child td { border-bottom: none; }
|
.rank-tbl tr:last-child td { border-bottom: none; }
|
||||||
.rank-tbl tr { transition: background .15s; }
|
.rank-tbl tr { transition: background .15s; }
|
||||||
.rank-tbl tr:hover { background: var(--bg); }
|
.rank-tbl tr:hover { background: var(--bg); }
|
||||||
|
.rank-tbl .rank-row { cursor: pointer; }
|
||||||
.rank-no {
|
.rank-no {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
@@ -119,16 +122,6 @@ function fmtSize(mb) {
|
|||||||
.rank-no.silver { background: linear-gradient(135deg, #E8E8EC, #C9C9CF); color: #5A5A62; box-shadow: 0 4px 10px -3px rgba(200, 200, 210, .5); }
|
.rank-no.silver { background: linear-gradient(135deg, #E8E8EC, #C9C9CF); color: #5A5A62; box-shadow: 0 4px 10px -3px rgba(200, 200, 210, .5); }
|
||||||
.rank-no.bronze { background: linear-gradient(135deg, #F2C9A0, #D9A066); color: #6E4520; box-shadow: 0 4px 10px -3px rgba(217, 160, 102, .5); }
|
.rank-no.bronze { background: linear-gradient(135deg, #F2C9A0, #D9A066); color: #6E4520; box-shadow: 0 4px 10px -3px rgba(217, 160, 102, .5); }
|
||||||
.rank-pkg { display: flex; align-items: center; gap: 10px; font-weight: 600; }
|
.rank-pkg { display: flex; align-items: center; gap: 10px; font-weight: 600; }
|
||||||
.mini-ico {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 9px;
|
|
||||||
background: var(--orange-soft);
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-size: 14px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.scope { color: var(--ink-faint); font-weight: 500; }
|
.scope { color: var(--ink-faint); font-weight: 500; }
|
||||||
.rank-visit { font-family: var(--num); font-weight: 700; font-size: 14.5px; color: var(--ink); }
|
.rank-visit { font-family: var(--num); font-weight: 700; font-size: 14.5px; color: var(--ink); }
|
||||||
@@ -143,4 +136,19 @@ function fmtSize(mb) {
|
|||||||
transform-origin: left;
|
transform-origin: left;
|
||||||
}
|
}
|
||||||
.heat-pct { font-family: var(--num); font-size: 11.5px; color: var(--ink-faint); font-weight: 600; width: 34px; text-align: right; }
|
.heat-pct { font-family: var(--num); font-size: 11.5px; color: var(--ink-faint); font-weight: 600; width: 34px; text-align: right; }
|
||||||
|
|
||||||
|
/* —— 移动端:隐藏「热度」列,缩小 padding —— */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.top30-head { padding: 12px 14px; }
|
||||||
|
.t { font-size: 13px; gap: 8px; }
|
||||||
|
.t .ico { font-size: 16px; }
|
||||||
|
.tag30 { font-size: 10px; padding: 3px 9px; }
|
||||||
|
.rank-tbl { min-width: 320px; }
|
||||||
|
.rank-tbl th, .rank-tbl td { padding: 9px 14px; font-size: 12px; }
|
||||||
|
.rank-tbl th:nth-child(4), .rank-tbl td:nth-child(4) { display: none; } /* 隐藏热度列 */
|
||||||
|
.rank-no { width: 26px; height: 26px; font-size: 12px; border-radius: 8px; }
|
||||||
|
.rank-pkg { gap: 7px; font-size: 12.5px; }
|
||||||
|
.rank-visit { font-size: 13px; }
|
||||||
|
.heat { display: none; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const THEME_KEY = 'vcm-theme'
|
const THEME_KEY = 'vcm-theme'
|
||||||
const theme = ref(document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'bright')
|
const saved = localStorage.getItem(THEME_KEY)
|
||||||
|
const theme = ref(saved === 'dark' ? 'dark' : 'bright')
|
||||||
|
|
||||||
function apply(t) {
|
function apply(t) {
|
||||||
document.documentElement.setAttribute('data-theme', t)
|
document.documentElement.setAttribute('data-theme', t)
|
||||||
localStorage.setItem(THEME_KEY, t)
|
localStorage.setItem(THEME_KEY, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 初始化即同步 DOM,确保首次点击时 data-theme 属性与状态一致
|
||||||
|
if (document.documentElement.getAttribute('data-theme') !== theme.value) {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme.value)
|
||||||
|
}
|
||||||
|
|
||||||
export function useTheme() {
|
export function useTheme() {
|
||||||
function toggle() {
|
function toggle() {
|
||||||
theme.value = theme.value === 'dark' ? 'bright' : 'dark'
|
theme.value = theme.value === 'dark' ? 'bright' : 'dark'
|
||||||
|
|||||||
@@ -16,15 +16,6 @@ const PKG_POOL = [
|
|||||||
'xlsx', 'csv-parse', 'js-yaml', 'fast-glob', 'execa', 'nanoid', 'clsx',
|
'xlsx', 'csv-parse', 'js-yaml', 'fast-glob', 'execa', 'nanoid', 'clsx',
|
||||||
]
|
]
|
||||||
|
|
||||||
const ICON_MAP = {
|
|
||||||
lodash: '📚', axios: '🔌', react: '⚛️', vue: '💚', express: '🚀', webpack: '🧱',
|
|
||||||
typescript: '📘', eslint: '🧹', '@antv/g2': '📊', '@antv/g6': '🕸️', tensorflow: '🧠',
|
|
||||||
sharp: '🖼️', puppeteer: '🤖', ioredis: '🔴', pg: '🐘', three: '🎮', d3: '📉',
|
|
||||||
echarts: '📈', xlsx: '📑', 'socket.io': '🔌', prisma: '💎', graphql: '🔗',
|
|
||||||
dayjs: '📅', moment: '⏰', uuid: '🆔', zod: '🛡️', vite: '⚡', rollup: '🌀',
|
|
||||||
jest: '🃏', mocha: '☕', prettier: '🎀', semver: '🔢', chalk: '🖍️',
|
|
||||||
}
|
|
||||||
|
|
||||||
const SCOPED = new Set(['@antv', '@ant-design', '@vue', '@vueuse', '@babel', '@types', '@element-plus', '@testing-library'])
|
const SCOPED = new Set(['@antv', '@ant-design', '@vue', '@vueuse', '@babel', '@types', '@element-plus', '@testing-library'])
|
||||||
|
|
||||||
function pad(n) {
|
function pad(n) {
|
||||||
@@ -65,13 +56,6 @@ function genVersions(name, count, baseVersion) {
|
|||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
function iconOf(name) {
|
|
||||||
if (ICON_MAP[name]) return ICON_MAP[name]
|
|
||||||
const base = name.split('/').pop() || name
|
|
||||||
if (ICON_MAP[base]) return ICON_MAP[base]
|
|
||||||
return '📦'
|
|
||||||
}
|
|
||||||
|
|
||||||
function genPackages() {
|
function genPackages() {
|
||||||
const threshold = 15
|
const threshold = 15
|
||||||
const list = []
|
const list = []
|
||||||
@@ -152,7 +136,7 @@ const packages = genPackages()
|
|||||||
const top30 = [...packages]
|
const top30 = [...packages]
|
||||||
.filter(p => p.status !== 'broken')
|
.filter(p => p.status !== 'broken')
|
||||||
.sort((a, b) => b.visits - a.visits)
|
.sort((a, b) => b.visits - a.visits)
|
||||||
.slice(0, 30)
|
.slice(0, 20)
|
||||||
.map(p => ({ ...p, heat: Math.round((p.visits / topMax(packages)) * 100) }))
|
.map(p => ({ ...p, heat: Math.round((p.visits / topMax(packages)) * 100) }))
|
||||||
|
|
||||||
function topMax(list) {
|
function topMax(list) {
|
||||||
@@ -189,4 +173,4 @@ function summary() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { packages, top30, logs, summary, fmtSize, iconOf }
|
export { packages, top30, logs, summary, fmtSize }
|
||||||
|
|||||||
+66
-11
@@ -14,30 +14,42 @@ const state = reactive({
|
|||||||
loading: false,
|
loading: false,
|
||||||
scannedAt: null,
|
scannedAt: null,
|
||||||
storageDir: localStorage.getItem('vcm-storage-dir') || '',
|
storageDir: localStorage.getItem('vcm-storage-dir') || '',
|
||||||
|
// 后端目录扫描总字节(所有文件,与 du 同口径;null 表示未知/演示模式)
|
||||||
|
totalBytes: null,
|
||||||
|
// 扫描进度(异步扫描期间实时更新,用于遮罩展示)
|
||||||
|
scanProgress: { packages: 0, bytes: 0 },
|
||||||
})
|
})
|
||||||
|
|
||||||
// —— 派生统计 ——
|
// —— 派生统计 ——
|
||||||
const stats = computed(() => {
|
const stats = computed(() => {
|
||||||
const valid = state.packages.filter(p => p.status !== 'broken')
|
const valid = state.packages.filter(p => p.status !== 'broken')
|
||||||
const totalSize = valid.reduce((s, p) => s + p.size, 0)
|
// 总占用统计全部物理缓存(含元数据异常的包,其大小按实际 .tgz 计算)
|
||||||
|
const totalSize = state.packages.reduce((s, p) => s + p.size, 0)
|
||||||
const warnCount = valid.filter(p => p.status === 'warn').length
|
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 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 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 oldestPkg = [...valid].sort((a, b) => (a.lastUpdated || '').localeCompare(b.lastUpdated || ''))[0] || null
|
||||||
// 真实环境无访问统计(visits 全 0)时,Top30 降级为「缓存占用 Top 30」
|
// 今日更新包数:最近更新时间等于今天的包数量
|
||||||
|
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 hasVisits = valid.some(p => (p.visits || 0) > 0)
|
||||||
const top30 = [...valid]
|
const top30 = [...valid]
|
||||||
.sort((a, b) => (hasVisits ? (b.visits || 0) - (a.visits || 0) : b.size - a.size))
|
.sort((a, b) => (hasVisits ? (b.visits || 0) - (a.visits || 0) : b.size - a.size))
|
||||||
.slice(0, 30)
|
.slice(0, 20)
|
||||||
const maxVisit = Math.max(...top30.map(p => p.visits || 0), 1)
|
const maxVisit = Math.max(...top30.map(p => p.visits || 0), 1)
|
||||||
const maxSize = Math.max(...top30.map(p => p.size), 1)
|
const maxSize = Math.max(...top30.map(p => p.size), 1)
|
||||||
return {
|
return {
|
||||||
pkgCount: valid.length,
|
pkgCount: valid.length,
|
||||||
|
totalVersions: state.packages.reduce((s, p) => s + (p.versions || 0), 0),
|
||||||
totalSize,
|
totalSize,
|
||||||
totalSizeText: fmtSize(totalSize),
|
// 总占用优先用后端「目录所有文件字节和」(与 du 一致),否则回退到包大小之和
|
||||||
|
totalSizeText: state.totalBytes != null ? fmtBytes(state.totalBytes) : fmtSize(totalSize),
|
||||||
warnCount,
|
warnCount,
|
||||||
cleanable,
|
cleanable,
|
||||||
cleanableText: fmtSize(cleanable),
|
cleanableText: fmtSize(cleanable),
|
||||||
|
todayUpdated,
|
||||||
maxPkg,
|
maxPkg,
|
||||||
oldestPkg,
|
oldestPkg,
|
||||||
top30: top30.map(p => ({
|
top30: top30.map(p => ({
|
||||||
@@ -55,19 +67,62 @@ function fmtSize(mb) {
|
|||||||
return `${mb.toFixed(1)} MB`
|
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
|
const fmtSizeFn = fmtSize
|
||||||
|
|
||||||
async function refreshPackages(keyword = '') {
|
async function refreshPackages(keyword = '') {
|
||||||
const { packages, scannedAt } = await api.getPackages(keyword)
|
const { packages, scannedAt, scanning, scanPackages, scanBytes, totalBytes } = await api.getPackages(keyword)
|
||||||
state.packages = packages
|
state.packages = packages || []
|
||||||
if (scannedAt) state.scannedAt = scannedAt
|
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() {
|
async function scanNow() {
|
||||||
const res = await api.scanNow()
|
state.loading = true
|
||||||
if (res.ok) await refreshAll()
|
try {
|
||||||
return res
|
const res = await api.scanNow()
|
||||||
|
if (!res.ok) return res
|
||||||
|
await doRefresh()
|
||||||
|
return res
|
||||||
|
} finally {
|
||||||
|
state.loading = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshThresholds() {
|
async function refreshThresholds() {
|
||||||
@@ -84,7 +139,7 @@ async function refreshLogs() {
|
|||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
state.loading = true
|
state.loading = true
|
||||||
try {
|
try {
|
||||||
await Promise.all([refreshPackages(), refreshThresholds(), refreshLogs()])
|
await doRefresh()
|
||||||
} finally {
|
} finally {
|
||||||
state.loading = false
|
state.loading = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ html[data-theme="dark"] .ambient::after {
|
|||||||
.vtable tbody tr { cursor: pointer; transition: background .13s; }
|
.vtable tbody tr { cursor: pointer; transition: background .13s; }
|
||||||
.vtable tbody tr:hover { background: var(--bg); }
|
.vtable tbody tr:hover { background: var(--bg); }
|
||||||
.num-cell { font-family: var(--num); font-weight: 600; font-size: 14px; }
|
.num-cell { font-family: var(--num); font-weight: 600; font-size: 14px; }
|
||||||
.date-cell { font-family: var(--num); font-size: 12.5px; color: var(--ink-dim); }
|
.date-cell { font-family: var(--num); font-size: 12.5px; color: var(--ink-dim); white-space: nowrap; }
|
||||||
|
|
||||||
/* —— 滚动条 —— */
|
/* —— 滚动条 —— */
|
||||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
|||||||
@@ -8,35 +8,15 @@ import PackageCard from '../components/PackageCard.vue'
|
|||||||
import PackageDrawer from '../components/PackageDrawer.vue'
|
import PackageDrawer from '../components/PackageDrawer.vue'
|
||||||
import CleanDialog from '../components/CleanDialog.vue'
|
import CleanDialog from '../components/CleanDialog.vue'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
import { iconOf } from '../mock/data.js'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { state, stats, refreshAll, ignorePkg, unignorePkg, cleanPkg, scanNow } = useStore()
|
const { state, stats, refreshAll, ignorePkg, unignorePkg, cleanPkg } = useStore()
|
||||||
|
|
||||||
const drawerPkg = ref(null)
|
const drawerPkg = ref(null)
|
||||||
const drawerVisible = ref(false)
|
const drawerVisible = ref(false)
|
||||||
const cleanVisible = ref(false)
|
const cleanVisible = ref(false)
|
||||||
const cleanPkgData = ref(null)
|
const cleanPkgData = ref(null)
|
||||||
const showTop30 = ref(true)
|
const showTop30 = ref(true)
|
||||||
const scanning = ref(false)
|
|
||||||
|
|
||||||
// 本次数据基于何时扫描(后端扫描时间,缓存复用)
|
|
||||||
const fmtScanTime = computed(() => {
|
|
||||||
const t = state.scannedAt
|
|
||||||
if (!t) return ''
|
|
||||||
const d = new Date(t)
|
|
||||||
const p = n => String(n).padStart(2, '0')
|
|
||||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
|
||||||
})
|
|
||||||
|
|
||||||
async function rescan() {
|
|
||||||
scanning.value = true
|
|
||||||
try {
|
|
||||||
await scanNow()
|
|
||||||
} finally {
|
|
||||||
scanning.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const warningExcess = computed(() =>
|
const warningExcess = computed(() =>
|
||||||
stats.value.warnCount
|
stats.value.warnCount
|
||||||
@@ -46,16 +26,15 @@ const warningExcess = computed(() =>
|
|||||||
: 0,
|
: 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
const top30WithIcon = computed(() =>
|
const top30WithIcon = computed(() => stats.value.top30)
|
||||||
stats.value.top30.map(p => ({ ...p, emoji: iconOf(p.name) })),
|
|
||||||
)
|
|
||||||
|
|
||||||
// 概览只展示需关注项(预警 + 异常),完整列表去检索页
|
// 概览只展示需关注项(预警 + 异常),完整列表去检索页
|
||||||
const attentionPackages = computed(() =>
|
const attentionPackages = computed(() =>
|
||||||
state.packages.filter(p => p.status === 'warn' || p.status === 'broken'),
|
state.packages.filter(p => p.status === 'warn' || p.status === 'broken'),
|
||||||
)
|
)
|
||||||
|
|
||||||
async function openDrawer(pkg) {
|
function openDrawer(pkg) {
|
||||||
|
// 版本明细由 PackageDrawer 打开后按需加载
|
||||||
drawerPkg.value = pkg
|
drawerPkg.value = pkg
|
||||||
drawerVisible.value = true
|
drawerVisible.value = true
|
||||||
}
|
}
|
||||||
@@ -105,25 +84,21 @@ function quickClean() {
|
|||||||
|
|
||||||
<!-- 第二行:辅助指标 5 -->
|
<!-- 第二行:辅助指标 5 -->
|
||||||
<div class="stats-2">
|
<div class="stats-2">
|
||||||
<StatMini tone="blue" label="访问排行" ico="🏆" main="Top 30" :sub="top30WithIcon.slice(0, 3).map(p => p.name).join(' · ')" link="展开" @link="showTop30 = true" />
|
<StatMini tone="blue" label="今日更新包数" ico="🆕" :main="String(stats.todayUpdated)" sub="今日有版本更新" link="查看" @link="router.push('/search')" />
|
||||||
<StatMini tone="green" label="今日下载量" ico="📈" main="1,234" sub="较昨日 +18%" />
|
<StatMini tone="green" label="缓存版本总数" ico="🧩" :main="stats.totalVersions.toLocaleString()" sub="全部缓存包的版本数" link="查看" @link="router.push('/search')" />
|
||||||
<StatMini tone="orange" label="最大包" ico="🐘" :main="stats.maxPkg?.name || '—'" :unit="stats.maxPkg ? stats.maxPkg.size + ' MB' : ''" :sub="`${stats.maxPkg?.versions || 0} 个版本`" @link="stats.maxPkg && openDrawer(stats.maxPkg)" />
|
<StatMini tone="orange" label="最大包" ico="🐘" :main="stats.maxPkg?.name || '—'" :unit="stats.maxPkg ? stats.maxPkg.size + ' MB' : ''" :sub="`${stats.maxPkg?.versions || 0} 个版本`" link="详情" @link="stats.maxPkg && openDrawer(stats.maxPkg)" />
|
||||||
<StatMini tone="purple" label="最老包时间" ico="⏳" :main="stats.oldestPkg?.lastUpdated || '—'" unit="⏰" :sub="stats.oldestPkg ? `${stats.oldestPkg.name} · 未更新` : ''" @link="stats.oldestPkg && openDrawer(stats.oldestPkg)" />
|
<StatMini tone="purple" label="最老包时间" ico="⏳" :main="stats.oldestPkg?.lastUpdated || '—'" unit="⏰" :sub="stats.oldestPkg ? `${stats.oldestPkg.name} · 未更新` : ''" link="详情" @link="stats.oldestPkg && openDrawer(stats.oldestPkg)" />
|
||||||
<StatMini tone="pink" label="版本数分布" ico="🧩" main=">15 版本" :unit="`${stats.warnCount} 个`" :sub="`超阈值共 ${warningExcess} 个版本`" />
|
<StatMini tone="pink" label="版本数分布" ico="🧩" main=">15 版本" :unit="`${stats.warnCount} 个`" :sub="`超阈值共 ${warningExcess} 个版本`" link="查看" @link="router.push({ path: '/search', query: { status: 'warn' } })" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Top 30 排行(可收起) -->
|
<!-- Top 20 排行(可收起,点击行打开详情) -->
|
||||||
<Top30Panel v-if="showTop30" :top30="top30WithIcon" :top30-by="stats.top30By" />
|
<Top30Panel v-if="showTop30" :top30="top30WithIcon" :top30-by="stats.top30By" @open="openDrawer" />
|
||||||
|
|
||||||
<!-- 包卡片列表:仅需关注项 -->
|
<!-- 包卡片列表:仅需关注项 -->
|
||||||
<div class="sec-head">
|
<div class="sec-head">
|
||||||
<span class="sec-title"><span class="emo">⚠️</span>需要关注</span>
|
<span class="sec-title"><span class="emo">⚠️</span>需要关注</span>
|
||||||
<span class="sec-sub">共 {{ attentionPackages.length }} 个需关注 · 预警置顶</span>
|
<span class="sec-sub">共 {{ attentionPackages.length }} 个需关注 · 预警置顶</span>
|
||||||
<span class="sec-actions">
|
<button class="link-all" @click="router.push('/search')">查看全部 →</button>
|
||||||
<span v-if="state.scannedAt" class="scan-stamp">🕐 基于 {{ fmtScanTime }} 扫描</span>
|
|
||||||
<button class="btn ghost mini" :disabled="scanning" @click="rescan">{{ scanning ? '⏳ 扫描中…' : '🔄 重新扫描' }}</button>
|
|
||||||
<button class="link-all" @click="router.push('/search')">查看全部 →</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="state.loading" class="loading-tip">⏳ 加载中…</div>
|
<div v-if="state.loading" class="loading-tip">⏳ 加载中…</div>
|
||||||
@@ -174,9 +149,6 @@ function quickClean() {
|
|||||||
.sec-title { font-size: 17px; font-weight: 700; display: flex; align-items: center; gap: 10px; }
|
.sec-title { font-size: 17px; font-weight: 700; display: flex; align-items: center; gap: 10px; }
|
||||||
.sec-title .emo { font-size: 19px; }
|
.sec-title .emo { font-size: 19px; }
|
||||||
.sec-sub { font-family: var(--num); font-size: 12px; color: var(--ink-faint); font-weight: 600; }
|
.sec-sub { font-family: var(--num); font-size: 12px; color: var(--ink-faint); font-weight: 600; }
|
||||||
.sec-actions { display: flex; align-items: center; gap: 10px; margin-left: auto; }
|
|
||||||
.scan-stamp { font-family: var(--num); font-size: 11.5px; color: var(--ink-faint); font-weight: 600; }
|
|
||||||
.btn.mini { padding: 6px 13px; font-size: 12.5px; border-radius: 999px; }
|
|
||||||
.link-all { padding: 6px 14px; border: none; border-radius: 999px; background: var(--panel-soft); color: var(--orange); font-size: 13px; font-weight: 600; cursor: pointer; transition: transform .2s, box-shadow .2s; }
|
.link-all { padding: 6px 14px; border: none; border-radius: 999px; background: var(--panel-soft); color: var(--orange); font-size: 13px; font-weight: 600; cursor: pointer; transition: transform .2s, box-shadow .2s; }
|
||||||
.link-all:hover { transform: translateY(-1px); box-shadow: var(--shadow); }
|
.link-all:hover { transform: translateY(-1px); box-shadow: var(--shadow); }
|
||||||
.pkg-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
.pkg-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||||||
@@ -186,13 +158,17 @@ function quickClean() {
|
|||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.stats-1 { grid-template-columns: repeat(2, 1fr); }
|
.stats-1 { grid-template-columns: repeat(2, 1fr); }
|
||||||
.stats-2 { grid-template-columns: repeat(3, 1fr); }
|
.stats-2 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.stats-2 > :nth-child(5) { grid-column: 1 / -1; } /* 5 卡跨列铺满,避免 2+2+1 */
|
||||||
.pkg-grid { grid-template-columns: repeat(2, 1fr); }
|
.pkg-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
}
|
}
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.stats-1 { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
.stats-1 { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||||
.stats-2 { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
.stats-2 { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||||
|
.stats-2 > :nth-child(5) { grid-column: 1 / -1; } /* 第 5 张跨整行 */
|
||||||
.pkg-grid { grid-template-columns: 1fr; gap: 12px; }
|
.pkg-grid { grid-template-columns: 1fr; gap: 12px; }
|
||||||
|
.sec-head { padding: 4px 0 12px; gap: 8px; }
|
||||||
.sec-title { font-size: 15px; }
|
.sec-title { font-size: 15px; }
|
||||||
.sec-sub { font-size: 11px; }
|
.sec-sub { font-size: 11px; }
|
||||||
|
.link-all { font-size: 12px; padding: 5px 10px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import PackageDrawer from '../components/PackageDrawer.vue'
|
import PackageDrawer from '../components/PackageDrawer.vue'
|
||||||
import CleanDialog from '../components/CleanDialog.vue'
|
import CleanDialog from '../components/CleanDialog.vue'
|
||||||
import StatusTag from '../components/StatusTag.vue'
|
import StatusTag from '../components/StatusTag.vue'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
|
|
||||||
const { state, stats, refreshPackages, ignorePkg, unignorePkg, cleanPkg, deleteVersion } = useStore()
|
const { state, stats, refreshPackages, ignorePkg, unignorePkg, cleanPkg, deleteVersion } = useStore()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const statusFilter = ref('all') // all | warn | ok | ignored | broken
|
const statusFilter = ref('all') // all | warn | ok | ignored | broken
|
||||||
const sortMode = ref('default') // default | versions-desc | size-desc
|
const sortMode = ref('default') // default | versions-desc | size-desc
|
||||||
let debounceTimer = null
|
let debounceTimer = null
|
||||||
|
|
||||||
onMounted(() => refreshPackages())
|
onMounted(() => {
|
||||||
|
// 数据由 App 初始化时统一加载,切换菜单不再重复拉取全量列表(响应大,避免卡顿)
|
||||||
|
if (route.query.status && ['all', 'warn', 'ok', 'ignored', 'broken'].includes(route.query.status)) {
|
||||||
|
statusFilter.value = route.query.status
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch(keyword, () => {
|
watch(keyword, () => {
|
||||||
clearTimeout(debounceTimer)
|
clearTimeout(debounceTimer)
|
||||||
@@ -46,6 +53,7 @@ const cleanVisible = ref(false)
|
|||||||
const cleanPkgData = ref(null)
|
const cleanPkgData = ref(null)
|
||||||
|
|
||||||
function openDrawer(pkg) {
|
function openDrawer(pkg) {
|
||||||
|
// 版本明细由 PackageDrawer 打开后按需加载
|
||||||
drawerPkg.value = pkg
|
drawerPkg.value = pkg
|
||||||
drawerVisible.value = true
|
drawerVisible.value = true
|
||||||
}
|
}
|
||||||
@@ -115,9 +123,9 @@ async function onCleanConfirmed(res) {
|
|||||||
<th>包名</th>
|
<th>包名</th>
|
||||||
<th style="width: 90px">版本数</th>
|
<th style="width: 90px">版本数</th>
|
||||||
<th style="width: 110px">占用空间</th>
|
<th style="width: 110px">占用空间</th>
|
||||||
<th style="width: 110px">最近更新</th>
|
<th style="width: 120px">最近更新</th>
|
||||||
<th style="width: 110px">状态</th>
|
<th style="width: 100px">状态</th>
|
||||||
<th style="width: 130px">操作</th>
|
<th style="width: 160px">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -262,6 +270,10 @@ async function onCleanConfirmed(res) {
|
|||||||
.result-note b { color: var(--orange); }
|
.result-note b { color: var(--orange); }
|
||||||
.pkg-cell { font-weight: 600; word-break: break-all; }
|
.pkg-cell { font-weight: 600; word-break: break-all; }
|
||||||
.num-cell.over { color: var(--red); }
|
.num-cell.over { color: var(--red); }
|
||||||
|
/* 操作列:按钮横排一行,不换行 */
|
||||||
|
.vtable td:last-child { white-space: nowrap; }
|
||||||
|
.vtable td:last-child .btn { padding: 6px 13px; font-size: 12px; }
|
||||||
|
.vtable td:last-child .btn + .btn { margin-left: 7px; }
|
||||||
.empty-tip { padding: 40px 0; text-align: center; color: var(--ink-faint); font-size: 12.5px; }
|
.empty-tip { padding: 40px 0; text-align: center; color: var(--ink-faint); font-size: 12.5px; }
|
||||||
.loading-tip { text-align: center; padding: 40px 0; color: var(--ink-faint); font-size: 13px; }
|
.loading-tip { text-align: center; padding: 40px 0; color: var(--ink-faint); font-size: 13px; }
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user