搜索页看起来只是一个输入框、一组结果和一次路由跳转,真正决定可信度的却是数据边界:用户搜到的究竟是法条、题目还是题库?高亮文字是否真的参与了匹配?点击结果后能否打开同一条内容?空结果究竟表示没有数据,还是索引尚未准备好?这些问题如果没有明确答案,页面即使能“搜到东西”,也不能称为可复核的法条搜索。

本文基于知律项目 D:\huawei\one19-11、包名 com.jiaweikang.one19 的真实源码,复核 SearchPage.etsMockBanks.ets、公共 Question 模型和 PracticePage.ets。当前页面真实实现了本地题库名匹配、题干匹配、题型分类筛选、20 条结果上限、搜索前首页态和空结果态;但它没有独立法条数据模型,没有关键词高亮,也没有法条详情页。题目卡片点击后传入 bankIdmode: 'random',进入的是整套题库随机练习,而不是命中的具体题目。本文先还原这些事实,再设计一条可落地的 HarmonyOS 5.0+ 本地搜索链路。

法条搜索可信链路封面

一、先给“法条搜索”划清能力边界

当前 SearchPage 导入的数据是:

import { BANKS, REGIONS, getQuestions } from '../mock/MockBanks'

BANKS 是六个法律主题题库,getQuestions 返回题目。搜索范围只有题库名称与题干:

this.bankResults = this.categoryType.length > 0
  ? []
  : BANKS.filter(bank => bank.name.toLowerCase().includes(keyword))

if (question.stem.toLowerCase().includes(keyword)) {
  questionResults.push(question)
}

所以当前能力更准确的名称是“法律题库与题目搜索”。题目解析中虽然包含《民法典》第 143 条等引用,但 doSearch() 并不检索 analysis,也不存在法条正文、效力层级、发布机关、生效日期等字段。文章中的升级方案不会把题目解析伪装成完整法规数据库。

二、当前页面有三种真实入口

第一种是普通关键词:用户输入文字,点击“搜索”或触发输入法提交。

第二种是热门搜索:点击 REGIONS 中的“民法”“劳动法”“网络安全”等分区名,写入 keyword 后调用 doSearch()

第三种是分类入口:路由参数带入 categoryTypecategoryName,页面在 aboutToAppear() 自动执行检索:

interface SearchParams {
  categoryType?: string
  categoryName?: string
}

分类模式不是关键词包含匹配,而是按 Question.type 精确过滤。用户只要编辑输入框,使值不再等于分类名,页面就会清空分类状态,重新回到关键词检索。这是当前代码里值得保留的模式切换规则。

三、四态 UI 已经具备基础骨架

页面通过 searchedbankResultsquestionResults 组合出三类可见状态:

  1. searched === false:展示热门搜索和提示;
  2. 已搜索且两类结果都为空:展示 NoResult()
  3. 任一结果非空:展示 ResultList()

如果升级为真实法条索引,还应增加 loadingerror,形成完整状态机:

type SearchStatus = 'idle' | 'loading' | 'content' | 'empty' | 'error'

interface SearchViewState {
  status: SearchStatus
  query: string
  items: SearchResultItem[]
  message: string
}

本地小数据检索通常很快,但索引首次构建、文件读取或数据库迁移仍可能失败。不能把“还没加载完”和“确实没有结果”渲染成同一张空图。

四、空输入处理是正确的,但语义还可更明确

当前逻辑会对关键词执行 trim()。当关键词为空且没有分类条件时,它清空结果并把 searched 设为 false

if (this.keyword.trim().length === 0 && this.categoryType.length === 0) {
  this.bankResults = []
  this.questionResults = []
  this.searched = false
  return
}

这避免了空字符串与所有题库名、题干都匹配的问题。升级时仍应保留这一规则,并把规范化后的查询作为唯一检索输入,避免显示值、匹配值和高亮值各自处理后产生偏差。

五、中文搜索不能只依赖 toLowerCase

toLowerCase() 对英文大小写有用,对“民法典”“劳动合同”这类中文词没有规范化效果。更稳妥的本地查询函数至少应处理首尾空白、连续空白与全角空格:

function normalizeQuery(value: string): string {
  return value
    .trim()
    .replace(/\u3000/g, ' ')
    .replace(/\s+/g, ' ')
    .toLowerCase()
}

