feat: 对接接口

This commit is contained in:
阿梦 2026-05-30 11:29:36 +08:00
parent 360834b437
commit 339c871d65
11 changed files with 959 additions and 217 deletions

4
.env
View File

@ -1,5 +1,5 @@
VITE_BASE_URL = http://43.136.52.196:9053 #VITE_BASE_URL = http://43.136.52.196:9053
# VITE_BASE_URL = https://ai.lingxixue.com VITE_BASE_URL = https://ai.lingxixue.com
# VITE_WS_URL = ws://8.138.151.141:8888/ # VITE_WS_URL = ws://8.138.151.141:8888/
VITE_WS_URL = ws://ec2-13-36-229-106.eu-west-3.compute.amazonaws.com:8888/ VITE_WS_URL = ws://ec2-13-36-229-106.eu-west-3.compute.amazonaws.com:8888/

View File

@ -4,6 +4,7 @@
"compileType": "miniprogram", "compileType": "miniprogram",
"appid": "wxa5522671f30c5891", "appid": "wxa5522671f30c5891",
"projectname": "灵犀学", "projectname": "灵犀学",
"libVersion": "3.4.4",
"setting": { "setting": {
"urlCheck": false, "urlCheck": false,
"es6": true, "es6": true,
@ -14,6 +15,5 @@
"ignoreUploadUnusedFiles": false, "ignoreUploadUnusedFiles": false,
"minifyWXML": true "minifyWXML": true
}, },
"libVersion": "3.4.4",
"condition": {} "condition": {}
} }

View File

