android-midscene-automation 0.1.21 → 0.1.22
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 +55 -128
- package/README.md +71 -145
- package/USAGE.md +16 -17
- package/bin/android-midscene-automation.js +4 -0
- package/package.json +35 -20
- package/server/appium-recorder/appium-runner.ts +43 -6
- package/server/appium-recorder/repository.ts +1 -0
- package/server/appium-recorder/routes.ts +14 -1
- package/server/http-api.ts +8 -4
- package/server/model-tester.ts +22 -6
- package/src/App.vue +4 -1
- package/src/api.ts +3 -1
- package/src/appium-recorder/AppiumPage.vue +175 -87
- package/src/appium-recorder/api.ts +4 -0
- package/src/appium-recorder/components/FlowCanvas.vue +201 -0
- package/src/appium-recorder/components/FlowNodeCard.vue +264 -0
- package/src/appium-recorder/components/FlowRoundedEdge.vue +67 -0
- package/src/appium-recorder/components/NestedConditionBranches.vue +24 -29
- package/src/appium-recorder/components/RecordedSteps.vue +119 -1457
- package/src/appium-recorder/flow-graph.ts +709 -0
- package/src/appium-recorder/flow-labels.ts +81 -0
- package/src/appium-recorder/types.ts +1 -0
- package/src/components/device/DevicePreviewPanel.vue +4 -1
- package/src/main.ts +3 -0
- package/src/style.css +387 -38
- package/src/ui-refresh.css +619 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, onMounted, onUnmounted, reactive, shallowRef, watch } from 'vue';
|
|
3
3
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
4
|
-
import { Check, CircleClose, CopyDocument, Delete, Document, Download, Refresh, Upload, VideoPlay } from '@element-plus/icons-vue';
|
|
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';
|
|
7
7
|
import {
|
|
8
8
|
appiumScriptDownloadUrl,
|
|
9
|
+
clearAppiumDeviceAppData,
|
|
9
10
|
deleteAppiumScript,
|
|
10
11
|
getAppiumScripts,
|
|
11
12
|
getAppiumTree,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
tapAppiumDevice,
|
|
18
19
|
} from './api';
|
|
19
20
|
import ComponentTree from './components/ComponentTree.vue';
|
|
21
|
+
import FlowCanvas from './components/FlowCanvas.vue';
|
|
20
22
|
import NodeDetail from './components/NodeDetail.vue';
|
|
21
23
|
import RecordedSteps from './components/RecordedSteps.vue';
|
|
22
24
|
import {
|
|
@@ -24,6 +26,8 @@ import {
|
|
|
24
26
|
pasteFlowClipboard,
|
|
25
27
|
type FlowClipboard,
|
|
26
28
|
} from './flow-copy';
|
|
29
|
+
import { labelFlowStep } from './flow-labels';
|
|
30
|
+
import type { FlowActionGroup, InsertAction } from './flow-graph';
|
|
27
31
|
import { findSmallestNodeAtPoint, flattenNodes, parseWindowHierarchy } from './tree';
|
|
28
32
|
import type { AppiumNode, AppiumRecordedScript, AppiumRecordedStep } from './types';
|
|
29
33
|
|
|
@@ -55,6 +59,7 @@ type RecorderAction =
|
|
|
55
59
|
| 'clearInput'
|
|
56
60
|
| 'coordinateTap'
|
|
57
61
|
| 'launchApp'
|
|
62
|
+
| 'clearAppData'
|
|
58
63
|
| 'waitDisappear'
|
|
59
64
|
| 'assertText'
|
|
60
65
|
| 'longPress'
|
|
@@ -84,11 +89,14 @@ const insertableRecorderActions: RecorderAction[] = [
|
|
|
84
89
|
'clearInput',
|
|
85
90
|
'coordinateTap',
|
|
86
91
|
'launchApp',
|
|
92
|
+
'clearAppData',
|
|
87
93
|
'waitDisappear',
|
|
88
94
|
'assertText',
|
|
89
95
|
'longPress',
|
|
90
96
|
'pinch',
|
|
91
97
|
];
|
|
98
|
+
const readonlyFlowActionGroups: FlowActionGroup[] = [];
|
|
99
|
+
const readonlyFlowSelectedIndexes: number[] = [];
|
|
92
100
|
|
|
93
101
|
const props = defineProps<{
|
|
94
102
|
active: boolean;
|
|
@@ -132,6 +140,9 @@ const linkedScriptTargetId = shallowRef('');
|
|
|
132
140
|
const linkedScriptInsertIndex = shallowRef<number | undefined>();
|
|
133
141
|
const linkedScriptBranchTarget = shallowRef<BranchTarget | null>(null);
|
|
134
142
|
const linkedScriptExpectedActivity = shallowRef('');
|
|
143
|
+
const linkedScriptPreviewVisible = shallowRef(false);
|
|
144
|
+
const linkedScriptPreviewScriptId = shallowRef('');
|
|
145
|
+
const linkedScriptPreviewResetToken = shallowRef(0);
|
|
135
146
|
const loadingTree = shallowRef(false);
|
|
136
147
|
const saving = shallowRef(false);
|
|
137
148
|
const importingScript = shallowRef(false);
|
|
@@ -143,6 +154,7 @@ const activeReplayDeviceId = shallowRef('');
|
|
|
143
154
|
const recordingTap = shallowRef(false);
|
|
144
155
|
const resolvingNavigation = shallowRef(false);
|
|
145
156
|
const launchingApp = shallowRef(false);
|
|
157
|
+
const executingAppStepId = shallowRef('');
|
|
146
158
|
const pendingNavigation = shallowRef<{
|
|
147
159
|
beforeActivity: string;
|
|
148
160
|
afterActivity: string;
|
|
@@ -182,6 +194,10 @@ markDraftSaved();
|
|
|
182
194
|
|
|
183
195
|
const selectedScript = computed(() => scripts.value.find((script) => script.id === selectedScriptId.value) || null);
|
|
184
196
|
const linkableScripts = computed(() => scripts.value.filter((script) => script.id !== selectedScriptId.value));
|
|
197
|
+
const linkedScriptTarget = computed(() => scripts.value.find((script) => script.id === linkedScriptTargetId.value) || null);
|
|
198
|
+
const linkedScriptPreviewScript = computed(() => (
|
|
199
|
+
scripts.value.find((script) => script.id === linkedScriptPreviewScriptId.value) || null
|
|
200
|
+
));
|
|
185
201
|
const compatibleLinkableScripts = computed(() => linkableScripts.value.filter((script) => (
|
|
186
202
|
script.appPackage === form.appPackage
|
|
187
203
|
&& (
|
|
@@ -640,7 +656,7 @@ async function refreshTree() {
|
|
|
640
656
|
|
|
641
657
|
async function executeFlowStep(index: number) {
|
|
642
658
|
const step = steps.value[index];
|
|
643
|
-
if (!step || step.type !== 'launchApp') return;
|
|
659
|
+
if (!step || (step.type !== 'launchApp' && step.type !== 'clearAppData')) return;
|
|
644
660
|
if (launchingApp.value) return;
|
|
645
661
|
if (!selectedDeviceId.value) {
|
|
646
662
|
ElMessage.warning('请选择设备');
|
|
@@ -648,29 +664,48 @@ async function executeFlowStep(index: number) {
|
|
|
648
664
|
}
|
|
649
665
|
const packageName = step.value?.trim() || form.appPackage;
|
|
650
666
|
if (!packageName || !props.appPresets.some((app) => app.packageName === packageName)) {
|
|
651
|
-
ElMessage.warning('
|
|
667
|
+
ElMessage.warning('当前节点没有匹配的预设 App');
|
|
652
668
|
return;
|
|
653
669
|
}
|
|
654
670
|
|
|
671
|
+
if (step.type === 'clearAppData') {
|
|
672
|
+
const confirmed = await ElMessageBox.confirm(
|
|
673
|
+
'Android 将清除该 App 的全部数据和缓存,登录状态与本地设置也会被重置。',
|
|
674
|
+
'确认清理 App 缓存',
|
|
675
|
+
{
|
|
676
|
+
confirmButtonText: '确认清理',
|
|
677
|
+
cancelButtonText: '取消',
|
|
678
|
+
type: 'warning',
|
|
679
|
+
center: true,
|
|
680
|
+
},
|
|
681
|
+
).catch(() => false);
|
|
682
|
+
if (!confirmed) return;
|
|
683
|
+
}
|
|
684
|
+
|
|
655
685
|
launchingApp.value = true;
|
|
686
|
+
executingAppStepId.value = step.id;
|
|
656
687
|
try {
|
|
657
|
-
|
|
658
|
-
deviceId: selectedDeviceId.value,
|
|
659
|
-
|
|
660
|
-
|
|
688
|
+
if (step.type === 'clearAppData') {
|
|
689
|
+
await clearAppiumDeviceAppData({ deviceId: selectedDeviceId.value, packageName });
|
|
690
|
+
} else {
|
|
691
|
+
await launchAppiumDeviceApp({ deviceId: selectedDeviceId.value, packageName });
|
|
692
|
+
}
|
|
661
693
|
props.refreshDevicePreview();
|
|
662
694
|
await wait(800);
|
|
663
695
|
await loadTreeSnapshot(true, true);
|
|
664
696
|
props.refreshDevicePreview();
|
|
665
|
-
if (
|
|
697
|
+
if (step.type === 'clearAppData') {
|
|
698
|
+
ElMessage.success('App 数据与缓存已清理');
|
|
699
|
+
} else if (scriptActivityMismatch.value) {
|
|
666
700
|
ElMessage.warning(`App 已启动,当前页面仍为 ${currentActivity.value || '-'}`);
|
|
667
701
|
} else {
|
|
668
702
|
ElMessage.success('已进入脚本绑定页面,编辑锁定已解除');
|
|
669
703
|
}
|
|
670
704
|
} catch (error) {
|
|
671
|
-
ElMessage.error(error instanceof Error ? error.message : '
|
|
705
|
+
ElMessage.error(error instanceof Error ? error.message : 'App 操作失败');
|
|
672
706
|
} finally {
|
|
673
707
|
launchingApp.value = false;
|
|
708
|
+
executingAppStepId.value = '';
|
|
674
709
|
}
|
|
675
710
|
}
|
|
676
711
|
|
|
@@ -867,6 +902,40 @@ function closeLinkedScriptDialog() {
|
|
|
867
902
|
linkedScriptExpectedActivity.value = '';
|
|
868
903
|
}
|
|
869
904
|
|
|
905
|
+
function openLinkedScriptPreview(scriptId = linkedScriptTargetId.value) {
|
|
906
|
+
const script = scripts.value.find((item) => item.id === scriptId);
|
|
907
|
+
if (!script) {
|
|
908
|
+
ElMessage.warning('请选择要预览的脚本');
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
linkedScriptPreviewScriptId.value = script.id;
|
|
912
|
+
linkedScriptPreviewResetToken.value += 1;
|
|
913
|
+
linkedScriptPreviewVisible.value = true;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function previewLinkedScriptStep(index: number) {
|
|
917
|
+
openLinkedScriptPreview(steps.value[index]?.value || '');
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function loadPreviewScriptForEditing() {
|
|
921
|
+
if (!linkedScriptPreviewScript.value) return;
|
|
922
|
+
linkedScriptPreviewVisible.value = false;
|
|
923
|
+
linkedScriptDialogVisible.value = false;
|
|
924
|
+
loadScript(linkedScriptPreviewScript.value);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function isReadonlyFlowActionDisabled(_action: InsertAction) {
|
|
928
|
+
return true;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function isReadonlyFlowAppExecutionDisabled(_action: 'launchApp' | 'clearAppData') {
|
|
932
|
+
return true;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function isReadonlyFlowCopySelected(_index: number) {
|
|
936
|
+
return false;
|
|
937
|
+
}
|
|
938
|
+
|
|
870
939
|
async function addAction(
|
|
871
940
|
action: RecorderAction,
|
|
872
941
|
index?: number,
|
|
@@ -878,8 +947,12 @@ async function addAction(
|
|
|
878
947
|
ElMessage.warning('启动 APP 节点只能添加一个');
|
|
879
948
|
return;
|
|
880
949
|
}
|
|
881
|
-
if (action === '
|
|
882
|
-
ElMessage.warning('
|
|
950
|
+
if (action === 'clearAppData' && steps.value.some((step) => step.type === 'clearAppData')) {
|
|
951
|
+
ElMessage.warning('清理 App 缓存节点只能添加一个');
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
if ((action === 'launchApp' || action === 'clearAppData') && index !== -1) {
|
|
955
|
+
ElMessage.warning(`${action === 'launchApp' ? '启动 APP' : '清理 App 缓存'}只能从开始节点添加`);
|
|
883
956
|
return;
|
|
884
957
|
}
|
|
885
958
|
if (action === 'runScript') {
|
|
@@ -947,11 +1020,20 @@ async function addAction(
|
|
|
947
1020
|
}, index);
|
|
948
1021
|
}
|
|
949
1022
|
if (action === 'launchApp') {
|
|
1023
|
+
const clearStepIndex = steps.value.findIndex((step) => step.type === 'clearAppData');
|
|
950
1024
|
return insertStep({
|
|
951
1025
|
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
|
952
1026
|
type: 'launchApp',
|
|
953
1027
|
label: `启动 APP ${form.appPackage}`,
|
|
954
1028
|
value: form.appPackage,
|
|
1029
|
+
}, clearStepIndex >= 0 ? clearStepIndex : index);
|
|
1030
|
+
}
|
|
1031
|
+
if (action === 'clearAppData') {
|
|
1032
|
+
return insertStep({
|
|
1033
|
+
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
|
1034
|
+
type: 'clearAppData',
|
|
1035
|
+
label: `清理 APP 缓存(含数据)${form.appPackage}`,
|
|
1036
|
+
value: form.appPackage,
|
|
955
1037
|
}, index);
|
|
956
1038
|
}
|
|
957
1039
|
const node = getSelectedNode();
|
|
@@ -1056,62 +1138,6 @@ async function addBranchAction(index: number, branch: BranchName, action: Record
|
|
|
1056
1138
|
}
|
|
1057
1139
|
}
|
|
1058
1140
|
|
|
1059
|
-
function connectBranchToNext(index: number, branch: BranchName) {
|
|
1060
|
-
if (recordingLocked.value) return;
|
|
1061
|
-
const condition = steps.value[index];
|
|
1062
|
-
if (!condition) return;
|
|
1063
|
-
const nextMainStep = steps.value.slice(index + 1).find((step) => !step.flow?.parentConditionId);
|
|
1064
|
-
if (!nextMainStep) {
|
|
1065
|
-
ElMessage.warning('当前判断节点后没有可连接的主流程节点');
|
|
1066
|
-
return;
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
const branchSteps = steps.value.filter((step) => (
|
|
1070
|
-
step.flow?.parentConditionId === condition.id
|
|
1071
|
-
&& step.flow.parentBranch === branch
|
|
1072
|
-
));
|
|
1073
|
-
if (!branchSteps.length) {
|
|
1074
|
-
updateBranchTarget(condition.id, branch, nextMainStep.id);
|
|
1075
|
-
} else {
|
|
1076
|
-
const firstBranchStep = branchSteps[0];
|
|
1077
|
-
const lastBranchStep = branchSteps[branchSteps.length - 1];
|
|
1078
|
-
updateBranchTarget(condition.id, branch, firstBranchStep.id);
|
|
1079
|
-
steps.value = steps.value.map((step) => (
|
|
1080
|
-
step.id === lastBranchStep.id
|
|
1081
|
-
? {
|
|
1082
|
-
...step,
|
|
1083
|
-
flow: { ...(step.flow || {}), successTargetId: nextMainStep.id },
|
|
1084
|
-
}
|
|
1085
|
-
: step
|
|
1086
|
-
));
|
|
1087
|
-
}
|
|
1088
|
-
ElMessage.success(`${branch === 'yes' ? '是' : '否'}分支已连接到「${nextMainStep.label}」`);
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
function disconnectBranchFromNext(index: number, branch: BranchName) {
|
|
1092
|
-
if (recordingLocked.value) return;
|
|
1093
|
-
const condition = steps.value[index];
|
|
1094
|
-
if (!condition) return;
|
|
1095
|
-
const branchSteps = steps.value.filter((step) => (
|
|
1096
|
-
step.flow?.parentConditionId === condition.id
|
|
1097
|
-
&& step.flow.parentBranch === branch
|
|
1098
|
-
));
|
|
1099
|
-
if (!branchSteps.length) {
|
|
1100
|
-
updateBranchTarget(condition.id, branch, '');
|
|
1101
|
-
} else {
|
|
1102
|
-
const lastBranchStep = branchSteps[branchSteps.length - 1];
|
|
1103
|
-
steps.value = steps.value.map((step) => (
|
|
1104
|
-
step.id === lastBranchStep.id
|
|
1105
|
-
? {
|
|
1106
|
-
...step,
|
|
1107
|
-
flow: { ...(step.flow || {}), successTargetId: '' },
|
|
1108
|
-
}
|
|
1109
|
-
: step
|
|
1110
|
-
));
|
|
1111
|
-
}
|
|
1112
|
-
ElMessage.success(`${branch === 'yes' ? '是' : '否'}分支已取消连接`);
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
1141
|
async function saveOnActivityChange() {
|
|
1116
1142
|
const pending = pendingNavigation.value;
|
|
1117
1143
|
if (!pending || resolvingNavigation.value) return;
|
|
@@ -1142,7 +1168,7 @@ async function saveOnActivityChange() {
|
|
|
1142
1168
|
}
|
|
1143
1169
|
|
|
1144
1170
|
function removeStep(index: number) {
|
|
1145
|
-
if (
|
|
1171
|
+
if (recordingBusy.value) return;
|
|
1146
1172
|
const removed = steps.value[index];
|
|
1147
1173
|
if (!removed) return;
|
|
1148
1174
|
const nextBranchStep = removed.flow?.parentConditionId && removed.flow.parentBranch
|
|
@@ -1183,8 +1209,8 @@ function updateStep(index: number, step: AppiumRecordedStep) {
|
|
|
1183
1209
|
|
|
1184
1210
|
async function editInputStep(index: number) {
|
|
1185
1211
|
const step = steps.value[index];
|
|
1186
|
-
if (!step || (step.type !== 'input' && step.type !== 'inputIfExists') ||
|
|
1187
|
-
const input = await ElMessageBox.prompt('
|
|
1212
|
+
if (!step || (step.type !== 'input' && step.type !== 'inputIfExists') || recordingBusy.value) return;
|
|
1213
|
+
const input = await ElMessageBox.prompt('', '修改输入内容', {
|
|
1188
1214
|
inputValue: step.value || '',
|
|
1189
1215
|
confirmButtonText: '保存',
|
|
1190
1216
|
cancelButtonText: '取消',
|
|
@@ -1392,7 +1418,9 @@ watch(() => form.appPackage, (packageName) => {
|
|
|
1392
1418
|
steps.value = steps.value.map((step) => (
|
|
1393
1419
|
step.type === 'launchApp'
|
|
1394
1420
|
? { ...step, label: `启动 APP ${packageName}`, value: packageName }
|
|
1395
|
-
: step
|
|
1421
|
+
: step.type === 'clearAppData'
|
|
1422
|
+
? { ...step, label: `清理 APP 缓存(含数据)${packageName}`, value: packageName }
|
|
1423
|
+
: step
|
|
1396
1424
|
));
|
|
1397
1425
|
});
|
|
1398
1426
|
|
|
@@ -1538,17 +1566,17 @@ watch(
|
|
|
1538
1566
|
:steps="steps"
|
|
1539
1567
|
:clipboard-count="flowClipboardCount"
|
|
1540
1568
|
:disabled="recordingLocked"
|
|
1569
|
+
:remove-disabled="recordingBusy"
|
|
1541
1570
|
:allowed-locked-actions="scriptActivityMismatch && !recordingBusy ? insertableRecorderActions : []"
|
|
1542
|
-
:launching-step-id="
|
|
1571
|
+
:launching-step-id="executingAppStepId"
|
|
1543
1572
|
@remove="removeStep"
|
|
1544
1573
|
@copy="copyFlowNodes"
|
|
1545
1574
|
@paste="pasteFlowNodes"
|
|
1546
1575
|
@add-delay="addDelayStep"
|
|
1547
1576
|
@insert-action="(index, action) => addAction(action, index)"
|
|
1548
1577
|
@insert-branch-action="addBranchAction"
|
|
1549
|
-
@connect-next="connectBranchToNext"
|
|
1550
|
-
@disconnect-next="disconnectBranchFromNext"
|
|
1551
1578
|
@edit-input="editInputStep"
|
|
1579
|
+
@preview-linked-script="previewLinkedScriptStep"
|
|
1552
1580
|
@execute-step="executeFlowStep"
|
|
1553
1581
|
@update-step="updateStep"
|
|
1554
1582
|
/>
|
|
@@ -1648,19 +1676,28 @@ watch(
|
|
|
1648
1676
|
<code class="appium-linked-script-activity">{{ linkedScriptExpectedActivity || '-' }}</code>
|
|
1649
1677
|
</el-form-item>
|
|
1650
1678
|
<el-form-item :label="linkedScriptBranchTarget ? '选择同一 App 的脚本(回放时等待入口 Activity)' : '选择入口 Activity 匹配的脚本'">
|
|
1651
|
-
<
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1679
|
+
<div class="appium-linked-script-picker">
|
|
1680
|
+
<el-select
|
|
1681
|
+
v-model="linkedScriptTargetId"
|
|
1682
|
+
filterable
|
|
1683
|
+
placeholder="选择要连接的脚本"
|
|
1684
|
+
class="appium-linked-script-select"
|
|
1685
|
+
>
|
|
1686
|
+
<el-option
|
|
1687
|
+
v-for="script in compatibleLinkableScripts"
|
|
1688
|
+
:key="script.id"
|
|
1689
|
+
:label="`${script.name} · ${script.appActivity || '未绑定 Activity'} · ${script.steps.length} 步`"
|
|
1690
|
+
:value="script.id"
|
|
1691
|
+
/>
|
|
1692
|
+
</el-select>
|
|
1693
|
+
<el-button
|
|
1694
|
+
:icon="View"
|
|
1695
|
+
:disabled="!linkedScriptTarget"
|
|
1696
|
+
@click="openLinkedScriptPreview()"
|
|
1697
|
+
>
|
|
1698
|
+
预览
|
|
1699
|
+
</el-button>
|
|
1700
|
+
</div>
|
|
1664
1701
|
</el-form-item>
|
|
1665
1702
|
</el-form>
|
|
1666
1703
|
<template #footer>
|
|
@@ -1669,6 +1706,57 @@ watch(
|
|
|
1669
1706
|
</template>
|
|
1670
1707
|
</el-dialog>
|
|
1671
1708
|
|
|
1709
|
+
<el-dialog
|
|
1710
|
+
v-model="linkedScriptPreviewVisible"
|
|
1711
|
+
:title="linkedScriptPreviewScript ? `预览脚本:${linkedScriptPreviewScript.name}` : '预览脚本'"
|
|
1712
|
+
width="86vw"
|
|
1713
|
+
top="6vh"
|
|
1714
|
+
class="appium-linked-script-preview-dialog"
|
|
1715
|
+
:z-index="4000"
|
|
1716
|
+
append-to-body
|
|
1717
|
+
>
|
|
1718
|
+
<template v-if="linkedScriptPreviewScript">
|
|
1719
|
+
<div class="appium-linked-script-preview-meta">
|
|
1720
|
+
<strong>{{ linkedScriptPreviewScript.name }}</strong>
|
|
1721
|
+
<span>
|
|
1722
|
+
{{ linkedScriptPreviewScript.appPackage || '-' }}
|
|
1723
|
+
· {{ linkedScriptPreviewScript.appActivity || '未绑定 Activity' }}
|
|
1724
|
+
· {{ linkedScriptPreviewScript.steps.length }} 步
|
|
1725
|
+
</span>
|
|
1726
|
+
</div>
|
|
1727
|
+
<FlowCanvas
|
|
1728
|
+
id="appium-linked-script-preview"
|
|
1729
|
+
class="appium-linked-script-preview-canvas"
|
|
1730
|
+
readonly
|
|
1731
|
+
disabled
|
|
1732
|
+
remove-disabled
|
|
1733
|
+
:steps="linkedScriptPreviewScript.steps"
|
|
1734
|
+
:expanded-step-index="null"
|
|
1735
|
+
:copy-mode="false"
|
|
1736
|
+
:selected-copy-indexes="readonlyFlowSelectedIndexes"
|
|
1737
|
+
:reset-view-token="linkedScriptPreviewResetToken"
|
|
1738
|
+
:start-action-groups="readonlyFlowActionGroups"
|
|
1739
|
+
:main-action-groups="readonlyFlowActionGroups"
|
|
1740
|
+
:can-open-insert-menu="false"
|
|
1741
|
+
:is-start-action-disabled="isReadonlyFlowActionDisabled"
|
|
1742
|
+
:is-insert-action-disabled="isReadonlyFlowActionDisabled"
|
|
1743
|
+
:is-app-execution-disabled="isReadonlyFlowAppExecutionDisabled"
|
|
1744
|
+
:label-step="labelFlowStep"
|
|
1745
|
+
:is-copy-selected="isReadonlyFlowCopySelected"
|
|
1746
|
+
/>
|
|
1747
|
+
</template>
|
|
1748
|
+
<template #footer>
|
|
1749
|
+
<el-button @click="linkedScriptPreviewVisible = false">关闭</el-button>
|
|
1750
|
+
<el-button
|
|
1751
|
+
type="primary"
|
|
1752
|
+
:disabled="!linkedScriptPreviewScript"
|
|
1753
|
+
@click="loadPreviewScriptForEditing"
|
|
1754
|
+
>
|
|
1755
|
+
加载后修改
|
|
1756
|
+
</el-button>
|
|
1757
|
+
</template>
|
|
1758
|
+
</el-dialog>
|
|
1759
|
+
|
|
1672
1760
|
<el-dialog
|
|
1673
1761
|
v-model="replayOutputDialogVisible"
|
|
1674
1762
|
title="回放输出"
|
|
@@ -38,6 +38,10 @@ export function launchAppiumDeviceApp(input: { deviceId: string; packageName: st
|
|
|
38
38
|
return postJson<{ success: boolean }>(`${APP_BASE}/api/appium-recorder/launch-app`, input);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export function clearAppiumDeviceAppData(input: { deviceId: string; packageName: string }) {
|
|
42
|
+
return postJson<{ success: boolean }>(`${APP_BASE}/api/appium-recorder/clear-app-data`, input);
|
|
43
|
+
}
|
|
44
|
+
|
|
41
45
|
export async function getAppiumScripts() {
|
|
42
46
|
const response = await fetch(`${APP_BASE}/api/appium-recorder/scripts`);
|
|
43
47
|
return readJson<{ scripts: AppiumRecordedScript[] }>(response);
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, nextTick, onBeforeUnmount, shallowRef, watch } from 'vue';
|
|
3
|
+
import { useVueFlow, VueFlow } from '@vue-flow/core';
|
|
4
|
+
import FlowNodeCard from './FlowNodeCard.vue';
|
|
5
|
+
import FlowRoundedEdge from './FlowRoundedEdge.vue';
|
|
6
|
+
import {
|
|
7
|
+
buildFlowGraph,
|
|
8
|
+
PASTE_COMMAND,
|
|
9
|
+
type FlowActionGroup,
|
|
10
|
+
type FlowBranch,
|
|
11
|
+
type InsertAction,
|
|
12
|
+
} from '../flow-graph';
|
|
13
|
+
import type { AppiumRecordedStep } from '../types';
|
|
14
|
+
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
id: string;
|
|
17
|
+
steps: AppiumRecordedStep[];
|
|
18
|
+
expandedStepIndex: number | null;
|
|
19
|
+
copyMode: boolean;
|
|
20
|
+
selectedCopyIndexes: number[];
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
removeDisabled?: boolean;
|
|
23
|
+
readonly?: boolean;
|
|
24
|
+
launchingStepId?: string;
|
|
25
|
+
clipboardCount?: number;
|
|
26
|
+
resetViewToken?: number;
|
|
27
|
+
startActionGroups: FlowActionGroup[];
|
|
28
|
+
mainActionGroups: FlowActionGroup[];
|
|
29
|
+
canOpenInsertMenu: boolean;
|
|
30
|
+
isStartActionDisabled: (action: InsertAction) => boolean;
|
|
31
|
+
isInsertActionDisabled: (action: InsertAction) => boolean;
|
|
32
|
+
isAppExecutionDisabled: (action: 'launchApp' | 'clearAppData') => boolean;
|
|
33
|
+
labelStep: (step: AppiumRecordedStep) => { title: string; meta: string; note?: string };
|
|
34
|
+
isCopySelected: (index: number) => boolean;
|
|
35
|
+
}>();
|
|
36
|
+
|
|
37
|
+
const emit = defineEmits<{
|
|
38
|
+
nodeClick: [index: number];
|
|
39
|
+
copy: [indexes: number[]];
|
|
40
|
+
paste: [index: number, branch?: FlowBranch];
|
|
41
|
+
remove: [index: number];
|
|
42
|
+
insertAction: [index: number, action: InsertAction];
|
|
43
|
+
insertBranchAction: [index: number, branch: FlowBranch, action: InsertAction];
|
|
44
|
+
editInput: [index: number];
|
|
45
|
+
previewLinkedScript: [index: number];
|
|
46
|
+
executeStep: [index: number];
|
|
47
|
+
updateStep: [index: number, step: AppiumRecordedStep];
|
|
48
|
+
}>();
|
|
49
|
+
|
|
50
|
+
const measuredNodeHeights = shallowRef<Record<string, number>>({});
|
|
51
|
+
const { fitView, updateNodeInternals } = useVueFlow(props.id);
|
|
52
|
+
const paneReady = shallowRef(false);
|
|
53
|
+
let internalsFrame = 0;
|
|
54
|
+
let resetViewFrame = 0;
|
|
55
|
+
let pendingResetView = false;
|
|
56
|
+
|
|
57
|
+
const graph = computed(() => buildFlowGraph(props.steps, {
|
|
58
|
+
expandedStepIndex: props.expandedStepIndex,
|
|
59
|
+
selectedCopyIndexes: props.selectedCopyIndexes,
|
|
60
|
+
copyMode: props.copyMode,
|
|
61
|
+
disabled: props.disabled,
|
|
62
|
+
removeDisabled: props.removeDisabled,
|
|
63
|
+
launchingStepId: props.launchingStepId,
|
|
64
|
+
clipboardCount: props.clipboardCount,
|
|
65
|
+
canOpenInsertMenu: props.canOpenInsertMenu,
|
|
66
|
+
isStartActionDisabled: props.isStartActionDisabled,
|
|
67
|
+
isInsertActionDisabled: props.isInsertActionDisabled,
|
|
68
|
+
isAppExecutionDisabled: props.isAppExecutionDisabled,
|
|
69
|
+
startActionGroups: props.startActionGroups,
|
|
70
|
+
mainActionGroups: props.mainActionGroups,
|
|
71
|
+
measuredNodeHeights: measuredNodeHeights.value,
|
|
72
|
+
labelStep: props.labelStep,
|
|
73
|
+
isCopySelected: props.isCopySelected,
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
function handleNodeResize(payload: { id: string; height: number }) {
|
|
77
|
+
const height = Math.ceil(payload.height) + 6;
|
|
78
|
+
if (!height || Math.abs((measuredNodeHeights.value[payload.id] || 0) - height) < 1) return;
|
|
79
|
+
measuredNodeHeights.value = {
|
|
80
|
+
...measuredNodeHeights.value,
|
|
81
|
+
[payload.id]: height,
|
|
82
|
+
};
|
|
83
|
+
void nextTick(scheduleNodeInternalsUpdate);
|
|
84
|
+
if (props.readonly) void nextTick(() => scheduleResetView());
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function scheduleNodeInternalsUpdate() {
|
|
88
|
+
if (internalsFrame) window.cancelAnimationFrame(internalsFrame);
|
|
89
|
+
internalsFrame = window.requestAnimationFrame(() => {
|
|
90
|
+
internalsFrame = 0;
|
|
91
|
+
updateNodeInternals(graph.value.nodes.map((node) => node.id));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function scheduleResetView(attempt = 0) {
|
|
96
|
+
if (!paneReady.value) {
|
|
97
|
+
pendingResetView = true;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (resetViewFrame) window.cancelAnimationFrame(resetViewFrame);
|
|
101
|
+
resetViewFrame = window.requestAnimationFrame(async () => {
|
|
102
|
+
resetViewFrame = 0;
|
|
103
|
+
updateNodeInternals(graph.value.nodes.map((node) => node.id));
|
|
104
|
+
const fitted = await fitView({
|
|
105
|
+
padding: 0.18,
|
|
106
|
+
minZoom: 0.35,
|
|
107
|
+
maxZoom: 1.8,
|
|
108
|
+
duration: attempt ? 0 : 240,
|
|
109
|
+
});
|
|
110
|
+
if (!fitted && attempt < 8) scheduleResetView(attempt + 1);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function handlePaneReady() {
|
|
115
|
+
paneReady.value = true;
|
|
116
|
+
if (!pendingResetView && !props.readonly) return;
|
|
117
|
+
pendingResetView = false;
|
|
118
|
+
void nextTick(() => scheduleResetView());
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
watch(() => props.steps.map((step) => step.id).join('|'), () => {
|
|
122
|
+
measuredNodeHeights.value = {};
|
|
123
|
+
void nextTick(scheduleNodeInternalsUpdate);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
watch(graph, () => {
|
|
127
|
+
void nextTick(scheduleNodeInternalsUpdate);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
watch(() => props.resetViewToken, () => {
|
|
131
|
+
void nextTick(scheduleResetView);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
onBeforeUnmount(() => {
|
|
135
|
+
if (internalsFrame) window.cancelAnimationFrame(internalsFrame);
|
|
136
|
+
if (resetViewFrame) window.cancelAnimationFrame(resetViewFrame);
|
|
137
|
+
pendingResetView = false;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
function handleInsert(payload: {
|
|
141
|
+
afterIndex: number;
|
|
142
|
+
action: InsertAction | typeof PASTE_COMMAND;
|
|
143
|
+
branch?: FlowBranch;
|
|
144
|
+
conditionIndex?: number;
|
|
145
|
+
}) {
|
|
146
|
+
if (payload.action === PASTE_COMMAND) {
|
|
147
|
+
emit('paste', payload.conditionIndex ?? payload.afterIndex, payload.branch);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (payload.branch && payload.conditionIndex !== undefined) {
|
|
151
|
+
emit('insertBranchAction', payload.conditionIndex, payload.branch, payload.action);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
emit('insertAction', payload.afterIndex, payload.action);
|
|
155
|
+
}
|
|
156
|
+
</script>
|
|
157
|
+
|
|
158
|
+
<template>
|
|
159
|
+
<div class="appium-flow-canvas appium-flow-canvas--vue">
|
|
160
|
+
<VueFlow
|
|
161
|
+
:id="id"
|
|
162
|
+
class="appium-vue-flow"
|
|
163
|
+
:nodes="graph.nodes"
|
|
164
|
+
:edges="graph.edges"
|
|
165
|
+
:fit-view-on-init="true"
|
|
166
|
+
:min-zoom="0.35"
|
|
167
|
+
:max-zoom="1.8"
|
|
168
|
+
:nodes-draggable="false"
|
|
169
|
+
:nodes-connectable="false"
|
|
170
|
+
:elements-selectable="false"
|
|
171
|
+
:pan-on-drag="true"
|
|
172
|
+
:zoom-on-scroll="true"
|
|
173
|
+
zoom-activation-key-code="Control"
|
|
174
|
+
:zoom-on-double-click="false"
|
|
175
|
+
:prevent-scrolling="false"
|
|
176
|
+
no-drag-class-name="nodrag"
|
|
177
|
+
no-pan-class-name="nopan"
|
|
178
|
+
@pane-ready="handlePaneReady"
|
|
179
|
+
>
|
|
180
|
+
<template #node-flow-node="{ id: nodeId, data }">
|
|
181
|
+
<FlowNodeCard
|
|
182
|
+
:node-id="nodeId"
|
|
183
|
+
:data="data"
|
|
184
|
+
:readonly="props.readonly"
|
|
185
|
+
@node-click="emit('nodeClick', $event)"
|
|
186
|
+
@copy="emit('copy', [$event])"
|
|
187
|
+
@remove="emit('remove', $event)"
|
|
188
|
+
@edit-input="emit('editInput', $event)"
|
|
189
|
+
@execute="emit('executeStep', $event)"
|
|
190
|
+
@insert="handleInsert"
|
|
191
|
+
@update-step="emit('updateStep', $event.index, $event.step)"
|
|
192
|
+
@preview-linked-script="emit('previewLinkedScript', $event)"
|
|
193
|
+
@resize="handleNodeResize"
|
|
194
|
+
/>
|
|
195
|
+
</template>
|
|
196
|
+
<template #edge-flow-rounded="edgeProps">
|
|
197
|
+
<FlowRoundedEdge v-bind="edgeProps" />
|
|
198
|
+
</template>
|
|
199
|
+
</VueFlow>
|
|
200
|
+
</div>
|
|
201
|
+
</template>
|