index.js 144 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781
  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'); // 已废弃,使用v3版本
  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. const cleanedData = inputData.multimodal_cleaned_posts || null;
  20. const cleanedPosts = cleanedData ? cleanedData.posts : [];
  21. console.log(`📊 发现清洗数据: ${cleanedPosts.length} 个帖子`);
  22. // 检测数据格式并转换
  23. let data;
  24. if (inputData.rounds && inputData.o) {
  25. // v6.1.2.8 格式,需要转换
  26. console.log('✨ 检测到 v6.1.2.8 格式,正在转换为图结构...');
  27. // 尝试读取 search_results.json(兼容旧版本)
  28. let searchResults = null;
  29. const searchResultsPath = path.join(path.dirname(inputFile), 'search_results.json');
  30. if (fs.existsSync(searchResultsPath)) {
  31. console.log('📄 读取外部搜索结果数据(兼容模式)...');
  32. searchResults = JSON.parse(fs.readFileSync(searchResultsPath, 'utf-8'));
  33. } else {
  34. console.log('✅ 使用 run_context.json 中的内嵌搜索结果');
  35. }
  36. // 尝试读取 search_extract.json(多模态提取数据)
  37. let extractionData = null;
  38. const extractionPath = path.join(path.dirname(inputFile), 'search_extract.json');
  39. if (fs.existsSync(extractionPath)) {
  40. console.log('📸 读取多模态提取数据...');
  41. extractionData = JSON.parse(fs.readFileSync(extractionPath, 'utf-8'));
  42. } else {
  43. console.log('ℹ️ 未找到 search_extract.json,跳过多模态展示');
  44. }
  45. // 选择转换函数
  46. let graphData;
  47. let fullData = null; // 用于目录的完整数据
  48. if (useSimplified) {
  49. console.log('🎨 使用简化视图(合并query节点)');
  50. // 生成简化版用于画布
  51. graphData = convertV8ToGraphSimplified(inputData, searchResults, extractionData);
  52. // 生成完整版用于目录
  53. const fullGraphData = convertV8ToGraphV2(inputData, searchResults, extractionData);
  54. fullData = {
  55. nodes: fullGraphData.nodes,
  56. edges: fullGraphData.edges,
  57. iterations: fullGraphData.iterations
  58. };
  59. console.log(`✅ 简化版: ${Object.keys(graphData.nodes).length} 个节点, ${graphData.edges.length} 条边`);
  60. console.log(`📋 完整版(用于目录): ${Object.keys(fullData.nodes).length} 个节点`);
  61. } else {
  62. console.log('📊 使用详细视图(完整流程)');
  63. graphData = convertV8ToGraphV2(inputData, searchResults, extractionData);
  64. console.log(`✅ 转换完成: ${Object.keys(graphData.nodes).length} 个节点, ${graphData.edges.length} 条边`);
  65. }
  66. data = {
  67. nodes: graphData.nodes,
  68. edges: graphData.edges,
  69. iterations: graphData.iterations,
  70. fullData: fullData // 传递完整数据
  71. };
  72. } else if (inputData.nodes && inputData.edges) {
  73. // v6.1.2.5 格式,直接使用
  74. console.log('✨ 检测到 v6.1.2.5 格式,直接使用');
  75. data = inputData;
  76. } else {
  77. console.error('❌ 无法识别的数据格式');
  78. process.exit(1);
  79. }
  80. // 创建临时 React 组件文件
  81. const reactComponentPath = path.join(__dirname, 'temp_flow_component_v2.jsx');
  82. const reactComponent = `
  83. import React, { useState, useCallback, useMemo, useEffect } from 'react';
  84. import { createRoot } from 'react-dom/client';
  85. import {
  86. ReactFlow,
  87. Controls,
  88. Background,
  89. useNodesState,
  90. useEdgesState,
  91. Handle,
  92. Position,
  93. useReactFlow,
  94. ReactFlowProvider,
  95. } from '@xyflow/react';
  96. import '@xyflow/react/dist/style.css';
  97. const data = ${JSON.stringify(data, null, 2)};
  98. const cleanedPosts = ${JSON.stringify(cleanedPosts, null, 2)};
  99. // 根据节点类型获取边框颜色
  100. function getNodeTypeColor(type) {
  101. const typeColors = {
  102. 'root': '#6b21a8', // 紫色 - 根节点
  103. 'round': '#7c3aed', // 深紫 - Round节点
  104. 'step': '#f59e0b', // 橙色 - 步骤节点
  105. 'seg': '#10b981', // 绿色 - 分词
  106. 'q': '#3b82f6', // 蓝色 - Query
  107. 'sug': '#06b6d4', // 青色 - Sug建议词
  108. 'seed': '#84cc16', // 黄绿 - Seed
  109. 'add_word': '#22c55e', // 绿色 - 加词生成
  110. 'search_word': '#8b5cf6', // 紫色 - 搜索词
  111. 'post': '#ec4899', // 粉色 - 帖子
  112. 'filtered_sug': '#14b8a6',// 青绿 - 筛选的sug
  113. 'next_q': '#2563eb', // 深蓝 - 下轮查询
  114. 'next_seed': '#65a30d', // 深黄绿 - 下轮种子
  115. 'search': '#8b5cf6', // 深紫 - 搜索(兼容旧版)
  116. 'operation': '#f59e0b', // 橙色 - 操作节点(兼容旧版)
  117. 'query': '#3b82f6', // 蓝色 - 查询(兼容旧版)
  118. 'note': '#ec4899', // 粉色 - 帖子(兼容旧版)
  119. };
  120. return typeColors[type] || '#9ca3af';
  121. }
  122. // 查询节点组件 - 卡片样式
  123. function QueryNode({ id, data, sourcePosition, targetPosition }) {
  124. // 所有节点默认展开
  125. const expanded = true;
  126. // 获取节点类型颜色
  127. const typeColor = getNodeTypeColor(data.nodeType || 'query');
  128. return (
  129. <div>
  130. <Handle
  131. type="target"
  132. position={targetPosition || Position.Left}
  133. style={{ background: typeColor, width: 8, height: 8 }}
  134. />
  135. <div
  136. style={{
  137. padding: '12px',
  138. borderRadius: '8px',
  139. border: data.isHighlighted ? \`3px solid \${typeColor}\` :
  140. data.isCollapsed ? \`2px solid \${typeColor}\` :
  141. data.isSelected === false ? '2px dashed #d1d5db' :
  142. \`2px solid \${typeColor}\`,
  143. background: data.isHighlighted ? '#eef2ff' :
  144. data.isSelected === false ? '#f9fafb' : 'white',
  145. minWidth: '200px',
  146. maxWidth: '280px',
  147. boxShadow: data.isHighlighted ? '0 0 0 4px rgba(102, 126, 234, 0.25), 0 4px 16px rgba(102, 126, 234, 0.4)' :
  148. data.isCollapsed ? '0 4px 12px rgba(102, 126, 234, 0.15)' :
  149. data.level === 0 ? '0 4px 12px rgba(139, 92, 246, 0.15)' : '0 2px 6px rgba(0, 0, 0, 0.06)',
  150. transition: 'all 0.3s ease',
  151. cursor: 'pointer',
  152. position: 'relative',
  153. opacity: data.isSelected === false ? 0.6 : 1,
  154. }}
  155. >
  156. {/* 折叠当前节点按钮 - 左边 */}
  157. <div
  158. style={{
  159. position: 'absolute',
  160. top: '6px',
  161. left: '6px',
  162. width: '20px',
  163. height: '20px',
  164. borderRadius: '50%',
  165. background: '#f59e0b',
  166. color: 'white',
  167. display: 'flex',
  168. alignItems: 'center',
  169. justifyContent: 'center',
  170. fontSize: '11px',
  171. fontWeight: 'bold',
  172. cursor: 'pointer',
  173. transition: 'all 0.2s ease',
  174. zIndex: 10,
  175. }}
  176. onClick={(e) => {
  177. e.stopPropagation();
  178. if (data.onHideSelf) {
  179. data.onHideSelf();
  180. }
  181. }}
  182. onMouseEnter={(e) => {
  183. e.currentTarget.style.background = '#d97706';
  184. }}
  185. onMouseLeave={(e) => {
  186. e.currentTarget.style.background = '#f59e0b';
  187. }}
  188. title="隐藏当前节点"
  189. >
  190. ×
  191. </div>
  192. {/* 聚焦按钮 - 右上角 */}
  193. <div
  194. style={{
  195. position: 'absolute',
  196. top: '6px',
  197. right: '6px',
  198. width: '20px',
  199. height: '20px',
  200. borderRadius: '50%',
  201. background: data.isFocused ? '#10b981' : '#e5e7eb',
  202. color: data.isFocused ? 'white' : '#6b7280',
  203. display: 'flex',
  204. alignItems: 'center',
  205. justifyContent: 'center',
  206. fontSize: '11px',
  207. fontWeight: 'bold',
  208. cursor: 'pointer',
  209. transition: 'all 0.2s ease',
  210. zIndex: 10,
  211. }}
  212. onClick={(e) => {
  213. e.stopPropagation();
  214. if (data.onFocus) {
  215. data.onFocus();
  216. }
  217. }}
  218. onMouseEnter={(e) => {
  219. if (!data.isFocused) {
  220. e.currentTarget.style.background = '#d1d5db';
  221. }
  222. }}
  223. onMouseLeave={(e) => {
  224. if (!data.isFocused) {
  225. e.currentTarget.style.background = '#e5e7eb';
  226. }
  227. }}
  228. title={data.isFocused ? '取消聚焦' : '聚焦到此节点'}
  229. >
  230. 🎯
  231. </div>
  232. {/* 折叠/展开子节点按钮 - 右边第二个位置 */}
  233. {data.hasChildren && (
  234. <div
  235. style={{
  236. position: 'absolute',
  237. top: '6px',
  238. right: '30px',
  239. width: '20px',
  240. height: '20px',
  241. borderRadius: '50%',
  242. background: data.isCollapsed ? '#667eea' : '#e5e7eb',
  243. color: data.isCollapsed ? 'white' : '#6b7280',
  244. display: 'flex',
  245. alignItems: 'center',
  246. justifyContent: 'center',
  247. fontSize: '11px',
  248. fontWeight: 'bold',
  249. cursor: 'pointer',
  250. transition: 'all 0.2s ease',
  251. zIndex: 10,
  252. }}
  253. onClick={(e) => {
  254. e.stopPropagation();
  255. data.onToggleCollapse();
  256. }}
  257. title={data.isCollapsed ? '展开子节点' : '折叠子节点'}
  258. >
  259. {data.isCollapsed ? '+' : '−'}
  260. </div>
  261. )}
  262. {/* 卡片内容 */}
  263. <div>
  264. {/* 标题行 */}
  265. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px', paddingLeft: '24px', paddingRight: data.hasChildren ? '54px' : '28px' }}>
  266. <div style={{ flex: 1 }}>
  267. <div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '3px' }}>
  268. <div style={{
  269. fontSize: '13px',
  270. fontWeight: data.level === 0 ? '700' : '600',
  271. color: data.level === 0 ? '#6b21a8' : '#1f2937',
  272. lineHeight: '1.3',
  273. flex: 1,
  274. }}>
  275. {data.title}
  276. </div>
  277. {data.isSelected === false && (
  278. <div style={{
  279. fontSize: '9px',
  280. padding: '1px 4px',
  281. borderRadius: '3px',
  282. background: '#fee2e2',
  283. color: '#991b1b',
  284. fontWeight: '500',
  285. flexShrink: 0,
  286. }}>
  287. 未选中
  288. </div>
  289. )}
  290. </div>
  291. </div>
  292. </div>
  293. {/* 展开的详细信息 - 始终显示 */}
  294. <div style={{ fontSize: '11px', lineHeight: 1.4 }}>
  295. <div style={{ display: 'flex', gap: '4px', marginBottom: '6px', flexWrap: 'wrap' }}>
  296. <span style={{
  297. display: 'inline-block',
  298. padding: '1px 6px',
  299. borderRadius: '10px',
  300. background: '#eff6ff',
  301. color: '#3b82f6',
  302. fontSize: '10px',
  303. fontWeight: '500',
  304. }}>
  305. Lv.{data.level}
  306. </span>
  307. <span style={{
  308. display: 'inline-block',
  309. padding: '1px 6px',
  310. borderRadius: '10px',
  311. background: '#f0fdf4',
  312. color: '#16a34a',
  313. fontSize: '10px',
  314. fontWeight: '500',
  315. }}>
  316. {data.score}
  317. </span>
  318. {data.strategy && data.strategy !== 'root' && (
  319. <span style={{
  320. display: 'inline-block',
  321. padding: '1px 6px',
  322. borderRadius: '10px',
  323. background: '#fef3c7',
  324. color: '#92400e',
  325. fontSize: '10px',
  326. fontWeight: '500',
  327. }}>
  328. {data.strategy}
  329. </span>
  330. )}
  331. {(data.typeLabel || data.type_label) && (
  332. <span style={{
  333. display: 'inline-block',
  334. padding: '1px 6px',
  335. borderRadius: '10px',
  336. background: '#fce7f3',
  337. color: '#9f1239',
  338. fontSize: '10px',
  339. fontWeight: '500',
  340. }}>
  341. {data.typeLabel || data.type_label}
  342. </span>
  343. )}
  344. {data.is_suggestion && data.suggestion_label && (
  345. <span style={{
  346. display: 'inline-block',
  347. padding: '1px 6px',
  348. borderRadius: '10px',
  349. background: '#ede9fe',
  350. color: '#6d28d9',
  351. fontSize: '10px',
  352. fontWeight: '600',
  353. }}>
  354. {data.suggestion_label}
  355. </span>
  356. )}
  357. </div>
  358. {data.parent && (
  359. <div style={{ color: '#6b7280', fontSize: '10px', marginTop: '4px', paddingTop: '4px', borderTop: '1px solid #f3f4f6' }}>
  360. <strong>Parent:</strong> {data.parent}
  361. </div>
  362. )}
  363. {data.nodeType === 'domain_combination' && Array.isArray(data.source_word_details) && data.source_word_details.length > 0 && (
  364. <div style={{
  365. marginTop: '6px',
  366. paddingTop: '6px',
  367. borderTop: '1px solid #f3f4f6',
  368. fontSize: '10px',
  369. color: '#6b7280',
  370. lineHeight: '1.5',
  371. }}>
  372. <strong style={{ color: '#4b5563' }}>来源词得分:</strong>
  373. <div style={{ marginTop: '4px', display: 'flex', flexDirection: 'column', gap: '4px' }}>
  374. {data.source_word_details.map((detail, idx) => {
  375. const words = (detail.words || []).map((w) => {
  376. const numericScore = typeof w.score === 'number' ? w.score : parseFloat(w.score || '0');
  377. const formattedScore = Number.isFinite(numericScore) ? numericScore.toFixed(2) : '0.00';
  378. return w.text + ' (' + formattedScore + ')';
  379. }).join(' + ');
  380. return (
  381. <div key={idx} style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', alignItems: 'center' }}>
  382. <span style={{ color: '#2563eb' }}>{words}</span>
  383. </div>
  384. );
  385. })}
  386. </div>
  387. <div style={{ marginTop: '4px', fontWeight: '500', color: data.is_above_sources ? '#16a34a' : '#dc2626' }}>
  388. {data.is_above_sources ? '✅ 组合得分高于所有来源词' : '⚠️ 组合得分未超过全部来源词'}
  389. </div>
  390. </div>
  391. )}
  392. {data.selectedWord && (
  393. <div style={{
  394. marginTop: '6px',
  395. paddingTop: '6px',
  396. borderTop: '1px solid #f3f4f6',
  397. fontSize: '10px',
  398. color: '#6b7280',
  399. lineHeight: '1.5',
  400. }}>
  401. <strong style={{ color: '#4b5563' }}>选择词:</strong>
  402. <span style={{ marginLeft: '4px', color: '#3b82f6', fontWeight: '500' }}>{data.selectedWord}</span>
  403. {data.seed_score !== undefined && (
  404. <div style={{ marginTop: '4px' }}>
  405. <strong style={{ color: '#4b5563' }}>种子得分:</strong>
  406. <span style={{ marginLeft: '4px', color: '#16a34a', fontWeight: '500' }}>
  407. {typeof data.seed_score === 'number' ? data.seed_score.toFixed(2) : data.seed_score}
  408. </span>
  409. </div>
  410. )}
  411. </div>
  412. )}
  413. {data.evaluationReason && (
  414. <div style={{
  415. marginTop: '6px',
  416. paddingTop: '6px',
  417. borderTop: '1px solid #f3f4f6',
  418. fontSize: '10px',
  419. color: '#6b7280',
  420. lineHeight: '1.5',
  421. }}>
  422. <strong style={{ color: '#4b5563' }}>评估:</strong>
  423. <div style={{ marginTop: '2px' }}>{data.evaluationReason}</div>
  424. </div>
  425. )}
  426. {data.occurrences && data.occurrences.length > 1 && (
  427. <div style={{
  428. marginTop: '6px',
  429. paddingTop: '6px',
  430. borderTop: '1px solid #f3f4f6',
  431. fontSize: '10px',
  432. color: '#6b7280',
  433. }}>
  434. <strong style={{ color: '#4b5563' }}>演化历史 ({data.occurrences.length}次):</strong>
  435. <div style={{ marginTop: '4px' }}>
  436. {data.occurrences.map((occ, idx) => (
  437. <div key={idx} style={{ marginTop: '2px', paddingLeft: '8px' }}>
  438. <span style={{ color: '#3b82f6', fontWeight: '500' }}>R{occ.round}</span>
  439. {' · '}
  440. <span>{occ.strategy}</span>
  441. {occ.score !== undefined && (
  442. <span style={{ color: '#16a34a', marginLeft: '4px' }}>
  443. ({typeof occ.score === 'number' ? occ.score.toFixed(2) : occ.score})
  444. </span>
  445. )}
  446. </div>
  447. ))}
  448. </div>
  449. </div>
  450. )}
  451. {data.hasSearchResults && (
  452. <div style={{
  453. marginTop: '6px',
  454. paddingTop: '6px',
  455. borderTop: '1px solid #f3f4f6',
  456. fontSize: '10px',
  457. background: '#fef3c7',
  458. padding: '4px 6px',
  459. borderRadius: '4px',
  460. color: '#92400e',
  461. fontWeight: '500',
  462. }}>
  463. 🔍 找到 {data.postCount} 个帖子
  464. </div>
  465. )}
  466. </div>
  467. </div>
  468. </div>
  469. <Handle
  470. type="source"
  471. position={sourcePosition || Position.Right}
  472. style={{ background: '#667eea', width: 8, height: 8 }}
  473. />
  474. </div>
  475. );
  476. }
  477. // 笔记节点组件 - 卡片样式,带轮播图
  478. function NoteNode({ id, data, sourcePosition, targetPosition }) {
  479. const [currentImageIndex, setCurrentImageIndex] = useState(0);
  480. const [showEvalDetails, setShowEvalDetails] = useState(false);
  481. const expanded = true;
  482. const hasImages = data.imageList && data.imageList.length > 0;
  483. const nextImage = (e) => {
  484. e.stopPropagation();
  485. if (hasImages) {
  486. setCurrentImageIndex((prev) => (prev + 1) % data.imageList.length);
  487. }
  488. };
  489. const prevImage = (e) => {
  490. e.stopPropagation();
  491. if (hasImages) {
  492. setCurrentImageIndex((prev) => (prev - 1 + data.imageList.length) % data.imageList.length);
  493. }
  494. };
  495. const handleCardClick = (e) => {
  496. // 如果点击的是链接或按钮(或其子元素),不处理(避免双重触发)
  497. if (e.target.closest('a') || e.target.closest('button')) {
  498. return;
  499. }
  500. // 打开原帖链接
  501. if (data.note_url) {
  502. window.open(data.note_url, '_blank', 'noopener,noreferrer');
  503. }
  504. };
  505. return (
  506. <div>
  507. <Handle
  508. type="target"
  509. position={targetPosition || Position.Left}
  510. style={{ background: '#ec4899', width: 8, height: 8 }}
  511. />
  512. <div
  513. onClick={handleCardClick}
  514. style={{
  515. padding: '28px',
  516. borderRadius: '40px',
  517. border: data.isHighlighted ? '6px solid #ec4899' : '4px solid #fce7f3',
  518. background: data.isHighlighted ? '#eef2ff' : 'white',
  519. minWidth: '880px',
  520. maxWidth: '1200px',
  521. boxShadow: data.isHighlighted ? '0 0 0 8px rgba(236, 72, 153, 0.25), 0 8px 32px rgba(236, 72, 153, 0.4)' : '0 8px 24px rgba(236, 72, 153, 0.15)',
  522. transition: 'all 0.3s ease',
  523. cursor: 'pointer',
  524. }}
  525. >
  526. {/* 🆕 原始问题展示 - 最顶部 */}
  527. {data.originalQuestion && (
  528. <div style={{
  529. marginBottom: '20px',
  530. paddingBottom: '20px',
  531. borderBottom: '2px solid #fce7f3',
  532. }}>
  533. <div style={{
  534. fontSize: '36px',
  535. color: '#6b21a8',
  536. lineHeight: '1.4',
  537. fontWeight: '600',
  538. }}>
  539. <span style={{ fontWeight: '700' }}>[原始需求问题]</span> {data.originalQuestion}
  540. </div>
  541. </div>
  542. )}
  543. {/* 帖子标题 - 明确标注 */}
  544. <div style={{ marginBottom: '20px', paddingBottom: '16px', borderBottom: '2px solid #fce7f3' }}>
  545. <div style={{ fontSize: '38px', fontWeight: '600', color: '#831843', lineHeight: '1.4' }}>
  546. <span style={{ fontSize: '32px', color: '#831843', fontWeight: '500' }}>帖子标题: </span>
  547. {data.title.replace(/^\[R\]\s*/, '')}
  548. </div>
  549. </div>
  550. {/* V3评估信息 - 可展开 */}
  551. {data.evaluator_version === 'v3.0' && (
  552. <div style={{ marginBottom: '20px', paddingBottom: '16px', borderBottom: '2px solid #fce7f3' }}>
  553. {/* 第1行:知识判定 + 内容知识 + 星级 */}
  554. <div style={{ display: 'flex', alignItems: 'center', gap: '20px', marginBottom: '10px', flexWrap: 'wrap' }}>
  555. <span style={{ fontSize: '28px', fontWeight: '600', color: data.is_knowledge ? '#166534' : '#991b1b' }}>
  556. {data.is_knowledge ? '✓ 是知识' : '✗ 非知识'}
  557. </span>
  558. {data.is_content_knowledge !== null && data.is_content_knowledge !== undefined && (
  559. <>
  560. <span style={{ fontSize: '28px', fontWeight: '600', color: data.is_content_knowledge ? '#166534' : '#991b1b' }}>
  561. {data.is_content_knowledge ? '✓ 是内容知识' : '✗ 非内容知识'}
  562. </span>
  563. {data.is_content_knowledge && data.content_knowledge_evaluation?.knowledge_score != null && (
  564. <span style={{ fontSize: '24px', lineHeight: '1' }}>
  565. {'⭐'.repeat(Math.min(5, Math.ceil(data.content_knowledge_evaluation.knowledge_score / 20)))}
  566. </span>
  567. )}
  568. {data.is_content_knowledge && data.content_knowledge_evaluation?.knowledge_score != null && (
  569. <span style={{ fontSize: '26px', fontWeight: '600', color: '#166534' }}>
  570. {data.content_knowledge_evaluation.knowledge_score}分
  571. </span>
  572. )}
  573. </>
  574. )}
  575. </div>
  576. {/* 第2行:匹配度得分(仅内容知识显示) */}
  577. {data.is_content_knowledge && data.final_score !== null && data.final_score !== undefined && (
  578. <div style={{ display: 'flex', alignItems: 'center', gap: '16px', flexWrap: 'wrap', marginBottom: '12px' }}>
  579. <span style={{ fontSize: '32px', fontWeight: '700', color: data.final_score >= 60 ? '#166534' : '#ea580c' }}>
  580. 匹配度得分 {data.final_score.toFixed(1)}分
  581. </span>
  582. <span style={{
  583. padding: '4px 16px',
  584. borderRadius: '20px',
  585. fontSize: '26px',
  586. fontWeight: '600',
  587. background: data.final_score >= 85 ? '#dcfce7' : data.final_score >= 60 ? '#fef3c7' : '#fee2e2',
  588. color: data.final_score >= 85 ? '#166534' : data.final_score >= 60 ? '#854d0e' : '#991b1b'
  589. }}>
  590. {data.match_level}
  591. </span>
  592. {data.purpose_score != null && (
  593. <span style={{ fontSize: '26px', color: '#9f1239' }}>
  594. 目的{data.purpose_score}分
  595. </span>
  596. )}
  597. {data.category_score != null && (
  598. <span style={{ fontSize: '26px', color: '#9f1239' }}>
  599. 品类{data.category_score}分
  600. </span>
  601. )}
  602. </div>
  603. )}
  604. {/* 展开按钮(所有V3评估都显示) */}
  605. {data.evaluator_version === 'v3.0' && (
  606. <div style={{ marginBottom: '12px' }}>
  607. <button
  608. onClick={(e) => { e.stopPropagation(); setShowEvalDetails(!showEvalDetails); }}
  609. style={{
  610. fontSize: '24px',
  611. padding: '6px 16px',
  612. borderRadius: '12px',
  613. border: '2px solid #ec4899',
  614. background: 'white',
  615. color: '#ec4899',
  616. cursor: 'pointer',
  617. fontWeight: '600',
  618. transition: 'all 0.2s'
  619. }}
  620. >
  621. {showEvalDetails ? '收起详情 ▲' : '展开详情 ▼'}
  622. </button>
  623. </div>
  624. )}
  625. {/* 详细内容(展开后显示) */}
  626. {showEvalDetails && (
  627. <div style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid #f3f4f6' }}>
  628. {/* 1. 知识评估 */}
  629. {data.is_knowledge !== null && (
  630. <div style={{ marginBottom: '16px', padding: '12px', background: '#fafafa', borderRadius: '8px' }}>
  631. <div style={{ fontSize: '36px', fontWeight: '600', color: '#831843', marginBottom: '8px' }}>
  632. 1️⃣ 知识评估
  633. </div>
  634. <div style={{ fontSize: '34px', color: '#9f1239', lineHeight: '1.4' }}>
  635. {data.knowledge_evaluation?.conclusion || '无评估信息'}
  636. </div>
  637. </div>
  638. )}
  639. {/* 2. 内容知识评估 */}
  640. {data.is_content_knowledge && data.content_knowledge_evaluation && (
  641. <div style={{ marginBottom: '16px', padding: '12px', background: '#fafafa', borderRadius: '8px' }}>
  642. <div style={{ fontSize: '36px', fontWeight: '600', color: '#831843', marginBottom: '8px' }}>
  643. 2️⃣ 内容知识评估 ({data.knowledge_score || 0}分)
  644. </div>
  645. <div style={{ fontSize: '34px', color: '#9f1239', lineHeight: '1.4' }}>
  646. {data.content_knowledge_evaluation.summary || '无评估信息'}
  647. </div>
  648. </div>
  649. )}
  650. {/* 3. 与原始需求匹配 */}
  651. {(data.purpose_evaluation || data.category_evaluation) && (
  652. <div style={{ marginBottom: '16px', padding: '12px', background: '#fafafa', borderRadius: '8px' }}>
  653. <div style={{ fontSize: '36px', fontWeight: '600', color: '#831843', marginBottom: '8px' }}>
  654. 3️⃣ 与原始需求匹配
  655. </div>
  656. {data.purpose_evaluation && (
  657. <div style={{ fontSize: '34px', color: '#9f1239', lineHeight: '1.4', marginBottom: '12px' }}>
  658. <div style={{ fontWeight: '600', marginBottom: '6px' }}>
  659. 目的性匹配({data.purpose_score}分)
  660. </div>
  661. <div>{data.purpose_evaluation.core_basis || '无评估信息'}</div>
  662. </div>
  663. )}
  664. {data.category_evaluation && (
  665. <div style={{ fontSize: '34px', color: '#9f1239', lineHeight: '1.4' }}>
  666. <div style={{ fontWeight: '600', marginBottom: '6px' }}>
  667. 品类匹配({data.category_score}分)
  668. </div>
  669. <div>{data.category_evaluation.core_basis || '无评估信息'}</div>
  670. </div>
  671. )}
  672. </div>
  673. )}
  674. </div>
  675. )}
  676. </div>
  677. )}
  678. {/* V2评估信息 - 兼容旧数据 */}
  679. {data.evaluator_version !== 'v3.0' && (data.knowledge_score !== undefined || data.post_relevance_score !== undefined || data.is_knowledge !== undefined) && (
  680. <div style={{
  681. marginBottom: '20px',
  682. paddingBottom: '16px',
  683. borderBottom: '2px solid #fce7f3',
  684. }}>
  685. {/* 知识评估 (V2) */}
  686. {(data.knowledge_score !== undefined || data.is_knowledge !== undefined) && (
  687. <div style={{ marginBottom: '16px' }}>
  688. <div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '8px' }}>
  689. {data.knowledge_level && (
  690. <span style={{ fontSize: '24px', lineHeight: '1' }}>
  691. {'⭐'.repeat(data.knowledge_level)}
  692. </span>
  693. )}
  694. {data.knowledge_score != null && (
  695. <span style={{
  696. fontSize: '34px',
  697. fontWeight: '700',
  698. color: data.knowledge_score >= 70 ? '#166534' : data.knowledge_score >= 40 ? '#854d0e' : '#991b1b',
  699. }}>
  700. 知识: {data.knowledge_score.toFixed(0)}分
  701. </span>
  702. )}
  703. {!data.knowledge_score && data.is_knowledge !== undefined && (
  704. <span style={{
  705. display: 'inline-block',
  706. padding: '6px 20px',
  707. borderRadius: '24px',
  708. fontSize: '34px',
  709. fontWeight: '600',
  710. background: data.is_knowledge ? '#dcfce7' : '#fee2e2',
  711. color: data.is_knowledge ? '#166534' : '#991b1b',
  712. }}>
  713. {data.is_knowledge ? '✓ 知识' : '✗ 非知识'}
  714. </span>
  715. )}
  716. </div>
  717. {data.knowledge_evaluation?.summary && (
  718. <div style={{ fontSize: '30px', color: '#9f1239', lineHeight: '1.4', marginTop: '8px' }}>
  719. {data.knowledge_evaluation.summary}
  720. </div>
  721. )}
  722. {!data.knowledge_evaluation?.summary && data.knowledge_reason && (
  723. <div style={{ fontSize: '30px', color: '#9f1239', lineHeight: '1.4', marginTop: '8px' }}>
  724. {data.knowledge_reason}
  725. </div>
  726. )}
  727. </div>
  728. )}
  729. {/* 相关性评估 (V2) */}
  730. {data.post_relevance_score != null && (
  731. <div>
  732. <div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
  733. <span style={{ fontSize: '34px', fontWeight: '600', color: '#9f1239' }}>
  734. 相关性: {data.post_relevance_score.toFixed(0)}分
  735. </span>
  736. {data.relevance_conclusion && (
  737. <span style={{
  738. padding: '4px 16px',
  739. borderRadius: '20px',
  740. fontSize: '30px',
  741. fontWeight: '600',
  742. background: data.relevance_conclusion.includes('高度') ? '#dcfce7' : data.relevance_conclusion.includes('中度') ? '#fef3c7' : '#fee2e2',
  743. color: data.relevance_conclusion.includes('高度') ? '#166534' : data.relevance_conclusion.includes('中度') ? '#854d0e' : '#991b1b',
  744. }}>
  745. {data.relevance_conclusion}
  746. </span>
  747. )}
  748. </div>
  749. {data.relevance_evaluation?.summary && (
  750. <div style={{ fontSize: '30px', color: '#9f1239', lineHeight: '1.4' }}>
  751. {data.relevance_evaluation.summary}
  752. </div>
  753. )}
  754. {data.relevance_evaluation?.purpose_score != null && data.relevance_evaluation?.category_score != null && (
  755. <div style={{ fontSize: '28px', color: '#9f1239', marginTop: '6px', opacity: 0.8 }}>
  756. 目的性:{data.relevance_evaluation.purpose_score.toFixed(0)}分(70%) |
  757. 品类:{data.relevance_evaluation.category_score.toFixed(0)}分(30%)
  758. </div>
  759. )}
  760. </div>
  761. )}
  762. </div>
  763. )}
  764. {/* 轮播图 */}
  765. {hasImages && (
  766. <div style={{
  767. position: 'relative',
  768. marginBottom: '16px',
  769. borderRadius: '24px',
  770. overflow: 'hidden',
  771. }}>
  772. <img
  773. src={data.imageList[currentImageIndex].image_url}
  774. alt={\`Image \${currentImageIndex + 1}\`}
  775. style={{
  776. width: '100%',
  777. height: 'auto',
  778. objectFit: 'contain',
  779. display: 'block',
  780. }}
  781. onError={(e) => {
  782. e.target.style.display = 'none';
  783. }}
  784. />
  785. {data.imageList.length > 1 && (
  786. <>
  787. {/* 左右切换按钮 */}
  788. <button
  789. onClick={prevImage}
  790. style={{
  791. position: 'absolute',
  792. left: '8px',
  793. top: '800px',
  794. background: 'rgba(0, 0, 0, 0.5)',
  795. color: 'white',
  796. border: 'none',
  797. borderRadius: '50%',
  798. width: '72px',
  799. height: '72px',
  800. cursor: 'pointer',
  801. display: 'flex',
  802. alignItems: 'center',
  803. justifyContent: 'center',
  804. fontSize: '40px',
  805. }}
  806. >
  807. </button>
  808. <button
  809. onClick={nextImage}
  810. style={{
  811. position: 'absolute',
  812. right: '8px',
  813. top: '800px',
  814. background: 'rgba(0, 0, 0, 0.5)',
  815. color: 'white',
  816. border: 'none',
  817. borderRadius: '50%',
  818. width: '72px',
  819. height: '72px',
  820. cursor: 'pointer',
  821. display: 'flex',
  822. alignItems: 'center',
  823. justifyContent: 'center',
  824. fontSize: '40px',
  825. }}
  826. >
  827. </button>
  828. {/* 图片计数 */}
  829. <div style={{
  830. position: 'absolute',
  831. bottom: '8px',
  832. right: '8px',
  833. background: 'rgba(0, 0, 0, 0.6)',
  834. color: 'white',
  835. padding: '4px 12px',
  836. borderRadius: '20px',
  837. fontSize: '20px',
  838. }}>
  839. {currentImageIndex + 1}/{data.imageList.length}
  840. </div>
  841. </>
  842. )}
  843. </div>
  844. )}
  845. {/* 互动数据 */}
  846. {data.interact_info && (
  847. <div style={{
  848. display: 'flex',
  849. gap: '16px',
  850. marginBottom: '16px',
  851. flexWrap: 'wrap',
  852. fontSize: '22px',
  853. color: '#9f1239',
  854. }}>
  855. {data.interact_info.liked_count > 0 && (
  856. <span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
  857. ❤️ {data.interact_info.liked_count}
  858. </span>
  859. )}
  860. {data.interact_info.collected_count > 0 && (
  861. <span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
  862. ⭐ {data.interact_info.collected_count}
  863. </span>
  864. )}
  865. {data.interact_info.comment_count > 0 && (
  866. <span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
  867. 💬 {data.interact_info.comment_count}
  868. </span>
  869. )}
  870. {data.interact_info.shared_count > 0 && (
  871. <span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
  872. 🔗 {data.interact_info.shared_count}
  873. </span>
  874. )}
  875. </div>
  876. )}
  877. {/* 被哪些query找到 */}
  878. {data.foundByQueries && data.foundByQueries.length > 0 && (
  879. <div style={{
  880. marginBottom: '16px',
  881. padding: '12px 16px',
  882. background: '#f0fdf4',
  883. borderRadius: '12px',
  884. fontSize: '20px',
  885. }}>
  886. <strong style={{ color: '#16a34a' }}>🔍 被找到:</strong>
  887. <div style={{ marginTop: '8px', display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
  888. {data.foundByQueries.map((query, idx) => (
  889. <span key={idx} style={{
  890. display: 'inline-block',
  891. padding: '4px 12px',
  892. background: '#dcfce7',
  893. color: '#166534',
  894. borderRadius: '8px',
  895. fontSize: '18px',
  896. }}>
  897. {query}
  898. </span>
  899. ))}
  900. </div>
  901. {data.foundInRounds && data.foundInRounds.length > 0 && (
  902. <div style={{ marginTop: '8px', color: '#6b7280' }}>
  903. 出现在: Round {data.foundInRounds.join(', ')}
  904. </div>
  905. )}
  906. </div>
  907. )}
  908. {/* 标签 */}
  909. {(data.matchLevel || data.score) && (
  910. <div style={{ display: 'flex', gap: '12px', marginBottom: '16px', flexWrap: 'wrap' }}>
  911. {data.matchLevel && (
  912. <span style={{
  913. display: 'inline-block',
  914. padding: '4px 16px',
  915. borderRadius: '24px',
  916. background: '#fff1f2',
  917. color: '#be123c',
  918. fontSize: '20px',
  919. fontWeight: '500',
  920. }}>
  921. {data.matchLevel}
  922. </span>
  923. )}
  924. {/* Score标签已隐藏 - V2不再需要 */}
  925. </div>
  926. )}
  927. {/* 描述 */}
  928. {expanded && data.description && (
  929. <div style={{
  930. fontSize: '22px',
  931. color: '#9f1239',
  932. lineHeight: '1.5',
  933. paddingTop: '16px',
  934. borderTop: '2px solid #fbcfe8',
  935. }}>
  936. {data.description}
  937. </div>
  938. )}
  939. {/* 评估理由 */}
  940. {expanded && data.evaluationReason && (
  941. <div style={{
  942. fontSize: '20px',
  943. color: '#831843',
  944. lineHeight: '1.5',
  945. paddingTop: '16px',
  946. marginTop: '16px',
  947. borderTop: '2px solid #fbcfe8',
  948. }}>
  949. <strong style={{ color: '#9f1239' }}>评估:</strong>
  950. <div style={{ marginTop: '4px' }}>{data.evaluationReason}</div>
  951. </div>
  952. )}
  953. </div>
  954. <Handle
  955. type="source"
  956. position={sourcePosition || Position.Right}
  957. style={{ background: '#ec4899', width: 8, height: 8 }}
  958. />
  959. </div>
  960. );
  961. }
  962. // AnalysisNode 组件:展示AI分析(左侧OCR文字,右侧缩略图+描述)
  963. function AnalysisNode({ data }) {
  964. const nodeStyle = {
  965. background: '#fffbeb',
  966. border: '2px solid #fbbf24',
  967. borderRadius: '8px',
  968. padding: '12px',
  969. minWidth: '700px',
  970. maxWidth: '900px',
  971. fontSize: '12px',
  972. boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
  973. cursor: 'pointer',
  974. };
  975. const handleCardClick = (e) => {
  976. // 如果点击的是链接或按钮(或其子元素),不处理(避免双重触发)
  977. if (e.target.closest('a') || e.target.closest('button')) {
  978. return;
  979. }
  980. // 打开原帖链接
  981. if (data.note_url) {
  982. window.open(data.note_url, '_blank', 'noopener,noreferrer');
  983. }
  984. };
  985. return (
  986. <div style={nodeStyle} onClick={handleCardClick}>
  987. <Handle
  988. type="target"
  989. position={Position.Left}
  990. style={{ background: '#fbbf24', width: 8, height: 8 }}
  991. />
  992. {/* 标题 */}
  993. <div style={{
  994. fontSize: '14px',
  995. fontWeight: 'bold',
  996. marginBottom: '8px',
  997. color: '#92400e',
  998. }}>
  999. 🖼️ {data.query}
  1000. </div>
  1001. {/* 评分和互动数据 */}
  1002. <div style={{
  1003. display: 'flex',
  1004. justifyContent: 'space-between',
  1005. marginBottom: '8px',
  1006. padding: '6px',
  1007. background: '#fef3c7',
  1008. borderRadius: '4px',
  1009. }}>
  1010. <div style={{ fontSize: '11px', fontWeight: 'bold' }}>
  1011. Score: {data.interact_info?.relevance_score || 0}
  1012. </div>
  1013. <div style={{ display: 'flex', gap: '12px', fontSize: '11px' }}>
  1014. {data.interact_info?.liked_count > 0 && (
  1015. <span>❤️ {data.interact_info.liked_count}</span>
  1016. )}
  1017. {data.interact_info?.collected_count > 0 && (
  1018. <span>⭐ {data.interact_info.collected_count}</span>
  1019. )}
  1020. {data.interact_info?.comment_count > 0 && (
  1021. <span>💬 {data.interact_info.comment_count}</span>
  1022. )}
  1023. </div>
  1024. </div>
  1025. {/* 完整正文内容 */}
  1026. {data.body_text && (
  1027. <div style={{
  1028. padding: '8px',
  1029. background: 'white',
  1030. borderRadius: '4px',
  1031. marginBottom: '12px',
  1032. fontSize: '11px',
  1033. lineHeight: '1.5',
  1034. border: '1px solid #fbbf24',
  1035. whiteSpace: 'pre-wrap',
  1036. wordBreak: 'break-word',
  1037. }}>
  1038. {data.body_text}
  1039. </div>
  1040. )}
  1041. {/* AI分析 - 左右分栏 */}
  1042. {data.extraction && data.extraction.images && (
  1043. <div style={{
  1044. display: 'flex',
  1045. flexDirection: 'column',
  1046. gap: '12px',
  1047. }}>
  1048. {data.extraction.images.map((img, idx) => (
  1049. <div
  1050. key={idx}
  1051. style={{
  1052. display: 'flex',
  1053. flexDirection: 'row',
  1054. gap: '16px',
  1055. padding: '10px',
  1056. background: 'white',
  1057. borderRadius: '4px',
  1058. border: '1px solid #d97706',
  1059. alignItems: 'flex-start',
  1060. }}
  1061. >
  1062. {/* 左侧:OCR提取文字 */}
  1063. <div style={{
  1064. flex: '1', // 1/3宽度
  1065. minWidth: '0',
  1066. }}>
  1067. <div style={{
  1068. fontSize: '11px',
  1069. fontWeight: 'bold',
  1070. color: '#92400e',
  1071. marginBottom: '6px',
  1072. }}>
  1073. 📝 图片 {idx + 1}/{data.extraction.images.length}
  1074. </div>
  1075. {img.extract_text && (
  1076. <div style={{
  1077. fontSize: '11px',
  1078. color: '#1f2937',
  1079. lineHeight: '1.6',
  1080. padding: '8px',
  1081. background: '#fef9e7',
  1082. borderRadius: '3px',
  1083. borderLeft: '3px solid #f39c12',
  1084. wordBreak: 'break-word',
  1085. }}>
  1086. <div style={{
  1087. fontSize: '10px',
  1088. fontWeight: 'bold',
  1089. color: '#d97706',
  1090. marginBottom: '4px',
  1091. }}>
  1092. 【提取文字】
  1093. </div>
  1094. {img.extract_text}
  1095. </div>
  1096. )}
  1097. </div>
  1098. {/* 右侧:缩略图 + 描述 */}
  1099. <div style={{
  1100. flex: '2', // 2/3宽度
  1101. display: 'flex',
  1102. flexDirection: 'column',
  1103. gap: '8px',
  1104. minWidth: '200px',
  1105. }}>
  1106. {/* 缩略图 */}
  1107. {data.image_list && data.image_list[idx] && (
  1108. <img
  1109. src={(data.image_list[idx].image_url || data.image_list[idx])}
  1110. alt={'图片' + (idx + 1)}
  1111. style={{
  1112. width: '100%',
  1113. height: 'auto',
  1114. maxHeight: '180px',
  1115. objectFit: 'contain',
  1116. borderRadius: '4px',
  1117. border: '1px solid #d97706',
  1118. cursor: 'pointer',
  1119. }}
  1120. onError={(e) => {
  1121. e.target.style.display = 'none';
  1122. }}
  1123. />
  1124. )}
  1125. {/* 描述文字(完整展示) */}
  1126. {img.description && (
  1127. <div
  1128. style={{
  1129. fontSize: '10px',
  1130. color: '#78350f',
  1131. lineHeight: '1.5',
  1132. wordBreak: 'break-word',
  1133. padding: '8px',
  1134. background: '#fef9e7',
  1135. borderRadius: '3px',
  1136. border: '1px solid #f39c12',
  1137. }}
  1138. >
  1139. <div style={{
  1140. fontSize: '9px',
  1141. fontWeight: 'bold',
  1142. color: '#d97706',
  1143. marginBottom: '4px',
  1144. }}>
  1145. 【图片描述】
  1146. </div>
  1147. {img.description}
  1148. </div>
  1149. )}
  1150. </div>
  1151. </div>
  1152. ))}
  1153. </div>
  1154. )}
  1155. {/* 查看原帖链接 */}
  1156. {data.note_url && (
  1157. <div style={{ marginTop: '8px', fontSize: '10px' }}>
  1158. <a
  1159. href={data.note_url}
  1160. target="_blank"
  1161. rel="noopener noreferrer"
  1162. style={{ color: '#92400e', textDecoration: 'underline' }}
  1163. >
  1164. 🔗 查看原帖
  1165. </a>
  1166. </div>
  1167. )}
  1168. <Handle
  1169. type="source"
  1170. position={Position.Right}
  1171. style={{ background: '#fbbf24', width: 8, height: 8 }}
  1172. />
  1173. </div>
  1174. );
  1175. }
  1176. const nodeTypes = {
  1177. query: QueryNode,
  1178. note: NoteNode,
  1179. post: NoteNode, // 帖子节点使用 NoteNode 组件渲染
  1180. analysis: AnalysisNode,
  1181. };
  1182. // 根据 score 获取颜色
  1183. function getScoreColor(score) {
  1184. if (score >= 0.7) return '#10b981'; // 绿色 - 高分
  1185. if (score >= 0.4) return '#f59e0b'; // 橙色 - 中分
  1186. return '#ef4444'; // 红色 - 低分
  1187. }
  1188. // 截断文本,保留头尾,中间显示省略号
  1189. function truncateMiddle(text, maxLength = 20) {
  1190. if (!text || text.length <= maxLength) return text;
  1191. const headLength = Math.ceil(maxLength * 0.4);
  1192. const tailLength = Math.floor(maxLength * 0.4);
  1193. const head = text.substring(0, headLength);
  1194. const tail = text.substring(text.length - tailLength);
  1195. return \`\${head}...\${tail}\`;
  1196. }
  1197. // 根据策略获取颜色
  1198. // 智能提取主要策略的辅助函数
  1199. function getPrimaryStrategy(nodeData) {
  1200. // 优先级1: 使用 primaryStrategy 字段
  1201. if (nodeData.primaryStrategy) {
  1202. return nodeData.primaryStrategy;
  1203. }
  1204. // 优先级2: 从 occurrences 数组中获取最新的策略
  1205. if (nodeData.occurrences && Array.isArray(nodeData.occurrences) && nodeData.occurrences.length > 0) {
  1206. const latestOccurrence = nodeData.occurrences[nodeData.occurrences.length - 1];
  1207. if (latestOccurrence && latestOccurrence.strategy) {
  1208. return latestOccurrence.strategy;
  1209. }
  1210. }
  1211. // 优先级3: 拆分组合策略字符串,取第一个
  1212. if (nodeData.strategy && typeof nodeData.strategy === 'string') {
  1213. const strategies = nodeData.strategy.split(' + ');
  1214. if (strategies.length > 0 && strategies[0]) {
  1215. return strategies[0].trim();
  1216. }
  1217. }
  1218. // 默认返回原始strategy或未知
  1219. return nodeData.strategy || '未知';
  1220. }
  1221. function getStrategyColor(strategy) {
  1222. const strategyColors = {
  1223. '初始分词': '#10b981',
  1224. '调用sug': '#06b6d4',
  1225. '同义改写': '#f59e0b',
  1226. '加词': '#3b82f6',
  1227. '抽象改写': '#8b5cf6',
  1228. '基于部分匹配改进': '#ec4899',
  1229. '结果分支-抽象改写': '#a855f7',
  1230. '结果分支-同义改写': '#fb923c',
  1231. // v6.1.2.8 新增策略
  1232. '原始问题': '#6b21a8',
  1233. '来自分词': '#10b981',
  1234. '加词生成': '#ef4444',
  1235. '建议词': '#06b6d4',
  1236. '执行搜索': '#8b5cf6',
  1237. // 添加简化版本的策略映射
  1238. '分词': '#10b981',
  1239. '推荐词': '#06b6d4',
  1240. };
  1241. return strategyColors[strategy] || '#9ca3af';
  1242. }
  1243. // 树节点组件
  1244. function TreeNode({ node, level, children, isCollapsed, onToggle, isSelected, onSelect }) {
  1245. const hasChildren = children && children.length > 0;
  1246. const score = node.data.score ? parseFloat(node.data.score) : 0;
  1247. const strategy = getPrimaryStrategy(node.data); // 使用智能提取函数
  1248. const strategyColor = getStrategyColor(strategy);
  1249. const nodeActualType = node.data.nodeType || node.type; // 获取实际节点类型
  1250. const isDomainCombination = nodeActualType === 'domain_combination';
  1251. let sourceSummary = '';
  1252. if (isDomainCombination && Array.isArray(node.data.source_word_details) && node.data.source_word_details.length > 0) {
  1253. const summaryParts = [];
  1254. node.data.source_word_details.forEach((detail) => {
  1255. const words = Array.isArray(detail.words) ? detail.words : [];
  1256. const wordTexts = [];
  1257. words.forEach((w) => {
  1258. const numericScore = typeof w.score === 'number' ? w.score : parseFloat(w.score || '0');
  1259. const formattedScore = Number.isFinite(numericScore) ? numericScore.toFixed(2) : '0.00';
  1260. wordTexts.push(w.text + ' (' + formattedScore + ')');
  1261. });
  1262. if (wordTexts.length > 0) {
  1263. const segmentLabel = detail.segment_type ? '[' + detail.segment_type + '] ' : '';
  1264. summaryParts.push(segmentLabel + wordTexts.join(' + '));
  1265. }
  1266. });
  1267. sourceSummary = summaryParts.join(' | ');
  1268. }
  1269. // 计算字体颜色:根据分数提升幅度判断
  1270. let fontColor = '#374151'; // 默认颜色
  1271. if (node.type === 'note') {
  1272. const evaluatorVersion = node.data.evaluator_version || '';
  1273. if (evaluatorVersion === 'v3.0') {
  1274. // V3评估:基于is_knowledge, is_content_knowledge和final_score判断颜色
  1275. const isKnowledge = node.data.is_knowledge;
  1276. const isContentKnowledge = node.data.is_content_knowledge;
  1277. const finalScore = node.data.final_score;
  1278. if (!isKnowledge || !isContentKnowledge) {
  1279. fontColor = '#ef4444'; // 红色 - 非知识或非内容知识
  1280. } else if (finalScore !== null && finalScore !== undefined) {
  1281. if (finalScore >= 60) {
  1282. fontColor = '#22c55e'; // 绿色 - 内容知识且高分
  1283. } else {
  1284. fontColor = '#eab308'; // 黄色 - 内容知识但分数偏低
  1285. }
  1286. }
  1287. } else {
  1288. // V2评估:基于知识得分和相关性得分判断颜色
  1289. const knowledgeScore = node.data.knowledge_score;
  1290. const relevanceScore = node.data.post_relevance_score;
  1291. if (knowledgeScore != null && relevanceScore != null) {
  1292. if (knowledgeScore <= 40) {
  1293. fontColor = '#ef4444'; // 红色 - 知识得分低
  1294. } else if (knowledgeScore > 40 && relevanceScore > 40) {
  1295. fontColor = '#22c55e'; // 绿色 - 知识和相关性都高
  1296. } else {
  1297. fontColor = '#eab308'; // 黄色 - 知识得分高但相关性低
  1298. }
  1299. } else {
  1300. // V1兼容:如果没有V2评估数据,使用matchLevel判断
  1301. fontColor = node.data.matchLevel === 'unsatisfied' ? '#ef4444' : '#374151';
  1302. }
  1303. }
  1304. } else if (node.data.seed_score !== undefined) {
  1305. const parentScore = parseFloat(node.data.seed_score);
  1306. const gain = score - parentScore;
  1307. fontColor = gain >= 0.05 ? '#16a34a' : '#ef4444';
  1308. } else if (node.data.isSelected === false) {
  1309. fontColor = '#ef4444';
  1310. }
  1311. return (
  1312. <div style={{ marginLeft: level * 12 + 'px', marginBottom: '8px' }}>
  1313. <div
  1314. style={{
  1315. padding: '6px 8px',
  1316. borderRadius: '4px',
  1317. cursor: 'pointer',
  1318. background: 'transparent',
  1319. border: isSelected ? '1px solid #3b82f6' : '1px solid transparent',
  1320. display: 'flex',
  1321. alignItems: 'center',
  1322. gap: '6px',
  1323. transition: 'all 0.2s ease',
  1324. position: 'relative',
  1325. overflow: 'visible',
  1326. }}
  1327. onMouseEnter={(e) => {
  1328. if (!isSelected) e.currentTarget.style.background = '#f9fafb';
  1329. }}
  1330. onMouseLeave={(e) => {
  1331. if (!isSelected) e.currentTarget.style.background = 'transparent';
  1332. }}
  1333. >
  1334. {/* 策略类型竖线 */}
  1335. <div style={{
  1336. width: '3px',
  1337. height: '20px',
  1338. background: strategyColor,
  1339. borderRadius: '2px',
  1340. flexShrink: 0,
  1341. position: 'relative',
  1342. zIndex: 1,
  1343. }} />
  1344. {hasChildren && (
  1345. <span
  1346. style={{
  1347. fontSize: '10px',
  1348. color: '#6b7280',
  1349. cursor: 'pointer',
  1350. width: '16px',
  1351. textAlign: 'center',
  1352. position: 'relative',
  1353. zIndex: 1,
  1354. }}
  1355. onClick={(e) => {
  1356. e.stopPropagation();
  1357. onToggle();
  1358. }}
  1359. >
  1360. {isCollapsed ? '▶' : '▼'}
  1361. </span>
  1362. )}
  1363. {!hasChildren && <span style={{ width: '16px', position: 'relative', zIndex: 1 }}></span>}
  1364. <div
  1365. style={{
  1366. flex: 1,
  1367. fontSize: '12px',
  1368. color: '#374151',
  1369. position: 'relative',
  1370. zIndex: 1,
  1371. minWidth: 0,
  1372. display: 'flex',
  1373. flexDirection: 'column',
  1374. gap: '4px',
  1375. }}
  1376. onClick={onSelect}
  1377. >
  1378. <div style={{
  1379. display: 'flex',
  1380. alignItems: 'center',
  1381. gap: '8px',
  1382. }}>
  1383. {/* 文本标题 - 左侧 */}
  1384. <div style={{
  1385. fontWeight: level === 0 ? '600' : '400',
  1386. flex: 1,
  1387. minWidth: 0,
  1388. color: node.data.scoreColor || fontColor,
  1389. overflow: 'hidden',
  1390. textOverflow: 'ellipsis',
  1391. whiteSpace: 'nowrap',
  1392. }}
  1393. title={node.data.title || node.id}
  1394. >
  1395. {node.data.title || node.id}
  1396. </div>
  1397. {/* 域标识 - 右侧,挨着分数,优先显示域类型,否则显示域索引或域字符串,但domain_combination节点不显示 */}
  1398. {(node.data.domain_type || node.data.domains_str || (node.data.domain_index !== null && node.data.domain_index !== undefined)) && nodeActualType !== 'domain_combination' && (
  1399. <span style={{
  1400. fontSize: '12px',
  1401. color: '#fff',
  1402. background: '#6366f1',
  1403. padding: '2px 5px',
  1404. borderRadius: '3px',
  1405. flexShrink: 0,
  1406. fontWeight: '600',
  1407. marginLeft: '4px',
  1408. }}
  1409. title={
  1410. node.data.domain_type ? '域: ' + node.data.domain_type + ' (D' + node.data.domain_index + ')' :
  1411. node.data.domains_str ? '域: ' + node.data.domains_str :
  1412. '域 D' + node.data.domain_index
  1413. }
  1414. >
  1415. {node.data.domain_type || node.data.domains_str || ('D' + node.data.domain_index)}
  1416. </span>
  1417. )}
  1418. {node.data.is_suggestion && node.data.suggestion_label && (
  1419. <span style={{
  1420. fontSize: '12px',
  1421. color: '#fff',
  1422. background: '#8b5cf6',
  1423. padding: '2px 5px',
  1424. borderRadius: '3px',
  1425. flexShrink: 0,
  1426. fontWeight: '600',
  1427. }}
  1428. >
  1429. {node.data.suggestion_label}
  1430. </span>
  1431. )}
  1432. {/* 类型标签 - 显示在右侧靠近分数,蓝色背景 */}
  1433. {node.data.type_label && (
  1434. <span style={{
  1435. fontSize: '12px',
  1436. color: '#fff',
  1437. background: '#6366f1',
  1438. padding: '2px 5px',
  1439. borderRadius: '3px',
  1440. flexShrink: 0,
  1441. fontWeight: '600',
  1442. }}
  1443. title={'类型: ' + node.data.type_label}
  1444. >
  1445. {node.data.type_label}
  1446. </span>
  1447. )}
  1448. {/* 分数显示 - 步骤和轮次节点不显示分数 */}
  1449. {nodeActualType !== 'step' && nodeActualType !== 'round' && (
  1450. <span style={{
  1451. fontSize: '11px',
  1452. color: '#6b7280',
  1453. fontWeight: '500',
  1454. flexShrink: 0,
  1455. minWidth: '35px',
  1456. textAlign: 'right',
  1457. }}>
  1458. {node.type === 'note' && node.data.evaluator_version === 'v3.0' && node.data.final_score !== null && node.data.final_score !== undefined
  1459. ? node.data.final_score.toFixed(1)
  1460. : score.toFixed(2)}
  1461. </span>
  1462. )}
  1463. </div>
  1464. {/* V3评估信息行 - 仅对note类型且有V3评估数据显示 */}
  1465. {node.type === 'note' && node.data.evaluator_version === 'v3.0' && (
  1466. <div style={{ fontSize: '10px', color: '#2563eb', marginTop: '2px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
  1467. <span style={{ fontWeight: '600', color: '#2563eb' }}>评估结论:</span>
  1468. <span style={{ color: node.data.is_knowledge ? '#16a34a' : '#dc2626', fontWeight: '500' }}>
  1469. {node.data.is_knowledge ? '✓ 是知识' : '✗ 非知识'}
  1470. </span>
  1471. {node.data.is_content_knowledge !== null && node.data.is_content_knowledge !== undefined && (
  1472. <span style={{ color: node.data.is_content_knowledge ? '#16a34a' : '#dc2626', fontWeight: '500' }}>
  1473. | {node.data.is_content_knowledge ? '✓ 是内容知识' : '✗ 非内容知识'}
  1474. </span>
  1475. )}
  1476. {node.data.is_content_knowledge && node.data.final_score !== null && node.data.final_score !== undefined && (
  1477. <>
  1478. <span style={{ fontWeight: '500', color: '#2563eb' }}>| {node.data.match_level}</span>
  1479. <span style={{ fontWeight: '600', color: node.data.final_score >= 60 ? '#16a34a' : '#ea580c' }}>
  1480. | {node.data.final_score.toFixed(1)}分
  1481. </span>
  1482. </>
  1483. )}
  1484. </div>
  1485. )}
  1486. {/* 域组合的来源词得分(树状视图,右对齐) */}
  1487. {isDomainCombination && sourceSummary && (
  1488. <div style={{
  1489. fontSize: '10px',
  1490. color: '#2563eb',
  1491. lineHeight: '1.4',
  1492. display: 'flex',
  1493. flexDirection: 'column',
  1494. alignItems: 'flex-end',
  1495. gap: '2px',
  1496. textAlign: 'right',
  1497. }}>
  1498. {node.data.source_word_details.map((detail, idx) => {
  1499. const words = Array.isArray(detail.words) ? detail.words : [];
  1500. const summary = words.map((w) => {
  1501. const numericScore = typeof w.score === 'number' ? w.score : parseFloat(w.score || '0');
  1502. const formattedScore = Number.isFinite(numericScore) ? numericScore.toFixed(2) : '0.00';
  1503. return w.text + ' (' + formattedScore + ')';
  1504. }).join(' + ');
  1505. return (
  1506. <span key={idx} title={summary}>
  1507. {summary}
  1508. </span>
  1509. );
  1510. })}
  1511. </div>
  1512. )}
  1513. {/* 分数下划线 - 步骤和轮次节点不显示 */}
  1514. {nodeActualType !== 'step' && nodeActualType !== 'round' && (
  1515. <div style={{
  1516. width: (score * 100) + '%',
  1517. height: '2px',
  1518. background: getScoreColor(score),
  1519. borderRadius: '1px',
  1520. }} />
  1521. )}
  1522. </div>
  1523. </div>
  1524. {hasChildren && !isCollapsed && (
  1525. <div>
  1526. {children}
  1527. </div>
  1528. )}
  1529. </div>
  1530. );
  1531. }
  1532. // 使用 dagre 自动布局
  1533. function getLayoutedElements(nodes, edges, direction = 'LR') {
  1534. console.log('🎯 Starting layout with dagre...');
  1535. console.log('Input:', nodes.length, 'nodes,', edges.length, 'edges');
  1536. // 检查 dagre 是否加载
  1537. if (typeof window === 'undefined' || typeof window.dagre === 'undefined') {
  1538. console.warn('⚠️ Dagre not loaded, using fallback layout');
  1539. // 降级到简单布局
  1540. const levelGroups = {};
  1541. nodes.forEach(node => {
  1542. const level = node.data.level || 0;
  1543. if (!levelGroups[level]) levelGroups[level] = [];
  1544. levelGroups[level].push(node);
  1545. });
  1546. Object.entries(levelGroups).forEach(([level, nodeList]) => {
  1547. const x = parseInt(level) * 480;
  1548. nodeList.forEach((node, index) => {
  1549. node.position = { x, y: index * 260 };
  1550. node.targetPosition = 'left';
  1551. node.sourcePosition = 'right';
  1552. });
  1553. });
  1554. return { nodes, edges };
  1555. }
  1556. try {
  1557. const dagreGraph = new window.dagre.graphlib.Graph();
  1558. dagreGraph.setDefaultEdgeLabel(() => ({}));
  1559. const isHorizontal = direction === 'LR';
  1560. dagreGraph.setGraph({
  1561. rankdir: direction,
  1562. nodesep: 800, // 垂直间距 - 增加以适应更高的note节点(卡片高度2600px + 800px间距)
  1563. ranksep: 400, // 水平间距 - 增加以容纳更宽的节点
  1564. });
  1565. // 添加节点 - 根据节点类型设置不同的尺寸
  1566. nodes.forEach((node) => {
  1567. let nodeWidth = 320;
  1568. let nodeHeight = 220;
  1569. // note 节点有轮播图,需要更大的空间
  1570. if (node.type === 'note') {
  1571. nodeWidth = 360;
  1572. nodeHeight = 2600; // 更新以适应完整内容:1:1图片(880px) + 标题/原始问题/评估(500px) + 正文/AI提取(最多1200px)
  1573. }
  1574. // analysis 节点内容很多,需要更大的空间
  1575. else if (node.type === 'analysis') {
  1576. nodeWidth = 900; // 宽度足够容纳左右分栏
  1577. nodeHeight = 600; // 高度足够容纳多张图片
  1578. }
  1579. dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
  1580. });
  1581. // 添加边
  1582. edges.forEach((edge) => {
  1583. dagreGraph.setEdge(edge.source, edge.target);
  1584. });
  1585. // 计算布局
  1586. window.dagre.layout(dagreGraph);
  1587. console.log('✅ Dagre layout completed');
  1588. // 更新节点位置和 handle 位置
  1589. nodes.forEach((node) => {
  1590. const nodeWithPosition = dagreGraph.node(node.id);
  1591. if (!nodeWithPosition) {
  1592. console.warn('Node position not found for:', node.id);
  1593. return;
  1594. }
  1595. node.targetPosition = isHorizontal ? 'left' : 'top';
  1596. node.sourcePosition = isHorizontal ? 'right' : 'bottom';
  1597. // 根据节点类型获取尺寸
  1598. let nodeWidth = 320;
  1599. let nodeHeight = 220;
  1600. if (node.type === 'note') {
  1601. nodeWidth = 360;
  1602. nodeHeight = 2600; // 与dagre布局参数保持一致
  1603. }
  1604. // 将 dagre 的中心点位置转换为 React Flow 的左上角位置
  1605. node.position = {
  1606. x: nodeWithPosition.x - nodeWidth / 2,
  1607. y: nodeWithPosition.y - nodeHeight / 2,
  1608. };
  1609. });
  1610. // 为同层级的 note 节点添加交错偏移,避免视觉重叠
  1611. console.log('=== 开始交错偏移逻辑 ===');
  1612. console.log('总节点数:', nodes.length);
  1613. const noteNodes = nodes.filter(n => n.type === 'note');
  1614. console.log('过滤后的 note 节点数:', noteNodes.length);
  1615. if (noteNodes.length > 1) {
  1616. // 输出排序前的位置
  1617. console.log('排序前的 note 节点位置:');
  1618. noteNodes.forEach((n, i) => {
  1619. console.log(' [' + i + '] ' + n.id.substring(0, 40) + '... | type=' + n.type + ' | pos=(' + n.position.x.toFixed(0) + ', ' + n.position.y.toFixed(0) + ')');
  1620. });
  1621. // 按 Y 坐标排序
  1622. noteNodes.sort((a, b) => a.position.y - b.position.y);
  1623. console.log('排序后的 note 节点位置:');
  1624. noteNodes.forEach((n, i) => {
  1625. console.log(' [' + i + '] ' + n.id.substring(0, 40) + '... | pos=(' + n.position.x.toFixed(0) + ', ' + n.position.y.toFixed(0) + ')');
  1626. });
  1627. // 为相邻的 note 节点添加 X 方向的交错(3个位置:左、中、右)
  1628. const baseX = noteNodes.length > 0 ? noteNodes[0].position.x : 0;
  1629. const leftX = baseX - 1500;
  1630. const centerX = baseX;
  1631. const rightX = baseX + 1500;
  1632. let appliedCount = 0;
  1633. noteNodes.forEach((node, index) => {
  1634. const oldX = node.position.x;
  1635. const position = index % 3;
  1636. if (position === 0) {
  1637. node.position.x = leftX;
  1638. console.log(' [' + index + '] 设置为左侧: X ' + oldX.toFixed(0) + ' → ' + node.position.x.toFixed(0));
  1639. } else if (position === 1) {
  1640. node.position.x = centerX;
  1641. console.log(' [' + index + '] 设置为中间: X ' + oldX.toFixed(0) + ' → ' + node.position.x.toFixed(0));
  1642. } else {
  1643. node.position.x = rightX;
  1644. console.log(' [' + index + '] 设置为右侧: X ' + oldX.toFixed(0) + ' → ' + node.position.x.toFixed(0));
  1645. }
  1646. appliedCount++;
  1647. });
  1648. console.log('总共应用了 ' + appliedCount + ' 次偏移');
  1649. } else {
  1650. console.log('note 节点数量 <= 1,不需要交错');
  1651. }
  1652. console.log('=== 交错偏移逻辑结束 ===');
  1653. console.log('✅ Layout completed, sample node:', nodes[0]);
  1654. return { nodes, edges };
  1655. } catch (error) {
  1656. console.error('❌ Error in dagre layout:', error);
  1657. console.error('Error details:', error.message, error.stack);
  1658. // 降级处理
  1659. console.log('Using fallback layout...');
  1660. const levelGroups = {};
  1661. nodes.forEach(node => {
  1662. const level = node.data.level || 0;
  1663. if (!levelGroups[level]) levelGroups[level] = [];
  1664. levelGroups[level].push(node);
  1665. });
  1666. Object.entries(levelGroups).forEach(([level, nodeList]) => {
  1667. const x = parseInt(level) * 480;
  1668. nodeList.forEach((node, index) => {
  1669. node.position = { x, y: index * 260 };
  1670. node.targetPosition = 'left';
  1671. node.sourcePosition = 'right';
  1672. });
  1673. });
  1674. return { nodes, edges };
  1675. }
  1676. }
  1677. function transformData(data) {
  1678. const nodes = [];
  1679. const edges = [];
  1680. const originalIdToCanvasId = {}; // 原始ID -> 画布ID的映射
  1681. const canvasIdToNodeData = {}; // 避免重复创建相同的节点
  1682. let analysisNodeCount = 0; // 用于给analysis节点添加X偏移
  1683. // 🆕 获取原始问题(从root节点)
  1684. const originalQuestion = data.nodes['root_o']?.query || '';
  1685. // 创建节点
  1686. Object.entries(data.nodes).forEach(([originalId, node]) => {
  1687. // 统一处理所有类型的节点
  1688. const nodeType = node.type || 'query';
  1689. // 直接使用originalId作为canvasId,避免冲突
  1690. const canvasId = originalId;
  1691. originalIdToCanvasId[originalId] = canvasId;
  1692. // 如果这个 canvasId 还没有创建过节点,则创建
  1693. if (!canvasIdToNodeData[canvasId]) {
  1694. canvasIdToNodeData[canvasId] = true;
  1695. // 根据节点类型创建不同的数据结构
  1696. if (nodeType === 'note' || nodeType === 'post') {
  1697. nodes.push({
  1698. id: canvasId,
  1699. originalId: originalId,
  1700. type: 'note',
  1701. data: {
  1702. title: node.query || node.title || '帖子',
  1703. matchLevel: node.match_level,
  1704. score: node.relevance_score ? node.relevance_score.toFixed(2) : '0.00',
  1705. description: node.body_text || node.desc || '',
  1706. isSelected: node.is_selected !== undefined ? node.is_selected : true,
  1707. imageList: node.image_list || [],
  1708. note_url: node.note_url || '',
  1709. evaluationReason: node.evaluationReason || node.evaluation_reason || '',
  1710. interact_info: node.interact_info || {},
  1711. nodeType: nodeType,
  1712. // 🆕 评估字段 (V2)
  1713. // 知识评估
  1714. is_knowledge: node.is_knowledge !== undefined ? node.is_knowledge : null,
  1715. knowledge_reason: node.knowledge_reason || '',
  1716. knowledge_score: node.knowledge_score !== undefined ? node.knowledge_score : null,
  1717. knowledge_level: node.knowledge_level !== undefined ? node.knowledge_level : null,
  1718. knowledge_evaluation: node.knowledge_evaluation || null,
  1719. // 相关性评估
  1720. post_relevance_score: node.post_relevance_score !== undefined ? node.post_relevance_score : null,
  1721. relevance_level: node.relevance_level || '',
  1722. relevance_reason: node.relevance_reason || '',
  1723. relevance_conclusion: node.relevance_conclusion || '',
  1724. relevance_evaluation: node.relevance_evaluation || null,
  1725. // 🆕 评估字段 (V3)
  1726. is_content_knowledge: node.is_content_knowledge !== undefined ? node.is_content_knowledge : null,
  1727. purpose_score: node.purpose_score !== undefined ? node.purpose_score : null,
  1728. category_score: node.category_score !== undefined ? node.category_score : null,
  1729. final_score: node.final_score !== undefined ? node.final_score : null,
  1730. match_level: node.match_level || '',
  1731. evaluator_version: node.evaluator_version || '',
  1732. content_knowledge_evaluation: node.content_knowledge_evaluation || null,
  1733. purpose_evaluation: node.purpose_evaluation || null,
  1734. category_evaluation: node.category_evaluation || null,
  1735. // 🆕 原始问题
  1736. originalQuestion: originalQuestion
  1737. },
  1738. position: { x: 0, y: 0 },
  1739. });
  1740. } else if (nodeType === 'analysis') {
  1741. // AI分析节点 - 添加X偏移避免叠加
  1742. const xOffset = analysisNodeCount * 150; // 每个节点偏移150px
  1743. analysisNodeCount++;
  1744. nodes.push({
  1745. id: canvasId,
  1746. originalId: originalId,
  1747. type: 'analysis',
  1748. data: {
  1749. query: node.query || '[AI分析]',
  1750. note_id: node.note_id,
  1751. note_url: node.note_url,
  1752. title: node.title || '',
  1753. body_text: node.body_text || '',
  1754. interact_info: node.interact_info || {},
  1755. extraction: node.extraction || null,
  1756. image_list: node.image_list || [],
  1757. },
  1758. position: { x: xOffset, y: 0 },
  1759. });
  1760. } else {
  1761. // query, seg, q, search, root 等节点
  1762. let displayTitle = node.query || originalId;
  1763. nodes.push({
  1764. id: canvasId,
  1765. originalId: originalId,
  1766. type: 'query', // 使用 query 组件渲染所有非note节点
  1767. data: {
  1768. title: displayTitle,
  1769. level: node.level || 0,
  1770. score: node.relevance_score ? node.relevance_score.toFixed(2) : '0.00',
  1771. strategy: node.strategy || '',
  1772. parent: node.parent_query || '',
  1773. isSelected: node.is_selected !== undefined ? node.is_selected : true,
  1774. evaluationReason: node.evaluationReason || node.evaluation_reason || '',
  1775. nodeType: nodeType, // 传递实际节点类型用于样式
  1776. searchCount: node.search_count, // search 节点特有
  1777. totalPosts: node.total_posts, // search 节点特有
  1778. selectedWord: node.selected_word || '', // 加词节点特有 - 显示选择的词
  1779. scoreColor: node.scoreColor || null, // SUG节点的颜色标识
  1780. parentQScore: node.parentQScore || 0, // 父Q得分(用于调试)
  1781. domain_index: node.domain_index !== undefined ? node.domain_index : null, // 域索引
  1782. domain_type: node.domain_type || '', // 域类型(如"中心名词"、"核心动作"),只有Q节点有,segment节点不显示
  1783. segment_type: node.segment_type || '', // segment类型(只有segment节点才有)
  1784. type_label: node.type_label || '', // 类型标签
  1785. domains: node.domains || [], // 域索引数组(domain_combination节点特有)
  1786. domains_str: node.domains_str || '', // 域标识字符串(如"D0,D1")
  1787. from_segments: node.from_segments || [], // 来源segments(domain_combination节点特有)
  1788. source_word_details: node.source_word_details || [], // 组合来源词及其得分
  1789. source_scores: node.source_scores || [], // 扁平来源得分
  1790. is_above_sources: node.is_above_sources || false, // 组合是否高于来源得分
  1791. max_source_score: node.max_source_score !== undefined ? node.max_source_score : null, // 来源最高分
  1792. item_type: node.item_type || '', // 构建下一轮节点来源类型
  1793. is_suggestion: node.is_suggestion || false,
  1794. suggestion_label: node.suggestion_label || '',
  1795. },
  1796. position: { x: 0, y: 0 },
  1797. });
  1798. }
  1799. }
  1800. });
  1801. // 创建边 - 使用虚线样式,映射到画布ID
  1802. data.edges.forEach((edge, index) => {
  1803. const edgeColors = {
  1804. '初始分词': '#10b981',
  1805. '调用sug': '#06b6d4',
  1806. '同义改写': '#f59e0b',
  1807. '加词': '#3b82f6',
  1808. '抽象改写': '#8b5cf6',
  1809. '基于部分匹配改进': '#ec4899',
  1810. '结果分支-抽象改写': '#a855f7',
  1811. '结果分支-同义改写': '#fb923c',
  1812. 'query_to_note': '#ec4899',
  1813. };
  1814. const color = edgeColors[edge.strategy] || edgeColors[edge.edge_type] || '#d1d5db';
  1815. const isNoteEdge = edge.edge_type === 'query_to_note';
  1816. edges.push({
  1817. id: \`edge-\${index}\`,
  1818. source: originalIdToCanvasId[edge.from], // 使用画布ID
  1819. target: originalIdToCanvasId[edge.to], // 使用画布ID
  1820. type: 'simplebezier', // 使用简单贝塞尔曲线
  1821. animated: isNoteEdge,
  1822. style: {
  1823. stroke: color,
  1824. strokeWidth: isNoteEdge ? 2.5 : 2,
  1825. strokeDasharray: isNoteEdge ? '5,5' : '8,4',
  1826. },
  1827. markerEnd: {
  1828. type: 'arrowclosed',
  1829. color: color,
  1830. width: 20,
  1831. height: 20,
  1832. },
  1833. });
  1834. });
  1835. // 使用 dagre 自动计算布局 - 从左到右
  1836. return getLayoutedElements(nodes, edges, 'LR');
  1837. }
  1838. function FlowContent() {
  1839. // 画布使用简化数据
  1840. const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
  1841. console.log('🔍 Transforming data for canvas...');
  1842. const result = transformData(data);
  1843. console.log('✅ Canvas data:', result.nodes.length, 'nodes,', result.edges.length, 'edges');
  1844. return result;
  1845. }, []);
  1846. // 目录使用完整数据(如果存在)
  1847. const { nodes: fullNodes, edges: fullEdges } = useMemo(() => {
  1848. if (data.fullData) {
  1849. console.log('🔍 Transforming full data for tree directory...');
  1850. const result = transformData(data.fullData);
  1851. console.log('✅ Directory data:', result.nodes.length, 'nodes,', result.edges.length, 'edges');
  1852. return result;
  1853. }
  1854. // 如果没有 fullData,使用简化数据
  1855. return { nodes: initialNodes, edges: initialEdges };
  1856. }, [initialNodes, initialEdges]);
  1857. // 初始化:找出所有有子节点的节点,默认折叠(画布节点)
  1858. const initialCollapsedNodes = useMemo(() => {
  1859. const nodesWithChildren = new Set();
  1860. initialEdges.forEach(edge => {
  1861. nodesWithChildren.add(edge.source);
  1862. });
  1863. // 排除根节点(level 0),让根节点默认展开
  1864. const rootNode = initialNodes.find(n => n.data.level === 0);
  1865. if (rootNode) {
  1866. nodesWithChildren.delete(rootNode.id);
  1867. }
  1868. return nodesWithChildren;
  1869. }, [initialNodes, initialEdges]);
  1870. // 树节点的折叠状态需要在树构建后初始化
  1871. const [collapsedNodes, setCollapsedNodes] = useState(() => initialCollapsedNodes);
  1872. const [collapsedTreeNodes, setCollapsedTreeNodes] = useState(new Set());
  1873. const [selectedNodeId, setSelectedNodeId] = useState(null);
  1874. const [hiddenNodes, setHiddenNodes] = useState(new Set()); // 用户手动隐藏的节点
  1875. const [focusMode, setFocusMode] = useState(false); // 全局聚焦模式,默认关闭
  1876. const [focusedNodeId, setFocusedNodeId] = useState(null); // 单独聚焦的节点ID
  1877. const [sidebarWidth, setSidebarWidth] = useState(400); // 左侧目录宽度
  1878. const [isResizing, setIsResizing] = useState(false); // 是否正在拖拽调整宽度
  1879. const [activeTab, setActiveTab] = useState('directory'); // Tab切换: 'directory' | 'cleaned'
  1880. const [selectedPost, setSelectedPost] = useState(null); // 选中的清洗帖子
  1881. // 拖拽调整侧边栏宽度的处理逻辑
  1882. const handleMouseDown = useCallback(() => {
  1883. setIsResizing(true);
  1884. }, []);
  1885. useEffect(() => {
  1886. if (!isResizing) return;
  1887. const handleMouseMove = (e) => {
  1888. const newWidth = e.clientX;
  1889. // 限制宽度范围:300px - 700px
  1890. if (newWidth >= 300 && newWidth <= 700) {
  1891. setSidebarWidth(newWidth);
  1892. }
  1893. };
  1894. const handleMouseUp = () => {
  1895. setIsResizing(false);
  1896. };
  1897. document.addEventListener('mousemove', handleMouseMove);
  1898. document.addEventListener('mouseup', handleMouseUp);
  1899. return () => {
  1900. document.removeEventListener('mousemove', handleMouseMove);
  1901. document.removeEventListener('mouseup', handleMouseUp);
  1902. };
  1903. }, [isResizing]);
  1904. // 获取 React Flow 实例以控制画布
  1905. const { setCenter, fitView } = useReactFlow();
  1906. // 获取某个节点的所有后代节点ID
  1907. const getDescendants = useCallback((nodeId) => {
  1908. const descendants = new Set();
  1909. const queue = [nodeId];
  1910. while (queue.length > 0) {
  1911. const current = queue.shift();
  1912. initialEdges.forEach(edge => {
  1913. if (edge.source === current && !descendants.has(edge.target)) {
  1914. descendants.add(edge.target);
  1915. queue.push(edge.target);
  1916. }
  1917. });
  1918. }
  1919. return descendants;
  1920. }, [initialEdges]);
  1921. // 获取直接父节点
  1922. const getDirectParents = useCallback((nodeId) => {
  1923. const parents = [];
  1924. initialEdges.forEach(edge => {
  1925. if (edge.target === nodeId) {
  1926. parents.push(edge.source);
  1927. }
  1928. });
  1929. return parents;
  1930. }, [initialEdges]);
  1931. // 获取直接子节点
  1932. const getDirectChildren = useCallback((nodeId) => {
  1933. const children = [];
  1934. initialEdges.forEach(edge => {
  1935. if (edge.source === nodeId) {
  1936. children.push(edge.target);
  1937. }
  1938. });
  1939. return children;
  1940. }, [initialEdges]);
  1941. // 切换节点折叠状态
  1942. const toggleNodeCollapse = useCallback((nodeId) => {
  1943. setCollapsedNodes(prev => {
  1944. const newSet = new Set(prev);
  1945. const descendants = getDescendants(nodeId);
  1946. if (newSet.has(nodeId)) {
  1947. // 展开:移除此节点,但保持其他折叠的节点
  1948. newSet.delete(nodeId);
  1949. } else {
  1950. // 折叠:添加此节点
  1951. newSet.add(nodeId);
  1952. }
  1953. return newSet;
  1954. });
  1955. }, [getDescendants]);
  1956. // 过滤可见的节点和边,并重新计算布局
  1957. const { nodes, edges } = useMemo(() => {
  1958. const nodesToHide = new Set();
  1959. // 判断使用哪个节点ID进行聚焦:优先使用单独聚焦的节点,否则使用全局聚焦模式的选中节点
  1960. const effectiveFocusNodeId = focusedNodeId || (focusMode ? selectedNodeId : null);
  1961. // 聚焦模式:只显示聚焦节点、其父节点和直接子节点
  1962. if (effectiveFocusNodeId) {
  1963. const visibleInFocus = new Set([effectiveFocusNodeId]);
  1964. // 添加所有父节点
  1965. initialEdges.forEach(edge => {
  1966. if (edge.target === effectiveFocusNodeId) {
  1967. visibleInFocus.add(edge.source);
  1968. }
  1969. });
  1970. // 添加所有直接子节点
  1971. initialEdges.forEach(edge => {
  1972. if (edge.source === effectiveFocusNodeId) {
  1973. visibleInFocus.add(edge.target);
  1974. }
  1975. });
  1976. // 隐藏不在聚焦范围内的节点
  1977. initialNodes.forEach(node => {
  1978. if (!visibleInFocus.has(node.id)) {
  1979. nodesToHide.add(node.id);
  1980. }
  1981. });
  1982. } else {
  1983. // 非聚焦模式:使用原有的折叠逻辑
  1984. // 收集所有被折叠节点的后代
  1985. collapsedNodes.forEach(collapsedId => {
  1986. const descendants = getDescendants(collapsedId);
  1987. descendants.forEach(id => nodesToHide.add(id));
  1988. });
  1989. }
  1990. // 添加用户手动隐藏的节点
  1991. hiddenNodes.forEach(id => nodesToHide.add(id));
  1992. const visibleNodes = initialNodes
  1993. .filter(node => !nodesToHide.has(node.id))
  1994. .map(node => ({
  1995. ...node,
  1996. data: {
  1997. ...node.data,
  1998. isCollapsed: collapsedNodes.has(node.id),
  1999. hasChildren: initialEdges.some(e => e.source === node.id),
  2000. onToggleCollapse: () => toggleNodeCollapse(node.id),
  2001. onHideSelf: () => {
  2002. setHiddenNodes(prev => {
  2003. const newSet = new Set(prev);
  2004. newSet.add(node.id);
  2005. return newSet;
  2006. });
  2007. },
  2008. onFocus: () => {
  2009. // 切换聚焦状态
  2010. if (focusedNodeId === node.id) {
  2011. setFocusedNodeId(null); // 如果已经聚焦,则取消聚焦
  2012. } else {
  2013. // 先取消之前的聚焦,然后聚焦到当前节点
  2014. setFocusedNodeId(node.id);
  2015. // 延迟聚焦视图到该节点
  2016. setTimeout(() => {
  2017. fitView({
  2018. nodes: [{ id: node.id }],
  2019. duration: 800,
  2020. padding: 0.3,
  2021. });
  2022. }, 100);
  2023. }
  2024. },
  2025. isFocused: focusedNodeId === node.id,
  2026. isHighlighted: selectedNodeId === node.id,
  2027. }
  2028. }));
  2029. const visibleEdges = initialEdges.filter(
  2030. edge => !nodesToHide.has(edge.source) && !nodesToHide.has(edge.target)
  2031. );
  2032. // 重新计算布局 - 只对可见节点
  2033. if (typeof window !== 'undefined' && typeof window.dagre !== 'undefined') {
  2034. try {
  2035. const dagreGraph = new window.dagre.graphlib.Graph();
  2036. dagreGraph.setDefaultEdgeLabel(() => ({}));
  2037. dagreGraph.setGraph({
  2038. rankdir: 'LR',
  2039. nodesep: 800, // 与static layout保持一致,确保不重叠
  2040. ranksep: 400, // 增加水平间距
  2041. });
  2042. visibleNodes.forEach((node) => {
  2043. let nodeWidth = 320;
  2044. let nodeHeight = 220;
  2045. // note 节点有轮播图,需要更大的空间
  2046. if (node.type === 'note') {
  2047. nodeWidth = 360;
  2048. nodeHeight = 2600; // 与static layout保持一致
  2049. }
  2050. dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
  2051. });
  2052. visibleEdges.forEach((edge) => {
  2053. dagreGraph.setEdge(edge.source, edge.target);
  2054. });
  2055. window.dagre.layout(dagreGraph);
  2056. visibleNodes.forEach((node) => {
  2057. const nodeWithPosition = dagreGraph.node(node.id);
  2058. if (nodeWithPosition) {
  2059. // 根据节点类型获取对应的尺寸
  2060. let nodeWidth = 320;
  2061. let nodeHeight = 220;
  2062. if (node.type === 'note') {
  2063. nodeWidth = 360;
  2064. nodeHeight = 2600; // 与static layout保持一致
  2065. }
  2066. node.position = {
  2067. x: nodeWithPosition.x - nodeWidth / 2,
  2068. y: nodeWithPosition.y - nodeHeight / 2,
  2069. };
  2070. node.targetPosition = 'left';
  2071. node.sourcePosition = 'right';
  2072. }
  2073. });
  2074. // 为同层级的 note 节点添加交错偏移,避免视觉重叠
  2075. console.log('[DYNAMIC LAYOUT] 开始应用交错偏移');
  2076. const noteNodesToStagger = visibleNodes.filter(n => n.type === 'note');
  2077. console.log('[DYNAMIC LAYOUT] note 节点数:', noteNodesToStagger.length);
  2078. if (noteNodesToStagger.length > 1) {
  2079. // 按 Y 坐标排序
  2080. noteNodesToStagger.sort((a, b) => a.position.y - b.position.y);
  2081. console.log('[DYNAMIC LAYOUT] 排序后准备应用偏移:');
  2082. noteNodesToStagger.forEach((n, i) => {
  2083. console.log(' [' + i + '] ' + n.id.substring(0, 40) + '... | pos=(' + n.position.x.toFixed(0) + ', ' + n.position.y.toFixed(0) + ')');
  2084. });
  2085. // 为相邻的 note 节点添加 X 方向的交错(3个位置:左、中、右)
  2086. const baseX = noteNodesToStagger.length > 0 ? noteNodesToStagger[0].position.x : 0;
  2087. const leftX = baseX - 1500;
  2088. const centerX = baseX;
  2089. const rightX = baseX + 1500;
  2090. let appliedCount = 0;
  2091. noteNodesToStagger.forEach((node, index) => {
  2092. const oldX = node.position.x;
  2093. const position = index % 3;
  2094. if (position === 0) {
  2095. node.position.x = leftX;
  2096. console.log('[DYNAMIC LAYOUT] [' + index + '] 设置为左侧: X ' + oldX.toFixed(0) + ' → ' + node.position.x.toFixed(0));
  2097. } else if (position === 1) {
  2098. node.position.x = centerX;
  2099. console.log('[DYNAMIC LAYOUT] [' + index + '] 设置为中间: X ' + oldX.toFixed(0) + ' → ' + node.position.x.toFixed(0));
  2100. } else {
  2101. node.position.x = rightX;
  2102. console.log('[DYNAMIC LAYOUT] [' + index + '] 设置为右侧: X ' + oldX.toFixed(0) + ' → ' + node.position.x.toFixed(0));
  2103. }
  2104. appliedCount++;
  2105. });
  2106. console.log('[DYNAMIC LAYOUT] 总共应用了 ' + appliedCount + ' 次偏移');
  2107. }
  2108. console.log('✅ Dynamic layout recalculated for', visibleNodes.length, 'visible nodes');
  2109. } catch (error) {
  2110. console.error('❌ Error in dynamic layout:', error);
  2111. }
  2112. }
  2113. return { nodes: visibleNodes, edges: visibleEdges };
  2114. }, [initialNodes, initialEdges, collapsedNodes, hiddenNodes, focusMode, focusedNodeId, getDescendants, toggleNodeCollapse, selectedNodeId]);
  2115. // 构建树形结构 - 允许一个节点有多个父节点
  2116. // 为目录构建树(使用完整数据)
  2117. const buildTree = useCallback(() => {
  2118. // 使用完整数据构建目录树
  2119. const nodeMap = new Map();
  2120. fullNodes.forEach(node => {
  2121. nodeMap.set(node.id, node);
  2122. });
  2123. // 为每个节点创建树节点的副本(允许多次出现)
  2124. const createTreeNode = (nodeId, pathKey) => {
  2125. const node = nodeMap.get(nodeId);
  2126. if (!node) return null;
  2127. return {
  2128. ...node,
  2129. treeKey: pathKey, // 唯一的树路径key,用于React key
  2130. children: []
  2131. };
  2132. };
  2133. // 构建父子关系映射:记录每个节点的所有父节点,去重边
  2134. const parentToChildren = new Map();
  2135. const childToParents = new Map();
  2136. fullEdges.forEach(edge => {
  2137. // 记录父->子关系(去重:同一个父节点到同一个子节点只记录一次)
  2138. if (!parentToChildren.has(edge.source)) {
  2139. parentToChildren.set(edge.source, []);
  2140. }
  2141. const children = parentToChildren.get(edge.source);
  2142. if (!children.includes(edge.target)) {
  2143. children.push(edge.target);
  2144. }
  2145. // 记录子->父关系(用于判断是否有多个父节点,也去重)
  2146. if (!childToParents.has(edge.target)) {
  2147. childToParents.set(edge.target, []);
  2148. }
  2149. const parents = childToParents.get(edge.target);
  2150. if (!parents.includes(edge.source)) {
  2151. parents.push(edge.source);
  2152. }
  2153. });
  2154. // 递归构建树
  2155. const buildSubtree = (nodeId, pathKey, visitedInPath) => {
  2156. // 避免循环引用:如果当前路径中已经访问过这个节点,跳过
  2157. if (visitedInPath.has(nodeId)) {
  2158. return null;
  2159. }
  2160. const treeNode = createTreeNode(nodeId, pathKey);
  2161. if (!treeNode) return null;
  2162. const newVisitedInPath = new Set(visitedInPath);
  2163. newVisitedInPath.add(nodeId);
  2164. const children = parentToChildren.get(nodeId) || [];
  2165. treeNode.children = children
  2166. .map((childId, index) => buildSubtree(childId, pathKey + '-' + childId + '-' + index, newVisitedInPath))
  2167. .filter(child => child !== null);
  2168. return treeNode;
  2169. };
  2170. // 找出所有根节点(没有入边的节点)
  2171. const hasParent = new Set();
  2172. fullEdges.forEach(edge => {
  2173. hasParent.add(edge.target);
  2174. });
  2175. const roots = [];
  2176. fullNodes.forEach((node, index) => {
  2177. if (!hasParent.has(node.id)) {
  2178. const treeNode = buildSubtree(node.id, 'root-' + node.id + '-' + index, new Set());
  2179. if (treeNode) roots.push(treeNode);
  2180. }
  2181. });
  2182. return roots;
  2183. }, [fullNodes, fullEdges]);
  2184. const treeRoots = useMemo(() => buildTree(), [buildTree]);
  2185. // 生成树形文本结构(使用完整数据)
  2186. const generateTreeText = useCallback(() => {
  2187. const lines = [];
  2188. // 递归生成树形文本
  2189. const traverse = (nodes, prefix = '', isLast = true, depth = 0) => {
  2190. nodes.forEach((node, index) => {
  2191. const isLastNode = index === nodes.length - 1;
  2192. const nodeData = fullNodes.find(n => n.id === node.id)?.data || {};
  2193. const nodeType = nodeData.nodeType || node.data?.nodeType || 'unknown';
  2194. const title = nodeData.title || node.data?.title || node.id;
  2195. // 优先从node.data获取score,然后从nodeData获取
  2196. let score = null;
  2197. if (node.data?.score !== undefined && node.data?.score !== null) {
  2198. score = node.data.score;
  2199. } else if (node.data?.relevance_score !== undefined && node.data?.relevance_score !== null) {
  2200. score = node.data.relevance_score;
  2201. } else if (nodeData.score !== undefined && nodeData.score !== null) {
  2202. score = nodeData.score;
  2203. } else if (nodeData.relevance_score !== undefined && nodeData.relevance_score !== null) {
  2204. score = nodeData.relevance_score;
  2205. }
  2206. const strategy = nodeData.strategy || node.data?.strategy || '';
  2207. // 构建当前行 - score可能是数字或字符串,step/round节点不显示分数
  2208. const connector = isLastNode ? '└─' : '├─';
  2209. let scoreText = '';
  2210. if (nodeType !== 'step' && nodeType !== 'round' && score !== null && score !== undefined) {
  2211. // score可能已经是字符串格式(如 "0.05"),也可能是数字
  2212. const scoreStr = typeof score === 'number' ? score.toFixed(2) : score;
  2213. scoreText = \` (分数: \${scoreStr})\`;
  2214. }
  2215. const strategyText = strategy ? \` [\${strategy}]\` : '';
  2216. lines.push(\`\${prefix}\${connector} \${title}\${scoreText}\${strategyText}\`);
  2217. // 递归处理子节点
  2218. if (node.children && node.children.length > 0) {
  2219. const childPrefix = prefix + (isLastNode ? ' ' : '│ ');
  2220. traverse(node.children, childPrefix, isLastNode, depth + 1);
  2221. }
  2222. });
  2223. };
  2224. // 添加标题
  2225. const rootNode = fullNodes.find(n => n.data?.level === 0);
  2226. if (rootNode) {
  2227. lines.push(\`📊 查询扩展树形结构\`);
  2228. lines.push(\`原始问题: \${rootNode.data.title || rootNode.data.query}\`);
  2229. lines.push('');
  2230. }
  2231. traverse(treeRoots);
  2232. return lines.join('\\n');
  2233. }, [treeRoots, fullNodes]);
  2234. // 复制树形结构到剪贴板
  2235. const copyTreeToClipboard = useCallback(async () => {
  2236. try {
  2237. const treeText = generateTreeText();
  2238. await navigator.clipboard.writeText(treeText);
  2239. alert('✅ 树形结构已复制到剪贴板!');
  2240. } catch (err) {
  2241. console.error('复制失败:', err);
  2242. alert('❌ 复制失败,请手动复制');
  2243. }
  2244. }, [generateTreeText]);
  2245. // 初始化树节点折叠状态
  2246. useEffect(() => {
  2247. const getAllTreeKeys = (nodes) => {
  2248. const keys = new Set();
  2249. const traverse = (node) => {
  2250. if (node.children && node.children.length > 0) {
  2251. // 排除根节点
  2252. if (node.data.level !== 0) {
  2253. keys.add(node.treeKey);
  2254. }
  2255. node.children.forEach(traverse);
  2256. }
  2257. };
  2258. nodes.forEach(traverse);
  2259. return keys;
  2260. };
  2261. setCollapsedTreeNodes(getAllTreeKeys(treeRoots));
  2262. }, [treeRoots]);
  2263. // 映射完整节点ID到画布简化节点ID
  2264. const mapTreeNodeToCanvasNode = useCallback((treeNodeId) => {
  2265. // 如果是简化模式,需要映射
  2266. if (data.fullData) {
  2267. // 从完整数据中找到节点
  2268. const fullNode = fullNodes.find(n => n.id === treeNodeId);
  2269. if (!fullNode) return treeNodeId;
  2270. // 根据节点类型和文本找到画布上的简化节点
  2271. const nodeText = fullNode.data.title || fullNode.data.query;
  2272. const nodeType = fullNode.data.nodeType || fullNode.type;
  2273. // Query类节点:找 query_xxx
  2274. if (['q', 'seg', 'sug', 'add_word', 'query'].includes(nodeType)) {
  2275. const canvasNode = initialNodes.find(n =>
  2276. (n.data.title === nodeText || n.data.query === nodeText) &&
  2277. ['query'].includes(n.type)
  2278. );
  2279. return canvasNode ? canvasNode.id : treeNodeId;
  2280. }
  2281. // Post节点:按note_id查找
  2282. if (nodeType === 'post' || nodeType === 'note') {
  2283. const noteId = fullNode.data.note_id;
  2284. if (noteId) {
  2285. const canvasNode = initialNodes.find(n => n.data.note_id === noteId);
  2286. return canvasNode ? canvasNode.id : treeNodeId;
  2287. }
  2288. }
  2289. // 其他节点类型(Round/Step等):直接返回
  2290. return treeNodeId;
  2291. }
  2292. // 非简化模式,直接返回
  2293. return treeNodeId;
  2294. }, [data.fullData, fullNodes, initialNodes]);
  2295. const renderTree = useCallback((treeNodes, level = 0) => {
  2296. return treeNodes.map(node => {
  2297. // 使用 treeKey 来区分树中的不同实例
  2298. const isCollapsed = collapsedTreeNodes.has(node.treeKey);
  2299. const isSelected = selectedNodeId === node.id;
  2300. return (
  2301. <TreeNode
  2302. key={node.treeKey}
  2303. node={node}
  2304. level={level}
  2305. isCollapsed={isCollapsed}
  2306. isSelected={isSelected}
  2307. onToggle={() => {
  2308. setCollapsedTreeNodes(prev => {
  2309. const newSet = new Set(prev);
  2310. if (newSet.has(node.treeKey)) {
  2311. newSet.delete(node.treeKey);
  2312. } else {
  2313. newSet.add(node.treeKey);
  2314. }
  2315. return newSet;
  2316. });
  2317. }}
  2318. onSelect={() => {
  2319. // 将目录节点ID映射到画布节点ID
  2320. const treeNodeId = node.id;
  2321. const canvasNodeId = mapTreeNodeToCanvasNode(treeNodeId);
  2322. // 检查画布上是否存在这个节点
  2323. const canvasNodeExists = initialNodes.some(n => n.id === canvasNodeId);
  2324. if (!canvasNodeExists) {
  2325. console.warn(\`节点 \${canvasNodeId} 在画布上不存在(可能被简化了)\`);
  2326. return;
  2327. }
  2328. const nodeId = canvasNodeId;
  2329. // 展开所有祖先节点
  2330. const ancestorIds = [nodeId];
  2331. const findAncestors = (id) => {
  2332. initialEdges.forEach(edge => {
  2333. if (edge.target === id && !ancestorIds.includes(edge.source)) {
  2334. ancestorIds.push(edge.source);
  2335. findAncestors(edge.source);
  2336. }
  2337. });
  2338. };
  2339. findAncestors(nodeId);
  2340. // 如果节点或其祖先被隐藏,先恢复它们
  2341. setHiddenNodes(prev => {
  2342. const newSet = new Set(prev);
  2343. ancestorIds.forEach(id => newSet.delete(id));
  2344. return newSet;
  2345. });
  2346. setSelectedNodeId(nodeId);
  2347. // 获取选中节点的直接子节点
  2348. const childrenIds = [];
  2349. initialEdges.forEach(edge => {
  2350. if (edge.source === nodeId) {
  2351. childrenIds.push(edge.target);
  2352. }
  2353. });
  2354. setCollapsedNodes(prev => {
  2355. const newSet = new Set(prev);
  2356. // 展开所有祖先节点
  2357. ancestorIds.forEach(id => newSet.delete(id));
  2358. // 展开选中节点本身
  2359. newSet.delete(nodeId);
  2360. // 展开选中节点的直接子节点
  2361. childrenIds.forEach(id => newSet.delete(id));
  2362. return newSet;
  2363. });
  2364. // 延迟聚焦,等待节点展开和布局重新计算
  2365. setTimeout(() => {
  2366. fitView({
  2367. nodes: [{ id: nodeId }],
  2368. duration: 800,
  2369. padding: 0.3,
  2370. });
  2371. }, 300);
  2372. }}
  2373. >
  2374. {node.children && node.children.length > 0 && renderTree(node.children, level + 1)}
  2375. </TreeNode>
  2376. );
  2377. });
  2378. }, [collapsedTreeNodes, selectedNodeId, nodes, setCenter, initialEdges, setCollapsedNodes, fitView, mapTreeNodeToCanvasNode, initialNodes, setHiddenNodes]);
  2379. console.log('📊 Rendering with', nodes.length, 'visible nodes and', edges.length, 'visible edges');
  2380. if (nodes.length === 0) {
  2381. return (
  2382. <div style={{ padding: 50, color: 'red', fontSize: 20 }}>
  2383. ERROR: No nodes to display!
  2384. </div>
  2385. );
  2386. }
  2387. return (
  2388. <div style={{ width: '100vw', height: '100vh', background: '#f9fafb', display: 'flex', flexDirection: 'column' }}>
  2389. {/* 顶部面包屑导航栏 */}
  2390. <div style={{
  2391. minHeight: '48px',
  2392. maxHeight: '120px',
  2393. background: 'white',
  2394. borderBottom: '1px solid #e5e7eb',
  2395. display: 'flex',
  2396. alignItems: 'flex-start',
  2397. padding: '12px 24px',
  2398. zIndex: 1000,
  2399. boxShadow: '0 1px 3px rgba(0, 0, 0, 0.05)',
  2400. flexShrink: 0,
  2401. overflowY: 'auto',
  2402. }}>
  2403. <div style={{ width: '100%' }}>
  2404. {selectedNodeId ? (
  2405. <div style={{ fontSize: '12px', color: '#6b7280' }}>
  2406. {/* 面包屑导航 - 显示所有路径 */}
  2407. {(() => {
  2408. const selectedNode = nodes.find(n => n.id === selectedNodeId);
  2409. if (!selectedNode) return null;
  2410. // 找到所有从根节点到当前节点的路径
  2411. const findAllPaths = (targetId) => {
  2412. const paths = [];
  2413. const buildPath = (nodeId, currentPath) => {
  2414. const node = initialNodes.find(n => n.id === nodeId);
  2415. if (!node) return;
  2416. const newPath = [node, ...currentPath];
  2417. // 找到所有父节点
  2418. const parents = initialEdges.filter(e => e.target === nodeId).map(e => e.source);
  2419. if (parents.length === 0) {
  2420. // 到达根节点
  2421. paths.push(newPath);
  2422. } else {
  2423. // 递归处理所有父节点
  2424. parents.forEach(parentId => {
  2425. buildPath(parentId, newPath);
  2426. });
  2427. }
  2428. };
  2429. buildPath(targetId, []);
  2430. return paths;
  2431. };
  2432. const allPaths = findAllPaths(selectedNodeId);
  2433. // 去重:将路径转换为字符串进行比较
  2434. const uniquePaths = [];
  2435. const pathStrings = new Set();
  2436. allPaths.forEach(path => {
  2437. const pathString = path.map(n => n.id).join('->');
  2438. if (!pathStrings.has(pathString)) {
  2439. pathStrings.add(pathString);
  2440. uniquePaths.push(path);
  2441. }
  2442. });
  2443. return (
  2444. <div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
  2445. {uniquePaths.map((path, pathIndex) => (
  2446. <div key={pathIndex} style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
  2447. {pathIndex > 0 && <span style={{ color: '#d1d5db', marginRight: '4px' }}>或</span>}
  2448. {path.map((node, index) => {
  2449. // 获取节点的 score、strategy 和 isSelected
  2450. const nodeScore = node.data.score ? parseFloat(node.data.score) : 0;
  2451. const nodeStrategy = getPrimaryStrategy(node.data); // 使用智能提取函数
  2452. const strategyColor = getStrategyColor(nodeStrategy);
  2453. const nodeIsSelected = node.type === 'note' ? node.data.matchLevel !== 'unsatisfied' : node.data.isSelected !== false;
  2454. const nodeActualType = node.data.nodeType || node.type; // 获取实际节点类型
  2455. // 计算路径节点字体颜色:根据分数提升幅度判断
  2456. let pathFontColor = '#374151'; // 默认颜色
  2457. if (node.type === 'note') {
  2458. pathFontColor = node.data.matchLevel === 'unsatisfied' ? '#ef4444' : '#374151';
  2459. } else if (node.data.seed_score !== undefined) {
  2460. const parentScore = parseFloat(node.data.seed_score);
  2461. const gain = nodeScore - parentScore;
  2462. pathFontColor = gain >= 0.05 ? '#16a34a' : '#ef4444';
  2463. } else if (index > 0) {
  2464. const prevNode = path[index - 1];
  2465. const prevScore = prevNode.data.score ? parseFloat(prevNode.data.score) : 0;
  2466. const gain = nodeScore - prevScore;
  2467. pathFontColor = gain >= 0.05 ? '#16a34a' : '#ef4444';
  2468. } else if (node.data.isSelected === false) {
  2469. pathFontColor = '#ef4444';
  2470. }
  2471. return (
  2472. <React.Fragment key={node.id + '-' + index}>
  2473. <span
  2474. onClick={() => {
  2475. const nodeId = node.id;
  2476. // 找到所有祖先节点
  2477. const ancestorIds = [nodeId];
  2478. const findAncestors = (id) => {
  2479. initialEdges.forEach(edge => {
  2480. if (edge.target === id && !ancestorIds.includes(edge.source)) {
  2481. ancestorIds.push(edge.source);
  2482. findAncestors(edge.source);
  2483. }
  2484. });
  2485. };
  2486. findAncestors(nodeId);
  2487. // 如果节点或其祖先被隐藏,先恢复它们
  2488. setHiddenNodes(prev => {
  2489. const newSet = new Set(prev);
  2490. ancestorIds.forEach(id => newSet.delete(id));
  2491. return newSet;
  2492. });
  2493. // 展开目录树中到达该节点的路径
  2494. // 需要找到所有包含该节点的树路径的 treeKey,并展开它们的父节点
  2495. setCollapsedTreeNodes(prev => {
  2496. const newSet = new Set(prev);
  2497. // 清空所有折叠状态,让目录树完全展开到选中节点
  2498. // 这样可以确保选中节点在目录中可见
  2499. return new Set();
  2500. });
  2501. setSelectedNodeId(nodeId);
  2502. setTimeout(() => {
  2503. fitView({
  2504. nodes: [{ id: nodeId }],
  2505. duration: 800,
  2506. padding: 0.3,
  2507. });
  2508. }, 100);
  2509. }}
  2510. style={{
  2511. padding: '6px 8px',
  2512. borderRadius: '4px',
  2513. background: 'white',
  2514. border: index === path.length - 1 ? '2px solid #3b82f6' : '1px solid #d1d5db',
  2515. color: '#374151',
  2516. fontWeight: index === path.length - 1 ? '600' : '400',
  2517. width: '180px',
  2518. cursor: 'pointer',
  2519. transition: 'all 0.2s ease',
  2520. position: 'relative',
  2521. display: 'inline-flex',
  2522. flexDirection: 'column',
  2523. gap: '4px',
  2524. }}
  2525. onMouseEnter={(e) => {
  2526. e.currentTarget.style.opacity = '0.8';
  2527. }}
  2528. onMouseLeave={(e) => {
  2529. e.currentTarget.style.opacity = '1';
  2530. }}
  2531. title={\`\${node.data.title || node.id} (Score: \${nodeScore.toFixed(2)}, Strategy: \${nodeStrategy}, Selected: \${nodeIsSelected})\`}
  2532. >
  2533. {/* 上半部分:竖线 + 图标 + 文字 + 分数 */}
  2534. <div style={{
  2535. display: 'flex',
  2536. alignItems: 'center',
  2537. gap: '6px',
  2538. }}>
  2539. {/* 策略类型竖线 */}
  2540. <div style={{
  2541. width: '3px',
  2542. height: '16px',
  2543. background: strategyColor,
  2544. borderRadius: '2px',
  2545. flexShrink: 0,
  2546. }} />
  2547. {/* 节点文字 - 左侧 */}
  2548. <span style={{
  2549. flex: 1,
  2550. fontSize: '12px',
  2551. color: pathFontColor,
  2552. overflow: 'hidden',
  2553. textOverflow: 'ellipsis',
  2554. whiteSpace: 'nowrap',
  2555. }}>
  2556. {node.data.title || node.id}
  2557. </span>
  2558. {/* 域标识 - 右侧,挨着分数 */}
  2559. {(node.data.domain_type || node.data.domains_str || (node.data.domain_index !== null && node.data.domain_index !== undefined)) && (
  2560. <span style={{
  2561. fontSize: '12px',
  2562. color: '#fff',
  2563. background: '#6366f1',
  2564. padding: '2px 5px',
  2565. borderRadius: '3px',
  2566. flexShrink: 0,
  2567. fontWeight: '600',
  2568. marginLeft: '4px',
  2569. }}
  2570. title={
  2571. node.data.domain_type ? '域: ' + node.data.domain_type + ' (D' + node.data.domain_index + ')' :
  2572. node.data.domains_str ? '域: ' + node.data.domains_str :
  2573. '域 D' + node.data.domain_index
  2574. }
  2575. >
  2576. {node.data.domain_type || node.data.domains_str || ('D' + node.data.domain_index)}
  2577. </span>
  2578. )}
  2579. {/* 分数显示 - 步骤和轮次节点不显示分数 */}
  2580. {nodeActualType !== 'step' && nodeActualType !== 'round' && (
  2581. <span style={{
  2582. fontSize: '10px',
  2583. color: '#6b7280',
  2584. fontWeight: '500',
  2585. flexShrink: 0,
  2586. minWidth: '35px',
  2587. textAlign: 'right',
  2588. marginLeft: '4px',
  2589. }}>
  2590. {nodeScore.toFixed(2)}
  2591. </span>
  2592. )}
  2593. </div>
  2594. {/* 分数下划线 - 步骤和轮次节点不显示 */}
  2595. {nodeActualType !== 'step' && nodeActualType !== 'round' && (
  2596. <div style={{
  2597. width: (nodeScore * 100) + '%',
  2598. height: '2px',
  2599. background: getScoreColor(nodeScore),
  2600. borderRadius: '1px',
  2601. marginLeft: '9px',
  2602. }} />
  2603. )}
  2604. </span>
  2605. {index < path.length - 1 && <span style={{ color: '#9ca3af' }}>›</span>}
  2606. </React.Fragment>
  2607. )})}
  2608. </div>
  2609. ))}
  2610. </div>
  2611. );
  2612. })()}
  2613. </div>
  2614. ) : (
  2615. <div style={{ fontSize: '13px', color: '#9ca3af', textAlign: 'center' }}>
  2616. 选择一个节点查看路径
  2617. </div>
  2618. )}
  2619. </div>
  2620. </div>
  2621. {/* 主内容区:目录 + 画布 */}
  2622. <div style={{
  2623. display: 'flex',
  2624. flex: 1,
  2625. overflow: 'hidden',
  2626. cursor: isResizing ? 'col-resize' : 'default',
  2627. userSelect: isResizing ? 'none' : 'auto',
  2628. }}>
  2629. {/* 左侧目录树/清洗结果 */}
  2630. <div style={{
  2631. width: \`\${sidebarWidth}px\`,
  2632. background: 'white',
  2633. borderRight: '1px solid #e5e7eb',
  2634. display: 'flex',
  2635. flexDirection: 'column',
  2636. flexShrink: 0,
  2637. }}>
  2638. {/* Tab切换 */}
  2639. <div style={{
  2640. display: 'flex',
  2641. borderBottom: '1px solid #e5e7eb',
  2642. background: '#f9fafb',
  2643. }}>
  2644. <button
  2645. onClick={() => setActiveTab('directory')}
  2646. style={{
  2647. flex: 1,
  2648. padding: '12px 16px',
  2649. border: 'none',
  2650. background: activeTab === 'directory' ? 'white' : 'transparent',
  2651. borderBottom: activeTab === 'directory' ? '2px solid #3b82f6' : '2px solid transparent',
  2652. color: activeTab === 'directory' ? '#111827' : '#6b7280',
  2653. fontWeight: activeTab === 'directory' ? '600' : '400',
  2654. fontSize: '14px',
  2655. cursor: 'pointer',
  2656. transition: 'all 0.2s',
  2657. }}
  2658. >
  2659. 节点目录
  2660. </button>
  2661. <button
  2662. onClick={() => setActiveTab('cleaned')}
  2663. style={{
  2664. flex: 1,
  2665. padding: '12px 16px',
  2666. border: 'none',
  2667. background: activeTab === 'cleaned' ? 'white' : 'transparent',
  2668. borderBottom: activeTab === 'cleaned' ? '2px solid #3b82f6' : '2px solid transparent',
  2669. color: activeTab === 'cleaned' ? '#111827' : '#6b7280',
  2670. fontWeight: activeTab === 'cleaned' ? '600' : '400',
  2671. fontSize: '14px',
  2672. cursor: 'pointer',
  2673. transition: 'all 0.2s',
  2674. }}
  2675. >
  2676. 排序&清洗结果 ({cleanedPosts.length})
  2677. </button>
  2678. </div>
  2679. {/* Tab内容区 */}
  2680. {activeTab === 'directory' && (
  2681. <>
  2682. <div style={{
  2683. padding: '12px 16px',
  2684. borderBottom: '1px solid #e5e7eb',
  2685. display: 'flex',
  2686. justifyContent: 'space-between',
  2687. alignItems: 'center',
  2688. }}>
  2689. <span style={{
  2690. fontWeight: '600',
  2691. fontSize: '14px',
  2692. color: '#111827',
  2693. }}>
  2694. 节点目录
  2695. </span>
  2696. <div style={{ display: 'flex', gap: '6px' }}>
  2697. <button
  2698. onClick={() => {
  2699. setCollapsedTreeNodes(new Set());
  2700. }}
  2701. style={{
  2702. fontSize: '11px',
  2703. padding: '4px 8px',
  2704. borderRadius: '4px',
  2705. border: '1px solid #d1d5db',
  2706. background: 'white',
  2707. color: '#6b7280',
  2708. cursor: 'pointer',
  2709. fontWeight: '500',
  2710. }}
  2711. title="展开全部节点"
  2712. >
  2713. 全部展开
  2714. </button>
  2715. <button
  2716. onClick={() => {
  2717. const getAllTreeKeys = (nodes) => {
  2718. const keys = new Set();
  2719. const traverse = (node) => {
  2720. if (node.children && node.children.length > 0) {
  2721. keys.add(node.treeKey);
  2722. node.children.forEach(traverse);
  2723. }
  2724. };
  2725. nodes.forEach(traverse);
  2726. return keys;
  2727. };
  2728. setCollapsedTreeNodes(getAllTreeKeys(treeRoots));
  2729. }}
  2730. style={{
  2731. fontSize: '11px',
  2732. padding: '4px 8px',
  2733. borderRadius: '4px',
  2734. border: '1px solid #d1d5db',
  2735. background: 'white',
  2736. color: '#6b7280',
  2737. cursor: 'pointer',
  2738. fontWeight: '500',
  2739. }}
  2740. title="折叠全部节点"
  2741. >
  2742. 全部折叠
  2743. </button>
  2744. <button
  2745. onClick={copyTreeToClipboard}
  2746. style={{
  2747. fontSize: '11px',
  2748. padding: '4px 8px',
  2749. borderRadius: '4px',
  2750. border: '1px solid #3b82f6',
  2751. background: '#3b82f6',
  2752. color: 'white',
  2753. cursor: 'pointer',
  2754. fontWeight: '500',
  2755. transition: 'all 0.2s',
  2756. }}
  2757. onMouseEnter={(e) => e.currentTarget.style.background = '#2563eb'}
  2758. onMouseLeave={(e) => e.currentTarget.style.background = '#3b82f6'}
  2759. title="复制树形结构为文本格式"
  2760. >
  2761. 📋 复制树形结构
  2762. </button>
  2763. </div>
  2764. </div>
  2765. <div style={{
  2766. flex: 1,
  2767. overflowX: 'auto',
  2768. overflowY: 'auto',
  2769. padding: '8px',
  2770. }}>
  2771. <div style={{ minWidth: 'fit-content' }}>
  2772. {renderTree(treeRoots)}
  2773. </div>
  2774. </div>
  2775. </>
  2776. )}
  2777. {/* 清洗结果列表 */}
  2778. {activeTab === 'cleaned' && (
  2779. <div style={{
  2780. flex: 1,
  2781. overflowY: 'auto',
  2782. padding: '8px',
  2783. }}>
  2784. {cleanedPosts.length === 0 ? (
  2785. <div style={{
  2786. padding: '20px',
  2787. textAlign: 'center',
  2788. color: '#9ca3af',
  2789. fontSize: '14px',
  2790. }}>
  2791. 暂无清洗数据<br/>
  2792. <span style={{ fontSize: '12px', marginTop: '8px', display: 'block' }}>
  2793. 请先运行 extract_topn_multimodal.py 脚本
  2794. </span>
  2795. </div>
  2796. ) : (
  2797. <div>
  2798. <div style={{
  2799. padding: '8px 12px',
  2800. marginBottom: '8px',
  2801. background: '#f3f4f6',
  2802. borderRadius: '6px',
  2803. fontSize: '12px',
  2804. color: '#6b7280',
  2805. }}>
  2806. 共 {cleanedPosts.length} 个帖子
  2807. </div>
  2808. {cleanedPosts.map((post, index) => (
  2809. <div
  2810. key={post.note_id}
  2811. onClick={() => setSelectedPost(post)}
  2812. style={{
  2813. padding: '12px',
  2814. marginBottom: '8px',
  2815. background: selectedPost?.note_id === post.note_id ? '#eff6ff' : 'white',
  2816. border: \`1px solid \${selectedPost?.note_id === post.note_id ? '#3b82f6' : '#e5e7eb'}\`,
  2817. borderRadius: '6px',
  2818. cursor: 'pointer',
  2819. transition: 'all 0.2s',
  2820. }}
  2821. onMouseEnter={(e) => {
  2822. if (selectedPost?.note_id !== post.note_id) {
  2823. e.currentTarget.style.background = '#f9fafb';
  2824. }
  2825. }}
  2826. onMouseLeave={(e) => {
  2827. if (selectedPost?.note_id !== post.note_id) {
  2828. e.currentTarget.style.background = 'white';
  2829. }
  2830. }}
  2831. >
  2832. <div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
  2833. <div style={{
  2834. minWidth: '24px',
  2835. height: '24px',
  2836. borderRadius: '50%',
  2837. background: '#3b82f6',
  2838. color: 'white',
  2839. display: 'flex',
  2840. alignItems: 'center',
  2841. justifyContent: 'center',
  2842. fontSize: '12px',
  2843. fontWeight: '600',
  2844. }}>
  2845. {index + 1}
  2846. </div>
  2847. <div style={{ flex: 1, minWidth: 0 }}>
  2848. <div style={{
  2849. fontSize: '13px',
  2850. fontWeight: '500',
  2851. color: '#111827',
  2852. marginBottom: '4px',
  2853. overflow: 'hidden',
  2854. textOverflow: 'ellipsis',
  2855. display: '-webkit-box',
  2856. WebkitLineClamp: 2,
  2857. WebkitBoxOrient: 'vertical',
  2858. }}>
  2859. {post.title}
  2860. </div>
  2861. <div style={{ display: 'flex', gap: '12px', fontSize: '11px', color: '#6b7280' }}>
  2862. <span>得分: {post.final_score?.toFixed(1) || 'N/A'}</span>
  2863. <span>图片: {post.content_structured?.total_images || 0}</span>
  2864. </div>
  2865. </div>
  2866. </div>
  2867. </div>
  2868. ))}
  2869. </div>
  2870. )}
  2871. </div>
  2872. )}
  2873. </div>
  2874. {/* 可拖拽的分隔条 */}
  2875. <div
  2876. onMouseDown={handleMouseDown}
  2877. style={{
  2878. width: '4px',
  2879. cursor: 'col-resize',
  2880. background: isResizing ? '#3b82f6' : 'transparent',
  2881. transition: isResizing ? 'none' : 'background 0.2s',
  2882. flexShrink: 0,
  2883. position: 'relative',
  2884. }}
  2885. onMouseEnter={(e) => e.currentTarget.style.background = '#e5e7eb'}
  2886. onMouseLeave={(e) => {
  2887. if (!isResizing) e.currentTarget.style.background = 'transparent';
  2888. }}
  2889. >
  2890. {/* 拖拽提示线 */}
  2891. <div style={{
  2892. position: 'absolute',
  2893. top: '50%',
  2894. left: '50%',
  2895. transform: 'translate(-50%, -50%)',
  2896. width: '1px',
  2897. height: '40px',
  2898. background: '#9ca3af',
  2899. opacity: isResizing ? 1 : 0.3,
  2900. }} />
  2901. </div>
  2902. {/* 画布区域 */}
  2903. <div style={{ flex: 1, position: 'relative' }}>
  2904. {/* 右侧图例 */}
  2905. <div style={{
  2906. position: 'absolute',
  2907. top: '20px',
  2908. right: '20px',
  2909. background: 'white',
  2910. padding: '16px',
  2911. borderRadius: '12px',
  2912. boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
  2913. zIndex: 1000,
  2914. maxWidth: '260px',
  2915. border: '1px solid #e5e7eb',
  2916. }}>
  2917. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
  2918. <h3 style={{ fontSize: '14px', fontWeight: '600', color: '#111827', margin: 0 }}>图例</h3>
  2919. <button
  2920. onClick={() => setFocusMode(!focusMode)}
  2921. style={{
  2922. fontSize: '11px',
  2923. padding: '4px 8px',
  2924. borderRadius: '4px',
  2925. border: '1px solid',
  2926. borderColor: focusMode ? '#3b82f6' : '#d1d5db',
  2927. background: focusMode ? '#3b82f6' : 'white',
  2928. color: focusMode ? 'white' : '#6b7280',
  2929. cursor: 'pointer',
  2930. fontWeight: '500',
  2931. }}
  2932. title={focusMode ? '关闭聚焦模式' : '开启聚焦模式'}
  2933. >
  2934. {focusMode ? '🎯 聚焦' : '📊 全图'}
  2935. </button>
  2936. </div>
  2937. <div style={{ fontSize: '12px' }}>
  2938. {/* 画布节点展开/折叠控制 */}
  2939. <div style={{ marginBottom: '12px', paddingBottom: '12px', borderBottom: '1px solid #f3f4f6' }}>
  2940. <div style={{ fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#374151' }}>节点控制</div>
  2941. <div style={{ display: 'flex', gap: '6px' }}>
  2942. <button
  2943. onClick={() => {
  2944. setCollapsedNodes(new Set());
  2945. }}
  2946. style={{
  2947. fontSize: '11px',
  2948. padding: '4px 8px',
  2949. borderRadius: '4px',
  2950. border: '1px solid #d1d5db',
  2951. background: 'white',
  2952. color: '#6b7280',
  2953. cursor: 'pointer',
  2954. fontWeight: '500',
  2955. flex: 1,
  2956. }}
  2957. title="展开画布中所有节点的子节点"
  2958. >
  2959. 全部展开
  2960. </button>
  2961. <button
  2962. onClick={() => {
  2963. const allNodeIds = new Set(initialNodes.map(n => n.id));
  2964. setCollapsedNodes(allNodeIds);
  2965. }}
  2966. style={{
  2967. fontSize: '11px',
  2968. padding: '4px 8px',
  2969. borderRadius: '4px',
  2970. border: '1px solid #d1d5db',
  2971. background: 'white',
  2972. color: '#6b7280',
  2973. cursor: 'pointer',
  2974. fontWeight: '500',
  2975. flex: 1,
  2976. }}
  2977. title="折叠画布中所有节点的子节点"
  2978. >
  2979. 全部折叠
  2980. </button>
  2981. </div>
  2982. </div>
  2983. <div style={{ paddingTop: '12px', borderTop: '1px solid #f3f4f6' }}>
  2984. <div style={{ fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#374151' }}>策略类型</div>
  2985. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  2986. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#10b981', opacity: 0.7 }}></div>
  2987. <span style={{ color: '#6b7280', fontSize: '11px' }}>初始分词</span>
  2988. </div>
  2989. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  2990. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#06b6d4', opacity: 0.7 }}></div>
  2991. <span style={{ color: '#6b7280', fontSize: '11px' }}>调用sug</span>
  2992. </div>
  2993. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  2994. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#f59e0b', opacity: 0.7 }}></div>
  2995. <span style={{ color: '#6b7280', fontSize: '11px' }}>同义改写</span>
  2996. </div>
  2997. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  2998. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#3b82f6', opacity: 0.7 }}></div>
  2999. <span style={{ color: '#6b7280', fontSize: '11px' }}>加词</span>
  3000. </div>
  3001. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  3002. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#8b5cf6', opacity: 0.7 }}></div>
  3003. <span style={{ color: '#6b7280', fontSize: '11px' }}>抽象改写</span>
  3004. </div>
  3005. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  3006. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#ec4899', opacity: 0.7 }}></div>
  3007. <span style={{ color: '#6b7280', fontSize: '11px' }}>基于部分匹配改进</span>
  3008. </div>
  3009. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  3010. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#a855f7', opacity: 0.7 }}></div>
  3011. <span style={{ color: '#6b7280', fontSize: '11px' }}>结果分支-抽象改写</span>
  3012. </div>
  3013. <div style={{ display: 'flex', alignItems: 'center', margin: '6px 0' }}>
  3014. <div style={{ width: '20px', height: '2px', marginRight: '8px', background: '#fb923c', opacity: 0.7 }}></div>
  3015. <span style={{ color: '#6b7280', fontSize: '11px' }}>结果分支-同义改写</span>
  3016. </div>
  3017. </div>
  3018. <div style={{
  3019. marginTop: '12px',
  3020. paddingTop: '12px',
  3021. borderTop: '1px solid #f3f4f6',
  3022. fontSize: '11px',
  3023. color: '#9ca3af',
  3024. lineHeight: '1.5',
  3025. }}>
  3026. 💡 点击节点左上角 × 隐藏节点
  3027. </div>
  3028. {/* 隐藏节点列表 - 在图例内部 */}
  3029. {hiddenNodes.size > 0 && (
  3030. <div style={{
  3031. marginTop: '12px',
  3032. paddingTop: '12px',
  3033. borderTop: '1px solid #f3f4f6',
  3034. }}>
  3035. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
  3036. <h4 style={{ fontSize: '12px', fontWeight: '600', color: '#111827' }}>已隐藏节点</h4>
  3037. <button
  3038. onClick={() => setHiddenNodes(new Set())}
  3039. style={{
  3040. fontSize: '10px',
  3041. color: '#3b82f6',
  3042. background: 'none',
  3043. border: 'none',
  3044. cursor: 'pointer',
  3045. textDecoration: 'underline',
  3046. }}
  3047. >
  3048. 全部恢复
  3049. </button>
  3050. </div>
  3051. <div style={{ fontSize: '12px', maxHeight: '200px', overflow: 'auto' }}>
  3052. {Array.from(hiddenNodes).map(nodeId => {
  3053. const node = initialNodes.find(n => n.id === nodeId);
  3054. if (!node) return null;
  3055. return (
  3056. <div
  3057. key={nodeId}
  3058. style={{
  3059. display: 'flex',
  3060. justifyContent: 'space-between',
  3061. alignItems: 'center',
  3062. padding: '6px 8px',
  3063. margin: '4px 0',
  3064. background: '#f9fafb',
  3065. borderRadius: '6px',
  3066. fontSize: '11px',
  3067. }}
  3068. >
  3069. <span
  3070. style={{
  3071. flex: 1,
  3072. overflow: 'hidden',
  3073. textOverflow: 'ellipsis',
  3074. whiteSpace: 'nowrap',
  3075. color: '#374151',
  3076. }}
  3077. title={node.data.title || nodeId}
  3078. >
  3079. {node.data.title || nodeId}
  3080. </span>
  3081. <button
  3082. onClick={() => {
  3083. setHiddenNodes(prev => {
  3084. const newSet = new Set(prev);
  3085. newSet.delete(nodeId);
  3086. return newSet;
  3087. });
  3088. }}
  3089. style={{
  3090. marginLeft: '8px',
  3091. fontSize: '10px',
  3092. color: '#10b981',
  3093. background: 'none',
  3094. border: 'none',
  3095. cursor: 'pointer',
  3096. flexShrink: 0,
  3097. }}
  3098. >
  3099. 恢复
  3100. </button>
  3101. </div>
  3102. );
  3103. })}
  3104. </div>
  3105. </div>
  3106. )}
  3107. </div>
  3108. </div>
  3109. {/* React Flow 画布 */}
  3110. <ReactFlow
  3111. nodes={nodes}
  3112. edges={edges}
  3113. nodeTypes={nodeTypes}
  3114. fitView
  3115. fitViewOptions={{ padding: 0.2, duration: 500 }}
  3116. minZoom={0.4}
  3117. maxZoom={1.5}
  3118. nodesDraggable={true}
  3119. nodesConnectable={false}
  3120. elementsSelectable={true}
  3121. defaultEdgeOptions={{
  3122. type: 'smoothstep',
  3123. }}
  3124. proOptions={{ hideAttribution: true }}
  3125. onNodeClick={(event, clickedNode) => {
  3126. setSelectedNodeId(clickedNode.id);
  3127. }}
  3128. >
  3129. <Controls style={{ bottom: '20px', left: 'auto', right: '20px' }} />
  3130. <Background variant="dots" gap={20} size={1} color="#e5e7eb" />
  3131. </ReactFlow>
  3132. {/* 清洗帖子详情卡片 */}
  3133. {selectedPost && (
  3134. <div
  3135. style={{
  3136. position: 'absolute',
  3137. top: 0,
  3138. right: 0,
  3139. bottom: 0,
  3140. left: 0,
  3141. background: 'rgba(0, 0, 0, 0.3)',
  3142. zIndex: 1000,
  3143. display: 'flex',
  3144. justifyContent: 'flex-end',
  3145. }}
  3146. onClick={() => setSelectedPost(null)}
  3147. >
  3148. <div
  3149. style={{
  3150. width: '600px',
  3151. maxWidth: '90%',
  3152. background: '#fff5f5',
  3153. boxShadow: '-4px 0 20px rgba(0, 0, 0, 0.15)',
  3154. overflowY: 'auto',
  3155. display: 'flex',
  3156. flexDirection: 'column',
  3157. }}
  3158. onClick={(e) => e.stopPropagation()}
  3159. >
  3160. {/* 卡片头部 */}
  3161. <div style={{
  3162. padding: '20px 24px',
  3163. borderBottom: '2px solid #fecaca',
  3164. background: 'white',
  3165. position: 'sticky',
  3166. top: 0,
  3167. zIndex: 1,
  3168. }}>
  3169. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
  3170. <h3 style={{
  3171. margin: 0,
  3172. fontSize: '18px',
  3173. fontWeight: '600',
  3174. color: '#111827',
  3175. flex: 1,
  3176. paddingRight: '16px',
  3177. lineHeight: '1.4',
  3178. }}>
  3179. {selectedPost.title}
  3180. </h3>
  3181. <button
  3182. onClick={() => setSelectedPost(null)}
  3183. style={{
  3184. fontSize: '24px',
  3185. lineHeight: '24px',
  3186. border: 'none',
  3187. background: 'none',
  3188. color: '#9ca3af',
  3189. cursor: 'pointer',
  3190. padding: '0',
  3191. width: '24px',
  3192. height: '24px',
  3193. display: 'flex',
  3194. alignItems: 'center',
  3195. justifyContent: 'center',
  3196. borderRadius: '4px',
  3197. transition: 'all 0.2s',
  3198. }}
  3199. onMouseEnter={(e) => {
  3200. e.currentTarget.style.background = '#f3f4f6';
  3201. e.currentTarget.style.color = '#111827';
  3202. }}
  3203. onMouseLeave={(e) => {
  3204. e.currentTarget.style.background = 'none';
  3205. e.currentTarget.style.color = '#9ca3af';
  3206. }}
  3207. >
  3208. ×
  3209. </button>
  3210. </div>
  3211. </div>
  3212. {/* 卡片内容 */}
  3213. <div style={{ flex: 1, overflowY: 'auto', padding: '24px' }}>
  3214. {/* 基本信息 */}
  3215. <div style={{
  3216. display: 'flex',
  3217. gap: '16px',
  3218. marginBottom: '24px',
  3219. padding: '16px',
  3220. background: 'white',
  3221. borderRadius: '8px',
  3222. border: '1px solid #fecaca',
  3223. }}>
  3224. <div style={{ flex: 1 }}>
  3225. <div style={{ fontSize: '12px', color: '#6b7280', marginBottom: '4px' }}>得分</div>
  3226. <div style={{ fontSize: '20px', fontWeight: '600', color: '#ef4444' }}>
  3227. {selectedPost.final_score?.toFixed(1) || 'N/A'}
  3228. </div>
  3229. </div>
  3230. <div style={{ flex: 1 }}>
  3231. <div style={{ fontSize: '12px', color: '#6b7280', marginBottom: '4px' }}>图片数量</div>
  3232. <div style={{ fontSize: '20px', fontWeight: '600', color: '#3b82f6' }}>
  3233. {selectedPost.content_structured?.total_images || 0}
  3234. </div>
  3235. </div>
  3236. <div style={{ flex: 1 }}>
  3237. <a
  3238. href={selectedPost.note_url}
  3239. target="_blank"
  3240. rel="noopener noreferrer"
  3241. style={{
  3242. display: 'inline-block',
  3243. marginTop: '20px',
  3244. padding: '8px 16px',
  3245. background: '#3b82f6',
  3246. color: 'white',
  3247. borderRadius: '6px',
  3248. textDecoration: 'none',
  3249. fontSize: '12px',
  3250. fontWeight: '500',
  3251. transition: 'all 0.2s',
  3252. }}
  3253. onMouseEnter={(e) => e.currentTarget.style.background = '#2563eb'}
  3254. onMouseLeave={(e) => e.currentTarget.style.background = '#3b82f6'}
  3255. >
  3256. 查看原帖 →
  3257. </a>
  3258. </div>
  3259. </div>
  3260. {/* 清洗后的内容 */}
  3261. {selectedPost.content_structured && selectedPost.content_structured.formatted_text && (
  3262. <div style={{ marginBottom: '24px' }}>
  3263. <h4 style={{
  3264. fontSize: '14px',
  3265. fontWeight: '600',
  3266. color: '#111827',
  3267. marginBottom: '12px',
  3268. display: 'flex',
  3269. alignItems: 'center',
  3270. gap: '8px',
  3271. }}>
  3272. <span style={{ fontSize: '16px' }}>📝</span>
  3273. 清洗后的结构化内容
  3274. </h4>
  3275. <div style={{
  3276. padding: '16px',
  3277. background: 'white',
  3278. borderRadius: '8px',
  3279. border: '1px solid #fecaca',
  3280. whiteSpace: 'pre-wrap',
  3281. fontSize: '13px',
  3282. lineHeight: '1.6',
  3283. color: '#374151',
  3284. }}>
  3285. {selectedPost.content_structured.formatted_text}
  3286. </div>
  3287. </div>
  3288. )}
  3289. {/* 图片列表 */}
  3290. {selectedPost.images && selectedPost.images.length > 0 && (
  3291. <div>
  3292. <h4 style={{
  3293. fontSize: '14px',
  3294. fontWeight: '600',
  3295. color: '#111827',
  3296. marginBottom: '12px',
  3297. display: 'flex',
  3298. alignItems: 'center',
  3299. gap: '8px',
  3300. }}>
  3301. <span style={{ fontSize: '16px' }}>🖼️</span>
  3302. 图片详情 ({selectedPost.images.length})
  3303. </h4>
  3304. {selectedPost.images.map((img, idx) => (
  3305. <div
  3306. key={idx}
  3307. style={{
  3308. marginBottom: '16px',
  3309. padding: '16px',
  3310. background: 'white',
  3311. borderRadius: '8px',
  3312. border: '1px solid #fecaca',
  3313. }}
  3314. >
  3315. <div style={{
  3316. fontSize: '12px',
  3317. fontWeight: '600',
  3318. color: '#6b7280',
  3319. marginBottom: '8px',
  3320. }}>
  3321. 图片 {idx + 1}
  3322. </div>
  3323. <img
  3324. src={img.original_url}
  3325. alt={'图片' + (idx + 1)}
  3326. style={{
  3327. width: '100%',
  3328. borderRadius: '6px',
  3329. marginBottom: '12px',
  3330. }}
  3331. />
  3332. {img.extract_text_cleaned && (
  3333. <div style={{
  3334. padding: '12px',
  3335. background: '#f9fafb',
  3336. borderRadius: '6px',
  3337. fontSize: '12px',
  3338. lineHeight: '1.6',
  3339. color: '#374151',
  3340. }}>
  3341. {img.extract_text_cleaned}
  3342. </div>
  3343. )}
  3344. </div>
  3345. ))}
  3346. </div>
  3347. )}
  3348. </div>
  3349. </div>
  3350. </div>
  3351. )}
  3352. </div>
  3353. </div>
  3354. </div>
  3355. );
  3356. }
  3357. function App() {
  3358. return (
  3359. <ReactFlowProvider>
  3360. <FlowContent />
  3361. </ReactFlowProvider>
  3362. );
  3363. }
  3364. const root = createRoot(document.getElementById('root'));
  3365. root.render(<App />);
  3366. `;
  3367. fs.writeFileSync(reactComponentPath, reactComponent);
  3368. // 调试:保存临时组件副本用于检查
  3369. fs.writeFileSync(path.join(__dirname, 'debug_component.jsx'), reactComponent);
  3370. console.log('📝 已保存临时组件副本: debug_component.jsx');
  3371. // 使用 esbuild 打包
  3372. console.log('🎨 Building modern visualization...');
  3373. build({
  3374. entryPoints: [reactComponentPath],
  3375. bundle: true,
  3376. outfile: path.join(__dirname, 'bundle_v2.js'),
  3377. format: 'iife',
  3378. loader: {
  3379. '.css': 'css',
  3380. },
  3381. minify: false,
  3382. treeShaking: false, // 禁用tree shaking
  3383. ignoreAnnotations: true, // 忽略所有注解,防止纯函数优化
  3384. keepNames: true, // 保留函数和变量名
  3385. sourcemap: 'inline',
  3386. // 强制所有 React 引用指向同一个位置,避免多副本
  3387. alias: {
  3388. 'react': path.join(__dirname, 'node_modules/react'),
  3389. 'react-dom': path.join(__dirname, 'node_modules/react-dom'),
  3390. 'react/jsx-runtime': path.join(__dirname, 'node_modules/react/jsx-runtime'),
  3391. 'react/jsx-dev-runtime': path.join(__dirname, 'node_modules/react/jsx-dev-runtime'),
  3392. },
  3393. define: {
  3394. 'process.env.NODE_ENV': '"development"' // 使用开发模式,减少优化
  3395. },
  3396. }).then(() => {
  3397. // 读取打包后的 JS
  3398. const bundleJs = fs.readFileSync(path.join(__dirname, 'bundle_v2.js'), 'utf-8');
  3399. // 调试:检查bundle中是否包含评估UI代码
  3400. const hasEvalCode = bundleJs.includes('知识内容') || bundleJs.includes('is_knowledge');
  3401. console.log('📝 Bundle调试: 包含评估代码 =', hasEvalCode);
  3402. if (hasEvalCode) {
  3403. console.log(' ✓ 评估UI代码在bundle中');
  3404. } else {
  3405. console.log(' ⚠️ 评估UI代码不在bundle中,检查临时组件文件...');
  3406. const tempContent = fs.readFileSync(reactComponentPath, 'utf-8');
  3407. const hasTempEvalCode = tempContent.includes('知识内容');
  3408. console.log(' 临时组件文件包含评估代码 =', hasTempEvalCode);
  3409. }
  3410. // 读取 CSS
  3411. const cssPath = path.join(__dirname, 'node_modules/@xyflow/react/dist/style.css');
  3412. const css = fs.readFileSync(cssPath, 'utf-8');
  3413. // 生成最终 HTML
  3414. const html = `<!DOCTYPE html>
  3415. <html lang="zh-CN">
  3416. <head>
  3417. <meta charset="UTF-8">
  3418. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  3419. <title>查询图可视化</title>
  3420. <link rel="preconnect" href="https://fonts.googleapis.com">
  3421. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  3422. <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
  3423. <script src="https://unpkg.com/dagre@0.8.5/dist/dagre.min.js"></script>
  3424. <script>
  3425. // 过滤特定的 React 警告
  3426. const originalError = console.error;
  3427. console.error = (...args) => {
  3428. if (typeof args[0] === 'string' && args[0].includes('Each child in a list should have a unique "key" prop')) {
  3429. return;
  3430. }
  3431. originalError.apply(console, args);
  3432. };
  3433. </script>
  3434. <style>
  3435. * {
  3436. margin: 0;
  3437. padding: 0;
  3438. box-sizing: border-box;
  3439. }
  3440. body {
  3441. font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  3442. overflow: hidden;
  3443. -webkit-font-smoothing: antialiased;
  3444. -moz-osx-font-smoothing: grayscale;
  3445. }
  3446. #root {
  3447. width: 100vw;
  3448. height: 100vh;
  3449. }
  3450. ${css}
  3451. /* 自定义样式覆盖 */
  3452. .react-flow__edge-path {
  3453. stroke-linecap: round;
  3454. }
  3455. .react-flow__controls {
  3456. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  3457. border: 1px solid #e5e7eb;
  3458. border-radius: 8px;
  3459. }
  3460. .react-flow__controls-button {
  3461. border: none;
  3462. border-bottom: 1px solid #e5e7eb;
  3463. }
  3464. .react-flow__controls-button:hover {
  3465. background: #f9fafb;
  3466. }
  3467. </style>
  3468. </head>
  3469. <body>
  3470. <div id="root"></div>
  3471. <script>${bundleJs}</script>
  3472. </body>
  3473. </html>`;
  3474. // 调试:详细检查bundle和HTML内容
  3475. const bundleHas知识 = bundleJs.includes('知识内容');
  3476. const bundleHasIsKnowledge = bundleJs.includes('is_knowledge');
  3477. const bundleHasDataIsKnowledge = bundleJs.includes('data.is_knowledge');
  3478. console.log('📝 Bundle内容检查:');
  3479. console.log(' 包含 "知识内容":', bundleHas知识);
  3480. console.log(' 包含 "is_knowledge":', bundleHasIsKnowledge);
  3481. console.log(' 包含 "data.is_knowledge":', bundleHasDataIsKnowledge);
  3482. console.log(' Bundle长度:', bundleJs.length);
  3483. const htmlHas知识 = html.includes('知识内容');
  3484. const htmlHasIsKnowledge = html.includes('is_knowledge');
  3485. const htmlHasDataIsKnowledge = html.includes('data.is_knowledge');
  3486. console.log('📝 HTML内容检查:');
  3487. console.log(' 包含 "知识内容":', htmlHas知识);
  3488. console.log(' 包含 "is_knowledge":', htmlHasIsKnowledge);
  3489. console.log(' 包含 "data.is_knowledge":', htmlHasDataIsKnowledge);
  3490. console.log(' HTML长度:', html.length);
  3491. // 如果bundle有但HTML没有,保存用于调试
  3492. if ((bundleHas知识 || bundleHasDataIsKnowledge) && !htmlHas知识 && !htmlHasDataIsKnowledge) {
  3493. console.log(' ⚠️ Bundle中有评估代码但HTML中没有!');
  3494. fs.writeFileSync(path.join(__dirname, 'debug_bundle.js'), bundleJs);
  3495. console.log(' 已保存 debug_bundle.js 用于调试');
  3496. }
  3497. // 写入输出文件
  3498. fs.writeFileSync(outputFile, html);
  3499. // 调试:暂时保留bundle文件用于分析
  3500. console.log('📝 保留 bundle_v2.js 和 temp_flow_component_v2.jsx 用于调试');
  3501. // 清理临时文件(调试期间注释掉)
  3502. // fs.unlinkSync(reactComponentPath);
  3503. // fs.unlinkSync(path.join(__dirname, 'bundle_v2.js'));
  3504. console.log('✅ Visualization generated: ' + outputFile);
  3505. console.log('📊 Nodes: ' + Object.keys(data.nodes).length);
  3506. console.log('🔗 Edges: ' + data.edges.length);
  3507. }).catch(error => {
  3508. console.error('❌ Build error:', error);
  3509. process.exit(1);
  3510. });