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