android-midscene-automation 0.1.34 → 0.1.36

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.
Files changed (32) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +10 -0
  3. package/package.json +2 -1
  4. package/server/appium-recorder/appium-runner.ts +39 -1
  5. package/server/appium-recorder/image-check.ts +160 -0
  6. package/server/appium-recorder/open-gallery.ts +10 -1
  7. package/server/appium-recorder/report.ts +41 -0
  8. package/server/appium-recorder/repository.ts +6 -0
  9. package/server/appium-recorder/routes.ts +19 -0
  10. package/server/appium-recorder/script-export.ts +1 -1
  11. package/src/appium-recorder/AppiumPage.vue +112 -8
  12. package/src/appium-recorder/action-descriptions.ts +46 -0
  13. package/src/appium-recorder/api.ts +5 -0
  14. package/src/appium-recorder/components/ComponentInspector.vue +9 -2
  15. package/src/appium-recorder/components/FlowActionMenu.vue +26 -5
  16. package/src/appium-recorder/components/FlowCanvas.vue +5 -4
  17. package/src/appium-recorder/components/FlowNodeCard.vue +9 -7
  18. package/src/appium-recorder/components/FlowStepEditor.vue +23 -4
  19. package/src/appium-recorder/components/ImageCheckDialog.vue +143 -0
  20. package/src/appium-recorder/components/MergeBranchesDialog.vue +31 -3
  21. package/src/appium-recorder/components/NestedConditionBranches.vue +6 -2
  22. package/src/appium-recorder/components/RecordedSteps.vue +7 -6
  23. package/src/appium-recorder/flow-copy.ts +6 -0
  24. package/src/appium-recorder/flow-graph.ts +22 -10
  25. package/src/appium-recorder/flow-insert.ts +27 -0
  26. package/src/appium-recorder/flow-labels.ts +4 -1
  27. package/src/appium-recorder/flow-merge.ts +12 -0
  28. package/src/appium-recorder/flow-unmerge.ts +145 -0
  29. package/src/appium-recorder/image-check.ts +117 -0
  30. package/src/appium-recorder/types.ts +15 -0
  31. package/src/appium-recorder/visual-change-pairs.ts +2 -0
  32. package/src/appium-recorder/visual-change.ts +13 -1
