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