feat: DSL 磁力聚合 — 搜索/解析/预览/投递 + cilisousuo 源 + 中英双语 + Docker 部署

This commit is contained in:
ice-a
2026-10-16 18:53:45 +08:00
commit d6999104bf
64 changed files with 16524 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<AppToasts />
</template>
+429
View File
@@ -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; }
}
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
const toasts = useToasts()
</script>
<template>
<div class="toasts">
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.kind" @click="dismissToast(t.id)">
<span class="mono small" :class="t.kind === 'err' ? 'err' : t.kind === 'ok' ? 'ok' : 'acc'">
{{ t.kind === 'err' ? '✕' : t.kind === 'warn' ? '!' : t.kind === 'ok' ? '✓' : 'i' }}
</span>
<span>
<strong style="font-weight: 600">{{ t.title }}</strong>
<span v-if="t.detail" class="dim" style="display: block; margin-top: 2px; font-size: 12px">{{ t.detail }}</span>
</span>
</div>
</div>
</template>
+99
View File
@@ -0,0 +1,99 @@
<script setup lang="ts">
import type { TorrentFileEntry } from '~/types/api'
const props = defineProps<{
files: TorrentFileEntry[] | null
total: number
resolving?: boolean
resolveError?: string | null
}>()
const emit = defineEmits<{ resolve: []; preview: [file: TorrentFileEntry] }>()
const keyword = ref('')
const sort = ref<'size' | 'name'>('size')
/** 图片且体积没超上限,才值得从 swarm 拉下来预览 */
function previewable(file: TorrentFileEntry): boolean {
return file.length > 0 && file.length <= MAX_IMAGE_PREVIEW_BYTES && isImagePath(file.path)
}
const visible = computed(() => {
let list = props.files || []
if (keyword.value) {
const kw = keyword.value.toLowerCase()
list = list.filter((f) => f.path.toLowerCase().includes(kw))
}
const sorted = [...list]
if (sort.value === 'size') sorted.sort((a, b) => b.length - a.length)
else sorted.sort((a, b) => a.path.localeCompare(b.path))
return sorted.slice(0, 500)
})
const truncated = computed(() => (props.files?.length || 0) > 500)
</script>
<template>
<div>
<div v-if="resolving" class="empty">
<span class="spin" /> 正在通过 DHT / tracker 获取种子元数据,通常几秒到几十秒…
</div>
<div v-else-if="!files" class="stack sm" style="padding: 18px">
<div class="note warn">
这个种子只有 infoHash,还没有文件清单。可以点击下面的按钮在线拉取元数据(需要本机能连上 DHT / tracker)。
</div>
<div v-if="resolveError" class="note err">{{ resolveError }}</div>
<div>
<button class="btn btn-primary" @click="emit('resolve')">在线解析文件清单</button>
</div>
</div>
<template v-else>
<div class="panel-hd" style="border-top: none">
<span class="label">FILE LIST</span>
<span class="mono small dim">{{ formatNumber(files.length) }} 个文件 · {{ formatBytes(total) }}</span>
<span class="spacer" />
<input v-model="keyword" class="input" style="width: 200px" placeholder="过滤文件名…" />
<select v-model="sort" class="select" style="width: 120px">
<option value="size">按大小</option>
<option value="name">按名称</option>
</select>
</div>
<div style="max-height: 520px; overflow: auto">
<table class="table">
<thead>
<tr>
<th style="width: 58%">路径</th>
<th class="right" style="width: 14%">大小</th>
<th class="right" style="width: 10%">占比</th>
<th style="width: 76px" />
</tr>
</thead>
<tbody>
<tr v-for="(f, i) in visible" :key="i">
<td class="file-path">
<span class="file-dir">{{ splitPath(f.path).dir }}</span><span class="file-name">{{ splitPath(f.path).name }}</span>
</td>
<td class="num right">{{ formatBytes(f.length) }}</td>
<td class="num right mute">{{ filePercent(f.length, total) }}</td>
<td class="right">
<button
v-if="previewable(f)"
class="btn btn-sm btn-ghost"
:title="`从做种节点拉取这张图片用于预览:${f.path}`"
@click="emit('preview', f)"
>
预览
</button>
<span v-else-if="isImagePath(f.path)" class="dim small" title="图片体积超出在线预览上限">过大</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="truncated" class="empty">结果较多,表格只展示前 500 条,可用过滤框缩小范围</div>
</template>
</div>
</template>
+120
View File
@@ -0,0 +1,120 @@
<script setup lang="ts">
import type { TorrentFileEntry } from '~/types/api'
const props = defineProps<{
infoHash: string
magnet: string
/** 全部可预览的图片,按文件清单原始顺序 */
images: TorrentFileEntry[]
index: number
}>()
const emit = defineEmits<{ close: []; 'update:index': [value: number] }>()
const status = ref<'loading' | 'ok' | 'err'>('loading')
const errorText = ref('')
const src = ref('')
let objectUrl = ''
const current = computed<TorrentFileEntry | undefined>(() => props.images[props.index])
const requestUrl = computed(
() =>
`/api/torrent/${props.infoHash}/preview?path=${encodeURIComponent(
current.value?.path || ''
)}&magnet=${encodeURIComponent(props.magnet)}`
)
function revoke() {
if (objectUrl) {
URL.revokeObjectURL(objectUrl)
objectUrl = ''
}
}
async function load() {
revoke()
src.value = ''
errorText.value = ''
status.value = 'loading'
try {
const res = await fetch(requestUrl.value)
if (!res.ok) {
// 服务端用 statusMessage 带上可读原因:没有做种节点 / 图片过大 / 文件不存在
const text = await res.text()
let message = text
try {
const json = JSON.parse(text)
message = json.statusMessage || json.message || text
} catch {
/* 不是 JSON 就用原文 */
}
throw new Error(message || `请求失败(HTTP ${res.status})`)
}
objectUrl = URL.createObjectURL(await res.blob())
src.value = objectUrl
status.value = 'ok'
} catch (err) {
errorText.value = errorMessage(err)
status.value = 'err'
}
}
function step(delta: number) {
const next = props.index + delta
if (next < 0 || next >= props.images.length) return
emit('update:index', next)
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
else if (e.key === 'ArrowLeft') step(-1)
else if (e.key === 'ArrowRight') step(1)
}
watch(() => [props.index, props.infoHash, props.magnet], load, { immediate: true })
onMounted(() => window.addEventListener('keydown', onKey))
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKey)
revoke()
})
</script>
<template>
<div class="overlay" @click.self="emit('close')">
<div class="lightbox">
<div class="panel-hd">
<span class="label">IMAGE</span>
<span class="mono small truncate" style="max-width: 36ch" :title="current?.path">
{{ splitPath(current?.path || '').name }}
</span>
<span class="spacer" />
<span class="mono small dim">{{ index + 1 }} / {{ images.length }}</span>
<span class="mono small dim">{{ formatBytes(current?.length) }}</span>
<button class="btn btn-sm btn-ghost" @click="emit('close')">关闭</button>
</div>
<div class="lightbox-bd">
<div v-if="status === 'loading'" class="empty">
<span class="spin" /> 正在从做种节点拉取这张图片,取决于对方的上传速度…
</div>
<div v-else-if="status === 'err'" class="stack sm" style="padding: 22px">
<div class="note err">{{ errorText }}</div>
<div class="row">
<button class="btn btn-sm" @click="load">重试</button>
<a class="btn btn-sm btn-ghost" :href="requestUrl" target="_blank" rel="noreferrer noopener">
直接打开图片地址
</a>
</div>
</div>
<img v-else :src="src" :alt="current?.path || 'preview'" class="lightbox-img" />
</div>
<div class="lightbox-ft">
<button class="btn btn-sm" :disabled="index <= 0" @click="step(-1)">← 上一张</button>
<span class="mono small dim truncate" style="max-width: 44ch" :title="current?.path">{{ current?.path }}</span>
<button class="btn btn-sm" :disabled="index >= images.length - 1" @click="step(1)">下一张 →</button>
</div>
</div>
</div>
</template>
+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
import type { ClientsResponse, TorrentView } from '~/types/api'
const props = defineProps<{ torrent: TorrentView }>()
const clients = ref<ClientsResponse | null>(null)
const loading = ref(false)
const sending = ref(false)
const selectedId = ref('')
const dir = ref('')
const statuses = computed(() => clients.value?.statuses || [])
const onlineStatuses = computed(() => statuses.value.filter((s) => s.online))
const current = computed(() => statuses.value.find((s) => s.id === selectedId.value) || null)
async function load() {
loading.value = true
try {
clients.value = await $fetch<ClientsResponse>('/api/clients')
const firstOnline = clients.value.statuses.find((s) => s.online)
if (!selectedId.value) selectedId.value = (firstOnline || clients.value.statuses[0])?.id || ''
syncDir()
} catch (err) {
pushToast('err', '读取下载器配置失败', errorMessage(err))
} finally {
loading.value = false
}
}
function syncDir() {
const cfg = clients.value?.configs.find((c) => c.id === selectedId.value)
dir.value = cfg?.downloadDir || ''
}
watch(selectedId, syncDir)
async function send() {
if (!selectedId.value) return
sending.value = true
try {
const res = await $fetch<{ ok: boolean; message: string }>('/api/send', {
method: 'POST',
body: {
clientId: selectedId.value,
infoHash: props.torrent.infoHash,
magnet: props.torrent.magnet || undefined,
name: props.torrent.name,
downloadDir: dir.value || undefined
}
})
pushToast('ok', '投递成功', res.message)
refreshHealth()
} catch (err) {
pushToast('err', '投递失败', errorMessage(err))
} finally {
sending.value = false
}
}
onMounted(load)
</script>
<template>
<div class="panel">
<div class="panel-hd">
<span class="label">SEND TO LOCAL CLIENT</span>
<span class="mono small" :class="onlineStatuses.length ? 'ok' : 'err'">
{{ loading ? '探测中…' : `${onlineStatuses.length}/${statuses.length} 在线` }}
</span>
<span class="spacer" />
<button class="btn btn-sm btn-ghost" :disabled="loading" @click="load">重新探测</button>
<NuxtLink to="/settings" class="btn btn-sm btn-ghost">配置</NuxtLink>
</div>
<div class="panel-bd stack sm">
<div v-if="!statuses.length" class="note warn">
还没有配置任何下载器。去「设置」里填 qBittorrent / Transmission / aria2 的地址和账号密码。
</div>
<template v-else>
<div class="row wrap">
<div
v-for="s in statuses"
:key="s.id"
class="pill"
:class="s.online ? 'on' : 'off'"
:title="s.error || s.endpoint"
>
<i class="dot" />
{{ s.name }}
<span class="mute">{{ s.version ? `v${s.version}` : s.online ? '' : '离线' }}</span>
</div>
</div>
<div class="grid-2">
<label class="field">
<span class="label">目标下载器</span>
<select v-model="selectedId" class="select">
<option v-for="s in statuses" :key="s.id" :value="s.id">
{{ s.name }}({{ s.online ? '在线' : '离线' }})
</option>
</select>
</label>
<label class="field">
<span class="label">下载目录(留空用客户端默认)</span>
<input v-model="dir" class="input" placeholder="D:\\Downloads" />
</label>
</div>
<div v-if="current && !current.online" class="note err">{{ current.error || '该下载器当前不可用' }}</div>
<div class="row">
<button class="btn btn-primary" :disabled="sending || !selectedId" @click="send">
<span v-if="sending" class="spin" />
{{ sending ? '投递中…' : '投递到本机下载器' }}
</button>
<span class="mute small">种子交给本机客户端下载,服务器不中转文件内容</span>
</div>
</template>
</div>
</div>
</template>
+178
View File
@@ -0,0 +1,178 @@
<script setup lang="ts">
import type { TorrentFileEntry, TorrentView } from '~/types/api'
const props = defineProps<{
torrent: TorrentView
/** 是否允许在线解析磁力链文件清单 */
canResolve?: boolean
}>()
const emit = defineEmits<{ resolved: [payload: { files: TorrentFileEntry[]; size: number; name: string }] }>()
const local = ref<TorrentView>({ ...props.torrent })
const resolving = ref(false)
const showTrackers = ref(false)
watch(
() => props.torrent,
(next) => {
local.value = { ...next }
},
{ deep: true }
)
const files = computed(() => local.value.files)
const total = computed(() => files.value?.reduce((s, f) => s + f.length, 0) || local.value.size || 0)
/** 可在线预览的图片:按文件清单原始顺序,预览弹窗靠这个顺序翻页 */
const previewImages = computed<TorrentFileEntry[]>(() =>
(files.value || []).filter((f) => f.length > 0 && f.length <= MAX_IMAGE_PREVIEW_BYTES && isImagePath(f.path))
)
const previewIndex = ref<number | null>(null)
function openPreview(file: TorrentFileEntry) {
const index = previewImages.value.findIndex((f) => f.path === file.path)
if (index >= 0) previewIndex.value = index
}
// 文件清单变了(例如刚在线解析完),旧的索引可能已经失效
watch(files, () => {
previewIndex.value = null
})
const sourceIcon: Record<string, string> = {
apibay: 'The Pirate Bay',
nyaa: 'Nyaa',
cilisousuo: '磁力搜索',
upload: '本地上传',
magnet: '磁力链',
database: '数据库'
}
async function doResolve() {
resolving.value = true
try {
const res = await $fetch<{ files: TorrentFileEntry[]; size: number; name: string }>(
`/api/torrent/${local.value.infoHash}/resolve`,
{ method: 'POST', body: { magnet: local.value.magnet } }
)
local.value.files = res.files
local.value.size = res.size
local.value.fileCount = res.files.length
local.value.resolveError = null
if (res.name) local.value.name = res.name
emit('resolved', res)
pushToast('ok', '文件清单已解析', `共 ${res.files.length} 个文件 / ${formatBytes(res.size)}`)
} catch (err) {
const msg = errorMessage(err)
local.value.resolveError = msg
pushToast('err', '解析失败', msg)
} finally {
resolving.value = false
}
}
async function copyMagnet() {
const ok = await copyText(local.value.magnet)
pushToast(ok ? 'ok' : 'err', ok ? '磁力链已复制到剪贴板' : '复制失败,请手动选中复制')
}
onMounted(() => {
// 如果没有文件清单,且是磁力链接(有 magnet),则自动尝试解析
if (!local.value.files && local.value.magnet && props.canResolve !== false) {
doResolve()
}
})
</script>
<template>
<div class="stack">
<div class="panel">
<div class="panel-hd">
<span class="label">TORRENT</span>
<span class="mono small truncate" style="max-width: 46ch" :title="local.name">{{ local.name }}</span>
<span class="spacer" />
<span class="tag">{{ sourceIcon[local.source || ''] || local.source || '未知来源' }}</span>
<span v-if="local.category" class="tag acc">{{ local.category }}</span>
</div>
<div class="panel-bd stack">
<div class="grid-4">
<div class="stat">
<span class="label">总大小</span>
<span class="v">{{ formatBytes(total) }}</span>
</div>
<div class="stat">
<span class="label">文件数</span>
<span class="v">{{ local.files ? formatNumber(local.files.length) : local.fileCount ?? '未知' }}</span>
</div>
<div class="stat">
<span class="label">做种 / 下载</span>
<span class="v">
<span class="ok">{{ local.seeders ?? '—' }}</span>
<span class="mute"> / </span>
<span class="warn">{{ local.leechers ?? '—' }}</span>
</span>
</div>
<div class="stat">
<span class="label">发布时间</span>
<span class="v small">{{ local.creationDate ? formatDate(local.creationDate) : '—' }}</span>
</div>
</div>
<dl class="kv">
<dt>infoHash</dt>
<dd class="mono break small">{{ local.infoHash }}</dd>
<dt>Trackers</dt>
<dd>
<button class="btn btn-sm btn-ghost" @click="showTrackers = !showTrackers">
{{ local.trackers?.length || 0 }} 个{{ showTrackers ? ' · 收起' : ' · 展开' }}
</button>
<div v-if="showTrackers" class="stack sm mt" style="max-height: 180px; overflow: auto">
<span v-for="t in local.trackers" :key="t" class="mono small dim break">{{ t }}</span>
</div>
</dd>
<dt v-if="local.comment">备注</dt>
<dd v-if="local.comment" class="small dim">{{ local.comment }}</dd>
</dl>
<div class="row wrap">
<button class="btn btn-sm" @click="copyMagnet">复制磁力链</button>
<a v-if="local.hasFile" class="btn btn-sm" :href="`/api/torrent/${local.infoHash}/file`">下载 .torrent</a>
<a v-if="local.sourceUrl" class="btn btn-sm btn-ghost" :href="local.sourceUrl" target="_blank" rel="noreferrer noopener">
打开来源页面
</a>
</div>
</div>
</div>
<SendPanel :torrent="local" />
<div v-if="previewImages.length" class="note">
文件清单里有 {{ previewImages.length }} 张图片可以点「预览」直接看:只会按需拉取这几张图所在的分片,不会下载整个种子。
</div>
<div class="panel">
<div class="panel-bd tight">
<FileTable
:files="files"
:total="total"
:resolving="resolving"
:resolve-error="local.resolveError"
@resolve="doResolve"
@preview="openPreview"
/>
</div>
</div>
<ImagePreview
v-if="previewIndex !== null"
:info-hash="local.infoHash"
:magnet="local.magnet"
:images="previewImages"
:index="previewIndex"
@update:index="previewIndex = $event"
@close="previewIndex = null"
/>
</div>
</template>
+16
View File
@@ -0,0 +1,16 @@
import type { HealthResponse } from '~/types/api'
/** 全局健康状态:Mongo / webtorrent / 客户端在线数,用于顶栏状态灯与页面提示 */
export function useHealth() {
return useState<HealthResponse | null>('health', () => null)
}
export async function refreshHealth() {
const state = useHealth()
try {
state.value = await $fetch<HealthResponse>('/api/health')
} catch {
state.value = null
}
return state.value
}
+41
View File
@@ -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<Toast[]>('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 ||
'请求失败'
)
}
+71
View File
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { locale, setLocale, type Locale } from '~/utils/i18n'
const route = useRoute()
const health = useHealth()
const lang = computed<Locale>({
get: () => locale.value,
set: (v) => setLocale(v)
})
onMounted(() => {
refreshHealth()
})
const mongoPill = computed(() => {
if (!health.value) return { cls: '', text: 'MONGO ?' }
return health.value.mongo.ok
? { cls: 'on', text: `MONGO · ${health.value.mongo.db}` }
: { cls: 'off', text: 'MONGO 未连接' }
})
</script>
<template>
<div class="shell">
<header class="topbar">
<div class="wrap topbar-in">
<NuxtLink to="/" class="brand">
<span class="brand-mark">BT</span>
<span>
<span class="brand-name">Torrent Console</span><br />
<span class="brand-sub">预览台 / 投递器</span>
</span>
</NuxtLink>
<nav class="nav">
<NuxtLink to="/" :class="{ active: route.path === '/' }">搜索</NuxtLink>
<NuxtLink to="/downloads" :class="{ active: route.path === '/downloads' }">任务</NuxtLink>
<NuxtLink to="/history" :class="{ active: route.path === '/history' }">历史</NuxtLink>
<NuxtLink to="/settings" :class="{ active: route.path === '/settings' }">设置</NuxtLink>
</nav>
<div class="topbar-right">
<span class="pill hide-sm" :class="health && !health.webTorrent ? 'warn' : ''">
<i class="dot" />
{{ health?.webTorrent ? '磁力解析就绪' : '磁力解析不可用' }}
</span>
<NuxtLink to="/settings" class="pill" :class="mongoPill.cls" :title="health?.mongo.error || ''">
<i class="dot" />
{{ mongoPill.text }}
</NuxtLink>
<select class="pill lang-select" v-model="lang" title="Language" aria-label="Language">
<option value="en">EN</option>
<option value="zh">中文</option>
</select>
</div>
</div>
</header>
<main class="grow wrap">
<slot />
</main>
<footer class="footer">
<div class="wrap spread">
<span>本地自托管 · 单进程 Nuxt 单体应用</span>
<span class="mute">种子只做解析与预览,文件内容由你自己的下载器拉取</span>
</div>
</footer>
</div>
</template>
+155
View File
@@ -0,0 +1,155 @@
<script setup lang="ts">
import type { BtTask, ClientTaskGroup } from '~/server/utils/clients'
const { data, refresh, pending } = await useFetch<{
groups: ClientTaskGroup[]
totalCount: number
onlineCount: number
}>('/api/clients/tasks')
const timer = ref<any>(null)
onMounted(() => {
timer.value = setInterval(() => {
if (!pending.value) refresh()
}, 5000)
})
onBeforeUnmount(() => {
if (timer.value) clearInterval(timer.value)
})
function statusCls(status: BtTask['status']) {
switch (status) {
case 'downloading': return 'ok'
case 'seeding': return 'acc'
case 'error': return 'err'
case 'paused': return 'mute'
case 'checking': return 'warn'
default: return ''
}
}
function statusText(status: BtTask['status']) {
switch (status) {
case 'downloading': return '下载中'
case 'seeding': return '做种中'
case 'paused': return '已暂停'
case 'error': return '错误'
case 'queued': return '排队中'
case 'checking': return '校验中'
case 'completed': return '已完成'
default: return '未知'
}
}
function formatEta(seconds?: number) {
if (seconds === undefined || seconds >= 8640000) return '—'
if (seconds < 60) return `${seconds}s`
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`
}
</script>
<template>
<div class="page">
<section class="rise">
<p class="label">ACTIVE · DOWNLOADS</p>
<h1>实时下载状态</h1>
<p class="sub">直接从你配置的 qBittorrent / Transmission / aria2 客户端拉取的实时数据。</p>
</section>
<div class="grid-4 mt rise rise-2">
<div class="stat">
<span class="label">活跃任务总数</span>
<span class="v">{{ data?.totalCount ?? 0 }}</span>
</div>
<div class="stat">
<span class="label">在线下载器</span>
<span class="v">{{ data?.onlineCount ?? 0 }}</span>
</div>
<div class="stat">
<span class="label">刷新频率</span>
<span class="v small">每 5 秒自动同步</span>
</div>
<div class="stat">
<button class="btn btn-sm btn-ghost mt-sm" :disabled="pending" @click="refresh()">
{{ pending ? '同步中…' : '手动刷新' }}
</button>
</div>
</div>
<div v-for="group in data?.groups" :key="group.clientId" class="stack mt rise rise-3">
<div class="panel">
<div class="panel-hd">
<span class="label">{{ group.clientName }}</span>
<span class="pill" :class="group.online ? 'on' : 'off'">
<i class="dot" />
{{ group.online ? '在线' : '离线' }}
</span>
<span v-if="group.error" class="small err ml">{{ group.error }}</span>
<span class="spacer" />
<span class="mono small dim">{{ group.tasks.length }} 个任务</span>
</div>
<div class="panel-bd tight">
<table v-if="group.tasks.length" class="table">
<thead>
<tr>
<th style="width: 40%">任务名称</th>
<th style="width: 15%">进度</th>
<th class="right">大小</th>
<th class="right hide-sm">下载速度</th>
<th class="right hide-sm">剩余时间</th>
<th class="right">状态</th>
</tr>
</thead>
<tbody>
<tr v-for="t in group.tasks" :key="t.infoHash">
<td class="name">
<NuxtLink class="mono small truncate" :to="`/torrent/${t.infoHash}`" style="max-width: 40ch">
{{ t.name }}
</NuxtLink>
</td>
<td>
<div class="row sm">
<div class="grow" style="height: 4px; background: var(--line); border-radius: 2px; overflow: hidden">
<div
style="height: 100%; background: var(--acc); transition: width 0.3s"
:style="{ width: (t.progress * 100) + '%' }"
/>
</div>
<span class="mono small" style="min-width: 4ch; text-align: right">
{{ (t.progress * 100).toFixed(1) }}%
</span>
</div>
</td>
<td class="num right">{{ formatBytes(t.size) }}</td>
<td class="num right hide-sm">
<span :class="t.dlspeed > 0 ? 'ok' : 'dim'">
{{ t.dlspeed > 0 ? `${formatBytes(t.dlspeed)}/s` : '—' }}
</span>
</td>
<td class="num right dim hide-sm">{{ formatEta(t.eta) }}</td>
<td class="right">
<span class="tag" :class="statusCls(t.status)">{{ statusText(t.status) }}</span>
</td>
</tr>
</tbody>
</table>
<div v-else class="empty">暂无任务</div>
</div>
</div>
</div>
<div v-if="!data?.groups.length && !pending" class="empty mt">
还没有配置任何下载器,或者所有下载器都已禁用。
<NuxtLink to="/settings" class="btn btn-sm btn-primary ml">去设置</NuxtLink>
</div>
</div>
</template>
<style scoped>
.ml { margin-left: 12px; }
.mt-sm { margin-top: 4px; }
</style>
+177
View File
@@ -0,0 +1,177 @@
<script setup lang="ts">
import type { TorrentView } from '~/types/api'
interface SearchItem {
_id: string
keyword: string
providers: string[]
resultCount: number
durationMs: number
ok: boolean
error: string | null
createdAt: string
}
const { data, refresh, pending } = await useFetch<{
items: SearchItem[]
hot: { keyword: string; count: number; last: string }[]
total: number
torrentTotal: number
offline: boolean
}>('/api/history')
const torrents = ref<TorrentView[]>([])
const torrentsPending = ref(false)
async function loadTorrents() {
torrentsPending.value = true
try {
const res = await $fetch<{ items: TorrentView[] }>('/api/torrents', { query: { limit: 30 } })
torrents.value = res.items
} catch {
torrents.value = []
} finally {
torrentsPending.value = false
}
}
async function removeOne(id: string) {
try {
await $fetch('/api/history', { method: 'DELETE', query: { id } })
pushToast('ok', '已删除该条记录')
await refresh()
} catch (err) {
pushToast('err', '删除失败', errorMessage(err))
}
}
async function clearAll() {
if (!confirm('确定清空全部搜索历史?')) return
try {
const res = await $fetch<{ deleted: number }>('/api/history', { method: 'DELETE' })
pushToast('ok', `已清空 ${res.deleted} 条记录`)
await refresh()
} catch (err) {
pushToast('err', '清空失败', errorMessage(err))
}
}
onMounted(loadTorrents)
</script>
<template>
<div class="page">
<section class="rise">
<p class="label">HISTORY · LIBRARY</p>
<h1>搜索历史与种子库</h1>
<p class="sub">每次搜索都会落到 MongoDB 的 searches 集合;搜到和解析过的种子按 infoHash 去重存在 torrents 集合。</p>
</section>
<div v-if="data?.offline" class="note warn mt rise rise-2">
MongoDB 未连接,历史记录不可用。启动本地 MongoDB 后刷新本页即可。
</div>
<div class="grid-4 mt rise rise-2">
<div class="stat"><span class="label">搜索总次数</span><span class="v">{{ formatNumber(data?.total) }}</span></div>
<div class="stat"><span class="label">库中种子数</span><span class="v">{{ formatNumber(data?.torrentTotal) }}</span></div>
<div class="stat"><span class="label">高频关键词</span><span class="v">{{ data?.hot?.length || 0 }}</span></div>
<div class="stat">
<span class="label">状态</span>
<span class="v small" :class="data?.offline ? 'err' : 'ok'">{{ data?.offline ? '未连接' : '正常' }}</span>
</div>
</div>
<div v-if="data?.hot?.length" class="panel mt rise rise-3">
<div class="panel-hd"><span class="label">TOP KEYWORDS</span><span class="spacer" /><span class="mute small">点击直接再搜一次</span></div>
<div class="panel-bd row wrap">
<NuxtLink
v-for="h in data.hot"
:key="h.keyword"
class="tag acc"
:to="{ path: '/', query: { q: h.keyword } }"
style="padding: 4px 10px; font-size: 12px"
>
{{ h.keyword }} ×{{ h.count }}
</NuxtLink>
</div>
</div>
<div class="panel mt rise rise-3">
<div class="panel-hd">
<span class="label">SEARCH LOG</span>
<span class="spacer" />
<button class="btn btn-sm btn-ghost" :disabled="pending" @click="refresh()">
{{ pending ? '刷新中…' : '刷新' }}
</button>
<button class="btn btn-sm btn-danger" :disabled="!data?.items?.length" @click="clearAll">清空</button>
</div>
<div class="panel-bd tight">
<table v-if="data?.items?.length" class="table">
<thead>
<tr>
<th style="width: 42%">关键词</th>
<th class="right">结果数</th>
<th class="right hide-sm">耗时</th>
<th class="hide-sm">源</th>
<th class="right">时间</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="it in data.items" :key="it._id">
<td>
<NuxtLink class="name" :to="{ path: '/', query: { q: it.keyword } }">{{ it.keyword }}</NuxtLink>
<span v-if="!it.ok && it.error" class="small err" style="display: block">{{ it.error }}</span>
</td>
<td class="num right" :class="it.ok ? '' : 'err'">{{ it.resultCount }}</td>
<td class="num right dim hide-sm">{{ it.durationMs }}ms</td>
<td class="hide-sm"><span class="tag">{{ it.providers?.join(' + ') || '—' }}</span></td>
<td class="num right dim">{{ formatRelative(it.createdAt) }}</td>
<td class="right">
<button class="btn btn-sm btn-ghost" @click="removeOne(it._id)">删除</button>
</td>
</tr>
</tbody>
</table>
<div v-else class="empty">还没有搜索记录</div>
</div>
</div>
<div class="panel mt rise rise-4">
<div class="panel-hd">
<span class="label">RECENT TORRENTS</span>
<span class="spacer" />
<button class="btn btn-sm btn-ghost" :disabled="torrentsPending" @click="loadTorrents">
{{ torrentsPending ? '加载中…' : '刷新' }}
</button>
</div>
<div class="panel-bd tight">
<table v-if="torrents.length" class="table">
<thead>
<tr>
<th style="width: 48%">名称</th>
<th>来源</th>
<th class="right">大小</th>
<th class="right hide-sm">文件</th>
<th class="right">更新</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="t in torrents" :key="t.infoHash">
<td class="name">{{ t.name }}</td>
<td><span class="tag">{{ t.source }}</span></td>
<td class="num right">{{ formatBytes(t.size) }}</td>
<td class="num right dim hide-sm">{{ t.files ? t.files.length : t.fileCount ?? '未解析' }}</td>
<td class="num right dim">{{ formatRelative(t.resolvedAt || t.creationDate) }}</td>
<td class="right">
<NuxtLink class="btn btn-sm" :to="{ path: `/torrent/${t.infoHash}`, query: { magnet: t.magnet } }">打开</NuxtLink>
</td>
</tr>
</tbody>
</table>
<div v-else class="empty">库里还没有种子</div>
</div>
</div>
</div>
</template>
+271
View File
@@ -0,0 +1,271 @@
<script setup lang="ts">
import type { SearchResponse, TorrentView } from '~/types/api'
type Tab = 'search' | 'file' | 'magnet'
const route = useRoute()
const health = useHealth()
const tab = ref<Tab>('search')
/* ---------------- 关键词搜索 ---------------- */
const keyword = ref('')
const activeProviders = ref<string[]>([])
const searching = ref(false)
const response = ref<SearchResponse | null>(null)
const page = ref(1)
const pageSize = ref(20)
const pagedResults = computed(() => {
if (!response.value) return []
const start = (page.value - 1) * pageSize.value
return response.value.results.slice(start, start + pageSize.value)
})
const totalPages = computed(() => {
if (!response.value) return 0
return Math.ceil(response.value.results.length / pageSize.value)
})
onMounted(async () => {
const data = health.value || (await refreshHealth())
if (data) activeProviders.value = data.enabledProviders
const q = String(route.query.q || '').trim()
if (q) {
keyword.value = q
await doSearch()
}
})
/* ---------------- 上传 / 磁力 ---------------- */
const dropping = ref(false)
const parsing = ref(false)
const parsed = ref<TorrentView | null>(null)
const magnetInput = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
async function doSearch(term?: string) {
const q = (term ?? keyword.value).trim()
if (!q) {
pushToast('warn', '请输入关键词')
return
}
// 如果输入的是 infoHash,直接跳转到详情页
if (/^[a-f0-9]{40}$/i.test(q)) {
navigateTo(`/torrent/${q.toLowerCase()}`)
return
}
keyword.value = q
searching.value = true
page.value = 1
try {
response.value = await $fetch<SearchResponse>('/api/search', {
query: { q, providers: activeProviders.value.join(',') }
})
if (!response.value.results.length) {
pushToast('warn', '没有搜到结果', '可以换个关键词,或检查代理设置')
}
} catch (err) {
pushToast('err', '搜索失败', errorMessage(err))
} finally {
searching.value = false
}
}
async function pickFile(file: File) {
parsing.value = true
try {
const form = new FormData()
form.append('file', file)
const res = await $fetch<{ torrent: TorrentView; stored: boolean }>('/api/parse', {
method: 'POST',
body: form
})
parsed.value = res.torrent
pushToast('ok', '种子解析完成', `${res.torrent.files?.length || 0} 个文件 / ${formatBytes(res.torrent.size)}`)
} catch (err) {
pushToast('err', '解析失败', errorMessage(err))
} finally {
parsing.value = false
}
}
function onFileChange(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (file) pickFile(file)
}
function onDrop(event: DragEvent) {
dropping.value = false
const file = event.dataTransfer?.files?.[0]
if (file) pickFile(file)
}
async function parseMagnet() {
const magnet = magnetInput.value.trim()
if (!magnet) {
pushToast('warn', '请粘贴磁力链')
return
}
parsing.value = true
try {
const res = await $fetch<{ torrent: TorrentView }>('/api/parse', { method: 'POST', body: { magnet } })
parsed.value = res.torrent
pushToast('ok', '磁力链解析完成', '文件清单需要点击「在线解析」拉取')
} catch (err) {
pushToast('err', '解析失败', errorMessage(err))
} finally {
parsing.value = false
}
}
function previewLink(item: TorrentView) {
return {
path: `/torrent/${item.infoHash}`,
query: { magnet: item.magnet, name: item.name }
}
}
</script>
<template>
<div class="page">
<section class="rise">
<p class="label">SEARCH · PARSE · HANDOFF</p>
<h1>搜种子、看清里面有什么,再丢给本机下载器</h1>
<p class="sub">
搜索走公开磁力源,预览只看文件清单(不下服务器),确认后把种子投递给本机的
qBittorrent / Transmission / aria2,由它们完成实际下载。
</p>
</section>
<div v-if="health && !health.mongo.ok" class="note warn mt rise rise-2">
MongoDB 未连接,搜索记录与种子缓存不会被保存(其余功能可用)。启动本地 MongoDB 后刷新即可:
<span class="mono">{{ health.mongo.uri }}</span>
</div>
<div class="tabs rise rise-2">
<button :class="{ on: tab === 'search' }" @click="tab = 'search'">关键词搜索</button>
<button :class="{ on: tab === 'file' }" @click="tab = 'file'">上传 .torrent</button>
<button :class="{ on: tab === 'magnet' }" @click="tab = 'magnet'">磁力链</button>
</div>
<!-- ============ 关键词搜索 ============ -->
<div v-show="tab === 'search'" class="stack mt rise rise-3">
<form class="panel" @submit.prevent="doSearch()">
<div class="panel-hd">
<span class="label">QUERY</span>
<span class="spacer" />
<span class="mono small mute">{{ health?.searchProxy === '已配置' ? '经代理访问' : '直连访问' }}</span>
</div>
<div class="panel-bd stack sm">
<div class="row">
<input v-model="keyword" class="input" placeholder="输入片名 / 剧名 / 关键词,回车搜索" autofocus />
<button class="btn btn-primary" type="submit" :disabled="searching">
<span v-if="searching" class="spin" />
{{ searching ? '搜索中' : '搜索' }}
</button>
</div>
<div class="row wrap">
<span class="label">搜索源</span>
<label v-for="p in health?.providers || []" :key="p.id" class="check">
<input v-model="activeProviders" type="checkbox" :value="p.id" />
{{ p.label }}
</label>
</div>
</div>
</form>
<div v-if="response" class="row wrap small">
<span class="mono dim">
{{ response.results.length }} 条结果 · {{ response.durationMs }}ms ·
{{ response.stored ? '已写入 MongoDB' : 'MongoDB 未连接,未记录' }}
</span>
<span class="spacer" />
<span v-for="r in response.reports" :key="r.id" class="tag" :class="r.ok ? 'ok' : 'err'" :title="r.error || ''">
{{ r.label }} {{ r.ok ? `${r.count} 条 / ${r.ms}ms` : r.error }}
</span>
</div>
<div v-if="response" class="panel rise">
<div class="panel-bd tight">
<table v-if="response.results.length" class="table">
<thead>
<tr>
<th style="width: 46%">名称</th>
<th class="hide-sm">来源</th>
<th class="right">大小</th>
<th class="right hide-sm">文件</th>
<th class="right">做种</th>
<th class="right hide-sm">下载</th>
<th class="right hide-sm">发布</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in pagedResults" :key="item.infoHash">
<td class="name">{{ item.name }}</td>
<td class="hide-sm"><span class="tag">{{ item.sourceLabel }}</span></td>
<td class="num right">{{ formatBytes(item.size) }}</td>
<td class="num right hide-sm dim">{{ item.fileCount ?? '—' }}</td>
<td class="num right ok">{{ item.seeders ?? '—' }}</td>
<td class="num right warn hide-sm">{{ item.leechers ?? '—' }}</td>
<td class="num right dim hide-sm">{{ formatRelative(item.addedAt) }}</td>
<td class="right">
<NuxtLink class="btn btn-sm" :to="previewLink(item)">预览</NuxtLink>
</td>
</tr>
</tbody>
</table>
<div v-else class="empty">没有结果。换个关键词,或在「设置」里配置代理后再试。</div>
</div>
<div v-if="response && response.results.length > pageSize" class="panel-ft row center py-md">
<button class="btn btn-sm btn-ghost" :disabled="page <= 1" @click="page--">上一页</button>
<span class="mono small dim mx-lg">第 {{ page }} / {{ totalPages }} 页</span>
<button class="btn btn-sm btn-ghost" :disabled="page >= totalPages" @click="page++">下一页</button>
</div>
</div>
<div v-else-if="!searching" class="panel">
<div class="empty">输入关键词开始搜索</div>
</div>
</div>
<!-- ============ 上传 .torrent ============ -->
<div v-show="tab === 'file'" class="stack mt rise rise-3">
<div
class="drop panel"
:class="{ over: dropping }"
@click="fileInput?.click()"
@dragover.prevent="dropping = true"
@dragleave.prevent="dropping = false"
@drop.prevent="onDrop"
>
<div class="big">{{ parsing ? '解析中…' : '把 .torrent 文件拖到这里,或点击选择' }}</div>
<p class="mute small">文件只在服务端解析出文件清单,不会上传到任何第三方</p>
<input ref="fileInput" type="file" accept=".torrent,application/x-bittorrent" hidden @change="onFileChange" />
</div>
<TorrentDetail v-if="parsed" :torrent="parsed" :can-resolve="true" />
</div>
<!-- ============ 磁力链 ============ -->
<div v-show="tab === 'magnet'" class="stack mt rise rise-3">
<div class="panel">
<div class="panel-hd"><span class="label">MAGNET URI</span></div>
<div class="panel-bd stack sm">
<textarea v-model="magnetInput" class="textarea" placeholder="magnet:?xt=urn:btih:…" />
<div class="row">
<button class="btn btn-primary" :disabled="parsing" @click="parseMagnet">
<span v-if="parsing" class="spin" />
解析磁力链
</button>
<span class="mute small">磁力链需要联网从 DHT / tracker 拉取元数据,才能看到文件清单</span>
</div>
</div>
</div>
<TorrentDetail v-if="parsed" :torrent="parsed" :can-resolve="true" />
</div>
</div>
</template>
+339
View File
@@ -0,0 +1,339 @@
<script setup lang="ts">
import type { BtClientConfig, ClientsResponse, ClientStatus } from '~/types/api'
interface DownloadItem {
_id: string
name: string
clientName: string
clientType: string
downloadDir: string | null
ok: boolean
message: string
createdAt: string
}
const health = useHealth()
const loading = ref(true)
const saving = ref(false)
const clients = ref<ClientsResponse | null>(null)
const configs = ref<BtClientConfig[]>([])
const statuses = ref<ClientStatus[]>([])
const downloads = ref<DownloadItem[]>([])
const testState = ref<Record<string, { testing?: boolean; ok?: boolean; error?: string; version?: string; endpoint?: string }>>({})
const enabledProviders = ref<string[]>([])
const searchProxy = ref('')
const searchTimeout = ref(12000)
const metadataTimeout = ref(30000)
async function load() {
loading.value = true
try {
const [c, , d] = await Promise.all([
$fetch<ClientsResponse>('/api/clients'),
refreshHealth(),
$fetch<{ items: DownloadItem[] }>('/api/downloads', { query: { limit: 25 } })
])
clients.value = c
configs.value = JSON.parse(JSON.stringify(c.configs))
statuses.value = c.statuses
downloads.value = d.items
enabledProviders.value = c.settings.enabledProviders
searchProxy.value = c.settings.searchProxy
searchTimeout.value = c.settings.searchTimeout
metadataTimeout.value = c.settings.metadataTimeout
} catch (err) {
pushToast('err', '读取配置失败', errorMessage(err))
} finally {
loading.value = false
}
}
const adapters = computed(() => clients.value?.adapters || [])
const statusOf = (id: string) => statuses.value.find((s) => s.id === id)
function statusPill(cfg: BtClientConfig): ClientStatus | undefined {
return statusOf(cfg.id)
}
function addClient() {
const seq = configs.value.length + 1
configs.value.push({
id: `client-${Date.now().toString(36)}`,
type: 'qbittorrent',
name: `新下载器 ${seq}`,
host: '127.0.0.1',
port: 8080,
username: 'admin',
password: '',
downloadDir: '',
enabled: true,
useHttps: false
})
}
function removeClient(index: number) {
configs.value.splice(index, 1)
}
function onTypeChange(cfg: BtClientConfig) {
const adapter = adapters.value.find((a) => a.type === cfg.type)
if (adapter) {
cfg.port = adapter.defaultPort
cfg.path = adapter.defaultPath || undefined
}
}
async function testClient(cfg: BtClientConfig) {
testState.value = { ...testState.value, [cfg.id]: { testing: true } }
try {
const res = await $fetch<{ ok: boolean; online: boolean; version: string | null; endpoint: string; error?: string }>(
'/api/clients/test',
{ method: 'POST', body: { client: cfg } }
)
testState.value = {
...testState.value,
[cfg.id]: { ok: res.ok, version: res.version || undefined, endpoint: res.endpoint, error: res.error }
}
pushToast(res.ok ? 'ok' : 'err', res.ok ? `${cfg.name} 连接正常` : `${cfg.name} 连接失败`, res.error || undefined)
} catch (err) {
const msg = errorMessage(err)
testState.value = { ...testState.value, [cfg.id]: { ok: false, error: msg } }
pushToast('err', '测试失败', msg)
}
}
async function save() {
saving.value = true
try {
await $fetch('/api/clients', {
method: 'PUT',
body: {
btClients: configs.value,
enabledProviders: enabledProviders.value,
searchProxy: searchProxy.value,
searchTimeout: searchTimeout.value,
metadataTimeout: metadataTimeout.value
}
})
pushToast('ok', '配置已保存')
await load()
} catch (err) {
pushToast('err', '保存失败', errorMessage(err))
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<div class="page">
<section class="rise">
<p class="label">SETTINGS · ADAPTERS</p>
<h1>搜索源与本机下载器</h1>
<p class="sub">
所有配置写在 MongoDB 的 settings 集合里(没连库时回落到 .env)。下载器地址默认指向本机,如果服务部署在别的机器上,改成本机可访问的地址即可。
</p>
</section>
<div class="row mt rise rise-2">
<button class="btn btn-primary" :disabled="saving || loading" @click="save">
<span v-if="saving" class="spin" />{{ saving ? '保存中…' : '保存全部配置' }}
</button>
<button class="btn btn-ghost" :disabled="loading" @click="load">重新加载</button>
<span v-if="health && !health.mongo.ok" class="small warn">MongoDB 未连接,配置只能在本次运行内生效</span>
</div>
<!-- ============ 搜索源 ============ -->
<div class="panel mt rise rise-2">
<div class="panel-hd"><span class="label">SEARCH SOURCES</span></div>
<div class="panel-bd stack">
<div class="row wrap">
<span class="label">启用的搜索源</span>
<label v-for="p in health?.providers || []" :key="p.id" class="check">
<input v-model="enabledProviders" type="checkbox" :value="p.id" />
{{ p.label }}
<span class="mute small">{{ p.homepage }}</span>
</label>
</div>
<div class="grid-3">
<label class="field">
<span class="label">HTTP 代理(搜索源用)</span>
<input v-model="searchProxy" class="input" placeholder="http://127.0.0.1:7890 / 留空直连" />
</label>
<label class="field">
<span class="label">搜索超时(毫秒)</span>
<input v-model.number="searchTimeout" class="input" type="number" min="1000" max="60000" step="500" />
</label>
<label class="field">
<span class="label">元数据解析超时(毫秒)</span>
<input v-model.number="metadataTimeout" class="input" type="number" min="3000" max="180000" step="1000" />
</label>
</div>
<div class="note">
公开磁力站在部分网络环境下不可直连,配好代理后「设置 → 保存」再回搜索页重试即可。代理只作用于搜索源请求,不影响下载器通信。
</div>
</div>
</div>
<!-- ============ 下载器 ============ -->
<div class="panel mt rise rise-3">
<div class="panel-hd">
<span class="label">LOCAL BT CLIENTS</span>
<span class="mono small" :class="statuses.some((s) => s.online) ? 'ok' : 'err'">
{{ statuses.filter((s) => s.online).length }}/{{ statuses.length }} 在线
</span>
<span class="spacer" />
<button class="btn btn-sm btn-ghost" @click="addClient">+ 新增下载器</button>
</div>
<div class="panel-bd stack">
<div v-for="(cfg, index) in configs" :key="cfg.id" class="panel" style="background: var(--panel-2)">
<div class="panel-hd" style="background: var(--panel-3)">
<label class="check">
<input v-model="cfg.enabled" type="checkbox" />
启用
</label>
<span class="spacer" />
<span v-if="statusPill(cfg)" class="pill" :class="statusPill(cfg)!.online ? 'on' : 'off'" :title="statusPill(cfg)!.error || ''">
<i class="dot" />
{{ statusPill(cfg)!.online ? `在线 ${statusPill(cfg)!.version || ''}` : '离线' }}
</span>
<button class="btn btn-sm" :disabled="testState[cfg.id]?.testing" @click="testClient(cfg)">
<span v-if="testState[cfg.id]?.testing" class="spin" />测试连接
</button>
<button class="btn btn-sm btn-danger" @click="removeClient(index)">删除</button>
</div>
<div class="panel-bd stack sm">
<div class="grid-4">
<label class="field">
<span class="label">名称</span>
<input v-model="cfg.name" class="input" />
</label>
<label class="field">
<span class="label">类型</span>
<select v-model="cfg.type" class="select" @change="onTypeChange(cfg)">
<option v-for="a in adapters" :key="a.type" :value="a.type">{{ a.label }}</option>
</select>
</label>
<label class="field">
<span class="label">主机</span>
<input v-model="cfg.host" class="input" />
</label>
<label class="field">
<span class="label">端口</span>
<input v-model.number="cfg.port" class="input" type="number" />
</label>
</div>
<div class="grid-4">
<label class="field">
<span class="label">RPC 路径(可空)</span>
<input v-model="cfg.path" class="input" :placeholder="adapters.find((a) => a.type === cfg.type)?.defaultPath || '默认'" />
</label>
<label class="field">
<span class="label">用户名{{ cfg.type === 'aria2' ? '(aria2 用不到)' : '' }}</span>
<input v-model="cfg.username" class="input" :disabled="cfg.type === 'aria2'" />
</label>
<label class="field">
<span class="label">{{ cfg.type === 'aria2' ? 'RPC 密钥 --rpc-secret' : '密码' }}</span>
<input
v-model="cfg.password"
class="input"
:type="cfg.type === 'aria2' ? 'text' : 'password'"
:placeholder="cfg.type === 'aria2' ? 'aria2 的 --rpc-secret 值' : ''"
/>
</label>
<label class="field">
<span class="label">默认下载目录</span>
<input v-model="cfg.downloadDir" class="input" placeholder="留空用客户端默认" />
</label>
</div>
<div class="row">
<label class="check">
<input v-model="cfg.useHttps" type="checkbox" />
使用 HTTPS
</label>
<span v-if="testState[cfg.id]?.error" class="small err">{{ testState[cfg.id]?.error }}</span>
<span v-else-if="testState[cfg.id]?.ok" class="small ok">
可达 {{ testState[cfg.id]?.endpoint }} {{ testState[cfg.id]?.version ? `· v${testState[cfg.id]?.version}` : '' }}
</span>
</div>
<div class="note">
<span v-if="cfg.type === 'qbittorrent'">
qBittorrent:开启「工具 → 选项 → Web UI」,默认端口 8080;首次访问会让客户端把本机 IP 加入白名单。
</span>
<span v-else-if="cfg.type === 'transmission'">
Transmission:开启远程控制,默认端口 9091,RPC 路径 <span class="mono">/transmission/rpc</span>;没设用户名密码就留空。
</span>
<span v-else>
aria2:启动时加 <span class="mono">--enable-rpc --rpc-listen-all=true --rpc-secret=你的密钥</span>,把密钥填到「RPC 密钥」框。
</span>
</div>
</div>
</div>
<div v-if="!configs.length" class="empty">还没有下载器,点右上角「新增下载器」</div>
</div>
</div>
<!-- ============ 下载记录 ============ -->
<div class="panel mt rise rise-4">
<div class="panel-hd"><span class="label">HANDOFF LOG</span><span class="spacer" /><span class="mute small">最近 25 条投递记录</span></div>
<div class="panel-bd tight">
<table v-if="downloads.length" class="table">
<thead>
<tr>
<th style="width: 44%">任务</th>
<th>下载器</th>
<th class="hide-sm">目录</th>
<th>结果</th>
<th class="right">时间</th>
</tr>
</thead>
<tbody>
<tr v-for="d in downloads" :key="d._id">
<td class="name">{{ d.name }}</td>
<td><span class="tag">{{ d.clientName || d.clientId }}</span></td>
<td class="small mute hide-sm">{{ d.downloadDir || '默认' }}</td>
<td>
<span class="tag" :class="d.ok ? 'ok' : 'err'">{{ d.ok ? '成功' : '失败' }}</span>
<span class="small mute" style="display: block">{{ d.message }}</span>
</td>
<td class="num right dim">{{ formatRelative(d.createdAt) }}</td>
</tr>
</tbody>
</table>
<div v-else class="empty">还没有投递记录</div>
</div>
</div>
<!-- ============ 环境信息 ============ -->
<div class="panel mt rise rise-4">
<div class="panel-hd"><span class="label">RUNTIME</span></div>
<div class="panel-bd">
<dl class="kv">
<dt>MongoDB</dt>
<dd>
<span :class="health?.mongo.ok ? 'ok' : 'err'">{{ health?.mongo.ok ? '已连接' : '未连接' }}</span>
<span class="mono small dim"> · {{ health?.mongo.uri }} / {{ health?.mongo.db }}</span>
<div v-if="health?.mongo.error" class="small err mono break">{{ health.mongo.error }}</div>
</dd>
<dt>磁力解析</dt>
<dd :class="health?.webTorrent ? 'ok' : 'err'">
{{ health?.webTorrent ? 'webtorrent 可用,可通过 DHT/tracker 取文件清单' : 'webtorrent 不可用,磁力链只能显示 infoHash' }}
</dd>
<dt>搜索源</dt>
<dd class="mono small">{{ health?.providers?.map((p) => p.label).join(' / ') }}</dd>
<dt>代理</dt>
<dd class="mono small">{{ health?.searchProxy }}</dd>
</dl>
</div>
</div>
</div>
</template>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import type { TorrentView } from '~/types/api'
const route = useRoute()
const infoHash = computed(() => String(route.params.infohash || '').toLowerCase())
const magnet = computed(() => String(route.query.magnet || ''))
const { data, error } = await useFetch<{ torrent: TorrentView; source: string }>(
() => `/api/torrent/${infoHash.value}`,
{ query: { magnet }, watch: [infoHash, magnet] }
)
useHead(() => ({ title: data.value?.torrent?.name ? `${data.value.torrent.name} · 种子预览` : '种子预览' }))
refreshHealth()
</script>
<template>
<div class="page">
<div class="row small rise">
<NuxtLink to="/" class="btn btn-sm btn-ghost">← 返回搜索</NuxtLink>
<span class="mono mute truncate">{{ infoHash }}</span>
<span v-if="data?.source" class="tag">{{ data.source === 'database' ? '来自 MongoDB 缓存' : '来自链接参数' }}</span>
</div>
<div v-if="error" class="panel mt rise rise-2">
<div class="panel-bd stack sm">
<span class="label">NOT FOUND</span>
<h2>拿不到这个种子的信息</h2>
<p class="sub">{{ errorMessage(error) }}</p>
<p class="mute small">
如果你是直接从外部链接进来的,请带上 magnet 参数,例如
<span class="mono">/torrent/&lt;infoHash&gt;?magnet=magnet:?xt=urn:btih:…</span>
</p>
<div><NuxtLink to="/" class="btn btn-primary">回到搜索页</NuxtLink></div>
</div>
</div>
<div v-else-if="data?.torrent" class="mt rise rise-2">
<TorrentDetail :torrent="data.torrent" :can-resolve="true" />
</div>
<div v-else class="empty mt">
<span class="spin" /> 加载中…
</div>
</div>
</template>
+48
View File
@@ -0,0 +1,48 @@
import { locale, translateDocument } from '~/utils/i18n'
export default defineNuxtPlugin(() => {
const applyLangAttr = () => {
if (import.meta.client) {
document.documentElement.lang = locale.value === 'zh' ? 'zh-CN' : 'en'
}
}
const run = () => {
translateDocument()
applyLangAttr()
}
// 初次翻译:等 DOM 就绪
if (import.meta.client) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run, { once: true })
} else {
run()
}
}
// 客户端渲染 / 数据刷新会改变 DOM,用 MutationObserver 兜底重新翻译
if (import.meta.client) {
let scheduled = false
const schedule = () => {
if (scheduled) return
scheduled = true
requestAnimationFrame(() => {
scheduled = false
translateDocument()
})
}
const observer = new MutationObserver(schedule)
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
// 路由切换
const router = useRouter()
router.afterEach(() => nextTick(schedule))
// 语言切换时整体重翻
watch(locale, () => {
applyLangAttr()
translateDocument()
})
}
})
+104
View File
@@ -0,0 +1,104 @@
export interface TorrentFileEntry {
path: string
length: number
}
export interface TorrentView {
infoHash: string
name: string
size: number
files: TorrentFileEntry[] | null
fileCount: number | null
trackers: string[]
magnet: string
source?: string
sourceLabel?: string
sourceUrl?: string | null
category?: string | null
seeders?: number | null
leechers?: number | null
comment?: string | null
creationDate?: string | null
resolvedAt?: string | null
resolveError?: string | null
/** 服务端是否保存了原始 .torrent 文件,可提供下载 */
hasFile?: boolean
}
export interface SearchResult extends TorrentView {
sourceLabel: string
}
export interface ProviderReport {
id: string
label: string
ok: boolean
count: number
ms: number
error: string | null
}
export interface SearchResponse {
query: string
durationMs: number
results: SearchResult[]
reports: ProviderReport[]
stored: boolean
}
export interface ClientStatus {
id: string
type: string
name: string
label: string
endpoint: string
online: boolean
version?: string
error?: string
}
export interface BtClientConfig {
id: string
type: 'qbittorrent' | 'transmission' | 'aria2'
name: string
host: string
port: number
path?: string
username?: string
password?: string
downloadDir?: string
useHttps?: boolean
enabled?: boolean
}
export interface ClientAdapterMeta {
type: string
label: string
defaultPort: number
defaultPath: string | null
}
export interface ClientsResponse {
adapters: ClientAdapterMeta[]
configs: BtClientConfig[]
statuses: ClientStatus[]
online: number
total: number
settings: {
searchProxy: string
searchTimeout: number
metadataTimeout: number
enabledProviders: string[]
}
}
export interface HealthResponse {
ok: boolean
time: string
mongo: { ok: boolean; uri: string; db: string; error: string | null }
webTorrent: boolean
providers: { id: string; label: string; homepage: string }[]
enabledProviders: string[]
searchProxy: string
clients: { id: string; type: string; name: string; endpoint: string; enabled: boolean }[]
}
+76
View File
@@ -0,0 +1,76 @@
export function formatBytes(bytes: number | null | undefined, digits = 2): string {
const value = Number(bytes)
if (!value || value < 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
let index = 0
let n = value
while (n >= 1024 && index < units.length - 1) {
n /= 1024
index++
}
return `${n.toFixed(index === 0 ? 0 : digits)} ${units[index]}`
}
export function formatDate(input: string | number | Date | null | undefined): string {
if (!input) return '—'
const date = input instanceof Date ? input : new Date(input)
if (Number.isNaN(date.getTime())) return '—'
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(
date.getMinutes()
)}`
}
export function formatRelative(input: string | number | Date | null | undefined): string {
if (!input) return '—'
const date = input instanceof Date ? input : new Date(input)
const diff = Date.now() - date.getTime()
if (Number.isNaN(diff)) return '—'
const min = Math.floor(diff / 60000)
if (min < 1) return '刚刚'
if (min < 60) return `${min} 分钟前`
const hour = Math.floor(min / 60)
if (hour < 24) return `${hour} 小时前`
const day = Math.floor(hour / 24)
if (day < 30) return `${day} 天前`
return formatDate(date)
}
export function formatNumber(value: number | null | undefined): string {
if (value === null || value === undefined) return '—'
return value.toLocaleString('en-US')
}
export function filePercent(length: number, total: number): string {
if (!total) return '0%'
const pct = (length / total) * 100
return pct >= 10 ? `${pct.toFixed(0)}%` : `${pct.toFixed(1)}%`
}
/** 把扁平的文件路径拆成 目录 + 文件名,便于展示层级 */
export function splitPath(path: string): { dir: string; name: string } {
const idx = path.lastIndexOf('/')
if (idx < 0) return { dir: '', name: path }
return { dir: path.slice(0, idx + 1), name: path.slice(idx + 1) }
}
/** 可以尝试在线预览的图片扩展名 */
export const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif']
/** 与服务端 preview 接口保持一致:超过这个大小就不做在线预览 */
export const MAX_IMAGE_PREVIEW_BYTES = 12 * 1024 * 1024
export function isImagePath(path: string): boolean {
const idx = path.lastIndexOf('.')
if (idx < 0) return false
return IMAGE_EXTENSIONS.includes(path.slice(idx + 1).toLowerCase())
}
export async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
return false
}
}
+232
View File
@@ -0,0 +1,232 @@
// 中文(源文) → 英文 映射。
// 站点模板以中文书写,默认显示英文;切换到中文时回退到源文(即中文)。
// 键已做空格归一化(连续空白合并为单个空格),便于整句匹配。
export const zh2en: Record<string, string> = {
// ===== 顶栏 / 布局 =====
'预览台 / 投递器': 'Preview / Handoff',
'搜索': 'Search',
'任务': 'Tasks',
'历史': 'History',
'设置': 'Settings',
'MONGO 未连接': 'MONGO disconnected',
'磁力解析就绪': 'Magnet parsing ready',
'磁力解析不可用': 'Magnet parsing unavailable',
'本地自托管 · 单进程 Nuxt 单体应用': 'Self-hosted · single-process Nuxt app',
'种子只做解析与预览,文件内容由你自己的下载器拉取': 'Torrents are only parsed and previewed; file contents are fetched by your own download client',
// ===== 种子详情页 [infohash] =====
'← 返回搜索': '← Back to search',
'来自 MongoDB 缓存': 'From MongoDB cache',
'来自链接参数': 'From link parameter',
'拿不到这个种子的信息': "Can't fetch info for this torrent",
'如果你是直接从外部链接进来的,请带上 magnet 参数,例如': 'If you came from an external link, include a magnet parameter, e.g.',
'回到搜索页': 'Back to search',
'加载中…': 'Loading…',
// ===== 首页 index =====
'SEARCH · PARSE · HANDOFF': 'SEARCH · PARSE · HANDOFF',
'搜种子、看清里面有什么,再丢给本机下载器': 'Search torrents, see what is inside, then hand off to your local downloader',
'qBittorrent / Transmission / aria2,由它们完成实际下载。': 'qBittorrent / Transmission / aria2, which do the actual downloading.',
'经代理访问': 'Via proxy',
'直连访问': 'Direct connection',
'请输入关键词': 'Please enter a keyword',
'没有搜到结果': 'No results found',
'可以换个关键词,或检查代理设置': 'Try another keyword or check proxy settings',
'没有结果。换个关键词,或在「设置」里配置代理后再试。': 'No results. Try another keyword, or configure a proxy in Settings.',
'上一页': 'Previous',
'下一页': 'Next',
'预览': 'Preview',
'输入关键词开始搜索': 'Enter a keyword to search',
'关键词搜索': 'Keyword search',
'上传 .torrent': 'Upload .torrent',
'磁力链': 'Magnet link',
'把 .torrent 文件拖到这里,或点击选择': 'Drag a .torrent file here, or click to choose',
'文件只在服务端解析出文件清单,不会上传到任何第三方': 'Files are parsed into a file list on the server only; nothing is uploaded to any third party',
'解析中…': 'Resolving…',
'磁力链需要联网从 DHT / tracker 拉取元数据,才能看到文件清单': 'A magnet link needs to fetch metadata from DHT / trackers to show the file list',
'解析磁力链': 'Resolve magnet',
'名称': 'Name',
'来源': 'Source',
'大小': 'Size',
'文件': 'Files',
'做种': 'Seeders',
'下载': 'Leechers',
'发布': 'Added',
'操作': 'Actions',
'搜索源': 'Search sources',
// ===== 下载任务 downloads =====
'实时下载状态': 'Live download status',
'直接从你配置的 qBittorrent / Transmission / aria2 客户端拉取的实时数据。': 'Real-time data pulled directly from your configured qBittorrent / Transmission / aria2 clients.',
'活跃任务总数': 'Total active tasks',
'在线下载器': 'Online clients',
'刷新频率': 'Refresh rate',
'每 5 秒自动同步': 'Auto-sync every 5s',
'手动刷新': 'Manual refresh',
'同步中…': 'Syncing…',
'任务名称': 'Task name',
'进度': 'Progress',
'下载速度': 'DL speed',
'剩余时间': 'ETA',
'状态': 'Status',
'暂无任务': 'No tasks',
'还没有配置任何下载器,或者所有下载器都已禁用。': 'No download clients configured, or all are disabled.',
'去设置': 'Go to settings',
'下载中': 'Downloading',
'做种中': 'Seeding',
'已暂停': 'Paused',
'错误': 'Error',
'排队中': 'Queued',
'校验中': 'Checking',
'已完成': 'Completed',
'未知': 'Unknown',
'在线': 'online',
'离线': 'offline',
// ===== 历史 history =====
'搜索历史与种子库': 'Search history & torrent library',
'每次搜索都会落到 MongoDB 的 searches 集合;搜到和解析过的种子按 infoHash 去重存在 torrents 集合。': "Every search is stored in MongoDB's searches collection; found and parsed torrents are deduplicated by infoHash in the torrents collection.",
'MongoDB 未连接,历史记录不可用。启动本地 MongoDB 后刷新本页即可。': 'MongoDB not connected, history unavailable. Start local MongoDB and refresh this page.',
'搜索总次数': 'Total searches',
'库中种子数': 'Torrents in library',
'高频关键词': 'Top keywords',
'未连接': 'Disconnected',
'正常': 'OK',
'点击直接再搜一次': 'Click to search again',
'刷新': 'Refresh',
'刷新中…': 'Refreshing…',
'清空': 'Clear all',
'关键词': 'Keyword',
'结果数': 'Results',
'耗时': 'Time',
'时间': 'Time',
'删除': 'Delete',
'还没有搜索记录': 'No search history yet',
'加载中…': 'Loading…',
'更新': 'Updated',
'打开': 'Open',
'库里还没有种子': 'No torrents in library yet',
'已删除该条记录': 'Record deleted',
'删除失败': 'Delete failed',
'确定清空全部搜索历史?': 'Clear all search history?',
'清空失败': 'Clear failed',
// ===== 种子详情 TorrentDetail =====
'未知来源': 'Unknown source',
'磁力搜索': 'Magnet Search',
'本地上传': 'Local upload',
'数据库': 'Database',
'文件清单已解析': 'File list resolved',
'磁力链已复制到剪贴板': 'Magnet link copied to clipboard',
'复制失败,请手动选中复制': 'Copy failed, please select and copy manually',
'解析失败': 'Resolve failed',
'总大小': 'Total size',
'文件数': 'File count',
'做种 / 下载': 'Seeders / Leechers',
'发布时间': 'Published',
'展开': 'Expand',
'收起': 'Collapse',
'备注': 'Comment',
'复制磁力链': 'Copy magnet',
'下载 .torrent': 'Download .torrent',
'打开来源页面': 'Open source page',
// ===== SendPanel =====
'SEND TO LOCAL CLIENT': 'SEND TO LOCAL CLIENT',
'探测中…': 'Probing…',
'重新探测': 'Re-probe',
'配置': 'Configure',
'还没有配置任何下载器。去「设置」里填 qBittorrent / Transmission / aria2 的地址和账号密码。': 'No download client configured. Go to Settings and provide qBittorrent / Transmission / aria2 address and credentials.',
'目标下载器': 'Target client',
'下载目录(留空用客户端默认)': 'Download dir (blank = client default)',
'该下载器当前不可用': 'This client is currently unavailable',
'投递到本机下载器': 'Send to local client',
'投递中…': 'Sending…',
'种子交给本机客户端下载,服务器不中转文件内容': 'The torrent is downloaded by your local client; the server does not proxy file contents',
'读取下载器配置失败': 'Failed to read client config',
'投递成功': 'Delivered',
'投递失败': 'Delivery failed',
// ===== FileTable =====
'正在通过 DHT / tracker 获取种子元数据,通常几秒到几十秒…': 'Fetching torrent metadata via DHT / tracker, usually takes seconds to tens of seconds…',
'这个种子只有 infoHash,还没有文件清单。可以点击下面的按钮在线拉取元数据(需要本机能连上 DHT / tracker)。': 'This torrent only has an infoHash and no file list yet. Click the button below to fetch metadata online (requires the server to reach DHT / trackers).',
'在线解析文件清单': 'Resolve file list online',
'FILE LIST': 'FILE LIST',
'过滤文件名…': 'Filter filenames…',
'按大小': 'By size',
'按名称': 'By name',
'路径': 'Path',
'占比': 'Ratio',
'过大': 'Too large',
'结果较多,表格只展示前 500 条,可用过滤框缩小范围': 'Too many results; table shows the first 500 rows. Use the filter to narrow down',
// ===== ImagePreview =====
'IMAGE': 'IMAGE',
'关闭': 'Close',
'正在从做种节点拉取这张图片,取决于对方的上传速度…': 'Fetching this image from seeding peers, depending on their upload speed…',
'重试': 'Retry',
'直接打开图片地址': 'Open image URL directly',
'上一张': 'Previous',
'下一张': 'Next',
// ===== 设置 settings =====
'搜索源与本机下载器': 'Search sources & local clients',
'所有配置写在 MongoDB 的 settings 集合里(没连库时回落到 .env)。下载器地址默认指向本机,如果服务部署在别的机器上,改成本机可访问的地址即可。': "All config is stored in MongoDB's settings collection (falls back to .env when the DB is down). Downloader addresses point to localhost by default; change them to whatever address is reachable.",
'保存中…': 'Saving…',
'保存全部配置': 'Save all settings',
'重新加载': 'Reload',
'MongoDB 未连接,配置只能在本次运行内生效': 'MongoDB not connected; config only takes effect for this session',
'启用的搜索源': 'Enabled search sources',
'HTTP 代理(搜索源用)': 'HTTP proxy (for search sources)',
'搜索超时(毫秒)': 'Search timeout (ms)',
'元数据解析超时(毫秒)': 'Metadata parse timeout (ms)',
'公开磁力站在部分网络环境下不可直连,配好代理后「设置 → 保存」再回搜索页重试即可。代理只作用于搜索源请求,不影响下载器通信。': 'Public magnet sites may be unreachable on some networks. After setting a proxy, Save in Settings then retry from the search page. The proxy only affects search requests, not downloader communication.',
'LOCAL BT CLIENTS': 'LOCAL BT CLIENTS',
'新增下载器': 'Add client',
'启用': 'Enable',
'测试连接': 'Test connection',
'类型': 'Type',
'主机': 'Host',
'端口': 'Port',
'RPC 路径(可空)': 'RPC path (optional)',
'默认': 'default',
'用户名': 'Username',
'(aria2 用不到)': '(not used by aria2)',
'RPC 密钥 --rpc-secret': 'RPC secret --rpc-secret',
'密码': 'Password',
'aria2 的 --rpc-secret 值': "aria2's --rpc-secret value",
'默认下载目录': 'Default download dir',
'留空用客户端默认': 'Blank = client default',
'使用 HTTPS': 'Use HTTPS',
'可达': 'Reachable',
'qBittorrent:开启「工具 → 选项 → Web UI」,默认端口 8080;首次访问会让客户端把本机 IP 加入白名单。': 'qBittorrent: enable "Tools → Options → Web UI", default port 8080; first visit whitelists your local IP in the client.',
'Transmission:开启远程控制,默认端口 9091,RPC 路径 /transmission/rpc;没设用户名密码就留空。': 'Transmission: enable remote control, default port 9091, RPC path /transmission/rpc; leave blank if no username/password.',
'aria2:启动时加 --enable-rpc --rpc-listen-all=true --rpc-secret=你的密钥,把密钥填到「RPC 密钥」框。': 'aria2: start with --enable-rpc --rpc-listen-all=true --rpc-secret=YOUR_SECRET, and put the secret in the RPC secret field.',
'还没有下载器,点右上角「新增下载器」': 'No downloaders yet. Click "Add client" at the top right.',
'HANDOFF LOG': 'HANDOFF LOG',
'最近 25 条投递记录': 'Last 25 handoff records',
'下载器': 'Client',
'目录': 'Dir',
'结果': 'Result',
'成功': 'Success',
'失败': 'Failed',
'还没有投递记录': 'No handoff records yet',
'RUNTIME': 'RUNTIME',
'已连接': 'Connected',
'磁力解析': 'Magnet parsing',
'webtorrent 可用,可通过 DHT/tracker 取文件清单': 'webtorrent available; can fetch file list via DHT/tracker',
'webtorrent 不可用,磁力链只能显示 infoHash': 'webtorrent unavailable; magnet links can only show infoHash',
// ===== 输入框占位符 =====
'输入片名 / 剧名 / 关键词,回车搜索': 'Movie / show / keyword, press Enter to search',
'http://127.0.0.1:7890 / 留空直连': 'http://127.0.0.1:7890 / blank for direct'
}
export const en2zh: Record<string, string> = Object.fromEntries(
Object.entries(zh2en).map(([zh, en]) => [en, zh])
)
/** 归一化:折叠所有连续空白为单个空格,并去掉首尾空白 */
export function normalizeWs(s: string): string {
return s.trim().replace(/\s+/g, ' ')
}
+65
View File
@@ -0,0 +1,65 @@
import { en2zh, normalizeWs, zh2en } from './i18n-dict'
export type Locale = 'en' | 'zh'
const STORAGE_KEY = 'dsl-locale'
function readInitialLocale(): Locale {
if (import.meta.client) {
const v = localStorage.getItem(STORAGE_KEY)
if (v === 'en' || v === 'zh') return v
}
return 'en' // 默认英文
}
// 模块级响应式语言状态(默认英文)
export const locale = ref<Locale>(readInitialLocale())
export function setLocale(next: Locale) {
locale.value = next
if (import.meta.client) {
localStorage.setItem(STORAGE_KEY, next)
document.documentElement.lang = next === 'zh' ? 'zh-CN' : 'en'
translateDocument()
}
}
/** 把一段中文(或英文)文本按当前语言翻译;找不到映射时原样返回 */
export function translateText(input: string): string {
if (!input) return input
const key = normalizeWs(input)
const map = locale.value === 'zh' ? en2zh : zh2en
return map[key] ?? input
}
/** 遍历 root 下的文本节点与常见属性,按当前语言就地翻译 */
export function walkAndTranslate(root: Node = document.body) {
if (!root || !import.meta.client) return
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
const fixes: { node: Text; text: string }[] = []
let n: Node | null
while ((n = walker.nextNode())) {
const t = (n as Text).textContent || ''
if (!t.trim()) continue
const tr = translateText(t)
if (tr !== t) fixes.push({ node: n as Text, text: tr })
}
for (const f of fixes) f.node.textContent = f.text
// 翻译属性(title / placeholder / alt)
if (root instanceof HTMLElement) {
root.querySelectorAll<HTMLElement>('*').forEach((el) => {
for (const attr of ['title', 'placeholder', 'alt']) {
const v = el.getAttribute(attr)
if (!v) continue
const tr = translateText(v)
if (tr !== v) el.setAttribute(attr, tr)
}
})
}
}
export function translateDocument() {
if (import.meta.client) walkAndTranslate(document.body)
}