android-midscene-automation 0.1.25 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # 更新记录
2
2
 
3
+ ## v0.1.26
4
+
5
+ - 修复流程节点复制与粘贴问题,避免 `structuredClone` 失败、粘贴后原节点消失、分支插入位置错误和复制后相对位置偏移。
6
+ - 修复判断分支已有节点时入口添加按钮缺失的问题,分支入口现在会保留可插入节点,并正确连接到首个分支节点。
7
+ - 滑动操作支持在添加弹窗和节点配置中修改起点、终点坐标及滑动时长。
8
+ - 长按操作支持设置长按时间,并在节点卡片中显示长按坐标和时长。
9
+
3
10
  ## v0.1.25
4
11
 
5
12
  - Android 设备预览恢复使用 scrcpy 实时流,优化实时流参数,减少 H.264 丢帧导致的花屏;刷新画面时会重启底层 Playground 预览流并等待会话就绪。
package/README.md CHANGED
@@ -1211,6 +1211,13 @@ output/2026-08-21_17-13-42-831-登录流程.html
1211
1211
 
1212
1212
  # 项目更新记录
1213
1213
 
1214
+ ## v0.1.26
1215
+
1216
+ - 修复流程节点复制与粘贴问题,避免 `structuredClone` 失败、粘贴后原节点消失、分支插入位置错误和复制后相对位置偏移。
1217
+ - 修复判断分支已有节点时入口添加按钮缺失的问题,分支入口现在会保留可插入节点,并正确连接到首个分支节点。
1218
+ - 滑动操作支持在添加弹窗和节点配置中修改起点、终点坐标及滑动时长。
1219
+ - 长按操作支持设置长按时间,并在节点卡片中显示长按坐标和时长。
1220
+
1214
1221
  ## v0.1.25
1215
1222
 
1216
1223
  - Android 设备预览恢复使用 scrcpy 实时流,优化实时流参数,减少 H.264 丢帧导致的花屏;刷新画面时会重启底层 Playground 预览流并等待会话就绪。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "android-midscene-automation",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oooooooko/android-midscene-automation.git"
@@ -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;
@@ -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 || '';
package/src/style.css CHANGED
@@ -242,7 +242,7 @@ select {
242
242
  .appium-recorder-layout {
243
243
  display: grid;
244
244
  flex: 1 1 auto;
245
- grid-template-columns: minmax(0, 3fr) minmax(320px, 2.8fr) minmax(0, 4.2fr);
245
+ grid-template-columns: minmax(320px, 2.8fr) minmax(0, 3fr) minmax(0, 4.2fr);
246
246
  gap: 12px;
247
247
  align-items: stretch;
248
248
  height: auto;
@@ -1247,6 +1247,20 @@ select {
1247
1247
  gap: 8px;
1248
1248
  }
1249
1249
 
1250
+ .appium-flow-editor__grid--swipe {
1251
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1252
+ }
1253
+
1254
+ .appium-swipe-message-box {
1255
+ width: min(560px, calc(100vw - 32px));
1256
+ }
1257
+
1258
+ .appium-swipe-dialog-grid {
1259
+ display: grid;
1260
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1261
+ gap: 10px 12px;
1262
+ }
1263
+
1250
1264
  .appium-flow-insert {
1251
1265
  position: relative;
1252
1266
  z-index: 1;