feat(episode-mapping): 映射规则接口与模拟预览、待处理确认 / 忽略、作品页集数映射分区与订阅摘要

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 19:28:09 +08:00
1 parent fe597187dd
commit 45a114263b
26 files changed
+2770 -9

No files matched your search

@@ -0,0 +1,451 @@
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 {
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 ? "已完结" : "未完结"}</span>
</div>
);
}
function RuleRow({
rule,
editing,
onEdit,
write,
}: {
rule: EpisodeMappingRuleRow;
editing: boolean;
onEdit: () => void;
write: ReturnType<typeof useEpisodeMappingWrite>;
}) {
const reason = disabledReasonText(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>
{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>
{" · "}
{actorText(rule.createdById ? `${rule.createdByType}:${rule.createdById}` : rule.createdByType)}
</div>
{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>
);
}