如果产品要支持“第143条”和“第 143 条”互相命中,可以再建立专用法条编号规范化函数,但不要直接删除正文中的全部标点。过度规范化可能让原本不同的条款编号或金额表达式碰撞。

六、当前检索复杂度可以接受

MockBanks 固定生成 6 个题库,每个题库扩展到 250 道题,总量约 1500。每次搜索会遍历全部题库与题目,并在累计 20 条后停止。这个数量级在本地内存中通常足够轻量,不需要为了“性能”立即引入复杂数据库。

需要注意的是,题目扩展使用多个情景前缀循环生成变体。同一基础题可能出现“真实案例”“实务考点”“强化训练”等版本。直接按题干包含匹配,会得到语义高度相似的多个结果。结果数上限保护了 UI,却没有解决重复内容挤占前 20 条的问题。

七、先去重,再截断

当前代码在发现第 20 条时立即 break,因此结果取决于题库与题目数组顺序。建议为搜索结果建立稳定去重键:

function baseStem(stem: string): string {
  return stem.replace(/^【[^】]+】\s*/, '').trim()
}

function dedupeQuestions(items: Question[]): Question[] {
  const seen = new Set<string>()
  const result: Question[] = []
  for (const item of items) {
    const key = `${item.bankId}:${baseStem(item.stem)}`
    if (seen.has(key)) {
      continue
    }
    seen.add(key)
    result.push(item)
  }
  return result
}

这里的去重只针对当前模拟题扩展规则。真实法条不能只按正文去重,而应使用法规 ID、条款 ID 与版本 ID,防止把修订前后的不同文本误合并。

八、搜索结果需要统一模型

目前页面分别维护 Bank[]Question[]。当未来加入法规、法条、案例与办事指南时,继续增加数组会让排序和状态管理越来越分散。可以在服务层统一为:

type SearchResultKind = 'bank' | 'question' | 'law'

interface SearchResultItem {
  id: string
  kind: SearchResultKind
  title: string
  summary: string
  sourceName: string
  score: number
  route: SearchRoute
}

interface SearchRoute {
  url: string
  params: Record<string, string>
}

id 必须对应可稳定回读的数据实体;score 只用于本地排序,不能显示为平台热度;route 必须能打开当前命中项,而不是只打开它所属的集合。

九、真正的法条模型不能复用 Question

当前公共 Question 包含题干、选项、正确答案和解析:

interface Question {
  id: string
  bankId: string
  chapterId: string
  type: string
  stem: string
  options: Option[]
  answer: string
  analysis: string
}

法条至少需要法规名称、条号、正文、版本与效力信息:

interface LawArticle {
  id: string
  lawId: string
  lawName: string
  articleNo: string
  content: string
  keywords: string[]
  effectiveFrom: string
  version: string
}

如果数据没有官方来源与版本信息,页面应明确标注为学习材料或题目解析,不能对外宣称“现行有效法条全文”。

十、关键词高亮必须来自匹配范围

当前 QuestionResultCard 直接渲染:

Text(question.stem)

没有任何 Span 或分段文本,因此目前不存在关键词高亮。改造时不要在 UI 层临时重复查找,而应由搜索服务返回高亮范围:

interface HighlightRange {
  start: number
  end: number
}

interface HighlightedText {
  text: string
  ranges: HighlightRange[]
}

同一份规范化规则必须同时参与匹配和范围计算。否则用户可能看到结果被命中,却找不到高亮,或者高亮偏移到错误字符。

十一、ArkUI 中安全渲染高亮片段

对于单个关键词,可以先把文本切成普通片段和命中片段:

interface TextSegment {
  text: string
  highlighted: boolean
}

function splitByKeyword(text: string, keyword: string): TextSegment[] {
  const query = keyword.trim()
  if (!query) {
    return [{ text, highlighted: false }]
  }

  const source = text.toLowerCase()
  const target = query.toLowerCase()
  const segments: TextSegment[] = []
  let cursor = 0
  let index = source.indexOf(target)

  while (index >= 0) {
    if (index > cursor) {
      segments.push({ text: text.slice(cursor, index), highlighted: false })
    }
    segments.push({
      text: text.slice(index, index + query.length),
      highlighted: true
    })
    cursor = index + query.length
    index = source.indexOf(target, cursor)
  }

  if (cursor < text.length) {
    segments.push({ text: text.slice(cursor), highlighted: false })
  }
  return segments
}

