android-midscene-automation 0.1.37 → 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.
package/CHANGELOG.md CHANGED
@@ -1,4 +1,9 @@
1
1
  # 更新记录
2
+ ## v0.1.38
3
+ - 移除节点类型切换;新节点及未配置的判断等待超时统一为 3000ms,已有自定义时间和延时、长按等操作时长保持不变。
4
+ - 取消 Activity 不匹配时的脚本编辑锁定,不在脚本绑定页面也可编辑节点;页面差异仅作提示,保留录制操作及回放期间的编辑保护。
5
+ - 所有分支节点新增“超时后处理”,可预设终止流程、进入左侧或右侧分支,默认终止;覆盖元素等待、文字匹配、勾选控件等待、AI 识别及图像观察超时,日志和 HTML 报告标明超时分支。正常 false、循环次数上限与超时区分处理,设备断开及 Appium 无响应仍终止。
6
+
2
7
  ## v0.1.37
3
8
  - 修复元素查找超时未覆盖 Appium 请求的问题,驱动无响应时及时中止并明确报错,不再误判为找不到元素;唯一原生定位优先使用 ID 或 Accessibility ID。
4
9
  - 设备预览新增设备信息,展示设备名称、品牌、型号、处理器、Android 版本、物理分辨率及当前分辨率,支持远程设备;问号位于标题右侧,样式与全局变量帮助图标一致。
package/README.md CHANGED
@@ -1229,6 +1229,11 @@ 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
+
1232
1237
  ## v0.1.37
1233
1238
  - 修复元素查找超时未覆盖 Appium 请求的问题,驱动无响应时及时中止并明确报错,不再误判为找不到元素;唯一原生定位优先使用 ID 或 Accessibility ID。
1234
1239
  - 设备预览新增设备信息,展示设备名称、品牌、型号、处理器、Android 版本、物理分辨率及当前分辨率,支持远程设备;问号位于标题右侧,样式与全局变量帮助图标一致。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "android-midscene-automation",
3
- "version": "0.1.37",
3
+ "version": "0.1.38",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oooooooko/android-midscene-automation.git"
@@ -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;
@@ -12,6 +12,8 @@ import { IMAGE_CHECK_MODES, type ImageCheckResult } from '../../src/appium-recor
12
12
  import { formatStageLog } from '../../src/appium-recorder/stage-log';
13
13
  import { openGalleryOnDevice } from './open-gallery';
14
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';
15
17
  import { AppiumRequestTimeoutError, timedAppiumFetch, withElementDeadline } from './request-timeout';
16
18
  import { textClickSelector } from '../../src/appium-recorder/text-click';
17
19
  import { AsyncLocalStorage } from 'node:async_hooks';
@@ -86,7 +88,7 @@ function isReplayStopped(error?: unknown) {
86
88
  function throwIfReplayStopped(error?: unknown) {
87
89
  if (isReplayStopped(error)) throw new ReplayStoppedError();
88
90
  // 驱动失去响应不是“元素不存在”,不能被备用定位或可选步骤吞掉。
89
- if (error instanceof AppiumRequestTimeoutError) throw error;
91
+ if (error instanceof AppiumRequestTimeoutError || error instanceof AppiumServiceError) throw error;
90
92
  }
91
93
 
92
94
  function errorDetail(error: unknown) {
@@ -209,16 +211,17 @@ function adbSwipe(deviceId: string, step: Required<AppiumRecordedStepRecord>['sw
209
211
  });
210
212
  }
211
213
 
212
- function adbText(deviceId: string, args: string[]) {
213
- return new Promise<string>((resolve) => {
214
- 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; }
215
218
  resolve((stdout || stderr || '').trim());
216
219
  });
217
220
  });
218
221
  }
219
222
 