@@ -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 {
@@ -57,6 +60,7 @@ import {
57
60
  boundsToVisualRegion,
58
61
  createStepId,
59
62
  normalizeVisualChangeConfig,
63
+ syncVisualChangeThreshold,
60
64
  visualRegionToBounds,
61
65
  } from './visual-change';
62
66
  import {
@@ -77,6 +81,7 @@ type LegacyFlow = NonNullable<AppiumRecordedStep['flow']> & { scope?: string };
77
81
  type SwipeGesture = NonNullable<AppiumRecordedStep['swipe']>;
78
82
  type SwipeForm = SwipeGesture & { direction: string };
79
83
  type BranchTarget = {
84
+ beforeStepId?: string;
80
85
  stepId: string;
81
86
  branch: BranchName;
82
87
  updateTarget?: boolean;
@@ -105,6 +110,7 @@ const insertableRecorderActions: RecorderAction[] = [
105
110
  'checkedState',
106
111
  'radioButtonState',
107
112
  'aiRecognition',
113
+ 'imageCheck',
108
114
  'textClick',
109
115
  'runScript',
110
116
  'keyBack',
@@ -177,6 +183,7 @@ const currentActivity = shallowRef('');
177
183
  const replayOutput = shallowRef('');
178
184
  const replayOutputDialogVisible = shallowRef(false);
179
185
  const linkedScriptDialogVisible = shallowRef(false);
186
+ const linkedScriptEditingId = shallowRef('');
180
187
  const linkedScriptTargetId = shallowRef('');
181
188
  const linkedParameters = reactive<AppiumRecordedStep>({ id: 'link-parameters', type: 'runScript', label: '', parameters: [], returns: [] });
182
189
  const linkedScriptInsertIndex = shallowRef<number | undefined>();
@@ -186,6 +193,11 @@ const linkedScriptPreviewVisible = shallowRef(false);
186
193
  const linkedScriptPreviewScriptId = shallowRef('');
187
194
  const linkedScriptPreviewResetToken = shallowRef(0);
188
195
  const visualChangeDialogVisible = shallowRef(false);
196
+ const imageCheckDraft = shallowRef<AppiumRecordedStep | null>(null);
197
+ let imageCheckIndex: number | undefined;
198
+ let imageCheckBranch: BranchTarget | undefined;
199
+ let imageCheckEditing = false;
200
+ const imageCheckPicking = shallowRef(false);
189
201
  const visualChangePicking = shallowRef(false);
190
202
  const visualChangeInsertIndex = shallowRef<number | undefined>();
191
203
  const visualChangeBranchTarget = shallowRef<BranchTarget | null>(null);
@@ -291,11 +303,12 @@ const selectedBounds = computed(() => {
291
303
  return bounds && selectedNode.value ? { id: selectedNode.value.id, ...bounds } : undefined;
292
304
  });
293
305
  const visualChangeSelectedRegion = computed(() => {
306
+ if (imageCheckDraft.value?.imageCheck?.target === 'region') return visualRegionToBounds('image-check-region', imageCheckDraft.value.imageCheck.region);
294
307
  if (!visualChangeDialogVisible.value || visualChangeConfig.value.mode !== 'region') return undefined;
295
308
  return visualRegionToBounds('visual-change-region', visualChangeConfig.value.region);
296
309
  });
297
310
  const visualChangeRegionSelectionEnabled = computed(() => (
298
- visualChangeDialogVisible.value && visualChangeConfig.value.mode === 'region'
311
+ (visualChangeDialogVisible.value && visualChangeConfig.value.mode === 'region') || imageCheckDraft.value?.imageCheck?.target === 'region'
299
312
  ));
300
313
 
301
314
  function stepWithBranchTarget(step: AppiumRecordedStep, branchTarget: BranchTarget) {
@@ -311,6 +324,10 @@ function stepWithBranchTarget(step: AppiumRecordedStep, branchTarget: BranchTarg
311
324
  }
312
325
 
313
326
  function insertStep(step: AppiumRecordedStep, index?: number, branchTarget?: BranchTarget) {
327
+ if (branchTarget?.beforeStepId) {
328
+ steps.value = insertBeforeSharedStep(steps.value, branchTarget.beforeStepId, [step], [step.id]);
329
+ return steps.value.find(item => item.id === step.id)!;
330
+ }
314
331
  let nextSteps = [...steps.value];
315
332
  let insertedStep = branchTarget ? stepWithBranchTarget(step, branchTarget) : step;
316
333
  if (index === -1) {
@@ -369,7 +386,7 @@ function copyFlowNodes(indexes: number[]) {
369
386
  }
370
387
  }
371
388
 
372
- function pasteFlowNodes(index: number, branch?: BranchName) {
389
+ function pasteFlowNodes(index: number, branch?: BranchName, beforeStepId?: string) {
373
390
  if (!flowClipboard.value) {
374
391
  ElMessage.warning('请先复制节点');
375
392
  return;
@@ -382,6 +399,7 @@ function pasteFlowNodes(index: number, branch?: BranchName) {
382
399
  const nextSteps = pasteFlowClipboard(steps.value, flowClipboard.value, {
383
400
  afterIndex: index,
384
401
  branch,
402
+ beforeStepId,
385
403
  });
386
404
  validateLoopSteps(nextSteps);
387
405
  steps.value = nextSteps;
@@ -631,6 +649,35 @@ function defaultVisualChangeRegion(): AppiumVisualChangeRegion {
631
649
  };
632
650
  }
633
651
 
652
+ function updateImageCheck(config: ImageCheckConfig) {
653
+ if (imageCheckDraft.value) imageCheckDraft.value = { ...imageCheckDraft.value, imageCheck: config };
654
+ }
655
+
656
+ function useImageCheckElement() {
657
+ const node = selectedNode.value;
658
+ if (!node?.bounds || !imageCheckDraft.value?.imageCheck) return;
659
+ const located = createNodeActionStep('imageCheck', '图像判断', node);
660
+ imageCheckDraft.value = { ...imageCheckDraft.value, selector: located.selector, contextSelector: located.contextSelector,
661
+ selectorChain: located.selectorChain, fallback: undefined,
662
+ imageCheck: { ...imageCheckDraft.value.imageCheck, target: 'element', region: boundsToVisualRegion(node.bounds) } };
663
+ }
664
+
665
+ function confirmImageCheck() {
666
+ const draft = imageCheckDraft.value;
667
+ if (!draft || recordingLocked.value) return;
668
+ try {
669
+ validateImageCheck(draft.imageCheck);
670
+ if (draft.imageCheck?.target === 'element' && (!draft.selector || draft.selector.strategy === 'bounds')) throw new Error('请选择具有有效定位信息的组件');
671
+ if (imageCheckEditing) {
672
+ const index = steps.value.findIndex(step => step.id === draft.id);
673
+ if (index < 0) throw new Error('原节点已删除');
674
+ updateStep(index, draft);
675
+ } else insertStep(draft, imageCheckIndex, imageCheckBranch);
676
+ imageCheckDraft.value = null;
677
+ imageCheckPicking.value = false;
678
+ } catch (error) { ElMessage.warning((error as Error).message); }
679
+ }
680
+
634
681
  function currentVisualChangeConfig() {
635
682
  const bounds = selectedNode.value?.bounds;
636
683
  return normalizeVisualChangeConfig({
@@ -1121,6 +1168,7 @@ async function addSwipeStep(index?: number, branchTarget?: BranchTarget) {
1121
1168
  }
1122
1169
 
1123
1170
  function openLinkedScriptDialog(index?: number, branchTarget?: BranchTarget) {
1171
+ linkedScriptEditingId.value = '';
1124
1172
  if (!linkableScripts.value.length) {
1125
1173
  ElMessage.warning('暂无可连接的已保存脚本');
1126
1174
  return;
@@ -1170,7 +1218,14 @@ function addLinkedScriptStep() {
1170
1218
  );
1171
1219
  return;
1172
1220
  }
1173
- insertStep(
1221
+ const editingIndex = linkedScriptEditingId.value ? steps.value.findIndex(step => step.id === linkedScriptEditingId.value) : -1;
1222
+ if (linkedScriptEditingId.value && editingIndex < 0) { ElMessage.warning('原节点已删除,请重新打开'); return; }
1223
+ if (editingIndex >= 0) {
1224
+ const original = steps.value[editingIndex]!;
1225
+ updateStep(editingIndex, { ...original, value: script.id,
1226
+ ...(original.label.startsWith('连接脚本 ') ? { label: `连接脚本 ${script.name}` } : {}),
1227
+ parameters: JSON.parse(JSON.stringify(linkedParameters.parameters || [])), returns: JSON.parse(JSON.stringify(linkedParameters.returns || [])) });
1228
+ } else insertStep(
1174
1229
  { ...createRunScriptStep(script), parameters: JSON.parse(JSON.stringify(linkedParameters.parameters || [])), returns: JSON.parse(JSON.stringify(linkedParameters.returns || [])) },
1175
1230
  linkedScriptInsertIndex.value,
1176
1231
  linkedScriptBranchTarget.value || undefined,
@@ -1179,10 +1234,12 @@ function addLinkedScriptStep() {
1179
1234
  linkedScriptBranchTarget.value = null;
1180
1235
  linkedScriptExpectedActivity.value = '';
1181
1236
  linkedScriptDialogVisible.value = false;
1182
- ElMessage.success('已添加连接脚本步骤');
1237
+ ElMessage.success(editingIndex >= 0 ? '已修改连接脚本' : '已添加连接脚本步骤');
1238
+ linkedScriptEditingId.value = '';
1183
1239
  }
1184
1240
 
1185
1241
  function closeLinkedScriptDialog() {
1242
+ linkedScriptEditingId.value = '';
1186
1243
  linkedScriptDialogVisible.value = false;
1187
1244
  linkedScriptInsertIndex.value = undefined;
1188
1245
  linkedScriptBranchTarget.value = null;
@@ -1245,6 +1302,11 @@ function pickVisualChangeRegion() {
1245
1302
  }
1246
1303
 
1247
1304
  function applyVisualChangeRegion(region: AppiumVisualChangeRegion) {
1305
+ if (imageCheckDraft.value?.imageCheck) {
1306
+ updateImageCheck({ ...imageCheckDraft.value.imageCheck, region, screenWidth: props.deviceWidth || 0, screenHeight: props.deviceHeight || 0 });
1307
+ imageCheckPicking.value = false;
1308
+ return;
1309
+ }
1248
1310
  visualChangeConfig.value = normalizeVisualChangeConfig({
1249
1311
  ...visualChangeConfig.value,
1250
1312
  mode: 'region',
@@ -1282,6 +1344,13 @@ function isReadonlyFlowCopySelected(_index: number) {
1282
1344
  return false;
1283
1345
  }
1284
1346
 
1347
+ function addSharedAction(action: RecorderAction, index: number, beforeStepId?: string) {
1348
+ if (!beforeStepId) return addAction(action, index);
1349
+ const target = steps.value.find(step => step.id === beforeStepId);
1350
+ if (!target) return;
1351
+ return addAction(action, index, { beforeStepId, stepId: target.flow?.parentConditionId || '', branch: target.flow?.parentBranch || 'yes' });
1352
+ }
1353
+
1285
1354
  async function addAction(
1286
1355
  action: RecorderAction,
1287
1356
  index?: number,
@@ -1350,6 +1419,16 @@ async function addAction(
1350
1419
  if (result) return insertStep({ ...step, breakLoopTargetId: scoped.breakLoopTargetId }, index, branchTarget);
1351
1420
  return;
1352
1421
  }
1422
+ if (action === 'imageCheck') {
1423
+ imageCheckIndex = index;
1424
+ imageCheckBranch = branchTarget;
1425
+ imageCheckEditing = false;
1426
+ imageCheckPicking.value = false;
1427
+ imageCheckDraft.value = { id: createStepId(), type: 'imageCheck', label: '图像判断', flow: { nodeKind: 'condition' },
1428
+ imageCheck: { ...createImageCheckConfig(), region: defaultVisualChangeRegion(), screenWidth: props.deviceWidth || 0, screenHeight: props.deviceHeight || 0 } };
1429
+ if (selectedNode.value?.bounds) useImageCheckElement();
1430
+ return;
1431
+ }
1353
1432
  if (action === 'aiRecognition') {
1354
1433
  const input = await ElMessageBox.prompt('识别内容', '添加 AI 识别', {
1355
1434
  inputType: 'textarea',
@@ -1724,11 +1803,28 @@ function updateStep(index: number, step: AppiumRecordedStep) {
1724
1803
  ? syncVisualChangePairConfig(candidate, visualChange)
1725
1804
  : candidate
1726
1805
  ));
1727
- steps.value = nextSteps;
1806
+ steps.value = syncVisualChangeThreshold(nextSteps, step);
1728
1807
  }
1729
1808
 
1730
1809
  async function editInputStep(index: number) {
1731
1810
  const step = steps.value[index];
1811
+ if (step?.type === 'runScript' && !recordingBusy.value) {
1812
+ const parentId = step.flow?.parentConditionId;
1813
+ openLinkedScriptDialog(index - 1, parentId && step.flow?.parentBranch ? { stepId: parentId, branch: step.flow.parentBranch } : undefined);
1814
+ if (!linkedScriptDialogVisible.value) return;
1815
+ linkedScriptEditingId.value = step.id;
1816
+ linkedScriptTargetId.value = step.value || '';
1817
+ linkedParameters.parameters = JSON.parse(JSON.stringify(step.parameters || []));
1818
+ linkedParameters.returns = JSON.parse(JSON.stringify(step.returns || []));
1819
+ return;
1820
+ }
1821
+ if (step?.type === 'imageCheck' && !recordingBusy.value) {
1822
+ imageCheckIndex = index;
1823
+ imageCheckEditing = true;
1824
+ imageCheckPicking.value = false;
1825
+ imageCheckDraft.value = JSON.parse(JSON.stringify(step));
1826
+ return;
1827
+ }
1732
1828
  if (!step || (step.type !== 'input' && step.type !== 'inputIfExists') || recordingBusy.value) return;
1733
1829
  const input = await ElMessageBox.prompt('', '修改输入内容', {
1734
1830
  inputValue: step.value || '',
@@ -2142,7 +2238,7 @@ watch(
2142
2238
  :selected-bounds="selectedBounds"
2143
2239
  :selected-region="visualChangeSelectedRegion"
2144
2240
  :region-selection="visualChangeRegionSelectionEnabled"
2145
- :region-draw-mode="visualChangePicking"
2241
+ :region-draw-mode="visualChangePicking || imageCheckPicking"
2146
2242
  :device-width="deviceWidth"
2147
2243
  :device-height="deviceHeight"
2148
2244
  @switch-device="switchDevice"
@@ -2216,7 +2312,7 @@ watch(
2216
2312
  @copy="copyFlowNodes"
2217
2313
  @paste="pasteFlowNodes"
2218
2314
  @add-delay="addDelayStep"
2219
- @insert-action="(index, action) => addAction(action, index)"
2315
+ @insert-action="(index, action, beforeStepId) => addSharedAction(action, index, beforeStepId)"
2220
2316
  @insert-branch-action="addBranchAction"
2221
2317
  @edit-input="editInputStep"
2222
2318
  @preview-linked-script="previewLinkedScriptStep"
@@ -2331,6 +2427,14 @@ watch(
2331
2427
  @close="aiRecognitionTestStep = null"
2332
2428
  />
2333
2429
 
2430
+ <ImageCheckDialog
2431
+ v-if="imageCheckDraft?.imageCheck"
2432
+ :config="imageCheckDraft.imageCheck" :device-id="selectedDeviceId"
2433
+ :has-element="Boolean(selectedNode?.bounds)" :editing="imageCheckEditing" :picking="imageCheckPicking"
2434
+ @update="updateImageCheck" @close="imageCheckDraft = null; imageCheckPicking = false"
2435
+ @confirm="confirmImageCheck" @use-element="useImageCheckElement"
2436
+ @pick="imageCheckPicking = true; workspaceRef?.showPreview()"
2437
+ />
2334
2438
  <VisualChangeDialog
2335
2439
  :model-value="visualChangeDialogVisible"
2336
2440
  :config="visualChangeConfig"
@@ -2409,7 +2513,7 @@ watch(
2409
2513
  </el-form>
2410
2514
  <template #footer>
2411
2515
  <el-button @click="closeLinkedScriptDialog">取消</el-button>
2412
- <el-button type="primary" @click="addLinkedScriptStep">添加连接</el-button>
2516
+ <el-button type="primary" @click="addLinkedScriptStep">{{ linkedScriptEditingId ? '保存' : '添加连接' }}</el-button>
2413
2517
  </template>
2414
2518
  </el-dialog>
2415
2519
 
@@ -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
 
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue';
3
3
  import { Handle, Position } from '@vue-flow/core';
4
- import { CopyDocument, Delete, Edit, Plus, VideoPlay, View, WarningFilled, Connection } from '@element-plus/icons-vue';
4
+ import { CopyDocument, Delete, Edit, Plus, VideoPlay, View, WarningFilled, Connection, RefreshLeft } from '@element-plus/icons-vue';
5
5
  import { AI_MODEL_CONFIG_HINT } from '../ai-recognition';
6
6
  import type { AppiumRecordedStep } from '../types';
7
7
  import {
@@ -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,
@@ -185,9 +187,9 @@ watch(() => props.data, () => {
185
187
  </span>
186
188
  </button>
187
189
  <span v-if="!props.readonly && !data.deleteMode" class="appium-flow-step-card__actions nodrag nopan" @click.stop>
188
- <el-tooltip v-if="data.flowKind === 'condition' && data.step.type !== 'loop' && !data.copyMode" content="合并分支:将两侧后续节点汇入公共流程" placement="top" :show-after="200">
189
- <span class="appium-node-action-tooltip"><el-button text size="small" :icon="Connection"
190
- title="合并分支" aria-label="合并分支" :disabled="data.mergeDisabled || Boolean(data.step.flow?.successTargetId)"
190
+ <el-tooltip v-if="data.flowKind === 'condition' && data.step.type !== 'loop' && !data.copyMode" :content="data.step.flow?.successTargetId ? '取消合并:还原分支并保留后续编辑' : '合并分支:将两侧后续节点汇入公共流程'" placement="top" :show-after="200">
191
+ <span class="appium-node-action-tooltip"><el-button text size="small" :icon="data.step.flow?.successTargetId ? RefreshLeft : Connection"
192
+ :title="data.step.flow?.successTargetId ? '取消合并' : '合并分支'" :aria-label="data.step.flow?.successTargetId ? '取消合并' : '合并分支'" :disabled="data.mergeDisabled"
191
193
  @click="emit('merge', data.index)" />
192
194
  </span></el-tooltip>
193
195
  <el-tooltip v-if="!data.copyMode && data.canCopy" content="复制节点:复制后可在插入位置粘贴" placement="top" :show-after="200">
@@ -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 === 'runScript' ? '修改连接脚本' : 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 === 'runScript' ? '修改连接脚本' : data.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容'"
224
226
  @click="emit('editInput', data.index)"
225
227
  />
226
228
  </span></el-tooltip>
@@ -5,7 +5,6 @@ import { normalizeVisualChangeConfig } from '../visual-change';
5
5
  import { longPressMode } from '../long-press';
6
6
  import { defaultFlowKind, isBooleanCondition } from '../flow-labels';
7
7
  import TextClickSettings from './TextClickSettings.vue';
8
- import { isNativeStateCondition } from '../native-control-state';
9
8
  import LongPressSettings from './LongPressSettings.vue';
10
9
  import StageLogSettings from './StageLogSettings.vue';
11
10
  import LoopSettings from './LoopSettings.vue';
@@ -47,7 +46,7 @@ function defaultKind(): FlowKind {
47
46
  function patchStep(patch: Partial<AppiumRecordedStep>) {
48
47
  emit('update', {
49
48
  index: props.index,
50
- step: { ...props.step, ...patch },
49
+ step: { ...props.step, ...patch, ...('contextSelector' in patch ? { selectorChain: undefined } : {}) },
51
50
  });
52
51
  }
53
52
 
@@ -79,6 +78,10 @@ function patchSwipe(patch: Partial<SwipeGesture>) {
79
78
  });
80
79
  }
81
80
 
81
+ function patchPinch(patch: Partial<NonNullable<AppiumRecordedStep['pinch']>>) {
82
+ patchStep({ pinch: { direction: 'out', centerX: 0, centerY: 0, percent: 0.5, ...props.step.pinch, ...patch } });
83
+ }
84
+
82
85
  function patchTapCoordinate(axis: 'centerX' | 'centerY', value: number | undefined) {
83
86
  if (value === undefined || !Number.isFinite(value)) return;
84
87
  const fallback = {
@@ -125,7 +128,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
125
128
  patchStep({
126
129
  selector: { ...props.step.selector!, ...patch },
127
130
  // 修改元素目标后,不能继续优先使用录制时的备用 XPath。
128
- ...(props.step.type === 'longPress' || props.step.type === 'extractVariable' || isNativeStateCondition(props.step) ? { selectorChain: undefined } : {}),
131
+ selectorChain: undefined,
129
132
  });
130
133
  }
131
134
  </script>
@@ -173,6 +176,22 @@ function patchSelector(patch: Partial<AppiumSelector>) {
173
176
  </el-form-item>
174
177
  </div>
175
178
  <LongPressSettings v-if="step.type === 'longPress'" :step="step" :disabled="disabled" @update="patchStep" />
179
+ <el-form-item v-if="['waitActivity', 'launchApp', 'clearAppData'].includes(step.type)" :label="step.type === 'waitActivity' ? '目标 Activity' : '目标 APP 包名'">
180
+ <el-input :model-value="step.value || ''" :disabled="disabled" @update:model-value="patchStep({ value: String($event) })" />
181
+ </el-form-item>
182
+ <template v-if="step.type === 'pinch'">
183
+ <el-form-item label="缩放方向">
184
+ <el-select :model-value="step.pinch?.direction || 'out'" :disabled="disabled" @update:model-value="patchPinch({ direction: $event })">
185
+ <el-option label="放大" value="out" /><el-option label="缩小" value="in" />
186
+ </el-select>
187
+ </el-form-item>
188
+ <el-form-item v-for="axis in (['centerX', 'centerY'] as const)" :key="axis" :label="axis === 'centerX' ? '中心 X' : '中心 Y'">
189
+ <el-input-number :model-value="step.pinch?.[axis] ?? 0" :disabled="disabled" :min="0" :max="99999" :precision="0" controls-position="right" @update:model-value="patchPinch({ [axis]: $event ?? 0 })" />
190
+ </el-form-item>
191
+ <el-form-item label="缩放比例">
192
+ <el-input-number :model-value="step.pinch?.percent ?? 0.5" :disabled="disabled" :min="0.01" :max="1" :step="0.05" controls-position="right" @update:model-value="patchPinch({ percent: $event ?? 0.5 })" />
193
+ </el-form-item>
194
+ </template>
176
195
  <div v-if="step.type === 'coordinateTap'" class="appium-flow-editor__grid">
177
196
  <el-form-item label="坐标 X">
178
197
  <el-input-number :model-value="step.fallback?.centerX ?? 0" :disabled="disabled" :min="0" :max="99999" :precision="0" controls-position="right" aria-label="坐标 X" @update:model-value="patchTapCoordinate('centerX', $event)" />
@@ -221,7 +240,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
221
240
  <el-form-item label="变化阈值 %">
222
241
  <el-input-number
223
242
  :model-value="visualChangeConfig.changeRatioThreshold"
224
- :disabled="visualChangeConfigDisabled"
243
+ :disabled="disabled"
225
244
  :min="0.01"
226
245
  :max="100"
227
246
  :step="0.1"