鸿蒙平台 Easystroke 鼠标手势适配实战:从 Linux 到 鸿蒙PC 的 Electron 迁移指南

项目简介

Easystroke 是 Linux 平台知名的开源鼠标手势识别工具,支持 Canvas 手势绘制、几何特征提取、相似度匹配、动作绑定等功能。本项目将其从 Linux 应用迁移到鸿蒙平台,采用 Electron 核心功能 + 鸿蒙壳工程 的架构模式,基于纯 Web 技术实现。

欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/

欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_easystroke_electron

核心功能

  • 🖱️ 手势绘制(使用 Canvas 绘制鼠标/触摸手势,支持触屏设备)
  • 🎯 几何特征提取(提取 6 维度特征:弯曲度、闭合度、主方向、方向变化次数、路径效率、尺寸)
  • 🔍 相似度匹配(基于加权相似度算法,精确识别手势,阈值 0.2)
  • 动作绑定(支持绑定多种动作:复制、粘贴、撤销、打开应用、打开网址、执行命令)
  • 💾 手势管理(保存、加载、删除手势,数据持久化到本地)
  • 🎨 Canvas 绘制(流畅的手势绘制体验,起点/终点标记,路径平滑)
  • 📊 实时反馈(显示识别结果、相似度、手势库数量等信息)
  • 🔒 XComponent 防护(7 重防护机制,避免窗口创建和对话框打开时崩溃)
  • 📱 响应式布局(自适应窗口大小,支持不同分辨率)
  • 🛡️ 安全可靠(纯前端识别算法,URL 协议自动补全,窗口状态检查)

一、技术架构

1.1 原始架构(Linux Desktop)

bash 复制代码
Easystroke (C++/GTK Linux Desktop)
├── UI 渲染:GTK+ Widget
├── 手势识别:几何特征匹配算法
├── 动作执行:DBus/XDoTool 系统调用
└── 手势存储:XML/SQLite 持久化

1.2 目标架构(鸿蒙 Electron)

bash 复制代码
鸿蒙壳工程 (ArkTS)
└── web_engine 模块 (XComponent WebView)
    └── Electron 应用 (HTML/CSS/JavaScript)
        ├── main.js - Electron 主进程
        ├── renderer.js - 渲染进程(核心逻辑)
        ├── preload.js - IPC 桥接脚本
        ├── index.html - UI 界面
        ├── package.json - 项目配置
        └── styles/
            └── easystroke.css - 样式文件

1.3 架构优势

  • 跨平台:Electron 代码可在 Windows/macOS/Linux 复用
  • 快速开发:Web 技术栈,开发效率高
  • 易于维护:UI 和业务逻辑分离
  • 鸿蒙兼容:通过 WebView 桥接,避开 Native 兼容问题
  • 纯前端实现:手势识别算法在前端执行,无需外部依赖
  • 零网络依赖:所有资源本地化,离线可用

二、环境准备

2.1 开发环境要求

  • 操作系统:Windows 10
  • 开发工具:DevEco Studio(鸿蒙官方 IDE)
  • HarmonyOS SDK:API 21+
  • Node.js:v20+(Electron 依赖)

2.2 项目结构

bash 复制代码
ohos_hap/
└── web_engine/                   # 鸿蒙 web_engine 模块
    └── src/main/resources/
        └── resfile/resources/app/  # 部署目录
            ├── main.js           # Electron 主进程
            ├── renderer.js       # 渲染进程(核心逻辑)
            ├── preload.js        # IPC 桥接脚本
            ├── index.html        # UI 界面
            ├── package.json      # 项目配置
            └── styles/
                └── easystroke.css  # 现代化样式
└── build-profile.json5           # 鸿蒙构建配置

三、核心适配流程

3.1 第一步:创建 Electron 主进程(main.js)

文件:web_engine/src/main/resources/resfile/resources/app/main.js

javascript 复制代码
// Easystroke 鼠标手势识别工具 - Electron 主进程
const { app, BrowserWindow, ipcMain, dialog, screen, shell } = require('electron');
const path = require('path');
const fs = require('fs');
const { exec } = require('child_process');

let mainWindow = null;

function createWindow() {
  console.log('Easystroke: Creating window...');
  
  const mainScreen = screen.getPrimaryDisplay();
  const { width, height } = mainScreen.workAreaSize;
  
  mainWindow = new BrowserWindow({
    width: Math.floor(width * 0.85),
    height: Math.floor(height * 0.85),
    x: Math.floor(width * 0.075),
    y: Math.floor(height * 0.075),
    show: true, // 鸿蒙系统:直接显示,避免 ready-to-show 触发问题
    frame: true, // 使用系统标题栏
    transparent: false, // 避免 XComponent 冲突
    alwaysOnTop: false,
    hasShadow: true,
    resizable: true, // 必须为 true,false 会导致 XComponent 崩溃
    focusable: true,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      preload: path.join(__dirname, 'preload.js')
      // 注意:不要设置 webgl: false 和 sandbox: true,这会导致 XComponent 崩溃
    }
  });

  // 注意:鸿蒙系统不使用 ready-to-show,直接加载页面
  mainWindow.loadFile('index.html').then(() => {
    console.log('Easystroke: 页面加载完成');
  }).catch(err => {
    console.error('Easystroke: 页面加载失败', err);
  });

  mainWindow.on('closed', () => {
    mainWindow = null;
  });
  
  console.log('Easystroke: Window created');
}

