Files
bt/app/pages/index.vue
T

272 lines
9.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>