HarmonyOS应用<节气通>开发第10篇:测验记录与错题本

引言

测验记录与错题本是帮助用户回顾和巩固知识的重要功能。本文将实现:

  • 测验历史记录展示
  • 错题本功能
  • 成绩统计分析
  • 重新练习错题

通过本文,你将掌握如何实现学习数据的管理和展示。


学习目标

完成本文后,你将能够:

  • ✅ 实现测验记录展示
  • ✅ 创建错题本功能
  • ✅ 添加成绩统计
  • ✅ 实现错题重做
  • ✅ 管理学习数据

需求分析

功能模块设计

模块 功能描述 技术要点
记录列表 展示所有测验记录 List布局、日期分组
成绩统计 平均分数、正确率 卡片布局、图表展示
错题本 收藏错误题目 列表展示、筛选功能
重做功能 重新练习错题 路由跳转、数据传递
数据管理 删除记录、清空错题 确认弹窗

核心实现

步骤1: 页面结构设计

完整代码
typescript 复制代码
// pages/MyQuizRecords.ets

import router from '@ohos.router';
import { common } from '@kit.AbilityKit';
import promptAction from '@ohos.promptAction';
import { StorageService, type QuizRecord, type QuizStats } from '../services/StorageService';

@Entry
@Component
struct MyQuizRecords {
  @State records: QuizRecord[] = [];
  @State isLoading: boolean = true;
  @State totalCount: number = 0;
  @State averageAccuracy: number = 0;
  @State bestScore: number = 0;
  
  private storageService: StorageService | null = null;

  async aboutToAppear() {
    try {
      const context = getContext(this) as common.UIAbilityContext;
      this.storageService = new StorageService(context);
      await this.storageService.init();
      await this.loadQuizRecords();
    } catch (error) {
      console.error('初始化失败:', error);
      promptAction.showToast({ message: '加载失败,请重试' });
    }
  }

  async loadQuizRecords() {
    if (!this.storageService) {
      return;
    }

    try {
      // 加载测验记录
      this.records = await this.storageService.getQuizRecords();
      
      // 加载统计信息
      const stats = await this.storageService.getQuizStats();
      this.totalCount = stats.totalCount;
      this.averageAccuracy = stats.averageAccuracy;
      this.bestScore = stats.bestScore;
      
      this.isLoading = false;
    } catch (error) {
      console.error('加载测验记录失败:', error);
      promptAction.showToast({ message: '加载失败' });
      this.isLoading = false;
    }
  }

  /**
   * 格式化时长
   */
  formatDuration(seconds: number): string {
    const minutes = Math.floor(seconds / 60);
    const remainingSeconds = seconds % 60;
    if (remainingSeconds > 0) {
      return `${minutes}分${remainingSeconds}秒`;
    }
    return `${minutes}分钟`;
  }

  /**
   * 获取等级颜色
   */
  getLevelColor(accuracy: number): string {
    if (accuracy >= 90) return '#4A9B6D';
    if (accuracy >= 70) return '#FFB347';
    if (accuracy >= 60) return '#FFA726';
    return '#FF6B6B';
  }

  /**
   * 获取等级文字
   */
  getLevelText(accuracy: number): string {
    if (accuracy >= 90) return '优秀';
    if (accuracy >= 70) return '良好';
    if (accuracy >= 60) return '及格';
    return '待提高';
  }

