feat(identify-words): 识别词——全局识别词(MP 语法)+ 作品识别词(季集换算),规则页「识别词」页签

- 新表 identify_words(0094):全局一块 + 每部作品一块原文,带版本与审计;episode_mapping_rule 转成文本行后删表
- 全局识别词移植 MP WordsMatcher(屏蔽 / 替换 / 集数偏移 / 替换+偏移 / {[tmdbid=…]}),所有按标题 / 文件名识别的地方解析前套用,命中的行记在解析结果上;保存后后台按改动行重新识别
- 作品识别词:S02 >> S01 EP+24 / S01E13 >> S00E01 / S01 @组 >> 忽略,解析器 + 打印器 + 编译;发布组繁简视为同一个
- 去掉规则自动停用:算出来对不上季表只待确认,要停用就注释掉那一行
- 集数待确认的采用 / 忽略与智能体 apply_rule 改为往作品识别词追加一行(带来历注释)
- 编辑器可视化 / 文本两种模式改同一份原文,保存前试算;作品页「集数映射」用同一个编辑器

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-08 17:03:00 +08:00
1 parent 12f8fc59c3
commit 63e94b308e
90 files changed
+6366 -2131

No files matched your search

@@ -47,8 +47,13 @@ const decision = (over: Partial<Decision> = {}): Decision =>
},
},
options: [
{ key: "confirm_suggestion", label: "确认建议规则", description: "按 EP-30 换算到第 2 季" },
{ key: "ignore_rule", label: "忽略并建忽略规则" },
{
key: "confirm_suggestion",
label: "确认建议规则",
description: "把「S01 @Lilith-Raws >> S02 EP-30」写进作品识别词",
input: { line: "S01 @Lilith-Raws >> S02 EP-30" },
},
{ key: "ignore_rule", label: "忽略并写忽略规则", input: { line: "S01 @Lilith-Raws >> 忽略" } },
],
...over,
}) as Decision;
@@ -59,6 +64,7 @@ describe("待处理「集数待确认」", () => {
expect(v).toMatchObject({ entityId: ENTITY, season: 1, releaseGroup: "Lilith-Raws", count: 2 });
expect(v.releases.map((r) => r.source)).toEqual(["S01E40", "S01E41"]);
expect(v.suggestion?.expression).toBe("EP-30");
expect(v.suggestionLine).toBe("S01 @Lilith-Raws >> S02 EP-30");
expect(v.confirm?.key).toBe("confirm_suggestion");
expect(v.ignoreRule?.key).toBe("ignore_rule");
const none = episodeDecisionView(decision({ options: [], evidence: { mediaEntityId: ENTITY, suggestion: null } }));
@@ -71,20 +77,19 @@ describe("待处理「集数待确认」", () => {
);
expect(html).toContain("第 1 季 · Lilith-Raws");
expect(html).toContain("S01E40");
expect(html).toContain("EP-30");
expect(html).toContain("第 2 季");
expect(html).toContain("S01 @Lilith-Raws &gt;&gt; S02 EP-30");
expect(html).toContain("S02E10");
expect(html).toContain("套用后 2 条都对得上");
});
it("没有建议时提示到作品页手动建", () => {
it("没有建议时提示在作品识别词里手动写", () => {
const html = renderToStaticMarkup(
createElement(EpisodeDecisionBody, {
view: episodeDecisionView(decision({ evidence: { mediaEntityId: ENTITY, suggestion: null } })),
simulation: null,
}),
);
expect(html).toContain("到作品页手动建");
expect(html).toContain("在上面的作品识别词里手动写");
});
it("模拟结果一句话", () => {
@@ -96,7 +101,7 @@ describe("待处理「集数待确认」", () => {
});
const agentSuggestion = {
rule: { season: 1, releaseGroup: "Lilith-Raws", kind: "expression", expression: "EP-30", targetSeason: 2 },
line: "S01 @Lilith-Raws >> S02 EP-30",
reasons: ["40、41 的发布时间对上第 2 季第 10、11 集的播出日期"],
simulation: { samples: 2, resolved: 2, passed: 2, missing: 0, regressions: 0, changed: 2, scanned: 5, ok: true },
samples: [
@@ -124,7 +129,7 @@ describe("待处理「集数待确认」", () => {
it("智能体建议:取规则、理由、模拟结果与「确认智能体建议」", () => {
const v = episodeDecisionView(withAgent());
expect(v.agentSuggestion?.rule.expression).toBe("EP-30");
expect(v.agentSuggestion?.line).toBe("S01 @Lilith-Raws >> S02 EP-30");
expect(v.agentSuggestion?.reasons).toHaveLength(1);
expect(v.confirmAgent?.key).toBe("confirm_agent_suggestion");
expect(v.confirm?.key).toBe("confirm_suggestion");
@@ -132,13 +137,12 @@ describe("待处理「集数待确认」", () => {
expect(episodeDecisionView(decision())).toMatchObject({ agentSuggestion: null, confirmAgent: null });
});
it("渲染智能体建议:规则、作用域、理由、样本套用后的季集与模拟结果", () => {
it("渲染智能体建议:那一行作品识别词、理由、样本套用后的季集与模拟结果", () => {
const html = renderToStaticMarkup(
createElement(EpisodeDecisionBody, { view: episodeDecisionView(withAgent()), simulation: null }),
);
expect(html).toContain("智能体建议");
expect(html).toContain("偏移表达式");
expect(html).toContain("EP-30 → 第 2 季");
expect(html).toContain("S01 @Lilith-Raws &gt;&gt; S02 EP-30");
expect(html).toContain("对上第 2 季第 10、11 集的播出日期");
expect(html).toContain("S01E40 → S02E10");
expect(html).toContain("模拟:这 2 条都对得上,不影响已对上的资源");
@@ -2,7 +2,6 @@ import type {
Decision,
DecisionOption,
EpisodeMappingPreview,
EpisodeMappingRuleSpec,
EpisodeResolutionView,
EpisodeResultBrief,
EpisodeSimulationSummary,
@@ -12,25 +11,25 @@ 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 { 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, RULE_KIND_LABEL, ruleText, scopeText, sourceText } from "./model.ts";
import { canonicalText, episodeCode, mappingHref, scopeText, sourceText } from "./model.ts";
// 作品页集数映射里的「集数待确认」(episode_mapping 内部事项):来源季集、原因、建议规则与它的模拟结果;
// 操作「确认」(建建议规则并重算)、「修改」(跳作品页集数映射并预填草稿)、「忽略」(可同时建忽略规则)。
// 智能体建议(批次 5):后台调查没有写规则的授权时,把模拟通过的规则和理由留在事项上,「确认智能体建议」一键建规则
// (服务端建规则前按当前资源再模拟一次)。
// 作品页集数映射里的「集数待确认」(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;
line: string;
reasons: string[];
simulation: EpisodeSimulationSummary;
samples: { title: string; source: EpisodeResolutionView["source"]; after: EpisodeResultBrief }[];
@@ -49,6 +48,8 @@ export interface EpisodeDecisionView {
releases: { id: string; title: string; source: string }[];
count: number;
suggestion: Suggestion | null;
/** 采用建议规则时写进作品识别词的那一行 */
suggestionLine: string | null;
agentSuggestion: AgentSuggestion | null;
confirm: DecisionOption | null;
confirmAgent: DecisionOption | null;
@@ -80,19 +81,25 @@ export function episodeDecisionView(d: Decision): EpisodeDecisionView {
count: typeof e.count === "number" ? e.count : releases.length,
suggestion: s && typeof s.expression === "string" ? s : null,
agentSuggestion:
a?.rule && a.simulation
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;
@@ -113,11 +120,7 @@ export function agentSimulationText(s: EpisodeSimulationSummary): string {
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>
<div className="font-mono break-all">{s.line}</div>
{s.reasons.length ? (
<ul className="list-disc space-y-0.5 pl-4 text-meta text-muted-foreground">
{s.reasons.map((r) => (
@@ -170,7 +173,9 @@ export function EpisodeDecisionBody({ view, simulation }: { view: EpisodeDecisio
<dd className="min-w-0 text-foreground">
{view.suggestion ? (
<>
<span className="font-mono">{view.suggestion.expression}</span> → 第 {view.suggestion.targetSeason} 季
<span className="font-mono break-all">
{view.suggestionLine ?? `${view.suggestion.expression} → 第 ${view.suggestion.targetSeason} 季`}
</span>
<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">
@@ -183,7 +188,7 @@ export function EpisodeDecisionBody({ view, simulation }: { view: EpisodeDecisio
{simulation ? <div className="mt-0.5 text-meta text-muted-foreground">{simulation}</div> : null}
</>
) : (
<span className="text-muted-foreground">样本不够推出唯一的规则,到作品页手动建</span>
<span className="text-muted-foreground">样本不够推出唯一的规则,在上面的作品识别词里手动写</span>
)}
</dd>
</dl>
@@ -211,7 +216,7 @@ function IgnoreButton({ d, view }: { d: Decision; view: EpisodeDecisionView }) {
withRule && view.ignoreRule ? { id: d.id, optionKey: view.ignoreRule.key } : { id: d.id, dismiss: true },
)
.then(
() => toast.success(withRule ? "已忽略,并建了忽略规则" : "已忽略"),
() => toast.success(withRule ? "已忽略,并写了忽略规则" : "已忽略"),
(err) => {
toast.error(errorMessage(err, "忽略失败"));
throw err;
@@ -222,43 +227,37 @@ function IgnoreButton({ d, view }: { d: Decision; view: EpisodeDecisionView }) {
{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)}的资源以后不参与集数解析
同时写一行「{optionLine(view.ignoreRule) ?? "忽略"}」:这些资源以后不参与集数换算
</label>
) : null}
</ConfirmDialog>
);
}
/** 集数待确认事项的证据与操作 */
export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) {
/**
* 集数待确认事项的证据与操作。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,
sug
line
? {
season: view.season,
releaseGroup: view.releaseGroup,
draft: {
season: view.season,
releaseGroup: view.releaseGroup,
kind: "expression",
expression: sug.expression,
targetSeason: sug.targetSeason,
},
words: current ? `${current}\n${line}\n` : `${line}\n`,
limit: 50,
}
: {},
open && !!sug,
open && !!line && words.isSuccess,
);
const modifyHref = view.entityId
? mappingHref(consolePath("library", view.entityId), {
season: view.season,
releaseGroup: view.releaseGroup,
...(sug ? { expression: sug.expression, targetSeason: sug.targetSeason } : {}),
})
: null;
const editLine = line ?? optionLine(view.ignoreRule ?? undefined);
const modifyHref = view.entityId ? mappingHref(consolePath("library", view.entityId), editLine ?? undefined) : null;
return (
<div className="space-y-3">
<EpisodeDecisionBody view={view} simulation={sug ? simulationText(preview.data) : null} />
@@ -272,12 +271,12 @@ export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) {
采用智能体建议
</Button>
}
title="按智能体建议建规则?"
description={`${view.confirmAgent.description ?? ""}。规则在作品页的集数映射里可以再改。`}
confirmLabel="建规则并重算"
title="采用智能体建议?"
description={`${view.confirmAgent.description ?? ""}。写进去之后可以在作品识别词里再改。`}
confirmLabel="写入并重算"
onConfirm={() =>
resolve.mutateAsync({ id: d.id, optionKey: view.confirmAgent!.key }).then(
() => toast.success("已建规则并重算"),
() => toast.success("已写进作品识别词并重算"),
(err) => {
toast.error(errorMessage(err, "处理失败"));
throw err;
@@ -294,12 +293,12 @@ export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) {
采用建议规则
</Button>
}
title="按建议建规则?"
description={`${view.confirm.description ?? ""}。规则在作品页的集数映射里可以再改。`}
confirmLabel="建规则并重算"
title="采用建议规则?"
description={`${view.confirm.description ?? ""}。写进去之后可以在作品识别词里再改。`}
confirmLabel="写入并重算"
onConfirm={() =>
resolve.mutateAsync({ id: d.id, optionKey: view.confirm!.key }).then(
() => toast.success("已建规则并重算"),
() => toast.success("已写进作品识别词并重算"),
(err) => {
toast.error(errorMessage(err, "处理失败"));
throw err;
@@ -308,7 +307,12 @@ export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) {
}
/>
) : null}
{modifyHref ? (
{onEdit && editLine ? (
<Button variant="outline" size="sm" onClick={() => onEdit(editLine)}>
<PencilLine />
修改
</Button>
) : modifyHref ? (
<Button asChild variant="outline" size="sm">
<Link to={modifyHref}>
<PencilLine />
@@ -4,7 +4,7 @@ import { humanizeReason } from "~/lib/reasons.ts";
import { EpisodeDecision } from "./decision.tsx";
// 集数待确认的原因(2026-10-06 去掉待处理中心后):不再作为逐条要处理的条目,
// 同一作品的问题聚合在作品页「集数映射」与订阅详情里显示原因;智能体建议 / 建议规则仍可在这里一键确认。
// 同一作品的问题聚合在作品页「集数映射」与订阅详情里显示原因;智能体建议 / 建议规则可在这里采用(写进作品识别词)。
// 数据来自内部事项 episode_mapping(只给关注中的作品开,见 server sources-identity/service/watched.ts)。
const OPEN = ["open", "investigating", "awaiting_user"];
@@ -27,7 +27,7 @@ export function useEpisodeIssues(entityId: string | null | undefined, season?: n
}
/** 作品页集数映射分区:逐组写原因,建议规则 / 智能体建议可确认 */
export function EpisodeIssuesList({ entityId }: { entityId: string }) {
export function EpisodeIssuesList({ entityId, onEdit }: { entityId: string; onEdit?: (line: string) => void }) {
const { items } = useEpisodeIssues(entityId);
if (!items.length) return null;
return (
@@ -36,7 +36,7 @@ export function EpisodeIssuesList({ entityId }: { entityId: string }) {
<ul className="space-y-3">
{items.map((d) => (
<li key={d.id} className="rounded-lg border border-border/80 p-3">
<EpisodeDecision d={d} open />
<EpisodeDecision d={d} open {...(onEdit ? { onEdit } : {})} />
</li>
))}
</ul>
@@ -1,59 +1,19 @@
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 type { EpisodeMappingPreview, EpisodeSuggestion } from "@umt/contracts";
import { Wand2 } from "lucide-react";
import { useEffect, 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 { useEntityWords, useEpisodeMappingPreview } from "~/api/episode-mapping.ts";
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 { WordsEditor } from "~/components/identify-words/words-editor.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 { groupMethodsText, lineFromSearch, MAPPING_ANCHOR } 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);
@@ -74,215 +34,12 @@ function SeasonSummary({ p }: { p: EpisodeMappingPreview["seasons"] }) {
);
}
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>
);
}
const pad = (n: number) => String(n).padStart(2, "0");
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>
);
/** 建议规则写成作品识别词的一行 */
export function suggestionLine(s: EpisodeSuggestion): string {
const scope = `${s.season == null ? "*" : `S${pad(s.season)}`}${s.releaseGroup ? ` @${s.releaseGroup}` : ""}`;
return `${scope} >> S${pad(s.targetSeason)} ${s.expression}`;
}
function GroupStats({
@@ -290,7 +47,7 @@ function GroupStats({
onUseSuggestion,
}: {
groups: EpisodeMappingPreview["groups"];
onUseSuggestion: (s: EpisodeSuggestion) => void;
onUseSuggestion: (line: string) => void;
}) {
if (!groups.length) return null;
return (
@@ -300,9 +57,9 @@ function GroupStats({
<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!)}>
<Button variant="ghost" size="sm" onClick={() => onUseSuggestion(suggestionLine(g.suggestion!))}>
<Wand2 />
试试建议 {g.suggestion.expression} → 第 {g.suggestion.targetSeason} 季
试试「{suggestionLine(g.suggestion)}」
</Button>
) : null}
</li>
@@ -314,139 +71,64 @@ function GroupStats({
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 words = useEntityWords(entityId);
const preview = useEpisodeMappingPreview(entityId, {});
const [draft, setDraft] = useState<string | null>(null);
const ref = useRef<HTMLDivElement>(null);
const saved = words.data?.block.text ?? "";
const text = draft ?? saved;
// 从待处理「修改」跳来:预填草稿并滚到这里
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 append = (line: string) => {
setDraft((cur) => {
const base = (cur ?? saved).replace(/\n*$/, "");
if (base.split("\n").some((l) => l.trim() === line.trim())) return cur ?? saved;
return `${base ? `${base}\n` : ""}${line}\n`;
});
ref.current?.scrollIntoView({ block: "start", behavior: "smooth" });
};
const list = rules.data?.items ?? [];
// 从集数待确认「修改」跳来:把那一行放进草稿并滚到这里
const fromLink = lineFromSearch(sp);
// biome-ignore lint/correctness/useExhaustiveDependencies: 识别词加载好之后只处理一次地址里带的行
useEffect(() => {
if (fromLink && words.isSuccess) append(fromLink);
if (location.hash === `#${MAPPING_ANCHOR}`) ref.current?.scrollIntoView({ block: "start" });
}, [fromLink, words.isSuccess, location.hash]);
// 保存后草稿归零(编辑器已拿到新版本)
useEffect(() => {
if (draft !== null && draft === saved) setDraft(null);
}, [draft, saved]);
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>
)
}
description="发布者写的季集对不上作品季表时,用作品识别词换成媒体库用的季集"
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>
{words.isPending ? (
<LoadingState rows={3} />
) : words.isError ? (
<ErrorState plain error={words.error} onRetry={() => words.refetch()} />
) : (
<p className="text-caption text-muted-foreground">
还没有规则。资源的集数先按原号、绝对集号和播出日期自动判定,换算不了的在这里列出原因。
</p>
<WordsEditor
scope="entity"
entityId={entityId}
block={words.data.block}
knownGroups={words.data.knownGroups}
draft={text}
onDraftChange={setDraft}
/>
)}
{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} />
{preview.data ? <GroupStats groups={preview.data.groups} onUseSuggestion={append} /> : null}
<EpisodeIssuesList entityId={entityId} onEdit={append} />
</Panel>
<Panel
className="mt-3"
title={draft ? "模拟预览" : "近期资源"}
description={draft ? "按草稿规则试算,不会保存;应用后才按作品重算" : "最近 100 条已绑定资源按当前规则的判定"}
title="近期资源"
description="最近 100 条已绑定资源按已保存的识别词的判定"
bodyClassName="p-0 sm:p-0"
>
{preview.isPending ? (
@@ -458,7 +140,7 @@ export function EpisodeMappingPanel({ entityId }: { entityId: string }) {
<ErrorState plain error={preview.error} onRetry={() => preview.refetch()} />
</div>
) : (
<PreviewTable items={preview.data.items} hasDraft={!!input.draft} />
<PreviewTable items={preview.data.items} hasDraft={false} />
)}
</Panel>
</div>
@@ -1,30 +1,23 @@
import type { EpisodeMappingPreview, EpisodeMappingRuleRow } from "@umt/contracts";
import type { EpisodeMappingPreview } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import { EXCLUDE_REASON } from "../organize-wizard/model.ts";
import {
absoluteGroups,
agentRuleInfo,
canonicalText,
disabledReasonText,
draftFromSearch,
draftOfRule,
EMPTY_DRAFT,
groupMethodsText,
lineFromSearch,
METHOD_LABEL,
mappingHref,
methodText,
parseTable,
reresolveToast,
ruleText,
sourceText,
specOfDraft,
} from "./model.ts";
describe("集数映射文案", () => {
it("方式名称给人话,覆盖全部方式", () => {
expect(METHOD_LABEL).toEqual({
raw: "原号",
rule: "按规则",
rule: "按作品识别词",
absolute_in_season: "季内绝对集号",
absolute_cross_season: "跨季绝对集号",
air_date: "按播出日期",
@@ -33,7 +26,7 @@ describe("集数映射文案", () => {
unresolved: "待确认",
skipped: "不适用",
});
expect(methodText({ method: "rule", special: "ignore" })).toBe("按规则:忽略");
expect(methodText({ method: "rule", special: "ignore" })).toBe("按作品识别词:忽略");
expect(methodText(null)).toBe("未解析");
});
@@ -52,86 +45,16 @@ describe("集数映射文案", () => {
expect(EXCLUDE_REASON.episode_unresolved).toBe("集数待确认");
expect(EXCLUDE_REASON.special_extra).toBeTruthy();
});
it("停用原因:手动 / 规则失效", () => {
const rule = { status: "disabled", disabledReason: JSON.stringify({ manual: true }) } as EpisodeMappingRuleRow;
expect(disabledReasonText(rule)).toBe("手动停用");
expect(
disabledReasonText({
...rule,
disabledReason: JSON.stringify({ source: { season: 2, episodes: { start: 30, end: 30 } }, evidence: [] }),
}),
).toBe("规则算出的集数对不上季表(来源 S02E30),已自动停用");
expect(disabledReasonText({ ...rule, status: "active" })).toBeNull();
});
});
describe("对照表与草稿", () => {
it("对照表文本解析与回写", () => {
const t = parseTable("26=S02E01\n27-28 = s2e2\n");
expect(t.error).toBeNull();
expect(t.entries).toEqual([
{ from: { start: 26, end: 26 }, to: { season: 2, episode: 1 } },
{ from: { start: 27, end: 28 }, to: { season: 2, episode: 2 } },
]);
expect(ruleText({ kind: "table", tableEntries: t.entries })).toBe("26=S02E01 等 2 行");
expect(parseTable("26->").error).toContain("第 1 行");
expect(parseTable("28-27=S02E01").error).toContain("写反");
});
it("草稿 → 规则内容:不完整时给错误,不发预览", () => {
expect(specOfDraft({ ...EMPTY_DRAFT, expression: "" }).error).toContain("偏移表达式");
expect(specOfDraft({ ...EMPTY_DRAFT, season: "x", expression: "EP-12" }).error).toContain("来源季");
expect(specOfDraft({ ...EMPTY_DRAFT, season: "2", releaseGroup: " ANi ", expression: "EP-12" }).spec).toEqual({
season: 2,
releaseGroup: "ANi",
kind: "expression",
expression: "EP-12",
targetSeason: null,
});
expect(specOfDraft({ ...EMPTY_DRAFT, kind: "special", special: "extra" }).spec).toMatchObject({
kind: "special",
special: "extra",
season: null,
releaseGroup: null,
});
});
it("规则 → 草稿 → 规则内容 保持一致", () => {
const row = {
season: 1,
releaseGroup: "Lilith-Raws",
kind: "expression",
expression: "EP-30",
targetSeason: 2,
tableEntries: null,
special: null,
} as EpisodeMappingRuleRow;
expect(specOfDraft(draftOfRule(row)).spec).toEqual({
season: 1,
releaseGroup: "Lilith-Raws",
kind: "expression",
expression: "EP-30",
targetSeason: 2,
});
});
it("待处理「修改」的跳转地址能读回预填草稿", () => {
const href = mappingHref("/console/library/x", {
season: 1,
releaseGroup: "ANi",
expression: "EP-12",
targetSeason: 2,
});
expect(href).toBe("/console/library/x?mapSeason=1&mapGroup=ANi&mapExpr=EP-12&mapTarget=2#episode-mapping");
describe("作品页地址", () => {
it("集数待确认「修改」带上要追加的一行,作品页能读回", () => {
const href = mappingHref("/console/library/x", "S01 @ANi >> S02 EP-12");
expect(href).toBe("/console/library/x?mapLine=S01+%40ANi+%3E%3E+S02+EP-12#episode-mapping");
const sp = new URLSearchParams(href.slice(href.indexOf("?") + 1, href.indexOf("#")));
expect(draftFromSearch(sp)).toMatchObject({
season: "1",
releaseGroup: "ANi",
expression: "EP-12",
targetSeason: "2",
});
expect(draftFromSearch(new URLSearchParams())).toBeNull();
expect(lineFromSearch(sp)).toBe("S01 @ANi >> S02 EP-12");
expect(lineFromSearch(new URLSearchParams())).toBeNull();
expect(mappingHref("/console/library/x")).toBe("/console/library/x#episode-mapping");
});
});
@@ -161,24 +84,11 @@ describe("按发布组统计", () => {
});
});
describe("应用后的提示", () => {
describe("保存后的提示", () => {
it("只列非零项", () => {
expect(reresolveToast({ releases: 12, changed: 2, resolved: 2 })).toBe(
"已应用:重算 12 条资源,2 条有变化,2 条待确认已解决",
"已保存:重算 12 条资源,2 条有变化,2 条待确认已解决",
);
expect(reresolveToast({ releases: 3, changed: 0, resolved: 0 }, "已停用")).toBe("已停用:重算 3 条资源");
});
});
describe("智能体写的规则", () => {
it("标出智能体来源与证据等级,带写规则时的理由;其它来源不标", () => {
const rule = (over: Partial<EpisodeMappingRuleRow>) =>
({ createdByType: "agent", evidenceLevel: "multi_evidence", evidence: {}, ...over }) as EpisodeMappingRuleRow;
expect(agentRuleInfo(rule({ evidence: { reasons: ["发布时间对上播出日期", 3] } }))).toEqual({
label: "智能体 · 多证据",
reasons: ["发布时间对上播出日期"],
});
expect(agentRuleInfo(rule({ evidenceLevel: "needs_review" }))?.label).toBe("智能体 · 待复核");
expect(agentRuleInfo(rule({ createdByType: "user", evidenceLevel: "manual" }))).toBeNull();
expect(reresolveToast({ releases: 3, changed: 0, resolved: 0 }, "已写入")).toBe("已写入:重算 3 条资源");
});
});
+14 -171
View File
@@ -1,24 +1,20 @@
import type {
EpisodeMappingPreview,
EpisodeMappingRuleRow,
EpisodeMappingRuleSpec,
EpisodeMappingRuleWriteResult,
EpisodeMappingTableEntry,
EpisodeReresolveResult,
EpisodeResolutionMethod,
EpisodeResolutionView,
EpisodeSpecialKind,
EpisodeValidationState,
} from "@umt/contracts";
import type { StatusTone } from "~/components/console/status-badge.tsx";
import { EVIDENCE_LEVEL } from "~/components/library/labels.ts";
// 集数映射的界面文案与纯函数(docs/plan/episode-resolution.md 批次 4):方式名称、季集写法、规则摘要、
// 表格规则的文本格式、作品页「集数映射」分区的地址。
// 集数映射的界面文案与纯函数:方式名称、季集写法、作品页「集数映射」分区的地址。
// 规则本身是作品识别词(components/identify-words),这里不再有结构化规则。
/** 识别方式的人话 */
export const METHOD_LABEL: Record<EpisodeResolutionMethod, string> = {
raw: "原号",
rule: "按规则",
rule: "按作品识别词",
absolute_in_season: "季内绝对集号",
absolute_cross_season: "跨季绝对集号",
air_date: "按播出日期",
@@ -34,12 +30,6 @@ export const SPECIAL_LABEL: Record<EpisodeSpecialKind, string> = {
ignore: "忽略",
};
export const RULE_KIND_LABEL: Record<EpisodeMappingRuleSpec["kind"], string> = {
expression: "偏移表达式",
table: "对照表",
special: "特别篇 / 花絮 / 忽略",
};
/** 校验状态:状态色只表示状态 */
export const VALIDATION: Record<EpisodeValidationState, { label: string; tone: StatusTone }> = {
pass: { label: "对得上", tone: "success" },
@@ -52,7 +42,7 @@ export const VALIDATION: Record<EpisodeValidationState, { label: string; tone: S
/** 方式名称:按规则映射成特别篇等时写具体去向 */
export function methodText(r: Pick<EpisodeResolutionView, "method" | "special"> | null | undefined): string {
if (!r) return "未解析";
if (r.special) return `按规则:${SPECIAL_LABEL[r.special]}`;
if (r.special) return `按作品识别词:${SPECIAL_LABEL[r.special]}`;
return METHOD_LABEL[r.method] ?? r.method;
}
@@ -91,166 +81,19 @@ export function canonicalText(r: Pick<EpisodeResolutionView, "canonical" | "spec
export const scopeText = (season: number | null | undefined, group: string | null | undefined) =>
`${season == null ? "任意季" : `第 ${season} 季`} · ${group?.trim() || "任意发布组"}`;
/** 规则内容一句话 */
export function ruleText(
r: Pick<EpisodeMappingRuleSpec, "kind" | "expression" | "targetSeason" | "tableEntries" | "special">,
) {
if (r.kind === "expression") {
return `${r.expression ?? ""}${r.targetSeason != null ? ` → 第 ${r.targetSeason} 季` : ""}`;
}
if (r.kind === "table") {
const n = r.tableEntries?.length ?? 0;
const first = r.tableEntries?.[0];
return first ? `${tableLine(first)}${n > 1 ? ` 等 ${n} 行` : ""}` : "空对照表";
}
return r.special ? `映射为${SPECIAL_LABEL[r.special]}` : "未选去向";
}
/** 智能体写的规则:来源与证据等级(「智能体 · 多证据」)、写规则时的理由;其它来源返回 null */
export function agentRuleInfo(
rule: Pick<EpisodeMappingRuleRow, "createdByType" | "evidenceLevel" | "evidence">,
): { label: string; reasons: string[] } | null {
if (rule.createdByType !== "agent") return null;
const raw = (rule.evidence as { reasons?: unknown }).reasons;
return {
label: `智能体 · ${EVIDENCE_LEVEL[rule.evidenceLevel] ?? rule.evidenceLevel}`,
reasons: Array.isArray(raw) ? raw.filter((x): x is string => typeof x === "string") : [],
};
}
/** 停用原因:手动停用 / 规则失效(带当时的来源季集) */
export function disabledReasonText(rule: Pick<EpisodeMappingRuleRow, "status" | "disabledReason">): string | null {
if (rule.status !== "disabled") return null;
if (!rule.disabledReason) return "已停用";
try {
const v = JSON.parse(rule.disabledReason) as {
manual?: boolean;
source?: EpisodeResolutionView["source"];
};
if (v.manual) return "手动停用";
if (v.source) return `规则算出的集数对不上季表(来源 ${sourceText({ source: v.source })}),已自动停用`;
} catch {
// 旧格式:原样显示
}
return rule.disabledReason;
}
// ── 对照表的文本格式:每行 `26=S02E01` 或 `27-28=S02E02` ──
export function tableLine(e: EpisodeMappingTableEntry): string {
const from = e.from.start === e.from.end ? `${e.from.start}` : `${e.from.start}-${e.from.end}`;
return `${from}=S${pad(e.to.season)}E${pad(e.to.episode)}`;
}
export const formatTable = (entries: readonly EpisodeMappingTableEntry[] | null | undefined) =>
(entries ?? []).map(tableLine).join("\n");
/** 解析对照表文本;有错返回第一处错误 */
export function parseTable(text: string): { entries: EpisodeMappingTableEntry[]; error: string | null } {
const entries: EpisodeMappingTableEntry[] = [];
const lines = text
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
for (const [i, line] of lines.entries()) {
const m = line.match(/^(\d{1,4})(?:\s*-\s*(\d{1,4}))?\s*(?:=|→|->)\s*S(\d{1,3})\s*E(\d{1,4})$/i);
if (!m) return { entries, error: `第 ${i + 1} 行格式不对,写成 26=S02E01 或 27-28=S02E02` };
const start = Number(m[1]);
const end = m[2] ? Number(m[2]) : start;
if (end < start) return { entries, error: `第 ${i + 1} 行的区间写反了` };
entries.push({ from: { start, end }, to: { season: Number(m[3]), episode: Number(m[4]) } });
}
return { entries, error: null };
}
// ── 草稿 ──
export interface RuleDraft {
season: string;
releaseGroup: string;
kind: EpisodeMappingRuleSpec["kind"];
expression: string;
targetSeason: string;
table: string;
special: EpisodeSpecialKind;
}
export const EMPTY_DRAFT: RuleDraft = {
season: "",
releaseGroup: "",
kind: "expression",
expression: "",
targetSeason: "",
table: "",
special: "ignore",
};
export function draftOfRule(r: EpisodeMappingRuleRow): RuleDraft {
return {
season: r.season != null ? String(r.season) : "",
releaseGroup: r.releaseGroup ?? "",
kind: r.kind,
expression: r.expression ?? "",
targetSeason: r.targetSeason != null ? String(r.targetSeason) : "",
table: formatTable(r.tableEntries),
special: r.special ?? "ignore",
};
}
const intOrNull = (s: string) => (/^\d{1,3}$/.test(s.trim()) ? Number(s.trim()) : null);
/** 草稿 → 接口的规则内容;不完整时返回错误(预览不发请求) */
export function specOfDraft(d: RuleDraft): { spec: EpisodeMappingRuleSpec | null; error: string | null } {
if (d.season.trim() && intOrNull(d.season) === null) return { spec: null, error: "来源季写数字,空着表示任意季" };
if (d.targetSeason.trim() && intOrNull(d.targetSeason) === null) return { spec: null, error: "目标季写数字" };
const base = { season: intOrNull(d.season), releaseGroup: d.releaseGroup.trim() || null };
if (d.kind === "expression") {
if (!d.expression.trim()) return { spec: null, error: "填偏移表达式,如 EP-12" };
return {
spec: { ...base, kind: "expression", expression: d.expression.trim(), targetSeason: intOrNull(d.targetSeason) },
error: null,
};
}
if (d.kind === "table") {
const t = parseTable(d.table);
if (t.error) return { spec: null, error: t.error };
if (!t.entries.length) return { spec: null, error: "对照表至少写一行" };
return { spec: { ...base, kind: "table", tableEntries: t.entries }, error: null };
}
return { spec: { ...base, kind: "special", special: d.special }, error: null };
}
// ── 作品页「集数映射」分区的地址(待处理「修改」跳转并预填草稿) ──
// ── 作品页「集数映射」分区的地址(集数待确认「修改」跳转时带上要追加的一行) ──
export const MAPPING_ANCHOR = "episode-mapping";
const LINE_PARAM = "mapLine";
export function mappingHref(
libraryPath: string,
prefill?: { season?: number | null; releaseGroup?: string | null; expression?: string; targetSeason?: number },
): string {
const qs = new URLSearchParams();
if (prefill?.season != null) qs.set("mapSeason", String(prefill.season));
if (prefill?.releaseGroup) qs.set("mapGroup", prefill.releaseGroup);
if (prefill?.expression) qs.set("mapExpr", prefill.expression);
if (prefill?.targetSeason != null) qs.set("mapTarget", String(prefill.targetSeason));
const s = qs.toString();
return `${libraryPath}${s ? `?${s}` : ""}#${MAPPING_ANCHOR}`;
export function mappingHref(libraryPath: string, line?: string): string {
const qs = line ? `?${new URLSearchParams({ [LINE_PARAM]: line })}` : "";
return `${libraryPath}${qs}#${MAPPING_ANCHOR}`;
}
/** 从地址读预填草稿;没有任何预填返回 null */
export function draftFromSearch(sp: URLSearchParams): RuleDraft | null {
const season = sp.get("mapSeason");
const group = sp.get("mapGroup");
const expr = sp.get("mapExpr");
const target = sp.get("mapTarget");
if (season === null && group === null && expr === null) return null;
return {
...EMPTY_DRAFT,
season: season ?? "",
releaseGroup: group ?? "",
expression: expr ?? "",
targetSeason: target ?? "",
};
/** 从地址读要追加进作品识别词草稿的一行;没有返回 null */
export function lineFromSearch(sp: URLSearchParams): string | null {
return sp.get(LINE_PARAM)?.trim() || null;
}
// ── 统计 ──
@@ -270,7 +113,7 @@ export function absoluteGroups(p: EpisodeMappingPreview | undefined): string[] {
}
/** 规则写入后的提示:只列非零项 */
export function reresolveToast(r: EpisodeMappingRuleWriteResult["reresolve"], verb = "已应用"): string {
export function reresolveToast(r: EpisodeReresolveResult, verb = "已保存"): string {
const parts = [`重算 ${r.releases} 条资源`];
if (r.changed) parts.push(`${r.changed} 条有变化`);
if (r.resolved) parts.push(`${r.resolved} 条待确认已解决`);
@@ -22,6 +22,7 @@ const item = (over: Partial<EpisodeMappingPreviewItem>): EpisodeMappingPreviewIt
ruleId: "draft",
evidence: [],
},
words: [],
validation: "pass",
changed: true,
...over,
@@ -34,12 +35,29 @@ describe("模拟预览表格", () => {
expect(html).toContain("S01E40");
expect(html).toContain("待确认");
expect(html).toContain("S02E10");
expect(html).toContain("按规则");
expect(html).toContain("按作品识别词");
expect(html).toContain("对得上");
expect(html).toContain("有变化");
expect(html).toContain('data-changed="true"');
});
it("方式下面写命中的作品识别词行与全局识别词", () => {
const html = renderToStaticMarkup(
createElement(PreviewTable, {
items: [
item({
preview: { ...item({}).preview, ruleLine: 2, ruleText: "S01 @Lilith-Raws >> S02 EP-30" },
words: ["Kusuriya => 药屋"],
}),
],
hasDraft: true,
}),
);
expect(html).toContain("第 2 行 S01 @Lilith-Raws &gt;&gt; S02 EP-30");
expect(html).toContain("命中识别词:");
expect(html).toContain("Kusuriya =&gt; 药屋");
});
it("没变化不高亮;校验不通过显示对不上;没有草稿时列名是重算", () => {
const html = renderToStaticMarkup(
createElement(PreviewTable, {
@@ -4,7 +4,8 @@ import { StatusBadge } from "~/components/console/status-badge.tsx";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { canonicalText, methodText, sourceText, VALIDATION } from "./model.ts";
// 模拟预览表格:原始季集 → 当前结果 → 套用草稿后的结果、方式、校验;与当前不同的行高亮并标「有变化」。
// 模拟预览表格:原始季集 → 当前结果 → 套用草稿后的结果、方式(命中的作品识别词行、全局识别词)、校验;
// 与当前不同的行高亮并标「有变化」。
export function PreviewTable({
items,
@@ -59,7 +60,20 @@ export function PreviewTable({
</div>
{it.changed ? <div className="text-meta text-muted-foreground">有变化</div> : null}
</td>
<td className="px-3 py-2 align-top">{methodText(it.preview)}</td>
<td className="max-w-[16rem] px-3 py-2 align-top">
<div>{methodText(it.preview)}</div>
{it.preview.ruleText ? (
<div className="font-mono text-meta break-all text-muted-foreground">
{it.preview.ruleLine ? `第 ${it.preview.ruleLine} 行 ` : ""}
{it.preview.ruleText}
</div>
) : null}
{it.words.length ? (
<div className="text-meta break-all text-muted-foreground">
命中识别词:<span className="font-mono">{it.words.join(";")}</span>
</div>
) : null}
</td>
<td className="px-4 py-2 align-top">
<StatusBadge tone={v.tone}>{v.label}</StatusBadge>
</td>