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
@@ -101,3 +101,16 @@ describe("下载面板展示换算(DL-2)", () => {
|
||||
expect(liveStateLabel(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("入库状态不能遮住最近一次部分整理或处理中结果", () => {
|
||||
expect(
|
||||
organizeState({ status: "imported", organize: rec("partial", { reason: "excluded_unresolved" }) }),
|
||||
).toMatchObject({
|
||||
label: "部分整理",
|
||||
tone: "warning",
|
||||
to: `/console/organize?open=${RID}`,
|
||||
});
|
||||
expect(organizeState({ status: "imported", organize: rec("running") })?.label).toBe("整理中");
|
||||
expect(organizeState({ status: "imported", organize: rec("failed") })?.tone).toBe("destructive");
|
||||
expect(organizeState({ status: "imported", organize: rec("succeeded") })?.tone).toBe("success");
|
||||
});
|
||||
@@ -50,12 +50,12 @@ export function organizeState(
|
||||
if (t.managed === false && !rec && t.status !== "importing" && t.status !== "imported") return null;
|
||||
// 有问题的整理在整理记录详情里就地处理(待处理中心已取消)
|
||||
const to = rec ? recordPath("organize", rec.recordId) : null;
|
||||
if (rec?.status === "succeeded" || (t.status === "imported" && rec?.status !== "failed"))
|
||||
return { label: "已整理", tone: "success", to, hint: null };
|
||||
if (rec?.status === "failed") return { label: "整理失败", tone: "destructive", to, hint: reasonHint(rec.reason) };
|
||||
if (rec?.status === "partial") return { label: "部分整理", tone: "warning", to, hint: reasonHint(rec.reason) };
|
||||
if (rec?.status === "succeeded") return { label: "已整理", tone: "success", to, hint: null };
|
||||
if (rec?.status === "queued" || rec?.status === "running" || t.status === "importing")
|
||||
return { label: "整理中", tone: "neutral", to, hint: null };
|
||||
if (t.status === "imported") return { label: "已整理", tone: "success", to, hint: null };
|
||||
return { label: "待整理", tone: "warning", to: rec?.decision ? to : null, hint: null };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { PublicOrganizationRecord } from "@umt/contracts";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { expect, it } from "vitest";
|
||||
import { DownloadOrganizeIssue } from "./download-detail.tsx";
|
||||
|
||||
const render = (status: PublicOrganizationRecord["status"]) =>
|
||||
renderToStaticMarkup(
|
||||
createElement(
|
||||
MemoryRouter,
|
||||
null,
|
||||
createElement(DownloadOrganizeIssue, {
|
||||
record: {
|
||||
id: "record",
|
||||
status,
|
||||
reason: { code: "episode_unresolved", message: "集数待确认" },
|
||||
} as PublicOrganizationRecord,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
it("下载详情中的部分结果与失败都指向原整理记录,成功没有问题提示", () => {
|
||||
expect(render("partial")).toContain("部分整理完成,还有文件需要处理");
|
||||
expect(render("partial")).toContain("/console/organize?open=record");
|
||||
expect(render("partial")).toContain("集数待确认");
|
||||
expect(render("partial")).toContain("text-warning");
|
||||
expect(render("failed")).toContain("整理失败");
|
||||
expect(render("failed")).toContain("text-destructive");
|
||||
expect(render("succeeded")).toBe("");
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DownloadLive, DownloadTask } from "@umt/contracts";
|
||||
import type { DownloadLive, DownloadTask, PublicOrganizationRecord } from "@umt/contracts";
|
||||
import { Bot, FolderInput, Pause, Play, ShieldAlert } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -24,7 +24,7 @@ import { Button } from "~/components/ui/button.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { humanizeReason, reasonCode, reasonLine } from "~/lib/reasons.ts";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
import { liveItems } from "./board-format.ts";
|
||||
import { BusinessTimeline } from "./business-timeline.tsx";
|
||||
import { WhySection } from "./plan-row.tsx";
|
||||
@@ -89,6 +89,29 @@ export function PauseResume({ t, size = "sm" }: { t: DownloadTask; size?: "sm" |
|
||||
);
|
||||
}
|
||||
|
||||
/** 最近一次整理的部分完成和失败都留在下载详情里,并指向原记录。 */
|
||||
export function DownloadOrganizeIssue({ record }: { record: PublicOrganizationRecord | null | undefined }) {
|
||||
if (!record || (record.status !== "failed" && record.status !== "partial")) return null;
|
||||
const partial = record.status === "partial";
|
||||
const reason = reasonLine(record.reason);
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-caption",
|
||||
partial
|
||||
? "border-warning/40 bg-warning/10 text-warning"
|
||||
: "border-destructive/40 bg-destructive/10 text-destructive",
|
||||
)}
|
||||
>
|
||||
{partial ? "部分整理完成,还有文件需要处理" : "整理失败"}
|
||||
{reason ? `:${reason}` : ""}。
|
||||
<RecordLink page="organize" id={record.id} className="ml-1 underline underline-offset-4">
|
||||
去整理记录处理
|
||||
</RecordLink>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** 下载任务详情:状态、阈值说明、业务时间线、为什么下这个(DL-3)、文件 */
|
||||
export function DownloadDetail({ task, live }: { task: DownloadTask; live?: DownloadLive | null }) {
|
||||
const plan = usePlan(task.acquisitionPlanId);
|
||||
@@ -96,11 +119,12 @@ export function DownloadDetail({ task, live }: { task: DownloadTask; live?: Down
|
||||
const trace = useDownloadTrace(task, plan.data);
|
||||
const [organizing, setOrganizing] = useState(false);
|
||||
const canOrganize = ["completed", "importing", "imported"].includes(task.status);
|
||||
// 下载完成但最近一次整理失败:状态行只写"下载完成"会误导,单独说明整理失败
|
||||
// 下载任务状态不能覆盖最近一次整理的部分完成 / 失败结果
|
||||
const lastRecord = [...(trace.records.data?.items ?? [])].sort((a, b) =>
|
||||
(b.startedAt ?? b.createdAt).localeCompare(a.startedAt ?? a.createdAt),
|
||||
)[0];
|
||||
const importFailed = task.status === "completed" && lastRecord?.status === "failed" ? lastRecord : null;
|
||||
const importIssue =
|
||||
canOrganize && (lastRecord?.status === "failed" || lastRecord?.status === "partial") ? lastRecord : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -141,14 +165,7 @@ export function DownloadDetail({ task, live }: { task: DownloadTask; live?: Down
|
||||
下载盘超过暂停线,任务已暂停;降到恢复线以下后自动恢复。
|
||||
</p>
|
||||
) : null}
|
||||
{importFailed ? (
|
||||
<p className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive">
|
||||
整理失败:{reasonLine(importFailed.reason)}。
|
||||
<RecordLink page="organize" id={importFailed.id} className="ml-1 underline underline-offset-4">
|
||||
去整理记录处理
|
||||
</RecordLink>
|
||||
</p>
|
||||
) : null}
|
||||
<DownloadOrganizeIssue record={importIssue} />
|
||||
{task.noProgressSince ? (
|
||||
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||
{formatDateTime(task.noProgressSince)}{" "}
|
||||
@@ -195,8 +212,8 @@ export function DownloadDetail({ task, live }: { task: DownloadTask; live?: Down
|
||||
{ label: "torrent hash", value: task.torrentHash, wide: true },
|
||||
{ label: "任务 ID", value: task.id, wide: true },
|
||||
{ label: "下载器 ID", value: task.instanceId, wide: true },
|
||||
importFailed ? { label: "整理失败原因码", value: reasonCode(importFailed.reason) } : null,
|
||||
importFailed ? { label: "整理记录 ID", value: importFailed.id, wide: true } : null,
|
||||
importIssue ? { label: "整理结果原因码", value: reasonCode(importIssue.reason) } : null,
|
||||
importIssue ? { label: "整理记录 ID", value: importIssue.id, wide: true } : null,
|
||||
]}
|
||||
>
|
||||
{task.lastError ? <JsonView label="下载器错误原文" value={task.lastError} tone="destructive" /> : null}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { PublicOrganizationRecord, RecordAttention } from "@umt/contracts";
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { OrganizeRecordSheet } from "./organize-record-sheet.tsx";
|
||||
|
||||
const state = vi.hoisted(() => ({ record: null as PublicOrganizationRecord | null }));
|
||||
vi.mock("~/api/records.ts", async (original) => ({
|
||||
...(await original<typeof import("~/api/records.ts")>()),
|
||||
useRecord: () => ({ data: state.record }),
|
||||
}));
|
||||
vi.mock("~/components/console/detail-sheet.tsx", () => ({
|
||||
DetailSheet: ({ actions, children }: { actions: ReactNode; children: ReactNode }) =>
|
||||
createElement("div", null, actions, children),
|
||||
DetailSection: ({ children }: { children: ReactNode }) => createElement("section", null, children),
|
||||
}));
|
||||
vi.mock("~/components/organize/organize-scrape-history.tsx", () => ({ OrganizeScrapeHistory: () => null }));
|
||||
vi.mock("~/components/organize/delete-records-dialog.tsx", () => ({ DeleteRecordsDialog: () => null }));
|
||||
const render = (attention: RecordAttention | null) => {
|
||||
state.record = {
|
||||
id: "record",
|
||||
media: null,
|
||||
status: "failed",
|
||||
action: "copy",
|
||||
trigger: "manual",
|
||||
sizeBytes: null,
|
||||
attention,
|
||||
reason: null,
|
||||
source: { path: "/input" },
|
||||
target: { path: null },
|
||||
counts: { total: 0, succeeded: 0, failed: 0, skipped: 0 },
|
||||
actor: { type: "system" },
|
||||
files: [],
|
||||
} as unknown as PublicOrganizationRecord;
|
||||
return renderToStaticMarkup(
|
||||
createElement(
|
||||
QueryClientProvider,
|
||||
{ client: new QueryClient() },
|
||||
createElement(MemoryRouter, null, createElement(OrganizeRecordSheet, { id: "record", onClose: () => {} })),
|
||||
),
|
||||
);
|
||||
};
|
||||
it("记录问题区承担主动作,不重复给重新整理或泛化排查", () => {
|
||||
const html = render({ topic: "other", decision: null, agentIdentify: false });
|
||||
expect(html.match(/重新整理<\/button>/g)).toHaveLength(1);
|
||||
expect(html).not.toContain("让智能体排查");
|
||||
expect(render(null)).toContain("重新整理");
|
||||
});
|
||||
it("调查中只显示会话入口,不用另一套泛化排查覆盖进度", () => {
|
||||
const html = render({
|
||||
topic: "other",
|
||||
agentIdentify: false,
|
||||
decision: {
|
||||
id: "decision",
|
||||
kind: "other",
|
||||
status: "investigating",
|
||||
title: "排查",
|
||||
summary: null,
|
||||
options: [],
|
||||
evidence: {},
|
||||
agentRunId: "run",
|
||||
},
|
||||
});
|
||||
expect(html).toContain("智能体处理中");
|
||||
expect(html).toContain("/console/agent/run");
|
||||
expect(html).not.toContain("重新整理");
|
||||
expect(html).not.toContain("让智能体排查");
|
||||
});
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { PublicOrganizationFile, PublicPathRef } from "@umt/contracts";
|
||||
import { ArrowRight, Bot, ChevronRight, Trash2, Wand2 } from "lucide-react";
|
||||
import { ArrowRight, ChevronRight, Trash2, Wand2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { ACTION_LABEL, RECORD_STATUS_LABEL, RECORD_STATUS_TONE, TRIGGER_LABEL, useRecord } from "~/api/records.ts";
|
||||
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
@@ -155,29 +154,12 @@ export function OrganizeRecordSheet({ id, onClose }: { id: string | null; onClos
|
||||
actions={
|
||||
r ? (
|
||||
<>
|
||||
{canReorganize(r) ? (
|
||||
{canReorganize(r) && !r.attention ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setReorganize(true)}>
|
||||
<Wand2 />
|
||||
重新整理
|
||||
</Button>
|
||||
) : null}
|
||||
{r.status === "failed" || r.status === "partial" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
openAgentDrawer({
|
||||
label: `整理记录:${r.media?.title ?? "未关联作品"}`,
|
||||
recordId: r.id,
|
||||
entityId: r.media?.entityId,
|
||||
goal: "排查这次整理为什么没有全部成功,给出恢复办法",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Bot />
|
||||
让智能体排查
|
||||
</Button>
|
||||
) : null}
|
||||
{r.media ? (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to={consolePath("library", r.media.entityId)}>查看作品</Link>
|
||||
|
||||
@@ -35,7 +35,8 @@ const decision = (over: Partial<NonNullable<RecordAttention["decision"]>> = {})
|
||||
...over,
|
||||
});
|
||||
|
||||
type Rec = Pick<PublicOrganizationRecord, "id" | "action" | "status" | "attention" | "reason">;
|
||||
type Rec = Pick<PublicOrganizationRecord, "id" | "action" | "status" | "attention" | "reason"> &
|
||||
Partial<Pick<PublicOrganizationRecord, "media">>;
|
||||
const rec = (attention: RecordAttention | null, over: Partial<Rec> = {}): Rec => ({
|
||||
id: R,
|
||||
action: "copy",
|
||||
@@ -128,3 +129,39 @@ describe("整理记录行内处理", () => {
|
||||
expect(attentionLine(rec(null))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("季集问题直接跳已有作品映射,部分整理也读排除文件证据", () => {
|
||||
const media = {
|
||||
entityId: R,
|
||||
title: "Foo",
|
||||
year: null,
|
||||
kind: "series" as const,
|
||||
contentUnitId: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
externalIds: [],
|
||||
};
|
||||
const a: RecordAttention = { topic: "other", decision: null, agentIdentify: false };
|
||||
const r = rec(a, { media, reason: { code: "episode_unresolved", message: "集数待确认" } });
|
||||
expect(primaryAction(r)).toBe("mapping");
|
||||
expect(html(r)).toContain(`/console/library/${R}#episode-mapping`);
|
||||
expect(html(r)).toContain("处理集数映射");
|
||||
const partial = rec(
|
||||
{
|
||||
...a,
|
||||
decision: decision({
|
||||
kind: "other",
|
||||
evidence: { files: [{ path: "/E13.mkv", reason: "excluded:episode_unresolved" }] },
|
||||
}),
|
||||
},
|
||||
{ media, status: "partial", reason: { code: "excluded_unresolved", message: "还有文件待处理" } },
|
||||
);
|
||||
expect(primaryAction(partial)).toBe("mapping");
|
||||
expect(primaryAction(rec(a, { reason: r.reason }))).toBe("reorganize");
|
||||
expect(primaryAction(rec(a, { media, reason: { code: "naming_failed", message: "命名失败" } }))).toBe("reorganize");
|
||||
expect(
|
||||
html(
|
||||
rec({ ...a, decision: decision({ kind: "episode_mapping", status: "investigating", agentRunId: D }) }, { media }),
|
||||
),
|
||||
).not.toContain("处理集数映射");
|
||||
});
|
||||
@@ -11,9 +11,10 @@ import {
|
||||
OptionButton,
|
||||
plainOptions,
|
||||
} from "~/components/decisions/decision-actions.tsx";
|
||||
import { mappingHref } from "~/components/episode-mapping/model.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { humanizeReason } from "~/lib/reasons.ts";
|
||||
import { humanizeReason, reasonCode } from "~/lib/reasons.ts";
|
||||
import { canReorganize } from "./record-parts.ts";
|
||||
|
||||
// 整理记录「需要处理」的行内处理(2026-10-06 去掉待处理中心后,整理侧的问题在这里就地处理):
|
||||
@@ -21,7 +22,20 @@ import { canReorganize } from "./record-parts.ts";
|
||||
// 目标已存在 / 路径冲突 → 事项给的选项(替换 / 保留两者 / 跳过);
|
||||
// 其余失败 / 部分完成 → 重新整理 / 忽略。执行都走已有端点(/decisions/{id}/resolve、整理向导、忽略)。
|
||||
|
||||
type Rec = Pick<PublicOrganizationRecord, "id" | "action" | "status" | "attention" | "reason">;
|
||||
type Rec = Pick<PublicOrganizationRecord, "id" | "action" | "status" | "attention" | "reason"> &
|
||||
Partial<Pick<PublicOrganizationRecord, "media">>;
|
||||
|
||||
/** 原因码和排除文件证据识别季集问题,直接进入已有作品映射分区。 */
|
||||
export function episodeMappingTarget(r: Rec): string | null {
|
||||
if (!r.media) return null;
|
||||
const files = r.attention?.decision?.evidence.files;
|
||||
const episodeIssue =
|
||||
reasonCode(r.reason) === "episode_unresolved" ||
|
||||
r.attention?.decision?.kind === "episode_mapping" ||
|
||||
(Array.isArray(files) &&
|
||||
files.some((f) => f?.reason === "excluded:episode_unresolved" || f?.reason === "episode_unresolved"));
|
||||
return episodeIssue ? mappingHref(consolePath("library", r.media.entityId)) : null;
|
||||
}
|
||||
|
||||
/** 一句话说清这条记录卡在哪(事项摘要优先,其次记录原因) */
|
||||
export function attentionLine(r: Rec): string | null {
|
||||
@@ -34,16 +48,18 @@ export function attentionLine(r: Rec): string | null {
|
||||
const suggestion = typeof recovery?.suggestion === "string" ? recovery.suggestion : a.decision.summary;
|
||||
return suggestion ? `需要你确认:${humanizeReason(suggestion)}` : "智能体暂时无法确定,需要你确认";
|
||||
}
|
||||
if (episodeMappingTarget(r)) return "季集对不上作品季表,到作品的集数映射里处理";
|
||||
if (a.topic === "identity") return "没识别出作品。指定作品,或让智能体识别";
|
||||
return a.decision?.summary ? humanizeReason(a.decision.summary) : null;
|
||||
}
|
||||
|
||||
/** 推荐的主动作(给列表文字提示与测试用):identity → 指定作品;conflict → 事项选项;other → 重新整理 */
|
||||
export function primaryAction(r: Rec): "assign" | "options" | "reorganize" | null {
|
||||
export function primaryAction(r: Rec): "assign" | "options" | "mapping" | "reorganize" | null {
|
||||
const a = r.attention;
|
||||
if (!a) return null;
|
||||
if (a.topic === "identity" && a.decision && canAssignWork(a.decision)) return "assign";
|
||||
if (a.topic === "conflict" && a.decision && plainOptions(a.decision).length) return "options";
|
||||
if (episodeMappingTarget(r)) return "mapping";
|
||||
return canReorganize(r) ? "reorganize" : null;
|
||||
}
|
||||
|
||||
@@ -90,6 +106,14 @@ export function RecordAttentionActions({
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{primary === "mapping" && !investigating ? (
|
||||
<Button asChild size="sm">
|
||||
<Link to={episodeMappingTarget(r)!}>
|
||||
<Wand2 />
|
||||
处理集数映射
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{primary === "assign" && d && !investigating ? <AssignWorkButton d={d} /> : null}
|
||||
{primary === "options" && d && !investigating
|
||||
? plainOptions(d).map((o, i) => (
|
||||
|
||||
Reference in new issue
Block a user