android-midscene-automation 0.1.34 → 0.1.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ import type { InsertAction } from './flow-graph';
2
+
3
+ // 普通画布、嵌套分支及流程总览共用说明,新增操作时必须补齐。
4
+ export const actionDescriptions: Record<InsertAction, string> = {
5
+ imageCheck: '使用 OpenCV 在本地判断指定区域,不调用 AI。适合固定图标、图片勾选状态、黑屏、颜色及多帧变化;不适合业务语义或任意文字识别,不能仅凭外观证明按钮可用或视频卡死。原生属性可读取时优先使用属性判断,证据不足报告无法判定。',
6
+ tap: '记录点击当前选中的组件,回放时重新定位后点击。',
7
+ input: '向当前选中的输入组件写入指定文字,支持 {{变量名}} 引用。',
8
+ extractVariable: '读取组件文字或属性,保存到当前脚本变量供后续节点引用。',
9
+ clearInput: '清空当前选中输入组件的内容。',
10
+ coordinateTap: '点击设备屏幕上的指定 X、Y 坐标。节点中可修改坐标,页面位置变化后需重新确认。',
11
+ longPress: '长按指定元素或屏幕坐标,可设置按住时长,默认按元素定位。',
12
+ keyBack: '执行 Android 系统返回操作。',
13
+ keyHome: '按 Home 键返回设备桌面,不等同于关闭应用。',
14
+ keyRecent: '打开 Android 最近任务列表。',
15
+ keyPower: '发送电源键操作,实际锁屏或唤醒行为由设备当前状态决定。',
16
+ swipe: '按指定起点、终点和时长滑动屏幕。',
17
+ pinch: '执行双指放大或缩小手势,需要当前页面支持缩放。',
18
+ launchApp: '启动指定应用,可放在流程中途或分支中执行。',
19
+ openGallery: '打开设备相册应用,不自动选择照片或视频。',
20
+ endFlow: '立即结束当前回放流程,后续节点不再执行。',
21
+ loop: '重复执行循环体,支持固定次数或条件退出,必须设置最大次数。',
22
+ breakLoop: '退出指定的当前或外层循环,继续执行该循环的结束路径。',
23
+ clearAppData: '清除目标应用数据并重置应用状态,可能删除登录信息和本地设置,仅允许在流程开始处添加。',
24
+ popupCondition: '判断目标组件是否存在,可进一步配置指定文本及精确或模糊匹配,按结果进入是或否分支。',
25
+ checkboxState: '读取原生 Checkbox 的 checked 状态,按 true/false 分支执行。',
26
+ radioButtonState: '读取原生 RadioButton 的 checked 状态,按 true/false 分支执行。',
27
+ checkedState: '读取 Checkbox、RadioButton 或 Switch 的真实勾选状态,按 true/false 分支执行。不支持仅用图片表示状态的普通 ImageView。',
28
+ aiRecognition: '用配置的模型判断当前画面,返回 true/false。适合语义、提示含义和复杂画面判断;可直接读取的组件属性优先使用原生判断。需要配置 AI 节点模型。',
29
+ textClick: '在组件树中按指定文字精确或模糊匹配并点击,进入匹配到文字或未匹配到文字分支。不识别图片或自绘画面中的文字。',
30
+ tapIfExists: '找到目标组件时点击,未找到则跳过并继续后续流程。',
31
+ inputIfExists: '找到目标输入组件时写入文字,未找到则跳过。支持 {{变量名}} 引用。',
32
+ clearIfExists: '找到目标输入组件时清空内容,未找到则跳过。',
33
+ backIfExists: '目标组件出现时执行系统返回,未出现则跳过。',
34
+ waitFor: '等待目标组件出现,超过配置的等待时间仍未出现则失败。',
35
+ assertExists: '校验目标组件是否存在,不满足时记录执行失败。旧脚本兼容操作。',
36
+ assertText: '校验组件文字是否满足预期,不满足时记录执行失败。旧脚本兼容操作。',
37
+ waitDisappear: '等待目标组件消失,超时仍存在则失败。',
38
+ waitActivity: '等待设备切换到指定的 Activity,适合确认页面跳转是否完成。',
39
+ delay: '暂停指定的毫秒数后继续执行,不判断页面是否已经准备完成。',
40
+ runScript: '执行连接的子脚本,支持显式传入参数并将返回变量映射到当前脚本。',
41
+ noop: '不执行设备操作,作为流程占位节点。',
42
+ log: '输出自定义内容和日志关键字,默认前缀为 stageLog,支持 {{变量名}} 引用。',
43
+ visualChangeStart: '截取指定组件或区域作为基准,与对应结束节点的截图比较。未添加对应结束节点时不比较。',
44
+ visualChangeEnd: '与同一可达路径上对应的开始截图比较,报告变化结果;多组检测可选择对应的开始节点。',
45
+ visualChange: '比较指定区域的截图变化,不直接判断业务状态或图片单选框是否勾选。',
46
+ };
@@ -2,6 +2,11 @@ import { APP_BASE } from '../api';
2
2
  import type { AppiumRecordedScript, AppiumRecordedStep } from './types';
3
3
  import type { AiRecognitionResult } from './ai-recognition';
4
4
  import type { TestVariable } from './variables';
