feat: 完成 Web 搜索界面与静态托管

This commit is contained in:
chuan
2026-08-07 01:21:18 +08:00
parent 0d852a9794
commit d992d305b9
35 changed files with 5062 additions and 16 deletions
+179
View File
@@ -0,0 +1,179 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Activity, ChevronLeft, ChevronRight, Database, Filter, LoaderCircle, Moon, Search, Server, Sun, X } from '@lucide/vue'
import SearchFiltersPanel from '@/components/SearchFilters.vue'
import SearchResultCard from '@/components/SearchResultCard.vue'
import TorrentDetailDialog from '@/components/TorrentDetailDialog.vue'
import { Button } from '@/components/ui/button'
import { getStats, getTorrent, getVariants, search } from '@/lib/api'
import type { SearchFilters, SearchHit, SearchPage, SearchSort, ServiceStats, TorrentDetail, ContentVariants } from '@/types/api'
const emptyFilters = (): SearchFilters => ({ minSizeMiB: '', maxSizeMiB: '', extension: '', minFiles: '', maxFiles: '', firstSeenAfter: '', firstSeenBefore: '', lastSeenAfter: '', lastSeenBefore: '', availability: '', heat: '' })
const query = ref('')
const submittedQuery = ref('')
const filters = ref(emptyFilters())
const filtersOpen = ref(false)
const sort = ref<SearchSort>('latest')
const page = ref<SearchPage | null>(null)
const offset = ref(0)
const limit = 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 detailError = ref('')
const selectedHash = ref('')
const detail = ref<TorrentDetail | null>(null)
const variants = ref<ContentVariants | null>(null)
let searchController: AbortController | null = null
let detailController: AbortController | null = null
let statsTimer: number | null = null
const pageNumber = computed(() => Math.floor((page.value?.offset ?? 0) / limit) + 1)
const pageCount = computed(() => Math.max(1, Math.ceil((page.value?.total ?? 0) / limit)))
const filterCount = computed(() => Object.values(filters.value).filter(Boolean).length)
function updateBrowserUrl() {
const params = new URLSearchParams()
if (submittedQuery.value) params.set('q', submittedQuery.value)
if (sort.value !== 'latest') params.set('sort', sort.value)
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, sort: sort.value, filters: filters.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(nextOffset: number) { offset.value = Math.max(0, nextOffset); void loadSearch(); window.scrollTo({ top: 0, behavior: 'smooth' }) }
function changeSort(event: Event) { sort.value = (event.target as HTMLSelectElement).value as SearchSort; offset.value = 0; void loadSearch() }
function resetFilters() { filters.value = emptyFilters(); offset.value = 0; void loadSearch() }
function toggleTheme() {
darkMode.value = !darkMode.value
document.documentElement.classList.toggle('dark', darkMode.value)
localStorage.setItem('dht-search-theme', darkMode.value ? 'dark' : 'light')
}
async function openDetail(hit: SearchHit) {
selectedHash.value = hit.info_hash
detailOpen.value = true
detailLoading.value = true
detailError.value = ''
detail.value = null
variants.value = null
detailController?.abort()
detailController = new AbortController()
try {
const [torrent, contentVariants] = await Promise.all([getTorrent(hit.info_hash, 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
}
}
function closeDetail() { detailController?.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() {
try { stats.value = await getStats() } catch { stats.value = null }
}
watch(statsOpen, (open) => {
if (statsTimer !== null) {
window.clearInterval(statsTimer)
statsTimer = null
}
if (open) {
void loadStats()
statsTimer = window.setInterval(() => void loadStats(), 5_000)
}
})
onMounted(() => {
darkMode.value = document.documentElement.classList.contains('dark')
const params = new URLSearchParams(location.search)
query.value = params.get('q') ?? ''
submittedQuery.value = query.value
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(() => {
searchController?.abort()
detailController?.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">
<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="512" placeholder="输入名称、文件名或 40 位 infohash" type="search" />
<Button size="sm" type="button" :variant="filtersOpen ? 'secondary' : 'ghost'" @click="filtersOpen = !filtersOpen"><Filter />筛选<span v-if="filterCount" class="rounded-full bg-primary px-1.5 text-[10px] text-primary-foreground">{{ filterCount }}</span></Button>
<select aria-label="结果排序" class="field-input h-9 w-auto py-0" :value="sort" @change="changeSort"><option value="relevance">相关度</option><option value="latest">最近收录</option><option value="oldest">最早收录</option><option value="heat">热度</option><option value="discoveries">发现次数</option><option value="size_desc">大小降序</option><option value="size_asc">大小升序</option></select>
<Button type="submit">搜索</Button>
</div>
<SearchFiltersPanel v-if="filtersOpen" v-model="filters" @reset="resetFilters" />
</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>
<nav v-if="page && page.total > limit" aria-label="搜索结果分页" class="mt-6 flex items-center justify-center gap-3"><Button size="sm" variant="outline" :disabled="offset === 0" @click="changePage(offset - limit)"><ChevronLeft />上一页</Button><span class="min-w-24 text-center text-xs text-muted-foreground">第 {{ pageNumber }} / {{ pageCount }} 页</span><Button size="sm" variant="outline" :disabled="offset + limit >= page.total || offset + limit > 10_000" @click="changePage(offset + limit)">下一页<ChevronRight /></Button></nav>
</section>
</main>
<TorrentDetailDialog :detail="detail" :error="detailError" :loading="detailLoading" :open="detailOpen" :variants="variants" @close="closeDetail" @retry="retryDetail" />
</div>
</template>
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { RotateCcw } from '@lucide/vue'
import { Button } from '@/components/ui/button'
import type { SearchFilters } from '@/types/api'
const props = defineProps<{ modelValue: SearchFilters }>()
const emit = defineEmits<{
'update:modelValue': [value: SearchFilters]
reset: []
}>()
function update<K extends keyof SearchFilters>(key: K, value: SearchFilters[K]) {
emit('update:modelValue', { ...props.modelValue, [key]: value })
}
</script>
<template>
<div class="grid gap-5 border-t px-5 py-5 sm:grid-cols-2 lg:grid-cols-4">
<label class="field-label">
<span>大小范围 MiB</span>
<span class="flex items-center gap-2">
<input class="field-input" min="0" placeholder="最小" type="number" :value="modelValue.minSizeMiB" @input="update('minSizeMiB', ($event.target as HTMLInputElement).value)" />
<span class="text-muted-foreground"></span>
<input class="field-input" min="0" placeholder="最大" type="number" :value="modelValue.maxSizeMiB" @input="update('maxSizeMiB', ($event.target as HTMLInputElement).value)" />
</span>
</label>
<label class="field-label">
<span>文件数量</span>
<span class="flex items-center gap-2">
<input class="field-input" min="0" placeholder="最少" type="number" :value="modelValue.minFiles" @input="update('minFiles', ($event.target as HTMLInputElement).value)" />
<span class="text-muted-foreground"></span>
<input class="field-input" min="0" placeholder="最多" type="number" :value="modelValue.maxFiles" @input="update('maxFiles', ($event.target as HTMLInputElement).value)" />
</span>
</label>
<label class="field-label">
<span>文件扩展名</span>
<input class="field-input" placeholder="例如 mkv" :value="modelValue.extension" @input="update('extension', ($event.target as HTMLInputElement).value)" />
</label>
<label class="field-label">
<span>可用性</span>
<select class="field-input" :value="modelValue.availability" @change="update('availability', ($event.target as HTMLSelectElement).value as SearchFilters['availability'])">
<option value="">全部</option><option value="active">可用</option><option value="unknown">未验证</option><option value="possibly_stale">可能失效</option>
</select>
</label>
<label class="field-label">
<span>热度</span>
<select class="field-input" :value="modelValue.heat" @change="update('heat', ($event.target as HTMLSelectElement).value as SearchFilters['heat'])">
<option value="">全部</option><option value="hot">热门</option><option value="active">活跃</option><option value="normal">一般</option><option value="cold">冷门</option>
</select>
</label>
<label class="field-label">
<span>首次收录起止</span>
<span class="flex gap-2"><input class="field-input min-w-0" type="date" :value="modelValue.firstSeenAfter" @input="update('firstSeenAfter', ($event.target as HTMLInputElement).value)" /><input class="field-input min-w-0" type="date" :value="modelValue.firstSeenBefore" @input="update('firstSeenBefore', ($event.target as HTMLInputElement).value)" /></span>
</label>
<label class="field-label">
<span>最近发现起止</span>
<span class="flex gap-2"><input class="field-input min-w-0" type="date" :value="modelValue.lastSeenAfter" @input="update('lastSeenAfter', ($event.target as HTMLInputElement).value)" /><input class="field-input min-w-0" type="date" :value="modelValue.lastSeenBefore" @input="update('lastSeenBefore', ($event.target as HTMLInputElement).value)" /></span>
</label>
<div class="flex items-end">
<Button class="w-full" size="sm" type="button" variant="outline" @click="$emit('reset')"><RotateCcw />重置筛选</Button>
</div>
</div>
</template>
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { ChevronRight, Files, Flame, HardDrive, Radio, Repeat2 } from '@lucide/vue'
import { availabilityLabel, formatBytes, heatLabel, relativeDate } from '@/lib/format'
import type { SearchHit } from '@/types/api'
defineProps<{ hit: SearchHit }>()
defineEmits<{ select: [hit: SearchHit] }>()
</script>
<template>
<button class="group w-full rounded-xl border bg-card p-5 text-left shadow-xs transition hover:-translate-y-0.5 hover:border-foreground/20 hover:shadow-md" type="button" @click="$emit('select', hit)">
<div class="flex items-start gap-4">
<div class="min-w-0 flex-1">
<h2 class="truncate text-base font-semibold tracking-tight">{{ hit.name }}</h2>
<p class="mt-1.5 truncate font-mono text-[11px] text-muted-foreground">{{ hit.info_hash }}</p>
</div>
<ChevronRight class="mt-1 size-4 shrink-0 text-muted-foreground transition group-hover:translate-x-0.5 group-hover:text-foreground" />
</div>
<div class="mt-4 flex flex-wrap gap-x-5 gap-y-2 text-xs text-muted-foreground">
<span class="result-meta"><HardDrive />{{ formatBytes(hit.total_size) }}</span>
<span class="result-meta"><Files />{{ hit.file_count.toLocaleString() }} 个文件</span>
<span class="result-meta"><Repeat2 />{{ hit.variant_count }} 个版本</span>
<span class="result-meta"><Flame />{{ heatLabel[hit.heat.level] }} {{ hit.heat.score }}</span>
<span class="result-meta" :class="hit.availability.status === 'active' && 'text-emerald-600'"><Radio />{{ availabilityLabel[hit.availability.status] }}</span>
<span class="ml-auto">最近发现 {{ relativeDate(hit.last_seen) }}</span>
</div>
</button>
</template>
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Check, Copy, Download, File, LoaderCircle, X } from '@lucide/vue'
import { Button } from '@/components/ui/button'
import { availabilityLabel, formatBytes, formatDate, heatLabel } from '@/lib/format'
import type { ContentVariants, TorrentDetail } from '@/types/api'
const props = defineProps<{
open: boolean
detail: TorrentDetail | null
variants: ContentVariants | null
loading: boolean
error: string
}>()
const emit = defineEmits<{ close: []; retry: [] }>()
const copied = ref('')
const totalFileSize = computed(() => props.detail?.files.reduce((sum, file) => sum + file.size, 0) ?? 0)
async function copy(value: string, type: string) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value)
} else {
const input = document.createElement('textarea')
input.value = value
input.style.position = 'fixed'
input.style.opacity = '0'
document.body.appendChild(input)
input.select()
document.execCommand('copy')
input.remove()
}
copied.value = type
window.setTimeout(() => { if (copied.value === type) copied.value = '' }, 1600)
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && props.open) emit('close')
}
watch(() => props.open, (open) => { document.body.style.overflow = open ? 'hidden' : '' })
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown)
document.body.style.overflow = ''
})
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="open" class="fixed inset-0 z-50 bg-black/45 backdrop-blur-[2px]" @click.self="$emit('close')">
<Transition appear name="slide">
<section aria-label="种子详情" aria-modal="true" class="absolute inset-y-0 right-0 flex w-full max-w-2xl flex-col border-l bg-background shadow-2xl" role="dialog">
<header class="flex h-16 shrink-0 items-center border-b px-5">
<span class="font-semibold">种子详情</span>
<Button class="ml-auto" size="icon" variant="ghost" aria-label="关闭详情" @click="$emit('close')"><X /></Button>
</header>
<div v-if="loading" class="flex flex-1 items-center justify-center text-sm text-muted-foreground"><LoaderCircle class="mr-2 size-4 animate-spin" />正在加载详情</div>
<div v-else-if="error" class="flex flex-1 flex-col items-center justify-center gap-4 px-6 text-center"><p class="text-sm text-destructive">{{ error }}</p><Button variant="outline" @click="$emit('retry')">重新加载</Button></div>
<div v-else-if="detail" class="flex-1 overflow-y-auto">
<div class="space-y-5 border-b p-5 sm:p-7">
<div><h2 class="break-words text-xl font-semibold tracking-tight">{{ detail.name }}</h2><p class="mt-2 break-all font-mono text-[11px] leading-5 text-muted-foreground">{{ detail.info_hash }}</p></div>
<div class="flex flex-wrap gap-2">
<Button as-child><a :href="detail.magnet_link"><Download />打开磁力链接</a></Button>
<Button variant="outline" @click="copy(detail.magnet_link, 'magnet')"><Check v-if="copied === 'magnet'" /><Copy v-else />{{ copied === 'magnet' ? '已复制' : '复制磁力链接' }}</Button>
</div>
<dl class="grid grid-cols-2 gap-x-5 gap-y-4 rounded-xl bg-muted/55 p-4 text-sm sm:grid-cols-3">
<div><dt>总大小</dt><dd>{{ formatBytes(detail.total_size) }}</dd></div>
<div><dt>文件数量</dt><dd>{{ detail.files.length.toLocaleString() }}</dd></div>
<div><dt>分片大小</dt><dd>{{ formatBytes(detail.piece_length) }}</dd></div>
<div><dt>热度</dt><dd>{{ heatLabel[detail.heat.level] }} · {{ detail.heat.score }}</dd></div>
<div><dt>可用性</dt><dd>{{ availabilityLabel[detail.availability.status] }}<template v-if="detail.availability.reachable_peers"> · {{ detail.availability.reachable_peers }} Peer</template></dd></div>
<div><dt>发现次数</dt><dd>{{ detail.seen_count.toLocaleString() }}</dd></div>
<div><dt>首次收录</dt><dd>{{ formatDate(detail.first_seen) }}</dd></div>
<div><dt>最近发现</dt><dd>{{ formatDate(detail.last_seen) }}</dd></div>
<div><dt>最近验证</dt><dd>{{ formatDate(detail.availability.last_verified_at) }}</dd></div>
</dl>
</div>
<div class="border-b p-5 sm:p-7">
<div class="mb-3 flex items-center"><h3 class="font-semibold">文件详情</h3><span class="ml-auto text-xs text-muted-foreground">{{ formatBytes(totalFileSize) }}</span></div>
<div class="divide-y rounded-xl border">
<div v-for="file in detail.files" :key="`${file.path}-${file.size}`" class="flex items-center gap-3 px-4 py-3 text-sm"><File class="size-4 shrink-0 text-muted-foreground" /><span class="min-w-0 flex-1 break-all">{{ file.path }}</span><span class="shrink-0 text-xs text-muted-foreground">{{ formatBytes(file.size) }}</span></div>
</div>
</div>
<div v-if="variants && variants.total > 1" class="p-5 sm:p-7">
<h3 class="mb-1 font-semibold">相同内容的其他版本</h3><p class="mb-3 text-xs text-muted-foreground">文件结构一致 infohash 不同</p>
<div class="space-y-2">
<div v-for="variant in variants.variants" :key="variant.info_hash" class="rounded-lg border p-3" :class="variant.info_hash === detail.info_hash && 'bg-muted/50'">
<div class="flex items-center gap-3"><div class="min-w-0 flex-1"><p class="truncate text-sm font-medium">{{ variant.name }}</p><p class="truncate font-mono text-[10px] text-muted-foreground">{{ variant.info_hash }}</p></div><Button size="icon" variant="ghost" aria-label="复制该版本磁力链接" @click="copy(variant.magnet_link, variant.info_hash)"><Check v-if="copied === variant.info_hash" /><Copy v-else /></Button></div>
</div>
</div>
</div>
</div>
</section>
</Transition>
</div>
</Transition>
</Teleport>
</template>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from "."
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "."
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
as: "button",
})
</script>
<template>
<Primitive
data-slot="button"
:data-variant="variant"
:data-size="size"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
+40
View File
@@ -0,0 +1,40 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Button } from "./Button.vue"
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
"xs": "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
"icon": "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
+80
View File
@@ -0,0 +1,80 @@
import type {
ContentVariants,
SearchFilters,
SearchPage,
SearchSort,
ServiceStats,
TorrentDetail,
} from '@/types/api'
interface SearchInput {
query: string
offset: number
limit: number
sort: SearchSort
filters: SearchFilters
}
function optionalNumber(params: URLSearchParams, key: string, value: string, scale = 1) {
if (value.trim() === '') return
const parsed = Number(value)
if (Number.isFinite(parsed) && parsed >= 0) params.set(key, String(Math.round(parsed * scale)))
}
function optionalDate(params: URLSearchParams, key: string, value: string) {
if (!value) return
const timestamp = new Date(value).getTime()
if (Number.isFinite(timestamp)) {
const endOfDay = key.endsWith('_before') ? 86_399 : 0
params.set(key, String(Math.floor(timestamp / 1000) + endOfDay))
}
}
async function request<T>(path: string, signal?: AbortSignal): Promise<T> {
const response = await fetch(path, { headers: { Accept: 'application/json' }, signal })
if (!response.ok) {
let message = `请求失败 (${response.status})`
try {
const body = (await response.json()) as { error?: string }
if (body.error) message = body.error
} catch {
// 保留包含状态码的稳定错误信息
}
throw new Error(message)
}
return response.json() as Promise<T>
}
export function search(input: SearchInput, signal?: AbortSignal): Promise<SearchPage> {
const params = new URLSearchParams({
q: input.query.trim(),
offset: String(input.offset),
limit: String(input.limit),
sort: input.sort,
})
const { filters } = input
optionalNumber(params, 'min_size', filters.minSizeMiB, 1024 * 1024)
optionalNumber(params, 'max_size', filters.maxSizeMiB, 1024 * 1024)
optionalNumber(params, 'min_files', filters.minFiles)
optionalNumber(params, 'max_files', filters.maxFiles)
optionalDate(params, 'first_seen_after', filters.firstSeenAfter)
optionalDate(params, 'first_seen_before', filters.firstSeenBefore)
optionalDate(params, 'last_seen_after', filters.lastSeenAfter)
optionalDate(params, 'last_seen_before', filters.lastSeenBefore)
if (filters.extension.trim()) params.set('extension', filters.extension.trim().replace(/^\./, ''))
if (filters.availability) params.set('availability', filters.availability)
if (filters.heat) params.set('heat', filters.heat)
return request<SearchPage>(`/search?${params}`, signal)
}
export function getTorrent(infoHash: string, signal?: AbortSignal): Promise<TorrentDetail> {
return request<TorrentDetail>(`/torrents/${encodeURIComponent(infoHash)}`, signal)
}
export function getVariants(contentKey: string, signal?: AbortSignal): Promise<ContentVariants> {
return request<ContentVariants>(`/contents/${encodeURIComponent(contentKey)}?limit=100`, signal)
}
export function getStats(signal?: AbortSignal): Promise<ServiceStats> {
return request<ServiceStats>('/stats', signal)
}
+46
View File
@@ -0,0 +1,46 @@
import type { AvailabilityStatus, HeatLevel } from '@/types/api'
const sizeUnits = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), sizeUnits.length - 1)
const value = bytes / 1024 ** unit
return `${value >= 100 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${sizeUnits[unit]}`
}
export function formatDate(timestamp: number | null): string {
if (!timestamp) return '暂无'
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(timestamp * 1000))
}
export function relativeDate(timestamp: number | null): string {
if (!timestamp) return '暂无'
const seconds = timestamp - Date.now() / 1000
const absolute = Math.abs(seconds)
const formatter = new Intl.RelativeTimeFormat('zh-CN', { numeric: 'auto' })
if (absolute < 60) return formatter.format(Math.round(seconds), 'second')
if (absolute < 3600) return formatter.format(Math.round(seconds / 60), 'minute')
if (absolute < 86_400) return formatter.format(Math.round(seconds / 3600), 'hour')
if (absolute < 2_592_000) return formatter.format(Math.round(seconds / 86_400), 'day')
return formatDate(timestamp)
}
export const availabilityLabel: Record<AvailabilityStatus, string> = {
active: '可用',
possibly_stale: '可能失效',
unknown: '未验证',
}
export const heatLabel: Record<HeatLevel, string> = {
hot: '热门',
active: '活跃',
normal: '一般',
cold: '冷门',
}
+7
View File
@@ -0,0 +1,7 @@
import type { ClassValue } from "clsx"
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')
+163
View File
@@ -0,0 +1,163 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply min-w-80 bg-background text-foreground antialiased;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button:not(:disabled) {
cursor: pointer;
}
input[type="search"]::-webkit-search-cancel-button {
display: none;
}
}
@layer components {
.field-label {
@apply flex flex-col gap-2 text-xs font-medium text-muted-foreground;
}
.field-input {
@apply h-9 min-w-0 w-full rounded-md border bg-background px-3 text-sm text-foreground outline-none transition focus:border-foreground/25 focus:ring-3 focus:ring-ring/15;
}
.result-meta {
@apply inline-flex items-center gap-1.5;
}
.result-meta svg {
@apply size-3.5;
}
.status-cell {
@apply flex flex-col gap-1 rounded-lg bg-muted/60 p-3;
}
.status-cell span {
@apply text-muted-foreground;
}
.status-cell b {
@apply text-sm font-semibold;
}
dl dt {
@apply text-xs text-muted-foreground;
}
dl dd {
@apply mt-1 font-medium;
}
}
.fade-enter-active, .fade-leave-active { transition: opacity 180ms ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.slide-enter-active, .slide-leave-active { transition: transform 220ms ease; }
.slide-enter-from, .slide-leave-to { transform: translateX(100%); }
+120
View File
@@ -0,0 +1,120 @@
export type AvailabilityStatus = 'unknown' | 'active' | 'possibly_stale'
export type HeatLevel = 'hot' | 'active' | 'normal' | 'cold'
export type SearchSort =
| 'relevance'
| 'latest'
| 'oldest'
| 'heat'
| 'size_desc'
| 'size_asc'
| 'discoveries'
export interface Heat {
score: number
level: HeatLevel
}
export interface AvailabilitySummary {
status: AvailabilityStatus
last_verified_at: number | null
reachable_peers: number
}
export interface Availability extends AvailabilitySummary {
last_success_at: number | null
discovered_peers: number
consecutive_failures: number
next_check_at: number
}
export interface SearchHit {
info_hash: string
name: string
total_size: number
file_count: number
first_seen: number
last_seen: number
seen_count: number
content_key: string
variant_count: number
score: number
heat: Heat
availability: AvailabilitySummary
}
export interface SearchPage {
total: number
offset: number
limit: number
hits: SearchHit[]
sort: SearchSort
}
export interface TorrentFile {
path: string
size: number
}
export interface TorrentDetail {
info_hash: string
magnet_link: string
name: string
total_size: number
files: TorrentFile[]
piece_length: number
content_key: string
first_seen: number
last_seen: number
seen_count: number
heat: Heat
availability: Availability
}
export interface TorrentVariant {
info_hash: string
magnet_link: string
name: string
total_size: number
file_count: number
first_seen: number
last_seen: number
seen_count: number
heat: Heat
availability: Availability
}
export interface ContentVariants {
content_key: string
total: number
offset: number
limit: number
variants: TorrentVariant[]
}
export interface ServiceStats {
nodes: number
metadata_ok: number
metadata_failed: number
metadata_in_flight: number
persistence_inserted: number
persistence_updated: number
persistence_queue: number
indexed_documents: number
verification_queue: number
verification_succeeded: number
verification_failed: number
}
export interface SearchFilters {
minSizeMiB: string
maxSizeMiB: string
extension: string
minFiles: string
maxFiles: string
firstSeenAfter: string
firstSeenBefore: string
lastSeenAfter: string
lastSeenBefore: string
availability: '' | AvailabilityStatus
heat: '' | HeatLevel
}