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 @@
+
+
+
+
+
+
+
+
+
+
+
+
📦
+
Verdaccio Manager
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
📦
+
+
Verdaccio Manager
+
你的缓存 · 尽在掌握
+
+
+
+
+
+
+
+
👤
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ pkg.name }}
+ 当前 {{ pkg.versions }} 个版本 · {{ pkg.size }} MB
+
+
+
+
+
+
+
+
+
+
将删除 {{ toDelete.length }} 个版本
+
+
+
+
将删除版本({{ toDelete.length }} 个)
+
+ {{ v.version }}
+
+
+
当前版本数未超过保留值,无需清理
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
{{ iconOf(pkg.name) }}
+
+
+
+ {{ splitName(pkg.name).scope }}{{ splitName(pkg.name).base }}
+
+
+
+
超阈值 {{ pkg.versions - threshold }} 个版本
+
元数据异常,不影响其他包
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ {{ label }}
+ {{ ico }}
+
+
+ {{ value }}{{ unit }}
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ {{ ico }}{{ label }}
+ {{ link }}
+
+
+ {{ main }}{{ unit }}
+
+
{{ sub }}
+
+
+
+
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 @@
+
+
+
+ {{ MAP[props.status]?.text }}
+
+
+
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 @@
+
+
+
+
+
🚫已忽略的包
+
共 {{ ignoredList.length }} 个已忽略包 · 移出后自动恢复状态判断
+
+
⏳ 加载中…
+
+
+
+
+ | 包名 |
+ 版本数 |
+ 占用空间 |
+ 忽略时间 |
+ 操作 |
+
+
+
+
+ | {{ p.name }} |
+ {{ p.versions }} |
+ {{ p.size }} MB |
+ {{ p.ignoredAt || '—' }} |
+
+
+ |
+
+
+
+
+ 🎉 还没有忽略任何包
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
📦
+
Verdaccio Manager
+
你的缓存 · 尽在掌握
+
+
+ 👤
+
+
+
+ 🔑
+
+
+
+
+
+
+
演示环境:任意用户名 / 密码即可登录
+
+
+
+
+
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 @@
+
+
+
+
+
🕒最近动态
+
+ 共 {{ state.logs.length }} 条清理记录 · 按时间倒序
+ LIVE
+
+
+
+
+
+
+ | 时间 |
+ 包名 |
+ 删除数量 |
+ 释放空间 |
+ 操作 |
+
+
+
+
+ | {{ fmtTime(log.created_at) }} |
+ {{ log.pkgName }} |
+ − {{ log.deletedCount }} 版 |
+ 🟢 {{ log.freedSpace }} |
+ |
+
+
+
+
暂无清理记录,清理操作会自动记录在这里
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 包名
+ {{ detail.pkgName }}
+
+
+ 操作时间
+ {{ fmtTime(detail.created_at) }}
+
+
+ 删除数量
+ − {{ detail.deletedCount }} 个版本
+
+
+ 释放空间
+ 🟢 {{ detail.freedSpace }}
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🎁你的缓存
+ 共 {{ stats.pkgCount }} 个包 · 预警置顶 · 同状态按版本数降序
+
+
+
⏳ 加载中…
+
+
+ 暂无缓存包,请检查 Verdaccio 缓存目录配置
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+ 🔍
+
+ ✕
+
+
+
+
+ 快捷筛选
+ {{ p.label }}
+ 排序
+ 默认
+ 版本数 ↓
+ 占用大小 ↓
+
+
+
+
+ 共找到 {{ filtered.length }} 个包
+ (关键词「{{ keyword }}」)
+
+
+
⏳ 加载中…
+
+
+
+
+ | 包名 |
+ 版本数 |
+ 占用空间 |
+ 最近更新 |
+ 状态 |
+ 操作 |
+
+
+
+
+ |
+ {{ p.name }}
+ |
+ {{ p.versions }} |
+ {{ p.size }} MB |
+ {{ p.lastUpdated || '—' }} |
+ |
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
没有匹配的包
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
⚙️预警设置
+
全局阈值与单包例外,保存后立即重算包列表状态
+
+
+
+
+
+ 📦版本数阈值
+ 默认 15 · 范围 1~100
+
+
版本数超过阈值的包将标记为预警并置顶,提示清理。
+
+
+
+
+
+
+
个版本
+
恢复默认
+
+
+
+
+
+ 🧊单包大小阈值
+ 默认 500 MB
+
+
单包占用超过该值的包标记为大包,便于定位体积异常缓存。
+
+
+
+
+
+
+
MB
+
恢复默认
+
+
+
+
+
+
+
+ ✅ 设置已生效,包列表状态已重新计算
+ 修改后点击保存生效
+
+
+
+
+
+
+ 🎯 单包例外
+ 为指定包覆盖全局阈值
+
+
+
+
+
+
+ | 包名 |
+ 版本数阈值 |
+ 大小阈值 |
+ 命中 |
+ 操作 |
+
+
+
+
+ | {{ e.pkgName }} |
+ {{ e.versionThreshold }} |
+ {{ e.sizeThreshold }} MB |
+
+
+ {{ hitOf(e.pkgName) ? '✅ 命中' : '💤 未命中' }}
+
+ |
+ |
+
+
+
+
暂无例外配置,可在下方添加
+
+
+
+
+
+
+
+
+
+
+
+
+
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,
+ },
+ },
+ },
+})