1792 lines
55 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">
<view class="detail-page">
<ParentCard class="hero-card">
<view class="hero-top">
<view>
<text class="category-tag">{{ report.category }}</text>
<text class="report-title">{{ report.title }}</text>
<text class="report-date">{{ report.date }}</text>
</view>
<text class="subject-text">{{ report.subject }}</text>
</view>
<view class="score-grid">
<view v-for="item in detail.scoreBlocks" :key="item.label" class="score-item">
<text class="score-label">{{ item.label }}</text>
<text class="score-value">{{ item.value }}</text>
<text class="score-desc">{{ item.desc }}</text>
</view>
</view>
</ParentCard>
<ParentCard class="nav-card">
<view class="tab-row">
<view
v-for="tab in tabs"
:key="tab.key"
class="tab-item"
:class="{ active: activeTab === tab.key }"
@click="switchTab(tab.key)"
>
{{ tab.label }}
</view>
</view>
<view class="rank-grid">
<view v-for="item in detail.ranks" :key="item.label" class="rank-item">
<text class="rank-label">{{ item.label }}</text>
<text class="rank-value">{{ item.value }}</text>
</view>
</view>
</ParentCard>
<template v-if="activeTab === 'analysis'">
<ParentCard class="section-card" title="题型分布" subtitle="按题型拆解本次卷面构成">
<view v-if="pageLoading && !channelAnalyzeList.length" class="answers-loading">
<xy-Empty type="loading" content="加载分析数据..." />
</view>
<view v-else-if="channelAnalyzeList.length" class="chart-block">
<view class="pie-row">
<view class="pie">
<view class="pie-circle" :style="channelPieStyle"></view>
<view class="pie-hole">
<text class="pie-hole-num">{{ totalQuestionCount }}</text>
<text class="pie-hole-label">题</text>
</view>
</view>
<view class="pie-legend">
<view
v-for="(item, idx) in channelAnalyzeList"
:key="idx"
class="legend-item"
>
<view
class="legend-dot"
:style="{ background: channelPieColors[idx % channelPieColors.length] }"
></view>
<text class="legend-name">{{ item.chanelTypeName }}</text>
<text class="legend-count">{{ item.titleCount }}题</text>
</view>
</view>
</view>
<view class="data-table chart-table">
<view class="table-row table-head channel-row">
<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 class="strong">{{ row.count }}</text>
<text class="rate-good">{{ row.rate }}</text>
</view>
</view>
</view>
<xy-Empty v-else type="task" content="暂无题型分析数据" />
</ParentCard>
<ParentCard class="section-card" title="难度分布" subtitle="各难度档位题量占比">
<view v-if="pageLoading && !difficultyAnalyzeList.length" class="answers-loading">
<xy-Empty type="loading" content="加载分析数据..." />
</view>
<view v-else-if="difficultyAnalyzeList.length" class="chart-block">
<view class="pie-row">
<view class="pie">
<view class="pie-circle" :style="difficultyPieStyle"></view>
<view class="pie-hole">
<text class="pie-hole-num">{{ totalDifficultyCount }}</text>
<text class="pie-hole-label"></text>
</view>
</view>
<view class="pie-legend">
<view
v-for="(item, idx) in difficultyAnalyzeList"
:key="idx"
class="legend-item"
>
<view
class="legend-dot"
:style="{ background: getDifficultyColor(item.difficultyName) }"
></view>
<text class="legend-name">{{ item.difficultyName }}</text>
<text class="legend-count">{{ item.titleCount }}</text>
</view>
</view>
</view>
<view class="bars">
<view
v-for="(item, idx) in difficultyAnalyzeList"
:key="idx"
class="bar-row"
>
<view class="bar-name">{{ item.difficultyName }}</view>
<view class="bar-track">
<view
class="bar-fill"
:style="{
width: getDifficultyBarWidth(item.score) + '%',
background: getDifficultyColor(item.difficultyName),
}"
></view>
</view>
<view class="bar-value">{{ item.score }}<text class="bar-value-unit"></text></view>
</view>
</view>
</view>
<xy-Empty v-else type="task" content="暂无难度分析数据" />
</ParentCard>
<ParentCard class="section-card" title="难度得分" subtitle="关注中难题稳定性,决定后续练习重点">
<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">
<text>难度</text>
<text>题量</text>
<text>得分</text>
<text>得分率</text>
</view>
<view v-for="row in difficultyRows" :key="row.difficulty" class="table-row">
<text>{{ row.difficulty }}</text>
<text>{{ row.count }}</text>
<text class="strong">{{ row.score }}</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>
</ParentCard>
</template>
<view v-if="activeTab === 'answers'" class="answers-panel">
<ParentCard class="section-card" title="答题卡详情" subtitle="点击题号查看每道题的答题情况">
<view class="answer-legend">
<view><text class="legend-dot correct"></text>正确</view>
<view><text class="legend-dot wrong"></text>错误</view>
<view><text class="legend-dot unanswered"></text>未答</view>
</view>
<view v-if="pageLoading && !questionSummaries.length" class="answers-loading">
<xy-Empty type="loading" content="加载答题卡..." />
</view>
<view v-else-if="questionSummaries.length" class="question-grid">
<view
v-for="question in questionSummaries"
:key="question.no"
class="question-no"
:class="[question.status, { active: selectedNo === question.no }]"
@click="selectQuestion(question.no)"
>
{{ question.no }}
</view>
</view>
<xy-Empty v-else type="task" content="暂无题目数据" />
</ParentCard>
<ParentCard v-if="selectedNo && selectedQuestionSummary" class="section-card" title="当前题目详情">
<view class="question-head">
<text> {{ selectedQuestionSummary.no }} </text>
<text>{{ selectedQuestionSummary.type }}</text>
<text :class="statusClass(selectedQuestionSummary.status)">
{{ statusLabel(selectedQuestionSummary.status) }}
</text>
</view>
<view v-if="detailLoadingNo === selectedNo" class="answers-loading">
<xy-Empty type="loading" content="加载题目详情..." />
</view>
<view v-else-if="selectedQuestionDetail" class="detail-fields">
<view class="field-card">
<text class="field-label">题干</text>
<view v-if="selectedQuestionDetail.stemHtml" class="field-content rich-content">
<rich-text :nodes="selectedQuestionDetail.stemHtml" />
</view>
<view v-if="selectedQuestionDetail.stemImages?.length" class="stem-images">
<image
v-for="(img, imgIdx) in selectedQuestionDetail.stemImages"
:key="imgIdx"
:src="img"
mode="widthFix"
class="stem-image"
@click="previewStemImage(imgIdx)"
/>
</view>
<text
v-if="!selectedQuestionDetail.stemHtml && !selectedQuestionDetail.stemImages?.length"
class="field-content"
>
暂无题干
</text>
</view>
<!-- 选项模块参考 web Subject/doWork SingleChoice/MultipleChoice 组件 -->
<view
v-if="selectedQuestionDetail.options?.length"
class="field-card options-card"
>
<text class="field-label">选项</text>
<view class="option-list">
<view
v-for="(opt, optIdx) in selectedQuestionDetail.options"
:key="optIdx"
class="option-item"
:class="optionItemClass(opt, optIdx)"
>
<text class="option-label">{{ opt.label }}</text>
<view class="option-value">
<rich-text
v-if="isHtmlContent(opt.value)"
:nodes="opt.value"
/>
<text v-else>{{ opt.value }}</text>
</view>
</view>
</view>
</view>
<view class="field-card success">
<text class="field-label">正确答案</text>
<view v-if="selectedQuestionDetail.correctAnswerHtml" class="field-content rich-content">
<rich-text :nodes="selectedQuestionDetail.correctAnswerHtml" />
</view>
<text v-else class="field-content">--</text>
</view>
<view class="field-card student">
<text class="field-label">学员答案</text>
<view v-if="selectedQuestionDetail.studentAnswerImages?.length" class="answer-images">
<image
v-for="(img, imgIdx) in selectedQuestionDetail.studentAnswerImages"
:key="imgIdx"
:src="img"
mode="widthFix"
class="answer-image"
@click="previewStudentImage(imgIdx)"
/>
</view>
<view v-if="selectedQuestionDetail.studentAnswerHtml" class="field-content rich-content">
<rich-text :nodes="selectedQuestionDetail.studentAnswerHtml" />
</view>
<text
v-else-if="selectedQuestionDetail.studentAnswer"
class="field-content"
>{{ selectedQuestionDetail.studentAnswer }}</text>
<text
v-else-if="!selectedQuestionDetail.studentAnswerImages?.length"
class="field-content"
>未作答</text>
</view>
<view class="field-card">
<text class="field-label">得分情况</text>
<text class="field-content">{{ selectedQuestionSummary.score }}</text>
</view>
<view class="field-card">
<text class="field-label">班级得分率</text>
<text class="field-content">{{ selectedQuestionSummary.classRate }}</text>
</view>
<view class="field-card">
<text class="field-label">学校得分率</text>
<text class="field-content">{{ selectedQuestionSummary.schoolRate }}</text>
</view>
</view>
</ParentCard>
<ParentCard class="section-card" title="每道题具体情况">
<view class="table-toggle" @click="showQuestionTable = !showQuestionTable">
<text>{{ showQuestionTable ? '收起列表' : '展开查看全部题目' }}</text>
<text class="table-toggle-icon">{{ showQuestionTable ? '▲' : '▼' }}</text>
</view>
<scroll-view v-if="showQuestionTable" scroll-x class="question-table-scroll">
<view class="question-table">
<view class="question-row table-head">
<text>题号</text>
<text>题型</text>
<text>得分</text>
<text>状态</text>
<text>知识点</text>
<text>班级得分率</text>
<text>学校得分率</text>
</view>
<view
v-for="question in questionSummaries"
:key="`row-${question.no}`"
class="question-row"
@click="selectQuestion(question.no)"
>
<text>{{ question.no }}</text>
<text>{{ question.type }}</text>
<text>{{ question.score }}</text>
<text :class="statusClass(question.status)">
{{ statusLabel(question.status) }}
</text>
<text>{{ question.knowledge }}</text>
<text>{{ question.classRate }}</text>
<text>{{ question.schoolRate }}</text>
</view>
</view>
</scroll-view>
</ParentCard>
</view>
<template v-if="activeTab === 'knowledge'">
<ParentCard class="section-card" title="错题知识点占比" subtitle="用于判断最近应优先补强的知识点">
<view v-if="pageLoading && !knowledgeList.length" class="answers-loading">
<xy-Empty type="loading" content="加载知识点..." />
</view>
<view v-else-if="knowledgeRatios.length" class="knowledge-summary">
<view class="wrong-total">
<text class="wrong-number">{{ knowledgeWeakCount }}</text>
<text class="wrong-label">薄弱知识点</text>
</view>
<scroll-view
scroll-y
class="ratio-list"
:style="{ height: ratioListHeight }"
:show-scrollbar="false"
>
<view v-for="item in knowledgeRatios" :key="item.name" class="ratio-row">
<view class="ratio-meta">
<text>{{ item.name }}</text>
<text>{{ item.percent }}%</text>
</view>
<view class="ratio-track">
<view class="ratio-fill" :class="item.tone" :style="{ width: `${item.percent}%` }"></view>
</view>
</view>
</scroll-view>
</view>
<xy-Empty v-else type="learn" content="暂无知识点占比数据" />
</ParentCard>
<ParentCard class="section-card" title="知识点得分">
<view v-if="pageLoading && !knowledgeList.length" class="answers-loading">
<xy-Empty type="loading" content="加载知识点..." />
</view>
<template v-else-if="knowledgeList.length">
<view class="level-tabs">
<view
v-for="level in knowledgeTabs"
:key="level.key"
class="level-tab"
:class="{ active: activeKnowledgeLevel === level.key }"
@click="activeKnowledgeLevel = level.key"
>
{{ level.label }}
</view>
</view>
<view class="data-table">
<view class="table-row table-head knowledge-row">
<text>知识点</text>
<text>满分</text>
<text>得分</text>
</view>
<view v-for="item in filteredKnowledgeScores" :key="item.name" class="table-row knowledge-row">
<text class="knowledge-name">{{ item.name }}</text>
<text>{{ item.fullScore }}</text>
<text :class="knowledgeScoreClass(item)">{{ item.score }}[{{ knowledgeRate(item) }}%]</text>
</view>
</view>
<xy-Empty v-if="!filteredKnowledgeScores.length" type="learn" content="当前分段暂无知识点" />
</template>
<xy-Empty v-else type="learn" content="暂无知识点数据" />
</ParentCard>
</template>
</view>
<view v-if="showDownloadReport" class="bottom-actions">
<button class="download-btn" @click="downloadReport">下载报告</button>
<!-- <view class="home-btn" @click="goHome">
<text class="home-roof"></text>
<text class="home-body"></text>
</view> -->
</view>
</ParentPage>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { getJobAnalyse, getKnowledgeAnalyse, getPaperRecordDetail, type JobAnalyseVo, type KnowledgeAnalyzeItem } from '@/api/homework';
import { useParentStore } from '@/state/modules/parent';
import { storeToRefs } from 'pinia';
import ParentCard from '../components/ParentCard.vue';
import ParentPage from '../components/ParentPage.vue';
import {
reportDetails,
type ReportKnowledgeScore,
type ReportQuestion,
type ReportQuestionSummary,
type ReportScoreBlock,
type ReportKnowledgeRatio,
} from '../utils/data';
import {
mapPaperQuestion,
mapPaperQuestionSummaries,
normalizeLegacyQuestion,
} from '../utils/paperDetail';
import { isHtmlContent } from '@/utils/questionHtml';
type DetailTab = 'analysis' | 'answers' | 'knowledge';
type KnowledgeLevel = 'all' | 'weak' | 'pass' | 'good';
const tabs: { key: DetailTab; label: string }[] = [
{ key: 'analysis', label: '试卷分析' },
{ key: 'answers', label: '答题卡' },
{ key: 'knowledge', label: '知识点' },
];
const knowledgeTabs: { key: KnowledgeLevel; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'weak', label: '薄弱' },
{ key: 'pass', label: '合格' },
{ key: 'good', label: '良好' },
];
const REPORT_FLAG = 1;
const showDownloadReport = false;
const parentStore = useParentStore();
const { activeChild } = storeToRefs(parentStore);
const channelPieColors = ['#2563eb', '#22c55e', '#f59e0b', '#ef4444', '#0ea5e9', '#a855f7', '#ec4899'];
const difficultyBaseColorMap: Record<string, string> = {
容易: '#22c55e',
较易: '#0ea5e9',
一般: '#f59e0b',
普通: '#f59e0b',
中等: '#f59e0b',
较难: '#f97316',
困难: '#ef4444',
};
const difficultyFallbackColors = ['#6366f1', '#14b8a6', '#f472b6', '#84cc16'];
const recordId = ref('');
const childUserId = ref('');
const reportName = ref('');
const reportSubject = ref('');
const reportCategory = ref('');
const reportDate = ref('');
const reportScoreRate = ref('');
const activeTab = ref<DetailTab>('analysis');
const selectedNo = ref(0);
const activeKnowledgeLevel = ref<KnowledgeLevel>('all');
const pageLoading = ref(false);
const showQuestionTable = ref(false);
const detailLoadingNo = ref(0);
const rawTitleItems = ref<Record<string, unknown>[]>([]);
const questionSummaries = ref<ReportQuestionSummary[]>([]);
const questionDetailCache = ref<Record<number, ReportQuestion>>({});
const paperDetail = ref<Record<string, unknown>>({});
const jobAnalyse = ref<JobAnalyseVo>({});
const knowledgeList = ref<KnowledgeAnalyzeItem[]>([]);
onLoad((query) => {
recordId.value = String(query?.id || '');
childUserId.value = String(query?.userId || '');
reportName.value = query?.name ? decodeURIComponent(String(query.name)) : '';
reportSubject.value = query?.subject ? decodeURIComponent(String(query.subject)) : '';
reportCategory.value = query?.category ? decodeURIComponent(String(query.category)) : '';
reportDate.value = String(query?.date || '');
reportScoreRate.value = query?.score != null ? String(query.score) : '';
loadReportData();
});
const mockDetail = computed(() => reportDetails[recordId.value] || reportDetails.r1);
const formatScoreRate = (value?: number) => {
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 channelAnalyzeList = computed(() => jobAnalyse.value.paperChanelAnalyzeList || []);
const difficultyAnalyzeList = computed(() => jobAnalyse.value.paperDifficultyAnalyzeList || []);
const getDifficultyColor = (name?: string) => {
if (!name) return difficultyFallbackColors[0];
return difficultyBaseColorMap[name] || difficultyFallbackColors[name.length % difficultyFallbackColors.length];
};
const totalQuestionCount = computed(() =>
channelAnalyzeList.value.reduce((sum, item) => sum + (Number(item.titleCount) || 0), 0),
);
const totalDifficultyCount = computed(() =>
difficultyAnalyzeList.value.reduce((sum, item) => sum + (Number(item.titleCount) || 0), 0),
);
const buildPieStyle = (
list: { titleCount?: number }[],
colorFn: (idx: number) => string,
) => {
if (!list.length) return {};
const total = list.reduce((sum, item) => sum + (Number(item.titleCount) || 0), 0);
if (total <= 0) return {};
const parts: string[] = [];
let cur = 0;
list.forEach((item, idx) => {
const pct = ((Number(item.titleCount) || 0) / total) * 100;
const color = colorFn(idx);
const next = cur + pct;
parts.push(`${color} ${cur}% ${next}%`);
cur = next;
});
return { background: `conic-gradient(${parts.join(', ')})` };
};
const channelPieStyle = computed(() =>
buildPieStyle(channelAnalyzeList.value, (idx) => channelPieColors[idx % channelPieColors.length]),
);
const difficultyPieStyle = computed(() =>
buildPieStyle(difficultyAnalyzeList.value, (idx) =>
getDifficultyColor(difficultyAnalyzeList.value[idx]?.difficultyName),
),
);
const getDifficultyBarWidth = (score?: number) => {
const max = Math.max(...difficultyAnalyzeList.value.map((item) => Number(item.score) || 0), 1);
return Math.round(((Number(score) || 0) / max) * 100);
};
const scoreBlocks = computed<ReportScoreBlock[]>(() => {
const vo = jobAnalyse.value.jobReportVo || {};
const mark = Number(vo.actualScore ?? vo.markScore ?? paperDetail.value.score ?? 0);
const total = Number(vo.totalScore ?? paperDetail.value.totalScore ?? 0);
const rateFromScore =
total > 0 ? `${Math.round((mark / total) * 1000) / 10}%` : reportScoreRate.value ? `${reportScoreRate.value}%` : '--';
return [
{ label: '我的得分', value: String(mark), desc: `总分 ${total || '--'}` },
{ label: '满分', value: String(total || '--'), desc: '本次卷面' },
{ label: '得分率', value: rateFromScore, desc: total > 0 && mark / total >= 0.8 ? '超过多数同学' : '' },
];
});
const classRankList = computed(() => jobAnalyse.value.stuRankVoList || []);
const ranks = computed(() => {
const rankList = classRankList.value;
const selfRank = rankList.find((item) => item.selfFlag === 1);
const classRank =
selfRank?.rankNum != null && rankList.length
? `${selfRank.rankNum}/${rankList.length}`
: '';
return [
{ label: '班级排名', value: classRank || '--' },
{ label: '年级排名', value: '--' },
];
});
const analysisRows = computed(() => {
const list = channelAnalyzeList.value;
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(() => ({
category: reportCategory.value || getPaperTestTypeLabelFromPaper(),
title:
reportName.value ||
jobAnalyse.value.jobReportVo?.jobName ||
String(paperDetail.value.paperName || '作业详情'),
date: reportDate.value,
subject: reportSubject.value || String(paperDetail.value.subjectName || '多科'),
}));
const getPaperTestTypeLabelFromPaper = () => {
const type = Number(paperDetail.value.type);
if (!type) return '';
const map: Record<number, string> = {
1: '单元测试',
2: '周测',
3: '月测',
4: '随堂练习',
5: '课前预习',
};
return map[type] || '';
};
const detail = computed(() => ({
...mockDetail.value,
scoreBlocks: scoreBlocks.value,
ranks: ranks.value,
}));
const knowledgeScores = computed<ReportKnowledgeScore[]>(() =>
knowledgeList.value.map((item) => ({
name: item.knowledgeName || '--',
fullScore: Number(item.titleScore) || 0,
score: Number(item.answerScore) || 0,
})),
);
const ratioTones: ReportKnowledgeRatio['tone'][] = ['blue', 'orange', 'green'];
const knowledgeRatios = computed<ReportKnowledgeRatio[]>(() => {
const lostItems = knowledgeList.value
.map((item) => ({
name: item.knowledgeName || '--',
lost: Math.max(0, (Number(item.titleScore) || 0) - (Number(item.answerScore) || 0)),
}))
.filter((item) => item.lost > 0)
.sort((a, b) => b.lost - a.lost);
const totalLost = lostItems.reduce((sum, item) => sum + item.lost, 0);
if (totalLost <= 0) return [];
return lostItems.map((item, idx) => ({
name: item.name,
percent: Math.round((item.lost / totalLost) * 100),
tone: ratioTones[idx % ratioTones.length],
}));
});
const knowledgeWeakCount = computed(
() =>
knowledgeList.value.filter(
(item) => (Number(item.answerScore) || 0) < (Number(item.titleScore) || 0),
).length,
);
const RATIO_ROW_HEIGHT = 72;
const RATIO_LIST_MIN = 200;
const RATIO_LIST_MAX = 480;
const ratioListHeight = computed(() => {
const count = knowledgeRatios.value.length || 1;
const height = Math.min(Math.max(count * RATIO_ROW_HEIGHT, RATIO_LIST_MIN), RATIO_LIST_MAX);
return `${height}rpx`;
});
const selectedQuestionSummary = computed(
() => questionSummaries.value.find((item) => item.no === selectedNo.value) || null,
);
const selectedQuestionDetail = computed(() =>
selectedNo.value ? questionDetailCache.value[selectedNo.value] : undefined,
);
const wrongCount = computed(
() =>
questionSummaries.value.filter((item) => item.status === 'wrong' || item.status === 'unanswered').length,
);
const statusLabel = (status?: ReportQuestion['status']) => {
if (status === 'correct') return '正确';
if (status === 'unanswered') return '未答';
return '错误';
};
const statusClass = (status?: ReportQuestion['status']) => {
if (status === 'correct') return 'status-correct';
if (status === 'unanswered') return 'status-muted';
return 'status-wrong';
};
/** 从正确答案 HTML 中提取选项字母(如 "A"、"ABD"、"C"),与 web 版 extractChoiceLettersFromAnswerHtml 逻辑一致 */
const extractCorrectAnswerLetters = (): string[] => {
const detail = selectedQuestionDetail.value;
if (!detail) return [];
// 优先从 correctAnswerHtml 提取纯字母
const answerHtml = detail.correctAnswerHtml || '';
const stripped = answerHtml.replace(/<[^>]*>/g, '').replace(/&nbsp;/gi, ' ').toUpperCase();
const letters = stripped.match(/[A-Z]/g);
if (letters?.length) return [...new Set(letters)];
// 退而求其次从 studentAnswer 反推(仅当状态为正确时)
if (detail.status === 'correct') {
const stuAns = (detail.studentAnswer || '').toUpperCase().match(/[A-Z]/g);
if (stuAns?.length) return [...new Set(stuAns)];
}
return [];
};
/** 从学员答案中提取选项字母 */
const extractStudentAnswerLetters = (): string[] => {
const detail = selectedQuestionDetail.value;
if (!detail) return [];
const ans = (detail.studentAnswer || '').toUpperCase();
const letters = ans.match(/[A-Z]/g);
return letters ? [...new Set(letters)] : [];
};
/** 计算单个选项的样式类名,参考 web 端 SingleChoice/MultipleChoice 的 success/fail 样式 */
const optionItemClass = (opt: { label: string }, _optIdx: number) => {
const correctLetters = extractCorrectAnswerLetters();
const studentLetters = extractStudentAnswerLetters();
const label = opt.label.toUpperCase();
const isCorrect = correctLetters.includes(label);
const studentSelected = studentLetters.includes(label);
if (isCorrect) return 'option-correct';
if (studentSelected) return 'option-wrong';
return '';
};
const switchTab = (key: DetailTab) => {
activeTab.value = key;
if (key === 'answers') {
ensureDefaultQuestion();
}
};
/** 进入答题卡时默认选中并加载第一题 */
const ensureDefaultQuestion = () => {
if (!questionSummaries.value.length) return;
if (!selectedNo.value) {
selectedNo.value = questionSummaries.value[0].no;
}
loadQuestionDetail(selectedNo.value);
};
const loadQuestionDetail = (no: number) => {
if (questionDetailCache.value[no]) {
if (detailLoadingNo.value === no) detailLoadingNo.value = 0;
return;
}
if (detailLoadingNo.value === no) return;
detailLoadingNo.value = no;
try {
const index = questionSummaries.value.findIndex((item) => item.no === no);
if (index >= 0 && rawTitleItems.value[index]) {
questionDetailCache.value = {
...questionDetailCache.value,
[no]: mapPaperQuestion(rawTitleItems.value[index], index),
};
} else {
const mockQuestion = mockDetail.value.questions.find((item) => Number(item.no) === no);
if (mockQuestion) {
questionDetailCache.value = {
...questionDetailCache.value,
[no]: normalizeLegacyQuestion(mockQuestion as unknown as Record<string, unknown>),
};
}
}
} finally {
if (detailLoadingNo.value === no) {
detailLoadingNo.value = 0;
}
}
};
const selectQuestion = (no: number) => {
selectedNo.value = no;
loadQuestionDetail(no);
};
const loadReportData = async () => {
if (!recordId.value) return;
pageLoading.value = true;
try {
const analyseParams: { recordId: string; userId?: string } = {
recordId: recordId.value,
};
const studentId = activeChild.value.id || childUserId.value;
if (studentId) {
analyseParams.userId = studentId;
}
const detailRes = await getPaperRecordDetail({ recordId: recordId.value, reportFlag: REPORT_FLAG });
paperDetail.value = (detailRes.data || {}) as Record<string, unknown>;
try {
const analyseRes = await getJobAnalyse(analyseParams);
jobAnalyse.value = analyseRes.data || {};
} catch (analyseErr) {
console.error('[parent-report-detail] analyse err', analyseErr);
jobAnalyse.value = {};
}
try {
const knowledgeRes = await getKnowledgeAnalyse(analyseParams);
knowledgeList.value = Array.isArray(knowledgeRes.data) ? knowledgeRes.data : [];
} catch (knowledgeErr) {
console.error('[parent-report-detail] knowledge err', knowledgeErr);
knowledgeList.value = [];
}
const titleList = paperDetail.value.titleVoList || paperDetail.value.subjectTitleVoList;
rawTitleItems.value = Array.isArray(titleList) ? (titleList as Record<string, unknown>[]) : [];
questionSummaries.value = mapPaperQuestionSummaries(rawTitleItems.value);
if (!reportName.value) {
reportName.value = String(paperDetail.value.paperName || '');
}
if (!reportSubject.value) {
reportSubject.value = String(paperDetail.value.subjectName || '');
}
if (questionSummaries.value.length && !selectedNo.value) {
selectedNo.value = questionSummaries.value[0].no;
}
} catch (err) {
console.error('[parent-report-detail] load err', err);
uni.showToast({ title: '报告加载失败', icon: 'none' });
} finally {
pageLoading.value = false;
}
};
const knowledgeRate = (item: ReportKnowledgeScore) => {
if (!item.fullScore) return 0;
return Math.round((item.score / item.fullScore) * 100);
};
const knowledgeScoreClass = (item: ReportKnowledgeScore) => {
const rate = knowledgeRate(item);
if (rate >= 80) return 'rate-good';
if (rate >= 60) return 'rate-warn';
return 'score-danger';
};
const knowledgeLevelOf = (item: ReportKnowledgeScore): KnowledgeLevel => {
const rate = knowledgeRate(item);
if (rate < 60) return 'weak';
if (rate < 80) return 'pass';
return 'good';
};
const filteredKnowledgeScores = computed(() => {
if (activeKnowledgeLevel.value === 'all') return knowledgeScores.value;
return knowledgeScores.value.filter((item) => knowledgeLevelOf(item) === activeKnowledgeLevel.value);
});
const previewStemImage = (index: number) => {
const urls = selectedQuestionDetail.value?.stemImages || [];
if (!urls.length) return;
uni.previewImage({ urls, current: urls[index] });
};
const previewStudentImage = (index: number) => {
const urls = selectedQuestionDetail.value?.studentAnswerImages || [];
if (!urls.length) return;
uni.previewImage({ urls, current: urls[index] });
};
const downloadReport = () => {
uni.showToast({
title: '报告生成中',
icon: 'none',
});
};
const goHome = () => {
uni.reLaunch({ url: '/pages/parent/home/index' });
};
</script>
<style lang="scss" scoped>
.detail-page {
display: flex;
flex-direction: column;
gap: 36rpx;
padding-bottom: calc(144rpx + env(safe-area-inset-bottom));
overflow-x: hidden;
width: 100%;
box-sizing: border-box;
}
.hero-card {
background:
linear-gradient(135deg, rgba(37, 99, 235, 0.08), rgba(20, 184, 166, 0.08)),
rgba(255, 255, 255, 0.96);
}
.hero-top {
display: flex;
justify-content: space-between;
gap: 22rpx;
}
.category-tag {
display: inline-flex;
padding: 8rpx 16rpx;
border-radius: 999rpx;
color: #1d4ed8;
background: #dbeafe;
font-size: 22rpx;
font-weight: 800;
}
.report-title {
display: block;
margin-top: 18rpx;
color: #0f172a;
font-size: 34rpx;
font-weight: 900;
line-height: 1.35;
}
.report-date {
display: block;
margin-top: 12rpx;
color: #64748b;
font-size: 24rpx;
}
.subject-text {
color: #0f172a;
font-size: 28rpx;
font-weight: 900;
flex-shrink: 0;
}
.score-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18rpx;
margin-top: 30rpx;
}
.score-item {
min-height: 138rpx;
border-radius: 28rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #ffffff;
box-shadow: inset 0 -2rpx 0 rgba(191, 219, 254, 0.7);
}
.score-label,
.score-desc {
color: #64748b;
font-size: 22rpx;
}
.score-value {
margin: 8rpx 0 4rpx;
color: #2563eb;
font-size: 42rpx;
font-weight: 900;
}
.tab-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12rpx;
padding: 8rpx;
border-radius: 28rpx;
background: #f1f5f9;
}
.tab-item {
height: 70rpx;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
color: #475569;
font-size: 25rpx;
font-weight: 800;
}
.tab-item.active {
color: #1d4ed8;
background: #ffffff;
box-shadow: 0 10rpx 22rpx rgba(37, 99, 235, 0.12);
}
.rank-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18rpx;
margin-top: 24rpx;
}
.rank-item {
min-height: 110rpx;
border-radius: 28rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #f8fafc;
}
.rank-label {
color: #64748b;
font-size: 22rpx;
}
.rank-value {
margin-top: 6rpx;
color: #2563eb;
font-size: 38rpx;
font-weight: 900;
}
.data-table {
overflow: hidden;
border: 2rpx solid #e2e8f0;
border-radius: 26rpx;
}
.chart-block {
display: flex;
flex-direction: column;
gap: 28rpx;
}
.chart-table {
margin-top: 4rpx;
}
.pie-row {
display: flex;
align-items: center;
gap: 24rpx;
}
.pie {
position: relative;
width: 200rpx;
height: 200rpx;
flex-shrink: 0;
}
.pie-circle {
width: 100%;
height: 100%;
border-radius: 50%;
box-shadow: 0 8rpx 16rpx rgba(37, 99, 235, 0.12);
}
.pie-hole {
position: absolute;
inset: 28rpx;
border-radius: 50%;
background: #ffffff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow:
inset 0 4rpx 8rpx rgba(0, 0, 0, 0.06),
0 2rpx 4rpx rgba(0, 0, 0, 0.04);
}
.pie-hole-num {
font-size: 44rpx;
font-weight: 900;
color: #0f172a;
line-height: 1;
}
.pie-hole-label {
font-size: 20rpx;
color: #94a3b8;
margin-top: 4rpx;
}
.pie-legend {
flex: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
min-width: 0;
}
.legend-item {
display: flex;
align-items: center;
gap: 12rpx;
}
.legend-name {
flex: 1;
font-size: 24rpx;
color: #475569;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.legend-count {
font-size: 24rpx;
color: #2563eb;
font-weight: 800;
flex-shrink: 0;
}
.bars {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.bar-row {
display: flex;
align-items: center;
gap: 16rpx;
}
.bar-name {
width: 88rpx;
flex-shrink: 0;
font-size: 24rpx;
color: #475569;
font-weight: 700;
text-align: right;
}
.bar-track {
flex: 1;
height: 24rpx;
border-radius: 999rpx;
background: #f1f5f9;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 999rpx;
transition: width 0.3s ease;
}
.bar-value {
width: 88rpx;
flex-shrink: 0;
font-size: 26rpx;
font-weight: 900;
color: #0f172a;
text-align: right;
}
.bar-value-unit {
font-size: 20rpx;
font-weight: 600;
color: #94a3b8;
}
.table-row.channel-row {
grid-template-columns: repeat(3, 1fr);
}
.table-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
min-height: 74rpx;
align-items: center;
color: #334155;
font-size: 24rpx;
text-align: center;
border-top: 2rpx solid #f1f5f9;
}
.table-row:first-child {
border-top: 0;
}
.table-head {
color: #475569;
background: #f8fafc;
font-weight: 900;
}
.strong,
.rate-good,
.status-correct {
color: #16a34a;
font-weight: 900;
}
.rate-warn,
.score-danger,
.status-wrong {
color: #ef4444;
font-weight: 900;
}
.status-muted {
color: #64748b;
font-weight: 900;
}
.answer-legend {
display: flex;
gap: 28rpx;
color: #475569;
font-size: 24rpx;
}
.answer-legend view {
display: flex;
align-items: center;
gap: 10rpx;
}
.legend-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
}
.legend-dot.correct {
background: #22c55e;
}
.legend-dot.wrong {
background: #ef4444;
}
.legend-dot.unanswered {
background: #94a3b8;
}
.answers-loading {
min-height: 220rpx;
margin-top: 24rpx;
}
.answers-panel {
display: flex;
flex-direction: column;
gap: 36rpx;
}
.table-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
margin-bottom: 16rpx;
padding: 16rpx 0;
color: #2563eb;
font-size: 26rpx;
font-weight: 800;
}
.table-toggle-icon {
font-size: 20rpx;
}
.question-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 16rpx;
margin-top: 24rpx;
}
.question-no {
height: 58rpx;
border: 2rpx solid #bbf7d0;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
color: #15803d;
background: #f0fdf4;
font-size: 24rpx;
font-weight: 900;
}
.question-no.wrong {
border-color: #fecaca;
color: #dc2626;
background: #fef2f2;
}
.question-no.unanswered {
border-color: #e2e8f0;
color: #64748b;
background: #f8fafc;
}
.question-no.active {
border-color: #2563eb;
box-shadow: 0 0 0 4rpx rgba(37, 99, 235, 0.1);
}
.question-head {
display: flex;
align-items: center;
gap: 20rpx;
color: #0f172a;
font-size: 26rpx;
font-weight: 900;
}
.detail-fields {
display: grid;
gap: 18rpx;
margin-top: 22rpx;
min-width: 0;
}
.field-card {
padding: 18rpx 20rpx;
border: 2rpx solid #dbeafe;
border-radius: 18rpx;
background: #ffffff;
overflow: hidden;
min-width: 0;
box-sizing: border-box;
}
.field-card.success {
border-color: #bbf7d0;
}
.field-card.student {
border-color: #ddd6fe;
}
/* 选项模块样式(参考 web 端 SingleChoice / MultipleChoice 组件) */
.options-card {
border-color: #c7d2fe;
}
.options-card .field-label {
background: #6366f1;
}
.option-list {
display: flex;
flex-direction: column;
gap: 14rpx;
margin-top: 4rpx;
}
.option-item {
display: flex;
align-items: flex-start;
padding: 14rpx 16rpx;
border-radius: 14rpx;
border: 2rpx solid #e0e7ff;
background: #f8faff;
gap: 12rpx;
transition: all 0.2s;
}
.option-item.option-correct {
border-color: #86efac;
background: #f0fdf4;
}
.option-item.option-correct .option-label {
background: #22c55e;
color: #ffffff;
}
.option-item.option-wrong {
border-color: #fca5a5;
background: #fef2f2;
}
.option-item.option-wrong .option-label {
background: #ef4444;
color: #ffffff;
}
.option-label {
flex-shrink: 0;
width: 40rpx;
height: 40rpx;
border-radius: 10rpx;
display: flex;
align-items: center;
justify-content: center;
background: #e0e7ff;
color: #6366f1;
font-size: 22rpx;
font-weight: 900;
line-height: 1;
}
.option-value {
flex: 1;
min-width: 0;
color: #334155;
font-size: 24rpx;
line-height: 1.55;
overflow-wrap: anywhere;
word-break: break-word;
}
.option-value rich-text {
width: 100%;
max-width: 100%;
}
.field-label {
display: inline-flex;
margin-bottom: 8rpx;
padding: 6rpx 12rpx;
border-radius: 12rpx;
color: #ffffff;
background: #2563eb;
font-size: 22rpx;
font-weight: 800;
}
.field-card.success .field-label {
background: #22c55e;
}
.field-card.student .field-label {
background: #7c3aed;
}
.field-content {
display: block;
color: #334155;
font-size: 25rpx;
line-height: 1.55;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-all;
box-sizing: border-box;
}
.rich-content {
width: 100%;
max-width: 100%;
overflow: hidden;
word-break: break-word;
box-sizing: border-box;
}
.rich-content :deep(.formula-text) {
font-size: 24rpx;
font-family: 'Times New Roman', 'STIXGeneral', Georgia, serif;
font-style: italic;
color: #1e1b4b;
}
.rich-content :deep(.formula-img) {
max-width: 100%;
vertical-align: middle;
}
.rich-content :deep(.fill-blank) {
display: inline-block;
min-width: 60rpx;
border-bottom: 3rpx solid #2563eb;
}
.stem-images {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 12rpx;
}
.stem-image {
width: 100%;
max-width: 100%;
border-radius: 12rpx;
}
.answer-images {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 8rpx;
max-width: 100%;
}
.answer-image {
width: 100%;
max-width: 100%;
border-radius: 12rpx;
background: #f8fafc;
}
.question-table-scroll {
width: 100%;
}
.question-table {
width: 1060rpx;
border: 2rpx solid #e2e8f0;
border-radius: 24rpx;
overflow: hidden;
}
.question-row {
display: grid;
grid-template-columns: 90rpx 140rpx 110rpx 110rpx 210rpx 190rpx 190rpx;
min-height: 72rpx;
align-items: center;
color: #334155;
font-size: 23rpx;
text-align: center;
border-top: 2rpx solid #f1f5f9;
}
.question-row:first-child {
border-top: 0;
}
.knowledge-summary {
display: flex;
align-items: flex-start;
gap: 28rpx;
}
.wrong-total {
width: 170rpx;
height: 170rpx;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #eff6ff 0%, #ffffff 100%);
box-shadow: inset 0 0 0 16rpx #dbeafe;
flex-shrink: 0;
}
.wrong-number {
color: #2563eb;
font-size: 46rpx;
font-weight: 900;
}
.wrong-label {
color: #64748b;
font-size: 22rpx;
}
.ratio-list {
flex: 1;
min-width: 0;
min-height: 200rpx;
max-height: 480rpx;
}
.ratio-row + .ratio-row {
margin-top: 18rpx;
}
.ratio-row:last-child {
padding-bottom: 4rpx;
}
.ratio-meta {
display: flex;
justify-content: space-between;
color: #334155;
font-size: 24rpx;
font-weight: 800;
}
.ratio-track {
height: 16rpx;
margin-top: 10rpx;
border-radius: 999rpx;
overflow: hidden;
background: #e2e8f0;
}
.ratio-fill {
height: 100%;
border-radius: 999rpx;
}
.ratio-fill.blue {
background: #2563eb;
}
.ratio-fill.orange {
background: #f97316;
}
.ratio-fill.green {
background: #22c55e;
}
.level-tabs {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14rpx;
margin-bottom: 20rpx;
}
.level-tab {
height: 62rpx;
border: 2rpx solid #e2e8f0;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
color: #475569;
background: #ffffff;
font-size: 24rpx;
font-weight: 800;
}
.level-tab.active {
border-color: #2563eb;
color: #ffffff;
background: #2563eb;
}
.knowledge-row {
grid-template-columns: 1fr 120rpx 180rpx;
}
.knowledge-name {
text-align: left;
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 {
position: fixed;
left: 28rpx;
right: 28rpx;
bottom: calc(22rpx + env(safe-area-inset-bottom));
z-index: 20;
display: flex;
align-items: center;
}
.download-btn {
flex: 1;
height: 84rpx;
border: 0;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
background: linear-gradient(135deg, #2563eb 0%, #0891b2 100%);
font-size: 30rpx;
font-weight: 900;
box-shadow: 0 16rpx 32rpx rgba(37, 99, 235, 0.22);
}
.download-btn::after {
border: 0;
}
.home-btn {
width: 88rpx;
height: 88rpx;
margin-left: -12rpx;
border-radius: 32rpx;
display: flex;
align-items: center;
justify-content: center;
background: #2563eb;
box-shadow: 0 16rpx 32rpx rgba(37, 99, 235, 0.26);
}
.home-roof {
position: absolute;
width: 28rpx;
height: 28rpx;
border-top: 5rpx solid #ffffff;
border-left: 5rpx solid #ffffff;
transform: translateY(-8rpx) rotate(45deg);
}
.home-body {
width: 34rpx;
height: 28rpx;
margin-top: 16rpx;
border: 5rpx solid #ffffff;
border-top: 0;
border-radius: 4rpx;
}
</style>