分类页最容易出现一种“每块代码都没错合起来却说不清”的问题主页写着“6 大法律分类”点进去看到的却是“法条选择、情景判断、法律纠错”等 7 种题型分类数量来自真实题目汇总但名称、说明、颜色、图标和路由规则分散在不同文件里。新增一个分类时开发者必须同时修改数据、主题映射、页面说明和搜索逻辑漏掉任何一处都会产生不一致。本文基于知律项目D:\huawei\one19-11、包名com.jiaweikang.one19的真实源码复核CategoryPage.ets、HomePage.ets、MockBanks.ets、公共Category模型、主题常量与SearchPage.ets。当前应用真实存在两套分类维度REGIONS表示民法、劳动法、消费者权益、婚姻法、网络安全、校园法律六个法律领域CATEGORIES表示法条选择、情景判断、法律纠错、案例分析、普法常识、维权指南、权益保护七种题型。文章将先还原这两套模型再给出一份可扩展、可统计、可导航的 ArkTS 分类契约。一、当前应用不是一套分类而是两套法律领域定义在REGIONSexport const REGIONS: Region[] [ { id: sichuan, name: 民法, shortName: 民, cover: ... }, { id: yue, name: 劳动法, shortName: 劳, cover: ... }, { id: northeast, name: 消费者权益, shortName: 消, cover: ... }, { id: shanghai, name: 婚姻法, shortName: 婚, cover: ... }, { id: minnan, name: 网络安全, shortName: 网, cover: ... }, { id: hakka, name: 校园法律, shortName: 校, cover: ... } ]题型定义在CATEGORIESexport const CATEGORIES: Category[] [ { type: vocab, name: 法条选择, count: 0 }, { type: guess, name: 情景判断, count: 0 }, { type: diff, name: 法律纠错, count: 0 }, { type: dialog, name: 案例分析, count: 0 }, { type: culture, name: 普法常识, count: 0 }, { type: proverb, name: 维权指南, count: 0 }, { type: region, name: 权益保护, count: 0 } ]前者回答“学哪类法律”后者回答“用什么题型学习”。它们都是合法分类但不能混为同一个维度。二、主页文案与目标页面发生了语义错位主页快捷入口显示this.EntryItem( , 法律分类, ${REGIONS.length} 大分类, Colors.ORANGE, () router.pushUrl({ url: pages/CategoryPage }) )用户看到的是 6 大法律领域但CategoryPage遍历的是CATEGORIES实际展示 7 种题型。主页“热门分类”也用REGIONS渲染民法、劳动法等横向标签而右侧“全部分类”仍跳转到题型页。这不是渲染错误而是信息架构合同不一致。最小修复要么把入口改成“全部题型 / 7 种题型”要么让页面真正展示六大法律领域。三、先建立明确的分类维度不要用type、region这类历史命名猜测业务含义。可以定义type CategoryDimension domain | questionType interface CategoryRoute { dimension: CategoryDimension value: string }domain表示民法、劳动、消费等法律领域questionType表示选择、判断、案例等学习形式。所有页面标题、统计和路由都必须携带维度不能只传一个容易碰撞的字符串。四、历史 ID 不应直接决定展示语义REGIONS的 ID 仍是sichuan、yue、northeast等旧命名源码注释说明这是为了兼容路由。它们可以继续作为稳定内部 ID但 UI 不应再把它们解释成地区。更稳妥的领域模型是interface LegalDomain { id: string name: string shortName: string bankIds: string[] cover: Resource order: number enabled: boolean }如果后续迁移 ID应提供显式映射不能直接替换已有 ID避免收藏、进度和历史记录失联。五、当前题型计数确实来自题目数据CATEGORIES初始count都是 0但MockBanks.syncCatalogCounts()会遍历所有题库和题目for (const question of questions) { categoryCounts.set( question.type, (categoryCounts.get(question.type) || 0) 1 ) } for (const category of CATEGORIES) { category.count categoryCounts.get(category.type) || 0 }因此分类卡展示的题量不是手写占位数字而是本地题目数组汇总结果。这一点可以从源码复核。但它是在模块加载阶段直接修改导出的全局对象仍有可维护性风险。六、不要让导出常量承担可变状态CATEGORIES声明为const只代表数组引用不能重赋值数组项仍然会被修改。其他页面如果在汇总完成前读取或者测试用例复用模块状态结果会依赖初始化顺序。建议保留不可变配置把计数作为派生结果interface QuestionTypeSpec { id: string name: string description: string icon: Resource accent: string order: number } interface QuestionTypeViewItem extends QuestionTypeSpec { count: number }配置说明“它是什么”聚合函数说明“现在有多少数据”两者不互相修改。七、用纯函数汇总数量function countQuestionTypes(questions: Question[]): Mapstring, number { const counts new Mapstring, number() for (const question of questions) { counts.set(question.type, (counts.get(question.type) || 0) 1) } return counts } function buildQuestionTypeItems( specs: QuestionTypeSpec[], questions: Question[] ): QuestionTypeViewItem[] { const counts countQuestionTypes(questions) return specs.map((spec: QuestionTypeSpec) { return { ...spec, count: counts.get(spec.id) || 0 } as QuestionTypeViewItem }) }纯函数没有隐藏写入输入相同就得到相同结果适合单元测试。数据更新后重新派生即可不需要手动同步多个计数器。八、名称、说明、颜色和图标目前分散当前分类名称在MockBanks.ets图标在questionTypeIcon()颜色在CategoryPage.categoryAccent()说明又在页面中手写七次TypeDesc()。新增题型必须至少修改四处。这种分散最容易产生“卡片出现了但图标走默认值”“搜索能筛选但说明列表没有新增项”等问题。应把这些元数据合并到QuestionTypeSpec。九、单一配置源的 ArkTS 写法export const QUESTION_TYPE_SPECS: QuestionTypeSpec[] [ { id: vocab, name: 法条选择, description: 考查对法律条文、法律术语的理解, icon: $r(app.media.ic_category_vocab), accent: Colors.PRIMARY, order: 10 }, { id: dialog, name: 案例分析, description: 通过案例学习法律维权思路, icon: $r(app.media.ic_category_dialog), accent: Colors.TEAL, order: 40 } ]页面卡片、题型说明、搜索标签和无障碍文案都从同一对象读取。新增题型时只增加配置与真实题目不再维护多个switch。十、默认分支不能掩盖非法题型当前questionTypeIcon()与categoryAccent()都对未知类型返回默认值。UI 不会崩溃但非法题型会悄悄伪装成“法条选择”的图标和主色。开发阶段更适合显式校验function findQuestionTypeSpec(type: string): QuestionTypeSpec | undefined { return QUESTION_TYPE_SPECS.find( (item: QuestionTypeSpec) item.id type ) }未知类型可以记入诊断并跳过或者显示“其他题型”但不能在没有记录的情况下混入现有分类。十一、领域分类也要从题库关系派生每个Bank已有regionIdinterface Bank { id: string regionId: string name: string totalCount: number chapters: Chapter[] }领域题量不需要再手写。可以按bank.regionId汇总bank.totalCount得到民法、劳动法等领域的真实题量function countDomainQuestions(banks: Bank[]): Mapstring, number { const counts new Mapstring, number() for (const bank of banks) { counts.set( bank.regionId, (counts.get(bank.regionId) || 0) bank.totalCount ) } return counts }如果一个领域未来关联多个题库这个聚合仍然有效。十二、分类页应该显式提供维度切换最清楚的 UI 不是把两套分类混在同一个网格而是使用页签或分段控件type CategoryTab domain | questionType State activeTab: CategoryTab domain“法律领域”页签展示民法、劳动、消费等六项“学习题型”页签展示七种题型。主页的“法律分类”直接打开domain案例学习入口可以直接打开questionTypedialog。十三、路由必须传维度和稳定 ID当前题型卡传入router.pushUrl({ url: pages/SearchPage, params: { categoryType: cat.type, categoryName: cat.name } })SearchPage只认识题型参数领域分类不能复用同一字段。建议改为interface CategorySearchParams { dimension: CategoryDimension categoryId: string categoryName: string }categoryName只用于显示真正筛选使用dimension categoryId。名称变化不会破坏收藏、历史或深链。十四、搜索页要按维度执行不同过滤function matchesCategory( question: Question, params: CategorySearchParams, bankById: Mapstring, Bank ): boolean { if (params.dimension questionType) { return question.type params.categoryId } const bank bankById.get(question.bankId) return bank?.regionId params.categoryId }领域筛选需要通过题目的bankId找到题库再判断regionId。不要把领域 ID 填进Question.type否则模型含义会被破坏。十五、返回路径也要保持筛选上下文用户从“民法”进入结果列表再进入题目或题库详情返回时应仍看到民法筛选。最简单的方式是让搜索页状态保留在路由栈中不使用会销毁上下文的替换式跳转。如果页面可能被系统回收则把dimension与categoryId保留在路由参数或轻量页面状态中。不要把整个分类对象序列化进参数。十六、当前响应式网格有真实实现CategoryPage通过onAreaChange记录页面宽度private itemWidth(): string { return this.pageWidth 720 ? 24% : 48% }小于 720 时两列大于等于 720 时约四列。FlexWrap.Wrap与SpaceBetween让卡片自动换行。这是可复核的多设备适配不是固定手机布局。十七、百分比宽度仍需实际窗口验证四个24%加上布局间距通常可以容纳但字体缩放、长名称与不同窗口宽度仍需验证。稳定方案可以让列数先由宽度计算再由约束确定卡片最小宽度private columnCount(): number { if (this.pageWidth 1000) return 4 if (this.pageWidth 600) return 3 return 2 }ArkUI 中还可以结合Grid与模板列避免百分比和SpaceBetween共同计算产生边缘误差。具体组件选择应服从现有项目风格。十八、卡片高度稳定但长文本被省略分类卡固定高度 168名称最多一行并使用省略号。当前七个中文名称都能容纳但未来加入“未成年人网络权益保护”等长名称时用户可能无法看到完整文字。可以允许两行并同步调整无障碍文本Text(item.name) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) .textAlign(TextAlign.Center)卡片高度不要因按压、数量变化或异步加载发生跳动。十九、无障碍信息已经包含名称和题量当前卡片设置.accessibilityText(${cat.name}共${cat.count}题) .accessibilityLevel(yes)这是值得保留的实现。加入双维分类后可把维度也纳入描述例如“法律领域民法共 250 题”避免读屏用户只听到同名项目。二十、图标不要只靠颜色表达分类当前不同题型同时使用图标、名称和颜色识别信息不是只有颜色方向正确。深色模式和高对比度模式仍要检查accent与卡片背景、图标本体之间的可读性。分类配置中的icon应来自统一媒体资源颜色来自主题令牌。不要为每个新增分类在页面里临时画一个字符或使用语义不明确的 emoji。二十一、说明列表应由同一配置生成当前页面手写this.TypeDesc(法条选择, 考查对法律条文、法律术语的理解) this.TypeDesc(情景判断, 生活场景中的能否起诉、能否维权判断)改造后直接遍历ForEach(this.questionTypeItems, (item: QuestionTypeViewItem) { this.TypeDesc(item.name, item.description) }, (item: QuestionTypeViewItem) item.id)卡片与说明天然保持同序、同名。禁用某个题型时两个区域也会一起消失。二十二、排序不能依赖数组偶然顺序当前展示顺序由CATEGORIES数组位置决定。配置中加入order后页面可以显式排序const visibleItems items .filter((item: QuestionTypeViewItem) item.enabled) .sort((a, b) a.order - b.order)如果排序值重复再用稳定 ID 作为次级条件保证不同设备与不同构建结果一致。二十三、零题分类要有产品规则当前计数可能为 0卡片仍然可点击。用户进入搜索页后会看到空结果。技术上没有崩溃但体验不完整。可选择隐藏零题分类展示但禁用并标注“内容准备中”允许进入空结果页提供其他分类推荐。规则应在配置或服务层统一决定不能由每个页面自行猜测。二十四、分类数据的来源要分层建议的数据流是MockBanks / Repository - CategoryService 聚合 - CategoryPage ViewState - 分类卡与说明列表页面不直接修改CATEGORIES也不在 Builder 中计算全量题目。服务层返回已经排序、带计数、可见性明确的视图数据。二十五、页面状态不只有 content当前数据在模块导入时同步可用所以页面直接渲染。未来如果分类来自 RDB 或本地文件迁移应支持type CategoryStatus loading | content | empty | error interface CategoryViewState { status: CategoryStatus domains: DomainViewItem[] questionTypes: QuestionTypeViewItem[] message: string }empty表示数据确实为空error表示加载失败两者不能使用同一文案。错误态提供重试空态提供返回或内容说明。二十六、分类数量必须能追溯到题目不要手写“6 大分类”“7 种题型”“1500 道题”后长期不更新。页面摘要从数组长度与聚合结果派生const domainCount domains.filter(item item.enabled).length const typeCount questionTypes.filter(item item.enabled).length const questionCount banks.reduce( (sum: number, bank: Bank) sum bank.totalCount, 0 )这些是本地数据统计不是平台用户量、热度或学习效果。二十七、避免 O(n²) 的重复过滤当前syncCatalogCounts()对每个章节都调用一次questions.filter()。六个题库、每库 250 题规模不大但可以一次遍历同时汇总题型和章节interface CatalogCounts { typeCounts: Mapstring, number chapterCounts: Mapstring, number } function aggregateQuestions(questions: Question[]): CatalogCounts { const typeCounts new Mapstring, number() const chapterCounts new Mapstring, number() for (const question of questions) { typeCounts.set( question.type, (typeCounts.get(question.type) || 0) 1 ) chapterCounts.set( question.chapterId, (chapterCounts.get(question.chapterId) || 0) 1 ) } return { typeCounts, chapterCounts } }优化重点不是追求一个虚构的毫秒数字而是减少重复扫描让聚合职责更清晰。二十八、缓存必须有明确失效条件本地题目固定时分类聚合可以缓存。题库支持下载、更新或用户自建后缓存必须与数据版本绑定interface CategorySnapshot { dataVersion: string generatedAt: string domains: DomainViewItem[] questionTypes: QuestionTypeViewItem[] }generatedAt只用于诊断是否失效应看dataVersion不能靠“超过一天就重算”这种与数据无关的策略。二十九、测试要覆盖两套维度服务层至少测试六个领域 ID 能映射到正确题库七种题型计数等于真实题目汇总未知question.type不会被默认伪装零题分类遵守可见性规则排序在相同输入下稳定domain路由按bank.regionId过滤questionType路由按question.type过滤名称变化不影响稳定 ID旧领域 ID 迁移后仍能恢复收藏与进度。页面测试再覆盖点击、返回、切换维度和空错误态。三十、多设备验收清单手机竖屏检查两列卡片和长名称横屏与小窗检查列数切换时没有半张卡片平板与 2in1 检查四列布局、鼠标点击与焦点顺序深浅色检查图标、数量、正文和按压态对比度大字体检查标题、说明和题量不会互相覆盖。当前页面使用Scroll长内容可到达但底部只放了固定 24vp 空白没有读取系统底部避让区。若该页面在手势导航区域出现遮挡应按项目其他页面的做法加入导航指示区安全间距。三十一、渐进式改造顺序第一步只修正文案把当前CategoryPage标为“学习题型”主页“全部分类”改成“全部题型”立即消除语义冲突。第二步把题型名称、说明、图标、颜色和顺序收敛到QuestionTypeSpec删除页面手写列表与多个映射switch。第三步用纯函数从真实题目派生计数停止修改导出的全局CATEGORIES。第四步增加领域页签与LegalDomain视图数据让民法、劳动、消费等六类真正可浏览。第五步把dimension categoryId传入搜索服务完成双维筛选与返回恢复。三十二、发布前闭环核验逐项确认“法律领域”和“学习题型”名称不混用首页显示数量与目标页面可见项一致领域与题型都使用稳定 ID名称、说明、颜色、图标、顺序来自单一配置源题量从真实题目数据派生未知类型有明确诊断或兜底分类零题分类有统一交互规则路由同时传维度和 ID搜索结果按正确维度过滤返回后筛选上下文不丢失小窗、平板、2in1、深浅色和大字体完成检查不把本地题量写成平台数据或用户数据。三十三、结语知律当前分类页已经具备不少可复用基础ForEach使用稳定题型 ID分类数量来自题目汇总卡片有按压态和无障碍文本宽度达到 720 时会从两列切换为约四列点击后也能把题型参数传给搜索页。真正需要修正的是分类语义和配置所有权。民法、劳动、消费属于法律领域法条选择、情景判断、案例分析属于题型。把这两个维度分开再让名称、图标、颜色、说明、计数和路由共享同一份 ArkTS 契约新增分类才会从“修改四五个文件的同步任务”变成“一处配置、一次聚合、全链路可验证”的常规扩展。---本文部分内容由 AI 辅助整理。所有现状判断均基于D:\huawei\one19-11中com.jiaweikang.one19的本地源码复核示例代码用于说明改造方案不代表当前版本已实现领域与题型双页签、统一分类服务或双维搜索路由。