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
@@ -1,8 +1,17 @@
1
1
  <script setup lang="ts">
2
+ import { computed } from 'vue';
2
3
  import type { AppiumRecordedStep, AppiumSelector } from '../types';
4
+ import { normalizeVisualChangeConfig } from '../visual-change';
5
+ import { longPressMode } from '../long-press';
6
+ import { defaultFlowKind, isBooleanCondition } from '../flow-labels';
7
+ import TextClickSettings from './TextClickSettings.vue';
8
+ import { isNativeStateCondition } from '../native-control-state';
9
+ import LongPressSettings from './LongPressSettings.vue';
10
+ import StageLogSettings from './StageLogSettings.vue';
3
11
 
4
12
  type FlowKind = 'action' | 'condition' | 'assertion';
5
13
  type SwipeGesture = NonNullable<AppiumRecordedStep['swipe']>;
14
+ type VisualChangeConfig = NonNullable<AppiumRecordedStep['visualChange']>;
6
15
 
7
16
  const props = defineProps<{
8
17
  step: AppiumRecordedStep;
@@ -14,10 +23,20 @@ const emit = defineEmits<{
14
23
  update: [payload: { index: number; step: AppiumRecordedStep }];
15
24
  }>();
16
25
 
26
+ const visualChangeConfig = computed(() => normalizeVisualChangeConfig(props.step.visualChange));
27
+ const visualChangeRoleLabel = computed(() => (
28
+ visualChangeConfig.value.role === 'start'
29
+ ? `${visualChangeConfig.value.pairLabel || '检测画面变化'} · 开始节点`
30
+ : visualChangeConfig.value.role === 'end'
31
+ ? `${visualChangeConfig.value.pairLabel || '检测画面变化'} · 结束节点`
32
+ : '起止节点截图对比'
33
+ ));
34
+ const visualChangeConfigDisabled = computed(() => Boolean(
35
+ props.disabled || visualChangeConfig.value.role === 'end',
36
+ ));
37
+
17
38
  function defaultKind(): FlowKind {
18
- if (props.step.flow?.nodeKind) return props.step.flow.nodeKind;
19
- if (props.step.type === 'assertExists' || props.step.type === 'assertText') return 'assertion';
20
- return 'action';
39
+ return defaultFlowKind(props.step);
21
40
  }
22
41
 
23
42
  function patchStep(patch: Partial<AppiumRecordedStep>) {
@@ -55,18 +74,41 @@ function patchSwipe(patch: Partial<SwipeGesture>) {
55
74
  });
56
75
  }
57
76
 
