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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user