android-midscene-automation 0.1.35 → 0.1.37
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 +11 -2
- package/README.md +11 -2
- package/package.json +7 -7
- package/remote-agent/index.ts +2 -0
- package/server/android-device-info.ts +28 -0
- package/server/appium-recorder/appium-runner.ts +26 -11
- package/server/appium-recorder/open-gallery.ts +10 -1
- package/server/appium-recorder/repository.ts +2 -0
- package/server/appium-recorder/request-timeout.ts +25 -0
- package/server/appium-recorder/script-export.ts +1 -1
- package/server/appium-recorder/stop-app.ts +18 -0
- package/server/http-api.ts +19 -0
- package/server/remote-agents/protocol.ts +1 -0
- package/src/appium-recorder/AppiumPage.vue +31 -4
- package/src/appium-recorder/action-descriptions.ts +1 -0
- package/src/appium-recorder/components/FlowNodeCard.vue +6 -6
- package/src/appium-recorder/components/FlowStepEditor.vue +25 -6
- package/src/appium-recorder/components/MergeBranchesDialog.vue +31 -3
- package/src/appium-recorder/components/NestedConditionBranches.vue +5 -3
- package/src/appium-recorder/components/RecordedSteps.vue +1 -0
- package/src/appium-recorder/flow-copy.ts +2 -0
- package/src/appium-recorder/flow-graph.ts +2 -1
- package/src/appium-recorder/flow-labels.ts +2 -0
- package/src/appium-recorder/flow-merge.ts +12 -0
- package/src/appium-recorder/flow-unmerge.ts +145 -0
- package/src/appium-recorder/types.ts +13 -0
- package/src/appium-recorder/visual-change.ts +13 -1
- package/src/components/device/DeviceInfoTooltip.vue +55 -0
- package/src/components/device/DevicePreviewPanel.vue +3 -1
- package/src/types.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
# 更新记录
|
|
2
|
+
## v0.1.37
|
|
3
|
+
- 修复元素查找超时未覆盖 Appium 请求的问题,驱动无响应时及时中止并明确报错,不再误判为找不到元素;唯一原生定位优先使用 ID 或 Accessibility ID。
|
|
4
|
+
- 设备预览新增设备信息,展示设备名称、品牌、型号、处理器、Android 版本、物理分辨率及当前分辨率,支持远程设备;问号位于标题右侧,样式与全局变量帮助图标一致。
|
|
5
|
+
- 新增独立“杀死 APP”操作节点,默认使用当前选中应用包名,支持编辑目标包名;停止当前设备用户下的应用进程,不清除应用数据。
|
|
6
|
+
- Midscene 相关模块由 1.12.5 统一升级至 1.12.6。
|
|
7
|
+
|
|
8
|
+
## v0.1.36
|
|
9
|
+
- 新合并支持取消:原合并按钮切换为取消合并,还原分支和被删除的重复节点,保留配置编辑;公共流程新增的普通操作可选择保留左侧、右侧或两侧,结构冲突时阻止还原。本地保存还原记录,导出及节点复制不携带历史记录。
|
|
10
|
+
- 启动相册前先停止当前设备用户下的目标相册进程,再重新打开;保留相册自动适配,不清除照片及应用数据,停止失败时明确报错。
|
|
11
|
+
- 修复修改元素或父级定位后仍使用旧备用定位、画面变化像素容差无法设为 0 的问题。
|
|
12
|
+
|
|
2
13
|
## v0.1.35
|
|
3
|
-
- 修复已有图像判断配置无效时导致整个脚本列表加载失败的问题,读取保留原数据,保存和执行时再校验。
|
|
4
|
-
- 图像判断配置显示模板尺寸,提前提示模板超出检测区域,并支持手动一键扩大框选区域;回放尺寸不匹配时展示具体尺寸,避免仅在运行时得到笼统错误。
|
|
5
14
|
- 新增“图像判断”分支节点,支持本地 OpenCV 模板匹配、图片勾选双模板、黑屏、区域颜色和多帧变化检测;支持组件定位、框选区域、模板采集与上传、连续采样及 true/false 分支,证据不足时明确报告无法判定。HTML 报告展示指标和可放大的检测图片,并沿用敏感运行截图保护。
|
|
6
15
|
- 合流后的公共节点上方新增插入入口,支持新增和粘贴操作,所有汇入分支先执行新操作再进入原公共节点。
|
|
7
16
|
- 修复嵌套判断显式合流到外层公共节点时连接被过滤、公共节点提前排列的问题,支持跨层分支合流。
|
package/README.md
CHANGED
|
@@ -1229,9 +1229,18 @@ Linux: ~/.local/share/android-midscene-automation
|
|
|
1229
1229
|
|
|
1230
1230
|
# 项目更新记录
|
|
1231
1231
|
|
|
1232
|
+
## v0.1.37
|
|
1233
|
+
- 修复元素查找超时未覆盖 Appium 请求的问题,驱动无响应时及时中止并明确报错,不再误判为找不到元素;唯一原生定位优先使用 ID 或 Accessibility ID。
|
|
1234
|
+
- 设备预览新增设备信息,展示设备名称、品牌、型号、处理器、Android 版本、物理分辨率及当前分辨率,支持远程设备;问号位于标题右侧,样式与全局变量帮助图标一致。
|
|
1235
|
+
- 新增独立“杀死 APP”操作节点,默认使用当前选中应用包名,支持编辑目标包名;停止当前设备用户下的应用进程,不清除应用数据。
|
|
1236
|
+
- Midscene 相关模块由 1.12.5 统一升级至 1.12.6。
|
|
1237
|
+
|
|
1238
|
+
## v0.1.36
|
|
1239
|
+
- 新合并支持取消:原合并按钮切换为取消合并,还原分支和被删除的重复节点,保留配置编辑;公共流程新增的普通操作可选择保留左侧、右侧或两侧,结构冲突时阻止还原。本地保存还原记录,导出及节点复制不携带历史记录。
|
|
1240
|
+
- 启动相册前先停止当前设备用户下的目标相册进程,再重新打开;保留相册自动适配,不清除照片及应用数据,停止失败时明确报错。
|
|
1241
|
+
- 修复修改元素或父级定位后仍使用旧备用定位、画面变化像素容差无法设为 0 的问题。
|
|
1242
|
+
|
|
1232
1243
|
## v0.1.35
|
|
1233
|
-
- 修复已有图像判断配置无效时导致整个脚本列表加载失败的问题,读取保留原数据,保存和执行时再校验。
|
|
1234
|
-
- 图像判断配置显示模板尺寸,提前提示模板超出检测区域,并支持手动一键扩大框选区域;回放尺寸不匹配时展示具体尺寸,避免仅在运行时得到笼统错误。
|
|
1235
1244
|
- 新增“图像判断”分支节点,支持本地 OpenCV 模板匹配、图片勾选双模板、黑屏、区域颜色和多帧变化检测;支持组件定位、框选区域、模板采集与上传、连续采样及 true/false 分支,证据不足时明确报告无法判定。HTML 报告展示指标和可放大的检测图片,并沿用敏感运行截图保护。
|
|
1236
1245
|
- 合流后的公共节点上方新增插入入口,支持新增和粘贴操作,所有汇入分支先执行新操作再进入原公共节点。
|
|
1237
1246
|
- 修复嵌套判断显式合流到外层公共节点时连接被过滤、公共节点提前排列的问题,支持跨层分支合流。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "android-midscene-automation",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.37",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/oooooooko/android-midscene-automation.git"
|
|
@@ -38,12 +38,12 @@
|
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@dagrejs/dagre": "1.1.5",
|
|
40
40
|
"@element-plus/icons-vue": "2.3.2",
|
|
41
|
-
"@midscene/android": "https://registry.npmjs.org/@midscene/android/-/android-1.12.
|
|
42
|
-
"@midscene/android-playground": "https://registry.npmjs.org/@midscene/android-playground/-/android-playground-1.12.
|
|
43
|
-
"@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.
|
|
44
|
-
"@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.
|
|
45
|
-
"@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.
|
|
46
|
-
"@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.12.
|
|
41
|
+
"@midscene/android": "https://registry.npmjs.org/@midscene/android/-/android-1.12.6.tgz",
|
|
42
|
+
"@midscene/android-playground": "https://registry.npmjs.org/@midscene/android-playground/-/android-playground-1.12.6.tgz",
|
|
43
|
+
"@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.6.tgz",
|
|
44
|
+
"@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.6.tgz",
|
|
45
|
+
"@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.6.tgz",
|
|
46
|
+
"@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.12.6.tgz",
|
|
47
47
|
"@techstark/opencv-js": "5.0.0-release.1",
|
|
48
48
|
"@vitejs/plugin-vue": "6.0.7",
|
|
49
49
|
"@vue-flow/core": "1.48.2",
|
package/remote-agent/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import os from 'node:os';
|
|
3
|
+
import { readAndroidDeviceInfo } from '../server/android-device-info';
|
|
3
4
|
import { readFreshWindowHierarchy } from '../server/appium-recorder/tree-dump';
|
|
4
5
|
import type { RemoteAgentDevice, RemoteCommand } from '../server/remote-agents/protocol';
|
|
5
6
|
import { replayAppiumScript } from '../server/appium-recorder/appium-runner';
|
|
@@ -100,6 +101,7 @@ async function handleCommand(command: RemoteCommand) {
|
|
|
100
101
|
return { base64: image.toString('base64') };
|
|
101
102
|
}
|
|
102
103
|
if (command.type === 'displayInfo') return await getDisplayInfo(command.deviceId);
|
|
104
|
+
if (command.type === 'deviceInfo') return await readAndroidDeviceInfo(command.deviceId, 'adb');
|
|
103
105
|
if (command.type === 'tree') {
|
|
104
106
|
const [xml, activity] = await Promise.all([
|
|
105
107
|
dumpWindowHierarchy(command.deviceId),
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import type { AndroidDeviceDetails } from '../src/types';
|
|
3
|
+
|
|
4
|
+
export function parseAndroidDeviceInfo(properties: string, size: string, deviceName = ''): AndroidDeviceDetails {
|
|
5
|
+
const values = new Map([...properties.matchAll(/^\[([^\]]+)\]: \[(.*)\]\s*$/gm)].map(match => [match[1], match[2]]));
|
|
6
|
+
const get = (...keys: string[]) => keys.map(key => values.get(key)?.trim()).find(Boolean) || '';
|
|
7
|
+
const physical = size.match(/Physical size:\s*(\d+)x(\d+)/i);
|
|
8
|
+
const current = size.match(/Override size:\s*(\d+)x(\d+)/i) || physical;
|
|
9
|
+
const resolution = (match: RegExpMatchArray | null) => match ? `${match[1]} × ${match[2]}` : '';
|
|
10
|
+
return {
|
|
11
|
+
name: deviceName.trim() && deviceName.trim() !== 'null' ? deviceName.trim() : get('persist.sys.device_name', 'ro.product.marketname', 'ro.product.model'),
|
|
12
|
+
brand: get('ro.product.brand', 'ro.product.manufacturer'), model: get('ro.product.model'),
|
|
13
|
+
processor: get('ro.soc.model', 'ro.board.platform', 'ro.hardware'),
|
|
14
|
+
androidVersion: get('ro.build.version.release'), physicalResolution: resolution(physical), resolution: resolution(current),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readAndroidDeviceInfo(deviceId: string, adb: string) {
|
|
19
|
+
const run = (args: string[]) => new Promise<string>((resolve, reject) => {
|
|
20
|
+
execFile(adb, ['-s', deviceId, 'shell', ...args], { timeout: 4000, maxBuffer: 1024 * 1024 }, (error, stdout) => {
|
|
21
|
+
if (error) reject(error); else resolve(stdout);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
const properties = await run(['getprop']);
|
|
25
|
+
const size = await run(['wm', 'size']).catch(() => '');
|
|
26
|
+
const name = await run(['settings', 'get', 'global', 'device_name']).catch(() => '');
|
|
27
|
+
return parseAndroidDeviceInfo(properties, size, name);
|
|
28
|
+
}
|
|
@@ -11,6 +11,8 @@ import { checkImage } from './image-check';
|
|
|
11
11
|
import { IMAGE_CHECK_MODES, type ImageCheckResult } from '../../src/appium-recorder/image-check';
|
|
12
12
|
import { formatStageLog } from '../../src/appium-recorder/stage-log';
|
|
13
13
|
import { openGalleryOnDevice } from './open-gallery';
|
|
14
|
+
import { stopAppOnDevice } from './stop-app';
|
|
15
|
+
import { AppiumRequestTimeoutError, timedAppiumFetch, withElementDeadline } from './request-timeout';
|
|
14
16
|
import { textClickSelector } from '../../src/appium-recorder/text-click';
|
|
15
17
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
16
18
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
@@ -83,6 +85,8 @@ function isReplayStopped(error?: unknown) {
|
|
|
83
85
|
|
|
84
86
|
function throwIfReplayStopped(error?: unknown) {
|
|
85
87
|
if (isReplayStopped(error)) throw new ReplayStoppedError();
|
|
88
|
+
// 驱动失去响应不是“元素不存在”,不能被备用定位或可选步骤吞掉。
|
|
89
|
+
if (error instanceof AppiumRequestTimeoutError) throw error;
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
function errorDetail(error: unknown) {
|
|
@@ -276,30 +280,31 @@ async function appiumRequest<T>(
|
|
|
276
280
|
if (!options?.ignoreAbort) throwIfReplayStopped();
|
|
277
281
|
context?.appiumLog(`[Appium] 请求:${method} ${path}`);
|
|
278
282
|
let response: Response;
|
|
283
|
+
let responseText: string;
|
|
279
284
|
try {
|
|
280
|
-
|
|
285
|
+
const result = await timedAppiumFetch(requestUrl, {
|
|
281
286
|
...init,
|
|
282
|
-
signal: options?.ignoreAbort
|
|
283
|
-
? (options.timeoutMs ? AbortSignal.timeout(options.timeoutMs) : undefined)
|
|
284
|
-
: context?.signal || init?.signal,
|
|
287
|
+
signal: options?.ignoreAbort ? undefined : context?.signal || init?.signal,
|
|
285
288
|
headers: {
|
|
286
289
|
'Content-Type': 'application/json',
|
|
287
290
|
...(init?.headers || {}),
|
|
288
291
|
},
|
|
289
|
-
});
|
|
292
|
+
}, options?.timeoutMs ?? (path === '/session' && method === 'POST' ? 120000 : 30000));
|
|
293
|
+
response = result.response;
|
|
294
|
+
responseText = result.text;
|
|
290
295
|
} catch (error) {
|
|
291
296
|
if (isReplayStopped(error) && !options?.ignoreAbort) {
|
|
292
297
|
context?.appiumLog(`[Appium] 取消:${method} ${path}`);
|
|
293
298
|
throw new ReplayStoppedError();
|
|
294
299
|
}
|
|
295
300
|
context?.appiumLog(`[Appium] 异常:${method} ${path}(${errorDetail(error)})`);
|
|
301
|
+
if (error instanceof AppiumRequestTimeoutError) throw error;
|
|
296
302
|
if (isTimeoutError(error)) {
|
|
297
303
|
throw new Error(`Appium 请求超时 ${requestUrl},${errorDetail(error)}`);
|
|
298
304
|
}
|
|
299
305
|
throw new Error(`无法连接 Appium 服务 ${requestUrl},${errorDetail(error)}`);
|
|
300
306
|
}
|
|
301
307
|
|
|
302
|
-
const responseText = await response.text();
|
|
303
308
|
context?.appiumLog(`[Appium] 响应:HTTP ${response.status} ${method} ${path}(${Date.now() - startedAt}ms)`);
|
|
304
309
|
const payload = (() => {
|
|
305
310
|
try {
|
|
@@ -439,7 +444,11 @@ async function findElementBySelector(
|
|
|
439
444
|
return elementId;
|
|
440
445
|
}
|
|
441
446
|
|
|
442
|
-
async function findElement(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
447
|
+
async function findElement(sessionId: string, step: AppiumRecordedStepRecord, timeoutMs = step.timeoutMs ?? 10000) {
|
|
448
|
+
return withElementDeadline(timeoutMs, () => findElementWithinDeadline(sessionId, step));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function findElementWithinDeadline(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
443
452
|
if (!step.selector) throw new Error(`${step.label} 缺少 selector`);
|
|
444
453
|
const attempts: string[] = [];
|
|
445
454
|
|
|
@@ -454,7 +463,9 @@ async function findElement(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
|
454
463
|
}
|
|
455
464
|
}
|
|
456
465
|
|
|
457
|
-
|
|
466
|
+
// 唯一原生定位优先;非唯一元素仍保留父级与备用 XPath 的精确匹配。
|
|
467
|
+
const preferPrimary = step.selector.unique === true && ['id', 'accessibilityId'].includes(step.selector.strategy);
|
|
468
|
+
for (const selector of [...(preferPrimary ? [step.selector] : []), ...(step.selectorChain || [])]) {
|
|
458
469
|
try {
|
|
459
470
|
attempts.push(`${selector.strategy} ${selector.value || ''}`);
|
|
460
471
|
return await findElementBySelector(sessionId, selector);
|
|
@@ -506,7 +517,7 @@ async function waitForElement(sessionId: string, step: AppiumRecordedStepRecord)
|
|
|
506
517
|
let lastError: unknown;
|
|
507
518
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
508
519
|
try {
|
|
509
|
-
return await findElement(sessionId, step);
|
|
520
|
+
return await findElement(sessionId, step, Math.max(1, timeoutMs - (Date.now() - startedAt)));
|
|
510
521
|
} catch (error) {
|
|
511
522
|
throwIfReplayStopped(error);
|
|
512
523
|
lastError = error;
|
|
@@ -521,7 +532,7 @@ async function findOptionalElement(sessionId: string, step: AppiumRecordedStepRe
|
|
|
521
532
|
const timeoutMs = step.timeoutMs ?? 2000;
|
|
522
533
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
523
534
|
try {
|
|
524
|
-
return await findElement(sessionId, step);
|
|
535
|
+
return await findElement(sessionId, step, Math.max(1, timeoutMs - (Date.now() - startedAt)));
|
|
525
536
|
} catch (error) {
|
|
526
537
|
throwIfReplayStopped(error);
|
|
527
538
|
await wait(250);
|
|
@@ -535,7 +546,7 @@ async function waitForElementGone(sessionId: string, step: AppiumRecordedStepRec
|
|
|
535
546
|
const timeoutMs = step.timeoutMs || 10000;
|
|
536
547
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
537
548
|
try {
|
|
538
|
-
await findElement(sessionId, step);
|
|
549
|
+
await findElement(sessionId, step, Math.max(1, timeoutMs - (Date.now() - startedAt)));
|
|
539
550
|
} catch (error) {
|
|
540
551
|
throwIfReplayStopped(error);
|
|
541
552
|
return;
|
|
@@ -768,6 +779,10 @@ async function runStep(
|
|
|
768
779
|
return `ADB 已启动 ${packageName}`;
|
|
769
780
|
}
|
|
770
781
|
|
|
782
|
+
if (step.type === 'stopApp') {
|
|
783
|
+
return stopAppOnDevice(deviceId, step.value || '', replayContext.getStore()?.signal);
|
|
784
|
+
}
|
|
785
|
+
|
|
771
786
|
if (step.type === 'clearAppData') {
|
|
772
787
|
const packageName = step.value || '';
|
|
773
788
|
await clearAppDataOnDevice(deviceId, packageName);
|
|
@@ -43,10 +43,19 @@ export async function openGalleryOnDevice(deviceId: string, signal?: AbortSignal
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
if (!component) throw new Error('未找到可启动的相册应用,请检查手机是否安装并启用了相册');
|
|
46
|
+
// 仅停止已解析出的目标相册,避免复用后台页面;不清除应用数据。
|
|
47
|
+
const packageName = component.split('/')[0]!;
|
|
48
|
+
try {
|
|
49
|
+
const stopped = await run(['am', 'force-stop', '--user', 'current', packageName]);
|
|
50
|
+
if (/Error:|Exception|Permission Denial/i.test(stopped)) throw new Error(stopped);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (signal?.aborted) throw error;
|
|
53
|
+
throw new Error(`停止相册失败(${packageName}):${error instanceof Error ? error.message : String(error)}`);
|
|
54
|
+
}
|
|
46
55
|
const output = await run(['am', 'start', '-W', '--user', 'current', '-a', MAIN, '-n', component]);
|
|
47
56
|
// am start 某些错误仍返回 0,不能只检查进程退出码。
|
|
48
57
|
if (!/^Status:\s*ok\s*$/im.test(output) || /Error:|Exception|Permission Denial/i.test(output)) {
|
|
49
58
|
throw new Error(`启动相册失败:${output || component}`);
|
|
50
59
|
}
|
|
51
|
-
return `已启动相册:${component}
|
|
60
|
+
return `已启动相册:${component}(启动前已停止相册进程)`;
|
|
52
61
|
}
|
|
@@ -13,6 +13,7 @@ import { getPresetVariables, savePresetVariables, deleteScriptVariables } from '
|
|
|
13
13
|
import { validateImageCheck, type ImageCheckConfig } from '../../src/appium-recorder/image-check';
|
|
14
14
|
|
|
15
15
|
export type AppiumRecordedStepRecord = {
|
|
16
|
+
mergeUndo?: import('../../src/appium-recorder/types').BranchMergeUndo;
|
|
16
17
|
id: string;
|
|
17
18
|
type:
|
|
18
19
|
| 'tap'
|
|
@@ -37,6 +38,7 @@ export type AppiumRecordedStepRecord = {
|
|
|
37
38
|
| 'swipe'
|
|
38
39
|
| 'screenshot'
|
|
39
40
|
| 'launchApp'
|
|
41
|
+
| 'stopApp'
|
|
40
42
|
| 'openGallery'
|
|
41
43
|
| 'endFlow'
|
|
42
44
|
| 'loop'
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
|
|
3
|
+
const deadline = new AsyncLocalStorage<number>();
|
|
4
|
+
export class AppiumRequestTimeoutError extends Error {}
|
|
5
|
+
|
|
6
|
+
export function withElementDeadline<T>(timeoutMs: number, action: () => Promise<T>) {
|
|
7
|
+
return deadline.run(Date.now() + Math.max(1, timeoutMs), action);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// 响应体也受同一个期限约束,避免收到 HTTP 头后继续无限等待。
|
|
11
|
+
export async function timedAppiumFetch(url: string, init: RequestInit, timeoutMs: number) {
|
|
12
|
+
const remaining = Math.max(1, Math.min(timeoutMs, (deadline.getStore() ?? Infinity) - Date.now()));
|
|
13
|
+
const timeout = AbortSignal.timeout(Math.ceil(remaining));
|
|
14
|
+
const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetch(url, { ...init, signal });
|
|
17
|
+
const text = await response.text();
|
|
18
|
+
return { response, text };
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (timeout.aborted && !init.signal?.aborted) {
|
|
21
|
+
throw new AppiumRequestTimeoutError(`设备自动化服务无响应:Appium 请求超过 ${Math.ceil(remaining)}ms,已停止等待(${url})`);
|
|
22
|
+
}
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -45,7 +45,7 @@ export async function createAppiumScriptExport(
|
|
|
45
45
|
appActivity: script.appActivity,
|
|
46
46
|
deviceId: script.deviceId,
|
|
47
47
|
variables: clearSecrets(script.variables),
|
|
48
|
-
steps: script.steps.map(step => ({ ...step, ...(step.parameters ? { parameters: clearSecrets(step.parameters) } : {}) })),
|
|
48
|
+
steps: script.steps.map(step => ({ ...step, mergeUndo: undefined, ...(step.parameters ? { parameters: clearSecrets(step.parameters) } : {}) })),
|
|
49
49
|
},
|
|
50
50
|
}), null, 2);
|
|
51
51
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { getAdbCommand } from '../android-sdk';
|
|
3
|
+
|
|
4
|
+
export function stopAppOnDevice(deviceId: string, packageName: string, signal?: AbortSignal) {
|
|
5
|
+
if (!/^[A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+$/.test(packageName)) {
|
|
6
|
+
throw new Error('杀死 APP 需要有效的应用包名');
|
|
7
|
+
}
|
|
8
|
+
// force-stop 只停止指定应用,不使用 pm clear,不删除应用数据。
|
|
9
|
+
return new Promise<string>((resolve, reject) => {
|
|
10
|
+
execFile(getAdbCommand(), ['-s', deviceId, 'shell', 'am', 'force-stop', '--user', 'current', packageName],
|
|
11
|
+
{ timeout: 10000, maxBuffer: 1024 * 1024, signal }, (error, stdout, stderr) => {
|
|
12
|
+
const output = `${stdout || ''}\n${stderr || ''}`.trim();
|
|
13
|
+
if (error || /Error:|Exception|Permission Denial/i.test(output)) {
|
|
14
|
+
reject(new Error(`停止 APP 失败(${packageName}):${output || error?.message}`));
|
|
15
|
+
} else resolve(`已停止 APP 进程:${packageName}(保留应用数据)`);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
package/server/http-api.ts
CHANGED
|
@@ -38,6 +38,7 @@ import { handleAppiumRecorderRequest } from './appium-recorder/routes';
|
|
|
38
38
|
import { handleRemoteAgentRequest } from './remote-agents/routes';
|
|
39
39
|
import { isRemoteDeviceId, listRemoteAndroidDevices, sendRemoteCommand } from './remote-agents/registry';
|
|
40
40
|
import { getAdbCommand } from './android-sdk';
|
|
41
|
+
import { readAndroidDeviceInfo } from './android-device-info';
|
|
41
42
|
|
|
42
43
|
async function readBody<T>(req: IncomingMessage) {
|
|
43
44
|
let body = '';
|
|
@@ -873,6 +874,24 @@ export function createApiMiddleware() {
|
|
|
873
874
|
return;
|
|
874
875
|
}
|
|
875
876
|
|
|
877
|
+
if (req.url?.startsWith('/api/android-device-info') && req.method === 'GET') {
|
|
878
|
+
try {
|
|
879
|
+
const deviceId = getRequestedDeviceId(req) || selectedDeviceId;
|
|
880
|
+
if (!deviceId) throw new Error('未检测到可用设备');
|
|
881
|
+
assertDeviceAllowedForRequest(req, deviceId);
|
|
882
|
+
const details = isRemoteDeviceId(deviceId)
|
|
883
|
+
? await sendRemoteCommand(deviceId, 'deviceInfo')
|
|
884
|
+
: await readAndroidDeviceInfo(deviceId, getAdbCommand());
|
|
885
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
886
|
+
res.end(JSON.stringify({ deviceId, details }));
|
|
887
|
+
} catch (error) {
|
|
888
|
+
res.statusCode = 500;
|
|
889
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
890
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '读取设备信息失败' }));
|
|
891
|
+
}
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
|
|
876
895
|
if (req.url?.startsWith('/api/android-display-info') && req.method === 'GET') {
|
|
877
896
|
try {
|
|
878
897
|
const deviceId = getRequestedDeviceId(req) || selectedDeviceId;
|
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
boundsToVisualRegion,
|
|
61
61
|
createStepId,
|
|
62
62
|
normalizeVisualChangeConfig,
|
|
63
|
+
syncVisualChangeThreshold,
|
|
63
64
|
visualRegionToBounds,
|
|
64
65
|
} from './visual-change';
|
|
65
66
|
import {
|
|
@@ -121,6 +122,7 @@ const insertableRecorderActions: RecorderAction[] = [
|
|
|
121
122
|
'clearInput',
|
|
122
123
|
'coordinateTap',
|
|
123
124
|
'launchApp',
|
|
125
|
+
'stopApp',
|
|
124
126
|
'openGallery',
|
|
125
127
|
'endFlow',
|
|
126
128
|
'loop',
|
|
@@ -182,6 +184,7 @@ const currentActivity = shallowRef('');
|
|
|
182
184
|
const replayOutput = shallowRef('');
|
|
183
185
|
const replayOutputDialogVisible = shallowRef(false);
|
|
184
186
|
const linkedScriptDialogVisible = shallowRef(false);
|
|
187
|
+
const linkedScriptEditingId = shallowRef('');
|
|
185
188
|
const linkedScriptTargetId = shallowRef('');
|
|
186
189
|
const linkedParameters = reactive<AppiumRecordedStep>({ id: 'link-parameters', type: 'runScript', label: '', parameters: [], returns: [] });
|
|
187
190
|
const linkedScriptInsertIndex = shallowRef<number | undefined>();
|
|
@@ -1166,6 +1169,7 @@ async function addSwipeStep(index?: number, branchTarget?: BranchTarget) {
|
|
|
1166
1169
|
}
|
|
1167
1170
|
|
|
1168
1171
|
function openLinkedScriptDialog(index?: number, branchTarget?: BranchTarget) {
|
|
1172
|
+
linkedScriptEditingId.value = '';
|
|
1169
1173
|
if (!linkableScripts.value.length) {
|
|
1170
1174
|
ElMessage.warning('暂无可连接的已保存脚本');
|
|
1171
1175
|
return;
|
|
@@ -1215,7 +1219,14 @@ function addLinkedScriptStep() {
|
|
|
1215
1219
|
);
|
|
1216
1220
|
return;
|
|
1217
1221
|
}
|
|
1218
|
-
|
|
1222
|
+
const editingIndex = linkedScriptEditingId.value ? steps.value.findIndex(step => step.id === linkedScriptEditingId.value) : -1;
|
|
1223
|
+
if (linkedScriptEditingId.value && editingIndex < 0) { ElMessage.warning('原节点已删除,请重新打开'); return; }
|
|
1224
|
+
if (editingIndex >= 0) {
|
|
1225
|
+
const original = steps.value[editingIndex]!;
|
|
1226
|
+
updateStep(editingIndex, { ...original, value: script.id,
|
|
1227
|
+
...(original.label.startsWith('连接脚本 ') ? { label: `连接脚本 ${script.name}` } : {}),
|
|
1228
|
+
parameters: JSON.parse(JSON.stringify(linkedParameters.parameters || [])), returns: JSON.parse(JSON.stringify(linkedParameters.returns || [])) });
|
|
1229
|
+
} else insertStep(
|
|
1219
1230
|
{ ...createRunScriptStep(script), parameters: JSON.parse(JSON.stringify(linkedParameters.parameters || [])), returns: JSON.parse(JSON.stringify(linkedParameters.returns || [])) },
|
|
1220
1231
|
linkedScriptInsertIndex.value,
|
|
1221
1232
|
linkedScriptBranchTarget.value || undefined,
|
|
@@ -1224,10 +1235,12 @@ function addLinkedScriptStep() {
|
|
|
1224
1235
|
linkedScriptBranchTarget.value = null;
|
|
1225
1236
|
linkedScriptExpectedActivity.value = '';
|
|
1226
1237
|
linkedScriptDialogVisible.value = false;
|
|
1227
|
-
ElMessage.success('已添加连接脚本步骤');
|
|
1238
|
+
ElMessage.success(editingIndex >= 0 ? '已修改连接脚本' : '已添加连接脚本步骤');
|
|
1239
|
+
linkedScriptEditingId.value = '';
|
|
1228
1240
|
}
|
|
1229
1241
|
|
|
1230
1242
|
function closeLinkedScriptDialog() {
|
|
1243
|
+
linkedScriptEditingId.value = '';
|
|
1231
1244
|
linkedScriptDialogVisible.value = false;
|
|
1232
1245
|
linkedScriptInsertIndex.value = undefined;
|
|
1233
1246
|
linkedScriptBranchTarget.value = null;
|
|
@@ -1568,6 +1581,10 @@ async function addAction(
|
|
|
1568
1581
|
value: form.appPackage,
|
|
1569
1582
|
}, index, branchTarget);
|
|
1570
1583
|
}
|
|
1584
|
+
if (action === 'stopApp') {
|
|
1585
|
+
return insertStep({ id: createStepId(), type: 'stopApp', label: `杀死 APP ${form.appPackage}`,
|
|
1586
|
+
value: form.appPackage, flow: { nodeKind: 'action' } }, index, branchTarget);
|
|
1587
|
+
}
|
|
1571
1588
|
if (action === 'clearAppData') {
|
|
1572
1589
|
return insertStep({
|
|
1573
1590
|
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
|
@@ -1791,11 +1808,21 @@ function updateStep(index: number, step: AppiumRecordedStep) {
|
|
|
1791
1808
|
? syncVisualChangePairConfig(candidate, visualChange)
|
|
1792
1809
|
: candidate
|
|
1793
1810
|
));
|
|
1794
|
-
steps.value = nextSteps;
|
|
1811
|
+
steps.value = syncVisualChangeThreshold(nextSteps, step);
|
|
1795
1812
|
}
|
|
1796
1813
|
|
|
1797
1814
|
async function editInputStep(index: number) {
|
|
1798
1815
|
const step = steps.value[index];
|
|
1816
|
+
if (step?.type === 'runScript' && !recordingBusy.value) {
|
|
1817
|
+
const parentId = step.flow?.parentConditionId;
|
|
1818
|
+
openLinkedScriptDialog(index - 1, parentId && step.flow?.parentBranch ? { stepId: parentId, branch: step.flow.parentBranch } : undefined);
|
|
1819
|
+
if (!linkedScriptDialogVisible.value) return;
|
|
1820
|
+
linkedScriptEditingId.value = step.id;
|
|
1821
|
+
linkedScriptTargetId.value = step.value || '';
|
|
1822
|
+
linkedParameters.parameters = JSON.parse(JSON.stringify(step.parameters || []));
|
|
1823
|
+
linkedParameters.returns = JSON.parse(JSON.stringify(step.returns || []));
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1799
1826
|
if (step?.type === 'imageCheck' && !recordingBusy.value) {
|
|
1800
1827
|
imageCheckIndex = index;
|
|
1801
1828
|
imageCheckEditing = true;
|
|
@@ -2491,7 +2518,7 @@ watch(
|
|
|
2491
2518
|
</el-form>
|
|
2492
2519
|
<template #footer>
|
|
2493
2520
|
<el-button @click="closeLinkedScriptDialog">取消</el-button>
|
|
2494
|
-
<el-button type="primary" @click="addLinkedScriptStep"
|
|
2521
|
+
<el-button type="primary" @click="addLinkedScriptStep">{{ linkedScriptEditingId ? '保存' : '添加连接' }}</el-button>
|
|
2495
2522
|
</template>
|
|
2496
2523
|
</el-dialog>
|
|
2497
2524
|
|
|
@@ -16,6 +16,7 @@ export const actionDescriptions: Record<InsertAction, string> = {
|
|
|
16
16
|
swipe: '按指定起点、终点和时长滑动屏幕。',
|
|
17
17
|
pinch: '执行双指放大或缩小手势,需要当前页面支持缩放。',
|
|
18
18
|
launchApp: '启动指定应用,可放在流程中途或分支中执行。',
|
|
19
|
+
stopApp: '停止当前所选包名的 APP 进程,不删除应用或清除数据;可在节点配置中修改目标包名。',
|
|
19
20
|
openGallery: '打开设备相册应用,不自动选择照片或视频。',
|
|
20
21
|
endFlow: '立即结束当前回放流程,后续节点不再执行。',
|
|
21
22
|
loop: '重复执行循环体,支持固定次数或条件退出,必须设置最大次数。',
|
|
@@ -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, Connection } from '@element-plus/icons-vue';
|
|
4
|
+
import { CopyDocument, Delete, Edit, Plus, VideoPlay, View, WarningFilled, Connection, RefreshLeft } 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 {
|
|
@@ -187,9 +187,9 @@ watch(() => props.data, () => {
|
|
|
187
187
|
</span>
|
|
188
188
|
</button>
|
|
189
189
|
<span v-if="!props.readonly && !data.deleteMode" class="appium-flow-step-card__actions nodrag nopan" @click.stop>
|
|
190
|
-
<el-tooltip v-if="data.flowKind === 'condition' && data.step.type !== 'loop' && !data.copyMode" content="合并分支:将两侧后续节点汇入公共流程" placement="top" :show-after="200">
|
|
191
|
-
<span class="appium-node-action-tooltip"><el-button text size="small" :icon="Connection"
|
|
192
|
-
title="合并分支" aria-label="合并分支" :disabled="data.mergeDisabled
|
|
190
|
+
<el-tooltip v-if="data.flowKind === 'condition' && data.step.type !== 'loop' && !data.copyMode" :content="data.step.flow?.successTargetId ? '取消合并:还原分支并保留后续编辑' : '合并分支:将两侧后续节点汇入公共流程'" placement="top" :show-after="200">
|
|
191
|
+
<span class="appium-node-action-tooltip"><el-button text size="small" :icon="data.step.flow?.successTargetId ? RefreshLeft : Connection"
|
|
192
|
+
:title="data.step.flow?.successTargetId ? '取消合并' : '合并分支'" :aria-label="data.step.flow?.successTargetId ? '取消合并' : '合并分支'" :disabled="data.mergeDisabled"
|
|
193
193
|
@click="emit('merge', data.index)" />
|
|
194
194
|
</span></el-tooltip>
|
|
195
195
|
<el-tooltip v-if="!data.copyMode && data.canCopy" content="复制节点:复制后可在插入位置粘贴" placement="top" :show-after="200">
|
|
@@ -215,14 +215,14 @@ watch(() => props.data, () => {
|
|
|
215
215
|
@click="emit('execute', data.index)"
|
|
216
216
|
/>
|
|
217
217
|
</span></el-tooltip>
|
|
218
|
-
<el-tooltip v-if="data.canEditInput" :content="data.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容:编辑此节点要输入的文字'" placement="top" :show-after="200">
|
|
218
|
+
<el-tooltip v-if="data.canEditInput" :content="data.step.type === 'runScript' ? '修改连接脚本' : data.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容:编辑此节点要输入的文字'" placement="top" :show-after="200">
|
|
219
219
|
<span class="appium-node-action-tooltip">
|
|
220
220
|
<el-button
|
|
221
221
|
text
|
|
222
222
|
size="small"
|
|
223
223
|
:icon="Edit"
|
|
224
224
|
:disabled="data.disabled"
|
|
225
|
-
:title="data.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容'"
|
|
225
|
+
:title="data.step.type === 'runScript' ? '修改连接脚本' : data.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容'"
|
|
226
226
|
@click="emit('editInput', data.index)"
|
|
227
227
|
/>
|
|
228
228
|
</span></el-tooltip>
|
|
@@ -5,7 +5,6 @@ import { normalizeVisualChangeConfig } from '../visual-change';
|
|
|
5
5
|
import { longPressMode } from '../long-press';
|
|
6
6
|
import { defaultFlowKind, isBooleanCondition } from '../flow-labels';
|
|
7
7
|
import TextClickSettings from './TextClickSettings.vue';
|
|
8
|
-
import { isNativeStateCondition } from '../native-control-state';
|
|
9
8
|
import LongPressSettings from './LongPressSettings.vue';
|
|
10
9
|
import StageLogSettings from './StageLogSettings.vue';
|
|
11
10
|
import LoopSettings from './LoopSettings.vue';
|
|
@@ -47,7 +46,7 @@ function defaultKind(): FlowKind {
|
|
|
47
46
|
function patchStep(patch: Partial<AppiumRecordedStep>) {
|
|
48
47
|
emit('update', {
|
|
49
48
|
index: props.index,
|
|
50
|
-
step: { ...props.step, ...patch },
|
|
49
|
+
step: { ...props.step, ...patch, ...('contextSelector' in patch ? { selectorChain: undefined } : {}) },
|
|
51
50
|
});
|
|
52
51
|
}
|
|
53
52
|
|
|
@@ -79,6 +78,10 @@ function patchSwipe(patch: Partial<SwipeGesture>) {
|
|
|
79
78
|
});
|
|
80
79
|
}
|
|
81
80
|
|
|
81
|
+
function patchPinch(patch: Partial<NonNullable<AppiumRecordedStep['pinch']>>) {
|
|
82
|
+
patchStep({ pinch: { direction: 'out', centerX: 0, centerY: 0, percent: 0.5, ...props.step.pinch, ...patch } });
|
|
83
|
+
}
|
|
84
|
+
|
|
82
85
|
function patchTapCoordinate(axis: 'centerX' | 'centerY', value: number | undefined) {
|
|
83
86
|
if (value === undefined || !Number.isFinite(value)) return;
|
|
84
87
|
const fallback = {
|
|
@@ -125,7 +128,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
125
128
|
patchStep({
|
|
126
129
|
selector: { ...props.step.selector!, ...patch },
|
|
127
130
|
// 修改元素目标后,不能继续优先使用录制时的备用 XPath。
|
|
128
|
-
|
|
131
|
+
selectorChain: undefined,
|
|
129
132
|
});
|
|
130
133
|
}
|
|
131
134
|
</script>
|
|
@@ -153,7 +156,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
153
156
|
<el-form-item label="节点类型">
|
|
154
157
|
<el-select
|
|
155
158
|
:model-value="defaultKind()"
|
|
156
|
-
:disabled="disabled || isBooleanCondition(step) || step.type === 'extractVariable' || step.type === 'log' || step.type === 'openGallery' || step.type === 'endFlow' || step.type === 'loop' || step.type === 'breakLoop'"
|
|
159
|
+
:disabled="disabled || isBooleanCondition(step) || step.type === 'stopApp' || step.type === 'extractVariable' || step.type === 'log' || step.type === 'openGallery' || step.type === 'endFlow' || step.type === 'loop' || step.type === 'breakLoop'"
|
|
157
160
|
@update:model-value="patchFlow({ nodeKind: $event as FlowKind })"
|
|
158
161
|
>
|
|
159
162
|
<el-option label="操作" value="action" />
|
|
@@ -161,7 +164,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
161
164
|
<el-option label="校验" value="assertion" />
|
|
162
165
|
</el-select>
|
|
163
166
|
</el-form-item>
|
|
164
|
-
<el-form-item v-if="!['longPress', 'log', 'openGallery', 'endFlow', 'loop', 'breakLoop'].includes(step.type)" label="超时时间 ms">
|
|
167
|
+
<el-form-item v-if="!['longPress', 'stopApp', 'log', 'openGallery', 'endFlow', 'loop', 'breakLoop'].includes(step.type)" label="超时时间 ms">
|
|
165
168
|
<el-input-number
|
|
166
169
|
:model-value="step.timeoutMs || undefined"
|
|
167
170
|
:disabled="disabled"
|
|
@@ -173,6 +176,22 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
173
176
|
</el-form-item>
|
|
174
177
|
</div>
|
|
175
178
|
<LongPressSettings v-if="step.type === 'longPress'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
179
|
+
<el-form-item v-if="['waitActivity', 'launchApp', 'stopApp', 'clearAppData'].includes(step.type)" :label="step.type === 'waitActivity' ? '目标 Activity' : '目标 APP 包名'">
|
|
180
|
+
<el-input :model-value="step.value || ''" :disabled="disabled" @update:model-value="patchStep({ value: String($event) })" />
|
|
181
|
+
</el-form-item>
|
|
182
|
+
<template v-if="step.type === 'pinch'">
|
|
183
|
+
<el-form-item label="缩放方向">
|
|
184
|
+
<el-select :model-value="step.pinch?.direction || 'out'" :disabled="disabled" @update:model-value="patchPinch({ direction: $event })">
|
|
185
|
+
<el-option label="放大" value="out" /><el-option label="缩小" value="in" />
|
|
186
|
+
</el-select>
|
|
187
|
+
</el-form-item>
|
|
188
|
+
<el-form-item v-for="axis in (['centerX', 'centerY'] as const)" :key="axis" :label="axis === 'centerX' ? '中心 X' : '中心 Y'">
|
|
189
|
+
<el-input-number :model-value="step.pinch?.[axis] ?? 0" :disabled="disabled" :min="0" :max="99999" :precision="0" controls-position="right" @update:model-value="patchPinch({ [axis]: $event ?? 0 })" />
|
|
190
|
+
</el-form-item>
|
|
191
|
+
<el-form-item label="缩放比例">
|
|
192
|
+
<el-input-number :model-value="step.pinch?.percent ?? 0.5" :disabled="disabled" :min="0.01" :max="1" :step="0.05" controls-position="right" @update:model-value="patchPinch({ percent: $event ?? 0.5 })" />
|
|
193
|
+
</el-form-item>
|
|
194
|
+
</template>
|
|
176
195
|
<div v-if="step.type === 'coordinateTap'" class="appium-flow-editor__grid">
|
|
177
196
|
<el-form-item label="坐标 X">
|
|
178
197
|
<el-input-number :model-value="step.fallback?.centerX ?? 0" :disabled="disabled" :min="0" :max="99999" :precision="0" controls-position="right" aria-label="坐标 X" @update:model-value="patchTapCoordinate('centerX', $event)" />
|
|
@@ -221,7 +240,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
221
240
|
<el-form-item label="变化阈值 %">
|
|
222
241
|
<el-input-number
|
|
223
242
|
:model-value="visualChangeConfig.changeRatioThreshold"
|
|
224
|
-
:disabled="
|
|
243
|
+
:disabled="disabled"
|
|
225
244
|
:min="0.01"
|
|
226
245
|
:max="100"
|
|
227
246
|
:step="0.1"
|
|
@@ -4,6 +4,7 @@ import { ElMessage } from 'element-plus';
|
|
|
4
4
|
import type { AppiumRecordedStep } from '../types';
|
|
5
5
|
import { branchSteps, mergeBranches } from '../flow-merge';
|
|
6
6
|
import { flowBranchLabel } from '../flow-labels';
|
|
7
|
+
import { unmergeBranches, type AddedNodeSide } from '../flow-unmerge';
|
|
7
8
|
|
|
8
9
|
const props = defineProps<{ steps: AppiumRecordedStep[]; conditionId: string; disabled?: boolean }>();
|
|
9
10
|
const emit = defineEmits<{ close: []; confirm: [steps: AppiumRecordedStep[]] }>();
|
|
@@ -12,6 +13,13 @@ const duplicateId = ref('');
|
|
|
12
13
|
const confirmed = ref(false);
|
|
13
14
|
const retainedSide = ref<'yes' | 'no'>('yes');
|
|
14
15
|
const condition = computed(() => props.steps.find((step) => step.id === props.conditionId));
|
|
16
|
+
const unmerging = ref(false);
|
|
17
|
+
const addedSide = ref<AddedNodeSide>('both');
|
|
18
|
+
const undoPreview = computed(() => {
|
|
19
|
+
if (!unmerging.value) return undefined;
|
|
20
|
+
try { return unmergeBranches(props.steps, props.conditionId, addedSide.value); }
|
|
21
|
+
catch (error) { return error instanceof Error ? error.message : String(error); }
|
|
22
|
+
});
|
|
15
23
|
const candidates = computed(() => branchSteps(props.steps, props.conditionId, retainedSide.value));
|
|
16
24
|
const duplicates = computed(() => branchSteps(props.steps, props.conditionId, retainedSide.value === 'yes' ? 'no' : 'yes'));
|
|
17
25
|
const preview = computed(() => {
|
|
@@ -20,6 +28,7 @@ const preview = computed(() => {
|
|
|
20
28
|
catch (error) { return error instanceof Error ? error.message : String(error); }
|
|
21
29
|
});
|
|
22
30
|
watch(() => props.conditionId, () => {
|
|
31
|
+
if (props.conditionId) unmerging.value = Boolean(condition.value?.flow?.successTargetId);
|
|
23
32
|
retainedSide.value = branchSteps(props.steps, props.conditionId, 'yes').length ? 'yes' : 'no';
|
|
24
33
|
commonId.value = ''; duplicateId.value = ''; confirmed.value = false;
|
|
25
34
|
});
|
|
@@ -28,6 +37,12 @@ watch(retainedSide, () => { commonId.value = ''; duplicateId.value = ''; confirm
|
|
|
28
37
|
watch(commonId, () => { duplicateId.value = ''; confirmed.value = false; });
|
|
29
38
|
watch(duplicateId, () => { confirmed.value = false; });
|
|
30
39
|
function submit() {
|
|
40
|
+
if (unmerging.value) {
|
|
41
|
+
if (props.disabled || !undoPreview.value || typeof undoPreview.value === 'string') return;
|
|
42
|
+
try { emit('confirm', unmergeBranches(props.steps, props.conditionId, addedSide.value).steps); emit('close'); }
|
|
43
|
+
catch (error) { ElMessage.error(String(error)); }
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
31
46
|
if (props.disabled || !preview.value || typeof preview.value === 'string') return;
|
|
32
47
|
if (preview.value.removedCount && !confirmed.value) return;
|
|
33
48
|
try {
|
|
@@ -38,8 +53,20 @@ function submit() {
|
|
|
38
53
|
</script>
|
|
39
54
|
|
|
40
55
|
<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">
|
|
56
|
+
<el-dialog :model-value="Boolean(conditionId)" :title="unmerging ? '取消合并' : '合并分支'" width="min(520px, 94vw)" append-to-body :close-on-click-modal="false" @close="emit('close')">
|
|
57
|
+
<el-form v-if="unmerging" label-position="top">
|
|
58
|
+
<el-alert v-if="typeof undoPreview === 'string'" :title="undoPreview" type="warning" :closable="false" />
|
|
59
|
+
<el-alert v-else title="还原合并前的分支及被删除的重复节点,保留现有节点配置修改。" type="info" :closable="false" />
|
|
60
|
+
<el-form-item v-if="condition?.mergeUndo && (typeof undoPreview === 'string' || undoPreview?.addedCount)" label="公共流程新增操作保留位置">
|
|
61
|
+
<el-radio-group v-model="addedSide" aria-label="新增操作保留位置">
|
|
62
|
+
<el-radio-button value="both">两侧都保留</el-radio-button>
|
|
63
|
+
<el-radio-button value="yes">仅左侧</el-radio-button>
|
|
64
|
+
<el-radio-button value="no">仅右侧</el-radio-button>
|
|
65
|
+
</el-radio-group>
|
|
66
|
+
<p>原来源分支保留新增操作的相对位置;分配到另一侧的操作放在该分支末尾。</p>
|
|
67
|
+
</el-form-item>
|
|
68
|
+
</el-form>
|
|
69
|
+
<el-form v-else label-position="top">
|
|
43
70
|
<el-form-item label="合并方向">
|
|
44
71
|
<el-radio-group v-model="retainedSide" aria-label="合并方向">
|
|
45
72
|
<el-radio-button value="yes">保留左侧流程</el-radio-button>
|
|
@@ -63,7 +90,8 @@ function submit() {
|
|
|
63
90
|
</el-form>
|
|
64
91
|
<template #footer>
|
|
65
92
|
<el-button @click="emit('close')">取消</el-button>
|
|
66
|
-
<el-button type="primary" :disabled="disabled || !
|
|
93
|
+
<el-button v-if="unmerging" type="primary" :disabled="disabled || !undoPreview || typeof undoPreview === 'string'" @click="submit">取消合并</el-button>
|
|
94
|
+
<el-button v-else type="primary" :disabled="disabled || !preview || typeof preview === 'string' || Boolean(preview.removedCount && !confirmed)" @click="submit">合并分支</el-button>
|
|
67
95
|
</template>
|
|
68
96
|
</el-dialog>
|
|
69
97
|
</template>
|
|
@@ -23,6 +23,7 @@ type InsertAction =
|
|
|
23
23
|
| 'swipe'
|
|
24
24
|
| 'pinch'
|
|
25
25
|
| 'launchApp'
|
|
26
|
+
| 'stopApp'
|
|
26
27
|
| 'openGallery'
|
|
27
28
|
| 'endFlow'
|
|
28
29
|
| 'loop'
|
|
@@ -103,6 +104,7 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
|
|
|
103
104
|
title: '设备操作',
|
|
104
105
|
actions: [
|
|
105
106
|
{ type: 'launchApp', label: '启动 App' },
|
|
107
|
+
{ type: 'stopApp', label: '杀死 APP' },
|
|
106
108
|
{ type: 'keyBack', label: '系统返回' },
|
|
107
109
|
{ type: 'keyHome', label: 'Home 键' },
|
|
108
110
|
{ type: 'openGallery', label: '启动相册' },
|
|
@@ -153,7 +155,7 @@ function typeLabel(step: AppiumRecordedStep) {
|
|
|
153
155
|
clearIfExists: '存在则清空', backIfExists: '存在则返回', clearInput: '清空',
|
|
154
156
|
waitFor: '等待出现', waitDisappear: '等待消失', assertExists: '断言存在',
|
|
155
157
|
assertText: '断言文本', key: '按键', waitActivity: '等待 Activity', delay: '延时',
|
|
156
|
-
coordinateTap: '坐标点击', swipe: '滑动', launchApp: '启动 APP', clearAppData: '清理 APP 缓存', longPress: '长按',
|
|
158
|
+
coordinateTap: '坐标点击', swipe: '滑动', launchApp: '启动 APP', stopApp: '杀死 APP', clearAppData: '清理 APP 缓存', longPress: '长按',
|
|
157
159
|
pinch: '双指缩放', runScript: '连接脚本', screenshot: '截图', visualChange: '检测画面变化',
|
|
158
160
|
noop: '空节点',
|
|
159
161
|
endFlow: '终止流程',
|
|
@@ -340,14 +342,14 @@ function branchSplitPath() {
|
|
|
340
342
|
@click="$emit('copy', [item.index])"
|
|
341
343
|
/>
|
|
342
344
|
</span></el-tooltip>
|
|
343
|
-
<el-tooltip v-if="
|
|
345
|
+
<el-tooltip v-if="['input', 'inputIfExists', 'imageCheck', 'runScript'].includes(item.step.type)" :content="item.step.type === 'runScript' ? '修改连接脚本' : item.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容:编辑此节点要输入的文字'" placement="top" :show-after="200">
|
|
344
346
|
<span style="display: inline-flex">
|
|
345
347
|
<el-button
|
|
346
348
|
text
|
|
347
349
|
size="small"
|
|
348
350
|
:icon="Edit"
|
|
349
351
|
:disabled="disabled"
|
|
350
|
-
:title="item.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容'"
|
|
352
|
+
:title="item.step.type === 'runScript' ? '修改连接脚本' : item.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容'"
|
|
351
353
|
@click="$emit('editInput', item.index)"
|
|
352
354
|
/>
|
|
353
355
|
</span></el-tooltip>
|
|
@@ -80,6 +80,7 @@ const insertActionGroups: FlowActionGroup[] = [
|
|
|
80
80
|
{ type: 'pinch', label: '双指缩放' },
|
|
81
81
|
{ type: 'clearAppData', label: '清理 App 缓存' },
|
|
82
82
|
{ type: 'launchApp', label: '启动 App' },
|
|
83
|
+
{ type: 'stopApp', label: '杀死 APP' },
|
|
83
84
|
{ type: 'openGallery', label: '启动相册' },
|
|
84
85
|
],
|
|
85
86
|
},
|
|
@@ -180,6 +180,8 @@ export function pasteFlowClipboard(
|
|
|
180
180
|
});
|
|
181
181
|
const copies = clipboard.steps.map((source) => {
|
|
182
182
|
const step = clone(source);
|
|
183
|
+
// 合并记录引用原流程 ID,不能随节点复制到另一条流程。
|
|
184
|
+
delete step.mergeUndo;
|
|
183
185
|
step.id = idMap.get(source.id) || createId();
|
|
184
186
|
if (step.breakLoopTargetId) step.breakLoopTargetId = idMap.get(step.breakLoopTargetId) || step.breakLoopTargetId;
|
|
185
187
|
const flow = { ...(step.flow || {}) };
|
|
@@ -24,6 +24,7 @@ export type InsertAction =
|
|
|
24
24
|
| 'swipe'
|
|
25
25
|
| 'pinch'
|
|
26
26
|
| 'launchApp'
|
|
27
|
+
| 'stopApp'
|
|
27
28
|
| 'openGallery'
|
|
28
29
|
| 'endFlow'
|
|
29
30
|
| 'loop'
|
|
@@ -458,7 +459,7 @@ export function buildFlowGraph(
|
|
|
458
459
|
canCopy: item.step.type !== 'launchApp'
|
|
459
460
|
&& item.step.type !== 'clearAppData'
|
|
460
461
|
&& item.step.visualChange?.role !== 'end',
|
|
461
|
-
canEditInput:
|
|
462
|
+
canEditInput: ['input', 'inputIfExists', 'imageCheck', 'runScript'].includes(item.step.type),
|
|
462
463
|
canExecute: item.step.type === 'launchApp' || item.step.type === 'clearAppData' || item.step.type === 'aiRecognition',
|
|
463
464
|
missingAiModel: item.step.type === 'aiRecognition' && !options.aiRecognitionModelConfigured,
|
|
464
465
|
},
|
|
@@ -58,6 +58,7 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
|
|
|
58
58
|
swipe: '滑动',
|
|
59
59
|
screenshot: '截图',
|
|
60
60
|
launchApp: '启动 APP',
|
|
61
|
+
stopApp: '杀死 APP',
|
|
61
62
|
openGallery: '启动相册',
|
|
62
63
|
endFlow: '终止流程',
|
|
63
64
|
loop: '有界循环',
|
|
@@ -98,6 +99,7 @@ export function flowStepMeta(step: AppiumRecordedStep) {
|
|
|
98
99
|
if (step.type === 'key') return `keyCode ${step.keyCode || ''}`;
|
|
99
100
|
if (step.type === 'waitActivity') return step.value || '';
|
|
100
101
|
if (step.type === 'launchApp') return step.value || '';
|
|
102
|
+
if (step.type === 'stopApp') return `${step.value || ''}(仅停止进程)`;
|
|
101
103
|
if (step.type === 'clearAppData') return `${step.value || ''}(清除数据与缓存)`;
|
|
102
104
|
if (step.type === 'runScript') return step.value ? `脚本 ${step.value}` : '';
|
|
103
105
|
if (step.type === 'noop') return '';
|
|
@@ -107,5 +107,17 @@ export function mergeBranches(steps: AppiumRecordedStep[], conditionId: string,
|
|
|
107
107
|
visited.add(step.id);
|
|
108
108
|
}
|
|
109
109
|
rest.forEach(visit);
|
|
110
|
+
// 仅记录结构差异和实际被删除的节点,配置编辑不会被取消合并覆盖。
|
|
111
|
+
const lastCommon = Math.max(...commonRoots.map(step => steps.indexOf(step)));
|
|
112
|
+
mergedCondition.mergeUndo = JSON.parse(JSON.stringify({
|
|
113
|
+
version: 1, conditionId, source: side, commonIds: [...commonIds], originalIds: steps.map(step => step.id),
|
|
114
|
+
boundaryId: steps.slice(lastCommon + 1).find(step => !ownedIds.has(step.id)
|
|
115
|
+
&& step.flow?.parentConditionId === condition.flow?.parentConditionId
|
|
116
|
+
&& step.flow?.parentBranch === condition.flow?.parentBranch)?.id,
|
|
117
|
+
flows: steps.filter(step => !removedIds.has(step.id) && (ownedIds.has(step.id)
|
|
118
|
+
|| keys.some(key => step.flow?.[key] !== byId.get(step.id)?.flow[key])))
|
|
119
|
+
.map(step => ({ id: step.id, before: step.flow, after: byId.get(step.id)?.flow })),
|
|
120
|
+
removed: steps.filter(step => removedIds.has(step.id)),
|
|
121
|
+
}));
|
|
110
122
|
return { steps: rest, removedCount: removedIds.size };
|
|
111
123
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { AppiumRecordedStep } from './types';
|
|
2
|
+
import { branchSteps } from './flow-merge';
|
|
3
|
+
import { defaultFlowKind } from './flow-labels';
|
|
4
|
+
import { createStepId } from './visual-change';
|
|
5
|
+
|
|
6
|
+
const edges = ['yesTargetId', 'noTargetId', 'successTargetId', 'failureTargetId'] as const;
|
|
7
|
+
const structure = [...edges, 'parentConditionId', 'parentBranch'] as const;
|
|
8
|
+
export type AddedNodeSide = 'yes' | 'no' | 'both';
|
|
9
|
+
|
|
10
|
+
export function unmergeBranches(steps: AppiumRecordedStep[], conditionId: string, addedSide?: AddedNodeSide) {
|
|
11
|
+
const condition = steps.find(step => step.id === conditionId);
|
|
12
|
+
const record = condition?.mergeUndo;
|
|
13
|
+
if (!record || record.version !== 1 || record.conditionId !== conditionId) {
|
|
14
|
+
throw new Error('该合并没有可用的还原记录,无法安全取消;仅新执行的合并支持还原');
|
|
15
|
+
}
|
|
16
|
+
const original = new Set(record.originalIds);
|
|
17
|
+
const common = new Set(record.commonIds);
|
|
18
|
+
const affected = new Set(record.flows.map(item => item.id));
|
|
19
|
+
const byId = new Map(steps.map(step => [step.id, step]));
|
|
20
|
+
if (record.flows.some(item => !byId.has(item.id)) || record.removed.some(step => byId.has(step.id))) {
|
|
21
|
+
throw new Error('合并涉及的节点已删除或 ID 冲突,无法安全取消');
|
|
22
|
+
}
|
|
23
|
+
const boundary = record.boundaryId ? steps.findIndex(step => step.id === record.boundaryId) : steps.length;
|
|
24
|
+
if (boundary < 0) throw new Error('公共流程边界已删除,无法安全取消');
|
|
25
|
+
const added = steps.slice(steps.indexOf(condition!) + 1, boundary).filter(step => !original.has(step.id)
|
|
26
|
+
&& step.flow?.parentConditionId === condition!.flow?.parentConditionId
|
|
27
|
+
&& step.flow?.parentBranch === condition!.flow?.parentBranch);
|
|
28
|
+
const shared = new Set([...common, ...added.map(step => step.id)]);
|
|
29
|
+
// 普通新增操作可分配到任一侧;控制节点和跨节点配对不做猜测性复制。
|
|
30
|
+
if (added.some(step => defaultFlowKind(step) === 'condition'
|
|
31
|
+
|| ['visualChange', 'breakLoop', 'endFlow'].includes(step.type) || step.mergeUndo)) {
|
|
32
|
+
throw new Error('公共流程新增了判断、循环、终止或配对节点,请先移出这些节点再取消合并');
|
|
33
|
+
}
|
|
34
|
+
if (added.some(step => edges.some(key => step.flow?.[key]
|
|
35
|
+
&& !shared.has(step.flow[key]!) && step.flow[key] !== record.boundaryId))) {
|
|
36
|
+
throw new Error('公共流程新增操作存在外部连接,请先处理该连接再取消合并');
|
|
37
|
+
}
|
|
38
|
+
for (const item of record.flows) {
|
|
39
|
+
const current = byId.get(item.id)!;
|
|
40
|
+
for (const key of structure) {
|
|
41
|
+
if (current.flow?.[key] === item.after?.[key]) continue;
|
|
42
|
+
const target = edges.includes(key as typeof edges[number]) ? byId.get(current.flow?.[key] || '') : undefined;
|
|
43
|
+
if (!target || original.has(target.id) || defaultFlowKind(target) === 'condition') {
|
|
44
|
+
throw new Error('合并后的连接或分支归属已改变,请先处理结构冲突再取消合并');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
for (const step of steps) {
|
|
49
|
+
if (!affected.has(step.id) && original.has(step.id) && edges.some(key => shared.has(step.flow?.[key] || ''))) {
|
|
50
|
+
throw new Error('其他流程引用了公共节点,无法安全取消合并');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const side = addedSide || record.source;
|
|
54
|
+
const next: AppiumRecordedStep[] = JSON.parse(JSON.stringify(steps));
|
|
55
|
+
const nextById = new Map(next.map(step => [step.id, step]));
|
|
56
|
+
// 只撤回合并写入的结构字段,保留用户修改的名称、参数、备注等。
|
|
57
|
+
for (const item of record.flows) {
|
|
58
|
+
const step = nextById.get(item.id)!;
|
|
59
|
+
const flow = step.flow ||= {};
|
|
60
|
+
for (const key of structure) {
|
|
61
|
+
if (item.before?.[key] === item.after?.[key]) continue;
|
|
62
|
+
const current = flow[key];
|
|
63
|
+
if (current !== item.after?.[key] && !shared.has(current || '')) continue;
|
|
64
|
+
if (item.before?.[key] === undefined) delete flow[key];
|
|
65
|
+
else Object.assign(flow, { [key]: item.before[key] });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// 恢复被合并删除的另一侧节点,按原始邻接顺序插回,而非覆盖现有节点。
|
|
69
|
+
for (const backup of record.removed) {
|
|
70
|
+
const restored: AppiumRecordedStep = JSON.parse(JSON.stringify(backup));
|
|
71
|
+
const later = record.originalIds.slice(record.originalIds.indexOf(backup.id) + 1);
|
|
72
|
+
const anchor = later.map(id => next.findIndex(step => step.id === id)).find(index => index >= 0);
|
|
73
|
+
next.splice(anchor ?? next.length, 0, restored);
|
|
74
|
+
nextById.set(restored.id, restored);
|
|
75
|
+
}
|
|
76
|
+
const assigned = side === 'both' ? record.source : side;
|
|
77
|
+
for (const item of added) {
|
|
78
|
+
const step = nextById.get(item.id)!;
|
|
79
|
+
step.flow = { ...step.flow, parentConditionId: conditionId, parentBranch: assigned };
|
|
80
|
+
for (const key of edges) delete step.flow[key];
|
|
81
|
+
}
|
|
82
|
+
if (assigned !== record.source) {
|
|
83
|
+
for (const item of added) {
|
|
84
|
+
const index = next.findIndex(step => step.id === item.id);
|
|
85
|
+
next.push(...next.splice(index, 1));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (side === 'both') {
|
|
89
|
+
for (const item of added) {
|
|
90
|
+
const copy: AppiumRecordedStep = JSON.parse(JSON.stringify(nextById.get(item.id)));
|
|
91
|
+
copy.id = createStepId();
|
|
92
|
+
copy.flow!.parentBranch = record.source === 'yes' ? 'no' : 'yes';
|
|
93
|
+
next.push(copy);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const other = record.source === 'yes' ? 'no' : 'yes';
|
|
97
|
+
if (added.length && (side === 'both' || side === other)) {
|
|
98
|
+
const siblings = branchSteps(next, conditionId, other);
|
|
99
|
+
const appended = siblings.slice(-added.length);
|
|
100
|
+
const previous = siblings[siblings.length - appended.length - 1];
|
|
101
|
+
if (previous && (defaultFlowKind(previous) === 'condition' || ['endFlow', 'breakLoop'].includes(previous.type))) {
|
|
102
|
+
throw new Error('另一侧末尾是判断、循环或终止操作,新增节点不能安全追加,请仅保留在原来源分支');
|
|
103
|
+
}
|
|
104
|
+
if (previous?.flow?.successTargetId) {
|
|
105
|
+
appended[appended.length - 1]!.flow!.successTargetId = previous.flow.successTargetId;
|
|
106
|
+
previous.flow.successTargetId = appended[0]!.id;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const restoredCondition = nextById.get(conditionId)!;
|
|
110
|
+
delete restoredCondition.mergeUndo;
|
|
111
|
+
const ownerBranch = (step: AppiumRecordedStep): 'yes' | 'no' | undefined => {
|
|
112
|
+
const visited = new Set<string>();
|
|
113
|
+
let current: AppiumRecordedStep | undefined = step;
|
|
114
|
+
while (current?.flow?.parentConditionId && !visited.has(current.id)) {
|
|
115
|
+
visited.add(current.id);
|
|
116
|
+
if (current.flow.parentConditionId === conditionId) return current.flow.parentBranch;
|
|
117
|
+
current = nextById.get(current.flow.parentConditionId);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
// 新增的分支尾节点可能仍指向公共入口,改接自己一侧的后续节点。
|
|
121
|
+
for (const step of next) {
|
|
122
|
+
const branch = ownerBranch(step);
|
|
123
|
+
if (!branch) continue;
|
|
124
|
+
const siblings = next.filter(item => item.flow?.parentConditionId === step.flow?.parentConditionId && item.flow?.parentBranch === step.flow?.parentBranch);
|
|
125
|
+
const sibling = siblings[siblings.indexOf(step) + 1];
|
|
126
|
+
for (const key of edges) {
|
|
127
|
+
const target = nextById.get(step.flow?.[key] || '');
|
|
128
|
+
if (!target || !shared.has(target.id)) continue;
|
|
129
|
+
if (ownerBranch(target) === branch && !added.some(item => item.id === target.id)) continue;
|
|
130
|
+
if (sibling) step.flow![key] = sibling.id;
|
|
131
|
+
else delete step.flow![key];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
for (const branch of ['yes', 'no'] as const) {
|
|
135
|
+
const first = branchSteps(next, conditionId, branch)[0];
|
|
136
|
+
const key = branch === 'yes' ? 'yesTargetId' : 'noTargetId';
|
|
137
|
+
if (first) restoredCondition.flow![key] = first.id;
|
|
138
|
+
else delete restoredCondition.flow![key];
|
|
139
|
+
}
|
|
140
|
+
// 将两个分支的完整子树重新放到判断之后,公共后继保持原位。
|
|
141
|
+
const owned = next.filter(step => ownerBranch(step));
|
|
142
|
+
const result = next.filter(step => !owned.includes(step));
|
|
143
|
+
result.splice(result.indexOf(restoredCondition) + 1, 0, ...owned);
|
|
144
|
+
return { steps: result, addedCount: added.length };
|
|
145
|
+
}
|
|
@@ -68,6 +68,7 @@ export type AppiumNode = {
|
|
|
68
68
|
};
|
|
69
69
|
|
|
70
70
|
export type AppiumRecordedStep = {
|
|
71
|
+
mergeUndo?: BranchMergeUndo;
|
|
71
72
|
id: string;
|
|
72
73
|
type:
|
|
73
74
|
| 'tap'
|
|
@@ -92,6 +93,7 @@ export type AppiumRecordedStep = {
|
|
|
92
93
|
| 'swipe'
|
|
93
94
|
| 'screenshot'
|
|
94
95
|
| 'launchApp'
|
|
96
|
+
| 'stopApp'
|
|
95
97
|
| 'openGallery'
|
|
96
98
|
| 'endFlow'
|
|
97
99
|
| 'loop'
|
|
@@ -161,6 +163,17 @@ export type AppiumRecordedStep = {
|
|
|
161
163
|
};
|
|
162
164
|
};
|
|
163
165
|
|
|
166
|
+
export type BranchMergeUndo = {
|
|
167
|
+
version: 1;
|
|
168
|
+
conditionId: string;
|
|
169
|
+
source: 'yes' | 'no';
|
|
170
|
+
commonIds: string[];
|
|
171
|
+
originalIds: string[];
|
|
172
|
+
boundaryId?: string;
|
|
173
|
+
flows: { id: string; before: AppiumRecordedStep['flow']; after: AppiumRecordedStep['flow'] }[];
|
|
174
|
+
removed: AppiumRecordedStep[];
|
|
175
|
+
};
|
|
176
|
+
|
|
164
177
|
export type AppiumRecordedScript = {
|
|
165
178
|
id: string;
|
|
166
179
|
name: string;
|
|
@@ -11,6 +11,18 @@ export function createStepId() {
|
|
|
11
11
|
return `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
export function syncVisualChangeThreshold(steps: AppiumRecordedStep[], source: AppiumRecordedStep) {
|
|
15
|
+
const config = source.visualChange;
|
|
16
|
+
if (source.type !== 'visualChange' || config?.role !== 'end' || !config.pairId) return steps;
|
|
17
|
+
const { changeRatioThreshold } = normalizeVisualChangeConfig(config);
|
|
18
|
+
// 回放以开始节点配置为准,结束节点修改阈值时同步整组;不同配对互不影响。
|
|
19
|
+
return steps.map(step => (
|
|
20
|
+
step.type === 'visualChange' && step.visualChange && step.visualChange.pairId === config.pairId
|
|
21
|
+
? { ...step, visualChange: { ...step.visualChange, changeRatioThreshold } }
|
|
22
|
+
: step
|
|
23
|
+
));
|
|
24
|
+
}
|
|
25
|
+
|
|
14
26
|
export function boundsToVisualRegion(bounds: AppiumBounds) {
|
|
15
27
|
return {
|
|
16
28
|
x: bounds.left,
|
|
@@ -47,7 +59,7 @@ export function normalizeVisualChangeConfig(
|
|
|
47
59
|
changeRatioThreshold: Math.max(0.01, Number(config?.changeRatioThreshold) || VISUAL_CHANGE_DEFAULTS.changeRatioThreshold),
|
|
48
60
|
pixelmatchThreshold: Math.min(
|
|
49
61
|
1,
|
|
50
|
-
Math.max(0, Number(config?.pixelmatchThreshold)
|
|
62
|
+
Math.max(0, Number.isFinite(config?.pixelmatchThreshold) ? Number(config?.pixelmatchThreshold) : VISUAL_CHANGE_DEFAULTS.pixelmatchThreshold),
|
|
51
63
|
),
|
|
52
64
|
};
|
|
53
65
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { shallowRef, watch, onBeforeUnmount } from 'vue';
|
|
3
|
+
import { QuestionFilled } from '@element-plus/icons-vue';
|
|
4
|
+
import { APP_BASE } from '../../api';
|
|
5
|
+
import type { AndroidDeviceDetails } from '../../types';
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{ deviceId: string }>();
|
|
8
|
+
const details = shallowRef<AndroidDeviceDetails>();
|
|
9
|
+
const loading = shallowRef(false);
|
|
10
|
+
const error = shallowRef('');
|
|
11
|
+
let request: AbortController | undefined;
|
|
12
|
+
const fields: [keyof AndroidDeviceDetails, string][] = [
|
|
13
|
+
['name', '设备名称'], ['brand', '品牌'], ['model', '型号'], ['processor', '处理器'],
|
|
14
|
+
['androidVersion', 'Android 版本'], ['physicalResolution', '物理分辨率'], ['resolution', '分辨率'],
|
|
15
|
+
];
|
|
16
|
+
watch(() => props.deviceId, () => { request?.abort(); request = undefined; details.value = undefined; error.value = ''; loading.value = false; });
|
|
17
|
+
onBeforeUnmount(() => request?.abort());
|
|
18
|
+
async function load() {
|
|
19
|
+
if (!props.deviceId || loading.value) return;
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
request = controller; loading.value = true; error.value = '';
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetch(`${APP_BASE}/api/android-device-info?deviceId=${encodeURIComponent(props.deviceId)}`, { signal: controller.signal });
|
|
24
|
+
const payload = await response.json();
|
|
25
|
+
if (!response.ok) throw new Error(payload.message || '读取设备信息失败');
|
|
26
|
+
if (request === controller) details.value = payload.details;
|
|
27
|
+
} catch (cause) {
|
|
28
|
+
if (request === controller && !controller.signal.aborted) error.value = cause instanceof Error ? cause.message : '读取失败';
|
|
29
|
+
} finally { if (request === controller) loading.value = false; }
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<el-tooltip placement="bottom" effect="dark" :show-after="200" @before-show="load">
|
|
35
|
+
<template #content>
|
|
36
|
+
<div class="device-info">
|
|
37
|
+
<div v-if="!deviceId">未选择设备</div>
|
|
38
|
+
<template v-else>
|
|
39
|
+
<div v-if="loading">正在读取设备信息…</div>
|
|
40
|
+
<div v-if="error" role="status">{{ error }}</div>
|
|
41
|
+
<dl><template v-for="[key, label] in fields" :key="key"><dt>{{ label }}</dt><dd>{{ details?.[key] || '未知' }}</dd></template></dl>
|
|
42
|
+
</template>
|
|
43
|
+
</div>
|
|
44
|
+
</template>
|
|
45
|
+
<el-button class="device-info-help" :icon="QuestionFilled" text aria-label="设备信息" />
|
|
46
|
+
</el-tooltip>
|
|
47
|
+
</template>
|
|
48
|
+
|
|
49
|
+
<style scoped>
|
|
50
|
+
.device-info-help { width: 28px; height: 28px; padding: 0; font-size: 16px; }
|
|
51
|
+
.device-info { max-width: min(340px, calc(100vw - 40px)); font-size: 12px; overflow-wrap: anywhere; }
|
|
52
|
+
.device-info dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 6px 14px; margin: 4px 0; }
|
|
53
|
+
.device-info dt { color: #cbd5e1; }
|
|
54
|
+
.device-info dd { margin: 0; }
|
|
55
|
+
</style>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
|
|
3
3
|
import { Close, Cpu, FullScreen, Refresh } from '@element-plus/icons-vue';
|
|
4
4
|
import type { AndroidDevice, DeviceAction } from '../../types';
|
|
5
|
+
import DeviceInfoTooltip from './DeviceInfoTooltip.vue';
|
|
5
6
|
|
|
6
7
|
type DeviceOverlayBounds = {
|
|
7
8
|
id: string;
|
|
@@ -293,7 +294,7 @@ function handlePointerCancel(event: PointerEvent) {
|
|
|
293
294
|
<el-card shadow="never" class="automation-card device-preview-card" :class="{ 'device-preview-card--compact': compact, 'device-preview-card--enlarged': enlarged }">
|
|
294
295
|
<template #header>
|
|
295
296
|
<div class="panel-header">
|
|
296
|
-
<span>设备预览</span>
|
|
297
|
+
<div class="device-preview-title"><span>设备预览</span><DeviceInfoTooltip :device-id="selectedDeviceId" /></div>
|
|
297
298
|
<div class="device-status">
|
|
298
299
|
<el-tag v-if="compact" :type="available ? 'success' : 'info'">
|
|
299
300
|
{{ available ? '已连接' : '未连接' }}
|
|
@@ -444,6 +445,7 @@ function handlePointerCancel(event: PointerEvent) {
|
|
|
444
445
|
</template>
|
|
445
446
|
|
|
446
447
|
<style scoped>
|
|
448
|
+
.device-preview-title { display: flex; align-items: center; gap: 8px; }
|
|
447
449
|
.device-preview-card--compact { display: flex; flex-direction: column; min-height: 0; }
|
|
448
450
|
.device-preview-card--compact :deep(> .el-card__body) { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; gap: 8px; }
|
|
449
451
|
.device-preview-card--compact .panel-header { flex-wrap: wrap; gap: 8px; font-size: 14px; }
|
package/src/types.ts
CHANGED
|
@@ -147,3 +147,12 @@ export type DeviceAction = {
|
|
|
147
147
|
icon: string;
|
|
148
148
|
keyCode: number;
|
|
149
149
|
};
|
|
150
|
+
export type AndroidDeviceDetails = {
|
|
151
|
+
name: string;
|
|
152
|
+
brand: string;
|
|
153
|
+
model: string;
|
|
154
|
+
processor: string;
|
|
155
|
+
androidVersion: string;
|
|
156
|
+
physicalResolution: string;
|
|
157
|
+
resolution: string;
|
|
158
|
+
};
|