Files
UHD_Media_Tool/apps/web/app/components/episode-mapping/mapping-panel.tsx
T
rootandClaude Opus 5.5 63e94b308e 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
2026-10-08 17:03:00 +08:00

149 lines
6.0 KiB
TypeScript

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 { useEntityWords, useEpisodeMappingPreview } from "~/api/episode-mapping.ts";
import { ErrorState, LoadingState } from "~/components/console/empty-state.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 { EpisodeIssuesList } from "./issues.tsx";
import { groupMethodsText, lineFromSearch, MAPPING_ANCHOR } from "./model.ts";
import { PreviewTable } from "./preview-table.tsx";
// 作品页「集数映射」分区:季表摘要、作品识别词(与规则页「识别词」同一个编辑器、同一份数据)、
// 按发布组的统计与建议、换算不了的资源,以及近期资源按已保存的识别词的判定。
function SeasonSummary({ p }: { p: EpisodeMappingPreview["seasons"] }) {
const main = p.seasons.filter((s) => s.season >= 1);
if (!main.length) return <p className="text-caption text-muted-foreground">还没有季表,刷新元数据后出现。</p>;
return (
<div className="flex flex-wrap items-center gap-1.5">
{main.map((s) => (
<Chip key={s.season}>
第 {s.season} 季 · {s.total != null ? `${s.total} 集` : "总集数未知"}
{s.season === p.latestSeason ? " · 最新" : ""}
</Chip>
))}
<span className="text-meta text-muted-foreground">
{p.ended ? "已完结" : "未完结"}
{p.structure ? ` · 按剧集组「${p.structure.name}」分季` : ""}
</span>
</div>
);
}
const pad = (n: number) => String(n).padStart(2, "0");
/** 建议规则写成作品识别词的一行 */
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({
groups,
onUseSuggestion,
}: {
groups: EpisodeMappingPreview["groups"];
onUseSuggestion: (line: string) => void;
}) {
if (!groups.length) return null;
return (
<ul className="space-y-1.5">
{groups.map((g) => (
<li key={g.releaseGroup ?? "-"} className="flex flex-wrap items-center gap-x-3 gap-y-1 text-caption">
<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(suggestionLine(g.suggestion!))}>
<Wand2 />
试试「{suggestionLine(g.suggestion)}」
</Button>
) : null}
</li>
))}
</ul>
);
}
export function EpisodeMappingPanel({ entityId }: { entityId: string }) {
const [sp] = useSearchParams();
const location = useLocation();
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;
/** 把一行追加进草稿(建议、集数待确认的「修改」、从别处跳来带的一行) */
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 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="发布者写的季集对不上作品季表时,用作品识别词换成媒体库用的季集"
bodyClassName="space-y-4"
>
{preview.data ? <SeasonSummary p={preview.data.seasons} /> : null}
{words.isPending ? (
<LoadingState rows={3} />
) : words.isError ? (
<ErrorState plain error={words.error} onRetry={() => words.refetch()} />
) : (
<WordsEditor
scope="entity"
entityId={entityId}
block={words.data.block}
knownGroups={words.data.knownGroups}
draft={text}
onDraftChange={setDraft}
/>
)}
{preview.data ? <GroupStats groups={preview.data.groups} onUseSuggestion={append} /> : null}
<EpisodeIssuesList entityId={entityId} onEdit={append} />
</Panel>
<Panel
className="mt-3"
title="近期资源"
description="最近 100 条已绑定资源按已保存的识别词的判定"
bodyClassName="p-0 sm:p-0"
>
{preview.isPending ? (
<div className="p-4">
<LoadingState rows={3} />
</div>
) : preview.isError ? (
<div className="p-4">
<ErrorState plain error={preview.error} onRetry={() => preview.refetch()} />
</div>
) : (
<PreviewTable items={preview.data.items} hasDraft={false} />
)}
</Panel>
</div>
);
}