android-midscene-automation 0.1.28 → 0.1.29

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 CHANGED
@@ -1,7 +1,16 @@
1
1
  # 更新记录
2
2
 
3
+ ## v0.1.29
4
+
5
+ - 判断节点新增“合并分支”,支持选择一侧公共流程起点,将两侧后续汇入同一段居中流程;另一侧可保留独有操作或确认删除重复后缀,支持嵌套判断及各类分支,公共节点只执行一次且不随单侧节点删除。
6
+ - 将 Checkbox、RadioButton 判断入口合并为“判断勾选”,新增 Switch 支持;添加和回放时自动校验组件类型及可勾选属性,读取实时 checked 状态进入 true / false 分支,不改变控件状态,兼容已有独立判断节点。
7
+ - “流程控制”新增终止流程节点,执行到该节点时主动结束本次回放,不再执行后续节点或连接脚本;子脚本触发时也结束整个流程,保留执行日志和报告,不作为执行失败。
8
+ - “设备操作”新增启动相册顺序节点,优先解析系统相册入口,必要时查找已安装的常见相册应用;仅打开相册,不选择照片,日志记录实际启动应用,找不到或启动失败时明确报错。
9
+ - Appium 配置新增流程背景色设置,默认沿用浅绿色,支持预选颜色、颜色选择器、#RGB / #RRGGBB 输入校验和恢复默认;保存后持久化,普通画布、放大视图和连接脚本预览统一应用。
10
+
3
11
  ## v0.1.28
4
12
 
13
+ - Appium 配置中的流程背景色独立成区,与节点模型配置分开展示,保留预选颜色、自定义颜色及保存入口。
5
14
  - “组件操作”新增文字点击分支节点,无需预选元素,支持精准匹配和模糊匹配原生组件文字;匹配并点击后进入“匹配到文字”,等待超时未找到则进入“未匹配到文字”。支持编辑文字和超时,多元素匹配及点击异常明确报错,日志和报告保留分支结果。
6
15
  - “流程控制”新增输出日志节点,支持设置日志内容和自定义关键字,默认按 `stageLog:内容` 输出;支持添加后编辑,回放实时输出、日志文件及报告均保留日志内容,多行内容逐行添加关键字。
7
16
  - 新增 AI 识别判断节点,可输入按钮可用、画面黑屏等识别要求,通过当前设备截图返回 `true / false` 并进入对应分支;Appium配置支持独立设置 Base URL、API Key、Model Name 和测试模型,模型需支持图片输入。
package/README.md CHANGED
@@ -1229,8 +1229,17 @@ Linux: ~/.local/share/android-midscene-automation
1229
1229
 
1230
1230
  # 项目更新记录
1231
1231
 
1232
+ ## v0.1.29
1233
+
1234
+ - 判断节点新增“合并分支”,支持选择一侧公共流程起点,将两侧后续汇入同一段居中流程;另一侧可保留独有操作或确认删除重复后缀,支持嵌套判断及各类分支,公共节点只执行一次且不随单侧节点删除。
1235
+ - 将 Checkbox、RadioButton 判断入口合并为“判断勾选”,新增 Switch 支持;添加和回放时自动校验组件类型及可勾选属性,读取实时 checked 状态进入 true / false 分支,不改变控件状态,兼容已有独立判断节点。
1236
+ - “流程控制”新增终止流程节点,执行到该节点时主动结束本次回放,不再执行后续节点或连接脚本;子脚本触发时也结束整个流程,保留执行日志和报告,不作为执行失败。
1237
+ - “设备操作”新增启动相册顺序节点,优先解析系统相册入口,必要时查找已安装的常见相册应用;仅打开相册,不选择照片,日志记录实际启动应用,找不到或启动失败时明确报错。
1238
+ - Appium 配置新增流程背景色设置,默认沿用浅绿色,支持预选颜色、颜色选择器、#RGB / #RRGGBB 输入校验和恢复默认;保存后持久化,普通画布、放大视图和连接脚本预览统一应用。
1239
+
1232
1240
  ## v0.1.28
1233
1241
 
1242
+ - Appium 配置中的流程背景色独立成区,与节点模型配置分开展示,保留预选颜色、自定义颜色及保存入口。
1234
1243
  - “组件操作”新增文字点击分支节点,无需预选元素,支持精准匹配和模糊匹配原生组件文字;匹配并点击后进入“匹配到文字”,等待超时未找到则进入“未匹配到文字”。支持编辑文字和超时,多元素匹配及点击异常明确报错,日志和报告保留分支结果。
1235
1244
  - “流程控制”新增输出日志节点,支持设置日志内容和自定义关键字,默认按 `stageLog:内容` 输出;支持添加后编辑,回放实时输出、日志文件及报告均保留日志内容,多行内容逐行添加关键字。
1236
1245
  - 新增 AI 识别判断节点,可输入按钮可用、画面黑屏等识别要求,通过当前设备截图返回 `true / false` 并进入对应分支;Appium配置支持独立设置 Base URL、API Key、Model Name 和测试模型,模型需支持图片输入。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "android-midscene-automation",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oooooooko/android-midscene-automation.git"
@@ -2,6 +2,7 @@ import { execFile } from 'node:child_process';
2
2
  import { adbScreenshotBase64 } from './screenshot';
3
3
  import { recognizeDeviceScreen } from './ai-recognition';
4
4
  import { formatStageLog } from '../../src/appium-recorder/stage-log';
5
+ import { openGalleryOnDevice } from './open-gallery';
5
6
  import { textClickSelector } from '../../src/appium-recorder/text-click';
6
7
  import { AsyncLocalStorage } from 'node:async_hooks';
7
8
  import { mkdir, writeFile } from 'node:fs/promises';
@@ -14,7 +15,7 @@ import { startManagedAppiumServer, usesManagedAppiumServer } from './managed-app
14
15
  import { compareVisualChangeFrames, type VisualChangeRegion } from './visual-change';
15
16
  import { longPressMode, validateLongPress } from '../../src/appium-recorder/long-press';
16
17
  import { defaultFlowKind, flowBranchLabel } from '../../src/appium-recorder/flow-labels';
17
- import { isNativeStateCondition, nativeControlName, nativeControlType, parseNativeBoolean } from '../../src/appium-recorder/native-control-state';
18
+ import { isNativeStateCondition, nativeControlName, matchesNativeControl, parseNativeBoolean } from '../../src/appium-recorder/native-control-state';
18
19
 