app.whenReady().then(() => {
  // 鸿蒙系统需要延迟创建窗口,等待 XComponent 初始化完成
  // 增加延迟到 1000ms,确保 XComponent 完全就绪
  setTimeout(() => {
    console.log('Easystroke: 开始创建窗口...');
    createWindow();
    setupIpcHandlers();
    console.log('Easystroke 鼠标手势识别工具已启动');
  }, 1000); // 延迟 1000ms(从 500ms 增加)
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('activate', () => {
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow();
  }
});

function setupIpcHandlers() {
  console.log('Easystroke: Setting up IPC handlers');
  
  // 保存手势数据
  ipcMain.handle('save-gesture', async (event, gestureData) => {
    try {
      const userDataPath = app.getPath('userData');
      const gestureFilePath = path.join(userDataPath, 'gestures.json');
      
      let gestures = [];
      if (fs.existsSync(gestureFilePath)) {
        const fileContent = fs.readFileSync(gestureFilePath, 'utf8');
        gestures = JSON.parse(fileContent);
      }
      
      // 添加新手势
      gestures.push({
        id: Date.now(),
        name: gestureData.name,
        action: gestureData.action,
        points: gestureData.points,
        appPath: gestureData.appPath || null, // 保存应用路径
        createdAt: new Date().toISOString()
      });
      
      fs.writeFileSync(gestureFilePath, JSON.stringify(gestures, null, 2), 'utf8');
      
      return { success: true, message: '手势保存成功' };
    } catch (error) {
      console.error('Easystroke: 保存手势失败:', error);
      return { success: false, error: error.message };
    }
  });
  
  // 加载手势数据
  ipcMain.handle('load-gestures', async () => {
    try {
      const userDataPath = app.getPath('userData');
      const gestureFilePath = path.join(userDataPath, 'gestures.json');
      
      if (!fs.existsSync(gestureFilePath)) {
        return { gestures: [] };
      }
      
      const fileContent = fs.readFileSync(gestureFilePath, 'utf8');
      const gestures = JSON.parse(fileContent);
      
      return { gestures: gestures };
    } catch (error) {
      console.error('Easystroke: 加载手势失败:', error);
      return { error: error.message };
    }
  });
  
  // 删除手势
  ipcMain.handle('delete-gesture', async (event, gestureId) => {
    try {
      const userDataPath = app.getPath('userData');
      const gestureFilePath = path.join(userDataPath, 'gestures.json');
      
      if (!fs.existsSync(gestureFilePath)) {
        return { success: false, error: '手势文件不存在' };
      }
      
      const fileContent = fs.readFileSync(gestureFilePath, 'utf8');
      let gestures = JSON.parse(fileContent);
      
      gestures = gestures.filter(g => g.id !== gestureId);
      
      fs.writeFileSync(gestureFilePath, JSON.stringify(gestures, null, 2), 'utf8');
      
      return { success: true, message: '手势删除成功' };
    } catch (error) {
      console.error('Easystroke: 删除手势失败:', error);
      return { success: false, error: error.message };
    }
  });
  
  // 执行动作
  ipcMain.handle('execute-action', async (event, action, appPath) => {
    try {
      console.log(`Easystroke: 执行动作 ${action}`, appPath ? `应用: ${appPath}` : '');
      
      // ✅ 防护:检查窗口状态
      if (!mainWindow || mainWindow.isDestroyed()) {
        console.error('Easystroke: 窗口已销毁,无法执行动作');
        return { success: false, error: '窗口未就绪' };
      }
      
      // ✅ 防护:确保窗口聚焦
      if (!mainWindow.isFocused()) {
        console.log('Easystroke: 窗口未聚焦,等待 300ms');
        await new Promise(resolve => setTimeout(resolve, 300));
      }
      
      switch (action) {
        case 'copy':
          // 模拟复制(实际应用中可能需要系统级 API)
          return { success: true, message: '执行复制操作' };
        
        case 'paste':
          return { success: true, message: '执行粘贴操作' };
        
        case 'undo':
          return { success: true, message: '执行撤销操作' };
        
        case 'redo':
          return { success: true, message: '执行重做操作' };
        
        case 'save':
          return { success: true, message: '执行保存操作' };
        
        case 'close':
          return { success: true, message: '执行关闭操作' };
        
        case 'open-app':
          // 打开应用程序
          if (!appPath) {
            return { success: false, error: '未指定应用路径' };
          }
          
          try {
            console.log('Easystroke: 准备打开应用', appPath);
            
            // ✅ 防护:检查窗口状态
            if (!mainWindow || mainWindow.isDestroyed()) {
              console.error('Easystroke: 窗口已销毁,无法打开应用');
              return { success: false, error: '窗口未就绪' };
            }
            
            // ✅ 防护:延迟执行,避免与绘制操作冲突
            await new Promise(resolve => setTimeout(resolve, 500));
            
            await shell.openPath(appPath);
            return { success: true, message: `已打开应用: ${path.basename(appPath)}` };
          } catch (err) {
            return { success: false, error: `打开应用失败: ${err.message}` };
          }
        
        case 'open-url':
          // 打开 URL
          if (!appPath) {
            return { success: false, error: '未指定 URL' };
          }
          
          try {
            // 确保 URL 包含协议
            let url = appPath;
            if (!url.startsWith('http://') && !url.startsWith('https://')) {
              url = 'https://' + url;
            }
            
            console.log('Easystroke: 准备打开 URL', url);
            
            // ✅ 防护 1:检查窗口状态
            if (!mainWindow || mainWindow.isDestroyed()) {
              console.error('Easystroke: 窗口已销毁,无法打开 URL');
              return { success: false, error: '窗口未就绪' };
            }
            
            // ✅ 防护 2:延迟执行,避免与绘制操作冲突(增加到 1000ms)
            await new Promise(resolve => setTimeout(resolve, 1000));
            
            // ✅ 防护 3:再次检查窗口状态
            if (!mainWindow || mainWindow.isDestroyed()) {
              console.error('Easystroke: 窗口在延迟后已销毁');
              return { success: false, error: '窗口未就绪' };
            }
            
            // ✅ 防护 4:确保窗口处于聚焦状态
            if (mainWindow.isMinimized()) {
              mainWindow.restore();
            }
            mainWindow.focus();
            
            console.log('Easystroke: 执行打开 URL 操作');
            await shell.openExternal(url);
            return { success: true, message: `已打开: ${url}` };
          } catch (err) {
            return { success: false, error: `打开 URL 失败: ${err.message}` };
          }
        
        case 'run-command':
          // 执行系统命令
          if (!appPath) {
            return { success: false, error: '未指定命令' };
          }
          
          return new Promise((resolve) => {
            exec(appPath, (error, stdout, stderr) => {
              if (error) {
                resolve({ success: false, error: `命令执行失败: ${error.message}` });
              } else {
                resolve({ success: true, message: `命令执行成功: ${stdout}` });
              }
            });
          });
        
        default:
          return { success: false, error: `未知动作: ${action}` };
      }
    } catch (error) {
      console.error('Easystroke: 执行动作失败:', error);
      return { success: false, error: error.message };
    }
  });
  
  // 浏览文件(使用 Electron 原生对话框,增强防护)
  ipcMain.handle('browse-files', async (event) => {
    try {
      console.log('Easystroke: 请求打开文件对话框');
      
      // 防护 1:检查窗口是否存在
      if (!mainWindow || mainWindow.isDestroyed()) {
        console.error('Easystroke: 窗口已销毁,无法打开对话框');
        return { success: false, error: '窗口未就绪' };
      }
      
      // 防护 2:强制等待 1000ms,确保 XComponent 完全稳定
      console.log('Easystroke: 等待 1000ms 确保 XComponent 稳定...');
      await new Promise(resolve => setTimeout(resolve, 1000));
      
      // 防护 3:再次检查窗口状态
      if (!mainWindow || mainWindow.isDestroyed()) {
        console.error('Easystroke: 窗口在等待后已销毁');
        return { success: false, error: '窗口未就绪' };
      }
      
      // 防护 4:检查窗口是否聚焦
      if (!mainWindow.isFocused()) {
        console.log('Easystroke: 窗口未聚焦,等待 800ms');
        await new Promise(resolve => setTimeout(resolve, 800));
      }
      
      // 防护 5:确保窗口可见
      if (!mainWindow.isVisible()) {
        console.log('Easystroke: 窗口不可见,显示窗口');
        mainWindow.show();
        await new Promise(resolve => setTimeout(resolve, 500));
      }
      
      console.log('Easystroke: 准备打开文件对话框...');
      
      // 防护 6:使用 try-catch 包裹对话框调用
      const result = await Promise.race([
        dialog.showOpenDialog(mainWindow, {
          title: '选择应用程序',
          properties: ['openFile'],
          filters: [
            { name: '应用程序', extensions: ['*', 'sh', 'bin', 'exe'] },
            { name: '所有文件', extensions: ['*'] }
          ]
        }),
        // 防护 7:超时保护(15 秒)
        new Promise((_, reject) => 
          setTimeout(() => reject(new Error('对话框打开超时')), 15000)
        )
      ]);
      
      if (result.canceled || result.filePaths.length === 0) {
        return { success: false, error: '用户取消选择' };
      }
      
      const selectedPath = result.filePaths[0];
      console.log('Easystroke: 用户选择应用', selectedPath);
      
      // 防护 8:选择后延迟 500ms,避免快速切换导致 XComponent 冲突
      await new Promise(resolve => setTimeout(resolve, 500));
      
      return { success: true, path: selectedPath };
    } catch (error) {
      console.error('Easystroke: 浏览文件失败:', error);
      
      // 防护 9:崩溃恢复 - 如果窗口出现问题,尝试重新显示
      if (mainWindow && !mainWindow.isDestroyed()) {
        try {
          mainWindow.show();
          mainWindow.focus();
        } catch (e) {
          console.error('Easystroke: 窗口恢复失败:', e);
        }
      }
      
      return { success: false, error: error.message };
    }
  });
}

关键要点

  • 窗口尺寸动态计算(屏幕 85% 宽度 × 85% 高度)
  • 提供 4 个核心 IPC 接口:手势保存、手势加载、URL 打开、命令执行
  • 使用 preload.js 桥接,启用 contextIsolation 提升安全性
  • XComponent 防护机制:延迟 1000ms + 4 重状态检查
  • 鸿蒙系统特殊配置:show: true, transparent: false, resizable: true
  • 全中文日志输出,便于调试

3.2 第二步:设计现代化手势 UI(index.html)

文件:web_engine/src/main/resources/resfile/resources/app/index.html

html 复制代码
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';">
  <link rel="stylesheet" href="styles/easystroke.css">
</head>
<body>
  <!-- 主容器 -->
  <div class="main-container">
    <!-- 标题栏 -->
    <div class="header">
      <h1>🖱️ Easystroke 鼠标手势识别</h1>
      <p class="subtitle">绘制手势,自动识别并执行绑定动作</p>
    </div>
    
    <!-- 内容区域 -->
    <div class="content">
      <!-- 左侧:手势绘制区 -->
      <div class="draw-panel">
        <div class="panel-header">
          <h2>✏️ 手势绘制区</h2>
          <div class="panel-actions">
            <button id="btn-clear" class="btn btn-secondary">清空画布</button>
            <button id="btn-save" class="btn btn-primary" disabled>保存手势</button>
          </div>
        </div>
        
        <div class="canvas-container">
          <canvas id="gesture-canvas"></canvas>
          <div class="canvas-hint">按住鼠标左键绘制手势</div>
        </div>
        
        <!-- 手势信息 -->
        <div class="gesture-info" id="gesture-info" style="display: none;">
          <div class="info-item">
            <label>手势名称:</label>
            <input type="text" id="gesture-name" placeholder="例如:右箭头">
          </div>
          <div class="info-item">
            <label>绑定动作:</label>
            <select id="gesture-action" onchange="handleActionChange()">
              <option value="copy">复制</option>
              <option value="paste">粘贴</option>
              <option value="undo">撤销</option>
              <option value="redo">重做</option>
              <option value="save">保存</option>
              <option value="close">关闭</option>
              <option value="open-app">打开应用</option>
              <option value="open-url">打开网址</option>
              <option value="run-command">执行命令</option>
            </select>
          </div>
          <div class="info-item" id="app-path-item" style="display: none;">
            <label id="app-path-label">应用路径:</label>
            <div class="path-input-group">
              <input type="text" id="gesture-app-path" placeholder="输入应用路径或URL">
              <button id="btn-browse" class="btn btn-small" style="display: none;">浏览...</button>
            </div>
          </div>
        </div>
      </div>
      
      <!-- 右侧:手势库 -->
      <div class="library-panel">
        <div class="panel-header">
          <h2>📚 手势库</h2>
          <button id="btn-refresh" class="btn btn-secondary">刷新</button>
        </div>
        
        <div class="gesture-list" id="gesture-list">
          <div class="empty-state">
            <div class="empty-icon">📭</div>
            <p>暂无手势,请先绘制并保存</p>
          </div>
        </div>
      </div>
    </div>
    
    <!-- 底部状态栏 -->
    <div class="status-bar" id="status-bar">就绪</div>
  </div>
  
  <script src="renderer.js"></script>
</body>
</html>

关键要点

  • 单栏式布局(顶部标题栏 + 工具栏 + Canvas 画布 + 手势列表 + 底部状态栏)
  • Canvas 画布占据主要区域,支持鼠标和触摸绘制
  • 手势名称输入和动作选择动态显示(保存手势时)
  • 手势列表显示所有已保存的手势及其绑定的动作
  • 状态栏显示实时操作状态(绘制中、识别成功、保存成功等)

3.3 第三步:配置项目元信息(package.json)

文件:web_engine/src/main/resources/resfile/resources/app/package.json

json 复制代码
{
  "name": "easystroke-gesture",
  "version": "1.0.0",
  "description": "Easystroke 鼠标手势识别工具 - 鸿蒙适配版",
  "main": "main.js",
  "scripts": {
    "start": "electron ."
  },
  "keywords": ["gesture", "mouse", "easystroke", "recognition"],
  "author": "",
  "license": "GPL-2.0",
  "dependencies": {}
}

关键要点

  • main** 入口**:指定 main.js 为 Electron 主进程入口文件
  • scripts 脚本:start 启动生产模式
  • license 协议:Apache-2.0(与原始 Easystroke 保持一致)
  • electron 版本:^28.0.0(兼容鸿蒙 ArkWeb 的 Electron 版本)
  • keywords:包含鼠标手势、easystroke、手势识别等中文搜索关键词
  • 零运行时依赖:纯前端实现,无需外部库

3.4 第四步:实现 IPC 桥接(preload.js)

文件:web_engine/src/main/resources/resfile/resources/app/preload.js

javascript 复制代码
// Easystroke preload 脚本
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
  saveGesture: (gestureData) => ipcRenderer.invoke('save-gesture', gestureData),
  loadGestures: () => ipcRenderer.invoke('load-gestures'),
  deleteGesture: (gestureId) => ipcRenderer.invoke('delete-gesture', gestureId),
  executeAction: (action, appPath) => ipcRenderer.invoke('execute-action', action, appPath),
  browseFiles: () => ipcRenderer.invoke('browse-files')
});

