feat: 重组项目结构并完善运行管理
This commit is contained in:
@@ -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?
|
||||
@@ -0,0 +1,75 @@
|
||||
# DHT Search Web
|
||||
|
||||
基于 Bun Vue 3 TypeScript Vite Tailwind CSS 和 shadcn-vue 的本地搜索界面
|
||||
|
||||
## 开发
|
||||
|
||||
先在仓库根目录启动后端服务
|
||||
|
||||
```powershell
|
||||
$env:LIBCLANG_PATH = "D:\tools\dht\.tools\libclang\clang\native"
|
||||
cargo run -p dht-search --bin dht-search -- --config dht-search.toml
|
||||
```
|
||||
|
||||
然后打开另一个终端启动前端开发服务
|
||||
|
||||
```powershell
|
||||
cd src/web
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
打开 `http://127.0.0.1:5173`
|
||||
|
||||
Vite 会将搜索详情状态诊断和配置接口代理到 `http://127.0.0.1:8080` 可以通过 `DHT_API_TARGET` 环境变量覆盖目标
|
||||
|
||||
## 检查和构建
|
||||
|
||||
```powershell
|
||||
bun run check
|
||||
```
|
||||
|
||||
生产静态资源输出到 `src/web/dist`
|
||||
|
||||
## 生产运行
|
||||
|
||||
在启动 Rust 服务前构建一次 Web 资源
|
||||
|
||||
```powershell
|
||||
cd src/web
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
cd ../..
|
||||
cargo run --release -p dht-search --bin dht-search -- --config dht-search.toml
|
||||
```
|
||||
|
||||
打开 `http://127.0.0.1:8080`
|
||||
|
||||
Axum 根据配置中的 `http.web_dir` 提供静态资源和单页回退 不需要单独运行 Vite
|
||||
|
||||
## 已实现功能
|
||||
|
||||
- 关键词 文件名片段和精确 infohash 搜索
|
||||
- 自动识别通配符的普通关键词搜索和名称 文件路径正则搜索
|
||||
- 相关度 时间 热度 大小和发现次数排序
|
||||
- 有上限的结果分页和 URL 查询恢复
|
||||
- 基于 reka-ui 的搜索结果数字分页和浏览器持久化每页数量选择
|
||||
- 文件列表 热度 可用性和收录时间详情
|
||||
- 基于 reka-ui 的数字分页文件列表和浏览器持久化每页数量选择
|
||||
- 相同内容的不同 infohash 变体展示
|
||||
- 磁力链接打开和复制
|
||||
- DHT 采集 索引 持久化和验证运行状态
|
||||
- 每秒自动刷新的运行状态和点击外部关闭
|
||||
- 独立的进程 RocksDB Tantivy DHT 和队列诊断页面
|
||||
- 使用懒加载 ECharts 展示内存存储压力 Metadata 吞吐以及 HTTP 请求错误趋势
|
||||
- 按职责分组的完整配置查看编辑校验保存和重启提示
|
||||
- 搜索诊断配置使用互不冲突的单页路由
|
||||
- 加载 空结果 接口错误 重试和移动端适配
|
||||
|
||||
## 添加组件
|
||||
|
||||
当前 shadcn-vue registry 与最新版 CLI 的样式列表暂时不一致 项目使用已验证的 CLI 版本
|
||||
|
||||
```powershell
|
||||
bunx shadcn-vue@2.4.3 add button -y
|
||||
```
|
||||
@@ -0,0 +1,322 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "dht-search-web",
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.29.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"echarts": "^6.1.0",
|
||||
"reka-ui": "^2.10.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"vue": "^3.5.40",
|
||||
"vue-echarts": "^8.1.0",
|
||||
"vue-router": "^4.6.4",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^24.13.3",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0",
|
||||
"vue-tsc": "^3.3.8",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.8", "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.8.tgz", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.8", "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.8.0", "https://registry.npmmirror.com/@floating-ui/core/-/core-1.8.0.tgz", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.8.0", "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.8.0.tgz", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.12", "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.12.tgz", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
|
||||
|
||||
"@floating-ui/vue": ["@floating-ui/vue@1.1.11", "https://registry.npmmirror.com/@floating-ui/vue/-/vue-1.1.11.tgz", { "dependencies": { "@floating-ui/dom": "^1.7.6", "@floating-ui/utils": "^0.2.11", "vue-demi": ">=0.13.0" } }, "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw=="],
|
||||
|
||||
"@internationalized/date": ["@internationalized/date@3.12.3", "https://registry.npmmirror.com/@internationalized/date/-/date-3.12.3.tgz", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q=="],
|
||||
|
||||
"@internationalized/number": ["@internationalized/number@3.6.7", "https://registry.npmmirror.com/@internationalized/number/-/number-3.6.7.tgz", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@lucide/vue": ["@lucide/vue@1.29.0", "https://registry.npmmirror.com/@lucide/vue/-/vue-1.29.0.tgz", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-UnbPPAdxWGqABrjcQJZFqawSgZTBmeKOrNFPI9sqKaDkgLoEbYyx+ivIdiYRR1YoYhWWg7dCdvjtCwK6zBxWDQ=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.143.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.143.0.tgz", {}, "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.3", "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", { "os": "android", "cpu": "arm64" }, "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.3", "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.3", "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.3", "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.3", "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", { "os": "linux", "cpu": "arm" }, "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.3", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.3", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.3", "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.3", "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.3", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", { "os": "linux", "cpu": "x64" }, "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.3", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", { "os": "linux", "cpu": "x64" }, "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.3", "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", { "os": "none", "cpu": "arm64" }, "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.3", "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.3", "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", { "os": "win32", "cpu": "x64" }, "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.23", "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.23.tgz", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.3.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.3.3.tgz", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.3.3.tgz", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
|
||||
|
||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.7", "https://registry.npmmirror.com/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz", {}, "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA=="],
|
||||
|
||||
"@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.35", "https://registry.npmmirror.com/@tanstack/vue-virtual/-/vue-virtual-3.13.35.tgz", { "dependencies": { "@tanstack/virtual-core": "3.17.7" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-lOfSPvgPdlaH6Qy+CyIc3XpycitaSQ9GECndGpTuDiu+uDA1am+90yWXwzDSd/20ZM196ggWJLS+Qb6WjVd/OA=="],
|
||||
|
||||
"@types/node": ["@types/node@24.13.3", "https://registry.npmmirror.com/@types/node/-/node-24.13.3.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="],
|
||||
|
||||
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
|
||||
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.8", "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew=="],
|
||||
|
||||
"@volar/language-core": ["@volar/language-core@2.4.28", "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.28.tgz", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
|
||||
|
||||
"@volar/source-map": ["@volar/source-map@2.4.28", "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.28.tgz", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
|
||||
|
||||
"@volar/typescript": ["@volar/typescript@2.4.28", "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.28.tgz", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="],
|
||||
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.41", "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.41.tgz", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.41", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg=="],
|
||||
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.41", "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", { "dependencies": { "@vue/compiler-core": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw=="],
|
||||
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.41", "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/compiler-core": "3.5.41", "@vue/compiler-dom": "3.5.41", "@vue/compiler-ssr": "3.5.41", "@vue/shared": "3.5.41", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.19", "source-map-js": "^1.2.1" } }, "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ=="],
|
||||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.41", "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A=="],
|
||||
|
||||
"@vue/devtools-api": ["@vue/devtools-api@6.6.4", "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
|
||||
|
||||
"@vue/language-core": ["@vue/language-core@3.3.9", "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.3.9.tgz", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-in/68oAa4BCtVY6n/nkuhLIkV8DHYd2UivedJ6cMZ6UYtlq9jaoaSNUBHYCVO44z3nKg7MdE5OBoHKt5SxeBKQ=="],
|
||||
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.41", "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.41.tgz", { "dependencies": { "@vue/shared": "3.5.41" } }, "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA=="],
|
||||
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.41", "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.41.tgz", { "dependencies": { "@vue/reactivity": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg=="],
|
||||
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.41", "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", { "dependencies": { "@vue/reactivity": "3.5.41", "@vue/runtime-core": "3.5.41", "@vue/shared": "3.5.41", "csstype": "^3.2.3" } }, "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw=="],
|
||||
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.41", "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.41.tgz", { "dependencies": { "@vue/compiler-ssr": "3.5.41", "@vue/runtime-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ=="],
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.41", "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.41.tgz", {}, "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA=="],
|
||||
|
||||
"@vue/tsconfig": ["@vue/tsconfig@0.9.1", "https://registry.npmmirror.com/@vue/tsconfig/-/tsconfig-0.9.1.tgz", { "peerDependencies": { "typescript": ">= 5.8", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w=="],
|
||||
|
||||
"@vueuse/core": ["@vueuse/core@14.4.0", "https://registry.npmmirror.com/@vueuse/core/-/core-14.4.0.tgz", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.4.0", "@vueuse/shared": "14.4.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ=="],
|
||||
|
||||
"@vueuse/metadata": ["@vueuse/metadata@14.4.0", "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.4.0.tgz", {}, "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g=="],
|
||||
|
||||
"@vueuse/shared": ["@vueuse/shared@14.4.0", "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.4.0.tgz", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g=="],
|
||||
|
||||
"alien-signals": ["alien-signals@3.2.1", "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.2.1.tgz", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="],
|
||||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"defu": ["defu@6.1.7", "https://registry.npmmirror.com/defu/-/defu-6.1.7.tgz", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"echarts": ["echarts@6.1.0", "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz", { "dependencies": { "tslib": "2.3.0", "zrender": "6.1.0" } }, "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.24.5", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"estree-walker": ["estree-walker@2.0.2", "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"jiti": ["jiti@2.7.0", "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.33.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.33.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "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" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"muggle-string": ["muggle-string@0.4.1", "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.17", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.17.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g=="],
|
||||
|
||||
"ohash": ["ohash@2.0.11", "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
|
||||
|
||||
"path-browserify": ["path-browserify@1.0.1", "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.5", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||
|
||||
"postcss": ["postcss@8.5.26", "https://registry.npmmirror.com/postcss/-/postcss-8.5.26.tgz", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
|
||||
|
||||
"reka-ui": ["reka-ui@2.10.1", "https://registry.npmmirror.com/reka-ui/-/reka-ui-2.10.1.tgz", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@floating-ui/vue": "^1.1.6", "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", "@vueuse/core": "^14.1.0", "@vueuse/shared": "^14.1.0", "aria-hidden": "^1.2.4", "defu": "^6.1.5", "ohash": "^2.0.11" }, "peerDependencies": { "vue": ">= 3.4.0" } }, "sha512-drcOQ4rQtDYAcGCsyQBqQg8QQ+H3B+zDaMJU0h8KPEPMa7g9BHu3zcOi4OB39XJSWizceFoNO0Z9tctSGLOXqg=="],
|
||||
|
||||
"rolldown": ["rolldown@1.2.3", "https://registry.npmmirror.com/rolldown/-/rolldown-1.2.3.tgz", { "dependencies": { "@oxc-project/types": "=0.143.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.3", "@rolldown/binding-darwin-arm64": "1.2.3", "@rolldown/binding-darwin-x64": "1.2.3", "@rolldown/binding-freebsd-x64": "1.2.3", "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", "@rolldown/binding-linux-arm64-gnu": "1.2.3", "@rolldown/binding-linux-arm64-musl": "1.2.3", "@rolldown/binding-linux-ppc64-gnu": "1.2.3", "@rolldown/binding-linux-s390x-gnu": "1.2.3", "@rolldown/binding-linux-x64-gnu": "1.2.3", "@rolldown/binding-linux-x64-musl": "1.2.3", "@rolldown/binding-openharmony-arm64": "1.2.3", "@rolldown/binding-win32-arm64-msvc": "1.2.3", "@rolldown/binding-win32-x64-msvc": "1.2.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.6.0", "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.6.0.tgz", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.3", "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.3.3.tgz", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||
|
||||
"tapable": ["tapable@2.3.3", "https://registry.npmmirror.com/tapable/-/tapable-2.3.3.tgz", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"tslib": ["tslib@2.3.0", "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "https://registry.npmmirror.com/tw-animate-css/-/tw-animate-css-1.4.0.tgz", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"vite": ["vite@8.2.1", "https://registry.npmmirror.com/vite/-/vite-8.2.1.tgz", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "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" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="],
|
||||
|
||||
"vscode-uri": ["vscode-uri@3.1.0", "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
||||
|
||||
"vue": ["vue@3.5.41", "https://registry.npmmirror.com/vue/-/vue-3.5.41.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/compiler-sfc": "3.5.41", "@vue/runtime-dom": "3.5.41", "@vue/server-renderer": "3.5.41", "@vue/shared": "3.5.41" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg=="],
|
||||
|
||||
"vue-demi": ["vue-demi@0.14.10", "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
|
||||
|
||||
"vue-echarts": ["vue-echarts@8.1.0", "https://registry.npmmirror.com/vue-echarts/-/vue-echarts-8.1.0.tgz", { "peerDependencies": { "echarts": "^6.0.0", "vue": "^3.3.0" } }, "sha512-/uJVwijy3M2vIZ0NcPDgdZVqgboc6zZtC/vERfESau0eFpkHOIujj0sIiMiLP4kztQrckMFnDYWrid+gLI+IOg=="],
|
||||
|
||||
"vue-router": ["vue-router@4.6.4", "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="],
|
||||
|
||||
"vue-tsc": ["vue-tsc@3.3.9", "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.3.9.tgz", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.9" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-TS3Y1ux/IRoE8OCP2PpACAeOseuIs0UvWrcr7u+w3PmfY+SlCfEf8zjrBgnQksHUgLpthi5vHlffcQTQTdPBZA=="],
|
||||
|
||||
"zrender": ["zrender@6.1.0", "https://registry.npmmirror.com/zrender/-/zrender-6.1.0.tgz", { "dependencies": { "tslib": "2.3.0" } }, "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ=="],
|
||||
|
||||
"@swc/helpers/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "https://registry.npmmirror.com/@emnapi/core/-/core-1.11.3.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.3.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.2", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" }, "bundled": true }, "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"aria-hidden/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "new-york",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/style.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"composables": "@/composables"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="本地 DHT 元数据搜索服务" />
|
||||
<script>
|
||||
(() => {
|
||||
const saved = localStorage.getItem('dht-search-theme')
|
||||
const dark = saved === 'dark' || (!saved && matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
document.documentElement.classList.toggle('dark', dark)
|
||||
})()
|
||||
</script>
|
||||
<title>DHT Search</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "dht-search-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"check": "bun run typecheck && bun run build",
|
||||
"typecheck": "vue-tsc -b",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.29.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"echarts": "^6.1.0",
|
||||
"reka-ui": "^2.10.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"vue": "^3.5.40",
|
||||
"vue-echarts": "^8.1.0",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^24.13.3",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0",
|
||||
"vue-tsc": "^3.3.8"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { Activity, ChartLine, Database, Moon, Search, Settings, Sun, X } from '@lucide/vue'
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getStats } from '@/lib/api'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
import type { ServiceStats } from '@/types/api'
|
||||
|
||||
const darkMode = ref(false)
|
||||
const stats = ref<ServiceStats | null>(null)
|
||||
const statsOpen = ref(false)
|
||||
let statsTimer: number | null = null
|
||||
let statsRequestActive = false
|
||||
|
||||
const navigation = [
|
||||
{ to: '/', label: '搜索', icon: Search },
|
||||
{ to: '/system', label: '诊断', icon: ChartLine },
|
||||
{ to: '/settings', label: '配置', icon: Settings },
|
||||
]
|
||||
|
||||
function toggleTheme() {
|
||||
darkMode.value = !darkMode.value
|
||||
document.documentElement.classList.toggle('dark', darkMode.value)
|
||||
localStorage.setItem('dht-search-theme', darkMode.value ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
function diskStateLabel(state: ServiceStats['disk_state']): string {
|
||||
if (state === 'normal') return '正常'
|
||||
if (state === 'draining') return '正在排空'
|
||||
return '只读保护'
|
||||
}
|
||||
|
||||
function closeStatsOnOutsideClick(event: PointerEvent) {
|
||||
if (statsOpen.value && event.target instanceof Element && !event.target.closest('[data-stats-panel]')) {
|
||||
statsOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
if (statsRequestActive) return
|
||||
statsRequestActive = true
|
||||
try { stats.value = await getStats() } catch { stats.value = null } finally { statsRequestActive = false }
|
||||
}
|
||||
|
||||
watch(statsOpen, (open) => {
|
||||
if (statsTimer !== null) window.clearInterval(statsTimer)
|
||||
statsTimer = null
|
||||
if (open) {
|
||||
void loadStats()
|
||||
statsTimer = window.setInterval(() => void loadStats(), 1_000)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
darkMode.value = document.documentElement.classList.contains('dark')
|
||||
document.addEventListener('pointerdown', closeStatsOnOutsideClick)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', closeStatsOnOutsideClick)
|
||||
if (statsTimer !== null) window.clearInterval(statsTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-svh bg-background text-foreground">
|
||||
<header class="sticky top-0 z-30 border-b bg-background/90 backdrop-blur-xl">
|
||||
<div class="mx-auto flex h-15 max-w-7xl items-center gap-2 px-4 sm:px-6">
|
||||
<RouterLink class="mr-1 flex items-center gap-2" to="/">
|
||||
<span class="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground"><Database class="size-4" aria-hidden="true" /></span>
|
||||
<span class="hidden text-sm font-semibold tracking-tight sm:inline">DHT Search</span>
|
||||
</RouterLink>
|
||||
|
||||
<nav class="flex items-center gap-1" aria-label="主导航">
|
||||
<RouterLink v-for="item in navigation" :key="item.to" v-slot="{ isActive }" :to="item.to">
|
||||
<span class="flex h-9 items-center gap-2 rounded-lg px-2.5 text-sm transition-colors" :class="isActive ? 'bg-secondary font-medium text-secondary-foreground' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'">
|
||||
<component :is="item.icon" class="size-4" aria-hidden="true" />
|
||||
<span class="hidden md:inline">{{ item.label }}</span>
|
||||
</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<Button size="icon" variant="ghost" :aria-label="darkMode ? '切换到浅色主题' : '切换到深色主题'" :title="darkMode ? '浅色主题' : '深色主题'" @click="toggleTheme"><Sun v-if="darkMode" /><Moon v-else /></Button>
|
||||
<div class="relative" data-stats-panel>
|
||||
<Button size="icon" variant="ghost" aria-label="运行状态" title="运行状态" @click="statsOpen = !statsOpen"><span class="relative"><Activity class="size-4" /><i class="absolute -right-0.5 -top-0.5 size-1.5 rounded-full" :class="!stats ? 'bg-muted-foreground' : stats.disk_state === 'normal' ? 'bg-emerald-500' : 'bg-amber-500'" /></span></Button>
|
||||
<div v-if="statsOpen" class="absolute right-0 top-11 w-72 rounded-xl border bg-popover p-4 text-popover-foreground shadow-xl">
|
||||
<div class="mb-3 flex items-center"><p class="text-sm font-semibold">服务运行状态</p><button class="ml-auto" aria-label="关闭状态面板" @click="statsOpen = false"><X class="size-4" /></button></div>
|
||||
<div v-if="stats" class="grid grid-cols-2 gap-3 text-xs"><div class="status-cell"><span>磁盘状态</span><b>{{ diskStateLabel(stats.disk_state) }}</b></div><div class="status-cell"><span>磁盘剩余</span><b>{{ stats.disk_available_bytes === null ? '未知' : formatBytes(stats.disk_available_bytes) }}</b></div><div class="status-cell"><span>DHT 节点</span><b>{{ stats.nodes.toLocaleString() }}</b></div><div class="status-cell"><span>已索引内容</span><b>{{ stats.indexed_documents.toLocaleString() }}</b></div><div class="status-cell"><span>获取成功</span><b>{{ stats.metadata_ok.toLocaleString() }}</b></div><div class="status-cell"><span>下载中</span><b>{{ stats.metadata_in_flight }}</b></div><div class="status-cell"><span>新收录</span><b>{{ stats.persistence_inserted.toLocaleString() }}</b></div><div class="status-cell"><span>已过滤</span><b>{{ stats.metadata_filtered.toLocaleString() }}</b></div><div class="status-cell"><span>验证成功</span><b>{{ stats.verification_succeeded.toLocaleString() }}</b></div></div>
|
||||
<p v-else class="py-4 text-center text-xs text-muted-foreground">无法获取服务状态</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronRight, Files, Flame, HardDrive, Radio, Repeat2 } from '@lucide/vue'
|
||||
|
||||
import { availabilityLabel, formatBytes, heatLabel, relativeDate } from '@/lib/format'
|
||||
import type { SearchHit } from '@/types/api'
|
||||
|
||||
defineProps<{ hit: SearchHit }>()
|
||||
defineEmits<{ select: [hit: SearchHit] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="group w-full rounded-xl border bg-card p-5 text-left shadow-xs transition hover:-translate-y-0.5 hover:border-foreground/20 hover:shadow-md" type="button" @click="$emit('select', hit)">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="truncate text-base font-semibold tracking-tight">{{ hit.name }}</h2>
|
||||
<p class="mt-1.5 truncate font-mono text-[11px] text-muted-foreground">{{ hit.info_hash }}</p>
|
||||
</div>
|
||||
<ChevronRight class="mt-1 size-4 shrink-0 text-muted-foreground transition group-hover:translate-x-0.5 group-hover:text-foreground" />
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-x-5 gap-y-2 text-xs text-muted-foreground">
|
||||
<span class="result-meta"><HardDrive />{{ formatBytes(hit.total_size) }}</span>
|
||||
<span class="result-meta"><Files />{{ hit.file_count.toLocaleString() }} 个文件</span>
|
||||
<span class="result-meta"><Repeat2 />{{ hit.variant_count }} 个版本</span>
|
||||
<span class="result-meta"><Flame />{{ heatLabel[hit.heat.level] }} {{ hit.heat.score }}</span>
|
||||
<span class="result-meta" :class="hit.availability.status === 'active' && 'text-emerald-600'"><Radio />{{ availabilityLabel[hit.availability.status] }}</span>
|
||||
<span class="ml-auto">最近发现 {{ relativeDate(hit.last_seen) }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { Check, Copy, Download, LoaderCircle, X } from '@lucide/vue'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AppPagination } from '@/components/ui/pagination'
|
||||
import { AppSelect } from '@/components/ui/select'
|
||||
import { availabilityLabel, formatBytes, formatDate, heatLabel } from '@/lib/format'
|
||||
import type { ContentVariants, TorrentDetail } from '@/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
detail: TorrentDetail | null
|
||||
variants: ContentVariants | null
|
||||
loading: boolean
|
||||
filesLoading: boolean
|
||||
filesError: string
|
||||
error: string
|
||||
}>()
|
||||
const emit = defineEmits<{ close: []; retry: []; 'file-page': [offset: number]; 'file-page-size': [size: number] }>()
|
||||
const copied = ref('')
|
||||
const filePage = computed(() => props.detail ? Math.floor(props.detail.file_offset / props.detail.file_limit) + 1 : 1)
|
||||
const filePageSizeOptions = [
|
||||
{ value: '25', label: '25 条/页' },
|
||||
{ value: '50', label: '50 条/页' },
|
||||
{ value: '100', label: '100 条/页' },
|
||||
{ value: '200', label: '200 条/页' },
|
||||
]
|
||||
|
||||
function fileName(path: string) {
|
||||
const normalized = path.replaceAll('\\', '/')
|
||||
return normalized.slice(normalized.lastIndexOf('/') + 1) || normalized
|
||||
}
|
||||
|
||||
function fileDirectory(path: string) {
|
||||
const normalized = path.replaceAll('\\', '/')
|
||||
const separator = normalized.lastIndexOf('/')
|
||||
return separator > 0 ? normalized.slice(0, separator) : '根目录'
|
||||
}
|
||||
|
||||
async function copy(value: string, type: string) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(value)
|
||||
} else {
|
||||
const input = document.createElement('textarea')
|
||||
input.value = value
|
||||
input.style.position = 'fixed'
|
||||
input.style.opacity = '0'
|
||||
document.body.appendChild(input)
|
||||
input.select()
|
||||
document.execCommand('copy')
|
||||
input.remove()
|
||||
}
|
||||
copied.value = type
|
||||
window.setTimeout(() => { if (copied.value === type) copied.value = '' }, 1600)
|
||||
}
|
||||
|
||||
function changeFilePage(page: number) {
|
||||
if (props.detail) emit('file-page', (page - 1) * props.detail.file_limit)
|
||||
}
|
||||
|
||||
function changeFilePageSize(value: string) {
|
||||
emit('file-page-size', Number(value))
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && props.open) emit('close')
|
||||
}
|
||||
|
||||
watch(() => props.open, (open) => { document.body.style.overflow = open ? 'hidden' : '' })
|
||||
onMounted(() => window.addEventListener('keydown', onKeydown))
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="open" class="fixed inset-0 z-50 bg-black/45 backdrop-blur-[2px]" @click.self="$emit('close')">
|
||||
<Transition appear name="slide">
|
||||
<section aria-label="种子详情" aria-modal="true" class="absolute inset-y-0 right-0 flex w-full max-w-2xl flex-col border-l bg-background shadow-2xl" role="dialog">
|
||||
<header class="flex h-16 shrink-0 items-center border-b px-5">
|
||||
<span class="font-semibold">种子详情</span>
|
||||
<Button class="ml-auto" size="icon" variant="ghost" aria-label="关闭详情" @click="$emit('close')"><X /></Button>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="flex flex-1 items-center justify-center text-sm text-muted-foreground"><LoaderCircle class="mr-2 size-4 animate-spin" />正在加载详情</div>
|
||||
<div v-else-if="error" class="flex flex-1 flex-col items-center justify-center gap-4 px-6 text-center"><p class="text-sm text-destructive">{{ error }}</p><Button variant="outline" @click="$emit('retry')">重新加载</Button></div>
|
||||
<div v-else-if="detail" class="flex-1 overflow-y-auto">
|
||||
<div class="space-y-5 border-b p-5 sm:p-7">
|
||||
<div><h2 class="break-words text-xl font-semibold tracking-tight">{{ detail.name }}</h2><button class="mt-2 flex max-w-full items-center gap-1.5 text-left font-mono text-[11px] leading-5 text-muted-foreground hover:text-foreground" title="复制 infohash" @click="copy(detail.info_hash, 'hash')"><span class="truncate">{{ detail.info_hash }}</span><Check v-if="copied === 'hash'" class="size-3 shrink-0 text-emerald-600" /><Copy v-else class="size-3 shrink-0" /></button></div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button as-child><a :href="detail.magnet_link"><Download />打开磁力链接</a></Button>
|
||||
<Button variant="outline" @click="copy(detail.magnet_link, 'magnet')"><Check v-if="copied === 'magnet'" /><Copy v-else />{{ copied === 'magnet' ? '已复制' : '复制磁力链接' }}</Button>
|
||||
</div>
|
||||
<dl class="grid grid-cols-2 gap-x-5 gap-y-4 rounded-xl bg-muted/55 p-4 text-sm sm:grid-cols-3">
|
||||
<div><dt>总大小</dt><dd>{{ formatBytes(detail.total_size) }}</dd></div>
|
||||
<div><dt>文件数量</dt><dd>{{ detail.file_count.toLocaleString() }}</dd></div>
|
||||
<div><dt>分片大小</dt><dd>{{ formatBytes(detail.piece_length) }}</dd></div>
|
||||
<div><dt>热度</dt><dd>{{ heatLabel[detail.heat.level] }} · {{ detail.heat.score }}</dd></div>
|
||||
<div><dt>可用性</dt><dd>{{ availabilityLabel[detail.availability.status] }}<template v-if="detail.availability.reachable_peers"> · {{ detail.availability.reachable_peers }} Peer</template></dd></div>
|
||||
<div><dt>发现次数</dt><dd>{{ detail.seen_count.toLocaleString() }}</dd></div>
|
||||
<div><dt>首次收录</dt><dd>{{ formatDate(detail.first_seen) }}</dd></div>
|
||||
<div><dt>最近发现</dt><dd>{{ formatDate(detail.last_seen) }}</dd></div>
|
||||
<div><dt>最近验证</dt><dd>{{ formatDate(detail.availability.last_verified_at) }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="border-b p-5 sm:p-7">
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<div><h3 class="font-semibold">文件详情</h3><p class="mt-1 text-xs text-muted-foreground">共 {{ detail.file_count.toLocaleString() }} 个文件</p></div>
|
||||
<div class="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||||
<AppSelect :model-value="String(detail.file_limit)" :options="filePageSizeOptions" label="每页文件数量" @update:model-value="changeFilePageSize" />
|
||||
<AppPagination v-if="detail.file_count > detail.file_limit" :disabled="filesLoading" :page="filePage" :page-size="detail.file_limit" :total="detail.file_count" @update:page="changeFilePage" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative overflow-hidden rounded-xl border bg-card">
|
||||
<div v-if="filesLoading" class="absolute inset-0 z-10 flex items-center justify-center bg-background/75 backdrop-blur-sm"><LoaderCircle class="size-5 animate-spin text-muted-foreground" /></div>
|
||||
<div v-if="filesError" class="border-b bg-destructive/10 px-4 py-2 text-xs text-destructive">{{ filesError }}</div>
|
||||
<div class="divide-y">
|
||||
<div v-for="(file, index) in detail.files" :key="`${detail.file_offset}-${file.path}-${file.size}`" class="group flex items-center gap-3 px-3 py-2 transition-colors hover:bg-muted/45 sm:px-4">
|
||||
<span class="w-7 shrink-0 text-left font-mono text-[10px] tabular-nums text-muted-foreground/70">{{ detail.file_offset + index + 1 }}</span>
|
||||
<div class="min-w-0 flex-1"><p class="truncate text-sm font-medium leading-4" :title="file.path">{{ fileName(file.path) }}</p><p class="truncate text-[10px] leading-4 text-muted-foreground" :title="fileDirectory(file.path)">{{ fileDirectory(file.path) }}</p></div>
|
||||
<span class="w-20 shrink-0 text-right text-xs tabular-nums text-muted-foreground">{{ formatBytes(file.size) }}</span>
|
||||
</div>
|
||||
<p v-if="!detail.files.length" class="px-4 py-8 text-center text-sm text-muted-foreground">没有文件信息</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="variants && variants.total > 1" class="p-5 sm:p-7">
|
||||
<h3 class="mb-1 font-semibold">相同内容的其他版本</h3><p class="mb-3 text-xs text-muted-foreground">文件结构一致,但 infohash 不同</p>
|
||||
<div class="space-y-2">
|
||||
<div v-for="variant in variants.variants" :key="variant.info_hash" class="rounded-lg border p-3" :class="variant.info_hash === detail.info_hash && 'bg-muted/50'">
|
||||
<div class="flex items-center gap-3"><div class="min-w-0 flex-1"><p class="truncate text-sm font-medium">{{ variant.name }}</p><p class="truncate font-mono text-[10px] text-muted-foreground">{{ variant.info_hash }}</p></div><Button size="icon" variant="ghost" aria-label="复制该版本磁力链接" @click="copy(variant.magnet_link, variant.info_hash)"><Check v-if="copied === variant.info_hash" /><Copy v-else /></Button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ButtonVariants } from "."
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "."
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants["variant"]
|
||||
size?: ButtonVariants["size"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: "button",
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Button } from "./Button.vue"
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
"xs": "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
"icon": "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from '@lucide/vue'
|
||||
import {
|
||||
PaginationEllipsis,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
PaginationNext,
|
||||
PaginationPrev,
|
||||
PaginationRoot,
|
||||
} from 'reka-ui'
|
||||
|
||||
defineProps<{
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:page': [page: number] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationRoot :page="page" :items-per-page="pageSize" :total="total" :disabled="disabled" :sibling-count="1" show-edges @update:page="emit('update:page', $event)">
|
||||
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
|
||||
<PaginationPrev class="pagination-button" title="上一页"><ChevronLeft class="size-4" /></PaginationPrev>
|
||||
<template v-for="(item, index) in items" :key="item.type === 'page' ? item.value : `ellipsis-${index}`">
|
||||
<PaginationListItem v-if="item.type === 'page'" class="pagination-button data-[selected]:bg-primary data-[selected]:text-primary-foreground" :value="item.value">{{ item.value }}</PaginationListItem>
|
||||
<PaginationEllipsis v-else class="flex size-8 items-center justify-center text-muted-foreground"><MoreHorizontal class="size-4" /></PaginationEllipsis>
|
||||
</template>
|
||||
<PaginationNext class="pagination-button" title="下一页"><ChevronRight class="size-4" /></PaginationNext>
|
||||
</PaginationList>
|
||||
</PaginationRoot>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AppPagination } from './AppPagination.vue'
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronDown } from '@lucide/vue'
|
||||
import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectPortal,
|
||||
SelectRoot,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectViewport,
|
||||
} from 'reka-ui'
|
||||
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
options: ReadonlyArray<{ value: string; label: string }>
|
||||
label: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectRoot :model-value="modelValue" @update:model-value="(value) => typeof value === 'string' && emit('update:modelValue', value)">
|
||||
<SelectTrigger :aria-label="label" class="flex h-9 min-w-28 items-center justify-between gap-2 rounded-md border bg-background px-3 text-sm outline-none transition hover:bg-accent focus:ring-3 focus:ring-ring/20 data-[placeholder]:text-muted-foreground">
|
||||
<SelectValue />
|
||||
<ChevronDown class="size-4 text-muted-foreground" />
|
||||
</SelectTrigger>
|
||||
<SelectPortal>
|
||||
<SelectContent class="z-50 min-w-[var(--reka-select-trigger-width)] overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-lg" position="popper" :side-offset="5">
|
||||
<SelectViewport class="p-1">
|
||||
<SelectItem v-for="option in options" :key="option.value" :value="option.value" class="relative flex cursor-default select-none items-center rounded-md py-2 pl-8 pr-3 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50">
|
||||
<SelectItemIndicator class="absolute left-2 flex size-4 items-center justify-center"><Check class="size-4" /></SelectItemIndicator>
|
||||
<SelectItemText>{{ option.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectViewport>
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</SelectRoot>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AppSelect } from './AppSelect.vue'
|
||||
@@ -0,0 +1,83 @@
|
||||
import type {
|
||||
ContentVariants,
|
||||
ConfigSnapshot,
|
||||
ConfigUpdateRequest,
|
||||
CurrentDiagnostics,
|
||||
DiagnosticHistory,
|
||||
SearchPage,
|
||||
SearchSort,
|
||||
ServiceStats,
|
||||
TorrentDetail,
|
||||
} from '@/types/api'
|
||||
|
||||
interface SearchInput {
|
||||
query: string
|
||||
offset: number
|
||||
limit: number
|
||||
sort: SearchSort
|
||||
regex: boolean
|
||||
}
|
||||
|
||||
async function request<T>(path: string, signal?: AbortSignal, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers: { Accept: 'application/json', ...init?.headers },
|
||||
signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
let message = `请求失败 (${response.status})`
|
||||
try {
|
||||
const body = (await response.json()) as { error?: string }
|
||||
if (body.error) message = body.error
|
||||
} catch {
|
||||
// 保留包含状态码的稳定错误信息
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
export function search(input: SearchInput, signal?: AbortSignal): Promise<SearchPage> {
|
||||
const params = new URLSearchParams({
|
||||
q: input.query.trim(),
|
||||
offset: String(input.offset),
|
||||
limit: String(input.limit),
|
||||
sort: input.sort,
|
||||
})
|
||||
if (input.regex) params.set('mode', 'regex')
|
||||
return request<SearchPage>(`/search?${params}`, signal)
|
||||
}
|
||||
|
||||
export function getTorrent(infoHash: string, fileOffset = 0, fileLimit = 100, signal?: AbortSignal): Promise<TorrentDetail> {
|
||||
const params = new URLSearchParams({ file_offset: String(fileOffset), file_limit: String(fileLimit) })
|
||||
return request<TorrentDetail>(`/torrents/${encodeURIComponent(infoHash)}?${params}`, signal)
|
||||
}
|
||||
|
||||
export function getVariants(contentKey: string, signal?: AbortSignal): Promise<ContentVariants> {
|
||||
return request<ContentVariants>(`/contents/${encodeURIComponent(contentKey)}?limit=100`, signal)
|
||||
}
|
||||
|
||||
export function getStats(signal?: AbortSignal): Promise<ServiceStats> {
|
||||
return request<ServiceStats>('/stats', signal)
|
||||
}
|
||||
|
||||
export function getCurrentDiagnostics(signal?: AbortSignal): Promise<CurrentDiagnostics> {
|
||||
return request<CurrentDiagnostics>('/diagnostics/current', signal)
|
||||
}
|
||||
|
||||
export function getDiagnosticHistory(rangeSecs: number, resolution: 'raw' | 'minute', signal?: AbortSignal): Promise<DiagnosticHistory> {
|
||||
const params = new URLSearchParams({ range_secs: String(rangeSecs), resolution })
|
||||
return request<DiagnosticHistory>(`/diagnostics/history?${params}`, signal)
|
||||
}
|
||||
|
||||
export function getConfig(signal?: AbortSignal): Promise<ConfigSnapshot> {
|
||||
return request<ConfigSnapshot>('/config', signal)
|
||||
}
|
||||
|
||||
export function updateConfig(input: ConfigUpdateRequest, signal?: AbortSignal): Promise<ConfigSnapshot> {
|
||||
return request<ConfigSnapshot>('/config', signal, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { AvailabilityStatus, HeatLevel } from '@/types/api'
|
||||
|
||||
const sizeUnits = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
||||
const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), sizeUnits.length - 1)
|
||||
const value = bytes / 1024 ** unit
|
||||
return `${value >= 100 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${sizeUnits[unit]}`
|
||||
}
|
||||
|
||||
export function formatDate(timestamp: number | null): string {
|
||||
if (!timestamp) return '暂无'
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(timestamp * 1000))
|
||||
}
|
||||
|
||||
export function relativeDate(timestamp: number | null): string {
|
||||
if (!timestamp) return '暂无'
|
||||
const seconds = timestamp - Date.now() / 1000
|
||||
const absolute = Math.abs(seconds)
|
||||
const formatter = new Intl.RelativeTimeFormat('zh-CN', { numeric: 'auto' })
|
||||
if (absolute < 60) return formatter.format(Math.round(seconds), 'second')
|
||||
if (absolute < 3600) return formatter.format(Math.round(seconds / 60), 'minute')
|
||||
if (absolute < 86_400) return formatter.format(Math.round(seconds / 3600), 'hour')
|
||||
if (absolute < 2_592_000) return formatter.format(Math.round(seconds / 86_400), 'day')
|
||||
return formatDate(timestamp)
|
||||
}
|
||||
|
||||
export const availabilityLabel: Record<AvailabilityStatus, string> = {
|
||||
active: '可用',
|
||||
possibly_stale: '可能失效',
|
||||
unknown: '未验证',
|
||||
}
|
||||
|
||||
export const heatLabel: Record<HeatLevel, string> = {
|
||||
hot: '热门',
|
||||
active: '活跃',
|
||||
normal: '一般',
|
||||
cold: '冷门',
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ClassValue } from "clsx"
|
||||
import { clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { AlertTriangle, Check, FileCog, LoaderCircle, RotateCcw, Save } from '@lucide/vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getConfig, updateConfig } from '@/lib/api'
|
||||
import type { AppConfigDto, ConfigSnapshot } from '@/types/api'
|
||||
|
||||
type ConfigFieldType = 'text' | 'number' | 'boolean' | 'select'
|
||||
|
||||
interface ConfigField {
|
||||
path: string
|
||||
label: string
|
||||
description: string
|
||||
type: ConfigFieldType
|
||||
nullable?: boolean
|
||||
options?: Array<{ value: string; label: string }>
|
||||
}
|
||||
|
||||
interface ConfigSection {
|
||||
title: string
|
||||
description: string
|
||||
fields: ConfigField[]
|
||||
}
|
||||
|
||||
const sections: ConfigSection[] = [
|
||||
{ title: '基础', description: '数据目录、任务队列和索引提交', fields: [
|
||||
{ path: 'data_dir', label: '数据目录', description: 'RocksDB 和 Tantivy 的根目录', type: 'text' },
|
||||
{ path: 'content_filter_file', label: '内容过滤规则', description: '无效文件隐藏规则 TOML', type: 'text' },
|
||||
{ path: 'persistence_queue_capacity', label: '持久化队列容量', description: 'Metadata 到 RocksDB 的有界队列', type: 'number' },
|
||||
{ path: 'stats_interval_secs', label: '状态日志间隔', description: '运行状态日志输出秒数', type: 'number' },
|
||||
{ path: 'run_duration_secs', label: '运行时长', description: '留空表示持续运行', type: 'number', nullable: true },
|
||||
{ path: 'index_batch_size', label: '索引批量大小', description: '单次提交的内容组数量', type: 'number' },
|
||||
{ path: 'index_interval_millis', label: '索引提交间隔', description: 'Tantivy 批量提交毫秒数', type: 'number' },
|
||||
] },
|
||||
{ title: 'DHT 网络', description: 'UDP 预算、采样和 Metadata 下载并发', fields: [
|
||||
{ path: 'dht.port', label: 'DHT 端口', description: '本机 UDP 监听端口', type: 'number' },
|
||||
{ path: 'dht.netmode', label: '网络模式', description: 'DHT 使用的 IP 协议族', type: 'select', options: [{ value: 'ipv4-only', label: '仅 IPv4' }, { value: 'ipv6-only', label: '仅 IPv6' }, { value: 'dual-stack', label: '双栈' }] },
|
||||
{ path: 'dht.hash_queue_capacity', label: 'InfoHash 队列', description: '待处理 Hash 有界容量', type: 'number' },
|
||||
{ path: 'dht.max_outbound_queries_per_second', label: 'UDP 总速率', description: '所有主动 DHT 查询每秒硬上限', type: 'number' },
|
||||
{ path: 'dht.outbound_query_burst', label: 'UDP 突发', description: '空闲后可立即消费的查询数', type: 'number' },
|
||||
{ path: 'dht.find_node_queries_per_second', label: 'Find Node 速率', description: '每秒节点探测查询', type: 'number' },
|
||||
{ path: 'dht.find_node_max_in_flight', label: 'Find Node 在途', description: '同时等待响应的查询数', type: 'number' },
|
||||
{ path: 'dht.new_destinations_per_minute', label: '新目标速率', description: '每分钟首次探测的目标数', type: 'number' },
|
||||
{ path: 'dht.peer_lookups_per_second', label: 'Peer Lookup 速率', description: '每秒启动的 Peer 查找', type: 'number' },
|
||||
{ path: 'dht.peer_lookup_max_active', label: 'Peer Lookup 并发', description: '同时运行的 Peer 查找', type: 'number' },
|
||||
{ path: 'dht.sample_queries_per_second', label: 'BEP-51 采样速率', description: '每秒采样查询数', type: 'number' },
|
||||
{ path: 'dht.sample_max_in_flight', label: '采样在途', description: '同时等待的采样请求数', type: 'number' },
|
||||
{ path: 'dht.sample_new_node_percent', label: '新节点采样比例', description: '分流到直接采样的百分比', type: 'number' },
|
||||
{ path: 'dht.sample_candidate_queue_capacity', label: '采样候选队列', description: '新节点采样通道容量', type: 'number' },
|
||||
{ path: 'dht.sample_fallback_to_iterative', label: '采样递归回退', description: '直接采样失败后继续迭代查找', type: 'boolean' },
|
||||
{ path: 'dht.metadata_timeout_secs', label: 'Metadata 超时', description: '单次下载总超时秒数', type: 'number' },
|
||||
{ path: 'dht.metadata_queue_capacity', label: 'Metadata 队列', description: '待下载任务容量', type: 'number' },
|
||||
{ path: 'dht.metadata_workers', label: 'Metadata Worker', description: '同时处理的下载任务数', type: 'number' },
|
||||
{ path: 'dht.metadata_connects_per_second', label: 'Metadata 建连速率', description: '每秒 TCP 连接硬上限', type: 'number' },
|
||||
] },
|
||||
{ title: 'Metadata 安全限制', description: '拒绝异常或恶意元数据的资源边界', fields: [
|
||||
{ path: 'metadata_limits.max_metadata_bytes', label: '最大 Metadata', description: '单条 Metadata 最大字节数', type: 'number' },
|
||||
{ path: 'metadata_limits.max_files', label: '最大文件数', description: '单个种子允许的文件数量', type: 'number' },
|
||||
{ path: 'metadata_limits.max_name_bytes', label: '最大名称长度', description: '种子名称最大字节数', type: 'number' },
|
||||
{ path: 'metadata_limits.max_path_bytes', label: '最大路径长度', description: '文件路径最大字节数', type: 'number' },
|
||||
{ path: 'metadata_limits.max_path_depth', label: '最大目录层级', description: '文件路径允许的目录深度', type: 'number' },
|
||||
] },
|
||||
{ title: '磁盘与备份', description: '空间保护和 RocksDB 在线检查点', fields: [
|
||||
{ path: 'disk_guard.enabled', label: '磁盘保护', description: '空间不足时自动进入只读状态', type: 'boolean' },
|
||||
{ path: 'disk_guard.check_interval_secs', label: '磁盘检查间隔', description: '剩余空间检查秒数', type: 'number' },
|
||||
{ path: 'disk_guard.minimum_free_bytes', label: '只读阈值', description: '低于该字节数停止写入', type: 'number' },
|
||||
{ path: 'disk_guard.resume_free_bytes', label: '恢复阈值', description: '高于该字节数恢复写入', type: 'number' },
|
||||
{ path: 'backup.enabled', label: '自动备份', description: '启用 RocksDB 在线检查点', type: 'boolean' },
|
||||
{ path: 'backup.directory', label: '备份目录', description: '检查点保存位置', type: 'text' },
|
||||
{ path: 'backup.interval_secs', label: '备份间隔', description: '自动检查点间隔秒数', type: 'number' },
|
||||
{ path: 'backup.retain_checkpoints', label: '备份保留数量', description: '保留最近检查点数量', type: 'number' },
|
||||
{ path: 'backup.create_on_start', label: '启动时备份', description: '服务启动后立即创建检查点', type: 'boolean' },
|
||||
] },
|
||||
{ title: '运行诊断', description: '独立 SQLite 历史采样与保留', fields: [
|
||||
{ path: 'diagnostics.enabled', label: '运行诊断', description: '采集并保存资源快照', type: 'boolean' },
|
||||
{ path: 'diagnostics.database', label: '诊断数据库', description: 'SQLite 数据库路径', type: 'text' },
|
||||
{ path: 'diagnostics.sample_interval_secs', label: '采样间隔', description: '实时采样秒数', type: 'number' },
|
||||
{ path: 'diagnostics.raw_retention_hours', label: '原始数据保留', description: '原始采样保留小时数', type: 'number' },
|
||||
{ path: 'diagnostics.minute_retention_days', label: '分钟数据保留', description: '分钟快照保留天数', type: 'number' },
|
||||
{ path: 'diagnostics.queue_capacity', label: '诊断写入队列', description: 'SQLite writer 有界容量', type: 'number' },
|
||||
] },
|
||||
{ title: '日志', description: '终端和滚动文件日志', fields: [
|
||||
{ path: 'logging.directory', label: '日志目录', description: '滚动日志文件位置', type: 'text' },
|
||||
{ path: 'logging.file_enabled', label: '文件日志', description: '写入滚动日志文件', type: 'boolean' },
|
||||
{ path: 'logging.console_enabled', label: '终端日志', description: '同时输出到当前终端', type: 'boolean' },
|
||||
{ path: 'logging.rotation', label: '轮转周期', description: '日志文件切换周期', type: 'select', options: [{ value: 'minutely', label: '每分钟' }, { value: 'hourly', label: '每小时' }, { value: 'daily', label: '每天' }, { value: 'never', label: '不轮转' }] },
|
||||
{ path: 'logging.retain_files', label: '日志保留数量', description: '最多保留的匹配日志文件', type: 'number' },
|
||||
{ path: 'logging.file_prefix', label: '日志前缀', description: '滚动日志文件名前缀', type: 'text' },
|
||||
] },
|
||||
{ title: 'HTTP 与验证', description: 'Web 服务和按需种子有效性验证', fields: [
|
||||
{ path: 'http.listen', label: 'HTTP 监听地址', description: 'Web 与 API 监听地址', type: 'text' },
|
||||
{ path: 'http.web_dir', label: 'Web 静态目录', description: '前端生产构建目录', type: 'text' },
|
||||
{ path: 'verification.enabled', label: '按需验证', description: '搜索和详情触发异步验证', type: 'boolean' },
|
||||
{ path: 'verification.queue_capacity', label: '验证队列', description: '持久化验证任务容量', type: 'number' },
|
||||
{ path: 'verification.max_active', label: '验证并发', description: '同时验证的种子数量', type: 'number' },
|
||||
{ path: 'verification.max_peer_attempts', label: 'Peer 尝试数', description: '每个种子最多握手数量', type: 'number' },
|
||||
{ path: 'verification.lease_secs', label: '验证租约', description: '异常退出后的任务恢复秒数', type: 'number' },
|
||||
{ path: 'verification.poll_interval_millis', label: '验证轮询间隔', description: '队列轮询毫秒数', type: 'number' },
|
||||
] },
|
||||
]
|
||||
|
||||
const snapshot = ref<ConfigSnapshot | null>(null)
|
||||
const config = ref<AppConfigDto | null>(null)
|
||||
const original = ref('')
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const saved = ref(false)
|
||||
const dirty = computed(() => config.value !== null && JSON.stringify(config.value) !== original.value)
|
||||
|
||||
function fieldValue(path: string): unknown {
|
||||
if (!config.value) return ''
|
||||
return path.split('.').reduce<unknown>((value, key) => (value as Record<string, unknown>)[key], config.value)
|
||||
}
|
||||
|
||||
function updateField(field: ConfigField, event: Event) {
|
||||
if (!config.value) return
|
||||
const input = event.target as HTMLInputElement | HTMLSelectElement
|
||||
let value: unknown
|
||||
if (field.type === 'boolean') value = (input as HTMLInputElement).checked
|
||||
else if (field.type === 'number') value = field.nullable && input.value === '' ? null : Number(input.value)
|
||||
else value = input.value
|
||||
const keys = field.path.split('.')
|
||||
const last = keys.pop()
|
||||
if (!last) return
|
||||
let target = config.value as unknown as Record<string, unknown>
|
||||
for (const key of keys) target = target[key] as Record<string, unknown>
|
||||
target[last] = value
|
||||
saved.value = false
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const next = await getConfig()
|
||||
snapshot.value = next
|
||||
config.value = structuredClone(next.config)
|
||||
original.value = JSON.stringify(next.config)
|
||||
saved.value = false
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : '无法读取配置'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!snapshot.value || !config.value || saving.value || !dirty.value) return
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const next = await updateConfig({ revision: snapshot.value.revision, config: config.value })
|
||||
snapshot.value = next
|
||||
config.value = structuredClone(next.config)
|
||||
original.value = JSON.stringify(next.config)
|
||||
saved.value = true
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : '配置保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function beforeUnload(event: BeforeUnloadEvent) {
|
||||
if (!dirty.value) return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('beforeunload', beforeUnload)
|
||||
void load()
|
||||
})
|
||||
onBeforeUnmount(() => window.removeEventListener('beforeunload', beforeUnload))
|
||||
onBeforeRouteLeave(() => !dirty.value || window.confirm('配置尚未保存,确定离开吗'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="mx-auto max-w-6xl px-4 py-7 sm:px-6 sm:py-10">
|
||||
<div class="mb-6 flex flex-wrap items-start gap-3">
|
||||
<div><h1 class="text-xl font-semibold tracking-tight">配置管理</h1><p class="mt-1 text-sm text-muted-foreground">修改经过完整校验后原子保存到 TOML</p></div>
|
||||
<div class="ml-auto flex gap-2"><Button :disabled="loading || saving" variant="outline" @click="load"><RotateCcw />重新加载</Button><Button :disabled="!dirty || saving" @click="save"><LoaderCircle v-if="saving" class="animate-spin" /><Save v-else />保存配置</Button></div>
|
||||
</div>
|
||||
|
||||
<div v-if="snapshot?.restart_required" class="mb-4 flex items-start gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-4 text-sm"><AlertTriangle class="mt-0.5 size-4 shrink-0 text-amber-500" /><div><p class="font-medium">配置已保存,需要重启服务后生效</p><p class="mt-1 text-muted-foreground">当前进程仍使用启动时的配置</p></div></div>
|
||||
<div v-if="snapshot?.command_line_overrides.length" class="mb-4 flex items-start gap-3 rounded-xl border bg-muted/40 p-4 text-sm"><FileCog class="mt-0.5 size-4 shrink-0" /><div><p class="font-medium">存在命令行覆盖</p><p class="mt-1 text-muted-foreground">{{ snapshot.command_line_overrides.join('、') }} 会优先于配置文件</p></div></div>
|
||||
<div v-if="saved" class="mb-4 flex items-center gap-2 rounded-xl border border-emerald-500/30 bg-emerald-500/5 p-4 text-sm text-emerald-700 dark:text-emerald-400"><Check class="size-4" />配置已安全保存</div>
|
||||
<div v-if="error" class="mb-4 rounded-xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{{ error }}</div>
|
||||
|
||||
<div v-if="loading" class="flex min-h-72 items-center justify-center text-sm text-muted-foreground"><LoaderCircle class="mr-2 size-5 animate-spin" />正在读取配置</div>
|
||||
<form v-else-if="config" class="space-y-4" @submit.prevent="save">
|
||||
<section v-for="section in sections" :key="section.title" class="rounded-xl border bg-card shadow-sm">
|
||||
<div class="border-b px-5 py-4"><h2 class="font-semibold">{{ section.title }}</h2><p class="mt-1 text-sm text-muted-foreground">{{ section.description }}</p></div>
|
||||
<div class="grid gap-x-6 px-5 sm:grid-cols-2">
|
||||
<label v-for="field in section.fields" :key="field.path" class="config-field" :class="field.type === 'boolean' ? 'flex-row items-center' : 'flex-col'">
|
||||
<span class="min-w-0 flex-1"><b>{{ field.label }}</b><small>{{ field.description }}</small></span>
|
||||
<input v-if="field.type === 'boolean'" class="size-4 accent-foreground" type="checkbox" :checked="Boolean(fieldValue(field.path))" @change="updateField(field, $event)" />
|
||||
<select v-else-if="field.type === 'select'" class="config-input" :value="String(fieldValue(field.path))" @change="updateField(field, $event)"><option v-for="option in field.options" :key="option.value" :value="option.value">{{ option.label }}</option></select>
|
||||
<input v-else class="config-input" :type="field.type" :value="fieldValue(field.path) ?? ''" :min="field.type === 'number' ? 0 : undefined" step="1" @input="updateField(field, $event)" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<div class="flex flex-wrap items-center gap-3 rounded-xl border bg-muted/30 px-4 py-3 text-xs text-muted-foreground"><span class="truncate">配置文件:{{ snapshot?.source }}</span><span v-if="dirty" class="ml-auto text-foreground">有未保存修改</span></div>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { AlertTriangle, Cpu, Database, Gauge, HardDrive, LoaderCircle, MemoryStick, Network } from '@lucide/vue'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import VChart from 'vue-echarts'
|
||||
|
||||
import { AppSelect } from '@/components/ui/select'
|
||||
import { getCurrentDiagnostics, getDiagnosticHistory } from '@/lib/api'
|
||||
import { formatBytes, formatDate } from '@/lib/format'
|
||||
import type { CurrentDiagnostics, DiagnosticHistory, DiagnosticSample } from '@/types/api'
|
||||
|
||||
use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent])
|
||||
|
||||
const rangeSecs = ref(3_600)
|
||||
const current = ref<CurrentDiagnostics | null>(null)
|
||||
const history = ref<DiagnosticHistory | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
let currentTimer: number | null = null
|
||||
let historyTimer: number | null = null
|
||||
let currentRequest = false
|
||||
let historyRequest = false
|
||||
|
||||
const rangeOptions = [
|
||||
{ value: '3600', label: '最近 1 小时' },
|
||||
{ value: '21600', label: '最近 6 小时' },
|
||||
{ value: '86400', label: '最近 24 小时' },
|
||||
{ value: '604800', label: '最近 7 天' },
|
||||
{ value: '2592000', label: '最近 30 天' },
|
||||
]
|
||||
|
||||
const sample = computed(() => current.value?.sample ?? null)
|
||||
const samples = computed(() => history.value?.samples ?? [])
|
||||
|
||||
function chartOption(series: Array<{ name: string; values: Array<number | null> }>, suffix: string): EChartsOption {
|
||||
const labels = samples.value.map((item) => new Date(item.captured_at * 1000).toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit', month: '2-digit', day: '2-digit' }))
|
||||
return {
|
||||
animation: false,
|
||||
color: ['#2563eb', '#10b981', '#f59e0b', '#8b5cf6'],
|
||||
grid: { left: 48, right: 16, top: 36, bottom: 30 },
|
||||
legend: { top: 0, textStyle: { color: '#737373' } },
|
||||
tooltip: { trigger: 'axis', valueFormatter: (value) => `${Number(value).toFixed(1)} ${suffix}` },
|
||||
xAxis: { type: 'category', data: labels, boundaryGap: false, axisLabel: { color: '#737373', hideOverlap: true }, axisLine: { lineStyle: { color: '#a3a3a3' } } },
|
||||
yAxis: { type: 'value', axisLabel: { color: '#737373', formatter: `{value} ${suffix}` }, splitLine: { lineStyle: { color: 'rgba(115,115,115,.15)' } } },
|
||||
series: series.map((item) => ({ name: item.name, type: 'line', data: item.values, showSymbol: false, smooth: true, connectNulls: true, lineStyle: { width: 2 }, areaStyle: { opacity: 0.05 } })),
|
||||
}
|
||||
}
|
||||
|
||||
const memoryChart = computed(() => chartOption([
|
||||
{ name: '工作集', values: samples.value.map((item) => bytesToMiB(item.process.resident_memory_bytes)) },
|
||||
{ name: '私有内存', values: samples.value.map((item) => bytesToMiB(item.process.private_memory_bytes)) },
|
||||
], 'MiB'))
|
||||
|
||||
const storageChart = computed(() => chartOption([
|
||||
{ name: 'Block Cache', values: samples.value.map((item) => bytesToMiB(item.storage.block_cache_bytes)) },
|
||||
{ name: 'MemTable', values: samples.value.map((item) => bytesToMiB(item.storage.memtable_bytes)) },
|
||||
{ name: '待压缩', values: samples.value.map((item) => bytesToMiB(item.storage.pending_compaction_bytes)) },
|
||||
], 'MiB'))
|
||||
|
||||
const throughputChart = computed(() => chartOption([
|
||||
{ name: 'UDP 发送', values: perSecond(samples.value, (item) => item.runtime.udp_tx_packets) },
|
||||
{ name: 'Metadata 成功', values: perSecond(samples.value, (item) => item.runtime.metadata_succeeded) },
|
||||
{ name: 'Metadata 失败', values: perSecond(samples.value, (item) => item.runtime.metadata_failed) },
|
||||
], '/s'))
|
||||
|
||||
const httpChart = computed(() => chartOption([
|
||||
{ name: 'HTTP 请求', values: perSecond(samples.value, (item) => item.http.requests) },
|
||||
{ name: '客户端错误', values: perSecond(samples.value, (item) => item.http.client_errors) },
|
||||
{ name: '服务端错误', values: perSecond(samples.value, (item) => item.http.server_errors) },
|
||||
], '/s'))
|
||||
|
||||
function bytesToMiB(value: number | null): number | null {
|
||||
return value === null ? null : Number((value / 1_048_576).toFixed(2))
|
||||
}
|
||||
|
||||
function perSecond(values: DiagnosticSample[], select: (sample: DiagnosticSample) => number): Array<number | null> {
|
||||
return values.map((item, index) => {
|
||||
if (index === 0) return null
|
||||
const previous = values[index - 1]
|
||||
if (!previous) return null
|
||||
const elapsed = item.captured_at - previous.captured_at
|
||||
if (elapsed <= 0 || item.session_started_at !== previous.session_started_at) return null
|
||||
return Number((Math.max(0, select(item) - select(previous)) / elapsed).toFixed(2))
|
||||
})
|
||||
}
|
||||
|
||||
async function loadCurrent() {
|
||||
if (currentRequest) return
|
||||
currentRequest = true
|
||||
try {
|
||||
current.value = await getCurrentDiagnostics()
|
||||
if (!current.value.status.enabled) error.value = '运行诊断未启用'
|
||||
else if (error.value === '运行诊断未启用') error.value = ''
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : '无法读取运行诊断'
|
||||
} finally {
|
||||
currentRequest = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
if (historyRequest) return
|
||||
historyRequest = true
|
||||
const resolution = rangeSecs.value <= 86_400 ? 'raw' : 'minute'
|
||||
try {
|
||||
history.value = await getDiagnosticHistory(rangeSecs.value, resolution)
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : '无法读取诊断历史'
|
||||
} finally {
|
||||
historyRequest = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function changeRange(value: string) {
|
||||
rangeSecs.value = Number(value)
|
||||
}
|
||||
|
||||
watch(rangeSecs, () => void loadHistory())
|
||||
|
||||
onMounted(() => {
|
||||
void Promise.all([loadCurrent(), loadHistory()])
|
||||
currentTimer = window.setInterval(() => void loadCurrent(), 2_000)
|
||||
historyTimer = window.setInterval(() => void loadHistory(), 10_000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (currentTimer !== null) window.clearInterval(currentTimer)
|
||||
if (historyTimer !== null) window.clearInterval(historyTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="mx-auto max-w-7xl px-4 py-7 sm:px-6 sm:py-10">
|
||||
<div class="mb-6 flex flex-wrap items-center gap-3">
|
||||
<div><h1 class="text-xl font-semibold tracking-tight">运行诊断</h1><p class="mt-1 text-sm text-muted-foreground">资源占用、存储压力和采集吞吐</p></div>
|
||||
<AppSelect class="ml-auto" :model-value="String(rangeSecs)" :options="rangeOptions" label="诊断时间范围" @update:model-value="changeRange" />
|
||||
</div>
|
||||
|
||||
<div v-if="current?.status.last_error || current?.status.write_failures" class="mb-4 flex items-start gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-4 text-sm"><AlertTriangle class="mt-0.5 size-4 shrink-0 text-amber-500" /><div><p class="font-medium">诊断数据写入异常</p><p class="mt-1 text-muted-foreground">{{ current.status.last_error ?? `${current.status.write_failures} 次写入失败` }}</p></div></div>
|
||||
<div v-if="error" class="mb-4 rounded-xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{{ error }}</div>
|
||||
|
||||
<div v-if="loading && !sample" class="flex min-h-72 items-center justify-center text-sm text-muted-foreground"><LoaderCircle class="mr-2 size-5 animate-spin" />正在读取诊断数据</div>
|
||||
<template v-else-if="sample">
|
||||
<section class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<article class="diagnostic-card"><MemoryStick /><span>私有内存</span><b>{{ sample.process.private_memory_bytes === null ? '未知' : formatBytes(sample.process.private_memory_bytes) }}</b></article>
|
||||
<article class="diagnostic-card"><Cpu /><span>CPU 时间</span><b>{{ sample.process.cpu_time_millis === null ? '未知' : `${(sample.process.cpu_time_millis / 1000).toFixed(1)} 秒` }}</b></article>
|
||||
<article class="diagnostic-card"><Network /><span>DHT 节点</span><b>{{ sample.runtime.nodes.toLocaleString() }}</b></article>
|
||||
<article class="diagnostic-card"><Gauge /><span>Metadata 下载中</span><b>{{ sample.runtime.metadata_in_flight.toLocaleString() }}</b></article>
|
||||
<article class="diagnostic-card"><Database /><span>RocksDB 缓存</span><b>{{ sample.storage.block_cache_bytes === null ? '未知' : formatBytes(sample.storage.block_cache_bytes) }}</b></article>
|
||||
<article class="diagnostic-card"><HardDrive /><span>待 Compaction</span><b>{{ sample.storage.pending_compaction_bytes === null ? '未知' : formatBytes(sample.storage.pending_compaction_bytes) }}</b></article>
|
||||
<article class="diagnostic-card"><Database /><span>索引文档</span><b>{{ sample.search.documents.toLocaleString() }}</b></article>
|
||||
<article class="diagnostic-card"><Gauge /><span>最近索引提交</span><b>{{ sample.search.last_commit_duration_millis }} ms</b></article>
|
||||
<article class="diagnostic-card"><Network /><span>HTTP 并发</span><b>{{ sample.http.active_requests }}</b></article>
|
||||
<article class="diagnostic-card"><Gauge /><span>HTTP P95</span><b>{{ sample.http.latency_p95_millis === null ? '暂无' : `${sample.http.latency_p95_millis} ms` }}</b></article>
|
||||
</section>
|
||||
|
||||
<section class="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
<article class="chart-card"><div><h2>进程内存</h2><p>{{ sample.process.thread_count ?? '未知' }} 个线程 · {{ sample.process.handle_count ?? '未知' }} 个句柄</p></div><VChart class="h-72" :option="memoryChart" autoresize /></article>
|
||||
<article class="chart-card"><div><h2>RocksDB 内存与压缩</h2><p>SST {{ sample.storage.live_sst_bytes === null ? '未知' : formatBytes(sample.storage.live_sst_bytes) }}</p></div><VChart class="h-72" :option="storageChart" autoresize /></article>
|
||||
<article class="chart-card lg:col-span-2"><div><h2>网络与 Metadata 吞吐</h2><p>{{ history?.samples.length ?? 0 }} 个采样点 · 最近保存 {{ formatDate(current?.status.last_persisted_at ?? null) }}</p></div><VChart class="h-72" :option="throughputChart" autoresize /></article>
|
||||
<article class="chart-card lg:col-span-2"><div><h2>HTTP 请求与错误</h2><p>累计 {{ sample.http.requests.toLocaleString() }} 个请求 · {{ sample.http.server_errors.toLocaleString() }} 个服务端错误</p></div><VChart class="h-72" :option="httpChart" autoresize /></article>
|
||||
</section>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { LoaderCircle, Search, Server } from '@lucide/vue'
|
||||
|
||||
import SearchResultCard from '@/components/SearchResultCard.vue'
|
||||
import TorrentDetailDialog from '@/components/TorrentDetailDialog.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AppPagination } from '@/components/ui/pagination'
|
||||
import { AppSelect } from '@/components/ui/select'
|
||||
import { getTorrent, getVariants, search } from '@/lib/api'
|
||||
import type { ContentVariants, SearchHit, SearchPage, SearchSort, TorrentDetail } from '@/types/api'
|
||||
|
||||
const query = ref('')
|
||||
const submittedQuery = ref('')
|
||||
const regexMode = ref(false)
|
||||
const sort = ref<SearchSort>('latest')
|
||||
const page = ref<SearchPage | null>(null)
|
||||
const offset = ref(0)
|
||||
const supportedResultPageSizes = [10, 20, 50, 100]
|
||||
const savedResultPageSize = Number(localStorage.getItem('dht-search-result-page-size'))
|
||||
const limit = ref(supportedResultPageSizes.includes(savedResultPageSize) ? savedResultPageSize : 20)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailFilesLoading = ref(false)
|
||||
const detailFilesError = ref('')
|
||||
const detailError = ref('')
|
||||
const selectedHash = ref('')
|
||||
const detail = ref<TorrentDetail | null>(null)
|
||||
const variants = ref<ContentVariants | null>(null)
|
||||
const supportedFilePageSizes = [25, 50, 100, 200]
|
||||
const savedFilePageSize = Number(localStorage.getItem('dht-search-file-page-size'))
|
||||
const detailFilePageSize = ref(supportedFilePageSizes.includes(savedFilePageSize) ? savedFilePageSize : 100)
|
||||
let searchController: AbortController | null = null
|
||||
let detailController: AbortController | null = null
|
||||
let filesController: AbortController | null = null
|
||||
|
||||
const pageNumber = computed(() => Math.floor((page.value?.offset ?? 0) / limit.value) + 1)
|
||||
const paginationTotal = computed(() => Math.min(page.value?.total ?? 0, 10_000 + limit.value))
|
||||
const searchPlaceholder = computed(() => {
|
||||
if (regexMode.value) return '输入正则表达式'
|
||||
return '输入名称、文件名、*.iso 或 40 位 infohash'
|
||||
})
|
||||
const resultPageSizeOptions = [
|
||||
{ value: '10', label: '10 条/页' },
|
||||
{ value: '20', label: '20 条/页' },
|
||||
{ value: '50', label: '50 条/页' },
|
||||
{ value: '100', label: '100 条/页' },
|
||||
]
|
||||
const sortOptions: ReadonlyArray<{ value: SearchSort; label: string }> = [
|
||||
{ value: 'relevance', label: '相关度' },
|
||||
{ value: 'latest', label: '最近收录' },
|
||||
{ value: 'oldest', label: '最早收录' },
|
||||
{ value: 'heat', label: '热度' },
|
||||
{ value: 'discoveries', label: '发现次数' },
|
||||
{ value: 'size_desc', label: '大小降序' },
|
||||
{ value: 'size_asc', label: '大小升序' },
|
||||
]
|
||||
function updateBrowserUrl() {
|
||||
const params = new URLSearchParams()
|
||||
if (submittedQuery.value) params.set('q', submittedQuery.value)
|
||||
if (sort.value !== 'latest') params.set('sort', sort.value)
|
||||
if (regexMode.value) params.set('mode', 'regex')
|
||||
if (offset.value) params.set('offset', String(offset.value))
|
||||
history.replaceState(null, '', params.size ? `?${params}` : location.pathname)
|
||||
}
|
||||
|
||||
async function loadSearch() {
|
||||
searchController?.abort()
|
||||
searchController = new AbortController()
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
updateBrowserUrl()
|
||||
try {
|
||||
page.value = await search({ query: submittedQuery.value, offset: offset.value, limit: limit.value, sort: sort.value, regex: regexMode.value }, searchController.signal)
|
||||
} catch (cause) {
|
||||
if ((cause as Error).name !== 'AbortError') error.value = cause instanceof Error ? cause.message : '搜索失败'
|
||||
} finally {
|
||||
if (!searchController.signal.aborted) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function submitSearch() {
|
||||
const hadQuery = submittedQuery.value.trim() !== ''
|
||||
submittedQuery.value = query.value
|
||||
if (!hadQuery && submittedQuery.value.trim() && sort.value === 'latest') sort.value = 'relevance'
|
||||
offset.value = 0
|
||||
void loadSearch()
|
||||
}
|
||||
function changePage(pageNumber: number) { offset.value = Math.max(0, (pageNumber - 1) * limit.value); void loadSearch(); window.scrollTo({ top: 0, behavior: 'smooth' }) }
|
||||
function changeResultPageSize(value: string) {
|
||||
const pageSize = Number(value)
|
||||
if (!supportedResultPageSizes.includes(pageSize) || pageSize === limit.value) return
|
||||
limit.value = pageSize
|
||||
localStorage.setItem('dht-search-result-page-size', String(pageSize))
|
||||
offset.value = 0
|
||||
void loadSearch()
|
||||
}
|
||||
function changeSort(value: string) { sort.value = value as SearchSort; offset.value = 0; void loadSearch() }
|
||||
function toggleRegex() { regexMode.value = !regexMode.value; offset.value = 0 }
|
||||
|
||||
async function openDetail(hit: SearchHit) {
|
||||
selectedHash.value = hit.info_hash
|
||||
detailOpen.value = true
|
||||
detailLoading.value = true
|
||||
detailError.value = ''
|
||||
detailFilesError.value = ''
|
||||
detail.value = null
|
||||
variants.value = null
|
||||
detailController?.abort()
|
||||
detailController = new AbortController()
|
||||
try {
|
||||
const [torrent, contentVariants] = await Promise.all([getTorrent(hit.info_hash, 0, detailFilePageSize.value, detailController.signal), getVariants(hit.content_key, detailController.signal)])
|
||||
detail.value = torrent
|
||||
variants.value = contentVariants
|
||||
} catch (cause) {
|
||||
if ((cause as Error).name !== 'AbortError') detailError.value = cause instanceof Error ? cause.message : '详情加载失败'
|
||||
} finally {
|
||||
if (!detailController.signal.aborted) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFilePage(fileOffset: number) {
|
||||
if (!detail.value || detailFilesLoading.value) return
|
||||
filesController?.abort()
|
||||
filesController = new AbortController()
|
||||
detailFilesLoading.value = true
|
||||
detailFilesError.value = ''
|
||||
const infoHash = detail.value.info_hash
|
||||
try {
|
||||
const next = await getTorrent(infoHash, fileOffset, detailFilePageSize.value, filesController.signal)
|
||||
if (selectedHash.value === infoHash) detail.value = next
|
||||
} catch (cause) {
|
||||
if ((cause as Error).name !== 'AbortError') detailFilesError.value = cause instanceof Error ? cause.message : '文件列表加载失败'
|
||||
} finally {
|
||||
if (!filesController.signal.aborted) detailFilesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function changeFilePageSize(fileLimit: number) {
|
||||
if (!supportedFilePageSizes.includes(fileLimit) || fileLimit === detailFilePageSize.value) return
|
||||
detailFilePageSize.value = fileLimit
|
||||
localStorage.setItem('dht-search-file-page-size', String(fileLimit))
|
||||
void loadFilePage(0)
|
||||
}
|
||||
|
||||
function closeDetail() { detailController?.abort(); filesController?.abort(); detailOpen.value = false }
|
||||
function retryDetail() { const hit = page.value?.hits.find((item) => item.info_hash === selectedHash.value); if (hit) void openDetail(hit) }
|
||||
|
||||
onMounted(() => {
|
||||
const params = new URLSearchParams(location.search)
|
||||
query.value = params.get('q') ?? ''
|
||||
submittedQuery.value = query.value
|
||||
regexMode.value = params.get('mode') === 'regex'
|
||||
const initialSort = params.get('sort') as SearchSort | null
|
||||
if (initialSort && ['relevance', 'latest', 'oldest', 'heat', 'size_desc', 'size_asc', 'discoveries'].includes(initialSort)) sort.value = initialSort
|
||||
else if (query.value.trim()) sort.value = 'relevance'
|
||||
offset.value = Math.min(10_000, Math.max(0, Number(params.get('offset')) || 0))
|
||||
void loadSearch()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
searchController?.abort()
|
||||
detailController?.abort()
|
||||
filesController?.abort()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="mx-auto max-w-6xl px-4 py-7 sm:px-6 sm:py-10">
|
||||
<section>
|
||||
<form class="overflow-hidden rounded-xl border bg-card shadow-sm focus-within:border-foreground/20" @submit.prevent="submitSearch">
|
||||
<div class="flex flex-wrap items-center gap-2 p-2">
|
||||
<Search class="ml-2.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<label class="sr-only" for="search-query">搜索关键词</label>
|
||||
<input id="search-query" v-model="query" class="h-9 min-w-48 flex-1 bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground" :maxlength="regexMode ? 256 : 512" :placeholder="searchPlaceholder" type="search" />
|
||||
<Button size="sm" type="button" :variant="regexMode ? 'secondary' : 'ghost'" :aria-pressed="regexMode" title="正则搜索" @click="toggleRegex"><span class="font-mono">.*</span><span class="hidden sm:inline">正则</span></Button>
|
||||
<AppSelect :model-value="sort" :options="sortOptions" label="结果排序" @update:model-value="changeSort" />
|
||||
<Button type="submit">搜索</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="loading" class="flex min-h-72 items-center justify-center text-sm text-muted-foreground"><LoaderCircle class="mr-2 size-5 animate-spin" />正在搜索</div>
|
||||
<div v-else-if="error" class="mt-4 flex min-h-72 flex-col items-center justify-center gap-4 rounded-xl border border-dashed text-center"><Server class="size-8 text-muted-foreground" /><div><p class="font-medium">无法完成搜索</p><p class="mt-1 text-sm text-muted-foreground">{{ error }}</p></div><Button variant="outline" @click="loadSearch">重试</Button></div>
|
||||
<div v-else-if="page?.hits.length" class="mt-4 space-y-3"><SearchResultCard v-for="hit in page.hits" :key="hit.content_key" :hit="hit" @select="openDetail" /></div>
|
||||
<div v-else class="mt-4 flex min-h-72 flex-col items-center justify-center rounded-xl border border-dashed text-center"><Search class="mb-3 size-8 text-muted-foreground" /><p class="font-medium">没有找到匹配内容</p><p class="mt-1 text-sm text-muted-foreground">尝试其他关键词、通配符或正则表达式</p></div>
|
||||
|
||||
<div v-if="page && page.total > 0" class="mt-6 flex flex-wrap items-center justify-center gap-2">
|
||||
<AppSelect :model-value="String(limit)" :options="resultPageSizeOptions" label="每页搜索结果数量" @update:model-value="changeResultPageSize" />
|
||||
<AppPagination v-if="paginationTotal > limit" :page="pageNumber" :page-size="limit" :total="paginationTotal" @update:page="changePage" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<TorrentDetailDialog :detail="detail" :error="detailError" :files-error="detailFilesError" :files-loading="detailFilesLoading" :loading="detailLoading" :open="detailOpen" :variants="variants" @close="closeDetail" @file-page="loadFilePage" @file-page-size="changeFilePageSize" @retry="retryDetail" />
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: () => import('@/pages/SearchPage.vue') },
|
||||
{ path: '/system', component: () => import('@/pages/DiagnosticsPage.vue') },
|
||||
{ path: '/settings', component: () => import('@/pages/ConfigPage.vue') },
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,199 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply min-w-80 bg-background text-foreground antialiased;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
button:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type="search"]::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.field-label {
|
||||
@apply flex flex-col gap-2 text-xs font-medium text-muted-foreground;
|
||||
}
|
||||
.field-input {
|
||||
@apply h-9 min-w-0 w-full rounded-md border bg-background px-3 text-sm text-foreground outline-none transition focus:border-foreground/25 focus:ring-3 focus:ring-ring/15;
|
||||
}
|
||||
.result-meta {
|
||||
@apply inline-flex items-center gap-1.5;
|
||||
}
|
||||
.result-meta svg {
|
||||
@apply size-3.5;
|
||||
}
|
||||
.status-cell {
|
||||
@apply flex flex-col gap-1 rounded-lg bg-muted/60 p-3;
|
||||
}
|
||||
.status-cell span {
|
||||
@apply text-muted-foreground;
|
||||
}
|
||||
.status-cell b {
|
||||
@apply text-sm font-semibold;
|
||||
}
|
||||
.pagination-button {
|
||||
@apply flex size-8 items-center justify-center rounded-md text-xs font-medium transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-40;
|
||||
}
|
||||
.diagnostic-card {
|
||||
@apply grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-1 rounded-xl border bg-card p-4 shadow-sm;
|
||||
}
|
||||
.diagnostic-card svg {
|
||||
@apply row-span-2 size-5 text-muted-foreground;
|
||||
}
|
||||
.diagnostic-card span {
|
||||
@apply text-xs text-muted-foreground;
|
||||
}
|
||||
.diagnostic-card b {
|
||||
@apply text-base font-semibold;
|
||||
}
|
||||
.chart-card {
|
||||
@apply min-w-0 rounded-xl border bg-card p-4 shadow-sm;
|
||||
}
|
||||
.chart-card h2 {
|
||||
@apply text-sm font-semibold;
|
||||
}
|
||||
.chart-card p {
|
||||
@apply mt-1 text-xs text-muted-foreground;
|
||||
}
|
||||
.config-field {
|
||||
@apply flex gap-3 border-b py-4 last:border-b-0;
|
||||
}
|
||||
.config-field b {
|
||||
@apply block text-sm font-medium;
|
||||
}
|
||||
.config-field small {
|
||||
@apply mt-1 block text-xs leading-relaxed text-muted-foreground;
|
||||
}
|
||||
.config-input {
|
||||
@apply mt-2 h-9 w-full rounded-md border bg-background px-3 text-sm outline-none transition focus:border-foreground/25 focus:ring-3 focus:ring-ring/15;
|
||||
}
|
||||
dl dt {
|
||||
@apply text-xs text-muted-foreground;
|
||||
}
|
||||
dl dd {
|
||||
@apply mt-1 font-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 180ms ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
.slide-enter-active, .slide-leave-active { transition: transform 220ms ease; }
|
||||
.slide-enter-from, .slide-leave-to { transform: translateX(100%); }
|
||||
@@ -0,0 +1,303 @@
|
||||
export type AvailabilityStatus = 'unknown' | 'active' | 'possibly_stale'
|
||||
export type HeatLevel = 'hot' | 'active' | 'normal' | 'cold'
|
||||
export type SearchSort =
|
||||
| 'relevance'
|
||||
| 'latest'
|
||||
| 'oldest'
|
||||
| 'heat'
|
||||
| 'size_desc'
|
||||
| 'size_asc'
|
||||
| 'discoveries'
|
||||
|
||||
export interface Heat {
|
||||
score: number
|
||||
level: HeatLevel
|
||||
}
|
||||
|
||||
export interface AvailabilitySummary {
|
||||
status: AvailabilityStatus
|
||||
last_verified_at: number | null
|
||||
reachable_peers: number
|
||||
}
|
||||
|
||||
export interface Availability extends AvailabilitySummary {
|
||||
last_success_at: number | null
|
||||
discovered_peers: number
|
||||
consecutive_failures: number
|
||||
next_check_at: number
|
||||
}
|
||||
|
||||
export interface SearchHit {
|
||||
info_hash: string
|
||||
name: string
|
||||
total_size: number
|
||||
file_count: number
|
||||
first_seen: number
|
||||
last_seen: number
|
||||
seen_count: number
|
||||
content_key: string
|
||||
variant_count: number
|
||||
score: number
|
||||
heat: Heat
|
||||
availability: AvailabilitySummary
|
||||
}
|
||||
|
||||
export interface SearchPage {
|
||||
total: number
|
||||
offset: number
|
||||
limit: number
|
||||
hits: SearchHit[]
|
||||
sort: SearchSort
|
||||
}
|
||||
|
||||
export interface TorrentFile {
|
||||
path: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface TorrentDetail {
|
||||
info_hash: string
|
||||
magnet_link: string
|
||||
name: string
|
||||
total_size: number
|
||||
file_count: number
|
||||
file_offset: number
|
||||
file_limit: number
|
||||
files: TorrentFile[]
|
||||
piece_length: number
|
||||
content_key: string
|
||||
first_seen: number
|
||||
last_seen: number
|
||||
seen_count: number
|
||||
heat: Heat
|
||||
availability: Availability
|
||||
}
|
||||
|
||||
export interface TorrentVariant {
|
||||
info_hash: string
|
||||
magnet_link: string
|
||||
name: string
|
||||
total_size: number
|
||||
file_count: number
|
||||
first_seen: number
|
||||
last_seen: number
|
||||
seen_count: number
|
||||
heat: Heat
|
||||
availability: Availability
|
||||
}
|
||||
|
||||
export interface ContentVariants {
|
||||
content_key: string
|
||||
total: number
|
||||
offset: number
|
||||
limit: number
|
||||
variants: TorrentVariant[]
|
||||
}
|
||||
|
||||
export interface ServiceStats {
|
||||
http_active_requests: number
|
||||
http_requests: number
|
||||
http_client_errors: number
|
||||
http_server_errors: number
|
||||
http_latency_average_micros: number | null
|
||||
http_latency_p95_millis: number | null
|
||||
http_latency_max_millis: number
|
||||
disk_state: 'normal' | 'draining' | 'read_only'
|
||||
disk_available_bytes: number | null
|
||||
disk_minimum_free_bytes: number
|
||||
disk_resume_free_bytes: number
|
||||
disk_active_writes: number
|
||||
disk_probe_failed: boolean
|
||||
disk_probe_failures: number
|
||||
disk_transitions: number
|
||||
disk_rejected_new_work: number
|
||||
nodes: number
|
||||
metadata_ok: number
|
||||
metadata_failed: number
|
||||
metadata_in_flight: number
|
||||
metadata_filtered: number
|
||||
persistence_inserted: number
|
||||
persistence_updated: number
|
||||
persistence_queue: number
|
||||
indexed_documents: number
|
||||
verification_queue: number
|
||||
verification_succeeded: number
|
||||
verification_failed: number
|
||||
}
|
||||
|
||||
export interface ProcessDiagnostics {
|
||||
resident_memory_bytes: number | null
|
||||
private_memory_bytes: number | null
|
||||
cpu_time_millis: number | null
|
||||
thread_count: number | null
|
||||
handle_count: number | null
|
||||
}
|
||||
|
||||
export interface StorageDiagnostics {
|
||||
block_cache_bytes: number | null
|
||||
memtable_bytes: number | null
|
||||
pending_compaction_bytes: number | null
|
||||
live_sst_bytes: number | null
|
||||
running_compactions: number | null
|
||||
estimated_keys: number | null
|
||||
}
|
||||
|
||||
export interface SearchDiagnostics {
|
||||
documents: number
|
||||
writer_memory_budget_bytes: number
|
||||
commits: number
|
||||
commit_failures: number
|
||||
last_commit_at: number | null
|
||||
last_commit_duration_millis: number
|
||||
last_commit_documents: number
|
||||
}
|
||||
|
||||
export interface RuntimeDiagnostics {
|
||||
nodes: number
|
||||
udp_tx_packets: number
|
||||
metadata_in_flight: number
|
||||
metadata_succeeded: number
|
||||
metadata_failed: number
|
||||
sample_queue_depth: number
|
||||
sample_queue_capacity: number
|
||||
persistence_queue_depth: number
|
||||
indexed_documents: number
|
||||
disk_available_bytes: number | null
|
||||
}
|
||||
|
||||
export interface DiagnosticSample {
|
||||
captured_at: number
|
||||
session_started_at: number
|
||||
process: ProcessDiagnostics
|
||||
storage: StorageDiagnostics
|
||||
search: SearchDiagnostics
|
||||
http: HttpDiagnostics
|
||||
runtime: RuntimeDiagnostics
|
||||
}
|
||||
|
||||
export interface HttpDiagnostics {
|
||||
active_requests: number
|
||||
requests: number
|
||||
client_errors: number
|
||||
server_errors: number
|
||||
latency_average_micros: number | null
|
||||
latency_p95_millis: number | null
|
||||
latency_max_millis: number
|
||||
}
|
||||
|
||||
export interface DiagnosticsStatus {
|
||||
enabled: boolean
|
||||
persisted_samples: number
|
||||
dropped_samples: number
|
||||
skipped_samples: number
|
||||
write_failures: number
|
||||
last_persisted_at: number | null
|
||||
last_error: string | null
|
||||
}
|
||||
|
||||
export interface CurrentDiagnostics {
|
||||
status: DiagnosticsStatus
|
||||
sample: DiagnosticSample | null
|
||||
}
|
||||
|
||||
export interface DiagnosticHistory {
|
||||
resolution: 'raw' | 'minute'
|
||||
from: number
|
||||
to: number
|
||||
samples: DiagnosticSample[]
|
||||
}
|
||||
|
||||
export type NetworkMode = 'ipv4-only' | 'ipv6-only' | 'dual-stack'
|
||||
export type LogRotation = 'minutely' | 'hourly' | 'daily' | 'never'
|
||||
|
||||
export interface AppConfigDto {
|
||||
data_dir: string
|
||||
content_filter_file: string
|
||||
persistence_queue_capacity: number
|
||||
stats_interval_secs: number
|
||||
run_duration_secs: number | null
|
||||
index_batch_size: number
|
||||
index_interval_millis: number
|
||||
metadata_limits: {
|
||||
max_metadata_bytes: number
|
||||
max_files: number
|
||||
max_name_bytes: number
|
||||
max_path_bytes: number
|
||||
max_path_depth: number
|
||||
}
|
||||
dht: {
|
||||
port: number
|
||||
netmode: NetworkMode
|
||||
hash_queue_capacity: number
|
||||
max_outbound_queries_per_second: number
|
||||
outbound_query_burst: number
|
||||
metadata_timeout_secs: number
|
||||
metadata_queue_capacity: number
|
||||
metadata_workers: number
|
||||
metadata_connects_per_second: number
|
||||
sample_queries_per_second: number
|
||||
sample_max_in_flight: number
|
||||
sample_new_node_percent: number
|
||||
sample_candidate_queue_capacity: number
|
||||
sample_fallback_to_iterative: boolean
|
||||
peer_lookups_per_second: number
|
||||
peer_lookup_max_active: number
|
||||
find_node_queries_per_second: number
|
||||
find_node_max_in_flight: number
|
||||
new_destinations_per_minute: number
|
||||
}
|
||||
disk_guard: {
|
||||
enabled: boolean
|
||||
check_interval_secs: number
|
||||
minimum_free_bytes: number
|
||||
resume_free_bytes: number
|
||||
}
|
||||
backup: {
|
||||
enabled: boolean
|
||||
directory: string
|
||||
interval_secs: number
|
||||
retain_checkpoints: number
|
||||
create_on_start: boolean
|
||||
}
|
||||
diagnostics: {
|
||||
enabled: boolean
|
||||
database: string
|
||||
sample_interval_secs: number
|
||||
raw_retention_hours: number
|
||||
minute_retention_days: number
|
||||
queue_capacity: number
|
||||
}
|
||||
logging: {
|
||||
directory: string
|
||||
file_enabled: boolean
|
||||
console_enabled: boolean
|
||||
rotation: LogRotation
|
||||
retain_files: number
|
||||
file_prefix: string
|
||||
}
|
||||
http: {
|
||||
listen: string
|
||||
web_dir: string
|
||||
}
|
||||
verification: {
|
||||
enabled: boolean
|
||||
queue_capacity: number
|
||||
max_active: number
|
||||
max_peer_attempts: number
|
||||
lease_secs: number
|
||||
poll_interval_millis: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConfigSnapshot {
|
||||
revision: string
|
||||
restart_required: boolean
|
||||
source: string
|
||||
command_line_overrides: string[]
|
||||
config: AppConfigDto
|
||||
}
|
||||
|
||||
export interface ConfigUpdateRequest {
|
||||
revision: string
|
||||
config: AppConfigDto
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"files": [],
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import path from 'node:path'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
const apiTarget = process.env.DHT_API_TARGET ?? 'http://127.0.0.1:8080'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(import.meta.dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/health': apiTarget,
|
||||
'/ready': apiTarget,
|
||||
'/stats': apiTarget,
|
||||
'/diagnostics': apiTarget,
|
||||
'/config': apiTarget,
|
||||
'/search': apiTarget,
|
||||
'/contents': apiTarget,
|
||||
'/torrents': apiTarget,
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user