在口算应用里,"分类训练"看起来只是把加法、减法、乘法、除法做成几张卡片。真正容易出错的地方,却发生在卡片点击之后:页面显示的是"加法练习",路由传递的是 add,搜索页按 Question.type 过滤,而训练页还要继续沿用同一条件。只要其中一层改用中文名称、临时序号或另一套枚举,用户看到的分类和实际进入的题目就可能不一致。
另一个更隐蔽的问题是年级范围。分类页可能让用户误以为"当前年级的加法",但如果路由只传 categoryType,搜索逻辑就会扫描全部题库。此时结果本质上是"所有年级中的加法题",并不是"二年级加法"。要实现年级与运算分类的交集,必须把两个维度都写进参数契约,而不能仅靠页面标题暗示。
本文基于口算王项目 D:\huawei\one16-11 的真实源码,复核 CategoryPage.ets、HomePage.ets、SearchPage.ets、PracticePage.ets 与 MockBanks.ets。项目包名 com.jiaweikang.one16 是本文草稿核验使用的唯一标记。当前代码已经实现七类题型、分类计数、响应式卡片和按题型进入搜索结果;年级与分类联动则作为可落地的增强方案单独说明,不把设计建议写成现成功能。

本文重点解决四件事:
- 用稳定的
type作为分类主键,避免展示文案参与业务判断; - 解释分类计数、首页入口、分类页与搜索页如何共享同一数据源;
- 明确当前"跨全部题库分类"和"年级+分类交集"的边界;
- 给出可验证的路由参数、归一化、响应式布局与回归测试方法。
一、七种分类不是七段文案,而是七个稳定标识
项目的分类目录来自 MockBanks.ets 中的 CATEGORIES。当前真实分类包括:
| type | 页面名称 | 业务含义 |
|---|---|---|
add |
加法练习 | 加法类题目 |
sub |
减法练习 | 减法类题目 |
mul |
乘法口诀 | 乘法类题目 |
div |
除法练习 | 除法类题目 |
mixed |
混合运算 | 混合运算题 |
word |
应用题 | 应用题 |
speed |
限时挑战 | 速度训练类题目 |
这里最值得保留的设计是:业务判断依赖 type,用户界面显示 name。两者职责不同。
ts
export interface Category {
type: string
name: string
count: number
}
export const CATEGORIES: Category[] = [
{ type: 'add', name: '加法练习', count: 0 },
{ type: 'sub', name: '减法练习', count: 0 },
{ type: 'mul', name: '乘法口诀', count: 0 },
{ type: 'div', name: '除法练习', count: 0 },
{ type: 'mixed', name: '混合运算', count: 0 },
{ type: 'word', name: '应用题', count: 0 },
{ type: 'speed', name: '限时挑战', count: 0 }
]
中文名称会随着产品文案调整,例如"乘法口诀"可能改成"乘法训练";稳定标识则不应跟着变化。若搜索逻辑使用 name,一次文案改版就可能让历史路由、收藏记录和统计维度全部失效。type 才是分类协议,name 只是协议的可读视图。
进一步收紧类型时,可以把字符串联合类型放到共享模型中:
ts
export type QuestionType =
'add' | 'sub' | 'mul' | 'div' |
'mixed' | 'word' | 'speed'
export interface Category {
type: QuestionType
name: string
count: number
}
这样,分类目录、路由参数和题目模型都能复用 QuestionType。拼错 mixed、传入不存在的 addition 等问题,会更早暴露在编译或参数校验阶段。
二、分类数量来自题库扫描,不是写死的展示数字
每张分类卡都会显示题目数量。源码不是手工填写七个数字,而是通过 syncCatalogCounts() 扫描全部题库和题目,再按 question.type 累计。
ts
export function syncCatalogCounts(): void {
const counts: Record<string, number> = {}
BANKS.forEach((bank: Bank) => {
bank.chapters.forEach((chapter: Chapter) => {
chapter.questions.forEach((question: Question) => {
counts[question.type] =
(counts[question.type] || 0) + 1
})
})
})
CATEGORIES.forEach((category: Category) => {
category.count = counts[category.type] || 0
})
}
这段逻辑把"题库是事实源"落实到了分类目录。新增一道 div 题后,只要重新同步目录,除法分类数量会自动增加;没有某种题型时,数量回落为 0,不会显示 undefined。
但也要看清统计口径:它遍历的是全部 BANKS。因此分类页上的"加法练习 300 题"代表所有题库中 type === 'add' 的总量,不是当前年级数量。如果产品要显示"二年级加法 42 题",计数函数必须增加题库范围:
ts
function countQuestions(
type: QuestionType,
bankId?: string
): number {
return BANKS
.filter((bank: Bank) =>
!bankId || bank.id === bankId
)
.flatMap((bank: Bank) => bank.chapters)
.flatMap((chapter: Chapter) => chapter.questions)
.filter((question: Question) =>
question.type === type
)
.length
}
计数范围必须与进入结果页后的筛选范围一致。否则卡片写着 42 题,进入后却出现 300 题,用户会把它理解为数据错误。
三、首页和分类页共享同一份目录
源码中有两个分类入口:HomePage.ets 的横向分类区,以及 CategoryPage.ets 的完整分类网格。两个入口都遍历 CATEGORIES,并用 cat.type 作为路由值。
ts
ForEach(CATEGORIES, (cat: Category) => {
this.CategoryItem(cat)
}, (cat: Category) => cat.type)
这里的 cat.type 同时承担三个职责:
- 作为
ForEach的稳定 key; - 作为题型筛选条件;
- 作为跨页面路由参数。
复用目录可以避免首页写一套"加减乘除",分类页再写一套"加法、减法、乘法、除法"。当新增 word 或 speed 时,两个页面都能看到同一项,计数也来自同一处。
页面仍然可以拥有不同布局。首页适合短入口,分类页适合展示完整名称、描述、图标和数量。共享的是业务目录,不是强行共享全部 UI。把数据与视觉组件分开,能让多设备布局更容易演进。