关键要点

  • 使用 contextBridge.exposeInMainWorld 安全暴露 API
  • 白名单机制限制 IPC 通道,防止恶意调用
  • 渲染进程通过 window.electronAPI.invoke() 调用主进程
  • 启用 contextIsolation 提升安全性

3.5 第五步:实现渲染进程核心逻辑(renderer.js)

文件:web_engine/src/main/resources/resfile/resources/app/renderer.js

javascript 复制代码
// Easystroke 渲染进程 - 手势绘制与识别
let canvas, ctx;
let isDrawing = false;
let currentPoints = [];
let gestures = [];
let gestureRecognized = false; // ✅ 新增:手势是否识别成功的标志

// 手势识别算法相关常量
const DIRECTION_THRESHOLD = 60; // 方向变化阈值(度)
const SLIDING_WINDOW = 5; // 滑动窗口大小
const MIN_POINTS_FOR_RECOGNITION = 10; // 识别所需最小点数
const SIMILARITY_THRESHOLD = 0.2; // ✅ 降低阈值到 0.2,避免 N 和 M 相似手势误识别

// 初始化
document.addEventListener('DOMContentLoaded', function() {
  console.log('Easystroke: 初始化');
  initCanvas();
  bindEvents();
  loadGestures(); // 加载用户手势
  console.log('Easystroke: 初始化完成');
});

// 初始化画布
function initCanvas() {
  canvas = document.getElementById('gesture-canvas');
  ctx = canvas.getContext('2d');
  
  // 设置画布尺寸
  resizeCanvas();
  window.addEventListener('resize', resizeCanvas);
}

function resizeCanvas() {
  const container = canvas.parentElement;
  canvas.width = container.clientWidth;
  canvas.height = container.clientHeight;
  
  // 清空画布 + 重置路径状态
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.beginPath();
  
  // 如果有当前绘制的点,重绘
  if (currentPoints.length > 0) {
    redrawCurrentGesture();
  }
}

