250 lines
14 KiB
Vue
250 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import { Activity, Database, LoaderCircle, Moon, Search, Server, Sun, X } from '@lucide/vue'
|
|
|
|
import SearchResultCard from '@/components/SearchResultCard.vue'
|
|
import TorrentDetailDialog from '@/components/TorrentDetailDialog.vue'
|
|
import { Button } from '@/components/ui/button'
|
|
import { AppPagination } from '@/components/ui/pagination'
|
|
import { AppSelect } from '@/components/ui/select'
|
|
import { getStats, getTorrent, getVariants, search } from '@/lib/api'
|
|
import type { SearchHit, SearchPage, SearchSort, ServiceStats, TorrentDetail, ContentVariants } from '@/types/api'
|
|
|
|
const query = ref('')
|
|
const submittedQuery = ref('')
|
|
const regexMode = ref(false)
|
|
const sort = ref<SearchSort>('latest')
|
|
const page = ref<SearchPage | null>(null)
|
|
const offset = ref(0)
|
|
const supportedResultPageSizes = [10, 20, 50, 100]
|
|
const savedResultPageSize = Number(localStorage.getItem('dht-search-result-page-size'))
|
|
const limit = ref(supportedResultPageSizes.includes(savedResultPageSize) ? savedResultPageSize : 20)
|
|
const loading = ref(true)
|
|
const error = ref('')
|
|
const stats = ref<ServiceStats | null>(null)
|
|
const statsOpen = ref(false)
|
|
const darkMode = ref(false)
|
|
const detailOpen = ref(false)
|
|
const detailLoading = ref(false)
|
|
const detailFilesLoading = ref(false)
|
|
const detailFilesError = ref('')
|
|
const detailError = ref('')
|
|
const selectedHash = ref('')
|
|
const detail = ref<TorrentDetail | null>(null)
|
|
const variants = ref<ContentVariants | null>(null)
|
|
const supportedFilePageSizes = [25, 50, 100, 200]
|
|
const savedFilePageSize = Number(localStorage.getItem('dht-search-file-page-size'))
|
|
const detailFilePageSize = ref(supportedFilePageSizes.includes(savedFilePageSize) ? savedFilePageSize : 100)
|
|
let searchController: AbortController | null = null
|
|
let detailController: AbortController | null = null
|
|
let filesController: AbortController | null = null
|
|
let statsTimer: number | null = null
|
|
let statsRequestActive = false
|
|
|
|
const pageNumber = computed(() => Math.floor((page.value?.offset ?? 0) / limit.value) + 1)
|
|
const paginationTotal = computed(() => Math.min(page.value?.total ?? 0, 10_000 + limit.value))
|
|
const resultPageSizeOptions = [
|
|
{ value: '10', label: '10 条/页' },
|
|
{ value: '20', label: '20 条/页' },
|
|
{ value: '50', label: '50 条/页' },
|
|
{ value: '100', label: '100 条/页' },
|
|
]
|
|
const sortOptions: ReadonlyArray<{ value: SearchSort; label: string }> = [
|
|
{ value: 'relevance', label: '相关度' },
|
|
{ value: 'latest', label: '最近收录' },
|
|
{ value: 'oldest', label: '最早收录' },
|
|
{ value: 'heat', label: '热度' },
|
|
{ value: 'discoveries', label: '发现次数' },
|
|
{ value: 'size_desc', label: '大小降序' },
|
|
{ value: 'size_asc', label: '大小升序' },
|
|
]
|
|
|
|
function updateBrowserUrl() {
|
|
const params = new URLSearchParams()
|
|
if (submittedQuery.value) params.set('q', submittedQuery.value)
|
|
if (sort.value !== 'latest') params.set('sort', sort.value)
|
|
if (regexMode.value) params.set('regex', 'true')
|
|
if (offset.value) params.set('offset', String(offset.value))
|
|
history.replaceState(null, '', params.size ? `?${params}` : location.pathname)
|
|
}
|
|
|
|
async function loadSearch() {
|
|
searchController?.abort()
|
|
searchController = new AbortController()
|
|
loading.value = true
|
|
error.value = ''
|
|
updateBrowserUrl()
|
|
try {
|
|
page.value = await search({ query: submittedQuery.value, offset: offset.value, limit: limit.value, sort: sort.value, regex: regexMode.value }, searchController.signal)
|
|
} catch (cause) {
|
|
if ((cause as Error).name !== 'AbortError') error.value = cause instanceof Error ? cause.message : '搜索失败'
|
|
} finally {
|
|
if (!searchController.signal.aborted) loading.value = false
|
|
}
|
|
}
|
|
|
|
function submitSearch() {
|
|
const hadQuery = submittedQuery.value.trim() !== ''
|
|
submittedQuery.value = query.value
|
|
if (!hadQuery && submittedQuery.value.trim() && sort.value === 'latest') sort.value = 'relevance'
|
|
offset.value = 0
|
|
void loadSearch()
|
|
}
|
|
function changePage(pageNumber: number) { offset.value = Math.max(0, (pageNumber - 1) * limit.value); void loadSearch(); window.scrollTo({ top: 0, behavior: 'smooth' }) }
|
|
function changeResultPageSize(value: string) {
|
|
const pageSize = Number(value)
|
|
if (!supportedResultPageSizes.includes(pageSize) || pageSize === limit.value) return
|
|
limit.value = pageSize
|
|
localStorage.setItem('dht-search-result-page-size', String(pageSize))
|
|
offset.value = 0
|
|
void loadSearch()
|
|
}
|
|
function changeSort(value: string) { sort.value = value as SearchSort; offset.value = 0; void loadSearch() }
|
|
function toggleRegex() { regexMode.value = !regexMode.value; offset.value = 0 }
|
|
|
|
function toggleTheme() {
|
|
darkMode.value = !darkMode.value
|
|
document.documentElement.classList.toggle('dark', darkMode.value)
|
|
localStorage.setItem('dht-search-theme', darkMode.value ? 'dark' : 'light')
|
|
}
|
|
|
|
function closeStatsOnOutsideClick(event: PointerEvent) {
|
|
if (statsOpen.value && event.target instanceof Element && !event.target.closest('[data-stats-panel]')) {
|
|
statsOpen.value = false
|
|
}
|
|
}
|
|
|
|
async function openDetail(hit: SearchHit) {
|
|
selectedHash.value = hit.info_hash
|
|
detailOpen.value = true
|
|
detailLoading.value = true
|
|
detailError.value = ''
|
|
detailFilesError.value = ''
|
|
detail.value = null
|
|
variants.value = null
|
|
detailController?.abort()
|
|
detailController = new AbortController()
|
|
try {
|
|
const [torrent, contentVariants] = await Promise.all([getTorrent(hit.info_hash, 0, detailFilePageSize.value, detailController.signal), getVariants(hit.content_key, detailController.signal)])
|
|
detail.value = torrent
|
|
variants.value = contentVariants
|
|
} catch (cause) {
|
|
if ((cause as Error).name !== 'AbortError') detailError.value = cause instanceof Error ? cause.message : '详情加载失败'
|
|
} finally {
|
|
if (!detailController.signal.aborted) detailLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function loadFilePage(fileOffset: number) {
|
|
if (!detail.value || detailFilesLoading.value) return
|
|
filesController?.abort()
|
|
filesController = new AbortController()
|
|
detailFilesLoading.value = true
|
|
detailFilesError.value = ''
|
|
const infoHash = detail.value.info_hash
|
|
try {
|
|
const next = await getTorrent(infoHash, fileOffset, detailFilePageSize.value, filesController.signal)
|
|
if (selectedHash.value === infoHash) detail.value = next
|
|
} catch (cause) {
|
|
if ((cause as Error).name !== 'AbortError') detailFilesError.value = cause instanceof Error ? cause.message : '文件列表加载失败'
|
|
} finally {
|
|
if (!filesController.signal.aborted) detailFilesLoading.value = false
|
|
}
|
|
}
|
|
|
|
function changeFilePageSize(fileLimit: number) {
|
|
if (!supportedFilePageSizes.includes(fileLimit) || fileLimit === detailFilePageSize.value) return
|
|
detailFilePageSize.value = fileLimit
|
|
localStorage.setItem('dht-search-file-page-size', String(fileLimit))
|
|
void loadFilePage(0)
|
|
}
|
|
|
|
function closeDetail() { detailController?.abort(); filesController?.abort(); detailOpen.value = false }
|
|
function retryDetail() { const hit = page.value?.hits.find((item) => item.info_hash === selectedHash.value); if (hit) void openDetail(hit) }
|
|
|
|
async function loadStats() {
|
|
if (statsRequestActive) return
|
|
statsRequestActive = true
|
|
try { stats.value = await getStats() } catch { stats.value = null } finally { statsRequestActive = false }
|
|
}
|
|
|
|
watch(statsOpen, (open) => {
|
|
if (statsTimer !== null) {
|
|
window.clearInterval(statsTimer)
|
|
statsTimer = null
|
|
}
|
|
if (open) {
|
|
void loadStats()
|
|
statsTimer = window.setInterval(() => void loadStats(), 1_000)
|
|
}
|
|
})
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('pointerdown', closeStatsOnOutsideClick)
|
|
darkMode.value = document.documentElement.classList.contains('dark')
|
|
const params = new URLSearchParams(location.search)
|
|
query.value = params.get('q') ?? ''
|
|
submittedQuery.value = query.value
|
|
regexMode.value = params.get('regex') === 'true'
|
|
const initialSort = params.get('sort') as SearchSort | null
|
|
if (initialSort && ['relevance', 'latest', 'oldest', 'heat', 'size_desc', 'size_asc', 'discoveries'].includes(initialSort)) sort.value = initialSort
|
|
else if (query.value.trim()) sort.value = 'relevance'
|
|
offset.value = Math.min(10_000, Math.max(0, Number(params.get('offset')) || 0))
|
|
void loadSearch()
|
|
})
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('pointerdown', closeStatsOnOutsideClick)
|
|
searchController?.abort()
|
|
detailController?.abort()
|
|
filesController?.abort()
|
|
if (statsTimer !== null) window.clearInterval(statsTimer)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="min-h-svh bg-background text-foreground">
|
|
<header class="sticky top-0 z-30 border-b bg-background/90 backdrop-blur-xl">
|
|
<div class="mx-auto flex h-15 max-w-6xl items-center gap-3 px-4 sm:px-6">
|
|
<div class="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground"><Database class="size-4" aria-hidden="true" /></div>
|
|
<p class="text-sm font-semibold tracking-tight">DHT Search</p>
|
|
<Button class="ml-auto" size="icon" variant="ghost" :aria-label="darkMode ? '切换到浅色主题' : '切换到深色主题'" :title="darkMode ? '浅色主题' : '深色主题'" @click="toggleTheme"><Sun v-if="darkMode" /><Moon v-else /></Button>
|
|
<div class="relative" data-stats-panel>
|
|
<Button size="icon" variant="ghost" aria-label="运行状态" title="运行状态" @click="statsOpen = !statsOpen"><span class="relative"><Activity class="size-4" /><i class="absolute -right-0.5 -top-0.5 size-1.5 rounded-full" :class="stats ? 'bg-emerald-500' : 'bg-muted-foreground'" /></span></Button>
|
|
<div v-if="statsOpen" class="absolute right-0 top-11 w-72 rounded-xl border bg-popover p-4 text-popover-foreground shadow-xl">
|
|
<div class="mb-3 flex items-center"><p class="text-sm font-semibold">服务运行状态</p><button class="ml-auto" aria-label="关闭状态面板" @click="statsOpen = false"><X class="size-4" /></button></div>
|
|
<div v-if="stats" class="grid grid-cols-2 gap-3 text-xs"><div class="status-cell"><span>DHT 节点</span><b>{{ stats.nodes.toLocaleString() }}</b></div><div class="status-cell"><span>已索引内容</span><b>{{ stats.indexed_documents.toLocaleString() }}</b></div><div class="status-cell"><span>获取成功</span><b>{{ stats.metadata_ok.toLocaleString() }}</b></div><div class="status-cell"><span>下载中</span><b>{{ stats.metadata_in_flight }}</b></div><div class="status-cell"><span>新收录</span><b>{{ stats.persistence_inserted.toLocaleString() }}</b></div><div class="status-cell"><span>验证成功</span><b>{{ stats.verification_succeeded.toLocaleString() }}</b></div></div>
|
|
<p v-else class="py-4 text-center text-xs text-muted-foreground">无法获取服务状态</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="mx-auto max-w-6xl px-4 py-7 sm:px-6 sm:py-10">
|
|
<section>
|
|
<form class="overflow-hidden rounded-xl border bg-card shadow-sm focus-within:border-foreground/20" @submit.prevent="submitSearch">
|
|
<div class="flex flex-wrap items-center gap-2 p-2">
|
|
<Search class="ml-2.5 size-4 shrink-0 text-muted-foreground" />
|
|
<label class="sr-only" for="search-query">搜索关键词</label>
|
|
<input id="search-query" v-model="query" class="h-9 min-w-48 flex-1 bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground" :maxlength="regexMode ? 256 : 512" :placeholder="regexMode ? '输入正则表达式' : '输入名称、文件名或 40 位 infohash'" type="search" />
|
|
<Button size="sm" type="button" :variant="regexMode ? 'secondary' : 'ghost'" :aria-pressed="regexMode" title="正则搜索" @click="toggleRegex"><span class="font-mono">.*</span><span class="hidden sm:inline">正则</span></Button>
|
|
<AppSelect :model-value="sort" :options="sortOptions" label="结果排序" @update:model-value="changeSort" />
|
|
<Button type="submit">搜索</Button>
|
|
</div>
|
|
</form>
|
|
|
|
<div v-if="loading" class="flex min-h-72 items-center justify-center text-sm text-muted-foreground"><LoaderCircle class="mr-2 size-5 animate-spin" />正在搜索</div>
|
|
<div v-else-if="error" class="mt-4 flex min-h-72 flex-col items-center justify-center gap-4 rounded-xl border border-dashed text-center"><Server class="size-8 text-muted-foreground" /><div><p class="font-medium">无法完成搜索</p><p class="mt-1 text-sm text-muted-foreground">{{ error }}</p></div><Button variant="outline" @click="loadSearch">重试</Button></div>
|
|
<div v-else-if="page?.hits.length" class="mt-4 space-y-3"><SearchResultCard v-for="hit in page.hits" :key="hit.content_key" :hit="hit" @select="openDetail" /></div>
|
|
<div v-else class="mt-4 flex min-h-72 flex-col items-center justify-center rounded-xl border border-dashed text-center"><Search class="mb-3 size-8 text-muted-foreground" /><p class="font-medium">没有找到匹配内容</p><p class="mt-1 text-sm text-muted-foreground">尝试其他关键词或正则表达式</p></div>
|
|
|
|
<div v-if="page && page.total > 0" class="mt-6 flex flex-wrap items-center justify-center gap-2">
|
|
<AppSelect :model-value="String(limit)" :options="resultPageSizeOptions" label="每页搜索结果数量" @update:model-value="changeResultPageSize" />
|
|
<AppPagination v-if="paginationTotal > limit" :page="pageNumber" :page-size="limit" :total="paginationTotal" @update:page="changePage" />
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<TorrentDetailDialog :detail="detail" :error="detailError" :files-error="detailFilesError" :files-loading="detailFilesLoading" :loading="detailLoading" :open="detailOpen" :variants="variants" @close="closeDetail" @file-page="loadFilePage" @file-page-size="changeFilePageSize" @retry="retryDetail" />
|
|
</div>
|
|
</template>
|