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

+37
View File
@@ -0,0 +1,37 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AgentSpend, AttentionSummary } from "@umt/contracts";
import { useRef } from "react";
import { useSse } from "~/lib/sse.ts";
import { apiParsed } from "./parse.ts";
// 需要关注的汇总(2026-10-06 去掉待处理中心后):铃铛只放等你批准的智能体操作与系统健康,
// 首页 / 运行状态放汇总计数(整理需要处理的条数等),点进去到对应页面。
export const attentionKeys = { all: ["attention"] as const, spend: ["attention", "agent-spend"] as const };
/** 事项、整理记录变化时(节流)刷新;另有 60 秒兜底轮询 */
export function useAttention() {
const qc = useQueryClient();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useSse(["decision.created", "decision.updated", "organization.changed"], () => {
if (timer.current) return;
timer.current = setTimeout(() => {
timer.current = null;
void qc.invalidateQueries({ queryKey: attentionKeys.all });
}, 800);
});
return useQuery({
queryKey: attentionKeys.all,
queryFn: ({ signal }) => apiParsed(AttentionSummary, "/api/v1/attention", { signal }),
refetchInterval: 60_000,
});
}
/** 智能体用量:今天与最近 7 天按会话类型的次数、token 与费用(不设上限,只展示) */
export function useAgentSpend() {
return useQuery({
queryKey: attentionKeys.spend,
queryFn: ({ signal }) => apiParsed(AgentSpend, "/api/v1/agent/usage", { signal }),
refetchInterval: 5 * 60_000,
});
}
+30
View File
@@ -4,6 +4,7 @@ import {
cursorPage, cursorPage,
LibraryItem, LibraryItem,
LibraryRoot, LibraryRoot,
OrganizationAgentIdentifyResult,
type OrganizationDeleteRequest, type OrganizationDeleteRequest,
OrganizationDeleteResult, OrganizationDeleteResult,
OrganizationStats, OrganizationStats,
@@ -31,6 +32,8 @@ export type RecordFilters = {
/** ISO 时间:只看之后创建的 */ /** ISO 时间:只看之后创建的 */
since?: string; since?: string;
organizeOnly?: boolean; organizeOnly?: boolean;
/** 只看需要处理的(失败 / 部分完成没被取代、没被忽略,或有未关闭的事项) */
attention?: boolean;
}; };
export const recordKeys = { export const recordKeys = {
@@ -51,6 +54,33 @@ export function useRecordStats(f: Omit<RecordFilters, "status">) {
}); });
} }
/** 需要处理的记录:忽略(不再算需要处理,关联事项一并关闭) */
export function useDismissRecordAttention() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiMutate(`/api/v1/organization-records/${id}/dismiss-attention`, { body: {} }).then((d) =>
PublicOrganizationRecord.parse(d),
),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: recordKeys.all });
void qc.invalidateQueries({ queryKey: ["attention"] });
},
});
}
/** 没识别出作品的记录:让智能体识别(认出来自动重新整理) */
export function useAgentIdentifyRecord() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiMutate(`/api/v1/organization-records/${id}/agent-identify`, { body: {} }).then((d) =>
OrganizationAgentIdentifyResult.parse(d),
),
onSuccess: () => void qc.invalidateQueries({ queryKey: recordKeys.all }),
});
}
/** HIS-1:删除整理记录(dryRun 只核对会删什么) */ /** HIS-1:删除整理记录(dryRun 只核对会删什么) */
export function useDeleteRecords() { export function useDeleteRecords() {
const qc = useQueryClient(); const qc = useQueryClient();
+1 -5
View File
@@ -30,16 +30,12 @@ const RULES: Rule[] = [
}, },
{ {
match: /^\/console\/organize/, match: /^\/console\/organize/,
goals: ["这次整理为什么有文件失败", "核对整理后的路径是否符合命名规则", "把失败的文件重新整理一遍"], goals: ["识别需要处理的整理记录里的作品", "这次整理为什么有文件失败", "把失败的文件重新整理一遍"],
}, },
{ {
match: /^\/console\/files/, match: /^\/console\/files/,
goals: ["把下载目录里还没整理的文件整理入库", "找出重复的文件", "看看哪些文件还没被识别"], goals: ["把下载目录里还没整理的文件整理入库", "找出重复的文件", "看看哪些文件还没被识别"],
}, },
{
match: /^\/console\/inbox/,
goals: ["逐条说明待处理事项的原因和建议", "先处理最紧急的一项待处理事项", "哪些待处理事项可以直接按建议批量处理"],
},
{ {
match: /^\/console\/identity/, match: /^\/console\/identity/,
goals: ["识别未识别的文件", "找出外部身份可能错误的作品", "补全缺少元数据的作品"], goals: ["识别未识别的文件", "找出外部身份可能错误的作品", "补全缺少元数据的作品"],
+94 -87
View File
@@ -1,71 +1,89 @@
import { useQueryClient } from "@tanstack/react-query"; import type { Decision } from "@umt/contracts";
import type { Decision, DecisionKind } from "@umt/contracts";
import { ArrowRight, Bell, CheckCheck } from "lucide-react"; import { ArrowRight, Bell, CheckCheck } from "lucide-react";
import { useMemo, useRef, useState } from "react"; import { useState } from "react";
import { Link, useNavigate } from "react-router"; import { Link, useNavigate } from "react-router";
import { decisionKeys, useDecisionSummary, useDecisions } from "~/api/decisions.ts"; import { useAttention } from "~/api/attention.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { SEVERITY } from "~/components/library/labels.ts"; import { SEVERITY } from "~/components/library/labels.ts";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { decisionKindLabel, formatRelative } from "~/lib/labels.ts"; import { formatRelative } from "~/lib/labels.ts";
import { useSse } from "~/lib/sse.ts";
import { cn } from "~/lib/utils.ts"; import { cn } from "~/lib/utils.ts";
// 顶栏收件箱(IA-3):待处理不进侧栏,铃铛 + 未决角标;下拉按问题类型列最近事项,点开进待处理页。 // 顶栏铃铛(2026-10-06 起只放两类):需要你批准的智能体操作(会话里的审批 / 方案选择、计划审批、原站页面等你接手)
// 角标只算需要你处理的事项(警告及以上):"仅信息"类(巡检报告、旧版本待处置等)不计入,在待处理页可展开查看。 // 与系统健康问题(站点不可达、下载器离线、下载目录读不到容量、磁盘空间不足、媒体库目录离线,恢复后自动消失)。
// 身份类事项已按来源 + 标题合并,一条事项可能代表多条资源(标题里写明 N 条)。 // 整理问题在整理记录里处理,集数待确认在订阅详情 / 作品页集数映射里看,不进铃铛。
const RECENT = 8; const RECENT = 6;
const DOT = { info: "bg-muted-foreground/60", warning: "bg-warning", critical: "bg-destructive" } as const; const DOT = { info: "bg-muted-foreground/60", warning: "bg-warning", critical: "bg-destructive" } as const;
/** 未决数与最近事项;SSE decision.* 到达时刷新 */ /** 等你批准的事项点开去哪:智能体会话、下载、下载任务;其余去智能体页 */
export function useLiveDecisions() { export function approvalHref(d: Pick<Decision, "subjectType" | "subjectId" | "agentRunId">): string {
const qc = useQueryClient(); if (d.subjectType === "agent_run" && d.subjectId) return consolePath("agent", d.subjectId);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null); if (d.agentRunId) return consolePath("agent", d.agentRunId);
useSse(["decision.created", "decision.updated"], () => { if (d.subjectType === "acquisition_plan" && d.subjectId)
if (timer.current) return; return `${consolePath("downloads")}?plan=${encodeURIComponent(d.subjectId)}`;
timer.current = setTimeout(() => { if (d.subjectType === "download_task" && d.subjectId) return consolePath("downloads", d.subjectId);
timer.current = null; return consolePath("agent");
void qc.invalidateQueries({ queryKey: decisionKeys.all });
}, 800);
});
return useDecisionSummary(undefined, "warning");
} }
export const inboxPath = (kind?: string) => (kind ? `/console/inbox?kind=${kind}` : "/console/inbox"); export const HEALTH_HREF = `${consolePath("status")}#attention`;
function Group({
title,
total,
items,
href,
onGo,
}: {
title: string;
total: number;
items: Decision[];
href: (d: Decision) => string;
onGo: () => void;
}) {
if (!total) return null;
return (
<section className="pt-1">
<p className="flex items-center justify-between gap-2 px-2 py-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
<span>{title}</span>
<span className="tracking-normal tabular-nums">{total}</span>
</p>
<ul>
{items.slice(0, RECENT).map((d) => (
<li key={d.id}>
<Link
to={href(d)}
onClick={onGo}
className="block rounded-lg px-2 py-2 outline-none transition-colors hover:bg-foreground/[0.06] focus-visible:bg-foreground/[0.06] focus-visible:ring-2 focus-visible:ring-ring/50"
>
<span className="line-clamp-2 text-body break-words text-foreground">{d.title}</span>
<span className="mt-0.5 flex items-center gap-1.5 text-meta text-muted-foreground">
<span aria-hidden className={cn("h-1.5 w-1.5 shrink-0 rounded-full", DOT[d.severity])} />
{SEVERITY[d.severity].label} · {formatRelative(d.createdAt)}
</span>
</Link>
</li>
))}
</ul>
</section>
);
}
export function InboxBell() { export function InboxBell() {
const summary = useLiveDecisions(); const q = useAttention();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const list = useDecisions({ minSeverity: "warning" }, open);
const navigate = useNavigate(); const navigate = useNavigate();
const total = summary.data?.total ?? 0; const approvals = q.data?.approvals ?? { total: 0, items: [] };
const health = q.data?.health ?? { total: 0, items: [] };
const groups = useMemo(() => { const total = approvals.total + health.total;
const items = (list.data?.pages[0]?.items ?? []).slice(0, RECENT); const label = total ? `需要你关注 ${total} 项` : "通知";
const m = new Map<DecisionKind, Decision[]>();
for (const d of items) m.set(d.kind, [...(m.get(d.kind) ?? []), d]);
const counts = new Map<DecisionKind, number>();
for (const g of summary.data?.groups ?? []) counts.set(g.kind, (counts.get(g.kind) ?? 0) + g.count);
return [...m.entries()].map(([kind, ds]) => ({ kind, items: ds, count: counts.get(kind) ?? ds.length }));
}, [list.data, summary.data]);
const go = (to: string) => {
setOpen(false);
navigate(to);
};
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button variant="outline" size="icon-sm" className="relative" aria-label={label} title={label}>
variant="outline"
size="icon-sm"
className="relative"
aria-label={total ? `待处理 ${total} 项` : "待处理"}
title={total ? `待处理 ${total} 项` : "待处理"}
>
<Bell className="h-4 w-4" /> <Bell className="h-4 w-4" />
{total ? ( {total ? (
<span className="absolute -top-0.5 -right-0.5 min-w-4 rounded-full bg-primary px-1 text-center text-micro leading-4 font-semibold text-primary-foreground tabular-nums"> <span className="absolute -top-0.5 -right-0.5 min-w-4 rounded-full bg-primary px-1 text-center text-micro leading-4 font-semibold text-primary-foreground tabular-nums">
@@ -78,56 +96,45 @@ export function InboxBell() {
align="end" align="end"
sideOffset={8} sideOffset={8}
collisionPadding={16} collisionPadding={16}
aria-label="待处理事项" aria-label="通知"
className="pg-float-solid flex max-h-[var(--radix-popover-content-available-height)] w-[min(22rem,calc(100vw-2rem))] flex-col overflow-hidden p-2" className="pg-float-solid flex max-h-[var(--radix-popover-content-available-height)] w-[min(22rem,calc(100vw-2rem))] flex-col overflow-hidden p-2"
> >
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-2 pt-1 pb-2"> <div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-2 pt-1 pb-2">
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">通知</p>
待处理 <Button
{total ? <span className="ml-1.5 font-normal text-muted-foreground tabular-nums">{total}</span> : null} variant="ghost"
</p> size="xs"
<Button variant="ghost" size="xs" onClick={() => go(inboxPath())}> onClick={() => {
查看全部 setOpen(false);
navigate(consolePath("status"));
}}
>
运行状态
<ArrowRight /> <ArrowRight />
</Button> </Button>
</div> </div>
{list.isPending ? ( {q.isPending ? (
<LoadingState className="px-4 py-6" /> <LoadingState className="px-4 py-6" />
) : list.isError ? ( ) : q.isError ? (
<ErrorState plain error="稍后再试" className="py-8" /> <ErrorState plain error="稍后再试" className="py-8" />
) : groups.length === 0 ? ( ) : total === 0 ? (
<EmptyState plain icon={CheckCheck} title="没有需要处理的事项" className="py-8" /> <EmptyState plain icon={CheckCheck} title="没有等你批准的操作,系统运行正常" className="py-8" />
) : ( ) : (
<div className="min-h-0 overflow-y-auto overscroll-contain pt-1"> <div className="min-h-0 overflow-y-auto overscroll-contain pt-1">
{groups.map((g) => ( <Group
<section key={g.kind} className="pt-1"> title="等你批准"
<button total={approvals.total}
type="button" items={approvals.items}
onClick={() => go(inboxPath(g.kind))} href={approvalHref}
className="flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50" onGo={() => setOpen(false)}
> />
<span>{decisionKindLabel(g.kind)}</span> <Group
<span className="tracking-normal tabular-nums">{g.count}</span> title="系统健康"
</button> total={health.total}
<ul> items={health.items}
{g.items.map((d) => ( href={() => HEALTH_HREF}
<li key={d.id}> onGo={() => setOpen(false)}
<Link />
to={inboxPath(d.kind)}
onClick={() => setOpen(false)}
className="block rounded-lg px-2 py-2 outline-none transition-colors hover:bg-foreground/[0.06] focus-visible:bg-foreground/[0.06] focus-visible:ring-2 focus-visible:ring-ring/50"
>
<span className="line-clamp-2 text-body break-words text-foreground">{d.title}</span>
<span className="mt-0.5 flex items-center gap-1.5 text-meta text-muted-foreground">
<span aria-hidden className={cn("h-1.5 w-1.5 shrink-0 rounded-full", DOT[d.severity])} />
{SEVERITY[d.severity].label} · {formatRelative(d.createdAt)}
</span>
</Link>
</li>
))}
</ul>
</section>
))}
</div> </div>
)} )}
</PopoverContent> </PopoverContent>
+10 -2
View File
@@ -39,10 +39,18 @@ describe("控制台导航", () => {
expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true); expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true);
}); });
it("取消的入口不在侧栏:待处理走顶栏收件箱,引擎面板 / 身份并入别处", () => { it("取消的入口不在侧栏:没有待处理(问题回到发生处),引擎面板 / 身份并入别处", () => {
const paths = NAV_ITEMS.map((i) => i.to); const paths = NAV_ITEMS.map((i) => i.to);
for (const p of ["/console/inbox", "/console/engine", "/console/identity"]) expect(paths).not.toContain(p); for (const p of ["/console/inbox", "/console/engine", "/console/identity"]) expect(paths).not.toContain(p);
expect(pageTitle("/console/inbox")).toBe("待处理"); expect(NAV_ITEMS.map((i) => i.label)).not.toContain("待处理");
expect(pageTitle("/console/inbox")).toBeUndefined();
});
it("旧待处理链接按问题类型落到对应页面", () => {
expect(legacyRedirect("/console/inbox", "?kind=path_conflict")).toBe("/console/organize?tab=records&attention=1");
expect(legacyRedirect("/console/inbox", "?kind=download_failed")).toBe("/console/downloads");
expect(legacyRedirect("/console/inbox", "?kind=space_insufficient")).toBe("/console/status#attention");
expect(legacyRedirect("/console/inbox", "?kind=unknown_kind")).toBe("/console/organize?tab=records&attention=1");
}); });
it("每个导航项都有路由", () => { it("每个导航项都有路由", () => {
+1 -2
View File
@@ -42,7 +42,7 @@ export const ACQUIRE_GROUP_TITLE = "获取";
/** /**
* 侧栏 4 组 14 项(IA-2,docs/design/console-ia.md):概览 → 获取 → 整理 → 系统。 * 侧栏 4 组 14 项(IA-2,docs/design/console-ia.md):概览 → 获取 → 整理 → 系统。
* 任务归系统;RSS 属于站点(站点详情的 RSS Tab),不再单独占一项。 * 任务归系统;RSS 属于站点(站点详情的 RSS Tab),不再单独占一项。
* 待处理不进侧栏,由顶栏收件箱打开;移动端抽屉用同一份表。 * 没有「待处理」(2026-10-06 取消):问题在发生处处理,铃铛只放等你批准的智能体操作与系统健康;移动端抽屉用同一份表。
*/ */
export const NAV_GROUPS: NavGroup[] = [ export const NAV_GROUPS: NavGroup[] = [
{ {
@@ -139,7 +139,6 @@ export const NAV_GROUPS: NavGroup[] = [
/** 不在侧栏但有页面的路由:顶栏标题用 */ /** 不在侧栏但有页面的路由:顶栏标题用 */
const EXTRA_TITLES: Record<string, string> = { const EXTRA_TITLES: Record<string, string> = {
"/console/inbox": "待处理",
"/console/preferences": "偏好标签", "/console/preferences": "偏好标签",
"/console/search": "搜索", "/console/search": "搜索",
}; };
@@ -0,0 +1,87 @@
import type { AttentionSummary, Decision } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import { approvalHref, HEALTH_HREF } from "../console/inbox-bell.tsx";
import { attentionRows } from "./attention-summary.tsx";
// 首页 / 运行状态的「需要关注」汇总计数与铃铛跳转(2026-10-06 去掉待处理中心后)。
const id = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
const dec = (n: number, over: Partial<Decision>): Decision =>
({
id: id(n),
kind: "other",
status: "open",
severity: "warning",
title: "t",
summary: null,
subjectType: null,
subjectId: null,
dedupeKey: `k${n}`,
evidence: {},
options: [],
resolution: null,
taskId: null,
agentRunId: null,
attempts: 0,
nextReviewAt: null,
resolvedBy: null,
resolvedAt: null,
createdAt: "2026-10-06T00:00:00.000Z",
updatedAt: "2026-10-06T00:00:00.000Z",
...over,
}) as Decision;
const empty: AttentionSummary = { organize: 0, approvals: { total: 0, items: [] }, health: { total: 0, items: [] } };
describe("需要关注汇总", () => {
it("只列非零项", () => {
expect(attentionRows(empty)).toEqual([]);
expect(attentionRows(undefined)).toEqual([]);
});
it("整理需要处理 / 等你批准 / 系统健康按问题归类,点进去到对应页面", () => {
const rows = attentionRows({
organize: 3,
approvals: {
total: 1,
items: [dec(1, { kind: "approval_required", subjectType: "agent_run", subjectId: id(9) })],
},
health: {
total: 3,
items: [
dec(2, { kind: "source_unreachable" }),
dec(3, { kind: "downloader_offline" }),
dec(4, { kind: "other", dedupeKey: "library_root_offline:x" }),
],
},
});
expect(rows.map((r) => [r.text, r.to])).toEqual([
["3 条整理需要处理", "/console/organize?tab=records&attention=1"],
["1 个操作等你批准", `/console/agent/${id(9)}`],
["1 个站点不可达", HEALTH_HREF],
["1 个下载器离线", HEALTH_HREF],
["1 个媒体库目录离线", HEALTH_HREF],
]);
});
it("健康问题超过列出的条数时补一行", () => {
const rows = attentionRows({ ...empty, health: { total: 5, items: [dec(2, { kind: "space_insufficient" })] } });
expect(rows.map((r) => r.text)).toEqual(["1 个下载盘空间不足", "4 个系统问题"]);
});
});
describe("铃铛跳转", () => {
it("智能体会话 / 下载计划 / 下载任务", () => {
expect(approvalHref({ subjectType: "agent_run", subjectId: id(1), agentRunId: null })).toBe(
`/console/agent/${id(1)}`,
);
expect(approvalHref({ subjectType: "acquisition_plan", subjectId: id(2), agentRunId: null })).toBe(
`/console/downloads?plan=${id(2)}`,
);
expect(approvalHref({ subjectType: "download_task", subjectId: id(3), agentRunId: null })).toBe(
`/console/downloads/${id(3)}`,
);
expect(approvalHref({ subjectType: null, subjectId: null, agentRunId: null })).toBe("/console/agent");
expect(HEALTH_HREF).toBe("/console/status#attention");
});
});
@@ -0,0 +1,78 @@
import type { AttentionSummary, Decision } from "@umt/contracts";
import { ChevronRight } from "lucide-react";
import { Link } from "react-router";
import { approvalHref, HEALTH_HREF } from "~/components/console/inbox-bell.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { ORGANIZE_ATTENTION_PATH } from "~/routes/console/redirects.ts";
// 首页与运行状态共用的「需要关注」汇总计数(2026-10-06 去掉待处理中心后):
// 「3 条整理需要处理」「1 个站点不可达」…,点进去到对应页面;只列非零项。
/** 系统健康事项按问题归类成一句(量词 + 说法) */
function healthKind(d: Pick<Decision, "kind" | "dedupeKey">): { key: string; unit: string } {
if (d.dedupeKey.startsWith("library_root_offline:")) return { key: "library_root", unit: "个媒体库目录离线" };
switch (d.kind) {
case "source_unreachable":
return { key: d.kind, unit: "个站点不可达" };
case "downloader_offline":
return { key: d.kind, unit: "个下载器离线" };
case "cache_volume_unknown":
return { key: d.kind, unit: "个下载目录读不到容量" };
case "space_insufficient":
return { key: d.kind, unit: "个下载盘空间不足" };
default:
return { key: "other", unit: "个系统问题" };
}
}
export interface AttentionRow {
key: string;
text: string;
to: string;
}
/** 汇总 → 一行一类(非零才列) */
export function attentionRows(s: AttentionSummary | undefined): AttentionRow[] {
if (!s) return [];
const rows: AttentionRow[] = [];
if (s.organize) rows.push({ key: "organize", text: `${s.organize} 条整理需要处理`, to: ORGANIZE_ATTENTION_PATH });
if (s.approvals.total) {
const first = s.approvals.items[0];
rows.push({
key: "approvals",
text: `${s.approvals.total} 个操作等你批准`,
to: s.approvals.total === 1 && first ? approvalHref(first) : consolePath("agent"),
});
}
const counts = new Map<string, { unit: string; n: number }>();
for (const d of s.health.items) {
const k = healthKind(d);
const cur = counts.get(k.key);
counts.set(k.key, { unit: k.unit, n: (cur?.n ?? 0) + 1 });
}
const listed = [...counts.values()].reduce((a, x) => a + x.n, 0);
if (s.health.total > listed) {
const cur = counts.get("other");
counts.set("other", { unit: "个系统问题", n: (cur?.n ?? 0) + s.health.total - listed });
}
for (const [key, v] of counts) rows.push({ key, text: `${v.n} ${v.unit}`, to: HEALTH_HREF });
return rows;
}
export function AttentionList({ rows }: { rows: AttentionRow[] }) {
return (
<ul className="divide-y divide-border/60">
{rows.map((r) => (
<li key={r.key}>
<Link
to={r.to}
className="flex min-w-0 items-center justify-between gap-3 py-1.5 text-sm text-foreground hover:underline"
>
<span className="truncate">{r.text}</span>
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</Link>
</li>
))}
</ul>
);
}
@@ -0,0 +1,199 @@
import type { Decision, DecisionOption } from "@umt/contracts";
import { Search } from "lucide-react";
import { useId, useState } from "react";
import { toast } from "sonner";
import { useResolveDecision } from "~/api/decisions.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx";
import { type AssignedWork, WorkAssignDialog } from "~/components/library/work-assign-dialog.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
// 事项(decision)的处理按钮:待处理中心取消后(2026-10-06),问题在发生处就地处理(整理记录行内等),
// 选项与执行仍走 POST /decisions/{id}/resolve(后端注册的执行钩子:指定作品后重新整理、替换 / 保留两者 / 跳过…)。
/** 处理按钮只用到事项的这几个字段(整理记录里带的是精简版) */
export type DecisionLike = Pick<Decision, "id" | "title" | "options" | "evidence">;
const str = (v: unknown) => (typeof v === "string" && v.trim() ? v : null);
/** 身份类手动指定选项需要的输入(后端在 option.input.requires 标注) */
type Requires = "tmdb" | "mediaEntityId" | null;
function requiresOf(option: DecisionOption): Requires {
const r = option.input?.requires;
return r === "tmdb" || r === "mediaEntityId" ? r : null;
}
/** 「指定作品」两个选项(本地作品 / TMDB)已并进 AssignWorkButton,其余选项各一个按钮 */
export function plainOptions(d: DecisionLike): DecisionOption[] {
return d.options.filter((o) => o.key !== "dismiss" && requiresOf(o) === null);
}
/** 事项带不带「指定作品」(本地作品或 TMDB) */
export function canAssignWork(d: DecisionLike): boolean {
return d.options.some((o) => requiresOf(o) !== null);
}
export function OptionButton({
d,
option,
variant = "outline",
}: {
d: DecisionLike;
option: DecisionOption;
variant?: "outline" | "default";
}) {
const resolve = useResolveDecision();
const uid = useId();
const [note, setNote] = useState("");
const requires = requiresOf(option);
const [tmdbType, setTmdbType] = useState<"movie" | "tv">("movie");
const [tmdbId, setTmdbId] = useState("");
const [entity, setEntity] = useState<PickedEntity | null>(null);
const tmdbOk = /^\d{1,10}$/.test(tmdbId.trim());
const identity =
requires === "tmdb"
? tmdbOk
? { tmdb: { type: tmdbType, id: tmdbId.trim() } }
: null
: requires === "mediaEntityId"
? entity
? { mediaEntityId: entity.id }
: null
: undefined;
return (
<ConfirmDialog
trigger={
<Button variant={variant} size="sm">
{option.label}
</Button>
}
title={option.label}
description={option.description ? `${option.description}。` : undefined}
confirmLabel={option.label}
confirmDisabled={identity === null}
onConfirm={() =>
resolve
.mutateAsync({
id: d.id,
optionKey: option.key,
note: note.trim() || undefined,
...(identity ? { identity } : {}),
})
.then(
() => toast.success(`已处理:${option.label}`),
(err) => {
toast.error(errorMessage(err, "处理失败"));
throw err;
},
)
}
>
{requires === "tmdb" ? (
<div className="grid gap-x-2 sm:grid-cols-[8rem_minmax(0,1fr)]">
<FormField label="类型" htmlFor={`${uid}-type`}>
<Select value={tmdbType} onValueChange={(v) => setTmdbType(v as "movie" | "tv")}>
<SelectTrigger id={`${uid}-type`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="movie">电影</SelectItem>
<SelectItem value="tv">剧集</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField label="TMDB ID" htmlFor={`${uid}-tmdb`} required hint="纯数字,如 872585">
<Input
id={`${uid}-tmdb`}
inputMode="numeric"
value={tmdbId}
onChange={(e) => setTmdbId(e.target.value)}
aria-invalid={tmdbId.trim() !== "" && !tmdbOk}
/>
</FormField>
</div>
) : requires === "mediaEntityId" ? (
<FormField label="本地作品" required>
<EntityPicker value={entity} onChange={setEntity} />
</FormField>
) : null}
<FormField label="备注" htmlFor={`${uid}-note`} hint="可选,记在处理结果里">
<Textarea id={`${uid}-note`} value={note} onChange={(e) => setNote(e.target.value)} rows={2} />
</FormField>
</ConfirmDialog>
);
}
/** 事项里能拿来搜作品的名字:代表资源标题 / 种子名 / 下载名 / 标题冒号后半段 */
export function subjectName(d: DecisionLike): string | null {
const e = d.evidence;
const first = Array.isArray(e.releases) ? (e.releases[0] as { title?: unknown } | undefined) : undefined;
return (
str(first?.title) ??
str(e.torrentName) ??
str(e.title) ??
str(e.name) ??
(d.title.includes(":") ? d.title.split(":").slice(1).join(":") : null)
);
}
/** 搜作品用的关键词:去掉扩展名、分隔符、年份之后的规格串 */
export function queryOf(name: string | null): string {
if (!name) return "";
const base = name.replace(/\.(mkv|mp4|ts|m2ts|iso|avi)$/i, "").replace(/[._]+/g, " ");
const m = base.match(/^(.*?)(?:\b(19|20)\d{2}\b|\bS\d{1,2}E?\d{0,3}\b|\b(2160|1080|720)p\b)/i);
return (m?.[1]?.trim() || base)
.replace(/[[\]()]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 60);
}
/** 「没识别出作品」的主操作:搜作品并指定(本地作品 / TMDB),后端随即按该作品重新整理 */
export function AssignWorkButton({ d, variant = "default" }: { d: DecisionLike; variant?: "default" | "outline" }) {
const resolve = useResolveDecision();
const [open, setOpen] = useState(false);
const local = d.options.find((o) => o.input?.requires === "mediaEntityId");
const tmdb = d.options.find((o) => o.input?.requires === "tmdb");
if (!local && !tmdb) return null;
const name = subjectName(d);
const assign = (w: AssignedWork) => {
const option = "mediaEntityId" in w ? local : tmdb;
if (!option) {
toast.error("mediaEntityId" in w ? "这条记录只能按 TMDB 指定" : "这条记录只能选媒体库里的作品");
return Promise.reject(new Error("unsupported"));
}
return resolve
.mutateAsync({
id: d.id,
optionKey: option.key,
identity: "mediaEntityId" in w ? { mediaEntityId: w.mediaEntityId } : { tmdb: w.tmdb },
})
.then(
() => toast.success(`已指定为「${w.title}」,正在重新整理`),
(err) => {
toast.error(errorMessage(err, "指定失败"));
throw err;
},
);
};
return (
<>
<Button size="sm" variant={variant} onClick={() => setOpen(true)}>
<Search />
指定作品
</Button>
<WorkAssignDialog
open={open}
onOpenChange={setOpen}
subject={name}
initialQuery={queryOf(name)}
onAssign={assign}
/>
</>
);
}
@@ -25,7 +25,7 @@ describe("下载面板展示换算(DL-2)", () => {
expect(formatEta(3 * 86400 + 4 * 3600)).toBe("3 天 4 小时"); expect(formatEta(3 * 86400 + 4 * 3600)).toBe("3 天 4 小时");
}); });
it("最近一次整理结果:已入库 / 整理中 / 待整理 / 入库失败 / 部分入库;有待处理事项时链到待处理", () => { it("最近一次整理结果:已入库 / 整理中 / 待整理 / 入库失败 / 部分入库;有问题的链到整理记录就地处理", () => {
expect(organizeState({ status: "completed", organize: null })).toMatchObject({ label: "待整理", to: null }); expect(organizeState({ status: "completed", organize: null })).toMatchObject({ label: "待整理", to: null });
expect(organizeState({ status: "importing", organize: null })?.label).toBe("整理中"); expect(organizeState({ status: "importing", organize: null })?.label).toBe("整理中");
expect(organizeState({ status: "imported", organize: null })?.label).toBe("已入库"); expect(organizeState({ status: "imported", organize: null })?.label).toBe("已入库");
@@ -48,7 +48,7 @@ describe("下载面板展示换算(DL-2)", () => {
tone: "destructive", tone: "destructive",
hint: "没识别出作品。指定作品后重新整理", hint: "没识别出作品。指定作品后重新整理",
}); });
expect(failed?.to).toBe("/console/inbox?kind=identity_unresolved"); expect(failed?.to).toBe(`/console/organize/${RID}`);
expect(organizeState({ status: "completed", organize: rec("failed", { reason: "磁盘已满" }) })?.hint).toBe( expect(organizeState({ status: "completed", organize: rec("failed", { reason: "磁盘已满" }) })?.hint).toBe(
"磁盘已满", "磁盘已满",
); );
@@ -58,7 +58,7 @@ describe("下载面板展示换算(DL-2)", () => {
); );
expect( expect(
organizeState({ status: "completed", organize: rec("partial", { reason: "blocked:target_exists" }) })?.hint, organizeState({ status: "completed", organize: rec("partial", { reason: "blocked:target_exists" }) })?.hint,
).toBe("媒体库里已有同名文件。在待处理里选择替换或跳过"); ).toBe("媒体库里已有同名文件。在整理记录里选择替换、保留两者或跳过");
// 不认识的码不显示 // 不认识的码不显示
expect( expect(
organizeState({ status: "completed", organize: rec("failed", { reason: "some_new_code" }) })?.hint, organizeState({ status: "completed", organize: rec("failed", { reason: "some_new_code" }) })?.hint,
@@ -28,7 +28,7 @@ export function formatEta(sec: number | null | undefined): string {
export type OrganizeState = { export type OrganizeState = {
label: string; label: string;
tone: "success" | "warning" | "destructive" | "neutral"; tone: "success" | "warning" | "destructive" | "neutral";
/** 点状态去哪:有未关闭的待处理事项去待处理,否则去整理记录;都没有为 null */ /** 点状态去哪:整理记录详情(有问题的在那里就地处理);没整理过为 null */
to: string | null; to: string | null;
/** 失败原因(人话);原因是内部码时不直接给人看 */ /** 失败原因(人话);原因是内部码时不直接给人看 */
hint: string | null; hint: string | null;
@@ -48,11 +48,8 @@ export function organizeState(
): OrganizeState | null { ): OrganizeState | null {
const rec = t.organize; const rec = t.organize;
if (t.managed === false && !rec && t.status !== "importing" && t.status !== "imported") return null; if (t.managed === false && !rec && t.status !== "importing" && t.status !== "imported") return null;
const to = rec?.decision // 有问题的整理在整理记录详情里就地处理(待处理中心已取消)
? `${consolePath("inbox")}?kind=${rec.decision.kind}` const to = rec ? consolePath("organize", rec.recordId) : null;
: rec
? consolePath("organize", rec.recordId)
: null;
if (rec?.status === "succeeded" || (t.status === "imported" && rec?.status !== "failed")) if (rec?.status === "succeeded" || (t.status === "imported" && rec?.status !== "failed"))
return { label: "已入库", tone: "success", to, hint: null }; return { label: "已入库", tone: "success", to, hint: null };
if (rec?.status === "failed") return { label: "入库失败", tone: "destructive", to, hint: reasonHint(rec.reason) }; if (rec?.status === "failed") return { label: "入库失败", tone: "destructive", to, hint: reasonHint(rec.reason) };
@@ -252,7 +252,7 @@ function OrganizeBadge({ t }: { t: DownloadBoardItem }) {
<Link <Link
to={s.to} to={s.to}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
title={t.organize?.decision ? "去待处理" : "查看整理记录"} title={t.organize?.decision ? "去整理记录处理" : "查看整理记录"}
className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring" className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
{badge} {badge}
@@ -144,14 +144,14 @@ export function DownloadDetail({ task, live }: { task: DownloadTask; live?: Down
{importFailed ? ( {importFailed ? (
<p className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive"> <p className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive">
入库失败:{reasonLine(importFailed.reason)}。 入库失败:{reasonLine(importFailed.reason)}。
<Link to="/console/inbox" className="ml-1 underline underline-offset-4"> <Link to={`/console/organize/${importFailed.id}`} className="ml-1 underline underline-offset-4">
去待处理 去整理记录处理
</Link> </Link>
</p> </p>
) : null} ) : null}
{task.noProgressSince ? ( {task.noProgressSince ? (
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning"> <p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
自 {formatDateTime(task.noProgressSince)} 起无进展,已记入待处理,不会自动换源。 自 {formatDateTime(task.noProgressSince)} 起无进展,不会自动换源;开了自动调查时智能体会在授权内尝试恢复。
</p> </p>
) : null} ) : null}
+2 -13
View File
@@ -1,5 +1,5 @@
import type { DownloadBoardPlanItem } from "@umt/contracts"; import type { DownloadBoardPlanItem } from "@umt/contracts";
import { Check, Inbox, X } from "lucide-react"; import { Check, X } from "lucide-react";
import { Link } from "react-router"; import { Link } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { useDecideApproval, useIsAdminSession } from "~/api/agent.ts"; import { useDecideApproval, useIsAdminSession } from "~/api/agent.ts";
@@ -147,18 +147,7 @@ export function PlanDetail({ p }: { p: DownloadBoardPlanItem }) {
</div> </div>
</div> </div>
{p.wait === "approval" ? ( {p.wait === "approval" ? (
<p className="flex flex-wrap items-center gap-1 text-caption text-muted-foreground"> <p className="text-caption text-muted-foreground">这次下载要你批准后才提交,铃铛里也有提醒。</p>
这次下载要你批准后才提交,同一件事也在待处理里。
{p.approval?.decisionId ? (
<Link
to={`${consolePath("inbox")}?kind=approval_required`}
className="inline-flex items-center gap-0.5 underline underline-offset-4"
>
<Inbox className="size-3.5" />
去待处理
</Link>
) : null}
</p>
) : hint ? ( ) : hint ? (
<p className="text-caption text-muted-foreground">{hint}。</p> <p className="text-caption text-muted-foreground">{hint}。</p>
) : null} ) : null}
@@ -20,7 +20,7 @@ import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts"; import { errorMessage } from "~/lib/api.ts";
import { canonicalText, episodeCode, mappingHref, RULE_KIND_LABEL, ruleText, scopeText, sourceText } from "./model.ts"; import { canonicalText, episodeCode, mappingHref, RULE_KIND_LABEL, ruleText, scopeText, sourceText } from "./model.ts";
// 待处理中心里的「集数待确认」(episode_mapping):来源季集、原因、建议规则与它的模拟结果; // 作品页集数映射里的「集数待确认」(episode_mapping 内部事项):来源季集、原因、建议规则与它的模拟结果;
// 操作「确认」(建建议规则并重算)、「修改」(跳作品页集数映射并预填草稿)、「忽略」(可同时建忽略规则)。 // 操作「确认」(建建议规则并重算)、「修改」(跳作品页集数映射并预填草稿)、「忽略」(可同时建忽略规则)。
// 智能体建议(批次 5):后台调查没有写规则的授权时,把模拟通过的规则和理由留在事项上,「确认智能体建议」一键建规则 // 智能体建议(批次 5):后台调查没有写规则的授权时,把模拟通过的规则和理由留在事项上,「确认智能体建议」一键建规则
// (服务端建规则前按当前资源再模拟一次)。 // (服务端建规则前按当前资源再模拟一次)。
@@ -0,0 +1,60 @@
import type { Decision } from "@umt/contracts";
import { useDecisions } from "~/api/decisions.ts";
import { humanizeReason } from "~/lib/reasons.ts";
import { EpisodeDecision } from "./decision.tsx";
// 集数待确认的原因(2026-10-06 去掉待处理中心后):不再作为逐条要处理的条目,
// 同一作品的问题聚合在作品页「集数映射」与订阅详情里显示原因;智能体建议 / 建议规则仍可在这里一键确认。
// 数据来自内部事项 episode_mapping(只给关注中的作品开,见 server sources-identity/service/watched.ts)。
const OPEN = ["open", "investigating", "awaiting_user"];
function entityOf(d: Decision): string | null {
const m = d.evidence.mediaEntityId;
return typeof m === "string" ? m : d.subjectType === "media_entity" ? d.subjectId : null;
}
/** 某作品(可选按来源季)未关闭的集数待确认事项 */
export function useEpisodeIssues(entityId: string | null | undefined, season?: number | null) {
const q = useDecisions({ kind: "episode_mapping" }, !!entityId);
const items = (q.data?.pages.flatMap((p) => p.items) ?? []).filter(
(d) =>
OPEN.includes(d.status) &&
entityOf(d) === entityId &&
(season == null || (d.evidence as { season?: unknown }).season === season),
);
return { ...q, items };
}
/** 作品页集数映射分区:逐组写原因,建议规则 / 智能体建议可确认 */
export function EpisodeIssuesList({ entityId }: { entityId: string }) {
const { items } = useEpisodeIssues(entityId);
if (!items.length) return null;
return (
<div className="space-y-3">
<p className="text-caption font-medium text-foreground">换算不了的资源({items.length} 组)</p>
<ul className="space-y-3">
{items.map((d) => (
<li key={d.id} className="rounded-lg border border-border/80 p-3">
<EpisodeDecision d={d} open />
</li>
))}
</ul>
</div>
);
}
/** 订阅详情:缺集原因一句一组(只读,处理去作品页) */
export function EpisodeIssueReasons({ entityId, season }: { entityId: string; season: number | null }) {
const { items } = useEpisodeIssues(entityId, season);
if (!items.length) return null;
return (
<ul className="mt-2 space-y-1 text-meta text-muted-foreground">
{items.map((d) => (
<li key={d.id} className="break-words">
{d.summary ? humanizeReason(d.summary) : d.title}
</li>
))}
</ul>
);
}
@@ -22,6 +22,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~
import { Textarea } from "~/components/ui/textarea.tsx"; import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts"; import { errorMessage } from "~/lib/api.ts";
import { actorText } from "~/lib/labels.ts"; import { actorText } from "~/lib/labels.ts";
import { EpisodeIssuesList } from "./issues.tsx";
import { import {
agentRuleInfo, agentRuleInfo,
disabledReasonText, disabledReasonText,
@@ -413,7 +414,7 @@ export function EpisodeMappingPanel({ entityId }: { entityId: string }) {
</ul> </ul>
) : ( ) : (
<p className="text-caption text-muted-foreground"> <p className="text-caption text-muted-foreground">
还没有规则。资源的集数先按原号、绝对集号和播出日期自动判定,对不上的进待处理。 还没有规则。资源的集数先按原号、绝对集号和播出日期自动判定,换算不了的在这里列出原因。
</p> </p>
)} )}
@@ -440,6 +441,7 @@ export function EpisodeMappingPanel({ entityId }: { entityId: string }) {
) : null} ) : null}
{preview.data ? <GroupStats groups={preview.data.groups} onUseSuggestion={adoptSuggestion} /> : null} {preview.data ? <GroupStats groups={preview.data.groups} onUseSuggestion={adoptSuggestion} /> : null}
<EpisodeIssuesList entityId={entityId} />
</Panel> </Panel>
<Panel <Panel
className="mt-3" className="mt-3"
@@ -5,6 +5,7 @@ import { useEntityDetail } from "~/api/library.ts";
import { consolePath } from "~/components/console/nav.ts"; import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Panel } from "~/components/library/parts.tsx"; import { Panel } from "~/components/library/parts.tsx";
import { EpisodeIssueReasons } from "./issues.tsx";
import { absoluteGroups, groupMethodsText, mappingHref } from "./model.ts"; import { absoluteGroups, groupMethodsText, mappingHref } from "./model.ts";
// 订阅详情的集数映射摘要 + 订阅表单的绝对集号提示(批次 4)。数据来自模拟预览(不带草稿 = 当前规则)。 // 订阅详情的集数映射摘要 + 订阅表单的绝对集号提示(批次 4)。数据来自模拟预览(不带草稿 = 当前规则)。
@@ -54,6 +55,8 @@ export function SubscriptionMappingSummary({
{pending ? ( {pending ? (
<div className="mt-2"> <div className="mt-2">
<StatusBadge tone="warning">待确认 {pending} 条</StatusBadge> <StatusBadge tone="warning">待确认 {pending} 条</StatusBadge>
<p className="mt-1 text-meta text-muted-foreground">这些资源集数对不上,不会下载和整理。</p>
<EpisodeIssueReasons entityId={entityId} season={season} />
</div> </div>
) : null} ) : null}
</Panel> </Panel>
@@ -1,5 +1,5 @@
import type { IdentityPoolItem, PoolCandidate } from "@umt/contracts"; import type { IdentityPoolItem, PoolCandidate } from "@umt/contracts";
import { ArrowRight, BellOff, ExternalLink, LoaderCircle, RotateCw } from "lucide-react"; import { BellOff, ExternalLink, LoaderCircle, RotateCw } from "lucide-react";
import { useId, useState } from "react"; import { useId, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -19,25 +19,8 @@ import { EnrichSection } from "./identity-enrich.tsx";
import { EVIDENCE_LEVEL, POOL_REASON, POOL_STATUS } from "./labels.ts"; import { EVIDENCE_LEVEL, POOL_REASON, POOL_STATUS } from "./labels.ts";
import { Chip, Tone } from "./parts.tsx"; import { Chip, Tone } from "./parts.tsx";
// 待识别资源详情:候选证据 + 重新识别 + 手动指定。进了待处理的(与订阅相关的歧义 / 无结果)在待处理里选候选; // 未识别资源详情:候选证据 + 重新识别 + 选候选 / 手动指定 / 忽略。
// 没进待处理的(unmatched:未命中订阅,惰性识别没查 TMDB)在这里手动指定 TMDB ID / 本地作品,或忽略。 // 2026-10-06 起待处理中心取消:搜索 / RSS 扫进来的未识别资源静默留在这里,不再提示;要处理的直接在这里选。
/** 与订阅 / 本地作品相关的歧义开"待确认身份"事项,相关却无结果开"待识别"事项(同来源同标题合并);unmatched 不进待处理 */
export function inInbox(item: Pick<IdentityPoolItem, "status" | "reason" | "candidates" | "decisionId">): boolean {
return item.status === "pending" && (item.decisionId !== null || hasCandidates(item));
}
function hasCandidates(item: Pick<IdentityPoolItem, "reason" | "candidates">): boolean {
return (item.reason === "ambiguous" || item.reason === "proposed") && item.candidates.length > 0;
}
export const INBOX_IDENTITY = `${consolePath("inbox")}?kind=identity_ambiguous`;
export const INBOX_UNRESOLVED = `${consolePath("inbox")}?kind=identity_unresolved`;
/** 条目对应的待处理筛选链接 */
export function inboxLink(item: Pick<IdentityPoolItem, "reason" | "candidates">): string {
return hasCandidates(item) ? INBOX_IDENTITY : INBOX_UNRESOLVED;
}
/** query 摘要:标题 / 年份 / 季集 */ /** query 摘要:标题 / 年份 / 季集 */
export function querySummary(q: Record<string, unknown>): string { export function querySummary(q: Record<string, unknown>): string {
@@ -51,7 +34,7 @@ export function querySummary(q: Record<string, unknown>): string {
return parts.join(" · ") || "—"; return parts.join(" · ") || "—";
} }
function CandidateRow({ c, index }: { c: PoolCandidate; index: number }) { function CandidateRow({ c, index, onPick }: { c: PoolCandidate; index: number; onPick?: () => void }) {
return ( return (
<li className="min-w-0 rounded-lg border border-border/80 p-3"> <li className="min-w-0 rounded-lg border border-border/80 p-3">
<div className="flex flex-wrap items-start justify-between gap-2"> <div className="flex flex-wrap items-start justify-between gap-2">
@@ -67,6 +50,11 @@ function CandidateRow({ c, index }: { c: PoolCandidate; index: number }) {
</div> </div>
<div className="mt-2 flex flex-wrap items-center gap-1.5"> <div className="mt-2 flex flex-wrap items-center gap-1.5">
<Chip>{EVIDENCE_LEVEL[c.level] ?? c.level}</Chip> <Chip>{EVIDENCE_LEVEL[c.level] ?? c.level}</Chip>
{onPick ? (
<Button variant="outline" size="xs" className="order-last ml-auto" onClick={onPick}>
选这个
</Button>
) : null}
{c.localEntityId ? ( {c.localEntityId ? (
<Link <Link
to={consolePath("library", c.localEntityId)} to={consolePath("library", c.localEntityId)}
@@ -185,8 +173,16 @@ function ManualAssign({ item }: { item: IdentityPoolItem }) {
export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; onClose: () => void }) { export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; onClose: () => void }) {
const retry = useRetryPool(); const retry = useRetryPool();
const assign = useAssignPool();
if (!item) return null; if (!item) return null;
const queued = inInbox(item); const pick = async (index: number) => {
try {
await assign.mutateAsync({ id: item.id, candidateIndex: index, lock: true });
toast.success("已指定");
} catch (err) {
toast.error(errorMessage(err, "指定失败"));
}
};
return ( return (
<DetailSheet <DetailSheet
@@ -196,38 +192,23 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
title={item.release.title} title={item.release.title}
description={item.release.subtitle ?? undefined} description={item.release.subtitle ?? undefined}
footer={ footer={
<> <Button
<Button variant="outline"
variant="outline" disabled={retry.isPending}
disabled={retry.isPending} onClick={async () => {
onClick={async () => { try {
try { await retry.mutateAsync(item.id);
await retry.mutateAsync(item.id); toast.success("已重新识别");
toast.success("已重新识别"); } catch (err) {
} catch (err) { toast.error(errorMessage(err));
toast.error(errorMessage(err)); }
} }}
}} >
> {retry.isPending ? <LoaderCircle className="animate-spin" /> : <RotateCw />}
{retry.isPending ? <LoaderCircle className="animate-spin" /> : <RotateCw />} 重新识别
重新识别 </Button>
</Button>
{queued ? (
<Button asChild>
<Link to={inboxLink(item)}>
去待处理
<ArrowRight />
</Link>
</Button>
) : null}
</>
} }
> >
{queued ? (
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
已进待处理,在那里选择候选或忽略。
</p>
) : null}
<KvList <KvList
items={[ items={[
{ label: "状态", value: <Tone v={POOL_STATUS[item.status]} /> }, { label: "状态", value: <Tone v={POOL_STATUS[item.status]} /> },
@@ -242,7 +223,7 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
]} ]}
/> />
{item.status !== "resolved" && !queued ? <ManualAssign item={item} /> : null} {item.status !== "resolved" ? <ManualAssign item={item} /> : null}
{item.status === "pending" && item.reason !== "ambiguous" && item.reason !== "proposed" ? ( {item.status === "pending" && item.reason !== "ambiguous" && item.reason !== "proposed" ? (
<EnrichSection key={item.id} item={item} /> <EnrichSection key={item.id} item={item} />
@@ -252,7 +233,12 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
{item.candidates.length ? ( {item.candidates.length ? (
<ul className="space-y-2"> <ul className="space-y-2">
{item.candidates.map((c, i) => ( {item.candidates.map((c, i) => (
<CandidateRow key={`${c.provider}:${c.entityType}:${c.externalId}`} c={c} index={i} /> <CandidateRow
key={`${c.provider}:${c.entityType}:${c.externalId}`}
c={c}
index={i}
onPick={item.status === "pending" && !assign.isPending ? () => void pick(i) : undefined}
/>
))} ))}
</ul> </ul>
) : ( ) : (
@@ -5,14 +5,13 @@ import type {
IdentityPoolItem, IdentityPoolItem,
IdentityStatus, IdentityStatus,
} from "@umt/contracts"; } from "@umt/contracts";
import { ArrowRight, Fingerprint, Lock, SearchX } from "lucide-react"; import { Fingerprint, Lock, SearchX } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router";
import { useExternalIdentities, useIdentityPool } from "~/api/identity.ts"; import { useExternalIdentities, useIdentityPool } from "~/api/identity.ts";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"; import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { ExternalIdentitySheet, identityRef } from "~/components/library/identity-external-sheet.tsx"; import { ExternalIdentitySheet, identityRef } from "~/components/library/identity-external-sheet.tsx";
import { inboxLink, inInbox, PoolSheet, querySummary } from "~/components/library/identity-pool-sheet.tsx"; import { PoolSheet, querySummary } from "~/components/library/identity-pool-sheet.tsx";
import { import {
EVIDENCE_LEVEL, EVIDENCE_LEVEL,
IDENTITY_STATUS, IDENTITY_STATUS,
@@ -24,7 +23,7 @@ import {
import { relTime, Tone } from "~/components/library/parts.tsx"; import { relTime, Tone } from "~/components/library/parts.tsx";
// 媒体概览的身份视图(原"身份与元数据"页,IA-3 并入 /console/library):待识别资源、外部身份。 // 媒体概览的身份视图(原"身份与元数据"页,IA-3 并入 /console/library):待识别资源、外部身份。
// 待识别资源只读;选候选、忽略在待处理里做(docs/design/console-ia.md §3.1 判定只在待处理)。 // 未识别资源:点开详情选候选、手动指定或忽略(2026-10-06 起待处理中心取消,不再转去待处理)。
function useDebounced(v: string, ms = 250) { function useDebounced(v: string, ms = 250) {
const [d, setD] = useState(v); const [d, setD] = useState(v);
@@ -69,16 +68,6 @@ export function PoolTab() {
cell: (r) => ( cell: (r) => (
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
<Tone v={POOL_REASON[r.reason]} /> <Tone v={POOL_REASON[r.reason]} />
{inInbox(r) ? (
<Link
to={inboxLink(r)}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-0.5 text-meta whitespace-nowrap text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
已进待处理
<ArrowRight className="h-3 w-3" />
</Link>
) : null}
</div> </div>
), ),
}, },
@@ -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>
);
}
+22 -28
View File
@@ -1,15 +1,13 @@
import { AgentSpendSummary } from "~/components/status/attention-cards.tsx";
import { kToTokens, tokensToK } from "~/lib/tokens.ts"; import { kToTokens, tokensToK } from "~/lib/tokens.ts";
import { AgentToolsBlock } from "./agent-tools.tsx"; import { AgentToolsBlock } from "./agent-tools.tsx";
import { WEBSEARCH_CARD } from "./agent-websearch.tsx"; import { WEBSEARCH_CARD } from "./agent-websearch.tsx";
import { PiUpstreamsBlock } from "./pi-upstreams.tsx"; import { PiUpstreamsBlock } from "./pi-upstreams.tsx";
import type { FieldSpec, FieldValue } from "./resource-section.tsx"; import type { FieldSpec } from "./resource-section.tsx";
import { num, str } from "./resource-section.tsx"; import { num, str } from "./resource-section.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx"; import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
// Agent 分区:上游(pi_upstream)+ 上下文与子代理(pi)+ 预算(budget.agent* 与单次上限)+ 自动调查 + 网页搜索 + 高级 // Agent 分区:上游(pi_upstream)+ 上下文与子代理(pi)+ 用量与限制(单次防跑飞上限 + 花费统计)+ 自动调查 + 网页搜索 + 高级
/** 可清空的数字:空串 → null */
const nullNum = (v: FieldValue | undefined) => (str(v) === "" ? null : Number(str(v)));
const RUNTIME_CARD: CardSpec = { const RUNTIME_CARD: CardSpec = {
anchor: "runtime", anchor: "runtime",
@@ -50,51 +48,45 @@ const RUNTIME_CARD: CardSpec = {
}), }),
}; };
// 2026-10-06:不设每日次数 / 每日花费 / 单次花费 / 单次 token 上限(旧值兼容读取、不生效),
// 只保留单次工具调用次数与时长两个防跑飞限制;花费在这里与运行状态里可见
const BUDGET_CARD: CardSpec = { const BUDGET_CARD: CardSpec = {
anchor: "budget", anchor: "budget",
title: "预算", title: "用量与限制",
description: "超出每日预算的运行停在「超出预算」,等你放行。", description: "不限次数和花费。单次运行只限制工具调用次数和时长,防止跑飞。",
fields: [ fields: [
{ name: "budget.agentMaxRunsPerDay", label: "每日运行次数", kind: "number" },
{ name: "budget.agentMaxCostUsdPerDay", label: "每日费用(USD)", kind: "number" },
{ name: "agent.maxToolCalls", label: "单次工具调用上限", kind: "number", hint: "1–500" }, { name: "agent.maxToolCalls", label: "单次工具调用上限", kind: "number", hint: "1–500" },
{ name: "agent.maxDurationSec", label: "单次时长上限(秒)", kind: "number" }, { name: "agent.maxDurationSec", label: "单次时长上限(秒)", kind: "number" },
{
name: "agent.maxTokensPerRun",
label: "单次 token 上限",
kind: "tokens",
tokens: { min: 1 },
hint: "单位 K,留空不限",
},
{ name: "agent.maxCostUsdPerRun", label: "单次费用上限(USD)", kind: "number", hint: "留空不限" },
], ],
fromSettings: (s) => ({ fromSettings: (s) => ({
"budget.agentMaxRunsPerDay": n(s.budget.agentMaxRunsPerDay),
"budget.agentMaxCostUsdPerDay": n(s.budget.agentMaxCostUsdPerDay),
"agent.maxToolCalls": n(s.agent.maxToolCalls), "agent.maxToolCalls": n(s.agent.maxToolCalls),
"agent.maxDurationSec": n(s.agent.maxDurationSec), "agent.maxDurationSec": n(s.agent.maxDurationSec),
"agent.maxTokensPerRun": tokensToK(s.agent.maxTokensPerRun),
"agent.maxCostUsdPerRun": n(s.agent.maxCostUsdPerRun),
}), }),
toPatch: (v) => ({ toPatch: (v) => ({
budget: {
agentMaxRunsPerDay: num(v["budget.agentMaxRunsPerDay"]),
agentMaxCostUsdPerDay: num(v["budget.agentMaxCostUsdPerDay"]),
},
agent: { agent: {
maxToolCalls: num(v["agent.maxToolCalls"]), maxToolCalls: num(v["agent.maxToolCalls"]),
maxDurationSec: num(v["agent.maxDurationSec"]), maxDurationSec: num(v["agent.maxDurationSec"]),
maxTokensPerRun: kToTokens(str(v["agent.maxTokensPerRun"])) ?? null,
maxCostUsdPerRun: nullNum(v["agent.maxCostUsdPerRun"]),
}, },
}), }),
extra: () => (
<div className="border-t border-border/60 pt-4">
<AgentSpendSummary />
</div>
),
}; };
const INVESTIGATE_CARD: CardSpec = { const INVESTIGATE_CARD: CardSpec = {
anchor: "investigate", anchor: "investigate",
title: "自动调查", title: "自动调查",
description: "待处理事项(没识别出作品、下载失败等)创建时,自动发起一次有界调查。", description: "下载完识别不到作品时叫智能体识别;下载失败、集数对不上等问题出现时,自动发起一次有界调查。",
fields: [ fields: [
{
name: "agent.autoIdentifyOrganize",
label: "整理识别不到作品时叫智能体识别",
kind: "switch",
wide: true,
hint: "认出来自动重新整理;认不出的留在整理记录「需要处理」里",
},
{ name: "agent.autoInvestigate", label: "自动调查", kind: "switch", wide: true }, { name: "agent.autoInvestigate", label: "自动调查", kind: "switch", wide: true },
{ {
name: "agent.autoInvestigateMode", name: "agent.autoInvestigateMode",
@@ -131,6 +123,7 @@ const INVESTIGATE_CARD: CardSpec = {
}, },
], ],
fromSettings: (s) => ({ fromSettings: (s) => ({
"agent.autoIdentifyOrganize": s.agent.autoIdentifyOrganize,
"agent.autoInvestigate": s.agent.autoInvestigate, "agent.autoInvestigate": s.agent.autoInvestigate,
"agent.autoInvestigateMode": s.agent.autoInvestigateMode, "agent.autoInvestigateMode": s.agent.autoInvestigateMode,
"agent.investigateMaxToolCalls": n(s.agent.investigateMaxToolCalls), "agent.investigateMaxToolCalls": n(s.agent.investigateMaxToolCalls),
@@ -139,6 +132,7 @@ const INVESTIGATE_CARD: CardSpec = {
}), }),
toPatch: (v) => ({ toPatch: (v) => ({
agent: { agent: {
autoIdentifyOrganize: v["agent.autoIdentifyOrganize"] === true,
autoInvestigate: v["agent.autoInvestigate"] === true, autoInvestigate: v["agent.autoInvestigate"] === true,
autoInvestigateMode: v["agent.autoInvestigateMode"] === "recover" ? "recover" : "investigate", autoInvestigateMode: v["agent.autoInvestigateMode"] === "recover" ? "recover" : "investigate",
investigateMaxToolCalls: num(v["agent.investigateMaxToolCalls"]), investigateMaxToolCalls: num(v["agent.investigateMaxToolCalls"]),
@@ -172,7 +172,7 @@ function FailureRow({ result, onUseCookie }: { result: SiteLoginResult; onUseCoo
) : null} ) : null}
{result.decisionId ? ( {result.decisionId ? (
<Button asChild size="xs" variant="ghost"> <Button asChild size="xs" variant="ghost">
<Link to={consolePath("inbox")}>查看待处理</Link> <Link to={`${consolePath("status")}#attention`}>查看运行状态</Link>
</Button> </Button>
) : null} ) : null}
</span> </span>
+1 -1
View File
@@ -317,7 +317,7 @@ export function loginFailureText(
return { text: "登录失败:站点要求二次验证。填写 TOTP 密钥后重试,或改用 Cookie", suggestCookie: true }; return { text: "登录失败:站点要求二次验证。填写 TOTP 密钥后重试,或改用 Cookie", suggestCookie: true };
case "challenge": case "challenge":
return { return {
text: "站点要求人机验证(Cloudflare)。会用浏览器自动过,过不去时在待处理里提醒你", text: "站点要求人机验证(Cloudflare)。会用浏览器自动过,过不去时在铃铛里提醒你",
suggestCookie: false, suggestCookie: false,
}; };
case "bad_credentials": case "bad_credentials":
@@ -0,0 +1,129 @@
import type { AgentSpendBucket, AgentSpendKind } from "@umt/contracts";
import { BellRing, Bot } from "lucide-react";
import { Link } from "react-router";
import { useAgentSpend, useAttention } from "~/api/attention.ts";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { settingsPath } from "~/components/console/nav.ts";
import { AttentionList, attentionRows } from "~/components/dashboard/attention-summary.tsx";
import { formatRelative } from "~/lib/labels.ts";
import { humanizeReason } from "~/lib/reasons.ts";
// 运行状态页(2026-10-06):「需要关注」汇总(整理需要处理、等你批准、系统健康,系统健康逐条列出、恢复后自动消失)
// 与智能体用量(今天 / 最近 7 天按会话类型;不设上限,只展示)。
function Slab({
id,
title,
icon: Icon,
children,
}: {
id?: string;
title: string;
icon: typeof Bot;
children: React.ReactNode;
}) {
return (
<section id={id} className="pg-slab min-w-0 scroll-mt-20 rounded-xl p-5">
<div className="mb-3 flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<h2 className="text-body font-semibold text-foreground">{title}</h2>
</div>
{children}
</section>
);
}
export function AttentionCard() {
const q = useAttention();
const rows = attentionRows(q.data);
const health = q.data?.health.items ?? [];
return (
<Slab id="attention" title="需要关注" icon={BellRing}>
{q.isPending ? (
<LoadingState rows={2} />
) : q.isError ? (
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
) : rows.length === 0 ? (
<p className="text-caption text-muted-foreground">
没有需要处理的整理记录,也没有等你批准的操作;站点、下载器和下载目录都正常。
</p>
) : (
<>
<AttentionList rows={rows} />
{health.length ? (
<ul className="mt-3 space-y-2 border-t border-border/60 pt-3">
{health.map((d) => (
<li key={d.id} className="min-w-0 text-caption">
<p className="font-medium break-words text-foreground">{d.title}</p>
{d.summary ? (
<p className="break-words text-meta text-muted-foreground">{humanizeReason(d.summary)}</p>
) : null}
<p className="text-meta text-muted-foreground">{formatRelative(d.createdAt)}起,恢复后自动消失</p>
</li>
))}
</ul>
) : null}
</>
)}
</Slab>
);
}
const KIND_LABEL: Record<AgentSpendKind, string> = {
chat: "对话",
patrol: "巡检",
investigate: "后台调查",
organize_identify: "整理识别",
subagent: "子会话",
};
const usd = (n: number) => `$${n < 0.01 && n > 0 ? n.toFixed(4) : n.toFixed(2)}`;
function SpendBlock({ label, b }: { label: string; b: AgentSpendBucket }) {
return (
<div className="min-w-0">
<p className="text-meta text-muted-foreground">{label}</p>
<p className="text-title-sm font-semibold text-foreground tabular-nums">
{usd(b.costUsd)}
<span className="ml-1.5 text-caption font-normal text-muted-foreground">{b.runs} 次</span>
</p>
{b.byKind.length ? (
<ul className="mt-1 space-y-0.5 text-meta text-muted-foreground tabular-nums">
{b.byKind.map((k) => (
<li key={k.kind}>
{KIND_LABEL[k.kind]} {k.runs} 次 · {usd(k.costUsd)}
</li>
))}
</ul>
) : null}
</div>
);
}
/** 智能体用量(设置页「用量与限制」也用) */
export function AgentSpendSummary() {
const q = useAgentSpend();
if (q.isPending) return <LoadingState rows={2} />;
if (q.isError) return <ErrorState plain error={q.error} onRetry={() => q.refetch()} />;
if (!q.data.last7d.runs) return <p className="text-caption text-muted-foreground">最近 7 天智能体没有运行。</p>;
return (
<div className="grid gap-4 sm:grid-cols-2">
<SpendBlock label="今天" b={q.data.today} />
<SpendBlock label="最近 7 天" b={q.data.last7d} />
</div>
);
}
export function AgentSpendCard() {
return (
<Slab title="智能体用量" icon={Bot}>
<AgentSpendSummary />
<p className="mt-3 text-meta text-muted-foreground">
不设次数和花费上限,只限制单次的工具调用次数与时长。
<Link to={settingsPath("agent", "budget")} className="ml-1 underline-offset-4 hover:underline">
调整
</Link>
</p>
</Slab>
);
}
+2 -1
View File
@@ -124,8 +124,9 @@ export function businessLink(b: EngineRun["business"], input?: unknown): { to: s
return { to: `${consolePath("sources")}?feed=${encodeURIComponent(id)}`, label: "RSS" }; return { to: `${consolePath("sources")}?feed=${encodeURIComponent(id)}`, label: "RSS" };
case "site": case "site":
return { to: `/console/sites/${id}`, label: "站点" }; return { to: `/console/sites/${id}`, label: "站点" };
// 事项是内部机制(2026-10-06 待处理中心取消),不单独链出
case "decision": case "decision":
return { to: consolePath("inbox"), label: "待处理" }; return null;
case "media": case "media":
return { to: `/console/library/${id}`, label: "作品" }; return { to: `/console/library/${id}`, label: "作品" };
default: default:
+14 -28
View File
@@ -16,8 +16,8 @@ import type { ReactNode } from "react";
import { useRef } from "react"; import { useRef } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAttention } from "~/api/attention.ts";
import { useDashboard, useSitesWithAccounts } from "~/api/dashboard.ts"; import { useDashboard, useSitesWithAccounts } from "~/api/dashboard.ts";
import { useDecisions } from "~/api/decisions.ts";
import { useEngineRunsPage } from "~/api/engine.ts"; import { useEngineRunsPage } from "~/api/engine.ts";
import { useUpdateSettings } from "~/api/settings.ts"; import { useUpdateSettings } from "~/api/settings.ts";
import { useSystemHealth } from "~/api/system.ts"; import { useSystemHealth } from "~/api/system.ts";
@@ -25,17 +25,18 @@ import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath, settingsPath } from "~/components/console/nav.ts"; import { consolePath, settingsPath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx"; import { PageHeader } from "~/components/console/page-header.tsx";
import { AttentionList, attentionRows } from "~/components/dashboard/attention-summary.tsx";
import { HomeRuntime } from "~/components/dashboard/home-runtime.tsx"; import { HomeRuntime } from "~/components/dashboard/home-runtime.tsx";
import { runTitle } from "~/components/tasks/labels.ts"; import { runTitle } from "~/components/tasks/labels.ts";
import { useLiveTasks } from "~/components/tasks/task-stream.tsx"; import { useLiveTasks } from "~/components/tasks/task-stream.tsx";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts"; import { errorMessage } from "~/lib/api.ts";
import { decisionKindLabel, formatRelative } from "~/lib/labels.ts"; import { formatRelative } from "~/lib/labels.ts";
import { qk } from "~/lib/query-keys.ts"; import { qk } from "~/lib/query-keys.ts";
import { useSse } from "~/lib/sse.ts"; import { useSse } from "~/lib/sse.ts";
import { cn } from "~/lib/utils.ts"; import { cn } from "~/lib/utils.ts";
// 首页概览:自动化、存储与下载器状态,以及当前工作、待处理事项和最近入库。 // 首页概览:自动化、存储与下载器状态,以及当前工作、要我处理的汇总计数(整理需要处理 / 等你批准 / 系统健康)和最近入库。
/** 首页关心的事件:任意一个到达就(节流)刷新聚合 */ /** 首页关心的事件:任意一个到达就(节流)刷新聚合 */
const DASHBOARD_EVENTS = [ const DASHBOARD_EVENTS = [
@@ -257,33 +258,18 @@ function Busy({ w }: { w: Dashboard["currentWork"] }) {
); );
} }
// ─── 要我处理什么:计数 + 前 3 条 ─── // ─── 要我处理什么:汇总计数(整理需要处理、等你批准、系统健康),点进去到对应页面 ───
function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) { function NeedsMe() {
const list = useDecisions({}); const q = useAttention();
const top = (list.data?.pages[0]?.items ?? []).slice(0, 3); const rows = attentionRows(q.data);
return ( return (
<Section title="要我处理" icon={AlertTriangle} to={consolePath("inbox")}> <Section title="要我处理" icon={AlertTriangle}>
{n.total === 0 ? ( {q.isPending ? (
<LoadingState rows={2} />
) : rows.length === 0 ? (
<EmptyState plain icon={CircleCheck} title="没有要你处理的事" className="py-6" /> <EmptyState plain icon={CircleCheck} title="没有要你处理的事" className="py-6" />
) : ( ) : (
<> <AttentionList rows={rows} />
<p className="mb-2 text-title-sm font-semibold text-warning tabular-nums">
{n.total}
<span className="ml-1.5 text-caption font-normal text-muted-foreground">
项 · {n.byKind.map((k) => `${decisionKindLabel(k.kind)} ${k.count}`).join(" · ")}
</span>
</p>
<ul className="divide-y divide-border/60">
{top.map((d) => (
<Row key={d.id}>
<Link to={`/console/inbox?kind=${d.kind}`} className="min-w-0 flex-1 hover:underline">
<span className="block truncate">{d.title}</span>
<span className="block truncate text-meta text-muted-foreground">{formatRelative(d.createdAt)}</span>
</Link>
</Row>
))}
</ul>
</>
)} )}
</Section> </Section>
); );
@@ -350,7 +336,7 @@ export default function Home() {
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Busy w={q.data.currentWork} /> <Busy w={q.data.currentWork} />
<NeedsMe n={q.data.needsAttention} /> <NeedsMe />
<RecentImports d={q.data} /> <RecentImports d={q.data} />
</div> </div>
)} )}
+6 -637
View File
@@ -1,640 +1,9 @@
import { useQueryClient } from "@tanstack/react-query"; import { legacyRedirectLoader } from "./redirects.ts";
import type { Decision, DecisionKind, DecisionOption, DecisionStatus } from "@umt/contracts";
import { BellOff, Bot, CheckCheck, ChevronDown, ChevronRight, Inbox, Radio, RotateCw, Search } from "lucide-react";
import { type ReactNode, useId, useMemo, useRef, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { toast } from "sonner";
import { decisionKeys, useDecisionSummary, useDecisions, useResolveDecision } from "~/api/decisions.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { EpisodeDecision } from "~/components/episode-mapping/decision.tsx";
import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx";
import { DECISION_KIND, DECISION_STATUS, opts, SEVERITY } from "~/components/library/labels.ts";
import { Chip, relTime, Tone } from "~/components/library/parts.tsx";
import { type AssignedWork, WorkAssignDialog } from "~/components/library/work-assign-dialog.tsx";
import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { actorText } from "~/lib/labels.ts";
import { humanizeReason } from "~/lib/reasons.ts";
import { useSse, useSseStatus } from "~/lib/sse.ts";
import { formatDateTime } from "~/lib/utils.ts";
// 待处理(REQ §13.1):按问题类型 + 作品 / 来源合并并可折叠;每项给证据、建议、影响范围与预览; // 2026-10-06:待处理中心取消,问题回到发生处(整理记录「需要处理」、下载面板、运行状态、订阅详情);
// 处理选项来自后端 decision.options;SSE 收到 decision.* 事件即刷新。 // 旧链接 /console/inbox?kind= 按问题类型跳到对应页面(redirects.ts inboxRedirect)
// 缺省只看需要你处理的事项(警告及以上),"仅信息"类在"级别"筛选里展开。身份类事项后端已按来源 + 标题合并, export const clientLoader = legacyRedirectLoader;
// 卡片里列出组内资源,选候选 / 指定 / 忽略对整组生效。
const OPEN: DecisionStatus[] = ["open", "investigating", "awaiting_user"]; export default function LegacyRedirect() {
const STATUS_FILTER = [ return null;
{ value: "resolved", label: "已处理" },
{ value: "dismissed", label: "已忽略" },
{ value: "expired", label: "已过期" },
];
const SEV_RANK = { info: 0, warning: 1, critical: 2 } as const;
const SEV_FILTER = [{ value: "all", label: "含仅信息" }];
/** 缺省展开的组数;其余折叠 */
const OPEN_GROUPS = 3;
/** 证据里约定俗成的字段(Agent/后端写入);其余原样放进"原始证据" */
const str = (v: unknown) => (typeof v === "string" && v.trim() ? v : null);
/** 身份类合并事项的来源分组(后端 evidence.group) */
function sourceGroupOf(d: Decision): { siteId: string; siteName: string | null } | null {
const g = d.evidence.group as { siteId?: unknown; siteName?: unknown } | undefined;
return g && typeof g.siteId === "string" ? { siteId: g.siteId, siteName: str(g.siteName) } : null;
}
/** 合并事项的组内资源 */
function GroupReleases({ d }: { d: Decision }) {
const list = Array.isArray(d.evidence.releases)
? (d.evidence.releases as { sourceReleaseId?: string; title?: string }[])
: [];
const count = typeof d.evidence.count === "number" ? d.evidence.count : list.length;
const [open, setOpen] = useState(false);
if (count <= 1) return null;
return (
<div className="mt-2">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="inline-flex cursor-pointer items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
aria-expanded={open}
>
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
{open ? "收起" : "展开"}组内 {count} 条资源
</button>
{open ? (
<ul className="mt-1.5 space-y-1 border-l border-border/60 pl-3">
{list.map((r) => (
<li key={r.sourceReleaseId} className="font-mono text-meta break-all text-muted-foreground">
{r.title}
</li>
))}
{count > list.length ? (
<li className="text-meta text-muted-foreground">
另有 {count - list.length} 条,可在媒体库 → 未识别按标题检索
</li>
) : null}
</ul>
) : null}
</div>
);
}
function entityOf(d: Decision): string | null {
const m = d.evidence.mediaEntityId;
if (typeof m === "string" && /^[0-9a-f-]{36}$/.test(m)) return m;
return d.subjectType === "media_entity" ? d.subjectId : null;
}
const IMPACT_KEY: Record<string, string> = {
slots: "收录版本",
units: "季与集",
files: "文件",
releases: "资源",
tasks: "任务",
entities: "作品",
};
function describe(v: unknown): ReactNode {
if (v === null || v === undefined) return null;
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return String(v);
if (Array.isArray(v)) return v.map((x) => (typeof x === "object" ? JSON.stringify(x) : String(x))).join("、");
return Object.entries(v as Record<string, unknown>)
.map(
([k, x]) =>
`${IMPACT_KEY[k] ?? k} ${Array.isArray(x) ? x.join("、") : typeof x === "object" ? JSON.stringify(x) : String(x)}`,
)
.join(";");
}
function Evidence({ d }: { d: Decision }) {
const e = d.evidence;
const suggestion = str(e.suggestion);
const impact = describe(e.impact);
const preview = describe(e.preview);
const checked = Array.isArray(e.checked) ? (e.checked as unknown[]).map(String) : [];
const rows: [string, ReactNode][] = [];
if (suggestion) rows.push(["建议", suggestion]);
if (impact) rows.push(["影响范围", impact]);
if (preview) rows.push(["预览", preview]);
return (
<div className="space-y-2">
{rows.length ? (
<dl className="grid gap-1.5 text-caption sm:grid-cols-[5rem_1fr]">
{rows.map(([k, v]) => (
<div key={k} className="contents">
<dt className="text-muted-foreground">{k}</dt>
<dd className="min-w-0 break-words text-foreground">{v}</dd>
</div>
))}
</dl>
) : null}
{checked.length ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="inline-flex items-center gap-1 text-meta text-muted-foreground">
<Bot className="h-3.5 w-3.5" />
智能体已检查
</span>
{checked.map((c) => (
<Chip key={c}>{c}</Chip>
))}
</div>
) : null}
<TechInfo
items={[
{ label: "事项 ID", value: d.id },
{ label: "类型", value: d.kind },
]}
>
<JsonView label="详细信息" value={e} defaultOpen={false} maxHeight="14rem" />
</TechInfo>
</div>
);
}
/** 身份类手动指定选项需要的输入(后端在 option.input.requires 标注) */
type Requires = "tmdb" | "mediaEntityId" | null;
function requiresOf(option: DecisionOption): Requires {
const r = option.input?.requires;
return r === "tmdb" || r === "mediaEntityId" ? r : null;
}
function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
const resolve = useResolveDecision();
const uid = useId();
const [note, setNote] = useState("");
const requires = requiresOf(option);
const [tmdbType, setTmdbType] = useState<"movie" | "tv">("movie");
const [tmdbId, setTmdbId] = useState("");
const [entity, setEntity] = useState<PickedEntity | null>(null);
const tmdbOk = /^\d{1,10}$/.test(tmdbId.trim());
const identity =
requires === "tmdb"
? tmdbOk
? { tmdb: { type: tmdbType, id: tmdbId.trim() } }
: null
: requires === "mediaEntityId"
? entity
? { mediaEntityId: entity.id }
: null
: undefined;
return (
<ConfirmDialog
trigger={
<Button variant="outline" size="sm">
{option.label}
</Button>
}
title={option.label}
description={
<>
{option.description ? `${option.description}。` : ""}
{option.capabilityId ? `由能力 ${option.capabilityId} 执行,仍做授权校验。` : null}
</>
}
confirmLabel={option.label}
confirmDisabled={identity === null}
onConfirm={() =>
resolve
.mutateAsync({
id: d.id,
optionKey: option.key,
note: note.trim() || undefined,
...(identity ? { identity } : {}),
})
.then(
() => toast.success(`已处理:${option.label}`),
(err) => {
toast.error(errorMessage(err, "处理失败"));
throw err;
},
)
}
>
{requires === "tmdb" ? (
<div className="grid gap-x-2 sm:grid-cols-[8rem_minmax(0,1fr)]">
<FormField label="类型" htmlFor={`${uid}-type`}>
<Select value={tmdbType} onValueChange={(v) => setTmdbType(v as "movie" | "tv")}>
<SelectTrigger id={`${uid}-type`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="movie">电影</SelectItem>
<SelectItem value="tv">剧集</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField label="TMDB ID" htmlFor={`${uid}-tmdb`} required hint="纯数字,如 872585">
<Input
id={`${uid}-tmdb`}
inputMode="numeric"
value={tmdbId}
onChange={(e) => setTmdbId(e.target.value)}
aria-invalid={tmdbId.trim() !== "" && !tmdbOk}
/>
</FormField>
</div>
) : requires === "mediaEntityId" ? (
<FormField label="本地作品" required>
<EntityPicker value={entity} onChange={setEntity} />
</FormField>
) : null}
<FormField label="备注" htmlFor={`${uid}-note`} hint="可选,记在处理结果里">
<Textarea id={`${uid}-note`} value={note} onChange={(e) => setNote(e.target.value)} rows={2} />
</FormField>
</ConfirmDialog>
);
}
/** 事项里能拿来搜作品的名字:代表资源标题 / 下载名 / 标题冒号后半段 */
function subjectName(d: Decision): string | null {
const e = d.evidence;
const first = Array.isArray(e.releases) ? (e.releases[0] as { title?: unknown } | undefined) : undefined;
return (
str(first?.title) ??
str(e.title) ??
str(e.name) ??
(d.title.includes(":") ? d.title.split(":").slice(1).join(":") : null)
);
}
/** 搜作品用的关键词:去掉扩展名、分隔符、年份之后的规格串 */
function queryOf(name: string | null): string {
if (!name) return "";
const base = name.replace(/\.(mkv|mp4|ts|m2ts|iso|avi)$/i, "").replace(/[._]+/g, " ");
const m = base.match(/^(.*?)(?:\b(19|20)\d{2}\b|\bS\d{1,2}E?\d{0,3}\b|\b(2160|1080|720)p\b)/i);
return (m?.[1]?.trim() || base)
.replace(/[[\]()]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 60);
}
const IDENTITY_KINDS: DecisionKind[] = ["identity_ambiguous", "identity_unresolved"];
/** 「没识别出作品」的主操作:搜作品并指定。资源类事项走 resolve(本地作品 / TMDB),整理类事项打开重新整理 */
function AssignWorkButton({ d }: { d: Decision }) {
const resolve = useResolveDecision();
const [open, setOpen] = useState(false);
const [wizard, setWizard] = useState(false);
const local = d.options.find((o) => o.input?.requires === "mediaEntityId");
const tmdb = d.options.find((o) => o.input?.requires === "tmdb");
const recordId = d.subjectType === "organization_record" ? d.subjectId : null;
if (!local && !tmdb && !recordId) return null;
const name = subjectName(d);
const assign = (w: AssignedWork) => {
const option = "mediaEntityId" in w ? local : tmdb;
if (!option) {
toast.error("mediaEntityId" in w ? "这条事项只能按 TMDB 指定" : "这条事项只能选媒体库里的作品");
return Promise.reject(new Error("unsupported"));
}
return resolve
.mutateAsync({
id: d.id,
optionKey: option.key,
identity: "mediaEntityId" in w ? { mediaEntityId: w.mediaEntityId } : { tmdb: w.tmdb },
})
.then(
() => toast.success(`已指定为「${w.title}」`),
(err) => {
toast.error(errorMessage(err, "指定失败"));
throw err;
},
);
};
return (
<>
<Button size="sm" onClick={() => (recordId && !local && !tmdb ? setWizard(true) : setOpen(true))}>
<Search />
指定作品
</Button>
<WorkAssignDialog
open={open}
onOpenChange={setOpen}
subject={name}
initialQuery={queryOf(name)}
onAssign={assign}
/>
{wizard && recordId ? (
<OrganizeWizard source={{ kind: "record", recordId }} onClose={() => setWizard(false)} />
) : null}
</>
);
}
function DismissButton({ d }: { d: Decision }) {
const resolve = useResolveDecision();
return (
<ConfirmDialog
trigger={
<Button variant="ghost" size="sm">
<BellOff />
忽略
</Button>
}
title="忽略这条事项?"
description="只关闭提醒,问题本身不变;同一根因出现新证据时会重新提出。"
confirmLabel="忽略"
onConfirm={() =>
resolve.mutateAsync({ id: d.id, dismiss: true }).then(
() => toast.success("已忽略"),
(err) => {
toast.error(errorMessage(err, "操作失败"));
throw err;
},
)
}
/>
);
}
function DecisionCard({ d }: { d: Decision }) {
const open = OPEN.includes(d.status);
const identity = IDENTITY_KINDS.includes(d.kind);
// 集数待确认:证据与「确认 / 修改 / 忽略」由 EpisodeDecision 渲染
const episode = d.kind === "episode_mapping";
// 身份类:手动 TMDB / 选本地作品两个选项并进「指定作品」
const options = d.options.filter(
(o) =>
o.key !== "dismiss" && !(identity && (o.input?.requires === "tmdb" || o.input?.requires === "mediaEntityId")),
);
const resolution = d.resolution as { optionKey?: string | null; note?: string | null } | null;
return (
<article className="min-w-0 border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5">
<div className="flex flex-wrap items-center gap-2">
{d.severity === "critical" ? <Tone v={SEVERITY[d.severity]} /> : null}
{d.status === "investigating" ? <Tone v={DECISION_STATUS[d.status]} /> : null}
<span className="ml-auto text-meta text-muted-foreground tabular-nums">
{relTime(d.createdAt)}
{d.attempts ? ` · 调查 ${d.attempts} 次` : ""}
</span>
</div>
<h3 className="mt-2 text-body font-medium break-words text-foreground">{d.title}</h3>
{d.summary ? (
<p className="mt-1 text-caption break-words text-muted-foreground">{humanizeReason(d.summary)}</p>
) : null}
{episode ? null : <GroupReleases d={d} />}
{episode ? (
<div className="mt-3">
<EpisodeDecision d={d} open={open} />
</div>
) : null}
<div className="mt-3">
<Evidence d={d} />
</div>
{open && !episode ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
{identity ? <AssignWorkButton d={d} /> : null}
{options.map((o) => (
<OptionButton key={o.key} d={d} option={o} />
))}
<DismissButton d={d} />
{d.agentRunId ? (
<Link
to={consolePath("agent", d.agentRunId)}
className="ml-auto inline-flex items-center text-meta text-muted-foreground hover:text-foreground hover:underline"
>
智能体调查记录
<ChevronRight className="h-3 w-3" />
</Link>
) : null}
</div>
) : open ? null : (
<p className="mt-3 text-meta text-muted-foreground">
{d.resolvedBy ? `${actorText(d.resolvedBy)} ` : ""}于 {formatDateTime(d.resolvedAt)}
{resolution?.optionKey
? ` 选择「${d.options.find((o) => o.key === resolution.optionKey)?.label ?? resolution.optionKey}」`
: " 关闭"}
{resolution?.note ? `:${resolution.note}` : ""}
</p>
)}
</article>
);
}
type Group = {
key: string;
entityId: string | null;
source: { siteId: string; siteName: string | null } | null;
kind: DecisionKind;
items: Decision[];
};
export default function InboxPage() {
const [sp, setSp] = useSearchParams();
const status = (sp.get("status") ?? "") as DecisionStatus | "";
const kind = (sp.get("kind") ?? "") as DecisionKind | "";
const sev = sp.get("sev") ?? "";
const minSeverity = sev === "all" ? undefined : "warning";
const set = (k: string, v: string) =>
setSp(
(prev) => {
const n = new URLSearchParams(prev);
if (v) n.set(k, v);
else n.delete(k);
return n;
},
{ replace: true },
);
const summary = useDecisionSummary(status || undefined, minSeverity);
const list = useDecisions({ status: status || undefined, kind: kind || undefined, minSeverity });
const rows = list.data?.pages.flatMap((p) => p.items);
const paged = useListPagination(rows, {
hasMore: list.hasNextPage,
onLoadMore: () => list.fetchNextPage(),
loadingMore: list.isFetchingNextPage,
error: list.error,
resetKey: JSON.stringify({ status, kind, minSeverity }),
});
// SSE:decision.created / decision.updated → 刷新(合并 500ms 内的多条事件)
const qc = useQueryClient();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useSse(["decision.created", "decision.updated"], () => {
if (timer.current) return;
timer.current = setTimeout(() => {
timer.current = null;
void qc.invalidateQueries({ queryKey: decisionKeys.all });
}, 500);
});
const live = useSseStatus();
const titles = useMemo(() => {
const m = new Map<string, string>();
for (const g of summary.data?.groups ?? [])
if (g.mediaEntityId && g.mediaEntityTitle) m.set(g.mediaEntityId, g.mediaEntityTitle);
return m;
}, [summary.data]);
const byKind = useMemo(() => {
const m = new Map<DecisionKind, { count: number; sev: keyof typeof SEV_RANK }>();
for (const g of summary.data?.groups ?? []) {
const cur = m.get(g.kind);
m.set(g.kind, {
count: (cur?.count ?? 0) + g.count,
sev: cur && SEV_RANK[cur.sev] > SEV_RANK[g.maxSeverity] ? cur.sev : g.maxSeverity,
});
}
return [...m.entries()].sort((a, b) => SEV_RANK[b[1].sev] - SEV_RANK[a[1].sev] || b[1].count - a[1].count);
}, [summary.data]);
// 按问题类型 + 作品 / 来源合并;组内按严重度、时间
const groups = useMemo(() => {
const m = new Map<string, Group>();
for (const d of paged.items ?? []) {
const entityId = entityOf(d);
const source = entityId ? null : sourceGroupOf(d);
const key = `${entityId ?? (source ? `site:${source.siteId}` : `subject:${d.subjectType ?? "none"}`)}|${d.kind}`;
const g = m.get(key) ?? { key, entityId, source, kind: d.kind, items: [] };
g.items.push(d);
m.set(key, g);
}
const maxSev = (g: Group) => Math.max(...g.items.map((d) => SEV_RANK[d.severity]));
return [...m.values()].sort((a, b) => maxSev(b) - maxSev(a) || b.items.length - a.items.length);
}, [paged.items]);
const isOpenView = !status;
// 折叠状态:缺省前 OPEN_GROUPS 组展开;用户点过的组按用户的来
const [toggled, setToggled] = useState<Record<string, boolean>>({});
const isExpanded = (key: string, index: number) => toggled[key] ?? index < OPEN_GROUPS;
return (
<>
<PageHeader
title="待处理"
description="需要你决定的事"
actions={
<>
{live === "reconnecting" ? (
<span className="hidden items-center gap-1.5 text-meta text-warning sm:inline-flex">
<Radio className="h-3.5 w-3.5" />
重连中
</span>
) : null}
<Button
variant="outline"
size="sm"
onClick={() => void qc.invalidateQueries({ queryKey: decisionKeys.all })}
>
<RotateCw className={list.isFetching ? "animate-spin" : undefined} />
刷新
</Button>
</>
}
/>
{summary.isError ? (
<ErrorState className="mb-4" title="汇总加载失败" error={summary.error} onRetry={() => summary.refetch()} />
) : (
<fieldset className="mb-4 flex min-w-0 flex-wrap items-center gap-1.5 border-0 p-0" aria-label="按问题类型筛选">
<Button variant={kind ? "ghost" : "secondary"} size="sm" onClick={() => set("kind", "")} aria-pressed={!kind}>
<Inbox />
{isOpenView ? "全部" : (DECISION_STATUS[status as DecisionStatus]?.label ?? "全部")}
<span className="tabular-nums">{summary.isPending ? "…" : (summary.data?.total ?? 0)}</span>
</Button>
{byKind.map(([k, v]) => (
<Button
key={k}
variant={kind === k ? "secondary" : "ghost"}
size="sm"
aria-pressed={kind === k}
onClick={() => set("kind", kind === k ? "" : k)}
className={v.sev === "critical" ? "text-destructive" : undefined}
>
{DECISION_KIND[k]}
<span className="tabular-nums">{v.count}</span>
</Button>
))}
</fieldset>
)}
<FilterBar onReset={status || kind || sev ? () => setSp(new URLSearchParams(), { replace: true }) : undefined}>
<FilterSelect
label="状态"
value={status}
onChange={(v) => set("status", v)}
options={STATUS_FILTER}
allLabel="未关闭"
/>
<FilterSelect label="问题类型" value={kind} onChange={(v) => set("kind", v)} options={opts(DECISION_KIND)} />
<FilterSelect label="级别" value={sev} onChange={(v) => set("sev", v)} options={SEV_FILTER} allLabel="需处理" />
</FilterBar>
{list.isPending ? (
<div className="pg-slab rounded-xl p-4">
<LoadingState rows={4} />
</div>
) : list.isError && !rows ? (
<ErrorState error={list.error} onRetry={() => list.refetch()} />
) : groups.length === 0 ? (
<EmptyState
icon={CheckCheck}
title={isOpenView ? "没有需要你处理的事项" : "没有符合条件的事项"}
description={isOpenView ? "新事项会实时出现在这里。" : undefined}
/>
) : (
<div className="space-y-4">
{groups.map((g, index) => {
const title = g.entityId ? titles.get(g.entityId) : null;
const expanded = isExpanded(g.key, index);
const maxSev = g.items.reduce<keyof typeof SEV_RANK>(
(a, d) => (SEV_RANK[d.severity] > SEV_RANK[a] ? d.severity : a),
"info",
);
return (
<section key={g.key} className="pg-slab min-w-0 overflow-hidden rounded-xl">
<header
className={`flex flex-wrap items-center gap-2 px-4 py-3 sm:px-5 ${expanded ? "border-b border-border/60" : ""}`}
>
<button
type="button"
onClick={() => setToggled((t) => ({ ...t, [g.key]: !expanded }))}
className="inline-flex cursor-pointer items-center text-muted-foreground hover:text-foreground"
aria-expanded={expanded}
aria-label={expanded ? "折叠" : "展开"}
>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
<StatusBadge tone={SEVERITY[maxSev].tone}>{DECISION_KIND[g.kind]}</StatusBadge>
{g.entityId ? (
<Link
to={consolePath("library", g.entityId)}
className="min-w-0 truncate text-body font-semibold text-foreground hover:underline"
>
{title ?? "关联作品"}
</Link>
) : g.source ? (
<span className="min-w-0 truncate text-body font-semibold text-foreground">
来源「{g.source.siteName ?? "未知站点"}」
</span>
) : (
<span className="text-body font-semibold text-muted-foreground">
{IDENTITY_KINDS.includes(g.kind) ? "未关联作品" : "其他"}
</span>
)}
<span className="ml-auto text-meta text-muted-foreground">{g.items.length} 项</span>
</header>
{expanded ? g.items.map((d) => <DecisionCard key={d.id} d={d} />) : null}
</section>
);
})}
</div>
)}
{rows?.length || list.hasNextPage ? <Pagination {...paged.pagination} /> : null}
</>
);
} }
+2 -15
View File
@@ -1,5 +1,5 @@
import type { LibraryItem, LibraryListQuery, MediaKind, SlotStatus } from "@umt/contracts"; import type { LibraryItem, LibraryListQuery, MediaKind, SlotStatus } from "@umt/contracts";
import { Download, Inbox, Library as LibraryIcon, RotateCw } from "lucide-react"; import { Download, Library as LibraryIcon, RotateCw } from "lucide-react";
import { useDeferredValue } from "react"; import { useDeferredValue } from "react";
import { Link, useNavigate, useSearchParams } from "react-router"; import { Link, useNavigate, useSearchParams } from "react-router";
import { useLibraryList } from "~/api/library.ts"; import { useLibraryList } from "~/api/library.ts";
@@ -9,7 +9,6 @@ import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx"; import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx";
import { AliasesPanel } from "~/components/library/identity-aliases.tsx"; import { AliasesPanel } from "~/components/library/identity-aliases.tsx";
import { INBOX_IDENTITY } from "~/components/library/identity-pool-sheet.tsx";
import { ExternalTab, PoolTab } from "~/components/library/identity-views.tsx"; import { ExternalTab, PoolTab } from "~/components/library/identity-views.tsx";
import { ENTITY_STATUS, MEDIA_KIND, opts, SLOT_STATUS, SLOT_STATUS_ORDER } from "~/components/library/labels.ts"; import { ENTITY_STATUS, MEDIA_KIND, opts, SLOT_STATUS, SLOT_STATUS_ORDER } from "~/components/library/labels.ts";
import { CoverPlaceholder, relTime, Tone } from "~/components/library/parts.tsx"; import { CoverPlaceholder, relTime, Tone } from "~/components/library/parts.tsx";
@@ -60,19 +59,7 @@ export default function LibraryPage() {
const view = viewOf(sp); const view = viewOf(sp);
return ( return (
<> <>
<PageHeader <PageHeader title="媒体库" />
title="媒体库"
actions={
view === "unidentified" ? (
<Button asChild variant="outline" size="sm">
<Link to={INBOX_IDENTITY}>
<Inbox />
确认身份
</Link>
</Button>
) : undefined
}
/>
<Tabs value={view} onValueChange={(v) => setSp(new URLSearchParams(VIEW_QUERY[v as View]), { replace: true })}> <Tabs value={view} onValueChange={(v) => setSp(new URLSearchParams(VIEW_QUERY[v as View]), { replace: true })}>
<TabsList className="mb-4"> <TabsList className="mb-4">
<TabsTrigger value="works">作品</TabsTrigger> <TabsTrigger value="works">作品</TabsTrigger>
@@ -13,6 +13,7 @@ import { TechInfo } from "~/components/console/tech-info.tsx";
import { CoverPlaceholder } from "~/components/library/parts.tsx"; import { CoverPlaceholder } from "~/components/library/parts.tsx";
import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx"; import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx";
import { OrganizeScrapeHistory } from "~/components/organize/organize-scrape-history.tsx"; import { OrganizeScrapeHistory } from "~/components/organize/organize-scrape-history.tsx";
import { attentionLine, RecordAttentionActions } from "~/components/organize/record-attention.tsx";
import { canReorganize, isRunning, recordFileProgress } from "~/components/organize/record-parts.ts"; import { canReorganize, isRunning, recordFileProgress } from "~/components/organize/record-parts.ts";
import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx"; import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
@@ -219,6 +220,12 @@ export default function OrganizeRecordPage() {
{reasonLine(r.reason)} {reasonLine(r.reason)}
</p> </p>
) : null} ) : null}
{r.attention ? (
<div className="space-y-2 rounded-lg border border-warning/40 px-3 py-2">
<p className="text-caption text-foreground">{attentionLine(r) ?? "这条记录需要处理"}</p>
<RecordAttentionActions r={r} onReorganize={() => setReorganize(true)} />
</div>
) : null}
<div className="flex flex-col gap-2 md:flex-row md:items-center"> <div className="flex flex-col gap-2 md:flex-row md:items-center">
<PathBlock label="整理前" p={r.source} /> <PathBlock label="整理前" p={r.source} />
<ArrowRight className="hidden h-4 w-4 shrink-0 text-muted-foreground md:block" /> <ArrowRight className="hidden h-4 w-4 shrink-0 text-muted-foreground md:block" />
+70 -17
View File
@@ -36,6 +36,7 @@ import { Checkbox } from "~/components/files/file-list.tsx";
import { CoverPlaceholder, relTime } from "~/components/library/parts.tsx"; import { CoverPlaceholder, relTime } from "~/components/library/parts.tsx";
import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx"; import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx";
import { OrganizeQueue } from "~/components/organize/organize-queue.tsx"; import { OrganizeQueue } from "~/components/organize/organize-queue.tsx";
import { attentionLine, RecordAttentionActions } from "~/components/organize/record-attention.tsx";
import { import {
canReorganize, canReorganize,
episodeRange, episodeRange,
@@ -62,6 +63,8 @@ import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
// 按状态 / 类型 / 来源 / 时间 / 关键字(支持 * ? 通配、种子 hash)筛选;按状态计数; // 按状态 / 类型 / 来源 / 时间 / 关键字(支持 * ? 通配、种子 hash)筛选;按状态计数;
// 缺省按作品分组(一季十集收成一组,组头写季集范围、条数和非成功状态;只有一条的不分组),切换后本浏览器记住; // 缺省按作品分组(一季十集收成一组,组头写季集范围、条数和非成功状态;只有一条的不分组),切换后本浏览器记住;
// 单条与批量的重新整理(走整理向导)和删除(可选源文件 / 媒体库文件进回收站)。 // 单条与批量的重新整理(走整理向导)和删除(可选源文件 / 媒体库文件进回收站)。
// 需要处理(2026-10-06 去掉待处理中心):下载完识别不到、目标冲突、被排除、卡住 / 失败的记录在这里就地处理,
// 「需要处理」筛选只看这些;行内写原因与推荐动作(指定作品 / 重新整理 / 让智能体识别 / 忽略),一键执行。
type Rec = PublicOrganizationRecord; type Rec = PublicOrganizationRecord;
@@ -80,11 +83,11 @@ function sinceOf(t: string): string | undefined {
const when = (r: Rec) => r.finishedAt ?? r.startedAt ?? r.createdAt; const when = (r: Rec) => r.finishedAt ?? r.startedAt ?? r.createdAt;
/** 状态:成功不加徽章,其余徽章 + 一句原因 */ /** 状态:成功不加徽章,其余徽章 + 一句原因;需要处理的再给行内动作 */
function StatusCell({ r }: { r: Rec }) { function StatusCell({ r, onReorganize }: { r: Rec; onReorganize?: () => void }) {
const progress = recordFileProgress(r); const progress = recordFileProgress(r);
if (r.status === "succeeded" && !r.reason) return <span className="text-muted-foreground">成功</span>; if (r.status === "succeeded" && !r.reason && !r.attention) return <span className="text-muted-foreground">成功</span>;
const line = reasonLine(r.reason); const line = attentionLine(r) ?? reasonLine(r.reason);
return ( return (
<div className="flex flex-col items-start gap-0.5"> <div className="flex flex-col items-start gap-0.5">
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge> <StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
@@ -95,10 +98,15 @@ function StatusCell({ r }: { r: Rec }) {
</span> </span>
) : null} ) : null}
{line ? ( {line ? (
<span className="line-clamp-2 max-w-52 text-meta text-muted-foreground" title={line}> <span className="line-clamp-2 max-w-64 text-meta text-muted-foreground" title={line}>
{line} {line}
</span> </span>
) : null} ) : null}
{r.attention && onReorganize ? (
<div className="mt-1">
<RecordAttentionActions r={r} onReorganize={onReorganize} compact />
</div>
) : null}
</div> </div>
); );
} }
@@ -271,14 +279,18 @@ function StatsLine({
filters, filters,
status, status,
onStatus, onStatus,
attention,
onAttention,
}: { }: {
filters: Omit<RecordFilters, "status">; filters: Omit<RecordFilters, "status">;
status: string; status: string;
onStatus: (s: string) => void; onStatus: (s: string) => void;
attention: boolean;
onAttention: (on: boolean) => void;
}) { }) {
const stats = useRecordStats(filters); const stats = useRecordStats({ ...filters, attention: undefined });
const s = stats.data; const s = stats.data;
if (!s || s.total === 0) return null; if (!s || (s.total === 0 && !s.attention)) return null;
const parts: { key: OrganizationStatus | "running"; label: string; n: number; tone?: string }[] = [ const parts: { key: OrganizationStatus | "running"; label: string; n: number; tone?: string }[] = [
{ key: "succeeded", label: "成功", n: s.succeeded }, { key: "succeeded", label: "成功", n: s.succeeded },
{ key: "failed", label: "失败", n: s.failed, tone: "text-destructive" }, { key: "failed", label: "失败", n: s.failed, tone: "text-destructive" },
@@ -298,6 +310,19 @@ function StatsLine({
> >
共 {s.total} 条 共 {s.total} 条
</button> </button>
{s.attention || attention ? (
<button
type="button"
aria-pressed={attention}
onClick={() => onAttention(!attention)}
className={cn(
"rounded-md px-1.5 py-0.5 text-warning hover:text-foreground",
attention && "bg-foreground/[0.06]",
)}
>
需要处理 {s.attention}
</button>
) : null}
{parts {parts
.filter((p) => p.n > 0) .filter((p) => p.n > 0)
.map((p) => ( .map((p) => (
@@ -321,11 +346,21 @@ function StatsLine({
export default function OrganizePage() { export default function OrganizePage() {
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const f = useQueryState(["status", "trigger", "kind", "time", "q", "group"] as const); const f = useQueryState(["status", "trigger", "kind", "time", "q", "group", "attention"] as const);
const { set: setFilter, values: v } = f; const { set: setFilter, values: v } = f;
const [qInput, setQInput] = useState(v.q); const [qInput, setQInput] = useState(v.q);
const entityId = params.get("mediaEntityId") ?? undefined; const entityId = params.get("mediaEntityId") ?? undefined;
const legacyRecords = !!(entityId || params.get("ids") || v.q || v.trigger || v.kind || v.time || v.status); const legacyRecords = !!(
entityId ||
params.get("ids") ||
v.q ||
v.trigger ||
v.kind ||
v.time ||
v.status ||
v.attention
);
const attention = v.attention === "1";
const tab = params.get("tab") === "records" || (!params.get("tab") && legacyRecords) ? "records" : "queue"; const tab = params.get("tab") === "records" || (!params.get("tab") && legacyRecords) ? "records" : "queue";
const setTab = (next: string) => const setTab = (next: string) =>
setParams( setParams(
@@ -351,7 +386,8 @@ export default function OrganizePage() {
// 时间筛选换成具体时刻只在切换时算一次,避免每次渲染查询键都变 // 时间筛选换成具体时刻只在切换时算一次,避免每次渲染查询键都变
const since = useMemo(() => sinceOf(v.time), [v.time]); const since = useMemo(() => sinceOf(v.time), [v.time]);
const base: Omit<RecordFilters, "status"> = { const base: Omit<RecordFilters, "status"> = {
phase: v.status === "running" || v.status === "queued" ? undefined : "history", phase: attention || v.status === "running" || v.status === "queued" ? undefined : "history",
attention: attention || undefined,
organizeOnly: true, organizeOnly: true,
trigger: (v.trigger || undefined) as OrganizationTrigger | undefined, trigger: (v.trigger || undefined) as OrganizationTrigger | undefined,
kind: (v.kind || undefined) as RecordFilters["kind"], kind: (v.kind || undefined) as RecordFilters["kind"],
@@ -376,7 +412,7 @@ export default function OrganizePage() {
resetKey: JSON.stringify({ ...base, status, ids }), resetKey: JSON.stringify({ ...base, status, ids }),
}); });
const rows = paged.items; const rows = paged.items;
const filtered = !!(v.status || v.trigger || v.kind || v.time || v.q) || !!entityId || pinned; const filtered = !!(v.status || v.trigger || v.kind || v.time || v.q || attention) || !!entityId || pinned;
const [selecting, setSelecting] = useState(false); const [selecting, setSelecting] = useState(false);
const [sel, setSel] = useState<Set<string>>(new Set()); const [sel, setSel] = useState<Set<string>>(new Set());
@@ -477,9 +513,9 @@ export default function OrganizePage() {
.filter(Boolean) .filter(Boolean)
.join(" · ")} .join(" · ")}
</p> </p>
{r.status !== "succeeded" || r.reason ? ( {r.status !== "succeeded" || r.reason || r.attention ? (
<div className="mt-1 sm:hidden"> <div className="mt-1 sm:hidden">
<StatusCell r={r} /> <StatusCell r={r} onReorganize={() => reorganize([r])} />
</div> </div>
) : null} ) : null}
</div> </div>
@@ -500,7 +536,12 @@ export default function OrganizePage() {
</div> </div>
), ),
}, },
{ key: "status", header: "状态", hideBelow: "sm", cell: (r) => <StatusCell r={r} /> }, {
key: "status",
header: "状态",
hideBelow: "sm",
cell: (r) => <StatusCell r={r} onReorganize={() => reorganize([r])} />,
},
{ {
key: "trigger", key: "trigger",
header: "来源", header: "来源",
@@ -639,7 +680,15 @@ export default function OrganizePage() {
</Button> </Button>
) : null} ) : null}
</FilterBar> </FilterBar>
{pinned ? null : <StatsLine filters={base} status={v.status} onStatus={(s) => setFilter("status", s)} />} {pinned ? null : (
<StatsLine
filters={base}
status={v.status}
onStatus={(s) => setFilter("status", s)}
attention={attention}
onAttention={(on) => setFilter("attention", on ? "1" : "")}
/>
)}
{selecting ? ( {selecting ? (
<div className="pg-slab sticky top-2 z-20 mb-3 flex flex-wrap items-center gap-2 rounded-xl px-3 py-2"> <div className="pg-slab sticky top-2 z-20 mb-3 flex flex-wrap items-center gap-2 rounded-xl px-3 py-2">
<span className="text-sm text-foreground tabular-nums">已选 {selRows.length}</span> <span className="text-sm text-foreground tabular-nums">已选 {selRows.length}</span>
@@ -692,8 +741,12 @@ export default function OrganizePage() {
} }
empty={{ empty={{
icon: FileStack, icon: FileStack,
title: filtered ? "没有符合筛选的整理记录" : "还没有整理记录", title: attention ? "没有需要处理的整理记录" : filtered ? "没有符合筛选的整理记录" : "还没有整理记录",
description: filtered ? undefined : "下载完成后会自动整理,也可以在文件管理里选中文件整理。", description: attention
? "识别不到作品、目标冲突或整理失败的记录会出现在这里。"
: filtered
? undefined
: "下载完成后会自动整理,也可以在文件管理里选中文件整理。",
}} }}
/> />
</TabsContent> </TabsContent>
+32
View File
@@ -39,6 +39,9 @@ export function legacyRedirect(pathname: string, search: string): string | null
return "/console/sites"; return "/console/sites";
} }
// 2026-10-06:待处理中心取消,问题回到发生处;旧链接按问题类型落到对应页面
if (path === "/console/inbox") return inboxRedirect(q.get("kind"));
if (!tab) return null; if (!tab) return null;
if (path === "/console/tasks") { if (path === "/console/tasks") {
// FE-6:计划从任务页拆出,回到独立的计划页(?job= 透传) // FE-6:计划从任务页拆出,回到独立的计划页(?job= 透传)
@@ -53,6 +56,30 @@ export function legacyRedirect(pathname: string, search: string): string | null
return null; return null;
} }
/** 整理侧的问题(下载完识别不到、路径冲突、文件被排除、整理卡住 / 失败)→ 整理记录「需要处理」 */
export const ORGANIZE_ATTENTION_PATH = "/console/organize?tab=records&attention=1";
const INBOX_KIND: Record<string, string> = {
identity_ambiguous: ORGANIZE_ATTENTION_PATH,
identity_unresolved: ORGANIZE_ATTENTION_PATH,
path_conflict: ORGANIZE_ATTENTION_PATH,
other: ORGANIZE_ATTENTION_PATH,
approval_required: "/console/agent",
download_failed: "/console/downloads",
download_stalled: "/console/downloads",
episode_mapping: "/console/subscriptions",
metadata_conflict: "/console/library",
source_unreachable: "/console/status#attention",
downloader_offline: "/console/status#attention",
cache_volume_unknown: "/console/status#attention",
space_insufficient: "/console/status#attention",
};
/** 旧待处理链接(?kind=)落到哪;没带类型的去整理记录「需要处理」(真正要人处理的大多在那里) */
export function inboxRedirect(kind: string | null): string {
return (kind && INBOX_KIND[kind]) || ORGANIZE_ATTENTION_PATH;
}
/** 旧链接样例 → 应落到的新地址(测试与冒烟共用) */ /** 旧链接样例 → 应落到的新地址(测试与冒烟共用) */
export const LEGACY_REDIRECTS: { from: string; to: string }[] = [ export const LEGACY_REDIRECTS: { from: string; to: string }[] = [
{ from: "/console/tasks?tab=downloads", to: "/console/downloads" }, { from: "/console/tasks?tab=downloads", to: "/console/downloads" },
@@ -79,6 +106,11 @@ export const LEGACY_REDIRECTS: { from: string; to: string }[] = [
{ from: "/console/identity", to: "/console/library?filter=unidentified" }, { from: "/console/identity", to: "/console/library?filter=unidentified" },
{ from: "/console/identity?tab=external", to: "/console/library?view=identities" }, { from: "/console/identity?tab=external", to: "/console/library?view=identities" },
{ from: "/console/identity?tab=aliases", to: "/console/library?view=aliases" }, { from: "/console/identity?tab=aliases", to: "/console/library?view=aliases" },
{ from: "/console/inbox", to: "/console/organize?tab=records&attention=1" },
{ from: "/console/inbox?kind=identity_unresolved", to: "/console/organize?tab=records&attention=1" },
{ from: "/console/inbox?kind=approval_required", to: "/console/agent" },
{ from: "/console/inbox?kind=downloader_offline", to: "/console/status#attention" },
{ from: "/console/inbox?kind=episode_mapping", to: "/console/subscriptions" },
]; ];
/** 路由模块的 clientLoader:命中旧链接就跳转 */ /** 路由模块的 clientLoader:命中旧链接就跳转 */
+6
View File
@@ -22,6 +22,7 @@ import { KvList } from "~/components/console/kv-list.tsx";
import { PageHeader } from "~/components/console/page-header.tsx"; import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx"; import { TechInfo } from "~/components/console/tech-info.tsx";
import { AgentSpendCard, AttentionCard } from "~/components/status/attention-cards.tsx";
import { HistoryChart } from "~/components/status/history-chart.tsx"; import { HistoryChart } from "~/components/status/history-chart.tsx";
import { StorageSection } from "~/components/status/storage-section.tsx"; import { StorageSection } from "~/components/status/storage-section.tsx";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
@@ -33,6 +34,7 @@ import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
export const meta = () => [{ title: "运行状态 · UHD 媒体自动化控制台" }]; export const meta = () => [{ title: "运行状态 · UHD 媒体自动化控制台" }];
// 运行状态(W9-I):进程心跳、事件循环、SSE、DB、磁盘、挂载根、引擎与定时任务;存储节(FIX-5)见 storage-section.tsx。阈值见 docs/runbook/ops.md。 // 运行状态(W9-I):进程心跳、事件循环、SSE、DB、磁盘、挂载根、引擎与定时任务;存储节(FIX-5)见 storage-section.tsx。阈值见 docs/runbook/ops.md。
// 2026-10-06:顶部加「需要关注」汇总(系统健康逐条列出,铃铛点进来落到 #attention)与智能体用量。
const LEVEL: Record<HealthLevel, { label: string; tone: StatusTone }> = { const LEVEL: Record<HealthLevel, { label: string; tone: StatusTone }> = {
ok: { label: "正常", tone: "success" }, ok: { label: "正常", tone: "success" },
@@ -511,6 +513,10 @@ export default function StatusPage() {
) : d ? ( ) : d ? (
<div className="space-y-4"> <div className="space-y-4">
<Banner d={d} /> <Banner d={d} />
<div className="grid gap-4 md:grid-cols-2">
<AttentionCard />
<AgentSpendCard />
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card title="数据库" icon={Database} level={d.db.level}> <Card title="数据库" icon={Database} level={d.db.level}>
{d.db.error ? <p className="mb-2 text-caption text-destructive">{d.db.error}</p> : null} {d.db.error ? <p className="mb-2 text-caption text-destructive">{d.db.error}</p> : null}
+20
View File
@@ -352,3 +352,23 @@ W10 已补(2026-09-28):
- `/engine/runs?status=running,pending` 多值;"进行中"页签改为一次查询 + 游标分页,不再两次各取 100 条合并。 - `/engine/runs?status=running,pending` 多值;"进行中"页签改为一次查询 + 游标分页,不再两次各取 100 条合并。
仍未补:定时任务的运行记录(`scheduled_job_run`)没有对应引擎 run id,卡片与任务之间只能按 `business.kind=schedule` + key 关联;feeds.poll 这类只发事件的定时任务,触发的任务记在 Feed 名下。 仍未补:定时任务的运行记录(`scheduled_job_run`)没有对应引擎 run id,卡片与任务之间只能按 `business.kind=schedule` + key 关联;feeds.poll 这类只发事件的定时任务,触发的任务记在 Feed 名下。
## 8. 问题回到发生处(2026-10-06,取消待处理中心)
用户决定:待处理中心「鸡肋」。优先自动解决;真正要人处理的主要是「下载完识别不到」,在整理记录里就地处理。
`decision` 表保留为内部机制(去重、触发智能体调查、自动关闭),不再作为逐条处理的页面。前面各节里关于「待处理」页、
「判定只在待处理」、顶栏收件箱按问题类型列事项的约定以本节为准。
| 问题 | 去向 |
| --- | --- |
| 下载完识别不到、路径冲突、文件被排除、整理卡住 / 失败 | 整理记录「需要处理」筛选,行内写原因与推荐动作(指定作品 / 重新整理 / 让智能体识别 / 忽略),一键执行;识别不到时缺省自动派一次智能体识别 |
| 集数待确认 | 先自动解决(确定性换算、剧集组、智能体写规则);解决不了的原因在订阅详情与作品页集数映射里按组显示,不开给用户逐条处理的条目 |
| 下载失败 / 卡住 | 下载面板行内状态 + 智能体授权内自动恢复 |
| 站点不可达、下载器离线、下载目录读不到容量、磁盘空间不足、媒体库目录离线 | 运行状态「需要关注」+ 铃铛,恢复后自动消失 |
| 需要你批准的智能体操作(会话审批、方案选择、计划审批、原站页面等你接手) | 铃铛 |
| 搜索 / RSS 扫进来的未识别资源 | 静默留在媒体库「未识别」,不提示;要处理的在那里选候选或手动指定 |
- 侧栏没有「待处理」;`/console/inbox?kind=` 按问题类型跳到上表对应页面(`redirects.ts inboxRedirect`,没带类型去整理记录「需要处理」)。
- 铃铛(`inbox-bell.tsx`)只放两组:等你批准、系统健康;数据来自 `GET /attention`。
- 首页「要我处理」与运行状态「需要关注」(`#attention`)放汇总计数(「3 条整理需要处理」「1 个站点不可达」),点进去到对应页面;只列非零项。
- 运行状态另有「智能体用量」(今天 / 最近 7 天按会话类型的次数与花费);智能体不设次数和花费上限。
+1 -1
View File
@@ -20,7 +20,7 @@
| 站点列表/搜索结果的一次抓取 | 抓取;时间写"上次抓取",按钮"刷新";站点实时搜索叫"去站点搜" | 快照、实时补查 | | 站点列表/搜索结果的一次抓取 | 抓取;时间写"上次抓取",按钮"刷新";站点实时搜索叫"去站点搜" | 快照、实时补查 |
| SourceRelease | 资源 | 来源资源、站点资源、种子(种子只指 .torrent 文件) | | SourceRelease | 资源 | 来源资源、站点资源、种子(种子只指 .torrent 文件) |
| 资源按收录版本评估后 | 不单独命名;说"可下载的资源" | 候选(只在代码与给模型的说明里) | | 资源按收录版本评估后 | 不单独命名;说"可下载的资源" | 候选(只在代码与给模型的说明里) |
| Decision | 待处理事项;入口叫"待处理" | 决策、工单、待决 | | Decision | 内部机制(2026-10-06 起界面不再有「待处理」入口):整理侧问题叫「需要处理」(整理记录筛选),铃铛两组叫「等你批准」「系统健康」 | 决策、工单、待决、待处理(界面入口) |
| Approval | 审批(名词);按钮用"批准 / 拒绝" | 授权记录 | | Approval | 审批(名词);按钮用"批准 / 拒绝" | 授权记录 |
| AcquisitionPlan(下载计划,DL-3 起界面不单独命名) | 下载;还没提交到下载器的按在等什么写状态:「等你批准」「等磁盘空间」「等开启自动化」「准备提交」;没提交就结束的写「没提交成功 / 已取消 / 已过期」+ 原因;来由写「为什么下这个」;智能体动作计数写"准备下载 N 个资源" | 下载计划、草稿、已预留(界面) | | AcquisitionPlan(下载计划,DL-3 起界面不单独命名) | 下载;还没提交到下载器的按在等什么写状态:「等你批准」「等磁盘空间」「等开启自动化」「准备提交」;没提交就结束的写「没提交成功 / 已取消 / 已过期」+ 原因;来由写「为什么下这个」;智能体动作计数写"准备下载 N 个资源" | 下载计划、草稿、已预留(界面) |
| DownloadTask | 下载任务 | 下载单 | | DownloadTask | 下载任务 | 下载单 |