android-midscene-automation 0.1.26 → 0.1.28

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 (56) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/FAQ.md +14 -0
  3. package/README.md +39 -3
  4. package/bin/android-midscene-automation.js +71 -1
  5. package/package.json +7 -2
  6. package/remote-agent/index.ts +2 -11
  7. package/server/appium-recorder/ai-recognition.ts +67 -0
  8. package/server/appium-recorder/appium-runner.ts +335 -52
  9. package/server/appium-recorder/report.ts +297 -16
  10. package/server/appium-recorder/repository.ts +31 -4
  11. package/server/appium-recorder/routes.ts +31 -32
  12. package/server/appium-recorder/screenshot.ts +21 -0
  13. package/server/appium-recorder/script-export.ts +67 -0
  14. package/server/appium-recorder/tree-dump.ts +22 -0
  15. package/server/appium-recorder/visual-change.ts +184 -0
  16. package/server/config.ts +10 -0
  17. package/server/http-api.ts +4 -5
  18. package/src/App.vue +14 -7
  19. package/src/api.ts +1 -1
  20. package/src/appium-recorder/AppiumPage.vue +658 -162
  21. package/src/appium-recorder/ai-recognition.ts +16 -0
  22. package/src/appium-recorder/api.ts +20 -3
  23. package/src/appium-recorder/components/AiRecognitionTestDialog.vue +71 -0
  24. package/src/appium-recorder/components/FlowActionMenu.vue +206 -0
  25. package/src/appium-recorder/components/FlowCanvas.vue +16 -1
  26. package/src/appium-recorder/components/FlowNodeCard.vue +23 -37
  27. package/src/appium-recorder/components/FlowStepEditor.vue +152 -25
  28. package/src/appium-recorder/components/LongPressSettings.vue +58 -0
  29. package/src/appium-recorder/components/NestedConditionBranches.vue +55 -35
  30. package/src/appium-recorder/components/NewScriptDialog.vue +27 -0
  31. package/src/appium-recorder/components/NodeDetail.vue +2 -0
  32. package/src/appium-recorder/components/RecordedSteps.vue +24 -4
  33. package/src/appium-recorder/components/StageLogSettings.vue +17 -0
  34. package/src/appium-recorder/components/TextClickSettings.vue +19 -0
  35. package/src/appium-recorder/components/VisualChangeDialog.vue +151 -0
  36. package/src/appium-recorder/flow-copy.ts +47 -1
  37. package/src/appium-recorder/flow-graph.ts +147 -64
  38. package/src/appium-recorder/flow-labels.ts +35 -6
  39. package/src/appium-recorder/flow-normalize.ts +76 -0
  40. package/src/appium-recorder/flow-remove.ts +69 -0
  41. package/src/appium-recorder/long-press.ts +27 -0
  42. package/src/appium-recorder/native-control-state.ts +26 -0
  43. package/src/appium-recorder/stage-log.ts +21 -0
  44. package/src/appium-recorder/text-click.ts +9 -0
  45. package/src/appium-recorder/tree.ts +3 -0
  46. package/src/appium-recorder/types.ts +34 -2
  47. package/src/appium-recorder/visual-change-pairs.ts +147 -0
  48. package/src/appium-recorder/visual-change.ts +75 -0
  49. package/src/components/config/AppiumConfigPanel.vue +48 -0
  50. package/src/components/config/BaseConfigPanel.vue +90 -0
  51. package/src/components/config/MidsceneConfigPanel.vue +204 -0
  52. package/src/components/device/DevicePreviewPanel.vue +148 -4
  53. package/src/pages/ConfigPage.vue +107 -265
  54. package/src/style.css +43 -4
  55. package/src/types.ts +2 -0
  56. package/src/ui-refresh.css +5 -0
