英语纠错页看起来只是一个输入框加一个结果列表,但真正写到 HarmonyOS 5.0+ ArkTS 页面里,容易出现几个实际问题:用户输入长句后页面挤压,示例句点击后旧结果还留在页面上,空输入时没有明确反馈,规则匹配到了“已经正确”的表达也被当成错误展示,结果卡片把错误片段、修改建议和解释混在一起,最后用户看不出到底该改哪里、为什么改。对学习工具来说,纠错页不是“显示一段答案”,而是要把原句、建议和解释层级展示清楚。
本文基于「句匠」真实源码复核,包名标记为com.jiaweikang.one18。核心文件是entry/src/main/ets/pages/AICorrectPage.ets。需要先说明边界:当前页面标题里写了“AI 纠错练习”,但源码实现是本地规则版,使用RegExp规则匹配中式英语、拼写、介词、时态、冠词和第三人称单数等高频错误;本文不会把它写成云端大模型、联网推理或服务端纠错能力。module.json5中虽然声明了ohos.permission.INTERNET,但本文所讨论的AICorrectPage纠错结果来自本地RULES数组。
1. 页面要解决的问题:纠错不是一个字符串,而是一组结构化建议
如果纠错结果只返回一段文本,例如“这里应该改成 like very much”,用户需要自己找原句中的位置,也看不出规则类型。当前源码没有这样做,而是定义了CorrectionItem:
interface CorrectionItem { rule: string before: string after: string reason: string }这个结构把纠错建议拆成四层。rule是错误类型,例如副词搭配、固定表达、介词搭配、时态错误;before是原句中匹配到的错误片段;after是建议替换片段;reason是面向学习者的解释。页面渲染时就能分别展示标签、删除线、绿色建议和解释文本。
这种结构比直接拼接字符串更稳。后续如果要把纠错结果保存到学习记录、加入错题本、做分类型统计,或者给每条建议加“我已掌握”状态,只需要扩展CorrectionItem,不需要重新解析一段自然语言结果。
2. 规则层:RulePattern 把匹配和构造结果绑定在一起
源码里第二个关键接口是RulePattern:
interface RulePattern { pattern: RegExp build: (m: RegExpMatchArray) => CorrectionItem }它的设计目标很明确:pattern只负责发现问题,build负责把匹配结果转换成结构化建议。例如very like这类中式英语问题,规则写成:
{ pattern: /\bvery\s+like\b/i, build: (m: RegExpMatchArray) => { return { rule: '副词搭配', before: m[0], after: 'like ... very much', reason: 'very 不能直接修饰一般动词,常用 very much 后置。' } as CorrectionItem } }这里的工程价值不是正则本身,而是“发现问题”和“解释问题”放在同一条规则里。这样页面不用知道very like为什么错,也不用根据规则名再查一次说明。每条规则都自带展示所需的信息,ResultCard只负责渲染。
当前规则覆盖的类型包括:
| 规则方向 | 示例 |
|---|---|
| 中式英语 | I very like the book. |
| 固定表达 | I no idea. |
| 主谓一致 | She go to school every day. |
| 介词搭配 | interested on、good in、depend of |
| 高频拼写 | recieve、tomorow、seperate |
| 时态错误 | I yesterday go、since 2020 I live |
| 冠词使用 | a honest、an university |
这些都是本地规则能复核的内容,不能被扩大描述为“覆盖所有语法错误”。
3. 状态层:input、results、analyzed 三个状态各司其职
AICorrectPage的页面状态很少,但职责清晰:
@State input: string = '' @State results: CorrectionItem[] = [] @State analyzed: boolean = false @StorageLink('navigationIndicatorHeightPx') navigationIndicatorHeightPx: number = 0input是用户正在编辑的原句;results是分析后的结构化结果;analyzed决定是否展示结果卡片。这里最关键的是analyzed。如果只靠results.length判断是否展示结果,空输入、无错误和未分析三种状态会混在一起:它们的结果数组都可能是空的,但用户含义完全不同。
当前源码用analyzed把这三种状态拆开:
| 状态 | input | results | analyzed | 页面含义 |
|---|---|---|---|---|
| 初始 | 空 | 空 | false | 不显示结果 |
| 空输入后点击分析 | 空 | 空 | true | 显示分析后的空结果 |
| 输入但规则未命中 | 非空 | 空 | true | 显示未发现明显错误 |
| 输入且命中规则 | 非空 | 非空 | true | 显示建议列表 |
| 输入变化后 | 变化中 | 旧值清空或隐藏 | false | 等待重新分析 |
这个设计能避免一个常见 UI 问题:用户改了输入句子,页面仍显示上一句的纠错建议。源码在TextArea.onChange中把analyzed重置为 false,就是为了让结果和当前输入保持一致。
4. 输入卡片:TextArea 承载原句,按钮只做两件事
输入区域使用TextArea,而不是TextInput。这符合英语句子纠错的场景,因为用户可能粘贴长句、复合句或多个短句。源码中设置了占位文本、字体颜色、背景色、圆角、padding、高度和宽度:
TextArea({ placeholder: 'e.g. I very like the book and I want to recieve it tomorow.', text: this.input }) .placeholderColor(Colors.INPUT_PLACEHOLDER) .fontColor(Colors.INPUT_TEXT) .fontSize(Sizes.BODY_FONT) .backgroundColor(Colors.BACKGROUND_ALT) .borderRadius(12) .padding(12) .height(120) .width('100%') .onChange((v: string) => { this.input = v; this.analyzed = false })这段代码有两个发布级细节。第一,输入框颜色使用Colors.INPUT_*,不是系统默认颜色,能降低深色模式下输入文字不可读的风险。第二,输入变化只更新input和analyzed,不立刻执行分析,避免每次输入都遍历规则并造成结果跳动。
按钮区只有“清空”和“开始纠错”两个动作。清空时同步重置输入、结果和分析状态:
.onClick(() => { this.input = '' this.results = [] this.analyzed = false })开始纠错只调用analyze()。这种按钮职责很清楚,测试时也容易覆盖:清空后不应显示旧结果;输入变化后旧结果隐藏;点击分析后才展示当前句子的结果。
5. analyze:空输入、规则遍历和结果过滤
页面核心方法是analyze():
private analyze(): void { const text = this.input const items: CorrectionItem[] = [] if (text.trim().length === 0) { this.results = [] this.analyzed = true return } for (const r of RULES) { const m = text.match(r.pattern) if (m) { const item = r.build(m) if (item.before !== item.after) items.push(item) } } this.results = items this.analyzed = true }这里有三个值得保留的工程判断。
第一,空输入不是异常。用户点击“开始纠错”但没有输入内容时,页面把results置空,并把analyzed设为 true。这样 UI 可以明确进入“已分析但没有建议”的状态,而不是保持无反馈。
第二,规则遍历是本地同步逻辑。当前RULES数量有限,适合在页面方法里直接遍历。后续如果规则数量变大,或者要加入网络纠错,就应该把规则引擎抽到 service 层,但当前源码还没有这个复杂度。
第三,只保留before !== after的建议。冠词规则里有些情况会返回“已经正确”的表达,如果不做过滤,页面会显示没有实际改动的建议。这个过滤让结果列表只包含真正需要用户关注的变化。
6. 示例句:fillExample 重置状态,避免示例和旧结果混在一起
示例卡片提供五条句子:
this.ExampleRow('I very like the book.') this.ExampleRow('She go to school every day.') this.ExampleRow('I am interested on music.') this.ExampleRow('I will recieve it tomorow.') this.ExampleRow('I yesterday go to park.')点击示例时调用fillExample:
private fillExample(s: string): void { this.input = s this.analyzed = false this.results = [] }这一步很关键。示例句本质上是快捷输入,不是自动分析。如果用户刚分析过上一句,再点一个示例,旧结果必须清空,否则用户会看到“输入已经变了,建议还是上一句”的错位状态。
示例卡片也用了单行省略:
Text(s) .layoutWeight(1) .fontSize(Sizes.BODY_FONT) .fontColor(Colors.TEXT_SECONDARY) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis })这保证较长示例不会挤掉右侧“试用”按钮。对手机、小窗和 2in1 窄窗口来说,这类文本约束是页面稳定性的基础。
7. ResultCard:先区分无建议,再渲染建议列表
结果卡片只有在analyzed为 true 时显示。进入卡片后,再根据results.length分支。没有建议时,页面展示“没有发现明显错误”的提示,并说明当前本地规则库覆盖范围有限:
if (this.results.length === 0) { Column({ space: 6 }) { Text('恭喜,没有发现明显错误') Text('当前 AI 助教使用本地规则库,覆盖高频中式英语、拼写、介词、时态、冠词、第三人称单数等常见错误。如果句子较短或类型未覆盖,也可能不会给出建议。') } }这段文案很重要,因为它没有夸大能力。规则没有命中,不等于句子绝对正确。页面明确告诉用户“类型未覆盖也可能不会给出建议”,这比直接写“句子完全正确”更符合本地规则版的边界。
有建议时,页面用ForEach渲染每条CorrectionItem:
ForEach(this.results, (r: CorrectionItem, i: number) => { this.CorrectionItemView(r, i + 1) }, (r: CorrectionItem, i: number) => `c_${i}_${r.before}`)key 使用序号加before,能区分同一结果列表中的不同建议。虽然当前analyze()对每条规则只取一次text.match,不会列出同一规则的多个命中,但这个 key 至少避免纯 index 在内容变化时完全失去业务含义。
8. CorrectionItemView:错误、建议、解释三层分开
每条纠错建议由CorrectionItemView渲染。它先显示规则标签,再显示错误片段、建议片段和解释原因。核心结构可以简化为:
Text(`#${idx} ${r.rule}`) Text(r.before).decoration({ type: TextDecorationType.LineThrough }) Text(r.after).fontWeight(FontWeight.Bold) Text(r.reason).lineHeight(18)这几个视觉选择服务于同一个目标:让用户按顺序读懂“错在哪里、改成什么、为什么”。before使用删除线和错误色;after使用成功色和加粗;reason使用较小字体和固定行高,承担学习解释而不是抢占视觉焦点。
如果把这三层放在一段普通文本里,用户很难快速定位。分层展示后,每条建议都像一个小型学习卡片,尤其适合手机屏幕上的连续阅读。
这里还要注意maxLines(2)。错误片段和建议片段最多两行,能避免某条长建议撑爆整个卡片。解释文本则允许用lineHeight(18)保持阅读节奏。
9. 本地规则版的边界:不要把页面写成云端纠错
当前AICorrectPage有“AI 助教”的产品表达,但源码层是本地规则库。这个边界要在技术文章和发布材料中讲清楚。
| 能力 | 当前源码是否支撑 | 说明 |
|---|---|---|
| 本地高频语法纠错 | 支撑 | RULES中有 RegExp 和解释 |
| 拼写纠错 | 支撑部分高频词 | 如receive、tomorrow、environment |
| 介词搭配 | 支撑部分固定搭配 | 如interested in、good at |
| 任意长文润色 | 不支撑 | 没有通用模型或长文本重写 |
| 云端 AI 纠错 | 未在此页体现 | 页面逻辑没有请求服务端 |
| 多语言翻译 | 不支撑 | 当前只围绕英文错误建议 |
module.json5里声明了ohos.permission.INTERNET,这说明应用级别存在联网权限配置。但就本页源码而言,AICorrectPage没有发起 HTTP、WebView 或外部模型调用。写文章时必须把“应用权限声明”和“当前页面实现”分开,不能因为页面标题含 AI 就虚构云端推理链路,也不能因为应用声明 INTERNET 就把本地规则页写成在线服务。
10. 验证清单:输入、示例、空态和多结果都要测
围绕当前源码,纠错页至少要覆盖下面这些测试:
| 测试项 | 操作 | 预期 |
|---|---|---|
| 空输入 | 不输入直接点开始纠错 | 显示已分析状态,不崩溃 |
| 示例填充 | 点击I very like the book. | 输入框更新,旧结果清空 |
| 单条命中 | 点击分析 | 显示副词搭配建议 |
| 多条命中 | 输入I very like it and recieve it tomorow. | 显示多条建议 |
| 无命中 | 输入规则未覆盖句子 | 显示本地规则覆盖范围提示 |
| 清空 | 点击清空 | 输入、结果和 analyzed 全部重置 |
| 长句 | 粘贴较长英文句 | TextArea 可滚动或保持布局,不遮挡按钮 |
| 底部安全区 | 手势导航设备 | 页面底部 Blank 使用bottomSafePadding()留出空间 |
这些测试都能从源码推导,不需要伪造线上接口结果。尤其是“无命中”场景,应该确认文案没有误导用户认为句子绝对正确。
11. 常见问题和修正方式
问题一:输入变化后旧建议仍显示。检查TextArea.onChange是否把analyzed设为 false。如果只改input,结果卡片会继续展示旧建议。
问题二:示例句填入后立即显示上一句结果。检查fillExample是否同步清空results。示例填充不是分析动作,应该等待用户点击“开始纠错”。
问题三:规则返回了没有变化的建议。检查analyze()中的item.before !== item.after过滤。冠词规则这类可能返回“已正确”的场景,不应进入建议列表。
问题四:错误、建议和解释混在一起不好读。把CorrectionItemView拆成规则标签、错误片段、建议片段和解释文本四个视觉层级,错误用删除线,建议用成功色,解释使用较小字号。
问题五:产品文案夸大为在线 AI。以源码为准。当前页面是本地规则版,可以说“本地规则纠错”“AI 助教式体验”,但不能说“云端大模型实时改写”或“覆盖所有语法错误”。
12. 小结:清晰的纠错页来自结构化结果,而不是更长的提示词
「句匠」当前AICorrectPage的关键点不是算法复杂,而是页面边界清楚:TextArea负责原句输入,RULES负责本地规则匹配,analyze()负责空输入、遍历和过滤,ResultCard负责区分无建议和建议列表,CorrectionItemView负责把错误、建议和解释分层展示。
这种写法适合 HarmonyOS 学习类应用的第一版纠错页。它不会伪装成万能纠错系统,但能把高频错误讲清楚,让用户知道“哪里错、改成什么、为什么改”。后续如果要接入真正的云端纠错或模型能力,也应该保留CorrectionItem这种结构化输出,再把网络请求、错误处理、隐私说明和权限声明补齐。
本篇文章的封面使用当前文章独立的media/cover.png,同一软件合集继续复用应用级collection-cover.png;合集封面保持统一,合集内每篇文章封面保持不同,便于跨平台发布和回读核验。