220
223
  async function getCurrentActivity(deviceId: string) {
221
- const output = await adbText(deviceId, ['shell', 'dumpsys', 'activity', 'activities']);
224
+ const output = await adbText(deviceId, ['shell', 'dumpsys', 'activity', 'activities'], true);
222
225
  const resumedLine = output
223
226
  .split(/\r?\n/)
224
227
  .find((line) => /(?:topResumedActivity|ResumedActivity|mResumedActivity)/.test(line));
@@ -235,7 +238,7 @@ async function waitForActivity(deviceId: string, step: AppiumRecordedStepRecord)
235
238
  const expectedActivity = step.value || '';
236
239
  if (!expectedActivity) throw new Error(`${step.label} 缺少目标 Activity`);
237
240
  const startedAt = Date.now();
238
- const timeoutMs = step.timeoutMs || 10000;
241
+ const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
239
242
  let currentActivity = '';
240
243
  while (Date.now() - startedAt <= timeoutMs) {
241
244
  throwIfReplayStopped();
@@ -243,7 +246,7 @@ async function waitForActivity(deviceId: string, step: AppiumRecordedStepRecord)
243
246
  if (currentActivity === expectedActivity) return;
244
247
  await wait(500);
245
248
  }
246
- throw new Error(`${step.label} 等待超时,当前 Activity:${currentActivity || '-'}`);
249
+ throw new ConditionTimeoutError(`${step.label} 等待超时,当前 Activity:${currentActivity || '-'}`);
247
250
  }
248
251
 
