android-midscene-automation 0.1.41 → 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 (77) hide show
  1. package/AI_RECOGNITION_GUIDE.md +370 -0
  2. package/CHANGELOG.md +16 -0
  3. package/README.md +35 -3
  4. package/USAGE.md +18 -3
  5. package/package.json +2 -1
  6. package/remote-agent/index.ts +1 -0
  7. package/server/appium-auto-save.test.ts +61 -0
  8. package/server/appium-config.test.ts +165 -0
  9. package/server/appium-recorder/ai-deduplication.test.ts +80 -0
  10. package/server/appium-recorder/ai-deduplication.ts +74 -0
  11. package/server/appium-recorder/ai-observation.test.ts +125 -0
  12. package/server/appium-recorder/ai-observation.ts +125 -0
  13. package/server/appium-recorder/ai-recognition.ts +118 -22
  14. package/server/appium-recorder/appium-runner.ts +79 -14
  15. package/server/appium-recorder/bounded-loop.test.ts +55 -0
  16. package/server/appium-recorder/bounded-loop.ts +12 -1
  17. package/server/appium-recorder/history-capture.ts +1 -0
  18. package/server/appium-recorder/image-check.ts +1 -1
  19. package/server/appium-recorder/managed-appium.ts +15 -0
  20. package/server/appium-recorder/managed-cleanup.test.ts +82 -0
  21. package/server/appium-recorder/prompt-optimizer.ts +57 -0
  22. package/server/appium-recorder/replay-log.ts +13 -0
  23. package/server/appium-recorder/report-summary.test.ts +78 -0
  24. package/server/appium-recorder/report-summary.ts +60 -0
  25. package/server/appium-recorder/report.ts +45 -14
  26. package/server/appium-recorder/repository.ts +7 -0
  27. package/server/appium-recorder/routes.ts +78 -4
  28. package/server/appium-recorder/screenshot.ts +1 -0
  29. package/server/config.ts +34 -4
  30. package/server/http-api.ts +31 -5
  31. package/server/model-tester.ts +2 -1
  32. package/src/App.vue +53 -30
  33. package/src/api.ts +11 -4
  34. package/src/appium-recorder/AppiumPage.vue +167 -68
  35. package/src/appium-recorder/RunHistoryDialog.vue +1 -1
  36. package/src/appium-recorder/action-descriptions.ts +2 -1
  37. package/src/appium-recorder/ai-deduplication.ts +34 -0
  38. package/src/appium-recorder/ai-prompt-presets.ts +34 -0
  39. package/src/appium-recorder/ai-recognition.ts +27 -2
  40. package/src/appium-recorder/api.ts +36 -2
  41. package/src/appium-recorder/bounded-loop.ts +9 -1
  42. package/src/appium-recorder/components/AiRecognitionPromptInput.vue +41 -0
  43. package/src/appium-recorder/components/AiRecognitionSettings.vue +167 -0
  44. package/src/appium-recorder/components/AiRecognitionTestDialog.vue +86 -0
  45. package/src/appium-recorder/components/BreakLoopSettings.vue +19 -3
  46. package/src/appium-recorder/components/ComponentTree.vue +5 -5
  47. package/src/appium-recorder/components/CoordinatePointPicker.vue +23 -0
  48. package/src/appium-recorder/components/FlowActionMenu.vue +50 -20
  49. package/src/appium-recorder/components/FlowCanvas.vue +3 -2
  50. package/src/appium-recorder/components/FlowNodeCard.vue +2 -2
  51. package/src/appium-recorder/components/FlowStepEditor.vue +17 -3
  52. package/src/appium-recorder/components/NestedConditionBranches.vue +25 -50
  53. package/src/appium-recorder/components/RecordedSteps.vue +23 -6
  54. package/src/appium-recorder/coordinate-preview.ts +37 -0
  55. package/src/appium-recorder/flow-appearance.ts +12 -1
  56. package/src/appium-recorder/flow-copy.ts +1 -0
  57. package/src/appium-recorder/flow-graph.ts +7 -4
  58. package/src/appium-recorder/flow-labels.ts +9 -5
  59. package/src/appium-recorder/flow-merge.ts +1 -1
  60. package/src/appium-recorder/flow-unmerge.ts +2 -2
  61. package/src/appium-recorder/report-summary.ts +60 -0
  62. package/src/appium-recorder/types.ts +6 -0
  63. package/src/components/config/AiDeduplicationSettings.vue +60 -0
  64. package/src/components/config/AiPromptPresetsSettings.vue +63 -0
  65. package/src/components/config/AppiumConfigPanel.vue +76 -3
  66. package/src/components/config/AppiumModelSettings.vue +45 -0
  67. package/src/components/config/BaseConfigPanel.vue +20 -8
  68. package/src/components/config/FlowAppearanceSettings.vue +51 -32
  69. package/src/components/config/MidsceneConfigPanel.vue +84 -86
  70. package/src/components/config/ReplayReportSummarySettings.vue +118 -0
  71. package/src/components/device/DevicePreviewPanel.vue +18 -3
  72. package/src/config/midscene-model-presets.ts +37 -9
  73. package/src/config/use-appium-auto-save.ts +62 -0
  74. package/src/pages/ConfigPage.vue +8 -6
  75. package/src/style.css +5 -55
  76. package/src/types.ts +4 -1
  77. package/src/ui-refresh.css +17 -4
