android-midscene-automation 0.1.26 → 0.1.28
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 +18 -0
- package/FAQ.md +14 -0
- package/README.md +39 -3
- package/bin/android-midscene-automation.js +71 -1
- package/package.json +7 -2
- package/remote-agent/index.ts +2 -11
- package/server/appium-recorder/ai-recognition.ts +67 -0
- package/server/appium-recorder/appium-runner.ts +335 -52
- package/server/appium-recorder/report.ts +297 -16
- package/server/appium-recorder/repository.ts +31 -4
- package/server/appium-recorder/routes.ts +31 -32
- package/server/appium-recorder/screenshot.ts +21 -0
- package/server/appium-recorder/script-export.ts +67 -0
- package/server/appium-recorder/tree-dump.ts +22 -0
- package/server/appium-recorder/visual-change.ts +184 -0
- package/server/config.ts +10 -0
- package/server/http-api.ts +4 -5
- package/src/App.vue +14 -7
- package/src/api.ts +1 -1
- package/src/appium-recorder/AppiumPage.vue +658 -162
- package/src/appium-recorder/ai-recognition.ts +16 -0
- package/src/appium-recorder/api.ts +20 -3
- package/src/appium-recorder/components/AiRecognitionTestDialog.vue +71 -0
- package/src/appium-recorder/components/FlowActionMenu.vue +206 -0
- package/src/appium-recorder/components/FlowCanvas.vue +16 -1
- package/src/appium-recorder/components/FlowNodeCard.vue +23 -37
- package/src/appium-recorder/components/FlowStepEditor.vue +152 -25
- package/src/appium-recorder/components/LongPressSettings.vue +58 -0
- package/src/appium-recorder/components/NestedConditionBranches.vue +55 -35
- package/src/appium-recorder/components/NewScriptDialog.vue +27 -0
- package/src/appium-recorder/components/NodeDetail.vue +2 -0
- package/src/appium-recorder/components/RecordedSteps.vue +24 -4
- package/src/appium-recorder/components/StageLogSettings.vue +17 -0
- package/src/appium-recorder/components/TextClickSettings.vue +19 -0
- package/src/appium-recorder/components/VisualChangeDialog.vue +151 -0
- package/src/appium-recorder/flow-copy.ts +47 -1
- package/src/appium-recorder/flow-graph.ts +147 -64
- package/src/appium-recorder/flow-labels.ts +35 -6
- package/src/appium-recorder/flow-normalize.ts +76 -0
- package/src/appium-recorder/flow-remove.ts +69 -0
- package/src/appium-recorder/long-press.ts +27 -0
- package/src/appium-recorder/native-control-state.ts +26 -0
- package/src/appium-recorder/stage-log.ts +21 -0
- package/src/appium-recorder/text-click.ts +9 -0
- package/src/appium-recorder/tree.ts +3 -0
- package/src/appium-recorder/types.ts +34 -2
- package/src/appium-recorder/visual-change-pairs.ts +147 -0
- package/src/appium-recorder/visual-change.ts +75 -0
- package/src/components/config/AppiumConfigPanel.vue +48 -0
- package/src/components/config/BaseConfigPanel.vue +90 -0
- package/src/components/config/MidsceneConfigPanel.vue +204 -0
- package/src/components/device/DevicePreviewPanel.vue +148 -4
- package/src/pages/ConfigPage.vue +107 -265
- package/src/style.css +43 -4
- package/src/types.ts +2 -0
- package/src/ui-refresh.css +5 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { DEFAULT_LOG_PREFIX } from '../stage-log';
|
|
3
|
+
import type { AppiumRecordedStep } from '../types';
|
|
4
|
+
defineProps<{ step: AppiumRecordedStep; disabled?: boolean }>();
|
|
5
|
+
defineEmits<{ update: [patch: Partial<AppiumRecordedStep>] }>();
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<el-form-item label="日志关键字">
|
|
10
|
+
<el-input :model-value="step.logPrefix ?? DEFAULT_LOG_PREFIX" :disabled="disabled" maxlength="100"
|
|
11
|
+
@update:model-value="$emit('update', { logPrefix: String($event) })" />
|
|
12
|
+
</el-form-item>
|
|
13
|
+
<el-form-item label="日志内容">
|
|
14
|
+
<el-input :model-value="step.value || ''" :disabled="disabled" type="textarea" :rows="3" maxlength="4000"
|
|
15
|
+
@update:model-value="$emit('update', { value: String($event) })" />
|
|
16
|
+
</el-form-item>
|
|
17
|
+
</template>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { AppiumRecordedStep } from '../types';
|
|
3
|
+
const props = defineProps<{ step: AppiumRecordedStep; disabled?: boolean }>();
|
|
4
|
+
const emit = defineEmits<{ update: [patch: Partial<AppiumRecordedStep>] }>();
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<el-form-item label="点击文字" required>
|
|
9
|
+
<el-input :model-value="step.value || ''" :disabled="disabled" maxlength="1000" aria-label="点击文字"
|
|
10
|
+
@update:model-value="emit('update', { value: String($event) })" />
|
|
11
|
+
</el-form-item>
|
|
12
|
+
<el-form-item label="文本匹配方式">
|
|
13
|
+
<el-select :model-value="step.flow?.textMatch || 'contains'" :disabled="disabled"
|
|
14
|
+
@update:model-value="emit('update', { flow: { ...props.step.flow, textMatch: $event } })">
|
|
15
|
+
<el-option label="精准匹配(完全一致)" value="exact" />
|
|
16
|
+
<el-option label="模糊匹配(包含)" value="contains" />
|
|
17
|
+
</el-select>
|
|
18
|
+
</el-form-item>
|
|
19
|
+
</template>
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import { Aim } from '@element-plus/icons-vue';
|
|
4
|
+
import type { AppiumVisualChangeConfig } from '../types';
|
|
5
|
+
import { normalizeVisualChangeConfig } from '../visual-change';
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
modelValue: boolean;
|
|
9
|
+
config: AppiumVisualChangeConfig;
|
|
10
|
+
hasSelectedElement: boolean;
|
|
11
|
+
pickingRegion?: boolean;
|
|
12
|
+
}>();
|
|
13
|
+
|
|
14
|
+
const emit = defineEmits<{
|
|
15
|
+
'update:modelValue': [value: boolean];
|
|
16
|
+
'update:config': [value: AppiumVisualChangeConfig];
|
|
17
|
+
pickRegion: [];
|
|
18
|
+
confirm: [];
|
|
19
|
+
}>();
|
|
20
|
+
|
|
21
|
+
const normalizedConfig = computed(() => normalizeVisualChangeConfig(props.config));
|
|
22
|
+
|
|
23
|
+
function patchConfig(patch: Partial<AppiumVisualChangeConfig>) {
|
|
24
|
+
emit('update:config', normalizeVisualChangeConfig({
|
|
25
|
+
...normalizedConfig.value,
|
|
26
|
+
...patch,
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function patchRegion(key: keyof AppiumVisualChangeConfig['region'], value: unknown) {
|
|
31
|
+
patchConfig({
|
|
32
|
+
region: {
|
|
33
|
+
...normalizedConfig.value.region,
|
|
34
|
+
[key]: Number(value),
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<el-dialog
|
|
42
|
+
:model-value="modelValue"
|
|
43
|
+
title="检测画面变化"
|
|
44
|
+
width="480px"
|
|
45
|
+
top="9vh"
|
|
46
|
+
class="appium-visual-change-dialog"
|
|
47
|
+
append-to-body
|
|
48
|
+
draggable
|
|
49
|
+
:modal="false"
|
|
50
|
+
modal-penetrable
|
|
51
|
+
:close-on-click-modal="false"
|
|
52
|
+
:close-on-press-escape="false"
|
|
53
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
54
|
+
>
|
|
55
|
+
<el-form label-position="top" size="small" class="appium-visual-change-form">
|
|
56
|
+
<el-form-item label="检测目标">
|
|
57
|
+
<el-radio-group
|
|
58
|
+
:model-value="normalizedConfig.mode"
|
|
59
|
+
@update:model-value="patchConfig({ mode: $event as AppiumVisualChangeConfig['mode'] })"
|
|
60
|
+
>
|
|
61
|
+
<el-radio-button label="selectedElement" :disabled="!hasSelectedElement">
|
|
62
|
+
当前选中元素
|
|
63
|
+
</el-radio-button>
|
|
64
|
+
<el-radio-button label="region">手动框选区域</el-radio-button>
|
|
65
|
+
</el-radio-group>
|
|
66
|
+
</el-form-item>
|
|
67
|
+
|
|
68
|
+
<el-alert
|
|
69
|
+
v-if="normalizedConfig.mode === 'selectedElement' && !hasSelectedElement"
|
|
70
|
+
type="warning"
|
|
71
|
+
:closable="false"
|
|
72
|
+
show-icon
|
|
73
|
+
title="当前没有可用的选中元素,请先选择组件或改为手动框选区域"
|
|
74
|
+
/>
|
|
75
|
+
|
|
76
|
+
<el-form-item v-if="normalizedConfig.mode === 'region'" label="框选区域">
|
|
77
|
+
<el-button :icon="Aim" :loading="pickingRegion" @click="emit('pickRegion')">
|
|
78
|
+
{{ pickingRegion ? '请在设备预览拖拽' : '框选/拖动区域' }}
|
|
79
|
+
</el-button>
|
|
80
|
+
</el-form-item>
|
|
81
|
+
|
|
82
|
+
<div class="appium-visual-change-form__grid">
|
|
83
|
+
<el-form-item label="X">
|
|
84
|
+
<el-input-number
|
|
85
|
+
:model-value="normalizedConfig.region.x"
|
|
86
|
+
:min="0"
|
|
87
|
+
:max="99999"
|
|
88
|
+
:precision="0"
|
|
89
|
+
controls-position="right"
|
|
90
|
+
@update:model-value="patchRegion('x', $event)"
|
|
91
|
+
/>
|
|
92
|
+
</el-form-item>
|
|
93
|
+
<el-form-item label="Y">
|
|
94
|
+
<el-input-number
|
|
95
|
+
:model-value="normalizedConfig.region.y"
|
|
96
|
+
:min="0"
|
|
97
|
+
:max="99999"
|
|
98
|
+
:precision="0"
|
|
99
|
+
controls-position="right"
|
|
100
|
+
@update:model-value="patchRegion('y', $event)"
|
|
101
|
+
/>
|
|
102
|
+
</el-form-item>
|
|
103
|
+
<el-form-item label="宽度">
|
|
104
|
+
<el-input-number
|
|
105
|
+
:model-value="normalizedConfig.region.width"
|
|
106
|
+
:min="1"
|
|
107
|
+
:max="99999"
|
|
108
|
+
:precision="0"
|
|
109
|
+
controls-position="right"
|
|
110
|
+
@update:model-value="patchRegion('width', $event)"
|
|
111
|
+
/>
|
|
112
|
+
</el-form-item>
|
|
113
|
+
<el-form-item label="高度">
|
|
114
|
+
<el-input-number
|
|
115
|
+
:model-value="normalizedConfig.region.height"
|
|
116
|
+
:min="1"
|
|
117
|
+
:max="99999"
|
|
118
|
+
:precision="0"
|
|
119
|
+
controls-position="right"
|
|
120
|
+
@update:model-value="patchRegion('height', $event)"
|
|
121
|
+
/>
|
|
122
|
+
</el-form-item>
|
|
123
|
+
<el-form-item label="变化阈值 %">
|
|
124
|
+
<el-input-number
|
|
125
|
+
:model-value="normalizedConfig.changeRatioThreshold"
|
|
126
|
+
:min="0.01"
|
|
127
|
+
:max="100"
|
|
128
|
+
:step="0.1"
|
|
129
|
+
controls-position="right"
|
|
130
|
+
@update:model-value="patchConfig({ changeRatioThreshold: Number($event) })"
|
|
131
|
+
/>
|
|
132
|
+
</el-form-item>
|
|
133
|
+
<el-form-item label="像素容差">
|
|
134
|
+
<el-input-number
|
|
135
|
+
:model-value="normalizedConfig.pixelmatchThreshold"
|
|
136
|
+
:min="0"
|
|
137
|
+
:max="1"
|
|
138
|
+
:step="0.01"
|
|
139
|
+
controls-position="right"
|
|
140
|
+
@update:model-value="patchConfig({ pixelmatchThreshold: Number($event) })"
|
|
141
|
+
/>
|
|
142
|
+
</el-form-item>
|
|
143
|
+
</div>
|
|
144
|
+
</el-form>
|
|
145
|
+
|
|
146
|
+
<template #footer>
|
|
147
|
+
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
|
148
|
+
<el-button type="primary" @click="emit('confirm')">添加开始节点</el-button>
|
|
149
|
+
</template>
|
|
150
|
+
</el-dialog>
|
|
151
|
+
</template>
|
|
@@ -58,6 +58,33 @@ function wireContinuation(step: AppiumRecordedStep, targetId: string) {
|
|
|
58
58
|
if (!step.flow.noTargetId) setFlowTarget(step, 'noTargetId', targetId);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
function isVisualChangeStart(step: AppiumRecordedStep) {
|
|
62
|
+
return step.visualChange?.role === 'start';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isVisualChangeEnd(step: AppiumRecordedStep) {
|
|
66
|
+
return step.visualChange?.role === 'end';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function visualChangePairKeys(step: AppiumRecordedStep) {
|
|
70
|
+
const config = step.visualChange;
|
|
71
|
+
if (!config) return [];
|
|
72
|
+
return [
|
|
73
|
+
config.pairId ? `pair:${config.pairId}` : '',
|
|
74
|
+
step.id ? `start:${step.id}` : '',
|
|
75
|
+
config.startStepId ? `start:${config.startStepId}` : '',
|
|
76
|
+
config.pairLabel ? `label:${config.pairLabel}` : '',
|
|
77
|
+
].filter(Boolean);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function hasCopiedVisualChangeStartForEnd(copiedSteps: AppiumRecordedStep[], endStep: AppiumRecordedStep) {
|
|
81
|
+
const endKeys = new Set(visualChangePairKeys(endStep));
|
|
82
|
+
return copiedSteps.some((step) => (
|
|
83
|
+
isVisualChangeStart(step)
|
|
84
|
+
&& visualChangePairKeys(step).some((key) => endKeys.has(key))
|
|
85
|
+
));
|
|
86
|
+
}
|
|
87
|
+
|
|
61
88
|
function branchConditionForTarget(
|
|
62
89
|
steps: AppiumRecordedStep[],
|
|
63
90
|
target: PasteTarget,
|
|
@@ -65,10 +92,10 @@ function branchConditionForTarget(
|
|
|
65
92
|
if (!target.branch) return undefined;
|
|
66
93
|
const anchor = steps[target.afterIndex];
|
|
67
94
|
if (!anchor) return undefined;
|
|
95
|
+
if (anchor.flow?.nodeKind === 'condition') return anchor;
|
|
68
96
|
if (anchor.flow?.parentConditionId && anchor.flow.parentBranch === target.branch) {
|
|
69
97
|
return steps.find((step) => step.id === anchor.flow?.parentConditionId);
|
|
70
98
|
}
|
|
71
|
-
if (anchor.flow?.nodeKind === 'condition') return anchor;
|
|
72
99
|
return undefined;
|
|
73
100
|
}
|
|
74
101
|
|
|
@@ -123,6 +150,9 @@ export function createFlowClipboard(steps: AppiumRecordedStep[], indexes: number
|
|
|
123
150
|
if (copiedSteps.some((step) => step.type === 'launchApp')) {
|
|
124
151
|
throw new Error('启动 APP 节点不能复制');
|
|
125
152
|
}
|
|
153
|
+
if (copiedSteps.some((step) => isVisualChangeEnd(step) && !hasCopiedVisualChangeStartForEnd(copiedSteps, step))) {
|
|
154
|
+
throw new Error('检测画面变化结束节点不能单独复制');
|
|
155
|
+
}
|
|
126
156
|
return { steps: clone(copiedSteps), rootIds };
|
|
127
157
|
}
|
|
128
158
|
|
|
@@ -141,6 +171,11 @@ export function pasteFlowClipboard(
|
|
|
141
171
|
throw new Error('目标分支不存在');
|
|
142
172
|
}
|
|
143
173
|
|
|
174
|
+
const pairMap = new Map<string, string>();
|
|
175
|
+
clipboard.steps.filter(isVisualChangeStart).forEach((step) => {
|
|
176
|
+
const pairId = createId();
|
|
177
|
+
visualChangePairKeys(step).forEach((key) => pairMap.set(key, pairId));
|
|
178
|
+
});
|
|
144
179
|
const copies = clipboard.steps.map((source) => {
|
|
145
180
|
const step = clone(source);
|
|
146
181
|
step.id = idMap.get(source.id) || createId();
|
|
@@ -168,6 +203,17 @@ export function pasteFlowClipboard(
|
|
|
168
203
|
}
|
|
169
204
|
}
|
|
170
205
|
step.flow = Object.keys(flow).length ? flow : undefined;
|
|
206
|
+
if (step.visualChange) {
|
|
207
|
+
const nextPairId = visualChangePairKeys(source)
|
|
208
|
+
.map((key) => pairMap.get(key))
|
|
209
|
+
.find(Boolean);
|
|
210
|
+
step.visualChange = {
|
|
211
|
+
...step.visualChange,
|
|
212
|
+
pairId: nextPairId || step.visualChange.pairId,
|
|
213
|
+
startStepId: idMap.get(step.visualChange.startStepId || '') || step.visualChange.startStepId,
|
|
214
|
+
endStepId: idMap.get(step.visualChange.endStepId || '') || step.visualChange.endStepId,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
171
217
|
return step;
|
|
172
218
|
});
|
|
173
219
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Position, type Edge, type Node } from '@vue-flow/core';
|
|
2
2
|
import * as dagre from '@dagrejs/dagre';
|
|
3
3
|
import type { AppiumRecordedStep } from './types';
|
|
4
|
+
import { defaultFlowKind as defaultKind, flowBranchLabel, isBooleanCondition } from './flow-labels';
|
|
4
5
|
|
|
5
6
|
type DagreApi = typeof dagre;
|
|
6
7
|
const dagreApi = ((dagre as DagreApi & { default?: DagreApi }).layout
|
|
@@ -25,6 +26,10 @@ export type InsertAction =
|
|
|
25
26
|
| 'launchApp'
|
|
26
27
|
| 'clearAppData'
|
|
27
28
|
| 'popupCondition'
|
|
29
|
+
| 'checkboxState'
|
|
30
|
+
| 'radioButtonState'
|
|
31
|
+
| 'aiRecognition'
|
|
32
|
+
| 'textClick'
|
|
28
33
|
| 'tapIfExists'
|
|
29
34
|
| 'inputIfExists'
|
|
30
35
|
| 'clearIfExists'
|
|
@@ -34,7 +39,12 @@ export type InsertAction =
|
|
|
34
39
|
| 'assertText'
|
|
35
40
|
| 'waitDisappear'
|
|
36
41
|
| 'waitActivity'
|
|
37
|
-
| 'runScript'
|
|
42
|
+
| 'runScript'
|
|
43
|
+
| 'noop'
|
|
44
|
+
| 'log'
|
|
45
|
+
| 'visualChangeStart'
|
|
46
|
+
| 'visualChangeEnd'
|
|
47
|
+
| 'visualChange';
|
|
38
48
|
|
|
39
49
|
export const PASTE_COMMAND = '__paste_flow_nodes__';
|
|
40
50
|
export const FLOW_STEP_NODE_WIDTH = 340;
|
|
@@ -50,6 +60,7 @@ const FLOW_STANDARD_LINE_GAP = 24;
|
|
|
50
60
|
const FLOW_BRANCH_TRUNK_GAP = 96;
|
|
51
61
|
const FLOW_BRANCH_LABEL_GAP = 58;
|
|
52
62
|
const FLOW_BRANCH_MIN_SPREAD = 260;
|
|
63
|
+
const FLOW_BRANCH_SUBTREE_GAP = 80;
|
|
53
64
|
|
|
54
65
|
export type FlowActionGroup = {
|
|
55
66
|
title: string;
|
|
@@ -82,12 +93,14 @@ export type FlowGraphNodeData =
|
|
|
82
93
|
canCopy: boolean;
|
|
83
94
|
canEditInput: boolean;
|
|
84
95
|
canExecute: boolean;
|
|
96
|
+
missingAiModel?: boolean;
|
|
85
97
|
}
|
|
86
98
|
| {
|
|
87
99
|
kind: 'insert';
|
|
88
100
|
actionGroups: FlowActionGroup[];
|
|
89
101
|
afterIndex: number;
|
|
90
102
|
branch?: FlowBranch;
|
|
103
|
+
branchLabel?: string;
|
|
91
104
|
conditionIndex?: number;
|
|
92
105
|
clipboardCount?: number;
|
|
93
106
|
canOpenInsertMenu: boolean;
|
|
@@ -96,6 +109,8 @@ export type FlowGraphNodeData =
|
|
|
96
109
|
| {
|
|
97
110
|
kind: 'branch';
|
|
98
111
|
branch: FlowBranch;
|
|
112
|
+
label: string;
|
|
113
|
+
width: number;
|
|
99
114
|
conditionIndex: number;
|
|
100
115
|
}
|
|
101
116
|
| {
|
|
@@ -111,6 +126,7 @@ type StepItem = {
|
|
|
111
126
|
};
|
|
112
127
|
|
|
113
128
|
type BuildFlowGraphOptions = {
|
|
129
|
+
aiRecognitionModelConfigured?: boolean;
|
|
114
130
|
expandedStepIndex: number | null;
|
|
115
131
|
selectedCopyIndexes: number[];
|
|
116
132
|
copyMode: boolean;
|
|
@@ -137,11 +153,12 @@ type Link = {
|
|
|
137
153
|
visual?: boolean;
|
|
138
154
|
};
|
|
139
155
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
156
|
+
type BranchLayout = {
|
|
157
|
+
yesWidth: number;
|
|
158
|
+
noWidth: number;
|
|
159
|
+
centerGap: number;
|
|
160
|
+
totalWidth: number;
|
|
161
|
+
};
|
|
145
162
|
|
|
146
163
|
function nodeIdForStep(step: AppiumRecordedStep) {
|
|
147
164
|
return `step:${step.id}`;
|
|
@@ -232,6 +249,21 @@ function estimateStepNodeHeight(label: { title: string; meta: string; note?: str
|
|
|
232
249
|
return Math.max(FLOW_STEP_NODE_HEIGHT, 34 + textHeight);
|
|
233
250
|
}
|
|
234
251
|
|
|
252
|
+
function withLaunchAppAction(groups: FlowActionGroup[]) {
|
|
253
|
+
if (groups.some((group) => group.actions.some((action) => action.type === 'launchApp'))) return groups;
|
|
254
|
+
const launchAction = { type: 'launchApp' as const, label: '启动 App' };
|
|
255
|
+
let added = false;
|
|
256
|
+
const nextGroups = groups.map((group) => {
|
|
257
|
+
if (group.title !== '设备操作') return group;
|
|
258
|
+
added = true;
|
|
259
|
+
return {
|
|
260
|
+
...group,
|
|
261
|
+
actions: [...group.actions, launchAction],
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
return added ? nextGroups : [...nextGroups, { title: '设备操作', actions: [launchAction] }];
|
|
265
|
+
}
|
|
266
|
+
|
|
235
267
|
export function buildFlowGraph(
|
|
236
268
|
steps: AppiumRecordedStep[],
|
|
237
269
|
options: BuildFlowGraphOptions,
|
|
@@ -242,9 +274,63 @@ export function buildFlowGraph(
|
|
|
242
274
|
const layoutLinks: Link[] = [];
|
|
243
275
|
const seenLinks = new Set<string>();
|
|
244
276
|
const allMainItems = mainItems(items);
|
|
277
|
+
const branchLayouts = new Map<string, BranchLayout>();
|
|
278
|
+
const measuringBranches = new Set<string>();
|
|
279
|
+
|
|
280
|
+
function conditionBranchLayout(condition: AppiumRecordedStep): BranchLayout {
|
|
281
|
+
const cached = branchLayouts.get(condition.id);
|
|
282
|
+
if (cached) return cached;
|
|
283
|
+
if (measuringBranches.has(condition.id)) {
|
|
284
|
+
return {
|
|
285
|
+
yesWidth: FLOW_STEP_NODE_WIDTH,
|
|
286
|
+
noWidth: FLOW_STEP_NODE_WIDTH,
|
|
287
|
+
centerGap: FLOW_BRANCH_MIN_SPREAD * 2,
|
|
288
|
+
totalWidth: FLOW_BRANCH_MIN_SPREAD * 2 + FLOW_STEP_NODE_WIDTH,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
measuringBranches.add(condition.id);
|
|
293
|
+
const branchWidth = (branch: FlowBranch) => Math.max(
|
|
294
|
+
FLOW_STEP_NODE_WIDTH,
|
|
295
|
+
...directBranchItems(items, condition.id, branch).map(({ step }) => (
|
|
296
|
+
defaultKind(step) === 'condition'
|
|
297
|
+
? conditionBranchLayout(step).totalWidth
|
|
298
|
+
: FLOW_STEP_NODE_WIDTH
|
|
299
|
+
)),
|
|
300
|
+
);
|
|
301
|
+
const yesWidth = branchWidth('yes');
|
|
302
|
+
const noWidth = branchWidth('no');
|
|
303
|
+
const centerGap = Math.max(
|
|
304
|
+
FLOW_BRANCH_MIN_SPREAD * 2,
|
|
305
|
+
yesWidth / 2 + noWidth / 2 + FLOW_BRANCH_SUBTREE_GAP,
|
|
306
|
+
);
|
|
307
|
+
const sideExtent = Math.max(
|
|
308
|
+
centerGap / 2 + yesWidth / 2,
|
|
309
|
+
centerGap / 2 + noWidth / 2,
|
|
310
|
+
);
|
|
311
|
+
const layout = {
|
|
312
|
+
yesWidth,
|
|
313
|
+
noWidth,
|
|
314
|
+
centerGap,
|
|
315
|
+
totalWidth: Math.max(FLOW_STEP_NODE_WIDTH, sideExtent * 2),
|
|
316
|
+
};
|
|
317
|
+
measuringBranches.delete(condition.id);
|
|
318
|
+
branchLayouts.set(condition.id, layout);
|
|
319
|
+
return layout;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
items
|
|
323
|
+
.filter(({ step }) => defaultKind(step) === 'condition')
|
|
324
|
+
.forEach(({ step }) => conditionBranchLayout(step));
|
|
245
325
|
|
|
246
326
|
const addNode = (node: FlowGraphNode) => {
|
|
247
|
-
nodes.push(
|
|
327
|
+
nodes.push({
|
|
328
|
+
...node,
|
|
329
|
+
dimensions: {
|
|
330
|
+
width: Number(node.width) || FLOW_STEP_NODE_WIDTH,
|
|
331
|
+
height: Number(node.height) || FLOW_STEP_NODE_HEIGHT,
|
|
332
|
+
},
|
|
333
|
+
} as FlowGraphNode);
|
|
248
334
|
};
|
|
249
335
|
const addVisibleLink = (source: string, target: string, branch?: FlowBranch) => {
|
|
250
336
|
addUniqueLink(links, seenLinks, {
|
|
@@ -266,6 +352,11 @@ export function buildFlowGraph(
|
|
|
266
352
|
) => {
|
|
267
353
|
const id = insertNodeId(afterIndex, branch, condition?.step.id);
|
|
268
354
|
const isStartInsert = afterIndex < 0 && !branch;
|
|
355
|
+
const previousStep = afterIndex >= 0 ? steps[afterIndex] : undefined;
|
|
356
|
+
const canLaunchAfterClearAppData = !isStartInsert
|
|
357
|
+
&& !branch
|
|
358
|
+
&& previousStep?.type === 'clearAppData'
|
|
359
|
+
&& !steps.some((step) => step.type === 'launchApp');
|
|
269
360
|
addNode({
|
|
270
361
|
id,
|
|
271
362
|
type: 'flow-node',
|
|
@@ -279,9 +370,14 @@ export function buildFlowGraph(
|
|
|
279
370
|
connectable: false,
|
|
280
371
|
data: {
|
|
281
372
|
kind: 'insert',
|
|
282
|
-
actionGroups: isStartInsert
|
|
373
|
+
actionGroups: isStartInsert
|
|
374
|
+
? options.startActionGroups
|
|
375
|
+
: canLaunchAfterClearAppData
|
|
376
|
+
? withLaunchAppAction(options.mainActionGroups)
|
|
377
|
+
: options.mainActionGroups,
|
|
283
378
|
afterIndex,
|
|
284
379
|
branch,
|
|
380
|
+
branchLabel: branch && condition ? flowBranchLabel(condition.step, branch) : undefined,
|
|
285
381
|
conditionIndex: condition?.index,
|
|
286
382
|
clipboardCount: options.clipboardCount,
|
|
287
383
|
canOpenInsertMenu: options.canOpenInsertMenu,
|
|
@@ -295,11 +391,13 @@ export function buildFlowGraph(
|
|
|
295
391
|
const id = nodeIdForStep(item.step);
|
|
296
392
|
const flowKind = defaultKind(item.step);
|
|
297
393
|
const label = options.labelStep(item.step);
|
|
298
|
-
|
|
394
|
+
// 字数估算仅用于首次布局,不强制卡片高度;实际换行由浏览器决定。
|
|
395
|
+
const cardMinHeight = FLOW_STEP_NODE_HEIGHT;
|
|
396
|
+
const estimatedCardHeight = estimateStepNodeHeight(label);
|
|
299
397
|
const estimatedNodeHeight = options.expandedStepIndex === item.index
|
|
300
|
-
?
|
|
301
|
-
:
|
|
302
|
-
const nodeHeight = Math.max(
|
|
398
|
+
? estimatedCardHeight + FLOW_EXPANDED_NODE_HEIGHT
|
|
399
|
+
: estimatedCardHeight;
|
|
400
|
+
const nodeHeight = Math.max(cardMinHeight, Math.ceil(options.measuredNodeHeights?.[id] || estimatedNodeHeight));
|
|
303
401
|
addNode({
|
|
304
402
|
id,
|
|
305
403
|
type: 'flow-node',
|
|
@@ -325,30 +423,34 @@ export function buildFlowGraph(
|
|
|
325
423
|
copyMode: options.copyMode,
|
|
326
424
|
disabled: item.step.type === 'launchApp' || item.step.type === 'clearAppData'
|
|
327
425
|
? options.isAppExecutionDisabled(item.step.type)
|
|
328
|
-
: options.disabled,
|
|
426
|
+
: item.step.type === 'aiRecognition' ? options.isInsertActionDisabled('aiRecognition') : options.disabled,
|
|
329
427
|
removeDisabled: options.removeDisabled,
|
|
330
428
|
launching: options.launchingStepId === item.step.id,
|
|
331
|
-
canCopy: item.step.type !== 'launchApp'
|
|
429
|
+
canCopy: item.step.type !== 'launchApp'
|
|
430
|
+
&& item.step.type !== 'clearAppData'
|
|
431
|
+
&& item.step.visualChange?.role !== 'end',
|
|
332
432
|
canEditInput: item.step.type === 'input' || item.step.type === 'inputIfExists',
|
|
333
|
-
canExecute: item.step.type === 'launchApp' || item.step.type === 'clearAppData',
|
|
433
|
+
canExecute: item.step.type === 'launchApp' || item.step.type === 'clearAppData' || item.step.type === 'aiRecognition',
|
|
434
|
+
missingAiModel: item.step.type === 'aiRecognition' && !options.aiRecognitionModelConfigured,
|
|
334
435
|
},
|
|
335
436
|
});
|
|
336
437
|
};
|
|
337
438
|
|
|
338
439
|
const addBranchNode = (condition: StepItem, branch: FlowBranch) => {
|
|
339
440
|
const id = branchNodeId(condition.step, branch);
|
|
441
|
+
const width = condition.step.type === 'textClick' ? 116 : isBooleanCondition(condition.step) ? 56 : FLOW_BRANCH_NODE_WIDTH;
|
|
340
442
|
addNode({
|
|
341
443
|
id,
|
|
342
444
|
type: 'flow-node',
|
|
343
445
|
position: { x: 0, y: 0 },
|
|
344
446
|
sourcePosition: Position.Bottom,
|
|
345
447
|
targetPosition: Position.Top,
|
|
346
|
-
width
|
|
448
|
+
width,
|
|
347
449
|
height: FLOW_BRANCH_NODE_HEIGHT,
|
|
348
450
|
selectable: false,
|
|
349
451
|
draggable: false,
|
|
350
452
|
connectable: false,
|
|
351
|
-
data: { kind: 'branch', branch, conditionIndex: condition.index },
|
|
453
|
+
data: { kind: 'branch', branch, label: flowBranchLabel(condition.step, branch), width, conditionIndex: condition.index },
|
|
352
454
|
});
|
|
353
455
|
return id;
|
|
354
456
|
};
|
|
@@ -519,19 +621,6 @@ export function buildFlowGraph(
|
|
|
519
621
|
const setCenterX = (node: FlowGraphNode, x: number) => {
|
|
520
622
|
node.position.x = Math.round(x - nodeWidth(node) / 2);
|
|
521
623
|
};
|
|
522
|
-
const normalizeBranchPositions = () => {
|
|
523
|
-
positionedNodes
|
|
524
|
-
.filter((node) => node.data?.kind === 'split')
|
|
525
|
-
.forEach((splitNode) => {
|
|
526
|
-
const branchLinks = links.filter((link) => link.source === splitNode.id && link.branch);
|
|
527
|
-
const yesNode = nodeById.get(branchLinks.find((link) => link.branch === 'yes')?.target || '');
|
|
528
|
-
const noNode = nodeById.get(branchLinks.find((link) => link.branch === 'no')?.target || '');
|
|
529
|
-
const splitX = centerX(splitNode);
|
|
530
|
-
const spread = FLOW_BRANCH_MIN_SPREAD;
|
|
531
|
-
if (yesNode) setCenterX(yesNode, splitX - spread);
|
|
532
|
-
if (noNode) setCenterX(noNode, splitX + spread);
|
|
533
|
-
});
|
|
534
|
-
};
|
|
535
624
|
const canAlignStepAfterFlowPoint = (sourceNode: FlowGraphNode, stepNode: FlowGraphNode) => {
|
|
536
625
|
if (stepNode.data?.kind !== 'step') return false;
|
|
537
626
|
return sourceNode.data?.kind === 'insert' || sourceNode.data?.kind === 'branch';
|
|
@@ -635,43 +724,37 @@ export function buildFlowGraph(
|
|
|
635
724
|
if (!moved) break;
|
|
636
725
|
}
|
|
637
726
|
};
|
|
727
|
+
// 按依赖顺序传递坐标,避免固定轮数只对齐长分支的前半段。
|
|
728
|
+
// 旧脚本若含回边,保留 Dagre 的纵向顺序,避免拓扑排序抛错。
|
|
729
|
+
const orderedNodeIds = dagreApi.graphlib.alg.isAcyclic(dagreGraph)
|
|
730
|
+
? dagreApi.graphlib.alg.topsort(dagreGraph)
|
|
731
|
+
: [...positionedNodes].sort((a, b) => a.position.y - b.position.y).map((node) => node.id);
|
|
732
|
+
const nodeOrder = new Map(orderedNodeIds.map((id, index) => [id, index]));
|
|
733
|
+
const orderedLinks = [...links].sort((a, b) => (
|
|
734
|
+
(nodeOrder.get(a.source) || 0) - (nodeOrder.get(b.source) || 0)
|
|
735
|
+
));
|
|
638
736
|
const alignVisibleChainGeometry = () => {
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
const target = nodeById.get(link.target);
|
|
659
|
-
if (!source || target?.data?.kind !== 'insert') return;
|
|
660
|
-
target.position.y = Math.round(source.position.y + nodeHeight(source) + FLOW_STANDARD_LINE_GAP);
|
|
661
|
-
setCenterX(target, centerX(source));
|
|
662
|
-
});
|
|
663
|
-
alignableLinks.forEach((link) => {
|
|
664
|
-
const source = nodeById.get(link.source);
|
|
665
|
-
const target = nodeById.get(link.target);
|
|
666
|
-
if (!source || !target || !canAlignStepAfterFlowPoint(source, target)) return;
|
|
667
|
-
target.position.y = Math.round(source.position.y + nodeHeight(source) + FLOW_STANDARD_LINE_GAP);
|
|
668
|
-
setCenterX(target, centerX(source));
|
|
669
|
-
});
|
|
670
|
-
}
|
|
737
|
+
orderedLinks.forEach((link) => {
|
|
738
|
+
const source = nodeById.get(link.source);
|
|
739
|
+
const target = nodeById.get(link.target);
|
|
740
|
+
if (!source || !target?.data) return;
|
|
741
|
+
const kind = target.data.kind;
|
|
742
|
+
if (!['branch', 'split', 'insert'].includes(kind) && !canAlignStepAfterFlowPoint(source, target)) return;
|
|
743
|
+
|
|
744
|
+
const lineGap = kind === 'split'
|
|
745
|
+
? FLOW_BRANCH_TRUNK_GAP
|
|
746
|
+
: kind === 'branch' ? FLOW_BRANCH_LABEL_GAP : FLOW_STANDARD_LINE_GAP;
|
|
747
|
+
target.position.y = Math.round(source.position.y + nodeHeight(source) + lineGap);
|
|
748
|
+
let x = centerX(source);
|
|
749
|
+
if (target.data.kind === 'branch') {
|
|
750
|
+
const conditionId = steps[target.data.conditionIndex].id;
|
|
751
|
+
const spread = (branchLayouts.get(conditionId)?.centerGap || FLOW_BRANCH_MIN_SPREAD * 2) / 2;
|
|
752
|
+
x += target.data.branch === 'yes' ? -spread : spread;
|
|
753
|
+
}
|
|
754
|
+
setCenterX(target, x);
|
|
755
|
+
});
|
|
671
756
|
};
|
|
672
757
|
|
|
673
|
-
const alignableLinks = [...links, ...layoutLinks];
|
|
674
|
-
|
|
675
758
|
alignVisibleChainGeometry();
|
|
676
759
|
|
|
677
760
|
resolveStepCollisions();
|