feat: expand intelligent investigation workspace
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent ddd03dbff6
commit c0e4231b83
223 changed files with 13155 additions and 5304 deletions
@@ -11,7 +11,7 @@ export const investigationDiagramConfig: MermaidConfig = {
fontFamily: INVESTIGATION_DIAGRAM_FONT,
themeVariables: {
fontFamily: INVESTIGATION_DIAGRAM_FONT,
fontSize: "15px",
fontSize: "18px",
background: "#FFFFFF",
primaryColor: "#FFFFFF",
primaryTextColor: "#26334D",
@@ -28,6 +28,7 @@ export const investigationDiagramConfig: MermaidConfig = {
clusterBorder: "#CCD6E5"
},
flowchart: {
useMaxWidth: false,
curve: "stepAfter",
nodeSpacing: 56,
rankSpacing: 64,
@@ -42,10 +43,10 @@ export const investigationDiagramConfig: MermaidConfig = {
.nodeLabel { font-weight: 500; }
.nodeLabel p { text-wrap: balance; line-height: 1.6; }
.edgePath path, .flowchart-link { stroke-width: 1.35px; }
.edgeLabel { color: #536784; font-size: 12px; }
.edgeLabel { color: #536784; font-size: 15px; }
.edgeLabel p { padding: 3px 7px; border-radius: 4px; line-height: 1.5; }
.cluster rect { rx: 8px; ry: 8px; stroke-width: 1px; }
.cluster-label { font-weight: 600; font-size: 13px; }
.cluster-label { font-weight: 600; font-size: 16px; }
.cluster-label .nodeLabel { background: #F6F8FC; padding: 0 4px; }
`
};
@@ -108,15 +109,60 @@ export const investigationDiagramPlugins = {
}
};
const roleStyles = [
"classDef tjAgent fill:#F6F8FC,stroke:#A5B5CC,color:#26334D,stroke-width:1.4px;",
"classDef tjHuman fill:#FFF6E8,stroke:#C4A168,color:#735018,stroke-width:1.4px;",
"classDef tjDecision fill:#F6F8FC,stroke:#A5B5CC,color:#26334D,stroke-width:1.5px;",
"classDef tjEvidence fill:#EDF7F4,stroke:#6B9E8B,color:#2D5E4E,stroke-width:1.6px;",
"classDef tjAction fill:#FFF6E8,stroke:#C4A168,color:#735018,stroke-width:1.6px;"
];
export const investigationDiagramStates = {
pending: { className: "tjPending", label: "尚未开始", fill: "#F6F8FC", stroke: "#A5B5CC", color: "#475976", width: 1.5, dash: "0" },
running: { className: "tjRunning", label: "正在执行", fill: "#E8F0FF", stroke: "#4263C7", color: "#284574", width: 2.5, dash: "0" },
completed: { className: "tjCompleted", label: "已完成", fill: "#EDF7F4", stroke: "#6B9E8B", color: "#2D5E4E", width: 1.8, dash: "0" },
waiting: { className: "tjWaiting", label: "等待依赖 / 外部结果", fill: "#FFFBEA", stroke: "#C8AC60", color: "#78531B", width: 1.8, dash: "6 3" },
waiting_human: { className: "tjWaitingHuman", label: "等待人工处理", fill: "#FFF6E8", stroke: "#C4A168", color: "#735018", width: 2, dash: "0" },
failed: { className: "tjFailed", label: "执行失败", fill: "#FEF2F2", stroke: "#D96C75", color: "#922C3E", width: 2, dash: "0" },
skipped: { className: "tjSkipped", label: "已跳过", fill: "#F6F8FC", stroke: "#A5B5CC", color: "#64748B", width: 1.5, dash: "8 4" },
cancelled: { className: "tjCancelled", label: "已取消", fill: "#F1F5F9", stroke: "#94A3B8", color: "#64748B", width: 1.5, dash: "2 3" },
recorded_running: { className: "tjRecordedRunning", label: "记录时执行中", fill: "#F6F8FC", stroke: "#A5B5CC", color: "#475976", width: 1.5, dash: "6 3" },
unknown: { className: "tjUnknown", label: "未标注状态", fill: "#FFFFFF", stroke: "#CCD6E5", color: "#536784", width: 1.4, dash: "0" }
} as const;
/** Style supplied relationships; never create nodes from a dangling display reference. */
export function investigationNodeState(
node: InvestigationDiagram["node_refs"][number],
running = false
): keyof typeof investigationDiagramStates {
if (node.status === "running" && !running) return "recorded_running";
return node.status ?? "unknown";
}
function nodeStyle(state: typeof investigationDiagramStates[keyof typeof investigationDiagramStates]) {
return `fill:${state.fill},stroke:${state.stroke},color:${state.color},stroke-width:${state.width}px,stroke-dasharray:${state.dash}`;
}
export const investigationDiagramNodeId = (node: Element | null | undefined) =>
node?.getAttribute("data-id") ?? node?.id.match(/(?:^|-)flowchart-(.+)-\d+$/)?.[1];
/** Update state-only changes in place, preserving the reader's pan and zoom. */
export function paintInvestigationDiagramStates(root: Element, diagram: InvestigationDiagram, running: boolean) {
const refs = new Map(diagram.node_refs.map((node) => [node.node_id, node]));
const classes = Object.values(investigationDiagramStates).map((state) => state.className);
root.querySelectorAll<SVGGElement>("svg g.node").forEach((node) => {
const ref = refs.get(investigationDiagramNodeId(node) ?? "");
const state = investigationDiagramStates[ref ? investigationNodeState(ref, running) : "unknown"];
node.classList.remove(...classes);
node.classList.add(state.className);
node.setAttribute("aria-label", `${ref?.label ?? node.textContent?.trim() ?? "节点"}:${state.label}`);
for (const shape of Array.from(node.children)) {
if (!shape.matches("rect, polygon, path, circle, ellipse")) continue;
const style = (shape as SVGElement).style;
style.setProperty("fill", state.fill, "important");
style.setProperty("stroke", state.stroke, "important");
style.setProperty("stroke-width", `${state.width}px`, "important");
style.setProperty("stroke-dasharray", state.dash, "important");
}
node.querySelectorAll<SVGElement | HTMLElement>(".nodeLabel, .nodeLabel p").forEach((label) => {
label.style.setProperty("color", state.color, "important");
label.style.setProperty("fill", state.color, "important");
});
});
}
/** Color explicit recorded states; relationships and evidence never imply completion. */
export function styleInvestigationDiagram(diagram: InvestigationDiagram, running = false) {
const assignments = diagram.node_refs.flatMap((node) => {
const escaped = node.node_id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -124,14 +170,15 @@ export function styleInvestigationDiagram(diagram: InvestigationDiagram, running
diagram.mermaid
);
if (!inSource) return [];
if (running && node.status === "running") return [`class ${node.node_id} tjRunning;`];
return node.action_refs.length
? [`class ${node.node_id} tjAction;`]
: node.evidence_refs.length
? [`class ${node.node_id} tjEvidence;`]
: [];
const state = investigationDiagramStates[investigationNodeState(node, running)];
return [`class ${node.node_id} ${state.className};`, `style ${node.node_id} ${nodeStyle(state)};`];
});
return [diagram.mermaid, ...roleStyles,
running ? "classDef tjRunning fill:#E8F0FF,stroke:#4263C7,color:#284574,stroke-width:2.5px;" : "classDef tjRunning fill:#F6F8FC,stroke:#A5B5CC,color:#26334D,stroke-width:1.4px;",
...assignments].join("\n");
// Keep role/reference classes neutral. Their presence conveys association,
// while only node_refs.status supplies an execution fact.
const roles = ["default", "tjAgent", "tjHuman", "tjDecision", "tjEvidence", "tjAction"]
.map((name) => `classDef ${name} ${nodeStyle(investigationDiagramStates.unknown)};`);
const states = Object.entries(investigationDiagramStates).map(([key, state]) =>
`classDef ${state.className} ${nodeStyle(key === "running" && !running ? investigationDiagramStates.recorded_running : state)};`
);
return [diagram.mermaid, ...roles, ...states, ...assignments].join("\n");
}