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