android-midscene-automation 0.1.41 → 0.1.42

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/CHANGELOG.md +11 -0
  2. package/README.md +13 -0
  3. package/USAGE.md +2 -0
  4. package/package.json +2 -1
  5. package/remote-agent/index.ts +1 -0
  6. package/server/appium-auto-save.test.ts +60 -0
  7. package/server/appium-config.test.ts +118 -0
  8. package/server/appium-recorder/ai-deduplication.test.ts +80 -0
  9. package/server/appium-recorder/ai-deduplication.ts +74 -0
  10. package/server/appium-recorder/ai-observation.test.ts +125 -0
  11. package/server/appium-recorder/ai-observation.ts +125 -0
  12. package/server/appium-recorder/ai-recognition.ts +118 -22
  13. package/server/appium-recorder/appium-runner.ts +59 -14
  14. package/server/appium-recorder/history-capture.ts +1 -0
  15. package/server/appium-recorder/image-check.ts +1 -1
  16. package/server/appium-recorder/managed-appium.ts +15 -0
  17. package/server/appium-recorder/managed-cleanup.test.ts +82 -0
  18. package/server/appium-recorder/replay-log.ts +13 -0
  19. package/server/appium-recorder/report.ts +14 -12
  20. package/server/appium-recorder/repository.ts +5 -0
  21. package/server/appium-recorder/routes.ts +26 -2
  22. package/server/appium-recorder/screenshot.ts +1 -0
  23. package/server/config.ts +19 -4
  24. package/server/http-api.ts +26 -4
  25. package/server/model-tester.ts +2 -1
  26. package/src/App.vue +41 -29
  27. package/src/api.ts +10 -3
  28. package/src/appium-recorder/AppiumPage.vue +41 -6
  29. package/src/appium-recorder/RunHistoryDialog.vue +1 -1
  30. package/src/appium-recorder/action-descriptions.ts +1 -1
  31. package/src/appium-recorder/ai-deduplication.ts +34 -0
  32. package/src/appium-recorder/ai-prompt-presets.ts +34 -0
  33. package/src/appium-recorder/ai-recognition.ts +27 -2
  34. package/src/appium-recorder/api.ts +14 -0
  35. package/src/appium-recorder/components/AiRecognitionPromptInput.vue +41 -0
  36. package/src/appium-recorder/components/AiRecognitionSettings.vue +143 -0
  37. package/src/appium-recorder/components/AiRecognitionTestDialog.vue +86 -0
  38. package/src/appium-recorder/components/FlowCanvas.vue +3 -2
  39. package/src/appium-recorder/components/FlowNodeCard.vue +2 -2
  40. package/src/appium-recorder/components/FlowStepEditor.vue +8 -2
  41. package/src/appium-recorder/components/NestedConditionBranches.vue +7 -1
  42. package/src/appium-recorder/components/RecordedSteps.vue +7 -1
  43. package/src/appium-recorder/flow-appearance.ts +12 -1
  44. package/src/appium-recorder/flow-graph.ts +2 -2
  45. package/src/appium-recorder/flow-labels.ts +6 -4
  46. package/src/appium-recorder/types.ts +4 -0
  47. package/src/components/config/AiDeduplicationSettings.vue +59 -0
  48. package/src/components/config/AiPromptPresetsSettings.vue +66 -0
  49. package/src/components/config/AppiumConfigPanel.vue +48 -3
  50. package/src/components/config/BaseConfigPanel.vue +20 -8
  51. package/src/components/config/FlowAppearanceSettings.vue +51 -31
  52. package/src/components/config/MidsceneConfigPanel.vue +84 -86
  53. package/src/config/midscene-model-presets.ts +37 -9
  54. package/src/config/use-appium-auto-save.ts +54 -0
  55. package/src/pages/ConfigPage.vue +4 -4
  56. package/src/style.css +5 -10
  57. package/src/types.ts +3 -1
  58. package/src/ui-refresh.css +17 -4
