fix(web): expose organize recovery and file episode evidence
This commit is contained in:
1 parent
b7883eaf4f
commit
2b3ccb89e4
12 files changed
+390
-72
No files matched your search
@@ -96,7 +96,7 @@ describe("待处理「集数待确认」", () => {
|
||||
simulation: null,
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("在上面的作品识别词里手动写");
|
||||
expect(html).toContain("展开作品识别词后手动写");
|
||||
});
|
||||
|
||||
it("模拟结果一句话", () => {
|
||||
@@ -186,3 +186,45 @@ describe("季集映射调查状态", () => {
|
||||
expect(renderStatus(decision())).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("文件事项显示原号、文件路径与已对上的校验样本,不虚构资源数量", () => {
|
||||
const sample = (ep: number, pending: boolean) => ({
|
||||
recordId: "record",
|
||||
path: `/anime/E${ep}.mkv`,
|
||||
parsed: { season: 1, episodes: { start: ep, end: ep } },
|
||||
publishedAt: null,
|
||||
resolution: {
|
||||
method: pending ? "unresolved" : "raw",
|
||||
source: { season: 1, episodes: { start: ep, end: ep } },
|
||||
canonical: pending ? null : { season: 1, episodes: { start: ep, end: ep } },
|
||||
},
|
||||
});
|
||||
const view = episodeDecisionView(
|
||||
decision({
|
||||
evidence: {
|
||||
mediaEntityId: ENTITY,
|
||||
count: 1,
|
||||
sourceReleaseIds: [],
|
||||
fileSamples: [sample(13, true), sample(1, false)],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(view.files.map((f) => f.pending)).toEqual([true, false]);
|
||||
const out = renderToStaticMarkup(createElement(EpisodeDecisionBody, { view, simulation: null }));
|
||||
expect(out).toContain("待确认文件");
|
||||
expect(out).toContain("S01E13");
|
||||
expect(out).toContain("/anime/E13.mkv");
|
||||
expect(out).toContain("已对上的 1 个文件");
|
||||
expect(out).toContain("S01E01 → S01E01");
|
||||
expect(out).not.toContain("来源季集");
|
||||
expect(out).not.toContain("另有");
|
||||
const mixed = episodeDecisionView(
|
||||
decision({
|
||||
evidence: { ...decision().evidence, count: 3, sourceReleaseIds: ["a", "b"], fileSamples: [sample(13, true)] },
|
||||
}),
|
||||
);
|
||||
expect(mixed.releaseCount).toBe(2);
|
||||
expect(renderToStaticMarkup(createElement(EpisodeDecisionBody, { view: mixed, simulation: null }))).not.toContain(
|
||||
"另有",
|
||||
);
|
||||
});
|
||||
@@ -49,6 +49,8 @@ export interface EpisodeDecisionView {
|
||||
releaseGroup: string | null;
|
||||
releases: { id: string; title: string; source: string }[];
|
||||
count: number;
|
||||
releaseCount: number;
|
||||
files: { recordId: string; path: string; pending: boolean; source: string; canonical: string }[];
|
||||
suggestion: Suggestion | null;
|
||||
/** 采用建议规则时写进作品识别词的那一行 */
|
||||
suggestionLine: string | null;
|
||||
@@ -73,6 +75,17 @@ export function episodeDecisionView(d: Decision): EpisodeDecisionView {
|
||||
source: r.source ? sourceText({ source: r.source }) : "—",
|
||||
}))
|
||||
: [];
|
||||
const files = Array.isArray(e.fileSamples)
|
||||
? (e.fileSamples as { recordId: string; path: string; resolution: EpisodeResolutionView }[])
|
||||
.filter((f) => typeof f.recordId === "string" && typeof f.path === "string" && f.resolution?.source)
|
||||
.map((f) => ({
|
||||
recordId: f.recordId,
|
||||
path: f.path,
|
||||
pending: f.resolution.method === "unresolved",
|
||||
source: sourceText(f.resolution),
|
||||
canonical: canonicalText(f.resolution),
|
||||
}))
|
||||
: [];
|
||||
const s = e.suggestion as Suggestion | null | undefined;
|
||||
const a = e.agentSuggestion as AgentSuggestion | null | undefined;
|
||||
return {
|
||||
@@ -80,6 +93,14 @@ export function episodeDecisionView(d: Decision): EpisodeDecisionView {
|
||||
season: typeof e.season === "number" ? e.season : null,
|
||||
releaseGroup: typeof e.releaseGroup === "string" ? e.releaseGroup : null,
|
||||
releases,
|
||||
files,
|
||||
releaseCount: Array.isArray(e.sourceReleaseIds)
|
||||
? e.sourceReleaseIds.length
|
||||
: files.length
|
||||
? releases.length
|
||||
: typeof e.count === "number"
|
||||
? e.count
|
||||
: releases.length,
|
||||
count: typeof e.count === "number" ? e.count : releases.length,
|
||||
suggestion: s && typeof s.expression === "string" ? s : null,
|
||||
agentSuggestion:
|
||||
@@ -142,27 +163,70 @@ export function AgentSuggestionBody({ s }: { s: AgentSuggestion }) {
|
||||
);
|
||||
}
|
||||
|
||||
function EpisodeFileSamples({ files }: { files: EpisodeDecisionView["files"] }) {
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
{files.slice(-5).map((f) => (
|
||||
<li key={`${f.recordId}:${f.path}`} className="break-all">
|
||||
<span className="font-mono tabular-nums">
|
||||
{f.source}
|
||||
{f.pending ? " · 待确认" : ` → ${f.canonical}`}
|
||||
</span>
|
||||
<div className="font-mono text-meta text-muted-foreground">{f.path}</div>
|
||||
</li>
|
||||
))}
|
||||
{files.length > 5 ? <li className="text-meta text-muted-foreground">另有 {files.length - 5} 个文件</li> : null}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** 展示部分(纯渲染,单测直接渲染) */
|
||||
export function EpisodeDecisionBody({ view, simulation }: { view: EpisodeDecisionView; simulation: string | null }) {
|
||||
const shown = view.releases.slice(-5);
|
||||
const pending = view.files.filter((f) => f.pending);
|
||||
const guards = view.files.filter((f) => !f.pending);
|
||||
return (
|
||||
<dl className="grid gap-1.5 text-caption sm:grid-cols-[5rem_1fr]">
|
||||
<dt className="text-muted-foreground">范围</dt>
|
||||
<dd className="text-foreground">{scopeText(view.season, view.releaseGroup)}</dd>
|
||||
<dt className="text-muted-foreground">来源季集</dt>
|
||||
<dd className="min-w-0 text-foreground">
|
||||
<ul className="space-y-0.5">
|
||||
{shown.map((r) => (
|
||||
<li key={r.id} className="break-all">
|
||||
<span className="font-mono tabular-nums">{r.source}</span>
|
||||
<span className="ml-2 text-meta text-muted-foreground">{r.title}</span>
|
||||
</li>
|
||||
))}
|
||||
{view.count > shown.length ? (
|
||||
<li className="text-meta text-muted-foreground">另有 {view.count - shown.length} 条</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</dd>
|
||||
{view.releaseCount ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">来源季集</dt>
|
||||
<dd className="min-w-0 text-foreground">
|
||||
<ul className="space-y-0.5">
|
||||
{shown.map((r) => (
|
||||
<li key={r.id} className="break-all">
|
||||
<span className="font-mono tabular-nums">{r.source}</span>
|
||||
<span className="ml-2 text-meta text-muted-foreground">{r.title}</span>
|
||||
</li>
|
||||
))}
|
||||
{view.releaseCount > shown.length ? (
|
||||
<li className="text-meta text-muted-foreground">另有 {view.releaseCount - shown.length} 条</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
{pending.length ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">待确认文件</dt>
|
||||
<dd className="min-w-0 text-foreground">
|
||||
<EpisodeFileSamples files={pending} />
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
{guards.length ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">校验文件</dt>
|
||||
<dd className="min-w-0">
|
||||
<details className="text-caption text-muted-foreground">
|
||||
<summary className="cursor-pointer">已对上的 {guards.length} 个文件</summary>
|
||||
<p className="my-1 text-meta">用于核对新规则,避免影响这些文件的季集。</p>
|
||||
<EpisodeFileSamples files={guards} />
|
||||
</details>
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
{view.agentSuggestion ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">智能体建议</dt>
|
||||
@@ -190,7 +254,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>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { EpisodeMappingPanel } from "./mapping-panel.tsx";
|
||||
|
||||
vi.mock("~/api/episode-mapping.ts", () => ({
|
||||
useEntityWords: () => ({ isSuccess: true, data: { block: { text: "saved-rule" }, knownGroups: [] } }),
|
||||
useEpisodeMappingPreview: () => ({ data: { seasons: { seasons: [] }, groups: [], items: [] } }),
|
||||
}));
|
||||
vi.mock("./issues.tsx", () => ({ EpisodeIssuesList: () => createElement("p", null, "待确认文件的问题") }));
|
||||
vi.mock("~/components/identify-words/words-editor.tsx", () => ({
|
||||
WordsEditor: ({ draft }: { draft: string }) => createElement("textarea", { defaultValue: draft }),
|
||||
}));
|
||||
|
||||
it("先展示待处理问题,长期规则默认折叠且保留原编辑器草稿", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(MemoryRouter, null, createElement(EpisodeMappingPanel, { entityId: "entity" })),
|
||||
);
|
||||
expect(html.indexOf("待确认文件的问题")).toBeLessThan(html.indexOf("作品识别词 · 长期规则"));
|
||||
expect(html).toContain("<details");
|
||||
expect(html).not.toMatch(/<details[^>]* open/);
|
||||
expect(html).toContain("saved-rule");
|
||||
});
|
||||
@@ -73,6 +73,8 @@ export function EpisodeMappingPanel({ entityId }: { entityId: string }) {
|
||||
const location = useLocation();
|
||||
const words = useEntityWords(entityId);
|
||||
const preview = useEpisodeMappingPreview(entityId, {});
|
||||
const [editing, setEditing] = useState(false);
|
||||
const editorRef = useRef<HTMLDetailsElement>(null);
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const saved = words.data?.block.text ?? "";
|
||||
@@ -80,12 +82,13 @@ export function EpisodeMappingPanel({ entityId }: { entityId: string }) {
|
||||
|
||||
/** 把一行追加进草稿(建议、集数待确认的「修改」、从别处跳来带的一行) */
|
||||
const append = (line: string) => {
|
||||
setEditing(true);
|
||||
setDraft((cur) => {
|
||||
const base = (cur ?? saved).replace(/\n*$/, "");
|
||||
if (base.split("\n").some((l) => l.trim() === line.trim())) return cur ?? saved;
|
||||
return `${base ? `${base}\n` : ""}${line}\n`;
|
||||
});
|
||||
ref.current?.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
editorRef.current?.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
};
|
||||
|
||||
// 从集数待确认「修改」跳来:把那一行放进草稿并滚到这里
|
||||
@@ -108,22 +111,33 @@ export function EpisodeMappingPanel({ entityId }: { entityId: string }) {
|
||||
bodyClassName="space-y-4"
|
||||
>
|
||||
{preview.data ? <SeasonSummary p={preview.data.seasons} /> : null}
|
||||
{words.isPending ? (
|
||||
<LoadingState rows={3} />
|
||||
) : words.isError ? (
|
||||
<ErrorState plain error={words.error} onRetry={() => words.refetch()} />
|
||||
) : (
|
||||
<WordsEditor
|
||||
scope="entity"
|
||||
entityId={entityId}
|
||||
block={words.data.block}
|
||||
knownGroups={words.data.knownGroups}
|
||||
draft={text}
|
||||
onDraftChange={setDraft}
|
||||
/>
|
||||
)}
|
||||
{preview.data ? <GroupStats groups={preview.data.groups} onUseSuggestion={append} /> : null}
|
||||
<EpisodeIssuesList entityId={entityId} onEdit={append} />
|
||||
<details
|
||||
ref={editorRef}
|
||||
open={editing}
|
||||
onToggle={(event) => setEditing(event.currentTarget.open)}
|
||||
className="space-y-3"
|
||||
>
|
||||
<summary className="cursor-pointer text-caption text-foreground">作品识别词 · 长期规则</summary>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
按来源季和发布组换算季集,保存后用于这部作品后续的资源与整理文件。
|
||||
</p>
|
||||
{words.isPending ? (
|
||||
<LoadingState rows={3} />
|
||||
) : words.isError ? (
|
||||
<ErrorState plain error={words.error} onRetry={() => words.refetch()} />
|
||||
) : (
|
||||
<WordsEditor
|
||||
scope="entity"
|
||||
entityId={entityId}
|
||||
block={words.data.block}
|
||||
knownGroups={words.data.knownGroups}
|
||||
draft={text}
|
||||
onDraftChange={setDraft}
|
||||
/>
|
||||
)}
|
||||
</details>
|
||||
{preview.data ? <GroupStats groups={preview.data.groups} onUseSuggestion={append} /> : null}
|
||||
</Panel>
|
||||
<Panel
|
||||
className="mt-3"
|
||||
|
||||
Reference in new issue
Block a user