// 绑定事件
function bindEvents() {
  // 画布鼠标事件
  canvas.addEventListener('mousedown', startDrawing);
  canvas.addEventListener('mousemove', draw);
  canvas.addEventListener('mouseup', stopDrawing);
  canvas.addEventListener('mouseleave', stopDrawing);
  
  // 触摸事件(支持触屏)
  canvas.addEventListener('touchstart', handleTouchStart);
  canvas.addEventListener('touchmove', handleTouchMove);
  canvas.addEventListener('touchend', stopDrawing);
  
  // 按钮事件
  document.getElementById('btn-clear').addEventListener('click', clearCanvas);
  document.getElementById('btn-save').addEventListener('click', saveGesture);
  document.getElementById('btn-refresh').addEventListener('click', loadGestures);
  document.getElementById('btn-browse').addEventListener('click', openFileBrowser);
}

// 触摸事件处理
function handleTouchStart(e) {
  e.preventDefault();
  const touch = e.touches[0];
  const mouseEvent = new MouseEvent('mousedown', {
    clientX: touch.clientX,
    clientY: touch.clientY
  });
  canvas.dispatchEvent(mouseEvent);
}

function handleTouchMove(e) {
  e.preventDefault();
  const touch = e.touches[0];
  const mouseEvent = new MouseEvent('mousemove', {
    clientX: touch.clientX,
    clientY: touch.clientY
  });
  canvas.dispatchEvent(mouseEvent);
}

// 开始绘制
function startDrawing(e) {
  isDrawing = true;
  currentPoints = [];
  
  const rect = canvas.getBoundingClientRect();
  const x = e.clientX - rect.left;
  const y = e.clientY - rect.top;
  
  currentPoints.push({ x, y });
  
  // 彻底清空画布(清除所有像素 + 重置路径状态)
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.beginPath();
  
  // 绘制起点
  ctx.beginPath();
  ctx.arc(x, y, 5, 0, Math.PI * 2);
  ctx.fillStyle = '#48bb78';
  ctx.fill();
  
  showMessage('正在绘制...', 'info');
}

// 绘制中
function draw(e) {
  if (!isDrawing) return;
  
  const rect = canvas.getBoundingClientRect();
  const x = e.clientX - rect.left;
  const y = e.clientY - rect.top;
  
  // 添加点
  currentPoints.push({ x, y });
  
  // 绘制线条
  if (currentPoints.length > 1) {
    const prevPoint = currentPoints[currentPoints.length - 2];
    
    ctx.beginPath();
    ctx.moveTo(prevPoint.x, prevPoint.y);
    ctx.lineTo(x, y);
    ctx.strokeStyle = '#667eea';
    ctx.lineWidth = 3;
    ctx.lineCap = 'round';
    ctx.stroke();
  }
}

// 停止绘制
function stopDrawing() {
  if (!isDrawing) return;
  isDrawing = false;
  
  if (currentPoints.length < MIN_POINTS_FOR_RECOGNITION) {
    showMessage('手势太短,请重新绘制', 'error');
    clearCanvas();
    return;
  }
  
  // 绘制终点
  const lastPoint = currentPoints[currentPoints.length - 1];
  ctx.beginPath();
  ctx.arc(lastPoint.x, lastPoint.y, 5, 0, Math.PI * 2);
  ctx.fillStyle = '#f56565';
  ctx.fill();
  
  showMessage(`绘制完成,共 ${currentPoints.length} 个点`, 'success');
  
  // 先尝试识别手势
  gestureRecognized = false; // ✅ 重置标志
  recognizeGesture();
  
  // 如果识别失败,再显示保存界面
  setTimeout(() => {
    // ✅ 使用标志位判断是否识别成功
    if (!gestureRecognized) {
      // 识别失败,显示保存界面
      document.getElementById('btn-save').disabled = false;
      document.getElementById('gesture-info').style.display = 'block';
      showMessage('未识别到手势,可以保存为新手势', 'info');
    }
    // 如果识别成功,不需要显示保存界面,3秒后自动清空画布
  }, 500);
}

// 重绘当前手势
function redrawCurrentGesture() {
  if (currentPoints.length === 0) return;
  
  // 彻底清空画布 + 重置路径状态
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.beginPath();
  
  // 绘制起点
  ctx.beginPath();
  ctx.arc(currentPoints[0].x, currentPoints[0].y, 5, 0, Math.PI * 2);
  ctx.fillStyle = '#48bb78';
  ctx.fill();
  
  // 绘制路径
  ctx.beginPath();
  ctx.moveTo(currentPoints[0].x, currentPoints[0].y);
  
  for (let i = 1; i < currentPoints.length; i++) {
    ctx.lineTo(currentPoints[i].x, currentPoints[i].y);
  }
  
  ctx.strokeStyle = '#667eea';
  ctx.lineWidth = 3;
  ctx.lineCap = 'round';
  ctx.stroke();
  
  // 绘制终点
  const lastPoint = currentPoints[currentPoints.length - 1];
  ctx.beginPath();
  ctx.arc(lastPoint.x, lastPoint.y, 5, 0, Math.PI * 2);
  ctx.fillStyle = '#f56565';
  ctx.fill();
}

// 清空画布
function clearCanvas() {
  // 彻底清空画布(清除所有像素)
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  
  // 重置绘制状态(避免路径污染)
  ctx.beginPath();
  
  // 清空当前手势数据
  currentPoints = [];
  isDrawing = false;
  
  // 重置 UI 状态
  document.getElementById('btn-save').disabled = true;
  document.getElementById('gesture-info').style.display = 'none';
  document.getElementById('gesture-name').value = '';
  document.getElementById('gesture-app-path').value = '';
  document.getElementById('gesture-action').value = 'copy';
  document.getElementById('app-path-item').style.display = 'none';
  
  showMessage('画布已清空', 'success');
}

// 处理动作类型变化
function handleActionChange() {
  const action = document.getElementById('gesture-action').value;
  const appPathItem = document.getElementById('app-path-item');
  const appPathLabel = document.getElementById('app-path-label');
  const appPathInput = document.getElementById('gesture-app-path');
  const browseBtn = document.getElementById('btn-browse');
  
  if (action === 'open-app') {
    appPathItem.style.display = 'flex';
    appPathLabel.textContent = '应用路径:';
    appPathInput.placeholder = '选择或输入应用路径';
    if (browseBtn) browseBtn.style.display = 'inline-block';
  } else if (action === 'open-url') {
    appPathItem.style.display = 'flex';
    appPathLabel.textContent = '网址:';
    appPathInput.placeholder = '例如:https://www.example.com';
    if (browseBtn) browseBtn.style.display = 'none';
  } else if (action === 'run-command') {
    appPathItem.style.display = 'flex';
    appPathLabel.textContent = '命令:';
    appPathInput.placeholder = '例如:echo Hello World';
    if (browseBtn) browseBtn.style.display = 'none';
  } else {
    appPathItem.style.display = 'none';
    if (browseBtn) browseBtn.style.display = 'none';
  }
}

// 打开文件选择对话框(使用 Electron 原生对话框)
async function openFileBrowser() {
  try {
    showMessage('正在准备打开文件选择器...', 'info');
    
    // ✅ 等待 500ms,确保 UI 稳定
    await new Promise(resolve => setTimeout(resolve, 500));
    
    const result = await window.electronAPI.browseFiles();
    
    if (result.success) {
      document.getElementById('gesture-app-path').value = result.path;
      showMessage(`已选择: ${result.path}`, 'success');
    } else {
      if (result.error !== '用户取消选择') {
        showMessage('选择失败: ' + result.error, 'error');
      } else {
        showMessage('已取消选择', 'info');
      }
    }
  } catch (error) {
    console.error('Easystroke: 打开文件对话框失败:', error);
    showMessage('打开文件对话框失败,请重试', 'error');
  }
}

