题库详情页不是一张“题库介绍海报”。它要把题库元数据、本地生成题目、学习进度、章节进度和三种练习入口放在同一页面中,还要保证手机、小窗和平板看到的是同一份业务状态。页面上的“250 题”“已答 42 题”“正确率 86%”都必须能回到真实数据源。
本文基于口算王项目D:\huawei\one16-11的真实源码,复核BankDetailPage.ets、MockBanks.ets、MathModels.ets、UserDataManager.ets与PracticePage.ets。项目包名com.jiaweikang.one16用作本文唯一核验标记。
当前实现已经覆盖一年级到六年级的题库目录,每个题库包含六个章节;题目由本地算法生成,详情页展示封面、简介、训练重点、章节进度,并提供章节练习、随机练习与限时挑战。需要特别注意的是,五六年级的页面文案提到小数、分数和百分数,但当前通用生成器实际仍主要产生整数加减乘除、混合运算与应用题。文章会把这种“目录文案与题目能力的一致性”作为发布复查重点,不会把尚未实现的题型写成已有能力。
一、详情页连接了五类数据
BankDetailContent同时消费:
- 路由或固定参数提供的
bankId; MockBanks返回的Bank与Chapter;AppStorage中的BankProgress;AppStorage中的ChapterProgress;- 当前断点、安全区与页面宽度。
这意味着页面不是静态展示组件。它既是目录浏览页,也是训练入口和学习状态汇总页。
从工程边界看,应先确定题库身份,再读取目录与进度,最后派生展示模型。路由、题库、进度任一环节缺失,页面都要有可解释的降级状态。
二、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[] }这里的finished、done和accuracy看起来像运行时进度,但当前页面并不直接信任这些字段。它通过UserDataManager查询本地进度,再计算完成度和正确率。这是正确方向:目录定义负责“有什么”,用户数据负责“做了多少”。
不过模型仍有冗余。Chapter.finished、Chapter.done与ChapterProgress表达重复概念,后续维护时容易出现两个来源不同步。更清晰的做法是让目录模型保持只读:
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_grade1、b_grade6。题库与区域之间通过regionId关联,而不是依靠显示名称匹配。这使标题文案可以调整,不会破坏路由和进度主键。
年级不是题型。页面用年级组织入口,而题目内部使用add、sub、mul、div、mixed、word、speed七种类型。后续做题型筛选时,应通过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()会原地修改全局BANKS和Chapter对象。当前单机数据规模很小,功能上可行,但有三个工程边界:
- 每次
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非空,路由参数就不会生效。
对于未知bankId,getBankById()返回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是展示文案,不代表题型已经实现。
这是上架审核和技术文章都必须守住的边界。修复有两条路径:
- 在真实题型实现前,把五六年级文案改为当前可支持的整数综合练习;
- 新增 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[] }构建流程可以是:
- 校验
bankId; - 读取不可变题库定义;
- 读取题库与章节进度;
- 归一化计数与比例;
- 生成
BankDetailViewState; - ArkUI Builder 只渲染。
页面不再散落业务判断,单元测试也能直接验证“未练习正确率显示--”“重复练习不冒充覆盖率”等规则。
十六、性能优化应先看数据规模
每个题库 250 题,共六个题库约 1500 题。syncCatalogCounts()在获取任一题库时扫描全部题库,章节统计又对每章执行一次filter()。当前规模通常可接受,但这是可避免的重复工作。
优化顺序建议:
- 给
getQuestions()保留现有缓存; - 目录统计只初始化一次;
- 单次遍历同时累计题库、章节和题型数量;
- 题库定义与用户进度分开;
- 只有测量发现瓶颈后,再考虑更复杂索引。
不要为了 1500 条本地对象引入网络数据库或沉重状态框架。简单缓存和单次归并已经足够。
十七、验证矩阵
| 场景 | 预期 |
|---|---|
| 合法 bankId | 展示对应年级题库 |
| 未知 bankId | 展示“未找到题库” |
| 无路由参数 | 安全空态,不崩溃 |
| 首次进入 | 已答 0,正确率显示无数据更合理 |
| 有题库进度 | 完成度和正确率按同一记录计算 |
| 累计作答超过题量 | 进度条不溢出,同时明确累计口径 |
| 章节无进度 | 按钮显示“开始” |
| 章节部分进度 | 按钮显示“继续” |
| 章节达到总题量 | 显示“完成” |
| 点击章节 | 携带 bankId、chapterId、chapter 模式 |
| 点击随机练习 | 携带 bankId、random 模式 |
| 点击限时挑战 | 携带 bankId、exam 模式 |
| 宽度低于 700 | 单列滚动 |
| lg 且宽度至少 700 | 双栏布局 |
| 深浅色切换 | 文本、封面遮罩、标签和按钮可读 |
还应抽样核对每个年级的章节标题与真实题目:不仅看题量,还要检查题型、数字范围、答案格式和解析内容。
十八、常见问题与排查
| 现象 | 可能原因 | 检查位置 |
|---|---|---|
| 题库总数为 0 | 未执行目录统计 | getBankById() |
| 章节题数都为 0 | chapterId 不匹配 | syncCatalogCounts() |
| 完成度长期 100% | finished 是累计次数 | 进度口径 |
| 正确率显示 NaN | finished 为 0 或数据损坏 | bankAccuracy() |
| 点击章节进入空页 | 章节 ID 与题目 ID 不一致 | getQuestionsByChapter() |
| 五年级没有小数题 | 文案超过生成器能力 | buildQuestions() |
| 平板仍是单列 | 断点或 pageWidth 未达条件 | useWideLayout() |
| 底部按钮被遮挡 | 安全区高度未初始化 | navigationIndicatorHeightPx |
十九、发布前检查清单
- 六个年级、六个题库和章节 ID 一一对应;
- 题库总数来自真实生成题目;
- 章节总数来自
chapterId过滤结果; - 题型标签与
Question.type一致; - 首次进入不把“无数据”误写成“0%能力”;
- 累计作答次数与唯一题目覆盖率分开;
- 章节完成规则符合产品口径;
- 三种练习入口参数可被
PracticePage正确识别; - 未知题库有空态和返回路径;
- 五六年级文案不超过当前题目生成能力;
- 手机、小窗、平板和 2in1 布局可达;
- 长标题、标签和章节名不溢出;
- 底部按钮避开系统导航区;
- 深浅色下封面遮罩和文本对比度合格;
- 本地生成、离线可用等声明与真实权限和代码一致。
二十、总结
口算王的题库详情页已经把年级目录、题库元数据、本地题量、题库进度、章节进度和三类训练入口组织成一个可用界面,并通过断点与实际宽度提供单列、双栏布局。它的核心价值不是卡片数量,而是把“题库定义”和“用户训练状态”合并为可操作入口。
继续提升时要守住三条线:目录文案必须与真实题目生成器一致;累计作答不能直接冒充唯一题目覆盖率;所有路由入口必须共用稳定的 bankId、chapterId 和 mode 契约。把这些数据边界明确后,页面才能在 HarmonyOS 5.0 以上设备上稳定适配,也能让文章、应用介绍和上架材料中的每个能力声明都可从源码复核。