android-midscene-automation 0.1.36 → 0.1.38

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 -0
  2. package/README.md +11 -0
  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/ai-recognition.ts +4 -2
  7. package/server/appium-recorder/appium-runner.ts +64 -30
  8. package/server/appium-recorder/condition-timeout.ts +4 -0
  9. package/server/appium-recorder/image-check.ts +6 -2
  10. package/server/appium-recorder/report.ts +1 -1
  11. package/server/appium-recorder/repository.ts +2 -0
  12. package/server/appium-recorder/request-timeout.ts +25 -0
  13. package/server/appium-recorder/stop-app.ts +18 -0
  14. package/server/http-api.ts +19 -0
  15. package/server/remote-agents/protocol.ts +1 -0
  16. package/src/appium-recorder/AppiumPage.vue +23 -57
  17. package/src/appium-recorder/action-descriptions.ts +1 -0
  18. package/src/appium-recorder/components/BranchTimeoutSettings.vue +23 -0
  19. package/src/appium-recorder/components/FlowStepEditor.vue +8 -17
  20. package/src/appium-recorder/components/ImageCheckDialog.vue +11 -5
  21. package/src/appium-recorder/components/NestedConditionBranches.vue +3 -1
  22. package/src/appium-recorder/components/RecordedSteps.vue +1 -0
  23. package/src/appium-recorder/flow-graph.ts +1 -0
  24. package/src/appium-recorder/flow-labels.ts +2 -0
  25. package/src/appium-recorder/image-check.ts +3 -1
  26. package/src/appium-recorder/node-timeout.ts +1 -0
  27. package/src/appium-recorder/types.ts +2 -0
  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,4 +1,15 @@
1
1
  # 更新记录
2
+ ## v0.1.38
3
+ - 移除节点类型切换;新节点及未配置的判断等待超时统一为 3000ms,已有自定义时间和延时、长按等操作时长保持不变。
4
+ - 取消 Activity 不匹配时的脚本编辑锁定,不在脚本绑定页面也可编辑节点;页面差异仅作提示,保留录制操作及回放期间的编辑保护。
5
+ - 所有分支节点新增“超时后处理”,可预设终止流程、进入左侧或右侧分支,默认终止;覆盖元素等待、文字匹配、勾选控件等待、AI 识别及图像观察超时,日志和 HTML 报告标明超时分支。正常 false、循环次数上限与超时区分处理,设备断开及 Appium 无响应仍终止。
6
+
7
+ ## v0.1.37
8
+ - 修复元素查找超时未覆盖 Appium 请求的问题,驱动无响应时及时中止并明确报错,不再误判为找不到元素;唯一原生定位优先使用 ID 或 Accessibility ID。
9
+ - 设备预览新增设备信息,展示设备名称、品牌、型号、处理器、Android 版本、物理分辨率及当前分辨率,支持远程设备;问号位于标题右侧,样式与全局变量帮助图标一致。
10
+ - 新增独立“杀死 APP”操作节点,默认使用当前选中应用包名,支持编辑目标包名;停止当前设备用户下的应用进程,不清除应用数据。
11
+ - Midscene 相关模块由 1.12.5 统一升级至 1.12.6。
12
+
2
13
  ## v0.1.36
3
14
  - 新合并支持取消:原合并按钮切换为取消合并,还原分支和被删除的重复节点,保留配置编辑;公共流程新增的普通操作可选择保留左侧、右侧或两侧,结构冲突时阻止还原。本地保存还原记录,导出及节点复制不携带历史记录。
4
15
  - 启动相册前先停止当前设备用户下的目标相册进程,再重新打开;保留相册自动适配,不清除照片及应用数据,停止失败时明确报错。
package/README.md CHANGED
@@ -1229,6 +1229,17 @@ Linux: ~/.local/share/android-midscene-automation
1229
1229
 
1230
1230
  # 项目更新记录
1231
1231
 
1232
+ ## v0.1.38
1233
+ - 移除节点类型切换;新节点及未配置的判断等待超时统一为 3000ms,已有自定义时间和延时、长按等操作时长保持不变。
1234
+ - 取消 Activity 不匹配时的脚本编辑锁定,不在脚本绑定页面也可编辑节点;页面差异仅作提示,保留录制操作及回放期间的编辑保护。
1235
+ - 所有分支节点新增“超时后处理”,可预设终止流程、进入左侧或右侧分支,默认终止;覆盖元素等待、文字匹配、勾选控件等待、AI 识别及图像观察超时,日志和 HTML 报告标明超时分支。正常 false、循环次数上限与超时区分处理,设备断开及 Appium 无响应仍终止。
1236
+
1237
+ ## v0.1.37
1238
+ - 修复元素查找超时未覆盖 Appium 请求的问题,驱动无响应时及时中止并明确报错,不再误判为找不到元素;唯一原生定位优先使用 ID 或 Accessibility ID。
1239
+ - 设备预览新增设备信息,展示设备名称、品牌、型号、处理器、Android 版本、物理分辨率及当前分辨率,支持远程设备;问号位于标题右侧,样式与全局变量帮助图标一致。
1240
+ - 新增独立“杀死 APP”操作节点,默认使用当前选中应用包名,支持编辑目标包名;停止当前设备用户下的应用进程,不清除应用数据。
1241
+ - Midscene 相关模块由 1.12.5 统一升级至 1.12.6。
1242
+
1232
1243
  ## v0.1.36
1233
1244
  - 新合并支持取消:原合并按钮切换为取消合并,还原分支和被删除的重复节点,保留配置编辑;公共流程新增的普通操作可选择保留左侧、右侧或两侧,结构冲突时阻止还原。本地保存还原记录,导出及节点复制不携带历史记录。
1234
1245
  - 启动相册前先停止当前设备用户下的目标相册进程,再重新打开;保留相册自动适配,不清除照片及应用数据,停止失败时明确报错。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "android-midscene-automation",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
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
+ }
@@ -1,4 +1,6 @@
1
1
  import OpenAI from 'openai';
2
+ import { DEFAULT_NODE_TIMEOUT_MS } from '../../src/appium-recorder/node-timeout';
3
+ import { ConditionTimeoutError } from './condition-timeout';
2
4
  import { loadConfig } from '../config';
3
5
  import { adbScreenshotBase64 } from './screenshot';
