Files
UHD_Media_Tool/apps/web/app/components/search/release-results.tsx
T
rootandClaude Opus 5.5 52402864d6 style(copy): punctuation rules and site-wide fix with a CI check (QA U11)
- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing,
  full-width parens around Chinese, single ellipsis, 「」 quotes, period rules)
- scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX
  text (not comments, types, keys, match/compare strings); --fix, --strict,
  --by-file; wired into deno task ci as check:copy in warn mode
- --fix applied to web, server and contracts; files other lanes were editing
  (subscriptions, sites, browser, agent, auth/account, inventory) left for them
- tests updated for the changed messages

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 20:09:29 +08:00

412 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { FacetCount, ReleaseSearchPage, SearchRelease } from "@umt/contracts";
import {
ArrowDown,
ArrowDownWideNarrow,
ArrowUp,
ArrowUpNarrowWide,
Bot,
ChevronDown,
Download,
ExternalLink,
PackageSearch,
RefreshCw,
TriangleAlert,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { type ReleaseSearchFilters, useRefreshReleaseSearch, useReleaseSearch } from "~/api/search.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterSelect } from "~/components/console/filter-bar.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { ChipToggleGroup } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
import { delegateToAgent, SearchDownloadDialog } from "./search-download-dialog.tsx";
import { gbToBytes, INVENTORY_STATE, promoLabel, SITE_STATUS, SORT_OPTIONS } from "./search-labels.ts";
// 资源段:跨站结果的筛选(站点/分辨率/来源/大小/免费/H&R)、排序、游标;行操作同站点工作台。
/** URL 里的资源筛选(字符串形态,空串 = 不筛) */
export type ReleaseFilterState = {
sites: string;
resolution: string;
source: string;
minGb: string;
maxGb: string;
free: string;
hr: string;
sort: string;
order: string;
};
export const RELEASE_FILTER_KEYS = [
"sites",
"resolution",
"source",
"minGb",
"maxGb",
"free",
"hr",
"sort",
"order",
] as const satisfies readonly (keyof ReleaseFilterState)[];
export function toApiFilters(q: string, f: ReleaseFilterState): ReleaseSearchFilters {
const bool = (v: string) => (v === "true" || v === "false" ? v : undefined);
const sort = SORT_OPTIONS.some((o) => o.value === f.sort) ? (f.sort as ReleaseSearchFilters["sort"]) : undefined;
return {
q,
sites: f.sites || undefined,
resolution: f.resolution || undefined,
source: f.source || undefined,
minSize: gbToBytes(f.minGb),
maxSize: gbToBytes(f.maxGb),
free: bool(f.free),
hr: bool(f.hr),
sort,
order: f.order === "asc" ? "asc" : f.order === "desc" ? "desc" : undefined,
};
}
const csvList = (s: string) => (s ? s.split(",").filter(Boolean) : []);
export function ReleaseResults({
q,
filters,
setFilter,
resetFilters,
}: {
q: string;
filters: ReleaseFilterState;
setFilter: (k: keyof ReleaseFilterState, v: string) => void;
resetFilters: () => void;
}) {
const api = useMemo(() => toApiFilters(q, filters), [q, filters]);
const res = useReleaseSearch(api);
const refresh = useRefreshReleaseSearch();
const pages = res.data?.pages ?? [];
const first = pages[0];
const rows = useMemo(() => pages.flatMap((p) => p.items), [pages]);
const [dl, setDl] = useState<SearchRelease | null>(null);
const filtered = RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]);
return (
<section aria-labelledby="search-releases" className="min-w-0">
<div className="mb-3 flex flex-wrap items-end justify-between gap-2">
<div className="min-w-0">
<h2 id="search-releases" className="text-body font-semibold text-foreground">
资源
{first ? (
<span className="ml-2 text-caption font-normal text-muted-foreground tabular-nums">
{first.total === first.unfilteredTotal
? `${first.total} 条`
: `${first.total} / ${first.unfilteredTotal} 条`}
</span>
) : null}
</h2>
{first ? (
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">
{first.cached ? "缓存结果" : "站点实时结果"} · 抓取于 {relTime(first.fetchedAt)} · {first.tookMs} ms
</p>
) : null}
</div>
<Button
variant="outline"
size="sm"
disabled={refresh.isPending || res.isFetching}
onClick={() => refresh.mutate(api, { onError: (e) => toast.error(errorMessage(e, "刷新失败")) })}
>
<RefreshCw className={cn(refresh.isPending && "animate-spin")} />
{refresh.isPending ? "查询中…" : "刷新"}
</Button>
</div>
{first ? <CoverageNotice page={first} /> : null}
{first ? (
<Filters page={first} filters={filters} setFilter={setFilter} onReset={filtered ? resetFilters : undefined} />
) : null}
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
{res.isPending ? (
<div className="p-4">
<p className="mb-3 text-meta text-muted-foreground">正在查询各站点,最慢约 15 秒…</p>
<LoadingState rows={4} />
</div>
) : res.isError && !rows.length ? (
<ErrorState plain error={res.error} onRetry={() => res.refetch()} />
) : rows.length === 0 ? (
<EmptyState
plain
icon={PackageSearch}
title={filtered ? "没有符合筛选条件的资源" : "各站点都没有找到资源"}
description={filtered ? "放宽筛选条件再看。" : "换个关键词,或到站点工作台检查站点状态。"}
className="py-12"
/>
) : (
<ul className="divide-y divide-border/60">
{rows.map((r) => (
<ReleaseRow key={r.key} r={r} onDownload={() => setDl(r)} />
))}
</ul>
)}
{rows.length > 0 && res.hasNextPage ? (
<div className="flex justify-center border-t border-border/60 px-4 py-3">
<Button variant="outline" size="sm" onClick={() => res.fetchNextPage()} disabled={res.isFetchingNextPage}>
{res.isFetchingNextPage ? "加载中…" : "加载更多"}
</Button>
</div>
) : null}
</div>
{dl ? <SearchDownloadDialog release={dl} open={!!dl} onOpenChange={(o) => !o && setDl(null)} /> : null}
</section>
);
}
/** 部分站点失败 / 跳过:列出站点,其余结果照常 */
function CoverageNotice({ page }: { page: ReleaseSearchPage }) {
const [open, setOpen] = useState(false);
const skipped = page.sites.filter((s) => s.status === "skipped");
const ok = page.sites.filter((s) => s.status === "ok").length;
return (
<div className="mb-3 space-y-2">
{page.partial ? (
<div className="flex gap-2 rounded-xl border border-warning/40 bg-warning/10 px-3 py-2.5 text-caption text-foreground">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<div className="min-w-0">
<p>
{page.failedSites.length} 个站点没有返回结果,下面只有 {ok} 个站点的资源。
</p>
<ul className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-meta text-muted-foreground">
{page.failedSites.map((s) => (
<li key={s.siteId} className="break-all" title={s.error ?? undefined}>
{s.siteName}:{SITE_STATUS[s.status]}
{s.error ? `(${s.error})` : ""}
</li>
))}
</ul>
</div>
</div>
) : null}
{skipped.length ? (
<div className="text-meta text-muted-foreground">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="inline-flex cursor-pointer items-center gap-1 hover:text-foreground"
>
<ChevronDown className={cn("h-3 w-3 transition-transform duration-micro", open && "rotate-180")} />
{skipped.length} 个站点未参与搜索
</button>
{open ? (
<ul className="mt-1 space-y-0.5 pl-4">
{skipped.map((s) => (
<li key={s.siteId} className="break-all">
{s.siteName}:{s.error ?? "站点不支持搜索"}
</li>
))}
</ul>
) : null}
</div>
) : null}
</div>
);
}
const facetOpts = (items: FacetCount[]) =>
items.map((f) => ({ value: f.value, label: `${f.value === "unknown" ? "未知" : f.label} ${f.count}` }));
function Filters({
page,
filters,
setFilter,
onReset,
}: {
page: ReleaseSearchPage;
filters: ReleaseFilterState;
setFilter: (k: keyof ReleaseFilterState, v: string) => void;
onReset?: () => void;
}) {
const [minGb, setMinGb] = useState(filters.minGb);
const [maxGb, setMaxGb] = useState(filters.maxGb);
useEffect(() => setMinGb(filters.minGb), [filters.minGb]);
useEffect(() => setMaxGb(filters.maxGb), [filters.maxGb]);
const facets: { key: "sites" | "resolution" | "source"; label: string; items: FacetCount[] }[] = [
{ key: "sites", label: "站点", items: page.facets.sites },
{ key: "resolution", label: "分辨率", items: page.facets.resolutions },
{ key: "source", label: "来源", items: page.facets.sources },
];
const desc = filters.order !== "asc";
return (
<div className="mb-3 space-y-2.5">
{facets
.filter((f) => f.items.length > 0)
.map((f) => (
<div key={f.key} className="flex min-w-0 flex-wrap items-start gap-2">
<span className="w-12 shrink-0 pt-1 text-meta text-muted-foreground">{f.label}</span>
<div className="min-w-0 flex-1">
<ChipToggleGroup
label={f.label}
options={facetOpts(f.items)}
value={csvList(filters[f.key])}
onChange={(v) => setFilter(f.key, v.join(","))}
/>
</div>
</div>
))}
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1.5">
<span className="text-meta text-muted-foreground">大小</span>
<Input
inputMode="decimal"
value={minGb}
onChange={(e) => setMinGb(e.target.value)}
onBlur={() => setFilter("minGb", minGb.trim())}
onKeyDown={(e) => e.key === "Enter" && setFilter("minGb", minGb.trim())}
placeholder="最小"
aria-label="最小大小(GB)"
className="h-8 w-20"
/>
<span className="text-meta text-muted-foreground">–</span>
<Input
inputMode="decimal"
value={maxGb}
onChange={(e) => setMaxGb(e.target.value)}
onBlur={() => setFilter("maxGb", maxGb.trim())}
onKeyDown={(e) => e.key === "Enter" && setFilter("maxGb", maxGb.trim())}
placeholder="最大"
aria-label="最大大小(GB)"
className="h-8 w-20"
/>
<span className="text-meta text-muted-foreground">GB</span>
</div>
<FilterSelect
label="免费"
value={filters.free}
onChange={(v) => setFilter("free", v)}
options={[
{ value: "true", label: "只看免费" },
{ value: "false", label: "排除免费" },
]}
/>
<FilterSelect
label="H&R"
value={filters.hr}
onChange={(v) => setFilter("hr", v)}
options={[
{ value: "true", label: "只看 H&R" },
{ value: "false", label: "排除 H&R" },
]}
/>
<div className="flex items-center gap-1">
<Select value={filters.sort || "default"} onValueChange={(v) => setFilter("sort", v === "default" ? "" : v)}>
<SelectTrigger size="sm" aria-label="排序" className="min-w-32">
<span className="text-muted-foreground">排序</span>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{SORT_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label={desc ? "降序,点击改为升序" : "升序,点击改为降序"}
title={desc ? "降序" : "升序"}
onClick={() => setFilter("order", desc ? "asc" : "")}
>
{desc ? <ArrowDownWideNarrow className="h-4 w-4" /> : <ArrowUpNarrowWide className="h-4 w-4" />}
</Button>
</div>
{onReset ? (
<Button variant="ghost" size="sm" onClick={onReset}>
清除筛选
</Button>
) : null}
</div>
</div>
);
}
function ReleaseRow({ r, onDownload }: { r: SearchRelease; onDownload: () => void }) {
const promo = promoLabel(r.promotion.type);
const inv = r.inventory ? INVENTORY_STATE[r.inventory.state] : null;
const p = r.parsed;
const tech = [p.resolution, p.source, p.videoCodec, ...p.hdr, p.releaseGroup].filter(Boolean).join(" · ");
return (
<li className="px-4 py-3">
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-1">
<span className="min-w-0 flex-1 text-sm font-medium break-all text-foreground">{r.title}</span>
<span className="flex flex-wrap gap-1">
{promo ? <StatusBadge tone={r.free ? "success" : "neutral"}>{promo}</StatusBadge> : null}
{r.hr === true ? <StatusBadge tone="warning">H&R</StatusBadge> : null}
{r.hr === "unknown" ? <StatusBadge>H&R 未知</StatusBadge> : null}
</span>
</div>
{r.subtitle ? <p className="mt-0.5 line-clamp-1 text-caption text-muted-foreground">{r.subtitle}</p> : null}
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums">
<span className="font-medium text-foreground">{r.siteName}</span>
{r.category.name ? <span>{r.category.name}</span> : null}
<span>{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}</span>
<span className="inline-flex items-center gap-0.5" title="做种 / 下载">
<ArrowUp className="h-3 w-3" />
{r.seeders ?? "?"}
<ArrowDown className="ml-1 h-3 w-3" />
{r.leechers ?? "?"}
</span>
<span title="站点发布时间">发布 {relTime(r.uploadedAt)}</span>
{tech ? <span className="break-all">{tech}</span> : null}
</div>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<span className="flex min-w-0 flex-wrap items-center gap-1.5 text-meta">
{r.entity ? (
<>
<Link
to={`/console/library/${r.entity.id}`}
className="truncate text-foreground underline-offset-4 hover:underline"
>
{r.entity.title}
{r.entity.year ? ` (${r.entity.year})` : ""}
</Link>
{r.entityMatch === "alias" ? <StatusBadge>未确认</StatusBadge> : null}
{inv ? <StatusBadge tone={inv.tone}>{inv.label}</StatusBadge> : null}
</>
) : (
<span className="text-muted-foreground">未关联本地作品</span>
)}
</span>
<span className="flex flex-wrap items-center gap-1">
{r.sourceReleaseId ? (
<Button asChild variant="ghost" size="sm">
<Link to={`/console/sites/${r.siteId}`} title="在站点工作台查看">
<ExternalLink />
工作台
</Link>
</Button>
) : null}
<Button variant="ghost" size="sm" onClick={() => delegateToAgent(r)}>
<Bot />
委托 Agent
</Button>
<Button size="sm" onClick={onDownload}>
<Download />
下载
</Button>
</span>
</div>
</li>
);
}