android-midscene-automation 0.1.37 → 0.1.39

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 (33) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +13 -0
  3. package/package.json +12 -7
  4. package/server/appium-recorder/ai-recognition.ts +4 -2
  5. package/server/appium-recorder/appium-runner.ts +90 -37
  6. package/server/appium-recorder/condition-timeout.ts +4 -0
  7. package/server/appium-recorder/image-check.ts +16 -2
  8. package/server/appium-recorder/replay-mp4.ts +72 -0
  9. package/server/appium-recorder/replay-video.ts +99 -0
  10. package/server/appium-recorder/report.ts +60 -6
  11. package/server/appium-recorder/repository.ts +1 -0
  12. package/server/appium-recorder/routes.ts +13 -17
  13. package/server/appium-recorder/run-history.ts +7 -1
  14. package/server/appium-recorder/scrcpy-recording-source.ts +80 -0
  15. package/server/appium-recorder/video-response.ts +36 -0
  16. package/src/appium-recorder/AppiumPage.vue +42 -98
  17. package/src/appium-recorder/RunHistoryDialog.vue +7 -2
  18. package/src/appium-recorder/action-descriptions.ts +1 -1
  19. package/src/appium-recorder/api.ts +3 -10
  20. package/src/appium-recorder/components/BranchTimeoutSettings.vue +23 -0
  21. package/src/appium-recorder/components/FlowNodeCard.vue +2 -3
  22. package/src/appium-recorder/components/FlowStepEditor.vue +6 -21
  23. package/src/appium-recorder/components/ImageCheckDialog.vue +38 -9
  24. package/src/appium-recorder/components/NestedConditionBranches.vue +0 -1
  25. package/src/appium-recorder/components/RecordedSteps.vue +0 -1
  26. package/src/appium-recorder/flow-graph.ts +2 -2
  27. package/src/appium-recorder/flow-labels.ts +2 -2
  28. package/src/appium-recorder/image-check.ts +6 -2
  29. package/src/appium-recorder/node-timeout.ts +1 -0
  30. package/src/appium-recorder/run-history.ts +1 -0
  31. package/src/appium-recorder/types.ts +1 -0
  32. package/src/components/config/AppiumConfigPanel.vue +0 -35
  33. package/src/appium-recorder/components/AiRecognitionTestDialog.vue +0 -71