5
+ import type { AppiumVisualChangeRegion } from './types';
6
+
7
+ export function captureImageCheckRegion(deviceId: string, region: AppiumVisualChangeRegion, screenWidth: number, screenHeight: number) {
8
+ return postJson<{ base64: string; screenWidth: number; screenHeight: number }>(`${APP_BASE}/api/appium-recorder/image-check/capture`, { deviceId, region, screenWidth, screenHeight });
9
+ }
5
10
 
6
11
  // 队列跨组件挂载保留,切换脚本或清空草稿时仍按编辑顺序写入。
7
12
  const variableWrites = new Map<string, Promise<unknown>>();
@@ -12,7 +12,13 @@ defineEmits<{ refresh: []; select: [node: AppiumNode] }>();
12
12
  <template #header><div class="panel-header"><span>App 组件树</span><el-tooltip content="刷新组件树" placement="top" :show-after="200"><el-button class="recorder-panel-tool" :icon="Refresh" size="small" aria-label="刷新组件树" :loading="loading" :disabled="disabled" @click="$emit('refresh')" /></el-tooltip></div></template>
13
13
  <ComponentTree :tree="tree" :selected-id="node?.id || ''" @select="$emit('select', $event)" />
14
14
  <NodeDetail :node="node" :current-activity="activity" :loading="recording" />
15
- <div class="component-inspector__activity"><span>Activity</span><code :title="activity">{{ activity || '-' }}</code></div>
15
+ <div class="component-inspector__activity">
16
+ <span>Activity</span>
17
+ <el-tooltip :content="activity" :disabled="!activity" placement="top" effect="dark" :show-after="200"
18
+ :popper-style="{ maxWidth: 'min(480px, calc(100vw - 24px))', overflowWrap: 'anywhere', fontSize: '12px' }">
19
+ <code :tabindex="activity ? 0 : undefined">{{ activity || '-' }}</code>
20
+ </el-tooltip>
21
+ </div>
16
22
  </el-card>
17
23
  </template>
18
24
 
@@ -22,5 +28,6 @@ defineEmits<{ refresh: []; select: [node: AppiumNode] }>();
22
28
  .component-inspector :deep(.appium-node-summary) { flex: none; }
23
29
  .panel-header { font-size: 14px; gap: 4px; }