@@ -13,6 +13,7 @@ import LoopSettings from './LoopSettings.vue';
13
13
  import BreakLoopSettings from './BreakLoopSettings.vue';
14
14
  import VariableExtractionSettings from './VariableExtractionSettings.vue';
15
15
  import ScriptParameterSettings from './ScriptParameterSettings.vue';
16
+ import AiRecognitionSettings from './AiRecognitionSettings.vue';
16
17
 
17
18
  type FlowKind = 'action' | 'condition' | 'assertion';
18
19
  type SwipeGesture = NonNullable<AppiumRecordedStep['swipe']>;
@@ -154,17 +155,21 @@ function patchSelector(patch: Partial<AppiumSelector>) {
154
155
  />
155
156
  </el-form-item>
156
157
  <div>
157
- <el-form-item v-if="!['longPress', 'stopApp', 'log', 'openGallery', 'endFlow', 'loop', 'breakLoop'].includes(step.type)" label="超时时间 ms">
158
+ <el-form-item v-if="step.type === 'aiRecognition'">
159
+ <el-checkbox :model-value="step.aiTimeoutEnabled === true" :disabled="disabled" @update:model-value="patchStep({ aiTimeoutEnabled: $event === true })">启用超时</el-checkbox>
160
+ </el-form-item>
161
+ <el-form-item v-if="!['longPress', 'stopApp', 'log', 'openGallery', 'endFlow', 'loop', 'breakLoop'].includes(step.type) && (step.type !== 'aiRecognition' || step.aiTimeoutEnabled === true)" :label="step.type === 'aiRecognition' && step.aiObservation ? '模型请求超时 ms' : '超时时间 ms'">
158
162
  <el-input-number
159
163
  :model-value="step.timeoutMs ?? (step.type === 'delay' ? 1000 : DEFAULT_NODE_TIMEOUT_MS)"
160
164
  :disabled="disabled"
161
165
  :min="0"
162
166
  :max="999999"
167
+ :step="1000"
163
168
  controls-position="right"
164
169
  @update:model-value="patchTimeout($event)"
165
170
  />
166
171
  </el-form-item>
167
- <BranchTimeoutSettings v-if="defaultKind() === 'condition'" :step="step" :disabled="disabled" @update="patchStep({ timeoutBranch: $event })" />
172
+ <BranchTimeoutSettings v-if="defaultKind() === 'condition' && (step.type !== 'aiRecognition' || step.aiTimeoutEnabled === true)" :step="step" :disabled="disabled" @update="patchStep({ timeoutBranch: $event })" />
168
173
  </div>
169
174
  <LongPressSettings v-if="step.type === 'longPress'" :step="step" :disabled="disabled" @update="patchStep" />
170
175
  <el-form-item v-if="['waitActivity', 'launchApp', 'stopApp', 'clearAppData'].includes(step.type)" :label="step.type === 'waitActivity' ? '目标 Activity' : '目标 APP 包名'">
@@ -197,6 +202,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
197
202
  <LoopSettings v-if="step.type === 'loop'" :step="step" :disabled="disabled" @update="patchStep" />
198
203
  <BreakLoopSettings v-if="step.type === 'breakLoop'" :step="step" :steps="steps" :disabled="disabled" @update="patchStep" />
199
204
  <TextClickSettings v-if="step.type === 'textClick'" :step="step" :disabled="disabled" @update="patchStep" />
205
+ <AiRecognitionSettings v-if="step.type === 'aiRecognition'" :step="step" :steps="steps" :disabled="disabled" @update="patchStep" />
200
206
  <el-form-item
201
207
  v-if="step.type === 'input' || step.type === 'inputIfExists' || step.type === 'assertText'"
202
208
  label="文本内容"
@@ -119,7 +119,6 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
119
119
  actions: [
120
120
  { type: 'delay', label: '添加延时' },
121
121
  { type: 'popupCondition', label: '判断存在' },
122
- { type: 'imageCheck', label: '图像判断' },
123
122
  { type: 'tapIfExists', label: '存在则点击' },
124
123
  { type: 'inputIfExists', label: '存在则输入' },
125
124
  { type: 'clearIfExists', label: '存在则清空' },
@@ -131,6 +130,13 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
131
130
  { type: 'visualChangeEnd', label: '检测画面变化结束' },
132
131
  ],
133
132
  },