@@ -1,8 +1,9 @@
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
- import { Check, CircleClose, CopyDocument, Delete, Document, Download, Edit, Plus, Refresh, Upload, VideoPlay, View, Clock } from '@element-plus/icons-vue';
6
+ import { ArrowDown, 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';
7
8
  import type { AndroidDevice, AppPreset, DeviceAction } from '../types';
8
9
  import DevicePreviewPanel from '../components/device/DevicePreviewPanel.vue';
@@ -42,8 +43,6 @@ import NewScriptDialog from './components/NewScriptDialog.vue';
42
43
  import VisualChangeDialog from './components/VisualChangeDialog.vue';
43
44
  import ImageCheckDialog from './components/ImageCheckDialog.vue';
44
45
  import { createImageCheckConfig, validateImageCheck, type ImageCheckConfig } from './image-check';
45
- import AiRecognitionTestDialog from './components/AiRecognitionTestDialog.vue';
46
- import { validateAiRecognitionPrompt } from './ai-recognition';
47
46
  import {
48
47
  createFlowClipboard,
49
48
  pasteFlowClipboard,
@@ -95,49 +94,9 @@ const workspaceRef = shallowRef<InstanceType<typeof RecorderWorkspace>>();
95
94
  const historyScript = shallowRef<{ id: string; name: string } | null>(null);
96
95
  const AUTO_TREE_REFRESH_INTERVAL_MS = 1800;
97
96
 
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
97
  const readonlyFlowActionGroups: FlowActionGroup[] = [];
140
98
  const readonlyFlowSelectedIndexes: number[] = [];
99
+ const recordReplayVideo = shallowRef(false);
141
100
 
142
101
  const props = defineProps<{
143
102
  aiRecognitionModelConfigured?: boolean;
@@ -177,7 +136,6 @@ const newScriptRevision = shallowRef(0);
177
136
  const storedWorkbenchTab = window.localStorage.getItem(WORKBENCH_TAB_STORAGE_KEY);
178
137
  const activeWorkbenchTab = shallowRef<'recording' | 'scripts' | 'variables'>(storedWorkbenchTab === 'scripts' ? 'scripts' : storedWorkbenchTab === 'variables' ? 'variables' : 'recording');
179
138
  const steps = shallowRef<AppiumRecordedStep[]>([]);
180
- const aiRecognitionTestStep = shallowRef<AppiumRecordedStep | null>(null);
181
139
  const flowClipboard = shallowRef<FlowClipboard | null>(null);
182
140
  const rawXml = shallowRef('');
183
141
  const currentActivity = shallowRef('');
@@ -294,7 +252,7 @@ const recordingBusy = computed(() => (
294
252
  || resolvingNavigation.value
295
253
  || Boolean(pendingNavigation.value)
296
254
  ));
297
- const recordingLocked = computed(() => recordingBusy.value || scriptActivityMismatch.value);
255
+ const recordingLocked = computed(() => recordingBusy.value || replaying.value);
298
256
  const newScriptDisabled = computed(() => saving.value || replaying.value || recordingBusy.value || launchingApp.value || importingScript.value);
299
257
  const overlayBounds = computed(() => flattenNodes(tree.value).flatMap((node) => (
300
258
  node.bounds ? [{ id: node.id, ...node.bounds }] : []
@@ -510,7 +468,7 @@ function createStep(type: NodeStepType, node: AppiumNode): AppiumRecordedStep {
510
468
  ? [{ strategy: 'xpath', value: node.xpath, unique: true, matchCount: 1 }]
511
469
  : undefined,
512
470
  fallback: node.bounds ? { strategy: 'bounds', centerX: node.bounds.centerX, centerY: node.bounds.centerY } : undefined,
513
- timeoutMs: type === 'waitFor' ? 10000 : undefined,
471
+ timeoutMs: type === 'waitFor' ? DEFAULT_NODE_TIMEOUT_MS : undefined,
514
472
  pageBefore: currentPageSnapshot(node),
515
473
  snapshot: {
516
474
  text: node.text,
@@ -614,7 +572,7 @@ function createWaitActivityStep(activity: string): AppiumRecordedStep {
614
572
  type: 'waitActivity',
615
573
  label: `等待 Activity ${activity}`,
616
574
  value: activity,
617
- timeoutMs: 10000,
575
+ timeoutMs: DEFAULT_NODE_TIMEOUT_MS,
618
576
  };
619
577
  }
620
578
 
@@ -928,11 +886,6 @@ async function refreshTree() {
928
886
 
929
887
  async function executeFlowStep(index: number) {
930
888
  const step = steps.value[index];
931
- if (step?.type === 'aiRecognition') {
932
- if (replaying.value || recordingBusy.value) { ElMessage.warning('设备正在执行操作,请稍后测试'); return; }
933
- aiRecognitionTestStep.value = { ...step };
934
- return;
935
- }
936
889
  if (!step || (step.type !== 'launchApp' && step.type !== 'clearAppData')) return;
937
890
  if (launchingApp.value) return;
938
891
  if (!selectedDeviceId.value) {
@@ -976,7 +929,7 @@ async function executeFlowStep(index: number) {
976
929
  } else if (scriptActivityMismatch.value) {
977
930
  ElMessage.warning(`App 已启动,当前页面仍为 ${currentActivity.value || '-'}`);
978
931
  } else {
979
- ElMessage.success('已进入脚本绑定页面,编辑锁定已解除');
932
+ ElMessage.success('已进入脚本绑定页面');
980
933
  }
981
934
  } catch (error) {
982
935
  ElMessage.error(error instanceof Error ? error.message : 'App 操作失败');
@@ -1431,25 +1384,12 @@ async function addAction(
1431
1384
  return;
1432
1385
  }
1433
1386
  if (action === 'aiRecognition') {
1434
- const input = await ElMessageBox.prompt('识别内容', '添加 AI 识别', {
1435
- inputType: 'textarea',
1436
- inputPlaceholder: '例如:检查当前画面有没有显示黑屏',
1437
- inputValidator: (value) => {
1438
- try { validateAiRecognitionPrompt(value); return true; }
1439
- catch (error) { return error instanceof Error ? error.message : '识别内容无效'; }
1440
- },
1441
- confirmButtonText: '添加', cancelButtonText: '取消',
1442
- }).catch(() => null);
1443
- if (!input) return;
1444
- return insertStep({
1445
- id: createStepId(), type: 'aiRecognition', label: 'AI 识别',
1446
- value: validateAiRecognitionPrompt(input.value), timeoutMs: 60000,
1447
- flow: { nodeKind: 'condition' },
1448
- }, index, branchTarget);
1387
+ ElMessage.warning('AI 识别操作已移除,请使用图像判断或原生组件判断');
1388
+ return;
1449
1389
  }
1450
1390
  if (action === 'textClick') {
1451
1391
  const step = reactive<AppiumRecordedStep>({
1452
- id: createStepId(), type: 'textClick', label: '文字点击', value: '', timeoutMs: 10000,
1392
+ id: createStepId(), type: 'textClick', label: '文字点击', value: '', timeoutMs: DEFAULT_NODE_TIMEOUT_MS,
1453
1393
  flow: { nodeKind: 'condition', textMatch: 'exact' },
1454
1394
  });
1455
1395
  const result = await ElMessageBox({
@@ -1617,7 +1557,7 @@ async function addAction(
1617
1557
  ElMessage.warning(`请选择原生 ${controlName} 元素,当前选中:${node.className || '未知类型'}`);
1618
1558
  return;
1619
1559
  }
1620
- const step = createNodeActionStep(action, action === 'checkedState' ? '判断勾选' : `判断 ${controlName} 状态`, node, { timeoutMs: 2000 });
1560
+ const step = createNodeActionStep(action, action === 'checkedState' ? '判断勾选' : `判断 ${controlName} 状态`, node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS });
1621
1561
  if ((!step.selector?.value || step.selector.strategy === 'bounds') && node.xpath) {
1622
1562
  step.selector = { strategy: 'xpath', value: node.xpath };
1623
1563
  step.contextSelector = undefined;
@@ -1633,13 +1573,13 @@ async function addAction(
1633
1573
  return insertStep(step, index, branchTarget);
1634
1574
  }
1635
1575
  if (action === 'popupCondition') {
1636
- const step = createNodeActionStep('assertExists', '判断存在', node, { timeoutMs: 2000 });
1576
+ const step = createNodeActionStep('assertExists', '判断存在', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS });
1637
1577
  const inserted = insertStep({ ...step, flow: { nodeKind: 'condition' } }, index, branchTarget);
1638
1578
  ElMessage.success('已添加判断节点,请在节点面板配置是/否分支');
1639
1579
  return inserted;
1640
1580
  }
1641
1581
  if (action === 'tapIfExists') {
1642
- return insertStep(createNodeActionStep('tapIfExists', '存在则点击', node, { timeoutMs: 2000 }), index, branchTarget);
1582
+ return insertStep(createNodeActionStep('tapIfExists', '存在则点击', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS }), index, branchTarget);
1643
1583
  }
1644
1584
  if (action === 'inputIfExists') {
1645
1585
  const input = await ElMessageBox.prompt('', '存在则输入', {
@@ -1650,16 +1590,16 @@ async function addAction(
1650
1590
  }).catch(() => null);
1651
1591
  if (!input) return;
1652
1592
  return insertStep(
1653
- createNodeActionStep('inputIfExists', '存在则输入', node, { timeoutMs: 2000, value: input.value }),
1593
+ createNodeActionStep('inputIfExists', '存在则输入', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS, value: input.value }),
1654
1594
  index,
1655
1595
  branchTarget,
1656
1596
  );
1657
1597
  }
1658
1598
  if (action === 'clearIfExists') {
1659
- return insertStep(createNodeActionStep('clearIfExists', '存在则清空', node, { timeoutMs: 2000 }), index, branchTarget);
1599
+ return insertStep(createNodeActionStep('clearIfExists', '存在则清空', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS }), index, branchTarget);
1660
1600
  }
1661
1601
  if (action === 'backIfExists') {
1662
- return insertStep(createNodeActionStep('backIfExists', '存在则返回', node, { timeoutMs: 2000 }), index, branchTarget);
1602
+ return insertStep(createNodeActionStep('backIfExists', '存在则返回', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS }), index, branchTarget);
1663
1603
  }
1664
1604
  if (action === 'clearInput') {
1665
1605
  return insertStep(createNodeActionStep('clearInput', '清空输入', node), index, branchTarget);
@@ -1706,7 +1646,7 @@ async function addAction(
1706
1646
  return insertStep({ ...step }, index, branchTarget);
1707
1647
  }
1708
1648
  if (action === 'waitDisappear') {
1709
- return insertStep(createNodeActionStep('waitDisappear', '等待元素消失', node, { timeoutMs: 10000 }), index, branchTarget);
1649
+ return insertStep(createNodeActionStep('waitDisappear', '等待元素消失', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS }), index, branchTarget);
1710
1650
  }
1711
1651
  }
1712
1652
 
@@ -2116,7 +2056,7 @@ async function replayScript() {
2116
2056
  try {
2117
2057
  await pauseAutoTreeRefresh();
2118
2058
  const result = await replayAppiumScript(
2119
- { id: selectedScript.value.id, deviceId: activeReplayDeviceId.value },
2059
+ { id: selectedScript.value.id, deviceId: activeReplayDeviceId.value, recordVideo: recordReplayVideo.value },
2120
2060
  (line) => {
2121
2061
  replayOutput.value += `${replayOutput.value ? '\n' : ''}${line}`;
2122
2062
  },
@@ -2207,15 +2147,23 @@ watch(
2207
2147
  </el-select>
2208
2148
  <el-button :icon="Plus" :disabled="newScriptDisabled || newScriptDialogVisible" @click="requestNewScript">新建</el-button>
2209
2149
  <el-button :icon="Check" :loading="saving" @click="saveScript">保存</el-button>
2210
- <el-button
2211
- type="primary"
2212
- :icon="VideoPlay"
2213
- :loading="replaying"
2214
- :disabled="!selectedScript"
2215
- @click="replayScript"
2216
- >
2217
- 回放
2218
- </el-button>
2150
+ <el-button-group class="replay-button-group">
2151
+ <el-button
2152
+ type="primary"
2153
+ :icon="VideoPlay"
2154
+ :loading="replaying"
2155
+ :disabled="!selectedScript"
2156
+ @click="replayScript"
2157
+ >
2158
+ 回放
2159
+ </el-button>
2160
+ <el-popover trigger="click" placement="bottom" :width="260">
2161
+ <template #reference><el-button type="primary" :icon="ArrowDown" :disabled="replaying" aria-label="回放设置" title="回放设置" class="replay-settings-toggle" /></template>
2162
+ <el-tooltip content="使用内置服务端后台录制,无需安装 scrcpy 或 FFmpeg。含敏感变量的运行不录制;分享 HTML 报告时需同时携带 MP4。" placement="bottom" :show-after="300">
2163
+ <el-checkbox v-model="recordReplayVideo" :disabled="replaying">录制回放视频</el-checkbox>
2164
+ </el-tooltip>
2165
+ </el-popover>
2166
+ </el-button-group>
2219
2167
  <el-button
2220
2168
  v-if="replaying"
2221
2169
  type="danger"
@@ -2287,7 +2235,7 @@ watch(
2287
2235
  v-if="scriptActivityMismatch"
2288
2236
  class="appium-activity-summary"
2289
2237
  >
2290
- <summary>当前页面与脚本不一致,部分录制操作不可用</summary>
2238
+ <summary>当前页面与脚本绑定的 Activity 不一致</summary>
2291
2239
  <div class="appium-activity-lock-alert__details">
2292
2240
  <div>
2293
2241
  <strong>脚本绑定</strong>
@@ -2297,7 +2245,7 @@ watch(
2297
2245
  <strong>当前 Activity</strong>
2298
2246
  <code>{{ currentActivity || '正在获取' }}</code>
2299
2247
  </div>
2300
- <p>请在录制流程中添加或执行“启动 APP”节点;Activity 匹配后会自动解除编辑锁定。</p>
2248
+ <p>不影响脚本编辑;如需录制当前页面的组件操作,请确认设备页面及所选组件。</p>
2301
2249
  </div>
2302
2250
  </details>
2303
2251
  </section>
@@ -2309,9 +2257,8 @@ watch(
2309
2257
  :steps="steps"
2310
2258
  :clipboard-count="flowClipboardCount"
2311
2259
  :disabled="recordingLocked"
2312
- :remove-disabled="recordingBusy"
2260
+ :remove-disabled="recordingLocked"
2313
2261
  :merge-disabled="recordingBusy || replaying || saving"
2314
- :allowed-locked-actions="scriptActivityMismatch && !recordingBusy ? insertableRecorderActions : []"
2315
2262
  :launching-step-id="executingAppStepId"
2316
2263
  @remove="removeStep"
2317
2264
  @copy="copyFlowNodes"
@@ -2424,16 +2371,11 @@ watch(
2424
2371
  @cancel="newScriptDialogVisible = false"
2425
2372
  />
2426
2373
 
2427
- <AiRecognitionTestDialog
2428
- v-if="aiRecognitionTestStep"
2429
- :step="aiRecognitionTestStep"
2430
- :device-id="selectedDeviceId"
2431
- :model-configured="Boolean(aiRecognitionModelConfigured)"
2432
- @close="aiRecognitionTestStep = null"
2433
- />
2434
2374
 
2435
2375
  <ImageCheckDialog
2436
2376
  v-if="imageCheckDraft?.imageCheck"
2377
+ :timeout-branch="imageCheckDraft.timeoutBranch"
2378
+ @timeout-branch="imageCheckDraft = { ...imageCheckDraft!, timeoutBranch: $event }"
2437
2379
  :config="imageCheckDraft.imageCheck" :device-id="selectedDeviceId"
2438
2380
  :has-element="Boolean(selectedNode?.bounds)" :editing="imageCheckEditing" :picking="imageCheckPicking"
2439
2381
  @update="updateImageCheck" @close="imageCheckDraft = null; imageCheckPicking = false"
@@ -2624,4 +2566,6 @@ watch(
2624
2566
  .appium-workbench__flow :deep(.appium-recorded-steps-panel) { display: flex; flex-direction: column; flex: 1; min-height: 0; }
2625
2567
  .appium-workbench__flow :deep(.appium-flow-toolbar) { flex: none; margin-bottom: 8px; }
2626
2568
  .appium-workbench__flow :deep(.appium-flow-canvas--vue:not(.appium-flow-canvas--dialog)) { flex: 1; height: auto; min-height: 120px; }
2569
+ .replay-button-group { display: inline-flex; flex-shrink: 0; }
2570
+ .replay-settings-toggle { padding-inline: 8px; }
2627
2571
  </style>
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, onMounted, ref } from 'vue';
3
- import { Delete, Refresh } from '@element-plus/icons-vue';
3
+ import { Delete, Refresh, VideoPlay } from '@element-plus/icons-vue';
4
4
  import { ElMessage, ElMessageBox } from 'element-plus';
5
5
  import { historyStatistics, type RunSummary, type RunDetail } from './run-history';
6
6
 
@@ -16,6 +16,7 @@ const device = ref('');
16
16
  const error = ref('');
17
17
  const nodeKey = ref('');
18
18
  const logQuery = ref('');
19
+ const videoRun = ref<RunSummary>();
19
20
  const base = `/api/appium-recorder/scripts/${encodeURIComponent(props.scriptId)}/history`;
20
21
  const labels = { passed: '通过', failed: '失败', stopped: '已终止' };
21
22
  const versions = computed(() => [...new Set(runs.value.map(run => run.appVersion || '未知'))]);
@@ -49,7 +50,7 @@ async function compare() {
49
50
  finally { comparing.value = false; }
50
51
  }
51
52
  async function remove() {
52
- try { await ElMessageBox.confirm(`删除选中的 ${selected.value.length} 条历史及截图、日志快照?原始报告文件不受影响。`, '删除历史结果', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }); }
53
+ try { await ElMessageBox.confirm(`删除选中的 ${selected.value.length} 条历史及截图、日志快照和回放视频?原始报告将无法再播放已删除的视频。`, '删除历史结果', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }); }
53
54
  catch { return; }
54
55
  loading.value = true;
55
56
  try {
@@ -98,6 +99,7 @@ onMounted(load);
98
99
  <el-table-column prop="deviceId" label="设备" min-width="140" />
99
100
  <el-table-column label="结果" width="85"><template #default="{ row }">{{ labels[row.status as keyof typeof labels] }}</template></el-table-column>
100
101
  <el-table-column label="耗时" width="100"><template #default="{ row }">{{ seconds(row.durationMs) }}</template></el-table-column>
102
+ <el-table-column label="录像" width="70"><template #default="{ row }"><el-tooltip v-if="row.video" content="播放回放视频"><el-button :icon="VideoPlay" aria-label="播放回放视频" @click="videoRun = row" /></el-tooltip></template></el-table-column>
101
103
  </el-table>
102
104
  <h3>失败节点分布</h3>
103
105
  <el-table :data="stats.failures" max-height="220" empty-text="暂无节点失败记录">
@@ -129,6 +131,9 @@ onMounted(load);
129
131
  </div>
130
132
  </section>
131
133
  </div>
134
+ <el-dialog :model-value="Boolean(videoRun)" title="回放视频" width="min(800px, 90vw)" align-center append-to-body destroy-on-close @close="videoRun = undefined">
135
+ <video v-if="videoRun" :src="`${base}/${encodeURIComponent(videoRun.id)}/video`" controls preload="metadata" style="width:100%;max-height:70vh" />
136
+ </el-dialog>
132
137
  </el-dialog>
133
138
  </template>
134
139
 
@@ -26,7 +26,7 @@ export const actionDescriptions: Record<InsertAction, string> = {
26
26
  checkboxState: '读取原生 Checkbox 的 checked 状态,按 true/false 分支执行。',
27
27
  radioButtonState: '读取原生 RadioButton 的 checked 状态,按 true/false 分支执行。',
28
28
  checkedState: '读取 Checkbox、RadioButton 或 Switch 的真实勾选状态,按 true/false 分支执行。不支持仅用图片表示状态的普通 ImageView。',
29
- aiRecognition: '用配置的模型判断当前画面,返回 true/false。适合语义、提示含义和复杂画面判断;可直接读取的组件属性优先使用原生判断。需要配置 AI 节点模型。',
29
+ aiRecognition: '此操作已移除,请使用图像判断或原生组件判断。',
30
30
  textClick: '在组件树中按指定文字精确或模糊匹配并点击,进入匹配到文字或未匹配到文字分支。不识别图片或自绘画面中的文字。',
31
31
  tapIfExists: '找到目标组件时点击,未找到则跳过并继续后续流程。',
32
32
  inputIfExists: '找到目标输入组件时写入文字,未找到则跳过。支持 {{变量名}} 引用。',
@@ -1,6 +1,5 @@
1
1
  import { APP_BASE } from '../api';
2
2
  import type { AppiumRecordedScript, AppiumRecordedStep } from './types';
3
- import type { AiRecognitionResult } from './ai-recognition';
4
3
  import type { TestVariable } from './variables';
5
4
  import type { AppiumVisualChangeRegion } from './types';
6
5
 
@@ -73,13 +72,6 @@ export async function getAppiumScripts() {
73
72
  return readJson<{ scripts: AppiumRecordedScript[] }>(response);
74
73
  }
75
74
 
76
- export async function testAiRecognition(input: { deviceId: string; prompt: string; timeoutMs?: number }, signal?: AbortSignal) {
77
- const response = await fetch(`${APP_BASE}/api/appium-recorder/ai-recognition/test`, {
78
- method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), signal,
79
- });
80
- return readJson<AiRecognitionResult & { imageBase64: string }>(response);
81
- }
82
-
83
75
  export function saveAppiumScript(input: {
84
76
  variables?: import('./variables').TestVariable[];
85
77
  id?: string;
@@ -115,6 +107,7 @@ export async function downloadAppiumScript(id: string) {
115
107
  }
116
108
 
117
109
  type ReplayResult = {
110
+ videoPath?: string;
118
111
  success: boolean;
119
112
  stopped?: boolean;
120
113
  output: string;
@@ -131,7 +124,7 @@ type ReplayStreamEvent =
131
124
  | { type: 'error'; message: string };
132
125
 
133
126
  export async function replayAppiumScript(
134
- input: { id: string; deviceId?: string; parameters?: import('./variables').TestVariable[] },
127
+ input: { id: string; deviceId?: string; recordVideo?: boolean; parameters?: import('./variables').TestVariable[] },
135
128
  onOutput?: (line: string) => void,
136
129
  ) {
137
130
  const response = await fetch(`${APP_BASE}/api/appium-recorder/scripts/${encodeURIComponent(input.id)}/replay`, {
@@ -140,7 +133,7 @@ export async function replayAppiumScript(
140
133
  'Content-Type': 'application/json',
141
134
  Accept: 'application/x-ndjson',
142
135
  },
143
- body: JSON.stringify({ deviceId: input.deviceId, parameters: input.parameters }),
136
+ body: JSON.stringify({ deviceId: input.deviceId, parameters: input.parameters, recordVideo: input.recordVideo }),
144
137
  });
145
138
  if (!response.ok || !response.body) return readJson<ReplayResult>(response);
146
139
 
@@ -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>
@@ -2,7 +2,6 @@
2
2
  import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue';
3
3
  import { Handle, Position } from '@vue-flow/core';
4
4
  import { CopyDocument, Delete, Edit, Plus, VideoPlay, View, WarningFilled, Connection, RefreshLeft } from '@element-plus/icons-vue';
5
- import { AI_MODEL_CONFIG_HINT } from '../ai-recognition';
6
5
  import type { AppiumRecordedStep } from '../types';
7
6
  import {
8
7
  PASTE_COMMAND,
@@ -176,8 +175,8 @@ watch(() => props.data, () => {
176
175
  @click="handleStepClick"
177
176
  >
178
177
  <span class="appium-flow-step-card__content">
179
- <el-tooltip v-if="data.missingAiModel" :content="AI_MODEL_CONFIG_HINT" placement="top">
180
- <el-icon class="appium-ai-model-warning" color="var(--el-color-danger)" role="img" aria-label="AI 识别模型未配置" tabindex="0">
178
+ <el-tooltip v-if="data.missingAiModel" content="AI 识别操作已移除,请删除此节点并改用图像判断或原生组件判断" placement="top">
179
+ <el-icon class="appium-ai-model-warning" color="var(--el-color-danger)" role="img" aria-label="AI 识别操作已移除" tabindex="0">
181
180
  <WarningFilled />
182
181
  </el-icon>
183
182
  </el-tooltip>
@@ -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 包名'">
@@ -206,12 +197,6 @@ function patchSelector(patch: Partial<AppiumSelector>) {
206
197
  <LoopSettings v-if="step.type === 'loop'" :step="step" :disabled="disabled" @update="patchStep" />
207
198
  <BreakLoopSettings v-if="step.type === 'breakLoop'" :step="step" :steps="steps" :disabled="disabled" @update="patchStep" />
208
199
  <TextClickSettings v-if="step.type === 'textClick'" :step="step" :disabled="disabled" @update="patchStep" />
209
- <el-form-item v-if="step.type === 'aiRecognition'" label="识别内容">
210
- <el-input
211
- :model-value="step.value || ''" type="textarea" :rows="3" maxlength="4000"
212
- :disabled="disabled" @update:model-value="patchStep({ value: String($event) })"
213
- />
214
- </el-form-item>
215
200
  <el-form-item
216
201
  v-if="step.type === 'input' || step.type === 'inputIfExists' || step.type === 'assertText'"
217
202
  label="文本内容"
@@ -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));
@@ -16,7 +18,7 @@ const slots = computed(() => props.config.mode === 'state'
16
18
  ? [{ key: 'template' as const, label: '选中模板' }, { key: 'negativeTemplate' as const, label: '未选中模板' }]
17
19
  : props.config.mode === 'template' ? [{ key: 'template' as const, label: '模板' }] : []);
18
20
  const numericFields = computed(() => [
19
- ...(['template', 'state'].includes(props.config.mode) ? [{ key: 'threshold' as const, label: '匹配得分阈值', min: 0.01, max: 1, step: 0.01 }] : []),
21
+ ...(['template', 'state'].includes(props.config.mode) ? [{ key: 'threshold' as const, label: '匹配严格度 (%)', min: 1, max: 100, step: 1 }] : []),
20
22
  ...(props.config.mode === 'state' ? [{ key: 'minScoreGap' as const, label: '两种状态最小得分差', min: 0.001, max: 1, step: 0.01 }] : []),
21
23
  ...(['black', 'color', 'change'].includes(props.config.mode) ? [
22
24
  { key: 'tolerance' as const, label: props.config.mode === 'black' ? '暗色亮度阈值 (0–255)' : 'RGB 通道容差 (0–255)', min: 0, max: 255, step: 1 },
@@ -108,10 +110,10 @@ async function upload(event: Event) {
108
110
  <el-alert v-if="regionIssue" :title="regionIssue" type="warning" show-icon :closable="false" class="image-check-region-warning">
109
111
  <el-button v-if="expandedRegion" size="small" @click="patch({ region: expandedRegion })">扩大检测区域</el-button>
110
112
  </el-alert>
111
- <el-form-item v-if="config.mode === 'template' || config.mode === 'change'" label="判断条件">
113
+ <el-form-item v-if="config.mode === 'template' || config.mode === 'change'" :label="config.mode === 'template' ? '预期匹配结果' : '判断条件'">
112
114
  <el-radio-group :model-value="config.expectation" @update:model-value="patch({ expectation: $event as 'present' | 'absent' })">
113
- <el-radio-button value="present">{{ config.mode === 'template' ? '图片存在' : '画面有变化' }}</el-radio-button>
114
- <el-radio-button value="absent">{{ config.mode === 'template' ? '图片不存在' : '持续无明显变化' }}</el-radio-button>
115
+ <el-radio-button value="present">{{ config.mode === 'template' ? '匹配到模板' : '画面有变化' }}</el-radio-button>
116
+ <el-radio-button value="absent">{{ config.mode === 'template' ? '未匹配到模板' : '持续无明显变化' }}</el-radio-button>
115
117
  </el-radio-group>
116
118
  </el-form-item>
117
119
  <el-form-item v-if="config.mode === 'color'" label="目标颜色 #RRGGBB">
@@ -119,10 +121,29 @@ 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
+ <template v-if="field.key === 'threshold'" #label>
127
+ <span class="image-check-threshold-label">{{ field.label }}
128
+ <el-tooltip effect="dark" placement="top" :show-after="200">
129
+ <template #content>
130
+ <div class="image-check-threshold-help">
131
+ 当前画面与参考模板的匹配得分达到此值,才算匹配成功,得分不是正确概率。
132
+ 数值越高越严格,可能漏掉有细微变化的目标;越低越宽松,可能误认相似图案。
133
+ 例如设为 98%,需要匹配得分达到 98%。设为 100% 时,极细微的像素差异也会判为不匹配。图片状态判断还会结合两种模板的最小得分差,得分太接近时无法判定。
134
+ </div>
135
+ </template>
136
+ <el-button class="image-check-threshold-button" :icon="QuestionFilled" text aria-label="匹配严格度说明" @click.prevent />
137
+ </el-tooltip>
138
+ </span>
139
+ </template>
140
+ <el-input-number :model-value="field.key === 'threshold' ? Number((config.threshold * 100).toFixed(8)) : config[field.key]" :min="field.min" :max="field.max" :step="field.step" controls-position="right" @update:model-value="$event !== undefined && patch({ [field.key]: field.key === 'threshold' ? $event / 100 : $event })" />
141
+ <el-alert v-if="field.key === 'threshold' && config.threshold === 1" title="100% 要求完全匹配,极细微的像素差异也会判为不匹配。" type="warning" :closable="false" show-icon class="image-check-strict-warning" />
142
+ </el-form-item>
143
+ <BranchTimeoutSettings v-if="field.key === 'durationMs'" style="grid-column: 1 / -1" :step="{ id: '', label: '', type: 'imageCheck', timeoutBranch }" :disabled="busy" @update="emit('timeoutBranch', $event)" />
144
+ </template>
125
145
  </div>
146
+ <BranchTimeoutSettings v-if="config.mode === 'state'" :step="{ id: '', label: '', type: 'imageCheck', timeoutBranch }" :disabled="busy" @update="emit('timeoutBranch', $event)" />
126
147
  </el-form>
127
148
  <template #footer><el-button :disabled="busy" @click="emit('close')">取消</el-button><el-button type="primary" :disabled="busy" @click="confirm">{{ editing ? '保存' : '添加' }}</el-button></template>
128
149
  </el-dialog>
@@ -135,9 +156,17 @@ async function upload(event: Event) {
135
156
  .image-check-dialog .el-input-number { width: 100%; }
136
157
  .image-check-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 12px; }
137
158
  .image-check-help { margin-left: 8px; color: #606266; }
159
+ .image-check-grid .el-form-item__label { display: flex; align-items: center; min-height: 20px; }
160
+ .image-check-threshold-label { display: inline-flex; align-items: center; gap: 4px; }
161
+ .image-check-dialog .el-button.image-check-threshold-button { flex: 0 0 20px; width: 20px; height: 20px; min-height: 20px; padding: 0; font-size: 14px; }
162
+ .image-check-threshold-help { max-width: min(300px, calc(100vw - 40px)); font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; }
163
+ .image-check-strict-warning { margin-top: 6px; }
138
164
  .image-check-template { display: grid; gap: 6px; margin-bottom: 16px; min-width: 0; }
139
165
  .image-check-size { color: #606266; font-size: 12px; }
140
166
  .image-check-region-warning { margin-bottom: 16px; }
141
167
  .image-check-template .el-image, .image-check-empty { height: 96px; width: 100%; background: #eff5f3; border: 1px solid #d5dfdb; border-radius: 4px; }
142
168
  .image-check-empty { display: grid; place-items: center; color: #606266; }
169
+ @media (max-width: 480px) {
170
+ .image-check-grid { grid-template-columns: minmax(0, 1fr); }
171
+ }
143
172
  </style>
@@ -119,7 +119,6 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
119
119
  actions: [
120
120
  { type: 'delay', label: '添加延时' },
121
121
  { type: 'popupCondition', label: '判断存在' },
122
- { type: 'aiRecognition', label: 'AI 识别' },
123
122
  { type: 'imageCheck', label: '图像判断' },
124
123
  { type: 'tapIfExists', label: '存在则点击' },
125
124
  { type: 'inputIfExists', label: '存在则输入' },
@@ -89,7 +89,6 @@ const insertActionGroups: FlowActionGroup[] = [
89
89
  actions: [
90
90
  { type: 'delay', label: '添加延时' },
91
91
  { type: 'popupCondition', label: '判断存在' },
92
- { type: 'aiRecognition', label: 'AI 识别' },
93
92
  { type: 'imageCheck', label: '图像判断' },
94
93
  { type: 'tapIfExists', label: '存在则点击' },
95
94
  { type: 'inputIfExists', label: '存在则输入' },
@@ -460,8 +460,8 @@ export function buildFlowGraph(
460
460
  && item.step.type !== 'clearAppData'
461
461
  && item.step.visualChange?.role !== 'end',
462
462
  canEditInput: ['input', 'inputIfExists', 'imageCheck', 'runScript'].includes(item.step.type),
463
- canExecute: item.step.type === 'launchApp' || item.step.type === 'clearAppData' || item.step.type === 'aiRecognition',
464
- missingAiModel: item.step.type === 'aiRecognition' && !options.aiRecognitionModelConfigured,
463
+ canExecute: item.step.type === 'launchApp' || item.step.type === 'clearAppData',
464
+ missingAiModel: item.step.type === 'aiRecognition',
465
465
  },
466
466
  });
467
467
  };