控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?open=,刷新、分享、前进后退都能还原,
后退就是关抽屉。标准写在 docs/design/console-ia.md §9,ui-controls.md 引用。
- DetailSheet 加 xl 宽度、标题下操作行;抽屉里再开的 DetailSheet 在同一抽屉里换成子视图(返回 / Esc)
- record-sheet.tsx:useRecordSheet / RecordLink / useOpenRecord / recordPath / taskPath
- 整理记录、订阅、站点(含页签)、下载、日志任务改成抽屉;旧业务任务 / 下载任务 id 在任务抽屉里换算
- 回收站并进文件管理右侧视图(?view=trash);设置左栏只列分区
- 规则组、策略版本、媒体库未识别 / 外部身份、设置实例编辑走 ?open=
- 旧地址 /console/{organize,subscriptions,sites,downloads}/:id、/tasks/:id、/files/trash 跳到列表页抽屉
- 站内链接、铃铛、命令面板、任务时间线改拼新地址;冒烟按 ?open= 检查抽屉
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
690 lines
25 KiB
TypeScript
690 lines
25 KiB
TypeScript
import type { ReleaseSearchPage, SearchRelease } from "@umt/contracts";
|
||
import {
|
||
ArrowDown,
|
||
ArrowDownWideNarrow,
|
||
ArrowUp,
|
||
ArrowUpNarrowWide,
|
||
Bot,
|
||
ChevronDown,
|
||
Download,
|
||
ExternalLink,
|
||
PackageSearch,
|
||
RefreshCw,
|
||
TriangleAlert,
|
||
UsersRound,
|
||
} 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 { RecordLink } from "~/components/console/record-sheet.tsx";
|
||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||
import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
||
import { Button } from "~/components/ui/button.tsx";
|
||
import { Input } from "~/components/ui/input.tsx";
|
||
import { Pagination } from "~/components/ui/pagination.tsx";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||
import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx";
|
||
import { absTime, relTime, remainingLabel } from "~/components/workbench/release-fields.ts";
|
||
import { errorMessage } from "~/lib/api.ts";
|
||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||
import { QuickFilterBar } from "./quick-filter-bar.tsx";
|
||
import {
|
||
formatQuickFilter,
|
||
mergeLegacyFilters,
|
||
type QuickSelection,
|
||
selectedCount,
|
||
useQuickFilters,
|
||
} from "./quick-filter-state.ts";
|
||
import { delegateToAgent, SearchDownloadDialog } from "./search-download-dialog.tsx";
|
||
import {
|
||
gbToBytes,
|
||
groupByWork,
|
||
hrBadge,
|
||
INVENTORY_STATE,
|
||
promoBadge,
|
||
releaseSpecTags,
|
||
SITE_STATUS,
|
||
SORT_OPTIONS,
|
||
verdictView,
|
||
vsLibraryLabel,
|
||
type WorkGroup,
|
||
} from "./search-labels.ts";
|
||
|
||
// 资源段(SEARCH-2 第二阶段):按作品或关键词、在选中的站点上搜;快速筛选(SEARCH-3:分辨率/编码/版本/站点/
|
||
// 促销/HDR/H&R/制作组/季集,多选带计数),同一行右侧是大小区间与排序(POLISH-1,手机收进抽屉);游标翻页;
|
||
// 行操作同站点工作台(库存叠加、下载、委托智能体)。耗时、查询词放进「技术信息」。
|
||
// SEARCH-5(参考 MP 资源卡片):促销放标题前、规格拆成中性标签、关键数字定宽对齐、按作品分组,
|
||
// 每行附规则判定(命中第几档 / 不满足什么 / 与库里版本比较,服务端 ruleVerdict)。
|
||
// 按作品搜时,服务端判定不属于这部作品的资源(对照 MP match_torrent)不参与筛选与分面,
|
||
// 在列表最后折叠为「另有 N 条可能不是这部作品」,展开后仍可下载。
|
||
|
||
/** 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 type ReleaseTarget = { q?: string; entityId?: string; tmdb?: string };
|
||
|
||
/** URL 参数 work → 搜索目标:local:<id> / tmdb:<type>:<id> / q:<关键词> */
|
||
export function parseWorkRef(ref: string): ReleaseTarget | null {
|
||
if (ref.startsWith("local:")) return { entityId: ref.slice(6) };
|
||
const m = /^tmdb:(movie|tv):(\d+)$/.exec(ref);
|
||
if (m) return { tmdb: `${m[1]}:${m[2]}` };
|
||
if (ref.startsWith("q:") && ref.slice(2).trim()) return { q: ref.slice(2).trim() };
|
||
return null;
|
||
}
|
||
|
||
export function toApiFilters(
|
||
target: ReleaseTarget,
|
||
f: ReleaseFilterState,
|
||
siteIds = "",
|
||
quick: QuickSelection = {},
|
||
): ReleaseSearchFilters {
|
||
const sort = SORT_OPTIONS.some((o) => o.value === f.sort) ? (f.sort as ReleaseSearchFilters["sort"]) : undefined;
|
||
return {
|
||
...target,
|
||
sites: siteIds || undefined,
|
||
siteFilter: f.sites || undefined,
|
||
resolution: f.resolution || undefined,
|
||
source: f.source || undefined,
|
||
qf: formatQuickFilter(quick),
|
||
minSize: gbToBytes(f.minGb),
|
||
maxSize: gbToBytes(f.maxGb),
|
||
// free / hr 是旧筛选行的 URL 参数,进页面时并进快速筛选(mergeLegacyFilters),不再单独发
|
||
sort,
|
||
order: f.order === "asc" ? "asc" : f.order === "desc" ? "desc" : undefined,
|
||
};
|
||
}
|
||
|
||
export function ReleaseResults({
|
||
target,
|
||
siteIds,
|
||
filters,
|
||
setFilter,
|
||
onChangeSites,
|
||
}: {
|
||
target: ReleaseTarget;
|
||
/** 选中的站点(逗号分隔);空 = 服务端按默认站点设置 */
|
||
siteIds: string;
|
||
onChangeSites?: () => void;
|
||
filters: ReleaseFilterState;
|
||
setFilter: (k: keyof ReleaseFilterState, v: string) => void;
|
||
}) {
|
||
const [quick, setQuick] = useQuickFilters("search");
|
||
const api = useMemo(() => toApiFilters(target, filters, siteIds, quick), [target, filters, siteIds, quick]);
|
||
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 paged = useListPagination(rows, {
|
||
hasMore: res.hasNextPage,
|
||
onLoadMore: () => res.fetchNextPage(),
|
||
loadingMore: res.isFetchingNextPage,
|
||
error: res.error,
|
||
resetKey: JSON.stringify(api),
|
||
});
|
||
const [dl, setDl] = useState<SearchRelease | null>(null);
|
||
const [selected, setSelected] = useState<SearchRelease | null>(null);
|
||
const filtered =
|
||
selectedCount(quick) > 0 || RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]);
|
||
// 旧链接里的免费 / H&R 参数并进快速筛选
|
||
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在旧参数出现时迁移一次
|
||
useEffect(() => {
|
||
if (!filters.free && !filters.hr) return;
|
||
setQuick(mergeLegacyFilters(quick, { free: filters.free, hr: filters.hr }));
|
||
setFilter("free", "");
|
||
setFilter("hr", "");
|
||
}, [filters.free, filters.hr]);
|
||
/** 唯一的「清除」:分面、大小区间都清掉,排序保留 */
|
||
const clearAll = () => {
|
||
setQuick({});
|
||
for (const k of RELEASE_FILTER_KEYS) if (k !== "sort" && k !== "order") setFilter(k, "");
|
||
};
|
||
const sizeTag = sizeLabel(filters.minGb, filters.maxGb);
|
||
|
||
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 break-words text-muted-foreground tabular-nums">
|
||
{first.sites.length} 个站点 · {first.cached ? "缓存结果" : "实时结果"} · 上次抓取{" "}
|
||
{relTime(first.fetchedAt)}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
{onChangeSites ? (
|
||
<Button variant="ghost" size="sm" onClick={onChangeSites}>
|
||
换站点
|
||
</Button>
|
||
) : null}
|
||
<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>
|
||
</div>
|
||
|
||
{first ? <CoverageNotice page={first} /> : null}
|
||
|
||
{first ? (
|
||
<QuickFilterBar
|
||
facets={first.facets}
|
||
value={quick}
|
||
onChange={setQuick}
|
||
onClearAll={clearAll}
|
||
// 大小已有精确区间(大小与排序),不再给分档
|
||
hide={["size"]}
|
||
extraTags={
|
||
sizeTag
|
||
? [
|
||
{
|
||
key: "size",
|
||
dimLabel: "大小",
|
||
label: sizeTag,
|
||
onRemove: () => {
|
||
setFilter("minGb", "");
|
||
setFilter("maxGb", "");
|
||
},
|
||
},
|
||
]
|
||
: []
|
||
}
|
||
aside={{
|
||
label: "大小与排序",
|
||
active: !!sizeTag || !!filters.sort || filters.order === "asc",
|
||
content: (layout) => <SizeSortControls filters={filters} setFilter={setFilter} layout={layout} />,
|
||
}}
|
||
/>
|
||
) : 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
|
||
? "没有符合筛选条件的资源"
|
||
: first?.unmatchedTotal
|
||
? "没有找到这部作品的资源"
|
||
: "所选站点都没有找到资源"
|
||
}
|
||
description={
|
||
filtered
|
||
? "放宽筛选条件再看。"
|
||
: first?.unmatchedTotal
|
||
? "下面是按名字搜到、但可能不是这部作品的资源。"
|
||
: "换站点或换关键词再搜,或到站点页检查站点状态。"
|
||
}
|
||
className="py-12"
|
||
/>
|
||
) : (
|
||
<ReleaseList rows={paged.items ?? []} onOpen={setSelected} onDownload={setDl} />
|
||
)}
|
||
{rows.length > 0 || res.hasNextPage ? <Pagination {...paged.pagination} /> : null}
|
||
{first?.unmatchedTotal ? (
|
||
<UnmatchedReleases
|
||
key={JSON.stringify(api)}
|
||
rows={first.unmatched ?? []}
|
||
total={first.unmatchedTotal}
|
||
onOpen={setSelected}
|
||
onDownload={setDl}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
|
||
{first ? (
|
||
<TechInfo
|
||
className="mt-3"
|
||
items={[
|
||
{ label: "耗时", value: `${first.tookMs} ms` },
|
||
{ label: "查询词", value: first.queries?.length ? first.queries.map((x) => `「${x}」`).join("") : null },
|
||
{ label: "搜索词", value: first.q || null },
|
||
...first.sites.map((x) => ({ label: x.siteName, value: `${x.count} 条 · ${x.tookMs} ms` })),
|
||
]}
|
||
/>
|
||
) : null}
|
||
|
||
{dl ? <SearchDownloadDialog release={dl} open={!!dl} onOpenChange={(o) => !o && setDl(null)} /> : null}
|
||
{selected?.sourceReleaseId ? (
|
||
<ReleaseDetailSheet
|
||
key={selected.key}
|
||
siteId={selected.siteId}
|
||
release={null}
|
||
releaseId={selected.sourceReleaseId}
|
||
title={selected.title}
|
||
entry={selected.inventory ?? undefined}
|
||
onClose={() => setSelected(null)}
|
||
/>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/** 「另有 N 条可能不是这部作品」:默认收起,展开后与上面的资源行一样可查看、下载 */
|
||
export function UnmatchedReleases({
|
||
rows,
|
||
total,
|
||
onOpen,
|
||
onDownload,
|
||
defaultOpen = false,
|
||
}: {
|
||
rows: SearchRelease[];
|
||
total: number;
|
||
onOpen: (r: SearchRelease) => void;
|
||
onDownload: (r: SearchRelease) => void;
|
||
defaultOpen?: boolean;
|
||
}) {
|
||
const [open, setOpen] = useState(defaultOpen);
|
||
if (!total) return null;
|
||
return (
|
||
<div className="border-t border-border/60">
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((v) => !v)}
|
||
aria-expanded={open}
|
||
className="flex w-full cursor-pointer items-center gap-1.5 px-4 py-2.5 text-left text-caption text-muted-foreground transition-colors duration-micro hover:bg-foreground/[0.03] hover:text-foreground"
|
||
>
|
||
<ChevronDown className={cn("h-4 w-4 transition-transform duration-micro", open && "rotate-180")} />
|
||
另有 {total} 条可能不是这部作品
|
||
</button>
|
||
{open ? (
|
||
<div className="border-t border-border/60">
|
||
<ReleaseList rows={rows} onOpen={onOpen} onDownload={onDownload} />
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 部分站点失败 / 跳过:列出站点,其余结果照常 */
|
||
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>
|
||
);
|
||
}
|
||
|
||
/** 大小区间的标签文字:「5–20 GB」「≥ 5 GB」「≤ 20 GB」 */
|
||
export function sizeLabel(min: string, max: string): string | null {
|
||
if (min && max) return `${min}–${max} GB`;
|
||
if (min) return `≥ ${min} GB`;
|
||
if (max) return `≤ ${max} GB`;
|
||
return null;
|
||
}
|
||
|
||
/** 大小区间 + 排序:桌面放在快速筛选同一行右侧,手机在抽屉里竖排 */
|
||
function SizeSortControls({
|
||
filters,
|
||
setFilter,
|
||
layout,
|
||
}: {
|
||
filters: ReleaseFilterState;
|
||
setFilter: (k: keyof ReleaseFilterState, v: string) => void;
|
||
layout: "inline" | "sheet";
|
||
}) {
|
||
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 desc = filters.order !== "asc";
|
||
const sheet = layout === "sheet";
|
||
const commit = (k: "minGb" | "maxGb", v: string) => setFilter(k, v.trim());
|
||
|
||
const size = (
|
||
<div className="flex items-center gap-1.5">
|
||
<span className={cn("text-meta text-muted-foreground", sheet && "w-10 shrink-0")}>大小</span>
|
||
<Input
|
||
inputMode="decimal"
|
||
value={minGb}
|
||
onChange={(e) => setMinGb(e.target.value)}
|
||
onBlur={() => commit("minGb", minGb)}
|
||
onKeyDown={(e) => e.key === "Enter" && commit("minGb", minGb)}
|
||
placeholder="最小"
|
||
aria-label="最小大小(GB)"
|
||
className={cn("h-8", sheet ? "min-w-0 flex-1" : "w-16")}
|
||
/>
|
||
<span className="text-meta text-muted-foreground">–</span>
|
||
<Input
|
||
inputMode="decimal"
|
||
value={maxGb}
|
||
onChange={(e) => setMaxGb(e.target.value)}
|
||
onBlur={() => commit("maxGb", maxGb)}
|
||
onKeyDown={(e) => e.key === "Enter" && commit("maxGb", maxGb)}
|
||
placeholder="最大"
|
||
aria-label="最大大小(GB)"
|
||
className={cn("h-8", sheet ? "min-w-0 flex-1" : "w-16")}
|
||
/>
|
||
<span className="text-meta text-muted-foreground">GB</span>
|
||
</div>
|
||
);
|
||
const sort = (
|
||
<div className="flex items-center gap-1">
|
||
{sheet ? <span className="w-10 shrink-0 text-meta text-muted-foreground">排序</span> : null}
|
||
<Select value={filters.sort || "default"} onValueChange={(v) => setFilter("sort", v === "default" ? "" : v)}>
|
||
<SelectTrigger size="sm" aria-label="排序" className={sheet ? "min-w-0 flex-1" : "min-w-28"}>
|
||
{sheet ? null : <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>
|
||
);
|
||
return sheet ? (
|
||
<>
|
||
{size}
|
||
{sort}
|
||
</>
|
||
) : (
|
||
<>
|
||
{size}
|
||
<span aria-hidden className="mx-0.5 h-4 w-px bg-border" />
|
||
{sort}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** 作品组头:作品名 + 年份 + 未确认 + 库存状态 + 条数 */
|
||
function WorkHeader({ g, count }: { g: WorkGroup; count: number }) {
|
||
const inv = g.inventory ? INVENTORY_STATE[g.inventory.state] : null;
|
||
return (
|
||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 bg-foreground/[0.03] px-4 py-2">
|
||
{g.entity ? (
|
||
<>
|
||
<Link
|
||
to={`/console/library/${g.entity.id}`}
|
||
className="min-w-0 truncate text-caption font-semibold text-foreground underline-offset-4 hover:underline"
|
||
>
|
||
{g.entity.title}
|
||
{g.entity.year ? ` (${g.entity.year})` : ""}
|
||
</Link>
|
||
{g.entityMatch === "alias" ? <StatusBadge>未确认</StatusBadge> : null}
|
||
{inv ? <StatusBadge tone={inv.tone}>{inv.label}</StatusBadge> : null}
|
||
</>
|
||
) : (
|
||
<span className="text-caption font-semibold text-muted-foreground">未关联本地作品</span>
|
||
)}
|
||
<span className="ml-auto text-meta text-muted-foreground tabular-nums">{count} 条</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 按作品分组的结果列表;只涉及一部作品时作品名只在上方出现一次 */
|
||
function ReleaseList({
|
||
rows,
|
||
onOpen,
|
||
onDownload,
|
||
}: {
|
||
rows: SearchRelease[];
|
||
onOpen: (r: SearchRelease) => void;
|
||
onDownload: (r: SearchRelease) => void;
|
||
}) {
|
||
const { grouped, groups } = groupByWork(rows);
|
||
const single = !grouped && groups[0]?.entity ? groups[0] : null;
|
||
const row = (r: SearchRelease) => (
|
||
<ReleaseRow key={r.key} r={r} onOpen={() => onOpen(r)} onDownload={() => onDownload(r)} />
|
||
);
|
||
if (!grouped)
|
||
return (
|
||
<>
|
||
{single ? <WorkHeader g={single} count={single.items.length} /> : null}
|
||
<ul className="divide-y divide-border/60">{rows.map(row)}</ul>
|
||
</>
|
||
);
|
||
return (
|
||
<div className="divide-y divide-border/60">
|
||
{groups.map((g) => (
|
||
<section key={g.key ?? "loose"} aria-label={g.entity ? g.entity.title : "未关联本地作品"}>
|
||
<WorkHeader g={g} count={g.items.length} />
|
||
<ul className="divide-y divide-border/60 border-t border-border/60">{g.items.map(row)}</ul>
|
||
</section>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 促销放在标题前:免费用 success(属状态),其余折扣中性加粗;带剩余时间 */
|
||
function PromoTag({ r }: { r: SearchRelease }) {
|
||
const promo = promoBadge(r);
|
||
if (!promo) return null;
|
||
const left = remainingLabel(r.promotion.expiresAt);
|
||
const text = left ? `${promo.label} · ${left}` : promo.label;
|
||
const title = r.promotion.expiresAt ? `促销截止 ${absTime(r.promotion.expiresAt)}` : undefined;
|
||
return promo.tone === "success" ? (
|
||
<span title={title} className="shrink-0">
|
||
<StatusBadge tone="success" className="font-semibold">
|
||
{text}
|
||
</StatusBadge>
|
||
</span>
|
||
) : (
|
||
<span
|
||
title={title}
|
||
className="inline-flex shrink-0 items-center rounded-full border border-foreground/30 px-2 py-0.5 text-meta font-semibold whitespace-nowrap text-foreground"
|
||
>
|
||
{text}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** 规格标签:中性小标签,固定顺序;制作组带图标区分 */
|
||
function SpecTagList({ r }: { r: SearchRelease }) {
|
||
const tags = releaseSpecTags(r.parsed);
|
||
if (!tags.length) return null;
|
||
return (
|
||
<span className="flex flex-wrap gap-1">
|
||
{tags.map((t) => (
|
||
<span
|
||
key={t.key}
|
||
title={t.title}
|
||
className={cn(
|
||
"inline-flex items-center gap-0.5 rounded-md border px-1.5 text-meta leading-5 whitespace-nowrap",
|
||
t.dim === "resolution" || t.dim === "hdr" || t.dim === "episode"
|
||
? "border-border text-foreground"
|
||
: "border-border/70 text-muted-foreground",
|
||
)}
|
||
>
|
||
{t.dim === "group" ? <UsersRound aria-hidden className="h-3 w-3" /> : null}
|
||
{t.label}
|
||
</span>
|
||
))}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** 规则判定 + 与库里版本比较 */
|
||
function VerdictLine({ r }: { r: SearchRelease }) {
|
||
const v = r.ruleVerdict;
|
||
if (!v) return null;
|
||
const view = verdictView(v);
|
||
const vs = vsLibraryLabel(v);
|
||
return (
|
||
<span className="flex flex-wrap items-center gap-1">
|
||
<span title={view.title || undefined}>
|
||
<StatusBadge tone={view.tone}>{view.label}</StatusBadge>
|
||
</span>
|
||
{vs ? <StatusBadge>{vs}</StatusBadge> : null}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 资源行:左边 = 促销 + 标题 / 规格标签 / 规则判定;右边 = 站点、大小、做种·下载、发布时间定宽对齐,
|
||
* 下面是操作。宽屏像表格一样上下对齐,窄屏右栏折到下面。
|
||
*/
|
||
function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () => void; onDownload: () => void }) {
|
||
const hr = hrBadge(r);
|
||
const seedless = r.seeders === 0 || r.seeders === null;
|
||
return (
|
||
<li className="flex min-w-0 flex-col gap-2 px-4 py-3 lg:flex-row lg:items-start lg:gap-6">
|
||
<div className="min-w-0 flex-1 space-y-1.5">
|
||
<div className="flex min-w-0 items-start gap-2">
|
||
<PromoTag r={r} />
|
||
<button
|
||
type="button"
|
||
onClick={onOpen}
|
||
disabled={!r.sourceReleaseId}
|
||
className="min-w-0 flex-1 cursor-pointer text-left text-sm font-medium break-all text-foreground underline-offset-4 hover:underline focus-visible:underline disabled:cursor-default disabled:no-underline"
|
||
>
|
||
{r.title}
|
||
</button>
|
||
{hr ? (
|
||
<span className="shrink-0">
|
||
<StatusBadge tone={hr.tone}>{hr.label}</StatusBadge>
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
{r.subtitle ? <p className="line-clamp-1 text-caption text-muted-foreground">{r.subtitle}</p> : null}
|
||
<SpecTagList r={r} />
|
||
<VerdictLine r={r} />
|
||
</div>
|
||
<div className="flex shrink-0 flex-col gap-2 lg:items-end">
|
||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-meta text-muted-foreground tabular-nums lg:flex-nowrap">
|
||
<span className="truncate font-medium text-foreground lg:w-24" title={r.category.name ?? undefined}>
|
||
{r.siteName}
|
||
</span>
|
||
<span className="lg:w-16 lg:text-right">{r.sizeBytes === null ? "—" : formatBytes(r.sizeBytes, 1)}</span>
|
||
<span className="inline-flex items-center gap-0.5 lg:w-28" title="做种 / 下载">
|
||
<ArrowUp aria-label="做种数" className="h-3.5 w-3.5" />
|
||
<span className={cn("text-body", seedless ? "text-muted-foreground" : "font-semibold text-foreground")}>
|
||
{r.seeders ?? "—"}
|
||
</span>
|
||
<ArrowDown aria-label="下载数" className="ml-1.5 h-3 w-3" />
|
||
{r.leechers ?? "—"}
|
||
</span>
|
||
<span
|
||
className="lg:w-20 lg:text-right"
|
||
title={r.uploadedAt ? `发布于 ${absTime(r.uploadedAt)}` : "发布时间未知"}
|
||
>
|
||
{relTime(r.uploadedAt)}
|
||
</span>
|
||
</div>
|
||
<span className="flex flex-wrap items-center gap-1">
|
||
{r.sourceReleaseId ? (
|
||
<Button asChild variant="ghost" size="sm">
|
||
<RecordLink page="sites" id={r.siteId} title="打开站点">
|
||
<ExternalLink />
|
||
站点
|
||
</RecordLink>
|
||
</Button>
|
||
) : null}
|
||
<Button variant="ghost" size="sm" onClick={() => delegateToAgent(r)}>
|
||
<Bot />
|
||
交给智能体
|
||
</Button>
|
||
<Button size="sm" onClick={onDownload}>
|
||
<Download />
|
||
下载
|
||
</Button>
|
||
</span>
|
||
</div>
|
||
</li>
|
||
);
|
||
}
|