feat: 增强搜索详情与索引稳定性
This commit is contained in:
+96
-26
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AppSelect } from './AppSelect.vue'
|
||||
+5
-31
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user