Files
UHD_Media_Tool/apps/web/app/components/subscriptions/subscription-sheet.tsx
T
rootandClaude Opus 5.5 21c55dfb3a feat(web): 记录抽屉与两层导航标准
控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?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
2026-10-08 14:03:47 +08:00

310 lines
13 KiB
TypeScript

import { Pause, Pencil, Play, RefreshCw, RotateCcw, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import {
useRefreshCoverage,
useSubscription,
useSubscriptionCoverage,
useSubscriptionOverviewItem,
} from "~/api/subscriptions.ts";
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { useStickyId } from "~/components/console/record-sheet.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { SubscriptionMappingSummary } from "~/components/episode-mapping/subscription-summary.tsx";
import { PRIORITY, SEASON_SCOPE, SUB_MODE, SUB_ORIGIN, SUB_STATUS } from "~/components/library/labels.ts";
import { CoverPlaceholder, Panel, relTime, Tone } from "~/components/library/parts.tsx";
import { SlotsPanel } from "~/components/library/subscriptions-slots.tsx";
import { SubscriptionMenu, useSubscriptionOps } from "~/components/subscriptions/actions.tsx";
import {
CellSheet,
CoverageLegend,
CoverageMatrix,
type SelectedCell,
} from "~/components/subscriptions/coverage-matrix.tsx";
import {
autoFillText,
foldNames,
GROUP_LABEL,
SUB_KIND_LABEL,
seasonLabel,
seasonSlices,
seasonTitle,
} from "~/components/subscriptions/model.ts";
import { ProgressBlock, SlotBadges } from "~/components/subscriptions/overview-item.tsx";
import { SlotPanel } from "~/components/subscriptions/slot-panel.tsx";
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { actorText } from "~/lib/labels.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
// 订阅对比视图(FE-5,docs/design/subscriptions.md §2):季页签 → 集 × 收录版本矩阵;收录版本排序与自动补全;订阅信息。
// 记录抽屉(console-ia.md §9):订阅页上 ?open=<订阅 id> 打开的宽抽屉,不再有独立页。
const TOTAL_SOURCE: Record<string, string> = {
tmdb: "TMDB",
injected: "订阅里填写",
manual: "手动",
covered_floor: "按已整理",
previous: "沿用上次",
};
export function SubscriptionSheet({ id, onClose }: { id: string | null; onClose: () => void }) {
const shown = useStickyId(id);
return (
<DetailSheet open={!!id} onOpenChange={(o) => !o && onClose()} width="xl" title={<SheetTitleText id={shown} />}>
{shown ? <SubscriptionDetail id={shown} /> : null}
</DetailSheet>
);
}
function SheetTitleText({ id }: { id: string | null }) {
const subQ = useSubscription(id ?? undefined);
const sub = subQ.data;
return <>{sub?.mediaEntityTitle ?? sub?.titleQuery ?? "订阅"}</>;
}
function SubscriptionDetail({ id }: { id: string }) {
const subQ = useSubscription(id);
const sub = subQ.data;
const itemQ = useSubscriptionOverviewItem(sub);
const item = itemQ.data;
const covQ = useSubscriptionCoverage(id);
const cov = covQ.data;
const refresh = useRefreshCoverage();
const ops = useSubscriptionOps();
const [season, setSeason] = useState(0);
const [sel, setSel] = useState<SelectedCell | null>(null);
const [editing, setEditing] = useState(false);
const seasonTabs = useMemo(() => {
if (!cov) return [];
const slices = seasonSlices(cov);
const any = cov.rows.find((r) => r.slotId === null);
return cov.seasons.map((s, i) => {
const sl = slices[i]!;
const have = any ? any.cells.slice(sl.start, sl.end).filter((c) => c.state === "have").length : 0;
return {
title: seasonTitle(s, cov.mediaKind),
stat: `${have}/${s.totalKnown ? s.total : "?"}`,
source: s.source ? (TOTAL_SOURCE[s.source] ?? s.source) : null,
locked: s.locked,
};
});
}, [cov]);
if (subQ.isPending) return <LoadingState rows={6} />;
if (subQ.isError || !sub) return <ErrorState plain error={subQ.error} onRetry={() => subQ.refetch()} />;
const title = sub.mediaEntityTitle ?? item?.mediaEntityTitle ?? sub.titleQuery ?? "订阅";
const tracking = sub.status === "active" || sub.status === "pending";
const ended = sub.status === "completed" || sub.status === "cancelled";
const sLabel = item ? seasonLabel(item) : null;
const doRefresh = () =>
refresh.mutate(sub.id, {
onSuccess: () => toast.success("对比已刷新"),
onError: (e) => toast.error(errorMessage(e)),
});
const seasonIdx = Math.min(season, Math.max(0, (cov?.seasons.length ?? 1) - 1));
return (
<>
<header className="flex flex-wrap items-start gap-4">
<CoverPlaceholder title={title} kind={sub.mediaKind} className="h-24 w-16 max-sm:hidden" />
<div className="min-w-0 flex-1 space-y-2">
<div className="min-w-0">
<p className="text-caption text-muted-foreground">
{[item?.year, SUB_KIND_LABEL[sub.mediaKind], sLabel, SUB_MODE[sub.mode]].filter(Boolean).join(" · ")}
</p>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Tone v={SUB_STATUS[sub.status]} />
{item?.group ? <StatusBadge>{GROUP_LABEL[item.group]}</StatusBadge> : null}
{item?.upgrading ? <StatusBadge tone="warning">洗版中</StatusBadge> : null}
{item?.awaitingNewSeason ? <StatusBadge tone="success">等待新季</StatusBadge> : null}
{!sub.mediaEntityId ? <StatusBadge tone="warning">不在媒体库</StatusBadge> : null}
</div>
{!sub.mediaEntityId ? (
<p className="max-w-xl rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
只按标题「{sub.titleQuery ?? title}
」订阅,还没关联到媒体库作品,不会自动搜索和下载。先把作品加入媒体库,再从作品订阅。
</p>
) : null}
{item ? (
<div className="grid max-w-xl gap-2">
<ProgressBlock x={item} />
<SlotBadges slots={item.slots} max={6} unbound={!sub.mediaEntityId} />
</div>
) : null}
</div>
<div className="flex w-full flex-wrap items-center gap-2">
{tracking && sub.mediaEntityId ? (
<Button variant="outline" size="sm" onClick={() => ops.search(sub.id)} disabled={ops.pending}>
<Search />
立即搜索
</Button>
) : null}
{!ended ? (
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
<Pencil />
编辑
</Button>
) : null}
{tracking ? (
<Button variant="outline" size="sm" onClick={() => ops.act(sub.id, "pause")} disabled={ops.pending}>
<Pause />
暂停
</Button>
) : sub.status === "paused" ? (
<Button variant="outline" size="sm" onClick={() => ops.act(sub.id, "resume")} disabled={ops.pending}>
<Play />
恢复
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => ops.act(sub.id, "resubscribe")} disabled={ops.pending}>
<RotateCcw />
重新订阅
</Button>
)}
<SubscriptionMenu sub={sub} onEdit={() => setEditing(true)} showDetail={false} />
</div>
</header>
<div className="space-y-5">
<Panel
title="覆盖对比"
description={cov ? `按收录版本逐集对比 · 计算于 ${relTime(cov.computedAt)}` : "按收录版本逐集对比"}
actions={
<Button variant="outline" size="sm" onClick={doRefresh} disabled={refresh.isPending || !sub.mediaEntityId}>
<RefreshCw className={cn(refresh.isPending && "animate-spin")} />
刷新对比
</Button>
}
>
{!sub.mediaEntityId ? (
<p className="text-caption text-muted-foreground">作品加入媒体库后才能对比,加入后自动关联到这个订阅。</p>
) : covQ.isPending ? (
<LoadingState rows={3} />
) : covQ.isError ? (
<ErrorState plain error={covQ.error} onRetry={() => covQ.refetch()} />
) : !cov || cov.seasons.length === 0 ? (
<p className="text-caption text-muted-foreground">
还没有季和集信息。元数据刷新后出现;也可以在编辑里设置总集数。
</p>
) : (
<div className="space-y-4">
{cov.seasons.length > 1 || cov.mediaKind !== "movie" ? (
<div role="tablist" aria-label="季" className="flex gap-1 overflow-x-auto scroll-bleed">
{seasonTabs.map((t, i) => (
<button
key={t.title + i}
type="button"
role="tab"
aria-selected={i === seasonIdx}
onClick={() => setSeason(i)}
className={cn(
"flex shrink-0 cursor-pointer flex-col items-start rounded-lg border px-3 py-1.5 text-left transition-colors duration-micro",
i === seasonIdx ? "pill-selected" : "border-transparent hover:bg-foreground/[0.06]",
)}
>
<span className="text-sm font-medium text-foreground">{t.title}</span>
<span className="text-meta text-muted-foreground tabular-nums">
{t.stat}
{t.source ? ` · ${t.source}` : ""}
{t.locked ? " · 锁定" : ""}
</span>
</button>
))}
</div>
) : null}
<CoverageMatrix cov={cov} seasonIndex={seasonIdx} onSelect={setSel} />
<CoverageLegend />
</div>
)}
</Panel>
{sub.mediaEntityId ? <SlotPanel sub={sub} cov={cov} item={item} canFill={tracking} /> : null}
{sub.mediaEntityId && sub.mediaKind !== "movie" ? (
<SubscriptionMappingSummary entityId={sub.mediaEntityId} seasonUnitId={sub.seasonUnitId} />
) : null}
<div className="grid gap-5 xl:grid-cols-2">
<Panel title="订阅信息">
<KvList
items={[
{ label: "方式", value: SUB_MODE[sub.mode] },
...(sub.mediaKind !== "movie" ? [{ label: "季范围", value: SEASON_SCOPE[sub.seasonScope] }] : []),
{ label: "优先级", value: PRIORITY[sub.priority] },
{ label: "来源", value: SUB_ORIGIN[sub.origin] ?? sub.origin },
{
label: "站点",
value: item
? foldNames(
item.sites.map((s) => s.name),
4,
)
: null,
},
{
label: "规则组",
value: item
? `${foldNames(
item.ruleGroups.map((g) => g.name),
4,
"未设置",
)}${item.defaultRuleGroups && item.ruleGroups.length ? "(未单独设置)" : ""}`
: null,
},
{ label: "自动补全", value: item ? autoFillText(item.autoFill) : null },
{ label: "上次更新", value: item ? formatDateTime(item.lastUpdatedAt) : null },
{
label: "主动搜索",
value: sub.lastSearchedAt
? `上次 ${relTime(sub.lastSearchedAt)}${sub.nextSearchAt ? ` · 下次 ${relTime(sub.nextSearchAt)}` : item?.searchEnabled === false ? " · 周期搜索已关" : ""}`
: "还没搜索过",
},
{ label: "状态说明", value: sub.statusReason },
{ label: "创建", value: `${formatDateTime(sub.createdAt)} · ${actorText(sub.createdBy)}`, wide: true },
...(sub.mediaEntityId
? [
{
label: "作品",
value: (
<Link
className="underline-offset-4 hover:underline"
to={consolePath("library", sub.mediaEntityId)}
>
查看作品
</Link>
),
},
]
: []),
]}
/>
</Panel>
{sub.mediaEntityId ? (
<Panel title="收录版本规则" description="条件、阶梯与升级;新建的收录版本排在最后">
<SlotsPanel mediaEntityId={sub.mediaEntityId} subscriptionId={sub.id} />
</Panel>
) : null}
</div>
</div>
<CellSheet
sel={sel}
subscriptionId={sub.id}
mediaEntityId={sub.mediaEntityId}
canFill={tracking}
onClose={() => setSel(null)}
/>
{editing ? <SubscriptionFormDialog sub={sub} title={title} onClose={() => setEditing(false)} /> : null}
</>
);
}