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.
Files changed (30) hide show
  1. package/CHANGELOG.md +11 -2
  2. package/README.md +11 -2
  3. package/package.json +7 -7
  4. package/remote-agent/index.ts +2 -0
  5. package/server/android-device-info.ts +28 -0
  6. package/server/appium-recorder/appium-runner.ts +26 -11
  7. package/server/appium-recorder/open-gallery.ts +10 -1
  8. package/server/appium-recorder/repository.ts +2 -0
  9. package/server/appium-recorder/request-timeout.ts +25 -0
  10. package/server/appium-recorder/script-export.ts +1 -1
  11. package/server/appium-recorder/stop-app.ts +18 -0
  12. package/server/http-api.ts +19 -0
  13. package/server/remote-agents/protocol.ts +1 -0
  14. package/src/appium-recorder/AppiumPage.vue +31 -4
  15. package/src/appium-recorder/action-descriptions.ts +1 -0
  16. package/src/appium-recorder/components/FlowNodeCard.vue +6 -6
  17. package/src/appium-recorder/components/FlowStepEditor.vue +25 -6
  18. package/src/appium-recorder/components/MergeBranchesDialog.vue +31 -3
  19. package/src/appium-recorder/components/NestedConditionBranches.vue +5 -3
  20. package/src/appium-recorder/components/RecordedSteps.vue +1 -0
  21. package/src/appium-recorder/flow-copy.ts +2 -0
  22. package/src/appium-recorder/flow-graph.ts +2 -1
  23. package/src/appium-recorder/flow-labels.ts +2 -0
  24. package/src/appium-recorder/flow-merge.ts +12 -0
  25. package/src/appium-recorder/flow-unmerge.ts +145 -0
  26. package/src/appium-recorder/types.ts +13 -0
  27. package/src/appium-recorder/visual-change.ts +13 -1
  28. package/src/components/device/DeviceInfoTooltip.vue +55 -0
  29. package/src/components/device/DevicePreviewPanel.vue +3 -1
  30. 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.35",
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.5.tgz",
42
- "@midscene/android-playground": "https://registry.npmjs.org/@midscene/android-playground/-/android-playground-1.12.5.tgz",
43
- "@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.5.tgz",
44
- "@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.5.tgz",
45
- "@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.5.tgz",
46
- "@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.12.5.tgz",
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",
@@ -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
- response = await fetch(requestUrl, {
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
- for (const selector of step.selectorChain || []) {
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
+ }
@@ -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;
@@ -8,6 +8,7 @@ export type RemoteAgentDevice = {
8
8
  export type RemoteCommandType =
9
9
  | 'screenshot'
10
10
  | 'displayInfo'
11
+ | 'deviceInfo'
11
12
  | 'tree'
12
13
  | 'tap'
13
14
  | 'swipe'
@@ -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
- insertStep(
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">添加连接</el-button>
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 || Boolean(data.step.flow?.successTargetId)"
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
- ...(props.step.type === 'longPress' || props.step.type === 'extractVariable' || isNativeStateCondition(props.step) ? { selectorChain: undefined } : {}),
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="visualChangeConfigDisabled"
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 || !preview || typeof preview === 'string' || Boolean(preview.removedCount && !confirmed)" @click="submit">合并分支</el-button>
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="item.step.type === 'input' || item.step.type === 'inputIfExists' || item.step.type === 'imageCheck'" :content="item.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容:编辑此节点要输入的文字'" placement="top" :show-after="200">
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: item.step.type === 'input' || item.step.type === 'inputIfExists' || item.step.type === 'imageCheck',
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) || VISUAL_CHANGE_DEFAULTS.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
+ };