/** * FlowChart 组件 - 纵向流程图 * * 功能说明: * 1. 主链节点纵向排列(A -> B -> C) * 2. 支持递归展开 sub_goals 和 msgGroup * 3. 弧线连接有子内容的节点,直线连接无子内容的节点 * 4. 点击弧线可以折叠/展开子节点 * 5. 线条粗细根据嵌套层级递减 */ import { useCallback, useEffect, useMemo, useRef, useState, forwardRef, useImperativeHandle } from "react"; import type { ForwardRefRenderFunction } from "react"; import type { Goal } from "../../types/goal"; import type { Edge as EdgeType, Message } from "../../types/message"; import { ArrowMarkers } from "./components/ArrowMarkers"; import styles from "./styles/FlowChart.module.css"; import { Tooltip } from "@douyinfe/semi-ui"; import { ImagePreviewModal } from "../ImagePreview/ImagePreviewModal"; import { extractImagesFromMessage } from "../../utils/imageExtraction"; /** * FlowChart 组件的 Props */ interface FlowChartProps { goals: Goal[]; // 目标节点列表 msgGroups?: Record; // 消息组,key 是 goal_id invalidBranches?: Message[][]; // 失效分支列表 onNodeClick?: (node: Goal | Message, edge?: EdgeType) => void; // 节点点击回调 onSubTraceClick?: (parentGoal: Goal, entry: SubTraceEntry) => void; // 子追踪点击回调 } /** * FlowChart 组件对外暴露的引用接口 */ export interface FlowChartRef { expandAll: () => void; collapseAll: () => void; } /** * 子追踪条目类型 */ export type SubTraceEntry = { id: string; mission?: string }; /** * 布局节点类型 * 用于存储节点的位置、数据和层级信息 */ interface LayoutNode { id: string; // 节点唯一标识 x: number; // X 坐标 y: number; // Y 坐标 data: Goal | Message; // 节点数据 type: "goal" | "subgoal" | "message"; // 节点类型 level: number; // 嵌套层级(0 表示主链节点,1 表示子节点,2 表示孙节点...) parentId?: string; // 父节点 ID isInvalid?: boolean; // 是否为失效节点 } /** * 连接线类型 * 用于存储连接线的信息和状态 */ interface LayoutEdge { id: string; // 连接线唯一标识 source: LayoutNode; // 源节点 target: LayoutNode; // 目标节点 type: "arc" | "line"; // 连接线类型:弧线(有子内容)或直线(无子内容) level: number; // 嵌套层级,用于计算线条粗细 collapsible: boolean; // 是否可折叠 collapsed: boolean; // 是否已折叠 children?: LayoutNode[]; // 折叠时隐藏的子节点列表 isInvalid?: boolean; // 是否为失效连接线 } const FlowChartComponent: ForwardRefRenderFunction = ( { goals, msgGroups = {}, invalidBranches, onNodeClick }, ref, ) => { // 过滤掉有父节点的 goals,只保留主链节点 goals = goals.filter((g) => !g.parent_id); // 确保 goals 中包含 END 节点,如果没有则自动添加 const displayGoals = useMemo(() => { if (!goals) return []; const hasEnd = goals.some((g) => g.id === "END"); if (hasEnd) return goals; const endGoal: Goal = { id: "END", description: "终止", status: "completed", created_at: new Date().toISOString(), reason: "", }; return [...goals, endGoal]; }, [goals]); // SVG 和容器的引用 const svgRef = useRef(null); const containerRef = useRef(null); // 画布尺寸状态 const [dimensions, setDimensions] = useState({ width: 1200, height: 800 }); // 选中的节点 ID const [selectedNodeId, setSelectedNodeId] = useState(null); // 折叠状态管理:使用 Set 存储已折叠的边的 ID const [collapsedEdges, setCollapsedEdges] = useState>(new Set()); // 标记是否已经初始化过折叠状态 const initializedRef = useRef(false); // 平移和缩放相关状态 const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); // 平移偏移量 const [isPanning, setIsPanning] = useState(false); // 是否正在平移 const [zoom, setZoom] = useState(1); // 缩放比例 const panStartRef = useRef({ x: 0, y: 0, originX: 0, originY: 0 }); // 平移起始位置 const zoomRange = { min: 0.6, max: 2.4 }; // 缩放范围 // 视图模式状态:scroll (滚动) 或 panzoom (拖拽缩放) const [viewMode, setViewMode] = useState<"scroll" | "panzoom">("scroll"); // 图片预览状态 const [previewImage, setPreviewImage] = useState(null); // 限制缩放比例在允许范围内 const clampZoom = (value: number) => Math.min(zoomRange.max, Math.max(zoomRange.min, value)); // 重置视图到初始状态 const resetView = () => { setZoom(1); setPanOffset({ x: 0, y: 0 }); }; // 监听容器尺寸变化,更新画布尺寸 useEffect(() => { const element = containerRef.current; if (!element) return; const update = () => { const rect = element.getBoundingClientRect(); const width = Math.max(800, rect.width || 0); const height = Math.max(600, rect.height || 0); setDimensions({ width, height }); }; update(); if (typeof ResizeObserver === "undefined") { window.addEventListener("resize", update); return () => window.removeEventListener("resize", update); } const observer = new ResizeObserver(() => update()); observer.observe(element); return () => observer.disconnect(); }, []); // 处理平移操作 useEffect(() => { if (!isPanning) return; const handleMove = (event: MouseEvent) => { const { x, y, originX, originY } = panStartRef.current; const nextX = originX + (event.clientX - x); const nextY = originY + (event.clientY - y); setPanOffset({ x: nextX, y: nextY }); }; const handleUp = () => { setIsPanning(false); }; window.addEventListener("mousemove", handleMove); window.addEventListener("mouseup", handleUp); return () => { window.removeEventListener("mousemove", handleMove); window.removeEventListener("mouseup", handleUp); }; }, [isPanning]); /** * 计算纵向布局 * * 布局算法说明: * 1. 使用全局 Y 坐标确保所有节点纵向排列 * 2. 递归展开每个主链节点的 sub_goals 和 msgGroup * 3. 子节点在父节点右侧水平偏移,形成层级结构 * 4. 生成两种类型的连接线: * - 弧线:连接有子内容的节点,可折叠/展开 * - 直线:连接无子内容的节点或子节点之间的连接 */ const layoutData = useMemo(() => { if (!displayGoals || displayGoals.length === 0) return { nodes: [], edges: [] }; const nodes: LayoutNode[] = []; // 所有节点列表 const edges: LayoutEdge[] = []; // 所有连接线列表 const NODE_HEIGHT = 110; // 节点间距(纵向) const centerX = dimensions.width / 2; // 主链节点的 X 坐标(居中) const HORIZONTAL_OFFSET = 0; // 子节点水平偏移量 let globalY = 100; // 全局 Y 坐标,确保所有节点纵向排列 /** * 递归展开节点及其子节点 * * @param goal - 当前目标节点 * @param x - X 坐标 * @param level - 嵌套层级(0 表示主链,1 表示子节点,2 表示孙节点...) * @param parentId - 父节点 ID * @returns 包含所有节点、第一个节点和最后一个节点的对象 */ const expandNode = ( goal: Goal, x: number, level: number, parentId?: string, ): { nodes: LayoutNode[]; firstNode: LayoutNode; lastNode: LayoutNode } => { const nodeId = goal.id; // 创建当前节点 const currentNode: LayoutNode = { id: nodeId, x, y: globalY, data: goal, type: "goal", level, parentId, }; const localNodes: LayoutNode[] = [currentNode]; let lastNode = currentNode; // 记录最后一个节点,用于连接到下一个主链节点 globalY += NODE_HEIGHT; // 更新全局 Y 坐标 // 1. 先展开 msgGroup(消息组)- 按照用户需求,消息显示在 sub_goals 之前 const messages = msgGroups[nodeId]; if (messages && messages.length > 0) { messages.forEach((msg) => { const msgNode: LayoutNode = { id: `${nodeId}-msg-${msg.message_id || Math.random()}`, x: x + HORIZONTAL_OFFSET, // 消息节点向右偏移 y: globalY, data: msg, type: "message", level: level + 1, parentId: nodeId, }; localNodes.push(msgNode); lastNode = msgNode; // 更新最后一个节点 globalY += NODE_HEIGHT; // 更新全局 Y 坐标 }); } // 2. 再递归展开 sub_goals if (goal.sub_goals && goal.sub_goals.length > 0) { goal.sub_goals.forEach((subGoal) => { const subX = x + HORIZONTAL_OFFSET; // 子节点向右偏移 const result = expandNode(subGoal, subX, level + 1, nodeId); localNodes.push(...result.nodes); lastNode = result.lastNode; // 更新最后一个节点 }); } return { nodes: localNodes, firstNode: currentNode, lastNode }; }; /** * 主链信息数组 * 记录每个主链节点的第一个节点、最后一个节点和所有子节点 */ const mainChainInfo: Array<{ goal: Goal; firstNode: LayoutNode; lastNode: LayoutNode; allNodes: LayoutNode[]; }> = []; // 展开所有主链节点 displayGoals.forEach((goal) => { const result = expandNode(goal, centerX, 0); nodes.push(...result.nodes); mainChainInfo.push({ goal, firstNode: result.firstNode, lastNode: result.lastNode, allNodes: result.nodes, }); }); /** * 生成连接线 * * 连接线生成规则: * 1. 主链节点之间: * - 如果有子节点:绘制弧线(外层)+ 直线(内层子节点连接) * - 如果无子节点:直接绘制直线 * 2. 子节点之间: * - 如果有孙节点:绘制弧线(外层)+ 直线(内层孙节点连接) * - 如果无孙节点:直接绘制直线 * 3. 递归处理所有层级的节点 */ for (let i = 0; i < mainChainInfo.length - 1; i++) { const current = mainChainInfo[i]; const next = mainChainInfo[i + 1]; const hasChildren = current.allNodes.length > 1; // 是否有子节点 if (hasChildren) { // 情况 1:有子节点 // 绘制弧线从第一个节点到下一个主链节点(外层包裹) const arcEdgeId = `arc-${current.firstNode.id}-${next.firstNode.id}`; edges.push({ id: arcEdgeId, source: current.firstNode, target: next.firstNode, type: "arc", level: 0, collapsible: true, // 可折叠 collapsed: collapsedEdges.has(arcEdgeId), children: current.allNodes.slice(1), // 除了第一个节点外的所有子节点 }); // 绘制直线连接子节点(内层) const childNodes = current.allNodes.slice(1); const directChildren = childNodes.filter((n) => n.parentId === current.firstNode.id); // Logic A: Messages Group Arc (A -> s1) // 检查是否有消息节点且后面跟着子目标节点 // 如果有,绘制一条蓝色弧线从主节点到第一个子目标节点,包裹所有消息节点 const firstSubgoalIndex = directChildren.findIndex((n) => n.type === "goal"); if (firstSubgoalIndex > 0) { const target = directChildren[firstSubgoalIndex]; const messages = directChildren.slice(0, firstSubgoalIndex); const arcId = `arc-msg-${current.firstNode.id}-${target.id}`; edges.push({ id: arcId, source: current.firstNode, target: target, type: "arc", level: 1, collapsible: true, collapsed: collapsedEdges.has(arcId), children: messages, }); } // 连接第一个节点到第一个子节点 if (directChildren.length > 0) { edges.push({ id: `line-${current.firstNode.id}-${directChildren[0].id}`, source: current.firstNode, target: directChildren[0], type: "line", level: 1, collapsible: false, collapsed: false, }); } // 连接子节点之间 for (let j = 0; j < directChildren.length - 1; j++) { const source = directChildren[j]; const target = directChildren[j + 1]; // 检查是否有孙节点 const grandChildren = nodes.filter((n) => n.parentId === source.id); const hasGrandChildren = grandChildren.length > 0; if (hasGrandChildren) { // 情况 2:有孙节点 // 绘制弧线从当前子节点到下一个子节点(第二层包裹) const arcId = `arc-${source.id}-${target.id}`; edges.push({ id: arcId, source, target, type: "arc", level: source.level, collapsible: true, // 可折叠 collapsed: collapsedEdges.has(arcId), children: grandChildren, }); // 绘制孙节点之间的直线(内层) if (grandChildren.length > 0) { // 连接子节点到第一个孙节点 edges.push({ id: `line-${source.id}-${grandChildren[0].id}`, source, target: grandChildren[0], type: "line", level: source.level + 1, collapsible: false, collapsed: false, }); // 连接孙节点之间 for (let k = 0; k < grandChildren.length - 1; k++) { edges.push({ id: `line-${grandChildren[k].id}-${grandChildren[k + 1].id}`, source: grandChildren[k], target: grandChildren[k + 1], type: "line", level: source.level + 1, collapsible: false, collapsed: false, }); } // 连接最后一个孙节点到下一个子节点 edges.push({ id: `line-${grandChildren[grandChildren.length - 1].id}-${target.id}`, source: grandChildren[grandChildren.length - 1], target, type: "line", level: source.level + 1, collapsible: false, collapsed: false, }); } } else { // 情况 3:没有孙节点,直接绘制直线 edges.push({ id: `line-${source.id}-${target.id}`, source, target, type: "line", level: source.level, collapsible: false, collapsed: false, }); } } // 连接最后一个子节点到下一个主链节点 if (childNodes.length > 0) { edges.push({ id: `line-${current.lastNode.id}-${next.firstNode.id}`, source: current.lastNode, target: next.firstNode, type: "line", level: 1, collapsible: false, collapsed: false, }); // Logic C: Last Child Arc (s2 -> B) // 如果最后一个直接子节点是 sub_goal 且有子节点,绘制弧线连接到下一个主节点 const lastChild = directChildren[directChildren.length - 1]; const lastChildChildren = nodes.filter((n) => n.parentId === lastChild.id); if (lastChild.type === "goal" && lastChildChildren.length > 0) { const arcId = `arc-${lastChild.id}-${next.firstNode.id}`; edges.push({ id: arcId, source: lastChild, target: next.firstNode, type: "arc", level: 1, collapsible: true, collapsed: collapsedEdges.has(arcId), children: lastChildChildren, }); // 补充绘制最后一个子节点的内部连线(直线) // 1. 连接 lastChild 到第一个孙节点 edges.push({ id: `line-${lastChild.id}-${lastChildChildren[0].id}`, source: lastChild, target: lastChildChildren[0], type: "line", level: lastChild.level + 1, collapsible: false, collapsed: false, }); // 2. 连接孙节点之间 for (let k = 0; k < lastChildChildren.length - 1; k++) { edges.push({ id: `line-${lastChildChildren[k].id}-${lastChildChildren[k + 1].id}`, source: lastChildChildren[k], target: lastChildChildren[k + 1], type: "line", level: lastChild.level + 1, collapsible: false, collapsed: false, }); } } } } else { // 情况 4:没有子节点,直接绘制直线 edges.push({ id: `line-${current.firstNode.id}-${next.firstNode.id}`, source: current.firstNode, target: next.firstNode, type: "line", level: 0, collapsible: false, collapsed: false, }); } } // 处理失效分支(invalidBranches) if (invalidBranches && invalidBranches.length > 0) { const validMsgMap = new Map(); nodes.forEach((n) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const seq = (n.data as any).sequence; if (typeof seq === "number") { validMsgMap.set(seq, n); } }); // Map to store invalid nodes by their anchor parent ID const invalidNodesByAnchor = new Map(); invalidBranches.forEach((branch) => { if (branch.length === 0) return; const firstMsg = branch[0]; // eslint-disable-next-line @typescript-eslint/no-explicit-any const pSeq = (firstMsg as any).parent_sequence; if (typeof pSeq === "number") { const parentNode = validMsgMap.get(pSeq); if (parentNode) { let currentParent = parentNode; const X_OFFSET = -200; // 向左偏移 const currentBranchNodes: LayoutNode[] = []; branch.forEach((msg, idx) => { // Ensure we have a stable ID. If msg.id is missing, use a deterministic fallback. // Note: Using Math.random() is bad for React reconciliation, but here we need something unique if ID is missing. // Better fallback: `${parentNode.id}-branch-${idx}` const stableId = msg.id || `${parentNode.id}-branch-${idx}`; const nodeId = `invalid-${stableId}`; const node: LayoutNode = { id: nodeId, x: parentNode.x + X_OFFSET, y: parentNode.y + (idx + 1) * NODE_HEIGHT, data: msg, type: "message", level: parentNode.level, parentId: parentNode.id, isInvalid: true, }; nodes.push(node); currentBranchNodes.push(node); edges.push({ id: `edge-${currentParent.id}-${node.id}`, source: currentParent, target: node, type: "line", level: 0, collapsible: false, collapsed: false, isInvalid: true, }); currentParent = node; }); // Store in map if (!invalidNodesByAnchor.has(parentNode.id)) { invalidNodesByAnchor.set(parentNode.id, []); } invalidNodesByAnchor.get(parentNode.id)!.push(...currentBranchNodes); } } }); // Associate invalid nodes with collapsible edges // If a parent node is hidden (part of a collapsed edge), its invalid children should also be hidden edges.forEach((edge) => { if (edge.collapsible && edge.children) { const extraChildren: LayoutNode[] = []; edge.children.forEach((child) => { if (invalidNodesByAnchor.has(child.id)) { extraChildren.push(...invalidNodesByAnchor.get(child.id)!); } }); if (extraChildren.length > 0) { edge.children.push(...extraChildren); } } }); } return { nodes, edges }; }, [displayGoals, dimensions, msgGroups, collapsedEdges, invalidBranches]); // 暴露给父组件的方法 useImperativeHandle( ref, () => ({ expandAll: () => { setCollapsedEdges(new Set()); }, collapseAll: () => { const allCollapsible = new Set(); layoutData.edges.forEach((edge) => { if (edge.collapsible) { allCollapsible.add(edge.id); } }); setCollapsedEdges(allCollapsible); }, }), [layoutData], ); // 初始化折叠状态:只展开第一个主链节点(A->B)之间的内容 useEffect(() => { if (initializedRef.current || !layoutData || layoutData.edges.length === 0) return; // 找出所有可折叠的弧线(level === 0 表示主链节点之间的弧线) // const mainChainArcs = layoutData.edges.filter( // (edge) => edge.type === "arc" && edge.collapsible && edge.level === 0, // ); // if (mainChainArcs.length > 0) { // // 除了第一个弧线外,其他都默认折叠 // const toCollapse = new Set(); // mainChainArcs.slice(1).forEach((edge) => { // toCollapse.add(edge.id); // }); // setCollapsedEdges(toCollapse); // initializedRef.current = true; // } }, [layoutData]); /** * 过滤掉被折叠的节点和边 * 当弧线被折叠时,其子节点和相关的边都会被隐藏 * 并且调整后续节点的位置,填补折叠后的空白 */ const visibleData = useMemo(() => { const NODE_HEIGHT = 110; // 1. 找出所有折叠的边 const allCollapsedEdges = layoutData.edges.filter((e) => e.collapsed); // 筛选出“有效”的折叠边:如果一个折叠边被另一个更大的折叠边包含(即其节点被隐藏),则忽略它 // 避免重复计算 shiftY const collapsedEdgesList = allCollapsedEdges.filter((edge) => { return !allCollapsedEdges.some((other) => { if (edge === other) return false; // 如果当前边的源节点或目标节点在另一个折叠边的子节点列表中,说明当前边是被包裹在内部的 return other.children?.some((child) => child.id === edge.source.id || child.id === edge.target.id); }); }); // 2. 过滤节点:隐藏被折叠的节点 const visibleNodesRaw = layoutData.nodes.filter((node) => { for (const edge of collapsedEdgesList) { if (edge.children?.some((child) => child.id === node.id)) { return false; // 节点在折叠的边的子节点中,隐藏 } } return true; // 节点可见 }); // 3. 计算每个节点需要向上移动的距离 // 逻辑:如果一个节点位于某个折叠区域的“下方”,它需要向上移动 const shiftedNodes = visibleNodesRaw.map((node) => { let shiftY = 0; for (const edge of collapsedEdgesList) { // 如果当前节点位于折叠边的目标节点下方(或就是目标节点) if (node.y >= edge.target.y) { const originalDist = edge.target.y - edge.source.y; const collapsedDist = NODE_HEIGHT; // 折叠后只保留一个节点间距 shiftY += originalDist - collapsedDist; } } return { ...node, y: node.y - shiftY, }; }); // 创建一个 id -> node 的映射,方便查找 const nodeMap = new Map(shiftedNodes.map((n) => [n.id, n])); // 4. 过滤并更新边 const visibleEdges = layoutData.edges .filter((edge) => { const sourceNode = nodeMap.get(edge.source.id); const targetNode = nodeMap.get(edge.target.id); // 只显示源节点和目标节点都可见的边,或者虽然中间节点被隐藏但作为连接线的边(折叠后的弧线) // 对于折叠的弧线,它的 source 和 target 应该是可见的(因为它们是折叠区域的边界) return sourceNode && targetNode; }) .map((edge) => ({ ...edge, source: nodeMap.get(edge.source.id)!, target: nodeMap.get(edge.target.id)!, })); return { nodes: shiftedNodes, edges: visibleEdges }; }, [layoutData]); /** * 计算内容尺寸(用于滚动模式) * 根据节点位置计算包围盒,确保内容完整显示 */ const contentSize = useMemo(() => { if (visibleData.nodes.length === 0) return { width: 0, height: 0, minX: 0 }; let minX = Infinity; let maxX = -Infinity; let maxY = -Infinity; visibleData.nodes.forEach((node) => { minX = Math.min(minX, node.x); maxX = Math.max(maxX, node.x); maxY = Math.max(maxY, node.y); }); // 增加一些内边距,确保边缘不被遮挡 const startX = Math.min(0, minX - 50); const endX = maxX + 150; // 在 scroll 模式下,使用实际内容尺寸,不受容器尺寸限制 // 这样可以保持 1:1 比例,通过滚动条查看超出部分 return { width: endX - startX, height: maxY + 150, minX: startX, }; }, [visibleData]); /** * 切换视图模式 * 在滚动模式和拖拽缩放模式之间切换 * 切换时重置视图状态,防止位置突变 */ const toggleView = () => { if (viewMode === "scroll") { setViewMode("panzoom"); // 切换到 panzoom 时重置视图 resetView(); } else { setViewMode("scroll"); // 切换回 scroll 时也重置视图 resetView(); } }; /** * 切换折叠状态 * 点击弧线时调用,切换该弧线的折叠/展开状态 */ const toggleCollapse = useCallback((edgeId: string) => { setCollapsedEdges((prev) => { const next = new Set(prev); if (next.has(edgeId)) { next.delete(edgeId); // 已折叠,展开 } else { next.add(edgeId); // 未折叠,折叠 } return next; }); }, []); /** * 绘制弧线路径 * 使用二次贝塞尔曲线(Quadratic Bezier Curve) * * @param source - 源节点 * @param target - 目标节点 * @param level - 嵌套层级,用于计算弧线偏移量 * @returns SVG 路径字符串 */ const getArcPath = (source: LayoutNode, target: LayoutNode, level: number) => { const sx = source.x; const sy = source.y + 25; // 从节点底部出发 const tx = target.x; const ty = target.y - 25; // 到节点顶部结束 // 弧线向右偏移,偏移量根据层级递减 // 外层弧线偏移量大,内层弧线偏移量小 // 增加基础偏移量,让弧线更明显 const offset = 800 - level * 30; const midY = (sy + ty) / 2; // Q: 二次贝塞尔曲线,控制点在 (sx + offset, midY) return `M ${sx},${sy} Q ${sx + offset},${midY} ${tx},${ty}`; }; /** * 绘制直线路径 * * @param source - 源节点 * @param target - 目标节点 * @returns SVG 路径字符串 */ const getLinePath = (source: LayoutNode, target: LayoutNode) => { return `M ${source.x},${source.y + 25} L ${target.x},${target.y - 25}`; }; /** * 计算线条粗细 * 根据嵌套层级递减,外层线条更粗,内层线条更细 * * @param level - 嵌套层级 * @returns 线条粗细(像素) */ const getStrokeWidth = (level: number) => { return Math.max(0.1, 6 - level * 2); }; /** * 节点点击处理 * 区分主链节点和子节点,触发不同的回调 */ const handleNodeClick = useCallback( (node: LayoutNode) => { if (node.type === "goal") { // const goalData = node.data as Goal; // // 只有具有 sub_trace_ids 的子目标节点(agent 委托执行)才触发 trace 切换 // // 普通的 sub_goal 节点(蓝色节点)没有 sub_trace_ids,应该打开 DetailPanel // const hasSubTraces = goalData.sub_trace_ids && goalData.sub_trace_ids.length > 0; // if (node.parentId && onSubTraceClick && hasSubTraces) { // const parentNode = layoutData.nodes.find((n) => n.id === node.parentId); // if (parentNode && parentNode.type === "goal") { // // 取第一个 sub_trace_id 作为跳转目标(使用 trace_id,而非 goal.id) // const firstEntry = goalData.sub_trace_ids![0]; // const entry: SubTraceEntry = // typeof firstEntry === "string" // ? { id: firstEntry } // : { id: firstEntry.trace_id, mission: firstEntry.mission }; // onSubTraceClick(parentNode.data as Goal, entry); // return; // } // } // 主链节点 或 没有 sub_trace_ids 的普通子目标节点 → 打开 DetailPanel setSelectedNodeId(node.id); onNodeClick?.(node.data as Goal); } else if (node.type === "message") { setSelectedNodeId(node.id); onNodeClick?.(node.data as Message); } }, [onNodeClick], ); if (!layoutData) return
Loading...
; return (
{ if (viewMode === "scroll") return; // 滚动模式下使用原生滚动 // 鼠标滚轮缩放 event.preventDefault(); const rect = svgRef.current?.getBoundingClientRect(); if (!rect) return; const cursorX = event.clientX - rect.left; const cursorY = event.clientY - rect.top; const nextZoom = clampZoom(zoom * (event.deltaY > 0 ? 0.92 : 1.08)); if (nextZoom === zoom) return; const scale = nextZoom / zoom; // 以鼠标位置为中心缩放 setPanOffset((prev) => ({ x: cursorX - (cursorX - prev.x) * scale, y: cursorY - (cursorY - prev.y) * scale, })); setZoom(nextZoom); }} onDoubleClick={() => resetView()} // 双击重置视图 onMouseDown={(event) => { if (viewMode === "scroll") return; // 滚动模式下禁用拖拽 // 开始平移 if (event.button !== 0) return; // 只响应左键 const target = event.target as Element; // 如果点击的是节点或连接线,不触发平移 if (target.closest(`.${styles.nodes}`) || target.closest(`.${styles.links}`)) return; panStartRef.current = { x: event.clientX, y: event.clientY, originX: panOffset.x, originY: panOffset.y, }; setIsPanning(true); }} > {/* 箭头标记定义 */} {/* 应用平移和缩放变换(仅在 panzoom 模式下) */} {/* 绘制连接线 */} {visibleData.edges.map((edge) => { // 根据连接线类型选择路径 const path = edge.type === "arc" && !edge.collapsed ? getArcPath(edge.source, edge.target, edge.level) : getLinePath(edge.source, edge.target); const strokeWidth = getStrokeWidth(edge.level); // 根据层级计算线条粗细 // 根据节点类型决定颜色 let color = "#2196F3"; // 默认蓝色 // 判断连接线连接的节点类型 const sourceIsMessage = edge.source.type === "message"; const targetIsMessage = edge.target.type === "message"; const sourceIsMainGoal = edge.source.type === "goal" && edge.source.level === 0; const targetIsMainGoal = edge.target.type === "goal" && edge.target.level === 0; if (sourceIsMessage || targetIsMessage) { // msgGroup 相关的连接线用灰色 color = "#94a3b8"; // Slate 400 } else if (sourceIsMainGoal && targetIsMainGoal) { // 主节点之间的连接线用绿色 color = "#10b981"; // Emerald 500 } else { // sub_goals 之间的连接线用蓝色 color = "#3b82f6"; // Blue 500 } return ( edge.collapsible && toggleCollapse(edge.id)} // 点击切换折叠状态 /> {/* 折叠状态提示徽章 */} {edge.collapsed && ( { e.stopPropagation(); toggleCollapse(edge.id); }} style={{ cursor: "pointer" }} > {edge.children ? edge.children.length : "+"} )} ); })} {/* 绘制节点 */} {visibleData.nodes.map((node) => { const isGoal = node.type === "goal"; const data = node.data as Goal; const text = isGoal ? data.description : (node.data as Message).description || ""; let thumbnail: string | null = null; if (node.type === "message") { const images = extractImagesFromMessage(node.data as Message); if (images.length > 0) thumbnail = images[0].url; } let textColor = "#3b82f6"; // Blue 500 if (node.type === "message") { textColor = "#64748b"; // Slate 500 } else if (node.type === "goal" && node.level === 0) { textColor = "#10b981"; // Emerald 500 } if (node.isInvalid) { textColor = "#94a3b8"; // Slate 400 } return ( handleNodeClick(node)} style={{ cursor: "pointer" }} > {/* 节点矩形 */} {/* 节点文本 */}
{text} {thumbnail && ( thumb { e.stopPropagation(); setPreviewImage(thumbnail); }} /> )}
); })}
{/* 控制按钮 */}
{viewMode === "panzoom" && ( <> )}
setPreviewImage(null)} src={previewImage || ""} />
); }; export const FlowChart = forwardRef(FlowChartComponent);