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.
Files changed (56) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/FAQ.md +14 -0
  3. package/README.md +39 -3
  4. package/bin/android-midscene-automation.js +71 -1
  5. package/package.json +7 -2
  6. package/remote-agent/index.ts +2 -11
  7. package/server/appium-recorder/ai-recognition.ts +67 -0
  8. package/server/appium-recorder/appium-runner.ts +335 -52
  9. package/server/appium-recorder/report.ts +297 -16
  10. package/server/appium-recorder/repository.ts +31 -4
  11. package/server/appium-recorder/routes.ts +31 -32
  12. package/server/appium-recorder/screenshot.ts +21 -0
  13. package/server/appium-recorder/script-export.ts +67 -0
  14. package/server/appium-recorder/tree-dump.ts +22 -0
  15. package/server/appium-recorder/visual-change.ts +184 -0
  16. package/server/config.ts +10 -0
  17. package/server/http-api.ts +4 -5
  18. package/src/App.vue +14 -7
  19. package/src/api.ts +1 -1
  20. package/src/appium-recorder/AppiumPage.vue +658 -162
  21. package/src/appium-recorder/ai-recognition.ts +16 -0
  22. package/src/appium-recorder/api.ts +20 -3
  23. package/src/appium-recorder/components/AiRecognitionTestDialog.vue +71 -0
  24. package/src/appium-recorder/components/FlowActionMenu.vue +206 -0
  25. package/src/appium-recorder/components/FlowCanvas.vue +16 -1
  26. package/src/appium-recorder/components/FlowNodeCard.vue +23 -37
  27. package/src/appium-recorder/components/FlowStepEditor.vue +152 -25
  28. package/src/appium-recorder/components/LongPressSettings.vue +58 -0
  29. package/src/appium-recorder/components/NestedConditionBranches.vue +55 -35
  30. package/src/appium-recorder/components/NewScriptDialog.vue +27 -0
  31. package/src/appium-recorder/components/NodeDetail.vue +2 -0
  32. package/src/appium-recorder/components/RecordedSteps.vue +24 -4
  33. package/src/appium-recorder/components/StageLogSettings.vue +17 -0
  34. package/src/appium-recorder/components/TextClickSettings.vue +19 -0
  35. package/src/appium-recorder/components/VisualChangeDialog.vue +151 -0
  36. package/src/appium-recorder/flow-copy.ts +47 -1
  37. package/src/appium-recorder/flow-graph.ts +147 -64
  38. package/src/appium-recorder/flow-labels.ts +35 -6
  39. package/src/appium-recorder/flow-normalize.ts +76 -0
  40. package/src/appium-recorder/flow-remove.ts +69 -0
  41. package/src/appium-recorder/long-press.ts +27 -0
  42. package/src/appium-recorder/native-control-state.ts +26 -0
  43. package/src/appium-recorder/stage-log.ts +21 -0
  44. package/src/appium-recorder/text-click.ts +9 -0
  45. package/src/appium-recorder/tree.ts +3 -0
  46. package/src/appium-recorder/types.ts +34 -2
  47. package/src/appium-recorder/visual-change-pairs.ts +147 -0
  48. package/src/appium-recorder/visual-change.ts +75 -0
  49. package/src/components/config/AppiumConfigPanel.vue +48 -0
  50. package/src/components/config/BaseConfigPanel.vue +90 -0
  51. package/src/components/config/MidsceneConfigPanel.vue +204 -0
  52. package/src/components/device/DevicePreviewPanel.vue +148 -4
  53. package/src/pages/ConfigPage.vue +107 -265
  54. package/src/style.css +43 -4
  55. package/src/types.ts +2 -0
  56. 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 appiumScriptDownloadUrl(id: string) {
72
- return `${APP_BASE}/api/appium-recorder/scripts/${encodeURIComponent(id)}/export`;
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
- watch(() => props.steps.map((step) => step.id).join('|'), () => {
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') return '在“是”分支插入操作';
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
- {{ branchLabel }}
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
- <el-dropdown
138
- trigger="click"
135
+ <FlowActionMenu
136
+ :groups="data.actionGroups"
139
137
  :disabled="!data.canOpenInsertMenu"
140
- max-height="320px"
141
- popper-class="appium-action-dropdown"
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
- <template #dropdown>
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>