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

+14 -28
View File
@@ -16,8 +16,8 @@ import type { ReactNode } from "react";
import { useRef } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useAttention } from "~/api/attention.ts";
import { useDashboard, useSitesWithAccounts } from "~/api/dashboard.ts";
import { useDecisions } from "~/api/decisions.ts";
import { useEngineRunsPage } from "~/api/engine.ts";
import { useUpdateSettings } from "~/api/settings.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 { consolePath, settingsPath } from "~/components/console/nav.ts";
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 { runTitle } from "~/components/tasks/labels.ts";
import { useLiveTasks } from "~/components/tasks/task-stream.tsx";
import { Button } from "~/components/ui/button.tsx";
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 { useSse } from "~/lib/sse.ts";
import { cn } from "~/lib/utils.ts";
// 首页概览:自动化、存储与下载器状态,以及当前工作、待处理事项和最近入库。
// 首页概览:自动化、存储与下载器状态,以及当前工作、要我处理的汇总计数(整理需要处理 / 等你批准 / 系统健康)和最近入库。
/** 首页关心的事件:任意一个到达就(节流)刷新聚合 */
const DASHBOARD_EVENTS = [
@@ -257,33 +258,18 @@ function Busy({ w }: { w: Dashboard["currentWork"] }) {
);
}
// ─── 要我处理什么:计数 + 前 3 条 ───
function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
const list = useDecisions({});
const top = (list.data?.pages[0]?.items ?? []).slice(0, 3);
// ─── 要我处理什么:汇总计数(整理需要处理、等你批准、系统健康),点进去到对应页面 ───
function NeedsMe() {
const q = useAttention();
const rows = attentionRows(q.data);
return (
<Section title="要我处理" icon={AlertTriangle} to={consolePath("inbox")}>
{n.total === 0 ? (
<Section title="要我处理" icon={AlertTriangle}>
{q.isPending ? (
<LoadingState rows={2} />
) : rows.length === 0 ? (
<EmptyState plain icon={CircleCheck} title="没有要你处理的事" className="py-6" />
) : (
<>
<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>
</>
<AttentionList rows={rows} />
)}
</Section>
);
@@ -350,7 +336,7 @@ export default function Home() {
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Busy w={q.data.currentWork} />
<NeedsMe n={q.data.needsAttention} />
<NeedsMe />
<RecentImports d={q.data} />
</div>
)}
+6 -637
View File
@@ -1,640 +1,9 @@
import { useQueryClient } from "@tanstack/react-query";
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";
import { legacyRedirectLoader } from "./redirects.ts";
// 待处理(REQ §13.1):按问题类型 + 作品 / 来源合并并可折叠;每项给证据、建议、影响范围与预览;
// 处理选项来自后端 decision.options;SSE 收到 decision.* 事件即刷新。
// 缺省只看需要你处理的事项(警告及以上),"仅信息"类在"级别"筛选里展开。身份类事项后端已按来源 + 标题合并,
// 卡片里列出组内资源,选候选 / 指定 / 忽略对整组生效。
// 2026-10-06:待处理中心取消,问题回到发生处(整理记录「需要处理」、下载面板、运行状态、订阅详情);
// 旧链接 /console/inbox?kind= 按问题类型跳到对应页面(redirects.ts inboxRedirect)
export const clientLoader = legacyRedirectLoader;
const OPEN: DecisionStatus[] = ["open", "investigating", "awaiting_user"];
const STATUS_FILTER = [
{ 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}
</>
);
export default function LegacyRedirect() {
return null;
}
+2 -15
View File
@@ -1,5 +1,5 @@
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 { Link, useNavigate, useSearchParams } from "react-router";
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 { StatusBadge } from "~/components/console/status-badge.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 { ENTITY_STATUS, MEDIA_KIND, opts, SLOT_STATUS, SLOT_STATUS_ORDER } from "~/components/library/labels.ts";
import { CoverPlaceholder, relTime, Tone } from "~/components/library/parts.tsx";
@@ -60,19 +59,7 @@ export default function LibraryPage() {
const view = viewOf(sp);
return (
<>
<PageHeader
title="媒体库"
actions={
view === "unidentified" ? (
<Button asChild variant="outline" size="sm">
<Link to={INBOX_IDENTITY}>
<Inbox />
确认身份
</Link>
</Button>
) : undefined
}
/>
<PageHeader title="媒体库" />
<Tabs value={view} onValueChange={(v) => setSp(new URLSearchParams(VIEW_QUERY[v as View]), { replace: true })}>
<TabsList className="mb-4">
<TabsTrigger value="works">作品</TabsTrigger>
@@ -13,6 +13,7 @@ import { TechInfo } from "~/components/console/tech-info.tsx";
import { CoverPlaceholder } from "~/components/library/parts.tsx";
import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.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 { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
import { Button } from "~/components/ui/button.tsx";
@@ -219,6 +220,12 @@ export default function OrganizeRecordPage() {
{reasonLine(r.reason)}
</p>
) : 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">
<PathBlock label="整理前" p={r.source} />
<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 { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx";
import { OrganizeQueue } from "~/components/organize/organize-queue.tsx";
import { attentionLine, RecordAttentionActions } from "~/components/organize/record-attention.tsx";
import {
canReorganize,
episodeRange,
@@ -62,6 +63,8 @@ import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
// 按状态 / 类型 / 来源 / 时间 / 关键字(支持 * ? 通配、种子 hash)筛选;按状态计数;
// 缺省按作品分组(一季十集收成一组,组头写季集范围、条数和非成功状态;只有一条的不分组),切换后本浏览器记住;
// 单条与批量的重新整理(走整理向导)和删除(可选源文件 / 媒体库文件进回收站)。
// 需要处理(2026-10-06 去掉待处理中心):下载完识别不到、目标冲突、被排除、卡住 / 失败的记录在这里就地处理,
// 「需要处理」筛选只看这些;行内写原因与推荐动作(指定作品 / 重新整理 / 让智能体识别 / 忽略),一键执行。
type Rec = PublicOrganizationRecord;
@@ -80,11 +83,11 @@ function sinceOf(t: string): string | undefined {
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);
if (r.status === "succeeded" && !r.reason) return <span className="text-muted-foreground">成功</span>;
const line = reasonLine(r.reason);
if (r.status === "succeeded" && !r.reason && !r.attention) return <span className="text-muted-foreground">成功</span>;
const line = attentionLine(r) ?? reasonLine(r.reason);
return (
<div className="flex flex-col items-start gap-0.5">
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
@@ -95,10 +98,15 @@ function StatusCell({ r }: { r: Rec }) {
</span>
) : null}
{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}
</span>
) : null}
{r.attention && onReorganize ? (
<div className="mt-1">
<RecordAttentionActions r={r} onReorganize={onReorganize} compact />
</div>
) : null}
</div>
);
}
@@ -271,14 +279,18 @@ function StatsLine({
filters,
status,
onStatus,
attention,
onAttention,
}: {
filters: Omit<RecordFilters, "status">;
status: string;
onStatus: (s: string) => void;
attention: boolean;
onAttention: (on: boolean) => void;
}) {
const stats = useRecordStats(filters);
const stats = useRecordStats({ ...filters, attention: undefined });
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 }[] = [
{ key: "succeeded", label: "成功", n: s.succeeded },
{ key: "failed", label: "失败", n: s.failed, tone: "text-destructive" },
@@ -298,6 +310,19 @@ function StatsLine({
>
共 {s.total} 条
</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
.filter((p) => p.n > 0)
.map((p) => (
@@ -321,11 +346,21 @@ function StatsLine({
export default function OrganizePage() {
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 [qInput, setQInput] = useState(v.q);
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 setTab = (next: string) =>
setParams(
@@ -351,7 +386,8 @@ export default function OrganizePage() {
// 时间筛选换成具体时刻只在切换时算一次,避免每次渲染查询键都变
const since = useMemo(() => sinceOf(v.time), [v.time]);
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,
trigger: (v.trigger || undefined) as OrganizationTrigger | undefined,
kind: (v.kind || undefined) as RecordFilters["kind"],
@@ -376,7 +412,7 @@ export default function OrganizePage() {
resetKey: JSON.stringify({ ...base, status, ids }),
});
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 [sel, setSel] = useState<Set<string>>(new Set());
@@ -477,9 +513,9 @@ export default function OrganizePage() {
.filter(Boolean)
.join(" · ")}
</p>
{r.status !== "succeeded" || r.reason ? (
{r.status !== "succeeded" || r.reason || r.attention ? (
<div className="mt-1 sm:hidden">
<StatusCell r={r} />
<StatusCell r={r} onReorganize={() => reorganize([r])} />
</div>
) : null}
</div>
@@ -500,7 +536,12 @@ export default function OrganizePage() {
</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",
header: "来源",
@@ -639,7 +680,15 @@ export default function OrganizePage() {
</Button>
) : null}
</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 ? (
<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>
@@ -692,8 +741,12 @@ export default function OrganizePage() {
}
empty={{
icon: FileStack,
title: filtered ? "没有符合筛选的整理记录" : "还没有整理记录",
description: filtered ? undefined : "下载完成后会自动整理,也可以在文件管理里选中文件整理。",
title: attention ? "没有需要处理的整理记录" : filtered ? "没有符合筛选的整理记录" : "还没有整理记录",
description: attention
? "识别不到作品、目标冲突或整理失败的记录会出现在这里。"
: filtered
? undefined
: "下载完成后会自动整理,也可以在文件管理里选中文件整理。",
}}
/>
</TabsContent>
+32
View File
@@ -39,6 +39,9 @@ export function legacyRedirect(pathname: string, search: string): string | null
return "/console/sites";
}
// 2026-10-06:待处理中心取消,问题回到发生处;旧链接按问题类型落到对应页面
if (path === "/console/inbox") return inboxRedirect(q.get("kind"));
if (!tab) return null;
if (path === "/console/tasks") {
// FE-6:计划从任务页拆出,回到独立的计划页(?job= 透传)
@@ -53,6 +56,30 @@ export function legacyRedirect(pathname: string, search: string): string | 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 }[] = [
{ 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?tab=external", to: "/console/library?view=identities" },
{ 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:命中旧链接就跳转 */
+6
View File
@@ -22,6 +22,7 @@ import { KvList } from "~/components/console/kv-list.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.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 { StorageSection } from "~/components/status/storage-section.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 媒体自动化控制台" }];
// 运行状态(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 }> = {
ok: { label: "正常", tone: "success" },
@@ -511,6 +513,10 @@ export default function StatusPage() {
) : d ? (
<div className="space-y-4">
<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">
<Card title="数据库" icon={Database} level={d.db.level}>
{d.db.error ? <p className="mb-2 text-caption text-destructive">{d.db.error}</p> : null}