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 ( +

+ ); +} + /** 展示部分(纯渲染,单测直接渲染) */ 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) => (