【口算王|06】HarmonyOS ArkTS 题库详情实战:组织年级、题型与练习入口
2026/9/10 22:57:09 网站建设 项目流程

题库详情页不是一张“题库介绍海报”。它要把题库元数据、本地生成题目、学习进度、章节进度和三种练习入口放在同一页面中,还要保证手机、小窗和平板看到的是同一份业务状态。页面上的“250 题”“已答 42 题”“正确率 86%”都必须能回到真实数据源。

本文基于口算王项目D:\huawei\one16-11的真实源码,复核BankDetailPage.etsMockBanks.etsMathModels.etsUserDataManager.etsPracticePage.ets。项目包名com.jiaweikang.one16用作本文唯一核验标记。

当前实现已经覆盖一年级到六年级的题库目录,每个题库包含六个章节;题目由本地算法生成,详情页展示封面、简介、训练重点、章节进度,并提供章节练习、随机练习与限时挑战。需要特别注意的是,五六年级的页面文案提到小数、分数和百分数,但当前通用生成器实际仍主要产生整数加减乘除、混合运算与应用题。文章会把这种“目录文案与题目能力的一致性”作为发布复查重点,不会把尚未实现的题型写成已有能力。

一、详情页连接了五类数据

BankDetailContent同时消费:

  1. 路由或固定参数提供的bankId
  2. MockBanks返回的BankChapter
  3. AppStorage中的BankProgress
  4. AppStorage中的ChapterProgress
  5. 当前断点、安全区与页面宽度。

这意味着页面不是静态展示组件。它既是目录浏览页,也是训练入口和学习状态汇总页。

从工程边界看,应先确定题库身份,再读取目录与进度,最后派生展示模型。路由、题库、进度任一环节缺失,页面都要有可解释的降级状态。

二、Bank 与 Chapter 的真实模型

题库模型定义在公共模型层:

export interface Chapter { id: string index: number title: string total: number finished: number done: boolean } export interface Bank { id: string regionId: string name: string cover: Resource totalCount: number accuracy: number hot: number chapters: Chapter[] }

这里的finisheddoneaccuracy看起来像运行时进度,但当前页面并不直接信任这些字段。它通过UserDataManager查询本地进度,再计算完成度和正确率。这是正确方向:目录定义负责“有什么”,用户数据负责“做了多少”。

不过模型仍有冗余。Chapter.finishedChapter.doneChapterProgress表达重复概念,后续维护时容易出现两个来源不同步。更清晰的做法是让目录模型保持只读:

export interface ChapterDefinition { id: string index: number title: string total: number } export interface ChapterViewState { definition: ChapterDefinition finished: number correct: number ratio: number done: boolean }

页面消费合并后的ChapterViewState,不再猜测哪个字段更新过。

三、六个年级如何组织

MockBanks.ets真实声明了六个区域:

export const REGIONS: Region[] = [ { id: 'grade1', name: '一年级', shortName: '一', ... }, { id: 'grade2', name: '二年级', shortName: '二', ... }, { id: 'grade3', name: '三年级', shortName: '三', ... }, { id: 'grade4', name: '四年级', shortName: '四', ... }, { id: 'grade5', name: '五年级', shortName: '五', ... }, { id: 'grade6', name: '六年级', shortName: '六', ... } ]

每个区域对应一个题库,如b_grade1b_grade6。题库与区域之间通过regionId关联,而不是依靠显示名称匹配。这使标题文案可以调整,不会破坏路由和进度主键。

年级不是题型。页面用年级组织入口,而题目内部使用addsubmuldivmixedwordspeed七种类型。后续做题型筛选时,应通过Question.type,不能从章节中文标题猜类型。

四、章节总数不是写死的展示数字

初始化时,章节的total都是 0。getBankById()会先调用syncCatalogCounts()

export function getBankById(id: string): Bank | undefined { syncCatalogCounts() return BANKS.find((bank: Bank) => bank.id === id) }

同步函数遍历真实生成的题目,计算题库总数和每章题数:

for (const bank of BANKS) { const questions = getQuestions(bank.id) bank.totalCount = questions.length for (const chapter of bank.chapters) { const chapterTotal = questions.filter( (question: Question) => question.chapterId === chapter.id ).length chapter.total = chapterTotal chapter.finished = 0 chapter.done = false } }