24
30
  .component-inspector__activity { flex: none; display: flex; min-width: 0; gap: 6px; padding-top: 8px; border-top: 1px solid #e4e7ed; font-size: 12px; color: #606266; }
25
- .component-inspector__activity code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
31
+ .component-inspector__activity > span { flex-shrink: 0; }
32
+ .component-inspector__activity code { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
26
33
  </style>
@@ -2,6 +2,7 @@
2
2
  import { nextTick, shallowRef } from 'vue';
3
3
  import type { MenuInstance, PopoverInstance } from 'element-plus';
4
4
  import { PASTE_COMMAND, type FlowActionGroup, type InsertAction } from '../flow-graph';
5
+ import { actionDescriptions } from '../action-descriptions';
5
6
 
6
7
  const props = defineProps<{
7
8
  groups: FlowActionGroup[];
@@ -118,14 +119,25 @@ function selectAction(value: string) {
118
119
  @click="openGroupOnClick($event, group.title)"
119
120
  >
120
121
  <template #title>{{ group.title }}</template>
121
- <el-menu-item
122
+ <el-tooltip
122
123
  v-for="action in group.actions"
123
124
  :key="action.type"
124
- :index="action.type"
125
- :disabled="isActionDisabled(action.type)"
125
+ :content="actionDescriptions[action.type]"
126
+ placement="right"
127
+ effect="dark"
128
+ :show-after="350"
129
+ :hide-after="0"
130
+ :enterable="false"
131
+ :popper-options="{ modifiers: [{ name: 'preventOverflow', options: { altAxis: true, padding: 8 } }] }"
132
+ popper-class="appium-action-description"
126
133
  >
127
- {{ action.label }}
128
- </el-menu-item>
134
+ <el-menu-item
135
+ :index="action.type"
136
+ :disabled="isActionDisabled(action.type)"
137
+ >
138
+ {{ action.label }}
139
+ </el-menu-item>
140
+ </el-tooltip>
129
141
  </el-sub-menu>
130
142
  <el-menu-item v-if="clipboardCount" :index="PASTE_COMMAND" class="appium-action-paste">
131
143
  粘贴 {{ clipboardCount }} 个节点
@@ -137,6 +149,15 @@ function selectAction(value: string) {
137
149
  </template>
138
150
 
139
151
  <style>
152
+ .appium-action-description.el-popper {
153
+ max-width: min(300px, calc(100vw - 32px));
154
+ padding: 8px 10px;
155
+ font-size: 12px;
156
+ line-height: 1.6;
157
+ overflow-wrap: anywhere;
158
+ pointer-events: none;
159
+ }
160
+
140
161
  .appium-action-menu-trigger {
141
162
  display: inline-flex;
142
163
  }
@@ -47,9 +47,9 @@ const emit = defineEmits<{
47
47
  nodeClick: [index: number];
48
48
  merge: [index: number];
49
49
  copy: [indexes: number[]];
50
- paste: [index: number, branch?: FlowBranch];
50
+ paste: [index: number, branch?: FlowBranch, beforeStepId?: string];
51
51
  remove: [index: number];
52
- insertAction: [index: number, action: InsertAction];
52
+ insertAction: [index: number, action: InsertAction, beforeStepId?: string];
53
53
  insertBranchAction: [index: number, branch: FlowBranch, action: InsertAction];
54
54
  editInput: [index: number];
55
55
  previewLinkedScript: [index: number];
@@ -180,16 +180,17 @@ function handleInsert(payload: {
180
180
  action: InsertAction | typeof PASTE_COMMAND;
181
181
  branch?: FlowBranch;
182
182
  conditionIndex?: number;
183
+ beforeStepId?: string;
183
184
  }) {
184
185
  if (payload.action === PASTE_COMMAND) {
185
- emit('paste', payload.afterIndex, payload.branch);
186
+ emit('paste', payload.afterIndex, payload.branch, payload.beforeStepId);
186
187
  return;
187
188
  }
188
189
  if (payload.branch && payload.conditionIndex !== undefined) {
189
190
  emit('insertBranchAction', payload.afterIndex, payload.branch, payload.action);
190
191
  return;
191
192
  }
192
- emit('insertAction', payload.afterIndex, payload.action);
193
+ emit('insertAction', payload.afterIndex, payload.action, payload.beforeStepId);
193
194
  }
194
195
  </script>
195
196
 
@@ -29,7 +29,7 @@ const emit = defineEmits<{
29
29
  editInput: [index: number];
30
30
  previewLinkedScript: [index: number];
31
31
  execute: [index: number];
32
- insert: [payload: { afterIndex: number; action: InsertAction | typeof PASTE_COMMAND; branch?: FlowBranch; conditionIndex?: number }];
32
+ insert: [payload: { afterIndex: number; action: InsertAction | typeof PASTE_COMMAND; branch?: FlowBranch; conditionIndex?: number; beforeStepId?: string }];
33
33
  updateStep: [payload: { index: number; step: AppiumRecordedStep }];
34
34
  resize: [payload: { id: string; height: number }];
35
35
  }>();
@@ -39,6 +39,7 @@ let resizeObserver: ResizeObserver | null = null;
39
39
 
40
40
  const insertTitle = computed(() => {
41
41
  if (props.data.kind !== 'insert') return '';
42
+ if (props.data.beforeStepId) return '在合流后插入操作';
42
43
  if (props.data.branch) return `在“${props.data.branchLabel || (props.data.branch === 'yes' ? '是' : '否')}”分支插入操作`;
43
44
  return props.data.afterIndex < 0 ? '在开始后插入操作' : '插入操作';
44
45
  });
@@ -47,6 +48,7 @@ function emitInsert(command: string | number | object) {
47
48
  if (props.readonly || props.data.kind !== 'insert') return;
48
49
  emit('insert', {
49
50
  afterIndex: props.data.afterIndex,
51
+ beforeStepId: props.data.beforeStepId,
50
52
  action: command === PASTE_COMMAND ? PASTE_COMMAND : command as InsertAction,
51
53
  branch: props.data.branch,
52
54
  conditionIndex: props.data.conditionIndex,
@@ -213,14 +215,14 @@ watch(() => props.data, () => {
213
215
  @click="emit('execute', data.index)"
214
216
  />
215
217
  </span></el-tooltip>
216
- <el-tooltip v-if="data.canEditInput" content="修改输入内容:编辑此节点要输入的文字" placement="top" :show-after="200">
218
+ <el-tooltip v-if="data.canEditInput" :content="data.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容:编辑此节点要输入的文字'" placement="top" :show-after="200">
217
219
  <span class="appium-node-action-tooltip">
218
220
  <el-button
219
221
  text
220
222
  size="small"
221
223
  :icon="Edit"
222
224
  :disabled="data.disabled"
223
- title="修改输入内容"
225
+ :title="data.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容'"
224
226
  @click="emit('editInput', data.index)"
225
227
  />
226
228
  </span></el-tooltip>
@@ -0,0 +1,143 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue';
3
+ import { ElMessage } from 'element-plus';
4
+ import { Aim, Camera, Upload, QuestionFilled } from '@element-plus/icons-vue';
5
+ import { IMAGE_CHECK_MODES, validateImageCheck, imageTemplateSize, imageTemplateRegionIssue, expandedImageTemplateRegion, type ImageCheckConfig } from '../image-check';
6
+ import { captureImageCheckRegion } from '../api';
7
+
8
+ const props = defineProps<{ config: ImageCheckConfig; deviceId: string; hasElement: boolean; editing: boolean; picking: boolean }>();
9
+ const emit = defineEmits<{ update: [config: ImageCheckConfig]; close: []; confirm: []; pick: []; useElement: [] }>();
10
+ const busy = ref(false);
11
+ const regionIssue = computed(() => imageTemplateRegionIssue(props.config));
12
+ const expandedRegion = computed(() => expandedImageTemplateRegion(props.config));
13
+ const fileInput = ref<HTMLInputElement>();
14
+ const uploadSlot = ref<'template' | 'negativeTemplate'>('template');
15
+ const slots = computed(() => props.config.mode === 'state'
16
+ ? [{ key: 'template' as const, label: '选中模板' }, { key: 'negativeTemplate' as const, label: '未选中模板' }]
17
+ : props.config.mode === 'template' ? [{ key: 'template' as const, label: '模板' }] : []);
18
+ const numericFields = computed(() => [
19
+ ...(['template', 'state'].includes(props.config.mode) ? [{ key: 'threshold' as const, label: '匹配得分阈值', min: 0.01, max: 1, step: 0.01 }] : []),
20
+ ...(props.config.mode === 'state' ? [{ key: 'minScoreGap' as const, label: '两种状态最小得分差', min: 0.001, max: 1, step: 0.01 }] : []),
21
+ ...(['black', 'color', 'change'].includes(props.config.mode) ? [
22
+ { key: 'tolerance' as const, label: props.config.mode === 'black' ? '暗色亮度阈值 (0–255)' : 'RGB 通道容差 (0–255)', min: 0, max: 255, step: 1 },
23
+ { key: 'ratio' as const, label: props.config.mode === 'change' ? '变化像素比例 %' : '目标像素占比 %', min: 0.01, max: 100, step: 1 },
24
+ ] : []),
25
+ ...(props.config.mode !== 'state' ? [
26
+ { key: 'durationMs' as const, label: '最长观察时长 ms', min: 0, max: 60000, step: 1000 },
27
+ { key: 'intervalMs' as const, label: '采样间隔 ms', min: 200, max: 10000, step: 200 },
28
+ ...(props.config.mode !== 'change' ? [{ key: 'consecutive' as const, label: '连续满足帧数', min: 1, max: 100, step: 1 }] : []),
29
+ ] : []),
30
+ ]);
31
+ function patch(value: Partial<ImageCheckConfig>) { emit('update', { ...props.config, ...value }); }
32
+ function confirm() {
33
+ try { validateImageCheck(props.config); emit('confirm'); }
34
+ catch (error) { ElMessage.warning((error as Error).message); }
35
+ }
36
+ async function capture(key: 'template' | 'negativeTemplate') {
37
+ busy.value = true;
38
+ const regionKey = JSON.stringify(props.config.region);
39
+ const deviceId = props.deviceId;
40
+ try {
41
+ const result = await captureImageCheckRegion(props.deviceId, props.config.region, props.config.screenWidth, props.config.screenHeight);
42
+ if (deviceId !== props.deviceId || regionKey !== JSON.stringify(props.config.region)) throw new Error('采集期间目标区域已变化,请重新采集');
43
+ patch({ [key]: result.base64, screenWidth: result.screenWidth, screenHeight: result.screenHeight });
44
+ } catch (error) { ElMessage.error((error as Error).message); }
45
+ finally { busy.value = false; }
46
+ }
47
+ function chooseFile(key: 'template' | 'negativeTemplate') { uploadSlot.value = key; fileInput.value?.click(); }
48
+ async function upload(event: Event) {
49
+ const input = event.target as HTMLInputElement;
50
+ const file = input.files?.[0];
51
+ input.value = '';
52
+ if (!file) return;
53
+ try {
54
+ if (file.size > 3 * 1024 * 1024 || file.type !== 'image/png') throw new Error('请选择小于 3MB 的 PNG 模板');
55
+ const bytes = new Uint8Array(await file.arrayBuffer());
56
+ if (Array.from(bytes.slice(0, 8)).join(',') !== '137,80,78,71,13,10,26,10') throw new Error('模板不是有效 PNG');
57
+ const reader = new FileReader();
58
+ reader.onload = () => patch({ [uploadSlot.value]: String(reader.result).split(',')[1] });
59
+ reader.readAsDataURL(file);
60
+ } catch (error) { ElMessage.error((error as Error).message); }
61
+ }
62
+ </script>
63
+
64
+ <template>
65
+ <el-dialog :model-value="true" :title="editing ? '修改图像判断' : '添加图像判断'" width="520px" top="5vh"
66
+ class="image-check-dialog" append-to-body draggable :modal="false" modal-penetrable
67
+ :close-on-click-modal="false" :close-on-press-escape="false" :show-close="!busy" @close="emit('close')">
68
+ <el-form label-position="top" size="small" :disabled="busy">
69
+ <el-form-item label="检测模式">
70
+ <el-select :model-value="config.mode" @update:model-value="patch({ mode: $event, consecutive: $event === 'black' ? 3 : 1, ratio: $event === 'change' ? 2 : 95 })">
71
+ <el-option v-for="(label, key) in IMAGE_CHECK_MODES" :key="key" :label="label" :value="key" />
72
+ </el-select>
73
+ <el-tooltip content="适合固定图标、图片勾选、黑屏、颜色和画面变化。不识别任意文字或业务语义,不能仅凭外观证明按钮可用或视频卡死。证据不足时报告无法判定。" :show-after="200">
74
+ <el-icon class="image-check-help"><QuestionFilled /></el-icon>
75
+ </el-tooltip>
76
+ </el-form-item>
77
+ <el-form-item label="检测目标">
78
+ <el-radio-group :model-value="config.target" @update:model-value="$event === 'element' ? emit('useElement') : patch({ target: 'region' })">
79
+ <el-radio-button value="element" :disabled="!hasElement && config.target !== 'element'">当前选中元素</el-radio-button>
80
+ <el-radio-button value="region">框选区域</el-radio-button>
81
+ </el-radio-group>
82
+ <el-tooltip content="组件按实时定位结果截图,同名组件必须限定父级;框选区域只在采集时相同屏幕尺寸下执行,尺寸变化需重新框选。模板按原始尺寸匹配,不自动缩放。">
83
+ <el-icon class="image-check-help"><QuestionFilled /></el-icon>
84
+ </el-tooltip>
85
+ </el-form-item>
86
+ <el-form-item v-if="config.target === 'region'" label="设备预览区域">
87
+ <el-button :icon="Aim" @click="emit('pick')">{{ picking ? '正在框选' : '框选 / 拖动区域' }}</el-button>
88
+ </el-form-item>
89
+ <div class="image-check-grid">
90
+ <el-form-item v-for="(label, key) in { x: 'X', y: 'Y', width: '宽度', height: '高度' }" :key="key" :label="label">
91
+ <el-input-number :model-value="config.region[key]" :disabled="config.target === 'element'" :min="key === 'x' || key === 'y' ? 0 : 1" :max="16000" :precision="0" controls-position="right"
92
+ @update:model-value="$event !== undefined && patch({ region: { ...config.region, [key]: $event } })" />
93
+ </el-form-item>
94
+ </div>
95
+ <div v-if="slots.length" class="image-check-grid">
96
+ <div v-for="slot in slots" :key="slot.key" class="image-check-template">
97
+ <span>{{ slot.label }}</span>
98
+ <small v-if="imageTemplateSize(config[slot.key])" class="image-check-size">{{ imageTemplateSize(config[slot.key])!.width }} × {{ imageTemplateSize(config[slot.key])!.height }} px</small>
99
+ <el-image v-if="config[slot.key]" :src="'data:image/png;base64,' + config[slot.key]" :preview-src-list="['data:image/png;base64,' + config[slot.key]]" fit="contain" preview-teleported />
100
+ <div v-else class="image-check-empty">未采集</div>
101
+ <div>
102
+ <el-tooltip content="截取当前检测区域作为模板,可再次采集替换"><el-button :icon="Camera" :disabled="!deviceId" :loading="busy" aria-label="采集模板" @click="capture(slot.key)" /></el-tooltip>
103
+ <el-tooltip content="上传 PNG 替换模板"><el-button :icon="Upload" aria-label="上传模板" @click="chooseFile(slot.key)" /></el-tooltip>
104
+ </div>
105
+ </div>
106
+ </div>
107
+ <input ref="fileInput" type="file" accept="image/png" hidden @change="upload" />
108
+ <el-alert v-if="regionIssue" :title="regionIssue" type="warning" show-icon :closable="false" class="image-check-region-warning">
109
+ <el-button v-if="expandedRegion" size="small" @click="patch({ region: expandedRegion })">扩大检测区域</el-button>
110
+ </el-alert>
111
+ <el-form-item v-if="config.mode === 'template' || config.mode === 'change'" label="判断条件">
112
+ <el-radio-group :model-value="config.expectation" @update:model-value="patch({ expectation: $event as 'present' | 'absent' })">
113
+ <el-radio-button value="present">{{ config.mode === 'template' ? '图片存在' : '画面有变化' }}</el-radio-button>
114
+ <el-radio-button value="absent">{{ config.mode === 'template' ? '图片不存在' : '持续无明显变化' }}</el-radio-button>
115
+ </el-radio-group>
116
+ </el-form-item>
117
+ <el-form-item v-if="config.mode === 'color'" label="目标颜色 #RRGGBB">
118
+ <el-color-picker :model-value="config.color" @update:model-value="$event && patch({ color: $event })" />
119
+ <el-input :model-value="config.color" maxlength="7" @update:model-value="patch({ color: $event })" />
120
+ </el-form-item>
121
+ <div class="image-check-grid">
122
+ <el-form-item v-for="field in numericFields" :key="field.key" :label="field.label">
123
+ <el-input-number :model-value="config[field.key]" :min="field.min" :max="field.max" :step="field.step" controls-position="right" @update:model-value="$event !== undefined && patch({ [field.key]: $event })" />
124
+ </el-form-item>
125
+ </div>
126
+ </el-form>
127
+ <template #footer><el-button :disabled="busy" @click="emit('close')">取消</el-button><el-button type="primary" :disabled="busy" @click="confirm">{{ editing ? '保存' : '添加' }}</el-button></template>
128
+ </el-dialog>
129
+ </template>
130
+
131
+ <style>
132
+ .image-check-dialog { max-width: calc(100vw - 24px); }
133
+ .image-check-dialog .el-dialog__body { max-height: 70vh; overflow-y: auto; }
134
+ .image-check-dialog .el-select { flex: 1; }
135
+ .image-check-dialog .el-input-number { width: 100%; }
136
+ .image-check-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 12px; }
137
+ .image-check-help { margin-left: 8px; color: #606266; }
138
+ .image-check-template { display: grid; gap: 6px; margin-bottom: 16px; min-width: 0; }
139
+ .image-check-size { color: #606266; font-size: 12px; }
140
+ .image-check-region-warning { margin-bottom: 16px; }
141
+ .image-check-template .el-image, .image-check-empty { height: 96px; width: 100%; background: #eff5f3; border: 1px solid #d5dfdb; border-radius: 4px; }
142
+ .image-check-empty { display: grid; place-items: center; color: #606266; }
143
+ </style>
@@ -33,6 +33,7 @@ type InsertAction =
33
33
  | 'checkedState'
34
34
  | 'radioButtonState'
35
35
  | 'aiRecognition'
36
+ | 'imageCheck'
36
37
  | 'textClick'
37
38
  | 'tapIfExists'
38
39
  | 'inputIfExists'
@@ -117,6 +118,7 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
117
118
  { type: 'delay', label: '添加延时' },
118
119
  { type: 'popupCondition', label: '判断存在' },
119
120
  { type: 'aiRecognition', label: 'AI 识别' },
121
+ { type: 'imageCheck', label: '图像判断' },
120
122
  { type: 'tapIfExists', label: '存在则点击' },
121
123
  { type: 'inputIfExists', label: '存在则输入' },
122
124
  { type: 'clearIfExists', label: '存在则清空' },
@@ -162,6 +164,7 @@ function typeLabel(step: AppiumRecordedStep) {
162
164
  checkedState: '判断勾选',
163
165
  radioButtonState: 'RadioButton 状态',
164
166
  aiRecognition: 'AI 识别',
167
+ imageCheck: '图像判断',
165
168
  openGallery: '启动相册',
166
169
  textClick: '文字点击',
167
170
  };
@@ -169,6 +172,7 @@ function typeLabel(step: AppiumRecordedStep) {
169
172
  }
170
173
 
171
174
  function stepMeta(step: AppiumRecordedStep) {
175
+ if (step.type === 'imageCheck') return flowStepMeta(step);
172
176
  if (step.type === 'delay') return `${step.timeoutMs || 1000}ms`;
173
177
  if (step.type === 'input' || step.type === 'inputIfExists') return `输入内容:${step.value || '空'}`;
174
178
  if (step.type === 'longPress') return flowStepMeta(step);
@@ -336,14 +340,14 @@ function branchSplitPath() {
336
340
  @click="$emit('copy', [item.index])"
337
341
  />
338
342
  </span></el-tooltip>
339
- <el-tooltip v-if="item.step.type === 'input' || item.step.type === 'inputIfExists'" content="修改输入内容:编辑此节点要输入的文字" placement="top" :show-after="200">
343
+ <el-tooltip v-if="item.step.type === 'input' || item.step.type === 'inputIfExists' || item.step.type === 'imageCheck'" :content="item.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容:编辑此节点要输入的文字'" placement="top" :show-after="200">
340
344
  <span style="display: inline-flex">
341
345
  <el-button
342
346
  text
343
347
  size="small"
344
348
  :icon="Edit"
345
349
  :disabled="disabled"
346
- title="修改输入内容"
350
+ :title="item.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容'"
347
351
  @click="$emit('editInput', item.index)"
348
352
  />
349
353
  </span></el-tooltip>
@@ -26,9 +26,9 @@ const props = defineProps<{
26
26
  const emit = defineEmits<{
27
27
  remove: [index: number];
28
28
  copy: [indexes: number[]];
29
- paste: [index: number, branch?: FlowBranch];
29
+ paste: [index: number, branch?: FlowBranch, beforeStepId?: string];
30
30
  addDelay: [index?: number];
31
- insertAction: [index: number, action: InsertAction];
31
+ insertAction: [index: number, action: InsertAction, beforeStepId?: string];
32
32
  insertBranchAction: [index: number, branch: FlowBranch, action: InsertAction];
33
33
  editInput: [index: number];
34
34
  previewLinkedScript: [index: number];
@@ -89,6 +89,7 @@ const insertActionGroups: FlowActionGroup[] = [
89
89
  { type: 'delay', label: '添加延时' },
90
90
  { type: 'popupCondition', label: '判断存在' },
91
91
  { type: 'aiRecognition', label: 'AI 识别' },
92
+ { type: 'imageCheck', label: '图像判断' },
92
93
  { type: 'tapIfExists', label: '存在则点击' },
93
94
  { type: 'inputIfExists', label: '存在则输入' },
94
95
  { type: 'clearIfExists', label: '存在则清空' },
@@ -330,9 +331,9 @@ function updateStep(index: number, step: AppiumRecordedStep) {
330
331
  :is-copy-selected="isCopySelected"
331
332
  @node-click="handleNodeClick"
332
333
  @copy="(indexes) => emit('copy', indexes)"
333
- @paste="(index, branch) => emit('paste', index, branch)"
334
+ @paste="(index, branch, beforeStepId) => emit('paste', index, branch, beforeStepId)"
334
335
  @remove="(index) => emit('remove', index)"
335
- @insert-action="(index, action) => emit('insertAction', index, action)"
336
+ @insert-action="(index, action, beforeStepId) => emit('insertAction', index, action, beforeStepId)"
336
337
  @insert-branch-action="(index, branch, action) => emit('insertBranchAction', index, branch, action)"
337
338
  @edit-input="(index) => emit('editInput', index)"
338
339
  @preview-linked-script="(index) => emit('previewLinkedScript', index)"
@@ -406,9 +407,9 @@ function updateStep(index: number, step: AppiumRecordedStep) {
406
407
  :is-copy-selected="isCopySelected"
407
408
  @node-click="handleNodeClick"
408
409
  @copy="(indexes) => emit('copy', indexes)"
409
- @paste="(index, branch) => emit('paste', index, branch)"
410
+ @paste="(index, branch, beforeStepId) => emit('paste', index, branch, beforeStepId)"
410
411
  @remove="(index) => emit('remove', index)"
411
- @insert-action="(index, action) => emit('insertAction', index, action)"
412
+ @insert-action="(index, action, beforeStepId) => emit('insertAction', index, action, beforeStepId)"
412
413
  @insert-branch-action="(index, branch, action) => emit('insertBranchAction', index, branch, action)"
413
414
  @edit-input="(index) => emit('editInput', index)"
414
415
  @preview-linked-script="(index) => emit('previewLinkedScript', index)"
@@ -1,4 +1,5 @@
1
1
  import type { AppiumRecordedStep } from './types';
2
+ import { insertBeforeSharedStep } from './flow-insert';
2
3
 
3
4
  export type FlowBranch = 'yes' | 'no';
4
5
 
@@ -10,6 +11,7 @@ export type FlowClipboard = {
10
11
  type PasteTarget = {
11
12
  afterIndex: number;
12
13
  branch?: FlowBranch;
14
+ beforeStepId?: string;
13
15
  };
14
16
 
15
17
  const targetKeys = [
@@ -226,6 +228,8 @@ export function pasteFlowClipboard(
226
228
  const lastRoot = copiedRoots[copiedRoots.length - 1];
227
229
  if (!firstRoot || !lastRoot) throw new Error('复制节点数据无效');
228
230
 
231
+ if (target.beforeStepId) return insertBeforeSharedStep(steps, target.beforeStepId, copies, copiedRoots.map(step => step.id));
232
+
229
233
  const nextSteps = [...steps];
230
234
  let insertAfterIndex = target.afterIndex;
231
235
  let continuationId = '';
@@ -34,6 +34,7 @@ export type InsertAction =
34
34
  | 'checkedState'
35
35
  | 'radioButtonState'
36
36
  | 'aiRecognition'
37
+ | 'imageCheck'
37
38
  | 'textClick'
38
39
  | 'tapIfExists'
39
40
  | 'inputIfExists'
@@ -107,6 +108,7 @@ export type FlowGraphNodeData =
107
108
  kind: 'insert';
108
109
  actionGroups: FlowActionGroup[];
109
110
  afterIndex: number;
111
+ beforeStepId?: string;
110
112
  branch?: FlowBranch;
111
113
  branchLabel?: string;
112
114
  conditionIndex?: number;
@@ -260,7 +262,8 @@ function branchConnectionTargetId(items: StepItem[], condition: AppiumRecordedSt
260
262
  const targetId = branch === 'yes' ? condition.flow?.yesTargetId : condition.flow?.noTargetId;
261
263
  if (!targetId) return condition.type === 'loop' && branch === 'yes' ? '' : continuationTargetId(items, condition);
262
264
  const target = items.find(({ step }) => step.id === targetId)?.step;
263
- return target && target.flow?.parentConditionId === condition.flow?.parentConditionId ? target.id : '';
265
+ // 合流目标可以属于祖先作用域,不能用同父节点限制过滤合法的显式连接。
266
+ return target?.id || '';
264
267
  }
265
268
 
266
269
  function estimateTextLines(text: string | undefined, charsPerLine: number) {
@@ -378,8 +381,9 @@ export function buildFlowGraph(
378
381
  afterIndex: number,
379
382
  branch?: FlowBranch,
380
383
  condition?: StepItem,
384
+ beforeStepId?: string,
381
385
  ) => {
382
- const id = insertNodeId(afterIndex, branch, condition?.step.id);
386
+ const id = beforeStepId ? `insert:before:${beforeStepId}` : insertNodeId(afterIndex, branch, condition?.step.id);
383
387
  const isStartInsert = afterIndex < 0 && !branch;
384
388
  addNode({
385
389
  id,
@@ -398,6 +402,7 @@ export function buildFlowGraph(
398
402
  ? options.startActionGroups
399
403
  : withLaunchAppAction(options.mainActionGroups),
400
404
  afterIndex,
405
+ beforeStepId,
401
406
  branch,
402
407
  branchLabel: branch && condition ? flowBranchLabel(condition.step, branch) : undefined,
403
408
  conditionIndex: condition?.index,
@@ -453,7 +458,7 @@ export function buildFlowGraph(
453
458
  canCopy: item.step.type !== 'launchApp'
454
459
  && item.step.type !== 'clearAppData'
455
460
  && item.step.visualChange?.role !== 'end',
456
- canEditInput: item.step.type === 'input' || item.step.type === 'inputIfExists',
461
+ canEditInput: item.step.type === 'input' || item.step.type === 'inputIfExists' || item.step.type === 'imageCheck',
457
462
  canExecute: item.step.type === 'launchApp' || item.step.type === 'clearAppData' || item.step.type === 'aiRecognition',
458
463
  missingAiModel: item.step.type === 'aiRecognition' && !options.aiRecognitionModelConfigured,
459
464
  },
@@ -585,6 +590,20 @@ export function buildFlowGraph(
585
590
  }
586
591
  });
587
592
 
593
+ // 汇入线先接公共插入点,再接原节点;分支内插入点仍保持独立。
594
+ const joins = new Map<string, string>();
595
+ items.forEach(({ step }) => {
596
+ const target = defaultKind(step) === 'condition' && stepById.get(continuationTargetId(items, step));
597
+ if (!target) return;
598
+ const targetId = nodeIdForStep(target.step);
599
+ const insertId = `insert:before:${target.step.id}`;
600
+ if (joins.has(insertId) || !links.some(link => link.target === targetId)) return;
601
+ addInsertNode(target.index - 1, undefined, undefined, target.step.id);
602
+ links.forEach(link => { if (link.target === targetId) link.target = insertId; });
603
+ addVisibleLink(insertId, targetId);
604
+ joins.set(insertId, nodeIdForStep(step));
605
+ });
606
+
588
607
  const selectedDescendantIds = collectDescendantIds(
589
608
  steps,
590
609
  options.selectedCopyIndexes.map((index) => steps[index]?.id).filter((id): id is string => Boolean(id)),
@@ -752,13 +771,6 @@ export function buildFlowGraph(
752
771
  (nodeOrder.get(a.source) || 0) - (nodeOrder.get(b.source) || 0)
753
772
  ));
754
773
  // 公共起点从两侧汇入,不能被最后一条分支连线拉到左列或右列。
755
- const joins = new Map<string, string>();
756
- items.forEach(({ step }) => {
757
- const target = defaultKind(step) === 'condition' && stepById.get(continuationTargetId(items, step));
758
- if (defaultKind(step) === 'condition' && target && !joins.has(nodeIdForStep(target.step))) {
759
- joins.set(nodeIdForStep(target.step), nodeIdForStep(step));
760
- }
761
- });
762
774
  const alignVisibleChainGeometry = () => {
763
775
  orderedLinks.forEach((link) => {
764
776
  const source = nodeById.get(link.source);
@@ -0,0 +1,27 @@
1
+ import type { AppiumRecordedStep } from './types';
2
+
3
+ /** 在公共节点前插入,不改变原有分支归属,并重接所有显式汇入连接。 */
4
+ export function insertBeforeSharedStep(steps: AppiumRecordedStep[], targetId: string, additions: AppiumRecordedStep[], rootIds: string[]) {
5
+ const index = steps.findIndex(step => step.id === targetId);
6
+ if (index < 0) throw new Error('合流节点已不存在,请重新选择插入位置');
7
+ const target = steps[index];
8
+ const roots = new Set(rootIds);
9
+ const first = rootIds[0];
10
+ const last = rootIds[rootIds.length - 1];
11
+ if (!first || !last) throw new Error('没有可插入的节点');
12
+ const inserted = additions.map(step => roots.has(step.id) ? {
13
+ ...step, flow: { ...step.flow, parentConditionId: target.flow?.parentConditionId, parentBranch: target.flow?.parentBranch,
14
+ ...(step.id === last ? { successTargetId: targetId } : {}) },
15
+ } : step);
16
+ const result = steps.map(step => {
17
+ if (!step.flow) return step;
18
+ const flow = { ...step.flow };
19
+ for (const key of ['yesTargetId', 'noTargetId', 'successTargetId'] as const) {
20
+ if (flow[key] === targetId) flow[key] = first;
21
+ }
22
+ return { ...step, flow };
23
+ });
24
+ // 无显式目标的默认返回会按同作用域顺序落到新节点。
25
+ result.splice(index, 0, ...inserted);
26
+ return result;
27
+ }
@@ -4,9 +4,10 @@ import { visualChangeMeta } from './visual-change';
4
4
  import { longPressMode } from './long-press';
5
5
  import { isNativeStateCondition } from './native-control-state';
6
6
  import { DEFAULT_LOG_PREFIX } from './stage-log';
7
+ import { imageCheckSummary } from './image-check';
7
8
 
8
9
  export function isBooleanCondition(step: Pick<AppiumRecordedStep, 'type'>) {
9
- return isNativeStateCondition(step) || step.type === 'aiRecognition' || step.type === 'textClick';
10
+ return isNativeStateCondition(step) || step.type === 'aiRecognition' || step.type === 'textClick' || step.type === 'imageCheck';
10
11
  }
11
12
 
12
13
  export function defaultFlowKind(step: AppiumRecordedStep): FlowKind {
@@ -47,6 +48,7 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
47
48
  checkedState: '判断勾选',
48
49
  radioButtonState: 'RadioButton 状态',
49
50
  aiRecognition: 'AI 识别',
51
+ imageCheck: '图像判断',
50
52
  textClick: '文字点击',
51
53
  assertText: '断言文本',
52
54
  key: '按键',
@@ -73,6 +75,7 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
73
75
  }
74
76
 
75
77
  export function flowStepMeta(step: AppiumRecordedStep) {
78
+ if (step.type === 'imageCheck') return imageCheckSummary(step.imageCheck);
76
79
  if (step.type === 'extractVariable') return `${step.extractVariable?.attribute || 'text'} → ${step.extractVariable?.name || '未设置变量名'}`;
77
80
  if (step.type === 'loop') return `最多 ${step.loop?.maxIterations ?? '?'} 次 · ${step.loop?.exitWhen === 'exists' ? '元素出现时退出' : step.loop?.exitWhen === 'notExists' ? '元素消失时退出' : '固定次数'}${step.loop?.exitWhen !== 'never' ? ` ${step.selector?.value || ''}` : ''}`;
78
81
  if (step.type === 'breakLoop') return step.breakLoopTargetId ? '退出指定循环,继续循环结束后的流程' : '退出当前循环,继续循环结束后的流程';