| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064 |
- <template>
- <div class="circularDiffusion">
- <!-- 教师端样式 -->
- <div class="knowledge_class">
- <!-- 图形查看容器 -->
- <div class="graph_content" v-if="activeView === 'graph'">
- <!-- 左侧图表 -->
- <div class="chart_container">
- <!-- 顶部容器:图例和视图切换按钮 -->
- <div class="top_container">
- <!-- 自定义图例 -->
- <div class="legend">
- <div class="legend_item" :class="{ 'selected': selectedLegend.weak }" @click="toggleLegend('weak')">
- <span class="legend_dot weak"></span>
- <span class="legend_text">{{ '薄弱(0%≤得分率<60%)' }}</span>
- </div>
- <div class="legend_item" :class="{ 'selected': selectedLegend.good }" @click="toggleLegend('good')">
- <span class="legend_dot good"></span>
- <span class="legend_text">
- {{'良好(60%≤得分率< 85 %) ' }}</span>
- </div>
- <div class="legend_item" :class="{ ' selected': selectedLegend.excellent }"
- @click="toggleLegend('excellent')">
- <span class="legend_dot excellent"></span>
- <span class="legend_text">{{'优秀(85%≤得分率)'}}</span>
- </div>
- </div>
- <!-- 视图切换按钮 -->
- <div class="view_switcher_container" v-if="activeView === 'graph'" :class="activeView">
- <el-button type="graph" @click="switchView('graph')" class="switch_btn graph_btn">
- <i class="icon_switch_graph">
- <img src="../../../../assets/studentAnalysis/circleBlue.svg" alt="按图形查看">
- </i>
- 按图形查看
- </el-button>
- <el-button type="text" class="switch_btn list_btn" @click="switchView('list')">
- <i class="icon_switch_list"><img src="../../../../assets/studentAnalysis/iconGrey.svg" alt="按列表查看"></i>
- 按列表查看
- </el-button>
- </div>
- </div>
- <div ref="chart" class="chart" style="width: 100%;"></div>
- </div>
- <!-- 右侧容器 -->
- <div class="knowledge_right_container">
- <!-- 折线图 -->
- <div class="line_chart_container">
- <!-- 标题栏(放到折线图上方):左右布局,左侧上下结构 -->
- <div class="chart_title" v-if="selectedKnowledgeName">
- <div class="chart_title_left">
- <span class="title_text" :title="selectedKnowledgeName">{{ selectedKnowledgeName }}</span>
- <span class="exam_freq">考频:{{ filteredTableData.length }}</span>
- </div>
- <el-button
- class="add_practice_btn"
- :class="addPracticeBtnThemeClass"
- :disabled="selectedIsJoin"
- @click="handleAddPractice">
- {{ selectedIsJoin ? '已加入练习' : '加入练习' }}
- </el-button>
- </div>
- <div ref="lineChart" class="line_chart"></div>
- </div>
- <!-- Tab切换和表格 -->
- <div class="table_container">
- <el-radio-group v-model="activeTableTab" class="table_tabs_radio">
- <el-radio-button label="all">全部</el-radio-button>
- <el-radio-button label="exam">考试</el-radio-button>
- <el-radio-button label="practice">练习</el-radio-button>
- </el-radio-group>
- <el-table :data="filteredTableData" border stripe class="data_table">
- <el-table-column prop="index" label="序号" width="60" align="center">
- <template slot-scope="scope">
- {{ scope.$index + 1 }}
- </template>
- </el-table-column>
- <el-table-column prop="examName" label="考试名称" show-overflow-tooltip align="center"></el-table-column>
- <el-table-column prop="examType" label="类型" width="100" align="center"></el-table-column>
- <el-table-column prop="scoreRate" label="得分率" width="100" align="center">
- <template slot-scope="scope">
- <span :style="{ color: getCurrentColor() }">{{ scope.row.scoreRate }}%</span>
- </template>
- </el-table-column>
- </el-table>
- </div>
- </div>
- </div>
- <!-- 列表视图头部(图例 + 视图切换按钮) -->
- <div class="graph_header" v-if="activeView === 'list'">
- <!-- 自定义图例 -->
- <div class="legend">
- <div class="legend_item" :class="{ 'selected': selectedLegend.weak }" @click="toggleLegend('weak')">
- <span class="legend_dot weak" :style="selectedLegend.weak ? {} : { backgroundColor: '#ccc' }"></span>
- <span class="legend_text" :style="selectedLegend.weak ? {} : { color: '#999999' }">{{ '薄弱(0%≤得分率<60%)'}}</span>
- </div>
- <div class="legend_item" :class="{ 'selected': selectedLegend.good }" @click="toggleLegend('good')">
- <span class="legend_dot good" :style="selectedLegend.good ? {} : { backgroundColor: '#ccc' }"></span>
- <span class="legend_text" :style="selectedLegend.good ? {} : { color: '#999999' }">{{ '良好(60%≤得分率<85%)'}}</span>
- </div>
- <div class="legend_item" :class="{ 'selected': selectedLegend.excellent }" @click="toggleLegend('excellent')">
- <span class="legend_dot excellent"
- :style="selectedLegend.excellent ? {} : { backgroundColor: '#ccc' }"></span>
- <span class="legend_text" :style="selectedLegend.excellent ? {} : { color: '#999999' }">{{ '优秀(85%≤得分率)'}}</span>
- </div>
- </div>
- <!-- 视图切换按钮 -->
- <div class="view_switcher_container">
- <el-button type="text" @click="switchView('graph')" class="switch_btn list_btn">
- <i class="icon_switch_graph">
- <img src="../../../../assets/studentAnalysis/circleGrey.svg" alt="按图形查看">
- </i>
- 按图形查看
- </el-button>
- <el-button type="graph" class="switch_btn graph_btn" @click="switchView('list')">
- <i class="icon_switch_list">
- <img src="../../../../assets/studentAnalysis/iconBlue.svg" alt="按列表查看">
- </i>
- 按列表查看
- </el-button>
- </div>
- </div>
- <!-- 列表查看容器 -->
- <div v-if="activeView === 'list'" class="list_content">
- <vxe-table ref="treeTable" :data="filteredTableDataForList" style="width: 100%;" maxHeight="480px" border show-header
- :tree-config="{ childrenField: 'children', hasChildField: 'hasChildren', showIcon: true, iconOpen: 'el-icon-remove', iconClose: 'el-icon-circle-plus' }"
- :scroll-y="{ enabled: true, gt: 480 }" fixed-header :header-cell-style="{ color: '#333' }"
- @row-click="handleRowClick" @cell-click="handleCellClick" :row-class-name="rowClassName">
- <vxe-table-column type="tree" tree-node prop="title" :title="subjectName">
- <template #default="{ row }">
- <div class="knowledge_item">
- <span class="knowledge_title">{{ row.knowledgeName }}</span>
- </div>
- </template>
- </vxe-table-column>
- <!-- 个人得分率 -->
- <vxe-table-column prop="personalScoreRate" title="个人得分率" width="180" align="center">
- <template #default="{ row }">
- <div class="rate_info" v-if="row.personalScoreRate !== null">
- <span class="rate_dot" :class="getRateClass(row.personalScoreRate)"></span>
- <span class="rate_value">{{ row.personalScoreRate }}%</span>
- </div>
- </template>
- </vxe-table-column>
- <!-- 个人知识点状态 -->
- <vxe-table-column prop="personalScoreRate" title="个人知识点状态" width="120" align="center">
- <template #default="{ row }">
- <div class="status_info" v-if="row.personalScoreRate !== null">
- <span class="status_value"
- :style="{ color: getRateName(row.personalScoreRate) === '优秀' ? '#3BA272' : getRateName(row.personalScoreRate) === '良好' ? '#FAC858' : '#EE6666' }">
- {{ getRateName(row.personalScoreRate) }}
- </span>
- </div>
- </template>
- </vxe-table-column>
- </vxe-table>
- </div>
- </div>
- </div>
- </template>
- <script>
- // 引入echarts
- import * as echarts from 'echarts';
- export default {
- name: 'CircularDiffusion',
- props: {
- studentName: {
- type: String,
- default: ''
- },
- mode: {
- type: String,
- default: 'class',
- validator: (value) => {
- return ['class', 'student'].includes(value);
- }
- },
- subjectName: {
- type: String,
- default: '知识点'
- },
- subjectScoreRate: {
- type: Number,
- default: 0
- },
- tableData: {
- type: Array,
- default: () => []
- },
- fatalVulnerability: {
- type: Array,
- default: () => []
- },
- highVulnerability: {
- type: Array,
- default: () => []
- },
- allKnowledgeList: {
- type: Array,
- default: () => []
- },
- isRecordKnowledge: {
- type: Boolean,
- default: false
- },
- studentKnowledgeLayering: {
- type: String,
- default: ''
- },
- classLevel: {
- type: [String, Number],
- default: ''
- },
- classGroupName: {
- type: String,
- default: ''
- },
- historicalChangeData: {
- type: Object,
- default: () => ({
- examNames: [],
- scores: [],
- trendData: [],
- personalScoreRate: []
- })
- },
- historicalChangeLoading: {
- type: Boolean,
- default: false
- },
- currentKnowledgeId: {
- type: [String, Number],
- default: ''
- },
- scoreRateTypes: {
- type: Array,
- default: () => []
- },
- comparisonOptions: {
- type: Array,
- default: null
- },
- isPush: {
- type: Boolean,
- default: false
- },
- // 父级已成功"加入练习"的知识点ID映射:{ [knowledgeId]: true }
- addedKnowledgeIds: {
- type: Object,
- default: () => ({})
- },
- },
- computed: {
- // 从扁平 allKnowledgeList(或tableData递归兜底)获取当前选中节点的完整对象
- currentKnowledgeItem() {
- if (!this.selectedKnowledgeId) return null;
- const sid = String(this.selectedKnowledgeId);
- if (Array.isArray(this.allKnowledgeList) && this.allKnowledgeList.length) {
- const hit = this.allKnowledgeList.find(item => String(item.knowledgeId) === sid);
- if (hit) return hit;
- }
- // 再从树形 tableData 递归找一次兜底(扁平列表可能为空但树形有数据)
- const findInTree = (arr) => {
- for (const item of arr) {
- if (String(item.knowledgeId) === sid) return item;
- if (Array.isArray(item.children) && item.children.length) {
- const found = findInTree(item.children);
- if (found) return found;
- }
- }
- return null;
- };
- return findInTree(this.tableData || []);
- },
- // 选中的知识点名称
- selectedKnowledgeName() {
- const item = this.currentKnowledgeItem;
- return item ? (item.knowledgeName ?? '') : '';
- },
- // 当前选中知识点是否"已加入练习"
- // 优先级:1) 父级 addedKnowledgeIds(当前会话刚点按钮成功,立即生效)→ 2) 节点自身 isJoin===1(接口返回的静态字段)
- selectedIsJoin() {
- if (this.selectedKnowledgeId
- && this.addedKnowledgeIds
- && Object.prototype.hasOwnProperty.call(this.addedKnowledgeIds, String(this.selectedKnowledgeId))) {
- return true;
- }
- const item = this.currentKnowledgeItem;
- return item ? (Number(item.isJoin) === 1) : false;
- },
- // 加入练习按钮主题 class(按当前选中圆点的类型匹配颜色)
- // 动态从 currentKnowledgeItem 取 knowledgeType,不依赖 data 初始值
- // knowledgeType=3 优秀/扎实巩固 → #3BA272
- // knowledgeType=2 一般/能力提升 → #FAC858
- // knowledgeType=1 薄弱 → #EE6666
- addPracticeBtnThemeClass() {
- const item = this.currentKnowledgeItem;
- const t = Number(item ? item.knowledgeType : this.selectedKnowledgeType);
- if (t === 3) return 'btn_theme_excellent';
- if (t === 2) return 'btn_theme_good';
- return 'btn_theme_weak';
- },
- // 选中知识点的考频
- selectedExamFreq() {
- const item = this.currentKnowledgeItem;
- if (item) {
- // 兼容常见考频字段名
- return item.examFrequency ?? item.examFreq ?? item.freq ?? item.frequency ?? item.count ?? '--';
- }
- return '--';
- },
- // 当前选中节点的考试/练习明细数组(来自后端 examList 字段)
- // 每个元素结构:{ dataName, dataType, dataRate }
- selectedExamList() {
- const item = this.currentKnowledgeItem;
- if (item && Array.isArray(item.examList)) {
- return item.examList.filter(e => e); // 过滤空项
- }
- return [];
- },
- // 筛选表格数据:全部 / 考试(dataType=1) / 练习(dataType=2)
- // 并将 examList 字段映射为表格列需要的字段:
- // dataName -> examName, dataType -> examType/type, dataRate -> scoreRate
- filteredTableData() {
- const list = (this.selectedExamList || []).map(item => {
- const dataTypeNumber = Number(item.dataType);
- const examTypeLabel = dataTypeNumber === 1 ? '考试' : (dataTypeNumber === 2 ? '练习' : '');
- const typeKey = dataTypeNumber === 1 ? 'exam' : 'practice';
- return {
- examName: item.dataName ?? '',
- examType: item.examType ?? examTypeLabel,
- scoreRate: item.dataRate ?? 0,
- type: typeKey,
- dataType: dataTypeNumber,
- dataRate: item.dataRate
- };
- });
- if (this.activeTableTab === 'all') {
- return list;
- }
- if (this.activeTableTab === 'exam') {
- return list.filter(row => row.dataType === 1 || row.type === 'exam');
- }
- return list.filter(row => row.dataType === 2 || row.type === 'practice');
- },
- // 列表视图:根据图例选中状态过滤树形表格数据
- filteredTableDataForList() {
- if (!Array.isArray(this.tableData)) return [];
- // 递归过滤:保留父节点(即使自身类型被隐藏,只要子树有可见节点就保留)
- const filterNodes = (nodes) => {
- if (!Array.isArray(nodes)) return [];
- return nodes.map(item => {
- const node = { ...item };
- const rate = parseFloat(node.personalScoreRate);
- let level = 'weak';
- if (!isNaN(rate)) {
- if (rate >= 85) level = 'excellent';
- else if (rate >= 60) level = 'good';
- else level = 'weak';
- }
- // 递归处理子节点
- if (Array.isArray(node.children) && node.children.length > 0) {
- node.children = filterNodes(node.children);
- }
- // 标记是否应该隐藏
- node._legendHidden = !this.selectedLegend[level];
- return node;
- });
- };
- const filtered = filterNodes(this.tableData);
- // 第二遍:移除"自身被隐藏且没有可见子节点"的节点
- const removeHidden = (nodes) => {
- if (!Array.isArray(nodes)) return [];
- const result = [];
- for (const node of nodes) {
- const hasVisibleChildren = node.children && node.children.length > 0;
- if (node._legendHidden && !hasVisibleChildren) {
- continue; // 跳过:自身隐藏且无可见子节点
- }
- if (node.children) {
- node.children = removeHidden(node.children);
- }
- result.push(node);
- }
- return result;
- };
- return removeHidden(filtered);
- }
- },
- data() {
- return {
- chart: null,
- lineChart: null,
- _isMounted: false,
- _isFirstRender: true,
- activeView: 'graph',
- activeComparison: null,
- selectedLegend: {
- weak: true,
- good: true,
- excellent: true
- },
- legendNumbers: [1, 2, 3],
- selectedIndex: 0,
- selectedKnowledgeId: '',
- selectedKnowledgeType: 1,
- chartRefreshKey: 0,
- showKnowledgePrompt: this.isRecordKnowledge,
- activeTableTab: 'all',
- selectedRow: null,
- expandRowKeys: []
- };
- },
- mounted() {
- this._isMounted = true;
- this.$nextTick(() => {
- this.initChart();
- this.initLineChart();
- });
- },
- beforeDestroy() {
- this._isMounted = false;
- if (this.chart) {
- this.chart.dispose();
- }
- if (this.lineChart) {
- this.lineChart.dispose();
- }
- },
- watch: {
- // 监听activeView变化,切换到列表视图时展开所有树形节点,切换到图形视图时初始化图表
- activeView(newView, oldView) {
- if (newView === 'list') {
- this.$nextTick(() => {
- this.expandAllNodes();
- });
- } else if (newView === 'graph') {
- this.$nextTick(() => {
- this.initChart();
- this.initLineChart();
- });
- }
- },
- // 图例过滤后数据变化,重新展开所有树形节点
- filteredTableDataForList() {
- if (this.activeView === 'list') {
- this.$nextTick(() => {
- this.expandAllNodes();
- });
- }
- },
- // 当知识点列表数据变化时,更新选中项的类型与折线图/表格
- allKnowledgeList: {
- handler(newVal) {
- if (newVal && newVal.length > 0 && this.selectedKnowledgeId) {
- const item = newVal.find(item => String(item.knowledgeId) === String(this.selectedKnowledgeId));
- if (item) {
- this.selectedKnowledgeType = item.knowledgeType || 3;
- }
- if (this.lineChart) this.updateLineChart();
- }
- },
- deep: true
- },
- tableData: {
- deep: true,
- handler(newVal) {
- if (this.chart) {
- this.$nextTick(() => {
- this.updateChart();
- // 数据刷新后,如果有选中项则重新应用放大效果
- if (this.selectedKnowledgeId) {
- this.applySelectedSizeToChart(this.selectedKnowledgeId);
- }
- if (this.lineChart) this.updateLineChart();
- });
- }
- }
- },
- currentKnowledgeId: {
- handler(newVal) {
- if (newVal === undefined || newVal === null) {
- this.selectedKnowledgeId = '';
- return;
- }
- this.selectedKnowledgeId = newVal;
- // 更新知识点类型:先在 allKnowledgeList 扁平表中找
- let item = (this.allKnowledgeList || []).find(i => String(i.knowledgeId) === String(newVal));
- // 兜底:在 tableData 整棵树里按 knowledgeId 查找(保证 region 点任何行都能拿到 knowledgeType)
- if (!item && Array.isArray(this.tableData)) {
- const stack = [...this.tableData];
- while (stack.length && !item) {
- const n = stack.pop();
- if (n && String(n.knowledgeId) === String(newVal)) {
- item = n;
- break;
- }
- if (n && Array.isArray(n.children) && n.children.length) {
- for (let i = 0; i < n.children.length; i++) stack.push(n.children[i]);
- }
- }
- }
- if (item) {
- this.selectedKnowledgeType = item.knowledgeType || 3;
- }
- if (this.chart) this.updateChart();
- // 更新折线图和表格(依赖 examList 驱动,computed 会同步)
- if (this.lineChart) this.updateLineChart();
- },
- immediate: true
- },
- // 考试/练习 tab 切换时重新计算表格数据(computed.filteredTableData 会自动响应)
- activeTableTab() {
- // 预留:切换 tab 后若需做高亮/滚动控制可补
- },
- isRecordKnowledge: {
- handler(newVal) {
- this.showKnowledgePrompt = newVal;
- }
- },
- scoreRateTypes: {
- deep: true,
- handler(newVal) {
- const val = Array.isArray(newVal) ? newVal : [];
- this.selectedLegend.weak = val.length === 0 || val.includes(1);
- this.selectedLegend.good = val.length === 0 || val.includes(2);
- this.selectedLegend.excellent = val.length === 0 || val.includes(3);
- this.legendNumbers = val.length === 0 ? [1, 2, 3] : [...val].sort((a, b) => a - b);
- if (this.chart) this.updateChart();
- }
- },
- },
- methods: {
- // 获取当前颜色(根据选中知识点类型)
- getCurrentColor() {
- const item = this.currentKnowledgeItem;
- const t = Number(item ? item.knowledgeType : this.selectedKnowledgeType);
- if (t === 3) {
- return '#3BA272';
- } else if (t === 2) {
- return '#FAC858';
- } else {
- return '#EE6666';
- }
- },
- // 初始化折线图
- initLineChart() {
- if (!this.$refs.lineChart) return;
- if (this.lineChart) {
- this.lineChart.dispose();
- }
- this.lineChart = echarts.init(this.$refs.lineChart);
- this.updateLineChart();
- },
- // 更新折线图:数据源为当前选中知识点节点上的 examList 数组
- // xAxis = examList[i].dataName
- // yAxis series.data = examList[i].dataRate (0~100百分比)
- updateLineChart() {
- const LOG_TAG = '%c[Diffusion.updateLineChart]';
- const LOG_STYLE = 'background:#3BA272;color:#fff;padding:2px 8px;border-radius:4px;font-weight:500;';
- const callerStack = (new Error()).stack
- ?.split('\n')
- ?.slice(1, 5)
- ?.map(s => s.trim())
- ?.join(' | ') || '';
- const snapshotBefore = {
- hasLineChartInstance: !!this.lineChart,
- hasLineChartRef: !!this.$refs.lineChart,
- selectedKnowledgeId: this.selectedKnowledgeId ?? '(empty)',
- selectedKnowledgeType: this.selectedKnowledgeType,
- selectedExamListLength: (this.selectedExamList || []).length,
- filteredTableDataLength: (this.filteredTableData || []).length,
- activeTableTab: this.activeTableTab,
- caller: callerStack
- };
- if (!this.lineChart) {
- console.warn(LOG_TAG + ' 提前跳过:lineChart 实例不存在(initLineChart 还未执行 / 已被 dispose)', LOG_STYLE, snapshotBefore);
- return;
- }
- const color = this.getCurrentColor();
- // 折线图渲染时对数据倒序排序(不影响表格等其他使用 selectedExamList 的地方)
- const examList = [...(this.selectedExamList || [])].reverse();
- const xAxisData = examList.map(item => item.dataName ?? '');
- const seriesData = examList.map(item => (item.dataRate === null || item.dataRate === undefined ? null : Number(item.dataRate)));
- const option = {
- tooltip: {
- trigger: 'axis',
- formatter: (params) => {
- const p = Array.isArray(params) ? params[0] : params;
- if (!p || p.value === null || p.value === undefined) return `${p.name ?? ''}`;
- return `${p.name}: ${p.value}%`;
- }
- },
- grid: {
- left: '5%',
- right: '5%',
- top: '10%',
- bottom: '10%'
- },
- xAxis: {
- show: false,
- type: 'category',
- data: xAxisData,
- axisLabel: {
- show: false,
- fontSize: 10,
- color: '#999',
- rotate: 30
- },
- axisLine: {
- show: false,
- lineStyle: { color: '#ECEEF3' }
- },
- axisTick: {
- show: false
- }
- },
- yAxis: {
- show: false,
- type: 'value',
- min: 0,
- max: 100,
- axisLabel: {
- show: false,
- fontSize: 10,
- color: '#999',
- formatter: '{value}%'
- },
- splitLine: {
- show: false,
- lineStyle: {
- color: '#f5f5f5',
- type: 'dashed'
- }
- }
- },
- series: [{
- data: seriesData,
- type: 'line',
- // smooth: true,
- symbol: 'circle',
- symbolSize: 8,
- connectNulls: false,
- itemStyle: {
- color: '#fff',
- borderColor: color,
- borderWidth: 2
- },
- lineStyle: {
- color: color,
- width: 2
- },
- areaStyle: {
- color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
- { offset: 0, color: color + '40' },
- { offset: 1, color: color + '05' }
- ])
- },
- // 确保圆点显示在折线上方,遮挡线条
- z: 10
- }]
- };
- const t0 = performance.now();
- this.lineChart.setOption(option, true); // notMerge=true,防止上一个节点残留x轴/点
- this.lineChart.resize();
- const t1 = performance.now();
- },
- // 处理加入练习:把当前选中节点的完整字段 emit 给父组件,父组件调加入练习接口
- handleAddPractice() {
- const item = this.currentKnowledgeItem;
- const knowledgeId = this.selectedKnowledgeId ?? item?.knowledgeId ?? null;
- const knowledgeName = this.selectedKnowledgeName ?? item?.knowledgeName ?? '';
- if (!knowledgeId) {
- this.$message && this.$message.warning && this.$message.warning('请先选择一个知识点');
- return;
- }
- // 得分率:personalScoreRate → gradeScoreRate → classScoreRate
- let personalScoreRate = item?.personalScoreRate;
- if (personalScoreRate === null || personalScoreRate === undefined || personalScoreRate === '') {
- personalScoreRate = item?.gradeScoreRate ?? item?.classScoreRate;
- }
- if (personalScoreRate === null || personalScoreRate === undefined || personalScoreRate === '') {
- personalScoreRate = 0;
- } else {
- personalScoreRate = Number(personalScoreRate);
- if (isNaN(personalScoreRate)) personalScoreRate = 0;
- }
- // 知识点类型 1薄弱 2一般 3优秀
- let knowledgeType = Number(item?.knowledgeType ?? item?.type ?? 0);
- if (isNaN(knowledgeType)) knowledgeType = 0;
- this.$emit('add-practice', {
- knowledgeId,
- knowledgeName,
- personalScoreRate,
- knowledgeType,
- ...(item ? { ...item } : {})
- });
- },
- setDefaultSelection() {
- // 如果父组件已经传入 currentKnowledgeId,则使用父级指定的选中项,不再重选
- // ——但如果父级指定的节点被"图例过滤规则"判定为不可见/降级(hasData=false),则需要重选
- if (this.currentKnowledgeId) {
- this.selectedKnowledgeId = this.currentKnowledgeId;
- // 先按当前图例规则重算一遍过滤树,判断父级指定的节点是否"最终可见且有数据"
- const filteredTree = this.calcFilteredTree(this.tableData);
- let matchNode = null;
- const findStack = [...filteredTree];
- while (findStack.length && !matchNode) {
- const n = findStack.pop();
- if (n && String(n.knowledgeId) === String(this.currentKnowledgeId)) {
- matchNode = n;
- break;
- }
- if (n && Array.isArray(n.children) && n.children.length > 0) {
- for (let i = 0; i < n.children.length; i++) findStack.push(n.children[i]);
- }
- }
- const isFinalValid = matchNode && matchNode.__finalVisible && matchNode.__finalHasData;
- if (isFinalValid) {
- // 父级指定的节点可见且有效:直接沿用
- this.selectedKnowledgeType = matchNode.knowledgeType || 3;
- if (this.lineChart) this.updateLineChart();
- // 通知父级联动 state(保证首次进来默认选中时 region 卡片也高亮对应条目 + 居中滚动)
- this.$emit('knowledge-item-click', {
- knowledgeId: matchNode.knowledgeId,
- knowledgeName: matchNode.knowledgeName,
- personalScoreRate: matchNode.personalScoreRate != null ? matchNode.personalScoreRate : matchNode.scoreRate,
- knowledgeType: matchNode.knowledgeType,
- examList: Array.isArray(matchNode.examList) ? matchNode.examList : []
- });
- return;
- }
- // 否则:父级指定被图例隐藏或降级 → 走重选逻辑(下面的 legacyLists/树形)
- }
- // 1. 优先从扁平化 allKnowledgeList / highVulnerability / fatalVulnerability 取第一个
- // ——仍然要过一遍"图例过滤",只有最终可见+hasData 的才能选中
- let targetNode = null;
- const legacyLists = [this.allKnowledgeList, this.highVulnerability, this.fatalVulnerability];
- for (const lst of legacyLists) {
- if (!Array.isArray(lst) || lst.length === 0) continue;
- // 从 lst 中找第一个:经过 calcFilteredTree 判定后 __finalVisible=true && __finalHasData=true
- const quickTree = this.calcFilteredTree(lst.map(it => ({ ...it }))); // 扁平化结构当 root 层处理即可
- for (let i = 0; i < quickTree.length; i++) {
- const n = quickTree[i];
- if (n && n.__finalVisible && n.__finalHasData) {
- targetNode = n;
- break;
- }
- }
- if (targetNode) break;
- }
- // 2. 兜底:从 tableData(树形)取"第一棵节点树的最后一个可见且有效叶子"
- if (!targetNode) {
- targetNode = this.findFirstVisibleLeafNode(this.calcFilteredTree(this.tableData));
- }
- if (!targetNode) {
- // 没找到任何可见有效节点(例如全部图例都关闭)→ 清空选中
- this.selectedKnowledgeId = '';
- this.selectedKnowledgeType = 3;
- if (this.lineChart) this.updateLineChart();
- return;
- }
- this.selectedIndex = 0;
- this.selectedKnowledgeId = targetNode.knowledgeId;
- this.selectedKnowledgeType = targetNode.knowledgeType || 3;
- // 初始化折线图(确保颜色正确)
- if (this.lineChart) {
- this.updateLineChart();
- }
- // 统一:应用选中大小(放大/还原)
- this.applySelectedSizeToChart(this.selectedKnowledgeId);
- // 通知父级联动 state(保证首次进来默认选中 / 图例过滤重选后 region 卡片也高亮对应条目 + 居中滚动)
- this.$emit('knowledge-item-click', {
- knowledgeId: targetNode.knowledgeId,
- knowledgeName: targetNode.knowledgeName,
- personalScoreRate: targetNode.personalScoreRate != null ? targetNode.personalScoreRate : targetNode.scoreRate,
- knowledgeType: targetNode.knowledgeType,
- examList: Array.isArray(targetNode.examList) ? targetNode.examList : []
- });
- },
- switchView(view) {
- this.activeView = view;
- this.$emit('view-change', view);
- },
- // 根据得分率获取对应的样式类
- getRateClass(rate) {
- const rateValue = parseFloat(rate);
- if (rateValue >= 85) {
- return 'rate_excellent';
- } else if (rateValue >= 60) {
- return 'rate_good';
- } else {
- return 'rate_weak';
- }
- },
- // 根据得分率获取对应的状态名称
- getRateName(rate) {
- const rateValue = parseFloat(rate);
- if (rateValue >= 85) {
- return '优秀';
- } else if (rateValue >= 60) {
- return '良好';
- } else {
- return '薄弱';
- }
- },
- // 行样式类名方法
- rowClassName({ row }) {
- if (this.selectedRow && this.selectedRow.knowledgeId === row.knowledgeId) {
- return 'selected-row';
- }
- return row.highlight ? 'row_highlight' : '';
- },
- // 处理树形表格行点击事件
- handleRowClick(row) {
- const isLastLevel = !row.children || row.children.length === 0;
- if (isLastLevel) {
- this.selectedRow = row;
- }
- },
- // 处理单元格点击事件
- handleCellClick({ row }) {
- this.handleRowClick(row);
- },
- // 展开所有树形节点
- expandAllNodes() {
- const treeTable = this.$refs.treeTable;
- if (treeTable) {
- const expandNodes = [];
- const collectExpandNodes = (data) => {
- if (!data || !Array.isArray(data)) return;
- data.forEach(item => {
- if (item.children && item.children.length > 0) {
- expandNodes.push(item);
- collectExpandNodes(item.children);
- }
- });
- };
- collectExpandNodes(this.filteredTableDataForList);
- if (treeTable.setExpandRows) {
- treeTable.setExpandRows(expandNodes);
- } else if (treeTable.setTreeExpand) {
- expandNodes.forEach(node => {
- treeTable.setTreeExpand(node, true);
- });
- }
- }
- },
- classIsGroup() {
- if (this.classLevel == 1) {
- return true;
- } else
- return false;
- },
- getNodeColor(knowledgeType) {
- const type = knowledgeType;
- if (type == 3) {
- return '#3BA272';
- } else if (type == 2) {
- return '#FAC858';
- } else {
- return '#EE6666';
- }
- },
- // 工具:knowledgeType -> legend 级别
- getNodeLevel(knowledgeType) {
- if (knowledgeType == 3) return 'excellent';
- if (knowledgeType == 2) return 'good';
- return 'weak';
- },
- /**
- * 基于 selectedLegend 计算"过滤后"的树形展示状态(后序遍历:先处理 children 再决定当前节点)
- * 规则(与产品说明严格对齐):
- * 1) 叶子节点:
- * - 自己的类型被图例隐藏(selectedLegend[type]=false)→ 整节点隐藏(opacity=0, invisible=true)
- * - 自己的类型显示 → 保持 hasData/颜色 原样,visible
- * 2) 非叶子节点(有 children):
- * a. 如果存在「可见且有数据」的子(不管子是哪一层,只要子树里存在)
- * → 父节点必须可见(否则子画不出来):
- * - 若自己的类型被图例隐藏 → 降级为「灰色节点、hasData=false」(名称保留,不显示真实得分率/颜色,不参与点击)
- * - 若自己的类型显示 → 保持原 hasData/颜色 原样
- * b. 如果不存在「可见且有数据」的子(全子树都被隐藏或都无数据)
- * → 父节点按自身规则走:
- * - 自己类型被隐藏 → 隐藏
- * - 自己类型显示 → 保持原 hasData/颜色 原样
- * 3) 返回的每一项新增字段(不修改原 props.tableData,做浅克隆):
- * __finalVisible : boolean 该节点最终是否可见
- * __finalHasData : boolean 是否显示为"有数据节点"(false=灰色圆点,无得分率)
- * __finalColor : string 最终绘制颜色
- * __scoreRate : number|null 得分率(若 __finalHasData=false 则不再用)
- * __rawHasData : boolean 原始数据是否有 scoreRate(用于 applySelectedSizeToChart 重算)
- */
- calcFilteredTree(data) {
- const vm = this;
- if (!Array.isArray(data)) return [];
- return data.map(item => {
- const node = { ...(item || {}) };
- const scoreRate = vm.classLevel == 2 || vm.mode == 'student' ? node.classScoreRate : node.gradeScoreRate;
- const rawHasData = scoreRate !== null && scoreRate !== undefined;
- node.__scoreRate = rawHasData ? Number(scoreRate) : null;
- node.__rawHasData = rawHasData;
- const selfLevel = vm.getNodeLevel(node.knowledgeType);
- const selfTypeHidden = vm.selectedLegend[selfLevel] === false;
- const hasChildren = Array.isArray(node.children) && node.children.length > 0;
- if (hasChildren) {
- // 先递归处理 children
- const subChildren = vm.calcFilteredTree(node.children);
- node.children = subChildren;
- // 统计:子树中存在多少"最终可见且最终 hasData=true"的节点(任意深度)
- let subHasVisibleHasData = false;
- const stack = [...subChildren];
- while (stack.length && !subHasVisibleHasData) {
- const n = stack.pop();
- if (n && n.__finalVisible && n.__finalHasData) {
- subHasVisibleHasData = true;
- break;
- }
- if (n && Array.isArray(n.children) && n.children.length > 0) {
- for (let i = 0; i < n.children.length; i++) stack.push(n.children[i]);
- }
- }
- if (subHasVisibleHasData) {
- // 父必须可见
- node.__finalVisible = true;
- if (selfTypeHidden) {
- // 降级为灰色节点(hasData=false)
- node.__finalHasData = false;
- node.__finalColor = '#BFC1C7';
- } else {
- node.__finalHasData = rawHasData;
- node.__finalColor = rawHasData ? vm.getNodeColor(node.knowledgeType) : '#BFC1C7';
- }
- } else {
- // 子树没有任何有效可见节点 → 父按自身规则
- if (selfTypeHidden) {
- node.__finalVisible = false;
- node.__finalHasData = rawHasData;
- node.__finalColor = '#BFC1C7';
- } else {
- node.__finalVisible = true;
- node.__finalHasData = rawHasData;
- node.__finalColor = rawHasData ? vm.getNodeColor(node.knowledgeType) : '#BFC1C7';
- }
- }
- } else {
- // 叶子节点
- if (selfTypeHidden) {
- node.__finalVisible = false;
- node.__finalHasData = rawHasData;
- node.__finalColor = '#BFC1C7';
- } else {
- node.__finalVisible = true;
- node.__finalHasData = rawHasData;
- node.__finalColor = rawHasData ? vm.getNodeColor(node.knowledgeType) : '#BFC1C7';
- }
- }
- return node;
- });
- },
- /**
- * 从过滤后的树形数据中,找到"最终可见且有数据"的一个叶子节点(用于默认选中)
- * - 优先在"第一棵子树的最后一个可见叶子"里找(和原 setDefaultSelection 风格一致)
- * - 如果整棵树都没可见有效点,返回 null
- * - 如果入参 excludeKnowledgeId 提供,则跳过该 knowledgeId(用于重选时跳过被隐藏的旧选中)
- */
- findFirstVisibleLeafNode(list, excludeKnowledgeId = null) {
- if (!Array.isArray(list) || list.length === 0) return null;
- // 遍历所有节点(深度优先),收集可见+hasData=true 的叶子
- const stack = [];
- // 从前往后 push,让最后一棵树最后处理;每次 pop 最后一个 children,以匹配"第一棵树最后一个叶子"
- for (let i = list.length - 1; i >= 0; i--) stack.push(list[i]);
- let best = null;
- while (stack.length) {
- const n = stack.pop();
- if (!n) continue;
- const isLeaf = !Array.isArray(n.children) || n.children.length === 0;
- const isValid = n.__finalVisible && n.__finalHasData &&
- (excludeKnowledgeId === null || String(n.knowledgeId) !== String(excludeKnowledgeId));
- if (isValid && isLeaf) {
- // 每找到一个就覆盖(因为从后入栈+pop,最后保留下的就是第一棵树最后一个叶子)
- best = n;
- }
- if (Array.isArray(n.children) && n.children.length > 0) {
- for (let i = n.children.length - 1; i >= 0; i--) stack.push(n.children[i]);
- }
- }
- return best;
- },
- /**
- * 统一生成 ECharts tree 结构 children
- * ——基于 calcFilteredTree 输出的节点(已经带 __final* 字段)
- * @param {Array} data calcFilteredTree 处理过的 tree(或子集 children)
- * @param {number} level 当前层级
- * @param {string|null} overrideKnowledgeId
- */
- generateTreeData(data, level = 0, overrideKnowledgeId = null) {
- const vm = this;
- const currentKnowledgeId = overrideKnowledgeId !== null ? overrideKnowledgeId : vm.selectedKnowledgeId;
- return (data || []).map(item => {
- const finalHasData = !!item.__finalHasData;
- const finalVisible = !!item.__finalVisible;
- const finalColor = item.__finalColor || '#BFC1C7';
- const finalScoreRate = item.__scoreRate;
- // 注意:节点被过滤(__finalVisible=false)时,即使 isMatched 也不显示放大——因为看不见
- const isMatched = finalHasData && finalVisible && (String(item.knowledgeId) === String(currentKnowledgeId));
- let symbolSize = 5;
- if (isMatched) {
- symbolSize = 25; // 选中:放大
- } else if (finalHasData) {
- symbolSize = 10; // 有效但未选中:默认
- }
- // 选中节点的绘制权重更高,防止被非选中圆点遮挡
- const drawZ = isMatched ? 99 : 10;
- const drawZLevel = isMatched ? 2 : 0;
- const node = {
- name: item.knowledgeName,
- symbolSize: symbolSize,
- value: finalScoreRate,
- itemStyle: {
- color: finalColor,
- opacity: finalVisible ? 1 : 0
- },
- lineStyle: {
- opacity: finalVisible ? 1 : 0
- },
- hasData: finalHasData, // 用于 tooltip 显示/点击忽略(灰色不能点)
- knowledgeId: item.knowledgeId,
- knowledgeType: item.knowledgeType,
- z: drawZ,
- zlevel: drawZLevel
- };
- if (item.children && item.children.length > 0) {
- node.children = vm.generateTreeData(item.children, level + 1, overrideKnowledgeId);
- }
- return node;
- });
- },
- /**
- * 统一入口:将当前选中的 knowledgeId 对应的 symbolSize 应用到 ECharts 图表上
- * ——基于过滤后的 filteredTree(保证图例过滤后重新算节点大小和选中状态)
- */
- applySelectedSizeToChart(overrideKnowledgeId) {
- if (!this.chart) return;
- const selectedId = overrideKnowledgeId !== undefined ? overrideKnowledgeId : this.selectedKnowledgeId;
- const filteredTree = this.calcFilteredTree(this.tableData);
- this.chart.setOption({
- series: [{
- data: [
- {
- name: this.subjectName,
- symbolSize: 5,
- value: this.subjectScoreRate,
- itemStyle: {
- color: '#BFC1C7',
- opacity: 1
- },
- hasData: false,
- children: this.generateTreeData(filteredTree, 0, selectedId)
- }
- ]
- }]
- }, {
- animation: true,
- animationDuration: 300
- });
- },
- /**
- * 从树形 list 中取"第一棵节点树的最后一个叶子节点"
- * 规则:取 list[0],沿 children[last] 一路下钻,直到没有 children
- */
- findFirstTreeLastLeafNode(list) {
- if (!Array.isArray(list) || list.length === 0) return null;
- let node = list[0];
- while (node && Array.isArray(node.children) && node.children.length > 0) {
- node = node.children[node.children.length - 1];
- }
- return node || null;
- },
- initChart() {
- try {
- if (!this.$refs.chart) {
- console.error('图表容器不存在');
- return;
- }
- this.createChart();
- }
- catch (error) {
- console.error('图表初始化失败:', error);
- }
- },
- createChart() {
- try {
- if (!this.$refs.chart) {
- console.error('图表容器不存在');
- return;
- }
- if (this.chart) {
- this.chart.dispose();
- }
- this.chart = echarts.init(this.$refs.chart);
- const vm = this;
- const countOutermostNodes = (data) => {
- let count = 0;
- const traverse = (nodes) => {
- nodes.forEach(node => {
- if (node.children && node.children.length > 0) {
- traverse(node.children);
- } else {
- count++;
- }
- });
- };
- traverse(data);
- return count;
- };
- const outermostCount = countOutermostNodes(this.tableData);
- const getOptimalZoom = () => {
- let zoom = 1.05;
- return zoom;
- };
- const getOptimalRadius = () => {
- if (outermostCount <= 5) {
- return ['20%', '65%'];
- }
- return ['12%', '75%'];
- };
- const option = {
- tooltip: {
- formatter: (params) => {
- if (params.treePathInfo && params.treePathInfo.length === 1) {
- return params.name;
- }
- const hasData = params.data && params.data.hasData;
- if (hasData) {
- return `${params.name}<br/>得分率: ${params.value !== null && params.value !== undefined ? params.value : '0'}%`;
- } else {
- return params.name;
- }
- }
- },
- animationDurationUpdate: 1500,
- animationEasingUpdate: 'quinticInOut',
- series: [
- {
- type: 'tree',
- layout: 'radial',
- symbol: 'circle',
- initialTreeDepth: 999,
- expandAndCollapse: false,
- orient: 'radial',
- roam: false,
- left: '8%',
- right: '8%',
- top: '8%',
- bottom: '8%',
- radius: getOptimalRadius(),
- nodeScaleRatio: 1,
- layerPadding: outermostCount <= 5 ? [20, 10] : [10, 5],
- data: [
- {
- name: this.subjectName,
- symbolSize: 5,
- value: this.subjectScoreRate,
- itemStyle: {
- color: '#BFC1C7'
- },
- hasData: false,
- children: this.generateTreeData(this.calcFilteredTree(this.tableData))
- }
- ],
- label: {
- show: false
- },
- lineStyle: {
- color: '#ECEEF3',
- width: 1,
- type: 'solid'
- },
- emphasis: {
- focus: 'adjacency',
- lineStyle: {
- width: 1
- }
- }
- }
- ]
- };
- this.chart.setOption(option);
- this.chart.resize();
- this.chart.on('click', (params) => {
- const dataItem = params && params.data;
- // 1. 灰色圆点(无数据 hasData=false)或学科中心节点:点击忽略,不做任何处理
- const isSubjectCenter = params.treePathInfo && params.treePathInfo.length === 1;
- if (isSubjectCenter || !dataItem || !dataItem.hasData) {
- return;
- }
- // 2. 从点击节点本身的 knowledgeId 去查(点击 params.data.knowledgeId 与 原始tableData 匹配)
- const clickedKnowledgeId = dataItem.knowledgeId;
- const allLists = [this.allKnowledgeList, this.highVulnerability, this.fatalVulnerability];
- let knowledgeItem = null;
- let targetIndex = -1;
- // 先从扁平化列表里找(用于回传 index 给父级)
- if (clickedKnowledgeId !== undefined && clickedKnowledgeId !== null && clickedKnowledgeId !== '') {
- for (const list of allLists) {
- if (!Array.isArray(list)) continue;
- targetIndex = list.findIndex(item => String(item.knowledgeId) === String(clickedKnowledgeId));
- if (targetIndex !== -1) {
- knowledgeItem = list[targetIndex];
- break;
- }
- }
- }
- // 扁平化找不到,就从 tableData 递归查找,按 name 兜底
- if (!knowledgeItem && params.name) {
- const findByNodeName = (arr) => {
- for (const item of arr) {
- if (item.knowledgeName === params.name) return item;
- if (Array.isArray(item.children) && item.children.length) {
- const f = findByNodeName(item.children);
- if (f) return f;
- }
- }
- return null;
- };
- knowledgeItem = findByNodeName(this.tableData);
- // 再回填一次 index
- if (knowledgeItem && knowledgeItem.knowledgeId !== undefined) {
- for (const list of allLists) {
- if (!Array.isArray(list)) continue;
- targetIndex = list.findIndex(item =>
- String(item.knowledgeId) === String(knowledgeItem.knowledgeId)
- );
- if (targetIndex !== -1) break;
- }
- }
- }
- // 3. 如果还是找不到知识点项,按点击的 data 做最小赋值(至少保证选中ID、类型对得上,有放大效果)
- if (!knowledgeItem) {
- knowledgeItem = {
- knowledgeId: clickedKnowledgeId ?? params.name,
- knowledgeName: params.name,
- knowledgeType: dataItem.knowledgeType || 3
- };
- }
- // 4. 回传事件:保持向后兼容 {item, index},同时把核心字段打平在 payload 顶层
- // ——父级联动(handleKnowledgeItemClick)会按顶层 knowledgeId 写入 currentLinkedKnowledgeId
- // ——外层其他监听者也可继续用 item / index
- this.$emit('knowledge-item-click', {
- item: knowledgeItem,
- index: targetIndex,
- knowledgeId: knowledgeItem.knowledgeId,
- knowledgeName: knowledgeItem.knowledgeName,
- personalScoreRate: knowledgeItem.personalScoreRate != null
- ? knowledgeItem.personalScoreRate
- : knowledgeItem.scoreRate,
- knowledgeType: knowledgeItem.knowledgeType,
- examList: Array.isArray(knowledgeItem.examList) ? knowledgeItem.examList : []
- });
- // 5. 更新内部状态
- if (targetIndex !== -1) this.selectedIndex = targetIndex;
- this.selectedKnowledgeId = knowledgeItem.knowledgeId;
- this.selectedKnowledgeType = knowledgeItem.knowledgeType || 3;
- // 6. 折线图颜色同步
- if (this.lineChart) {
- this.updateLineChart();
- }
- // 7. 统一更新圆点大小:当前点放大(25),上一个选中点自动恢复为10
- this.applySelectedSizeToChart(this.selectedKnowledgeId);
- });
- window.addEventListener('resize', () => {
- if (this.chart) {
- this.chart.resize();
- }
- });
- }
- catch (error) {
- console.error('图表创建失败:', error);
- }
- },
- updateChart() {
- try {
- const LOG_TAG = '%c[Diffusion.updateChart]';
- const LOG_STYLE = 'background:#2E64FA;color:#fff;padding:2px 8px;border-radius:4px;font-weight:500;';
- const callerStack = (new Error()).stack
- ?.split('\n')
- ?.slice(1, 5)
- ?.map(s => s.trim())
- ?.join(' | ') || '';
- if (!this.$refs.chart) {
- console.warn(LOG_TAG + ' 提前跳过:$refs.chart 不存在(模板 ref 未挂载 / 容器尚未渲染)', LOG_STYLE);
- return;
- }
- const t0 = performance.now();
- const oldChartExist = !!this.chart;
- this.createChart();
- const t1 = performance.now();
- }
- catch (error) {
- console.error('[Diffusion.updateChart] 图表更新失败:', error);
- }
- },
- reset() {
- this.selectedIndex = 0;
- this.updateChart();
- },
- toggleLegend(type) {
- const prevSelected = this.selectedLegend[type];
- this.selectedLegend[type] = !prevSelected;
- const typeMap = {
- weak: 1,
- good: 2,
- excellent: 3
- };
- const number = typeMap[type];
- if (!this.selectedLegend[type]) {
- const index = this.legendNumbers.indexOf(number);
- if (index > -1) {
- this.legendNumbers.splice(index, 1);
- }
- } else {
- if (!this.legendNumbers.includes(number)) {
- this.legendNumbers.push(number);
- }
- this.legendNumbers.sort((a, b) => a - b);
- }
- const hasAllNumbers = this.legendNumbers.includes(1) && this.legendNumbers.includes(2) && this.legendNumbers.includes(3);
- const dataToSend = hasAllNumbers ? null : this.legendNumbers;
- this.$emit('legend-numbers-change', dataToSend);
- // ---- 图例变化后:重算过滤 → 重绘扩散图 ----
- this.$nextTick(() => {
- if (this.chart) {
- this.updateChart();
- // 重新应用选中圆点放大效果
- if (this.selectedKnowledgeId) {
- this.applySelectedSizeToChart(this.selectedKnowledgeId);
- }
- }
- });
- },
- }
- };
- </script>
- <style lang="scss" scoped>
- .circularDiffusion {
- position: relative;
- }
- .circularDiffusion,
- .knowledge_graph_student {
- .knowledge_class {
- :deep(.vxe-body--row.selected-row),
- :deep(.selected-row) {
- background-color: rgba(46, 100, 250, 0.1) !important;
- color: #2E64FA !important;
- * {
- color: #2E64FA !important;
- }
- .rate_dot {
- border-color: rgba(46, 100, 250, 0.1) !important;
- }
- }
- .graph_header {
- margin-bottom: 10px;
- position: relative;
- display: flex;
- justify-content: space-between;
- align-items: center;
- .legend {
- display: flex;
- gap: 20px;
- .legend_item {
- display: flex;
- align-items: center;
- gap: 5px;
- cursor: pointer;
- font-weight: 500;
- &.selected {
- opacity: 1;
- }
- &:hover {
- opacity: 1;
- }
- .legend_dot {
- width: 20px;
- height: 10px;
- border-radius: 2px;
- transition: all 0.3s ease;
- &.weak {
- background: #ccc;
- }
- &.good {
- background: #cccccc;
- }
- &.excellent {
- background: #ccc;
- }
- }
- .legend_text {
- font-size: 12px;
- color: #999999;
- transition: all 0.3s ease;
- }
- &.selected {
- .legend_dot.weak {
- background: #EE6666;
- }
- .legend_dot.good {
- background: #FAC858;
- }
- .legend_dot.excellent {
- background: #3BA272;
- }
- .legend_text {
- color: #333;
- }
- }
- }
- }
- }
- .graph_content {
- display: flex;
- gap: 20px;
- position: relative;
- flex-wrap: wrap;
- min-width: 0;
- .knowledge_right_container {
- width: 400px;
- max-width: 100%;
- display: flex;
- flex-direction: column;
- gap: 10px;
- .line_chart_container {
- border: 1px solid #DCDFE6;
- border-radius: 6px;
- position: relative;
- display: flex;
- flex-direction: column;
- /* 标题栏:左右分布,放在折线图上方 */
- .chart_title {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 12px;
- padding: 10px 16px;
- border-bottom: 1px solid #DCDFE6;
- /* 左侧:上下结构(标题 + 考频) */
- .chart_title_left {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- justify-content: center;
- gap: 4px;
- }
- .title_text {
- font-weight: 500;
- font-size: 14px;
- color: #333333;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- line-height: 1.2;
- }
- .exam_freq {
- font-weight: 400;
- font-size: 12px;
- color: #999999;
- line-height: 1.2;
- }
- /* 加入练习按钮:使用 Element UI el-button,描边风格跟随当前选中圆点的 knowledgeType 颜色 */
- .add_practice_btn {
- border-radius: 4px;
- font-weight: 500;
- font-size: 14px;
- background: #fff !important;
- flex-shrink: 0;
- padding: 0;
- width: 100px;
- height: 36px;
- line-height: 36px;
- text-align: center;
- /* 薄弱:knowledgeType=1 → 与选中圆点的 #EE6666 颜色一致 */
- &.btn_theme_weak {
- border: 1px solid #EE6666 !important;
- color: #EE6666 !important;
- &:hover, &:focus {
- border: 1px solid #EE6666 !important;
- color: #EE6666 !important;
- background: #fff !important;
- }
- }
- /* 一般/能力提升:knowledgeType=2 → #FAC858 */
- &.btn_theme_good {
- border: 1px solid #FAC858 !important;
- color: #FAC858 !important;
- &:hover, &:focus {
- border: 1px solid #FAC858 !important;
- color: #FAC858 !important;
- background: #fff !important;
- }
- }
- /* 优秀/扎实巩固:knowledgeType=3 → #3BA272 */
- &.btn_theme_excellent {
- border: 1px solid #3BA272 !important;
- color: #3BA272 !important;
- &:hover, &:focus {
- border: 1px solid #3BA272 !important;
- color: #3BA272 !important;
- background: #fff !important;
- }
- }
- /* isJoin=1 的置灰状态:disabled 时边框和文字变浅灰,背景淡灰(优先级最高,覆盖主题色) */
- &.is-disabled,
- &[disabled] {
- border: 1px solid #DCDFE6 !important;
- color: #C0C4CC !important;
- background: #F5F7FA !important;
- cursor: not-allowed !important;
- &:hover,
- &:focus {
- border: 1px solid #DCDFE6 !important;
- color: #C0C4CC !important;
- background: #F5F7FA !important;
- }
- }
- }
- }
- .line_chart {
- width: calc(100% - 30px);
- height: 180px;
- padding: 15px;
- }
- }
- .table_container {
- /* 按钮组 tabs:按设计图样式 */
- .table_tabs_radio {
- margin-bottom: 10px;
- display: inline-flex;
- align-items: center;
- line-height: 0;
- border-collapse: collapse;
- :deep(.el-radio-button) {
- /* 让选中的按钮在视觉上压过相邻按钮的边框 */
- &:first-child .el-radio-button__inner {
- border-radius: 4px 0 0 4px;
- }
- &:last-child .el-radio-button__inner {
- border-radius: 0 4px 4px 0;
- }
- }
- :deep(.el-radio-button__inner) {
- height: 36px;
- line-height: 36px;
- padding: 0 20px;
- font-size: 13px;
- color: #909399; /* 未选中:灰字 */
- background: #FFFFFF;
- border: 1px solid #DCDFE6; /* 未选中:灰边框 */
- box-sizing: border-box;
- margin: 0;
- box-shadow: none !important;
- transition: none;
- position: relative;
- border-radius: 0;
- }
- /* 相邻按钮的重叠(Element 默认 margin-left:-1px,这里保持默认通过 border-color 变化即可,显式指定分隔线颜色) */
- :deep(.el-radio-button + .el-radio-button) {
- margin-left: -1px;
- }
- /* 选中态:蓝边框 + 蓝字 + 白底(完全匹配图片) */
- :deep(.el-radio-button__orig-radio:checked + .el-radio-button__inner) {
- color: #2E64FA;
- background-color: #FFFFFF;
- border-color: #2E64FA;
- box-shadow: none !important;
- z-index: 2; /* 把选中按钮抬高,其蓝色边框盖住相邻按钮的灰色边框 */
- }
- /* 未选中 hover:保持灰字灰边框,不变成蓝色 */
- :deep(.el-radio-button__orig-radio:not(:checked) + .el-radio-button__inner:hover) {
- color: #909399;
- border-color: #DCDFE6;
- background-color: #FFFFFF;
- }
- :deep(.el-radio-button__inner:hover) {
- color: #909399;
- }
- }
- .data_table {
- height: 360px;
- border-radius: 6px;
- /* 整表统一 cell 高度为 40px(Element UI 是通过 .cell 控制) */
- :deep(.el-table__header-wrapper th .cell),
- :deep(.el-table__body-wrapper td .cell) {
- height: 40px;
- line-height: 40px;
- padding-top: 0;
- padding-bottom: 0;
- box-sizing: border-box;
-
- }
- /* 表头样式 */
- :deep(.el-table__header th) {
- background: #F5F7FA;
- color: #333333;
- font-weight: 500;
- font-size: 14px;
- padding:0px !important;
- }
- /* 斑马纹:开启 stripe 后,Element UI 会给偶数行加 el-table__row--striped */
- :deep(.el-table__body tr.el-table__row--striped td) {
- background: #FAFBFC;
- }
- :deep(.el-table__body tr.el-table__row--striped td.el-table__cell) {
- background: #FAFBFC;
- }
- /* 表体样式 */
- :deep(.el-table__body td) {
- font-weight: 400;
- font-size: 14px;
- color: #666666;
- padding:0px !important;
- }
- /* hover 行时不改变斑马纹底色(仅轻微提亮边框感知) */
- :deep(.el-table__body tr:hover > td) {
- background-color: #EFF6FF;
- }
- }
- }
- }
- .item_chart_container {
- border: 1px solid #E9EBEF;
- border-radius: 10px;
- margin-bottom: 10px;
- }
- .chart_container {
- flex: 1;
- min-width: 0;
- border-radius: 10px;
- border: 1px solid #E9EBEF;
- position: relative;
- padding: 20px;
- overflow: hidden;
- .top_container {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- flex-wrap: wrap;
- gap: 10px;
- margin-bottom: 10px;
- }
- .legend {
- display: flex;
- gap: 20px;
- flex-wrap: wrap;
- flex: 1;
- min-width: 0;
- .legend_item {
- display: flex;
- align-items: center;
- gap: 5px;
- cursor: pointer;
- font-weight: 500;
- &.selected {
- opacity: 1;
- }
- &:hover {
- opacity: 1;
- }
- .legend_dot {
- width: 20px;
- height: 10px;
- border-radius: 2px;
- transition: all 0.3s ease;
- &.weak {
- background: #ccc;
- }
- &.good {
- background: #ccc;
- }
- &.excellent {
- background: #ccc;
- }
- }
- .legend_text {
- font-size: 12px;
- color: #999999;
- transition: all 0.3s ease;
- }
- &.selected {
- .legend_dot.weak {
- background: #EE6666;
- }
- .legend_dot.good {
- background: #FAC858;
- }
- .legend_dot.excellent {
- background: #3BA272;
- }
- .legend_text {
- color: #333;
- }
- }
- }
- }
- .chart {
- display: flex;
- justify-content: center;
- align-items: center;
- min-height: 590px;
- }
- }
- .knowledge_list {
- width: 100%;
- height: 410px;
- overflow-y: auto;
- padding-right: 10px;
- position: relative;
- &::-webkit-scrollbar {
- width: 6px;
- }
- &::-webkit-scrollbar-track {
- background: #f1f1f1;
- border-radius: 3px;
- }
- &::-webkit-scrollbar-thumb {
- background: #c1c1c1;
- border-radius: 3px;
- }
- &::-webkit-scrollbar-thumb:hover {
- background: #a8a8a8;
- }
- }
- }
- // 视图切换按钮样式
- .view_switcher_container {
- display: flex;
- gap: 20px;
- z-index: 10;
- justify-content: flex-end;
- font-size: 14px;
- .switch_btn {
- display: flex;
- align-items: center;
- padding: 0;
- background-color: transparent;
- border: none;
- border-radius: 0;
- font-size: 14px;
- transition: color 0.3s ease;
- cursor: pointer;
- font-weight: 400;
- color: #999999;
- &:hover {
- color: #2E64FA;
- }
- }
- .graph_btn {
- color: #2E64FA;
- }
- // 图标样式,确保与文字对齐
- .icon_switch_graph,
- .icon_switch_list {
- img {
- display: inline-block;
- vertical-align: middle;
- margin: 0;
- padding: 0;
- margin-top: -3px;
- }
- }
- // 鼠标悬停时图片换色效果
- .switch_btn:hover .icon_switch_graph img,
- .switch_btn:hover .icon_switch_list img {
- filter: brightness(0) saturate(100%) invert(34%) sepia(100%) saturate(5000%) hue-rotate(210deg) brightness(95%) contrast(100%);
- }
- }
- // vxe表格样式
- .list_content {
- border-radius: 10px 10px 10px 10px;
- .vxe-table {
- // 展开收起的样式
- :deep(.el-icon-circle-plus) {
- color: #DCDFE6 !important;
- font-size: 16px !important;
- }
- :deep(.el-icon-remove) {
- color: #2E64FA !important;
- font-size: 16px !important;
- }
- }
- /* 设置表格行高度和光标样式 */
- :deep {
- .vxe-table {
- border-radius: 8px;
- overflow-y: auto;
- overflow-x: hidden;
- --vxe-ui-table-row-height-default: 20px !important;
- // 表头样式
- .vxe-table--header {
- display: table-header-group !important;
- visibility: visible !important;
- opacity: 1 !important;
- height: 52px !important;
- }
- .vxe-table--header {
- .vxe-header--row {
- height: 52px !important;
- }
- }
- // 行样式
- .vxe-table--body {
- tbody {
- tr {
- border-bottom: 1px solid #F0F2F5;
- cursor: pointer !important;
- height: 20px !important;
- line-height: 20px !important;
- min-height: 20px !important;
- max-height: 20px !important;
- &:hover {
- background-color: #f5f7fa !important;
- }
- &.row_highlight {
- background-color: rgba(195, 219, 255, 0.2);
- cursor: pointer !important;
- }
- &.selected-row {
- background-color: rgba(195, 219, 255, 0.2) !important;
- color: #2E64FA !important;
- cursor: pointer !important;
- }
- }
- }
- }
- // 单元格样式
- .vxe-body--column,
- .vxe-header--column {
- padding: 0 !important;
- height: 20px !important;
- line-height: 20px !important;
- min-height: 20px !important;
- max-height: 20px !important;
- }
- .vxe-table--body {
- min-height: 0 !important;
- }
- /* 自定义树形图标样式 */
- .vxe-tree-icon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 14px;
- height: 14px;
- border-radius: 50%;
- margin-right: 6px;
- font-size: 12px;
- font-weight: bold;
- line-height: 1;
- }
- .vxe-tree-icon--minus {
- background-color: #409EFF;
- color: white;
- }
- .vxe-tree-icon--plus {
- background-color: #C0C4CC;
- color: white;
- }
- // 斑马纹
- .vxe-table--body {
- tr:nth-child(even) {
- background-color: #F5F7FA;
- }
- }
- }
- }
- .knowledge_item {
- display: flex;
- align-items: center;
- .knowledge_title {
- font-size: 14px;
- color: #606266;
- }
- }
- .rate_info {
- align-items: center;
- gap: 8px;
- justify-content: center;
- width: 80px;
- margin: 0 auto;
- position: relative;
- }
- .rate_dot {
- display: inline-block;
- width: 6px;
- height: 6px;
- border-radius: 50%;
- position: absolute;
- top: 8px;
- left: 2px;
- &.rate_excellent {
- background-color: #3BA272;
- }
- &.rate_good {
- background-color: #FAC858;
- }
- &.rate_weak {
- background-color: #EE6666;
- }
- }
- .rate_value {
- font-size: 14px;
- color: #606266;
- }
- }
- }
- }
- </style>
|