133
+ {
134
+ title: '视觉识别',
135
+ actions: [
136
+ { type: 'aiRecognition', label: 'AI 识别' },
137
+ { type: 'imageCheck', label: '图像判断' },
138
+ ],
139
+ },
134
140
  {
135
141
  title: '流程控制',
136
142
  actions: [
@@ -89,7 +89,6 @@ const insertActionGroups: FlowActionGroup[] = [
89
89
  actions: [
90
90
  { type: 'delay', label: '添加延时' },
91
91
  { type: 'popupCondition', label: '判断存在' },
92
- { type: 'imageCheck', label: '图像判断' },
93
92
  { type: 'tapIfExists', label: '存在则点击' },
94
93
  { type: 'inputIfExists', label: '存在则输入' },
95
94
  { type: 'clearIfExists', label: '存在则清空' },
@@ -101,6 +100,13 @@ const insertActionGroups: FlowActionGroup[] = [
101
100
  { type: 'visualChangeEnd', label: '检测画面变化结束节点' },
102
101
  ],
103
102
  },
103
+ {
104
+ title: '视觉识别',
105
+ actions: [
106
+ { type: 'aiRecognition', label: 'AI 识别' },
107
+ { type: 'imageCheck', label: '图像判断' },
108
+ ],
109
+ },
104
110
  {
105
111
  title: '流程控制',
106
112
  actions: [
@@ -3,13 +3,24 @@ import type { InjectionKey, Ref } from 'vue';
3
3
  export const DEFAULT_FLOW_BACKGROUND = '#d4e8dd';
4
4
  export const FLOW_BACKGROUND_PRESETS = ['#d4e8dd', '#d5e6f5', '#e4dcf2', '#f2dedc', '#d5eae9', '#e8e4cc'];
5
5
  export const flowBackgroundKey: InjectionKey<Readonly<Ref<string>>> = Symbol('flowBackground');
6
+ export const DEFAULT_FLOW_LINE_COLOR = '#719985';
7
+ export const FLOW_LINE_COLOR_PRESETS = ['#719985', '#4b83c4', '#8b6bb1', '#c78a46', '#7b8794', '#374151'];
8
+ export const flowLineColorKey: InjectionKey<Readonly<Ref<string>>> = Symbol('flowLineColor');
6
9
 
7
10
  export function isHexColor(value: unknown): value is string {
8
11
  return typeof value === 'string' && /^#(?:[\da-f]{3}|[\da-f]{6})$/i.test(value.trim());
9
12
  }
10
13
 
11
14
  export function normalizeFlowBackground(value: unknown) {
12
- if (!isHexColor(value)) return DEFAULT_FLOW_BACKGROUND;
15
+ return normalizeFlowColor(value, DEFAULT_FLOW_BACKGROUND);
16
+ }
17
+
18
+ export function normalizeFlowLineColor(value: unknown) {
19
+ return normalizeFlowColor(value, DEFAULT_FLOW_LINE_COLOR);
20
+ }
21
+
22
+ function normalizeFlowColor(value: unknown, fallback: string) {
23
+ if (!isHexColor(value)) return fallback;
13
24
  const color = value.trim().toLowerCase();
14
25
  return color.length === 4 ? '#' + [...color.slice(1)].map((char) => char + char).join('') : color;
15
26
  }
@@ -460,8 +460,8 @@ export function buildFlowGraph(
460
460
  && item.step.type !== 'clearAppData'
461
461
  && item.step.visualChange?.role !== 'end',
462
462
  canEditInput: ['input', 'inputIfExists', 'imageCheck', 'runScript'].includes(item.step.type),
463
- canExecute: item.step.type === 'launchApp' || item.step.type === 'clearAppData',
464
- missingAiModel: item.step.type === 'aiRecognition',
463
+ canExecute: item.step.type === 'launchApp' || item.step.type === 'clearAppData' || item.step.type === 'aiRecognition',
464
+ missingAiModel: item.step.type === 'aiRecognition' && !options.aiRecognitionModelConfigured,
465
465
  },
466
466
  });
467
467
  };
@@ -5,12 +5,14 @@ import { longPressMode } from './long-press';
5
5
  import { isNativeStateCondition } from './native-control-state';
6
6
  import { DEFAULT_LOG_PREFIX } from './stage-log';
7
7
  import { imageCheckSummary } from './image-check';
8
+ import { isAiBranchEnabled } from './ai-recognition';
8
9
 
9
- export function isBooleanCondition(step: Pick<AppiumRecordedStep, 'type'>) {
10
- return isNativeStateCondition(step) || step.type === 'aiRecognition' || step.type === 'textClick' || step.type === 'imageCheck';
10
+ export function isBooleanCondition(step: Pick<AppiumRecordedStep, 'type' | 'aiBranchEnabled'>) {
11
+ return isNativeStateCondition(step) || (step.type === 'aiRecognition' && isAiBranchEnabled(step)) || step.type === 'textClick' || step.type === 'imageCheck';
11
12
  }
12
13
 
13
14
  export function defaultFlowKind(step: AppiumRecordedStep): FlowKind {
15
+ if (step.type === 'aiRecognition') return isAiBranchEnabled(step) ? 'condition' : 'action';
14
16
  if (step.type === 'extractVariable') return 'action';
15
17
  if (step.type === 'loop') return 'condition';
16
18
  if (step.type === 'breakLoop') return 'action';
@@ -47,7 +49,7 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
47
49
  checkboxState: 'Checkbox 状态',
48
50
  checkedState: '判断勾选',
49
51
  radioButtonState: 'RadioButton 状态',
50
- aiRecognition: 'AI 识别(已移除)',
52
+ aiRecognition: 'AI 识别',
51
53
  imageCheck: '图像判断',
52
54
  textClick: '文字点击',
53
55
  assertText: '断言文本',
@@ -81,7 +83,7 @@ export function flowStepMeta(step: AppiumRecordedStep) {
81
83
  if (step.type === 'loop') return `最多 ${step.loop?.maxIterations ?? '?'} 次 · ${step.loop?.exitWhen === 'exists' ? '元素出现时退出' : step.loop?.exitWhen === 'notExists' ? '元素消失时退出' : '固定次数'}${step.loop?.exitWhen !== 'never' ? ` ${step.selector?.value || ''}` : ''}`;
82
84
  if (step.type === 'breakLoop') return step.breakLoopTargetId ? '退出指定循环,继续循环结束后的流程' : '退出当前循环,继续循环结束后的流程';
83
85
  if (step.type === 'log') return `${step.logPrefix ?? DEFAULT_LOG_PREFIX}:${step.value || ''}`;
84
- if (step.type === 'aiRecognition') return '此操作已移除,请替换为图像判断或原生组件判断';
86
+ if (step.type === 'aiRecognition') return `AI ${step.aiObservation?.mode === 'untilMatch' ? '命中即结束' : step.aiObservation ? '持续观察' : '识别'} · ${step.value || '未填写识别内容'}`;
85
87
  if (step.type === 'longPress') {
86
88
  const target = longPressMode(step) === 'element'
87
89
  ? `元素 ${step.selector?.strategy || ''} ${step.selector?.value || ''}`
@@ -1,3 +1,4 @@
1
+ import type { AiObservationConfig } from './ai-recognition';
1
2
  import type { TestVariable, VariableExtraction, ScriptReturn } from './variables';
2
3
  import type { ImageCheckConfig } from './image-check';
3
4
 
@@ -121,6 +122,9 @@ export type AppiumRecordedStep = {
121
122
  returns?: ScriptReturn[];
122
123
  keyCode?: number;
123
124
  timeoutMs?: number;
125
+ aiTimeoutEnabled?: boolean;
126
+ aiBranchEnabled?: boolean;
127
+ aiObservation?: AiObservationConfig;
124
128
  timeoutBranch?: 'stop' | 'yes' | 'no';
125
129
  longPressMode?: 'element' | 'coordinates';
126
130
  breakLoopTargetId?: string;
@@ -0,0 +1,59 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { resolveAiDeduplication, type AiDeduplicationConfig } from '../../appium-recorder/ai-deduplication';
4
+
5
+ const props = defineProps<{ modelValue?: AiDeduplicationConfig; disabled?: boolean }>();
6
+ const emit = defineEmits<{ 'update:modelValue': [value: AiDeduplicationConfig] }>();
7
+ const settings = computed(() => props.modelValue ?? resolveAiDeduplication());
8
+ function patch(value: Partial<AiDeduplicationConfig>) {
9
+ emit('update:modelValue', { ...settings.value, ...value });
10
+ }
11
+ </script>
12
+
13
+ <template>
14
+ <section class="ai-dedup-settings" aria-label="持续观察截图去重">
15
+ <h3 class="ai-dedup-title">持续观察截图去重</h3>
16
+ <el-form-item label="去重方式">
17
+ <el-radio-group :model-value="settings.method" :disabled="disabled" @update:model-value="patch({ method: $event as AiDeduplicationConfig['method'] })">
18
+ <el-radio-button value="none">不去重</el-radio-button>
19
+ <el-radio-button value="pixelmatch">pixelmatch(默认)</el-radio-button>
20
+ <el-radio-button value="opencv">OpenCV(SSIM)</el-radio-button>
21
+ </el-radio-group>
22
+ </el-form-item>
23
+ <template v-if="settings.method === 'pixelmatch'">
24
+ <el-form-item label="像素颜色容差">
25
+ <el-input-number :model-value="settings.pixelmatch.threshold" :disabled="disabled" :min="0" :max="1" :step="0.01" :precision="2" controls-position="right" @update:model-value="patch({ pixelmatch: { ...settings.pixelmatch, threshold: $event ?? 0.1 } })" />
26
+ <p class="ai-dedup-help">默认 0.10。越大越容易忽略颜色细微差异;设为 0 时逐像素严格比较。</p>
27
+ </el-form-item>
28
+ <el-form-item label="允许变化面积(%)">
29
+ <el-input-number :model-value="settings.pixelmatch.maxChangedRatio" :disabled="disabled" :min="0" :max="100" :step="0.1" :precision="2" controls-position="right" @update:model-value="patch({ pixelmatch: { ...settings.pixelmatch, maxChangedRatio: $event ?? 0.5 } })" />
30
+ <p class="ai-dedup-help">默认 0.50%。变化像素占比不超过此值时去重。越大保留越少,但可能忽略小弹窗或文字变化。</p>
31
+ </el-form-item>
32
+ </template>
33
+ <template v-else-if="settings.method === 'opencv'">
34
+ <el-form-item label="SSIM 相似度阈值">
35
+ <el-input-number :model-value="settings.opencv.similarityThreshold" :disabled="disabled" :min="0.5" :max="1" :step="0.005" :precision="3" controls-position="right" @update:model-value="patch({ opencv: { ...settings.opencv, similarityThreshold: $event ?? 0.99 } })" />
36
+ <p class="ai-dedup-help">默认 0.990。相似度达到此值时去重;越接近 1 越严格、保留越多,调低可减少提交图片。</p>
37
+ </el-form-item>
38
+ <el-form-item label="比较图像最长边(px)">
39
+ <el-input-number :model-value="settings.opencv.maxDimension" :disabled="disabled" :min="128" :max="1024" :step="128" :precision="0" controls-position="right" @update:model-value="patch({ opencv: { ...settings.opencv, maxDimension: $event ?? 512 } })" />
40
+ <p class="ai-dedup-help">默认 512。仅缩小本地比较图像;越大越能保留细节,也更耗本地计算资源,不改变提交模型的图片尺寸。</p>
41
+ </el-form-item>
42
+ </template>
43
+ <div class="ai-dedup-explanation">
44
+ <p><strong>不去重:</strong>全部采样截图交给模型,保留信息最多,图片输入消耗也最高。</p>
45
+ <p><strong>pixelmatch:</strong>比较原图的像素颜色和变化面积,适合同设备的静态画面,对细节变化敏感。</p>
46
+ <p><strong>OpenCV(SSIM):</strong>缩小图片后比较彩色画面的局部亮度、对比度和结构,更侧重整体相似性;可能忽略小文字,不能识别语义,也不自动对齐位移。</p>
47
+ <p>仅对持续观察生效。首帧始终保留,后续与上一张保留的截图比较;两种去重均在本地完成,不调用模型。切换方式会保留各自参数,用于后续回放和识别测试。</p>
48
+ </div>
49
+ </section>
50
+ </template>
51
+
52
+ <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); }
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; }
57
+ .ai-dedup-explanation p { margin: 6px 0; }
58
+ .ai-dedup-settings :deep(.el-radio-group) { gap: 4px 0; }
59
+ </style>
@@ -0,0 +1,66 @@
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">预设提示词</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-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; }
53
+ .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; }
55
+ .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; }
58
+ .ai-prompt-row + .ai-prompt-row { border-top: 1px solid var(--ui-border); }
59
+ .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); }
61
+ .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
+ }
66
+ </style>
@@ -1,16 +1,61 @@
1
1
  <script setup lang="ts">
