fix(web): expose organize recovery and file episode evidence

This commit is contained in:
root committed 2026-10-13 20:26:43 +08:00
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"