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
+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>