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 +7 -0
- package/README.md +7 -0
- package/package.json +1 -1
- package/src/appium-recorder/AppiumPage.vue +155 -54
- package/src/appium-recorder/components/FlowCanvas.vue +2 -2
- package/src/appium-recorder/components/FlowStepEditor.vue +93 -3
- package/src/appium-recorder/components/NestedConditionBranches.vue +6 -0
- package/src/appium-recorder/flow-copy.ts +49 -18
- package/src/appium-recorder/flow-graph.ts +3 -2
- package/src/appium-recorder/flow-labels.ts +4 -1
- package/src/style.css +15 -1
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
|
<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
|
|
336
|
-
const
|
|
337
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
1109
|
-
if (!
|
|
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 ===
|
|
1112
|
-
&& step.flow.parentBranch === branch
|
|
1207
|
+
step.flow?.parentConditionId === condition.id && step.flow.parentBranch === branch
|
|
1113
1208
|
));
|
|
1114
|
-
const currentTargetId = branch === 'yes' ?
|
|
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 ===
|
|
1211
|
+
const targetIsBranchStep = currentTarget?.flow?.parentConditionId === condition.id
|
|
1117
1212
|
&& currentTarget.flow.parentBranch === branch;
|
|
1118
|
-
const
|
|
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:
|
|
1219
|
+
stepId: condition.id,
|
|
1121
1220
|
branch,
|
|
1122
|
-
updateTarget: !currentTargetId || !targetIsBranchStep,
|
|
1123
|
-
entryTargetId: existingBranchSteps[0]?.id,
|
|
1124
|
-
nextTargetId
|
|
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,
|
|
1128
|
-
if (
|
|
1226
|
+
attachStepToBranch(inserted.id, condition.id, branch);
|
|
1227
|
+
if (nextTargetId) {
|
|
1129
1228
|
steps.value = steps.value.map((step) => (
|
|
1130
|
-
step.id === inserted.id
|
|
1229
|
+
step.id === inserted.id
|
|
1131
1230
|
? {
|
|
1132
1231
|
...step,
|
|
1133
|
-
flow: { ...(step.flow || {}), successTargetId:
|
|
1232
|
+
flow: { ...(step.flow || {}), successTargetId: nextTargetId },
|
|
1134
1233
|
}
|
|
1135
1234
|
: step
|
|
1136
1235
|
));
|
|
1137
|
-
|
|
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.
|
|
147
|
+
emit('paste', payload.afterIndex, payload.branch);
|
|
148
148
|
return;
|
|
149
149
|
}
|
|
150
150
|
if (payload.branch && payload.conditionIndex !== undefined) {
|
|
151
|
-
emit('insertBranchAction', payload.
|
|
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="
|
|
103
|
+
<el-form-item :label="timeoutLabel()">
|
|
71
104
|
<el-input-number
|
|
72
105
|
:model-value="step.timeoutMs || undefined"
|
|
73
106
|
:disabled="disabled"
|
|
74
|
-
:min="
|
|
107
|
+
:min="timeoutMin()"
|
|
75
108
|
:max="999999"
|
|
76
109
|
controls-position="right"
|
|
77
|
-
@update:model-value="
|
|
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
|
|
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 =
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
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 =
|
|
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,
|
|
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'
|
|
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(
|
|
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;
|