|
|
@@ -0,0 +1,934 @@
|
|
|
+<x-filament::page>
|
|
|
+ <div
|
|
|
+ class="space-y-4"
|
|
|
+ x-data="knowledgeMindmap()"
|
|
|
+ x-init="initMindmap()"
|
|
|
+ >
|
|
|
+ <div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
|
|
+ <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
|
+ <div class="flex-1">
|
|
|
+ <h2 class="text-lg font-semibold text-gray-900">初中数学知识图谱</h2>
|
|
|
+ <div class="mt-2 flex gap-4 text-xs text-gray-500">
|
|
|
+ <div>知识点总数:<span x-text="stats.nodes"></span></div>
|
|
|
+ <div>已选中学生:<span x-text="$wire.selectedStudentName || '未选择'"></span></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 学生选择器 -->
|
|
|
+ <div class="mt-3 grid grid-cols-2 gap-3 max-w-md">
|
|
|
+ <div>
|
|
|
+ <label class="block text-xs font-medium text-gray-700 mb-1">选择老师</label>
|
|
|
+ <select
|
|
|
+ wire:model.live="selectedTeacherId"
|
|
|
+ class="w-full text-sm border border-gray-300 rounded-md px-3 py-1.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
|
+ >
|
|
|
+ <option value="">请选择老师...</option>
|
|
|
+ @foreach($teachers as $teacher)
|
|
|
+ <option value="{{ $teacher['teacher_id'] }}">{{ $teacher['name'] }}</option>
|
|
|
+ @endforeach
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label class="block text-xs font-medium text-gray-700 mb-1">选择学生</label>
|
|
|
+ <select
|
|
|
+ wire:model.live="selectedStudentId"
|
|
|
+ class="w-full text-sm border border-gray-300 rounded-md px-3 py-1.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
|
+ {{ empty($selectedTeacherId) ? 'disabled' : '' }}
|
|
|
+ >
|
|
|
+ <option value="">
|
|
|
+ {{ empty($selectedTeacherId) ? '请先选择老师...' : '请选择学生...' }}
|
|
|
+ </option>
|
|
|
+ @foreach($students as $student)
|
|
|
+ <option value="{{ $student['student_id'] }}">{{ $student['name'] }}</option>
|
|
|
+ @endforeach
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="flex flex-wrap gap-3 text-xs text-gray-600">
|
|
|
+ <span class="inline-flex items-center gap-1">
|
|
|
+ <span class="h-2 w-4 rounded-full bg-blue-500"></span> 前置
|
|
|
+ </span>
|
|
|
+ <span class="inline-flex items-center gap-1">
|
|
|
+ <span class="h-2 w-4 rounded-full bg-red-500"></span> 后继
|
|
|
+ </span>
|
|
|
+ <span class="inline-flex items-center gap-1">
|
|
|
+ <span class="h-2 w-4 rounded-full border border-dashed border-gray-500"></span> 兄弟
|
|
|
+ </span>
|
|
|
+ <span class="inline-flex items-center gap-1">
|
|
|
+ <span class="h-2 w-4 rounded-full bg-yellow-400"></span> 联合考查
|
|
|
+ </span>
|
|
|
+ <span class="inline-flex items-center gap-1 ml-2 border-l border-gray-300 pl-2">
|
|
|
+ <span class="h-2 w-4 rounded-full" style="background: linear-gradient(90deg, #ef4444 0%, #22c55e 100%);"></span> 掌握度
|
|
|
+ </span>
|
|
|
+ <span class="inline-flex items-center gap-1 text-xs">
|
|
|
+ <span class="text-red-500">●</span> < 60% (薄弱)
|
|
|
+ </span>
|
|
|
+ <span class="inline-flex items-center gap-1 text-xs">
|
|
|
+ <span class="text-yellow-500">●</span> 60-85% (良好)
|
|
|
+ </span>
|
|
|
+ <span class="inline-flex items-center gap-1 text-xs">
|
|
|
+ <span class="text-green-500">●</span> > 85% (优秀)
|
|
|
+ </span>
|
|
|
+ <span class="inline-flex items-center gap-1 text-xs">
|
|
|
+ <span class="text-yellow-400 text-sm">★</span> 大师级
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div
|
|
|
+ wire:ignore
|
|
|
+ id="knowledge-mindmap"
|
|
|
+ class="knowledge-mindmap-container h-[80vh] min-h-[720px] w-full rounded-lg border border-gray-200 overflow-hidden relative"
|
|
|
+ ></div>
|
|
|
+
|
|
|
+ <!-- Drawer Component -->
|
|
|
+ <div
|
|
|
+ x-show="$wire.drawerOpen"
|
|
|
+ x-transition:enter="transition ease-out duration-300"
|
|
|
+ x-transition:enter-start="translate-x-full"
|
|
|
+ x-transition:enter-end="translate-x-0"
|
|
|
+ x-transition:leave="transition ease-in duration-200"
|
|
|
+ x-transition:leave-start="translate-x-0"
|
|
|
+ x-transition:leave-end="translate-x-full"
|
|
|
+ class="fixed inset-y-0 right-0 w-96 bg-white shadow-2xl z-50 overflow-y-auto"
|
|
|
+ style="display: none;"
|
|
|
+ >
|
|
|
+ <div class="p-6">
|
|
|
+ <!-- Header -->
|
|
|
+ <div class="flex items-center justify-between mb-6">
|
|
|
+ <h3 class="text-lg font-bold text-gray-900">知识点详情</h3>
|
|
|
+ <button
|
|
|
+ wire:click="closeDrawer"
|
|
|
+ class="text-gray-400 hover:text-gray-600 transition"
|
|
|
+ >
|
|
|
+ <svg class="w-6 h-6" 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"/>
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ @if(!empty($nodeDetails))
|
|
|
+ <!-- Node Info -->
|
|
|
+ <div class="mb-6">
|
|
|
+ <h4 class="text-xl font-semibold text-gray-800 mb-2">{{ $nodeDetails['name'] ?? '' }}</h4>
|
|
|
+ <p class="text-sm text-gray-500">编号: {{ $nodeDetails['code'] ?? '' }}</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Mastery Progress -->
|
|
|
+ <div class="mb-6 p-4 bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg">
|
|
|
+ <div class="flex justify-between items-center mb-2">
|
|
|
+ <span class="text-sm font-medium text-gray-700">掌握度</span>
|
|
|
+ <span class="text-lg font-bold text-indigo-600">{{ number_format(($nodeDetails['mastery_level'] ?? 0) * 100, 1) }}%</span>
|
|
|
+ </div>
|
|
|
+ <div class="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
|
|
+ <div
|
|
|
+ class="h-full rounded-full transition-all duration-500"
|
|
|
+ style="width: {{ ($nodeDetails['mastery_level'] ?? 0) * 100 }}%;
|
|
|
+ background: linear-gradient(90deg, #ef4444 0%, #eab308 50%, #22c55e 100%);"
|
|
|
+ ></div>
|
|
|
+ </div>
|
|
|
+ <div class="mt-3 grid grid-cols-2 gap-2 text-xs">
|
|
|
+ <div>
|
|
|
+ <span class="text-gray-600">练习次数:</span>
|
|
|
+ <span class="font-semibold ml-1">{{ $nodeDetails['total_attempts'] ?? 0 }}</span>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <span class="text-gray-600">错误率:</span>
|
|
|
+ <span class="font-semibold ml-1 text-red-600">{{ number_format(($nodeDetails['error_rate'] ?? 0) * 100, 1) }}%</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Prerequisites -->
|
|
|
+ @if(!empty($nodeDetails['prerequisites']))
|
|
|
+ <div class="mb-6">
|
|
|
+ <h5 class="text-sm font-semibold text-gray-700 mb-3">前置知识</h5>
|
|
|
+ <div class="space-y-2">
|
|
|
+ @foreach($nodeDetails['prerequisites'] as $prereq)
|
|
|
+ <div class="flex items-center justify-between p-2 bg-gray-50 rounded hover:bg-gray-100 cursor-pointer transition">
|
|
|
+ <span class="text-sm text-gray-700">{{ $prereq['name'] }}</span>
|
|
|
+ <span class="text-xs px-2 py-1 rounded-full {{ $prereq['mastery'] >= 0.6 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700' }}">
|
|
|
+ {{ number_format($prereq['mastery'] * 100, 0) }}%
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ @endforeach
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ @endif
|
|
|
+
|
|
|
+ <!-- Successors -->
|
|
|
+ @if(!empty($nodeDetails['successors']))
|
|
|
+ <div class="mb-6">
|
|
|
+ <h5 class="text-sm font-semibold text-gray-700 mb-3">后续知识</h5>
|
|
|
+ <div class="space-y-2">
|
|
|
+ @foreach($nodeDetails['successors'] as $succ)
|
|
|
+ <div class="flex items-center justify-between p-2 bg-gray-50 rounded hover:bg-gray-100 cursor-pointer transition">
|
|
|
+ <span class="text-sm text-gray-700">{{ $succ['name'] }}</span>
|
|
|
+ <span class="text-xs px-2 py-1 rounded-full {{ $succ['mastery'] >= 0.6 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700' }}">
|
|
|
+ {{ number_format($succ['mastery'] * 100, 0) }}%
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ @endforeach
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ @endif
|
|
|
+
|
|
|
+ <!-- Recommendations -->
|
|
|
+ @if(!empty($nodeDetails['recommendations']))
|
|
|
+ <div class="mb-6">
|
|
|
+ <h5 class="text-sm font-semibold text-gray-700 mb-3">推荐练习</h5>
|
|
|
+ <div class="space-y-3">
|
|
|
+ @foreach($nodeDetails['recommendations'] as $rec)
|
|
|
+ <div class="p-3 border border-gray-200 rounded-lg hover:border-indigo-300 transition">
|
|
|
+ <div class="flex items-start justify-between mb-1">
|
|
|
+ <span class="text-sm font-medium text-gray-800">{{ $rec['title'] }}</span>
|
|
|
+ <span class="text-xs px-2 py-0.5 rounded-full
|
|
|
+ {{ $rec['difficulty'] === '简单' ? 'bg-green-100 text-green-700' : '' }}
|
|
|
+ {{ $rec['difficulty'] === '中等' ? 'bg-yellow-100 text-yellow-700' : '' }}
|
|
|
+ {{ $rec['difficulty'] === '困难' ? 'bg-red-100 text-red-700' : '' }}
|
|
|
+ ">{{ $rec['difficulty'] }}</span>
|
|
|
+ </div>
|
|
|
+ <span class="text-xs text-gray-500">{{ $rec['type'] }}</span>
|
|
|
+ </div>
|
|
|
+ @endforeach
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ @endif
|
|
|
+
|
|
|
+ <!-- Action Button -->
|
|
|
+ <button class="w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-lg transition shadow-md">
|
|
|
+ 开始练习
|
|
|
+ </button>
|
|
|
+ @endif
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Drawer Overlay -->
|
|
|
+ <div
|
|
|
+ x-show="$wire.drawerOpen"
|
|
|
+ @click="$wire.closeDrawer()"
|
|
|
+ x-transition:enter="transition ease-out duration-300"
|
|
|
+ x-transition:enter-start="opacity-0"
|
|
|
+ x-transition:enter-end="opacity-100"
|
|
|
+ x-transition:leave="transition ease-in duration-200"
|
|
|
+ x-transition:leave-start="opacity-100"
|
|
|
+ x-transition:leave-end="opacity-0"
|
|
|
+ class="fixed inset-0 bg-black bg-opacity-30 z-40"
|
|
|
+ style="display: none;"
|
|
|
+ ></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <style>
|
|
|
+ .knowledge-mindmap-container {
|
|
|
+ background: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 50%, #60a5fa 100%);
|
|
|
+ position: relative;
|
|
|
+ }
|
|
|
+
|
|
|
+ .knowledge-mindmap-container::before {
|
|
|
+ content: '';
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ bottom: 0;
|
|
|
+ background:
|
|
|
+ radial-gradient(ellipse at 20% 30%, rgba(255,255,255,0.1) 0%, transparent 50%),
|
|
|
+ radial-gradient(ellipse at 80% 70%, rgba(255,255,255,0.08) 0%, transparent 50%);
|
|
|
+ pointer-events: none;
|
|
|
+ }
|
|
|
+ </style>
|
|
|
+</x-filament::page>
|
|
|
+
|
|
|
+@push('scripts')
|
|
|
+ <script src="https://gw.alipayobjects.com/os/lib/antv/g6/5.0.18/dist/g6.min.js"></script>
|
|
|
+ <script src="{{ asset('js/g6-custom-node.js') }}"></script>
|
|
|
+ <script src="{{ asset('js/knowledge-mindmap-graph.js') }}"></script>
|
|
|
+ <script>
|
|
|
+ document.addEventListener('alpine:init', () => {
|
|
|
+ window.knowledgeMindmap = () => ({
|
|
|
+ graphInstance: null,
|
|
|
+ stats: { nodes: 0, extraEdges: 0 },
|
|
|
+
|
|
|
+ async initMindmap() {
|
|
|
+ try {
|
|
|
+ if (!window.G6) {
|
|
|
+ console.error('G6 未加载');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.graphInstance = new KnowledgeMindmapGraph();
|
|
|
+ await this.graphInstance.init();
|
|
|
+
|
|
|
+ this.stats = this.graphInstance.stats;
|
|
|
+ } catch (err) {
|
|
|
+ console.error('初始化思维导图失败', err);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ });
|
|
|
+ });
|
|
|
+ </script>
|
|
|
+@endpush
|
|
|
+
|
|
|
+ graph: null,
|
|
|
+ treeData: null,
|
|
|
+ relationEdges: [],
|
|
|
+ stats: { nodes: 0, extraEdges: 0 },
|
|
|
+
|
|
|
+ // 学生选择相关
|
|
|
+ // 学生选择相关 - 现在由Livewire管理
|
|
|
+ masteryData: {}, // 存储掌握度数据 { 'F01': 80, 'F02': 65, ... }
|
|
|
+
|
|
|
+
|
|
|
+ arrow(w = 12, h = 14, r = 5) {
|
|
|
+ if (window.G6?.Arrow?.triangle) {
|
|
|
+ return G6.Arrow.triangle(w, h, r);
|
|
|
+ }
|
|
|
+ return [
|
|
|
+ ['M', 0, 0],
|
|
|
+ ['L', w, h / 2],
|
|
|
+ ['L', 0, h],
|
|
|
+ ['Z'],
|
|
|
+ ];
|
|
|
+ },
|
|
|
+ levelStyles: [
|
|
|
+ {
|
|
|
+ fill: '#0ea5e9',
|
|
|
+ stroke: '#0369a1',
|
|
|
+ labelColor: '#0f172a',
|
|
|
+ fontSize: 17,
|
|
|
+ fontWeight: 700,
|
|
|
+ size: 34,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ fill: '#e0f2fe',
|
|
|
+ stroke: '#38bdf8',
|
|
|
+ labelColor: '#0f172a',
|
|
|
+ fontSize: 16,
|
|
|
+ fontWeight: 700,
|
|
|
+ size: 30,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ fill: '#f1f5f9',
|
|
|
+ stroke: '#cbd5e1',
|
|
|
+ labelColor: '#0f172a',
|
|
|
+ fontSize: 14,
|
|
|
+ fontWeight: 600,
|
|
|
+ size: 26,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ relationStyles: {
|
|
|
+ prerequisite: {
|
|
|
+ type: 'quadratic',
|
|
|
+ curveOffset: 60,
|
|
|
+ style: {
|
|
|
+ stroke: '#2563eb',
|
|
|
+ lineWidth: 3.4,
|
|
|
+ lineDash: [8, 6],
|
|
|
+ endArrow: {
|
|
|
+ path: null,
|
|
|
+ fill: '#2563eb',
|
|
|
+ d: 12,
|
|
|
+ },
|
|
|
+ startArrow: false,
|
|
|
+ },
|
|
|
+ label: '前置',
|
|
|
+ },
|
|
|
+ successor: {
|
|
|
+ type: 'quadratic',
|
|
|
+ curveOffset: 60,
|
|
|
+ style: {
|
|
|
+ stroke: '#dc2626',
|
|
|
+ lineWidth: 3.4,
|
|
|
+ lineDash: [8, 6],
|
|
|
+ endArrow: {
|
|
|
+ path: null,
|
|
|
+ fill: '#dc2626',
|
|
|
+ d: 12,
|
|
|
+ },
|
|
|
+ startArrow: false,
|
|
|
+ },
|
|
|
+ label: '后继',
|
|
|
+ },
|
|
|
+ sibling: {
|
|
|
+ type: 'quadratic',
|
|
|
+ curveOffset: 50,
|
|
|
+ style: {
|
|
|
+ stroke: '#64748b',
|
|
|
+ lineDash: [6, 6],
|
|
|
+ lineWidth: 3,
|
|
|
+ endArrow: {
|
|
|
+ path: null,
|
|
|
+ fill: '#64748b',
|
|
|
+ d: 10,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ label: '兄弟',
|
|
|
+ },
|
|
|
+ joint: {
|
|
|
+ type: 'quadratic',
|
|
|
+ curveOffset: 50,
|
|
|
+ style: {
|
|
|
+ stroke: '#fcd34d',
|
|
|
+ lineWidth: 3,
|
|
|
+ lineDash: [10, 8],
|
|
|
+ endArrow: {
|
|
|
+ path: null,
|
|
|
+ fill: '#fbbf24',
|
|
|
+ d: 10,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ label: '联合',
|
|
|
+ },
|
|
|
+ default: {
|
|
|
+ type: 'quadratic',
|
|
|
+ curveOffset: 50,
|
|
|
+ style: {
|
|
|
+ stroke: '#94a3b8',
|
|
|
+ lineWidth: 3,
|
|
|
+ lineDash: [10, 8],
|
|
|
+ endArrow: {
|
|
|
+ path: null,
|
|
|
+ fill: '#94a3b8',
|
|
|
+ d: 10,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ label: '',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ async initMindmap() {
|
|
|
+ try {
|
|
|
+ if (this.$nextTick) {
|
|
|
+ await this.$nextTick();
|
|
|
+ }
|
|
|
+ if (!window.G6) {
|
|
|
+ console.error('G6 未加载');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ Object.keys(this.relationStyles).forEach((key) => {
|
|
|
+ const rel = this.relationStyles[key];
|
|
|
+ if (rel?.style && rel.style.endArrow && !rel.style.endArrow.path) {
|
|
|
+ rel.style.endArrow.path = this.arrow(rel.style.endArrow.d || 10, (rel.style.endArrow.d || 10) + 2, 4);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ await Promise.all([
|
|
|
+ this.loadData(),
|
|
|
+ ]);
|
|
|
+ this.applyInitialCollapse(this.treeData);
|
|
|
+ this.renderGraph();
|
|
|
+ window.addEventListener('resize', () => this.resizeGraph());
|
|
|
+
|
|
|
+ // 监听 Livewire 事件
|
|
|
+ window.addEventListener('mastery-updated', (event) => {
|
|
|
+ console.log('Mastery updated:', event.detail.data);
|
|
|
+ this.masteryData = event.detail.data || {};
|
|
|
+ this.refreshGraph();
|
|
|
+ });
|
|
|
+ } catch (err) {
|
|
|
+ console.error('初始化思维导图失败', err);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ // loadTeachers, loadStudents, loadMasteryData 已移除,由 Livewire 处理
|
|
|
+ async loadData() {
|
|
|
+ const [treeResp, edgesResp] = await Promise.all([
|
|
|
+ fetch('/data/tree.json'),
|
|
|
+ fetch('/data/edges.json'),
|
|
|
+ ]);
|
|
|
+ const rawTree = await treeResp.json();
|
|
|
+ const edges = await edgesResp.json();
|
|
|
+ const rawEdges = Array.isArray(edges) ? edges : edges?.edges || [];
|
|
|
+ this.treeData = this.transformNode(rawTree);
|
|
|
+ this.relationEdges = this.normalizeEdges(rawEdges);
|
|
|
+ this.stats = {
|
|
|
+ nodes: this.countNodes(this.treeData),
|
|
|
+ extraEdges: this.relationEdges.length,
|
|
|
+ };
|
|
|
+ },
|
|
|
+ refreshGraph() {
|
|
|
+ if (this.graph && this.treeData) {
|
|
|
+ // 重新装饰树数据以应用掌握度
|
|
|
+ const decoratedData = this.decorateTree(this.treeData);
|
|
|
+ this.graph.changeData(decoratedData);
|
|
|
+ this.graph.render();
|
|
|
+ this.graph.fitView(24);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ transformNode(node, depth = 0) {
|
|
|
+ if (!node) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ const id = node.code || node.id || node.label || `node-${Math.random().toString(36).slice(2, 8)}`;
|
|
|
+ const label = node.name || node.label || node.code || node.id || '未命名节点';
|
|
|
+
|
|
|
+ // 优先使用动态掌握度数据,其次回退到静态数据
|
|
|
+ const dynamicMastery = this.masteryData[id] || this.masteryData[node.code] || 0;
|
|
|
+ const staticMastery = node.mastery_level || 0;
|
|
|
+
|
|
|
+ const meta = {
|
|
|
+ code: node.code || node.id || '',
|
|
|
+ name: label,
|
|
|
+ direct_score: node.direct_score || [],
|
|
|
+ related_score: node.related_score || [],
|
|
|
+ skills: node.skills || [],
|
|
|
+ mastery_level: dynamicMastery || staticMastery, // 动态掌握度优先
|
|
|
+ };
|
|
|
+ return {
|
|
|
+ id,
|
|
|
+ label,
|
|
|
+ meta,
|
|
|
+ depth,
|
|
|
+ children: (node.children || []).map((child) => this.transformNode(child, depth + 1)).filter(Boolean),
|
|
|
+ };
|
|
|
+ },
|
|
|
+ applyInitialCollapse(node, depth = 0) {
|
|
|
+ if (!node) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (depth >= 2 && node.children.length > 0) {
|
|
|
+ node.collapsed = true;
|
|
|
+ }
|
|
|
+ node.children.forEach((child) => this.applyInitialCollapse(child, depth + 1));
|
|
|
+ },
|
|
|
+ countNodes(node) {
|
|
|
+ if (!node) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ return 1 + node.children.reduce((sum, child) => sum + this.countNodes(child), 0);
|
|
|
+ },
|
|
|
+ normalizeEdges(rawEdges) {
|
|
|
+ const seen = new Set();
|
|
|
+ const normalized = [];
|
|
|
+ (rawEdges || []).forEach((edge, index) => {
|
|
|
+ if (!edge?.source || !edge?.target) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const key = `${edge.source}-${edge.target}-${edge.type}`;
|
|
|
+ if (seen.has(key)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ seen.add(key);
|
|
|
+ const relationStyle = this.relationStyles[edge.type] || this.relationStyles.default;
|
|
|
+ normalized.push({
|
|
|
+ id: `rel-${index}-${edge.source}-${edge.target}`,
|
|
|
+ source: edge.source,
|
|
|
+ target: edge.target,
|
|
|
+ type: relationStyle.type || 'quadratic',
|
|
|
+ curveOffset: relationStyle.curveOffset || 50,
|
|
|
+ style: relationStyle.style,
|
|
|
+ label: relationStyle.label,
|
|
|
+ comment: edge.comment || edge.note || '',
|
|
|
+ });
|
|
|
+ });
|
|
|
+ return normalized;
|
|
|
+ },
|
|
|
+ renderGraph(containerEl = null) {
|
|
|
+ if (!this.treeData) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const container = containerEl || document.getElementById('knowledge-mindmap');
|
|
|
+ if (!container) {
|
|
|
+ console.error('容器未找到');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const ensuredId = container.id || 'knowledge-mindmap';
|
|
|
+ if (!container.id) {
|
|
|
+ container.id = ensuredId;
|
|
|
+ }
|
|
|
+ const bounds = container.getBoundingClientRect();
|
|
|
+ const width = Math.max(bounds.width, 600);
|
|
|
+ const height = Math.max(bounds.height, 600);
|
|
|
+ const tooltipEl = document.createElement('div');
|
|
|
+ tooltipEl.className = 'fixed z-50 pointer-events-none hidden';
|
|
|
+ document.body.appendChild(tooltipEl);
|
|
|
+ const showTooltip = (html, x, y) => {
|
|
|
+ tooltipEl.innerHTML = html;
|
|
|
+ tooltipEl.style.left = `${x + 12}px`;
|
|
|
+ tooltipEl.style.top = `${y + 12}px`;
|
|
|
+ tooltipEl.classList.remove('hidden');
|
|
|
+ };
|
|
|
+ const hideTooltip = () => {
|
|
|
+ tooltipEl.classList.add('hidden');
|
|
|
+ tooltipEl.innerHTML = '';
|
|
|
+ };
|
|
|
+ const G6Lib = window.G6?.default || window.G6;
|
|
|
+ const TreeGraphClass = G6Lib?.TreeGraph || null;
|
|
|
+ if (!TreeGraphClass) {
|
|
|
+ console.error('G6 TreeGraph 不可用');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const graphData = this.decorateTree(this.treeData);
|
|
|
+ const graphConfig = {
|
|
|
+ container: ensuredId,
|
|
|
+ width,
|
|
|
+ height,
|
|
|
+ data: graphData,
|
|
|
+ linkCenter: true,
|
|
|
+ modes: {
|
|
|
+ default: [
|
|
|
+ 'drag-canvas',
|
|
|
+ 'zoom-canvas',
|
|
|
+ {
|
|
|
+ type: 'collapse-expand',
|
|
|
+ trigger: 'click',
|
|
|
+ onChange: function onChange(item, collapsed) {
|
|
|
+ if (!item) return;
|
|
|
+ item.getModel().collapsed = collapsed;
|
|
|
+ return true;
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ layout: {
|
|
|
+ type: 'mindmap',
|
|
|
+ direction: 'H',
|
|
|
+ getHeight: () => 32,
|
|
|
+ getWidth: () => 140,
|
|
|
+ getVGap: () => 32,
|
|
|
+ getHGap: () => 110,
|
|
|
+ },
|
|
|
+ defaultNode: {
|
|
|
+ size: 22,
|
|
|
+ style: {
|
|
|
+ stroke: '#94a3b8',
|
|
|
+ fill: '#fff',
|
|
|
+ radius: 4,
|
|
|
+ shadowColor: undefined,
|
|
|
+ shadowBlur: 0,
|
|
|
+ lineWidth: 3,
|
|
|
+ },
|
|
|
+ labelCfg: {
|
|
|
+ style: {
|
|
|
+ fontSize: 13,
|
|
|
+ fill: '#0f172a',
|
|
|
+ fontWeight: 500,
|
|
|
+ },
|
|
|
+ position: 'right',
|
|
|
+ offset: 12,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ defaultEdge: {
|
|
|
+ type: 'cubic-horizontal',
|
|
|
+ style: {
|
|
|
+ stroke: '#cbd5f5',
|
|
|
+ lineWidth: 3,
|
|
|
+ shadowBlur: 0,
|
|
|
+ shadowColor: undefined,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ nodeStateStyles: {
|
|
|
+ selected: {
|
|
|
+ lineWidth: 3.2,
|
|
|
+ stroke: '#2563eb',
|
|
|
+ fill: '#e0f2fe',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ edgeStateStyles: {
|
|
|
+ highlight: {
|
|
|
+ lineWidth: 3.4,
|
|
|
+ stroke: '#fb923c',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ plugins: [],
|
|
|
+ };
|
|
|
+ this.graph = new TreeGraphClass(graphConfig);
|
|
|
+ if (typeof this.graph.data === 'function') {
|
|
|
+ this.graph.data(graphData);
|
|
|
+ } else if (typeof this.graph.changeData === 'function') {
|
|
|
+ this.graph.changeData(graphData);
|
|
|
+ }
|
|
|
+ if (typeof this.graph.render === 'function') {
|
|
|
+ this.graph.render();
|
|
|
+ }
|
|
|
+ this.graph.data(this.decorateTree(this.treeData));
|
|
|
+ this.graph.render();
|
|
|
+ this.graph.fitView(24);
|
|
|
+ this.drawRelationEdges();
|
|
|
+ this.bindEvents();
|
|
|
+ this.graph.on('node:mouseenter', (evt) => {
|
|
|
+ const { clientX, clientY } = evt;
|
|
|
+ showTooltip(this.buildTooltip(evt?.item?.getModel()), clientX, clientY);
|
|
|
+ });
|
|
|
+ this.graph.on('node:mouseleave', hideTooltip);
|
|
|
+ this.graph.on('edge:mouseenter', (evt) => {
|
|
|
+ const model = evt?.item?.getModel() || {};
|
|
|
+ const relation = model.label || '关联关系';
|
|
|
+ const text = `${model.source || ''} → ${model.target || ''}`;
|
|
|
+ const comment = model.comment ? `<div class="text-[11px] text-gray-600 mt-1 whitespace-pre-line">${model.comment}</div>` : '';
|
|
|
+ const html = `
|
|
|
+ <div class="rounded-md border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700 shadow-md">
|
|
|
+ <div class="font-semibold text-gray-900 mb-1">${relation}</div>
|
|
|
+ <div>${text}</div>
|
|
|
+ ${comment}
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ const { clientX, clientY } = evt;
|
|
|
+ showTooltip(html, clientX, clientY);
|
|
|
+ });
|
|
|
+ this.graph.on('edge:mouseleave', hideTooltip);
|
|
|
+ const canvas = this.graph && typeof this.graph.get === 'function' ? this.graph.get('canvas') : null;
|
|
|
+ if (canvas && typeof canvas.set === 'function') {
|
|
|
+ canvas.set('localRefresh', false);
|
|
|
+ const ctx = typeof canvas.get === 'function' ? canvas.get('context') : null;
|
|
|
+ if (ctx) {
|
|
|
+ ctx.shadowColor = 'transparent';
|
|
|
+ ctx.shadowBlur = 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ decorateTree(node) {
|
|
|
+ if (!node) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 动态获取最新掌握度数据对象
|
|
|
+ const id = node.id;
|
|
|
+ const code = node.meta?.code;
|
|
|
+ // masteryData 现在是对象 { 'KP_CODE': { mastery_level: 0.8, total_attempts: 5, ... } }
|
|
|
+ const masteryInfo = this.masteryData[id] || (code && this.masteryData[code]) || null;
|
|
|
+
|
|
|
+ const masteryLevel = masteryInfo ? (masteryInfo.mastery_level || 0) : (node.meta.mastery_level || 0);
|
|
|
+ const totalAttempts = masteryInfo ? (masteryInfo.total_attempts || 0) : 0;
|
|
|
+
|
|
|
+ const { nodeStyle, labelCfg, size, icon } = this.getNodeLevelStyle(node.depth, masteryLevel, totalAttempts);
|
|
|
+
|
|
|
+ // 构建带图标的标签
|
|
|
+ let label = `${node.meta.code ? `${node.meta.code} · ` : ''}${node.label}`;
|
|
|
+ if (icon) {
|
|
|
+ label += ` ${icon}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 更新meta中的掌握度,以便tooltip使用
|
|
|
+ const meta = {
|
|
|
+ ...node.meta,
|
|
|
+ mastery_level: masteryLevel,
|
|
|
+ total_attempts: totalAttempts,
|
|
|
+ mastery_info: masteryInfo
|
|
|
+ };
|
|
|
+
|
|
|
+ return {
|
|
|
+ id: node.id,
|
|
|
+ label: label,
|
|
|
+ meta: meta,
|
|
|
+ collapsed: node.collapsed,
|
|
|
+ depth: node.depth,
|
|
|
+ size,
|
|
|
+ style: nodeStyle,
|
|
|
+ labelCfg,
|
|
|
+ children: node.children.map((child) => this.decorateTree(child)).filter(Boolean),
|
|
|
+ };
|
|
|
+ },
|
|
|
+ getNodeLevelStyle(depth = 0, masteryLevel = 0, totalAttempts = 0) {
|
|
|
+ const style = this.levelStyles[depth] || this.levelStyles[this.levelStyles.length - 1];
|
|
|
+
|
|
|
+ // 根据掌握度调整颜色和样式
|
|
|
+ let fillColor, strokeColor, shadowColor, shadowBlur, icon;
|
|
|
+
|
|
|
+ // 只要有答题记录(totalAttempts > 0),即使掌握度为0,也视为"薄弱"(红色)
|
|
|
+ // 如果没有答题记录,则保持默认样式(白色)
|
|
|
+ const hasAttempts = totalAttempts > 0;
|
|
|
+
|
|
|
+ if (masteryLevel >= 85) {
|
|
|
+ // 85%以上:大师级(绿色 + 光晕 + 星星)
|
|
|
+ fillColor = '#dcfce7'; // green-100
|
|
|
+ strokeColor = '#16a34a'; // green-600
|
|
|
+ shadowColor = 'rgba(34, 197, 94, 0.6)';
|
|
|
+ shadowBlur = 10;
|
|
|
+ icon = '★';
|
|
|
+ } else if (masteryLevel >= 60) {
|
|
|
+ // 60-85%:良好(黄色)
|
|
|
+ fillColor = '#fef9c3'; // yellow-100
|
|
|
+ strokeColor = '#ca8a04'; // yellow-600
|
|
|
+ shadowColor = undefined;
|
|
|
+ shadowBlur = 0;
|
|
|
+ icon = '';
|
|
|
+ } else if (masteryLevel > 0 || hasAttempts) {
|
|
|
+ // 1-60% 或 掌握度为0但有答题记录:薄弱(红色)
|
|
|
+ fillColor = '#fee2e2'; // red-100
|
|
|
+ strokeColor = '#dc2626'; // red-600
|
|
|
+ shadowColor = undefined;
|
|
|
+ shadowBlur = 0;
|
|
|
+ icon = '';
|
|
|
+ } else {
|
|
|
+ // 未掌握且无记录:默认
|
|
|
+ fillColor = style.fill || '#fff';
|
|
|
+ strokeColor = style.stroke || '#cbd5f5';
|
|
|
+ shadowColor = undefined;
|
|
|
+ shadowBlur = 0;
|
|
|
+ icon = '';
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ size: style.size || 22,
|
|
|
+ icon,
|
|
|
+ nodeStyle: {
|
|
|
+ fill: fillColor,
|
|
|
+ stroke: strokeColor,
|
|
|
+ lineWidth: masteryLevel > 0 ? 3 : 3, // 保持一致线条宽度,靠颜色区分
|
|
|
+ radius: 6,
|
|
|
+ shadowColor: shadowColor,
|
|
|
+ shadowBlur: shadowBlur,
|
|
|
+ cursor: 'pointer',
|
|
|
+ },
|
|
|
+ labelCfg: {
|
|
|
+ position: 'right',
|
|
|
+ offset: 12,
|
|
|
+ style: {
|
|
|
+ fontSize: style.fontSize || 13,
|
|
|
+ fontWeight: style.fontWeight || 600,
|
|
|
+ fill: style.labelColor || '#0f172a',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ };
|
|
|
+ },
|
|
|
+ drawRelationEdges() {
|
|
|
+ if (!this.graph || !this.relationEdges.length) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.relationEdges.forEach((edge, index) => {
|
|
|
+ const style = { ...(edge.style || {}), lineAppendWidth: 14 };
|
|
|
+ if (style.endArrow && !style.endArrow.path) {
|
|
|
+ style.endArrow = {
|
|
|
+ ...style.endArrow,
|
|
|
+ path: this.arrow(style.endArrow.d || 10, (style.endArrow.d || 10) + 2, 4),
|
|
|
+ };
|
|
|
+ }
|
|
|
+ this.graph.addItem('edge', {
|
|
|
+ id: `extra-${index}`,
|
|
|
+ source: edge.source,
|
|
|
+ target: edge.target,
|
|
|
+ type: edge.type || 'quadratic',
|
|
|
+ curveOffset: edge.curveOffset || 50,
|
|
|
+ style,
|
|
|
+ label: edge.label,
|
|
|
+ comment: edge.comment || '',
|
|
|
+ labelCfg: {
|
|
|
+ autoRotate: true,
|
|
|
+ style: {
|
|
|
+ fill: '#475569',
|
|
|
+ fontSize: 11,
|
|
|
+ background: {
|
|
|
+ fill: 'rgba(255,255,255,0.85)',
|
|
|
+ padding: [2, 4],
|
|
|
+ radius: 4,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ });
|
|
|
+ });
|
|
|
+ },
|
|
|
+ buildTooltip(model) {
|
|
|
+ const meta = model?.meta;
|
|
|
+ if (!meta) {
|
|
|
+ return '<div class="text-xs text-gray-600">无数据</div>';
|
|
|
+ }
|
|
|
+ const range = (value) => (value?.length ? `${value[0]}-${value[1]}` : '未配置');
|
|
|
+ const mastery = meta.mastery_level || 0;
|
|
|
+ const attempts = meta.total_attempts || 0;
|
|
|
+
|
|
|
+ // 进度条颜色
|
|
|
+ let progressColorClass = 'bg-gray-300';
|
|
|
+ let masteryColor = '#9ca3af';
|
|
|
+
|
|
|
+ if (mastery >= 85) {
|
|
|
+ progressColorClass = 'bg-green-500';
|
|
|
+ masteryColor = '#22c55e';
|
|
|
+ } else if (mastery >= 60) {
|
|
|
+ progressColorClass = 'bg-yellow-500';
|
|
|
+ masteryColor = '#eab308';
|
|
|
+ } else if (mastery > 0 || attempts > 0) {
|
|
|
+ progressColorClass = 'bg-red-500';
|
|
|
+ masteryColor = '#ef4444';
|
|
|
+ }
|
|
|
+
|
|
|
+ const skills = (meta.skills || []).map(s => `<li class="text-[10px] text-gray-600">• ${s}</li>`).join('') || '<li class="text-[10px] text-gray-400 italic">暂无技能要点</li>';
|
|
|
+
|
|
|
+ // 下一级所需经验(模拟)
|
|
|
+ const nextLevel = mastery >= 100 ? '已满级' : `距离下一级还需 ${Math.max(0, 100 - mastery)} 点`;
|
|
|
+
|
|
|
+ return `
|
|
|
+ <div class="min-w-[260px] max-w-sm rounded-lg border border-gray-200 bg-white p-4 text-xs text-gray-700 shadow-xl">
|
|
|
+ <div class="flex items-center justify-between mb-2">
|
|
|
+ <div class="text-sm font-bold text-gray-900">${meta.code || model.id} · ${meta.name}</div>
|
|
|
+ ${mastery >= 85 ? '<span class="px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700 text-[10px] font-bold border border-yellow-200">★ 大师</span>' : ''}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 掌握度进度条 -->
|
|
|
+ <div class="mb-3">
|
|
|
+ <div class="flex justify-between text-[10px] text-gray-500 mb-1">
|
|
|
+ <span>掌握度 Lv.${Math.floor(mastery / 10)} <span class="text-gray-400 ml-1">(${attempts}次练习)</span></span>
|
|
|
+ <span class="font-medium" style="color: ${masteryColor}">${mastery}%</span>
|
|
|
+ </div>
|
|
|
+ <div class="h-2 w-full rounded-full bg-gray-100 overflow-hidden">
|
|
|
+ <div class="h-full rounded-full ${progressColorClass} transition-all duration-500" style="width: ${mastery}%"></div>
|
|
|
+ </div>
|
|
|
+ <div class="mt-1 text-[10px] text-gray-400 text-right">${nextLevel}</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="grid grid-cols-2 gap-2 mb-3 bg-gray-50 p-2 rounded border border-gray-100">
|
|
|
+ <div>
|
|
|
+ <div class="text-[10px] text-gray-500">直接得分</div>
|
|
|
+ <div class="font-medium">${range(meta.direct_score)}</div>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <div class="text-[10px] text-gray-500">关联得分</div>
|
|
|
+ <div class="font-medium">${range(meta.related_score)}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <div class="font-medium mb-1 flex items-center gap-1">
|
|
|
+ <svg class="w-3 h-3 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
|
|
+ 技能要点
|
|
|
+ </div>
|
|
|
+ <ul class="list-none space-y-1 pl-1">
|
|
|
+ ${skills}
|
|
|
+ </ul>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ },
|
|
|
+ bindEvents() {
|
|
|
+ if (!this.graph) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.graph.on('node:click', (evt) => {
|
|
|
+ const nodeId = evt?.item?.getID();
|
|
|
+ if (nodeId) {
|
|
|
+ this.highlightEdges(nodeId);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ this.graph.on('canvas:click', () => this.resetHighlight());
|
|
|
+ },
|
|
|
+ highlightEdges(nodeId) {
|
|
|
+ this.graph.getNodes().forEach((node) => {
|
|
|
+ this.graph.clearItemStates(node);
|
|
|
+ if (node.getID() === nodeId) {
|
|
|
+ this.graph.setItemState(node, 'selected', true);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ this.graph.getEdges().forEach((edge) => {
|
|
|
+ const { source, target } = edge.getModel();
|
|
|
+ const linked = source === nodeId || target === nodeId;
|
|
|
+ if (linked) {
|
|
|
+ this.graph.setItemState(edge, 'highlight', true);
|
|
|
+ } else {
|
|
|
+ this.graph.clearItemStates(edge);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ },
|
|
|
+ resetHighlight() {
|
|
|
+ if (!this.graph) return;
|
|
|
+ this.graph.getNodes().forEach((node) => this.graph.clearItemStates(node));
|
|
|
+ this.graph.getEdges().forEach((edge) => this.graph.clearItemStates(edge));
|
|
|
+ },
|
|
|
+ resizeGraph() {
|
|
|
+ if (!this.graph) return;
|
|
|
+ const container = document.getElementById('knowledge-mindmap');
|
|
|
+ if (!container) return;
|
|
|
+ this.graph.changeSize(container.clientWidth, container.clientHeight);
|
|
|
+ this.graph.fitView(24);
|
|
|
+ },
|
|
|
+ });
|
|
|
+ });
|
|
|
+ </script>
|
|
|
+@endpush
|