Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
465 lines
16 KiB
TypeScript
465 lines
16 KiB
TypeScript
import type {
|
||
EpisodeMappingPreview,
|
||
EpisodeMappingPreviewInput,
|
||
EpisodeMappingRuleRow,
|
||
EpisodeMappingRuleSpec,
|
||
EpisodeSpecialKind,
|
||
EpisodeSuggestion,
|
||
} from "@umt/contracts";
|
||
import { Pause, PencilLine, Play, Plus, Trash2, Wand2 } from "lucide-react";
|
||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||
import { useLocation, useSearchParams } from "react-router";
|
||
import { toast } from "sonner";
|
||
import { useEpisodeMappingPreview, useEpisodeMappingRules, useEpisodeMappingWrite } from "~/api/episode-mapping.ts";
|
||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||
import { FormField } from "~/components/console/form-field.tsx";
|
||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||
import { Chip, Panel } from "~/components/library/parts.tsx";
|
||
import { Button } from "~/components/ui/button.tsx";
|
||
import { Input } from "~/components/ui/input.tsx";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||
import { errorMessage } from "~/lib/api.ts";
|
||
import { actorText } from "~/lib/labels.ts";
|
||
import {
|
||
agentRuleInfo,
|
||
disabledReasonText,
|
||
draftFromSearch,
|
||
draftOfRule,
|
||
EMPTY_DRAFT,
|
||
groupMethodsText,
|
||
MAPPING_ANCHOR,
|
||
RULE_KIND_LABEL,
|
||
type RuleDraft,
|
||
reresolveToast,
|
||
ruleText,
|
||
SPECIAL_LABEL,
|
||
scopeText,
|
||
specOfDraft,
|
||
} from "./model.ts";
|
||
import { PreviewTable } from "./preview-table.tsx";
|
||
|
||
// 作品页「集数映射」分区(批次 4):季表摘要、规则列表(增改停删)、草稿规则边改边预览、应用后提示重算了多少条。
|
||
|
||
/** 草稿改动后等这么久再预览 */
|
||
const PREVIEW_DEBOUNCE_MS = 400;
|
||
|
||
function useDebounced<T>(value: T, ms: number): T {
|
||
const [v, setV] = useState(value);
|
||
useEffect(() => {
|
||
const t = setTimeout(() => setV(value), ms);
|
||
return () => clearTimeout(t);
|
||
}, [value, ms]);
|
||
return v;
|
||
}
|
||
|
||
function SeasonSummary({ p }: { p: EpisodeMappingPreview["seasons"] }) {
|
||
const main = p.seasons.filter((s) => s.season >= 1);
|
||
if (!main.length) return <p className="text-caption text-muted-foreground">还没有季表,刷新元数据后出现。</p>;
|
||
return (
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
{main.map((s) => (
|
||
<Chip key={s.season}>
|
||
第 {s.season} 季 · {s.total != null ? `${s.total} 集` : "总集数未知"}
|
||
{s.season === p.latestSeason ? " · 最新" : ""}
|
||
</Chip>
|
||
))}
|
||
<span className="text-meta text-muted-foreground">
|
||
{p.ended ? "已完结" : "未完结"}
|
||
{p.structure ? ` · 按剧集组「${p.structure.name}」分季` : ""}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RuleRow({
|
||
rule,
|
||
editing,
|
||
onEdit,
|
||
write,
|
||
}: {
|
||
rule: EpisodeMappingRuleRow;
|
||
editing: boolean;
|
||
onEdit: () => void;
|
||
write: ReturnType<typeof useEpisodeMappingWrite>;
|
||
}) {
|
||
const reason = disabledReasonText(rule);
|
||
const agent = agentRuleInfo(rule);
|
||
const run = (vars: Parameters<typeof write.mutateAsync>[0], verb: string) =>
|
||
write.mutateAsync(vars).then(
|
||
(r) => toast.success(reresolveToast(r.reresolve, verb)),
|
||
(e) => {
|
||
toast.error(errorMessage(e));
|
||
throw e;
|
||
},
|
||
);
|
||
return (
|
||
<li className="flex flex-wrap items-start gap-x-3 gap-y-1.5 border-t border-border/60 py-2.5 first:border-t-0">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="text-caption font-medium text-foreground">{scopeText(rule.season, rule.releaseGroup)}</span>
|
||
<StatusBadge tone={rule.status === "active" ? "success" : "neutral"}>
|
||
{rule.status === "active" ? "启用" : "停用"}
|
||
</StatusBadge>
|
||
{agent ? <Chip>{agent.label}</Chip> : null}
|
||
{editing ? <span className="text-meta text-muted-foreground">修改中</span> : null}
|
||
</div>
|
||
<div className="mt-0.5 text-meta text-muted-foreground">
|
||
{RULE_KIND_LABEL[rule.kind]}:<span className="font-mono text-foreground">{ruleText(rule)}</span>
|
||
{agent ? null : (
|
||
<>
|
||
{" · "}
|
||
{actorText(rule.createdById ? `${rule.createdByType}:${rule.createdById}` : rule.createdByType)}
|
||
</>
|
||
)}
|
||
</div>
|
||
{agent?.reasons.length ? (
|
||
<div className="mt-0.5 text-meta text-muted-foreground">依据:{agent.reasons.join(";")}</div>
|
||
) : null}
|
||
{reason ? <div className="mt-0.5 text-meta text-muted-foreground">{reason}</div> : null}
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
<Button variant="outline" size="sm" onClick={onEdit}>
|
||
<PencilLine />
|
||
修改
|
||
</Button>
|
||
{rule.status === "active" ? (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={write.isPending}
|
||
onClick={() =>
|
||
void run({ op: "update", id: rule.id, body: { status: "disabled" } }, "已停用").catch(() => {})
|
||
}
|
||
>
|
||
<Pause />
|
||
停用
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={write.isPending}
|
||
onClick={() =>
|
||
void run({ op: "update", id: rule.id, body: { status: "active" } }, "已启用").catch(() => {})
|
||
}
|
||
>
|
||
<Play />
|
||
启用
|
||
</Button>
|
||
)}
|
||
<ConfirmDialog
|
||
trigger={
|
||
<Button variant="ghost" size="sm" aria-label="删除规则">
|
||
<Trash2 />
|
||
</Button>
|
||
}
|
||
title="删除这条规则?"
|
||
description={`删除后按作品重算:「${scopeText(rule.season, rule.releaseGroup)}」的资源回到自动判定。不能恢复。`}
|
||
confirmLabel="删除"
|
||
destructive
|
||
onConfirm={() => run({ op: "delete", id: rule.id }, "已删除")}
|
||
/>
|
||
</div>
|
||
</li>
|
||
);
|
||
}
|
||
|
||
function DraftEditor({
|
||
draft,
|
||
onChange,
|
||
error,
|
||
groups,
|
||
}: {
|
||
draft: RuleDraft;
|
||
onChange: (d: RuleDraft) => void;
|
||
error: string | null;
|
||
groups: string[];
|
||
}) {
|
||
const id = useId();
|
||
const f = (k: string) => `${id}-${k}`;
|
||
const set = (patch: Partial<RuleDraft>) => onChange({ ...draft, ...patch });
|
||
return (
|
||
<div className="grid gap-x-3 sm:grid-cols-2 lg:grid-cols-4">
|
||
<FormField label="来源季" htmlFor={f("season")} hint="空着 = 任意季">
|
||
<Input
|
||
id={f("season")}
|
||
inputMode="numeric"
|
||
value={draft.season}
|
||
onChange={(e) => set({ season: e.target.value })}
|
||
/>
|
||
</FormField>
|
||
<FormField label="发布组" htmlFor={f("group")} hint="空着 = 任意发布组">
|
||
<Input
|
||
id={f("group")}
|
||
list={f("groups")}
|
||
value={draft.releaseGroup}
|
||
onChange={(e) => set({ releaseGroup: e.target.value })}
|
||
/>
|
||
<datalist id={f("groups")}>
|
||
{groups.map((g) => (
|
||
<option key={g} value={g} />
|
||
))}
|
||
</datalist>
|
||
</FormField>
|
||
<FormField label="类型" htmlFor={f("kind")}>
|
||
<Select value={draft.kind} onValueChange={(v) => set({ kind: v as RuleDraft["kind"] })}>
|
||
<SelectTrigger id={f("kind")} className="w-full">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{(Object.keys(RULE_KIND_LABEL) as RuleDraft["kind"][]).map((k) => (
|
||
<SelectItem key={k} value={k}>
|
||
{RULE_KIND_LABEL[k]}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</FormField>
|
||
{draft.kind === "expression" ? (
|
||
<FormField label="目标季" htmlFor={f("target")} hint="空着 = 来源季">
|
||
<Input
|
||
id={f("target")}
|
||
inputMode="numeric"
|
||
value={draft.targetSeason}
|
||
onChange={(e) => set({ targetSeason: e.target.value })}
|
||
/>
|
||
</FormField>
|
||
) : draft.kind === "special" ? (
|
||
<FormField label="映射为" htmlFor={f("special")}>
|
||
<Select value={draft.special} onValueChange={(v) => set({ special: v as EpisodeSpecialKind })}>
|
||
<SelectTrigger id={f("special")} className="w-full">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{(Object.keys(SPECIAL_LABEL) as EpisodeSpecialKind[]).map((k) => (
|
||
<SelectItem key={k} value={k}>
|
||
{SPECIAL_LABEL[k]}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</FormField>
|
||
) : (
|
||
<div />
|
||
)}
|
||
{draft.kind === "expression" ? (
|
||
<FormField
|
||
label="偏移表达式"
|
||
htmlFor={f("expr")}
|
||
className="sm:col-span-2 lg:col-span-4"
|
||
error={error}
|
||
hint="EP 是来源集号:EP-12 表示减 12,EP*2-1 表示双集拆分"
|
||
>
|
||
<Input
|
||
id={f("expr")}
|
||
className="font-mono"
|
||
value={draft.expression}
|
||
onChange={(e) => set({ expression: e.target.value })}
|
||
aria-invalid={!!error}
|
||
/>
|
||
</FormField>
|
||
) : draft.kind === "table" ? (
|
||
<FormField
|
||
label="对照表"
|
||
htmlFor={f("table")}
|
||
className="sm:col-span-2 lg:col-span-4"
|
||
error={error}
|
||
hint="每行一条:26=S02E01,区间写 27-28=S02E02"
|
||
>
|
||
<Textarea
|
||
id={f("table")}
|
||
className="font-mono"
|
||
rows={4}
|
||
value={draft.table}
|
||
onChange={(e) => set({ table: e.target.value })}
|
||
aria-invalid={!!error}
|
||
/>
|
||
</FormField>
|
||
) : error ? (
|
||
<p className="text-meta text-destructive sm:col-span-2 lg:col-span-4">{error}</p>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function GroupStats({
|
||
groups,
|
||
onUseSuggestion,
|
||
}: {
|
||
groups: EpisodeMappingPreview["groups"];
|
||
onUseSuggestion: (s: EpisodeSuggestion) => void;
|
||
}) {
|
||
if (!groups.length) return null;
|
||
return (
|
||
<ul className="space-y-1.5">
|
||
{groups.map((g) => (
|
||
<li key={g.releaseGroup ?? "-"} className="flex flex-wrap items-center gap-x-3 gap-y-1 text-caption">
|
||
<span className="text-foreground">{groupMethodsText(g)}</span>
|
||
{g.pending ? <StatusBadge tone="warning">待确认 {g.pending} 条</StatusBadge> : null}
|
||
{g.suggestion ? (
|
||
<Button variant="ghost" size="sm" onClick={() => onUseSuggestion(g.suggestion!)}>
|
||
<Wand2 />
|
||
试试建议 {g.suggestion.expression} → 第 {g.suggestion.targetSeason} 季
|
||
</Button>
|
||
) : null}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
);
|
||
}
|
||
|
||
export function EpisodeMappingPanel({ entityId }: { entityId: string }) {
|
||
const [sp] = useSearchParams();
|
||
const location = useLocation();
|
||
const rules = useEpisodeMappingRules(entityId);
|
||
const write = useEpisodeMappingWrite(entityId);
|
||
const [draft, setDraft] = useState<RuleDraft | null>(() => draftFromSearch(sp));
|
||
const [editingId, setEditingId] = useState<string | null>(null);
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
|
||
// 从待处理「修改」跳来:预填草稿并滚到这里
|
||
useEffect(() => {
|
||
const d = draftFromSearch(sp);
|
||
if (d) {
|
||
setDraft(d);
|
||
setEditingId(null);
|
||
}
|
||
if (location.hash === `#${MAPPING_ANCHOR}`) ref.current?.scrollIntoView({ block: "start" });
|
||
}, [sp, location.hash]);
|
||
|
||
const parsed = useMemo(() => (draft ? specOfDraft(draft) : { spec: null, error: null }), [draft]);
|
||
const input: EpisodeMappingPreviewInput = useMemo(() => {
|
||
const spec = parsed.spec;
|
||
if (!draft) return {};
|
||
return {
|
||
...(spec ? { draft: spec, season: spec.season ?? null, releaseGroup: spec.releaseGroup ?? null } : {}),
|
||
...(editingId ? { replaceRuleId: editingId } : {}),
|
||
};
|
||
}, [draft, parsed.spec, editingId]);
|
||
const debounced = useDebounced(input, PREVIEW_DEBOUNCE_MS);
|
||
const preview = useEpisodeMappingPreview(entityId, debounced);
|
||
const groupNames = useMemo(
|
||
() => (preview.data?.groups ?? []).map((g) => g.releaseGroup).filter((g): g is string => !!g),
|
||
[preview.data],
|
||
);
|
||
|
||
const startNew = () => {
|
||
setEditingId(null);
|
||
setDraft({ ...EMPTY_DRAFT });
|
||
};
|
||
const cancel = () => {
|
||
setDraft(null);
|
||
setEditingId(null);
|
||
};
|
||
const apply = (spec: EpisodeMappingRuleSpec) =>
|
||
write
|
||
.mutateAsync(editingId ? { op: "update", id: editingId, body: spec } : { op: "create", entityId, body: spec })
|
||
.then(
|
||
(r) => {
|
||
toast.success(reresolveToast(r.reresolve));
|
||
cancel();
|
||
},
|
||
(e) => toast.error(errorMessage(e, "应用失败")),
|
||
);
|
||
const adoptSuggestion = (s: EpisodeSuggestion) => {
|
||
setEditingId(null);
|
||
setDraft({
|
||
...EMPTY_DRAFT,
|
||
season: s.season != null ? String(s.season) : "",
|
||
releaseGroup: s.releaseGroup ?? "",
|
||
expression: s.expression,
|
||
targetSeason: String(s.targetSeason),
|
||
});
|
||
};
|
||
|
||
const list = rules.data?.items ?? [];
|
||
return (
|
||
<div id={MAPPING_ANCHOR} ref={ref} className="scroll-mt-4">
|
||
<Panel
|
||
title="集数映射"
|
||
description="发布者写的季集对不上作品季表时,按规则换成媒体库用的季集"
|
||
actions={
|
||
draft ? null : (
|
||
<Button variant="outline" size="sm" onClick={startNew}>
|
||
<Plus />
|
||
新建规则
|
||
</Button>
|
||
)
|
||
}
|
||
bodyClassName="space-y-4"
|
||
>
|
||
{preview.data ? <SeasonSummary p={preview.data.seasons} /> : null}
|
||
|
||
{rules.isPending ? (
|
||
<LoadingState rows={2} />
|
||
) : rules.isError ? (
|
||
<ErrorState plain error={rules.error} onRetry={() => rules.refetch()} />
|
||
) : list.length ? (
|
||
<ul>
|
||
{list.map((r) => (
|
||
<RuleRow
|
||
key={r.id}
|
||
rule={r}
|
||
editing={editingId === r.id}
|
||
write={write}
|
||
onEdit={() => {
|
||
setEditingId(r.id);
|
||
setDraft(draftOfRule(r));
|
||
}}
|
||
/>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<p className="text-caption text-muted-foreground">
|
||
还没有规则。资源的集数先按原号、绝对集号和播出日期自动判定,对不上的进待处理。
|
||
</p>
|
||
)}
|
||
|
||
{draft ? (
|
||
<div className="rounded-xl border border-border/60 px-4 pt-3 pb-2">
|
||
<div className="mb-2 text-caption font-medium text-foreground">{editingId ? "修改规则" : "新建规则"}</div>
|
||
<DraftEditor draft={draft} onChange={setDraft} error={parsed.error} groups={groupNames} />
|
||
<div className="flex flex-wrap items-center gap-2 pb-1">
|
||
<Button
|
||
size="sm"
|
||
disabled={!parsed.spec || write.isPending}
|
||
onClick={() => parsed.spec && void apply(parsed.spec)}
|
||
>
|
||
应用
|
||
</Button>
|
||
<Button variant="outline" size="sm" onClick={cancel}>
|
||
取消
|
||
</Button>
|
||
{preview.data && parsed.spec ? (
|
||
<span className="text-meta text-muted-foreground">预览里 {preview.data.changed} 条会变化</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{preview.data ? <GroupStats groups={preview.data.groups} onUseSuggestion={adoptSuggestion} /> : null}
|
||
</Panel>
|
||
<Panel
|
||
className="mt-3"
|
||
title={draft ? "模拟预览" : "近期资源"}
|
||
description={draft ? "按草稿规则试算,不会保存;应用后才按作品重算" : "最近 100 条已绑定资源按当前规则的判定"}
|
||
bodyClassName="p-0 sm:p-0"
|
||
>
|
||
{preview.isPending ? (
|
||
<div className="p-4">
|
||
<LoadingState rows={3} />
|
||
</div>
|
||
) : preview.isError ? (
|
||
<div className="p-4">
|
||
<ErrorState plain error={preview.error} onRetry={() => preview.refetch()} />
|
||
</div>
|
||
) : (
|
||
<PreviewTable items={preview.data.items} hasDraft={!!input.draft} />
|
||
)}
|
||
</Panel>
|
||
</div>
|
||
);
|
||
}
|