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

230 lines
9.1 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, EpisodeResolutionView } from "@umt/contracts";
import { BellOff, 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 { episodeCode, mappingHref, scopeText, sourceText } from "./model.ts";
// 待处理中心里的「集数待确认」(episode_mapping):来源季集、原因、建议规则与它的模拟结果;
// 操作「确认」(建建议规则并重算)、「修改」(跳作品页集数映射并预填草稿)、「忽略」(可同时建忽略规则)。
export const EPISODE_CONFIRM_KEY = "confirm_suggestion";
export const EPISODE_IGNORE_RULE_KEY = "ignore_rule";
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;
confirm: 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;
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,
confirm: d.options.find((o) => o.key === EPISODE_CONFIRM_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 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>
<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.confirm ? (
<ConfirmDialog
trigger={
<Button size="sm">
<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>
);
}