| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966 |
- #!/usr/bin/env node
- const fs = require('fs');
- const path = require('path');
- const { build } = require('esbuild');
- // 读取命令行参数
- const args = process.argv.slice(2);
- if (args.length === 0) {
- console.error('Usage: node index.js <path-to-query_graph.json> [output.html]');
- process.exit(1);
- }
- const inputFile = args[0];
- const outputFile = args[1] || 'query_graph_output.html';
- // 读取输入数据
- const data = JSON.parse(fs.readFileSync(inputFile, 'utf-8'));
- // 创建临时 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 QueryNode({ id, data, sourcePosition, targetPosition }) {
- // 所有节点默认展开
- const expanded = true;
- return (
- <div>
- <Handle
- type="target"
- position={targetPosition || Position.Left}
- style={{ background: '#667eea', width: 8, height: 8 }}
- />
- <div
- style={{
- padding: '12px',
- borderRadius: '8px',
- border: data.isHighlighted ? '3px solid #667eea' :
- data.isCollapsed ? '2px solid #667eea' :
- data.isSelected === false ? '2px dashed #d1d5db' :
- data.level === 0 ? '2px solid #8b5cf6' : '1px solid #e5e7eb',
- 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' }}>
- <span style={{ fontSize: '16px', marginRight: '8px' }}>📝</span>
- <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',
- };
- 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);
- 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',
- }}>
- {/* 节点类型图标 */}
- <span style={{
- fontSize: '12px',
- flexShrink: 0,
- }}>
- {node.type === 'note' ? '📝' : '🔍'}
- </span>
- <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>
- {/* 分数显示 */}
- <span style={{
- fontSize: '11px',
- color: '#6b7280',
- fontWeight: '500',
- flexShrink: 0,
- }}>
- {score.toFixed(2)}
- </span>
- </div>
- {/* 分数下划线 */}
- <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]) => {
- if (node.type === 'query') {
- // 使用 query_level 作为唯一ID
- const canvasId = node.query + '_' + node.level;
- originalIdToCanvasId[originalId] = canvasId;
- // 如果这个 canvasId 还没有创建过节点,则创建
- if (!canvasIdToNodeData[canvasId]) {
- canvasIdToNodeData[canvasId] = true;
- nodes.push({
- id: canvasId, // 使用 query_level 格式
- originalId: originalId, // 保留原始ID用于调试
- type: 'query',
- data: {
- title: node.query,
- level: node.level,
- score: node.relevance_score.toFixed(2),
- strategy: node.strategy,
- parent: node.parent_query,
- isSelected: node.is_selected,
- evaluationReason: node.evaluation_reason || '',
- },
- position: { x: 0, y: 0 }, // 初始位置,会被 dagre 覆盖
- });
- }
- } else if (node.type === 'note') {
- // note节点直接使用原始ID
- originalIdToCanvasId[originalId] = originalId;
- if (!canvasIdToNodeData[originalId]) {
- canvasIdToNodeData[originalId] = true;
- nodes.push({
- id: originalId,
- originalId: originalId,
- type: 'note',
- data: {
- title: node.title,
- matchLevel: node.match_level,
- score: node.relevance_score.toFixed(2),
- description: node.desc,
- isSelected: node.is_selected !== undefined ? node.is_selected : true,
- imageList: node.image_list || [], // 添加图片列表
- noteUrl: node.note_url || '', // 添加帖子链接
- evaluationReason: node.evaluation_reason || '', // 添加评估理由
- },
- 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]);
- // 初始化树节点折叠状态
- 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;
- 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={{
- fontSize: '11px',
- flexShrink: 0,
- }}>
- {node.type === 'note' ? '📝' : '🔍'}
- </span>
- {/* 节点文字 */}
- <span style={{
- flex: 1,
- fontSize: '12px',
- color: nodeIsSelected ? '#374151' : '#ef4444',
- }}>
- {truncateMiddle(node.data.title || node.id, 18)}
- </span>
- {/* 分数显示 */}
- <span style={{
- fontSize: '10px',
- color: '#6b7280',
- fontWeight: '500',
- flexShrink: 0,
- }}>
- {nodeScore.toFixed(2)}
- </span>
- </div>
- {/* 分数下划线 */}
- <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>
- </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);
- });
|