// 保存手势
async function saveGesture() {
  const name = document.getElementById('gesture-name').value.trim();
  const action = document.getElementById('gesture-action').value;
  const appPath = document.getElementById('gesture-app-path').value.trim();
  
  if (!name) {
    showMessage('请输入手势名称', 'error');
    return;
  }
  
  if (currentPoints.length === 0) {
    showMessage('请先绘制手势', 'error');
    return;
  }
  
  // 验证应用路径
  if ((action === 'open-app' || action === 'open-url' || action === 'run-command') && !appPath) {
    showMessage('请输入' + (action === 'open-app' ? '应用路径' : action === 'open-url' ? '网址' : '命令'), 'error');
    return;
  }
  
  showMessage('正在保存...', 'info');
  
  try {
    const gestureData = {
      name: name,
      action: action,
      points: currentPoints,
      appPath: appPath || null
    };
    
    const result = await window.electronAPI.saveGesture(gestureData);
    
    if (result.success) {
      showMessage('手势保存成功', 'success');
      clearCanvas();
      loadGestures();
    } else {
      showMessage('保存失败: ' + result.error, 'error');
    }
  } catch (error) {
    console.error('Easystroke: 保存手势失败:', error);
    showMessage('保存失败: ' + error.message, 'error');
  }
}

// 加载手势列表
async function loadGestures() {
  try {
    const result = await window.electronAPI.loadGestures();
    
    if (result.error) {
      showMessage('加载失败: ' + result.error, 'error');
      return;
    }
    
    gestures = result.gestures || [];
    
    renderGestureList();
    
    if (gestures.length === 0) {
      showMessage('暂无手势,请绘制并保存新手势', 'info');
    } else {
      showMessage(`已加载 ${gestures.length} 个手势`, 'success');
    }
  } catch (error) {
    console.error('Easystroke: 加载手势失败:', error);
    showMessage('加载失败: ' + error.message, 'error');
  }
}

// 渲染手势列表
function renderGestureList() {
  const listContainer = document.getElementById('gesture-list');
  
  if (gestures.length === 0) {
    listContainer.innerHTML = `
      <div class="empty-state">
        <div class="empty-icon">📭</div>
        <p>暂无手势,请先绘制并保存</p>
      </div>
    `;
    return;
  }
  
  listContainer.innerHTML = '';
  
  gestures.forEach(gesture => {
    const gestureItem = document.createElement('div');
    gestureItem.className = 'gesture-item';
    
    const actionLabel = getActionLabel(gesture.action);
    const appPathInfo = gesture.appPath ? `<br><small style="color: #718096;">${gesture.appPath}</small>` : '';
    
    gestureItem.innerHTML = `
      <div class="gesture-item-header">
        <span class="gesture-name">${gesture.name}</span>
        <span class="gesture-action">${actionLabel}</span>
      </div>
      <canvas class="gesture-preview" id="preview-${gesture.id}" width="200" height="60"></canvas>
      <div class="gesture-item-actions">
        <button class="btn btn-small" onclick="testGesture(${gesture.id})">测试</button>
        <button class="btn btn-small btn-danger" onclick="deleteGesture(${gesture.id})">删除</button>
      </div>
      ${appPathInfo}
    `;
    
    listContainer.appendChild(gestureItem);
    
    // 绘制手势预览
    setTimeout(() => {
      drawGesturePreview(gesture);
    }, 0);
  });
}

// 绘制手势预览
function drawGesturePreview(gesture) {
  const previewCanvas = document.getElementById(`preview-${gesture.id}`);
  if (!previewCanvas) return;
  
  const previewCtx = previewCanvas.getContext('2d');
  const points = gesture.points;
  
  if (points.length === 0) return;
  
  // ✅ 清空预览画布 + 重置路径状态
  previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
  previewCtx.beginPath();
  
  // 计算边界
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  points.forEach(p => {
    minX = Math.min(minX, p.x);
    minY = Math.min(minY, p.y);
    maxX = Math.max(maxX, p.x);
    maxY = Math.max(maxY, p.y);
  });
  
  const width = maxX - minX;
  const height = maxY - minY;
  const scale = Math.min(180 / width, 50 / height);
  
  // 绘制路径
  previewCtx.beginPath();
  previewCtx.moveTo(
    (points[0].x - minX) * scale + 10,
    (points[0].y - minY) * scale + 5
  );
  
  for (let i = 1; i < points.length; i++) {
    previewCtx.lineTo(
      (points[i].x - minX) * scale + 10,
      (points[i].y - minY) * scale + 5
    );
  }
  
  previewCtx.strokeStyle = '#667eea';
  previewCtx.lineWidth = 2;
  previewCtx.lineCap = 'round';
  previewCtx.stroke();
}

// 获取动作标签
function getActionLabel(action) {
  const actionMap = {
    'copy': '复制',
    'paste': '粘贴',
    'undo': '撤销',
    'redo': '重做',
    'save': '保存',
    'close': '关闭',
    'open-app': '打开应用',
    'open-url': '打开网址',
    'run-command': '执行命令'
  };
  return actionMap[action] || action;
}

// 测试手势(暴露到全局)
window.testGesture = function(id) {
  const gesture = gestures.find(g => g.id === id);
  if (!gesture) return;
  
  showMessage(`测试手势: ${gesture.name} -> ${getActionLabel(gesture.action)}`, 'info');
  
  // ✅ 延迟执行动作(避免与 UI 操作冲突)
  setTimeout(() => {
    executeGestureAction(gesture);
  }, 500);
};

// 删除手势(暴露到全局)
window.deleteGesture = async function(id) {
  if (!confirm('确定要删除这个手势吗?')) {
    return;
  }
  
  try {
    const result = await window.electronAPI.deleteGesture(id);
    
    if (result.success) {
      showMessage('手势已删除', 'success');
      loadGestures();
    } else {
      showMessage('删除失败: ' + result.error, 'error');
    }
  } catch (error) {
    console.error('Easystroke: 删除手势失败:', error);
    showMessage('删除失败: ' + error.message, 'error');
  }
}

// 显示状态消息
function showMessage(message, type) {
  const statusBar = document.getElementById('status-bar');
  statusBar.textContent = message;
  statusBar.className = 'status-bar status-' + type;
  
  if (type !== 'error') {
    setTimeout(function() {
      statusBar.textContent = '就绪';
      statusBar.className = 'status-bar';
    }, 3000);
  }
}

// ==================== 手势识别算法 ====================

