Files

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>
);
}