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 ? ( ) : 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}