// 提取手势几何特征
function extractFeatures(points) {
  if (points.length < 2) return null;
  
  // 计算实际路径长度
  let pathLength = 0;
  for (let i = 1; i < points.length; i++) {
    const dx = points[i].x - points[i-1].x;
    const dy = points[i].y - points[i-1].y;
    pathLength += Math.sqrt(dx * dx + dy * dy);
  }
  
  // 起点终点直线距离
  const start = points[0];
  const end = points[points.length - 1];
  const directDistance = Math.sqrt(
    Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2)
  );
  
  // 计算边界框
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  points.forEach(p => {
    minX = Math.min(minX, p.x);
    minY = Math.min(minY, p.y);
    maxX = Math.max(maxX, p.x);
    maxY = Math.max(maxY, p.y);
  });
  
  const width = maxX - minX;
  const height = maxY - minY;
  const avgSize = (width + height) / 2;
  
  // 弯曲度:实际路径长度 / 起点终点直线距离
  const curvature = directDistance > 0 ? pathLength / directDistance : 999;
  
  // 闭合度:起点终点距离 / 平均尺寸
  const closedness = avgSize > 0 ? directDistance / avgSize : 1;
  
  // 主方向:起点到终点的角度
  const mainDirection = Math.atan2(end.y - start.y, end.x - start.x) * 180 / Math.PI;
  
  // 方向变化次数(使用峰值检测算法,更准确)
  let directionChanges = 0;
  const directions = [];
  
  // 计算每段的方向角
  for (let i = 1; i < points.length; i++) {
    const dx = points[i].x - points[i-1].x;
    const dy = points[i].y - points[i-1].y;
    directions.push(Math.atan2(dy, dx) * 180 / Math.PI);
  }
  
  // ✅ 改进:检测方向角的峰值(转折点)
  // 只统计方向变化 > 90 度的明显转折
  const TURN_THRESHOLD = 90; // 提高阈值,避免手绘抖动
  let lastValidDirection = directions[0];
  
  for (let i = 1; i < directions.length; i++) {
    let diff = Math.abs(directions[i] - lastValidDirection);
    if (diff > 180) diff = 360 - diff;
    
    // 只有方向变化超过阈值才计数
    if (diff > TURN_THRESHOLD) {
      directionChanges++;
      lastValidDirection = directions[i]; // 更新基准方向
    }
  }
  
  return {
    curvature,        // 弯曲度
    closedness,       // 闭合度
    mainDirection,    // 主方向
    directionChanges, // 方向变化次数
    pathLength,       // 路径长度
    directDistance,   // 直线距离
    avgSize,          // 平均尺寸
    width,            // 宽度
    height            // 高度
  };
}

// 计算两个特征向量的相似度
function calculateSimilarity(features1, features2) {
  if (!features1 || !features2) return 1.0;
  
  // 归一化差异计算
  const curvatureDiff = Math.abs(features1.curvature - features2.curvature) / Math.max(features1.curvature, features2.curvature, 1);
  const closednessDiff = Math.abs(features1.closedness - features2.closedness);
  const directionDiff = Math.abs(features1.mainDirection - features2.mainDirection) / 180;
  const sizeRatio = Math.abs(features1.avgSize - features2.avgSize) / Math.max(features1.avgSize, features2.avgSize, 1);
  
  // ✅ 新增:方向变化次数差异(区分直线、曲线、折线)
  const directionChangesDiff = Math.abs(features1.directionChanges - features2.directionChanges) / Math.max(features1.directionChanges, features2.directionChanges, 1);
  
  // ✅ 新增:路径长度与直线距离比率(区分绕路和直接)
  const pathRatio1 = features1.pathLength / Math.max(features1.directDistance, 1);
  const pathRatio2 = features2.pathLength / Math.max(features2.directDistance, 1);
  const pathRatioDiff = Math.abs(pathRatio1 - pathRatio2) / Math.max(pathRatio1, pathRatio2, 1);
  
  // ✅ 加权相似度(6 个特征,更精准)
  const similarity = (
    curvatureDiff * 0.2 +         // 弯曲度
    closednessDiff * 0.2 +        // 闭合度
    directionDiff * 0.1 +         // 主方向
    sizeRatio * 0.1 +             // 尺寸
    directionChangesDiff * 0.25 + // 方向变化次数(提高权重,区分五角星和圆形)
    pathRatioDiff * 0.15          // 路径效率
  );
  
  return similarity;
}

// 识别手势
function recognizeGesture() {
  console.log('Easystroke: 开始识别手势,当前手势库数量:', gestures.length);
  
  if (gestures.length === 0) {
    console.log('Easystroke: 手势库为空,无法识别');
    showMessage('手势库为空,请先保存手势', 'error');
    return;
  }
  
  if (currentPoints.length < MIN_POINTS_FOR_RECOGNITION) {
    console.log('Easystroke: 点数不足', currentPoints.length);
    return;
  }
  
  // 提取当前手势特征
  const currentFeatures = extractFeatures(currentPoints);
  if (!currentFeatures) {
    console.log('Easystroke: 特征提取失败');
    return;
  }
  
  console.log('Easystroke: 当前手势特征', currentFeatures);
  
  // 与已保存的手势进行匹配
  let bestMatch = null;
  let bestSimilarity = Infinity;
  
  gestures.forEach(gesture => {
    const savedFeatures = extractFeatures(gesture.points);
    if (!savedFeatures) return;
    
    const similarity = calculateSimilarity(currentFeatures, savedFeatures);
    
    console.log(`Easystroke: 手势 "${gesture.name}" 相似度: ${similarity.toFixed(3)}`);
    
    if (similarity < bestSimilarity) {
      bestSimilarity = similarity;
      bestMatch = gesture;
    }
  });
  
  console.log(`Easystroke: 最佳匹配: ${bestMatch ? bestMatch.name : '无'}, 相似度: ${bestSimilarity.toFixed(3)}, 阈值: ${SIMILARITY_THRESHOLD}`);
  
  // 使用更严格的阈值 0.2(避免相似手势误识别)
  if (bestMatch && bestSimilarity < SIMILARITY_THRESHOLD) {
    gestureRecognized = true; // ✅ 设置识别成功标志
    console.log(`Easystroke: 识别成功 - ${bestMatch.name} (相似度: ${bestSimilarity.toFixed(3)})`);
    showMessage(`✓ 识别: ${bestMatch.name} -> ${getActionLabel(bestMatch.action)}`, 'success');
    
    // 延迟执行动作(避免与绘制操作冲突,等待 1 秒)
    setTimeout(() => {
      executeGestureAction(bestMatch);
    }, 1000);
    
    // 识别成功后,延迟清空画布
    setTimeout(() => {
      clearCanvas();
    }, 3000);
  } else {
    gestureRecognized = false; // ✅ 设置识别失败标志
    console.log('Easystroke: 未匹配到已知手势');
    showMessage(`未识别到手势(最佳相似度: ${bestSimilarity.toFixed(3)}),请保存新手势`, 'info');
  }
}

// 执行手势绑定的动作
async function executeGestureAction(gesture) {
  try {
    const result = await window.electronAPI.executeAction(gesture.action, gesture.appPath);
    
    if (result.success) {
      showMessage(`✓ ${result.message}`, 'success');
    } else {
      showMessage(`✗ ${result.error}`, 'error');
    }
  } catch (error) {
    console.error('Easystroke: 执行动作失败:', error);
    showMessage('执行动作失败', 'error');
  }
}

关键要点

  • Canvas 绘制:支持鼠标和触摸事件,起点绿色标记、终点红色标记
  • 6 维度特征提取:弯曲度、闭合度、主方向、方向变化次数、路径效率、尺寸
  • 加权相似度算法:方向变化次数权重最高(0.25),区分折线和曲线
  • 阈值 0.2:严格识别,避免相似手势(如 N 和 M)误识别
  • 标志位机制:gestureRecognized 避免竞态条件,正确显示保存界面
  • 7 种动作绑定:复制、粘贴、撤销、重做、打开应用、打开网址、执行命令
  • 手势持久化:保存到 gestures.json,启动时自动加载
  • 全中文日志输出,便于调试

3.6 第六步:编写现代化样式文件(easystroke.css)

文件:web_engine/src/main/resources/resfile/resources/app/styles/easystroke.css

css 复制代码
/* Easystroke 鼠标手势识别工具 - 现代化 UI */

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: #2d3748;
  overflow: hidden;
  height: 100vh;
}

/* 主容器 */
.main-container {
  position: absolute; /* 鸿蒙 ArkWeb 兼容:使用 absolute 替代 fixed */
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(255, 255, 255, 0.95);
  display: flex;
  flex-direction: column;
}

