knowledge-mindmap.blade.php.backup 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934
  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="knowledge-mindmap-container h-[80vh] min-h-[720px] w-full rounded-lg border border-gray-200 overflow-hidden relative"
  81. ></div>
  82. <!-- Drawer Component -->
  83. <div
  84. x-show="$wire.drawerOpen"
  85. x-transition:enter="transition ease-out duration-300"
  86. x-transition:enter-start="translate-x-full"
  87. x-transition:enter-end="translate-x-0"
  88. x-transition:leave="transition ease-in duration-200"
  89. x-transition:leave-start="translate-x-0"
  90. x-transition:leave-end="translate-x-full"
  91. class="fixed inset-y-0 right-0 w-96 bg-white shadow-2xl z-50 overflow-y-auto"
  92. style="display: none;"
  93. >
  94. <div class="p-6">
  95. <!-- Header -->
  96. <div class="flex items-center justify-between mb-6">
  97. <h3 class="text-lg font-bold text-gray-900">知识点详情</h3>
  98. <button
  99. wire:click="closeDrawer"
  100. class="text-gray-400 hover:text-gray-600 transition"
  101. >
  102. <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  103. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
  104. </svg>
  105. </button>
  106. </div>
  107. @if(!empty($nodeDetails))
  108. <!-- Node Info -->
  109. <div class="mb-6">
  110. <h4 class="text-xl font-semibold text-gray-800 mb-2">{{ $nodeDetails['name'] ?? '' }}</h4>
  111. <p class="text-sm text-gray-500">编号: {{ $nodeDetails['code'] ?? '' }}</p>
  112. </div>
  113. <!-- Mastery Progress -->
  114. <div class="mb-6 p-4 bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg">
  115. <div class="flex justify-between items-center mb-2">
  116. <span class="text-sm font-medium text-gray-700">掌握度</span>
  117. <span class="text-lg font-bold text-indigo-600">{{ number_format(($nodeDetails['mastery_level'] ?? 0) * 100, 1) }}%</span>
  118. </div>
  119. <div class="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
  120. <div
  121. class="h-full rounded-full transition-all duration-500"
  122. style="width: {{ ($nodeDetails['mastery_level'] ?? 0) * 100 }}%;
  123. background: linear-gradient(90deg, #ef4444 0%, #eab308 50%, #22c55e 100%);"
  124. ></div>
  125. </div>
  126. <div class="mt-3 grid grid-cols-2 gap-2 text-xs">
  127. <div>
  128. <span class="text-gray-600">练习次数:</span>
  129. <span class="font-semibold ml-1">{{ $nodeDetails['total_attempts'] ?? 0 }}</span>
  130. </div>
  131. <div>
  132. <span class="text-gray-600">错误率:</span>
  133. <span class="font-semibold ml-1 text-red-600">{{ number_format(($nodeDetails['error_rate'] ?? 0) * 100, 1) }}%</span>
  134. </div>
  135. </div>
  136. </div>
  137. <!-- Prerequisites -->
  138. @if(!empty($nodeDetails['prerequisites']))
  139. <div class="mb-6">
  140. <h5 class="text-sm font-semibold text-gray-700 mb-3">前置知识</h5>
  141. <div class="space-y-2">
  142. @foreach($nodeDetails['prerequisites'] as $prereq)
  143. <div class="flex items-center justify-between p-2 bg-gray-50 rounded hover:bg-gray-100 cursor-pointer transition">
  144. <span class="text-sm text-gray-700">{{ $prereq['name'] }}</span>
  145. <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' }}">
  146. {{ number_format($prereq['mastery'] * 100, 0) }}%
  147. </span>
  148. </div>
  149. @endforeach
  150. </div>
  151. </div>
  152. @endif
  153. <!-- Successors -->
  154. @if(!empty($nodeDetails['successors']))
  155. <div class="mb-6">
  156. <h5 class="text-sm font-semibold text-gray-700 mb-3">后续知识</h5>
  157. <div class="space-y-2">
  158. @foreach($nodeDetails['successors'] as $succ)
  159. <div class="flex items-center justify-between p-2 bg-gray-50 rounded hover:bg-gray-100 cursor-pointer transition">
  160. <span class="text-sm text-gray-700">{{ $succ['name'] }}</span>
  161. <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' }}">
  162. {{ number_format($succ['mastery'] * 100, 0) }}%
  163. </span>
  164. </div>
  165. @endforeach
  166. </div>
  167. </div>
  168. @endif
  169. <!-- Recommendations -->
  170. @if(!empty($nodeDetails['recommendations']))
  171. <div class="mb-6">
  172. <h5 class="text-sm font-semibold text-gray-700 mb-3">推荐练习</h5>
  173. <div class="space-y-3">
  174. @foreach($nodeDetails['recommendations'] as $rec)
  175. <div class="p-3 border border-gray-200 rounded-lg hover:border-indigo-300 transition">
  176. <div class="flex items-start justify-between mb-1">
  177. <span class="text-sm font-medium text-gray-800">{{ $rec['title'] }}</span>
  178. <span class="text-xs px-2 py-0.5 rounded-full
  179. {{ $rec['difficulty'] === '简单' ? 'bg-green-100 text-green-700' : '' }}
  180. {{ $rec['difficulty'] === '中等' ? 'bg-yellow-100 text-yellow-700' : '' }}
  181. {{ $rec['difficulty'] === '困难' ? 'bg-red-100 text-red-700' : '' }}
  182. ">{{ $rec['difficulty'] }}</span>
  183. </div>
  184. <span class="text-xs text-gray-500">{{ $rec['type'] }}</span>
  185. </div>
  186. @endforeach
  187. </div>
  188. </div>
  189. @endif
  190. <!-- Action Button -->
  191. <button class="w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-lg transition shadow-md">
  192. 开始练习
  193. </button>
  194. @endif
  195. </div>
  196. </div>
  197. <!-- Drawer Overlay -->
  198. <div
  199. x-show="$wire.drawerOpen"
  200. @click="$wire.closeDrawer()"
  201. x-transition:enter="transition ease-out duration-300"
  202. x-transition:enter-start="opacity-0"
  203. x-transition:enter-end="opacity-100"
  204. x-transition:leave="transition ease-in duration-200"
  205. x-transition:leave-start="opacity-100"
  206. x-transition:leave-end="opacity-0"
  207. class="fixed inset-0 bg-black bg-opacity-30 z-40"
  208. style="display: none;"
  209. ></div>
  210. </div>
  211. <style>
  212. .knowledge-mindmap-container {
  213. background: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 50%, #60a5fa 100%);
  214. position: relative;
  215. }
  216. .knowledge-mindmap-container::before {
  217. content: '';
  218. position: absolute;
  219. top: 0;
  220. left: 0;
  221. right: 0;
  222. bottom: 0;
  223. background:
  224. radial-gradient(ellipse at 20% 30%, rgba(255,255,255,0.1) 0%, transparent 50%),
  225. radial-gradient(ellipse at 80% 70%, rgba(255,255,255,0.08) 0%, transparent 50%);
  226. pointer-events: none;
  227. }
  228. </style>
  229. </x-filament::page>
  230. @push('scripts')
  231. <script src="https://gw.alipayobjects.com/os/lib/antv/g6/5.0.18/dist/g6.min.js"></script>
  232. <script src="{{ asset('js/g6-custom-node.js') }}"></script>
  233. <script src="{{ asset('js/knowledge-mindmap-graph.js') }}"></script>
  234. <script>
  235. document.addEventListener('alpine:init', () => {
  236. window.knowledgeMindmap = () => ({
  237. graphInstance: null,
  238. stats: { nodes: 0, extraEdges: 0 },
  239. async initMindmap() {
  240. try {
  241. if (!window.G6) {
  242. console.error('G6 未加载');
  243. return;
  244. }
  245. this.graphInstance = new KnowledgeMindmapGraph();
  246. await this.graphInstance.init();
  247. this.stats = this.graphInstance.stats;
  248. } catch (err) {
  249. console.error('初始化思维导图失败', err);
  250. }
  251. },
  252. });
  253. });
  254. </script>
  255. @endpush
  256. graph: null,
  257. treeData: null,
  258. relationEdges: [],
  259. stats: { nodes: 0, extraEdges: 0 },
  260. // 学生选择相关
  261. // 学生选择相关 - 现在由Livewire管理
  262. masteryData: {}, // 存储掌握度数据 { 'F01': 80, 'F02': 65, ... }
  263. arrow(w = 12, h = 14, r = 5) {
  264. if (window.G6?.Arrow?.triangle) {
  265. return G6.Arrow.triangle(w, h, r);
  266. }
  267. return [
  268. ['M', 0, 0],
  269. ['L', w, h / 2],
  270. ['L', 0, h],
  271. ['Z'],
  272. ];
  273. },
  274. levelStyles: [
  275. {
  276. fill: '#0ea5e9',
  277. stroke: '#0369a1',
  278. labelColor: '#0f172a',
  279. fontSize: 17,
  280. fontWeight: 700,
  281. size: 34,
  282. },
  283. {
  284. fill: '#e0f2fe',
  285. stroke: '#38bdf8',
  286. labelColor: '#0f172a',
  287. fontSize: 16,
  288. fontWeight: 700,
  289. size: 30,
  290. },
  291. {
  292. fill: '#f1f5f9',
  293. stroke: '#cbd5e1',
  294. labelColor: '#0f172a',
  295. fontSize: 14,
  296. fontWeight: 600,
  297. size: 26,
  298. },
  299. ],
  300. relationStyles: {
  301. prerequisite: {
  302. type: 'quadratic',
  303. curveOffset: 60,
  304. style: {
  305. stroke: '#2563eb',
  306. lineWidth: 3.4,
  307. lineDash: [8, 6],
  308. endArrow: {
  309. path: null,
  310. fill: '#2563eb',
  311. d: 12,
  312. },
  313. startArrow: false,
  314. },
  315. label: '前置',
  316. },
  317. successor: {
  318. type: 'quadratic',
  319. curveOffset: 60,
  320. style: {
  321. stroke: '#dc2626',
  322. lineWidth: 3.4,
  323. lineDash: [8, 6],
  324. endArrow: {
  325. path: null,
  326. fill: '#dc2626',
  327. d: 12,
  328. },
  329. startArrow: false,
  330. },
  331. label: '后继',
  332. },
  333. sibling: {
  334. type: 'quadratic',
  335. curveOffset: 50,
  336. style: {
  337. stroke: '#64748b',
  338. lineDash: [6, 6],
  339. lineWidth: 3,
  340. endArrow: {
  341. path: null,
  342. fill: '#64748b',
  343. d: 10,
  344. },
  345. },
  346. label: '兄弟',
  347. },
  348. joint: {
  349. type: 'quadratic',
  350. curveOffset: 50,
  351. style: {
  352. stroke: '#fcd34d',
  353. lineWidth: 3,
  354. lineDash: [10, 8],
  355. endArrow: {
  356. path: null,
  357. fill: '#fbbf24',
  358. d: 10,
  359. },
  360. },
  361. label: '联合',
  362. },
  363. default: {
  364. type: 'quadratic',
  365. curveOffset: 50,
  366. style: {
  367. stroke: '#94a3b8',
  368. lineWidth: 3,
  369. lineDash: [10, 8],
  370. endArrow: {
  371. path: null,
  372. fill: '#94a3b8',
  373. d: 10,
  374. },
  375. },
  376. label: '',
  377. },
  378. },
  379. async initMindmap() {
  380. try {
  381. if (this.$nextTick) {
  382. await this.$nextTick();
  383. }
  384. if (!window.G6) {
  385. console.error('G6 未加载');
  386. return;
  387. }
  388. Object.keys(this.relationStyles).forEach((key) => {
  389. const rel = this.relationStyles[key];
  390. if (rel?.style && rel.style.endArrow && !rel.style.endArrow.path) {
  391. rel.style.endArrow.path = this.arrow(rel.style.endArrow.d || 10, (rel.style.endArrow.d || 10) + 2, 4);
  392. }
  393. });
  394. await Promise.all([
  395. this.loadData(),
  396. ]);
  397. this.applyInitialCollapse(this.treeData);
  398. this.renderGraph();
  399. window.addEventListener('resize', () => this.resizeGraph());
  400. // 监听 Livewire 事件
  401. window.addEventListener('mastery-updated', (event) => {
  402. console.log('Mastery updated:', event.detail.data);
  403. this.masteryData = event.detail.data || {};
  404. this.refreshGraph();
  405. });
  406. } catch (err) {
  407. console.error('初始化思维导图失败', err);
  408. }
  409. },
  410. // loadTeachers, loadStudents, loadMasteryData 已移除,由 Livewire 处理
  411. async loadData() {
  412. const [treeResp, edgesResp] = await Promise.all([
  413. fetch('/data/tree.json'),
  414. fetch('/data/edges.json'),
  415. ]);
  416. const rawTree = await treeResp.json();
  417. const edges = await edgesResp.json();
  418. const rawEdges = Array.isArray(edges) ? edges : edges?.edges || [];
  419. this.treeData = this.transformNode(rawTree);
  420. this.relationEdges = this.normalizeEdges(rawEdges);
  421. this.stats = {
  422. nodes: this.countNodes(this.treeData),
  423. extraEdges: this.relationEdges.length,
  424. };
  425. },
  426. refreshGraph() {
  427. if (this.graph && this.treeData) {
  428. // 重新装饰树数据以应用掌握度
  429. const decoratedData = this.decorateTree(this.treeData);
  430. this.graph.changeData(decoratedData);
  431. this.graph.render();
  432. this.graph.fitView(24);
  433. }
  434. },
  435. transformNode(node, depth = 0) {
  436. if (!node) {
  437. return null;
  438. }
  439. const id = node.code || node.id || node.label || `node-${Math.random().toString(36).slice(2, 8)}`;
  440. const label = node.name || node.label || node.code || node.id || '未命名节点';
  441. // 优先使用动态掌握度数据,其次回退到静态数据
  442. const dynamicMastery = this.masteryData[id] || this.masteryData[node.code] || 0;
  443. const staticMastery = node.mastery_level || 0;
  444. const meta = {
  445. code: node.code || node.id || '',
  446. name: label,
  447. direct_score: node.direct_score || [],
  448. related_score: node.related_score || [],
  449. skills: node.skills || [],
  450. mastery_level: dynamicMastery || staticMastery, // 动态掌握度优先
  451. };
  452. return {
  453. id,
  454. label,
  455. meta,
  456. depth,
  457. children: (node.children || []).map((child) => this.transformNode(child, depth + 1)).filter(Boolean),
  458. };
  459. },
  460. applyInitialCollapse(node, depth = 0) {
  461. if (!node) {
  462. return;
  463. }
  464. if (depth >= 2 && node.children.length > 0) {
  465. node.collapsed = true;
  466. }
  467. node.children.forEach((child) => this.applyInitialCollapse(child, depth + 1));
  468. },
  469. countNodes(node) {
  470. if (!node) {
  471. return 0;
  472. }
  473. return 1 + node.children.reduce((sum, child) => sum + this.countNodes(child), 0);
  474. },
  475. normalizeEdges(rawEdges) {
  476. const seen = new Set();
  477. const normalized = [];
  478. (rawEdges || []).forEach((edge, index) => {
  479. if (!edge?.source || !edge?.target) {
  480. return;
  481. }
  482. const key = `${edge.source}-${edge.target}-${edge.type}`;
  483. if (seen.has(key)) {
  484. return;
  485. }
  486. seen.add(key);
  487. const relationStyle = this.relationStyles[edge.type] || this.relationStyles.default;
  488. normalized.push({
  489. id: `rel-${index}-${edge.source}-${edge.target}`,
  490. source: edge.source,
  491. target: edge.target,
  492. type: relationStyle.type || 'quadratic',
  493. curveOffset: relationStyle.curveOffset || 50,
  494. style: relationStyle.style,
  495. label: relationStyle.label,
  496. comment: edge.comment || edge.note || '',
  497. });
  498. });
  499. return normalized;
  500. },
  501. renderGraph(containerEl = null) {
  502. if (!this.treeData) {
  503. return;
  504. }
  505. const container = containerEl || document.getElementById('knowledge-mindmap');
  506. if (!container) {
  507. console.error('容器未找到');
  508. return;
  509. }
  510. const ensuredId = container.id || 'knowledge-mindmap';
  511. if (!container.id) {
  512. container.id = ensuredId;
  513. }
  514. const bounds = container.getBoundingClientRect();
  515. const width = Math.max(bounds.width, 600);
  516. const height = Math.max(bounds.height, 600);
  517. const tooltipEl = document.createElement('div');
  518. tooltipEl.className = 'fixed z-50 pointer-events-none hidden';
  519. document.body.appendChild(tooltipEl);
  520. const showTooltip = (html, x, y) => {
  521. tooltipEl.innerHTML = html;
  522. tooltipEl.style.left = `${x + 12}px`;
  523. tooltipEl.style.top = `${y + 12}px`;
  524. tooltipEl.classList.remove('hidden');
  525. };
  526. const hideTooltip = () => {
  527. tooltipEl.classList.add('hidden');
  528. tooltipEl.innerHTML = '';
  529. };
  530. const G6Lib = window.G6?.default || window.G6;
  531. const TreeGraphClass = G6Lib?.TreeGraph || null;
  532. if (!TreeGraphClass) {
  533. console.error('G6 TreeGraph 不可用');
  534. return;
  535. }
  536. const graphData = this.decorateTree(this.treeData);
  537. const graphConfig = {
  538. container: ensuredId,
  539. width,
  540. height,
  541. data: graphData,
  542. linkCenter: true,
  543. modes: {
  544. default: [
  545. 'drag-canvas',
  546. 'zoom-canvas',
  547. {
  548. type: 'collapse-expand',
  549. trigger: 'click',
  550. onChange: function onChange(item, collapsed) {
  551. if (!item) return;
  552. item.getModel().collapsed = collapsed;
  553. return true;
  554. },
  555. },
  556. ],
  557. },
  558. layout: {
  559. type: 'mindmap',
  560. direction: 'H',
  561. getHeight: () => 32,
  562. getWidth: () => 140,
  563. getVGap: () => 32,
  564. getHGap: () => 110,
  565. },
  566. defaultNode: {
  567. size: 22,
  568. style: {
  569. stroke: '#94a3b8',
  570. fill: '#fff',
  571. radius: 4,
  572. shadowColor: undefined,
  573. shadowBlur: 0,
  574. lineWidth: 3,
  575. },
  576. labelCfg: {
  577. style: {
  578. fontSize: 13,
  579. fill: '#0f172a',
  580. fontWeight: 500,
  581. },
  582. position: 'right',
  583. offset: 12,
  584. },
  585. },
  586. defaultEdge: {
  587. type: 'cubic-horizontal',
  588. style: {
  589. stroke: '#cbd5f5',
  590. lineWidth: 3,
  591. shadowBlur: 0,
  592. shadowColor: undefined,
  593. },
  594. },
  595. nodeStateStyles: {
  596. selected: {
  597. lineWidth: 3.2,
  598. stroke: '#2563eb',
  599. fill: '#e0f2fe',
  600. },
  601. },
  602. edgeStateStyles: {
  603. highlight: {
  604. lineWidth: 3.4,
  605. stroke: '#fb923c',
  606. },
  607. },
  608. plugins: [],
  609. };
  610. this.graph = new TreeGraphClass(graphConfig);
  611. if (typeof this.graph.data === 'function') {
  612. this.graph.data(graphData);
  613. } else if (typeof this.graph.changeData === 'function') {
  614. this.graph.changeData(graphData);
  615. }
  616. if (typeof this.graph.render === 'function') {
  617. this.graph.render();
  618. }
  619. this.graph.data(this.decorateTree(this.treeData));
  620. this.graph.render();
  621. this.graph.fitView(24);
  622. this.drawRelationEdges();
  623. this.bindEvents();
  624. this.graph.on('node:mouseenter', (evt) => {
  625. const { clientX, clientY } = evt;
  626. showTooltip(this.buildTooltip(evt?.item?.getModel()), clientX, clientY);
  627. });
  628. this.graph.on('node:mouseleave', hideTooltip);
  629. this.graph.on('edge:mouseenter', (evt) => {
  630. const model = evt?.item?.getModel() || {};
  631. const relation = model.label || '关联关系';
  632. const text = `${model.source || ''} → ${model.target || ''}`;
  633. const comment = model.comment ? `<div class="text-[11px] text-gray-600 mt-1 whitespace-pre-line">${model.comment}</div>` : '';
  634. const html = `
  635. <div class="rounded-md border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700 shadow-md">
  636. <div class="font-semibold text-gray-900 mb-1">${relation}</div>
  637. <div>${text}</div>
  638. ${comment}
  639. </div>
  640. `;
  641. const { clientX, clientY } = evt;
  642. showTooltip(html, clientX, clientY);
  643. });
  644. this.graph.on('edge:mouseleave', hideTooltip);
  645. const canvas = this.graph && typeof this.graph.get === 'function' ? this.graph.get('canvas') : null;
  646. if (canvas && typeof canvas.set === 'function') {
  647. canvas.set('localRefresh', false);
  648. const ctx = typeof canvas.get === 'function' ? canvas.get('context') : null;
  649. if (ctx) {
  650. ctx.shadowColor = 'transparent';
  651. ctx.shadowBlur = 0;
  652. }
  653. }
  654. },
  655. decorateTree(node) {
  656. if (!node) {
  657. return null;
  658. }
  659. // 动态获取最新掌握度数据对象
  660. const id = node.id;
  661. const code = node.meta?.code;
  662. // masteryData 现在是对象 { 'KP_CODE': { mastery_level: 0.8, total_attempts: 5, ... } }
  663. const masteryInfo = this.masteryData[id] || (code && this.masteryData[code]) || null;
  664. const masteryLevel = masteryInfo ? (masteryInfo.mastery_level || 0) : (node.meta.mastery_level || 0);
  665. const totalAttempts = masteryInfo ? (masteryInfo.total_attempts || 0) : 0;
  666. const { nodeStyle, labelCfg, size, icon } = this.getNodeLevelStyle(node.depth, masteryLevel, totalAttempts);
  667. // 构建带图标的标签
  668. let label = `${node.meta.code ? `${node.meta.code} · ` : ''}${node.label}`;
  669. if (icon) {
  670. label += ` ${icon}`;
  671. }
  672. // 更新meta中的掌握度,以便tooltip使用
  673. const meta = {
  674. ...node.meta,
  675. mastery_level: masteryLevel,
  676. total_attempts: totalAttempts,
  677. mastery_info: masteryInfo
  678. };
  679. return {
  680. id: node.id,
  681. label: label,
  682. meta: meta,
  683. collapsed: node.collapsed,
  684. depth: node.depth,
  685. size,
  686. style: nodeStyle,
  687. labelCfg,
  688. children: node.children.map((child) => this.decorateTree(child)).filter(Boolean),
  689. };
  690. },
  691. getNodeLevelStyle(depth = 0, masteryLevel = 0, totalAttempts = 0) {
  692. const style = this.levelStyles[depth] || this.levelStyles[this.levelStyles.length - 1];
  693. // 根据掌握度调整颜色和样式
  694. let fillColor, strokeColor, shadowColor, shadowBlur, icon;
  695. // 只要有答题记录(totalAttempts > 0),即使掌握度为0,也视为"薄弱"(红色)
  696. // 如果没有答题记录,则保持默认样式(白色)
  697. const hasAttempts = totalAttempts > 0;
  698. if (masteryLevel >= 85) {
  699. // 85%以上:大师级(绿色 + 光晕 + 星星)
  700. fillColor = '#dcfce7'; // green-100
  701. strokeColor = '#16a34a'; // green-600
  702. shadowColor = 'rgba(34, 197, 94, 0.6)';
  703. shadowBlur = 10;
  704. icon = '★';
  705. } else if (masteryLevel >= 60) {
  706. // 60-85%:良好(黄色)
  707. fillColor = '#fef9c3'; // yellow-100
  708. strokeColor = '#ca8a04'; // yellow-600
  709. shadowColor = undefined;
  710. shadowBlur = 0;
  711. icon = '';
  712. } else if (masteryLevel > 0 || hasAttempts) {
  713. // 1-60% 或 掌握度为0但有答题记录:薄弱(红色)
  714. fillColor = '#fee2e2'; // red-100
  715. strokeColor = '#dc2626'; // red-600
  716. shadowColor = undefined;
  717. shadowBlur = 0;
  718. icon = '';
  719. } else {
  720. // 未掌握且无记录:默认
  721. fillColor = style.fill || '#fff';
  722. strokeColor = style.stroke || '#cbd5f5';
  723. shadowColor = undefined;
  724. shadowBlur = 0;
  725. icon = '';
  726. }
  727. return {
  728. size: style.size || 22,
  729. icon,
  730. nodeStyle: {
  731. fill: fillColor,
  732. stroke: strokeColor,
  733. lineWidth: masteryLevel > 0 ? 3 : 3, // 保持一致线条宽度,靠颜色区分
  734. radius: 6,
  735. shadowColor: shadowColor,
  736. shadowBlur: shadowBlur,
  737. cursor: 'pointer',
  738. },
  739. labelCfg: {
  740. position: 'right',
  741. offset: 12,
  742. style: {
  743. fontSize: style.fontSize || 13,
  744. fontWeight: style.fontWeight || 600,
  745. fill: style.labelColor || '#0f172a',
  746. },
  747. },
  748. };
  749. },
  750. drawRelationEdges() {
  751. if (!this.graph || !this.relationEdges.length) {
  752. return;
  753. }
  754. this.relationEdges.forEach((edge, index) => {
  755. const style = { ...(edge.style || {}), lineAppendWidth: 14 };
  756. if (style.endArrow && !style.endArrow.path) {
  757. style.endArrow = {
  758. ...style.endArrow,
  759. path: this.arrow(style.endArrow.d || 10, (style.endArrow.d || 10) + 2, 4),
  760. };
  761. }
  762. this.graph.addItem('edge', {
  763. id: `extra-${index}`,
  764. source: edge.source,
  765. target: edge.target,
  766. type: edge.type || 'quadratic',
  767. curveOffset: edge.curveOffset || 50,
  768. style,
  769. label: edge.label,
  770. comment: edge.comment || '',
  771. labelCfg: {
  772. autoRotate: true,
  773. style: {
  774. fill: '#475569',
  775. fontSize: 11,
  776. background: {
  777. fill: 'rgba(255,255,255,0.85)',
  778. padding: [2, 4],
  779. radius: 4,
  780. },
  781. },
  782. },
  783. });
  784. });
  785. },
  786. buildTooltip(model) {
  787. const meta = model?.meta;
  788. if (!meta) {
  789. return '<div class="text-xs text-gray-600">无数据</div>';
  790. }
  791. const range = (value) => (value?.length ? `${value[0]}-${value[1]}` : '未配置');
  792. const mastery = meta.mastery_level || 0;
  793. const attempts = meta.total_attempts || 0;
  794. // 进度条颜色
  795. let progressColorClass = 'bg-gray-300';
  796. let masteryColor = '#9ca3af';
  797. if (mastery >= 85) {
  798. progressColorClass = 'bg-green-500';
  799. masteryColor = '#22c55e';
  800. } else if (mastery >= 60) {
  801. progressColorClass = 'bg-yellow-500';
  802. masteryColor = '#eab308';
  803. } else if (mastery > 0 || attempts > 0) {
  804. progressColorClass = 'bg-red-500';
  805. masteryColor = '#ef4444';
  806. }
  807. 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>';
  808. // 下一级所需经验(模拟)
  809. const nextLevel = mastery >= 100 ? '已满级' : `距离下一级还需 ${Math.max(0, 100 - mastery)} 点`;
  810. return `
  811. <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">
  812. <div class="flex items-center justify-between mb-2">
  813. <div class="text-sm font-bold text-gray-900">${meta.code || model.id} · ${meta.name}</div>
  814. ${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>' : ''}
  815. </div>
  816. <!-- 掌握度进度条 -->
  817. <div class="mb-3">
  818. <div class="flex justify-between text-[10px] text-gray-500 mb-1">
  819. <span>掌握度 Lv.${Math.floor(mastery / 10)} <span class="text-gray-400 ml-1">(${attempts}次练习)</span></span>
  820. <span class="font-medium" style="color: ${masteryColor}">${mastery}%</span>
  821. </div>
  822. <div class="h-2 w-full rounded-full bg-gray-100 overflow-hidden">
  823. <div class="h-full rounded-full ${progressColorClass} transition-all duration-500" style="width: ${mastery}%"></div>
  824. </div>
  825. <div class="mt-1 text-[10px] text-gray-400 text-right">${nextLevel}</div>
  826. </div>
  827. <div class="grid grid-cols-2 gap-2 mb-3 bg-gray-50 p-2 rounded border border-gray-100">
  828. <div>
  829. <div class="text-[10px] text-gray-500">直接得分</div>
  830. <div class="font-medium">${range(meta.direct_score)}</div>
  831. </div>
  832. <div>
  833. <div class="text-[10px] text-gray-500">关联得分</div>
  834. <div class="font-medium">${range(meta.related_score)}</div>
  835. </div>
  836. </div>
  837. <div>
  838. <div class="font-medium mb-1 flex items-center gap-1">
  839. <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>
  840. 技能要点
  841. </div>
  842. <ul class="list-none space-y-1 pl-1">
  843. ${skills}
  844. </ul>
  845. </div>
  846. </div>
  847. `;
  848. },
  849. bindEvents() {
  850. if (!this.graph) {
  851. return;
  852. }
  853. this.graph.on('node:click', (evt) => {
  854. const nodeId = evt?.item?.getID();
  855. if (nodeId) {
  856. this.highlightEdges(nodeId);
  857. }
  858. });
  859. this.graph.on('canvas:click', () => this.resetHighlight());
  860. },
  861. highlightEdges(nodeId) {
  862. this.graph.getNodes().forEach((node) => {
  863. this.graph.clearItemStates(node);
  864. if (node.getID() === nodeId) {
  865. this.graph.setItemState(node, 'selected', true);
  866. }
  867. });
  868. this.graph.getEdges().forEach((edge) => {
  869. const { source, target } = edge.getModel();
  870. const linked = source === nodeId || target === nodeId;
  871. if (linked) {
  872. this.graph.setItemState(edge, 'highlight', true);
  873. } else {
  874. this.graph.clearItemStates(edge);
  875. }
  876. });
  877. },
  878. resetHighlight() {
  879. if (!this.graph) return;
  880. this.graph.getNodes().forEach((node) => this.graph.clearItemStates(node));
  881. this.graph.getEdges().forEach((edge) => this.graph.clearItemStates(edge));
  882. },
  883. resizeGraph() {
  884. if (!this.graph) return;
  885. const container = document.getElementById('knowledge-mindmap');
  886. if (!container) return;
  887. this.graph.changeSize(container.clientWidth, container.clientHeight);
  888. this.graph.fitView(24);
  889. },
  890. });
  891. });
  892. </script>
  893. @endpush