android-midscene-automation 0.1.33 → 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.
@@ -10,6 +10,7 @@ import LoopSettings from './components/LoopSettings.vue';
10
10
  import BreakLoopSettings from './components/BreakLoopSettings.vue';
11
11
  import VisualChangeEndPicker from './components/VisualChangeEndPicker.vue';
12
12
  import PresetVariables from './components/PresetVariables.vue';
13
+ import { insertBeforeSharedStep } from './flow-insert';
13
14
  import VariableExtractionSettings from './components/VariableExtractionSettings.vue';
14
15
  import ScriptParameterSettings from './components/ScriptParameterSettings.vue';
15
16
  import { validateVariables, validateExtraction, validateReturns, type TestVariable } from './variables';
@@ -39,6 +40,8 @@ import { DEFAULT_LOG_PREFIX, validateStageLog } from './stage-log';
39
40
  import RecordedSteps from './components/RecordedSteps.vue';
40
41
  import NewScriptDialog from './components/NewScriptDialog.vue';
41
42
  import VisualChangeDialog from './components/VisualChangeDialog.vue';
43
+ import ImageCheckDialog from './components/ImageCheckDialog.vue';
44
+ import { createImageCheckConfig, validateImageCheck, type ImageCheckConfig } from './image-check';
42
45
  import AiRecognitionTestDialog from './components/AiRecognitionTestDialog.vue';
43
46
  import { validateAiRecognitionPrompt } from './ai-recognition';
