visualize_v2.js 66 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const { build } = require('esbuild');
  5. // 读取命令行参数
  6. const args = process.argv.slice(2);
  7. if (args.length === 0) {
  8. console.error('Usage: node visualize_v2.js <path-to-query_graph.json> [output.html]');
  9. process.exit(1);
  10. }
  11. const inputFile = args[0];
  12. const outputFile = args[1] || 'query_graph_output.html';
  13. // 读取输入数据
  14. const data = JSON.parse(fs.readFileSync(inputFile, 'utf-8'));
  15. // 创建临时 React 组件文件
  16. const reactComponentPath = path.join(__dirname, 'temp_flow_component_v2.jsx');
  17. const reactComponent = `
  18. import React, { useState, useCallback, useMemo, useEffect } from 'react';
  19. import { createRoot } from 'react-dom/client';
  20. import {
  21. ReactFlow,
  22. Controls,
  23. Background,
  24. useNodesState,
  25. useEdgesState,
  26. Handle,
  27. Position,
  28. useReactFlow,
  29. ReactFlowProvider,
  30. } from '@xyflow/react';
  31. import '@xyflow/react/dist/style.css';
  32. const data = ${JSON.stringify(data, null, 2)};
  33. // 查询节点组件 - 卡片样式
  34. function QueryNode({ id, data, sourcePosition, targetPosition }) {
  35. // 所有节点默认展开
  36. const expanded = true;
  37. return (
  38. <div>
  39. <Handle
  40. type="target"
  41. position={targetPosition || Position.Left}
  42. style={{ background: '#667eea', width: 8, height: 8 }}
  43. />
  44. <div
  45. style={{
  46. padding: '12px',
  47. borderRadius: '8px',
  48. border: data.isHighlighted ? '3px solid #667eea' :
  49. data.isCollapsed ? '2px solid #667eea' :
  50. data.isSelected === false ? '2px dashed #d1d5db' :
  51. data.level === 0 ? '2px solid #8b5cf6' : '1px solid #e5e7eb',
  52. background: data.isHighlighted ? '#eef2ff' :
  53. data.isSelected === false ? '#f9fafb' : 'white',
  54. minWidth: '200px',
  55. maxWidth: '280px',
  56. boxShadow: data.isHighlighted ? '0 0 0 4px rgba(102, 126, 234, 0.25), 0 4px 16px rgba(102, 126, 234, 0.4)' :
  57. data.isCollapsed ? '0 4px 12px rgba(102, 126, 234, 0.15)' :
  58. data.level === 0 ? '0 4px 12px rgba(139, 92, 246, 0.15)' : '0 2px 6px rgba(0, 0, 0, 0.06)',
  59. transition: 'all 0.3s ease',
  60. cursor: 'pointer',
  61. position: 'relative',
  62. opacity: data.isSelected === false ? 0.6 : 1,
  63. }}
  64. >
  65. {/* 折叠当前节点按钮 - 左边 */}
  66. <div
  67. style={{
  68. position: 'absolute',
  69. top: '6px',
  70. left: '6px',
  71. width: '20px',
  72. height: '20px',
  73. borderRadius: '50%',
  74. background: '#f59e0b',
  75. color: 'white',
  76. display: 'flex',
  77. alignItems: 'center',
  78. justifyContent: 'center',
  79. fontSize: '11px',
  80. fontWeight: 'bold',
  81. cursor: 'pointer',
  82. transition: 'all 0.2s ease',
  83. zIndex: 10,
  84. }}
  85. onClick={(e) => {
  86. e.stopPropagation();
  87. if (data.onHideSelf) {
  88. data.onHideSelf();
  89. }
  90. }}
  91. onMouseEnter={(e) => {
  92. e.currentTarget.style.background = '#d97706';
  93. }}
  94. onMouseLeave={(e) => {
  95. e.currentTarget.style.background = '#f59e0b';
  96. }}
  97. title="隐藏当前节点"
  98. >
  99. ×
  100. </div>
  101. {/* 聚焦按钮 - 右上角 */}
  102. <div
  103. style={{
  104. position: 'absolute',
  105. top: '6px',
  106. right: '6px',
  107. width: '20px',
  108. height: '20px',
  109. borderRadius: '50%',
  110. background: data.isFocused ? '#10b981' : '#e5e7eb',
  111. color: data.isFocused ? 'white' : '#6b7280',
  112. display: 'flex',
  113. alignItems: 'center',
  114. justifyContent: 'center',
  115. fontSize: '11px',
  116. fontWeight: 'bold',
  117. cursor: 'pointer',
  118. transition: 'all 0.2s ease',
  119. zIndex: 10,
  120. }}
  121. onClick={(e) => {
  122. e.stopPropagation();
  123. if (data.onFocus) {
  124. data.onFocus();
  125. }
  126. }}
  127. onMouseEnter={(e) => {
  128. if (!data.isFocused) {
  129. e.currentTarget.style.background = '#d1d5db';
  130. }
  131. }}
  132. onMouseLeave={(e) => {
  133. if (!data.isFocused) {
  134. e.currentTarget.style.background = '#e5e7eb';
  135. }
  136. }}
  137. title={data.isFocused ? '取消聚焦' : '聚焦到此节点'}
  138. >
  139. 🎯
  140. </div>
  141. {/* 折叠/展开子节点按钮 - 右边第二个位置 */}
  142. {data.hasChildren && (
  143. <div
  144. style={{
  145. position: 'absolute',
  146. top: '6px',
  147. right: '30px',
  148. width: '20px',
  149. height: '20px',
  150. borderRadius: '50%',
  151. background: data.isCollapsed ? '#667eea' : '#e5e7eb',
  152. color: data.isCollapsed ? 'white' : '#6b7280',
  153. display: 'flex',
  154. alignItems: 'center',
  155. justifyContent: 'center',
  156. fontSize: '11px',
  157. fontWeight: 'bold',
  158. cursor: 'pointer',
  159. transition: 'all 0.2s ease',
  160. zIndex: 10,
  161. }}
  162. onClick={(e) => {
  163. e.stopPropagation();
  164. data.onToggleCollapse();
  165. }}
  166. title={data.isCollapsed ? '展开子节点' : '折叠子节点'}
  167. >
  168. {data.isCollapsed ? '+' : '−'}
  169. </div>
  170. )}
  171. {/* 卡片内容 */}
  172. <div>
  173. {/* 标题行 */}
  174. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px', paddingLeft: '24px', paddingRight: data.hasChildren ? '54px' : '28px' }}>
  175. <div style={{ flex: 1 }}>
  176. <div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '3px' }}>
  177. <div style={{
  178. fontSize: '13px',
  179. fontWeight: data.level === 0 ? '700' : '600',
  180. color: data.level === 0 ? '#6b21a8' : '#1f2937',
  181. lineHeight: '1.3',
  182. flex: 1,
  183. }}>
  184. {data.title}
  185. </div>
  186. {data.isSelected === false && (
  187. <div style={{
  188. fontSize: '9px',
  189. padding: '1px 4px',
  190. borderRadius: '3px',
  191. background: '#fee2e2',
  192. color: '#991b1b',
  193. fontWeight: '500',
  194. flexShrink: 0,
  195. }}>
  196. 未选中
  197. </div>
  198. )}
  199. </div>
  200. </div>
  201. </div>
  202. {/* 展开的详细信息 - 始终显示 */}
  203. <div style={{ fontSize: '11px', lineHeight: 1.4 }}>
  204. <div style={{ display: 'flex', gap: '4px', marginBottom: '6px', flexWrap: 'wrap' }}>
  205. <span style={{
  206. display: 'inline-block',
  207. padding: '1px 6px',
  208. borderRadius: '10px',
  209. background: '#eff6ff',
  210. color: '#3b82f6',
  211. fontSize: '10px',
  212. fontWeight: '500',
  213. }}>
  214. Lv.{data.level}
  215. </span>
  216. <span style={{
  217. display: 'inline-block',
  218. padding: '1px 6px',
  219. borderRadius: '10px',
  220. background: '#f0fdf4',
  221. color: '#16a34a',
  222. fontSize: '10px',
  223. fontWeight: '500',
  224. }}>
  225. {data.score}
  226. </span>
  227. {data.strategy && data.strategy !== 'root' && (
  228. <span style={{
  229. display: 'inline-block',
  230. padding: '1px 6px',
  231. borderRadius: '10px',
  232. background: '#fef3c7',
  233. color: '#92400e',
  234. fontSize: '10px',
  235. fontWeight: '500',
  236. }}>
  237. {data.strategy}
  238. </span>
  239. )}
  240. </div>
  241. {data.parent && (
  242. <div style={{ color: '#6b7280', fontSize: '10px', marginTop: '4px', paddingTop: '4px', borderTop: '1px solid #f3f4f6' }}>
  243. <strong>Parent:</strong> {data.parent}
  244. </div>
  245. )}
  246. {data.evaluationReason && (
  247. <div style={{
  248. marginTop: '6px',
  249. paddingTop: '6px',
  250. borderTop: '1px solid #f3f4f6',
  251. fontSize: '10px',
  252. color: '#6b7280',
  253. lineHeight: '1.5',
  254. }}>
  255. <strong style={{ color: '#4b5563' }}>评估:</strong>
  256. <div style={{ marginTop: '2px' }}>{data.evaluationReason}</div>
  257. </div>
  258. )}
  259. </div>
  260. </div>
  261. </div>
  262. <Handle
  263. type="source"
  264. position={sourcePosition || Position.Right}
  265. style={{ background: '#667eea', width: 8, height: 8 }}
  266. />
  267. </div>
  268. );
  269. }
  270. // 笔记节点组件 - 卡片样式,带轮播图
  271. function NoteNode({ id, data, sourcePosition, targetPosition }) {
  272. const [currentImageIndex, setCurrentImageIndex] = useState(0);
  273. const expanded = true;
  274. const hasImages = data.imageList && data.imageList.length > 0;
  275. const nextImage = (e) => {
  276. e.stopPropagation();
  277. if (hasImages) {
  278. setCurrentImageIndex((prev) => (prev + 1) % data.imageList.length);
  279. }
  280. };
  281. const prevImage = (e) => {
  282. e.stopPropagation();
  283. if (hasImages) {
  284. setCurrentImageIndex((prev) => (prev - 1 + data.imageList.length) % data.imageList.length);
  285. }
  286. };
  287. return (
  288. <div>
  289. <Handle
  290. type="target"
  291. position={targetPosition || Position.Left}
  292. style={{ background: '#ec4899', width: 8, height: 8 }}
  293. />
  294. <div
  295. style={{
  296. padding: '14px',
  297. borderRadius: '20px',
  298. border: data.isHighlighted ? '3px solid #ec4899' : '2px solid #fce7f3',
  299. background: data.isHighlighted ? '#eef2ff' : 'white',
  300. minWidth: '220px',
  301. maxWidth: '300px',
  302. 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)',
  303. transition: 'all 0.3s ease',
  304. cursor: 'pointer',
  305. }}
  306. >
  307. {/* 笔记图标和标题 */}
  308. <div style={{ display: 'flex', alignItems: 'flex-start', marginBottom: '8px' }}>
  309. <span style={{ fontSize: '16px', marginRight: '8px' }}>📝</span>
  310. <div style={{ flex: 1 }}>
  311. <div style={{
  312. fontSize: '13px',
  313. fontWeight: '600',
  314. color: '#831843',
  315. lineHeight: '1.4',
  316. marginBottom: '4px',
  317. }}>
  318. {data.title}
  319. </div>
  320. </div>
  321. </div>
  322. {/* 轮播图 */}
  323. {hasImages && (
  324. <div style={{
  325. position: 'relative',
  326. marginBottom: '8px',
  327. borderRadius: '12px',
  328. overflow: 'hidden',
  329. }}>
  330. <img
  331. src={data.imageList[currentImageIndex].image_url}
  332. alt={\`Image \${currentImageIndex + 1}\`}
  333. style={{
  334. width: '100%',
  335. height: '160px',
  336. objectFit: 'cover',
  337. display: 'block',
  338. }}
  339. onError={(e) => {
  340. e.target.style.display = 'none';
  341. }}
  342. />
  343. {data.imageList.length > 1 && (
  344. <>
  345. {/* 左右切换按钮 */}
  346. <button
  347. onClick={prevImage}
  348. style={{
  349. position: 'absolute',
  350. left: '4px',
  351. top: '50%',
  352. transform: 'translateY(-50%)',
  353. background: 'rgba(0, 0, 0, 0.5)',
  354. color: 'white',
  355. border: 'none',
  356. borderRadius: '50%',
  357. width: '24px',
  358. height: '24px',
  359. cursor: 'pointer',
  360. display: 'flex',
  361. alignItems: 'center',
  362. justifyContent: 'center',
  363. fontSize: '14px',
  364. }}
  365. >
  366. </button>
  367. <button
  368. onClick={nextImage}
  369. style={{
  370. position: 'absolute',
  371. right: '4px',
  372. top: '50%',
  373. transform: 'translateY(-50%)',
  374. background: 'rgba(0, 0, 0, 0.5)',
  375. color: 'white',
  376. border: 'none',
  377. borderRadius: '50%',
  378. width: '24px',
  379. height: '24px',
  380. cursor: 'pointer',
  381. display: 'flex',
  382. alignItems: 'center',
  383. justifyContent: 'center',
  384. fontSize: '14px',
  385. }}
  386. >
  387. </button>
  388. {/* 图片计数 */}
  389. <div style={{
  390. position: 'absolute',
  391. bottom: '4px',
  392. right: '4px',
  393. background: 'rgba(0, 0, 0, 0.6)',
  394. color: 'white',
  395. padding: '2px 6px',
  396. borderRadius: '10px',
  397. fontSize: '10px',
  398. }}>
  399. {currentImageIndex + 1}/{data.imageList.length}
  400. </div>
  401. </>
  402. )}
  403. </div>
  404. )}
  405. {/* 标签 */}
  406. <div style={{ display: 'flex', gap: '6px', marginBottom: '8px', flexWrap: 'wrap' }}>
  407. <span style={{
  408. display: 'inline-block',
  409. padding: '2px 8px',
  410. borderRadius: '12px',
  411. background: '#fff1f2',
  412. color: '#be123c',
  413. fontSize: '10px',
  414. fontWeight: '500',
  415. }}>
  416. {data.matchLevel}
  417. </span>
  418. <span style={{
  419. display: 'inline-block',
  420. padding: '2px 8px',
  421. borderRadius: '12px',
  422. background: '#fff7ed',
  423. color: '#c2410c',
  424. fontSize: '10px',
  425. fontWeight: '500',
  426. }}>
  427. Score: {data.score}
  428. </span>
  429. </div>
  430. {/* 描述 */}
  431. {expanded && data.description && (
  432. <div style={{
  433. fontSize: '11px',
  434. color: '#9f1239',
  435. lineHeight: '1.5',
  436. paddingTop: '8px',
  437. borderTop: '1px solid #fbcfe8',
  438. }}>
  439. {data.description}
  440. </div>
  441. )}
  442. {/* 评估理由 */}
  443. {expanded && data.evaluationReason && (
  444. <div style={{
  445. fontSize: '10px',
  446. color: '#831843',
  447. lineHeight: '1.5',
  448. paddingTop: '8px',
  449. marginTop: '8px',
  450. borderTop: '1px solid #fbcfe8',
  451. }}>
  452. <strong style={{ color: '#9f1239' }}>评估:</strong>
  453. <div style={{ marginTop: '2px' }}>{data.evaluationReason}</div>
  454. </div>
  455. )}
  456. </div>
  457. <Handle
  458. type="source"
  459. position={sourcePosition || Position.Right}
  460. style={{ background: '#ec4899', width: 8, height: 8 }}
  461. />
  462. </div>
  463. );
  464. }
  465. const nodeTypes = {
  466. query: QueryNode,
  467. note: NoteNode,
  468. };
  469. // 根据 score 获取颜色
  470. function getScoreColor(score) {
  471. if (score >= 0.7) return '#10b981'; // 绿色 - 高分
  472. if (score >= 0.4) return '#f59e0b'; // 橙色 - 中分
  473. return '#ef4444'; // 红色 - 低分
  474. }
  475. // 截断文本,保留头尾,中间显示省略号
  476. function truncateMiddle(text, maxLength = 20) {
  477. if (!text || text.length <= maxLength) return text;
  478. const headLength = Math.ceil(maxLength * 0.4);
  479. const tailLength = Math.floor(maxLength * 0.4);
  480. const head = text.substring(0, headLength);
  481. const tail = text.substring(text.length - tailLength);
  482. return \`\${head}...\${tail}\`;
  483. }
  484. // 根据策略获取颜色
  485. function getStrategyColor(strategy) {
  486. const strategyColors = {
  487. '初始分词': '#10b981',
  488. '调用sug': '#06b6d4',
  489. '同义改写': '#f59e0b',
  490. '加词': '#3b82f6',
  491. '抽象改写': '#8b5cf6',
  492. '基于部分匹配改进': '#ec4899',
  493. '结果分支-抽象改写': '#a855f7',
  494. '结果分支-同义改写': '#fb923c',
  495. };
  496. return strategyColors[strategy] || '#9ca3af';
  497. }
  498. // 树节点组件
  499. function TreeNode({ node, level, children, isCollapsed, onToggle, isSelected, onSelect }) {
  500. const hasChildren = children && children.length > 0;
  501. const score = node.data.score ? parseFloat(node.data.score) : 0;
  502. const strategy = node.data.strategy || '';
  503. const strategyColor = getStrategyColor(strategy);
  504. return (
  505. <div style={{ marginLeft: level * 12 + 'px' }}>
  506. <div
  507. style={{
  508. padding: '6px 8px',
  509. borderRadius: '4px',
  510. cursor: 'pointer',
  511. background: 'transparent',
  512. border: isSelected ? '1px solid #3b82f6' : '1px solid transparent',
  513. display: 'flex',
  514. alignItems: 'center',
  515. gap: '6px',
  516. transition: 'all 0.2s ease',
  517. position: 'relative',
  518. overflow: 'visible',
  519. }}
  520. onMouseEnter={(e) => {
  521. if (!isSelected) e.currentTarget.style.background = '#f9fafb';
  522. }}
  523. onMouseLeave={(e) => {
  524. if (!isSelected) e.currentTarget.style.background = 'transparent';
  525. }}
  526. >
  527. {/* 策略类型竖线 */}
  528. <div style={{
  529. width: '3px',
  530. height: '20px',
  531. background: strategyColor,
  532. borderRadius: '2px',
  533. flexShrink: 0,
  534. position: 'relative',
  535. zIndex: 1,
  536. }} />
  537. {hasChildren && (
  538. <span
  539. style={{
  540. fontSize: '10px',
  541. color: '#6b7280',
  542. cursor: 'pointer',
  543. width: '16px',
  544. textAlign: 'center',
  545. position: 'relative',
  546. zIndex: 1,
  547. }}
  548. onClick={(e) => {
  549. e.stopPropagation();
  550. onToggle();
  551. }}
  552. >
  553. {isCollapsed ? '▶' : '▼'}
  554. </span>
  555. )}
  556. {!hasChildren && <span style={{ width: '16px', position: 'relative', zIndex: 1 }}></span>}
  557. <div
  558. style={{
  559. flex: 1,
  560. fontSize: '12px',
  561. color: '#374151',
  562. position: 'relative',
  563. zIndex: 1,
  564. minWidth: 0,
  565. display: 'flex',
  566. flexDirection: 'column',
  567. gap: '4px',
  568. }}
  569. onClick={onSelect}
  570. >
  571. <div style={{
  572. display: 'flex',
  573. alignItems: 'center',
  574. gap: '8px',
  575. }}>
  576. {/* 节点类型图标 */}
  577. <span style={{
  578. fontSize: '12px',
  579. flexShrink: 0,
  580. }}>
  581. {node.type === 'note' ? '📝' : '🔍'}
  582. </span>
  583. <div style={{
  584. fontWeight: level === 0 ? '600' : '400',
  585. maxWidth: '180px',
  586. flex: 1,
  587. minWidth: 0,
  588. color: (node.type === 'note' ? node.data.matchLevel === 'unsatisfied' : node.data.isSelected === false) ? '#ef4444' : '#374151',
  589. }}
  590. title={node.data.title || node.id}
  591. >
  592. {truncateMiddle(node.data.title || node.id, 18)}
  593. </div>
  594. {/* 分数显示 */}
  595. <span style={{
  596. fontSize: '11px',
  597. color: '#6b7280',
  598. fontWeight: '500',
  599. flexShrink: 0,
  600. }}>
  601. {score.toFixed(2)}
  602. </span>
  603. </div>
  604. {/* 分数下划线 */}
  605. <div style={{
  606. width: (score * 100) + '%',
  607. height: '2px',
  608. background: getScoreColor(score),
  609. borderRadius: '1px',
  610. }} />
  611. </div>
  612. </div>
  613. {hasChildren && !isCollapsed && (
  614. <div>
  615. {children}
  616. </div>
  617. )}
  618. </div>
  619. );
  620. }
  621. // 使用 dagre 自动布局
  622. function getLayoutedElements(nodes, edges, direction = 'LR') {
  623. console.log('🎯 Starting layout with dagre...');
  624. console.log('Input:', nodes.length, 'nodes,', edges.length, 'edges');
  625. // 检查 dagre 是否加载
  626. if (typeof window === 'undefined' || typeof window.dagre === 'undefined') {
  627. console.warn('⚠️ Dagre not loaded, using fallback layout');
  628. // 降级到简单布局
  629. const levelGroups = {};
  630. nodes.forEach(node => {
  631. const level = node.data.level || 0;
  632. if (!levelGroups[level]) levelGroups[level] = [];
  633. levelGroups[level].push(node);
  634. });
  635. Object.entries(levelGroups).forEach(([level, nodeList]) => {
  636. const x = parseInt(level) * 350;
  637. nodeList.forEach((node, index) => {
  638. node.position = { x, y: index * 150 };
  639. node.targetPosition = 'left';
  640. node.sourcePosition = 'right';
  641. });
  642. });
  643. return { nodes, edges };
  644. }
  645. try {
  646. const dagreGraph = new window.dagre.graphlib.Graph();
  647. dagreGraph.setDefaultEdgeLabel(() => ({}));
  648. const isHorizontal = direction === 'LR';
  649. dagreGraph.setGraph({
  650. rankdir: direction,
  651. nodesep: 120, // 垂直间距 - 增加以避免节点重叠
  652. ranksep: 280, // 水平间距 - 增加以容纳更宽的节点
  653. });
  654. // 添加节点 - 根据节点类型设置不同的尺寸
  655. nodes.forEach((node) => {
  656. let nodeWidth = 280;
  657. let nodeHeight = 180;
  658. // note 节点有轮播图,需要更大的空间
  659. if (node.type === 'note') {
  660. nodeWidth = 320;
  661. nodeHeight = 350; // 增加高度以容纳轮播图
  662. }
  663. dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
  664. });
  665. // 添加边
  666. edges.forEach((edge) => {
  667. dagreGraph.setEdge(edge.source, edge.target);
  668. });
  669. // 计算布局
  670. window.dagre.layout(dagreGraph);
  671. console.log('✅ Dagre layout completed');
  672. // 更新节点位置和 handle 位置
  673. nodes.forEach((node) => {
  674. const nodeWithPosition = dagreGraph.node(node.id);
  675. if (!nodeWithPosition) {
  676. console.warn('Node position not found for:', node.id);
  677. return;
  678. }
  679. node.targetPosition = isHorizontal ? 'left' : 'top';
  680. node.sourcePosition = isHorizontal ? 'right' : 'bottom';
  681. // 将 dagre 的中心点位置转换为 React Flow 的左上角位置
  682. node.position = {
  683. x: nodeWithPosition.x - nodeWidth / 2,
  684. y: nodeWithPosition.y - nodeHeight / 2,
  685. };
  686. });
  687. console.log('✅ Layout completed, sample node:', nodes[0]);
  688. return { nodes, edges };
  689. } catch (error) {
  690. console.error('❌ Error in dagre layout:', error);
  691. console.error('Error details:', error.message, error.stack);
  692. // 降级处理
  693. console.log('Using fallback layout...');
  694. const levelGroups = {};
  695. nodes.forEach(node => {
  696. const level = node.data.level || 0;
  697. if (!levelGroups[level]) levelGroups[level] = [];
  698. levelGroups[level].push(node);
  699. });
  700. Object.entries(levelGroups).forEach(([level, nodeList]) => {
  701. const x = parseInt(level) * 350;
  702. nodeList.forEach((node, index) => {
  703. node.position = { x, y: index * 150 };
  704. node.targetPosition = 'left';
  705. node.sourcePosition = 'right';
  706. });
  707. });
  708. return { nodes, edges };
  709. }
  710. }
  711. function transformData(data) {
  712. const nodes = [];
  713. const edges = [];
  714. const originalIdToCanvasId = {}; // 原始ID -> 画布ID的映射
  715. const canvasIdToNodeData = {}; // 避免重复创建相同的节点
  716. // 创建节点
  717. Object.entries(data.nodes).forEach(([originalId, node]) => {
  718. if (node.type === 'query') {
  719. // 使用 query_level 作为唯一ID
  720. const canvasId = node.query + '_' + node.level;
  721. originalIdToCanvasId[originalId] = canvasId;
  722. // 如果这个 canvasId 还没有创建过节点,则创建
  723. if (!canvasIdToNodeData[canvasId]) {
  724. canvasIdToNodeData[canvasId] = true;
  725. nodes.push({
  726. id: canvasId, // 使用 query_level 格式
  727. originalId: originalId, // 保留原始ID用于调试
  728. type: 'query',
  729. data: {
  730. title: node.query,
  731. level: node.level,
  732. score: node.relevance_score.toFixed(2),
  733. strategy: node.strategy,
  734. parent: node.parent_query,
  735. isSelected: node.is_selected,
  736. evaluationReason: node.evaluation_reason || '',
  737. },
  738. position: { x: 0, y: 0 }, // 初始位置,会被 dagre 覆盖
  739. });
  740. }
  741. } else if (node.type === 'note') {
  742. // note节点直接使用原始ID
  743. originalIdToCanvasId[originalId] = originalId;
  744. if (!canvasIdToNodeData[originalId]) {
  745. canvasIdToNodeData[originalId] = true;
  746. nodes.push({
  747. id: originalId,
  748. originalId: originalId,
  749. type: 'note',
  750. data: {
  751. title: node.title,
  752. matchLevel: node.match_level,
  753. score: node.relevance_score.toFixed(2),
  754. description: node.desc,
  755. isSelected: node.is_selected !== undefined ? node.is_selected : true,
  756. imageList: node.image_list || [], // 添加图片列表
  757. noteUrl: node.note_url || '', // 添加帖子链接
  758. evaluationReason: node.evaluation_reason || '', // 添加评估理由
  759. },
  760. position: { x: 0, y: 0 },
  761. });
  762. }
  763. }
  764. });
  765. // 创建边 - 使用虚线样式,映射到画布ID
  766. data.edges.forEach((edge, index) => {
  767. const edgeColors = {
  768. '初始分词': '#10b981',
  769. '调用sug': '#06b6d4',
  770. '同义改写': '#f59e0b',
  771. '加词': '#3b82f6',
  772. '抽象改写': '#8b5cf6',
  773. '基于部分匹配改进': '#ec4899',
  774. '结果分支-抽象改写': '#a855f7',
  775. '结果分支-同义改写': '#fb923c',
  776. 'query_to_note': '#ec4899',
  777. };
  778. const color = edgeColors[edge.strategy] || edgeColors[edge.edge_type] || '#d1d5db';
  779. const isNoteEdge = edge.edge_type === 'query_to_note';
  780. edges.push({
  781. id: \`edge-\${index}\`,
  782. source: originalIdToCanvasId[edge.from], // 使用画布ID
  783. target: originalIdToCanvasId[edge.to], // 使用画布ID
  784. type: 'simplebezier', // 使用简单贝塞尔曲线
  785. animated: isNoteEdge,
  786. style: {
  787. stroke: color,
  788. strokeWidth: isNoteEdge ? 2.5 : 2,
  789. strokeDasharray: isNoteEdge ? '5,5' : '8,4',
  790. },
  791. markerEnd: {
  792. type: 'arrowclosed',
  793. color: color,
  794. width: 20,
  795. height: 20,
  796. },
  797. });
  798. });
  799. // 使用 dagre 自动计算布局 - 从左到右
  800. return getLayoutedElements(nodes, edges, 'LR');
  801. }
  802. function FlowContent() {
  803. const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
  804. console.log('🔍 Transforming data...');
  805. const result = transformData(data);
  806. console.log('✅ Transformed:', result.nodes.length, 'nodes,', result.edges.length, 'edges');
  807. return result;
  808. }, []);
  809. // 初始化:找出所有有子节点的节点,默认折叠(画布节点)
  810. const initialCollapsedNodes = useMemo(() => {
  811. const nodesWithChildren = new Set();
  812. initialEdges.forEach(edge => {
  813. nodesWithChildren.add(edge.source);
  814. });
  815. // 排除根节点(level 0),让根节点默认展开
  816. const rootNode = initialNodes.find(n => n.data.level === 0);
  817. if (rootNode) {
  818. nodesWithChildren.delete(rootNode.id);
  819. }
  820. return nodesWithChildren;
  821. }, [initialNodes, initialEdges]);
  822. // 树节点的折叠状态需要在树构建后初始化
  823. const [collapsedNodes, setCollapsedNodes] = useState(() => initialCollapsedNodes);
  824. const [collapsedTreeNodes, setCollapsedTreeNodes] = useState(new Set());
  825. const [selectedNodeId, setSelectedNodeId] = useState(null);
  826. const [hiddenNodes, setHiddenNodes] = useState(new Set()); // 用户手动隐藏的节点
  827. const [focusMode, setFocusMode] = useState(false); // 全局聚焦模式,默认关闭
  828. const [focusedNodeId, setFocusedNodeId] = useState(null); // 单独聚焦的节点ID
  829. // 获取 React Flow 实例以控制画布
  830. const { setCenter, fitView } = useReactFlow();
  831. // 获取某个节点的所有后代节点ID
  832. const getDescendants = useCallback((nodeId) => {
  833. const descendants = new Set();
  834. const queue = [nodeId];
  835. while (queue.length > 0) {
  836. const current = queue.shift();
  837. initialEdges.forEach(edge => {
  838. if (edge.source === current && !descendants.has(edge.target)) {
  839. descendants.add(edge.target);
  840. queue.push(edge.target);
  841. }
  842. });
  843. }
  844. return descendants;
  845. }, [initialEdges]);
  846. // 获取直接父节点
  847. const getDirectParents = useCallback((nodeId) => {
  848. const parents = [];
  849. initialEdges.forEach(edge => {
  850. if (edge.target === nodeId) {
  851. parents.push(edge.source);
  852. }
  853. });
  854. return parents;
  855. }, [initialEdges]);
  856. // 获取直接子节点
  857. const getDirectChildren = useCallback((nodeId) => {
  858. const children = [];
  859. initialEdges.forEach(edge => {
  860. if (edge.source === nodeId) {
  861. children.push(edge.target);
  862. }
  863. });
  864. return children;
  865. }, [initialEdges]);
  866. // 切换节点折叠状态
  867. const toggleNodeCollapse = useCallback((nodeId) => {
  868. setCollapsedNodes(prev => {
  869. const newSet = new Set(prev);
  870. const descendants = getDescendants(nodeId);
  871. if (newSet.has(nodeId)) {
  872. // 展开:移除此节点,但保持其他折叠的节点
  873. newSet.delete(nodeId);
  874. } else {
  875. // 折叠:添加此节点
  876. newSet.add(nodeId);
  877. }
  878. return newSet;
  879. });
  880. }, [getDescendants]);
  881. // 过滤可见的节点和边,并重新计算布局
  882. const { nodes, edges } = useMemo(() => {
  883. const nodesToHide = new Set();
  884. // 判断使用哪个节点ID进行聚焦:优先使用单独聚焦的节点,否则使用全局聚焦模式的选中节点
  885. const effectiveFocusNodeId = focusedNodeId || (focusMode ? selectedNodeId : null);
  886. // 聚焦模式:只显示聚焦节点、其父节点和直接子节点
  887. if (effectiveFocusNodeId) {
  888. const visibleInFocus = new Set([effectiveFocusNodeId]);
  889. // 添加所有父节点
  890. initialEdges.forEach(edge => {
  891. if (edge.target === effectiveFocusNodeId) {
  892. visibleInFocus.add(edge.source);
  893. }
  894. });
  895. // 添加所有直接子节点
  896. initialEdges.forEach(edge => {
  897. if (edge.source === effectiveFocusNodeId) {
  898. visibleInFocus.add(edge.target);
  899. }
  900. });
  901. // 隐藏不在聚焦范围内的节点
  902. initialNodes.forEach(node => {
  903. if (!visibleInFocus.has(node.id)) {
  904. nodesToHide.add(node.id);
  905. }
  906. });
  907. } else {
  908. // 非聚焦模式:使用原有的折叠逻辑
  909. // 收集所有被折叠节点的后代
  910. collapsedNodes.forEach(collapsedId => {
  911. const descendants = getDescendants(collapsedId);
  912. descendants.forEach(id => nodesToHide.add(id));
  913. });
  914. }
  915. // 添加用户手动隐藏的节点
  916. hiddenNodes.forEach(id => nodesToHide.add(id));
  917. const visibleNodes = initialNodes
  918. .filter(node => !nodesToHide.has(node.id))
  919. .map(node => ({
  920. ...node,
  921. data: {
  922. ...node.data,
  923. isCollapsed: collapsedNodes.has(node.id),
  924. hasChildren: initialEdges.some(e => e.source === node.id),
  925. onToggleCollapse: () => toggleNodeCollapse(node.id),
  926. onHideSelf: () => {
  927. setHiddenNodes(prev => {
  928. const newSet = new Set(prev);
  929. newSet.add(node.id);
  930. return newSet;
  931. });
  932. },
  933. onFocus: () => {
  934. // 切换聚焦状态
  935. if (focusedNodeId === node.id) {
  936. setFocusedNodeId(null); // 如果已经聚焦,则取消聚焦
  937. } else {
  938. // 先取消之前的聚焦,然后聚焦到当前节点
  939. setFocusedNodeId(node.id);
  940. // 延迟聚焦视图到该节点
  941. setTimeout(() => {
  942. fitView({
  943. nodes: [{ id: node.id }],
  944. duration: 800,
  945. padding: 0.3,
  946. });
  947. }, 100);
  948. }
  949. },
  950. isFocused: focusedNodeId === node.id,
  951. isHighlighted: selectedNodeId === node.id,
  952. }
  953. }));
  954. const visibleEdges = initialEdges.filter(
  955. edge => !nodesToHide.has(edge.source) && !nodesToHide.has(edge.target)
  956. );
  957. // 重新计算布局 - 只对可见节点
  958. if (typeof window !== 'undefined' && typeof window.dagre !== 'undefined') {
  959. try {
  960. const dagreGraph = new window.dagre.graphlib.Graph();
  961. dagreGraph.setDefaultEdgeLabel(() => ({}));
  962. dagreGraph.setGraph({
  963. rankdir: 'LR',
  964. nodesep: 120, // 垂直间距 - 增加以避免节点重叠
  965. ranksep: 280, // 水平间距 - 增加以容纳更宽的节点
  966. });
  967. visibleNodes.forEach((node) => {
  968. let nodeWidth = 280;
  969. let nodeHeight = 180;
  970. // note 节点有轮播图,需要更大的空间
  971. if (node.type === 'note') {
  972. nodeWidth = 320;
  973. nodeHeight = 350;
  974. }
  975. dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
  976. });
  977. visibleEdges.forEach((edge) => {
  978. dagreGraph.setEdge(edge.source, edge.target);
  979. });
  980. window.dagre.layout(dagreGraph);
  981. visibleNodes.forEach((node) => {
  982. const nodeWithPosition = dagreGraph.node(node.id);
  983. if (nodeWithPosition) {
  984. // 根据节点类型获取对应的尺寸
  985. let nodeWidth = 280;
  986. let nodeHeight = 180;
  987. if (node.type === 'note') {
  988. nodeWidth = 320;
  989. nodeHeight = 350;
  990. }
  991. node.position = {
  992. x: nodeWithPosition.x - nodeWidth / 2,
  993. y: nodeWithPosition.y - nodeHeight / 2,
  994. };
  995. node.targetPosition = 'left';
  996. node.sourcePosition = 'right';
  997. }
  998. });
  999. console.log('✅ Dynamic layout recalculated for', visibleNodes.length, 'visible nodes');
  1000. } catch (error) {
  1001. console.error('❌ Error in dynamic layout:', error);
  1002. }
  1003. }
  1004. return { nodes: visibleNodes, edges: visibleEdges };
  1005. }, [initialNodes, initialEdges, collapsedNodes, hiddenNodes, focusMode, focusedNodeId, getDescendants, toggleNodeCollapse, selectedNodeId]);
  1006. // 构建树形结构 - 允许一个节点有多个父节点
  1007. const buildTree = useCallback(() => {
  1008. const nodeMap = new Map();
  1009. initialNodes.forEach(node => {
  1010. nodeMap.set(node.id, node);
  1011. });
  1012. // 为每个节点创建树节点的副本(允许多次出现)
  1013. const createTreeNode = (nodeId, pathKey) => {
  1014. const node = nodeMap.get(nodeId);
  1015. if (!node) return null;
  1016. return {
  1017. ...node,
  1018. treeKey: pathKey, // 唯一的树路径key,用于React key
  1019. children: []
  1020. };
  1021. };
  1022. // 构建父子关系映射:记录每个节点的所有父节点,去重边
  1023. const parentToChildren = new Map();
  1024. const childToParents = new Map();
  1025. initialEdges.forEach(edge => {
  1026. // 记录父->子关系(去重:同一个父节点到同一个子节点只记录一次)
  1027. if (!parentToChildren.has(edge.source)) {
  1028. parentToChildren.set(edge.source, []);
  1029. }
  1030. const children = parentToChildren.get(edge.source);
  1031. if (!children.includes(edge.target)) {
  1032. children.push(edge.target);
  1033. }
  1034. // 记录子->父关系(用于判断是否有多个父节点,也去重)
  1035. if (!childToParents.has(edge.target)) {
  1036. childToParents.set(edge.target, []);
  1037. }
  1038. const parents = childToParents.get(edge.target);
  1039. if (!parents.includes(edge.source)) {
  1040. parents.push(edge.source);
  1041. }
  1042. });
  1043. // 递归构建树
  1044. const buildSubtree = (nodeId, pathKey, visitedInPath) => {
  1045. // 避免循环引用:如果当前路径中已经访问过这个节点,跳过
  1046. if (visitedInPath.has(nodeId)) {
  1047. return null;
  1048. }
  1049. const treeNode = createTreeNode(nodeId, pathKey);
  1050. if (!treeNode) return null;
  1051. const newVisitedInPath = new Set(visitedInPath);
  1052. newVisitedInPath.add(nodeId);
  1053. const children = parentToChildren.get(nodeId) || [];
  1054. treeNode.children = children
  1055. .map((childId, index) => buildSubtree(childId, pathKey + '-' + childId + '-' + index, newVisitedInPath))
  1056. .filter(child => child !== null);
  1057. return treeNode;
  1058. };
  1059. // 找出所有根节点(没有入边的节点)
  1060. const hasParent = new Set();
  1061. initialEdges.forEach(edge => {
  1062. hasParent.add(edge.target);
  1063. });
  1064. const roots = [];
  1065. initialNodes.forEach((node, index) => {
  1066. if (!hasParent.has(node.id)) {
  1067. const treeNode = buildSubtree(node.id, 'root-' + node.id + '-' + index, new Set());
  1068. if (treeNode) roots.push(treeNode);
  1069. }
  1070. });
  1071. return roots;
  1072. }, [initialNodes, initialEdges]);
  1073. const treeRoots = useMemo(() => buildTree(), [buildTree]);
  1074. // 初始化树节点折叠状态
  1075. useEffect(() => {
  1076. const getAllTreeKeys = (nodes) => {
  1077. const keys = new Set();
  1078. const traverse = (node) => {
  1079. if (node.children && node.children.length > 0) {
  1080. // 排除根节点
  1081. if (node.data.level !== 0) {
  1082. keys.add(node.treeKey);
  1083. }
  1084. node.children.forEach(traverse);
  1085. }
  1086. };
  1087. nodes.forEach(traverse);
  1088. return keys;
  1089. };
  1090. setCollapsedTreeNodes(getAllTreeKeys(treeRoots));
  1091. }, [treeRoots]);
  1092. const renderTree = useCallback((treeNodes, level = 0) => {
  1093. return treeNodes.map(node => {
  1094. // 使用 treeKey 来区分树中的不同实例
  1095. const isCollapsed = collapsedTreeNodes.has(node.treeKey);
  1096. const isSelected = selectedNodeId === node.id;
  1097. return (
  1098. <TreeNode
  1099. key={node.treeKey}
  1100. node={node}
  1101. level={level}
  1102. isCollapsed={isCollapsed}
  1103. isSelected={isSelected}
  1104. onToggle={() => {
  1105. setCollapsedTreeNodes(prev => {
  1106. const newSet = new Set(prev);
  1107. if (newSet.has(node.treeKey)) {
  1108. newSet.delete(node.treeKey);
  1109. } else {
  1110. newSet.add(node.treeKey);
  1111. }
  1112. return newSet;
  1113. });
  1114. }}
  1115. onSelect={() => {
  1116. const nodeId = node.id;
  1117. // 展开所有祖先节点
  1118. const ancestorIds = [nodeId];
  1119. const findAncestors = (id) => {
  1120. initialEdges.forEach(edge => {
  1121. if (edge.target === id && !ancestorIds.includes(edge.source)) {
  1122. ancestorIds.push(edge.source);
  1123. findAncestors(edge.source);
  1124. }
  1125. });
  1126. };
  1127. findAncestors(nodeId);
  1128. // 如果节点或其祖先被隐藏,先恢复它们
  1129. setHiddenNodes(prev => {
  1130. const newSet = new Set(prev);
  1131. ancestorIds.forEach(id => newSet.delete(id));
  1132. return newSet;
  1133. });
  1134. setSelectedNodeId(nodeId);
  1135. // 获取选中节点的直接子节点
  1136. const childrenIds = [];
  1137. initialEdges.forEach(edge => {
  1138. if (edge.source === nodeId) {
  1139. childrenIds.push(edge.target);
  1140. }
  1141. });
  1142. setCollapsedNodes(prev => {
  1143. const newSet = new Set(prev);
  1144. // 展开所有祖先节点
  1145. ancestorIds.forEach(id => newSet.delete(id));
  1146. // 展开选中节点本身
  1147. newSet.delete(nodeId);
  1148. // 展开选中节点的直接子节点
  1149. childrenIds.forEach(id => newSet.delete(id));
  1150. return newSet;
  1151. });
  1152. // 延迟聚焦,等待节点展开和布局重新计算
  1153. setTimeout(() => {
  1154. fitView({
  1155. nodes: [{ id: nodeId }],
  1156. duration: 800,
  1157. padding: 0.3,
  1158. });
  1159. }, 300);
  1160. }}
  1161. >
  1162. {node.children && node.children.length > 0 && renderTree(node.children, level + 1)}
  1163. </TreeNode>
  1164. );
  1165. });
  1166. }, [collapsedTreeNodes, selectedNodeId, nodes, setCenter, initialEdges, setCollapsedNodes, fitView]);
  1167. console.log('📊 Rendering with', nodes.length, 'visible nodes and', edges.length, 'visible edges');
  1168. if (nodes.length === 0) {
  1169. return (
  1170. <div style={{ padding: 50, color: 'red', fontSize: 20 }}>
  1171. ERROR: No nodes to display!
  1172. </div>
  1173. );
  1174. }
  1175. return (
  1176. <div style={{ width: '100vw', height: '100vh', background: '#f9fafb', display: 'flex', flexDirection: 'column' }}>
  1177. {/* 顶部面包屑导航栏 */}
  1178. <div style={{
  1179. minHeight: '48px',
  1180. maxHeight: '120px',
  1181. background: 'white',
  1182. borderBottom: '1px solid #e5e7eb',
  1183. display: 'flex',
  1184. alignItems: 'flex-start',
  1185. padding: '12px 24px',
  1186. zIndex: 1000,
  1187. boxShadow: '0 1px 3px rgba(0, 0, 0, 0.05)',
  1188. flexShrink: 0,
  1189. overflowY: 'auto',
  1190. }}>
  1191. <div style={{ width: '100%' }}>
  1192. {selectedNodeId ? (
  1193. <div style={{ fontSize: '12px', color: '#6b7280' }}>
  1194. {/* 面包屑导航 - 显示所有路径 */}
  1195. {(() => {
  1196. const selectedNode = nodes.find(n => n.id === selectedNodeId);
  1197. if (!selectedNode) return null;
  1198. // 找到所有从根节点到当前节点的路径
  1199. const findAllPaths = (targetId) => {
  1200. const paths = [];
  1201. const buildPath = (nodeId, currentPath) => {
  1202. const node = initialNodes.find(n => n.id === nodeId);
  1203. if (!node) return;
  1204. const newPath = [node, ...currentPath];
  1205. // 找到所有父节点
  1206. const parents = initialEdges.filter(e => e.target === nodeId).map(e => e.source);
  1207. if (parents.length === 0) {
  1208. // 到达根节点
  1209. paths.push(newPath);
  1210. } else {
  1211. // 递归处理所有父节点
  1212. parents.forEach(parentId => {
  1213. buildPath(parentId, newPath);
  1214. });
  1215. }
  1216. };
  1217. buildPath(targetId, []);
  1218. return paths;
  1219. };
  1220. const allPaths = findAllPaths(selectedNodeId);
  1221. // 去重:将路径转换为字符串进行比较
  1222. const uniquePaths = [];
  1223. const pathStrings = new Set();
  1224. allPaths.forEach(path => {
  1225. const pathString = path.map(n => n.id).join('->');
  1226. if (!pathStrings.has(pathString)) {
  1227. pathStrings.add(pathString);
  1228. uniquePaths.push(path);
  1229. }
  1230. });
  1231. return (
  1232. <div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
  1233. {uniquePaths.map((path, pathIndex) => (
  1234. <div key={pathIndex} style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
  1235. {pathIndex > 0 && <span style={{ color: '#d1d5db', marginRight: '4px' }}>或</span>}
  1236. {path.map((node, index) => {
  1237. // 获取节点的 score、strategy 和 isSelected
  1238. const nodeScore = node.data.score ? parseFloat(node.data.score) : 0;
  1239. const nodeStrategy = node.data.strategy || '';
  1240. const strategyColor = getStrategyColor(nodeStrategy);
  1241. const nodeIsSelected = node.type === 'note' ? node.data.matchLevel !== 'unsatisfied' : node.data.isSelected !== false;
  1242. return (
  1243. <React.Fragment key={node.id + '-' + index}>
  1244. <span
  1245. onClick={() => {
  1246. const nodeId = node.id;
  1247. // 找到所有祖先节点
  1248. const ancestorIds = [nodeId];
  1249. const findAncestors = (id) => {
  1250. initialEdges.forEach(edge => {
  1251. if (edge.target === id && !ancestorIds.includes(edge.source)) {
  1252. ancestorIds.push(edge.source);
  1253. findAncestors(edge.source);
  1254. }
  1255. });
  1256. };
  1257. findAncestors(nodeId);
  1258. // 如果节点或其祖先被隐藏,先恢复它们
  1259. setHiddenNodes(prev => {
  1260. const newSet = new Set(prev);
  1261. ancestorIds.forEach(id => newSet.delete(id));
  1262. return newSet;
  1263. });
  1264. // 展开目录树中到达该节点的路径
  1265. // 需要找到所有包含该节点的树路径的 treeKey,并展开它们的父节点
  1266. setCollapsedTreeNodes(prev => {
  1267. const newSet = new Set(prev);
  1268. // 清空所有折叠状态,让目录树完全展开到选中节点
  1269. // 这样可以确保选中节点在目录中可见
  1270. return new Set();
  1271. });
  1272. setSelectedNodeId(nodeId);
  1273. setTimeout(() => {
  1274. fitView({
  1275. nodes: [{ id: nodeId }],
  1276. duration: 800,
  1277. padding: 0.3,
  1278. });
  1279. }, 100);
  1280. }}
  1281. style={{
  1282. padding: '6px 8px',
  1283. borderRadius: '4px',
  1284. background: 'white',
  1285. border: index === path.length - 1 ? '2px solid #3b82f6' : '1px solid #d1d5db',
  1286. color: '#374151',
  1287. fontWeight: index === path.length - 1 ? '600' : '400',
  1288. width: '180px',
  1289. cursor: 'pointer',
  1290. transition: 'all 0.2s ease',
  1291. position: 'relative',
  1292. display: 'inline-flex',
  1293. flexDirection: 'column',
  1294. gap: '4px',
  1295. }}
  1296. onMouseEnter={(e) => {
  1297. e.currentTarget.style.opacity = '0.8';
  1298. }}
  1299. onMouseLeave={(e) => {
  1300. e.currentTarget.style.opacity = '1';
  1301. }}
  1302. title={\`\${node.data.title || node.id} (Score: \${nodeScore.toFixed(2)}, Strategy: \${nodeStrategy}, Selected: \${nodeIsSelected})\`}
  1303. >
  1304. {/* 上半部分:竖线 + 图标 + 文字 + 分数 */}
  1305. <div style={{
  1306. display: 'flex',
  1307. alignItems: 'center',
  1308. gap: '6px',
  1309. }}>
  1310. {/* 策略类型竖线 */}
  1311. <div style={{
  1312. width: '3px',
  1313. height: '16px',
  1314. background: strategyColor,
  1315. borderRadius: '2px',
  1316. flexShrink: 0,
  1317. }} />
  1318. {/* 节点类型图标 */}
  1319. <span style={{
  1320. fontSize: '11px',
  1321. flexShrink: 0,
  1322. }}>
  1323. {node.type === 'note' ? '📝' : '🔍'}
  1324. </span>
  1325. {/* 节点文字 */}
  1326. <span style={{
  1327. flex: 1,
  1328. fontSize: '12px',
  1329. color: nodeIsSelected ? '#374151' : '#ef4444',
  1330. }}>
  1331. {truncateMiddle(node.data.title || node.id, 18)}
  1332. </span>
  1333. {/* 分数显示 */}
  1334. <span style={{
  1335. fontSize: '10px',
  1336. color: '#6b7280',
  1337. fontWeight: '500',
  1338. flexShrink: 0,
  1339. }}>
  1340. {nodeScore.toFixed(2)}
  1341. </span>
  1342. </div>
  1343. {/* 分数下划线 */}
  1344. <div style={{
  1345. width: (nodeScore * 100) + '%',
  1346. height: '2px',
  1347. background: getScoreColor(nodeScore),
  1348. borderRadius: '1px',
  1349. marginLeft: '9px',
  1350. }} />
  1351. </span>
  1352. {index < path.length - 1 && <span style={{ color: '#9ca3af' }}>›</span>}
  1353. </React.Fragment>
  1354. )})}
  1355. </div>
  1356. ))}
  1357. </div>
  1358. );
  1359. })()}
  1360. </div>
  1361. ) : (
  1362. <div style={{ fontSize: '13px', color: '#9ca3af', textAlign: 'center' }}>
  1363. 选择一个节点查看路径
  1364. </div>
  1365. )}
  1366. </div>
  1367. </div>
  1368. {/* 主内容区:目录 + 画布 */}
  1369. <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
  1370. {/* 左侧目录树 */}
  1371. <div style={{
  1372. width: '320px',
  1373. background: 'white',
  1374. borderRight: '1px solid #e5e7eb',
  1375. display: 'flex',
  1376. flexDirection: 'column',
  1377. flexShrink: 0,
  1378. }}>
  1379. <div style={{
  1380. padding: '12px 16px',
  1381. borderBottom: '1px solid #e5e7eb',
  1382. display: 'flex',
  1383. justifyContent: 'space-between',
  1384. alignItems: 'center',
  1385. }}>
  1386. <span style={{
  1387. fontWeight: '600',
  1388. fontSize: '14px',
  1389. color: '#111827',
  1390. }}>
  1391. 节点目录
  1392. </span>
  1393. <div style={{ display: 'flex', gap: '6px' }}>
  1394. <button
  1395. onClick={() => {
  1396. setCollapsedTreeNodes(new Set());
  1397. }}
  1398. style={{
  1399. fontSize: '11px',
  1400. padding: '4px 8px',
  1401. borderRadius: '4px',
  1402. border: '1px solid #d1d5db',
  1403. background: 'white',
  1404. color: '#6b7280',
  1405. cursor: 'pointer',
  1406. fontWeight: '500',
  1407. }}
  1408. title="展开全部节点"
  1409. >
  1410. 全部展开
  1411. </button>
  1412. <button
  1413. onClick={() => {
  1414. const getAllTreeKeys = (nodes) => {
  1415. const keys = new Set();
  1416. const traverse = (node) => {
  1417. if (node.children && node.children.length > 0) {
  1418. keys.add(node.treeKey);
  1419. node.children.forEach(traverse);
  1420. }
  1421. };
  1422. nodes.forEach(traverse);
  1423. return keys;
  1424. };
  1425. setCollapsedTreeNodes(getAllTreeKeys(treeRoots));
  1426. }}
  1427. style={{
  1428. fontSize: '11px',
  1429. padding: '4px 8px',
  1430. borderRadius: '4px',
  1431. border: '1px solid #d1d5db',
  1432. background: 'white',
  1433. color: '#6b7280',
  1434. cursor: 'pointer',
  1435. fontWeight: '500',
  1436. }}
  1437. title="折叠全部节点"
  1438. >
  1439. 全部折叠
  1440. </button>
  1441. </div>
  1442. </div>
  1443. <div style={{
  1444. flex: 1,
  1445. overflowX: 'auto',
  1446. overflowY: 'auto',
  1447. padding: '8px',
  1448. }}>
  1449. <div style={{ minWidth: 'fit-content' }}>
  1450. {renderTree(treeRoots)}
  1451. </div>
  1452. </div>
  1453. </div>
  1454. {/* 画布区域 */}
  1455. <div style={{ flex: 1, position: 'relative' }}>
  1456. {/* 右侧图例 */}
  1457. <div style={{
  1458. position: 'absolute',
  1459. top: '20px',
  1460. right: '20px',
  1461. background: 'white',
  1462. padding: '16px',
  1463. borderRadius: '12px',
  1464. boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
  1465. zIndex: 1000,
  1466. maxWidth: '260px',
  1467. border: '1px solid #e5e7eb',
  1468. }}>
  1469. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
  1470. <h3 style={{ fontSize: '14px', fontWeight: '600', color: '#111827', margin: 0 }}>图例</h3>
  1471. <button
  1472. onClick={() => setFocusMode(!focusMode)}
  1473. style={{
  1474. fontSize: '11px',
  1475. padding: '4px 8px',
  1476. borderRadius: '4px',
  1477. border: '1px solid',
  1478. borderColor: focusMode ? '#3b82f6' : '#d1d5db',
  1479. background: focusMode ? '#3b82f6' : 'white',
  1480. color: focusMode ? 'white' : '#6b7280',
  1481. cursor: 'pointer',
  1482. fontWeight: '500',
  1483. }}
  1484. title={focusMode ? '关闭聚焦模式' : '开启聚焦模式'}
  1485. >
  1486. {focusMode ? '🎯 聚焦' : '📊 全图'}
  1487. </button>
  1488. </div>
  1489. <div style={{ fontSize: '12px' }}>
  1490. {/* 画布节点展开/折叠控制 */}
  1491. <div style={{ marginBottom: '12px', paddingBottom: '12px', borderBottom: '1px solid #f3f4f6' }}>
  1492. <div style={{ fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#374151' }}>节点控制</div>
  1493. <div style={{ display: 'flex', gap: '6px' }}>
  1494. <button
  1495. onClick={() => {
  1496. setCollapsedNodes(new Set());
  1497. }}
  1498. style={{
  1499. fontSize: '11px',
  1500. padding: '4px 8px',
  1501. borderRadius: '4px',
  1502. border: '1px solid #d1d5db',
  1503. background: 'white',
  1504. color: '#6b7280',
  1505. cursor: 'pointer',
  1506. fontWeight: '500',
  1507. flex: 1,
  1508. }}
  1509. title="展开画布中所有节点的子节点"
  1510. >
  1511. 全部展开
  1512. </button>
  1513. <button
  1514. onClick={() => {
  1515. const allNodeIds = new Set(initialNodes.map(n => n.id));
  1516. setCollapsedNodes(allNodeIds);
  1517. }}
  1518. style={{
  1519. fontSize: '11px',
  1520. padding: '4px 8px',
  1521. borderRadius: '4px',
  1522. border: '1px solid #d1d5db',
  1523. background: 'white',
  1524. color: '#6b7280',
  1525. cursor: 'pointer',
  1526. fontWeight: '500',
  1527. flex: 1,
  1528. }}
  1529. title="折叠画布中所有节点的子节点"
  1530. >
  1531. 全部折叠
  1532. </button>
  1533. </div>
  1534. </div>
  1535. <div style={{ paddingTop: '12px', borderTop: '1px solid #f3f4f6' }}>
  1536. <div style={{ fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#374151' }}>策略类型</div>
  1537. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1538. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#10b981', opacity: 0.7 }}></div>
  1539. <span style={{ color: '#6b7280', fontSize: '11px' }}>初始分词</span>
  1540. </div>
  1541. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1542. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#06b6d4', opacity: 0.7 }}></div>
  1543. <span style={{ color: '#6b7280', fontSize: '11px' }}>调用sug</span>
  1544. </div>
  1545. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1546. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#f59e0b', opacity: 0.7 }}></div>
  1547. <span style={{ color: '#6b7280', fontSize: '11px' }}>同义改写</span>
  1548. </div>
  1549. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1550. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#3b82f6', opacity: 0.7 }}></div>
  1551. <span style={{ color: '#6b7280', fontSize: '11px' }}>加词</span>
  1552. </div>
  1553. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1554. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#8b5cf6', opacity: 0.7 }}></div>
  1555. <span style={{ color: '#6b7280', fontSize: '11px' }}>抽象改写</span>
  1556. </div>
  1557. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1558. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#ec4899', opacity: 0.7 }}></div>
  1559. <span style={{ color: '#6b7280', fontSize: '11px' }}>基于部分匹配改进</span>
  1560. </div>
  1561. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1562. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#a855f7', opacity: 0.7 }}></div>
  1563. <span style={{ color: '#6b7280', fontSize: '11px' }}>结果分支-抽象改写</span>
  1564. </div>
  1565. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1566. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#fb923c', opacity: 0.7 }}></div>
  1567. <span style={{ color: '#6b7280', fontSize: '11px' }}>结果分支-同义改写</span>
  1568. </div>
  1569. </div>
  1570. <div style={{
  1571. marginTop: '12px',
  1572. paddingTop: '12px',
  1573. borderTop: '1px solid #f3f4f6',
  1574. fontSize: '11px',
  1575. color: '#9ca3af',
  1576. lineHeight: '1.5',
  1577. }}>
  1578. 💡 点击节点左上角 × 隐藏节点
  1579. </div>
  1580. {/* 隐藏节点列表 - 在图例内部 */}
  1581. {hiddenNodes.size > 0 && (
  1582. <div style={{
  1583. marginTop: '12px',
  1584. paddingTop: '12px',
  1585. borderTop: '1px solid #f3f4f6',
  1586. }}>
  1587. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
  1588. <h4 style={{ fontSize: '12px', fontWeight: '600', color: '#111827' }}>已隐藏节点</h4>
  1589. <button
  1590. onClick={() => setHiddenNodes(new Set())}
  1591. style={{
  1592. fontSize: '10px',
  1593. color: '#3b82f6',
  1594. background: 'none',
  1595. border: 'none',
  1596. cursor: 'pointer',
  1597. textDecoration: 'underline',
  1598. }}
  1599. >
  1600. 全部恢复
  1601. </button>
  1602. </div>
  1603. <div style={{ fontSize: '12px', maxHeight: '200px', overflow: 'auto' }}>
  1604. {Array.from(hiddenNodes).map(nodeId => {
  1605. const node = initialNodes.find(n => n.id === nodeId);
  1606. if (!node) return null;
  1607. return (
  1608. <div
  1609. key={nodeId}
  1610. style={{
  1611. display: 'flex',
  1612. justifyContent: 'space-between',
  1613. alignItems: 'center',
  1614. padding: '6px 8px',
  1615. margin: '4px 0',
  1616. background: '#f9fafb',
  1617. borderRadius: '6px',
  1618. fontSize: '11px',
  1619. }}
  1620. >
  1621. <span
  1622. style={{
  1623. flex: 1,
  1624. overflow: 'hidden',
  1625. textOverflow: 'ellipsis',
  1626. whiteSpace: 'nowrap',
  1627. color: '#374151',
  1628. }}
  1629. title={node.data.title || nodeId}
  1630. >
  1631. {node.data.title || nodeId}
  1632. </span>
  1633. <button
  1634. onClick={() => {
  1635. setHiddenNodes(prev => {
  1636. const newSet = new Set(prev);
  1637. newSet.delete(nodeId);
  1638. return newSet;
  1639. });
  1640. }}
  1641. style={{
  1642. marginLeft: '8px',
  1643. fontSize: '10px',
  1644. color: '#10b981',
  1645. background: 'none',
  1646. border: 'none',
  1647. cursor: 'pointer',
  1648. flexShrink: 0,
  1649. }}
  1650. >
  1651. 恢复
  1652. </button>
  1653. </div>
  1654. );
  1655. })}
  1656. </div>
  1657. </div>
  1658. )}
  1659. </div>
  1660. </div>
  1661. {/* React Flow 画布 */}
  1662. <ReactFlow
  1663. nodes={nodes}
  1664. edges={edges}
  1665. nodeTypes={nodeTypes}
  1666. fitView
  1667. fitViewOptions={{ padding: 0.2, duration: 500 }}
  1668. minZoom={0.1}
  1669. maxZoom={1.5}
  1670. nodesDraggable={true}
  1671. nodesConnectable={false}
  1672. elementsSelectable={true}
  1673. defaultEdgeOptions={{
  1674. type: 'smoothstep',
  1675. }}
  1676. proOptions={{ hideAttribution: true }}
  1677. onNodeClick={(event, clickedNode) => {
  1678. setSelectedNodeId(clickedNode.id);
  1679. }}
  1680. >
  1681. <Controls style={{ bottom: '20px', left: 'auto', right: '20px' }} />
  1682. <Background variant="dots" gap={20} size={1} color="#e5e7eb" />
  1683. </ReactFlow>
  1684. </div>
  1685. </div>
  1686. </div>
  1687. );
  1688. }
  1689. function App() {
  1690. return (
  1691. <ReactFlowProvider>
  1692. <FlowContent />
  1693. </ReactFlowProvider>
  1694. );
  1695. }
  1696. const root = createRoot(document.getElementById('root'));
  1697. root.render(<App />);
  1698. `;
  1699. fs.writeFileSync(reactComponentPath, reactComponent);
  1700. // 使用 esbuild 打包
  1701. console.log('🎨 Building modern visualization...');
  1702. build({
  1703. entryPoints: [reactComponentPath],
  1704. bundle: true,
  1705. outfile: path.join(__dirname, 'bundle_v2.js'),
  1706. format: 'iife',
  1707. loader: {
  1708. '.css': 'css',
  1709. },
  1710. minify: false,
  1711. sourcemap: 'inline',
  1712. }).then(() => {
  1713. // 读取打包后的 JS
  1714. const bundleJs = fs.readFileSync(path.join(__dirname, 'bundle_v2.js'), 'utf-8');
  1715. // 读取 CSS
  1716. const cssPath = path.join(__dirname, 'node_modules/@xyflow/react/dist/style.css');
  1717. const css = fs.readFileSync(cssPath, 'utf-8');
  1718. // 生成最终 HTML
  1719. const html = `<!DOCTYPE html>
  1720. <html lang="zh-CN">
  1721. <head>
  1722. <meta charset="UTF-8">
  1723. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  1724. <title>查询图可视化</title>
  1725. <link rel="preconnect" href="https://fonts.googleapis.com">
  1726. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  1727. <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
  1728. <script src="https://unpkg.com/dagre@0.8.5/dist/dagre.min.js"></script>
  1729. <script>
  1730. // 过滤特定的 React 警告
  1731. const originalError = console.error;
  1732. console.error = (...args) => {
  1733. if (typeof args[0] === 'string' && args[0].includes('Each child in a list should have a unique "key" prop')) {
  1734. return;
  1735. }
  1736. originalError.apply(console, args);
  1737. };
  1738. </script>
  1739. <style>
  1740. * {
  1741. margin: 0;
  1742. padding: 0;
  1743. box-sizing: border-box;
  1744. }
  1745. body {
  1746. font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  1747. overflow: hidden;
  1748. -webkit-font-smoothing: antialiased;
  1749. -moz-osx-font-smoothing: grayscale;
  1750. }
  1751. #root {
  1752. width: 100vw;
  1753. height: 100vh;
  1754. }
  1755. ${css}
  1756. /* 自定义样式覆盖 */
  1757. .react-flow__edge-path {
  1758. stroke-linecap: round;
  1759. }
  1760. .react-flow__controls {
  1761. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  1762. border: 1px solid #e5e7eb;
  1763. border-radius: 8px;
  1764. }
  1765. .react-flow__controls-button {
  1766. border: none;
  1767. border-bottom: 1px solid #e5e7eb;
  1768. }
  1769. .react-flow__controls-button:hover {
  1770. background: #f9fafb;
  1771. }
  1772. </style>
  1773. </head>
  1774. <body>
  1775. <div id="root"></div>
  1776. <script>${bundleJs}</script>
  1777. </body>
  1778. </html>`;
  1779. // 写入输出文件
  1780. fs.writeFileSync(outputFile, html);
  1781. // 清理临时文件
  1782. fs.unlinkSync(reactComponentPath);
  1783. fs.unlinkSync(path.join(__dirname, 'bundle_v2.js'));
  1784. console.log('✅ Visualization generated: ' + outputFile);
  1785. console.log('📊 Nodes: ' + Object.keys(data.nodes).length);
  1786. console.log('🔗 Edges: ' + data.edges.length);
  1787. }).catch(error => {
  1788. console.error('❌ Build error:', error);
  1789. process.exit(1);
  1790. });