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.
- package/CHANGELOG.md +15 -5
- package/README.md +15 -5
- package/package.json +1 -1
- package/server/http-api.ts +141 -105
- package/server/script-agent.ts +50 -45
- package/src/App.vue +133 -54
- package/src/api.ts +15 -10
- package/src/appium-recorder/AppiumPage.vue +156 -55
- 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/config/prompt-example.ts +4 -0
- package/src/config/prompt-presets.ts +172 -0
- package/src/pages/AiGeneratorPage.vue +188 -5
- package/src/pages/AutomationPage.vue +3 -3
- package/src/pages/ConfigPage.vue +13 -35
- package/src/pages/GeneratorPage.vue +7 -0
- package/src/style.css +108 -139
- package/src/types.ts +0 -14
- package/server/model-call-usage-importer.ts +0 -108
- package/server/model-usage-repository.ts +0 -131
- package/src/components/config/ModelUsageChart.vue +0 -188
|
@@ -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
|
|
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 || '';
|
|
@@ -3,4 +3,8 @@ export const promptDocument = `# 示例 App 登录测试
|
|
|
3
3
|
进入目标 App 后,按以下规则处理启动阶段弹窗,这一步必须执行点击动作,不要只判断弹窗是否存在:若弹出位置、存储、相机、麦克风、通知等权限申请弹窗,点击允许;若弹出“声明与条款”、用户协议、隐私政策、服务协议弹窗,点击同意、接受或我已阅读并同意;若弹出广告、活动、版本更新、评分、通知引导等与登录无关的弹窗,点击关闭、跳过、稍后再说或取消。
|
|
4
4
|
|
|
5
5
|
弹窗处理完成后,确认页面已经稳定停留在目标 App 的登录页、手机号登录页、账号密码登录页,或显示可进入登录流程的入口。页面上不应存在遮挡登录操作的权限、协议、广告、更新或活动弹窗;如果仍存在遮挡弹窗,脚本应失败而不是显示执行完成。
|
|
6
|
+
|
|
7
|
+
提交登录后,再处理一次登录后的遮挡弹窗:若出现版本升级、广告、活动、评分、通知引导、登录后协议或权限弹窗,点击关闭、跳过、稍后再说、取消、同意或允许;如果没有弹窗则保持当前状态。不要点击首页业务内容、添加设备入口、底部导航或退出登录;如果是强制升级且没有关闭、取消或稍后按钮,不要点击立即更新。
|
|
8
|
+
|
|
9
|
+
最后等待首页或主界面稳定显示,页面应出现设备列表、添加设备入口、底部导航、用户头像或我的页面入口等首页核心元素之一,并且没有版本升级、广告、活动、评分或通知引导弹窗遮挡首页。
|
|
6
10
|
`;
|