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,16 @@
|
|
|
1
|
+
export type AiRecognitionModel = { baseUrl: string; apiKey: string; name: string };
|
|
2
|
+
export type AiRecognitionResult = { result: boolean; reason: string; durationMs: number };
|
|
3
|
+
|
|
4
|
+
export const AI_MODEL_CONFIG_HINT = '请在参数配置 > Appium配置中填写并保存 AI 识别模型的 Base URL、API Key 和 Model Name';
|
|
5
|
+
|
|
6
|
+
export function isAiRecognitionModelConfigured(model?: Partial<AiRecognitionModel> | null) {
|
|
7
|
+
return Boolean(model && [model.baseUrl, model.apiKey, model.name].every(
|
|
8
|
+
(value) => typeof value === 'string' && value.trim(),
|
|
9
|
+
));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function validateAiRecognitionPrompt(value: unknown): string {
|
|
13
|
+
if (typeof value !== 'string' || !value.trim()) throw new Error('请输入 AI 识别内容');
|
|
14
|
+
if (value.trim().length > 4000) throw new Error('AI 识别内容不能超过 4000 字');
|
|
15
|
+
return value.trim();
|
|
16
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { APP_BASE } from '../api';
|
|
2
2
|
import type { AppiumRecordedScript, AppiumRecordedStep } from './types';
|
|
3
|
+
import type { AiRecognitionResult } from './ai-recognition';
|
|
3
4
|
|
|
4
5
|
async function readJson<T>(response: Response) {
|
|
5
6
|
const payload = (await response.json().catch(() => ({}))) as T & {
|
|
@@ -22,7 +23,7 @@ function postJson<T>(url: string, body?: unknown) {
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export async function getAppiumTree(deviceId: string) {
|
|
25
|
-
const response = await fetch(`${APP_BASE}/api/appium-recorder/tree?deviceId=${encodeURIComponent(deviceId)}
|
|
26
|
+
const response = await fetch(`${APP_BASE}/api/appium-recorder/tree?deviceId=${encodeURIComponent(deviceId)}`, { cache: 'no-store' });
|
|
26
27
|
return readJson<{ deviceId: string; xml: string; activity: string; dumpedAt: string }>(response);
|
|
27
28
|
}
|
|
28
29
|
|
|
@@ -47,6 +48,13 @@ export async function getAppiumScripts() {
|
|
|
47
48
|
return readJson<{ scripts: AppiumRecordedScript[] }>(response);
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
export async function testAiRecognition(input: { deviceId: string; prompt: string; timeoutMs?: number }, signal?: AbortSignal) {
|
|
52
|
+
const response = await fetch(`${APP_BASE}/api/appium-recorder/ai-recognition/test`, {
|
|
53
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), signal,
|
|
54
|
+
});
|
|
55
|
+
return readJson<AiRecognitionResult & { imageBase64: string }>(response);
|
|
56
|
+
}
|
|
57
|
+
|
|
50
58
|
export function saveAppiumScript(input: {
|
|
51
59
|
id?: string;
|
|
52
60
|
name: string;
|
|
@@ -68,8 +76,16 @@ export function importAppiumScript(input: unknown) {
|
|
|
68
76
|
return postJson<{ script: AppiumRecordedScript }>(`${APP_BASE}/api/appium-recorder/scripts/import`, input);
|
|
69
77
|
}
|
|
70
78
|
|
|
71
|
-
export function
|
|
72
|
-
|
|
79
|
+
export async function downloadAppiumScript(id: string) {
|
|
80
|
+
const response = await fetch(`${APP_BASE}/api/appium-recorder/scripts/${encodeURIComponent(id)}/export`);
|
|
81
|
+
if (!response.ok) await readJson(response);
|
|
82
|
+
const disposition = response.headers.get('Content-Disposition') || '';
|
|
83
|
+
const encodedName = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
|
|
84
|
+
const extension = response.headers.get('Content-Type')?.includes('application/zip') ? 'zip' : 'json';
|
|
85
|
+
return {
|
|
86
|
+
fileName: encodedName ? decodeURIComponent(encodedName) : `appium-script.${extension}`,
|
|
87
|
+
blob: await response.blob(),
|
|
88
|
+
};
|
|
73
89
|
}
|
|
74
90
|
|
|
75
91
|
type ReplayResult = {
|
|
@@ -80,6 +96,7 @@ type ReplayResult = {
|
|
|
80
96
|
reportId?: string;
|
|
81
97
|
logPath?: string;
|
|
82
98
|
htmlReportPath?: string;
|
|
99
|
+
softFailureCount?: number;
|
|
83
100
|
};
|
|
84
101
|
|
|
85
102
|
type ReplayStreamEvent =
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onBeforeUnmount, onMounted, shallowRef } from 'vue';
|
|
3
|
+
import { Refresh } from '@element-plus/icons-vue';
|
|
4
|
+
import { testAiRecognition } from '../api';
|
|
5
|
+
import { AI_MODEL_CONFIG_HINT, validateAiRecognitionPrompt } from '../ai-recognition';
|
|
6
|
+
import type { AppiumRecordedStep } from '../types';
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{ step: AppiumRecordedStep; deviceId: string; modelConfigured: boolean }>();
|
|
9
|
+
defineEmits<{ close: [] }>();
|
|
10
|
+
const loading = shallowRef(false);
|
|
11
|
+
const error = shallowRef('');
|
|
12
|
+
const result = shallowRef<Awaited<ReturnType<typeof testAiRecognition>> | null>(null);
|
|
13
|
+
let controller: AbortController | undefined;
|
|
14
|
+
|
|
15
|
+
async function runTest() {
|
|
16
|
+
if (loading.value) return;
|
|
17
|
+
error.value = '';
|
|
18
|
+
result.value = null;
|
|
19
|
+
controller = new AbortController();
|
|
20
|
+
loading.value = true;
|
|
21
|
+
try {
|
|
22
|
+
if (!props.modelConfigured) throw new Error(AI_MODEL_CONFIG_HINT);
|
|
23
|
+
if (!props.deviceId) throw new Error('请选择设备');
|
|
24
|
+
const prompt = validateAiRecognitionPrompt(props.step.value);
|
|
25
|
+
result.value = await testAiRecognition({ deviceId: props.deviceId, prompt, timeoutMs: props.step.timeoutMs }, controller.signal);
|
|
26
|
+
} catch (cause) {
|
|
27
|
+
if (!controller.signal.aborted) error.value = cause instanceof Error ? cause.message : 'AI 识别测试失败';
|
|
28
|
+
} finally {
|
|
29
|
+
loading.value = false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
onMounted(runTest);
|
|
34
|
+
onBeforeUnmount(() => controller?.abort());
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<el-dialog :model-value="true" title="测试 AI 识别" width="min(560px, calc(100vw - 32px))" append-to-body @close="$emit('close')">
|
|
39
|
+
<div class="ai-recognition-test" aria-live="polite">
|
|
40
|
+
<p class="ai-recognition-test__prompt">{{ step.value }}</p>
|
|
41
|
+
<el-alert v-if="error" type="error" :title="error" :closable="false" show-icon />
|
|
42
|
+
<p v-if="loading">识别中…</p>
|
|
43
|
+
<template v-if="result">
|
|
44
|
+
<div class="ai-recognition-test__result">
|
|
45
|
+
<strong>{{ String(result.result) }}</strong>
|
|
46
|
+
<span>{{ result.durationMs }}ms</span>
|
|
47
|
+
</div>
|
|
48
|
+
<p>{{ result.reason }}</p>
|
|
49
|
+
<el-image
|
|
50
|
+
v-if="result.imageBase64"
|
|
51
|
+
class="ai-recognition-test__image" fit="contain"
|
|
52
|
+
:src="`data:image/png;base64,${result.imageBase64}`"
|
|
53
|
+
:preview-src-list="[`data:image/png;base64,${result.imageBase64}`]"
|
|
54
|
+
preview-teleported alt="AI 识别使用的设备截图"
|
|
55
|
+
/>
|
|
56
|
+
</template>
|
|
57
|
+
</div>
|
|
58
|
+
<template #footer>
|
|
59
|
+
<el-button @click="$emit('close')">关闭</el-button>
|
|
60
|
+
<el-button type="primary" :icon="Refresh" :loading="loading" :disabled="!modelConfigured || !deviceId" @click="runTest">重新测试</el-button>
|
|
61
|
+
</template>
|
|
62
|
+
</el-dialog>
|
|
63
|
+
</template>
|
|
64
|
+
|
|
65
|
+
<style scoped>
|
|
66
|
+
.ai-recognition-test { overflow-wrap: anywhere; }
|
|
67
|
+
.ai-recognition-test__prompt { white-space: pre-wrap; margin-top: 0; }
|
|
68
|
+
.ai-recognition-test__result { display: flex; align-items: center; gap: 16px; }
|
|
69
|
+
.ai-recognition-test__result strong { font-size: 20px; }
|
|
70
|
+
.ai-recognition-test__image { display: block; width: 100%; height: 280px; }
|
|
71
|
+
</style>
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { nextTick, shallowRef } from 'vue';
|
|
3
|
+
import type { MenuInstance, PopoverInstance } from 'element-plus';
|
|
4
|
+
import { PASTE_COMMAND, type FlowActionGroup, type InsertAction } from '../flow-graph';
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
groups: FlowActionGroup[];
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
clipboardCount?: number;
|
|
10
|
+
isActionDisabled: (action: InsertAction) => boolean;
|
|
11
|
+
}>();
|
|
12
|
+
|
|
13
|
+
const emit = defineEmits<{
|
|
14
|
+
command: [action: InsertAction | typeof PASTE_COMMAND];
|
|
15
|
+
}>();
|
|
16
|
+
|
|
17
|
+
const popoverRef = shallowRef<PopoverInstance>();
|
|
18
|
+
const triggerRef = shallowRef<HTMLElement>();
|
|
19
|
+
const menuRef = shallowRef<HTMLElement>();
|
|
20
|
+
const menuControlRef = shallowRef<MenuInstance>();
|
|
21
|
+
const menuMounted = shallowRef(false);
|
|
22
|
+
|
|
23
|
+
async function openMenu() {
|
|
24
|
+
// 每次打开重新创建面板,避免上次选中的操作被当成当前值而无法重复添加。
|
|
25
|
+
menuMounted.value = true;
|
|
26
|
+
await nextTick();
|
|
27
|
+
menuRef.value?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function openGroupOnClick(event: MouseEvent, title: string) {
|
|
31
|
+
if ((event.target as HTMLElement).closest('.el-sub-menu__title')) {
|
|
32
|
+
menuControlRef.value?.open(title);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function handleMenuKeydown(event: KeyboardEvent) {
|
|
37
|
+
const target = (event.target as HTMLElement).closest<HTMLElement>('[role="menuitem"]');
|
|
38
|
+
if (!target || !['Enter', ' ', 'ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
event.stopPropagation();
|
|
41
|
+
const group = target.dataset.group;
|
|
42
|
+
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
|
43
|
+
const siblings = Array.from(target.parentElement!.querySelectorAll<HTMLElement>(
|
|
44
|
+
':scope > [role="menuitem"]:not(.is-disabled)',
|
|
45
|
+
));
|
|
46
|
+
const offset = event.key === 'ArrowUp' ? -1 : 1;
|
|
47
|
+
siblings[(siblings.indexOf(target) + offset + siblings.length) % siblings.length]?.focus();
|
|
48
|
+
} else if (event.key === 'ArrowLeft') {
|
|
49
|
+
const parent = target.parentElement?.closest<HTMLElement>('[data-group]');
|
|
50
|
+
if (parent?.dataset.group) {
|
|
51
|
+
menuControlRef.value?.close(parent.dataset.group);
|
|
52
|
+
parent.focus();
|
|
53
|
+
}
|
|
54
|
+
} else if (group) {
|
|
55
|
+
menuControlRef.value?.open(group);
|
|
56
|
+
await nextTick();
|
|
57
|
+
target.querySelector<HTMLElement>('.el-menu-item:not(.is-disabled)')?.focus();
|
|
58
|
+
} else if (event.key !== 'ArrowRight') {
|
|
59
|
+
target.click();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function closeMenu() {
|
|
64
|
+
popoverRef.value?.hide();
|
|
65
|
+
triggerRef.value?.querySelector('button')?.focus();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function selectAction(value: string) {
|
|
69
|
+
if (props.disabled) return;
|
|
70
|
+
const action = value as InsertAction | typeof PASTE_COMMAND;
|
|
71
|
+
if (action === PASTE_COMMAND ? !props.clipboardCount : props.isActionDisabled(action)) return;
|
|
72
|
+
closeMenu();
|
|
73
|
+
emit('command', action);
|
|
74
|
+
}
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
<template>
|
|
78
|
+
<span ref="triggerRef" class="appium-action-menu-trigger">
|
|
79
|
+
<el-popover
|
|
80
|
+
ref="popoverRef"
|
|
81
|
+
trigger="click"
|
|
82
|
+
placement="bottom-start"
|
|
83
|
+
width="auto"
|
|
84
|
+
:disabled="disabled"
|
|
85
|
+
:show-arrow="false"
|
|
86
|
+
:hide-after="0"
|
|
87
|
+
popper-class="appium-action-dropdown appium-action-menu"
|
|
88
|
+
@before-enter="openMenu"
|
|
89
|
+
@after-leave="menuMounted = false"
|
|
90
|
+
>
|
|
91
|
+
<template #reference><slot /></template>
|
|
92
|
+
<div
|
|
93
|
+
ref="menuRef"
|
|
94
|
+
@keydown.capture="handleMenuKeydown"
|
|
95
|
+
@keydown.esc.stop.prevent="closeMenu"
|
|
96
|
+
@keydown.tab="closeMenu"
|
|
97
|
+
>
|
|
98
|
+
<el-menu
|
|
99
|
+
v-if="menuMounted"
|
|
100
|
+
ref="menuControlRef"
|
|
101
|
+
collapse
|
|
102
|
+
unique-opened
|
|
103
|
+
:collapse-transition="false"
|
|
104
|
+
:show-timeout="100"
|
|
105
|
+
:hide-timeout="250"
|
|
106
|
+
:popper-offset="4"
|
|
107
|
+
@select="selectAction"
|
|
108
|
+
>
|
|
109
|
+
<el-sub-menu
|
|
110
|
+
v-for="group in groups"
|
|
111
|
+
:key="group.title"
|
|
112
|
+
:index="group.title"
|
|
113
|
+
:data-group="group.title"
|
|
114
|
+
:aria-label="group.title"
|
|
115
|
+
tabindex="-1"
|
|
116
|
+
:teleported="false"
|
|
117
|
+
popper-class="appium-action-submenu"
|
|
118
|
+
@click="openGroupOnClick($event, group.title)"
|
|
119
|
+
>
|
|
120
|
+
<template #title>{{ group.title }}</template>
|
|
121
|
+
<el-menu-item
|
|
122
|
+
v-for="action in group.actions"
|
|
123
|
+
:key="action.type"
|
|
124
|
+
:index="action.type"
|
|
125
|
+
:disabled="isActionDisabled(action.type)"
|
|
126
|
+
>
|
|
127
|
+
{{ action.label }}
|
|
128
|
+
</el-menu-item>
|
|
129
|
+
</el-sub-menu>
|
|
130
|
+
<el-menu-item v-if="clipboardCount" :index="PASTE_COMMAND" class="appium-action-paste">
|
|
131
|
+
粘贴 {{ clipboardCount }} 个节点
|
|
132
|
+
</el-menu-item>
|
|
133
|
+
</el-menu>
|
|
134
|
+
</div>
|
|
135
|
+
</el-popover>
|
|
136
|
+
</span>
|
|
137
|
+
</template>
|
|
138
|
+
|
|
139
|
+
<style>
|
|
140
|
+
.appium-action-menu-trigger {
|
|
141
|
+
display: inline-flex;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.appium-action-menu.el-popover {
|
|
145
|
+
min-width: 0;
|
|
146
|
+
max-width: calc(100vw - 24px);
|
|
147
|
+
padding: 0;
|
|
148
|
+
overflow: visible;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.appium-action-menu .el-menu {
|
|
152
|
+
--el-menu-item-height: 34px;
|
|
153
|
+
--el-menu-sub-item-height: 34px;
|
|
154
|
+
--el-menu-base-level-padding: 14px;
|
|
155
|
+
--el-menu-text-color: var(--el-text-color-regular);
|
|
156
|
+
--el-menu-hover-bg-color: var(--el-color-primary-light-9);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.appium-action-menu .el-menu--collapse {
|
|
160
|
+
width: 132px;
|
|
161
|
+
padding: 6px 0;
|
|
162
|
+
border: 0;
|
|
163
|
+
border-radius: 6px;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.appium-action-menu .appium-action-paste {
|
|
167
|
+
font-size: 12px;
|
|
168
|
+
font-weight: 700;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.appium-action-menu .el-menu--collapse > .el-sub-menu > .el-sub-menu__title .el-sub-menu__icon-arrow {
|
|
172
|
+
display: inline-flex;
|
|
173
|
+
position: static;
|
|
174
|
+
width: 14px;
|
|
175
|
+
height: 14px;
|
|
176
|
+
margin: 0 0 0 auto;
|
|
177
|
+
font-size: 14px;
|
|
178
|
+
transform: none !important;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.appium-action-menu .el-sub-menu:focus-visible > .el-sub-menu__title,
|
|
182
|
+
.appium-action-menu .el-sub-menu.is-opened > .el-sub-menu__title,
|
|
183
|
+
.appium-action-menu .el-menu-item:focus-visible,
|
|
184
|
+
.appium-action-menu .el-menu-item:not(.is-disabled):hover {
|
|
185
|
+
color: var(--el-color-primary);
|
|
186
|
+
background: var(--el-color-primary-light-9);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.appium-action-submenu .el-menu--popup {
|
|
190
|
+
width: 226px;
|
|
191
|
+
min-width: 0;
|
|
192
|
+
max-width: calc(100vw - 24px);
|
|
193
|
+
max-height: min(360px, calc(100dvh - 48px));
|
|
194
|
+
overflow-y: auto;
|
|
195
|
+
overscroll-behavior: contain;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.appium-action-submenu .el-menu-item {
|
|
199
|
+
height: auto;
|
|
200
|
+
min-height: 34px;
|
|
201
|
+
padding: 7px 14px;
|
|
202
|
+
line-height: 20px;
|
|
203
|
+
white-space: normal;
|
|
204
|
+
overflow-wrap: anywhere;
|
|
205
|
+
}
|
|
206
|
+
</style>
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import type { AppiumRecordedStep } from '../types';
|
|
14
14
|
|
|
15
15
|
const props = defineProps<{
|
|
16
|
+
aiRecognitionModelConfigured?: boolean;
|
|
16
17
|
id: string;
|
|
17
18
|
steps: AppiumRecordedStep[];
|
|
18
19
|
expandedStepIndex: number | null;
|
|
@@ -55,6 +56,7 @@ let resetViewFrame = 0;
|
|
|
55
56
|
let pendingResetView = false;
|
|
56
57
|
|
|
57
58
|
const graph = computed(() => buildFlowGraph(props.steps, {
|
|
59
|
+
aiRecognitionModelConfigured: props.aiRecognitionModelConfigured,
|
|
58
60
|
expandedStepIndex: props.expandedStepIndex,
|
|
59
61
|
selectedCopyIndexes: props.selectedCopyIndexes,
|
|
60
62
|
copyMode: props.copyMode,
|
|
@@ -118,7 +120,19 @@ function handlePaneReady() {
|
|
|
118
120
|
void nextTick(() => scheduleResetView());
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
|
|
123
|
+
function flowStructureKey() {
|
|
124
|
+
return props.steps.map((step) => [
|
|
125
|
+
step.id,
|
|
126
|
+
step.flow?.nodeKind || '',
|
|
127
|
+
step.flow?.parentConditionId || '',
|
|
128
|
+
step.flow?.parentBranch || '',
|
|
129
|
+
step.flow?.yesTargetId || '',
|
|
130
|
+
step.flow?.noTargetId || '',
|
|
131
|
+
step.flow?.successTargetId || '',
|
|
132
|
+
].join(':')).join('|');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
watch(flowStructureKey, () => {
|
|
122
136
|
measuredNodeHeights.value = {};
|
|
123
137
|
void nextTick(scheduleNodeInternalsUpdate);
|
|
124
138
|
});
|
|
@@ -168,6 +182,7 @@ function handleInsert(payload: {
|
|
|
168
182
|
:nodes-draggable="false"
|
|
169
183
|
:nodes-connectable="false"
|
|
170
184
|
:elements-selectable="false"
|
|
185
|
+
:only-render-visible-elements="false"
|
|
171
186
|
:pan-on-drag="true"
|
|
172
187
|
:zoom-on-scroll="true"
|
|
173
188
|
zoom-activation-key-code="Control"
|
|
@@ -1,7 +1,8 @@
|
|
|
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 } from '@element-plus/icons-vue';
|
|
4
|
+
import { CopyDocument, Delete, Edit, Plus, VideoPlay, View, WarningFilled } from '@element-plus/icons-vue';
|
|
5
|
+
import { AI_MODEL_CONFIG_HINT } from '../ai-recognition';
|
|
5
6
|
import type { AppiumRecordedStep } from '../types';
|
|
6
7
|
import {
|
|
7
8
|
PASTE_COMMAND,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
type InsertAction,
|
|
11
12
|
} from '../flow-graph';
|
|
12
13
|
import FlowStepEditor from './FlowStepEditor.vue';
|
|
14
|
+
import FlowActionMenu from './FlowActionMenu.vue';
|
|
13
15
|
|
|
14
16
|
const props = defineProps<{
|
|
15
17
|
nodeId: string;
|
|
@@ -32,15 +34,9 @@ const emit = defineEmits<{
|
|
|
32
34
|
const rootRef = shallowRef<HTMLElement | null>(null);
|
|
33
35
|
let resizeObserver: ResizeObserver | null = null;
|
|
34
36
|
|
|
35
|
-
const branchLabel = computed(() => {
|
|
36
|
-
if (props.data.kind !== 'branch') return '';
|
|
37
|
-
return props.data.branch === 'yes' ? '是' : '否';
|
|
38
|
-
});
|
|
39
|
-
|
|
40
37
|
const insertTitle = computed(() => {
|
|
41
38
|
if (props.data.kind !== 'insert') return '';
|
|
42
|
-
if (props.data.branch === 'yes'
|
|
43
|
-
if (props.data.branch === 'no') return '在“否”分支插入操作';
|
|
39
|
+
if (props.data.branch) return `在“${props.data.branchLabel || (props.data.branch === 'yes' ? '是' : '否')}”分支插入操作`;
|
|
44
40
|
return props.data.afterIndex < 0 ? '在开始后插入操作' : '插入操作';
|
|
45
41
|
});
|
|
46
42
|
|
|
@@ -92,6 +88,7 @@ watch(() => props.data, () => {
|
|
|
92
88
|
ref="rootRef"
|
|
93
89
|
class="appium-flow-graph-node nodrag nopan"
|
|
94
90
|
:class="`appium-flow-graph-node--${data.kind}`"
|
|
91
|
+
:style="data.kind === 'branch' ? { width: `${data.width}px` } : undefined"
|
|
95
92
|
@pointerdown.stop
|
|
96
93
|
@mousedown.stop
|
|
97
94
|
@touchstart.stop
|
|
@@ -116,9 +113,10 @@ watch(() => props.data, () => {
|
|
|
116
113
|
type="button"
|
|
117
114
|
class="appium-flow-branch-pill nodrag nopan"
|
|
118
115
|
:class="`appium-flow-branch-pill--${data.branch}`"
|
|
116
|
+
style="width: 100%"
|
|
119
117
|
tabindex="-1"
|
|
120
118
|
>
|
|
121
|
-
{{
|
|
119
|
+
{{ data.label }}
|
|
122
120
|
</button>
|
|
123
121
|
|
|
124
122
|
<span
|
|
@@ -134,11 +132,11 @@ watch(() => props.data, () => {
|
|
|
134
132
|
aria-hidden="true"
|
|
135
133
|
/>
|
|
136
134
|
<el-tooltip v-else content="添加操作" placement="top">
|
|
137
|
-
<
|
|
138
|
-
|
|
135
|
+
<FlowActionMenu
|
|
136
|
+
:groups="data.actionGroups"
|
|
139
137
|
:disabled="!data.canOpenInsertMenu"
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
:clipboard-count="data.clipboardCount"
|
|
139
|
+
:is-action-disabled="data.isActionDisabled"
|
|
142
140
|
@command="emitInsert"
|
|
143
141
|
>
|
|
144
142
|
<el-button
|
|
@@ -148,29 +146,7 @@ watch(() => props.data, () => {
|
|
|
148
146
|
:disabled="!data.canOpenInsertMenu"
|
|
149
147
|
:aria-label="insertTitle"
|
|
150
148
|
/>
|
|
151
|
-
|
|
152
|
-
<el-dropdown-menu>
|
|
153
|
-
<el-dropdown-item
|
|
154
|
-
v-if="data.clipboardCount"
|
|
155
|
-
:command="PASTE_COMMAND"
|
|
156
|
-
divided
|
|
157
|
-
>
|
|
158
|
-
粘贴 {{ data.clipboardCount }} 个节点
|
|
159
|
-
</el-dropdown-item>
|
|
160
|
-
<template v-for="group in data.actionGroups" :key="group.title">
|
|
161
|
-
<div class="appium-action-dropdown__group">{{ group.title }}</div>
|
|
162
|
-
<el-dropdown-item
|
|
163
|
-
v-for="action in group.actions"
|
|
164
|
-
:key="action.type"
|
|
165
|
-
:command="action.type"
|
|
166
|
-
:disabled="data.isActionDisabled(action.type)"
|
|
167
|
-
>
|
|
168
|
-
{{ action.label }}
|
|
169
|
-
</el-dropdown-item>
|
|
170
|
-
</template>
|
|
171
|
-
</el-dropdown-menu>
|
|
172
|
-
</template>
|
|
173
|
-
</el-dropdown>
|
|
149
|
+
</FlowActionMenu>
|
|
174
150
|
</el-tooltip>
|
|
175
151
|
</div>
|
|
176
152
|
|
|
@@ -194,6 +170,11 @@ watch(() => props.data, () => {
|
|
|
194
170
|
@click="handleStepClick"
|
|
195
171
|
>
|
|
196
172
|
<span class="appium-flow-step-card__content">
|
|
173
|
+
<el-tooltip v-if="data.missingAiModel" :content="AI_MODEL_CONFIG_HINT" placement="top">
|
|
174
|
+
<el-icon class="appium-ai-model-warning" color="var(--el-color-danger)" role="img" aria-label="AI 识别模型未配置" tabindex="0">
|
|
175
|
+
<WarningFilled />
|
|
176
|
+
</el-icon>
|
|
177
|
+
</el-tooltip>
|
|
197
178
|
<strong :title="data.title">{{ data.title }}</strong>
|
|
198
179
|
<small :title="data.meta">{{ data.meta }}</small>
|
|
199
180
|
<em v-if="data.note" :title="data.note">{{ data.note }}</em>
|
|
@@ -215,7 +196,8 @@ watch(() => props.data, () => {
|
|
|
215
196
|
:icon="VideoPlay"
|
|
216
197
|
:loading="data.launching"
|
|
217
198
|
:disabled="data.disabled"
|
|
218
|
-
title="立即执行"
|
|
199
|
+
:title="data.step.type === 'aiRecognition' ? '测试 AI 识别' : '立即执行'"
|
|
200
|
+
:aria-label="data.step.type === 'aiRecognition' ? '测试 AI 识别' : '立即执行'"
|
|
219
201
|
@click="emit('execute', data.index)"
|
|
220
202
|
/>
|
|
221
203
|
<el-button
|
|
@@ -262,3 +244,7 @@ watch(() => props.data, () => {
|
|
|
262
244
|
/>
|
|
263
245
|
</div>
|
|
264
246
|
</template>
|
|
247
|
+
|
|
248
|
+
<style scoped>
|
|
249
|
+
.appium-ai-model-warning { float: left; margin: 2px 6px 0 0; font-size: 16px; }
|
|
250
|
+
</style>
|