diff --git a/apps/web/app/components/downloads/board-format.test.ts b/apps/web/app/components/downloads/board-format.test.ts
index 8d755034..760dba8a 100644
--- a/apps/web/app/components/downloads/board-format.test.ts
+++ b/apps/web/app/components/downloads/board-format.test.ts
@@ -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");
+});
diff --git a/apps/web/app/components/downloads/board-format.ts b/apps/web/app/components/downloads/board-format.ts
index a37c071e..6b5a0300 100644
--- a/apps/web/app/components/downloads/board-format.ts
+++ b/apps/web/app/components/downloads/board-format.ts
@@ -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 };
}
diff --git a/apps/web/app/components/downloads/download-detail.test.ts b/apps/web/app/components/downloads/download-detail.test.ts
new file mode 100644
index 00000000..57d52d1f
--- /dev/null
+++ b/apps/web/app/components/downloads/download-detail.test.ts
@@ -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("");
+});
diff --git a/apps/web/app/components/downloads/download-detail.tsx b/apps/web/app/components/downloads/download-detail.tsx
index 5a852d49..976a6d25 100644
--- a/apps/web/app/components/downloads/download-detail.tsx
+++ b/apps/web/app/components/downloads/download-detail.tsx
@@ -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 (
+
+ {partial ? "部分整理完成,还有文件需要处理" : "整理失败"}
+ {reason ? `:${reason}` : ""}。
+
+ 去整理记录处理
+
+
+ );
+}
+
/** 下载任务详情:状态、阈值说明、业务时间线、为什么下这个(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 (
@@ -141,14 +165,7 @@ export function DownloadDetail({ task, live }: { task: DownloadTask; live?: Down
下载盘超过暂停线,任务已暂停;降到恢复线以下后自动恢复。
) : null}
- {importFailed ? (
-
- 整理失败:{reasonLine(importFailed.reason)}。
-
- 去整理记录处理
-
-
- ) : null}
+
{task.noProgressSince ? (
{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 ? : null}
diff --git a/apps/web/app/components/episode-mapping/decision.test.ts b/apps/web/app/components/episode-mapping/decision.test.ts
index 300d60fb..56b321a2 100644
--- a/apps/web/app/components/episode-mapping/decision.test.ts
+++ b/apps/web/app/components/episode-mapping/decision.test.ts
@@ -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(
+ "另有",
+ );
+});
diff --git a/apps/web/app/components/episode-mapping/decision.tsx b/apps/web/app/components/episode-mapping/decision.tsx
index 7b9e2a80..ffba88e7 100644
--- a/apps/web/app/components/episode-mapping/decision.tsx
+++ b/apps/web/app/components/episode-mapping/decision.tsx
@@ -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 (
+
+ {files.slice(-5).map((f) => (
+ -
+
+ {f.source}
+ {f.pending ? " · 待确认" : ` → ${f.canonical}`}
+
+
{f.path}
+
+ ))}
+ {files.length > 5 ? - 另有 {files.length - 5} 个文件
: null}
+
+ );
+}
+
/** 展示部分(纯渲染,单测直接渲染) */
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 (
- 范围
- {scopeText(view.season, view.releaseGroup)}
- - 来源季集
- -
-
- {shown.map((r) => (
- -
- {r.source}
- {r.title}
-
- ))}
- {view.count > shown.length ? (
- - 另有 {view.count - shown.length} 条
- ) : null}
-
-
+ {view.releaseCount ? (
+ <>
+ - 来源季集
+ -
+
+ {shown.map((r) => (
+ -
+ {r.source}
+ {r.title}
+
+ ))}
+ {view.releaseCount > shown.length ? (
+ - 另有 {view.releaseCount - shown.length} 条
+ ) : null}
+
+
+ >
+ ) : null}
+ {pending.length ? (
+ <>
+ - 待确认文件
+ -
+
+
+ >
+ ) : null}
+ {guards.length ? (
+ <>
+ - 校验文件
+ -
+
+ 已对上的 {guards.length} 个文件
+ 用于核对新规则,避免影响这些文件的季集。
+
+
+
+ >
+ ) : null}
{view.agentSuggestion ? (
<>
- 智能体建议
@@ -190,7 +254,7 @@ export function EpisodeDecisionBody({ view, simulation }: { view: EpisodeDecisio
{simulation ? {simulation}
: null}
>
) : (
- 样本不够推出唯一的规则,在上面的作品识别词里手动写
+ 样本不够推出唯一的规则,展开作品识别词后手动写
)}
diff --git a/apps/web/app/components/episode-mapping/mapping-panel.test.ts b/apps/web/app/components/episode-mapping/mapping-panel.test.ts
new file mode 100644
index 00000000..d0093d05
--- /dev/null
+++ b/apps/web/app/components/episode-mapping/mapping-panel.test.ts
@@ -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("
]* open/);
+ expect(html).toContain("saved-rule");
+});
diff --git a/apps/web/app/components/episode-mapping/mapping-panel.tsx b/apps/web/app/components/episode-mapping/mapping-panel.tsx
index e0e0c45e..46b60b01 100644
--- a/apps/web/app/components/episode-mapping/mapping-panel.tsx
+++ b/apps/web/app/components/episode-mapping/mapping-panel.tsx
@@ -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(null);
const [draft, setDraft] = useState(null);
const ref = useRef(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 ? : null}
- {words.isPending ? (
-
- ) : words.isError ? (
- words.refetch()} />
- ) : (
-
- )}
- {preview.data ? : null}
+ setEditing(event.currentTarget.open)}
+ className="space-y-3"
+ >
+ 作品识别词 · 长期规则
+
+ 按来源季和发布组换算季集,保存后用于这部作品后续的资源与整理文件。
+
+ {words.isPending ? (
+
+ ) : words.isError ? (
+ words.refetch()} />
+ ) : (
+
+ )}
+
+ {preview.data ? : null}
({ record: null as PublicOrganizationRecord | null }));
+vi.mock("~/api/records.ts", async (original) => ({
+ ...(await original()),
+ 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("让智能体排查");
+});
diff --git a/apps/web/app/components/organize/organize-record-sheet.tsx b/apps/web/app/components/organize/organize-record-sheet.tsx
index 41c61d70..0bc481e7 100644
--- a/apps/web/app/components/organize/organize-record-sheet.tsx
+++ b/apps/web/app/components/organize/organize-record-sheet.tsx
@@ -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 ? (
) : null}
- {r.status === "failed" || r.status === "partial" ? (
-
- ) : null}
{r.media ? (
) : null}
+ {primary === "mapping" && !investigating ? (
+
+ ) : null}
{primary === "assign" && d && !investigating ? : null}
{primary === "options" && d && !investigating
? plainOptions(d).map((o, i) => (