feat(控制台): 去掉待处理中心,问题回到发生处

- 侧栏与命令面板没有「待处理」;/console/inbox?kind= 按问题类型跳到整理记录「需要处理」、下载面板、运行状态或订阅
- 整理记录加「需要处理」筛选,行内写原因与推荐动作(指定作品 / 重新整理 / 让智能体识别 / 替换·保留两者·跳过 / 忽略),
  详情页同样可处理;处理按钮从原待处理页抽成 components/decisions/decision-actions.tsx 复用
- 铃铛只放等你批准的智能体操作与系统健康;首页「要我处理」与运行状态「需要关注」放汇总计数,运行状态加智能体用量
- 设置 → 智能体:预算卡改为「用量与限制」(只剩单次工具调用与时长,附花费统计),自动调查卡加「整理识别不到作品时叫智能体识别」
- 集数待确认的原因显示在作品页集数映射(可确认建议规则 / 智能体建议)与订阅详情;未识别资源在媒体库「未识别」里直接选候选或指定
- 下载、站点、任务里指向待处理的链接改到对应页面

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 16:47:25 +08:00
1 parent 1c1a954703
commit fa76d7563c
36 files changed
+1209 -921

No files matched your search

@@ -0,0 +1,107 @@
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 { describe, expect, it } from "vitest";
import { attentionLine, primaryAction, RecordAttentionActions } from "./record-attention.tsx";
// 整理记录「需要处理」的行内处理(2026-10-06 去掉待处理中心):原因一句话 + 推荐动作,一键执行。
const D = "11111111-1111-4111-8111-111111111111";
const R = "22222222-2222-4222-8222-222222222222";
const render = (node: ReactNode) =>
renderToStaticMarkup(
createElement(QueryClientProvider, { client: new QueryClient() }, createElement(MemoryRouter, null, node)),
);
const decision = (over: Partial<NonNullable<RecordAttention["decision"]>> = {}) => ({
id: D,
kind: "identity_ambiguous" as const,
status: "open" as const,
title: "没识别出作品:Foo.2019.1080p",
summary: null,
options: [
{ key: "candidate_0", label: "Foo (2019)", input: { candidate: { mediaEntityId: R } } },
{ key: "select_entity", label: "选择作品(本地)", input: { requires: "mediaEntityId" } },
{ key: "select_tmdb", label: "选择作品(TMDB)", input: { requires: "tmdb" } },
{ key: "provisional", label: "作为临时作品入库", input: {} },
{ key: "dismiss", label: "忽略" },
],
agentRunId: null,
evidence: { downloadTaskId: R, torrentName: "Foo.2019.1080p" },
...over,
});
type Rec = Pick<PublicOrganizationRecord, "id" | "action" | "status" | "attention" | "reason">;
const rec = (attention: RecordAttention | null, over: Partial<Rec> = {}): Rec => ({
id: R,
action: "copy",
status: "failed",
reason: null,
attention,
...over,
});
const html = (r: Rec) => render(createElement(RecordAttentionActions, { r, onReorganize: () => {} }));
describe("整理记录行内处理", () => {
it("没识别出作品:主操作指定作品,可让智能体识别、重新整理、忽略", () => {
const r = rec({ topic: "identity", decision: decision(), agentIdentify: true });
expect(primaryAction(r)).toBe("assign");
expect(attentionLine(r)).toBe("没识别出作品。指定作品,或让智能体识别");
const out = html(r);
for (const t of ["指定作品", "让智能体识别", "重新整理", "忽略", "Foo (2019)"]) expect(out).toContain(t);
// 「选择作品(本地 / TMDB)」并进「指定作品」,不单列;临时作品不在行内
expect(out).not.toContain("选择作品(本地)");
expect(out).not.toContain("作为临时作品入库");
});
it("智能体识别中:显示进度与会话入口,不再给指定 / 识别按钮", () => {
const r = rec({
topic: "identity",
decision: decision({ status: "investigating", agentRunId: D }),
agentIdentify: true,
});
expect(attentionLine(r)).toContain("智能体正在识别作品");
const out = html(r);
expect(out).toContain("智能体识别中");
expect(out).toContain(`/console/agent/${D}`);
expect(out).not.toContain("让智能体识别");
expect(out).not.toContain("指定作品");
});
it("目标已存在:按事项选项给替换 / 保留两者 / 跳过", () => {
const r = rec({
topic: "conflict",
decision: decision({
kind: "path_conflict",
summary: "媒体库里已有同名文件",
options: [
{ key: "replace", label: "替换" },
{ key: "keep_both", label: "保留两者" },
{ key: "skip", label: "跳过" },
],
}),
agentIdentify: false,
});
expect(primaryAction(r)).toBe("options");
expect(attentionLine(r)).toBe("媒体库里已有同名文件");
const out = html(r);
for (const t of ["替换", "保留两者", "跳过", "忽略"]) expect(out).toContain(t);
expect(out).not.toContain("让智能体识别");
});
it("其它失败:重新整理为主操作", () => {
const r = rec({ topic: "other", decision: null, agentIdentify: false });
expect(primaryAction(r)).toBe("reorganize");
const out = html(r);
expect(out).toContain("重新整理");
expect(out).toContain("忽略");
});
it("不需要处理的记录不渲染", () => {
expect(html(rec(null, { status: "succeeded" }))).toBe("");
expect(attentionLine(rec(null))).toBeNull();
});
});
@@ -0,0 +1,130 @@
import type { PublicOrganizationRecord } from "@umt/contracts";
import { BellOff, Bot, ChevronRight, LoaderCircle, Wand2 } from "lucide-react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useAgentIdentifyRecord, useDismissRecordAttention } from "~/api/records.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { consolePath } from "~/components/console/nav.ts";
import {
AssignWorkButton,
canAssignWork,
OptionButton,
plainOptions,
} from "~/components/decisions/decision-actions.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { humanizeReason } from "~/lib/reasons.ts";
import { canReorganize } from "./record-parts.ts";
// 整理记录「需要处理」的行内处理(2026-10-06 去掉待处理中心后,整理侧的问题在这里就地处理):
// 没识别出作品 → 指定作品(主操作)/ 让智能体识别 / 忽略;识别中显示进度与会话入口;
// 目标已存在 / 路径冲突 → 事项给的选项(替换 / 保留两者 / 跳过);
// 其余失败 / 部分完成 → 重新整理 / 忽略。执行都走已有端点(/decisions/{id}/resolve、整理向导、忽略)。
type Rec = Pick<PublicOrganizationRecord, "id" | "action" | "status" | "attention" | "reason">;
/** 一句话说清这条记录卡在哪(事项摘要优先,其次记录原因) */
export function attentionLine(r: Rec): string | null {
const a = r.attention;
if (!a) return null;
if (a.decision?.status === "investigating") 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 {
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";
return canReorganize(r) ? "reorganize" : null;
}
export function RecordAttentionActions({
r,
onReorganize,
compact = false,
}: {
r: Rec;
onReorganize: () => void;
compact?: boolean;
}) {
const identify = useAgentIdentifyRecord();
const dismiss = useDismissRecordAttention();
const a = r.attention;
if (!a) return null;
const d = a.decision;
const investigating = d?.status === "investigating";
const primary = primaryAction(r);
return (
// 行点击会进详情;按钮区不冒泡
// biome-ignore lint/a11y/noStaticElementInteractions: 只拦截冒泡,不是交互元素
// biome-ignore lint/a11y/useKeyWithClickEvents: 同上
<div className="flex flex-wrap items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
{investigating && d?.agentRunId ? (
<Button asChild variant="ghost" size="sm">
<Link to={consolePath("agent", d.agentRunId)}>
<LoaderCircle className="animate-spin" />
智能体识别中
<ChevronRight />
</Link>
</Button>
) : null}
{primary === "assign" && d && !investigating ? <AssignWorkButton d={d} /> : null}
{primary === "options" && d
? plainOptions(d).map((o, i) => (
<OptionButton key={o.key} d={d} option={o} variant={i === 0 ? "default" : "outline"} />
))
: null}
{a.topic === "identity" && d && !investigating
? plainOptions(d)
.filter((o) => !compact && o.key !== "provisional")
.slice(0, 2)
.map((o) => <OptionButton key={o.key} d={d} option={o} />)
: null}
{canReorganize(r) && !investigating && (!compact || primary === "reorganize") ? (
<Button size="sm" variant={primary === "reorganize" ? "default" : "outline"} onClick={onReorganize}>
<Wand2 />
重新整理
</Button>
) : null}
{a.agentIdentify && !investigating ? (
<Button
size="sm"
variant="outline"
disabled={identify.isPending}
onClick={() =>
identify.mutateAsync(r.id).then(
(x) => toast.success(x.queued ? "已让智能体识别,认出来会自动重新整理" : "智能体已经在识别了"),
(err) => toast.error(errorMessage(err, "操作失败")),
)
}
>
<Bot />
让智能体识别
</Button>
) : null}
<ConfirmDialog
trigger={
<Button variant="ghost" size="sm">
<BellOff />
忽略
</Button>
}
title="忽略这条记录的问题?"
description="不再算需要处理,文件和记录都不动;之后可以随时重新整理。"
confirmLabel="忽略"
onConfirm={() =>
dismiss.mutateAsync(r.id).then(
() => toast.success("已忽略"),
(err) => {
toast.error(errorMessage(err, "操作失败"));
throw err;
},
)
}
/>
</div>
);
}