1792 lines
55 KiB
Vue
1792 lines
55 KiB
Vue
<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(/ /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>
|