knowledge-mindmap.blade.php 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753
  1. <x-filament::page>
  2. <div
  3. class="space-y-4"
  4. x-data="knowledgeMindmap()"
  5. x-init="initMindmap()"
  6. >
  7. <div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
  8. <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
  9. <div class="flex-1">
  10. <h2 class="text-lg font-semibold text-gray-900">初中数学知识图谱</h2>
  11. <div class="mt-2 flex gap-4 text-xs text-gray-500">
  12. <div>知识点总数:<span x-text="stats.nodes"></span></div>
  13. <div>已选中学生:<span x-text="$wire.selectedStudentName || '未选择'"></span></div>
  14. </div>
  15. <!-- 学生选择器 -->
  16. <div class="mt-3 grid grid-cols-2 gap-3 max-w-md">
  17. <div>
  18. <label class="block text-xs font-medium text-gray-700 mb-1">选择老师</label>
  19. <select
  20. wire:model.live="selectedTeacherId"
  21. 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"
  22. >
  23. <option value="">请选择老师...</option>
  24. @foreach($teachers as $teacher)
  25. <option value="{{ $teacher['teacher_id'] }}">{{ $teacher['name'] }}</option>
  26. @endforeach
  27. </select>
  28. </div>
  29. <div>
  30. <label class="block text-xs font-medium text-gray-700 mb-1">选择学生</label>
  31. <select
  32. wire:model.live="selectedStudentId"
  33. 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"
  34. {{ empty($selectedTeacherId) ? 'disabled' : '' }}
  35. >
  36. <option value="">
  37. {{ empty($selectedTeacherId) ? '请先选择老师...' : '请选择学生...' }}
  38. </option>
  39. @foreach($students as $student)
  40. <option value="{{ $student['student_id'] }}">{{ $student['name'] }}</option>
  41. @endforeach
  42. </select>
  43. </div>
  44. </div>
  45. </div>
  46. <div class="flex flex-wrap gap-3 text-xs text-gray-600">
  47. <span class="inline-flex items-center gap-1">
  48. <span class="h-2 w-4 rounded-full bg-blue-500"></span> 前置
  49. </span>
  50. <span class="inline-flex items-center gap-1">
  51. <span class="h-2 w-4 rounded-full bg-red-500"></span> 后继
  52. </span>
  53. <span class="inline-flex items-center gap-1">
  54. <span class="h-2 w-4 rounded-full border border-dashed border-gray-500"></span> 兄弟
  55. </span>
  56. <span class="inline-flex items-center gap-1">
  57. <span class="h-2 w-4 rounded-full bg-yellow-400"></span> 联合考查
  58. </span>
  59. <span class="inline-flex items-center gap-1 ml-2 border-l border-gray-300 pl-2">
  60. <span class="h-2 w-4 rounded-full" style="background: linear-gradient(90deg, #ef4444 0%, #22c55e 100%);"></span> 掌握度
  61. </span>
  62. <span class="inline-flex items-center gap-1 text-xs">
  63. <span class="text-red-500">●</span> < 60% (薄弱)
  64. </span>
  65. <span class="inline-flex items-center gap-1 text-xs">
  66. <span class="text-yellow-500">●</span> 60-85% (良好)
  67. </span>
  68. <span class="inline-flex items-center gap-1 text-xs">
  69. <span class="text-green-500">●</span> > 85% (优秀)
  70. </span>
  71. <span class="inline-flex items-center gap-1 text-xs">
  72. <span class="text-yellow-400 text-sm">★</span> 大师级
  73. </span>
  74. </div>
  75. </div>
  76. </div>
  77. <div
  78. wire:ignore
  79. id="knowledge-mindmap"
  80. class="h-[80vh] min-h-[720px] w-full rounded-lg border border-gray-200 bg-white"
  81. ></div>
  82. </div>
  83. </x-filament::page>
  84. @push('scripts')
  85. <script src="https://gw.alipayobjects.com/os/lib/antv/g6/5.0.18/dist/g6.min.js"></script>
  86. <script>
  87. document.addEventListener('alpine:init', () => {
  88. window.knowledgeMindmap = () => ({
  89. graph: null,
  90. treeData: null,
  91. relationEdges: [],
  92. stats: { nodes: 0, extraEdges: 0 },
  93. // 学生选择相关
  94. // 学生选择相关 - 现在由Livewire管理
  95. masteryData: {}, // 存储掌握度数据 { 'F01': 80, 'F02': 65, ... }
  96. arrow(w = 12, h = 14, r = 5) {
  97. if (window.G6?.Arrow?.triangle) {
  98. return G6.Arrow.triangle(w, h, r);
  99. }
  100. return [
  101. ['M', 0, 0],
  102. ['L', w, h / 2],
  103. ['L', 0, h],
  104. ['Z'],
  105. ];
  106. },
  107. levelStyles: [
  108. {
  109. fill: '#0ea5e9',
  110. stroke: '#0369a1',
  111. labelColor: '#0f172a',
  112. fontSize: 17,
  113. fontWeight: 700,
  114. size: 34,
  115. },
  116. {
  117. fill: '#e0f2fe',
  118. stroke: '#38bdf8',
  119. labelColor: '#0f172a',
  120. fontSize: 16,
  121. fontWeight: 700,
  122. size: 30,
  123. },
  124. {
  125. fill: '#f1f5f9',
  126. stroke: '#cbd5e1',
  127. labelColor: '#0f172a',
  128. fontSize: 14,
  129. fontWeight: 600,
  130. size: 26,
  131. },
  132. ],
  133. relationStyles: {
  134. prerequisite: {
  135. type: 'quadratic',
  136. curveOffset: 60,
  137. style: {
  138. stroke: '#2563eb',
  139. lineWidth: 3.4,
  140. lineDash: [8, 6],
  141. endArrow: {
  142. path: null,
  143. fill: '#2563eb',
  144. d: 12,
  145. },
  146. startArrow: false,
  147. },
  148. label: '前置',
  149. },
  150. successor: {
  151. type: 'quadratic',
  152. curveOffset: 60,
  153. style: {
  154. stroke: '#dc2626',
  155. lineWidth: 3.4,
  156. lineDash: [8, 6],
  157. endArrow: {
  158. path: null,
  159. fill: '#dc2626',
  160. d: 12,
  161. },
  162. startArrow: false,
  163. },
  164. label: '后继',
  165. },
  166. sibling: {
  167. type: 'quadratic',
  168. curveOffset: 50,
  169. style: {
  170. stroke: '#64748b',
  171. lineDash: [6, 6],
  172. lineWidth: 3,
  173. endArrow: {
  174. path: null,
  175. fill: '#64748b',
  176. d: 10,
  177. },
  178. },
  179. label: '兄弟',
  180. },
  181. joint: {
  182. type: 'quadratic',
  183. curveOffset: 50,
  184. style: {
  185. stroke: '#fcd34d',
  186. lineWidth: 3,
  187. lineDash: [10, 8],
  188. endArrow: {
  189. path: null,
  190. fill: '#fbbf24',
  191. d: 10,
  192. },
  193. },
  194. label: '联合',
  195. },
  196. default: {
  197. type: 'quadratic',
  198. curveOffset: 50,
  199. style: {
  200. stroke: '#94a3b8',
  201. lineWidth: 3,
  202. lineDash: [10, 8],
  203. endArrow: {
  204. path: null,
  205. fill: '#94a3b8',
  206. d: 10,
  207. },
  208. },
  209. label: '',
  210. },
  211. },
  212. async initMindmap() {
  213. try {
  214. if (this.$nextTick) {
  215. await this.$nextTick();
  216. }
  217. if (!window.G6) {
  218. console.error('G6 未加载');
  219. return;
  220. }
  221. Object.keys(this.relationStyles).forEach((key) => {
  222. const rel = this.relationStyles[key];
  223. if (rel?.style && rel.style.endArrow && !rel.style.endArrow.path) {
  224. rel.style.endArrow.path = this.arrow(rel.style.endArrow.d || 10, (rel.style.endArrow.d || 10) + 2, 4);
  225. }
  226. });
  227. await Promise.all([
  228. this.loadData(),
  229. ]);
  230. this.applyInitialCollapse(this.treeData);
  231. this.renderGraph();
  232. window.addEventListener('resize', () => this.resizeGraph());
  233. // 监听 Livewire 事件
  234. window.addEventListener('mastery-updated', (event) => {
  235. console.log('Mastery updated:', event.detail.data);
  236. this.masteryData = event.detail.data || {};
  237. this.refreshGraph();
  238. });
  239. } catch (err) {
  240. console.error('初始化思维导图失败', err);
  241. }
  242. },
  243. // loadTeachers, loadStudents, loadMasteryData 已移除,由 Livewire 处理
  244. async loadData() {
  245. const [treeResp, edgesResp] = await Promise.all([
  246. fetch('/data/tree.json'),
  247. fetch('/data/edges.json'),
  248. ]);
  249. const rawTree = await treeResp.json();
  250. const edges = await edgesResp.json();
  251. const rawEdges = Array.isArray(edges) ? edges : edges?.edges || [];
  252. this.treeData = this.transformNode(rawTree);
  253. this.relationEdges = this.normalizeEdges(rawEdges);
  254. this.stats = {
  255. nodes: this.countNodes(this.treeData),
  256. extraEdges: this.relationEdges.length,
  257. };
  258. },
  259. refreshGraph() {
  260. if (this.graph && this.treeData) {
  261. // 重新装饰树数据以应用掌握度
  262. const decoratedData = this.decorateTree(this.treeData);
  263. this.graph.changeData(decoratedData);
  264. this.graph.render();
  265. this.graph.fitView(24);
  266. }
  267. },
  268. transformNode(node, depth = 0) {
  269. if (!node) {
  270. return null;
  271. }
  272. const id = node.code || node.id || node.label || `node-${Math.random().toString(36).slice(2, 8)}`;
  273. const label = node.name || node.label || node.code || node.id || '未命名节点';
  274. // 优先使用动态掌握度数据,其次回退到静态数据
  275. const dynamicMastery = this.masteryData[id] || this.masteryData[node.code] || 0;
  276. const staticMastery = node.mastery_level || 0;
  277. const meta = {
  278. code: node.code || node.id || '',
  279. name: label,
  280. direct_score: node.direct_score || [],
  281. related_score: node.related_score || [],
  282. skills: node.skills || [],
  283. mastery_level: dynamicMastery || staticMastery, // 动态掌握度优先
  284. };
  285. return {
  286. id,
  287. label,
  288. meta,
  289. depth,
  290. children: (node.children || []).map((child) => this.transformNode(child, depth + 1)).filter(Boolean),
  291. };
  292. },
  293. applyInitialCollapse(node, depth = 0) {
  294. if (!node) {
  295. return;
  296. }
  297. if (depth >= 2 && node.children.length > 0) {
  298. node.collapsed = true;
  299. }
  300. node.children.forEach((child) => this.applyInitialCollapse(child, depth + 1));
  301. },
  302. countNodes(node) {
  303. if (!node) {
  304. return 0;
  305. }
  306. return 1 + node.children.reduce((sum, child) => sum + this.countNodes(child), 0);
  307. },
  308. normalizeEdges(rawEdges) {
  309. const seen = new Set();
  310. const normalized = [];
  311. (rawEdges || []).forEach((edge, index) => {
  312. if (!edge?.source || !edge?.target) {
  313. return;
  314. }
  315. const key = `${edge.source}-${edge.target}-${edge.type}`;
  316. if (seen.has(key)) {
  317. return;
  318. }
  319. seen.add(key);
  320. const relationStyle = this.relationStyles[edge.type] || this.relationStyles.default;
  321. normalized.push({
  322. id: `rel-${index}-${edge.source}-${edge.target}`,
  323. source: edge.source,
  324. target: edge.target,
  325. type: relationStyle.type || 'quadratic',
  326. curveOffset: relationStyle.curveOffset || 50,
  327. style: relationStyle.style,
  328. label: relationStyle.label,
  329. comment: edge.comment || edge.note || '',
  330. });
  331. });
  332. return normalized;
  333. },
  334. renderGraph(containerEl = null) {
  335. if (!this.treeData) {
  336. return;
  337. }
  338. const container = containerEl || document.getElementById('knowledge-mindmap');
  339. if (!container) {
  340. console.error('容器未找到');
  341. return;
  342. }
  343. const ensuredId = container.id || 'knowledge-mindmap';
  344. if (!container.id) {
  345. container.id = ensuredId;
  346. }
  347. const bounds = container.getBoundingClientRect();
  348. const width = Math.max(bounds.width, 600);
  349. const height = Math.max(bounds.height, 600);
  350. const tooltipEl = document.createElement('div');
  351. tooltipEl.className = 'fixed z-50 pointer-events-none hidden';
  352. document.body.appendChild(tooltipEl);
  353. const showTooltip = (html, x, y) => {
  354. tooltipEl.innerHTML = html;
  355. tooltipEl.style.left = `${x + 12}px`;
  356. tooltipEl.style.top = `${y + 12}px`;
  357. tooltipEl.classList.remove('hidden');
  358. };
  359. const hideTooltip = () => {
  360. tooltipEl.classList.add('hidden');
  361. tooltipEl.innerHTML = '';
  362. };
  363. const G6Lib = window.G6?.default || window.G6;
  364. const TreeGraphClass = G6Lib?.TreeGraph || null;
  365. if (!TreeGraphClass) {
  366. console.error('G6 TreeGraph 不可用');
  367. return;
  368. }
  369. const graphData = this.decorateTree(this.treeData);
  370. const graphConfig = {
  371. container: ensuredId,
  372. width,
  373. height,
  374. data: graphData,
  375. linkCenter: true,
  376. modes: {
  377. default: [
  378. 'drag-canvas',
  379. 'zoom-canvas',
  380. {
  381. type: 'collapse-expand',
  382. trigger: 'click',
  383. onChange: function onChange(item, collapsed) {
  384. if (!item) return;
  385. item.getModel().collapsed = collapsed;
  386. return true;
  387. },
  388. },
  389. ],
  390. },
  391. layout: {
  392. type: 'mindmap',
  393. direction: 'H',
  394. getHeight: () => 32,
  395. getWidth: () => 140,
  396. getVGap: () => 32,
  397. getHGap: () => 110,
  398. },
  399. defaultNode: {
  400. size: 22,
  401. style: {
  402. stroke: '#94a3b8',
  403. fill: '#fff',
  404. radius: 4,
  405. shadowColor: undefined,
  406. shadowBlur: 0,
  407. lineWidth: 3,
  408. },
  409. labelCfg: {
  410. style: {
  411. fontSize: 13,
  412. fill: '#0f172a',
  413. fontWeight: 500,
  414. },
  415. position: 'right',
  416. offset: 12,
  417. },
  418. },
  419. defaultEdge: {
  420. type: 'cubic-horizontal',
  421. style: {
  422. stroke: '#cbd5f5',
  423. lineWidth: 3,
  424. shadowBlur: 0,
  425. shadowColor: undefined,
  426. },
  427. },
  428. nodeStateStyles: {
  429. selected: {
  430. lineWidth: 3.2,
  431. stroke: '#2563eb',
  432. fill: '#e0f2fe',
  433. },
  434. },
  435. edgeStateStyles: {
  436. highlight: {
  437. lineWidth: 3.4,
  438. stroke: '#fb923c',
  439. },
  440. },
  441. plugins: [],
  442. };
  443. this.graph = new TreeGraphClass(graphConfig);
  444. if (typeof this.graph.data === 'function') {
  445. this.graph.data(graphData);
  446. } else if (typeof this.graph.changeData === 'function') {
  447. this.graph.changeData(graphData);
  448. }
  449. if (typeof this.graph.render === 'function') {
  450. this.graph.render();
  451. }
  452. this.graph.data(this.decorateTree(this.treeData));
  453. this.graph.render();
  454. this.graph.fitView(24);
  455. this.drawRelationEdges();
  456. this.bindEvents();
  457. this.graph.on('node:mouseenter', (evt) => {
  458. const { clientX, clientY } = evt;
  459. showTooltip(this.buildTooltip(evt?.item?.getModel()), clientX, clientY);
  460. });
  461. this.graph.on('node:mouseleave', hideTooltip);
  462. this.graph.on('edge:mouseenter', (evt) => {
  463. const model = evt?.item?.getModel() || {};
  464. const relation = model.label || '关联关系';
  465. const text = `${model.source || ''} → ${model.target || ''}`;
  466. const comment = model.comment ? `<div class="text-[11px] text-gray-600 mt-1 whitespace-pre-line">${model.comment}</div>` : '';
  467. const html = `
  468. <div class="rounded-md border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700 shadow-md">
  469. <div class="font-semibold text-gray-900 mb-1">${relation}</div>
  470. <div>${text}</div>
  471. ${comment}
  472. </div>
  473. `;
  474. const { clientX, clientY } = evt;
  475. showTooltip(html, clientX, clientY);
  476. });
  477. this.graph.on('edge:mouseleave', hideTooltip);
  478. const canvas = this.graph && typeof this.graph.get === 'function' ? this.graph.get('canvas') : null;
  479. if (canvas && typeof canvas.set === 'function') {
  480. canvas.set('localRefresh', false);
  481. const ctx = typeof canvas.get === 'function' ? canvas.get('context') : null;
  482. if (ctx) {
  483. ctx.shadowColor = 'transparent';
  484. ctx.shadowBlur = 0;
  485. }
  486. }
  487. },
  488. decorateTree(node) {
  489. if (!node) {
  490. return null;
  491. }
  492. // 动态获取最新掌握度数据对象
  493. const id = node.id;
  494. const code = node.meta?.code;
  495. // masteryData 现在是对象 { 'KP_CODE': { mastery_level: 0.8, total_attempts: 5, ... } }
  496. const masteryInfo = this.masteryData[id] || (code && this.masteryData[code]) || null;
  497. const masteryLevel = masteryInfo ? (masteryInfo.mastery_level || 0) : (node.meta.mastery_level || 0);
  498. const totalAttempts = masteryInfo ? (masteryInfo.total_attempts || 0) : 0;
  499. const { nodeStyle, labelCfg, size, icon } = this.getNodeLevelStyle(node.depth, masteryLevel, totalAttempts);
  500. // 构建带图标的标签
  501. let label = `${node.meta.code ? `${node.meta.code} · ` : ''}${node.label}`;
  502. if (icon) {
  503. label += ` ${icon}`;
  504. }
  505. // 更新meta中的掌握度,以便tooltip使用
  506. const meta = {
  507. ...node.meta,
  508. mastery_level: masteryLevel,
  509. total_attempts: totalAttempts,
  510. mastery_info: masteryInfo
  511. };
  512. return {
  513. id: node.id,
  514. label: label,
  515. meta: meta,
  516. collapsed: node.collapsed,
  517. depth: node.depth,
  518. size,
  519. style: nodeStyle,
  520. labelCfg,
  521. children: node.children.map((child) => this.decorateTree(child)).filter(Boolean),
  522. };
  523. },
  524. getNodeLevelStyle(depth = 0, masteryLevel = 0, totalAttempts = 0) {
  525. const style = this.levelStyles[depth] || this.levelStyles[this.levelStyles.length - 1];
  526. // 根据掌握度调整颜色和样式
  527. let fillColor, strokeColor, shadowColor, shadowBlur, icon;
  528. // 只要有答题记录(totalAttempts > 0),即使掌握度为0,也视为"薄弱"(红色)
  529. // 如果没有答题记录,则保持默认样式(白色)
  530. const hasAttempts = totalAttempts > 0;
  531. if (masteryLevel >= 85) {
  532. // 85%以上:大师级(绿色 + 光晕 + 星星)
  533. fillColor = '#dcfce7'; // green-100
  534. strokeColor = '#16a34a'; // green-600
  535. shadowColor = 'rgba(34, 197, 94, 0.6)';
  536. shadowBlur = 10;
  537. icon = '★';
  538. } else if (masteryLevel >= 60) {
  539. // 60-85%:良好(黄色)
  540. fillColor = '#fef9c3'; // yellow-100
  541. strokeColor = '#ca8a04'; // yellow-600
  542. shadowColor = undefined;
  543. shadowBlur = 0;
  544. icon = '';
  545. } else if (masteryLevel > 0 || hasAttempts) {
  546. // 1-60% 或 掌握度为0但有答题记录:薄弱(红色)
  547. fillColor = '#fee2e2'; // red-100
  548. strokeColor = '#dc2626'; // red-600
  549. shadowColor = undefined;
  550. shadowBlur = 0;
  551. icon = '';
  552. } else {
  553. // 未掌握且无记录:默认
  554. fillColor = style.fill || '#fff';
  555. strokeColor = style.stroke || '#cbd5f5';
  556. shadowColor = undefined;
  557. shadowBlur = 0;
  558. icon = '';
  559. }
  560. return {
  561. size: style.size || 22,
  562. icon,
  563. nodeStyle: {
  564. fill: fillColor,
  565. stroke: strokeColor,
  566. lineWidth: masteryLevel > 0 ? 3 : 3, // 保持一致线条宽度,靠颜色区分
  567. radius: 6,
  568. shadowColor: shadowColor,
  569. shadowBlur: shadowBlur,
  570. cursor: 'pointer',
  571. },
  572. labelCfg: {
  573. position: 'right',
  574. offset: 12,
  575. style: {
  576. fontSize: style.fontSize || 13,
  577. fontWeight: style.fontWeight || 600,
  578. fill: style.labelColor || '#0f172a',
  579. },
  580. },
  581. };
  582. },
  583. drawRelationEdges() {
  584. if (!this.graph || !this.relationEdges.length) {
  585. return;
  586. }
  587. this.relationEdges.forEach((edge, index) => {
  588. const style = { ...(edge.style || {}), lineAppendWidth: 14 };
  589. if (style.endArrow && !style.endArrow.path) {
  590. style.endArrow = {
  591. ...style.endArrow,
  592. path: this.arrow(style.endArrow.d || 10, (style.endArrow.d || 10) + 2, 4),
  593. };
  594. }
  595. this.graph.addItem('edge', {
  596. id: `extra-${index}`,
  597. source: edge.source,
  598. target: edge.target,
  599. type: edge.type || 'quadratic',
  600. curveOffset: edge.curveOffset || 50,
  601. style,
  602. label: edge.label,
  603. comment: edge.comment || '',
  604. labelCfg: {
  605. autoRotate: true,
  606. style: {
  607. fill: '#475569',
  608. fontSize: 11,
  609. background: {
  610. fill: 'rgba(255,255,255,0.85)',
  611. padding: [2, 4],
  612. radius: 4,
  613. },
  614. },
  615. },
  616. });
  617. });
  618. },
  619. buildTooltip(model) {
  620. const meta = model?.meta;
  621. if (!meta) {
  622. return '<div class="text-xs text-gray-600">无数据</div>';
  623. }
  624. const range = (value) => (value?.length ? `${value[0]}-${value[1]}` : '未配置');
  625. const mastery = meta.mastery_level || 0;
  626. const attempts = meta.total_attempts || 0;
  627. // 进度条颜色
  628. let progressColorClass = 'bg-gray-300';
  629. let masteryColor = '#9ca3af';
  630. if (mastery >= 85) {
  631. progressColorClass = 'bg-green-500';
  632. masteryColor = '#22c55e';
  633. } else if (mastery >= 60) {
  634. progressColorClass = 'bg-yellow-500';
  635. masteryColor = '#eab308';
  636. } else if (mastery > 0 || attempts > 0) {
  637. progressColorClass = 'bg-red-500';
  638. masteryColor = '#ef4444';
  639. }
  640. 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>';
  641. // 下一级所需经验(模拟)
  642. const nextLevel = mastery >= 100 ? '已满级' : `距离下一级还需 ${Math.max(0, 100 - mastery)} 点`;
  643. return `
  644. <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">
  645. <div class="flex items-center justify-between mb-2">
  646. <div class="text-sm font-bold text-gray-900">${meta.code || model.id} · ${meta.name}</div>
  647. ${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>' : ''}
  648. </div>
  649. <!-- 掌握度进度条 -->
  650. <div class="mb-3">
  651. <div class="flex justify-between text-[10px] text-gray-500 mb-1">
  652. <span>掌握度 Lv.${Math.floor(mastery / 10)} <span class="text-gray-400 ml-1">(${attempts}次练习)</span></span>
  653. <span class="font-medium" style="color: ${masteryColor}">${mastery}%</span>
  654. </div>
  655. <div class="h-2 w-full rounded-full bg-gray-100 overflow-hidden">
  656. <div class="h-full rounded-full ${progressColorClass} transition-all duration-500" style="width: ${mastery}%"></div>
  657. </div>
  658. <div class="mt-1 text-[10px] text-gray-400 text-right">${nextLevel}</div>
  659. </div>
  660. <div class="grid grid-cols-2 gap-2 mb-3 bg-gray-50 p-2 rounded border border-gray-100">
  661. <div>
  662. <div class="text-[10px] text-gray-500">直接得分</div>
  663. <div class="font-medium">${range(meta.direct_score)}</div>
  664. </div>
  665. <div>
  666. <div class="text-[10px] text-gray-500">关联得分</div>
  667. <div class="font-medium">${range(meta.related_score)}</div>
  668. </div>
  669. </div>
  670. <div>
  671. <div class="font-medium mb-1 flex items-center gap-1">
  672. <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>
  673. 技能要点
  674. </div>
  675. <ul class="list-none space-y-1 pl-1">
  676. ${skills}
  677. </ul>
  678. </div>
  679. </div>
  680. `;
  681. },
  682. bindEvents() {
  683. if (!this.graph) {
  684. return;
  685. }
  686. this.graph.on('node:click', (evt) => {
  687. const nodeId = evt?.item?.getID();
  688. if (nodeId) {
  689. this.highlightEdges(nodeId);
  690. }
  691. });
  692. this.graph.on('canvas:click', () => this.resetHighlight());
  693. },
  694. highlightEdges(nodeId) {
  695. this.graph.getNodes().forEach((node) => {
  696. this.graph.clearItemStates(node);
  697. if (node.getID() === nodeId) {
  698. this.graph.setItemState(node, 'selected', true);
  699. }
  700. });
  701. this.graph.getEdges().forEach((edge) => {
  702. const { source, target } = edge.getModel();
  703. const linked = source === nodeId || target === nodeId;
  704. if (linked) {
  705. this.graph.setItemState(edge, 'highlight', true);
  706. } else {
  707. this.graph.clearItemStates(edge);
  708. }
  709. });
  710. },
  711. resetHighlight() {
  712. if (!this.graph) return;
  713. this.graph.getNodes().forEach((node) => this.graph.clearItemStates(node));
  714. this.graph.getEdges().forEach((edge) => this.graph.clearItemStates(edge));
  715. },
  716. resizeGraph() {
  717. if (!this.graph) return;
  718. const container = document.getElementById('knowledge-mindmap');
  719. if (!container) return;
  720. this.graph.changeSize(container.clientWidth, container.clientHeight);
  721. this.graph.fitView(24);
  722. },
  723. });
  724. });
  725. </script>
  726. @endpush