163 lines
6.6 KiB
TypeScript
163 lines
6.6 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 [editing, setEditing] = useState(false);
|
|
const editorRef = useRef<HTMLDetailsElement>(null);
|
|
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) => {
|
|
setEditing(true);
|
|
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`;
|
|
});
|
|
editorRef.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}
|
|
<EpisodeIssuesList entityId={entityId} onEdit={append} />
|
|
<details
|
|
ref={editorRef}
|
|
open={editing}
|
|
onToggle={(event) => setEditing(event.currentTarget.open)}
|
|
className="space-y-3"
|
|
>
|
|
<summary className="cursor-pointer text-caption text-foreground">作品识别词 · 长期规则</summary>
|
|
<p className="text-meta text-muted-foreground">
|
|
按来源季和发布组换算季集,保存后用于这部作品后续的资源与整理文件。
|
|
</p>
|
|
{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}
|
|
/>
|
|
)}
|
|
</details>
|
|
{preview.data ? <GroupStats groups={preview.data.groups} onUseSuggestion={append} /> : null}
|
|
</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>
|
|
);
|
|
}
|