欢迎加入开源鸿蒙 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 布局策略

反馈浮层需要满足以下要求:

  1. 显示在画布左上角,不遮挡主要书写区域
  2. 字体足够大(28px),远距离也能看清
  3. 半透明背景,不完全遮挡画布内容
  4. 不影响手写触摸事件(穿透点击)
  5. 错误时额外显示正确答案

我们使用 StackalignContent: 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 中要将元素定位到父容器的右上角,需要组合使用 positionmarkAnchor

.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 + “识别中” ❌ 否

为什么不用 Buttonenabled 属性?

因为 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 绿)?

  1. 视觉舒适度:纯色饱和度过高,长时间看会疲劳
  2. 设计一致性:低饱和度的颜色更容易搭配,整体界面更和谐
  3. 可访问性:适当降低饱和度有助于色弱用户区分
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() 后,浮层没有出现。

排查步骤

  1. 检查 feedbackText 是否被正确赋值(在 checkAnswer 中打日志)
  2. 确认 if (this.feedbackText.length > 0) 条件是否满足
  3. 确认浮层代码在 Stack 内部,且在 Canvas 之后(层级更高)
  4. 检查浮层是否被其他元素遮挡(z-index 问题)
8.2 触摸穿透失效

现象:浮层区域无法书写。

原因:忘记设置 hitTestBehavior(HitTestMode.Transparent)

注意hitTestBehavior 需要设置在浮层的根容器上,不是子元素上。如果浮层有多个层级,每一层都需要设置。

8.3 识别按钮重复点击

现象:用户快速点击"识别"按钮,触发多次 OCR 请求。

解决:使用 isRecognizing 状态锁,在请求进行中用条件渲染替换按钮为 Loading 状态,从根本上阻止重复点击。这比 enabled(false) 更可靠,因为禁用按钮仍然可能被快速连击触发。

8.4 颜色在不同设备上显示差异

现象:同一个颜色值在不同设备上看起来不一样。

原因:不同屏幕的色域和亮度不同。鸿蒙 PC 通常使用 sRGB 色域,而部分平板支持 P3 广色域。

建议:使用中等饱和度的颜色(如本项目的语义色),在不同色域下差异较小。避免使用高饱和度的纯色。


下一篇预告

第 8 篇:单词切换与底部导航 — 我们将实现单词之间的切换导航、进度显示、音标词义提示区,以及整体页面间距的统一管理。用户将能够在单词列表中前后切换,完成完整的默写流程。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