android-midscene-automation 0.1.42 → 0.1.43

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 (51) hide show
  1. package/AI_RECOGNITION_GUIDE.md +370 -0
  2. package/CHANGELOG.md +7 -2
  3. package/README.md +24 -5
  4. package/USAGE.md +16 -3
  5. package/package.json +1 -1
  6. package/server/appium-auto-save.test.ts +2 -1
  7. package/server/appium-config.test.ts +48 -1
  8. package/server/appium-recorder/appium-runner.ts +21 -1
  9. package/server/appium-recorder/bounded-loop.test.ts +55 -0
  10. package/server/appium-recorder/bounded-loop.ts +12 -1
  11. package/server/appium-recorder/prompt-optimizer.ts +57 -0
  12. package/server/appium-recorder/report-summary.test.ts +78 -0
  13. package/server/appium-recorder/report-summary.ts +60 -0
  14. package/server/appium-recorder/report.ts +31 -2
  15. package/server/appium-recorder/repository.ts +2 -0
  16. package/server/appium-recorder/routes.ts +53 -3
  17. package/server/config.ts +17 -2
  18. package/server/http-api.ts +8 -4
  19. package/src/App.vue +16 -5
  20. package/src/api.ts +2 -2
  21. package/src/appium-recorder/AppiumPage.vue +126 -62
  22. package/src/appium-recorder/action-descriptions.ts +1 -0
  23. package/src/appium-recorder/api.ts +22 -2
  24. package/src/appium-recorder/bounded-loop.ts +9 -1
  25. package/src/appium-recorder/components/AiRecognitionSettings.vue +30 -6
  26. package/src/appium-recorder/components/BreakLoopSettings.vue +19 -3
  27. package/src/appium-recorder/components/ComponentTree.vue +5 -5
  28. package/src/appium-recorder/components/CoordinatePointPicker.vue +23 -0
  29. package/src/appium-recorder/components/FlowActionMenu.vue +50 -20
  30. package/src/appium-recorder/components/FlowStepEditor.vue +10 -2
  31. package/src/appium-recorder/components/NestedConditionBranches.vue +18 -49
  32. package/src/appium-recorder/components/RecordedSteps.vue +16 -5
  33. package/src/appium-recorder/coordinate-preview.ts +37 -0
  34. package/src/appium-recorder/flow-copy.ts +1 -0
  35. package/src/appium-recorder/flow-graph.ts +5 -2
  36. package/src/appium-recorder/flow-labels.ts +3 -1
  37. package/src/appium-recorder/flow-merge.ts +1 -1
  38. package/src/appium-recorder/flow-unmerge.ts +2 -2
  39. package/src/appium-recorder/report-summary.ts +60 -0
  40. package/src/appium-recorder/types.ts +2 -0
  41. package/src/components/config/AiDeduplicationSettings.vue +5 -4
  42. package/src/components/config/AiPromptPresetsSettings.vue +9 -12
  43. package/src/components/config/AppiumConfigPanel.vue +66 -38
  44. package/src/components/config/AppiumModelSettings.vue +45 -0
  45. package/src/components/config/FlowAppearanceSettings.vue +0 -1
  46. package/src/components/config/ReplayReportSummarySettings.vue +118 -0
  47. package/src/components/device/DevicePreviewPanel.vue +18 -3
  48. package/src/config/use-appium-auto-save.ts +12 -4
  49. package/src/pages/ConfigPage.vue +4 -2
  50. package/src/style.css +0 -45
  51. package/src/types.ts +2 -1
package/src/api.ts CHANGED
@@ -115,14 +115,14 @@ export function saveConfig(input: Pick<ConfigForm, 'runtime'>) {
115
115
  return postJson<{ success?: boolean } | ConfigForm>(`${APP_BASE}/api/config`, input);
116
116
  }
117
117
 