当前每个题库目标生成 250 道题,章节 ID 按序分配,所以六章题量大致均衡。详情页展示的“共 N 题”来自同步后的bank.totalCount,不是单独维护的营销数字。

五、同步目录时直接修改全局对象的边界

syncCatalogCounts()会原地修改全局BANKSChapter对象。当前单机数据规模很小,功能上可行,但有三个工程边界:

  • 每次getBankById()都遍历所有题库和题目;
  • 全局可变对象让测试用例之间可能互相影响;
  • 目录定义与派生统计混在一起。

可以缓存不可变目录快照:

let catalogReady: boolean = false function ensureCatalogCounts(): void { if (catalogReady) return for (const bank of BANKS) { const questions = getQuestions(bank.id) bank.totalCount = questions.length for (const chapter of bank.chapters) { chapter.total = questions.filter( (item: Question) => item.chapterId === chapter.id ).length } } catalogReady = true }

更进一步,可在构建阶段生成题库清单 JSON,让运行时只读,减少首次进入详情页的工作量。

六、题库身份支持两种来源

页面既可以作为独立路由页,也可以嵌入其他产品布局。fixedBankId优先于路由参数:

aboutToAppear(): void { if (this.fixedBankId.length > 0) { this.bank = getBankById(this.fixedBankId) return } const params = router.getParams() as BankDetailParams | undefined if (params && params.bankId) { this.bank = getBankById(params.bankId) } }

这个设计让同一内容组件可复用,但要明确优先级:只要fixedBankId非空,路由参数就不会生效。

对于未知bankIdgetBankById()返回undefined,页面展示“未找到题库”空态。这是当前真实存在的降级能力。仍建议给空态增加返回动作,避免用户只能依靠系统返回手势。

七、题库级进度如何派生

页面从BankProgress[]查找当前题库:

private bankFinished(): number { const progress = UserDataManager.getProgress( this.progressList, this.bankId() ) return progress ? progress.finished : 0 }

完成度计算会限制到 1:

private bankProgressRatio(): number { if (!this.bank || this.bank.totalCount === 0) return 0 return Math.min( this.bankFinished() / this.bank.totalCount, 1 ) }

这个上限处理很重要,因为当前updateProgress()按每次练习累加已答题数。用户重复练习同一题后,finished可以超过题库唯一题目数。页面把进度条限制为 100%,但旁边文字仍可能显示320/250

这里需要先决定业务口径:

  • 如果finished表示累计作答次数,就不应作为“题库完成度”分子;
  • 如果表示已覆盖的唯一题数,就要按questionId去重;
  • 如果产品需要两者,应分别命名“累计作答”和“题目覆盖率”。

仅用Math.min()隐藏进度条溢出,并没有解决统计语义。

八、首次进入时的正确率回退

页面正确率实现为:

private bankAccuracy(): number { const progress = UserDataManager.getProgress( this.progressList, this.bankId() ) if (!progress || progress.finished === 0) { return this.bank ? this.bank.accuracy : 0 } return progress.correct / progress.finished }

当前BANKS初始accuracy都是 0,因此未练习时展示 0%。这可能让用户误以为已经答错,而不是尚无数据。

可以让返回值表达缺失状态:

private bankAccuracyText(): string { const progress = UserDataManager.getProgress( this.progressList, this.bankId() ) if (!progress || progress.finished <= 0) return '--' const ratio = Math.min( 1, Math.max(0, progress.correct / progress.finished) ) return `${Math.round(ratio * 100)}%` }

“暂无数据”与“正确率 0%”是不同事实,详情页应该区分。

九、章节进度与完成状态

章节进度使用(bankId, chapterId)作为复合键:

private chapterFinished(chapter: Chapter): number { const progress = UserDataManager.getChapterProgress( this.chapterProgressList, this.bankId(), chapter.id ) return progress ? progress.finished : 0 } private chapterDone(chapter: Chapter): boolean { return chapter.total > 0 && this.chapterFinished(chapter) >= chapter.total }

章节卡根据进度显示“开始”“继续”或“完成”。这个状态机来自真实数据:

  • finished = 0:开始;
  • 0 < finished < total:继续;
  • finished >= total:完成。