249
252
  async function appendSettingsDiagnostics(lines: string[], deviceId: string) {
@@ -300,9 +303,9 @@ async function appiumRequest<T>(
300
303
  context?.appiumLog(`[Appium] 异常:${method} ${path}(${errorDetail(error)})`);
301
304
  if (error instanceof AppiumRequestTimeoutError) throw error;
302
305
  if (isTimeoutError(error)) {
303
- throw new Error(`Appium 请求超时 ${requestUrl},${errorDetail(error)}`);
306
+ throw new AppiumServiceError(`Appium 请求超时 ${requestUrl},${errorDetail(error)}`);
304
307
  }
305
- throw new Error(`无法连接 Appium 服务 ${requestUrl},${errorDetail(error)}`);
308
+ throw new AppiumServiceError(`无法连接 Appium 服务 ${requestUrl},${errorDetail(error)}`);
306
309
  }
307
310
 
308
311
  context?.appiumLog(`[Appium] 响应:HTTP ${response.status} ${method} ${path}(${Date.now() - startedAt}ms)`);
@@ -325,7 +328,8 @@ async function appiumRequest<T>(
325
328
  payload.value?.stacktrace || payload.stacktrace ? `堆栈信息:\n${payload.value?.stacktrace || payload.stacktrace}` : '',
326
329
  responseText ? `原始响应:\n${responseText}` : '',
327
330
  ].filter(Boolean).join('\n');
328
- 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 || '未返回错误详情'}`);
329
333
  }
330
334
  return payload;
331
335
  }
@@ -444,7 +448,7 @@ async function findElementBySelector(
444
448
  return elementId;
445
449
  }
446
450
 
447
- async function findElement(sessionId: string, step: AppiumRecordedStepRecord, timeoutMs = step.timeoutMs ?? 10000) {
451
+ async function findElement(sessionId: string, step: AppiumRecordedStepRecord, timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS) {
448
452
  return withElementDeadline(timeoutMs, () => findElementWithinDeadline(sessionId, step));
449
453
  }
450
454
 
@@ -513,7 +517,7 @@ async function tapFallback(deviceId: string, step: AppiumRecordedStepRecord) {
513
517
 
514
518
  async function waitForElement(sessionId: string, step: AppiumRecordedStepRecord) {
515
519
  const startedAt = Date.now();
516
- const timeoutMs = step.timeoutMs || 10000;
520
+ const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
517
521
  let lastError: unknown;
518
522
  while (Date.now() - startedAt <= timeoutMs) {
519
523
  try {
@@ -524,12 +528,12 @@ async function waitForElement(sessionId: string, step: AppiumRecordedStepRecord)
524
528
  await wait(500);
525
529
  }
526
530
  }
527
- throw lastError instanceof Error ? lastError : new Error(`${step.label} 等待超时`);
531
+ throw new ConditionTimeoutError(`${step.label} 等待元素超时(${timeoutMs}ms):${errorDetail(lastError)}`);
528
532
  }
529
533
 
530
534
  async function findOptionalElement(sessionId: string, step: AppiumRecordedStepRecord) {
531
535
  const startedAt = Date.now();
532
- const timeoutMs = step.timeoutMs ?? 2000;
536
+ const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
533
537
  while (Date.now() - startedAt <= timeoutMs) {
534
538
  try {
535
539
  return await findElement(sessionId, step, Math.max(1, timeoutMs - (Date.now() - startedAt)));
@@ -543,7 +547,7 @@ async function findOptionalElement(sessionId: string, step: AppiumRecordedStepRe
543
547
 
544
548
  async function waitForElementGone(sessionId: string, step: AppiumRecordedStepRecord) {
545
549
  const startedAt = Date.now();
546
- const timeoutMs = step.timeoutMs || 10000;
550
+ const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
547
551
  while (Date.now() - startedAt <= timeoutMs) {
548
552
  try {
549
553
  await findElement(sessionId, step, Math.max(1, timeoutMs - (Date.now() - startedAt)));
@@ -553,7 +557,7 @@ async function waitForElementGone(sessionId: string, step: AppiumRecordedStepRec
553
557
  }
554
558
  await wait(500);
555
559
  }
556
- throw new Error(`${step.label} 等待消失超时`);
560
+ throw new ConditionTimeoutError(`${step.label} 等待消失超时`);
557
561
  }
558
562
 
559
563
  async function saveScreenshot(deviceId: string) {
@@ -998,7 +1002,7 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
998
1002
  }
999
1003
  if (step.type === 'textClick') {
1000
1004
  const selector = textClickSelector(step);
1001
- const deadline = Date.now() + Math.max(0, step.timeoutMs ?? 10000);
1005
+ const deadline = Date.now() + Math.max(0, step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS);
1002
1006
  // 查询空结果才是未匹配;通信错误、重复匹配和点击失败走现有异常处理。
1003
1007
  do {
1004
1008
  const payload = await appiumRequest<AppiumValueResponse<Record<string, string>[]>>(`/session/${sessionId}/elements`, {
@@ -1013,7 +1017,10 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
1013
1017
  await appiumRequest(`/session/${sessionId}/element/${id}/click`, { method: 'POST', body: '{}' });
1014
1018
  return true;
1015
1019
  }
1016
- 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
+ }
1017
1024
  await wait(Math.max(0, Math.min(500, deadline - Date.now())));
1018
1025
  } while (true);
1019
1026
  }
@@ -1029,7 +1036,7 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
1029
1036
  return true;
1030
1037
  }
1031
1038
  if (step.type === 'assertText' || (step.type === 'assertExists' && step.value)) {
1032
- const elementId = await findElement(sessionId, step);
1039
+ const elementId = await waitForElement(sessionId, step);
1033
1040
  const payload = await appiumRequest<AppiumValueResponse<string>>(`/session/${sessionId}/element/${elementId}/text`);
1034
1041
  const actualText = payload.value || '';
1035
1042
  const expectedText = step.value || '';
@@ -1041,7 +1048,7 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
1041
1048
  return true;
1042
1049
  } catch (error) {
1043
1050
  throwIfReplayStopped(error);
1044
- return false;
1051
+ throw error;
1045
1052
  }
1046
1053
  }
1047
1054
 
@@ -1117,7 +1124,7 @@ async function replayLinkedScript(
1117
1124
  type: 'waitActivity',
1118
1125
  label: step.label,
1119
1126
  value: linkedScript.appActivity,
1120
- timeoutMs: 10000,
1127
+ timeoutMs: DEFAULT_NODE_TIMEOUT_MS,
1121
1128
  });
1122
1129
  } catch (error) {
1123
1130
  throwIfReplayStopped(error);
@@ -1270,6 +1277,7 @@ async function replayFlowSteps(
1270
1277
  replayContext.getStore()?.imageChecks.push({ ...imageResult, nodeId: step.id, nodeNumber: index + 1, nodeLabel: frameStep.label, scriptName: options.scriptName || '', settings });
1271
1278
  lines.push(`[节点 ${index + 1}] 图像判断:${imageResult.result === null ? '无法判定' : imageResult.result},${imageResult.message};${settings};采样 ${imageResult.sampleCount} 帧,耗时 ${imageResult.durationMs}ms;指标 ${JSON.stringify(imageResult.metrics)}`);
1272
1279
  if (imageResult.result === null) throw new Error(imageResult.message);
1280
+ if (imageResult.timedOut) throw new ConditionTimeoutError(imageResult.message);
1273
1281
  }
1274
1282
  const matched = imageResult ? imageResult.result! : recognition ? recognition.result : await evaluateCondition(sessionId, deviceId, step);
1275
1283
  if (recognition) lines.push(`[节点 ${index + 1}] AI 识别:${recognition.result},耗时 ${recognition.durationMs}ms${recognition.reason ? `,依据:${recognition.reason}` : ''}`);
@@ -1294,6 +1302,17 @@ async function replayFlowSteps(
1294
1302
  await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'stopped', '已终止');
1295
1303
  throw new ReplayStoppedError();
1296
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
+ }
1297
1316
  lines.push(`[节点 ${index + 1}] 失败:${errorDetail(error)}`);
1298
1317
  await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'error', '失败');
1299
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]) => {
@@ -99,6 +99,7 @@ export type AppiumRecordedStepRecord = {
99
99
  returns?: ScriptReturn[];
100
100
  keyCode?: number;
101
101
  timeoutMs?: number;
102
+ timeoutBranch?: 'stop' | 'yes' | 'no';
102
103
  longPressMode?: 'element' | 'coordinates';
103
104
  breakLoopTargetId?: string;
104
105
  loop?: {
@@ -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,47 +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
- 'stopApp',
126
- 'openGallery',
127
- 'endFlow',
128
- 'loop',
129
- 'breakLoop',
130
- 'clearAppData',
131
- 'waitDisappear',
132
- 'longPress',
133
- 'pinch',
134
- 'noop',
135
- 'log',
136
- 'visualChangeStart',
137
- 'visualChangeEnd',
138
- ];
139
99
  const readonlyFlowActionGroups: FlowActionGroup[] = [];
140
100
  const readonlyFlowSelectedIndexes: number[] = [];
141
101
 
@@ -294,7 +254,7 @@ const recordingBusy = computed(() => (
294
254
  || resolvingNavigation.value
295
255
  || Boolean(pendingNavigation.value)
296
256
  ));
297
- const recordingLocked = computed(() => recordingBusy.value || scriptActivityMismatch.value);
257
+ const recordingLocked = computed(() => recordingBusy.value || replaying.value);
298
258
  const newScriptDisabled = computed(() => saving.value || replaying.value || recordingBusy.value || launchingApp.value || importingScript.value);
299
259
  const overlayBounds = computed(() => flattenNodes(tree.value).flatMap((node) => (
300
260
  node.bounds ? [{ id: node.id, ...node.bounds }] : []
@@ -510,7 +470,7 @@ function createStep(type: NodeStepType, node: AppiumNode): AppiumRecordedStep {
510
470
  ? [{ strategy: 'xpath', value: node.xpath, unique: true, matchCount: 1 }]
511
471
  : undefined,
512
472
  fallback: node.bounds ? { strategy: 'bounds', centerX: node.bounds.centerX, centerY: node.bounds.centerY } : undefined,
513
- timeoutMs: type === 'waitFor' ? 10000 : undefined,
473
+ timeoutMs: type === 'waitFor' ? DEFAULT_NODE_TIMEOUT_MS : undefined,
514
474
  pageBefore: currentPageSnapshot(node),
515
475
  snapshot: {
516
476
  text: node.text,
@@ -614,7 +574,7 @@ function createWaitActivityStep(activity: string): AppiumRecordedStep {
614
574
  type: 'waitActivity',
615
575
  label: `等待 Activity ${activity}`,
616
576
  value: activity,
617
- timeoutMs: 10000,
577
+ timeoutMs: DEFAULT_NODE_TIMEOUT_MS,
618
578
  };
619
579
  }
620
580
 
@@ -976,7 +936,7 @@ async function executeFlowStep(index: number) {
976
936
  } else if (scriptActivityMismatch.value) {
977
937
  ElMessage.warning(`App 已启动,当前页面仍为 ${currentActivity.value || '-'}`);
978
938
  } else {
979
- ElMessage.success('已进入脚本绑定页面,编辑锁定已解除');
939
+ ElMessage.success('已进入脚本绑定页面');
980
940
  }
981
941
  } catch (error) {
982
942
  ElMessage.error(error instanceof Error ? error.message : 'App 操作失败');
@@ -1443,13 +1403,13 @@ async function addAction(
1443
1403
  if (!input) return;
1444
1404
  return insertStep({
1445
1405
  id: createStepId(), type: 'aiRecognition', label: 'AI 识别',
1446
- value: validateAiRecognitionPrompt(input.value), timeoutMs: 60000,
1406
+ value: validateAiRecognitionPrompt(input.value), timeoutMs: DEFAULT_NODE_TIMEOUT_MS,
1447
1407
  flow: { nodeKind: 'condition' },
1448
1408
  }, index, branchTarget);
1449
1409
  }
1450
1410
  if (action === 'textClick') {
1451
1411
  const step = reactive<AppiumRecordedStep>({
1452
- id: createStepId(), type: 'textClick', label: '文字点击', value: '', timeoutMs: 10000,
1412
+ id: createStepId(), type: 'textClick', label: '文字点击', value: '', timeoutMs: DEFAULT_NODE_TIMEOUT_MS,
1453
1413
  flow: { nodeKind: 'condition', textMatch: 'exact' },
1454
1414
  });
1455
1415
  const result = await ElMessageBox({
@@ -1617,7 +1577,7 @@ async function addAction(
1617
1577
  ElMessage.warning(`请选择原生 ${controlName} 元素,当前选中:${node.className || '未知类型'}`);
1618
1578
  return;
1619
1579
  }
1620
- const step = createNodeActionStep(action, action === 'checkedState' ? '判断勾选' : `判断 ${controlName} 状态`, node, { timeoutMs: 2000 });
1580
+ const step = createNodeActionStep(action, action === 'checkedState' ? '判断勾选' : `判断 ${controlName} 状态`, node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS });
1621
1581
  if ((!step.selector?.value || step.selector.strategy === 'bounds') && node.xpath) {
1622
1582
  step.selector = { strategy: 'xpath', value: node.xpath };
1623
1583
  step.contextSelector = undefined;
@@ -1633,13 +1593,13 @@ async function addAction(
1633
1593
  return insertStep(step, index, branchTarget);
1634
1594
  }
1635
1595
  if (action === 'popupCondition') {
1636
- const step = createNodeActionStep('assertExists', '判断存在', node, { timeoutMs: 2000 });
1596
+ const step = createNodeActionStep('assertExists', '判断存在', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS });
1637
1597
  const inserted = insertStep({ ...step, flow: { nodeKind: 'condition' } }, index, branchTarget);
1638
1598
  ElMessage.success('已添加判断节点,请在节点面板配置是/否分支');
1639
1599
  return inserted;
1640
1600
  }
1641
1601
  if (action === 'tapIfExists') {
1642
- return insertStep(createNodeActionStep('tapIfExists', '存在则点击', node, { timeoutMs: 2000 }), index, branchTarget);
1602
+ return insertStep(createNodeActionStep('tapIfExists', '存在则点击', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS }), index, branchTarget);
1643
1603
  }
1644
1604
  if (action === 'inputIfExists') {
1645
1605
  const input = await ElMessageBox.prompt('', '存在则输入', {
@@ -1650,16 +1610,16 @@ async function addAction(
1650
1610
  }).catch(() => null);
1651
1611
  if (!input) return;
1652
1612
  return insertStep(
1653
- createNodeActionStep('inputIfExists', '存在则输入', node, { timeoutMs: 2000, value: input.value }),
1613
+ createNodeActionStep('inputIfExists', '存在则输入', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS, value: input.value }),
1654
1614
  index,
1655
1615
  branchTarget,
1656
1616
  );
1657
1617
  }
1658
1618
  if (action === 'clearIfExists') {
1659
- return insertStep(createNodeActionStep('clearIfExists', '存在则清空', node, { timeoutMs: 2000 }), index, branchTarget);
1619
+ return insertStep(createNodeActionStep('clearIfExists', '存在则清空', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS }), index, branchTarget);
1660
1620
  }
1661
1621
  if (action === 'backIfExists') {
1662
- return insertStep(createNodeActionStep('backIfExists', '存在则返回', node, { timeoutMs: 2000 }), index, branchTarget);
1622
+ return insertStep(createNodeActionStep('backIfExists', '存在则返回', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS }), index, branchTarget);
1663
1623
  }
1664
1624
  if (action === 'clearInput') {
1665
1625
  return insertStep(createNodeActionStep('clearInput', '清空输入', node), index, branchTarget);
@@ -1706,7 +1666,7 @@ async function addAction(
1706
1666
  return insertStep({ ...step }, index, branchTarget);
1707
1667
  }
1708
1668
  if (action === 'waitDisappear') {
1709
- return insertStep(createNodeActionStep('waitDisappear', '等待元素消失', node, { timeoutMs: 10000 }), index, branchTarget);
1669
+ return insertStep(createNodeActionStep('waitDisappear', '等待元素消失', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS }), index, branchTarget);
1710
1670
  }
1711
1671
  }
1712
1672
 
@@ -2287,7 +2247,7 @@ watch(
2287
2247
  v-if="scriptActivityMismatch"
2288
2248
  class="appium-activity-summary"
2289
2249
  >
2290
- <summary>当前页面与脚本不一致,部分录制操作不可用</summary>
2250
+ <summary>当前页面与脚本绑定的 Activity 不一致</summary>
2291
2251
  <div class="appium-activity-lock-alert__details">
2292
2252
  <div>
2293
2253
  <strong>脚本绑定</strong>
@@ -2297,7 +2257,7 @@ watch(
2297
2257
  <strong>当前 Activity</strong>
2298
2258
  <code>{{ currentActivity || '正在获取' }}</code>
2299
2259
  </div>
2300
- <p>请在录制流程中添加或执行“启动 APP”节点;Activity 匹配后会自动解除编辑锁定。</p>
2260
+ <p>不影响脚本编辑;如需录制当前页面的组件操作,请确认设备页面及所选组件。</p>
2301
2261
  </div>
2302
2262
  </details>
2303
2263
  </section>
@@ -2309,9 +2269,8 @@ watch(
2309
2269
  :steps="steps"
2310
2270
  :clipboard-count="flowClipboardCount"
2311
2271
  :disabled="recordingLocked"
2312
- :remove-disabled="recordingBusy"
2272
+ :remove-disabled="recordingLocked"
2313
2273
  :merge-disabled="recordingBusy || replaying || saving"
2314
- :allowed-locked-actions="scriptActivityMismatch && !recordingBusy ? insertableRecorderActions : []"
2315
2274
  :launching-step-id="executingAppStepId"
2316
2275
  @remove="removeStep"
2317
2276
  @copy="copyFlowNodes"
@@ -2434,6 +2393,8 @@ watch(
2434
2393
 
2435
2394
  <ImageCheckDialog
2436
2395
  v-if="imageCheckDraft?.imageCheck"
2396
+ :timeout-branch="imageCheckDraft.timeoutBranch"
2397
+ @timeout-branch="imageCheckDraft = { ...imageCheckDraft!, timeoutBranch: $event }"
2437
2398
  :config="imageCheckDraft.imageCheck" :device-id="selectedDeviceId"
2438
2399
  :has-element="Boolean(selectedNode?.bounds)" :editing="imageCheckEditing" :picking="imageCheckPicking"
2439
2400
  @update="updateImageCheck" @close="imageCheckDraft = null; imageCheckPicking = false"
@@ -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 === 'stopApp' || 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>
156
+ <div>
167
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,6 +164,7 @@ 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
170
  <el-form-item v-if="['waitActivity', 'launchApp', 'stopApp', 'clearAppData'].includes(step.type)" :label="step.type === 'waitActivity' ? '目标 Activity' : '目标 APP 包名'">
@@ -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>
@@ -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;
@@ -121,6 +121,7 @@ export type AppiumRecordedStep = {
121
121
  returns?: ScriptReturn[];
122
122
  keyCode?: number;
123
123
  timeoutMs?: number;
124
+ timeoutBranch?: 'stop' | 'yes' | 'no';
124
125
  longPressMode?: 'element' | 'coordinates';
125
126
  breakLoopTargetId?: string;
126
127
  loop?: {