控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?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
310 lines
13 KiB
TypeScript
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}
|
|
</>
|
|
);
|
|
}
|