From 0a9ca8894a1fee59cf006e01bac4cf47556f1552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E9=93=B6=E5=86=9B?= Date: Sat, 19 Sep 2026 15:00:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Verdaccio=20=E7=BC=93=E5=AD=98=E5=8F=AF?= =?UTF-8?q?=E8=A7=86=E5=8C=96=E7=AE=A1=E7=90=86=E9=9D=A2=E6=9D=BF=EF=BC=88?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=20+=20=E5=90=8E=E7=AB=AF=20+=20Docker?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端 Vue3+Vite+Element Plus 按需引入,双主题(bright/dark)CSS 变量 - 三端响应式(桌面/平板/手机),登录/概览/检索/阈值/忽略/动态 6 页面 - 后端 Express + better-sqlite3,目录扫描 + demo 模式,清理原子写入 - Dockerfile 多阶段构建 + docker-compose(storage 只读挂载) --- .dockerignore | 12 + .gitignore | 16 + Dockerfile | 32 + backend/clean.js | 166 +++ backend/db.js | 86 ++ backend/package.json | 21 + backend/pnpm-lock.yaml | 996 ++++++++++++++++++ backend/scanner.js | 221 ++++ backend/semver.js | 22 + backend/server.js | 271 +++++ docker-compose.yml | 22 + frontend/.gitignore | 24 + frontend/index.html | 13 + frontend/package.json | 22 + frontend/pnpm-lock.yaml | 1136 +++++++++++++++++++++ frontend/public/favicon.svg | 10 + frontend/src/App.vue | 89 ++ frontend/src/api/index.js | 211 ++++ frontend/src/components/AppTopbar.vue | 183 ++++ frontend/src/components/CleanDialog.vue | 231 +++++ frontend/src/components/MobileTabbar.vue | 68 ++ frontend/src/components/PackageCard.vue | 136 +++ frontend/src/components/PackageDrawer.vue | 291 ++++++ frontend/src/components/SettingDrawer.vue | 173 ++++ frontend/src/components/StatCard.vue | 112 ++ frontend/src/components/StatMini.vue | 78 ++ frontend/src/components/StatusTag.vue | 34 + frontend/src/components/Top30Panel.vue | 138 +++ frontend/src/composables/useTheme.js | 17 + frontend/src/main.js | 14 + frontend/src/mock/data.js | 192 ++++ frontend/src/router/index.js | 31 + frontend/src/store/index.js | 122 +++ frontend/src/style.css | 296 ++++++ frontend/src/styles/base.css | 253 +++++ frontend/src/styles/tokens.css | 100 ++ frontend/src/views/IgnoredView.vue | 69 ++ frontend/src/views/LoginView.vue | 200 ++++ frontend/src/views/LogsView.vue | 148 +++ frontend/src/views/OverviewView.vue | 161 +++ frontend/src/views/SearchView.vue | 277 +++++ frontend/src/views/ThresholdsView.vue | 311 ++++++ frontend/vite.config.js | 31 + 43 files changed, 7036 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 backend/clean.js create mode 100644 backend/db.js create mode 100644 backend/package.json create mode 100644 backend/pnpm-lock.yaml create mode 100644 backend/scanner.js create mode 100644 backend/semver.js create mode 100644 backend/server.js create mode 100644 docker-compose.yml create mode 100644 frontend/.gitignore create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/pnpm-lock.yaml create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/index.js create mode 100644 frontend/src/components/AppTopbar.vue create mode 100644 frontend/src/components/CleanDialog.vue create mode 100644 frontend/src/components/MobileTabbar.vue create mode 100644 frontend/src/components/PackageCard.vue create mode 100644 frontend/src/components/PackageDrawer.vue create mode 100644 frontend/src/components/SettingDrawer.vue create mode 100644 frontend/src/components/StatCard.vue create mode 100644 frontend/src/components/StatMini.vue create mode 100644 frontend/src/components/StatusTag.vue create mode 100644 frontend/src/components/Top30Panel.vue create mode 100644 frontend/src/composables/useTheme.js create mode 100644 frontend/src/main.js create mode 100644 frontend/src/mock/data.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/store/index.js create mode 100644 frontend/src/style.css create mode 100644 frontend/src/styles/base.css create mode 100644 frontend/src/styles/tokens.css create mode 100644 frontend/src/views/IgnoredView.vue create mode 100644 frontend/src/views/LoginView.vue create mode 100644 frontend/src/views/LogsView.vue create mode 100644 frontend/src/views/OverviewView.vue create mode 100644 frontend/src/views/SearchView.vue create mode 100644 frontend/src/views/ThresholdsView.vue create mode 100644 frontend/vite.config.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..03da613 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +# node_modules 与产物 +node_modules +frontend/node_modules +frontend/dist +backend/node_modules +backend/data +manager-data +storage +.git +doc +*.log +.DS_Store diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c71036 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# 依赖与构建产物 +node_modules/ +frontend/node_modules/ +frontend/dist/ +backend/node_modules/ + +# 运行时数据(SQLite) +backend/data/ +manager-data/ +storage/ + +# 系统与编辑器 +.DS_Store +*.log +.env +.env.local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cb59edc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# ============ 阶段一:构建前端(Vue3 + Vite) ============ +FROM node:18-slim AS frontend-build +WORKDIR /build +RUN corepack enable +COPY frontend/package.json frontend/pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile +COPY frontend/ ./ +RUN pnpm build + +# ============ 阶段二:运行时(Express + better-sqlite3) ============ +FROM node:18-slim +ENV NODE_ENV=production \ + PORT=3000 \ + DATA_DIR=/app/data + +WORKDIR /app +RUN corepack enable + +# 后端依赖(better-sqlite3 在 node:18 有预编译二进制,无需编译工具链) +COPY backend/package.json backend/pnpm-lock.yaml ./ +RUN pnpm install --prod --frozen-lockfile + +COPY backend/ ./ +# 前端产物托管在 /frontend/dist(server.js 中 distDir 指向 ../frontend/dist) +COPY --from=frontend-build /build/dist /frontend/dist + +# 非 root 运行(§3.7 安全:UID 10000) +RUN mkdir -p /app/data && chown -R 10000:10000 /app/data +USER 10000:10000 + +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/backend/clean.js b/backend/clean.js new file mode 100644 index 0000000..c471962 --- /dev/null +++ b/backend/clean.js @@ -0,0 +1,166 @@ +/** + * 预警清理原子逻辑(§3.4 / AC-7 原子性) + * 1. 读 package.json + * 2. 按 semver 排序保留最新 N 个 + * 3. 删低版本 versions/time 记录 + * 4. 修正 dist-tags(latest 指向已删版本时指向保留的最新) + * 5. 写回元数据 + * 6. 物理删 .tgz + * 7. 记日志 + * 8. 返回「删除数量、释放空间」 + * 失败时不做任何文件修改(先备份→写临时文件→rename 提交) + */ +import fs from 'node:fs' +import path from 'node:path' +import semverCompare from './semver.js' + +export function cleanPackage(storageDir, pkgName, keep) { + const pkgDir = locatePackageDir(storageDir, pkgName) + if (!pkgDir) return { ok: false, message: `未找到包 ${pkgName}` } + + const pkgFile = path.join(pkgDir, 'package.json') + let meta + try { + meta = JSON.parse(fs.readFileSync(pkgFile, 'utf8')) + } catch { + return { ok: false, message: `读取 ${pkgName} 元数据失败` } + } + + const versions = Object.keys(meta.versions || {}) + if (!versions.length) return { ok: false, message: '包内没有可清理的版本' } + + // 按 semver 数值排序(旧 → 新),保留最新 keep 个 + const sorted = versions.sort((a, b) => semverCompare(a, b)) + const toDelete = sorted.slice(0, Math.max(0, sorted.length - keep)) + if (!toDelete.length) { + return { ok: false, message: '版本数未超过保留值,无需清理' } + } + + // —— 准备提交内容 —— + const nextMeta = JSON.parse(JSON.stringify(meta)) + const time = nextMeta.time || {} + const freed = [] + for (const v of toDelete) { + const info = nextMeta.versions[v] || {} + delete nextMeta.versions[v] + delete time[v] + // 物理文件大小(用于释放空间统计) + const tgz = path.join(pkgDir, `${v}.tgz`) + try { + freed.push({ v, size: fs.statSync(tgz).size, tgz }) + } catch { + freed.push({ v, size: info.dist?.unpackedSize || 0, tgz }) + } + } + nextMeta.time = time + + // —— 修正 dist-tags —— + const distTags = nextMeta['dist-tags'] || {} + const kept = sorted.slice(-keep) + for (const [tag, v] of Object.entries(distTags)) { + if (toDelete.includes(v)) { + distTags[tag] = kept[kept.length - 1] + } + } + nextMeta['dist-tags'] = distTags + + // —— 原子提交:临时文件写入 → rename —— + const tmp = `${pkgFile}.clean.tmp` + try { + fs.writeFileSync(tmp, JSON.stringify(nextMeta, null, 2)) + fs.renameSync(tmp, pkgFile) // 同目录 rename 为原子操作 + } catch (err) { + try { fs.unlinkSync(tmp) } catch { /* ignore */ } + return { ok: false, message: `写回元数据失败:${err.message}` } + } + + // —— 物理删除 tgz(元数据已提交,删除失败仅记录不阻塞) —— + const deletedBytes = freed.reduce((s, f) => s + f.size, 0) + for (const f of freed) { + try { + if (f.tgz && fs.existsSync(f.tgz)) fs.unlinkSync(f.tgz) + } catch { + /* 忽略单个文件删除失败 */ + } + } + + const freedText = `${(deletedBytes / 1024 / 1024).toFixed(1)} MB` + return { + ok: true, + pkgName, + deletedVersions: toDelete.join(', '), + deletedCount: toDelete.length, + freedSpace: freedText, + freedBytes: deletedBytes, + } +} + +/** 删除单个版本(抽屉内单版本删除) */ +export function deleteSingleVersion(storageDir, pkgName, version) { + const pkgDir = locatePackageDir(storageDir, pkgName) + if (!pkgDir) return { ok: false, message: `未找到包 ${pkgName}` } + const pkgFile = path.join(pkgDir, 'package.json') + + let meta + try { + meta = JSON.parse(fs.readFileSync(pkgFile, 'utf8')) + } catch { + return { ok: false, message: '读取元数据失败' } + } + if (!meta.versions || !meta.versions[version]) { + return { ok: false, message: `版本 ${version} 不存在` } + } + + const nextMeta = JSON.parse(JSON.stringify(meta)) + const info = nextMeta.versions[version] + delete nextMeta.versions[version] + if (nextMeta.time) delete nextMeta.time[version] + + // 修正 dist-tags + const distTags = nextMeta['dist-tags'] || {} + const remaining = Object.keys(nextMeta.versions).sort((a, b) => semverCompare(a, b)) + for (const [tag, v] of Object.entries(distTags)) { + if (v === version) distTags[tag] = remaining[remaining.length - 1] || version + } + nextMeta['dist-tags'] = distTags + + const tmp = `${pkgFile}.clean.tmp` + try { + fs.writeFileSync(tmp, JSON.stringify(nextMeta, null, 2)) + fs.renameSync(tmp, pkgFile) + } catch (err) { + try { fs.unlinkSync(tmp) } catch { /* ignore */ } + return { ok: false, message: `写回元数据失败:${err.message}` } + } + + let size = info.dist?.unpackedSize || 0 + const tgz = path.join(pkgDir, `${version}.tgz`) + try { + if (fs.existsSync(tgz)) { + size = fs.statSync(tgz).size + fs.unlinkSync(tgz) + } + } catch { /* ignore */ } + + return { + ok: true, + freedSpace: `${(size / 1024 / 1024).toFixed(1)} MB`, + } +} + +/** 定位包目录(支持 scoped:@scope/name 嵌套目录) */ +export function locatePackageDir(storageDir, pkgName) { + const parts = pkgName.split('/') + const candidates = [] + if (parts.length === 2 && parts[0].startsWith('@')) { + candidates.push(path.join(storageDir, parts[0], parts[1])) + } else { + candidates.push(path.join(storageDir, parts[parts.length - 1])) + // 兼容 scoped 包以全名作为单一目录的情况 + if (parts.length === 2) candidates.push(path.join(storageDir, pkgName)) + } + for (const c of candidates) { + if (fs.existsSync(path.join(c, 'package.json'))) return c + } + return null +} diff --git a/backend/db.js b/backend/db.js new file mode 100644 index 0000000..f6314fb --- /dev/null +++ b/backend/db.js @@ -0,0 +1,86 @@ +/** + * SQLite 数据层(§4.1) + * 表:settings / ignored_packages / logs + */ +import Database from 'better-sqlite3' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +// 数据目录:环境变量 DATA_DIR 可覆盖(Docker 挂载 manager-data) +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data') +fs.mkdirSync(DATA_DIR, { recursive: true }) + +const db = new Database(path.join(DATA_DIR, 'manager.db')) +db.pragma('journal_mode = WAL') + +db.exec(` +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT +); +CREATE TABLE IF NOT EXISTS ignored_packages ( + pkg_name TEXT PRIMARY KEY, + ignored_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pkg_name TEXT, + deleted_versions TEXT, + deleted_count INTEGER, + freed_space TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +`) + +// —— settings —— +export function getSetting(key, def = null) { + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) + return row ? row.value : def +} + +export function setSetting(key, value) { + db.prepare( + 'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', + ).run(key, String(value)) +} + +// —— ignored —— +export function getIgnored() { + return db.prepare('SELECT pkg_name FROM ignored_packages ORDER BY ignored_at DESC').all().map(r => r.pkg_name) +} + +export function getIgnoredRows() { + return db.prepare('SELECT pkg_name, ignored_at FROM ignored_packages ORDER BY ignored_at DESC').all() +} + +export function isIgnored(pkgName) { + return !!db.prepare('SELECT 1 FROM ignored_packages WHERE pkg_name = ?').get(pkgName) +} + +export function addIgnored(pkgName) { + db.prepare( + 'INSERT INTO ignored_packages (pkg_name) VALUES (?) ON CONFLICT(pkg_name) DO NOTHING', + ).run(pkgName) +} + +export function removeIgnored(pkgName) { + db.prepare('DELETE FROM ignored_packages WHERE pkg_name = ?').run(pkgName) +} + +// —— logs —— +export function addLog({ pkgName, deletedVersions, deletedCount, freedSpace }) { + const info = db.prepare( + 'INSERT INTO logs (pkg_name, deleted_versions, deleted_count, freed_space) VALUES (?, ?, ?, ?)', + ).run(pkgName, deletedVersions, deletedCount, freedSpace) + return info.lastInsertRowid +} + +export function getLogs(limit = 50) { + return db.prepare( + 'SELECT * FROM logs ORDER BY id DESC LIMIT ?', + ).all(limit) +} + +export default db diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..a0e6c70 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,21 @@ +{ + "name": "verdaccio-cache-manager-backend", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "better-sqlite3": "^11.10.0", + "express": "^4.21.2" + }, + "devDependencies": { + "node-gyp": "^13.0.2" + } +} diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml new file mode 100644 index 0000000..29c408d --- /dev/null +++ b/backend/pnpm-lock.yaml @@ -0,0 +1,996 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + better-sqlite3: + specifier: ^11.10.0 + version: 11.10.0 + express: + specifier: ^4.21.2 + version: 4.22.3 + devDependencies: + node-gyp: + specifier: ^13.0.2 + version: 13.0.2 + +packages: + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + abbrev@5.0.0: + resolution: {integrity: sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + better-sqlite3@11.10.0: + resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + body-parser@1.20.8: + resolution: {integrity: sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + express@4.22.3: + resolution: {integrity: sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==} + engines: {node: '>= 0.10.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + node-abi@3.96.0: + resolution: {integrity: sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==} + engines: {node: '>=10'} + + node-gyp@13.0.2: + resolution: {integrity: sha512-SXTvw3PxznpowYhJSOD9mVQBgDaCTWXffX+wQZsQ7PbcTV86TsXCUcSZhHnskFQvrvn/OdSzJPMsptT2pIj9ww==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + hasBin: true + + nopt@10.0.1: + resolution: {integrity: sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + hasBin: true + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + proc-log@7.0.0: + resolution: {integrity: sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + undici@8.10.2: + resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} + engines: {node: '>=22.19.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + which@7.0.0: + resolution: {integrity: sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + +snapshots: + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + abbrev@5.0.0: {} + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + array-flatten@1.1.1: {} + + base64-js@1.5.1: {} + + better-sqlite3@11.10.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + body-parser@1.20.8: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chownr@1.1.4: {} + + chownr@3.0.0: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + detect-libc@2.1.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + env-paths@2.2.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + expand-template@2.0.3: {} + + exponential-backoff@3.1.3: {} + + express@4.22.3: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.8 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.8 + qs: 6.16.0 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + file-uri-to-path@1.0.0: {} + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + fs-constants@1.0.0: {} + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + github-from-package@0.0.0: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ipaddr.js@1.9.1: {} + + isexe@4.0.0: {} + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + mimic-response@3.1.0: {} + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + mkdirp-classic@0.5.3: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + napi-build-utils@2.0.0: {} + + negotiator@0.6.3: {} + + node-abi@3.96.0: + dependencies: + semver: 7.8.5 + + node-gyp@13.0.2: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.3 + graceful-fs: 4.2.11 + nopt: 10.0.1 + proc-log: 7.0.0 + semver: 7.8.5 + tar: 7.5.22 + tinyglobby: 0.2.17 + undici: 8.10.2 + which: 7.0.0 + + nopt@10.0.1: + dependencies: + abbrev: 5.0.0 + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-to-regexp@0.1.13: {} + + picomatch@4.0.7: {} + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.96.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + + proc-log@7.0.0: {} + + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + semver@7.8.5: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + statuses@2.0.2: {} + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-json-comments@2.0.1: {} + + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + toidentifier@1.0.1: {} + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + undici@8.10.2: {} + + unpipe@1.0.0: {} + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + vary@1.1.2: {} + + which@7.0.0: + dependencies: + isexe: 4.0.0 + + wrappy@1.0.2: {} + + yallist@5.0.0: {} diff --git a/backend/scanner.js b/backend/scanner.js new file mode 100644 index 0000000..44b6d40 --- /dev/null +++ b/backend/scanner.js @@ -0,0 +1,221 @@ +/** + * Verdaccio 缓存目录扫描(§12.1) + * - storage 目录解析优先级:VERDACCIO_STORAGE 环境变量 → UI 设置(settings.storage_dir) + * - 递归扫描目录内所有 package.json,解析 versions/time,匹配 .tgz 计算占用,支持 scoped 包 + * - 未配置或无效时进入 demo 模式(VCM_DEMO=1 或首次无配置) + */ +import fs from 'node:fs' +import path from 'node:path' +import { getSetting } from './db.js' + +// —— storage 目录解析 —— +export function resolveStorageDir() { + return process.env.VERDACCIO_STORAGE || getSetting('storage_dir') || null +} + +export function isValidStorage(dir) { + if (!dir) return false + try { + const st = fs.statSync(dir) + if (!st.isDirectory()) return false + fs.accessSync(dir, fs.constants.R_OK) + return true + } catch { + return false + } +} + +export function validateStorage(dir) { + if (!isValidStorage(dir)) return { ok: false, message: '目录不存在或不可读' } + // 至少包含 package.json 或 .tgz 之一,才算有效缓存目录 + const files = listFiles(dir, 1) + const hasPkg = files.some(f => f.endsWith('package.json')) + const hasTgz = files.some(f => f.endsWith('.tgz')) + if (!hasPkg && !hasTgz) { + return { ok: false, message: '目录中未找到 package.json / .tgz,不是有效的 Verdaccio 缓存目录' } + } + return { ok: true, message: 'ok' } +} + +function listFiles(dir, depth = Infinity, out = []) { + let entries + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch { + return out + } + for (const e of entries) { + if (e.name === 'node_modules' || e.name.startsWith('.') || e.name === '.DS_Store') continue + const full = path.join(dir, e.name) + if (e.isDirectory()) { + if (depth > 0) listFiles(full, depth - 1, out) + } else { + out.push(full) + } + } + return out +} + +function readJson(file) { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')) + } catch { + return null + } +} + +function fmtMb(bytes) { + return Number((bytes / 1024 / 1024).toFixed(1)) +} + +// 目录名 → 包名(支持 scoped:@scope/name) +function dirToPkgName(segments) { + if (segments.length >= 2 && segments[0].startsWith('@')) { + return `${segments[0]}/${segments[1]}` + } + return segments[segments.length - 1] +} + +// 从 package.json 所在目录向上推导包名 +function pkgNameFromDir(pkgDir, storageDir) { + const rel = path.relative(storageDir, pkgDir).split(path.sep) + return dirToPkgName(rel) +} + +function parseVersion(v) { + // 去前缀 v、prerelease 后缀仅比较主体 + return (String(v).replace(/^v/, '') || '').trim() +} + +/** + * 扫描 storage 目录 + * @returns {Array} 包列表(versionsDetail: [{version, size(MB), date}]) + */ +export function scanStorage(storageDir) { + const packages = [] + if (!isValidStorage(storageDir)) return packages + + const pkgFiles = listFiles(storageDir).filter(f => f.endsWith('package.json')) + 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'] || {} + + const versionsDetail = [] + let totalBytes = 0 + for (const [v, info] of Object.entries(versionsObj)) { + // tgz 物理文件:Verdaccio 约定 {pkgDir}/{version}.tgz + const tgz = path.join(pkgDir, `${v}.tgz`) + let size = 0 + try { + size = fs.statSync(tgz).size + } catch { + // 无物理文件:用元数据 dist.tarball 大小兜底(无则 0) + size = (info && info.dist && typeof info.dist.unpackedSize === 'number') ? info.dist.unpackedSize : 0 + } + totalBytes += size + versionsDetail.push({ + version: parseVersion(v), + size: fmtMb(size), + date: timeObj[v] ? timeObj[v].slice(0, 10) : null, + }) + } + // 按 semver 数值排序(旧 → 新) + versionsDetail.sort((a, b) => a.version.localeCompare(b.version, undefined, { numeric: true })) + + // 最近更新时间:取 time 字段最大 + 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, + versionsDetail, + }) + } + + return packages +} + +// —— demo 模式数据(无真实目录时的演示) —— +const DEMO_SEED = [ + ['lodash', 25, 4, 12456], ['@antv/g2', 32, 12, 7654], ['axios', 18, 1.2, 9832], + ['react', 14, 10, 6543], ['vue', 12, 9, 5218], ['express', 16, 0.8, 4376], + ['tensorflow', 24, 90, 2103], ['typescript', 20, 22, 3421], ['webpack', 19, 18, 2876], + ['echarts', 15, 16, 1987], ['dayjs', 8, 0.6, 1789], ['uuid', 11, 0.4, 1654], + ['vite', 9, 14, 1432], ['prisma', 7, 28, 1120], ['pg', 6, 5, 986], + ['three', 10, 30, 876], ['sharp', 5, 24, 654], ['chalk', 6, 0.3, 743], + ['semver', 4, 0.3, 532], ['zod', 5, 0.5, 421], + ['prettier', 3, 1, 388], ['rollup', 8, 6, 356], ['eslint', 13, 8, 342], + ['moment', 20, 2, 312], ['mocha', 7, 1.4, 301], ['cheerio', 9, 3, 287], + ['@vue/compiler-sfc', 11, 5, 265], ['ioredis', 10, 4, 254], ['graphql', 12, 7, 243], + ['socket.io', 6, 2, 231], ['xlsx', 14, 20, 218], ['d3', 9, 12, 205], + ['puppeteer', 4, 15, 187], ['sharp', 5, 24, 176], +] + +function pad(n) { return String(n).padStart(2, '0') } +function dateStr(offset) { + const d = new Date(Date.now() - offset * 86400000) + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}` +} +function rnd(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min } + +function genVersions(count) { + const list = [] + for (let i = 0; i < count; i++) { + list.push({ + version: `1.${rnd(0, 9)}.${rnd(0, 20)}`, + size: Number((rnd(5, 400) / 10).toFixed(1)), + date: dateStr((count - i) * rnd(3, 9)), + }) + } + list.sort((a, b) => a.version.localeCompare(b.version, undefined, { numeric: true })) + return list +} + +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) + return { + name, + versions: detail.length, + size: Number((detail.reduce((s, v) => s + v.size, 0)).toFixed(1)), + lastUpdated: detail.length ? detail[detail.length - 1].date : null, + visits, + distTags: { latest: detail.length ? detail[detail.length - 1].version : '1.0.0' }, + versionsDetail: detail, + } + }) +} + +export function demoSummary() { + return { + demo: true, + todayDownloads: 1234, + todayTrend: '+18%', + note: '演示模式:未配置 Verdaccio 缓存目录,展示内置演示数据。', + } +} diff --git a/backend/semver.js b/backend/semver.js new file mode 100644 index 0000000..b393f9c --- /dev/null +++ b/backend/semver.js @@ -0,0 +1,22 @@ +/** + * semver 数值比较(轻量实现,无第三方依赖) + * 支持 v 前缀与 prerelease 后缀(仅比较主版本号主体) + */ +export default function compare(a, b) { + const pa = parse(a) + const pb = parse(b) + for (let i = 0; i < 3; i++) { + if (pa[i] !== pb[i]) return pa[i] - pb[i] + } + return 0 +} + +function parse(v) { + const main = String(v).replace(/^v/, '').split(/[-+]/)[0] + const parts = main.split('.') + return [ + parseInt(parts[0], 10) || 0, + parseInt(parts[1], 10) || 0, + parseInt(parts[2], 10) || 0, + ] +} diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..667d296 --- /dev/null +++ b/backend/server.js @@ -0,0 +1,271 @@ +/** + * Verdaccio 缓存可视化管理面板 — 后端服务 + * Express + better-sqlite3,§4.2 REST API + * + * 环境变量: + * VERDACCIO_STORAGE Verdaccio 缓存目录(优先于 UI 设置) + * PORT 监听端口(默认 3000) + * DATA_DIR SQLite 数据目录(默认 backend/data) + */ +import express from 'express' +import path from 'node:path' +import fs from 'node:fs' +import { fileURLToPath } from 'node:url' +import { + getSetting, setSetting, getIgnored, getIgnoredRows, addIgnored, removeIgnored, + addLog, getLogs, +} from './db.js' +import { + resolveStorageDir, isValidStorage, validateStorage, scanStorage, + demoPackages, demoSummary, +} from './scanner.js' +import { cleanPackage, deleteSingleVersion } from './clean.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const app = express() +app.use(express.json({ limit: '1mb' })) + +const PORT = Number(process.env.PORT || 3000) +const DEFAULT_VERSION_THRESHOLD = 15 +const DEFAULT_SIZE_THRESHOLD = 500 + +// —— 当前生效的 storage 目录(每次请求动态解析,UI 保存后立即生效) —— +function currentStorage() { + return resolveStorageDir() +} + +function isDemoMode() { + return process.env.VCM_DEMO === '1' || !isValidStorage(currentStorage()) +} + +// 构建包列表(统一状态计算 + 排序 + 忽略标记) +function buildPackages(keyword = '') { + const threshold = Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)) + const ignoredRows = getIgnoredRows() + 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 demo = isDemoMode() + const raw = demo ? demoPackages() : scanStorage(currentStorage()) + + let list = raw.map(p => { + let status + if (p.status === 'broken') status = 'broken' + else if (ignoredSet.has(p.name)) status = 'ignored' + else status = p.versions > threshold ? 'warn' : 'ok' + return { + name: p.name, + versions: p.versions, + size: p.size, + lastUpdated: p.lastUpdated, + visits: p.visits || 0, + distTags: p.distTags || {}, + status, + ignoredAt: ignoredAtMap[p.name] || null, + versionsDetail: p.versionsDetail || [], + } + }) + + // 检索:不区分大小写子串,匹配包名 / 版本号 / 文件大小文本 + const kw = keyword.trim().toLowerCase() + if (kw) { + list = list.filter(p => + p.name.toLowerCase().includes(kw) || + (p.versionsDetail || []).some(v => v.version.toLowerCase().includes(kw)) || + String(p.size).includes(kw), + ) + } + + // 排序:预警置顶 → 正常 → 忽略 → 异常;同状态按版本数降序 + const rank = { warn: 0, ok: 1, ignored: 2, broken: 3 } + list.sort((a, b) => { + if (rank[a.status] !== rank[b.status]) return rank[a.status] - rank[b.status] + return b.versions - a.versions + }) + return list +} + +// —— API:阈值 —— +// spec §4.2:GET/POST /api/threshold(版本阈值) +app.get('/api/threshold', (req, res) => { + res.json({ versionThreshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)) }) +}) +app.post('/api/threshold', (req, res) => { + const v = clampInt(req.body.versionThreshold, 1, 100) + setSetting('version_threshold', v) + res.json({ ok: true, versionThreshold: v }) +}) + +// 扩展:GET/POST /api/thresholds(版本 + 大小 + 单包例外) +app.get('/api/thresholds', (req, res) => { + let exceptions = [] + try { exceptions = JSON.parse(getSetting('exceptions', '[]')) } catch { exceptions = [] } + res.json({ + versionThreshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)), + sizeThreshold: Number(getSetting('size_threshold', DEFAULT_SIZE_THRESHOLD)), + exceptions, + }) +}) +app.post('/api/thresholds', (req, res) => { + const { versionThreshold, sizeThreshold, exceptions } = req.body || {} + setSetting('version_threshold', clampInt(versionThreshold, 1, 100)) + setSetting('size_threshold', clampInt(sizeThreshold, 1, 10000)) + const ex = Array.isArray(exceptions) + ? exceptions.map(e => ({ + pkgName: String(e.pkgName || '').trim(), + versionThreshold: clampInt(e.versionThreshold, 1, 100), + sizeThreshold: clampInt(e.sizeThreshold, 1, 10000), + })).filter(e => e.pkgName) + : [] + setSetting('exceptions', JSON.stringify(ex)) + res.json({ ok: true }) +}) + +// —— API:忽略 —— +app.get('/api/ignored', (req, res) => { + res.json(getIgnored()) +}) +app.post('/api/ignore', (req, res) => { + const { pkgName } = req.body || {} + if (!pkgName) return res.status(400).json({ ok: false, message: '缺少 pkgName' }) + addIgnored(String(pkgName)) + res.json({ ok: true }) +}) +app.delete('/api/ignore/:pkgName', (req, res) => { + removeIgnored(req.params.pkgName) + res.json({ ok: true }) +}) + +// —— API:包列表(扫描) —— +app.get('/api/packages', (req, res) => { + const demo = isDemoMode() + const storage = currentStorage() + const validation = demo ? { ok: true, message: 'demo' } : validateStorage(storage) + res.json({ + demo, + storageDir: storage || null, + storageValid: validation.ok, + storageMessage: validation.message, + threshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)), + packages: buildPackages(req.query.keyword || ''), + ...(demo ? demoSummary() : {}), + }) +}) + +// —— API:清理 —— +app.post('/api/clean', (req, res) => { + const { pkgName, keep } = req.body || {} + if (!pkgName) return res.status(400).json({ ok: false, message: '缺少 pkgName' }) + if (isDemoMode()) { + return demoClean(res, pkgName, clampInt(keep, 1, 999)) + } + const storage = currentStorage() + const result = cleanPackage(storage, pkgName, clampInt(keep, 1, 999)) + if (result.ok) { + addLog({ + pkgName, + deletedVersions: result.deletedVersions, + deletedCount: result.deletedCount, + freedSpace: result.freedSpace, + }) + } + res.json(result) +}) + +// —— API:单版本删除 —— +app.delete('/api/packages/:pkgName/versions/:version', (req, res) => { + const { pkgName, version } = req.params + if (isDemoMode()) { + return demoDeleteVersion(res, pkgName, version) + } + const storage = currentStorage() + const result = deleteSingleVersion(storage, pkgName, version) + if (result.ok) { + addLog({ + pkgName, + deletedVersions: version, + deletedCount: 1, + freedSpace: result.freedSpace, + }) + } + res.json(result) +}) + +// —— API:日志 —— +app.get('/api/logs', (req, res) => { + res.json(getLogs(50)) +}) + +// —— API:storage 配置(UI 设置页) —— +app.get('/api/storage', (req, res) => { + const storage = currentStorage() + const demo = isDemoMode() + res.json({ + storageDir: storage, + envDir: process.env.VERDACCIO_STORAGE || null, + demo, + validation: demo ? { ok: true, message: 'demo' } : validateStorage(storage), + }) +}) +app.post('/api/storage', (req, res) => { + const { storageDir } = req.body || {} + if (!storageDir) return res.status(400).json({ ok: false, message: '缺少 storageDir' }) + const v = validateStorage(String(storageDir)) + if (!v.ok) return res.status(400).json(v) + setSetting('storage_dir', String(storageDir)) + res.json({ ok: true }) +}) + +// —— 生产:托管前端 dist —— +const distDir = path.join(__dirname, '..', 'frontend', 'dist') +if (fs.existsSync(distDir)) { + app.use(express.static(distDir)) + app.get('*', (req, res) => { + if (req.path.startsWith('/api/')) return res.status(404).json({ ok: false, message: 'not found' }) + res.sendFile(path.join(distDir, 'index.html')) + }) +} + +app.listen(PORT, () => { + const storage = resolveStorageDir() + const mode = isDemoMode() ? 'demo' : 'storage' + console.log(`[verdaccio-manager] listening on :${PORT} (mode=${mode})`) + if (mode === 'storage') console.log(`[verdaccio-manager] storage: ${storage}`) + else console.log('[verdaccio-manager] 未配置 VERDACCIO_STORAGE,运行于演示模式(设置 VCM_DEMO=0 + 真实目录可关闭)') +}) + +function clampInt(v, min, max) { + const n = Number(v) + if (Number.isNaN(n)) return min + return Math.min(max, Math.max(min, Math.round(n))) +} + +// —— demo 模式下的清理/删除(内存态) —— +const demoMemory = { logs: [] } +function demoClean(res, pkgName, keep) { + const p = demoPackages().find(x => x.name === pkgName) + if (!p) return res.json({ ok: false, message: '包不存在' }) + const sorted = [...p.versionsDetail].sort((a, b) => + a.version.localeCompare(b.version, undefined, { numeric: true }), + ) + const toDel = sorted.slice(0, Math.max(0, sorted.length - keep)) + if (!toDel.length) return res.json({ ok: false, message: '无需清理' }) + const freed = toDel.reduce((s, v) => s + v.size, 0) + const result = { + ok: true, + pkgName, + deletedVersions: toDel.map(v => v.version).join(', '), + deletedCount: toDel.length, + freedSpace: `${freed.toFixed(1)} MB`, + } + // demo 日志并入 logs 表,保持一致 + addLog({ pkgName, deletedVersions: result.deletedVersions, deletedCount: result.deletedCount, freedSpace: result.freedSpace }) + res.json(result) +} + +function demoDeleteVersion(res, pkgName, version) { + const p = demoPackages().find(x => x.name === pkgName) + const v = p?.versionsDetail?.find(x => x.version === version) + if (!v) return res.json({ ok: false, message: '版本不存在' }) + addLog({ pkgName, deletedVersions: version, deletedCount: 1, freedSpace: `${v.size.toFixed(1)} MB` }) + res.json({ ok: true, freedSpace: `${v.size.toFixed(1)} MB` }) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..caf3fe1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +# Verdaccio 缓存可视化管理面板(§12.2 Docker 部署) +# 与 Verdaccio 同主机:storage 只读挂载,manager-data 读写(SQLite 数据) +services: + verdaccio: + image: verdaccio/verdaccio + restart: unless-stopped + volumes: + - "./storage:/verdaccio/storage" # Verdaccio 读写缓存目录 + + verdaccio-manager: + build: ./ + restart: unless-stopped + environment: + - VERDACCIO_STORAGE=/verdaccio/storage # 只读扫描该目录 + - PORT=3000 + volumes: + - "./storage:/verdaccio/storage:ro" # 面板只读挂载 + - "./manager-data:/app/data" # 读写:SQLite 数据 + ports: + - "3000:3000" + depends_on: + - verdaccio diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d2f17cc --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Verdaccio Manager · 你的缓存尽在掌握 + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..64c01e3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "element-plus": "^2.14.6", + "vue": "^3.5.42", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.8", + "unplugin-auto-import": "^21.1.0", + "unplugin-vue-components": "^32.1.0", + "vite": "^8.3.0" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..115aa4d --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,1136 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + element-plus: + specifier: ^2.14.6 + version: 2.14.6(vue@3.5.43) + vue: + specifier: ^3.5.42 + version: 3.5.43 + vue-router: + specifier: ^4.6.4 + version: 4.6.4(vue@3.5.43) + devDependencies: + '@vitejs/plugin-vue': + specifier: ^6.0.8 + version: 6.0.9(vite@8.3.0)(vue@3.5.43) + unplugin-auto-import: + specifier: ^21.1.0 + version: 21.1.0(@vueuse/core@15.0.0(vue@3.5.43))(rolldown@1.2.9)(vite@8.3.0) + unplugin-vue-components: + specifier: ^32.1.0 + version: 32.1.0(rolldown@1.2.9)(vite@8.3.0)(vue@3.5.43) + vite: + specifier: ^8.3.0 + version: 8.3.0 + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@ctrl/tinycolor@4.2.1': + resolution: {integrity: sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==} + engines: {node: '>=14'} + + '@element-plus/icons-vue@2.3.2': + resolution: {integrity: sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==} + peerDependencies: + vue: ^3.2.0 + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.150.0': + resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==} + + '@rolldown/binding-android-arm-eabi@1.2.9': + resolution: {integrity: sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.9': + resolution: {integrity: sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.9': + resolution: {integrity: sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.9': + resolution: {integrity: sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.9': + resolution: {integrity: sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.9': + resolution: {integrity: sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.9': + resolution: {integrity: sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.9': + resolution: {integrity: sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.9': + resolution: {integrity: sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.9': + resolution: {integrity: sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.9': + resolution: {integrity: sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.9': + resolution: {integrity: sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.9': + resolution: {integrity: sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.9': + resolution: {integrity: sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.9': + resolution: {integrity: sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@sxzz/popperjs-es@2.11.8': + resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@vitejs/plugin-vue@6.0.9': + resolution: {integrity: sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@vue/compiler-core@3.5.43': + resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==} + + '@vue/compiler-dom@3.5.43': + resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==} + + '@vue/compiler-sfc@3.5.43': + resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==} + + '@vue/compiler-ssr@3.5.43': + resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/reactivity@3.5.43': + resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==} + + '@vue/runtime-core@3.5.43': + resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==} + + '@vue/runtime-dom@3.5.43': + resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==} + + '@vue/server-renderer@3.5.43': + resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==} + + '@vue/shared@3.5.43': + resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==} + + '@vueuse/core@15.0.0': + resolution: {integrity: sha512-Zv67h5mrk7YHDJsRObx+DXaCBz6RKBUpxqLesJ+3xZNPG6yuQs7ja7kg3veFrrouYLWAqydQ7CwGEbLVxB43Ng==} + engines: {node: '>=22'} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/metadata@15.0.0': + resolution: {integrity: sha512-sfWE5LADAKJ0KnjtGliMdSN8Ij/L7uP6rXIZlgPloAI4zvvefbw6H5Lt7KJA6RqQOYgVqWF5Guj50HHMIzygBg==} + engines: {node: '>=22'} + + '@vueuse/shared@15.0.0': + resolution: {integrity: sha512-vbNXhGrbV53xPOMSyOzMHQRvhMgZf7oNDw2RChUetVxwTIEkInQ1joZ9JbT0G3z2n2AJNjvmCWeM9ySYrnvogA==} + engines: {node: '>=22'} + peerDependencies: + vue: ^3.5.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.3.1: + resolution: {integrity: sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + element-plus@2.14.6: + resolution: {integrity: sha512-GH0fMoMgVEKkHimoaKvg5B00NmfPbQ+qxX6/JrO7F+dLAC3atrZr2Y2MoccHMxS22M7QE12/s0DG0Zs49iwJ/A==} + peerDependencies: + vue: ^3.3.7 + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + local-pkg@1.2.1: + resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} + engines: {node: '>=14'} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash-unified@1.0.3: + resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==} + peerDependencies: + '@types/lodash-es': '*' + lodash: '*' + lodash-es: '*' + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magic-string@1.4.1: + resolution: {integrity: sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + normalize-wheel-es@1.2.0: + resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==} + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.3: + resolution: {integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + rolldown@1.2.9: + resolution: {integrity: sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + strip-literal@4.0.0: + resolution: {integrity: sha512-PaqAvfUZKBwc/SLmNZtHmzK+v19Z4O4eS3cKPeGvbIv/U3pnyEq4Tuw3/4v/FwfM8VQaEawsyCcOQ0P+kpwWWw==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + unimport@6.5.0: + resolution: {integrity: sha512-t0KLJLRbebz3f7NrQe+vy2ObeugVeM1XqI2oeYnauddmHSglYj3U6bxOJ65IbXmOFkcAGFJK2OyGH7aqHPM6Ug==} + engines: {node: '>=18.12.0'} + peerDependencies: + oxc-parser: '*' + rolldown: ^1.0.0 + peerDependenciesMeta: + oxc-parser: + optional: true + rolldown: + optional: true + + unplugin-auto-import@21.1.0: + resolution: {integrity: sha512-EzrSqWIBulEqCuP7idADXH+tVKYrbwKlR5+r/lOWik0o+Ksny1kitmtryHGNSv7puzzORlcIwT/x2JStiszlHA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@nuxt/kit': ^4.0.0 + '@vueuse/core': '*' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@vueuse/core': + optional: true + + unplugin-utils@0.3.2: + resolution: {integrity: sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==} + engines: {node: '>=20.19.0'} + + unplugin-vue-components@32.1.0: + resolution: {integrity: sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@nuxt/kit': ^3.2.2 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + + unplugin@3.4.0: + resolution: {integrity: sha512-9skdIFlCsPdFV7wUfZxNsFInlW+7nJmGu2gkTu0OUhF56aXGsHab9x52/QhdJ4lC7ZDPWTxbiC4ANqVlsuaW3w==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@farmfe/core': '*' + '@rsbuild/core': '*' + '@rspack/core': '*' + bun-types-no-globals: '*' + esbuild: '*' + rolldown: '*' + rollup: '*' + unloader: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + '@farmfe/core': + optional: true + '@rsbuild/core': + optional: true + '@rspack/core': + optional: true + bun-types-no-globals: + optional: true + esbuild: + optional: true + rolldown: + optional: true + rollup: + optional: true + unloader: + optional: true + vite: + optional: true + webpack: + optional: true + + vite@8.3.0: + resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vue-component-type-helpers@3.3.11: + resolution: {integrity: sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==} + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue@3.5.43: + resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@ctrl/tinycolor@4.2.1': {} + + '@element-plus/icons-vue@2.3.2(vue@3.5.43)': + dependencies: + vue: 3.5.43 + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/utils@0.2.12': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@oxc-project/types@0.150.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.9': + optional: true + + '@rolldown/binding-android-arm64@1.2.9': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.9': + optional: true + + '@rolldown/binding-darwin-x64@1.2.9': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.9': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.9': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.9': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.9': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.9': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.9': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.9': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@sxzz/popperjs-es@2.11.8': {} + + '@types/estree@1.0.9': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.25 + + '@types/lodash@4.17.25': {} + + '@types/web-bluetooth@0.0.21': {} + + '@vitejs/plugin-vue@6.0.9(vite@8.3.0)(vue@3.5.43)': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.0 + vue: 3.5.43 + + '@vue/compiler-core@3.5.43': + dependencies: + '@babel/parser': 7.29.9 + '@vue/shared': 3.5.43 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.43': + dependencies: + '@vue/compiler-core': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/compiler-sfc@3.5.43': + dependencies: + '@babel/parser': 7.29.9 + '@vue/compiler-core': 3.5.43 + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-ssr': 3.5.43 + '@vue/shared': 3.5.43 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.28 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.43': + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/devtools-api@6.6.4': {} + + '@vue/reactivity@3.5.43': + dependencies: + '@vue/shared': 3.5.43 + + '@vue/runtime-core@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/runtime-dom@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/runtime-core': 3.5.43 + '@vue/shared': 3.5.43 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.43': + dependencies: + '@vue/compiler-ssr': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/shared@3.5.43': {} + + '@vueuse/core@15.0.0(vue@3.5.43)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 15.0.0 + '@vueuse/shared': 15.0.0(vue@3.5.43) + vue: 3.5.43 + + '@vueuse/metadata@15.0.0': {} + + '@vueuse/shared@15.0.0(vue@3.5.43)': + dependencies: + vue: 3.5.43 + + acorn@8.18.0: {} + + async-validator@4.2.5: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + confbox@0.1.8: {} + + confbox@0.3.1: {} + + csstype@3.2.3: {} + + dayjs@1.11.23: {} + + detect-libc@2.1.2: {} + + element-plus@2.14.6(vue@3.5.43): + dependencies: + '@ctrl/tinycolor': 4.2.1 + '@element-plus/icons-vue': 2.3.2(vue@3.5.43) + '@floating-ui/dom': 1.8.0 + '@popperjs/core': '@sxzz/popperjs-es@2.11.8' + '@types/lodash': 4.17.25 + '@types/lodash-es': 4.17.12 + '@vueuse/core': 15.0.0(vue@3.5.43) + async-validator: 4.2.5 + dayjs: 1.11.23 + lodash: 4.18.1 + lodash-es: 4.18.1 + lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1) + memoize-one: 6.0.0 + normalize-wheel-es: 1.2.0 + vue: 3.5.43 + vue-component-type-helpers: 3.3.11 + + entities@7.0.1: {} + + escape-string-regexp@5.0.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + exsolve@1.1.1: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + js-tokens@10.0.0: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + local-pkg@1.2.1: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.3 + quansync: 0.2.11 + + lodash-es@4.18.1: {} + + lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1): + dependencies: + '@types/lodash-es': 4.17.12 + lodash: 4.18.1 + lodash-es: 4.18.1 + + lodash@4.18.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magic-string@1.4.1: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + memoize-one@6.0.0: {} + + mlly@1.8.2: + dependencies: + acorn: 8.18.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + nanoid@3.3.19: {} + + normalize-wheel-es@1.2.0: {} + + obug@2.2.1: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.3: + dependencies: + confbox: 0.3.1 + exsolve: 1.1.1 + pathe: 2.0.3 + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + quansync@0.2.11: {} + + readdirp@5.1.1: {} + + rolldown@1.2.9: + dependencies: + '@oxc-project/types': 0.150.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.9 + '@rolldown/binding-android-arm64': 1.2.9 + '@rolldown/binding-darwin-arm64': 1.2.9 + '@rolldown/binding-darwin-x64': 1.2.9 + '@rolldown/binding-freebsd-x64': 1.2.9 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.9 + '@rolldown/binding-linux-arm64-gnu': 1.2.9 + '@rolldown/binding-linux-arm64-musl': 1.2.9 + '@rolldown/binding-linux-ppc64-gnu': 1.2.9 + '@rolldown/binding-linux-s390x-gnu': 1.2.9 + '@rolldown/binding-linux-x64-gnu': 1.2.9 + '@rolldown/binding-linux-x64-musl': 1.2.9 + '@rolldown/binding-openharmony-arm64': 1.2.9 + '@rolldown/binding-win32-arm64-msvc': 1.2.9 + '@rolldown/binding-win32-x64-msvc': 1.2.9 + + scule@1.3.0: {} + + source-map-js@1.2.1: {} + + strip-literal@4.0.0: + dependencies: + js-tokens: 10.0.0 + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + ufo@1.6.4: {} + + unimport@6.5.0(rolldown@1.2.9)(vite@8.3.0): + dependencies: + acorn: 8.18.0 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + local-pkg: 1.2.1 + magic-string: 1.4.1 + mlly: 1.8.2 + pathe: 2.0.3 + picomatch: 4.0.7 + pkg-types: 2.3.3 + scule: 1.3.0 + strip-literal: 4.0.0 + tinyglobby: 0.2.17 + unplugin: 3.4.0(rolldown@1.2.9)(vite@8.3.0) + unplugin-utils: 0.3.2 + optionalDependencies: + rolldown: 1.2.9 + transitivePeerDependencies: + - '@farmfe/core' + - '@rsbuild/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rollup + - unloader + - vite + - webpack + + unplugin-auto-import@21.1.0(@vueuse/core@15.0.0(vue@3.5.43))(rolldown@1.2.9)(vite@8.3.0): + dependencies: + local-pkg: 1.2.1 + magic-string: 1.4.1 + picomatch: 4.0.7 + unimport: 6.5.0(rolldown@1.2.9)(vite@8.3.0) + unplugin: 3.4.0(rolldown@1.2.9)(vite@8.3.0) + unplugin-utils: 0.3.2 + optionalDependencies: + '@vueuse/core': 15.0.0(vue@3.5.43) + transitivePeerDependencies: + - '@farmfe/core' + - '@rsbuild/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - oxc-parser + - rolldown + - rollup + - unloader + - vite + - webpack + + unplugin-utils@0.3.2: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.7 + + unplugin-vue-components@32.1.0(rolldown@1.2.9)(vite@8.3.0)(vue@3.5.43): + dependencies: + chokidar: 5.0.0 + local-pkg: 1.2.1 + magic-string: 0.30.21 + mlly: 1.8.2 + obug: 2.2.1 + picomatch: 4.0.7 + tinyglobby: 0.2.17 + unplugin: 3.4.0(rolldown@1.2.9)(vite@8.3.0) + unplugin-utils: 0.3.2 + vue: 3.5.43 + transitivePeerDependencies: + - '@farmfe/core' + - '@rsbuild/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rolldown + - rollup + - unloader + - vite + - webpack + + unplugin@3.4.0(rolldown@1.2.9)(vite@8.3.0): + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.7 + webpack-virtual-modules: 0.6.2 + optionalDependencies: + rolldown: 1.2.9 + vite: 8.3.0 + + vite@8.3.0: + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.9 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vue-component-type-helpers@3.3.11: {} + + vue-router@4.6.4(vue@3.5.43): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.43 + + vue@3.5.43: + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-sfc': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/server-renderer': 3.5.43 + '@vue/shared': 3.5.43 + + webpack-virtual-modules@0.6.2: {} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..69c7d47 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + 📦 + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..75fb36f --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..4a9e4f3 --- /dev/null +++ b/frontend/src/api/index.js @@ -0,0 +1,211 @@ +/** + * API 封装 —— 对接后端 §4.2 REST API + * 开发期默认走 mock(VITE_USE_MOCK=false 时切真实接口) + */ +import { reactive } from 'vue' +import * as mock from '../mock/data.js' + +const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false' + +// —— mock 内存态(模拟服务端持久化) —— +const mockState = reactive({ + versionThreshold: 15, + sizeThreshold: 500, + exceptions: [ + { pkgName: 'tensorflow', versionThreshold: 30, sizeThreshold: 2000 }, + ], + ignored: new Set(['moment', '@vue/compiler-sfc', 'mocha', 'cheerio']), + packages: [...mock.packages], + logs: [...mock.logs], +}) + +function delay(ms = 180) { + return new Promise(r => setTimeout(r, ms)) +} + +function clone(v) { + return JSON.parse(JSON.stringify(v)) +} + +// 包排序:预警置顶 → 正常 → 忽略;同状态按版本数降序 +function sortPackages(list) { + const rank = { warn: 0, ok: 1, ignored: 2, broken: 3 } + return [...list].sort((a, b) => { + if (rank[a.status] !== rank[b.status]) return rank[a.status] - rank[b.status] + return b.versions - a.versions + }) +} + +function computeStatus(p, threshold) { + if (p.status === 'ignored') return 'ignored' + if (p.status === 'broken') return 'broken' + return p.versions > threshold ? 'warn' : 'ok' +} + +const api = { + async getSummary() { + if (USE_MOCK) { + await delay() + return mock.summary() + } + const r = await fetch('/api/packages') + const { packages } = await r.json() + const valid = packages.filter(p => p.status !== 'broken') + const warnCount = valid.filter(p => p.status === 'warn').length + return { + pkgCount: valid.length, + totalSize: valid.reduce((s, p) => s + p.size, 0), + warnCount, + cleanable: valid.filter(p => p.status === 'warn').reduce((s, p) => s + p.size, 0), + } + }, + + async getPackages(keyword = '') { + if (USE_MOCK) { + await delay() + const kw = keyword.trim().toLowerCase() + let list = clone(mockState.packages) + list = list.map(p => ({ ...p, status: computeStatus(p, mockState.versionThreshold) })) + if (kw) list = list.filter(p => p.name.toLowerCase().includes(kw)) + return sortPackages(list) + } + const r = await fetch(`/api/packages?keyword=${encodeURIComponent(keyword)}`) + const { packages } = await r.json() + return packages + }, + + async getThresholds() { + if (USE_MOCK) { + await delay(80) + return { + versionThreshold: mockState.versionThreshold, + sizeThreshold: mockState.sizeThreshold, + exceptions: clone(mockState.exceptions), + } + } + const r = await fetch('/api/thresholds') + return r.json() + }, + + async saveThresholds(payload) { + if (USE_MOCK) { + await delay() + mockState.versionThreshold = payload.versionThreshold + mockState.sizeThreshold = payload.sizeThreshold + mockState.exceptions = clone(payload.exceptions || []) + return { ok: true } + } + const r = await fetch('/api/thresholds', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + return r.json() + }, + + async getIgnored() { + if (USE_MOCK) { + await delay(80) + return [...mockState.ignored] + } + const r = await fetch('/api/ignored') + return r.json() + }, + + async ignore(pkgName) { + if (USE_MOCK) { + await delay(100) + mockState.ignored.add(pkgName) + const p = mockState.packages.find(x => x.name === pkgName) + if (p) { p.status = 'ignored'; p.ignoredAt = new Date().toISOString().slice(0, 10) } + return { ok: true } + } + const r = await fetch('/api/ignore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pkgName }), + }) + return r.json() + }, + + async unignore(pkgName) { + if (USE_MOCK) { + await delay(100) + mockState.ignored.delete(pkgName) + const p = mockState.packages.find(x => x.name === pkgName) + if (p) { + p.status = computeStatus(p, mockState.versionThreshold) + delete p.ignoredAt + } + return { ok: true } + } + const r = await fetch(`/api/ignore/${encodeURIComponent(pkgName)}`, { method: 'DELETE' }) + return r.json() + }, + + /** + * 清理:保留最新 keep 个版本 + * @returns {Promise<{ok, deletedCount, freedSpace, deletedVersions}>} + */ + async clean(pkgName, keep) { + if (USE_MOCK) { + await delay(420) + const p = mockState.packages.find(x => x.name === pkgName) + if (!p) return { ok: false, message: '包不存在' } + const vers = [...p.versionsDetail].sort((a, b) => a.version.localeCompare(b.version, undefined, { numeric: true })) + const toDel = vers.slice(0, Math.max(0, vers.length - keep)) + if (!toDel.length) return { ok: false, message: '无需清理' } + const freed = toDel.reduce((s, v) => s + v.size, 0) + p.versionsDetail = vers.slice(-keep) + p.versions = p.versionsDetail.length + p.size = Number(Math.max(0.1, p.size - freed).toFixed(1)) + p.status = computeStatus(p, mockState.versionThreshold) + const log = { + id: Date.now(), + pkgName, + deletedVersions: toDel.map(v => v.version).join(', '), + deletedCount: toDel.length, + freedSpace: `${freed.toFixed(1)} MB`, + created_at: new Date().toISOString().slice(0, 16).replace('T', ' '), + } + mockState.logs.unshift(log) + return { ok: true, deletedCount: toDel.length, freedSpace: `${freed.toFixed(1)} MB`, deletedVersions: log.deletedVersions } + } + const r = await fetch('/api/clean', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pkgName, keep }), + }) + return r.json() + }, + + async deleteVersion(pkgName, version) { + if (USE_MOCK) { + await delay(150) + const p = mockState.packages.find(x => x.name === pkgName) + if (!p) return { ok: false } + const idx = p.versionsDetail.findIndex(v => v.version === version) + if (idx === -1) return { ok: false } + const [v] = p.versionsDetail.splice(idx, 1) + p.versions = p.versionsDetail.length + p.size = Number(Math.max(0.1, p.size - v.size).toFixed(1)) + p.status = computeStatus(p, mockState.versionThreshold) + return { ok: true, freedSpace: `${v.size.toFixed(1)} MB` } + } + const r = await fetch(`/api/packages/${encodeURIComponent(pkgName)}/versions/${encodeURIComponent(version)}`, { + method: 'DELETE', + }) + return r.json() + }, + + async getLogs() { + if (USE_MOCK) { + await delay(80) + return clone(mockState.logs) + } + const r = await fetch('/api/logs') + return r.json() + }, +} + +export default api diff --git a/frontend/src/components/AppTopbar.vue b/frontend/src/components/AppTopbar.vue new file mode 100644 index 0000000..bd4270e --- /dev/null +++ b/frontend/src/components/AppTopbar.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/frontend/src/components/CleanDialog.vue b/frontend/src/components/CleanDialog.vue new file mode 100644 index 0000000..5af27e5 --- /dev/null +++ b/frontend/src/components/CleanDialog.vue @@ -0,0 +1,231 @@ + + + + + diff --git a/frontend/src/components/MobileTabbar.vue b/frontend/src/components/MobileTabbar.vue new file mode 100644 index 0000000..d1328d5 --- /dev/null +++ b/frontend/src/components/MobileTabbar.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/frontend/src/components/PackageCard.vue b/frontend/src/components/PackageCard.vue new file mode 100644 index 0000000..c369032 --- /dev/null +++ b/frontend/src/components/PackageCard.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/frontend/src/components/PackageDrawer.vue b/frontend/src/components/PackageDrawer.vue new file mode 100644 index 0000000..5d7dc54 --- /dev/null +++ b/frontend/src/components/PackageDrawer.vue @@ -0,0 +1,291 @@ + + + + + + + diff --git a/frontend/src/components/SettingDrawer.vue b/frontend/src/components/SettingDrawer.vue new file mode 100644 index 0000000..e5dad44 --- /dev/null +++ b/frontend/src/components/SettingDrawer.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/frontend/src/components/StatCard.vue b/frontend/src/components/StatCard.vue new file mode 100644 index 0000000..10cc772 --- /dev/null +++ b/frontend/src/components/StatCard.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/frontend/src/components/StatMini.vue b/frontend/src/components/StatMini.vue new file mode 100644 index 0000000..83c8095 --- /dev/null +++ b/frontend/src/components/StatMini.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/frontend/src/components/StatusTag.vue b/frontend/src/components/StatusTag.vue new file mode 100644 index 0000000..a47eaeb --- /dev/null +++ b/frontend/src/components/StatusTag.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/frontend/src/components/Top30Panel.vue b/frontend/src/components/Top30Panel.vue new file mode 100644 index 0000000..398db13 --- /dev/null +++ b/frontend/src/components/Top30Panel.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/frontend/src/composables/useTheme.js b/frontend/src/composables/useTheme.js new file mode 100644 index 0000000..1b3c645 --- /dev/null +++ b/frontend/src/composables/useTheme.js @@ -0,0 +1,17 @@ +import { ref } from 'vue' + +const THEME_KEY = 'vcm-theme' +const theme = ref(document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'bright') + +function apply(t) { + document.documentElement.setAttribute('data-theme', t) + localStorage.setItem(THEME_KEY, t) +} + +export function useTheme() { + function toggle() { + theme.value = theme.value === 'dark' ? 'bright' : 'dark' + apply(theme.value) + } + return { theme, toggle } +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..38aa88e --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,14 @@ +import { createApp } from 'vue' +import App from './App.vue' +import router from './router' +import './styles/base.css' + +// 主题:加载时读取 localStorage(vcm-theme),默认 bright +const saved = localStorage.getItem('vcm-theme') +if (saved === 'dark') { + document.documentElement.setAttribute('data-theme', 'dark') +} + +const app = createApp(App) +app.use(router) +app.mount('#app') diff --git a/frontend/src/mock/data.js b/frontend/src/mock/data.js new file mode 100644 index 0000000..66ea948 --- /dev/null +++ b/frontend/src/mock/data.js @@ -0,0 +1,192 @@ +/** + * Mock 数据层 —— 先行跑通 UI + * 数据结构与后端 §4 对齐,之后切换 api 实现即可无缝替换 + */ + +// 常见包名池(含 scoped) +const PKG_POOL = [ + 'lodash', 'axios', 'react', 'vue', 'express', 'webpack', 'typescript', 'eslint', + 'prettier', 'vite', 'rollup', 'jest', 'mocha', 'dayjs', 'moment', 'uuid', + 'dotenv', 'commander', 'chalk', 'semver', 'debug', 'ws', 'zod', 'yup', + '@antv/g2', '@antv/g6', '@ant-design/icons', '@vue/compiler-sfc', '@vueuse/core', + '@babel/core', '@types/node', '@types/react', '@types/lodash', '@element-plus/icons-vue', + '@testing-library/react', 'tensorflow', 'sharp', 'puppeteer', 'cheerio', 'ioredis', + 'pg', 'mysql2', 'mongo-express', 'knex', 'sequelize', 'prisma', 'graphql', + 'apollo-server', 'socket.io', 'three', 'd3', 'echarts', 'chart.js', 'pdf-lib', + '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']) + +function pad(n) { + return String(n).padStart(2, '0') +} + +function dateStr(offsetDays) { + const d = new Date(Date.now() - offsetDays * 86400000) + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}` +} + +function rnd(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min +} + +function fmtSize(mb) { + return mb >= 1024 ? `${(mb / 1024).toFixed(1)} GB` : `${mb.toFixed(1)} MB` +} + +// 生成单个包的版本明细 +function genVersions(name, count, baseVersion) { + const list = [] + const [major] = baseVersion.split('.') + let curMajor = Number(major) + for (let i = 0; i < count; i++) { + // 版本号随时间自然递增:越新越靠后 + const minor = rnd(0, 9) + const patch = rnd(0, 20) + const v = `${curMajor}.${minor}.${patch}` + const size = rnd(2, 40) * 0.1 + 0.5 + list.push({ + version: v, + size: Number(size.toFixed(1)), + date: dateStr((count - i - 1) * rnd(3, 9) + rnd(0, 2)), + }) + if (i === count - 1) curMajor++ + } + 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() { + const threshold = 15 + const list = [] + const seed = [ + // [name, 版本数, 单版本大小基数, 访问次数, 标记] + ['lodash', 25, 4, 12456], + ['@antv/g2', 32, 12, 7654], + ['axios', 18, 1.2, 9832], + ['react', 14, 10, 6543], + ['vue', 12, 9, 5218], + ['express', 16, 0.8, 4376], + ['tensorflow', 24, 90, 2103], + ['typescript', 20, 22, 3421], + ['webpack', 19, 18, 2876], + ['echarts', 15, 16, 1987], + ['dayjs', 8, 0.6, 1789], + ['uuid', 11, 0.4, 1654], + ['vite', 9, 14, 1432], + ['prisma', 7, 28, 1120], + ['pg', 6, 5, 986], + ['three', 10, 30, 876], + ['sharp', 5, 24, 654], + ['chalk', 6, 0.3, 743], + ['semver', 4, 0.3, 532], + ['zod', 5, 0.5, 421], + ] + for (const [name, verCount, sizeBase, visits] of seed) { + list.push({ + name, + versions: verCount, + size: Number((verCount * sizeBase).toFixed(1)), + lastUpdated: dateStr(rnd(1, 40)), + visits, + status: verCount > threshold ? 'warn' : 'ok', + versionsDetail: genVersions(name, verCount, `1.${rnd(0, 9)}.0`), + }) + } + + // 程序化补充一批普通包 + const rest = PKG_POOL.filter(n => !seed.some(s => s[0] === n)) + for (const name of rest) { + const verCount = rnd(1, 14) + const sizeBase = rnd(3, 80) / 10 + list.push({ + name, + versions: verCount, + size: Number((verCount * sizeBase).toFixed(1)), + lastUpdated: dateStr(rnd(1, 60)), + visits: rnd(80, 1800), + status: 'ok', + versionsDetail: genVersions(name, verCount, `1.${rnd(0, 9)}.0`), + }) + } + + // 忽略包 + const ignoredNames = ['moment', '@vue/compiler-sfc', 'mocha', 'cheerio'] + for (const name of ignoredNames) { + const p = list.find(x => x.name === name) + if (p) { + p.status = 'ignored' + p.ignoredAt = dateStr(rnd(3, 20)) + } + } + + // 单个异常包(元数据损坏演示) + const broken = list.find(x => x.name === 'prettier') + if (broken) { + broken.status = 'broken' + broken.versionsDetail = [] + } + + return list +} + +const packages = genPackages() + +// 访问排行 Top30(按 visits 降序) +const top30 = [...packages] + .filter(p => p.status !== 'broken') + .sort((a, b) => b.visits - a.visits) + .slice(0, 30) + .map(p => ({ ...p, heat: Math.round((p.visits / topMax(packages)) * 100) })) + +function topMax(list) { + return Math.max(...list.map(p => p.visits), 1) +} + +// 日志 +const logs = [ + { id: 3, pkgName: '@antv/g2', deletedVersions: '1.0.0, 1.1.0, 1.2.0', deletedCount: 3, freedSpace: '36.5 MB', created_at: dateStr(0) + ' 09:12:04' }, + { id: 2, pkgName: 'lodash', deletedVersions: '4.0.0, 4.1.0, 4.2.0, 4.3.0', deletedCount: 4, freedSpace: '15.2 MB', created_at: dateStr(1) + ' 18:40:22' }, + { id: 1, pkgName: 'axios', deletedVersions: '0.19.0', deletedCount: 1, freedSpace: '1.1 MB', created_at: dateStr(2) + ' 11:05:47' }, +] + +// 状态统计(与 overview 对齐) +function summary() { + const valid = packages.filter(p => p.status !== 'broken') + const totalSize = valid.reduce((s, p) => s + p.size, 0) + const warnCount = valid.filter(p => p.status === 'warn').length + const cleanable = valid.filter(p => p.status === 'warn').reduce((s, p) => s + p.size, 0) + const maxPkg = [...valid].sort((a, b) => b.size - a.size)[0] + const oldest = [...valid].sort((a, b) => a.lastUpdated.localeCompare(b.lastUpdated))[0] + return { + pkgCount: valid.length, + totalSize, + totalSizeText: fmtSize(totalSize), + warnCount, + cleanable, + cleanableText: fmtSize(cleanable), + todayDownloads: 1234, + todayTrend: '+18%', + maxPkg, + oldestPkg: oldest, + versionDist: { over50: 12, over30: 41, overThreshold: warnCount }, + } +} + +export { packages, top30, logs, summary, fmtSize, iconOf } diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..b480f73 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,31 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const routes = [ + { path: '/login', name: 'login', component: () => import('../views/LoginView.vue'), meta: { public: true } }, + { path: '/', name: 'overview', component: () => import('../views/OverviewView.vue') }, + { path: '/search', name: 'search', component: () => import('../views/SearchView.vue') }, + { path: '/thresholds', name: 'thresholds', component: () => import('../views/ThresholdsView.vue') }, + { path: '/ignored', name: 'ignored', component: () => import('../views/IgnoredView.vue') }, + { path: '/logs', name: 'logs', component: () => import('../views/LogsView.vue') }, + { path: '/:pathMatch(.*)*', redirect: '/' }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, + scrollBehavior() { + return { top: 0 } + }, +}) + +router.beforeEach((to) => { + const logged = localStorage.getItem('vcm-user') !== null + if (!to.meta.public && !logged) { + return { name: 'login', query: { redirect: to.fullPath } } + } + if (to.name === 'login' && logged) { + return { name: 'overview' } + } +}) + +export default router diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js new file mode 100644 index 0000000..2e3a825 --- /dev/null +++ b/frontend/src/store/index.js @@ -0,0 +1,122 @@ +/** + * 全局数据状态(模块级 reactive 单例,多页面共享) + * - packages:包列表(已排序:预警置顶 → 正常 → 忽略) + * - thresholds:全局阈值 + 单包例外 + * - logs:操作日志 + */ +import { reactive, computed } from 'vue' +import api from '../api' + +const state = reactive({ + packages: [], + threshold: { versionThreshold: 15, sizeThreshold: 500, exceptions: [] }, + logs: [], + loading: false, + storageDir: localStorage.getItem('vcm-storage-dir') || '', +}) + +// —— 派生统计 —— +const stats = computed(() => { + const valid = state.packages.filter(p => p.status !== 'broken') + const totalSize = valid.reduce((s, p) => s + p.size, 0) + const warnCount = valid.filter(p => p.status === 'warn').length + const cleanable = valid.filter(p => p.status === 'warn').reduce((s, p) => s + p.size, 0) + const maxPkg = [...valid].sort((a, b) => b.size - a.size)[0] || null + const oldestPkg = [...valid].sort((a, b) => (a.lastUpdated || '').localeCompare(b.lastUpdated || ''))[0] || null + const top30 = [...valid].sort((a, b) => (b.visits || 0) - (a.visits || 0)).slice(0, 30) + const maxVisit = Math.max(...top30.map(p => p.visits || 0), 1) + return { + pkgCount: valid.length, + totalSize, + totalSizeText: fmtSize(totalSize), + warnCount, + cleanable, + cleanableText: fmtSize(cleanable), + maxPkg, + oldestPkg, + top30: top30.map(p => ({ ...p, heat: Math.round(((p.visits || 0) / maxVisit) * 100) })), + } +}) + +function fmtSize(mb) { + if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB` + return `${mb.toFixed(1)} MB` +} + +const fmtSizeFn = fmtSize + +async function refreshPackages(keyword = '') { + state.packages = await api.getPackages(keyword) +} + +async function refreshThresholds() { + const t = await api.getThresholds() + state.threshold.versionThreshold = t.versionThreshold + state.threshold.sizeThreshold = t.sizeThreshold + state.threshold.exceptions = t.exceptions || [] +} + +async function refreshLogs() { + state.logs = await api.getLogs() +} + +async function refreshAll() { + state.loading = true + try { + await Promise.all([refreshPackages(), refreshThresholds(), refreshLogs()]) + } finally { + state.loading = false + } +} + +async function saveThresholds(payload) { + const ok = await api.saveThresholds(payload) + if (ok) { + await refreshThresholds() + await refreshPackages() + } + return ok +} + +async function ignorePkg(name) { + await api.ignore(name) + await refreshPackages() +} + +async function unignorePkg(name) { + await api.unignore(name) + await refreshPackages() +} + +async function cleanPkg(name, keep) { + const res = await api.clean(name, keep) + if (res.ok) { + await Promise.all([refreshPackages(), refreshLogs()]) + } + return res +} + +async function deleteVersion(name, version) { + const res = await api.deleteVersion(name, version) + if (res.ok) { + await Promise.all([refreshPackages(), refreshLogs()]) + } + return res +} + +export function useStore() { + return { + state, + stats, + fmtSize: fmtSizeFn, + refreshAll, + refreshPackages, + refreshThresholds, + refreshLogs, + saveThresholds, + ignorePkg, + unignorePkg, + cleanPkg, + deleteVersion, + } +} diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..527d4fb --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,296 @@ +:root { + --text: #6b6375; + --text-h: #08060d; + --bg: #fff; + --border: #e5e4e7; + --code-bg: #f4f3ec; + --accent: #aa3bff; + --accent-bg: rgba(170, 59, 255, 0.1); + --accent-border: rgba(170, 59, 255, 0.5); + --social-bg: rgba(244, 243, 236, 0.5); + --shadow: + rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + + --sans: system-ui, 'Segoe UI', Roboto, sans-serif; + --heading: system-ui, 'Segoe UI', Roboto, sans-serif; + --mono: ui-monospace, Consolas, monospace; + + font: 18px/145% var(--sans); + letter-spacing: 0.18px; + color-scheme: light dark; + color: var(--text); + background: var(--bg); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + @media (max-width: 1024px) { + font-size: 16px; + } +} + +@media (prefers-color-scheme: dark) { + :root { + --text: #9ca3af; + --text-h: #f3f4f6; + --bg: #16171d; + --border: #2e303a; + --code-bg: #1f2028; + --accent: #c084fc; + --accent-bg: rgba(192, 132, 252, 0.15); + --accent-border: rgba(192, 132, 252, 0.5); + --social-bg: rgba(47, 48, 58, 0.5); + --shadow: + rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; + } + + #social .button-icon { + filter: invert(1) brightness(2); + } +} + +body { + margin: 0; +} + +h1, +h2 { + font-family: var(--heading); + font-weight: 500; + color: var(--text-h); +} + +h1 { + font-size: 56px; + letter-spacing: -1.68px; + margin: 32px 0; + @media (max-width: 1024px) { + font-size: 36px; + margin: 20px 0; + } +} +h2 { + font-size: 24px; + line-height: 118%; + letter-spacing: -0.24px; + margin: 0 0 8px; + @media (max-width: 1024px) { + font-size: 20px; + } +} +p { + margin: 0; +} + +code, +.counter { + font-family: var(--mono); + display: inline-flex; + border-radius: 4px; + color: var(--text-h); +} + +code { + font-size: 15px; + line-height: 135%; + padding: 4px 8px; + background: var(--code-bg); +} + +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#app { + width: 1126px; + max-width: 100%; + margin: 0 auto; + text-align: center; + border-inline: 1px solid var(--border); + min-height: 100svh; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css new file mode 100644 index 0000000..88b2b5b --- /dev/null +++ b/frontend/src/styles/base.css @@ -0,0 +1,253 @@ +/* ============================================================ + 全局基础样式 + 通用工具类(对应设计稿 body/ambient/btn/tag/动效) + ============================================================ */ +@import './tokens.css'; + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + background: var(--bg); + color: var(--ink); + font-family: var(--sans); + -webkit-font-smoothing: antialiased; + font-size: 14px; + line-height: 1.5; + min-height: 100vh; +} + +#app { + min-height: 100vh; +} + +a { + color: inherit; + text-decoration: none; +} + +button { + font-family: var(--sans); +} + +/* —— 暖色背景氛围(双主题切换同步覆盖) —— */ +.ambient { + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background: + radial-gradient(900px 460px at 82% -8%, rgba(255, 140, 0, .10), transparent 58%), + radial-gradient(760px 400px at -4% 6%, rgba(50, 205, 50, .07), transparent 55%), + radial-gradient(700px 420px at 55% 110%, rgba(30, 144, 255, .07), transparent 60%); +} +.ambient::after { + content: ""; + position: absolute; + inset: 0; + opacity: .5; + background-image: radial-gradient(rgba(140, 90, 30, .05) 1px, transparent 1px); + background-size: 22px 22px; +} +html[data-theme="dark"] .ambient { + background: + radial-gradient(900px 460px at 82% -8%, rgba(255, 160, 51, .10), transparent 58%), + radial-gradient(760px 400px at -4% 6%, rgba(61, 219, 77, .05), transparent 55%), + radial-gradient(700px 420px at 55% 110%, rgba(69, 162, 255, .06), transparent 60%); +} +html[data-theme="dark"] .ambient::after { + background-image: radial-gradient(rgba(200, 160, 120, .06) 1px, transparent 1px); +} + +/* —— 页面容器 —— */ +.page { + max-width: 1480px; + margin: 0 auto; + padding: 26px 28px 48px; + animation: fadeUp .3s both; +} +.page-inner { + padding: 30px 28px 40px; + max-width: 1480px; + margin: 0 auto; + animation: fadeUp .3s both; +} +.page-title { + display: flex; + align-items: center; + gap: 11px; + font-size: 19px; + font-weight: 700; + margin-bottom: 6px; +} +.page-title .emo { font-size: 22px; } +.page-sub { + font-size: 12.5px; + color: var(--ink-dim); + margin-bottom: 20px; +} +.page-sub b { color: var(--blue); font-family: var(--num); } + +/* —— 按钮(设计稿 btn 体系) —— */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border: none; + cursor: pointer; + font-size: 12.5px; + font-weight: 600; + padding: 8px 15px; + border-radius: 99px; + transition: .18s; + white-space: nowrap; + user-select: none; +} +.btn:active { transform: scale(.95); } +.btn.clean { + background: var(--grad-main); + color: #fff; + box-shadow: 0 8px 16px -6px rgba(255, 140, 0, .55); +} +.btn.clean:hover { transform: translateY(-1px); box-shadow: 0 12px 22px -6px rgba(255, 140, 0, .65); } +.btn.ignore { background: var(--blue-soft); color: var(--blue); } +.btn.ignore:hover { background: color-mix(in srgb, var(--blue-soft), var(--blue) 10%); } +.btn.unignore { + background: var(--num-bg); + color: var(--ink-dim); +} +.btn.unignore:hover { color: var(--ink); background: color-mix(in srgb, var(--num-bg), var(--ink) 8%); } +.btn.remove { background: var(--red-soft); color: var(--red); } +.btn.remove:hover { background: var(--red); color: #fff; } +.btn.ghost { + background: var(--card); + border: 1.5px solid var(--line); + color: var(--ink-dim); +} +.btn.ghost:hover { border-color: var(--orange); color: var(--orange); } +.btn.disabled { background: var(--num-bg); color: var(--ink-faint); cursor: not-allowed; } +.btn.block { flex: 1; justify-content: center; padding: 12px 0; } + +/* —— 标签(状态标签体系) —— */ +.tag { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11.5px; + font-weight: 700; + padding: 5px 12px; + border-radius: 99px; + white-space: nowrap; +} +.tag.warn { background: var(--red-soft); color: var(--red); } +.tag.ok { background: var(--green-soft); color: var(--green-deep); } +.tag.ign { background: var(--blue-soft); color: var(--blue); } +.tag.broken { background: var(--orange-soft); color: var(--orange); } + +/* —— 数字字体工具 —— */ +.num { font-family: var(--num); } + +/* —— 表格通用(无边框风 vtable/ign-table) —— */ +.vtable { + background: var(--card); + border: 1.5px solid var(--line); + border-radius: var(--r-lg); + box-shadow: var(--shadow); + overflow: hidden; +} +.vtable table { width: 100%; border-collapse: collapse; } +.vtable th { + font-size: 11px; + font-weight: 600; + letter-spacing: 1px; + color: var(--ink-faint); + text-align: left; + padding: 13px 20px; + border-bottom: 1px solid var(--line); + background: var(--panel-soft); + white-space: nowrap; +} +.vtable td { + padding: 14px 20px; + border-bottom: 1px solid var(--line-soft); + font-size: 13.5px; + vertical-align: middle; +} +.vtable tr:last-child td { border-bottom: none; } +.vtable tbody tr { cursor: pointer; transition: background .13s; } +.vtable tbody tr:hover { background: var(--bg); } +.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); } + +/* —— 滚动条 —— */ +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-thumb { + background: var(--line); + border-radius: 99px; +} +::-webkit-scrollbar-thumb:hover { background: var(--ink-faint); } +::-webkit-scrollbar-track { background: transparent; } + +/* —— 动效(对应设计稿 §8) —— */ +@keyframes fadeUp { + from { opacity: 0; transform: translateY(14px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes breathe { + 0%, 100% { box-shadow: 0 0 0 5px rgba(255, 69, 0, .22), 0 12px 30px -12px rgba(255, 69, 0, .3); } + 50% { box-shadow: 0 0 0 12px rgba(255, 69, 0, .06), 0 14px 34px -10px rgba(255, 69, 0, .4); } +} +@keyframes slideIn { + from { transform: translateX(80px); opacity: .4; } + to { transform: translateX(0); opacity: 1; } +} +@keyframes slideUp { + from { transform: translateY(60px); opacity: .4; } + to { transform: translateY(0); opacity: 1; } +} +@keyframes grow { + from { transform: scaleX(0); } + to { transform: scaleX(1); } +} +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: .35; } +} +@keyframes spin360 { + from { transform: rotate(0); } + to { transform: rotate(360deg); } +} + +/* —— 路由切换过渡 —— */ +.fade-slide-enter-active, +.fade-slide-leave-active { + transition: opacity .3s, transform .3s; +} +.fade-slide-enter-from { + opacity: 0; + transform: translateY(10px); +} +.fade-slide-leave-to { + opacity: 0; + transform: translateY(-6px); +} + +/* —— 响应式断点(桌面 ≥1024 / 平板 768~1023 / 手机 <768) —— */ +@media (max-width: 1023px) { + .page { padding: 20px 18px 40px; } + .page-inner { padding: 22px 18px 40px; } +} +@media (max-width: 767px) { + .page { padding: 14px 12px 96px; } + .page-inner { padding: 16px 12px 96px; } + .page-title { font-size: 17px; } + .page-title .emo { font-size: 19px; } + .page-sub { font-size: 12px; } +} diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css new file mode 100644 index 0000000..6103d0c --- /dev/null +++ b/frontend/src/styles/tokens.css @@ -0,0 +1,100 @@ +/* ============================================================ + Verdaccio Cache Manager — 双主题 Design Tokens + 一套代码:bright(浅色默认) / dark(暗色) + 依据 doc/design-spec.md §1 与 bright/dark 设计稿 + ============================================================ */ + +:root { + /* —— 多巴胺色板 —— */ + --green: #32CD32; + --green-soft: #E9F9E9; + --green-deep: #1F9E1F; + --orange: #FF8C00; + --orange-soft: #FFF3E2; + --red: #FF4500; + --red-soft: #FFE9E2; + --blue: #1E90FF; + --blue-soft: #E6F2FF; + --purple: #9B5DE5; + --pink: #FF5E8E; + + /* —— 中性色 / 背景 —— */ + --ink: #2B2B33; + --ink-dim: #8A8A94; + --ink-faint: #B9B9C2; + --line: #F1E7DA; + --line-soft: #FBF4E9; + --card: #FFFFFF; + --card-2: #FFFFFF; + --bg: #FFF8EF; + --bg-deep: #FFFDF9; + --panel-soft: #FFFDF8; /* 表头/表格 hover 底 */ + --num-bg: #F4F4F6; /* 数字底 / 中性按钮底 */ + --num-bg-2: #F4F0E8; /* 进度条轨道底 */ + --ignored-card: #FBFBFD; /* 忽略卡片底 */ + + /* —— 字体 —— */ + --num: 'Fredoka', 'PingFang SC', sans-serif; + --sans: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + --display: 'ZCOOL QingKe HuangYou', 'PingFang SC', sans-serif; + + /* —— 圆角 / 阴影 —— */ + --r-lg: 22px; + --r-md: 16px; + --shadow: 0 10px 30px -12px rgba(180, 120, 60, .18), 0 2px 8px -2px rgba(180, 120, 60, .08); + --shadow-hover: 0 18px 44px -14px rgba(180, 120, 60, .30), 0 4px 14px -4px rgba(180, 120, 60, .12); + + /* —— 渐变常量 —— */ + --grad-main: linear-gradient(120deg, #FFB347, #FF8C00); + --grad-warn: linear-gradient(120deg, #FF5E1A, #FF8C00); + --grad-avatar: linear-gradient(135deg, #7CC6FF, #1E90FF); + --grad-pkg-ico: linear-gradient(135deg, #FFE9C7, #FFD9A0); + + /* —— 顶部导航/次级元素 hover —— */ + --nav-hover-bg: #FFF4E6; + + /* —— 主题切换按钮 —— */ + --toggle-bg: var(--card); +} + +html[data-theme="dark"] { + /* —— 多巴胺色板 —— */ + --green: #3DDB4D; + --green-soft: rgba(61, 219, 77, .14); + --green-deep: #3DDB4D; + --orange: #FFA033; + --orange-soft: rgba(255, 160, 51, .14); + --red: #FF5630; + --red-soft: rgba(255, 86, 48, .15); + --blue: #45A2FF; + --blue-soft: rgba(69, 162, 255, .15); + --purple: #B083F0; + --pink: #FF7AA8; + + /* —— 中性色 / 背景 —— */ + --ink: #F1E8DE; + --ink-dim: #A79E91; + --ink-faint: #6E655A; + --line: #3A2F25; + --line-soft: #332A21; + --card: #221B14; + --card-2: #2A211A; + --bg: #16110C; + --bg-deep: #100C08; + --panel-soft: #2E2620; + --num-bg: #2E2620; + --num-bg-2: #2E2620; + --ignored-card: #1C1612; + + /* —— 阴影 —— */ + --shadow: 0 12px 32px -14px rgba(0, 0, 0, .6), 0 2px 8px -2px rgba(0, 0, 0, .4); + --shadow-hover: 0 20px 46px -16px rgba(0, 0, 0, .75), 0 4px 14px -4px rgba(0, 0, 0, .5); + + /* —— 渐变常量 —— */ + --grad-warn: linear-gradient(120deg, #FF5630, #FF8C00); + --grad-avatar: linear-gradient(135deg, #5FA8E8, #2B74B9); + --grad-pkg-ico: linear-gradient(135deg, #4A3622, #5C4326); + + --nav-hover-bg: rgba(255, 160, 51, .10); + --toggle-bg: #2A211A; +} diff --git a/frontend/src/views/IgnoredView.vue b/frontend/src/views/IgnoredView.vue new file mode 100644 index 0000000..02ea4f0 --- /dev/null +++ b/frontend/src/views/IgnoredView.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue new file mode 100644 index 0000000..b88a154 --- /dev/null +++ b/frontend/src/views/LoginView.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/frontend/src/views/LogsView.vue b/frontend/src/views/LogsView.vue new file mode 100644 index 0000000..5853a6a --- /dev/null +++ b/frontend/src/views/LogsView.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/frontend/src/views/OverviewView.vue b/frontend/src/views/OverviewView.vue new file mode 100644 index 0000000..5de24b6 --- /dev/null +++ b/frontend/src/views/OverviewView.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/frontend/src/views/SearchView.vue b/frontend/src/views/SearchView.vue new file mode 100644 index 0000000..d2945e1 --- /dev/null +++ b/frontend/src/views/SearchView.vue @@ -0,0 +1,277 @@ + + + + + diff --git a/frontend/src/views/ThresholdsView.vue b/frontend/src/views/ThresholdsView.vue new file mode 100644 index 0000000..be029f6 --- /dev/null +++ b/frontend/src/views/ThresholdsView.vue @@ -0,0 +1,311 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..68a2017 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,31 @@ +import vue from '@vitejs/plugin-vue' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' +import { defineConfig } from 'vite' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + vue(), + AutoImport({ + resolvers: [ElementPlusResolver()], + imports: ['vue', 'vue-router'], + dts: false, + }), + Components({ + resolvers: [ElementPlusResolver()], + dts: false, + }), + ], + server: { + port: 5173, + proxy: { + // 本地开发代理到后端 API + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + }, + }, +})