knowledge-graph-visualization.blade.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. <div class="space-y-4">
  2. {{-- 控制栏 --}}
  3. <div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
  4. <div class="flex items-center justify-between">
  5. <div class="flex items-center gap-4">
  6. {{-- 布局切换 --}}
  7. <div class="flex items-center gap-2">
  8. <span class="text-sm font-medium text-gray-700 dark:text-gray-300">布局:</span>
  9. <div class="inline-flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
  10. <button
  11. wire:click="setLayoutType('full')"
  12. class="px-3 py-1 text-sm {{ $layoutType === 'full' ? 'bg-indigo-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700' }}"
  13. >
  14. 全图模式
  15. </button>
  16. <button
  17. wire:click="setLayoutType('selected')"
  18. class="px-3 py-1 text-sm {{ $layoutType === 'selected' ? 'bg-indigo-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700' }}"
  19. >
  20. 选中模式
  21. </button>
  22. </div>
  23. </div>
  24. {{-- 筛选器 --}}
  25. <div class="flex items-center gap-2">
  26. <select
  27. wire:model.live="filterPhase"
  28. class="text-sm border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-100"
  29. >
  30. <option value="">所有学段</option>
  31. @foreach($this->filterOptions['phases'] as $phase)
  32. <option value="{{ $phase }}">{{ $phase }}</option>
  33. @endforeach
  34. </select>
  35. <select
  36. wire:model.live="filterCategory"
  37. class="text-sm border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-100"
  38. >
  39. <option value="">所有类别</option>
  40. @foreach($this->filterOptions['categories'] as $category)
  41. <option value="{{ $category }}">{{ $category }}</option>
  42. @endforeach
  43. </select>
  44. </div>
  45. </div>
  46. <div class="flex items-center gap-2">
  47. @if($selectedKpCode)
  48. <div class="flex items-center gap-2 px-3 py-1 bg-indigo-100 dark:bg-indigo-900 rounded-lg">
  49. <span class="text-sm font-medium text-indigo-900 dark:text-indigo-300">
  50. 已选择: {{ $selectedKpCode }}
  51. </span>
  52. <button
  53. wire:click="clearSelection"
  54. class="text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-200"
  55. >
  56. <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  57. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
  58. </svg>
  59. </button>
  60. </div>
  61. @else
  62. <span class="text-sm text-gray-500 dark:text-gray-400">点击节点查看详情</span>
  63. @endif
  64. <button
  65. wire:click="loadGraphData"
  66. class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:text-gray-300 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700"
  67. >
  68. <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  69. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
  70. </svg>
  71. 刷新
  72. </button>
  73. </div>
  74. </div>
  75. </div>
  76. {{-- 图谱可视化区域 --}}
  77. <div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden" style="height: 500px;" x-data="knowledgeGraph()">
  78. @if($isLoading)
  79. <div class="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-gray-800/80 z-10">
  80. <div class="flex items-center gap-3">
  81. <svg class="animate-spin h-6 w-6 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
  82. <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
  83. <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
  84. </svg>
  85. <span class="text-gray-600 dark:text-gray-300">加载知识图谱...</span>
  86. </div>
  87. </div>
  88. @endif
  89. <div id="knowledge-graph-viz" class="w-full h-full"></div>
  90. {{-- 图例 --}}
  91. <div class="absolute top-4 right-4 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-4 shadow-lg">
  92. <h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">图例</h4>
  93. <div class="space-y-2 text-xs">
  94. <div class="flex items-center gap-2">
  95. <div class="w-3 h-3 rounded-full bg-blue-500"></div>
  96. <span class="text-gray-600 dark:text-gray-400">知识点</span>
  97. </div>
  98. <div class="flex items-center gap-2">
  99. <div class="w-3 h-3 rounded-full bg-green-500"></div>
  100. <span class="text-gray-600 dark:text-gray-400">已掌握</span>
  101. </div>
  102. <div class="flex items-center gap-2">
  103. <div class="w-3 h-3 rounded-full bg-yellow-500"></div>
  104. <span class="text-gray-600 dark:text-gray-400">需加强</span>
  105. </div>
  106. </div>
  107. </div>
  108. {{-- 统计信息 --}}
  109. <div class="absolute bottom-4 left-4 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-4 shadow-lg">
  110. <div class="text-xs text-gray-600 dark:text-gray-400 space-y-1">
  111. <div>节点: <span x-text="stats.nodes || 0">0</span></div>
  112. <div>边: <span x-text="stats.edges || 0">0</span></div>
  113. </div>
  114. </div>
  115. </div>
  116. {{-- 知识点详情面板 --}}
  117. <livewire:integrations.knowledge-point-details />
  118. </div>
  119. @push('styles')
  120. <style>
  121. /* G6 节点样式覆盖 */
  122. .g6-tooltip {
  123. border-radius: 8px !important;
  124. background: rgba(15, 23, 42, 0.95) !important;
  125. color: #e2e8f0 !important;
  126. padding: 10px 12px !important;
  127. font-size: 12px !important;
  128. box-shadow: 0 10px 30px rgba(0,0,0,0.18) !important;
  129. }
  130. </style>
  131. @endpush
  132. @push('scripts')
  133. <script>
  134. // 全局变量存储图谱实例
  135. window.knowledgeGraphInstance = null;
  136. // Alpine 组件定义
  137. function knowledgeGraph() {
  138. return {
  139. graph: null,
  140. data: @json($graphData),
  141. stats: {
  142. nodes: {{ count($graphData['nodes'] ?? []) }},
  143. edges: {{ count($graphData['edges'] ?? []) }}
  144. },
  145. selectedNode: '{{ $selectedKpCode ?? '' }}',
  146. init() {
  147. console.log('初始化知识图谱...', this.data);
  148. this.initGraph();
  149. this.bindEvents();
  150. },
  151. initGraph() {
  152. if (typeof window.KnowledgeMindmapGraph === 'undefined') {
  153. console.error('KnowledgeMindmapGraph 类未加载');
  154. return;
  155. }
  156. const container = document.getElementById('knowledge-graph-viz');
  157. if (!container) {
  158. console.error('找不到容器元素 #knowledge-graph-viz');
  159. return;
  160. }
  161. // 转换 API 数据为 tree.json 格式
  162. const treeData = this.transformApiDataToTree(this.data);
  163. // 实例化现有的 KnowledgeMindmapGraph
  164. this.graphInstance = new window.KnowledgeMindmapGraph({
  165. containerId: 'knowledge-graph-viz',
  166. livewireMethod: 'handleNodeSelected',
  167. livewireId: @this.id,
  168. emitSelection: true,
  169. showEdges: true,
  170. showRelationEdges: true,
  171. highlightLowMastery: false,
  172. });
  173. // 手动设置数据(绕过文件加载)
  174. this.graphInstance.rawTree = treeData;
  175. this.graphInstance.relationEdges = this.transformEdgesForKnowledgeMindmap(this.data.edges || []);
  176. this.graphInstance.treeData = this.graphInstance.transformNode(this.graphInstance.rawTree);
  177. this.graphInstance.buildParentMap(this.graphInstance.treeData);
  178. // 构建节点ID集合
  179. const flatIds = [];
  180. this.graphInstance.collectIds(this.graphInstance.treeData, flatIds);
  181. this.graphInstance.nodeIdSet = new Set(flatIds);
  182. // 渲染图谱
  183. this.graphInstance.renderGraph();
  184. // 暴露到全局
  185. window.knowledgeGraphInstance = this.graphInstance;
  186. window.knowledgeGraphG6Graph = this.graphInstance.graph;
  187. },
  188. transformDataForG6(apiData) {
  189. const nodes = apiData.nodes || [];
  190. const edges = apiData.edges || [];
  191. // 构建树形结构
  192. const nodeMap = {};
  193. nodes.forEach(node => {
  194. nodeMap[node.kp_code] = {
  195. id: node.kp_code,
  196. label: `${node.kp_code} · ${node.cn_name || node.kp_code}`,
  197. children: [],
  198. meta: {
  199. code: node.kp_code,
  200. name: node.cn_name || node.kp_code,
  201. phase: node.phase || '',
  202. category: node.category || '',
  203. importance: node.importance || 0,
  204. description: node.description || '',
  205. question_count: node.question_count || 0,
  206. has_mastery: node.question_count > 0,
  207. }
  208. };
  209. });
  210. // 构建父子关系
  211. const rootNodes = [];
  212. edges.forEach(edge => {
  213. const source = nodeMap[edge.from];
  214. const target = nodeMap[edge.to];
  215. if (source && target) {
  216. // 将 target 添加为 source 的子节点
  217. source.children.push(target);
  218. }
  219. });
  220. // 找出根节点(没有父节点的节点)
  221. const childCodes = new Set();
  222. edges.forEach(edge => {
  223. childCodes.add(edge.to);
  224. });
  225. nodes.forEach(node => {
  226. if (!childCodes.has(node.kp_code)) {
  227. rootNodes.push(nodeMap[node.kp_code]);
  228. }
  229. });
  230. // 如果只有一个根节点,直接返回;否则包装成虚拟根节点
  231. if (rootNodes.length === 1) {
  232. return rootNodes[0];
  233. } else if (rootNodes.length > 1) {
  234. return {
  235. id: 'root',
  236. label: '知识点根节点',
  237. children: rootNodes,
  238. meta: { isVirtualRoot: true }
  239. };
  240. } else {
  241. // 如果没有根节点,返回第一个节点
  242. return nodes.length > 0 ? nodeMap[nodes[0].kp_code] : null;
  243. }
  244. },
  245. // 转换 API 数据为 KnowledgeMindmapGraph 期望的 tree.json 格式
  246. transformApiDataToTree(apiData) {
  247. const nodes = apiData.nodes || [];
  248. const edges = apiData.edges || [];
  249. // 构建节点映射
  250. const nodeMap = {};
  251. nodes.forEach(node => {
  252. nodeMap[node.kp_code] = {
  253. id: node.kp_code,
  254. name: node.cn_name || node.kp_code,
  255. label: node.cn_name || node.kp_code,
  256. code: node.kp_code,
  257. children: [],
  258. };
  259. });
  260. // 构建父子关系(使用 children 属性)
  261. edges.forEach(edge => {
  262. const source = nodeMap[edge.from];
  263. const target = nodeMap[edge.to];
  264. if (source && target) {
  265. source.children.push(target);
  266. }
  267. });
  268. // 找出根节点(没有父节点的节点)
  269. const childCodes = new Set();
  270. edges.forEach(edge => {
  271. childCodes.add(edge.to);
  272. });
  273. const rootNodes = nodes
  274. .filter(node => !childCodes.has(node.kp_code))
  275. .map(node => nodeMap[node.kp_code]);
  276. // 构建最终的树形结构
  277. if (rootNodes.length === 1) {
  278. return rootNodes[0];
  279. } else if (rootNodes.length > 1) {
  280. return {
  281. id: 'root',
  282. name: '知识点根节点',
  283. label: '知识点根节点',
  284. code: 'root',
  285. children: rootNodes,
  286. };
  287. } else {
  288. // 如果没有根节点,返回第一个节点
  289. return nodes.length > 0 ? nodeMap[nodes[0].kp_code] : null;
  290. }
  291. },
  292. // 转换边数据为 KnowledgeMindmapGraph 期望的格式
  293. transformEdgesForKnowledgeMindmap(edges) {
  294. return edges.map((edge, index) => ({
  295. id: `rel-${index}`,
  296. source: edge.from,
  297. target: edge.to,
  298. type: edge.type || 'successor',
  299. edgeType: edge.type || 'successor',
  300. comment: edge.comment || '',
  301. }));
  302. },
  303. bindEvents() {
  304. // KnowledgeMindmapGraph 已经在构造函数中绑定了事件
  305. // 这里不需要额外绑定,因为节点点击事件会通过 notifySelection 传递到 Livewire
  306. // 监听自定义事件(从 KnowledgeMindmapGraph 发出)
  307. window.addEventListener('mindmap-node-selected', (evt) => {
  308. const model = evt.detail;
  309. console.log('节点选中事件:', model);
  310. });
  311. },
  312. // 更新图谱数据
  313. updateData(newData) {
  314. this.data = newData;
  315. this.stats = {
  316. nodes: (newData.nodes || []).length,
  317. edges: (newData.edges || []).length
  318. };
  319. if (this.graphInstance && this.graphInstance.graph) {
  320. // 转换新数据
  321. const treeData = this.transformApiDataToTree(newData);
  322. const edgesData = this.transformEdgesForKnowledgeMindmap(newData.edges || []);
  323. // 更新数据
  324. this.graphInstance.rawTree = treeData;
  325. this.graphInstance.relationEdges = edgesData;
  326. // 刷新图谱
  327. this.graphInstance.refreshGraph();
  328. }
  329. }
  330. }
  331. }
  332. // Livewire 事件监听
  333. document.addEventListener('livewire:initialized', () => {
  334. // 监听图谱数据更新事件
  335. Livewire.on('graphDataUpdated', (data) => {
  336. console.log('收到图谱数据更新:', data);
  337. const graphElement = document.querySelector('#knowledge-graph-viz');
  338. if (graphElement && graphElement._x_dataStack) {
  339. const graphComponent = graphElement._x_dataStack[0];
  340. if (graphComponent && graphComponent.updateData) {
  341. graphComponent.updateData(data);
  342. }
  343. }
  344. });
  345. // 节点选择事件
  346. Livewire.on('nodeSelected', (event) => {
  347. console.log('节点被选中:', event);
  348. // 通知详情面板
  349. Livewire.dispatch('kpSelected', { kpCode: event });
  350. });
  351. // 监听 Livewire 的数据更新
  352. Livewire.on('refreshGraph', () => {
  353. console.log('刷新图谱');
  354. @this.call('loadGraphData');
  355. });
  356. });
  357. // 页面加载完成后初始化
  358. document.addEventListener('DOMContentLoaded', () => {
  359. console.log('DOM 加载完成');
  360. });
  361. </script>
  362. @endpush