分类页最容易出现一种“每块代码都没错,合起来却说不清”的问题:主页写着“6 大法律分类”,点进去看到的却是“法条选择、情景判断、法律纠错”等 7 种题型;分类数量来自真实题目汇总,但名称、说明、颜色、图标和路由规则分散在不同文件里。新增一个分类时,开发者必须同时修改数据、主题映射、页面说明和搜索逻辑,漏掉任何一处都会产生不一致。

本文基于知律项目 D:\huawei\one19-11、包名 com.jiaweikang.one19 的真实源码,复核 CategoryPage.etsHomePage.etsMockBanks.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 种题型”,要么让页面真正展示六大法律领域。

三、先建立明确的分类维度

不要用 typeregion 这类历史命名猜测业务含义。可以定义:

type CategoryDimension = 'domain' | 'questionType'

interface CategoryRoute {
  dimension: CategoryDimension
  value: string
}

domain 表示民法、劳动、消费等法律领域;questionType 表示选择、判断、案例等学习形式。所有页面标题、统计和路由都必须携带维度,不能只传一个容易碰撞的字符串。

四、历史 ID 不应直接决定展示语义

REGIONS 的 ID 仍是 sichuanyuenortheast 等旧命名,源码注释说明这是为了兼容路由。它们可以继续作为稳定内部 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,否则模型含义会被破坏。

十五、返回路径也要保持筛选上下文

用户从“民法”进入结果列表,再进入题目或题库详情,返回时应仍看到民法筛选。最简单的方式是让搜索页状态保留在路由栈中,不使用会销毁上下文的替换式跳转。

如果页面可能被系统回收,则把 dimensioncategoryId 保留在路由参数或轻量页面状态中。不要把整个分类对象序列化进参数。

十六、当前响应式网格有真实实现

CategoryPage 通过 onAreaChange 记录页面宽度:

private itemWidth(): string {
  return this.pageWidth >= 720 ? '24%' : '48%'
}

小于 720 时两列,大于等于 720 时约四列。FlexWrap.WrapSpaceBetween 让卡片自动换行。这是可复核的多设备适配,不是固定手机布局。

十七、百分比宽度仍需实际窗口验证

四个 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,卡片仍然可点击。用户进入搜索页后会看到空结果。技术上没有崩溃,但体验不完整。

可选择:

  1. 隐藏零题分类;
  2. 展示但禁用,并标注“内容准备中”;
  3. 允许进入空结果页,提供其他分类推荐。

规则应在配置或服务层统一决定,不能由每个页面自行猜测。

二十四、分类数据的来源要分层

建议的数据流是:

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-11com.jiaweikang.one19 的本地源码复核;示例代码用于说明改造方案,不代表当前版本已实现领域与题型双页签、统一分类服务或双维搜索路由。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