主题切换
第 10 章:综合项目:RPA 桌面助手
本章将综合运用前面所有章节的知识,从零构建一个模仿影刀 RPA 的桌面自动化助手。项目包含可视化流程编辑器、操作录制与回放、数据管理等核心功能,是一个完整的生产级 Electron 应用。
10.1 项目需求分析
影刀 RPA 是一款流行的桌面自动化工具,核心功能包括:可视化流程编排、鼠标键盘录制、自动化任务执行、数据表格管理等。我们将实现一个简化但功能完整的版本。
功能清单
| 模块 | 功能 | 技术点 |
|---|---|---|
| 流程编辑器 | 拖拽式节点编排 | Vue3 + 自定义拖拽 |
| 操作录制 | 记录鼠标/键盘操作 | @nut-tree/nut.js |
| 任务回放 | 执行录制的操作序列 | IPC + 工作线程 |
| 数据管理 | 变量、数据表格 | Pinia + SQLite |
| 截图识别 | 屏幕截图 + OCR | desktopCapturer + Tesseract.js |
| 任务调度 | 定时执行、循环执行 | Node.js 定时器 |
10.2 架构设计
好的架构是项目成功的一半。我们采用分层设计,将核心自动化逻辑放在主进程,UI 层放在渲染进程。
text
┌─────────────────────────────────────────────────────────┐
│ RPA Desktop Assistant │
├─────────────────────────────────────────────────────────┤
│ 渲染进程 (Renderer) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 流程编辑器 │ │ 任务列表 │ │ 数据表格 │ │
│ │ (Vue3) │ │ (Vue3) │ │ (Vue3) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └─────────────────┼─────────────────┘ │
│ │ │
│ ┌────────────────────────┴────────────────────────┐ │
│ │ IPC (contextBridge) │ │
│ └────────────────────────┬────────────────────────┘ │
├───────────────────────────┼─────────────────────────────┤
│ 主进程 (Main) │ │
│ ┌────────────────────────┴────────────────────────┐ │
│ │ 自动化引擎 (Automation Engine) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ 录制器 │ │ 执行器 │ │ 调度器 │ │ │
│ │ │ Recorder │ │ Executor │ │ Scheduler│ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 数据层 (Data Layer) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ electron-│ │ SQLite │ │ 文件系统 │ │ │
│ │ │ store │ │(better- │ │ │ │ │
│ │ │ │ │ sqlite3) │ │ │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘10.3 项目初始化
使用 electron-vite 创建项目,并安装必要的依赖。
bash
# 创建项目
npm create electron-vite@latest rpa-assistant --template vue-ts
# 进入项目目录
cd rpa-assistant
# 安装核心依赖
npm install @nut-tree/nut-js tesseract.js electron-store pinia vue-router element-plus better-sqlite3
# 安装开发依赖
npm install -D @types/node @types/better-sqlite3 electron-builder electron-updater vitest @vue/test-utils jsdom @electron/rebuild10.4 核心模块实现
自动化引擎(主进程)
AutomationEngine 是整个 RPA 助手的核心——它负责执行所有自动化操作。
javascript
const { mouse, keyboard, screen, clipboard } = require('@nut-tree/nut-js')
const { ipcMain } = require('electron')
const path = require('path')
const { app } = require('electron')
const sharp = require('sharp')
class AutomationEngine {
constructor() {
// 建议使用状态机替代 boolean 组合:
// state: 'idle' | 'recording' | 'playing' | 'paused' | 'error'
this.isRecording = false
this.isPlaying = false
this.actions = []
this.setupIPC()
}
setupIPC() {
ipcMain.handle('automation:start-record', () => {
this.startRecording()
return { success: true }
})
ipcMain.handle('automation:stop-record', () => {
const actions = this.stopRecording()
return { success: true, actions }
})
ipcMain.handle('automation:execute', async (event, actions) => {
try {
await this.executeActions(actions, (progress) => {
event.sender.send('automation:progress', progress)
})
return { success: true }
} catch (error) {
return { success: false, error: error.message }
}
})
ipcMain.handle('automation:stop-execution', () => {
this.stopExecution()
return { success: true }
})
}
startRecording() {
this.isRecording = true
this.actions = []
mouse.config.mouseSpeed = 500
this.recordAction({ type: 'start', timestamp: Date.now() })
}
stopRecording() {
this.isRecording = false
return this.actions
}
recordAction(action) {
if (this.isRecording) {
this.actions.push({ ...action, index: this.actions.length })
}
}
// 执行操作序列 — 完整支持 12 种操作类型
async executeActions(actions, onProgress) {
this.isPlaying = true
const total = actions.length
for (let i = 0; i < total; i++) {
if (!this.isPlaying) break
const action = actions[i]
onProgress({ current: i + 1, total, type: action.type })
switch (action.type) {
case 'click':
await mouse.setPosition({ x: action.x, y: action.y })
await mouse.leftClick()
break
case 'doubleClick':
await mouse.setPosition({ x: action.x, y: action.y })
await mouse.doubleClick()
break
case 'rightClick':
await mouse.setPosition({ x: action.x, y: action.y })
await mouse.rightClick()
break
case 'input':
await keyboard.type(action.text)
break
case 'hotkey':
await keyboard.pressKey(...action.keys)
break
case 'wait':
await new Promise(resolve => setTimeout(resolve, action.duration))
break
case 'scroll':
await mouse.scrollDown(action.amount || 100)
break
case 'drag':
await mouse.setPosition({ x: action.fromX, y: action.fromY })
await mouse.pressButton(0)
await mouse.setPosition({ x: action.toX, y: action.toY })
await mouse.releaseButton(0)
break
case 'getPosition':
const pos = await mouse.getPosition()
action.result = pos
break
case 'screenshot':
const capture = action.region
? await screen.captureRegion(action.region.x, action.region.y, action.region.width, action.region.height)
: await screen.capture()
const ssPath = path.join(app.getPath('temp'), `ss-${Date.now()}.png`)
await sharp(capture.data).png().toFile(ssPath)
action.result = ssPath
break
case 'clipboardGet':
action.result = await clipboard.getContent()
break
case 'clipboardSet':
await clipboard.setContent(action.text)
break
case 'findImage':
const result = await screen.findOnScreen(action.imagePath)
action.result = result
break
default:
console.warn(`未知操作类型: ${action.type}`)
}
}
this.isPlaying = false
}
stopExecution() {
this.isPlaying = false
}
}
module.exports = { AutomationEngine }可视化流程编辑器(Vue3)
vue
<template>
<div class="flow-editor">
<!-- 顶部工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<el-button size="small" @click="addNode('click')">+ 点击</el-button>
<el-button size="small" @click="addNode('input')">+ 输入</el-button>
<el-button size="small" @click="addNode('wait')">+ 等待</el-button>
<el-button size="small" @click="addNode('screenshot')">+ 截图</el-button>
<el-button size="small" @click="addNode('hotkey')">+ 快捷键</el-button>
<el-divider direction="vertical" />
<el-button size="small" type="warning" @click="startRecord" :disabled="isRecording">
{{ isRecording ? '录制中...' : '录制' }}
</el-button>
<el-button size="small" type="success" @click="executeFlow" :disabled="isPlaying">
{{ isPlaying ? '执行中...' : '执行' }}
</el-button>
</div>
<div class="toolbar-right">
<el-button size="small" @click="saveFlow">保存</el-button>
<el-button size="small" @click="loadFlow">加载</el-button>
<el-button size="small" type="danger" @click="clearFlow">清空</el-button>
</div>
</div>
<!-- 主区域 -->
<div class="main-area">
<div class="canvas" ref="canvasRef">
<div
v-for="node in nodes"
:key="node.id"
class="node-item"
:class="{ selected: selectedNode?.id === node.id }"
:style="{ left: node.x + 'px', top: node.y + 'px' }"
@mousedown.stop="selectNode(node, $event)"
>
<div class="node-header">
<span>{{ node.name }}</span>
<button class="node-delete" @click.stop="removeNode(node.id)">x</button>
</div>
<div class="node-body">
<template v-if="node.type === 'click' || node.type === 'doubleClick'">
位置: ({{ node.data.x }}, {{ node.data.y }})
</template>
<template v-else-if="node.type === 'input'">
输入: {{ node.data.text || '(空)' }}
</template>
<template v-else-if="node.type === 'wait'">
等待: {{ node.data.duration }}ms
</template>
<template v-else-if="node.type === 'screenshot'">
截图保存
</template>
<template v-else-if="node.type === 'hotkey'">
快捷键: {{ (node.data.keys || []).join('+') || '(未设置)' }}
</template>
</div>
</div>
</div>
<!-- 属性面板 -->
<div v-if="selectedNode" class="properties-panel">
<h4>节点属性</h4>
<el-form label-width="80px" size="small">
<el-form-item label="名称">
<el-input v-model="selectedNode.name" />
</el-form-item>
<template v-if="selectedNode.type === 'click' || selectedNode.type === 'doubleClick'">
<el-form-item label="X 坐标">
<el-input-number v-model="selectedNode.data.x" :min="0" />
</el-form-item>
<el-form-item label="Y 坐标">
<el-input-number v-model="selectedNode.data.y" :min="0" />
</el-form-item>
</template>
<template v-else-if="selectedNode.type === 'input'">
<el-form-item label="输入文本">
<el-input v-model="selectedNode.data.text" type="textarea" :rows="2" />
</el-form-item>
</template>
<template v-else-if="selectedNode.type === 'wait'">
<el-form-item label="等待时长(ms)">
<el-input-number v-model="selectedNode.data.duration" :min="100" :step="100" />
</el-form-item>
</template>
<template v-else-if="selectedNode.type === 'hotkey'">
<el-form-item label="按键组合">
<el-select v-model="selectedNode.data.keys" multiple placeholder="选择按键">
<el-option label="Ctrl" value="Control" />
<el-option label="Alt" value="Alt" />
<el-option label="Shift" value="Shift" />
<el-option label="A" value="A" />
<el-option label="C" value="C" />
<el-option label="V" value="V" />
</el-select>
</el-form-item>
</template>
</el-form>
</div>
</div>
<!-- 进度条 -->
<div v-if="isPlaying" class="progress-bar-container">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: progressPercent + '%' }" />
</div>
<span>{{ progressText }}</span>
</div>
</div>
</template>
<script setup>
import { ref, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const nodes = ref([])
const connections = ref([])
const selectedNode = ref(null)
const isRecording = ref(false)
const isPlaying = ref(false)
const progressPercent = ref(0)
const progressText = ref('')
let nodeIdCounter = 0
let progressHandler = null
const nodeTemplates = {
click: { name: '点击', data: { x: 0, y: 0 } },
doubleClick: { name: '双击', data: { x: 0, y: 0 } },
input: { name: '输入', data: { text: '' } },
wait: { name: '等待', data: { duration: 1000 } },
screenshot: { name: '截图', data: {} },
hotkey: { name: '快捷键', data: { keys: [] } },
}
function addNode(type) {
const template = nodeTemplates[type]
nodes.value.push({
id: `node-${++nodeIdCounter}`,
type,
name: template.name,
x: 100 + Math.random() * 200,
y: 100 + Math.random() * 150,
data: JSON.parse(JSON.stringify(template.data)),
})
}
function selectNode(node, event) {
selectedNode.value = node
const startX = event.clientX
const startY = event.clientY
const origX = node.x
const origY = node.y
const onMove = (e) => {
node.x = origX + (e.clientX - startX)
node.y = origY + (e.clientY - startY)
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
function removeNode(id) {
nodes.value = nodes.value.filter(n => n.id !== id)
if (selectedNode.value?.id === id) selectedNode.value = null
}
function clearFlow() {
ElMessageBox.confirm('确定清空所有节点吗?', '确认', { type: 'warning' })
.then(() => {
nodes.value = []
connections.value = []
selectedNode.value = null
ElMessage.success('已清空')
})
.catch(() => {})
}
async function startRecord() {
try {
await window.electronAPI.automation.startRecord()
isRecording.value = true
ElMessage.success('开始录制,请执行操作')
} catch (error) {
ElMessage.error('录制失败: ' + error.message)
}
}
async function executeFlow() {
try {
const actions = nodes.value.map(node => ({ type: node.type, ...node.data }))
isPlaying.value = true
ElMessage.info('开始执行任务...')
progressHandler = (progress) => {
progressPercent.value = Math.round((progress.current / progress.total) * 100)
progressText.value = `${progress.type} (${progress.current}/${progress.total})`
}
window.electronAPI.automation.onProgress(progressHandler)
const result = await window.electronAPI.automation.execute(actions)
if (result.success) {
ElMessage.success('任务执行完成')
} else {
ElMessage.error('执行失败: ' + result.error)
}
} catch (error) {
ElMessage.error('执行出错: ' + error.message)
} finally {
isPlaying.value = false
progressPercent.value = 0
if (progressHandler) {
window.electronAPI.automation.offProgress?.(progressHandler)
progressHandler = null
}
}
}
async function saveFlow() {
const flow = { nodes: nodes.value, connections: connections.value }
await window.electronAPI.file.save(
{ filters: [{ name: '流程文件', extensions: ['flow'] }] },
JSON.stringify(flow, null, 2),
)
ElMessage.success('流程已保存')
}
async function loadFlow() {
const result = await window.electronAPI.file.open({
filters: [{ name: '流程文件', extensions: ['flow'] }],
})
if (!result.canceled) {
const content = await window.electronAPI.file.read(result.filePath)
const flow = JSON.parse(content)
nodes.value = flow.nodes || []
connections.value = flow.connections || []
ElMessage.success('流程已加载')
}
}
// 组件卸载时清理 IPC 监听器,避免内存泄漏
onUnmounted(() => {
if (progressHandler) {
window.electronAPI.automation.offProgress?.(progressHandler)
progressHandler = null
}
})
</script>
<style scoped>
.flow-editor {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--vp-c-bg);
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
background: var(--vp-c-bg-soft);
border-bottom: 1px solid var(--vp-c-border);
gap: 8px;
}
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
gap: 6px;
}
.main-area {
flex: 1;
display: flex;
overflow: hidden;
}
.canvas {
flex: 1;
position: relative;
background-image:
linear-gradient(90deg, var(--vp-c-bg-mute) 1px, transparent 1px),
linear-gradient(var(--vp-c-bg-mute) 1px, transparent 1px);
background-size: 20px 20px;
overflow: auto;
}
.node-item {
position: absolute;
width: 170px;
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-border);
border-radius: 8px;
cursor: grab;
user-select: none;
transition: box-shadow 0.2s;
}
.node-item:active { cursor: grabbing; }
.node-item.selected {
border-color: #58a6ff;
box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.3);
}
.node-header {
padding: 8px 12px;
background: var(--vp-c-bg-mute);
border-radius: 8px 8px 0 0;
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
font-size: 13px;
}
.node-delete {
background: none;
border: none;
color: var(--vp-c-text-3);
cursor: pointer;
font-size: 12px;
}
.node-delete:hover { color: #e81123; }
.node-body {
padding: 10px 12px;
font-size: 12px;
color: var(--vp-c-text-2);
}
.properties-panel {
width: 260px;
background: var(--vp-c-bg-soft);
border-left: 1px solid var(--vp-c-border);
padding: 16px;
overflow-y: auto;
}
.properties-panel h4 {
margin: 0 0 16px 0;
font-size: 15px;
font-weight: 600;
}
.progress-bar-container {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: var(--vp-c-bg-soft);
border-top: 1px solid var(--vp-c-border);
}
.progress-bar {
flex: 1;
height: 4px;
background: var(--vp-c-bg-mute);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #3fb950;
border-radius: 2px;
transition: width 0.3s ease;
}
.progress-bar-container span {
font-size: 12px;
color: var(--vp-c-text-2);
white-space: nowrap;
}
</style>10.5 Preload 脚本配置
为项目配置完整的 Preload 脚本,暴露所有需要的 API。
javascript
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
// ===== 自动化 API =====
automation: {
startRecord: () => ipcRenderer.invoke('automation:start-record'),
stopRecord: () => ipcRenderer.invoke('automation:stop-record'),
execute: (actions) => ipcRenderer.invoke('automation:execute', actions),
stopExecution: () => ipcRenderer.invoke('automation:stop-execution'),
onProgress: (cb) => ipcRenderer.on('automation:progress', cb),
offProgress: (cb) => ipcRenderer.removeListener('automation:progress', cb),
},
// ===== 文件 API =====
file: {
open: (options) => ipcRenderer.invoke('dialog:openFile', options),
save: (options, content) => ipcRenderer.invoke('dialog:saveFile', options, content),
read: (path) => ipcRenderer.invoke('file:read', path),
},
// ===== 屏幕 API =====
screen: {
capture: () => ipcRenderer.invoke('screen:capture'),
getSize: () => ipcRenderer.invoke('screen:getSize'),
},
// ===== 系统 API =====
system: {
getVersion: () => ipcRenderer.invoke('app:getVersion'),
getPlatform: () => process.platform,
},
// ===== 更新 API =====
update: {
check: () => ipcRenderer.invoke('update:check'),
install: () => ipcRenderer.invoke('update:install'),
},
// ===== 通用事件 =====
on: (channel, cb) => ipcRenderer.on(channel, cb),
off: (channel, cb) => ipcRenderer.removeListener(channel, cb),
})10.6 SQLite 数据持久化
RPA 应用需要存储任务历史、执行日志和变量数据。better-sqlite3 是同步但高性能的 SQLite 封装,非常适合 Electron 主进程。
安装
bash
npm install better-sqlite3
npm install -D @types/better-sqlite3DatabaseManager 实现
javascript
const Database = require('better-sqlite3')
const path = require('path')
const { app } = require('electron')
class DatabaseManager {
constructor() {
const dbPath = path.join(app.getPath('userData'), 'rpa-assistant.db')
this.db = new Database(dbPath)
this.db.pragma('journal_mode = WAL')
this.db.pragma('foreign_keys = ON')
this.initTables()
}
initTables() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
flow_data TEXT NOT NULL,
status TEXT DEFAULT 'draft',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_run_at DATETIME
);
CREATE TABLE IF NOT EXISTS execution_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER,
status TEXT NOT NULL,
start_time DATETIME NOT NULL,
end_time DATETIME,
duration_ms INTEGER,
error_message TEXT,
actions_count INTEGER DEFAULT 0,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS variables (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT,
type TEXT DEFAULT 'string',
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS screenshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER,
file_path TEXT NOT NULL,
ocr_text TEXT,
taken_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE SET NULL
);
`)
}
// ===== 任务操作 =====
createTask(name, description, flowData) {
const stmt = this.db.prepare(
'INSERT INTO tasks (name, description, flow_data) VALUES (?, ?, ?)'
)
return stmt.run(name, description, JSON.stringify(flowData))
}
getTask(id) {
const row = this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id)
if (row) row.flow_data = JSON.parse(row.flow_data)
return row
}
getAllTasks() {
return this.db.prepare('SELECT * FROM tasks ORDER BY updated_at DESC').all()
}
updateTask(id, data) {
const sets = []
const params = []
if (data.name !== undefined) { sets.push('name = ?'); params.push(data.name) }
if (data.flow_data !== undefined) { sets.push('flow_data = ?'); params.push(JSON.stringify(data.flow_data)) }
if (data.status !== undefined) { sets.push('status = ?'); params.push(data.status) }
sets.push("updated_at = datetime('now')")
params.push(id)
return this.db.prepare(`UPDATE tasks SET ${sets.join(', ')} WHERE id = ?`).run(...params)
}
deleteTask(id) {
return this.db.prepare('DELETE FROM tasks WHERE id = ?').run(id)
}
// ===== 执行日志 =====
logExecutionStart(taskId) {
return this.db.prepare(
"INSERT INTO execution_logs (task_id, status, start_time) VALUES (?, 'running', datetime('now'))"
).run(taskId)
}
logExecutionEnd(logId, status, errorMessage = null) {
return this.db.prepare(
`UPDATE execution_logs
SET status = ?, end_time = datetime('now'),
duration_ms = (julianday('now') - julianday(start_time)) * 86400000,
error_message = ?
WHERE id = ?`
).run(status, errorMessage, logId)
}
getRecentLogs(limit = 50) {
return this.db.prepare(
`SELECT el.*, t.name AS task_name
FROM execution_logs el
LEFT JOIN tasks t ON el.task_id = t.id
ORDER BY el.start_time DESC LIMIT ?`
).all(limit)
}
// ===== 变量操作 =====
setVariable(key, value, type = 'string') {
return this.db.prepare(
`INSERT INTO variables (key, value, type) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = ?, type = ?, updated_at = datetime('now')`
).run(key, value, type, value, type)
}
getVariable(key) {
return this.db.prepare('SELECT * FROM variables WHERE key = ?').get(key)
}
getAllVariables() {
return this.db.prepare('SELECT * FROM variables ORDER BY key').all()
}
deleteVariable(key) {
return this.db.prepare('DELETE FROM variables WHERE key = ?').run(key)
}
// ===== 截图记录 =====
addScreenshot(taskId, filePath, ocrText = null) {
return this.db.prepare(
'INSERT INTO screenshots (task_id, file_path, ocr_text) VALUES (?, ?, ?)'
).run(taskId, filePath, ocrText)
}
getScreenshotsByTask(taskId) {
return this.db.prepare(
'SELECT * FROM screenshots WHERE task_id = ? ORDER BY taken_at DESC'
).all(taskId)
}
close() {
this.db.close()
}
}
module.exports = { DatabaseManager }打包注意
better-sqlite3 包含原生 .node 文件,打包时必须在 electron-builder.yml 中配置 asarUnpack: ["node_modules/better-sqlite3/**/*"],并运行 @electron/rebuild 确保模块针对 Electron 的 Node.js 版本编译。
10.7 测试
单元测试示例
javascript
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@nut-tree/nut-js', () => ({
mouse: {
setPosition: vi.fn(),
leftClick: vi.fn(),
doubleClick: vi.fn(),
rightClick: vi.fn(),
scrollDown: vi.fn(),
pressButton: vi.fn(),
releaseButton: vi.fn(),
getPosition: vi.fn().mockResolvedValue({ x: 100, y: 200 }),
config: { mouseSpeed: 0 },
},
keyboard: {
type: vi.fn(),
pressKey: vi.fn(),
},
screen: {
capture: vi.fn().mockResolvedValue({ data: Buffer.alloc(1) }),
captureRegion: vi.fn().mockResolvedValue({ data: Buffer.alloc(1) }),
findOnScreen: vi.fn().mockResolvedValue({ x: 0, y: 0 }),
},
clipboard: {
getContent: vi.fn().mockResolvedValue('test'),
setContent: vi.fn(),
},
}))
const { AutomationEngine } = require('../main/automation/engine')
describe('AutomationEngine', () => {
let engine
beforeEach(() => {
engine = new AutomationEngine()
})
it('should start and stop recording', () => {
engine.startRecording()
expect(engine.isRecording).toBe(true)
const actions = engine.stopRecording()
expect(engine.isRecording).toBe(false)
expect(actions[0].type).toBe('start')
})
it('should execute click action', async () => {
const { mouse } = require('@nut-tree/nut-js')
const onProgress = vi.fn()
await engine.executeActions([{ type: 'click', x: 300, y: 400 }], onProgress)
expect(mouse.setPosition).toHaveBeenCalledWith({ x: 300, y: 400 })
expect(mouse.leftClick).toHaveBeenCalled()
expect(onProgress).toHaveBeenCalledTimes(1)
})
it('should execute wait with approximate duration', async () => {
const start = Date.now()
await engine.executeActions([{ type: 'wait', duration: 100 }], vi.fn())
expect(Date.now() - start).toBeGreaterThanOrEqual(90)
})
it('should stop execution mid-flow', async () => {
const onProgress = vi.fn()
const promise = engine.executeActions(
Array(10).fill({ type: 'wait', duration: 100 }),
onProgress,
)
setTimeout(() => engine.stopExecution(), 50)
await promise
expect(onProgress.mock.calls.length).toBeLessThan(10)
})
})详细测试配置和策略请参考第 3 章的"测试策略概述"小节。
10.8 打包与发布
完成开发后,使用 electron-builder 打包应用并发布。
yaml
appId: com.yourcompany.rpa-assistant
productName: "RPA Desktop Assistant"
directories:
output: release
buildResources: build
files:
- "out/**/*"
- "node_modules/**/*"
- "package.json"
asarUnpack:
- "node_modules/@nut-tree/**/*"
- "node_modules/better-sqlite3/**/*"
- "node_modules/tesseract.js/**/*"
- "node_modules/sharp/**/*"
win:
target: [nsis]
icon: build/icon.ico
mac:
target: [dmg]
icon: build/icon.icns
category: public.app-category.productivity
linux:
target: [AppImage]
icon: build/icon.png
publish:
provider: github
owner: your-username
repo: rpa-assistant10.9 项目总结
通过这个综合项目,我们实践了 Electron 桌面应用开发的完整流程:
- 环境搭建:使用 electron-vite 快速搭建 Vue3 + Electron + TypeScript 工程化项目
- 核心功能:实现了录制、回放、可视化流程编辑等 RPA 核心功能,AutomationEngine 支持 12 种操作类型(click、doubleClick、rightClick、input、hotkey、wait、scroll、drag、getPosition、screenshot、clipboardGet/Set、findImage)
- 系统交互:通过 @nut-tree/nut-js 实现鼠标键盘控制,通过 desktopCapturer + sharp 实现截图保存
- 数据持久化:使用 better-sqlite3 管理 tasks、execution_logs、variables、screenshots 四张核心表,WAL 模式保证并发性能
- 测试体系:Vitest 单元测试覆盖 AutomationEngine 核心逻辑,mock @nut-tree/nut-js 实现离线测试
- 打包发布:配置 electron-builder 实现 Windows/macOS/Linux 全平台打包,正确配置 asarUnpack
你已经完成了 Electron 桌面应用开发的系统学习!从前端开发者到桌面应用工程师,你现在具备了构建跨平台桌面应用的完整能力。继续深入某个方向(如更复杂的 AI 集成、企业级功能),或者开始你的下一个项目吧!