|
@@ -0,0 +1,2118 @@
|
|
|
|
|
+#!/usr/bin/env node
|
|
|
|
|
+
|
|
|
|
|
+const fs = require('fs');
|
|
|
|
|
+const path = require('path');
|
|
|
|
|
+const { build } = require('esbuild');
|
|
|
|
|
+const { convertV8ToGraph } = require('./convert_v8_to_graph');
|
|
|
|
|
+const { convertV8ToGraphV2 } = require('./convert_v8_to_graph_v2');
|
|
|
|
|
+
|
|
|
|
|
+// 读取命令行参数
|
|
|
|
|
+const args = process.argv.slice(2);
|
|
|
|
|
+if (args.length === 0) {
|
|
|
|
|
+ console.error('Usage: node index.js <path-to-run_context.json> [output.html]');
|
|
|
|
|
+ process.exit(1);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const inputFile = args[0];
|
|
|
|
|
+const outputFile = args[1] || 'query_graph_output.html';
|
|
|
|
|
+
|
|
|
|
|
+// 读取输入数据
|
|
|
|
|
+const inputData = JSON.parse(fs.readFileSync(inputFile, 'utf-8'));
|
|
|
|
|
+
|
|
|
|
|
+// 检测数据格式并转换
|
|
|
|
|
+let data;
|
|
|
|
|
+if (inputData.rounds && inputData.o) {
|
|
|
|
|
+ // v6.1.2.8 格式,需要转换
|
|
|
|
|
+ console.log('✨ 检测到 v6.1.2.8 格式,正在转换为图结构...');
|
|
|
|
|
+
|
|
|
|
|
+ // 尝试读取 search_results.json
|
|
|
|
|
+ let searchResults = null;
|
|
|
|
|
+ const searchResultsPath = path.join(path.dirname(inputFile), 'search_results.json');
|
|
|
|
|
+ if (fs.existsSync(searchResultsPath)) {
|
|
|
|
|
+ console.log('📄 读取搜索结果数据...');
|
|
|
|
|
+ searchResults = JSON.parse(fs.readFileSync(searchResultsPath, 'utf-8'));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 使用新的转换函数(按 Round > 步骤 > 数据 组织)
|
|
|
|
|
+ const graphData = convertV8ToGraphV2(inputData, searchResults);
|
|
|
|
|
+ data = {
|
|
|
|
|
+ nodes: graphData.nodes,
|
|
|
|
|
+ edges: graphData.edges,
|
|
|
|
|
+ iterations: graphData.iterations
|
|
|
|
|
+ };
|
|
|
|
|
+ console.log(`✅ 转换完成: ${Object.keys(data.nodes).length} 个节点, ${data.edges.length} 条边`);
|
|
|
|
|
+} else if (inputData.nodes && inputData.edges) {
|
|
|
|
|
+ // v6.1.2.5 格式,直接使用
|
|
|
|
|
+ console.log('✨ 检测到 v6.1.2.5 格式,直接使用');
|
|
|
|
|
+ data = inputData;
|
|
|
|
|
+} else {
|
|
|
|
|
+ console.error('❌ 无法识别的数据格式');
|
|
|
|
|
+ process.exit(1);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 创建临时 React 组件文件
|
|
|
|
|
+const reactComponentPath = path.join(__dirname, 'temp_flow_component_v2.jsx');
|
|
|
|
|
+const reactComponent = `
|
|
|
|
|
+import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
|
|
|
|
+import { createRoot } from 'react-dom/client';
|
|
|
|
|
+import {
|
|
|
|
|
+ ReactFlow,
|
|
|
|
|
+ Controls,
|
|
|
|
|
+ Background,
|
|
|
|
|
+ useNodesState,
|
|
|
|
|
+ useEdgesState,
|
|
|
|
|
+ Handle,
|
|
|
|
|
+ Position,
|
|
|
|
|
+ useReactFlow,
|
|
|
|
|
+ ReactFlowProvider,
|
|
|
|
|
+} from '@xyflow/react';
|
|
|
|
|
+import '@xyflow/react/dist/style.css';
|
|
|
|
|
+
|
|
|
|
|
+const data = ${JSON.stringify(data, null, 2)};
|
|
|
|
|
+
|
|
|
|
|
+// 根据节点类型获取边框颜色
|
|
|
|
|
+function getNodeTypeColor(type) {
|
|
|
|
|
+ const typeColors = {
|
|
|
|
|
+ 'root': '#6b21a8', // 紫色 - 根节点
|
|
|
|
|
+ 'round': '#7c3aed', // 深紫 - Round节点
|
|
|
|
|
+ 'step': '#f59e0b', // 橙色 - 步骤节点
|
|
|
|
|
+ 'seg': '#10b981', // 绿色 - 分词
|
|
|
|
|
+ 'q': '#3b82f6', // 蓝色 - Query
|
|
|
|
|
+ 'sug': '#06b6d4', // 青色 - Sug建议词
|
|
|
|
|
+ 'seed': '#84cc16', // 黄绿 - Seed
|
|
|
|
|
+ 'add_word': '#22c55e', // 绿色 - 加词生成
|
|
|
|
|
+ 'search_word': '#8b5cf6', // 紫色 - 搜索词
|
|
|
|
|
+ 'post': '#ec4899', // 粉色 - 帖子
|
|
|
|
|
+ 'filtered_sug': '#14b8a6',// 青绿 - 筛选的sug
|
|
|
|
|
+ 'next_q': '#2563eb', // 深蓝 - 下轮查询
|
|
|
|
|
+ 'next_seed': '#65a30d', // 深黄绿 - 下轮种子
|
|
|
|
|
+ 'search': '#8b5cf6', // 深紫 - 搜索(兼容旧版)
|
|
|
|
|
+ 'operation': '#f59e0b', // 橙色 - 操作节点(兼容旧版)
|
|
|
|
|
+ 'query': '#3b82f6', // 蓝色 - 查询(兼容旧版)
|
|
|
|
|
+ 'note': '#ec4899', // 粉色 - 帖子(兼容旧版)
|
|
|
|
|
+ };
|
|
|
|
|
+ return typeColors[type] || '#9ca3af';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 查询节点组件 - 卡片样式
|
|
|
|
|
+function QueryNode({ id, data, sourcePosition, targetPosition }) {
|
|
|
|
|
+ // 所有节点默认展开
|
|
|
|
|
+ const expanded = true;
|
|
|
|
|
+
|
|
|
|
|
+ // 获取节点类型颜色
|
|
|
|
|
+ const typeColor = getNodeTypeColor(data.nodeType || 'query');
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <Handle
|
|
|
|
|
+ type="target"
|
|
|
|
|
+ position={targetPosition || Position.Left}
|
|
|
|
|
+ style={{ background: typeColor, width: 8, height: 8 }}
|
|
|
|
|
+ />
|
|
|
|
|
+ <div
|
|
|
|
|
+ style={{
|
|
|
|
|
+ padding: '12px',
|
|
|
|
|
+ borderRadius: '8px',
|
|
|
|
|
+ border: data.isHighlighted ? \`3px solid \${typeColor}\` :
|
|
|
|
|
+ data.isCollapsed ? \`2px solid \${typeColor}\` :
|
|
|
|
|
+ data.isSelected === false ? '2px dashed #d1d5db' :
|
|
|
|
|
+ \`2px solid \${typeColor}\`,
|
|
|
|
|
+ background: data.isHighlighted ? '#eef2ff' :
|
|
|
|
|
+ data.isSelected === false ? '#f9fafb' : 'white',
|
|
|
|
|
+ minWidth: '200px',
|
|
|
|
|
+ maxWidth: '280px',
|
|
|
|
|
+ boxShadow: data.isHighlighted ? '0 0 0 4px rgba(102, 126, 234, 0.25), 0 4px 16px rgba(102, 126, 234, 0.4)' :
|
|
|
|
|
+ data.isCollapsed ? '0 4px 12px rgba(102, 126, 234, 0.15)' :
|
|
|
|
|
+ data.level === 0 ? '0 4px 12px rgba(139, 92, 246, 0.15)' : '0 2px 6px rgba(0, 0, 0, 0.06)',
|
|
|
|
|
+ transition: 'all 0.3s ease',
|
|
|
|
|
+ cursor: 'pointer',
|
|
|
|
|
+ position: 'relative',
|
|
|
|
|
+ opacity: data.isSelected === false ? 0.6 : 1,
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {/* 折叠当前节点按钮 - 左边 */}
|
|
|
|
|
+ <div
|
|
|
|
|
+ style={{
|
|
|
|
|
+ position: 'absolute',
|
|
|
|
|
+ top: '6px',
|
|
|
|
|
+ left: '6px',
|
|
|
|
|
+ width: '20px',
|
|
|
|
|
+ height: '20px',
|
|
|
|
|
+ borderRadius: '50%',
|
|
|
|
|
+ background: '#f59e0b',
|
|
|
|
|
+ color: 'white',
|
|
|
|
|
+ display: 'flex',
|
|
|
|
|
+ alignItems: 'center',
|
|
|
|
|
+ justifyContent: 'center',
|
|
|
|
|
+ fontSize: '11px',
|
|
|
|
|
+ fontWeight: 'bold',
|
|
|
|
|
+ cursor: 'pointer',
|
|
|
|
|
+ transition: 'all 0.2s ease',
|
|
|
|
|
+ zIndex: 10,
|
|
|
|
|
+ }}
|
|
|
|
|
+ onClick={(e) => {
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ if (data.onHideSelf) {
|
|
|
|
|
+ data.onHideSelf();
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ onMouseEnter={(e) => {
|
|
|
|
|
+ e.currentTarget.style.background = '#d97706';
|
|
|
|
|
+ }}
|
|
|
|
|
+ onMouseLeave={(e) => {
|
|
|
|
|
+ e.currentTarget.style.background = '#f59e0b';
|
|
|
|
|
+ }}
|
|
|
|
|
+ title="隐藏当前节点"
|
|
|
|
|
+ >
|
|
|
|
|
+ ×
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 聚焦按钮 - 右上角 */}
|
|
|
|
|
+ <div
|
|
|
|
|
+ style={{
|
|
|
|
|
+ position: 'absolute',
|
|
|
|
|
+ top: '6px',
|
|
|
|
|
+ right: '6px',
|
|
|
|
|
+ width: '20px',
|
|
|
|
|
+ height: '20px',
|
|
|
|
|
+ borderRadius: '50%',
|
|
|
|
|
+ background: data.isFocused ? '#10b981' : '#e5e7eb',
|
|
|
|
|
+ color: data.isFocused ? 'white' : '#6b7280',
|
|
|
|
|
+ display: 'flex',
|
|
|
|
|
+ alignItems: 'center',
|
|
|
|
|
+ justifyContent: 'center',
|
|
|
|
|
+ fontSize: '11px',
|
|
|
|
|
+ fontWeight: 'bold',
|
|
|
|
|
+ cursor: 'pointer',
|
|
|
|
|
+ transition: 'all 0.2s ease',
|
|
|
|
|
+ zIndex: 10,
|
|
|
|
|
+ }}
|
|
|
|
|
+ onClick={(e) => {
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ if (data.onFocus) {
|
|
|
|
|
+ data.onFocus();
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ onMouseEnter={(e) => {
|
|
|
|
|
+ if (!data.isFocused) {
|
|
|
|
|
+ e.currentTarget.style.background = '#d1d5db';
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ onMouseLeave={(e) => {
|
|
|
|
|
+ if (!data.isFocused) {
|
|
|
|
|
+ e.currentTarget.style.background = '#e5e7eb';
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ title={data.isFocused ? '取消聚焦' : '聚焦到此节点'}
|
|
|
|
|
+ >
|
|
|
|
|
+ 🎯
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 折叠/展开子节点按钮 - 右边第二个位置 */}
|
|
|
|
|
+ {data.hasChildren && (
|
|
|
|
|
+ <div
|
|
|
|
|
+ style={{
|
|
|
|
|
+ position: 'absolute',
|
|
|
|
|
+ top: '6px',
|
|
|
|
|
+ right: '30px',
|
|
|
|
|
+ width: '20px',
|
|
|
|
|
+ height: '20px',
|
|
|
|
|
+ borderRadius: '50%',
|
|
|
|
|
+ background: data.isCollapsed ? '#667eea' : '#e5e7eb',
|
|
|
|
|
+ color: data.isCollapsed ? 'white' : '#6b7280',
|
|
|
|
|
+ display: 'flex',
|
|
|
|
|
+ alignItems: 'center',
|
|
|
|
|
+ justifyContent: 'center',
|
|
|
|
|
+ fontSize: '11px',
|
|
|
|
|
+ fontWeight: 'bold',
|
|
|
|
|
+ cursor: 'pointer',
|
|
|
|
|
+ transition: 'all 0.2s ease',
|
|
|
|
|
+ zIndex: 10,
|
|
|
|
|
+ }}
|
|
|
|
|
+ onClick={(e) => {
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ data.onToggleCollapse();
|
|
|
|
|
+ }}
|
|
|
|
|
+ title={data.isCollapsed ? '展开子节点' : '折叠子节点'}
|
|
|
|
|
+ >
|
|
|
|
|
+ {data.isCollapsed ? '+' : '−'}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* 卡片内容 */}
|
|
|
|
|
+ <div>
|
|
|
|
|
+ {/* 标题行 */}
|
|
|
|
|
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px', paddingLeft: '24px', paddingRight: data.hasChildren ? '54px' : '28px' }}>
|
|
|
|
|
+ <div style={{ flex: 1 }}>
|
|
|
|
|
+ <div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '3px' }}>
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ fontSize: '13px',
|
|
|
|
|
+ fontWeight: data.level === 0 ? '700' : '600',
|
|
|
|
|
+ color: data.level === 0 ? '#6b21a8' : '#1f2937',
|
|
|
|
|
+ lineHeight: '1.3',
|
|
|
|
|
+ flex: 1,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {data.title}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {data.isSelected === false && (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ fontSize: '9px',
|
|
|
|
|
+ padding: '1px 4px',
|
|
|
|
|
+ borderRadius: '3px',
|
|
|
|
|
+ background: '#fee2e2',
|
|
|
|
|
+ color: '#991b1b',
|
|
|
|
|
+ fontWeight: '500',
|
|
|
|
|
+ flexShrink: 0,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ 未选中
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 展开的详细信息 - 始终显示 */}
|
|
|
|
|
+ <div style={{ fontSize: '11px', lineHeight: 1.4 }}>
|
|
|
|
|
+ <div style={{ display: 'flex', gap: '4px', marginBottom: '6px', flexWrap: 'wrap' }}>
|
|
|
|
|
+ <span style={{
|
|
|
|
|
+ display: 'inline-block',
|
|
|
|
|
+ padding: '1px 6px',
|
|
|
|
|
+ borderRadius: '10px',
|
|
|
|
|
+ background: '#eff6ff',
|
|
|
|
|
+ color: '#3b82f6',
|
|
|
|
|
+ fontSize: '10px',
|
|
|
|
|
+ fontWeight: '500',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ Lv.{data.level}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span style={{
|
|
|
|
|
+ display: 'inline-block',
|
|
|
|
|
+ padding: '1px 6px',
|
|
|
|
|
+ borderRadius: '10px',
|
|
|
|
|
+ background: '#f0fdf4',
|
|
|
|
|
+ color: '#16a34a',
|
|
|
|
|
+ fontSize: '10px',
|
|
|
|
|
+ fontWeight: '500',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {data.score}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ {data.strategy && data.strategy !== 'root' && (
|
|
|
|
|
+ <span style={{
|
|
|
|
|
+ display: 'inline-block',
|
|
|
|
|
+ padding: '1px 6px',
|
|
|
|
|
+ borderRadius: '10px',
|
|
|
|
|
+ background: '#fef3c7',
|
|
|
|
|
+ color: '#92400e',
|
|
|
|
|
+ fontSize: '10px',
|
|
|
|
|
+ fontWeight: '500',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {data.strategy}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {data.parent && (
|
|
|
|
|
+ <div style={{ color: '#6b7280', fontSize: '10px', marginTop: '4px', paddingTop: '4px', borderTop: '1px solid #f3f4f6' }}>
|
|
|
|
|
+ <strong>Parent:</strong> {data.parent}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {data.evaluationReason && (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ marginTop: '6px',
|
|
|
|
|
+ paddingTop: '6px',
|
|
|
|
|
+ borderTop: '1px solid #f3f4f6',
|
|
|
|
|
+ fontSize: '10px',
|
|
|
|
|
+ color: '#6b7280',
|
|
|
|
|
+ lineHeight: '1.5',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <strong style={{ color: '#4b5563' }}>评估:</strong>
|
|
|
|
|
+ <div style={{ marginTop: '2px' }}>{data.evaluationReason}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <Handle
|
|
|
|
|
+ type="source"
|
|
|
|
|
+ position={sourcePosition || Position.Right}
|
|
|
|
|
+ style={{ background: '#667eea', width: 8, height: 8 }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 笔记节点组件 - 卡片样式,带轮播图
|
|
|
|
|
+function NoteNode({ id, data, sourcePosition, targetPosition }) {
|
|
|
|
|
+ const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
|
|
|
|
+ const expanded = true;
|
|
|
|
|
+ const hasImages = data.imageList && data.imageList.length > 0;
|
|
|
|
|
+
|
|
|
|
|
+ const nextImage = (e) => {
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ if (hasImages) {
|
|
|
|
|
+ setCurrentImageIndex((prev) => (prev + 1) % data.imageList.length);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const prevImage = (e) => {
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ if (hasImages) {
|
|
|
|
|
+ setCurrentImageIndex((prev) => (prev - 1 + data.imageList.length) % data.imageList.length);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <Handle
|
|
|
|
|
+ type="target"
|
|
|
|
|
+ position={targetPosition || Position.Left}
|
|
|
|
|
+ style={{ background: '#ec4899', width: 8, height: 8 }}
|
|
|
|
|
+ />
|
|
|
|
|
+ <div
|
|
|
|
|
+ style={{
|
|
|
|
|
+ padding: '14px',
|
|
|
|
|
+ borderRadius: '20px',
|
|
|
|
|
+ border: data.isHighlighted ? '3px solid #ec4899' : '2px solid #fce7f3',
|
|
|
|
|
+ background: data.isHighlighted ? '#eef2ff' : 'white',
|
|
|
|
|
+ minWidth: '220px',
|
|
|
|
|
+ maxWidth: '300px',
|
|
|
|
|
+ boxShadow: data.isHighlighted ? '0 0 0 4px rgba(236, 72, 153, 0.25), 0 4px 16px rgba(236, 72, 153, 0.4)' : '0 4px 12px rgba(236, 72, 153, 0.15)',
|
|
|
|
|
+ transition: 'all 0.3s ease',
|
|
|
|
|
+ cursor: 'pointer',
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {/* 笔记标题 */}
|
|
|
|
|
+ <div style={{ display: 'flex', alignItems: 'flex-start', marginBottom: '8px' }}>
|
|
|
|
|
+ <div style={{ flex: 1 }}>
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ fontSize: '13px',
|
|
|
|
|
+ fontWeight: '600',
|
|
|
|
|
+ color: '#831843',
|
|
|
|
|
+ lineHeight: '1.4',
|
|
|
|
|
+ marginBottom: '4px',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {data.title}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 轮播图 */}
|
|
|
|
|
+ {hasImages && (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ position: 'relative',
|
|
|
|
|
+ marginBottom: '8px',
|
|
|
|
|
+ borderRadius: '12px',
|
|
|
|
|
+ overflow: 'hidden',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <img
|
|
|
|
|
+ src={data.imageList[currentImageIndex].image_url}
|
|
|
|
|
+ alt={\`Image \${currentImageIndex + 1}\`}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ width: '100%',
|
|
|
|
|
+ height: '160px',
|
|
|
|
|
+ objectFit: 'cover',
|
|
|
|
|
+ display: 'block',
|
|
|
|
|
+ }}
|
|
|
|
|
+ onError={(e) => {
|
|
|
|
|
+ e.target.style.display = 'none';
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ {data.imageList.length > 1 && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ {/* 左右切换按钮 */}
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={prevImage}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ position: 'absolute',
|
|
|
|
|
+ left: '4px',
|
|
|
|
|
+ top: '50%',
|
|
|
|
|
+ transform: 'translateY(-50%)',
|
|
|
|
|
+ background: 'rgba(0, 0, 0, 0.5)',
|
|
|
|
|
+ color: 'white',
|
|
|
|
|
+ border: 'none',
|
|
|
|
|
+ borderRadius: '50%',
|
|
|
|
|
+ width: '24px',
|
|
|
|
|
+ height: '24px',
|
|
|
|
|
+ cursor: 'pointer',
|
|
|
|
|
+ display: 'flex',
|
|
|
|
|
+ alignItems: 'center',
|
|
|
|
|
+ justifyContent: 'center',
|
|
|
|
|
+ fontSize: '14px',
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ ‹
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={nextImage}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ position: 'absolute',
|
|
|
|
|
+ right: '4px',
|
|
|
|
|
+ top: '50%',
|
|
|
|
|
+ transform: 'translateY(-50%)',
|
|
|
|
|
+ background: 'rgba(0, 0, 0, 0.5)',
|
|
|
|
|
+ color: 'white',
|
|
|
|
|
+ border: 'none',
|
|
|
|
|
+ borderRadius: '50%',
|
|
|
|
|
+ width: '24px',
|
|
|
|
|
+ height: '24px',
|
|
|
|
|
+ cursor: 'pointer',
|
|
|
|
|
+ display: 'flex',
|
|
|
|
|
+ alignItems: 'center',
|
|
|
|
|
+ justifyContent: 'center',
|
|
|
|
|
+ fontSize: '14px',
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ ›
|
|
|
|
|
+ </button>
|
|
|
|
|
+ {/* 图片计数 */}
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ position: 'absolute',
|
|
|
|
|
+ bottom: '4px',
|
|
|
|
|
+ right: '4px',
|
|
|
|
|
+ background: 'rgba(0, 0, 0, 0.6)',
|
|
|
|
|
+ color: 'white',
|
|
|
|
|
+ padding: '2px 6px',
|
|
|
|
|
+ borderRadius: '10px',
|
|
|
|
|
+ fontSize: '10px',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {currentImageIndex + 1}/{data.imageList.length}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* 标签 */}
|
|
|
|
|
+ <div style={{ display: 'flex', gap: '6px', marginBottom: '8px', flexWrap: 'wrap' }}>
|
|
|
|
|
+ <span style={{
|
|
|
|
|
+ display: 'inline-block',
|
|
|
|
|
+ padding: '2px 8px',
|
|
|
|
|
+ borderRadius: '12px',
|
|
|
|
|
+ background: '#fff1f2',
|
|
|
|
|
+ color: '#be123c',
|
|
|
|
|
+ fontSize: '10px',
|
|
|
|
|
+ fontWeight: '500',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {data.matchLevel}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span style={{
|
|
|
|
|
+ display: 'inline-block',
|
|
|
|
|
+ padding: '2px 8px',
|
|
|
|
|
+ borderRadius: '12px',
|
|
|
|
|
+ background: '#fff7ed',
|
|
|
|
|
+ color: '#c2410c',
|
|
|
|
|
+ fontSize: '10px',
|
|
|
|
|
+ fontWeight: '500',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ Score: {data.score}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 描述 */}
|
|
|
|
|
+ {expanded && data.description && (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ fontSize: '11px',
|
|
|
|
|
+ color: '#9f1239',
|
|
|
|
|
+ lineHeight: '1.5',
|
|
|
|
|
+ paddingTop: '8px',
|
|
|
|
|
+ borderTop: '1px solid #fbcfe8',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {data.description}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* 评估理由 */}
|
|
|
|
|
+ {expanded && data.evaluationReason && (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ fontSize: '10px',
|
|
|
|
|
+ color: '#831843',
|
|
|
|
|
+ lineHeight: '1.5',
|
|
|
|
|
+ paddingTop: '8px',
|
|
|
|
|
+ marginTop: '8px',
|
|
|
|
|
+ borderTop: '1px solid #fbcfe8',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <strong style={{ color: '#9f1239' }}>评估:</strong>
|
|
|
|
|
+ <div style={{ marginTop: '2px' }}>{data.evaluationReason}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <Handle
|
|
|
|
|
+ type="source"
|
|
|
|
|
+ position={sourcePosition || Position.Right}
|
|
|
|
|
+ style={{ background: '#ec4899', width: 8, height: 8 }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const nodeTypes = {
|
|
|
|
|
+ query: QueryNode,
|
|
|
|
|
+ note: NoteNode,
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// 根据 score 获取颜色
|
|
|
|
|
+function getScoreColor(score) {
|
|
|
|
|
+ if (score >= 0.7) return '#10b981'; // 绿色 - 高分
|
|
|
|
|
+ if (score >= 0.4) return '#f59e0b'; // 橙色 - 中分
|
|
|
|
|
+ return '#ef4444'; // 红色 - 低分
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 截断文本,保留头尾,中间显示省略号
|
|
|
|
|
+function truncateMiddle(text, maxLength = 20) {
|
|
|
|
|
+ if (!text || text.length <= maxLength) return text;
|
|
|
|
|
+ const headLength = Math.ceil(maxLength * 0.4);
|
|
|
|
|
+ const tailLength = Math.floor(maxLength * 0.4);
|
|
|
|
|
+ const head = text.substring(0, headLength);
|
|
|
|
|
+ const tail = text.substring(text.length - tailLength);
|
|
|
|
|
+ return \`\${head}...\${tail}\`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 根据策略获取颜色
|
|
|
|
|
+function getStrategyColor(strategy) {
|
|
|
|
|
+ const strategyColors = {
|
|
|
|
|
+ '初始分词': '#10b981',
|
|
|
|
|
+ '调用sug': '#06b6d4',
|
|
|
|
|
+ '同义改写': '#f59e0b',
|
|
|
|
|
+ '加词': '#3b82f6',
|
|
|
|
|
+ '抽象改写': '#8b5cf6',
|
|
|
|
|
+ '基于部分匹配改进': '#ec4899',
|
|
|
|
|
+ '结果分支-抽象改写': '#a855f7',
|
|
|
|
|
+ '结果分支-同义改写': '#fb923c',
|
|
|
|
|
+ // v6.1.2.8 新增策略
|
|
|
|
|
+ '原始问题': '#6b21a8',
|
|
|
|
|
+ '来自分词': '#10b981',
|
|
|
|
|
+ '加词生成': '#ef4444',
|
|
|
|
|
+ '建议词': '#06b6d4',
|
|
|
|
|
+ '执行搜索': '#8b5cf6',
|
|
|
|
|
+ };
|
|
|
|
|
+ return strategyColors[strategy] || '#9ca3af';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 树节点组件
|
|
|
|
|
+function TreeNode({ node, level, children, isCollapsed, onToggle, isSelected, onSelect }) {
|
|
|
|
|
+ const hasChildren = children && children.length > 0;
|
|
|
|
|
+ const score = node.data.score ? parseFloat(node.data.score) : 0;
|
|
|
|
|
+ const strategy = node.data.strategy || '';
|
|
|
|
|
+ const strategyColor = getStrategyColor(strategy);
|
|
|
|
|
+ const nodeActualType = node.data.nodeType || node.type; // 获取实际节点类型
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div style={{ marginLeft: level * 12 + 'px' }}>
|
|
|
|
|
+ <div
|
|
|
|
|
+ style={{
|
|
|
|
|
+ padding: '6px 8px',
|
|
|
|
|
+ borderRadius: '4px',
|
|
|
|
|
+ cursor: 'pointer',
|
|
|
|
|
+ background: 'transparent',
|
|
|
|
|
+ border: isSelected ? '1px solid #3b82f6' : '1px solid transparent',
|
|
|
|
|
+ display: 'flex',
|
|
|
|
|
+ alignItems: 'center',
|
|
|
|
|
+ gap: '6px',
|
|
|
|
|
+ transition: 'all 0.2s ease',
|
|
|
|
|
+ position: 'relative',
|
|
|
|
|
+ overflow: 'visible',
|
|
|
|
|
+ }}
|
|
|
|
|
+ onMouseEnter={(e) => {
|
|
|
|
|
+ if (!isSelected) e.currentTarget.style.background = '#f9fafb';
|
|
|
|
|
+ }}
|
|
|
|
|
+ onMouseLeave={(e) => {
|
|
|
|
|
+ if (!isSelected) e.currentTarget.style.background = 'transparent';
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {/* 策略类型竖线 */}
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ width: '3px',
|
|
|
|
|
+ height: '20px',
|
|
|
|
|
+ background: strategyColor,
|
|
|
|
|
+ borderRadius: '2px',
|
|
|
|
|
+ flexShrink: 0,
|
|
|
|
|
+ position: 'relative',
|
|
|
|
|
+ zIndex: 1,
|
|
|
|
|
+ }} />
|
|
|
|
|
+
|
|
|
|
|
+ {hasChildren && (
|
|
|
|
|
+ <span
|
|
|
|
|
+ style={{
|
|
|
|
|
+ fontSize: '10px',
|
|
|
|
|
+ color: '#6b7280',
|
|
|
|
|
+ cursor: 'pointer',
|
|
|
|
|
+ width: '16px',
|
|
|
|
|
+ textAlign: 'center',
|
|
|
|
|
+ position: 'relative',
|
|
|
|
|
+ zIndex: 1,
|
|
|
|
|
+ }}
|
|
|
|
|
+ onClick={(e) => {
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ onToggle();
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {isCollapsed ? '▶' : '▼'}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {!hasChildren && <span style={{ width: '16px', position: 'relative', zIndex: 1 }}></span>}
|
|
|
|
|
+
|
|
|
|
|
+ <div
|
|
|
|
|
+ style={{
|
|
|
|
|
+ flex: 1,
|
|
|
|
|
+ fontSize: '12px',
|
|
|
|
|
+ color: '#374151',
|
|
|
|
|
+ position: 'relative',
|
|
|
|
|
+ zIndex: 1,
|
|
|
|
|
+ minWidth: 0,
|
|
|
|
|
+ display: 'flex',
|
|
|
|
|
+ flexDirection: 'column',
|
|
|
|
|
+ gap: '4px',
|
|
|
|
|
+ }}
|
|
|
|
|
+ onClick={onSelect}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ display: 'flex',
|
|
|
|
|
+ alignItems: 'center',
|
|
|
|
|
+ gap: '8px',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ fontWeight: level === 0 ? '600' : '400',
|
|
|
|
|
+ maxWidth: '180px',
|
|
|
|
|
+ flex: 1,
|
|
|
|
|
+ minWidth: 0,
|
|
|
|
|
+ color: (node.type === 'note' ? node.data.matchLevel === 'unsatisfied' : node.data.isSelected === false) ? '#ef4444' : '#374151',
|
|
|
|
|
+ }}
|
|
|
|
|
+ title={node.data.title || node.id}
|
|
|
|
|
+ >
|
|
|
|
|
+ {truncateMiddle(node.data.title || node.id, 18)}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 分数显示 - 步骤和轮次节点不显示分数 */}
|
|
|
|
|
+ {nodeActualType !== 'step' && nodeActualType !== 'round' && (
|
|
|
|
|
+ <span style={{
|
|
|
|
|
+ fontSize: '11px',
|
|
|
|
|
+ color: '#6b7280',
|
|
|
|
|
+ fontWeight: '500',
|
|
|
|
|
+ flexShrink: 0,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {score.toFixed(2)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 分数下划线 - 步骤和轮次节点不显示 */}
|
|
|
|
|
+ {nodeActualType !== 'step' && nodeActualType !== 'round' && (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ width: (score * 100) + '%',
|
|
|
|
|
+ height: '2px',
|
|
|
|
|
+ background: getScoreColor(score),
|
|
|
|
|
+ borderRadius: '1px',
|
|
|
|
|
+ }} />
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {hasChildren && !isCollapsed && (
|
|
|
|
|
+ <div>
|
|
|
|
|
+ {children}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 使用 dagre 自动布局
|
|
|
|
|
+function getLayoutedElements(nodes, edges, direction = 'LR') {
|
|
|
|
|
+ console.log('🎯 Starting layout with dagre...');
|
|
|
|
|
+ console.log('Input:', nodes.length, 'nodes,', edges.length, 'edges');
|
|
|
|
|
+
|
|
|
|
|
+ // 检查 dagre 是否加载
|
|
|
|
|
+ if (typeof window === 'undefined' || typeof window.dagre === 'undefined') {
|
|
|
|
|
+ console.warn('⚠️ Dagre not loaded, using fallback layout');
|
|
|
|
|
+ // 降级到简单布局
|
|
|
|
|
+ const levelGroups = {};
|
|
|
|
|
+ nodes.forEach(node => {
|
|
|
|
|
+ const level = node.data.level || 0;
|
|
|
|
|
+ if (!levelGroups[level]) levelGroups[level] = [];
|
|
|
|
|
+ levelGroups[level].push(node);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ Object.entries(levelGroups).forEach(([level, nodeList]) => {
|
|
|
|
|
+ const x = parseInt(level) * 350;
|
|
|
|
|
+ nodeList.forEach((node, index) => {
|
|
|
|
|
+ node.position = { x, y: index * 150 };
|
|
|
|
|
+ node.targetPosition = 'left';
|
|
|
|
|
+ node.sourcePosition = 'right';
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return { nodes, edges };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const dagreGraph = new window.dagre.graphlib.Graph();
|
|
|
|
|
+ dagreGraph.setDefaultEdgeLabel(() => ({}));
|
|
|
|
|
+
|
|
|
|
|
+ const isHorizontal = direction === 'LR';
|
|
|
|
|
+ dagreGraph.setGraph({
|
|
|
|
|
+ rankdir: direction,
|
|
|
|
|
+ nodesep: 120, // 垂直间距 - 增加以避免节点重叠
|
|
|
|
|
+ ranksep: 280, // 水平间距 - 增加以容纳更宽的节点
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 添加节点 - 根据节点类型设置不同的尺寸
|
|
|
|
|
+ nodes.forEach((node) => {
|
|
|
|
|
+ let nodeWidth = 280;
|
|
|
|
|
+ let nodeHeight = 180;
|
|
|
|
|
+
|
|
|
|
|
+ // note 节点有轮播图,需要更大的空间
|
|
|
|
|
+ if (node.type === 'note') {
|
|
|
|
|
+ nodeWidth = 320;
|
|
|
|
|
+ nodeHeight = 350; // 增加高度以容纳轮播图
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 添加边
|
|
|
|
|
+ edges.forEach((edge) => {
|
|
|
|
|
+ dagreGraph.setEdge(edge.source, edge.target);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 计算布局
|
|
|
|
|
+ window.dagre.layout(dagreGraph);
|
|
|
|
|
+ console.log('✅ Dagre layout completed');
|
|
|
|
|
+
|
|
|
|
|
+ // 更新节点位置和 handle 位置
|
|
|
|
|
+ nodes.forEach((node) => {
|
|
|
|
|
+ const nodeWithPosition = dagreGraph.node(node.id);
|
|
|
|
|
+
|
|
|
|
|
+ if (!nodeWithPosition) {
|
|
|
|
|
+ console.warn('Node position not found for:', node.id);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ node.targetPosition = isHorizontal ? 'left' : 'top';
|
|
|
|
|
+ node.sourcePosition = isHorizontal ? 'right' : 'bottom';
|
|
|
|
|
+
|
|
|
|
|
+ // 根据节点类型获取尺寸
|
|
|
|
|
+ let nodeWidth = 280;
|
|
|
|
|
+ let nodeHeight = 180;
|
|
|
|
|
+ if (node.type === 'note') {
|
|
|
|
|
+ nodeWidth = 320;
|
|
|
|
|
+ nodeHeight = 350;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 将 dagre 的中心点位置转换为 React Flow 的左上角位置
|
|
|
|
|
+ node.position = {
|
|
|
|
|
+ x: nodeWithPosition.x - nodeWidth / 2,
|
|
|
|
|
+ y: nodeWithPosition.y - nodeHeight / 2,
|
|
|
|
|
+ };
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ console.log('✅ Layout completed, sample node:', nodes[0]);
|
|
|
|
|
+ return { nodes, edges };
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('❌ Error in dagre layout:', error);
|
|
|
|
|
+ console.error('Error details:', error.message, error.stack);
|
|
|
|
|
+
|
|
|
|
|
+ // 降级处理
|
|
|
|
|
+ console.log('Using fallback layout...');
|
|
|
|
|
+ const levelGroups = {};
|
|
|
|
|
+ nodes.forEach(node => {
|
|
|
|
|
+ const level = node.data.level || 0;
|
|
|
|
|
+ if (!levelGroups[level]) levelGroups[level] = [];
|
|
|
|
|
+ levelGroups[level].push(node);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ Object.entries(levelGroups).forEach(([level, nodeList]) => {
|
|
|
|
|
+ const x = parseInt(level) * 350;
|
|
|
|
|
+ nodeList.forEach((node, index) => {
|
|
|
|
|
+ node.position = { x, y: index * 150 };
|
|
|
|
|
+ node.targetPosition = 'left';
|
|
|
|
|
+ node.sourcePosition = 'right';
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return { nodes, edges };
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function transformData(data) {
|
|
|
|
|
+ const nodes = [];
|
|
|
|
|
+ const edges = [];
|
|
|
|
|
+
|
|
|
|
|
+ const originalIdToCanvasId = {}; // 原始ID -> 画布ID的映射
|
|
|
|
|
+ const canvasIdToNodeData = {}; // 避免重复创建相同的节点
|
|
|
|
|
+
|
|
|
|
|
+ // 创建节点
|
|
|
|
|
+ Object.entries(data.nodes).forEach(([originalId, node]) => {
|
|
|
|
|
+ // 统一处理所有类型的节点
|
|
|
|
|
+ const nodeType = node.type || 'query';
|
|
|
|
|
+
|
|
|
|
|
+ // 直接使用originalId作为canvasId,避免冲突
|
|
|
|
|
+ const canvasId = originalId;
|
|
|
|
|
+
|
|
|
|
|
+ originalIdToCanvasId[originalId] = canvasId;
|
|
|
|
|
+
|
|
|
|
|
+ // 如果这个 canvasId 还没有创建过节点,则创建
|
|
|
|
|
+ if (!canvasIdToNodeData[canvasId]) {
|
|
|
|
|
+ canvasIdToNodeData[canvasId] = true;
|
|
|
|
|
+
|
|
|
|
|
+ // 根据节点类型创建不同的数据结构
|
|
|
|
|
+ if (nodeType === 'note') {
|
|
|
|
|
+ nodes.push({
|
|
|
|
|
+ id: canvasId,
|
|
|
|
|
+ originalId: originalId,
|
|
|
|
|
+ type: 'note',
|
|
|
|
|
+ data: {
|
|
|
|
|
+ title: node.title || '帖子',
|
|
|
|
|
+ matchLevel: node.match_level,
|
|
|
|
|
+ score: node.relevance_score ? node.relevance_score.toFixed(2) : '0.00',
|
|
|
|
|
+ description: node.desc || '',
|
|
|
|
|
+ isSelected: node.is_selected !== undefined ? node.is_selected : true,
|
|
|
|
|
+ imageList: node.image_list || [],
|
|
|
|
|
+ noteUrl: node.note_url || '',
|
|
|
|
|
+ evaluationReason: node.evaluation_reason || '',
|
|
|
|
|
+ nodeType: 'note',
|
|
|
|
|
+ },
|
|
|
|
|
+ position: { x: 0, y: 0 },
|
|
|
|
|
+ });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // query, seg, q, search, root 等节点
|
|
|
|
|
+ let displayTitle = node.query || originalId;
|
|
|
|
|
+
|
|
|
|
|
+ nodes.push({
|
|
|
|
|
+ id: canvasId,
|
|
|
|
|
+ originalId: originalId,
|
|
|
|
|
+ type: 'query', // 使用 query 组件渲染所有非note节点
|
|
|
|
|
+ data: {
|
|
|
|
|
+ title: displayTitle,
|
|
|
|
|
+ level: node.level || 0,
|
|
|
|
|
+ score: node.relevance_score ? node.relevance_score.toFixed(2) : '0.00',
|
|
|
|
|
+ strategy: node.strategy || '',
|
|
|
|
|
+ parent: node.parent_query || '',
|
|
|
|
|
+ isSelected: node.is_selected !== undefined ? node.is_selected : true,
|
|
|
|
|
+ evaluationReason: node.evaluation_reason || '',
|
|
|
|
|
+ nodeType: nodeType, // 传递实际节点类型用于样式
|
|
|
|
|
+ searchCount: node.search_count, // search 节点特有
|
|
|
|
|
+ totalPosts: node.total_posts, // search 节点特有
|
|
|
|
|
+ },
|
|
|
|
|
+ position: { x: 0, y: 0 },
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 创建边 - 使用虚线样式,映射到画布ID
|
|
|
|
|
+ data.edges.forEach((edge, index) => {
|
|
|
|
|
+ const edgeColors = {
|
|
|
|
|
+ '初始分词': '#10b981',
|
|
|
|
|
+ '调用sug': '#06b6d4',
|
|
|
|
|
+ '同义改写': '#f59e0b',
|
|
|
|
|
+ '加词': '#3b82f6',
|
|
|
|
|
+ '抽象改写': '#8b5cf6',
|
|
|
|
|
+ '基于部分匹配改进': '#ec4899',
|
|
|
|
|
+ '结果分支-抽象改写': '#a855f7',
|
|
|
|
|
+ '结果分支-同义改写': '#fb923c',
|
|
|
|
|
+ 'query_to_note': '#ec4899',
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const color = edgeColors[edge.strategy] || edgeColors[edge.edge_type] || '#d1d5db';
|
|
|
|
|
+ const isNoteEdge = edge.edge_type === 'query_to_note';
|
|
|
|
|
+
|
|
|
|
|
+ edges.push({
|
|
|
|
|
+ id: \`edge-\${index}\`,
|
|
|
|
|
+ source: originalIdToCanvasId[edge.from], // 使用画布ID
|
|
|
|
|
+ target: originalIdToCanvasId[edge.to], // 使用画布ID
|
|
|
|
|
+ type: 'simplebezier', // 使用简单贝塞尔曲线
|
|
|
|
|
+ animated: isNoteEdge,
|
|
|
|
|
+ style: {
|
|
|
|
|
+ stroke: color,
|
|
|
|
|
+ strokeWidth: isNoteEdge ? 2.5 : 2,
|
|
|
|
|
+ strokeDasharray: isNoteEdge ? '5,5' : '8,4',
|
|
|
|
|
+ },
|
|
|
|
|
+ markerEnd: {
|
|
|
|
|
+ type: 'arrowclosed',
|
|
|
|
|
+ color: color,
|
|
|
|
|
+ width: 20,
|
|
|
|
|
+ height: 20,
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 使用 dagre 自动计算布局 - 从左到右
|
|
|
|
|
+ return getLayoutedElements(nodes, edges, 'LR');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function FlowContent() {
|
|
|
|
|
+ const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
|
|
|
|
|
+ console.log('🔍 Transforming data...');
|
|
|
|
|
+ const result = transformData(data);
|
|
|
|
|
+ console.log('✅ Transformed:', result.nodes.length, 'nodes,', result.edges.length, 'edges');
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
|
|
+ // 初始化:找出所有有子节点的节点,默认折叠(画布节点)
|
|
|
|
|
+ const initialCollapsedNodes = useMemo(() => {
|
|
|
|
|
+ const nodesWithChildren = new Set();
|
|
|
|
|
+ initialEdges.forEach(edge => {
|
|
|
|
|
+ nodesWithChildren.add(edge.source);
|
|
|
|
|
+ });
|
|
|
|
|
+ // 排除根节点(level 0),让根节点默认展开
|
|
|
|
|
+ const rootNode = initialNodes.find(n => n.data.level === 0);
|
|
|
|
|
+ if (rootNode) {
|
|
|
|
|
+ nodesWithChildren.delete(rootNode.id);
|
|
|
|
|
+ }
|
|
|
|
|
+ return nodesWithChildren;
|
|
|
|
|
+ }, [initialNodes, initialEdges]);
|
|
|
|
|
+
|
|
|
|
|
+ // 树节点的折叠状态需要在树构建后初始化
|
|
|
|
|
+ const [collapsedNodes, setCollapsedNodes] = useState(() => initialCollapsedNodes);
|
|
|
|
|
+ const [collapsedTreeNodes, setCollapsedTreeNodes] = useState(new Set());
|
|
|
|
|
+ const [selectedNodeId, setSelectedNodeId] = useState(null);
|
|
|
|
|
+ const [hiddenNodes, setHiddenNodes] = useState(new Set()); // 用户手动隐藏的节点
|
|
|
|
|
+ const [focusMode, setFocusMode] = useState(false); // 全局聚焦模式,默认关闭
|
|
|
|
|
+ const [focusedNodeId, setFocusedNodeId] = useState(null); // 单独聚焦的节点ID
|
|
|
|
|
+
|
|
|
|
|
+ // 获取 React Flow 实例以控制画布
|
|
|
|
|
+ const { setCenter, fitView } = useReactFlow();
|
|
|
|
|
+
|
|
|
|
|
+ // 获取某个节点的所有后代节点ID
|
|
|
|
|
+ const getDescendants = useCallback((nodeId) => {
|
|
|
|
|
+ const descendants = new Set();
|
|
|
|
|
+ const queue = [nodeId];
|
|
|
|
|
+
|
|
|
|
|
+ while (queue.length > 0) {
|
|
|
|
|
+ const current = queue.shift();
|
|
|
|
|
+ initialEdges.forEach(edge => {
|
|
|
|
|
+ if (edge.source === current && !descendants.has(edge.target)) {
|
|
|
|
|
+ descendants.add(edge.target);
|
|
|
|
|
+ queue.push(edge.target);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return descendants;
|
|
|
|
|
+ }, [initialEdges]);
|
|
|
|
|
+
|
|
|
|
|
+ // 获取直接父节点
|
|
|
|
|
+ const getDirectParents = useCallback((nodeId) => {
|
|
|
|
|
+ const parents = [];
|
|
|
|
|
+ initialEdges.forEach(edge => {
|
|
|
|
|
+ if (edge.target === nodeId) {
|
|
|
|
|
+ parents.push(edge.source);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ return parents;
|
|
|
|
|
+ }, [initialEdges]);
|
|
|
|
|
+
|
|
|
|
|
+ // 获取直接子节点
|
|
|
|
|
+ const getDirectChildren = useCallback((nodeId) => {
|
|
|
|
|
+ const children = [];
|
|
|
|
|
+ initialEdges.forEach(edge => {
|
|
|
|
|
+ if (edge.source === nodeId) {
|
|
|
|
|
+ children.push(edge.target);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ return children;
|
|
|
|
|
+ }, [initialEdges]);
|
|
|
|
|
+
|
|
|
|
|
+ // 切换节点折叠状态
|
|
|
|
|
+ const toggleNodeCollapse = useCallback((nodeId) => {
|
|
|
|
|
+ setCollapsedNodes(prev => {
|
|
|
|
|
+ const newSet = new Set(prev);
|
|
|
|
|
+ const descendants = getDescendants(nodeId);
|
|
|
|
|
+
|
|
|
|
|
+ if (newSet.has(nodeId)) {
|
|
|
|
|
+ // 展开:移除此节点,但保持其他折叠的节点
|
|
|
|
|
+ newSet.delete(nodeId);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 折叠:添加此节点
|
|
|
|
|
+ newSet.add(nodeId);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return newSet;
|
|
|
|
|
+ });
|
|
|
|
|
+ }, [getDescendants]);
|
|
|
|
|
+
|
|
|
|
|
+ // 过滤可见的节点和边,并重新计算布局
|
|
|
|
|
+ const { nodes, edges } = useMemo(() => {
|
|
|
|
|
+ const nodesToHide = new Set();
|
|
|
|
|
+
|
|
|
|
|
+ // 判断使用哪个节点ID进行聚焦:优先使用单独聚焦的节点,否则使用全局聚焦模式的选中节点
|
|
|
|
|
+ const effectiveFocusNodeId = focusedNodeId || (focusMode ? selectedNodeId : null);
|
|
|
|
|
+
|
|
|
|
|
+ // 聚焦模式:只显示聚焦节点、其父节点和直接子节点
|
|
|
|
|
+ if (effectiveFocusNodeId) {
|
|
|
|
|
+ const visibleInFocus = new Set([effectiveFocusNodeId]);
|
|
|
|
|
+
|
|
|
|
|
+ // 添加所有父节点
|
|
|
|
|
+ initialEdges.forEach(edge => {
|
|
|
|
|
+ if (edge.target === effectiveFocusNodeId) {
|
|
|
|
|
+ visibleInFocus.add(edge.source);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 添加所有直接子节点
|
|
|
|
|
+ initialEdges.forEach(edge => {
|
|
|
|
|
+ if (edge.source === effectiveFocusNodeId) {
|
|
|
|
|
+ visibleInFocus.add(edge.target);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 隐藏不在聚焦范围内的节点
|
|
|
|
|
+ initialNodes.forEach(node => {
|
|
|
|
|
+ if (!visibleInFocus.has(node.id)) {
|
|
|
|
|
+ nodesToHide.add(node.id);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 非聚焦模式:使用原有的折叠逻辑
|
|
|
|
|
+ // 收集所有被折叠节点的后代
|
|
|
|
|
+ collapsedNodes.forEach(collapsedId => {
|
|
|
|
|
+ const descendants = getDescendants(collapsedId);
|
|
|
|
|
+ descendants.forEach(id => nodesToHide.add(id));
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 添加用户手动隐藏的节点
|
|
|
|
|
+ hiddenNodes.forEach(id => nodesToHide.add(id));
|
|
|
|
|
+
|
|
|
|
|
+ const visibleNodes = initialNodes
|
|
|
|
|
+ .filter(node => !nodesToHide.has(node.id))
|
|
|
|
|
+ .map(node => ({
|
|
|
|
|
+ ...node,
|
|
|
|
|
+ data: {
|
|
|
|
|
+ ...node.data,
|
|
|
|
|
+ isCollapsed: collapsedNodes.has(node.id),
|
|
|
|
|
+ hasChildren: initialEdges.some(e => e.source === node.id),
|
|
|
|
|
+ onToggleCollapse: () => toggleNodeCollapse(node.id),
|
|
|
|
|
+ onHideSelf: () => {
|
|
|
|
|
+ setHiddenNodes(prev => {
|
|
|
|
|
+ const newSet = new Set(prev);
|
|
|
|
|
+ newSet.add(node.id);
|
|
|
|
|
+ return newSet;
|
|
|
|
|
+ });
|
|
|
|
|
+ },
|
|
|
|
|
+ onFocus: () => {
|
|
|
|
|
+ // 切换聚焦状态
|
|
|
|
|
+ if (focusedNodeId === node.id) {
|
|
|
|
|
+ setFocusedNodeId(null); // 如果已经聚焦,则取消聚焦
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 先取消之前的聚焦,然后聚焦到当前节点
|
|
|
|
|
+ setFocusedNodeId(node.id);
|
|
|
|
|
+
|
|
|
|
|
+ // 延迟聚焦视图到该节点
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ fitView({
|
|
|
|
|
+ nodes: [{ id: node.id }],
|
|
|
|
|
+ duration: 800,
|
|
|
|
|
+ padding: 0.3,
|
|
|
|
|
+ });
|
|
|
|
|
+ }, 100);
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ isFocused: focusedNodeId === node.id,
|
|
|
|
|
+ isHighlighted: selectedNodeId === node.id,
|
|
|
|
|
+ }
|
|
|
|
|
+ }));
|
|
|
|
|
+
|
|
|
|
|
+ const visibleEdges = initialEdges.filter(
|
|
|
|
|
+ edge => !nodesToHide.has(edge.source) && !nodesToHide.has(edge.target)
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // 重新计算布局 - 只对可见节点
|
|
|
|
|
+ if (typeof window !== 'undefined' && typeof window.dagre !== 'undefined') {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const dagreGraph = new window.dagre.graphlib.Graph();
|
|
|
|
|
+ dagreGraph.setDefaultEdgeLabel(() => ({}));
|
|
|
|
|
+
|
|
|
|
|
+ dagreGraph.setGraph({
|
|
|
|
|
+ rankdir: 'LR',
|
|
|
|
|
+ nodesep: 120, // 垂直间距 - 增加以避免节点重叠
|
|
|
|
|
+ ranksep: 280, // 水平间距 - 增加以容纳更宽的节点
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ visibleNodes.forEach((node) => {
|
|
|
|
|
+ let nodeWidth = 280;
|
|
|
|
|
+ let nodeHeight = 180;
|
|
|
|
|
+
|
|
|
|
|
+ // note 节点有轮播图,需要更大的空间
|
|
|
|
|
+ if (node.type === 'note') {
|
|
|
|
|
+ nodeWidth = 320;
|
|
|
|
|
+ nodeHeight = 350;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ visibleEdges.forEach((edge) => {
|
|
|
|
|
+ dagreGraph.setEdge(edge.source, edge.target);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ window.dagre.layout(dagreGraph);
|
|
|
|
|
+
|
|
|
|
|
+ visibleNodes.forEach((node) => {
|
|
|
|
|
+ const nodeWithPosition = dagreGraph.node(node.id);
|
|
|
|
|
+ if (nodeWithPosition) {
|
|
|
|
|
+ // 根据节点类型获取对应的尺寸
|
|
|
|
|
+ let nodeWidth = 280;
|
|
|
|
|
+ let nodeHeight = 180;
|
|
|
|
|
+ if (node.type === 'note') {
|
|
|
|
|
+ nodeWidth = 320;
|
|
|
|
|
+ nodeHeight = 350;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ node.position = {
|
|
|
|
|
+ x: nodeWithPosition.x - nodeWidth / 2,
|
|
|
|
|
+ y: nodeWithPosition.y - nodeHeight / 2,
|
|
|
|
|
+ };
|
|
|
|
|
+ node.targetPosition = 'left';
|
|
|
|
|
+ node.sourcePosition = 'right';
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ console.log('✅ Dynamic layout recalculated for', visibleNodes.length, 'visible nodes');
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('❌ Error in dynamic layout:', error);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return { nodes: visibleNodes, edges: visibleEdges };
|
|
|
|
|
+ }, [initialNodes, initialEdges, collapsedNodes, hiddenNodes, focusMode, focusedNodeId, getDescendants, toggleNodeCollapse, selectedNodeId]);
|
|
|
|
|
+
|
|
|
|
|
+ // 构建树形结构 - 允许一个节点有多个父节点
|
|
|
|
|
+ const buildTree = useCallback(() => {
|
|
|
|
|
+ const nodeMap = new Map();
|
|
|
|
|
+ initialNodes.forEach(node => {
|
|
|
|
|
+ nodeMap.set(node.id, node);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 为每个节点创建树节点的副本(允许多次出现)
|
|
|
|
|
+ const createTreeNode = (nodeId, pathKey) => {
|
|
|
|
|
+ const node = nodeMap.get(nodeId);
|
|
|
|
|
+ if (!node) return null;
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...node,
|
|
|
|
|
+ treeKey: pathKey, // 唯一的树路径key,用于React key
|
|
|
|
|
+ children: []
|
|
|
|
|
+ };
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 构建父子关系映射:记录每个节点的所有父节点,去重边
|
|
|
|
|
+ const parentToChildren = new Map();
|
|
|
|
|
+ const childToParents = new Map();
|
|
|
|
|
+
|
|
|
|
|
+ initialEdges.forEach(edge => {
|
|
|
|
|
+ // 记录父->子关系(去重:同一个父节点到同一个子节点只记录一次)
|
|
|
|
|
+ if (!parentToChildren.has(edge.source)) {
|
|
|
|
|
+ parentToChildren.set(edge.source, []);
|
|
|
|
|
+ }
|
|
|
|
|
+ const children = parentToChildren.get(edge.source);
|
|
|
|
|
+ if (!children.includes(edge.target)) {
|
|
|
|
|
+ children.push(edge.target);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 记录子->父关系(用于判断是否有多个父节点,也去重)
|
|
|
|
|
+ if (!childToParents.has(edge.target)) {
|
|
|
|
|
+ childToParents.set(edge.target, []);
|
|
|
|
|
+ }
|
|
|
|
|
+ const parents = childToParents.get(edge.target);
|
|
|
|
|
+ if (!parents.includes(edge.source)) {
|
|
|
|
|
+ parents.push(edge.source);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 递归构建树
|
|
|
|
|
+ const buildSubtree = (nodeId, pathKey, visitedInPath) => {
|
|
|
|
|
+ // 避免循环引用:如果当前路径中已经访问过这个节点,跳过
|
|
|
|
|
+ if (visitedInPath.has(nodeId)) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const treeNode = createTreeNode(nodeId, pathKey);
|
|
|
|
|
+ if (!treeNode) return null;
|
|
|
|
|
+
|
|
|
|
|
+ const newVisitedInPath = new Set(visitedInPath);
|
|
|
|
|
+ newVisitedInPath.add(nodeId);
|
|
|
|
|
+
|
|
|
|
|
+ const children = parentToChildren.get(nodeId) || [];
|
|
|
|
|
+ treeNode.children = children
|
|
|
|
|
+ .map((childId, index) => buildSubtree(childId, pathKey + '-' + childId + '-' + index, newVisitedInPath))
|
|
|
|
|
+ .filter(child => child !== null);
|
|
|
|
|
+
|
|
|
|
|
+ return treeNode;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 找出所有根节点(没有入边的节点)
|
|
|
|
|
+ const hasParent = new Set();
|
|
|
|
|
+ initialEdges.forEach(edge => {
|
|
|
|
|
+ hasParent.add(edge.target);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const roots = [];
|
|
|
|
|
+ initialNodes.forEach((node, index) => {
|
|
|
|
|
+ if (!hasParent.has(node.id)) {
|
|
|
|
|
+ const treeNode = buildSubtree(node.id, 'root-' + node.id + '-' + index, new Set());
|
|
|
|
|
+ if (treeNode) roots.push(treeNode);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return roots;
|
|
|
|
|
+ }, [initialNodes, initialEdges]);
|
|
|
|
|
+
|
|
|
|
|
+ const treeRoots = useMemo(() => buildTree(), [buildTree]);
|
|
|
|
|
+
|
|
|
|
|
+ // 生成树形文本结构
|
|
|
|
|
+ const generateTreeText = useCallback(() => {
|
|
|
|
|
+ const lines = [];
|
|
|
|
|
+
|
|
|
|
|
+ // 递归生成树形文本
|
|
|
|
|
+ const traverse = (nodes, prefix = '', isLast = true, depth = 0) => {
|
|
|
|
|
+ nodes.forEach((node, index) => {
|
|
|
|
|
+ const isLastNode = index === nodes.length - 1;
|
|
|
|
|
+ const nodeData = initialNodes.find(n => n.id === node.id)?.data || {};
|
|
|
|
|
+ const nodeType = nodeData.nodeType || node.data?.nodeType || 'unknown';
|
|
|
|
|
+ const title = nodeData.title || node.data?.title || node.id;
|
|
|
|
|
+
|
|
|
|
|
+ // 优先从node.data获取score,然后从nodeData获取
|
|
|
|
|
+ let score = null;
|
|
|
|
|
+ if (node.data?.score !== undefined) {
|
|
|
|
|
+ score = node.data.score;
|
|
|
|
|
+ } else if (node.data?.relevance_score !== undefined) {
|
|
|
|
|
+ score = node.data.relevance_score;
|
|
|
|
|
+ } else if (nodeData.score !== undefined) {
|
|
|
|
|
+ score = nodeData.score;
|
|
|
|
|
+ } else if (nodeData.relevance_score !== undefined) {
|
|
|
|
|
+ score = nodeData.relevance_score;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const strategy = nodeData.strategy || node.data?.strategy || '';
|
|
|
|
|
+
|
|
|
|
|
+ // 构建当前行 - 确保score为数字且不是step/round节点时显示
|
|
|
|
|
+ const connector = isLastNode ? '└─' : '├─';
|
|
|
|
|
+ const scoreText = (nodeType !== 'step' && nodeType !== 'round' && typeof score === 'number') ?
|
|
|
|
|
+ \` (分数: \${score.toFixed(2)})\` : '';
|
|
|
|
|
+ const strategyText = strategy ? \` [\${strategy}]\` : '';
|
|
|
|
|
+
|
|
|
|
|
+ lines.push(\`\${prefix}\${connector} \${title}\${scoreText}\${strategyText}\`);
|
|
|
|
|
+
|
|
|
|
|
+ // 递归处理子节点
|
|
|
|
|
+ if (node.children && node.children.length > 0) {
|
|
|
|
|
+ const childPrefix = prefix + (isLastNode ? ' ' : '│ ');
|
|
|
|
|
+ traverse(node.children, childPrefix, isLastNode, depth + 1);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 添加标题
|
|
|
|
|
+ const rootNode = initialNodes.find(n => n.data?.level === 0);
|
|
|
|
|
+ if (rootNode) {
|
|
|
|
|
+ lines.push(\`📊 查询扩展树形结构\`);
|
|
|
|
|
+ lines.push(\`原始问题: \${rootNode.data.title || rootNode.data.query}\`);
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ traverse(treeRoots);
|
|
|
|
|
+
|
|
|
|
|
+ return lines.join('\\n');
|
|
|
|
|
+ }, [treeRoots, initialNodes]);
|
|
|
|
|
+
|
|
|
|
|
+ // 复制树形结构到剪贴板
|
|
|
|
|
+ const copyTreeToClipboard = useCallback(async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const treeText = generateTreeText();
|
|
|
|
|
+ await navigator.clipboard.writeText(treeText);
|
|
|
|
|
+ alert('✅ 树形结构已复制到剪贴板!');
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('复制失败:', err);
|
|
|
|
|
+ alert('❌ 复制失败,请手动复制');
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [generateTreeText]);
|
|
|
|
|
+
|
|
|
|
|
+ // 初始化树节点折叠状态
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ const getAllTreeKeys = (nodes) => {
|
|
|
|
|
+ const keys = new Set();
|
|
|
|
|
+ const traverse = (node) => {
|
|
|
|
|
+ if (node.children && node.children.length > 0) {
|
|
|
|
|
+ // 排除根节点
|
|
|
|
|
+ if (node.data.level !== 0) {
|
|
|
|
|
+ keys.add(node.treeKey);
|
|
|
|
|
+ }
|
|
|
|
|
+ node.children.forEach(traverse);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ nodes.forEach(traverse);
|
|
|
|
|
+ return keys;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ setCollapsedTreeNodes(getAllTreeKeys(treeRoots));
|
|
|
|
|
+ }, [treeRoots]);
|
|
|
|
|
+
|
|
|
|
|
+ const renderTree = useCallback((treeNodes, level = 0) => {
|
|
|
|
|
+ return treeNodes.map(node => {
|
|
|
|
|
+ // 使用 treeKey 来区分树中的不同实例
|
|
|
|
|
+ const isCollapsed = collapsedTreeNodes.has(node.treeKey);
|
|
|
|
|
+ const isSelected = selectedNodeId === node.id;
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <TreeNode
|
|
|
|
|
+ key={node.treeKey}
|
|
|
|
|
+ node={node}
|
|
|
|
|
+ level={level}
|
|
|
|
|
+ isCollapsed={isCollapsed}
|
|
|
|
|
+ isSelected={isSelected}
|
|
|
|
|
+ onToggle={() => {
|
|
|
|
|
+ setCollapsedTreeNodes(prev => {
|
|
|
|
|
+ const newSet = new Set(prev);
|
|
|
|
|
+ if (newSet.has(node.treeKey)) {
|
|
|
|
|
+ newSet.delete(node.treeKey);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ newSet.add(node.treeKey);
|
|
|
|
|
+ }
|
|
|
|
|
+ return newSet;
|
|
|
|
|
+ });
|
|
|
|
|
+ }}
|
|
|
|
|
+ onSelect={() => {
|
|
|
|
|
+ const nodeId = node.id;
|
|
|
|
|
+
|
|
|
|
|
+ // 展开所有祖先节点
|
|
|
|
|
+ const ancestorIds = [nodeId];
|
|
|
|
|
+ const findAncestors = (id) => {
|
|
|
|
|
+ initialEdges.forEach(edge => {
|
|
|
|
|
+ if (edge.target === id && !ancestorIds.includes(edge.source)) {
|
|
|
|
|
+ ancestorIds.push(edge.source);
|
|
|
|
|
+ findAncestors(edge.source);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ };
|
|
|
|
|
+ findAncestors(nodeId);
|
|
|
|
|
+
|
|
|
|
|
+ // 如果节点或其祖先被隐藏,先恢复它们
|
|
|
|
|
+ setHiddenNodes(prev => {
|
|
|
|
|
+ const newSet = new Set(prev);
|
|
|
|
|
+ ancestorIds.forEach(id => newSet.delete(id));
|
|
|
|
|
+ return newSet;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ setSelectedNodeId(nodeId);
|
|
|
|
|
+
|
|
|
|
|
+ // 获取选中节点的直接子节点
|
|
|
|
|
+ const childrenIds = [];
|
|
|
|
|
+ initialEdges.forEach(edge => {
|
|
|
|
|
+ if (edge.source === nodeId) {
|
|
|
|
|
+ childrenIds.push(edge.target);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ setCollapsedNodes(prev => {
|
|
|
|
|
+ const newSet = new Set(prev);
|
|
|
|
|
+ // 展开所有祖先节点
|
|
|
|
|
+ ancestorIds.forEach(id => newSet.delete(id));
|
|
|
|
|
+ // 展开选中节点本身
|
|
|
|
|
+ newSet.delete(nodeId);
|
|
|
|
|
+ // 展开选中节点的直接子节点
|
|
|
|
|
+ childrenIds.forEach(id => newSet.delete(id));
|
|
|
|
|
+ return newSet;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 延迟聚焦,等待节点展开和布局重新计算
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ fitView({
|
|
|
|
|
+ nodes: [{ id: nodeId }],
|
|
|
|
|
+ duration: 800,
|
|
|
|
|
+ padding: 0.3,
|
|
|
|
|
+ });
|
|
|
|
|
+ }, 300);
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {node.children && node.children.length > 0 && renderTree(node.children, level + 1)}
|
|
|
|
|
+ </TreeNode>
|
|
|
|
|
+ );
|
|
|
|
|
+ });
|
|
|
|
|
+ }, [collapsedTreeNodes, selectedNodeId, nodes, setCenter, initialEdges, setCollapsedNodes, fitView]);
|
|
|
|
|
+
|
|
|
|
|
+ console.log('📊 Rendering with', nodes.length, 'visible nodes and', edges.length, 'visible edges');
|
|
|
|
|
+
|
|
|
|
|
+ if (nodes.length === 0) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div style={{ padding: 50, color: 'red', fontSize: 20 }}>
|
|
|
|
|
+ ERROR: No nodes to display!
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div style={{ width: '100vw', height: '100vh', background: '#f9fafb', display: 'flex', flexDirection: 'column' }}>
|
|
|
|
|
+ {/* 顶部面包屑导航栏 */}
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ minHeight: '48px',
|
|
|
|
|
+ maxHeight: '120px',
|
|
|
|
|
+ background: 'white',
|
|
|
|
|
+ borderBottom: '1px solid #e5e7eb',
|
|
|
|
|
+ display: 'flex',
|
|
|
|
|
+ alignItems: 'flex-start',
|
|
|
|
|
+ padding: '12px 24px',
|
|
|
|
|
+ zIndex: 1000,
|
|
|
|
|
+ boxShadow: '0 1px 3px rgba(0, 0, 0, 0.05)',
|
|
|
|
|
+ flexShrink: 0,
|
|
|
|
|
+ overflowY: 'auto',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <div style={{ width: '100%' }}>
|
|
|
|
|
+ {selectedNodeId ? (
|
|
|
|
|
+ <div style={{ fontSize: '12px', color: '#6b7280' }}>
|
|
|
|
|
+ {/* 面包屑导航 - 显示所有路径 */}
|
|
|
|
|
+ {(() => {
|
|
|
|
|
+ const selectedNode = nodes.find(n => n.id === selectedNodeId);
|
|
|
|
|
+ if (!selectedNode) return null;
|
|
|
|
|
+
|
|
|
|
|
+ // 找到所有从根节点到当前节点的路径
|
|
|
|
|
+ const findAllPaths = (targetId) => {
|
|
|
|
|
+ const paths = [];
|
|
|
|
|
+
|
|
|
|
|
+ const buildPath = (nodeId, currentPath) => {
|
|
|
|
|
+ const node = initialNodes.find(n => n.id === nodeId);
|
|
|
|
|
+ if (!node) return;
|
|
|
|
|
+
|
|
|
|
|
+ const newPath = [node, ...currentPath];
|
|
|
|
|
+
|
|
|
|
|
+ // 找到所有父节点
|
|
|
|
|
+ const parents = initialEdges.filter(e => e.target === nodeId).map(e => e.source);
|
|
|
|
|
+
|
|
|
|
|
+ if (parents.length === 0) {
|
|
|
|
|
+ // 到达根节点
|
|
|
|
|
+ paths.push(newPath);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 递归处理所有父节点
|
|
|
|
|
+ parents.forEach(parentId => {
|
|
|
|
|
+ buildPath(parentId, newPath);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ buildPath(targetId, []);
|
|
|
|
|
+ return paths;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const allPaths = findAllPaths(selectedNodeId);
|
|
|
|
|
+
|
|
|
|
|
+ // 去重:将路径转换为字符串进行比较
|
|
|
|
|
+ const uniquePaths = [];
|
|
|
|
|
+ const pathStrings = new Set();
|
|
|
|
|
+ allPaths.forEach(path => {
|
|
|
|
|
+ const pathString = path.map(n => n.id).join('->');
|
|
|
|
|
+ if (!pathStrings.has(pathString)) {
|
|
|
|
|
+ pathStrings.add(pathString);
|
|
|
|
|
+ uniquePaths.push(path);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
|
|
|
|
+ {uniquePaths.map((path, pathIndex) => (
|
|
|
|
|
+ <div key={pathIndex} style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
|
|
|
|
|
+ {pathIndex > 0 && <span style={{ color: '#d1d5db', marginRight: '4px' }}>或</span>}
|
|
|
|
|
+ {path.map((node, index) => {
|
|
|
|
|
+ // 获取节点的 score、strategy 和 isSelected
|
|
|
|
|
+ const nodeScore = node.data.score ? parseFloat(node.data.score) : 0;
|
|
|
|
|
+ const nodeStrategy = node.data.strategy || '';
|
|
|
|
|
+ const strategyColor = getStrategyColor(nodeStrategy);
|
|
|
|
|
+ const nodeIsSelected = node.type === 'note' ? node.data.matchLevel !== 'unsatisfied' : node.data.isSelected !== false;
|
|
|
|
|
+ const nodeActualType = node.data.nodeType || node.type; // 获取实际节点类型
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <React.Fragment key={node.id + '-' + index}>
|
|
|
|
|
+ <span
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ const nodeId = node.id;
|
|
|
|
|
+
|
|
|
|
|
+ // 找到所有祖先节点
|
|
|
|
|
+ const ancestorIds = [nodeId];
|
|
|
|
|
+ const findAncestors = (id) => {
|
|
|
|
|
+ initialEdges.forEach(edge => {
|
|
|
|
|
+ if (edge.target === id && !ancestorIds.includes(edge.source)) {
|
|
|
|
|
+ ancestorIds.push(edge.source);
|
|
|
|
|
+ findAncestors(edge.source);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ };
|
|
|
|
|
+ findAncestors(nodeId);
|
|
|
|
|
+
|
|
|
|
|
+ // 如果节点或其祖先被隐藏,先恢复它们
|
|
|
|
|
+ setHiddenNodes(prev => {
|
|
|
|
|
+ const newSet = new Set(prev);
|
|
|
|
|
+ ancestorIds.forEach(id => newSet.delete(id));
|
|
|
|
|
+ return newSet;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 展开目录树中到达该节点的路径
|
|
|
|
|
+ // 需要找到所有包含该节点的树路径的 treeKey,并展开它们的父节点
|
|
|
|
|
+ setCollapsedTreeNodes(prev => {
|
|
|
|
|
+ const newSet = new Set(prev);
|
|
|
|
|
+ // 清空所有折叠状态,让目录树完全展开到选中节点
|
|
|
|
|
+ // 这样可以确保选中节点在目录中可见
|
|
|
|
|
+ return new Set();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ setSelectedNodeId(nodeId);
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ fitView({
|
|
|
|
|
+ nodes: [{ id: nodeId }],
|
|
|
|
|
+ duration: 800,
|
|
|
|
|
+ padding: 0.3,
|
|
|
|
|
+ });
|
|
|
|
|
+ }, 100);
|
|
|
|
|
+ }}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ padding: '6px 8px',
|
|
|
|
|
+ borderRadius: '4px',
|
|
|
|
|
+ background: 'white',
|
|
|
|
|
+ border: index === path.length - 1 ? '2px solid #3b82f6' : '1px solid #d1d5db',
|
|
|
|
|
+ color: '#374151',
|
|
|
|
|
+ fontWeight: index === path.length - 1 ? '600' : '400',
|
|
|
|
|
+ width: '180px',
|
|
|
|
|
+ cursor: 'pointer',
|
|
|
|
|
+ transition: 'all 0.2s ease',
|
|
|
|
|
+ position: 'relative',
|
|
|
|
|
+ display: 'inline-flex',
|
|
|
|
|
+ flexDirection: 'column',
|
|
|
|
|
+ gap: '4px',
|
|
|
|
|
+ }}
|
|
|
|
|
+ onMouseEnter={(e) => {
|
|
|
|
|
+ e.currentTarget.style.opacity = '0.8';
|
|
|
|
|
+ }}
|
|
|
|
|
+ onMouseLeave={(e) => {
|
|
|
|
|
+ e.currentTarget.style.opacity = '1';
|
|
|
|
|
+ }}
|
|
|
|
|
+ title={\`\${node.data.title || node.id} (Score: \${nodeScore.toFixed(2)}, Strategy: \${nodeStrategy}, Selected: \${nodeIsSelected})\`}
|
|
|
|
|
+ >
|
|
|
|
|
+ {/* 上半部分:竖线 + 图标 + 文字 + 分数 */}
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ display: 'flex',
|
|
|
|
|
+ alignItems: 'center',
|
|
|
|
|
+ gap: '6px',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {/* 策略类型竖线 */}
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ width: '3px',
|
|
|
|
|
+ height: '16px',
|
|
|
|
|
+ background: strategyColor,
|
|
|
|
|
+ borderRadius: '2px',
|
|
|
|
|
+ flexShrink: 0,
|
|
|
|
|
+ }} />
|
|
|
|
|
+
|
|
|
|
|
+ {/* 节点文字 */}
|
|
|
|
|
+ <span style={{
|
|
|
|
|
+ flex: 1,
|
|
|
|
|
+ fontSize: '12px',
|
|
|
|
|
+ color: nodeIsSelected ? '#374151' : '#ef4444',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {truncateMiddle(node.data.title || node.id, 18)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 分数显示 - 步骤和轮次节点不显示分数 */}
|
|
|
|
|
+ {nodeActualType !== 'step' && nodeActualType !== 'round' && (
|
|
|
|
|
+ <span style={{
|
|
|
|
|
+ fontSize: '10px',
|
|
|
|
|
+ color: '#6b7280',
|
|
|
|
|
+ fontWeight: '500',
|
|
|
|
|
+ flexShrink: 0,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {nodeScore.toFixed(2)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 分数下划线 - 步骤和轮次节点不显示 */}
|
|
|
|
|
+ {nodeActualType !== 'step' && nodeActualType !== 'round' && (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ width: (nodeScore * 100) + '%',
|
|
|
|
|
+ height: '2px',
|
|
|
|
|
+ background: getScoreColor(nodeScore),
|
|
|
|
|
+ borderRadius: '1px',
|
|
|
|
|
+ marginLeft: '9px',
|
|
|
|
|
+ }} />
|
|
|
|
|
+ )}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ {index < path.length - 1 && <span style={{ color: '#9ca3af' }}>›</span>}
|
|
|
|
|
+ </React.Fragment>
|
|
|
|
|
+ )})}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })()}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div style={{ fontSize: '13px', color: '#9ca3af', textAlign: 'center' }}>
|
|
|
|
|
+ 选择一个节点查看路径
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 主内容区:目录 + 画布 */}
|
|
|
|
|
+ <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
|
|
|
|
+ {/* 左侧目录树 */}
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ width: '320px',
|
|
|
|
|
+ background: 'white',
|
|
|
|
|
+ borderRight: '1px solid #e5e7eb',
|
|
|
|
|
+ display: 'flex',
|
|
|
|
|
+ flexDirection: 'column',
|
|
|
|
|
+ flexShrink: 0,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ padding: '12px 16px',
|
|
|
|
|
+ borderBottom: '1px solid #e5e7eb',
|
|
|
|
|
+ display: 'flex',
|
|
|
|
|
+ justifyContent: 'space-between',
|
|
|
|
|
+ alignItems: 'center',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <span style={{
|
|
|
|
|
+ fontWeight: '600',
|
|
|
|
|
+ fontSize: '14px',
|
|
|
|
|
+ color: '#111827',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ 节点目录
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <div style={{ display: 'flex', gap: '6px' }}>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ setCollapsedTreeNodes(new Set());
|
|
|
|
|
+ }}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ fontSize: '11px',
|
|
|
|
|
+ padding: '4px 8px',
|
|
|
|
|
+ borderRadius: '4px',
|
|
|
|
|
+ border: '1px solid #d1d5db',
|
|
|
|
|
+ background: 'white',
|
|
|
|
|
+ color: '#6b7280',
|
|
|
|
|
+ cursor: 'pointer',
|
|
|
|
|
+ fontWeight: '500',
|
|
|
|
|
+ }}
|
|
|
|
|
+ title="展开全部节点"
|
|
|
|
|
+ >
|
|
|
|
|
+ 全部展开
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ const getAllTreeKeys = (nodes) => {
|
|
|
|
|
+ const keys = new Set();
|
|
|
|
|
+ const traverse = (node) => {
|
|
|
|
|
+ if (node.children && node.children.length > 0) {
|
|
|
|
|
+ keys.add(node.treeKey);
|
|
|
|
|
+ node.children.forEach(traverse);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ nodes.forEach(traverse);
|
|
|
|
|
+ return keys;
|
|
|
|
|
+ };
|
|
|
|
|
+ setCollapsedTreeNodes(getAllTreeKeys(treeRoots));
|
|
|
|
|
+ }}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ fontSize: '11px',
|
|
|
|
|
+ padding: '4px 8px',
|
|
|
|
|
+ borderRadius: '4px',
|
|
|
|
|
+ border: '1px solid #d1d5db',
|
|
|
|
|
+ background: 'white',
|
|
|
|
|
+ color: '#6b7280',
|
|
|
|
|
+ cursor: 'pointer',
|
|
|
|
|
+ fontWeight: '500',
|
|
|
|
|
+ }}
|
|
|
|
|
+ title="折叠全部节点"
|
|
|
|
|
+ >
|
|
|
|
|
+ 全部折叠
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={copyTreeToClipboard}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ fontSize: '11px',
|
|
|
|
|
+ padding: '4px 8px',
|
|
|
|
|
+ borderRadius: '4px',
|
|
|
|
|
+ border: '1px solid #3b82f6',
|
|
|
|
|
+ background: '#3b82f6',
|
|
|
|
|
+ color: 'white',
|
|
|
|
|
+ cursor: 'pointer',
|
|
|
|
|
+ fontWeight: '500',
|
|
|
|
|
+ transition: 'all 0.2s',
|
|
|
|
|
+ }}
|
|
|
|
|
+ onMouseEnter={(e) => e.currentTarget.style.background = '#2563eb'}
|
|
|
|
|
+ onMouseLeave={(e) => e.currentTarget.style.background = '#3b82f6'}
|
|
|
|
|
+ title="复制树形结构为文本格式"
|
|
|
|
|
+ >
|
|
|
|
|
+ 📋 复制树形结构
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ flex: 1,
|
|
|
|
|
+ overflowX: 'auto',
|
|
|
|
|
+ overflowY: 'auto',
|
|
|
|
|
+ padding: '8px',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <div style={{ minWidth: 'fit-content' }}>
|
|
|
|
|
+ {renderTree(treeRoots)}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 画布区域 */}
|
|
|
|
|
+ <div style={{ flex: 1, position: 'relative' }}>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 右侧图例 */}
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ position: 'absolute',
|
|
|
|
|
+ top: '20px',
|
|
|
|
|
+ right: '20px',
|
|
|
|
|
+ background: 'white',
|
|
|
|
|
+ padding: '16px',
|
|
|
|
|
+ borderRadius: '12px',
|
|
|
|
|
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
|
|
|
|
|
+ zIndex: 1000,
|
|
|
|
|
+ maxWidth: '260px',
|
|
|
|
|
+ border: '1px solid #e5e7eb',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
|
|
|
|
+ <h3 style={{ fontSize: '14px', fontWeight: '600', color: '#111827', margin: 0 }}>图例</h3>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => setFocusMode(!focusMode)}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ fontSize: '11px',
|
|
|
|
|
+ padding: '4px 8px',
|
|
|
|
|
+ borderRadius: '4px',
|
|
|
|
|
+ border: '1px solid',
|
|
|
|
|
+ borderColor: focusMode ? '#3b82f6' : '#d1d5db',
|
|
|
|
|
+ background: focusMode ? '#3b82f6' : 'white',
|
|
|
|
|
+ color: focusMode ? 'white' : '#6b7280',
|
|
|
|
|
+ cursor: 'pointer',
|
|
|
|
|
+ fontWeight: '500',
|
|
|
|
|
+ }}
|
|
|
|
|
+ title={focusMode ? '关闭聚焦模式' : '开启聚焦模式'}
|
|
|
|
|
+ >
|
|
|
|
|
+ {focusMode ? '🎯 聚焦' : '📊 全图'}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div style={{ fontSize: '12px' }}>
|
|
|
|
|
+ {/* 画布节点展开/折叠控制 */}
|
|
|
|
|
+ <div style={{ marginBottom: '12px', paddingBottom: '12px', borderBottom: '1px solid #f3f4f6' }}>
|
|
|
|
|
+ <div style={{ fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#374151' }}>节点控制</div>
|
|
|
|
|
+ <div style={{ display: 'flex', gap: '6px' }}>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ setCollapsedNodes(new Set());
|
|
|
|
|
+ }}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ fontSize: '11px',
|
|
|
|
|
+ padding: '4px 8px',
|
|
|
|
|
+ borderRadius: '4px',
|
|
|
|
|
+ border: '1px solid #d1d5db',
|
|
|
|
|
+ background: 'white',
|
|
|
|
|
+ color: '#6b7280',
|
|
|
|
|
+ cursor: 'pointer',
|
|
|
|
|
+ fontWeight: '500',
|
|
|
|
|
+ flex: 1,
|
|
|
|
|
+ }}
|
|
|
|
|
+ title="展开画布中所有节点的子节点"
|
|
|
|
|
+ >
|
|
|
|
|
+ 全部展开
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ const allNodeIds = new Set(initialNodes.map(n => n.id));
|
|
|
|
|
+ setCollapsedNodes(allNodeIds);
|
|
|
|
|
+ }}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ fontSize: '11px',
|
|
|
|
|
+ padding: '4px 8px',
|
|
|
|
|
+ borderRadius: '4px',
|
|
|
|
|
+ border: '1px solid #d1d5db',
|
|
|
|
|
+ background: 'white',
|
|
|
|
|
+ color: '#6b7280',
|
|
|
|
|
+ cursor: 'pointer',
|
|
|
|
|
+ fontWeight: '500',
|
|
|
|
|
+ flex: 1,
|
|
|
|
|
+ }}
|
|
|
|
|
+ title="折叠画布中所有节点的子节点"
|
|
|
|
|
+ >
|
|
|
|
|
+ 全部折叠
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div style={{ paddingTop: '12px', borderTop: '1px solid #f3f4f6' }}>
|
|
|
|
|
+ <div style={{ fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#374151' }}>策略类型</div>
|
|
|
|
|
+ <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
|
|
|
|
|
+ <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#10b981', opacity: 0.7 }}></div>
|
|
|
|
|
+ <span style={{ color: '#6b7280', fontSize: '11px' }}>初始分词</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
|
|
|
|
|
+ <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#06b6d4', opacity: 0.7 }}></div>
|
|
|
|
|
+ <span style={{ color: '#6b7280', fontSize: '11px' }}>调用sug</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
|
|
|
|
|
+ <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#f59e0b', opacity: 0.7 }}></div>
|
|
|
|
|
+ <span style={{ color: '#6b7280', fontSize: '11px' }}>同义改写</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
|
|
|
|
|
+ <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#3b82f6', opacity: 0.7 }}></div>
|
|
|
|
|
+ <span style={{ color: '#6b7280', fontSize: '11px' }}>加词</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
|
|
|
|
|
+ <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#8b5cf6', opacity: 0.7 }}></div>
|
|
|
|
|
+ <span style={{ color: '#6b7280', fontSize: '11px' }}>抽象改写</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
|
|
|
|
|
+ <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#ec4899', opacity: 0.7 }}></div>
|
|
|
|
|
+ <span style={{ color: '#6b7280', fontSize: '11px' }}>基于部分匹配改进</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
|
|
|
|
|
+ <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#a855f7', opacity: 0.7 }}></div>
|
|
|
|
|
+ <span style={{ color: '#6b7280', fontSize: '11px' }}>结果分支-抽象改写</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
|
|
|
|
|
+ <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#fb923c', opacity: 0.7 }}></div>
|
|
|
|
|
+ <span style={{ color: '#6b7280', fontSize: '11px' }}>结果分支-同义改写</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ marginTop: '12px',
|
|
|
|
|
+ paddingTop: '12px',
|
|
|
|
|
+ borderTop: '1px solid #f3f4f6',
|
|
|
|
|
+ fontSize: '11px',
|
|
|
|
|
+ color: '#9ca3af',
|
|
|
|
|
+ lineHeight: '1.5',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ 💡 点击节点左上角 × 隐藏节点
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 隐藏节点列表 - 在图例内部 */}
|
|
|
|
|
+ {hiddenNodes.size > 0 && (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ marginTop: '12px',
|
|
|
|
|
+ paddingTop: '12px',
|
|
|
|
|
+ borderTop: '1px solid #f3f4f6',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
|
|
|
|
+ <h4 style={{ fontSize: '12px', fontWeight: '600', color: '#111827' }}>已隐藏节点</h4>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => setHiddenNodes(new Set())}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ fontSize: '10px',
|
|
|
|
|
+ color: '#3b82f6',
|
|
|
|
|
+ background: 'none',
|
|
|
|
|
+ border: 'none',
|
|
|
|
|
+ cursor: 'pointer',
|
|
|
|
|
+ textDecoration: 'underline',
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ 全部恢复
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div style={{ fontSize: '12px', maxHeight: '200px', overflow: 'auto' }}>
|
|
|
|
|
+ {Array.from(hiddenNodes).map(nodeId => {
|
|
|
|
|
+ const node = initialNodes.find(n => n.id === nodeId);
|
|
|
|
|
+ if (!node) return null;
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={nodeId}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ display: 'flex',
|
|
|
|
|
+ justifyContent: 'space-between',
|
|
|
|
|
+ alignItems: 'center',
|
|
|
|
|
+ padding: '6px 8px',
|
|
|
|
|
+ margin: '4px 0',
|
|
|
|
|
+ background: '#f9fafb',
|
|
|
|
|
+ borderRadius: '6px',
|
|
|
|
|
+ fontSize: '11px',
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <span
|
|
|
|
|
+ style={{
|
|
|
|
|
+ flex: 1,
|
|
|
|
|
+ overflow: 'hidden',
|
|
|
|
|
+ textOverflow: 'ellipsis',
|
|
|
|
|
+ whiteSpace: 'nowrap',
|
|
|
|
|
+ color: '#374151',
|
|
|
|
|
+ }}
|
|
|
|
|
+ title={node.data.title || nodeId}
|
|
|
|
|
+ >
|
|
|
|
|
+ {node.data.title || nodeId}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ setHiddenNodes(prev => {
|
|
|
|
|
+ const newSet = new Set(prev);
|
|
|
|
|
+ newSet.delete(nodeId);
|
|
|
|
|
+ return newSet;
|
|
|
|
|
+ });
|
|
|
|
|
+ }}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ marginLeft: '8px',
|
|
|
|
|
+ fontSize: '10px',
|
|
|
|
|
+ color: '#10b981',
|
|
|
|
|
+ background: 'none',
|
|
|
|
|
+ border: 'none',
|
|
|
|
|
+ cursor: 'pointer',
|
|
|
|
|
+ flexShrink: 0,
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ 恢复
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* React Flow 画布 */}
|
|
|
|
|
+ <ReactFlow
|
|
|
|
|
+ nodes={nodes}
|
|
|
|
|
+ edges={edges}
|
|
|
|
|
+ nodeTypes={nodeTypes}
|
|
|
|
|
+ fitView
|
|
|
|
|
+ fitViewOptions={{ padding: 0.2, duration: 500 }}
|
|
|
|
|
+ minZoom={0.1}
|
|
|
|
|
+ maxZoom={1.5}
|
|
|
|
|
+ nodesDraggable={true}
|
|
|
|
|
+ nodesConnectable={false}
|
|
|
|
|
+ elementsSelectable={true}
|
|
|
|
|
+ defaultEdgeOptions={{
|
|
|
|
|
+ type: 'smoothstep',
|
|
|
|
|
+ }}
|
|
|
|
|
+ proOptions={{ hideAttribution: true }}
|
|
|
|
|
+ onNodeClick={(event, clickedNode) => {
|
|
|
|
|
+ setSelectedNodeId(clickedNode.id);
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Controls style={{ bottom: '20px', left: 'auto', right: '20px' }} />
|
|
|
|
|
+ <Background variant="dots" gap={20} size={1} color="#e5e7eb" />
|
|
|
|
|
+ </ReactFlow>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function App() {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <ReactFlowProvider>
|
|
|
|
|
+ <FlowContent />
|
|
|
|
|
+ </ReactFlowProvider>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const root = createRoot(document.getElementById('root'));
|
|
|
|
|
+root.render(<App />);
|
|
|
|
|
+`;
|
|
|
|
|
+
|
|
|
|
|
+fs.writeFileSync(reactComponentPath, reactComponent);
|
|
|
|
|
+
|
|
|
|
|
+// 使用 esbuild 打包
|
|
|
|
|
+console.log('🎨 Building modern visualization...');
|
|
|
|
|
+
|
|
|
|
|
+build({
|
|
|
|
|
+ entryPoints: [reactComponentPath],
|
|
|
|
|
+ bundle: true,
|
|
|
|
|
+ outfile: path.join(__dirname, 'bundle_v2.js'),
|
|
|
|
|
+ format: 'iife',
|
|
|
|
|
+ loader: {
|
|
|
|
|
+ '.css': 'css',
|
|
|
|
|
+ },
|
|
|
|
|
+ minify: false,
|
|
|
|
|
+ sourcemap: 'inline',
|
|
|
|
|
+ // 强制所有 React 引用指向同一个位置,避免多副本
|
|
|
|
|
+ alias: {
|
|
|
|
|
+ 'react': path.join(__dirname, 'node_modules/react'),
|
|
|
|
|
+ 'react-dom': path.join(__dirname, 'node_modules/react-dom'),
|
|
|
|
|
+ 'react/jsx-runtime': path.join(__dirname, 'node_modules/react/jsx-runtime'),
|
|
|
|
|
+ 'react/jsx-dev-runtime': path.join(__dirname, 'node_modules/react/jsx-dev-runtime'),
|
|
|
|
|
+ },
|
|
|
|
|
+}).then(() => {
|
|
|
|
|
+ // 读取打包后的 JS
|
|
|
|
|
+ const bundleJs = fs.readFileSync(path.join(__dirname, 'bundle_v2.js'), 'utf-8');
|
|
|
|
|
+
|
|
|
|
|
+ // 读取 CSS
|
|
|
|
|
+ const cssPath = path.join(__dirname, 'node_modules/@xyflow/react/dist/style.css');
|
|
|
|
|
+ const css = fs.readFileSync(cssPath, 'utf-8');
|
|
|
|
|
+
|
|
|
|
|
+ // 生成最终 HTML
|
|
|
|
|
+ const html = `<!DOCTYPE html>
|
|
|
|
|
+<html lang="zh-CN">
|
|
|
|
|
+<head>
|
|
|
|
|
+ <meta charset="UTF-8">
|
|
|
|
|
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
+ <title>查询图可视化</title>
|
|
|
|
|
+ <link rel="preconnect" href="https://fonts.googleapis.com">
|
|
|
|
|
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
|
|
|
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
|
|
|
|
+ <script src="https://unpkg.com/dagre@0.8.5/dist/dagre.min.js"></script>
|
|
|
|
|
+ <script>
|
|
|
|
|
+ // 过滤特定的 React 警告
|
|
|
|
|
+ const originalError = console.error;
|
|
|
|
|
+ console.error = (...args) => {
|
|
|
|
|
+ if (typeof args[0] === 'string' && args[0].includes('Each child in a list should have a unique "key" prop')) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ originalError.apply(console, args);
|
|
|
|
|
+ };
|
|
|
|
|
+ </script>
|
|
|
|
|
+ <style>
|
|
|
|
|
+ * {
|
|
|
|
|
+ margin: 0;
|
|
|
|
|
+ padding: 0;
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+ }
|
|
|
|
|
+ body {
|
|
|
|
|
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ -webkit-font-smoothing: antialiased;
|
|
|
|
|
+ -moz-osx-font-smoothing: grayscale;
|
|
|
|
|
+ }
|
|
|
|
|
+ #root {
|
|
|
|
|
+ width: 100vw;
|
|
|
|
|
+ height: 100vh;
|
|
|
|
|
+ }
|
|
|
|
|
+ ${css}
|
|
|
|
|
+
|
|
|
|
|
+ /* 自定义样式覆盖 */
|
|
|
|
|
+ .react-flow__edge-path {
|
|
|
|
|
+ stroke-linecap: round;
|
|
|
|
|
+ }
|
|
|
|
|
+ .react-flow__controls {
|
|
|
|
|
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
|
|
|
+ border: 1px solid #e5e7eb;
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .react-flow__controls-button {
|
|
|
|
|
+ border: none;
|
|
|
|
|
+ border-bottom: 1px solid #e5e7eb;
|
|
|
|
|
+ }
|
|
|
|
|
+ .react-flow__controls-button:hover {
|
|
|
|
|
+ background: #f9fafb;
|
|
|
|
|
+ }
|
|
|
|
|
+ </style>
|
|
|
|
|
+</head>
|
|
|
|
|
+<body>
|
|
|
|
|
+ <div id="root"></div>
|
|
|
|
|
+ <script>${bundleJs}</script>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>`;
|
|
|
|
|
+
|
|
|
|
|
+ // 写入输出文件
|
|
|
|
|
+ fs.writeFileSync(outputFile, html);
|
|
|
|
|
+
|
|
|
|
|
+ // 清理临时文件
|
|
|
|
|
+ fs.unlinkSync(reactComponentPath);
|
|
|
|
|
+ fs.unlinkSync(path.join(__dirname, 'bundle_v2.js'));
|
|
|
|
|
+
|
|
|
|
|
+ console.log('✅ Visualization generated: ' + outputFile);
|
|
|
|
|
+ console.log('📊 Nodes: ' + Object.keys(data.nodes).length);
|
|
|
|
|
+ console.log('🔗 Edges: ' + data.edges.length);
|
|
|
|
|
+}).catch(error => {
|
|
|
|
|
+ console.error('❌ Build error:', error);
|
|
|
|
|
+ process.exit(1);
|
|
|
|
|
+});
|