import type { Decision, DecisionOption, EpisodeMappingPreview, 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 { useEntityWords, 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, 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 { line: string; 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; /** 采用建议规则时写进作品识别词的那一行 */ suggestionLine: string | 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; 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: typeof a?.line === "string" && a.simulation ? { ...a, reasons: Array.isArray(a.reasons) ? a.reasons : [], samples: Array.isArray(a.samples) ? a.samples : [], } : null, suggestionLine: optionLine(d.options.find((o) => o.key === EPISODE_CONFIRM_KEY)), 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, }; } const optionLine = (o: DecisionOption | undefined): string | null => { const line = (o?.input as { line?: unknown } | undefined)?.line; return typeof line === "string" ? line : 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 (
{s.line}
{s.reasons.length ? ( ) : null}
{agentSimulationText(s.simulation)}
); } /** 展示部分(纯渲染,单测直接渲染) */ export function EpisodeDecisionBody({ view, simulation }: { view: EpisodeDecisionView; simulation: string | null }) { const shown = view.releases.slice(-5); return (
范围
{scopeText(view.season, view.releaseGroup)}
来源季集
    {shown.map((r) => (
  • {r.source} {r.title}
  • ))} {view.count > shown.length ? (
  • 另有 {view.count - shown.length} 条
  • ) : null}
{view.agentSuggestion ? ( <>
智能体建议
) : null}
建议规则
{view.suggestion ? ( <> {view.suggestionLine ?? `${view.suggestion.expression} → 第 ${view.suggestion.targetSeason} 季`}
    {view.suggestion.samples.slice(0, 3).map((s) => (
  • E{String(s.source.start).padStart(2, "0")} →{" "} {episodeCode(s.target.season, { start: s.target.episode, end: s.target.episode })} (播出 {s.airDate})
  • ))}
{simulation ?
{simulation}
: null} ) : ( 样本不够推出唯一的规则,在上面的作品识别词里手动写 )}
); } function IgnoreButton({ d, view }: { d: Decision; view: EpisodeDecisionView }) { const resolve = useResolveDecision(); const id = useId(); const [withRule, setWithRule] = useState(false); return ( 忽略 } 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 ? ( ) : null} ); } /** * 集数待确认事项的证据与操作。onEdit 给了(作品页集数映射里)时「修改」把这一行放进编辑器草稿,否则跳到作品页。 */ export function EpisodeDecision({ d, open, onEdit }: { d: Decision; open: boolean; onEdit?: (line: string) => void }) { const view = episodeDecisionView(d); const resolve = useResolveDecision(); const sug = view.suggestion; const line = view.suggestionLine; const words = useEntityWords(open && line ? view.entityId : null); const current = words.data?.block.text.replace(/\n*$/, "") ?? ""; const preview = useEpisodeMappingPreview( view.entityId, line ? { season: view.season, releaseGroup: view.releaseGroup, words: current ? `${current}\n${line}\n` : `${line}\n`, limit: 50, } : {}, open && !!line && words.isSuccess, ); const editLine = line ?? optionLine(view.ignoreRule ?? undefined); const modifyHref = view.entityId ? mappingHref(consolePath("library", view.entityId), editLine ?? undefined) : null; return (
{open ? (
{view.confirmAgent && view.agentSuggestion ? ( 采用智能体建议 } 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 ? ( 采用建议规则 } 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} {onEdit && editLine ? ( ) : modifyHref ? ( ) : null}
) : null}
); }