- 新表 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
149 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
}
|