index.js 67 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966
  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 index.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. // 根据节点类型获取尺寸
  682. let nodeWidth = 280;
  683. let nodeHeight = 180;
  684. if (node.type === 'note') {
  685. nodeWidth = 320;
  686. nodeHeight = 350;
  687. }
  688. // 将 dagre 的中心点位置转换为 React Flow 的左上角位置
  689. node.position = {
  690. x: nodeWithPosition.x - nodeWidth / 2,
  691. y: nodeWithPosition.y - nodeHeight / 2,
  692. };
  693. });
  694. console.log('✅ Layout completed, sample node:', nodes[0]);
  695. return { nodes, edges };
  696. } catch (error) {
  697. console.error('❌ Error in dagre layout:', error);
  698. console.error('Error details:', error.message, error.stack);
  699. // 降级处理
  700. console.log('Using fallback layout...');
  701. const levelGroups = {};
  702. nodes.forEach(node => {
  703. const level = node.data.level || 0;
  704. if (!levelGroups[level]) levelGroups[level] = [];
  705. levelGroups[level].push(node);
  706. });
  707. Object.entries(levelGroups).forEach(([level, nodeList]) => {
  708. const x = parseInt(level) * 350;
  709. nodeList.forEach((node, index) => {
  710. node.position = { x, y: index * 150 };
  711. node.targetPosition = 'left';
  712. node.sourcePosition = 'right';
  713. });
  714. });
  715. return { nodes, edges };
  716. }
  717. }
  718. function transformData(data) {
  719. const nodes = [];
  720. const edges = [];
  721. const originalIdToCanvasId = {}; // 原始ID -> 画布ID的映射
  722. const canvasIdToNodeData = {}; // 避免重复创建相同的节点
  723. // 创建节点
  724. Object.entries(data.nodes).forEach(([originalId, node]) => {
  725. if (node.type === 'query') {
  726. // 使用 query_level 作为唯一ID
  727. const canvasId = node.query + '_' + node.level;
  728. originalIdToCanvasId[originalId] = canvasId;
  729. // 如果这个 canvasId 还没有创建过节点,则创建
  730. if (!canvasIdToNodeData[canvasId]) {
  731. canvasIdToNodeData[canvasId] = true;
  732. nodes.push({
  733. id: canvasId, // 使用 query_level 格式
  734. originalId: originalId, // 保留原始ID用于调试
  735. type: 'query',
  736. data: {
  737. title: node.query,
  738. level: node.level,
  739. score: node.relevance_score.toFixed(2),
  740. strategy: node.strategy,
  741. parent: node.parent_query,
  742. isSelected: node.is_selected,
  743. evaluationReason: node.evaluation_reason || '',
  744. },
  745. position: { x: 0, y: 0 }, // 初始位置,会被 dagre 覆盖
  746. });
  747. }
  748. } else if (node.type === 'note') {
  749. // note节点直接使用原始ID
  750. originalIdToCanvasId[originalId] = originalId;
  751. if (!canvasIdToNodeData[originalId]) {
  752. canvasIdToNodeData[originalId] = true;
  753. nodes.push({
  754. id: originalId,
  755. originalId: originalId,
  756. type: 'note',
  757. data: {
  758. title: node.title,
  759. matchLevel: node.match_level,
  760. score: node.relevance_score.toFixed(2),
  761. description: node.desc,
  762. isSelected: node.is_selected !== undefined ? node.is_selected : true,
  763. imageList: node.image_list || [], // 添加图片列表
  764. noteUrl: node.note_url || '', // 添加帖子链接
  765. evaluationReason: node.evaluation_reason || '', // 添加评估理由
  766. },
  767. position: { x: 0, y: 0 },
  768. });
  769. }
  770. }
  771. });
  772. // 创建边 - 使用虚线样式,映射到画布ID
  773. data.edges.forEach((edge, index) => {
  774. const edgeColors = {
  775. '初始分词': '#10b981',
  776. '调用sug': '#06b6d4',
  777. '同义改写': '#f59e0b',
  778. '加词': '#3b82f6',
  779. '抽象改写': '#8b5cf6',
  780. '基于部分匹配改进': '#ec4899',
  781. '结果分支-抽象改写': '#a855f7',
  782. '结果分支-同义改写': '#fb923c',
  783. 'query_to_note': '#ec4899',
  784. };
  785. const color = edgeColors[edge.strategy] || edgeColors[edge.edge_type] || '#d1d5db';
  786. const isNoteEdge = edge.edge_type === 'query_to_note';
  787. edges.push({
  788. id: \`edge-\${index}\`,
  789. source: originalIdToCanvasId[edge.from], // 使用画布ID
  790. target: originalIdToCanvasId[edge.to], // 使用画布ID
  791. type: 'simplebezier', // 使用简单贝塞尔曲线
  792. animated: isNoteEdge,
  793. style: {
  794. stroke: color,
  795. strokeWidth: isNoteEdge ? 2.5 : 2,
  796. strokeDasharray: isNoteEdge ? '5,5' : '8,4',
  797. },
  798. markerEnd: {
  799. type: 'arrowclosed',
  800. color: color,
  801. width: 20,
  802. height: 20,
  803. },
  804. });
  805. });
  806. // 使用 dagre 自动计算布局 - 从左到右
  807. return getLayoutedElements(nodes, edges, 'LR');
  808. }
  809. function FlowContent() {
  810. const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
  811. console.log('🔍 Transforming data...');
  812. const result = transformData(data);
  813. console.log('✅ Transformed:', result.nodes.length, 'nodes,', result.edges.length, 'edges');
  814. return result;
  815. }, []);
  816. // 初始化:找出所有有子节点的节点,默认折叠(画布节点)
  817. const initialCollapsedNodes = useMemo(() => {
  818. const nodesWithChildren = new Set();
  819. initialEdges.forEach(edge => {
  820. nodesWithChildren.add(edge.source);
  821. });
  822. // 排除根节点(level 0),让根节点默认展开
  823. const rootNode = initialNodes.find(n => n.data.level === 0);
  824. if (rootNode) {
  825. nodesWithChildren.delete(rootNode.id);
  826. }
  827. return nodesWithChildren;
  828. }, [initialNodes, initialEdges]);
  829. // 树节点的折叠状态需要在树构建后初始化
  830. const [collapsedNodes, setCollapsedNodes] = useState(() => initialCollapsedNodes);
  831. const [collapsedTreeNodes, setCollapsedTreeNodes] = useState(new Set());
  832. const [selectedNodeId, setSelectedNodeId] = useState(null);
  833. const [hiddenNodes, setHiddenNodes] = useState(new Set()); // 用户手动隐藏的节点
  834. const [focusMode, setFocusMode] = useState(false); // 全局聚焦模式,默认关闭
  835. const [focusedNodeId, setFocusedNodeId] = useState(null); // 单独聚焦的节点ID
  836. // 获取 React Flow 实例以控制画布
  837. const { setCenter, fitView } = useReactFlow();
  838. // 获取某个节点的所有后代节点ID
  839. const getDescendants = useCallback((nodeId) => {
  840. const descendants = new Set();
  841. const queue = [nodeId];
  842. while (queue.length > 0) {
  843. const current = queue.shift();
  844. initialEdges.forEach(edge => {
  845. if (edge.source === current && !descendants.has(edge.target)) {
  846. descendants.add(edge.target);
  847. queue.push(edge.target);
  848. }
  849. });
  850. }
  851. return descendants;
  852. }, [initialEdges]);
  853. // 获取直接父节点
  854. const getDirectParents = useCallback((nodeId) => {
  855. const parents = [];
  856. initialEdges.forEach(edge => {
  857. if (edge.target === nodeId) {
  858. parents.push(edge.source);
  859. }
  860. });
  861. return parents;
  862. }, [initialEdges]);
  863. // 获取直接子节点
  864. const getDirectChildren = useCallback((nodeId) => {
  865. const children = [];
  866. initialEdges.forEach(edge => {
  867. if (edge.source === nodeId) {
  868. children.push(edge.target);
  869. }
  870. });
  871. return children;
  872. }, [initialEdges]);
  873. // 切换节点折叠状态
  874. const toggleNodeCollapse = useCallback((nodeId) => {
  875. setCollapsedNodes(prev => {
  876. const newSet = new Set(prev);
  877. const descendants = getDescendants(nodeId);
  878. if (newSet.has(nodeId)) {
  879. // 展开:移除此节点,但保持其他折叠的节点
  880. newSet.delete(nodeId);
  881. } else {
  882. // 折叠:添加此节点
  883. newSet.add(nodeId);
  884. }
  885. return newSet;
  886. });
  887. }, [getDescendants]);
  888. // 过滤可见的节点和边,并重新计算布局
  889. const { nodes, edges } = useMemo(() => {
  890. const nodesToHide = new Set();
  891. // 判断使用哪个节点ID进行聚焦:优先使用单独聚焦的节点,否则使用全局聚焦模式的选中节点
  892. const effectiveFocusNodeId = focusedNodeId || (focusMode ? selectedNodeId : null);
  893. // 聚焦模式:只显示聚焦节点、其父节点和直接子节点
  894. if (effectiveFocusNodeId) {
  895. const visibleInFocus = new Set([effectiveFocusNodeId]);
  896. // 添加所有父节点
  897. initialEdges.forEach(edge => {
  898. if (edge.target === effectiveFocusNodeId) {
  899. visibleInFocus.add(edge.source);
  900. }
  901. });
  902. // 添加所有直接子节点
  903. initialEdges.forEach(edge => {
  904. if (edge.source === effectiveFocusNodeId) {
  905. visibleInFocus.add(edge.target);
  906. }
  907. });
  908. // 隐藏不在聚焦范围内的节点
  909. initialNodes.forEach(node => {
  910. if (!visibleInFocus.has(node.id)) {
  911. nodesToHide.add(node.id);
  912. }
  913. });
  914. } else {
  915. // 非聚焦模式:使用原有的折叠逻辑
  916. // 收集所有被折叠节点的后代
  917. collapsedNodes.forEach(collapsedId => {
  918. const descendants = getDescendants(collapsedId);
  919. descendants.forEach(id => nodesToHide.add(id));
  920. });
  921. }
  922. // 添加用户手动隐藏的节点
  923. hiddenNodes.forEach(id => nodesToHide.add(id));
  924. const visibleNodes = initialNodes
  925. .filter(node => !nodesToHide.has(node.id))
  926. .map(node => ({
  927. ...node,
  928. data: {
  929. ...node.data,
  930. isCollapsed: collapsedNodes.has(node.id),
  931. hasChildren: initialEdges.some(e => e.source === node.id),
  932. onToggleCollapse: () => toggleNodeCollapse(node.id),
  933. onHideSelf: () => {
  934. setHiddenNodes(prev => {
  935. const newSet = new Set(prev);
  936. newSet.add(node.id);
  937. return newSet;
  938. });
  939. },
  940. onFocus: () => {
  941. // 切换聚焦状态
  942. if (focusedNodeId === node.id) {
  943. setFocusedNodeId(null); // 如果已经聚焦,则取消聚焦
  944. } else {
  945. // 先取消之前的聚焦,然后聚焦到当前节点
  946. setFocusedNodeId(node.id);
  947. // 延迟聚焦视图到该节点
  948. setTimeout(() => {
  949. fitView({
  950. nodes: [{ id: node.id }],
  951. duration: 800,
  952. padding: 0.3,
  953. });
  954. }, 100);
  955. }
  956. },
  957. isFocused: focusedNodeId === node.id,
  958. isHighlighted: selectedNodeId === node.id,
  959. }
  960. }));
  961. const visibleEdges = initialEdges.filter(
  962. edge => !nodesToHide.has(edge.source) && !nodesToHide.has(edge.target)
  963. );
  964. // 重新计算布局 - 只对可见节点
  965. if (typeof window !== 'undefined' && typeof window.dagre !== 'undefined') {
  966. try {
  967. const dagreGraph = new window.dagre.graphlib.Graph();
  968. dagreGraph.setDefaultEdgeLabel(() => ({}));
  969. dagreGraph.setGraph({
  970. rankdir: 'LR',
  971. nodesep: 120, // 垂直间距 - 增加以避免节点重叠
  972. ranksep: 280, // 水平间距 - 增加以容纳更宽的节点
  973. });
  974. visibleNodes.forEach((node) => {
  975. let nodeWidth = 280;
  976. let nodeHeight = 180;
  977. // note 节点有轮播图,需要更大的空间
  978. if (node.type === 'note') {
  979. nodeWidth = 320;
  980. nodeHeight = 350;
  981. }
  982. dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
  983. });
  984. visibleEdges.forEach((edge) => {
  985. dagreGraph.setEdge(edge.source, edge.target);
  986. });
  987. window.dagre.layout(dagreGraph);
  988. visibleNodes.forEach((node) => {
  989. const nodeWithPosition = dagreGraph.node(node.id);
  990. if (nodeWithPosition) {
  991. // 根据节点类型获取对应的尺寸
  992. let nodeWidth = 280;
  993. let nodeHeight = 180;
  994. if (node.type === 'note') {
  995. nodeWidth = 320;
  996. nodeHeight = 350;
  997. }
  998. node.position = {
  999. x: nodeWithPosition.x - nodeWidth / 2,
  1000. y: nodeWithPosition.y - nodeHeight / 2,
  1001. };
  1002. node.targetPosition = 'left';
  1003. node.sourcePosition = 'right';
  1004. }
  1005. });
  1006. console.log('✅ Dynamic layout recalculated for', visibleNodes.length, 'visible nodes');
  1007. } catch (error) {
  1008. console.error('❌ Error in dynamic layout:', error);
  1009. }
  1010. }
  1011. return { nodes: visibleNodes, edges: visibleEdges };
  1012. }, [initialNodes, initialEdges, collapsedNodes, hiddenNodes, focusMode, focusedNodeId, getDescendants, toggleNodeCollapse, selectedNodeId]);
  1013. // 构建树形结构 - 允许一个节点有多个父节点
  1014. const buildTree = useCallback(() => {
  1015. const nodeMap = new Map();
  1016. initialNodes.forEach(node => {
  1017. nodeMap.set(node.id, node);
  1018. });
  1019. // 为每个节点创建树节点的副本(允许多次出现)
  1020. const createTreeNode = (nodeId, pathKey) => {
  1021. const node = nodeMap.get(nodeId);
  1022. if (!node) return null;
  1023. return {
  1024. ...node,
  1025. treeKey: pathKey, // 唯一的树路径key,用于React key
  1026. children: []
  1027. };
  1028. };
  1029. // 构建父子关系映射:记录每个节点的所有父节点,去重边
  1030. const parentToChildren = new Map();
  1031. const childToParents = new Map();
  1032. initialEdges.forEach(edge => {
  1033. // 记录父->子关系(去重:同一个父节点到同一个子节点只记录一次)
  1034. if (!parentToChildren.has(edge.source)) {
  1035. parentToChildren.set(edge.source, []);
  1036. }
  1037. const children = parentToChildren.get(edge.source);
  1038. if (!children.includes(edge.target)) {
  1039. children.push(edge.target);
  1040. }
  1041. // 记录子->父关系(用于判断是否有多个父节点,也去重)
  1042. if (!childToParents.has(edge.target)) {
  1043. childToParents.set(edge.target, []);
  1044. }
  1045. const parents = childToParents.get(edge.target);
  1046. if (!parents.includes(edge.source)) {
  1047. parents.push(edge.source);
  1048. }
  1049. });
  1050. // 递归构建树
  1051. const buildSubtree = (nodeId, pathKey, visitedInPath) => {
  1052. // 避免循环引用:如果当前路径中已经访问过这个节点,跳过
  1053. if (visitedInPath.has(nodeId)) {
  1054. return null;
  1055. }
  1056. const treeNode = createTreeNode(nodeId, pathKey);
  1057. if (!treeNode) return null;
  1058. const newVisitedInPath = new Set(visitedInPath);
  1059. newVisitedInPath.add(nodeId);
  1060. const children = parentToChildren.get(nodeId) || [];
  1061. treeNode.children = children
  1062. .map((childId, index) => buildSubtree(childId, pathKey + '-' + childId + '-' + index, newVisitedInPath))
  1063. .filter(child => child !== null);
  1064. return treeNode;
  1065. };
  1066. // 找出所有根节点(没有入边的节点)
  1067. const hasParent = new Set();
  1068. initialEdges.forEach(edge => {
  1069. hasParent.add(edge.target);
  1070. });
  1071. const roots = [];
  1072. initialNodes.forEach((node, index) => {
  1073. if (!hasParent.has(node.id)) {
  1074. const treeNode = buildSubtree(node.id, 'root-' + node.id + '-' + index, new Set());
  1075. if (treeNode) roots.push(treeNode);
  1076. }
  1077. });
  1078. return roots;
  1079. }, [initialNodes, initialEdges]);
  1080. const treeRoots = useMemo(() => buildTree(), [buildTree]);
  1081. // 初始化树节点折叠状态
  1082. useEffect(() => {
  1083. const getAllTreeKeys = (nodes) => {
  1084. const keys = new Set();
  1085. const traverse = (node) => {
  1086. if (node.children && node.children.length > 0) {
  1087. // 排除根节点
  1088. if (node.data.level !== 0) {
  1089. keys.add(node.treeKey);
  1090. }
  1091. node.children.forEach(traverse);
  1092. }
  1093. };
  1094. nodes.forEach(traverse);
  1095. return keys;
  1096. };
  1097. setCollapsedTreeNodes(getAllTreeKeys(treeRoots));
  1098. }, [treeRoots]);
  1099. const renderTree = useCallback((treeNodes, level = 0) => {
  1100. return treeNodes.map(node => {
  1101. // 使用 treeKey 来区分树中的不同实例
  1102. const isCollapsed = collapsedTreeNodes.has(node.treeKey);
  1103. const isSelected = selectedNodeId === node.id;
  1104. return (
  1105. <TreeNode
  1106. key={node.treeKey}
  1107. node={node}
  1108. level={level}
  1109. isCollapsed={isCollapsed}
  1110. isSelected={isSelected}
  1111. onToggle={() => {
  1112. setCollapsedTreeNodes(prev => {
  1113. const newSet = new Set(prev);
  1114. if (newSet.has(node.treeKey)) {
  1115. newSet.delete(node.treeKey);
  1116. } else {
  1117. newSet.add(node.treeKey);
  1118. }
  1119. return newSet;
  1120. });
  1121. }}
  1122. onSelect={() => {
  1123. const nodeId = node.id;
  1124. // 展开所有祖先节点
  1125. const ancestorIds = [nodeId];
  1126. const findAncestors = (id) => {
  1127. initialEdges.forEach(edge => {
  1128. if (edge.target === id && !ancestorIds.includes(edge.source)) {
  1129. ancestorIds.push(edge.source);
  1130. findAncestors(edge.source);
  1131. }
  1132. });
  1133. };
  1134. findAncestors(nodeId);
  1135. // 如果节点或其祖先被隐藏,先恢复它们
  1136. setHiddenNodes(prev => {
  1137. const newSet = new Set(prev);
  1138. ancestorIds.forEach(id => newSet.delete(id));
  1139. return newSet;
  1140. });
  1141. setSelectedNodeId(nodeId);
  1142. // 获取选中节点的直接子节点
  1143. const childrenIds = [];
  1144. initialEdges.forEach(edge => {
  1145. if (edge.source === nodeId) {
  1146. childrenIds.push(edge.target);
  1147. }
  1148. });
  1149. setCollapsedNodes(prev => {
  1150. const newSet = new Set(prev);
  1151. // 展开所有祖先节点
  1152. ancestorIds.forEach(id => newSet.delete(id));
  1153. // 展开选中节点本身
  1154. newSet.delete(nodeId);
  1155. // 展开选中节点的直接子节点
  1156. childrenIds.forEach(id => newSet.delete(id));
  1157. return newSet;
  1158. });
  1159. // 延迟聚焦,等待节点展开和布局重新计算
  1160. setTimeout(() => {
  1161. fitView({
  1162. nodes: [{ id: nodeId }],
  1163. duration: 800,
  1164. padding: 0.3,
  1165. });
  1166. }, 300);
  1167. }}
  1168. >
  1169. {node.children && node.children.length > 0 && renderTree(node.children, level + 1)}
  1170. </TreeNode>
  1171. );
  1172. });
  1173. }, [collapsedTreeNodes, selectedNodeId, nodes, setCenter, initialEdges, setCollapsedNodes, fitView]);
  1174. console.log('📊 Rendering with', nodes.length, 'visible nodes and', edges.length, 'visible edges');
  1175. if (nodes.length === 0) {
  1176. return (
  1177. <div style={{ padding: 50, color: 'red', fontSize: 20 }}>
  1178. ERROR: No nodes to display!
  1179. </div>
  1180. );
  1181. }
  1182. return (
  1183. <div style={{ width: '100vw', height: '100vh', background: '#f9fafb', display: 'flex', flexDirection: 'column' }}>
  1184. {/* 顶部面包屑导航栏 */}
  1185. <div style={{
  1186. minHeight: '48px',
  1187. maxHeight: '120px',
  1188. background: 'white',
  1189. borderBottom: '1px solid #e5e7eb',
  1190. display: 'flex',
  1191. alignItems: 'flex-start',
  1192. padding: '12px 24px',
  1193. zIndex: 1000,
  1194. boxShadow: '0 1px 3px rgba(0, 0, 0, 0.05)',
  1195. flexShrink: 0,
  1196. overflowY: 'auto',
  1197. }}>
  1198. <div style={{ width: '100%' }}>
  1199. {selectedNodeId ? (
  1200. <div style={{ fontSize: '12px', color: '#6b7280' }}>
  1201. {/* 面包屑导航 - 显示所有路径 */}
  1202. {(() => {
  1203. const selectedNode = nodes.find(n => n.id === selectedNodeId);
  1204. if (!selectedNode) return null;
  1205. // 找到所有从根节点到当前节点的路径
  1206. const findAllPaths = (targetId) => {
  1207. const paths = [];
  1208. const buildPath = (nodeId, currentPath) => {
  1209. const node = initialNodes.find(n => n.id === nodeId);
  1210. if (!node) return;
  1211. const newPath = [node, ...currentPath];
  1212. // 找到所有父节点
  1213. const parents = initialEdges.filter(e => e.target === nodeId).map(e => e.source);
  1214. if (parents.length === 0) {
  1215. // 到达根节点
  1216. paths.push(newPath);
  1217. } else {
  1218. // 递归处理所有父节点
  1219. parents.forEach(parentId => {
  1220. buildPath(parentId, newPath);
  1221. });
  1222. }
  1223. };
  1224. buildPath(targetId, []);
  1225. return paths;
  1226. };
  1227. const allPaths = findAllPaths(selectedNodeId);
  1228. // 去重:将路径转换为字符串进行比较
  1229. const uniquePaths = [];
  1230. const pathStrings = new Set();
  1231. allPaths.forEach(path => {
  1232. const pathString = path.map(n => n.id).join('->');
  1233. if (!pathStrings.has(pathString)) {
  1234. pathStrings.add(pathString);
  1235. uniquePaths.push(path);
  1236. }
  1237. });
  1238. return (
  1239. <div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
  1240. {uniquePaths.map((path, pathIndex) => (
  1241. <div key={pathIndex} style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
  1242. {pathIndex > 0 && <span style={{ color: '#d1d5db', marginRight: '4px' }}>或</span>}
  1243. {path.map((node, index) => {
  1244. // 获取节点的 score、strategy 和 isSelected
  1245. const nodeScore = node.data.score ? parseFloat(node.data.score) : 0;
  1246. const nodeStrategy = node.data.strategy || '';
  1247. const strategyColor = getStrategyColor(nodeStrategy);
  1248. const nodeIsSelected = node.type === 'note' ? node.data.matchLevel !== 'unsatisfied' : node.data.isSelected !== false;
  1249. return (
  1250. <React.Fragment key={node.id + '-' + index}>
  1251. <span
  1252. onClick={() => {
  1253. const nodeId = node.id;
  1254. // 找到所有祖先节点
  1255. const ancestorIds = [nodeId];
  1256. const findAncestors = (id) => {
  1257. initialEdges.forEach(edge => {
  1258. if (edge.target === id && !ancestorIds.includes(edge.source)) {
  1259. ancestorIds.push(edge.source);
  1260. findAncestors(edge.source);
  1261. }
  1262. });
  1263. };
  1264. findAncestors(nodeId);
  1265. // 如果节点或其祖先被隐藏,先恢复它们
  1266. setHiddenNodes(prev => {
  1267. const newSet = new Set(prev);
  1268. ancestorIds.forEach(id => newSet.delete(id));
  1269. return newSet;
  1270. });
  1271. // 展开目录树中到达该节点的路径
  1272. // 需要找到所有包含该节点的树路径的 treeKey,并展开它们的父节点
  1273. setCollapsedTreeNodes(prev => {
  1274. const newSet = new Set(prev);
  1275. // 清空所有折叠状态,让目录树完全展开到选中节点
  1276. // 这样可以确保选中节点在目录中可见
  1277. return new Set();
  1278. });
  1279. setSelectedNodeId(nodeId);
  1280. setTimeout(() => {
  1281. fitView({
  1282. nodes: [{ id: nodeId }],
  1283. duration: 800,
  1284. padding: 0.3,
  1285. });
  1286. }, 100);
  1287. }}
  1288. style={{
  1289. padding: '6px 8px',
  1290. borderRadius: '4px',
  1291. background: 'white',
  1292. border: index === path.length - 1 ? '2px solid #3b82f6' : '1px solid #d1d5db',
  1293. color: '#374151',
  1294. fontWeight: index === path.length - 1 ? '600' : '400',
  1295. width: '180px',
  1296. cursor: 'pointer',
  1297. transition: 'all 0.2s ease',
  1298. position: 'relative',
  1299. display: 'inline-flex',
  1300. flexDirection: 'column',
  1301. gap: '4px',
  1302. }}
  1303. onMouseEnter={(e) => {
  1304. e.currentTarget.style.opacity = '0.8';
  1305. }}
  1306. onMouseLeave={(e) => {
  1307. e.currentTarget.style.opacity = '1';
  1308. }}
  1309. title={\`\${node.data.title || node.id} (Score: \${nodeScore.toFixed(2)}, Strategy: \${nodeStrategy}, Selected: \${nodeIsSelected})\`}
  1310. >
  1311. {/* 上半部分:竖线 + 图标 + 文字 + 分数 */}
  1312. <div style={{
  1313. display: 'flex',
  1314. alignItems: 'center',
  1315. gap: '6px',
  1316. }}>
  1317. {/* 策略类型竖线 */}
  1318. <div style={{
  1319. width: '3px',
  1320. height: '16px',
  1321. background: strategyColor,
  1322. borderRadius: '2px',
  1323. flexShrink: 0,
  1324. }} />
  1325. {/* 节点类型图标 */}
  1326. <span style={{
  1327. fontSize: '11px',
  1328. flexShrink: 0,
  1329. }}>
  1330. {node.type === 'note' ? '📝' : '🔍'}
  1331. </span>
  1332. {/* 节点文字 */}
  1333. <span style={{
  1334. flex: 1,
  1335. fontSize: '12px',
  1336. color: nodeIsSelected ? '#374151' : '#ef4444',
  1337. }}>
  1338. {truncateMiddle(node.data.title || node.id, 18)}
  1339. </span>
  1340. {/* 分数显示 */}
  1341. <span style={{
  1342. fontSize: '10px',
  1343. color: '#6b7280',
  1344. fontWeight: '500',
  1345. flexShrink: 0,
  1346. }}>
  1347. {nodeScore.toFixed(2)}
  1348. </span>
  1349. </div>
  1350. {/* 分数下划线 */}
  1351. <div style={{
  1352. width: (nodeScore * 100) + '%',
  1353. height: '2px',
  1354. background: getScoreColor(nodeScore),
  1355. borderRadius: '1px',
  1356. marginLeft: '9px',
  1357. }} />
  1358. </span>
  1359. {index < path.length - 1 && <span style={{ color: '#9ca3af' }}>›</span>}
  1360. </React.Fragment>
  1361. )})}
  1362. </div>
  1363. ))}
  1364. </div>
  1365. );
  1366. })()}
  1367. </div>
  1368. ) : (
  1369. <div style={{ fontSize: '13px', color: '#9ca3af', textAlign: 'center' }}>
  1370. 选择一个节点查看路径
  1371. </div>
  1372. )}
  1373. </div>
  1374. </div>
  1375. {/* 主内容区:目录 + 画布 */}
  1376. <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
  1377. {/* 左侧目录树 */}
  1378. <div style={{
  1379. width: '320px',
  1380. background: 'white',
  1381. borderRight: '1px solid #e5e7eb',
  1382. display: 'flex',
  1383. flexDirection: 'column',
  1384. flexShrink: 0,
  1385. }}>
  1386. <div style={{
  1387. padding: '12px 16px',
  1388. borderBottom: '1px solid #e5e7eb',
  1389. display: 'flex',
  1390. justifyContent: 'space-between',
  1391. alignItems: 'center',
  1392. }}>
  1393. <span style={{
  1394. fontWeight: '600',
  1395. fontSize: '14px',
  1396. color: '#111827',
  1397. }}>
  1398. 节点目录
  1399. </span>
  1400. <div style={{ display: 'flex', gap: '6px' }}>
  1401. <button
  1402. onClick={() => {
  1403. setCollapsedTreeNodes(new Set());
  1404. }}
  1405. style={{
  1406. fontSize: '11px',
  1407. padding: '4px 8px',
  1408. borderRadius: '4px',
  1409. border: '1px solid #d1d5db',
  1410. background: 'white',
  1411. color: '#6b7280',
  1412. cursor: 'pointer',
  1413. fontWeight: '500',
  1414. }}
  1415. title="展开全部节点"
  1416. >
  1417. 全部展开
  1418. </button>
  1419. <button
  1420. onClick={() => {
  1421. const getAllTreeKeys = (nodes) => {
  1422. const keys = new Set();
  1423. const traverse = (node) => {
  1424. if (node.children && node.children.length > 0) {
  1425. keys.add(node.treeKey);
  1426. node.children.forEach(traverse);
  1427. }
  1428. };
  1429. nodes.forEach(traverse);
  1430. return keys;
  1431. };
  1432. setCollapsedTreeNodes(getAllTreeKeys(treeRoots));
  1433. }}
  1434. style={{
  1435. fontSize: '11px',
  1436. padding: '4px 8px',
  1437. borderRadius: '4px',
  1438. border: '1px solid #d1d5db',
  1439. background: 'white',
  1440. color: '#6b7280',
  1441. cursor: 'pointer',
  1442. fontWeight: '500',
  1443. }}
  1444. title="折叠全部节点"
  1445. >
  1446. 全部折叠
  1447. </button>
  1448. </div>
  1449. </div>
  1450. <div style={{
  1451. flex: 1,
  1452. overflowX: 'auto',
  1453. overflowY: 'auto',
  1454. padding: '8px',
  1455. }}>
  1456. <div style={{ minWidth: 'fit-content' }}>
  1457. {renderTree(treeRoots)}
  1458. </div>
  1459. </div>
  1460. </div>
  1461. {/* 画布区域 */}
  1462. <div style={{ flex: 1, position: 'relative' }}>
  1463. {/* 右侧图例 */}
  1464. <div style={{
  1465. position: 'absolute',
  1466. top: '20px',
  1467. right: '20px',
  1468. background: 'white',
  1469. padding: '16px',
  1470. borderRadius: '12px',
  1471. boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
  1472. zIndex: 1000,
  1473. maxWidth: '260px',
  1474. border: '1px solid #e5e7eb',
  1475. }}>
  1476. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
  1477. <h3 style={{ fontSize: '14px', fontWeight: '600', color: '#111827', margin: 0 }}>图例</h3>
  1478. <button
  1479. onClick={() => setFocusMode(!focusMode)}
  1480. style={{
  1481. fontSize: '11px',
  1482. padding: '4px 8px',
  1483. borderRadius: '4px',
  1484. border: '1px solid',
  1485. borderColor: focusMode ? '#3b82f6' : '#d1d5db',
  1486. background: focusMode ? '#3b82f6' : 'white',
  1487. color: focusMode ? 'white' : '#6b7280',
  1488. cursor: 'pointer',
  1489. fontWeight: '500',
  1490. }}
  1491. title={focusMode ? '关闭聚焦模式' : '开启聚焦模式'}
  1492. >
  1493. {focusMode ? '🎯 聚焦' : '📊 全图'}
  1494. </button>
  1495. </div>
  1496. <div style={{ fontSize: '12px' }}>
  1497. {/* 画布节点展开/折叠控制 */}
  1498. <div style={{ marginBottom: '12px', paddingBottom: '12px', borderBottom: '1px solid #f3f4f6' }}>
  1499. <div style={{ fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#374151' }}>节点控制</div>
  1500. <div style={{ display: 'flex', gap: '6px' }}>
  1501. <button
  1502. onClick={() => {
  1503. setCollapsedNodes(new Set());
  1504. }}
  1505. style={{
  1506. fontSize: '11px',
  1507. padding: '4px 8px',
  1508. borderRadius: '4px',
  1509. border: '1px solid #d1d5db',
  1510. background: 'white',
  1511. color: '#6b7280',
  1512. cursor: 'pointer',
  1513. fontWeight: '500',
  1514. flex: 1,
  1515. }}
  1516. title="展开画布中所有节点的子节点"
  1517. >
  1518. 全部展开
  1519. </button>
  1520. <button
  1521. onClick={() => {
  1522. const allNodeIds = new Set(initialNodes.map(n => n.id));
  1523. setCollapsedNodes(allNodeIds);
  1524. }}
  1525. style={{
  1526. fontSize: '11px',
  1527. padding: '4px 8px',
  1528. borderRadius: '4px',
  1529. border: '1px solid #d1d5db',
  1530. background: 'white',
  1531. color: '#6b7280',
  1532. cursor: 'pointer',
  1533. fontWeight: '500',
  1534. flex: 1,
  1535. }}
  1536. title="折叠画布中所有节点的子节点"
  1537. >
  1538. 全部折叠
  1539. </button>
  1540. </div>
  1541. </div>
  1542. <div style={{ paddingTop: '12px', borderTop: '1px solid #f3f4f6' }}>
  1543. <div style={{ fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#374151' }}>策略类型</div>
  1544. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1545. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#10b981', opacity: 0.7 }}></div>
  1546. <span style={{ color: '#6b7280', fontSize: '11px' }}>初始分词</span>
  1547. </div>
  1548. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1549. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#06b6d4', opacity: 0.7 }}></div>
  1550. <span style={{ color: '#6b7280', fontSize: '11px' }}>调用sug</span>
  1551. </div>
  1552. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1553. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#f59e0b', opacity: 0.7 }}></div>
  1554. <span style={{ color: '#6b7280', fontSize: '11px' }}>同义改写</span>
  1555. </div>
  1556. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1557. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#3b82f6', opacity: 0.7 }}></div>
  1558. <span style={{ color: '#6b7280', fontSize: '11px' }}>加词</span>
  1559. </div>
  1560. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1561. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#8b5cf6', opacity: 0.7 }}></div>
  1562. <span style={{ color: '#6b7280', fontSize: '11px' }}>抽象改写</span>
  1563. </div>
  1564. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1565. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#ec4899', opacity: 0.7 }}></div>
  1566. <span style={{ color: '#6b7280', fontSize: '11px' }}>基于部分匹配改进</span>
  1567. </div>
  1568. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1569. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#a855f7', opacity: 0.7 }}></div>
  1570. <span style={{ color: '#6b7280', fontSize: '11px' }}>结果分支-抽象改写</span>
  1571. </div>
  1572. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  1573. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#fb923c', opacity: 0.7 }}></div>
  1574. <span style={{ color: '#6b7280', fontSize: '11px' }}>结果分支-同义改写</span>
  1575. </div>
  1576. </div>
  1577. <div style={{
  1578. marginTop: '12px',
  1579. paddingTop: '12px',
  1580. borderTop: '1px solid #f3f4f6',
  1581. fontSize: '11px',
  1582. color: '#9ca3af',
  1583. lineHeight: '1.5',
  1584. }}>
  1585. 💡 点击节点左上角 × 隐藏节点
  1586. </div>
  1587. {/* 隐藏节点列表 - 在图例内部 */}
  1588. {hiddenNodes.size > 0 && (
  1589. <div style={{
  1590. marginTop: '12px',
  1591. paddingTop: '12px',
  1592. borderTop: '1px solid #f3f4f6',
  1593. }}>
  1594. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
  1595. <h4 style={{ fontSize: '12px', fontWeight: '600', color: '#111827' }}>已隐藏节点</h4>
  1596. <button
  1597. onClick={() => setHiddenNodes(new Set())}
  1598. style={{
  1599. fontSize: '10px',
  1600. color: '#3b82f6',
  1601. background: 'none',
  1602. border: 'none',
  1603. cursor: 'pointer',
  1604. textDecoration: 'underline',
  1605. }}
  1606. >
  1607. 全部恢复
  1608. </button>
  1609. </div>
  1610. <div style={{ fontSize: '12px', maxHeight: '200px', overflow: 'auto' }}>
  1611. {Array.from(hiddenNodes).map(nodeId => {
  1612. const node = initialNodes.find(n => n.id === nodeId);
  1613. if (!node) return null;
  1614. return (
  1615. <div
  1616. key={nodeId}
  1617. style={{
  1618. display: 'flex',
  1619. justifyContent: 'space-between',
  1620. alignItems: 'center',
  1621. padding: '6px 8px',
  1622. margin: '4px 0',
  1623. background: '#f9fafb',
  1624. borderRadius: '6px',
  1625. fontSize: '11px',
  1626. }}
  1627. >
  1628. <span
  1629. style={{
  1630. flex: 1,
  1631. overflow: 'hidden',
  1632. textOverflow: 'ellipsis',
  1633. whiteSpace: 'nowrap',
  1634. color: '#374151',
  1635. }}
  1636. title={node.data.title || nodeId}
  1637. >
  1638. {node.data.title || nodeId}
  1639. </span>
  1640. <button
  1641. onClick={() => {
  1642. setHiddenNodes(prev => {
  1643. const newSet = new Set(prev);
  1644. newSet.delete(nodeId);
  1645. return newSet;
  1646. });
  1647. }}
  1648. style={{
  1649. marginLeft: '8px',
  1650. fontSize: '10px',
  1651. color: '#10b981',
  1652. background: 'none',
  1653. border: 'none',
  1654. cursor: 'pointer',
  1655. flexShrink: 0,
  1656. }}
  1657. >
  1658. 恢复
  1659. </button>
  1660. </div>
  1661. );
  1662. })}
  1663. </div>
  1664. </div>
  1665. )}
  1666. </div>
  1667. </div>
  1668. {/* React Flow 画布 */}
  1669. <ReactFlow
  1670. nodes={nodes}
  1671. edges={edges}
  1672. nodeTypes={nodeTypes}
  1673. fitView
  1674. fitViewOptions={{ padding: 0.2, duration: 500 }}
  1675. minZoom={0.1}
  1676. maxZoom={1.5}
  1677. nodesDraggable={true}
  1678. nodesConnectable={false}
  1679. elementsSelectable={true}
  1680. defaultEdgeOptions={{
  1681. type: 'smoothstep',
  1682. }}
  1683. proOptions={{ hideAttribution: true }}
  1684. onNodeClick={(event, clickedNode) => {
  1685. setSelectedNodeId(clickedNode.id);
  1686. }}
  1687. >
  1688. <Controls style={{ bottom: '20px', left: 'auto', right: '20px' }} />
  1689. <Background variant="dots" gap={20} size={1} color="#e5e7eb" />
  1690. </ReactFlow>
  1691. </div>
  1692. </div>
  1693. </div>
  1694. );
  1695. }
  1696. function App() {
  1697. return (
  1698. <ReactFlowProvider>
  1699. <FlowContent />
  1700. </ReactFlowProvider>
  1701. );
  1702. }
  1703. const root = createRoot(document.getElementById('root'));
  1704. root.render(<App />);
  1705. `;
  1706. fs.writeFileSync(reactComponentPath, reactComponent);
  1707. // 使用 esbuild 打包
  1708. console.log('🎨 Building modern visualization...');
  1709. build({
  1710. entryPoints: [reactComponentPath],
  1711. bundle: true,
  1712. outfile: path.join(__dirname, 'bundle_v2.js'),
  1713. format: 'iife',
  1714. loader: {
  1715. '.css': 'css',
  1716. },
  1717. minify: false,
  1718. sourcemap: 'inline',
  1719. // 强制所有 React 引用指向同一个位置,避免多副本
  1720. alias: {
  1721. 'react': path.join(__dirname, 'node_modules/react'),
  1722. 'react-dom': path.join(__dirname, 'node_modules/react-dom'),
  1723. 'react/jsx-runtime': path.join(__dirname, 'node_modules/react/jsx-runtime'),
  1724. 'react/jsx-dev-runtime': path.join(__dirname, 'node_modules/react/jsx-dev-runtime'),
  1725. },
  1726. }).then(() => {
  1727. // 读取打包后的 JS
  1728. const bundleJs = fs.readFileSync(path.join(__dirname, 'bundle_v2.js'), 'utf-8');
  1729. // 读取 CSS
  1730. const cssPath = path.join(__dirname, 'node_modules/@xyflow/react/dist/style.css');
  1731. const css = fs.readFileSync(cssPath, 'utf-8');
  1732. // 生成最终 HTML
  1733. const html = `<!DOCTYPE html>
  1734. <html lang="zh-CN">
  1735. <head>
  1736. <meta charset="UTF-8">
  1737. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  1738. <title>查询图可视化</title>
  1739. <link rel="preconnect" href="https://fonts.googleapis.com">
  1740. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  1741. <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
  1742. <script src="https://unpkg.com/dagre@0.8.5/dist/dagre.min.js"></script>
  1743. <script>
  1744. // 过滤特定的 React 警告
  1745. const originalError = console.error;
  1746. console.error = (...args) => {
  1747. if (typeof args[0] === 'string' && args[0].includes('Each child in a list should have a unique "key" prop')) {
  1748. return;
  1749. }
  1750. originalError.apply(console, args);
  1751. };
  1752. </script>
  1753. <style>
  1754. * {
  1755. margin: 0;
  1756. padding: 0;
  1757. box-sizing: border-box;
  1758. }
  1759. body {
  1760. font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  1761. overflow: hidden;
  1762. -webkit-font-smoothing: antialiased;
  1763. -moz-osx-font-smoothing: grayscale;
  1764. }
  1765. #root {
  1766. width: 100vw;
  1767. height: 100vh;
  1768. }
  1769. ${css}
  1770. /* 自定义样式覆盖 */
  1771. .react-flow__edge-path {
  1772. stroke-linecap: round;
  1773. }
  1774. .react-flow__controls {
  1775. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  1776. border: 1px solid #e5e7eb;
  1777. border-radius: 8px;
  1778. }
  1779. .react-flow__controls-button {
  1780. border: none;
  1781. border-bottom: 1px solid #e5e7eb;
  1782. }
  1783. .react-flow__controls-button:hover {
  1784. background: #f9fafb;
  1785. }
  1786. </style>
  1787. </head>
  1788. <body>
  1789. <div id="root"></div>
  1790. <script>${bundleJs}</script>
  1791. </body>
  1792. </html>`;
  1793. // 写入输出文件
  1794. fs.writeFileSync(outputFile, html);
  1795. // 清理临时文件
  1796. fs.unlinkSync(reactComponentPath);
  1797. fs.unlinkSync(path.join(__dirname, 'bundle_v2.js'));
  1798. console.log('✅ Visualization generated: ' + outputFile);
  1799. console.log('📊 Nodes: ' + Object.keys(data.nodes).length);
  1800. console.log('🔗 Edges: ' + data.edges.length);
  1801. }).catch(error => {
  1802. console.error('❌ Build error:', error);
  1803. process.exit(1);
  1804. });