【知律|06】HarmonyOS ArkTS 法律分类实战:让民法、劳动、消费等入口可维护
分类页最容易出现一种“每块代码都没错,合起来却说不清”的问题:主页写着“6 大法律分类”,点进去看到的却是“法条选择、情景判断、法律纠错”等 7 种题型;分类数量来自真实题目汇总,但名称、说明、颜色、图标和路由规则分散在不同文件里。新增一个分类时,开发者必须同时修改数据、主题映射、页面说明和搜索逻辑,漏掉任何一处都会产生不一致。
本文基于知律项目 D:\huawei\one19-11、包名 com.jiaweikang.one19 的真实源码,复核 CategoryPage.ets、HomePage.ets、MockBanks.ets、公共 Category 模型、主题常量与 SearchPage.ets。当前应用真实存在两套分类维度:REGIONS 表示民法、劳动法、消费者权益、婚姻法、网络安全、校园法律六个法律领域;CATEGORIES 表示法条选择、情景判断、法律纠错、案例分析、普法常识、维权指南、权益保护七种题型。文章将先还原这两套模型,再给出一份可扩展、可统计、可导航的 ArkTS 分类契约。

一、当前应用不是一套分类,而是两套
法律领域定义在 REGIONS:
export 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: ... }
]
题型定义在 CATEGORIES:
export 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[]): Map<string, number> {
const counts = new Map<string, 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 已有 regionId:
interface Bank {
id: string
regionId: string
name: string
totalCount: number
chapters: Chapter[]
}
领域题量不需要再手写。可以按 bank.regionId 汇总 bank.totalCount,得到民法、劳动法等领域的真实题量:
function countDomainQuestions(banks: Bank[]): Map<string, number> {
const counts = new Map<string, 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,案例学习入口可以直接打开 questionType=dialog。

十三、路由必须传维度和稳定 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: Map<string, 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: Map<string, number>
chapterCounts: Map<string, number>
}
function aggregateQuestions(questions: Question[]): CatalogCounts {
const typeCounts = new Map<string, number>()
const chapterCounts = new Map<string, 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的本地源码复核;示例代码用于说明改造方案,不代表当前版本已实现领域与题型双页签、统一分类服务或双维搜索路由。
更多推荐




所有评论(0)