import { useQueryClient } from "@tanstack/react-query";
import type { Decision, DecisionKind, DecisionOption, DecisionStatus } from "@umt/contracts";
import { BellOff, Bot, CheckCheck, ChevronDown, ChevronRight, Inbox, Radio, RotateCw } 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 { StatTile } from "~/components/console/stat-tile.tsx";
import { StatusBadge } from "~/components/console/status-badge.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 { 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";
import { actorText } from "~/lib/labels.ts";
import { useSse, useSseStatus } from "~/lib/sse.ts";
import { formatDateTime } from "~/lib/utils.ts";
// 待处理(REQ §13.1):按问题类型 + 作品 / 来源合并并可折叠;每项给证据、建议、影响范围与预览;
// 处理选项来自后端 decision.options;SSE 收到 decision.* 事件即刷新。
// 缺省只看需要你处理的事项(警告及以上),"仅信息"类在"级别"筛选里展开。身份类事项后端已按来源 + 标题合并,
// 卡片里列出组内资源,选候选 / 指定 / 忽略对整组生效。
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 (
{open ? (
{list.map((r) => (
-
{r.title}
))}
{count > list.length ? (
-
另有 {count - list.length} 条,可在媒体概览 → 待识别按标题检索
) : null}
) : null}
);
}
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 = {
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)
.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 (
{rows.length ? (
{rows.map(([k, v]) => (
- {k}
- {v}
))}
) : null}
{checked.length ? (
Agent 已检查
{checked.map((c) => (
{c}
))}
) : 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;
}
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(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 (
{option.label}
}
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" ? (
setTmdbId(e.target.value)}
aria-invalid={tmdbId.trim() !== "" && !tmdbOk}
/>
) : requires === "mediaEntityId" ? (
) : null}
);
}
function DismissButton({ d }: { d: Decision }) {
const resolve = useResolveDecision();
return (
忽略
}
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 options = d.options.filter((o) => o.key !== "dismiss");
const resolution = d.resolution as { optionKey?: string | null; note?: string | null } | null;
return (
{relTime(d.createdAt)}
{d.attempts ? ` · 调查 ${d.attempts} 次` : ""}
{d.title}
{d.summary ? {d.summary}
: null}
{open ? (
{options.map((o) => (
))}
{d.agentRunId ? (
Agent 调查记录
) : null}
) : (
{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}` : ""}
)}
);
}
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);
// SSE:decision.created / decision.updated → 刷新(合并 500ms 内的多条事件)
const qc = useQueryClient();
const timer = useRef | 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();
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();
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();
for (const d of rows ?? []) {
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);
}, [rows]);
const isOpenView = !status;
// 折叠状态:缺省前 OPEN_GROUPS 组展开;用户点过的组按用户的来
const [toggled, setToggled] = useState>({});
const isExpanded = (key: string, index: number) => toggled[key] ?? index < OPEN_GROUPS;
return (
<>
{live === "open" ? "实时" : live === "reconnecting" ? "重连中" : "连接中"}
>
}
/>
{summary.isError ? (
summary.refetch()} />
) : (
set("kind", "")}
/>
{byKind.map(([k, v]) => (
set("kind", kind === k ? "" : k)}
/>
))}
)}
setSp(new URLSearchParams(), { replace: true }) : undefined}>
set("status", v)}
options={STATUS_FILTER}
allLabel="未关闭"
/>
set("kind", v)} options={opts(DECISION_KIND)} />
set("sev", v)} options={SEV_FILTER} allLabel="需处理" />
{list.isPending ? (
) : list.isError && !rows ? (
list.refetch()} />
) : groups.length === 0 ? (
) : (
{groups.map((g, index) => {
const title = g.entityId ? titles.get(g.entityId) : null;
const expanded = isExpanded(g.key, index);
const maxSev = g.items.reduce
(
(a, d) => (SEV_RANK[d.severity] > SEV_RANK[a] ? d.severity : a),
"info",
);
return (
{DECISION_KIND[g.kind]}
{g.entityId ? (
{title ?? "关联作品"}
) : g.source ? (
来源「{g.source.siteName ?? "未知站点"}」
) : (
{g.kind === "identity_ambiguous" || g.kind === "identity_unresolved" ? "尚未归属作品" : "系统级"}
)}
{g.items.length} 项
{expanded ? g.items.map((d) => ) : null}
);
})}
{list.hasNextPage ? (
) : null}
)}
>
);
}