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
@@ -0,0 +1,60 @@
1
+ export const DEFAULT_REPORT_SUMMARY_PROMPT = `请根据提供的回放日志、执行元数据和脚本配置,生成中文 Markdown 测试报告。默认包含:
2
+ 1. 测试概览:脚本名称、开始时间、结束时间、执行结果(成功/失败/终止)、总时长、设备和 App 信息。
3
+ 2. 执行配置:主脚本及连接脚本名称、节点类型、参数、超时、分支、循环、AI 识别条件等,用表格展示关键配置。
4
+ 3. 用例描述表格:序号、所属脚本、测试场景/用例描述、操作步骤、预期结果、实际结果、通过/失败/未执行;循环多次执行时区分尝试次数。
5
+ 4. 失败详情:失败节点、错误日志证据、实际与预期差异、可能原因及排查建议;没有失败则明确说明。
6
+ 5. 汇总:执行情况、主要问题、重试结果及建议。
7
+ 严格依据日志,未执行节点不能计为通过;AI 判断 false 与执行异常要区分。无法确认的信息写“日志未提供”,推测原因明确标为推测。不要编造截图内容或测试结果。只输出 Markdown 正文,不要在全文外包裹代码块。`;
8
+
9
+ export const IMAGE_GENERATION_TIME_REPORT_SUMMARY_PROMPT = `${DEFAULT_REPORT_SUMMARY_PROMPT}
10
+
11
+ 另外必须包含“出图时间分析”:
12
+ - 定位每次触发图片生成的操作,以及其后用于判断已出图的 AI 识别或图像判断节点。
13
+ - 首次出图时间必须采用日志中“距观察开始 Nms”或首次命中帧的明确时间,换算为秒并保留两位小数。
14
+ - AI 识别耗时或图像判断耗时是包含截图采样、模型请求和响应等待的检测节点总耗时,不能直接作为首次出图时间。
15
+ - 如果没有首次命中帧时间,首次出图时间写“日志未提供”,并单独展示检测节点总耗时;不得根据观察时长、超时时间或节点顺序推测。
16
+ - 多次生成、循环或重试须逐次列出,表格列为:次数、触发出图操作、检测条件、首次出图时间、检测节点总耗时、判定结果、日志证据。
17
+ - 汇总成功出图次数、失败或超时次数、最快、最慢和平均首次出图时间;只有至少两次具有明确时间时才计算平均值。`;
18
+
19
+ export type ReportSummaryPromptPreset = { name: string; prompt: string };
20
+
21
+ export const REPORT_SUMMARY_PROMPT_PRESETS: readonly ReportSummaryPromptPreset[] = [
22
+ { name: '默认测试报告', prompt: DEFAULT_REPORT_SUMMARY_PROMPT },
23
+ { name: '出图时间分析', prompt: IMAGE_GENERATION_TIME_REPORT_SUMMARY_PROMPT },
24
+ ] as const;
25
+
26
+ export interface ReportSummaryConfig {
27
+ enabled: boolean;
28
+ prompt: string;
29
+ customPresets: ReportSummaryPromptPreset[];
30
+ }
31
+
32
+ export function validateReportSummaryPrompt(value: unknown) {
33
+ if (typeof value !== 'string' || !value.trim() || value.trim().length > 20000) {
34
+ throw new Error('报告提示词不能为空,且不能超过 20000 字');
35
+ }
36
+ return value.trim();
37
+ }
38
+
39
+ export function resolveReportSummary(value?: Partial<ReportSummaryConfig>): ReportSummaryConfig {
40
+ if (value != null && (typeof value !== 'object' || Array.isArray(value))) throw new Error('回放报告总结配置无效');
41
+ if (value?.enabled !== undefined && typeof value.enabled !== 'boolean') throw new Error('回放报告总结开关无效');
42
+ const prompt = validateReportSummaryPrompt(value?.prompt ?? DEFAULT_REPORT_SUMMARY_PROMPT);
43
+ const rawPresets = value?.customPresets;
44
+ if (rawPresets !== undefined && !Array.isArray(rawPresets)) throw new Error('报告测试场景必须是列表');
45
+ if ((rawPresets?.length || 0) > 50) throw new Error('报告测试场景不能超过 50 个');
46
+ const reservedNames = new Set(REPORT_SUMMARY_PROMPT_PRESETS.map(preset => preset.name));
47
+ const names = new Set<string>();
48
+ const customPresets = (rawPresets || []).map(item => {
49
+ const name = typeof item?.name === 'string' ? item.name.trim() : '';
50
+ if (!name || name.length > 80) throw new Error('报告测试场景名称不能为空,且不能超过 80 字');
51
+ if (reservedNames.has(name) || names.has(name)) throw new Error(`报告测试场景名称已存在:${name}`);
52
+ names.add(name);
53
+ return { name, prompt: validateReportSummaryPrompt(item?.prompt) };
54
+ });
55
+ return {
56
+ enabled: value?.enabled === true,
57
+ prompt,
58
+ customPresets,
59
+ };
60
+ }
@@ -1,4 +1,4 @@
1
- import type { AiObservationConfig } from './ai-recognition';
1
+ import type { AiInvalidResultBranch, AiObservationConfig } from './ai-recognition';
2
2
  import type { TestVariable, VariableExtraction, ScriptReturn } from './variables';
