feat: DSL 磁力聚合 — 搜索/解析/预览/投递 + cilisousuo 源 + 中英双语 + Docker 部署
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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/<infoHash>?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>
|
||||
Reference in New Issue
Block a user