2026-05-31 14:17:51 +08:00

706 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<ParentPage active="reports" :show-tab="false" :use-scroll-view="false">
<ParentFilterRow :items="filterItems" @change="onFilterChange" />
<view class="module-section">
<ParentCard title="错题知识点占比" subtitle="按错题占比识别最近需要优先补强的知识点">
<view v-if="pageLoading" class="loading-wrap">
<xy-Empty type="loading" content="加载中..." />
</view>
<scroll-view
v-else-if="wrongRatioList.length"
class="ratio-scroll"
scroll-y
:show-scrollbar="false"
:style="ratioScrollStyle"
>
<view class="ratio-layout">
<view class="ratio-main">
<view class="ratio-total">
<text class="ratio-number">{{ mainWrongPercent }}%</text>
<text class="ratio-label">最高错题占比</text>
</view>
<view
v-for="item in wrongRatioList"
:key="`bar-${item.kpointId}`"
class="ratio-bar"
>
<view class="ratio-fill" :class="item.tone" :style="{ width: `${item.ratioPercent}%` }"></view>
</view>
</view>
<view class="legend-list">
<view v-for="item in wrongRatioList" :key="item.kpointId" class="legend-item">
<view class="legend-dot" :class="item.tone"></view>
<text class="legend-name ellipsis">{{ item.kpointName }}</text>
<text class="legend-percent">{{ formatPercent(item.ratioPercent) }}%</text>
</view>
</view>
</view>
</scroll-view>
<xy-Empty v-else type="task" :content="emptyTip" />
</ParentCard>
</view>
<view class="module-section">
<ParentCard title="知识点得分" subtitle="可切换得分等级,快速定位当前薄弱段">
<view class="level-tabs">
<view
v-for="level in scoreLevels"
:key="level.key"
class="level-tab"
:class="{ active: activeLevel === level.key }"
@click="activeLevel = level.key"
>
{{ level.label }}
</view>
</view>
<view v-if="pageLoading" class="loading-wrap">
<xy-Empty type="loading" content="加载中..." />
</view>
<view v-else-if="levelKnowledge.length" class="score-table-wrap">
<view class="score-row table-head">
<text>知识点</text>
<text>满分</text>
<text>得分</text>
</view>
<scroll-view class="score-table-scroll" scroll-y :show-scrollbar="false" :style="scoreTableScrollStyle">
<view v-for="item in levelKnowledge" :key="`${item.kpointId}-${activeLevel}`" class="score-row">
<text class="knowledge-name ellipsis">{{ item.kpointName }}</text>
<text>{{ item.fullScore }}</text>
<text class="score-value">{{ item.score }}<text class="score-rate">({{ formatPercent(item.scoreRatePercent) }}%)</text></text>
</view>
</scroll-view>
</view>
<xy-Empty v-else type="learn" content="当前等级暂无知识点" />
</ParentCard>
</view>
<view class="module-section">
<ParentCard title="得分能力" subtitle="综合最近多次练习得分,观察能力变化和稳定性">
<view v-if="pageLoading" class="loading-wrap">
<xy-Empty type="loading" content="加载中..." />
</view>
<template v-else-if="abilityHasContent">
<view v-if="abilityHasChartData" class="ucharts-wrap">
<qiun-data-charts
class="ability-chart"
type="line"
canvasId="parentAbilityTrendCanvas2dLineChart"
background="rgba(255,255,255,0)"
:canvas2d="false"
:in-scroll-view="false"
:ontouch="false"
:disable-scroll="true"
:reload="chartReload"
:reshow="chartReshow"
:opts="abilityChartOpts"
:chartData="abilityChartData"
/>
</view>
<view class="summary-grid">
<view v-for="item in abilitySummaryList" :key="item.label" class="summary-item">
<text class="summary-label">{{ item.label }}</text>
<text class="summary-value">{{ item.value }}</text>
<text class="summary-desc">{{ item.desc }}</text>
</view>
</view>
</template>
<xy-Empty v-else type="learn" :content="abilityEmptyTip" />
</ParentCard>
</view>
</ParentPage>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { storeToRefs } from 'pinia';
import {
getKpointScore,
getScoreAbility,
getWrongKpointRatio,
type KpointScoreLevel,
type ScoreAbilityPoint,
type ScoreAbilityResult,
type WrongKpointRatioItem,
} from '@/api/parent';
import { useParentStore } from '@/state/modules/parent';
import { useUserStore } from '@/state/modules/user';
import { formatTime } from '@/utils/format';
import { SUBJECT_PICKER_OPTIONS } from '@/utils/subject';
import ParentCard from '../components/ParentCard.vue';
import ParentFilterRow from '../components/ParentFilterRow.vue';
import ParentPage from '../components/ParentPage.vue';
const categoryOptions = ['全部类型', '周作业', '月考'];
type ScoreLevel = 'weak' | 'pass' | 'good' | 'excellent';
type RatioTone = 'blue' | 'orange' | 'green' | 'rose';
interface WrongRatioViewItem {
kpointId: number;
kpointName: string;
ratioPercent: number;
tone: RatioTone;
}
interface ScoreViewItem {
kpointId: number;
kpointName: string;
fullScore: number;
score: number;
scoreRatePercent: number;
}
interface AbilityTrendViewItem {
date: string;
score: number;
}
interface AbilitySummaryViewItem {
label: string;
value: string;
desc: string;
}
const RATIO_TONES: RatioTone[] = ['blue', 'orange', 'green', 'rose'];
/** 列表区域高度区间rpx */
const MODULE_SCROLL_MIN_RPX = 280;
const MODULE_SCROLL_MAX_RPX = 520;
const SCORE_ROW_RPX = 76;
const clampModuleScrollHeight = (contentRpx: number) => {
const heightRpx = Math.max(MODULE_SCROLL_MIN_RPX, Math.min(MODULE_SCROLL_MAX_RPX, contentRpx));
return `${heightRpx}rpx`;
};
const LEVEL_CODE_MAP: Record<ScoreLevel, number> = {
excellent: 1,
good: 2,
pass: 3,
weak: 4,
};
const userStore = useUserStore();
const parentStore = useParentStore();
const { activeChild } = storeToRefs(parentStore);
const subjectIndex = ref(0);
const categoryIndex = ref(0);
const activeLevel = ref<ScoreLevel>('weak');
const pageLoading = ref(false);
const wrongRatioList = ref<WrongRatioViewItem[]>([]);
const scoreLevelsData = ref<KpointScoreLevel[]>([]);
const abilityTrendList = ref<AbilityTrendViewItem[]>([]);
const abilitySummaryList = ref<AbilitySummaryViewItem[]>([]);
const abilityHasContent = ref(false);
const abilityHasChartData = ref(false);
const filterItems = computed(() => [
{ key: 'subject', range: SUBJECT_PICKER_OPTIONS.map((item) => item.label), value: subjectIndex.value },
{ key: 'category', range: categoryOptions, value: categoryIndex.value },
]);
const emptyTip = computed(() => {
if (!activeChild.value.id) return '请先绑定并选择孩子';
return '暂无符合条件的知识点数据';
});
const abilityEmptyTip = computed(() => {
if (!activeChild.value.id) return '请先绑定并选择孩子';
return '暂无得分能力数据';
});
const mainWrongPercent = computed(() => {
if (!wrongRatioList.value.length) return 0;
return Math.max(...wrongRatioList.value.map((item) => item.ratioPercent));
});
const scoreLevels: { key: ScoreLevel; label: string }[] = [
{ key: 'weak', label: '薄弱' },
{ key: 'pass', label: '合格' },
{ key: 'good', label: '良好' },
{ key: 'excellent', label: '优秀' },
];
const ratioScrollStyle = computed(() => {
const count = wrongRatioList.value.length;
const mainHeight = 116 + count * 30;
const legendHeight = count * 46;
return { height: clampModuleScrollHeight(Math.max(mainHeight, legendHeight)) };
});
const levelKnowledge = computed<ScoreViewItem[]>(() => {
const levelCode = LEVEL_CODE_MAP[activeLevel.value];
const level = scoreLevelsData.value.find((item) => item.levelCode === levelCode);
return (level?.items || []).map((item) => ({
kpointId: item.kpointId ?? 0,
kpointName: item.kpointName || '--',
fullScore: item.fullScore ?? 0,
score: item.actualScore ?? 0,
scoreRatePercent: item.scoreRatePercent ?? 0,
}));
});
const scoreTableScrollStyle = computed(() => {
const rows = levelKnowledge.value.length;
return { height: clampModuleScrollHeight(rows * SCORE_ROW_RPX) };
});
const formatPercent = (value?: number) => {
const num = Number(value);
if (!Number.isFinite(num)) return '0';
return Number.isInteger(num) ? String(num) : num.toFixed(1);
};
const mapWrongRatioItems = (items: WrongKpointRatioItem[] = []): WrongRatioViewItem[] =>
items.map((item, index) => ({
kpointId: item.kpointId ?? index,
kpointName: item.kpointName || '--',
ratioPercent: item.ratioPercent ?? 0,
tone: RATIO_TONES[index % RATIO_TONES.length],
}));
const formatAbilityDate = (value?: string) => {
if (!value) return '--';
const formatted = formatTime(value, 'MM-DD');
return formatted || value;
};
const normalizeAbilityScore = (item: ScoreAbilityPoint) => {
if (!item.hasData) return 0;
const score = Number(item.scoreAbility);
return Number.isFinite(score) ? score : 0;
};
const mapAbilityTrendItems = (points: ScoreAbilityPoint[] = []): AbilityTrendViewItem[] =>
points.map((item) => ({
date: formatAbilityDate(item.statDate),
score: normalizeAbilityScore(item),
}));
const hasAbilityChartData = (points: ScoreAbilityPoint[] = []) => points.length > 0;
const mapAbilitySummary = (data?: ScoreAbilityResult): AbilitySummaryViewItem[] => {
const overallScore = data?.overallScoreAbility;
const overallDesc =
overallScore != null
? `综合得分能力 ${formatPercent(overallScore)}`
: '暂无评估数据';
const periodDesc =
data?.startDate && data?.endDate
? `统计周期 ${formatAbilityDate(data.startDate)} ~ ${formatAbilityDate(data.endDate)}`
: '暂无稳定性分析';
return [
{
label: '得分能力综合评估',
value: data?.assessmentName || '--',
desc: overallDesc,
},
{
label: '得分能力稳定性',
value: data?.stabilityName || '--',
desc: periodDesc,
},
];
};
const getSelectedSubjectId = () => SUBJECT_PICKER_OPTIONS[subjectIndex.value]?.value;
const fetchAnalysisData = async () => {
const userId = activeChild.value.id;
const subjectId = getSelectedSubjectId();
if (!userId || !subjectId) {
wrongRatioList.value = [];
scoreLevelsData.value = [];
abilityTrendList.value = [];
abilitySummaryList.value = [];
abilityHasContent.value = false;
abilityHasChartData.value = false;
return;
}
pageLoading.value = true;
try {
const params = { subjectId, userId };
const [ratioRes, scoreRes, abilityRes] = await Promise.all([
getWrongKpointRatio(params),
getKpointScore(params),
getScoreAbility(params),
]);
wrongRatioList.value = mapWrongRatioItems(ratioRes.data?.items);
scoreLevelsData.value = scoreRes.data?.levels || [];
abilityTrendList.value = mapAbilityTrendItems(abilityRes.data?.points);
abilitySummaryList.value = mapAbilitySummary(abilityRes.data);
abilityHasChartData.value = hasAbilityChartData(abilityRes.data?.points);
abilityHasContent.value = abilityHasChartData.value || !!abilityRes.data?.assessmentName;
} catch (err) {
console.error('[parent-analysis] fetch err', err);
wrongRatioList.value = [];
scoreLevelsData.value = [];
abilityTrendList.value = [];
abilitySummaryList.value = [];
abilityHasContent.value = false;
abilityHasChartData.value = false;
} finally {
pageLoading.value = false;
await refreshChart();
}
};
const onFilterChange = (payload: { key: string; value: number }) => {
if (payload.key === 'subject') {
subjectIndex.value = payload.value;
return;
}
if (payload.key === 'category') {
categoryIndex.value = payload.value;
}
};
watch([subjectIndex, categoryIndex, () => activeChild.value.id], () => {
fetchAnalysisData();
});
const buildAbilityChartData = () => ({
categories: abilityTrendList.value.map((item) => item.date),
series: [
{
name: '得分能力',
data: abilityTrendList.value.map((item) => item.score),
color: '#0891b2',
},
],
});
const abilityChartData = ref<Record<string, unknown>>({ categories: [], series: [] });
const chartReshow = ref(false);
const chartReload = ref(false);
const refreshChart = async () => {
if (!abilityHasChartData.value) {
abilityChartData.value = { categories: [], series: [] };
return;
}
abilityChartData.value = JSON.parse(JSON.stringify(buildAbilityChartData()));
await nextTick();
setTimeout(() => {
chartReload.value = !chartReload.value;
chartReshow.value = !chartReshow.value;
}, 320);
};
const abilityChartMin = computed(() => {
if (!abilityTrendList.value.length) return 0;
const minScore = Math.min(...abilityTrendList.value.map((item) => item.score));
return Math.max(0, Math.floor(minScore / 10) * 10 - 10);
});
onShow(async () => {
try {
if (!userStore.userInfo.id) {
await userStore.fetchParentUserInfo();
}
await parentStore.fetchBindChildren(userStore.userInfo.id);
} catch {
/* ignore */
}
await fetchAnalysisData();
});
const abilityChartOpts = computed(() => {
const pointCount = abilityTrendList.value.length || 30;
const labelCount = Math.min(6, Math.max(4, Math.ceil(pointCount / 5)));
return {
color: ['#0891b2'],
padding: [18, 12, 8, 12],
enableScroll: false,
dataLabel: false,
legend: { show: false },
xAxis: {
disableGrid: false,
gridColor: '#eef2f7',
fontColor: '#64748b',
fontSize: 10,
boundaryGap: 'justify',
labelCount,
},
yAxis: {
gridType: 'dash',
dashLength: 2,
gridColor: '#eef2f7',
data: [
{
min: abilityChartMin.value,
max: 100,
splitNumber: 4,
fontColor: '#94a3b8',
fontSize: 10,
},
],
},
extra: {
line: {
type: 'curve',
width: 3,
activeType: 'hollow',
},
markLine: {
type: 'dash',
dashLength: 6,
data: [
{ value: 85, lineColor: 'rgba(244, 63, 94, 0.32)', showLabel: true, labelText: '优秀' },
{ value: 70, lineColor: 'rgba(244, 63, 94, 0.28)', showLabel: true, labelText: '良好' },
{ value: 60, lineColor: 'rgba(244, 63, 94, 0.22)', showLabel: true, labelText: '一般' },
],
},
},
};
});
</script>
<style lang="scss" scoped>
.module-section {
margin-top: 44rpx;
width: 100%;
max-width: 100%;
overflow-x: hidden;
}
.filter-row + .module-section {
margin-top: 36rpx;
}
.loading-wrap {
padding: 24rpx 0;
}
.ratio-scroll {
min-height: 280rpx;
max-height: 520rpx;
}
.ratio-layout {
display: flex;
align-items: flex-start;
gap: 28rpx;
width: 100%;
max-width: 100%;
}
.ratio-main {
width: 232rpx;
flex-shrink: 0;
}
.ratio-total {
min-height: 116rpx;
border-radius: 28rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #ecfeff 0%, #ffffff 100%);
box-shadow: inset 0 -2rpx 0 rgba(34, 211, 238, 0.24);
}
.ratio-number {
color: #164e63;
font-size: 42rpx;
font-weight: 900;
}
.ratio-label {
margin-top: 2rpx;
color: #64748b;
font-size: 20rpx;
}
.ratio-bar {
height: 16rpx;
margin-top: 14rpx;
border-radius: 999rpx;
overflow: hidden;
background: #e2e8f0;
}
.ratio-fill {
height: 100%;
border-radius: 999rpx;
&.blue { background: #3b82f6; }
&.orange { background: #f97316; }
&.green { background: #22c55e; }
&.rose { background: #f43f5e; }
}
.legend-list {
flex: 1;
min-width: 0;
}
.legend-item {
display: flex;
align-items: center;
gap: 12rpx;
min-height: 46rpx;
color: #164e63;
font-size: 24rpx;
font-weight: 700;
}
.legend-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
flex-shrink: 0;
&.blue { background: #3b82f6; }
&.orange { background: #f97316; }
&.green { background: #22c55e; }
&.rose { background: #f43f5e; }
}
.legend-name {
flex: 1;
}
.legend-percent {
color: #0891b2;
}
.level-tabs {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14rpx;
margin-bottom: 20rpx;
}
.level-tab {
height: 64rpx;
border: 2rpx solid #e2e8f0;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
color: #475569;
background: #ffffff;
font-size: 24rpx;
font-weight: 800;
&.active {
border-color: #0891b2;
color: #ffffff;
background: linear-gradient(180deg, #22d3ee 0%, #0891b2 100%);
box-shadow: 0 10rpx 20rpx rgba(8, 145, 178, 0.2);
}
}
.score-table-wrap {
border: 2rpx solid #e2e8f0;
border-radius: 24rpx;
overflow: hidden;
}
.score-table-scroll {
min-height: 280rpx;
max-height: 520rpx;
}
.score-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 112rpx 150rpx;
align-items: center;
min-height: 76rpx;
padding: 0 22rpx;
color: #164e63;
font-size: 24rpx;
text-align: center;
border-top: 2rpx solid #f1f5f9;
&:first-child {
border-top: 0;
}
> text,
> .knowledge-name {
min-width: 0;
}
}
.table-head {
color: #475569;
background: #f8fafc;
font-weight: 900;
}
.knowledge-name {
text-align: left;
}
.score-value {
color: #ea580c;
font-weight: 900;
}
.score-rate {
margin-left: 2rpx;
font-size: 22rpx;
font-weight: 700;
}
.ucharts-wrap {
width: 100%;
max-width: 100%;
height: 360rpx;
margin-top: 4rpx;
overflow: hidden;
position: relative;
}
.ability-chart {
display: block;
width: 100% !important;
max-width: 100%;
height: 100%;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
margin-top: 26rpx;
}
.summary-item {
padding: 18rpx;
border-radius: 24rpx;
background: #f8fafc;
text-align: center;
}
.summary-label,
.summary-desc {
display: block;
color: #64748b;
font-size: 22rpx;
line-height: 1.45;
}
.summary-value {
display: block;
margin: 8rpx 0;
color: #0891b2;
font-size: 32rpx;
font-weight: 900;
}
</style>