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
This commit is contained in:
1 parent
12f8fc59c3
commit
63e94b308e
90 files changed
+6366
-2131
No files matched your search
@@ -1,24 +1,33 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
EntityWordsDryRun,
|
||||
EntityWordsList,
|
||||
EntityWordsSaveResult,
|
||||
EntityWordsView,
|
||||
EpisodeGroupList,
|
||||
EpisodeGroupSetResult,
|
||||
EpisodeMappingPreview,
|
||||
type EpisodeMappingPreviewInput,
|
||||
type EpisodeMappingRuleCreate,
|
||||
EpisodeMappingRuleList,
|
||||
type EpisodeMappingRuleUpdate,
|
||||
EpisodeMappingRuleWriteResult,
|
||||
EpisodeStructureSeasons,
|
||||
GlobalWordsDryRun,
|
||||
GlobalWordsSaveResult,
|
||||
IdentifyWordsBlock,
|
||||
type WordsTryInput,
|
||||
WordsTryResult,
|
||||
} from "@umt/contracts";
|
||||
import { api } from "~/lib/api.ts";
|
||||
import { decisionKeys } from "./decisions.ts";
|
||||
import { getParsed, libraryKeys, parseOrThrow } from "./library.ts";
|
||||
|
||||
// 集数映射:作品的规则列表 / 增改停删(写入后服务端按作品重算)/ 模拟预览(纯读)。
|
||||
// 识别词(全局 / 作品,原文整块读写;保存前试算,作品保存后服务端按作品重算)、集数映射预览(纯读)、剧集组。
|
||||
|
||||
export const episodeMappingKeys = {
|
||||
all: ["episode-mapping"] as const,
|
||||
rules: (entityId: string) => ["episode-mapping", "rules", entityId] as const,
|
||||
words: (entityId: string | null) => ["episode-mapping", "words", entityId ?? "global"] as const,
|
||||
wordsList: (q: string) => ["episode-mapping", "words-list", q] as const,
|
||||
dryRun: (entityId: string | null, text: string) =>
|
||||
["episode-mapping", "dry-run", entityId ?? "global", text] as const,
|
||||
tryLine: (input: WordsTryInput) => ["episode-mapping", "try", input] as const,
|
||||
preview: (entityId: string, input: EpisodeMappingPreviewInput) =>
|
||||
["episode-mapping", "preview", entityId, input] as const,
|
||||
groups: (entityId: string) => ["episode-mapping", "groups", entityId] as const,
|
||||
@@ -26,12 +35,94 @@ export const episodeMappingKeys = {
|
||||
["episode-mapping", "structure", entityId, groupId ?? "default"] as const,
|
||||
};
|
||||
|
||||
export function useEpisodeMappingRules(entityId: string | undefined) {
|
||||
// ── 识别词 ──
|
||||
|
||||
export function useGlobalWords() {
|
||||
return useQuery({
|
||||
queryKey: episodeMappingKeys.rules(entityId ?? ""),
|
||||
queryKey: episodeMappingKeys.words(null),
|
||||
queryFn: ({ signal }) => getParsed(IdentifyWordsBlock, "/identify-words/global", undefined, signal),
|
||||
// 保存后后台重算有进度:进行中时每 3 秒刷一次
|
||||
refetchInterval: (q) => {
|
||||
const s = q.state.data?.reresolve?.status;
|
||||
return s === "queued" || s === "running" ? 3_000 : false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useEntityWords(entityId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: episodeMappingKeys.words(entityId ?? ""),
|
||||
enabled: !!entityId,
|
||||
queryFn: ({ signal }) =>
|
||||
getParsed(EpisodeMappingRuleList, `/media-entities/${entityId}/episode-mapping-rules`, undefined, signal),
|
||||
getParsed(EntityWordsView, `/media-entities/${entityId}/identify-words`, undefined, signal),
|
||||
});
|
||||
}
|
||||
|
||||
export function useEntityWordsList(q: string) {
|
||||
return useQuery({
|
||||
queryKey: episodeMappingKeys.wordsList(q),
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: ({ signal }) => getParsed(EntityWordsList, "/identify-words/entities", { q: q || undefined }, signal),
|
||||
});
|
||||
}
|
||||
|
||||
/** 试算(纯读):text 为 null 时不发 */
|
||||
export function useWordsDryRun(entityId: string | null, text: string | null) {
|
||||
return useQuery({
|
||||
queryKey: episodeMappingKeys.dryRun(entityId, text ?? ""),
|
||||
enabled: text !== null,
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: async () =>
|
||||
entityId
|
||||
? parseOrThrow(
|
||||
EntityWordsDryRun,
|
||||
await api.post<unknown>(`/media-entities/${entityId}/identify-words/dry-run`, { text }),
|
||||
"/media-entities/{id}/identify-words/dry-run",
|
||||
)
|
||||
: parseOrThrow(
|
||||
GlobalWordsDryRun,
|
||||
await api.post<unknown>("/identify-words/global/dry-run", { text }),
|
||||
"/identify-words/global/dry-run",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/** 一行识别词的实时示例(纯读) */
|
||||
export function useWordsTry(input: WordsTryInput | null) {
|
||||
return useQuery({
|
||||
queryKey: episodeMappingKeys.tryLine(input ?? { scope: "global", line: "" }),
|
||||
enabled: !!input?.line.trim(),
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 60_000,
|
||||
queryFn: async () =>
|
||||
parseOrThrow(WordsTryResult, await api.post<unknown>("/identify-words/try", input), "/identify-words/try"),
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存识别词:成功后刷新识别词、预览、事项与作品详情(季集可能变了) */
|
||||
export function useSaveWords(entityId: string | null) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body: { text: string; expectedVersion: number }) =>
|
||||
entityId
|
||||
? parseOrThrow(
|
||||
EntityWordsSaveResult,
|
||||
await api.put<unknown>(`/media-entities/${entityId}/identify-words`, body),
|
||||
"/media-entities/{id}/identify-words",
|
||||
)
|
||||
: parseOrThrow(
|
||||
GlobalWordsSaveResult,
|
||||
await api.put<unknown>("/identify-words/global", body),
|
||||
"/identify-words/global",
|
||||
),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: episodeMappingKeys.all });
|
||||
void qc.invalidateQueries({ queryKey: decisionKeys.all });
|
||||
if (entityId) {
|
||||
void qc.invalidateQueries({ queryKey: libraryKeys.detail(entityId) });
|
||||
void qc.invalidateQueries({ queryKey: libraryKeys.entity(entityId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,33 +144,6 @@ export function useEpisodeMappingPreview(
|
||||
});
|
||||
}
|
||||
|
||||
type WriteVars =
|
||||
| { op: "create"; entityId: string; body: EpisodeMappingRuleCreate }
|
||||
| { op: "update"; id: string; body: EpisodeMappingRuleUpdate }
|
||||
| { op: "delete"; id: string };
|
||||
|
||||
/** 规则写入:成功后刷新规则、预览、待处理与作品详情(季集可能变了) */
|
||||
export function useEpisodeMappingWrite(entityId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (v: WriteVars) => {
|
||||
const data =
|
||||
v.op === "create"
|
||||
? await api.post<unknown>(`/media-entities/${v.entityId}/episode-mapping-rules`, v.body)
|
||||
: v.op === "update"
|
||||
? await api.patch<unknown>(`/episode-mapping-rules/${v.id}`, v.body)
|
||||
: await api.del<unknown>(`/episode-mapping-rules/${v.id}`);
|
||||
return parseOrThrow(EpisodeMappingRuleWriteResult, data, "/episode-mapping-rules");
|
||||
},
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: episodeMappingKeys.all });
|
||||
void qc.invalidateQueries({ queryKey: decisionKeys.all });
|
||||
void qc.invalidateQueries({ queryKey: libraryKeys.detail(entityId) });
|
||||
void qc.invalidateQueries({ queryKey: libraryKeys.entity(entityId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── 剧集组(季集结构) ──
|
||||
|
||||
/** 作品可选的 TMDB 剧集组与当前选择 */
|
||||
|
||||
@@ -47,8 +47,13 @@ const decision = (over: Partial<Decision> = {}): Decision =>
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{ key: "confirm_suggestion", label: "确认建议规则", description: "按 EP-30 换算到第 2 季" },
|
||||
{ key: "ignore_rule", label: "忽略并建忽略规则" },
|
||||
{
|
||||
key: "confirm_suggestion",
|
||||
label: "确认建议规则",
|
||||
description: "把「S01 @Lilith-Raws >> S02 EP-30」写进作品识别词",
|
||||
input: { line: "S01 @Lilith-Raws >> S02 EP-30" },
|
||||
},
|
||||
{ key: "ignore_rule", label: "忽略并写忽略规则", input: { line: "S01 @Lilith-Raws >> 忽略" } },
|
||||
],
|
||||
...over,
|
||||
}) as Decision;
|
||||
@@ -59,6 +64,7 @@ describe("待处理「集数待确认」", () => {
|
||||
expect(v).toMatchObject({ entityId: ENTITY, season: 1, releaseGroup: "Lilith-Raws", count: 2 });
|
||||
expect(v.releases.map((r) => r.source)).toEqual(["S01E40", "S01E41"]);
|
||||
expect(v.suggestion?.expression).toBe("EP-30");
|
||||
expect(v.suggestionLine).toBe("S01 @Lilith-Raws >> S02 EP-30");
|
||||
expect(v.confirm?.key).toBe("confirm_suggestion");
|
||||
expect(v.ignoreRule?.key).toBe("ignore_rule");
|
||||
const none = episodeDecisionView(decision({ options: [], evidence: { mediaEntityId: ENTITY, suggestion: null } }));
|
||||
@@ -71,20 +77,19 @@ describe("待处理「集数待确认」", () => {
|
||||
);
|
||||
expect(html).toContain("第 1 季 · Lilith-Raws");
|
||||
expect(html).toContain("S01E40");
|
||||
expect(html).toContain("EP-30");
|
||||
expect(html).toContain("第 2 季");
|
||||
expect(html).toContain("S01 @Lilith-Raws >> S02 EP-30");
|
||||
expect(html).toContain("S02E10");
|
||||
expect(html).toContain("套用后 2 条都对得上");
|
||||
});
|
||||
|
||||
it("没有建议时提示到作品页手动建", () => {
|
||||
it("没有建议时提示在作品识别词里手动写", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(EpisodeDecisionBody, {
|
||||
view: episodeDecisionView(decision({ evidence: { mediaEntityId: ENTITY, suggestion: null } })),
|
||||
simulation: null,
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("到作品页手动建");
|
||||
expect(html).toContain("在上面的作品识别词里手动写");
|
||||
});
|
||||
|
||||
it("模拟结果一句话", () => {
|
||||
@@ -96,7 +101,7 @@ describe("待处理「集数待确认」", () => {
|
||||
});
|
||||
|
||||
const agentSuggestion = {
|
||||
rule: { season: 1, releaseGroup: "Lilith-Raws", kind: "expression", expression: "EP-30", targetSeason: 2 },
|
||||
line: "S01 @Lilith-Raws >> S02 EP-30",
|
||||
reasons: ["40、41 的发布时间对上第 2 季第 10、11 集的播出日期"],
|
||||
simulation: { samples: 2, resolved: 2, passed: 2, missing: 0, regressions: 0, changed: 2, scanned: 5, ok: true },
|
||||
samples: [
|
||||
@@ -124,7 +129,7 @@ describe("待处理「集数待确认」", () => {
|
||||
|
||||
it("智能体建议:取规则、理由、模拟结果与「确认智能体建议」", () => {
|
||||
const v = episodeDecisionView(withAgent());
|
||||
expect(v.agentSuggestion?.rule.expression).toBe("EP-30");
|
||||
expect(v.agentSuggestion?.line).toBe("S01 @Lilith-Raws >> S02 EP-30");
|
||||
expect(v.agentSuggestion?.reasons).toHaveLength(1);
|
||||
expect(v.confirmAgent?.key).toBe("confirm_agent_suggestion");
|
||||
expect(v.confirm?.key).toBe("confirm_suggestion");
|
||||
@@ -132,13 +137,12 @@ describe("待处理「集数待确认」", () => {
|
||||
expect(episodeDecisionView(decision())).toMatchObject({ agentSuggestion: null, confirmAgent: null });
|
||||
});
|
||||
|
||||
it("渲染智能体建议:规则、作用域、理由、样本套用后的季集与模拟结果", () => {
|
||||
it("渲染智能体建议:那一行作品识别词、理由、样本套用后的季集与模拟结果", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(EpisodeDecisionBody, { view: episodeDecisionView(withAgent()), simulation: null }),
|
||||
);
|
||||
expect(html).toContain("智能体建议");
|
||||
expect(html).toContain("偏移表达式");
|
||||
expect(html).toContain("EP-30 → 第 2 季");
|
||||
expect(html).toContain("S01 @Lilith-Raws >> S02 EP-30");
|
||||
expect(html).toContain("对上第 2 季第 10、11 集的播出日期");
|
||||
expect(html).toContain("S01E40 → S02E10");
|
||||
expect(html).toContain("模拟:这 2 条都对得上,不影响已对上的资源");
|
||||
|
||||
@@ -2,7 +2,6 @@ import type {
|
||||
Decision,
|
||||
DecisionOption,
|
||||
EpisodeMappingPreview,
|
||||
EpisodeMappingRuleSpec,
|
||||
EpisodeResolutionView,
|
||||
EpisodeResultBrief,
|
||||
EpisodeSimulationSummary,
|
||||
@@ -12,25 +11,25 @@ import { useId, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useResolveDecision } from "~/api/decisions.ts";
|
||||
import { useEpisodeMappingPreview } from "~/api/episode-mapping.ts";
|
||||
import { useEntityWords, useEpisodeMappingPreview } from "~/api/episode-mapping.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { canonicalText, episodeCode, mappingHref, RULE_KIND_LABEL, ruleText, scopeText, sourceText } from "./model.ts";
|
||||
import { canonicalText, episodeCode, mappingHref, scopeText, sourceText } from "./model.ts";
|
||||
|
||||
// 作品页集数映射里的「集数待确认」(episode_mapping 内部事项):来源季集、原因、建议规则与它的模拟结果;
|
||||
// 操作「确认」(建建议规则并重算)、「修改」(跳作品页集数映射并预填草稿)、「忽略」(可同时建忽略规则)。
|
||||
// 智能体建议(批次 5):后台调查没有写规则的授权时,把模拟通过的规则和理由留在事项上,「确认智能体建议」一键建规则
|
||||
// (服务端建规则前按当前资源再模拟一次)。
|
||||
// 作品页集数映射里的「集数待确认」(episode_mapping 内部事项):来源季集、原因、建议的那一行作品识别词与它的模拟结果;
|
||||
// 操作「采用」(把这一行写进作品识别词并重算)、「修改」(把这一行放进编辑器草稿)、「忽略」(可同时写一行忽略)。
|
||||
// 智能体建议(批次 5):后台调查没有写规则的授权时,把模拟通过的那一行和理由留在事项上,「采用智能体建议」写进去
|
||||
// (服务端写之前按当前资源再模拟一次)。
|
||||
|
||||
export const EPISODE_CONFIRM_KEY = "confirm_suggestion";
|
||||
export const EPISODE_CONFIRM_AGENT_KEY = "confirm_agent_suggestion";
|
||||
export const EPISODE_IGNORE_RULE_KEY = "ignore_rule";
|
||||
|
||||
export interface AgentSuggestion {
|
||||
rule: EpisodeMappingRuleSpec;
|
||||
line: string;
|
||||
reasons: string[];
|
||||
simulation: EpisodeSimulationSummary;
|
||||
samples: { title: string; source: EpisodeResolutionView["source"]; after: EpisodeResultBrief }[];
|
||||
@@ -49,6 +48,8 @@ export interface EpisodeDecisionView {
|
||||
releases: { id: string; title: string; source: string }[];
|
||||
count: number;
|
||||
suggestion: Suggestion | null;
|
||||
/** 采用建议规则时写进作品识别词的那一行 */
|
||||
suggestionLine: string | null;
|
||||
agentSuggestion: AgentSuggestion | null;
|
||||
confirm: DecisionOption | null;
|
||||
confirmAgent: DecisionOption | null;
|
||||
@@ -80,19 +81,25 @@ export function episodeDecisionView(d: Decision): EpisodeDecisionView {
|
||||
count: typeof e.count === "number" ? e.count : releases.length,
|
||||
suggestion: s && typeof s.expression === "string" ? s : null,
|
||||
agentSuggestion:
|
||||
a?.rule && a.simulation
|
||||
typeof a?.line === "string" && a.simulation
|
||||
? {
|
||||
...a,
|
||||
reasons: Array.isArray(a.reasons) ? a.reasons : [],
|
||||
samples: Array.isArray(a.samples) ? a.samples : [],
|
||||
}
|
||||
: null,
|
||||
suggestionLine: optionLine(d.options.find((o) => o.key === EPISODE_CONFIRM_KEY)),
|
||||
confirm: d.options.find((o) => o.key === EPISODE_CONFIRM_KEY) ?? null,
|
||||
confirmAgent: d.options.find((o) => o.key === EPISODE_CONFIRM_AGENT_KEY) ?? null,
|
||||
ignoreRule: d.options.find((o) => o.key === EPISODE_IGNORE_RULE_KEY) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const optionLine = (o: DecisionOption | undefined): string | null => {
|
||||
const line = (o?.input as { line?: unknown } | undefined)?.line;
|
||||
return typeof line === "string" ? line : null;
|
||||
};
|
||||
|
||||
/** 建议规则套用到同季同组近期资源的结果一句话 */
|
||||
export function simulationText(p: EpisodeMappingPreview | undefined): string | null {
|
||||
if (!p) return null;
|
||||
@@ -113,11 +120,7 @@ export function agentSimulationText(s: EpisodeSimulationSummary): string {
|
||||
export function AgentSuggestionBody({ s }: { s: AgentSuggestion }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{RULE_KIND_LABEL[s.rule.kind]}:</span>
|
||||
<span className="font-mono">{ruleText(s.rule)}</span>
|
||||
<span className="ml-2 text-meta text-muted-foreground">{scopeText(s.rule.season, s.rule.releaseGroup)}</span>
|
||||
</div>
|
||||
<div className="font-mono break-all">{s.line}</div>
|
||||
{s.reasons.length ? (
|
||||
<ul className="list-disc space-y-0.5 pl-4 text-meta text-muted-foreground">
|
||||
{s.reasons.map((r) => (
|
||||
@@ -170,7 +173,9 @@ export function EpisodeDecisionBody({ view, simulation }: { view: EpisodeDecisio
|
||||
<dd className="min-w-0 text-foreground">
|
||||
{view.suggestion ? (
|
||||
<>
|
||||
<span className="font-mono">{view.suggestion.expression}</span> → 第 {view.suggestion.targetSeason} 季
|
||||
<span className="font-mono break-all">
|
||||
{view.suggestionLine ?? `${view.suggestion.expression} → 第 ${view.suggestion.targetSeason} 季`}
|
||||
</span>
|
||||
<ul className="mt-0.5 space-y-0.5 text-meta text-muted-foreground">
|
||||
{view.suggestion.samples.slice(0, 3).map((s) => (
|
||||
<li key={`${s.source.start}-${s.target.episode}`} className="font-mono tabular-nums">
|
||||
@@ -183,7 +188,7 @@ export function EpisodeDecisionBody({ view, simulation }: { view: EpisodeDecisio
|
||||
{simulation ? <div className="mt-0.5 text-meta text-muted-foreground">{simulation}</div> : null}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">样本不够推出唯一的规则,到作品页手动建</span>
|
||||
<span className="text-muted-foreground">样本不够推出唯一的规则,在上面的作品识别词里手动写</span>
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
@@ -211,7 +216,7 @@ function IgnoreButton({ d, view }: { d: Decision; view: EpisodeDecisionView }) {
|
||||
withRule && view.ignoreRule ? { id: d.id, optionKey: view.ignoreRule.key } : { id: d.id, dismiss: true },
|
||||
)
|
||||
.then(
|
||||
() => toast.success(withRule ? "已忽略,并建了忽略规则" : "已忽略"),
|
||||
() => toast.success(withRule ? "已忽略,并写了忽略规则" : "已忽略"),
|
||||
(err) => {
|
||||
toast.error(errorMessage(err, "忽略失败"));
|
||||
throw err;
|
||||
@@ -222,43 +227,37 @@ function IgnoreButton({ d, view }: { d: Decision; view: EpisodeDecisionView }) {
|
||||
{view.ignoreRule ? (
|
||||
<label htmlFor={id} className="flex items-center gap-2 text-caption text-foreground">
|
||||
<Switch id={id} checked={withRule} onCheckedChange={setWithRule} />
|
||||
同时建「忽略」规则:{scopeText(view.season, view.releaseGroup)}的资源以后不参与集数解析
|
||||
同时写一行「{optionLine(view.ignoreRule) ?? "忽略"}」:这些资源以后不参与集数换算
|
||||
</label>
|
||||
) : null}
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** 集数待确认事项的证据与操作 */
|
||||
export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) {
|
||||
/**
|
||||
* 集数待确认事项的证据与操作。onEdit 给了(作品页集数映射里)时「修改」把这一行放进编辑器草稿,否则跳到作品页。
|
||||
*/
|
||||
export function EpisodeDecision({ d, open, onEdit }: { d: Decision; open: boolean; onEdit?: (line: string) => void }) {
|
||||
const view = episodeDecisionView(d);
|
||||
const resolve = useResolveDecision();
|
||||
const sug = view.suggestion;
|
||||
const line = view.suggestionLine;
|
||||
const words = useEntityWords(open && line ? view.entityId : null);
|
||||
const current = words.data?.block.text.replace(/\n*$/, "") ?? "";
|
||||
const preview = useEpisodeMappingPreview(
|
||||
view.entityId,
|
||||
sug
|
||||
line
|
||||
? {
|
||||
season: view.season,
|
||||
releaseGroup: view.releaseGroup,
|
||||
draft: {
|
||||
season: view.season,
|
||||
releaseGroup: view.releaseGroup,
|
||||
kind: "expression",
|
||||
expression: sug.expression,
|
||||
targetSeason: sug.targetSeason,
|
||||
},
|
||||
words: current ? `${current}\n${line}\n` : `${line}\n`,
|
||||
limit: 50,
|
||||
}
|
||||
: {},
|
||||
open && !!sug,
|
||||
open && !!line && words.isSuccess,
|
||||
);
|
||||
const modifyHref = view.entityId
|
||||
? mappingHref(consolePath("library", view.entityId), {
|
||||
season: view.season,
|
||||
releaseGroup: view.releaseGroup,
|
||||
...(sug ? { expression: sug.expression, targetSeason: sug.targetSeason } : {}),
|
||||
})
|
||||
: null;
|
||||
const editLine = line ?? optionLine(view.ignoreRule ?? undefined);
|
||||
const modifyHref = view.entityId ? mappingHref(consolePath("library", view.entityId), editLine ?? undefined) : null;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<EpisodeDecisionBody view={view} simulation={sug ? simulationText(preview.data) : null} />
|
||||
@@ -272,12 +271,12 @@ export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) {
|
||||
采用智能体建议
|
||||
</Button>
|
||||
}
|
||||
title="按智能体建议建规则?"
|
||||
description={`${view.confirmAgent.description ?? ""}。规则在作品页的集数映射里可以再改。`}
|
||||
confirmLabel="建规则并重算"
|
||||
title="采用智能体建议?"
|
||||
description={`${view.confirmAgent.description ?? ""}。写进去之后可以在作品识别词里再改。`}
|
||||
confirmLabel="写入并重算"
|
||||
onConfirm={() =>
|
||||
resolve.mutateAsync({ id: d.id, optionKey: view.confirmAgent!.key }).then(
|
||||
() => toast.success("已建规则并重算"),
|
||||
() => toast.success("已写进作品识别词并重算"),
|
||||
(err) => {
|
||||
toast.error(errorMessage(err, "处理失败"));
|
||||
throw err;
|
||||
@@ -294,12 +293,12 @@ export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) {
|
||||
采用建议规则
|
||||
</Button>
|
||||
}
|
||||
title="按建议建规则?"
|
||||
description={`${view.confirm.description ?? ""}。规则在作品页的集数映射里可以再改。`}
|
||||
confirmLabel="建规则并重算"
|
||||
title="采用建议规则?"
|
||||
description={`${view.confirm.description ?? ""}。写进去之后可以在作品识别词里再改。`}
|
||||
confirmLabel="写入并重算"
|
||||
onConfirm={() =>
|
||||
resolve.mutateAsync({ id: d.id, optionKey: view.confirm!.key }).then(
|
||||
() => toast.success("已建规则并重算"),
|
||||
() => toast.success("已写进作品识别词并重算"),
|
||||
(err) => {
|
||||
toast.error(errorMessage(err, "处理失败"));
|
||||
throw err;
|
||||
@@ -308,7 +307,12 @@ export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) {
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{modifyHref ? (
|
||||
{onEdit && editLine ? (
|
||||
<Button variant="outline" size="sm" onClick={() => onEdit(editLine)}>
|
||||
<PencilLine />
|
||||
修改
|
||||
</Button>
|
||||
) : modifyHref ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={modifyHref}>
|
||||
<PencilLine />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { humanizeReason } from "~/lib/reasons.ts";
|
||||
import { EpisodeDecision } from "./decision.tsx";
|
||||
|
||||
// 集数待确认的原因(2026-10-06 去掉待处理中心后):不再作为逐条要处理的条目,
|
||||
// 同一作品的问题聚合在作品页「集数映射」与订阅详情里显示原因;智能体建议 / 建议规则仍可在这里一键确认。
|
||||
// 同一作品的问题聚合在作品页「集数映射」与订阅详情里显示原因;智能体建议 / 建议规则可在这里采用(写进作品识别词)。
|
||||
// 数据来自内部事项 episode_mapping(只给关注中的作品开,见 server sources-identity/service/watched.ts)。
|
||||
|
||||
const OPEN = ["open", "investigating", "awaiting_user"];
|
||||
@@ -27,7 +27,7 @@ export function useEpisodeIssues(entityId: string | null | undefined, season?: n
|
||||
}
|
||||
|
||||
/** 作品页集数映射分区:逐组写原因,建议规则 / 智能体建议可确认 */
|
||||
export function EpisodeIssuesList({ entityId }: { entityId: string }) {
|
||||
export function EpisodeIssuesList({ entityId, onEdit }: { entityId: string; onEdit?: (line: string) => void }) {
|
||||
const { items } = useEpisodeIssues(entityId);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
@@ -36,7 +36,7 @@ export function EpisodeIssuesList({ entityId }: { entityId: string }) {
|
||||
<ul className="space-y-3">
|
||||
{items.map((d) => (
|
||||
<li key={d.id} className="rounded-lg border border-border/80 p-3">
|
||||
<EpisodeDecision d={d} open />
|
||||
<EpisodeDecision d={d} open {...(onEdit ? { onEdit } : {})} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,59 +1,19 @@
|
||||
import type {
|
||||
EpisodeMappingPreview,
|
||||
EpisodeMappingPreviewInput,
|
||||
EpisodeMappingRuleRow,
|
||||
EpisodeMappingRuleSpec,
|
||||
EpisodeSpecialKind,
|
||||
EpisodeSuggestion,
|
||||
} from "@umt/contracts";
|
||||
import { Pause, PencilLine, Play, Plus, Trash2, Wand2 } from "lucide-react";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
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 { toast } from "sonner";
|
||||
import { useEpisodeMappingPreview, useEpisodeMappingRules, useEpisodeMappingWrite } from "~/api/episode-mapping.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { useEntityWords, useEpisodeMappingPreview } from "~/api/episode-mapping.ts";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { FormField } from "~/components/console/form-field.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 { Input } from "~/components/ui/input.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { actorText } from "~/lib/labels.ts";
|
||||
import { EpisodeIssuesList } from "./issues.tsx";
|
||||
import {
|
||||
agentRuleInfo,
|
||||
disabledReasonText,
|
||||
draftFromSearch,
|
||||
draftOfRule,
|
||||
EMPTY_DRAFT,
|
||||
groupMethodsText,
|
||||
MAPPING_ANCHOR,
|
||||
RULE_KIND_LABEL,
|
||||
type RuleDraft,
|
||||
reresolveToast,
|
||||
ruleText,
|
||||
SPECIAL_LABEL,
|
||||
scopeText,
|
||||
specOfDraft,
|
||||
} from "./model.ts";
|
||||
import { groupMethodsText, lineFromSearch, MAPPING_ANCHOR } from "./model.ts";
|
||||
import { PreviewTable } from "./preview-table.tsx";
|
||||
|
||||
// 作品页「集数映射」分区(批次 4):季表摘要、规则列表(增改停删)、草稿规则边改边预览、应用后提示重算了多少条。
|
||||
|
||||
/** 草稿改动后等这么久再预览 */
|
||||
const PREVIEW_DEBOUNCE_MS = 400;
|
||||
|
||||
function useDebounced<T>(value: T, ms: number): T {
|
||||
const [v, setV] = useState(value);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setV(value), ms);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, ms]);
|
||||
return v;
|
||||
}
|
||||
// 作品页「集数映射」分区:季表摘要、作品识别词(与规则页「识别词」同一个编辑器、同一份数据)、
|
||||
// 按发布组的统计与建议、换算不了的资源,以及近期资源按已保存的识别词的判定。
|
||||
|
||||
function SeasonSummary({ p }: { p: EpisodeMappingPreview["seasons"] }) {
|
||||
const main = p.seasons.filter((s) => s.season >= 1);
|
||||
@@ -74,215 +34,12 @@ function SeasonSummary({ p }: { p: EpisodeMappingPreview["seasons"] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RuleRow({
|
||||
rule,
|
||||
editing,
|
||||
onEdit,
|
||||
write,
|
||||
}: {
|
||||
rule: EpisodeMappingRuleRow;
|
||||
editing: boolean;
|
||||
onEdit: () => void;
|
||||
write: ReturnType<typeof useEpisodeMappingWrite>;
|
||||
}) {
|
||||
const reason = disabledReasonText(rule);
|
||||
const agent = agentRuleInfo(rule);
|
||||
const run = (vars: Parameters<typeof write.mutateAsync>[0], verb: string) =>
|
||||
write.mutateAsync(vars).then(
|
||||
(r) => toast.success(reresolveToast(r.reresolve, verb)),
|
||||
(e) => {
|
||||
toast.error(errorMessage(e));
|
||||
throw e;
|
||||
},
|
||||
);
|
||||
return (
|
||||
<li className="flex flex-wrap items-start gap-x-3 gap-y-1.5 border-t border-border/60 py-2.5 first:border-t-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-caption font-medium text-foreground">{scopeText(rule.season, rule.releaseGroup)}</span>
|
||||
<StatusBadge tone={rule.status === "active" ? "success" : "neutral"}>
|
||||
{rule.status === "active" ? "启用" : "停用"}
|
||||
</StatusBadge>
|
||||
{agent ? <Chip>{agent.label}</Chip> : null}
|
||||
{editing ? <span className="text-meta text-muted-foreground">修改中</span> : null}
|
||||
</div>
|
||||
<div className="mt-0.5 text-meta text-muted-foreground">
|
||||
{RULE_KIND_LABEL[rule.kind]}:<span className="font-mono text-foreground">{ruleText(rule)}</span>
|
||||
{agent ? null : (
|
||||
<>
|
||||
{" · "}
|
||||
{actorText(rule.createdById ? `${rule.createdByType}:${rule.createdById}` : rule.createdByType)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{agent?.reasons.length ? (
|
||||
<div className="mt-0.5 text-meta text-muted-foreground">依据:{agent.reasons.join(";")}</div>
|
||||
) : null}
|
||||
{reason ? <div className="mt-0.5 text-meta text-muted-foreground">{reason}</div> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button variant="outline" size="sm" onClick={onEdit}>
|
||||
<PencilLine />
|
||||
修改
|
||||
</Button>
|
||||
{rule.status === "active" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={write.isPending}
|
||||
onClick={() =>
|
||||
void run({ op: "update", id: rule.id, body: { status: "disabled" } }, "已停用").catch(() => {})
|
||||
}
|
||||
>
|
||||
<Pause />
|
||||
停用
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={write.isPending}
|
||||
onClick={() =>
|
||||
void run({ op: "update", id: rule.id, body: { status: "active" } }, "已启用").catch(() => {})
|
||||
}
|
||||
>
|
||||
<Play />
|
||||
启用
|
||||
</Button>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" aria-label="删除规则">
|
||||
<Trash2 />
|
||||
</Button>
|
||||
}
|
||||
title="删除这条规则?"
|
||||
description={`删除后按作品重算:「${scopeText(rule.season, rule.releaseGroup)}」的资源回到自动判定。不能恢复。`}
|
||||
confirmLabel="删除"
|
||||
destructive
|
||||
onConfirm={() => run({ op: "delete", id: rule.id }, "已删除")}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
function DraftEditor({
|
||||
draft,
|
||||
onChange,
|
||||
error,
|
||||
groups,
|
||||
}: {
|
||||
draft: RuleDraft;
|
||||
onChange: (d: RuleDraft) => void;
|
||||
error: string | null;
|
||||
groups: string[];
|
||||
}) {
|
||||
const id = useId();
|
||||
const f = (k: string) => `${id}-${k}`;
|
||||
const set = (patch: Partial<RuleDraft>) => onChange({ ...draft, ...patch });
|
||||
return (
|
||||
<div className="grid gap-x-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<FormField label="来源季" htmlFor={f("season")} hint="空着 = 任意季">
|
||||
<Input
|
||||
id={f("season")}
|
||||
inputMode="numeric"
|
||||
value={draft.season}
|
||||
onChange={(e) => set({ season: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="发布组" htmlFor={f("group")} hint="空着 = 任意发布组">
|
||||
<Input
|
||||
id={f("group")}
|
||||
list={f("groups")}
|
||||
value={draft.releaseGroup}
|
||||
onChange={(e) => set({ releaseGroup: e.target.value })}
|
||||
/>
|
||||
<datalist id={f("groups")}>
|
||||
{groups.map((g) => (
|
||||
<option key={g} value={g} />
|
||||
))}
|
||||
</datalist>
|
||||
</FormField>
|
||||
<FormField label="类型" htmlFor={f("kind")}>
|
||||
<Select value={draft.kind} onValueChange={(v) => set({ kind: v as RuleDraft["kind"] })}>
|
||||
<SelectTrigger id={f("kind")} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(RULE_KIND_LABEL) as RuleDraft["kind"][]).map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{RULE_KIND_LABEL[k]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
{draft.kind === "expression" ? (
|
||||
<FormField label="目标季" htmlFor={f("target")} hint="空着 = 来源季">
|
||||
<Input
|
||||
id={f("target")}
|
||||
inputMode="numeric"
|
||||
value={draft.targetSeason}
|
||||
onChange={(e) => set({ targetSeason: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
) : draft.kind === "special" ? (
|
||||
<FormField label="映射为" htmlFor={f("special")}>
|
||||
<Select value={draft.special} onValueChange={(v) => set({ special: v as EpisodeSpecialKind })}>
|
||||
<SelectTrigger id={f("special")} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(SPECIAL_LABEL) as EpisodeSpecialKind[]).map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{SPECIAL_LABEL[k]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{draft.kind === "expression" ? (
|
||||
<FormField
|
||||
label="偏移表达式"
|
||||
htmlFor={f("expr")}
|
||||
className="sm:col-span-2 lg:col-span-4"
|
||||
error={error}
|
||||
hint="EP 是来源集号:EP-12 表示减 12,EP*2-1 表示双集拆分"
|
||||
>
|
||||
<Input
|
||||
id={f("expr")}
|
||||
className="font-mono"
|
||||
value={draft.expression}
|
||||
onChange={(e) => set({ expression: e.target.value })}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
</FormField>
|
||||
) : draft.kind === "table" ? (
|
||||
<FormField
|
||||
label="对照表"
|
||||
htmlFor={f("table")}
|
||||
className="sm:col-span-2 lg:col-span-4"
|
||||
error={error}
|
||||
hint="每行一条:26=S02E01,区间写 27-28=S02E02"
|
||||
>
|
||||
<Textarea
|
||||
id={f("table")}
|
||||
className="font-mono"
|
||||
rows={4}
|
||||
value={draft.table}
|
||||
onChange={(e) => set({ table: e.target.value })}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
</FormField>
|
||||
) : error ? (
|
||||
<p className="text-meta text-destructive sm:col-span-2 lg:col-span-4">{error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
/** 建议规则写成作品识别词的一行 */
|
||||
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({
|
||||
@@ -290,7 +47,7 @@ function GroupStats({
|
||||
onUseSuggestion,
|
||||
}: {
|
||||
groups: EpisodeMappingPreview["groups"];
|
||||
onUseSuggestion: (s: EpisodeSuggestion) => void;
|
||||
onUseSuggestion: (line: string) => void;
|
||||
}) {
|
||||
if (!groups.length) return null;
|
||||
return (
|
||||
@@ -300,9 +57,9 @@ function GroupStats({
|
||||
<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(g.suggestion!)}>
|
||||
<Button variant="ghost" size="sm" onClick={() => onUseSuggestion(suggestionLine(g.suggestion!))}>
|
||||
<Wand2 />
|
||||
试试建议 {g.suggestion.expression} → 第 {g.suggestion.targetSeason} 季
|
||||
试试「{suggestionLine(g.suggestion)}」
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
@@ -314,139 +71,64 @@ function GroupStats({
|
||||
export function EpisodeMappingPanel({ entityId }: { entityId: string }) {
|
||||
const [sp] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const rules = useEpisodeMappingRules(entityId);
|
||||
const write = useEpisodeMappingWrite(entityId);
|
||||
const [draft, setDraft] = useState<RuleDraft | null>(() => draftFromSearch(sp));
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
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;
|
||||
|
||||
// 从待处理「修改」跳来:预填草稿并滚到这里
|
||||
useEffect(() => {
|
||||
const d = draftFromSearch(sp);
|
||||
if (d) {
|
||||
setDraft(d);
|
||||
setEditingId(null);
|
||||
}
|
||||
if (location.hash === `#${MAPPING_ANCHOR}`) ref.current?.scrollIntoView({ block: "start" });
|
||||
}, [sp, location.hash]);
|
||||
|
||||
const parsed = useMemo(() => (draft ? specOfDraft(draft) : { spec: null, error: null }), [draft]);
|
||||
const input: EpisodeMappingPreviewInput = useMemo(() => {
|
||||
const spec = parsed.spec;
|
||||
if (!draft) return {};
|
||||
return {
|
||||
...(spec ? { draft: spec, season: spec.season ?? null, releaseGroup: spec.releaseGroup ?? null } : {}),
|
||||
...(editingId ? { replaceRuleId: editingId } : {}),
|
||||
};
|
||||
}, [draft, parsed.spec, editingId]);
|
||||
const debounced = useDebounced(input, PREVIEW_DEBOUNCE_MS);
|
||||
const preview = useEpisodeMappingPreview(entityId, debounced);
|
||||
const groupNames = useMemo(
|
||||
() => (preview.data?.groups ?? []).map((g) => g.releaseGroup).filter((g): g is string => !!g),
|
||||
[preview.data],
|
||||
);
|
||||
|
||||
const startNew = () => {
|
||||
setEditingId(null);
|
||||
setDraft({ ...EMPTY_DRAFT });
|
||||
};
|
||||
const cancel = () => {
|
||||
setDraft(null);
|
||||
setEditingId(null);
|
||||
};
|
||||
const apply = (spec: EpisodeMappingRuleSpec) =>
|
||||
write
|
||||
.mutateAsync(editingId ? { op: "update", id: editingId, body: spec } : { op: "create", entityId, body: spec })
|
||||
.then(
|
||||
(r) => {
|
||||
toast.success(reresolveToast(r.reresolve));
|
||||
cancel();
|
||||
},
|
||||
(e) => toast.error(errorMessage(e, "应用失败")),
|
||||
);
|
||||
const adoptSuggestion = (s: EpisodeSuggestion) => {
|
||||
setEditingId(null);
|
||||
setDraft({
|
||||
...EMPTY_DRAFT,
|
||||
season: s.season != null ? String(s.season) : "",
|
||||
releaseGroup: s.releaseGroup ?? "",
|
||||
expression: s.expression,
|
||||
targetSeason: String(s.targetSeason),
|
||||
/** 把一行追加进草稿(建议、集数待确认的「修改」、从别处跳来带的一行) */
|
||||
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 list = rules.data?.items ?? [];
|
||||
// 从集数待确认「修改」跳来:把那一行放进草稿并滚到这里
|
||||
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="发布者写的季集对不上作品季表时,按规则换成媒体库用的季集"
|
||||
actions={
|
||||
draft ? null : (
|
||||
<Button variant="outline" size="sm" onClick={startNew}>
|
||||
<Plus />
|
||||
新建规则
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
description="发布者写的季集对不上作品季表时,用作品识别词换成媒体库用的季集"
|
||||
bodyClassName="space-y-4"
|
||||
>
|
||||
{preview.data ? <SeasonSummary p={preview.data.seasons} /> : null}
|
||||
|
||||
{rules.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : rules.isError ? (
|
||||
<ErrorState plain error={rules.error} onRetry={() => rules.refetch()} />
|
||||
) : list.length ? (
|
||||
<ul>
|
||||
{list.map((r) => (
|
||||
<RuleRow
|
||||
key={r.id}
|
||||
rule={r}
|
||||
editing={editingId === r.id}
|
||||
write={write}
|
||||
onEdit={() => {
|
||||
setEditingId(r.id);
|
||||
setDraft(draftOfRule(r));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{words.isPending ? (
|
||||
<LoadingState rows={3} />
|
||||
) : words.isError ? (
|
||||
<ErrorState plain error={words.error} onRetry={() => words.refetch()} />
|
||||
) : (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
还没有规则。资源的集数先按原号、绝对集号和播出日期自动判定,换算不了的在这里列出原因。
|
||||
</p>
|
||||
<WordsEditor
|
||||
scope="entity"
|
||||
entityId={entityId}
|
||||
block={words.data.block}
|
||||
knownGroups={words.data.knownGroups}
|
||||
draft={text}
|
||||
onDraftChange={setDraft}
|
||||
/>
|
||||
)}
|
||||
|
||||
{draft ? (
|
||||
<div className="rounded-xl border border-border/60 px-4 pt-3 pb-2">
|
||||
<div className="mb-2 text-caption font-medium text-foreground">{editingId ? "修改规则" : "新建规则"}</div>
|
||||
<DraftEditor draft={draft} onChange={setDraft} error={parsed.error} groups={groupNames} />
|
||||
<div className="flex flex-wrap items-center gap-2 pb-1">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!parsed.spec || write.isPending}
|
||||
onClick={() => parsed.spec && void apply(parsed.spec)}
|
||||
>
|
||||
应用
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={cancel}>
|
||||
取消
|
||||
</Button>
|
||||
{preview.data && parsed.spec ? (
|
||||
<span className="text-meta text-muted-foreground">预览里 {preview.data.changed} 条会变化</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{preview.data ? <GroupStats groups={preview.data.groups} onUseSuggestion={adoptSuggestion} /> : null}
|
||||
<EpisodeIssuesList entityId={entityId} />
|
||||
{preview.data ? <GroupStats groups={preview.data.groups} onUseSuggestion={append} /> : null}
|
||||
<EpisodeIssuesList entityId={entityId} onEdit={append} />
|
||||
</Panel>
|
||||
<Panel
|
||||
className="mt-3"
|
||||
title={draft ? "模拟预览" : "近期资源"}
|
||||
description={draft ? "按草稿规则试算,不会保存;应用后才按作品重算" : "最近 100 条已绑定资源按当前规则的判定"}
|
||||
title="近期资源"
|
||||
description="最近 100 条已绑定资源按已保存的识别词的判定"
|
||||
bodyClassName="p-0 sm:p-0"
|
||||
>
|
||||
{preview.isPending ? (
|
||||
@@ -458,7 +140,7 @@ export function EpisodeMappingPanel({ entityId }: { entityId: string }) {
|
||||
<ErrorState plain error={preview.error} onRetry={() => preview.refetch()} />
|
||||
</div>
|
||||
) : (
|
||||
<PreviewTable items={preview.data.items} hasDraft={!!input.draft} />
|
||||
<PreviewTable items={preview.data.items} hasDraft={false} />
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
import type { EpisodeMappingPreview, EpisodeMappingRuleRow } from "@umt/contracts";
|
||||
import type { EpisodeMappingPreview } from "@umt/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EXCLUDE_REASON } from "../organize-wizard/model.ts";
|
||||
import {
|
||||
absoluteGroups,
|
||||
agentRuleInfo,
|
||||
canonicalText,
|
||||
disabledReasonText,
|
||||
draftFromSearch,
|
||||
draftOfRule,
|
||||
EMPTY_DRAFT,
|
||||
groupMethodsText,
|
||||
lineFromSearch,
|
||||
METHOD_LABEL,
|
||||
mappingHref,
|
||||
methodText,
|
||||
parseTable,
|
||||
reresolveToast,
|
||||
ruleText,
|
||||
sourceText,
|
||||
specOfDraft,
|
||||
} from "./model.ts";
|
||||
|
||||
describe("集数映射文案", () => {
|
||||
it("方式名称给人话,覆盖全部方式", () => {
|
||||
expect(METHOD_LABEL).toEqual({
|
||||
raw: "原号",
|
||||
rule: "按规则",
|
||||
rule: "按作品识别词",
|
||||
absolute_in_season: "季内绝对集号",
|
||||
absolute_cross_season: "跨季绝对集号",
|
||||
air_date: "按播出日期",
|
||||
@@ -33,7 +26,7 @@ describe("集数映射文案", () => {
|
||||
unresolved: "待确认",
|
||||
skipped: "不适用",
|
||||
});
|
||||
expect(methodText({ method: "rule", special: "ignore" })).toBe("按规则:忽略");
|
||||
expect(methodText({ method: "rule", special: "ignore" })).toBe("按作品识别词:忽略");
|
||||
expect(methodText(null)).toBe("未解析");
|
||||
});
|
||||
|
||||
@@ -52,86 +45,16 @@ describe("集数映射文案", () => {
|
||||
expect(EXCLUDE_REASON.episode_unresolved).toBe("集数待确认");
|
||||
expect(EXCLUDE_REASON.special_extra).toBeTruthy();
|
||||
});
|
||||
|
||||
it("停用原因:手动 / 规则失效", () => {
|
||||
const rule = { status: "disabled", disabledReason: JSON.stringify({ manual: true }) } as EpisodeMappingRuleRow;
|
||||
expect(disabledReasonText(rule)).toBe("手动停用");
|
||||
expect(
|
||||
disabledReasonText({
|
||||
...rule,
|
||||
disabledReason: JSON.stringify({ source: { season: 2, episodes: { start: 30, end: 30 } }, evidence: [] }),
|
||||
}),
|
||||
).toBe("规则算出的集数对不上季表(来源 S02E30),已自动停用");
|
||||
expect(disabledReasonText({ ...rule, status: "active" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("对照表与草稿", () => {
|
||||
it("对照表文本解析与回写", () => {
|
||||
const t = parseTable("26=S02E01\n27-28 = s2e2\n");
|
||||
expect(t.error).toBeNull();
|
||||
expect(t.entries).toEqual([
|
||||
{ from: { start: 26, end: 26 }, to: { season: 2, episode: 1 } },
|
||||
{ from: { start: 27, end: 28 }, to: { season: 2, episode: 2 } },
|
||||
]);
|
||||
expect(ruleText({ kind: "table", tableEntries: t.entries })).toBe("26=S02E01 等 2 行");
|
||||
expect(parseTable("26->").error).toContain("第 1 行");
|
||||
expect(parseTable("28-27=S02E01").error).toContain("写反");
|
||||
});
|
||||
|
||||
it("草稿 → 规则内容:不完整时给错误,不发预览", () => {
|
||||
expect(specOfDraft({ ...EMPTY_DRAFT, expression: "" }).error).toContain("偏移表达式");
|
||||
expect(specOfDraft({ ...EMPTY_DRAFT, season: "x", expression: "EP-12" }).error).toContain("来源季");
|
||||
expect(specOfDraft({ ...EMPTY_DRAFT, season: "2", releaseGroup: " ANi ", expression: "EP-12" }).spec).toEqual({
|
||||
season: 2,
|
||||
releaseGroup: "ANi",
|
||||
kind: "expression",
|
||||
expression: "EP-12",
|
||||
targetSeason: null,
|
||||
});
|
||||
expect(specOfDraft({ ...EMPTY_DRAFT, kind: "special", special: "extra" }).spec).toMatchObject({
|
||||
kind: "special",
|
||||
special: "extra",
|
||||
season: null,
|
||||
releaseGroup: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("规则 → 草稿 → 规则内容 保持一致", () => {
|
||||
const row = {
|
||||
season: 1,
|
||||
releaseGroup: "Lilith-Raws",
|
||||
kind: "expression",
|
||||
expression: "EP-30",
|
||||
targetSeason: 2,
|
||||
tableEntries: null,
|
||||
special: null,
|
||||
} as EpisodeMappingRuleRow;
|
||||
expect(specOfDraft(draftOfRule(row)).spec).toEqual({
|
||||
season: 1,
|
||||
releaseGroup: "Lilith-Raws",
|
||||
kind: "expression",
|
||||
expression: "EP-30",
|
||||
targetSeason: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("待处理「修改」的跳转地址能读回预填草稿", () => {
|
||||
const href = mappingHref("/console/library/x", {
|
||||
season: 1,
|
||||
releaseGroup: "ANi",
|
||||
expression: "EP-12",
|
||||
targetSeason: 2,
|
||||
});
|
||||
expect(href).toBe("/console/library/x?mapSeason=1&mapGroup=ANi&mapExpr=EP-12&mapTarget=2#episode-mapping");
|
||||
describe("作品页地址", () => {
|
||||
it("集数待确认「修改」带上要追加的一行,作品页能读回", () => {
|
||||
const href = mappingHref("/console/library/x", "S01 @ANi >> S02 EP-12");
|
||||
expect(href).toBe("/console/library/x?mapLine=S01+%40ANi+%3E%3E+S02+EP-12#episode-mapping");
|
||||
const sp = new URLSearchParams(href.slice(href.indexOf("?") + 1, href.indexOf("#")));
|
||||
expect(draftFromSearch(sp)).toMatchObject({
|
||||
season: "1",
|
||||
releaseGroup: "ANi",
|
||||
expression: "EP-12",
|
||||
targetSeason: "2",
|
||||
});
|
||||
expect(draftFromSearch(new URLSearchParams())).toBeNull();
|
||||
expect(lineFromSearch(sp)).toBe("S01 @ANi >> S02 EP-12");
|
||||
expect(lineFromSearch(new URLSearchParams())).toBeNull();
|
||||
expect(mappingHref("/console/library/x")).toBe("/console/library/x#episode-mapping");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,24 +84,11 @@ describe("按发布组统计", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("应用后的提示", () => {
|
||||
describe("保存后的提示", () => {
|
||||
it("只列非零项", () => {
|
||||
expect(reresolveToast({ releases: 12, changed: 2, resolved: 2 })).toBe(
|
||||
"已应用:重算 12 条资源,2 条有变化,2 条待确认已解决",
|
||||
"已保存:重算 12 条资源,2 条有变化,2 条待确认已解决",
|
||||
);
|
||||
expect(reresolveToast({ releases: 3, changed: 0, resolved: 0 }, "已停用")).toBe("已停用:重算 3 条资源");
|
||||
});
|
||||
});
|
||||
|
||||
describe("智能体写的规则", () => {
|
||||
it("标出智能体来源与证据等级,带写规则时的理由;其它来源不标", () => {
|
||||
const rule = (over: Partial<EpisodeMappingRuleRow>) =>
|
||||
({ createdByType: "agent", evidenceLevel: "multi_evidence", evidence: {}, ...over }) as EpisodeMappingRuleRow;
|
||||
expect(agentRuleInfo(rule({ evidence: { reasons: ["发布时间对上播出日期", 3] } }))).toEqual({
|
||||
label: "智能体 · 多证据",
|
||||
reasons: ["发布时间对上播出日期"],
|
||||
});
|
||||
expect(agentRuleInfo(rule({ evidenceLevel: "needs_review" }))?.label).toBe("智能体 · 待复核");
|
||||
expect(agentRuleInfo(rule({ createdByType: "user", evidenceLevel: "manual" }))).toBeNull();
|
||||
expect(reresolveToast({ releases: 3, changed: 0, resolved: 0 }, "已写入")).toBe("已写入:重算 3 条资源");
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,20 @@
|
||||
import type {
|
||||
EpisodeMappingPreview,
|
||||
EpisodeMappingRuleRow,
|
||||
EpisodeMappingRuleSpec,
|
||||
EpisodeMappingRuleWriteResult,
|
||||
EpisodeMappingTableEntry,
|
||||
EpisodeReresolveResult,
|
||||
EpisodeResolutionMethod,
|
||||
EpisodeResolutionView,
|
||||
EpisodeSpecialKind,
|
||||
EpisodeValidationState,
|
||||
} from "@umt/contracts";
|
||||
import type { StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { EVIDENCE_LEVEL } from "~/components/library/labels.ts";
|
||||
|
||||
// 集数映射的界面文案与纯函数(docs/plan/episode-resolution.md 批次 4):方式名称、季集写法、规则摘要、
|
||||
// 表格规则的文本格式、作品页「集数映射」分区的地址。
|
||||
// 集数映射的界面文案与纯函数:方式名称、季集写法、作品页「集数映射」分区的地址。
|
||||
// 规则本身是作品识别词(components/identify-words),这里不再有结构化规则。
|
||||
|
||||
/** 识别方式的人话 */
|
||||
export const METHOD_LABEL: Record<EpisodeResolutionMethod, string> = {
|
||||
raw: "原号",
|
||||
rule: "按规则",
|
||||
rule: "按作品识别词",
|
||||
absolute_in_season: "季内绝对集号",
|
||||
absolute_cross_season: "跨季绝对集号",
|
||||
air_date: "按播出日期",
|
||||
@@ -34,12 +30,6 @@ export const SPECIAL_LABEL: Record<EpisodeSpecialKind, string> = {
|
||||
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" },
|
||||
@@ -52,7 +42,7 @@ export const VALIDATION: Record<EpisodeValidationState, { label: string; tone: S
|
||||
/** 方式名称:按规则映射成特别篇等时写具体去向 */
|
||||
export function methodText(r: Pick<EpisodeResolutionView, "method" | "special"> | null | undefined): string {
|
||||
if (!r) return "未解析";
|
||||
if (r.special) return `按规则:${SPECIAL_LABEL[r.special]}`;
|
||||
if (r.special) return `按作品识别词:${SPECIAL_LABEL[r.special]}`;
|
||||
return METHOD_LABEL[r.method] ?? r.method;
|
||||
}
|
||||
|
||||
@@ -91,166 +81,19 @@ export function canonicalText(r: Pick<EpisodeResolutionView, "canonical" | "spec
|
||||
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]}` : "未选去向";
|
||||
}
|
||||
|
||||
/** 智能体写的规则:来源与证据等级(「智能体 · 多证据」)、写规则时的理由;其它来源返回 null */
|
||||
export function agentRuleInfo(
|
||||
rule: Pick<EpisodeMappingRuleRow, "createdByType" | "evidenceLevel" | "evidence">,
|
||||
): { label: string; reasons: string[] } | null {
|
||||
if (rule.createdByType !== "agent") return null;
|
||||
const raw = (rule.evidence as { reasons?: unknown }).reasons;
|
||||
return {
|
||||
label: `智能体 · ${EVIDENCE_LEVEL[rule.evidenceLevel] ?? rule.evidenceLevel}`,
|
||||
reasons: Array.isArray(raw) ? raw.filter((x): x is string => typeof x === "string") : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** 停用原因:手动停用 / 规则失效(带当时的来源季集) */
|
||||
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";
|
||||
const LINE_PARAM = "mapLine";
|
||||
|
||||
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}`;
|
||||
export function mappingHref(libraryPath: string, line?: string): string {
|
||||
const qs = line ? `?${new URLSearchParams({ [LINE_PARAM]: line })}` : "";
|
||||
return `${libraryPath}${qs}#${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 ?? "",
|
||||
};
|
||||
/** 从地址读要追加进作品识别词草稿的一行;没有返回 null */
|
||||
export function lineFromSearch(sp: URLSearchParams): string | null {
|
||||
return sp.get(LINE_PARAM)?.trim() || null;
|
||||
}
|
||||
|
||||
// ── 统计 ──
|
||||
@@ -270,7 +113,7 @@ export function absoluteGroups(p: EpisodeMappingPreview | undefined): string[] {
|
||||
}
|
||||
|
||||
/** 规则写入后的提示:只列非零项 */
|
||||
export function reresolveToast(r: EpisodeMappingRuleWriteResult["reresolve"], verb = "已应用"): string {
|
||||
export function reresolveToast(r: EpisodeReresolveResult, verb = "已保存"): string {
|
||||
const parts = [`重算 ${r.releases} 条资源`];
|
||||
if (r.changed) parts.push(`${r.changed} 条有变化`);
|
||||
if (r.resolved) parts.push(`${r.resolved} 条待确认已解决`);
|
||||
|
||||
@@ -22,6 +22,7 @@ const item = (over: Partial<EpisodeMappingPreviewItem>): EpisodeMappingPreviewIt
|
||||
ruleId: "draft",
|
||||
evidence: [],
|
||||
},
|
||||
words: [],
|
||||
validation: "pass",
|
||||
changed: true,
|
||||
...over,
|
||||
@@ -34,12 +35,29 @@ describe("模拟预览表格", () => {
|
||||
expect(html).toContain("S01E40");
|
||||
expect(html).toContain("待确认");
|
||||
expect(html).toContain("S02E10");
|
||||
expect(html).toContain("按规则");
|
||||
expect(html).toContain("按作品识别词");
|
||||
expect(html).toContain("对得上");
|
||||
expect(html).toContain("有变化");
|
||||
expect(html).toContain('data-changed="true"');
|
||||
});
|
||||
|
||||
it("方式下面写命中的作品识别词行与全局识别词", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(PreviewTable, {
|
||||
items: [
|
||||
item({
|
||||
preview: { ...item({}).preview, ruleLine: 2, ruleText: "S01 @Lilith-Raws >> S02 EP-30" },
|
||||
words: ["Kusuriya => 药屋"],
|
||||
}),
|
||||
],
|
||||
hasDraft: true,
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("第 2 行 S01 @Lilith-Raws >> S02 EP-30");
|
||||
expect(html).toContain("命中识别词:");
|
||||
expect(html).toContain("Kusuriya => 药屋");
|
||||
});
|
||||
|
||||
it("没变化不高亮;校验不通过显示对不上;没有草稿时列名是重算", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(PreviewTable, {
|
||||
|
||||
@@ -4,7 +4,8 @@ import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import { canonicalText, methodText, sourceText, VALIDATION } from "./model.ts";
|
||||
|
||||
// 模拟预览表格:原始季集 → 当前结果 → 套用草稿后的结果、方式、校验;与当前不同的行高亮并标「有变化」。
|
||||
// 模拟预览表格:原始季集 → 当前结果 → 套用草稿后的结果、方式(命中的作品识别词行、全局识别词)、校验;
|
||||
// 与当前不同的行高亮并标「有变化」。
|
||||
|
||||
export function PreviewTable({
|
||||
items,
|
||||
@@ -59,7 +60,20 @@ export function PreviewTable({
|
||||
</div>
|
||||
{it.changed ? <div className="text-meta text-muted-foreground">有变化</div> : null}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">{methodText(it.preview)}</td>
|
||||
<td className="max-w-[16rem] px-3 py-2 align-top">
|
||||
<div>{methodText(it.preview)}</div>
|
||||
{it.preview.ruleText ? (
|
||||
<div className="font-mono text-meta break-all text-muted-foreground">
|
||||
{it.preview.ruleLine ? `第 ${it.preview.ruleLine} 行 ` : ""}
|
||||
{it.preview.ruleText}
|
||||
</div>
|
||||
) : null}
|
||||
{it.words.length ? (
|
||||
<div className="text-meta break-all text-muted-foreground">
|
||||
命中识别词:<span className="font-mono">{it.words.join(";")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-4 py-2 align-top">
|
||||
<StatusBadge tone={v.tone}>{v.label}</StatusBadge>
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { rowsToText, wordsToRows } from "@umt/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
entityFields,
|
||||
exprChoice,
|
||||
exprOf,
|
||||
globalFields,
|
||||
printEntityFields,
|
||||
printGlobalFields,
|
||||
ruleRow,
|
||||
switchGlobalType,
|
||||
} from "./model.ts";
|
||||
|
||||
// 可视化编辑的字段 ⇄ 原文:改任何字段都能打印回合法的一行;没改的行原文不动(往返在 contracts 测)。
|
||||
|
||||
const KUSURIYA = [
|
||||
"# 药屋少女的呢喃:TMDB 只有一季,后面的季接着编号",
|
||||
"S02 >> S01 EP+24",
|
||||
"S03 >> S01 EP+48",
|
||||
"S03 @ADWeb @MWeb @CHDWEB @黒ネズミたち @Judas @绿茶字幕组 @綠茶字幕組 >> S01 EP",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
describe("作品识别词字段", () => {
|
||||
it("药屋的三行解析成字段再打印,原样回来", () => {
|
||||
for (const line of KUSURIYA.split("\n").filter((l) => l && !l.startsWith("#"))) {
|
||||
expect(printEntityFields(entityFields(line))).toBe(line);
|
||||
}
|
||||
expect(entityFields("S03 @ADWeb @MWeb >> S01 EP")).toMatchObject({
|
||||
type: "map",
|
||||
season: "3",
|
||||
groups: ["ADWeb", "MWeb"],
|
||||
targetSeason: "1",
|
||||
expr: "EP",
|
||||
});
|
||||
});
|
||||
|
||||
it("改字段:换类型、改发布组、来源季留空 = 任意季", () => {
|
||||
const f = entityFields("S02 >> S01 EP+24");
|
||||
expect(printEntityFields({ ...f, groups: ["ANi"] })).toBe("S02 @ANi >> S01 EP+24");
|
||||
expect(printEntityFields({ ...f, season: "" })).toBe("* >> S01 EP+24");
|
||||
expect(printEntityFields({ ...f, targetSeason: "" })).toBe("S02 >> EP+24");
|
||||
expect(printEntityFields({ ...f, type: "special", special: "extra" })).toBe("S02 >> 花絮");
|
||||
expect(printEntityFields({ ...f, type: "table", fromStart: "13", toSeason: "0", toEpisode: "1" })).toBe(
|
||||
"S02E13 >> S00E01",
|
||||
);
|
||||
expect(printEntityFields({ ...entityFields("S01E27-E28 >> S02E02"), fromEnd: "29" })).toBe("S01E27-E29 >> S02E02");
|
||||
});
|
||||
|
||||
it("集号换算快捷选项", () => {
|
||||
expect(exprChoice("EP")).toEqual({ mode: "same" });
|
||||
expect(exprChoice("EP+24")).toEqual({ mode: "plus", n: "24" });
|
||||
expect(exprChoice("ep - 12")).toEqual({ mode: "minus", n: "12" });
|
||||
expect(exprChoice("EP*2-1")).toEqual({ mode: "custom", expr: "EP*2-1" });
|
||||
expect(exprOf({ mode: "minus", n: "12" })).toBe("EP-12");
|
||||
expect(exprOf({ mode: "plus", n: "" })).toBe("EP+0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("全局识别词字段", () => {
|
||||
it("四种 MP 写法与指定作品", () => {
|
||||
expect(globalFields("国语中字")).toMatchObject({ type: "block", pattern: "国语中字" });
|
||||
expect(globalFields("A => B")).toMatchObject({ type: "replace", pattern: "A", replacement: "B" });
|
||||
expect(globalFields("第 <> 集 >> EP-12")).toMatchObject({
|
||||
type: "offset",
|
||||
front: "第",
|
||||
back: "集",
|
||||
offset: "EP-12",
|
||||
});
|
||||
const id = globalFields("Bakemonogatari => 物语系列 {[tmdbid=46195;type=tv;s=1]}");
|
||||
expect(id).toMatchObject({ type: "identify", pattern: "Bakemonogatari", replacement: "物语系列" });
|
||||
expect(id.tag).toMatchObject({ tmdbId: "46195", type: "tv", season: "1" });
|
||||
expect(printGlobalFields(id)).toBe("Bakemonogatari => 物语系列 {[tmdbid=46195;type=tv;s=1]}");
|
||||
expect(printGlobalFields({ ...id, tag: { ...id.tag, episode: "3" } })).toBe(
|
||||
"Bakemonogatari => 物语系列 {[tmdbid=46195;type=tv;s=1;e=3]}",
|
||||
);
|
||||
});
|
||||
|
||||
it("换类型保留已填的内容", () => {
|
||||
const f = globalFields("旧名 => 新名");
|
||||
expect(printGlobalFields(switchGlobalType(f, "replace_offset"))).toBe("旧名 => 新名 && <> >> EP");
|
||||
expect(printGlobalFields(switchGlobalType(f, "block"))).toBe("旧名");
|
||||
});
|
||||
});
|
||||
|
||||
describe("可视化增删改 → 文本", () => {
|
||||
it("新增一行追加在后面;停用 = 注释掉;原有注释和空行不动", () => {
|
||||
const rows = wordsToRows(KUSURIYA, "entity");
|
||||
const next = [...rows, ruleRow("x", "S04 >> S01 EP+72", false, ["第四季还没播"])];
|
||||
expect(rowsToText(next, true)).toBe(`${KUSURIYA}# 第四季还没播\n# S04 >> S01 EP+72\n`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
type EntityWordSpecial,
|
||||
type IdentityTag,
|
||||
parseEntityWordLine,
|
||||
parseGlobalWordLine,
|
||||
parseIdentityTag,
|
||||
printEntityWord,
|
||||
printGlobalWord,
|
||||
printIdentityTag,
|
||||
uncomment,
|
||||
type WordsRow,
|
||||
type WordsScope,
|
||||
} from "@umt/contracts";
|
||||
|
||||
// 识别词编辑器的纯函数(docs/design/identify-words.md):可视化里每一行的字段 ⇄ 那一行原文、
|
||||
// 集号换算的快捷选项、编辑模式的本机记忆。行模型本身(文本 ⇄ 行、注释与停用)在 contracts。
|
||||
|
||||
// ── 全局识别词一行的字段 ──
|
||||
|
||||
export type GlobalRowType = "block" | "replace" | "offset" | "replace_offset" | "identify";
|
||||
|
||||
export const GLOBAL_TYPE_LABEL: Record<GlobalRowType, string> = {
|
||||
block: "屏蔽词",
|
||||
replace: "替换",
|
||||
offset: "集数偏移",
|
||||
replace_offset: "替换 + 偏移",
|
||||
identify: "指定作品",
|
||||
};
|
||||
|
||||
export interface GlobalFields {
|
||||
type: GlobalRowType;
|
||||
/** 屏蔽词 / 被替换词 */
|
||||
pattern: string;
|
||||
/** 替换词(指定作品时是标签以外的部分) */
|
||||
replacement: string;
|
||||
front: string;
|
||||
back: string;
|
||||
offset: string;
|
||||
/** 指定作品:{[tmdbid=…;type=…;s=…;e=…]} */
|
||||
tag: IdentityTag;
|
||||
}
|
||||
|
||||
const EMPTY_TAG: IdentityTag = { extra: [] };
|
||||
|
||||
export function globalFields(rule: string): GlobalFields {
|
||||
const p = parseGlobalWordLine(rule);
|
||||
const base: GlobalFields = {
|
||||
type: "block",
|
||||
pattern: p.pattern ?? "",
|
||||
replacement: p.replacement ?? "",
|
||||
front: p.front ?? "",
|
||||
back: p.back ?? "",
|
||||
offset: p.offset ?? "",
|
||||
tag: EMPTY_TAG,
|
||||
};
|
||||
if (p.kind === "replace") {
|
||||
const t = parseIdentityTag(p.replacement ?? "");
|
||||
if (t?.tag.tmdbId) return { ...base, type: "identify", replacement: `${t.before}${t.after}`.trim(), tag: t.tag };
|
||||
return { ...base, type: "replace" };
|
||||
}
|
||||
if (p.kind === "offset" || p.kind === "replace_offset") return { ...base, type: p.kind };
|
||||
return base;
|
||||
}
|
||||
|
||||
export function printGlobalFields(f: GlobalFields): string {
|
||||
if (f.type === "identify") {
|
||||
const text = f.replacement.trim();
|
||||
return printGlobalWord({
|
||||
kind: "replace",
|
||||
pattern: f.pattern,
|
||||
replacement: `${text}${text ? " " : ""}${printIdentityTag(f.tag)}`,
|
||||
});
|
||||
}
|
||||
return printGlobalWord({ ...f, kind: f.type });
|
||||
}
|
||||
|
||||
/** 换类型时尽量保留已填的内容 */
|
||||
export function switchGlobalType(f: GlobalFields, type: GlobalRowType): GlobalFields {
|
||||
const next = { ...f, type };
|
||||
if (type === "offset" || type === "replace_offset") next.offset ||= "EP";
|
||||
if (type === "identify") next.tag = { ...f.tag, type: f.tag.type ?? "tv" };
|
||||
return next;
|
||||
}
|
||||
|
||||
// ── 作品识别词一行的字段 ──
|
||||
|
||||
export type EntityRowType = "map" | "table" | "special";
|
||||
|
||||
export const ENTITY_TYPE_LABEL: Record<EntityRowType, string> = {
|
||||
map: "季集换算",
|
||||
table: "单集对照",
|
||||
special: "特别篇 / 花絮 / 忽略",
|
||||
};
|
||||
|
||||
export const SPECIAL_LABEL: Record<EntityWordSpecial, string> = { special: "特别篇", extra: "花絮", ignore: "忽略" };
|
||||
|
||||
export interface EntityFields {
|
||||
type: EntityRowType;
|
||||
/** 来源季;空 = 任意季 */
|
||||
season: string;
|
||||
groups: string[];
|
||||
/** 目标季;空 = 来源季 */
|
||||
targetSeason: string;
|
||||
expr: string;
|
||||
fromStart: string;
|
||||
fromEnd: string;
|
||||
toSeason: string;
|
||||
toEpisode: string;
|
||||
special: EntityWordSpecial;
|
||||
}
|
||||
|
||||
const num = (n: number | null | undefined) => (n == null ? "" : String(n));
|
||||
|
||||
export function entityFields(rule: string): EntityFields {
|
||||
const p = parseEntityWordLine(rule);
|
||||
return {
|
||||
type: p.kind === "table" || p.kind === "special" ? p.kind : "map",
|
||||
season: num(p.season),
|
||||
groups: p.groups ?? [],
|
||||
targetSeason: num(p.targetSeason),
|
||||
expr: p.expr ?? "EP",
|
||||
fromStart: num(p.from?.start),
|
||||
fromEnd: p.from && p.from.end !== p.from.start ? String(p.from.end) : "",
|
||||
toSeason: num(p.to?.season),
|
||||
toEpisode: num(p.to?.episode),
|
||||
special: p.special ?? "ignore",
|
||||
};
|
||||
}
|
||||
|
||||
const int = (s: string) => (/^\d+$/.test(s.trim()) ? Number(s.trim()) : null);
|
||||
|
||||
export function printEntityFields(f: EntityFields): string {
|
||||
const season = int(f.season);
|
||||
if (f.type === "table") {
|
||||
const start = int(f.fromStart) ?? 1;
|
||||
return printEntityWord({
|
||||
kind: "table",
|
||||
season,
|
||||
groups: f.groups,
|
||||
from: { start, end: int(f.fromEnd) ?? start },
|
||||
to: { season: int(f.toSeason) ?? 0, episode: int(f.toEpisode) ?? 1 },
|
||||
});
|
||||
}
|
||||
if (f.type === "special") return printEntityWord({ kind: "special", season, groups: f.groups, special: f.special });
|
||||
return printEntityWord({
|
||||
kind: "map",
|
||||
season,
|
||||
groups: f.groups,
|
||||
targetSeason: int(f.targetSeason),
|
||||
expr: f.expr.trim() || "EP",
|
||||
});
|
||||
}
|
||||
|
||||
// ── 集号换算的快捷选项 ──
|
||||
|
||||
export type ExprChoice = { mode: "same" } | { mode: "plus" | "minus"; n: string } | { mode: "custom"; expr: string };
|
||||
|
||||
export function exprChoice(expr: string): ExprChoice {
|
||||
const e = expr.replace(/\s+/g, "").toUpperCase();
|
||||
if (e === "EP" || e === "") return { mode: "same" };
|
||||
const m = /^EP([+-])(\d+)$/.exec(e);
|
||||
if (m) return { mode: m[1] === "+" ? "plus" : "minus", n: m[2]! };
|
||||
return { mode: "custom", expr };
|
||||
}
|
||||
|
||||
export function exprOf(c: ExprChoice): string {
|
||||
if (c.mode === "same") return "EP";
|
||||
if (c.mode === "custom") return c.expr;
|
||||
return `EP${c.mode === "plus" ? "+" : "-"}${c.n || "0"}`;
|
||||
}
|
||||
|
||||
// ── 新行 ──
|
||||
|
||||
export function newRuleText(scope: WordsScope): string {
|
||||
return scope === "entity" ? "S01 >> EP" : "要屏蔽的词";
|
||||
}
|
||||
|
||||
/** 注释行改成规则(可视化里打开开关) */
|
||||
export const commentToRule = (lines: readonly string[]) => lines.map(uncomment).join(" ").trim();
|
||||
|
||||
export function ruleRow(
|
||||
id: string,
|
||||
rule: string,
|
||||
enabled = true,
|
||||
note: string[] = [],
|
||||
): Extract<WordsRow, { kind: "rule" }> {
|
||||
return {
|
||||
kind: "rule",
|
||||
id,
|
||||
note,
|
||||
enabled,
|
||||
rule,
|
||||
lines: [...note.map((n) => `# ${n}`.trimEnd()), enabled ? rule : `# ${rule}`],
|
||||
};
|
||||
}
|
||||
|
||||
// ── 编辑模式记在本机 ──
|
||||
|
||||
export type EditorMode = "visual" | "text";
|
||||
const MODE_KEY = "umt.identify-words.mode";
|
||||
|
||||
export function loadMode(): EditorMode {
|
||||
try {
|
||||
const v = globalThis.localStorage?.getItem(MODE_KEY);
|
||||
return v === "text" ? "text" : "visual";
|
||||
} catch {
|
||||
return "visual";
|
||||
}
|
||||
}
|
||||
|
||||
export function saveMode(m: EditorMode): void {
|
||||
try {
|
||||
globalThis.localStorage?.setItem(MODE_KEY, m);
|
||||
} catch {
|
||||
// 隐私模式等存不了就算了,下次仍是缺省
|
||||
}
|
||||
}
|
||||
|
||||
/** 文本结尾有没有换行(往返时保持) */
|
||||
export const endsWithNewline = (text: string) => /\n$/.test(text);
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { WordsScope } from "@umt/contracts";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
// 语法说明(可折叠)。全局识别词与 MoviePilot 自定义识别词完全一样;作品识别词是本项目的季集换算。
|
||||
|
||||
const GLOBAL: [string, string][] = [
|
||||
["国语中字", "屏蔽词:整行当正则,从标题里删掉"],
|
||||
["Fake.Show => 假剧", "替换:左边是正则,右边可以用 \\1 引用分组,留空就是删掉"],
|
||||
["第 <> 集 >> EP-12", "集数偏移:前后定位词之间的数字按表达式换算;标题里没有就换副标题里的集数"],
|
||||
["旧名 => 新名 && 第 <> 集 >> EP+1", "替换 + 偏移:替换成功才偏移"],
|
||||
[
|
||||
"Bakemonogatari => 物语系列 {[tmdbid=46195;type=tv;s=1]}",
|
||||
"指定作品:替换词里带 {[tmdbid=…;type=tv;s=1;e=3]},识别直接用这部作品(s 是季、e 是集,可以不写;电影写 type=movie)",
|
||||
],
|
||||
["# 注释", "# 开头的行不生效;可视化里关掉开关就是把这一行注释掉"],
|
||||
];
|
||||
|
||||
const ENTITY: [string, string][] = [
|
||||
["S02 >> S01 EP+24", "第 2 季的资源接着第 1 季编号:第 2 季第 1 集是第 1 季第 25 集"],
|
||||
["S03 >> S01 EP+48", "第 3 季同理,再往后加 24 集"],
|
||||
["S03 @ADWeb @MWeb >> S01 EP", "这几个发布组第 3 季已经按第 1 季的集号发:只换季,集号不变"],
|
||||
["S01 @Lilith-Raws >> S02 EP-12", "第 1 季连续编号的 13 集起是第 2 季"],
|
||||
["S01E13 >> S00E01", "单集对照:第 1 季第 13 集是特别篇第 1 集;区间写 S01E27-E28 >> S02E02"],
|
||||
["S01 @SweetSub >> 特别篇", "整季当特别篇;也可以写 花絮、忽略(不参与集数换算)"],
|
||||
["* @Judas >> EP*2-1", "* = 任意季;集号表达式可以用 + - * / // % 和括号"],
|
||||
];
|
||||
|
||||
export function SyntaxHelp({ scope }: { scope: WordsScope }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rows = scope === "global" ? GLOBAL : ENTITY;
|
||||
return (
|
||||
<div className="rounded-xl border border-border/60">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex w-full cursor-pointer items-center justify-between gap-2 px-3 py-2 text-left text-caption font-medium text-foreground"
|
||||
>
|
||||
{scope === "global" ? "写法(与 MoviePilot 自定义识别词相同)" : "写法"}
|
||||
<ChevronDown
|
||||
className={cn("size-4 text-muted-foreground transition-transform duration-base", open && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="space-y-2 border-t border-border/60 px-3 py-2.5">
|
||||
<dl className="grid gap-x-4 gap-y-1.5 text-meta sm:grid-cols-[minmax(0,22rem)_1fr]">
|
||||
{rows.map(([code, text]) => (
|
||||
<div key={code} className="contents">
|
||||
<dt className="font-mono break-all text-foreground">{code}</dt>
|
||||
<dd className="text-muted-foreground">{text}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{scope === "entity" ? (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
范围越具体越优先:季 + 发布组 > 只写季 > 只写发布组 >
|
||||
任意。同一范围只能有一行。发布组不分大小写,繁简写法算同一个。
|
||||
规则照写的用:算出来对不上季表的资源保持集数待确认,不会自动停用这一行。
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
按行序处理,前一行的结果交给后一行。资源标题、副标题和整理时的文件名、目录名在识别前都会先过一遍。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { splitLines, type WordsLineError, type WordsScope } from "@umt/contracts";
|
||||
import { type ReactNode, useImperativeHandle, useMemo, useRef } from "react";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
// 文本模式:等宽编辑器,左侧行号,按识别词语法着色,写错的行在行号上标红、整行浅红底。
|
||||
// 实现是透明文字的 textarea 叠在着色的 <pre> 上(同字体、同行高、同内边距,滚动同步),不引编辑器库;
|
||||
// 不自动换行,行号与原文一一对应。粘贴 MP 的识别词列表就是普通粘贴。
|
||||
|
||||
export interface TextEditorHandle {
|
||||
/** 选中第 n 行并滚到它 */
|
||||
focusLine(line: number): void;
|
||||
}
|
||||
|
||||
const OPS_GLOBAL = /( => | <> | >> | && )/;
|
||||
|
||||
/** 一行的着色片段 */
|
||||
function highlight(line: string, scope: WordsScope): ReactNode {
|
||||
if (!line.trim()) return " ";
|
||||
if (line.trimStart().startsWith("#")) return <span className="text-content-faint italic">{line}</span>;
|
||||
if (scope === "global") {
|
||||
return line.split(OPS_GLOBAL).map((part, i) =>
|
||||
OPS_GLOBAL.test(part) ? (
|
||||
<span key={i} className="font-semibold text-brand-strong">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
tagged(part, i)
|
||||
),
|
||||
);
|
||||
}
|
||||
const at = line.indexOf(">>");
|
||||
if (at < 0) return line;
|
||||
const left = line.slice(0, at);
|
||||
const right = line.slice(at + 2);
|
||||
return (
|
||||
<>
|
||||
{left.split(/(\s+)/).map((t, i) =>
|
||||
t.startsWith("@") ? (
|
||||
<span key={i} className="text-brand-strong">
|
||||
{t}
|
||||
</span>
|
||||
) : /^(S\d+(E\d+(-E?\d+)?)?|E\d+(-E?\d+)?|\*|S\*)$/i.test(t) ? (
|
||||
<span key={i} className="font-semibold text-content-strong">
|
||||
{t}
|
||||
</span>
|
||||
) : (
|
||||
t
|
||||
),
|
||||
)}
|
||||
<span className="font-semibold text-brand-strong">{">>"}</span>
|
||||
{right.split(/(\s+)/).map((t, i) =>
|
||||
/^S\d+(E\d+)?$/i.test(t) ? (
|
||||
<span key={i} className="font-semibold text-content-strong">
|
||||
{t}
|
||||
</span>
|
||||
) : /EP/i.test(t) ? (
|
||||
<span key={i} className="text-content-secondary">
|
||||
{t}
|
||||
</span>
|
||||
) : (
|
||||
t
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** `{[tmdbid=…]}` 指定作品的标签单独着色 */
|
||||
function tagged(part: string, key: number): ReactNode {
|
||||
const m = /\{\[[^\]]*\]\}/.exec(part);
|
||||
if (!m) return <span key={key}>{part}</span>;
|
||||
return (
|
||||
<span key={key}>
|
||||
{part.slice(0, m.index)}
|
||||
<span className="text-content-secondary underline decoration-dotted underline-offset-2">{m[0]}</span>
|
||||
{part.slice(m.index + m[0].length)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function WordsTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
scope,
|
||||
errors,
|
||||
label,
|
||||
handle,
|
||||
minRows = 8,
|
||||
maxRows = 28,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
scope: WordsScope;
|
||||
errors: readonly WordsLineError[];
|
||||
label: string;
|
||||
handle?: React.Ref<TextEditorHandle>;
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
}) {
|
||||
const area = useRef<HTMLTextAreaElement>(null);
|
||||
const pre = useRef<HTMLPreElement>(null);
|
||||
const gutter = useRef<HTMLDivElement>(null);
|
||||
const lines = useMemo(() => splitLines(value), [value]);
|
||||
const bad = useMemo(() => new Map(errors.map((e) => [e.line, e.message])), [errors]);
|
||||
const rows = Math.min(Math.max(lines.length + 1, minRows), maxRows);
|
||||
|
||||
useImperativeHandle(handle, () => ({
|
||||
focusLine(line: number) {
|
||||
const el = area.current;
|
||||
if (!el) return;
|
||||
const start = lines.slice(0, line - 1).reduce((n, l) => n + l.length + 1, 0);
|
||||
el.focus();
|
||||
el.setSelectionRange(start, start + (lines[line - 1]?.length ?? 0));
|
||||
// 行高 1.5rem:把选中行滚到可见区中间附近
|
||||
const lh = Number.parseFloat(getComputedStyle(el).lineHeight) || 24;
|
||||
el.scrollTop = Math.max(0, (line - 3) * lh);
|
||||
},
|
||||
}));
|
||||
|
||||
const sync = () => {
|
||||
const el = area.current;
|
||||
if (!el) return;
|
||||
if (pre.current) {
|
||||
pre.current.scrollTop = el.scrollTop;
|
||||
pre.current.scrollLeft = el.scrollLeft;
|
||||
}
|
||||
if (gutter.current) gutter.current.scrollTop = el.scrollTop;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="field-glass flex min-w-0 overflow-hidden rounded-xl font-mono text-meta leading-6">
|
||||
<div
|
||||
ref={gutter}
|
||||
aria-hidden
|
||||
className="shrink-0 overflow-hidden border-r border-border/60 py-2 text-right tabular-nums text-muted-foreground select-none"
|
||||
style={{ height: `calc(${rows} * 1.5rem + 1rem)` }}
|
||||
>
|
||||
{lines.map((_, i) => {
|
||||
const err = bad.get(i + 1);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
title={err}
|
||||
className={cn("min-w-9 px-2", err && "bg-destructive/[0.12] font-semibold text-destructive")}
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<pre
|
||||
ref={pre}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 m-0 overflow-hidden px-3 py-2 whitespace-pre text-foreground"
|
||||
>
|
||||
{lines.map((l, i) => (
|
||||
<div key={i} className={cn(bad.has(i + 1) && "bg-destructive/[0.08]")}>
|
||||
{highlight(l, scope)}
|
||||
</div>
|
||||
))}
|
||||
<div> </div>
|
||||
</pre>
|
||||
<textarea
|
||||
ref={area}
|
||||
aria-label={label}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onScroll={sync}
|
||||
wrap="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className="relative block w-full resize-none overflow-auto bg-transparent px-3 py-2 whitespace-pre text-transparent caret-foreground outline-none selection:bg-brand/25"
|
||||
style={{ height: `calc(${rows} * 1.5rem + 1rem)` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,923 @@
|
||||
import {
|
||||
type EntityWordSpecial,
|
||||
evalEpisodeExpr,
|
||||
lineError,
|
||||
type WordsRow,
|
||||
type WordsScope,
|
||||
wordsToRows,
|
||||
} from "@umt/contracts";
|
||||
import { ArrowRight, FileText, GripVertical, Plus, Search, Trash2, X } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useWordsTry } from "~/api/episode-mapping.ts";
|
||||
import { useEntityDetail } from "~/api/library.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { canonicalText, sourceText, VALIDATION } from "~/components/episode-mapping/model.ts";
|
||||
import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { SegmentedControl } from "~/components/ui/segmented.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import {
|
||||
commentToRule,
|
||||
ENTITY_TYPE_LABEL,
|
||||
type EntityFields,
|
||||
type EntityRowType,
|
||||
entityFields,
|
||||
exprChoice,
|
||||
exprOf,
|
||||
GLOBAL_TYPE_LABEL,
|
||||
type GlobalFields,
|
||||
type GlobalRowType,
|
||||
globalFields,
|
||||
newRuleText,
|
||||
printEntityFields,
|
||||
printGlobalFields,
|
||||
ruleRow,
|
||||
SPECIAL_LABEL,
|
||||
switchGlobalType,
|
||||
} from "./model.ts";
|
||||
|
||||
// 可视化模式:一行规则一张卡(开关 = 注释掉与否、拖动排序、备注 = 上方的注释行、删除),字段按类型展开,
|
||||
// 卡片下面是拿真实资源标题试这一行的示例。和文本模式是同一份原文:每次改动都把这一行重新打印回原文。
|
||||
// 可视化表达不了的行(写错的)显示成只读的「原文」行,去文本模式改,不会丢。
|
||||
|
||||
type RuleRowT = Extract<WordsRow, { kind: "rule" }>;
|
||||
|
||||
let seq = 0;
|
||||
const newId = () => `n${seq++}`;
|
||||
|
||||
export function WordsVisualEditor({
|
||||
rows,
|
||||
onChange,
|
||||
scope,
|
||||
entityId,
|
||||
knownGroups = [],
|
||||
onEditText,
|
||||
}: {
|
||||
rows: WordsRow[];
|
||||
onChange: (rows: WordsRow[]) => void;
|
||||
scope: WordsScope;
|
||||
entityId?: string | null;
|
||||
knownGroups?: readonly string[];
|
||||
/** 原文行的「去文本里改」:切到文本模式并选中这一行 */
|
||||
onEditText: (line: number) => void;
|
||||
}) {
|
||||
const [drag, setDrag] = useState<{ from: number; over: number } | null>(null);
|
||||
const list = useRef<HTMLDivElement>(null);
|
||||
// 行号:每个行模型在原文里从第几行开始(原文行跳过去要用)
|
||||
const starts = useMemo(() => {
|
||||
const out: number[] = [];
|
||||
let n = 1;
|
||||
for (const r of rows) {
|
||||
out.push(n);
|
||||
n += r.lines.length;
|
||||
}
|
||||
return out;
|
||||
}, [rows]);
|
||||
|
||||
const replace = (i: number, row: WordsRow) => onChange(rows.map((r, k) => (k === i ? row : r)));
|
||||
const remove = (i: number) => onChange(rows.filter((_, k) => k !== i));
|
||||
const move = (from: number, to: number) => {
|
||||
if (from === to || to < 0 || to >= rows.length) return;
|
||||
const next = [...rows];
|
||||
const [r] = next.splice(from, 1);
|
||||
next.splice(to, 0, r!);
|
||||
onChange(next);
|
||||
};
|
||||
const add = () => onChange([...rows, ruleRow(newId(), newRuleText(scope))]);
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!drag) return;
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY)?.closest<HTMLElement>("[data-row-index]");
|
||||
const over = el ? Number(el.dataset.rowIndex) : drag.over;
|
||||
if (over !== drag.over) setDrag({ ...drag, over });
|
||||
};
|
||||
const endDrag = () => {
|
||||
if (drag) move(drag.from, drag.over);
|
||||
setDrag(null);
|
||||
};
|
||||
|
||||
const visible = rows.map((r, i) => ({ r, i })).filter(({ r }) => r.kind !== "blank");
|
||||
return (
|
||||
<div className="space-y-2" ref={list} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag}>
|
||||
{visible.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-border px-4 py-6 text-center text-caption text-muted-foreground">
|
||||
{scope === "entity"
|
||||
? "还没有作品识别词。发布者写的季集对不上季表时,在这里写换算。"
|
||||
: "还没有全局识别词。可以直接粘贴 MoviePilot 的自定义识别词。"}
|
||||
</p>
|
||||
) : null}
|
||||
{visible.map(({ r, i }) => (
|
||||
<div
|
||||
key={r.id}
|
||||
data-row-index={i}
|
||||
className={cn(
|
||||
"rounded-xl transition-[box-shadow,opacity] duration-base",
|
||||
drag?.from === i && "opacity-60",
|
||||
drag && drag.over === i && drag.from !== i && "ring-2 ring-ring/50",
|
||||
)}
|
||||
>
|
||||
{r.kind === "rule" ? (
|
||||
<RuleCard
|
||||
row={r}
|
||||
scope={scope}
|
||||
entityId={entityId ?? null}
|
||||
knownGroups={knownGroups}
|
||||
onChange={(row) => replace(i, row)}
|
||||
onRemove={() => remove(i)}
|
||||
handle={
|
||||
<DragHandle
|
||||
onStart={() => setDrag({ from: i, over: i })}
|
||||
onKey={(d) => move(i, i + d)}
|
||||
dragging={drag?.from === i}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : r.kind === "comment" ? (
|
||||
<CommentCard
|
||||
row={r}
|
||||
onEnable={() => replace(i, ruleRow(r.id, commentToRule(r.lines)))}
|
||||
onRemove={() => remove(i)}
|
||||
handle={
|
||||
<DragHandle
|
||||
onStart={() => setDrag({ from: i, over: i })}
|
||||
onKey={(d) => move(i, i + d)}
|
||||
dragging={drag?.from === i}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : r.kind === "invalid" ? (
|
||||
<InvalidCard row={r} line={starts[i]!} onEditText={onEditText} onRemove={() => remove(i)} />
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" size="sm" onClick={add}>
|
||||
<Plus />
|
||||
添加一行
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DragHandle({
|
||||
onStart,
|
||||
onKey,
|
||||
dragging,
|
||||
}: {
|
||||
onStart: () => void;
|
||||
onKey: (delta: -1 | 1) => void;
|
||||
dragging: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖动排序(也可以用上下方向键)"
|
||||
title="拖动排序"
|
||||
className={cn(
|
||||
"inline-flex size-7 shrink-0 cursor-grab touch-none items-center justify-center rounded-lg text-muted-foreground outline-none hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||
dragging && "cursor-grabbing",
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
|
||||
onStart();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
onKey(e.key === "ArrowUp" ? -1 : 1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CardShell({ children, muted }: { children: ReactNode; muted?: boolean }) {
|
||||
return (
|
||||
<div className={cn("rounded-xl border border-border/80 px-3 py-2.5 sm:px-4", muted && "bg-foreground/[0.02]")}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleCard({
|
||||
row,
|
||||
scope,
|
||||
entityId,
|
||||
knownGroups,
|
||||
onChange,
|
||||
onRemove,
|
||||
handle,
|
||||
}: {
|
||||
row: RuleRowT;
|
||||
scope: WordsScope;
|
||||
entityId: string | null;
|
||||
knownGroups: readonly string[];
|
||||
onChange: (row: RuleRowT) => void;
|
||||
onRemove: () => void;
|
||||
handle: ReactNode;
|
||||
}) {
|
||||
const id = useId();
|
||||
const setRule = (rule: string) => onChange({ ...row, rule, lines: linesOf(row, { rule }) });
|
||||
const setEnabled = (enabled: boolean) => onChange({ ...row, enabled, lines: linesOf(row, { enabled }) });
|
||||
const setNote = (text: string) => {
|
||||
const note = text.trim() ? text.split("\n") : [];
|
||||
onChange({ ...row, note, lines: linesOf(row, { note }) });
|
||||
};
|
||||
const error = lineError(row.rule, scope);
|
||||
return (
|
||||
<CardShell muted={!row.enabled}>
|
||||
<div className="flex flex-wrap items-start gap-x-2 gap-y-2">
|
||||
<div className="flex items-center gap-1.5 pt-0.5">
|
||||
{handle}
|
||||
<Switch
|
||||
checked={row.enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
aria-label={row.enabled ? "停用这一行(注释掉)" : "启用这一行"}
|
||||
title={row.enabled ? "启用中,关掉 = 注释掉这一行" : "已停用(注释掉了)"}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"order-last min-w-0 flex-1 basis-full space-y-2 sm:order-none sm:basis-0",
|
||||
!row.enabled && "opacity-70",
|
||||
)}
|
||||
>
|
||||
{scope === "entity" ? (
|
||||
<EntityRuleFields rule={row.rule} onChange={setRule} knownGroups={knownGroups} idPrefix={id} />
|
||||
) : (
|
||||
<GlobalRuleFields rule={row.rule} onChange={setRule} idPrefix={id} />
|
||||
)}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 text-meta text-muted-foreground">备注</span>
|
||||
<Input
|
||||
aria-label="备注(写成这一行上面的注释)"
|
||||
className="h-8 text-meta"
|
||||
placeholder="写给自己看的说明,保存为上面一行注释"
|
||||
value={row.note.join(" ")}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<code className="block min-w-0 font-mono text-meta break-all text-muted-foreground">
|
||||
{row.enabled ? "" : "# "}
|
||||
{row.rule}
|
||||
</code>
|
||||
{error ? (
|
||||
<p className="text-meta text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : row.enabled ? (
|
||||
<RowExample scope={scope} entityId={entityId} line={row.rule} />
|
||||
) : null}
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-sm" className="ml-auto sm:ml-0" aria-label="删除这一行" onClick={onRemove}>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** 规则行的原文:备注只在改备注时重写,其余时候原样保留 */
|
||||
function linesOf(row: RuleRowT, patch: Partial<Pick<RuleRowT, "rule" | "enabled" | "note">>): string[] {
|
||||
const note = patch.note ?? row.note;
|
||||
const noteLines = patch.note ? note.map((n) => `# ${n}`.trimEnd()) : row.lines.slice(0, row.note.length);
|
||||
const enabled = patch.enabled ?? row.enabled;
|
||||
const rule = patch.rule ?? row.rule;
|
||||
return [...noteLines, enabled ? rule : `# ${rule}`];
|
||||
}
|
||||
|
||||
function CommentCard({
|
||||
row,
|
||||
onEnable,
|
||||
onRemove,
|
||||
handle,
|
||||
}: {
|
||||
row: Extract<WordsRow, { kind: "comment" }>;
|
||||
onEnable: () => void;
|
||||
onRemove: () => void;
|
||||
handle: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<CardShell muted>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex items-center gap-1.5 pt-0.5">
|
||||
{handle}
|
||||
<Switch checked={false} onCheckedChange={onEnable} aria-label="把这条注释改成识别词启用" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-meta text-muted-foreground">注释</div>
|
||||
<pre className="m-0 font-mono text-meta whitespace-pre-wrap break-all text-foreground">
|
||||
{row.lines.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="删除这条注释" onClick={onRemove}>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
function InvalidCard({
|
||||
row,
|
||||
line,
|
||||
onEditText,
|
||||
onRemove,
|
||||
}: {
|
||||
row: Extract<WordsRow, { kind: "invalid" }>;
|
||||
line: number;
|
||||
onEditText: (line: number) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-destructive/40 px-3 py-2.5 sm:px-4">
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-meta">
|
||||
<StatusBadge tone="destructive">原文</StatusBadge>
|
||||
<span className="text-muted-foreground">第 {line} 行,可视化里改不了</span>
|
||||
</div>
|
||||
<code className="mt-1 block font-mono text-meta break-all text-foreground">{row.rule}</code>
|
||||
<p className="mt-1 text-meta text-destructive">{row.error}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="sm" onClick={() => onEditText(line)}>
|
||||
<FileText />
|
||||
在文本里改
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="删除这一行" onClick={onRemove}>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 字段 ──
|
||||
|
||||
function Field({ label, children, className }: { label: string; children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn("grid min-w-0 gap-1", className)}>
|
||||
<span className="text-meta text-muted-foreground">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeSelect<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
value: T;
|
||||
options: Record<T, string>;
|
||||
onChange: (v: T) => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={(v) => onChange(v as T)}>
|
||||
<SelectTrigger aria-label={label} className="h-8 w-auto min-w-32 text-caption">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(options) as T[]).map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{options[k]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
const mono = "h-8 font-mono text-meta";
|
||||
|
||||
function GlobalRuleFields({
|
||||
rule,
|
||||
onChange,
|
||||
idPrefix,
|
||||
}: {
|
||||
rule: string;
|
||||
onChange: (rule: string) => void;
|
||||
idPrefix: string;
|
||||
}) {
|
||||
const f = useMemo(() => globalFields(rule), [rule]);
|
||||
const set = (patch: Partial<GlobalFields>) => onChange(printGlobalFields({ ...f, ...patch }));
|
||||
const input = (k: "pattern" | "replacement" | "front" | "back" | "offset", label: string, placeholder = "") => (
|
||||
<Field label={label}>
|
||||
<Input
|
||||
id={`${idPrefix}-${k}`}
|
||||
className={mono}
|
||||
value={f[k]}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => set({ [k]: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<TypeSelect<GlobalRowType>
|
||||
value={f.type}
|
||||
options={GLOBAL_TYPE_LABEL}
|
||||
label="识别词类型"
|
||||
onChange={(t) => onChange(printGlobalFields(switchGlobalType(f, t)))}
|
||||
/>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{f.type === "block" ? input("pattern", "屏蔽词(正则)", "如 国语中字") : null}
|
||||
{f.type === "replace" || f.type === "replace_offset" ? (
|
||||
<>
|
||||
{input("pattern", "被替换词(正则)")}
|
||||
{input("replacement", "替换为", "留空 = 删掉")}
|
||||
</>
|
||||
) : null}
|
||||
{f.type === "offset" || f.type === "replace_offset" ? (
|
||||
<>
|
||||
{input("front", "前定位词(正则)", "如 第")}
|
||||
{input("back", "后定位词(正则)", "如 集")}
|
||||
<Field label="偏移" className="sm:col-span-2">
|
||||
<OffsetInput value={f.offset} onChange={(offset) => set({ offset })} strictEp />
|
||||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
{f.type === "identify" ? <IdentifyFields f={f} set={set} idPrefix={idPrefix} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IdentifyFields({
|
||||
f,
|
||||
set,
|
||||
idPrefix,
|
||||
}: {
|
||||
f: GlobalFields;
|
||||
set: (patch: Partial<GlobalFields>) => void;
|
||||
idPrefix: string;
|
||||
}) {
|
||||
const [picked, setPicked] = useState<PickedEntity | null>(null);
|
||||
const detail = useEntityDetail(picked?.id);
|
||||
// 选了本地作品:用它的 TMDB 身份填 tmdbid 与类型
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在拿到所选作品详情时填一次
|
||||
useEffect(() => {
|
||||
if (!picked || !detail.data) return;
|
||||
const tmdb = detail.data.identities.find((x) => x.provider === "tmdb" && x.status === "accepted");
|
||||
set({
|
||||
tag: {
|
||||
...f.tag,
|
||||
...(tmdb ? { tmdbId: tmdb.externalId } : {}),
|
||||
type: picked.kind === "movie" ? "movie" : "tv",
|
||||
},
|
||||
replacement: f.replacement || picked.title,
|
||||
});
|
||||
setPicked(null);
|
||||
}, [detail.data, picked]);
|
||||
const tag = (patch: Partial<GlobalFields["tag"]>) => set({ tag: { ...f.tag, ...patch } });
|
||||
return (
|
||||
<>
|
||||
<Field label="匹配词(正则)">
|
||||
<Input className={mono} value={f.pattern} onChange={(e) => set({ pattern: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="改成的标题">
|
||||
<Input
|
||||
className="h-8 text-meta"
|
||||
value={f.replacement}
|
||||
placeholder="可留空"
|
||||
onChange={(e) => set({ replacement: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="从本地作品填" className="sm:col-span-2">
|
||||
<EntityPicker
|
||||
id={`${idPrefix}-entity`}
|
||||
value={null}
|
||||
onChange={setPicked}
|
||||
placeholder="搜索本地作品,填入 TMDB ID"
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2 sm:col-span-2 sm:grid-cols-4">
|
||||
<Field label="TMDB ID">
|
||||
<Input
|
||||
className={mono}
|
||||
inputMode="numeric"
|
||||
value={f.tag.tmdbId ?? ""}
|
||||
onChange={(e) => tag({ tmdbId: e.target.value.replace(/\D/g, "") })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="类型">
|
||||
<Select value={f.tag.type ?? "tv"} onValueChange={(v) => tag({ type: v as "tv" | "movie" })}>
|
||||
<SelectTrigger aria-label="类型" className="h-8 text-caption">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tv">剧集</SelectItem>
|
||||
<SelectItem value="movie">电影</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="季(可选)">
|
||||
<Input
|
||||
className={mono}
|
||||
value={f.tag.season ?? ""}
|
||||
placeholder="1 或 1-2"
|
||||
onChange={(e) => tag({ season: e.target.value.replace(/[^\d-]/g, "") || undefined })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="集(可选)">
|
||||
<Input
|
||||
className={mono}
|
||||
value={f.tag.episode ?? ""}
|
||||
placeholder="3 或 3-4"
|
||||
onChange={(e) => tag({ episode: e.target.value.replace(/[^\d-]/g, "") || undefined })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityRuleFields({
|
||||
rule,
|
||||
onChange,
|
||||
knownGroups,
|
||||
idPrefix,
|
||||
}: {
|
||||
rule: string;
|
||||
onChange: (rule: string) => void;
|
||||
knownGroups: readonly string[];
|
||||
idPrefix: string;
|
||||
}) {
|
||||
const f = useMemo(() => entityFields(rule), [rule]);
|
||||
const set = (patch: Partial<EntityFields>) => onChange(printEntityFields({ ...f, ...patch }));
|
||||
const seasonInput = (k: "season" | "targetSeason" | "toSeason", label: string, placeholder: string) => (
|
||||
<Field label={label}>
|
||||
<Input
|
||||
id={`${idPrefix}-${k}`}
|
||||
className={mono}
|
||||
inputMode="numeric"
|
||||
value={f[k]}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => set({ [k]: e.target.value.replace(/\D/g, "") })}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<TypeSelect<EntityRowType>
|
||||
value={f.type}
|
||||
options={ENTITY_TYPE_LABEL}
|
||||
label="规则类型"
|
||||
onChange={(type) => set({ type })}
|
||||
/>
|
||||
<div className="grid gap-2 sm:grid-cols-4">
|
||||
{f.type === "table" ? (
|
||||
<>
|
||||
{seasonInput("season", "来源季", "空 = 任意")}
|
||||
<Field label="来源集号">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
className={mono}
|
||||
inputMode="numeric"
|
||||
value={f.fromStart}
|
||||
onChange={(e) => set({ fromStart: e.target.value.replace(/\D/g, "") })}
|
||||
/>
|
||||
<span className="text-muted-foreground">–</span>
|
||||
<Input
|
||||
className={mono}
|
||||
inputMode="numeric"
|
||||
value={f.fromEnd}
|
||||
placeholder="单集"
|
||||
aria-label="来源集号结束"
|
||||
onChange={(e) => set({ fromEnd: e.target.value.replace(/\D/g, "") })}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
{seasonInput("toSeason", "目标季", "0 = 特别篇")}
|
||||
<Field label="目标集">
|
||||
<Input
|
||||
className={mono}
|
||||
inputMode="numeric"
|
||||
value={f.toEpisode}
|
||||
onChange={(e) => set({ toEpisode: e.target.value.replace(/\D/g, "") })}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
seasonInput("season", "来源季", "空 = 任意")
|
||||
)}
|
||||
<Field label="发布组" className={f.type === "table" ? "sm:col-span-4" : "sm:col-span-3"}>
|
||||
<GroupsInput value={f.groups} onChange={(groups) => set({ groups })} suggestions={knownGroups} />
|
||||
</Field>
|
||||
{f.type === "map" ? (
|
||||
<>
|
||||
{seasonInput("targetSeason", "目标季", "空 = 来源季")}
|
||||
<Field label="集号换算" className="sm:col-span-3">
|
||||
<OffsetInput value={f.expr} onChange={(expr) => set({ expr })} />
|
||||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
{f.type === "special" ? (
|
||||
<Field label="处理为">
|
||||
<Select value={f.special} onValueChange={(v) => set({ special: v as EntityWordSpecial })}>
|
||||
<SelectTrigger aria-label="处理为" className="h-8 text-caption">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(SPECIAL_LABEL) as EntityWordSpecial[]).map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{SPECIAL_LABEL[k]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 集号换算:不变 / +N / −N / 自定义表达式;自定义按同一套安全算术校验 */
|
||||
function OffsetInput({
|
||||
value,
|
||||
onChange,
|
||||
strictEp = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
/** 全局识别词的偏移按 MP 原样(EP 大写),作品识别词不分大小写 */
|
||||
strictEp?: boolean;
|
||||
}) {
|
||||
const c = exprChoice(value);
|
||||
const [custom, setCustom] = useState(c.mode === "custom" ? c.expr : value);
|
||||
let error: string | null = null;
|
||||
if (c.mode === "custom") {
|
||||
try {
|
||||
if (strictEp && !value.includes("EP")) throw new Error("偏移里要有 EP,如 EP+12");
|
||||
evalEpisodeExpr(value, 1);
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="overflow-x-auto scroll-bleed">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
label="集号换算方式"
|
||||
value={c.mode}
|
||||
onChange={(mode) => {
|
||||
if (mode === "same") onChange("EP");
|
||||
else if (mode === "custom") onChange(custom || value || "EP");
|
||||
else onChange(exprOf({ mode, n: c.mode === "plus" || c.mode === "minus" ? c.n : "1" }));
|
||||
}}
|
||||
options={[
|
||||
{ value: "same", label: "不变" },
|
||||
{ value: "plus", label: "+N" },
|
||||
{ value: "minus", label: "−N" },
|
||||
{ value: "custom", label: "自定义表达式" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{c.mode === "plus" || c.mode === "minus" ? (
|
||||
<Input
|
||||
aria-label="集号加减多少"
|
||||
className="h-8 w-20 font-mono text-meta"
|
||||
inputMode="numeric"
|
||||
value={c.n}
|
||||
onChange={(e) => onChange(exprOf({ mode: c.mode, n: e.target.value.replace(/\D/g, "") }))}
|
||||
/>
|
||||
) : null}
|
||||
{c.mode === "custom" ? (
|
||||
<Input
|
||||
aria-label="集号表达式"
|
||||
aria-invalid={!!error}
|
||||
className="h-8 w-40 font-mono text-meta"
|
||||
value={value}
|
||||
placeholder="如 EP*2-1"
|
||||
onChange={(e) => {
|
||||
setCustom(e.target.value);
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{error ? <p className="text-meta text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 发布组:回车或逗号加一个,点 × 去掉;提示这部作品资源里出现过的发布组 */
|
||||
function GroupsInput({
|
||||
value,
|
||||
onChange,
|
||||
suggestions,
|
||||
}: {
|
||||
value: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
suggestions: readonly string[];
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const listId = useId();
|
||||
const commit = (raw: string) => {
|
||||
const g = raw.trim().replace(/^@/, "");
|
||||
if (g && !value.some((x) => x.toLowerCase() === g.toLowerCase())) onChange([...value, g]);
|
||||
setDraft("");
|
||||
};
|
||||
return (
|
||||
<div className="field-glass flex min-h-8 flex-wrap items-center gap-1 rounded-xl px-2 py-1">
|
||||
{value.map((g) => (
|
||||
<span key={g} className="inline-flex items-center gap-0.5 rounded-full border border-border px-2 text-meta">
|
||||
@{g}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`去掉发布组 ${g}`}
|
||||
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onChange(value.filter((x) => x !== g))}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
aria-label="添加发布组"
|
||||
list={listId}
|
||||
className="h-6 min-w-24 flex-1 bg-transparent text-meta outline-none placeholder:text-muted-foreground"
|
||||
placeholder={value.length ? "" : "空 = 任意发布组"}
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
// 从提示列表里点选时直接加入
|
||||
if (suggestions.includes(v)) commit(v);
|
||||
else setDraft(v);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === "," || e.key === ",") {
|
||||
e.preventDefault();
|
||||
commit(draft);
|
||||
} else if (e.key === "Backspace" && !draft && value.length) onChange(value.slice(0, -1));
|
||||
}}
|
||||
onBlur={() => draft && commit(draft)}
|
||||
/>
|
||||
<datalist id={listId}>
|
||||
{suggestions
|
||||
.filter((s) => !value.some((x) => x.toLowerCase() === s.toLowerCase()))
|
||||
.map((s) => (
|
||||
<option key={s} value={s} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 实时示例 ──
|
||||
|
||||
/** 卡片进入视口才去试(长列表不一次发几百个请求) */
|
||||
function useInView<T extends HTMLElement>() {
|
||||
const ref = useRef<T>(null);
|
||||
const [seen, setSeen] = useState(false);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || seen) return;
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
setSeen(true);
|
||||
return;
|
||||
}
|
||||
const io = new IntersectionObserver((es) => es.some((e) => e.isIntersecting) && setSeen(true));
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [seen]);
|
||||
return { ref, seen };
|
||||
}
|
||||
|
||||
function useDebounced<T>(value: T, ms: number): T {
|
||||
const [v, setV] = useState(value);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setV(value), ms);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, ms]);
|
||||
return v;
|
||||
}
|
||||
|
||||
export function RowExample({ scope, entityId, line }: { scope: WordsScope; entityId: string | null; line: string }) {
|
||||
const { ref, seen } = useInView<HTMLDivElement>();
|
||||
const [own, setOwn] = useState<string | null>(null);
|
||||
const debLine = useDebounced(line, 400);
|
||||
const debOwn = useDebounced(own, 400);
|
||||
const input = seen
|
||||
? {
|
||||
scope,
|
||||
line: debLine,
|
||||
...(scope === "entity" && entityId ? { mediaEntityId: entityId } : {}),
|
||||
...(debOwn?.trim() ? { title: debOwn.trim() } : {}),
|
||||
}
|
||||
: null;
|
||||
const q = useWordsTry(scope === "entity" && !entityId ? null : input);
|
||||
const r = q.data;
|
||||
return (
|
||||
<div ref={ref} className="rounded-lg bg-foreground/[0.03] px-2.5 py-2 text-meta">
|
||||
{own === null ? null : (
|
||||
<div className="mb-1.5 flex items-center gap-1.5">
|
||||
<Search className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
aria-label="示例标题"
|
||||
className="h-6 min-w-0 flex-1 bg-transparent font-mono outline-none placeholder:text-muted-foreground"
|
||||
placeholder="粘贴一个资源标题试试"
|
||||
value={own}
|
||||
onChange={(e) => setOwn(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setOwn(null)}
|
||||
>
|
||||
用近期资源
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!seen || q.isPending ? (
|
||||
<span className="text-muted-foreground">示例加载中…</span>
|
||||
) : q.isError ? (
|
||||
<span className="text-destructive">{(q.error as Error).message}</span>
|
||||
) : r?.error ? (
|
||||
<span className="text-destructive">{r.error}</span>
|
||||
) : !r?.sample ? (
|
||||
<span className="text-muted-foreground">
|
||||
近期资源里没有这一行能命中的。
|
||||
{own === null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 cursor-pointer underline underline-offset-2"
|
||||
onClick={() => setOwn("")}
|
||||
>
|
||||
自己给个标题
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1.5">
|
||||
<span className="shrink-0 text-muted-foreground">示例</span>
|
||||
<span className="min-w-0 font-mono break-all text-foreground">{r.sample.title}</span>
|
||||
{own === null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 cursor-pointer text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
||||
onClick={() => setOwn("")}
|
||||
>
|
||||
换个标题
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{!r.matched ? (
|
||||
<div className="text-muted-foreground">这一行没有命中这个标题</div>
|
||||
) : scope === "global" ? (
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1.5">
|
||||
<ArrowRight className="size-3 shrink-0 self-center text-muted-foreground" />
|
||||
<span className="min-w-0 font-mono break-all text-foreground">{r.titleAfter}</span>
|
||||
{r.parsed ? <span className="text-muted-foreground">· {parsedText(r.parsed)}</span> : null}
|
||||
</div>
|
||||
) : r.episode ? (
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1">
|
||||
<span className="font-mono tabular-nums">{sourceText(r.episode)}</span>
|
||||
<ArrowRight className="size-3 text-muted-foreground" />
|
||||
<span className="font-mono font-semibold tabular-nums">{canonicalText(r.episode)}</span>
|
||||
{r.validation ? (
|
||||
<StatusBadge tone={VALIDATION[r.validation].tone}>{VALIDATION[r.validation].label}</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
function parsedText(p: NonNullable<ReturnType<typeof useWordsTry>["data"]>["parsed"]): string {
|
||||
if (!p) return "";
|
||||
const parts: string[] = [];
|
||||
if (p.title) parts.push(`${p.title}${p.year ? ` (${p.year})` : ""}`);
|
||||
if (p.season !== null || p.episodes) {
|
||||
const s = p.season !== null ? `S${pad(p.season)}` : "";
|
||||
const e = p.episodes
|
||||
? p.episodes.start === p.episodes.end
|
||||
? `E${pad(p.episodes.start)}`
|
||||
: `E${pad(p.episodes.start)}-E${pad(p.episodes.end)}`
|
||||
: "";
|
||||
parts.push(`${s}${e}`);
|
||||
}
|
||||
const tmdb = p.explicitIds.find((x) => x.provider === "tmdb");
|
||||
if (tmdb) parts.push(`TMDB ${tmdb.id}`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** 文本 → 行模型(给外层用,行 id 稳定性只在一次编辑会话内) */
|
||||
export const toRows = (text: string, scope: WordsScope) => wordsToRows(text, scope);
|
||||
@@ -0,0 +1,378 @@
|
||||
import {
|
||||
compileEntityWords,
|
||||
type EntityWordsDryRun,
|
||||
type EntityWordsSaveResult,
|
||||
type GlobalWordsDryRun,
|
||||
type IdentifyWordsBlock,
|
||||
parseGlobalWords,
|
||||
rowsToText,
|
||||
type WordsLineError,
|
||||
type WordsRow,
|
||||
type WordsScope,
|
||||
wordsToRows,
|
||||
} from "@umt/contracts";
|
||||
import { ArrowRight, Code2, LayoutList, RotateCcw, Save } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useSaveWords, useWordsDryRun } from "~/api/episode-mapping.ts";
|
||||
import { ErrorState } from "~/components/console/empty-state.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { reresolveToast } from "~/components/episode-mapping/model.ts";
|
||||
import { PreviewTable } from "~/components/episode-mapping/preview-table.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { SegmentedControl } from "~/components/ui/segmented.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { actorText } from "~/lib/labels.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import { type EditorMode, endsWithNewline, loadMode, saveMode } from "./model.ts";
|
||||
import { SyntaxHelp } from "./syntax-help.tsx";
|
||||
import { type TextEditorHandle, WordsTextEditor } from "./text-editor.tsx";
|
||||
import { WordsVisualEditor } from "./visual-editor.tsx";
|
||||
|
||||
// 识别词编辑器(全局与作品共用,规则页「识别词」与作品页「集数映射」同一个组件):
|
||||
// 可视化 / 文本两种模式改同一份原文,随时切换不丢内容;改动后自动试算(纯读),确认无误再保存。
|
||||
|
||||
const DRY_RUN_DEBOUNCE_MS = 600;
|
||||
|
||||
function useDebounced<T>(value: T, ms: number): T {
|
||||
const [v, setV] = useState(value);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setV(value), ms);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, ms]);
|
||||
return v;
|
||||
}
|
||||
|
||||
/** 逐行错误(本地就能算,不等试算) */
|
||||
export function localErrors(text: string, scope: WordsScope): WordsLineError[] {
|
||||
if (scope === "entity") return compileEntityWords(text).errors;
|
||||
return parseGlobalWords(text)
|
||||
.filter((w) => w.error)
|
||||
.map((w) => ({ line: w.line, message: w.error! }));
|
||||
}
|
||||
|
||||
export function WordsEditor({
|
||||
scope,
|
||||
entityId = null,
|
||||
block,
|
||||
knownGroups,
|
||||
draft: controlledDraft,
|
||||
onDraftChange,
|
||||
showPreview = true,
|
||||
}: {
|
||||
scope: WordsScope;
|
||||
entityId?: string | null;
|
||||
block: IdentifyWordsBlock;
|
||||
knownGroups?: readonly string[];
|
||||
/** 受控草稿(作品页要从外面往里追加一行);不给时组件自己管 */
|
||||
draft?: string;
|
||||
onDraftChange?: (text: string) => void;
|
||||
/** 作品识别词的试算结果表格(作品页另有近期资源表时可以关掉) */
|
||||
showPreview?: boolean;
|
||||
}) {
|
||||
const [own, setOwn] = useState(block.text);
|
||||
const text = controlledDraft ?? own;
|
||||
const setText = (t: string) => (onDraftChange ? onDraftChange(t) : setOwn(t));
|
||||
const [mode, setModeState] = useState<EditorMode>("visual");
|
||||
// 本机记住上次用的模式(SSR 时没有 localStorage,挂载后再读)
|
||||
useEffect(() => setModeState(loadMode()), []);
|
||||
const setMode = (m: EditorMode) => {
|
||||
if (m === "visual") setRows(wordsToRows(text, scope));
|
||||
setModeState(m);
|
||||
saveMode(m);
|
||||
};
|
||||
const [rows, setRows] = useState<WordsRow[]>(() => wordsToRows(text, scope));
|
||||
const textRef = useRef<TextEditorHandle>(null);
|
||||
const [pendingLine, setPendingLine] = useState<number | null>(null);
|
||||
|
||||
// 别处保存了新版本、本地没改动时跟上
|
||||
const base = useRef(block);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 只跟版本变化
|
||||
useEffect(() => {
|
||||
if (block.version === base.current.version) return;
|
||||
const clean = text === base.current.text;
|
||||
base.current = block;
|
||||
if (clean) {
|
||||
setText(block.text);
|
||||
setRows(wordsToRows(block.text, scope));
|
||||
}
|
||||
}, [block.version]);
|
||||
// 外面改了受控草稿(追加一行)时,可视化行跟着重建
|
||||
const lastText = useRef(text);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 只看文本变化
|
||||
useEffect(() => {
|
||||
if (text !== lastText.current && text !== rowsToText(rows, endsWithNewline(text)))
|
||||
setRows(wordsToRows(text, scope));
|
||||
lastText.current = text;
|
||||
}, [text]);
|
||||
useEffect(() => {
|
||||
if (mode === "text" && pendingLine) {
|
||||
textRef.current?.focusLine(pendingLine);
|
||||
setPendingLine(null);
|
||||
}
|
||||
}, [mode, pendingLine]);
|
||||
|
||||
const onRows = (next: WordsRow[]) => {
|
||||
setRows(next);
|
||||
const t = rowsToText(next, endsWithNewline(text) || !text);
|
||||
lastText.current = t;
|
||||
setText(t);
|
||||
};
|
||||
|
||||
const dirty = text !== block.text;
|
||||
const errors = useMemo(() => localErrors(text, scope), [text, scope]);
|
||||
const debounced = useDebounced(text, DRY_RUN_DEBOUNCE_MS);
|
||||
const dry = useWordsDryRun(scope === "entity" ? entityId : null, dirty ? debounced : null);
|
||||
const save = useSaveWords(scope === "entity" ? entityId : null);
|
||||
const blocked = scope === "entity" && errors.length > 0;
|
||||
|
||||
const doSave = () =>
|
||||
save.mutateAsync({ text, expectedVersion: block.version }).then(
|
||||
(r) => {
|
||||
base.current = r.block;
|
||||
if (scope === "entity") toast.success(reresolveToast((r as EntityWordsSaveResult).reresolve));
|
||||
else toast.success(r.block.reresolve?.status === "queued" ? "已保存,后台按改动的行重新识别资源" : "已保存");
|
||||
},
|
||||
(e) => toast.error(errorMessage(e, "保存失败")),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<SegmentedControl
|
||||
label="编辑方式"
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: "visual", label: "可视化", icon: <LayoutList /> },
|
||||
{ value: "text", label: "文本", icon: <Code2 /> },
|
||||
]}
|
||||
/>
|
||||
<span className="text-meta text-muted-foreground">
|
||||
{block.updatedAt
|
||||
? `第 ${block.version} 版 · ${formatDateTime(block.updatedAt)}${block.updatedBy ? ` · ${actorText(block.updatedBy)}` : ""}`
|
||||
: "还没保存过"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{mode === "text" ? (
|
||||
<WordsTextEditor
|
||||
value={text}
|
||||
onChange={setText}
|
||||
scope={scope}
|
||||
errors={errors}
|
||||
label={scope === "entity" ? "作品识别词" : "全局识别词"}
|
||||
handle={textRef}
|
||||
/>
|
||||
) : (
|
||||
<WordsVisualEditor
|
||||
rows={rows}
|
||||
onChange={onRows}
|
||||
scope={scope}
|
||||
entityId={entityId}
|
||||
knownGroups={knownGroups ?? []}
|
||||
onEditText={(line) => {
|
||||
setPendingLine(line);
|
||||
setMode("text");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{errors.length ? (
|
||||
<ul className="space-y-0.5 text-meta" aria-label="有问题的行">
|
||||
{errors.map((e) => (
|
||||
<li key={`${e.line}-${e.message}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer text-destructive underline-offset-2 hover:underline"
|
||||
onClick={() => {
|
||||
setPendingLine(e.line);
|
||||
if (mode === "text") textRef.current?.focusLine(e.line);
|
||||
else setMode("text");
|
||||
}}
|
||||
>
|
||||
第 {e.line} 行:{e.message}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{scope === "global" ? (
|
||||
<li className="text-muted-foreground">写错的行可以保存,套用时会跳过(MoviePilot 也是这样)。</li>
|
||||
) : null}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<SyntaxHelp scope={scope} />
|
||||
|
||||
{dirty ? (
|
||||
<div className="space-y-3 rounded-xl border border-border/80 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-caption font-medium text-foreground">保存前试算</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setText(block.text);
|
||||
setRows(wordsToRows(block.text, scope));
|
||||
}}
|
||||
>
|
||||
<RotateCcw />
|
||||
撤销修改
|
||||
</Button>
|
||||
<Button size="sm" disabled={blocked || save.isPending} onClick={() => void doSave()}>
|
||||
<Save />
|
||||
{save.isPending ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{blocked ? <p className="text-meta text-destructive">先改好有问题的行再保存。</p> : null}
|
||||
{dry.isError ? (
|
||||
<ErrorState plain error={dry.error} onRetry={() => dry.refetch()} />
|
||||
) : !dry.data || dry.isFetching ? (
|
||||
<p className="text-meta text-muted-foreground">试算中…</p>
|
||||
) : scope === "entity" ? (
|
||||
<EntityDryRunView r={dry.data as EntityWordsDryRun} showTable={showPreview} />
|
||||
) : (
|
||||
<GlobalDryRunView r={dry.data as GlobalWordsDryRun} />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{scope === "global" && block.reresolve ? <ReresolveProgress p={block.reresolve} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityDryRunView({ r, showTable }: { r: EntityWordsDryRun; showTable: boolean }) {
|
||||
const items = r.preview.items.filter((i) => i.changed || i.preview.method === "unresolved");
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-caption text-foreground">
|
||||
{r.changed ? `近期资源里 ${r.changed} 条的季集会变` : "近期资源的季集都不变"}
|
||||
{r.mismatched ? (
|
||||
<>
|
||||
{","}
|
||||
<span className="text-destructive">{r.mismatched} 条仍对不上季表</span>(保持集数待确认)
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
{showTable && items.length ? (
|
||||
<div className="-mx-3 border-t border-border/60">
|
||||
<PreviewTable items={items} hasDraft />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GlobalDryRunView({ r }: { r: GlobalWordsDryRun }) {
|
||||
return (
|
||||
<div className="space-y-2 text-caption">
|
||||
{r.added.length || r.removed.length ? (
|
||||
<div className="space-y-0.5 font-mono text-meta">
|
||||
{r.added.map((l) => (
|
||||
<div key={`+${l}`} className="break-all text-foreground">
|
||||
+ {l}
|
||||
</div>
|
||||
))}
|
||||
{r.removed.map((l) => (
|
||||
<div key={`-${l}`} className="break-all text-muted-foreground line-through">
|
||||
− {l}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">生效的行没有变化(只改了注释或空行),保存后不用重新识别。</p>
|
||||
)}
|
||||
{r.added.length || r.removed.length ? (
|
||||
<p className="text-foreground">
|
||||
最近 {r.scanned} 条资源里 {r.matched} 条命中改动的行
|
||||
{r.items.length ? `,${r.items.length} 条识别结果会变` : ",识别结果都不变"}
|
||||
。保存后在后台把全部命中的资源重新识别。
|
||||
</p>
|
||||
) : null}
|
||||
{r.items.length ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[40rem] text-meta">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 text-left text-muted-foreground">
|
||||
<th className="py-1.5 pr-3 font-medium">资源</th>
|
||||
<th className="py-1.5 pr-3 font-medium">现在识别为</th>
|
||||
<th className="py-1.5 font-medium">改后识别为</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{r.items.map((it) => (
|
||||
<tr key={it.sourceReleaseId} className="border-b border-border/60 align-top last:border-b-0">
|
||||
<td className="max-w-[20rem] py-1.5 pr-3">
|
||||
<div className="font-mono break-all text-foreground">{it.title}</div>
|
||||
{it.entityTitle ? <div className="text-muted-foreground">现在绑定:{it.entityTitle}</div> : null}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-muted-foreground">{briefText(it.before)}</td>
|
||||
<td className="py-1.5">
|
||||
<span className="inline-flex items-start gap-1 font-semibold text-foreground">
|
||||
<ArrowRight className="mt-0.5 size-3 shrink-0 text-muted-foreground" />
|
||||
{briefText(it.after)}
|
||||
</span>
|
||||
{it.words.length ? (
|
||||
<div className="mt-0.5 font-mono break-all text-muted-foreground">
|
||||
命中识别词:{it.words.join(";")}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
function briefText(b: GlobalWordsDryRun["items"][number]["before"]): string {
|
||||
const parts: string[] = [b.title ? `${b.title}${b.year ? ` (${b.year})` : ""}` : "认不出片名"];
|
||||
if (b.season !== null || b.episodes) {
|
||||
const e = b.episodes
|
||||
? b.episodes.start === b.episodes.end
|
||||
? `E${pad(b.episodes.start)}`
|
||||
: `E${pad(b.episodes.start)}-E${pad(b.episodes.end)}`
|
||||
: "";
|
||||
parts.push(`${b.season !== null ? `S${pad(b.season)}` : ""}${e}`);
|
||||
}
|
||||
const tmdb = b.explicitIds.find((x) => x.provider === "tmdb");
|
||||
if (tmdb) parts.push(`TMDB ${tmdb.id}`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function ReresolveProgress({ p }: { p: NonNullable<IdentifyWordsBlock["reresolve"]> }) {
|
||||
const running = p.status === "queued" || p.status === "running";
|
||||
const pct = p.total ? Math.min(100, Math.round((p.scanned / p.total) * 100)) : 0;
|
||||
return (
|
||||
<div className="space-y-1.5 text-meta">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge tone={p.status === "failed" ? "destructive" : running ? "neutral" : "success"}>
|
||||
{p.status === "queued"
|
||||
? "排队中"
|
||||
: p.status === "running"
|
||||
? "重新识别中"
|
||||
: p.status === "failed"
|
||||
? "失败"
|
||||
: "已完成"}
|
||||
</StatusBadge>
|
||||
<span className="text-muted-foreground">
|
||||
上次保存后:扫描 {p.scanned}/{p.total} 条资源,{p.matched} 条命中改动的行,已重新识别 {p.queued} 条
|
||||
{p.finishedAt ? ` · ${formatDateTime(p.finishedAt)}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
{running ? (
|
||||
<div className="h-1 overflow-hidden rounded-full bg-foreground/[0.08]">
|
||||
<div className={cn("h-full bg-brand transition-[width] duration-layout")} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
) : null}
|
||||
{p.error ? <p className="text-destructive">{p.error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { EntityWordsSummary } from "@umt/contracts";
|
||||
import { ExternalLink, Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useEntityWords, useEntityWordsList, useGlobalWords } from "~/api/episode-mapping.ts";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { FilterBar } from "~/components/console/filter-bar.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { useRecordSheet } from "~/components/console/record-sheet.tsx";
|
||||
import { MAPPING_ANCHOR } from "~/components/episode-mapping/model.ts";
|
||||
import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx";
|
||||
import { Panel } from "~/components/library/parts.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { actorText } from "~/lib/labels.ts";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
import { WordsEditor } from "./words-editor.tsx";
|
||||
|
||||
// 规则页「识别词」页签(docs/design/identify-words.md):上面是全局识别词,下面是写了作品识别词的作品;
|
||||
// 点一部作品在右侧抽屉里改它的识别词(?open=<作品 id>),与作品页「集数映射」是同一个编辑器、同一份数据。
|
||||
|
||||
export function IdentifyWordsTab() {
|
||||
const global = useGlobalWords();
|
||||
const [q, setQ] = useState("");
|
||||
const list = useEntityWordsList(q.trim());
|
||||
const sheet = useRecordSheet();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [picked, setPicked] = useState<PickedEntity | null>(null);
|
||||
|
||||
const columns: DataColumn<EntityWordsSummary>[] = [
|
||||
{
|
||||
key: "title",
|
||||
header: "作品",
|
||||
cell: (r) => (
|
||||
<span className="font-medium text-foreground">
|
||||
{r.title}
|
||||
{r.year ? <span className="ml-1 text-muted-foreground">({r.year})</span> : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "lines",
|
||||
header: "规则",
|
||||
cell: (r) => (
|
||||
<span className="tabular-nums">
|
||||
{r.rules} 行{r.disabled ? <span className="text-muted-foreground"> · 停用 {r.disabled}</span> : null}
|
||||
{r.errors ? <span className="text-destructive"> · 有问题 {r.errors}</span> : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "updated",
|
||||
header: "最后修改",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => (
|
||||
<span className="text-muted-foreground">
|
||||
{r.updatedAt ? formatDateTime(r.updatedAt) : "—"}
|
||||
{r.updatedBy ? ` · ${actorText(r.updatedBy)}` : ""}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "link",
|
||||
header: "",
|
||||
align: "right",
|
||||
cell: (r) => (
|
||||
<Button asChild variant="ghost" size="sm" onClick={(e) => e.stopPropagation()}>
|
||||
<Link to={`${consolePath("library", r.mediaEntityId)}#${MAPPING_ANCHOR}`}>
|
||||
<ExternalLink />
|
||||
作品页
|
||||
</Link>
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Panel
|
||||
title="全局识别词"
|
||||
description="所有资源标题、副标题和整理时的文件名在识别前先过一遍,写法与 MoviePilot 自定义识别词相同"
|
||||
>
|
||||
{global.isPending ? (
|
||||
<LoadingState rows={4} />
|
||||
) : global.isError ? (
|
||||
<ErrorState plain error={global.error} onRetry={() => global.refetch()} />
|
||||
) : (
|
||||
<WordsEditor scope="global" block={global.data} />
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
title="作品识别词"
|
||||
description="只对一部作品生效的季集换算;作品页「集数映射」里改的是同一份"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => setAdding(true)}>
|
||||
<Plus />
|
||||
为作品添加
|
||||
</Button>
|
||||
}
|
||||
bodyClassName="px-0 py-0 sm:px-0"
|
||||
>
|
||||
<div className="px-4 pt-4 sm:px-5">
|
||||
<FilterBar search={{ value: q, onChange: setQ, placeholder: "搜索作品" }} className="mb-3" />
|
||||
</div>
|
||||
<DataTable
|
||||
plain
|
||||
columns={columns}
|
||||
rows={list.data?.items}
|
||||
rowKey={(r) => r.mediaEntityId}
|
||||
loading={list.isPending}
|
||||
error={list.error}
|
||||
onRetry={() => list.refetch()}
|
||||
onRowClick={(r) => sheet.open(r.mediaEntityId)}
|
||||
selectedKey={sheet.id}
|
||||
empty={{
|
||||
title: q.trim() ? "没有匹配的作品" : "还没有作品写过识别词",
|
||||
description: q.trim() ? undefined : "发布者写的季集和作品季表对不上时,为那部作品写一行换算。",
|
||||
}}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<EntityWordsSheet id={sheet.id} onClose={sheet.close} />
|
||||
|
||||
<Dialog
|
||||
open={adding}
|
||||
onOpenChange={(o) => {
|
||||
setAdding(o);
|
||||
if (!o) setPicked(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>为作品添加识别词</DialogTitle>
|
||||
<DialogDescription>选一部本地作品,在右侧抽屉里写它的季集换算。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<EntityPicker value={picked} onChange={setPicked} />
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAdding(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!picked}
|
||||
onClick={() => {
|
||||
if (!picked) return;
|
||||
setAdding(false);
|
||||
setPicked(null);
|
||||
sheet.open(picked.id);
|
||||
}}
|
||||
>
|
||||
写识别词
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityWordsSheet({ id, onClose }: { id: string | null; onClose: () => void }) {
|
||||
const q = useEntityWords(id);
|
||||
return (
|
||||
<DetailSheet
|
||||
open={!!id}
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
width="xl"
|
||||
title={q.data?.title ?? "作品识别词"}
|
||||
description={
|
||||
q.data && !q.data.applicable ? "这部作品不是剧集或综艺,季集换算不会生效" : "只对这部作品生效的季集换算"
|
||||
}
|
||||
actions={
|
||||
id ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={`${consolePath("library", id)}#${MAPPING_ANCHOR}`}>
|
||||
<ExternalLink />
|
||||
打开作品页
|
||||
</Link>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div>
|
||||
{q.isPending ? (
|
||||
<LoadingState rows={4} />
|
||||
) : q.isError ? (
|
||||
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
|
||||
) : (
|
||||
<WordsEditor
|
||||
key={id ?? ""}
|
||||
scope="entity"
|
||||
entityId={id}
|
||||
block={q.data.block}
|
||||
knownGroups={q.data.knownGroups}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,7 @@ export const WORKFLOW_LABEL: Record<string, string> = {
|
||||
scrapeMedia: "刮削",
|
||||
refreshMetadata: "刷新元数据",
|
||||
prewarmMetadata: "预热元数据缓存",
|
||||
reresolveIdentifyWords: "按识别词重新识别",
|
||||
reconcileLibrary: "文件核对",
|
||||
cleanupDownloadCache: "清理缓存",
|
||||
resolveDecision: "处理事项",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRuleGroups, useRuleRefs } from "~/api/rules.ts";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { useRecordSheet } from "~/components/console/record-sheet.tsx";
|
||||
import { IdentifyWordsTab } from "~/components/identify-words/words-tab.tsx";
|
||||
import { PolicyWorkspace } from "~/components/library/subscriptions-policy.tsx";
|
||||
import { RuleGroupEditor } from "~/components/rules/group-editor.tsx";
|
||||
import { RuleGroupTable, RuleTags } from "~/components/rules/group-list.tsx";
|
||||
@@ -15,11 +16,11 @@ import { Button } from "~/components/ui/button.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
|
||||
// 规则(docs/design/subscriptions.md §6):收录策略 + 偏好标签合一。
|
||||
// Tab:规则组(含单条规则的标签形态)/ 自定义条件 / 默认值 / 收录策略 / 抑制记录。
|
||||
// Tab:规则组(含单条规则的标签形态)/ 自定义条件 / 默认值 / 收录策略 / 抑制记录 / 识别词(docs/design/identify-words.md)。
|
||||
|
||||
export const meta = () => [{ title: "规则 · UHD 媒体自动化控制台" }];
|
||||
|
||||
const TABS = ["groups", "atoms", "defaults", "policies", "suppressions"] as const;
|
||||
const TABS = ["groups", "atoms", "defaults", "policies", "suppressions", "words"] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
|
||||
/** 旧 /preferences?origin= 透传过来的来源 */
|
||||
@@ -82,7 +83,7 @@ export default function RulesPage() {
|
||||
<PageHeader
|
||||
title="规则"
|
||||
actions={
|
||||
tab === "policies" ? (
|
||||
tab === "words" ? null : tab === "policies" ? (
|
||||
<Button onClick={() => setCreatingPolicy(true)}>
|
||||
<FilePlus2 />
|
||||
新建
|
||||
@@ -115,6 +116,7 @@ export default function RulesPage() {
|
||||
<TabsTrigger value="defaults">默认规则</TabsTrigger>
|
||||
<TabsTrigger value="policies">自动收新作品</TabsTrigger>
|
||||
<TabsTrigger value="suppressions">已忽略</TabsTrigger>
|
||||
<TabsTrigger value="words">识别词</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="groups">
|
||||
@@ -194,10 +196,11 @@ export default function RulesPage() {
|
||||
<TabsContent value="suppressions">
|
||||
<SuppressionsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="words">{tab === "words" ? <IdentifyWordsTab /> : null}</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<RuleGroupEditor
|
||||
open={creating || (!!sheet.id && !!editing)}
|
||||
open={creating || (tab !== "words" && !!sheet.id && !!editing)}
|
||||
group={editing}
|
||||
defaultKind={(kind || "acquisition") as RuleGroupKind}
|
||||
onOpenChange={(o) => {
|
||||
|
||||
Reference in new issue
Block a user