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 = []
|
||||
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)
|
||||
|
||||
for (const pkgFile of pkgFiles) {
|
||||
const pkgDir = path.dirname(pkgFile)
|
||||
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'] || {}
|
||||
for (const pkgDir of pkgDirs) {
|
||||
const pkgFile = path.join(pkgDir, 'package.json')
|
||||
|
||||
// 物理 .tgz 文件:先列目录建立文件名 → 大小索引
|
||||
// (Verdaccio 各版本命名不同:{version}.tgz 或 {pkgname}-{version}.tgz,不猜、直接扫)
|
||||
// 先统计再读元数据:即使元数据损坏也能算出真实占用
|
||||
const tgzSizes = new Map()
|
||||
let tgzTotal = 0
|
||||
try {
|
||||
@@ -131,8 +120,36 @@ export function scanStorage(storageDir) {
|
||||
}
|
||||
} 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 = []
|
||||
// 总大小以实际 .tgz 文件为准;unpackedSize 是解压后大小,会数倍虚增,仅作无物理文件时的兜底
|
||||
// 总大小以实际 .tgz 文件为准
|
||||
const totalBytes = tgzTotal
|
||||
for (const [v, info] of Object.entries(versionsObj)) {
|
||||
let size = tgzSizes.get(v)
|
||||
@@ -145,9 +162,8 @@ export function scanStorage(storageDir) {
|
||||
if (typeof hit === 'number') size = hit
|
||||
}
|
||||
}
|
||||
if (typeof size !== 'number') {
|
||||
size = (info && info.dist && typeof info.dist.unpackedSize === 'number') ? info.dist.unpackedSize : 0
|
||||
}
|
||||
// 无物理 .tgz 文件的版本是元数据残留(实体已被清理),不计数,避免版本数虚高
|
||||
if (typeof size !== 'number') continue
|
||||
versionsDetail.push({
|
||||
version: parseVersion(v),
|
||||
size: fmtMb(size),
|
||||
@@ -169,6 +185,11 @@ export function scanStorage(storageDir) {
|
||||
lastUpdated,
|
||||
visits: 0, // 真实环境无访问统计,界面不依赖真实值
|
||||
distTags,
|
||||
description,
|
||||
homepage,
|
||||
repository,
|
||||
author,
|
||||
license,
|
||||
versionsDetail,
|
||||
})
|
||||
}
|
||||
@@ -176,6 +197,115 @@ export function scanStorage(storageDir) {
|
||||
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 模式数据(无真实目录时的演示) ——
|
||||
const DEMO_SEED = [
|
||||
['lodash', 25, 4, 12456], ['@antv/g2', 32, 12, 7654], ['axios', 18, 1.2, 9832],
|
||||
@@ -212,10 +342,29 @@ function genVersions(count) {
|
||||
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() {
|
||||
const seen = new Set()
|
||||
return DEMO_SEED.filter(([n]) => !seen.has(n) && seen.add(n)).map(([name, verCount, sizeBase, visits]) => {
|
||||
const detail = genVersions(verCount)
|
||||
const meta = DEMO_META[name] || {}
|
||||
return {
|
||||
name,
|
||||
versions: detail.length,
|
||||
@@ -223,6 +372,11 @@ export function demoPackages() {
|
||||
lastUpdated: detail.length ? detail[detail.length - 1].date : null,
|
||||
visits,
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user