android-midscene-automation 0.1.42 → 0.2.1

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 (58) hide show
  1. package/AI/350/257/206/345/210/253/346/223/215/344/275/234/350/257/264/346/230/216.md +442 -0
  2. package/CHANGELOG.md +12 -2
  3. package/FAQ.md +8 -8
  4. package/README.md +156 -19
  5. package/USAGE.md +134 -9
  6. package/package.json +3 -2
  7. package/server/appium-auto-save.test.ts +2 -1
  8. package/server/appium-config.test.ts +48 -1
  9. package/server/appium-recorder/ai-recognition.test.ts +27 -0
  10. package/server/appium-recorder/ai-recognition.ts +16 -4
  11. package/server/appium-recorder/appium-runner.ts +32 -2
  12. package/server/appium-recorder/bounded-loop.test.ts +55 -0
  13. package/server/appium-recorder/bounded-loop.ts +12 -1
  14. package/server/appium-recorder/prompt-optimizer.ts +57 -0
  15. package/server/appium-recorder/report-summary.test.ts +78 -0
  16. package/server/appium-recorder/report-summary.ts +60 -0
  17. package/server/appium-recorder/report.ts +31 -2
  18. package/server/appium-recorder/repository.ts +8 -2
  19. package/server/appium-recorder/routes.ts +53 -3
  20. package/server/config.ts +17 -2
  21. package/server/http-api.ts +8 -4
  22. package/src/App.vue +34 -5
  23. package/src/api.ts +2 -2
  24. package/src/appium-recorder/AppiumPage.vue +126 -62
  25. package/src/appium-recorder/action-descriptions.ts +1 -0
  26. package/src/appium-recorder/ai-recognition.ts +7 -0
  27. package/src/appium-recorder/api.ts +22 -2
  28. package/src/appium-recorder/bounded-loop.ts +9 -1
  29. package/src/appium-recorder/components/AiRecognitionSettings.vue +57 -6
  30. package/src/appium-recorder/components/BreakLoopSettings.vue +19 -3
  31. package/src/appium-recorder/components/ComponentTree.vue +5 -5
  32. package/src/appium-recorder/components/CoordinatePointPicker.vue +23 -0
  33. package/src/appium-recorder/components/FlowActionMenu.vue +50 -20
  34. package/src/appium-recorder/components/FlowStepEditor.vue +10 -2
  35. package/src/appium-recorder/components/NestedConditionBranches.vue +18 -49
  36. package/src/appium-recorder/components/RecordedSteps.vue +16 -5
  37. package/src/appium-recorder/coordinate-preview.ts +37 -0
  38. package/src/appium-recorder/flow-copy.ts +1 -0
  39. package/src/appium-recorder/flow-graph.ts +5 -2
  40. package/src/appium-recorder/flow-labels.ts +4 -2
  41. package/src/appium-recorder/flow-merge.ts +1 -1
  42. package/src/appium-recorder/flow-unmerge.ts +2 -2
  43. package/src/appium-recorder/report-summary.ts +60 -0
  44. package/src/appium-recorder/types.ts +4 -1
  45. package/src/components/config/AiDeduplicationSettings.vue +5 -4
  46. package/src/components/config/AiPromptPresetsSettings.vue +64 -17
  47. package/src/components/config/AppiumConfigPanel.vue +66 -38
  48. package/src/components/config/AppiumModelSettings.vue +45 -0
  49. package/src/components/config/FlowAppearanceSettings.vue +0 -1
  50. package/src/components/config/ReplayReportSummarySettings.vue +118 -0
  51. package/src/components/device/DevicePreviewPanel.vue +18 -3
  52. package/src/components/help/HelpCenterDialog.vue +160 -0
  53. package/src/components/help/MarkdownDocument.vue +139 -0
  54. package/src/config/use-appium-auto-save.ts +12 -4
  55. package/src/pages/ConfigPage.vue +4 -2
  56. package/src/style.css +37 -45
  57. package/src/types.ts +2 -1
  58. package//346/265/201/347/250/213/350/212/202/347/202/271/346/223/215/344/275/234/350/257/264/346/230/216.md +408 -0