@@ -0,0 +1,63 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue';
3
+ import { Delete, Plus } from '@element-plus/icons-vue';
4
+ import { ElMessage } from 'element-plus';
5
+ import { resolveAiPromptPresets, type AiPromptPreset } from '../../appium-recorder/ai-prompt-presets';
6
+ import { validateAiRecognitionPrompt } from '../../appium-recorder/ai-recognition';
7
+
8
+ const props = defineProps<{ modelValue?: AiPromptPreset[] }>();
9
+ const emit = defineEmits<{ 'update:modelValue': [presets: AiPromptPreset[]] }>();
10
+ const presets = computed(() => resolveAiPromptPresets(props.modelValue));
11
+ const draft = ref('');
12
+ const sceneName = ref('');
13
+
14
+ function addPreset() {
15
+ try {
16
+ const prompt = validateAiRecognitionPrompt(draft.value);
17
+ const next = resolveAiPromptPresets([...presets.value, { name: sceneName.value, prompt }]);
18
+ emit('update:modelValue', next);
19
+ sceneName.value = '';
20
+ draft.value = '';
21
+ } catch (error) { ElMessage.warning(error instanceof Error ? error.message : '添加提示词失败'); }
22
+ }
23
+ </script>
24
+
25
+ <template>
26
+ <section class="ai-prompt-settings" aria-labelledby="ai-prompt-presets-title">
27
+ <h3 id="ai-prompt-presets-title" class="ai-prompt-visually-hidden">AI 识别预设提示词</h3>
28
+ <p class="ai-prompt-description">添加常用测试场景,录制 AI 识别节点时可以直接选择。删除预设不会修改已有节点。</p>
29
+ <div class="ai-prompt-layout">
30
+ <el-form label-position="top" @submit.prevent="addPreset">
31
+ <el-form-item label="测试场景">
32
+ <el-input v-model="sceneName" maxlength="80" placeholder="例如:开屏广告检测" />
33
+ </el-form-item>
34
+ <el-form-item label="新增提示词">
35
+ <el-input v-model="draft" type="textarea" :rows="4" maxlength="4000" show-word-limit placeholder="输入常用的识别内容,例如:当前画面是否出现广告弹窗?" />
36
+ </el-form-item>
37
+ <div class="ai-prompt-actions"><el-button native-type="submit" :icon="Plus">添加提示词</el-button></div>
38
+ </el-form>
39
+ <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))" />
43
+ </div>
44
+ <el-empty v-if="!presets.length" description="暂无预设提示词" :image-size="60" />
45
+ </div>
46
+ </div>
47
+ </section>
48
+ </template>
49
+
50
+ <style scoped>
51
+ .ai-prompt-visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
52
+ .ai-prompt-description { margin: 0 0 20px; color: var(--el-text-color-secondary); font-size: 12px; }
53
+ .ai-prompt-layout { display: grid; gap: 20px; }
54
+ .ai-prompt-actions { display: flex; justify-content: flex-end; }
55
+ .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); }
56
+ .ai-prompt-row { display: flex; align-items: flex-start; gap: 12px; height: var(--ai-prompt-row-height); box-sizing: border-box; padding: 10px 0; }
57
+ .ai-prompt-row + .ai-prompt-row { border-top: 1px solid var(--ui-border); }
58
+ .ai-prompt-content { flex: 1; min-width: 0; font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.6; }
59
+ .ai-prompt-content strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
60
+ .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; }
61
+ .ai-prompt-row .el-button { flex-shrink: 0; }
62
+ .ai-prompt-list :deep(.el-empty) { height: 100%; padding: 0; }
63
+ </style>
@@ -1,16 +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';
5
+ import AiDeduplicationSettings from './AiDeduplicationSettings.vue';
6
+ import AiPromptPresetsSettings from './AiPromptPresetsSettings.vue';
7
+ import AppiumModelSettings from './AppiumModelSettings.vue';
4
8
 