118
- export function saveAppiumConfig(appium: Omit<ConfigForm['appium'], 'model'>) {
118
+ export function saveAppiumConfig(appium: Omit<ConfigForm['appium'], 'model' | 'promptOptimizer'>) {
119
119
  return postJson<{ success: boolean }>(`${APP_BASE}/api/config/appium`, { appium });
120
120
  }
121
121
 
122
122
  // 测试指定模型配置是否可用,通常用于保存前验证 baseUrl/apiKey/model。
123
123
  export function testModel(input: {
124
124
  save?: boolean;
125
- modelKey: 'midscene' | 'scriptOptimizer' | 'appium';
125
+ modelKey: 'midscene' | 'scriptOptimizer' | 'appium' | 'promptOptimizer';
126
126
  model: {
127
127
  provider?: MidsceneModelProvider;
128
128
  baseUrl: string;
@@ -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, flowLineColorKey, normalizeFlowLineColor } from './flow-appearance';
4
+ import { useCoordinatePicker } from './coordinate-preview';
4
5
  import { DEFAULT_NODE_TIMEOUT_MS } from './node-timeout';
5
6
  import { aiPromptPresetsKey, DEFAULT_AI_PROMPT_PRESETS, type AiPromptPreset } from './ai-prompt-presets';
6
7
  import AiRecognitionPromptInput from './components/AiRecognitionPromptInput.vue';
@@ -17,9 +18,10 @@ import { insertBeforeSharedStep } from './flow-insert';
17
18
  import VariableExtractionSettings from './components/VariableExtractionSettings.vue';
18
19
  import ScriptParameterSettings from './components/ScriptParameterSettings.vue';
19
20
  import { validateVariables, validateExtraction, validateReturns, type TestVariable } from './variables';
20
- import { defaultLoopConfig, enclosingLoops, breakLoopTarget, validateLoop, validateLoopSteps } from './bounded-loop';
21
+ import { breakLoopTarget, continueLoopTarget, defaultLoopConfig, enclosingLoops, validateLoop, validateLoopSteps } from './bounded-loop';
21
22
  import {
22
23
  clearAppiumDeviceAppData,
24
+ checkReportSummaryModel,
23
25
  deleteAppiumScript,
24
26
  downloadAppiumScript,
25
27
  getAppiumScripts,
@@ -46,6 +48,7 @@ import VisualChangeDialog from './components/VisualChangeDialog.vue';
46
48
  import ImageCheckDialog from './components/ImageCheckDialog.vue';
47
49
  import AiRecognitionTestDialog from './components/AiRecognitionTestDialog.vue';
48
50
  import { validateAiRecognitionPrompt } from './ai-recognition';
51
+ import { DEFAULT_REPORT_SUMMARY_PROMPT, REPORT_SUMMARY_PROMPT_PRESETS, type ReportSummaryConfig } from './report-summary';
49
52
  import { createImageCheckConfig, validateImageCheck, type ImageCheckConfig } from './image-check';
50
53
  import {
51
54
  createFlowClipboard,
@@ -101,10 +104,12 @@ const AUTO_TREE_REFRESH_INTERVAL_MS = 1800;
101
104
  const readonlyFlowActionGroups: FlowActionGroup[] = [];
102
105
  const readonlyFlowSelectedIndexes: number[] = [];
103
106
  const recordReplayVideo = shallowRef(false);
107
+ const selectedReportTemplateName = shallowRef('');
104
108
 
105
109
  const props = defineProps<{
106
110
  aiRecognitionModelConfigured?: boolean;
107
111
  aiPromptPresets?: AiPromptPreset[];
112
+ reportSummary?: ReportSummaryConfig;
108
113
  flowBackgroundColor?: string;
109
114
  flowLineColor?: string;
110
115
  active: boolean;
@@ -134,6 +139,11 @@ const props = defineProps<{
134
139
  provide(flowBackgroundKey, computed(() => normalizeFlowBackground(props.flowBackgroundColor)));
135
140
  provide(flowLineColorKey, computed(() => normalizeFlowLineColor(props.flowLineColor)));
136
141
  const availableAiPromptPresets = computed(() => props.aiPromptPresets ?? DEFAULT_AI_PROMPT_PRESETS);
142
+ const availableReportTemplates = computed(() => [
143
+ ...REPORT_SUMMARY_PROMPT_PRESETS,
144
+ ...(props.reportSummary?.customPresets || []),
145
+ ]);
146
+ const selectedReportTemplate = computed(() => availableReportTemplates.value.find(item => item.name === selectedReportTemplateName.value));
137
147
  provide(aiPromptPresetsKey, availableAiPromptPresets);
138
148
  const selectedDeviceId = computed(() => props.playgroundDeviceId);
139
149
  const tree = shallowRef<AppiumNode | null>(null);
@@ -180,6 +190,7 @@ const duplicatingScriptId = shallowRef('');
180
190
  const renamingScriptId = shallowRef('');
181
191
  const downloadingScriptId = shallowRef('');
182
192
  const replaying = shallowRef(false);
193
+ const preparingReplay = shallowRef(false);
183
194
  const stoppingReplay = shallowRef(false);
184
195
  const activeReplayDeviceId = shallowRef('');
185
196
  const recordingTap = shallowRef(false);
@@ -241,16 +252,9 @@ const linkedScriptTarget = computed(() => scripts.value.find((script) => script.
241
252
  const linkedScriptPreviewScript = computed(() => (
242
253
  scripts.value.find((script) => script.id === linkedScriptPreviewScriptId.value) || null
243
254
  ));
244
- const compatibleLinkableScripts = computed(() => linkableScripts.value.filter((script) => (
245
- script.appPackage === form.appPackage
246
- && (
247
- Boolean(linkedScriptBranchTarget.value)
248
- || (
249
- Boolean(linkedScriptExpectedActivity.value)
250
- && script.appActivity === linkedScriptExpectedActivity.value
251
- )
252
- )
253
- )));
255
+ const compatibleLinkableScripts = computed(() => linkableScripts.value.filter(
256
+ (script) => script.appPackage === form.appPackage,
257
+ ));
254
258
  const hasSelectedAppPackage = computed(() => props.appPresets.some((app) => app.packageName === form.appPackage));
255
259
  const scriptActivityMismatch = computed(() => {
256
260
  const expectedActivity = selectedScript.value?.appActivity?.trim();
@@ -262,8 +266,8 @@ const recordingBusy = computed(() => (
262
266
  || resolvingNavigation.value
263
267
  || Boolean(pendingNavigation.value)
264
268
  ));
265
- const recordingLocked = computed(() => recordingBusy.value || replaying.value);
266
- const newScriptDisabled = computed(() => saving.value || replaying.value || recordingBusy.value || launchingApp.value || importingScript.value);
269
+ const recordingLocked = computed(() => recordingBusy.value || replaying.value || preparingReplay.value);
270
+ const newScriptDisabled = computed(() => saving.value || replaying.value || preparingReplay.value || recordingBusy.value || launchingApp.value || importingScript.value);
267
271
  const overlayBounds = computed(() => flattenNodes(tree.value).flatMap((node) => (
268
272
  node.bounds ? [{ id: node.id, ...node.bounds }] : []
269
273
  )));
@@ -280,6 +284,13 @@ const visualChangeRegionSelectionEnabled = computed(() => (
280
284
  (visualChangeDialogVisible.value && visualChangeConfig.value.mode === 'region') || imageCheckDraft.value?.imageCheck?.target === 'region'
281
285
  ));
282
286
 
287
+ const coordinatePicker = useCoordinatePicker(
288
+ computed(() => Boolean(selectedDeviceId.value) && !recordingLocked.value && !visualChangeRegionSelectionEnabled.value),
289
+ () => workspaceRef.value?.showPreview(),
290
+ );
291
+ const pickingCoordinate = coordinatePicker.picking;
292
+ watch([selectedDeviceId, () => props.active], () => coordinatePicker.cancel());
293
+
283
294
  function stepWithBranchTarget(step: AppiumRecordedStep, branchTarget: BranchTarget) {
284
295
  return {
285
296
  ...step,
@@ -955,6 +966,10 @@ async function executeFlowStep(index: number) {
955
966
  }
956
967
 
957
968
  function selectNodeFromPoint(point: { x: number; y: number }) {
969
+ if (pickingCoordinate.value) {
970
+ coordinatePicker.select(point);
971
+ return;
972
+ }
958
973
  const node = findSmallestNodeAtPoint(tree.value, point.x, point.y);
959
974
  if (node) {
960
975
  selectedNode.value = node;
@@ -1144,16 +1159,8 @@ function openLinkedScriptDialog(index?: number, branchTarget?: BranchTarget) {
1144
1159
  }
1145
1160
  linkedScriptBranchTarget.value = branchTarget || null;
1146
1161
  linkedScriptExpectedActivity.value = expectedActivityAfterStep(index);
1147
- if (!linkedScriptBranchTarget.value && !linkedScriptExpectedActivity.value) {
1148
- ElMessage.warning('无法确定当前插入点的 Activity,请先刷新组件树');
1149
- return;
1150
- }
1151
1162
  if (!compatibleLinkableScripts.value.length) {
1152
- ElMessage.warning(
1153
- linkedScriptBranchTarget.value
1154
- ? '没有属于当前 App 的可连接脚本'
1155
- : `没有入口 Activity 为 ${linkedScriptExpectedActivity.value} 的可连接脚本`,
1156
- );
1163
+ ElMessage.warning('没有属于当前 App 的可连接脚本');
1157
1164
  linkedScriptExpectedActivity.value = '';
1158
1165
  linkedScriptBranchTarget.value = null;
1159
1166
  return;
@@ -1177,16 +1184,6 @@ function addLinkedScriptStep() {
1177
1184
  ElMessage.error('连接脚本必须属于当前 App');
1178
1185
  return;
1179
1186
  }
1180
- if (
1181
- !linkedScriptBranchTarget.value
1182
- && (!linkedScriptExpectedActivity.value || script.appActivity !== linkedScriptExpectedActivity.value)
1183
- ) {
1184
- ElMessage.error(
1185
- `无法连接:插入点 Activity 为 ${linkedScriptExpectedActivity.value || '-'},`
1186
- + `目标脚本入口 Activity 为 ${script.appActivity || '-'}`,
1187
- );
1188
- return;
1189
- }
1190
1187
  const editingIndex = linkedScriptEditingId.value ? steps.value.findIndex(step => step.id === linkedScriptEditingId.value) : -1;
1191
1188
  if (linkedScriptEditingId.value && editingIndex < 0) { ElMessage.warning('原节点已删除,请重新打开'); return; }
1192
1189
  if (editingIndex >= 0) {
@@ -1388,6 +1385,31 @@ async function addAction(
1388
1385
  if (result) return insertStep({ ...step, breakLoopTargetId: scoped.breakLoopTargetId }, index, branchTarget);
1389
1386
  return;
1390
1387
  }
1388
+ if (action === 'continueLoop') {
1389
+ const step: AppiumRecordedStep = { id: createStepId(), type: 'continueLoop', label: '继续下一次循环', flow: { nodeKind: 'action' } };
1390
+ const scoped = reactive(branchTarget ? stepWithBranchTarget(step, branchTarget) : step);
1391
+ const loops = enclosingLoops(steps.value, scoped);
1392
+ if (!loops.length) { ElMessage.warning('继续下一次循环只能添加在循环体内'); return; }
1393
+ scoped.continueLoopTargetId = loops[0]!.id;
1394
+ const result = await ElMessageBox({
1395
+ title: '添加继续下一次循环',
1396
+ message: h(ElForm, { labelPosition: 'top' }, () => h(BreakLoopSettings, {
1397
+ step: scoped,
1398
+ steps: steps.value,
1399
+ mode: 'continue',
1400
+ onUpdate: (patch) => Object.assign(scoped, patch),
1401
+ })),
1402
+ showCancelButton: true, confirmButtonText: '添加', cancelButtonText: '取消',
1403
+ beforeClose: (action, _instance, done) => {
1404
+ if (action === 'confirm') {
1405
+ try { continueLoopTarget(steps.value, scoped); } catch (error) { ElMessage.warning((error as Error).message); return; }
1406
+ }
1407
+ done();
1408
+ },
1409
+ }).catch(() => null);
1410
+ if (result) return insertStep({ ...step, continueLoopTargetId: scoped.continueLoopTargetId }, index, branchTarget);
1411
+ return;
1412
+ }
1391
1413
  if (action === 'imageCheck') {
1392
1414
  imageCheckIndex = index;
1393
1415
  imageCheckBranch = branchTarget;
@@ -1714,6 +1736,14 @@ async function addBranchAction(index: number, branch: BranchName, action: Record
1714
1736
  applyBranchTargetToInsertedStep(inserted, branchTarget);
1715
1737
  }
1716
1738
 
1739
+ function continueOnActivityChange() {
1740
+ if (!pendingNavigation.value || resolvingNavigation.value) return;
1741
+ // 跨页面继续录制时,入口仍是首次录制页面,不能改成跳转后的页面。
1742
+ form.appActivity ||= pendingNavigation.value.beforeActivity;
1743
+ pendingNavigation.value = null;
1744
+ ElMessage.success('已继续录制,后续操作将加入当前脚本');
1745
+ }
1746
+
1717
1747
  async function saveOnActivityChange() {
1718
1748
  const pending = pendingNavigation.value;
1719
1749
  if (!pending || resolvingNavigation.value) return;
@@ -1726,7 +1756,7 @@ async function saveOnActivityChange() {
1726
1756
  id: selectedScriptId.value || undefined,
1727
1757
  name: scriptName,
1728
1758
  appPackage: form.appPackage,
1729
- appActivity: pending.beforeActivity || form.appActivity,
1759
+ appActivity: form.appActivity || pending.beforeActivity,
1730
1760
  deviceId: selectedDeviceId.value,
1731
1761
  steps: steps.value,
1732
1762
  variables: scriptVariables.value,
@@ -2075,6 +2105,7 @@ async function saveScript() {
2075
2105
  }
2076
2106
 
2077
2107
  async function replayScript() {
2108
+ if (preparingReplay.value || replaying.value) return;
2078
2109
  if (!selectedScript.value) {
2079
2110
  ElMessage.warning('请选择已保存脚本');
2080
2111
  return;
@@ -2083,6 +2114,32 @@ async function replayScript() {
2083
2114
  const saved = await saveScript();
2084
2115
  if (!saved || !selectedScript.value) return;
2085
2116
  }
2117
+ let reportSummaryEnabled = false;
2118
+ let reportSummaryPrompt: string | undefined;
2119
+ if (props.reportSummary?.enabled) {
2120
+ preparingReplay.value = true;
2121
+ try {
2122
+ const status = await checkReportSummaryModel();
2123
+ if (status.available) {
2124
+ reportSummaryEnabled = true;
2125
+ reportSummaryPrompt = selectedReportTemplate.value?.prompt || DEFAULT_REPORT_SUMMARY_PROMPT;
2126
+ } else {
2127
+ await ElMessageBox.alert(
2128
+ `当前提示词优化模型不可用${status.message ? `:${status.message}` : ''}。本次将跳过 AI 总结并生成默认格式的基础报告。`,
2129
+ '回放报告总结不可用',
2130
+ { type: 'warning', confirmButtonText: '继续回放' },
2131
+ ).catch(() => {});
2132
+ }
2133
+ } catch (error) {
2134
+ await ElMessageBox.alert(
2135
+ `无法检测提示词优化模型:${error instanceof Error ? error.message : '未知错误'}。本次将跳过 AI 总结并生成默认格式的基础报告。`,
2136
+ '回放报告总结不可用',
2137
+ { type: 'warning', confirmButtonText: '继续回放' },
2138
+ ).catch(() => {});
2139
+ } finally {
2140
+ preparingReplay.value = false;
2141
+ }
2142
+ }
2086
2143
  replaying.value = true;
2087
2144
  stoppingReplay.value = false;
2088
2145
  activeReplayDeviceId.value = selectedDeviceId.value;
@@ -2090,7 +2147,13 @@ async function replayScript() {
2090
2147
  try {
2091
2148
  await pauseAutoTreeRefresh();
2092
2149
  const result = await replayAppiumScript(
2093
- { id: selectedScript.value.id, deviceId: activeReplayDeviceId.value, recordVideo: recordReplayVideo.value },
2150
+ {
2151
+ id: selectedScript.value.id,
2152
+ deviceId: activeReplayDeviceId.value,
2153
+ recordVideo: recordReplayVideo.value,
2154
+ reportSummaryEnabled,
2155
+ reportSummaryPrompt,
2156
+ },
2094
2157
  (line) => {
2095
2158
  replayOutput.value += `${replayOutput.value ? '\n' : ''}${line}`;
2096
2159
  },
@@ -2185,17 +2248,26 @@ watch(
2185
2248
  <el-button
2186
2249
  type="primary"
2187
2250
  :icon="VideoPlay"
2188
- :loading="replaying"
2251
+ :loading="replaying || preparingReplay"
2189
2252
  :disabled="!selectedScript"
2190
2253
  @click="replayScript"
2191
2254
  >
2192
2255
  回放
2193
2256
  </el-button>
2194
- <el-popover trigger="click" placement="bottom" :width="260">
2195
- <template #reference><el-button type="primary" :icon="ArrowDown" :disabled="replaying" aria-label="回放设置" title="回放设置" class="replay-settings-toggle" /></template>
2196
- <el-tooltip content="使用内置服务端后台录制,无需安装 scrcpy 或 FFmpeg。含敏感变量的运行不录制;分享 HTML 报告时需同时携带 MP4。" placement="bottom" :show-after="300">
2197
- <el-checkbox v-model="recordReplayVideo" :disabled="replaying">录制回放视频</el-checkbox>
2198
- </el-tooltip>
2257
+ <el-popover trigger="click" placement="bottom" :width="320">
2258
+ <template #reference><el-button type="primary" :icon="ArrowDown" :disabled="replaying || preparingReplay" aria-label="回放设置" title="回放设置" class="replay-settings-toggle" /></template>
2259
+ <div class="replay-settings-panel">
2260
+ <el-tooltip content="使用内置服务端后台录制,无需安装 scrcpy 或 FFmpeg。含敏感变量的运行不录制;分享 HTML 报告时需同时携带 MP4。" placement="bottom" :show-after="300">
2261
+ <el-checkbox v-model="recordReplayVideo" :disabled="replaying || preparingReplay">录制回放视频</el-checkbox>
2262
+ </el-tooltip>
2263
+ <div class="replay-report-template">
2264
+ <span>报告模板</span>
2265
+ <el-select v-model="selectedReportTemplateName" clearable :disabled="replaying || preparingReplay || !reportSummary?.enabled" placeholder="默认测试报告(未选择时使用)">
2266
+ <el-option v-for="template in availableReportTemplates" :key="template.name" :label="template.name" :value="template.name" />
2267
+ </el-select>
2268
+ <small v-if="!reportSummary?.enabled">需先在参数配置中开启回放报告总结。</small>
2269
+ </div>
2270
+ </div>
2199
2271
  </el-popover>
2200
2272
  </el-button-group>
2201
2273
  <el-button
@@ -2225,6 +2297,8 @@ watch(
2225
2297
  :selected-bounds="selectedBounds"
2226
2298
  :selected-region="visualChangeSelectedRegion"
2227
2299
  :region-selection="visualChangeRegionSelectionEnabled"
2300
+ :point-selection="pickingCoordinate"
2301
+ @cancel-point-selection="coordinatePicker.cancel()"
2228
2302
  :region-draw-mode="visualChangePicking || imageCheckPicking"
2229
2303
  :device-width="deviceWidth"
2230
2304
  :device-height="deviceHeight"
@@ -2265,23 +2339,6 @@ watch(
2265
2339
  />
2266
2340
  </el-select>
2267
2341
  </div>
2268
- <details
2269
- v-if="scriptActivityMismatch"
2270
- class="appium-activity-summary"
2271
- >
2272
- <summary>当前页面与脚本绑定的 Activity 不一致</summary>
2273
- <div class="appium-activity-lock-alert__details">
2274
- <div>
2275
- <strong>脚本绑定</strong>
2276
- <code>{{ selectedScript?.appActivity || '-' }}</code>
2277
- </div>
2278
- <div>
2279
- <strong>当前 Activity</strong>
2280
- <code>{{ currentActivity || '正在获取' }}</code>
2281
- </div>
2282
- <p>不影响脚本编辑;如需录制当前页面的组件操作,请确认设备页面及所选组件。</p>
2283
- </div>
2284
- </details>
2285
2342
  </section>
2286
2343
 
2287
2344
  <section class="appium-workbench__section appium-workbench__flow">
@@ -2437,7 +2494,7 @@ watch(
2437
2494
  :show-close="false"
2438
2495
  >
2439
2496
  <div class="appium-navigation-dialog">
2440
- <p>当前录制仅支持单个 Activity。检测到 Activity 已跳转,请先保存当前脚本。</p>
2497
+ <p>检测到 Activity 已跳转。可以继续在当前脚本中录制后续操作,或保存当前脚本并开始录制新页面。</p>
2441
2498
  <dl>
2442
2499
  <div>
2443
2500
  <dt>点击前</dt>
@@ -2451,6 +2508,7 @@ watch(
2451
2508
  </div>
2452
2509
  <template #footer>
2453
2510
  <div class="appium-navigation-footer">
2511
+ <el-button :disabled="resolvingNavigation" @click="continueOnActivityChange">继续录制</el-button>
2454
2512
  <el-button type="primary" :loading="resolvingNavigation" @click="saveOnActivityChange">保存脚本</el-button>
2455
2513
  </div>
2456
2514
  </template>
@@ -2467,7 +2525,7 @@ watch(
2467
2525
  <el-form-item :label="linkedScriptBranchTarget ? '分支录制 Activity' : '插入点 Activity'">
2468
2526
  <code class="appium-linked-script-activity">{{ linkedScriptExpectedActivity || '-' }}</code>
2469
2527
  </el-form-item>
2470
- <el-form-item :label="linkedScriptBranchTarget ? '选择同一 App 的脚本(回放时等待入口 Activity)' : '选择入口 Activity 匹配的脚本'">
2528
+ <el-form-item label="选择同一 App 的脚本">
2471
2529
  <div class="appium-linked-script-picker">
2472
2530
  <el-select
2473
2531
  v-model="linkedScriptTargetId"
@@ -2491,6 +2549,11 @@ watch(
2491
2549
  </el-button>
2492
2550
  </div>
2493
2551
  </el-form-item>
2552
+ <el-alert v-if="linkedScriptTarget" :closable="false" type="info" show-icon
2553
+ :title="linkedScriptTarget.appActivity
2554
+ ? `回放时等待进入 ${linkedScriptTarget.appActivity},请在前序步骤中完成页面跳转。`
2555
+ : '此脚本未绑定入口 Activity,将直接从当前画面执行。请确保前序步骤已进入目标页面。'"
2556
+ />
2494
2557
  <ScriptParameterSettings :step="linkedParameters" @update="Object.assign(linkedParameters, $event)" />
2495
2558
  </el-form>
2496
2559
  <template #footer>
@@ -2589,9 +2652,6 @@ watch(
2589
2652
  <style scoped>
2590
2653
  .appium-script-icon { display: inline-flex; }
2591
2654
  .appium-script-icon .el-button { width: 30px; height: 30px; min-height: 30px; padding: 0; margin: 0; }
2592
- .appium-activity-summary { margin: 8px 0; color: #946000; font-size: 12px; }
2593
- .appium-activity-summary summary { cursor: pointer; line-height: 20px; }
2594
- .appium-activity-summary[open] .appium-activity-lock-alert__details { max-height: 110px; overflow: auto; margin-top: 6px; }
2595
2655
  :deep(.appium-recorder-card--workbench > .el-card__body) { overflow: hidden; }
2596
2656
  :deep(.appium-workbench-tabs > .el-tabs__content) { overflow: hidden; }
2597
2657
  :deep(.appium-workbench-tabs .el-tab-pane) { height: 100%; overflow: auto; }
@@ -2603,4 +2663,8 @@ watch(
2603
2663
  .appium-workbench__flow :deep(.appium-flow-canvas--vue:not(.appium-flow-canvas--dialog)) { flex: 1; height: auto; min-height: 120px; }
2604
2664
  .replay-button-group { display: inline-flex; flex-shrink: 0; }
2605
2665
  .replay-settings-toggle { padding-inline: 8px; }
2666
+ .replay-settings-panel { display: grid; gap: 14px; }
2667
+ .replay-report-template { display: grid; gap: 6px; }
2668
+ .replay-report-template > span { color: var(--el-text-color-regular); font-size: 13px; }
2669
+ .replay-report-template small { color: var(--el-text-color-secondary); line-height: 1.5; }
2606
2670
  </style>
@@ -21,6 +21,7 @@ export const actionDescriptions: Record<InsertAction, string> = {
21
21
  endFlow: '立即结束当前回放流程,后续节点不再执行。',
22
22
  loop: '重复执行循环体,支持固定次数或条件退出,必须设置最大次数。',
23
23
  breakLoop: '退出指定的当前或外层循环,继续执行该循环的结束路径。',
24
+ continueLoop: '跳过当前轮剩余节点,进入指定的当前或外层循环的下一轮;达到最大次数后执行循环结束路径。',
24
25
  clearAppData: '清除目标应用数据并重置应用状态,可能删除登录信息和本地设置,仅允许在流程开始处添加。',
25
26
  popupCondition: '判断目标组件是否存在,可进一步配置指定文本及精确或模糊匹配,按结果进入是或否分支。',
26
27
  checkboxState: '读取原生 Checkbox 的 checked 状态,按 true/false 分支执行。',
@@ -17,6 +17,16 @@ export async function validateAiBranchQuestion(prompt: string, signal?: AbortSig
17
17
  }));
18
18
  }
19
19
 
20
+ export async function optimizeAppiumPrompt(input: {
21
+ kind: 'aiRecognition' | 'reportSummary';
22
+ prompt: string;
23
+ condition?: boolean;
24
+ }, signal?: AbortSignal) {
25
+ return readJson<{ prompt: string }>(await fetch(`${APP_BASE}/api/appium-recorder/prompts/optimize`, {
26
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), signal,
27
+ }));
28
+ }
29
+
20
30
  export function captureImageCheckRegion(deviceId: string, region: AppiumVisualChangeRegion, screenWidth: number, screenHeight: number) {
21
31
  return postJson<{ base64: string; screenWidth: number; screenHeight: number }>(`${APP_BASE}/api/appium-recorder/image-check/capture`, { deviceId, region, screenWidth, screenHeight });
22
32
  }
@@ -138,7 +148,7 @@ type ReplayStreamEvent =
138
148
  | { type: 'error'; message: string };
139
149
 
140
150
  export async function replayAppiumScript(
141
- input: { id: string; deviceId?: string; recordVideo?: boolean; parameters?: import('./variables').TestVariable[] },
151
+ input: { id: string; deviceId?: string; recordVideo?: boolean; reportSummaryEnabled?: boolean; reportSummaryPrompt?: string; parameters?: import('./variables').TestVariable[] },
142
152
  onOutput?: (line: string) => void,
143
153
  ) {
144
154
  const response = await fetch(`${APP_BASE}/api/appium-recorder/scripts/${encodeURIComponent(input.id)}/replay`, {
@@ -147,7 +157,13 @@ export async function replayAppiumScript(
147
157
  'Content-Type': 'application/json',
148
158
  Accept: 'application/x-ndjson',
149
159
  },
150
- body: JSON.stringify({ deviceId: input.deviceId, parameters: input.parameters, recordVideo: input.recordVideo }),
160
+ body: JSON.stringify({
161
+ deviceId: input.deviceId,
162
+ parameters: input.parameters,
163
+ recordVideo: input.recordVideo,
164
+ reportSummaryEnabled: input.reportSummaryEnabled,
165
+ reportSummaryPrompt: input.reportSummaryPrompt,
166
+ }),
151
167
  });
152
168
  if (!response.ok || !response.body) return readJson<ReplayResult>(response);
153
169
 
@@ -177,6 +193,10 @@ export async function replayAppiumScript(
177
193
  return result;
178
194
  }
179
195
 
196
+ export function checkReportSummaryModel() {
197
+ return postJson<{ enabled: boolean; available: boolean; message?: string }>(`${APP_BASE}/api/appium-recorder/report-summary/check`);
198
+ }
199
+
180
200
  export function stopAppiumReplay(deviceId?: string) {
181
201
  return postJson<{ success: boolean; stopped: boolean }>(
182
202
  `${APP_BASE}/api/appium-recorder/replay/stop`,
@@ -42,8 +42,15 @@ export function breakLoopTarget(steps: readonly AppiumRecordedStep[], step: Appi
42
42
  return target;
43
43
  }
44
44
 
45
+ export function continueLoopTarget(steps: readonly AppiumRecordedStep[], step: AppiumRecordedStep) {
46
+ const loops = enclosingLoops(steps, step);
47
+ const target = step.continueLoopTargetId ? loops.find((loop) => loop.id === step.continueLoopTargetId) : loops[0];
48
+ if (!target) throw new Error('继续循环目标必须是当前节点所属的循环体,目标可能已删除或不在当前分支');
49
+ return target;
50
+ }
51
+
45
52
  export function validateLoopSteps(steps: readonly AppiumRecordedStep[]) {
46
- if (!steps.some((step) => step.type === 'loop' || step.type === 'breakLoop')) return;
53
+ if (!steps.some((step) => ['loop', 'breakLoop', 'continueLoop'].includes(step.type))) return;
47
54
  const byId = new Map(steps.map((step) => [step.id, step]));
48
55
  if (byId.size !== steps.length) throw new Error('流程节点 ID 重复');
49
56
  for (const step of steps) {
@@ -58,6 +65,7 @@ export function validateLoopSteps(steps: readonly AppiumRecordedStep[]) {
58
65
  }
59
66
  if (step.type === 'loop') validateLoop(step);
60
67
  if (step.type === 'breakLoop') breakLoopTarget(steps, step);
68
+ if (step.type === 'continueLoop') continueLoopTarget(steps, step);
61
69
  if (step.type === 'loop' && step.flow?.yesTargetId) {
62
70
  const target = byId.get(step.flow.yesTargetId);
63
71
  if (!target || target.flow?.parentConditionId !== step.id || target.flow.parentBranch !== 'yes') throw new Error('循环入口必须指向所属循环体');
@@ -1,8 +1,8 @@
1
1
  <script setup lang="ts">
2
- import { computed, inject, onBeforeUnmount, ref, watch } from 'vue';
2
+ import { computed, inject, onBeforeUnmount, ref, shallowRef, watch } from 'vue';
3
3
  import { Loading, QuestionFilled } from '@element-plus/icons-vue';
4
4
  import { isAiBranchEnabled, validateAiRecognitionPrompt, DEFAULT_AI_OBSERVATION, validateAiObservation, type AiObservationConfig } from '../ai-recognition';
5
- import { validateAiBranchQuestion } from '../api';
5
+ import { optimizeAppiumPrompt, validateAiBranchQuestion } from '../api';
6
6
  import type { AppiumRecordedStep } from '../types';
7
7
  import { aiPromptPresetsKey, DEFAULT_AI_PROMPT_PRESETS } from '../ai-prompt-presets';
8
8
  import AiRecognitionPromptInput from './AiRecognitionPromptInput.vue';
@@ -12,14 +12,16 @@ const emit = defineEmits<{ update: [patch: Partial<AppiumRecordedStep>] }>();
12
12
  const prompt = ref(props.step.value || '');
13
13
  const promptPresets = inject(aiPromptPresetsKey, ref(DEFAULT_AI_PROMPT_PRESETS));
14
14
  const busy = ref(false);
15
+ const optimizingPrompt = shallowRef(false);
15
16
  const error = ref('');
16
17
  const branch = computed(() => isAiBranchEnabled(props.step));
17
18
  const untilMatch = computed(() => props.step.aiObservation?.mode === 'untilMatch');
18
19
  const branchChoice = ref(branch.value);
19
20
  watch(branch, value => { branchChoice.value = value; });
20
21
  let controller: AbortController | undefined;
22
+ let optimizeController: AbortController | undefined;
21
23
  watch(() => props.step.value, value => { prompt.value = value || ''; });
22
- onBeforeUnmount(() => controller?.abort());
24
+ onBeforeUnmount(() => { controller?.abort(); optimizeController?.abort(); });
23
25
 
24
26
  function updateObservation(patch: Partial<AiObservationConfig>) {
25
27
  try {
@@ -35,6 +37,27 @@ async function toggleBranch(enabled: boolean) {
35
37
  branchChoice.value = branch.value;
36
38
  }
37
39
 
40
+ async function optimizePrompt() {
41
+ if (busy.value || optimizingPrompt.value || props.disabled) return;
42
+ error.value = '';
43
+ optimizeController = new AbortController();
44
+ optimizingPrompt.value = true;
45
+ let optimized = '';
46
+ try {
47
+ optimized = (await optimizeAppiumPrompt({
48
+ kind: 'aiRecognition',
49
+ prompt: prompt.value,
50
+ condition: branch.value || untilMatch.value,
51
+ }, optimizeController.signal)).prompt;
52
+ prompt.value = optimized;
53
+ } catch (cause) {
54
+ if (!optimizeController.signal.aborted) error.value = cause instanceof Error ? cause.message : '提示词优化失败';
55
+ } finally {
56
+ optimizingPrompt.value = false;
57
+ }
58
+ if (optimized) await save(branch.value);
59
+ }
60
+
38
61
  async function save(enabled: boolean, observation?: AiObservationConfig) {
39
62
  if (busy.value || props.disabled) return;
40
63
  error.value = '';
@@ -112,9 +135,10 @@ async function save(enabled: boolean, observation?: AiObservationConfig) {
112
135
  </el-tooltip>
113
136
  </span>
114
137
  </template>
115
- <AiRecognitionPromptInput v-model="prompt" :presets="promptPresets" :placeholder="untilMatch ? '例如:当前画面是否出现广告弹窗?' : ''" :disabled="disabled || busy" @change="error = ''; !branch && !untilMatch && save(false)" />
116
- <div v-if="(branch || untilMatch) && prompt !== step.value" class="recognition-prompt-actions">
117
- <el-button :disabled="disabled || busy" @click="save(branch)">验证并保存问题</el-button>
138
+ <AiRecognitionPromptInput v-model="prompt" :presets="promptPresets" :placeholder="untilMatch ? '例如:当前画面是否出现广告弹窗?' : ''" :disabled="disabled || busy || optimizingPrompt" @change="error = ''; !branch && !untilMatch && save(false)" />
139
+ <div class="recognition-prompt-actions">
140
+ <el-button :loading="optimizingPrompt" :disabled="disabled || busy || !prompt.trim()" @click="optimizePrompt">优化提示词</el-button>
141
+ <el-button v-if="(branch || untilMatch) && prompt !== step.value" :disabled="disabled || busy || optimizingPrompt" @click="save(branch)">验证并保存问题</el-button>
118
142
  </div>
119
143
  </el-form-item>
120
144
  <el-form-item>
@@ -3,14 +3,30 @@ import { computed } from 'vue';
3
3
  import type { AppiumRecordedStep } from '../types';
4
4
  import { enclosingLoops } from '../bounded-loop';
5
5
 
6
- const props = defineProps<{ step: AppiumRecordedStep; steps: AppiumRecordedStep[]; disabled?: boolean }>();
6
+ const props = defineProps<{
7
+ step: AppiumRecordedStep;
8
+ steps: AppiumRecordedStep[];
9
+ disabled?: boolean;
10
+ mode?: 'break' | 'continue';
11
+ }>();
7
12
  const emit = defineEmits<{ update: [patch: Partial<AppiumRecordedStep>] }>();
8
13
  const loops = computed(() => enclosingLoops(props.steps, props.step));
14
+ const isContinue = computed(() => props.mode === 'continue');
15
+ const fieldLabel = computed(() => isContinue.value ? '继续目标循环' : '退出目标循环');
16
+ const targetId = computed(() => (
17
+ isContinue.value ? props.step.continueLoopTargetId : props.step.breakLoopTargetId
18
+ ) || loops.value[0]?.id);
19
+
20
+ function updateTarget(value: string) {
21
+ emit('update', isContinue.value
22
+ ? { continueLoopTargetId: value }
23
+ : { breakLoopTargetId: value });
24
+ }
9
25
  </script>
10
26
 
11
27
  <template>
12
- <el-form-item label="退出目标循环">
13
- <el-select class="break-loop-target" popper-class="break-loop-target-popper" :model-value="step.breakLoopTargetId || loops[0]?.id" :disabled="disabled" aria-label="退出目标循环" @update:model-value="emit('update', { breakLoopTargetId: $event })">
28
+ <el-form-item :label="fieldLabel">
29
+ <el-select class="break-loop-target" popper-class="break-loop-target-popper" :model-value="targetId" :disabled="disabled" :aria-label="fieldLabel" @update:model-value="updateTarget">
14
30
  <el-option class="break-loop-target-option" v-for="(loop, index) in loops" :key="loop.id" :value="loop.id" :label="`${loop.label}(${index === 0 ? '当前循环' : `外层 ${index}`} · 节点 ${steps.findIndex(item => item.id === loop.id) + 1})`" />
15
31
  </el-select>
16
32
  </el-form-item>
@@ -69,9 +69,9 @@ watch(
69
69
  </template>
70
70
 
71
71
  <style scoped>
72
- .appium-tree-container { display: flex; flex-direction: column; gap: 8px; min-height: 0; }
73
- .appium-tree { flex: 1; height: auto; overflow-x: hidden; }
74
- .appium-tree :deep(.el-tree) { min-width: 0; }
75
- .appium-tree-node { flex: 1; min-width: 0; overflow: hidden; }
76
- .appium-tree-node__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
72
+ .appium-tree-container { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 0; }
73
+ .appium-tree { flex: 1; height: auto; overflow: auto; }
74
+ .appium-tree :deep(.el-tree) { width: max-content; min-width: 100%; }
75
+ .appium-tree-node { flex: 1 0 auto; }
76
+ .appium-tree-node__label { white-space: nowrap; }
77
77
  </style>
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ import { computed, inject, onBeforeUnmount, watch } from 'vue';
3
+ import { Aim } from '@element-plus/icons-vue';
4
+ import { coordinatePreviewKey } from '../coordinate-preview';
5
+
6
+ const props = defineProps<{ disabled?: boolean }>();
7
+ const emit = defineEmits<{ select: [point: { x: number; y: number }] }>();
8
+ const picker = inject(coordinatePreviewKey, undefined);
9
+ const owner = Symbol('coordinate-editor');
10
+ const picking = computed(() => picker?.owner.value === owner);
11
+ function cancel() { picker?.cancel(owner); }
12
+ watch(() => props.disabled, (disabled) => { if (disabled) cancel(); });
13
+ onBeforeUnmount(cancel);
14
+ </script>
15
+
16
+ <template>
17
+ <el-form-item>
18
+ <el-button :icon="Aim" :disabled="disabled || !picker?.enabled.value" @click="picker?.start(owner, point => emit('select', point))">
19
+ {{ picking ? '请在左侧设备预览点击' : '获取点击坐标' }}
20
+ </el-button>
21
+ <el-button v-if="picking" text @click="cancel">取消取点</el-button>
22
+ </el-form-item>
23
+ </template>