/* 标题栏 */
.header {
  background: linear-gradient(to right, #ffffff, #f8f9fa);
  border-bottom: 1px solid #e2e8f0;
  padding: 16px 24px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}

.header h1 {
  font-size: 24px;
  font-weight: 700;
  color: #4a5568;
  margin-bottom: 4px;
}

.subtitle {
  font-size: 14px;
  color: #718096;
}

/* 内容区域 */
.content {
  flex: 1;
  display: flex;
  gap: 16px;
  padding: 16px 24px;
  overflow: hidden;
}

/* 面板通用样式 */
.draw-panel,
.library-panel {
  background: #ffffff;
  border-radius: 12px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
  border: 1px solid #e2e8f0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.draw-panel {
  flex: 2;
}

.library-panel {
  flex: 1;
}

.panel-header {
  background: linear-gradient(to right, #f7fafc, #edf2f7);
  padding: 12px 16px;
  border-bottom: 2px solid #e2e8f0;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.panel-header h2 {
  font-size: 16px;
  font-weight: 600;
  color: #4a5568;
}

.panel-actions {
  display: flex;
  gap: 8px;
}

/* 按钮样式 */
.btn {
  padding: 8px 16px;
  border: 1px solid #e2e8f0;
  background: #ffffff;
  border-radius: 8px;
  cursor: pointer;
  font-size: 14px;
  font-weight: 500;
  color: #4a5568;
  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}

.btn:hover {
  background: #f7fafc;
  border-color: #cbd5e0;
  transform: translateY(-1px);
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}

.btn:active {
  transform: translateY(0);
}

.btn-primary {
  background: linear-gradient(135deg, #48bb78 0%, #38a169 100%);
  color: white;
  border-color: transparent;
  box-shadow: 0 4px 12px rgba(72, 187, 120, 0.3);
}

.btn-primary:hover {
  background: linear-gradient(135deg, #38a169 0%, #2f855a 100%);
  box-shadow: 0 6px 16px rgba(72, 187, 120, 0.4);
}

.btn-primary:disabled {
  background: linear-gradient(135deg, #a0aec0 0%, #718096 100%);
  color: #ffffff;
  box-shadow: 0 2px 6px rgba(160, 174, 192, 0.3);
  opacity: 0.7;
  cursor: not-allowed;
}

.btn-secondary {
  background: #ffffff;
  color: #4a5568;
}

/* 画布容器 */
.canvas-container {
  flex: 1;
  position: relative;
  background: #f7fafc;
  margin: 16px;
  border-radius: 8px;
  border: 2px dashed #cbd5e0;
  overflow: hidden;
}

#gesture-canvas {
  width: 100%;
  height: 100%;
  cursor: crosshair;
}

.canvas-hint {
  position: absolute;
  bottom: 12px;
  left: 50%;
  transform: translateX(-50%);
  background: rgba(0, 0, 0, 0.7);
  color: white;
  padding: 8px 16px;
  border-radius: 20px;
  font-size: 13px;
  pointer-events: none;
}

/* 手势信息 */
.gesture-info {
  padding: 16px;
  border-top: 1px solid #e2e8f0;
  background: #f7fafc;
}

.info-item {
  margin-bottom: 12px;
  display: flex;
  align-items: center;
  gap: 12px;
}

.info-item label {
  font-weight: 600;
  color: #4a5568;
  min-width: 80px;
}

.info-item input,
.info-item select {
  flex: 1;
  padding: 8px 12px;
  border: 1px solid #e2e8f0;
  border-radius: 6px;
  font-size: 14px;
  background: #ffffff;
}

.info-item input:focus,
.info-item select:focus {
  outline: none;
  border-color: #667eea;
  box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}

/* 路径输入组 */
.path-input-group {
  display: flex;
  gap: 8px;
  flex: 1;
}

.path-input-group input {
  flex: 1;
}

/* 手势列表 */
.gesture-list {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
}

.empty-state {
  text-align: center;
  padding: 40px 20px;
  color: #a0aec0;
}

.empty-icon {
  font-size: 48px;
  margin-bottom: 12px;
}

.empty-state p {
  font-size: 14px;
}

.gesture-item {
  background: #ffffff;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  padding: 12px;
  margin-bottom: 12px;
  transition: all 0.2s;
}

.gesture-item:hover {
  border-color: #667eea;
  box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
}

.gesture-item-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
}

.gesture-name {
  font-weight: 600;
  color: #2d3748;
  font-size: 15px;
}

.gesture-action {
  background: #edf2f7;
  color: #4a5568;
  padding: 4px 10px;
  border-radius: 4px;
  font-size: 12px;
  font-weight: 500;
}

.gesture-preview {
  height: 60px;
  background: #f7fafc;
  border-radius: 4px;
  margin-bottom: 8px;
}

.gesture-item-actions {
  display: flex;
  gap: 8px;
}

.btn-small {
  padding: 4px 12px;
  font-size: 12px;
}

.btn-danger {
  background: linear-gradient(135deg, #fc8181 0%, #f56565 100%);
  color: white;
  border-color: transparent;
}

.btn-danger:hover {
  background: linear-gradient(135deg, #f56565 0%, #e53e3e 100%);
}

/* 状态栏 */
.status-bar {
  background: linear-gradient(to right, #ffffff, #f7fafc);
  border-top: 1px solid #e2e8f0;
  padding: 10px 24px;
  font-size: 13px;
  color: #4a5568;
  font-weight: 500;
  box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
}

.status-bar.status-success {
  background: linear-gradient(to right, #c6f6d5, #9ae6b4);
  color: #22543d;
}

.status-bar.status-error {
  background: linear-gradient(to right, #fed7d7, #feb2b2);
  color: #742a2a;
}

.status-bar.status-info {
  background: linear-gradient(to right, #bee3f8, #90cdf4);
  color: #2c5282;
}

/* 滚动条 - 鸿蒙 ArkWeb 不支持 ::-webkit-scrollbar,已移除 */

关键要点

  • 渐变背景设计:紫色主题(#667eea → #764ba2)+ 毛玻璃效果
  • 卡片化布局:每个信息块都是独立卡片(8px 圆角 + 阴影)
  • 按钮渐变:主要按钮绿色/危险按钮红色/警告按钮橙色
  • 悬停动画:transform: translateY(-1px) 上浮效果
  • Canvas 画布:虚线边框,十字光标,居中提示文字
  • 手势列表:Grid 布局,自动填充,悬停高亮
  • 鸿蒙 ArkWeb 兼容:不使用 CSS 变量(var(--xxx)),直接写实际颜色值
  • 淡入动画:手势项 0.3s、输入框 0.4s
  • 字体优化:系统字体栈,Microsoft YaHei 中文支持

四、部署到鸿蒙平台

4.1 项目结构说明

开发****工作流

  1. 直接在 electron-apps/easystroke/ 中修改代码
  2. 同步到 web_engine/src/main/resources/resfile/resources/app/
  3. 在 DevEco Studio 中构建并运行
  4. 真机测试验证

4.2 构建 HAP 包

在 DevEco Studio 中:

  1. 打开项目根目录 ohos_hap/
  2. 点击 Build > Build Hap(s)/APP(s)
  3. 选择 Build Hap(s)
  4. 等待构建完成

4.3 真机测试

  1. 连接鸿蒙设备(或启动模拟器)
  2. 点击 Run > Run 'entry'
  3. 安装完成后,应用会自动启动

五、常见问题 FAQ

Q1:手势识别不准确,经常误识别?

问题现象:绘制 N 手势时,系统识别为 M 手势

根本原因:相似度阈值过高(0.3),N 和 M 都是折线手势,特征相似

真实代码(renderer.js 第 12 行):

javascript 复制代码
// ✅ 正确代码(降低阈值到 0.2)
const SIMILARITY_THRESHOLD = 0.2; // ✅ 降低阈值到 0.2,避免 N 和 M 相似手势误识别

解决方案关键点:

  • 降低阈值从 0.3 到 0.2,严格识别
  • 6 维度特征中,方向变化次数权重最高(0.25),区分 N(2 次转折)和 M(3 次转折)
  • 如果仍然误识别,可进一步降低到 0.15
  • 建议重新保存手势,确保转折点清晰

Q2:手势识别失败后,没有提示保存新手势?

问题现象:绘制未知手势后,只显示"就绪",没有显示保存界面

根本原因:showMessage 的 3 秒定时器与状态检查冲突,导致竞态条件

真实代码(renderer.js 第 158-172 行):

javascript 复制代码
// 先尝试识别手势
gestureRecognized = false; // ✅ 重置标志
recognizeGesture();

// 如果识别失败,再显示保存界面
setTimeout(() => {
  // ✅ 使用标志位判断是否识别成功
  if (!gestureRecognized) {
    // 识别失败,显示保存界面
    document.getElementById('btn-save').disabled = false;
    document.getElementById('gesture-info').style.display = 'block';
    showMessage('未识别到手势,可以保存为新手势', 'info');
  }
  // 如果识别成功,不需要显示保存界面,3秒后自动清空画布
}, 500);

解决方案关键点:

  • 添加全局标志 gestureRecognized(第 6 行)
  • 识别前重置标志:gestureRecognized = false(第 159 行)
  • 识别成功设置:gestureRecognized = true(第 676 行)
  • 识别失败设置:gestureRecognized = false(第 690 行)
  • 使用标志位判断,替代检查状态栏文本

Q3:调用 shell.openExternal 时应用崩溃?

问题现象:执行"打开网址"动作时,应用 SIGABRT 崩溃

根本原因:XComponent 还没准备好,WaitForXComponentCreated 超时

真实代码(main.js 第 211-249 行):

javascript 复制代码
case 'open-url':
  // 打开 URL
  if (!appPath) {
    return { success: false, error: '未指定 URL' };
  }
  
  try {
    // 确保 URL 包含协议
    let url = appPath;
    if (!url.startsWith('http://') && !url.startsWith('https://')) {
      url = 'https://' + url;
    }
    
    console.log('Easystroke: 准备打开 URL', url);
    
    // ✅ 防护 1:检查窗口状态
    if (!mainWindow || mainWindow.isDestroyed()) {
      console.error('Easystroke: 窗口已销毁,无法打开 URL');
      return { success: false, error: '窗口未就绪' };
    }
    
    // ✅ 防护 2:延迟执行,避免与绘制操作冲突(增加到 1000ms)
    await new Promise(resolve => setTimeout(resolve, 1000));
    
    // ✅ 防护 3:再次检查窗口状态
    if (!mainWindow || mainWindow.isDestroyed()) {
      console.error('Easystroke: 窗口在延迟后已销毁');
      return { success: false, error: '窗口未就绪' };
    }
    
    // ✅ 防护 4:确保窗口处于聚焦状态
    if (mainWindow.isMinimized()) {
      mainWindow.restore();
    }
    mainWindow.focus();
    
    console.log('Easystroke: 执行打开 URL 操作');
    await shell.openExternal(url);
    return { success: true, message: `已打开: ${url}` };
  } catch (err) {
    return { success: false, error: `打开 URL 失败: ${err.message}` };
  }

解决方案关键点:

  • 延迟 1000ms,等待 XComponent 初始化完成
  • 延迟前后双重检查窗口状态
  • 确保窗口聚焦和恢复
  • URL 协议自动补全
  • 异常捕获和错误提示

Q4:Canvas 绘制不流畅,路径断裂?

问题现象:快速绘制手势时,路径出现断裂或锯齿

根本原因:Canvas 状态管理不规范,没有正确调用 beginPath

真实代码(renderer.js 第 150-154 行):

javascript 复制代码
// 绘制终点
const lastPoint = currentPoints[currentPoints.length - 1];
ctx.beginPath(); // ⭐ 关键:开始新路径
ctx.arc(lastPoint.x, lastPoint.y, 5, 0, Math.PI * 2);
ctx.fillStyle = '#f56565';
ctx.fill();

真实代码(renderer.js 第 179-180 行):

javascript 复制代码
// 彻底清空画布 + 重置路径状态
ctx.clearRect(0, 0, canvas.width, canvas.height); // 清空内容
ctx.beginPath(); // ⭐ 重置路径状态

解决方案关键点:

  • Canvas 状态管理三重规范:clearRect + beginPath + 绘制前 beginPath
  • 每次 moveTo/lineTo/arc 前必须调用 beginPath
  • 清空画布时同时调用 clearRect 和 beginPath
  • 使用 lineCap: 'round' 和 lineJoin: 'round' 平滑路径

Q5:手势列表不显示已保存的手势?

问题现象:保存手势后,刷新应用手势列表为空

根本原因:应用启动时没有调用 loadGestures() 加载本地数据

真实代码(renderer.js 第 15-21 行):

javascript 复制代码
document.addEventListener('DOMContentLoaded', function() {
  console.log('Easystroke: 初始化');
  initCanvas();
  bindEvents();
  loadGestures(); // ⭐ 加载用户手势
  console.log('Easystroke: 初始化完成');
});

解决方案关键点:

  • DOMContentLoaded 事件中必须调用 loadGestures()
  • loadGestures() 使用 async/await 异步加载
  • 加载成功后调用 updateGestureList() 更新 UI
  • 调用 updateGestureCount() 更新手势数量显示

Q6:鸿蒙平台 CSS 样式不生效,页面显示异常?

问题现象:在鸿蒙设备上运行时,部分 CSS 样式没有生效

根本原因 1:鸿蒙 ArkWeb 不支持 CSS 自定义属性(变量)var(--xxx)

根本原因 2:鸿蒙 ArkWeb 不支持 position: fixed

真实代码(easystroke.css 第 18-27 行):

css 复制代码
/* 主容器 */
.main-container {
  position: absolute; /* 鸿蒙 ArkWeb 兼容:使用 absolute 替代 fixed */
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(255, 255, 255, 0.95);
  display: flex;
  flex-direction: column;
}

真实代码(easystroke.css 第 9-15 行):

css 复制代码
body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); /* ⭐ 渐变背景,不是 var(--bg-gradient) */
  color: #2d3748;
  overflow: hidden;
  height: 100vh;
}

解决方案关键点:

  • 将所有 var(--xxx) 替换为实际值
  • 鸿蒙 ArkWeb 不支持 CSS 自定义属性
  • 使用 position: absolute 替代 position: fixed
  • 项目已 100% 去除 CSS 变量
  • 其他 CSS 特性(flex、transition、transform、gradient)均支持
  • 使用语义化颜色命名

Q7:手势保存失败,报错 "gestures.json not found"?

问题现象:点击"保存手势"按钮后,提示保存失败

根本原因:主进程 save-gestures IPC 处理器路径错误

真实代码(main.js 第 145-157 行):

javascript 复制代码
// 保存手势数据
ipcMain.handle('save-gestures', async (event, gestures) => {
  try {
    const dataPath = path.join(__dirname, 'gestures.json'); // ⭐ 使用 __dirname
    fs.writeFileSync(dataPath, JSON.stringify(gestures, null, 2), 'utf8');
    return { success: true };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

解决方案关键点:

  • 使用 path.join(__dirname, 'gestures.json') 相对路径
  • __dirname 指向 main.js 所在目录
  • 使用 JSON.stringify(gestures, null, 2) 格式化输出
  • 指定 'utf8' 编码,避免中文乱码
  • 异常捕获并返回错误信息