android-midscene-automation 0.1.28 → 0.1.30
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 +13 -0
- package/README.md +13 -0
- package/package.json +1 -1
- package/server/appium-recorder/appium-runner.ts +15 -2
- package/server/appium-recorder/open-gallery.ts +52 -0
- package/server/appium-recorder/repository.ts +3 -0
- package/server/config.ts +6 -1
- package/src/App.vue +6 -0
- package/src/appium-recorder/AppiumPage.vue +40 -13
- package/src/appium-recorder/components/FlowCanvas.vue +11 -2
- package/src/appium-recorder/components/FlowNodeCard.vue +29 -6
- package/src/appium-recorder/components/FlowRoundedEdge.vue +6 -0
- package/src/appium-recorder/components/FlowStepEditor.vue +2 -2
- package/src/appium-recorder/components/MergeBranchesDialog.vue +74 -0
- package/src/appium-recorder/components/NestedConditionBranches.vue +18 -4
- package/src/appium-recorder/components/NodeDetail.vue +22 -4
- package/src/appium-recorder/components/RecordedSteps.vue +99 -42
- package/src/appium-recorder/flow-appearance.ts +15 -0
- package/src/appium-recorder/flow-graph.ts +31 -3
- package/src/appium-recorder/flow-labels.ts +4 -1
- package/src/appium-recorder/flow-merge.ts +110 -0
- package/src/appium-recorder/flow-remove.ts +6 -0
- package/src/appium-recorder/native-control-state.ts +10 -3
- package/src/appium-recorder/types.ts +3 -0
- package/src/components/config/AppiumConfigPanel.vue +4 -1
- package/src/components/config/FlowAppearanceSettings.vue +51 -0
- package/src/style.css +2 -2
- package/src/types.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
# 更新记录
|
|
2
2
|
|
|
3
|
+
## v0.1.30
|
|
4
|
+
|
|
5
|
+
- 新增节点批量删除功能,支持多选节点,选中节点及所属子节点以柔和红色背景和浅色文字标记,确认后统一删除;支持普通与放大视图同步操作。
|
|
6
|
+
|
|
7
|
+
## v0.1.29
|
|
8
|
+
|
|
9
|
+
- 判断节点新增“合并分支”,支持选择一侧公共流程起点,将两侧后续汇入同一段居中流程;另一侧可保留独有操作或确认删除重复后缀,支持嵌套判断及各类分支,公共节点只执行一次且不随单侧节点删除。
|
|
10
|
+
- 将 Checkbox、RadioButton 判断入口合并为“判断勾选”,新增 Switch 支持;添加和回放时自动校验组件类型及可勾选属性,读取实时 checked 状态进入 true / false 分支,不改变控件状态,兼容已有独立判断节点。
|
|
11
|
+
- “流程控制”新增终止流程节点,执行到该节点时主动结束本次回放,不再执行后续节点或连接脚本;子脚本触发时也结束整个流程,保留执行日志和报告,不作为执行失败。
|
|
12
|
+
- “设备操作”新增启动相册顺序节点,优先解析系统相册入口,必要时查找已安装的常见相册应用;仅打开相册,不选择照片,日志记录实际启动应用,找不到或启动失败时明确报错。
|
|
13
|
+
- Appium 配置新增流程背景色设置,默认沿用浅绿色,支持预选颜色、颜色选择器、#RGB / #RRGGBB 输入校验和恢复默认;保存后持久化,普通画布、放大视图和连接脚本预览统一应用。
|
|
14
|
+
|
|
3
15
|
## v0.1.28
|
|
4
16
|
|
|
17
|
+
- Appium 配置中的流程背景色独立成区,与节点模型配置分开展示,保留预选颜色、自定义颜色及保存入口。
|
|
5
18
|
- “组件操作”新增文字点击分支节点,无需预选元素,支持精准匹配和模糊匹配原生组件文字;匹配并点击后进入“匹配到文字”,等待超时未找到则进入“未匹配到文字”。支持编辑文字和超时,多元素匹配及点击异常明确报错,日志和报告保留分支结果。
|
|
6
19
|
- “流程控制”新增输出日志节点,支持设置日志内容和自定义关键字,默认按 `stageLog:内容` 输出;支持添加后编辑,回放实时输出、日志文件及报告均保留日志内容,多行内容逐行添加关键字。
|
|
7
20
|
- 新增 AI 识别判断节点,可输入按钮可用、画面黑屏等识别要求,通过当前设备截图返回 `true / false` 并进入对应分支;Appium配置支持独立设置 Base URL、API Key、Model Name 和测试模型,模型需支持图片输入。
|
package/README.md
CHANGED
|
@@ -1229,8 +1229,21 @@ Linux: ~/.local/share/android-midscene-automation
|
|
|
1229
1229
|
|
|
1230
1230
|
# 项目更新记录
|
|
1231
1231
|
|
|
1232
|
+
## v0.1.30
|
|
1233
|
+
|
|
1234
|
+
- 新增节点批量删除功能,支持多选节点,选中节点及所属子节点以柔和红色背景和浅色文字标记,确认后统一删除;支持普通与放大视图同步操作。
|
|
1235
|
+
|
|
1236
|
+
## v0.1.29
|
|
1237
|
+
|
|
1238
|
+
- 判断节点新增“合并分支”,支持选择一侧公共流程起点,将两侧后续汇入同一段居中流程;另一侧可保留独有操作或确认删除重复后缀,支持嵌套判断及各类分支,公共节点只执行一次且不随单侧节点删除。
|
|
1239
|
+
- 将 Checkbox、RadioButton 判断入口合并为“判断勾选”,新增 Switch 支持;添加和回放时自动校验组件类型及可勾选属性,读取实时 checked 状态进入 true / false 分支,不改变控件状态,兼容已有独立判断节点。
|
|
1240
|
+
- “流程控制”新增终止流程节点,执行到该节点时主动结束本次回放,不再执行后续节点或连接脚本;子脚本触发时也结束整个流程,保留执行日志和报告,不作为执行失败。
|
|
1241
|
+
- “设备操作”新增启动相册顺序节点,优先解析系统相册入口,必要时查找已安装的常见相册应用;仅打开相册,不选择照片,日志记录实际启动应用,找不到或启动失败时明确报错。
|
|
1242
|
+
- Appium 配置新增流程背景色设置,默认沿用浅绿色,支持预选颜色、颜色选择器、#RGB / #RRGGBB 输入校验和恢复默认;保存后持久化,普通画布、放大视图和连接脚本预览统一应用。
|
|
1243
|
+
|
|
1232
1244
|
## v0.1.28
|
|
1233
1245
|
|
|
1246
|
+
- Appium 配置中的流程背景色独立成区,与节点模型配置分开展示,保留预选颜色、自定义颜色及保存入口。
|
|
1234
1247
|
- “组件操作”新增文字点击分支节点,无需预选元素,支持精准匹配和模糊匹配原生组件文字;匹配并点击后进入“匹配到文字”,等待超时未找到则进入“未匹配到文字”。支持编辑文字和超时,多元素匹配及点击异常明确报错,日志和报告保留分支结果。
|
|
1235
1248
|
- “流程控制”新增输出日志节点,支持设置日志内容和自定义关键字,默认按 `stageLog:内容` 输出;支持添加后编辑,回放实时输出、日志文件及报告均保留日志内容,多行内容逐行添加关键字。
|
|
1236
1249
|
- 新增 AI 识别判断节点,可输入按钮可用、画面黑屏等识别要求,通过当前设备截图返回 `true / false` 并进入对应分支;Appium配置支持独立设置 Base URL、API Key、Model Name 和测试模型,模型需支持图片输入。
|
package/package.json
CHANGED
|
@@ -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,
|
|
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 (
|
|
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,
|
|
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 (
|
|
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;
|
|
@@ -2082,14 +2095,11 @@ watch(
|
|
|
2082
2095
|
/>
|
|
2083
2096
|
</el-select>
|
|
2084
2097
|
</div>
|
|
2085
|
-
<
|
|
2098
|
+
<details
|
|
2086
2099
|
v-if="scriptActivityMismatch"
|
|
2087
|
-
class="appium-activity-
|
|
2088
|
-
title="当前脚本已锁定"
|
|
2089
|
-
type="warning"
|
|
2090
|
-
show-icon
|
|
2091
|
-
:closable="false"
|
|
2100
|
+
class="appium-activity-summary"
|
|
2092
2101
|
>
|
|
2102
|
+
<summary>当前页面与脚本不一致,部分录制操作不可用</summary>
|
|
2093
2103
|
<div class="appium-activity-lock-alert__details">
|
|
2094
2104
|
<div>
|
|
2095
2105
|
<strong>脚本绑定</strong>
|
|
@@ -2101,7 +2111,7 @@ watch(
|
|
|
2101
2111
|
</div>
|
|
2102
2112
|
<p>请在录制流程中添加或执行“启动 APP”节点;Activity 匹配后会自动解除编辑锁定。</p>
|
|
2103
2113
|
</div>
|
|
2104
|
-
</
|
|
2114
|
+
</details>
|
|
2105
2115
|
<NodeDetail
|
|
2106
2116
|
:node="selectedNode"
|
|
2107
2117
|
:current-activity="currentActivity"
|
|
@@ -2109,7 +2119,7 @@ watch(
|
|
|
2109
2119
|
/>
|
|
2110
2120
|
</section>
|
|
2111
2121
|
|
|
2112
|
-
<section class="appium-workbench__section">
|
|
2122
|
+
<section class="appium-workbench__section appium-workbench__flow">
|
|
2113
2123
|
<h3>录制步骤</h3>
|
|
2114
2124
|
<RecordedSteps
|
|
2115
2125
|
:key="newScriptRevision"
|
|
@@ -2118,6 +2128,7 @@ watch(
|
|
|
2118
2128
|
:clipboard-count="flowClipboardCount"
|
|
2119
2129
|
:disabled="recordingLocked"
|
|
2120
2130
|
:remove-disabled="recordingBusy"
|
|
2131
|
+
:merge-disabled="recordingBusy || replaying || saving"
|
|
2121
2132
|
:allowed-locked-actions="scriptActivityMismatch && !recordingBusy ? insertableRecorderActions : []"
|
|
2122
2133
|
:launching-step-id="executingAppStepId"
|
|
2123
2134
|
@remove="removeStep"
|
|
@@ -2130,6 +2141,7 @@ watch(
|
|
|
2130
2141
|
@preview-linked-script="previewLinkedScriptStep"
|
|
2131
2142
|
@execute-step="executeFlowStep"
|
|
2132
2143
|
@update-step="updateStep"
|
|
2144
|
+
@replace-steps="!recordingBusy && !replaying && !saving && (steps = $event)"
|
|
2133
2145
|
/>
|
|
2134
2146
|
</section>
|
|
2135
2147
|
|
|
@@ -2388,3 +2400,18 @@ watch(
|
|
|
2388
2400
|
</el-tooltip>
|
|
2389
2401
|
</section>
|
|
2390
2402
|
</template>
|
|
2403
|
+
|
|
2404
|
+
<style scoped>
|
|
2405
|
+
.appium-activity-summary { margin: 8px 0; color: #946000; font-size: 12px; }
|
|
2406
|
+
.appium-activity-summary summary { cursor: pointer; line-height: 20px; }
|
|
2407
|
+
.appium-activity-summary[open] .appium-activity-lock-alert__details { max-height: 110px; overflow: auto; margin-top: 6px; }
|
|
2408
|
+
:deep(.appium-recorder-card--workbench > .el-card__body) { overflow: hidden; }
|
|
2409
|
+
:deep(.appium-workbench-tabs > .el-tabs__content) { overflow: hidden; }
|
|
2410
|
+
:deep(.appium-workbench-tabs .el-tab-pane) { height: 100%; overflow: auto; }
|
|
2411
|
+
.appium-workbench { height: 100%; box-sizing: border-box; grid-template-rows: auto minmax(180px, 1fr); gap: 10px; overflow: auto; }
|
|
2412
|
+
.appium-workbench__flow { display: flex; flex-direction: column; min-height: 0; }
|
|
2413
|
+
.appium-workbench__flow h3 { margin-bottom: 6px; }
|
|
2414
|
+
.appium-workbench__flow :deep(.appium-recorded-steps-panel) { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
|
2415
|
+
.appium-workbench__flow :deep(.appium-flow-toolbar) { flex: none; margin-bottom: 8px; }
|
|
2416
|
+
.appium-workbench__flow :deep(.appium-flow-canvas--vue:not(.appium-flow-canvas--dialog)) { flex: 1; height: auto; min-height: 120px; }
|
|
2417
|
+
</style>
|
|
@@ -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,15 +13,19 @@ 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;
|
|
18
21
|
steps: AppiumRecordedStep[];
|
|
19
22
|
expandedStepIndex: number | null;
|
|
20
23
|
copyMode: boolean;
|
|
24
|
+
deleteMode?: boolean;
|
|
21
25
|
selectedCopyIndexes: number[];
|
|
22
26
|
disabled?: boolean;
|
|
23
27
|
removeDisabled?: boolean;
|
|
28
|
+
mergeDisabled?: boolean;
|
|
24
29
|
readonly?: boolean;
|
|
25
30
|
launchingStepId?: string;
|
|
26
31
|
clipboardCount?: number;
|
|
@@ -37,6 +42,7 @@ const props = defineProps<{
|
|
|
37
42
|
|
|
38
43
|
const emit = defineEmits<{
|
|
39
44
|
nodeClick: [index: number];
|
|
45
|
+
merge: [index: number];
|
|
40
46
|
copy: [indexes: number[]];
|
|
41
47
|
paste: [index: number, branch?: FlowBranch];
|
|
42
48
|
remove: [index: number];
|
|
@@ -56,6 +62,8 @@ let resetViewFrame = 0;
|
|
|
56
62
|
let pendingResetView = false;
|
|
57
63
|
|
|
58
64
|
const graph = computed(() => buildFlowGraph(props.steps, {
|
|
65
|
+
deleteMode: props.deleteMode,
|
|
66
|
+
mergeDisabled: props.mergeDisabled,
|
|
59
67
|
aiRecognitionModelConfigured: props.aiRecognitionModelConfigured,
|
|
60
68
|
expandedStepIndex: props.expandedStepIndex,
|
|
61
69
|
selectedCopyIndexes: props.selectedCopyIndexes,
|
|
@@ -170,7 +178,7 @@ function handleInsert(payload: {
|
|
|
170
178
|
</script>
|
|
171
179
|
|
|
172
180
|
<template>
|
|
173
|
-
<div class="appium-flow-canvas appium-flow-canvas--vue">
|
|
181
|
+
<div class="appium-flow-canvas appium-flow-canvas--vue" :style="{ '--flow-background': flowBackground }">
|
|
174
182
|
<VueFlow
|
|
175
183
|
:id="id"
|
|
176
184
|
class="appium-vue-flow"
|
|
@@ -204,6 +212,7 @@ function handleInsert(payload: {
|
|
|
204
212
|
@execute="emit('executeStep', $event)"
|
|
205
213
|
@insert="handleInsert"
|
|
206
214
|
@update-step="emit('updateStep', $event.index, $event.step)"
|
|
215
|
+
@merge="emit('merge', $event)"
|
|
207
216
|
@preview-linked-script="emit('previewLinkedScript', $event)"
|
|
208
217
|
@resize="handleNodeResize"
|
|
209
218
|
/>
|
|
@@ -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];
|
|
@@ -158,6 +159,7 @@ watch(() => props.data, () => {
|
|
|
158
159
|
{
|
|
159
160
|
'appium-flow-step-shell--expanded': data.expanded,
|
|
160
161
|
'appium-flow-step-shell--selected': data.selected,
|
|
162
|
+
'appium-flow-step-shell--delete-selected': data.selected && data.deleteMode,
|
|
161
163
|
'appium-flow-step-shell--copying': data.copyMode,
|
|
162
164
|
'appium-flow-step-shell--readonly': props.readonly,
|
|
163
165
|
},
|
|
@@ -180,17 +182,25 @@ watch(() => props.data, () => {
|
|
|
180
182
|
<em v-if="data.note" :title="data.note">{{ data.note }}</em>
|
|
181
183
|
</span>
|
|
182
184
|
</button>
|
|
183
|
-
<span v-if="!props.readonly" class="appium-flow-step-card__actions nodrag nopan" @click.stop>
|
|
185
|
+
<span v-if="!props.readonly && !data.deleteMode" class="appium-flow-step-card__actions nodrag nopan" @click.stop>
|
|
186
|
+
<el-tooltip v-if="data.flowKind === 'condition' && !data.copyMode" content="合并分支:将两侧后续节点汇入公共流程" placement="top" :show-after="200">
|
|
187
|
+
<span class="appium-node-action-tooltip"><el-button text size="small" :icon="Connection"
|
|
188
|
+
title="合并分支" aria-label="合并分支" :disabled="data.mergeDisabled || Boolean(data.step.flow?.successTargetId)"
|
|
189
|
+
@click="emit('merge', data.index)" />
|
|
190
|
+
</span></el-tooltip>
|
|
191
|
+
<el-tooltip v-if="!data.copyMode && data.canCopy" content="复制节点:复制后可在插入位置粘贴" placement="top" :show-after="200">
|
|
192
|
+
<span class="appium-node-action-tooltip">
|
|
184
193
|
<el-button
|
|
185
|
-
v-if="!data.copyMode && data.canCopy"
|
|
186
194
|
text
|
|
187
195
|
size="small"
|
|
188
196
|
:icon="CopyDocument"
|
|
189
197
|
title="复制节点"
|
|
190
198
|
@click="emit('copy', data.index)"
|
|
191
199
|
/>
|
|
200
|
+
</span></el-tooltip>
|
|
201
|
+
<el-tooltip v-if="data.canExecute" :content="data.step.type === 'aiRecognition' ? '测试 AI 识别:使用当前设备画面测试识别结果' : '立即执行:在当前设备上执行此操作'" placement="top" :show-after="200">
|
|
202
|
+
<span class="appium-node-action-tooltip">
|
|
192
203
|
<el-button
|
|
193
|
-
v-if="data.canExecute"
|
|
194
204
|
text
|
|
195
205
|
size="small"
|
|
196
206
|
:icon="VideoPlay"
|
|
@@ -200,8 +210,10 @@ watch(() => props.data, () => {
|
|
|
200
210
|
:aria-label="data.step.type === 'aiRecognition' ? '测试 AI 识别' : '立即执行'"
|
|
201
211
|
@click="emit('execute', data.index)"
|
|
202
212
|
/>
|
|
213
|
+
</span></el-tooltip>
|
|
214
|
+
<el-tooltip v-if="data.canEditInput" content="修改输入内容:编辑此节点要输入的文字" placement="top" :show-after="200">
|
|
215
|
+
<span class="appium-node-action-tooltip">
|
|
203
216
|
<el-button
|
|
204
|
-
v-if="data.canEditInput"
|
|
205
217
|
text
|
|
206
218
|
size="small"
|
|
207
219
|
:icon="Edit"
|
|
@@ -209,14 +221,19 @@ watch(() => props.data, () => {
|
|
|
209
221
|
title="修改输入内容"
|
|
210
222
|
@click="emit('editInput', data.index)"
|
|
211
223
|
/>
|
|
224
|
+
</span></el-tooltip>
|
|
225
|
+
<el-tooltip v-if="data.step.type === 'runScript'" content="预览连接脚本:查看关联脚本的流程" placement="top" :show-after="200">
|
|
226
|
+
<span class="appium-node-action-tooltip">
|
|
212
227
|
<el-button
|
|
213
|
-
v-if="data.step.type === 'runScript'"
|
|
214
228
|
text
|
|
215
229
|
size="small"
|
|
216
230
|
:icon="View"
|
|
217
231
|
title="预览连接脚本"
|
|
218
232
|
@click="emit('previewLinkedScript', data.index)"
|
|
219
233
|
/>
|
|
234
|
+
</span></el-tooltip>
|
|
235
|
+
<el-tooltip :content="data.flowKind === 'condition' ? '删除节点:同时删除所属分支子节点,保留公共流程' : '删除节点:移除此操作并连接前后节点'" placement="top" :show-after="200">
|
|
236
|
+
<span class="appium-node-action-tooltip">
|
|
220
237
|
<el-button
|
|
221
238
|
text
|
|
222
239
|
size="small"
|
|
@@ -225,6 +242,7 @@ watch(() => props.data, () => {
|
|
|
225
242
|
title="删除节点"
|
|
226
243
|
@click="emit('remove', data.index)"
|
|
227
244
|
/>
|
|
245
|
+
</span></el-tooltip>
|
|
228
246
|
</span>
|
|
229
247
|
<FlowStepEditor
|
|
230
248
|
v-if="!props.readonly && data.expanded"
|
|
@@ -246,5 +264,10 @@ watch(() => props.data, () => {
|
|
|
246
264
|
</template>
|
|
247
265
|
|
|
248
266
|
<style scoped>
|
|
267
|
+
.appium-flow-step-shell--delete-selected .appium-flow-step-card { background: #b83232; border-color: #a52a2a; }
|
|
268
|
+
.appium-flow-step-shell--delete-selected .appium-flow-step-card__content strong,
|
|
269
|
+
.appium-flow-step-shell--delete-selected .appium-flow-step-card__content em { color: #fff; }
|
|
270
|
+
.appium-flow-step-shell--delete-selected .appium-flow-step-card__content small { color: #fee2e2; }
|
|
271
|
+
.appium-node-action-tooltip { display: inline-flex; }
|
|
249
272
|
.appium-ai-model-warning { float: left; margin: 2px 6px 0 0; font-size: 16px; }
|
|
250
273
|
</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"
|