5
9
  defineProps<{
6
10
  configForm: ConfigForm;
7
11
  testingModelKey: string;
8
- isSavingModelConfig: boolean;
9
12
  testStatus?: string;
13
+ promptOptimizerTestStatus?: string;
14
+ saveStatus?: string;
10
15
  }>();
11
- defineEmits<{ testModel: []; saveModelConfig: [] }>();
16
+ defineEmits<{ testModel: []; testPromptOptimizerModel: [] }>();
12
17
  </script>
13
18
 
14
19
  <template>
15
- <FlowAppearanceSettings v-model="configForm.appium.flowBackgroundColor" :saving="isSavingModelConfig" @save="$emit('saveModelConfig')" />
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')" />
34
+ </div>
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>
16
72
  </template>
73
+
74
+ <style scoped>
75
+ .config-save-status { font-size: 12px; font-weight: 400; color: var(--el-text-color-secondary); }
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; }
85
+ @media (max-width: 1000px) {
86
+ .appium-model-grid, .appium-settings-grid { grid-template-columns: minmax(0, 1fr); }
87
+ .appium-section-heading { align-items: flex-start; flex-direction: column; }
88
+ }
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>
@@ -15,6 +15,7 @@ defineEmits<{
15
15
  saveAppPreset: [];
16
16
  editAppPreset: [app: AppPreset];
17
17
  deleteAppPreset: [id: string];
18
+ cancelAppPresetEdit: [];
18
19
  }>();
19
20
  </script>
20
21
 
