feat: 增强搜索详情与索引稳定性

This commit is contained in:
chuan
2026-08-07 02:07:02 +08:00
parent d992d305b9
commit b898cfb01b
25 changed files with 607 additions and 203 deletions
+96 -26
View File
@@ -1,23 +1,24 @@
<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 { Activity, Database, 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 { AppPagination } from '@/components/ui/pagination'
import { AppSelect } from '@/components/ui/select'
import { getStats, getTorrent, getVariants, search } from '@/lib/api'
import type { SearchFilters, SearchHit, SearchPage, SearchSort, ServiceStats, TorrentDetail, ContentVariants } from '@/types/api'
import type { 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 regexMode = ref(false)
const sort = ref<SearchSort>('latest')
const page = ref<SearchPage | null>(null)
const offset = ref(0)
const limit = 20
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)
@@ -25,22 +26,44 @@ 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) + 1)
const pageCount = computed(() => Math.max(1, Math.ceil((page.value?.total ?? 0) / limit)))
const filterCount = computed(() => Object.values(filters.value).filter(Boolean).length)
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)
}
@@ -52,7 +75,7 @@ async function loadSearch() {
error.value = ''
updateBrowserUrl()
try {
page.value = await search({ query: submittedQuery.value, offset: offset.value, limit, sort: sort.value, filters: filters.value }, searchController.signal)
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 {
@@ -67,9 +90,17 @@ function submitSearch() {
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 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
@@ -77,17 +108,24 @@ function toggleTheme() {
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, detailController.signal), getVariants(hit.content_key, detailController.signal)])
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) {
@@ -97,11 +135,37 @@ async function openDetail(hit: SearchHit) {
}
}
function closeDetail() { detailController?.abort(); detailOpen.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() {
try { stats.value = await getStats() } catch { stats.value = null }
if (statsRequestActive) return
statsRequestActive = true
try { stats.value = await getStats() } catch { stats.value = null } finally { statsRequestActive = false }
}
watch(statsOpen, (open) => {
@@ -111,15 +175,17 @@ watch(statsOpen, (open) => {
}
if (open) {
void loadStats()
statsTimer = window.setInterval(() => void loadStats(), 5_000)
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'
@@ -127,8 +193,10 @@ onMounted(() => {
void loadSearch()
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', closeStatsOnOutsideClick)
searchController?.abort()
detailController?.abort()
filesController?.abort()
if (statsTimer !== null) window.clearInterval(statsTimer)
})
</script>
@@ -140,7 +208,7 @@ onBeforeUnmount(() => {
<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">
<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>
@@ -157,23 +225,25 @@ onBeforeUnmount(() => {
<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>
<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>
<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>
<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>
<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" :loading="detailLoading" :open="detailOpen" :variants="variants" @close="closeDetail" @retry="retryDetail" />
<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>
-64
View File
@@ -1,64 +0,0 @@
<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>
+52 -8
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Check, Copy, Download, File, LoaderCircle, X } from '@lucide/vue'
import { Check, Copy, Download, LoaderCircle, X } from '@lucide/vue'
import { Button } from '@/components/ui/button'
import { AppPagination } from '@/components/ui/pagination'
import { AppSelect } from '@/components/ui/select'
import { availabilityLabel, formatBytes, formatDate, heatLabel } from '@/lib/format'
import type { ContentVariants, TorrentDetail } from '@/types/api'
@@ -11,11 +13,30 @@ const props = defineProps<{
detail: TorrentDetail | null
variants: ContentVariants | null
loading: boolean
filesLoading: boolean
filesError: string
error: string
}>()
const emit = defineEmits<{ close: []; retry: [] }>()
const emit = defineEmits<{ close: []; retry: []; 'file-page': [offset: number]; 'file-page-size': [size: number] }>()
const copied = ref('')
const totalFileSize = computed(() => props.detail?.files.reduce((sum, file) => sum + file.size, 0) ?? 0)
const filePage = computed(() => props.detail ? Math.floor(props.detail.file_offset / props.detail.file_limit) + 1 : 1)
const filePageSizeOptions = [
{ value: '25', label: '25 条/页' },
{ value: '50', label: '50 条/页' },
{ value: '100', label: '100 条/页' },
{ value: '200', label: '200 条/页' },
]
function fileName(path: string) {
const normalized = path.replaceAll('\\', '/')
return normalized.slice(normalized.lastIndexOf('/') + 1) || normalized
}
function fileDirectory(path: string) {
const normalized = path.replaceAll('\\', '/')
const separator = normalized.lastIndexOf('/')
return separator > 0 ? normalized.slice(0, separator) : '根目录'
}
async function copy(value: string, type: string) {
if (navigator.clipboard && window.isSecureContext) {
@@ -34,6 +55,14 @@ async function copy(value: string, type: string) {
window.setTimeout(() => { if (copied.value === type) copied.value = '' }, 1600)
}
function changeFilePage(page: number) {
if (props.detail) emit('file-page', (page - 1) * props.detail.file_limit)
}
function changeFilePageSize(value: string) {
emit('file-page-size', Number(value))
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && props.open) emit('close')
}
@@ -61,14 +90,14 @@ onBeforeUnmount(() => {
<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><h2 class="break-words text-xl font-semibold tracking-tight">{{ detail.name }}</h2><button class="mt-2 flex max-w-full items-center gap-1.5 text-left font-mono text-[11px] leading-5 text-muted-foreground hover:text-foreground" title="复制 infohash" @click="copy(detail.info_hash, 'hash')"><span class="truncate">{{ detail.info_hash }}</span><Check v-if="copied === 'hash'" class="size-3 shrink-0 text-emerald-600" /><Copy v-else class="size-3 shrink-0" /></button></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>{{ detail.file_count.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>
@@ -80,9 +109,24 @@ onBeforeUnmount(() => {
</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 class="mb-4 flex flex-wrap items-center gap-3">
<div><h3 class="font-semibold">文件详情</h3><p class="mt-1 text-xs text-muted-foreground"> {{ detail.file_count.toLocaleString() }} 个文件</p></div>
<div class="ml-auto flex flex-wrap items-center justify-end gap-2">
<AppSelect :model-value="String(detail.file_limit)" :options="filePageSizeOptions" label="每页文件数量" @update:model-value="changeFilePageSize" />
<AppPagination v-if="detail.file_count > detail.file_limit" :disabled="filesLoading" :page="filePage" :page-size="detail.file_limit" :total="detail.file_count" @update:page="changeFilePage" />
</div>
</div>
<div class="relative overflow-hidden rounded-xl border bg-card">
<div v-if="filesLoading" class="absolute inset-0 z-10 flex items-center justify-center bg-background/75 backdrop-blur-sm"><LoaderCircle class="size-5 animate-spin text-muted-foreground" /></div>
<div v-if="filesError" class="border-b bg-destructive/10 px-4 py-2 text-xs text-destructive">{{ filesError }}</div>
<div class="divide-y">
<div v-for="(file, index) in detail.files" :key="`${detail.file_offset}-${file.path}-${file.size}`" class="group flex items-center gap-3 px-3 py-2 transition-colors hover:bg-muted/45 sm:px-4">
<span class="w-7 shrink-0 text-left font-mono text-[10px] tabular-nums text-muted-foreground/70">{{ detail.file_offset + index + 1 }}</span>
<div class="min-w-0 flex-1"><p class="truncate text-sm font-medium leading-4" :title="file.path">{{ fileName(file.path) }}</p><p class="truncate text-[10px] leading-4 text-muted-foreground" :title="fileDirectory(file.path)">{{ fileDirectory(file.path) }}</p></div>
<span class="w-20 shrink-0 text-right text-xs tabular-nums text-muted-foreground">{{ formatBytes(file.size) }}</span>
</div>
<p v-if="!detail.files.length" class="px-4 py-8 text-center text-sm text-muted-foreground">没有文件信息</p>
</div>
</div>
</div>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { ChevronLeft, ChevronRight, MoreHorizontal } from '@lucide/vue'
import {
PaginationEllipsis,
PaginationList,
PaginationListItem,
PaginationNext,
PaginationPrev,
PaginationRoot,
} from 'reka-ui'
defineProps<{
page: number
pageSize: number
total: number
disabled?: boolean
}>()
const emit = defineEmits<{ 'update:page': [page: number] }>()
</script>
<template>
<PaginationRoot :page="page" :items-per-page="pageSize" :total="total" :disabled="disabled" :sibling-count="1" show-edges @update:page="emit('update:page', $event)">
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
<PaginationPrev class="pagination-button" title="上一页"><ChevronLeft class="size-4" /></PaginationPrev>
<template v-for="(item, index) in items" :key="item.type === 'page' ? item.value : `ellipsis-${index}`">
<PaginationListItem v-if="item.type === 'page'" class="pagination-button data-[selected]:bg-primary data-[selected]:text-primary-foreground" :value="item.value">{{ item.value }}</PaginationListItem>
<PaginationEllipsis v-else class="flex size-8 items-center justify-center text-muted-foreground"><MoreHorizontal class="size-4" /></PaginationEllipsis>
</template>
<PaginationNext class="pagination-button" title="下一页"><ChevronRight class="size-4" /></PaginationNext>
</PaginationList>
</PaginationRoot>
</template>
@@ -0,0 +1 @@
export { default as AppPagination } from './AppPagination.vue'
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { Check, ChevronDown } from '@lucide/vue'
import {
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectValue,
SelectViewport,
} from 'reka-ui'
defineProps<{
modelValue: string
options: ReadonlyArray<{ value: string; label: string }>
label: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<SelectRoot :model-value="modelValue" @update:model-value="(value) => typeof value === 'string' && emit('update:modelValue', value)">
<SelectTrigger :aria-label="label" class="flex h-9 min-w-28 items-center justify-between gap-2 rounded-md border bg-background px-3 text-sm outline-none transition hover:bg-accent focus:ring-3 focus:ring-ring/20 data-[placeholder]:text-muted-foreground">
<SelectValue />
<ChevronDown class="size-4 text-muted-foreground" />
</SelectTrigger>
<SelectPortal>
<SelectContent class="z-50 min-w-[var(--reka-select-trigger-width)] overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-lg" position="popper" :side-offset="5">
<SelectViewport class="p-1">
<SelectItem v-for="option in options" :key="option.value" :value="option.value" class="relative flex cursor-default select-none items-center rounded-md py-2 pl-8 pr-3 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50">
<SelectItemIndicator class="absolute left-2 flex size-4 items-center justify-center"><Check class="size-4" /></SelectItemIndicator>
<SelectItemText>{{ option.label }}</SelectItemText>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as AppSelect } from './AppSelect.vue'
+5 -31
View File
@@ -1,6 +1,5 @@
import type {
ContentVariants,
SearchFilters,
SearchPage,
SearchSort,
ServiceStats,
@@ -12,22 +11,7 @@ interface SearchInput {
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))
}
regex: boolean
}
async function request<T>(path: string, signal?: AbortSignal): Promise<T> {
@@ -52,23 +36,13 @@ export function search(input: SearchInput, signal?: AbortSignal): Promise<Search
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)
if (input.regex) params.set('regex', 'true')
return request<SearchPage>(`/search?${params}`, signal)
}
export function getTorrent(infoHash: string, signal?: AbortSignal): Promise<TorrentDetail> {
return request<TorrentDetail>(`/torrents/${encodeURIComponent(infoHash)}`, signal)
export function getTorrent(infoHash: string, fileOffset = 0, fileLimit = 100, signal?: AbortSignal): Promise<TorrentDetail> {
const params = new URLSearchParams({ file_offset: String(fileOffset), file_limit: String(fileLimit) })
return request<TorrentDetail>(`/torrents/${encodeURIComponent(infoHash)}?${params}`, signal)
}
export function getVariants(contentKey: string, signal?: AbortSignal): Promise<ContentVariants> {
+3
View File
@@ -149,6 +149,9 @@
.status-cell b {
@apply text-sm font-semibold;
}
.pagination-button {
@apply flex size-8 items-center justify-center rounded-md text-xs font-medium transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-40;
}
dl dt {
@apply text-xs text-muted-foreground;
}
+3 -14
View File
@@ -60,6 +60,9 @@ export interface TorrentDetail {
magnet_link: string
name: string
total_size: number
file_count: number
file_offset: number
file_limit: number
files: TorrentFile[]
piece_length: number
content_key: string
@@ -104,17 +107,3 @@ export interface ServiceStats {
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
}