circularDiffusion.vue 57 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673
  1. <template>
  2. <div class="circularDiffusion">
  3. <!-- 教师端样式 -->
  4. <div class="knowledge_class">
  5. <!-- 图形查看容器 -->
  6. <div class="graph_content">
  7. <!-- 左侧图表 -->
  8. <div class="chart_container">
  9. <!-- 顶部容器:图例和视图切换按钮 -->
  10. <div class="top_container">
  11. <!-- 自定义图例 -->
  12. <div class="legend">
  13. <div class="legend_item" :class="{ 'selected': selectedLegend.weak }" @click="toggleLegend('weak')">
  14. <span class="legend_dot weak"></span>
  15. <span class="legend_text">{{ mode === 'student' && activeComparison ? '薄弱(得分率差值<-10%)'
  16. : '薄弱(0%≤得分率<60%)' }}</span>
  17. </div>
  18. <div class="legend_item" :class="{ 'selected': selectedLegend.good }" @click="toggleLegend('good')">
  19. <span class="legend_dot good"></span>
  20. <span class="legend_text">
  21. {{ mode === 'student' && activeComparison ? '持平(-10%≤得分率差值≤10%)' : '良好(60%≤得分率< 85 %) ' }}</span>
  22. </div>
  23. <div class="legend_item" :class="{ ' selected': selectedLegend.excellent }"
  24. @click="toggleLegend('excellent')">
  25. <span class="legend_dot excellent"></span>
  26. <span class="legend_text">{{ mode === 'student' && activeComparison ? '优势(得分率差值>10%)' :
  27. '优秀(85%≤得分率)'
  28. }}</span>
  29. </div>
  30. </div>
  31. </div>
  32. <div ref="chart" class="chart" style="width: 100%;"></div>
  33. </div>
  34. <!-- 右侧容器 -->
  35. <div class="knowledge_right_container">
  36. <!-- 折线图 -->
  37. <div class="line_chart_container">
  38. <!-- 标题栏(放到折线图上方):左右布局,左侧上下结构 -->
  39. <div class="chart_title" v-if="selectedKnowledgeName">
  40. <div class="chart_title_left">
  41. <span class="title_text" :title="selectedKnowledgeName">{{ selectedKnowledgeName }}</span>
  42. <span class="exam_freq">考频:{{ filteredTableData.length }}</span>
  43. </div>
  44. <el-button
  45. class="add_practice_btn"
  46. :class="addPracticeBtnThemeClass"
  47. :disabled="selectedIsJoin"
  48. @click="handleAddPractice">
  49. {{ selectedIsJoin ? '已加入练习' : '加入练习' }}
  50. </el-button>
  51. </div>
  52. <div ref="lineChart" class="line_chart"></div>
  53. </div>
  54. <!-- Tab切换和表格 -->
  55. <div class="table_container">
  56. <el-radio-group v-model="activeTableTab" class="table_tabs_radio">
  57. <el-radio-button label="all">全部</el-radio-button>
  58. <el-radio-button label="exam">考试</el-radio-button>
  59. <el-radio-button label="practice">练习</el-radio-button>
  60. </el-radio-group>
  61. <el-table :data="filteredTableData" border stripe class="data_table">
  62. <el-table-column prop="index" label="序号" width="60" align="center">
  63. <template slot-scope="scope">
  64. {{ scope.$index + 1 }}
  65. </template>
  66. </el-table-column>
  67. <el-table-column prop="examName" label="考试名称" show-overflow-tooltip align="center"></el-table-column>
  68. <el-table-column prop="examType" label="类型" width="100" align="center"></el-table-column>
  69. <el-table-column prop="scoreRate" label="得分率" width="100" align="center">
  70. <template slot-scope="scope">
  71. <span :style="{ color: getCurrentColor() }">{{ scope.row.scoreRate }}%</span>
  72. </template>
  73. </el-table-column>
  74. </el-table>
  75. </div>
  76. </div>
  77. </div>
  78. </div>
  79. </div>
  80. </template>
  81. <script>
  82. // 引入echarts
  83. import * as echarts from 'echarts';
  84. export default {
  85. name: 'CircularDiffusion',
  86. props: {
  87. studentName: {
  88. type: String,
  89. default: ''
  90. },
  91. mode: {
  92. type: String,
  93. default: 'class',
  94. validator: (value) => {
  95. return ['class', 'student'].includes(value);
  96. }
  97. },
  98. subjectName: {
  99. type: String,
  100. default: '知识点'
  101. },
  102. subjectScoreRate: {
  103. type: Number,
  104. default: 0
  105. },
  106. tableData: {
  107. type: Array,
  108. default: () => []
  109. },
  110. fatalVulnerability: {
  111. type: Array,
  112. default: () => []
  113. },
  114. highVulnerability: {
  115. type: Array,
  116. default: () => []
  117. },
  118. allKnowledgeList: {
  119. type: Array,
  120. default: () => []
  121. },
  122. isRecordKnowledge: {
  123. type: Boolean,
  124. default: false
  125. },
  126. studentKnowledgeLayering: {
  127. type: String,
  128. default: ''
  129. },
  130. classLevel: {
  131. type: [String, Number],
  132. default: ''
  133. },
  134. classGroupName: {
  135. type: String,
  136. default: ''
  137. },
  138. historicalChangeData: {
  139. type: Object,
  140. default: () => ({
  141. examNames: [],
  142. scores: [],
  143. trendData: [],
  144. personalScoreRate: []
  145. })
  146. },
  147. historicalChangeLoading: {
  148. type: Boolean,
  149. default: false
  150. },
  151. currentKnowledgeId: {
  152. type: [String, Number],
  153. default: ''
  154. },
  155. scoreRateTypes: {
  156. type: Array,
  157. default: () => []
  158. },
  159. comparisonOptions: {
  160. type: Array,
  161. default: null
  162. },
  163. isPush: {
  164. type: Boolean,
  165. default: false
  166. },
  167. // 父级已成功"加入练习"的知识点ID映射:{ [knowledgeId]: true }
  168. addedKnowledgeIds: {
  169. type: Object,
  170. default: () => ({})
  171. },
  172. },
  173. computed: {
  174. // 从扁平 allKnowledgeList(或tableData递归兜底)获取当前选中节点的完整对象
  175. currentKnowledgeItem() {
  176. if (!this.selectedKnowledgeId) return null;
  177. const sid = String(this.selectedKnowledgeId);
  178. if (Array.isArray(this.allKnowledgeList) && this.allKnowledgeList.length) {
  179. const hit = this.allKnowledgeList.find(item => String(item.knowledgeId) === sid);
  180. if (hit) return hit;
  181. }
  182. // 再从树形 tableData 递归找一次兜底(扁平列表可能为空但树形有数据)
  183. const findInTree = (arr) => {
  184. for (const item of arr) {
  185. if (String(item.knowledgeId) === sid) return item;
  186. if (Array.isArray(item.children) && item.children.length) {
  187. const found = findInTree(item.children);
  188. if (found) return found;
  189. }
  190. }
  191. return null;
  192. };
  193. return findInTree(this.tableData || []);
  194. },
  195. // 选中的知识点名称
  196. selectedKnowledgeName() {
  197. const item = this.currentKnowledgeItem;
  198. return item ? (item.knowledgeName ?? '') : '';
  199. },
  200. // 当前选中知识点是否"已加入练习"
  201. // 优先级:1) 父级 addedKnowledgeIds(当前会话刚点按钮成功,立即生效)→ 2) 节点自身 isJoin===1(接口返回的静态字段)
  202. selectedIsJoin() {
  203. if (this.selectedKnowledgeId
  204. && this.addedKnowledgeIds
  205. && Object.prototype.hasOwnProperty.call(this.addedKnowledgeIds, String(this.selectedKnowledgeId))) {
  206. return true;
  207. }
  208. const item = this.currentKnowledgeItem;
  209. return item ? (Number(item.isJoin) === 1) : false;
  210. },
  211. // 加入练习按钮主题 class(按当前选中圆点的类型匹配颜色)
  212. // 与 getCurrentColor() / getNodeColor() 严格对齐:
  213. // knowledgeType=3 优秀/扎实巩固 → #3BA272
  214. // knowledgeType=2 一般/能力提升 → #FAC858
  215. // knowledgeType=1 薄弱 → #EE6666
  216. addPracticeBtnThemeClass() {
  217. const t = Number(this.selectedKnowledgeType);
  218. if (t === 3) return 'btn_theme_excellent';
  219. if (t === 2) return 'btn_theme_good';
  220. return 'btn_theme_weak';
  221. },
  222. // 选中知识点的考频
  223. selectedExamFreq() {
  224. const item = this.currentKnowledgeItem;
  225. if (item) {
  226. // 兼容常见考频字段名
  227. return item.examFrequency ?? item.examFreq ?? item.freq ?? item.frequency ?? item.count ?? '--';
  228. }
  229. return '--';
  230. },
  231. // 当前选中节点的考试/练习明细数组(来自后端 examList 字段)
  232. // 每个元素结构:{ dataName, dataType, dataRate }
  233. selectedExamList() {
  234. const item = this.currentKnowledgeItem;
  235. if (item && Array.isArray(item.examList)) {
  236. return item.examList.filter(e => e); // 过滤空项
  237. }
  238. return [];
  239. },
  240. // 筛选表格数据:全部 / 考试(dataType=1) / 练习(dataType=2)
  241. // 并将 examList 字段映射为表格列需要的字段:
  242. // dataName -> examName, dataType -> examType/type, dataRate -> scoreRate
  243. filteredTableData() {
  244. const list = (this.selectedExamList || []).map(item => {
  245. const dataTypeNumber = Number(item.dataType);
  246. const examTypeLabel = dataTypeNumber === 1 ? '考试' : (dataTypeNumber === 2 ? '练习' : '');
  247. const typeKey = dataTypeNumber === 1 ? 'exam' : 'practice';
  248. return {
  249. examName: item.dataName ?? '',
  250. examType: item.examType ?? examTypeLabel,
  251. scoreRate: item.dataRate ?? 0,
  252. type: typeKey,
  253. dataType: dataTypeNumber,
  254. dataRate: item.dataRate
  255. };
  256. });
  257. if (this.activeTableTab === 'all') {
  258. return list;
  259. }
  260. if (this.activeTableTab === 'exam') {
  261. return list.filter(row => row.dataType === 1 || row.type === 'exam');
  262. }
  263. return list.filter(row => row.dataType === 2 || row.type === 'practice');
  264. }
  265. },
  266. data() {
  267. return {
  268. chart: null,
  269. lineChart: null,
  270. _isMounted: false,
  271. _isFirstRender: true,
  272. activeComparison: null,
  273. selectedLegend: {
  274. weak: true,
  275. good: true,
  276. excellent: true
  277. },
  278. legendNumbers: [1, 2, 3],
  279. selectedIndex: 0,
  280. selectedKnowledgeId: '',
  281. selectedKnowledgeType: 3,
  282. chartRefreshKey: 0,
  283. showKnowledgePrompt: this.isRecordKnowledge,
  284. activeTableTab: 'all'
  285. };
  286. },
  287. mounted() {
  288. this._isMounted = true;
  289. this.$nextTick(() => {
  290. this.initChart();
  291. this.initLineChart();
  292. });
  293. },
  294. beforeDestroy() {
  295. this._isMounted = false;
  296. if (this.chart) {
  297. this.chart.dispose();
  298. }
  299. if (this.lineChart) {
  300. this.lineChart.dispose();
  301. }
  302. },
  303. watch: {
  304. // 当知识点列表数据变化时,更新选中项的类型与折线图/表格
  305. allKnowledgeList: {
  306. handler(newVal) {
  307. if (newVal && newVal.length > 0 && this.selectedKnowledgeId) {
  308. const item = newVal.find(item => String(item.knowledgeId) === String(this.selectedKnowledgeId));
  309. if (item) {
  310. this.selectedKnowledgeType = item.knowledgeType || 3;
  311. }
  312. if (this.lineChart) this.updateLineChart();
  313. }
  314. },
  315. deep: true
  316. },
  317. tableData: {
  318. deep: true,
  319. handler(newVal) {
  320. if (this.chart) {
  321. this.$nextTick(() => {
  322. this.updateChart();
  323. // 数据刷新后,如果父组件没传 currentKnowledgeId,则重新走一次默认选中
  324. if (!this.currentKnowledgeId) {
  325. this.setDefaultSelection();
  326. }
  327. if (this.lineChart) this.updateLineChart();
  328. });
  329. }
  330. }
  331. },
  332. currentKnowledgeId: {
  333. handler(newVal) {
  334. if (newVal === undefined || newVal === null) {
  335. this.selectedKnowledgeId = '';
  336. return;
  337. }
  338. this.selectedKnowledgeId = newVal;
  339. // 更新知识点类型:先在 allKnowledgeList 扁平表中找
  340. let item = (this.allKnowledgeList || []).find(i => String(i.knowledgeId) === String(newVal));
  341. // 兜底:在 tableData 整棵树里按 knowledgeId 查找(保证 region 点任何行都能拿到 knowledgeType)
  342. if (!item && Array.isArray(this.tableData)) {
  343. const stack = [...this.tableData];
  344. while (stack.length && !item) {
  345. const n = stack.pop();
  346. if (n && String(n.knowledgeId) === String(newVal)) {
  347. item = n;
  348. break;
  349. }
  350. if (n && Array.isArray(n.children) && n.children.length) {
  351. for (let i = 0; i < n.children.length; i++) stack.push(n.children[i]);
  352. }
  353. }
  354. }
  355. if (item) {
  356. this.selectedKnowledgeType = item.knowledgeType || 3;
  357. }
  358. if (this.chart) this.updateChart();
  359. // 更新折线图和表格(依赖 examList 驱动,computed 会同步)
  360. if (this.lineChart) this.updateLineChart();
  361. },
  362. immediate: true
  363. },
  364. // 考试/练习 tab 切换时重新计算表格数据(computed.filteredTableData 会自动响应)
  365. activeTableTab() {
  366. // 预留:切换 tab 后若需做高亮/滚动控制可补
  367. },
  368. isRecordKnowledge: {
  369. handler(newVal) {
  370. this.showKnowledgePrompt = newVal;
  371. }
  372. },
  373. scoreRateTypes: {
  374. deep: true,
  375. handler(newVal) {
  376. const val = Array.isArray(newVal) ? newVal : [];
  377. this.selectedLegend.weak = val.length === 0 || val.includes(1);
  378. this.selectedLegend.good = val.length === 0 || val.includes(2);
  379. this.selectedLegend.excellent = val.length === 0 || val.includes(3);
  380. this.legendNumbers = val.length === 0 ? [1, 2, 3] : [...val].sort((a, b) => a - b);
  381. if (this.chart) this.updateChart();
  382. }
  383. },
  384. },
  385. methods: {
  386. // 获取当前颜色(根据选中知识点类型)
  387. getCurrentColor() {
  388. if (this.selectedKnowledgeType === 3) {
  389. return '#3BA272';
  390. } else if (this.selectedKnowledgeType === 2) {
  391. return '#FAC858';
  392. } else {
  393. return '#EE6666';
  394. }
  395. },
  396. // 初始化折线图
  397. initLineChart() {
  398. if (!this.$refs.lineChart) return;
  399. if (this.lineChart) {
  400. this.lineChart.dispose();
  401. }
  402. this.lineChart = echarts.init(this.$refs.lineChart);
  403. this.updateLineChart();
  404. },
  405. // 更新折线图:数据源为当前选中知识点节点上的 examList 数组
  406. // xAxis = examList[i].dataName
  407. // yAxis series.data = examList[i].dataRate (0~100百分比)
  408. updateLineChart() {
  409. const LOG_TAG = '%c[Diffusion.updateLineChart]';
  410. const LOG_STYLE = 'background:#3BA272;color:#fff;padding:2px 8px;border-radius:4px;font-weight:500;';
  411. const callerStack = (new Error()).stack
  412. ?.split('\n')
  413. ?.slice(1, 5)
  414. ?.map(s => s.trim())
  415. ?.join(' | ') || '';
  416. const snapshotBefore = {
  417. hasLineChartInstance: !!this.lineChart,
  418. hasLineChartRef: !!this.$refs.lineChart,
  419. selectedKnowledgeId: this.selectedKnowledgeId ?? '(empty)',
  420. selectedKnowledgeType: this.selectedKnowledgeType,
  421. selectedExamListLength: (this.selectedExamList || []).length,
  422. filteredTableDataLength: (this.filteredTableData || []).length,
  423. activeTableTab: this.activeTableTab,
  424. caller: callerStack
  425. };
  426. if (!this.lineChart) {
  427. console.warn(LOG_TAG + ' 提前跳过:lineChart 实例不存在(initLineChart 还未执行 / 已被 dispose)', LOG_STYLE, snapshotBefore);
  428. return;
  429. }
  430. const color = this.getCurrentColor();
  431. const examList = this.selectedExamList || [];
  432. const xAxisData = examList.map(item => item.dataName ?? '');
  433. const seriesData = examList.map(item => (item.dataRate === null || item.dataRate === undefined ? null : Number(item.dataRate)));
  434. const option = {
  435. tooltip: {
  436. trigger: 'axis',
  437. formatter: (params) => {
  438. const p = Array.isArray(params) ? params[0] : params;
  439. if (!p || p.value === null || p.value === undefined) return `${p.name ?? ''}`;
  440. return `${p.name}: ${p.value}%`;
  441. }
  442. },
  443. grid: {
  444. left: '5%',
  445. right: '5%',
  446. top: '10%',
  447. bottom: '10%'
  448. },
  449. xAxis: {
  450. show: false,
  451. type: 'category',
  452. data: xAxisData,
  453. axisLabel: {
  454. show: false,
  455. fontSize: 10,
  456. color: '#999',
  457. rotate: 30
  458. },
  459. axisLine: {
  460. show: false,
  461. lineStyle: { color: '#ECEEF3' }
  462. },
  463. axisTick: {
  464. show: false
  465. }
  466. },
  467. yAxis: {
  468. show: false,
  469. type: 'value',
  470. min: 0,
  471. max: 100,
  472. axisLabel: {
  473. show: false,
  474. fontSize: 10,
  475. color: '#999',
  476. formatter: '{value}%'
  477. },
  478. splitLine: {
  479. show: false,
  480. lineStyle: {
  481. color: '#f5f5f5',
  482. type: 'dashed'
  483. }
  484. }
  485. },
  486. series: [{
  487. data: seriesData,
  488. type: 'line',
  489. smooth: true,
  490. symbol: 'circle',
  491. symbolSize: 8,
  492. connectNulls: false,
  493. itemStyle: {
  494. color: '#fff',
  495. borderColor: color,
  496. borderWidth: 2
  497. },
  498. lineStyle: {
  499. color: color,
  500. width: 2
  501. },
  502. areaStyle: {
  503. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  504. { offset: 0, color: color + '40' },
  505. { offset: 1, color: color + '05' }
  506. ])
  507. },
  508. // 确保圆点显示在折线上方,遮挡线条
  509. z: 10
  510. }]
  511. };
  512. const t0 = performance.now();
  513. this.lineChart.setOption(option, true); // notMerge=true,防止上一个节点残留x轴/点
  514. this.lineChart.resize();
  515. const t1 = performance.now();
  516. },
  517. // 处理加入练习:把当前选中节点的完整字段 emit 给父组件,父组件调加入练习接口
  518. handleAddPractice() {
  519. const item = this.currentKnowledgeItem;
  520. const knowledgeId = this.selectedKnowledgeId ?? item?.knowledgeId ?? null;
  521. const knowledgeName = this.selectedKnowledgeName ?? item?.knowledgeName ?? '';
  522. if (!knowledgeId) {
  523. this.$message && this.$message.warning && this.$message.warning('请先选择一个知识点');
  524. return;
  525. }
  526. // 得分率:personalScoreRate → gradeScoreRate → classScoreRate
  527. let personalScoreRate = item?.personalScoreRate;
  528. if (personalScoreRate === null || personalScoreRate === undefined || personalScoreRate === '') {
  529. personalScoreRate = item?.gradeScoreRate ?? item?.classScoreRate;
  530. }
  531. if (personalScoreRate === null || personalScoreRate === undefined || personalScoreRate === '') {
  532. personalScoreRate = 0;
  533. } else {
  534. personalScoreRate = Number(personalScoreRate);
  535. if (isNaN(personalScoreRate)) personalScoreRate = 0;
  536. }
  537. // 知识点类型 1薄弱 2一般 3优秀
  538. let knowledgeType = Number(item?.knowledgeType ?? item?.type ?? 0);
  539. if (isNaN(knowledgeType)) knowledgeType = 0;
  540. this.$emit('add-practice', {
  541. knowledgeId,
  542. knowledgeName,
  543. personalScoreRate,
  544. knowledgeType,
  545. ...(item ? { ...item } : {})
  546. });
  547. },
  548. setDefaultSelection() {
  549. // 如果父组件已经传入 currentKnowledgeId,则使用父级指定的选中项,不再重选
  550. // ——但如果父级指定的节点被"图例过滤规则"判定为不可见/降级(hasData=false),则需要重选
  551. if (this.currentKnowledgeId) {
  552. this.selectedKnowledgeId = this.currentKnowledgeId;
  553. // 先按当前图例规则重算一遍过滤树,判断父级指定的节点是否"最终可见且有数据"
  554. const filteredTree = this.calcFilteredTree(this.tableData);
  555. let matchNode = null;
  556. const findStack = [...filteredTree];
  557. while (findStack.length && !matchNode) {
  558. const n = findStack.pop();
  559. if (n && String(n.knowledgeId) === String(this.currentKnowledgeId)) {
  560. matchNode = n;
  561. break;
  562. }
  563. if (n && Array.isArray(n.children) && n.children.length > 0) {
  564. for (let i = 0; i < n.children.length; i++) findStack.push(n.children[i]);
  565. }
  566. }
  567. const isFinalValid = matchNode && matchNode.__finalVisible && matchNode.__finalHasData;
  568. if (isFinalValid) {
  569. // 父级指定的节点可见且有效:直接沿用
  570. this.selectedKnowledgeType = matchNode.knowledgeType || 3;
  571. if (this.lineChart) this.updateLineChart();
  572. // 通知父级联动 state(保证首次进来默认选中时 region 卡片也高亮对应条目 + 居中滚动)
  573. this.$emit('knowledge-item-click', {
  574. knowledgeId: matchNode.knowledgeId,
  575. knowledgeName: matchNode.knowledgeName,
  576. personalScoreRate: matchNode.personalScoreRate != null ? matchNode.personalScoreRate : matchNode.scoreRate,
  577. knowledgeType: matchNode.knowledgeType,
  578. examList: Array.isArray(matchNode.examList) ? matchNode.examList : []
  579. });
  580. return;
  581. }
  582. // 否则:父级指定被图例隐藏或降级 → 走重选逻辑(下面的 legacyLists/树形)
  583. }
  584. // 1. 优先从扁平化 allKnowledgeList / highVulnerability / fatalVulnerability 取第一个
  585. // ——仍然要过一遍"图例过滤",只有最终可见+hasData 的才能选中
  586. let targetNode = null;
  587. const legacyLists = [this.allKnowledgeList, this.highVulnerability, this.fatalVulnerability];
  588. for (const lst of legacyLists) {
  589. if (!Array.isArray(lst) || lst.length === 0) continue;
  590. // 从 lst 中找第一个:经过 calcFilteredTree 判定后 __finalVisible=true && __finalHasData=true
  591. const quickTree = this.calcFilteredTree(lst.map(it => ({ ...it }))); // 扁平化结构当 root 层处理即可
  592. for (let i = 0; i < quickTree.length; i++) {
  593. const n = quickTree[i];
  594. if (n && n.__finalVisible && n.__finalHasData) {
  595. targetNode = n;
  596. break;
  597. }
  598. }
  599. if (targetNode) break;
  600. }
  601. // 2. 兜底:从 tableData(树形)取"第一棵节点树的最后一个可见且有效叶子"
  602. if (!targetNode) {
  603. targetNode = this.findFirstVisibleLeafNode(this.calcFilteredTree(this.tableData));
  604. }
  605. if (!targetNode) {
  606. // 没找到任何可见有效节点(例如全部图例都关闭)→ 清空选中
  607. this.selectedKnowledgeId = '';
  608. this.selectedKnowledgeType = 3;
  609. if (this.lineChart) this.updateLineChart();
  610. return;
  611. }
  612. this.selectedIndex = 0;
  613. this.selectedKnowledgeId = targetNode.knowledgeId;
  614. this.selectedKnowledgeType = targetNode.knowledgeType || 3;
  615. // 初始化折线图(确保颜色正确)
  616. if (this.lineChart) {
  617. this.updateLineChart();
  618. }
  619. // 统一:应用选中大小(放大/还原)
  620. this.applySelectedSizeToChart(this.selectedKnowledgeId);
  621. // 通知父级联动 state(保证首次进来默认选中 / 图例过滤重选后 region 卡片也高亮对应条目 + 居中滚动)
  622. this.$emit('knowledge-item-click', {
  623. knowledgeId: targetNode.knowledgeId,
  624. knowledgeName: targetNode.knowledgeName,
  625. personalScoreRate: targetNode.personalScoreRate != null ? targetNode.personalScoreRate : targetNode.scoreRate,
  626. knowledgeType: targetNode.knowledgeType,
  627. examList: Array.isArray(targetNode.examList) ? targetNode.examList : []
  628. });
  629. },
  630. switchView(view) {
  631. this.$emit('view-change', view);
  632. },
  633. classIsGroup() {
  634. if (this.classLevel == 1) {
  635. return true;
  636. } else
  637. return false;
  638. },
  639. getNodeColor(knowledgeType) {
  640. const type = knowledgeType;
  641. if (type == 3) {
  642. return '#3BA272';
  643. } else if (type == 2) {
  644. return '#FAC858';
  645. } else {
  646. return '#EE6666';
  647. }
  648. },
  649. // 工具:knowledgeType -> legend 级别
  650. getNodeLevel(knowledgeType) {
  651. if (knowledgeType == 3) return 'excellent';
  652. if (knowledgeType == 2) return 'good';
  653. return 'weak';
  654. },
  655. /**
  656. * 基于 selectedLegend 计算"过滤后"的树形展示状态(后序遍历:先处理 children 再决定当前节点)
  657. * 规则(与产品说明严格对齐):
  658. * 1) 叶子节点:
  659. * - 自己的类型被图例隐藏(selectedLegend[type]=false)→ 整节点隐藏(opacity=0, invisible=true)
  660. * - 自己的类型显示 → 保持 hasData/颜色 原样,visible
  661. * 2) 非叶子节点(有 children):
  662. * a. 如果存在「可见且有数据」的子(不管子是哪一层,只要子树里存在)
  663. * → 父节点必须可见(否则子画不出来):
  664. * - 若自己的类型被图例隐藏 → 降级为「灰色节点、hasData=false」(名称保留,不显示真实得分率/颜色,不参与点击)
  665. * - 若自己的类型显示 → 保持原 hasData/颜色 原样
  666. * b. 如果不存在「可见且有数据」的子(全子树都被隐藏或都无数据)
  667. * → 父节点按自身规则走:
  668. * - 自己类型被隐藏 → 隐藏
  669. * - 自己类型显示 → 保持原 hasData/颜色 原样
  670. * 3) 返回的每一项新增字段(不修改原 props.tableData,做浅克隆):
  671. * __finalVisible : boolean 该节点最终是否可见
  672. * __finalHasData : boolean 是否显示为"有数据节点"(false=灰色圆点,无得分率)
  673. * __finalColor : string 最终绘制颜色
  674. * __scoreRate : number|null 得分率(若 __finalHasData=false 则不再用)
  675. * __rawHasData : boolean 原始数据是否有 scoreRate(用于 applySelectedSizeToChart 重算)
  676. */
  677. calcFilteredTree(data) {
  678. const vm = this;
  679. if (!Array.isArray(data)) return [];
  680. return data.map(item => {
  681. const node = { ...(item || {}) };
  682. const scoreRate = vm.classLevel == 2 || vm.mode == 'student' ? node.classScoreRate : node.gradeScoreRate;
  683. const rawHasData = scoreRate !== null && scoreRate !== undefined;
  684. node.__scoreRate = rawHasData ? Number(scoreRate) : null;
  685. node.__rawHasData = rawHasData;
  686. const selfLevel = vm.getNodeLevel(node.knowledgeType);
  687. const selfTypeHidden = vm.selectedLegend[selfLevel] === false;
  688. const hasChildren = Array.isArray(node.children) && node.children.length > 0;
  689. if (hasChildren) {
  690. // 先递归处理 children
  691. const subChildren = vm.calcFilteredTree(node.children);
  692. node.children = subChildren;
  693. // 统计:子树中存在多少"最终可见且最终 hasData=true"的节点(任意深度)
  694. let subHasVisibleHasData = false;
  695. const stack = [...subChildren];
  696. while (stack.length && !subHasVisibleHasData) {
  697. const n = stack.pop();
  698. if (n && n.__finalVisible && n.__finalHasData) {
  699. subHasVisibleHasData = true;
  700. break;
  701. }
  702. if (n && Array.isArray(n.children) && n.children.length > 0) {
  703. for (let i = 0; i < n.children.length; i++) stack.push(n.children[i]);
  704. }
  705. }
  706. if (subHasVisibleHasData) {
  707. // 父必须可见
  708. node.__finalVisible = true;
  709. if (selfTypeHidden) {
  710. // 降级为灰色节点(hasData=false)
  711. node.__finalHasData = false;
  712. node.__finalColor = '#BFC1C7';
  713. } else {
  714. node.__finalHasData = rawHasData;
  715. node.__finalColor = rawHasData ? vm.getNodeColor(node.knowledgeType) : '#BFC1C7';
  716. }
  717. } else {
  718. // 子树没有任何有效可见节点 → 父按自身规则
  719. if (selfTypeHidden) {
  720. node.__finalVisible = false;
  721. node.__finalHasData = rawHasData;
  722. node.__finalColor = '#BFC1C7';
  723. } else {
  724. node.__finalVisible = true;
  725. node.__finalHasData = rawHasData;
  726. node.__finalColor = rawHasData ? vm.getNodeColor(node.knowledgeType) : '#BFC1C7';
  727. }
  728. }
  729. } else {
  730. // 叶子节点
  731. if (selfTypeHidden) {
  732. node.__finalVisible = false;
  733. node.__finalHasData = rawHasData;
  734. node.__finalColor = '#BFC1C7';
  735. } else {
  736. node.__finalVisible = true;
  737. node.__finalHasData = rawHasData;
  738. node.__finalColor = rawHasData ? vm.getNodeColor(node.knowledgeType) : '#BFC1C7';
  739. }
  740. }
  741. return node;
  742. });
  743. },
  744. /**
  745. * 从过滤后的树形数据中,找到"最终可见且有数据"的一个叶子节点(用于默认选中)
  746. * - 优先在"第一棵子树的最后一个可见叶子"里找(和原 setDefaultSelection 风格一致)
  747. * - 如果整棵树都没可见有效点,返回 null
  748. * - 如果入参 excludeKnowledgeId 提供,则跳过该 knowledgeId(用于重选时跳过被隐藏的旧选中)
  749. */
  750. findFirstVisibleLeafNode(list, excludeKnowledgeId = null) {
  751. if (!Array.isArray(list) || list.length === 0) return null;
  752. // 遍历所有节点(深度优先),收集可见+hasData=true 的叶子
  753. const stack = [];
  754. // 从前往后 push,让最后一棵树最后处理;每次 pop 最后一个 children,以匹配"第一棵树最后一个叶子"
  755. for (let i = list.length - 1; i >= 0; i--) stack.push(list[i]);
  756. let best = null;
  757. while (stack.length) {
  758. const n = stack.pop();
  759. if (!n) continue;
  760. const isLeaf = !Array.isArray(n.children) || n.children.length === 0;
  761. const isValid = n.__finalVisible && n.__finalHasData &&
  762. (excludeKnowledgeId === null || String(n.knowledgeId) !== String(excludeKnowledgeId));
  763. if (isValid && isLeaf) {
  764. // 每找到一个就覆盖(因为从后入栈+pop,最后保留下的就是第一棵树最后一个叶子)
  765. best = n;
  766. }
  767. if (Array.isArray(n.children) && n.children.length > 0) {
  768. for (let i = n.children.length - 1; i >= 0; i--) stack.push(n.children[i]);
  769. }
  770. }
  771. return best;
  772. },
  773. /**
  774. * 统一生成 ECharts tree 结构 children
  775. * ——基于 calcFilteredTree 输出的节点(已经带 __final* 字段)
  776. * @param {Array} data calcFilteredTree 处理过的 tree(或子集 children)
  777. * @param {number} level 当前层级
  778. * @param {string|null} overrideKnowledgeId
  779. */
  780. generateTreeData(data, level = 0, overrideKnowledgeId = null) {
  781. const vm = this;
  782. const currentKnowledgeId = overrideKnowledgeId !== null ? overrideKnowledgeId : vm.selectedKnowledgeId;
  783. return (data || []).map(item => {
  784. const finalHasData = !!item.__finalHasData;
  785. const finalVisible = !!item.__finalVisible;
  786. const finalColor = item.__finalColor || '#BFC1C7';
  787. const finalScoreRate = item.__scoreRate;
  788. // 注意:节点被过滤(__finalVisible=false)时,即使 isMatched 也不显示放大——因为看不见
  789. const isMatched = finalHasData && finalVisible && (String(item.knowledgeId) === String(currentKnowledgeId));
  790. let symbolSize = 5;
  791. if (isMatched) {
  792. symbolSize = 25; // 选中:放大
  793. } else if (finalHasData) {
  794. symbolSize = 10; // 有效但未选中:默认
  795. }
  796. // 选中节点的绘制权重更高,防止被非选中圆点遮挡
  797. const drawZ = isMatched ? 99 : 10;
  798. const drawZLevel = isMatched ? 2 : 0;
  799. const node = {
  800. name: item.knowledgeName,
  801. symbolSize: symbolSize,
  802. value: finalScoreRate,
  803. itemStyle: {
  804. color: finalColor,
  805. opacity: finalVisible ? 1 : 0
  806. },
  807. lineStyle: {
  808. opacity: finalVisible ? 1 : 0
  809. },
  810. hasData: finalHasData, // 用于 tooltip 显示/点击忽略(灰色不能点)
  811. knowledgeId: item.knowledgeId,
  812. knowledgeType: item.knowledgeType,
  813. z: drawZ,
  814. zlevel: drawZLevel
  815. };
  816. if (item.children && item.children.length > 0) {
  817. node.children = vm.generateTreeData(item.children, level + 1, overrideKnowledgeId);
  818. }
  819. return node;
  820. });
  821. },
  822. /**
  823. * 统一入口:将当前选中的 knowledgeId 对应的 symbolSize 应用到 ECharts 图表上
  824. * ——基于过滤后的 filteredTree(保证图例过滤后重新算节点大小和选中状态)
  825. */
  826. applySelectedSizeToChart(overrideKnowledgeId) {
  827. if (!this.chart) return;
  828. const selectedId = overrideKnowledgeId !== undefined ? overrideKnowledgeId : this.selectedKnowledgeId;
  829. const filteredTree = this.calcFilteredTree(this.tableData);
  830. this.chart.setOption({
  831. series: [{
  832. data: [
  833. {
  834. name: this.subjectName,
  835. symbolSize: 5,
  836. value: this.subjectScoreRate,
  837. itemStyle: {
  838. color: '#BFC1C7',
  839. opacity: 1
  840. },
  841. hasData: false,
  842. children: this.generateTreeData(filteredTree, 0, selectedId)
  843. }
  844. ]
  845. }]
  846. }, {
  847. animation: true,
  848. animationDuration: 300
  849. });
  850. },
  851. /**
  852. * 从树形 list 中取"第一棵节点树的最后一个叶子节点"
  853. * 规则:取 list[0],沿 children[last] 一路下钻,直到没有 children
  854. */
  855. findFirstTreeLastLeafNode(list) {
  856. if (!Array.isArray(list) || list.length === 0) return null;
  857. let node = list[0];
  858. while (node && Array.isArray(node.children) && node.children.length > 0) {
  859. node = node.children[node.children.length - 1];
  860. }
  861. return node || null;
  862. },
  863. initChart() {
  864. try {
  865. if (!this.$refs.chart) {
  866. console.error('图表容器不存在');
  867. return;
  868. }
  869. this.createChart();
  870. }
  871. catch (error) {
  872. console.error('图表初始化失败:', error);
  873. }
  874. },
  875. createChart() {
  876. try {
  877. if (!this.$refs.chart) {
  878. console.error('图表容器不存在');
  879. return;
  880. }
  881. if (this.chart) {
  882. this.chart.dispose();
  883. }
  884. this.chart = echarts.init(this.$refs.chart);
  885. const vm = this;
  886. const countOutermostNodes = (data) => {
  887. let count = 0;
  888. const traverse = (nodes) => {
  889. nodes.forEach(node => {
  890. if (node.children && node.children.length > 0) {
  891. traverse(node.children);
  892. } else {
  893. count++;
  894. }
  895. });
  896. };
  897. traverse(data);
  898. return count;
  899. };
  900. const outermostCount = countOutermostNodes(this.tableData);
  901. const getOptimalZoom = () => {
  902. let zoom = 1.05;
  903. return zoom;
  904. };
  905. const getOptimalRadius = () => {
  906. if (outermostCount <= 5) {
  907. return ['20%', '65%'];
  908. }
  909. return ['12%', '75%'];
  910. };
  911. const option = {
  912. tooltip: {
  913. formatter: (params) => {
  914. if (params.treePathInfo && params.treePathInfo.length === 1) {
  915. return params.name;
  916. }
  917. const hasData = params.data && params.data.hasData;
  918. if (hasData) {
  919. return `${params.name}<br/>得分率: ${params.value !== null && params.value !== undefined ? params.value : '0'}%`;
  920. } else {
  921. return params.name;
  922. }
  923. }
  924. },
  925. animationDurationUpdate: 1500,
  926. animationEasingUpdate: 'quinticInOut',
  927. series: [
  928. {
  929. type: 'tree',
  930. layout: 'radial',
  931. symbol: 'circle',
  932. initialTreeDepth: 999,
  933. expandAndCollapse: false,
  934. orient: 'radial',
  935. roam: false,
  936. left: '8%',
  937. right: '8%',
  938. top: '8%',
  939. bottom: '8%',
  940. radius: getOptimalRadius(),
  941. nodeScaleRatio: 1,
  942. layerPadding: outermostCount <= 5 ? [20, 10] : [10, 5],
  943. data: [
  944. {
  945. name: this.subjectName,
  946. symbolSize: 5,
  947. value: this.subjectScoreRate,
  948. itemStyle: {
  949. color: '#BFC1C7'
  950. },
  951. hasData: false,
  952. children: this.generateTreeData(this.calcFilteredTree(this.tableData))
  953. }
  954. ],
  955. label: {
  956. show: false
  957. },
  958. lineStyle: {
  959. color: '#ECEEF3',
  960. width: 1,
  961. type: 'solid'
  962. },
  963. emphasis: {
  964. focus: 'adjacency',
  965. lineStyle: {
  966. width: 1
  967. }
  968. }
  969. }
  970. ]
  971. };
  972. this.chart.setOption(option);
  973. this.chart.resize();
  974. // 设置默认选中项
  975. this.$nextTick(() => {
  976. this.setDefaultSelection();
  977. });
  978. this.chart.on('click', (params) => {
  979. const dataItem = params && params.data;
  980. // 1. 灰色圆点(无数据 hasData=false)或学科中心节点:点击忽略,不做任何处理
  981. const isSubjectCenter = params.treePathInfo && params.treePathInfo.length === 1;
  982. if (isSubjectCenter || !dataItem || !dataItem.hasData) {
  983. return;
  984. }
  985. // 2. 从点击节点本身的 knowledgeId 去查(点击 params.data.knowledgeId 与 原始tableData 匹配)
  986. const clickedKnowledgeId = dataItem.knowledgeId;
  987. const allLists = [this.allKnowledgeList, this.highVulnerability, this.fatalVulnerability];
  988. let knowledgeItem = null;
  989. let targetIndex = -1;
  990. // 先从扁平化列表里找(用于回传 index 给父级)
  991. if (clickedKnowledgeId !== undefined && clickedKnowledgeId !== null && clickedKnowledgeId !== '') {
  992. for (const list of allLists) {
  993. if (!Array.isArray(list)) continue;
  994. targetIndex = list.findIndex(item => String(item.knowledgeId) === String(clickedKnowledgeId));
  995. if (targetIndex !== -1) {
  996. knowledgeItem = list[targetIndex];
  997. break;
  998. }
  999. }
  1000. }
  1001. // 扁平化找不到,就从 tableData 递归查找,按 name 兜底
  1002. if (!knowledgeItem && params.name) {
  1003. const findByNodeName = (arr) => {
  1004. for (const item of arr) {
  1005. if (item.knowledgeName === params.name) return item;
  1006. if (Array.isArray(item.children) && item.children.length) {
  1007. const f = findByNodeName(item.children);
  1008. if (f) return f;
  1009. }
  1010. }
  1011. return null;
  1012. };
  1013. knowledgeItem = findByNodeName(this.tableData);
  1014. // 再回填一次 index
  1015. if (knowledgeItem && knowledgeItem.knowledgeId !== undefined) {
  1016. for (const list of allLists) {
  1017. if (!Array.isArray(list)) continue;
  1018. targetIndex = list.findIndex(item =>
  1019. String(item.knowledgeId) === String(knowledgeItem.knowledgeId)
  1020. );
  1021. if (targetIndex !== -1) break;
  1022. }
  1023. }
  1024. }
  1025. // 3. 如果还是找不到知识点项,按点击的 data 做最小赋值(至少保证选中ID、类型对得上,有放大效果)
  1026. if (!knowledgeItem) {
  1027. knowledgeItem = {
  1028. knowledgeId: clickedKnowledgeId ?? params.name,
  1029. knowledgeName: params.name,
  1030. knowledgeType: dataItem.knowledgeType || 3
  1031. };
  1032. }
  1033. // 4. 回传事件:保持向后兼容 {item, index},同时把核心字段打平在 payload 顶层
  1034. // ——父级联动(handleKnowledgeItemClick)会按顶层 knowledgeId 写入 currentLinkedKnowledgeId
  1035. // ——外层其他监听者也可继续用 item / index
  1036. this.$emit('knowledge-item-click', {
  1037. item: knowledgeItem,
  1038. index: targetIndex,
  1039. knowledgeId: knowledgeItem.knowledgeId,
  1040. knowledgeName: knowledgeItem.knowledgeName,
  1041. personalScoreRate: knowledgeItem.personalScoreRate != null
  1042. ? knowledgeItem.personalScoreRate
  1043. : knowledgeItem.scoreRate,
  1044. knowledgeType: knowledgeItem.knowledgeType,
  1045. examList: Array.isArray(knowledgeItem.examList) ? knowledgeItem.examList : []
  1046. });
  1047. // 5. 更新内部状态
  1048. if (targetIndex !== -1) this.selectedIndex = targetIndex;
  1049. this.selectedKnowledgeId = knowledgeItem.knowledgeId;
  1050. this.selectedKnowledgeType = knowledgeItem.knowledgeType || 3;
  1051. // 6. 折线图颜色同步
  1052. if (this.lineChart) {
  1053. this.updateLineChart();
  1054. }
  1055. // 7. 统一更新圆点大小:当前点放大(25),上一个选中点自动恢复为10
  1056. this.applySelectedSizeToChart(this.selectedKnowledgeId);
  1057. });
  1058. window.addEventListener('resize', () => {
  1059. if (this.chart) {
  1060. this.chart.resize();
  1061. }
  1062. });
  1063. }
  1064. catch (error) {
  1065. console.error('图表创建失败:', error);
  1066. }
  1067. },
  1068. updateChart() {
  1069. try {
  1070. const LOG_TAG = '%c[Diffusion.updateChart]';
  1071. const LOG_STYLE = 'background:#2E64FA;color:#fff;padding:2px 8px;border-radius:4px;font-weight:500;';
  1072. const callerStack = (new Error()).stack
  1073. ?.split('\n')
  1074. ?.slice(1, 5)
  1075. ?.map(s => s.trim())
  1076. ?.join(' | ') || '';
  1077. if (!this.$refs.chart) {
  1078. console.warn(LOG_TAG + ' 提前跳过:$refs.chart 不存在(模板 ref 未挂载 / 容器尚未渲染)', LOG_STYLE);
  1079. return;
  1080. }
  1081. const t0 = performance.now();
  1082. const oldChartExist = !!this.chart;
  1083. this.createChart();
  1084. const t1 = performance.now();
  1085. }
  1086. catch (error) {
  1087. console.error('[Diffusion.updateChart] 图表更新失败:', error);
  1088. }
  1089. },
  1090. reset() {
  1091. this.selectedIndex = 0;
  1092. this.updateChart();
  1093. },
  1094. toggleLegend(type) {
  1095. const prevSelected = this.selectedLegend[type];
  1096. this.selectedLegend[type] = !prevSelected;
  1097. const typeMap = {
  1098. weak: 1,
  1099. good: 2,
  1100. excellent: 3
  1101. };
  1102. const number = typeMap[type];
  1103. if (!this.selectedLegend[type]) {
  1104. const index = this.legendNumbers.indexOf(number);
  1105. if (index > -1) {
  1106. this.legendNumbers.splice(index, 1);
  1107. }
  1108. } else {
  1109. if (!this.legendNumbers.includes(number)) {
  1110. this.legendNumbers.push(number);
  1111. }
  1112. this.legendNumbers.sort((a, b) => a - b);
  1113. }
  1114. const hasAllNumbers = this.legendNumbers.includes(1) && this.legendNumbers.includes(2) && this.legendNumbers.includes(3);
  1115. const dataToSend = hasAllNumbers ? null : this.legendNumbers;
  1116. this.$emit('legend-numbers-change', dataToSend);
  1117. // ---- 图例变化后:重算过滤 → 重绘扩散图 → 若选中被隐藏则重选可见节点 ----
  1118. this.$nextTick(() => {
  1119. // 1) 全量重绘(内部 createChart 已经是 calcFilteredTree(tableData) 再生成 children)
  1120. if (this.chart) {
  1121. this.updateChart();
  1122. }
  1123. // 2) 判断当前选中的节点在新的图例规则下是否"最终可见且有数据"
  1124. const filteredTree = this.calcFilteredTree(this.tableData);
  1125. let matchNode = null;
  1126. const findStack = [...filteredTree];
  1127. while (findStack.length && !matchNode) {
  1128. const n = findStack.pop();
  1129. if (n && this.selectedKnowledgeId &&
  1130. String(n.knowledgeId) === String(this.selectedKnowledgeId)) {
  1131. matchNode = n;
  1132. break;
  1133. }
  1134. if (n && Array.isArray(n.children) && n.children.length > 0) {
  1135. for (let i = 0; i < n.children.length; i++) findStack.push(n.children[i]);
  1136. }
  1137. }
  1138. const stillValid = matchNode && matchNode.__finalVisible && matchNode.__finalHasData;
  1139. if (!stillValid) {
  1140. // 当前选中的点被隐藏/降级 → 重选一个可见有效叶子(排除当前,避免又选回来)
  1141. const replacement = this.findFirstVisibleLeafNode(filteredTree, this.selectedKnowledgeId);
  1142. if (replacement) {
  1143. this.selectedKnowledgeId = replacement.knowledgeId;
  1144. this.selectedKnowledgeType = replacement.knowledgeType || 3;
  1145. // 重新应用圆点放大
  1146. if (this.chart) this.applySelectedSizeToChart(replacement.knowledgeId);
  1147. // 同步折线图/表格(computed 依赖 selectedKnowledgeId → selectedExamList 会变,折线这里手动触发)
  1148. if (this.lineChart) this.updateLineChart();
  1149. this.$emit('knowledge-item-click', {
  1150. knowledgeId: replacement.knowledgeId,
  1151. knowledgeName: replacement.knowledgeName
  1152. });
  1153. } else {
  1154. // 全部关闭了 → 清空
  1155. this.selectedKnowledgeId = '';
  1156. this.selectedKnowledgeType = 3;
  1157. if (this.chart) this.applySelectedSizeToChart('');
  1158. if (this.lineChart) this.updateLineChart();
  1159. }
  1160. } else {
  1161. // 当前选中仍然有效 → 仅重新"应用选中大小"(因为 createChart 后放大状态没保留)
  1162. if (this.chart) this.applySelectedSizeToChart(this.selectedKnowledgeId);
  1163. }
  1164. });
  1165. },
  1166. }
  1167. };
  1168. </script>
  1169. <style lang="scss" scoped>
  1170. .circularDiffusion {
  1171. position: relative;
  1172. }
  1173. .circularDiffusion,
  1174. .knowledge_graph_student {
  1175. .knowledge_class {
  1176. :deep(.vxe-body--row.selected-row),
  1177. :deep(.selected-row) {
  1178. background-color: rgba(46, 100, 250, 0.1) !important;
  1179. color: #2E64FA !important;
  1180. * {
  1181. color: #2E64FA !important;
  1182. }
  1183. .rate_dot {
  1184. border-color: rgba(46, 100, 250, 0.1) !important;
  1185. }
  1186. }
  1187. .graph_header {
  1188. margin-bottom: 20px;
  1189. position: relative;
  1190. display: flex;
  1191. justify-content: space-between;
  1192. align-items: center;
  1193. .legend {
  1194. display: flex;
  1195. gap: 20px;
  1196. .legend_item {
  1197. display: flex;
  1198. align-items: center;
  1199. gap: 5px;
  1200. cursor: pointer;
  1201. font-weight: 500;
  1202. &.selected {
  1203. opacity: 1;
  1204. }
  1205. &:hover {
  1206. opacity: 1;
  1207. }
  1208. .legend_dot {
  1209. width: 20px;
  1210. height: 10px;
  1211. border-radius: 2px;
  1212. transition: all 0.3s ease;
  1213. &.weak {
  1214. background: #ccc;
  1215. }
  1216. &.good {
  1217. background: #cccccc;
  1218. }
  1219. &.excellent {
  1220. background: #ccc;
  1221. }
  1222. }
  1223. .legend_text {
  1224. font-size: 12px;
  1225. color: #999999;
  1226. transition: all 0.3s ease;
  1227. }
  1228. &.selected {
  1229. .legend_dot.weak {
  1230. background: #EE6666;
  1231. }
  1232. .legend_dot.good {
  1233. background: #FAC858;
  1234. }
  1235. .legend_dot.excellent {
  1236. background: #3BA272;
  1237. }
  1238. .legend_text {
  1239. color: #333;
  1240. }
  1241. }
  1242. }
  1243. }
  1244. }
  1245. .graph_content {
  1246. display: flex;
  1247. gap: 20px;
  1248. position: relative;
  1249. .knowledge_right_container {
  1250. width: 500px;
  1251. display: flex;
  1252. flex-direction: column;
  1253. gap: 10px;
  1254. .line_chart_container {
  1255. border: 1px solid #DCDFE6;
  1256. border-radius: 6px;
  1257. position: relative;
  1258. display: flex;
  1259. flex-direction: column;
  1260. /* 标题栏:左右分布,放在折线图上方 */
  1261. .chart_title {
  1262. display: flex;
  1263. justify-content: space-between;
  1264. align-items: center;
  1265. gap: 12px;
  1266. padding: 10px 16px;
  1267. border-bottom: 1px solid #DCDFE6;
  1268. /* 左侧:上下结构(标题 + 考频) */
  1269. .chart_title_left {
  1270. flex: 1;
  1271. min-width: 0;
  1272. display: flex;
  1273. flex-direction: column;
  1274. justify-content: center;
  1275. gap: 4px;
  1276. }
  1277. .title_text {
  1278. font-weight: 500;
  1279. font-size: 14px;
  1280. color: #333333;
  1281. overflow: hidden;
  1282. text-overflow: ellipsis;
  1283. white-space: nowrap;
  1284. line-height: 1.2;
  1285. }
  1286. .exam_freq {
  1287. font-weight: 400;
  1288. font-size: 12px;
  1289. color: #999999;
  1290. line-height: 1.2;
  1291. }
  1292. /* 加入练习按钮:使用 Element UI el-button,描边风格跟随当前选中圆点的 knowledgeType 颜色 */
  1293. .add_practice_btn {
  1294. border-radius: 4px;
  1295. font-weight: 500;
  1296. font-size: 14px;
  1297. background: #fff !important;
  1298. flex-shrink: 0;
  1299. padding: 0;
  1300. width: 100px;
  1301. height: 36px;
  1302. line-height: 36px;
  1303. text-align: center;
  1304. /* 薄弱:knowledgeType=1 → 与选中圆点的 #EE6666 颜色一致 */
  1305. &.btn_theme_weak {
  1306. border: 1px solid #EE6666 !important;
  1307. color: #EE6666 !important;
  1308. &:hover, &:focus {
  1309. border: 1px solid #EE6666 !important;
  1310. color: #EE6666 !important;
  1311. background: #fff !important;
  1312. }
  1313. }
  1314. /* 一般/能力提升:knowledgeType=2 → #FAC858 */
  1315. &.btn_theme_good {
  1316. border: 1px solid #FAC858 !important;
  1317. color: #FAC858 !important;
  1318. &:hover, &:focus {
  1319. border: 1px solid #FAC858 !important;
  1320. color: #FAC858 !important;
  1321. background: #fff !important;
  1322. }
  1323. }
  1324. /* 优秀/扎实巩固:knowledgeType=3 → #3BA272 */
  1325. &.btn_theme_excellent {
  1326. border: 1px solid #3BA272 !important;
  1327. color: #3BA272 !important;
  1328. &:hover, &:focus {
  1329. border: 1px solid #3BA272 !important;
  1330. color: #3BA272 !important;
  1331. background: #fff !important;
  1332. }
  1333. }
  1334. /* isJoin=1 的置灰状态:disabled 时边框和文字变浅灰,背景淡灰(优先级最高,覆盖主题色) */
  1335. &.is-disabled,
  1336. &[disabled] {
  1337. border: 1px solid #DCDFE6 !important;
  1338. color: #C0C4CC !important;
  1339. background: #F5F7FA !important;
  1340. cursor: not-allowed !important;
  1341. &:hover,
  1342. &:focus {
  1343. border: 1px solid #DCDFE6 !important;
  1344. color: #C0C4CC !important;
  1345. background: #F5F7FA !important;
  1346. }
  1347. }
  1348. }
  1349. }
  1350. .line_chart {
  1351. width: calc(100% - 30px);
  1352. height: 180px;
  1353. padding: 15px;
  1354. }
  1355. }
  1356. .table_container {
  1357. /* 按钮组 tabs:按设计图样式 */
  1358. .table_tabs_radio {
  1359. margin-bottom: 10px;
  1360. display: inline-flex;
  1361. align-items: center;
  1362. line-height: 0;
  1363. border-collapse: collapse;
  1364. :deep(.el-radio-button) {
  1365. /* 让选中的按钮在视觉上压过相邻按钮的边框 */
  1366. &:first-child .el-radio-button__inner {
  1367. border-radius: 4px 0 0 4px;
  1368. }
  1369. &:last-child .el-radio-button__inner {
  1370. border-radius: 0 4px 4px 0;
  1371. }
  1372. }
  1373. :deep(.el-radio-button__inner) {
  1374. height: 36px;
  1375. line-height: 36px;
  1376. padding: 0 20px;
  1377. font-size: 13px;
  1378. color: #909399; /* 未选中:灰字 */
  1379. background: #FFFFFF;
  1380. border: 1px solid #DCDFE6; /* 未选中:灰边框 */
  1381. box-sizing: border-box;
  1382. margin: 0;
  1383. box-shadow: none !important;
  1384. transition: none;
  1385. position: relative;
  1386. border-radius: 0;
  1387. }
  1388. /* 相邻按钮的重叠(Element 默认 margin-left:-1px,这里保持默认通过 border-color 变化即可,显式指定分隔线颜色) */
  1389. :deep(.el-radio-button + .el-radio-button) {
  1390. margin-left: -1px;
  1391. }
  1392. /* 选中态:蓝边框 + 蓝字 + 白底(完全匹配图片) */
  1393. :deep(.el-radio-button__orig-radio:checked + .el-radio-button__inner) {
  1394. color: #2E64FA;
  1395. background-color: #FFFFFF;
  1396. border-color: #2E64FA;
  1397. box-shadow: none !important;
  1398. z-index: 2; /* 把选中按钮抬高,其蓝色边框盖住相邻按钮的灰色边框 */
  1399. }
  1400. /* 未选中 hover:保持灰字灰边框,不变成蓝色 */
  1401. :deep(.el-radio-button__orig-radio:not(:checked) + .el-radio-button__inner:hover) {
  1402. color: #909399;
  1403. border-color: #DCDFE6;
  1404. background-color: #FFFFFF;
  1405. }
  1406. :deep(.el-radio-button__inner:hover) {
  1407. color: #909399;
  1408. }
  1409. }
  1410. .data_table {
  1411. height: 330px;
  1412. border-radius: 6px;
  1413. /* 整表统一 cell 高度为 40px(Element UI 是通过 .cell 控制) */
  1414. :deep(.el-table__header-wrapper th .cell),
  1415. :deep(.el-table__body-wrapper td .cell) {
  1416. height: 40px;
  1417. line-height: 40px;
  1418. padding-top: 0;
  1419. padding-bottom: 0;
  1420. box-sizing: border-box;
  1421. }
  1422. /* 表头样式 */
  1423. :deep(.el-table__header th) {
  1424. background: #F5F7FA;
  1425. color: #333333;
  1426. font-weight: 500;
  1427. font-size: 14px;
  1428. padding:0px !important;
  1429. }
  1430. /* 斑马纹:开启 stripe 后,Element UI 会给偶数行加 el-table__row--striped */
  1431. :deep(.el-table__body tr.el-table__row--striped td) {
  1432. background: #FAFBFC;
  1433. }
  1434. :deep(.el-table__body tr.el-table__row--striped td.el-table__cell) {
  1435. background: #FAFBFC;
  1436. }
  1437. /* 表体样式 */
  1438. :deep(.el-table__body td) {
  1439. font-weight: 400;
  1440. font-size: 14px;
  1441. color: #666666;
  1442. padding:0px !important;
  1443. }
  1444. /* hover 行时不改变斑马纹底色(仅轻微提亮边框感知) */
  1445. :deep(.el-table__body tr:hover > td) {
  1446. background-color: #EFF6FF;
  1447. }
  1448. }
  1449. }
  1450. }
  1451. .item_chart_container {
  1452. border: 1px solid #E9EBEF;
  1453. border-radius: 10px;
  1454. margin-bottom: 10px;
  1455. }
  1456. .chart_container {
  1457. flex: 1;
  1458. min-width: 0;
  1459. border-radius: 10px;
  1460. border: 1px solid #E9EBEF;
  1461. position: relative;
  1462. padding: 20px;
  1463. overflow: hidden;
  1464. .top_container {
  1465. display: flex;
  1466. justify-content: space-between;
  1467. align-items: flex-start;
  1468. flex-wrap: wrap;
  1469. gap: 10px;
  1470. margin-bottom: 10px;
  1471. }
  1472. .legend {
  1473. display: flex;
  1474. gap: 20px;
  1475. flex-wrap: wrap;
  1476. flex: 1;
  1477. min-width: 0;
  1478. .legend_item {
  1479. display: flex;
  1480. align-items: center;
  1481. gap: 5px;
  1482. cursor: pointer;
  1483. font-weight: 500;
  1484. &.selected {
  1485. opacity: 1;
  1486. }
  1487. &:hover {
  1488. opacity: 1;
  1489. }
  1490. .legend_dot {
  1491. width: 20px;
  1492. height: 10px;
  1493. border-radius: 2px;
  1494. transition: all 0.3s ease;
  1495. &.weak {
  1496. background: #ccc;
  1497. }
  1498. &.good {
  1499. background: #ccc;
  1500. }
  1501. &.excellent {
  1502. background: #ccc;
  1503. }
  1504. }
  1505. .legend_text {
  1506. font-size: 12px;
  1507. color: #999999;
  1508. transition: all 0.3s ease;
  1509. }
  1510. &.selected {
  1511. .legend_dot.weak {
  1512. background: #EE6666;
  1513. }
  1514. .legend_dot.good {
  1515. background: #FAC858;
  1516. }
  1517. .legend_dot.excellent {
  1518. background: #3BA272;
  1519. }
  1520. .legend_text {
  1521. color: #333;
  1522. }
  1523. }
  1524. }
  1525. }
  1526. .chart {
  1527. display: flex;
  1528. justify-content: center;
  1529. align-items: center;
  1530. min-height: 590px;
  1531. }
  1532. }
  1533. .knowledge_list {
  1534. width: 100%;
  1535. height: 410px;
  1536. overflow-y: auto;
  1537. padding-right: 10px;
  1538. position: relative;
  1539. &::-webkit-scrollbar {
  1540. width: 6px;
  1541. }
  1542. &::-webkit-scrollbar-track {
  1543. background: #f1f1f1;
  1544. border-radius: 3px;
  1545. }
  1546. &::-webkit-scrollbar-thumb {
  1547. background: #c1c1c1;
  1548. border-radius: 3px;
  1549. }
  1550. &::-webkit-scrollbar-thumb:hover {
  1551. background: #a8a8a8;
  1552. }
  1553. }
  1554. }
  1555. }
  1556. }
  1557. </style>