@@ -55,21 +56,21 @@ defineEmits<{
55
56
  <span>预设 App 参数</span>
56
57
  <el-tag v-if="appPresetForm.id" size="small" type="warning">编辑中</el-tag>
57
58
  </div>
58
- <div class="panel-header__actions">
59
- <el-button type="primary" :loading="isSavingAppPreset" @click="$emit('saveAppPreset')">
60
- {{ appPresetForm.id ? '保存修改' : '保存 App' }}
61
- </el-button>
62
- </div>
63
59
  </div>
64
60
  </template>
65
61
 
66
- <el-form label-position="top">
62
+ <div class="app-preset-layout">
63
+ <el-form label-position="top" :disabled="isSavingAppPreset" @submit.prevent="$emit('saveAppPreset')">
67
64
  <el-form-item label="App 名称">
68
65
  <el-input v-model="appPresetForm.name" placeholder="例如:示例 App" />
69
66
  </el-form-item>
70
67
  <el-form-item label="App 包名">
71
68
  <el-input v-model="appPresetForm.packageName" placeholder="例如:com.example.app" />
72
69
  </el-form-item>
70
+ <div class="app-preset-actions">
71
+ <el-button v-if="appPresetForm.id" :disabled="isSavingAppPreset" @click="$emit('cancelAppPresetEdit')">取消编辑</el-button>
72
+ <el-button native-type="submit" :loading="isSavingAppPreset">{{ appPresetForm.id ? '保存修改' : '添加' }}</el-button>
73
+ </div>
73
74
  </el-form>
74
75
 
75
76
  <div class="app-preset-list">
@@ -79,12 +80,23 @@ defineEmits<{
79
80
  <span>{{ app.packageName }}</span>
80
81
  </div>
81
82
  <div class="script-row__actions">
82
- <el-button text size="small" :icon="Edit" @click="$emit('editAppPreset', app)" />
83
- <el-button text size="small" :icon="Delete" @click="$emit('deleteAppPreset', app.id)" />
83
+ <el-button text size="small" :icon="Edit" :disabled="isSavingAppPreset" :aria-label="`编辑 ${app.name}`" @click="$emit('editAppPreset', app)" />
84
+ <el-button text size="small" :icon="Delete" :disabled="isSavingAppPreset" :aria-label="`删除 ${app.name}`" @click="$emit('deleteAppPreset', app.id)" />
84
85
  </div>
85
86
  </div>
86
87
  <el-empty v-if="!appPresets.length" description="暂无预设 App" />
87
88
  </div>
89
+ </div>
88
90
  </el-card>
89
91
  </div>
90
92
  </template>
93
+
94
+ <style scoped>
95
+ .app-preset-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 24px; align-items: start; }
96
+ .app-preset-actions { display: flex; justify-content: flex-end; margin-top: 4px; }
97
+ .app-preset-layout .app-preset-list { margin-top: 0; padding-left: 24px; border-left: 1px solid var(--ui-border); }
98
+ @media (max-width: 800px) {
99
+ .app-preset-layout { grid-template-columns: minmax(0, 1fr); }
100
+ .app-preset-layout .app-preset-list { padding: 24px 4px 0 0; border-left: 0; border-top: 1px solid var(--ui-border); }
101
+ }
102
+ </style>
@@ -1,51 +1,70 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue';
3
3
  import { RefreshLeft } from '@element-plus/icons-vue';
4
- import { DEFAULT_FLOW_BACKGROUND, FLOW_BACKGROUND_PRESETS, isHexColor, normalizeFlowBackground } from '../../appium-recorder/flow-appearance';
4
+ import {
5
+ DEFAULT_FLOW_BACKGROUND, FLOW_BACKGROUND_PRESETS, normalizeFlowBackground,
6
+ DEFAULT_FLOW_LINE_COLOR, FLOW_LINE_COLOR_PRESETS, normalizeFlowLineColor, isHexColor,
7
+ } from '../../appium-recorder/flow-appearance';
5
8
 
6
- const props = defineProps<{ modelValue?: string; saving: boolean }>();
7
- const emit = defineEmits<{ 'update:modelValue': [color: string]; save: [] }>();
8
- const color = computed(() => props.modelValue ?? DEFAULT_FLOW_BACKGROUND);
9
- const error = computed(() => isHexColor(color.value) ? '' : '请输入有效的十六进制颜色,如 #ABC 或 #AABBCC');
9
+ const props = defineProps<{ modelValue?: string; lineColor?: string }>();
10
+ const emit = defineEmits<{ 'update:modelValue': [color: string]; 'update:lineColor': [color: string] }>();
11
+ const panels = computed(() => [
12
+ { key: 'background' as const, title: '流程背景色', color: props.modelValue ?? DEFAULT_FLOW_BACKGROUND,
13
+ fallback: DEFAULT_FLOW_BACKGROUND, presets: FLOW_BACKGROUND_PRESETS, normalize: normalizeFlowBackground,
14
+ inputLabel: '十六进制背景色', pickerLabel: '选择流程背景色', resetLabel: '恢复默认颜色', presetLabel: '选择颜色' },
15
+ { key: 'line' as const, title: '连接线条颜色', color: props.lineColor ?? DEFAULT_FLOW_LINE_COLOR,
16
+ fallback: DEFAULT_FLOW_LINE_COLOR, presets: FLOW_LINE_COLOR_PRESETS, normalize: normalizeFlowLineColor,
17
+ inputLabel: '十六进制线条颜色', pickerLabel: '选择连接线条颜色', resetLabel: '恢复默认线条颜色', presetLabel: '选择线条颜色' },
18
+ ].map(panel => ({ ...panel, error: isHexColor(panel.color) ? '' : '请输入有效的十六进制颜色,如 #ABC 或 #AABBCC' })));
19
+ function update(key: 'background' | 'line', color: string) {
20
+ if (key === 'background') emit('update:modelValue', color);
21
+ else emit('update:lineColor', color);
22
+ }
10
23
  </script>
11
24
 
12
25
  <template>
13
26
  <el-card shadow="never" class="config-module-card config-appium-card flow-appearance-settings">
14
- <template #header>
15
- <div class="panel-header">
16
- <span>流程背景色</span>
17
- <el-button type="primary" :loading="saving" :disabled="Boolean(error)" @click="emit('save')">保存背景色</el-button>
18
- </div>
19
- </template>
20
- <el-form label-position="top">
21
- <el-form-item label="预选颜色">
22
- <div class="flow-color-presets">
23
- <button v-for="preset in FLOW_BACKGROUND_PRESETS" :key="preset" type="button"
24
- :style="{ backgroundColor: preset }" :aria-label="`选择颜色 ${preset}`" :title="preset"
25
- :aria-pressed="normalizeFlowBackground(color) === preset && !error"
26
- @click="emit('update:modelValue', preset)" />
27
- </div>
28
- </el-form-item>
29
- <el-form-item label="自定义颜色" :error="error">
30
- <div class="flow-color-input">
31
- <el-color-picker :model-value="normalizeFlowBackground(color)" color-format="hex" :predefine="FLOW_BACKGROUND_PRESETS"
32
- aria-label="选择流程背景色" @update:model-value="emit('update:modelValue', $event || DEFAULT_FLOW_BACKGROUND)" />
33
- <el-input :model-value="color" aria-label="十六进制背景色" placeholder="#D4E8DD"
34
- @update:model-value="emit('update:modelValue', String($event))" />
35
- <el-button :icon="RefreshLeft" title="恢复默认颜色" aria-label="恢复默认颜色" @click="emit('update:modelValue', DEFAULT_FLOW_BACKGROUND)" />
36
- </div>
37
- </el-form-item>
38
- </el-form>
27
+ <template #header><div class="panel-header"><span>流程外观</span></div></template>
28
+ <div class="flow-appearance-columns">
29
+ <section v-for="panel in panels" :key="panel.key" class="flow-appearance-section" :aria-label="panel.title">
30
+ <h3>{{ panel.title }}</h3>
31
+ <el-form label-position="top">
32
+ <el-form-item label="预选颜色">
33
+ <div class="flow-color-presets">
34
+ <button v-for="preset in panel.presets" :key="preset" type="button"
35
+ :style="{ backgroundColor: preset }" :aria-label="`${panel.presetLabel} ${preset}`" :title="preset"
36
+ :aria-pressed="panel.normalize(panel.color) === preset && !panel.error"
37
+ @click="update(panel.key, preset)" />
38
+ </div>
39
+ </el-form-item>
40
+ <el-form-item label="自定义颜色" :error="panel.error">
41
+ <div class="flow-color-input">
42
+ <el-color-picker :model-value="panel.normalize(panel.color)" color-format="hex" :predefine="panel.presets"
43
+ :aria-label="panel.pickerLabel" @update:model-value="update(panel.key, $event || panel.fallback)" />
44
+ <el-input :model-value="panel.color" :aria-label="panel.inputLabel" :placeholder="panel.fallback"
45
+ @update:model-value="update(panel.key, String($event))" />
46
+ <el-button :icon="RefreshLeft" :title="panel.resetLabel" :aria-label="panel.resetLabel" @click="update(panel.key, panel.fallback)" />
47
+ </div>
48
+ </el-form-item>
49
+ </el-form>
50
+ </section>
51
+ </div>
39
52
  </el-card>
40
53
  </template>
41
54
 
42
55
  <style scoped>
43
- .flow-appearance-settings { margin-top: 20px; }
44
- .flow-appearance-settings .el-form { max-width: 640px; }
56
+ .flow-appearance-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 32px; }
57
+ .flow-appearance-section { min-width: 0; }
58
+ .flow-appearance-section + .flow-appearance-section { padding-left: 32px; border-left: 1px solid var(--ui-border); }
59
+ .flow-appearance-section h3 { margin: 0 0 20px; font-size: 15px; font-weight: 600; color: var(--el-text-color-primary); }
45
60
  .flow-color-presets, .flow-color-input { display: flex; align-items: center; gap: 10px; }
