circularDiffusion.vue 77 KB

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