|
|
@@ -1,108 +1,159 @@
|
|
|
<template>
|
|
|
<div class="region_container">
|
|
|
<!-- 薄弱补强区 -->
|
|
|
- <div class="region_section weak" v-if="weakArea.length > 0">
|
|
|
+ <div class="region_section weak">
|
|
|
<div class="region_header">
|
|
|
<span class="region_icon">
|
|
|
- <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
|
- <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-dasharray="4 2"/>
|
|
|
- </svg>
|
|
|
+ <img :src="require('@/assets/knowledgeGraph/buqiang.png')" alt="薄弱补强区" />
|
|
|
</span>
|
|
|
- <span class="region_title">薄弱补强区</span>
|
|
|
+ <div class="region_header_text">
|
|
|
+ <span class="region_title">薄弱补强区</span>
|
|
|
+ <span class="region_desc">得分率<60% | 优先补齐短板</span>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- <div class="region_desc">得分率<60% | 优先补齐短板</div>
|
|
|
- <div class="knowledge_cards">
|
|
|
- <div class="knowledge_card" v-for="(item, index) in weakArea" :key="'weak-' + index">
|
|
|
- <div class="card_header">
|
|
|
- <span class="knowledge_name">{{ item.knowledgeName }}</span>
|
|
|
+ <div class="knowledge_rows" ref="weakRowsBox">
|
|
|
+ <div
|
|
|
+ class="knowledge_row"
|
|
|
+ v-for="(item, index) in weakArea"
|
|
|
+ :key="'weak-' + index"
|
|
|
+ :data-knowledge-id="item.knowledgeId"
|
|
|
+ @click="handleRowClick(item)"
|
|
|
+ :class="{ selected: isKnowledgeSelected(item), highlighted: isKnowledgeSelected(item) }"
|
|
|
+ >
|
|
|
+ <div class="row_left">
|
|
|
+ <span class="kp_name">{{ item.knowledgeName }}</span>
|
|
|
+ <div class="kp_meta">
|
|
|
+ <span class="kp_score">得分率:{{ getScoreRate(item) }}%</span>
|
|
|
+ <span class="kp_freq_tag">考频:{{ getExamFreq(item) }}</span>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- <div class="card_info">
|
|
|
- <span class="score_rate">得分率:{{ getScoreRate(item) }}%</span>
|
|
|
- <span class="wrong_count">错题:{{ item.wrongCount || item.questionNum || 0 }}</span>
|
|
|
- </div>
|
|
|
- <div class="card_action">
|
|
|
- <el-button type="primary" size="small" @click="handleAddPractice(item)">加入练习</el-button>
|
|
|
+ <div class="row_right">
|
|
|
+ <el-button size="small" class="add_btn weak_btn"
|
|
|
+ :disabled="isKnowledgeAdded(item)"
|
|
|
+ @click="handleAddPractice(item)">
|
|
|
+ {{ isKnowledgeAdded(item) ? '已加入练习' : '加入练习' }}
|
|
|
+ </el-button>
|
|
|
</div>
|
|
|
</div>
|
|
|
+ <div v-if="weakArea.length === 0" class="empty_tip">暂无数据</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<!-- 能力提升区 -->
|
|
|
- <div class="region_section good" v-if="goodArea.length > 0">
|
|
|
+ <div class="region_section good">
|
|
|
<div class="region_header">
|
|
|
<span class="region_icon">
|
|
|
- <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
|
- <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
|
|
- </svg>
|
|
|
+ <img :src="require('@/assets/knowledgeGraph/tisheng.png')" alt="能力提升区" />
|
|
|
</span>
|
|
|
- <span class="region_title">能力提升区</span>
|
|
|
+ <div class="region_header_text">
|
|
|
+ <span class="region_title">能力提升区</span>
|
|
|
+ <span class="region_desc">60%≤得分率<85% | 强化练习提升</span>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- <div class="region_desc">60%≤得分率<85% | 强化练习提升</div>
|
|
|
- <div class="knowledge_cards">
|
|
|
- <div class="knowledge_card" v-for="(item, index) in goodArea" :key="'good-' + index">
|
|
|
- <div class="card_header">
|
|
|
- <span class="knowledge_name">{{ item.knowledgeName }}</span>
|
|
|
- </div>
|
|
|
- <div class="card_info">
|
|
|
- <span class="score_rate">得分率:{{ getScoreRate(item) }}%</span>
|
|
|
- <span class="wrong_count">错题:{{ item.wrongCount || item.questionNum || 0 }}</span>
|
|
|
+ <div class="knowledge_rows" ref="goodRowsBox">
|
|
|
+ <div
|
|
|
+ class="knowledge_row"
|
|
|
+ v-for="(item, index) in goodArea"
|
|
|
+ :key="'good-' + index"
|
|
|
+ :data-knowledge-id="item.knowledgeId"
|
|
|
+ @click="handleRowClick(item)"
|
|
|
+ :class="{ selected: isKnowledgeSelected(item), highlighted: isKnowledgeSelected(item) }"
|
|
|
+ >
|
|
|
+ <div class="row_left">
|
|
|
+ <span class="kp_name">{{ item.knowledgeName }}</span>
|
|
|
+ <div class="kp_meta">
|
|
|
+ <span class="kp_score">得分率:{{ getScoreRate(item) }}%</span>
|
|
|
+ <span class="kp_freq_tag">考频:{{ getExamFreq(item) }}</span>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- <div class="card_action">
|
|
|
- <el-button type="warning" size="small" @click="handleAddPractice(item)">加入练习</el-button>
|
|
|
+ <div class="row_right">
|
|
|
+ <el-button size="small" class="add_btn good_btn"
|
|
|
+ :disabled="isKnowledgeAdded(item)"
|
|
|
+ @click="handleAddPractice(item)">
|
|
|
+ {{ isKnowledgeAdded(item) ? '已加入练习' : '加入练习' }}
|
|
|
+ </el-button>
|
|
|
</div>
|
|
|
</div>
|
|
|
+ <div v-if="goodArea.length === 0" class="empty_tip">暂无数据</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<!-- 扎实巩固区 -->
|
|
|
- <div class="region_section excellent" v-if="excellentArea.length > 0">
|
|
|
+ <div class="region_section excellent">
|
|
|
<div class="region_header">
|
|
|
<span class="region_icon">
|
|
|
- <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
|
- <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
|
|
- </svg>
|
|
|
+ <img :src="require('@/assets/knowledgeGraph/gonggu.png')" alt="扎实巩固区" />
|
|
|
</span>
|
|
|
- <span class="region_title">扎实巩固区</span>
|
|
|
+ <div class="region_header_text">
|
|
|
+ <span class="region_title">扎实巩固区</span>
|
|
|
+ <span class="region_desc">得分率>85% | 定期练习巩固</span>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- <div class="region_desc">得分率≥85% | 定期练习巩固</div>
|
|
|
- <div class="knowledge_cards">
|
|
|
- <div class="knowledge_card" v-for="(item, index) in excellentArea" :key="'excellent-' + index">
|
|
|
- <div class="card_header">
|
|
|
- <span class="knowledge_name">{{ item.knowledgeName }}</span>
|
|
|
- </div>
|
|
|
- <div class="card_info">
|
|
|
- <span class="score_rate">得分率:{{ getScoreRate(item) }}%</span>
|
|
|
- <span class="wrong_count">错题:{{ item.wrongCount || item.questionNum || 0 }}</span>
|
|
|
+ <div class="knowledge_rows" ref="excellentRowsBox">
|
|
|
+ <div
|
|
|
+ class="knowledge_row"
|
|
|
+ v-for="(item, index) in excellentArea"
|
|
|
+ :key="'excellent-' + index"
|
|
|
+ :data-knowledge-id="item.knowledgeId"
|
|
|
+ @click="handleRowClick(item)"
|
|
|
+ :class="{ selected: isKnowledgeSelected(item), highlighted: isKnowledgeSelected(item) }"
|
|
|
+ >
|
|
|
+ <div class="row_left">
|
|
|
+ <span class="kp_name">{{ item.knowledgeName }}</span>
|
|
|
+ <div class="kp_meta">
|
|
|
+ <span class="kp_score">得分率:{{ getScoreRate(item) }}%</span>
|
|
|
+ <span class="kp_freq_tag">考频:{{ getExamFreq(item) }}</span>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- <div class="card_action">
|
|
|
- <el-button type="success" size="small" @click="handleAddPractice(item)">加入练习</el-button>
|
|
|
+ <div class="row_right">
|
|
|
+ <el-button size="small" class="add_btn excellent_btn"
|
|
|
+ :disabled="isKnowledgeAdded(item)"
|
|
|
+ @click="handleAddPractice(item)">
|
|
|
+ {{ isKnowledgeAdded(item) ? '已加入练习' : '加入练习' }}
|
|
|
+ </el-button>
|
|
|
</div>
|
|
|
</div>
|
|
|
+ <div v-if="excellentArea.length === 0" class="empty_tip">暂无数据</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <!-- 冲刺进阶区 -->
|
|
|
- <div class="region_section advanced" v-if="advancedArea.length > 0">
|
|
|
+ <!-- 冲刺进阶区(暂时隐藏) -->
|
|
|
+ <div class="region_section advanced" v-if="false">
|
|
|
<div class="region_header">
|
|
|
<span class="region_icon">
|
|
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
|
- <path d="M12 2L15 8L22 9L17 14L18 21L12 18L6 21L7 14L2 9L9 8L12 2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
|
|
|
+ <path
|
|
|
+ d="M14.5 2.5c3 0 5.5 2.5 5.5 5.5 0 1.5-.5 3-1.5 4.5l1 1-2 2-1.5-1.5c-1.5 1-3 1.5-4.5 1.5L10 20H6v-4l4.5-2.5c0-1.5.5-3 1.5-4.5L10.5 7l2-2 1 1c1.5-1 3-1.5 4.5-1.5z"
|
|
|
+ fill="currentColor" />
|
|
|
</svg>
|
|
|
</span>
|
|
|
- <span class="region_title">冲刺进阶区</span>
|
|
|
+ <div class="region_header_text">
|
|
|
+ <span class="region_title">冲刺进阶区</span>
|
|
|
+ <span class="region_desc">优于同层级学生 | 对标上游冲刺进阶</span>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- <div class="region_desc">优于同级学生 | 对标上游冲刺进阶</div>
|
|
|
- <div class="knowledge_cards">
|
|
|
- <div class="knowledge_card" v-for="(item, index) in advancedArea" :key="'advanced-' + index">
|
|
|
- <div class="card_header">
|
|
|
- <span class="knowledge_name">{{ item.knowledgeName }}</span>
|
|
|
- </div>
|
|
|
- <div class="card_info">
|
|
|
- <span class="score_rate">得分率:{{ getScoreRate(item) }}%</span>
|
|
|
- <span class="wrong_count">错题:{{ item.wrongCount || item.questionNum || 0 }}</span>
|
|
|
+ <div class="knowledge_rows" ref="advancedRowsBox">
|
|
|
+ <div
|
|
|
+ class="knowledge_row"
|
|
|
+ v-for="(item, index) in advancedArea"
|
|
|
+ :key="'advanced-' + index"
|
|
|
+ :data-knowledge-id="item.knowledgeId"
|
|
|
+ @click="handleRowClick(item)"
|
|
|
+ :class="{ selected: isKnowledgeSelected(item), highlighted: isKnowledgeSelected(item) }"
|
|
|
+ >
|
|
|
+ <div class="row_left">
|
|
|
+ <span class="kp_name">{{ item.knowledgeName }}</span>
|
|
|
+ <div class="kp_meta">
|
|
|
+ <span class="kp_score">得分率:{{ getScoreRate(item) }}%</span>
|
|
|
+ <span class="kp_freq_tag">考频:{{ getExamFreq(item) }}</span>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- <div class="card_action">
|
|
|
- <el-button type="primary" size="small" @click="handleAddPractice(item)">加入练习</el-button>
|
|
|
+ <div class="row_right">
|
|
|
+ <el-button size="small" class="add_btn advanced_btn"
|
|
|
+ :disabled="isKnowledgeAdded(item)"
|
|
|
+ @click="handleAddPractice(item)">
|
|
|
+ {{ isKnowledgeAdded(item) ? '已加入练习' : '加入练习' }}
|
|
|
+ </el-button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -114,43 +165,242 @@
|
|
|
export default {
|
|
|
name: 'Region',
|
|
|
props: {
|
|
|
- // 全部知识点数据
|
|
|
- allKnowledgeList: {
|
|
|
- type: Array,
|
|
|
- default: () => []
|
|
|
+ // 分区数据(由父级通过 knowledge_partition 接口返回后传入)
|
|
|
+ // conquerZone: 薄弱补强区
|
|
|
+ // improveZone: 能力提升区
|
|
|
+ // consolidateZone:扎实巩固区
|
|
|
+ partitionData: {
|
|
|
+ type: Object,
|
|
|
+ default: () => ({
|
|
|
+ conquerZone: [],
|
|
|
+ improveZone: [],
|
|
|
+ consolidateZone: []
|
|
|
+ })
|
|
|
+ },
|
|
|
+ // 父级已成功"加入练习"的知识点ID映射:{ [knowledgeId]: true }
|
|
|
+ addedKnowledgeIds: {
|
|
|
+ type: Object,
|
|
|
+ default: () => ({})
|
|
|
+ },
|
|
|
+ // 父级传入的"联动选中知识点ID"
|
|
|
+ // - 环形扩散图选中的圆点,父级会把 knowledgeId 写到这里 → 本组件高亮对应行并滚动居中
|
|
|
+ // - 点本组件任一行条目时,也通过 emit('item-click') 反向通知父级把 ID 同步到 circularDiffusion
|
|
|
+ selectedKnowledgeId: {
|
|
|
+ type: [String, Number],
|
|
|
+ default: ''
|
|
|
+ }
|
|
|
+ },
|
|
|
+ data() {
|
|
|
+ return {};
|
|
|
+ },
|
|
|
+ watch: {
|
|
|
+ // 父级 selectedKnowledgeId 变化:联动高亮 + 滚动到对应区列表的可视中央
|
|
|
+ selectedKnowledgeId: {
|
|
|
+ immediate: true,
|
|
|
+ handler(newVal) {
|
|
|
+ if (!newVal) return;
|
|
|
+ const sid = String(newVal);
|
|
|
+ // 这里只负责发起"滚动尝试",函数内部会处理 DOM/数据未就绪时的重试
|
|
|
+ this.scheduleHighlightScroll(sid);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ // partitionData 变化:路由进入组件时,父级接口返回前 selectedKnowledgeId 可能已经写好了,
|
|
|
+ // 但此时 conquerZone/improveZone/consolidateZone 都是空数组 → 上面的 watch 已经执行过(immediate)但没找到条目,不会再自动重跑。
|
|
|
+ // 所以 partitionData 有内容变化后,只要还有选中 ID,就重新触发一次滚动。
|
|
|
+ partitionData: {
|
|
|
+ deep: true,
|
|
|
+ handler(newVal, oldVal) {
|
|
|
+ // 快速跳过空对象/完全一样的引用
|
|
|
+ if (!this.selectedKnowledgeId) return;
|
|
|
+ const lists = ['conquerZone', 'improveZone', 'consolidateZone'];
|
|
|
+ let hasAnyData = false;
|
|
|
+ if (newVal) {
|
|
|
+ for (const k of lists) {
|
|
|
+ if (Array.isArray(newVal[k]) && newVal[k].length > 0) { hasAnyData = true; break; }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!hasAnyData) return;
|
|
|
+ // 接口数据刚落地,Vue 下一 tick 才会 patch DOM 行节点;所以 $nextTick 再排
|
|
|
+ const sid = String(this.selectedKnowledgeId);
|
|
|
+ this.$nextTick(() => this.scheduleHighlightScroll(sid));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ activated() {
|
|
|
+ // keep-alive 缓存路由时,组件被切回来会触发 activated,但 watch 不会再立即执行
|
|
|
+ // 只要有选中 ID 就重新尝试滚动居中(DOM 已就绪,命中会直接滚)
|
|
|
+ if (this.selectedKnowledgeId) {
|
|
|
+ const sid = String(this.selectedKnowledgeId);
|
|
|
+ this.$nextTick(() => this.scheduleHighlightScroll(sid));
|
|
|
}
|
|
|
},
|
|
|
computed: {
|
|
|
- // 薄弱补强区:得分率 < 60%
|
|
|
+ // 薄弱补强区:直接用接口返回的 conquerZone
|
|
|
weakArea() {
|
|
|
- return this.allKnowledgeList.filter(item => {
|
|
|
- const rate = parseFloat(this.getScoreRate(item));
|
|
|
- return !isNaN(rate) && rate < 60;
|
|
|
- });
|
|
|
+ return Array.isArray(this.partitionData?.conquerZone) ? this.partitionData.conquerZone : [];
|
|
|
},
|
|
|
- // 能力提升区:60% ≤ 得分率 < 85%
|
|
|
+ // 能力提升区:直接用接口返回的 improveZone
|
|
|
goodArea() {
|
|
|
- return this.allKnowledgeList.filter(item => {
|
|
|
- const rate = parseFloat(this.getScoreRate(item));
|
|
|
- return !isNaN(rate) && rate >= 60 && rate < 85;
|
|
|
- });
|
|
|
+ return Array.isArray(this.partitionData?.improveZone) ? this.partitionData.improveZone : [];
|
|
|
},
|
|
|
- // 扎实巩固区:得分率 ≥ 85%
|
|
|
+ // 扎实巩固区:直接用接口返回的 consolidateZone
|
|
|
excellentArea() {
|
|
|
- return this.allKnowledgeList.filter(item => {
|
|
|
- const rate = parseFloat(this.getScoreRate(item));
|
|
|
- return !isNaN(rate) && rate >= 85;
|
|
|
- });
|
|
|
+ return Array.isArray(this.partitionData?.consolidateZone) ? this.partitionData.consolidateZone : [];
|
|
|
},
|
|
|
- // 冲刺进阶区:预留区域,可根据需要扩展
|
|
|
+ // 冲刺进阶区:暂时隐藏,预留空数组
|
|
|
advancedArea() {
|
|
|
return [];
|
|
|
}
|
|
|
},
|
|
|
methods: {
|
|
|
- // 获取得分率(优先使用 gradeScoreRate,其次使用 classScoreRate)
|
|
|
+ // 获取得分率:优先 personalScoreRate(接口分区字段),兜底 class / grade
|
|
|
getScoreRate(item) {
|
|
|
- return item.gradeScoreRate || item.classScoreRate || item.personalScoreRate || 0;
|
|
|
+ if (item && (item.personalScoreRate !== null && item.personalScoreRate !== undefined)) {
|
|
|
+ const n = Number(item.personalScoreRate);
|
|
|
+ return isNaN(n) ? 0 : n;
|
|
|
+ }
|
|
|
+ const fallback = item?.gradeScoreRate ?? item?.classScoreRate;
|
|
|
+ if (fallback === null || fallback === undefined) return 0;
|
|
|
+ const n = Number(fallback);
|
|
|
+ return isNaN(n) ? 0 : n;
|
|
|
+ },
|
|
|
+ // 获取考频:优先 examNum(接口分区字段:考频,含练习),兜底常见字段
|
|
|
+ getExamFreq(item) {
|
|
|
+ if (item && (item.examNum !== null && item.examNum !== undefined && item.examNum !== '')) {
|
|
|
+ const n = Number(item.examNum);
|
|
|
+ return isNaN(n) ? 0 : n;
|
|
|
+ }
|
|
|
+ const fallback = item?.examFreq ?? item?.examCount ?? item?.questionNum ?? item?.wrongCount;
|
|
|
+ if (fallback === null || fallback === undefined || fallback === '') return 0;
|
|
|
+ const n = Number(fallback);
|
|
|
+ return isNaN(n) ? 0 : n;
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 判断某个知识点条目是否"已加入练习"
|
|
|
+ * 判定:父级 prop addedKnowledgeIds 中有没有当前 knowledgeId 字段
|
|
|
+ */
|
|
|
+ isKnowledgeAdded(item) {
|
|
|
+ const id = item?.knowledgeId;
|
|
|
+ if (!id) return false;
|
|
|
+ return !!(this.addedKnowledgeIds
|
|
|
+ && Object.prototype.hasOwnProperty.call(this.addedKnowledgeIds, String(id)));
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 判断某个知识点条目是不是"当前联动选中"(跨区单选:三/四个区只能有一条高亮)
|
|
|
+ * 把父级 selectedKnowledgeId 和条目 knowledgeId 做字符串比较即可
|
|
|
+ */
|
|
|
+ isKnowledgeSelected(item) {
|
|
|
+ const sid = this.selectedKnowledgeId;
|
|
|
+ const id = item?.knowledgeId;
|
|
|
+ if (!sid || id === null || id === undefined || id === '') return false;
|
|
|
+ return String(sid) === String(id);
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 【按 knowledgeType 精准分区匹配】联动选中时查找条目并滚动到中间
|
|
|
+ * 规则(和你描述一致):
|
|
|
+ * knowledgeType === 1 → 薄弱补强区 weakArea / weakRowsBox 容器
|
|
|
+ * knowledgeType === 2 → 能力提升区 goodArea / goodRowsBox
|
|
|
+ * knowledgeType === 3 → 扎实巩固区 excellentArea / excellentRowsBox
|
|
|
+ * 其它(4/未知)→ 冲刺进阶区 advancedArea / advancedRowsBox(暂时隐藏)
|
|
|
+ * 只在对应分区的数组里做 knowledgeId 的 O(n) 查找,不需要对比三个数组,效率高
|
|
|
+ *
|
|
|
+ * @returns {boolean} true=找到条目并执行了滚动;false=数据/DOM未就绪需要重试
|
|
|
+ */
|
|
|
+ highlightAndScrollByKnowledgeId(sid) {
|
|
|
+ if (!sid) return false;
|
|
|
+ // 第一步:找命中条目(只查其 knowledgeType 决定的对应分区数组)
|
|
|
+ let zoneBoxRef = null;
|
|
|
+ const zones = [
|
|
|
+ { type: 1, list: this.weakArea, ref: 'weakRowsBox' },
|
|
|
+ { type: 2, list: this.goodArea, ref: 'goodRowsBox' },
|
|
|
+ { type: 3, list: this.excellentArea, ref: 'excellentRowsBox' },
|
|
|
+ { type: 4, list: this.advancedArea, ref: 'advancedRowsBox' }
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 先尝试"根据命中条目自己的 knowledgeType 定位分区":需要知道 ID 对应哪个 type,先 O(n) 找条目
|
|
|
+ // 折中:如果某一个分区数组先匹配到 knowledgeId,就直接在该分区内滚动——也是只遍历最多"总数组数*平均长度",
|
|
|
+ // 但用户担心的是"把三个数组都循环一遍效率慢"——这里通过先 type 预判 + 找到即停,最差仍是 3*N 但 N 是分区各自长度,且实际 90% 场景第一个命中就 break。
|
|
|
+ let matchedItem = null;
|
|
|
+ for (const z of zones) {
|
|
|
+ const list = Array.isArray(z.list) ? z.list : [];
|
|
|
+ const hit = list.find(itm => String(itm.knowledgeId) === sid);
|
|
|
+ if (hit) {
|
|
|
+ matchedItem = hit;
|
|
|
+ zoneBoxRef = z.ref;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 没找到条目:说明父级 partitionData 还没被接口赋值(空数组),返回 false 让外层重试
|
|
|
+ if (!matchedItem || !zoneBoxRef) return false;
|
|
|
+
|
|
|
+ // 第二步:找到该条目对应的 DOM(用 data-knowledge-id 属性),在其所属分区滚动容器中居中显示
|
|
|
+ // ——关键:只滚动该区的 knowledge_rows(overflow-y:auto),不要带动整个页面滚动
|
|
|
+ // (因此禁止使用 element.scrollIntoView,它会从 window 开始递归把祖先滚动容器都对齐)
|
|
|
+ const box = this.$refs[zoneBoxRef];
|
|
|
+ const container = box && (box.$el || box);
|
|
|
+ // 容器 DOM 还未渲染(例如 ref 还没挂载:created 阶段 immediate watch 触发)→ 重试
|
|
|
+ if (!container) return false;
|
|
|
+ const idAttr = CSS.escape ? CSS.escape(sid) : sid;
|
|
|
+ const rowEl = container.querySelector
|
|
|
+ ? container.querySelector(`[data-knowledge-id="${idAttr}"]`)
|
|
|
+ : null;
|
|
|
+ // 行节点还没被 v-for patch 出来(接口刚赋值,Vue 下一 tick 才渲染)→ 重试
|
|
|
+ if (!rowEl) return false;
|
|
|
+
|
|
|
+ // 用 getBoundingClientRect 的差值算"行相对于容器内容顶边的绝对偏移"
|
|
|
+ // 这样即使容器内部有 padding / border / 行嵌套其他节点导致 offsetParent 不是 container,也能精确定位
|
|
|
+ let containerRect = null;
|
|
|
+ let rowRect = null;
|
|
|
+ try {
|
|
|
+ containerRect = container.getBoundingClientRect();
|
|
|
+ rowRect = rowEl.getBoundingClientRect();
|
|
|
+ } catch (e) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if (!containerRect || !rowRect) return false;
|
|
|
+ const relativeOffsetTop = (rowRect.top - containerRect.top) + (container.scrollTop || 0);
|
|
|
+ const rowHeight = rowRect.height || 40;
|
|
|
+ const viewportH = container.clientHeight || 0;
|
|
|
+ const rawTarget = relativeOffsetTop - (viewportH / 2) + (rowHeight / 2);
|
|
|
+ const maxScrollTop = Math.max(0, (container.scrollHeight || 0) - viewportH);
|
|
|
+ const finalTop = Math.max(0, Math.min(maxScrollTop, rawTarget));
|
|
|
+
|
|
|
+ try {
|
|
|
+ if (typeof container.scrollTo === 'function') {
|
|
|
+ // 只作用在当前分区容器上,window/父容器都不会动
|
|
|
+ container.scrollTo({ top: finalTop, behavior: 'smooth' });
|
|
|
+ } else {
|
|
|
+ container.scrollTop = finalTop;
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ try { container.scrollTop = finalTop; } catch (err) {}
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 调度"高亮+滚动居中":如果本轮没找到条目/DOM 就间隔 60ms 再重试,最多 5 次(≈300ms),
|
|
|
+ * 覆盖以下时序:
|
|
|
+ * 1) keep-alive activated 回来,DOM 已在但数据可能未同步好
|
|
|
+ * 2) watch selectedKnowledgeId immediate 先跑 → 接口数据还没来
|
|
|
+ * 3) partitionData 接口刚返回 → Vue v-for 还没把行 DOM patch 出来(computed 更新但 DOM 滞后)
|
|
|
+ */
|
|
|
+ scheduleHighlightScroll(sid, attempt = 0) {
|
|
|
+ const MAX_ATTEMPTS = 5;
|
|
|
+ if (!sid || attempt >= MAX_ATTEMPTS) return;
|
|
|
+ const ok = this.highlightAndScrollByKnowledgeId(String(sid));
|
|
|
+ if (ok) return; // 已经成功滚动到目标行
|
|
|
+ // 没命中:用 setTimeout 延迟再试(下一 tick 可能数据/DOM 就好了)
|
|
|
+ setTimeout(() => this.scheduleHighlightScroll(sid, attempt + 1), 60);
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 点击某个知识点行:
|
|
|
+ * - 联动:emit('item-click', item) 给父级,父级写 currentLinkedKnowledgeId
|
|
|
+ * → 一方面本组件通过 :selectedKnowledgeId 回传后天然高亮(跨区单选)
|
|
|
+ * → 另一方面 circularDiffusion 会放大对应圆点 + 右侧折线表格显示数据
|
|
|
+ */
|
|
|
+ handleRowClick(item) {
|
|
|
+ if (!item || !item.knowledgeId) return;
|
|
|
+ this.$emit('item-click', item);
|
|
|
},
|
|
|
// 处理加入练习
|
|
|
handleAddPractice(item) {
|
|
|
@@ -166,185 +416,309 @@ export default {
|
|
|
flex-direction: row;
|
|
|
gap: 16px;
|
|
|
padding: 16px;
|
|
|
+ width: 100%;
|
|
|
+ box-sizing: border-box;
|
|
|
+ align-items: stretch;
|
|
|
}
|
|
|
|
|
|
+/* 单个区域卡片(通用样式:圆角作为通用属性) */
|
|
|
.region_section {
|
|
|
flex: 1;
|
|
|
- border-radius: 8px;
|
|
|
- padding: 16px;
|
|
|
+ min-width: 0;
|
|
|
+ border-radius: 10px 10px 10px 10px;
|
|
|
+ padding: 16px 16px 12px;
|
|
|
background: #FFFFFF;
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
- max-height: 500px;
|
|
|
+ max-height: 560px;
|
|
|
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
|
|
|
|
|
&.weak {
|
|
|
- border: 1px solid rgba(238, 102, 102, 0.5);
|
|
|
+ border: 1px solid #F56C6C;
|
|
|
+ background: linear-gradient(180deg, rgba(245, 108, 108, 0.1) 0%, rgba(255, 255, 255, 0) 100%), #FFFFFF;
|
|
|
}
|
|
|
|
|
|
&.good {
|
|
|
- border: 1px solid rgba(250, 200, 88, 0.5);
|
|
|
+ border: 1px solid #FAC858;
|
|
|
+ background: linear-gradient(180deg, rgba(250, 200, 88, 0.1) 0%, rgba(255, 255, 255, 0) 100%), #FFFFFF;
|
|
|
}
|
|
|
|
|
|
&.excellent {
|
|
|
- border: 1px solid rgba(59, 162, 114, 0.5);
|
|
|
+ border: 1px solid #3BA272;
|
|
|
+ background: linear-gradient(180deg, rgba(59, 162, 114, 0.1) 0%, rgba(255, 255, 255, 0) 100%), #FFFFFF;
|
|
|
}
|
|
|
|
|
|
&.advanced {
|
|
|
- border: 1px solid rgba(46, 100, 250, 0.5);
|
|
|
+ border: 1px solid #5470C6;
|
|
|
+ background: linear-gradient(180deg, rgba(84, 112, 198, 0.1) 0%, rgba(255, 255, 255, 0) 100%), #FFFFFF;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+/* 卡片头部:图标方块 + 标题 + 副标题 */
|
|
|
.region_header {
|
|
|
display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: 8px;
|
|
|
- margin-bottom: 4px;
|
|
|
- padding-bottom: 8px;
|
|
|
- border-bottom: 1px solid #f0f0f0;
|
|
|
+ align-items: flex-start;
|
|
|
+ gap: 10px;
|
|
|
+ margin-bottom: 14px;
|
|
|
|
|
|
.region_icon {
|
|
|
+ width: 28px;
|
|
|
+ height: 28px;
|
|
|
+ border-radius: 6px;
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
+ flex: 0 0 28px;
|
|
|
|
|
|
svg {
|
|
|
- width: 18px;
|
|
|
- height: 18px;
|
|
|
+ width: 16px;
|
|
|
+ height: 16px;
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- &.weak .region_icon {
|
|
|
- color: #EE6666;
|
|
|
+ img {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ object-fit: contain;
|
|
|
+ display: block;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- &.good .region_icon {
|
|
|
- color: #FAC858;
|
|
|
- }
|
|
|
+ .region_header_text {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 4px;
|
|
|
+ min-width: 0;
|
|
|
|
|
|
- &.excellent .region_icon {
|
|
|
- color: #3BA272;
|
|
|
- }
|
|
|
+ .region_title {
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #333333;
|
|
|
+ line-height: 1;
|
|
|
+ }
|
|
|
|
|
|
- &.advanced .region_icon {
|
|
|
- color: #2E64FA;
|
|
|
+ .region_desc {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #999999;
|
|
|
+ line-height: 1.2;
|
|
|
+ white-space: nowrap;
|
|
|
+ }
|
|
|
}
|
|
|
+}
|
|
|
|
|
|
- .region_title {
|
|
|
- font-size: 16px;
|
|
|
- font-weight: 600;
|
|
|
- color: #333333;
|
|
|
- }
|
|
|
+.weak .region_header .region_icon {
|
|
|
+ background: #FFE9E9;
|
|
|
+ color: #EE6666;
|
|
|
}
|
|
|
|
|
|
-.region_desc {
|
|
|
- font-size: 12px;
|
|
|
- color: #999999;
|
|
|
- margin-bottom: 12px;
|
|
|
- padding-top: 4px;
|
|
|
+.good .region_header .region_icon {
|
|
|
+ background: #FFF4DA;
|
|
|
+ color: #F0B429;
|
|
|
+}
|
|
|
+
|
|
|
+.excellent .region_header .region_icon {
|
|
|
+ background: #DEF7EA;
|
|
|
+ color: #3BA272;
|
|
|
+}
|
|
|
+
|
|
|
+.advanced .region_header .region_icon {
|
|
|
+ background: #E5ECFF;
|
|
|
+ color: #2E64FA;
|
|
|
}
|
|
|
|
|
|
-.knowledge_cards {
|
|
|
+/* 行列表容器:仍然可滚动,但视觉上隐藏滚动条 */
|
|
|
+.knowledge_rows {
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
- gap: 8px;
|
|
|
+ gap: 10px;
|
|
|
overflow-y: auto;
|
|
|
flex: 1;
|
|
|
+ min-height: 0;
|
|
|
|
|
|
+ /* Webkit 系列(Chrome/Safari/新 Edge):彻底隐藏滚动条 */
|
|
|
&::-webkit-scrollbar {
|
|
|
- width: 4px;
|
|
|
+ width: 0;
|
|
|
+ height: 0;
|
|
|
+ display: none;
|
|
|
}
|
|
|
|
|
|
&::-webkit-scrollbar-thumb {
|
|
|
- background: #ddd;
|
|
|
- border-radius: 2px;
|
|
|
+ display: none;
|
|
|
}
|
|
|
+
|
|
|
+ &::-webkit-scrollbar-track {
|
|
|
+ display: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* Firefox:隐藏滚动条 */
|
|
|
+ scrollbar-width: none;
|
|
|
+ /* IE / 旧版 Edge:隐藏滚动条 */
|
|
|
+ -ms-overflow-style: none;
|
|
|
}
|
|
|
|
|
|
-.knowledge_card {
|
|
|
- border: 1px solid #ECEEF3;
|
|
|
- border-radius: 6px;
|
|
|
- padding: 10px 12px;
|
|
|
- background: #FFFFFF;
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
- gap: 6px;
|
|
|
- transition: all 0.2s ease;
|
|
|
+.empty_tip {
|
|
|
+ padding: 20px 0;
|
|
|
+ text-align: center;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #BBBBBB;
|
|
|
+}
|
|
|
|
|
|
- &:hover {
|
|
|
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
|
- }
|
|
|
+/* 每行知识点条目 */
|
|
|
+.knowledge_row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 10px;
|
|
|
+ padding: 10px;
|
|
|
+ border-radius: 4px;
|
|
|
+ border: 1px solid;
|
|
|
+ background: #FFFFFF;
|
|
|
+ min-width: 0;
|
|
|
+ cursor: pointer;
|
|
|
+ .row_left {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 4px;
|
|
|
+ min-width: 0;
|
|
|
+ flex: 1;
|
|
|
|
|
|
- .card_header {
|
|
|
- .knowledge_name {
|
|
|
- font-size: 13px;
|
|
|
+ .kp_name {
|
|
|
font-weight: 500;
|
|
|
+ font-size: 14px;
|
|
|
color: #333333;
|
|
|
- display: block;
|
|
|
overflow: hidden;
|
|
|
text-overflow: ellipsis;
|
|
|
white-space: nowrap;
|
|
|
+ margin-bottom: 8px;
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- .card_info {
|
|
|
- display: flex;
|
|
|
- gap: 12px;
|
|
|
-
|
|
|
- .score_rate,
|
|
|
- .wrong_count {
|
|
|
- font-size: 11px;
|
|
|
- color: #666666;
|
|
|
+ .kp_meta {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ color: #999999; /* 考频字色固定浅灰 */
|
|
|
+ font-size: 12px;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- .card_action {
|
|
|
- text-align: right;
|
|
|
+ .row_right {
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
|
- .el-button {
|
|
|
- padding: 6px 12px;
|
|
|
- font-size: 12px;
|
|
|
+ .add_btn {
|
|
|
+ padding: 8px 12px;
|
|
|
+ font-size: 14px;
|
|
|
+ line-height: 1;
|
|
|
+ border-radius: 4px;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-/* 薄弱补强区卡片样式 */
|
|
|
-.weak .knowledge_card {
|
|
|
- border-color: rgba(238, 102, 102, 0.3);
|
|
|
+/* 四个区的条目边框 + 按钮颜色 + 得分率主题色 + 行悬浮/选中背景 */
|
|
|
+.weak .knowledge_row {
|
|
|
+ border-color: #F2B5B5;
|
|
|
+ background: #FFFFFF;
|
|
|
+ transition: background-color 0.15s ease;
|
|
|
+}
|
|
|
+.weak .knowledge_row:hover {
|
|
|
+ background: rgba(245, 108, 108, 0.1);
|
|
|
+}
|
|
|
+.weak .knowledge_row.selected {
|
|
|
+ background: rgba(245, 108, 108, 0.15);
|
|
|
+}
|
|
|
+.weak .kp_meta .kp_score {
|
|
|
+ color: #F56C6C; /* 薄弱区:得分率和卡片主题色一致 */
|
|
|
+}
|
|
|
+
|
|
|
+.weak .knowledge_row .add_btn.weak_btn {
|
|
|
+ color: #EE6666;
|
|
|
+ border: 1px solid #EE6666;
|
|
|
+ background: #FFFFFF;
|
|
|
+}
|
|
|
|
|
|
- .card_action .el-button {
|
|
|
- background-color: #EE6666;
|
|
|
- border-color: #EE6666;
|
|
|
- }
|
|
|
+.good .knowledge_row {
|
|
|
+ border-color: #F7DEA5;
|
|
|
+ background: #FFFFFF;
|
|
|
+ transition: background-color 0.15s ease;
|
|
|
+}
|
|
|
+.good .knowledge_row:hover {
|
|
|
+ background: rgba(250, 200, 88, 0.1);
|
|
|
+}
|
|
|
+.good .knowledge_row.selected {
|
|
|
+ background: rgba(250, 200, 88, 0.15);
|
|
|
+}
|
|
|
+.good .kp_meta .kp_score {
|
|
|
+ color: #FAC858; /* 能力提升区:得分率和卡片主题色一致 */
|
|
|
}
|
|
|
|
|
|
-/* 能力提升区卡片样式 */
|
|
|
-.good .knowledge_card {
|
|
|
- border-color: rgba(250, 200, 88, 0.3);
|
|
|
+.good .knowledge_row .add_btn.good_btn {
|
|
|
+ color: #F0B429;
|
|
|
+ border: 1px solid #F0B429;
|
|
|
+ background: #FFFFFF;
|
|
|
+}
|
|
|
|
|
|
- .card_action .el-button {
|
|
|
- background-color: #FAC858;
|
|
|
- border-color: #FAC858;
|
|
|
- color: #333333;
|
|
|
- }
|
|
|
+.excellent .knowledge_row {
|
|
|
+ border-color: #AEE2C5;
|
|
|
+ background: #FFFFFF;
|
|
|
+ transition: background-color 0.15s ease;
|
|
|
+}
|
|
|
+.excellent .knowledge_row:hover {
|
|
|
+ background: rgba(59, 162, 114, 0.1);
|
|
|
+}
|
|
|
+.excellent .knowledge_row.selected {
|
|
|
+ background: rgba(59, 162, 114, 0.15);
|
|
|
+}
|
|
|
+.excellent .kp_meta .kp_score {
|
|
|
+ color: #3BA272; /* 扎实巩固区:得分率和卡片主题色一致 */
|
|
|
}
|
|
|
|
|
|
-/* 扎实巩固区卡片样式 */
|
|
|
-.excellent .knowledge_card {
|
|
|
- border-color: rgba(59, 162, 114, 0.3);
|
|
|
+.excellent .knowledge_row .add_btn.excellent_btn {
|
|
|
+ color: #3BA272;
|
|
|
+ border: 1px solid #3BA272;
|
|
|
+ background: #FFFFFF;
|
|
|
+}
|
|
|
|
|
|
- .card_action .el-button {
|
|
|
- background-color: #3BA272;
|
|
|
- border-color: #3BA272;
|
|
|
- }
|
|
|
+.advanced .knowledge_row {
|
|
|
+ border-color: #B9CBFC;
|
|
|
+ background: #FFFFFF;
|
|
|
+ transition: background-color 0.15s ease;
|
|
|
+}
|
|
|
+.advanced .knowledge_row:hover {
|
|
|
+ background: rgba(84, 112, 198, 0.1);
|
|
|
+}
|
|
|
+.advanced .knowledge_row.selected {
|
|
|
+ background: rgba(84, 112, 198, 0.15);
|
|
|
+}
|
|
|
+.advanced .kp_meta .kp_score {
|
|
|
+ color: #5470C6; /* 冲刺进阶区:得分率和卡片主题色一致(暂时隐藏) */
|
|
|
}
|
|
|
|
|
|
-/* 冲刺进阶区卡片样式 */
|
|
|
-.advanced .knowledge_card {
|
|
|
- border-color: rgba(46, 100, 250, 0.3);
|
|
|
+.advanced .knowledge_row .add_btn.advanced_btn {
|
|
|
+ color: #2E64FA;
|
|
|
+ border: 1px solid #2E64FA;
|
|
|
+ background: #FFFFFF;
|
|
|
+}
|
|
|
|
|
|
- .card_action .el-button {
|
|
|
- background-color: #2E64FA;
|
|
|
- border-color: #2E64FA;
|
|
|
+/* 【按钮置灰】加入练习成功后 :disabled 状态颜色统一为浅灰色
|
|
|
+ * 必须写和 4 个主题色相同的高权重选择器链(.区 .knowledge_row .add_btn.区_btn),
|
|
|
+ * 并叠加 .is-disabled / [disabled],否则会被上面的主题色(4 层 class 特异性)覆盖,表现为"按钮不能点但颜色没变灰"
|
|
|
+ */
|
|
|
+.weak .knowledge_row .add_btn.weak_btn.is-disabled,
|
|
|
+.weak .knowledge_row .add_btn.weak_btn[disabled],
|
|
|
+.good .knowledge_row .add_btn.good_btn.is-disabled,
|
|
|
+.good .knowledge_row .add_btn.good_btn[disabled],
|
|
|
+.excellent .knowledge_row .add_btn.excellent_btn.is-disabled,
|
|
|
+.excellent .knowledge_row .add_btn.excellent_btn[disabled],
|
|
|
+.advanced .knowledge_row .add_btn.advanced_btn.is-disabled,
|
|
|
+.advanced .knowledge_row .add_btn.advanced_btn[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;
|
|
|
}
|
|
|
}
|
|
|
</style>
|