circularDiffusion.vue 69 KB

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