circularDiffusion.vue 72 KB

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