|
|
@@ -0,0 +1,409 @@
|
|
|
+<div class="space-y-4">
|
|
|
+ {{-- 控制栏 --}}
|
|
|
+ <div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
|
+ <div class="flex items-center justify-between">
|
|
|
+ <div class="flex items-center gap-4">
|
|
|
+ {{-- 布局切换 --}}
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <span class="text-sm font-medium text-gray-700 dark:text-gray-300">布局:</span>
|
|
|
+ <div class="inline-flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
|
|
+ <button
|
|
|
+ wire:click="setLayoutType('full')"
|
|
|
+ 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' }}"
|
|
|
+ >
|
|
|
+ 全图模式
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ wire:click="setLayoutType('selected')"
|
|
|
+ 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' }}"
|
|
|
+ >
|
|
|
+ 选中模式
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {{-- 筛选器 --}}
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <select
|
|
|
+ wire:model.live="filterPhase"
|
|
|
+ class="text-sm border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-100"
|
|
|
+ >
|
|
|
+ <option value="">所有学段</option>
|
|
|
+ @foreach($this->filterOptions['phases'] as $phase)
|
|
|
+ <option value="{{ $phase }}">{{ $phase }}</option>
|
|
|
+ @endforeach
|
|
|
+ </select>
|
|
|
+
|
|
|
+ <select
|
|
|
+ wire:model.live="filterCategory"
|
|
|
+ class="text-sm border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-100"
|
|
|
+ >
|
|
|
+ <option value="">所有类别</option>
|
|
|
+ @foreach($this->filterOptions['categories'] as $category)
|
|
|
+ <option value="{{ $category }}">{{ $category }}</option>
|
|
|
+ @endforeach
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ @if($selectedKpCode)
|
|
|
+ <div class="flex items-center gap-2 px-3 py-1 bg-indigo-100 dark:bg-indigo-900 rounded-lg">
|
|
|
+ <span class="text-sm font-medium text-indigo-900 dark:text-indigo-300">
|
|
|
+ 已选择: {{ $selectedKpCode }}
|
|
|
+ </span>
|
|
|
+ <button
|
|
|
+ wire:click="clearSelection"
|
|
|
+ class="text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-200"
|
|
|
+ >
|
|
|
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ @else
|
|
|
+ <span class="text-sm text-gray-500 dark:text-gray-400">点击节点查看详情</span>
|
|
|
+ @endif
|
|
|
+
|
|
|
+ <button
|
|
|
+ wire:click="loadGraphData"
|
|
|
+ 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"
|
|
|
+ >
|
|
|
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
+ <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>
|
|
|
+ </svg>
|
|
|
+ 刷新
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {{-- 图谱可视化区域 --}}
|
|
|
+ <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()">
|
|
|
+ @if($isLoading)
|
|
|
+ <div class="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-gray-800/80 z-10">
|
|
|
+ <div class="flex items-center gap-3">
|
|
|
+ <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">
|
|
|
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
|
+ <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>
|
|
|
+ </svg>
|
|
|
+ <span class="text-gray-600 dark:text-gray-300">加载知识图谱...</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ @endif
|
|
|
+
|
|
|
+ <div id="knowledge-graph-viz" class="w-full h-full"></div>
|
|
|
+
|
|
|
+ {{-- 图例 --}}
|
|
|
+ <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">
|
|
|
+ <h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">图例</h4>
|
|
|
+ <div class="space-y-2 text-xs">
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <div class="w-3 h-3 rounded-full bg-blue-500"></div>
|
|
|
+ <span class="text-gray-600 dark:text-gray-400">知识点</span>
|
|
|
+ </div>
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <div class="w-3 h-3 rounded-full bg-green-500"></div>
|
|
|
+ <span class="text-gray-600 dark:text-gray-400">已掌握</span>
|
|
|
+ </div>
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <div class="w-3 h-3 rounded-full bg-yellow-500"></div>
|
|
|
+ <span class="text-gray-600 dark:text-gray-400">需加强</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {{-- 统计信息 --}}
|
|
|
+ <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">
|
|
|
+ <div class="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
|
|
+ <div>节点: <span x-text="stats.nodes || 0">0</span></div>
|
|
|
+ <div>边: <span x-text="stats.edges || 0">0</span></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {{-- 知识点详情面板 --}}
|
|
|
+ <livewire:integrations.knowledge-point-details />
|
|
|
+
|
|
|
+</div>
|
|
|
+
|
|
|
+@push('styles')
|
|
|
+<style>
|
|
|
+ /* G6 节点样式覆盖 */
|
|
|
+ .g6-tooltip {
|
|
|
+ border-radius: 8px !important;
|
|
|
+ background: rgba(15, 23, 42, 0.95) !important;
|
|
|
+ color: #e2e8f0 !important;
|
|
|
+ padding: 10px 12px !important;
|
|
|
+ font-size: 12px !important;
|
|
|
+ box-shadow: 0 10px 30px rgba(0,0,0,0.18) !important;
|
|
|
+ }
|
|
|
+</style>
|
|
|
+@endpush
|
|
|
+
|
|
|
+@push('scripts')
|
|
|
+<script>
|
|
|
+ // 全局变量存储图谱实例
|
|
|
+ window.knowledgeGraphInstance = null;
|
|
|
+
|
|
|
+ // Alpine 组件定义
|
|
|
+ function knowledgeGraph() {
|
|
|
+ return {
|
|
|
+ graph: null,
|
|
|
+ data: @json($graphData),
|
|
|
+ stats: {
|
|
|
+ nodes: {{ count($graphData['nodes'] ?? []) }},
|
|
|
+ edges: {{ count($graphData['edges'] ?? []) }}
|
|
|
+ },
|
|
|
+ selectedNode: '{{ $selectedKpCode ?? '' }}',
|
|
|
+
|
|
|
+ init() {
|
|
|
+ console.log('初始化知识图谱...', this.data);
|
|
|
+ this.initGraph();
|
|
|
+ this.bindEvents();
|
|
|
+ },
|
|
|
+
|
|
|
+ initGraph() {
|
|
|
+ if (typeof window.KnowledgeMindmapGraph === 'undefined') {
|
|
|
+ console.error('KnowledgeMindmapGraph 类未加载');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const container = document.getElementById('knowledge-graph-viz');
|
|
|
+ if (!container) {
|
|
|
+ console.error('找不到容器元素 #knowledge-graph-viz');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 转换 API 数据为 tree.json 格式
|
|
|
+ const treeData = this.transformApiDataToTree(this.data);
|
|
|
+
|
|
|
+ // 实例化现有的 KnowledgeMindmapGraph
|
|
|
+ this.graphInstance = new window.KnowledgeMindmapGraph({
|
|
|
+ containerId: 'knowledge-graph-viz',
|
|
|
+ livewireMethod: 'handleNodeSelected',
|
|
|
+ livewireId: @this.id,
|
|
|
+ emitSelection: true,
|
|
|
+ showEdges: true,
|
|
|
+ showRelationEdges: true,
|
|
|
+ highlightLowMastery: false,
|
|
|
+ });
|
|
|
+
|
|
|
+ // 手动设置数据(绕过文件加载)
|
|
|
+ this.graphInstance.rawTree = treeData;
|
|
|
+ this.graphInstance.relationEdges = this.transformEdgesForKnowledgeMindmap(this.data.edges || []);
|
|
|
+ this.graphInstance.treeData = this.graphInstance.transformNode(this.graphInstance.rawTree);
|
|
|
+ this.graphInstance.buildParentMap(this.graphInstance.treeData);
|
|
|
+
|
|
|
+ // 构建节点ID集合
|
|
|
+ const flatIds = [];
|
|
|
+ this.graphInstance.collectIds(this.graphInstance.treeData, flatIds);
|
|
|
+ this.graphInstance.nodeIdSet = new Set(flatIds);
|
|
|
+
|
|
|
+ // 渲染图谱
|
|
|
+ this.graphInstance.renderGraph();
|
|
|
+
|
|
|
+ // 暴露到全局
|
|
|
+ window.knowledgeGraphInstance = this.graphInstance;
|
|
|
+ window.knowledgeGraphG6Graph = this.graphInstance.graph;
|
|
|
+ },
|
|
|
+
|
|
|
+ transformDataForG6(apiData) {
|
|
|
+ const nodes = apiData.nodes || [];
|
|
|
+ const edges = apiData.edges || [];
|
|
|
+
|
|
|
+ // 构建树形结构
|
|
|
+ const nodeMap = {};
|
|
|
+ nodes.forEach(node => {
|
|
|
+ nodeMap[node.kp_code] = {
|
|
|
+ id: node.kp_code,
|
|
|
+ label: `${node.kp_code} · ${node.cn_name || node.kp_code}`,
|
|
|
+ children: [],
|
|
|
+ meta: {
|
|
|
+ code: node.kp_code,
|
|
|
+ name: node.cn_name || node.kp_code,
|
|
|
+ phase: node.phase || '',
|
|
|
+ category: node.category || '',
|
|
|
+ importance: node.importance || 0,
|
|
|
+ description: node.description || '',
|
|
|
+ question_count: node.question_count || 0,
|
|
|
+ has_mastery: node.question_count > 0,
|
|
|
+ }
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ // 构建父子关系
|
|
|
+ const rootNodes = [];
|
|
|
+ edges.forEach(edge => {
|
|
|
+ const source = nodeMap[edge.from];
|
|
|
+ const target = nodeMap[edge.to];
|
|
|
+
|
|
|
+ if (source && target) {
|
|
|
+ // 将 target 添加为 source 的子节点
|
|
|
+ source.children.push(target);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 找出根节点(没有父节点的节点)
|
|
|
+ const childCodes = new Set();
|
|
|
+ edges.forEach(edge => {
|
|
|
+ childCodes.add(edge.to);
|
|
|
+ });
|
|
|
+
|
|
|
+ nodes.forEach(node => {
|
|
|
+ if (!childCodes.has(node.kp_code)) {
|
|
|
+ rootNodes.push(nodeMap[node.kp_code]);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 如果只有一个根节点,直接返回;否则包装成虚拟根节点
|
|
|
+ if (rootNodes.length === 1) {
|
|
|
+ return rootNodes[0];
|
|
|
+ } else if (rootNodes.length > 1) {
|
|
|
+ return {
|
|
|
+ id: 'root',
|
|
|
+ label: '知识点根节点',
|
|
|
+ children: rootNodes,
|
|
|
+ meta: { isVirtualRoot: true }
|
|
|
+ };
|
|
|
+ } else {
|
|
|
+ // 如果没有根节点,返回第一个节点
|
|
|
+ return nodes.length > 0 ? nodeMap[nodes[0].kp_code] : null;
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // 转换 API 数据为 KnowledgeMindmapGraph 期望的 tree.json 格式
|
|
|
+ transformApiDataToTree(apiData) {
|
|
|
+ const nodes = apiData.nodes || [];
|
|
|
+ const edges = apiData.edges || [];
|
|
|
+
|
|
|
+ // 构建节点映射
|
|
|
+ const nodeMap = {};
|
|
|
+ nodes.forEach(node => {
|
|
|
+ nodeMap[node.kp_code] = {
|
|
|
+ id: node.kp_code,
|
|
|
+ name: node.cn_name || node.kp_code,
|
|
|
+ label: node.cn_name || node.kp_code,
|
|
|
+ code: node.kp_code,
|
|
|
+ children: [],
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ // 构建父子关系(使用 children 属性)
|
|
|
+ edges.forEach(edge => {
|
|
|
+ const source = nodeMap[edge.from];
|
|
|
+ const target = nodeMap[edge.to];
|
|
|
+
|
|
|
+ if (source && target) {
|
|
|
+ source.children.push(target);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 找出根节点(没有父节点的节点)
|
|
|
+ const childCodes = new Set();
|
|
|
+ edges.forEach(edge => {
|
|
|
+ childCodes.add(edge.to);
|
|
|
+ });
|
|
|
+
|
|
|
+ const rootNodes = nodes
|
|
|
+ .filter(node => !childCodes.has(node.kp_code))
|
|
|
+ .map(node => nodeMap[node.kp_code]);
|
|
|
+
|
|
|
+ // 构建最终的树形结构
|
|
|
+ if (rootNodes.length === 1) {
|
|
|
+ return rootNodes[0];
|
|
|
+ } else if (rootNodes.length > 1) {
|
|
|
+ return {
|
|
|
+ id: 'root',
|
|
|
+ name: '知识点根节点',
|
|
|
+ label: '知识点根节点',
|
|
|
+ code: 'root',
|
|
|
+ children: rootNodes,
|
|
|
+ };
|
|
|
+ } else {
|
|
|
+ // 如果没有根节点,返回第一个节点
|
|
|
+ return nodes.length > 0 ? nodeMap[nodes[0].kp_code] : null;
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // 转换边数据为 KnowledgeMindmapGraph 期望的格式
|
|
|
+ transformEdgesForKnowledgeMindmap(edges) {
|
|
|
+ return edges.map((edge, index) => ({
|
|
|
+ id: `rel-${index}`,
|
|
|
+ source: edge.from,
|
|
|
+ target: edge.to,
|
|
|
+ type: edge.type || 'successor',
|
|
|
+ edgeType: edge.type || 'successor',
|
|
|
+ comment: edge.comment || '',
|
|
|
+ }));
|
|
|
+ },
|
|
|
+
|
|
|
+ bindEvents() {
|
|
|
+ // KnowledgeMindmapGraph 已经在构造函数中绑定了事件
|
|
|
+ // 这里不需要额外绑定,因为节点点击事件会通过 notifySelection 传递到 Livewire
|
|
|
+
|
|
|
+ // 监听自定义事件(从 KnowledgeMindmapGraph 发出)
|
|
|
+ window.addEventListener('mindmap-node-selected', (evt) => {
|
|
|
+ const model = evt.detail;
|
|
|
+ console.log('节点选中事件:', model);
|
|
|
+ });
|
|
|
+ },
|
|
|
+
|
|
|
+ // 更新图谱数据
|
|
|
+ updateData(newData) {
|
|
|
+ this.data = newData;
|
|
|
+ this.stats = {
|
|
|
+ nodes: (newData.nodes || []).length,
|
|
|
+ edges: (newData.edges || []).length
|
|
|
+ };
|
|
|
+
|
|
|
+ if (this.graphInstance && this.graphInstance.graph) {
|
|
|
+ // 转换新数据
|
|
|
+ const treeData = this.transformApiDataToTree(newData);
|
|
|
+ const edgesData = this.transformEdgesForKnowledgeMindmap(newData.edges || []);
|
|
|
+
|
|
|
+ // 更新数据
|
|
|
+ this.graphInstance.rawTree = treeData;
|
|
|
+ this.graphInstance.relationEdges = edgesData;
|
|
|
+
|
|
|
+ // 刷新图谱
|
|
|
+ this.graphInstance.refreshGraph();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Livewire 事件监听
|
|
|
+ document.addEventListener('livewire:initialized', () => {
|
|
|
+ // 监听图谱数据更新事件
|
|
|
+ Livewire.on('graphDataUpdated', (data) => {
|
|
|
+ console.log('收到图谱数据更新:', data);
|
|
|
+ const graphElement = document.querySelector('#knowledge-graph-viz');
|
|
|
+ if (graphElement && graphElement._x_dataStack) {
|
|
|
+ const graphComponent = graphElement._x_dataStack[0];
|
|
|
+ if (graphComponent && graphComponent.updateData) {
|
|
|
+ graphComponent.updateData(data);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 节点选择事件
|
|
|
+ Livewire.on('nodeSelected', (event) => {
|
|
|
+ console.log('节点被选中:', event);
|
|
|
+ // 通知详情面板
|
|
|
+ Livewire.dispatch('kpSelected', { kpCode: event });
|
|
|
+ });
|
|
|
+
|
|
|
+ // 监听 Livewire 的数据更新
|
|
|
+ Livewire.on('refreshGraph', () => {
|
|
|
+ console.log('刷新图谱');
|
|
|
+ @this.call('loadGraphData');
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ // 页面加载完成后初始化
|
|
|
+ document.addEventListener('DOMContentLoaded', () => {
|
|
|
+ console.log('DOM 加载完成');
|
|
|
+ });
|
|
|
+</script>
|
|
|
+@endpush
|