From d6999104bf6e85144ea7cec5a45a036ec022d733 Mon Sep 17 00:00:00 2001 From: ice-a Date: Fri, 16 Oct 2026 18:53:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20DSL=20=E7=A3=81=E5=8A=9B=E8=81=9A?= =?UTF-8?q?=E5=90=88=20=E2=80=94=20=E6=90=9C=E7=B4=A2/=E8=A7=A3=E6=9E=90/?= =?UTF-8?q?=E9=A2=84=E8=A7=88/=E6=8A=95=E9=80=92=20+=20cilisousuo=20?= =?UTF-8?q?=E6=BA=90=20+=20=E4=B8=AD=E8=8B=B1=E5=8F=8C=E8=AF=AD=20+=20Dock?= =?UTF-8?q?er=20=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 9 + .env.example | 20 + .gitignore | 9 + Dockerfile | 31 + README.md | 150 + app/app.vue | 6 + app/assets/css/main.css | 429 + app/components/AppToasts.vue | 17 + app/components/FileTable.vue | 99 + app/components/ImagePreview.vue | 120 + app/components/SendPanel.vue | 122 + app/components/TorrentDetail.vue | 178 + app/composables/useHealth.ts | 16 + app/composables/useToast.ts | 41 + app/layouts/default.vue | 71 + app/pages/downloads.vue | 155 + app/pages/history.vue | 177 + app/pages/index.vue | 271 + app/pages/settings.vue | 339 + app/pages/torrent/[infohash].vue | 48 + app/plugins/i18n.client.ts | 48 + app/types/api.ts | 104 + app/utils/format.ts | 76 + app/utils/i18n-dict.ts | 232 + app/utils/i18n.ts | 65 + docker-compose.yml | 29 + nuxt.config.ts | 26 + package-lock.json | 11294 ++++++++++++++++ package.json | 25 + server/api/clients/index.get.ts | 27 + server/api/clients/index.put.ts | 32 + server/api/clients/tasks.get.ts | 10 + server/api/clients/test.post.ts | 22 + server/api/downloads.get.ts | 14 + server/api/health.get.ts | 18 + server/api/history.delete.ts | 23 + server/api/history.get.ts | 31 + server/api/parse.post.ts | 46 + server/api/search.get.ts | 44 + server/api/send.post.ts | 62 + server/api/torrent/[infohash].get.ts | 27 + server/api/torrent/[infohash]/file.get.ts | 25 + server/api/torrent/[infohash]/preview.get.ts | 83 + server/api/torrent/[infohash]/resolve.post.ts | 30 + server/api/torrents.get.ts | 25 + server/utils/appconfig.ts | 116 + server/utils/clients/aria2.ts | 114 + server/utils/clients/index.ts | 114 + server/utils/clients/qbittorrent.ts | 122 + server/utils/clients/transmission.ts | 112 + server/utils/clients/types.ts | 60 + server/utils/http.ts | 54 + server/utils/mongo.ts | 93 + server/utils/search/apibay.ts | 62 + server/utils/search/cilisousuo.ts | 145 + server/utils/search/index.ts | 86 + server/utils/search/nyaa.ts | 94 + server/utils/search/solidtorrents.ts | 42 + server/utils/search/types.ts | 37 + server/utils/store.ts | 157 + server/utils/swarm.ts | 90 + server/utils/torrent.ts | 242 + server/utils/types.ts | 55 + tsconfig.json | 3 + 64 files changed, 16524 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/app.vue create mode 100644 app/assets/css/main.css create mode 100644 app/components/AppToasts.vue create mode 100644 app/components/FileTable.vue create mode 100644 app/components/ImagePreview.vue create mode 100644 app/components/SendPanel.vue create mode 100644 app/components/TorrentDetail.vue create mode 100644 app/composables/useHealth.ts create mode 100644 app/composables/useToast.ts create mode 100644 app/layouts/default.vue create mode 100644 app/pages/downloads.vue create mode 100644 app/pages/history.vue create mode 100644 app/pages/index.vue create mode 100644 app/pages/settings.vue create mode 100644 app/pages/torrent/[infohash].vue create mode 100644 app/plugins/i18n.client.ts create mode 100644 app/types/api.ts create mode 100644 app/utils/format.ts create mode 100644 app/utils/i18n-dict.ts create mode 100644 app/utils/i18n.ts create mode 100644 docker-compose.yml create mode 100644 nuxt.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server/api/clients/index.get.ts create mode 100644 server/api/clients/index.put.ts create mode 100644 server/api/clients/tasks.get.ts create mode 100644 server/api/clients/test.post.ts create mode 100644 server/api/downloads.get.ts create mode 100644 server/api/health.get.ts create mode 100644 server/api/history.delete.ts create mode 100644 server/api/history.get.ts create mode 100644 server/api/parse.post.ts create mode 100644 server/api/search.get.ts create mode 100644 server/api/send.post.ts create mode 100644 server/api/torrent/[infohash].get.ts create mode 100644 server/api/torrent/[infohash]/file.get.ts create mode 100644 server/api/torrent/[infohash]/preview.get.ts create mode 100644 server/api/torrent/[infohash]/resolve.post.ts create mode 100644 server/api/torrents.get.ts create mode 100644 server/utils/appconfig.ts create mode 100644 server/utils/clients/aria2.ts create mode 100644 server/utils/clients/index.ts create mode 100644 server/utils/clients/qbittorrent.ts create mode 100644 server/utils/clients/transmission.ts create mode 100644 server/utils/clients/types.ts create mode 100644 server/utils/http.ts create mode 100644 server/utils/mongo.ts create mode 100644 server/utils/search/apibay.ts create mode 100644 server/utils/search/cilisousuo.ts create mode 100644 server/utils/search/index.ts create mode 100644 server/utils/search/nyaa.ts create mode 100644 server/utils/search/solidtorrents.ts create mode 100644 server/utils/search/types.ts create mode 100644 server/utils/store.ts create mode 100644 server/utils/swarm.ts create mode 100644 server/utils/torrent.ts create mode 100644 server/utils/types.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f37e5d6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +.output +.nuxt +.data +.env +.git +.gitignore +*.log +npm-debug.log* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d2a76c8 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# MongoDB 连接(留空则回落到本地默认,配置/历史不持久化) +MONGODB_URI=mongodb://127.0.0.1:27017 +MONGODB_DB=torrent_console + +# 访问搜索源时使用的 HTTP 代理(国内网络连不上公开磁力站时配这里) +# 例如:http://127.0.0.1:7890 +SEARCH_PROXY= + +# 超时(毫秒) +SEARCH_TIMEOUT=12000 +METADATA_TIMEOUT=30000 + +# 各搜索源基地址(一般无需修改) +APIBAY_BASE=https://apibay.org +NYAA_BASE=https://nyaa.si +CILISOUSUO_BASE=https://cilisousuo.cc + +# 生产运行监听(仅直接运行 .output 时需要) +HOST=0.0.0.0 +PORT=3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e0de010 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules +.nuxt +.output +.data +dist +.env +.env.local +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f6233e1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# 语法:docker/docker-buildx 多阶段构建 +FROM node:20-alpine AS builder +WORKDIR /app + +# 仅复制依赖清单,利用层缓存 +COPY package*.json ./ +RUN npm ci + +# 复制源码并构建 Nitro 产物到 .output/ +COPY . . +RUN npm run build + +# ---------- 运行阶段 ---------- +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=3000 + +# 只带运行所需文件,减小镜像体积 +COPY --from=builder /app/.output ./.output +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./package.json + +EXPOSE 3000 + +# 健康检查:命中 /api/health +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3000/api/health >/dev/null 2>&1 || exit 1 + +CMD ["node", ".output/server/index.mjs"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab34967 --- /dev/null +++ b/README.md @@ -0,0 +1,150 @@ +# BT 种子预览台(DSL) + +搜索 / 解析 / 预览 BT 种子内容,并一键投递到本机下载工具(qBittorrent / Transmission / aria2)的 Web 控制台。 + +## 功能特性 + +- **多源聚合搜索**:同时搜索多个公开磁力站并自动按 infoHash 去重、按做种数排序。 + - The Pirate Bay(apibay) + - Nyaa + - SolidTorrents + - 磁力搜索(cilisousuo.cc) +- **种子内容预览**:通过 WebTorrent 的 DHT / Tracker 解析磁力链,取得文件清单、总大小、做种/下载人数(无需真正下载文件)。 +- **一键投递**:把选中的磁力链直接发给本机 BT 客户端开始下载,并记录投递历史。 +- **可配置**:搜索代理、超时、启用哪些搜索源、BT 客户端地址都可在「设置」页或 `.env` 中调整。 + +## 技术栈 + +- 前端/全栈:[Nuxt 4](https://nuxt.com/)(Vue 3 + Nitro 服务端) +- 数据库:[MongoDB](https://www.mongodb.com/)(搜索历史、投递记录、配置持久化;不连库时回落到 `.env` 默认值) +- 关键依赖:`webtorrent`(种子解析)、`mongodb`、`parse-torrent`、`undici`(带代理的 fetch)、`fast-xml-parser` + +## 环境要求 + +- Node.js ≥ 18(推荐 20 LTS) +- MongoDB(可选;不安装也能跑,只是配置和历史记录不会持久化) + +## 快速开始(本地开发) + +```bash +# 1. 安装依赖 +npm install + +# 2. 配置环境变量(可选,见下文「配置项」) +cp .env.example .env + +# 3. 启动开发服务器 +npm run dev +``` + +打开 http://localhost:3000 即可。 + +构建并本地预览生产版本: + +```bash +npm run build +npm run preview +``` + +## 配置项 + +所有配置都可写入 `.env`(项目根目录),未设置时使用括号内默认值。 + +| 变量 | 说明 | 默认 | +| --- | --- | --- | +| `MONGODB_URI` | MongoDB 连接串 | `mongodb://127.0.0.1:27017` | +| `MONGODB_DB` | 数据库名 | `torrent_console` | +| `SEARCH_PROXY` | 访问搜索源时使用的 HTTP 代理(如 `http://127.0.0.1:7890`),留空直连 | 空 | +| `SEARCH_TIMEOUT` | 单次搜索源请求超时(毫秒) | `12000` | +| `METADATA_TIMEOUT` | 磁力链元数据解析超时(毫秒) | `30000` | +| `APIBAY_BASE` | The Pirate Bay 接口地址 | `https://apibay.org` | +| `NYAA_BASE` | Nyaa 地址 | `https://nyaa.si` | +| `CILISOUSUO_BASE` | 磁力搜索(cilisousuo)地址 | `https://cilisousuo.cc` | + +> 这些项也可在「设置」页通过 UI 修改(保存后写入 MongoDB 的 `settings` 集合,优先级高于 `.env`)。 + +## 搜索源说明 + +cilisousuo.cc 的列表页只暴露名称、大小和详情页短链,不含 infoHash,因此该源会**逐条抓取详情页**补全磁力链(单次搜索最多取前 24 条、5 路并发)。若服务端网络连不上该站,请在「设置」里配置 `SEARCH_PROXY`。 + +## 部署 + +### 方式一:Docker(推荐) + +已提供 `Dockerfile` 与 `docker-compose.yml`(自带 MongoDB): + +```bash +# 构建并启动(后台) +docker compose up -d --build + +# 查看日志 +docker compose logs -f app +``` + +访问 http://<服务器IP>:3000 。 + +仅构建镜像单独运行: + +```bash +docker build -t bt-preview . +docker run -d --name bt-preview -p 3000:3000 \ + -e MONGODB_URI=mongodb://mongo:27017 \ + bt-preview +``` + +### 方式二:直接运行(Node 服务器) + +```bash +# 生产构建,产物在 .output/ +npm run build + +# 用任意进程管理器跑起来(以 PM2 为例) +npm install -g pm2 +pm2 start .output/server/index.mjs --name bt-preview +pm2 save +``` + +关键运行环境变量: + +```bash +HOST=0.0.0.0 # 监听地址 +PORT=3000 # 监听端口 +MONGODB_URI=mongodb://127.0.0.1:27017 +``` + +### 方式三:反向代理(Nginx 示例) + +```nginx +server { + listen 80; + server_name your.domain; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +## 常见问题 + +- **搜索源"打不开" / 搜不到结果**:公开磁力站在部分网络下被墙。在「设置」里填一个可用的 HTTP 代理(`SEARCH_PROXY`),保存后重试。 +- **MongoDB 未连接提示**:不影响基础搜索;只是配置和历史不会持久化。安装并启动 MongoDB,或设置 `MONGODB_URI` 指向可用的实例即可。 +- **磁力解析不可用**:WebTorrent 依赖 Node 网络环境(DHT/UDP)。在受限网络下可能只能显示 infoHash,拿不到文件清单,属正常现象。 + +## 目录结构 + +``` +app/ 前端页面与组件(Nuxt pages / components / composables) +server/ + api/ Nitro 接口(search / torrent / clients / history / downloads ...) + utils/ + search/ 各搜索源实现(apibay / nyaa / solidtorrents / cilisousuo) + mongo.ts MongoDB 连接与索引 + appconfig.ts 配置读写与默认值 + torrent.ts 磁力链 / 种子解析工具 +``` diff --git a/app/app.vue b/app/app.vue new file mode 100644 index 0000000..bdd0b11 --- /dev/null +++ b/app/app.vue @@ -0,0 +1,6 @@ + diff --git a/app/assets/css/main.css b/app/assets/css/main.css new file mode 100644 index 0000000..c62d863 --- /dev/null +++ b/app/assets/css/main.css @@ -0,0 +1,429 @@ +/* ============================================================ + 设计令牌:深色工业控制台 / 网络信号台 + 近黑蓝底 + 发丝线 + 信号橙点缀,等宽字体承载数据 + ============================================================ */ +:root { + --bg: #08090b; + --panel: #0d0f12; + --panel-2: #121519; + --panel-3: #171b20; + --line: #1d2228; + --line-2: #2a3138; + --fg: #d6dbdf; + --fg-dim: #8d979f; + --fg-mute: #5c656d; + --acc: #ff6a1f; + --acc-soft: rgba(255, 106, 31, 0.12); + --acc-line: rgba(255, 106, 31, 0.42); + --ok: #2fd39f; + --warn: #ffc046; + --err: #ff5757; + --ok-soft: rgba(47, 211, 159, 0.12); + --err-soft: rgba(255, 87, 87, 0.12); + + --display: Bahnschrift, 'Segoe UI Variable Display', 'Segoe UI', 'Microsoft YaHei UI', sans-serif; + --body: 'Segoe UI', 'Microsoft YaHei UI', 'PingFang SC', 'Hiragino Sans GB', system-ui, sans-serif; + --mono: 'Cascadia Mono', 'Cascadia Code', Consolas, 'JetBrains Mono', 'DejaVu Sans Mono', monospace; +} + +* { box-sizing: border-box; } + +html, body, #__nuxt { height: 100%; } + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font-family: var(--body); + font-size: 14px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; +} + +/* 背景网格:轻微的技术图纸感 */ +body::before { + content: ''; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: + linear-gradient(to right, rgba(255, 255, 255, 0.016) 1px, transparent 1px), + linear-gradient(to bottom, rgba(255, 255, 255, 0.016) 1px, transparent 1px); + background-size: 64px 64px; +} + +body::after { + content: ''; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: radial-gradient(120% 80% at 50% -20%, rgba(255, 106, 31, 0.055), transparent 60%); +} + +a { color: inherit; text-decoration: none; } +::selection { background: var(--acc); color: #0b0b0b; } + +/* 滚动条 */ +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: var(--bg); } +::-webkit-scrollbar-thumb { background: var(--line-2); border: 3px solid var(--bg); } +::-webkit-scrollbar-thumb:hover { background: #3a434c; } + +/* ================= 布局 ================= */ +.shell { position: relative; z-index: 1; min-height: 100%; display: flex; flex-direction: column; } +.wrap { width: 100%; max-width: 1320px; margin: 0 auto; padding: 0 28px; } +.grow { flex: 1; } + +/* ================= 顶栏 ================= */ +.topbar { + position: sticky; + top: 0; + z-index: 40; + background: rgba(8, 9, 11, 0.86); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--line); +} +.topbar-in { display: flex; align-items: center; gap: 26px; height: 58px; } + +.brand { display: flex; align-items: center; gap: 10px; font-family: var(--display); } +.brand-mark { + width: 26px; height: 26px; + display: grid; place-items: center; + border: 1px solid var(--acc-line); + background: var(--acc-soft); + color: var(--acc); + font-family: var(--mono); + font-size: 13px; + font-weight: 700; + letter-spacing: -0.06em; +} +.brand-name { font-size: 15px; letter-spacing: 0.16em; text-transform: uppercase; font-weight: 600; } +.brand-sub { font-family: var(--mono); font-size: 10px; color: var(--fg-mute); letter-spacing: 0.1em; } + +.nav { display: flex; gap: 2px; margin-left: 6px; } +.nav a { + position: relative; + padding: 7px 13px; + font-size: 13px; + color: var(--fg-dim); + border: 1px solid transparent; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} +.nav a:hover { color: var(--fg); border-color: var(--line); background: var(--panel); } +.nav a.active { color: var(--acc); border-color: var(--acc-line); background: var(--acc-soft); } + +.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 10px; } + +.pill { + display: inline-flex; align-items: center; gap: 7px; + padding: 4px 10px; + border: 1px solid var(--line-2); + background: var(--panel); + font-family: var(--mono); + font-size: 11px; + color: var(--fg-dim); + white-space: nowrap; +} +.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--fg-mute); } +.pill.on { border-color: rgba(47, 211, 159, 0.3); color: var(--ok); } +.pill.on .dot { background: var(--ok); box-shadow: 0 0 8px var(--ok); } +.pill.off { border-color: rgba(255, 87, 87, 0.3); color: var(--err); } +.pill.off .dot { background: var(--err); } +.pill.warn { border-color: rgba(255, 192, 70, 0.3); color: var(--warn); } +.pill.warn .dot { background: var(--warn); } + +/* ================= 通用组件 ================= */ +.page { padding: 34px 0 90px; } + +.label { + font-family: var(--mono); + font-size: 10px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--fg-mute); +} + +h1 { font-family: var(--display); font-size: 34px; line-height: 1.15; margin: 10px 0 8px; font-weight: 600; letter-spacing: -0.005em; } +h2 { font-family: var(--display); font-size: 19px; margin: 0; font-weight: 600; letter-spacing: 0.01em; } +h3 { font-family: var(--display); font-size: 15px; margin: 0; font-weight: 600; } +.sub { color: var(--fg-dim); max-width: 62ch; font-size: 13.5px; } + +.panel { + position: relative; + background: var(--panel); + border: 1px solid var(--line); +} +.panel::before { + content: ''; + position: absolute; left: -1px; top: -1px; bottom: -1px; + width: 2px; background: var(--acc); + opacity: 0.85; +} +.panel-hd { + display: flex; align-items: center; gap: 12px; + padding: 12px 18px; + border-bottom: 1px solid var(--line); + background: linear-gradient(180deg, var(--panel-2), var(--panel)); +} +.panel-hd .spacer { margin-left: auto; } +.panel-bd { padding: 18px; } +.panel-bd.tight { padding: 0; } + +.btn { + display: inline-flex; align-items: center; gap: 7px; + padding: 8px 15px; + background: var(--panel-2); + color: var(--fg); + border: 1px solid var(--line-2); + font-family: var(--body); + font-size: 13px; + cursor: pointer; + transition: background 0.14s, border-color 0.14s, color 0.14s, transform 0.08s; +} +.btn:hover:not(:disabled) { background: var(--panel-3); border-color: #3a434c; } +.btn:active:not(:disabled) { transform: translateY(1px); } +.btn:disabled { opacity: 0.42; cursor: not-allowed; } +.btn-primary { background: var(--acc); border-color: var(--acc); color: #12100e; font-weight: 600; } +.btn-primary:hover:not(:disabled) { background: #ff7f3d; border-color: #ff7f3d; } +.btn-ghost { background: transparent; border-color: var(--line); color: var(--fg-dim); } +.btn-ghost:hover:not(:disabled) { color: var(--fg); background: var(--panel-2); } +.btn-danger { border-color: rgba(255, 87, 87, 0.35); color: var(--err); background: transparent; } +.btn-danger:hover:not(:disabled) { background: var(--err-soft); } +.btn-sm { padding: 5px 10px; font-size: 12px; } + +.input, .select, .textarea { + width: 100%; + padding: 9px 12px; + background: #0a0c0e; + border: 1px solid var(--line-2); + color: var(--fg); + font-family: var(--mono); + font-size: 13px; + transition: border-color 0.14s, box-shadow 0.14s; +} +.input:focus, .select:focus, .textarea:focus { + outline: none; + border-color: var(--acc-line); + box-shadow: 0 0 0 3px var(--acc-soft); +} +.input::placeholder, .textarea::placeholder { color: var(--fg-mute); } +.textarea { resize: vertical; min-height: 108px; line-height: 1.6; } +.select { font-family: var(--body); cursor: pointer; } +.field { display: flex; flex-direction: column; gap: 6px; } +.field > .label { color: var(--fg-dim); } + +.grid-2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } +.grid-3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; } +.grid-4 { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; } + +/* 标签页 */ +.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--line); margin: 22px 0 0; } +.tabs button { + padding: 10px 18px; + background: transparent; + border: 1px solid transparent; + border-bottom: none; + color: var(--fg-mute); + font-family: var(--mono); + font-size: 12px; + letter-spacing: 0.06em; + cursor: pointer; + margin-bottom: -1px; + transition: color 0.14s, background 0.14s; +} +.tabs button:hover { color: var(--fg-dim); background: var(--panel); } +.tabs button.on { color: var(--acc); background: var(--panel); border-color: var(--line); border-bottom-color: var(--panel); } + +/* 勾选 */ +.check { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px; color: var(--fg-dim); user-select: none; } +.check input { appearance: none; width: 15px; height: 15px; border: 1px solid var(--line-2); background: #0a0c0e; cursor: pointer; position: relative; } +.check input:checked { background: var(--acc); border-color: var(--acc); } +.check input:checked::after { content: '✓'; position: absolute; inset: 0; display: grid; place-items: center; color: #12100e; font-size: 11px; font-weight: 700; } +.check:hover { color: var(--fg); } + +/* 表格 */ +.table { width: 100%; border-collapse: collapse; font-size: 13px; } +.table th { + text-align: left; + padding: 9px 12px; + font-family: var(--mono); + font-size: 10px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--fg-mute); + border-bottom: 1px solid var(--line-2); + background: var(--panel-2); + white-space: nowrap; + font-weight: 500; +} +.table td { padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: top; } +.table tbody tr { transition: background 0.1s; } +.table tbody tr:hover { background: rgba(255, 255, 255, 0.022); } +.table .num { font-family: var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; } +.table .right { text-align: right; } +.table .name { color: var(--fg); word-break: break-word; } + +.mono { font-family: var(--mono); } +.dim { color: var(--fg-dim); } +.mute { color: var(--fg-mute); } +.ok { color: var(--ok); } +.warn { color: var(--warn); } +.err { color: var(--err); } +.acc { color: var(--acc); } +.small { font-size: 12px; } +.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.break { word-break: break-all; } + +.tag { + display: inline-block; + padding: 1px 7px; + border: 1px solid var(--line-2); + background: var(--panel-2); + font-family: var(--mono); + font-size: 10.5px; + color: var(--fg-dim); + white-space: nowrap; +} +.tag.acc { border-color: var(--acc-line); color: var(--acc); background: var(--acc-soft); } +.tag.ok { border-color: rgba(47, 211, 159, 0.32); color: var(--ok); background: var(--ok-soft); } +.tag.err { border-color: rgba(255, 87, 87, 0.32); color: var(--err); background: var(--err-soft); } + +.stat { display: flex; flex-direction: column; gap: 3px; padding: 11px 14px; border: 1px solid var(--line); background: var(--panel); } +.stat .v { font-family: var(--mono); font-size: 17px; font-variant-numeric: tabular-nums; } + +.empty { padding: 38px 18px; text-align: center; color: var(--fg-mute); font-family: var(--mono); font-size: 12px; } + +.note { display: flex; gap: 10px; padding: 11px 14px; border: 1px solid var(--line-2); background: var(--panel-2); font-size: 12.5px; color: var(--fg-dim); } +.note.err { border-color: rgba(255, 87, 87, 0.3); background: var(--err-soft); color: #ffb3b3; } +.note.warn { border-color: rgba(255, 192, 70, 0.3); background: rgba(255, 192, 70, 0.08); color: #ffd98a; } +.note.ok { border-color: rgba(47, 211, 159, 0.28); background: var(--ok-soft); color: #a6f0d8; } + +.row { display: flex; align-items: center; gap: 10px; } +.row.center { justify-content: center; } +.row.wrap { flex-wrap: wrap; } +.stack { display: flex; flex-direction: column; gap: 14px; } +.stack.sm { gap: 8px; } +.mt { margin-top: 14px; } +.mt-lg { margin-top: 26px; } +.py-md { padding-top: 14px; padding-bottom: 14px; } +.mx-lg { margin-left: 20px; margin-right: 20px; } +.spread { display: flex; align-items: center; justify-content: space-between; gap: 12px; } + +.panel-ft { padding: 10px 14px; border-top: 1px solid var(--line-2); background: var(--panel-2); } + +.kv { display: grid; grid-template-columns: 104px 1fr; gap: 6px 14px; font-size: 13px; } +.kv dt { color: var(--fg-mute); font-family: var(--mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; padding-top: 2px; } +.kv dd { margin: 0; } + +/* 拖放区 */ +.drop { + border: 1px dashed var(--line-2); + background: var(--panel-2); + padding: 40px 20px; + text-align: center; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} +.drop:hover, .drop.over { border-color: var(--acc); background: var(--acc-soft); } +.drop .big { font-family: var(--display); font-size: 17px; margin-bottom: 6px; } + +/* 文件树缩进 */ +.file-indent { display: inline-block; color: var(--fg-mute); } +.file-path { font-family: var(--mono); font-size: 12.5px; } +.file-dir { color: var(--fg-mute); } +.file-name { color: var(--fg); } + +.bar-track { height: 3px; background: var(--line); width: 100%; overflow: hidden; } +.bar-fill { height: 100%; background: var(--acc); } + +/* toast */ +.toasts { position: fixed; right: 22px; bottom: 22px; z-index: 90; display: flex; flex-direction: column; gap: 9px; width: min(420px, calc(100vw - 44px)); } +.toast { + display: flex; gap: 10px; align-items: flex-start; + padding: 12px 14px; + background: var(--panel-2); + border: 1px solid var(--line-2); + border-left: 2px solid var(--acc); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.55); + font-size: 13px; + animation: toast-in 0.22s cubic-bezier(0.2, 0.9, 0.3, 1); +} +.toast.ok { border-left-color: var(--ok); } +.toast.err { border-left-color: var(--err); } +.toast.warn { border-left-color: var(--warn); } +@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } + +/* 页面进入的错落动画 */ +.rise { animation: rise 0.42s cubic-bezier(0.16, 1, 0.3, 1) both; } +.rise-2 { animation-delay: 0.06s; } +.rise-3 { animation-delay: 0.12s; } +.rise-4 { animation-delay: 0.18s; } +@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } } + +.spin { display: inline-block; width: 11px; height: 11px; border: 1.5px solid var(--line-2); border-top-color: var(--acc); border-radius: 50%; animation: spin 0.7s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +.footer { border-top: 1px solid var(--line); padding: 16px 0 26px; color: var(--fg-mute); font-family: var(--mono); font-size: 11px; } + +/* 图片预览弹层 */ +.overlay { + position: fixed; + inset: 0; + z-index: 80; + display: flex; + align-items: center; + justify-content: center; + padding: 3vh 3vw; + background: rgba(4, 5, 6, 0.87); + backdrop-filter: blur(3px); + animation: overlay-in 0.18s ease both; +} +@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } } + +.lightbox { + display: flex; + flex-direction: column; + width: min(1120px, 100%); + max-height: 94vh; + background: var(--panel); + border: 1px solid var(--line-2); + box-shadow: 0 40px 100px rgba(0, 0, 0, 0.72); + animation: rise 0.26s cubic-bezier(0.16, 1, 0.3, 1) both; +} +.lightbox-bd { + flex: 1; + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + overflow: auto; + /* 细网格底纹,透明 PNG 也能看清边界 */ + background: + linear-gradient(rgba(255, 255, 255, 0.018) 1px, transparent 1px) 0 0 / 100% 28px, + linear-gradient(90deg, rgba(255, 255, 255, 0.018) 1px, transparent 1px) 0 0 / 28px 100%, + var(--bg); +} +.lightbox-img { display: block; max-width: 100%; max-height: 76vh; } +.lightbox-ft { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + border-top: 1px solid var(--line); + font-family: var(--mono); +} +.lightbox-ft > .mono { flex: 1; text-align: center; } + +@media (max-width: 900px) { + .wrap { padding: 0 16px; } + .grid-4, .grid-3 { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .grid-2 { grid-template-columns: 1fr; } + h1 { font-size: 26px; } + .nav a { padding: 6px 9px; font-size: 12px; } + .hide-sm { display: none; } + .overlay { padding: 0; } + .lightbox { max-height: 100vh; width: 100%; border: none; } + .lightbox-img { max-height: 66vh; } +} diff --git a/app/components/AppToasts.vue b/app/components/AppToasts.vue new file mode 100644 index 0000000..2380fba --- /dev/null +++ b/app/components/AppToasts.vue @@ -0,0 +1,17 @@ + + + diff --git a/app/components/FileTable.vue b/app/components/FileTable.vue new file mode 100644 index 0000000..cd75b09 --- /dev/null +++ b/app/components/FileTable.vue @@ -0,0 +1,99 @@ + + + diff --git a/app/components/ImagePreview.vue b/app/components/ImagePreview.vue new file mode 100644 index 0000000..03f79a5 --- /dev/null +++ b/app/components/ImagePreview.vue @@ -0,0 +1,120 @@ + + + diff --git a/app/components/SendPanel.vue b/app/components/SendPanel.vue new file mode 100644 index 0000000..5eb8bd8 --- /dev/null +++ b/app/components/SendPanel.vue @@ -0,0 +1,122 @@ + + + diff --git a/app/components/TorrentDetail.vue b/app/components/TorrentDetail.vue new file mode 100644 index 0000000..d5313a4 --- /dev/null +++ b/app/components/TorrentDetail.vue @@ -0,0 +1,178 @@ + + + diff --git a/app/composables/useHealth.ts b/app/composables/useHealth.ts new file mode 100644 index 0000000..fd15ed5 --- /dev/null +++ b/app/composables/useHealth.ts @@ -0,0 +1,16 @@ +import type { HealthResponse } from '~/types/api' + +/** 全局健康状态:Mongo / webtorrent / 客户端在线数,用于顶栏状态灯与页面提示 */ +export function useHealth() { + return useState('health', () => null) +} + +export async function refreshHealth() { + const state = useHealth() + try { + state.value = await $fetch('/api/health') + } catch { + state.value = null + } + return state.value +} diff --git a/app/composables/useToast.ts b/app/composables/useToast.ts new file mode 100644 index 0000000..e6def43 --- /dev/null +++ b/app/composables/useToast.ts @@ -0,0 +1,41 @@ +export type ToastKind = 'info' | 'ok' | 'err' | 'warn' + +export interface Toast { + id: number + kind: ToastKind + title: string + detail?: string +} + +let seq = 0 + +export function useToasts() { + return useState('toasts', () => []) +} + +export function dismissToast(id: number) { + const toasts = useToasts() + toasts.value = toasts.value.filter((t) => t.id !== id) +} + +export function pushToast(kind: ToastKind, title: string, detail?: string, ttl = 5200) { + const toasts = useToasts() + const id = ++seq + toasts.value = [...toasts.value, { id, kind, title, detail }] + if (import.meta.client && ttl > 0) { + setTimeout(() => dismissToast(id), ttl) + } + return id +} + +/** 从 $fetch 抛出的错误里抠出可读信息 */ +export function errorMessage(err: unknown): string { + const e = err as { statusMessage?: string; data?: { statusMessage?: string; message?: string }; message?: string } + return ( + e?.data?.statusMessage || + e?.statusMessage || + e?.data?.message || + e?.message || + '请求失败' + ) +} diff --git a/app/layouts/default.vue b/app/layouts/default.vue new file mode 100644 index 0000000..ee839b5 --- /dev/null +++ b/app/layouts/default.vue @@ -0,0 +1,71 @@ + + + diff --git a/app/pages/downloads.vue b/app/pages/downloads.vue new file mode 100644 index 0000000..3512f2f --- /dev/null +++ b/app/pages/downloads.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/app/pages/history.vue b/app/pages/history.vue new file mode 100644 index 0000000..e47ff46 --- /dev/null +++ b/app/pages/history.vue @@ -0,0 +1,177 @@ + + + diff --git a/app/pages/index.vue b/app/pages/index.vue new file mode 100644 index 0000000..75660ee --- /dev/null +++ b/app/pages/index.vue @@ -0,0 +1,271 @@ + + +