19
20
  type AppiumSessionResponse = {
20
21
  value?: {
@@ -57,6 +58,7 @@ const replayContext = new AsyncLocalStorage<{
57
58
  visualChecks: AppiumReplayVisualCheck[];
58
59
  pendingVisualChecks: PendingVisualChangeCheck[];
59
60
  softFailureCount: number;
61
+ flowEnded?: boolean;
60
62
  }>();
61
63
 
62
64
  const appiumServerUrl = () => replayContext.getStore()?.serverUrl || configuredAppiumServerUrl();
@@ -523,6 +525,7 @@ type RunStepResult = string | {
523
525
  };
524
526
 
525
527
  function completedStepStatus(step: AppiumRecordedStepRecord, softFailed: boolean) {
528
+ if (step.type === 'endFlow') return '流程已结束';
526
529
  if (step.type === 'visualChange') {
527
530
  if (step.visualChange?.role === 'start') return '已记录基准帧';
528
531
  if (step.visualChange?.role === 'end') return '已记录对比帧';
@@ -729,6 +732,13 @@ async function runStep(
729
732
  return;
730
733
  }
731
734
  if (step.type === 'log') return formatStageLog(step);
735
+ if (step.type === 'openGallery') return openGalleryOnDevice(deviceId, replayContext.getStore()?.signal);
736
+ if (step.type === 'endFlow') {
737
+ // 同一次回放的主脚本和连接脚本共享结束状态,不影响下一次回放。
738
+ const context = replayContext.getStore();
739
+ if (context) context.flowEnded = true;
740
+ return '流程已主动结束,后续节点不再执行';
741
+ }
732
742
 
733
743
  if (step.type === 'coordinateTap') {
734
744
  await tapFallback(deviceId, step);
@@ -894,7 +904,7 @@ async function readNativeControlState(sessionId: string, step: AppiumRecordedSte
894
904
  await appiumRequest<AppiumValueResponse<unknown>>(`/session/${sessionId}/element/${elementId}/attribute/${name}`)
895
905
  ).value;
896
906
  const className = await attribute('className');
897
- if (nativeControlType(className) !== step.type) {
907
+ if (!matchesNativeControl(step.type, className)) {
898
908
  throw new Error(`${step.label} 目标不是原生 ${nativeControlName(step.type)},实际类型:${String(className ?? '未知')}`);
899
909
  }
900
910
  if (parseNativeBoolean(await attribute('checkable')) !== true) {
@@ -1067,6 +1077,7 @@ async function replayLinearSteps(
1067
1077
  options: ReplayStepOptions = {},
1068
1078
  ) {
1069
1079
  for (const [index, step] of steps.entries()) {
1080
+ if (replayContext.getStore()?.flowEnded) return;
1070
1081
  throwIfReplayStopped();
1071
1082
  if (shouldSkipLinkedScriptInitStep(options, step)) {
1072
1083
  lines.push(`[节点 ${index + 1}] 跳过:${step.label}(${linkedScriptInitSkipReason(step)})`);
@@ -1133,6 +1144,7 @@ async function replayFlowSteps(
1133
1144
  const maxVisits = Math.max(steps.length * 4, 20);
1134
1145
 
1135
1146
  while (typeof index === 'number' && index >= 0 && index < steps.length) {
1147
+ if (replayContext.getStore()?.flowEnded) return;
1136
1148
  throwIfReplayStopped();
1137
1149
  guard += 1;
1138
1150
  if (guard > maxVisits) throw new Error('流程图可能存在循环,已终止回放');
@@ -1261,6 +1273,7 @@ async function replayScriptSteps(
1261
1273
  await replayStepGroup(sessionId, deviceId, mainSteps, lines, stack, options);
1262
1274
 
1263
1275
  for (const [offset, step] of trailingLinkedSteps.entries()) {
1276
+ if (replayContext.getStore()?.flowEnded) return;
1264
1277
  const nodeNumber = trailingLinkIndex + offset + 1;
1265
1278
  lines.push(`当前脚本步骤完成,开始执行:${step.label}`);
1266
1279
  lines.push(`[节点 ${nodeNumber}] 开始:${step.label}`);
@@ -0,0 +1,52 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { getAdbCommand } from '../android-sdk';
3
+
4
+ const MAIN = 'android.intent.action.MAIN';
5
+ const GALLERY = 'android.intent.category.APP_GALLERY';
6
+ const LAUNCHER = 'android.intent.category.LAUNCHER';
7
+ // 仅检查已安装的相册应用,不安装应用,也不打开照片选择器。
8
+ const galleryPackages = [
9
+ 'com.android.gallery3d', 'com.miui.gallery', 'com.sec.android.gallery3d',
10
+ 'com.huawei.photos', 'com.hihonor.photos', 'com.coloros.gallery3d',
11
+ 'com.oplus.gallery', 'com.vivo.gallery', 'com.google.android.apps.photos',
12
+ ];
13
+
14
+ function components(output: string) {
15
+ return output.split(/\r?\n/).map((line) => line.trim()).filter((line) => (
16
+ /^[\w.]+\/[\w.$]+$/.test(line) && !/(?:Resolver|Chooser)Activity/.test(line)
17
+ ));
18
+ }
19
+
20
+ export async function openGalleryOnDevice(deviceId: string, signal?: AbortSignal) {
21
+ const run = (args: string[]) => new Promise<string>((resolve, reject) => {
22
+ execFile(getAdbCommand(), ['-s', deviceId, 'shell', ...args], {
23
+ timeout: 20000, maxBuffer: 4 * 1024 * 1024, signal,
24
+ }, (error, stdout, stderr) => {
25
+ if (error) { reject(error); return; }
26
+ resolve(`${stdout}\n${stderr}`.trim());
27
+ });
28
+ });
29
+ const query = (command: string, category: string, packageName?: string) => run([
30
+ 'cmd', 'package', command, '--brief', '--user', 'current', '-a', MAIN, '-c', category,
31
+ ...(packageName ? ['-p', packageName] : []),
32
+ ]);
33
+ let component = components(await query('resolve-activity', GALLERY))[0];
34
+ if (!component) component = components(await query('query-activities', GALLERY))[0];
35
+ if (!component) {
36
+ const installed = new Set((await run(['pm', 'list', 'packages', '--user', 'current']))
37
+ .split(/\r?\n/).map((line) => line.trim().replace(/^package:/, '')));
38
+ for (const packageName of galleryPackages) {
39
+ if (!installed.has(packageName)) continue;
40
+ component = components(await query('resolve-activity', LAUNCHER, packageName))
41
+ .find((item) => item.startsWith(`${packageName}/`));
42
+ if (component) break;
43
+ }
44
+ }
45
+ if (!component) throw new Error('未找到可启动的相册应用,请检查手机是否安装并启用了相册');
46
+ const output = await run(['am', 'start', '-W', '--user', 'current', '-a', MAIN, '-n', component]);
47
+ // am start 某些错误仍返回 0,不能只检查进程退出码。
48
+ if (!/^Status:\s*ok\s*$/im.test(output) || /Error:|Exception|Permission Denial/i.test(output)) {
49
+ throw new Error(`启动相册失败:${output || component}`);
50
+ }
51
+ return `已启动相册:${component}`;
52
+ }
@@ -20,6 +20,7 @@ export type AppiumRecordedStepRecord = {
20
20
  | 'waitFor'
21
21
  | 'assertExists'
22
22
  | 'checkboxState'
23
+ | 'checkedState'
23
24
  | 'radioButtonState'
24
25
  | 'aiRecognition'
25
26
  | 'textClick'
@@ -31,6 +32,8 @@ export type AppiumRecordedStepRecord = {
31
32
  | 'swipe'
32
33
  | 'screenshot'
33
34
  | 'launchApp'
35
+ | 'openGallery'
36
+ | 'endFlow'
34
37
  | 'clearAppData'
35
38
  | 'waitDisappear'
36
39
  | 'assertText'
package/server/config.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import { isHexColor, normalizeFlowBackground } from '../src/appium-recorder/flow-appearance';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import YAML from 'yaml';
@@ -7,7 +8,7 @@ import { loadModelConfigFromDb, saveModelConfigToDb } from './config-store';
7
8
  import type { AiRecognitionModel } from '../src/appium-recorder/ai-recognition';
8
9
 
9
10
  export type AppConfig = {
10
- appium: { model: AiRecognitionModel };
11
+ appium: { model: AiRecognitionModel; flowBackgroundColor?: string };
11
12
  runtime: {
12
13
  androidSdkPath: string;
13
14
  reportOutputPath: string;
@@ -83,6 +84,7 @@ function normalizeConfig(config: Partial<AppConfig> | null | undefined): AppConf
83
84
  return {
84
85
  // 旧配置没有 Appium 模型时保留空值,不借用其他模型或改变已有配置。
85
86
  appium: {
87
+ flowBackgroundColor: normalizeFlowBackground(config?.appium?.flowBackgroundColor),
86
88
  model: Object.fromEntries(['baseUrl', 'apiKey', 'name'].map((key) => {
87
89
  const value = config?.appium?.model?.[key as keyof AiRecognitionModel];
88
90
  return [key, typeof value === 'string' ? value.trim() : ''];
@@ -204,6 +206,9 @@ export function loadConfig(): AppConfig {
204
206
  }
205
207
 
206
208
  export function saveConfig(config: AppConfig) {
209
+ if (config.appium?.flowBackgroundColor !== undefined && !isHexColor(config.appium.flowBackgroundColor)) {
210
+ throw new ConfigValidationError('流程背景色必须是有效的十六进制颜色(#RGB 或 #RRGGBB)');
211
+ }
207
212
  const normalized = normalizeConfig(config);
208
213
  validateRuntimeConfig(normalized);
209
214
  cachedConfig = normalized;
package/src/App.vue CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  import * as api from './api';
34
34
  import AutomationPage from './pages/AutomationPage.vue';
35
35
  import AppiumPage from './appium-recorder/AppiumPage.vue';
36
+ import { DEFAULT_FLOW_BACKGROUND, isHexColor } from './appium-recorder/flow-appearance';
36
37
  import ConfigPage from './pages/ConfigPage.vue';
37
38
  import GeneratorPage from './pages/GeneratorPage.vue';
38
39
  import type {
@@ -702,6 +703,10 @@ const getMidsceneModelConfigError = () => {
702
703
  };
703
704
 
704
705
  const saveModelConfig = async () => {
706
+ if (!isHexColor(configForm.appium.flowBackgroundColor ?? DEFAULT_FLOW_BACKGROUND)) {
707
+ ElMessage.warning('流程背景色格式错误,请输入 #RGB 或 #RRGGBB');
708
+ return;
709
+ }
705
710
  isSavingModelConfig.value = true;
706
711
  errorMessage.value = '';
707
712
  openActionDialog('保存参数配置');
@@ -1564,6 +1569,7 @@ onUnmounted(() => {
1564
1569
  :active="activeMenu === 'appium'"
1565
1570
  :app-presets="appPresets"
1566
1571
  :ai-recognition-model-configured="aiRecognitionModelConfigured"
1572
+ :flow-background-color="configForm.appium.flowBackgroundColor"
1567
1573
  :device-actions="deviceActions"
1568
1574
  :playground-available="playgroundAvailable"
1569
1575
  :playground-device-id="playgroundDeviceId"
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
- import { computed, h, onMounted, onUnmounted, reactive, shallowRef, watch } from 'vue';
2
+ import { computed, h, onMounted, onUnmounted, provide, reactive, shallowRef, watch } from 'vue';
3
+ import { flowBackgroundKey, normalizeFlowBackground } from './flow-appearance';
3
4
  import { ElForm, ElFormItem, ElInputNumber, ElMessage, ElMessageBox, ElOption, ElSelect } from 'element-plus';
4
5
  import { Check, CircleClose, CopyDocument, Delete, Document, Download, Edit, Plus, Refresh, Upload, VideoPlay, View } from '@element-plus/icons-vue';
5
6
  import type { AndroidDevice, AppPreset, DeviceAction } from '../types';
@@ -37,7 +38,7 @@ import {
37
38
  } from './flow-copy';
38
39
  import { labelFlowStep } from './flow-labels';
39
40
  import { validateLongPress } from './long-press';
40
- import { nativeControlName, nativeControlType } from './native-control-state';
41
+ import { nativeControlName, matchesNativeControl } from './native-control-state';
41
42
  import { normalizeLegacyNestedConditionBranches } from './flow-normalize';
42
43
  import { removeFlowStep } from './flow-remove';
43
44
  import type { FlowActionGroup, InsertAction } from './flow-graph';
@@ -89,6 +90,7 @@ const insertableRecorderActions: RecorderAction[] = [
89
90
  'backIfExists',
90
91
  'popupCondition',
91
92
  'checkboxState',
93
+ 'checkedState',
92
94
  'radioButtonState',
93
95
  'aiRecognition',
94
96
  'textClick',
@@ -102,6 +104,8 @@ const insertableRecorderActions: RecorderAction[] = [
102
104
  'clearInput',
103
105
  'coordinateTap',
104
106
  'launchApp',
107
+ 'openGallery',
108
+ 'endFlow',
105
109
  'clearAppData',
106
110
  'waitDisappear',
107
111
  'assertText',
@@ -117,6 +121,7 @@ const readonlyFlowSelectedIndexes: number[] = [];
117
121
 
118
122
  const props = defineProps<{
119
123
  aiRecognitionModelConfigured?: boolean;
124
+ flowBackgroundColor?: string;
120
125
  active: boolean;
121
126
  appPresets: AppPreset[];
122
127
  deviceActions: readonly DeviceAction[];
@@ -140,6 +145,8 @@ const props = defineProps<{
140
145
  ) => Promise<void>;
141
146
  }>();
142
147
 
148
+ // provide/inject 随组件关系传递,放大弹窗 teleport 后也能使用同一颜色。
149
+ provide(flowBackgroundKey, computed(() => normalizeFlowBackground(props.flowBackgroundColor)));
143
150
  const selectedDeviceId = computed(() => props.playgroundDeviceId);
144
151
  const tree = shallowRef<AppiumNode | null>(null);
145
152
  const selectedNode = shallowRef<AppiumNode | null>(null);
@@ -1273,6 +1280,12 @@ async function addAction(
1273
1280
  if (action === 'delay') {
1274
1281
  return addDelayStep(index, branchTarget);
1275
1282
  }
1283
+ if (action === 'openGallery') {
1284
+ return insertStep({ id: createStepId(), type: 'openGallery', label: '启动相册', flow: { nodeKind: 'action' } }, index, branchTarget);
1285
+ }
1286
+ if (action === 'endFlow') {
1287
+ return insertStep({ id: createStepId(), type: 'endFlow', label: '终止流程', flow: { nodeKind: 'action' } }, index, branchTarget);
1288
+ }
1276
1289
  if (action === 'aiRecognition') {
1277
1290
  const input = await ElMessageBox.prompt('识别内容', '添加 AI 识别', {
1278
1291
  inputType: 'textarea',
@@ -1416,13 +1429,13 @@ async function addAction(
1416
1429
  }
1417
1430
  const node = getSelectedNode();
1418
1431
  if (!node) return;
1419
- if (action === 'checkboxState' || action === 'radioButtonState') {
1432
+ if (action === 'checkboxState' || action === 'radioButtonState' || action === 'checkedState') {
1420
1433
  const controlName = nativeControlName(action);
1421
- if (nativeControlType(node.className) !== action || node.checkable !== true) {
1434
+ if (!matchesNativeControl(action, node.className) || node.checkable !== true) {
1422
1435
  ElMessage.warning(`请选择原生 ${controlName} 元素,当前选中:${node.className || '未知类型'}`);
1423
1436
  return;
1424
1437
  }
1425
- const step = createNodeActionStep(action, `判断 ${controlName} 状态`, node, { timeoutMs: 2000 });
1438
+ const step = createNodeActionStep(action, action === 'checkedState' ? '判断勾选' : `判断 ${controlName} 状态`, node, { timeoutMs: 2000 });
1426
1439
  if ((!step.selector?.value || step.selector.strategy === 'bounds') && node.xpath) {
1427
1440
  step.selector = { strategy: 'xpath', value: node.xpath };
1428
1441
  step.contextSelector = undefined;
@@ -2118,6 +2131,7 @@ watch(
2118
2131
  :clipboard-count="flowClipboardCount"
2119
2132
  :disabled="recordingLocked"
2120
2133
  :remove-disabled="recordingBusy"
2134
+ :merge-disabled="recordingBusy || replaying || saving"
2121
2135
  :allowed-locked-actions="scriptActivityMismatch && !recordingBusy ? insertableRecorderActions : []"
2122
2136
  :launching-step-id="executingAppStepId"
2123
2137
  @remove="removeStep"
@@ -2130,6 +2144,7 @@ watch(
2130
2144
  @preview-linked-script="previewLinkedScriptStep"
2131
2145
  @execute-step="executeFlowStep"
2132
2146
  @update-step="updateStep"
2147
+ @replace-steps="!recordingBusy && !replaying && !saving && (steps = $event)"
2133
2148
  />
2134
2149
  </section>
2135
2150
 
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
- import { computed, nextTick, onBeforeUnmount, shallowRef, watch } from 'vue';
2
+ import { computed, inject, nextTick, onBeforeUnmount, shallowRef, watch } from 'vue';
3
+ import { DEFAULT_FLOW_BACKGROUND, flowBackgroundKey } from '../flow-appearance';
3
4
  import { useVueFlow, VueFlow } from '@vue-flow/core';
4
5
  import FlowNodeCard from './FlowNodeCard.vue';
5
6
  import FlowRoundedEdge from './FlowRoundedEdge.vue';
@@ -12,6 +13,8 @@ import {
12
13
  } from '../flow-graph';
13
14
  import type { AppiumRecordedStep } from '../types';
14
15
 
16
+ const flowBackground = inject(flowBackgroundKey, computed(() => DEFAULT_FLOW_BACKGROUND));
17
+
15
18
  const props = defineProps<{
16
19
  aiRecognitionModelConfigured?: boolean;
17
20
  id: string;
@@ -21,6 +24,7 @@ const props = defineProps<{
21
24
  selectedCopyIndexes: number[];
22
25
  disabled?: boolean;
23
26
  removeDisabled?: boolean;
27
+ mergeDisabled?: boolean;
24
28
  readonly?: boolean;
25
29
  launchingStepId?: string;
26
30
  clipboardCount?: number;
@@ -37,6 +41,7 @@ const props = defineProps<{
37
41
 
38
42
  const emit = defineEmits<{
39
43
  nodeClick: [index: number];
44
+ merge: [index: number];
40
45
  copy: [indexes: number[]];
41
46
  paste: [index: number, branch?: FlowBranch];
42
47
  remove: [index: number];
@@ -56,6 +61,7 @@ let resetViewFrame = 0;
56
61
  let pendingResetView = false;
57
62
 
58
63
  const graph = computed(() => buildFlowGraph(props.steps, {
64
+ mergeDisabled: props.mergeDisabled,
59
65
  aiRecognitionModelConfigured: props.aiRecognitionModelConfigured,
60
66
  expandedStepIndex: props.expandedStepIndex,
61
67
  selectedCopyIndexes: props.selectedCopyIndexes,
@@ -170,7 +176,7 @@ function handleInsert(payload: {
170
176
  </script>
171
177
 
172
178
  <template>
173
- <div class="appium-flow-canvas appium-flow-canvas--vue">
179
+ <div class="appium-flow-canvas appium-flow-canvas--vue" :style="{ '--flow-background': flowBackground }">
174
180
  <VueFlow
175
181
  :id="id"
176
182
  class="appium-vue-flow"
@@ -204,6 +210,7 @@ function handleInsert(payload: {
204
210
  @execute="emit('executeStep', $event)"
205
211
  @insert="handleInsert"
206
212
  @update-step="emit('updateStep', $event.index, $event.step)"
213
+ @merge="emit('merge', $event)"
207
214
  @preview-linked-script="emit('previewLinkedScript', $event)"
208
215
  @resize="handleNodeResize"
209
216
  />
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue';
3
3
  import { Handle, Position } from '@vue-flow/core';
4
- import { CopyDocument, Delete, Edit, Plus, VideoPlay, View, WarningFilled } from '@element-plus/icons-vue';
4
+ import { CopyDocument, Delete, Edit, Plus, VideoPlay, View, WarningFilled, Connection } from '@element-plus/icons-vue';
5
5
  import { AI_MODEL_CONFIG_HINT } from '../ai-recognition';
6
6
  import type { AppiumRecordedStep } from '../types';
7
7
  import {
@@ -22,6 +22,7 @@ const props = defineProps<{
22
22
  const emit = defineEmits<{
23
23
  nodeClick: [index: number];
24
24
  copy: [index: number];
25
+ merge: [index: number];
25
26
  remove: [index: number];
26
27
  editInput: [index: number];
27
28
  previewLinkedScript: [index: number];
@@ -181,16 +182,24 @@ watch(() => props.data, () => {
181
182
  </span>
182
183
  </button>
183
184
  <span v-if="!props.readonly" class="appium-flow-step-card__actions nodrag nopan" @click.stop>
185
+ <el-tooltip v-if="data.flowKind === 'condition' && !data.copyMode" content="合并分支:将两侧后续节点汇入公共流程" placement="top" :show-after="200">
186
+ <span class="appium-node-action-tooltip"><el-button text size="small" :icon="Connection"
187
+ title="合并分支" aria-label="合并分支" :disabled="data.mergeDisabled || Boolean(data.step.flow?.successTargetId)"
188
+ @click="emit('merge', data.index)" />
189
+ </span></el-tooltip>
190
+ <el-tooltip v-if="!data.copyMode && data.canCopy" content="复制节点:复制后可在插入位置粘贴" placement="top" :show-after="200">
191
+ <span class="appium-node-action-tooltip">
184
192
  <el-button
185
- v-if="!data.copyMode && data.canCopy"
186
193
  text
187
194
  size="small"
188
195
  :icon="CopyDocument"
189
196
  title="复制节点"
190
197
  @click="emit('copy', data.index)"
191
198
  />
199
+ </span></el-tooltip>
200
+ <el-tooltip v-if="data.canExecute" :content="data.step.type === 'aiRecognition' ? '测试 AI 识别:使用当前设备画面测试识别结果' : '立即执行:在当前设备上执行此操作'" placement="top" :show-after="200">
201
+ <span class="appium-node-action-tooltip">
192
202
  <el-button
193
- v-if="data.canExecute"
194
203
  text
195
204
  size="small"
196
205
  :icon="VideoPlay"
@@ -200,8 +209,10 @@ watch(() => props.data, () => {
200
209
  :aria-label="data.step.type === 'aiRecognition' ? '测试 AI 识别' : '立即执行'"
201
210
  @click="emit('execute', data.index)"
202
211
  />
212
+ </span></el-tooltip>
213
+ <el-tooltip v-if="data.canEditInput" content="修改输入内容:编辑此节点要输入的文字" placement="top" :show-after="200">
214
+ <span class="appium-node-action-tooltip">
203
215
  <el-button
204
- v-if="data.canEditInput"
205
216
  text
206
217
  size="small"
207
218
  :icon="Edit"
@@ -209,14 +220,19 @@ watch(() => props.data, () => {
209
220
  title="修改输入内容"
210
221
  @click="emit('editInput', data.index)"
211
222
  />
223
+ </span></el-tooltip>
224
+ <el-tooltip v-if="data.step.type === 'runScript'" content="预览连接脚本:查看关联脚本的流程" placement="top" :show-after="200">
225
+ <span class="appium-node-action-tooltip">
212
226
  <el-button
213
- v-if="data.step.type === 'runScript'"
214
227
  text
215
228
  size="small"
216
229
  :icon="View"
217
230
  title="预览连接脚本"
218
231
  @click="emit('previewLinkedScript', data.index)"
219
232
  />
233
+ </span></el-tooltip>
234
+ <el-tooltip :content="data.flowKind === 'condition' ? '删除节点:同时删除所属分支子节点,保留公共流程' : '删除节点:移除此操作并连接前后节点'" placement="top" :show-after="200">
235
+ <span class="appium-node-action-tooltip">
220
236
  <el-button
221
237
  text
222
238
  size="small"
@@ -225,6 +241,7 @@ watch(() => props.data, () => {
225
241
  title="删除节点"
226
242
  @click="emit('remove', data.index)"
227
243
  />
244
+ </span></el-tooltip>
228
245
  </span>
229
246
  <FlowStepEditor
230
247
  v-if="!props.readonly && data.expanded"
@@ -246,5 +263,6 @@ watch(() => props.data, () => {
246
263
  </template>
247
264
 
248
265
  <style scoped>
266
+ .appium-node-action-tooltip { display: inline-flex; }
249
267
  .appium-ai-model-warning { float: left; margin: 2px 6px 0 0; font-size: 16px; }
250
268
  </style>
@@ -27,6 +27,12 @@ const path = computed(() => {
27
27
  const deltaX = target.x - source.x;
28
28
  const deltaY = target.y - source.y;
29
29
 
30
+ if (props.data?.merge && deltaY > 0) {
31
+ // 同一公共节点的入边共用横线高度,不能随各分支的长度变化。
32
+ const joinY = target.y - 24;
33
+ return linePath([source, { x: source.x, y: joinY }, { x: target.x, y: joinY }, target]);
34
+ }
35
+
30
36
  if (targetData.value?.kind === 'branch') {
31
37
  const direction = deltaY >= 0 ? 1 : -1;
32
38
  const branchY = source.y + direction * Math.min(Math.max(Math.abs(deltaY) * 0.45, 18), 34);
@@ -134,7 +134,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
134
134
  <el-form-item label="节点类型">
135
135
  <el-select
136
136
  :model-value="defaultKind()"
137
- :disabled="disabled || isBooleanCondition(step) || step.type === 'log'"
137
+ :disabled="disabled || isBooleanCondition(step) || step.type === 'log' || step.type === 'openGallery' || step.type === 'endFlow'"
138
138
  @update:model-value="patchFlow({ nodeKind: $event as FlowKind })"
139
139
  >
140
140
  <el-option label="操作" value="action" />
@@ -142,7 +142,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
142
142
  <el-option label="校验" value="assertion" />
143
143
  </el-select>
144
144
  </el-form-item>
145
- <el-form-item v-if="step.type !== 'longPress' && step.type !== 'log'" label="超时时间 ms">
145
+ <el-form-item v-if="step.type !== 'longPress' && step.type !== 'log' && step.type !== 'openGallery' && step.type !== 'endFlow'" label="超时时间 ms">
146
146
  <el-input-number
147
147
  :model-value="step.timeoutMs || undefined"
148
148
  :disabled="disabled"
@@ -0,0 +1,74 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue';
3
+ import { ElMessage } from 'element-plus';
4
+ import type { AppiumRecordedStep } from '../types';
5
+ import { branchSteps, mergeBranches } from '../flow-merge';
6
+ import { flowBranchLabel } from '../flow-labels';
7
+
8
+ const props = defineProps<{ steps: AppiumRecordedStep[]; conditionId: string; disabled?: boolean }>();
9
+ const emit = defineEmits<{ close: []; confirm: [steps: AppiumRecordedStep[]] }>();
10
+ const commonId = ref('');
11
+ const duplicateId = ref('');
12
+ const confirmed = ref(false);
13
+ const retainedSide = ref<'yes' | 'no'>('yes');
14
+ const condition = computed(() => props.steps.find((step) => step.id === props.conditionId));
15
+ const candidates = computed(() => branchSteps(props.steps, props.conditionId, retainedSide.value));
16
+ const duplicates = computed(() => branchSteps(props.steps, props.conditionId, retainedSide.value === 'yes' ? 'no' : 'yes'));
17
+ const preview = computed(() => {
18
+ if (!commonId.value) return undefined;
19
+ try { return mergeBranches(props.steps, props.conditionId, commonId.value, duplicateId.value); }
20
+ catch (error) { return error instanceof Error ? error.message : String(error); }
21
+ });
22
+ watch(() => props.conditionId, () => {
23
+ retainedSide.value = branchSteps(props.steps, props.conditionId, 'yes').length ? 'yes' : 'no';
24
+ commonId.value = ''; duplicateId.value = ''; confirmed.value = false;
25
+ });
26
+ // 切换方向时清空此前的选择和删除确认,避免误删新方向下的节点。
27
+ watch(retainedSide, () => { commonId.value = ''; duplicateId.value = ''; confirmed.value = false; });
28
+ watch(commonId, () => { duplicateId.value = ''; confirmed.value = false; });
29
+ watch(duplicateId, () => { confirmed.value = false; });
30
+ function submit() {
31
+ if (props.disabled || !preview.value || typeof preview.value === 'string') return;
32
+ if (preview.value.removedCount && !confirmed.value) return;
33
+ try {
34
+ emit('confirm', mergeBranches(props.steps, props.conditionId, commonId.value, duplicateId.value).steps);
35
+ emit('close');
36
+ } catch (error) { ElMessage.error(String(error)); }
37
+ }
38
+ </script>
39
+
40
+ <template>
41
+ <el-dialog :model-value="Boolean(conditionId)" title="合并分支" width="min(520px, 94vw)" append-to-body :close-on-click-modal="false" @close="emit('close')">
42
+ <el-form label-position="top">
43
+ <el-form-item label="合并方向">
44
+ <el-radio-group v-model="retainedSide" aria-label="合并方向">
45
+ <el-radio-button value="yes">保留左侧流程</el-radio-button>
46
+ <el-radio-button value="no">保留右侧流程</el-radio-button>
47
+ </el-radio-group>
48
+ </el-form-item>
49
+ <el-form-item label="保留的公共流程起点">
50
+ <el-select v-model="commonId" aria-label="保留的公共流程起点" style="width: 100%">
51
+ <el-option v-for="step in candidates" :key="step.id" :value="step.id"
52
+ :label="`${condition ? flowBranchLabel(condition, step.flow!.parentBranch!) : ''} · ${step.label}`" />
53
+ </el-select>
54
+ </el-form-item>
55
+ <el-form-item :label="retainedSide === 'yes' ? '右侧重复部分起点' : '左侧重复部分起点'">
56
+ <el-select v-model="duplicateId" :disabled="!commonId" aria-label="另一侧重复部分起点" style="width: 100%">
57
+ <el-option value="" label="不删除,保留另一侧全部操作后接入" />
58
+ <el-option v-for="step in duplicates" :key="step.id" :value="step.id" :label="step.label" />
59
+ </el-select>
60
+ </el-form-item>
61
+ <el-alert v-if="typeof preview === 'string'" :title="preview" type="error" :closable="false" />
62
+ <el-checkbox v-else-if="preview?.removedCount" v-model="confirmed">确认删除另一侧从所选起点开始的 {{ preview.removedCount }} 个节点</el-checkbox>
63
+ </el-form>
64
+ <template #footer>
65
+ <el-button @click="emit('close')">取消</el-button>
66
+ <el-button type="primary" :disabled="disabled || !preview || typeof preview === 'string' || Boolean(preview.removedCount && !confirmed)" @click="submit">合并分支</el-button>
67
+ </template>
68
+ </el-dialog>
69
+ </template>
70
+
71
+ <style scoped>
72
+ :deep(.el-checkbox) { height: auto; white-space: normal; }
73
+ :deep(.el-checkbox__label) { white-space: normal; overflow-wrap: anywhere; }
74
+ </style>
@@ -23,9 +23,12 @@ type InsertAction =
23
23
  | 'swipe'
24
24
  | 'pinch'
25
25
  | 'launchApp'
26
+ | 'openGallery'
27
+ | 'endFlow'
26
28
  | 'clearAppData'
27
29
  | 'popupCondition'
28
30
  | 'checkboxState'
31
+ | 'checkedState'
29
32
  | 'radioButtonState'
30
33
  | 'aiRecognition'
31
34
  | 'textClick'
@@ -87,9 +90,8 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
87
90
  { type: 'clearInput', label: '清空输入' },
88
91
  { type: 'coordinateTap', label: '点击坐标' },
89
92
  { type: 'longPress', label: '长按' },
90
- { type: 'checkboxState', label: '判断 Checkbox 状态' },
93
+ { type: 'checkedState', label: '判断勾选' },
91
94
  { type: 'textClick', label: '文字点击' },
92
- { type: 'radioButtonState', label: '判断 RadioButton 状态' },
93
95
  ],
94
96
  },
95
97
  {
@@ -97,6 +99,7 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
97
99
  actions: [
98
100
  { type: 'keyBack', label: '系统返回' },
99
101
  { type: 'keyHome', label: 'Home 键' },
102
+ { type: 'openGallery', label: '启动相册' },
100
103
  { type: 'keyRecent', label: '最近任务' },
101
104
  { type: 'keyPower', label: '电源键' },
102
105
  { type: 'swipe', label: '滑动' },
@@ -126,6 +129,7 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
126
129
  title: '流程控制',
127
130
  actions: [
128
131
  { type: 'noop', label: '空节点' },
132
+ { type: 'endFlow', label: '终止流程' },
129
133
  { type: 'log', label: '输出日志' },
130
134
  { type: 'runScript', label: '连接脚本' },
131
135
  ],
@@ -145,10 +149,13 @@ function typeLabel(step: AppiumRecordedStep) {
145
149
  coordinateTap: '坐标点击', swipe: '滑动', launchApp: '启动 APP', clearAppData: '清理 APP 缓存', longPress: '长按',
146
150
  pinch: '双指缩放', runScript: '连接脚本', screenshot: '截图', visualChange: '检测画面变化',
147
151
  noop: '空节点',
152
+ endFlow: '终止流程',
148
153
  log: '输出日志',
149
154
  checkboxState: 'Checkbox 状态',
155
+ checkedState: '判断勾选',
150
156
  radioButtonState: 'RadioButton 状态',
151
157
  aiRecognition: 'AI 识别',
158
+ openGallery: '启动相册',
152
159
  textClick: '文字点击',
153
160
  };
154
161
  return labels[step.type] || step.type;
@@ -312,16 +319,19 @@ function branchSplitPath() {
312
319
  </span>
313
320
  </button>
314
321
  <span class="appium-flow-node__actions" @click.stop>
322
+ <el-tooltip v-if="!copyMode && canCopyStep(item.step)" content="复制节点:复制后可在插入位置粘贴" placement="top" :show-after="200">
323
+ <span style="display: inline-flex">
315
324
  <el-button
316
- v-if="!copyMode && canCopyStep(item.step)"
317
325
  text
318
326
  size="small"
319
327
  :icon="CopyDocument"
320
328
  title="复制节点"
321
329
  @click="$emit('copy', [item.index])"
322
330
  />
331
+ </span></el-tooltip>
332
+ <el-tooltip v-if="item.step.type === 'input' || item.step.type === 'inputIfExists'" content="修改输入内容:编辑此节点要输入的文字" placement="top" :show-after="200">
333
+ <span style="display: inline-flex">
323
334
  <el-button
324
- v-if="item.step.type === 'input' || item.step.type === 'inputIfExists'"
325
335
  text
326
336
  size="small"
327
337
  :icon="Edit"
@@ -329,6 +339,9 @@ function branchSplitPath() {
329
339
  title="修改输入内容"
330
340
  @click="$emit('editInput', item.index)"
331
341
  />
342
+ </span></el-tooltip>
343
+ <el-tooltip content="删除节点:移除此操作;判断节点同时删除所属分支子节点" placement="top" :show-after="200">
344
+ <span style="display: inline-flex">
332
345
  <el-button
333
346
  text
334
347
  size="small"
@@ -337,6 +350,7 @@ function branchSplitPath() {
337
350
  title="删除节点"
338
351
  @click="$emit('remove', item.index)"
339
352
  />
353
+ </span></el-tooltip>
340
354
  </span>
341
355
  </div>
342
356
  <FlowStepEditor
@@ -5,6 +5,7 @@ import { Aim, CopyDocument, FullScreen } from '@element-plus/icons-vue';
5
5
  import type { AppiumRecordedStep } from '../types';
6
6
  import { createFlowClipboard } from '../flow-copy';
7
7
  import FlowCanvas from './FlowCanvas.vue';
8
+ import MergeBranchesDialog from './MergeBranchesDialog.vue';
8
9
  import { labelFlowStep } from '../flow-labels';
9
10
  import type { FlowActionGroup, FlowBranch, InsertAction } from '../flow-graph';
10
11
 
@@ -13,6 +14,7 @@ const props = defineProps<{
13
14
  steps: AppiumRecordedStep[];
14
15
  disabled?: boolean;
15
16
  removeDisabled?: boolean;
17
+ mergeDisabled?: boolean;
16
18
  allowedLockedActions?: InsertAction[];
17
19
  launchingStepId?: string;
18
20
  clipboardCount?: number;
@@ -29,9 +31,11 @@ const emit = defineEmits<{
29
31
  previewLinkedScript: [index: number];
30
32
  executeStep: [index: number];
31
33
  updateStep: [index: number, step: AppiumRecordedStep];
34
+ replaceSteps: [steps: AppiumRecordedStep[]];
32
35
  }>();
33
36
 
34
37
  const expandedStepIndex = shallowRef<number | null>(null);
38
+ const mergeConditionId = shallowRef('');
35
39
  const copyMode = shallowRef(false);
36
40
  const selectedCopyIndexes = shallowRef<number[]>([]);
37
41
  const flowDialogVisible = shallowRef(false);
@@ -47,9 +51,8 @@ const insertActionGroups: FlowActionGroup[] = [
47
51
  { type: 'clearInput', label: '清空输入' },
48
52
  { type: 'coordinateTap', label: '点击坐标' },
49
53
  { type: 'longPress', label: '长按' },
50
- { type: 'checkboxState', label: '判断 Checkbox 状态' },
54
+ { type: 'checkedState', label: '判断勾选' },
51
55
  { type: 'textClick', label: '文字点击' },
52
- { type: 'radioButtonState', label: '判断 RadioButton 状态' },
53
56
  ],
54
57
  },
55
58
  {
@@ -63,6 +66,7 @@ const insertActionGroups: FlowActionGroup[] = [
63
66
  { type: 'pinch', label: '双指缩放' },
64
67
  { type: 'clearAppData', label: '清理 App 缓存' },
65
68
  { type: 'launchApp', label: '启动 App' },
69
+ { type: 'openGallery', label: '启动相册' },
66
70
  ],
67
71
  },
68
72
  {
@@ -88,6 +92,7 @@ const insertActionGroups: FlowActionGroup[] = [
88
92
  title: '流程控制',
89
93
  actions: [
90
94
  { type: 'noop', label: '空节点' },
95
+ { type: 'endFlow', label: '终止流程' },
91
96
  { type: 'log', label: '输出日志' },
92
97
  { type: 'runScript', label: '连接脚本' },
93
98
  ],
@@ -260,6 +265,7 @@ function updateStep(index: number, step: AppiumRecordedStep) {
260
265
  :selected-copy-indexes="selectedCopyIndexes"
261
266
  :disabled="disabled"
262
267
  :remove-disabled="removeDisabled"
268
+ :merge-disabled="mergeDisabled"
263
269
  :launching-step-id="launchingStepId"
264
270
  :ai-recognition-model-configured="aiRecognitionModelConfigured"
265
271
  :clipboard-count="clipboardCount"
@@ -282,6 +288,7 @@ function updateStep(index: number, step: AppiumRecordedStep) {
282
288
  @preview-linked-script="(index) => emit('previewLinkedScript', index)"
283
289
  @execute-step="(index) => emit('executeStep', index)"
284
290
  @update-step="(index, step) => updateStep(index, step)"
291
+ @merge="mergeConditionId = steps[$event]?.id || ''"
285
292
  />
286
293
 
287
294
  <el-dialog
@@ -324,6 +331,7 @@ function updateStep(index: number, step: AppiumRecordedStep) {
324
331
  :selected-copy-indexes="selectedCopyIndexes"
325
332
  :disabled="disabled"
326
333
  :remove-disabled="removeDisabled"
334
+ :merge-disabled="mergeDisabled"
327
335
  :launching-step-id="launchingStepId"
328
336
  :ai-recognition-model-configured="aiRecognitionModelConfigured"
329
337
  :clipboard-count="clipboardCount"
@@ -346,7 +354,10 @@ function updateStep(index: number, step: AppiumRecordedStep) {
346
354
  @preview-linked-script="(index) => emit('previewLinkedScript', index)"
347
355
  @execute-step="(index) => emit('executeStep', index)"
348
356
  @update-step="(index, step) => updateStep(index, step)"
357
+ @merge="mergeConditionId = steps[$event]?.id || ''"
349
358
  />
350
359
  </el-dialog>
360
+ <MergeBranchesDialog :steps="steps" :condition-id="mergeConditionId" :disabled="mergeDisabled"
361
+ @close="mergeConditionId = ''" @confirm="expandedStepIndex = null; emit('replaceSteps', $event)" />
351
362
  </div>
352
363
  </template>
@@ -0,0 +1,15 @@
1
+ import type { InjectionKey, Ref } from 'vue';
2
+
3
+ export const DEFAULT_FLOW_BACKGROUND = '#d4e8dd';
4
+ export const FLOW_BACKGROUND_PRESETS = ['#d4e8dd', '#d5e6f5', '#e4dcf2', '#f2dedc', '#d5eae9', '#e8e4cc'];
5
+ export const flowBackgroundKey: InjectionKey<Readonly<Ref<string>>> = Symbol('flowBackground');
6
+
7
+ export function isHexColor(value: unknown): value is string {
8
+ return typeof value === 'string' && /^#(?:[\da-f]{3}|[\da-f]{6})$/i.test(value.trim());
9
+ }
10
+
11
+ export function normalizeFlowBackground(value: unknown) {
12
+ if (!isHexColor(value)) return DEFAULT_FLOW_BACKGROUND;
13
+ const color = value.trim().toLowerCase();
14
+ return color.length === 4 ? '#' + [...color.slice(1)].map((char) => char + char).join('') : color;
15
+ }
@@ -24,9 +24,12 @@ export type InsertAction =
24
24
  | 'swipe'
25
25
  | 'pinch'
26
26
  | 'launchApp'
27
+ | 'openGallery'
28
+ | 'endFlow'
27
29
  | 'clearAppData'
28
30
  | 'popupCondition'
29
31
  | 'checkboxState'
32
+ | 'checkedState'
30
33
  | 'radioButtonState'
31
34
  | 'aiRecognition'
32
35
  | 'textClick'
@@ -89,6 +92,7 @@ export type FlowGraphNodeData =
89
92
  copyMode: boolean;
90
93
  disabled?: boolean;
91
94
  removeDisabled?: boolean;
95
+ mergeDisabled?: boolean;
92
96
  launching?: boolean;
93
97
  canCopy: boolean;
94
98
  canEditInput: boolean;
@@ -132,6 +136,7 @@ type BuildFlowGraphOptions = {
132
136
  copyMode: boolean;
133
137
  disabled?: boolean;
134
138
  removeDisabled?: boolean;
139
+ mergeDisabled?: boolean;
135
140
  launchingStepId?: string;
136
141
  clipboardCount?: number;
137
142
  canOpenInsertMenu: boolean;
@@ -231,7 +236,7 @@ function branchConnectionTargetId(items: StepItem[], condition: AppiumRecordedSt
231
236
  }
232
237
  const targetId = branch === 'yes' ? condition.flow?.yesTargetId : condition.flow?.noTargetId;
233
238
  const target = items.find(({ step }) => step.id === targetId)?.step;
234
- return target && !target.flow?.parentConditionId ? target.id : '';
239
+ return target && target.flow?.parentConditionId === condition.flow?.parentConditionId ? target.id : '';
235
240
  }
236
241
 
237
242
  function estimateTextLines(text: string | undefined, charsPerLine: number) {
@@ -425,6 +430,7 @@ export function buildFlowGraph(
425
430
  ? options.isAppExecutionDisabled(item.step.type)
426
431
  : item.step.type === 'aiRecognition' ? options.isInsertActionDisabled('aiRecognition') : options.disabled,
427
432
  removeDisabled: options.removeDisabled,
433
+ mergeDisabled: options.mergeDisabled,
428
434
  launching: options.launchingStepId === item.step.id,
429
435
  canCopy: item.step.type !== 'launchApp'
430
436
  && item.step.type !== 'clearAppData'
@@ -542,7 +548,8 @@ export function buildFlowGraph(
542
548
 
543
549
  const targetId = item.step.flow?.successTargetId;
544
550
  const explicitTarget = targetId ? stepById.get(targetId) : undefined;
545
- const explicitMainTarget = explicitTarget && !explicitTarget.step.flow?.parentConditionId
551
+ const explicitMainTarget = explicitTarget && (!explicitTarget.step.flow?.parentConditionId
552
+ || explicitTarget.step.flow.parentConditionId !== item.step.flow?.parentConditionId)
546
553
  ? explicitTarget
547
554
  : undefined;
548
555
  const target = explicitMainTarget || nextSibling;
@@ -733,11 +740,29 @@ export function buildFlowGraph(
733
740
  const orderedLinks = [...links].sort((a, b) => (
734
741
  (nodeOrder.get(a.source) || 0) - (nodeOrder.get(b.source) || 0)
735
742
  ));
743
+ // 公共起点从两侧汇入,不能被最后一条分支连线拉到左列或右列。
744
+ const joins = new Map<string, string>();
745
+ items.forEach(({ step }) => {
746
+ const target = step.flow?.successTargetId && stepById.get(step.flow.successTargetId);
747
+ if (defaultKind(step) === 'condition' && target && !joins.has(nodeIdForStep(target.step))) {
748
+ joins.set(nodeIdForStep(target.step), nodeIdForStep(step));
749
+ }
750
+ });
736
751
  const alignVisibleChainGeometry = () => {
737
752
  orderedLinks.forEach((link) => {
738
753
  const source = nodeById.get(link.source);
739
754
  const target = nodeById.get(link.target);
740
755
  if (!source || !target?.data) return;
756
+ const joinOwner = joins.get(target.id);
757
+ if (joinOwner && target.id !== joinOwner) {
758
+ const incoming = links.filter((edge) => edge.target === target.id).map((edge) => nodeById.get(edge.source)!).filter(Boolean);
759
+ const owner = nodeById.get(joinOwner);
760
+ if (owner && incoming.length) {
761
+ target.position.y = Math.round(Math.max(...incoming.map((node) => node.position.y + nodeHeight(node))) + FLOW_STANDARD_LINE_GAP * 2);
762
+ setCenterX(target, centerX(owner));
763
+ }
764
+ return;
765
+ }
741
766
  const kind = target.data.kind;
742
767
  if (!['branch', 'split', 'insert'].includes(kind) && !canAlignStepAfterFlowPoint(source, target)) return;
743
768
 
@@ -777,7 +802,7 @@ export function buildFlowGraph(
777
802
  selectable: false,
778
803
  focusable: false,
779
804
  class: 'appium-vue-flow-edge',
780
- data: { branch: link.branch },
805
+ data: { branch: link.branch, merge: joins.has(link.target) },
781
806
  }));
782
807
 
783
808
  return { nodes: positionedNodes, edges };
@@ -10,7 +10,7 @@ export function isBooleanCondition(step: Pick<AppiumRecordedStep, 'type'>) {
10
10
  }
11
11
 
12
12
  export function defaultFlowKind(step: AppiumRecordedStep): FlowKind {
13
- if (step.type === 'log') return 'action';
13
+ if (step.type === 'log' || step.type === 'openGallery' || step.type === 'endFlow') return 'action';
14
14
  if (isBooleanCondition(step)) return 'condition';
15
15
  if (step.flow?.nodeKind) return step.flow.nodeKind;
16
16
  if (step.type === 'assertExists' || step.type === 'assertText' || step.type === 'visualChange') return 'assertion';
@@ -40,6 +40,7 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
40
40
  waitDisappear: '等待消失',
41
41
  assertExists: '断言存在',
42
42
  checkboxState: 'Checkbox 状态',
43
+ checkedState: '判断勾选',
43
44
  radioButtonState: 'RadioButton 状态',
44
45
  aiRecognition: 'AI 识别',
45
46
  textClick: '文字点击',
@@ -51,6 +52,8 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
51
52
  swipe: '滑动',
52
53
  screenshot: '截图',
53
54
  launchApp: '启动 APP',
55
+ openGallery: '启动相册',
56
+ endFlow: '终止流程',
54
57
  clearAppData: '清理 APP 缓存',
55
58
  longPress: '长按',
56
59
  pinch: '双指缩放',
@@ -0,0 +1,110 @@
1
+ import type { AppiumRecordedStep } from './types';
2
+ import { defaultFlowKind } from './flow-labels';
3
+
4
+ type Branch = 'yes' | 'no';
5
+ export function branchSteps(steps: AppiumRecordedStep[], conditionId: string, branch: Branch) {
6
+ return steps.filter((step) => step.flow?.parentConditionId === conditionId && step.flow.parentBranch === branch);
7
+ }
8
+
9
+ function descendants(steps: AppiumRecordedStep[], roots: string[]) {
10
+ const ids = new Set(roots);
11
+ for (let changed = true; changed;) {
12
+ changed = false;
13
+ for (const step of steps) {
14
+ if (step.flow?.parentConditionId && ids.has(step.flow.parentConditionId) && !ids.has(step.id)) {
15
+ ids.add(step.id);
16
+ changed = true;
17
+ }
18
+ }
19
+ }
20
+ return ids;
21
+ }
22
+
23
+ // 合流仅提升明确选中的分支后缀,不按名称猜测重复节点,也不改写其他判断的分支。
24
+ export function mergeBranches(steps: AppiumRecordedStep[], conditionId: string, commonId: string, duplicateId = '') {
25
+ const condition = steps.find((step) => step.id === conditionId);
26
+ const common = steps.find((step) => step.id === commonId);
27
+ if (!condition || defaultFlowKind(condition) !== 'condition') throw new Error('请选择判断节点');
28
+ if (condition.flow?.successTargetId) throw new Error('该判断已有后续公共流程,不能重复合流');
29
+ if (!common || common.flow?.parentConditionId !== conditionId || !common.flow.parentBranch) throw new Error('公共起点必须属于当前判断的直接分支');
30
+ const side = common.flow.parentBranch;
31
+ const otherSide = side === 'yes' ? 'no' : 'yes';
32
+ const source = branchSteps(steps, conditionId, side);
33
+ const other = branchSteps(steps, conditionId, otherSide);
34
+ const commonRoots = source.slice(source.findIndex((step) => step.id === commonId));
35
+ const commonIds = descendants(steps, commonRoots.map((step) => step.id));
36
+ const duplicateIndex = other.findIndex((step) => step.id === duplicateId);
37
+ if (duplicateId && duplicateIndex < 0) throw new Error('重复部分必须属于另一侧直接分支');
38
+ const removedIds = descendants(steps, duplicateId ? other.slice(duplicateIndex).map((step) => step.id) : []);
39
+ const ownedIds = descendants(steps, [conditionId]);
40
+ const keys = ['yesTargetId', 'noTargetId', 'successTargetId', 'failureTargetId'] as const;
41
+ for (const step of steps) {
42
+ for (const key of keys) {
43
+ const target = step.flow?.[key];
44
+ if (!target) continue;
45
+ if (commonIds.has(step.id) && ownedIds.has(target) && !commonIds.has(target)) throw new Error('公共流程存在返回分支的连接,合流会形成循环');
46
+ if (!ownedIds.has(step.id) && removedIds.has(target)) throw new Error('待删除节点被其他流程引用,不能合并');
47
+ if (ownedIds.has(step.id) && !commonIds.has(step.id) && !removedIds.has(step.id) && !ownedIds.has(target)) throw new Error('分支已有外部连接,请先处理该连接');
48
+ }
49
+ }
50
+ const next = steps.filter((step) => !removedIds.has(step.id)).map((step) => ({ ...step, flow: { ...step.flow } }));
51
+ const byId = new Map(next.map((step) => [step.id, step]));
52
+ const mergedCondition = byId.get(conditionId)!;
53
+ mergedCondition.flow.successTargetId = commonId;
54
+ // 只改变公共后缀的直接归属;后缀内部的嵌套判断仍拥有自己的子节点。
55
+ for (const root of commonRoots) {
56
+ const flow = byId.get(root.id)!.flow;
57
+ delete flow.parentConditionId;
58
+ delete flow.parentBranch;
59
+ if (condition.flow?.parentConditionId) {
60
+ flow.parentConditionId = condition.flow.parentConditionId;
61
+ flow.parentBranch = condition.flow.parentBranch;
62
+ }
63
+ }
64
+ function connectEnd(step: typeof next[number]) {
65
+ if (step.type === 'endFlow') return;
66
+ step.flow.successTargetId = commonId;
67
+ if (defaultFlowKind(step) === 'condition') {
68
+ for (const branch of ['yes', 'no'] as const) {
69
+ const children = branchSteps(next, step.id, branch);
70
+ if (children.length) connectEnd(byId.get(children[children.length - 1]!.id)!);
71
+ else step.flow[branch === 'yes' ? 'yesTargetId' : 'noTargetId'] = commonId;
72
+ }
73
+ }
74
+ }
75
+ for (const branch of ['yes', 'no'] as const) {
76
+ const remaining = branchSteps(next, conditionId, branch);
77
+ mergedCondition.flow[branch === 'yes' ? 'yesTargetId' : 'noTargetId'] = remaining[0]?.id || commonId;
78
+ if (remaining.length) connectEnd(byId.get(remaining[remaining.length - 1]!.id)!);
79
+ }
80
+ // 公共节点排在整个分支子树之后,保存和顺序回放不会插入到另一侧分支中。
81
+ const commonBlock = next.filter((step) => commonIds.has(step.id));
82
+ const rest = next.filter((step) => !commonIds.has(step.id));
83
+ let after = rest.findIndex((step) => step.id === conditionId);
84
+ rest.forEach((step, index) => { if (ownedIds.has(step.id)) after = Math.max(after, index); });
85
+ rest.splice(after + 1, 0, ...commonBlock);
86
+ for (const step of rest) {
87
+ for (const key of keys) {
88
+ if (step.flow[key] && removedIds.has(step.flow[key]!)) step.flow[key] = commonId;
89
+ }
90
+ }
91
+ // 校验完整执行图,包含隐式顺序边,拒绝回边及跨分支错误连接。
92
+ const visiting = new Set<string>();
93
+ const visited = new Set<string>();
94
+ function visit(step: typeof next[number]) {
95
+ if (visiting.has(step.id)) throw new Error('合流会形成循环,请检查节点连接');
96
+ if (visited.has(step.id)) return;
97
+ visiting.add(step.id);
98
+ const index = rest.indexOf(step);
99
+ const targets = keys.map((key) => step.flow[key]).filter(Boolean) as string[];
100
+ if (defaultFlowKind(step) !== 'condition' && step.type !== 'endFlow' && !step.flow.successTargetId) {
101
+ const sibling = rest.slice(index + 1).find((item) => item.flow.parentConditionId === step.flow.parentConditionId && item.flow.parentBranch === step.flow.parentBranch);
102
+ if (sibling) targets.push(sibling.id);
103
+ }
104
+ for (const id of targets) { const target = byId.get(id); if (target) visit(target); }
105
+ visiting.delete(step.id);
106
+ visited.add(step.id);
107
+ }
108
+ rest.forEach(visit);
109
+ return { steps: rest, removedCount: removedIds.size };
110
+ }
@@ -1,13 +1,13 @@
1
1
  import type { AppiumRecordedStep } from './types';
2
2
 
3
- export type NativeStateType = 'checkboxState' | 'radioButtonState';
3
+ export type NativeStateType = 'checkboxState' | 'radioButtonState' | 'checkedState';
4
4
 
5
5
  export function isNativeStateCondition(step: Pick<AppiumRecordedStep, 'type'>): step is { type: NativeStateType } {
6
- return step.type === 'checkboxState' || step.type === 'radioButtonState';
6
+ return step.type === 'checkboxState' || step.type === 'radioButtonState' || step.type === 'checkedState';
7
7
  }
8
8
 
9
9
  export function nativeControlName(type: NativeStateType) {
10
- return type === 'checkboxState' ? 'Checkbox' : 'RadioButton';
10
+ return type === 'checkedState' ? '勾选组件(Checkbox / RadioButton / Switch)' : type === 'checkboxState' ? 'Checkbox' : 'RadioButton';
11
11
  }
12
12
 
13
13
  export function nativeControlType(className: unknown): NativeStateType | undefined {
@@ -15,9 +15,16 @@ export function nativeControlType(className: unknown): NativeStateType | undefin
15
15
  // 识别原生、AppCompat 和 Material 控件;仅有 checkable 的 View 不能证明控件类型。
16
16
  if (/(?:^|\.)(?:AppCompat|Material)?CheckBox$/.test(className)) return 'checkboxState';
17
17
  if (/(?:^|\.)(?:AppCompat|Material)?RadioButton$/.test(className)) return 'radioButtonState';
18
+ if (/(?:^|\.)(?:Switch|SwitchCompat|SwitchMaterial|MaterialSwitch)$/.test(className)) return 'checkedState';
18
19
  return undefined;
19
20
  }
20
21
 
22
+ export function matchesNativeControl(type: NativeStateType, className: unknown) {
23
+ const detected = nativeControlType(className);
24
+ // 新节点自动识别三类控件,旧节点仍保留原有的类型约束。
25
+ return type === 'checkedState' ? detected !== undefined : detected === type;
26
+ }
27
+
21
28
  // UIAutomator XML 使用字符串,Appium 属性响应也可能返回布尔值;缺失值不能视为 false。
22
29
  export function parseNativeBoolean(value: unknown): boolean | undefined {
23
30
  if (value === true || value === 'true') return true;
@@ -76,6 +76,7 @@ export type AppiumRecordedStep = {
76
76
  | 'waitFor'
77
77
  | 'assertExists'
78
78
  | 'checkboxState'
79
+ | 'checkedState'
79
80
  | 'radioButtonState'
80
81
  | 'aiRecognition'
81
82
  | 'textClick'
@@ -87,6 +88,8 @@ export type AppiumRecordedStep = {
87
88
  | 'swipe'
88
89
  | 'screenshot'
89
90
  | 'launchApp'
91
+ | 'openGallery'
92
+ | 'endFlow'
90
93
  | 'clearAppData'
91
94
  | 'waitDisappear'
92
95
  | 'assertText'
@@ -1,5 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { ConfigForm } from '../../types';
3
+ import FlowAppearanceSettings from './FlowAppearanceSettings.vue';
4
+ import { DEFAULT_FLOW_BACKGROUND, isHexColor } from '../../appium-recorder/flow-appearance';
3
5
 
4
6
  defineProps<{
5
7
  configForm: ConfigForm;
@@ -15,7 +17,7 @@ defineEmits<{ testModel: []; saveModelConfig: [] }>();
15
17
  <template #header>
16
18
  <div class="panel-header">
17
19
  <span>节点模型配置</span>
18
- <el-button type="primary" :loading="isSavingModelConfig" @click="$emit('saveModelConfig')">
20
+ <el-button type="primary" :loading="isSavingModelConfig" :disabled="!isHexColor(configForm.appium.flowBackgroundColor ?? DEFAULT_FLOW_BACKGROUND)" @click="$emit('saveModelConfig')">
19
21
  保存模型配置
20
22
  </el-button>
21
23
  </div>
@@ -41,6 +43,7 @@ defineEmits<{ testModel: []; saveModelConfig: [] }>();
41
43
  </el-form>
42
44
  </section>
43
45
  </el-card>
46
+ <FlowAppearanceSettings v-model="configForm.appium.flowBackgroundColor" :saving="isSavingModelConfig" @save="$emit('saveModelConfig')" />
44
47
  </template>
45
48
 
46
49
  <style scoped>
@@ -0,0 +1,51 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { RefreshLeft } from '@element-plus/icons-vue';
4
+ import { DEFAULT_FLOW_BACKGROUND, FLOW_BACKGROUND_PRESETS, isHexColor, normalizeFlowBackground } from '../../appium-recorder/flow-appearance';
5
+
6
+ const props = defineProps<{ modelValue?: string; saving: boolean }>();
7
+ const emit = defineEmits<{ 'update:modelValue': [color: string]; save: [] }>();
8
+ const color = computed(() => props.modelValue ?? DEFAULT_FLOW_BACKGROUND);
9
+ const error = computed(() => isHexColor(color.value) ? '' : '请输入有效的十六进制颜色,如 #ABC 或 #AABBCC');
10
+ </script>
11
+
12
+ <template>
13
+ <el-card shadow="never" class="config-module-card config-appium-card flow-appearance-settings">
14
+ <template #header>
15
+ <div class="panel-header">
16
+ <span>流程背景色</span>
17
+ <el-button type="primary" :loading="saving" :disabled="Boolean(error)" @click="emit('save')">保存背景色</el-button>
18
+ </div>
19
+ </template>
20
+ <el-form label-position="top">
21
+ <el-form-item label="预选颜色">
22
+ <div class="flow-color-presets">
23
+ <button v-for="preset in FLOW_BACKGROUND_PRESETS" :key="preset" type="button"
24
+ :style="{ backgroundColor: preset }" :aria-label="`选择颜色 ${preset}`" :title="preset"
25
+ :aria-pressed="normalizeFlowBackground(color) === preset && !error"
26
+ @click="emit('update:modelValue', preset)" />
27
+ </div>
28
+ </el-form-item>
29
+ <el-form-item label="自定义颜色" :error="error">
30
+ <div class="flow-color-input">
31
+ <el-color-picker :model-value="normalizeFlowBackground(color)" color-format="hex" :predefine="FLOW_BACKGROUND_PRESETS"
32
+ aria-label="选择流程背景色" @update:model-value="emit('update:modelValue', $event || DEFAULT_FLOW_BACKGROUND)" />
33
+ <el-input :model-value="color" aria-label="十六进制背景色" placeholder="#D4E8DD"
34
+ @update:model-value="emit('update:modelValue', String($event))" />
35
+ <el-button :icon="RefreshLeft" title="恢复默认颜色" aria-label="恢复默认颜色" @click="emit('update:modelValue', DEFAULT_FLOW_BACKGROUND)" />
36
+ </div>
37
+ </el-form-item>
38
+ </el-form>
39
+ </el-card>
40
+ </template>
41
+
42
+ <style scoped>
43
+ .flow-appearance-settings { margin-top: 20px; }
44
+ .flow-appearance-settings .el-form { max-width: 640px; }
45
+ .flow-color-presets, .flow-color-input { display: flex; align-items: center; gap: 10px; }
46
+ .flow-color-presets { flex-wrap: wrap; }
47
+ .flow-color-presets button { width: 32px; height: 32px; border: 1px solid var(--el-border-color-darker); border-radius: 4px; cursor: pointer; }
48
+ .flow-color-presets button[aria-pressed="true"] { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
49
+ .flow-color-input { width: 100%; }
50
+ .flow-color-input .el-input { flex: 1; min-width: 0; }
51
+ </style>
package/src/style.css CHANGED
@@ -2814,7 +2814,7 @@ select {
2814
2814
  overflow: hidden;
2815
2815
  border: 1px solid #e6edf5;
2816
2816
  border-radius: 12px;
2817
- background: #d4e8dd;
2817
+ background: var(--flow-background, #d4e8dd);
2818
2818
  }
2819
2819
 
2820
2820
  .appium-flow-canvas--dialog.appium-flow-canvas--vue {
@@ -2827,7 +2827,7 @@ select {
2827
2827
  height: 100%;
2828
2828
  background:
2829
2829
  radial-gradient(circle at 1px 1px, #aecfbd 1px, transparent 0) 0 0 / 22px 22px,
2830
- #d4e8dd;
2830
+ var(--flow-background, #d4e8dd);
2831
2831
  }
2832
2832
 
2833
2833
  .appium-vue-flow .vue-flow__pane {
package/src/types.ts CHANGED
@@ -12,7 +12,7 @@ export type GeneratorForm = {
12
12
  };
13
13
 
14
14
  export type ConfigForm = {
15
- appium: { model: AiRecognitionModel };
15
+ appium: { model: AiRecognitionModel; flowBackgroundColor?: string };
16
16
  runtime: {
17
17
  androidSdkPath: string;
18
18
  reportOutputPath: string;