|
|
@@ -6,14 +6,42 @@
|
|
|
>
|
|
|
<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>
|
|
|
- <h2 class="text-lg font-semibold text-gray-900">初中数学知识图谱 · 思维导图</h2>
|
|
|
- <p class="text-sm text-gray-500">
|
|
|
- tree.json 提供完整层级(模块 → 知识点),edges.json 描述跨节点关系;基于 AntV G6 MindMap 布局,节点可逐层展开/折叠并叠加前置/后继/兄弟/联合连线。
|
|
|
- </p>
|
|
|
+ <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="stats.extraEdges"></span></div>
|
|
|
+ <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">
|
|
|
@@ -29,11 +57,27 @@
|
|
|
<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="h-[80vh] min-h-[720px] w-full rounded-lg border border-gray-200 bg-white"
|
|
|
></div>
|
|
|
@@ -49,6 +93,12 @@
|
|
|
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);
|
|
|
@@ -180,14 +230,24 @@
|
|
|
rel.style.endArrow.path = this.arrow(rel.style.endArrow.d || 10, (rel.style.endArrow.d || 10) + 2, 4);
|
|
|
}
|
|
|
});
|
|
|
- await this.loadData();
|
|
|
+ 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'),
|
|
|
@@ -203,18 +263,33 @@
|
|
|
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,
|
|
|
@@ -423,11 +498,36 @@
|
|
|
if (!node) {
|
|
|
return null;
|
|
|
}
|
|
|
- const { nodeStyle, labelCfg, size } = this.getNodeLevelStyle(node.depth);
|
|
|
+
|
|
|
+ // 动态获取最新掌握度数据对象
|
|
|
+ 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: `${node.meta.code ? `${node.meta.code} · ` : ''}${node.label}`,
|
|
|
- meta: node.meta,
|
|
|
+ label: label,
|
|
|
+ meta: meta,
|
|
|
collapsed: node.collapsed,
|
|
|
depth: node.depth,
|
|
|
size,
|
|
|
@@ -436,17 +536,57 @@
|
|
|
children: node.children.map((child) => this.decorateTree(child)).filter(Boolean),
|
|
|
};
|
|
|
},
|
|
|
- getNodeLevelStyle(depth = 0) {
|
|
|
+ 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: style.fill || '#fff',
|
|
|
- stroke: style.stroke || '#cbd5f5',
|
|
|
- lineWidth: 3,
|
|
|
+ fill: fillColor,
|
|
|
+ stroke: strokeColor,
|
|
|
+ lineWidth: masteryLevel > 0 ? 3 : 3, // 保持一致线条宽度,靠颜色区分
|
|
|
radius: 6,
|
|
|
- shadowColor: undefined,
|
|
|
- shadowBlur: 0,
|
|
|
+ shadowColor: shadowColor,
|
|
|
+ shadowBlur: shadowBlur,
|
|
|
+ cursor: 'pointer',
|
|
|
},
|
|
|
labelCfg: {
|
|
|
position: 'right',
|
|
|
@@ -501,20 +641,65 @@
|
|
|
return '<div class="text-xs text-gray-600">无数据</div>';
|
|
|
}
|
|
|
const range = (value) => (value?.length ? `${value[0]}-${value[1]}` : '未配置');
|
|
|
- const skills = (meta.skills || [])
|
|
|
- .slice(0, 6)
|
|
|
- .map((skill) => `<li class="leading-snug">${skill.trim()}</li>`)
|
|
|
- .join('') || '<li>暂无技能</li>';
|
|
|
+ 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-[230px] max-w-sm rounded-md border border-gray-200 bg-white p-3 text-xs text-gray-700 shadow-lg">
|
|
|
- <div class="text-sm font-semibold text-gray-900 mb-1">${meta.code || model.id} · ${meta.name}</div>
|
|
|
- <div class="flex gap-3 text-xs">
|
|
|
- <span>直接:${range(meta.direct_score)}</span>
|
|
|
- <span>关联:${range(meta.related_score)}</span>
|
|
|
+ <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="mt-2">
|
|
|
- <div class="font-medium">技能要点</div>
|
|
|
- <ul class="list-disc pl-5 space-y-0.5">
|
|
|
+
|
|
|
+ <!-- 掌握度进度条 -->
|
|
|
+ <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>
|