3
3
  import type { ImageCheckConfig } from './image-check';
4
4
 
@@ -99,6 +99,7 @@ export type AppiumRecordedStep = {
99
99
  | 'endFlow'
100
100
  | 'loop'
101
101
  | 'breakLoop'
102
+ | 'continueLoop'
102
103
  | 'clearAppData'
103
104
  | 'waitDisappear'
104
105
  | 'assertText'
@@ -124,10 +125,12 @@ export type AppiumRecordedStep = {
124
125
  timeoutMs?: number;
125
126
  aiTimeoutEnabled?: boolean;
126
127
  aiBranchEnabled?: boolean;
128
+ aiInvalidResultBranch?: AiInvalidResultBranch;
127
129
  aiObservation?: AiObservationConfig;
128
130
  timeoutBranch?: 'stop' | 'yes' | 'no';
129
131
  longPressMode?: 'element' | 'coordinates';
130
132
  breakLoopTargetId?: string;
133
+ continueLoopTargetId?: string;
131
134
  loop?: {
132
135
  maxIterations: number;
133
136
  exitWhen: 'never' | 'exists' | 'notExists';
@@ -12,7 +12,7 @@ function patch(value: Partial<AiDeduplicationConfig>) {
12
12
 
13
13
  <template>
14
14
  <section class="ai-dedup-settings" aria-label="持续观察截图去重">
15
- <h3 class="ai-dedup-title">持续观察截图去重</h3>
15
+ <p class="ai-dedup-description">相似截图在本地过滤后再提交模型,减少重复图片和识别开销。</p>
16
16
  <el-form-item label="去重方式">
17
17
  <el-radio-group :model-value="settings.method" :disabled="disabled" @update:model-value="patch({ method: $event as AiDeduplicationConfig['method'] })">
18
18
  <el-radio-button value="none">不去重</el-radio-button>
@@ -41,6 +41,7 @@ function patch(value: Partial<AiDeduplicationConfig>) {
41
41
  </el-form-item>
42
42
  </template>
43
43
  <div class="ai-dedup-explanation">
44
+ <h4>去重方式说明</h4>
44
45
  <p><strong>不去重:</strong>全部采样截图交给模型,保留信息最多,图片输入消耗也最高。</p>
45
46
  <p><strong>pixelmatch:</strong>比较原图的像素颜色和变化面积,适合同设备的静态画面,对细节变化敏感。</p>
46
47
  <p><strong>OpenCV(SSIM):</strong>缩小图片后比较彩色画面的局部亮度、对比度和结构,更侧重整体相似性;可能忽略小文字,不能识别语义,也不自动对齐位移。</p>
@@ -50,10 +51,10 @@ function patch(value: Partial<AiDeduplicationConfig>) {
50
51
  </template>
51
52
 
52
53
  <style scoped>
53
- .ai-dedup-settings { height: 540px; box-sizing: border-box; overflow-y: auto; scrollbar-gutter: stable; padding-right: 4px; }
54
- .ai-dedup-title { margin: 0 0 20px; font-size: 15px; font-weight: 600; color: var(--el-text-color-primary); }
54
+ .ai-dedup-description { margin: 0 0 18px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; }
55
55
  .ai-dedup-help { width: 100%; margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; }
56
- .ai-dedup-explanation { color: var(--el-text-color-regular); font-size: 13px; line-height: 1.7; }
56
+ .ai-dedup-explanation { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--ui-border); color: var(--el-text-color-regular); font-size: 13px; line-height: 1.7; }
57
+ .ai-dedup-explanation h4 { margin: 0 0 8px; color: var(--el-text-color-primary); font-size: 13px; }
57
58
  .ai-dedup-explanation p { margin: 6px 0; }
58
59
  .ai-dedup-settings :deep(.el-radio-group) { gap: 4px 0; }
59
60
  </style>
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref } from 'vue';
3
- import { Delete, Plus } from '@element-plus/icons-vue';
2
+ import { computed, ref, shallowRef } from 'vue';
3
+ import { Check, Close, Delete, Edit, Plus } from '@element-plus/icons-vue';
4
4
  import { ElMessage } from 'element-plus';
5
5
  import { resolveAiPromptPresets, type AiPromptPreset } from '../../appium-recorder/ai-prompt-presets';
6
6
  import { validateAiRecognitionPrompt } from '../../appium-recorder/ai-recognition';
@@ -10,6 +10,9 @@ const emit = defineEmits<{ 'update:modelValue': [presets: AiPromptPreset[]] }>()
10
10
  const presets = computed(() => resolveAiPromptPresets(props.modelValue));
11
11
  const draft = ref('');
12
12
  const sceneName = ref('');
13
+ const editIndex = shallowRef<number | null>(null);
14
+ const editSceneName = ref('');
15
+ const editPrompt = ref('');
13
16
 
14
17
  function addPreset() {
15
18
  try {
@@ -20,12 +23,40 @@ function addPreset() {
20
23
  draft.value = '';
21
24
  } catch (error) { ElMessage.warning(error instanceof Error ? error.message : '添加提示词失败'); }
22
25
  }
26
+
27
+ function startEdit(index: number) {
28
+ const preset = presets.value[index];
29
+ if (!preset) return;
30
+ editIndex.value = index;
31
+ editSceneName.value = preset.name;
32
+ editPrompt.value = preset.prompt;
33
+ }
34
+
35
+ function cancelEdit() {
36
+ editIndex.value = null;
37
+ editSceneName.value = '';
38
+ editPrompt.value = '';
39
+ }
40
+
41
+ function saveEdit() {
42
+ if (editIndex.value === null) return;
43
+ try {
44
+ const next = presets.value.map((preset, index) => index === editIndex.value
45
+ ? { name: editSceneName.value, prompt: validateAiRecognitionPrompt(editPrompt.value) }
46
+ : preset);
47
+ emit('update:modelValue', resolveAiPromptPresets(next));
48
+ cancelEdit();
49
+ ElMessage.success('预设提示词已修改');
50
+ } catch (error) {
51
+ ElMessage.warning(error instanceof Error ? error.message : '修改提示词失败');
52
+ }
53
+ }
23
54
  </script>
24
55
 
25
56
  <template>
26
57
  <section class="ai-prompt-settings" aria-labelledby="ai-prompt-presets-title">
27
- <h3 id="ai-prompt-presets-title">预设提示词</h3>
28
- <p class="ai-prompt-description">可在 AI 识别节点中选择并修改。删除预设不会修改已有节点的识别内容。</p>
58
+ <h3 id="ai-prompt-presets-title" class="ai-prompt-visually-hidden">AI 识别预设提示词</h3>
59
+ <p class="ai-prompt-description">添加常用测试场景,录制 AI 识别节点时可以直接选择。删除预设不会修改已有节点。</p>
29
60
  <div class="ai-prompt-layout">
30
61
  <el-form label-position="top" @submit.prevent="addPreset">
31
62
  <el-form-item label="测试场景">
@@ -37,9 +68,24 @@ function addPreset() {
37
68
  <div class="ai-prompt-actions"><el-button native-type="submit" :icon="Plus">添加提示词</el-button></div>
38
69
  </el-form>
39
70
  <div class="ai-prompt-list" aria-label="已添加的提示词">
40
- <div v-for="(preset, index) in presets" :key="preset.name" class="ai-prompt-row">
41
- <div class="ai-prompt-content"><strong>{{ preset.name }}</strong><p>{{ preset.prompt }}</p></div>
42
- <el-button text :icon="Delete" :aria-label="`删除提示词 ${preset.name}`" @click="emit('update:modelValue', presets.filter((_, i) => i !== index))" />
71
+ <div v-for="(preset, index) in presets" :key="`${preset.name}-${index}`" class="ai-prompt-row" :class="{ 'ai-prompt-row--editing': editIndex === index }">
72
+ <template v-if="editIndex === index">
73
+ <div class="ai-prompt-editor">
74
+ <el-input v-model="editSceneName" maxlength="80" placeholder="测试场景名称" />
75
+ <el-input v-model="editPrompt" type="textarea" :rows="3" maxlength="4000" show-word-limit placeholder="提示词内容" />
76
+ <div class="ai-prompt-editor__actions">
77
+ <el-button size="small" :icon="Close" @click="cancelEdit">取消</el-button>
78
+ <el-button size="small" type="primary" :icon="Check" @click="saveEdit">保存</el-button>
79
+ </div>
80
+ </div>
81
+ </template>
82
+ <template v-else>
83
+ <div class="ai-prompt-content"><strong>{{ preset.name }}</strong><p>{{ preset.prompt }}</p></div>
84
+ <div class="ai-prompt-row__actions">
85
+ <el-button text :icon="Edit" :disabled="editIndex !== null" :aria-label="`编辑提示词 ${preset.name}`" @click="startEdit(index)" />
86
+ <el-button text :icon="Delete" :disabled="editIndex !== null" :aria-label="`删除提示词 ${preset.name}`" @click="emit('update:modelValue', presets.filter((_, i) => i !== index))" />
87
+ </div>
88
+ </template>
43
89
  </div>
44
90
  <el-empty v-if="!presets.length" description="暂无预设提示词" :image-size="60" />
45
91
  </div>
@@ -48,19 +94,20 @@ function addPreset() {
48
94
  </template>
49
95
 
50
96
  <style scoped>
51
- .ai-prompt-settings { margin-top: 24px; padding-top: 24px; border-top: 1px solid var(--ui-border); }
52
- .ai-prompt-settings h3 { margin: 0 0 8px; font-size: 15px; font-weight: 600; }
97
+ .ai-prompt-visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
53
98
  .ai-prompt-description { margin: 0 0 20px; color: var(--el-text-color-secondary); font-size: 12px; }
54
- .ai-prompt-layout { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 32px; }
99
+ .ai-prompt-layout { display: grid; gap: 20px; }
55
100
  .ai-prompt-actions { display: flex; justify-content: flex-end; }
56
- .ai-prompt-list { min-width: 0; max-height: 260px; overflow-y: auto; padding-left: 32px; border-left: 1px solid var(--ui-border); }
57
- .ai-prompt-row { display: flex; align-items: flex-start; gap: 12px; padding: 10px 0; }
101
+ .ai-prompt-list { --ai-prompt-row-height: 76px; min-width: 0; height: calc(var(--ai-prompt-row-height) * 3 + 1px); overflow-y: auto; scrollbar-gutter: stable; border-top: 1px solid var(--ui-border); }
102
+ .ai-prompt-row { display: flex; align-items: flex-start; gap: 12px; height: var(--ai-prompt-row-height); box-sizing: border-box; padding: 10px 0; }
103
+ .ai-prompt-row--editing { height: auto; min-height: var(--ai-prompt-row-height); }
58
104
  .ai-prompt-row + .ai-prompt-row { border-top: 1px solid var(--ui-border); }
59
105
  .ai-prompt-content { flex: 1; min-width: 0; font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.6; }
60
- .ai-prompt-content p { margin: 4px 0 0; color: var(--el-text-color-secondary); }
106
+ .ai-prompt-content strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
107
+ .ai-prompt-content p { display: -webkit-box; margin: 4px 0 0; overflow: hidden; color: var(--el-text-color-secondary); -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
108
+ .ai-prompt-row__actions { display: flex; flex-shrink: 0; }
109
+ .ai-prompt-editor { display: grid; flex: 1; gap: 10px; min-width: 0; padding-right: 4px; }
110
+ .ai-prompt-editor__actions { display: flex; justify-content: flex-end; gap: 8px; }
61
111
  .ai-prompt-row .el-button { flex-shrink: 0; }
62
- @media (max-width: 1000px) {
63
- .ai-prompt-layout { grid-template-columns: minmax(0, 1fr); gap: 24px; }
64
- .ai-prompt-list { padding: 16px 0 0; border-left: 0; border-top: 1px solid var(--ui-border); }
65
- }
112
+ .ai-prompt-list :deep(.el-empty) { height: 100%; padding: 0; }
66
113
  </style>
@@ -1,61 +1,89 @@
1
1
  <script setup lang="ts">
2
+ import ReplayReportSummarySettings from './ReplayReportSummarySettings.vue';
2
3
  import type { ConfigForm } from '../../types';
3
4
  import FlowAppearanceSettings from './FlowAppearanceSettings.vue';
4
5
  import AiDeduplicationSettings from './AiDeduplicationSettings.vue';
5
6
  import AiPromptPresetsSettings from './AiPromptPresetsSettings.vue';
7
+ import AppiumModelSettings from './AppiumModelSettings.vue';
6
8
 
7
9
  defineProps<{
8
10
  configForm: ConfigForm;
9
11
  testingModelKey: string;
10
12
  testStatus?: string;
13
+ promptOptimizerTestStatus?: string;
11
14
  saveStatus?: string;
12
15
  }>();
13
- defineEmits<{ testModel: [] }>();
16
+ defineEmits<{ testModel: []; testPromptOptimizerModel: [] }>();
14
17
  </script>
15
18
 
16
19
  <template>
17
- <el-card shadow="never" class="config-module-card config-appium-card" style="margin-bottom:20px">
18
- <template #header><div class="panel-header"><span>AI 识别配置</span><span v-if="saveStatus" class="config-save-status" role="status">{{ saveStatus }}</span></div></template>
19
- <div class="config-ai-layout">
20
- <section aria-labelledby="appium-ai-model-title">
21
- <h3 id="appium-ai-model-title" class="config-ai-title">模型连接</h3>
22
- <el-form label-position="top" :disabled="!!testingModelKey">
23
- <el-form-item label="Base URL"><el-input v-model="configForm.appium.model.baseUrl" /></el-form-item>
24
- <el-form-item label="API Key"><el-input v-model="configForm.appium.model.apiKey" type="password" show-password /></el-form-item>
25
- <el-form-item label="Model Name"><el-input v-model="configForm.appium.model.name" placeholder="支持图片输入的模型" /></el-form-item>
26
- <el-form-item v-if="testStatus" label="测试结果"><el-input :model-value="testStatus" readonly /></el-form-item>
27
- <el-button :loading="testingModelKey === 'appium'" @click="$emit('testModel')">测试并保存</el-button>
28
- </el-form>
29
- </section>
30
- <el-form label-position="top" class="config-ai-dedup">
31
- <AiDeduplicationSettings v-model="configForm.appium.aiDeduplication" />
32
- </el-form>
33
- </div>
34
- <AiPromptPresetsSettings v-model="configForm.appium.aiPromptPresets" />
35
- </el-card>
36
- <el-card shadow="never" class="config-module-card config-appium-card">
37
- <template #header>
38
- <div class="panel-header">
39
- <span>回放截图</span>
20
+ <div class="appium-config-stack">
21
+ <section class="appium-config-section" aria-labelledby="appium-models-title">
22
+ <header class="appium-section-heading">
23
+ <div><h2 id="appium-models-title">模型服务</h2><p>不同任务使用独立模型,配置修改需测试通过后保存。</p></div>
24
+ <span v-if="saveStatus" class="config-save-status" role="status">{{ saveStatus }}</span>
25
+ </header>
26
+ <div class="appium-model-grid">
27
+ <AppiumModelSettings v-model="configForm.appium.model" title="AI 识别模型" badge="视觉"
28
+ description="用于识别设备截图和持续观察,需要选择支持图片输入的模型。" name-placeholder="支持图片输入的模型"
29
+ model-key="appium" :testing-model-key="testingModelKey" :test-status="testStatus" @test="$emit('testModel')" />
30
+ <AppiumModelSettings v-if="configForm.appium.promptOptimizer" v-model="configForm.appium.promptOptimizer.model"
31
+ title="提示词优化模型" badge="文本" description="用于优化 AI 识别和回放报告提示词,也用于生成回放测试报告总结,只需支持文本输入。"
32
+ name-placeholder="用于优化提示词的文本模型" model-key="promptOptimizer" :testing-model-key="testingModelKey"
33
+ :test-status="promptOptimizerTestStatus" @test="$emit('testPromptOptimizerModel')" />
40
34
  </div>
41
- </template>
42
- <el-form label-position="top">
43
- <el-form-item label="截图与 HTML 报告">
44
- <el-switch v-model="configForm.appium.screenshotReport" aria-label="截图与 HTML 报告" />
45
- </el-form-item>
46
- </el-form>
47
- <el-alert title="默认关闭。开启后将在节点执行前后截图并生成 HTML 报告,截图会增加回放耗时,实际操作间隔可能超过设置的延时时间。" type="warning" :closable="false" show-icon />
48
- </el-card>
49
- <FlowAppearanceSettings v-model="configForm.appium.flowBackgroundColor" v-model:line-color="configForm.appium.flowLineColor" />
35
+ </section>
36
+
37
+ <section class="appium-config-section" aria-labelledby="appium-recognition-title">
38
+ <header class="appium-section-heading"><div><h2 id="appium-recognition-title">AI 识别设置</h2><p>控制持续观察的截图筛选,并管理录制节点可选的常用提示词。</p></div></header>
39
+ <div class="appium-settings-grid">
40
+ <el-card shadow="never" class="config-module-card config-appium-card">
41
+ <template #header><div class="panel-header"><span>持续观察截图去重</span></div></template>
42
+ <el-form label-position="top"><AiDeduplicationSettings v-model="configForm.appium.aiDeduplication" /></el-form>
43
+ </el-card>
44
+ <el-card shadow="never" class="config-module-card config-appium-card">
45
+ <template #header><div class="panel-header"><span>AI 识别预设提示词</span></div></template>
46
+ <AiPromptPresetsSettings v-model="configForm.appium.aiPromptPresets" />
47
+ </el-card>
48
+ </div>
49
+ </section>
50
+
51
+ <section class="appium-config-section" aria-labelledby="appium-replay-title">
52
+ <header class="appium-section-heading"><div><h2 id="appium-replay-title">回放与报告</h2><p>配置回放证据、基础报告和 AI 总结。</p></div></header>
53
+ <el-card shadow="never" class="config-module-card config-appium-card">
54
+ <template #header>
55
+ <div class="panel-header">
56
+ <span class="panel-header__title">
57
+ <span>回放截图</span>
58
+ <el-switch v-model="configForm.appium.screenshotReport" aria-label="截图与 HTML 报告" />
59
+ </span>
60
+ </div>
61
+ </template>
62
+ <el-alert title="开启后会在节点执行前后截图并生成 HTML 报告。截图会增加回放耗时,实际操作间隔可能超过设置的延时时间。" type="info" :closable="false" show-icon />
63
+ </el-card>
64
+ <ReplayReportSummarySettings v-if="configForm.appium.reportSummary" :config="configForm.appium.reportSummary" :testing-model-key="testingModelKey" />
65
+ </section>
66
+
67
+ <section class="appium-config-section" aria-labelledby="appium-editor-title">
68
+ <header class="appium-section-heading"><div><h2 id="appium-editor-title">流程编辑器</h2><p>调整流程画布和连接线的显示颜色。</p></div></header>
69
+ <FlowAppearanceSettings v-model="configForm.appium.flowBackgroundColor" v-model:line-color="configForm.appium.flowLineColor" />
70
+ </section>
71
+ </div>
50
72
  </template>
51
73
 
52
74
  <style scoped>
53
75
  .config-save-status { font-size: 12px; font-weight: 400; color: var(--el-text-color-secondary); }
54
- .config-ai-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 32px; }
55
- .config-ai-title { margin: 0 0 20px; font-size: 15px; font-weight: 600; color: var(--el-text-color-primary); }
56
- .config-ai-dedup { min-width: 0; padding-left: 32px; border-left: 1px solid var(--el-border-color-lighter); }
76
+ .appium-config-stack { display: grid; gap: 28px; }
77
+ .appium-config-section { display: grid; gap: 14px; min-width: 0; }
78
+ .appium-section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; padding: 0 4px; }
79
+ .appium-section-heading h2 { margin: 0; color: var(--el-text-color-primary); font-size: 16px; line-height: 1.4; }
80
+ .appium-section-heading p { margin: 4px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.5; }
81
+ .appium-model-grid, .appium-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; align-items: stretch; }
82
+ .appium-settings-grid > .config-appium-card { display: flex; flex-direction: column; }
83
+ .appium-settings-grid > .config-appium-card :deep(.el-card__body) { flex: 1; }
84
+ .appium-config-section > .config-appium-card + .config-appium-card { margin-top: 2px; }
57
85
  @media (max-width: 1000px) {
58
- .config-ai-layout { grid-template-columns: minmax(0, 1fr); gap: 24px; }
59
- .config-ai-dedup { padding: 24px 0 0; border-left: 0; border-top: 1px solid var(--el-border-color-lighter); }
86
+ .appium-model-grid, .appium-settings-grid { grid-template-columns: minmax(0, 1fr); }
87
+ .appium-section-heading { align-items: flex-start; flex-direction: column; }
60
88
  }
61
89
  </style>
@@ -0,0 +1,45 @@
1
+ <script setup lang="ts">
2
+ import type { AiRecognitionModel } from '../../appium-recorder/ai-recognition';
3
+
4
+ const model = defineModel<AiRecognitionModel>({ required: true });
5
+ const props = defineProps<{
6
+ title: string;
7
+ badge: string;
8
+ description: string;
9
+ namePlaceholder: string;
10
+ modelKey: 'appium' | 'promptOptimizer';
11
+ testingModelKey: string;
12
+ testStatus?: string;
13
+ }>();
14
+ defineEmits<{ test: [] }>();
15
+ </script>
16
+
17
+ <template>
18
+ <el-card shadow="never" class="config-module-card config-appium-card appium-model-card">
19
+ <template #header>
20
+ <div class="panel-header">
21
+ <span class="panel-header__title">
22
+ <span>{{ title }}</span>
23
+ <el-tag size="small" effect="plain">{{ badge }}</el-tag>
24
+ </span>
25
+ </div>
26
+ </template>
27
+ <p class="appium-model-description">{{ description }}</p>
28
+ <el-form label-position="top" :disabled="!!testingModelKey">
29
+ <el-form-item label="Base URL"><el-input v-model="model.baseUrl" placeholder="兼容 OpenAI 的 API 地址" /></el-form-item>
30
+ <el-form-item label="API Key"><el-input v-model="model.apiKey" type="password" show-password /></el-form-item>
31
+ <el-form-item label="Model Name"><el-input v-model="model.name" :placeholder="namePlaceholder" /></el-form-item>
32
+ <el-alert v-if="testStatus" class="appium-model-status" :title="testStatus"
33
+ :type="testStatus.startsWith('测试通过') ? 'success' : 'warning'" :closable="false" show-icon />
34
+ <div class="appium-model-actions">
35
+ <el-button type="primary" plain :loading="testingModelKey === modelKey" :disabled="!!testingModelKey && testingModelKey !== modelKey" @click="$emit('test')">测试并保存</el-button>
36
+ </div>
37
+ </el-form>
38
+ </el-card>
39
+ </template>
40
+
41
+ <style scoped>
42
+ .appium-model-description { min-height: 40px; margin: 0 0 18px; color: var(--el-text-color-secondary); font-size: 13px; line-height: 1.6; }
43
+ .appium-model-status { margin-bottom: 16px; }
44
+ .appium-model-actions { display: flex; justify-content: flex-end; }
45
+ </style>
@@ -53,7 +53,6 @@ function update(key: 'background' | 'line', color: string) {
53
53
  </template>
54
54
 
55
55
  <style scoped>
56
- .flow-appearance-settings { margin-top: 20px; }
57
56
  .flow-appearance-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 32px; }
58
57
  .flow-appearance-section { min-width: 0; }
59
58
  .flow-appearance-section + .flow-appearance-section { padding-left: 32px; border-left: 1px solid var(--ui-border); }
@@ -0,0 +1,118 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, shallowRef, watch } from 'vue';
3
+ import { Plus } from '@element-plus/icons-vue';
4
+ import { ElMessage, ElMessageBox } from 'element-plus';
5
+ import { checkReportSummaryModel, optimizeAppiumPrompt } from '../../appium-recorder/api';
6
+ import { REPORT_SUMMARY_PROMPT_PRESETS, validateReportSummaryPrompt, type ReportSummaryConfig } from '../../appium-recorder/report-summary';
7
+ const props = defineProps<{ config: ReportSummaryConfig; testingModelKey?: string }>();
8
+ const promptPresets = computed(() => [...REPORT_SUMMARY_PROMPT_PRESETS, ...props.config.customPresets]);
9
+ const selectedScenario = computed(() => promptPresets.value.find(preset => preset.prompt === props.config.prompt)?.name);
10
+ const optimizingPrompt = shallowRef(false);
11
+ const modelStatus = shallowRef<'idle' | 'checking' | 'available' | 'unavailable'>('idle');
12
+ const modelStatusDetail = shallowRef('');
13
+ let modelCheckRevision = 0;
14
+ let optimizeController: AbortController | undefined;
15
+ onBeforeUnmount(() => { optimizeController?.abort(); modelCheckRevision += 1; });
16
+ async function validateSummaryModel() {
17
+ const revision = ++modelCheckRevision;
18
+ if (!props.config.enabled) { modelStatus.value = 'idle'; modelStatusDetail.value = ''; return; }
19
+ modelStatus.value = 'checking';
20
+ modelStatusDetail.value = '';
21
+ try {
22
+ const status = await checkReportSummaryModel();
23
+ if (revision !== modelCheckRevision || !props.config.enabled) return;
24
+ modelStatus.value = status.available ? 'available' : 'unavailable';
25
+ modelStatusDetail.value = status.message || '';
26
+ } catch (error) {
27
+ if (revision !== modelCheckRevision || !props.config.enabled) return;
28
+ modelStatus.value = 'unavailable';
29
+ modelStatusDetail.value = error instanceof Error ? error.message : '模型验证失败';
30
+ }
31
+ }
32
+ watch(() => props.config.enabled, () => { void validateSummaryModel(); }, { immediate: true });
33
+ watch(() => props.testingModelKey, (current, previous) => {
34
+ if (props.config.enabled && previous === 'promptOptimizer' && !current) void validateSummaryModel();
35
+ });
36
+ function selectScenario(name: string) {
37
+ const preset = promptPresets.value.find(item => item.name === name);
38
+ if (preset) props.config.prompt = preset.prompt;
39
+ }
40
+ async function addScenario() {
41
+ try {
42
+ const prompt = validateReportSummaryPrompt(props.config.prompt);
43
+ const result = await ElMessageBox.prompt('当前报告提示词将保存为新的测试场景。', '新增测试场景', {
44
+ inputPlaceholder: '请输入测试场景名称',
45
+ inputValidator: value => {
46
+ const name = value.trim();
47
+ if (!name || name.length > 80) return '场景名称不能为空,且不能超过 80 字';
48
+ if (promptPresets.value.some(item => item.name === name)) return '场景名称已存在';
49
+ return true;
50
+ },
51
+ confirmButtonText: '新增',
52
+ cancelButtonText: '取消',
53
+ });
54
+ props.config.customPresets.push({ name: result.value.trim(), prompt });
55
+ ElMessage.success('测试场景已新增');
56
+ } catch (action) {
57
+ if (action !== 'cancel' && action !== 'close') ElMessage.error(action instanceof Error ? action.message : '新增测试场景失败');
58
+ }
59
+ }
60
+ async function optimizePrompt() {
61
+ if (optimizingPrompt.value) return;
62
+ optimizeController = new AbortController();
63
+ optimizingPrompt.value = true;
64
+ try {
65
+ props.config.prompt = (await optimizeAppiumPrompt({
66
+ kind: 'reportSummary',
67
+ prompt: props.config.prompt,
68
+ }, optimizeController.signal)).prompt;
69
+ ElMessage.success('报告提示词已优化');
70
+ } catch (error) {
71
+ if (!optimizeController.signal.aborted) ElMessage.error(error instanceof Error ? error.message : '提示词优化失败');
72
+ } finally {
73
+ optimizingPrompt.value = false;
74
+ }
75
+ }
76
+ </script>
77
+
78
+ <template>
79
+ <el-card shadow="never" class="config-module-card config-appium-card report-summary-card">
80
+ <template #header>
81
+ <div class="panel-header">
82
+ <span class="panel-header__title">
83
+ <span>回放报告总结</span>
84
+ <el-switch v-model="config.enabled" aria-label="启用回放报告总结" />
85
+ <el-tag v-if="config.enabled && modelStatus === 'checking'" size="small" effect="plain" type="info">正在验证模型…</el-tag>
86
+ <el-tooltip v-else-if="config.enabled && modelStatus === 'unavailable'" :content="modelStatusDetail || '回放时将跳过 AI 总结并保留默认基础报告'" placement="top">
87
+ <el-tag size="small" effect="plain" type="warning">模型不可用,使用默认模板</el-tag>
88
+ </el-tooltip>
89
+ </span>
90
+ </div>
91
+ </template>
92
+ <el-alert class="report-summary-description" title="开启后使用上方的提示词优化模型,根据日志和节点配置生成 Markdown 报告。总结耗时不计入测试总时长,生成失败时保留基础报告。" type="info" :closable="false" show-icon />
93
+ <el-form label-position="top" class="report-summary-prompt">
94
+ <el-form-item label="测试场景">
95
+ <div class="report-summary-scenario">
96
+ <el-select :model-value="selectedScenario" :disabled="optimizingPrompt" filterable placeholder="选择测试场景,或在下方输入" aria-label="选择报告测试场景" @change="selectScenario">
97
+ <el-option v-for="preset in promptPresets" :key="preset.name" :label="preset.name" :value="preset.name" />
98
+ </el-select>
99
+ <el-button :icon="Plus" :disabled="optimizingPrompt || !config.prompt.trim()" @click="addScenario">新增场景</el-button>
100
+ </div>
101
+ </el-form-item>
102
+ <el-form-item label="报告提示词">
103
+ <el-input v-model="config.prompt" type="textarea" :rows="14" maxlength="20000" show-word-limit aria-label="报告提示词" :disabled="optimizingPrompt" />
104
+ </el-form-item>
105
+ <div class="report-summary-prompt-actions">
106
+ <el-button :loading="optimizingPrompt" :disabled="!config.prompt.trim()" @click="optimizePrompt">优化提示词</el-button>
107
+ </div>
108
+ </el-form>
109
+ </el-card>
110
+ </template>
111
+
112
+ <style scoped>
113
+ .report-summary-description { margin-bottom: 20px; }
114
+ .report-summary-prompt { min-width: 0; }
115
+ .report-summary-scenario { display: flex; width: 100%; gap: 10px; }
116
+ .report-summary-scenario .el-select { flex: 1; min-width: 0; }
117
+ .report-summary-prompt-actions { display: flex; justify-content: flex-end; }
118
+ </style>
@@ -31,6 +31,7 @@ const props = defineProps<{
31
31
  selectedBounds?: DeviceOverlayBounds;
32
32
  selectedRegion?: DeviceOverlayBounds;
33
33
  regionSelection?: boolean;
34
+ pointSelection?: boolean;
34
35
  regionDrawMode?: boolean;
35
36
  deviceWidth?: number;
36
37
  deviceHeight?: number;
@@ -45,6 +46,7 @@ const emit = defineEmits<{
45
46
  previewError: [];
46
47
  refreshPreview: [];
47
48
  tap: [point: { x: number; y: number }];
49
+ cancelPointSelection: [];
48
50
  regionSelect: [region: DeviceRegionSelection];
49
51
  swipe: [gesture: {
50
52
  startX: number;
@@ -257,6 +259,14 @@ function handlePointerUp(event: PointerEvent) {
257
259
  return;
258
260
  }
259
261
 
262
+ if (props.pointSelection) {
263
+ if (!props.interactionDisabled) emit('tap', {
264
+ x: Math.min(point.x, (props.deviceWidth || imageSize.value.width) - 1),
265
+ y: Math.min(point.y, (props.deviceHeight || imageSize.value.height) - 1),
266
+ });
267
+ return;
268
+ }
269
+
260
270
  if (session.mode !== 'gesture') {
261
271
  const bounds = draftRegion.value || boundsFromPoints('selected-region', session.start, point);
262
272
  draftRegion.value = null;
@@ -299,7 +309,7 @@ function handlePointerCancel(event: PointerEvent) {
299
309
  <el-tag v-if="compact" :type="available ? 'success' : 'info'">
300
310
  {{ available ? '已连接' : '未连接' }}
301
311
  </el-tag>
302
- <el-tooltip v-if="compact" :content="enlarged ? '退出设备放大' : '放大设备预览'" placement="top" :show-after="200"><el-button class="recorder-panel-tool" size="small" :icon="enlarged ? Close : FullScreen" :aria-label="enlarged ? '退出设备放大' : '放大设备预览'" @click="enlarged = !enlarged" /></el-tooltip>
312
+ <el-tooltip v-if="compact && !pointSelection" :content="enlarged ? '退出设备放大' : '放大设备预览'" placement="top" :show-after="200"><el-button class="recorder-panel-tool" size="small" :icon="enlarged ? Close : FullScreen" :aria-label="enlarged ? '退出设备放大' : '放大设备预览'" @click="enlarged = !enlarged" /></el-tooltip>
303
313
  <el-tooltip content="刷新画面" placement="top" :show-after="200">
304
314
  <el-button
305
315
  :text="!compact"
@@ -339,7 +349,7 @@ function handlePointerCancel(event: PointerEvent) {
339
349
  :key="action.key"
340
350
  type="button"
341
351
  class="device-action-button"
342
- :disabled="interactionDisabled || regionSelection"
352
+ :disabled="interactionDisabled || regionSelection || pointSelection"
343
353
  :aria-label="action.label"
344
354
  @click="emit('triggerKey', action.keyCode)"
345
355
  >
@@ -348,6 +358,10 @@ function handlePointerCancel(event: PointerEvent) {
348
358
  </button>
349
359
  </div>
350
360
 
361
+ <div v-if="pointSelection" class="device-point-selection">
362
+ <span>请点击画面获取坐标</span>
363
+ <el-button size="small" text @click="emit('cancelPointSelection')">取消取点</el-button>
364
+ </div>
351
365
  <div
352
366
  ref="previewRef"
353
367
  class="device-preview"
@@ -364,7 +378,7 @@ function handlePointerCancel(event: PointerEvent) {
364
378
  >
365
379
  <div
366
380
  class="device-preview__surface"
367
- :class="{ 'device-preview__surface--selecting': regionSelection }"
381
+ :class="{ 'device-preview__surface--selecting': regionSelection || pointSelection }"
368
382
  @pointerdown="handlePointerDown"
369
383
  @pointermove="handlePointerMove"
370
384
  @pointerup="handlePointerUp"
@@ -445,6 +459,7 @@ function handlePointerCancel(event: PointerEvent) {
445
459
  </template>
446
460
 
447
461
  <style scoped>
462
+ .device-point-selection { display: flex; align-items: center; justify-content: space-between; gap: 4px; font-size: 12px; color: var(--el-color-primary); }
448
463
  .device-preview-title { display: flex; align-items: center; gap: 8px; }
449
464
  .device-preview-card--compact { display: flex; flex-direction: column; min-height: 0; }
450
465
  .device-preview-card--compact :deep(> .el-card__body) { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; gap: 8px; }