@ -138,9 +138,50 @@ export const getPaperRecordDetail = (data: { recordId: string; reportFlag: numbe
data, data,
}); });
export interface JobReportVo {
actualScore?: number;
beginTime?: string;
costTime?: number;
jobId?: number;
jobName?: string;
submitEndTime?: string;
totalScore?: number;
userId?: number;
}
export interface PaperChanelAnalyzeItem {
chanelTypeName?: string;
titleCount?: number;
}
export interface PaperDifficultyAnalyzeItem {
actualScore?: number;
difficultyName?: string;
/** 百分比比例,乘以 10000如 9850 表示 98.5% */
proportion?: number;
score?: number;
titleCount?: number;
}
export interface StuRankVoItem {
markScore?: number;
paperScore?: number;
rankNum?: number;
scoreRate?: number;
selfFlag?: number;
stuName?: string;
}
export interface JobAnalyseVo {
jobReportVo?: JobReportVo;
paperChanelAnalyzeList?: PaperChanelAnalyzeItem[];
paperDifficultyAnalyzeList?: PaperDifficultyAnalyzeItem[];
stuRankVoList?: StuRankVoItem[];
}
/** 作业整体分析 */ /** 作业整体分析 */
export const getJobAnalyse = (params: { recordId: string }) => export const getJobAnalyse = (params: { recordId: string }) =>
request<any>({ request<JobAnalyseVo>({
url: '/sc/job/getJobAnalyse', url: '/sc/job/getJobAnalyse',
method: 'GET', method: 'GET',
params, params,

View File

@ -90,6 +90,150 @@ export const manualBindChild = (childAcc: string) =>
data: `childAcc=${encodeURIComponent(childAcc.trim())}`, data: `childAcc=${encodeURIComponent(childAcc.trim())}`,
}); });
export interface WrongKpointRatioItem {
errorCount?: number;
kpointId?: number;
kpointName?: string;
ratio?: number;
ratioPercent?: number;
}
export interface WrongKpointRatioResult {
items?: WrongKpointRatioItem[];
subjectId?: number;
subjectName?: string;
totalErrorCount?: number;
}
export interface KpointScoreItem {
kpointId?: number;
kpointName?: string;
actualScore?: number;
fullscore?: number;
scoreRate?: number;
scoreRatePercent?: number;
levelCode?: number;
levelName?: string;
}
export interface KpointScoreLevel {
levelCode?: number;
levelName?: string;
items?: KpointScoreItem[];
}
export interface KpointScoreResult {
subjectId?: number;
subjectName?: string;
levels?: KpointScoreLevel[];
}
export interface ScoreAbilityPoint {
hasData?: boolean;
scoreAbility?: number;
scoreRate?: number;
statDate?: string;
}
export interface ScoreAbilityResult {
assessmentCode?: number;
assessmentName?: string;
endDate?: string;
overallScoreAbility?: number;
points?: ScoreAbilityPoint[];
stabilityCode?: number;
stabilityName?: string;
startDate?: string;
subjectId?: number;
subjectName?: string;
}
export interface WrongQuestionSubjectSummary {
subjectId?: number;
subjectName?: string;
totalCount?: number;
masteredCount?: number;
reviewCount?: number;
newCount?: number;
}
export interface WrongQuestionSummaryResult {
thisWeekStart?: string;
thisWeekEnd?: string;
subjects?: WrongQuestionSubjectSummary[];
}
export type WeeklySubjectTrendCode = 'PROGRESS' | 'REGRESSION' | 'STABLE' | 'NO_COMPARE';
export interface WeeklySubjectItem {
subjectId?: number;
subjectName?: string;
levelCode?: number;
levelName?: string;
thisWeekRecordCount?: number;
thisWeekScoreRate?: number;
lastWeekRecordCount?: number;
lastWeekScoreRate?: number | null;
delta?: number | null;
trendCode?: WeeklySubjectTrendCode | string;
trendName?: string;
}
export interface WeeklySubjectLevel {
levelCode?: number;
levelName?: string;
subjects?: WeeklySubjectItem[];
}
export interface WeeklySubjectResult {
lastWeekEnd?: string;
lastWeekStart?: string;
thisWeekEnd?: string;
thisWeekStart?: string;
levels?: WeeklySubjectLevel[];
subjects?: WeeklySubjectItem[];
}
/** 家长端首页 - 本周学科分层与趋势 */
export const getWeeklySubject = (params: { userId: string | number }) =>
request<WeeklySubjectResult>({
url: '/school/parent/weeklySubject',
method: 'GET',
params,
});
/** 家长端 - 错题汇总(按学科) */
export const getWrongQuestionSummary = (params: { userId: string | number }) =>
request<WrongQuestionSummaryResult>({
url: '/school/parent/wrongQuestionSummary',
method: 'GET',
params,
});
/** 家长端 - 错题知识点占比 */
export const getWrongKpointRatio = (params: { subjectId: string | number; userId: string | number }) =>
request<WrongKpointRatioResult>({
url: '/school/parent/wrongKpointRatio',
method: 'GET',
params,
});
/** 家长端 - 知识点得分(按等级分组) */
export const getKpointScore = (params: { subjectId: string | number; userId: string | number }) =>
request<KpointScoreResult>({
url: '/school/parent/kpointScore',
method: 'GET',
params,
});
/** 家长端 - 得分能力趋势 */
export const getScoreAbility = (params: { subjectId: string | number; userId: string | number }) =>
request<ScoreAbilityResult>({
url: '/school/parent/scoreAbility',
method: 'GET',
params,
});
/** 扫码后申请绑定孩子/设备 */ /** 扫码后申请绑定孩子/设备 */
export const parentBindApply = (params: { export const parentBindApply = (params: {
childId?: string | number; childId?: string | number;

View File

@ -195,7 +195,7 @@
"role": "parent" "role": "parent"
}, },
"style": { "style": {
"navigationBarTitleText": "成绩查询家长主页", "navigationBarTitleText": "家长主页",
"mp-weixin": { "mp-weixin": {
"pageOrientation": "portrait" "pageOrientation": "portrait"
} }

View File

@ -1,32 +1,43 @@
<template> <template>
<ParentPage active="reports" :show-tab="false"> <ParentPage active="reports" :show-tab="false" :use-scroll-view="false">
<ParentFilterRow :items="filterItems" @change="onFilterChange" /> <ParentFilterRow :items="filterItems" @change="onFilterChange" />
<view class="module-section"> <view class="module-section">
<ParentCard title="错题知识点占比" subtitle="按错题占比识别最近需要优先补强的知识点"> <ParentCard title="错题知识点占比" subtitle="按错题占比识别最近需要优先补强的知识点">
<view v-if="filteredKnowledge.length" class="ratio-layout"> <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-main">
<view class="ratio-total"> <view class="ratio-total">
<text class="ratio-number">{{ mainWrongPercent }}%</text> <text class="ratio-number">{{ mainWrongPercent }}%</text>
<text class="ratio-label">最高错题占比</text> <text class="ratio-label">最高错题占比</text>
</view> </view>
<view <view
v-for="item in filteredKnowledge" v-for="item in wrongRatioList"
:key="`bar-${item.name}`" :key="`bar-${item.kpointId}`"
class="ratio-bar" class="ratio-bar"
> >
<view class="ratio-fill" :class="item.tone" :style="{ width: `${item.wrongPercent}%` }"></view> <view class="ratio-fill" :class="item.tone" :style="{ width: `${item.ratioPercent}%` }"></view>
</view> </view>
</view> </view>
<view class="legend-list"> <view class="legend-list">
<view v-for="item in filteredKnowledge" :key="item.name" class="legend-item"> <view v-for="item in wrongRatioList" :key="item.kpointId" class="legend-item">
<view class="legend-dot" :class="item.tone"></view> <view class="legend-dot" :class="item.tone"></view>
<text class="legend-name ellipsis">{{ item.name }}</text> <text class="legend-name ellipsis">{{ item.kpointName }}</text>
<text class="legend-percent">{{ item.wrongPercent }}%</text> <text class="legend-percent">{{ formatPercent(item.ratioPercent) }}%</text>
</view> </view>
</view> </view>
</view> </view>
<xy-Empty v-else type="task" content="暂无符合条件的知识点数据" /> </scroll-view>
<xy-Empty v-else type="task" :content="emptyTip" />
</ParentCard> </ParentCard>
</view> </view>
@ -44,17 +55,22 @@
</view> </view>
</view> </view>
<view v-if="levelKnowledge.length" class="score-table"> <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"> <view class="score-row table-head">
<text>知识点</text> <text>知识点</text>
<text>满分</text> <text>满分</text>
<text>得分</text> <text>得分</text>
</view> </view>
<view v-for="item in levelKnowledge" :key="`${item.name}-${activeLevel}`" class="score-row"> <scroll-view class="score-table-scroll" scroll-y :show-scrollbar="false" :style="scoreTableScrollStyle">
<text class="knowledge-name ellipsis">{{ item.name }}</text> <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>{{ item.fullScore }}</text>
<text class="score-value">{{ item.score }}<text class="score-rate">({{ scoreRate(item) }}%)</text></text> <text class="score-value">{{ item.score }}<text class="score-rate">({{ formatPercent(item.scoreRatePercent) }}%)</text></text>
</view> </view>
</scroll-view>
</view> </view>
<xy-Empty v-else type="learn" content="当前等级暂无知识点" /> <xy-Empty v-else type="learn" content="当前等级暂无知识点" />
</ParentCard> </ParentCard>
@ -62,16 +78,21 @@
<view class="module-section"> <view class="module-section">
<ParentCard title="得分能力" subtitle="综合最近多次练习得分,观察能力变化和稳定性"> <ParentCard title="得分能力" subtitle="综合最近多次练习得分,观察能力变化和稳定性">
<view class="ucharts-wrap"> <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 <qiun-data-charts
class="ability-chart" class="ability-chart"
type="line" type="line"
canvasId="parentAbilityTrendCanvas2dLineChart" canvasId="parentAbilityTrendCanvas2dLineChart"
background="rgba(255,255,255,0)" background="rgba(255,255,255,0)"
:canvas2d="true" :canvas2d="false"
:in-scroll-view="true" :in-scroll-view="false"
:ontouch="false" :ontouch="false"
:disable-scroll="true" :disable-scroll="true"
:reload="chartReload"
:reshow="chartReshow" :reshow="chartReshow"
:opts="abilityChartOpts" :opts="abilityChartOpts"
:chartData="abilityChartData" :chartData="abilityChartData"
@ -79,59 +100,125 @@
</view> </view>
<view class="summary-grid"> <view class="summary-grid">
<view v-for="item in abilitySummary" :key="item.label" class="summary-item"> <view v-for="item in abilitySummaryList" :key="item.label" class="summary-item">
<text class="summary-label">{{ item.label }}</text> <text class="summary-label">{{ item.label }}</text>
<text class="summary-value">{{ item.value }}</text> <text class="summary-value">{{ item.value }}</text>
<text class="summary-desc">{{ item.desc }}</text> <text class="summary-desc">{{ item.desc }}</text>
</view> </view>
</view> </view>
</template>
<xy-Empty v-else type="learn" :content="abilityEmptyTip" />
</ParentCard> </ParentCard>
</view> </view>
</ParentPage> </ParentPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import { onReady, onShow } from '@dcloudio/uni-app'; 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 ParentCard from '../components/ParentCard.vue';
import ParentFilterRow from '../components/ParentFilterRow.vue'; import ParentFilterRow from '../components/ParentFilterRow.vue';
import ParentPage from '../components/ParentPage.vue'; import ParentPage from '../components/ParentPage.vue';
import {
abilitySummary, const categoryOptions = ['全部类型', '周作业', '月考'];
abilityTrend,
categoryOptions,
gradeOptions,
knowledgeWeakPoints,
subjectOptions,
type KnowledgeWeakPoint,
} from '../utils/data';
type ScoreLevel = 'weak' | 'pass' | 'good' | 'excellent'; 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 subjectIndex = ref(0);
const gradeIndex = ref(1);
const categoryIndex = ref(0); const categoryIndex = ref(0);
const activeLevel = ref<ScoreLevel>('weak'); 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(() => [ const filterItems = computed(() => [
{ key: 'subject', range: subjectOptions, value: subjectIndex.value }, { key: 'subject', range: SUBJECT_PICKER_OPTIONS.map((item) => item.label), value: subjectIndex.value },
{ key: 'grade', range: gradeOptions, value: gradeIndex.value },
{ key: 'category', range: categoryOptions, value: categoryIndex.value }, { key: 'category', range: categoryOptions, value: categoryIndex.value },
]); ]);
const onFilterChange = (payload: { key: string; value: number }) => { const emptyTip = computed(() => {
if (payload.key === 'subject') { if (!activeChild.value.id) return '请先绑定并选择孩子';
subjectIndex.value = payload.value; return '暂无符合条件的知识点数据';
return; });
}
if (payload.key === 'grade') { const abilityEmptyTip = computed(() => {
gradeIndex.value = payload.value; if (!activeChild.value.id) return '请先绑定并选择孩子';
return; return '暂无得分能力数据';
} });
if (payload.key === 'category') {
categoryIndex.value = payload.value; const mainWrongPercent = computed(() => {
} if (!wrongRatioList.value.length) return 0;
}; return Math.max(...wrongRatioList.value.map((item) => item.ratioPercent));
});
const scoreLevels: { key: ScoreLevel; label: string }[] = [ const scoreLevels: { key: ScoreLevel; label: string }[] = [
{ key: 'weak', label: '薄弱' }, { key: 'weak', label: '薄弱' },
@ -140,65 +227,202 @@ const scoreLevels: { key: ScoreLevel; label: string }[] = [
{ key: 'excellent', label: '优秀' }, { key: 'excellent', label: '优秀' },
]; ];
const filteredKnowledge = computed(() => { const ratioScrollStyle = computed(() => {
const subject = subjectOptions[subjectIndex.value]; const count = wrongRatioList.value.length;
const grade = gradeOptions[gradeIndex.value]; const mainHeight = 116 + count * 30;
const category = categoryOptions[categoryIndex.value]; const legendHeight = count * 46;
return knowledgeWeakPoints.filter((item) => { return { height: clampModuleScrollHeight(Math.max(mainHeight, legendHeight)) };
const subjectMatched = subject === '全部学科' || item.subject === subject;
const gradeMatched = grade === '全部年级' || item.grade === grade;
const categoryMatched = category === '全部类型' || item.category === category;
return subjectMatched && gradeMatched && categoryMatched;
});
}); });
const mainWrongPercent = computed(() => Math.max(...filteredKnowledge.value.map((item) => item.wrongPercent), 0)); 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 scoreRate = (item: KnowledgeWeakPoint) => Math.round((item.score / item.fullScore) * 100); const scoreTableScrollStyle = computed(() => {
const rows = levelKnowledge.value.length;
return { height: clampModuleScrollHeight(rows * SCORE_ROW_RPX) };
});
const levelOf = (item: KnowledgeWeakPoint): ScoreLevel => { const formatPercent = (value?: number) => {
const rate = scoreRate(item); const num = Number(value);
if (rate < 60) return 'weak'; if (!Number.isFinite(num)) return '0';
if (rate < 75) return 'pass'; return Number.isInteger(num) ? String(num) : num.toFixed(1);
if (rate < 90) return 'good';
return 'excellent';
}; };
const levelKnowledge = computed(() => filteredKnowledge.value.filter((item) => levelOf(item) === activeLevel.value)); 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 abilityChartData = ref({}); const formatAbilityDate = (value?: string) => {
const chartReshow = ref(false); 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 = () => ({ const buildAbilityChartData = () => ({
categories: abilityTrend.map((item) => item.date), categories: abilityTrendList.value.map((item) => item.date),
series: [ series: [
{ {
name: '得分能力', name: '得分能力',
data: abilityTrend.map((item) => item.score), data: abilityTrendList.value.map((item) => item.score),
color: '#0891b2', color: '#0891b2',
}, },
], ],
}); });
const fillChart = () => { 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())); abilityChartData.value = JSON.parse(JSON.stringify(buildAbilityChartData()));
await nextTick();
setTimeout(() => {
chartReload.value = !chartReload.value;
chartReshow.value = !chartReshow.value; chartReshow.value = !chartReshow.value;
}, 320);
}; };
onReady(() => { const abilityChartMin = computed(() => {
setTimeout(fillChart, 500); 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(() => { onShow(async () => {
if (Object.keys(abilityChartData.value).length) { try {
chartReshow.value = !chartReshow.value; if (!userStore.userInfo.id) {
await userStore.fetchParentUserInfo();
} }
await parentStore.fetchBindChildren(userStore.userInfo.id);
} catch {
/* ignore */
}
await fetchAnalysisData();
}); });
const abilityChartOpts = { const abilityChartOpts = computed(() => {
const pointCount = abilityTrendList.value.length || 30;
const labelCount = Math.min(6, Math.max(4, Math.ceil(pointCount / 5)));
return {
color: ['#0891b2'], color: ['#0891b2'],
padding: [18, 12, 0, 12], padding: [18, 12, 8, 12],
enableScroll: false, enableScroll: false,
dataLabel: false,
legend: { show: false }, legend: { show: false },
xAxis: { xAxis: {
disableGrid: false, disableGrid: false,
@ -206,6 +430,7 @@ const abilityChartOpts = {
fontColor: '#64748b', fontColor: '#64748b',
fontSize: 10, fontSize: 10,
boundaryGap: 'justify', boundaryGap: 'justify',
labelCount,
}, },
yAxis: { yAxis: {
gridType: 'dash', gridType: 'dash',
@ -213,7 +438,7 @@ const abilityChartOpts = {
gridColor: '#eef2f7', gridColor: '#eef2f7',
data: [ data: [
{ {
min: 40, min: abilityChartMin.value,
max: 100, max: 100,
splitNumber: 4, splitNumber: 4,
fontColor: '#94a3b8', fontColor: '#94a3b8',
@ -237,7 +462,8 @@ const abilityChartOpts = {
], ],
}, },
}, },
}; };
});
</script> </script>
@ -253,13 +479,21 @@ const abilityChartOpts = {
margin-top: 36rpx; margin-top: 36rpx;
} }
.loading-wrap {
padding: 24rpx 0;
}
.ratio-scroll {
min-height: 280rpx;
max-height: 520rpx;
}
.ratio-layout { .ratio-layout {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 28rpx; gap: 28rpx;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
overflow: hidden;
} }
.ratio-main { .ratio-main {
@ -370,12 +604,17 @@ const abilityChartOpts = {
} }
} }
.score-table { .score-table-wrap {
border: 2rpx solid #e2e8f0; border: 2rpx solid #e2e8f0;
border-radius: 24rpx; border-radius: 24rpx;
overflow: hidden; overflow: hidden;
} }
.score-table-scroll {
min-height: 280rpx;
max-height: 520rpx;
}
.score-row { .score-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 112rpx 150rpx; grid-template-columns: minmax(0, 1fr) 112rpx 150rpx;

View File

@ -14,20 +14,25 @@
<view class="grade-card"> <view class="grade-card">
<ParentCard> <ParentCard>
<view v-if="weeklyLoading" class="weekly-loading">
<xy-Empty type="loading" content="加载中..." />
</view>
<template v-else>
<view v-for="band in gradeBands" :key="band.level" class="band-row"> <view v-for="band in gradeBands" :key="band.level" class="band-row">
<view class="band-badge" :class="band.level">{{ band.label.slice(0, 2) }}</view> <view class="band-badge" :class="band.level">{{ band.label.slice(0, 2) }}</view>
<view v-if="band.subjects.length" class="subject-list"> <view v-if="band.subjects.length" class="subject-list">
<view v-for="item in band.subjects" :key="`${band.level}-${item.subject}`" class="subject-chip"> <view v-for="item in band.subjects" :key="`${band.level}-${item.subject}`" class="subject-chip">
<text>{{ item.subject }}</text> <text>{{ item.subject }}</text>
<text class="trend" :class="item.status">{{ trendText(item.status) }}</text> <text v-if="trendLabel(item)" class="trend" :class="item.status">{{ trendLabel(item) }}</text>
</view> </view>
</view> </view>
<text v-else class="empty-text">暂无需要关注的学科</text> <text v-else class="empty-text">暂无需要关注的学科</text>
</view> </view>
</template>
</ParentCard> </ParentCard>
</view> </view>
<view class="member-card"> <!-- <view class="member-card">
<ParentCard clickable @click="showComingSoon('家长会员')"> <ParentCard clickable @click="showComingSoon('家长会员')">
<view class="member-tag">家长会员</view> <view class="member-tag">家长会员</view>
<view class="member-main"> <view class="member-main">
@ -38,14 +43,14 @@
<text class="member-arrow">查看</text> <text class="member-arrow">查看</text>
</view> </view>
</ParentCard> </ParentCard>
</view> </view> -->
<view class="read-card"> <!-- <view class="read-card">
<ParentCard title="本周家长必读" subtitle="系统按孩子最近表现生成关注建议"> <ParentCard title="本周家长必读" subtitle="系统按孩子最近表现生成关注建议">
<text class="read-text">数学周练后情绪略低建议先肯定一次认真订正再安排一条免费话术在家长关怀</text> <text class="read-text">数学周练后情绪略低建议先肯定一次认真订正再安排一条免费话术在家长关怀</text>
<view class="read-link" @click="showComingSoon('家长关怀')">进入家长关怀中心</view> <view class="read-link" @click="showComingSoon('家长关怀')">进入家长关怀中心</view>
</ParentCard> </ParentCard>
</view> </view> -->
<view class="quick-grid"> <view class="quick-grid">
<ParentCard v-for="item in quickActions" :key="item.title" class="quick-card" clickable @click="handleQuickAction(item)"> <ParentCard v-for="item in quickActions" :key="item.title" class="quick-card" clickable @click="handleQuickAction(item)">
@ -58,41 +63,117 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app'; import { onShow } from '@dcloudio/uni-app';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { getWeeklySubject, type WeeklySubjectLevel } from '@/api/parent';
import { useParentStore } from '@/state/modules/parent'; import { useParentStore } from '@/state/modules/parent';
import { useUserStore } from '@/state/modules/user'; import { useUserStore } from '@/state/modules/user';
import ParentCard from '../components/ParentCard.vue'; import ParentCard from '../components/ParentCard.vue';
import ParentPage from '../components/ParentPage.vue'; import ParentPage from '../components/ParentPage.vue';
import { gradeBands, quickActions, type QuickAction, type SubjectTrend } from '../utils/data'; import { quickActions, type GradeBand, type QuickAction, type SubjectTrend } from '../utils/data';
const DEFAULT_AVATAR = 'https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com/urm/logo.png'; const DEFAULT_AVATAR = 'https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com/urm/logo.png';
const LEVEL_BAND_DEFAULTS: GradeBand[] = [
{ label: '优势保持', level: 'excellent', subjects: [] },
{ label: '良好巩固', level: 'good', subjects: [] },
{ label: '重点提升', level: 'middle', subjects: [] },
{ label: '及时补弱', level: 'weak', subjects: [] },
];
const LEVEL_CLASS_MAP: Record<number, GradeBand['level']> = {
1: 'excellent',
2: 'good',
3: 'middle',
4: 'weak',
};
const userStore = useUserStore(); const userStore = useUserStore();
const parentStore = useParentStore(); const parentStore = useParentStore();
const { userInfo } = storeToRefs(userStore); const { userInfo } = storeToRefs(userStore);
const { activeChild } = storeToRefs(parentStore); const { activeChild } = storeToRefs(parentStore);
const parentAvatarUrl = computed(() => userInfo.value.avatarUrl || DEFAULT_AVATAR); const parentAvatarUrl = computed(() => userInfo.value.avatarUrl || DEFAULT_AVATAR);
const gradeBands = ref<GradeBand[]>(LEVEL_BAND_DEFAULTS.map((item) => ({ ...item, subjects: [] })));
const weeklyLoading = ref(false);
const createEmptyGradeBands = () =>
LEVEL_BAND_DEFAULTS.map((item) => ({
...item,
subjects: [] as SubjectTrend[],
}));
const mapTrendStatus = (trendCode?: string): SubjectTrend['status'] => {
if (trendCode === 'PROGRESS') return 'up';
if (trendCode === 'REGRESSION') return 'down';
if (trendCode === 'STABLE') return 'stable';
if (trendCode === 'NO_COMPARE') return 'none';
return undefined;
};
const mapLevelsToBands = (levels?: WeeklySubjectLevel[]): GradeBand[] => {
const byCode = new Map((levels || []).map((item) => [item.levelCode, item]));
return LEVEL_BAND_DEFAULTS.map((fallback, index) => {
const levelCode = index + 1;
const apiLevel = byCode.get(levelCode);
return {
label: apiLevel?.levelName || fallback.label,
level: LEVEL_CLASS_MAP[levelCode] || fallback.level,
subjects: (apiLevel?.subjects || [])
.filter((item) => item.subjectName)
.map((item) => {
const status = mapTrendStatus(item.trendCode);
return {
subject: item.subjectName || '',
status,
trendName: item.trendName,
};
}),
};
});
};
const fetchWeeklySubject = async () => {
const userId = activeChild.value.id;
if (!userId) {
gradeBands.value = createEmptyGradeBands();
return;
}
weeklyLoading.value = true;
try {
const res = await getWeeklySubject({ userId });
gradeBands.value = mapLevelsToBands(res.data?.levels);
} catch (err) {
console.error('[parent-home] weeklySubject error', err);
gradeBands.value = createEmptyGradeBands();
} finally {
weeklyLoading.value = false;
}
};
onShow(async () => { onShow(async () => {
try { try {
if (!userInfo.value.id) { if (!userInfo.value.id) {
await userStore.fetchParentUserInfo(); await userStore.fetchParentUserInfo();
} }
await parentStore.fetchBindChildren(userInfo.value.id); await parentStore.fetchBindChildren(userInfo.value.id);
await fetchWeeklySubject();
} catch { } catch {
/* ignore */ /* ignore */
} }
}); });
const trendText = (status: SubjectTrend['status']) => { const trendLabel = (item: SubjectTrend) => {
const map: Record<SubjectTrend['status'], string> = { if (item.trendName) return item.trendName;
const map: Record<NonNullable<SubjectTrend['status']>, string> = {
up: '进步', up: '进步',
down: '退步', down: '退步',
stable: '稳定', stable: '持平',
none: '',
}; };
return map[status]; return item.status ? map[item.status] : '';
}; };
const showComingSoon = (name: string) => { const showComingSoon = (name: string) => {
@ -230,6 +311,11 @@ const handleQuickAction = (item: QuickAction) => {
&.up { background: #22c55e; } &.up { background: #22c55e; }
&.down { background: #f43f5e; } &.down { background: #f43f5e; }
&.stable { background: #0891b2; } &.stable { background: #0891b2; }
&.none { background: #94a3b8; }
}
.weekly-loading {
padding: 24rpx 0;
} }
.empty-text { .empty-text {

View File

@ -42,36 +42,63 @@
</ParentCard> </ParentCard>
<template v-if="activeTab === 'analysis'"> <template v-if="activeTab === 'analysis'">
<ParentCard class="section-card" title="题型得分" subtitle="按题型拆解本次卷面得分"> <ParentCard class="section-card" title="题型分布" subtitle="按题型拆解本次卷面构成">
<view class="data-table"> <view v-if="pageLoading && !analysisRows.length" class="answers-loading">
<view class="table-row table-head"> <xy-Empty type="loading" content="加载分析数据..." />
<text>题号</text>
<text>题型</text>
<text>得分</text>
<text>得分率</text>
</view> </view>
<view v-for="row in detail.analysisRows" :key="row.range" class="table-row"> <view v-else-if="analysisRows.length" class="data-table">
<text>{{ row.range }}</text> <view class="table-row table-head">
<text>题型</text>
<text>题量</text>
<text>占比</text>
</view>
<view v-for="row in analysisRows" :key="row.type" class="table-row channel-row">
<text>{{ row.type }}</text> <text>{{ row.type }}</text>
<text class="strong">{{ row.score }}</text> <text class="strong">{{ row.count }}</text>
<text class="rate-good">{{ row.rate }}</text> <text class="rate-good">{{ row.rate }}</text>
</view> </view>
</view> </view>
<xy-Empty v-else type="task" content="暂无题型分析数据" />
</ParentCard> </ParentCard>
<ParentCard class="section-card" title="难度得分" subtitle="关注中难题稳定性,决定后续练习重点"> <ParentCard class="section-card" title="难度得分" subtitle="关注中难题稳定性,决定后续练习重点">
<view class="data-table"> <view v-if="pageLoading && !difficultyRows.length" class="answers-loading">
<xy-Empty type="loading" content="加载分析数据..." />
</view>
<view v-else-if="difficultyRows.length" class="data-table">
<view class="table-row table-head"> <view class="table-row table-head">
<text>难度</text> <text>难度</text>
<text>题号</text> <text></text>
<text>得分</text> <text>得分</text>
<text>得分率</text> <text>得分率</text>
</view> </view>
<view v-for="row in detail.difficultyRows" :key="row.difficulty" class="table-row"> <view v-for="row in difficultyRows" :key="row.difficulty" class="table-row">
<text>{{ row.difficulty }}</text> <text>{{ row.difficulty }}</text>
<text>{{ row.range }}</text> <text>{{ row.count }}</text>
<text class="strong">{{ row.score }}</text> <text class="strong">{{ row.score }}</text>
<text :class="row.rate === '100%' ? 'rate-good' : 'rate-warn'">{{ row.rate }}</text> <text :class="row.rateValue >= 80 ? 'rate-good' : 'rate-warn'">{{ row.rate }}</text>
</view>
</view>
<xy-Empty v-else type="task" content="暂无难度分析数据" />
</ParentCard>
<ParentCard
v-if="classRankList.length"
class="section-card"
title="班级排名"
subtitle="本次作业班级得分排行"
>
<view class="rank-list">
<view
v-for="item in classRankList"
:key="`${item.rankNum}-${item.stuName}`"
class="rank-list-item"
:class="{ self: item.selfFlag === 1 }"
>
<text class="rank-no">{{ item.rankNum }}</text>
<text class="rank-name">{{ item.stuName }}</text>
<text class="rank-score">{{ item.markScore }}/{{ item.paperScore }}</text>
<text class="rank-rate">{{ formatScoreRate(item.scoreRate) }}</text>
</view> </view>
</view> </view>
</ParentCard> </ParentCard>
@ -281,7 +308,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app'; import { onLoad } from '@dcloudio/uni-app';
import { getJobAnalyse, getPaperRecordDetail } from '@/api/homework'; import { getJobAnalyse, getPaperRecordDetail, type JobAnalyseVo } from '@/api/homework';
import ParentCard from '../components/ParentCard.vue'; import ParentCard from '../components/ParentCard.vue';
import ParentPage from '../components/ParentPage.vue'; import ParentPage from '../components/ParentPage.vue';
import { import {
@ -332,7 +359,7 @@ const rawTitleItems = ref<Record<string, unknown>[]>([]);
const questionSummaries = ref<ReportQuestionSummary[]>([]); const questionSummaries = ref<ReportQuestionSummary[]>([]);
const questionDetailCache = ref<Record<number, ReportQuestion>>({}); const questionDetailCache = ref<Record<number, ReportQuestion>>({});
const paperDetail = ref<Record<string, unknown>>({}); const paperDetail = ref<Record<string, unknown>>({});
const jobAnalyse = ref<Record<string, unknown>>({}); const jobAnalyse = ref<JobAnalyseVo>({});
onLoad((query) => { onLoad((query) => {
recordId.value = String(query?.id || ''); recordId.value = String(query?.id || '');
@ -346,14 +373,23 @@ onLoad((query) => {
const mockDetail = computed(() => reportDetails[recordId.value] || reportDetails.r1); const mockDetail = computed(() => reportDetails[recordId.value] || reportDetails.r1);
const formatRank = (rank?: unknown, total?: unknown) => {
if (rank == null || rank === '' || total == null || total === '') return ''; const formatScoreRate = (value?: number) => {
return `${rank}/${total}`; if (value == null) return '--';
const rate = value <= 1 ? value * 100 : value;
return `${Math.round(rate * 10) / 10}%`;
};
const formatDifficultyScoreRate = (actual?: number, total?: number) => {
const score = Number(total) || 0;
if (score <= 0) return { text: '--', value: 0 };
const rate = ((Number(actual) || 0) / score) * 100;
return { text: `${Math.round(rate * 10) / 10}%`, value: rate };
}; };
const scoreBlocks = computed<ReportScoreBlock[]>(() => { const scoreBlocks = computed<ReportScoreBlock[]>(() => {
const vo = (jobAnalyse.value?.jobReportVo || {}) as Record<string, unknown>; const vo = jobAnalyse.value.jobReportVo || {};
const mark = Number(vo.markScore ?? paperDetail.value.score ?? 0); const mark = Number(vo.actualScore ?? paperDetail.value.score ?? 0);
const total = Number(vo.totalScore ?? paperDetail.value.totalScore ?? 0); const total = Number(vo.totalScore ?? paperDetail.value.totalScore ?? 0);
const rateFromScore = const rateFromScore =
total > 0 ? `${Math.round((mark / total) * 1000) / 10}%` : reportScoreRate.value ? `${reportScoreRate.value}%` : '--'; total > 0 ? `${Math.round((mark / total) * 1000) / 10}%` : reportScoreRate.value ? `${reportScoreRate.value}%` : '--';
@ -365,27 +401,58 @@ const scoreBlocks = computed<ReportScoreBlock[]>(() => {
]; ];
}); });
const classRankList = computed(() => jobAnalyse.value.stuRankVoList || []);
const ranks = computed(() => { const ranks = computed(() => {
const vo = (jobAnalyse.value?.jobReportVo || {}) as Record<string, unknown>; const rankList = classRankList.value;
const paper = paperDetail.value; const selfRank = rankList.find((item) => item.selfFlag === 1);
const classRank = formatRank( const classRank =
vo.classRank ?? paper.classRank ?? paper.classRanking, selfRank?.rankNum != null && rankList.length
vo.classTotal ?? paper.classTotal ?? paper.classRankTotal, ? `${selfRank.rankNum}/${rankList.length}`
); : '';
const gradeRank = formatRank(
vo.gradeRank ?? paper.gradeRank ?? paper.gradeRanking,
vo.gradeTotal ?? paper.gradeTotal ?? paper.gradeRankTotal,
);
return [ return [
{ label: '班级排名', value: classRank || mockDetail.value.ranks[0]?.value || '--' }, { label: '班级排名', value: classRank || '--' },
{ label: '年级排名', value: gradeRank || mockDetail.value.ranks[1]?.value || '--' }, { label: '年级排名', value: '--' },
]; ];
}); });
const analysisRows = computed(() => {
const list = jobAnalyse.value.paperChanelAnalyzeList || [];
const totalCount = list.reduce((sum, item) => sum + (Number(item.titleCount) || 0), 0);
return list.map((item) => {
const count = Number(item.titleCount) || 0;
const rate =
totalCount > 0 ? `${Math.round((count / totalCount) * 1000) / 10}%` : '--';
return {
type: item.chanelTypeName || '--',
count: String(count),
rate,
};
});
});
const difficultyRows = computed(() =>
(jobAnalyse.value.paperDifficultyAnalyzeList || []).map((item) => {
const actual = Number(item.actualScore) || 0;
const total = Number(item.score) || 0;
const { text, value } = formatDifficultyScoreRate(actual, total);
return {
difficulty: item.difficultyName || '--',
count: String(Number(item.titleCount) || 0),
score: total > 0 ? `${actual}/${total}` : String(actual),
rate: text,
rateValue: value,
};
}),
);
const report = computed(() => ({ const report = computed(() => ({
category: reportCategory.value || getPaperTestTypeLabelFromPaper(), category: reportCategory.value || getPaperTestTypeLabelFromPaper(),
title: reportName.value || String(paperDetail.value.paperName || '作业详情'), title:
reportName.value ||
jobAnalyse.value.jobReportVo?.jobName ||
String(paperDetail.value.paperName || '作业详情'),
date: reportDate.value, date: reportDate.value,
subject: reportSubject.value || String(paperDetail.value.subjectName || '多科'), subject: reportSubject.value || String(paperDetail.value.subjectName || '多科'),
})); }));
@ -495,7 +562,7 @@ const loadReportData = async () => {
]); ]);
paperDetail.value = (detailRes.data || {}) as Record<string, unknown>; paperDetail.value = (detailRes.data || {}) as Record<string, unknown>;
jobAnalyse.value = (analyseRes.data || {}) as Record<string, unknown>; jobAnalyse.value = analyseRes.data || {};
const titleList = paperDetail.value.titleVoList || paperDetail.value.subjectTitleVoList; const titleList = paperDetail.value.titleVoList || paperDetail.value.subjectTitleVoList;
rawTitleItems.value = Array.isArray(titleList) ? (titleList as Record<string, unknown>[]) : []; rawTitleItems.value = Array.isArray(titleList) ? (titleList as Record<string, unknown>[]) : [];
@ -705,6 +772,10 @@ const goHome = () => {
border-radius: 26rpx; border-radius: 26rpx;
} }
.table-row.channel-row {
grid-template-columns: repeat(3, 1fr);
}
.table-row { .table-row {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
@ -1091,6 +1162,49 @@ const goHome = () => {
padding-left: 24rpx; padding-left: 24rpx;
} }
.rank-list {
border: 2rpx solid #e2e8f0;
border-radius: 26rpx;
overflow: hidden;
}
.rank-list-item {
display: grid;
grid-template-columns: 80rpx 1fr 140rpx 120rpx;
min-height: 74rpx;
align-items: center;
padding: 0 20rpx;
color: #334155;
font-size: 24rpx;
border-top: 2rpx solid #f1f5f9;
}
.rank-list-item:first-child {
border-top: 0;
}
.rank-list-item.self {
color: #1d4ed8;
background: #eff6ff;
font-weight: 900;
}
.rank-no {
text-align: center;
font-weight: 900;
}
.rank-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rank-score,
.rank-rate {
text-align: center;
}
.bottom-actions { .bottom-actions {
position: fixed; position: fixed;
left: 28rpx; left: 28rpx;

View File

@ -11,7 +11,8 @@ export interface ParentChild {
export interface SubjectTrend { export interface SubjectTrend {
subject: string; subject: string;
status: 'up' | 'down' | 'stable'; status?: 'up' | 'down' | 'stable' | 'none';
trendName?: string;
} }
export interface GradeBand { export interface GradeBand {

View File

@ -170,10 +170,10 @@
<view class="safe-bottom"></view> <view class="safe-bottom"></view>
</scroll-view> </scroll-view>
<view class="home-float" @click="goHome"> <!-- <view class="home-float" @click="goHome">
<view class="home-roof"></view> <view class="home-roof"></view>
<view class="home-body"></view> <view class="home-body"></view>
</view> </view> -->
<view v-if="!pageLoading && filteredQuestions.length" class="bottom-bar"> <view v-if="!pageLoading && filteredQuestions.length" class="bottom-bar">
<view class="download-btn" @click="downloadWrongQuestions">一键下载错题</view> <view class="download-btn" @click="downloadWrongQuestions">一键下载错题</view>
@ -183,9 +183,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app'; import { onLoad, onShow } from '@dcloudio/uni-app';
import { storeToRefs } from 'pinia';
import { getWrongQuestionSummary, type WrongQuestionSummaryResult } from '@/api/parent';
import { getListCompleteRecord, getPaperRecordDetail } from '../../api/homework'; import { getListCompleteRecord, getPaperRecordDetail } from '../../api/homework';
import { resource_type_homework } from '@/constants/doWork'; import { resource_type_homework } from '@/constants/doWork';
import { useParentStore } from '@/state/modules/parent';
import { useUserStore } from '@/state/modules/user';
type WrongStatus = 'new' | 'review' | 'mastered'; type WrongStatus = 'new' | 'review' | 'mastered';
type TabKey = 'all' | 'new' | 'mastered'; type TabKey = 'all' | 'new' | 'mastered';
@ -215,7 +219,13 @@ interface WrongQuestion {
openPanel: 'mine' | 'analysis'; openPanel: 'mine' | 'analysis';
} }
const userStore = useUserStore();
const parentStore = useParentStore();
const { userInfo } = storeToRefs(userStore);
const { activeChild } = storeToRefs(parentStore);
const pageLoading = ref(true); const pageLoading = ref(true);
const summaryData = ref<WrongQuestionSummaryResult | null>(null);
const wrongQuestions = ref<WrongQuestion[]>([]); const wrongQuestions = ref<WrongQuestion[]>([]);
const selectedSubject = ref('全部'); const selectedSubject = ref('全部');
const activeTab = ref<TabKey>('all'); const activeTab = ref<TabKey>('all');
@ -231,21 +241,64 @@ const tabs: { key: TabKey; label: string }[] = [
const subjectTones = ['blue', 'green', 'orange', 'rose', 'violet']; const subjectTones = ['blue', 'green', 'orange', 'rose', 'violet'];
const totalWrong = computed(() => wrongQuestions.value.length); const totalWrong = computed(() => {
const subjects = summaryData.value?.subjects || [];
if (subjects.length) {
return subjects.reduce((sum, item) => sum + (item.totalCount || 0), 0);
}
return wrongQuestions.value.length;
});
const subjectStats = computed(() => { const buildSubjectStatRow = (
const subjects = ['全部', ...Array.from(new Set(wrongQuestions.value.map((item) => item.subject).filter(Boolean)))]; name: string,
return subjects.map((name, index) => { stats: { total: number; newCount: number; reviewCount: number; masteredCount: number },
const list = name === '全部' ? wrongQuestions.value : wrongQuestions.value.filter((item) => item.subject === name); index: number,
return { ) => ({
name, name,
shortName: name === '全部' ? '全' : name.slice(0, 1), shortName: name === '全部' ? '全' : name.slice(0, 1),
tone: subjectTones[index % subjectTones.length], tone: subjectTones[index % subjectTones.length],
total: stats.total,
newCount: stats.newCount,
reviewCount: stats.reviewCount,
masteredCount: stats.masteredCount,
});
const subjectStats = computed(() => {
const apiSubjects = summaryData.value?.subjects || [];
if (apiSubjects.length) {
const allStats = apiSubjects.reduce<{ total: number; newCount: number; reviewCount: number; masteredCount: number }>(
(acc, item) => ({
total: acc.total + (item.totalCount || 0),
newCount: acc.newCount + (item.newCount || 0),
reviewCount: acc.reviewCount + (item.reviewCount || 0),
masteredCount: acc.masteredCount + (item.masteredCount || 0),
}),
{ total: 0, newCount: 0, reviewCount: 0, masteredCount: 0 },
);
return [
buildSubjectStatRow('全部', allStats, 0),
...apiSubjects.map((item, index) => buildSubjectStatRow(
item.subjectName || '其他',
{
total: item.totalCount || 0,
newCount: item.newCount || 0,
reviewCount: item.reviewCount || 0,
masteredCount: item.masteredCount || 0,
},
index + 1,
)),
];
}
const subjects = ['全部', ...Array.from(new Set(wrongQuestions.value.map((item) => item.subject).filter(Boolean)))];
return subjects.map((name, index) => {
const list = name === '全部' ? wrongQuestions.value : wrongQuestions.value.filter((item) => item.subject === name);
return buildSubjectStatRow(name, {
total: list.length, total: list.length,
newCount: list.filter((item) => item.status === 'new').length, newCount: list.filter((item) => item.status === 'new').length,
reviewCount: list.filter((item) => item.inReviewBook).length, reviewCount: list.filter((item) => item.inReviewBook).length,
masteredCount: list.filter((item) => item.status === 'mastered').length, masteredCount: list.filter((item) => item.status === 'mastered').length,
}; }, index);
}); });
}); });
@ -352,9 +405,53 @@ const mapQuestion = (record: any, item: any, index: number): WrongQuestion => ({
openPanel: 'analysis', openPanel: 'analysis',
}); });
const resolveUserId = async () => {
try {
if (!userInfo.value.id) {
await userStore.fetchParentUserInfo();
}
if (userInfo.value.id) {
await parentStore.fetchBindChildren(userInfo.value.id);
}
} catch {
/* ignore */
}
if (activeChild.value.id) {
return activeChild.value.id;
}
try {
if (!userInfo.value.id) {
await userStore.fetchUserInfo();
}
} catch {
/* ignore */
}
return userInfo.value.id || '';
};
const loadSummary = async () => {
const userId = await resolveUserId();
if (!userId) {
summaryData.value = null;
return;
}
try {
const res = await getWrongQuestionSummary({ userId });
summaryData.value = res.data || null;
} catch (err) {
console.error('[wrong] summary err', err);
summaryData.value = null;
}
};
const loadWrongQuestions = async () => { const loadWrongQuestions = async () => {
pageLoading.value = true; pageLoading.value = true;
try { try {
await loadSummary();
const recordsRes: any = await getListCompleteRecord(); const recordsRes: any = await getListCompleteRecord();
const records = Array.isArray(recordsRes?.data) ? recordsRes.data : Array.isArray(recordsRes) ? recordsRes : []; const records = Array.isArray(recordsRes?.data) ? recordsRes.data : Array.isArray(recordsRes) ? recordsRes : [];
const detailRecords = await Promise.all( const detailRecords = await Promise.all(
@ -421,6 +518,20 @@ const goHome = () => {
uni.reLaunch({ url: '/pages/index/index' }); uni.reLaunch({ url: '/pages/index/index' });
}; };
onShow(async () => {
try {
if (!userInfo.value.id) {
await userStore.fetchParentUserInfo();
}
if (userInfo.value.id) {
await parentStore.fetchBindChildren(userInfo.value.id);
}
} catch {
/* ignore */
}
await loadSummary();
});
onLoad(() => { onLoad(() => {
loadWrongQuestions(); loadWrongQuestions();
}); });

View File

@ -104,3 +104,9 @@ export const SUBJECT_FILTER_OPTIONS: SubjectFilterOption[] = [
subjectId: String(item.value), subjectId: String(item.value),
})), })),
]; ];
/** 学科选择器选项(不含「全部学科」,默认选中第一项) */
export const SUBJECT_PICKER_OPTIONS: SubjectFilterOption[] = SUBJECT_OPTIONS.map((item) => ({
...item,
subjectId: String(item.value),
}));