【知律|05】HarmonyOS ArkTS 法条搜索实战:实现关键词高亮、空结果与详情跳转
搜索页看起来只是一个输入框、一组结果和一次路由跳转,真正决定可信度的却是数据边界:用户搜到的究竟是法条、题目还是题库?高亮文字是否真的参与了匹配?点击结果后能否打开同一条内容?空结果究竟表示没有数据,还是索引尚未准备好?这些问题如果没有明确答案,页面即使能“搜到东西”,也不能称为可复核的法条搜索。
本文基于知律项目 D:\huawei\one19-11、包名 com.jiaweikang.one19 的真实源码,复核 SearchPage.ets、MockBanks.ets、公共 Question 模型和 PracticePage.ets。当前页面真实实现了本地题库名匹配、题干匹配、题型分类筛选、20 条结果上限、搜索前首页态和空结果态;但它没有独立法条数据模型,没有关键词高亮,也没有法条详情页。题目卡片点击后传入 bankId 和 mode: '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()。
第三种是分类入口:路由参数带入 categoryType 与 categoryName,页面在 aboutToAppear() 自动执行检索:
interface SearchParams {
categoryType?: string
categoryName?: string
}
分类模式不是关键词包含匹配,而是按 Question.type 精确过滤。用户只要编辑输入框,使值不再等于分类名,页面就会清空分类状态,重新回到关键词检索。这是当前代码里值得保留的模式切换规则。
三、四态 UI 已经具备基础骨架
页面通过 searched、bankResults 与 questionResults 组合出三类可见状态:
searched === false:展示热门搜索和提示;- 已搜索且两类结果都为空:展示
NoResult(); - 任一结果非空:展示
ResultList()。
如果升级为真实法条索引,还应增加 loading 与 error,形成完整状态机:
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'
}
})
它没有传 questionId。PracticePage 在随机模式中加载整个题库并洗牌,所以用户点击搜索命中的某道题后,不保证首先看到它。这是集合入口,不是详情入口。
十八、精确题目路由的最小改造
如果暂时不增加法条页,可以先让练习页支持单题预览:
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_id、law_id、article_no、content、normalized_content、version 与更新时间。是否使用全文索引要以 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 使用筛选标签显示当前范围。清除某个标签只修改对应字段,不应通过比较显示字符串来推断用户意图。
二十六、测试必须覆盖高亮边界
至少准备以下本地单元测试:
- 空字符串、全角空格与连续空格回到初始态;
- 关键词位于开头、中间、结尾时范围正确;
- 多次出现的关键词全部高亮;
(、[、+等字符不会造成正则异常;- 无结果返回空数组,不返回推荐伪结果;
- 相似题去重后再执行 20 条截断;
- 结果 ID 能回读同一条题目或法条;
- 过期路由 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-11中com.jiaweikang.one19的本地源码复核;示例改造代码用于说明工程方案,不代表当前版本已经实现独立法条库、关键词高亮或法条详情页。
更多推荐



所有评论(0)