- library list (kind/status/search, cursor paging) and detail: paths and roots, external identity mapping with lock, field-level metadata sources and locks, slot cards with rejection reasons, content units (provisional flag), downloads with verification; remap/lock/refresh/merge actions - subscriptions: list/create/update, slot rules editor (hard/soft/upgrade/completion), policy revision draft -> preview -> publish (grant warning) -> rollback - preferences: tag CRUD, disable/enable, suppression records and lift, history timeline - inbox: grouped by entity and kind, evidence/suggestion/impact/preview, resolve options, SSE refresh - identity: pool assign/dismiss/retry, external identity revisions and lock, title alias management - all responses validated against contracts zod schemas Co-Authored-By: Claude Opus 5.5 <[email protected]>
399 lines
15 KiB
TypeScript
399 lines
15 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
|
import type { Decision, DecisionKind, DecisionOption, DecisionStatus } from "@umt/contracts";
|
|
import { BellOff, Bot, CheckCheck, ChevronRight, Inbox, Radio, RotateCw } from "lucide-react";
|
|
import { type ReactNode, 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 { 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 { 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 { Textarea } from "~/components/ui/textarea.tsx";
|
|
import { errorMessage } from "~/lib/api.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;
|
|
|
|
/** 证据里约定俗成的字段(Agent/后端写入);其余原样放进"原始证据" */
|
|
const str = (v: unknown) => (typeof v === "string" && v.trim() ? v : 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<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" />
|
|
Agent 已检查
|
|
</span>
|
|
{checked.map((c) => (
|
|
<Chip key={c}>{c}</Chip>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
<JsonView label="原始证据" value={e} defaultOpen={false} maxHeight="14rem" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
|
|
const resolve = useResolveDecision();
|
|
const [note, setNote] = useState("");
|
|
return (
|
|
<ConfirmDialog
|
|
trigger={
|
|
<Button variant="outline" size="sm">
|
|
{option.label}
|
|
</Button>
|
|
}
|
|
title={option.label}
|
|
description={
|
|
<>
|
|
{option.description ? `${option.description}。` : ""}
|
|
{option.capabilityId
|
|
? `将以能力 ${option.capabilityId} 执行,仍经授权校验。`
|
|
: "处理结果会记录并通知相关任务。"}
|
|
</>
|
|
}
|
|
confirmLabel="确认"
|
|
onConfirm={() =>
|
|
resolve.mutateAsync({ id: d.id, optionKey: option.key, note: note.trim() || undefined }).then(
|
|
() => toast.success(`已处理:${option.label}`),
|
|
(err) => {
|
|
toast.error(errorMessage(err, "处理失败"));
|
|
throw err;
|
|
},
|
|
)
|
|
}
|
|
>
|
|
<Textarea value={note} onChange={(e) => setNote(e.target.value)} placeholder="备注(可选)" rows={2} />
|
|
</ConfirmDialog>
|
|
);
|
|
}
|
|
|
|
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 options = d.options.filter((o) => o.key !== "dismiss");
|
|
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">
|
|
<Tone v={SEVERITY[d.severity]} />
|
|
<Tone v={DECISION_STATUS[d.status]} />
|
|
<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">{d.summary}</p> : null}
|
|
<div className="mt-3">
|
|
<Evidence d={d} />
|
|
</div>
|
|
{open ? (
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
{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"
|
|
>
|
|
Agent 调查记录
|
|
<ChevronRight className="h-3 w-3" />
|
|
</Link>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<p className="mt-3 text-meta text-muted-foreground">
|
|
{d.resolvedBy ? `${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; 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 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);
|
|
const list = useDecisions({ status: status || undefined, kind: kind || undefined });
|
|
const rows = list.data?.pages.flatMap((p) => p.items);
|
|
|
|
// 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 rows ?? []) {
|
|
const entityId = entityOf(d);
|
|
const key = `${entityId ?? `subject:${d.subjectType ?? "none"}`}|${d.kind}`;
|
|
const g = m.get(key) ?? { key, entityId, 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;
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="待处理中心"
|
|
description="Agent 先在授权内调查与有限重试,解决不了的才进这里;同一作品同类问题合并展示"
|
|
actions={
|
|
<>
|
|
<span className="hidden items-center gap-1.5 text-meta text-muted-foreground sm:inline-flex">
|
|
<Radio className="h-3.5 w-3.5" />
|
|
{live === "open" ? "实时" : live === "reconnecting" ? "重连中" : "连接中"}
|
|
</span>
|
|
<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()} />
|
|
) : (
|
|
<div className="mb-4 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
|
<StatTile
|
|
label={isOpenView ? "未关闭" : (DECISION_STATUS[status as DecisionStatus]?.label ?? "全部")}
|
|
value={summary.isPending ? "…" : (summary.data?.total ?? 0)}
|
|
hint={isOpenView ? "待处理 + 调查中 + 等你决定" : undefined}
|
|
icon={Inbox}
|
|
active={!kind}
|
|
onClick={() => set("kind", "")}
|
|
/>
|
|
{byKind.map(([k, v]) => (
|
|
<StatTile
|
|
key={k}
|
|
label={DECISION_KIND[k]}
|
|
value={v.count}
|
|
hint={`最高 ${SEVERITY[v.sev].label}`}
|
|
tone={SEVERITY[v.sev].tone}
|
|
active={kind === k}
|
|
onClick={() => set("kind", kind === k ? "" : k)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<FilterBar onReset={status || kind ? () => 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)} />
|
|
</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 ? "系统在正常推进目标;新的待决定事项会实时出现在这里。" : "换个状态或类型看看。"}
|
|
/>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{groups.map((g) => {
|
|
const title = g.entityId ? titles.get(g.entityId) : null;
|
|
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 border-b border-border/60 px-4 py-3 sm:px-5">
|
|
<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>
|
|
) : (
|
|
<span className="text-body font-semibold text-muted-foreground">
|
|
{g.kind === "identity_ambiguous" ? "尚未归属作品" : "系统级"}
|
|
</span>
|
|
)}
|
|
<span className="ml-auto text-meta text-muted-foreground">{g.items.length} 项</span>
|
|
</header>
|
|
{g.items.map((d) => (
|
|
<DecisionCard key={d.id} d={d} />
|
|
))}
|
|
</section>
|
|
);
|
|
})}
|
|
{list.hasNextPage ? (
|
|
<div className="flex justify-center">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => list.fetchNextPage()}
|
|
disabled={list.isFetchingNextPage}
|
|
>
|
|
{list.isFetchingNextPage ? "加载中…" : "加载更多"}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|