android-midscene-automation 0.1.34 → 0.1.36
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 +10 -0
- package/README.md +10 -0
- package/package.json +2 -1
- package/server/appium-recorder/appium-runner.ts +39 -1
- package/server/appium-recorder/image-check.ts +160 -0
- package/server/appium-recorder/open-gallery.ts +10 -1
- package/server/appium-recorder/report.ts +41 -0
- package/server/appium-recorder/repository.ts +6 -0
- package/server/appium-recorder/routes.ts +19 -0
- package/server/appium-recorder/script-export.ts +1 -1
- package/src/appium-recorder/AppiumPage.vue +112 -8
- package/src/appium-recorder/action-descriptions.ts +46 -0
- package/src/appium-recorder/api.ts +5 -0
- package/src/appium-recorder/components/ComponentInspector.vue +9 -2
- package/src/appium-recorder/components/FlowActionMenu.vue +26 -5
- package/src/appium-recorder/components/FlowCanvas.vue +5 -4
- package/src/appium-recorder/components/FlowNodeCard.vue +9 -7
- package/src/appium-recorder/components/FlowStepEditor.vue +23 -4
- package/src/appium-recorder/components/ImageCheckDialog.vue +143 -0
- package/src/appium-recorder/components/MergeBranchesDialog.vue +31 -3
- package/src/appium-recorder/components/NestedConditionBranches.vue +6 -2
- package/src/appium-recorder/components/RecordedSteps.vue +7 -6
- package/src/appium-recorder/flow-copy.ts +6 -0
- package/src/appium-recorder/flow-graph.ts +22 -10
- package/src/appium-recorder/flow-insert.ts +27 -0
- package/src/appium-recorder/flow-labels.ts +4 -1
- package/src/appium-recorder/flow-merge.ts +12 -0
- package/src/appium-recorder/flow-unmerge.ts +145 -0
- package/src/appium-recorder/image-check.ts +117 -0
- package/src/appium-recorder/types.ts +15 -0
- package/src/appium-recorder/visual-change-pairs.ts +2 -0
- package/src/appium-recorder/visual-change.ts +13 -1
|
@@ -10,6 +10,7 @@ import LoopSettings from './components/LoopSettings.vue';
|
|
|
10
10
|
import BreakLoopSettings from './components/BreakLoopSettings.vue';
|
|
11
11
|
import VisualChangeEndPicker from './components/VisualChangeEndPicker.vue';
|
|
12
12
|
import PresetVariables from './components/PresetVariables.vue';
|
|
13
|
+
import { insertBeforeSharedStep } from './flow-insert';
|
|
13
14
|
import VariableExtractionSettings from './components/VariableExtractionSettings.vue';
|
|
14
15
|
import ScriptParameterSettings from './components/ScriptParameterSettings.vue';
|
|
15
16
|
import { validateVariables, validateExtraction, validateReturns, type TestVariable } from './variables';
|
|
@@ -39,6 +40,8 @@ import { DEFAULT_LOG_PREFIX, validateStageLog } from './stage-log';
|
|
|
39
40
|
import RecordedSteps from './components/RecordedSteps.vue';
|
|
40
41
|
import NewScriptDialog from './components/NewScriptDialog.vue';
|
|
41
42
|
import VisualChangeDialog from './components/VisualChangeDialog.vue';
|
|
43
|
+
import ImageCheckDialog from './components/ImageCheckDialog.vue';
|
|
44
|
+
import { createImageCheckConfig, validateImageCheck, type ImageCheckConfig } from './image-check';
|
|
42
45
|
import AiRecognitionTestDialog from './components/AiRecognitionTestDialog.vue';
|
|
43
46
|
import { validateAiRecognitionPrompt } from './ai-recognition';
|
|
44
47
|
import {
|
|
@@ -57,6 +60,7 @@ import {
|
|
|
57
60
|
boundsToVisualRegion,
|
|
58
61
|
createStepId,
|
|
59
62
|
normalizeVisualChangeConfig,
|
|
63
|
+
syncVisualChangeThreshold,
|
|
60
64
|
visualRegionToBounds,
|
|
61
65
|
} from './visual-change';
|
|
62
66
|
import {
|
|
@@ -77,6 +81,7 @@ type LegacyFlow = NonNullable<AppiumRecordedStep['flow']> & { scope?: string };
|
|
|
77
81
|
type SwipeGesture = NonNullable<AppiumRecordedStep['swipe']>;
|
|
78
82
|
type SwipeForm = SwipeGesture & { direction: string };
|
|
79
83
|
type BranchTarget = {
|
|
84
|
+
beforeStepId?: string;
|
|
80
85
|
stepId: string;
|
|
81
86
|
branch: BranchName;
|
|
82
87
|
updateTarget?: boolean;
|
|
@@ -105,6 +110,7 @@ const insertableRecorderActions: RecorderAction[] = [
|
|
|
105
110
|
'checkedState',
|
|
106
111
|
'radioButtonState',
|
|
107
112
|
'aiRecognition',
|
|
113
|
+
'imageCheck',
|
|
108
114
|
'textClick',
|
|
109
115
|
'runScript',
|
|
110
116
|
'keyBack',
|
|
@@ -177,6 +183,7 @@ const currentActivity = shallowRef('');
|
|
|
177
183
|
const replayOutput = shallowRef('');
|
|
178
184
|
const replayOutputDialogVisible = shallowRef(false);
|
|
179
185
|
const linkedScriptDialogVisible = shallowRef(false);
|
|
186
|
+
const linkedScriptEditingId = shallowRef('');
|
|
180
187
|
const linkedScriptTargetId = shallowRef('');
|
|
181
188
|
const linkedParameters = reactive<AppiumRecordedStep>({ id: 'link-parameters', type: 'runScript', label: '', parameters: [], returns: [] });
|
|
182
189
|
const linkedScriptInsertIndex = shallowRef<number | undefined>();
|
|
@@ -186,6 +193,11 @@ const linkedScriptPreviewVisible = shallowRef(false);
|
|
|
186
193
|
const linkedScriptPreviewScriptId = shallowRef('');
|
|
187
194
|
const linkedScriptPreviewResetToken = shallowRef(0);
|
|
188
195
|
const visualChangeDialogVisible = shallowRef(false);
|
|
196
|
+
const imageCheckDraft = shallowRef<AppiumRecordedStep | null>(null);
|
|
197
|
+
let imageCheckIndex: number | undefined;
|
|
198
|
+
let imageCheckBranch: BranchTarget | undefined;
|
|
199
|
+
let imageCheckEditing = false;
|
|
200
|
+
const imageCheckPicking = shallowRef(false);
|
|
189
201
|
const visualChangePicking = shallowRef(false);
|
|
190
202
|
const visualChangeInsertIndex = shallowRef<number | undefined>();
|
|
191
203
|
const visualChangeBranchTarget = shallowRef<BranchTarget | null>(null);
|
|
@@ -291,11 +303,12 @@ const selectedBounds = computed(() => {
|
|
|
291
303
|
return bounds && selectedNode.value ? { id: selectedNode.value.id, ...bounds } : undefined;
|
|
292
304
|
});
|
|
293
305
|
const visualChangeSelectedRegion = computed(() => {
|
|
306
|
+
if (imageCheckDraft.value?.imageCheck?.target === 'region') return visualRegionToBounds('image-check-region', imageCheckDraft.value.imageCheck.region);
|
|
294
307
|
if (!visualChangeDialogVisible.value || visualChangeConfig.value.mode !== 'region') return undefined;
|
|
295
308
|
return visualRegionToBounds('visual-change-region', visualChangeConfig.value.region);
|
|
296
309
|
});
|
|
297
310
|
const visualChangeRegionSelectionEnabled = computed(() => (
|
|
298
|
-
visualChangeDialogVisible.value && visualChangeConfig.value.mode === 'region'
|
|
311
|
+
(visualChangeDialogVisible.value && visualChangeConfig.value.mode === 'region') || imageCheckDraft.value?.imageCheck?.target === 'region'
|
|
299
312
|
));
|
|
300
313
|
|
|
301
314
|
function stepWithBranchTarget(step: AppiumRecordedStep, branchTarget: BranchTarget) {
|
|
@@ -311,6 +324,10 @@ function stepWithBranchTarget(step: AppiumRecordedStep, branchTarget: BranchTarg
|
|
|
311
324
|
}
|
|
312
325
|
|
|
313
326
|
function insertStep(step: AppiumRecordedStep, index?: number, branchTarget?: BranchTarget) {
|
|
327
|
+
if (branchTarget?.beforeStepId) {
|
|
328
|
+
steps.value = insertBeforeSharedStep(steps.value, branchTarget.beforeStepId, [step], [step.id]);
|
|
329
|
+
return steps.value.find(item => item.id === step.id)!;
|
|
330
|
+
}
|
|
314
331
|
let nextSteps = [...steps.value];
|
|
315
332
|
let insertedStep = branchTarget ? stepWithBranchTarget(step, branchTarget) : step;
|
|
316
333
|
if (index === -1) {
|
|
@@ -369,7 +386,7 @@ function copyFlowNodes(indexes: number[]) {
|
|
|
369
386
|
}
|
|
370
387
|
}
|
|
371
388
|
|
|
372
|
-
function pasteFlowNodes(index: number, branch?: BranchName) {
|
|
389
|
+
function pasteFlowNodes(index: number, branch?: BranchName, beforeStepId?: string) {
|
|
373
390
|
if (!flowClipboard.value) {
|
|
374
391
|
ElMessage.warning('请先复制节点');
|
|
375
392
|
return;
|
|
@@ -382,6 +399,7 @@ function pasteFlowNodes(index: number, branch?: BranchName) {
|
|
|
382
399
|
const nextSteps = pasteFlowClipboard(steps.value, flowClipboard.value, {
|
|
383
400
|
afterIndex: index,
|
|
384
401
|
branch,
|
|
402
|
+
beforeStepId,
|
|
385
403
|
});
|
|
386
404
|
validateLoopSteps(nextSteps);
|
|
387
405
|
steps.value = nextSteps;
|
|
@@ -631,6 +649,35 @@ function defaultVisualChangeRegion(): AppiumVisualChangeRegion {
|
|
|
631
649
|
};
|
|
632
650
|
}
|
|
633
651
|
|
|
652
|
+
function updateImageCheck(config: ImageCheckConfig) {
|
|
653
|
+
if (imageCheckDraft.value) imageCheckDraft.value = { ...imageCheckDraft.value, imageCheck: config };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function useImageCheckElement() {
|
|
657
|
+
const node = selectedNode.value;
|
|
658
|
+
if (!node?.bounds || !imageCheckDraft.value?.imageCheck) return;
|
|
659
|
+
const located = createNodeActionStep('imageCheck', '图像判断', node);
|
|
660
|
+
imageCheckDraft.value = { ...imageCheckDraft.value, selector: located.selector, contextSelector: located.contextSelector,
|
|
661
|
+
selectorChain: located.selectorChain, fallback: undefined,
|
|
662
|
+
imageCheck: { ...imageCheckDraft.value.imageCheck, target: 'element', region: boundsToVisualRegion(node.bounds) } };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function confirmImageCheck() {
|
|
666
|
+
const draft = imageCheckDraft.value;
|
|
667
|
+
if (!draft || recordingLocked.value) return;
|
|
668
|
+
try {
|
|
669
|
+
validateImageCheck(draft.imageCheck);
|
|
670
|
+
if (draft.imageCheck?.target === 'element' && (!draft.selector || draft.selector.strategy === 'bounds')) throw new Error('请选择具有有效定位信息的组件');
|
|
671
|
+
if (imageCheckEditing) {
|
|
672
|
+
const index = steps.value.findIndex(step => step.id === draft.id);
|
|
673
|
+
if (index < 0) throw new Error('原节点已删除');
|
|
674
|
+
updateStep(index, draft);
|
|
675
|
+
} else insertStep(draft, imageCheckIndex, imageCheckBranch);
|
|
676
|
+
imageCheckDraft.value = null;
|
|
677
|
+
imageCheckPicking.value = false;
|
|
678
|
+
} catch (error) { ElMessage.warning((error as Error).message); }
|
|
679
|
+
}
|
|
680
|
+
|
|
634
681
|
function currentVisualChangeConfig() {
|
|
635
682
|
const bounds = selectedNode.value?.bounds;
|
|
636
683
|
return normalizeVisualChangeConfig({
|
|
@@ -1121,6 +1168,7 @@ async function addSwipeStep(index?: number, branchTarget?: BranchTarget) {
|
|
|
1121
1168
|
}
|
|
1122
1169
|
|
|
1123
1170
|
function openLinkedScriptDialog(index?: number, branchTarget?: BranchTarget) {
|
|
1171
|
+
linkedScriptEditingId.value = '';
|
|
1124
1172
|
if (!linkableScripts.value.length) {
|
|
1125
1173
|
ElMessage.warning('暂无可连接的已保存脚本');
|
|
1126
1174
|
return;
|
|
@@ -1170,7 +1218,14 @@ function addLinkedScriptStep() {
|
|
|
1170
1218
|
);
|
|
1171
1219
|
return;
|
|
1172
1220
|
}
|
|
1173
|
-
|
|
1221
|
+
const editingIndex = linkedScriptEditingId.value ? steps.value.findIndex(step => step.id === linkedScriptEditingId.value) : -1;
|
|
1222
|
+
if (linkedScriptEditingId.value && editingIndex < 0) { ElMessage.warning('原节点已删除,请重新打开'); return; }
|
|
1223
|
+
if (editingIndex >= 0) {
|
|
1224
|
+
const original = steps.value[editingIndex]!;
|
|
1225
|
+
updateStep(editingIndex, { ...original, value: script.id,
|
|
1226
|
+
...(original.label.startsWith('连接脚本 ') ? { label: `连接脚本 ${script.name}` } : {}),
|
|
1227
|
+
parameters: JSON.parse(JSON.stringify(linkedParameters.parameters || [])), returns: JSON.parse(JSON.stringify(linkedParameters.returns || [])) });
|
|
1228
|
+
} else insertStep(
|
|
1174
1229
|
{ ...createRunScriptStep(script), parameters: JSON.parse(JSON.stringify(linkedParameters.parameters || [])), returns: JSON.parse(JSON.stringify(linkedParameters.returns || [])) },
|
|
1175
1230
|
linkedScriptInsertIndex.value,
|
|
1176
1231
|
linkedScriptBranchTarget.value || undefined,
|
|
@@ -1179,10 +1234,12 @@ function addLinkedScriptStep() {
|
|
|
1179
1234
|
linkedScriptBranchTarget.value = null;
|
|
1180
1235
|
linkedScriptExpectedActivity.value = '';
|
|
1181
1236
|
linkedScriptDialogVisible.value = false;
|
|
1182
|
-
ElMessage.success('已添加连接脚本步骤');
|
|
1237
|
+
ElMessage.success(editingIndex >= 0 ? '已修改连接脚本' : '已添加连接脚本步骤');
|
|
1238
|
+
linkedScriptEditingId.value = '';
|
|
1183
1239
|
}
|
|
1184
1240
|
|
|
1185
1241
|
function closeLinkedScriptDialog() {
|
|
1242
|
+
linkedScriptEditingId.value = '';
|
|
1186
1243
|
linkedScriptDialogVisible.value = false;
|
|
1187
1244
|
linkedScriptInsertIndex.value = undefined;
|
|
1188
1245
|
linkedScriptBranchTarget.value = null;
|
|
@@ -1245,6 +1302,11 @@ function pickVisualChangeRegion() {
|
|
|
1245
1302
|
}
|
|
1246
1303
|
|
|
1247
1304
|
function applyVisualChangeRegion(region: AppiumVisualChangeRegion) {
|
|
1305
|
+
if (imageCheckDraft.value?.imageCheck) {
|
|
1306
|
+
updateImageCheck({ ...imageCheckDraft.value.imageCheck, region, screenWidth: props.deviceWidth || 0, screenHeight: props.deviceHeight || 0 });
|
|
1307
|
+
imageCheckPicking.value = false;
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1248
1310
|
visualChangeConfig.value = normalizeVisualChangeConfig({
|
|
1249
1311
|
...visualChangeConfig.value,
|
|
1250
1312
|
mode: 'region',
|
|
@@ -1282,6 +1344,13 @@ function isReadonlyFlowCopySelected(_index: number) {
|
|
|
1282
1344
|
return false;
|
|
1283
1345
|
}
|
|
1284
1346
|
|
|
1347
|
+
function addSharedAction(action: RecorderAction, index: number, beforeStepId?: string) {
|
|
1348
|
+
if (!beforeStepId) return addAction(action, index);
|
|
1349
|
+
const target = steps.value.find(step => step.id === beforeStepId);
|
|
1350
|
+
if (!target) return;
|
|
1351
|
+
return addAction(action, index, { beforeStepId, stepId: target.flow?.parentConditionId || '', branch: target.flow?.parentBranch || 'yes' });
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1285
1354
|
async function addAction(
|
|
1286
1355
|
action: RecorderAction,
|
|
1287
1356
|
index?: number,
|
|
@@ -1350,6 +1419,16 @@ async function addAction(
|
|
|
1350
1419
|
if (result) return insertStep({ ...step, breakLoopTargetId: scoped.breakLoopTargetId }, index, branchTarget);
|
|
1351
1420
|
return;
|
|
1352
1421
|
}
|
|
1422
|
+
if (action === 'imageCheck') {
|
|
1423
|
+
imageCheckIndex = index;
|
|
1424
|
+
imageCheckBranch = branchTarget;
|
|
1425
|
+
imageCheckEditing = false;
|
|
1426
|
+
imageCheckPicking.value = false;
|
|
1427
|
+
imageCheckDraft.value = { id: createStepId(), type: 'imageCheck', label: '图像判断', flow: { nodeKind: 'condition' },
|
|
1428
|
+
imageCheck: { ...createImageCheckConfig(), region: defaultVisualChangeRegion(), screenWidth: props.deviceWidth || 0, screenHeight: props.deviceHeight || 0 } };
|
|
1429
|
+
if (selectedNode.value?.bounds) useImageCheckElement();
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1353
1432
|
if (action === 'aiRecognition') {
|
|
1354
1433
|
const input = await ElMessageBox.prompt('识别内容', '添加 AI 识别', {
|
|
1355
1434
|
inputType: 'textarea',
|
|
@@ -1724,11 +1803,28 @@ function updateStep(index: number, step: AppiumRecordedStep) {
|
|
|
1724
1803
|
? syncVisualChangePairConfig(candidate, visualChange)
|
|
1725
1804
|
: candidate
|
|
1726
1805
|
));
|
|
1727
|
-
steps.value = nextSteps;
|
|
1806
|
+
steps.value = syncVisualChangeThreshold(nextSteps, step);
|
|
1728
1807
|
}
|
|
1729
1808
|
|
|
1730
1809
|
async function editInputStep(index: number) {
|
|
1731
1810
|
const step = steps.value[index];
|
|
1811
|
+
if (step?.type === 'runScript' && !recordingBusy.value) {
|
|
1812
|
+
const parentId = step.flow?.parentConditionId;
|
|
1813
|
+
openLinkedScriptDialog(index - 1, parentId && step.flow?.parentBranch ? { stepId: parentId, branch: step.flow.parentBranch } : undefined);
|
|
1814
|
+
if (!linkedScriptDialogVisible.value) return;
|
|
1815
|
+
linkedScriptEditingId.value = step.id;
|
|
1816
|
+
linkedScriptTargetId.value = step.value || '';
|
|
1817
|
+
linkedParameters.parameters = JSON.parse(JSON.stringify(step.parameters || []));
|
|
1818
|
+
linkedParameters.returns = JSON.parse(JSON.stringify(step.returns || []));
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1821
|
+
if (step?.type === 'imageCheck' && !recordingBusy.value) {
|
|
1822
|
+
imageCheckIndex = index;
|
|
1823
|
+
imageCheckEditing = true;
|
|
1824
|
+
imageCheckPicking.value = false;
|
|
1825
|
+
imageCheckDraft.value = JSON.parse(JSON.stringify(step));
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1732
1828
|
if (!step || (step.type !== 'input' && step.type !== 'inputIfExists') || recordingBusy.value) return;
|
|
1733
1829
|
const input = await ElMessageBox.prompt('', '修改输入内容', {
|
|
1734
1830
|
inputValue: step.value || '',
|
|
@@ -2142,7 +2238,7 @@ watch(
|
|
|
2142
2238
|
:selected-bounds="selectedBounds"
|
|
2143
2239
|
:selected-region="visualChangeSelectedRegion"
|
|
2144
2240
|
:region-selection="visualChangeRegionSelectionEnabled"
|
|
2145
|
-
:region-draw-mode="visualChangePicking"
|
|
2241
|
+
:region-draw-mode="visualChangePicking || imageCheckPicking"
|
|
2146
2242
|
:device-width="deviceWidth"
|
|
2147
2243
|
:device-height="deviceHeight"
|
|
2148
2244
|
@switch-device="switchDevice"
|
|
@@ -2216,7 +2312,7 @@ watch(
|
|
|
2216
2312
|
@copy="copyFlowNodes"
|
|
2217
2313
|
@paste="pasteFlowNodes"
|
|
2218
2314
|
@add-delay="addDelayStep"
|
|
2219
|
-
@insert-action="(index, action) =>
|
|
2315
|
+
@insert-action="(index, action, beforeStepId) => addSharedAction(action, index, beforeStepId)"
|
|
2220
2316
|
@insert-branch-action="addBranchAction"
|
|
2221
2317
|
@edit-input="editInputStep"
|
|
2222
2318
|
@preview-linked-script="previewLinkedScriptStep"
|
|
@@ -2331,6 +2427,14 @@ watch(
|
|
|
2331
2427
|
@close="aiRecognitionTestStep = null"
|
|
2332
2428
|
/>
|
|
2333
2429
|
|
|
2430
|
+
<ImageCheckDialog
|
|
2431
|
+
v-if="imageCheckDraft?.imageCheck"
|
|
2432
|
+
:config="imageCheckDraft.imageCheck" :device-id="selectedDeviceId"
|
|
2433
|
+
:has-element="Boolean(selectedNode?.bounds)" :editing="imageCheckEditing" :picking="imageCheckPicking"
|
|
2434
|
+
@update="updateImageCheck" @close="imageCheckDraft = null; imageCheckPicking = false"
|
|
2435
|
+
@confirm="confirmImageCheck" @use-element="useImageCheckElement"
|
|
2436
|
+
@pick="imageCheckPicking = true; workspaceRef?.showPreview()"
|
|
2437
|
+
/>
|
|
2334
2438
|
<VisualChangeDialog
|
|
2335
2439
|
:model-value="visualChangeDialogVisible"
|
|
2336
2440
|
:config="visualChangeConfig"
|
|
@@ -2409,7 +2513,7 @@ watch(
|
|
|
2409
2513
|
</el-form>
|
|
2410
2514
|
<template #footer>
|
|
2411
2515
|
<el-button @click="closeLinkedScriptDialog">取消</el-button>
|
|
2412
|
-
<el-button type="primary" @click="addLinkedScriptStep"
|
|
2516
|
+
<el-button type="primary" @click="addLinkedScriptStep">{{ linkedScriptEditingId ? '保存' : '添加连接' }}</el-button>
|
|
2413
2517
|
</template>
|
|
2414
2518
|
</el-dialog>
|
|
2415
2519
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { InsertAction } from './flow-graph';
|
|
2
|
+
|
|
3
|
+
// 普通画布、嵌套分支及流程总览共用说明,新增操作时必须补齐。
|
|
4
|
+
export const actionDescriptions: Record<InsertAction, string> = {
|
|
5
|
+
imageCheck: '使用 OpenCV 在本地判断指定区域,不调用 AI。适合固定图标、图片勾选状态、黑屏、颜色及多帧变化;不适合业务语义或任意文字识别,不能仅凭外观证明按钮可用或视频卡死。原生属性可读取时优先使用属性判断,证据不足报告无法判定。',
|
|
6
|
+
tap: '记录点击当前选中的组件,回放时重新定位后点击。',
|
|
7
|
+
input: '向当前选中的输入组件写入指定文字,支持 {{变量名}} 引用。',
|
|
8
|
+
extractVariable: '读取组件文字或属性,保存到当前脚本变量供后续节点引用。',
|
|
9
|
+
clearInput: '清空当前选中输入组件的内容。',
|
|
10
|
+
coordinateTap: '点击设备屏幕上的指定 X、Y 坐标。节点中可修改坐标,页面位置变化后需重新确认。',
|
|
11
|
+
longPress: '长按指定元素或屏幕坐标,可设置按住时长,默认按元素定位。',
|
|
12
|
+
keyBack: '执行 Android 系统返回操作。',
|
|
13
|
+
keyHome: '按 Home 键返回设备桌面,不等同于关闭应用。',
|
|
14
|
+
keyRecent: '打开 Android 最近任务列表。',
|
|
15
|
+
keyPower: '发送电源键操作,实际锁屏或唤醒行为由设备当前状态决定。',
|
|
16
|
+
swipe: '按指定起点、终点和时长滑动屏幕。',
|
|
17
|
+
pinch: '执行双指放大或缩小手势,需要当前页面支持缩放。',
|
|
18
|
+
launchApp: '启动指定应用,可放在流程中途或分支中执行。',
|
|
19
|
+
openGallery: '打开设备相册应用,不自动选择照片或视频。',
|
|
20
|
+
endFlow: '立即结束当前回放流程,后续节点不再执行。',
|
|
21
|
+
loop: '重复执行循环体,支持固定次数或条件退出,必须设置最大次数。',
|
|
22
|
+
breakLoop: '退出指定的当前或外层循环,继续执行该循环的结束路径。',
|
|
23
|
+
clearAppData: '清除目标应用数据并重置应用状态,可能删除登录信息和本地设置,仅允许在流程开始处添加。',
|
|
24
|
+
popupCondition: '判断目标组件是否存在,可进一步配置指定文本及精确或模糊匹配,按结果进入是或否分支。',
|
|
25
|
+
checkboxState: '读取原生 Checkbox 的 checked 状态,按 true/false 分支执行。',
|
|
26
|
+
radioButtonState: '读取原生 RadioButton 的 checked 状态,按 true/false 分支执行。',
|
|
27
|
+
checkedState: '读取 Checkbox、RadioButton 或 Switch 的真实勾选状态,按 true/false 分支执行。不支持仅用图片表示状态的普通 ImageView。',
|
|
28
|
+
aiRecognition: '用配置的模型判断当前画面,返回 true/false。适合语义、提示含义和复杂画面判断;可直接读取的组件属性优先使用原生判断。需要配置 AI 节点模型。',
|
|
29
|
+
textClick: '在组件树中按指定文字精确或模糊匹配并点击,进入匹配到文字或未匹配到文字分支。不识别图片或自绘画面中的文字。',
|
|
30
|
+
tapIfExists: '找到目标组件时点击,未找到则跳过并继续后续流程。',
|
|
31
|
+
inputIfExists: '找到目标输入组件时写入文字,未找到则跳过。支持 {{变量名}} 引用。',
|
|
32
|
+
clearIfExists: '找到目标输入组件时清空内容,未找到则跳过。',
|
|
33
|
+
backIfExists: '目标组件出现时执行系统返回,未出现则跳过。',
|
|
34
|
+
waitFor: '等待目标组件出现,超过配置的等待时间仍未出现则失败。',
|
|
35
|
+
assertExists: '校验目标组件是否存在,不满足时记录执行失败。旧脚本兼容操作。',
|
|
36
|
+
assertText: '校验组件文字是否满足预期,不满足时记录执行失败。旧脚本兼容操作。',
|
|
37
|
+
waitDisappear: '等待目标组件消失,超时仍存在则失败。',
|
|
38
|
+
waitActivity: '等待设备切换到指定的 Activity,适合确认页面跳转是否完成。',
|
|
39
|
+
delay: '暂停指定的毫秒数后继续执行,不判断页面是否已经准备完成。',
|
|
40
|
+
runScript: '执行连接的子脚本,支持显式传入参数并将返回变量映射到当前脚本。',
|
|
41
|
+
noop: '不执行设备操作,作为流程占位节点。',
|
|
42
|
+
log: '输出自定义内容和日志关键字,默认前缀为 stageLog,支持 {{变量名}} 引用。',
|
|
43
|
+
visualChangeStart: '截取指定组件或区域作为基准,与对应结束节点的截图比较。未添加对应结束节点时不比较。',
|
|
44
|
+
visualChangeEnd: '与同一可达路径上对应的开始截图比较,报告变化结果;多组检测可选择对应的开始节点。',
|
|
45
|
+
visualChange: '比较指定区域的截图变化,不直接判断业务状态或图片单选框是否勾选。',
|
|
46
|
+
};
|
|
@@ -2,6 +2,11 @@ import { APP_BASE } from '../api';
|
|
|
2
2
|
import type { AppiumRecordedScript, AppiumRecordedStep } from './types';
|
|
3
3
|
import type { AiRecognitionResult } from './ai-recognition';
|
|
4
4
|
import type { TestVariable } from './variables';
|
|
5
|
+
import type { AppiumVisualChangeRegion } from './types';
|
|
6
|
+
|
|
7
|
+
export function captureImageCheckRegion(deviceId: string, region: AppiumVisualChangeRegion, screenWidth: number, screenHeight: number) {
|
|
8
|
+
return postJson<{ base64: string; screenWidth: number; screenHeight: number }>(`${APP_BASE}/api/appium-recorder/image-check/capture`, { deviceId, region, screenWidth, screenHeight });
|
|
9
|
+
}
|
|
5
10
|
|
|
6
11
|
// 队列跨组件挂载保留,切换脚本或清空草稿时仍按编辑顺序写入。
|
|
7
12
|
const variableWrites = new Map<string, Promise<unknown>>();
|
|
@@ -12,7 +12,13 @@ defineEmits<{ refresh: []; select: [node: AppiumNode] }>();
|
|
|
12
12
|
<template #header><div class="panel-header"><span>App 组件树</span><el-tooltip content="刷新组件树" placement="top" :show-after="200"><el-button class="recorder-panel-tool" :icon="Refresh" size="small" aria-label="刷新组件树" :loading="loading" :disabled="disabled" @click="$emit('refresh')" /></el-tooltip></div></template>
|
|
13
13
|
<ComponentTree :tree="tree" :selected-id="node?.id || ''" @select="$emit('select', $event)" />
|
|
14
14
|
<NodeDetail :node="node" :current-activity="activity" :loading="recording" />
|
|
15
|
-
<div class="component-inspector__activity"
|
|
15
|
+
<div class="component-inspector__activity">
|
|
16
|
+
<span>Activity</span>
|
|
17
|
+
<el-tooltip :content="activity" :disabled="!activity" placement="top" effect="dark" :show-after="200"
|
|
18
|
+
:popper-style="{ maxWidth: 'min(480px, calc(100vw - 24px))', overflowWrap: 'anywhere', fontSize: '12px' }">
|
|
19
|
+
<code :tabindex="activity ? 0 : undefined">{{ activity || '-' }}</code>
|
|
20
|
+
</el-tooltip>
|
|
21
|
+
</div>
|
|
16
22
|
</el-card>
|
|
17
23
|
</template>
|
|
18
24
|
|
|
@@ -22,5 +28,6 @@ defineEmits<{ refresh: []; select: [node: AppiumNode] }>();
|
|
|
22
28
|
.component-inspector :deep(.appium-node-summary) { flex: none; }
|
|
23
29
|
.panel-header { font-size: 14px; gap: 4px; }
|
|
24
30
|
.component-inspector__activity { flex: none; display: flex; min-width: 0; gap: 6px; padding-top: 8px; border-top: 1px solid #e4e7ed; font-size: 12px; color: #606266; }
|
|
25
|
-
.component-inspector__activity
|
|
31
|
+
.component-inspector__activity > span { flex-shrink: 0; }
|
|
32
|
+
.component-inspector__activity code { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
26
33
|
</style>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { nextTick, shallowRef } from 'vue';
|
|
3
3
|
import type { MenuInstance, PopoverInstance } from 'element-plus';
|
|
4
4
|
import { PASTE_COMMAND, type FlowActionGroup, type InsertAction } from '../flow-graph';
|
|
5
|
+
import { actionDescriptions } from '../action-descriptions';
|
|
5
6
|
|
|
6
7
|
const props = defineProps<{
|
|
7
8
|
groups: FlowActionGroup[];
|
|
@@ -118,14 +119,25 @@ function selectAction(value: string) {
|
|
|
118
119
|
@click="openGroupOnClick($event, group.title)"
|
|
119
120
|
>
|
|
120
121
|
<template #title>{{ group.title }}</template>
|
|
121
|
-
<el-
|
|
122
|
+
<el-tooltip
|
|
122
123
|
v-for="action in group.actions"
|
|
123
124
|
:key="action.type"
|
|
124
|
-
:
|
|
125
|
-
|
|
125
|
+
:content="actionDescriptions[action.type]"
|
|
126
|
+
placement="right"
|
|
127
|
+
effect="dark"
|
|
128
|
+
:show-after="350"
|
|
129
|
+
:hide-after="0"
|
|
130
|
+
:enterable="false"
|
|
131
|
+
:popper-options="{ modifiers: [{ name: 'preventOverflow', options: { altAxis: true, padding: 8 } }] }"
|
|
132
|
+
popper-class="appium-action-description"
|
|
126
133
|
>
|
|
127
|
-
|
|
128
|
-
|
|
134
|
+
<el-menu-item
|
|
135
|
+
:index="action.type"
|
|
136
|
+
:disabled="isActionDisabled(action.type)"
|
|
137
|
+
>
|
|
138
|
+
{{ action.label }}
|
|
139
|
+
</el-menu-item>
|
|
140
|
+
</el-tooltip>
|
|
129
141
|
</el-sub-menu>
|
|
130
142
|
<el-menu-item v-if="clipboardCount" :index="PASTE_COMMAND" class="appium-action-paste">
|
|
131
143
|
粘贴 {{ clipboardCount }} 个节点
|
|
@@ -137,6 +149,15 @@ function selectAction(value: string) {
|
|
|
137
149
|
</template>
|
|
138
150
|
|
|
139
151
|
<style>
|
|
152
|
+
.appium-action-description.el-popper {
|
|
153
|
+
max-width: min(300px, calc(100vw - 32px));
|
|
154
|
+
padding: 8px 10px;
|
|
155
|
+
font-size: 12px;
|
|
156
|
+
line-height: 1.6;
|
|
157
|
+
overflow-wrap: anywhere;
|
|
158
|
+
pointer-events: none;
|
|
159
|
+
}
|
|
160
|
+
|
|
140
161
|
.appium-action-menu-trigger {
|
|
141
162
|
display: inline-flex;
|
|
142
163
|
}
|
|
@@ -47,9 +47,9 @@ const emit = defineEmits<{
|
|
|
47
47
|
nodeClick: [index: number];
|
|
48
48
|
merge: [index: number];
|
|
49
49
|
copy: [indexes: number[]];
|
|
50
|
-
paste: [index: number, branch?: FlowBranch];
|
|
50
|
+
paste: [index: number, branch?: FlowBranch, beforeStepId?: string];
|
|
51
51
|
remove: [index: number];
|
|
52
|
-
insertAction: [index: number, action: InsertAction];
|
|
52
|
+
insertAction: [index: number, action: InsertAction, beforeStepId?: string];
|
|
53
53
|
insertBranchAction: [index: number, branch: FlowBranch, action: InsertAction];
|
|
54
54
|
editInput: [index: number];
|
|
55
55
|
previewLinkedScript: [index: number];
|
|
@@ -180,16 +180,17 @@ function handleInsert(payload: {
|
|
|
180
180
|
action: InsertAction | typeof PASTE_COMMAND;
|
|
181
181
|
branch?: FlowBranch;
|
|
182
182
|
conditionIndex?: number;
|
|
183
|
+
beforeStepId?: string;
|
|
183
184
|
}) {
|
|
184
185
|
if (payload.action === PASTE_COMMAND) {
|
|
185
|
-
emit('paste', payload.afterIndex, payload.branch);
|
|
186
|
+
emit('paste', payload.afterIndex, payload.branch, payload.beforeStepId);
|
|
186
187
|
return;
|
|
187
188
|
}
|
|
188
189
|
if (payload.branch && payload.conditionIndex !== undefined) {
|
|
189
190
|
emit('insertBranchAction', payload.afterIndex, payload.branch, payload.action);
|
|
190
191
|
return;
|
|
191
192
|
}
|
|
192
|
-
emit('insertAction', payload.afterIndex, payload.action);
|
|
193
|
+
emit('insertAction', payload.afterIndex, payload.action, payload.beforeStepId);
|
|
193
194
|
}
|
|
194
195
|
</script>
|
|
195
196
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue';
|
|
3
3
|
import { Handle, Position } from '@vue-flow/core';
|
|
4
|
-
import { CopyDocument, Delete, Edit, Plus, VideoPlay, View, WarningFilled, Connection } from '@element-plus/icons-vue';
|
|
4
|
+
import { CopyDocument, Delete, Edit, Plus, VideoPlay, View, WarningFilled, Connection, RefreshLeft } from '@element-plus/icons-vue';
|
|
5
5
|
import { AI_MODEL_CONFIG_HINT } from '../ai-recognition';
|
|
6
6
|
import type { AppiumRecordedStep } from '../types';
|
|
7
7
|
import {
|
|
@@ -29,7 +29,7 @@ const emit = defineEmits<{
|
|
|
29
29
|
editInput: [index: number];
|
|
30
30
|
previewLinkedScript: [index: number];
|
|
31
31
|
execute: [index: number];
|
|
32
|
-
insert: [payload: { afterIndex: number; action: InsertAction | typeof PASTE_COMMAND; branch?: FlowBranch; conditionIndex?: number }];
|
|
32
|
+
insert: [payload: { afterIndex: number; action: InsertAction | typeof PASTE_COMMAND; branch?: FlowBranch; conditionIndex?: number; beforeStepId?: string }];
|
|
33
33
|
updateStep: [payload: { index: number; step: AppiumRecordedStep }];
|
|
34
34
|
resize: [payload: { id: string; height: number }];
|
|
35
35
|
}>();
|
|
@@ -39,6 +39,7 @@ let resizeObserver: ResizeObserver | null = null;
|
|
|
39
39
|
|
|
40
40
|
const insertTitle = computed(() => {
|
|
41
41
|
if (props.data.kind !== 'insert') return '';
|
|
42
|
+
if (props.data.beforeStepId) return '在合流后插入操作';
|
|
42
43
|
if (props.data.branch) return `在“${props.data.branchLabel || (props.data.branch === 'yes' ? '是' : '否')}”分支插入操作`;
|
|
43
44
|
return props.data.afterIndex < 0 ? '在开始后插入操作' : '插入操作';
|
|
44
45
|
});
|
|
@@ -47,6 +48,7 @@ function emitInsert(command: string | number | object) {
|
|
|
47
48
|
if (props.readonly || props.data.kind !== 'insert') return;
|
|
48
49
|
emit('insert', {
|
|
49
50
|
afterIndex: props.data.afterIndex,
|
|
51
|
+
beforeStepId: props.data.beforeStepId,
|
|
50
52
|
action: command === PASTE_COMMAND ? PASTE_COMMAND : command as InsertAction,
|
|
51
53
|
branch: props.data.branch,
|
|
52
54
|
conditionIndex: props.data.conditionIndex,
|
|
@@ -185,9 +187,9 @@ watch(() => props.data, () => {
|
|
|
185
187
|
</span>
|
|
186
188
|
</button>
|
|
187
189
|
<span v-if="!props.readonly && !data.deleteMode" class="appium-flow-step-card__actions nodrag nopan" @click.stop>
|
|
188
|
-
<el-tooltip v-if="data.flowKind === 'condition' && data.step.type !== 'loop' && !data.copyMode" content="合并分支:将两侧后续节点汇入公共流程" placement="top" :show-after="200">
|
|
189
|
-
<span class="appium-node-action-tooltip"><el-button text size="small" :icon="Connection"
|
|
190
|
-
title="合并分支" aria-label="合并分支" :disabled="data.mergeDisabled
|
|
190
|
+
<el-tooltip v-if="data.flowKind === 'condition' && data.step.type !== 'loop' && !data.copyMode" :content="data.step.flow?.successTargetId ? '取消合并:还原分支并保留后续编辑' : '合并分支:将两侧后续节点汇入公共流程'" placement="top" :show-after="200">
|
|
191
|
+
<span class="appium-node-action-tooltip"><el-button text size="small" :icon="data.step.flow?.successTargetId ? RefreshLeft : Connection"
|
|
192
|
+
:title="data.step.flow?.successTargetId ? '取消合并' : '合并分支'" :aria-label="data.step.flow?.successTargetId ? '取消合并' : '合并分支'" :disabled="data.mergeDisabled"
|
|
191
193
|
@click="emit('merge', data.index)" />
|
|
192
194
|
</span></el-tooltip>
|
|
193
195
|
<el-tooltip v-if="!data.copyMode && data.canCopy" content="复制节点:复制后可在插入位置粘贴" placement="top" :show-after="200">
|
|
@@ -213,14 +215,14 @@ watch(() => props.data, () => {
|
|
|
213
215
|
@click="emit('execute', data.index)"
|
|
214
216
|
/>
|
|
215
217
|
</span></el-tooltip>
|
|
216
|
-
<el-tooltip v-if="data.canEditInput" content="修改输入内容:编辑此节点要输入的文字" placement="top" :show-after="200">
|
|
218
|
+
<el-tooltip v-if="data.canEditInput" :content="data.step.type === 'runScript' ? '修改连接脚本' : data.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容:编辑此节点要输入的文字'" placement="top" :show-after="200">
|
|
217
219
|
<span class="appium-node-action-tooltip">
|
|
218
220
|
<el-button
|
|
219
221
|
text
|
|
220
222
|
size="small"
|
|
221
223
|
:icon="Edit"
|
|
222
224
|
:disabled="data.disabled"
|
|
223
|
-
title="修改输入内容"
|
|
225
|
+
:title="data.step.type === 'runScript' ? '修改连接脚本' : data.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容'"
|
|
224
226
|
@click="emit('editInput', data.index)"
|
|
225
227
|
/>
|
|
226
228
|
</span></el-tooltip>
|
|
@@ -5,7 +5,6 @@ import { normalizeVisualChangeConfig } from '../visual-change';
|
|
|
5
5
|
import { longPressMode } from '../long-press';
|
|
6
6
|
import { defaultFlowKind, isBooleanCondition } from '../flow-labels';
|
|
7
7
|
import TextClickSettings from './TextClickSettings.vue';
|
|
8
|
-
import { isNativeStateCondition } from '../native-control-state';
|
|
9
8
|
import LongPressSettings from './LongPressSettings.vue';
|
|
10
9
|
import StageLogSettings from './StageLogSettings.vue';
|
|
11
10
|
import LoopSettings from './LoopSettings.vue';
|
|
@@ -47,7 +46,7 @@ function defaultKind(): FlowKind {
|
|
|
47
46
|
function patchStep(patch: Partial<AppiumRecordedStep>) {
|
|
48
47
|
emit('update', {
|
|
49
48
|
index: props.index,
|
|
50
|
-
step: { ...props.step, ...patch },
|
|
49
|
+
step: { ...props.step, ...patch, ...('contextSelector' in patch ? { selectorChain: undefined } : {}) },
|
|
51
50
|
});
|
|
52
51
|
}
|
|
53
52
|
|
|
@@ -79,6 +78,10 @@ function patchSwipe(patch: Partial<SwipeGesture>) {
|
|
|
79
78
|
});
|
|
80
79
|
}
|
|
81
80
|
|
|
81
|
+
function patchPinch(patch: Partial<NonNullable<AppiumRecordedStep['pinch']>>) {
|
|
82
|
+
patchStep({ pinch: { direction: 'out', centerX: 0, centerY: 0, percent: 0.5, ...props.step.pinch, ...patch } });
|
|
83
|
+
}
|
|
84
|
+
|
|
82
85
|
function patchTapCoordinate(axis: 'centerX' | 'centerY', value: number | undefined) {
|
|
83
86
|
if (value === undefined || !Number.isFinite(value)) return;
|
|
84
87
|
const fallback = {
|
|
@@ -125,7 +128,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
125
128
|
patchStep({
|
|
126
129
|
selector: { ...props.step.selector!, ...patch },
|
|
127
130
|
// 修改元素目标后,不能继续优先使用录制时的备用 XPath。
|
|
128
|
-
|
|
131
|
+
selectorChain: undefined,
|
|
129
132
|
});
|
|
130
133
|
}
|
|
131
134
|
</script>
|
|
@@ -173,6 +176,22 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
173
176
|
</el-form-item>
|
|
174
177
|
</div>
|
|
175
178
|
<LongPressSettings v-if="step.type === 'longPress'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
179
|
+
<el-form-item v-if="['waitActivity', 'launchApp', 'clearAppData'].includes(step.type)" :label="step.type === 'waitActivity' ? '目标 Activity' : '目标 APP 包名'">
|
|
180
|
+
<el-input :model-value="step.value || ''" :disabled="disabled" @update:model-value="patchStep({ value: String($event) })" />
|
|
181
|
+
</el-form-item>
|
|
182
|
+
<template v-if="step.type === 'pinch'">
|
|
183
|
+
<el-form-item label="缩放方向">
|
|
184
|
+
<el-select :model-value="step.pinch?.direction || 'out'" :disabled="disabled" @update:model-value="patchPinch({ direction: $event })">
|
|
185
|
+
<el-option label="放大" value="out" /><el-option label="缩小" value="in" />
|
|
186
|
+
</el-select>
|
|
187
|
+
</el-form-item>
|
|
188
|
+
<el-form-item v-for="axis in (['centerX', 'centerY'] as const)" :key="axis" :label="axis === 'centerX' ? '中心 X' : '中心 Y'">
|
|
189
|
+
<el-input-number :model-value="step.pinch?.[axis] ?? 0" :disabled="disabled" :min="0" :max="99999" :precision="0" controls-position="right" @update:model-value="patchPinch({ [axis]: $event ?? 0 })" />
|
|
190
|
+
</el-form-item>
|
|
191
|
+
<el-form-item label="缩放比例">
|
|
192
|
+
<el-input-number :model-value="step.pinch?.percent ?? 0.5" :disabled="disabled" :min="0.01" :max="1" :step="0.05" controls-position="right" @update:model-value="patchPinch({ percent: $event ?? 0.5 })" />
|
|
193
|
+
</el-form-item>
|
|
194
|
+
</template>
|
|
176
195
|
<div v-if="step.type === 'coordinateTap'" class="appium-flow-editor__grid">
|
|
177
196
|
<el-form-item label="坐标 X">
|
|
178
197
|
<el-input-number :model-value="step.fallback?.centerX ?? 0" :disabled="disabled" :min="0" :max="99999" :precision="0" controls-position="right" aria-label="坐标 X" @update:model-value="patchTapCoordinate('centerX', $event)" />
|
|
@@ -221,7 +240,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
221
240
|
<el-form-item label="变化阈值 %">
|
|
222
241
|
<el-input-number
|
|
223
242
|
:model-value="visualChangeConfig.changeRatioThreshold"
|
|
224
|
-
:disabled="
|
|
243
|
+
:disabled="disabled"
|
|
225
244
|
:min="0.01"
|
|
226
245
|
:max="100"
|
|
227
246
|
:step="0.1"
|