四、当前路由只传题型,不传年级
分类卡点击后的真实路由如下:
ts
router.pushUrl({
url: 'pages/SearchPage',
params: {
categoryType: cat.type,
categoryName: cat.name
}
})
categoryType 是查询值,categoryName 是显示值。SearchPage 出现时读取参数,并自动执行一次搜索:
ts
interface SearchParams {
categoryType?: string
categoryName?: string
}
aboutToAppear(): void {
const params =
router.getParams() as SearchParams | undefined
if (params && params.categoryType) {
this.categoryType = params.categoryType
this.categoryName =
params.categoryName || params.categoryType
this.keyword = this.categoryName
this.doSearch()
}
}
搜索框里显示"加法练习",真正过滤时仍使用 add。这是合理的,因为用户不需要理解内部枚举,代码也不依赖可变中文。
需要特别说明的是,路由参数里没有 bankId、gradeId 或 regionId。因此当前分类结果会扫描全部题库,只按题型过滤。文章标题中的"让年级与运算分类参数保持一致",指的是如何把现有题型协议扩展成两个维度都明确的契约,而不是宣称源码已经完成年级交集筛选。
五、搜索页按 Question.type 精确过滤
分类模式与自由文本搜索不是一回事。分类模式收到 categoryType 后,应按题目模型的 type 精确匹配:
ts
private searchByCategory(type: string): void {
const matched: Question[] = []
BANKS.forEach((bank: Bank) => {
bank.chapters.forEach((chapter: Chapter) => {
chapter.questions.forEach((question: Question) => {
if (question.type === type) {
matched.push(question)
}
})
})
})
this.questionResults = matched
}
精确比较比对标题做模糊匹配可靠。例如一道题的题干里出现"加法",不代表它的训练类型一定是 add;应用题也可能包含加法运算。模型字段决定分类,题干只负责内容。
结果卡进入 PracticePage 时,也要继续传递真实题型或目标题目标识,不能把条件丢在搜索页:
ts
router.pushUrl({
url: 'pages/PracticePage',
params: {
questionType: this.categoryType,
targetQuestionId: question.id
}
})
训练页收到 questionType 后再次按同一枚举取题,并让 targetQuestionId 对应的题目优先出现。这样"分类入口 -> 搜索结果 -> 开始训练"使用的是一条完整协议。
六、年级和题型是两个正交维度
把"二年级加法"建模成一个长字符串,例如 grade2_add,短期看起来省事,后续却会迅速膨胀。六个年级乘七种题型就是四十二个组合;再加入地区、教材版本或难度,组合数量继续增长。
更稳妥的做法是保持维度独立:
ts
export interface CategoryScope {
type: QuestionType
bankId?: string
regionId?: string
}
export interface CategoryRouteParams {
categoryType: QuestionType
categoryName: string
bankId?: string
bankName?: string
}
当用户从"二年级题库"内部点击加法时,路由可以携带交集范围:
ts
const params: CategoryRouteParams = {
categoryType: 'add',
categoryName: '加法练习',
bankId: 'grade-2',
bankName: '二年级'
}
router.pushUrl({
url: 'pages/SearchPage',
params
})
当用户从全局分类页点击时,则只传题型:
ts
const params: CategoryRouteParams = {
categoryType: 'add',
categoryName: '加法练习'
}
两种入口使用同一个参数结构,但范围不同:有 bankId 就做交集,没有就做全局题型筛选。这比用页面来源判断更稳定,因为目标页只需要解释参数,不必猜测用户从哪个页面来。
七、把筛选条件收敛成一个纯函数
如果搜索页和训练页分别手写过滤,规则很容易漂移。可以把题型与题库范围组合成纯函数,供多个页面复用。
ts
function matchesScope(
question: Question,
bank: Bank,
scope: CategoryScope
): boolean {
if (question.type !== scope.type) {
return false
}
if (scope.bankId && bank.id !== scope.bankId) {
return false
}
if (
scope.regionId &&
bank.regionId !== scope.regionId
) {
return false
}
return true
}
然后用同一方法生成结果:
ts
function findQuestions(
scope: CategoryScope
): Question[] {
const result: Question[] = []
BANKS.forEach((bank: Bank) => {
bank.chapters.forEach((chapter: Chapter) => {
chapter.questions.forEach((question: Question) => {
if (matchesScope(question, bank, scope)) {
result.push(question)
}
})
})
})
return result
}
纯函数不依赖页面状态,适合单元测试。它同时回答了三个问题:什么题型、哪个题库、哪个地区。未来增加难度时,也能在 CategoryScope 中增加 difficulty,而不是复制第三套过滤循环。