2
2
  import type { ConfigForm } from '../../types';
3
3
  import FlowAppearanceSettings from './FlowAppearanceSettings.vue';
4
+ import AiDeduplicationSettings from './AiDeduplicationSettings.vue';
5
+ import AiPromptPresetsSettings from './AiPromptPresetsSettings.vue';
4
6
 
5
7
  defineProps<{
6
8
  configForm: ConfigForm;
7
9
  testingModelKey: string;
8
- isSavingModelConfig: boolean;
9
10
  testStatus?: string;
11
+ saveStatus?: string;
10
12
  }>();
11
- defineEmits<{ testModel: []; saveModelConfig: [] }>();
13
+ defineEmits<{ testModel: [] }>();
12
14
  </script>
13
15
 
14
16
  <template>
15
- <FlowAppearanceSettings v-model="configForm.appium.flowBackgroundColor" :saving="isSavingModelConfig" @save="$emit('saveModelConfig')" />
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>
40
+ </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" />
16
50
  </template>
51
+
52
+ <style scoped>
53
+ .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); }
57
+ @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); }
60
+ }
61
+ </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,71 @@
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
56
  .flow-appearance-settings { margin-top: 20px; }
44
- .flow-appearance-settings .el-form { max-width: 640px; }
57
+ .flow-appearance-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 32px; }
58
+ .flow-appearance-section { min-width: 0; }
59
+ .flow-appearance-section + .flow-appearance-section { padding-left: 32px; border-left: 1px solid var(--ui-border); }
60
+ .flow-appearance-section h3 { margin: 0 0 20px; font-size: 15px; font-weight: 600; color: var(--el-text-color-primary); }
45
61
  .flow-color-presets, .flow-color-input { display: flex; align-items: center; gap: 10px; }
46
62
  .flow-color-presets { flex-wrap: wrap; }
47
63
  .flow-color-presets button { width: 32px; height: 32px; border: 1px solid var(--el-border-color-darker); border-radius: 4px; cursor: pointer; }
48
64
  .flow-color-presets button[aria-pressed="true"] { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
49
65
  .flow-color-input { width: 100%; }
50
66
  .flow-color-input .el-input { flex: 1; min-width: 0; }
67
+ @media (max-width: 800px) {
68
+ .flow-appearance-columns { grid-template-columns: minmax(0, 1fr); gap: 24px; }
69
+ .flow-appearance-section + .flow-appearance-section { padding: 24px 0 0; border-left: 0; border-top: 1px solid var(--ui-border); }
70
+ }
51
71
  </style>