@@ -1,13 +1,13 @@
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, Plus, 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 {
8
- appiumScriptDownloadUrl,
9
8
  clearAppiumDeviceAppData,
10
9
  deleteAppiumScript,
10
+ downloadAppiumScript,
11
11
  getAppiumScripts,
12
12
  getAppiumTree,
13
13
  importAppiumScript,
@@ -20,16 +20,45 @@ import {
20
20
  import ComponentTree from './components/ComponentTree.vue';
21
21
  import FlowCanvas from './components/FlowCanvas.vue';
22
22
  import NodeDetail from './components/NodeDetail.vue';
23
+ import LongPressSettings from './components/LongPressSettings.vue';
24
+ import StageLogSettings from './components/StageLogSettings.vue';
25
+ import TextClickSettings from './components/TextClickSettings.vue';
26
+ import { textClickSelector } from './text-click';
27
+ import { DEFAULT_LOG_PREFIX, validateStageLog } from './stage-log';
23
28
  import RecordedSteps from './components/RecordedSteps.vue';
29
+ import NewScriptDialog from './components/NewScriptDialog.vue';
30
+ import VisualChangeDialog from './components/VisualChangeDialog.vue';
31
+ import AiRecognitionTestDialog from './components/AiRecognitionTestDialog.vue';
32
+ import { validateAiRecognitionPrompt } from './ai-recognition';
24
33
  import {
25
34
  createFlowClipboard,
26
35
  pasteFlowClipboard,
27
36
  type FlowClipboard,
28
37
  } from './flow-copy';
29
38
  import { labelFlowStep } from './flow-labels';
39
+ import { validateLongPress } from './long-press';
40
+ import { nativeControlName, nativeControlType } from './native-control-state';
41
+ import { normalizeLegacyNestedConditionBranches } from './flow-normalize';
42
+ import { removeFlowStep } from './flow-remove';
30
43
  import type { FlowActionGroup, InsertAction } from './flow-graph';
31
44
  import { findSmallestNodeAtPoint, flattenNodes, parseWindowHierarchy } from './tree';
32
- import type { AppiumNode, AppiumRecordedScript, AppiumRecordedStep } from './types';
45
+ import {
46
+ boundsToVisualRegion,
47
+ createStepId,
48
+ normalizeVisualChangeConfig,
49
+ visualRegionToBounds,
50
+ } from './visual-change';
51
+ import {
52
+ findOpenVisualChangeStart,
53
+ nextVisualChangePairLabel,
54
+ } from './visual-change-pairs';
55
+ import type {
56
+ AppiumNode,
57
+ AppiumRecordedScript,
58
+ AppiumRecordedStep,
59
+ AppiumVisualChangeConfig,
60
+ AppiumVisualChangeRegion,
61
+ } from './types';
33
62
 
34
63
  type NodeStepType = 'tap' | 'input' | 'assertExists' | 'waitFor';
35
64
  type BranchName = 'yes' | 'no';
@@ -43,29 +72,7 @@ type BranchTarget = {
43
72
  entryTargetId?: string;
44
73
  nextTargetId?: string;
45
74
  };
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';
75
+ type RecorderAction = InsertAction;
69
76
 
70
77
  const WORKBENCH_TAB_STORAGE_KEY = 'android-midscene-automation:appium-workbench-tab';
71
78
  const AUTO_TREE_REFRESH_INTERVAL_MS = 1800;
@@ -81,6 +88,10 @@ const insertableRecorderActions: RecorderAction[] = [
81
88
  'clearIfExists',
82
89
  'backIfExists',
83
90
  'popupCondition',
91
+ 'checkboxState',
92
+ 'radioButtonState',
93
+ 'aiRecognition',
94
+ 'textClick',
84
95
  'runScript',
85
96
  'keyBack',
86
97
  'keyHome',
@@ -96,11 +107,16 @@ const insertableRecorderActions: RecorderAction[] = [
96
107
  'assertText',
97
108
  'longPress',
98
109
  'pinch',
110
+ 'noop',
111
+ 'log',
112
+ 'visualChangeStart',
113
+ 'visualChangeEnd',
99
114
  ];
100
115
  const readonlyFlowActionGroups: FlowActionGroup[] = [];
101
116
  const readonlyFlowSelectedIndexes: number[] = [];
102
117
 
103
118
  const props = defineProps<{
119
+ aiRecognitionModelConfigured?: boolean;
104
120
  active: boolean;
105
121
  appPresets: AppPreset[];
106
122
  deviceActions: readonly DeviceAction[];
@@ -129,9 +145,12 @@ const tree = shallowRef<AppiumNode | null>(null);
129
145
  const selectedNode = shallowRef<AppiumNode | null>(null);
130
146
  const scripts = shallowRef<AppiumRecordedScript[]>([]);
131
147
  const selectedScriptId = shallowRef('');
148
+ const newScriptDialogVisible = shallowRef(false);
149
+ const newScriptRevision = shallowRef(0);
132
150
  const storedWorkbenchTab = window.localStorage.getItem(WORKBENCH_TAB_STORAGE_KEY);
133
151
  const activeWorkbenchTab = shallowRef<'recording' | 'scripts'>(storedWorkbenchTab === 'scripts' ? 'scripts' : 'recording');
134
152
  const steps = shallowRef<AppiumRecordedStep[]>([]);
153
+ const aiRecognitionTestStep = shallowRef<AppiumRecordedStep | null>(null);
135
154
  const flowClipboard = shallowRef<FlowClipboard | null>(null);
136
155
  const rawXml = shallowRef('');
137
156
  const currentActivity = shallowRef('');
@@ -145,11 +164,19 @@ const linkedScriptExpectedActivity = shallowRef('');
145
164
  const linkedScriptPreviewVisible = shallowRef(false);
146
165
  const linkedScriptPreviewScriptId = shallowRef('');
147
166
  const linkedScriptPreviewResetToken = shallowRef(0);
167
+ const visualChangeDialogVisible = shallowRef(false);
168
+ const visualChangePicking = shallowRef(false);
169
+ const visualChangeInsertIndex = shallowRef<number | undefined>();
170
+ const visualChangeBranchTarget = shallowRef<BranchTarget | null>(null);
171
+ const visualChangeConfig = shallowRef<AppiumVisualChangeConfig>(normalizeVisualChangeConfig());
148
172
  const loadingTree = shallowRef(false);
149
173
  const saving = shallowRef(false);
150
174
  const importingScript = shallowRef(false);
151
175
  const scriptImportInput = shallowRef<HTMLInputElement | null>(null);
152
176
  const deletingScriptId = shallowRef('');
177
+ const duplicatingScriptId = shallowRef('');
178
+ const renamingScriptId = shallowRef('');
179
+ const downloadingScriptId = shallowRef('');
153
180
  const replaying = shallowRef(false);
154
181
  const stoppingReplay = shallowRef(false);
155
182
  const activeReplayDeviceId = shallowRef('');
@@ -222,6 +249,7 @@ const recordingBusy = computed(() => (
222
249
  || Boolean(pendingNavigation.value)
223
250
  ));
224
251
  const recordingLocked = computed(() => recordingBusy.value || scriptActivityMismatch.value);
252
+ const newScriptDisabled = computed(() => saving.value || replaying.value || recordingBusy.value || launchingApp.value || importingScript.value);
225
253
  const overlayBounds = computed(() => flattenNodes(tree.value).flatMap((node) => (
226
254
  node.bounds ? [{ id: node.id, ...node.bounds }] : []
227
255
  )));
@@ -229,16 +257,38 @@ const selectedBounds = computed(() => {
229
257
  const bounds = selectedNode.value?.bounds;
230
258
  return bounds && selectedNode.value ? { id: selectedNode.value.id, ...bounds } : undefined;
231
259
  });
260
+ const visualChangeSelectedRegion = computed(() => {
261
+ if (!visualChangeDialogVisible.value || visualChangeConfig.value.mode !== 'region') return undefined;
262
+ return visualRegionToBounds('visual-change-region', visualChangeConfig.value.region);
263
+ });
264
+ const visualChangeRegionSelectionEnabled = computed(() => (
265
+ visualChangeDialogVisible.value && visualChangeConfig.value.mode === 'region'
266
+ ));
232
267
 
233
- function insertStep(step: AppiumRecordedStep, index?: number) {
234
- const nextSteps = [...steps.value];
235
- let insertedStep = step;
268
+ function stepWithBranchTarget(step: AppiumRecordedStep, branchTarget: BranchTarget) {
269
+ return {
270
+ ...step,
271
+ flow: {
272
+ ...(step.flow || {}),
273
+ parentConditionId: branchTarget.stepId,
274
+ parentBranch: branchTarget.branch,
275
+ ...(branchTarget.nextTargetId ? { successTargetId: branchTarget.nextTargetId } : {}),
276
+ },
277
+ };
278
+ }
279
+
280
+ function insertStep(step: AppiumRecordedStep, index?: number, branchTarget?: BranchTarget) {
281
+ let nextSteps = [...steps.value];
282
+ let insertedStep = branchTarget ? stepWithBranchTarget(step, branchTarget) : step;
236
283
  if (index === -1) {
237
284
  nextSteps.unshift(insertedStep);
238
285
  } else if (typeof index === 'number') {
239
286
  const previousStep = nextSteps[index];
240
287
  const previousTargetId = previousStep?.flow?.successTargetId;
241
- if (previousStep && previousTargetId) {
288
+ const shouldRewireSuccess = previousStep
289
+ && previousTargetId
290
+ && !(branchTarget && previousStep.id === branchTarget.stepId);
291
+ if (shouldRewireSuccess) {
242
292
  insertedStep = {
243
293
  ...insertedStep,
244
294
  flow: {
@@ -256,7 +306,22 @@ function insertStep(step: AppiumRecordedStep, index?: number) {
256
306
  }
257
307
  nextSteps.splice(index + 1, 0, insertedStep);
258
308
  } else {
259
- nextSteps.push(step);
309
+ nextSteps.push(insertedStep);
310
+ }
311
+ if (branchTarget?.updateTarget) {
312
+ const targetKey = branchTarget.branch === 'yes' ? 'yesTargetId' : 'noTargetId';
313
+ nextSteps = nextSteps.map((item) => (
314
+ item.id === branchTarget.stepId
315
+ ? {
316
+ ...item,
317
+ flow: {
318
+ ...(item.flow || {}),
319
+ nodeKind: 'condition',
320
+ [targetKey]: branchTarget.entryTargetId || insertedStep.id,
321
+ },
322
+ }
323
+ : item
324
+ ));
260
325
  }
261
326
  steps.value = nextSteps;
262
327
  return insertedStep;
@@ -287,6 +352,7 @@ function pasteFlowNodes(index: number, branch?: BranchName) {
287
352
  });
288
353
  steps.value = nextSteps;
289
354
  ElMessage.success(`已粘贴 ${flowClipboardCount.value} 个节点${branch ? `到${branch === 'yes' ? '是' : '否'}分支` : ''}`);
355
+ flowClipboard.value = null;
290
356
  } catch (error) {
291
357
  ElMessage.warning(error instanceof Error ? error.message : '粘贴节点失败');
292
358
  }
@@ -334,17 +400,38 @@ function attachStepToBranch(stepId: string, conditionId: string, branch: BranchN
334
400
  ));
335
401
  }
336
402
 
403
+ function applyBranchTargetToInsertedStep(inserted: AppiumRecordedStep, branchTarget: BranchTarget) {
404
+ attachStepToBranch(inserted.id, branchTarget.stepId, branchTarget.branch);
405
+ if (branchTarget.nextTargetId) {
406
+ steps.value = steps.value.map((step) => (
407
+ step.id === inserted.id
408
+ ? {
409
+ ...step,
410
+ flow: { ...(step.flow || {}), successTargetId: branchTarget.nextTargetId },
411
+ }
412
+ : step
413
+ ));
414
+ }
415
+ if (branchTarget.updateTarget) {
416
+ updateBranchTarget(
417
+ branchTarget.stepId,
418
+ branchTarget.branch,
419
+ branchTarget.entryTargetId || inserted.id,
420
+ );
421
+ }
422
+ }
423
+
337
424
  function branchContextForInsert(index: number, branch: BranchName) {
338
425
  const anchor = steps.value[index];
339
426
  if (!anchor) return undefined;
427
+ if (anchor.flow?.nodeKind === 'condition') {
428
+ return { condition: anchor, conditionIndex: index };
429
+ }
340
430
  if (anchor.flow?.parentConditionId && anchor.flow.parentBranch === branch) {
341
431
  const conditionIndex = steps.value.findIndex((step) => step.id === anchor.flow?.parentConditionId);
342
432
  const condition = conditionIndex >= 0 ? steps.value[conditionIndex] : undefined;
343
433
  return condition ? { condition, conditionIndex } : undefined;
344
434
  }
345
- if (anchor.flow?.nodeKind === 'condition') {
346
- return { condition: anchor, conditionIndex: index };
347
- }
348
435
  return undefined;
349
436
  }
350
437
 
@@ -489,6 +576,87 @@ function createRunScriptStep(script: AppiumRecordedScript): AppiumRecordedStep {
489
576
  };
490
577
  }
491
578
 
579
+ function createNoopStep(): AppiumRecordedStep {
580
+ return {
581
+ id: createStepId(),
582
+ type: 'noop',
583
+ label: '空节点',
584
+ flow: { nodeKind: 'action' },
585
+ };
586
+ }
587
+
588
+ function defaultVisualChangeRegion(): AppiumVisualChangeRegion {
589
+ const width = props.deviceWidth || 1080;
590
+ const height = props.deviceHeight || 1920;
591
+ const regionWidth = Math.max(1, Math.round(width * 0.5));
592
+ const regionHeight = Math.max(1, Math.round(height * 0.25));
593
+ return {
594
+ x: Math.max(0, Math.round((width - regionWidth) / 2)),
595
+ y: Math.max(0, Math.round((height - regionHeight) / 2)),
596
+ width: regionWidth,
597
+ height: regionHeight,
598
+ };
599
+ }
600
+
601
+ function currentVisualChangeConfig() {
602
+ const bounds = selectedNode.value?.bounds;
603
+ return normalizeVisualChangeConfig({
604
+ mode: bounds ? 'selectedElement' : 'region',
605
+ region: bounds ? boundsToVisualRegion(bounds) : defaultVisualChangeRegion(),
606
+ } as AppiumVisualChangeConfig);
607
+ }
608
+
609
+ function createVisualChangeStartStep(config: AppiumVisualChangeConfig): AppiumRecordedStep {
610
+ const normalizedConfig = normalizeVisualChangeConfig(config);
611
+ const pairId = createStepId();
612
+ const pairLabel = nextVisualChangePairLabel(steps.value);
613
+ const visualChange = {
614
+ ...normalizedConfig,
615
+ role: 'start' as const,
616
+ pairId,
617
+ pairLabel,
618
+ startStepId: '',
619
+ endStepId: '',
620
+ };
621
+ const node = normalizedConfig.mode === 'selectedElement' ? selectedNode.value : null;
622
+ if (node) {
623
+ const step = createNodeActionStep('visualChange', `${pairLabel}-开始节点`, node);
624
+ return {
625
+ ...step,
626
+ label: `${pairLabel}-开始节点`,
627
+ visualChange,
628
+ flow: { ...(step.flow || {}), nodeKind: 'assertion' },
629
+ };
630
+ }
631
+ return {
632
+ id: createStepId(),
633
+ type: 'visualChange',
634
+ label: `${pairLabel}-开始节点`,
635
+ visualChange,
636
+ flow: { nodeKind: 'assertion' },
637
+ };
638
+ }
639
+
640
+ function createVisualChangeEndStep(startStep: AppiumRecordedStep): AppiumRecordedStep {
641
+ const config = normalizeVisualChangeConfig(startStep.visualChange);
642
+ const id = createStepId();
643
+ const pairLabel = config.pairLabel || '检测画面变化';
644
+ const pairId = config.pairId || startStep.id;
645
+ return {
646
+ id,
647
+ type: 'visualChange',
648
+ label: `${pairLabel}-结束节点`,
649
+ visualChange: {
650
+ ...config,
651
+ role: 'end',
652
+ pairId,
653
+ startStepId: startStep.id,
654
+ endStepId: id,
655
+ },
656
+ flow: { nodeKind: 'assertion' },
657
+ };
658
+ }
659
+
492
660
  function linkedScriptExitActivity(script: AppiumRecordedScript) {
493
661
  for (let index = script.steps.length - 1; index >= 0; index -= 1) {
494
662
  const activity = script.steps[index]?.pageAfter?.activity;
@@ -679,6 +847,11 @@ async function refreshTree() {
679
847
 
680
848
  async function executeFlowStep(index: number) {
681
849
  const step = steps.value[index];
850
+ if (step?.type === 'aiRecognition') {
851
+ if (replaying.value || recordingBusy.value) { ElMessage.warning('设备正在执行操作,请稍后测试'); return; }
852
+ aiRecognitionTestStep.value = { ...step };
853
+ return;
854
+ }
682
855
  if (!step || (step.type !== 'launchApp' && step.type !== 'clearAppData')) return;
683
856
  if (launchingApp.value) return;
684
857
  if (!selectedDeviceId.value) {
@@ -763,7 +936,7 @@ async function swipePreview(gesture: {
763
936
  }
764
937
  }
765
938
 
766
- async function recordTapStep(index?: number) {
939
+ async function recordTapStep(index?: number, branchTarget?: BranchTarget) {
767
940
  if (!selectedNode.value || recordingTap.value) return;
768
941
  if (!ensureAppPackageSelected()) return;
769
942
  if (!selectedDeviceId.value) {
@@ -778,7 +951,7 @@ async function recordTapStep(index?: number) {
778
951
  }
779
952
  const beforeActivity = currentActivity.value;
780
953
  const step = createStep('tap', node);
781
- insertStep(step, index);
954
+ const inserted = insertStep(step, index, branchTarget);
782
955
  recordingTap.value = true;
783
956
  try {
784
957
  await tapAppiumDevice({ deviceId: selectedDeviceId.value, x: bounds.centerX, y: bounds.centerY });
@@ -805,12 +978,12 @@ async function recordTapStep(index?: number) {
805
978
  }
806
979
  }
807
980
 
808
- async function addStep(type: NodeStepType, index?: number) {
981
+ async function addStep(type: NodeStepType, index?: number, branchTarget?: BranchTarget) {
809
982
  if (recordingBusy.value) return;
810
983
  if (!selectedNode.value) return;
811
984
  if (!ensureAppPackageSelected()) return;
812
985
  if (type === 'tap') {
813
- return recordTapStep(index);
986
+ return recordTapStep(index, branchTarget);
814
987
  }
815
988
  const step = createStep(type, selectedNode.value);
816
989
  if (type === 'input') {
@@ -823,10 +996,10 @@ async function addStep(type: NodeStepType, index?: number) {
823
996
  if (!input) return;
824
997
  step.value = input.value;
825
998
  }
826
- return insertStep(step, index);
999
+ return insertStep(step, index, branchTarget);
827
1000
  }
828
1001
 
829
- async function addDelayStep(index?: number) {
1002
+ async function addDelayStep(index?: number, branchTarget?: BranchTarget) {
830
1003
  if (recordingBusy.value) return;
831
1004
  const input = await ElMessageBox.prompt('请输入延时时间,单位毫秒', '添加延时', {
832
1005
  inputValue: '1000',
@@ -838,7 +1011,7 @@ async function addDelayStep(index?: number) {
838
1011
  if (!input) return;
839
1012
  const timeoutMs = Number(input.value);
840
1013
  const step = createDelayStep(timeoutMs);
841
- return insertStep(step, index);
1014
+ return insertStep(step, index, branchTarget);
842
1015
  }
843
1016
 
844
1017
  function toSwipeNumber(value: unknown, fallback: number) {
@@ -884,7 +1057,7 @@ function renderSwipeForm(form: SwipeForm) {
884
1057
  ]);
885
1058
  }
886
1059
 
887
- async function addSwipeStep(index?: number) {
1060
+ async function addSwipeStep(index?: number, branchTarget?: BranchTarget) {
888
1061
  const preset = swipePreset('up');
889
1062
  const form = reactive<SwipeForm>({
890
1063
  direction: preset.direction,
@@ -909,7 +1082,7 @@ async function addSwipeStep(index?: number) {
909
1082
  endY: toSwipeNumber(form.endY, finalPreset.swipe.endY),
910
1083
  duration: Math.max(80, toSwipeNumber(form.duration, finalPreset.swipe.duration)),
911
1084
  },
912
- }), index);
1085
+ }), index, branchTarget);
913
1086
  }
914
1087
 
915
1088
  function openLinkedScriptDialog(index?: number, branchTarget?: BranchTarget) {
@@ -958,30 +1131,11 @@ function addLinkedScriptStep() {
958
1131
  );
959
1132
  return;
960
1133
  }
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
- }
1134
+ insertStep(
1135
+ createRunScriptStep(script),
1136
+ linkedScriptInsertIndex.value,
1137
+ linkedScriptBranchTarget.value || undefined,
1138
+ );
985
1139
  linkedScriptInsertIndex.value = undefined;
986
1140
  linkedScriptBranchTarget.value = null;
987
1141
  linkedScriptExpectedActivity.value = '';
@@ -1018,6 +1172,64 @@ function loadPreviewScriptForEditing() {
1018
1172
  loadScript(linkedScriptPreviewScript.value);
1019
1173
  }
1020
1174
 
1175
+ function openVisualChangeDialog(index?: number, branchTarget?: BranchTarget) {
1176
+ visualChangeInsertIndex.value = index;
1177
+ visualChangeBranchTarget.value = branchTarget || null;
1178
+ visualChangeConfig.value = currentVisualChangeConfig();
1179
+ visualChangePicking.value = false;
1180
+ visualChangeDialogVisible.value = true;
1181
+ }
1182
+
1183
+ function closeVisualChangeDialog() {
1184
+ visualChangeDialogVisible.value = false;
1185
+ visualChangePicking.value = false;
1186
+ visualChangeInsertIndex.value = undefined;
1187
+ visualChangeBranchTarget.value = null;
1188
+ }
1189
+
1190
+ function updateVisualChangeConfig(config: AppiumVisualChangeConfig) {
1191
+ visualChangeConfig.value = normalizeVisualChangeConfig(config);
1192
+ }
1193
+
1194
+ function pickVisualChangeRegion() {
1195
+ if (!selectedDeviceId.value) {
1196
+ ElMessage.warning('请选择设备');
1197
+ return;
1198
+ }
1199
+ visualChangeConfig.value = normalizeVisualChangeConfig({
1200
+ ...visualChangeConfig.value,
1201
+ mode: 'region',
1202
+ });
1203
+ visualChangePicking.value = true;
1204
+ ElMessage.info('请在设备预览中拖拽框选检测区域');
1205
+ }
1206
+
1207
+ function applyVisualChangeRegion(region: AppiumVisualChangeRegion) {
1208
+ visualChangeConfig.value = normalizeVisualChangeConfig({
1209
+ ...visualChangeConfig.value,
1210
+ mode: 'region',
1211
+ region,
1212
+ });
1213
+ visualChangePicking.value = false;
1214
+ ElMessage.success('已选择检测区域');
1215
+ }
1216
+
1217
+ function confirmVisualChangeStep() {
1218
+ const config = normalizeVisualChangeConfig(visualChangeConfig.value);
1219
+ if (config.mode === 'selectedElement' && !selectedNode.value?.bounds) {
1220
+ ElMessage.warning('当前没有可用的选中元素,请先选择组件或改为手动框选区域');
1221
+ return;
1222
+ }
1223
+ const step = createVisualChangeStartStep(config);
1224
+ insertStep(
1225
+ step,
1226
+ visualChangeInsertIndex.value,
1227
+ visualChangeBranchTarget.value || undefined,
1228
+ );
1229
+ closeVisualChangeDialog();
1230
+ ElMessage.success('已添加检测画面变化开始节点');
1231
+ }
1232
+
1021
1233
  function isReadonlyFlowActionDisabled(_action: InsertAction) {
1022
1234
  return true;
1023
1235
  }
@@ -1037,6 +1249,7 @@ async function addAction(
1037
1249
  ) {
1038
1250
  if (recordingBusy.value) return;
1039
1251
  if (!ensureAppPackageSelected()) return;
1252
+ const previousStep = typeof index === 'number' && index >= 0 ? steps.value[index] : undefined;
1040
1253
  if (action === 'launchApp' && steps.value.some((step) => step.type === 'launchApp')) {
1041
1254
  ElMessage.warning('启动 APP 节点只能添加一个');
1042
1255
  return;
@@ -1045,8 +1258,12 @@ async function addAction(
1045
1258
  ElMessage.warning('清理 App 缓存节点只能添加一个');
1046
1259
  return;
1047
1260
  }
1048
- if ((action === 'launchApp' || action === 'clearAppData') && index !== -1) {
1049
- ElMessage.warning(`${action === 'launchApp' ? '启动 APP' : '清理 App 缓存'}只能从开始节点添加`);
1261
+ if (action === 'clearAppData' && index !== -1) {
1262
+ ElMessage.warning('清理 App 缓存只能从开始节点添加');
1263
+ return;
1264
+ }
1265
+ if (action === 'launchApp' && index !== -1 && previousStep?.type !== 'clearAppData') {
1266
+ ElMessage.warning('启动 APP 只能从开始节点或清理 App 缓存后添加');
1050
1267
  return;
1051
1268
  }
1052
1269
  if (action === 'runScript') {
@@ -1054,22 +1271,60 @@ async function addAction(
1054
1271
  return;
1055
1272
  }
1056
1273
  if (action === 'delay') {
1057
- return addDelayStep(index);
1274
+ return addDelayStep(index, branchTarget);
1275
+ }
1276
+ if (action === 'aiRecognition') {
1277
+ const input = await ElMessageBox.prompt('识别内容', '添加 AI 识别', {
1278
+ inputType: 'textarea',
1279
+ inputPlaceholder: '例如:检查当前画面有没有显示黑屏',
1280
+ inputValidator: (value) => {
1281
+ try { validateAiRecognitionPrompt(value); return true; }
1282
+ catch (error) { return error instanceof Error ? error.message : '识别内容无效'; }
1283
+ },
1284
+ confirmButtonText: '添加', cancelButtonText: '取消',
1285
+ }).catch(() => null);
1286
+ if (!input) return;
1287
+ return insertStep({
1288
+ id: createStepId(), type: 'aiRecognition', label: 'AI 识别',
1289
+ value: validateAiRecognitionPrompt(input.value), timeoutMs: 60000,
1290
+ flow: { nodeKind: 'condition' },
1291
+ }, index, branchTarget);
1292
+ }
1293
+ if (action === 'textClick') {
1294
+ const step = reactive<AppiumRecordedStep>({
1295
+ id: createStepId(), type: 'textClick', label: '文字点击', value: '', timeoutMs: 10000,
1296
+ flow: { nodeKind: 'condition', textMatch: 'exact' },
1297
+ });
1298
+ const result = await ElMessageBox({
1299
+ title: '添加文字点击',
1300
+ message: h(ElForm, { labelPosition: 'top', style: { width: 'min(380px, calc(100vw - 64px))' } },
1301
+ () => h(TextClickSettings, { step, onUpdate: (patch) => Object.assign(step, patch) })),
1302
+ showCancelButton: true, confirmButtonText: '添加', cancelButtonText: '取消',
1303
+ beforeClose: (action, _instance, done) => {
1304
+ if (action === 'confirm') {
1305
+ try { textClickSelector(step); }
1306
+ catch (error) { ElMessage.warning((error as Error).message); return; }
1307
+ }
1308
+ done();
1309
+ },
1310
+ }).catch(() => null);
1311
+ if (result) return insertStep({ ...step }, index, branchTarget);
1312
+ return;
1058
1313
  }
1059
1314
  if (action === 'tap' || action === 'input' || action === 'assertExists' || action === 'waitFor') {
1060
- return addStep(action, index);
1315
+ return addStep(action, index, branchTarget);
1061
1316
  }
1062
1317
  if (action === 'keyBack') {
1063
- return insertStep(createKeyStep(4, '系统返回'), index);
1318
+ return insertStep(createKeyStep(4, '系统返回'), index, branchTarget);
1064
1319
  }
1065
1320
  if (action === 'keyHome') {
1066
- return insertStep(createKeyStep(3, 'Home 键'), index);
1321
+ return insertStep(createKeyStep(3, 'Home 键'), index, branchTarget);
1067
1322
  }
1068
1323
  if (action === 'keyRecent') {
1069
- return insertStep(createKeyStep(187, '最近任务'), index);
1324
+ return insertStep(createKeyStep(187, '最近任务'), index, branchTarget);
1070
1325
  }
1071
1326
  if (action === 'keyPower') {
1072
- return insertStep(createKeyStep(26, '电源键'), index);
1327
+ return insertStep(createKeyStep(26, '电源键'), index, branchTarget);
1073
1328
  }
1074
1329
  if (action === 'waitActivity') {
1075
1330
  const input = await ElMessageBox.prompt('请输入目标 Activity', '等待 Activity', {
@@ -1077,11 +1332,25 @@ async function addAction(
1077
1332
  confirmButtonText: '添加',
1078
1333
  cancelButtonText: '取消',
1079
1334
  }).catch(() => null);
1080
- if (input?.value) return insertStep(createWaitActivityStep(input.value), index);
1335
+ if (input?.value) return insertStep(createWaitActivityStep(input.value), index, branchTarget);
1081
1336
  return;
1082
1337
  }
1083
1338
  if (action === 'swipe') {
1084
- return addSwipeStep(index);
1339
+ return addSwipeStep(index, branchTarget);
1340
+ }
1341
+ if (action === 'visualChange' || action === 'visualChangeStart') {
1342
+ openVisualChangeDialog(index, branchTarget);
1343
+ return;
1344
+ }
1345
+ if (action === 'visualChangeEnd') {
1346
+ const startStep = findOpenVisualChangeStart(steps.value, index, branchTarget);
1347
+ if (!startStep) {
1348
+ ElMessage.warning('当前分支没有可对应的检测画面变化开始节点');
1349
+ return;
1350
+ }
1351
+ const inserted = insertStep(createVisualChangeEndStep(startStep), index, branchTarget);
1352
+ ElMessage.success(`已添加 ${inserted.label}`);
1353
+ return inserted;
1085
1354
  }
1086
1355
  if (action === 'pinch') {
1087
1356
  const input = await ElMessageBox.prompt('请输入缩放方向:放大 / 缩小', '添加双指缩放', {
@@ -1103,16 +1372,39 @@ async function addAction(
1103
1372
  centerY: Math.round((props.deviceHeight || 1920) / 2),
1104
1373
  percent: 0.5,
1105
1374
  },
1106
- }, index);
1375
+ }, index, branchTarget);
1376
+ }
1377
+ if (action === 'noop') {
1378
+ return insertStep(createNoopStep(), index, branchTarget);
1379
+ }
1380
+ if (action === 'log') {
1381
+ const step = reactive<AppiumRecordedStep>({
1382
+ id: createStepId(), type: 'log', label: '输出日志', logPrefix: DEFAULT_LOG_PREFIX, value: '',
1383
+ });
1384
+ const result = await ElMessageBox({
1385
+ title: '添加输出日志',
1386
+ message: h(ElForm, { labelPosition: 'top', style: { width: 'min(380px, calc(100vw - 64px))' } }, () => h(StageLogSettings, {
1387
+ step, onUpdate: (patch) => Object.assign(step, patch),
1388
+ })),
1389
+ showCancelButton: true, confirmButtonText: '添加', cancelButtonText: '取消',
1390
+ beforeClose: (action, _instance, done) => {
1391
+ const error = action === 'confirm' ? validateStageLog(step) : '';
1392
+ if (error) { ElMessage.warning(error); return; }
1393
+ done();
1394
+ },
1395
+ }).catch(() => null);
1396
+ if (result) return insertStep({ ...step }, index, branchTarget);
1397
+ return;
1107
1398
  }
1108
1399
  if (action === 'launchApp') {
1109
1400
  const clearStepIndex = steps.value.findIndex((step) => step.type === 'clearAppData');
1401
+ const insertIndex = previousStep?.type === 'clearAppData' ? index : clearStepIndex >= 0 ? clearStepIndex : index;
1110
1402
  return insertStep({
1111
1403
  id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
1112
1404
  type: 'launchApp',
1113
1405
  label: `启动 APP ${form.appPackage}`,
1114
1406
  value: form.appPackage,
1115
- }, clearStepIndex >= 0 ? clearStepIndex : index);
1407
+ }, insertIndex);
1116
1408
  }
1117
1409
  if (action === 'clearAppData') {
1118
1410
  return insertStep({
@@ -1124,14 +1416,35 @@ async function addAction(
1124
1416
  }
1125
1417
  const node = getSelectedNode();
1126
1418
  if (!node) return;
1419
+ if (action === 'checkboxState' || action === 'radioButtonState') {
1420
+ const controlName = nativeControlName(action);
1421
+ if (nativeControlType(node.className) !== action || node.checkable !== true) {
1422
+ ElMessage.warning(`请选择原生 ${controlName} 元素,当前选中:${node.className || '未知类型'}`);
1423
+ return;
1424
+ }
1425
+ const step = createNodeActionStep(action, `判断 ${controlName} 状态`, node, { timeoutMs: 2000 });
1426
+ if ((!step.selector?.value || step.selector.strategy === 'bounds') && node.xpath) {
1427
+ step.selector = { strategy: 'xpath', value: node.xpath };
1428
+ step.contextSelector = undefined;
1429
+ step.selectorChain = undefined;
1430
+ }
1431
+ if (!step.selector?.value || step.selector.strategy === 'bounds') {
1432
+ ElMessage.warning(`当前 ${controlName} 没有可用的元素定位器`);
1433
+ return;
1434
+ }
1435
+ // 只保存定位信息,回放时读取实时状态;坐标不能用于判断元素属性。
1436
+ step.fallback = undefined;
1437
+ step.flow = { nodeKind: 'condition' };
1438
+ return insertStep(step, index, branchTarget);
1439
+ }
1127
1440
  if (action === 'popupCondition') {
1128
1441
  const step = createNodeActionStep('assertExists', '判断存在', node, { timeoutMs: 2000 });
1129
- const inserted = insertStep({ ...step, flow: { nodeKind: 'condition' } }, index);
1442
+ const inserted = insertStep({ ...step, flow: { nodeKind: 'condition' } }, index, branchTarget);
1130
1443
  ElMessage.success('已添加判断节点,请在节点面板配置是/否分支');
1131
1444
  return inserted;
1132
1445
  }
1133
1446
  if (action === 'tapIfExists') {
1134
- return insertStep(createNodeActionStep('tapIfExists', '存在则点击', node, { timeoutMs: 2000 }), index);
1447
+ return insertStep(createNodeActionStep('tapIfExists', '存在则点击', node, { timeoutMs: 2000 }), index, branchTarget);
1135
1448
  }
1136
1449
  if (action === 'inputIfExists') {
1137
1450
  const input = await ElMessageBox.prompt('', '存在则输入', {
@@ -1141,16 +1454,20 @@ async function addAction(
1141
1454
  center: true,
1142
1455
  }).catch(() => null);
1143
1456
  if (!input) return;
1144
- return insertStep(createNodeActionStep('inputIfExists', '存在则输入', node, { timeoutMs: 2000, value: input.value }), index);
1457
+ return insertStep(
1458
+ createNodeActionStep('inputIfExists', '存在则输入', node, { timeoutMs: 2000, value: input.value }),
1459
+ index,
1460
+ branchTarget,
1461
+ );
1145
1462
  }
1146
1463
  if (action === 'clearIfExists') {
1147
- return insertStep(createNodeActionStep('clearIfExists', '存在则清空', node, { timeoutMs: 2000 }), index);
1464
+ return insertStep(createNodeActionStep('clearIfExists', '存在则清空', node, { timeoutMs: 2000 }), index, branchTarget);
1148
1465
  }
1149
1466
  if (action === 'backIfExists') {
1150
- return insertStep(createNodeActionStep('backIfExists', '存在则返回', node, { timeoutMs: 2000 }), index);
1467
+ return insertStep(createNodeActionStep('backIfExists', '存在则返回', node, { timeoutMs: 2000 }), index, branchTarget);
1151
1468
  }
1152
1469
  if (action === 'clearInput') {
1153
- return insertStep(createNodeActionStep('clearInput', '清空输入', node), index);
1470
+ return insertStep(createNodeActionStep('clearInput', '清空输入', node), index, branchTarget);
1154
1471
  }
1155
1472
  if (action === 'coordinateTap') {
1156
1473
  if (!node.bounds) {
@@ -1162,32 +1479,39 @@ async function addAction(
1162
1479
  type: 'coordinateTap',
1163
1480
  label: `点击坐标 ${node.bounds.centerX},${node.bounds.centerY}`,
1164
1481
  fallback: { strategy: 'bounds', centerX: node.bounds.centerX, centerY: node.bounds.centerY },
1165
- }, index);
1482
+ }, index, branchTarget);
1166
1483
  }
1167
1484
  if (action === 'longPress') {
1168
- if (!node.bounds) {
1169
- ElMessage.warning('当前组件没有可长按坐标');
1170
- return;
1485
+ const step = reactive<AppiumRecordedStep>({
1486
+ ...createNodeActionStep('longPress', '长按', node, { timeoutMs: 800 }),
1487
+ longPressMode: 'element',
1488
+ });
1489
+ // 无 id/text 的组件仍可用组件树中的绝对 XPath 定位,不依赖录制坐标。
1490
+ if ((!step.selector?.value || step.selector.strategy === 'bounds') && node.xpath) {
1491
+ step.selector = { strategy: 'xpath', value: node.xpath };
1492
+ step.contextSelector = undefined;
1493
+ step.selectorChain = undefined;
1171
1494
  }
1172
- const input = await ElMessageBox.prompt('请输入长按时间(毫秒)', '添加长按', {
1173
- inputValue: '800',
1174
- inputPattern: /^[1-9]\d*$/,
1175
- inputErrorMessage: '请输入大于 0 的整数',
1495
+ const result = await ElMessageBox({
1496
+ title: '添加长按',
1497
+ message: h(ElForm, { labelPosition: 'top', size: 'small' }, () => [
1498
+ h(ElFormItem, { label: '当前元素' }, () => h('div', { style: { overflowWrap: 'anywhere' } }, node.label)),
1499
+ h(LongPressSettings, { step, onUpdate: (patch) => Object.assign(step, patch) }),
1500
+ ]),
1501
+ showCancelButton: true,
1176
1502
  confirmButtonText: '添加',
1177
1503
  cancelButtonText: '取消',
1504
+ beforeClose: (action, _instance, done) => {
1505
+ const error = action === 'confirm' ? validateLongPress(step) : '';
1506
+ if (error) { ElMessage.warning(error); return; }
1507
+ done();
1508
+ },
1178
1509
  }).catch(() => null);
1179
- if (!input?.value) return;
1180
- const duration = Math.max(80, Math.round(Number(input.value) || 800));
1181
- return insertStep({
1182
- id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
1183
- type: 'longPress',
1184
- label: `长按 ${node.label}`,
1185
- fallback: { strategy: 'bounds', centerX: node.bounds.centerX, centerY: node.bounds.centerY },
1186
- timeoutMs: duration,
1187
- }, index);
1510
+ if (!result) return;
1511
+ return insertStep({ ...step }, index, branchTarget);
1188
1512
  }
1189
1513
  if (action === 'waitDisappear') {
1190
- return insertStep(createNodeActionStep('waitDisappear', '等待元素消失', node, { timeoutMs: 10000 }), index);
1514
+ return insertStep(createNodeActionStep('waitDisappear', '等待元素消失', node, { timeoutMs: 10000 }), index, branchTarget);
1191
1515
  }
1192
1516
  if (action === 'assertText') {
1193
1517
  const input = await ElMessageBox.prompt('请输入要断言的文本', '断言文本', {
@@ -1195,7 +1519,7 @@ async function addAction(
1195
1519
  confirmButtonText: '添加',
1196
1520
  cancelButtonText: '取消',
1197
1521
  }).catch(() => null);
1198
- if (input?.value) return insertStep(createNodeActionStep('assertText', '断言文本', node, { value: input.value }), index);
1522
+ if (input?.value) return insertStep(createNodeActionStep('assertText', '断言文本', node, { value: input.value }), index, branchTarget);
1199
1523
  }
1200
1524
  }
1201
1525
 
@@ -1215,28 +1539,18 @@ async function addBranchAction(index: number, branch: BranchName, action: Record
1215
1539
  const nextTargetId = insertAtBranchEntry
1216
1540
  ? nextBranchStepAfter(condition.id, branch, index)?.id || (!targetIsBranchStep ? currentTargetId : undefined)
1217
1541
  : undefined;
1218
- const inserted = await addAction(action, insertIndex, {
1542
+ const branchTarget: BranchTarget = {
1219
1543
  stepId: condition.id,
1220
1544
  branch,
1221
1545
  updateTarget: insertAtBranchEntry || !currentTargetId || !targetIsBranchStep,
1222
1546
  entryTargetId: insertAtBranchEntry ? undefined : existingBranchSteps[0]?.id,
1223
1547
  nextTargetId,
1224
- });
1548
+ };
1549
+ const inserted = await addAction(action, insertIndex, branchTarget);
1225
1550
  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
- }
1551
+ const currentInserted = steps.value.find((step) => step.id === inserted.id);
1552
+ if (currentInserted?.flow?.parentConditionId === condition.id && currentInserted.flow.parentBranch === branch) return;
1553
+ applyBranchTargetToInsertedStep(inserted, branchTarget);
1240
1554
  }
1241
1555
 
1242
1556
  async function saveOnActivityChange() {
@@ -1270,41 +1584,42 @@ async function saveOnActivityChange() {
1270
1584
 
1271
1585
  function removeStep(index: number) {
1272
1586
  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
- });
1587
+ steps.value = removeFlowStep(steps.value, index);
1588
+ }
1589
+
1590
+ function syncVisualChangePairConfig(
1591
+ candidate: AppiumRecordedStep,
1592
+ sourceConfig: NonNullable<AppiumRecordedStep['visualChange']>,
1593
+ ) {
1594
+ if (candidate.visualChange?.role !== 'end') return candidate;
1595
+ if (!sourceConfig.pairId || candidate.visualChange.pairId !== sourceConfig.pairId) return candidate;
1596
+ return {
1597
+ ...candidate,
1598
+ visualChange: {
1599
+ ...candidate.visualChange,
1600
+ mode: sourceConfig.mode,
1601
+ region: sourceConfig.region,
1602
+ pairLabel: sourceConfig.pairLabel,
1603
+ durationMs: sourceConfig.durationMs,
1604
+ intervalMs: sourceConfig.intervalMs,
1605
+ changeRatioThreshold: sourceConfig.changeRatioThreshold,
1606
+ pixelmatchThreshold: sourceConfig.pixelmatchThreshold,
1607
+ },
1608
+ };
1302
1609
  }
1303
1610
 
1304
1611
  function updateStep(index: number, step: AppiumRecordedStep) {
1305
1612
  if (recordingLocked.value) return;
1306
- const nextSteps = [...steps.value];
1307
- nextSteps[index] = step;
1613
+ const visualChange = step.visualChange?.role === 'start'
1614
+ ? normalizeVisualChangeConfig(step.visualChange)
1615
+ : undefined;
1616
+ const nextSteps = steps.value.map((candidate, stepIndex) => (
1617
+ stepIndex === index
1618
+ ? step
1619
+ : visualChange
1620
+ ? syncVisualChangePairConfig(candidate, visualChange)
1621
+ : candidate
1622
+ ));
1308
1623
  steps.value = nextSteps;
1309
1624
  }
1310
1625
 
@@ -1339,7 +1654,7 @@ function loadScript(script: AppiumRecordedScript) {
1339
1654
  if (script.deviceId && script.deviceId !== selectedDeviceId.value) {
1340
1655
  void switchDevice(script.deviceId);
1341
1656
  }
1342
- steps.value = (script.steps || []).map(normalizeLegacyFlowScope);
1657
+ steps.value = normalizeLegacyNestedConditionBranches((script.steps || []).map(normalizeLegacyFlowScope));
1343
1658
  markDraftSaved();
1344
1659
  }
1345
1660
 
@@ -1361,12 +1676,121 @@ function resetCurrentScript() {
1361
1676
  markDraftSaved();
1362
1677
  }
1363
1678
 
1679
+ function requestNewScript() {
1680
+ if (newScriptDisabled.value) return;
1681
+ if (selectedScriptId.value || form.name.trim() || steps.value.length) {
1682
+ newScriptDialogVisible.value = true;
1683
+ return;
1684
+ }
1685
+ startNewScript();
1686
+ }
1687
+
1688
+ function startNewScript() {
1689
+ if (newScriptDisabled.value) return;
1690
+ resetCurrentScript();
1691
+ // 新脚本不沿用旧画布的展开、批量选择和缩放状态。
1692
+ newScriptRevision.value += 1;
1693
+ newScriptDialogVisible.value = false;
1694
+ setActiveWorkbenchTab('recording');
1695
+ }
1696
+
1697
+ async function saveAndNewScript() {
1698
+ if (newScriptDisabled.value) return;
1699
+ // 保存成功后才重置,校验错误或网络异常均保留当前草稿。
1700
+ if (await saveScript()) startNewScript();
1701
+ }
1702
+
1364
1703
  function formatScriptTime(value: string) {
1365
1704
  if (!value) return '-';
1366
1705
  const date = new Date(value);
1367
1706
  return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
1368
1707
  }
1369
1708
 
1709
+ function cloneScriptSteps(stepList: AppiumRecordedStep[]) {
1710
+ return JSON.parse(JSON.stringify(stepList || [])) as AppiumRecordedStep[];
1711
+ }
1712
+
1713
+ function isScriptNameTaken(name: string, exceptId = '') {
1714
+ return scripts.value.some((script) => script.id !== exceptId && script.name === name);
1715
+ }
1716
+
1717
+ function createDuplicateScriptName(sourceName: string) {
1718
+ const baseName = `${sourceName} Copy`;
1719
+ if (!isScriptNameTaken(baseName)) return baseName;
1720
+
1721
+ let suffix = 2;
1722
+ let nextName = `${baseName} ${suffix}`;
1723
+ while (isScriptNameTaken(nextName)) {
1724
+ suffix += 1;
1725
+ nextName = `${baseName} ${suffix}`;
1726
+ }
1727
+ return nextName;
1728
+ }
1729
+
1730
+ async function duplicateScript(script: AppiumRecordedScript) {
1731
+ if (duplicatingScriptId.value) return;
1732
+ duplicatingScriptId.value = script.id;
1733
+ try {
1734
+ const payload = await saveAppiumScript({
1735
+ name: createDuplicateScriptName(script.name),
1736
+ appPackage: script.appPackage,
1737
+ appActivity: script.appActivity,
1738
+ deviceId: script.deviceId,
1739
+ steps: cloneScriptSteps(script.steps),
1740
+ });
1741
+ await loadScripts();
1742
+ ElMessage.success(`已复制为「${payload.script.name}」`);
1743
+ } catch (error) {
1744
+ ElMessage.error(error instanceof Error ? error.message : '复制脚本失败');
1745
+ } finally {
1746
+ duplicatingScriptId.value = '';
1747
+ }
1748
+ }
1749
+
1750
+ async function renameScript(script: AppiumRecordedScript) {
1751
+ if (renamingScriptId.value) return;
1752
+ const input = await ElMessageBox.prompt('请输入新的脚本名称', '修改脚本名称', {
1753
+ inputValue: script.name,
1754
+ confirmButtonText: '保存',
1755
+ cancelButtonText: '取消',
1756
+ center: true,
1757
+ inputValidator(value) {
1758
+ const nextName = String(value || '').trim();
1759
+ if (!nextName) return '脚本名称不能为空';
1760
+ if (isScriptNameTaken(nextName, script.id)) return '脚本名称已存在';
1761
+ return true;
1762
+ },
1763
+ }).catch(() => null);
1764
+ const nextName = input ? String(input.value || '').trim() : '';
1765
+ if (!nextName || nextName === script.name) return;
1766
+
1767
+ const wasCurrentScript = selectedScriptId.value === script.id;
1768
+ const hadUnsavedChanges = hasUnsavedChanges.value;
1769
+ renamingScriptId.value = script.id;
1770
+ try {
1771
+ const payload = await saveAppiumScript({
1772
+ id: script.id,
1773
+ name: nextName,
1774
+ appPackage: script.appPackage,
1775
+ appActivity: script.appActivity,
1776
+ deviceId: script.deviceId,
1777
+ steps: cloneScriptSteps(script.steps),
1778
+ });
1779
+ await loadScripts();
1780
+ if (wasCurrentScript) {
1781
+ form.name = payload.script.name;
1782
+ if (!hadUnsavedChanges) {
1783
+ markDraftSaved();
1784
+ }
1785
+ }
1786
+ ElMessage.success('脚本名称已修改');
1787
+ } catch (error) {
1788
+ ElMessage.error(error instanceof Error ? error.message : '修改脚本名称失败');
1789
+ } finally {
1790
+ renamingScriptId.value = '';
1791
+ }
1792
+ }
1793
+
1370
1794
  async function removeScript(script: AppiumRecordedScript) {
1371
1795
  try {
1372
1796
  await ElMessageBox.confirm(`确定删除脚本「${script.name}」吗?`, '删除脚本', {
@@ -1395,13 +1819,24 @@ async function removeScript(script: AppiumRecordedScript) {
1395
1819
  }
1396
1820
  }
1397
1821
 
1398
- function downloadScript(script: AppiumRecordedScript) {
1399
- const link = document.createElement('a');
1400
- link.href = appiumScriptDownloadUrl(script.id);
1401
- link.download = `${script.name}.json`;
1402
- document.body.appendChild(link);
1403
- link.click();
1404
- link.remove();
1822
+ async function downloadScript(script: AppiumRecordedScript) {
1823
+ if (downloadingScriptId.value) return;
1824
+ downloadingScriptId.value = script.id;
1825
+ try {
1826
+ const { blob, fileName } = await downloadAppiumScript(script.id);
1827
+ const url = URL.createObjectURL(blob);
1828
+ const link = document.createElement('a');
1829
+ link.href = url;
1830
+ link.download = fileName;
1831
+ document.body.appendChild(link);
1832
+ link.click();
1833
+ link.remove();
1834
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000);
1835
+ } catch (error) {
1836
+ ElMessage.error(error instanceof Error ? error.message : '导出脚本失败');
1837
+ } finally {
1838
+ downloadingScriptId.value = '';
1839
+ }
1405
1840
  }
1406
1841
 
1407
1842
  function chooseScriptImportFile() {
@@ -1428,7 +1863,7 @@ async function importScriptFile(event: Event) {
1428
1863
  }
1429
1864
 
1430
1865
  async function saveScript() {
1431
- if (!ensureAppPackageSelected()) return;
1866
+ if (!ensureAppPackageSelected()) return false;
1432
1867
  saving.value = true;
1433
1868
  try {
1434
1869
  const payload = await saveAppiumScript({
@@ -1443,8 +1878,10 @@ async function saveScript() {
1443
1878
  selectedScriptId.value = payload.script.id;
1444
1879
  markDraftSaved();
1445
1880
  ElMessage.success('Appium 脚本已保存');
1881
+ return true;
1446
1882
  } catch (error) {
1447
1883
  ElMessage.error(error instanceof Error ? error.message : '保存失败');
1884
+ return false;
1448
1885
  } finally {
1449
1886
  saving.value = false;
1450
1887
  }
@@ -1455,6 +1892,10 @@ async function replayScript() {
1455
1892
  ElMessage.warning('请选择已保存脚本');
1456
1893
  return;
1457
1894
  }
1895
+ if (hasUnsavedChanges.value) {
1896
+ const saved = await saveScript();
1897
+ if (!saved || !selectedScript.value) return;
1898
+ }
1458
1899
  replaying.value = true;
1459
1900
  stoppingReplay.value = false;
1460
1901
  activeReplayDeviceId.value = selectedDeviceId.value;
@@ -1470,6 +1911,8 @@ async function replayScript() {
1470
1911
  replayOutput.value = result.output || '';
1471
1912
  if (result.stopped) {
1472
1913
  ElMessage.info('回放已终止,报告、日志和截图回放已生成');
1914
+ } else if (result.softFailureCount) {
1915
+ ElMessage.warning(`回放完成,但存在 ${result.softFailureCount} 个视觉变化未达预期节点`);
1473
1916
  } else if (result.success) {
1474
1917
  ElMessage.success('回放完成');
1475
1918
  } else {
@@ -1549,6 +1992,7 @@ watch(
1549
1992
  >
1550
1993
  <el-option v-for="script in scripts" :key="script.id" :label="script.name" :value="script.id" />
1551
1994
  </el-select>
1995
+ <el-button :icon="Plus" :disabled="newScriptDisabled || newScriptDialogVisible" @click="requestNewScript">新建</el-button>
1552
1996
  <el-button :icon="Check" :loading="saving" @click="saveScript">保存</el-button>
1553
1997
  <el-button
1554
1998
  type="primary"
@@ -1581,12 +2025,16 @@ watch(
1581
2025
  :actions="deviceActions"
1582
2026
  :overlay-bounds="overlayBounds"
1583
2027
  :selected-bounds="selectedBounds"
2028
+ :selected-region="visualChangeSelectedRegion"
2029
+ :region-selection="visualChangeRegionSelectionEnabled"
2030
+ :region-draw-mode="visualChangePicking"
1584
2031
  :device-width="deviceWidth"
1585
2032
  :device-height="deviceHeight"
1586
2033
  @switch-device="switchDevice"
1587
2034
  @trigger-key="triggerDeviceKey"
1588
2035
  @refresh-preview="refreshDevicePreview"
1589
2036
  @tap="selectNodeFromPoint"
2037
+ @region-select="applyVisualChangeRegion"
1590
2038
  @swipe="swipePreview"
1591
2039
  />
1592
2040
 
@@ -1664,6 +2112,8 @@ watch(
1664
2112
  <section class="appium-workbench__section">
1665
2113
  <h3>录制步骤</h3>
1666
2114
  <RecordedSteps
2115
+ :key="newScriptRevision"
2116
+ :ai-recognition-model-configured="aiRecognitionModelConfigured"
1667
2117
  :steps="steps"
1668
2118
  :clipboard-count="flowClipboardCount"
1669
2119
  :disabled="recordingLocked"
@@ -1715,18 +2165,35 @@ watch(
1715
2165
  </div>
1716
2166
  <div class="appium-script-list__actions">
1717
2167
  <el-button size="small" @click.stop="loadScript(script)">加载</el-button>
2168
+ <el-button
2169
+ size="small"
2170
+ :icon="Edit"
2171
+ :loading="renamingScriptId === script.id"
2172
+ title="修改脚本名称"
2173
+ @click.stop="renameScript(script)"
2174
+ />
2175
+ <el-button
2176
+ size="small"
2177
+ :icon="CopyDocument"
2178
+ :loading="duplicatingScriptId === script.id"
2179
+ title="复制脚本"
2180
+ @click.stop="duplicateScript(script)"
2181
+ />
1718
2182
  <el-button
1719
2183
  size="small"
1720
2184
  :icon="Download"
2185
+ :loading="downloadingScriptId === script.id"
2186
+ :disabled="Boolean(downloadingScriptId) && downloadingScriptId !== script.id"
1721
2187
  title="下载脚本"
1722
- @click="downloadScript(script)"
2188
+ @click.stop="downloadScript(script)"
1723
2189
  />
1724
2190
  <el-button
1725
2191
  size="small"
1726
2192
  type="danger"
1727
2193
  :icon="Delete"
1728
2194
  :loading="deletingScriptId === script.id"
1729
- @click="removeScript(script)"
2195
+ title="删除脚本"
2196
+ @click.stop="removeScript(script)"
1730
2197
  />
1731
2198
  </div>
1732
2199
  </article>
@@ -1737,6 +2204,34 @@ watch(
1737
2204
  </el-card>
1738
2205
  </div>
1739
2206
 
2207
+ <NewScriptDialog
2208
+ v-if="newScriptDialogVisible"
2209
+ :script-name="form.name"
2210
+ :saving="saving"
2211
+ @save="saveAndNewScript"
2212
+ @discard="startNewScript"
2213
+ @cancel="newScriptDialogVisible = false"
2214
+ />
2215
+
2216
+ <AiRecognitionTestDialog
2217
+ v-if="aiRecognitionTestStep"
2218
+ :step="aiRecognitionTestStep"
2219
+ :device-id="selectedDeviceId"
2220
+ :model-configured="Boolean(aiRecognitionModelConfigured)"
2221
+ @close="aiRecognitionTestStep = null"
2222
+ />
2223
+
2224
+ <VisualChangeDialog
2225
+ :model-value="visualChangeDialogVisible"
2226
+ :config="visualChangeConfig"
2227
+ :has-selected-element="Boolean(selectedNode?.bounds)"
2228
+ :picking-region="visualChangePicking"
2229
+ @update:model-value="$event ? (visualChangeDialogVisible = true) : closeVisualChangeDialog()"
2230
+ @update:config="updateVisualChangeConfig"
2231
+ @pick-region="pickVisualChangeRegion"
2232
+ @confirm="confirmVisualChangeStep"
2233
+ />
2234
+
1740
2235
  <el-dialog
1741
2236
  :model-value="Boolean(pendingNavigation)"
1742
2237
  title="检测到 Activity 跳转"
@@ -1827,6 +2322,7 @@ watch(
1827
2322
  </div>
1828
2323
  <FlowCanvas
1829
2324
  id="appium-linked-script-preview"
2325
+ :ai-recognition-model-configured="aiRecognitionModelConfigured"
1830
2326
  class="appium-linked-script-preview-canvas"
1831
2327
  readonly
1832
2328
  disabled