android-midscene-automation 0.1.24 → 0.1.26

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,6 +1,6 @@
1
1
  <script setup lang="ts">
2
- import { computed, onMounted, onUnmounted, reactive, shallowRef, watch } from 'vue';
3
- import { ElMessage, ElMessageBox } from 'element-plus';
2
+ import { computed, h, onMounted, onUnmounted, reactive, shallowRef, watch } from 'vue';
3
+ import { ElForm, ElFormItem, ElInputNumber, ElMessage, ElMessageBox, ElOption, ElSelect } from 'element-plus';
4
4
  import { Check, CircleClose, CopyDocument, Delete, Document, Download, 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';
@@ -34,6 +34,8 @@ import type { AppiumNode, AppiumRecordedScript, AppiumRecordedStep } from './typ
34
34
  type NodeStepType = 'tap' | 'input' | 'assertExists' | 'waitFor';
35
35
  type BranchName = 'yes' | 'no';
36
36
  type LegacyFlow = NonNullable<AppiumRecordedStep['flow']> & { scope?: string };
37
+ type SwipeGesture = NonNullable<AppiumRecordedStep['swipe']>;
38
+ type SwipeForm = SwipeGesture & { direction: string };
37
39
  type BranchTarget = {
38
40
  stepId: string;
39
41
  branch: BranchName;
@@ -112,7 +114,7 @@ const props = defineProps<{
112
114
  deviceHeight: number;
113
115
  switchAndroidDevice: (deviceId: string) => Promise<void>;
114
116
  triggerDeviceKey: (keyCode: number) => Promise<void>;
115
- refreshDevicePreview: () => void;
117
+ refreshDevicePreview: () => void | Promise<void>;
116
118
  swipeDevice: (
117
119
  startX: number,
118
120
  startY: number,
@@ -332,11 +334,24 @@ function attachStepToBranch(stepId: string, conditionId: string, branch: BranchN
332
334
  ));
333
335
  }
334
336
 
335
- function branchInsertIndex(conditionId: string, branch: BranchName, fallbackIndex: number) {
336
- const ownedIndexes = steps.value.flatMap((step, index) => (
337
- step.flow?.parentConditionId === conditionId && step.flow?.parentBranch === branch ? [index] : []
337
+ function branchContextForInsert(index: number, branch: BranchName) {
338
+ const anchor = steps.value[index];
339
+ if (!anchor) return undefined;
340
+ if (anchor.flow?.parentConditionId && anchor.flow.parentBranch === branch) {
341
+ const conditionIndex = steps.value.findIndex((step) => step.id === anchor.flow?.parentConditionId);
342
+ const condition = conditionIndex >= 0 ? steps.value[conditionIndex] : undefined;
343
+ return condition ? { condition, conditionIndex } : undefined;
344
+ }
345
+ if (anchor.flow?.nodeKind === 'condition') {
346
+ return { condition: anchor, conditionIndex: index };
347
+ }
348
+ return undefined;
349
+ }
350
+
351
+ function nextBranchStepAfter(conditionId: string, branch: BranchName, index: number) {
352
+ return steps.value.slice(index + 1).find((step) => (
353
+ step.flow?.parentConditionId === conditionId && step.flow.parentBranch === branch
338
354
  ));
339
- return ownedIndexes.length ? ownedIndexes[ownedIndexes.length - 1] : fallbackIndex;
340
355
  }
341
356
 
342
357
  function createStep(type: NodeStepType, node: AppiumNode): AppiumRecordedStep {
@@ -423,7 +438,7 @@ function createKeyStep(keyCode: number, label = '按返回键'): AppiumRecordedS
423
438
  };
424
439
  }
425
440
 
426
- function createSwipeStep(direction: string): AppiumRecordedStep {
441
+ function swipePreset(direction: string): { direction: string; label: string; swipe: SwipeGesture } {
427
442
  const normalizedDirection = ({ 上: 'up', 下: 'down', 左: 'left', 右: 'right' } as Record<string, string>)[direction] || direction;
428
443
  const width = props.deviceWidth || 1080;
429
444
  const height = props.deviceHeight || 1920;
@@ -431,7 +446,7 @@ function createSwipeStep(direction: string): AppiumRecordedStep {
431
446
  const centerY = Math.round(height * 0.5);
432
447
  const distanceX = Math.round(width * 0.35);
433
448
  const distanceY = Math.round(height * 0.35);
434
- const swipeMap: Record<string, AppiumRecordedStep['swipe']> = {
449
+ const swipeMap: Record<string, SwipeGesture> = {
435
450
  up: { startX: centerX, startY: centerY + distanceY, endX: centerX, endY: centerY - distanceY, duration: 500 },
436
451
  down: { startX: centerX, startY: centerY - distanceY, endX: centerX, endY: centerY + distanceY, duration: 500 },
437
452
  left: { startX: centerX + distanceX, startY: centerY, endX: centerX - distanceX, endY: centerY, duration: 500 },
@@ -439,13 +454,21 @@ function createSwipeStep(direction: string): AppiumRecordedStep {
439
454
  };
440
455
  const labelMap: Record<string, string> = { up: '上滑', down: '下滑', left: '左滑', right: '右滑' };
441
456
  return {
442
- id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
443
- type: 'swipe',
457
+ direction: normalizedDirection,
444
458
  label: labelMap[normalizedDirection] || '滑动',
445
459
  swipe: swipeMap[normalizedDirection] || swipeMap.up,
446
460
  };
447
461
  }
448
462
 
463
+ function createSwipeStep(input: { label: string; swipe: SwipeGesture }): AppiumRecordedStep {
464
+ return {
465
+ id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
466
+ type: 'swipe',
467
+ label: input.label,
468
+ swipe: input.swipe,
469
+ };
470
+ }
471
+
449
472
  function createWaitActivityStep(activity: string): AppiumRecordedStep {
450
473
  return {
451
474
  id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
@@ -818,6 +841,77 @@ async function addDelayStep(index?: number) {
818
841
  return insertStep(step, index);
819
842
  }
820
843
 
844
+ function toSwipeNumber(value: unknown, fallback: number) {
845
+ const number = Number(value);
846
+ return Number.isFinite(number) ? Math.round(number) : fallback;
847
+ }
848
+
849
+ function patchSwipeForm(form: SwipeForm, key: keyof SwipeGesture, value: unknown) {
850
+ form[key] = toSwipeNumber(value, form[key]);
851
+ }
852
+
853
+ function renderSwipeForm(form: SwipeForm) {
854
+ const coordinateInput = (label: string, key: keyof SwipeGesture, min = 0) => h(ElFormItem, { label }, () => h(ElInputNumber, {
855
+ modelValue: form[key],
856
+ min,
857
+ max: 99999,
858
+ precision: 0,
859
+ controlsPosition: 'right',
860
+ 'onUpdate:modelValue': (value: number | undefined) => patchSwipeForm(form, key, value),
861
+ }));
862
+
863
+ return h(ElForm, { labelPosition: 'top', size: 'small', class: 'appium-swipe-dialog-form' }, () => [
864
+ h(ElFormItem, { label: '滑动方向' }, () => h(ElSelect, {
865
+ modelValue: form.direction,
866
+ 'onUpdate:modelValue': (value: string) => {
867
+ const preset = swipePreset(value);
868
+ form.direction = preset.direction;
869
+ Object.assign(form, preset.swipe);
870
+ },
871
+ }, () => [
872
+ h(ElOption, { label: '上滑', value: 'up' }),
873
+ h(ElOption, { label: '下滑', value: 'down' }),
874
+ h(ElOption, { label: '左滑', value: 'left' }),
875
+ h(ElOption, { label: '右滑', value: 'right' }),
876
+ ])),
877
+ h('div', { class: 'appium-swipe-dialog-grid' }, () => [
878
+ coordinateInput('起点 X', 'startX'),
879
+ coordinateInput('起点 Y', 'startY'),
880
+ coordinateInput('终点 X', 'endX'),
881
+ coordinateInput('终点 Y', 'endY'),
882
+ coordinateInput('时长 ms', 'duration', 80),
883
+ ]),
884
+ ]);
885
+ }
886
+
887
+ async function addSwipeStep(index?: number) {
888
+ const preset = swipePreset('up');
889
+ const form = reactive<SwipeForm>({
890
+ direction: preset.direction,
891
+ ...preset.swipe,
892
+ });
893
+ const result = await ElMessageBox({
894
+ title: '添加滑动',
895
+ message: renderSwipeForm(form),
896
+ showCancelButton: true,
897
+ confirmButtonText: '添加',
898
+ cancelButtonText: '取消',
899
+ customClass: 'appium-swipe-message-box',
900
+ }).catch(() => null);
901
+ if (!result) return;
902
+ const finalPreset = swipePreset(form.direction);
903
+ return insertStep(createSwipeStep({
904
+ label: finalPreset.label,
905
+ swipe: {
906
+ startX: toSwipeNumber(form.startX, finalPreset.swipe.startX),
907
+ startY: toSwipeNumber(form.startY, finalPreset.swipe.startY),
908
+ endX: toSwipeNumber(form.endX, finalPreset.swipe.endX),
909
+ endY: toSwipeNumber(form.endY, finalPreset.swipe.endY),
910
+ duration: Math.max(80, toSwipeNumber(form.duration, finalPreset.swipe.duration)),
911
+ },
912
+ }), index);
913
+ }
914
+
821
915
  function openLinkedScriptDialog(index?: number, branchTarget?: BranchTarget) {
822
916
  if (!linkableScripts.value.length) {
823
917
  ElMessage.warning('暂无可连接的已保存脚本');
@@ -987,15 +1081,7 @@ async function addAction(
987
1081
  return;
988
1082
  }
989
1083
  if (action === 'swipe') {
990
- const input = await ElMessageBox.prompt('请输入滑动方向:上 / 下 / 左 / 右', '添加滑动', {
991
- inputValue: '上',
992
- inputPattern: /^(上|下|左|右|up|down|left|right)$/,
993
- inputErrorMessage: '只能输入 上、下、左、右',
994
- confirmButtonText: '添加',
995
- cancelButtonText: '取消',
996
- }).catch(() => null);
997
- if (input?.value) return insertStep(createSwipeStep(input.value), index);
998
- return;
1084
+ return addSwipeStep(index);
999
1085
  }
1000
1086
  if (action === 'pinch') {
1001
1087
  const input = await ElMessageBox.prompt('请输入缩放方向:放大 / 缩小', '添加双指缩放', {
@@ -1083,12 +1169,21 @@ async function addAction(
1083
1169
  ElMessage.warning('当前组件没有可长按坐标');
1084
1170
  return;
1085
1171
  }
1172
+ const input = await ElMessageBox.prompt('请输入长按时间(毫秒)', '添加长按', {
1173
+ inputValue: '800',
1174
+ inputPattern: /^[1-9]\d*$/,
1175
+ inputErrorMessage: '请输入大于 0 的整数',
1176
+ confirmButtonText: '添加',
1177
+ cancelButtonText: '取消',
1178
+ }).catch(() => null);
1179
+ if (!input?.value) return;
1180
+ const duration = Math.max(80, Math.round(Number(input.value) || 800));
1086
1181
  return insertStep({
1087
1182
  id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
1088
1183
  type: 'longPress',
1089
1184
  label: `长按 ${node.label}`,
1090
1185
  fallback: { strategy: 'bounds', centerX: node.bounds.centerX, centerY: node.bounds.centerY },
1091
- timeoutMs: 800,
1186
+ timeoutMs: duration,
1092
1187
  }, index);
1093
1188
  }
1094
1189
  if (action === 'waitDisappear') {
@@ -1105,36 +1200,42 @@ async function addAction(
1105
1200
  }
1106
1201
 
1107
1202
  async function addBranchAction(index: number, branch: BranchName, action: RecorderAction) {
1108
- const source = steps.value[index];
1109
- if (!source) return;
1203
+ const context = branchContextForInsert(index, branch);
1204
+ if (!context) return;
1205
+ const { condition, conditionIndex } = context;
1110
1206
  const existingBranchSteps = steps.value.filter((step) => (
1111
- step.flow?.parentConditionId === source.id
1112
- && step.flow.parentBranch === branch
1207
+ step.flow?.parentConditionId === condition.id && step.flow.parentBranch === branch
1113
1208
  ));
1114
- const currentTargetId = branch === 'yes' ? source.flow?.yesTargetId : source.flow?.noTargetId;
1209
+ const currentTargetId = branch === 'yes' ? condition.flow?.yesTargetId : condition.flow?.noTargetId;
1115
1210
  const currentTarget = steps.value.find((step) => step.id === currentTargetId);
1116
- const targetIsBranchStep = currentTarget?.flow?.parentConditionId === source.id
1211
+ const targetIsBranchStep = currentTarget?.flow?.parentConditionId === condition.id
1117
1212
  && currentTarget.flow.parentBranch === branch;
1118
- const insertIndex = branchInsertIndex(source.id, branch, index);
1213
+ const insertAtBranchEntry = index === conditionIndex;
1214
+ const insertIndex = insertAtBranchEntry ? conditionIndex : index;
1215
+ const nextTargetId = insertAtBranchEntry
1216
+ ? nextBranchStepAfter(condition.id, branch, index)?.id || (!targetIsBranchStep ? currentTargetId : undefined)
1217
+ : undefined;
1119
1218
  const inserted = await addAction(action, insertIndex, {
1120
- stepId: source.id,
1219
+ stepId: condition.id,
1121
1220
  branch,
1122
- updateTarget: !currentTargetId || !targetIsBranchStep,
1123
- entryTargetId: existingBranchSteps[0]?.id,
1124
- nextTargetId: !targetIsBranchStep ? currentTargetId : undefined,
1221
+ updateTarget: insertAtBranchEntry || !currentTargetId || !targetIsBranchStep,
1222
+ entryTargetId: insertAtBranchEntry ? undefined : existingBranchSteps[0]?.id,
1223
+ nextTargetId,
1125
1224
  });
1126
1225
  if (!inserted) return;
1127
- attachStepToBranch(inserted.id, source.id, branch);
1128
- if (!currentTargetId || !targetIsBranchStep) {
1226
+ attachStepToBranch(inserted.id, condition.id, branch);
1227
+ if (nextTargetId) {
1129
1228
  steps.value = steps.value.map((step) => (
1130
- step.id === inserted.id && currentTargetId
1229
+ step.id === inserted.id
1131
1230
  ? {
1132
1231
  ...step,
1133
- flow: { ...(step.flow || {}), successTargetId: currentTargetId },
1232
+ flow: { ...(step.flow || {}), successTargetId: nextTargetId },
1134
1233
  }
1135
1234
  : step
1136
1235
  ));
1137
- updateBranchTarget(source.id, branch, existingBranchSteps[0]?.id || inserted.id);
1236
+ }
1237
+ if (insertAtBranchEntry || !currentTargetId || !targetIsBranchStep) {
1238
+ updateBranchTarget(condition.id, branch, insertAtBranchEntry ? inserted.id : existingBranchSteps[0]?.id || inserted.id);
1138
1239
  }
1139
1240
  }
1140
1241
 
@@ -1470,24 +1571,6 @@ watch(
1470
1571
  </div>
1471
1572
 
1472
1573
  <div class="appium-recorder-layout">
1473
- <el-card shadow="never" class="appium-recorder-card">
1474
- <template #header>
1475
- <div class="panel-header">
1476
- <span>App 组件树</span>
1477
- <el-button :icon="Refresh" :loading="loadingTree" :disabled="replaying" @click="refreshTree">
1478
- 刷新组件树
1479
- </el-button>
1480
- </div>
1481
- </template>
1482
- <div class="appium-tree-panel">
1483
- <ComponentTree :tree="tree" :selected-id="selectedNode?.id || ''" @select="selectedNode = $event" />
1484
- <div class="appium-current-activity">
1485
- <span>当前 Activity</span>
1486
- <code>{{ currentActivity || '-' }}</code>
1487
- </div>
1488
- </div>
1489
- </el-card>
1490
-
1491
1574
  <DevicePreviewPanel
1492
1575
  :available="playgroundAvailable"
1493
1576
  :devices="androidDevices"
@@ -1507,6 +1590,24 @@ watch(
1507
1590
  @swipe="swipePreview"
1508
1591
  />
1509
1592
 
1593
+ <el-card shadow="never" class="appium-recorder-card">
1594
+ <template #header>
1595
+ <div class="panel-header">
1596
+ <span>App 组件树</span>
1597
+ <el-button :icon="Refresh" :loading="loadingTree" :disabled="replaying" @click="refreshTree">
1598
+ 刷新组件树
1599
+ </el-button>
1600
+ </div>
1601
+ </template>
1602
+ <div class="appium-tree-panel">
1603
+ <ComponentTree :tree="tree" :selected-id="selectedNode?.id || ''" @select="selectedNode = $event" />
1604
+ <div class="appium-current-activity">
1605
+ <span>当前 Activity</span>
1606
+ <code>{{ currentActivity || '-' }}</code>
1607
+ </div>
1608
+ </div>
1609
+ </el-card>
1610
+
1510
1611
  <el-card shadow="never" class="appium-recorder-card appium-recorder-card--workbench">
1511
1612
  <template #header>录制与脚本</template>
1512
1613
  <el-tabs
@@ -144,11 +144,11 @@ function handleInsert(payload: {
144
144
  conditionIndex?: number;
145
145
  }) {
146
146
  if (payload.action === PASTE_COMMAND) {
147
- emit('paste', payload.conditionIndex ?? payload.afterIndex, payload.branch);
147
+ emit('paste', payload.afterIndex, payload.branch);
148
148
  return;
149
149
  }
150
150
  if (payload.branch && payload.conditionIndex !== undefined) {
151
- emit('insertBranchAction', payload.conditionIndex, payload.branch, payload.action);
151
+ emit('insertBranchAction', payload.afterIndex, payload.branch, payload.action);
152
152
  return;
153
153
  }
154
154
  emit('insertAction', payload.afterIndex, payload.action);
@@ -2,6 +2,7 @@
2
2
  import type { AppiumRecordedStep, AppiumSelector } from '../types';
3
3
 
4
4
  type FlowKind = 'action' | 'condition' | 'assertion';
5
+ type SwipeGesture = NonNullable<AppiumRecordedStep['swipe']>;
5
6
 
6
7
  const props = defineProps<{
7
8
  step: AppiumRecordedStep;
@@ -34,6 +35,38 @@ function patchFlow(patch: NonNullable<AppiumRecordedStep['flow']>) {
34
35
  },
35
36
  });
36
37
  }
38
+
39
+ function toInteger(value: unknown, fallback = 0) {
40
+ const number = Number(value);
41
+ return Number.isFinite(number) ? Math.round(number) : fallback;
42
+ }
43
+
44
+ function patchSwipe(patch: Partial<SwipeGesture>) {
45
+ patchStep({
46
+ swipe: {
47
+ startX: 0,
48
+ startY: 0,
49
+ endX: 0,
50
+ endY: 0,
51
+ duration: 500,
52
+ ...(props.step.swipe || {}),
53
+ ...patch,
54
+ },
55
+ });
56
+ }
57
+
58
+ function timeoutLabel() {
59
+ return props.step.type === 'longPress' ? '长按时间 ms' : '超时时间 ms';
60
+ }
61
+
62
+ function timeoutMin() {
63
+ return props.step.type === 'longPress' ? 80 : 0;
64
+ }
65
+
66
+ function patchTimeout(value: unknown) {
67
+ const timeout = Math.max(timeoutMin(), toInteger(value, props.step.timeoutMs || 0));
68
+ patchStep({ timeoutMs: timeout || undefined });
69
+ }
37
70
  </script>
38
71
 
39
72
  <template>
@@ -67,14 +100,14 @@ function patchFlow(patch: NonNullable<AppiumRecordedStep['flow']>) {
67
100
  <el-option label="校验" value="assertion" />
68
101
  </el-select>
69
102
  </el-form-item>
70
- <el-form-item label="超时时间 ms">
103
+ <el-form-item :label="timeoutLabel()">
71
104
  <el-input-number
72
105
  :model-value="step.timeoutMs || undefined"
73
106
  :disabled="disabled"
74
- :min="0"
107
+ :min="timeoutMin()"
75
108
  :max="999999"
76
109
  controls-position="right"
77
- @update:model-value="patchStep({ timeoutMs: Number($event || 0) || undefined })"
110
+ @update:model-value="patchTimeout($event)"
78
111
  />
79
112
  </el-form-item>
80
113
  </div>
@@ -88,6 +121,63 @@ function patchFlow(patch: NonNullable<AppiumRecordedStep['flow']>) {
88
121
  @update:model-value="patchStep({ value: String($event) })"
89
122
  />
90
123
  </el-form-item>
124
+ <div v-if="step.type === 'swipe'" class="appium-flow-editor__grid appium-flow-editor__grid--swipe">
125
+ <el-form-item label="起点 X">
126
+ <el-input-number
127
+ :model-value="step.swipe?.startX ?? 0"
128
+ :disabled="disabled"
129
+ :min="0"
130
+ :max="99999"
131
+ :precision="0"
132
+ controls-position="right"
133
+ @update:model-value="patchSwipe({ startX: toInteger($event, step.swipe?.startX ?? 0) })"
134
+ />
135
+ </el-form-item>
136
+ <el-form-item label="起点 Y">
137
+ <el-input-number
138
+ :model-value="step.swipe?.startY ?? 0"
139
+ :disabled="disabled"
140
+ :min="0"
141
+ :max="99999"
142
+ :precision="0"
143
+ controls-position="right"
144
+ @update:model-value="patchSwipe({ startY: toInteger($event, step.swipe?.startY ?? 0) })"
145
+ />
146
+ </el-form-item>
147
+ <el-form-item label="终点 X">
148
+ <el-input-number
149
+ :model-value="step.swipe?.endX ?? 0"
150
+ :disabled="disabled"
151
+ :min="0"
152
+ :max="99999"
153
+ :precision="0"
154
+ controls-position="right"
155
+ @update:model-value="patchSwipe({ endX: toInteger($event, step.swipe?.endX ?? 0) })"
156
+ />
157
+ </el-form-item>
158
+ <el-form-item label="终点 Y">
159
+ <el-input-number
160
+ :model-value="step.swipe?.endY ?? 0"
161
+ :disabled="disabled"
162
+ :min="0"
163
+ :max="99999"
164
+ :precision="0"
165
+ controls-position="right"
166
+ @update:model-value="patchSwipe({ endY: toInteger($event, step.swipe?.endY ?? 0) })"
167
+ />
168
+ </el-form-item>
169
+ <el-form-item label="时长 ms">
170
+ <el-input-number
171
+ :model-value="step.swipe?.duration ?? 500"
172
+ :disabled="disabled"
173
+ :min="80"
174
+ :max="99999"
175
+ :precision="0"
176
+ controls-position="right"
177
+ @update:model-value="patchSwipe({ duration: Math.max(80, toInteger($event, step.swipe?.duration ?? 500)) })"
178
+ />
179
+ </el-form-item>
180
+ </div>
91
181
  <div v-if="step.selector" class="appium-flow-editor__grid">
92
182
  <el-form-item label="Selector 类型">
93
183
  <el-select
@@ -133,6 +133,12 @@ function typeLabel(step: AppiumRecordedStep) {
133
133
  function stepMeta(step: AppiumRecordedStep) {
134
134
  if (step.type === 'delay') return `${step.timeoutMs || 1000}ms`;
135
135
  if (step.type === 'input' || step.type === 'inputIfExists') return `输入内容:${step.value || '空'}`;
136
+ if (step.type === 'longPress') return `${step.fallback?.centerX || ''},${step.fallback?.centerY || ''} · ${step.timeoutMs || 800}ms`;
137
+ if (step.type === 'coordinateTap') return `${step.fallback?.centerX || ''},${step.fallback?.centerY || ''}`;
138
+ if (step.type === 'swipe') {
139
+ const swipe = step.swipe;
140
+ return swipe ? `[${swipe.startX},${swipe.startY}] -> [${swipe.endX},${swipe.endY}]` : '';
141
+ }
136
142
  if (defaultKind(step) === 'condition' && step.value) {
137
143
  return `${step.flow?.textMatch === 'exact' ? '精准匹配' : '模糊匹配'}:${step.value}`;
138
144
  }
@@ -20,7 +20,7 @@ const targetKeys = [
20
20
  ] as const;
21
21
 
22
22
  function clone<T>(value: T): T {
23
- return structuredClone(value);
23
+ return JSON.parse(JSON.stringify(value)) as T;
24
24
  }
25
25
 
26
26
  function scopeKey(step: AppiumRecordedStep) {
@@ -58,6 +58,38 @@ function wireContinuation(step: AppiumRecordedStep, targetId: string) {
58
58
  if (!step.flow.noTargetId) setFlowTarget(step, 'noTargetId', targetId);
59
59
  }
60
60
 
61
+ function branchConditionForTarget(
62
+ steps: AppiumRecordedStep[],
63
+ target: PasteTarget,
64
+ ) {
65
+ if (!target.branch) return undefined;
66
+ const anchor = steps[target.afterIndex];
67
+ if (!anchor) return undefined;
68
+ if (anchor.flow?.parentConditionId && anchor.flow.parentBranch === target.branch) {
69
+ return steps.find((step) => step.id === anchor.flow?.parentConditionId);
70
+ }
71
+ if (anchor.flow?.nodeKind === 'condition') return anchor;
72
+ return undefined;
73
+ }
74
+
75
+ function nextSiblingInScope(steps: AppiumRecordedStep[], index: number) {
76
+ const anchor = steps[index];
77
+ if (!anchor) return undefined;
78
+ const key = scopeKey(anchor);
79
+ return steps.slice(index + 1).find((step) => scopeKey(step) === key);
80
+ }
81
+
82
+ function nextBranchStepAfter(
83
+ steps: AppiumRecordedStep[],
84
+ conditionId: string,
85
+ branch: FlowBranch,
86
+ index: number,
87
+ ) {
88
+ return steps.slice(index + 1).find((step) => (
89
+ step.flow?.parentConditionId === conditionId && step.flow.parentBranch === branch
90
+ ));
91
+ }
92
+
61
93
  export function createFlowClipboard(steps: AppiumRecordedStep[], indexes: number[]): FlowClipboard {
62
94
  const selectedIndexes = [...new Set(indexes)].sort((left, right) => left - right);
63
95
  const selectedSteps = selectedIndexes.map((index) => steps[index]).filter(Boolean);
@@ -104,7 +136,7 @@ export function pasteFlowClipboard(
104
136
 
105
137
  const idMap = new Map(clipboard.steps.map((step) => [step.id, createId()]));
106
138
  const rootIdSet = new Set(clipboard.rootIds);
107
- const condition = target.branch ? steps[target.afterIndex] : undefined;
139
+ const condition = branchConditionForTarget(steps, target);
108
140
  if (target.branch && (!condition || condition.flow?.nodeKind !== 'condition')) {
109
141
  throw new Error('目标分支不存在');
110
142
  }
@@ -152,29 +184,28 @@ export function pasteFlowClipboard(
152
184
  let continuationId = '';
153
185
 
154
186
  if (target.branch && condition) {
155
- const branchRoots = steps.filter((step) => (
156
- step.flow?.parentConditionId === condition.id
157
- && step.flow.parentBranch === target.branch
158
- ));
159
- if (branchRoots.length) {
160
- const ownedIds = collectDescendantIds(steps, branchRoots.map((step) => step.id));
161
- insertAfterIndex = Math.max(...steps.flatMap((step, index) => (ownedIds.has(step.id) ? [index] : [])));
162
- const lastExistingRoot = branchRoots[branchRoots.length - 1];
163
- continuationId = lastExistingRoot.flow?.successTargetId || condition.flow?.successTargetId || '';
164
- const lastExistingIndex = nextSteps.findIndex((step) => step.id === lastExistingRoot.id);
165
- const updatedLastExisting = clone(nextSteps[lastExistingIndex]);
166
- setFlowTarget(updatedLastExisting, 'successTargetId', firstRoot.id);
167
- nextSteps[lastExistingIndex] = updatedLastExisting;
168
- } else {
187
+ const anchor = steps[target.afterIndex];
188
+ const isBranchEntry = anchor?.id === condition.id;
189
+ if (isBranchEntry) {
169
190
  const targetKey = target.branch === 'yes' ? 'yesTargetId' : 'noTargetId';
170
191
  const currentTargetId = condition.flow?.[targetKey] || '';
171
192
  const currentTarget = steps.find((step) => step.id === currentTargetId);
172
- continuationId = currentTarget && !currentTarget.flow?.parentConditionId
193
+ continuationId = nextBranchStepAfter(steps, condition.id, target.branch, target.afterIndex)?.id
194
+ || (currentTarget && !currentTarget.flow?.parentConditionId
173
195
  ? currentTargetId
174
- : condition.flow?.successTargetId || '';
196
+ : condition.flow?.successTargetId || '');
175
197
  const updatedCondition = clone(nextSteps[target.afterIndex]);
176
198
  setFlowTarget(updatedCondition, targetKey, firstRoot.id);
177
199
  nextSteps[target.afterIndex] = updatedCondition;
200
+ } else {
201
+ const previous = nextSteps[target.afterIndex];
202
+ const nextSibling = nextSiblingInScope(steps, target.afterIndex);
203
+ continuationId = previous?.flow?.successTargetId || nextSibling?.id || condition.flow?.successTargetId || '';
204
+ if (previous?.flow?.successTargetId) {
205
+ const updatedPrevious = clone(previous);
206
+ setFlowTarget(updatedPrevious, 'successTargetId', firstRoot.id);
207
+ nextSteps[target.afterIndex] = updatedPrevious;
208
+ }
178
209
  }
179
210
  } else {
180
211
  const previous = target.afterIndex >= 0 ? nextSteps[target.afterIndex] : undefined;
@@ -402,11 +402,12 @@ export function buildFlowGraph(
402
402
  const directItems = directBranchItems(items, condition.step.id, branch);
403
403
  const connectedTargetId = branchConnectionTargetId(items, condition.step, branch);
404
404
  const targetStep = connectedTargetId ? stepById.get(connectedTargetId) : undefined;
405
+ const entryInsertId = addInsertNode(condition.index, branch, condition);
405
406
  addVisibleLink(splitId, labelId, branch);
406
407
  if (directItems[0]) {
407
- addVisibleLink(labelId, nodeIdForStep(directItems[0].step), branch);
408
+ addVisibleLink(labelId, entryInsertId, branch);
409
+ addVisibleLink(entryInsertId, nodeIdForStep(directItems[0].step), branch);
408
410
  } else {
409
- const entryInsertId = addInsertNode(condition.index, branch, condition);
410
411
  addVisibleLink(labelId, entryInsertId, branch);
411
412
  if (targetStep) {
412
413
  addVisibleLink(entryInsertId, nodeIdForStep(targetStep.step), branch);
@@ -59,7 +59,10 @@ export function flowStepMeta(step: AppiumRecordedStep) {
59
59
  return swipe ? `[${swipe.startX},${swipe.startY}] -> [${swipe.endX},${swipe.endY}]` : '';
60
60
  }
61
61
  if (step.type === 'pinch') return step.pinch?.direction === 'out' ? '放大' : '缩小';
62
- if (step.type === 'longPress' || step.type === 'coordinateTap') {
62
+ if (step.type === 'longPress') {
63
+ return `${step.fallback?.centerX || ''},${step.fallback?.centerY || ''} · ${step.timeoutMs || 800}ms`;
64
+ }
65
+ if (step.type === 'coordinateTap') {
63
66
  return `${step.fallback?.centerX || ''},${step.fallback?.centerY || ''}`;
64
67
  }
65
68
  if (step.type === 'assertText') return step.value || '';
@@ -3,4 +3,8 @@ export const promptDocument = `# 示例 App 登录测试
3
3
  进入目标 App 后,按以下规则处理启动阶段弹窗,这一步必须执行点击动作,不要只判断弹窗是否存在:若弹出位置、存储、相机、麦克风、通知等权限申请弹窗,点击允许;若弹出“声明与条款”、用户协议、隐私政策、服务协议弹窗,点击同意、接受或我已阅读并同意;若弹出广告、活动、版本更新、评分、通知引导等与登录无关的弹窗,点击关闭、跳过、稍后再说或取消。
4
4
 
5
5
  弹窗处理完成后,确认页面已经稳定停留在目标 App 的登录页、手机号登录页、账号密码登录页,或显示可进入登录流程的入口。页面上不应存在遮挡登录操作的权限、协议、广告、更新或活动弹窗;如果仍存在遮挡弹窗,脚本应失败而不是显示执行完成。
6
+
7
+ 提交登录后,再处理一次登录后的遮挡弹窗:若出现版本升级、广告、活动、评分、通知引导、登录后协议或权限弹窗,点击关闭、跳过、稍后再说、取消、同意或允许;如果没有弹窗则保持当前状态。不要点击首页业务内容、添加设备入口、底部导航或退出登录;如果是强制升级且没有关闭、取消或稍后按钮,不要点击立即更新。
8
+
9
+ 最后等待首页或主界面稳定显示,页面应出现设备列表、添加设备入口、底部导航、用户头像或我的页面入口等首页核心元素之一,并且没有版本升级、广告、活动、评分或通知引导弹窗遮挡首页。
6
10
  `;