46
61
  .flow-color-presets { flex-wrap: wrap; }
47
62
  .flow-color-presets button { width: 32px; height: 32px; border: 1px solid var(--el-border-color-darker); border-radius: 4px; cursor: pointer; }
48
63
  .flow-color-presets button[aria-pressed="true"] { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
49
64
  .flow-color-input { width: 100%; }
50
65
  .flow-color-input .el-input { flex: 1; min-width: 0; }
66
+ @media (max-width: 800px) {
67
+ .flow-appearance-columns { grid-template-columns: minmax(0, 1fr); gap: 24px; }
68
+ .flow-appearance-section + .flow-appearance-section { padding: 24px 0 0; border-left: 0; border-top: 1px solid var(--ui-border); }
69
+ }
51
70
  </style>
@@ -1,9 +1,11 @@
1
1
  <script setup lang="ts">
2
- import { computed } from 'vue';
3
- import { QuestionFilled } from '@element-plus/icons-vue';
2
+ import { computed, reactive } from 'vue';
3
+ import { ArrowDown, QuestionFilled } from '@element-plus/icons-vue';
4
4
  import {
5
5
  codexMidsceneModelOptions,
6
6
  midsceneModelPresets,
7
+ midsceneModelOptions,
8
+ midsceneModelFamilyOptions,
7
9
  type MidsceneModelProvider,
8
10
  } from '../../config/midscene-model-presets';
9
11
  import type { ConfigForm } from '../../types';
@@ -11,44 +13,52 @@ import type { ConfigForm } from '../../types';
11
13
  const props = defineProps<{
12
14
  configForm: ConfigForm;
13
15
  testingModelKey: string;
14
- isSavingModelConfig: boolean;
15
16
  modelTestStatus: { midscene: string; scriptOptimizer: string };
16
17
  }>();
17
18
 
18
19
  const emit = defineEmits<{
19
20
  testModel: [key: 'midscene' | 'scriptOptimizer'];
20
- saveModelConfig: [];
21
21
  updateMidsceneModelProvider: [provider: MidsceneModelProvider];
22
22
  }>();
23
23
 
24
24
  const activeMidsceneProvider = computed<MidsceneModelProvider>(() =>
25
- props.configForm.midscene.model.provider === 'codex' ? 'codex' : 'custom',
25
+ props.configForm.midscene.model.provider || 'custom',
26
26
  );
27
- const modelConfigGuideUrl = 'https://midscenejs.com/zh/model-common-config.html';
28
-
29
- const normalizeBaseUrl = (value: string) => value.trim().replace(/\/+$/, '');
27
+ const activePreset = computed(() => midsceneModelPresets.find(item => item.key === activeMidsceneProvider.value));
28
+ const modelFamilyOptions = computed(() => {
29
+ if (activeMidsceneProvider.value === 'custom') return midsceneModelFamilyOptions;
30
+ const families = activeMidsceneProvider.value === 'codex'
31
+ ? codexMidsceneModelOptions.map(option => option.family)
32
+ : activePreset.value?.modelFamilies ?? (activePreset.value ? [activePreset.value.modelFamily] : []);
33
+ return [...new Set(families)].map(family => ({ label: family, value: family }));
34
+ });
35
+ const modelNameOptions = computed(() => activeMidsceneProvider.value === 'codex'
36
+ ? codexMidsceneModelOptions
37
+ : midsceneModelOptions.filter(option => modelFamilyOptions.value.some(family => family.value === option.family)));
38
+ const modelSearchActive = reactive({ name: false, family: false });
39
+ const modelSuggestions = (field: 'name' | 'family', query: string) => {
40
+ const options = field === 'name' ? modelNameOptions.value : modelFamilyOptions.value;
41
+ return modelSearchActive[field]
42
+ ? options.filter(option => option.value.toLowerCase().includes(query.toLowerCase()))
43
+ : options;
44
+ };
45
+ const modelConfigGuideUrl = 'https://midscenejs.com/zh/model-common-config.html#glm-v';
30
46
 
31
47
  const updateMidsceneModelName = (value: string) => {
32
48
  props.configForm.midscene.model.name = value;
33
- const option = codexMidsceneModelOptions.find((item) => item.value === value);
49
+ const option = modelNameOptions.value.find((item) => item.value === value);
34
50
  if (option) {
35
51
  props.configForm.midscene.model.family = option.family;
36
52
  }
37
53
  };
38
54
 
39
- const updateCustomMidsceneBaseUrl = (value: string) => {
40
- props.configForm.midscene.model.baseUrl = value;
41
- const preset = midsceneModelPresets.find((item) => normalizeBaseUrl(item.baseUrl) === normalizeBaseUrl(value));
42
- if (preset) {
43
- props.configForm.midscene.model.name = preset.modelName;
44
- props.configForm.midscene.model.family = preset.modelFamily;
45
- }
46
- };
47
-
48
55
  const updateMidsceneProvider = (value: string) => {
49
56
  if (value === 'custom' || value === 'codex') {
50
57
  emit('updateMidsceneModelProvider', value);
58
+ return;
51
59
  }
60
+ const preset = midsceneModelPresets.find(item => item.key === value);
61
+ if (preset) emit('updateMidsceneModelProvider', preset.key);
52
62
  };
53
63
 
54
64
  const openModelConfigGuide = () => {
@@ -57,13 +67,21 @@ const openModelConfigGuide = () => {
57
67
  </script>
58
68
 
59
69
  <template>
60
- <el-card shadow="never" class="config-module-card">
70
+ <el-card shadow="never" class="config-module-card config-midscene-card">
61
71
  <template #header>
62
72
  <div class="panel-header">
63
- <span>模型配置</span>
64
- <el-button type="primary" :loading="isSavingModelConfig" @click="$emit('saveModelConfig')">
65
- 保存模型配置
66
- </el-button>
73
+ <span class="panel-header__title">
74
+ <span>模型配置</span>
75
+ <el-tooltip content="模型参考配置" placement="top" :show-after="200">
76
+ <el-button
77
+ class="config-model-help"
78
+ text
79
+ :icon="QuestionFilled"
80
+ aria-label="模型参考配置"
81
+ @click="openModelConfigGuide"
82
+ />
83
+ </el-tooltip>
84
+ </span>
67
85
  </div>
68
86
  </template>
69
87
 
@@ -73,71 +91,36 @@ const openModelConfigGuide = () => {
73
91
  <span>Midscene 模型</span>
74
92
  <el-button
75
93
  :loading="testingModelKey === 'midscene'"
94
+ :disabled="!!testingModelKey"
76
95
  @click="$emit('testModel', 'midscene')"
77
96
  >
78
- 测试模型
97
+ 测试并保存
79
98
  </el-button>
80
99
  </div>
81
- <el-form label-position="top">
100
+ <el-form label-position="top" :disabled="!!testingModelKey">
82
101
  <el-form-item label="接入方式">
83
102
  <el-select
84
103
  :model-value="activeMidsceneProvider"
85
104
  @change="updateMidsceneProvider"
86
105
  >
87
106
  <el-option label="自定义提供方" value="custom" />
107
+ <el-option v-for="preset in midsceneModelPresets" :key="preset.key" :label="preset.label" :value="preset.key" />
88
108
  <el-option label="使用 Codex" value="codex" />
89
109
  </el-select>
90
110
  </el-form-item>
91
111
 
92
- <template v-if="activeMidsceneProvider === 'custom'">
112
+ <template v-if="activeMidsceneProvider !== 'codex'">
113
+ <el-alert v-if="activePreset?.hint" class="config-form-alert" type="info" :closable="false" :title="activePreset.hint" />
93
114
  <el-form-item label="Base URL">
94
115
  <el-input
95
- :model-value="configForm.midscene.model.baseUrl"
96
- placeholder="输入 Base URL,匹配已知提供方时自动填充模型"
97
- @input="updateCustomMidsceneBaseUrl"
116
+ v-model="configForm.midscene.model.baseUrl"
117
+ placeholder="选择接入方式自动填充,也可手动修改"
98
118
  />
99
119
  </el-form-item>
100
120
  <el-form-item label="API Key">
101
121
  <el-input v-model="configForm.midscene.model.apiKey" show-password />
102
122
  </el-form-item>
103
- <el-form-item>
104
- <template #label>
105
- <span class="config-field-label">
106
- <span>Model Name</span>
107
- <el-tooltip content="查看模型填写参考" placement="top">
108
- <el-button
109
- class="config-field-help"
110
- text
111
- size="small"
112
- :icon="QuestionFilled"
113
- aria-label="查看 Model Name 填写参考"
114
- @click.stop="openModelConfigGuide"
115
- />
116
- </el-tooltip>
117
- </span>
118
- </template>
119
- <el-input v-model="configForm.midscene.model.name" placeholder="例如:gpt-5.5" />
120
- </el-form-item>
121
- <el-form-item>
122
- <template #label>
123
- <span class="config-field-label">
124
- <span>Model Family</span>
125
- <el-tooltip content="查看模型填写参考" placement="top">
126
- <el-button
127
- class="config-field-help"
128
- text
129
- size="small"
130
- :icon="QuestionFilled"
131
- aria-label="查看 Model Family 填写参考"
132
- @click.stop="openModelConfigGuide"
133
- />
134
- </el-tooltip>
135
- </span>
136
- </template>
137
- <el-input v-model="configForm.midscene.model.family" placeholder="例如:gpt-5" />
138
- </el-form-item>
139
123
  </template>
140
-
141
124
  <template v-else>
142
125
  <el-alert
143
126
  class="config-form-alert"
@@ -148,25 +131,34 @@ const openModelConfigGuide = () => {
148
131
  <el-form-item label="Base URL">
149
132
  <el-input :model-value="configForm.midscene.model.baseUrl" readonly />
150
133
  </el-form-item>
151
- <el-form-item label="Model Name">
152
- <el-select
153
- :model-value="configForm.midscene.model.name"
154
- @change="updateMidsceneModelName"
155
- >
156
- <el-option
157
- v-for="option in codexMidsceneModelOptions"
158
- :key="option.value"
159
- :label="option.label"
160
- :value="option.value"
161
- />
162
- </el-select>
163
- </el-form-item>
164
- <el-form-item label="Model Family">
165
- <el-select v-model="configForm.midscene.model.family" disabled>
166
- <el-option label="gpt-5" value="gpt-5" />
167
- </el-select>
168
- </el-form-item>
169
134
  </template>
135
+ <el-form-item label="Model Name">
136
+ <el-autocomplete
137
+ class="config-model-input"
138
+ :model-value="configForm.midscene.model.name"
139
+ :fetch-suggestions="(query: string) => modelSuggestions('name', query)"
140
+ :debounce="0"
141
+ :suffix-icon="ArrowDown"
142
+ fit-input-width
143
+ placeholder="选择或输入模型名称"
144
+ @focus="modelSearchActive.name = false"
145
+ @input="modelSearchActive.name = true"
146
+ @update:model-value="updateMidsceneModelName"
147
+ />
148
+ </el-form-item>
149
+ <el-form-item label="Model Family">
150
+ <el-autocomplete
151
+ v-model="configForm.midscene.model.family"
152
+ class="config-model-input"
153
+ :fetch-suggestions="(query: string) => modelSuggestions('family', query)"
154
+ :debounce="0"
155
+ :suffix-icon="ArrowDown"
156
+ fit-input-width
157
+ placeholder="选择或输入模型系列"
158
+ @focus="modelSearchActive.family = false"
159
+ @input="modelSearchActive.family = true"
160
+ />
161
+ </el-form-item>
170
162
  <el-form-item v-if="modelTestStatus.midscene" label="测试结果">
171
163
  <el-input :model-value="modelTestStatus.midscene" readonly />
172
164
  </el-form-item>
@@ -178,12 +170,13 @@ const openModelConfigGuide = () => {
178
170
  <span>脚本优化模型</span>
179
171
  <el-button
180
172
  :loading="testingModelKey === 'scriptOptimizer'"
173
+ :disabled="!!testingModelKey"
181
174
  @click="$emit('testModel', 'scriptOptimizer')"
182
175
  >
183
- 测试模型
176
+ 测试并保存
184
177
  </el-button>
185
178
  </div>
186
- <el-form label-position="top">
179
+ <el-form label-position="top" :disabled="!!testingModelKey">
187
180
  <el-form-item label="Base URL">
188
181
  <el-input v-model="configForm.scriptOptimizer.model.baseUrl" />
189
182
  </el-form-item>
@@ -202,3 +195,8 @@ const openModelConfigGuide = () => {
202
195
 
203
196
  </el-card>
204
197
  </template>
198
+
199
+ <style scoped>
200
+ .config-midscene-card :deep(.el-card__body) { background: var(--ui-bg-soft, #f8f9fb); }
201
+ .config-model-input { width: 100%; }
202
+ </style>