knowledgeGraph.vue 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898
  1. <template>
  2. <div class="knowledge_graph" ref="knowledgeGraph" :style="{ maxHeight: containerHeight + 'px' }">
  3. <div class="graph_top">
  4. <!-- 顶部信息模块 -->
  5. <div class="knowledge_info">
  6. <div class="knowledge_header">
  7. <div class="header_top">
  8. <span class="header_title">我的知识图谱</span>
  9. <!-- 年级选择标签,只有一个年级时不显示 -->
  10. <el-checkbox-group v-if="gradeList && gradeList.length > 1" v-model="currentGradeList" @change="handleGradeChange">
  11. <el-checkbox v-for="grade in gradeList" :key="grade.value" :label="grade.value">
  12. {{ grade.label }}
  13. </el-checkbox>
  14. </el-checkbox-group>
  15. </div>
  16. <div class="header_stats">
  17. <span class="stats_text">
  18. 你的知识图谱共包含<span class="stats_num">{{ totalCount }}</span> 个知识点。当前红色薄弱知识点<span class="stats_weak"> {{ weakCount }}</span> 个,黄色待提升知识点<span class="stats_good"> {{ goodCount }}</span> 个,<span class="stats_excellent">坚持练习,把全部知识点都进阶为绿色已掌握状态吧!</span>
  19. </span>
  20. </div>
  21. </div>
  22. <!-- 环形扩散图组件 -->
  23. <circular-diffusion
  24. ref="circularDiffusion"
  25. :subjectName="actualSubjectName" :subjectScoreRate="subjectScoreRate"
  26. :tableData="displayTableData"
  27. :classLevel="classLevel" :classGroupName="classGroupName"
  28. :currentKnowledgeId="currentLinkedKnowledgeId || currentKnowledgeId || defaultKnowledgeId"
  29. :addedKnowledgeIds="addedKnowledgeIds"
  30. @legend-change="handleLegendChange"
  31. @activeTabChange="handleActiveTabChange"
  32. @add-practice="handleAddPractice"
  33. @knowledge-item-click="handleKnowledgeItemClick" />
  34. </div>
  35. <!-- 区域展示组件 -->
  36. <region
  37. :partitionData="partitionData"
  38. :selectedKnowledgeId="currentLinkedKnowledgeId || currentKnowledgeId || defaultKnowledgeId"
  39. @add-practice="handleAddPractice"
  40. @item-click="handleRegionItemClick" />
  41. </div>
  42. </div>
  43. </template>
  44. <script>
  45. import CircularDiffusion from './knowledgeGraph/circularDiffusion.vue';
  46. import Region from './knowledgeGraph/region.vue';
  47. import { mapState } from 'vuex';
  48. export default {
  49. name: 'KnowledgeGraph',
  50. components: {
  51. CircularDiffusion,
  52. Region
  53. },
  54. props: {
  55. subjectScoreRate: {
  56. type: Number,
  57. default: 85
  58. },
  59. tableData: {
  60. type: Array,
  61. default: () => []
  62. },
  63. fatalVulnerability: {
  64. type: Array,
  65. default: () => []
  66. },
  67. highVulnerability: {
  68. type: Array,
  69. default: () => []
  70. },
  71. allKnowledgeList: {
  72. type: Array,
  73. default: () => []
  74. },
  75. classLevel: {
  76. type: [String, Number],
  77. default: ''
  78. },
  79. classGroupName: {
  80. type: String,
  81. default: ''
  82. },
  83. currentKnowledgeId: {
  84. type: [String, Number],
  85. default: ''
  86. },
  87. showRegion: {
  88. type: Boolean,
  89. default: true
  90. }
  91. },
  92. data() {
  93. return {
  94. // 容器最大高度
  95. containerHeight: 600,
  96. // 当前选中的年级列表(多选)
  97. currentGradeList: [],
  98. // 年级列表(从localStorage的student_gradesList读取)
  99. gradeList: [],
  100. // 中心圆点学科名称(通过 subjectCode 从 student_subjectList 匹配)
  101. subjectNameState: '',
  102. // 默认选中知识点ID:接口返回的 knowList 中"第一棵节点树的最后一个子节点(叶子)"
  103. defaultKnowledgeId: '',
  104. // 接口返回的真实统计数据
  105. graphStats: {
  106. allKnowNum: 0, // 知识点总数
  107. weakKnowNum: 0, // 薄弱数量
  108. goodKnowNum: 0 // 待提升数量
  109. },
  110. // 接口返回的树形知识数据(第一层直接传给环形扩散图)
  111. knowList: [],
  112. // knowList 扁平化后的一维数组(用于统计、列表展示)
  113. flatKnowList: [],
  114. // 知识点分区:由 knowledgeGraph 接口返回
  115. // conquerZone: 薄弱补强区 / improveZone: 能力提升区 / consolidateZone: 扎实巩固区 / sprintZone: 冲刺进阶区
  116. partitionData: {
  117. conquerZone: [],
  118. improveZone: [],
  119. consolidateZone: [],
  120. sprintZone: []
  121. },
  122. // 当前会话中"已加入练习"的知识点 ID 容器(响应式:父级 $set 后自动驱动子组件按钮置灰)
  123. // 形如 { '1001': true }
  124. addedKnowledgeIds: {},
  125. // 环形扩散图 ↔ 区域卡片 的联动选中知识点 ID
  126. // - 点环形扩散图圆点 → 写这里 → region 读 prop :selectedKnowledgeId 高亮并滚动居中
  127. // - 点 region 某行条目 → 写这里 → circularDiffusion 读 prop :currentKnowledgeId 放大圆点 + 更新右侧折线表格
  128. currentLinkedKnowledgeId: '',
  129. // 假数据:表格树形结构数据(用于无接口数据时兜底)
  130. mockTableData: []
  131. };
  132. },
  133. computed: {
  134. ...mapState('target', ['subjectCode', 'subjectName']),
  135. // 中心圆点学科名称:优先从 localStorage 的 student_subjectList 匹配 subjectCode
  136. actualSubjectName() {
  137. return this.subjectNameState || '知识点';
  138. },
  139. // 统计知识点总数:优先使用接口返回的 allKnowNum
  140. totalCount() {
  141. if (this.graphStats.allKnowNum !== null && this.graphStats.allKnowNum !== undefined) {
  142. return this.graphStats.allKnowNum;
  143. }
  144. return this.displayAllKnowledgeList.length;
  145. },
  146. // 薄弱知识点数量(knowledgeType === 1):优先使用接口返回的 weakKnowNum
  147. weakCount() {
  148. if (this.graphStats.weakKnowNum !== null && this.graphStats.weakKnowNum !== undefined) {
  149. return this.graphStats.weakKnowNum;
  150. }
  151. return this.displayAllKnowledgeList.filter(item => item.knowledgeType === 1).length;
  152. },
  153. // 良好/待提升知识点数量(knowledgeType === 2):优先使用接口返回的 goodKnowNum
  154. goodCount() {
  155. if (this.graphStats.goodKnowNum !== null && this.graphStats.goodKnowNum !== undefined) {
  156. return this.graphStats.goodKnowNum;
  157. }
  158. return this.displayAllKnowledgeList.filter(item => item.knowledgeType === 2).length;
  159. },
  160. // 优秀知识点数量(knowledgeType === 3)
  161. excellentCount() {
  162. return this.displayAllKnowledgeList.filter(item => item.knowledgeType === 3).length;
  163. },
  164. // 实际显示的表格数据:接口knowList > props.tableData > 假数据
  165. displayTableData() {
  166. if (this.knowList && this.knowList.length > 0) {
  167. return this.knowList;
  168. }
  169. if (this.tableData && this.tableData.length > 0) {
  170. return this.tableData;
  171. }
  172. return this.mockTableData;
  173. },
  174. // 实际显示的全部知识点数据:flatKnowList > props.allKnowledgeList > 假数据
  175. displayAllKnowledgeList() {
  176. if (this.flatKnowList && this.flatKnowList.length > 0) {
  177. return this.flatKnowList;
  178. }
  179. if (this.allKnowledgeList && this.allKnowledgeList.length > 0) {
  180. return this.allKnowledgeList;
  181. }
  182. // 假数据:全部知识点(扁平化结构,用于列表显示和区域展示)
  183. return [
  184. { knowledgeId: '1', knowledgeName: '函数与方程', knowledgeType: 1, gradeScoreRate: 45, classScoreRate: 45, examNum: 8, questionNum: 120, scoreRateDiff: -5 },
  185. { knowledgeId: '1-1', knowledgeName: '一元二次方程', knowledgeType: 1, gradeScoreRate: 35, classScoreRate: 35, examNum: 5, questionNum: 60, scoreRateDiff: -10 },
  186. { knowledgeId: '1-2', knowledgeName: '函数图像', knowledgeType: 1, gradeScoreRate: 55, classScoreRate: 55, examNum: 3, questionNum: 40, scoreRateDiff: -2 },
  187. { knowledgeId: '2', knowledgeName: '几何图形', knowledgeType: 2, gradeScoreRate: 72, classScoreRate: 72, examNum: 4, questionNum: 80, scoreRateDiff: 3 },
  188. { knowledgeId: '2-1', knowledgeName: '三角形', knowledgeType: 2, gradeScoreRate: 68, classScoreRate: 68, examNum: 2, questionNum: 30, scoreRateDiff: 2 },
  189. { knowledgeId: '2-2', knowledgeName: '圆的性质', knowledgeType: 2, gradeScoreRate: 78, classScoreRate: 78, examNum: 2, questionNum: 50, scoreRateDiff: 5 },
  190. { knowledgeId: '3', knowledgeName: '概率统计', knowledgeType: 3, gradeScoreRate: 90, classScoreRate: 90, examNum: 1, questionNum: 25, scoreRateDiff: 8 },
  191. { knowledgeId: '3-1', knowledgeName: '数据分析', knowledgeType: 3, gradeScoreRate: 92, classScoreRate: 92, examNum: 1, questionNum: 15, scoreRateDiff: 10 },
  192. { knowledgeId: '3-2', knowledgeName: '概率计算', knowledgeType: 3, gradeScoreRate: 88, classScoreRate: 88, examNum: 1, questionNum: 10, scoreRateDiff: 6 },
  193. { knowledgeId: '4', knowledgeName: '数列', knowledgeType: 2, gradeScoreRate: 65, classScoreRate: 65, examNum: 6, questionNum: 90, scoreRateDiff: 1 },
  194. { knowledgeId: '5', knowledgeName: '立体几何', knowledgeType: 1, gradeScoreRate: 50, classScoreRate: 50, examNum: 7, questionNum: 100, scoreRateDiff: -3 },
  195. { knowledgeId: '6', knowledgeName: '向量', knowledgeType: 2, gradeScoreRate: 82, classScoreRate: 82, examNum: 3, questionNum: 45, scoreRateDiff: 4 },
  196. { knowledgeId: '7', knowledgeName: '导数应用', knowledgeType: 3, gradeScoreRate: 95, classScoreRate: 95, examNum: 2, questionNum: 20, scoreRateDiff: 12 },
  197. { knowledgeId: '8', knowledgeName: '不等式', knowledgeType: 1, gradeScoreRate: 58, classScoreRate: 58, examNum: 5, questionNum: 75, scoreRateDiff: -1 }
  198. ];
  199. }
  200. },
  201. watch: {
  202. // 监听学科变化,重新调用知识图谱接口
  203. subjectCode(newVal, oldVal) {
  204. if (newVal && newVal !== oldVal) {
  205. this.fetchKnowledgeGraph();
  206. }
  207. },
  208. // 监听 vuex store 中 gradesList 变化(TargetSubject 异步接口返回后会更新 store)
  209. '$store.state.target.gradesList'(newVal) {
  210. if (Array.isArray(newVal) && newVal.length > 0 && this.gradeList.length === 0) {
  211. this.loadGradeListFromStorage();
  212. }
  213. }
  214. },
  215. mounted() {
  216. // 从localStorage读取年级列表
  217. this.loadGradeListFromStorage();
  218. // 初始化容器高度
  219. this.calculateContainerHeight();
  220. // 监听窗口大小变化
  221. window.addEventListener('resize', this.calculateContainerHeight);
  222. // 首次加载知识图谱数据(含分区四区)
  223. this.fetchKnowledgeGraph();
  224. },
  225. // 当从其他路由切回来时(keep-alive),重新加载年级列表
  226. activated() {
  227. this.loadGradeListFromStorage();
  228. },
  229. beforeDestroy() {
  230. // 移除窗口大小变化监听
  231. window.removeEventListener('resize', this.calculateContainerHeight);
  232. },
  233. methods: {
  234. /**
  235. * 从 localStorage 读取年级列表 student_gradesList
  236. * 并规范化为 [{ label, value }] 结构
  237. */
  238. loadGradeListFromStorage() {
  239. try {
  240. let list = [];
  241. // 1. 优先从 localStorage 读取
  242. const raw = localStorage.getItem('student_gradesList');
  243. if (raw) {
  244. const parsed = JSON.parse(raw);
  245. if (Array.isArray(parsed)) list = parsed;
  246. }
  247. // 2. localStorage 没有则从 vuex store 兜底(store 初始化时也会从 localStorage 读)
  248. if (list.length === 0 && this.$store.state.target?.gradesList) {
  249. list = this.$store.state.target.gradesList;
  250. }
  251. if (!Array.isArray(list) || list.length === 0) return;
  252. // 规范化字段:支持 label/value、label/code、name/value 等常见组合
  253. this.gradeList = list.map(item => ({
  254. label: item.label || item.name || String(item.value ?? item.code ?? item),
  255. value: item.value ?? item.code ?? item.label ?? item.name ?? String(item)
  256. }));
  257. // 默认只选中最后一个年级
  258. // 例:[{高一,10},{高二,11}] → currentGradeList = ['11'](仅选中高二)
  259. // 同"加入练习接口 gradeCode = gradeList 末尾 value"规则保持一致
  260. this.currentGradeList = this.gradeList.length > 0
  261. ? [this.gradeList[this.gradeList.length - 1].value]
  262. : [];
  263. } catch (e) {
  264. console.warn('[KnowledgeGraph] 解析 localStorage.student_gradesList 失败:', e);
  265. }
  266. },
  267. /**
  268. * 【接口调用】获取知识图谱数据
  269. * 参数:
  270. * - levelCode: 学段CODE(localStorage.student_level_code)
  271. * - gradeCodes: 年级codes数组(checkbox选中的value,即使只有1个也要传)
  272. * - subjectCode: 学科code(vuex -> target.subjectCode)
  273. * 调用成功后打印返回数据
  274. */
  275. fetchKnowledgeGraph() {
  276. // 读取学段CODE
  277. const levelCode = localStorage.getItem('student_level_code') || '';
  278. // 年级codes:优先取checkbox勾选的value;未初始化时取最后一个年级的value(与默认勾选最后一个逻辑一致)
  279. let gradeCodes = this.currentGradeList && this.currentGradeList.length
  280. ? this.currentGradeList
  281. : (this.gradeList && this.gradeList.length > 0
  282. ? [this.gradeList[this.gradeList.length - 1].value]
  283. : []);
  284. // 学科code
  285. const subjectCode = this.subjectCode || '';
  286. const classType = 1;
  287. const params = {
  288. levelCode,
  289. gradeCodes,
  290. subjectCode,
  291. classType
  292. };
  293. this.$api.target.knowledgeGraph(params).then(res => {
  294. if (res.code == 200 && res.data) {
  295. const d = res.data;
  296. // 1. 统计数据
  297. this.graphStats = {
  298. allKnowNum: d.allKnowNum !== undefined ? Number(d.allKnowNum) : 0,
  299. weakKnowNum: d.weakKnowNum !== undefined ? Number(d.weakKnowNum) : 0,
  300. goodKnowNum: d.goodKnowNum !== undefined ? Number(d.goodKnowNum) : 0
  301. };
  302. // 2. 中心圆点学科名称:通过 subjectCode 从 localStorage.student_subjectList 匹配
  303. this.resolveSubjectNameByCode();
  304. // 3. knowList 树形数据 + 拍平(并把 personalScoreRate 映射为 classScoreRate / gradeScoreRate 以便图表组件识别)
  305. const list = Array.isArray(d.knowList) ? d.knowList : [];
  306. this.knowList = this.mapKnowListItem(list);
  307. this.flatKnowList = this.flattenKnowList(this.knowList);
  308. // 4. 默认选中:第一棵节点树的最后一个子节点(叶子节点)
  309. const defaultNode = this.findFirstTreeLastLeafNode(this.knowList);
  310. this.defaultKnowledgeId = defaultNode ? (defaultNode.knowledgeId ?? '') : '';
  311. // 5. 四区数据(结构与原 knowledgePartition 一致)
  312. this.partitionData = {
  313. conquerZone: this.mapPartitionItem(Array.isArray(d.conquerZone) ? d.conquerZone : []),
  314. improveZone: this.mapPartitionItem(Array.isArray(d.improveZone) ? d.improveZone : []),
  315. consolidateZone: this.mapPartitionItem(Array.isArray(d.consolidateZone) ? d.consolidateZone : []),
  316. sprintZone: this.mapPartitionItem(Array.isArray(d.sprintZone) ? d.sprintZone : (Array.isArray(d.sprint) ? d.sprint : []))
  317. };
  318. this.setDefaultSelectionFromPartition();
  319. } else {
  320. // 接口失败 / code !== 200 / 无data:清空数据,扩散图和统计数字归零
  321. this.resetKnowledgeGraphEmpty();
  322. }
  323. }).catch(err => {
  324. console.error('[KnowledgeGraph] knowledgeGraph 接口异常:', err);
  325. this.resetKnowledgeGraphEmpty();
  326. });
  327. },
  328. /**
  329. * 分区接口返回的每个子数组字段规范化
  330. * - knowledgeId / knowledgeName / personalScoreRate / examNum / knowledgeType
  331. * 顺便兜底转换成 Number,避免字符串导致渲染异常
  332. */
  333. mapPartitionItem(list) {
  334. if (!Array.isArray(list)) return [];
  335. return list.map(item => {
  336. const node = { ...(item || {}) };
  337. node.knowledgeId = node.knowledgeId ?? node.id;
  338. node.knowledgeName = node.knowledgeName ?? node.name ?? node.label ?? '';
  339. node.knowledgeType = Number(node.knowledgeType ?? node.type ?? 3);
  340. const pRate = node.personalScoreRate ?? node.scoreRate ?? node.personalRate;
  341. if (pRate !== null && pRate !== undefined && pRate !== '') {
  342. const n = Number(pRate);
  343. node.personalScoreRate = isNaN(n) ? 0 : n;
  344. if (node.classScoreRate === null || node.classScoreRate === undefined) {
  345. node.classScoreRate = node.personalScoreRate;
  346. }
  347. if (node.gradeScoreRate === null || node.gradeScoreRate === undefined) {
  348. node.gradeScoreRate = node.personalScoreRate;
  349. }
  350. }
  351. // 考频:examNum
  352. if (node.examNum !== null && node.examNum !== undefined && node.examNum !== '') {
  353. const n = Number(node.examNum);
  354. node.examNum = isNaN(n) ? 0 : n;
  355. }
  356. return node;
  357. });
  358. },
  359. /**
  360. * 清空分区数据(接口失败或无数据时调用)
  361. */
  362. resetPartitionEmpty() {
  363. this.partitionData = {
  364. conquerZone: [],
  365. improveZone: [],
  366. consolidateZone: [],
  367. sprintZone: []
  368. };
  369. },
  370. /**
  371. * 清空知识图谱所有数据(接口失败或无数据时调用)
  372. * - knowList / flatKnowList 清空
  373. * - graphStats 三项归零
  374. * - defaultKnowledgeId 清空
  375. * - subjectNameState 不重置(保持本地匹配兜底名,避免名称闪烁)
  376. */
  377. resetKnowledgeGraphEmpty() {
  378. this.knowList = [];
  379. this.flatKnowList = [];
  380. this.graphStats = {
  381. allKnowNum: 0,
  382. weakKnowNum: 0,
  383. goodKnowNum: 0
  384. };
  385. this.defaultKnowledgeId = '';
  386. this.resetPartitionEmpty();
  387. },
  388. /**
  389. * 从树形 knowList 中取"第一棵节点树的最后一个子节点(叶子)"
  390. * 规则:取 list[0] 作为根节点,然后一路沿 children[children.length-1] 下钻
  391. * 直到某节点没有 children 或 children 为空,返回该节点;
  392. * list 为空返回 null
  393. */
  394. findFirstTreeLastLeafNode(list) {
  395. if (!Array.isArray(list) || list.length === 0) return null;
  396. let node = list[0];
  397. while (node && Array.isArray(node.children) && node.children.length > 0) {
  398. node = node.children[node.children.length - 1];
  399. }
  400. return node || null;
  401. },
  402. /**
  403. * 通过 subjectCode 从 localStorage.student_subjectList 匹配学科名称
  404. * 并写入 this.subjectNameState
  405. */
  406. resolveSubjectNameByCode() {
  407. try {
  408. const subjectCode = String(this.subjectCode ?? '');
  409. if (!subjectCode) return;
  410. const raw = localStorage.getItem('student_subjectList');
  411. if (!raw) return;
  412. const list = JSON.parse(raw);
  413. if (!Array.isArray(list) || list.length === 0) return;
  414. const match = list.find(item => {
  415. const val = String(item.value ?? item.code ?? item.subjectCode ?? item.id ?? '');
  416. return val === subjectCode;
  417. });
  418. if (match) {
  419. const name = match.label ?? match.name ?? match.subjectName ?? String(match);
  420. if (name) {
  421. this.subjectNameState = name;
  422. return;
  423. }
  424. }
  425. // 兜底:取第一个
  426. const first = list[0];
  427. if (first) {
  428. const fallbackName = first.label ?? first.name ?? first.subjectName ?? String(first);
  429. if (fallbackName) this.subjectNameState = fallbackName;
  430. }
  431. } catch (e) {
  432. console.warn('[KnowledgeGraph] 解析 localStorage.student_subjectList 失败:', e);
  433. }
  434. },
  435. /**
  436. * 递归遍历 knowList,确保每个节点具备环形图需要的字段:
  437. * - knowledgeId / knowledgeName / knowledgeType
  438. * - personalScoreRate → 同时映射为 classScoreRate、gradeScoreRate(组件读取的是这两个)
  439. * - isJoin 原样保留(子组件需要据此显示"已加入练习")
  440. * - children 递归处理
  441. */
  442. mapKnowListItem(list) {
  443. if (!Array.isArray(list)) return [];
  444. return list.map(item => {
  445. const node = { ...(item || {}) };
  446. // 兼容字段
  447. node.knowledgeId = node.knowledgeId ?? node.id;
  448. node.knowledgeName = node.knowledgeName ?? node.name ?? node.label;
  449. node.knowledgeType = Number(node.knowledgeType ?? node.type ?? 3);
  450. const pRate = node.personalScoreRate ?? node.scoreRate ?? node.personalRate;
  451. if (pRate !== null && pRate !== undefined) {
  452. // 环形扩散图 generateTreeData 中会按 classLevel / mode 读取 classScoreRate 或 gradeScoreRate
  453. // 为了兼容两种路径,把 personalScoreRate 同时复制到两者
  454. if (node.classScoreRate === null || node.classScoreRate === undefined) {
  455. node.classScoreRate = Number(pRate);
  456. }
  457. if (node.gradeScoreRate === null || node.gradeScoreRate === undefined) {
  458. node.gradeScoreRate = Number(pRate);
  459. }
  460. node.personalScoreRate = Number(pRate);
  461. }
  462. if (node.children && node.children.length > 0) {
  463. node.children = this.mapKnowListItem(node.children);
  464. }
  465. return node;
  466. });
  467. },
  468. /**
  469. * 将 knowList(树形)拍平为一维数组(保留 isJoin 等全部字段)
  470. */
  471. flattenKnowList(list, out = []) {
  472. if (!Array.isArray(list)) return out;
  473. list.forEach(item => {
  474. out.push(item);
  475. if (item.children && item.children.length > 0) {
  476. this.flattenKnowList(item.children, out);
  477. }
  478. });
  479. return out;
  480. },
  481. // 年级切换(多选)
  482. handleGradeChange(gradeList) {
  483. this.currentGradeList = gradeList;
  484. this.$emit('grade-change', gradeList);
  485. // 年级变化后重新请求知识图谱(含分区四区)
  486. this.fetchKnowledgeGraph();
  487. },
  488. /**
  489. * 默认选中:薄弱补强区第一条 → 能力提升区第一条 → 扎实巩固区第一条
  490. * 将选中 knowledgeId 写入 currentLinkedKnowledgeId,联动 circularDiffusion 圆点高亮
  491. */
  492. setDefaultSelectionFromPartition() {
  493. const conquer = this.partitionData.conquerZone || [];
  494. const improve = this.partitionData.improveZone || [];
  495. const consolidate = this.partitionData.consolidateZone || [];
  496. let firstItem = null;
  497. if (conquer.length > 0) {
  498. firstItem = conquer[0];
  499. } else if (improve.length > 0) {
  500. firstItem = improve[0];
  501. } else if (consolidate.length > 0) {
  502. firstItem = consolidate[0];
  503. }
  504. if (firstItem && firstItem.knowledgeId) {
  505. this.currentLinkedKnowledgeId = String(firstItem.knowledgeId);
  506. }
  507. },
  508. // 计算容器高度
  509. calculateContainerHeight() {
  510. if (this.$refs.knowledgeGraph) {
  511. // 获取容器距离视口顶部的距离
  512. const rect = this.$refs.knowledgeGraph.getBoundingClientRect();
  513. // 计算容器最大高度:窗口高度 - 容器距离顶部的距离 - 底部留白
  514. const maxHeight = window.innerHeight - rect.top - 20;
  515. // 设置最小高度为400px
  516. this.containerHeight = Math.max(maxHeight, 400);
  517. }
  518. },
  519. // 处理图例变化
  520. handleLegendChange(data) {
  521. this.$emit('legend-change', data);
  522. },
  523. // 处理tab切换
  524. handleActiveTabChange(tab) {
  525. this.$emit('activeTabChange', tab);
  526. },
  527. // 处理知识点点击(来自环形扩散图组件:圆点被用户点击或默认选中)
  528. handleKnowledgeItemClick(data) {
  529. // 兼容两种 payload 结构:
  530. // 1) 圆点点击 / setDefaultSelection 新结构:{ knowledgeId, knowledgeName, ... } (顶层)
  531. // 2) 旧的 item 包装结构:{ item: {knowledgeId,...}, index: N }
  532. let knowledgeId = data?.knowledgeId;
  533. if ((knowledgeId === undefined || knowledgeId === null || knowledgeId === '')
  534. && data?.item) {
  535. knowledgeId = data.item.knowledgeId;
  536. }
  537. if (knowledgeId !== undefined && knowledgeId !== null && knowledgeId !== '') {
  538. this.currentLinkedKnowledgeId = String(knowledgeId);
  539. }
  540. this.$emit('knowledge-item-click', data);
  541. },
  542. // 处理区域卡片中某行条目被点击(来自 region 组件)→ 反向联动到环形扩散图对应圆点高亮 + 右侧数据
  543. handleRegionItemClick(item) {
  544. const id = item?.knowledgeId;
  545. if (id === undefined || id === null || id === '') return;
  546. this.currentLinkedKnowledgeId = String(id);
  547. },
  548. // 处理加入练习
  549. // 参数来源:
  550. // - 环形扩散图按钮:emit 传出 { knowledgeId, knowledgeName, personalScoreRate?, knowledgeType? }
  551. // - region 卡片按钮:emit 传出整条 item(含 knowledgeId/Name/personalScoreRate/knowledgeType/examNum...)
  552. handleAddPractice(item, sourceEl) {
  553. if (!item) {
  554. this.$message.warning('未获取到知识点信息');
  555. return;
  556. }
  557. const knowledgeId = item.knowledgeId ?? item.id;
  558. const knowledgeName = item.knowledgeName ?? item.name ?? item.label ?? '';
  559. if (!knowledgeId || !knowledgeName) {
  560. this.$message.warning('知识点信息不完整,请稍后重试');
  561. return;
  562. }
  563. // 已经加过的直接返回,避免重复请求
  564. if (this.addedKnowledgeIds[String(knowledgeId)]) {
  565. this.$message.info('该知识点已加入练习');
  566. return;
  567. }
  568. // 得分率:优先 personalScoreRate → gradeScoreRate → classScoreRate
  569. let personalScoreRate = item.personalScoreRate;
  570. if (personalScoreRate === null || personalScoreRate === undefined || personalScoreRate === '') {
  571. personalScoreRate = item.gradeScoreRate ?? item.classScoreRate;
  572. }
  573. if (personalScoreRate === null || personalScoreRate === undefined || personalScoreRate === '') {
  574. personalScoreRate = 0;
  575. } else {
  576. personalScoreRate = Number(personalScoreRate);
  577. if (isNaN(personalScoreRate)) personalScoreRate = 0;
  578. }
  579. // 知识点类型:1薄弱 2一般 3优秀
  580. let knowledgeType = Number(item.knowledgeType ?? item.type ?? 0);
  581. if (isNaN(knowledgeType)) knowledgeType = 0;
  582. const subjectCode = this.subjectCode || '';
  583. const levelCode = localStorage.getItem('student_level_code') || '';
  584. // 年级CODE:取 gradeList 数组中最后一个年级的 value
  585. // - 多个年级(如[{高一,10},{高二,11}])→ 取最后一个(高二.value = 11)
  586. // - 只有一个年级 → 直接取该年级 value
  587. // - 空/无效 → 传空串兜底
  588. let gradeCode = '';
  589. let gradeName = '';
  590. let schoolYearId = '';
  591. if (Array.isArray(this.gradeList) && this.gradeList.length > 0) {
  592. const lastGrade = this.gradeList[this.gradeList.length - 1];
  593. if (lastGrade && lastGrade.value !== undefined && lastGrade.value !== null && lastGrade.value !== '') {
  594. gradeCode = String(lastGrade.value);
  595. }
  596. // 年级NAME:与 gradeCode 一一对应,取同一元素的 label
  597. if (lastGrade && lastGrade.label !== undefined && lastGrade.label !== null) {
  598. gradeName = String(lastGrade.label);
  599. }
  600. const raw = JSON.parse(localStorage.getItem('student_gradesList'));
  601. schoolYearId = raw?.[0]?.gradeId || '';
  602. }
  603. const subjectName = this.subjectName || '';
  604. const params = {
  605. knowledgeId,
  606. knowledgeName,
  607. personalScoreRate,
  608. knowledgeType,
  609. subjectCode,
  610. subjectName,
  611. levelCode,
  612. gradeCode,
  613. gradeName,
  614. schoolYearId
  615. };
  616. this.$api.target.addExercise(params).then(async (res) => {
  617. if (res && (res.code == 200 || res.code == 0)) {
  618. this.$message.success('已加入练习成功');
  619. // 1. 记录已加入练习 ID → circularDiffusion 的加入练习按钮置灰
  620. this.$set(this.addedKnowledgeIds, String(knowledgeId), true);
  621. // 2. 先抓取当前按钮位置并开启动画(分区列表移除后 DOM 会消失)
  622. const startEl = (sourceEl && sourceEl.nodeType === 1)
  623. ? sourceEl
  624. : this.getGrabSourceEl();
  625. const animPromise = this.createGrabAnimation(startEl, item);
  626. // 3. 从 region 分区列表中移除该条数据
  627. this.removeKnowledgeFromPartition(knowledgeId);
  628. await animPromise;
  629. this.$emit('add-practice-success');
  630. } else {
  631. this.$message.error(res.msg);
  632. }
  633. }).catch(err => {
  634. console.error('[KnowledgeGraph] addExercise 接口异常:', err);
  635. this.$message.error('加入练习失败,请稍后重试');
  636. });
  637. },
  638. /**
  639. * 获取环形图右侧「加入练习」模块 DOM,作为抓取动画起点
  640. */
  641. getGrabSourceEl() {
  642. const chartRef = this.$refs.circularDiffusion;
  643. return (chartRef && chartRef.$refs && chartRef.$refs.grabSourceEl) || null;
  644. },
  645. /**
  646. * 按知识点类型取分区条目的背景色 / 边框色
  647. * 1薄弱 #F2B5B5 / 2能力提升 #F7DEA5 / 3扎实巩固 #AEE2C5 / 其他冲刺 #B9CBFC
  648. */
  649. getZoneGrabTheme(item) {
  650. const t = Number(item && (item.knowledgeType ?? item.type));
  651. if (t === 2) return { backgroundColor: '#FFFFFF', borderColor: '#F7DEA5' };
  652. if (t === 3) return { backgroundColor: '#FFFFFF', borderColor: '#AEE2C5' };
  653. if (t === 4) return { backgroundColor: '#FFFFFF', borderColor: '#B9CBFC' };
  654. return { backgroundColor: '#FFFFFF', borderColor: '#F2B5B5' };
  655. },
  656. /**
  657. * 抓取飞入动画:从加入练习模块飞向顶部「知识点练习作答」
  658. * 起点/终点均用 getBoundingClientRect(相对视口),自动适配页面滚动
  659. * 克隆体带上当前分区条目的背景色和边框色
  660. */
  661. createGrabAnimation(startEl, item) {
  662. try {
  663. const endEl = document.querySelector('.js-practice-answer-step');
  664. if (!startEl || !endEl) return Promise.resolve();
  665. const visualEl = (startEl.closest && startEl.closest('.knowledge_row')) || startEl;
  666. const startRect = visualEl.getBoundingClientRect();
  667. const endRect = endEl.getBoundingClientRect();
  668. if (!startRect.width || !startRect.height) return Promise.resolve();
  669. const HOLD_MS = 80;
  670. const FLY_MS = 800;
  671. const FADE_MS = 100;
  672. const theme = this.getZoneGrabTheme(item);
  673. let backgroundColor = theme.backgroundColor;
  674. let borderColor = theme.borderColor;
  675. let borderRadius = '4px';
  676. const isRow = visualEl.classList && visualEl.classList.contains('knowledge_row');
  677. if (isRow) {
  678. try {
  679. const cs = window.getComputedStyle(visualEl);
  680. if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') {
  681. backgroundColor = cs.backgroundColor;
  682. }
  683. if (cs.borderColor && cs.borderColor !== 'rgba(0, 0, 0, 0)') {
  684. borderColor = cs.borderColor;
  685. }
  686. if (cs.borderRadius) {
  687. borderRadius = cs.borderRadius;
  688. }
  689. } catch (e) {}
  690. }
  691. const clone = visualEl.cloneNode(true);
  692. clone.style.position = 'fixed';
  693. clone.style.left = `${startRect.left}px`;
  694. clone.style.top = `${startRect.top}px`;
  695. clone.style.width = `${startRect.width}px`;
  696. clone.style.height = `${startRect.height}px`;
  697. clone.style.margin = '0';
  698. clone.style.zIndex = '2000';
  699. clone.style.transition = `transform ${FLY_MS}ms cubic-bezier(.22,.61,.36,1)`;
  700. clone.style.boxSizing = 'border-box';
  701. clone.style.backgroundColor = backgroundColor;
  702. clone.style.border = `1px solid ${borderColor}`;
  703. clone.style.borderRadius = borderRadius;
  704. clone.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.3)';
  705. clone.style.overflow = 'hidden';
  706. clone.style.pointerEvents = 'none';
  707. clone.style.transformOrigin = 'center center';
  708. clone.style.opacity = '1';
  709. clone.removeAttribute('id');
  710. document.body.appendChild(clone);
  711. const dx = endRect.left + endRect.width / 2 - (startRect.left + startRect.width / 2);
  712. const dy = endRect.top + endRect.height / 2 - (startRect.top + startRect.height / 2);
  713. clone.getBoundingClientRect();
  714. return new Promise(resolve => {
  715. let done = false;
  716. const finish = () => {
  717. if (done) return;
  718. done = true;
  719. clone.removeEventListener('transitionend', onEnd);
  720. if (document.body.contains(clone)) {
  721. try { document.body.removeChild(clone); } catch (e) {}
  722. }
  723. resolve();
  724. };
  725. const onEnd = (e) => {
  726. if (e && e.target !== clone) return;
  727. if (e && e.propertyName && e.propertyName !== 'transform') return;
  728. // 飞到目标后再淡出移除,避免中途消失
  729. clone.style.transition = `opacity ${FADE_MS}ms ease`;
  730. clone.style.opacity = '0';
  731. setTimeout(finish, FADE_MS);
  732. };
  733. clone.addEventListener('transitionend', onEnd);
  734. setTimeout(() => {
  735. clone.style.transform = `translate(${dx}px, ${dy}px)`;
  736. }, HOLD_MS);
  737. setTimeout(finish, HOLD_MS + FLY_MS + FADE_MS + 300);
  738. });
  739. } catch (e) {
  740. console.error('创建抓取动画失败:', e);
  741. return Promise.resolve();
  742. }
  743. },
  744. /**
  745. * 从分区列表中移除已加入练习的知识点
  746. * 遍历四个分区数组,按 knowledgeId 删除匹配项
  747. */
  748. removeKnowledgeFromPartition(knowledgeId) {
  749. const sid = String(knowledgeId);
  750. const zones = ['conquerZone', 'improveZone', 'consolidateZone', 'sprintZone'];
  751. let changed = false;
  752. for (const zone of zones) {
  753. const list = this.partitionData[zone];
  754. if (!Array.isArray(list)) continue;
  755. const idx = list.findIndex(item => String(item.knowledgeId) === sid);
  756. if (idx !== -1) {
  757. // 用 splice 保证响应式
  758. this.partitionData[zone].splice(idx, 1);
  759. changed = true;
  760. break;
  761. }
  762. }
  763. return changed;
  764. }
  765. }
  766. };
  767. </script>
  768. <style lang="scss" scoped>
  769. .knowledge_graph {
  770. overflow-y: auto;
  771. overflow-x: hidden;
  772. width: 100%;
  773. box-sizing: border-box;
  774. .graph_top {
  775. width: 100%;
  776. box-sizing: border-box;
  777. overflow: hidden;
  778. .knowledge_info{
  779. border-radius: 10px;
  780. padding: 20px;
  781. background: #FFFFFF;
  782. margin-bottom: 10px;
  783. }
  784. }
  785. /* 顶部信息模块 */
  786. .knowledge_header {
  787. margin-bottom: 20px;
  788. .header_top {
  789. display: flex;
  790. align-items: center;
  791. gap: 20px;
  792. margin-bottom: 10px;
  793. .header_title {
  794. font-size: 16px;
  795. font-weight: 500;
  796. color: #333;
  797. }
  798. .el-checkbox-group{
  799. .el-checkbox{
  800. margin-right:20px;
  801. }
  802. }
  803. }
  804. .header_stats {
  805. font-size: 14px;
  806. .stats_text {
  807. color: #999999;
  808. }
  809. .stats_num {
  810. color: #2E64FA;
  811. font-weight: 550;
  812. }
  813. .stats_weak {
  814. color: #F56C6C;
  815. font-weight: 550;
  816. }
  817. .stats_good {
  818. color: #FAC858;
  819. font-weight: 550;
  820. }
  821. }
  822. }
  823. /* 自定义滚动条样式 */
  824. &::-webkit-scrollbar {
  825. width: 6px;
  826. }
  827. &::-webkit-scrollbar-track {
  828. background: #f1f1f1;
  829. border-radius: 3px;
  830. }
  831. &::-webkit-scrollbar-thumb {
  832. background: #c1c1c1;
  833. border-radius: 3px;
  834. &:hover {
  835. background: #a8a8a8;
  836. }
  837. }
  838. }
  839. </style>