| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934 |
- <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
|