HarmonyOS应用<奇妙科学乐园>开发第95篇:趣味问答页开发——分类选择/答题/结果展示

引言
趣味问答是《奇妙科学乐园》中互动性最强的功能模块,也是6-12岁儿童检验科学知识掌握程度的趣味途径。Quiz 页面承载了完整的答题闭环流程:分类选择 → 题目加载 → 选项作答 → 即时反馈 → 成绩展示。这个看似简单的"选择-答题-看结果"三步曲,背后涉及多视图状态管理、答题引擎协同、选项反馈动画、计分逻辑串联以及结果页面跳转等复杂的技术实现。
从架构角度看,Quiz 页面采用了三视图条件渲染模式——通过 quizStarted 和 currentQuestion 两个核心状态变量,在分类选择视图(SelectCategoryView)、答题视图(QuizView)和结果视图(ResultView)之间平滑切换。这种三态设计使得整个答题流程在一个页面内闭环完成,避免了多页面跳转带来的状态丢失和页面栈管理复杂性。
本文将以 Quiz.ets 的完整源码为主线,逐一拆解分类选择视图的 UI 结构与事件绑定、答题引擎 QuizEngine 的初始化与题目加载流程、选项渲染 QuizOptionItem 的选中/反馈状态切换、答题反馈(正确/错误)的即时视觉差异化、计分逻辑与错题记录的完整数据链、答题完成后的结果展示(答对/答错/正确率/评语),以及 QuizResult 独立结果页的星级评定与持久化记录。
学习目标
完成本文后,你将能够:
- ✅ 掌握 Quiz 页面的三视图条件渲染架构:SelectCategoryView / QuizView / ResultView
- ✅ 理解 @Builder 自定义组件构建器的定义与调用方式
- ✅ 掌握 QuizEngine 答题引擎的 startQuiz / submitAnswer / nextQuestion 完整调用链
- ✅ 实现 QuizOptionItem 选项组件的多状态渲染(默认/选中/正确/错误)
- ✅ 设计答题反馈的即时视觉差异化(绿色正确 vs 红色错误 + 解释卡片)
- ✅ 掌握答题会话 QuizSession 的数据结构与计分逻辑
- ✅ 实现进度条 Progress 组件的动态更新
- ✅ 理解答题完成后的结果跳转与 QuizResult 独立页面数据传递
- ✅ 掌握 Fisher-Yates 洗牌算法在题目随机排序中的应用
需求分析
页面功能架构
Quiz 页面的功能模块与状态流转如下:
Quiz 页面(三视图条件渲染)
│
├── 视图1:SelectCategoryView(分类选择)
│ ├── AppBar(标题 + 返回)
│ ├── 标题区(图标 + "趣味科学问答" + 副标题)
│ └── 分类列表
│ ├── "全部挑战" 选项(随机所有分类)
│ └── ForEach categories → QuizCategoryOption
│
├── 视图2:QuizView(答题界面)
│ ├── 顶部控制栏(退出按钮 + 题号 + 空占位)
│ ├── Progress 进度条
│ ├── Scroll 滚动区
│ │ ├── 题目卡片(分类标签 + 图标 + 题目文字)
│ │ ├── ForEach options → QuizOptionItem(4个选项)
│ │ └── 反馈卡片(条件显示:正确/错误 + 解释文字)
│ └── 底部按钮栏("下一题"/"查看结果" / "请选择一个答案")
│
└── 视图3:ResultView(结果展示,页面内嵌版)
├── 结果图标(根据正确率动态切换)
├── 结果标题("太棒了!科学小达人!" 等)
├── 答对/答错/正确率统计卡片
└── 操作按钮("再来一次" / "返回首页")
状态流转图
quizStarted=false
│
┌───────────────┘
▼
SelectCategoryView
(分类选择)
│
startQuiz() ──────→ quizStarted=true, currentQuestion≠null
│
▼
QuizView
(答题界面)
│
selectOption() ────→ submitAnswer() ───→ showFeedback=true
│ │
│ nextQuestion()
│ hasMore=true → currentIndex++
│ hasMore=false → currentQuestion=null
│ │
│ ┌───────┘
│ ▼
│ currentQuestion=null
│ │
│ ▼
│ ResultView
│ (结果展示)
│ │
│ restartQuiz() ◄───────┘
│ │
└──────────────┘
quizStarted=false, session=null
依赖关系
| 模块 | 作用 | 引入方式 |
|---|---|---|
| QuizQuestion | 问答题目数据模型 | 模型导入 |
| QuizSession / quizEngine | 答题引擎(单例) | 服务导入 |
| Category | 分类数据模型 | 模型导入 |
| scienceData | 数据服务单例 | 服务导入 |
| QuizOptionItem | 选项渲染组件 | 组件导入 |
| QuizCategoryOption | 分类选项组件 | 组件导入 |
| AppBar | 通用导航栏组件 | 组件导入 |
| Logger / RouterUtil | 工具类 | 工具导入 |
| ThemeColors / AppConstants / RouteUrls | 常量配置 | 常量导入 |
核心实现
步骤1:页面状态声明与初始化
功能说明
Quiz 页面使用 9 个 @State 变量管理整个答题流程的状态。quizStarted 控制是否进入答题模式,currentQuestion 控制是否显示答题视图还是结果视图。aboutToAppear 中从路由参数获取 categoryId(允许从外部页面直接跳转到指定分类答题)。
源码实现
// 文件路径:entry/src/main/ets/pages/Quiz.ets
import { QuizQuestion } from '../model/Quiz';
import { QuizSession, quizEngine } from '../viewmodel/QuizEngine';
import { Category } from '../model/Category';
import { scienceData } from '../viewmodel/ScienceData';
import { QuizOptionItem } from '../components/quiz/QuizOptionItem';
import { QuizCategoryOption } from '../components/quiz/QuizCategoryOption';
import { Logger } from '../utils/Logger';
import { RouterUtil } from '../utils/RouterUtil';
import { AppConstants, ThemeColors } from '../constants/AppConstants';
import { AppBar } from '../components/base/AppBar';
import { RouteUrls } from '../constants/RouteUrls';
const TAG = 'QuizPage';
@Entry
@Component
struct Quiz {
// ===== 分类选择视图状态 =====
@State currentCategory: string = 'all'; // 当前选中分类
@State categories: Category[] = []; // 分类列表数据
// ===== 答题引擎状态 =====
@State session: QuizSession | null = null; // 当前答题会话
@State currentQuestion: QuizQuestion | null = null; // 当前题目
// ===== 答题过程状态 =====
@State currentIndex: number = 0; // 当前题目索引
@State questionCount: number = 0; // 总题目数
@State selectedOption: number = -1; // 当前选中选项(-1未选)
@State showFeedback: boolean = false; // 是否显示答题反馈
@State isCorrect: boolean = false; // 当前答题是否正确
@State explanation: string = ''; // 当前题目的解释文字
@State correctIdx: number = -1; // 当前题目的正确选项索引
@State quizStarted: boolean = false; // 是否已开始答题
aboutToAppear() {
// 支持路由参数传入分类 ID(从外部页面跳转)
const params = RouterUtil.getParams() as Record<string, Object>;
if (params && params.categoryId) {
this.currentCategory = params.categoryId as string;
}
// 加载全部分类数据
this.categories = scienceData.getAllCategories();
Logger.info(TAG, '问答页面加载');
}
}
状态变量职责一览
| 状态变量 | 类型 | 默认值 | 职责 |
|---|---|---|---|
| quizStarted | boolean | false | 控制视图1→视图2的切换 |
| currentQuestion | QuizQuestion|null | null | 控制视图2→视图3的切换 |
| currentCategory | string | 'all' | 当前答题分类('all' 或具体分类 ID) |
| session | QuizSession|null | null | 答题会话(包含题目列表、分数、答案记录) |
| currentIndex | number | 0 | 当前题目在会话中的索引(从 0 开始) |
| questionCount | number | 0 | 本轮答题总题数 |
| selectedOption | number | -1 | 用户选中的选项索引(-1 表示未选择) |
| showFeedback | boolean | false | 是否已提交答案并显示反馈 |
| isCorrect | boolean | false | 当前题目答题结果 |
| explanation | string | '' | 当前题目的解释说明文字 |
| correctIdx | number | -1 | 当前题目正确选项的索引 |
步骤2:三视图条件渲染——build 方法
功能说明
Quiz 页面的 build 方法不包含具体 UI,而是通过三个条件分支调用对应的 @Builder 方法。这种设计模式将三个视图的 UI 完全隔离,代码职责清晰,便于独立维护。
源码实现
// 文件路径:entry/src/main/ets/pages/Quiz.ets
build() {
// 条件渲染:根据状态决定显示哪个视图
if (!this.quizStarted) {
this.SelectCategoryView(); // 视图1:分类选择
} else if (this.currentQuestion) {
this.QuizView(); // 视图2:答题界面
} else {
this.ResultView(); // 视图3:结果展示
}
}
条件渲染逻辑
判断顺序:
① quizStarted === false → SelectCategoryView(分类选择)
② quizStarted === true
AND currentQuestion !== null → QuizView(答题界面)
③ quizStarted === true
AND currentQuestion === null → ResultView(结果展示)
// ✅ 正确:三个条件互斥,每个时刻只渲染一个视图
if (!this.quizStarted) {
this.SelectCategoryView();
} else if (this.currentQuestion) {
this.QuizView();
} else {
this.ResultView();
}
// ❌ 错误:使用 if-else if-else if 不带 else,可能出现空白页面
if (!this.quizStarted) {
this.SelectCategoryView();
} else if (this.currentQuestion) {
this.QuizView();
} else if (this.session) {
this.ResultView();
}
// 缺少最终 else,如果 session 为 null 且 quizStarted 为 true → 白屏
步骤3:分类选择视图——SelectCategoryView
功能说明
分类选择视图是用户进入问答页面的第一个界面。顶部使用 AppBar 通用导航栏,中部展示问答图标和标题,下方列出"全部挑战"和各分类选项。点击任意分类选项后立即开始答题。
源码实现
// 文件路径:entry/src/main/ets/pages/Quiz.ets
@Builder
SelectCategoryView() {
Column() {
// 顶部导航栏
AppBar({
barTitle: '趣味问答',
showBack: true,
onBack: () => this.goBack(),
barBorderBottom: false,
barBgColor: ThemeColors.BG_SECONDARY
});
Scroll() {
Column() {
// ===== 标题区 =====
Column() {
Image($r('app.media.icon_quiz'))
.width(48)
.height(48)
.objectFit(ImageFit.Contain)
.margin({ bottom: 12 });
Text('趣味科学问答')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(ThemeColors.TEXT_PRIMARY)
.margin({ bottom: 6 });
Text('选择一个分类,测试你的科学知识!')
.fontSize(14)
.fontColor(ThemeColors.TEXT_SECONDARY);
}
.width('100%')
.padding({ top: 20, bottom: 24 })
.alignItems(HorizontalAlign.Center);
// ===== 分类列表 =====
Column() {
// "全部挑战"固定选项
QuizCategoryOption({
categoryId: 'all',
icon: '',
name: '全部挑战',
desc: '随机抽取所有分类题目',
onCategoryClick: (id: string) => {
this.startQuizWithCategory(id);
}
});
// 动态渲染各分类选项
ForEach(
this.categories,
(category: Category) => {
QuizCategoryOption({
categoryId: category.id,
icon: category.icon,
name: category.name,
desc: category.description,
onCategoryClick: (id: string) => {
this.startQuizWithCategory(id);
}
});
},
(category: Category) => category.id
);
}
.width('100%');
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 32 });
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
.backgroundColor(ThemeColors.BG_SECONDARY);
}
.width('100%')
.height('100%')
.backgroundColor(ThemeColors.BG_SECONDARY);
}
QuizCategoryOption 组件结构
// 文件路径:entry/src/main/ets/components/quiz/QuizCategoryOption.ets
@Component
export struct QuizCategoryOption {
categoryId: string = '';
icon: string = '';
name: string = '';
desc: string = '';
onCategoryClick?: (categoryId: string) => void = () => {};
@Builder
build() {
Row() {
// 左侧:分类图标
Column() {
Text(this.icon)
.fontSize(24);
}
.width(48)
.height(48)
.borderRadius(24)
.backgroundColor('#fff3e0') // 浅橙色背景
.justifyContent(FlexAlign.Center)
.margin({ right: 12 });
// 中间:分类名称 + 描述
Column() {
Text(this.name)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor(ThemeColors.TEXT_PRIMARY)
.margin({ bottom: 2 });
Text(this.desc)
.fontSize(12)
.fontColor(ThemeColors.TEXT_TERTIARY);
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start);
// 右侧:"开始 →" 引导文字
Text('开始 →')
.fontSize(13)
.fontColor(ThemeColors.PRIMARY);
}
.width('100%')
.padding(14)
.backgroundColor(ThemeColors.BG_PRIMARY)
.borderRadius(12)
.border({ width: 1, color: ThemeColors.BORDER_COLOR })
.margin({ bottom: 10 })
.onClick(() => {
if (this.onCategoryClick) {
this.onCategoryClick(this.categoryId);
}
});
}
}
步骤4:答题启动——QuizEngine 协同
功能说明
当用户选择一个分类后,startQuizWithCategory 方法更新分类 ID 并调用 startQuiz,后者与 QuizEngine 交互完成答题会话的创建:从题库中筛选对应分类的题目、Fisher-Yates 洗牌随机排序、截取指定数量的题目、初始化 QuizSession 会话对象。
源码实现
// 文件路径:entry/src/main/ets/pages/Quiz.ets
/**
* 指定分类开始答题
* @param categoryId - 分类 ID('all' 为全部挑战)
*/
startQuizWithCategory(categoryId: string) {
this.currentCategory = categoryId;
this.startQuiz();
}
/**
* 启动答题会话
* 调用 QuizEngine 创建会话、加载第一题、重置所有状态
*/
startQuiz() {
// ① 通过答题引擎创建答题会话
this.session = quizEngine.startQuiz(
this.currentCategory,
AppConstants.DEFAULT_QUIZ_COUNT // 默认10题
);
// ② 获取题目总数
this.questionCount = this.session?.questions.length || 0;
// ③ 加载第一题
this.currentQuestion = quizEngine.getCurrentQuestion();
// ④ 重置答题过程状态
this.currentIndex = 0;
this.selectedOption = -1;
this.showFeedback = false;
// ⑤ 切换到答题视图
this.quizStarted = true;
}
QuizEngine.startQuiz 内部流程
// 文件路径:entry/src/main/ets/viewmodel/QuizEngine.ets
/**
* 开始答题会话
* @param categoryId - 分类 ID('all' 表示全部)
* @param questionCount - 题目数量
* @returns QuizSession 答题会话对象
*/
startQuiz(categoryId: string = 'all', questionCount: number = 10): QuizSession {
// ① 筛选题库:按分类过滤
let questionPool: QuizQuestion[];
if (categoryId === 'all') {
questionPool = [...this.questions]; // 全部题目
} else {
questionPool = this.questions.filter( // 指定分类
(q: QuizQuestion) => q.category === categoryId
);
}
// ② Fisher-Yates 洗牌算法随机排序
const shuffled = this.shuffleArray(questionPool);
// ③ 截取指定数量的题目
const selectedQuestions = shuffled.slice(
0, Math.min(questionCount, shuffled.length)
);
// ④ 创建答题会话
const session: QuizSession = {
questions: selectedQuestions, // 题目列表
currentIndex: 0, // 当前索引
correctCount: 0, // 答对数
wrongCount: 0, // 答错数
answers: [], // 答案记录
isFinished: false, // 是否已完成
startTime: Date.now(), // 开始时间
category: categoryId // 分类
};
this.currentSession = session;
Logger.info(TAG, `开始答题: 分类=${categoryId}, 题目数=${selectedQuestions.length}`);
return this.currentSession;
}
Fisher-Yates 洗牌算法
/**
* Fisher-Yates 洗牌算法
* 时间复杂度 O(n),空间复杂度 O(n)
* 保证每个排列等概率出现
*/
private shuffleArray<T>(array: T[]): T[] {
const result = [...array]; // 浅拷贝,不修改原数组
for (let i = result.length - 1; i > 0; i--) {
// 从 [0, i] 中随机选一个索引 j
const j = Math.floor(Math.random() * (i + 1));
// 交换 i 和 j 的位置
const temp = result[i];
result[i] = result[j];
result[j] = temp;
}
return result;
}
// ✅ 正确:Fisher-Yates 洗牌保证均匀随机
private shuffleArray<T>(array: T[]): T[] {
const result = [...array]; // 不修改原数组
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]]; // 交换
}
return result;
}
// ❌ 错误1:sort + Math.random 不是均匀随机
array.sort(() => Math.random() - 0.5);
// 不同浏览器中 sort 比较函数调用次数不同,分布不均匀
// ❌ 错误2:直接修改原数组
private shuffleArray<T>(array: T[]): T[] {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]]; // 修改了原数组!
}
return array; // 副作用:原数组被永久打乱
}
步骤5:答题视图——QuizView 完整结构
功能说明
答题视图是用户进行答题的核心界面,分为四个区域:顶部控制栏(退出按钮 + 题号显示)、进度条、可滚动的内容区(题目卡片 + 选项列表 + 反馈卡片)、底部操作按钮栏。
源码实现
// 文件路径:entry/src/main/ets/pages/Quiz.ets
@Builder
QuizView() {
Column() {
if (this.session && this.currentQuestion) {
// ===== 顶部控制栏 =====
Column() {
Row() {
// 退出按钮:圆形半透明背景 + "✕"
Button() {
Text('✕')
.fontSize(16)
.fontColor(ThemeColors.TEXT_TERTIARY);
}
.type(ButtonType.Circle)
.width(32)
.height(32)
.backgroundColor(ThemeColors.BG_TERTIARY)
.onClick(() => {
this.restartQuiz(); // 退出答题,回到分类选择
});
// 题号显示
Column() {
Text('第 ' + (this.currentIndex + 1) + ' / ' + this.questionCount + ' 题')
.fontSize(13)
.fontColor(ThemeColors.TEXT_SECONDARY);
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center);
// 右侧空占位(保持对称)
Column() { }
.width(32);
}
.width('100%')
.margin({ bottom: 12 });
// 进度条
Progress({
value: this.currentIndex + 1,
total: this.questionCount
})
.width('100%')
.color(ThemeColors.PRIMARY)
.backgroundColor(ThemeColors.BG_TERTIARY)
.margin({ bottom: 20 });
}
.width('100%')
.padding({ top: 16, left: 16, right: 16 });
Progress 进度条动态更新
// 进度值 = 当前索引 + 1(人类可读的第N题)
Progress({
value: this.currentIndex + 1, // 当前已完成题数
total: this.questionCount // 总题数
})
// 示例:总共10题,当前第3题 → value=3, total=10 → 进度30%
// ✅ 正确:使用 currentIndex + 1 表示当前进度
Progress({ value: this.currentIndex + 1, total: this.questionCount })
// ❌ 错误:使用 currentIndex 导致第一题进度为 0
Progress({ value: this.currentIndex, total: this.questionCount })
// 用户看到第一题时进度为 0%,体验异常
步骤6:题目卡片渲染
功能说明
题目卡片展示当前题目的分类标签、图标和题目文字。分类标签使用主色调背景 + 白色文字,营造信息提示的视觉层级。
源码实现
Scroll() {
Column() {
// ===== 题目卡片 =====
Column() {
// 分类标签 + 图标
Row() {
Text(this.currentQuestion.icon)
.fontSize(24)
.margin({ right: 8 });
Text(this.currentQuestion.categoryName)
.fontSize(12)
.fontColor(ThemeColors.PRIMARY)
.backgroundColor('#fff0f0')
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
.borderRadius(9999); // 胶囊分类标签
}
.width('100%')
.margin({ bottom: 12 });
// 题目文字
Text(this.currentQuestion.question)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(ThemeColors.TEXT_PRIMARY)
.width('100%')
.lineHeight(28)
.wordBreak(WordBreak.BREAK_ALL);
}
.width('100%')
.padding(18)
.backgroundColor(ThemeColors.BG_PRIMARY)
.borderRadius(16)
.border({ width: 1, color: ThemeColors.BORDER_COLOR })
.margin({ bottom: 16 });
QuizQuestion 数据模型
// 文件路径:entry/src/main/ets/model/Quiz.ets
/**
* 问答题目数据模型
* 每道题包含4个选项、1个正确索引和解释说明
*/
export interface QuizQuestion {
id: number; // 题目唯一标识
category: string; // 所属分类 ID
categoryName: string; // 所属分类名称
question: string; // 题目内容
options: string[]; // 选项数组(4个选项)
correctIndex: number; // 正确答案索引(0-3)
explanation: string; // 解释说明
difficulty: 'easy' | 'medium' | 'hard'; // 难度等级
icon: string; // 分类图标 emoji
}
步骤7:选项渲染——QuizOptionItem
功能说明
选项列表使用 ForEach 遍历当前题目的 options 数组,每个选项渲染为 QuizOptionItem 组件。该组件支持四种视觉状态:默认未选、已选中(蓝色)、正确答案(绿色,反馈后)、错误选项(红色,反馈后)。
源码实现
// 文件路径:entry/src/main/ets/pages/Quiz.ets
// ===== 选项列表 =====
Column() {
ForEach(
this.currentQuestion.options,
(option: string, index: number) => {
QuizOptionItem({
option: option,
index: index,
selected: this.selectedOption === index,
showFeedback: this.showFeedback,
isCorrect: this.isCorrect,
correctIndex: this.correctIdx,
onSelect: (idx: number) => {
this.selectOption(idx);
}
});
},
// Key 生成:题目ID + 选项索引,确保唯一性
(_: string, index: number) =>
'q' + (this.currentQuestion?.id ?? 0) +
'-opt-' + index.toString()
);
}
.width('100%')
.margin({ bottom: 16 });
QuizOptionItem 组件多状态渲染
// 文件路径:entry/src/main/ets/components/quiz/QuizOptionItem.ets
@Component
export struct QuizOptionItem {
option: string = ''; // 选项文字
index: number = 0; // 选项索引(0=A, 1=B, 2=C, 3=D)
selected: boolean = false; // 是否被用户选中
showFeedback: boolean = false; // 是否已提交答案
isCorrect: boolean = false; // 本次答题是否正确
correctIndex: number = -1; // 正确选项索引
onSelect?: (index: number) => void = () => {}; // 选择回调
@Builder
build() {
Row() {
// 左侧字母标识圆圈
Column() {
Text(String.fromCharCode(65 + this.index)) // A/B/C/D
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor(this.getOptionTextColor());
}
.width(32)
.height(32)
.borderRadius(16)
.backgroundColor(this.getOptionBgColor())
.border({ width: 2, color: this.getOptionBorderColor() })
.justifyContent(FlexAlign.Center)
.margin({ right: 12 });
// 选项文字
Text(this.option)
.fontSize(15)
.fontColor(ThemeColors.TEXT_PRIMARY)
.layoutWeight(1);
// 反馈标识(仅在 showFeedback 时显示)
if (this.showFeedback) {
if (this.index === this.correctIndex) {
Text('✓')
.fontSize(18)
.fontColor(ThemeColors.SUCCESS)
.fontWeight(FontWeight.Bold); // 绿色对勾
} else if (this.selected && !this.isCorrect) {
Text('✗')
.fontSize(18)
.fontColor(ThemeColors.PRIMARY)
.fontWeight(FontWeight.Bold); // 红色叉号
}
}
}
.width('100%')
.padding(14)
.backgroundColor(this.getOptionCardBg())
.borderRadius(12)
.border({ width: 2, color: this.getOptionBorderColor() })
.margin({ bottom: 10 })
.onClick(() => {
if (!this.showFeedback && this.onSelect) {
this.onSelect(this.index); // 未反馈时才响应点击
}
});
}
四种状态的视觉参数矩阵
| 状态 | 字母圆背景 | 字母字色 | 卡片背景 | 边框颜色 | 右侧标识 |
|---|---|---|---|---|---|
| 默认未选 | #f5f5f5 | TEXT_SECONDARY | BG_PRIMARY | transparent | 无 |
| 用户选中 | PRIMARY | TEXT_WHITE | #fff5f5 | PRIMARY | 无 |
| 正确答案 | SUCCESS | TEXT_WHITE | #f1f8e9 | SUCCESS | ✓ 绿色 |
| 错误选项 | PRIMARY | TEXT_WHITE | #ffebee | PRIMARY | ✗ 红色 |
// ✅ 正确:根据 showFeedback 和正确性分层判断
private getOptionCardBg(): string {
if (!this.showFeedback) {
// 未提交:选中为浅红,未选为白色
return this.selected ? '#fff5f5' : ThemeColors.BG_PRIMARY;
}
if (this.index === this.correctIndex) {
return '#f1f8e9'; // 正确答案:浅绿背景
}
if (this.selected && !this.isCorrect) {
return '#ffebee'; // 错误选项:浅红背景
}
return ThemeColors.BG_PRIMARY; // 其他:白色
}
// ❌ 错误:只判断 selected,不区分反馈状态
if (this.selected) {
return '#fff5f5'; // 反馈后正确选项也显示浅红色
}
步骤8:答题反馈——正确/错误差异化
功能说明
用户选择选项后,selectOption 方法调用 QuizEngine 提交答案,获取正确性结果。反馈区域条件渲染:显示正确/错误的图标与文案、解释说明文字。整体卡片背景根据正确性切换绿色或红色。
提交答案逻辑
// 文件路径:entry/src/main/ets/pages/Quiz.ets
/**
* 选择选项并提交答案
* @param index - 选项索引
*/
selectOption(index: number) {
// 防重复提交:已显示反馈时不再响应
if (this.showFeedback) return;
// 记录用户选中
this.selectedOption = index;
// 调用答题引擎提交答案
const result = quizEngine.submitAnswer(index);
if (result) {
this.isCorrect = result.isCorrect; // 是否正确
this.correctIdx = result.correctIndex; // 正确答案索引
this.explanation = result.explanation; // 解释文字
this.showFeedback = true; // 显示反馈
}
}
QuizEngine.submitAnswer 内部逻辑
// 文件路径:entry/src/main/ets/viewmodel/QuizEngine.ets
/**
* 提交答案
* @param optionIndex - 用户选择的选项索引
* @returns SubmitResult 提交结果(正确性、正确索引、解释)
*/
submitAnswer(optionIndex: number): SubmitResult | null {
if (!this.currentSession || this.currentSession.isFinished) return null;
const question = this.currentSession.questions[this.currentSession.currentIndex];
if (!question) return null;
// 判断正确性
const isCorrect = optionIndex === question.correctIndex;
// 记录答案
this.currentSession.answers.push(optionIndex);
if (isCorrect) {
this.currentSession.correctCount++; // 答对计数+1
} else {
this.currentSession.wrongCount++; // 答错计数+1
// 答错的题目加入错题本
userPrefs.addWrongQuiz(question.id).catch((err: Error) => {
Logger.error(TAG, '添加错题失败', err);
});
}
Logger.debug(TAG, `答题: 第${this.currentSession.currentIndex + 1}题, `
+ `结果=${isCorrect ? '正确' : '错误'}`);
return {
isCorrect: isCorrect,
correctIndex: question.correctIndex,
explanation: question.explanation
};
}
反馈卡片 UI 渲染
// 文件路径:entry/src/main/ets/pages/Quiz.ets
// ===== 答题反馈卡片(条件显示) =====
if (this.showFeedback) {
Column({ space: 8 }) {
// 反馈标题行
Row() {
Image($r(
this.isCorrect
? 'app.media.icon_trophy'
: 'app.media.icon_empty'
))
.width(18)
.height(18)
.objectFit(ImageFit.Contain)
.fillColor(
this.isCorrect
? ThemeColors.SUCCESS
: ThemeColors.PRIMARY
)
.margin({ right: 6 });
Text(this.isCorrect ? '回答正确!' : '回答错误')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(
this.isCorrect
? ThemeColors.SUCCESS
: ThemeColors.PRIMARY
);
}
.width('100%');
// 解释说明行
Row() {
Image($r('app.media.icon_lightbulb'))
.width(14)
.height(14)
.objectFit(ImageFit.Contain)
.margin({ right: 4 });
Text(this.explanation)
.fontSize(14)
.fontColor(ThemeColors.TEXT_SECONDARY)
.layoutWeight(1);
}
.width('100%')
.alignItems(VerticalAlign.Top);
}
.width('100%')
.padding(14)
// 正确:浅绿背景 + 绿边框;错误:浅红背景 + 红边框
.backgroundColor(
this.isCorrect ? '#e8f5e9' : '#ffebee'
)
.borderRadius(12)
.border({
width: 1,
color: this.isCorrect ? '#a5d6a7' : '#ef9a9a'
})
.margin({ bottom: 16 });
}
反馈视觉对比
正确反馈:
┌─────────────────────────────────────┐
│ 🏆 回答正确! │ ← 绿色文字
│ 💡 太阳光从太阳到地球需要大约8分钟... │ ← 灰色解释文字
└─────────────────────────────────────┘
背景色:#e8f5e9(浅绿) 边框:#a5d6a7(绿色)
错误反馈:
┌─────────────────────────────────────┐
│ ⊘ 回答错误 │ ← 红色文字
│ 💡 正确答案是:太阳是一个恒星... │ ← 灰色解释文字
└─────────────────────────────────────┘
背景色:#ffebee(浅红) 边框:#ef9a9a(红色)
步骤9:底部操作按钮栏
功能说明
底部按钮栏根据答题状态动态切换:未选择答案时显示禁用状态的"请选择一个答案"按钮;已选择答案后显示"下一题"或"查看结果"按钮(最后一题时文字切换)。
源码实现
// 文件路径:entry/src/main/ets/pages/Quiz.ets
// ===== 底部按钮栏 =====
Row() {
if (this.showFeedback) {
// 已提交答案:显示"下一题"或"查看结果"
Button(
this.currentIndex < this.questionCount - 1
? '下一题'
: '查看结果'
)
.width('100%')
.height(48)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.type(ButtonType.Capsule)
.backgroundColor(ThemeColors.PRIMARY)
.onClick(() => {
this.nextQuestion();
});
} else {
// 未提交答案:禁用状态提示
Button('请选择一个答案')
.width('100%')
.height(48)
.fontSize(15)
.type(ButtonType.Capsule)
.backgroundColor('#e0e0e0') // 灰色背景
.fontColor(ThemeColors.TEXT_TERTIARY)
.enabled(false); // 禁用按钮
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 16 })
.backgroundColor(ThemeColors.BG_PRIMARY)
.border({ width: { top: 1 }, color: ThemeColors.BORDER_COLOR });
按钮状态对比
// ✅ 正确:禁用按钮明确告知用户需要操作
Button('请选择一个答案')
.backgroundColor('#e0e0e0')
.enabled(false); // 灰色 + 不可点击
// ❌ 错误:不显示按钮,用户不知道需要选择答案
// 底部空白,无操作提示
步骤10:下一题与答题完成
功能说明
nextQuestion 方法调用 QuizEngine 推进到下一题。如果有更多题目,更新 currentIndex 和 currentQuestion;如果没有更多题目,将 currentQuestion 设为 null,触发视图切换到 ResultView。
源码实现
// 文件路径:entry/src/main/ets/pages/Quiz.ets
/**
* 推进到下一题
* 无更多题目时 currentQuestion 置为 null,自动切换到结果视图
*/
nextQuestion() {
const hasMore = quizEngine.nextQuestion();
if (hasMore) {
// 还有下一题:加载题目、重置状态
const nextQ = quizEngine.getCurrentQuestion();
this.currentIndex++;
this.selectedOption = -1; // 清除选中
this.showFeedback = false; // 隐藏反馈
this.isCorrect = false;
this.explanation = '';
this.correctIdx = -1;
this.currentQuestion = nextQ;
} else {
// 答题完成:清除当前题目,触发结果视图
this.currentQuestion = null;
this.showFeedback = false;
}
}
QuizEngine.nextQuestion 内部逻辑
// 文件路径:entry/src/main/ets/viewmodel/QuizEngine.ets
/**
* 推进到下一题
* @returns true 还有下一题 / false 答题完成
*/
nextQuestion(): boolean {
if (!this.currentSession) return false;
if (this.currentSession.currentIndex < this.currentSession.questions.length - 1) {
this.currentSession.currentIndex++; // 索引+1
return true; // 还有题目
} else {
this.currentSession.isFinished = true; // 标记完成
this.saveScore(); // 保存成绩
return false; // 答题完成
}
}
成绩保存与成就联动
// 文件路径:entry/src/main/ets/viewmodel/QuizEngine.ets
/**
* 保存答题成绩
* 包含:持久化成绩记录 + 触发成就系统判定
*/
private saveScore(): void {
if (!this.currentSession) return;
const total = this.currentSession.questions.length;
const correct = this.currentSession.correctCount;
const isPerfect = correct === total && total > 0;
// ① 持久化成绩到 UserPreferences
const scoreRecord: QuizScoreRecord = {
totalQuestions: total,
correctCount: correct,
timestamp: Date.now(),
category: this.currentSession.category
};
userPrefs.addQuizScore(scoreRecord).catch((err: Error) => {
Logger.error(TAG, '保存答题成绩失败', err);
});
// ② 触发成就系统判定(满分、正确率等)
achievementManager.recordQuizResult(correct, total, isPerfect);
Logger.info(TAG, `答题完成: 正确${correct}/${total}, `
+ `正确率=${total > 0 ? Math.round((correct / total) * 100) : 0}%`);
}
步骤11:结果视图——ResultView(页面内嵌版)
功能说明
答题完成后,Quiz 页面内嵌一个简化版的结果视图,展示答对/答错数量、正确率、鼓励性标题和操作按钮。用户可以选择"再来一次"回到分类选择,或"返回首页"回到主页。
源码实现
// 文件路径:entry/src/main/ets/pages/Quiz.ets
@Builder
ResultView() {
Column() {
if (this.session) {
Column() {
// 结果图标(根据正确率动态切换)
Row() {
Image(this.getResultIconResource())
.width(64)
.height(64)
.objectFit(ImageFit.Contain);
}
.margin({ bottom: 16 });
// 结果标题(鼓励性文案)
Text(this.getResultTitle())
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor(ThemeColors.TEXT_PRIMARY)
.margin({ bottom: 6 });
// 答题概要
Text('你答对了 ' + this.session.correctCount + ' / '
+ this.session.questions.length + ' 题')
.fontSize(15)
.fontColor(ThemeColors.TEXT_SECONDARY)
.margin({ bottom: 20 });
// ===== 统计卡片 =====
Row() {
// 答对
Column() {
Text(this.session.correctCount.toString())
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor(ThemeColors.SUCCESS);
Text('答对')
.fontSize(12)
.fontColor(ThemeColors.TEXT_TERTIARY);
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center);
// 分隔线
Column()
.width(1)
.height(40)
.backgroundColor(ThemeColors.BORDER_COLOR);
// 答错
Column() {
Text(this.session.wrongCount.toString())
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor(ThemeColors.PRIMARY);
Text('答错')
.fontSize(12)
.fontColor(ThemeColors.TEXT_TERTIARY);
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center);
// 分隔线
Column()
.width(1)
.height(40)
.backgroundColor(ThemeColors.BORDER_COLOR);
// 正确率
Column() {
Text(this.getPercentage() + '%')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor(ThemeColors.WARNING);
Text('正确率')
.fontSize(12)
.fontColor(ThemeColors.TEXT_TERTIARY);
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center);
}
.width('100%')
.padding({ top: 16, bottom: 16 })
.backgroundColor('#f9f9f9')
.borderRadius(12);
}
.width('100%')
.padding(24)
.backgroundColor(ThemeColors.BG_PRIMARY)
.borderRadius(20)
.alignItems(HorizontalAlign.Center);
// ===== 操作按钮 =====
Column() {
Button('再来一次')
.width('100%')
.height(48)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.type(ButtonType.Capsule)
.backgroundColor(ThemeColors.PRIMARY)
.margin({ bottom: 10 })
.onClick(() => {
this.restartQuiz(); // 回到分类选择
});
Button('返回首页')
.width('100%')
.height(48)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.type(ButtonType.Capsule)
.backgroundColor(ThemeColors.BG_PRIMARY)
.fontColor(ThemeColors.PRIMARY)
.border({ width: 1, color: ThemeColors.PRIMARY })
.onClick(() => {
RouterUtil.replaceUrl(
{ url: RouteUrls.MAIN_TABS }, 'Quiz'
);
});
}
.width('100%')
.margin({ top: 20 });
}
}
.width('100%')
.height('100%')
.padding(20)
.justifyContent(FlexAlign.Center)
.backgroundColor(ThemeColors.BG_SECONDARY);
}
结果标题与图标策略
/**
* 根据正确率计算百分比
*/
private getPercentage(): number {
if (!this.session || this.session.questions.length === 0) return 0;
return Math.round(
(this.session.correctCount / this.session.questions.length) * 100
);
}
/**
* 根据正确率获取结果图标
* >=90% 或 >=70%:奖杯图标
* >=50%:问答图标
* <50%:空状态图标
*/
private getResultIconResource(): ResourceStr {
const pct = this.getPercentage();
if (pct >= 90) return $r('app.media.icon_trophy');
if (pct >= 70) return $r('app.media.icon_trophy');
if (pct >= 50) return $r('app.media.icon_quiz');
return $r('app.media.icon_empty');
}
/**
* 根据正确率获取鼓励性标题
*/
private getResultTitle(): string {
const pct = this.getPercentage();
if (pct >= 90) return '太棒了!科学小达人!';
if (pct >= 70) return '很不错哦,继续加油!';
if (pct >= 50) return '还不错,再接再厉!';
return '没关系,多多学习!';
}
正确率与反馈对应关系:
| 正确率 | 图标 | 标题文案 | 语气 |
|---|---|---|---|
| >=90% | 奖杯 | 太棒了!科学小达人! | 高度表扬 |
| >=70% | 奖杯 | 很不错哦,继续加油! | 鼓励表扬 |
| >=50% | 问答 | 还不错,再接再厉! | 正向鼓励 |
| <50% | 空状态 | 没关系,多多学习! | 温和鼓励 |
步骤12:重新开始——状态重置
功能说明
用户点击"再来一次"或顶部退出按钮时,restartQuiz 方法将所有状态重置为初始值,视图自动切换回分类选择界面。
源码实现
// 文件路径:entry/src/main/ets/pages/Quiz.ets
/**
* 重置答题状态,回到分类选择视图
*/
restartQuiz() {
this.quizStarted = false; // 回到视图1
this.session = null; // 清空会话
this.currentQuestion = null; // 清空当前题目
this.selectedOption = -1; // 重置选中
this.showFeedback = false; // 隐藏反馈
}
// ✅ 正确:完整重置所有答题状态
restartQuiz() {
this.quizStarted = false;
this.session = null;
this.currentQuestion = null;
this.selectedOption = -1;
this.showFeedback = false;
}
// ❌ 错误:部分状态未重置,回到分类选择后残留答题数据
restartQuiz() {
this.quizStarted = false;
this.session = null;
// 缺少:currentQuestion、selectedOption、showFeedback 重置
// 可能导致下次答题时 showFeedback 仍为 true
}
步骤13:QuizResult 独立结果页
功能说明
QuizResult 是独立的 @Entry 结果页面,支持通过路由参数接收答题数据,提供更丰富的结果展示:星级评定、用时统计、分类标签、主题色切换(优秀金色 vs 一般主色)。该页面可从每日挑战、错题练习等多个入口跳转。
源码实现(核心结构)
// 文件路径:entry/src/main/ets/pages/QuizResult.ets
@Entry
@Component
struct QuizResult {
@State correctCount: number = 0;
@State totalCount: number = 0;
@State wrongCount: number = 0;
@State percentage: number = 0;
@State category: string = '';
@State isDaily: boolean = false;
@State usedTime: number = 0;
@State hasUsedTime: boolean = false;
aboutToAppear() {
// 从路由参数安全获取所有数据
const params = RouterUtil.getParams() as Record<string, Object>;
if (params) {
if (params.correctCount !== undefined && params.correctCount !== null) {
this.correctCount = params.correctCount as number;
}
if (params.totalCount !== undefined && params.totalCount !== null) {
this.totalCount = params.totalCount as number;
}
// ... 其他参数安全获取
}
}
/**
* 计算星级评定
* >=90% 三星,>=70% 两星,>=50% 一星,<50% 零星
*/
getStarLevel(): number {
if (this.percentage >= 90) return 3;
if (this.percentage >= 70) return 2;
if (this.percentage >= 50) return 1;
return 0;
}
build() {
Column() {
AppBar({
barTitle: '答题结果',
showBack: true,
onBack: () => this.goBack(),
barBgColor: ThemeColors.BG_SECONDARY
});
Scroll() {
Column() {
// ===== 星级评定区域 =====
Row({ space: 12 }) {
ForEach([1, 2, 3], (star: number) => {
Image($r('app.media.icon_star'))
.width(40)
.height(40)
.objectFit(ImageFit.Contain)
// 达到星级的亮金色,未达到的灰色
.fillColor(
star <= this.getStarLevel()
? ThemeColors.WARNING
: ThemeColors.TEXT_HINT
);
}, (star: number) => 'result-star-' + star.toString());
}
.justifyContent(FlexAlign.Center);
// ===== 评级标题(主题色切换) =====
Text(this.getResultTitle())
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor(this.getMainColor()); // 优秀=金色,一般=主色
// ===== 统计卡片:答对/答错/正确率 =====
// ...(与 Quiz.ets ResultView 类似结构)
// ===== 用时卡片(条件显示) =====
if (this.hasUsedTime) {
Column() {
Row() {
Image($r('app.media.icon_quiz'))
.width(18).height(18);
Text('用时');
Text(this.formatUsedTime(this.usedTime))
.fontSize(20)
.fontWeight(FontWeight.Bold);
}
}
.padding(16)
.borderRadius(16);
}
// ===== 操作按钮 =====
Button('再来一次')
.backgroundColor(this.getMainColor())
.onClick(() => { RouterUtil.back('QuizResult'); });
Button('返回首页')
.backgroundColor(ThemeColors.BG_PRIMARY)
.border({ width: 1, color: ThemeColors.PRIMARY })
.onClick(() => {
RouterUtil.replaceUrl(
{ url: RouteUrls.MAIN_TABS }, 'QuizResult'
);
});
}
}
}
}
}
星级评定逻辑
正确率 ≥ 90% → ★★★ "太棒了" → 金色主题
正确率 ≥ 70% → ★★ "很厉害" → 金色主题
正确率 ≥ 50% → ★ "继续加油" → 主色主题
正确率 < 50% → ☆☆☆ "再接再厉" → 主色主题
主题色动态切换
/**
* 判断成绩是否优秀(>=70%)
*/
isExcellent(): boolean {
return this.percentage >= 70;
}
/**
* 动态主题色:优秀用金色,一般用主色
*/
getMainColor(): string {
return this.isExcellent() ? ThemeColors.WARNING : ThemeColors.PRIMARY;
}
最佳实践
1. 防重复提交设计
// ✅ 正确:showFeedback 标志位阻止重复提交
selectOption(index: number) {
if (this.showFeedback) return; // 已提交,直接返回
this.selectedOption = index;
const result = quizEngine.submitAnswer(index);
// ...
}
// ❌ 错误:无防重复提交,快速点击可多次提交
selectOption(index: number) {
const result = quizEngine.submitAnswer(index);
// 每次点击都会提交,可能影响计分
}
2. ForEach Key 生成策略
// ✅ 正确:题目ID + 选项索引,确保全局唯一
(_: string, index: number) =>
'q' + (this.currentQuestion?.id ?? 0) + '-opt-' + index.toString()
// 生成:q5-opt-0, q5-opt-1, q5-opt-2, q5-opt-3
// ❌ 错误:仅用索引,切换题目时可能复用旧 Key
(_: string, index: number) => 'opt-' + index.toString()
// 题目1:opt-0, opt-1, opt-2, opt-3
// 题目2:opt-0, opt-1, opt-2, opt-3 ← Key 重复,Diff 算法无法正确更新
3. 选项点击防护
// ✅ 正确:反馈已显示时禁用选项点击
.onClick(() => {
if (!this.showFeedback && this.onSelect) {
this.onSelect(this.index);
}
})
// ❌ 错误:无防护,反馈后点击其他选项仍触发回调
.onClick(() => {
this.onSelect(this.index);
})
4. 状态重置完整性
// ✅ 正确:restartQuiz 重置所有答题相关状态
restartQuiz() {
this.quizStarted = false;
this.session = null;
this.currentQuestion = null;
this.selectedOption = -1;
this.showFeedback = false;
// 不需要重置:isCorrect、explanation、correctIdx
// 因为它们只在 showFeedback=true 时使用,不影响其他视图
}
// ❌ 错误:遗漏关键状态重置
restartQuiz() {
this.quizStarted = false;
// 缺少 session、currentQuestion 重置
// 下次 startQuiz 时 session 指向旧数据
}
5. 路由参数安全获取
// ✅ 正确:逐一检查参数存在性和非空性
if (params.correctCount !== undefined && params.correctCount !== null) {
this.correctCount = params.correctCount as number;
}
// ❌ 错误:直接类型断言不校验
this.correctCount = params.correctCount as number;
// Previewer 空参数时 undefined as number → NaN
常见问题与排查
Q1:分类选择后页面白屏
原因:currentQuestion 为 null 导致 QuizView 不渲染,但 quizStarted 已为 true,不显示 SelectCategoryView。
排查步骤:
// 检查 startQuiz 方法中 session 是否成功创建
startQuiz() {
this.session = quizEngine.startQuiz(this.currentCategory, 10);
// 添加日志排查
Logger.info(TAG, `session: ${JSON.stringify(this.session)}`);
Logger.info(TAG, `questions: ${this.session?.questions.length}`);
this.currentQuestion = quizEngine.getCurrentQuestion();
Logger.info(TAG, `currentQuestion: ${this.currentQuestion?.id}`);
}
Q2:选项点击后无反应
原因1:showFeedback 已为 true(防重复提交生效)。
原因2:QuizEngine 未初始化(quizEngine.init(context) 未在 EntryAbility.onCreate 中调用)。
// EntryAbility.ets 中确保初始化
quizEngine.init(this.context);
Q3:答题完成后成绩未保存
原因:saveScore 在 nextQuestion 返回 false 时触发,如果用户直接退出页面(返回键),saveScore 不会执行。
解决方案:在 aboutToDisappear 中检查并保存未完成的成绩。
aboutToDisappear() {
if (this.session && !this.session.isFinished) {
// 用户中途退出,仍保存已答题目的成绩
this.session.isFinished = true;
quizEngine.getSession()?.isFinished; // 需要引擎支持
}
}
Q4:进度条不更新
原因:Progress 组件的 value 未正确绑定到 currentIndex + 1。
// ✅ 正确:value 绑定到当前进度
Progress({ value: this.currentIndex + 1, total: this.questionCount })
// ❌ 错误:value 使用静态值
Progress({ value: 3, total: this.questionCount })
// 进度永远不变
总结
Quiz 趣味问答页是《奇妙科学乐园》中技术复杂度较高的页面,涉及状态管理、引擎协同、动态渲染和成绩持久化等多个维度的技术实现。通过本文的完整拆解,我们梳理了以下核心技术要点:
-
三视图条件渲染:通过
quizStarted和currentQuestion两个核心状态变量,在一个页面内实现分类选择、答题和结果展示三个视图的切换,避免了多页面跳转的状态管理复杂性。 -
@Builder 构建器模式:SelectCategoryView、QuizView、ResultView 三个 @Builder 方法各自独立管理 UI 结构,代码职责清晰,build 方法只负责条件分发。
-
QuizEngine 答题引擎:单例模式管理答题会话,Fisher-Yates 洗牌算法保证题目随机性,submitAnswer 同时处理计分与错题记录,nextQuestion 自动触发成绩保存与成就联动。
-
QuizOptionItem 四态渲染:通过 selected、showFeedback、isCorrect、correctIndex 四个 props 驱动默认/选中/正确/错误四种视觉状态,差异化反馈即时明确。
-
即时反馈差异化设计:正确反馈使用绿色背景 + 奖杯图标 + "回答正确!",错误反馈使用红色背景 + 空状态图标 + "回答错误",解释说明帮助儿童理解正确答案。
-
防重复提交:showFeedback 标志位在选项组件和页面方法双重防护,确保每题只能提交一次答案。
-
进度条动态更新:Progress 组件绑定
currentIndex + 1,实时反映答题进度。 -
成绩多入口适配:Quiz 页面内嵌简化结果视图 + QuizResult 独立结果页支持路由参数传递,适配每日挑战、错题练习等多场景。
源码仓库:https://atomgit.com/2301_79280419/WonderSciencePark
上一篇:第94篇 - 知识详情页TopicDetail开发
下一篇:第96篇 - QuizResult答题结果页开发
更多推荐


所有评论(0)