Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
import type { CoverageCell, SubscriptionCoverage } from "@umt/contracts";
|
|
import { Clock, Download, ExternalLink, Wand2 } from "lucide-react";
|
|
import { useState } from "react";
|
|
import { Link } from "react-router";
|
|
import { toast } from "sonner";
|
|
import { useFillSlot } from "~/api/subscriptions.ts";
|
|
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
|
import { KvList } from "~/components/console/kv-list.tsx";
|
|
import { consolePath } from "~/components/console/nav.ts";
|
|
import { Button } from "~/components/ui/button.tsx";
|
|
import { errorMessage } from "~/lib/api.ts";
|
|
import { cn } from "~/lib/utils.ts";
|
|
import {
|
|
CELL_LABEL,
|
|
type CellKind,
|
|
type CoverageColumn,
|
|
cellKind,
|
|
columnLabel,
|
|
PLAN_STAGE,
|
|
SKIP_REASON,
|
|
seasonSlices,
|
|
seasonTitle,
|
|
} from "./model.ts";
|
|
|
|
// 覆盖矩阵(设计 §2.1):列 = 集(电影 = 内容版本),行 = 收录版本,首行"库内任一版本"。
|
|
// 底色无彩,只有状态用状态色:已入库 success、缺失 destructive、可升级/延后 warning;在途用斜纹。
|
|
|
|
type Row = SubscriptionCoverage["rows"][number];
|
|
|
|
/** 格样式:每种都配文字(悬浮 + 侧栏),不只靠颜色 */
|
|
const CELL_CLS: Record<CellKind, string> = {
|
|
have: "border-success/50 bg-success/70",
|
|
upgradable:
|
|
"border-success/50 bg-success/70 after:absolute after:top-0 after:right-0 after:h-0 after:w-0 after:border-t-[7px] after:border-l-[7px] after:border-t-warning after:border-l-transparent",
|
|
inflight:
|
|
"border-foreground/40 bg-[repeating-linear-gradient(135deg,color-mix(in_oklab,var(--color-foreground)_45%,transparent)_0_2px,transparent_2px_5px)]",
|
|
deferred: "border-warning/70 bg-transparent",
|
|
missing: "border-destructive/70 bg-transparent",
|
|
suspected: "border-dashed border-destructive/60 bg-transparent",
|
|
not_aired: "border-transparent bg-foreground/[0.10]",
|
|
skipped: "border-transparent bg-foreground/[0.04]",
|
|
};
|
|
|
|
const LEGEND: CellKind[] = [
|
|
"have",
|
|
"upgradable",
|
|
"inflight",
|
|
"missing",
|
|
"suspected",
|
|
"deferred",
|
|
"not_aired",
|
|
"skipped",
|
|
];
|
|
|
|
function CellSwatch({ k, className }: { k: CellKind; className?: string }) {
|
|
return (
|
|
<span
|
|
aria-hidden
|
|
className={cn(
|
|
"relative inline-grid h-3.5 w-3.5 shrink-0 place-items-center overflow-hidden rounded-tight border",
|
|
CELL_CLS[k],
|
|
className,
|
|
)}
|
|
>
|
|
{k === "deferred" ? <Clock className="h-2.5 w-2.5 text-warning" /> : null}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function CoverageLegend() {
|
|
return (
|
|
<ul className="flex flex-wrap gap-x-3 gap-y-1 text-meta text-muted-foreground">
|
|
{LEGEND.map((k) => (
|
|
<li key={k} className="flex items-center gap-1.5">
|
|
<CellSwatch k={k} />
|
|
{CELL_LABEL[k]}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
function cellTitle(row: Row, col: CoverageColumn, c: CoverageCell) {
|
|
const k = cellKind(c);
|
|
const parts = [`${row.name} · ${columnLabel(col)}`, CELL_LABEL[k]];
|
|
if (c.quality?.label) parts.push(c.quality.label);
|
|
if (c.pri !== null) parts.push(`pri ${c.pri}`);
|
|
if (c.planStage) parts.push(PLAN_STAGE[c.planStage] ?? c.planStage);
|
|
if (c.reason && SKIP_REASON[c.reason]) parts.push(SKIP_REASON[c.reason]);
|
|
return parts.join(" · ");
|
|
}
|
|
|
|
export type SelectedCell = { row: Row; col: CoverageColumn; cell: CoverageCell; season: string };
|
|
|
|
export function CoverageMatrix({
|
|
cov,
|
|
seasonIndex,
|
|
onSelect,
|
|
}: {
|
|
cov: SubscriptionCoverage;
|
|
seasonIndex: number;
|
|
onSelect: (s: SelectedCell) => void;
|
|
}) {
|
|
const season = cov.seasons[seasonIndex];
|
|
const slice = seasonSlices(cov)[seasonIndex];
|
|
if (!season || !slice) return null;
|
|
const cols = season.columns;
|
|
if (cols.length === 0)
|
|
return (
|
|
<p className="py-6 text-center text-caption text-muted-foreground">这一季还没有集信息。设置总集数后按集显示。</p>
|
|
);
|
|
const sTitle = seasonTitle(season, cov.mediaKind);
|
|
const wide = cov.mediaKind === "movie";
|
|
return (
|
|
<div className="-mx-4 overflow-x-auto px-4 sm:-mx-5 sm:px-5">
|
|
<table className="border-separate border-spacing-[3px] text-meta" aria-label={`${sTitle}覆盖矩阵`}>
|
|
<thead>
|
|
<tr>
|
|
<th
|
|
scope="col"
|
|
className="sticky left-0 z-10 bg-surface-raised pr-2 text-left font-normal text-muted-foreground"
|
|
>
|
|
收录版本
|
|
</th>
|
|
{cols.map((c) => (
|
|
<th
|
|
key={c.unitKey}
|
|
scope="col"
|
|
title={
|
|
[c.airDate ? `播出 ${c.airDate}` : null, c.temporary ? "编号待确认" : null]
|
|
.filter(Boolean)
|
|
.join(" · ") || undefined
|
|
}
|
|
className={cn(
|
|
"px-0 font-mono font-normal text-muted-foreground tabular-nums",
|
|
wide ? "min-w-24" : "w-7 min-w-7",
|
|
c.temporary && "italic",
|
|
)}
|
|
>
|
|
{wide ? columnLabel(c) : (c.episode ?? columnLabel(c))}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{cov.rows.map((row) => {
|
|
const cells = row.cells.slice(slice.start, slice.end);
|
|
const any = row.slotId === null;
|
|
return (
|
|
<tr key={row.slotId ?? "any"}>
|
|
<th
|
|
scope="row"
|
|
className={cn(
|
|
"sticky left-0 z-10 max-w-36 truncate bg-surface-raised pr-2 text-left font-medium",
|
|
any ? "text-muted-foreground" : "text-foreground",
|
|
)}
|
|
title={row.name}
|
|
>
|
|
{row.name}
|
|
{row.primary ? <span className="ml-1 font-normal text-muted-foreground">主</span> : null}
|
|
</th>
|
|
{cells.map((c, i) => {
|
|
const col = cols[i]!;
|
|
const k = cellKind(c);
|
|
return (
|
|
<td key={c.unitKey} className="p-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelect({ row, col, cell: c, season: sTitle })}
|
|
title={cellTitle(row, col, c)}
|
|
aria-label={cellTitle(row, col, c)}
|
|
className={cn(
|
|
"relative grid cursor-pointer place-items-center overflow-hidden rounded-tight border outline-none transition-transform duration-micro hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring",
|
|
wide ? "h-7 w-full px-2" : "h-7 w-7",
|
|
CELL_CLS[k],
|
|
any && "opacity-80",
|
|
)}
|
|
>
|
|
{k === "deferred" ? <Clock className="h-3 w-3 text-warning" /> : null}
|
|
{wide && c.quality?.label ? (
|
|
<span className="truncate text-micro font-medium text-success-foreground">
|
|
{c.quality.label}
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 点格打开:资产 / 质量 / pri / 计划 / 原因 / 可升级 / 延后 → 手动补全、跳下载面板 */
|
|
export function CellSheet({
|
|
sel,
|
|
subscriptionId,
|
|
mediaEntityId,
|
|
canFill,
|
|
onClose,
|
|
}: {
|
|
sel: SelectedCell | null;
|
|
subscriptionId: string;
|
|
mediaEntityId: string | null;
|
|
canFill: boolean;
|
|
onClose: () => void;
|
|
}) {
|
|
const fill = useFillSlot();
|
|
const [done, setDone] = useState<string | null>(null);
|
|
const c = sel?.cell;
|
|
const k = c ? cellKind(c) : null;
|
|
const slotId = sel?.row.slotId ?? null;
|
|
const fillable = !!slotId && !!c && (c.state === "missing" || c.upgradable);
|
|
const doFill = () =>
|
|
slotId &&
|
|
c &&
|
|
fill.mutate(
|
|
{ id: subscriptionId, slotId, unitKeys: [c.unitKey] },
|
|
{
|
|
onSuccess: (r) => {
|
|
setDone(c.unitKey);
|
|
toast.success(r.deduplicated ? "补全任务已在排队" : "已开始补全这一集");
|
|
},
|
|
onError: (e) => toast.error(errorMessage(e, "补全失败")),
|
|
},
|
|
);
|
|
return (
|
|
<DetailSheet
|
|
open={!!sel}
|
|
onOpenChange={(o) => {
|
|
if (!o) {
|
|
setDone(null);
|
|
onClose();
|
|
}
|
|
}}
|
|
title={sel ? `${sel.season} · ${columnLabel(sel.col)}` : ""}
|
|
description={sel ? `${sel.row.name}${sel.row.slotId === null ? "(作品全部文件)" : ""}` : null}
|
|
footer={
|
|
sel && c ? (
|
|
<>
|
|
{c.planId ? (
|
|
<Button asChild variant="outline">
|
|
<Link to={`${consolePath("downloads")}?plan=${c.planId}`}>
|
|
<Download />
|
|
查看下载
|
|
</Link>
|
|
</Button>
|
|
) : null}
|
|
{fillable ? (
|
|
<Button
|
|
onClick={doFill}
|
|
disabled={!canFill || fill.isPending || done === c.unitKey}
|
|
title={canFill ? undefined : "订阅不在追踪中,先恢复"}
|
|
>
|
|
<Wand2 />
|
|
{done === c.unitKey ? "已提交" : c.upgradable ? "补全更好版本" : "手动补全"}
|
|
</Button>
|
|
) : null}
|
|
</>
|
|
) : null
|
|
}
|
|
>
|
|
{sel && c && k ? (
|
|
<>
|
|
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
|
<CellSwatch k={k} className="h-4 w-4" />
|
|
{CELL_LABEL[k]}
|
|
</div>
|
|
<DetailSection title="详情">
|
|
<KvList
|
|
items={[
|
|
{ label: "单元", value: sel.col.unitKey, mono: true },
|
|
...(sel.col.airDate ? [{ label: "播出日期", value: sel.col.airDate }] : []),
|
|
...(sel.col.temporary ? [{ label: "编号待确认", value: "这一集的编号还没确认" }] : []),
|
|
{
|
|
label: "质量",
|
|
value:
|
|
c.quality?.label ?? ([c.quality?.resolution, c.quality?.source].filter(Boolean).join(" ") || null),
|
|
},
|
|
{ label: "pri", value: c.pri !== null ? String(c.pri) : null },
|
|
{ label: "可升级", value: c.state === "have" ? (c.upgradable ? "是,低于目标档位" : "否") : null },
|
|
{
|
|
label: "文件",
|
|
value: c.assetId ? (
|
|
mediaEntityId ? (
|
|
<Link
|
|
className="inline-flex items-center gap-1 underline-offset-4 hover:underline"
|
|
to={consolePath("library", mediaEntityId)}
|
|
>
|
|
<span className="font-mono">{c.assetId.slice(0, 8)}</span>
|
|
<ExternalLink className="h-3 w-3" />
|
|
</Link>
|
|
) : (
|
|
<span className="font-mono">{c.assetId}</span>
|
|
)
|
|
) : null,
|
|
},
|
|
{
|
|
label: "下载",
|
|
value: c.planId
|
|
? `${PLAN_STAGE[c.planStage ?? ""] ?? c.planStage ?? "下载中"} · ${c.planId.slice(0, 8)}`
|
|
: null,
|
|
},
|
|
{ label: "原因", value: c.reason ? (SKIP_REASON[c.reason] ?? c.reason) : null },
|
|
{ label: "延后", value: c.deferred ? "有资源等待重新评估(预算用完或自动补全已关)" : null },
|
|
]}
|
|
/>
|
|
</DetailSection>
|
|
{fillable ? (
|
|
<p className="text-meta text-muted-foreground">
|
|
手动补全不受自动补全开关和补全预算限制,但仍受每日总预算和下载盘阈值限制。
|
|
</p>
|
|
) : null}
|
|
{sel.row.slotId === null && c.state === "missing" ? (
|
|
<p className="text-meta text-muted-foreground">这一行汇总全部版本。要补全,点具体收录版本那一行的格。</p>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
</DetailSheet>
|
|
);
|
|
}
|