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:
rootandClaude Opus 5.5 committed 2026-10-03 19:28:09 +08:00
1 parent fe597187dd
commit 45a114263b
26 files changed
+2770 -9

No files matched your search

@@ -0,0 +1,264 @@
import type {
EpisodeMappingPreview,
EpisodeMappingRuleRow,
EpisodeMappingRuleSpec,
EpisodeMappingRuleWriteResult,
EpisodeMappingTableEntry,
EpisodeResolutionMethod,
EpisodeResolutionView,
EpisodeSpecialKind,
EpisodeValidationState,
} from "@umt/contracts";
import type { StatusTone } from "~/components/console/status-badge.tsx";
// 集数映射的界面文案与纯函数(docs/plan/episode-resolution.md 批次 4):方式名称、季集写法、规则摘要、
// 表格规则的文本格式、作品页「集数映射」分区的地址。
/** 识别方式的人话 */
export const METHOD_LABEL: Record<EpisodeResolutionMethod, string> = {
raw: "原号",
rule: "按规则",
absolute_in_season: "季内绝对集号",
absolute_cross_season: "跨季绝对集号",
air_date: "按播出日期",
unverified: "元数据未覆盖,先按原号",
unresolved: "待确认",
skipped: "不适用",
};
export const SPECIAL_LABEL: Record<EpisodeSpecialKind, string> = {
special: "特别篇",
extra: "花絮",
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" },
unverified: { label: "未核对", tone: "warning" },
fail: { label: "对不上", tone: "destructive" },
special: { label: "不按集入库", tone: "neutral" },
skipped: { label: "不适用", tone: "neutral" },
};
/** 方式名称:按规则映射成特别篇等时写具体去向 */
export function methodText(r: Pick<EpisodeResolutionView, "method" | "special"> | null | undefined): string {
if (!r) return "未解析";
if (r.special) return `按规则:${SPECIAL_LABEL[r.special]}`;
return METHOD_LABEL[r.method] ?? r.method;
}
/** 绝对集号类方式:订阅表单据此提示「这个发布组用的是绝对集号」 */
export const isAbsoluteMethod = (m: EpisodeResolutionMethod) =>
m === "absolute_in_season" || m === "absolute_cross_season";
const pad = (n: number) => String(n).padStart(2, "0");
/** 季集写法:S01E25、S02E01-E02;只有集号没季号写 E25 */
export function episodeCode(season: number | null | undefined, episodes: { start: number; end: number } | undefined) {
if (!episodes) return season != null ? `S${pad(season)}` : "";
const s = season != null ? `S${pad(season)}` : "";
const e =
episodes.start === episodes.end ? `E${pad(episodes.start)}` : `E${pad(episodes.start)}-E${pad(episodes.end)}`;
return `${s}${e}`;
}
/** 发布者声称的季集(原始) */
export function sourceText(r: Pick<EpisodeResolutionView, "source"> | null | undefined): string {
if (!r) return "—";
const { season, episodes, airDate } = r.source;
if (episodes) return episodeCode(season, episodes);
if (airDate) return airDate.replace(/^(\d{4})(\d{2})(\d{2})$/, "$1-$2-$3");
return "—";
}
/** 媒体库应当使用的季集(规范);按规则去特别篇等写去向,换不出写「—」 */
export function canonicalText(r: Pick<EpisodeResolutionView, "canonical" | "special" | "method"> | null | undefined) {
if (!r) return "—";
if (r.special) return SPECIAL_LABEL[r.special];
if (r.canonical) return episodeCode(r.canonical.season, r.canonical.episodes);
return "—";
}
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]}` : "未选去向";
}
/** 停用原因:手动停用 / 规则失效(带当时的来源季集) */
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";
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}`;
}
/** 从地址读预填草稿;没有任何预填返回 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 ?? "",
};
}
// ── 统计 ──
/** 按发布组的方式统计一句话:`ANi:跨季绝对集号 12、原号 3`(只列非零) */
export function groupMethodsText(g: EpisodeMappingPreview["groups"][number]): string {
const parts = [...g.methods].sort((a, b) => b.count - a.count).map((m) => `${METHOD_LABEL[m.method]} ${m.count}`);
return `${g.releaseGroup ?? "未知发布组"}:${parts.join("、")}`;
}
/** 用绝对集号的发布组(订阅表单提示用) */
export function absoluteGroups(p: EpisodeMappingPreview | undefined): string[] {
if (!p) return [];
return p.groups
.filter((g) => g.methods.some((m) => isAbsoluteMethod(m.method)))
.map((g) => g.releaseGroup ?? "未知发布组");
}
/** 规则写入后的提示:只列非零项 */
export function reresolveToast(r: EpisodeMappingRuleWriteResult["reresolve"], verb = "已应用"): string {
const parts = [`重算 ${r.releases} 条资源`];
if (r.changed) parts.push(`${r.changed} 条有变化`);
if (r.resolved) parts.push(`${r.resolved} 条待确认已解决`);
return `${verb}:${parts.join(",")}`;
}