58
- function timeoutLabel() {
59
- return props.step.type === 'longPress' ? '长按时间 ms' : '超时时间 ms';
77
+ function patchVisualChange(patch: Partial<VisualChangeConfig>) {
78
+ patchStep({
79
+ visualChange: normalizeVisualChangeConfig({
80
+ ...normalizeVisualChangeConfig(props.step.visualChange),
81
+ ...patch,
82
+ }),
83
+ });
60
84
  }
61
85
 
62
- function timeoutMin() {
63
- return props.step.type === 'longPress' ? 80 : 0;
86
+ function patchVisualRegion(key: keyof VisualChangeConfig['region'], value: unknown) {
87
+ const current = normalizeVisualChangeConfig(props.step.visualChange);
88
+ patchVisualChange({
89
+ region: {
90
+ ...current.region,
91
+ [key]: Number(value),
92
+ },
93
+ });
64
94
  }
65
95
 
66
96
  function patchTimeout(value: unknown) {
67
- const timeout = Math.max(timeoutMin(), toInteger(value, props.step.timeoutMs || 0));
97
+ const timeout = Math.max(0, toInteger(value, props.step.timeoutMs || 0));
68
98
  patchStep({ timeoutMs: timeout || undefined });
69
99
  }
100
+
101
+ const showSelector = computed(() => props.step.selector && (
102
+ props.step.type !== 'longPress' || longPressMode(props.step) === 'element'
103
+ ));
104
+
105
+ function patchSelector(patch: Partial<AppiumSelector>) {
106
+ patchStep({
107
+ selector: { ...props.step.selector!, ...patch },
108
+ // 修改元素目标后,不能继续优先使用录制时的备用 XPath。
109
+ ...(props.step.type === 'longPress' || isNativeStateCondition(props.step) ? { selectorChain: undefined } : {}),
110
+ });
111
+ }
70
112
  </script>
71
113
 
72
114
  <template>
@@ -92,7 +134,7 @@ function patchTimeout(value: unknown) {
92
134
  <el-form-item label="节点类型">
93
135
  <el-select
94
136
  :model-value="defaultKind()"
95
- :disabled="disabled"
137
+ :disabled="disabled || isBooleanCondition(step) || step.type === 'log'"
96
138
  @update:model-value="patchFlow({ nodeKind: $event as FlowKind })"
97
139
  >
98
140
  <el-option label="操作" value="action" />
@@ -100,17 +142,26 @@ function patchTimeout(value: unknown) {
100
142
  <el-option label="校验" value="assertion" />
101
143
  </el-select>
102
144
  </el-form-item>
103
- <el-form-item :label="timeoutLabel()">
145
+ <el-form-item v-if="step.type !== 'longPress' && step.type !== 'log'" label="超时时间 ms">
104
146
  <el-input-number
105
147
  :model-value="step.timeoutMs || undefined"
106
148
  :disabled="disabled"
107
- :min="timeoutMin()"
149
+ :min="0"
108
150
  :max="999999"
109
151
  controls-position="right"
110
152
  @update:model-value="patchTimeout($event)"
111
153
  />
112
154
  </el-form-item>
113
155
  </div>
156
+ <LongPressSettings v-if="step.type === 'longPress'" :step="step" :disabled="disabled" @update="patchStep" />
157
+ <StageLogSettings v-if="step.type === 'log'" :step="step" :disabled="disabled" @update="patchStep" />
158
+ <TextClickSettings v-if="step.type === 'textClick'" :step="step" :disabled="disabled" @update="patchStep" />
159
+ <el-form-item v-if="step.type === 'aiRecognition'" label="识别内容">
160
+ <el-input
161
+ :model-value="step.value || ''" type="textarea" :rows="3" maxlength="4000"
162
+ :disabled="disabled" @update:model-value="patchStep({ value: String($event) })"
163
+ />
164
+ </el-form-item>
114
165
  <el-form-item
115
166
  v-if="step.type === 'input' || step.type === 'inputIfExists' || step.type === 'assertText'"
116
167
  label="文本内容"
@@ -121,6 +172,91 @@ function patchTimeout(value: unknown) {
121
172
  @update:model-value="patchStep({ value: String($event) })"
122
173
  />
123
174
  </el-form-item>
175
+ <template v-if="step.type === 'visualChange'">
176
+ <div class="appium-flow-editor__grid">
177
+ <el-form-item label="检测标记">
178
+ <el-input :model-value="visualChangeRoleLabel" disabled />
179
+ </el-form-item>
180
+ <el-form-item label="检测目标">
181
+ <el-select
182
+ :model-value="visualChangeConfig.mode"
183
+ :disabled="visualChangeConfigDisabled"
184
+ @update:model-value="patchVisualChange({ mode: $event as VisualChangeConfig['mode'] })"
185
+ >
186
+ <el-option label="当前选中元素" value="selectedElement" />
187
+ <el-option label="手动区域" value="region" />
188
+ </el-select>
189
+ </el-form-item>
190
+ <el-form-item label="变化阈值 %">
191
+ <el-input-number
192
+ :model-value="visualChangeConfig.changeRatioThreshold"
193
+ :disabled="visualChangeConfigDisabled"
194
+ :min="0.01"
195
+ :max="100"
196
+ :step="0.1"
197
+ controls-position="right"
198
+ @update:model-value="patchVisualChange({ changeRatioThreshold: Number($event) })"
199
+ />
200
+ </el-form-item>
201
+ </div>
202
+ <div class="appium-flow-editor__grid appium-flow-editor__grid--visual">
203
+ <el-form-item label="X">
204
+ <el-input-number
205
+ :model-value="visualChangeConfig.region.x"
206
+ :disabled="visualChangeConfigDisabled"
207
+ :min="0"
208
+ :max="99999"
209
+ :precision="0"
210
+ controls-position="right"
211
+ @update:model-value="patchVisualRegion('x', $event)"
212
+ />
213
+ </el-form-item>
214
+ <el-form-item label="Y">
215
+ <el-input-number
216
+ :model-value="visualChangeConfig.region.y"
217
+ :disabled="visualChangeConfigDisabled"
218
+ :min="0"
219
+ :max="99999"
220
+ :precision="0"
221
+ controls-position="right"
222
+ @update:model-value="patchVisualRegion('y', $event)"
223
+ />
224
+ </el-form-item>
225
+ <el-form-item label="宽度">
226
+ <el-input-number
227
+ :model-value="visualChangeConfig.region.width"
228
+ :disabled="visualChangeConfigDisabled"
229
+ :min="1"
230
+ :max="99999"
231
+ :precision="0"
232
+ controls-position="right"
233
+ @update:model-value="patchVisualRegion('width', $event)"
234
+ />
235
+ </el-form-item>
236
+ <el-form-item label="高度">
237
+ <el-input-number
238
+ :model-value="visualChangeConfig.region.height"
239
+ :disabled="visualChangeConfigDisabled"
240
+ :min="1"
241
+ :max="99999"
242
+ :precision="0"
243
+ controls-position="right"
244
+ @update:model-value="patchVisualRegion('height', $event)"
245
+ />
246
+ </el-form-item>
247
+ <el-form-item label="像素容差">
248
+ <el-input-number
249
+ :model-value="visualChangeConfig.pixelmatchThreshold"
250
+ :disabled="visualChangeConfigDisabled"
251
+ :min="0"
252
+ :max="1"
253
+ :step="0.01"
254
+ controls-position="right"
255
+ @update:model-value="patchVisualChange({ pixelmatchThreshold: Number($event) })"
256
+ />
257
+ </el-form-item>
258
+ </div>
259
+ </template>
124
260
  <div v-if="step.type === 'swipe'" class="appium-flow-editor__grid appium-flow-editor__grid--swipe">
125
261
  <el-form-item label="起点 X">
126
262
  <el-input-number
@@ -178,12 +314,12 @@ function patchTimeout(value: unknown) {
178
314
  />
179
315
  </el-form-item>
180
316
  </div>
181
- <div v-if="step.selector" class="appium-flow-editor__grid">
317
+ <div v-if="showSelector && step.selector" class="appium-flow-editor__grid">
182
318
  <el-form-item label="Selector 类型">
183
319
  <el-select
184
320
  :model-value="step.selector.strategy"
185
321
  :disabled="disabled"
186
- @update:model-value="patchStep({ selector: { ...step.selector!, strategy: $event as AppiumSelector['strategy'] } })"
322
+ @update:model-value="patchSelector({ strategy: $event as AppiumSelector['strategy'] })"
187
323
  >
188
324
  <el-option label="accessibility id" value="accessibilityId" />
189
325
  <el-option label="id" value="id" />
@@ -195,11 +331,11 @@ function patchTimeout(value: unknown) {
195
331
  <el-input
196
332
  :model-value="step.selector.value || ''"
197
333
  :disabled="disabled"
198
- @update:model-value="patchStep({ selector: { ...step.selector!, value: String($event) } })"
334
+ @update:model-value="patchSelector({ value: String($event) })"
199
335
  />
200
336
  </el-form-item>
201
337
  </div>
202
- <div v-if="step.selector" class="appium-flow-editor__grid">
338
+ <div v-if="showSelector" class="appium-flow-editor__grid">
203
339
  <el-form-item label="父级上下文类型">
204
340
  <el-select
205
341
  :model-value="step.contextSelector?.strategy || ''"
@@ -222,16 +358,7 @@ function patchTimeout(value: unknown) {
222
358
  />
223
359
  </el-form-item>
224
360
  </div>
225
- <el-form-item label="可选步骤">
226
- <el-switch
227
- :model-value="Boolean(step.optional)"
228
- :disabled="disabled"
229
- active-text="找不到时跳过"
230
- inactive-text="找不到时失败"
231
- @update:model-value="patchStep({ optional: Boolean($event) })"
232
- />
233
- </el-form-item>
234
- <div v-if="defaultKind() === 'condition'" class="appium-flow-editor__grid">
361
+ <div v-if="defaultKind() === 'condition' && !isBooleanCondition(step)" class="appium-flow-editor__grid">
235
362
  <el-form-item label="指定文本(可选)">
236
363
  <el-input
237
364
  :model-value="step.value || ''"
@@ -0,0 +1,58 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import type { AppiumRecordedStep } from '../types';
4
+ import { longPressMode } from '../long-press';
5
+
6
+ const props = defineProps<{ step: AppiumRecordedStep; disabled?: boolean }>();
7
+ const emit = defineEmits<{ update: [patch: Partial<AppiumRecordedStep>] }>();
8
+ const mode = computed(() => longPressMode(props.step));
9
+
10
+ function changeMode(value: AppiumRecordedStep['longPressMode']) {
11
+ emit('update', {
12
+ longPressMode: value,
13
+ // 旧坐标节点切换到元素模式后,开放定位器输入。
14
+ ...(value === 'element' && (!props.step.selector || props.step.selector.strategy === 'bounds')
15
+ ? { selector: { strategy: 'id' as const, value: '' }, selectorChain: undefined, contextSelector: undefined }
16
+ : {}),
17
+ });
18
+ }
19
+
20
+ function changeCoordinate(key: 'centerX' | 'centerY', value: number | undefined) {
21
+ emit('update', { fallback: { ...props.step.fallback, strategy: 'bounds', [key]: value } });
22
+ }
23
+ </script>
24
+
25
+ <template>
26
+ <div>
27
+ <el-form-item label="长按方式">
28
+ <el-radio-group :model-value="mode" :disabled="disabled" @update:model-value="changeMode($event as AppiumRecordedStep['longPressMode'])">
29
+ <el-radio-button value="element">长按元素</el-radio-button>
30
+ <el-radio-button value="coordinates">长按坐标</el-radio-button>
31
+ </el-radio-group>
32
+ </el-form-item>
33
+ <el-form-item label="长按时间 ms">
34
+ <el-input-number
35
+ :model-value="step.timeoutMs ?? 800"
36
+ :disabled="disabled"
37
+ :min="80"
38
+ :max="999999"
39
+ :precision="0"
40
+ controls-position="right"
41
+ @update:model-value="emit('update', { timeoutMs: $event ?? 800 })"
42
+ />
43
+ </el-form-item>
44
+ <div v-if="mode === 'coordinates'" class="appium-flow-editor__grid">
45
+ <el-form-item v-for="axis in (['X', 'Y'] as const)" :key="axis" :label="axis">
46
+ <el-input-number
47
+ :model-value="axis === 'X' ? step.fallback?.centerX : step.fallback?.centerY"
48
+ :disabled="disabled"
49
+ :min="0"
50
+ :max="99999"
51
+ :precision="0"
52
+ controls-position="right"
53
+ @update:model-value="changeCoordinate(axis === 'X' ? 'centerX' : 'centerY', $event)"
54
+ />
55
+ </el-form-item>
56
+ </div>
57
+ </div>
58
+ </template>
@@ -1,9 +1,11 @@
1
1
  <script setup lang="ts">
2
+ import { defaultFlowKind as defaultKind, flowBranchLabel, flowStepMeta } from '../flow-labels';
2
3
  import { computed } from 'vue';
3
4
  import { CopyDocument, Delete, Edit, Timer } from '@element-plus/icons-vue';
4
5
  import type { AppiumRecordedStep } from '../types';
5
6
  import { buildConditionLayouts, FLOW_BRANCH_GAP, FLOW_NODE_WIDTH } from '../flow-layout';
6
7
  import FlowStepEditor from './FlowStepEditor.vue';
8
+ import FlowActionMenu from './FlowActionMenu.vue';
7
9
 
8
10
  type BranchName = 'yes' | 'no';
9
11
  type FlowKind = 'action' | 'condition' | 'assertion';
@@ -23,6 +25,10 @@ type InsertAction =
23
25
  | 'launchApp'
24
26
  | 'clearAppData'
25
27
  | 'popupCondition'
28
+ | 'checkboxState'
29
+ | 'radioButtonState'
30
+ | 'aiRecognition'
31
+ | 'textClick'
26
32
  | 'tapIfExists'
27
33
  | 'inputIfExists'
28
34
  | 'clearIfExists'
@@ -32,7 +38,12 @@ type InsertAction =
32
38
  | 'assertText'
33
39
  | 'waitDisappear'
34
40
  | 'waitActivity'
35
- | 'runScript';
41
+ | 'runScript'
42
+ | 'noop'
43
+ | 'log'
44
+ | 'visualChangeStart'
45
+ | 'visualChangeEnd'
46
+ | 'visualChange';
36
47
 
37
48
  const PASTE_COMMAND = '__paste_flow_nodes__';
38
49
 
@@ -76,6 +87,9 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
76
87
  { type: 'clearInput', label: '清空输入' },
77
88
  { type: 'coordinateTap', label: '点击坐标' },
78
89
  { type: 'longPress', label: '长按' },
90
+ { type: 'checkboxState', label: '判断 Checkbox 状态' },
91
+ { type: 'textClick', label: '文字点击' },
92
+ { type: 'radioButtonState', label: '判断 RadioButton 状态' },
79
93
  ],
80
94
  },
81
95
  {
@@ -94,6 +108,7 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
94
108
  actions: [
95
109
  { type: 'delay', label: '添加延时' },
96
110
  { type: 'popupCondition', label: '判断存在' },
111
+ { type: 'aiRecognition', label: 'AI 识别' },
97
112
  { type: 'tapIfExists', label: '存在则点击' },
98
113
  { type: 'inputIfExists', label: '存在则输入' },
99
114
  { type: 'clearIfExists', label: '存在则清空' },
@@ -103,17 +118,20 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
103
118
  { type: 'assertText', label: '断言文本' },
104
119
  { type: 'waitDisappear', label: '等待元素消失' },
105
120
  { type: 'waitActivity', label: '等待 Activity' },
121
+ { type: 'visualChangeStart', label: '检测画面变化开始节点' },
122
+ { type: 'visualChangeEnd', label: '检测画面变化结束节点' },
123
+ ],
124
+ },
125
+ {
126
+ title: '流程控制',
127
+ actions: [
128
+ { type: 'noop', label: '空节点' },
129
+ { type: 'log', label: '输出日志' },
130
+ { type: 'runScript', label: '连接脚本' },
106
131
  ],
107
132
  },
108
- { title: '流程控制', actions: [{ type: 'runScript', label: '连接脚本' }] },
109
133
  ];
110
134
 
111
- function defaultKind(step: AppiumRecordedStep): FlowKind {
112
- if (step.flow?.nodeKind) return step.flow.nodeKind;
113
- if (step.type === 'assertExists' || step.type === 'assertText') return 'assertion';
114
- return 'action';
115
- }
116
-
117
135
  function kindLabel(kind: FlowKind) {
118
136
  return { action: '操作', condition: '判断', assertion: '校验' }[kind];
119
137
  }
@@ -125,7 +143,13 @@ function typeLabel(step: AppiumRecordedStep) {
125
143
  waitFor: '等待出现', waitDisappear: '等待消失', assertExists: '断言存在',
126
144
  assertText: '断言文本', key: '按键', waitActivity: '等待 Activity', delay: '延时',
127
145
  coordinateTap: '坐标点击', swipe: '滑动', launchApp: '启动 APP', clearAppData: '清理 APP 缓存', longPress: '长按',
128
- pinch: '双指缩放', runScript: '连接脚本', screenshot: '截图',
146
+ pinch: '双指缩放', runScript: '连接脚本', screenshot: '截图', visualChange: '检测画面变化',
147
+ noop: '空节点',
148
+ log: '输出日志',
149
+ checkboxState: 'Checkbox 状态',
150
+ radioButtonState: 'RadioButton 状态',
151
+ aiRecognition: 'AI 识别',
152
+ textClick: '文字点击',
129
153
  };
130
154
  return labels[step.type] || step.type;
131
155
  }
@@ -133,12 +157,20 @@ function typeLabel(step: AppiumRecordedStep) {
133
157
  function stepMeta(step: AppiumRecordedStep) {
134
158
  if (step.type === 'delay') return `${step.timeoutMs || 1000}ms`;
135
159
  if (step.type === 'input' || step.type === 'inputIfExists') return `输入内容:${step.value || '空'}`;
136
- if (step.type === 'longPress') return `${step.fallback?.centerX || ''},${step.fallback?.centerY || ''} · ${step.timeoutMs || 800}ms`;
160
+ if (step.type === 'longPress') return flowStepMeta(step);
137
161
  if (step.type === 'coordinateTap') return `${step.fallback?.centerX || ''},${step.fallback?.centerY || ''}`;
138
162
  if (step.type === 'swipe') {
139
163
  const swipe = step.swipe;
140
164
  return swipe ? `[${swipe.startX},${swipe.startY}] -> [${swipe.endX},${swipe.endY}]` : '';
141
165
  }
166
+ if (step.type === 'visualChange') {
167
+ const config = step.visualChange;
168
+ if (!config) return '';
169
+ if (config.role === 'start') return `${config.pairLabel || '检测画面变化'} · 开始 · 阈值 ${config.changeRatioThreshold}%`;
170
+ if (config.role === 'end') return `${config.pairLabel || '检测画面变化'} · 结束`;
171
+ return `${config.startStepId && config.endStepId ? '起止节点截图对比' : '请选择起止节点'} · 阈值 ${config.changeRatioThreshold}%`;
172
+ }
173
+ if (step.type === 'noop') return '';
142
174
  if (defaultKind(step) === 'condition' && step.value) {
143
175
  return `${step.flow?.textMatch === 'exact' ? '精准匹配' : '模糊匹配'}:${step.value}`;
144
176
  }
@@ -199,6 +231,12 @@ function isActionDisabled(action: InsertAction) {
199
231
  return Boolean(props.disabled && !props.allowedLockedActions?.includes(action));
200
232
  }
201
233
 
234
+ function canCopyStep(step: AppiumRecordedStep) {
235
+ return step.type !== 'launchApp'
236
+ && step.type !== 'clearAppData'
237
+ && step.visualChange?.role !== 'end';
238
+ }
239
+
202
240
  function insert(branch: BranchName, command: string | number | object) {
203
241
  if (command === PASTE_COMMAND) emit('paste', props.conditionIndex, branch);
204
242
  else emit('insertBranchAction', props.conditionIndex, branch, command as InsertAction);
@@ -244,7 +282,7 @@ function branchSplitPath() {
244
282
  <svg class="appium-flow-line appium-flow-branch__line" viewBox="0 0 10 100" preserveAspectRatio="none" aria-hidden="true">
245
283
  <path d="M5 0 V100" />
246
284
  </svg>
247
- <strong class="appium-flow-branch__label">{{ branch === 'yes' ? '是' : '否' }}</strong>
285
+ <strong class="appium-flow-branch__label">{{ flowBranchLabel(condition, branch) }}</strong>
248
286
  <div v-if="branchItems(branch).length" class="appium-flow-branch-steps">
249
287
  <div v-for="item in branchItems(branch)" :key="item.step.id" class="appium-flow-branch-step-group">
250
288
  <div
@@ -275,7 +313,7 @@ function branchSplitPath() {
275
313
  </button>
276
314
  <span class="appium-flow-node__actions" @click.stop>
277
315
  <el-button
278
- v-if="!copyMode"
316
+ v-if="!copyMode && canCopyStep(item.step)"
279
317
  text
280
318
  size="small"
281
319
  :icon="CopyDocument"
@@ -332,11 +370,11 @@ function branchSplitPath() {
332
370
  </div>
333
371
  <span v-else class="appium-flow-branch__target">{{ targetLabel(branch) }}</span>
334
372
  <div class="appium-flow-branch__actions">
335
- <el-dropdown
336
- trigger="click"
373
+ <FlowActionMenu
374
+ :groups="actionGroups"
337
375
  :disabled="!canOpenInsertMenu()"
338
- max-height="320px"
339
- popper-class="appium-action-dropdown"
376
+ :clipboard-count="clipboardCount"
377
+ :is-action-disabled="isActionDisabled"
340
378
  @command="insert(branch, $event)"
341
379
  >
342
380
  <el-button
@@ -348,25 +386,7 @@ function branchSplitPath() {
348
386
  >
349
387
  插入操作
350
388
  </el-button>
351
- <template #dropdown>
352
- <el-dropdown-menu>
353
- <el-dropdown-item v-if="clipboardCount" :command="PASTE_COMMAND" divided>
354
- 粘贴 {{ clipboardCount }} 个节点
355
- </el-dropdown-item>
356
- <template v-for="group in actionGroups" :key="group.title">
357
- <div class="appium-action-dropdown__group">{{ group.title }}</div>
358
- <el-dropdown-item
359
- v-for="action in group.actions"
360
- :key="action.type"
361
- :command="action.type"
362
- :disabled="isActionDisabled(action.type)"
363
- >
364
- {{ action.label }}
365
- </el-dropdown-item>
366
- </template>
367
- </el-dropdown-menu>
368
- </template>
369
- </el-dropdown>
389
+ </FlowActionMenu>
370
390
  </div>
371
391
  </div>
372
392
  </div>
@@ -0,0 +1,27 @@
1
+ <script setup lang="ts">
2
+ defineProps<{ scriptName: string; saving: boolean }>();
3
+ defineEmits<{ save: []; discard: []; cancel: [] }>();
4
+ </script>
5
+
6
+ <template>
7
+ <el-dialog
8
+ :model-value="true" title="新建脚本" width="min(480px, calc(100vw - 32px))"
9
+ append-to-body :close-on-click-modal="false" :close-on-press-escape="!saving"
10
+ :show-close="!saving" @close="$emit('cancel')"
11
+ >
12
+ <p class="new-script-message">是否保存当前脚本「{{ scriptName || '未命名脚本' }}」后再新建?</p>
13
+ <template #footer>
14
+ <div class="new-script-actions">
15
+ <el-button :disabled="saving" @click="$emit('cancel')">取消</el-button>
16
+ <el-button :disabled="saving" @click="$emit('discard')">不保存并新建</el-button>
17
+ <el-button type="primary" :loading="saving" @click="$emit('save')">保存并新建</el-button>
18
+ </div>
19
+ </template>
20
+ </el-dialog>
21
+ </template>
22
+
23
+ <style scoped>
24
+ .new-script-message { overflow-wrap: anywhere; }
25
+ .new-script-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
26
+ .new-script-actions .el-button { margin: 0; }
27
+ </style>
@@ -21,6 +21,8 @@ defineProps<{
21
21
  <el-descriptions-item label="content-desc">{{ node.contentDesc || '-' }}</el-descriptions-item>
22
22
  <el-descriptions-item label="text">{{ node.text || '-' }}</el-descriptions-item>
23
23
  <el-descriptions-item label="class">{{ node.className || '-' }}</el-descriptions-item>
24
+ <el-descriptions-item v-if="node.checkable !== undefined" label="checkable">{{ String(node.checkable) }}</el-descriptions-item>
25
+ <el-descriptions-item v-if="node.checkable" label="checked">{{ node.checked === undefined ? '-' : String(node.checked) }}</el-descriptions-item>
24
26
  <el-descriptions-item label="当前 Activity">{{ currentActivity || '-' }}</el-descriptions-item>
25
27
  <el-descriptions-item label="selector">
26
28
  <span v-if="currentActivity">Activity {{ currentActivity }} · </span>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, shallowRef } from 'vue';
2
+ import { computed, nextTick, shallowRef, watch } from 'vue';
3
3
  import { ElMessage } from 'element-plus';
4
4
  import { Aim, CopyDocument, FullScreen } from '@element-plus/icons-vue';
5
5
  import type { AppiumRecordedStep } from '../types';
@@ -9,6 +9,7 @@ import { labelFlowStep } from '../flow-labels';
9
9
  import type { FlowActionGroup, FlowBranch, InsertAction } from '../flow-graph';
10
10
 
11
11
  const props = defineProps<{
12
+ aiRecognitionModelConfigured?: boolean;
12
13
  steps: AppiumRecordedStep[];
13
14
  disabled?: boolean;
14
15
  removeDisabled?: boolean;
@@ -35,6 +36,7 @@ const copyMode = shallowRef(false);
35
36
  const selectedCopyIndexes = shallowRef<number[]>([]);
36
37
  const flowDialogVisible = shallowRef(false);
37
38
  const mainResetViewToken = shallowRef(0);
39
+ const dialogResetViewToken = shallowRef(0);
38
40
 
39
41
  const insertActionGroups: FlowActionGroup[] = [
40
42
  {
@@ -45,6 +47,9 @@ const insertActionGroups: FlowActionGroup[] = [
45
47
  { type: 'clearInput', label: '清空输入' },
46
48
  { type: 'coordinateTap', label: '点击坐标' },
47
49
  { type: 'longPress', label: '长按' },
50
+ { type: 'checkboxState', label: '判断 Checkbox 状态' },
51
+ { type: 'textClick', label: '文字点击' },
52
+ { type: 'radioButtonState', label: '判断 RadioButton 状态' },
48
53
  ],
49
54
  },
50
55
  {
@@ -65,6 +70,7 @@ const insertActionGroups: FlowActionGroup[] = [
65
70
  actions: [
66
71
  { type: 'delay', label: '添加延时' },
67
72
  { type: 'popupCondition', label: '判断存在' },
73
+ { type: 'aiRecognition', label: 'AI 识别' },
68
74
  { type: 'tapIfExists', label: '存在则点击' },
69
75
  { type: 'inputIfExists', label: '存在则输入' },
70
76
  { type: 'clearIfExists', label: '存在则清空' },
@@ -74,11 +80,17 @@ const insertActionGroups: FlowActionGroup[] = [
74
80
  { type: 'assertText', label: '断言文本' },
75
81
  { type: 'waitDisappear', label: '等待元素消失' },
76
82
  { type: 'waitActivity', label: '等待 Activity' },
83
+ { type: 'visualChangeStart', label: '检测画面变化开始节点' },
84
+ { type: 'visualChangeEnd', label: '检测画面变化结束节点' },
77
85
  ],
78
86
  },
79
87
  {
80
88
  title: '流程控制',
81
- actions: [{ type: 'runScript', label: '连接脚本' }],
89
+ actions: [
90
+ { type: 'noop', label: '空节点' },
91
+ { type: 'log', label: '输出日志' },
92
+ { type: 'runScript', label: '连接脚本' },
93
+ ],
82
94
  },
83
95
  ];
84
96
 
@@ -169,6 +181,13 @@ function resetMainFlowPosition() {
169
181
  mainResetViewToken.value += 1;
170
182
  }
171
183
 
184
+ watch(flowDialogVisible, (visible) => {
185
+ if (!visible) return;
186
+ void nextTick(() => {
187
+ dialogResetViewToken.value += 1;
188
+ });
189
+ });
190
+
172
191
  function canOpenInsertMenu() {
173
192
  return !props.disabled || Boolean(props.allowedLockedActions?.length);
174
193
  }
@@ -220,7 +239,6 @@ function updateStep(index: number, step: AppiumRecordedStep) {
220
239
  <el-button
221
240
  size="small"
222
241
  :icon="Aim"
223
- :disabled="!steps.length"
224
242
  @click="resetMainFlowPosition"
225
243
  >
226
244
  还原位置
@@ -228,7 +246,6 @@ function updateStep(index: number, step: AppiumRecordedStep) {
228
246
  <el-button
229
247
  size="small"
230
248
  :icon="FullScreen"
231
- :disabled="!steps.length"
232
249
  @click="flowDialogVisible = true"
233
250
  >
234
251
  放大
@@ -244,6 +261,7 @@ function updateStep(index: number, step: AppiumRecordedStep) {
244
261
  :disabled="disabled"
245
262
  :remove-disabled="removeDisabled"
246
263
  :launching-step-id="launchingStepId"
264
+ :ai-recognition-model-configured="aiRecognitionModelConfigured"
247
265
  :clipboard-count="clipboardCount"
248
266
  :reset-view-token="mainResetViewToken"
249
267
  :start-action-groups="startActionGroups"
@@ -307,7 +325,9 @@ function updateStep(index: number, step: AppiumRecordedStep) {
307
325
  :disabled="disabled"
308
326
  :remove-disabled="removeDisabled"
309
327
  :launching-step-id="launchingStepId"
328
+ :ai-recognition-model-configured="aiRecognitionModelConfigured"
310
329
  :clipboard-count="clipboardCount"
330
+ :reset-view-token="dialogResetViewToken"
311
331
  :start-action-groups="startActionGroups"
312
332
  :main-action-groups="mainActionGroups"
313
333
  :can-open-insert-menu="canOpenInsertMenu()"