一、应用背景与意义

在现代社会,机动车驾驶证已经成为几乎每个人生活中不可或缺的技能凭证。然而,从报名驾校到最终拿到驾照,整个过程并非一蹴而就。学车人需要经历科目一理论考试、科目二场地驾驶技能考试、科目三道路驾驶技能考试以及科目四安全文明驾驶常识考试,四个科目层层递进、缺一不可。每一个科目都有大量的知识点需要记忆、有大量的实操技能需要反复练习,而传统的纸质教材和口头讲解已经远远无法满足现代学员对高效、便捷、随时随地学习的要求。

`

正是在这样的背景下,驾考类移动应用应运而生。一款优秀的驾考助手应用,能够将海量的题库、模拟考试、错题回顾、练车进度跟踪以及个人学习数据统计等功能融为一体,为学员提供一站式的备考体验。学员不再需要翻阅厚重的教材,也不再需要手抄错题本,更不需要凭记忆去回忆上次练车的细节——所有这些都由应用代为管理和呈现。

本文要深入剖析的,正是一款这样的驾考助手应用。它基于声明式 UI 框架构建,采用了组件化的架构设计,将整个应用拆分为五个核心功能模块:题库练习、模拟考试、错题本、学车进度和个人中心。每个模块都有清晰的职责边界和独立的交互逻辑,同时又通过统一的底部导航栏有机地串联在一起,形成了一个完整、自洽的用户体验闭环。

从技术角度来看,这份代码展示了许多值得学习的设计理念。它运用了装饰器驱动的状态管理机制,让 UI 与数据之间保持自动同步;它通过类型接口和配置映射表实现了数据与展示的解耦;它大量使用了自定义构建器来复用 UI 片段,有效降低了代码的重复度;它还通过堆叠布局和遮罩层实现了优雅的模态弹窗交互。这些技术细节不仅适用于驾考场景,对于任何需要列表展示、表单录入、数据统计和进度追踪的应用都具有很强的借鉴意义。

接下来,我们将逐段、逐行地分析这份代码,从类型定义到数据模型,从静态配置到页面组件,深入理解每一个设计决策背后的考量。


二、类型定义:构建严谨的数据契约

2.1 元数据接口族

在任何复杂应用中,第一步都应该是明确数据的形状。这份代码开头就定义了一组接口,它们充当了整个应用的"数据契约"。

interface SubjectMeta {
  label: string
  icon: string
  color: string
  bg: string
  desc: string
  total: number
}

在这里插入图片描述

这段代码定义了 SubjectMeta 接口,用于描述"科目"这一概念的展示元数据。

  • label 字段存储科目的显示名称,例如"科目一"。
  • icon 字段存储一个 emoji 图标,用于在卡片中以视觉化的方式快速识别科目。
  • color 字段是该科目的主题色,采用十六进制颜色值,这里用橙色 #E65100 代表科目一的活力感。
  • bg 字段是对应的浅色背景色,用于标签或徽章的底色,确保文字与背景之间有足够的对比度。
  • desc 字段是一句简短描述,向用户说明该科目的性质和题量。
  • total 字段记录该科目的题目总数,用于在界面上显示"共 1280 题"之类的信息。

这种将"展示属性"与"业务数据"分离的设计思路非常关键。科目的业务逻辑(如考试规则)与它的视觉呈现(如颜色和图标)是两个不同的关注点,将它们拆开能够让代码更加清晰,也便于后续修改主题。

interface DifficultyMeta {
  label: string
  color: string
  bg: string
}

在这里插入图片描述

DifficultyMeta 接口描述题目难度的展示信息。它只包含三个字段:label 是难度名称(如"简单"“中等”“困难”),colorbg 分别是对应的主题色和背景色。难度信息在题库列表中以小标签的形式出现,不同难度用不同颜色区分——绿色代表简单、橙色代表中等、红色代表困难——这种颜色编码符合用户对"信号灯"式语义的直觉认知。

interface QuestionTypeMeta {
  label: string
  color: string
  bg: string
}

在这里插入图片描述

QuestionTypeMeta 接口描述题目类型(单选、多选、判断)的展示信息。结构与难度接口一致,但颜色映射不同:单选题用蓝色、多选题用紫色、判断题用橙色。这种区分让用户在浏览题目时一眼就能识别题型,而不需要阅读文字说明。

interface ExamStatusMeta {
  label: string
  color: string
  bg: string
}

在这里插入图片描述

ExamStatusMeta 接口用于考试结果的展示。"通过"用绿色,"未通过"用红色,这是最典型的成功/失败色彩语义。

interface DrivePhaseMeta {
  label: string
  icon: string
  color: string
  bg: string
}

在这里插入图片描述

DrivePhaseMeta 接口描述学车各阶段(科目一到科目四)的展示信息,比 SubjectMeta 多了一个 icon 字段但没有 desctotal。这个接口主要用于学车进度页面,每个科目配以不同的图标和颜色,形成视觉上的进度感。

interface BarChartData {
  label: string
  value: number
  color: string
}

在这里插入图片描述

BarChartData 接口定义了柱状图的单条数据结构。label 是横轴标签(如"1月"),value 是数值,color 是该柱子的颜色。值得注意的是,每根柱子可以有不同的颜色,这为渐变色柱状图提供了可能。

interface ProfileMenuItem {
  icon: string
  label: string
  value: string
}

在这里插入图片描述

ProfileMenuItem 接口定义了个人中心菜单项的结构。icon 是图标,label 是菜单名称,value 是当前值或状态描述(如"科目三预约中")。这种结构让菜单项既能展示静态标题,又能动态呈现关联的实时信息。

2.2 小结

这一组接口定义体现了"配置即数据"的哲学。所有的颜色、图标、描述文本都没有硬编码在组件内部,而是被提取到接口中,再通过配置表统一管理。这样做的好处是显而易见的:如果未来需要做暗色主题或多语言切换,只需要替换配置表即可,组件代码完全不需要改动。


三、数据模型:可观察的业务实体

3.1 题目模型 QuestionItem

@Observed
export class QuestionItem {
  id: number = 0
  subject: string = ''
  type: string = ''
  title: string = ''
  optionA: string = ''
  optionB: string = ''
  optionC: string = ''
  optionD: string = ''
  answer: string = ''
  analysis: string = ''
  difficulty: string = '简单'
  isFavorite: boolean = false
  constructor(id: number, subject: string, type: string, title: string, optionA: string, optionB: string, optionC: string, optionD: string, answer: string, analysis: string, difficulty: string, isFavorite: boolean) {
    this.id = id; this.subject = subject; this.type = type; this.title = title
    this.optionA = optionA; this.optionB = optionB; this.optionC = optionC; this.optionD = optionD
    this.answer = answer; this.analysis = analysis; this.difficulty = difficulty; this.isFavorite = isFavorite
  }
}

在这里插入图片描述

QuestionItem 是整个应用最核心的数据模型,代表一道练习题。

首先是 @Observed 装饰器。这个装饰器的意义在于:被它标注的类实例,其属性变化能够被框架自动监听。当某个题目的 isFavoritefalse 变为 true 时,所有引用了这个题目的 UI 组件都会自动刷新。这是声明式 UI 框架实现数据驱动视图的关键机制。

接下来看字段设计:

  • id 是题目的唯一标识符,用于列表渲染时的 key 和点击事件的定位。
  • subject 标记该题属于哪个科目(“科目一"或"科目四”)。
  • type 表示题型,取值为"单选"“多选”"判断"三者之一。
  • title 是题干文本,这是题目的核心内容。
  • optionAoptionD 是四个选项。对于判断题,只用到 A 和 B(正确/错误),C 和 D 为空字符串;对于单选题和多选题,四个选项都可能被使用。
  • answer 是正确答案。单选题存储选项字母(如"A"),多选题存储多个字母(如"ABCD"),判断题存储"正确"或"错误"。
  • analysis 是答案解析,帮助用户理解为什么这个答案是对的。
  • difficulty 是难度等级,默认值为"简单"。
  • isFavorite 标记该题是否被用户收藏。

构造函数接收所有字段并逐一赋值。这种显式构造函数的设计虽然略显冗长,但保证了对象创建时所有字段都能被正确初始化,避免了"创建半成品对象"的风险。

3.2 考试记录模型 ExamRecord

@Observed
export class ExamRecord {
  id: number = 0
  subject: string = ''
  score: number = 0
  correctCount: number = 0
  wrongCount: number = 0
  totalCount: number = 0
  duration: string = ''
  date: string = ''
  passed: boolean = false
  constructor(id: number, subject: string, score: number, correctCount: number, wrongCount: number, totalCount: number, duration: string, date: string, passed: boolean) {
    this.id = id; this.subject = subject; this.score = score
    this.correctCount = correctCount; this.wrongCount = wrongCount; this.totalCount = totalCount
    this.duration = duration; this.date = date; this.passed = passed
  }
}

在这里插入图片描述

ExamRecord 模型记录一次模拟考试的完整结果。

  • score 是考试得分(0-100)。
  • correctCountwrongCount 分别是答对和答错的题目数。
  • totalCount 是总题数(固定为 100)。
  • duration 是用时,以字符串形式存储(如"32分钟"),这样可以直接在 UI 中展示。
  • date 是考试日期,格式为"2026-08-08"。
  • passed 是布尔值,表示是否及格(90 分及以上为通过)。

这个模型的设计简洁而完整,涵盖了考试结果的所有关键维度。passed 字段虽然是可以通过 score >= 90 计算出来的衍生属性,但将其作为独立字段存储有两个好处:一是避免了每次渲染都重新计算的开销,二是允许未来及格线变化时只修改数据而不修改 UI 逻辑。

3.3 错题模型 WrongQuestion

@Observed
export class WrongQuestion {
  id: number = 0
  subject: string = ''
  title: string = ''
  correctAnswer: string = ''
  wrongAnswer: string = ''
  category: string = ''
  note: string = ''
  addedDate: string = ''
  reviewCount: number = 0
  constructor(id: number, subject: string, title: string, correctAnswer: string, wrongAnswer: string, category: string, note: string, addedDate: string, reviewCount: number) {
    this.id = id; this.subject = subject; this.title = title
    this.correctAnswer = correctAnswer; this.wrongAnswer = wrongAnswer
    this.category = category; this.note = note; this.addedDate = addedDate; this.reviewCount = reviewCount
  }
}

在这里插入图片描述

WrongQuestion 模型代表错题本中的一条记录,它的设计体现了"复盘学习"的理念。

  • correctAnswerwrongAnswer 并列存储,让用户能够直观对比正确答案和自己的错误答案,加深印象。
  • category 是错题分类(如"交通标志"“安全驾驶”"违章处罚"等),用于按类别筛选。
  • note 是用户自定义的备注,可以记录自己犯错的原因或记忆口诀。这个字段是空字符串时表示暂无备注。
  • reviewCount 记录这道题被复习了多少次,这是一个重要的学习指标——复习次数越多的题目,说明越难记住。

3.4 学车进度模型 SubjectProgress

@Observed
export class SubjectProgress {
  id: number = 0
  name: string = ''
  icon: string = ''
  progress: number = 0
  passRate: number = 0
  practiceCount: number = 0
  status: string = ''
  color: string = ''
  constructor(id: number, name: string, icon: string, progress: number, passRate: number, practiceCount: number, status: string, color: string) {
    this.id = id; this.name = name; this.icon = icon
    this.progress = progress; this.passRate = passRate; this.practiceCount = practiceCount
    this.status = status; this.color = color
  }
}

在这里插入图片描述

SubjectProgress 模型描述单个科目的学习进度。

  • progress 是完成百分比(0-100),科目一为 100 表示已完成,科目二为 75 表示进行中。
  • passRate 是模拟考试的通过率。
  • practiceCount 是练习次数,对于理论科目是刷题次数,对于实操科目是练车次数。
  • status 是文字状态描述(“已通过”“练习中”)。
  • color 直接存储在该模型中,用于进度环和状态标签的着色。这里将颜色下沉到数据模型而非纯靠配置表查找,是一种权衡——它让模型自带视觉属性,渲染时更直接。

3.5 练车记录模型 DriveRecord

@Observed
export class DriveRecord {
  id: number = 0
  date: string = ''
  time: string = ''
  coach: string = ''
  project: string = ''
  duration: number = 0
  score: number = 0
  note: string = ''
  constructor(id: number, date: string, time: string, coach: string, project: string, duration: number, score: number, note: string) {
    this.id = id; this.date = date; this.time = time; this.coach = coach
    this.project = project; this.duration = duration; this.score = score; this.note = note
  }
}

DriveRecord 模型记录一次实际的练车经历。

  • datetime 分开存储日期和时间,便于在列表中分别展示。
  • coach 是教练姓名,支持多位教练切换。
  • project 是练习项目(如"倒车入库"“侧方停车”“坡道定点”),这些都是科目二的经典项目。
  • duration 是练习时长(分钟),数值类型便于后续统计。
  • score 是教练给出的评分,UI 中会根据分数区间(85 分以上绿色、75-84 橙色、75 以下红色)动态着色。
  • note 是教练的评价或学员自己的笔记。

3.6 小结

五个数据模型各司其职,覆盖了驾考场景下的全部核心实体。它们都使用了 @Observed 装饰器,意味着它们是"活的"数据——任何属性变化都会自动触发界面更新。构造函数的设计虽然传统,但保证了数据完整性。


四、静态配置:数据与视觉的桥梁

4.1 科目配置

const SUBJECT_CONFIG: Record<string, SubjectMeta> = {
  '科目一': { label: '科目一', icon: '📖', color: '#E65100', bg: '#FFF3E0', desc: '理论考试·1280题', total: 1280 },
  '科目四': { label: '科目四', icon: '📘', color: '#0277BD', bg: '#E1F5FE', desc: '安全文明·1125题', total: 1125 }
}

SUBJECT_CONFIG 是一个以科目名称为键、以 SubjectMeta 为值的映射表。

  • 科目一用橙色系(#E65100 主色 + #FFF3E0 背景),搭配书本图标,暗示这是需要阅读记忆的理论考试。
  • 科目四用蓝色系(#0277BD 主色 + #E1F5FE 背景),搭配蓝色书本图标,传递安全文明的冷静感。

这种"一键一值"的配置方式,让组件在渲染时只需通过 SUBJECT_CONFIG[subject] 就能拿到全部展示信息,无需在组件内部写大量的 if-else 判断。

4.2 难度配置

const DIFFICULTY_CONFIG: Record<string, DifficultyMeta> = {
  '简单': { label: '简单', color: '#43A047', bg: '#E8F5E9' },
  '中等': { label: '中等', color: '#FB8C00', bg: '#FFF3E0' },
  '困难': { label: '困难', color: '#E53935', bg: '#FFEBEE' }
}

难度配置采用了交通信号灯的色彩语义:绿色表示安全(简单)、橙色表示警示(中等)、红色表示危险(困难)。这种色彩选择并非随意,而是利用了人类对颜色本能的心理联想,让用户无需阅读文字就能感知难度等级。

4.3 题型配置

const QUESTION_TYPE_CONFIG: Record<string, QuestionTypeMeta> = {
  '单选': { label: '单选题', color: '#0277BD', bg: '#E1F5FE' },
  '多选': { label: '多选题', color: '#7B1FA2', bg: '#F3E5F5' },
  '判断': { label: '判断题', color: '#E65100', bg: '#FFF3E0' }
}

题型配置为每种题型分配了不同的色彩:单选题蓝色、多选题紫色、判断题橙色。注意配置中的 label 字段将短键名(“单选”)展开为完整描述(“单选题”),这在数据存储和 UI 展示之间形成了一层转换——数据层用简洁的短名,展示层用完整的名称。

4.4 考试状态配置

const EXAM_STATUS_CONFIG: Record<string, ExamStatusMeta> = {
  'passed': { label: '通过', color: '#43A047', bg: '#E8F5E9' },
  'failed': { label: '未通过', color: '#E53935', bg: '#FFEBEE' }
}

考试状态配置使用英文键(“passed”/“failed”)映射到中文标签。这里使用英文键是一个不错的实践:数据层用语言无关的标识符,展示层再翻译成本地语言,便于未来国际化。

4.5 学车阶段配置

const DRIVE_PHASE_CONFIG: Record<string, DrivePhaseMeta> = {
  '科目一': { label: '科目一', icon: '📖', color: '#E65100', bg: '#FFF3E0' },
  '科目二': { label: '科目二', icon: '🚗', color: '#0277BD', bg: '#E1F5FE' },
  '科目三': { label: '科目三', icon: '🛣️', color: '#00838F', bg: '#E0F7FA' },
  '科目四': { label: '科目四', icon: '📘', color: '#6A1B9A', bg: '#F3E5F5' }
}

学车阶段配置覆盖了全部四个科目,每个科目有独特的图标和颜色。科目二用汽车图标、科目三用公路图标,形象地表达了"场地驾驶"和"道路驾驶"的区别。四个颜色各不相同(橙、蓝、青、紫),在进度页面中并列展示时能够形成清晰的视觉区分。

4.6 错题分类列表

const WRONG_CATEGORY_LIST: string[] = ['全部', '交通标志', '安全驾驶', '违章处罚', '应急处理', '车辆操作']

这是一个简单的字符串数组,定义了错题本的筛选标签。第一个元素"全部"表示不筛选,后面五个是具体的知识分类。这种数组形式的配置适合用于横向滚动的标签栏,通过 ForEach 直接渲染。

4.7 月度练习数据

const MONTHLY_PRACTICE_DATA: BarChartData[] = [
  { label: '1月', value: 12, color: '#FFB74D' },
  { label: '2月', value: 18, color: '#FFB74D' },
  { label: '3月', value: 25, color: '#FFA726' },
  { label: '4月', value: 30, color: '#FF9800' },
  { label: '5月', value: 22, color: '#FB8C00' },
  { label: '6月', value: 35, color: '#F57C00' },
  { label: '7月', value: 40, color: '#EF6C00' },
  { label: '8月', value: 28, color: '#E65100' }
]

月度练习数据用于个人中心的柱状图。每条数据包含月份标签、练习次数和柱子颜色。值得注意的是,颜色从浅橙到深橙渐变,形成了一种"温度上升"的视觉效果——随着月份推进,颜色越来越深,暗示学习的"热度"在升高。虽然数据值有起伏,但颜色渐变让整个图表更具视觉一致性。

4.8 个人中心菜单

const PROFILE_MENU: ProfileMenuItem[] = [
  { icon: '📅', label: '考试预约', value: '科目三预约中' },
  { icon: '📋', label: '学习计划', value: '每日30题' },
  { icon: '🔔', label: '考试提醒', value: '已开启' },
  { icon: '📊', label: '学习报告', value: '本周进步15%' },
  { icon: '🎁', label: '积分商城', value: '2680积分' },
  { icon: '⚙️', label: '设置', value: '' }
]

个人中心菜单定义了六项功能入口。每项除了图标和名称外,还携带一个 value 值,用于展示当前状态。比如"考试预约"的值是"科目三预约中",让用户一眼就知道当前的预约进度。最后一项"设置"的 value 为空字符串,渲染时会做条件判断,不显示多余的箭头右侧文本。

4.9 小结

静态配置是这份代码中最体现工程素养的部分。所有"魔法值"——颜色、图标、描述文本——都被集中管理在配置表中,组件代码只负责"如何展示"而不关心"展示什么"。这种分离让应用的主题、文案、分类体系都可以独立演进,而不需要触碰组件逻辑。


五、模拟数据:应用的"血肉"

5.1 题库数据

代码中定义了一个包含 24 道题目的 mockQuestions 数组,覆盖科目一和科目四的各类题型和难度。这些题目都是真实的驾考题目,例如:

new QuestionItem(1, '科目一', '判断', '驾驶机动车在高速公路上倒车、逆行、穿越中央分隔带掉头的一次记6分。', '正确', '错误', '', '', '错误', '在高速公路上倒车、逆行、穿越中央分隔带掉头的一次记12分,不是6分。', '中等', false)

这道判断题故意设置了"6分"这个错误选项,正确答案是"错误"(实际应记12分),解析详细说明了正确的记分规则。这种"似是而非"的选项设计正是驾考题目的典型特征,能够有效检验学员是否真正理解了规则。

题库中混合了简单、中等、困难三种难度,也包含了单选、多选、判断三种题型。部分题目的 isFavoritetrue,模拟用户已收藏的题目。这种数据多样性确保了 UI 渲染逻辑能够覆盖各种状态。

5.2 考试记录数据

mockExamRecords 数组包含 12 条模拟考试记录,时间跨度从 7 月到 8 月。分数从 72 到 96 不等,其中低于 90 分的记录 passedfalse。这种数据分布让通过率统计页面能够展示有意义的数字(12 次考试中 9 次通过,通过率 75%)。

5.3 错题数据

mockWrongQuestions 数组包含 20 条错题记录,覆盖了五个分类(交通标志、安全驾驶、违章处罚、应急处理、车辆操作)。每条记录都有 correctAnswerwrongAnswer 的对比,部分还有用户备注。reviewCount 从 0 到 3 不等,反映了不同题目的复习频次。

5.4 科目进度数据

const mockSubjectProgress: SubjectProgress[] = [
  new SubjectProgress(1, '科目一', '📖', 100, 95, 320, '已通过', '#43A047'),
  new SubjectProgress(2, '科目二', '🚗', 75, 80, 45, '练习中', '#0277BD'),
  new SubjectProgress(3, '科目三', '🛣️', 40, 70, 18, '练习中', '#FB8C00'),
  new SubjectProgress(4, '科目四', '📘', 60, 88, 150, '练习中', '#6A1B9A')
]

四条进度数据模拟了一个典型的学车学员状态:科目一已通过(100%),科目二正在练习(75%),科目三刚开始(40%),科目四进行中(60%)。这种"递减"的进度模式非常真实——先考完的科目进度高,后考的科目进度低。

5.5 练车记录数据

mockDriveRecords 包含 12 条练车记录,涉及两位教练(王教练、李教练)和多个练习项目(倒车入库、侧方停车、坡道定点、曲线行驶、直角转弯、科目三路考)。每条记录都有教练评语,如"入库角度有进步,继续保持",这些评语让数据看起来非常真实。

5.6 小结

模拟数据虽然不是最终产品中的真实数据,但它的质量直接影响开发和调试的效率。这份代码的模拟数据在数量、多样性和真实性上都做得很好,覆盖了各种边界情况,让 UI 组件在各种状态下都能得到充分测试。


六、入口页面与底部导航

6.1 导航枚举

enum DriveExamTab {
  BANK = 0,
  EXAM = 1,
  WRONG = 2,
  PROGRESS = 3,
  PROFILE = 4
}

DriveExamTab 枚举定义了五个底部标签页的索引值。使用枚举而非魔法数字(0、1、2、3、4)的好处是显而易见的:代码中 DriveExamTab.BANK0 更具可读性,也避免了数字含义混淆的风险。枚举值从 0 开始递增,与数组索引天然对应。

6.2 入口组件结构

@Entry
@Component
struct DriveExamApp {
  @State activeTab: DriveExamTab = DriveExamTab.BANK
  ...
}

DriveExamApp 是整个应用的入口组件,由 @Entry@Component 两个装饰器标记。@Entry 表示这是页面的根组件,@Component 表示这是一个自定义组件。

@State activeTab 是该组件的核心状态变量,记录当前激活的标签页。初始值为 BANK,即应用启动时默认显示题库页面。当 activeTab 的值改变时,整个内容区域会自动切换到对应的子页面。

6.3 内容区域构建器

@Builder contentArea() {
  Column() {
    if (this.activeTab === DriveExamTab.BANK) {
      QuestionBankContent()
    } else if (this.activeTab === DriveExamTab.EXAM) {
      MockExamContent()
    } else if (this.activeTab === DriveExamTab.WRONG) {
      WrongBookContent()
    } else if (this.activeTab === DriveExamTab.PROGRESS) {
      DriveProgressContent()
    } else {
      DriveProfileContent()
    }
  }
  .layoutWeight(1)
}

contentArea 是一个 @Builder 方法,它根据 activeTab 的值条件性地渲染不同的子组件。@Builder 装饰器的作用是将一个方法标记为"UI 构建器",使其返回一段可复用的 UI 声明。

这里的 if-else 链条实现了页面切换的核心逻辑。外层的 Column 设置了 layoutWeight(1),意味着它会占据除底部导航栏之外的所有垂直空间。这种布局方式确保了内容区域能够自适应不同屏幕高度。

6.4 底部标签项构建器

@Builder bottomTabItem(icon: string, label: string, tab: DriveExamTab) {
  Column() {
    Text(icon).fontSize(20).opacity(this.activeTab === tab ? 1.0 : 0.4)
    Text(label).fontSize(9)
      .fontColor(this.activeTab === tab ? '#E65100' : '#999999')
      .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
      .margin({ top: 1 })
    if (this.activeTab === tab) {
      Column().width(18).height(3)
        .backgroundColor('#E65100').borderRadius(2).margin({ top: 2 })
    }
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)
  .padding({ top: 5, bottom: 5 })
  .onClick(() => { this.activeTab = tab })
}

bottomTabItem 构建器封装了单个底部标签的渲染逻辑。它接收三个参数:图标、标签文字和对应的枚举值。

渲染逻辑中大量使用了三元运算符来实现"选中/未选中"两种状态的视觉差异:

  • 图标透明度:选中时为 1.0(完全显示),未选中时为 0.4(半透明灰色感)。
  • 标签颜色:选中时为主题橙色 #E65100,未选中时为灰色 #999999
  • 标签字重:选中时为粗体,未选中时为常规。

最精彩的是那个条件渲染的指示条——只有当前选中的标签才会显示一个 18x3 像素的橙色小条,作为"激活状态"的视觉锚点。这种设计在主流移动应用中非常常见。

onClick 事件中简单地执行 this.activeTab = tab,由于 activeTab@State 变量,赋值后会自动触发界面刷新,选中的标签会高亮,内容区域也会切换。

6.5 入口 build 方法

build() {
  Column() {
    this.contentArea()
    Row() {
      this.bottomTabItem('📚', '题库', DriveExamTab.BANK)
      this.bottomTabItem('📝', '模考', DriveExamTab.EXAM)
      this.bottomTabItem('❌', '错题', DriveExamTab.WRONG)
      this.bottomTabItem('📊', '进度', DriveExamTab.PROGRESS)
      this.bottomTabItem('👤', '我的', DriveExamTab.PROFILE)
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
    .padding({ top: 4, bottom: 6 })
    .shadow({ radius: 8, color: '#1A000000', color: offsetY: -2 })
  }
  .width('100%').height('100%')
  .backgroundColor('#FFF8E1')
}

整个页面的 build 方法非常简洁:一个垂直布局,上方是内容区域(占据剩余空间),下方是一行五个底部标签。

底部导航栏的 Row 设置了白色背景和向上偏移的阴影(offsetY: -2),营造出一种"浮在内容之上"的层次感。阴影颜色 #1A000000 中的 1A 是透明度(约 10%),保证了阴影柔和而不突兀。

整个页面的背景色 #FFF8E1 是一种非常浅的暖黄色,与主题橙色系形成统一的暖色调氛围。


七、题库练习页:核心学习场景

7.1 状态管理

@Component
struct QuestionBankContent {
  @State selectedSubject: string = '科目一'
  @State showAnswerModal: boolean = false
  @State showFavoriteModal: boolean = false
  @State selectedQuestion: QuestionItem | null = null
  @State selectedOption: string = ''
  @State favoriteNote: string = ''
  ...
}

题库页定义了六个状态变量:

  • selectedSubject 记录当前选中的科目,初始为"科目一",决定列表展示哪个科目的题目。
  • showAnswerModalshowFavoriteModal 分别控制答题详情弹窗和收藏弹窗的显隐。
  • selectedQuestion 存储当前被点击的题目对象,供弹窗展示详情。类型为 QuestionItem | null,初始为 null 表示未选择。
  • selectedOption 记录用户在答题时选择的选项。
  • favoriteNote 记录用户在收藏弹窗中输入的备注文本。

7.2 遮罩层构建器

@Builder modalOverlay(onClose: () => void) {
  Column()
    .width('100%').height('100%')
    .backgroundColor('rgba(0,0,0,0.5)')
    .onClick(onClose)
}

modalOverlay 是一个可复用的遮罩层构建器。它接收一个 onClose 回调函数作为参数,渲染一个覆盖全屏的半透明黑色 Column

rgba(0,0,0,0.5) 表示 50% 透明度的黑色,这是模态弹窗遮罩层的标准做法——既能让背景内容变暗以突出弹窗,又不会完全遮挡背景。

点击遮罩层会触发 onClose 回调,通常用于关闭弹窗。这种"点击外部关闭"的交互模式符合用户的操作直觉。

7.3 科目卡片构建器

@Builder subjectCard(subject: string) {
  Column() {
    Row() {
      Text(SUBJECT_CONFIG[subject]?.icon ?? '📖').fontSize(32)
      Column() {
        Text(SUBJECT_CONFIG[subject]?.label ?? subject).fontSize(15).fontWeight(FontWeight.Bold).fontColor(SUBJECT_CONFIG[subject]?.color ?? '#E65100')
        Text(SUBJECT_CONFIG[subject]?.desc ?? '').fontSize(10).fontColor('#888888').margin({ top: 2 })
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
      Text((SUBJECT_CONFIG[subject]?.total ?? 0).toString() + '题').fontSize(11)
        .fontColor(SUBJECT_CONFIG[subject]?.color ?? '#E65100')
        .backgroundColor(SUBJECT_CONFIG[subject]?.bg ?? '#FFF3E0')
        .padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(10)
    }
    .width('100%')
    Row() {
      Column()
        .width(this.selectedSubject === subject ? '100%' : '0%')
        .height(3).backgroundColor(SUBJECT_CONFIG[subject]?.color ?? '#E65100').borderRadius(2)
    }
    .width('100%').height(3).backgroundColor('#F0F0F0').borderRadius(2).margin({ top: 8 })
  }
  .width('100%').padding(14).backgroundColor('#FFFFFF')
  .borderRadius(14).margin({ left: 12, right: 12, top: 6 })
  .onClick(() => { this.selectedSubject = subject })
}

subjectCard 构建器渲染一个科目选择卡片。这段代码值得仔细分析:

第一行是一个水平布局,包含三部分。左侧是科目的 emoji 图标,字号 32,非常醒目。中间是一个垂直布局,包含科目名称(15 号粗体字,使用科目主题色)和描述文本(10 号灰色字)。右侧是一个圆角标签,显示题目总数(如"1280题"),使用科目的主题色和背景色。

这里大量使用了 ?? 空值合并运算符。SUBJECT_CONFIG[subject]?.icon ?? '📖' 的含义是:先尝试从配置表中获取图标,如果配置表不存在该键或值为空,则回退到默认值’📖’。这种防御性编程确保了即使配置缺失,UI 也不会崩溃。

卡片底部是一个进度条样式的指示器。外层是一个灰色背景条(#F0F0F0),内层是一个彩色条,宽度根据选中状态在 100%0% 之间切换。这种动画式的选中指示器比简单的边框高亮更加优雅。

点击卡片时执行 this.selectedSubject = subject,触发状态更新,列表会刷新显示对应科目的题目。

7.4 题目项构建器

@Builder questionItemBuilder(q: QuestionItem) {
  Column() {
    Row() {
      Text(QUESTION_TYPE_CONFIG[q.type]?.label ?? '单选题').fontSize(9)
        .fontColor(QUESTION_TYPE_CONFIG[q.type]?.color ?? '#0277BD')
        .backgroundColor(QUESTION_TYPE_CONFIG[q.type]?.bg ?? '#E1F5FE')
        .padding({ left: 5, right: 5, top: 2, bottom: 2 }).borderRadius(6)
      Text(DIFFICULTY_CONFIG[q.difficulty]?.label ?? '简单').fontSize(9)
        .fontColor(DIFFICULTY_CONFIG[q.difficulty]?.color ?? '#43A047')
        .backgroundColor(DIFFICULTY_CONFIG[q.difficulty]?.bg ?? '#E8F5E9')
        .padding({ left: 5, right: 5, top: 2, bottom: 2 }).borderRadius(6).margin({ left: 4 })
      Column().layoutWeight(1)
      Text(q.isFavorite ? '⭐' : '☆').fontSize(14)
        .fontColor('#FFB300')
        .onClick(() => { this.selectedQuestion = q; this.showFavoriteModal = true })
    }
    .width('100%')
    Text(q.id.toString() + '. ' + q.title).fontSize(13).fontColor('#212121')
      .fontWeight(FontWeight.Medium).margin({ top: 8 })
    ...
  }
}

questionItemBuilder 是题库列表中单个题目卡片的渲染逻辑。

第一行是标签栏,左侧依次显示题型标签和难度标签。两个标签都使用 9 号字、圆角 6 的小徽章样式,颜色和背景均来自配置表。中间用一个 layoutWeight(1) 的空 Column 占位,将右侧的收藏图标推到最右端。

收藏图标根据 isFavorite 状态显示实心星(⭐)或空心星(☆),点击后打开收藏弹窗。

题干文本使用 13 号字、深灰色 #212121、中等字重,前面拼接了题目序号,形成"1. 驾驶机动车在…"的格式。

    if (q.optionA !== '') {
      Text('A. ' + q.optionA).fontSize(12).fontColor('#555555').margin({ top: 6, left: 4 })
    }
    if (q.optionB !== '') {
      Text('B. ' + q.optionB).fontSize(12).fontColor('#555555').margin({ top: 4, left: 4 })
    }
    if (q.optionC !== '') {
      Text('C. ' + q.optionC).fontSize(12).fontColor('#555555').margin({ top: 4, left: 4 })
    }
    if (q.optionD !== '') {
      Text('D. ' + q.optionD).fontSize(12).fontColor('#555555').margin({ top: 4, left: 4 })
    }

选项的渲染使用了条件判断:只有当选项内容不为空字符串时才渲染。这样判断题(只有 A、B 两个选项)就不会显示多余的 C、D 选项。每个选项前面拼接了字母前缀("A. "),12 号字、中灰色 #555555

    Row() {
      Text('正确答案:' + q.answer).fontSize(11).fontColor('#E65100').fontWeight(FontWeight.Bold)
      Column().layoutWeight(1)
      Text('查看解析 >').fontSize(11).fontColor('#0277BD')
        .onClick(() => { this.selectedQuestion = q; this.selectedOption = ''; this.showAnswerModal = true })
    }
    .width('100%').margin({ top: 8 })

卡片底部一行显示正确答案(橙色粗体)和"查看解析 >"链接(蓝色可点击)。点击链接会将当前题目赋值给 selectedQuestion,清空 selectedOption,然后打开答题详情弹窗。

7.5 答题详情弹窗

@Builder answerModal() {
  Column() {
    this.modalOverlay(() => { this.showAnswerModal = false })
    Column() {
      Row() {
        Text('📝 答题详情').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
        Column().layoutWeight(1)
        Text('✕').fontSize(18).fontColor('#999999')
          .onClick(() => { this.showAnswerModal = false })
      }
      .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
      Divider().color('#FFF3E0')
      Scroll() {
        Column() {
          Text(this.selectedQuestion?.title ?? '').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#212121')
            .width('100%').margin({ top: 12 })
          Text('A. ' + (this.selectedQuestion?.optionA ?? '')).fontSize(13).fontColor('#555555').margin({ top: 8 })
          ...
          Row() {
            Text('正确答案:' + (this.selectedQuestion?.answer ?? '')).fontSize(14)
              .fontColor('#43A047').fontWeight(FontWeight.Bold)
            Column().layoutWeight(1)
            Text('✅').fontSize(16)
          }
          .width('100%').margin({ top: 12 })
          Column() {
            Text('💡 解析').fontSize(12).fontColor('#E65100').fontWeight(FontWeight.Bold)
            Text(this.selectedQuestion?.analysis ?? '').fontSize(12).fontColor('#555555').margin({ top: 6 })
          }
          .width('100%').backgroundColor('#FFF8E1').borderRadius(10)
          .padding(12).margin({ top: 12 })
        }
        .padding({ left: 20, right: 20, bottom: 16 })
      }
      .layoutWeight(1)
      Row() {
        Text('❌ 加入错题本').fontSize(13).fontColor('#E53935')
          .backgroundColor('#FFEBEE').borderRadius(18)
          .padding({ left: 20, right: 20, top: 8, bottom: 8 })
        Text('⭐ 收藏题目').fontSize(13).fontColor('#FFB300')
          .backgroundColor('#FFF8E1').borderRadius(18)
          .padding({ left: 20, right: 20, top: 8, bottom: 8 }).margin({ left: 8 })
        Text('下一题').fontSize(13).fontColor('#FFFFFF')
          .backgroundColor('#E65100').borderRadius(18)
          .padding({ left: 20, right: 20, top: 8, bottom: 8 }).margin({ left: 8 })
          .onClick(() => { this.showAnswerModal = false })
      }
      .width('100%').justifyContent(FlexAlign.Center)
      .padding({ left: 20, right: 20, top: 12, bottom: 14 })
    }
    .width('92%').height('72%').backgroundColor('#FFFFFF').borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .position({ x: '4%', y: '14%' })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

answerModal 是一个功能丰富的答题详情弹窗。它由三部分组成:

弹窗结构:最外层是一个全屏 Column,设置 position 为绝对定位、zIndex(999) 确保浮于所有内容之上。内层先调用 modalOverlay 渲染半透明遮罩,然后是实际的弹窗内容容器(宽 92%、高 72%、圆角 16 的白色面板,通过 position 定位在屏幕上方 14% 处)。

内容区域:顶部是标题栏("📝 答题详情"和关闭按钮),下方有分割线。中间是可滚动的内容区,依次展示题干、四个选项、正确答案(绿色)和解析(浅黄色背景块)。解析区域使用了 #FFF8E1 的浅黄色背景和 10 的圆角,视觉上与正文区分开来。

底部操作栏:三个按钮横向排列——“加入错题本”(红底红字)、“收藏题目”(黄底黄字)、“下一题”(橙底白字)。三个按钮使用不同的色彩语义,让用户能够快速识别每个操作的性质。

7.6 收藏弹窗

@Builder favoriteModal() {
  Column() {
    this.modalOverlay(() => { this.showFavoriteModal = false })
    Column() {
      Row() {
        Text('⭐ 收藏错题').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
        Column().layoutWeight(1)
        Text('✕').fontSize(18).fontColor('#999999')
          .onClick(() => { this.showFavoriteModal = false })
      }
      .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
      Divider().color('#FFF3E0')
      Column() {
        Text(this.selectedQuestion?.title ?? '').fontSize(13).fontColor('#212121')
          .fontWeight(FontWeight.Medium).margin({ top: 12, left: 20 })
        Text('添加备注:').fontSize(12).fontColor('#888888').margin({ top: 16, left: 20 })
        TextArea({ placeholder: '记录易错点或学习笔记...' })
          .placeholderColor('#BBBBBB').fontSize(12).width('90%').height(80)
          .backgroundColor('#FFF8E1').borderRadius(8)
          .margin({ top: 6 })
          .onChange((v: string) => { this.favoriteNote = v })
      }
      .width('100%')
      Row() {
        Text('取消').fontSize(14).fontColor('#888888')
          .backgroundColor('#F5F5F5').borderRadius(20)
          .padding({ left: 28, right: 28, top: 10, bottom: 10 })
          .onClick(() => { this.showFavoriteModal = false })
        Text('确认收藏').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#E65100').borderRadius(20)
          .padding({ left: 28, right: 28, top: 10, bottom: 10 }).margin({ left: 12 })
          .onClick(() => { this.showFavoriteModal = false })
      }
      .width('100%').justifyContent(FlexAlign.Center)
      .padding({ left: 20, right: 20, top: 20, bottom: 20 })
    }
    .width('80%').backgroundColor('#FFFFFF').borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .position({ x: '10%', y: '32%' })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

收藏弹窗比答题弹窗更小巧(宽 80%),主要用于让用户为收藏的题目添加备注。核心交互是一个 TextArea 输入框,onChange 回调将输入值同步到 favoriteNote 状态变量。底部是"取消"和"确认收藏"两个按钮,分别使用灰色和橙色背景。

7.7 题库页 build 方法

build() {
  Stack() {
    Column() {
      Row() {
        Text('📚 题库练习').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#E65100')
        Column().layoutWeight(1)
        Text('🔍').fontSize(18).margin({ right: 12 })
        Text('⚙️').fontSize(18)
      }
      .width('100%').padding({ left: 16, right: 16, top: 14, bottom: 10 })

      this.subjectCard('科目一')
      this.subjectCard('科目四')

      Row() {
        Text('共' + (SUBJECT_CONFIG[this.selectedSubject]?.total ?? 0).toString() + '道题').fontSize(11).fontColor('#888888')
        Column().layoutWeight(1)
        Text('已练习 320题').fontSize(11).fontColor('#0277BD')
      }
      .width('100%').padding({ left: 16, right: 16, top: 8, bottom: 4 })

      Scroll() {
        Column() {
          this.questionItemBuilder(mockQuestions[0])
          ... (依次渲染 24 道题目)
          this.questionItemBuilder(mockQuestions[23])
        }
        .padding({ bottom: 20 })
      }
      .layoutWeight(1).scrollBar(BarState.Off)
    }
    .width('100%').height('100%')

    if (this.showAnswerModal) { this.answerModal() }
    if (this.showFavoriteModal) { this.favoriteModal() }
  }
  .width('100%').height('100%')
}

build 方法使用 Stack(堆叠布局)作为根容器。Stack 的特点是子元素会层叠放置,后声明的子元素覆盖在前面的之上。

Stack 内部先放主体内容 Column(标题栏、科目卡片、统计信息、可滚动的题目列表),然后条件性地在上方叠加弹窗。当 showAnswerModalshowFavoriteModaltrue 时,对应的弹窗会渲染在 Stack 的最上层,覆盖在内容之上。

这种"主体 + 弹窗层叠"的模式是处理模态交互的标准做法。主体内容始终存在(不被销毁),弹窗只是临时覆盖在上方,关闭后立即恢复交互。

标题栏右侧有搜索和设置两个图标,为后续功能预留了入口。统计信息行显示当前科目的总题数和已练习题数,让用户了解学习进度。

题目列表使用 Scroll 包裹,设置 scrollBar(BarState.Off) 隐藏滚动条,保持界面整洁。24 道题目通过逐个调用 questionItemBuilder 渲染。


八、模拟考试页:检验学习成果

8.1 状态管理

@Component
struct MockExamContent {
  @State showStartModal: boolean = false
  @State showDetailModal: boolean = false
  @State showDeleteModal: boolean = false
  @State selectedExam: ExamRecord | null = null
  @State selectedExamSubject: string = '科目一'
  ...
}

模拟考试页管理着三个弹窗的显隐状态(开始考试、考试详情、删除确认)和一个当前选中的考试记录。selectedExamSubject 记录用户在"开始考试"弹窗中选择的科目。

8.2 考试记录卡片构建器

@Builder examRecordBuilder(e: ExamRecord) {
  Column() {
    Row() {
      Column() {
        Text(e.score.toString()).fontSize(28).fontWeight(FontWeight.Bold)
          .fontColor(e.passed ? '#43A047' : '#E53935')
        Text('分').fontSize(10).fontColor('#888888')
      }
      .width(70).height(70).backgroundColor(e.passed ? '#E8F5E9' : '#FFEBEE')
      .borderRadius(14).alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
      Column() {
        Text(e.subject + ' · 模拟考试').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#212121')
        Row() {
          Text(EXAM_STATUS_CONFIG[e.passed ? 'passed' : 'failed']?.label ?? '').fontSize(10)
            .fontColor(EXAM_STATUS_CONFIG[e.passed ? 'passed' : 'failed']?.color ?? '#888888')
            .backgroundColor(EXAM_STATUS_CONFIG[e.passed ? 'passed' : 'failed']?.bg ?? '#F5F5F5')
            .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
          Text(e.date).fontSize(10).fontColor('#AAAAAA').margin({ left: 8 })
        }
        .margin({ top: 4 })
        Row() {
          Column() {
            Text(e.correctCount.toString()).fontSize(13).fontColor('#43A047').fontWeight(FontWeight.Bold)
            Text('正确').fontSize(9).fontColor('#888888')
          }.alignItems(HorizontalAlign.Center)
          Column() {
            Text(e.wrongCount.toString()).fontSize(13).fontColor('#E53935').fontWeight(FontWeight.Bold)
            Text('错误').fontSize(9).fontColor('#888888')
          }.margin({ left: 16 }).alignItems(HorizontalAlign.Center)
          Column() {
            Text(e.duration).fontSize(13).fontColor('#0277BD').fontWeight(FontWeight.Bold)
            Text('用时').fontSize(9).fontColor('#888888')
          }.margin({ left: 16 }).alignItems(HorizontalAlign.Center)
        }
        .margin({ top: 6 })
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 12 })
      Text('>').fontSize(14).fontColor('#CCCCCC')
    }
    .width('100%')
  }
  .width('100%').padding(14).backgroundColor('#FFFFFF')
  .borderRadius(12).margin({ left: 12, right: 12, top: 6 })
  .onClick(() => { this.selectedExam = e; this.showDetailModal = true })
}

examRecordBuilder 渲染单条考试记录卡片,信息密度很高但层次分明:

左侧分数块:一个 70x70 的圆角方块,背景色根据通过状态变化(通过为浅绿、未通过为浅红)。中间显示大号分数(28 号字)和"分"字小标签。分数的颜色也跟随通过状态——绿色或红色。这个色块是整张卡片最醒目的元素,让用户一眼就能看到成绩。

中间信息区:包含三行信息。第一行是"科目一 · 模拟考试"的标题和状态标签、日期。第二行是三个数据列:正确数(绿色)、错误数(红色)、用时(蓝色),每个数据下方有小字标签说明含义。这种"数字 + 标签"的统计展示模式简洁高效。

右侧箭头:一个浅灰色的">"符号,暗示卡片可点击。点击后打开考试详情弹窗。

8.3 开始考试弹窗

@Builder startExamModal() {
  Column() {
    this.modalOverlay(() => { this.showStartModal = false })
    Column() {
      Text('📝 开始模拟考试').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
        .margin({ top: 20 })
      Text('选择考试科目').fontSize(12).fontColor('#888888').margin({ top: 16 })
      Row() {
        Text('科目一').fontSize(13).fontColor(this.selectedExamSubject === '科目一' ? '#FFFFFF' : '#E65100')
          .backgroundColor(this.selectedExamSubject === '科目一' ? '#E65100' : '#FFF3E0')
          .padding({ left: 20, right: 20, top: 8, bottom: 8 }).borderRadius(18)
          .onClick(() => { this.selectedExamSubject = '科目一' })
        Text('科目四').fontSize(13).fontColor(this.selectedExamSubject === '科目四' ? '#FFFFFF' : '#0277BD')
          .backgroundColor(this.selectedExamSubject === '科目四' ? '#0277BD' : '#E1F5FE')
          .padding({ left: 20, right: 20, top: 8, bottom: 8 }).borderRadius(18).margin({ left: 10 })
          .onClick(() => { this.selectedExamSubject = '科目四' })
      }
      .margin({ top: 10 })
      Column() {
        Text('考试须知').fontSize(12).fontColor('#E65100').fontWeight(FontWeight.Bold)
        Text('· 考试时间:45分钟').fontSize(11).fontColor('#555555').margin({ top: 6 })
        Text('· 题目数量:100题').fontSize(11).fontColor('#555555').margin({ top: 3 })
        Text('· 及格分数:90分').fontSize(11).fontColor('#555555').margin({ top: 3 })
        Text('· 每题1分,答错不扣分').fontSize(11).fontColor('#555555').margin({ top: 3 })
      }
      .width('85%').backgroundColor('#FFF8E1').borderRadius(10)
      .padding(14).margin({ top: 16 })
      Row() {
        Text('取消').fontSize(14).fontColor('#888888')
          .backgroundColor('#F5F5F5').borderRadius(20)
          .padding({ left: 28, right: 28, top: 10, bottom: 10 })
          .onClick(() => { this.showStartModal = false })
        Text('开始考试').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#E65100').borderRadius(20)
          .padding({ left: 28, right: 28, top: 10, bottom: 10 }).margin({ left: 12 })
          .onClick(() => { this.showStartModal = false })
      }
      .width('100%').justifyContent(FlexAlign.Center)
      .padding({ top: 20, bottom: 20 })
    }
    .width('82%').backgroundColor('#FFFFFF').borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .position({ x: '9%', y: '28%' })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

开始考试弹窗提供了考试前的全部准备信息。科目选择采用两个切换按钮,选中状态时文字变白、背景变为主题色,未选中时文字为主题色、背景为浅色。这种"反色切换"的交互非常直观。

考试须知区域列出了四条关键信息:时长、题量、及格线、计分规则。这些信息放在浅黄色背景的卡片中,与正文区域区分,让用户在开始考试前能够明确规则。

8.4 考试详情弹窗

@Builder detailModal() {
  Column() {
    this.modalOverlay(() => { this.showDetailModal = false })
    Column() {
      Row() {
        Text('📊 考试详情').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
        Column().layoutWeight(1)
        Text('✕').fontSize(18).fontColor('#999999')
          .onClick(() => { this.showDetailModal = false })
      }
      .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
      Divider().color('#FFF3E0')
      Scroll() {
        Column() {
          Row() {
            Column() {
              Text((this.selectedExam?.score ?? 0).toString()).fontSize(40).fontWeight(FontWeight.Bold)
                .fontColor(this.selectedExam?.passed ?? false ? '#43A047' : '#E53935')
              Text('总分').fontSize(11).fontColor('#888888')
            }.width(100).alignItems(HorizontalAlign.Center)
            ...
          }
          .width('100%').padding({ top: 16 })
          Row() {
            Column() {
              Text('✅').fontSize(20)
              Text((this.selectedExam?.correctCount ?? 0).toString()).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#43A047')
              Text('答对').fontSize(10).fontColor('#888888')
            }.layoutWeight(1).alignItems(HorizontalAlign.Center)
            Column() {
              Text('❌').fontSize(20)
              Text((this.selectedExam?.wrongCount ?? 0).toString()).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#E53935')
              Text('答错').fontSize(10).fontColor('#888888')
            }.layoutWeight(1).alignItems(HorizontalAlign.Center)
            ...
          }
          .width('100%').backgroundColor('#FFF8E1').borderRadius(12)
          .padding({ top: 14, bottom: 14 }).margin({ left: 20, right: 20, top: 16 })
          ...
        }
      }
      ...
    }
    ...
  }
  ...
}

考试详情弹窗是整个应用中信息最密集的界面之一。它分为几个区域:

分数总览:左侧是 40 号字的超大分数显示,颜色随通过状态变化。右侧是状态标签、科目信息和日期。

统计四宫格:一个浅黄色背景的横排区域,平均分为四个等宽列,分别展示答对数(绿色 + 勾号)、答错数(红色 + 叉号)、总题数(蓝色 + 列表图标)、用时(橙色 + 计时图标)。每列都是"图标 + 大数字 + 小标签"的三层结构,信息层次清晰。

成绩分析:一段文字描述,根据通过状态显示不同的鼓励或建议文案。

底部操作栏提供"删除记录"和"再考一次"两个按钮,后者会先关闭详情弹窗再打开开始考试弹窗,实现了弹窗间的跳转。

8.5 删除确认弹窗

@Builder deleteModal() {
  Column() {
    this.modalOverlay(() => { this.showDeleteModal = false })
    Column() {
      Text('⚠️').fontSize(48).margin({ top: 24 })
      Text('确认删除此考试记录?').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
      Text('删除后不可恢复').fontSize(13).fontColor('#E53935').margin({ top: 4 })
      Row() {
        Text('取消').fontSize(14).fontColor('#888888')
          .backgroundColor('#F5F5F5').borderRadius(20)
          .padding({ left: 28, right: 28, top: 10, bottom: 10 })
          .onClick(() => { this.showDeleteModal = false })
        Text('确认删除').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#E53935').borderRadius(20)
          .padding({ left: 28, right: 28, top: 10, bottom: 10 }).margin({ left: 12 })
          .onClick(() => { this.showDeleteModal = false; this.showDetailModal = false })
      }
      .width('100%').justifyContent(FlexAlign.Center)
      .padding({ left: 20, right: 20, top: 20, bottom: 20 })
    }
    .width('80%').backgroundColor('#FFFFFF').borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .position({ x: '10%', y: '38%' })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

删除确认弹窗是最简洁的弹窗设计——一个警告图标、一句确认提问、一句不可恢复提示、两个按钮。确认删除按钮会同时关闭删除弹窗和详情弹窗,形成"连锁关闭"的效果。这种二次确认机制是防止误操作的标准做法。

8.6 模考页 build 方法

build() {
  Stack() {
    Column() {
      Row() {
        Text('📝 模拟考试').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#E65100')
        Column().layoutWeight(1)
        Text('📊').fontSize(18).margin({ right: 12 })
      }
      .width('100%').padding({ left: 16, right: 16, top: 14, bottom: 10 })

      Row() {
        Column() {
          Text('12').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#E65100')
          Text('总考试次数').fontSize(10).fontColor('#888888')
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        Column() {
          Text('9').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#43A047')
          Text('通过次数').fontSize(10).fontColor('#888888')
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        Column() {
          Text('75%').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#0277BD')
          Text('通过率').fontSize(10).fontColor('#888888')
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        Column() {
          Text('88').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FB8C00')
          Text('平均分').fontSize(10).fontColor('#888888')
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
      }
      .width('100%').backgroundColor('#FFFFFF')
      .padding({ top: 14, bottom: 14 })

      Row() {
        Text('考试记录').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#212121')
        Column().layoutWeight(1)
        Text('+ 新模考').fontSize(12).fontColor('#FFFFFF')
          .backgroundColor('#E65100').borderRadius(14)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .onClick(() => { this.showStartModal = true })
      }
      .width('100%').padding({ left: 16, right: 16, top: 10, bottom: 6 })

      Scroll() {
        Column() {
          this.examRecordBuilder(mockExamRecords[0])
          ... (渲染 12 条记录)
          this.examRecordBuilder(mockExamRecords[11])
        }
        .padding({ bottom: 20 })
      }
      .layoutWeight(1).scrollBar(BarState.Off)
    }
    .width('100%').height('100%')

    if (this.showStartModal) { this.startExamModal() }
    if (this.showDetailModal) { this.detailModal() }
    if (this.showDeleteModal) { this.deleteModal() }
  }
  .width('100%').height('100%')
}

模考页的 build 方法结构清晰。标题栏下方是一个四列统计面板,分别展示总考试次数(12,橙色)、通过次数(9,绿色)、通过率(75%,蓝色)、平均分(88,橙色)。四个数字使用不同颜色,让每个指标都有独立的视觉标识。

统计面板下方是"考试记录"标题和"+ 新模考"按钮,点击后打开开始考试弹窗。再下方是可滚动的考试记录列表。


九、错题本页:精准打击薄弱环节

9.1 状态管理

@Component
struct WrongBookContent {
  @State selectedCategory: string = '全部'
  @State showDetailModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteModal: boolean = false
  @State selectedWrong: WrongQuestion | null = null
  @State editNote: string = ''
  ...
}

错题本页管理着分类筛选状态(selectedCategory)、三个弹窗的显隐状态、当前选中的错题对象和编辑备注的文本内容。

9.2 错题项构建器

@Builder wrongItemBuilder(w: WrongQuestion) {
  Column() {
    Row() {
      Text(SUBJECT_CONFIG[w.subject]?.icon ?? '📖').fontSize(14)
      Text(w.subject).fontSize(10)
        .fontColor(SUBJECT_CONFIG[w.subject]?.color ?? '#E65100')
        .backgroundColor(SUBJECT_CONFIG[w.subject]?.bg ?? '#FFF3E0')
        .padding({ left: 5, right: 5, top: 2, bottom: 2 }).borderRadius(6).margin({ left: 4 })
      Text(w.category).fontSize(10)
        .fontColor('#0277BD').backgroundColor('#E1F5FE')
        .padding({ left: 5, right: 5, top: 2, bottom: 2 }).borderRadius(6).margin({ left: 4 })
      Column().layoutWeight(1)
      Text('复习' + w.reviewCount.toString() + '次').fontSize(9).fontColor('#AAAAAA')
    }
    .width('100%')
    Text(w.title).fontSize(13).fontColor('#212121').fontWeight(FontWeight.Medium).margin({ top: 8 })
    Row() {
      Column() {
        Text('✅ ' + w.correctAnswer).fontSize(11).fontColor('#43A047').fontWeight(FontWeight.Bold)
      }.layoutWeight(1).alignItems(HorizontalAlign.Start)
      Column() {
        Text('❌ ' + w.wrongAnswer).fontSize(11).fontColor('#E53935')
      }.alignItems(HorizontalAlign.Start)
    }
    .width('100%').margin({ top: 6 })
    if (w.note !== '') {
      Text('📝 ' + w.note).fontSize(11).fontColor('#888888').margin({ top: 6 })
        .width('100%').backgroundColor('#FFF8E1').borderRadius(6)
        .padding({ left: 8, right: 8, top: 4, bottom: 4 })
    }
  }
  .width('100%').padding(12).backgroundColor('#FFFFFF')
  .borderRadius(12).margin({ left: 12, right: 12, top: 6 })
  .onClick(() => { this.selectedWrong = w; this.showDetailModal = true })
}

wrongItemBuilder 渲染单条错题卡片。卡片的信息架构如下:

标签行:科目图标 + 科目标签 + 分类标签 + 右侧的复习次数。三个标签使用不同颜色体系,让用户能够快速识别错题的归属和类别。

题干:13 号字的中灰色文本。

答案对比行:左侧是正确答案(绿色 + 勾号),右侧是用户的错误答案(红色 + 叉号)。这种并排对比是最有效的纠错方式——用户一眼就能看到自己哪里做错了。

备注块(条件渲染):只有当 note 不为空时才渲染。备注以浅黄色背景的小卡片形式呈现,视觉上与卡片其他部分区分。

9.3 错题详情弹窗

@Builder wrongDetailModal() {
  Column() {
    this.modalOverlay(() => { this.showDetailModal = false })
    Column() {
      Row() {
        Text('❌ 错题详情').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
        Column().layoutWeight(1)
        Text('✕').fontSize(18).fontColor('#999999')
          .onClick(() => { this.showDetailModal = false })
      }
      .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
      Divider().color('#FFF3E0')
      Scroll() {
        Column() {
          Row() {
            Text(SUBJECT_CONFIG[this.selectedWrong?.subject ?? '科目一']?.icon ?? '📖').fontSize(16)
            Text(this.selectedWrong?.subject ?? '').fontSize(12)
              .fontColor(SUBJECT_CONFIG[this.selectedWrong?.subject ?? '科目一']?.color ?? '#E65100')
              .backgroundColor(SUBJECT_CONFIG[this.selectedWrong?.subject ?? '科目一']?.bg ?? '#FFF3E0')
              .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).margin({ left: 6 })
            ...
          }
          ...
          Row() {
            Text('✅ 正确答案').fontSize(12).fontColor('#43A047').fontWeight(FontWeight.Bold)
            Column().layoutWeight(1)
            Text(this.selectedWrong?.correctAnswer ?? '').fontSize(14).fontColor('#43A047').fontWeight(FontWeight.Bold)
          }
          .width('100%').backgroundColor('#E8F5E9').borderRadius(10)
          .padding({ left: 14, right: 14, top: 10, bottom: 10 }).margin({ top: 12 })
          Row() {
            Text('❌ 你的答案').fontSize(12).fontColor('#E53935').fontWeight(FontWeight.Bold)
            Column().layoutWeight(1)
            Text(this.selectedWrong?.wrongAnswer ?? '').fontSize(14).fontColor('#E53935').fontWeight(FontWeight.Bold)
          }
          .width('100%').backgroundColor('#FFEBEE').borderRadius(10)
          .padding({ left: 14, right: 14, top: 10, bottom: 10 }).margin({ top: 8 })
          ...
        }
      }
      ...
      Row() {
        Text('✏️ 编辑备注').fontSize(13).fontColor('#FFFFFF')
          .backgroundColor('#0277BD').borderRadius(16)
          .padding({ left: 18, right: 18, top: 8, bottom: 8 })
          .onClick(() => { this.showEditModal = true })
        Text('🔄 已掌握').fontSize(13).fontColor('#FFFFFF')
          .backgroundColor('#43A047').borderRadius(16)
          .padding({ left: 18, right: 18, top: 8, bottom: 8 }).margin({ left: 8 })
        Text('🗑️ 删除').fontSize(13).fontColor('#FFFFFF')
          .backgroundColor('#E53935').borderRadius(16)
          .padding({ left: 18, right: 18, top: 8, bottom: 8 }).margin({ left: 8 })
          .onClick(() => { this.showDeleteModal = true })
      }
      ...
    }
    ...
  }
  ...
}

错题详情弹窗比列表卡片展示了更完整的信息。最突出的设计是正确答案和错误答案分别用独立的色块呈现——正确答案用浅绿色背景的圆角块,错误答案用浅红色背景的圆角块。这种"双块对比"的视觉设计比简单的文字对比更加醒目。

底部操作栏提供三个按钮:“编辑备注”(蓝色)、“已掌握”(绿色)、“删除”(红色)。三个操作对应三种不同的后续处理路径,颜色语义清晰。

9.4 编辑备注弹窗

@Builder editNoteModal() {
  Column() {
    this.modalOverlay(() => { this.showEditModal = false })
    Column() {
      ...
      Text('题目:' + (this.selectedWrong?.title ?? '')).fontSize(12).fontColor('#555555')
        .margin({ top: 14, left: 20 })
      Text('备注内容:').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
      TextArea({ placeholder: this.selectedWrong?.note ?? '编辑你的备注...' })
        .placeholderColor('#BBBBBB').fontSize(12).width('90%').height(80)
        .backgroundColor('#FFF8E1').borderRadius(8)
        .margin({ top: 6 })
        .onChange((v: string) => { this.editNote = v })
      ...
    }
    ...
  }
  ...
}

编辑备注弹窗与收藏弹窗类似,核心是一个 TextArea 输入框。不同的是,placeholder 直接使用了当前备注内容,让用户在编辑时能看到原有内容作为参考。

9.5 错题本 build 方法

build() {
  Stack() {
    Column() {
      Row() {
        Text('❌ 错题本').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#E65100')
        Column().layoutWeight(1)
        Text('共20题').fontSize(11).fontColor('#888888').margin({ right: 8 })
      }
      .width('100%').padding({ left: 16, right: 16, top: 14, bottom: 8 })

      Row() {
        Column() {
          Text('20').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#E53935')
          Text('总错题').fontSize(10).fontColor('#888888')
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        Column() {
          Text('8').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FB8C00')
          Text('已掌握').fontSize(10).fontColor('#888888')
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        Column() {
          Text('12').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#E65100')
          Text('待复习').fontSize(10).fontColor('#888888')
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
      }
      .width('100%').backgroundColor('#FFFFFF')
      .padding({ top: 12, bottom: 12 })

      Scroll() {
        Row() {
          ForEach(WRONG_CATEGORY_LIST, (cat: string) => {
            if (this.selectedCategory === cat) {
              Text(cat).fontSize(11).fontColor('#FFFFFF').backgroundColor('#E65100')
                .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(14)
                .margin({ left: 3, right: 3 })
            } else {
              Text(cat).fontSize(11).fontColor('#E65100').backgroundColor('#FFF3E0')
                .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(14)
                .margin({ left: 3, right: 3 })
                .onClick(() => { this.selectedCategory = cat })
            }
          })
        }
        .padding({ left: 8, right: 8 })
      }
      .scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(38)

      Scroll() {
        Column() {
          this.wrongItemBuilder(mockWrongQuestions[0])
          ... (渲染 20 条错题)
          this.wrongItemBuilder(mockWrongQuestions[19])
        }
        .padding({ bottom: 20 })
      }
      .layoutWeight(1).scrollBar(BarState.Off)
    }
    .width('100%').height('100%')

    if (this.showDetailModal) { this.wrongDetailModal() }
    if (this.showEditModal) { this.editNoteModal() }
    if (this.showDeleteModal) { this.deleteWrongModal() }
  }
  .width('100%').height('100%')
}

错题本页的 build 方法有几个值得关注的细节:

统计面板:三列数据——总错题(20,红色)、已掌握(8,橙色)、待复习(12,橙色)。这三个数字形成了一个"漏斗":总错题减去已掌握等于待复习,逻辑自洽。

分类筛选栏:使用 Scroll + Row 实现横向滚动的标签栏。ForEach 遍历 WRONG_CATEGORY_LIST,对每个分类渲染一个标签。选中的标签是橙底白字,未选中的是浅橙底橙字。这种反色切换的交互模式在移动端非常常见。scrollable(ScrollDirection.Horizontal) 让标签栏支持水平滑动,即使标签数量超出屏幕宽度也能正常使用。


十、学车进度页:全流程进度追踪

10.1 状态管理

@Component
struct DriveProgressContent {
  @State showAddModal: boolean = false
  @State newDate: string = '2026-08-10'
  @State newTime: string = '09:00'
  @State newCoach: string = '王教练'
  @State newProject: string = '倒车入库'
  @State newDuration: string = '60'
  ...
}

学车进度页除了弹窗显隐状态外,还管理着五个表单字段的状态。这些字段的初始值就是新增记录弹窗中的默认值,方便用户快速录入。

10.2 进度卡片构建器

@Builder progressCardBuilder(p: SubjectProgress) {
  Column() {
    Row() {
      Column() {
        Text(p.icon).fontSize(28)
        Text(p.name).fontSize(12).fontColor('#212121').fontWeight(FontWeight.Bold).margin({ top: 4 })
      }.width(80).alignItems(HorizontalAlign.Center)
      Column() {
        Progress({ value: p.progress, total: 100, type: ProgressType.Ring })
          .width(56).height(56)
          .color(p.color)
          .backgroundColor('#FFF3E0')
          .style({ strokeWidth: 6 })
        Text(p.progress.toString() + '%').fontSize(10).fontColor('#888888').margin({ top: 2 })
      }.width(70).alignItems(HorizontalAlign.Center)
      Column() {
        Text('通过率').fontSize(10).fontColor('#888888')
        Text(p.passRate.toString() + '%').fontSize(16).fontWeight(FontWeight.Bold).fontColor(p.color).margin({ top: 2 })
        Text('练习次数').fontSize(10).fontColor('#888888').margin({ top: 6 })
        Text(p.practiceCount.toString() + '次').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#212121').margin({ top: 2 })
      }.layoutWeight(1).alignItems(HorizontalAlign.Start)
      Column() {
        Text(p.status).fontSize(10)
          .fontColor(p.color)
          .backgroundColor(DRIVE_PHASE_CONFIG[p.name]?.bg ?? '#FFF3E0')
          .padding({ left: 6, right: 6, top: 3, bottom: 3 }).borderRadius(8)
      }
    }
    .width('100%')
  }
  .width('100%').padding(14).backgroundColor('#FFFFFF')
  .borderRadius(14).margin({ left: 12, right: 12, top: 6 })
}

progressCardBuilder 渲染单个科目的进度卡片,是信息密度最高的卡片之一。卡片分为四个区域:

左侧图标区(宽 80):科目图标(28 号字)和科目名称(12 号粗体)。

中间进度环区(宽 70):使用 Progress 组件的环形进度条(ProgressType.Ring),进度值为 p.progress,总值为 100。进度条颜色使用科目的主题色,背景为浅橙色,线宽 6 像素。下方显示百分比文字。环形进度条是展示"完成度"的最佳可视化形式之一——它既能传达具体数值,又能传达"还差多少"的感觉。

右侧数据区:展示通过率和练习次数两组数据。通过率用科目的主题色(大号粗体),练习次数用深灰色。

最右侧状态标签:显示"已通过"或"练习中",背景色从 DRIVE_PHASE_CONFIG 中获取。

10.3 练车记录构建器

@Builder driveRecordBuilder(r: DriveRecord) {
  Column() {
    Row() {
      Column() {
        Text(r.date.substring(5)).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#E65100')
        Text(r.time).fontSize(10).fontColor('#AAAAAA').margin({ top: 2 })
      }.width(56).alignItems(HorizontalAlign.Center)
      Column() {
        Text(r.project).fontSize(13).fontColor('#212121').fontWeight(FontWeight.Medium)
        Text('👨‍🏫 ' + r.coach).fontSize(10).fontColor('#888888').margin({ top: 3 })
        Text(r.note).fontSize(10).fontColor('#AAAAAA').margin({ top: 2 })
      }.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
      Column() {
        Text(r.score.toString()).fontSize(20).fontWeight(FontWeight.Bold)
          .fontColor(r.score >= 85 ? '#43A047' : (r.score >= 75 ? '#FB8C00' : '#E53935'))
        Text('评分').fontSize(9).fontColor('#888888')
      }.alignItems(HorizontalAlign.Center)
      Column() {
        Text(r.duration.toString() + 'min').fontSize(11).fontColor('#0277BD')
      }.alignItems(HorizontalAlign.Center).padding({ left: 8 })
    }
    .width('100%')
  }
  .width('100%').padding(12).backgroundColor('#FFFFFF')
  .borderRadius(10).margin({ left: 12, right: 12, top: 4 })
}

driveRecordBuilder 渲染单条练车记录。值得特别关注的是评分的颜色逻辑:

.fontColor(r.score >= 85 ? '#43A047' : (r.score >= 75 ? '#FB8C00' : '#E53935'))

这是一个嵌套的三元运算符,实现了三档颜色映射:85 分及以上为绿色(优秀)、75-84 分为橙色(良好)、75 分以下为红色(待提高)。这种动态着色让用户一眼就能感知每次练车的表现水平。

日期字段使用了 r.date.substring(5),从"2026-08-09"中截取"08-09",去掉了年份前缀,让日期显示更紧凑。

10.4 新增记录弹窗

@Builder addRecordModal() {
  Column() {
    this.modalOverlay(() => { this.showAddModal = false })
    Column() {
      Row() {
        Text('➕ 新增练车记录').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
        Column().layoutWeight(1)
        Text('✕').fontSize(18).fontColor('#999999')
          .onClick(() => { this.showAddModal = false })
      }
      .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
      Divider().color('#FFF3E0')
      Scroll() {
        Column() {
          Text('练习日期').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
          TextInput({ placeholder: '2026-08-10' })
            .placeholderColor('#BBBBBB').fontSize(13).width('90%')
            .backgroundColor('#FFF8E1').borderRadius(8)
            .margin({ top: 4 })
            .onChange((v: string) => { this.newDate = v })
          ...
          Text('教练').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
          Row() {
            Text('王教练').fontSize(12).fontColor(this.newCoach === '王教练' ? '#FFFFFF' : '#E65100')
              .backgroundColor(this.newCoach === '王教练' ? '#E65100' : '#FFF3E0')
              .padding({ left: 14, right: 14, top: 6, bottom: 6 }).borderRadius(14)
              .onClick(() => { this.newCoach = '王教练' })
            Text('李教练').fontSize(12).fontColor(this.newCoach === '李教练' ? '#FFFFFF' : '#E65100')
              .backgroundColor(this.newCoach === '李教练' ? '#E65100' : '#FFF3E0')
              .padding({ left: 14, right: 14, top: 6, bottom: 6 }).borderRadius(14).margin({ left: 8 })
              .onClick(() => { this.newCoach = '李教练' })
          }
          .margin({ top: 4, left: 16 })
          Text('练习项目').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
          Row() {
            Text('倒车入库').fontSize(11).fontColor(this.newProject === '倒车入库' ? '#FFFFFF' : '#0277BD')
              .backgroundColor(this.newProject === '倒车入库' ? '#0277BD' : '#E1F5FE')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12).margin({ left: 3, right: 3 })
              .onClick(() => { this.newProject = '倒车入库' })
            ...
          }
          ...
        }
      }
      ...
    }
    ...
  }
  ...
}

新增记录弹窗是一个完整的表单,包含日期、时间、教练、练习项目、时长五个字段。

教练选择使用了两个切换按钮,与开始考试弹窗中的科目选择逻辑一致。练习项目选择提供了三个选项(倒车入库、侧方停车、坡道定点),同样是切换按钮模式。日期、时间和时长使用 TextInput 文本输入框,onChange 回调将输入值同步到状态变量。

10.5 进度页 build 方法

build() {
  Stack() {
    Column() {
      Row() {
        Text('📊 学车进度').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#E65100')
        Column().layoutWeight(1)
        Text('+ 新记录').fontSize(12).fontColor('#FFFFFF')
          .backgroundColor('#E65100').borderRadius(14)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .onClick(() => { this.showAddModal = true })
      }
      .width('100%').padding({ left: 16, right: 16, top: 14, bottom: 10 })

      Scroll() {
        Column() {
          Text('科目进度').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#212121')
            .width('100%').padding({ left: 16, top: 6, bottom: 4 })
          this.progressCardBuilder(mockSubjectProgress[0])
          this.progressCardBuilder(mockSubjectProgress[1])
          this.progressCardBuilder(mockSubjectProgress[2])
          this.progressCardBuilder(mockSubjectProgress[3])

          Text('练车记录').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#212121')
            .width('100%').padding({ left: 16, top: 14, bottom: 4 })
          this.driveRecordBuilder(mockDriveRecords[0])
          ... (渲染 12 条记录)
          this.driveRecordBuilder(mockDriveRecords[11])
        }
        .padding({ bottom: 20 })
      }
      .layoutWeight(1).scrollBar(BarState.Off)
    }
    .width('100%').height('100%')

    if (this.showAddModal) { this.addRecordModal() }
  }
  .width('100%').height('100%')
}

进度页的 build 方法将两个不同的内容区块放在同一个可滚动列表中:上方是四个科目进度卡片,下方是十二条件练车记录。两个区块之间用"科目进度"和"练车记录"的小标题分隔。这种"分区块的滚动列表"设计让用户能够在一个页面内纵览全部进度信息,无需切换标签。


十一、个人中心页:学习数据全景

11.1 数据成员

@Component
struct DriveProfileContent {
  monthlyData: number[] = [12, 18, 25, 30, 22, 35, 40, 28]
  monthlyLabels: string[] = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月']
  maxMonthly: number = 40

  @Builder statCard(icon: string, label: string, value: string, color: string) {
    Column() {
      Text(icon).fontSize(22)
      Text(value).fontSize(18).fontWeight(FontWeight.Bold).fontColor(color).margin({ top: 4 })
      Text(label).fontSize(10).fontColor('#888888').margin({ top: 2 })
    }
    .layoutWeight(1).alignItems(HorizontalAlign.Center)
    .backgroundColor('#FFFFFF').borderRadius(12)
    .padding({ top: 14, bottom: 14 })
  }
  ...
}

个人中心页没有使用 @State 变量(因为它是一个纯展示页面,没有交互状态需要管理),而是定义了三个普通成员变量用于柱状图渲染。

monthlyData 是月度练习次数的数据数组,monthlyLabels 是对应的月份标签,maxMonthly 是数据中的最大值(40),用于计算每根柱子的高度比例。

statCard 构建器是一个通用的统计卡片,接收图标、标签、数值和颜色四个参数,渲染一个"图标 + 大数字 + 小标签"的卡片。这种参数化的构建器让统计卡片的复用变得非常简单。

11.2 菜单行构建器

@Builder menuRowBuilder(item: ProfileMenuItem) {
  Row() {
    Text(item.icon).fontSize(18)
    Text(item.label).fontSize(13).fontColor('#212121').layoutWeight(1).margin({ left: 10 })
    Text(item.value).fontSize(11).fontColor('#AAAAAA')
    Text('>').fontSize(12).fontColor('#CCCCCC').margin({ left: 6 })
  }
  .width('100%').padding({ top: 12, bottom: 12, left: 4 })
}

menuRowBuilder 渲染个人中心的一个菜单项。左侧图标,中间标签(占据剩余空间),右侧值和箭头。这种"图标 + 标题 + 值 + 箭头"的行布局是设置页面的标准范式。

11.3 个人中心 build 方法

build() {
  Column() {
    Scroll() {
      Column() {
        // 用户信息卡
        Column() {
          Row() {
            Column() {
              Text('🧑‍🎓').fontSize(40)
            }.width(64).height(64).backgroundColor('#FFF3E0').borderRadius(32)
            .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
            Column() {
              Text('李学员').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
              Text('科目二练习中 · 上海').fontSize(11).fontColor('#888888').margin({ top: 3 })
              Row() {
                Text('📖 科目一已通过').fontSize(9).fontColor('#43A047')
                  .backgroundColor('#E8F5E9').padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
                Text('🚗 科目二进行中').fontSize(9).fontColor('#0277BD')
                  .backgroundColor('#E1F5FE').padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8).margin({ left: 6 })
              }
              .margin({ top: 4 })
            }.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 14 })
          }
          .width('100%').padding({ left: 16, right: 16, top: 16, bottom: 16 })
        }
        .width('100%').backgroundColor('#FFFFFF').margin({ left: 12, right: 12, top: 10 })
        .borderRadius(14)
        ...
      }
    }
    .layoutWeight(1).scrollBar(BarState.Off)
  }
  .width('100%').height('100%')
}

个人中心页是最长的页面,内容从上到下依次为:

用户信息卡:左侧是一个 64x64 的圆形头像(使用 emoji),右侧是用户名、状态描述和两个状态标签。状态标签分别显示科目一的通过状态(绿色)和科目二的进行状态(蓝色)。

驾校信息卡:以"标题 + 键值对列表"的形式展示驾校名称、所属教练、报名日期、培训类型四项信息。每行都是"标签 + 占位空隙 + 值"的布局。

学习统计卡片:四张统计卡片以 2x2 网格排列,分别展示总练习题数(320)、模考通过(9 次)、总学时(45h)、正确率(88%)。每张卡片使用不同颜色的数值。

月度柱状图:这是一个手写的简易柱状图。

Row() {
  ForEach([0, 1, 2, 3, 4, 5, 6, 7], (d: number) => {
    Column() {
      Text(this.monthlyData[d].toString()).fontSize(9)
        .fontColor('#E65100').margin({ bottom: 3 })
      Column()
        .width(22)
        .height((this.monthlyData[d] / this.maxMonthly * 90).toFixed(0) + 'vp')
        .backgroundColor(MONTHLY_PRACTICE_DATA[d].color)
        .borderRadius({ topLeft: 4, topRight: 4 })
      Text(this.monthlyLabels[d]).fontSize(8).fontColor('#999999').margin({ top: 3 })
    }
    .layoutWeight(1).alignItems(HorizontalAlign.Center)
  })
}

柱状图的实现思路是:遍历索引 0-7,为每个月份渲染一个 Column,内含数值文字、柱子(一个有高度的 Column)、月份标签。柱子高度通过 (this.monthlyData[d] / this.maxMonthly * 90).toFixed(0) + 'vp' 计算——将数据值归一化到 0-90 的像素范围。每个柱子使用 MONTHLY_PRACTICE_DATA 中对应的渐变色,顶部圆角,形成美观的柱状效果。

快捷操作菜单:六项菜单以列表形式排列,每项之间用浅色分割线分隔。

版本信息:页面底部显示"v3.2 · 驾考助手 · 2026"的版本号,字号小、颜色浅,不喧宾夺主。


十二、关键技术特性对比

特性维度 题库练习页 模拟考试页 错题本页 学车进度页 个人中心页
核心功能 题目浏览与收藏 模拟考试与成绩查看 错题管理与复习 进度追踪与练车记录 个人信息与数据统计
状态变量数 6 个 5 个 6 个 6 个 3 个(非响应式)
弹窗数量 2 个(答题详情、收藏) 3 个(开始考试、详情、删除) 3 个(详情、编辑、删除) 1 个(新增记录) 0 个
列表渲染方式 逐个调用构建器 逐个调用构建器 逐个调用构建器 逐个调用构建器 ForEach + 逐个调用
可滚动区域 题目列表(垂直) 考试记录列表(垂直) 分类标签(水平)+ 错题列表(垂直) 进度与记录列表(垂直) 全页面(垂直)
交互复杂度
数据可视化 四列统计面板 三列统计面板 环形进度条 柱状图 + 统计卡片
表单输入 TextArea(备注) TextArea(备注编辑) TextInput x3 + 切换按钮
色彩主题 橙色为主 橙色 + 绿/红状态 橙色 + 红/绿对比 多色(四科各异) 橙色为主
条件渲染 选项空值判断、弹窗显隐 弹窗显隐、状态色切换 备注空值判断、分类选中切换 弹窗显隐、教练/项目选中
核心设计模式 配置表驱动 + 构建器复用 统计面板 + 多弹窗联动 标签筛选 + 对比展示 进度可视化 + 表单录入 数据统计 + 手写图表

安装DevEco Studio程序

在这里插入图片描述
选择目标安装目录:

在这里插入图片描述
设置环境变量,但是需要重启一下:

在这里插入图片描述
新建一个空白模板:

在这里插入图片描述
设置API为24的模板项目:
在这里插入图片描述
初始化项目,自动下载相关依赖:

在这里插入图片描述


完整代码:

// ============ 类型定义 ============
interface SubjectMeta {
  label: string
  icon: string
  color: string
  bg: string
  desc: string
  total: number
}

interface DifficultyMeta {
  label: string
  color: string
  bg: string
}

interface QuestionTypeMeta {
  label: string
  color: string
  bg: string
}

interface ExamStatusMeta {
  label: string
  color: string
  bg: string
}

interface DrivePhaseMeta {
  label: string
  icon: string
  color: string
  bg: string
}

interface BarChartData {
  label: string
  value: number
  color: string
}

interface ProfileMenuItem {
  icon: string
  label: string
  value: string
}

// ============ 题目数据模型 ============
@Observed
export class QuestionItem {
  id: number = 0
  subject: string = ''
  type: string = ''
  title: string = ''
  optionA: string = ''
  optionB: string = ''
  optionC: string = ''
  optionD: string = ''
  answer: string = ''
  analysis: string = ''
  difficulty: string = '简单'
  isFavorite: boolean = false
  constructor(id: number, subject: string, type: string, title: string, optionA: string, optionB: string, optionC: string, optionD: string, answer: string, analysis: string, difficulty: string, isFavorite: boolean) {
    this.id = id; this.subject = subject; this.type = type; this.title = title
    this.optionA = optionA; this.optionB = optionB; this.optionC = optionC; this.optionD = optionD
    this.answer = answer; this.analysis = analysis; this.difficulty = difficulty; this.isFavorite = isFavorite
  }
}

// ============ 模拟考试记录模型 ============
@Observed
export class ExamRecord {
  id: number = 0
  subject: string = ''
  score: number = 0
  correctCount: number = 0
  wrongCount: number = 0
  totalCount: number = 0
  duration: string = ''
  date: string = ''
  passed: boolean = false
  constructor(id: number, subject: string, score: number, correctCount: number, wrongCount: number, totalCount: number, duration: string, date: string, passed: boolean) {
    this.id = id; this.subject = subject; this.score = score
    this.correctCount = correctCount; this.wrongCount = wrongCount; this.totalCount = totalCount
    this.duration = duration; this.date = date; this.passed = passed
  }
}

// ============ 错题模型 ============
@Observed
export class WrongQuestion {
  id: number = 0
  subject: string = ''
  title: string = ''
  correctAnswer: string = ''
  wrongAnswer: string = ''
  category: string = ''
  note: string = ''
  addedDate: string = ''
  reviewCount: number = 0
  constructor(id: number, subject: string, title: string, correctAnswer: string, wrongAnswer: string, category: string, note: string, addedDate: string, reviewCount: number) {
    this.id = id; this.subject = subject; this.title = title
    this.correctAnswer = correctAnswer; this.wrongAnswer = wrongAnswer
    this.category = category; this.note = note; this.addedDate = addedDate; this.reviewCount = reviewCount
  }
}

// ============ 学车进度模型 ============
@Observed
export class SubjectProgress {
  id: number = 0
  name: string = ''
  icon: string = ''
  progress: number = 0
  passRate: number = 0
  practiceCount: number = 0
  status: string = ''
  color: string = ''
  constructor(id: number, name: string, icon: string, progress: number, passRate: number, practiceCount: number, status: string, color: string) {
    this.id = id; this.name = name; this.icon = icon
    this.progress = progress; this.passRate = passRate; this.practiceCount = practiceCount
    this.status = status; this.color = color
  }
}

// ============ 练车记录模型 ============
@Observed
export class DriveRecord {
  id: number = 0
  date: string = ''
  time: string = ''
  coach: string = ''
  project: string = ''
  duration: number = 0
  score: number = 0
  note: string = ''
  constructor(id: number, date: string, time: string, coach: string, project: string, duration: number, score: number, note: string) {
    this.id = id; this.date = date; this.time = time; this.coach = coach
    this.project = project; this.duration = duration; this.score = score; this.note = note
  }
}

// ============ 静态配置 ============
const SUBJECT_CONFIG: Record<string, SubjectMeta> = {
  '科目一': { label: '科目一', icon: '📖', color: '#E65100', bg: '#FFF3E0', desc: '理论考试·1280题', total: 1280 },
  '科目四': { label: '科目四', icon: '📘', color: '#0277BD', bg: '#E1F5FE', desc: '安全文明·1125题', total: 1125 }
}

const DIFFICULTY_CONFIG: Record<string, DifficultyMeta> = {
  '简单': { label: '简单', color: '#43A047', bg: '#E8F5E9' },
  '中等': { label: '中等', color: '#FB8C00', bg: '#FFF3E0' },
  '困难': { label: '困难', color: '#E53935', bg: '#FFEBEE' }
}

const QUESTION_TYPE_CONFIG: Record<string, QuestionTypeMeta> = {
  '单选': { label: '单选题', color: '#0277BD', bg: '#E1F5FE' },
  '多选': { label: '多选题', color: '#7B1FA2', bg: '#F3E5F5' },
  '判断': { label: '判断题', color: '#E65100', bg: '#FFF3E0' }
}

const EXAM_STATUS_CONFIG: Record<string, ExamStatusMeta> = {
  'passed': { label: '通过', color: '#43A047', bg: '#E8F5E9' },
  'failed': { label: '未通过', color: '#E53935', bg: '#FFEBEE' }
}

const DRIVE_PHASE_CONFIG: Record<string, DrivePhaseMeta> = {
  '科目一': { label: '科目一', icon: '📖', color: '#E65100', bg: '#FFF3E0' },
  '科目二': { label: '科目二', icon: '🚗', color: '#0277BD', bg: '#E1F5FE' },
  '科目三': { label: '科目三', icon: '🛣️', color: '#00838F', bg: '#E0F7FA' },
  '科目四': { label: '科目四', icon: '📘', color: '#6A1B9A', bg: '#F3E5F5' }
}

const WRONG_CATEGORY_LIST: string[] = ['全部', '交通标志', '安全驾驶', '违章处罚', '应急处理', '车辆操作']

const MONTHLY_PRACTICE_DATA: BarChartData[] = [
  { label: '1月', value: 12, color: '#FFB74D' },
  { label: '2月', value: 18, color: '#FFB74D' },
  { label: '3月', value: 25, color: '#FFA726' },
  { label: '4月', value: 30, color: '#FF9800' },
  { label: '5月', value: 22, color: '#FB8C00' },
  { label: '6月', value: 35, color: '#F57C00' },
  { label: '7月', value: 40, color: '#EF6C00' },
  { label: '8月', value: 28, color: '#E65100' }
]

const PROFILE_MENU: ProfileMenuItem[] = [
  { icon: '📅', label: '考试预约', value: '科目三预约中' },
  { icon: '📋', label: '学习计划', value: '每日30题' },
  { icon: '🔔', label: '考试提醒', value: '已开启' },
  { icon: '📊', label: '学习报告', value: '本周进步15%' },
  { icon: '🎁', label: '积分商城', value: '2680积分' },
  { icon: '⚙️', label: '设置', value: '' }
]

// ============ 全局写死数据:24道题目 ============
const mockQuestions: QuestionItem[] = [
  new QuestionItem(1, '科目一', '判断', '驾驶机动车在高速公路上倒车、逆行、穿越中央分隔带掉头的一次记6分。', '正确', '错误', '', '', '错误', '在高速公路上倒车、逆行、穿越中央分隔带掉头的一次记12分,不是6分。', '中等', false),
  new QuestionItem(2, '科目一', '单选', '道路最左侧白色虚线区域是何含义?', '快速车道', '慢速车道', '应急车道', '专用车道', 'D', '白色虚线区域表示专用车道,如公交专用道、多乘员车道等。', '中等', true),
  new QuestionItem(3, '科目一', '单选', '这个标志是何含义?圆形红色边框,白色底,中间黑色数字40', '限制速度40公里/小时', '最低速度40公里/小时', '解除限制速度40', '建议速度40公里/小时', 'A', '红色圆圈内有限速数字,表示限制最高行驶速度为该数值。', '简单', false),
  new QuestionItem(4, '科目一', '判断', '机动车驾驶证有效期分为6年、10年和长期。', '正确', '错误', '', '', '正确', '机动车驾驶证有效期分为6年、10年和长期三种。', '简单', false),
  new QuestionItem(5, '科目一', '单选', '雾天行车时,应当开启哪个灯光?', '远光灯', '近光灯', '雾灯和危险报警闪光灯', '示廓灯', 'C', '雾天行车应开启雾灯和危险报警闪光灯,提高能见度并警示其他车辆。', '简单', true),
  new QuestionItem(6, '科目一', '单选', '驾驶机动车在没有中心线的道路上遇相对方向来车时,应当怎么做?', '紧靠道路右侧行驶', '紧靠道路左侧行驶', '保持原速行驶', '加速通过', 'A', '在没有中心线的道路上遇相对方向来车时,应当紧靠道路右侧行驶。', '中等', false),
  new QuestionItem(7, '科目一', '判断', '行车中前方遇自行车影响通行时,可鸣喇叭提示,加速绕行。', '正确', '错误', '', '', '错误', '遇自行车影响通行时应当减速让行,不可加速绕行。', '简单', false),
  new QuestionItem(8, '科目一', '单选', '这个路面标记是什么标线?菱形标记', '减速让行线', '停车让行线', '人行横道预告标线', '禁止停车线', 'C', '路面上的菱形标记是人行横道预告标线,提示前方有人行横道,需减速慢行。', '困难', false),
  new QuestionItem(9, '科目一', '多选', '驾驶机动车在雨天行驶,以下做法正确的是什么?', '减速慢行', '保持安全车距', '开启雾灯', '随时做好制动准备', 'A', '雨天路滑应当减速慢行,保持安全车距,随时做好制动准备。雾灯在雨天非必要不开。', '中等', true),
  new QuestionItem(10, '科目一', '单选', '在同向3车道高速公路上行车,最右侧车道的最低车速是多少?', '60公里/小时', '80公里/小时', '90公里/小时', '100公里/小时', 'A', '最右侧车道最低车速为60公里/小时。', '中等', false),
  new QuestionItem(11, '科目四', '判断', '在冰雪路面上行车,必须降低车速、尽量利用发动机制动。', '正确', '错误', '', '', '正确', '冰雪路面附着系数低,应降低车速并尽量利用发动机制动,避免紧急制动。', '简单', false),
  new QuestionItem(12, '科目四', '单选', '夜间会车应当在距相对方向来车多少米改用近光灯?', '50米', '100米', '150米', '200米', 'C', '夜间会车应当在距相对方向来车150米以外改用近光灯。', '中等', false),
  new QuestionItem(13, '科目四', '单选', '抢救骨折伤员时首先应当注意什么?', '立即搬运', '固定骨折部位', '止血包扎', '保持呼吸道通畅', 'D', '抢救骨折伤员时首先应保持呼吸道通畅,再进行固定和止血。', '困难', true),
  new QuestionItem(14, '科目四', '判断', '搬运昏迷失去知觉的伤员要采取仰卧位。', '正确', '错误', '', '', '错误', '昏迷失去知觉的伤员应采取侧卧位,防止呕吐物窒息。', '中等', false),
  new QuestionItem(15, '科目四', '单选', '雨天机动车在高速公路行驶发生"水滑"现象时,应当怎么做?', '急踏制动踏板减速', '缓抬加速踏板让车速自然降低', '迅速转向调整', '紧急停车', 'B', '发生水滑时应缓抬加速踏板让车速自然降低,不可急刹车或急转向。', '困难', true),
  new QuestionItem(16, '科目四', '多选', '驾驶汽车在山区道路转弯时,应注意什么?', '减速靠右行驶', '提前鸣喇叭', '禁止超车', '注意对向盲区来车', 'A', '山区道路转弯时应减速靠右、提前鸣喇叭、禁止超车并注意对向盲区来车。', '中等', false),
  new QuestionItem(17, '科目四', '判断', '在山区道路行驶时,驾驶人要严格控制车速,特别是在陡坡和连续弯道。', '正确', '错误', '', '', '正确', '山区道路坡陡弯急,必须严格控制车速确保安全。', '简单', false),
  new QuestionItem(18, '科目四', '单选', '行车中发动机突然熄火后,应当怎么做?', '紧急制动停车', '缓慢减速,将车停到路边检查', '继续行驶', '加速到最近加油站', 'B', '发动机熄火后应缓慢减速,将车停到路边检查原因。', '中等', false),
  new QuestionItem(19, '科目四', '判断', '驾驶人发现轮胎漏气,将机动车驶离主车道时,不要采用紧急制动。', '正确', '错误', '', '', '正确', '轮胎漏气后紧急制动可能导致车辆失控侧翻,应缓慢减速靠边。', '简单', true),
  new QuestionItem(20, '科目一', '单选', '机动车在道路上发生故障,需要停车排除时,驾驶人应当怎么办?', '开启危险报警闪光灯', '在来车方向设置警告标志', '将车移至不妨碍交通的地方', '以上都对', 'D', '发生故障需要停车排除时,应开启危险报警闪光灯、设置警告标志并移车至安全位置。', '简单', false),
  new QuestionItem(21, '科目一', '判断', '驾驶机动车超车后立即开启右转向灯驶回原车道。', '正确', '错误', '', '', '错误', '超车后应当与被超车辆保持安全距离后,再开启右转向灯驶回原车道。', '中等', false),
  new QuestionItem(22, '科目四', '单选', '驾驶人在行车中经过积水路面时,应怎样做?', '保持原速通过', '减速慢行通过', '加速通过', '停车观察', 'B', '经过积水路面应减速慢行,避免溅水影响行人及防止车辆失控。', '简单', true),
  new QuestionItem(23, '科目一', '单选', '前方路口这种信号灯亮表示什么含义?红灯亮', '禁止通行', '允许通行', '警示通行', '准备通行', 'A', '红灯亮表示禁止通行,车辆应在停止线外等待。', '简单', false),
  new QuestionItem(24, '科目四', '判断', '在泥泞路段行车要牢牢握住转向盘,加速通过。', '正确', '错误', '', '', '错误', '泥泞路段应牢牢握住转向盘但应减速通过,不可加速。', '中等', false)
]

// ============ 12条模拟考试记录 ============
const mockExamRecords: ExamRecord[] = [
  new ExamRecord(1, '科目一', 95, 95, 5, 100, '32分钟', '2026-08-08', true),
  new ExamRecord(2, '科目一', 88, 88, 12, 100, '35分钟', '2026-08-06', true),
  new ExamRecord(3, '科目一', 76, 76, 24, 100, '40分钟', '2026-08-04', false),
  new ExamRecord(4, '科目四', 92, 92, 8, 100, '28分钟', '2026-08-03', true),
  new ExamRecord(5, '科目一', 90, 90, 10, 100, '30分钟', '2026-08-01', true),
  new ExamRecord(6, '科目四', 85, 85, 15, 100, '33分钟', '2026-07-30', true),
  new ExamRecord(7, '科目一', 72, 72, 28, 100, '42分钟', '2026-07-28', false),
  new ExamRecord(8, '科目四', 96, 96, 4, 100, '25分钟', '2026-07-26', true),
  new ExamRecord(9, '科目一', 82, 82, 18, 100, '38分钟', '2026-07-24', true),
  new ExamRecord(10, '科目四', 78, 78, 22, 100, '36分钟', '2026-07-22', false),
  new ExamRecord(11, '科目一', 94, 94, 6, 100, '31分钟', '2026-07-20', true),
  new ExamRecord(12, '科目四', 89, 89, 11, 100, '29分钟', '2026-07-18', true)
]

// ============ 20条错题 ============
const mockWrongQuestions: WrongQuestion[] = [
  new WrongQuestion(1, '科目一', '高速公路上倒车、逆行记几分?', '12分', '6分', '违章处罚', '容易混淆6分和12分的情况', '2026-08-08', 2),
  new WrongQuestion(2, '科目一', '白色虚线区域表示什么?', '专用车道', '快速车道', '交通标志', '', '2026-08-06', 1),
  new WrongQuestion(3, '科目一', '限速标志中红色圆圈数字40表示?', '限制最高速度40', '最低速度40', '交通标志', '红色=限制最高', '2026-08-05', 3),
  new WrongQuestion(4, '科目一', '雾天行车应开启什么灯?', '雾灯和危险报警闪光灯', '远光灯', '安全驾驶', '远光灯在雾天会造成漫反射', '2026-08-04', 1),
  new WrongQuestion(5, '科目一', '无中心线道路遇来车怎么办?', '紧靠右侧行驶', '保持原速', '安全驾驶', '', '2026-08-03', 2),
  new WrongQuestion(6, '科目一', '菱形路面标记是什么标线?', '人行横道预告标线', '减速让行线', '交通标志', '菱形=人行横道预告', '2026-08-02', 1),
  new WrongQuestion(7, '科目一', '雨天行车正确做法?', '减速慢行保持车距', '开启雾灯', '安全驾驶', '雨天不需要开雾灯', '2026-08-01', 2),
  new WrongQuestion(8, '科目一', '3车道高速最右最低车速?', '60公里/小时', '80公里/小时', '安全驾驶', '', '2026-07-30', 1),
  new WrongQuestion(9, '科目一', '驾驶证有效期分为?', '6年、10年、长期', '5年、10年、长期', '车辆操作', '', '2026-07-28', 0),
  new WrongQuestion(10, '科目一', '前方红灯亮表示?', '禁止通行', '警示通行', '交通标志', '', '2026-07-26', 3),
  new WrongQuestion(11, '科目一', '超车后如何驶回原车道?', '保持安全距离后开右转向灯', '立即开右转向灯', '安全驾驶', '需保持安全距离', '2026-07-25', 1),
  new WrongQuestion(12, '科目一', '故障停车应该怎么做?', '开危险报警灯+设警告标志+移车', '仅开危险报警灯', '应急处理', '三步缺一不可', '2026-07-24', 2),
  new WrongQuestion(13, '科目四', '夜间会车多远改近光?', '150米外', '100米外', '安全驾驶', '', '2026-07-22', 1),
  new WrongQuestion(14, '科目四', '抢救骨折伤员首先注意?', '保持呼吸道通畅', '固定骨折部位', '应急处理', '先保命再处理伤情', '2026-07-20', 2),
  new WrongQuestion(15, '科目四', '昏迷伤员采取什么卧位?', '侧卧位', '仰卧位', '应急处理', '防呕吐物窒息', '2026-07-18', 1),
  new WrongQuestion(16, '科目四', '水滑现象怎么处理?', '缓抬加速踏板', '急踏制动', '应急处理', '不可急刹', '2026-07-16', 0),
  new WrongQuestion(17, '科目四', '山区转弯注意什么?', '减速靠右+鸣喇叭+禁超车', '仅减速', '安全驾驶', '多选需全选', '2026-07-15', 2),
  new WrongQuestion(18, '科目四', '轮胎漏气怎么办?', '缓慢减速靠边', '紧急制动', '应急处理', '', '2026-07-14', 1),
  new WrongQuestion(19, '科目四', '积水路面怎么通过?', '减速慢行', '加速通过', '安全驾驶', '', '2026-07-12', 0),
  new WrongQuestion(20, '科目四', '发动机熄火后怎么办?', '缓慢减速停路边检查', '紧急制动', '应急处理', '', '2026-07-10', 1)
]

// ============ 4个科目进度 ============
const mockSubjectProgress: SubjectProgress[] = [
  new SubjectProgress(1, '科目一', '📖', 100, 95, 320, '已通过', '#43A047'),
  new SubjectProgress(2, '科目二', '🚗', 75, 80, 45, '练习中', '#0277BD'),
  new SubjectProgress(3, '科目三', '🛣️', 40, 70, 18, '练习中', '#FB8C00'),
  new SubjectProgress(4, '科目四', '📘', 60, 88, 150, '练习中', '#6A1B9A')
]

// ============ 12条练车记录 ============
const mockDriveRecords: DriveRecord[] = [
  new DriveRecord(1, '2026-08-09', '09:00', '王教练', '倒车入库', 60, 85, '入库角度有进步,继续保持'),
  new DriveRecord(2, '2026-08-07', '14:00', '王教练', '侧方停车', 60, 80, '还需注意后视镜观察时机'),
  new DriveRecord(3, '2026-08-05', '09:00', '李教练', '坡道定点', 45, 75, '定点位置不够精准'),
  new DriveRecord(4, '2026-08-03', '15:00', '王教练', '曲线行驶', 40, 90, '曲线控制有进步'),
  new DriveRecord(5, '2026-08-01', '10:00', '李教练', '直角转弯', 30, 88, '转弯时机把握较好'),
  new DriveRecord(6, '2026-07-30', '14:00', '王教练', '倒车入库', 60, 78, '方向盘回正时机需加强'),
  new DriveRecord(7, '2026-07-28', '09:00', '王教练', '科目三路考', 90, 82, '变道需多观察后方来车'),
  new DriveRecord(8, '2026-07-26', '15:00', '李教练', '侧方停车', 60, 85, '进步明显,基本掌握'),
  new DriveRecord(9, '2026-07-24', '10:00', '李教练', '坡道定点', 45, 72, '离合控制还需练习'),
  new DriveRecord(10, '2026-07-22', '14:00', '王教练', '倒车入库', 60, 82, '入库速度控制不错'),
  new DriveRecord(11, '2026-07-20', '09:00', '王教练', '科目三路考', 90, 79, '起步需更平稳'),
  new DriveRecord(12, '2026-07-18', '15:00', '李教练', '曲线行驶', 40, 87, '曲线方向盘控制良好')
]

// ============ 底部Tab枚举 ============
enum DriveExamTab {
  BANK = 0,
  EXAM = 1,
  WRONG = 2,
  PROGRESS = 3,
  PROFILE = 4
}

// ============ 入口页面 ============
@Entry
@Component
struct DriveExamApp {
  @State activeTab: DriveExamTab = DriveExamTab.BANK

  @Builder contentArea() {
    Column() {
      if (this.activeTab === DriveExamTab.BANK) {
        QuestionBankContent()
      } else if (this.activeTab === DriveExamTab.EXAM) {
        MockExamContent()
      } else if (this.activeTab === DriveExamTab.WRONG) {
        WrongBookContent()
      } else if (this.activeTab === DriveExamTab.PROGRESS) {
        DriveProgressContent()
      } else {
        DriveProfileContent()
      }
    }
    .layoutWeight(1)
  }

  @Builder bottomTabItem(icon: string, label: string, tab: DriveExamTab) {
    Column() {
      Text(icon).fontSize(20).opacity(this.activeTab === tab ? 1.0 : 0.4)
      Text(label).fontSize(9)
        .fontColor(this.activeTab === tab ? '#E65100' : '#999999')
        .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
        .margin({ top: 1 })
      if (this.activeTab === tab) {
        Column().width(18).height(3)
          .backgroundColor('#E65100').borderRadius(2).margin({ top: 2 })
      }
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 5, bottom: 5 })
    .onClick(() => { this.activeTab = tab })
  }

  build() {
    Column() {
      this.contentArea()
      Row() {
        this.bottomTabItem('📚', '题库', DriveExamTab.BANK)
        this.bottomTabItem('📝', '模考', DriveExamTab.EXAM)
        this.bottomTabItem('❌', '错题', DriveExamTab.WRONG)
        this.bottomTabItem('📊', '进度', DriveExamTab.PROGRESS)
        this.bottomTabItem('👤', '我的', DriveExamTab.PROFILE)
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .padding({ top: 4, bottom: 6 })
      .shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
    }
    .width('100%').height('100%')
    .backgroundColor('#FFF8E1')
  }
}


            Text('成绩分析').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#212121')
              .width('100%').margin({ top: 16, left: 20 })
            Column() {
              Text('本次考试' + (this.selectedExam?.passed ?? false ? '已通过' : '未通过') + ',正确率' + (this.selectedExam?.correctCount ?? 0).toString() + '%').fontSize(12).fontColor('#555555')
              Text(this.selectedExam?.passed ?? false ? '继续保持,争取正式考试一次通过!' : '继续加油,多练习薄弱环节').fontSize(11).fontColor('#888888').margin({ top: 4 })
            }
            .width('100%').margin({ top: 8, left: 20 })
          }
          .padding({ bottom: 16 })
        }
        .layoutWeight(1)
        Row() {
          Text('🗑️ 删除记录').fontSize(13).fontColor('#FFFFFF')
            .backgroundColor('#E53935').borderRadius(16)
            .padding({ left: 20, right: 20, top: 8, bottom: 8 })
            .onClick(() => { this.showDeleteModal = true })
          Text('🔄 再考一次').fontSize(13).fontColor('#FFFFFF')
            .backgroundColor('#E65100').borderRadius(16)
            .padding({ left: 20, right: 20, top: 8, bottom: 8 }).margin({ left: 8 })
            .onClick(() => { this.showDetailModal = false; this.showStartModal = true })
        }
        .width('100%').justifyContent(FlexAlign.Center)
        .padding({ left: 20, right: 20, top: 12, bottom: 14 })
      }
      .width('92%').height('70%').backgroundColor('#FFFFFF').borderRadius(16)
      .alignItems(HorizontalAlign.Center)
      .position({ x: '4%', y: '15%' })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  @Builder deleteModal() {
    Column() {
      this.modalOverlay(() => { this.showDeleteModal = false })
      Column() {
        Text('⚠️').fontSize(48).margin({ top: 24 })
        Text('确认删除此考试记录?').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
        Text('删除后不可恢复').fontSize(13).fontColor('#E53935').margin({ top: 4 })
        Row() {
          Text('取消').fontSize(14).fontColor('#888888')
            .backgroundColor('#F5F5F5').borderRadius(20)
            .padding({ left: 28, right: 28, top: 10, bottom: 10 })
            .onClick(() => { this.showDeleteModal = false })
          Text('确认删除').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#E53935').borderRadius(20)
            .padding({ left: 28, right: 28, top: 10, bottom: 10 }).margin({ left: 12 })
            .onClick(() => { this.showDeleteModal = false; this.showDetailModal = false })
        }
        .width('100%').justifyContent(FlexAlign.Center)
        .padding({ left: 20, right: 20, top: 20, bottom: 20 })
      }
      .width('80%').backgroundColor('#FFFFFF').borderRadius(16)
      .alignItems(HorizontalAlign.Center)
      .position({ x: '10%', y: '38%' })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  build() {
    Stack() {
      Column() {
        Row() {
          Text('📝 模拟考试').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#E65100')
          Column().layoutWeight(1)
          Text('📊').fontSize(18).margin({ right: 12 })
        }
        .width('100%').padding({ left: 16, right: 16, top: 14, bottom: 10 })

        Row() {
          Column() {
            Text('12').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#E65100')
            Text('总考试次数').fontSize(10).fontColor('#888888')
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column() {
            Text('9').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#43A047')
            Text('通过次数').fontSize(10).fontColor('#888888')
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column() {
            Text('75%').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#0277BD')
            Text('通过率').fontSize(10).fontColor('#888888')
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column() {
            Text('88').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FB8C00')
            Text('平均分').fontSize(10).fontColor('#888888')
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        }
        .width('100%').backgroundColor('#FFFFFF')
        .padding({ top: 14, bottom: 14 })

        Row() {
          Text('考试记录').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#212121')
          Column().layoutWeight(1)
          Text('+ 新模考').fontSize(12).fontColor('#FFFFFF')
            .backgroundColor('#E65100').borderRadius(14)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .onClick(() => { this.showStartModal = true })
        }
        .width('100%').padding({ left: 16, right: 16, top: 10, bottom: 6 })

        Scroll() {
          Column() {
            this.examRecordBuilder(mockExamRecords[0])
            this.examRecordBuilder(mockExamRecords[1])
            this.examRecordBuilder(mockExamRecords[2])
            this.examRecordBuilder(mockExamRecords[3])
            this.examRecordBuilder(mockExamRecords[4])
            this.examRecordBuilder(mockExamRecords[5])
            this.examRecordBuilder(mockExamRecords[6])
            this.examRecordBuilder(mockExamRecords[7])
            this.examRecordBuilder(mockExamRecords[8])
            this.examRecordBuilder(mockExamRecords[9])
            this.examRecordBuilder(mockExamRecords[10])
            this.examRecordBuilder(mockExamRecords[11])
          }
          .padding({ bottom: 20 })
        }
        .layoutWeight(1).scrollBar(BarState.Off)
      }
      .width('100%').height('100%')

      if (this.showStartModal) { this.startExamModal() }
      if (this.showDetailModal) { this.detailModal() }
      if (this.showDeleteModal) { this.deleteModal() }
    }
    .width('100%').height('100%')
  }
}

// ============ 错题本页 ============
@Component
struct WrongBookContent {
  @State selectedCategory: string = '全部'
  @State showDetailModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteModal: boolean = false
  @State selectedWrong: WrongQuestion | null = null
  @State editNote: string = ''

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.5)')
      .onClick(onClose)
  }

  @Builder wrongItemBuilder(w: WrongQuestion) {
    Column() {
      Row() {
        Text(SUBJECT_CONFIG[w.subject]?.icon ?? '📖').fontSize(14)
        Text(w.subject).fontSize(10)
          .fontColor(SUBJECT_CONFIG[w.subject]?.color ?? '#E65100')
          .backgroundColor(SUBJECT_CONFIG[w.subject]?.bg ?? '#FFF3E0')
          .padding({ left: 5, right: 5, top: 2, bottom: 2 }).borderRadius(6).margin({ left: 4 })
        Text(w.category).fontSize(10)
          .fontColor('#0277BD').backgroundColor('#E1F5FE')
          .padding({ left: 5, right: 5, top: 2, bottom: 2 }).borderRadius(6).margin({ left: 4 })
        Column().layoutWeight(1)
        Text('复习' + w.reviewCount.toString() + '次').fontSize(9).fontColor('#AAAAAA')
      }
      .width('100%')
      Text(w.title).fontSize(13).fontColor('#212121').fontWeight(FontWeight.Medium).margin({ top: 8 })
      Row() {
        Column() {
          Text('✅ ' + w.correctAnswer).fontSize(11).fontColor('#43A047').fontWeight(FontWeight.Bold)
        }.layoutWeight(1).alignItems(HorizontalAlign.Start)
        Column() {
          Text('❌ ' + w.wrongAnswer).fontSize(11).fontColor('#E53935')
        }.alignItems(HorizontalAlign.Start)
      }
      .width('100%').margin({ top: 6 })
      if (w.note !== '') {
        Text('📝 ' + w.note).fontSize(11).fontColor('#888888').margin({ top: 6 })
          .width('100%').backgroundColor('#FFF8E1').borderRadius(6)
          .padding({ left: 8, right: 8, top: 4, bottom: 4 })
      }
    }
    .width('100%').padding(12).backgroundColor('#FFFFFF')
    .borderRadius(12).margin({ left: 12, right: 12, top: 6 })
    .onClick(() => { this.selectedWrong = w; this.showDetailModal = true })
  }

  @Builder wrongDetailModal() {
    Column() {
      this.modalOverlay(() => { this.showDetailModal = false })
      Column() {
        Row() {
          Text('❌ 错题详情').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
          Column().layoutWeight(1)
          Text('✕').fontSize(18).fontColor('#999999')
            .onClick(() => { this.showDetailModal = false })
        }
        .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
        Divider().color('#FFF3E0')
        Scroll() {
          Column() {
            Row() {
              Text(SUBJECT_CONFIG[this.selectedWrong?.subject ?? '科目一']?.icon ?? '📖').fontSize(16)
              Text(this.selectedWrong?.subject ?? '').fontSize(12)
                .fontColor(SUBJECT_CONFIG[this.selectedWrong?.subject ?? '科目一']?.color ?? '#E65100')
                .backgroundColor(SUBJECT_CONFIG[this.selectedWrong?.subject ?? '科目一']?.bg ?? '#FFF3E0')
                .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).margin({ left: 6 })
              Text(this.selectedWrong?.category ?? '').fontSize(12)
                .fontColor('#0277BD').backgroundColor('#E1F5FE')
                .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).margin({ left: 6 })
              Column().layoutWeight(1)
              Text('复习' + (this.selectedWrong?.reviewCount ?? 0).toString() + '次').fontSize(10).fontColor('#AAAAAA')
            }
            .width('100%').margin({ top: 14 })
            Text(this.selectedWrong?.title ?? '').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#212121')
              .margin({ top: 12 }).width('100%')
            Row() {
              Text('✅ 正确答案').fontSize(12).fontColor('#43A047').fontWeight(FontWeight.Bold)
              Column().layoutWeight(1)
              Text(this.selectedWrong?.correctAnswer ?? '').fontSize(14).fontColor('#43A047').fontWeight(FontWeight.Bold)
            }
            .width('100%').backgroundColor('#E8F5E9').borderRadius(10)
            .padding({ left: 14, right: 14, top: 10, bottom: 10 }).margin({ top: 12 })
            Row() {
              Text('❌ 你的答案').fontSize(12).fontColor('#E53935').fontWeight(FontWeight.Bold)
              Column().layoutWeight(1)
              Text(this.selectedWrong?.wrongAnswer ?? '').fontSize(14).fontColor('#E53935').fontWeight(FontWeight.Bold)
            }
            .width('100%').backgroundColor('#FFEBEE').borderRadius(10)
            .padding({ left: 14, right: 14, top: 10, bottom: 10 }).margin({ top: 8 })
            Column() {
              Text('📝 我的备注').fontSize(12).fontColor('#E65100').fontWeight(FontWeight.Bold)
              Text(this.selectedWrong?.note ?? '暂无备注').fontSize(12).fontColor('#555555').margin({ top: 6 })
            }
            .width('100%').backgroundColor('#FFF8E1').borderRadius(10)
            .padding(12).margin({ top: 12 })
            Text('添加时间:' + (this.selectedWrong?.addedDate ?? '')).fontSize(10).fontColor('#AAAAAA')
              .margin({ top: 10 })
          }
          .padding({ left: 20, right: 20, bottom: 16 })
        }
        .layoutWeight(1)
        Row() {
          Text('✏️ 编辑备注').fontSize(13).fontColor('#FFFFFF')
            .backgroundColor('#0277BD').borderRadius(16)
            .padding({ left: 18, right: 18, top: 8, bottom: 8 })
            .onClick(() => { this.showEditModal = true })
          Text('🔄 已掌握').fontSize(13).fontColor('#FFFFFF')
            .backgroundColor('#43A047').borderRadius(16)
            .padding({ left: 18, right: 18, top: 8, bottom: 8 }).margin({ left: 8 })
          Text('🗑️ 删除').fontSize(13).fontColor('#FFFFFF')
            .backgroundColor('#E53935').borderRadius(16)
            .padding({ left: 18, right: 18, top: 8, bottom: 8 }).margin({ left: 8 })
            .onClick(() => { this.showDeleteModal = true })
        }
        .width('100%').justifyContent(FlexAlign.Center)
        .padding({ left: 20, right: 20, top: 12, bottom: 14 })
      }
      .width('92%').height('75%').backgroundColor('#FFFFFF').borderRadius(16)
      .alignItems(HorizontalAlign.Center)
      .position({ x: '4%', y: '12%' })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  @Builder editNoteModal() {
    Column() {
      this.modalOverlay(() => { this.showEditModal = false })
      Column() {
        Row() {
          Text('✏️ 编辑备注').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
          Column().layoutWeight(1)
          Text('✕').fontSize(18).fontColor('#999999')
            .onClick(() => { this.showEditModal = false })
        }
        .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
        Divider().color('#FFF3E0')
        Text('题目:' + (this.selectedWrong?.title ?? '')).fontSize(12).fontColor('#555555')
          .margin({ top: 14, left: 20 })
        Text('备注内容:').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
        TextArea({ placeholder: this.selectedWrong?.note ?? '编辑你的备注...' })
          .placeholderColor('#BBBBBB').fontSize(12).width('90%').height(80)
          .backgroundColor('#FFF8E1').borderRadius(8)
          .margin({ top: 6 })
          .onChange((v: string) => { this.editNote = v })
        Row() {
          Text('取消').fontSize(14).fontColor('#888888')
            .backgroundColor('#F5F5F5').borderRadius(20)
            .padding({ left: 28, right: 28, top: 10, bottom: 10 })
            .onClick(() => { this.showEditModal = false })
          Text('保存').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#0277BD').borderRadius(20)
            .padding({ left: 28, right: 28, top: 10, bottom: 10 }).margin({ left: 12 })
            .onClick(() => { this.showEditModal = false })
        }
        .width('100%').justifyContent(FlexAlign.Center)
        .padding({ top: 20, bottom: 20 })
      }
      .width('85%').backgroundColor('#FFFFFF').borderRadius(16)
      .alignItems(HorizontalAlign.Center)
      .position({ x: '7.5%', y: '30%' })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  @Builder deleteWrongModal() {
    Column() {
      this.modalOverlay(() => { this.showDeleteModal = false })
      Column() {
        Text('⚠️').fontSize(48).margin({ top: 24 })
        Text('确认删除此错题?').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
        Text('删除后不可恢复').fontSize(13).fontColor('#E53935').margin({ top: 4 })
        Row() {
          Text('取消').fontSize(14).fontColor('#888888')
            .backgroundColor('#F5F5F5').borderRadius(20)
            .padding({ left: 28, right: 28, top: 10, bottom: 10 })
            .onClick(() => { this.showDeleteModal = false })
          Text('确认删除').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#E53935').borderRadius(20)
            .padding({ left: 28, right: 28, top: 10, bottom: 10 }).margin({ left: 12 })
            .onClick(() => { this.showDeleteModal = false; this.showDetailModal = false })
        }
        .width('100%').justifyContent(FlexAlign.Center)
        .padding({ top: 20, bottom: 20 })
      }
      .width('80%').backgroundColor('#FFFFFF').borderRadius(16)
      .alignItems(HorizontalAlign.Center)
      .position({ x: '10%', y: '38%' })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  build() {
    Stack() {
      Column() {
        Row() {
          Text('❌ 错题本').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#E65100')
          Column().layoutWeight(1)
          Text('共20题').fontSize(11).fontColor('#888888').margin({ right: 8 })
        }
        .width('100%').padding({ left: 16, right: 16, top: 14, bottom: 8 })

        Row() {
          Column() {
            Text('20').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#E53935')
            Text('总错题').fontSize(10).fontColor('#888888')
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column() {
            Text('8').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FB8C00')
            Text('已掌握').fontSize(10).fontColor('#888888')
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column() {
            Text('12').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#E65100')
            Text('待复习').fontSize(10).fontColor('#888888')
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        }
        .width('100%').backgroundColor('#FFFFFF')
        .padding({ top: 12, bottom: 12 })

        Scroll() {
          Row() {
            ForEach(WRONG_CATEGORY_LIST, (cat: string) => {
              if (this.selectedCategory === cat) {
                Text(cat).fontSize(11).fontColor('#FFFFFF').backgroundColor('#E65100')
                  .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(14)
                  .margin({ left: 3, right: 3 })
              } else {
                Text(cat).fontSize(11).fontColor('#E65100').backgroundColor('#FFF3E0')
                  .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(14)
                  .margin({ left: 3, right: 3 })
                  .onClick(() => { this.selectedCategory = cat })
              }
            })
          }
          .padding({ left: 8, right: 8 })
        }
        .scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(38)

        Scroll() {
          Column() {
            this.wrongItemBuilder(mockWrongQuestions[0])
            this.wrongItemBuilder(mockWrongQuestions[1])
            this.wrongItemBuilder(mockWrongQuestions[2])
            this.wrongItemBuilder(mockWrongQuestions[3])
            this.wrongItemBuilder(mockWrongQuestions[4])
            this.wrongItemBuilder(mockWrongQuestions[5])
            this.wrongItemBuilder(mockWrongQuestions[6])
            this.wrongItemBuilder(mockWrongQuestions[7])
            this.wrongItemBuilder(mockWrongQuestions[8])
            this.wrongItemBuilder(mockWrongQuestions[9])
            this.wrongItemBuilder(mockWrongQuestions[10])
            this.wrongItemBuilder(mockWrongQuestions[11])
            this.wrongItemBuilder(mockWrongQuestions[12])
            this.wrongItemBuilder(mockWrongQuestions[13])
            this.wrongItemBuilder(mockWrongQuestions[14])
            this.wrongItemBuilder(mockWrongQuestions[15])
            this.wrongItemBuilder(mockWrongQuestions[16])
            this.wrongItemBuilder(mockWrongQuestions[17])
            this.wrongItemBuilder(mockWrongQuestions[18])
            this.wrongItemBuilder(mockWrongQuestions[19])
          }
          .padding({ bottom: 20 })
        }
        .layoutWeight(1).scrollBar(BarState.Off)
      }
      .width('100%').height('100%')

      if (this.showDetailModal) { this.wrongDetailModal() }
      if (this.showEditModal) { this.editNoteModal() }
      if (this.showDeleteModal) { this.deleteWrongModal() }
    }
    .width('100%').height('100%')
  }
}

// ============ 学车进度页 ============
@Component
struct DriveProgressContent {
  @State showAddModal: boolean = false
  @State newDate: string = '2026-08-10'
  @State newTime: string = '09:00'
  @State newCoach: string = '王教练'
  @State newProject: string = '倒车入库'
  @State newDuration: string = '60'

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.5)')
      .onClick(onClose)
  }

  @Builder progressCardBuilder(p: SubjectProgress) {
    Column() {
      Row() {
        Column() {
          Text(p.icon).fontSize(28)
          Text(p.name).fontSize(12).fontColor('#212121').fontWeight(FontWeight.Bold).margin({ top: 4 })
        }.width(80).alignItems(HorizontalAlign.Center)
        Column() {
          Progress({ value: p.progress, total: 100, type: ProgressType.Ring })
            .width(56).height(56)
            .color(p.color)
            .backgroundColor('#FFF3E0')
            .style({ strokeWidth: 6 })
          Text(p.progress.toString() + '%').fontSize(10).fontColor('#888888').margin({ top: 2 })
        }.width(70).alignItems(HorizontalAlign.Center)
        Column() {
          Text('通过率').fontSize(10).fontColor('#888888')
          Text(p.passRate.toString() + '%').fontSize(16).fontWeight(FontWeight.Bold).fontColor(p.color).margin({ top: 2 })
          Text('练习次数').fontSize(10).fontColor('#888888').margin({ top: 6 })
          Text(p.practiceCount.toString() + '次').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#212121').margin({ top: 2 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Start)
        Column() {
          Text(p.status).fontSize(10)
            .fontColor(p.color)
            .backgroundColor(DRIVE_PHASE_CONFIG[p.name]?.bg ?? '#FFF3E0')
            .padding({ left: 6, right: 6, top: 3, bottom: 3 }).borderRadius(8)
        }
      }
      .width('100%')
    }
    .width('100%').padding(14).backgroundColor('#FFFFFF')
    .borderRadius(14).margin({ left: 12, right: 12, top: 6 })
  }

  @Builder driveRecordBuilder(r: DriveRecord) {
    Column() {
      Row() {
        Column() {
          Text(r.date.substring(5)).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#E65100')
          Text(r.time).fontSize(10).fontColor('#AAAAAA').margin({ top: 2 })
        }.width(56).alignItems(HorizontalAlign.Center)
        Column() {
          Text(r.project).fontSize(13).fontColor('#212121').fontWeight(FontWeight.Medium)
          Text('👨‍🏫 ' + r.coach).fontSize(10).fontColor('#888888').margin({ top: 3 })
          Text(r.note).fontSize(10).fontColor('#AAAAAA').margin({ top: 2 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
        Column() {
          Text(r.score.toString()).fontSize(20).fontWeight(FontWeight.Bold)
            .fontColor(r.score >= 85 ? '#43A047' : (r.score >= 75 ? '#FB8C00' : '#E53935'))
          Text('评分').fontSize(9).fontColor('#888888')
        }.alignItems(HorizontalAlign.Center)
        Column() {
          Text(r.duration.toString() + 'min').fontSize(11).fontColor('#0277BD')
        }.alignItems(HorizontalAlign.Center).padding({ left: 8 })
      }
      .width('100%')
    }
    .width('100%').padding(12).backgroundColor('#FFFFFF')
    .borderRadius(10).margin({ left: 12, right: 12, top: 4 })
  }

  @Builder addRecordModal() {
    Column() {
      this.modalOverlay(() => { this.showAddModal = false })
      Column() {
        Row() {
          Text('➕ 新增练车记录').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
          Column().layoutWeight(1)
          Text('✕').fontSize(18).fontColor('#999999')
            .onClick(() => { this.showAddModal = false })
        }
        .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
        Divider().color('#FFF3E0')
        Scroll() {
          Column() {
            Text('练习日期').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
            TextInput({ placeholder: '2026-08-10' })
              .placeholderColor('#BBBBBB').fontSize(13).width('90%')
              .backgroundColor('#FFF8E1').borderRadius(8)
              .margin({ top: 4 })
              .onChange((v: string) => { this.newDate = v })
            Text('练习时间').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
            TextInput({ placeholder: '09:00' })
              .placeholderColor('#BBBBBB').fontSize(13).width('90%')
              .backgroundColor('#FFF8E1').borderRadius(8)
              .margin({ top: 4 })
              .onChange((v: string) => { this.newTime = v })
            Text('教练').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
            Row() {
              Text('王教练').fontSize(12).fontColor(this.newCoach === '王教练' ? '#FFFFFF' : '#E65100')
                .backgroundColor(this.newCoach === '王教练' ? '#E65100' : '#FFF3E0')
                .padding({ left: 14, right: 14, top: 6, bottom: 6 }).borderRadius(14)
                .onClick(() => { this.newCoach = '王教练' })
              Text('李教练').fontSize(12).fontColor(this.newCoach === '李教练' ? '#FFFFFF' : '#E65100')
                .backgroundColor(this.newCoach === '李教练' ? '#E65100' : '#FFF3E0')
                .padding({ left: 14, right: 14, top: 6, bottom: 6 }).borderRadius(14).margin({ left: 8 })
                .onClick(() => { this.newCoach = '李教练' })
            }
            .margin({ top: 4, left: 16 })
            Text('练习项目').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
            Row() {
              Text('倒车入库').fontSize(11).fontColor(this.newProject === '倒车入库' ? '#FFFFFF' : '#0277BD')
                .backgroundColor(this.newProject === '倒车入库' ? '#0277BD' : '#E1F5FE')
                .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12).margin({ left: 3, right: 3 })
                .onClick(() => { this.newProject = '倒车入库' })
              Text('侧方停车').fontSize(11).fontColor(this.newProject === '侧方停车' ? '#FFFFFF' : '#0277BD')
                .backgroundColor(this.newProject === '侧方停车' ? '#0277BD' : '#E1F5FE')
                .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12).margin({ left: 3, right: 3 })
                .onClick(() => { this.newProject = '侧方停车' })
              Text('坡道定点').fontSize(11).fontColor(this.newProject === '坡道定点' ? '#FFFFFF' : '#0277BD')
                .backgroundColor(this.newProject === '坡道定点' ? '#0277BD' : '#E1F5FE')
                .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12).margin({ left: 3, right: 3 })
                .onClick(() => { this.newProject = '坡道定点' })
            }
            .margin({ top: 4, left: 16 })
            Text('练习时长(分钟)').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
            TextInput({ placeholder: '60' })
              .placeholderColor('#BBBBBB').fontSize(13).width('90%')
              .backgroundColor('#FFF8E1').borderRadius(8)
              .margin({ top: 4 })
              .onChange((v: string) => { this.newDuration = v })
          }
        }
        .layoutWeight(1)
        Row() {
          Text('取消').fontSize(14).fontColor('#888888')
            .backgroundColor('#F5F5F5').borderRadius(20)
            .padding({ left: 28, right: 28, top: 10, bottom: 10 })
            .onClick(() => { this.showAddModal = false })
          Text('保存记录').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#E65100').borderRadius(20)
            .padding({ left: 28, right: 28, top: 10, bottom: 10 }).margin({ left: 12 })
            .onClick(() => { this.showAddModal = false })
        }
        .width('100%').justifyContent(FlexAlign.Center)
        .padding({ top: 16, bottom: 16 })
      }
      .width('90%').height('75%').backgroundColor('#FFFFFF').borderRadius(16)
      .alignItems(HorizontalAlign.Center)
      .position({ x: '5%', y: '12%' })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  build() {
    Stack() {
      Column() {
        Row() {
          Text('📊 学车进度').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#E65100')
          Column().layoutWeight(1)
          Text('+ 新记录').fontSize(12).fontColor('#FFFFFF')
            .backgroundColor('#E65100').borderRadius(14)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .onClick(() => { this.showAddModal = true })
        }
        .width('100%').padding({ left: 16, right: 16, top: 14, bottom: 10 })

        Scroll() {
          Column() {
            Text('科目进度').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#212121')
              .width('100%').padding({ left: 16, top: 6, bottom: 4 })
            this.progressCardBuilder(mockSubjectProgress[0])
            this.progressCardBuilder(mockSubjectProgress[1])
            this.progressCardBuilder(mockSubjectProgress[2])
            this.progressCardBuilder(mockSubjectProgress[3])

            Text('练车记录').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#212121')
              .width('100%').padding({ left: 16, top: 14, bottom: 4 })
            this.driveRecordBuilder(mockDriveRecords[0])
            this.driveRecordBuilder(mockDriveRecords[1])
            this.driveRecordBuilder(mockDriveRecords[2])
            this.driveRecordBuilder(mockDriveRecords[3])
            this.driveRecordBuilder(mockDriveRecords[4])
            this.driveRecordBuilder(mockDriveRecords[5])
            this.driveRecordBuilder(mockDriveRecords[6])
            this.driveRecordBuilder(mockDriveRecords[7])
            this.driveRecordBuilder(mockDriveRecords[8])
            this.driveRecordBuilder(mockDriveRecords[9])
            this.driveRecordBuilder(mockDriveRecords[10])
            this.driveRecordBuilder(mockDriveRecords[11])
          }
          .padding({ bottom: 20 })
        }
        .layoutWeight(1).scrollBar(BarState.Off)
      }
      .width('100%').height('100%')

      if (this.showAddModal) { this.addRecordModal() }
    }
    .width('100%').height('100%')
  }
}

// ============ 我的页 ============
@Component
struct DriveProfileContent {
  monthlyData: number[] = [12, 18, 25, 30, 22, 35, 40, 28]
  monthlyLabels: string[] = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月']
  maxMonthly: number = 40

  @Builder statCard(icon: string, label: string, value: string, color: string) {
    Column() {
      Text(icon).fontSize(22)
      Text(value).fontSize(18).fontWeight(FontWeight.Bold).fontColor(color).margin({ top: 4 })
      Text(label).fontSize(10).fontColor('#888888').margin({ top: 2 })
    }
    .layoutWeight(1).alignItems(HorizontalAlign.Center)
    .backgroundColor('#FFFFFF').borderRadius(12)
    .padding({ top: 14, bottom: 14 })
  }

  @Builder menuRowBuilder(item: ProfileMenuItem) {
    Row() {
      Text(item.icon).fontSize(18)
      Text(item.label).fontSize(13).fontColor('#212121').layoutWeight(1).margin({ left: 10 })
      Text(item.value).fontSize(11).fontColor('#AAAAAA')
      Text('>').fontSize(12).fontColor('#CCCCCC').margin({ left: 6 })
    }
    .width('100%').padding({ top: 12, bottom: 12, left: 4 })
  }

  build() {
    Column() {
      Scroll() {
        Column() {
          // 用户信息卡
          Column() {
            Row() {
              Column() {
                Text('🧑‍🎓').fontSize(40)
              }.width(64).height(64).backgroundColor('#FFF3E0').borderRadius(32)
              .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
              Column() {
                Text('李学员').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
                Text('科目二练习中 · 上海').fontSize(11).fontColor('#888888').margin({ top: 3 })
                Row() {
                  Text('📖 科目一已通过').fontSize(9).fontColor('#43A047')
                    .backgroundColor('#E8F5E9').padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
                  Text('🚗 科目二进行中').fontSize(9).fontColor('#0277BD')
                    .backgroundColor('#E1F5FE').padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8).margin({ left: 6 })
                }
                .margin({ top: 4 })
              }.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 14 })
            }
            .width('100%').padding({ left: 16, right: 16, top: 16, bottom: 16 })
          }
          .width('100%').backgroundColor('#FFFFFF').margin({ left: 12, right: 12, top: 10 })
          .borderRadius(14)

          // 驾校信息
          Column() {
            Row() {
              Text('🏫').fontSize(20)
              Text('驾校信息').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#212121').margin({ left: 8 })
              Column().layoutWeight(1)
              Text('查看 >').fontSize(11).fontColor('#0277BD')
            }
            .width('100%')
            Row() { Text('驾校名称').fontSize(11).fontColor('#888888'); Column().layoutWeight(1); Text('上海通略驾校').fontSize(11).fontColor('#212121') }
            .width('100%').margin({ top: 8 })
            Row() { Text('所属教练').fontSize(11).fontColor('#888888'); Column().layoutWeight(1); Text('王教练(科目二/三)').fontSize(11).fontColor('#212121') }
            .width('100%').margin({ top: 4 })
            Row() { Text('报名日期').fontSize(11).fontColor('#888888'); Column().layoutWeight(1); Text('2026-05-15').fontSize(11).fontColor('#212121') }
            .width('100%').margin({ top: 4 })
            Row() { Text('培训类型').fontSize(11).fontColor('#888888'); Column().layoutWeight(1); Text('C1手动挡').fontSize(11).fontColor('#212121') }
            .width('100%').margin({ top: 4 })
          }
          .width('100%').backgroundColor('#FFFFFF').borderRadius(14)
          .margin({ left: 12, right: 12, top: 8 }).padding(14)

          // 学习统计
          Row() {
            this.statCard('📚', '总练习题数', '320', '#E65100')
            Column().width(6)
            this.statCard('✅', '模考通过', '9次', '#43A047')
          }
          .width('100%').padding({ left: 12, right: 12, top: 8 })
          Row() {
            this.statCard('⏱️', '总学时', '45h', '#0277BD')
            Column().width(6)
            this.statCard('🎯', '正确率', '88%', '#FB8C00')
          }
          .width('100%').padding({ left: 12, right: 12, top: 6 })

          // 月度练习柱状图
          Column() {
            Text('📊 月度练习次数').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#212121')
              .width('100%').padding({ left: 16, top: 12, bottom: 8 })
            Row() {
              ForEach([0, 1, 2, 3, 4, 5, 6, 7], (d: number) => {
                Column() {
                  Text(this.monthlyData[d].toString()).fontSize(9)
                    .fontColor('#E65100').margin({ bottom: 3 })
                  Column()
                    .width(22)
                    .height((this.monthlyData[d] / this.maxMonthly * 90).toFixed(0) + 'vp')
                    .backgroundColor(MONTHLY_PRACTICE_DATA[d].color)
                    .borderRadius({ topLeft: 4, topRight: 4 })
                  Text(this.monthlyLabels[d]).fontSize(8).fontColor('#999999').margin({ top: 3 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Center)
              })
            }
            .padding({ left: 12, right: 12, bottom: 12 })
          }
          .width('100%').backgroundColor('#FFFFFF').borderRadius(14)
          .margin({ left: 12, right: 12, top: 8 })

          // 快捷菜单
          Column() {
            Text('快捷操作').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#212121')
              .width('100%').padding({ left: 16, top: 12, bottom: 8 })
            Column() {
              this.menuRowBuilder(PROFILE_MENU[0])
              Divider().color('#FFF3E0')
              this.menuRowBuilder(PROFILE_MENU[1])
              Divider().color('#FFF3E0')
              this.menuRowBuilder(PROFILE_MENU[2])
              Divider().color('#FFF3E0')
              this.menuRowBuilder(PROFILE_MENU[3])
              Divider().color('#FFF3E0')
              this.menuRowBuilder(PROFILE_MENU[4])
              Divider().color('#FFF3E0')
              this.menuRowBuilder(PROFILE_MENU[5])
            }
            .padding({ left: 16, right: 16 })
          }
          .width('100%').backgroundColor('#FFFFFF').borderRadius(14)
          .margin({ left: 12, right: 12, top: 8 })

          Text('v3.2 · 驾考助手 · 2026').fontSize(10).fontColor('#CCCCCC')
            .alignSelf(ItemAlign.Center).margin({ top: 16, bottom: 16 })
        }
        .padding({ bottom: 20 })
      }
      .layoutWeight(1).scrollBar(BarState.Off)
    }
    .width('100%').height('100%')
  }
}


十三、总结

通过对这份驾考助手应用代码的逐段、逐行分析,我们可以提炼出以下几个核心设计理念和技术亮点。

在这里插入图片描述

第一,配置驱动的展示层设计。 整个应用没有将颜色、图标、文案等展示属性硬编码在组件内部,而是通过 SUBJECT_CONFIGDIFFICULTY_CONFIGQUESTION_TYPE_CONFIG 等一组配置表统一管理。组件在渲染时通过键值查找获取展示信息,配合空值合并运算符(??)提供默认值,既保证了灵活性又保证了健壮性。这种设计让主题切换、多语言适配变得轻而易举——只需要替换配置表即可。

第二,装饰器驱动的响应式状态管理。 @Entry@Component@State@Observed@Builder 这一组装饰器构成了整个应用的架构骨架。@State 让组件内部的状态变化自动触发 UI 刷新,开发者无需手动操作 DOM;@Observed 让数据模型的属性变化能够被框架监听,实现了真正的数据驱动视图;@Builder 将 UI 片段封装为可复用的方法,有效降低了代码重复度。这种声明式的编程范式让开发者能够专注于"描述界面应该是什么样子"而非"如何操作界面"。

第三,构建器模式实现 UI 复用。 subjectCardquestionItemBuilderexamRecordBuilderwrongItemBuilderprogressCardBuilderdriveRecordBuilderstatCardmenuRowBuilder 等构建器方法,将重复的 UI 结构封装为参数化的可复用单元。每个构建器接收数据参数,返回一段 UI 声明,调用时只需传入不同的数据即可渲染出结构一致但内容不同的界面。这种模式在列表渲染场景中尤为高效。

第四,Stack + 遮罩层的模态交互模式。 五个页面中有四个使用了 Stack 作为根容器,将主体内容和弹窗层叠放置。遮罩层通过 modalOverlay 构建器统一封装,保证了所有弹窗的遮罩样式一致。弹窗本身通过 position 绝对定位和 zIndex(999) 确保浮于最上层。点击遮罩层关闭弹窗的交互模式统一且符合用户直觉。多个弹窗之间的联动(如详情弹窗中点击删除打开确认弹窗)通过状态变量的连锁赋值实现。

第五,条件渲染的灵活运用。 代码中大量使用了条件渲染来处理各种边界情况:选项为空时不渲染对应选项、备注为空时不渲染备注块、收藏状态切换图标、选中状态切换样式、弹窗显隐切换。这些条件判断让 UI 能够自适应不同的数据状态,避免了"空内容占位"的尴尬。

第六,手写数据可视化的实践。 个人中心页的柱状图完全使用基础组件(ColumnText)手写实现,没有依赖任何图表库。通过将数据值归一化到像素高度,配合颜色渐变和圆角样式,实现了一个简洁美观的柱状图。学车进度页的环形进度条则使用了框架内置的 Progress 组件。这种"内置组件 + 手写图表"的组合策略,在功能需求和包体积之间取得了良好的平衡。

第七,色彩语义的系统化应用。 整个应用建立了一套完整的色彩语义体系:橙色(#E65100)是主题色,用于标题、按钮和强调;绿色(#43A047)代表成功、通过、正确;红色(#E53935)代表失败、错误、删除;蓝色(#0277BD)代表信息、链接、进行中。这套色彩语义在所有页面中保持一致,让用户形成了稳定的色彩认知,降低了理解成本。

第八,信息层次通过字号和颜色梯度建立。 代码中的字号从 8 到 40 跨越了多个级别,配合字重(Bold/Medium/Normal)和颜色深浅,构建了清晰的信息层次。最重要的信息(如分数、标题)使用大号粗体字,次要信息使用小号灰色字,辅助信息使用更小号浅色字。这种字号梯度让用户在扫描信息时能够自然地聚焦到关键内容。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