Files
UHD_Media_Tool/apps/web/app/components/episode-mapping/decision.tsx
T

325 lines
13 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 {
Decision,
DecisionOption,
EpisodeMappingPreview,
EpisodeMappingRuleSpec,
EpisodeResolutionView,
EpisodeResultBrief,
EpisodeSimulationSummary,
} from "@umt/contracts";
import { BellOff, Bot, Check, PencilLine } from "lucide-react";
import { useId, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useResolveDecision } from "~/api/decisions.ts";
import { useEpisodeMappingPreview } from "~/api/episode-mapping.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { canonicalText, episodeCode, mappingHref, RULE_KIND_LABEL, ruleText, scopeText, sourceText } from "./model.ts";
// 待处理中心里的「集数待确认」(episode_mapping):来源季集、原因、建议规则与它的模拟结果;
// 操作「确认」(建建议规则并重算)、「修改」(跳作品页集数映射并预填草稿)、「忽略」(可同时建忽略规则)。
// 智能体建议(批次 5):后台调查没有写规则的授权时,把模拟通过的规则和理由留在事项上,「确认智能体建议」一键建规则
// (服务端建规则前按当前资源再模拟一次)。
export const EPISODE_CONFIRM_KEY = "confirm_suggestion";
export const EPISODE_CONFIRM_AGENT_KEY = "confirm_agent_suggestion";
export const EPISODE_IGNORE_RULE_KEY = "ignore_rule";
export interface AgentSuggestion {
rule: EpisodeMappingRuleSpec;
reasons: string[];
simulation: EpisodeSimulationSummary;
samples: { title: string; source: EpisodeResolutionView["source"]; after: EpisodeResultBrief }[];
}
interface Suggestion {
expression: string;
targetSeason: number;
samples: { source: { start: number; end: number }; target: { season: number; episode: number }; airDate: string }[];
}
export interface EpisodeDecisionView {
entityId: string | null;
season: number | null;
releaseGroup: string | null;
releases: { id: string; title: string; source: string }[];
count: number;
suggestion: Suggestion | null;
agentSuggestion: AgentSuggestion | null;
confirm: DecisionOption | null;
confirmAgent: DecisionOption | null;
ignoreRule: DecisionOption | null;
}
const UUID = /^[0-9a-f-]{36}$/;
/** 从事项证据里取界面要的字段(后端 writeEpisodeDecision 写入的结构) */
export function episodeDecisionView(d: Decision): EpisodeDecisionView {
const e = d.evidence as Record<string, unknown>;
const entity = typeof e.mediaEntityId === "string" && UUID.test(e.mediaEntityId) ? e.mediaEntityId : null;
const releases = Array.isArray(e.releases)
? (e.releases as { sourceReleaseId?: string; title?: string; source?: EpisodeResolutionView["source"] | null }[])
.filter((r) => r.sourceReleaseId)
.map((r) => ({
id: r.sourceReleaseId!,
title: r.title ?? "",
source: r.source ? sourceText({ source: r.source }) : "—",
}))
: [];
const s = e.suggestion as Suggestion | null | undefined;
const a = e.agentSuggestion as AgentSuggestion | null | undefined;
return {
entityId: entity ?? (d.subjectType === "media_entity" ? d.subjectId : null),
season: typeof e.season === "number" ? e.season : null,
releaseGroup: typeof e.releaseGroup === "string" ? e.releaseGroup : null,
releases,
count: typeof e.count === "number" ? e.count : releases.length,
suggestion: s && typeof s.expression === "string" ? s : null,
agentSuggestion:
a?.rule && a.simulation
? {
...a,
reasons: Array.isArray(a.reasons) ? a.reasons : [],
samples: Array.isArray(a.samples) ? a.samples : [],
}
: null,
confirm: d.options.find((o) => o.key === EPISODE_CONFIRM_KEY) ?? null,
confirmAgent: d.options.find((o) => o.key === EPISODE_CONFIRM_AGENT_KEY) ?? null,
ignoreRule: d.options.find((o) => o.key === EPISODE_IGNORE_RULE_KEY) ?? null,
};
}
/** 建议规则套用到同季同组近期资源的结果一句话 */
export function simulationText(p: EpisodeMappingPreview | undefined): string | null {
if (!p) return null;
const total = p.items.length;
if (!total) return null;
const fail = p.items.filter((i) => i.validation === "fail").length;
return fail ? `套用后 ${total - fail} 条对得上,${fail} 条仍对不上` : `套用后 ${total} 条都对得上`;
}
/** 智能体建议的模拟结果一句话 */
export function agentSimulationText(s: EpisodeSimulationSummary): string {
const head =
s.resolved === s.samples ? `模拟:这 ${s.samples} 条都对得上` : `模拟:${s.samples} 条里 ${s.resolved} 条对得上`;
return `${head}${s.regressions ? `,会影响 ${s.regressions} 条已对上的资源` : ",不影响已对上的资源"}`;
}
/** 智能体建议:规则、理由、模拟结果与样本套用后的季集 */
export function AgentSuggestionBody({ s }: { s: AgentSuggestion }) {
return (
<div className="space-y-1">
<div>
<span className="text-muted-foreground">{RULE_KIND_LABEL[s.rule.kind]}:</span>
<span className="font-mono">{ruleText(s.rule)}</span>
<span className="ml-2 text-meta text-muted-foreground">{scopeText(s.rule.season, s.rule.releaseGroup)}</span>
</div>
{s.reasons.length ? (
<ul className="list-disc space-y-0.5 pl-4 text-meta text-muted-foreground">
{s.reasons.map((r) => (
<li key={r}>{r}</li>
))}
</ul>
) : null}
<ul className="space-y-0.5 text-meta text-muted-foreground">
{s.samples.slice(0, 3).map((x) => (
<li key={x.title} className="font-mono tabular-nums">
{sourceText({ source: x.source })} → {canonicalText({ ...x.after, special: x.after.special ?? undefined })}
</li>
))}
</ul>
<div className="text-meta text-muted-foreground">{agentSimulationText(s.simulation)}</div>
</div>
);
}
/** 展示部分(纯渲染,单测直接渲染) */
export function EpisodeDecisionBody({ view, simulation }: { view: EpisodeDecisionView; simulation: string | null }) {
const shown = view.releases.slice(-5);
return (
<dl className="grid gap-1.5 text-caption sm:grid-cols-[5rem_1fr]">
<dt className="text-muted-foreground">范围</dt>
<dd className="text-foreground">{scopeText(view.season, view.releaseGroup)}</dd>
<dt className="text-muted-foreground">来源季集</dt>
<dd className="min-w-0 text-foreground">
<ul className="space-y-0.5">
{shown.map((r) => (
<li key={r.id} className="break-all">
<span className="font-mono tabular-nums">{r.source}</span>
<span className="ml-2 text-meta text-muted-foreground">{r.title}</span>
</li>
))}
{view.count > shown.length ? (
<li className="text-meta text-muted-foreground">另有 {view.count - shown.length} 条</li>
) : null}
</ul>
</dd>
{view.agentSuggestion ? (
<>
<dt className="text-muted-foreground">智能体建议</dt>
<dd className="min-w-0 text-foreground">
<AgentSuggestionBody s={view.agentSuggestion} />
</dd>
</>
) : null}
<dt className="text-muted-foreground">建议规则</dt>
<dd className="min-w-0 text-foreground">
{view.suggestion ? (
<>
<span className="font-mono">{view.suggestion.expression}</span> → 第 {view.suggestion.targetSeason} 季
<ul className="mt-0.5 space-y-0.5 text-meta text-muted-foreground">
{view.suggestion.samples.slice(0, 3).map((s) => (
<li key={`${s.source.start}-${s.target.episode}`} className="font-mono tabular-nums">
E{String(s.source.start).padStart(2, "0")} →{" "}
{episodeCode(s.target.season, { start: s.target.episode, end: s.target.episode })}
(播出 {s.airDate})
</li>
))}
</ul>
{simulation ? <div className="mt-0.5 text-meta text-muted-foreground">{simulation}</div> : null}
</>
) : (
<span className="text-muted-foreground">样本不够推出唯一的规则,到作品页手动建</span>
)}
</dd>
</dl>
);
}
function IgnoreButton({ d, view }: { d: Decision; view: EpisodeDecisionView }) {
const resolve = useResolveDecision();
const id = useId();
const [withRule, setWithRule] = useState(false);
return (
<ConfirmDialog
trigger={
<Button variant="ghost" size="sm">
<BellOff />
忽略
</Button>
}
title="忽略这条事项?"
description="只关闭提醒,这些资源仍是集数待确认,不会下载和整理;同组出现新的待确认资源时会重新提出。"
confirmLabel="忽略"
onConfirm={() =>
resolve
.mutateAsync(
withRule && view.ignoreRule ? { id: d.id, optionKey: view.ignoreRule.key } : { id: d.id, dismiss: true },
)
.then(
() => toast.success(withRule ? "已忽略,并建了忽略规则" : "已忽略"),
(err) => {
toast.error(errorMessage(err, "操作失败"));
throw err;
},
)
}
>
{view.ignoreRule ? (
<label htmlFor={id} className="flex items-center gap-2 text-caption text-foreground">
<Switch id={id} checked={withRule} onCheckedChange={setWithRule} />
同时建「忽略」规则:{scopeText(view.season, view.releaseGroup)}的资源以后不参与集数解析
</label>
) : null}
</ConfirmDialog>
);
}
/** 集数待确认事项的证据与操作 */
export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) {
const view = episodeDecisionView(d);
const resolve = useResolveDecision();
const sug = view.suggestion;
const preview = useEpisodeMappingPreview(
view.entityId,
sug
? {
season: view.season,
releaseGroup: view.releaseGroup,
draft: {
season: view.season,
releaseGroup: view.releaseGroup,
kind: "expression",
expression: sug.expression,
targetSeason: sug.targetSeason,
},
limit: 50,
}
: {},
open && !!sug,
);
const modifyHref = view.entityId
? mappingHref(consolePath("library", view.entityId), {
season: view.season,
releaseGroup: view.releaseGroup,
...(sug ? { expression: sug.expression, targetSeason: sug.targetSeason } : {}),
})
: null;
return (
<div className="space-y-3">
<EpisodeDecisionBody view={view} simulation={sug ? simulationText(preview.data) : null} />
{open ? (
<div className="flex flex-wrap items-center gap-2">
{view.confirmAgent && view.agentSuggestion ? (
<ConfirmDialog
trigger={
<Button size="sm">
<Bot />
确认智能体建议
</Button>
}
title="按智能体建议的规则处理?"
description={`${view.confirmAgent.description ?? ""}。规则在作品页的集数映射里可以再改。`}
confirmLabel="确认"
onConfirm={() =>
resolve.mutateAsync({ id: d.id, optionKey: view.confirmAgent!.key }).then(
() => toast.success("已建规则并重算"),
(err) => {
toast.error(errorMessage(err, "处理失败"));
throw err;
},
)
}
/>
) : null}
{view.confirm ? (
<ConfirmDialog
trigger={
<Button size="sm" variant={view.confirmAgent && view.agentSuggestion ? "outline" : "default"}>
<Check />
确认
</Button>
}
title="按建议规则处理?"
description={`${view.confirm.description ?? ""}。规则在作品页的集数映射里可以再改。`}
confirmLabel="确认"
onConfirm={() =>
resolve.mutateAsync({ id: d.id, optionKey: view.confirm!.key }).then(
() => toast.success("已建规则并重算"),
(err) => {
toast.error(errorMessage(err, "处理失败"));
throw err;
},
)
}
/>
) : null}
{modifyHref ? (
<Button asChild variant="outline" size="sm">
<Link to={modifyHref}>
<PencilLine />
修改
</Link>
</Button>
) : null}
<IgnoreButton d={d} view={view} />
</div>
) : null}
</div>
);
}