index.js 103 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const { build } = require('esbuild');
  5. const { convertV8ToGraph } = require('./convert_v8_to_graph');
  6. const { convertV8ToGraphV2, convertV8ToGraphSimplified } = require('./convert_v8_to_graph_v3');
  7. // 读取命令行参数
  8. const args = process.argv.slice(2);
  9. if (args.length === 0) {
  10. console.error('Usage: node index.js <path-to-run_context.json> [output.html] [--simplified]');
  11. process.exit(1);
  12. }
  13. const inputFile = args[0];
  14. const outputFile = args[1] || 'query_graph_output.html';
  15. const useSimplified = args.includes('--simplified');
  16. // 读取输入数据
  17. const inputData = JSON.parse(fs.readFileSync(inputFile, 'utf-8'));
  18. // 检测数据格式并转换
  19. let data;
  20. if (inputData.rounds && inputData.o) {
  21. // v6.1.2.8 格式,需要转换
  22. console.log('✨ 检测到 v6.1.2.8 格式,正在转换为图结构...');
  23. // 尝试读取 search_results.json(兼容旧版本)
  24. let searchResults = null;
  25. const searchResultsPath = path.join(path.dirname(inputFile), 'search_results.json');
  26. if (fs.existsSync(searchResultsPath)) {
  27. console.log('📄 读取外部搜索结果数据(兼容模式)...');
  28. searchResults = JSON.parse(fs.readFileSync(searchResultsPath, 'utf-8'));
  29. } else {
  30. console.log('✅ 使用 run_context.json 中的内嵌搜索结果');
  31. }
  32. // 尝试读取 search_extract.json(多模态提取数据)
  33. let extractionData = null;
  34. const extractionPath = path.join(path.dirname(inputFile), 'search_extract.json');
  35. if (fs.existsSync(extractionPath)) {
  36. console.log('📸 读取多模态提取数据...');
  37. extractionData = JSON.parse(fs.readFileSync(extractionPath, 'utf-8'));
  38. } else {
  39. console.log('ℹ️ 未找到 search_extract.json,跳过多模态展示');
  40. }
  41. // 选择转换函数
  42. let graphData;
  43. let fullData = null; // 用于目录的完整数据
  44. if (useSimplified) {
  45. console.log('🎨 使用简化视图(合并query节点)');
  46. // 生成简化版用于画布
  47. graphData = convertV8ToGraphSimplified(inputData, searchResults, extractionData);
  48. // 生成完整版用于目录
  49. const fullGraphData = convertV8ToGraphV2(inputData, searchResults, extractionData);
  50. fullData = {
  51. nodes: fullGraphData.nodes,
  52. edges: fullGraphData.edges,
  53. iterations: fullGraphData.iterations
  54. };
  55. console.log(`✅ 简化版: ${Object.keys(graphData.nodes).length} 个节点, ${graphData.edges.length} 条边`);
  56. console.log(`📋 完整版(用于目录): ${Object.keys(fullData.nodes).length} 个节点`);
  57. } else {
  58. console.log('📊 使用详细视图(完整流程)');
  59. graphData = convertV8ToGraphV2(inputData, searchResults, extractionData);
  60. console.log(`✅ 转换完成: ${Object.keys(graphData.nodes).length} 个节点, ${graphData.edges.length} 条边`);
  61. }
  62. data = {
  63. nodes: graphData.nodes,
  64. edges: graphData.edges,
  65. iterations: graphData.iterations,
  66. fullData: fullData // 传递完整数据
  67. };
  68. } else if (inputData.nodes && inputData.edges) {
  69. // v6.1.2.5 格式,直接使用
  70. console.log('✨ 检测到 v6.1.2.5 格式,直接使用');
  71. data = inputData;
  72. } else {
  73. console.error('❌ 无法识别的数据格式');
  74. process.exit(1);
  75. }
  76. // 创建临时 React 组件文件
  77. const reactComponentPath = path.join(__dirname, 'temp_flow_component_v2.jsx');
  78. const reactComponent = `
  79. import React, { useState, useCallback, useMemo, useEffect } from 'react';
  80. import { createRoot } from 'react-dom/client';
  81. import {
  82. ReactFlow,
  83. Controls,
  84. Background,
  85. useNodesState,
  86. useEdgesState,
  87. Handle,
  88. Position,
  89. useReactFlow,
  90. ReactFlowProvider,
  91. } from '@xyflow/react';
  92. import '@xyflow/react/dist/style.css';
  93. const data = ${JSON.stringify(data, null, 2)};
  94. // 根据节点类型获取边框颜色
  95. function getNodeTypeColor(type) {
  96. const typeColors = {
  97. 'root': '#6b21a8', // 紫色 - 根节点
  98. 'round': '#7c3aed', // 深紫 - Round节点
  99. 'step': '#f59e0b', // 橙色 - 步骤节点
  100. 'seg': '#10b981', // 绿色 - 分词
  101. 'q': '#3b82f6', // 蓝色 - Query
  102. 'sug': '#06b6d4', // 青色 - Sug建议词
  103. 'seed': '#84cc16', // 黄绿 - Seed
  104. 'add_word': '#22c55e', // 绿色 - 加词生成
  105. 'search_word': '#8b5cf6', // 紫色 - 搜索词
  106. 'post': '#ec4899', // 粉色 - 帖子
  107. 'filtered_sug': '#14b8a6',// 青绿 - 筛选的sug
  108. 'next_q': '#2563eb', // 深蓝 - 下轮查询
  109. 'next_seed': '#65a30d', // 深黄绿 - 下轮种子
  110. 'search': '#8b5cf6', // 深紫 - 搜索(兼容旧版)
  111. 'operation': '#f59e0b', // 橙色 - 操作节点(兼容旧版)
  112. 'query': '#3b82f6', // 蓝色 - 查询(兼容旧版)
  113. 'note': '#ec4899', // 粉色 - 帖子(兼容旧版)
  114. };
  115. return typeColors[type] || '#9ca3af';
  116. }
  117. // 查询节点组件 - 卡片样式
  118. function QueryNode({ id, data, sourcePosition, targetPosition }) {
  119. // 所有节点默认展开
  120. const expanded = true;
  121. // 获取节点类型颜色
  122. const typeColor = getNodeTypeColor(data.nodeType || 'query');
  123. return (
  124. <div>
  125. <Handle
  126. type="target"
  127. position={targetPosition || Position.Left}
  128. style={{ background: typeColor, width: 8, height: 8 }}
  129. />
  130. <div
  131. style={{
  132. padding: '12px',
  133. borderRadius: '8px',
  134. border: data.isHighlighted ? \`3px solid \${typeColor}\` :
  135. data.isCollapsed ? \`2px solid \${typeColor}\` :
  136. data.isSelected === false ? '2px dashed #d1d5db' :
  137. \`2px solid \${typeColor}\`,
  138. background: data.isHighlighted ? '#eef2ff' :
  139. data.isSelected === false ? '#f9fafb' : 'white',
  140. minWidth: '200px',
  141. maxWidth: '280px',
  142. boxShadow: data.isHighlighted ? '0 0 0 4px rgba(102, 126, 234, 0.25), 0 4px 16px rgba(102, 126, 234, 0.4)' :
  143. data.isCollapsed ? '0 4px 12px rgba(102, 126, 234, 0.15)' :
  144. data.level === 0 ? '0 4px 12px rgba(139, 92, 246, 0.15)' : '0 2px 6px rgba(0, 0, 0, 0.06)',
  145. transition: 'all 0.3s ease',
  146. cursor: 'pointer',
  147. position: 'relative',
  148. opacity: data.isSelected === false ? 0.6 : 1,
  149. }}
  150. >
  151. {/* 折叠当前节点按钮 - 左边 */}
  152. <div
  153. style={{
  154. position: 'absolute',
  155. top: '6px',
  156. left: '6px',
  157. width: '20px',
  158. height: '20px',
  159. borderRadius: '50%',
  160. background: '#f59e0b',
  161. color: 'white',
  162. display: 'flex',
  163. alignItems: 'center',
  164. justifyContent: 'center',
  165. fontSize: '11px',
  166. fontWeight: 'bold',
  167. cursor: 'pointer',
  168. transition: 'all 0.2s ease',
  169. zIndex: 10,
  170. }}
  171. onClick={(e) => {
  172. e.stopPropagation();
  173. if (data.onHideSelf) {
  174. data.onHideSelf();
  175. }
  176. }}
  177. onMouseEnter={(e) => {
  178. e.currentTarget.style.background = '#d97706';
  179. }}
  180. onMouseLeave={(e) => {
  181. e.currentTarget.style.background = '#f59e0b';
  182. }}
  183. title="隐藏当前节点"
  184. >
  185. ×
  186. </div>
  187. {/* 聚焦按钮 - 右上角 */}
  188. <div
  189. style={{
  190. position: 'absolute',
  191. top: '6px',
  192. right: '6px',
  193. width: '20px',
  194. height: '20px',
  195. borderRadius: '50%',
  196. background: data.isFocused ? '#10b981' : '#e5e7eb',
  197. color: data.isFocused ? 'white' : '#6b7280',
  198. display: 'flex',
  199. alignItems: 'center',
  200. justifyContent: 'center',
  201. fontSize: '11px',
  202. fontWeight: 'bold',
  203. cursor: 'pointer',
  204. transition: 'all 0.2s ease',
  205. zIndex: 10,
  206. }}
  207. onClick={(e) => {
  208. e.stopPropagation();
  209. if (data.onFocus) {
  210. data.onFocus();
  211. }
  212. }}
  213. onMouseEnter={(e) => {
  214. if (!data.isFocused) {
  215. e.currentTarget.style.background = '#d1d5db';
  216. }
  217. }}
  218. onMouseLeave={(e) => {
  219. if (!data.isFocused) {
  220. e.currentTarget.style.background = '#e5e7eb';
  221. }
  222. }}
  223. title={data.isFocused ? '取消聚焦' : '聚焦到此节点'}
  224. >
  225. 🎯
  226. </div>
  227. {/* 折叠/展开子节点按钮 - 右边第二个位置 */}
  228. {data.hasChildren && (
  229. <div
  230. style={{
  231. position: 'absolute',
  232. top: '6px',
  233. right: '30px',
  234. width: '20px',
  235. height: '20px',
  236. borderRadius: '50%',
  237. background: data.isCollapsed ? '#667eea' : '#e5e7eb',
  238. color: data.isCollapsed ? 'white' : '#6b7280',
  239. display: 'flex',
  240. alignItems: 'center',
  241. justifyContent: 'center',
  242. fontSize: '11px',
  243. fontWeight: 'bold',
  244. cursor: 'pointer',
  245. transition: 'all 0.2s ease',
  246. zIndex: 10,
  247. }}
  248. onClick={(e) => {
  249. e.stopPropagation();
  250. data.onToggleCollapse();
  251. }}
  252. title={data.isCollapsed ? '展开子节点' : '折叠子节点'}
  253. >
  254. {data.isCollapsed ? '+' : '−'}
  255. </div>
  256. )}
  257. {/* 卡片内容 */}
  258. <div>
  259. {/* 标题行 */}
  260. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px', paddingLeft: '24px', paddingRight: data.hasChildren ? '54px' : '28px' }}>
  261. <div style={{ flex: 1 }}>
  262. <div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '3px' }}>
  263. <div style={{
  264. fontSize: '13px',
  265. fontWeight: data.level === 0 ? '700' : '600',
  266. color: data.level === 0 ? '#6b21a8' : '#1f2937',
  267. lineHeight: '1.3',
  268. flex: 1,
  269. }}>
  270. {data.title}
  271. </div>
  272. {data.isSelected === false && (
  273. <div style={{
  274. fontSize: '9px',
  275. padding: '1px 4px',
  276. borderRadius: '3px',
  277. background: '#fee2e2',
  278. color: '#991b1b',
  279. fontWeight: '500',
  280. flexShrink: 0,
  281. }}>
  282. 未选中
  283. </div>
  284. )}
  285. </div>
  286. </div>
  287. </div>
  288. {/* 展开的详细信息 - 始终显示 */}
  289. <div style={{ fontSize: '11px', lineHeight: 1.4 }}>
  290. <div style={{ display: 'flex', gap: '4px', marginBottom: '6px', flexWrap: 'wrap' }}>
  291. <span style={{
  292. display: 'inline-block',
  293. padding: '1px 6px',
  294. borderRadius: '10px',
  295. background: '#eff6ff',
  296. color: '#3b82f6',
  297. fontSize: '10px',
  298. fontWeight: '500',
  299. }}>
  300. Lv.{data.level}
  301. </span>
  302. <span style={{
  303. display: 'inline-block',
  304. padding: '1px 6px',
  305. borderRadius: '10px',
  306. background: '#f0fdf4',
  307. color: '#16a34a',
  308. fontSize: '10px',
  309. fontWeight: '500',
  310. }}>
  311. {data.score}
  312. </span>
  313. {data.strategy && data.strategy !== 'root' && (
  314. <span style={{
  315. display: 'inline-block',
  316. padding: '1px 6px',
  317. borderRadius: '10px',
  318. background: '#fef3c7',
  319. color: '#92400e',
  320. fontSize: '10px',
  321. fontWeight: '500',
  322. }}>
  323. {data.strategy}
  324. </span>
  325. )}
  326. {(data.typeLabel || data.type_label) && (
  327. <span style={{
  328. display: 'inline-block',
  329. padding: '1px 6px',
  330. borderRadius: '10px',
  331. background: '#fce7f3',
  332. color: '#9f1239',
  333. fontSize: '10px',
  334. fontWeight: '500',
  335. }}>
  336. {data.typeLabel || data.type_label}
  337. </span>
  338. )}
  339. {data.is_suggestion && data.suggestion_label && (
  340. <span style={{
  341. display: 'inline-block',
  342. padding: '1px 6px',
  343. borderRadius: '10px',
  344. background: '#ede9fe',
  345. color: '#6d28d9',
  346. fontSize: '10px',
  347. fontWeight: '600',
  348. }}>
  349. {data.suggestion_label}
  350. </span>
  351. )}
  352. </div>
  353. {data.parent && (
  354. <div style={{ color: '#6b7280', fontSize: '10px', marginTop: '4px', paddingTop: '4px', borderTop: '1px solid #f3f4f6' }}>
  355. <strong>Parent:</strong> {data.parent}
  356. </div>
  357. )}
  358. {data.nodeType === 'domain_combination' && Array.isArray(data.source_word_details) && data.source_word_details.length > 0 && (
  359. <div style={{
  360. marginTop: '6px',
  361. paddingTop: '6px',
  362. borderTop: '1px solid #f3f4f6',
  363. fontSize: '10px',
  364. color: '#6b7280',
  365. lineHeight: '1.5',
  366. }}>
  367. <strong style={{ color: '#4b5563' }}>来源词得分:</strong>
  368. <div style={{ marginTop: '4px', display: 'flex', flexDirection: 'column', gap: '4px' }}>
  369. {data.source_word_details.map((detail, idx) => {
  370. const words = (detail.words || []).map((w) => {
  371. const numericScore = typeof w.score === 'number' ? w.score : parseFloat(w.score || '0');
  372. const formattedScore = Number.isFinite(numericScore) ? numericScore.toFixed(2) : '0.00';
  373. return w.text + ' (' + formattedScore + ')';
  374. }).join(' + ');
  375. return (
  376. <div key={idx} style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', alignItems: 'center' }}>
  377. <span style={{ color: '#2563eb' }}>{words}</span>
  378. </div>
  379. );
  380. })}
  381. </div>
  382. <div style={{ marginTop: '4px', fontWeight: '500', color: data.is_above_sources ? '#16a34a' : '#dc2626' }}>
  383. {data.is_above_sources ? '✅ 组合得分高于所有来源词' : '⚠️ 组合得分未超过全部来源词'}
  384. </div>
  385. </div>
  386. )}
  387. {data.selectedWord && (
  388. <div style={{
  389. marginTop: '6px',
  390. paddingTop: '6px',
  391. borderTop: '1px solid #f3f4f6',
  392. fontSize: '10px',
  393. color: '#6b7280',
  394. lineHeight: '1.5',
  395. }}>
  396. <strong style={{ color: '#4b5563' }}>选择词:</strong>
  397. <span style={{ marginLeft: '4px', color: '#3b82f6', fontWeight: '500' }}>{data.selectedWord}</span>
  398. {data.seed_score !== undefined && (
  399. <div style={{ marginTop: '4px' }}>
  400. <strong style={{ color: '#4b5563' }}>种子得分:</strong>
  401. <span style={{ marginLeft: '4px', color: '#16a34a', fontWeight: '500' }}>
  402. {typeof data.seed_score === 'number' ? data.seed_score.toFixed(2) : data.seed_score}
  403. </span>
  404. </div>
  405. )}
  406. </div>
  407. )}
  408. {data.evaluationReason && (
  409. <div style={{
  410. marginTop: '6px',
  411. paddingTop: '6px',
  412. borderTop: '1px solid #f3f4f6',
  413. fontSize: '10px',
  414. color: '#6b7280',
  415. lineHeight: '1.5',
  416. }}>
  417. <strong style={{ color: '#4b5563' }}>评估:</strong>
  418. <div style={{ marginTop: '2px' }}>{data.evaluationReason}</div>
  419. </div>
  420. )}
  421. {data.occurrences && data.occurrences.length > 1 && (
  422. <div style={{
  423. marginTop: '6px',
  424. paddingTop: '6px',
  425. borderTop: '1px solid #f3f4f6',
  426. fontSize: '10px',
  427. color: '#6b7280',
  428. }}>
  429. <strong style={{ color: '#4b5563' }}>演化历史 ({data.occurrences.length}次):</strong>
  430. <div style={{ marginTop: '4px' }}>
  431. {data.occurrences.map((occ, idx) => (
  432. <div key={idx} style={{ marginTop: '2px', paddingLeft: '8px' }}>
  433. <span style={{ color: '#3b82f6', fontWeight: '500' }}>R{occ.round}</span>
  434. {' · '}
  435. <span>{occ.strategy}</span>
  436. {occ.score !== undefined && (
  437. <span style={{ color: '#16a34a', marginLeft: '4px' }}>
  438. ({typeof occ.score === 'number' ? occ.score.toFixed(2) : occ.score})
  439. </span>
  440. )}
  441. </div>
  442. ))}
  443. </div>
  444. </div>
  445. )}
  446. {data.hasSearchResults && (
  447. <div style={{
  448. marginTop: '6px',
  449. paddingTop: '6px',
  450. borderTop: '1px solid #f3f4f6',
  451. fontSize: '10px',
  452. background: '#fef3c7',
  453. padding: '4px 6px',
  454. borderRadius: '4px',
  455. color: '#92400e',
  456. fontWeight: '500',
  457. }}>
  458. 🔍 找到 {data.postCount} 个帖子
  459. </div>
  460. )}
  461. </div>
  462. </div>
  463. </div>
  464. <Handle
  465. type="source"
  466. position={sourcePosition || Position.Right}
  467. style={{ background: '#667eea', width: 8, height: 8 }}
  468. />
  469. </div>
  470. );
  471. }
  472. // 笔记节点组件 - 卡片样式,带轮播图
  473. function NoteNode({ id, data, sourcePosition, targetPosition }) {
  474. const [currentImageIndex, setCurrentImageIndex] = useState(0);
  475. const expanded = true;
  476. const hasImages = data.imageList && data.imageList.length > 0;
  477. const nextImage = (e) => {
  478. e.stopPropagation();
  479. if (hasImages) {
  480. setCurrentImageIndex((prev) => (prev + 1) % data.imageList.length);
  481. }
  482. };
  483. const prevImage = (e) => {
  484. e.stopPropagation();
  485. if (hasImages) {
  486. setCurrentImageIndex((prev) => (prev - 1 + data.imageList.length) % data.imageList.length);
  487. }
  488. };
  489. return (
  490. <div>
  491. <Handle
  492. type="target"
  493. position={targetPosition || Position.Left}
  494. style={{ background: '#ec4899', width: 8, height: 8 }}
  495. />
  496. <div
  497. style={{
  498. padding: '14px',
  499. borderRadius: '20px',
  500. border: data.isHighlighted ? '3px solid #ec4899' : '2px solid #fce7f3',
  501. background: data.isHighlighted ? '#eef2ff' : 'white',
  502. minWidth: '220px',
  503. maxWidth: '300px',
  504. 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)',
  505. transition: 'all 0.3s ease',
  506. cursor: 'pointer',
  507. }}
  508. >
  509. {/* 笔记标题 */}
  510. <div style={{ display: 'flex', alignItems: 'flex-start', marginBottom: '8px' }}>
  511. <div style={{ flex: 1 }}>
  512. <div style={{
  513. fontSize: '13px',
  514. fontWeight: '600',
  515. color: '#831843',
  516. lineHeight: '1.4',
  517. marginBottom: '4px',
  518. }}>
  519. {data.title}
  520. </div>
  521. </div>
  522. </div>
  523. {/* 轮播图 */}
  524. {hasImages && (
  525. <div style={{
  526. position: 'relative',
  527. marginBottom: '8px',
  528. borderRadius: '12px',
  529. overflow: 'hidden',
  530. }}>
  531. <img
  532. src={data.imageList[currentImageIndex].image_url}
  533. alt={\`Image \${currentImageIndex + 1}\`}
  534. style={{
  535. width: '100%',
  536. height: '160px',
  537. objectFit: 'cover',
  538. display: 'block',
  539. }}
  540. onError={(e) => {
  541. e.target.style.display = 'none';
  542. }}
  543. />
  544. {data.imageList.length > 1 && (
  545. <>
  546. {/* 左右切换按钮 */}
  547. <button
  548. onClick={prevImage}
  549. style={{
  550. position: 'absolute',
  551. left: '4px',
  552. top: '50%',
  553. transform: 'translateY(-50%)',
  554. background: 'rgba(0, 0, 0, 0.5)',
  555. color: 'white',
  556. border: 'none',
  557. borderRadius: '50%',
  558. width: '24px',
  559. height: '24px',
  560. cursor: 'pointer',
  561. display: 'flex',
  562. alignItems: 'center',
  563. justifyContent: 'center',
  564. fontSize: '14px',
  565. }}
  566. >
  567. </button>
  568. <button
  569. onClick={nextImage}
  570. style={{
  571. position: 'absolute',
  572. right: '4px',
  573. top: '50%',
  574. transform: 'translateY(-50%)',
  575. background: 'rgba(0, 0, 0, 0.5)',
  576. color: 'white',
  577. border: 'none',
  578. borderRadius: '50%',
  579. width: '24px',
  580. height: '24px',
  581. cursor: 'pointer',
  582. display: 'flex',
  583. alignItems: 'center',
  584. justifyContent: 'center',
  585. fontSize: '14px',
  586. }}
  587. >
  588. </button>
  589. {/* 图片计数 */}
  590. <div style={{
  591. position: 'absolute',
  592. bottom: '4px',
  593. right: '4px',
  594. background: 'rgba(0, 0, 0, 0.6)',
  595. color: 'white',
  596. padding: '2px 6px',
  597. borderRadius: '10px',
  598. fontSize: '10px',
  599. }}>
  600. {currentImageIndex + 1}/{data.imageList.length}
  601. </div>
  602. </>
  603. )}
  604. </div>
  605. )}
  606. {/* 互动数据 */}
  607. {data.interact_info && (
  608. <div style={{
  609. display: 'flex',
  610. gap: '8px',
  611. marginBottom: '8px',
  612. flexWrap: 'wrap',
  613. fontSize: '11px',
  614. color: '#9f1239',
  615. }}>
  616. {data.interact_info.liked_count > 0 && (
  617. <span style={{ display: 'flex', alignItems: 'center', gap: '2px' }}>
  618. ❤️ {data.interact_info.liked_count}
  619. </span>
  620. )}
  621. {data.interact_info.collected_count > 0 && (
  622. <span style={{ display: 'flex', alignItems: 'center', gap: '2px' }}>
  623. ⭐ {data.interact_info.collected_count}
  624. </span>
  625. )}
  626. {data.interact_info.comment_count > 0 && (
  627. <span style={{ display: 'flex', alignItems: 'center', gap: '2px' }}>
  628. 💬 {data.interact_info.comment_count}
  629. </span>
  630. )}
  631. {data.interact_info.shared_count > 0 && (
  632. <span style={{ display: 'flex', alignItems: 'center', gap: '2px' }}>
  633. 🔗 {data.interact_info.shared_count}
  634. </span>
  635. )}
  636. </div>
  637. )}
  638. {/* 被哪些query找到 */}
  639. {data.foundByQueries && data.foundByQueries.length > 0 && (
  640. <div style={{
  641. marginBottom: '8px',
  642. padding: '6px 8px',
  643. background: '#f0fdf4',
  644. borderRadius: '6px',
  645. fontSize: '10px',
  646. }}>
  647. <strong style={{ color: '#16a34a' }}>🔍 被找到:</strong>
  648. <div style={{ marginTop: '4px', display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
  649. {data.foundByQueries.map((query, idx) => (
  650. <span key={idx} style={{
  651. display: 'inline-block',
  652. padding: '2px 6px',
  653. background: '#dcfce7',
  654. color: '#166534',
  655. borderRadius: '4px',
  656. fontSize: '9px',
  657. }}>
  658. {query}
  659. </span>
  660. ))}
  661. </div>
  662. {data.foundInRounds && data.foundInRounds.length > 0 && (
  663. <div style={{ marginTop: '4px', color: '#6b7280' }}>
  664. 出现在: Round {data.foundInRounds.join(', ')}
  665. </div>
  666. )}
  667. </div>
  668. )}
  669. {/* 标签 */}
  670. {(data.matchLevel || data.score) && (
  671. <div style={{ display: 'flex', gap: '6px', marginBottom: '8px', flexWrap: 'wrap' }}>
  672. {data.matchLevel && (
  673. <span style={{
  674. display: 'inline-block',
  675. padding: '2px 8px',
  676. borderRadius: '12px',
  677. background: '#fff1f2',
  678. color: '#be123c',
  679. fontSize: '10px',
  680. fontWeight: '500',
  681. }}>
  682. {data.matchLevel}
  683. </span>
  684. )}
  685. {data.score && (
  686. <span style={{
  687. display: 'inline-block',
  688. padding: '2px 8px',
  689. borderRadius: '12px',
  690. background: '#fff7ed',
  691. color: '#c2410c',
  692. fontSize: '10px',
  693. fontWeight: '500',
  694. }}>
  695. Score: {data.score}
  696. </span>
  697. )}
  698. </div>
  699. )}
  700. {/* 描述 */}
  701. {expanded && data.description && (
  702. <div style={{
  703. fontSize: '11px',
  704. color: '#9f1239',
  705. lineHeight: '1.5',
  706. paddingTop: '8px',
  707. borderTop: '1px solid #fbcfe8',
  708. }}>
  709. {data.description}
  710. </div>
  711. )}
  712. {/* 评估理由 */}
  713. {expanded && data.evaluationReason && (
  714. <div style={{
  715. fontSize: '10px',
  716. color: '#831843',
  717. lineHeight: '1.5',
  718. paddingTop: '8px',
  719. marginTop: '8px',
  720. borderTop: '1px solid #fbcfe8',
  721. }}>
  722. <strong style={{ color: '#9f1239' }}>评估:</strong>
  723. <div style={{ marginTop: '2px' }}>{data.evaluationReason}</div>
  724. </div>
  725. )}
  726. </div>
  727. <Handle
  728. type="source"
  729. position={sourcePosition || Position.Right}
  730. style={{ background: '#ec4899', width: 8, height: 8 }}
  731. />
  732. </div>
  733. );
  734. }
  735. // AnalysisNode 组件:展示AI分析(左侧OCR文字,右侧缩略图+描述)
  736. function AnalysisNode({ data }) {
  737. const nodeStyle = {
  738. background: '#fffbeb',
  739. border: '2px solid #fbbf24',
  740. borderRadius: '8px',
  741. padding: '12px',
  742. minWidth: '600px',
  743. maxWidth: '800px',
  744. fontSize: '12px',
  745. boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
  746. };
  747. return (
  748. <div style={nodeStyle}>
  749. <Handle
  750. type="target"
  751. position={Position.Left}
  752. style={{ background: '#fbbf24', width: 8, height: 8 }}
  753. />
  754. {/* 标题 */}
  755. <div style={{
  756. fontSize: '14px',
  757. fontWeight: 'bold',
  758. marginBottom: '8px',
  759. color: '#92400e',
  760. }}>
  761. 🖼️ {data.query}
  762. </div>
  763. {/* 评分和互动数据 */}
  764. <div style={{
  765. display: 'flex',
  766. justifyContent: 'space-between',
  767. marginBottom: '8px',
  768. padding: '6px',
  769. background: '#fef3c7',
  770. borderRadius: '4px',
  771. }}>
  772. <div style={{ fontSize: '11px', fontWeight: 'bold' }}>
  773. Score: {data.interact_info?.relevance_score || 0}
  774. </div>
  775. <div style={{ display: 'flex', gap: '12px', fontSize: '11px' }}>
  776. {data.interact_info?.liked_count > 0 && (
  777. <span>❤️ {data.interact_info.liked_count}</span>
  778. )}
  779. {data.interact_info?.collected_count > 0 && (
  780. <span>⭐ {data.interact_info.collected_count}</span>
  781. )}
  782. {data.interact_info?.comment_count > 0 && (
  783. <span>💬 {data.interact_info.comment_count}</span>
  784. )}
  785. </div>
  786. </div>
  787. {/* 完整正文内容 */}
  788. {data.body_text && (
  789. <div style={{
  790. padding: '8px',
  791. background: 'white',
  792. borderRadius: '4px',
  793. marginBottom: '12px',
  794. fontSize: '11px',
  795. lineHeight: '1.5',
  796. maxHeight: '120px',
  797. overflow: 'auto',
  798. border: '1px solid #fbbf24',
  799. }}>
  800. {data.body_text}
  801. </div>
  802. )}
  803. {/* AI分析 - 左右分栏 */}
  804. {data.extraction && data.extraction.images && (
  805. <div>
  806. {data.extraction.images.map((img, idx) => (
  807. <div
  808. key={idx}
  809. style={{
  810. display: 'grid',
  811. gridTemplateColumns: '2fr 1fr',
  812. gap: '16px',
  813. marginBottom: '12px',
  814. padding: '10px',
  815. background: 'white',
  816. borderRadius: '4px',
  817. border: '1px solid #d97706',
  818. }}
  819. >
  820. {/* 左侧:OCR提取文字 */}
  821. <div>
  822. <div style={{
  823. fontSize: '11px',
  824. fontWeight: 'bold',
  825. color: '#92400e',
  826. marginBottom: '6px',
  827. }}>
  828. 📝 图片 {idx + 1}/{data.extraction.images.length}
  829. </div>
  830. {img.extract_text && (
  831. <div style={{
  832. fontSize: '11px',
  833. color: '#1f2937',
  834. lineHeight: '1.6',
  835. padding: '8px',
  836. background: '#fef9e7',
  837. borderRadius: '3px',
  838. borderLeft: '3px solid #f39c12',
  839. }}>
  840. <div style={{
  841. fontSize: '10px',
  842. fontWeight: 'bold',
  843. color: '#d97706',
  844. marginBottom: '4px',
  845. }}>
  846. 【提取文字】
  847. </div>
  848. {img.extract_text}
  849. </div>
  850. )}
  851. </div>
  852. {/* 右侧:缩略图 + 描述 */}
  853. <div style={{
  854. display: 'flex',
  855. flexDirection: 'column',
  856. gap: '8px',
  857. }}>
  858. {/* 缩略图 */}
  859. {data.image_list && data.image_list[idx] && (
  860. <img
  861. src={(data.image_list[idx].image_url || data.image_list[idx])}
  862. alt={'图片' + (idx + 1)}
  863. style={{
  864. width: '100%',
  865. maxWidth: '150px',
  866. height: 'auto',
  867. maxHeight: '150px',
  868. objectFit: 'cover',
  869. borderRadius: '4px',
  870. border: '1px solid #d97706',
  871. cursor: 'pointer',
  872. }}
  873. onError={(e) => {
  874. e.target.style.display = 'none';
  875. }}
  876. />
  877. )}
  878. {/* 描述文字(带悬浮显示全部) */}
  879. {img.description && (
  880. <div
  881. style={{
  882. fontSize: '9px',
  883. color: '#78350f',
  884. lineHeight: '1.3',
  885. maxHeight: '60px',
  886. overflow: 'hidden',
  887. textOverflow: 'ellipsis',
  888. position: 'relative',
  889. cursor: 'help',
  890. }}
  891. title={img.description}
  892. >
  893. {img.description.length > 80
  894. ? img.description.substring(0, 80) + '...'
  895. : img.description
  896. }
  897. </div>
  898. )}
  899. </div>
  900. </div>
  901. ))}
  902. </div>
  903. )}
  904. {/* 查看原帖链接 */}
  905. {data.note_url && (
  906. <div style={{ marginTop: '8px', fontSize: '10px' }}>
  907. <a
  908. href={data.note_url}
  909. target="_blank"
  910. rel="noopener noreferrer"
  911. style={{ color: '#92400e', textDecoration: 'underline' }}
  912. >
  913. 🔗 查看原帖
  914. </a>
  915. </div>
  916. )}
  917. <Handle
  918. type="source"
  919. position={Position.Right}
  920. style={{ background: '#fbbf24', width: 8, height: 8 }}
  921. />
  922. </div>
  923. );
  924. }
  925. const nodeTypes = {
  926. query: QueryNode,
  927. note: NoteNode,
  928. analysis: AnalysisNode,
  929. };
  930. // 根据 score 获取颜色
  931. function getScoreColor(score) {
  932. if (score >= 0.7) return '#10b981'; // 绿色 - 高分
  933. if (score >= 0.4) return '#f59e0b'; // 橙色 - 中分
  934. return '#ef4444'; // 红色 - 低分
  935. }
  936. // 截断文本,保留头尾,中间显示省略号
  937. function truncateMiddle(text, maxLength = 20) {
  938. if (!text || text.length <= maxLength) return text;
  939. const headLength = Math.ceil(maxLength * 0.4);
  940. const tailLength = Math.floor(maxLength * 0.4);
  941. const head = text.substring(0, headLength);
  942. const tail = text.substring(text.length - tailLength);
  943. return \`\${head}...\${tail}\`;
  944. }
  945. // 根据策略获取颜色
  946. // 智能提取主要策略的辅助函数
  947. function getPrimaryStrategy(nodeData) {
  948. // 优先级1: 使用 primaryStrategy 字段
  949. if (nodeData.primaryStrategy) {
  950. return nodeData.primaryStrategy;
  951. }
  952. // 优先级2: 从 occurrences 数组中获取最新的策略
  953. if (nodeData.occurrences && Array.isArray(nodeData.occurrences) && nodeData.occurrences.length > 0) {
  954. const latestOccurrence = nodeData.occurrences[nodeData.occurrences.length - 1];
  955. if (latestOccurrence && latestOccurrence.strategy) {
  956. return latestOccurrence.strategy;
  957. }
  958. }
  959. // 优先级3: 拆分组合策略字符串,取第一个
  960. if (nodeData.strategy && typeof nodeData.strategy === 'string') {
  961. const strategies = nodeData.strategy.split(' + ');
  962. if (strategies.length > 0 && strategies[0]) {
  963. return strategies[0].trim();
  964. }
  965. }
  966. // 默认返回原始strategy或未知
  967. return nodeData.strategy || '未知';
  968. }
  969. function getStrategyColor(strategy) {
  970. const strategyColors = {
  971. '初始分词': '#10b981',
  972. '调用sug': '#06b6d4',
  973. '同义改写': '#f59e0b',
  974. '加词': '#3b82f6',
  975. '抽象改写': '#8b5cf6',
  976. '基于部分匹配改进': '#ec4899',
  977. '结果分支-抽象改写': '#a855f7',
  978. '结果分支-同义改写': '#fb923c',
  979. // v6.1.2.8 新增策略
  980. '原始问题': '#6b21a8',
  981. '来自分词': '#10b981',
  982. '加词生成': '#ef4444',
  983. '建议词': '#06b6d4',
  984. '执行搜索': '#8b5cf6',
  985. // 添加简化版本的策略映射
  986. '分词': '#10b981',
  987. '推荐词': '#06b6d4',
  988. };
  989. return strategyColors[strategy] || '#9ca3af';
  990. }
  991. // 树节点组件
  992. function TreeNode({ node, level, children, isCollapsed, onToggle, isSelected, onSelect }) {
  993. const hasChildren = children && children.length > 0;
  994. const score = node.data.score ? parseFloat(node.data.score) : 0;
  995. const strategy = getPrimaryStrategy(node.data); // 使用智能提取函数
  996. const strategyColor = getStrategyColor(strategy);
  997. const nodeActualType = node.data.nodeType || node.type; // 获取实际节点类型
  998. const isDomainCombination = nodeActualType === 'domain_combination';
  999. let sourceSummary = '';
  1000. if (isDomainCombination && Array.isArray(node.data.source_word_details) && node.data.source_word_details.length > 0) {
  1001. const summaryParts = [];
  1002. node.data.source_word_details.forEach((detail) => {
  1003. const words = Array.isArray(detail.words) ? detail.words : [];
  1004. const wordTexts = [];
  1005. words.forEach((w) => {
  1006. const numericScore = typeof w.score === 'number' ? w.score : parseFloat(w.score || '0');
  1007. const formattedScore = Number.isFinite(numericScore) ? numericScore.toFixed(2) : '0.00';
  1008. wordTexts.push(w.text + ' (' + formattedScore + ')');
  1009. });
  1010. if (wordTexts.length > 0) {
  1011. const segmentLabel = detail.segment_type ? '[' + detail.segment_type + '] ' : '';
  1012. summaryParts.push(segmentLabel + wordTexts.join(' + '));
  1013. }
  1014. });
  1015. sourceSummary = summaryParts.join(' | ');
  1016. }
  1017. // 计算字体颜色:根据分数提升幅度判断
  1018. let fontColor = '#374151'; // 默认颜色
  1019. if (node.type === 'note') {
  1020. fontColor = node.data.matchLevel === 'unsatisfied' ? '#ef4444' : '#374151';
  1021. } else if (node.data.seed_score !== undefined) {
  1022. const parentScore = parseFloat(node.data.seed_score);
  1023. const gain = score - parentScore;
  1024. fontColor = gain >= 0.05 ? '#16a34a' : '#ef4444';
  1025. } else if (node.data.isSelected === false) {
  1026. fontColor = '#ef4444';
  1027. }
  1028. return (
  1029. <div style={{ marginLeft: level * 12 + 'px', marginBottom: '8px' }}>
  1030. <div
  1031. style={{
  1032. padding: '6px 8px',
  1033. borderRadius: '4px',
  1034. cursor: 'pointer',
  1035. background: 'transparent',
  1036. border: isSelected ? '1px solid #3b82f6' : '1px solid transparent',
  1037. display: 'flex',
  1038. alignItems: 'center',
  1039. gap: '6px',
  1040. transition: 'all 0.2s ease',
  1041. position: 'relative',
  1042. overflow: 'visible',
  1043. }}
  1044. onMouseEnter={(e) => {
  1045. if (!isSelected) e.currentTarget.style.background = '#f9fafb';
  1046. }}
  1047. onMouseLeave={(e) => {
  1048. if (!isSelected) e.currentTarget.style.background = 'transparent';
  1049. }}
  1050. >
  1051. {/* 策略类型竖线 */}
  1052. <div style={{
  1053. width: '3px',
  1054. height: '20px',
  1055. background: strategyColor,
  1056. borderRadius: '2px',
  1057. flexShrink: 0,
  1058. position: 'relative',
  1059. zIndex: 1,
  1060. }} />
  1061. {hasChildren && (
  1062. <span
  1063. style={{
  1064. fontSize: '10px',
  1065. color: '#6b7280',
  1066. cursor: 'pointer',
  1067. width: '16px',
  1068. textAlign: 'center',
  1069. position: 'relative',
  1070. zIndex: 1,
  1071. }}
  1072. onClick={(e) => {
  1073. e.stopPropagation();
  1074. onToggle();
  1075. }}
  1076. >
  1077. {isCollapsed ? '▶' : '▼'}
  1078. </span>
  1079. )}
  1080. {!hasChildren && <span style={{ width: '16px', position: 'relative', zIndex: 1 }}></span>}
  1081. <div
  1082. style={{
  1083. flex: 1,
  1084. fontSize: '12px',
  1085. color: '#374151',
  1086. position: 'relative',
  1087. zIndex: 1,
  1088. minWidth: 0,
  1089. display: 'flex',
  1090. flexDirection: 'column',
  1091. gap: '4px',
  1092. }}
  1093. onClick={onSelect}
  1094. >
  1095. <div style={{
  1096. display: 'flex',
  1097. alignItems: 'center',
  1098. gap: '8px',
  1099. }}>
  1100. {/* 文本标题 - 左侧 */}
  1101. <div style={{
  1102. fontWeight: level === 0 ? '600' : '400',
  1103. flex: 1,
  1104. minWidth: 0,
  1105. color: node.data.scoreColor || fontColor,
  1106. overflow: 'hidden',
  1107. textOverflow: 'ellipsis',
  1108. whiteSpace: 'nowrap',
  1109. }}
  1110. title={node.data.title || node.id}
  1111. >
  1112. {node.data.title || node.id}
  1113. </div>
  1114. {/* 域标识 - 右侧,挨着分数,优先显示域类型,否则显示域索引或域字符串,但domain_combination节点不显示 */}
  1115. {(node.data.domain_type || node.data.domains_str || (node.data.domain_index !== null && node.data.domain_index !== undefined)) && nodeActualType !== 'domain_combination' && (
  1116. <span style={{
  1117. fontSize: '12px',
  1118. color: '#fff',
  1119. background: '#6366f1',
  1120. padding: '2px 5px',
  1121. borderRadius: '3px',
  1122. flexShrink: 0,
  1123. fontWeight: '600',
  1124. marginLeft: '4px',
  1125. }}
  1126. title={
  1127. node.data.domain_type ? '域: ' + node.data.domain_type + ' (D' + node.data.domain_index + ')' :
  1128. node.data.domains_str ? '域: ' + node.data.domains_str :
  1129. '域 D' + node.data.domain_index
  1130. }
  1131. >
  1132. {node.data.domain_type || node.data.domains_str || ('D' + node.data.domain_index)}
  1133. </span>
  1134. )}
  1135. {node.data.is_suggestion && node.data.suggestion_label && (
  1136. <span style={{
  1137. fontSize: '12px',
  1138. color: '#fff',
  1139. background: '#8b5cf6',
  1140. padding: '2px 5px',
  1141. borderRadius: '3px',
  1142. flexShrink: 0,
  1143. fontWeight: '600',
  1144. }}
  1145. >
  1146. {node.data.suggestion_label}
  1147. </span>
  1148. )}
  1149. {/* 类型标签 - 显示在右侧靠近分数,蓝色背景 */}
  1150. {node.data.type_label && (
  1151. <span style={{
  1152. fontSize: '12px',
  1153. color: '#fff',
  1154. background: '#6366f1',
  1155. padding: '2px 5px',
  1156. borderRadius: '3px',
  1157. flexShrink: 0,
  1158. fontWeight: '600',
  1159. }}
  1160. title={'类型: ' + node.data.type_label}
  1161. >
  1162. {node.data.type_label}
  1163. </span>
  1164. )}
  1165. {/* 分数显示 - 步骤和轮次节点不显示分数 */}
  1166. {nodeActualType !== 'step' && nodeActualType !== 'round' && (
  1167. <span style={{
  1168. fontSize: '11px',
  1169. color: '#6b7280',
  1170. fontWeight: '500',
  1171. flexShrink: 0,
  1172. minWidth: '35px',
  1173. textAlign: 'right',
  1174. }}>
  1175. {score.toFixed(2)}
  1176. </span>
  1177. )}
  1178. </div>
  1179. {/* 域组合的来源词得分(树状视图,右对齐) */}
  1180. {isDomainCombination && sourceSummary && (
  1181. <div style={{
  1182. fontSize: '10px',
  1183. color: '#2563eb',
  1184. lineHeight: '1.4',
  1185. display: 'flex',
  1186. flexDirection: 'column',
  1187. alignItems: 'flex-end',
  1188. gap: '2px',
  1189. textAlign: 'right',
  1190. }}>
  1191. {node.data.source_word_details.map((detail, idx) => {
  1192. const words = Array.isArray(detail.words) ? detail.words : [];
  1193. const summary = words.map((w) => {
  1194. const numericScore = typeof w.score === 'number' ? w.score : parseFloat(w.score || '0');
  1195. const formattedScore = Number.isFinite(numericScore) ? numericScore.toFixed(2) : '0.00';
  1196. return w.text + ' (' + formattedScore + ')';
  1197. }).join(' + ');
  1198. return (
  1199. <span key={idx} title={summary}>
  1200. {summary}
  1201. </span>
  1202. );
  1203. })}
  1204. </div>
  1205. )}
  1206. {/* 分数下划线 - 步骤和轮次节点不显示 */}
  1207. {nodeActualType !== 'step' && nodeActualType !== 'round' && (
  1208. <div style={{
  1209. width: (score * 100) + '%',
  1210. height: '2px',
  1211. background: getScoreColor(score),
  1212. borderRadius: '1px',
  1213. }} />
  1214. )}
  1215. </div>
  1216. </div>
  1217. {hasChildren && !isCollapsed && (
  1218. <div>
  1219. {children}
  1220. </div>
  1221. )}
  1222. </div>
  1223. );
  1224. }
  1225. // 使用 dagre 自动布局
  1226. function getLayoutedElements(nodes, edges, direction = 'LR') {
  1227. console.log('🎯 Starting layout with dagre...');
  1228. console.log('Input:', nodes.length, 'nodes,', edges.length, 'edges');
  1229. // 检查 dagre 是否加载
  1230. if (typeof window === 'undefined' || typeof window.dagre === 'undefined') {
  1231. console.warn('⚠️ Dagre not loaded, using fallback layout');
  1232. // 降级到简单布局
  1233. const levelGroups = {};
  1234. nodes.forEach(node => {
  1235. const level = node.data.level || 0;
  1236. if (!levelGroups[level]) levelGroups[level] = [];
  1237. levelGroups[level].push(node);
  1238. });
  1239. Object.entries(levelGroups).forEach(([level, nodeList]) => {
  1240. const x = parseInt(level) * 480;
  1241. nodeList.forEach((node, index) => {
  1242. node.position = { x, y: index * 260 };
  1243. node.targetPosition = 'left';
  1244. node.sourcePosition = 'right';
  1245. });
  1246. });
  1247. return { nodes, edges };
  1248. }
  1249. try {
  1250. const dagreGraph = new window.dagre.graphlib.Graph();
  1251. dagreGraph.setDefaultEdgeLabel(() => ({}));
  1252. const isHorizontal = direction === 'LR';
  1253. dagreGraph.setGraph({
  1254. rankdir: direction,
  1255. nodesep: 180, // 垂直间距 - 增加以避免节点重叠
  1256. ranksep: 360, // 水平间距 - 增加以容纳更宽的节点
  1257. });
  1258. // 添加节点 - 根据节点类型设置不同的尺寸
  1259. nodes.forEach((node) => {
  1260. let nodeWidth = 320;
  1261. let nodeHeight = 220;
  1262. // note 节点有轮播图,需要更大的空间
  1263. if (node.type === 'note') {
  1264. nodeWidth = 360;
  1265. nodeHeight = 380; // 增加高度以容纳轮播图
  1266. }
  1267. dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
  1268. });
  1269. // 添加边
  1270. edges.forEach((edge) => {
  1271. dagreGraph.setEdge(edge.source, edge.target);
  1272. });
  1273. // 计算布局
  1274. window.dagre.layout(dagreGraph);
  1275. console.log('✅ Dagre layout completed');
  1276. // 更新节点位置和 handle 位置
  1277. nodes.forEach((node) => {
  1278. const nodeWithPosition = dagreGraph.node(node.id);
  1279. if (!nodeWithPosition) {
  1280. console.warn('Node position not found for:', node.id);
  1281. return;
  1282. }
  1283. node.targetPosition = isHorizontal ? 'left' : 'top';
  1284. node.sourcePosition = isHorizontal ? 'right' : 'bottom';
  1285. // 根据节点类型获取尺寸
  1286. let nodeWidth = 320;
  1287. let nodeHeight = 220;
  1288. if (node.type === 'note') {
  1289. nodeWidth = 360;
  1290. nodeHeight = 380;
  1291. }
  1292. // 将 dagre 的中心点位置转换为 React Flow 的左上角位置
  1293. node.position = {
  1294. x: nodeWithPosition.x - nodeWidth / 2,
  1295. y: nodeWithPosition.y - nodeHeight / 2,
  1296. };
  1297. });
  1298. console.log('✅ Layout completed, sample node:', nodes[0]);
  1299. return { nodes, edges };
  1300. } catch (error) {
  1301. console.error('❌ Error in dagre layout:', error);
  1302. console.error('Error details:', error.message, error.stack);
  1303. // 降级处理
  1304. console.log('Using fallback layout...');
  1305. const levelGroups = {};
  1306. nodes.forEach(node => {
  1307. const level = node.data.level || 0;
  1308. if (!levelGroups[level]) levelGroups[level] = [];
  1309. levelGroups[level].push(node);
  1310. });
  1311. Object.entries(levelGroups).forEach(([level, nodeList]) => {
  1312. const x = parseInt(level) * 480;
  1313. nodeList.forEach((node, index) => {
  1314. node.position = { x, y: index * 260 };
  1315. node.targetPosition = 'left';
  1316. node.sourcePosition = 'right';
  1317. });
  1318. });
  1319. return { nodes, edges };
  1320. }
  1321. }
  1322. function transformData(data) {
  1323. const nodes = [];
  1324. const edges = [];
  1325. const originalIdToCanvasId = {}; // 原始ID -> 画布ID的映射
  1326. const canvasIdToNodeData = {}; // 避免重复创建相同的节点
  1327. // 创建节点
  1328. Object.entries(data.nodes).forEach(([originalId, node]) => {
  1329. // 统一处理所有类型的节点
  1330. const nodeType = node.type || 'query';
  1331. // 直接使用originalId作为canvasId,避免冲突
  1332. const canvasId = originalId;
  1333. originalIdToCanvasId[originalId] = canvasId;
  1334. // 如果这个 canvasId 还没有创建过节点,则创建
  1335. if (!canvasIdToNodeData[canvasId]) {
  1336. canvasIdToNodeData[canvasId] = true;
  1337. // 根据节点类型创建不同的数据结构
  1338. if (nodeType === 'note' || nodeType === 'post') {
  1339. nodes.push({
  1340. id: canvasId,
  1341. originalId: originalId,
  1342. type: 'note',
  1343. data: {
  1344. title: node.query || node.title || '帖子',
  1345. matchLevel: node.match_level,
  1346. score: node.relevance_score ? node.relevance_score.toFixed(2) : '0.00',
  1347. description: node.body_text || node.desc || '',
  1348. isSelected: node.is_selected !== undefined ? node.is_selected : true,
  1349. imageList: node.image_list || [],
  1350. noteUrl: node.note_url || '',
  1351. evaluationReason: node.evaluationReason || node.evaluation_reason || '',
  1352. interact_info: node.interact_info || {},
  1353. nodeType: nodeType,
  1354. },
  1355. position: { x: 0, y: 0 },
  1356. });
  1357. } else {
  1358. // query, seg, q, search, root 等节点
  1359. let displayTitle = node.query || originalId;
  1360. nodes.push({
  1361. id: canvasId,
  1362. originalId: originalId,
  1363. type: 'query', // 使用 query 组件渲染所有非note节点
  1364. data: {
  1365. title: displayTitle,
  1366. level: node.level || 0,
  1367. score: node.relevance_score ? node.relevance_score.toFixed(2) : '0.00',
  1368. strategy: node.strategy || '',
  1369. parent: node.parent_query || '',
  1370. isSelected: node.is_selected !== undefined ? node.is_selected : true,
  1371. evaluationReason: node.evaluationReason || node.evaluation_reason || '',
  1372. nodeType: nodeType, // 传递实际节点类型用于样式
  1373. searchCount: node.search_count, // search 节点特有
  1374. totalPosts: node.total_posts, // search 节点特有
  1375. selectedWord: node.selected_word || '', // 加词节点特有 - 显示选择的词
  1376. scoreColor: node.scoreColor || null, // SUG节点的颜色标识
  1377. parentQScore: node.parentQScore || 0, // 父Q得分(用于调试)
  1378. domain_index: node.domain_index !== undefined ? node.domain_index : null, // 域索引
  1379. domain_type: node.domain_type || '', // 域类型(如"中心名词"、"核心动作"),只有Q节点有,segment节点不显示
  1380. segment_type: node.segment_type || '', // segment类型(只有segment节点才有)
  1381. type_label: node.type_label || '', // 类型标签
  1382. domains: node.domains || [], // 域索引数组(domain_combination节点特有)
  1383. domains_str: node.domains_str || '', // 域标识字符串(如"D0,D1")
  1384. from_segments: node.from_segments || [], // 来源segments(domain_combination节点特有)
  1385. source_word_details: node.source_word_details || [], // 组合来源词及其得分
  1386. source_scores: node.source_scores || [], // 扁平来源得分
  1387. is_above_sources: node.is_above_sources || false, // 组合是否高于来源得分
  1388. max_source_score: node.max_source_score !== undefined ? node.max_source_score : null, // 来源最高分
  1389. item_type: node.item_type || '', // 构建下一轮节点来源类型
  1390. is_suggestion: node.is_suggestion || false,
  1391. suggestion_label: node.suggestion_label || '',
  1392. },
  1393. position: { x: 0, y: 0 },
  1394. });
  1395. }
  1396. }
  1397. });
  1398. // 创建边 - 使用虚线样式,映射到画布ID
  1399. data.edges.forEach((edge, index) => {
  1400. const edgeColors = {
  1401. '初始分词': '#10b981',
  1402. '调用sug': '#06b6d4',
  1403. '同义改写': '#f59e0b',
  1404. '加词': '#3b82f6',
  1405. '抽象改写': '#8b5cf6',
  1406. '基于部分匹配改进': '#ec4899',
  1407. '结果分支-抽象改写': '#a855f7',
  1408. '结果分支-同义改写': '#fb923c',
  1409. 'query_to_note': '#ec4899',
  1410. };
  1411. const color = edgeColors[edge.strategy] || edgeColors[edge.edge_type] || '#d1d5db';
  1412. const isNoteEdge = edge.edge_type === 'query_to_note';
  1413. edges.push({
  1414. id: \`edge-\${index}\`,
  1415. source: originalIdToCanvasId[edge.from], // 使用画布ID
  1416. target: originalIdToCanvasId[edge.to], // 使用画布ID
  1417. type: 'simplebezier', // 使用简单贝塞尔曲线
  1418. animated: isNoteEdge,
  1419. style: {
  1420. stroke: color,
  1421. strokeWidth: isNoteEdge ? 2.5 : 2,
  1422. strokeDasharray: isNoteEdge ? '5,5' : '8,4',
  1423. },
  1424. markerEnd: {
  1425. type: 'arrowclosed',
  1426. color: color,
  1427. width: 20,
  1428. height: 20,
  1429. },
  1430. });
  1431. });
  1432. // 使用 dagre 自动计算布局 - 从左到右
  1433. return getLayoutedElements(nodes, edges, 'LR');
  1434. }
  1435. function FlowContent() {
  1436. // 画布使用简化数据
  1437. const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
  1438. console.log('🔍 Transforming data for canvas...');
  1439. const result = transformData(data);
  1440. console.log('✅ Canvas data:', result.nodes.length, 'nodes,', result.edges.length, 'edges');
  1441. return result;
  1442. }, []);
  1443. // 目录使用完整数据(如果存在)
  1444. const { nodes: fullNodes, edges: fullEdges } = useMemo(() => {
  1445. if (data.fullData) {
  1446. console.log('🔍 Transforming full data for tree directory...');
  1447. const result = transformData(data.fullData);
  1448. console.log('✅ Directory data:', result.nodes.length, 'nodes,', result.edges.length, 'edges');
  1449. return result;
  1450. }
  1451. // 如果没有 fullData,使用简化数据
  1452. return { nodes: initialNodes, edges: initialEdges };
  1453. }, [initialNodes, initialEdges]);
  1454. // 初始化:找出所有有子节点的节点,默认折叠(画布节点)
  1455. const initialCollapsedNodes = useMemo(() => {
  1456. const nodesWithChildren = new Set();
  1457. initialEdges.forEach(edge => {
  1458. nodesWithChildren.add(edge.source);
  1459. });
  1460. // 排除根节点(level 0),让根节点默认展开
  1461. const rootNode = initialNodes.find(n => n.data.level === 0);
  1462. if (rootNode) {
  1463. nodesWithChildren.delete(rootNode.id);
  1464. }
  1465. return nodesWithChildren;
  1466. }, [initialNodes, initialEdges]);
  1467. // 树节点的折叠状态需要在树构建后初始化
  1468. const [collapsedNodes, setCollapsedNodes] = useState(() => initialCollapsedNodes);
  1469. const [collapsedTreeNodes, setCollapsedTreeNodes] = useState(new Set());
  1470. const [selectedNodeId, setSelectedNodeId] = useState(null);
  1471. const [hiddenNodes, setHiddenNodes] = useState(new Set()); // 用户手动隐藏的节点
  1472. const [focusMode, setFocusMode] = useState(false); // 全局聚焦模式,默认关闭
  1473. const [focusedNodeId, setFocusedNodeId] = useState(null); // 单独聚焦的节点ID
  1474. const [sidebarWidth, setSidebarWidth] = useState(400); // 左侧目录宽度
  1475. const [isResizing, setIsResizing] = useState(false); // 是否正在拖拽调整宽度
  1476. // 拖拽调整侧边栏宽度的处理逻辑
  1477. const handleMouseDown = useCallback(() => {
  1478. setIsResizing(true);
  1479. }, []);
  1480. useEffect(() => {
  1481. if (!isResizing) return;
  1482. const handleMouseMove = (e) => {
  1483. const newWidth = e.clientX;
  1484. // 限制宽度范围:300px - 700px
  1485. if (newWidth >= 300 && newWidth <= 700) {
  1486. setSidebarWidth(newWidth);
  1487. }
  1488. };
  1489. const handleMouseUp = () => {
  1490. setIsResizing(false);
  1491. };
  1492. document.addEventListener('mousemove', handleMouseMove);
  1493. document.addEventListener('mouseup', handleMouseUp);
  1494. return () => {
  1495. document.removeEventListener('mousemove', handleMouseMove);
  1496. document.removeEventListener('mouseup', handleMouseUp);
  1497. };
  1498. }, [isResizing]);
  1499. // 获取 React Flow 实例以控制画布
  1500. const { setCenter, fitView } = useReactFlow();
  1501. // 获取某个节点的所有后代节点ID
  1502. const getDescendants = useCallback((nodeId) => {
  1503. const descendants = new Set();
  1504. const queue = [nodeId];
  1505. while (queue.length > 0) {
  1506. const current = queue.shift();
  1507. initialEdges.forEach(edge => {
  1508. if (edge.source === current && !descendants.has(edge.target)) {
  1509. descendants.add(edge.target);
  1510. queue.push(edge.target);
  1511. }
  1512. });
  1513. }
  1514. return descendants;
  1515. }, [initialEdges]);
  1516. // 获取直接父节点
  1517. const getDirectParents = useCallback((nodeId) => {
  1518. const parents = [];
  1519. initialEdges.forEach(edge => {
  1520. if (edge.target === nodeId) {
  1521. parents.push(edge.source);
  1522. }
  1523. });
  1524. return parents;
  1525. }, [initialEdges]);
  1526. // 获取直接子节点
  1527. const getDirectChildren = useCallback((nodeId) => {
  1528. const children = [];
  1529. initialEdges.forEach(edge => {
  1530. if (edge.source === nodeId) {
  1531. children.push(edge.target);
  1532. }
  1533. });
  1534. return children;
  1535. }, [initialEdges]);
  1536. // 切换节点折叠状态
  1537. const toggleNodeCollapse = useCallback((nodeId) => {
  1538. setCollapsedNodes(prev => {
  1539. const newSet = new Set(prev);
  1540. const descendants = getDescendants(nodeId);
  1541. if (newSet.has(nodeId)) {
  1542. // 展开:移除此节点,但保持其他折叠的节点
  1543. newSet.delete(nodeId);
  1544. } else {
  1545. // 折叠:添加此节点
  1546. newSet.add(nodeId);
  1547. }
  1548. return newSet;
  1549. });
  1550. }, [getDescendants]);
  1551. // 过滤可见的节点和边,并重新计算布局
  1552. const { nodes, edges } = useMemo(() => {
  1553. const nodesToHide = new Set();
  1554. // 判断使用哪个节点ID进行聚焦:优先使用单独聚焦的节点,否则使用全局聚焦模式的选中节点
  1555. const effectiveFocusNodeId = focusedNodeId || (focusMode ? selectedNodeId : null);
  1556. // 聚焦模式:只显示聚焦节点、其父节点和直接子节点
  1557. if (effectiveFocusNodeId) {
  1558. const visibleInFocus = new Set([effectiveFocusNodeId]);
  1559. // 添加所有父节点
  1560. initialEdges.forEach(edge => {
  1561. if (edge.target === effectiveFocusNodeId) {
  1562. visibleInFocus.add(edge.source);
  1563. }
  1564. });
  1565. // 添加所有直接子节点
  1566. initialEdges.forEach(edge => {
  1567. if (edge.source === effectiveFocusNodeId) {
  1568. visibleInFocus.add(edge.target);
  1569. }
  1570. });
  1571. // 隐藏不在聚焦范围内的节点
  1572. initialNodes.forEach(node => {
  1573. if (!visibleInFocus.has(node.id)) {
  1574. nodesToHide.add(node.id);
  1575. }
  1576. });
  1577. } else {
  1578. // 非聚焦模式:使用原有的折叠逻辑
  1579. // 收集所有被折叠节点的后代
  1580. collapsedNodes.forEach(collapsedId => {
  1581. const descendants = getDescendants(collapsedId);
  1582. descendants.forEach(id => nodesToHide.add(id));
  1583. });
  1584. }
  1585. // 添加用户手动隐藏的节点
  1586. hiddenNodes.forEach(id => nodesToHide.add(id));
  1587. const visibleNodes = initialNodes
  1588. .filter(node => !nodesToHide.has(node.id))
  1589. .map(node => ({
  1590. ...node,
  1591. data: {
  1592. ...node.data,
  1593. isCollapsed: collapsedNodes.has(node.id),
  1594. hasChildren: initialEdges.some(e => e.source === node.id),
  1595. onToggleCollapse: () => toggleNodeCollapse(node.id),
  1596. onHideSelf: () => {
  1597. setHiddenNodes(prev => {
  1598. const newSet = new Set(prev);
  1599. newSet.add(node.id);
  1600. return newSet;
  1601. });
  1602. },
  1603. onFocus: () => {
  1604. // 切换聚焦状态
  1605. if (focusedNodeId === node.id) {
  1606. setFocusedNodeId(null); // 如果已经聚焦,则取消聚焦
  1607. } else {
  1608. // 先取消之前的聚焦,然后聚焦到当前节点
  1609. setFocusedNodeId(node.id);
  1610. // 延迟聚焦视图到该节点
  1611. setTimeout(() => {
  1612. fitView({
  1613. nodes: [{ id: node.id }],
  1614. duration: 800,
  1615. padding: 0.3,
  1616. });
  1617. }, 100);
  1618. }
  1619. },
  1620. isFocused: focusedNodeId === node.id,
  1621. isHighlighted: selectedNodeId === node.id,
  1622. }
  1623. }));
  1624. const visibleEdges = initialEdges.filter(
  1625. edge => !nodesToHide.has(edge.source) && !nodesToHide.has(edge.target)
  1626. );
  1627. // 重新计算布局 - 只对可见节点
  1628. if (typeof window !== 'undefined' && typeof window.dagre !== 'undefined') {
  1629. try {
  1630. const dagreGraph = new window.dagre.graphlib.Graph();
  1631. dagreGraph.setDefaultEdgeLabel(() => ({}));
  1632. dagreGraph.setGraph({
  1633. rankdir: 'LR',
  1634. nodesep: 180, // 垂直间距 - 增加以避免节点重叠
  1635. ranksep: 360, // 水平间距 - 增加以容纳更宽的节点
  1636. });
  1637. visibleNodes.forEach((node) => {
  1638. let nodeWidth = 320;
  1639. let nodeHeight = 220;
  1640. // note 节点有轮播图,需要更大的空间
  1641. if (node.type === 'note') {
  1642. nodeWidth = 360;
  1643. nodeHeight = 380;
  1644. }
  1645. dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
  1646. });
  1647. visibleEdges.forEach((edge) => {
  1648. dagreGraph.setEdge(edge.source, edge.target);
  1649. });
  1650. window.dagre.layout(dagreGraph);
  1651. visibleNodes.forEach((node) => {
  1652. const nodeWithPosition = dagreGraph.node(node.id);
  1653. if (nodeWithPosition) {
  1654. // 根据节点类型获取对应的尺寸
  1655. let nodeWidth = 320;
  1656. let nodeHeight = 220;
  1657. if (node.type === 'note') {
  1658. nodeWidth = 360;
  1659. nodeHeight = 380;
  1660. }
  1661. node.position = {
  1662. x: nodeWithPosition.x - nodeWidth / 2,
  1663. y: nodeWithPosition.y - nodeHeight / 2,
  1664. };
  1665. node.targetPosition = 'left';
  1666. node.sourcePosition = 'right';
  1667. }
  1668. });
  1669. console.log('✅ Dynamic layout recalculated for', visibleNodes.length, 'visible nodes');
  1670. } catch (error) {
  1671. console.error('❌ Error in dynamic layout:', error);
  1672. }
  1673. }
  1674. return { nodes: visibleNodes, edges: visibleEdges };
  1675. }, [initialNodes, initialEdges, collapsedNodes, hiddenNodes, focusMode, focusedNodeId, getDescendants, toggleNodeCollapse, selectedNodeId]);
  1676. // 构建树形结构 - 允许一个节点有多个父节点
  1677. // 为目录构建树(使用完整数据)
  1678. const buildTree = useCallback(() => {
  1679. // 使用完整数据构建目录树
  1680. const nodeMap = new Map();
  1681. fullNodes.forEach(node => {
  1682. nodeMap.set(node.id, node);
  1683. });
  1684. // 为每个节点创建树节点的副本(允许多次出现)
  1685. const createTreeNode = (nodeId, pathKey) => {
  1686. const node = nodeMap.get(nodeId);
  1687. if (!node) return null;
  1688. return {
  1689. ...node,
  1690. treeKey: pathKey, // 唯一的树路径key,用于React key
  1691. children: []
  1692. };
  1693. };
  1694. // 构建父子关系映射:记录每个节点的所有父节点,去重边
  1695. const parentToChildren = new Map();
  1696. const childToParents = new Map();
  1697. fullEdges.forEach(edge => {
  1698. // 记录父->子关系(去重:同一个父节点到同一个子节点只记录一次)
  1699. if (!parentToChildren.has(edge.source)) {
  1700. parentToChildren.set(edge.source, []);
  1701. }
  1702. const children = parentToChildren.get(edge.source);
  1703. if (!children.includes(edge.target)) {
  1704. children.push(edge.target);
  1705. }
  1706. // 记录子->父关系(用于判断是否有多个父节点,也去重)
  1707. if (!childToParents.has(edge.target)) {
  1708. childToParents.set(edge.target, []);
  1709. }
  1710. const parents = childToParents.get(edge.target);
  1711. if (!parents.includes(edge.source)) {
  1712. parents.push(edge.source);
  1713. }
  1714. });
  1715. // 递归构建树
  1716. const buildSubtree = (nodeId, pathKey, visitedInPath) => {
  1717. // 避免循环引用:如果当前路径中已经访问过这个节点,跳过
  1718. if (visitedInPath.has(nodeId)) {
  1719. return null;
  1720. }
  1721. const treeNode = createTreeNode(nodeId, pathKey);
  1722. if (!treeNode) return null;
  1723. const newVisitedInPath = new Set(visitedInPath);
  1724. newVisitedInPath.add(nodeId);
  1725. const children = parentToChildren.get(nodeId) || [];
  1726. treeNode.children = children
  1727. .map((childId, index) => buildSubtree(childId, pathKey + '-' + childId + '-' + index, newVisitedInPath))
  1728. .filter(child => child !== null);
  1729. return treeNode;
  1730. };
  1731. // 找出所有根节点(没有入边的节点)
  1732. const hasParent = new Set();
  1733. fullEdges.forEach(edge => {
  1734. hasParent.add(edge.target);
  1735. });
  1736. const roots = [];
  1737. fullNodes.forEach((node, index) => {
  1738. if (!hasParent.has(node.id)) {
  1739. const treeNode = buildSubtree(node.id, 'root-' + node.id + '-' + index, new Set());
  1740. if (treeNode) roots.push(treeNode);
  1741. }
  1742. });
  1743. return roots;
  1744. }, [fullNodes, fullEdges]);
  1745. const treeRoots = useMemo(() => buildTree(), [buildTree]);
  1746. // 生成树形文本结构(使用完整数据)
  1747. const generateTreeText = useCallback(() => {
  1748. const lines = [];
  1749. // 递归生成树形文本
  1750. const traverse = (nodes, prefix = '', isLast = true, depth = 0) => {
  1751. nodes.forEach((node, index) => {
  1752. const isLastNode = index === nodes.length - 1;
  1753. const nodeData = fullNodes.find(n => n.id === node.id)?.data || {};
  1754. const nodeType = nodeData.nodeType || node.data?.nodeType || 'unknown';
  1755. const title = nodeData.title || node.data?.title || node.id;
  1756. // 优先从node.data获取score,然后从nodeData获取
  1757. let score = null;
  1758. if (node.data?.score !== undefined && node.data?.score !== null) {
  1759. score = node.data.score;
  1760. } else if (node.data?.relevance_score !== undefined && node.data?.relevance_score !== null) {
  1761. score = node.data.relevance_score;
  1762. } else if (nodeData.score !== undefined && nodeData.score !== null) {
  1763. score = nodeData.score;
  1764. } else if (nodeData.relevance_score !== undefined && nodeData.relevance_score !== null) {
  1765. score = nodeData.relevance_score;
  1766. }
  1767. const strategy = nodeData.strategy || node.data?.strategy || '';
  1768. // 构建当前行 - score可能是数字或字符串,step/round节点不显示分数
  1769. const connector = isLastNode ? '└─' : '├─';
  1770. let scoreText = '';
  1771. if (nodeType !== 'step' && nodeType !== 'round' && score !== null && score !== undefined) {
  1772. // score可能已经是字符串格式(如 "0.05"),也可能是数字
  1773. const scoreStr = typeof score === 'number' ? score.toFixed(2) : score;
  1774. scoreText = \` (分数: \${scoreStr})\`;
  1775. }
  1776. const strategyText = strategy ? \` [\${strategy}]\` : '';
  1777. lines.push(\`\${prefix}\${connector} \${title}\${scoreText}\${strategyText}\`);
  1778. // 递归处理子节点
  1779. if (node.children && node.children.length > 0) {
  1780. const childPrefix = prefix + (isLastNode ? ' ' : '│ ');
  1781. traverse(node.children, childPrefix, isLastNode, depth + 1);
  1782. }
  1783. });
  1784. };
  1785. // 添加标题
  1786. const rootNode = fullNodes.find(n => n.data?.level === 0);
  1787. if (rootNode) {
  1788. lines.push(\`📊 查询扩展树形结构\`);
  1789. lines.push(\`原始问题: \${rootNode.data.title || rootNode.data.query}\`);
  1790. lines.push('');
  1791. }
  1792. traverse(treeRoots);
  1793. return lines.join('\\n');
  1794. }, [treeRoots, fullNodes]);
  1795. // 复制树形结构到剪贴板
  1796. const copyTreeToClipboard = useCallback(async () => {
  1797. try {
  1798. const treeText = generateTreeText();
  1799. await navigator.clipboard.writeText(treeText);
  1800. alert('✅ 树形结构已复制到剪贴板!');
  1801. } catch (err) {
  1802. console.error('复制失败:', err);
  1803. alert('❌ 复制失败,请手动复制');
  1804. }
  1805. }, [generateTreeText]);
  1806. // 初始化树节点折叠状态
  1807. useEffect(() => {
  1808. const getAllTreeKeys = (nodes) => {
  1809. const keys = new Set();
  1810. const traverse = (node) => {
  1811. if (node.children && node.children.length > 0) {
  1812. // 排除根节点
  1813. if (node.data.level !== 0) {
  1814. keys.add(node.treeKey);
  1815. }
  1816. node.children.forEach(traverse);
  1817. }
  1818. };
  1819. nodes.forEach(traverse);
  1820. return keys;
  1821. };
  1822. setCollapsedTreeNodes(getAllTreeKeys(treeRoots));
  1823. }, [treeRoots]);
  1824. // 映射完整节点ID到画布简化节点ID
  1825. const mapTreeNodeToCanvasNode = useCallback((treeNodeId) => {
  1826. // 如果是简化模式,需要映射
  1827. if (data.fullData) {
  1828. // 从完整数据中找到节点
  1829. const fullNode = fullNodes.find(n => n.id === treeNodeId);
  1830. if (!fullNode) return treeNodeId;
  1831. // 根据节点类型和文本找到画布上的简化节点
  1832. const nodeText = fullNode.data.title || fullNode.data.query;
  1833. const nodeType = fullNode.data.nodeType || fullNode.type;
  1834. // Query类节点:找 query_xxx
  1835. if (['q', 'seg', 'sug', 'add_word', 'query'].includes(nodeType)) {
  1836. const canvasNode = initialNodes.find(n =>
  1837. (n.data.title === nodeText || n.data.query === nodeText) &&
  1838. ['query'].includes(n.type)
  1839. );
  1840. return canvasNode ? canvasNode.id : treeNodeId;
  1841. }
  1842. // Post节点:按note_id查找
  1843. if (nodeType === 'post' || nodeType === 'note') {
  1844. const noteId = fullNode.data.note_id;
  1845. if (noteId) {
  1846. const canvasNode = initialNodes.find(n => n.data.note_id === noteId);
  1847. return canvasNode ? canvasNode.id : treeNodeId;
  1848. }
  1849. }
  1850. // 其他节点类型(Round/Step等):直接返回
  1851. return treeNodeId;
  1852. }
  1853. // 非简化模式,直接返回
  1854. return treeNodeId;
  1855. }, [data.fullData, fullNodes, initialNodes]);
  1856. const renderTree = useCallback((treeNodes, level = 0) => {
  1857. return treeNodes.map(node => {
  1858. // 使用 treeKey 来区分树中的不同实例
  1859. const isCollapsed = collapsedTreeNodes.has(node.treeKey);
  1860. const isSelected = selectedNodeId === node.id;
  1861. return (
  1862. <TreeNode
  1863. key={node.treeKey}
  1864. node={node}
  1865. level={level}
  1866. isCollapsed={isCollapsed}
  1867. isSelected={isSelected}
  1868. onToggle={() => {
  1869. setCollapsedTreeNodes(prev => {
  1870. const newSet = new Set(prev);
  1871. if (newSet.has(node.treeKey)) {
  1872. newSet.delete(node.treeKey);
  1873. } else {
  1874. newSet.add(node.treeKey);
  1875. }
  1876. return newSet;
  1877. });
  1878. }}
  1879. onSelect={() => {
  1880. // 将目录节点ID映射到画布节点ID
  1881. const treeNodeId = node.id;
  1882. const canvasNodeId = mapTreeNodeToCanvasNode(treeNodeId);
  1883. // 检查画布上是否存在这个节点
  1884. const canvasNodeExists = initialNodes.some(n => n.id === canvasNodeId);
  1885. if (!canvasNodeExists) {
  1886. console.warn(\`节点 \${canvasNodeId} 在画布上不存在(可能被简化了)\`);
  1887. return;
  1888. }
  1889. const nodeId = canvasNodeId;
  1890. // 展开所有祖先节点
  1891. const ancestorIds = [nodeId];
  1892. const findAncestors = (id) => {
  1893. initialEdges.forEach(edge => {
  1894. if (edge.target === id && !ancestorIds.includes(edge.source)) {
  1895. ancestorIds.push(edge.source);
  1896. findAncestors(edge.source);
  1897. }
  1898. });
  1899. };
  1900. findAncestors(nodeId);
  1901. // 如果节点或其祖先被隐藏,先恢复它们
  1902. setHiddenNodes(prev => {
  1903. const newSet = new Set(prev);
  1904. ancestorIds.forEach(id => newSet.delete(id));
  1905. return newSet;
  1906. });
  1907. setSelectedNodeId(nodeId);
  1908. // 获取选中节点的直接子节点
  1909. const childrenIds = [];
  1910. initialEdges.forEach(edge => {
  1911. if (edge.source === nodeId) {
  1912. childrenIds.push(edge.target);
  1913. }
  1914. });
  1915. setCollapsedNodes(prev => {
  1916. const newSet = new Set(prev);
  1917. // 展开所有祖先节点
  1918. ancestorIds.forEach(id => newSet.delete(id));
  1919. // 展开选中节点本身
  1920. newSet.delete(nodeId);
  1921. // 展开选中节点的直接子节点
  1922. childrenIds.forEach(id => newSet.delete(id));
  1923. return newSet;
  1924. });
  1925. // 延迟聚焦,等待节点展开和布局重新计算
  1926. setTimeout(() => {
  1927. fitView({
  1928. nodes: [{ id: nodeId }],
  1929. duration: 800,
  1930. padding: 0.3,
  1931. });
  1932. }, 300);
  1933. }}
  1934. >
  1935. {node.children && node.children.length > 0 && renderTree(node.children, level + 1)}
  1936. </TreeNode>
  1937. );
  1938. });
  1939. }, [collapsedTreeNodes, selectedNodeId, nodes, setCenter, initialEdges, setCollapsedNodes, fitView, mapTreeNodeToCanvasNode, initialNodes, setHiddenNodes]);
  1940. console.log('📊 Rendering with', nodes.length, 'visible nodes and', edges.length, 'visible edges');
  1941. if (nodes.length === 0) {
  1942. return (
  1943. <div style={{ padding: 50, color: 'red', fontSize: 20 }}>
  1944. ERROR: No nodes to display!
  1945. </div>
  1946. );
  1947. }
  1948. return (
  1949. <div style={{ width: '100vw', height: '100vh', background: '#f9fafb', display: 'flex', flexDirection: 'column' }}>
  1950. {/* 顶部面包屑导航栏 */}
  1951. <div style={{
  1952. minHeight: '48px',
  1953. maxHeight: '120px',
  1954. background: 'white',
  1955. borderBottom: '1px solid #e5e7eb',
  1956. display: 'flex',
  1957. alignItems: 'flex-start',
  1958. padding: '12px 24px',
  1959. zIndex: 1000,
  1960. boxShadow: '0 1px 3px rgba(0, 0, 0, 0.05)',
  1961. flexShrink: 0,
  1962. overflowY: 'auto',
  1963. }}>
  1964. <div style={{ width: '100%' }}>
  1965. {selectedNodeId ? (
  1966. <div style={{ fontSize: '12px', color: '#6b7280' }}>
  1967. {/* 面包屑导航 - 显示所有路径 */}
  1968. {(() => {
  1969. const selectedNode = nodes.find(n => n.id === selectedNodeId);
  1970. if (!selectedNode) return null;
  1971. // 找到所有从根节点到当前节点的路径
  1972. const findAllPaths = (targetId) => {
  1973. const paths = [];
  1974. const buildPath = (nodeId, currentPath) => {
  1975. const node = initialNodes.find(n => n.id === nodeId);
  1976. if (!node) return;
  1977. const newPath = [node, ...currentPath];
  1978. // 找到所有父节点
  1979. const parents = initialEdges.filter(e => e.target === nodeId).map(e => e.source);
  1980. if (parents.length === 0) {
  1981. // 到达根节点
  1982. paths.push(newPath);
  1983. } else {
  1984. // 递归处理所有父节点
  1985. parents.forEach(parentId => {
  1986. buildPath(parentId, newPath);
  1987. });
  1988. }
  1989. };
  1990. buildPath(targetId, []);
  1991. return paths;
  1992. };
  1993. const allPaths = findAllPaths(selectedNodeId);
  1994. // 去重:将路径转换为字符串进行比较
  1995. const uniquePaths = [];
  1996. const pathStrings = new Set();
  1997. allPaths.forEach(path => {
  1998. const pathString = path.map(n => n.id).join('->');
  1999. if (!pathStrings.has(pathString)) {
  2000. pathStrings.add(pathString);
  2001. uniquePaths.push(path);
  2002. }
  2003. });
  2004. return (
  2005. <div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
  2006. {uniquePaths.map((path, pathIndex) => (
  2007. <div key={pathIndex} style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
  2008. {pathIndex > 0 && <span style={{ color: '#d1d5db', marginRight: '4px' }}>或</span>}
  2009. {path.map((node, index) => {
  2010. // 获取节点的 score、strategy 和 isSelected
  2011. const nodeScore = node.data.score ? parseFloat(node.data.score) : 0;
  2012. const nodeStrategy = getPrimaryStrategy(node.data); // 使用智能提取函数
  2013. const strategyColor = getStrategyColor(nodeStrategy);
  2014. const nodeIsSelected = node.type === 'note' ? node.data.matchLevel !== 'unsatisfied' : node.data.isSelected !== false;
  2015. const nodeActualType = node.data.nodeType || node.type; // 获取实际节点类型
  2016. // 计算路径节点字体颜色:根据分数提升幅度判断
  2017. let pathFontColor = '#374151'; // 默认颜色
  2018. if (node.type === 'note') {
  2019. pathFontColor = node.data.matchLevel === 'unsatisfied' ? '#ef4444' : '#374151';
  2020. } else if (node.data.seed_score !== undefined) {
  2021. const parentScore = parseFloat(node.data.seed_score);
  2022. const gain = nodeScore - parentScore;
  2023. pathFontColor = gain >= 0.05 ? '#16a34a' : '#ef4444';
  2024. } else if (index > 0) {
  2025. const prevNode = path[index - 1];
  2026. const prevScore = prevNode.data.score ? parseFloat(prevNode.data.score) : 0;
  2027. const gain = nodeScore - prevScore;
  2028. pathFontColor = gain >= 0.05 ? '#16a34a' : '#ef4444';
  2029. } else if (node.data.isSelected === false) {
  2030. pathFontColor = '#ef4444';
  2031. }
  2032. return (
  2033. <React.Fragment key={node.id + '-' + index}>
  2034. <span
  2035. onClick={() => {
  2036. const nodeId = node.id;
  2037. // 找到所有祖先节点
  2038. const ancestorIds = [nodeId];
  2039. const findAncestors = (id) => {
  2040. initialEdges.forEach(edge => {
  2041. if (edge.target === id && !ancestorIds.includes(edge.source)) {
  2042. ancestorIds.push(edge.source);
  2043. findAncestors(edge.source);
  2044. }
  2045. });
  2046. };
  2047. findAncestors(nodeId);
  2048. // 如果节点或其祖先被隐藏,先恢复它们
  2049. setHiddenNodes(prev => {
  2050. const newSet = new Set(prev);
  2051. ancestorIds.forEach(id => newSet.delete(id));
  2052. return newSet;
  2053. });
  2054. // 展开目录树中到达该节点的路径
  2055. // 需要找到所有包含该节点的树路径的 treeKey,并展开它们的父节点
  2056. setCollapsedTreeNodes(prev => {
  2057. const newSet = new Set(prev);
  2058. // 清空所有折叠状态,让目录树完全展开到选中节点
  2059. // 这样可以确保选中节点在目录中可见
  2060. return new Set();
  2061. });
  2062. setSelectedNodeId(nodeId);
  2063. setTimeout(() => {
  2064. fitView({
  2065. nodes: [{ id: nodeId }],
  2066. duration: 800,
  2067. padding: 0.3,
  2068. });
  2069. }, 100);
  2070. }}
  2071. style={{
  2072. padding: '6px 8px',
  2073. borderRadius: '4px',
  2074. background: 'white',
  2075. border: index === path.length - 1 ? '2px solid #3b82f6' : '1px solid #d1d5db',
  2076. color: '#374151',
  2077. fontWeight: index === path.length - 1 ? '600' : '400',
  2078. width: '180px',
  2079. cursor: 'pointer',
  2080. transition: 'all 0.2s ease',
  2081. position: 'relative',
  2082. display: 'inline-flex',
  2083. flexDirection: 'column',
  2084. gap: '4px',
  2085. }}
  2086. onMouseEnter={(e) => {
  2087. e.currentTarget.style.opacity = '0.8';
  2088. }}
  2089. onMouseLeave={(e) => {
  2090. e.currentTarget.style.opacity = '1';
  2091. }}
  2092. title={\`\${node.data.title || node.id} (Score: \${nodeScore.toFixed(2)}, Strategy: \${nodeStrategy}, Selected: \${nodeIsSelected})\`}
  2093. >
  2094. {/* 上半部分:竖线 + 图标 + 文字 + 分数 */}
  2095. <div style={{
  2096. display: 'flex',
  2097. alignItems: 'center',
  2098. gap: '6px',
  2099. }}>
  2100. {/* 策略类型竖线 */}
  2101. <div style={{
  2102. width: '3px',
  2103. height: '16px',
  2104. background: strategyColor,
  2105. borderRadius: '2px',
  2106. flexShrink: 0,
  2107. }} />
  2108. {/* 节点文字 - 左侧 */}
  2109. <span style={{
  2110. flex: 1,
  2111. fontSize: '12px',
  2112. color: pathFontColor,
  2113. overflow: 'hidden',
  2114. textOverflow: 'ellipsis',
  2115. whiteSpace: 'nowrap',
  2116. }}>
  2117. {node.data.title || node.id}
  2118. </span>
  2119. {/* 域标识 - 右侧,挨着分数 */}
  2120. {(node.data.domain_type || node.data.domains_str || (node.data.domain_index !== null && node.data.domain_index !== undefined)) && (
  2121. <span style={{
  2122. fontSize: '12px',
  2123. color: '#fff',
  2124. background: '#6366f1',
  2125. padding: '2px 5px',
  2126. borderRadius: '3px',
  2127. flexShrink: 0,
  2128. fontWeight: '600',
  2129. marginLeft: '4px',
  2130. }}
  2131. title={
  2132. node.data.domain_type ? '域: ' + node.data.domain_type + ' (D' + node.data.domain_index + ')' :
  2133. node.data.domains_str ? '域: ' + node.data.domains_str :
  2134. '域 D' + node.data.domain_index
  2135. }
  2136. >
  2137. {node.data.domain_type || node.data.domains_str || ('D' + node.data.domain_index)}
  2138. </span>
  2139. )}
  2140. {/* 分数显示 - 步骤和轮次节点不显示分数 */}
  2141. {nodeActualType !== 'step' && nodeActualType !== 'round' && (
  2142. <span style={{
  2143. fontSize: '10px',
  2144. color: '#6b7280',
  2145. fontWeight: '500',
  2146. flexShrink: 0,
  2147. minWidth: '35px',
  2148. textAlign: 'right',
  2149. marginLeft: '4px',
  2150. }}>
  2151. {nodeScore.toFixed(2)}
  2152. </span>
  2153. )}
  2154. </div>
  2155. {/* 分数下划线 - 步骤和轮次节点不显示 */}
  2156. {nodeActualType !== 'step' && nodeActualType !== 'round' && (
  2157. <div style={{
  2158. width: (nodeScore * 100) + '%',
  2159. height: '2px',
  2160. background: getScoreColor(nodeScore),
  2161. borderRadius: '1px',
  2162. marginLeft: '9px',
  2163. }} />
  2164. )}
  2165. </span>
  2166. {index < path.length - 1 && <span style={{ color: '#9ca3af' }}>›</span>}
  2167. </React.Fragment>
  2168. )})}
  2169. </div>
  2170. ))}
  2171. </div>
  2172. );
  2173. })()}
  2174. </div>
  2175. ) : (
  2176. <div style={{ fontSize: '13px', color: '#9ca3af', textAlign: 'center' }}>
  2177. 选择一个节点查看路径
  2178. </div>
  2179. )}
  2180. </div>
  2181. </div>
  2182. {/* 主内容区:目录 + 画布 */}
  2183. <div style={{
  2184. display: 'flex',
  2185. flex: 1,
  2186. overflow: 'hidden',
  2187. cursor: isResizing ? 'col-resize' : 'default',
  2188. userSelect: isResizing ? 'none' : 'auto',
  2189. }}>
  2190. {/* 左侧目录树 */}
  2191. <div style={{
  2192. width: \`\${sidebarWidth}px\`,
  2193. background: 'white',
  2194. borderRight: '1px solid #e5e7eb',
  2195. display: 'flex',
  2196. flexDirection: 'column',
  2197. flexShrink: 0,
  2198. }}>
  2199. <div style={{
  2200. padding: '12px 16px',
  2201. borderBottom: '1px solid #e5e7eb',
  2202. display: 'flex',
  2203. justifyContent: 'space-between',
  2204. alignItems: 'center',
  2205. }}>
  2206. <span style={{
  2207. fontWeight: '600',
  2208. fontSize: '14px',
  2209. color: '#111827',
  2210. }}>
  2211. 节点目录
  2212. </span>
  2213. <div style={{ display: 'flex', gap: '6px' }}>
  2214. <button
  2215. onClick={() => {
  2216. setCollapsedTreeNodes(new Set());
  2217. }}
  2218. style={{
  2219. fontSize: '11px',
  2220. padding: '4px 8px',
  2221. borderRadius: '4px',
  2222. border: '1px solid #d1d5db',
  2223. background: 'white',
  2224. color: '#6b7280',
  2225. cursor: 'pointer',
  2226. fontWeight: '500',
  2227. }}
  2228. title="展开全部节点"
  2229. >
  2230. 全部展开
  2231. </button>
  2232. <button
  2233. onClick={() => {
  2234. const getAllTreeKeys = (nodes) => {
  2235. const keys = new Set();
  2236. const traverse = (node) => {
  2237. if (node.children && node.children.length > 0) {
  2238. keys.add(node.treeKey);
  2239. node.children.forEach(traverse);
  2240. }
  2241. };
  2242. nodes.forEach(traverse);
  2243. return keys;
  2244. };
  2245. setCollapsedTreeNodes(getAllTreeKeys(treeRoots));
  2246. }}
  2247. style={{
  2248. fontSize: '11px',
  2249. padding: '4px 8px',
  2250. borderRadius: '4px',
  2251. border: '1px solid #d1d5db',
  2252. background: 'white',
  2253. color: '#6b7280',
  2254. cursor: 'pointer',
  2255. fontWeight: '500',
  2256. }}
  2257. title="折叠全部节点"
  2258. >
  2259. 全部折叠
  2260. </button>
  2261. <button
  2262. onClick={copyTreeToClipboard}
  2263. style={{
  2264. fontSize: '11px',
  2265. padding: '4px 8px',
  2266. borderRadius: '4px',
  2267. border: '1px solid #3b82f6',
  2268. background: '#3b82f6',
  2269. color: 'white',
  2270. cursor: 'pointer',
  2271. fontWeight: '500',
  2272. transition: 'all 0.2s',
  2273. }}
  2274. onMouseEnter={(e) => e.currentTarget.style.background = '#2563eb'}
  2275. onMouseLeave={(e) => e.currentTarget.style.background = '#3b82f6'}
  2276. title="复制树形结构为文本格式"
  2277. >
  2278. 📋 复制树形结构
  2279. </button>
  2280. </div>
  2281. </div>
  2282. <div style={{
  2283. flex: 1,
  2284. overflowX: 'auto',
  2285. overflowY: 'auto',
  2286. padding: '8px',
  2287. }}>
  2288. <div style={{ minWidth: 'fit-content' }}>
  2289. {renderTree(treeRoots)}
  2290. </div>
  2291. </div>
  2292. </div>
  2293. {/* 可拖拽的分隔条 */}
  2294. <div
  2295. onMouseDown={handleMouseDown}
  2296. style={{
  2297. width: '4px',
  2298. cursor: 'col-resize',
  2299. background: isResizing ? '#3b82f6' : 'transparent',
  2300. transition: isResizing ? 'none' : 'background 0.2s',
  2301. flexShrink: 0,
  2302. position: 'relative',
  2303. }}
  2304. onMouseEnter={(e) => e.currentTarget.style.background = '#e5e7eb'}
  2305. onMouseLeave={(e) => {
  2306. if (!isResizing) e.currentTarget.style.background = 'transparent';
  2307. }}
  2308. >
  2309. {/* 拖拽提示线 */}
  2310. <div style={{
  2311. position: 'absolute',
  2312. top: '50%',
  2313. left: '50%',
  2314. transform: 'translate(-50%, -50%)',
  2315. width: '1px',
  2316. height: '40px',
  2317. background: '#9ca3af',
  2318. opacity: isResizing ? 1 : 0.3,
  2319. }} />
  2320. </div>
  2321. {/* 画布区域 */}
  2322. <div style={{ flex: 1, position: 'relative' }}>
  2323. {/* 右侧图例 */}
  2324. <div style={{
  2325. position: 'absolute',
  2326. top: '20px',
  2327. right: '20px',
  2328. background: 'white',
  2329. padding: '16px',
  2330. borderRadius: '12px',
  2331. boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
  2332. zIndex: 1000,
  2333. maxWidth: '260px',
  2334. border: '1px solid #e5e7eb',
  2335. }}>
  2336. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
  2337. <h3 style={{ fontSize: '14px', fontWeight: '600', color: '#111827', margin: 0 }}>图例</h3>
  2338. <button
  2339. onClick={() => setFocusMode(!focusMode)}
  2340. style={{
  2341. fontSize: '11px',
  2342. padding: '4px 8px',
  2343. borderRadius: '4px',
  2344. border: '1px solid',
  2345. borderColor: focusMode ? '#3b82f6' : '#d1d5db',
  2346. background: focusMode ? '#3b82f6' : 'white',
  2347. color: focusMode ? 'white' : '#6b7280',
  2348. cursor: 'pointer',
  2349. fontWeight: '500',
  2350. }}
  2351. title={focusMode ? '关闭聚焦模式' : '开启聚焦模式'}
  2352. >
  2353. {focusMode ? '🎯 聚焦' : '📊 全图'}
  2354. </button>
  2355. </div>
  2356. <div style={{ fontSize: '12px' }}>
  2357. {/* 画布节点展开/折叠控制 */}
  2358. <div style={{ marginBottom: '12px', paddingBottom: '12px', borderBottom: '1px solid #f3f4f6' }}>
  2359. <div style={{ fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#374151' }}>节点控制</div>
  2360. <div style={{ display: 'flex', gap: '6px' }}>
  2361. <button
  2362. onClick={() => {
  2363. setCollapsedNodes(new Set());
  2364. }}
  2365. style={{
  2366. fontSize: '11px',
  2367. padding: '4px 8px',
  2368. borderRadius: '4px',
  2369. border: '1px solid #d1d5db',
  2370. background: 'white',
  2371. color: '#6b7280',
  2372. cursor: 'pointer',
  2373. fontWeight: '500',
  2374. flex: 1,
  2375. }}
  2376. title="展开画布中所有节点的子节点"
  2377. >
  2378. 全部展开
  2379. </button>
  2380. <button
  2381. onClick={() => {
  2382. const allNodeIds = new Set(initialNodes.map(n => n.id));
  2383. setCollapsedNodes(allNodeIds);
  2384. }}
  2385. style={{
  2386. fontSize: '11px',
  2387. padding: '4px 8px',
  2388. borderRadius: '4px',
  2389. border: '1px solid #d1d5db',
  2390. background: 'white',
  2391. color: '#6b7280',
  2392. cursor: 'pointer',
  2393. fontWeight: '500',
  2394. flex: 1,
  2395. }}
  2396. title="折叠画布中所有节点的子节点"
  2397. >
  2398. 全部折叠
  2399. </button>
  2400. </div>
  2401. </div>
  2402. <div style={{ paddingTop: '12px', borderTop: '1px solid #f3f4f6' }}>
  2403. <div style={{ fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#374151' }}>策略类型</div>
  2404. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  2405. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#10b981', opacity: 0.7 }}></div>
  2406. <span style={{ color: '#6b7280', fontSize: '11px' }}>初始分词</span>
  2407. </div>
  2408. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  2409. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#06b6d4', opacity: 0.7 }}></div>
  2410. <span style={{ color: '#6b7280', fontSize: '11px' }}>调用sug</span>
  2411. </div>
  2412. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  2413. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#f59e0b', opacity: 0.7 }}></div>
  2414. <span style={{ color: '#6b7280', fontSize: '11px' }}>同义改写</span>
  2415. </div>
  2416. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  2417. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#3b82f6', opacity: 0.7 }}></div>
  2418. <span style={{ color: '#6b7280', fontSize: '11px' }}>加词</span>
  2419. </div>
  2420. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  2421. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#8b5cf6', opacity: 0.7 }}></div>
  2422. <span style={{ color: '#6b7280', fontSize: '11px' }}>抽象改写</span>
  2423. </div>
  2424. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  2425. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#ec4899', opacity: 0.7 }}></div>
  2426. <span style={{ color: '#6b7280', fontSize: '11px' }}>基于部分匹配改进</span>
  2427. </div>
  2428. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  2429. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#a855f7', opacity: 0.7 }}></div>
  2430. <span style={{ color: '#6b7280', fontSize: '11px' }}>结果分支-抽象改写</span>
  2431. </div>
  2432. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  2433. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#fb923c', opacity: 0.7 }}></div>
  2434. <span style={{ color: '#6b7280', fontSize: '11px' }}>结果分支-同义改写</span>
  2435. </div>
  2436. </div>
  2437. <div style={{
  2438. marginTop: '12px',
  2439. paddingTop: '12px',
  2440. borderTop: '1px solid #f3f4f6',
  2441. fontSize: '11px',
  2442. color: '#9ca3af',
  2443. lineHeight: '1.5',
  2444. }}>
  2445. 💡 点击节点左上角 × 隐藏节点
  2446. </div>
  2447. {/* 隐藏节点列表 - 在图例内部 */}
  2448. {hiddenNodes.size > 0 && (
  2449. <div style={{
  2450. marginTop: '12px',
  2451. paddingTop: '12px',
  2452. borderTop: '1px solid #f3f4f6',
  2453. }}>
  2454. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
  2455. <h4 style={{ fontSize: '12px', fontWeight: '600', color: '#111827' }}>已隐藏节点</h4>
  2456. <button
  2457. onClick={() => setHiddenNodes(new Set())}
  2458. style={{
  2459. fontSize: '10px',
  2460. color: '#3b82f6',
  2461. background: 'none',
  2462. border: 'none',
  2463. cursor: 'pointer',
  2464. textDecoration: 'underline',
  2465. }}
  2466. >
  2467. 全部恢复
  2468. </button>
  2469. </div>
  2470. <div style={{ fontSize: '12px', maxHeight: '200px', overflow: 'auto' }}>
  2471. {Array.from(hiddenNodes).map(nodeId => {
  2472. const node = initialNodes.find(n => n.id === nodeId);
  2473. if (!node) return null;
  2474. return (
  2475. <div
  2476. key={nodeId}
  2477. style={{
  2478. display: 'flex',
  2479. justifyContent: 'space-between',
  2480. alignItems: 'center',
  2481. padding: '6px 8px',
  2482. margin: '4px 0',
  2483. background: '#f9fafb',
  2484. borderRadius: '6px',
  2485. fontSize: '11px',
  2486. }}
  2487. >
  2488. <span
  2489. style={{
  2490. flex: 1,
  2491. overflow: 'hidden',
  2492. textOverflow: 'ellipsis',
  2493. whiteSpace: 'nowrap',
  2494. color: '#374151',
  2495. }}
  2496. title={node.data.title || nodeId}
  2497. >
  2498. {node.data.title || nodeId}
  2499. </span>
  2500. <button
  2501. onClick={() => {
  2502. setHiddenNodes(prev => {
  2503. const newSet = new Set(prev);
  2504. newSet.delete(nodeId);
  2505. return newSet;
  2506. });
  2507. }}
  2508. style={{
  2509. marginLeft: '8px',
  2510. fontSize: '10px',
  2511. color: '#10b981',
  2512. background: 'none',
  2513. border: 'none',
  2514. cursor: 'pointer',
  2515. flexShrink: 0,
  2516. }}
  2517. >
  2518. 恢复
  2519. </button>
  2520. </div>
  2521. );
  2522. })}
  2523. </div>
  2524. </div>
  2525. )}
  2526. </div>
  2527. </div>
  2528. {/* React Flow 画布 */}
  2529. <ReactFlow
  2530. nodes={nodes}
  2531. edges={edges}
  2532. nodeTypes={nodeTypes}
  2533. fitView
  2534. fitViewOptions={{ padding: 0.2, duration: 500 }}
  2535. minZoom={0.1}
  2536. maxZoom={1.5}
  2537. nodesDraggable={true}
  2538. nodesConnectable={false}
  2539. elementsSelectable={true}
  2540. defaultEdgeOptions={{
  2541. type: 'smoothstep',
  2542. }}
  2543. proOptions={{ hideAttribution: true }}
  2544. onNodeClick={(event, clickedNode) => {
  2545. setSelectedNodeId(clickedNode.id);
  2546. }}
  2547. >
  2548. <Controls style={{ bottom: '20px', left: 'auto', right: '20px' }} />
  2549. <Background variant="dots" gap={20} size={1} color="#e5e7eb" />
  2550. </ReactFlow>
  2551. </div>
  2552. </div>
  2553. </div>
  2554. );
  2555. }
  2556. function App() {
  2557. return (
  2558. <ReactFlowProvider>
  2559. <FlowContent />
  2560. </ReactFlowProvider>
  2561. );
  2562. }
  2563. const root = createRoot(document.getElementById('root'));
  2564. root.render(<App />);
  2565. `;
  2566. fs.writeFileSync(reactComponentPath, reactComponent);
  2567. // 使用 esbuild 打包
  2568. console.log('🎨 Building modern visualization...');
  2569. build({
  2570. entryPoints: [reactComponentPath],
  2571. bundle: true,
  2572. outfile: path.join(__dirname, 'bundle_v2.js'),
  2573. format: 'iife',
  2574. loader: {
  2575. '.css': 'css',
  2576. },
  2577. minify: false,
  2578. sourcemap: 'inline',
  2579. // 强制所有 React 引用指向同一个位置,避免多副本
  2580. alias: {
  2581. 'react': path.join(__dirname, 'node_modules/react'),
  2582. 'react-dom': path.join(__dirname, 'node_modules/react-dom'),
  2583. 'react/jsx-runtime': path.join(__dirname, 'node_modules/react/jsx-runtime'),
  2584. 'react/jsx-dev-runtime': path.join(__dirname, 'node_modules/react/jsx-dev-runtime'),
  2585. },
  2586. }).then(() => {
  2587. // 读取打包后的 JS
  2588. const bundleJs = fs.readFileSync(path.join(__dirname, 'bundle_v2.js'), 'utf-8');
  2589. // 读取 CSS
  2590. const cssPath = path.join(__dirname, 'node_modules/@xyflow/react/dist/style.css');
  2591. const css = fs.readFileSync(cssPath, 'utf-8');
  2592. // 生成最终 HTML
  2593. const html = `<!DOCTYPE html>
  2594. <html lang="zh-CN">
  2595. <head>
  2596. <meta charset="UTF-8">
  2597. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  2598. <title>查询图可视化</title>
  2599. <link rel="preconnect" href="https://fonts.googleapis.com">
  2600. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  2601. <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
  2602. <script src="https://unpkg.com/dagre@0.8.5/dist/dagre.min.js"></script>
  2603. <script>
  2604. // 过滤特定的 React 警告
  2605. const originalError = console.error;
  2606. console.error = (...args) => {
  2607. if (typeof args[0] === 'string' && args[0].includes('Each child in a list should have a unique "key" prop')) {
  2608. return;
  2609. }
  2610. originalError.apply(console, args);
  2611. };
  2612. </script>
  2613. <style>
  2614. * {
  2615. margin: 0;
  2616. padding: 0;
  2617. box-sizing: border-box;
  2618. }
  2619. body {
  2620. font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  2621. overflow: hidden;
  2622. -webkit-font-smoothing: antialiased;
  2623. -moz-osx-font-smoothing: grayscale;
  2624. }
  2625. #root {
  2626. width: 100vw;
  2627. height: 100vh;
  2628. }
  2629. ${css}
  2630. /* 自定义样式覆盖 */
  2631. .react-flow__edge-path {
  2632. stroke-linecap: round;
  2633. }
  2634. .react-flow__controls {
  2635. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  2636. border: 1px solid #e5e7eb;
  2637. border-radius: 8px;
  2638. }
  2639. .react-flow__controls-button {
  2640. border: none;
  2641. border-bottom: 1px solid #e5e7eb;
  2642. }
  2643. .react-flow__controls-button:hover {
  2644. background: #f9fafb;
  2645. }
  2646. </style>
  2647. </head>
  2648. <body>
  2649. <div id="root"></div>
  2650. <script>${bundleJs}</script>
  2651. </body>
  2652. </html>`;
  2653. // 写入输出文件
  2654. fs.writeFileSync(outputFile, html);
  2655. // 清理临时文件
  2656. fs.unlinkSync(reactComponentPath);
  2657. fs.unlinkSync(path.join(__dirname, 'bundle_v2.js'));
  2658. console.log('✅ Visualization generated: ' + outputFile);
  2659. console.log('📊 Nodes: ' + Object.keys(data.nodes).length);
  2660. console.log('🔗 Edges: ' + data.edges.length);
  2661. }).catch(error => {
  2662. console.error('❌ Build error:', error);
  2663. process.exit(1);
  2664. });