  build(): void {
    Column() {
      // 标题栏
      Row() {
        Image($r('app.media.icon_back'))
          .width(24)
          .height(24)
          .fillColor('#333333')
          .onClick(() => {
            router.back();
          })

        Text('我的测验')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
          .layoutWeight(1)
          .textAlign(TextAlign.Center)

        Blank()
          .width(24)
      }
      .width('100%')
      .height(56)
      .padding({ left: 16, right: 16 })
      .backgroundColor('#FFFFFF')

      // 内容区域
      Column({ space: 12 }) {
        // 统计卡片
        this.buildStatsCard()

        // 测验记录列表
        if (this.isLoading) {
          Column() {
            Text('加载中...')
              .fontSize(14)
              .fontColor('#999999')
              .margin({ top: 40 })
          }
          .width('100%')
          .height(200)
          .justifyContent(FlexAlign.Center)
        } else if (this.records.length === 0) {
          // 空状态
          Column() {
            Image($r('app.media.icon_none'))
              .width(64)
              .height(64)
              .opacity(0.3)
              .margin({ bottom: 16 })
            
            Text('暂无测验记录')
              .fontSize(16)
              .fontColor('#999999')
              .margin({ bottom: 8 })
            
            Text('去参加测验吧')
              .fontSize(14)
              .fontColor('#CCCCCC')
            
            Button('去测验')
              .fontSize(14)
              .fontColor('#4A9B6D')
              .backgroundColor('#FFFFFF')
              .border({ width: 1, color: '#4A9B6D' })
              .padding({ left: 32, right: 32, top: 10, bottom: 10 })
              .borderRadius(20)
              .margin({ top: 24 })
              .onClick(() => {
                router.pushUrl({ url: 'pages/Quiz' });
              })
          }
          .width('100%')
          .height(300)
          .justifyContent(FlexAlign.Center)
        } else {
          Scroll() {
            Column({ space: 12 }) {
              ForEach(this.records, (record: QuizRecord) => {
                this.buildRecordCard(record)
              })
            }
            .width('100%')
            .padding({ bottom: 40 })
          }
          .width('100%')
          .scrollBar(BarState.Off)
        }
      }
      .width('100%')
      .flexGrow(1)
      .padding({ left: 16, right: 16, top: 12 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  @Builder
  buildStatsCard(): void {
    Row({ space: 0 }) {
      // 总测验次数
      Column({ space: 6 }) {
        Text(this.totalCount.toString())
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .fontColor('#4A9B6D')
        Text('测验次数')
          .fontSize(12)
          .fontColor('#999999')
      }
      .flexGrow(1)
      .padding(16)

      Divider()
        .height(40)
        .width(1)
        .color('#EEEEEE')

      // 平均正确率
      Column({ space: 6 }) {
        Text(this.averageAccuracy + '%')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFB347')
        Text('平均正确率')
          .fontSize(12)
          .fontColor('#999999')
      }
      .flexGrow(1)
      .padding(16)

      Divider()
        .height(40)
        .width(1)
        .color('#EEEEEE')

      // 最佳成绩
      Column({ space: 6 }) {
        Text(this.bestScore.toString())
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FF6B6B')
        Text('最佳成绩')
          .fontSize(12)
          .fontColor('#999999')
      }
      .flexGrow(1)
      .padding(16)
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .shadow({ radius: 4, color: '#0D000000', offsetX: 0, offsetY: 2 })
  }

  @Builder
  buildRecordCard(record: QuizRecord): void {
    Column({ space: 12 }) {
      Row() {
        Column({ space: 4 }) {
          Text(record.category)
            .fontSize(16)
            .fontWeight(FontWeight.Medium)
            .fontColor('#333333')
          Text(record.date)
            .fontSize(12)
            .fontColor('#999999')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        // 等级标签
        Text(this.getLevelText(record.accuracy))
          .fontSize(12)
          .fontColor(this.getLevelColor(record.accuracy))
          .padding({ left: 12, right: 12, top: 4, bottom: 4 })
          .backgroundColor(this.getLevelColor(record.accuracy) + '1A')
          .borderRadius(12)
      }

      Row({ space: 24 }) {
        Column({ space: 4 }) {
          Text(`${record.score}/${record.totalQuestions}`)
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#333333')
          Text('答对题数')
            .fontSize(11)
            .fontColor('#999999')
        }
        .alignItems(HorizontalAlign.Center)

        Column({ space: 4 }) {
          Text(this.formatDuration(record.duration))
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#333333')
          Text('用时')
            .fontSize(11)
            .fontColor('#999999')
        }
        .alignItems(HorizontalAlign.Center)

        Column({ space: 4 }) {
          Text(`${record.accuracy}%`)
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.getLevelColor(record.accuracy))
          Text('正确率')
            .fontSize(11)
            .fontColor('#999999')
        }
        .alignItems(HorizontalAlign.Center)
      }

      // 进度条
      Stack({ alignContent: Alignment.Start }) {
        Row()
          .width('100%')
          .height(6)
          .backgroundColor('#F0F0F0')
          .borderRadius(3)
        
        Row()
          .width(`${record.accuracy}%`)
          .height(6)
          .backgroundColor(this.getLevelColor(record.accuracy))
          .borderRadius(3)
      }
    }
    .padding(16)
    .width('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .shadow({ radius: 4, color: '#0D000000', offsetX: 0, offsetY: 2 })
    .onClick(() => {
      // TODO: 可以跳转到查看测验详情
      console.info('查看测验详情:', record.id);
    })
  }


}
代码解析

1. 状态管理

  • currentTab: 当前选中的标签(records/wrong)
  • records: 测验记录列表
  • wrongQuestions: 错题列表
  • statistics: 统计数据

2. 数据加载

  • loadRecords(): 加载测验记录
  • loadWrongQuestions(): 加载错题数据

步骤2: 顶部导航和标签切换

typescript 复制代码
/**
 * 构建顶部导航
 */
@Builder
buildHeader(): void {
  Row({ space: 16 }) {
    Image($r('app.media.ic_back'))
      .width(24)
      .height(24)
      .fillColor('#333333')
      .onClick(() => {
        try {
          router.back();
        } catch (error) {
          console.error('返回失败: ' + JSON.stringify(error));
        }
      })
    
    Text('学习记录')
      .fontSize(18)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
    
    Blank()
    
    Image($r('app.media.ic_more'))
      .width(24)
      .height(24)
      .fillColor('#999999')
  }
  .width('100%')
  .height(56)
  .padding({ left: 16, right: 16 })
  .backgroundColor('#FFFFFF')
}

/**
 * 构建标签切换栏
 */
@Builder
buildTabBar(): void {
  Row({ space: 0 }) {
    // 测验记录
    Column({ space: 4 }) {
      Text('测验记录')
        .fontSize(16)
        .fontWeight(this.currentTab === 'records' ? FontWeight.Bold : FontWeight.Normal)
        .fontColor(this.currentTab === 'records' ? '#4A9B6D' : '#666666')
      
      if (this.currentTab === 'records') {
        Row()
          .width(24)
          .height(3)
          .backgroundColor('#4A9B6D')
          .borderRadius(2)
      }
    }
    .width('50%')
    .height(48)
    .justifyContent(FlexAlign.Center)
    .onClick(() => {
      this.currentTab = 'records';
    })
    
    // 错题本
    Column({ space: 4 }) {
      Text('错题本')
        .fontSize(16)
        .fontWeight(this.currentTab === 'wrong' ? FontWeight.Bold : FontWeight.Normal)
        .fontColor(this.currentTab === 'wrong' ? '#4A9B6D' : '#666666')
      
      if (this.currentTab === 'wrong') {
        Row()
          .width(24)
          .height(3)
          .backgroundColor('#4A9B6D')
          .borderRadius(2)
      }
    }
    .width('50%')
    .height(48)
    .justifyContent(FlexAlign.Center)
    .onClick(() => {
      this.currentTab = 'wrong';
    })
  }
  .width('100%')
  .backgroundColor('#FFFFFF')
  .borderStyle({ bottom: { width: 1, color: '#EEEEEE' } })
}

设计要点:

  • 顶部导航栏
  • 标签切换功能
  • 下划线指示当前选中标签

步骤3: 统计数据展示

typescript 复制代码
/**
 * 构建统计卡片
 */
@Builder
buildStatisticsCard(): void {
  Card() {
    Column({ space: 16 }) {
      // 标题
      Row({ space: 8 }) {
        Image($r('app.media.ic_trending'))
          .width(20)
          .height(20)
          .fillColor('#4A9B6D')
        
        Text('学习统计')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
      }
      
      // 统计数据
      Grid() {
        GridItem() {
          this.buildStatItem('总测验次数', this.statistics.totalQuizzes.toString())
        }
        
        GridItem() {
          this.buildStatItem('平均得分', this.statistics.averageScore.toString() + '%')
        }
        
        GridItem() {
          this.buildStatItem('最高分', this.statistics.highestScore.toString() + '%')
        }
        
        GridItem() {
          this.buildStatItem('错题数量', this.statistics.wrongCount.toString())
        }
      }
      .columnsTemplate('1fr 1fr 1fr 1fr')
    }
    .padding(16)
  }
  .width('92%')
  .margin({ left: '4%', right: '4%', top: 12 })
}

/**
 * 构建统计项
 */
@Builder
buildStatItem(title: string, value: string): void {
  Column({ space: 4 }) {
    Text(value)
      .fontSize(18)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
    
    Text(title)
      .fontSize(12)
      .fontColor('#999999')
  }
  .width('100%')
  .alignItems(HorizontalAlign.Center)
}

设计要点:

  • 四列网格布局
  • 显示总测验次数、平均分、最高分、错题数

步骤4: 测验记录列表

typescript 复制代码
/**
 * 构建测验记录区域
 */
@Builder
buildRecordsSection(): void {
  Column({ space: 12 }) {
    // 统计卡片
    this.buildStatisticsCard()
    
    // 记录列表
    if (this.records.length === 0) {
      // 空状态
      Column({ space: 16 }) {
        Image($r('app.media.ic_empty'))
          .width(80)
          .height(80)
          .opacity(0.5)
        
        Text('暂无测验记录')
          .fontSize(14)
          .fontColor('#999999')
        
        Button('去测验')
          .width(120)
          .height(40)
          .backgroundColor('#4A9B6D')
          .fontColor('#FFFFFF')
          .borderRadius(20)
          .onClick(() => {
            try {
              router.pushUrl({ url: 'pages/Quiz' });
            } catch (error) {
              console.error('路由跳转失败: ' + JSON.stringify(error));
            }
          })
      }
      .width('100%')
      .padding(40)
    } else {
      // 记录列表
      List({ space: 12 }) {
        ForEach(this.records, (record: QuizRecord) => {
          ListItem() {
            this.buildRecordItem(record)
          }
        }, (record: QuizRecord) => record.id)
      }
      .width('92%')
      .padding({ bottom: 100 })
    }
  }
  .width('100%')
}

/**
 * 构建记录项
 */
@Builder
buildRecordItem(record: QuizRecord): void {
  Card() {
    Row({ space: 16 }) {
      // 分数展示
      Column({ space: 4 }) {
        Text(record.score.toString())
          .fontSize(32)
          .fontWeight(FontWeight.Bold)
          .fontColor(record.score >= 60 ? '#4A9B6D' : '#FF5252')
        
        Text('分')
          .fontSize(12)
          .fontColor('#999999')
      }
      .width(70)
      .alignItems(HorizontalAlign.Center)
      
      // 详情
      Column({ space: 6 }) {
        Row({ space: 8 }) {
          Text('正确率')
            .fontSize(14)
            .fontColor('#999999')
          
          Text((record.correct / record.total * 100).toFixed(0) + '%')
            .fontSize(14)
            .fontColor('#333333')
            .fontWeight(FontWeight.Medium)
        }
        
        Row({ space: 8 }) {
          Text(record.date)
            .fontSize(12)
            .fontColor('#999999')
          
          Text('|')
            .fontSize(12)
            .fontColor('#EEEEEE')
          
          Text(record.time)
            .fontSize(12)
            .fontColor('#999999')
        }
      }
      .flexGrow(1)
      
      // 删除按钮
      Image($r('app.media.ic_delete'))
        .width(20)
        .height(20)
        .fillColor('#CCCCCC')
        .onClick(() => {
          this.deleteRecord(record.id);
        })
    }
    .padding(16)
  }
}

设计要点:

  • 卡片式布局展示记录
  • 分数高亮显示(及格绿色,不及格红色)
  • 删除按钮

步骤5: 错题本

typescript 复制代码
/**
 * 构建错题本区域
 */
@Builder
buildWrongQuestionsSection(): void {
  Column({ space: 12 }) {
    // 头部操作栏
    Row({ space: 8 }) {
      Text('共 ' + this.wrongQuestions.length + ' 道错题')
        .fontSize(14)
        .fontColor('#666666')
      
      Blank()
      
      if (this.wrongQuestions.length > 0) {
        Text('清空')
          .fontSize(13)
          .fontColor('#FF5252')
          .onClick(() => {
            this.clearWrongQuestions();
          })
      }
    }
    .width('92%')
    .padding({ top: 12 })
    
    // 错题列表
    if (this.wrongQuestions.length === 0) {
      // 空状态
      Column({ space: 16 }) {
        Image($r('app.media.ic_empty'))
          .width(80)
          .height(80)
          .opacity(0.5)
        
        Text('暂无错题')
          .fontSize(14)
          .fontColor('#999999')
        
        Text('继续保持,争取全对!')
          .fontSize(13)
          .fontColor('#BBBBBB')
      }
      .width('100%')
      .padding(40)
    } else {
      // 错题列表
      List({ space: 12 }) {
        ForEach(this.wrongQuestions, (question: WrongQuestion) => {
          ListItem() {
            this.buildWrongQuestionItem(question)
          }
        }, (question: WrongQuestion) => question.id)
      }
      .width('92%')
      .padding({ bottom: 100 })
    }
    
    // 底部操作按钮
    if (this.wrongQuestions.length > 0) {
      Button('重做错题')
        .width('92%')
        .height(48)
        .backgroundColor('#4A9B6D')
        .fontColor('#FFFFFF')
        .borderRadius(24)
        .position({ bottom: 60 })
        .onClick(() => {
          try {
            router.pushUrl({ url: 'pages/Quiz', params: { mode: 'wrong' } });
          } catch (error) {
            console.error('路由跳转失败: ' + JSON.stringify(error));
          }
        })
    }
  }
  .width('100%')
}

/**
 * 构建错题项
 */
@Builder
buildWrongQuestionItem(question: WrongQuestion): void {
  Card() {
    Column({ space: 12 }) {
      // 题目
      Text(question.question)
        .fontSize(15)
        .fontColor('#333333')
        .lineHeight(24)
      
      // 选项
      Column({ space: 8 }) {
        ForEach(question.options, (option: string, index: number) => {
          Row({ space: 8 }) {
            let icon = '○';
            let iconColor = '#999999';
            
            if (index === question.correctAnswer) {
              icon = '✓';
              iconColor = '#4A9B6D';
            } else if (index === question.userAnswer) {
              icon = '✗';
              iconColor = '#FF5252';
            }
            
            Text(icon)
              .fontSize(14)
              .fontColor(iconColor)
            
            Text(option)
              .fontSize(14)
              .fontColor(index === question.correctAnswer ? '#4A9B6D' : 
                         index === question.userAnswer ? '#FF5252' : '#666666')
          }
        })
      }
      
      // 底部信息
      Row({ space: 16 }) {
        Row({ space: 4 }) {
          Text('错误次数')
            .fontSize(12)
            .fontColor('#999999')
          
          Text(question.wrongCount.toString())
            .fontSize(12)
            .fontColor('#FF5252')
        }
        
        Text(question.lastWrongDate)
          .fontSize(12)
          .fontColor('#999999')
        
        Blank()
        
        Row({ space: 8 }) {
          Text('移除')
            .fontSize(12)
            .fontColor('#999999')
            .onClick(() => {
              this.removeWrongQuestion(question.id);
            })
        }
      }
    }
    .padding(16)
  }
}

设计要点:

  • 题目内容展示
  • 错误答案和正确答案对比
  • 错误次数统计
  • 移除和重做功能

常见问题与解决方案

问题1: 数据不持久

现象 :

退出应用后记录丢失。

解决方案:

typescript 复制代码
// 使用StorageService保存数据
async saveRecords(): Promise<void> {
  await this.storageService.saveRecords(this.records);
}

问题2: 错题重做功能不生效

现象 :

点击重做错题没有反应。

解决方案:

typescript 复制代码
// 确保路由参数正确传递
router.pushUrl({ 
  url: 'pages/Quiz', 
  params: { mode: 'wrong', questions: JSON.stringify(this.wrongQuestions) } 
});

本章小结

核心知识点

本文完成了测验记录与错题本功能的实现:

1. 测验记录

  • 记录列表展示
  • 成绩统计
  • 删除记录功能

2. 错题本

  • 错题列表展示
  • 正确/错误答案对比
  • 移除错题功能
  • 重做错题功能

3. 统计数据

  • 总测验次数
  • 平均得分
  • 最高分
  • 错题数量

下一步预告

测验记录与错题本已经完成!在下一篇文章中,我们将学习:

  • 个人中心页开发
  • 用户信息展示
  • 设置功能
  • 学习统计

相关链接

相关推荐
G_dou_2 小时前
Flutter三方库适配OpenHarmony【tip_calculator】小费计算器项目完整实战
flutter·harmonyos
yuegu7772 小时前
HarmonyOS应用<节气通>开发第6篇:节气详情页(下)——诗词与养生
华为·harmonyos
慧海灵舟2 小时前
鸿蒙南向开发教程Day 2:创建自己的 Hello World 工程
华为·harmonyos·写文章,赢小鸿ai
颜淡慕潇3 小时前
鸿蒙 PC的 vcpkg 交叉编译库在x86_64宿主环境下的AI自动化验证方案
人工智能·自动化·harmonyos
再见6583 小时前
HarmonyOS NEXT 实战:从零开发一款密码生成器应用
华为·harmonyos
李二。3 小时前
鸿蒙原生ArkTS布局方式之ColumnBaseline垂直排列
华为·harmonyos
yuegu7774 小时前
HarmonyOS应用<节气通>开发第8篇:知识百科页开发
华为·harmonyos
想你依然心痛4 小时前
HarmonyOS 6(API 23)实战:基于悬浮导航、沉浸光感与HMAF的“航界智脑“——PC端AI智能体沉浸式无人机集群任务规划与空域协同管理工作台
人工智能·ar·无人机·harmonyos·智能体