ChapterProgress.finished同样按练习次数累加。如果一章有 42 道题,用户重复完成两组 20 题,即使题目重叠也可能被标为完成。要表达真实覆盖率,需要保存已完成题目 ID 集合或位图。

十、三个练习入口的路由契约

详情页提供三类入口。

章节练习:

router.pushUrl({ url: 'pages/PracticePage', params: { bankId: this.bank.id, chapterId: chapter.id, mode: 'chapter' } })

随机练习:

router.pushUrl({ url: 'pages/PracticePage', params: { bankId: this.bank!.id, mode: 'random' } })

限时挑战:

router.pushUrl({ url: 'pages/PracticePage', params: { bankId: this.bank!.id, mode: 'exam' } })

同一个PracticePage通过mode和可选的chapterId决定题目来源。这种路由契约简单清晰,但字符串模式容易拼写错误。可以收敛为常量:

export class PracticeMode { static readonly CHAPTER: string = 'chapter' static readonly RANDOM: string = 'random' static readonly EXAM: string = 'exam' }

所有入口、参数模型和页面判断共用同一组值。

十一、题库文案与题目生成能力必须一致

详情页为每个年级准备了专属介绍。例如五年级文案包含“小数、分数和单位换算”,六年级包含“分数、百分数和比例”。

但当前buildQuestions()的七个槽位实际生成:

  • 整数加法;
  • 非负整数减法;
  • 表内乘法;
  • 整除题;
  • 整数混合运算;
  • 简单加法应用题;
  • 整数限时题。

grade只影响数字范围和少量常数,并没有生成小数、分数、百分数或比例。章节标题与focusTags是展示文案,不代表题型已经实现。

这是上架审核和技术文章都必须守住的边界。修复有两条路径:

  1. 在真实题型实现前,把五六年级文案改为当前可支持的整数综合练习;
  2. 新增 decimal、fraction、percent、ratio 题目模型、生成器、格式化与解析,再保留现有文案。

不能只改标题或标签来制造功能完整的印象。

十二、题型与章节目前是轮转分配

生成题目时,章节 ID 由题目序号取模得到:

chapterId: `${regionId}_c${(i % 6) + 1}`

题型则由另一个七槽位轮转决定。因为 6 和 7 不同,每个章节会混合多种题型,而不是“第一章只含加法、第二章只含减法”。

getQuestionsByChapter()只按chapterId过滤:

export function getQuestionsByChapter( bankId: string, chapterId: string ): Question[] { return getQuestions(bankId).filter( (question: Question) => question.chapterId === chapterId ) }

因此章节中文标题与真实题目集合也需要复查。理想设计是在生成时根据章节定义选择题型和参数,而不是仅按序号轮转。

十三、宽屏布局是当前真实能力

页面通过断点和实际宽度共同决定宽屏:

private useWideLayout(): boolean { return this.currentBp === 'lg' && this.pageWidth >= 700 }

页面根容器监听面积变化:

.onAreaChange((oldArea: Area, newArea: Area) => { const width = Number(newArea.width) if (width > 0) { this.pageWidth = width } })

宽屏时左侧 38% 展示封面、汇总和简介,右侧展示训练重点与章节列表;窄屏则使用单列滚动。这是面向 HarmonyOS 多设备和窗口变化的真实适配,不是只按设备名分支。

需要验证临界宽度附近是否频繁切换、38%左栏是否能容纳长标题,以及 2in1 缩放时底部操作区是否一直可达。

十四、底部操作区如何避开系统导航

页面固定显示随机练习和限时挑战按钮,底部间距取:

private bottomSafePadding(): number { return Math.max( Sizes.BOTTOM_NAV_MIN_PADDING, this.getUIContext().px2vp( this.navigationIndicatorHeightPx ) ) }

这保证按钮至少保留项目定义的最小安全距离,同时适配真实导航指示区高度。主内容使用Scroll,底部操作不随内容滚走,常用训练入口始终可达。

横屏小窗仍要检查两个文本按钮是否过窄。必要时可在极窄宽度下改为纵向按钮或只保留图标加短标签,但不能让文字溢出。

十五、建议构造统一的 BankDetailViewState

当前多个 Builder 会重复调用bankFinished()bankAccuracy()profile()和章节查询。数据量很小,但统一展示状态更利于测试:

export interface BankDetailViewState { bankId: string title: string subtitle: string totalCount: number answeredCount: number accuracyText: string progressRatio: number chapters: ChapterViewState[] focusTags: string[] sceneTags: string[] }

构建流程可以是:

  1. 校验bankId
  2. 读取不可变题库定义;
  3. 读取题库与章节进度;
  4. 归一化计数与比例;
  5. 生成BankDetailViewState
  6. ArkUI Builder 只渲染。

页面不再散落业务判断,单元测试也能直接验证“未练习正确率显示--”“重复练习不冒充覆盖率”等规则。

十六、性能优化应先看数据规模

每个题库 250 题,共六个题库约 1500 题。syncCatalogCounts()在获取任一题库时扫描全部题库,章节统计又对每章执行一次filter()。当前规模通常可接受,但这是可避免的重复工作。

优化顺序建议:

  1. getQuestions()保留现有缓存;
  2. 目录统计只初始化一次;
  3. 单次遍历同时累计题库、章节和题型数量;
  4. 题库定义与用户进度分开;
  5. 只有测量发现瓶颈后,再考虑更复杂索引。

不要为了 1500 条本地对象引入网络数据库或沉重状态框架。简单缓存和单次归并已经足够。

十七、验证矩阵

场景预期
合法 bankId展示对应年级题库
未知 bankId展示“未找到题库”
无路由参数安全空态,不崩溃
首次进入已答 0,正确率显示无数据更合理
有题库进度完成度和正确率按同一记录计算
累计作答超过题量进度条不溢出,同时明确累计口径
章节无进度按钮显示“开始”
章节部分进度按钮显示“继续”
章节达到总题量显示“完成”
点击章节携带 bankId、chapterId、chapter 模式
点击随机练习携带 bankId、random 模式
点击限时挑战携带 bankId、exam 模式
宽度低于 700单列滚动
lg 且宽度至少 700双栏布局
深浅色切换文本、封面遮罩、标签和按钮可读

还应抽样核对每个年级的章节标题与真实题目:不仅看题量,还要检查题型、数字范围、答案格式和解析内容。

十八、常见问题与排查

现象可能原因检查位置
题库总数为 0未执行目录统计getBankById()
章节题数都为 0chapterId 不匹配syncCatalogCounts()
完成度长期 100%finished 是累计次数进度口径
正确率显示 NaNfinished 为 0 或数据损坏bankAccuracy()
点击章节进入空页章节 ID 与题目 ID 不一致getQuestionsByChapter()
五年级没有小数题文案超过生成器能力buildQuestions()
平板仍是单列断点或 pageWidth 未达条件useWideLayout()
底部按钮被遮挡安全区高度未初始化navigationIndicatorHeightPx

十九、发布前检查清单

  • 六个年级、六个题库和章节 ID 一一对应;
  • 题库总数来自真实生成题目;
  • 章节总数来自chapterId过滤结果;
  • 题型标签与Question.type一致;
  • 首次进入不把“无数据”误写成“0%能力”;
  • 累计作答次数与唯一题目覆盖率分开;
  • 章节完成规则符合产品口径;
  • 三种练习入口参数可被PracticePage正确识别;
  • 未知题库有空态和返回路径;
  • 五六年级文案不超过当前题目生成能力;
  • 手机、小窗、平板和 2in1 布局可达;
  • 长标题、标签和章节名不溢出;
  • 底部按钮避开系统导航区;
  • 深浅色下封面遮罩和文本对比度合格;
  • 本地生成、离线可用等声明与真实权限和代码一致。

二十、总结

口算王的题库详情页已经把年级目录、题库元数据、本地题量、题库进度、章节进度和三类训练入口组织成一个可用界面,并通过断点与实际宽度提供单列、双栏布局。它的核心价值不是卡片数量,而是把“题库定义”和“用户训练状态”合并为可操作入口。

继续提升时要守住三条线:目录文案必须与真实题目生成器一致;累计作答不能直接冒充唯一题目覆盖率;所有路由入口必须共用稳定的 bankId、chapterId 和 mode 契约。把这些数据边界明确后,页面才能在 HarmonyOS 5.0 以上设备上稳定适配,也能让文章、应用介绍和上架材料中的每个能力声明都可从源码复核。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询