tree.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. /**
  2. * 树形菜单组件
  3. */
  4. class TreeView {
  5. constructor(container, data) {
  6. this.container = container;
  7. this.rawData = data;
  8. this.treeData = [];
  9. this.expandedNodes = new Set();
  10. this.selectedNode = null;
  11. this.onNodeClick = null;
  12. this.buildTree();
  13. this.render();
  14. }
  15. /**
  16. * 构建树形数据结构
  17. */
  18. buildTree() {
  19. this.treeData = this.rawData.map((result, resultIdx) => {
  20. // 第1层:结果项
  21. const matchInfo = result.最高匹配信息 || {};
  22. const similarity = matchInfo.相似度 || matchInfo.Jaccard相似度 || 0;
  23. const similarityScore = (similarity * 100).toFixed(1);
  24. const personaName = matchInfo.人设特征名称 || '';
  25. const resultNode = {
  26. id: `result-${resultIdx}`,
  27. level: 1,
  28. type: 'result',
  29. label: personaName
  30. ? `${result.原始特征名称} → ${personaName} | 相似度: ${similarityScore}%`
  31. : result.原始特征名称,
  32. data: result,
  33. children: []
  34. };
  35. // 第2层:关联分类
  36. (result.找到的关联 || []).forEach((assoc, assocIdx) => {
  37. // 对特征列表去重:同名特征只保留有搜索结果的
  38. const featureMap = new Map();
  39. (assoc.特征列表 || []).forEach(feature => {
  40. const name = feature.特征名称;
  41. const hasResult = !!feature.search_result;
  42. if (!featureMap.has(name)) {
  43. // 首次出现,直接添加
  44. featureMap.set(name, feature);
  45. } else {
  46. // 已存在同名特征
  47. const existing = featureMap.get(name);
  48. const existingHasResult = !!existing.search_result;
  49. // 优先保留有搜索结果的
  50. if (hasResult && !existingHasResult) {
  51. featureMap.set(name, feature);
  52. }
  53. // 如果当前和已存在的都有结果,保留第一个
  54. // 如果都没有结果,也保留第一个
  55. }
  56. });
  57. const uniqueFeatures = Array.from(featureMap.values());
  58. const jaccardScore = (assoc['Jaccard相似度'] * 100).toFixed(1);
  59. const assocNode = {
  60. id: `assoc-${resultIdx}-${assocIdx}`,
  61. level: 2,
  62. type: 'association',
  63. label: assoc.目标分类路径,
  64. badge: `${uniqueFeatures.length}个特征 | Jaccard: ${jaccardScore}%`,
  65. data: assoc,
  66. parent: resultNode,
  67. children: []
  68. };
  69. // 第3层:特征列表(使用去重后的)
  70. uniqueFeatures.forEach((feature, featureIdx) => {
  71. const hasResult = !!feature.search_result;
  72. const status = feature.search_metadata?.status || 'pending';
  73. const featureNode = {
  74. id: `feature-${resultIdx}-${assocIdx}-${featureIdx}`,
  75. level: 3,
  76. type: 'feature',
  77. label: feature.特征名称,
  78. searchWord: feature.search_word,
  79. badge: hasResult ? `${this.getNoteCount(feature)}条` : '待搜索',
  80. badgeClass: `status-${status}`,
  81. data: feature,
  82. parent: assocNode,
  83. children: []
  84. };
  85. // 第4层:显示所有帖子
  86. if (hasResult && feature.search_result) {
  87. const notes = this.getNotes(feature);
  88. notes.forEach((note, noteIdx) => {
  89. const noteCard = note.note_card || note;
  90. const noteNode = {
  91. id: `note-${resultIdx}-${assocIdx}-${featureIdx}-${noteIdx}`,
  92. level: 4,
  93. type: 'note',
  94. label: noteCard.display_title || '无标题',
  95. data: note,
  96. parent: featureNode
  97. };
  98. featureNode.children.push(noteNode);
  99. });
  100. }
  101. assocNode.children.push(featureNode);
  102. });
  103. resultNode.children.push(assocNode);
  104. });
  105. return resultNode;
  106. });
  107. }
  108. /**
  109. * 渲染树形菜单
  110. */
  111. render() {
  112. if (this.treeData.length === 0) {
  113. this.container.innerHTML = '<div class="empty-state">暂无数据</div>';
  114. return;
  115. }
  116. const html = this.treeData.map(node => this.renderNode(node)).join('');
  117. this.container.innerHTML = html;
  118. this.bindEvents();
  119. }
  120. /**
  121. * 渲染单个节点
  122. * @param {Object} node - 节点数据
  123. * @returns {string} HTML字符串
  124. */
  125. renderNode(node) {
  126. const hasChildren = node.children && node.children.length > 0;
  127. const isExpanded = this.expandedNodes.has(node.id);
  128. const isSelected = this.selectedNode === node.id;
  129. let iconClass = 'tree-node-icon';
  130. if (hasChildren) {
  131. iconClass += isExpanded ? ' expanded' : ' collapsed';
  132. } else {
  133. iconClass += ' no-children';
  134. }
  135. return `
  136. <div class="tree-node level-${node.level}" data-node-id="${node.id}">
  137. <div class="tree-node-content ${isSelected ? 'active' : ''}"
  138. data-node-content="${node.id}">
  139. <span class="${iconClass}"
  140. data-node-toggle="${node.id}"
  141. ${!hasChildren ? 'style="cursor: default;"' : ''}></span>
  142. <span class="tree-node-label" data-node-label="${node.id}">
  143. ${this.getNodeIcon(node.type)} ${this.escapeHtml(node.label)}
  144. </span>
  145. ${node.badge ? `
  146. <span class="tree-node-badge ${node.badgeClass || ''}">${node.badge}</span>
  147. ` : ''}
  148. </div>
  149. ${hasChildren ? `
  150. <div class="tree-node-children ${isExpanded ? 'expanded' : ''}"
  151. data-node-children="${node.id}">
  152. ${node.children.map(child => this.renderNode(child)).join('')}
  153. </div>
  154. ` : ''}
  155. </div>
  156. `;
  157. }
  158. /**
  159. * 绑定事件
  160. */
  161. bindEvents() {
  162. // 展开/折叠
  163. this.container.querySelectorAll('[data-node-toggle]').forEach(toggle => {
  164. toggle.addEventListener('click', (e) => {
  165. e.stopPropagation();
  166. const nodeId = toggle.getAttribute('data-node-toggle');
  167. this.toggleNode(nodeId);
  168. });
  169. });
  170. // 节点点击
  171. this.container.querySelectorAll('[data-node-content]').forEach(content => {
  172. content.addEventListener('click', () => {
  173. const nodeId = content.getAttribute('data-node-content');
  174. this.selectNode(nodeId);
  175. });
  176. });
  177. }
  178. /**
  179. * 展开/折叠节点
  180. * @param {string} nodeId - 节点ID
  181. */
  182. toggleNode(nodeId) {
  183. if (this.expandedNodes.has(nodeId)) {
  184. this.expandedNodes.delete(nodeId);
  185. } else {
  186. this.expandedNodes.add(nodeId);
  187. }
  188. this.render();
  189. }
  190. /**
  191. * 选中节点
  192. * @param {string} nodeId - 节点ID
  193. */
  194. selectNode(nodeId) {
  195. this.selectedNode = nodeId;
  196. const node = this.findNodeById(nodeId);
  197. // 更新UI
  198. this.container.querySelectorAll('.tree-node-content').forEach(content => {
  199. content.classList.remove('active');
  200. });
  201. const selectedContent = this.container.querySelector(`[data-node-content="${nodeId}"]`);
  202. if (selectedContent) {
  203. selectedContent.classList.add('active');
  204. }
  205. // 触发回调
  206. if (this.onNodeClick && node) {
  207. this.onNodeClick(node);
  208. }
  209. }
  210. /**
  211. * 通过ID查找节点
  212. * @param {string} nodeId - 节点ID
  213. * @returns {Object|null} 节点对象
  214. */
  215. findNodeById(nodeId) {
  216. const search = (nodes) => {
  217. for (const node of nodes) {
  218. if (node.id === nodeId) return node;
  219. if (node.children) {
  220. const found = search(node.children);
  221. if (found) return found;
  222. }
  223. }
  224. return null;
  225. };
  226. return search(this.treeData);
  227. }
  228. /**
  229. * 全部折叠
  230. */
  231. collapseAll() {
  232. this.expandedNodes.clear();
  233. this.render();
  234. }
  235. /**
  236. * 展开所有第一层节点
  237. */
  238. expandFirstLevel() {
  239. this.expandedNodes.clear();
  240. this.treeData.forEach(node => {
  241. this.expandedNodes.add(node.id);
  242. });
  243. this.render();
  244. }
  245. /**
  246. * 搜索节点
  247. * @param {string} keyword - 搜索关键词
  248. */
  249. search(keyword) {
  250. if (!keyword) {
  251. this.render();
  252. return;
  253. }
  254. const lowerKeyword = keyword.toLowerCase();
  255. const matchedNodes = new Set();
  256. const searchNodes = (nodes) => {
  257. for (const node of nodes) {
  258. if (node.label.toLowerCase().includes(lowerKeyword) ||
  259. (node.searchWord && node.searchWord.toLowerCase().includes(lowerKeyword))) {
  260. matchedNodes.add(node.id);
  261. // 展开父节点
  262. let parent = node.parent;
  263. while (parent) {
  264. this.expandedNodes.add(parent.id);
  265. matchedNodes.add(parent.id);
  266. parent = parent.parent;
  267. }
  268. }
  269. if (node.children) {
  270. searchNodes(node.children);
  271. }
  272. }
  273. };
  274. searchNodes(this.treeData);
  275. // 过滤显示
  276. this.filterNodes(matchedNodes);
  277. }
  278. /**
  279. * 过滤节点显示
  280. * @param {Set} matchedNodes - 匹配的节点ID集合
  281. */
  282. filterNodes(matchedNodes) {
  283. this.container.querySelectorAll('.tree-node').forEach(nodeEl => {
  284. const nodeId = nodeEl.getAttribute('data-node-id');
  285. nodeEl.style.display = matchedNodes.has(nodeId) ? '' : 'none';
  286. });
  287. }
  288. /**
  289. * 获取节点图标
  290. * @param {string} type - 节点类型
  291. * @returns {string} 图标
  292. */
  293. getNodeIcon(type) {
  294. const icons = {
  295. result: '📂',
  296. association: '📁',
  297. feature: '🔍',
  298. note: '📄'
  299. };
  300. return icons[type] || '•';
  301. }
  302. /**
  303. * 获取帖子数量
  304. * @param {Object} feature - 特征数据
  305. * @returns {number} 帖子数量
  306. */
  307. getNoteCount(feature) {
  308. if (feature.search_metadata) {
  309. return feature.search_metadata.note_count || 0;
  310. }
  311. return this.getNotes(feature).length;
  312. }
  313. /**
  314. * 获取帖子列表
  315. * @param {Object} feature - 特征数据
  316. * @returns {Array} 帖子数组
  317. */
  318. getNotes(feature) {
  319. if (!feature.search_result) return [];
  320. return feature.search_result.data?.data || [];
  321. }
  322. /**
  323. * HTML 转义
  324. * @param {string} text - 原始文本
  325. * @returns {string} 转义后的文本
  326. */
  327. escapeHtml(text) {
  328. if (!text) return '';
  329. const div = document.createElement('div');
  330. div.textContent = text;
  331. return div.innerHTML;
  332. }
  333. }