44
47
  import {
@@ -77,6 +80,7 @@ type LegacyFlow = NonNullable<AppiumRecordedStep['flow']> & { scope?: string };
77
80
  type SwipeGesture = NonNullable<AppiumRecordedStep['swipe']>;
78
81
  type SwipeForm = SwipeGesture & { direction: string };
79
82
  type BranchTarget = {
83
+ beforeStepId?: string;
80
84
  stepId: string;
81
85
  branch: BranchName;
82
86
  updateTarget?: boolean;
@@ -105,6 +109,7 @@ const insertableRecorderActions: RecorderAction[] = [
105
109
  'checkedState',
106
110
  'radioButtonState',
107
111
  'aiRecognition',
112
+ 'imageCheck',
108
113
  'textClick',
109
114
  'runScript',
110
115
  'keyBack',
@@ -186,6 +191,11 @@ const linkedScriptPreviewVisible = shallowRef(false);
186
191
  const linkedScriptPreviewScriptId = shallowRef('');
187
192
  const linkedScriptPreviewResetToken = shallowRef(0);
188
193
  const visualChangeDialogVisible = shallowRef(false);
194
+ const imageCheckDraft = shallowRef<AppiumRecordedStep | null>(null);
195
+ let imageCheckIndex: number | undefined;
196
+ let imageCheckBranch: BranchTarget | undefined;
197
+ let imageCheckEditing = false;
198
+ const imageCheckPicking = shallowRef(false);
189
199
  const visualChangePicking = shallowRef(false);
190
200
  const visualChangeInsertIndex = shallowRef<number | undefined>();
191
201
  const visualChangeBranchTarget = shallowRef<BranchTarget | null>(null);
@@ -291,11 +301,12 @@ const selectedBounds = computed(() => {
291
301
  return bounds && selectedNode.value ? { id: selectedNode.value.id, ...bounds } : undefined;
292
302
  });
293
303
  const visualChangeSelectedRegion = computed(() => {
304
+ if (imageCheckDraft.value?.imageCheck?.target === 'region') return visualRegionToBounds('image-check-region', imageCheckDraft.value.imageCheck.region);
294
305
  if (!visualChangeDialogVisible.value || visualChangeConfig.value.mode !== 'region') return undefined;
295
306
  return visualRegionToBounds('visual-change-region', visualChangeConfig.value.region);
296
307
  });
297
308
  const visualChangeRegionSelectionEnabled = computed(() => (
298
- visualChangeDialogVisible.value && visualChangeConfig.value.mode === 'region'
309
+ (visualChangeDialogVisible.value && visualChangeConfig.value.mode === 'region') || imageCheckDraft.value?.imageCheck?.target === 'region'
299
310
  ));
300
311
 
301
312
  function stepWithBranchTarget(step: AppiumRecordedStep, branchTarget: BranchTarget) {
@@ -311,6 +322,10 @@ function stepWithBranchTarget(step: AppiumRecordedStep, branchTarget: BranchTarg
311
322
  }
312
323
 
313
324
  function insertStep(step: AppiumRecordedStep, index?: number, branchTarget?: BranchTarget) {
325
+ if (branchTarget?.beforeStepId) {
326
+ steps.value = insertBeforeSharedStep(steps.value, branchTarget.beforeStepId, [step], [step.id]);
327
+ return steps.value.find(item => item.id === step.id)!;
328
+ }
314
329
  let nextSteps = [...steps.value];
315
330
  let insertedStep = branchTarget ? stepWithBranchTarget(step, branchTarget) : step;
316
331
  if (index === -1) {
@@ -369,7 +384,7 @@ function copyFlowNodes(indexes: number[]) {
369
384
  }
370
385
  }
371
386
 
372
- function pasteFlowNodes(index: number, branch?: BranchName) {
387
+ function pasteFlowNodes(index: number, branch?: BranchName, beforeStepId?: string) {
373
388
  if (!flowClipboard.value) {
374
389
  ElMessage.warning('请先复制节点');
375
390
  return;
@@ -382,6 +397,7 @@ function pasteFlowNodes(index: number, branch?: BranchName) {
382
397
  const nextSteps = pasteFlowClipboard(steps.value, flowClipboard.value, {
383
398
  afterIndex: index,
384
399
  branch,
400
+ beforeStepId,
385
401
  });
386
402
  validateLoopSteps(nextSteps);
387
403
  steps.value = nextSteps;
@@ -631,6 +647,35 @@ function defaultVisualChangeRegion(): AppiumVisualChangeRegion {
631
647
  };
632
648
  }
633
649
 
650
+ function updateImageCheck(config: ImageCheckConfig) {
651
+ if (imageCheckDraft.value) imageCheckDraft.value = { ...imageCheckDraft.value, imageCheck: config };
652
+ }
653
+
654
+ function useImageCheckElement() {
655
+ const node = selectedNode.value;
656
+ if (!node?.bounds || !imageCheckDraft.value?.imageCheck) return;
657
+ const located = createNodeActionStep('imageCheck', '图像判断', node);
658
+ imageCheckDraft.value = { ...imageCheckDraft.value, selector: located.selector, contextSelector: located.contextSelector,
659
+ selectorChain: located.selectorChain, fallback: undefined,
660
+ imageCheck: { ...imageCheckDraft.value.imageCheck, target: 'element', region: boundsToVisualRegion(node.bounds) } };
661
+ }
662
+
663
+ function confirmImageCheck() {
664
+ const draft = imageCheckDraft.value;
665
+ if (!draft || recordingLocked.value) return;
666
+ try {
667
+ validateImageCheck(draft.imageCheck);
668
+ if (draft.imageCheck?.target === 'element' && (!draft.selector || draft.selector.strategy === 'bounds')) throw new Error('请选择具有有效定位信息的组件');
669
+ if (imageCheckEditing) {
670
+ const index = steps.value.findIndex(step => step.id === draft.id);
671
+ if (index < 0) throw new Error('原节点已删除');
672
+ updateStep(index, draft);
673
+ } else insertStep(draft, imageCheckIndex, imageCheckBranch);
674
+ imageCheckDraft.value = null;
675
+ imageCheckPicking.value = false;
676
+ } catch (error) { ElMessage.warning((error as Error).message); }
677
+ }
678
+
634
679
  function currentVisualChangeConfig() {
635
680
  const bounds = selectedNode.value?.bounds;
636
681
  return normalizeVisualChangeConfig({
@@ -1245,6 +1290,11 @@ function pickVisualChangeRegion() {
1245
1290
  }
1246
1291
 
1247
1292
  function applyVisualChangeRegion(region: AppiumVisualChangeRegion) {
1293
+ if (imageCheckDraft.value?.imageCheck) {
1294
+ updateImageCheck({ ...imageCheckDraft.value.imageCheck, region, screenWidth: props.deviceWidth || 0, screenHeight: props.deviceHeight || 0 });
1295
+ imageCheckPicking.value = false;
1296
+ return;
1297
+ }
1248
1298
  visualChangeConfig.value = normalizeVisualChangeConfig({
1249
1299
  ...visualChangeConfig.value,
1250
1300
  mode: 'region',
@@ -1282,6 +1332,13 @@ function isReadonlyFlowCopySelected(_index: number) {
1282
1332
  return false;
1283
1333
  }
1284
1334
 
1335
+ function addSharedAction(action: RecorderAction, index: number, beforeStepId?: string) {
1336
+ if (!beforeStepId) return addAction(action, index);
1337
+ const target = steps.value.find(step => step.id === beforeStepId);
1338
+ if (!target) return;
1339
+ return addAction(action, index, { beforeStepId, stepId: target.flow?.parentConditionId || '', branch: target.flow?.parentBranch || 'yes' });
1340
+ }
1341
+
1285
1342
  async function addAction(
1286
1343
  action: RecorderAction,
1287
1344
  index?: number,
@@ -1350,6 +1407,16 @@ async function addAction(
1350
1407
  if (result) return insertStep({ ...step, breakLoopTargetId: scoped.breakLoopTargetId }, index, branchTarget);
1351
1408
  return;
1352
1409
  }
1410
+ if (action === 'imageCheck') {
1411
+ imageCheckIndex = index;
1412
+ imageCheckBranch = branchTarget;
1413
+ imageCheckEditing = false;
1414
+ imageCheckPicking.value = false;
1415
+ imageCheckDraft.value = { id: createStepId(), type: 'imageCheck', label: '图像判断', flow: { nodeKind: 'condition' },
1416
+ imageCheck: { ...createImageCheckConfig(), region: defaultVisualChangeRegion(), screenWidth: props.deviceWidth || 0, screenHeight: props.deviceHeight || 0 } };
1417
+ if (selectedNode.value?.bounds) useImageCheckElement();
1418
+ return;
1419
+ }
1353
1420
  if (action === 'aiRecognition') {
1354
1421
  const input = await ElMessageBox.prompt('识别内容', '添加 AI 识别', {
1355
1422
  inputType: 'textarea',
@@ -1729,6 +1796,13 @@ function updateStep(index: number, step: AppiumRecordedStep) {
1729
1796
 
1730
1797
  async function editInputStep(index: number) {
1731
1798
  const step = steps.value[index];
1799
+ if (step?.type === 'imageCheck' && !recordingBusy.value) {
1800
+ imageCheckIndex = index;
1801
+ imageCheckEditing = true;
1802
+ imageCheckPicking.value = false;
1803
+ imageCheckDraft.value = JSON.parse(JSON.stringify(step));
1804
+ return;
1805
+ }
1732
1806
  if (!step || (step.type !== 'input' && step.type !== 'inputIfExists') || recordingBusy.value) return;
1733
1807
  const input = await ElMessageBox.prompt('', '修改输入内容', {
1734
1808
  inputValue: step.value || '',
@@ -2142,7 +2216,7 @@ watch(
2142
2216
  :selected-bounds="selectedBounds"
2143
2217
  :selected-region="visualChangeSelectedRegion"
2144
2218
  :region-selection="visualChangeRegionSelectionEnabled"
2145
- :region-draw-mode="visualChangePicking"
2219
+ :region-draw-mode="visualChangePicking || imageCheckPicking"
2146
2220
  :device-width="deviceWidth"
2147
2221
  :device-height="deviceHeight"
2148
2222
  @switch-device="switchDevice"
@@ -2216,7 +2290,7 @@ watch(
2216
2290
  @copy="copyFlowNodes"
2217
2291
  @paste="pasteFlowNodes"
2218
2292
  @add-delay="addDelayStep"
2219
- @insert-action="(index, action) => addAction(action, index)"
2293
+ @insert-action="(index, action, beforeStepId) => addSharedAction(action, index, beforeStepId)"
2220
2294
  @insert-branch-action="addBranchAction"
2221
2295
  @edit-input="editInputStep"
2222
2296
  @preview-linked-script="previewLinkedScriptStep"
@@ -2331,6 +2405,14 @@ watch(
2331
2405
  @close="aiRecognitionTestStep = null"
2332
2406
  />
2333
2407
 
2408
+ <ImageCheckDialog
2409
+ v-if="imageCheckDraft?.imageCheck"
2410
+ :config="imageCheckDraft.imageCheck" :device-id="selectedDeviceId"
2411
+ :has-element="Boolean(selectedNode?.bounds)" :editing="imageCheckEditing" :picking="imageCheckPicking"
2412
+ @update="updateImageCheck" @close="imageCheckDraft = null; imageCheckPicking = false"
2413
+ @confirm="confirmImageCheck" @use-element="useImageCheckElement"
2414
+ @pick="imageCheckPicking = true; workspaceRef?.showPreview()"
2415
+ />
2334
2416
  <VisualChangeDialog
2335
2417
  :model-value="visualChangeDialogVisible"
2336
2418
  :config="visualChangeConfig"
@@ -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>