android-midscene-automation 0.1.38 → 0.1.40
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.
- package/CHANGELOG.md +11 -0
- package/README.md +11 -0
- package/package.json +12 -7
- package/server/appium-recorder/appium-runner.ts +61 -16
- package/server/appium-recorder/image-check.ts +10 -0
- package/server/appium-recorder/replay-mp4.ts +72 -0
- package/server/appium-recorder/replay-video-segments.ts +77 -0
- package/server/appium-recorder/replay-video.ts +107 -0
- package/server/appium-recorder/report.ts +98 -6
- package/server/appium-recorder/routes.ts +16 -17
- package/server/appium-recorder/run-history.ts +9 -1
- package/server/appium-recorder/scrcpy-recording-source.ts +80 -0
- package/server/appium-recorder/video-response.ts +36 -0
- package/src/appium-recorder/AppiumPage.vue +24 -41
- package/src/appium-recorder/RunHistoryDialog.vue +13 -3
- package/src/appium-recorder/action-descriptions.ts +1 -1
- package/src/appium-recorder/api.ts +3 -10
- package/src/appium-recorder/components/FlowNodeCard.vue +2 -3
- package/src/appium-recorder/components/FlowStepEditor.vue +0 -6
- package/src/appium-recorder/components/ImageCheckDialog.vue +28 -5
- package/src/appium-recorder/components/NestedConditionBranches.vue +0 -1
- package/src/appium-recorder/components/RecordedSteps.vue +0 -1
- package/src/appium-recorder/flow-graph.ts +2 -2
- package/src/appium-recorder/flow-labels.ts +2 -2
- package/src/appium-recorder/image-check.ts +3 -1
- package/src/appium-recorder/run-history.ts +3 -0
- package/src/components/config/AppiumConfigPanel.vue +0 -35
- package/src/appium-recorder/components/AiRecognitionTestDialog.vue +0 -71
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { APP_BASE } from '../api';
|
|
2
2
|
import type { AppiumRecordedScript, AppiumRecordedStep } from './types';
|
|
3
|
-
import type { AiRecognitionResult } from './ai-recognition';
|
|
4
3
|
import type { TestVariable } from './variables';
|
|
5
4
|
import type { AppiumVisualChangeRegion } from './types';
|
|
6
5
|
|
|
@@ -73,13 +72,6 @@ export async function getAppiumScripts() {
|
|
|
73
72
|
return readJson<{ scripts: AppiumRecordedScript[] }>(response);
|
|
74
73
|
}
|
|
75
74
|
|
|
76
|
-
export async function testAiRecognition(input: { deviceId: string; prompt: string; timeoutMs?: number }, signal?: AbortSignal) {
|
|
77
|
-
const response = await fetch(`${APP_BASE}/api/appium-recorder/ai-recognition/test`, {
|
|
78
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), signal,
|
|
79
|
-
});
|
|
80
|
-
return readJson<AiRecognitionResult & { imageBase64: string }>(response);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
75
|
export function saveAppiumScript(input: {
|
|
84
76
|
variables?: import('./variables').TestVariable[];
|
|
85
77
|
id?: string;
|
|
@@ -115,6 +107,7 @@ export async function downloadAppiumScript(id: string) {
|
|
|
115
107
|
}
|
|
116
108
|
|
|
117
109
|
type ReplayResult = {
|
|
110
|
+
videoPath?: string;
|
|
118
111
|
success: boolean;
|
|
119
112
|
stopped?: boolean;
|
|
120
113
|
output: string;
|
|
@@ -131,7 +124,7 @@ type ReplayStreamEvent =
|
|
|
131
124
|
| { type: 'error'; message: string };
|
|
132
125
|
|
|
133
126
|
export async function replayAppiumScript(
|
|
134
|
-
input: { id: string; deviceId?: string; parameters?: import('./variables').TestVariable[] },
|
|
127
|
+
input: { id: string; deviceId?: string; recordVideo?: boolean; parameters?: import('./variables').TestVariable[] },
|
|
135
128
|
onOutput?: (line: string) => void,
|
|
136
129
|
) {
|
|
137
130
|
const response = await fetch(`${APP_BASE}/api/appium-recorder/scripts/${encodeURIComponent(input.id)}/replay`, {
|
|
@@ -140,7 +133,7 @@ export async function replayAppiumScript(
|
|
|
140
133
|
'Content-Type': 'application/json',
|
|
141
134
|
Accept: 'application/x-ndjson',
|
|
142
135
|
},
|
|
143
|
-
body: JSON.stringify({ deviceId: input.deviceId, parameters: input.parameters }),
|
|
136
|
+
body: JSON.stringify({ deviceId: input.deviceId, parameters: input.parameters, recordVideo: input.recordVideo }),
|
|
144
137
|
});
|
|
145
138
|
if (!response.ok || !response.body) return readJson<ReplayResult>(response);
|
|
146
139
|
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue';
|
|
3
3
|
import { Handle, Position } from '@vue-flow/core';
|
|
4
4
|
import { CopyDocument, Delete, Edit, Plus, VideoPlay, View, WarningFilled, Connection, RefreshLeft } from '@element-plus/icons-vue';
|
|
5
|
-
import { AI_MODEL_CONFIG_HINT } from '../ai-recognition';
|
|
6
5
|
import type { AppiumRecordedStep } from '../types';
|
|
7
6
|
import {
|
|
8
7
|
PASTE_COMMAND,
|
|
@@ -176,8 +175,8 @@ watch(() => props.data, () => {
|
|
|
176
175
|
@click="handleStepClick"
|
|
177
176
|
>
|
|
178
177
|
<span class="appium-flow-step-card__content">
|
|
179
|
-
<el-tooltip v-if="data.missingAiModel"
|
|
180
|
-
<el-icon class="appium-ai-model-warning" color="var(--el-color-danger)" role="img" aria-label="AI
|
|
178
|
+
<el-tooltip v-if="data.missingAiModel" content="AI 识别操作已移除,请删除此节点并改用图像判断或原生组件判断" placement="top">
|
|
179
|
+
<el-icon class="appium-ai-model-warning" color="var(--el-color-danger)" role="img" aria-label="AI 识别操作已移除" tabindex="0">
|
|
181
180
|
<WarningFilled />
|
|
182
181
|
</el-icon>
|
|
183
182
|
</el-tooltip>
|
|
@@ -197,12 +197,6 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
197
197
|
<LoopSettings v-if="step.type === 'loop'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
198
198
|
<BreakLoopSettings v-if="step.type === 'breakLoop'" :step="step" :steps="steps" :disabled="disabled" @update="patchStep" />
|
|
199
199
|
<TextClickSettings v-if="step.type === 'textClick'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
200
|
-
<el-form-item v-if="step.type === 'aiRecognition'" label="识别内容">
|
|
201
|
-
<el-input
|
|
202
|
-
:model-value="step.value || ''" type="textarea" :rows="3" maxlength="4000"
|
|
203
|
-
:disabled="disabled" @update:model-value="patchStep({ value: String($event) })"
|
|
204
|
-
/>
|
|
205
|
-
</el-form-item>
|
|
206
200
|
<el-form-item
|
|
207
201
|
v-if="step.type === 'input' || step.type === 'inputIfExists' || step.type === 'assertText'"
|
|
208
202
|
label="文本内容"
|
|
@@ -18,7 +18,7 @@ const slots = computed(() => props.config.mode === 'state'
|
|
|
18
18
|
? [{ key: 'template' as const, label: '选中模板' }, { key: 'negativeTemplate' as const, label: '未选中模板' }]
|
|
19
19
|
: props.config.mode === 'template' ? [{ key: 'template' as const, label: '模板' }] : []);
|
|
20
20
|
const numericFields = computed(() => [
|
|
21
|
-
...(['template', 'state'].includes(props.config.mode) ? [{ key: 'threshold' as const, label: '
|
|
21
|
+
...(['template', 'state'].includes(props.config.mode) ? [{ key: 'threshold' as const, label: '匹配严格度 (%)', min: 1, max: 100, step: 1 }] : []),
|
|
22
22
|
...(props.config.mode === 'state' ? [{ key: 'minScoreGap' as const, label: '两种状态最小得分差', min: 0.001, max: 1, step: 0.01 }] : []),
|
|
23
23
|
...(['black', 'color', 'change'].includes(props.config.mode) ? [
|
|
24
24
|
{ key: 'tolerance' as const, label: props.config.mode === 'black' ? '暗色亮度阈值 (0–255)' : 'RGB 通道容差 (0–255)', min: 0, max: 255, step: 1 },
|
|
@@ -110,10 +110,10 @@ async function upload(event: Event) {
|
|
|
110
110
|
<el-alert v-if="regionIssue" :title="regionIssue" type="warning" show-icon :closable="false" class="image-check-region-warning">
|
|
111
111
|
<el-button v-if="expandedRegion" size="small" @click="patch({ region: expandedRegion })">扩大检测区域</el-button>
|
|
112
112
|
</el-alert>
|
|
113
|
-
<el-form-item v-if="config.mode === 'template' || config.mode === 'change'" label="判断条件">
|
|
113
|
+
<el-form-item v-if="config.mode === 'template' || config.mode === 'change'" :label="config.mode === 'template' ? '预期匹配结果' : '判断条件'">
|
|
114
114
|
<el-radio-group :model-value="config.expectation" @update:model-value="patch({ expectation: $event as 'present' | 'absent' })">
|
|
115
|
-
<el-radio-button value="present">{{ config.mode === 'template' ? '
|
|
116
|
-
<el-radio-button value="absent">{{ config.mode === 'template' ? '
|
|
115
|
+
<el-radio-button value="present">{{ config.mode === 'template' ? '匹配到模板' : '画面有变化' }}</el-radio-button>
|
|
116
|
+
<el-radio-button value="absent">{{ config.mode === 'template' ? '未匹配到模板' : '持续无明显变化' }}</el-radio-button>
|
|
117
117
|
</el-radio-group>
|
|
118
118
|
</el-form-item>
|
|
119
119
|
<el-form-item v-if="config.mode === 'color'" label="目标颜色 #RRGGBB">
|
|
@@ -123,7 +123,22 @@ async function upload(event: Event) {
|
|
|
123
123
|
<div class="image-check-grid">
|
|
124
124
|
<template v-for="field in numericFields" :key="field.key">
|
|
125
125
|
<el-form-item :label="field.label">
|
|
126
|
-
<
|
|
126
|
+
<template v-if="field.key === 'threshold'" #label>
|
|
127
|
+
<span class="image-check-threshold-label">{{ field.label }}
|
|
128
|
+
<el-tooltip effect="dark" placement="top" :show-after="200">
|
|
129
|
+
<template #content>
|
|
130
|
+
<div class="image-check-threshold-help">
|
|
131
|
+
当前画面与参考模板的匹配得分达到此值,才算匹配成功,得分不是正确概率。
|
|
132
|
+
数值越高越严格,可能漏掉有细微变化的目标;越低越宽松,可能误认相似图案。
|
|
133
|
+
例如设为 98%,需要匹配得分达到 98%。设为 100% 时,极细微的像素差异也会判为不匹配。图片状态判断还会结合两种模板的最小得分差,得分太接近时无法判定。
|
|
134
|
+
</div>
|
|
135
|
+
</template>
|
|
136
|
+
<el-button class="image-check-threshold-button" :icon="QuestionFilled" text aria-label="匹配严格度说明" @click.prevent />
|
|
137
|
+
</el-tooltip>
|
|
138
|
+
</span>
|
|
139
|
+
</template>
|
|
140
|
+
<el-input-number :model-value="field.key === 'threshold' ? Number((config.threshold * 100).toFixed(8)) : config[field.key]" :min="field.min" :max="field.max" :step="field.step" controls-position="right" @update:model-value="$event !== undefined && patch({ [field.key]: field.key === 'threshold' ? $event / 100 : $event })" />
|
|
141
|
+
<el-alert v-if="field.key === 'threshold' && config.threshold === 1" title="100% 要求完全匹配,极细微的像素差异也会判为不匹配。" type="warning" :closable="false" show-icon class="image-check-strict-warning" />
|
|
127
142
|
</el-form-item>
|
|
128
143
|
<BranchTimeoutSettings v-if="field.key === 'durationMs'" style="grid-column: 1 / -1" :step="{ id: '', label: '', type: 'imageCheck', timeoutBranch }" :disabled="busy" @update="emit('timeoutBranch', $event)" />
|
|
129
144
|
</template>
|
|
@@ -141,9 +156,17 @@ async function upload(event: Event) {
|
|
|
141
156
|
.image-check-dialog .el-input-number { width: 100%; }
|
|
142
157
|
.image-check-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 12px; }
|
|
143
158
|
.image-check-help { margin-left: 8px; color: #606266; }
|
|
159
|
+
.image-check-grid .el-form-item__label { display: flex; align-items: center; min-height: 20px; }
|
|
160
|
+
.image-check-threshold-label { display: inline-flex; align-items: center; gap: 4px; }
|
|
161
|
+
.image-check-dialog .el-button.image-check-threshold-button { flex: 0 0 20px; width: 20px; height: 20px; min-height: 20px; padding: 0; font-size: 14px; }
|
|
162
|
+
.image-check-threshold-help { max-width: min(300px, calc(100vw - 40px)); font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; }
|
|
163
|
+
.image-check-strict-warning { margin-top: 6px; }
|
|
144
164
|
.image-check-template { display: grid; gap: 6px; margin-bottom: 16px; min-width: 0; }
|
|
145
165
|
.image-check-size { color: #606266; font-size: 12px; }
|
|
146
166
|
.image-check-region-warning { margin-bottom: 16px; }
|
|
147
167
|
.image-check-template .el-image, .image-check-empty { height: 96px; width: 100%; background: #eff5f3; border: 1px solid #d5dfdb; border-radius: 4px; }
|
|
148
168
|
.image-check-empty { display: grid; place-items: center; color: #606266; }
|
|
169
|
+
@media (max-width: 480px) {
|
|
170
|
+
.image-check-grid { grid-template-columns: minmax(0, 1fr); }
|
|
171
|
+
}
|
|
149
172
|
</style>
|
|
@@ -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: 'aiRecognition', label: 'AI 识别' },
|
|
123
122
|
{ type: 'imageCheck', label: '图像判断' },
|
|
124
123
|
{ type: 'tapIfExists', label: '存在则点击' },
|
|
125
124
|
{ type: 'inputIfExists', label: '存在则输入' },
|
|
@@ -89,7 +89,6 @@ const insertActionGroups: FlowActionGroup[] = [
|
|
|
89
89
|
actions: [
|
|
90
90
|
{ type: 'delay', label: '添加延时' },
|
|
91
91
|
{ type: 'popupCondition', label: '判断存在' },
|
|
92
|
-
{ type: 'aiRecognition', label: 'AI 识别' },
|
|
93
92
|
{ type: 'imageCheck', label: '图像判断' },
|
|
94
93
|
{ type: 'tapIfExists', label: '存在则点击' },
|
|
95
94
|
{ type: 'inputIfExists', label: '存在则输入' },
|
|
@@ -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',
|
|
464
|
+
missingAiModel: item.step.type === 'aiRecognition',
|
|
465
465
|
},
|
|
466
466
|
});
|
|
467
467
|
};
|
|
@@ -47,7 +47,7 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
|
|
|
47
47
|
checkboxState: 'Checkbox 状态',
|
|
48
48
|
checkedState: '判断勾选',
|
|
49
49
|
radioButtonState: 'RadioButton 状态',
|
|
50
|
-
aiRecognition: 'AI
|
|
50
|
+
aiRecognition: 'AI 识别(已移除)',
|
|
51
51
|
imageCheck: '图像判断',
|
|
52
52
|
textClick: '文字点击',
|
|
53
53
|
assertText: '断言文本',
|
|
@@ -81,7 +81,7 @@ export function flowStepMeta(step: AppiumRecordedStep) {
|
|
|
81
81
|
if (step.type === 'loop') return `最多 ${step.loop?.maxIterations ?? '?'} 次 · ${step.loop?.exitWhen === 'exists' ? '元素出现时退出' : step.loop?.exitWhen === 'notExists' ? '元素消失时退出' : '固定次数'}${step.loop?.exitWhen !== 'never' ? ` ${step.selector?.value || ''}` : ''}`;
|
|
82
82
|
if (step.type === 'breakLoop') return step.breakLoopTargetId ? '退出指定循环,继续循环结束后的流程' : '退出当前循环,继续循环结束后的流程';
|
|
83
83
|
if (step.type === 'log') return `${step.logPrefix ?? DEFAULT_LOG_PREFIX}:${step.value || ''}`;
|
|
84
|
-
if (step.type === 'aiRecognition') return
|
|
84
|
+
if (step.type === 'aiRecognition') return '此操作已移除,请替换为图像判断或原生组件判断';
|
|
85
85
|
if (step.type === 'longPress') {
|
|
86
86
|
const target = longPressMode(step) === 'element'
|
|
87
87
|
? `元素 ${step.selector?.strategy || ''} ${step.selector?.value || ''}`
|
|
@@ -21,6 +21,7 @@ export type ImageCheckConfig = {
|
|
|
21
21
|
consecutive: number;
|
|
22
22
|
};
|
|
23
23
|
export type ImageCheckResult = {
|
|
24
|
+
templateMatch?: { matched: boolean; expected: 'present' | 'absent'; score: number; threshold: number };
|
|
24
25
|
timedOut?: boolean;
|
|
25
26
|
result: boolean | null;
|
|
26
27
|
mode: ImageCheckConfig['mode'];
|
|
@@ -115,5 +116,6 @@ export function validateImageCheck(config: ImageCheckConfig | undefined) {
|
|
|
115
116
|
|
|
116
117
|
export function imageCheckSummary(config?: ImageCheckConfig) {
|
|
117
118
|
if (!config) return '未配置图像判断';
|
|
118
|
-
|
|
119
|
+
const expectation = config.mode === 'template' ? ` · ${config.expectation === 'present' ? '匹配到模板' : '未匹配到模板'}` : '';
|
|
120
|
+
return `${IMAGE_CHECK_MODES[config.mode]}${expectation} · ${config.target === 'element' ? '组件区域' : '框选区域'} · ${config.durationMs}ms`;
|
|
119
121
|
}
|
|
@@ -5,7 +5,10 @@ export type HistoryNode = {
|
|
|
5
5
|
durationMs: number | null;
|
|
6
6
|
};
|
|
7
7
|
|
|
8
|
+
export type RunVideo = { filePath: string; fileName: string; startedAt: string; warning?: string; scriptId?: string; scriptName?: string; boundaryAt?: string; segments?: RunVideo[] };
|
|
9
|
+
|
|
8
10
|
export type RunSummary = {
|
|
11
|
+
video?: RunVideo;
|
|
9
12
|
id: string;
|
|
10
13
|
scriptId: string;
|
|
11
14
|
scriptName: string;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { ConfigForm } from '../../types';
|
|
3
3
|
import FlowAppearanceSettings from './FlowAppearanceSettings.vue';
|
|
4
|
-
import { DEFAULT_FLOW_BACKGROUND, isHexColor } from '../../appium-recorder/flow-appearance';
|
|
5
4
|
|
|
6
5
|
defineProps<{
|
|
7
6
|
configForm: ConfigForm;
|
|
@@ -13,39 +12,5 @@ defineEmits<{ testModel: []; saveModelConfig: [] }>();
|
|
|
13
12
|
</script>
|
|
14
13
|
|
|
15
14
|
<template>
|
|
16
|
-
<el-card shadow="never" class="config-module-card config-appium-card">
|
|
17
|
-
<template #header>
|
|
18
|
-
<div class="panel-header">
|
|
19
|
-
<span>节点模型配置</span>
|
|
20
|
-
<el-button type="primary" :loading="isSavingModelConfig" :disabled="!isHexColor(configForm.appium.flowBackgroundColor ?? DEFAULT_FLOW_BACKGROUND)" @click="$emit('saveModelConfig')">
|
|
21
|
-
保存模型配置
|
|
22
|
-
</el-button>
|
|
23
|
-
</div>
|
|
24
|
-
</template>
|
|
25
|
-
<section class="appium-model-form">
|
|
26
|
-
<div class="panel-header panel-header--sub">
|
|
27
|
-
<span>AI 识别模型</span>
|
|
28
|
-
<el-button :loading="testingModelKey === 'appium'" @click="$emit('testModel')">测试模型</el-button>
|
|
29
|
-
</div>
|
|
30
|
-
<el-form label-position="top">
|
|
31
|
-
<el-form-item label="Base URL">
|
|
32
|
-
<el-input v-model="configForm.appium.model.baseUrl" />
|
|
33
|
-
</el-form-item>
|
|
34
|
-
<el-form-item label="API Key">
|
|
35
|
-
<el-input v-model="configForm.appium.model.apiKey" show-password />
|
|
36
|
-
</el-form-item>
|
|
37
|
-
<el-form-item label="Model Name">
|
|
38
|
-
<el-input v-model="configForm.appium.model.name" placeholder="支持图片输入的模型" />
|
|
39
|
-
</el-form-item>
|
|
40
|
-
<el-form-item v-if="testStatus" label="测试结果">
|
|
41
|
-
<el-input :model-value="testStatus" readonly />
|
|
42
|
-
</el-form-item>
|
|
43
|
-
</el-form>
|
|
44
|
-
</section>
|
|
45
|
-
</el-card>
|
|
46
15
|
<FlowAppearanceSettings v-model="configForm.appium.flowBackgroundColor" :saving="isSavingModelConfig" @save="$emit('saveModelConfig')" />
|
|
47
16
|
</template>
|
|
48
|
-
|
|
49
|
-
<style scoped>
|
|
50
|
-
.appium-model-form { max-width: 640px; }
|
|
51
|
-
</style>
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import { onBeforeUnmount, onMounted, shallowRef } from 'vue';
|
|
3
|
-
import { Refresh } from '@element-plus/icons-vue';
|
|
4
|
-
import { testAiRecognition } from '../api';
|
|
5
|
-
import { AI_MODEL_CONFIG_HINT, validateAiRecognitionPrompt } from '../ai-recognition';
|
|
6
|
-
import type { AppiumRecordedStep } from '../types';
|
|
7
|
-
|
|
8
|
-
const props = defineProps<{ step: AppiumRecordedStep; deviceId: string; modelConfigured: boolean }>();
|
|
9
|
-
defineEmits<{ close: [] }>();
|
|
10
|
-
const loading = shallowRef(false);
|
|
11
|
-
const error = shallowRef('');
|
|
12
|
-
const result = shallowRef<Awaited<ReturnType<typeof testAiRecognition>> | null>(null);
|
|
13
|
-
let controller: AbortController | undefined;
|
|
14
|
-
|
|
15
|
-
async function runTest() {
|
|
16
|
-
if (loading.value) return;
|
|
17
|
-
error.value = '';
|
|
18
|
-
result.value = null;
|
|
19
|
-
controller = new AbortController();
|
|
20
|
-
loading.value = true;
|
|
21
|
-
try {
|
|
22
|
-
if (!props.modelConfigured) throw new Error(AI_MODEL_CONFIG_HINT);
|
|
23
|
-
if (!props.deviceId) throw new Error('请选择设备');
|
|
24
|
-
const prompt = validateAiRecognitionPrompt(props.step.value);
|
|
25
|
-
result.value = await testAiRecognition({ deviceId: props.deviceId, prompt, timeoutMs: props.step.timeoutMs }, controller.signal);
|
|
26
|
-
} catch (cause) {
|
|
27
|
-
if (!controller.signal.aborted) error.value = cause instanceof Error ? cause.message : 'AI 识别测试失败';
|
|
28
|
-
} finally {
|
|
29
|
-
loading.value = false;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
onMounted(runTest);
|
|
34
|
-
onBeforeUnmount(() => controller?.abort());
|
|
35
|
-
</script>
|
|
36
|
-
|
|
37
|
-
<template>
|
|
38
|
-
<el-dialog :model-value="true" title="测试 AI 识别" width="min(560px, calc(100vw - 32px))" append-to-body @close="$emit('close')">
|
|
39
|
-
<div class="ai-recognition-test" aria-live="polite">
|
|
40
|
-
<p class="ai-recognition-test__prompt">{{ step.value }}</p>
|
|
41
|
-
<el-alert v-if="error" type="error" :title="error" :closable="false" show-icon />
|
|
42
|
-
<p v-if="loading">识别中…</p>
|
|
43
|
-
<template v-if="result">
|
|
44
|
-
<div class="ai-recognition-test__result">
|
|
45
|
-
<strong>{{ String(result.result) }}</strong>
|
|
46
|
-
<span>{{ result.durationMs }}ms</span>
|
|
47
|
-
</div>
|
|
48
|
-
<p>{{ result.reason }}</p>
|
|
49
|
-
<el-image
|
|
50
|
-
v-if="result.imageBase64"
|
|
51
|
-
class="ai-recognition-test__image" fit="contain"
|
|
52
|
-
:src="`data:image/png;base64,${result.imageBase64}`"
|
|
53
|
-
:preview-src-list="[`data:image/png;base64,${result.imageBase64}`]"
|
|
54
|
-
preview-teleported alt="AI 识别使用的设备截图"
|
|
55
|
-
/>
|
|
56
|
-
</template>
|
|
57
|
-
</div>
|
|
58
|
-
<template #footer>
|
|
59
|
-
<el-button @click="$emit('close')">关闭</el-button>
|
|
60
|
-
<el-button type="primary" :icon="Refresh" :loading="loading" :disabled="!modelConfigured || !deviceId" @click="runTest">重新测试</el-button>
|
|
61
|
-
</template>
|
|
62
|
-
</el-dialog>
|
|
63
|
-
</template>
|
|
64
|
-
|
|
65
|
-
<style scoped>
|
|
66
|
-
.ai-recognition-test { overflow-wrap: anywhere; }
|
|
67
|
-
.ai-recognition-test__prompt { white-space: pre-wrap; margin-top: 0; }
|
|
68
|
-
.ai-recognition-test__result { display: flex; align-items: center; gap: 16px; }
|
|
69
|
-
.ai-recognition-test__result strong { font-size: 20px; }
|
|
70
|
-
.ai-recognition-test__image { display: block; width: 100%; height: 280px; }
|
|
71
|
-
</style>
|