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,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(",")}`;
|
||||
}
|
||||
Reference in new issue
Block a user