android-midscene-automation 0.1.26 → 0.1.27

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.
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, h, onMounted, onUnmounted, reactive, shallowRef, watch } from 'vue';
3
3
  import { ElForm, ElFormItem, ElInputNumber, ElMessage, ElMessageBox, ElOption, ElSelect } from 'element-plus';
4
- import { Check, CircleClose, CopyDocument, Delete, Document, Download, Refresh, Upload, VideoPlay, View } from '@element-plus/icons-vue';
4
+ import { Check, CircleClose, CopyDocument, Delete, Document, Download, Edit, Refresh, Upload, VideoPlay, View } from '@element-plus/icons-vue';
5
5
  import type { AndroidDevice, AppPreset, DeviceAction } from '../types';
6
6
  import DevicePreviewPanel from '../components/device/DevicePreviewPanel.vue';
7
7
  import {
@@ -21,15 +21,34 @@ import ComponentTree from './components/ComponentTree.vue';
21
21
  import FlowCanvas from './components/FlowCanvas.vue';
22
22
  import NodeDetail from './components/NodeDetail.vue';
23
23
  import RecordedSteps from './components/RecordedSteps.vue';
24
+ import VisualChangeDialog from './components/VisualChangeDialog.vue';
24
25
  import {
25
26
  createFlowClipboard,
26
27
  pasteFlowClipboard,
27
28
  type FlowClipboard,
28
29
  } from './flow-copy';
29
30
  import { labelFlowStep } from './flow-labels';
31
+ import { normalizeLegacyNestedConditionBranches } from './flow-normalize';
32
+ import { removeFlowStep } from './flow-remove';
30
33
  import type { FlowActionGroup, InsertAction } from './flow-graph';
31
34
  import { findSmallestNodeAtPoint, flattenNodes, parseWindowHierarchy } from './tree';
32
- import type { AppiumNode, AppiumRecordedScript, AppiumRecordedStep } from './types';
35
+ import {
36
+ boundsToVisualRegion,
37
+ createStepId,
38
+ normalizeVisualChangeConfig,
39
+ visualRegionToBounds,
40
+ } from './visual-change';
41
+ import {
42
+ findOpenVisualChangeStart,
43
+ nextVisualChangePairLabel,
44
+ } from './visual-change-pairs';
45
+ import type {
46
+ AppiumNode,
47
+ AppiumRecordedScript,
48
+ AppiumRecordedStep,
49
+ AppiumVisualChangeConfig,
50
+ AppiumVisualChangeRegion,
51
+ } from './types';
33
52
 
34
53
  type NodeStepType = 'tap' | 'input' | 'assertExists' | 'waitFor';
35
54
  type BranchName = 'yes' | 'no';
@@ -43,29 +62,7 @@ type BranchTarget = {
43
62
  entryTargetId?: string;
44
63
  nextTargetId?: string;
45
64
  };
46
- type RecorderAction =
47
- | 'delay'
48
- | NodeStepType
49
- | 'tapIfExists'
50
- | 'inputIfExists'
51
- | 'clearIfExists'
52
- | 'backIfExists'
53
- | 'popupCondition'
54
- | 'runScript'
55
- | 'keyBack'
56
- | 'keyHome'
57
- | 'keyRecent'
58
- | 'keyPower'
59
- | 'waitActivity'
60
- | 'swipe'
61
- | 'clearInput'
62
- | 'coordinateTap'
63
- | 'launchApp'
64
- | 'clearAppData'
65
- | 'waitDisappear'
66
- | 'assertText'
67
- | 'longPress'
68
- | 'pinch';
65
+ type RecorderAction = InsertAction;
69
66
 
70
67
  const WORKBENCH_TAB_STORAGE_KEY = 'android-midscene-automation:appium-workbench-tab';
71
68
  const AUTO_TREE_REFRESH_INTERVAL_MS = 1800;
@@ -96,6 +93,9 @@ const insertableRecorderActions: RecorderAction[] = [
96
93
  'assertText',
97
94
  'longPress',
98
95
  'pinch',
96
+ 'noop',
97
+ 'visualChangeStart',
98
+ 'visualChangeEnd',
99
99
  ];
100
100
  const readonlyFlowActionGroups: FlowActionGroup[] = [];
101
101
  const readonlyFlowSelectedIndexes: number[] = [];
@@ -145,11 +145,18 @@ const linkedScriptExpectedActivity = shallowRef('');
145
145
  const linkedScriptPreviewVisible = shallowRef(false);
146
146
  const linkedScriptPreviewScriptId = shallowRef('');
147
147
  const linkedScriptPreviewResetToken = shallowRef(0);
148
+ const visualChangeDialogVisible = shallowRef(false);
149
+ const visualChangePicking = shallowRef(false);
150
+ const visualChangeInsertIndex = shallowRef<number | undefined>();
151
+ const visualChangeBranchTarget = shallowRef<BranchTarget | null>(null);
152
+ const visualChangeConfig = shallowRef<AppiumVisualChangeConfig>(normalizeVisualChangeConfig());
148
153
  const loadingTree = shallowRef(false);
149
154
  const saving = shallowRef(false);
150
155
  const importingScript = shallowRef(false);
151
156
  const scriptImportInput = shallowRef<HTMLInputElement | null>(null);
152
157
  const deletingScriptId = shallowRef('');
158
+ const duplicatingScriptId = shallowRef('');
159
+ const renamingScriptId = shallowRef('');
153
160
  const replaying = shallowRef(false);
154
161
  const stoppingReplay = shallowRef(false);
155
162
  const activeReplayDeviceId = shallowRef('');
@@ -229,16 +236,38 @@ const selectedBounds = computed(() => {
229
236
  const bounds = selectedNode.value?.bounds;
230
237
  return bounds && selectedNode.value ? { id: selectedNode.value.id, ...bounds } : undefined;
231
238
  });
239
+ const visualChangeSelectedRegion = computed(() => {
240
+ if (!visualChangeDialogVisible.value || visualChangeConfig.value.mode !== 'region') return undefined;
241
+ return visualRegionToBounds('visual-change-region', visualChangeConfig.value.region);
242
+ });
243
+ const visualChangeRegionSelectionEnabled = computed(() => (
244
+ visualChangeDialogVisible.value && visualChangeConfig.value.mode === 'region'
245
+ ));
232
246
 
233
- function insertStep(step: AppiumRecordedStep, index?: number) {
234
- const nextSteps = [...steps.value];
235
- let insertedStep = step;
247
+ function stepWithBranchTarget(step: AppiumRecordedStep, branchTarget: BranchTarget) {
248
+ return {
249
+ ...step,
250
+ flow: {
251
+ ...(step.flow || {}),
252
+ parentConditionId: branchTarget.stepId,
253
+ parentBranch: branchTarget.branch,
254
+ ...(branchTarget.nextTargetId ? { successTargetId: branchTarget.nextTargetId } : {}),
255
+ },
256
+ };
257
+ }
258
+
259
+ function insertStep(step: AppiumRecordedStep, index?: number, branchTarget?: BranchTarget) {
260
+ let nextSteps = [...steps.value];
261
+ let insertedStep = branchTarget ? stepWithBranchTarget(step, branchTarget) : step;
236
262
  if (index === -1) {
237
263
  nextSteps.unshift(insertedStep);
238
264
  } else if (typeof index === 'number') {
239
265
  const previousStep = nextSteps[index];
240
266
  const previousTargetId = previousStep?.flow?.successTargetId;
241
- if (previousStep && previousTargetId) {
267
+ const shouldRewireSuccess = previousStep
268
+ && previousTargetId
269
+ && !(branchTarget && previousStep.id === branchTarget.stepId);
270
+ if (shouldRewireSuccess) {
242
271
  insertedStep = {
243
272
  ...insertedStep,
244
273
  flow: {
@@ -256,7 +285,22 @@ function insertStep(step: AppiumRecordedStep, index?: number) {
256
285
  }
257
286
  nextSteps.splice(index + 1, 0, insertedStep);
258
287
  } else {
259
- nextSteps.push(step);
288
+ nextSteps.push(insertedStep);
289
+ }
290
+ if (branchTarget?.updateTarget) {
291
+ const targetKey = branchTarget.branch === 'yes' ? 'yesTargetId' : 'noTargetId';
292
+ nextSteps = nextSteps.map((item) => (
293
+ item.id === branchTarget.stepId
294
+ ? {
295
+ ...item,
296
+ flow: {
297
+ ...(item.flow || {}),
298
+ nodeKind: 'condition',
299
+ [targetKey]: branchTarget.entryTargetId || insertedStep.id,
300
+ },
301
+ }
302
+ : item
303
+ ));
260
304
  }
261
305
  steps.value = nextSteps;
262
306
  return insertedStep;
@@ -334,17 +378,38 @@ function attachStepToBranch(stepId: string, conditionId: string, branch: BranchN
334
378
  ));
335
379
  }
336
380
 
381
+ function applyBranchTargetToInsertedStep(inserted: AppiumRecordedStep, branchTarget: BranchTarget) {
382
+ attachStepToBranch(inserted.id, branchTarget.stepId, branchTarget.branch);
383
+ if (branchTarget.nextTargetId) {
384
+ steps.value = steps.value.map((step) => (
385
+ step.id === inserted.id
386
+ ? {
387
+ ...step,
388
+ flow: { ...(step.flow || {}), successTargetId: branchTarget.nextTargetId },
389
+ }
390
+ : step
391
+ ));
392
+ }
393
+ if (branchTarget.updateTarget) {
394
+ updateBranchTarget(
395
+ branchTarget.stepId,
396
+ branchTarget.branch,
397
+ branchTarget.entryTargetId || inserted.id,
398
+ );
399
+ }
400
+ }
401
+
337
402
  function branchContextForInsert(index: number, branch: BranchName) {
338
403
  const anchor = steps.value[index];
339
404
  if (!anchor) return undefined;
405
+ if (anchor.flow?.nodeKind === 'condition') {
406
+ return { condition: anchor, conditionIndex: index };
407
+ }
340
408
  if (anchor.flow?.parentConditionId && anchor.flow.parentBranch === branch) {
341
409
  const conditionIndex = steps.value.findIndex((step) => step.id === anchor.flow?.parentConditionId);
342
410
  const condition = conditionIndex >= 0 ? steps.value[conditionIndex] : undefined;
343
411
  return condition ? { condition, conditionIndex } : undefined;
344
412
  }
345
- if (anchor.flow?.nodeKind === 'condition') {
346
- return { condition: anchor, conditionIndex: index };
347
- }
348
413
  return undefined;
349
414
  }
350
415
 
@@ -489,6 +554,87 @@ function createRunScriptStep(script: AppiumRecordedScript): AppiumRecordedStep {
489
554
  };
490
555
  }
491
556
 
557
+ function createNoopStep(): AppiumRecordedStep {
558
+ return {
559
+ id: createStepId(),
560
+ type: 'noop',
561
+ label: '空节点',
562
+ flow: { nodeKind: 'action' },
563
+ };
564
+ }
565
+
566
+ function defaultVisualChangeRegion(): AppiumVisualChangeRegion {
567
+ const width = props.deviceWidth || 1080;
568
+ const height = props.deviceHeight || 1920;
569
+ const regionWidth = Math.max(1, Math.round(width * 0.5));
570
+ const regionHeight = Math.max(1, Math.round(height * 0.25));
571
+ return {
572
+ x: Math.max(0, Math.round((width - regionWidth) / 2)),
573
+ y: Math.max(0, Math.round((height - regionHeight) / 2)),
574
+ width: regionWidth,
575
+ height: regionHeight,
576
+ };
577
+ }
578
+
579
+ function currentVisualChangeConfig() {
580
+ const bounds = selectedNode.value?.bounds;
581
+ return normalizeVisualChangeConfig({
582
+ mode: bounds ? 'selectedElement' : 'region',
583
+ region: bounds ? boundsToVisualRegion(bounds) : defaultVisualChangeRegion(),
584
+ } as AppiumVisualChangeConfig);
585
+ }
586
+
587
+ function createVisualChangeStartStep(config: AppiumVisualChangeConfig): AppiumRecordedStep {
588
+ const normalizedConfig = normalizeVisualChangeConfig(config);
589
+ const pairId = createStepId();
590
+ const pairLabel = nextVisualChangePairLabel(steps.value);
591
+ const visualChange = {
592
+ ...normalizedConfig,
593
+ role: 'start' as const,
594
+ pairId,
595
+ pairLabel,
596
+ startStepId: '',
597
+ endStepId: '',
598
+ };
599
+ const node = normalizedConfig.mode === 'selectedElement' ? selectedNode.value : null;
600
+ if (node) {
601
+ const step = createNodeActionStep('visualChange', `${pairLabel}-开始节点`, node);
602
+ return {
603
+ ...step,
604
+ label: `${pairLabel}-开始节点`,
605
+ visualChange,
606
+ flow: { ...(step.flow || {}), nodeKind: 'assertion' },
607
+ };
608
+ }
609
+ return {
610
+ id: createStepId(),
611
+ type: 'visualChange',
612
+ label: `${pairLabel}-开始节点`,
613
+ visualChange,
614
+ flow: { nodeKind: 'assertion' },
615
+ };
616
+ }
617
+
618
+ function createVisualChangeEndStep(startStep: AppiumRecordedStep): AppiumRecordedStep {
619
+ const config = normalizeVisualChangeConfig(startStep.visualChange);
620
+ const id = createStepId();
621
+ const pairLabel = config.pairLabel || '检测画面变化';
622
+ const pairId = config.pairId || startStep.id;
623
+ return {
624
+ id,
625
+ type: 'visualChange',
626
+ label: `${pairLabel}-结束节点`,
627
+ visualChange: {
628
+ ...config,
629
+ role: 'end',
630
+ pairId,
631
+ startStepId: startStep.id,
632
+ endStepId: id,
633
+ },
634
+ flow: { nodeKind: 'assertion' },
635
+ };
636
+ }
637
+
492
638
  function linkedScriptExitActivity(script: AppiumRecordedScript) {
493
639
  for (let index = script.steps.length - 1; index >= 0; index -= 1) {
494
640
  const activity = script.steps[index]?.pageAfter?.activity;
@@ -763,7 +909,7 @@ async function swipePreview(gesture: {
763
909
  }
764
910
  }
765
911
 
766
- async function recordTapStep(index?: number) {
912
+ async function recordTapStep(index?: number, branchTarget?: BranchTarget) {
767
913
  if (!selectedNode.value || recordingTap.value) return;
768
914
  if (!ensureAppPackageSelected()) return;
769
915
  if (!selectedDeviceId.value) {
@@ -778,7 +924,7 @@ async function recordTapStep(index?: number) {
778
924
  }
779
925
  const beforeActivity = currentActivity.value;
780
926
  const step = createStep('tap', node);
781
- insertStep(step, index);
927
+ const inserted = insertStep(step, index, branchTarget);
782
928
  recordingTap.value = true;
783
929
  try {
784
930
  await tapAppiumDevice({ deviceId: selectedDeviceId.value, x: bounds.centerX, y: bounds.centerY });
@@ -805,12 +951,12 @@ async function recordTapStep(index?: number) {
805
951
  }
806
952
  }
807
953
 
808
- async function addStep(type: NodeStepType, index?: number) {
954
+ async function addStep(type: NodeStepType, index?: number, branchTarget?: BranchTarget) {
809
955
  if (recordingBusy.value) return;
810
956
  if (!selectedNode.value) return;
811
957
  if (!ensureAppPackageSelected()) return;
812
958
  if (type === 'tap') {
813
- return recordTapStep(index);
959
+ return recordTapStep(index, branchTarget);
814
960
  }
815
961
  const step = createStep(type, selectedNode.value);
816
962
  if (type === 'input') {
@@ -823,10 +969,10 @@ async function addStep(type: NodeStepType, index?: number) {
823
969
  if (!input) return;
824
970
  step.value = input.value;
825
971
  }
826
- return insertStep(step, index);
972
+ return insertStep(step, index, branchTarget);
827
973
  }
828
974
 
829
- async function addDelayStep(index?: number) {
975
+ async function addDelayStep(index?: number, branchTarget?: BranchTarget) {
830
976
  if (recordingBusy.value) return;
831
977
  const input = await ElMessageBox.prompt('请输入延时时间,单位毫秒', '添加延时', {
832
978
  inputValue: '1000',
@@ -838,7 +984,7 @@ async function addDelayStep(index?: number) {
838
984
  if (!input) return;
839
985
  const timeoutMs = Number(input.value);
840
986
  const step = createDelayStep(timeoutMs);
841
- return insertStep(step, index);
987
+ return insertStep(step, index, branchTarget);
842
988
  }
843
989
 
844
990
  function toSwipeNumber(value: unknown, fallback: number) {
@@ -884,7 +1030,7 @@ function renderSwipeForm(form: SwipeForm) {
884
1030
  ]);
885
1031
  }
886
1032
 
887
- async function addSwipeStep(index?: number) {
1033
+ async function addSwipeStep(index?: number, branchTarget?: BranchTarget) {
888
1034
  const preset = swipePreset('up');
889
1035
  const form = reactive<SwipeForm>({
890
1036
  direction: preset.direction,
@@ -909,7 +1055,7 @@ async function addSwipeStep(index?: number) {
909
1055
  endY: toSwipeNumber(form.endY, finalPreset.swipe.endY),
910
1056
  duration: Math.max(80, toSwipeNumber(form.duration, finalPreset.swipe.duration)),
911
1057
  },
912
- }), index);
1058
+ }), index, branchTarget);
913
1059
  }
914
1060
 
915
1061
  function openLinkedScriptDialog(index?: number, branchTarget?: BranchTarget) {
@@ -958,30 +1104,11 @@ function addLinkedScriptStep() {
958
1104
  );
959
1105
  return;
960
1106
  }
961
- const step = insertStep(createRunScriptStep(script), linkedScriptInsertIndex.value);
962
- if (linkedScriptBranchTarget.value) {
963
- attachStepToBranch(step.id, linkedScriptBranchTarget.value.stepId, linkedScriptBranchTarget.value.branch);
964
- if (linkedScriptBranchTarget.value.nextTargetId) {
965
- steps.value = steps.value.map((item) => (
966
- item.id === step.id
967
- ? {
968
- ...item,
969
- flow: {
970
- ...(item.flow || {}),
971
- successTargetId: linkedScriptBranchTarget.value?.nextTargetId,
972
- },
973
- }
974
- : item
975
- ));
976
- }
977
- if (linkedScriptBranchTarget.value.updateTarget) {
978
- updateBranchTarget(
979
- linkedScriptBranchTarget.value.stepId,
980
- linkedScriptBranchTarget.value.branch,
981
- linkedScriptBranchTarget.value.entryTargetId || step.id,
982
- );
983
- }
984
- }
1107
+ insertStep(
1108
+ createRunScriptStep(script),
1109
+ linkedScriptInsertIndex.value,
1110
+ linkedScriptBranchTarget.value || undefined,
1111
+ );
985
1112
  linkedScriptInsertIndex.value = undefined;
986
1113
  linkedScriptBranchTarget.value = null;
987
1114
  linkedScriptExpectedActivity.value = '';
@@ -1018,6 +1145,64 @@ function loadPreviewScriptForEditing() {
1018
1145
  loadScript(linkedScriptPreviewScript.value);
1019
1146
  }
1020
1147
 
1148
+ function openVisualChangeDialog(index?: number, branchTarget?: BranchTarget) {
1149
+ visualChangeInsertIndex.value = index;
1150
+ visualChangeBranchTarget.value = branchTarget || null;
1151
+ visualChangeConfig.value = currentVisualChangeConfig();
1152
+ visualChangePicking.value = false;
1153
+ visualChangeDialogVisible.value = true;
1154
+ }
1155
+
1156
+ function closeVisualChangeDialog() {
1157
+ visualChangeDialogVisible.value = false;
1158
+ visualChangePicking.value = false;
1159
+ visualChangeInsertIndex.value = undefined;
1160
+ visualChangeBranchTarget.value = null;
1161
+ }
1162
+
1163
+ function updateVisualChangeConfig(config: AppiumVisualChangeConfig) {
1164
+ visualChangeConfig.value = normalizeVisualChangeConfig(config);
1165
+ }
1166
+
1167
+ function pickVisualChangeRegion() {
1168
+ if (!selectedDeviceId.value) {
1169
+ ElMessage.warning('请选择设备');
1170
+ return;
1171
+ }
1172
+ visualChangeConfig.value = normalizeVisualChangeConfig({
1173
+ ...visualChangeConfig.value,
1174
+ mode: 'region',
1175
+ });
1176
+ visualChangePicking.value = true;
1177
+ ElMessage.info('请在设备预览中拖拽框选检测区域');
1178
+ }
1179
+
1180
+ function applyVisualChangeRegion(region: AppiumVisualChangeRegion) {
1181
+ visualChangeConfig.value = normalizeVisualChangeConfig({
1182
+ ...visualChangeConfig.value,
1183
+ mode: 'region',
1184
+ region,
1185
+ });
1186
+ visualChangePicking.value = false;
1187
+ ElMessage.success('已选择检测区域');
1188
+ }
1189
+
1190
+ function confirmVisualChangeStep() {
1191
+ const config = normalizeVisualChangeConfig(visualChangeConfig.value);
1192
+ if (config.mode === 'selectedElement' && !selectedNode.value?.bounds) {
1193
+ ElMessage.warning('当前没有可用的选中元素,请先选择组件或改为手动框选区域');
1194
+ return;
1195
+ }
1196
+ const step = createVisualChangeStartStep(config);
1197
+ insertStep(
1198
+ step,
1199
+ visualChangeInsertIndex.value,
1200
+ visualChangeBranchTarget.value || undefined,
1201
+ );
1202
+ closeVisualChangeDialog();
1203
+ ElMessage.success('已添加检测画面变化开始节点');
1204
+ }
1205
+
1021
1206
  function isReadonlyFlowActionDisabled(_action: InsertAction) {
1022
1207
  return true;
1023
1208
  }
@@ -1037,6 +1222,7 @@ async function addAction(
1037
1222
  ) {
1038
1223
  if (recordingBusy.value) return;
1039
1224
  if (!ensureAppPackageSelected()) return;
1225
+ const previousStep = typeof index === 'number' && index >= 0 ? steps.value[index] : undefined;
1040
1226
  if (action === 'launchApp' && steps.value.some((step) => step.type === 'launchApp')) {
1041
1227
  ElMessage.warning('启动 APP 节点只能添加一个');
1042
1228
  return;
@@ -1045,8 +1231,12 @@ async function addAction(
1045
1231
  ElMessage.warning('清理 App 缓存节点只能添加一个');
1046
1232
  return;
1047
1233
  }
1048
- if ((action === 'launchApp' || action === 'clearAppData') && index !== -1) {
1049
- ElMessage.warning(`${action === 'launchApp' ? '启动 APP' : '清理 App 缓存'}只能从开始节点添加`);
1234
+ if (action === 'clearAppData' && index !== -1) {
1235
+ ElMessage.warning('清理 App 缓存只能从开始节点添加');
1236
+ return;
1237
+ }
1238
+ if (action === 'launchApp' && index !== -1 && previousStep?.type !== 'clearAppData') {
1239
+ ElMessage.warning('启动 APP 只能从开始节点或清理 App 缓存后添加');
1050
1240
  return;
1051
1241
  }
1052
1242
  if (action === 'runScript') {
@@ -1054,22 +1244,22 @@ async function addAction(
1054
1244
  return;
1055
1245
  }
1056
1246
  if (action === 'delay') {
1057
- return addDelayStep(index);
1247
+ return addDelayStep(index, branchTarget);
1058
1248
  }
1059
1249
  if (action === 'tap' || action === 'input' || action === 'assertExists' || action === 'waitFor') {
1060
- return addStep(action, index);
1250
+ return addStep(action, index, branchTarget);
1061
1251
  }
1062
1252
  if (action === 'keyBack') {
1063
- return insertStep(createKeyStep(4, '系统返回'), index);
1253
+ return insertStep(createKeyStep(4, '系统返回'), index, branchTarget);
1064
1254
  }
1065
1255
  if (action === 'keyHome') {
1066
- return insertStep(createKeyStep(3, 'Home 键'), index);
1256
+ return insertStep(createKeyStep(3, 'Home 键'), index, branchTarget);
1067
1257
  }
1068
1258
  if (action === 'keyRecent') {
1069
- return insertStep(createKeyStep(187, '最近任务'), index);
1259
+ return insertStep(createKeyStep(187, '最近任务'), index, branchTarget);
1070
1260
  }
1071
1261
  if (action === 'keyPower') {
1072
- return insertStep(createKeyStep(26, '电源键'), index);
1262
+ return insertStep(createKeyStep(26, '电源键'), index, branchTarget);
1073
1263
  }
1074
1264
  if (action === 'waitActivity') {
1075
1265
  const input = await ElMessageBox.prompt('请输入目标 Activity', '等待 Activity', {
@@ -1077,11 +1267,25 @@ async function addAction(
1077
1267
  confirmButtonText: '添加',
1078
1268
  cancelButtonText: '取消',
1079
1269
  }).catch(() => null);
1080
- if (input?.value) return insertStep(createWaitActivityStep(input.value), index);
1270
+ if (input?.value) return insertStep(createWaitActivityStep(input.value), index, branchTarget);
1081
1271
  return;
1082
1272
  }
1083
1273
  if (action === 'swipe') {
1084
- return addSwipeStep(index);
1274
+ return addSwipeStep(index, branchTarget);
1275
+ }
1276
+ if (action === 'visualChange' || action === 'visualChangeStart') {
1277
+ openVisualChangeDialog(index, branchTarget);
1278
+ return;
1279
+ }
1280
+ if (action === 'visualChangeEnd') {
1281
+ const startStep = findOpenVisualChangeStart(steps.value, index, branchTarget);
1282
+ if (!startStep) {
1283
+ ElMessage.warning('当前分支没有可对应的检测画面变化开始节点');
1284
+ return;
1285
+ }
1286
+ const inserted = insertStep(createVisualChangeEndStep(startStep), index, branchTarget);
1287
+ ElMessage.success(`已添加 ${inserted.label}`);
1288
+ return inserted;
1085
1289
  }
1086
1290
  if (action === 'pinch') {
1087
1291
  const input = await ElMessageBox.prompt('请输入缩放方向:放大 / 缩小', '添加双指缩放', {
@@ -1103,16 +1307,20 @@ async function addAction(
1103
1307
  centerY: Math.round((props.deviceHeight || 1920) / 2),
1104
1308
  percent: 0.5,
1105
1309
  },
1106
- }, index);
1310
+ }, index, branchTarget);
1311
+ }
1312
+ if (action === 'noop') {
1313
+ return insertStep(createNoopStep(), index, branchTarget);
1107
1314
  }
1108
1315
  if (action === 'launchApp') {
1109
1316
  const clearStepIndex = steps.value.findIndex((step) => step.type === 'clearAppData');
1317
+ const insertIndex = previousStep?.type === 'clearAppData' ? index : clearStepIndex >= 0 ? clearStepIndex : index;
1110
1318
  return insertStep({
1111
1319
  id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
1112
1320
  type: 'launchApp',
1113
1321
  label: `启动 APP ${form.appPackage}`,
1114
1322
  value: form.appPackage,
1115
- }, clearStepIndex >= 0 ? clearStepIndex : index);
1323
+ }, insertIndex);
1116
1324
  }
1117
1325
  if (action === 'clearAppData') {
1118
1326
  return insertStep({
@@ -1126,12 +1334,12 @@ async function addAction(
1126
1334
  if (!node) return;
1127
1335
  if (action === 'popupCondition') {
1128
1336
  const step = createNodeActionStep('assertExists', '判断存在', node, { timeoutMs: 2000 });
1129
- const inserted = insertStep({ ...step, flow: { nodeKind: 'condition' } }, index);
1337
+ const inserted = insertStep({ ...step, flow: { nodeKind: 'condition' } }, index, branchTarget);
1130
1338
  ElMessage.success('已添加判断节点,请在节点面板配置是/否分支');
1131
1339
  return inserted;
1132
1340
  }
1133
1341
  if (action === 'tapIfExists') {
1134
- return insertStep(createNodeActionStep('tapIfExists', '存在则点击', node, { timeoutMs: 2000 }), index);
1342
+ return insertStep(createNodeActionStep('tapIfExists', '存在则点击', node, { timeoutMs: 2000 }), index, branchTarget);
1135
1343
  }
1136
1344
  if (action === 'inputIfExists') {
1137
1345
  const input = await ElMessageBox.prompt('', '存在则输入', {
@@ -1141,16 +1349,20 @@ async function addAction(
1141
1349
  center: true,
1142
1350
  }).catch(() => null);
1143
1351
  if (!input) return;
1144
- return insertStep(createNodeActionStep('inputIfExists', '存在则输入', node, { timeoutMs: 2000, value: input.value }), index);
1352
+ return insertStep(
1353
+ createNodeActionStep('inputIfExists', '存在则输入', node, { timeoutMs: 2000, value: input.value }),
1354
+ index,
1355
+ branchTarget,
1356
+ );
1145
1357
  }
1146
1358
  if (action === 'clearIfExists') {
1147
- return insertStep(createNodeActionStep('clearIfExists', '存在则清空', node, { timeoutMs: 2000 }), index);
1359
+ return insertStep(createNodeActionStep('clearIfExists', '存在则清空', node, { timeoutMs: 2000 }), index, branchTarget);
1148
1360
  }
1149
1361
  if (action === 'backIfExists') {
1150
- return insertStep(createNodeActionStep('backIfExists', '存在则返回', node, { timeoutMs: 2000 }), index);
1362
+ return insertStep(createNodeActionStep('backIfExists', '存在则返回', node, { timeoutMs: 2000 }), index, branchTarget);
1151
1363
  }
1152
1364
  if (action === 'clearInput') {
1153
- return insertStep(createNodeActionStep('clearInput', '清空输入', node), index);
1365
+ return insertStep(createNodeActionStep('clearInput', '清空输入', node), index, branchTarget);
1154
1366
  }
1155
1367
  if (action === 'coordinateTap') {
1156
1368
  if (!node.bounds) {
@@ -1162,7 +1374,7 @@ async function addAction(
1162
1374
  type: 'coordinateTap',
1163
1375
  label: `点击坐标 ${node.bounds.centerX},${node.bounds.centerY}`,
1164
1376
  fallback: { strategy: 'bounds', centerX: node.bounds.centerX, centerY: node.bounds.centerY },
1165
- }, index);
1377
+ }, index, branchTarget);
1166
1378
  }
1167
1379
  if (action === 'longPress') {
1168
1380
  if (!node.bounds) {
@@ -1184,10 +1396,10 @@ async function addAction(
1184
1396
  label: `长按 ${node.label}`,
1185
1397
  fallback: { strategy: 'bounds', centerX: node.bounds.centerX, centerY: node.bounds.centerY },
1186
1398
  timeoutMs: duration,
1187
- }, index);
1399
+ }, index, branchTarget);
1188
1400
  }
1189
1401
  if (action === 'waitDisappear') {
1190
- return insertStep(createNodeActionStep('waitDisappear', '等待元素消失', node, { timeoutMs: 10000 }), index);
1402
+ return insertStep(createNodeActionStep('waitDisappear', '等待元素消失', node, { timeoutMs: 10000 }), index, branchTarget);
1191
1403
  }
1192
1404
  if (action === 'assertText') {
1193
1405
  const input = await ElMessageBox.prompt('请输入要断言的文本', '断言文本', {
@@ -1195,7 +1407,7 @@ async function addAction(
1195
1407
  confirmButtonText: '添加',
1196
1408
  cancelButtonText: '取消',
1197
1409
  }).catch(() => null);
1198
- if (input?.value) return insertStep(createNodeActionStep('assertText', '断言文本', node, { value: input.value }), index);
1410
+ if (input?.value) return insertStep(createNodeActionStep('assertText', '断言文本', node, { value: input.value }), index, branchTarget);
1199
1411
  }
1200
1412
  }
1201
1413
 
@@ -1215,28 +1427,18 @@ async function addBranchAction(index: number, branch: BranchName, action: Record
1215
1427
  const nextTargetId = insertAtBranchEntry
1216
1428
  ? nextBranchStepAfter(condition.id, branch, index)?.id || (!targetIsBranchStep ? currentTargetId : undefined)
1217
1429
  : undefined;
1218
- const inserted = await addAction(action, insertIndex, {
1430
+ const branchTarget: BranchTarget = {
1219
1431
  stepId: condition.id,
1220
1432
  branch,
1221
1433
  updateTarget: insertAtBranchEntry || !currentTargetId || !targetIsBranchStep,
1222
1434
  entryTargetId: insertAtBranchEntry ? undefined : existingBranchSteps[0]?.id,
1223
1435
  nextTargetId,
1224
- });
1436
+ };
1437
+ const inserted = await addAction(action, insertIndex, branchTarget);
1225
1438
  if (!inserted) return;
1226
- attachStepToBranch(inserted.id, condition.id, branch);
1227
- if (nextTargetId) {
1228
- steps.value = steps.value.map((step) => (
1229
- step.id === inserted.id
1230
- ? {
1231
- ...step,
1232
- flow: { ...(step.flow || {}), successTargetId: nextTargetId },
1233
- }
1234
- : step
1235
- ));
1236
- }
1237
- if (insertAtBranchEntry || !currentTargetId || !targetIsBranchStep) {
1238
- updateBranchTarget(condition.id, branch, insertAtBranchEntry ? inserted.id : existingBranchSteps[0]?.id || inserted.id);
1239
- }
1439
+ const currentInserted = steps.value.find((step) => step.id === inserted.id);
1440
+ if (currentInserted?.flow?.parentConditionId === condition.id && currentInserted.flow.parentBranch === branch) return;
1441
+ applyBranchTargetToInsertedStep(inserted, branchTarget);
1240
1442
  }
1241
1443
 
1242
1444
  async function saveOnActivityChange() {
@@ -1270,41 +1472,42 @@ async function saveOnActivityChange() {
1270
1472
 
1271
1473
  function removeStep(index: number) {
1272
1474
  if (recordingBusy.value) return;
1273
- const removed = steps.value[index];
1274
- if (!removed) return;
1275
- const nextBranchStep = removed.flow?.parentConditionId && removed.flow.parentBranch
1276
- ? steps.value.slice(index + 1).find((step) => (
1277
- step.flow?.parentConditionId === removed.flow?.parentConditionId
1278
- && step.flow?.parentBranch === removed.flow?.parentBranch
1279
- ))
1280
- : undefined;
1281
- const replacementId = removed.flow?.parentConditionId
1282
- ? nextBranchStep?.id || ''
1283
- : steps.value[index + 1]?.id || '';
1284
- steps.value = steps.value
1285
- .filter((_step, stepIndex) => stepIndex !== index)
1286
- .map((step) => {
1287
- if (!step.flow) return step;
1288
- const flow = { ...step.flow };
1289
- let changed = false;
1290
- (['yesTargetId', 'noTargetId', 'successTargetId', 'failureTargetId'] as const).forEach((key) => {
1291
- if (flow[key] !== removed.id) return;
1292
- flow[key] = replacementId;
1293
- changed = true;
1294
- });
1295
- if (flow.parentConditionId === removed.id) {
1296
- delete flow.parentConditionId;
1297
- delete flow.parentBranch;
1298
- changed = true;
1299
- }
1300
- return changed ? { ...step, flow } : step;
1301
- });
1475
+ steps.value = removeFlowStep(steps.value, index);
1476
+ }
1477
+
1478
+ function syncVisualChangePairConfig(
1479
+ candidate: AppiumRecordedStep,
1480
+ sourceConfig: NonNullable<AppiumRecordedStep['visualChange']>,
1481
+ ) {
1482
+ if (candidate.visualChange?.role !== 'end') return candidate;
1483
+ if (!sourceConfig.pairId || candidate.visualChange.pairId !== sourceConfig.pairId) return candidate;
1484
+ return {
1485
+ ...candidate,
1486
+ visualChange: {
1487
+ ...candidate.visualChange,
1488
+ mode: sourceConfig.mode,
1489
+ region: sourceConfig.region,
1490
+ pairLabel: sourceConfig.pairLabel,
1491
+ durationMs: sourceConfig.durationMs,
1492
+ intervalMs: sourceConfig.intervalMs,
1493
+ changeRatioThreshold: sourceConfig.changeRatioThreshold,
1494
+ pixelmatchThreshold: sourceConfig.pixelmatchThreshold,
1495
+ },
1496
+ };
1302
1497
  }
1303
1498
 
1304
1499
  function updateStep(index: number, step: AppiumRecordedStep) {
1305
1500
  if (recordingLocked.value) return;
1306
- const nextSteps = [...steps.value];
1307
- nextSteps[index] = step;
1501
+ const visualChange = step.visualChange?.role === 'start'
1502
+ ? normalizeVisualChangeConfig(step.visualChange)
1503
+ : undefined;
1504
+ const nextSteps = steps.value.map((candidate, stepIndex) => (
1505
+ stepIndex === index
1506
+ ? step
1507
+ : visualChange
1508
+ ? syncVisualChangePairConfig(candidate, visualChange)
1509
+ : candidate
1510
+ ));
1308
1511
  steps.value = nextSteps;
1309
1512
  }
1310
1513
 
@@ -1339,7 +1542,7 @@ function loadScript(script: AppiumRecordedScript) {
1339
1542
  if (script.deviceId && script.deviceId !== selectedDeviceId.value) {
1340
1543
  void switchDevice(script.deviceId);
1341
1544
  }
1342
- steps.value = (script.steps || []).map(normalizeLegacyFlowScope);
1545
+ steps.value = normalizeLegacyNestedConditionBranches((script.steps || []).map(normalizeLegacyFlowScope));
1343
1546
  markDraftSaved();
1344
1547
  }
1345
1548
 
@@ -1367,6 +1570,91 @@ function formatScriptTime(value: string) {
1367
1570
  return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
1368
1571
  }
1369
1572
 
1573
+ function cloneScriptSteps(stepList: AppiumRecordedStep[]) {
1574
+ return JSON.parse(JSON.stringify(stepList || [])) as AppiumRecordedStep[];
1575
+ }
1576
+
1577
+ function isScriptNameTaken(name: string, exceptId = '') {
1578
+ return scripts.value.some((script) => script.id !== exceptId && script.name === name);
1579
+ }
1580
+
1581
+ function createDuplicateScriptName(sourceName: string) {
1582
+ const baseName = `${sourceName} Copy`;
1583
+ if (!isScriptNameTaken(baseName)) return baseName;
1584
+
1585
+ let suffix = 2;
1586
+ let nextName = `${baseName} ${suffix}`;
1587
+ while (isScriptNameTaken(nextName)) {
1588
+ suffix += 1;
1589
+ nextName = `${baseName} ${suffix}`;
1590
+ }
1591
+ return nextName;
1592
+ }
1593
+
1594
+ async function duplicateScript(script: AppiumRecordedScript) {
1595
+ if (duplicatingScriptId.value) return;
1596
+ duplicatingScriptId.value = script.id;
1597
+ try {
1598
+ const payload = await saveAppiumScript({
1599
+ name: createDuplicateScriptName(script.name),
1600
+ appPackage: script.appPackage,
1601
+ appActivity: script.appActivity,
1602
+ deviceId: script.deviceId,
1603
+ steps: cloneScriptSteps(script.steps),
1604
+ });
1605
+ await loadScripts();
1606
+ ElMessage.success(`已复制为「${payload.script.name}」`);
1607
+ } catch (error) {
1608
+ ElMessage.error(error instanceof Error ? error.message : '复制脚本失败');
1609
+ } finally {
1610
+ duplicatingScriptId.value = '';
1611
+ }
1612
+ }
1613
+
1614
+ async function renameScript(script: AppiumRecordedScript) {
1615
+ if (renamingScriptId.value) return;
1616
+ const input = await ElMessageBox.prompt('请输入新的脚本名称', '修改脚本名称', {
1617
+ inputValue: script.name,
1618
+ confirmButtonText: '保存',
1619
+ cancelButtonText: '取消',
1620
+ center: true,
1621
+ inputValidator(value) {
1622
+ const nextName = String(value || '').trim();
1623
+ if (!nextName) return '脚本名称不能为空';
1624
+ if (isScriptNameTaken(nextName, script.id)) return '脚本名称已存在';
1625
+ return true;
1626
+ },
1627
+ }).catch(() => null);
1628
+ const nextName = input ? String(input.value || '').trim() : '';
1629
+ if (!nextName || nextName === script.name) return;
1630
+
1631
+ const wasCurrentScript = selectedScriptId.value === script.id;
1632
+ const hadUnsavedChanges = hasUnsavedChanges.value;
1633
+ renamingScriptId.value = script.id;
1634
+ try {
1635
+ const payload = await saveAppiumScript({
1636
+ id: script.id,
1637
+ name: nextName,
1638
+ appPackage: script.appPackage,
1639
+ appActivity: script.appActivity,
1640
+ deviceId: script.deviceId,
1641
+ steps: cloneScriptSteps(script.steps),
1642
+ });
1643
+ await loadScripts();
1644
+ if (wasCurrentScript) {
1645
+ form.name = payload.script.name;
1646
+ if (!hadUnsavedChanges) {
1647
+ markDraftSaved();
1648
+ }
1649
+ }
1650
+ ElMessage.success('脚本名称已修改');
1651
+ } catch (error) {
1652
+ ElMessage.error(error instanceof Error ? error.message : '修改脚本名称失败');
1653
+ } finally {
1654
+ renamingScriptId.value = '';
1655
+ }
1656
+ }
1657
+
1370
1658
  async function removeScript(script: AppiumRecordedScript) {
1371
1659
  try {
1372
1660
  await ElMessageBox.confirm(`确定删除脚本「${script.name}」吗?`, '删除脚本', {
@@ -1428,7 +1716,7 @@ async function importScriptFile(event: Event) {
1428
1716
  }
1429
1717
 
1430
1718
  async function saveScript() {
1431
- if (!ensureAppPackageSelected()) return;
1719
+ if (!ensureAppPackageSelected()) return false;
1432
1720
  saving.value = true;
1433
1721
  try {
1434
1722
  const payload = await saveAppiumScript({
@@ -1443,8 +1731,10 @@ async function saveScript() {
1443
1731
  selectedScriptId.value = payload.script.id;
1444
1732
  markDraftSaved();
1445
1733
  ElMessage.success('Appium 脚本已保存');
1734
+ return true;
1446
1735
  } catch (error) {
1447
1736
  ElMessage.error(error instanceof Error ? error.message : '保存失败');
1737
+ return false;
1448
1738
  } finally {
1449
1739
  saving.value = false;
1450
1740
  }
@@ -1455,6 +1745,10 @@ async function replayScript() {
1455
1745
  ElMessage.warning('请选择已保存脚本');
1456
1746
  return;
1457
1747
  }
1748
+ if (hasUnsavedChanges.value) {
1749
+ const saved = await saveScript();
1750
+ if (!saved || !selectedScript.value) return;
1751
+ }
1458
1752
  replaying.value = true;
1459
1753
  stoppingReplay.value = false;
1460
1754
  activeReplayDeviceId.value = selectedDeviceId.value;
@@ -1470,6 +1764,8 @@ async function replayScript() {
1470
1764
  replayOutput.value = result.output || '';
1471
1765
  if (result.stopped) {
1472
1766
  ElMessage.info('回放已终止,报告、日志和截图回放已生成');
1767
+ } else if (result.softFailureCount) {
1768
+ ElMessage.warning(`回放完成,但存在 ${result.softFailureCount} 个视觉变化未达预期节点`);
1473
1769
  } else if (result.success) {
1474
1770
  ElMessage.success('回放完成');
1475
1771
  } else {
@@ -1581,12 +1877,16 @@ watch(
1581
1877
  :actions="deviceActions"
1582
1878
  :overlay-bounds="overlayBounds"
1583
1879
  :selected-bounds="selectedBounds"
1880
+ :selected-region="visualChangeSelectedRegion"
1881
+ :region-selection="visualChangeRegionSelectionEnabled"
1882
+ :region-draw-mode="visualChangePicking"
1584
1883
  :device-width="deviceWidth"
1585
1884
  :device-height="deviceHeight"
1586
1885
  @switch-device="switchDevice"
1587
1886
  @trigger-key="triggerDeviceKey"
1588
1887
  @refresh-preview="refreshDevicePreview"
1589
1888
  @tap="selectNodeFromPoint"
1889
+ @region-select="applyVisualChangeRegion"
1590
1890
  @swipe="swipePreview"
1591
1891
  />
1592
1892
 
@@ -1715,18 +2015,33 @@ watch(
1715
2015
  </div>
1716
2016
  <div class="appium-script-list__actions">
1717
2017
  <el-button size="small" @click.stop="loadScript(script)">加载</el-button>
2018
+ <el-button
2019
+ size="small"
2020
+ :icon="Edit"
2021
+ :loading="renamingScriptId === script.id"
2022
+ title="修改脚本名称"
2023
+ @click.stop="renameScript(script)"
2024
+ />
2025
+ <el-button
2026
+ size="small"
2027
+ :icon="CopyDocument"
2028
+ :loading="duplicatingScriptId === script.id"
2029
+ title="复制脚本"
2030
+ @click.stop="duplicateScript(script)"
2031
+ />
1718
2032
  <el-button
1719
2033
  size="small"
1720
2034
  :icon="Download"
1721
2035
  title="下载脚本"
1722
- @click="downloadScript(script)"
2036
+ @click.stop="downloadScript(script)"
1723
2037
  />
1724
2038
  <el-button
1725
2039
  size="small"
1726
2040
  type="danger"
1727
2041
  :icon="Delete"
1728
2042
  :loading="deletingScriptId === script.id"
1729
- @click="removeScript(script)"
2043
+ title="删除脚本"
2044
+ @click.stop="removeScript(script)"
1730
2045
  />
1731
2046
  </div>
1732
2047
  </article>
@@ -1737,6 +2052,17 @@ watch(
1737
2052
  </el-card>
1738
2053
  </div>
1739
2054
 
2055
+ <VisualChangeDialog
2056
+ :model-value="visualChangeDialogVisible"
2057
+ :config="visualChangeConfig"
2058
+ :has-selected-element="Boolean(selectedNode?.bounds)"
2059
+ :picking-region="visualChangePicking"
2060
+ @update:model-value="$event ? (visualChangeDialogVisible = true) : closeVisualChangeDialog()"
2061
+ @update:config="updateVisualChangeConfig"
2062
+ @pick-region="pickVisualChangeRegion"
2063
+ @confirm="confirmVisualChangeStep"
2064
+ />
2065
+
1740
2066
  <el-dialog
1741
2067
  :model-value="Boolean(pendingNavigation)"
1742
2068
  title="检测到 Activity 跳转"