feat: 完成 Web 搜索界面与静态托管
This commit is contained in:
+179
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: '冷门',
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -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%); }
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user