Files
UHD_Media_Tool/apps/web/app/components/episode-mapping/mapping-panel.tsx
T
rootandClaude Opus 5.5 fa76d7563c feat(控制台): 去掉待处理中心,问题回到发生处
- 侧栏与命令面板没有「待处理」;/console/inbox?kind= 按问题类型跳到整理记录「需要处理」、下载面板、运行状态或订阅
- 整理记录加「需要处理」筛选,行内写原因与推荐动作(指定作品 / 重新整理 / 让智能体识别 / 替换·保留两者·跳过 / 忽略),
  详情页同样可处理;处理按钮从原待处理页抽成 components/decisions/decision-actions.tsx 复用
- 铃铛只放等你批准的智能体操作与系统健康;首页「要我处理」与运行状态「需要关注」放汇总计数,运行状态加智能体用量
- 设置 → 智能体:预算卡改为「用量与限制」(只剩单次工具调用与时长,附花费统计),自动调查卡加「整理识别不到作品时叫智能体识别」
- 集数待确认的原因显示在作品页集数映射(可确认建议规则 / 智能体建议)与订阅详情;未识别资源在媒体库「未识别」里直接选候选或指定
- 下载、站点、任务里指向待处理的链接改到对应页面

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 16:47:25 +08:00

467 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { EpisodeIssuesList } from "./issues.tsx";
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}
<EpisodeIssuesList entityId={entityId} />
</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>
);
}