visualize_v2.js 65 KB

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