@@ -1,3 +1,4 @@
1
+ import { resolveReportSummary } from '../src/appium-recorder/report-summary';
1
2
  // The Midscene Admin HTTP API — extracted from vite.config.ts so the exact
2
3
  // same middleware serves the standalone dev/preview flow AND the DSH plugin.
3
4
  // It is a Connect-style handler: (req, res, next). Callers that have no next
@@ -724,7 +725,8 @@ export function createApiMiddleware() {
724
725
  }
725
726
  const current = loadConfig();
726
727
  const config = req.url === '/api/config/appium'
727
- ? { ...current, appium: { ...current.appium, ...parsed.appium, model: current.appium.model } }
728
+ ? { ...current, appium: { ...current.appium, ...parsed.appium, model: current.appium.model, promptOptimizer: current.appium.promptOptimizer,
729
+ reportSummary: { ...resolveReportSummary(current.appium.reportSummary), ...parsed.appium?.reportSummary } } }
728
730
  : { ...current, runtime: parsed.runtime ?? current.runtime };
729
731
  saveConfig(config);
730
732
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
@@ -1097,7 +1099,7 @@ export function createApiMiddleware() {
1097
1099
  try {
1098
1100
  const parsed = await readBody<{
1099
1101
  save?: boolean;
1100
- modelKey?: 'midscene' | 'scriptOptimizer' | 'appium';
1102
+ modelKey?: 'midscene' | 'scriptOptimizer' | 'appium' | 'promptOptimizer';
1101
1103
  model?: {
1102
1104
  provider?: import('../src/config/midscene-model-presets').MidsceneModelProvider;
1103
1105
  baseUrl?: string;
@@ -1108,7 +1110,7 @@ export function createApiMiddleware() {
1108
1110
  }>(req);
1109
1111
  const model = parsed.model || {};
1110
1112
 
1111
- if (parsed.save && !['midscene', 'scriptOptimizer', 'appium'].includes(parsed.modelKey || '')) {
1113
+ if (parsed.save && !['midscene', 'scriptOptimizer', 'appium', 'promptOptimizer'].includes(parsed.modelKey || '')) {
1112
1114
  throw new ConfigValidationError('请选择有效的模型配置');
1113
1115
  }
1114
1116
  if (parsed.save && parsed.modelKey === 'midscene' && !model.family?.trim()) {
@@ -1127,7 +1129,9 @@ export function createApiMiddleware() {
1127
1129
  const key = parsed.modelKey!;
1128
1130
  // 测试期间其他配置可能已保存,此时重新读取,只更新已通过测试的模型。
1129
1131
  const current = loadConfig();
1130
- saveConfig({ ...current, [key]: { ...current[key], model: { ...current[key].model, ...model } } });
1132
+ if (key === 'promptOptimizer') {
1133
+ saveConfig({ ...current, appium: { ...current.appium, promptOptimizer: { model: { baseUrl: model.baseUrl || '', apiKey: model.apiKey || '', name: model.name || '' } } } });
1134
+ } else saveConfig({ ...current, [key]: { ...current[key], model: { ...current[key].model, ...model } } });
1131
1135
  }
1132
1136
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
1133
1137
  res.end(JSON.stringify({ ...result, saved: parsed.save === true }));
package/src/App.vue CHANGED
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { resolveReportSummary } from './appium-recorder/report-summary';
2
3
  import { isAiRecognitionModelConfigured } from './appium-recorder/ai-recognition';
3
4
  import { resolveAiDeduplication } from './appium-recorder/ai-deduplication';
4
5
  import { resolveAiPromptPresets } from './appium-recorder/ai-prompt-presets';
@@ -11,6 +12,7 @@ import {
11
12
  Loading,
12
13
  Monitor,
13
14
  Operation,
15
+ QuestionFilled,
14
16
  RefreshLeft,
15
17
  Setting,
16
18
  VideoPause,
@@ -39,6 +41,7 @@ import AppiumPage from './appium-recorder/AppiumPage.vue';
39
41
  import { normalizeFlowBackground, normalizeFlowLineColor } from './appium-recorder/flow-appearance';
40
42
  import ConfigPage from './pages/ConfigPage.vue';
41
43
  import GeneratorPage from './pages/GeneratorPage.vue';
44
+ import HelpCenterDialog from './components/help/HelpCenterDialog.vue';
42
45
  import type {
43
46
  AndroidDevice,
44
47
  ConfigForm,
@@ -66,6 +69,7 @@ const isSavingAppPreset = ref(false);
66
69
  const isRunningScript = ref(false);
67
70
  const isStoppingScript = ref(false);
68
71
  const showGeneratedCode = ref(false);
72
+ const helpCenterVisible = shallowRef(false);
69
73
  const generatedCodeOverride = shallowRef<string | null>(null);
70
74
  const generatedCodeDraft = shallowRef('');
71
75
  const generatedCodeEditing = shallowRef(false);
@@ -105,6 +109,7 @@ const modelTestStatus = reactive({
105
109
  midscene: '',
106
110
  scriptOptimizer: '',
107
111
  appium: '',
112
+ promptOptimizer: '',
108
113
  });
109
114
  const aiRecognitionModelConfigured = shallowRef(false);
110
115
  const actionDialog = reactive({
@@ -156,7 +161,7 @@ const appPresetForm = reactive({
156
161
  });
157
162
 
158
163
  const configForm = reactive<ConfigForm>({
159
- appium: { model: { baseUrl: '', apiKey: '', name: '' }, aiDeduplication: resolveAiDeduplication(), screenshotReport: false },
164
+ appium: { reportSummary: resolveReportSummary(), model: { baseUrl: '', apiKey: '', name: '' }, promptOptimizer: { model: { baseUrl: '', apiKey: '', name: '' } }, aiDeduplication: resolveAiDeduplication(), screenshotReport: false },
160
165
  runtime: {
161
166
  androidSdkPath: '',
162
167
  reportOutputPath: '',
@@ -671,7 +676,9 @@ const loadConfig = async () => {
671
676
  Object.assign(configForm.midscene.env, payload.midscene.env || {});
672
677
  Object.assign(configForm.scriptOptimizer.model, payload.scriptOptimizer.model);
673
678
  Object.assign(configForm.appium.model, payload.appium?.model || { baseUrl: '', apiKey: '', name: '' });
679
+ Object.assign(configForm.appium.promptOptimizer!.model, payload.appium?.promptOptimizer?.model || { baseUrl: '', apiKey: '', name: '' });
674
680
  configForm.appium.screenshotReport = payload.appium?.screenshotReport === true;
681
+ configForm.appium.reportSummary = resolveReportSummary(payload.appium?.reportSummary);
675
682
  configForm.appium.aiDeduplication = resolveAiDeduplication(payload.appium?.aiDeduplication);
676
683
  configForm.appium.aiPromptPresets = resolveAiPromptPresets(payload.appium?.aiPromptPresets);
677
684
  configForm.appium.flowBackgroundColor = normalizeFlowBackground(payload.appium?.flowBackgroundColor);
@@ -794,19 +801,25 @@ const removeAppPreset = async (id: string) => {
794
801
  }
795
802
  };
796
803
 
797
- const testModel = async (key: 'midscene' | 'scriptOptimizer' | 'appium') => {
804
+ const testModel = async (key: 'midscene' | 'scriptOptimizer' | 'appium' | 'promptOptimizer') => {
798
805
  if (testingModelKey.value) return;
799
806
  testingModelKey.value = key;
800
807
  errorMessage.value = '';
801
808
  modelTestStatus[key] = '';
802
- const model = { ...configForm[key].model };
809
+ const model = { ...(key === 'promptOptimizer'
810
+ ? configForm.appium.promptOptimizer!.model
811
+ : configForm[key].model) };
803
812
 
804
813
  try {
805
814
  const payload = await api.testModel({ modelKey: key, model, save: true });
806
815
  if (!payload.saved) throw new Error('模型未保存,请重试');
807
- modelTestStatus[key] = `测试通过,配置已保存${payload.content ? `:${payload.content}` : ''}`;
816
+ const successMessage = `测试通过,配置已保存${payload.content ? `:${payload.content}` : ''}`;
817
+ modelTestStatus[key] = '';
808
818
  if (key === 'appium') aiRecognitionModelConfigured.value = isAiRecognitionModelConfigured(model);
809
- ElMessage.success('测试通过,模型配置已保存');
819
+ void ElMessageBox.alert(successMessage, '模型测试成功', {
820
+ type: 'success',
821
+ confirmButtonText: '确定',
822
+ }).catch(() => {});
810
823
  } catch (error) {
811
824
  modelTestStatus[key] = `未保存:${error instanceof Error ? error.message : '测试失败'}`;
812
825
  } finally {
@@ -1441,6 +1454,18 @@ onUnmounted(() => {
1441
1454
  <span>参数配置</span>
1442
1455
  </el-menu-item>
1443
1456
  </el-menu>
1457
+ <div class="layout-sidebar__footer">
1458
+ <el-tooltip content="帮助与版本" placement="right">
1459
+ <button
1460
+ type="button"
1461
+ class="layout-sidebar__help"
1462
+ aria-label="打开帮助与版本信息"
1463
+ @click="helpCenterVisible = true"
1464
+ >
1465
+ <el-icon><QuestionFilled /></el-icon>
1466
+ </button>
1467
+ </el-tooltip>
1468
+ </div>
1444
1469
  </aside>
1445
1470
 
1446
1471
  <div class="layout-main">
@@ -1580,6 +1605,7 @@ onUnmounted(() => {
1580
1605
  :app-presets="appPresets"
1581
1606
  :ai-recognition-model-configured="aiRecognitionModelConfigured"
1582
1607
  :ai-prompt-presets="configForm.appium.aiPromptPresets"
1608
+ :report-summary="configForm.appium.reportSummary"
1583
1609
  :flow-background-color="configForm.appium.flowBackgroundColor"
1584
1610
  :flow-line-color="configForm.appium.flowLineColor"
1585
1611
  :device-actions="deviceActions"
@@ -1596,7 +1622,10 @@ onUnmounted(() => {
1596
1622
  :refresh-device-preview="refreshDevicePreview"
1597
1623
  :swipe-device="swipeDevice"
1598
1624
  />
1625
+
1599
1626
  </main>
1600
1627
  </div>
1628
+
1629
+ <HelpCenterDialog v-model="helpCenterVisible" />
1601
1630
  </div>
1602
1631
  </template>
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 分支执行。',
@@ -1,6 +1,7 @@
1
1
  import type { AiDeduplicationConfig } from './ai-deduplication';
2
2
 
3
3
  export type AiRecognitionModel = { baseUrl: string; apiKey: string; name: string };
4
+ export type AiInvalidResultBranch = 'yes' | 'no';
4
5
  export type AiObservationConfig = { durationMs: number; intervalMs: number; mode?: 'batch' | 'untilMatch' };
5
6
  export type AiObservationFrame = { elapsedMs: number; imageDataUrl: string };
6
7
  export type AiRecognitionResult = {
@@ -21,6 +22,12 @@ export function validateAiObservation(value: unknown): AiObservationConfig | und
21
22
  return { durationMs, intervalMs, ...(mode ? { mode } : {}) };
22
23
  }
23
24
 
25
+ export function validateAiInvalidResultBranch(value: unknown): AiInvalidResultBranch | undefined {
26
+ if (value === undefined || value === null || value === '') return undefined;
27
+ if (value !== 'yes' && value !== 'no') throw new Error('AI 无有效结果兜底分支无效');
28
+ return value;
29
+ }
30
+
24
31
  // 未保存此字段的是旧版判断节点,保留已有分支;新节点显式保存 false。
25
32
  export function isAiBranchEnabled(step: { aiBranchEnabled?: boolean }) {
26
33
  return step.aiBranchEnabled !== 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`,