fix(admin): 任务下钻按真实轨迹字段渲染 + 修评测页白屏

docker 起来后拿真库验证,上一版有两处只有真数据才暴露的问题:

1. 轨迹字段名猜错了。实际是 seq/ts/node/kind/phase/label/detail,我写的是
   type/msg/at。更糟的是兜底判断用了 !e.msg && !e.node —— node 恰好存在,
   兜底不触发,结果会渲染出一列几乎空白的行。现按真实字段渲染:label 作
   标题、detail 作正文、kind 上色(system/model/tool/agent/plan/section/
   render/approval 八种)、phase=error 整行标红、ts 转本地时间。

2. 评测页签点开直接白屏。库里 flags 存的是字符串 "null"(不是 "[]"),
   JSON.parse 出来是 null,flags.length 抛 TypeError 把整个控制台打崩。
   `as string[]` 的类型断言骗过了 tsc,单测也覆盖不到——只有点开真数据的
   页面才会炸。改为 Array.isArray 校验。

验证方式:本地起 gateway + 真 Postgres,浏览器里逐个页签点过(轨迹 15 条
真实事件、最终输出、评测 100/100/100、DSL 格式化),并在真库上确认跨租户
读取、jsonb ->> 取 topic、cast(jsonb as text) 取 graph 均正常。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-20 14:20:58 +08:00
parent e4ec74893f
commit 00ad6c8ac0
@@ -9,7 +9,38 @@ import { adminTaskDetail, type AdminTask, type AdminTaskDetail } from "../api";
//
// 不含审批操作:审批是客户端用户的行为(桌面端 ApprovalBar),管理端只做观测。
type ExecEvent = { node?: string; type?: string; msg?: string; at?: string; [k: string]: unknown };
// 字段名以库里真实轨迹为准(不是猜的):seq/ts/node/kind/phase/label/detail。
// ts 是毫秒时间戳;kind ∈ system|model|tool|agent|plan|section|render|approval
// phase ∈ info|start|end|await|error。
type ExecEvent = {
seq?: number;
ts?: number;
node?: string;
kind?: string;
phase?: string;
label?: string;
detail?: string;
[k: string]: unknown;
};
const KIND_TONE: Record<string, string> = {
system: "bg-gray-100 text-gray-600",
model: "bg-violet-50 text-violet-600",
tool: "bg-sky-50 text-sky-600",
agent: "bg-indigo-50 text-indigo-600",
plan: "bg-amber-50 text-amber-700",
section: "bg-emerald-50 text-emerald-600",
render: "bg-teal-50 text-teal-600",
approval: "bg-orange-50 text-orange-600",
};
// 只有 error/await 值得跳出来:前者是失败点,后者是卡在人工审批。
const PHASE_TONE: Record<string, string> = {
error: "text-rose-600 font-medium",
await: "text-orange-600",
};
const hhmmss = (ms?: number) => (ms ? new Date(ms).toLocaleTimeString("zh-CN", { hour12: false }) : "");
export function TaskDetailDrawer({ task, onClose }: { task: AdminTask; onClose: () => void }) {
const [d, setD] = useState<AdminTaskDetail | null>(null);
@@ -31,9 +62,12 @@ export function TaskDetailDrawer({ task, onClose }: { task: AdminTask; onClose:
};
}, [task.task_id]);
// flags 是库里存的 JSON 字符串,实测可能是 "null"(不是 "[]")——JSON.parse 出来是 null
// 不校验就会在 flags.length 上抛 TypeError 把整个控制台白屏掉。必须确认真是数组。
const flags: string[] = (() => {
try {
return d?.eval?.flags ? (JSON.parse(d.eval.flags) as string[]) : [];
const v: unknown = d?.eval?.flags ? JSON.parse(d.eval.flags) : null;
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
} catch {
return [];
}
@@ -104,15 +138,29 @@ export function TaskDetailDrawer({ task, onClose }: { task: AdminTask; onClose:
) : (
<ol className="space-y-2">
{exec.map((e, i) => (
<li key={i} className="rounded-lg border border-gray-100 bg-gray-50/60 p-2.5 text-[11px]">
<li
key={e.seq ?? i}
className={`rounded-lg border p-2.5 text-[11px] ${
e.phase === "error" ? "border-rose-200 bg-rose-50/50" : "border-gray-100 bg-gray-50/60"
}`}
>
<div className="flex items-baseline gap-2">
<span className="shrink-0 text-gray-300">#{i + 1}</span>
{e.node && <span className="font-medium text-gray-700">{e.node}</span>}
{e.type && <span className="rounded bg-white px-1.5 py-0.5 text-[10px] text-violet-600">{e.type}</span>}
{e.at && <span className="ml-auto shrink-0 text-gray-300">{e.at}</span>}
<span className="shrink-0 tabular-nums text-gray-300">#{e.seq ?? i + 1}</span>
{e.label && <span className={`font-medium text-gray-700 ${PHASE_TONE[e.phase ?? ""] ?? ""}`}>{e.label}</span>}
{e.kind && (
<span className={`rounded px-1.5 py-0.5 text-[10px] ${KIND_TONE[e.kind] ?? "bg-gray-100 text-gray-500"}`}>
{e.kind}
</span>
)}
{e.phase && e.phase !== "info" && (
<span className={`text-[10px] ${PHASE_TONE[e.phase] ?? "text-gray-400"}`}>{e.phase}</span>
)}
<span className="ml-auto shrink-0 tabular-nums text-gray-300">{hhmmss(e.ts)}</span>
</div>
{e.msg && <p className="mt-1 whitespace-pre-wrap break-words text-gray-600">{e.msg}</p>}
{!e.msg && !e.node && (
{e.node && <div className="mt-0.5 font-mono text-[10px] text-gray-400">{e.node}</div>}
{e.detail && <p className="mt-1 whitespace-pre-wrap break-words text-gray-600">{e.detail}</p>}
{/* 兜底:字段形状变了也别把线索吞掉(上一版就是猜错字段名,白渲染了一列空行) */}
{!e.label && !e.detail && (
<pre className="mt-1 overflow-x-auto text-[10px] text-gray-500">{JSON.stringify(e)}</pre>
)}
</li>