07答案比对与反馈UI-鸿蒙PC端Electron开发
文章摘要: 本文介绍了开源鸿蒙PC项目中手写识别的答案比对与反馈UI实现。通过normalize()函数标准化OCR识别结果和正确答案,解决了大小写、空格、标点等导致的误判问题。比对逻辑分为三种状态:正确(绿色✓)、错误(红色✗并显示正确答案)、无识别内容(黄色警告)。反馈浮层采用Stack布局,定位在画布左上角,确保清晰可见且不干扰书写。错误时显示正确答案的设计强化了纠错学习功能,提升了用户体验
欢迎加入开源鸿蒙 PC社区
https://harmonypc.csdn.net/
效果截图

第7篇:答案比对与反馈UI
系列教程导航
| 篇号 | 标题 | 状态 |
|---|---|---|
| 01 | 环境搭建与项目创建 | ✅ 已完成 |
| 02 | 数据模型与单词仓库 | ✅ 已完成 |
| 03 | 主入口页面与导航结构 | ✅ 已完成 |
| 04 | 极速划词页面实现 | ✅ 已完成 |
| 05 | 手写画布实现 | ✅ 已完成 |
| 06 | 百度OCR手写识别接入 | ✅ 已完成 |
| 07 | 答案比对与反馈UI | 📖 本篇 |
| 08 | 单词切换与底部导航 | ⏳ 下一篇 |
| 09 | 词根分解与水印展示 | |
| 10 | 项目总结与优化方向 |
源码仓库:https://gitcode.com/qq_33247427/englishProject
一、文本标准化 normalize()
1.1 为什么需要标准化
在手写识别的应用场景中,文本标准化是一个非常关键的环节。如果我们直接拿百度 OCR 返回的原始文本和正确答案做字符串比较,会出现大量"明明写对了却被判错"的情况,严重影响用户体验和学习积极性。
百度 OCR 返回的手写识别结果往往不是"干净"的文本。用户手写时可能出现以下情况:
- 识别结果前后带有空格(如
" hello ") - 大小写不一致(用户写的是
Hello,正确答案是hello) - 识别出多余的标点符号或特殊字符(如
hello.或hello!) - 中英文混合场景下可能出现全角字符
如果直接用 === 比较,用户明明写对了却被判错,体验会非常糟糕。因此我们需要一个 normalize() 函数,将识别结果和正确答案都标准化后再比对。
1.2 实现代码
/**
* 文本标准化处理
* 将输入文本转换为可比较的标准格式
* @param text 原始文本(OCR 识别结果或正确答案)
* @returns 标准化后的文本
*/
private normalize(text: string): string {
return text
.trim() // 1. 去除首尾空格
.toLowerCase() // 2. 统一转为小写
.replace(/[^a-z\u4e00-\u9fa5]/g, ''); // 3. 只保留英文字母和中文字符
}
1.3 逐步解析
| 步骤 | 方法 | 作用 | 示例 |
|---|---|---|---|
| 1 | trim() |
去除首尾空白字符 | " hello " → "hello" |
| 2 | toLowerCase() |
统一转为小写 | "Hello" → "hello" |
| 3 | replace(/[^a-z\u4e00-\u9fa5]/g, '') |
移除非字母非中文字符 | "hello!" → "hello" |
正则表达式解析:
[^...]:取反,匹配不在括号内的字符a-z:小写英文字母(因为前面已经 toLowerCase)\u4e00-\u9fa5:Unicode 中文字符范围(基本汉字区)/g:全局匹配,替换所有符合条件的字符
1.4 标准化效果对照
// 测试用例
normalize(" Hello ") // → "hello"
normalize("WORLD!") // → "world"
normalize("trans-form") // → "transform"
normalize(" Electrical. ") // → "electrical"
normalize("你好World") // → "你好world"
normalize("") // → ""
这样处理后,只要用户写的字母顺序正确,无论大小写、空格、标点如何,都能正确判定。
二、checkAnswer() 比对逻辑
2.1 三种比对状态
识别完成后,比对结果分为三种状态:
| 状态 | 条件 | 反馈颜色 | 显示内容 |
|---|---|---|---|
| ✅ 正确 | 标准化后完全匹配 | 绿色 #5B8A4A |
✓ + 识别文本 |
| ❌ 错误 | 标准化后不匹配 | 红色 #B5533C |
✗ + 识别文本 + 正确答案 |
| ⚠️ 空内容 | 识别结果为空 | 黄色 #C9A14A |
警告提示 |
2.2 完整实现
/** 反馈文本 */
@State feedbackText: string = '';
/** 反馈颜色 */
@State feedbackColor: string = '#6B7280';
/** 是否显示正确答案(错误时显示) */
@State showAnswer: boolean = false;
/** OCR 识别到的原始文本 */
@State recognizedText: string = '';
/**
* 比对识别结果与正确答案
* @param recognized OCR 返回的识别文本
*/
private checkAnswer(recognized: string): void {
// 安全检查:当前必须有单词
if (this.currentWord === null) {
return;
}
// 保存原始识别文本(用于显示)
this.recognizedText = recognized;
// 标准化处理
const userInput: string = this.normalize(recognized);
const correctAnswer: string = this.normalize(this.currentWord.english);
// 状态一:识别结果为空
if (userInput.length === 0) {
this.feedbackText = '⚠ 未识别到有效内容';
this.feedbackColor = '#C9A14A'; // 警告黄
this.showAnswer = false;
return;
}
// 状态二:答案正确
if (userInput === correctAnswer) {
this.feedbackText = `✓ ${recognized}`;
this.feedbackColor = '#5B8A4A'; // 成功绿
this.showAnswer = false; // 正确时不需要显示答案
return;
}
// 状态三:答案错误
this.feedbackText = `✗ ${recognized}`;
this.feedbackColor = '#B5533C'; // 错误红
this.showAnswer = true; // 错误时显示正确答案
}
2.3 调用时机
checkAnswer() 在 OCR 识别回调中被调用:
/**
* 执行 OCR 识别
*/
private async doRecognize(): Promise<void> {
this.isRecognizing = true;
try {
// 1. 截取画布图片
const pixelMap: image.PixelMap = await componentSnapshot.get('handwriting-canvas');
// 2. 调用百度 OCR
const result: string = await this.ocrService.recognizeHandwriting(pixelMap);
// 3. 比对答案
this.checkAnswer(result);
} catch (error) {
this.feedbackText = '识别失败,请重试';
this.feedbackColor = '#B5533C';
this.showAnswer = false;
} finally {
this.isRecognizing = false;
}
}
2.4 设计考量
为什么正确时不显示正确答案?
用户已经写对了,再显示一遍正确答案是多余信息。只需要一个绿色 ✓ 加上识别到的文本,给用户正向反馈即可。
为什么错误时要显示正确答案?
用户写错了,需要知道正确答案是什么,方便对照学习。这是"纠错学习"的核心环节。
为什么要保存 recognizedText?
识别结果可能和用户预期不同(比如 OCR 把 b 识别成了 d),显示原始识别文本让用户知道系统"看到了什么",有助于改进书写。
三、反馈浮层实现(画布左上角)
3.1 布局策略
反馈浮层需要满足以下要求:
- 显示在画布左上角,不遮挡主要书写区域
- 字体足够大(28px),远距离也能看清
- 半透明背景,不完全遮挡画布内容
- 不影响手写触摸事件(穿透点击)
- 错误时额外显示正确答案
我们使用 Stack 的 alignContent: Alignment.TopStart 将浮层定位在左上角。
3.2 完整实现
// 画布区域使用 Stack 叠加布局
Stack({ alignContent: Alignment.TopStart }) {
// 第一层:白色底层(保证 OCR 截图有白色背景)
Column()
.width('100%')
.height('100%')
.backgroundColor('#FFFFFF')
// 第二层:水印层(可选显示)
// ... 水印代码(第 9 篇详解)
// 第三层:Canvas 手写层
Canvas(this.canvasContext)
.width('100%')
.height('100%')
.backgroundColor(Color.Transparent)
.onTouch((event: TouchEvent) => { this.handleTouch(event); })
.id('handwriting-canvas')
// 第四层:反馈浮层(左上角)
if (this.feedbackText.length > 0) {
Column({ space: 4 }) {
// 主反馈文本(大字体)
Text(this.feedbackText)
.fontSize(28)
.fontColor(this.feedbackColor)
.fontWeight(FontWeight.Bold)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
// 错误时显示正确答案
if (this.showAnswer && this.currentWord !== null) {
Text(`正确答案:${this.currentWord.english}`)
.fontSize(20)
.fontColor('#8B9D6B')
.fontWeight(FontWeight.Medium)
.margin({ top: 4 })
}
}
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.backgroundColor('#FFFFFFEE') // 半透明白色背景
.borderRadius(8)
.margin({ left: 12, top: 12 })
.hitTestBehavior(HitTestMode.Transparent) // 不拦截触摸事件
}
// 第五层:工具栏浮层(右上角)
// ... 下一节详解
}
.width('100%')
.layoutWeight(1)
.borderRadius(12)
.clip(true)
3.3 关键属性详解
backgroundColor('#FFFFFFEE')
这是一个带透明度的白色。#FFFFFF 是纯白,最后两位 EE 是 Alpha 通道值(0~FF),EE 约等于 93% 不透明度。这样浮层有白色底但不完全遮挡画布内容。
hitTestBehavior(HitTestMode.Transparent)
这是关键属性。如果不设置,浮层会拦截触摸事件,用户无法在浮层覆盖的区域书写。设置为 Transparent 后,触摸事件会穿透浮层传递给下方的 Canvas。
maxLines(2) + textOverflow
防止识别结果过长导致浮层过大。最多显示两行,超出部分用省略号表示。
3.4 三种状态的视觉效果
┌─────────────────────────────────────────┐
│ ┌──────────────────┐ │
│ │ ✓ electrical │ ← 正确(绿色) │
│ └──────────────────┘ │
│ │
│ (手写区域) │
│ │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ ┌──────────────────┐ │
│ │ ✗ electrcal │ ← 错误(红色) │
│ │ 正确答案:electrical │ │
│ └──────────────────┘ │
│ (手写区域) │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ ┌──────────────────┐ │
│ │ ⚠ 未识别到有效内容 │ ← 警告(黄色) │
│ └──────────────────┘ │
│ (手写区域) │
└─────────────────────────────────────────┘
四、工具栏浮层(画布右上角)
4.1 定位原理
ArkUI 中要将元素定位到父容器的右上角,需要组合使用 position 和 markAnchor:
.position({ x: '100%', y: 0 }) // 元素左上角放在父容器右上角
.markAnchor({ x: '100%', y: 0 }) // 以元素自身右上角为锚点
原理图解:
父容器
┌────────────────────────────────────┐
│ position │
│ (100%,0) │
│ ↓ │
│ ┌─────────┐ │
│ │ 工具栏 │ │
│ │ markAnchor│
│ │ (100%,0) │ │
│ └─────────┘ │
│ │
└────────────────────────────────────┘
position({ x: '100%', y: 0 }):将元素的定位参考点放在父容器宽度 100% 处(即右边缘),垂直方向为 0(顶部)markAnchor({ x: '100%', y: 0 }):将元素自身的锚点设为自身宽度 100% 处(即自身右边缘)
两者结合的效果:元素的右上角对齐父容器的右上角。
4.2 完整实现
// 工具栏浮层(画布右上角)
Row({ space: 10 }) {
// 水印开关
Row({ space: 6 }) {
Text('水印')
.fontSize(12)
.fontColor('#6B7280')
Toggle({ type: ToggleType.Switch, isOn: this.showWatermark })
.selectedColor('#8B9D6B')
.width(40)
.height(22)
.onChange((isOn: boolean) => {
this.showWatermark = isOn;
})
}
.alignItems(VerticalAlign.Center)
// 重做按钮
Button('重做')
.fontSize(13)
.fontColor('#374151')
.backgroundColor('#F3F4F6')
.borderRadius(6)
.height(32)
.padding({ left: 14, right: 14 })
.type(ButtonType.Normal)
.onClick(() => {
this.clearCanvas();
})
// 识别按钮(带 Loading 状态)
if (this.isRecognizing) {
Row({ space: 6 }) {
LoadingProgress()
.width(16)
.height(16)
.color('#FFFFFF')
Text('识别中')
.fontSize(13)
.fontColor('#FFFFFF')
}
.backgroundColor('#8B9D6B')
.borderRadius(6)
.height(32)
.padding({ left: 14, right: 14 })
.justifyContent(FlexAlign.Center)
.alignItems(VerticalAlign.Center)
} else {
Button('识别')
.fontSize(13)
.fontColor('#FFFFFF')
.backgroundColor('#8B9D6B')
.borderRadius(6)
.height(32)
.padding({ left: 14, right: 14 })
.type(ButtonType.Normal)
.onClick(() => {
this.doRecognize();
})
}
}
.position({ x: '100%', y: 0 })
.markAnchor({ x: '100%', y: 0 })
.padding({ right: 12, top: 12 })
.hitTestBehavior(HitTestMode.Transparent)
4.3 识别按钮的 Loading 状态
当用户点击"识别"后,按钮变为 Loading 状态,防止重复点击:
@State isRecognizing: boolean = false;
| 状态 | 显示 | 可点击 |
|---|---|---|
isRecognizing = false |
绿色"识别"按钮 | ✅ 是 |
isRecognizing = true |
绿色背景 + LoadingProgress + “识别中” | ❌ 否 |
为什么不用 Button 的 enabled 属性?
因为 Loading 状态需要同时显示旋转动画和文字,用条件渲染(if/else)替换整个按钮组件更灵活。
4.4 水印开关 Toggle
Toggle 组件是 ArkUI 提供的开关控件:
Toggle({ type: ToggleType.Switch, isOn: this.showWatermark })
.selectedColor('#8B9D6B') // 开启时的颜色(主题绿)
.width(40) // 开关宽度
.height(22) // 开关高度
.onChange((isOn: boolean) => {
this.showWatermark = isOn; // 更新状态,触发水印层显示/隐藏
})
五、状态重置
5.1 clearCanvas 方法
当用户点击"重做"或切换到下一个单词时,需要重置所有状态:
/**
* 清空画布并重置所有反馈状态
*/
private clearCanvas(): void {
// 1. 清空 Canvas 画布内容
const width: number = this.canvasWidth;
const height: number = this.canvasHeight;
this.canvasContext.clearRect(0, 0, width, height);
// 2. 重新设置画笔属性(clearRect 不会重置画笔)
this.setupBrush();
// 3. 重置反馈状态
this.feedbackText = ''; // 清空反馈文本
this.feedbackColor = '#6B7280'; // 重置为默认灰色
this.showAnswer = false; // 隐藏正确答案
this.recognizedText = ''; // 清空识别文本
// 4. 重置笔画数据(用于重绘)
this.strokes = [];
}
/**
* 设置画笔属性
*/
private setupBrush(): void {
this.canvasContext.lineWidth = 3;
this.canvasContext.lineCap = 'round';
this.canvasContext.lineJoin = 'round';
this.canvasContext.strokeStyle = '#1A1A1A';
}
5.2 重置时机
| 触发场景 | 调用方式 |
|---|---|
| 点击"重做"按钮 | this.clearCanvas() |
| 切换到上一个单词 | prevWord() 内部调用 |
| 切换到下一个单词 | nextWord() 内部调用 |
| 页面首次加载 | aboutToAppear() 中初始化 |
5.3 为什么要重置 feedbackColor?
如果不重置颜色,下次识别前浮层可能短暂显示上一次的颜色。虽然 feedbackText 为空时浮层不显示,但重置颜色是一种防御性编程,确保状态完全干净。
六、颜色语义系统
6.1 语义色定义
本项目定义了一套语义化的颜色系统,让颜色使用有据可循:
| 语义 | 色值 | 用途 | 色名 |
|---|---|---|---|
| success | #5B8A4A |
正确反馈、确认操作 | 深草绿 |
| error | #B5533C |
错误反馈、危险操作 | 砖红色 |
| warning | #C9A14A |
警告提示、注意事项 | 琥珀黄 |
| info | #6B7280 |
辅助信息、次要文本 | 中性灰 |
| primary | #8B9D6B |
主题色、主要按钮 | 鼠尾草绿 |
6.2 颜色选择原则
// ✅ 正确:使用语义色
this.feedbackColor = '#5B8A4A'; // success - 答案正确
// ❌ 错误:使用无语义的颜色
this.feedbackColor = '#00FF00'; // 纯绿色,太刺眼,无设计感
为什么不用纯色(如 #FF0000 红、#00FF00 绿)?
- 视觉舒适度:纯色饱和度过高,长时间看会疲劳
- 设计一致性:低饱和度的颜色更容易搭配,整体界面更和谐
- 可访问性:适当降低饱和度有助于色弱用户区分
6.3 在代码中统一管理
建议将颜色常量集中定义,避免硬编码散落各处:
/**
* 应用颜色常量
* 集中管理,修改时只需改一处
*/
const AppColors = {
// 语义色
success: '#5B8A4A',
error: '#B5533C',
warning: '#C9A14A',
info: '#6B7280',
// 主题色
primary: '#8B9D6B',
primaryDark: '#6F7F52',
primaryLight: '#B6C496',
primarySurface: '#EEF1E4',
// 中性色
textPrimary: '#374151',
textSecondary: '#6B7280',
textMuted: '#9CA3AF',
background: '#FAFAF7',
surface: '#FFFFFF',
border: '#E5E7EB',
} as const;
6.4 反馈浮层中的颜色应用
在实际开发中,颜色的使用应该遵循语义化原则。不要随意使用颜色值,而是根据信息的类型选择对应的语义色。这样做的好处是:当设计师调整配色方案时,只需要修改颜色常量定义,所有使用该语义色的地方都会自动更新。同时,代码的可读性也大大提高,其他开发者看到 AppColors.success 就知道这是正确反馈的颜色,而不需要去查 #5B8A4A 是什么颜色。
// 使用语义色的完整示例
if (userInput === correctAnswer) {
this.feedbackColor = AppColors.success; // 正确 → 绿色
} else if (userInput.length === 0) {
this.feedbackColor = AppColors.warning; // 空内容 → 黄色
} else {
this.feedbackColor = AppColors.error; // 错误 → 红色
}
七、本篇小结
通过本篇教程,我们完成了答案比对与反馈 UI 的核心功能:
- ✅ 实现
normalize()文本标准化(trim + toLowerCase + 正则过滤) - ✅ 实现
checkAnswer()三状态比对逻辑(正确/错误/空内容) - ✅ 画布左上角反馈浮层(大字体 + 半透明背景 + 触摸穿透)
- ✅ 画布右上角工具栏(水印开关 + 重做 + 识别按钮)
- ✅ position + markAnchor 右上角定位技巧
- ✅ 识别按钮的 LoadingProgress 状态
- ✅ clearCanvas 状态重置逻辑
- ✅ 建立颜色语义系统(success/error/warning/info)
本篇新增/修改的文件
electron/src/main/ets/
├── pages/
│ └── SpeedVocabPage.ets ← 修改:添加比对逻辑和反馈浮层
└── (颜色常量可提取到 utils/Colors.ets)
八、开发过程中的常见问题
8.1 反馈浮层不显示
现象:调用 checkAnswer() 后,浮层没有出现。
排查步骤:
- 检查
feedbackText是否被正确赋值(在checkAnswer中打日志) - 确认
if (this.feedbackText.length > 0)条件是否满足 - 确认浮层代码在 Stack 内部,且在 Canvas 之后(层级更高)
- 检查浮层是否被其他元素遮挡(z-index 问题)
8.2 触摸穿透失效
现象:浮层区域无法书写。
原因:忘记设置 hitTestBehavior(HitTestMode.Transparent)。
注意:hitTestBehavior 需要设置在浮层的根容器上,不是子元素上。如果浮层有多个层级,每一层都需要设置。
8.3 识别按钮重复点击
现象:用户快速点击"识别"按钮,触发多次 OCR 请求。
解决:使用 isRecognizing 状态锁,在请求进行中用条件渲染替换按钮为 Loading 状态,从根本上阻止重复点击。这比 enabled(false) 更可靠,因为禁用按钮仍然可能被快速连击触发。
8.4 颜色在不同设备上显示差异
现象:同一个颜色值在不同设备上看起来不一样。
原因:不同屏幕的色域和亮度不同。鸿蒙 PC 通常使用 sRGB 色域,而部分平板支持 P3 广色域。
建议:使用中等饱和度的颜色(如本项目的语义色),在不同色域下差异较小。避免使用高饱和度的纯色。
下一篇预告
第 8 篇:单词切换与底部导航 — 我们将实现单词之间的切换导航、进度显示、音标词义提示区,以及整体页面间距的统一管理。用户将能够在单词列表中前后切换,完成完整的默写流程。
更多推荐




所有评论(0)