这段方案适合当前中英文混合题干。若加入多关键词、同义词或拼音匹配,应在索引层计算原文偏移,避免 UI 猜测命中位置。

十二、用 Text 与 Span 保持可访问文本

ArkUI 可以在一个 Text 容器中渲染多个 Span

@Builder
HighlightedTitle(text: string, keyword: string) {
  Text() {
    ForEach(splitByKeyword(text, keyword), (segment: TextSegment) => {
      Span(segment.text)
        .fontColor(segment.highlighted
          ? Colors.PRIMARY
          : Colors.TEXT_PRIMARY)
        .fontWeight(segment.highlighted
          ? FontWeight.Bold
          : FontWeight.Medium)
    })
  }
  .fontSize(Sizes.BODY_FONT)
  .lineHeight(22)
  .maxLines(3)
  .textOverflow({ overflow: TextOverflow.Ellipsis })
}

高亮不应只靠低对比度背景色。正文与背景仍要满足可读性,命中项可以同时使用字重和主题色,深浅色模式下都从资源或主题令牌取值。

搜索输入到精确详情的流程

十三、不要把用户输入当成正则表达式

有些实现会直接写 new RegExp(keyword, 'gi')。当用户输入 ([+ 等字符时,表达式可能报错或改变匹配含义。当前需求只需要字面量包含匹配,indexOf 更安全。

如果确实要使用正则,必须先转义元字符,并对超长输入设置上限。法律文本中括号、点号和条款编号很常见,这个边界不能忽略。

十四、相关性排序要可解释

当前结果顺序就是数据顺序。一个简单、可复核的本地评分可以是:

function scoreQuestion(question: Question, query: string): number {
  const stem = normalizeQuery(question.stem)
  const analysis = normalizeQuery(question.analysis)
  if (stem === query) return 100
  if (stem.startsWith(query)) return 80
  if (stem.includes(query)) return 60
  if (analysis.includes(query)) return 30
  return 0
}

题干完全匹配优先,其次是前缀、包含和解析命中。分值是排序权重,不是正确率、热度或权威等级,UI 没有必要把它展示给用户。

十五、空结果要告诉用户“搜了什么”

当前空结果文案是“未找到相关内容”“换个关键词试试”,功能上成立,但缺少查询上下文。可以改为:

Text(`未找到“${this.viewState.query}”相关内容`)
Text('可缩短关键词,或尝试法规名称、条款编号和主题词')

同时保留清空操作和热门主题入口。不要在空结果时展示虚构推荐数量,也不要为了显得“有内容”而返回不相关题目。

十六、初始态与空结果态不能混用

用户刚进入页面时没有搜索行为,此时展示热门主题合理;用户执行搜索后没有结果,才应展示空结果。当前 searched 已经区分了二者。

升级为状态机后,idle 对应初始态,empty 对应有效查询后的零结果。清空输入时回到 idle,而不是保留上一轮“未找到”的提示。

十七、当前题目点击不是详情跳转

源码中的点击事件是:

router.pushUrl({
  url: 'pages/PracticePage',
  params: {
    bankId: question.bankId,
    mode: 'random'
  }
})

它没有传 questionIdPracticePage 在随机模式中加载整个题库并洗牌,所以用户点击搜索命中的某道题后,不保证首先看到它。这是集合入口,不是详情入口。

十八、精确题目路由的最小改造

如果暂时不增加法条页,可以先让练习页支持单题预览:

interface PracticeParams {
  bankId: string
  chapterId?: string
  questionId?: string
  mode: string
}

搜索结果点击时传入稳定题目 ID:

router.pushUrl({
  url: 'pages/PracticePage',
  params: {
    bankId: question.bankId,
    questionId: question.id,
    mode: 'preview'
  }
})

练习页通过 bankId 获取题库后再按 questionId 查找,找不到时进入明确错误态,不能静默回退到随机题库,否则用户仍会看到不一致内容。

十九、独立法条页要使用 lawId 与 articleId

当产品加入真实法规数据后,路由契约应避免传完整正文:

interface LawDetailParams {
  lawId: string
  articleId: string
}

详情页根据 ID 从同一数据仓库回读内容。这样既减少路由载荷,也能保证收藏、历史和分享都引用同一条记录。法条版本变化时,可以额外传 versionId 或由仓库解析当前版本。

二十、路由参数需要运行时校验

ArkTS 接口只提供编译期约束,外部路由参数仍可能缺失。详情页应先验证:

function parseLawDetailParams(
  params: LawDetailParams | undefined
): LawDetailParams | undefined {
  if (!params || !params.lawId || !params.articleId) {
    return undefined
  }
  return params
}

无效参数要展示“内容不存在或已更新”,并提供返回操作。不要用空字符串查询仓库后展示第一条数据。

二十一、把检索从页面移到服务层

当前 doSearch() 同时负责输入判定、遍历数据、分类逻辑、数量上限与状态写入。数据继续扩展后,页面会越来越难测试。建议拆成:

interface SearchOptions {
  query: string
  categoryType?: string
  limit: number
}

class LegalSearchService {
  search(options: SearchOptions): SearchResultItem[] {
    const query = normalizeQuery(options.query)
    // 读取仓库、匹配、去重、评分、排序、截断
    return []
  }
}

页面只负责收集输入、调用服务、更新 SearchViewState 和渲染。索引与数据读取再通过 Repository 隔离,符合 Page -> Service -> Repository 的职责方向。

二十二、小数据内存索引与大数据 RDB 的选择

当前约 1500 道本地题目,应用启动后已经在内存中生成,直接遍历最简单。若法规正文扩展到数万条、需要多字段排序和版本管理,才值得评估关系型数据库。

RDB 表可以包含 article_idlaw_idarticle_nocontentnormalized_contentversion 与更新时间。是否使用全文索引要以 HarmonyOS 当前版本官方 API 能力为准;没有确认 API 时,不应在文章里承诺不存在的全文检索接口。

二十三、索引构建不能阻塞首帧

如果法条数据来自本地 JSON 或数据库迁移,首次构建索引应放在服务层异步执行,并让 UI 进入 loading。完成后只提交最终结果给状态。

还要处理查询竞态:用户快速输入“劳动”后又输入“劳动合同”,较慢的第一轮结果不应覆盖第二轮。可以使用递增请求号:

private searchRequestId: number = 0

private async runSearch(query: string): Promise<void> {
  const requestId = ++this.searchRequestId
  this.viewState = { status: 'loading', query, items: [], message: '' }
  const items = await this.searchService.searchAsync(query)
  if (requestId !== this.searchRequestId) {
    return
  }
  this.viewState = {
    status: items.length > 0 ? 'content' : 'empty',
    query,
    items,
    message: ''
  }
}

即使当前检索是同步的,保留清晰的请求所有权也能为后续数据规模增长留出空间。

搜索能力四层职责结构

二十四、输入提交与按钮点击要走同一方法

当前输入法提交和“搜索”按钮都调用 doSearch(),这是正确做法。升级后也应只保留一个入口,例如 submitSearch(),集中完成规范化、空值判断与服务调用。

热门主题点击、分类页自动进入和历史搜索回填也要复用这个入口,避免四种入口产生四种状态转换。

二十五、分类筛选与关键词筛选可以组合

当前分类模式下只比较 question.type,不再使用关键词。当用户编辑分类名时,页面直接退出分类模式。这个交互简单,但无法表达“只在案例分析中搜索合同”。

如果产品需要组合筛选,可以把查询条件建模为:

interface SearchFilter {
  query: string
  categoryTypes: string[]
  bankIds: string[]
}

筛选条件作为结构化数据参与服务调用,UI 使用筛选标签显示当前范围。清除某个标签只修改对应字段,不应通过比较显示字符串来推断用户意图。

二十六、测试必须覆盖高亮边界

至少准备以下本地单元测试:

  1. 空字符串、全角空格与连续空格回到初始态;
  2. 关键词位于开头、中间、结尾时范围正确;
  3. 多次出现的关键词全部高亮;
  4. ([+ 等字符不会造成正则异常;
  5. 无结果返回空数组,不返回推荐伪结果;
  6. 相似题去重后再执行 20 条截断;
  7. 结果 ID 能回读同一条题目或法条;
  8. 过期路由 ID进入错误态。

这些测试可以在纯 ArkTS 服务层完成,不依赖 ArkUI 页面实例。

二十七、UI 验收要覆盖长文本与安全区

当前页面已经读取顶部避让区和底部导航指示区,并在结果列表底部增加安全间距。继续改造时要验证:

  • 手机竖屏下长法条标题最多显示几行;
  • 横屏、小窗和平板宽度下结果卡片是否过宽;
  • 深色模式中高亮色与正文色是否都可读;
  • 输入法弹出后最后一条结果能否滚动到可见区域;
  • 空结果图、文案和返回操作是否被系统导航区遮挡;
  • 连续点击同一结果是否重复压入多个详情页。

高亮样式不能改变文本容器稳定高度,否则结果滚动时会发生明显跳动。

二十八、法律内容还需要版本与来源提示

技术上“搜得到”不等于内容可以被当作法律依据。真实法条详情至少要展示法规名称、条号、内容来源、版本或更新时间,并在数据过期时有更新策略。

当前模拟题解析中出现法律名称和条号,适合作为普法练习材料,但模型里没有来源 URL、公布机关和版本字段。因此本阶段只能如实定位为本地学习题库搜索,不能写成联网法规查询,也不能声称覆盖全部现行法律。

二十九、性能指标要从真实测量产生

当前源码没有搜索耗时埋点,也没有平台 PV、点击率或转化率数据。文章不虚构“毫秒级”“命中率 99%”等数字。

可以在开发阶段记录本地指标:

const startedAt = Date.now()
const items = service.search(options)
const elapsedMs = Date.now() - startedAt

这些数据只用于调试或性能测试,不应未经统计设计就展示给用户。发布文章时也不把单次设备测量包装成平台结论。

三十、渐进式落地顺序

第一步,保留现有题库与题干搜索,增加统一 SearchResultItem 和可测试的 LegalSearchService

第二步,先实现安全高亮、去重、排序和完整四态 UI,不改变数据来源。

第三步,为题目结果增加 questionId 精确路由,修复“点击命中题目却进入随机整库”的语义偏差。

第四步,在来源与版本可核验后新增 LawArticle、仓库和详情页,再把能力名称升级为真正的法条搜索。

第五步,根据真实数据规模决定继续内存遍历还是迁移到 RDB,不提前引入不必要的复杂度。

三十一、闭环验收清单

交付前逐项确认:

  • 输入框、按钮、热门主题和分类入口复用同一搜索方法;
  • 空输入回到初始态,有效查询零结果进入空结果态;
  • 匹配、高亮与排序使用同一份规范化查询;
  • 题目变体去重发生在 20 条截断之前;
  • 高亮不会被特殊字符破坏;
  • 每条结果携带稳定 ID;
  • 点击结果能打开同一条内容,而不是随机集合;
  • 无效详情参数有明确错误态与返回动作;
  • 法条数据具备来源和版本字段后才对外称为法条;
  • 深浅色、长文本、小窗、平板和系统安全区均完成检查;
  • 不展示虚构热度、排名、搜索耗时或覆盖率。

三十二、结语

知律现有 SearchPage 已经有一个可用的本地检索骨架:数据全部来自本地 MockBanks,关键词匹配和分类筛选逻辑清楚,初始态、空结果态、列表态与安全区适配也能从源码复核。它当前的真实边界同样明确:搜的是题库和题干,没有关键词高亮,没有独立法条模型,点击题目进入的是随机练习而非精确详情。

可靠的升级路线不是在卡片上加一块彩色背景,而是让数据实体、匹配范围、展示高亮和详情路由共享同一份契约。先用稳定 ID 保证“搜到什么就打开什么”,再补来源、版本与法条正文,搜索能力才会从一个能用的题库入口,成长为可解释、可测试、可复现的 HarmonyOS 法律内容检索链路。

---

本文部分内容由 AI 辅助整理。所有现状判断均基于 D:\huawei\one19-11com.jiaweikang.one19 的本地源码复核;示例改造代码用于说明工程方案,不代表当前版本已经实现独立法条库、关键词高亮或法条详情页。

Logo

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

更多推荐