八、路由边界必须拒绝未知分类
ArkTS 的接口只在编译阶段提供约束,router.getParams() 仍然是运行时输入。旧版本页面、错误跳转或异常状态都可能传入未知字符串。目标页应先归一化,再进入查询。
ts
const SUPPORTED_TYPES: QuestionType[] = [
'add', 'sub', 'mul', 'div',
'mixed', 'word', 'speed'
]
function normalizeQuestionType(
value?: string
): QuestionType | undefined {
if (!value) {
return undefined
}
const found = SUPPORTED_TYPES.find(
(item: QuestionType) => item === value
)
return found
}
页面读取参数时可显式处理失败:
ts
const params =
router.getParams() as SearchParams | undefined
const type = normalizeQuestionType(
params?.categoryType
)
if (!type) {
this.searched = false
this.categoryType = ''
this.questionResults = []
return
}
未知分类不应伪装成一个"正常但没有题"的类别。回到搜索初始态、显示参数错误提示,或安全返回上一页,都比默默制造空结果更容易排查。
bankId 也要执行同样校验。不能仅凭路由带了一个字符串,就认为它对应真实题库:
ts
function findBank(bankId?: string): Bank | undefined {
if (!bankId) {
return undefined
}
return BANKS.find((bank: Bank) =>
bank.id === bankId
)
}
当 bankId 无效时,产品要明确选择:退回全局分类,还是提示题库不存在。不要在不同页面里各自猜测。
九、分类视觉信息应集中,避免多个 switch 漂移
CategoryPage.ets 会为不同类型提供副标题、颜色、背景和图标。当前实现中,图标使用共享的 questionTypeIcon,部分副标题和颜色则由页面内的 switch 返回。只要分类增加或改名,多个分支就可能漏改。
可以把展示信息和分类目录放在同一份配置中:
ts
export interface CategoryPresentation {
type: QuestionType
name: string
subtitle: string
icon: Resource
color: ResourceColor
background: ResourceColor
}
export const CATEGORY_PRESENTATIONS:
CategoryPresentation[] = [
{
type: 'add',
name: '加法练习',
subtitle: '从基础口算开始',
icon: $r('app.media.ic_add'),
color: '#2563EB',
background: '#EFF6FF'
}
]
页面只根据 type 读取配置,不再分别维护四个 switch。这能保证首页、分类页、搜索结果和统计图例的颜色语义一致。
这里还要区分 speed 的业务含义。分类名"限时挑战"对应的是 type === 'speed' 的速度类题目;分类页副标题中的"60秒挑战"是一条展示文案。它不能与完整考试模式的 20、30、45、60 分钟配置混为一谈。一个是题目类型,一个是考试时长,两者需要不同字段。
十、响应式网格与底部安全区是分类页的真实能力
分类页通过 onAreaChange 记录页面宽度,并根据阈值调整卡片宽度:
ts
private categoryCardWidth(): string {
return this.pageWidth >= 720
? '31.5%'
: '47.8%'
}
宽屏每行约三张卡,窄屏每行两张卡。百分比宽度给卡间距留下余量,比固定 vp 宽度更适合手机、平板和窗口化场景。根容器监听面积变化后,折叠、旋转或拖拽窗口都会触发布局重新计算。
底部还需要避开系统导航区域。项目使用系统导航指示区高度与最小安全值取较大者:
ts
private bottomSafePadding(): number {
return Math.max(
PAGE_BOTTOM_MIN,
this.navigationIndicatorHeight
)
}
这部分不是装饰细节。分类列表如果只给固定小间距,最后一行卡片可能进入手势区,造成难以点击或内容被遮挡。多设备验收时至少要检查:
- 手机竖屏两列是否完整;
- 手机横屏和小窗口是否溢出;
- 720vp 以上窗口是否稳定切换三列;
- 最后一张卡是否能滚动到系统导航区上方;
- 长分类名称是否截断或挤压数量标签。
十一、静态"训练建议"不能写成个性化推荐
分类页底部还有"训练建议"区域,展示"课后练习""每日打卡""考前冲刺"等场景。源码中这些是固定文本,没有读取学习记录、正确率、年级或薄弱题型,也没有推荐算法。
因此准确的产品描述应是"提供训练场景提示",而不是"根据学习情况智能推荐训练"。如果后续确实要做个性化,可以定义可核验的输入:
ts
interface TrainingProfile {
bankId: string
weakTypes: QuestionType[]
recentAccuracy: number
dailyCompleted: boolean
}
interface TrainingSuggestion {
type: QuestionType
reason: string
questionCount: number
}
只有当页面根据真实训练记录生成 TrainingSuggestion,并能解释推荐原因时,才适合使用"个性化建议"。静态场景文案本身不等于推荐能力。
同样,页面顶部的"每日练习""错题巩固""限时挑战"芯片当前也是展示元素,不是可点击筛选器。测试时不要把它们列入交互功能,发布说明也不应声称用户可以切换这些模式。
十二、分类页状态与无题分类要显式处理
当前本地目录加载很快,没有网络请求,但分类数量仍可能为零。卡片是否可点击需要明确策略:
ts
private openCategory(cat: Category): void {
if (cat.count <= 0) {
this.promptMessage = '当前分类暂无可用题目'
return
}
router.pushUrl({
url: 'pages/SearchPage',
params: {
categoryType: cat.type,
categoryName: cat.name
}
})
}
如果允许点击零题分类,目标页必须显示清晰空状态,并提供返回分类或选择其他题型的动作;如果不允许点击,则卡片要有禁用样式和可理解原因。仅让点击"没有反应"会被误判为按钮故障。
本地数据也可能因初始化顺序出现计数未同步。分类页出现前应保证 syncCatalogCounts() 已执行,或者把计数改成查询时计算。选择哪一种都可以,关键是不要让可变全局数组的更新时间隐含在某个无关页面里。
十三、测试矩阵要覆盖范围,而不只是点击成功
分类训练的核心验证不是"卡片能打开",而是结果范围是否符合参数。建议用下面的矩阵回归:
| 用例 | 输入 | 预期结果 |
|---|---|---|
| 全局加法 | type=add |
全部题库中的加法题 |
| 二年级加法 | type=add, bankId=grade-2 |
仅二年级加法题 |
| 全局应用题 | type=word |
全部应用题 |
| 未知题型 | type=addition |
拒绝参数或安全降级 |
| 未知题库 | type=add, bankId=missing |
明确提示或按既定策略降级 |
| 零题分类 | 合法 type,结果为空 | 空状态可返回、可换分类 |
| 宽屏布局 | pageWidth>=720 |
三列卡片且无重叠 |
| 窄屏布局 | pageWidth<720 |
两列卡片且文本可读 |
还可以验证"计数与结果一致":
ts
const scope: CategoryScope = {
type: 'add',
bankId: 'grade-2'
}
const count = countQuestions(
scope.type,
scope.bankId
)
const results = findQuestions(scope)
console.info(
`category count=${count}, results=${results.length}`
)
两者数量不同,优先检查是不是一个函数按全部题库计数,另一个函数按单个题库过滤。此类问题通常不是 UI 渲染错误,而是统计口径不一致。
十四、常见问题与排查顺序
| 现象 | 优先检查 | 修复方向 |
|---|---|---|
| 点击"加法"进入空结果 | 路由是否传 add |
不要传中文名或临时序号 |
| 卡片数量与结果数不同 | 计数和过滤的题库范围 | 复用同一个 CategoryScope |
| 二年级入口出现其他年级题 | 是否缺少 bankId |
路由携带题库维度并做交集 |
| 修改分类名后搜索失效 | 是否按 name 判断 |
业务层统一使用稳定 type |
| 新分类没有图标或颜色 | 多处 switch 是否漏改 |
集中 CategoryPresentation |
| 未知参数显示成正常分类 | 是否缺少运行时校验 | 白名单归一化并显式降级 |
| 平板仍显示两列 | onAreaChange 是否更新宽度 |
检查根容器尺寸与阈值 |
| 最后一行被手势区遮挡 | 底部 padding 是否固定 | 合并系统避让区高度 |
| "限时挑战"时长不一致 | 是否混淆题型与考试时长 | 分离 QuestionType 和 duration |
排查时先打印"入口参数、归一化结果、筛选范围、结果数量"四项,不要先改卡片样式。分类错误多数来自数据契约,而不是视觉层。
十五、发布前的可复核清单
在 HarmonyOS 5.0 及以上设备或模拟环境中,可以按以下顺序验收:
- 七种分类都来自同一
CATEGORIES目录; - 首页与分类页使用相同
type,不存在名称映射差异; - 卡片数量由真实题目扫描得到;
- 全局分类的统计口径明确为全部题库;
- 若启用年级范围,计数与搜索都使用同一
bankId; - 搜索页拒绝未知
categoryType; - 训练页继续沿用题型条件,不在跳转时丢失范围;
speed题型没有被误写成固定考试时长;- 720vp 上下切换时列数正确;
- 长名称、零题分类、空结果和返回操作均可用;
- 底部内容不进入系统手势或导航区域;
- 静态训练建议和静态芯片没有被描述成可交互或智能推荐。
总结
分类训练的关键不是多画几张彩色卡片,而是让"目录、路由、查询、训练、统计"共享同一套稳定参数。当前口算王源码已经用 add、sub、mul、div、mixed、word、speed 串起首页、分类页与搜索页,并通过扫描真实题目同步分类数量。
需要继续增强时,应把年级或题库作为独立维度加入 CategoryScope:全局入口只传题型,年级入口同时传 type + bankId,计数和结果都使用相同交集。再配合运行时白名单、统一展示配置、响应式网格和明确空状态,分类训练才能从"看起来能点"变成范围可解释、结果可验证、后续可扩展的 HarmonyOS 工程能力。
本文由 AI 辅助整理,所有功能边界、代码路径与结论均依据项目真实源码复核。