4
6
  import {
@@ -35,7 +37,7 @@ export async function recognizeDeviceScreen(input: {
35
37
  if (!['http:', 'https:'].includes(baseURL.protocol)) throw new Error('AI 识别模型 Base URL 必须为 HTTP(S) 地址');
36
38
  const startedAt = Date.now();
37
39
  const timeoutMs = Number.isFinite(input.timeoutMs) && input.timeoutMs! > 0
38
- ? Math.min(300000, Math.max(1000, input.timeoutMs!)) : 60000;
40
+ ? Math.min(300000, Math.max(1000, input.timeoutMs!)) : DEFAULT_NODE_TIMEOUT_MS;
39
41
  const deadline = AbortSignal.timeout(timeoutMs);
40
42
  const signal = input.signal ? AbortSignal.any([input.signal, deadline]) : deadline;
41
43
  signal.throwIfAborted();
@@ -59,7 +61,7 @@ export async function recognizeDeviceScreen(input: {
59
61
  return { ...parseAiRecognitionResult(choice.message.content), durationMs: Date.now() - startedAt, imageBase64 };
60
62
  } catch (error) {
61
63
  if (input.signal?.aborted) throw input.signal.reason;
62
- if (deadline.aborted) throw new Error(`AI 识别超时(${timeoutMs}ms)`);
64
+ if (deadline.aborted) throw new ConditionTimeoutError(`AI 识别超时(${timeoutMs}ms)`);
63
65
  // 不透传模型服务的原始响应,避免 API Key 或敏感请求信息进入报告。
64
66
  if (error instanceof OpenAI.APIError) throw new Error(`AI 识别模型请求失败(HTTP ${error.status || '连接异常'}),请检查模型配置及图片输入支持`);
65
67
  throw error;
@@ -11,6 +11,10 @@ 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 { DEFAULT_NODE_TIMEOUT_MS } from '../../src/appium-recorder/node-timeout';
16
+ import { ConditionTimeoutError, AppiumServiceError } from './condition-timeout';
17
+ import { AppiumRequestTimeoutError, timedAppiumFetch, withElementDeadline } from './request-timeout';
14
18
  import { textClickSelector } from '../../src/appium-recorder/text-click';
15
19
  import { AsyncLocalStorage } from 'node:async_hooks';
16
20
  import { mkdir, writeFile } from 'node:fs/promises';
@@ -83,6 +87,8 @@ function isReplayStopped(error?: unknown) {
83
87
 
84
88
  function throwIfReplayStopped(error?: unknown) {
85
89
  if (isReplayStopped(error)) throw new ReplayStoppedError();
90
+ // 驱动失去响应不是“元素不存在”,不能被备用定位或可选步骤吞掉。
91
+ if (error instanceof AppiumRequestTimeoutError || error instanceof AppiumServiceError) throw error;
86
92
  }
87
93
 
88
94
  function errorDetail(error: unknown) {
@@ -205,16 +211,17 @@ function adbSwipe(deviceId: string, step: Required<AppiumRecordedStepRecord>['sw
205
211
  });
206
212
  }
207
213
 
208
- function adbText(deviceId: string, args: string[]) {
209
- return new Promise<string>((resolve) => {
210
- execFile(getAdbCommand(), ['-s', deviceId, ...args], { maxBuffer: 4 * 1024 * 1024 }, (_error, stdout, stderr) => {
214
+ function adbText(deviceId: string, args: string[], strict = false) {
215
+ return new Promise<string>((resolve, reject) => {
216
+ execFile(getAdbCommand(), ['-s', deviceId, ...args], { maxBuffer: 4 * 1024 * 1024, timeout: 10000, signal: replayContext.getStore()?.signal }, (error, stdout, stderr) => {
217
+ if (strict && error) { reject(new AppiumServiceError(`读取设备状态失败:${stderr || error.message}`)); return; }
211
218
  resolve((stdout || stderr || '').trim());
212
219
  });
213
220
  });
214
221
  }
215
222
 
216
223
  async function getCurrentActivity(deviceId: string) {
217
- const output = await adbText(deviceId, ['shell', 'dumpsys', 'activity', 'activities']);
224
+ const output = await adbText(deviceId, ['shell', 'dumpsys', 'activity', 'activities'], true);
218
225
  const resumedLine = output
219
226
  .split(/\r?\n/)
220
227
  .find((line) => /(?:topResumedActivity|ResumedActivity|mResumedActivity)/.test(line));
@@ -231,7 +238,7 @@ async function waitForActivity(deviceId: string, step: AppiumRecordedStepRecord)
231
238
  const expectedActivity = step.value || '';
232
239
  if (!expectedActivity) throw new Error(`${step.label} 缺少目标 Activity`);
233
240
  const startedAt = Date.now();
234
- const timeoutMs = step.timeoutMs || 10000;
241
+ const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
235
242
  let currentActivity = '';
236
243
  while (Date.now() - startedAt <= timeoutMs) {
237
244
  throwIfReplayStopped();
@@ -239,7 +246,7 @@ async function waitForActivity(deviceId: string, step: AppiumRecordedStepRecord)
239
246
  if (currentActivity === expectedActivity) return;
240
247
  await wait(500);
241
248
  }
242
- throw new Error(`${step.label} 等待超时,当前 Activity:${currentActivity || '-'}`);
249
+ throw new ConditionTimeoutError(`${step.label} 等待超时,当前 Activity:${currentActivity || '-'}`);
243
250
  }
244
251
 
245
252
  async function appendSettingsDiagnostics(lines: string[], deviceId: string) {
@@ -276,30 +283,31 @@ async function appiumRequest<T>(
276
283
  if (!options?.ignoreAbort) throwIfReplayStopped();
277
284
  context?.appiumLog(`[Appium] 请求:${method} ${path}`);
278
285
  let response: Response;
286
+ let responseText: string;
279
287
  try {
280
- response = await fetch(requestUrl, {
288
+ const result = await timedAppiumFetch(requestUrl, {
281
289
  ...init,
282
- signal: options?.ignoreAbort
283
- ? (options.timeoutMs ? AbortSignal.timeout(options.timeoutMs) : undefined)
284
- : context?.signal || init?.signal,
290
+ signal: options?.ignoreAbort ? undefined : context?.signal || init?.signal,
285
291
  headers: {
286
292
  'Content-Type': 'application/json',
287
293
  ...(init?.headers || {}),
288
294
  },
289
- });
295
+ }, options?.timeoutMs ?? (path === '/session' && method === 'POST' ? 120000 : 30000));
296
+ response = result.response;
297
+ responseText = result.text;
290
298
  } catch (error) {
291
299
  if (isReplayStopped(error) && !options?.ignoreAbort) {
292
300
  context?.appiumLog(`[Appium] 取消:${method} ${path}`);
293
301
  throw new ReplayStoppedError();
294
302
  }
295
303
  context?.appiumLog(`[Appium] 异常:${method} ${path}(${errorDetail(error)})`);
304
+ if (error instanceof AppiumRequestTimeoutError) throw error;
296
305
  if (isTimeoutError(error)) {
297
- throw new Error(`Appium 请求超时 ${requestUrl},${errorDetail(error)}`);
306
+ throw new AppiumServiceError(`Appium 请求超时 ${requestUrl},${errorDetail(error)}`);
298
307
  }
299
- throw new Error(`无法连接 Appium 服务 ${requestUrl},${errorDetail(error)}`);
308
+ throw new AppiumServiceError(`无法连接 Appium 服务 ${requestUrl},${errorDetail(error)}`);
300
309
  }
301
310
 
302
- const responseText = await response.text();
303
311
  context?.appiumLog(`[Appium] 响应:HTTP ${response.status} ${method} ${path}(${Date.now() - startedAt}ms)`);
304
312
  const payload = (() => {
305
313
  try {
@@ -320,7 +328,8 @@ async function appiumRequest<T>(
320
328
  payload.value?.stacktrace || payload.stacktrace ? `堆栈信息:\n${payload.value?.stacktrace || payload.stacktrace}` : '',
321
329
  responseText ? `原始响应:\n${responseText}` : '',
322
330
  ].filter(Boolean).join('\n');
323
- throw new Error(`Appium ${method} ${path} 失败(HTTP ${response.status}):\n${detail || '未返回错误详情'}`);
331
+ const ErrorType = ['no such element', 'stale element reference'].includes(payload.value?.error || payload.error || '') ? Error : AppiumServiceError;
332
+ throw new ErrorType(`Appium ${method} ${path} 失败(HTTP ${response.status}):\n${detail || '未返回错误详情'}`);
324
333
  }
325
334
  return payload;
326
335
  }
@@ -439,7 +448,11 @@ async function findElementBySelector(
439
448
  return elementId;
440
449
  }
441
450
 
442
- async function findElement(sessionId: string, step: AppiumRecordedStepRecord) {
451
+ async function findElement(sessionId: string, step: AppiumRecordedStepRecord, timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS) {
452
+ return withElementDeadline(timeoutMs, () => findElementWithinDeadline(sessionId, step));
453
+ }
454
+
455
+ async function findElementWithinDeadline(sessionId: string, step: AppiumRecordedStepRecord) {
443
456
  if (!step.selector) throw new Error(`${step.label} 缺少 selector`);
444
457
  const attempts: string[] = [];
445
458
 
@@ -454,7 +467,9 @@ async function findElement(sessionId: string, step: AppiumRecordedStepRecord) {
454
467
  }
455
468
  }
456
469
 
457
- for (const selector of step.selectorChain || []) {
470
+ // 唯一原生定位优先;非唯一元素仍保留父级与备用 XPath 的精确匹配。
471
+ const preferPrimary = step.selector.unique === true && ['id', 'accessibilityId'].includes(step.selector.strategy);
472
+ for (const selector of [...(preferPrimary ? [step.selector] : []), ...(step.selectorChain || [])]) {
458
473
  try {
459
474
  attempts.push(`${selector.strategy} ${selector.value || ''}`);
460
475
  return await findElementBySelector(sessionId, selector);
@@ -502,26 +517,26 @@ async function tapFallback(deviceId: string, step: AppiumRecordedStepRecord) {
502
517
 
503
518
  async function waitForElement(sessionId: string, step: AppiumRecordedStepRecord) {
504
519
  const startedAt = Date.now();
505
- const timeoutMs = step.timeoutMs || 10000;
520
+ const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
506
521
  let lastError: unknown;
507
522
  while (Date.now() - startedAt <= timeoutMs) {
508
523
  try {
509
- return await findElement(sessionId, step);
524
+ return await findElement(sessionId, step, Math.max(1, timeoutMs - (Date.now() - startedAt)));
510
525
  } catch (error) {
511
526
  throwIfReplayStopped(error);
512
527
  lastError = error;
513
528
  await wait(500);
514
529
  }
515
530
  }
516
- throw lastError instanceof Error ? lastError : new Error(`${step.label} 等待超时`);
531
+ throw new ConditionTimeoutError(`${step.label} 等待元素超时(${timeoutMs}ms):${errorDetail(lastError)}`);
517
532
  }
518
533
 
519
534
  async function findOptionalElement(sessionId: string, step: AppiumRecordedStepRecord) {
520
535
  const startedAt = Date.now();
521
- const timeoutMs = step.timeoutMs ?? 2000;
536
+ const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
522
537
  while (Date.now() - startedAt <= timeoutMs) {
523
538
  try {
524
- return await findElement(sessionId, step);
539
+ return await findElement(sessionId, step, Math.max(1, timeoutMs - (Date.now() - startedAt)));
525
540
  } catch (error) {
526
541
  throwIfReplayStopped(error);
527
542
  await wait(250);
@@ -532,17 +547,17 @@ async function findOptionalElement(sessionId: string, step: AppiumRecordedStepRe
532
547
 
533
548
  async function waitForElementGone(sessionId: string, step: AppiumRecordedStepRecord) {
534
549
  const startedAt = Date.now();
535
- const timeoutMs = step.timeoutMs || 10000;
550
+ const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
536
551
  while (Date.now() - startedAt <= timeoutMs) {
537
552
  try {
538
- await findElement(sessionId, step);
553
+ await findElement(sessionId, step, Math.max(1, timeoutMs - (Date.now() - startedAt)));
539
554
  } catch (error) {
540
555
  throwIfReplayStopped(error);
541
556
  return;
542
557
  }
543
558
  await wait(500);
544
559
  }
545
- throw new Error(`${step.label} 等待消失超时`);
560
+ throw new ConditionTimeoutError(`${step.label} 等待消失超时`);
546
561
  }
547
562
 
548
563
  async function saveScreenshot(deviceId: string) {
@@ -768,6 +783,10 @@ async function runStep(
768
783
  return `ADB 已启动 ${packageName}`;
769
784
  }
770
785
 
786
+ if (step.type === 'stopApp') {
787
+ return stopAppOnDevice(deviceId, step.value || '', replayContext.getStore()?.signal);
788
+ }
789
+
771
790
  if (step.type === 'clearAppData') {
772
791
  const packageName = step.value || '';
773
792
  await clearAppDataOnDevice(deviceId, packageName);
@@ -983,7 +1002,7 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
983
1002
  }
984
1003
  if (step.type === 'textClick') {
985
1004
  const selector = textClickSelector(step);
986
- const deadline = Date.now() + Math.max(0, step.timeoutMs ?? 10000);
1005
+ const deadline = Date.now() + Math.max(0, step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS);
987
1006
  // 查询空结果才是未匹配;通信错误、重复匹配和点击失败走现有异常处理。
988
1007
  do {
989
1008
  const payload = await appiumRequest<AppiumValueResponse<Record<string, string>[]>>(`/session/${sessionId}/elements`, {
@@ -998,7 +1017,10 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
998
1017
  await appiumRequest(`/session/${sessionId}/element/${id}/click`, { method: 'POST', body: '{}' });
999
1018
  return true;
1000
1019
  }
1001
- if (Date.now() >= deadline) return false;
1020
+ if (Date.now() >= deadline) {
1021
+ if (step.timeoutMs === 0) return false;
1022
+ throw new ConditionTimeoutError(`文字点击等待匹配超时(${step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS}ms)`);
1023
+ }
1002
1024
  await wait(Math.max(0, Math.min(500, deadline - Date.now())));
1003
1025
  } while (true);
1004
1026
  }
@@ -1014,7 +1036,7 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
1014
1036
  return true;
1015
1037
  }
1016
1038
  if (step.type === 'assertText' || (step.type === 'assertExists' && step.value)) {
1017
- const elementId = await findElement(sessionId, step);
1039
+ const elementId = await waitForElement(sessionId, step);
1018
1040
  const payload = await appiumRequest<AppiumValueResponse<string>>(`/session/${sessionId}/element/${elementId}/text`);
1019
1041
  const actualText = payload.value || '';
1020
1042
  const expectedText = step.value || '';
@@ -1026,7 +1048,7 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
1026
1048
  return true;
1027
1049
  } catch (error) {
1028
1050
  throwIfReplayStopped(error);
1029
- return false;
1051
+ throw error;
1030
1052
  }
1031
1053
  }
1032
1054
 
@@ -1102,7 +1124,7 @@ async function replayLinkedScript(
1102
1124
  type: 'waitActivity',
1103
1125
  label: step.label,
1104
1126
  value: linkedScript.appActivity,
1105
- timeoutMs: 10000,
1127
+ timeoutMs: DEFAULT_NODE_TIMEOUT_MS,
1106
1128
  });
1107
1129
  } catch (error) {
1108
1130
  throwIfReplayStopped(error);
@@ -1255,6 +1277,7 @@ async function replayFlowSteps(
1255
1277
  replayContext.getStore()?.imageChecks.push({ ...imageResult, nodeId: step.id, nodeNumber: index + 1, nodeLabel: frameStep.label, scriptName: options.scriptName || '', settings });
1256
1278
  lines.push(`[节点 ${index + 1}] 图像判断:${imageResult.result === null ? '无法判定' : imageResult.result},${imageResult.message};${settings};采样 ${imageResult.sampleCount} 帧,耗时 ${imageResult.durationMs}ms;指标 ${JSON.stringify(imageResult.metrics)}`);
1257
1279
  if (imageResult.result === null) throw new Error(imageResult.message);
1280
+ if (imageResult.timedOut) throw new ConditionTimeoutError(imageResult.message);
1258
1281
  }
1259
1282
  const matched = imageResult ? imageResult.result! : recognition ? recognition.result : await evaluateCondition(sessionId, deviceId, step);
1260
1283
  if (recognition) lines.push(`[节点 ${index + 1}] AI 识别:${recognition.result},耗时 ${recognition.durationMs}ms${recognition.reason ? `,依据:${recognition.reason}` : ''}`);
@@ -1279,6 +1302,17 @@ async function replayFlowSteps(
1279
1302
  await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'stopped', '已终止');
1280
1303
  throw new ReplayStoppedError();
1281
1304
  }
1305
+ if (error instanceof ConditionTimeoutError && (step.timeoutBranch === 'yes' || step.timeoutBranch === 'no')) {
1306
+ const branch = step.timeoutBranch;
1307
+ const status = `判断超时,按配置进入${branch === 'yes' ? '左' : '右'}侧分支(${flowBranchLabel(step, branch)})`;
1308
+ lines.push(`[节点 ${index + 1}] ${status};${error.message}`);
1309
+ await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'after', status);
1310
+ // 循环体必须经过 enter,才能正确记轮次及受最大次数保护。
1311
+ index = step.type === 'loop'
1312
+ ? branch === 'yes' ? traversal.enter(step).next : traversal.exit(step)
1313
+ : traversal.branch(step, branch === 'yes');
1314
+ continue;
1315
+ }
1282
1316
  lines.push(`[节点 ${index + 1}] 失败:${errorDetail(error)}`);
1283
1317
  await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'error', '失败');
1284
1318
  await appendStepDiagnostics(lines, deviceId, step);
@@ -0,0 +1,4 @@
1
+ // 仅业务等待到期使用此错误;设备、通信和配置异常不能按超时分支放行。
2
+ export class ConditionTimeoutError extends Error {}
3
+
4
+ export class AppiumServiceError extends Error {}
@@ -1,4 +1,6 @@
1
1
  import { PNG } from 'pngjs';
2
+ import { AppiumRequestTimeoutError } from './request-timeout';
3
+ import { AppiumServiceError } from './condition-timeout';
2
4
  import { validateImageCheck, type ImageCheckConfig, type ImageCheckResult } from '../../src/appium-recorder/image-check';
3
5
  import type { AppiumVisualChangeRegion } from '../../src/appium-recorder/types';
4
6
 
@@ -138,20 +140,22 @@ export async function checkImage(input: {
138
140
  const changed = maximum >= config.ratio;
139
141
  if (changed || (result.sampleCount >= 2 && now() - observedAt >= config.durationMs)) {
140
142
  result.result = config.expectation === 'present' ? changed : !changed;
143
+ result.timedOut = !changed && config.expectation === 'present' && config.durationMs > 0;
141
144
  break;
142
145
  }
143
146
  } else {
144
147
  consecutive = measurement.matched ? consecutive + 1 : 0;
145
148
  result.metrics['连续满足帧数'] = consecutive;
146
149
  if (consecutive >= config.consecutive) { result.result = true; break; }
147
- if (now() - observedAt >= config.durationMs) { result.result = false; break; }
150
+ if (now() - observedAt >= config.durationMs) { result.result = false; result.timedOut = config.durationMs > 0; break; }
148
151
  }
149
152
  await input.wait(Math.min(config.intervalMs, Math.max(1, config.durationMs - (now() - observedAt))));
150
153
  }
151
154
  if (last) keep('判定帧', last);
152
- result.message = result.result ? '条件成立' : '条件不成立';
155
+ result.message = result.timedOut ? '观察超时,未在规定时间内满足条件' : result.result ? '条件成立' : '条件不成立';
153
156
  } catch (error) {
154
157
  if (input.signal?.aborted) throw error;
158
+ if (error instanceof AppiumRequestTimeoutError || error instanceof AppiumServiceError) throw error;
155
159
  result.message = `无法判定:${error instanceof Error ? error.message : '图像检测异常'}`;
156
160
  result.result = null;
157
161
  }
@@ -605,7 +605,7 @@ function createReplayHtml(input: {
605
605
  article.append(title);
606
606
  const detail = document.createElement('dl');
607
607
  const region = check.region || {};
608
- const entries = [['结果', check.result === null ? '无法判定' : String(check.result)], ['配置', check.settings],
608
+ const entries = [['结果', check.timedOut ? '观察超时' : check.result === null ? '无法判定' : String(check.result)], ['配置', check.settings],
609
609
  ['区域', [region.x, region.y, region.width, region.height].join(', ')],
610
610
  ['采样', check.sampleCount + ' 帧'], ['耗时', check.durationMs + 'ms'], ['说明', check.message], ...Object.entries(check.metrics)];
611
611
  entries.forEach(([key, value]) => {
@@ -38,6 +38,7 @@ export type AppiumRecordedStepRecord = {
38
38
  | 'swipe'
39
39
  | 'screenshot'
40
40
  | 'launchApp'
41
+ | 'stopApp'
41
42
  | 'openGallery'
42
43
  | 'endFlow'
43
44
  | 'loop'
@@ -98,6 +99,7 @@ export type AppiumRecordedStepRecord = {
98
99
  returns?: ScriptReturn[];
99
100
  keyCode?: number;
100
101
  timeoutMs?: number;
102
+ timeoutBranch?: 'stop' | 'yes' | 'no';
101
103
  longPressMode?: 'element' | 'coordinates';
102
104
  breakLoopTargetId?: string;
103
105
  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
+ }
@@ -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'
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, h, onMounted, onUnmounted, provide, reactive, shallowRef, watch } from 'vue';
3
3
  import { flowBackgroundKey, normalizeFlowBackground } from './flow-appearance';
4
+ import { DEFAULT_NODE_TIMEOUT_MS } from './node-timeout';
4
5
  import { ElForm, ElFormItem, ElInputNumber, ElMessage, ElMessageBox, ElOption, ElSelect } from 'element-plus';
5
6
  import { Check, CircleClose, CopyDocument, Delete, Document, Download, Edit, Plus, Refresh, Upload, VideoPlay, View, Clock } from '@element-plus/icons-vue';
6
7
  import RunHistoryDialog from './RunHistoryDialog.vue';
@@ -95,46 +96,6 @@ const workspaceRef = shallowRef<InstanceType<typeof RecorderWorkspace>>();
95
96
  const historyScript = shallowRef<{ id: string; name: string } | null>(null);
96
97
  const AUTO_TREE_REFRESH_INTERVAL_MS = 1800;
97
98
 
98
- const insertableRecorderActions: RecorderAction[] = [
99
- 'extractVariable',
100
- 'delay',
101
- 'tap',
102
- 'input',
103
- 'waitFor',
104
- 'tapIfExists',
105
- 'inputIfExists',
106
- 'clearIfExists',
107
- 'backIfExists',
108
- 'popupCondition',
109
- 'checkboxState',
110
- 'checkedState',
111
- 'radioButtonState',
112
- 'aiRecognition',
113
- 'imageCheck',
114
- 'textClick',
115
- 'runScript',
116
- 'keyBack',
117
- 'keyHome',
118
- 'keyRecent',
119
- 'keyPower',
120
- 'waitActivity',
121
- 'swipe',
122
- 'clearInput',
123
- 'coordinateTap',
124
- 'launchApp',
125
- 'openGallery',
126
- 'endFlow',
127
- 'loop',
128
- 'breakLoop',
129
- 'clearAppData',
130
- 'waitDisappear',
131
- 'longPress',
132
- 'pinch',
133
- 'noop',
134
- 'log',
135
- 'visualChangeStart',
136
- 'visualChangeEnd',
137
- ];
138
99
  const readonlyFlowActionGroups: FlowActionGroup[] = [];
139
100
  const readonlyFlowSelectedIndexes: number[] = [];
140
101
 
@@ -293,7 +254,7 @@ const recordingBusy = computed(() => (
293
254
  || resolvingNavigation.value
294
255
  || Boolean(pendingNavigation.value)
295
256
  ));
296
- const recordingLocked = computed(() => recordingBusy.value || scriptActivityMismatch.value);
257
+ const recordingLocked = computed(() => recordingBusy.value || replaying.value);
297
258
  const newScriptDisabled = computed(() => saving.value || replaying.value || recordingBusy.value || launchingApp.value || importingScript.value);
298
259
  const overlayBounds = computed(() => flattenNodes(tree.value).flatMap((node) => (
299
260
  node.bounds ? [{ id: node.id, ...node.bounds }] : []
@@ -509,7 +470,7 @@ function createStep(type: NodeStepType, node: AppiumNode): AppiumRecordedStep {
509
470
  ? [{ strategy: 'xpath', value: node.xpath, unique: true, matchCount: 1 }]
510
471
  : undefined,
511
472
  fallback: node.bounds ? { strategy: 'bounds', centerX: node.bounds.centerX, centerY: node.bounds.centerY } : undefined,
512
- timeoutMs: type === 'waitFor' ? 10000 : undefined,
473
+ timeoutMs: type === 'waitFor' ? DEFAULT_NODE_TIMEOUT_MS : undefined,
513
474
  pageBefore: currentPageSnapshot(node),
514
475
  snapshot: {
515
476
  text: node.text,
@@ -613,7 +574,7 @@ function createWaitActivityStep(activity: string): AppiumRecordedStep {
613
574
  type: 'waitActivity',
614
575
  label: `等待 Activity ${activity}`,
615
576
  value: activity,
616
- timeoutMs: 10000,
577
+ timeoutMs: DEFAULT_NODE_TIMEOUT_MS,
617
578
  };
618
579
  }
619
580
 
@@ -975,7 +936,7 @@ async function executeFlowStep(index: number) {
975
936
  } else if (scriptActivityMismatch.value) {
976
937
  ElMessage.warning(`App 已启动,当前页面仍为 ${currentActivity.value || '-'}`);
977
938
  } else {
978
- ElMessage.success('已进入脚本绑定页面,编辑锁定已解除');
939
+ ElMessage.success('已进入脚本绑定页面');
979
940
  }
980
941
  } catch (error) {
981
942
  ElMessage.error(error instanceof Error ? error.message : 'App 操作失败');
@@ -1442,13 +1403,13 @@ async function addAction(
1442
1403
  if (!input) return;
1443
1404
  return insertStep({
1444
1405
  id: createStepId(), type: 'aiRecognition', label: 'AI 识别',
1445
- value: validateAiRecognitionPrompt(input.value), timeoutMs: 60000,
1406
+ value: validateAiRecognitionPrompt(input.value), timeoutMs: DEFAULT_NODE_TIMEOUT_MS,
1446
1407
  flow: { nodeKind: 'condition' },
1447
1408
  }, index, branchTarget);
1448
1409
  }
1449
1410
  if (action === 'textClick') {
1450
1411
  const step = reactive<AppiumRecordedStep>({
1451
- id: createStepId(), type: 'textClick', label: '文字点击', value: '', timeoutMs: 10000,
1412
+ id: createStepId(), type: 'textClick', label: '文字点击', value: '', timeoutMs: DEFAULT_NODE_TIMEOUT_MS,
1452
1413
  flow: { nodeKind: 'condition', textMatch: 'exact' },
1453
1414
  });
1454
1415
  const result = await ElMessageBox({
@@ -1580,6 +1541,10 @@ async function addAction(
1580
1541
  value: form.appPackage,
1581
1542
  }, index, branchTarget);
1582
1543
  }
1544
+ if (action === 'stopApp') {
1545
+ return insertStep({ id: createStepId(), type: 'stopApp', label: `杀死 APP ${form.appPackage}`,
1546
+ value: form.appPackage, flow: { nodeKind: 'action' } }, index, branchTarget);
1547
+ }
1583
1548
  if (action === 'clearAppData') {
1584
1549
  return insertStep({
1585
1550
  id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
@@ -1612,7 +1577,7 @@ async function addAction(
1612
1577
  ElMessage.warning(`请选择原生 ${controlName} 元素,当前选中:${node.className || '未知类型'}`);
1613
1578
  return;
1614
1579
  }
1615
- const step = createNodeActionStep(action, action === 'checkedState' ? '判断勾选' : `判断 ${controlName} 状态`, node, { timeoutMs: 2000 });
1580
+ const step = createNodeActionStep(action, action === 'checkedState' ? '判断勾选' : `判断 ${controlName} 状态`, node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS });
1616
1581
  if ((!step.selector?.value || step.selector.strategy === 'bounds') && node.xpath) {
1617
1582
  step.selector = { strategy: 'xpath', value: node.xpath };
1618
1583
  step.contextSelector = undefined;
@@ -1628,13 +1593,13 @@ async function addAction(
1628
1593
  return insertStep(step, index, branchTarget);
1629
1594
  }
1630
1595
  if (action === 'popupCondition') {
1631
- const step = createNodeActionStep('assertExists', '判断存在', node, { timeoutMs: 2000 });
1596
+ const step = createNodeActionStep('assertExists', '判断存在', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS });
1632
1597
  const inserted = insertStep({ ...step, flow: { nodeKind: 'condition' } }, index, branchTarget);
1633
1598
  ElMessage.success('已添加判断节点,请在节点面板配置是/否分支');
1634
1599
  return inserted;
1635
1600
  }
1636
1601
  if (action === 'tapIfExists') {
1637
- return insertStep(createNodeActionStep('tapIfExists', '存在则点击', node, { timeoutMs: 2000 }), index, branchTarget);
1602
+ return insertStep(createNodeActionStep('tapIfExists', '存在则点击', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS }), index, branchTarget);
1638
1603
  }
1639
1604
  if (action === 'inputIfExists') {
1640
1605
  const input = await ElMessageBox.prompt('', '存在则输入', {
@@ -1645,16 +1610,16 @@ async function addAction(
1645
1610
  }).catch(() => null);
1646
1611
  if (!input) return;
1647
1612
  return insertStep(
1648
- createNodeActionStep('inputIfExists', '存在则输入', node, { timeoutMs: 2000, value: input.value }),
1613
+ createNodeActionStep('inputIfExists', '存在则输入', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS, value: input.value }),
1649
1614
  index,
1650
1615
  branchTarget,
1651
1616
  );
1652
1617
  }
1653
1618
  if (action === 'clearIfExists') {
1654
- return insertStep(createNodeActionStep('clearIfExists', '存在则清空', node, { timeoutMs: 2000 }), index, branchTarget);
1619
+ return insertStep(createNodeActionStep('clearIfExists', '存在则清空', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS }), index, branchTarget);
1655
1620
  }
1656
1621
  if (action === 'backIfExists') {
1657
- return insertStep(createNodeActionStep('backIfExists', '存在则返回', node, { timeoutMs: 2000 }), index, branchTarget);
1622
+ return insertStep(createNodeActionStep('backIfExists', '存在则返回', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS }), index, branchTarget);
1658
1623
  }
1659
1624
  if (action === 'clearInput') {
1660
1625
  return insertStep(createNodeActionStep('clearInput', '清空输入', node), index, branchTarget);
@@ -1701,7 +1666,7 @@ async function addAction(
1701
1666
  return insertStep({ ...step }, index, branchTarget);
1702
1667
  }
1703
1668
  if (action === 'waitDisappear') {
1704
- return insertStep(createNodeActionStep('waitDisappear', '等待元素消失', node, { timeoutMs: 10000 }), index, branchTarget);
1669
+ return insertStep(createNodeActionStep('waitDisappear', '等待元素消失', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS }), index, branchTarget);
1705
1670
  }
1706
1671
  }
1707
1672
 
@@ -2282,7 +2247,7 @@ watch(
2282
2247
  v-if="scriptActivityMismatch"
2283
2248
  class="appium-activity-summary"
2284
2249
  >
2285
- <summary>当前页面与脚本不一致,部分录制操作不可用</summary>
2250
+ <summary>当前页面与脚本绑定的 Activity 不一致</summary>
2286
2251
  <div class="appium-activity-lock-alert__details">
2287
2252
  <div>
2288
2253
  <strong>脚本绑定</strong>
@@ -2292,7 +2257,7 @@ watch(
2292
2257
  <strong>当前 Activity</strong>
2293
2258
  <code>{{ currentActivity || '正在获取' }}</code>
2294
2259
  </div>
2295
- <p>请在录制流程中添加或执行“启动 APP”节点;Activity 匹配后会自动解除编辑锁定。</p>
2260
+ <p>不影响脚本编辑;如需录制当前页面的组件操作,请确认设备页面及所选组件。</p>
2296
2261
  </div>
2297
2262
  </details>
2298
2263
  </section>
@@ -2304,9 +2269,8 @@ watch(
2304
2269
  :steps="steps"
2305
2270
  :clipboard-count="flowClipboardCount"
2306
2271
  :disabled="recordingLocked"
2307
- :remove-disabled="recordingBusy"
2272
+ :remove-disabled="recordingLocked"
2308
2273
  :merge-disabled="recordingBusy || replaying || saving"
2309
- :allowed-locked-actions="scriptActivityMismatch && !recordingBusy ? insertableRecorderActions : []"
2310
2274
  :launching-step-id="executingAppStepId"
2311
2275
  @remove="removeStep"
2312
2276
  @copy="copyFlowNodes"
@@ -2429,6 +2393,8 @@ watch(
2429
2393
 
2430
2394
  <ImageCheckDialog
2431
2395
  v-if="imageCheckDraft?.imageCheck"
2396
+ :timeout-branch="imageCheckDraft.timeoutBranch"
2397
+ @timeout-branch="imageCheckDraft = { ...imageCheckDraft!, timeoutBranch: $event }"
2432
2398
  :config="imageCheckDraft.imageCheck" :device-id="selectedDeviceId"
2433
2399
  :has-element="Boolean(selectedNode?.bounds)" :editing="imageCheckEditing" :picking="imageCheckPicking"
2434
2400
  @update="updateImageCheck" @close="imageCheckDraft = null; imageCheckPicking = false"
@@ -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: '重复执行循环体,支持固定次数或条件退出,必须设置最大次数。',
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ import type { AppiumRecordedStep } from '../types';
3
+ import { flowBranchLabel } from '../flow-labels';
4
+ defineProps<{ step: AppiumRecordedStep; disabled?: boolean }>();
5
+ defineEmits<{ update: [value: NonNullable<AppiumRecordedStep['timeoutBranch']>] }>();
6
+ </script>
7
+
8
+ <template>
9
+ <el-form-item label="超时后处理">
10
+ <el-select class="branch-timeout-select" size="small" popper-class="branch-timeout-popper" :model-value="step.timeoutBranch || 'stop'" :disabled="disabled" @update:model-value="$emit('update', $event)">
11
+ <el-option label="未配置分支(超时终止)" value="stop" />
12
+ <el-option :label="`进入左侧分支(${flowBranchLabel(step, 'yes')})`" value="yes" />
13
+ <el-option :label="`进入右侧分支(${flowBranchLabel(step, 'no')})`" value="no" />
14
+ </el-select>
15
+ </el-form-item>
16
+ </template>
17
+
18
+ <style>
19
+ .el-select.branch-timeout-select { width: 240px; max-width: 100%; flex: none; }
20
+ .branch-timeout-popper .el-select-dropdown__list { padding: 4px 0; }
21
+ .branch-timeout-popper .el-select-dropdown__item { height: 28px; line-height: 28px; padding: 0 12px; font-size: 12px; }
22
+ .branch-timeout-popper .el-select-dropdown__item.is-selected { color: var(--el-color-primary); }
23
+ </style>
@@ -5,6 +5,8 @@ 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 BranchTimeoutSettings from './BranchTimeoutSettings.vue';
9
+ import { DEFAULT_NODE_TIMEOUT_MS } from '../node-timeout';
8
10
  import LongPressSettings from './LongPressSettings.vue';
9
11
  import StageLogSettings from './StageLogSettings.vue';
10
12
  import LoopSettings from './LoopSettings.vue';
@@ -116,8 +118,7 @@ function patchVisualRegion(key: keyof VisualChangeConfig['region'], value: unkno
116
118
  }
117
119
 
118
120
  function patchTimeout(value: unknown) {
119
- const timeout = Math.max(0, toInteger(value, props.step.timeoutMs || 0));
120
- patchStep({ timeoutMs: timeout || undefined });
121
+ patchStep({ timeoutMs: value == null || value === '' ? undefined : Math.max(0, toInteger(value, DEFAULT_NODE_TIMEOUT_MS)) });
121
122
  }
122
123
 
123
124
  const showSelector = computed(() => props.step.type !== 'loop' && props.step.selector && (
@@ -152,21 +153,10 @@ function patchSelector(patch: Partial<AppiumSelector>) {
152
153
  @update:model-value="patchStep({ note: String($event) || undefined })"
153
154
  />
154
155
  </el-form-item>
155
- <div class="appium-flow-editor__grid">
156
- <el-form-item label="节点类型">
157
- <el-select
158
- :model-value="defaultKind()"
159
- :disabled="disabled || isBooleanCondition(step) || step.type === 'extractVariable' || step.type === 'log' || step.type === 'openGallery' || step.type === 'endFlow' || step.type === 'loop' || step.type === 'breakLoop'"
160
- @update:model-value="patchFlow({ nodeKind: $event as FlowKind })"
161
- >
162
- <el-option label="操作" value="action" />
163
- <el-option label="判断" value="condition" />
164
- <el-option label="校验" value="assertion" />
165
- </el-select>
166
- </el-form-item>
167
- <el-form-item v-if="!['longPress', 'log', 'openGallery', 'endFlow', 'loop', 'breakLoop'].includes(step.type)" label="超时时间 ms">
156
+ <div>
157
+ <el-form-item v-if="!['longPress', 'stopApp', 'log', 'openGallery', 'endFlow', 'loop', 'breakLoop'].includes(step.type)" label="超时时间 ms">
168
158
  <el-input-number
169
- :model-value="step.timeoutMs || undefined"
159
+ :model-value="step.timeoutMs ?? (step.type === 'delay' ? 1000 : DEFAULT_NODE_TIMEOUT_MS)"
170
160
  :disabled="disabled"
171
161
  :min="0"
172
162
  :max="999999"
@@ -174,9 +164,10 @@ function patchSelector(patch: Partial<AppiumSelector>) {
174
164
  @update:model-value="patchTimeout($event)"
175
165
  />
176
166
  </el-form-item>
167
+ <BranchTimeoutSettings v-if="defaultKind() === 'condition'" :step="step" :disabled="disabled" @update="patchStep({ timeoutBranch: $event })" />
177
168
  </div>
178
169
  <LongPressSettings v-if="step.type === 'longPress'" :step="step" :disabled="disabled" @update="patchStep" />
179
- <el-form-item v-if="['waitActivity', 'launchApp', 'clearAppData'].includes(step.type)" :label="step.type === 'waitActivity' ? '目标 Activity' : '目标 APP 包名'">
170
+ <el-form-item v-if="['waitActivity', 'launchApp', 'stopApp', 'clearAppData'].includes(step.type)" :label="step.type === 'waitActivity' ? '目标 Activity' : '目标 APP 包名'">
180
171
  <el-input :model-value="step.value || ''" :disabled="disabled" @update:model-value="patchStep({ value: String($event) })" />
181
172
  </el-form-item>
182
173
  <template v-if="step.type === 'pinch'">
@@ -4,9 +4,11 @@ import { ElMessage } from 'element-plus';
4
4
  import { Aim, Camera, Upload, QuestionFilled } from '@element-plus/icons-vue';
5
5
  import { IMAGE_CHECK_MODES, validateImageCheck, imageTemplateSize, imageTemplateRegionIssue, expandedImageTemplateRegion, type ImageCheckConfig } from '../image-check';
6
6
  import { captureImageCheckRegion } from '../api';
7
+ import BranchTimeoutSettings from './BranchTimeoutSettings.vue';
8
+ import type { AppiumRecordedStep } from '../types';
7
9
 
8
- const props = defineProps<{ config: ImageCheckConfig; deviceId: string; hasElement: boolean; editing: boolean; picking: boolean }>();
9
- const emit = defineEmits<{ update: [config: ImageCheckConfig]; close: []; confirm: []; pick: []; useElement: [] }>();
10
+ const props = defineProps<{ config: ImageCheckConfig; deviceId: string; hasElement: boolean; editing: boolean; picking: boolean; timeoutBranch?: AppiumRecordedStep['timeoutBranch'] }>();
11
+ const emit = defineEmits<{ update: [config: ImageCheckConfig]; timeoutBranch: [value: NonNullable<AppiumRecordedStep['timeoutBranch']>]; close: []; confirm: []; pick: []; useElement: [] }>();
10
12
  const busy = ref(false);
11
13
  const regionIssue = computed(() => imageTemplateRegionIssue(props.config));
12
14
  const expandedRegion = computed(() => expandedImageTemplateRegion(props.config));
@@ -119,10 +121,14 @@ async function upload(event: Event) {
119
121
  <el-input :model-value="config.color" maxlength="7" @update:model-value="patch({ color: $event })" />
120
122
  </el-form-item>
121
123
  <div class="image-check-grid">
122
- <el-form-item v-for="field in numericFields" :key="field.key" :label="field.label">
123
- <el-input-number :model-value="config[field.key]" :min="field.min" :max="field.max" :step="field.step" controls-position="right" @update:model-value="$event !== undefined && patch({ [field.key]: $event })" />
124
- </el-form-item>
124
+ <template v-for="field in numericFields" :key="field.key">
125
+ <el-form-item :label="field.label">
126
+ <el-input-number :model-value="config[field.key]" :min="field.min" :max="field.max" :step="field.step" controls-position="right" @update:model-value="$event !== undefined && patch({ [field.key]: $event })" />
127
+ </el-form-item>
128
+ <BranchTimeoutSettings v-if="field.key === 'durationMs'" style="grid-column: 1 / -1" :step="{ id: '', label: '', type: 'imageCheck', timeoutBranch }" :disabled="busy" @update="emit('timeoutBranch', $event)" />
129
+ </template>
125
130
  </div>
131
+ <BranchTimeoutSettings v-if="config.mode === 'state'" :step="{ id: '', label: '', type: 'imageCheck', timeoutBranch }" :disabled="busy" @update="emit('timeoutBranch', $event)" />
126
132
  </el-form>
127
133
  <template #footer><el-button :disabled="busy" @click="emit('close')">取消</el-button><el-button type="primary" :disabled="busy" @click="confirm">{{ editing ? '保存' : '添加' }}</el-button></template>
128
134
  </el-dialog>
@@ -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: '终止流程',
@@ -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
  },
@@ -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'
@@ -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 '';
@@ -1,4 +1,5 @@
1
1
  import type { AppiumVisualChangeRegion } from './types';
2
+ import { DEFAULT_NODE_TIMEOUT_MS } from './node-timeout';
2
3
 
3
4
  export const IMAGE_CHECK_MODES = { template: '模板匹配', state: '图片状态判断', black: '黑屏检测', color: '区域颜色判断', change: '多帧变化检测' } as const;
4
5
  export type ImageCheckConfig = {
@@ -20,6 +21,7 @@ export type ImageCheckConfig = {
20
21
  consecutive: number;
21
22
  };
22
23
  export type ImageCheckResult = {
24
+ timedOut?: boolean;
23
25
  result: boolean | null;
24
26
  mode: ImageCheckConfig['mode'];
25
27
  region: AppiumVisualChangeRegion;
@@ -33,7 +35,7 @@ export type ImageCheckResult = {
33
35
  export function createImageCheckConfig(): ImageCheckConfig {
34
36
  return { mode: 'template', target: 'region', region: { x: 0, y: 0, width: 100, height: 100 },
35
37
  screenWidth: 0, screenHeight: 0, expectation: 'present', threshold: 0.9, minScoreGap: 0.1,
36
- color: '#000000', tolerance: 30, ratio: 95, durationMs: 5000, intervalMs: 1000, consecutive: 1 };
38
+ color: '#000000', tolerance: 30, ratio: 95, durationMs: DEFAULT_NODE_TIMEOUT_MS, intervalMs: 1000, consecutive: 1 };
37
39
  }
38
40
 
39
41
  // 只读取 PNG 头部尺寸,避免为了表单校验解码整张模板。
@@ -0,0 +1 @@
1
+ export const DEFAULT_NODE_TIMEOUT_MS = 3000;
@@ -93,6 +93,7 @@ export type AppiumRecordedStep = {
93
93
  | 'swipe'
94
94
  | 'screenshot'
95
95
  | 'launchApp'
96
+ | 'stopApp'
96
97
  | 'openGallery'
97
98
  | 'endFlow'
98
99
  | 'loop'
@@ -120,6 +121,7 @@ export type AppiumRecordedStep = {
120
121
  returns?: ScriptReturn[];
121
122
  keyCode?: number;
122
123
  timeoutMs?: number;
124
+ timeoutBranch?: 'stop' | 'yes' | 'no';
123
125
  longPressMode?: 'element' | 'coordinates';
124
126
  breakLoopTargetId?: string;
125
127
  loop?: {
@@ -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
+ };