android-midscene-automation 0.2.1 → 0.2.3
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 +15 -1
- package/README.md +15 -1
- package/package.json +1 -1
- package/remote-agent/index.ts +50 -22
- package/server/analytics.test.ts +46 -0
- package/server/analytics.ts +24 -0
- package/server/api-safety-analytics.test.ts +57 -0
- package/server/appium-auto-save.test.ts +6 -0
- package/server/appium-recorder/appium-runner.ts +30 -9
- package/server/appium-recorder/appium-version.test.ts +55 -0
- package/server/appium-recorder/managed-appium.ts +37 -1
- package/server/appium-recorder/managed-cleanup.test.ts +38 -8
- package/server/appium-recorder/replay-lifecycle.test.ts +123 -0
- package/server/appium-recorder/routes.ts +8 -18
- package/server/appium-recorder/run-history.ts +11 -0
- package/server/config-access.test.ts +27 -0
- package/server/config-access.ts +27 -0
- package/server/http-api.ts +63 -54
- package/server/operations/repository.ts +8 -0
- package/server/remote-agents/protocol.ts +1 -0
- package/server/remote-agents/registry.test.ts +65 -0
- package/server/remote-agents/registry.ts +60 -5
- package/server/remote-agents/replay-integration.test.ts +79 -0
- package/server/remote-agents/routes.ts +12 -15
- package/server/request-body.test.ts +40 -0
- package/server/request-body.ts +48 -0
- package/src/App.vue +96 -758
- package/src/analytics/summary.ts +75 -0
- package/src/api.ts +11 -0
- package/src/appium-recorder/AppiumPage.vue +4 -1
- package/src/components/AppSidebar.vue +31 -0
- package/src/components/analytics/AnalysisCharts.vue +189 -0
- package/src/components/analytics/ReportAnalysis.vue +78 -0
- package/src/components/config/AppiumConfigPanel.vue +4 -1
- package/src/components/config/AppiumModelSettings.vue +1 -1
- package/src/components/config/BaseConfigPanel.vue +6 -2
- package/src/components/config/MidsceneConfigPanel.vue +4 -5
- package/src/components/help/HelpCenter.vue +185 -0
- package/src/composables/use-device-preview.ts +279 -0
- package/src/composables/use-initial-resource.ts +17 -0
- package/src/composables/use-script-execution.ts +277 -0
- package/src/config/credentials.ts +2 -0
- package/src/config/use-appium-auto-save.ts +22 -11
- package/src/config/use-model-config.ts +157 -0
- package/src/main.ts +2 -2
- package/src/pages/AnalysisPage.vue +60 -0
- package/src/pages/ConfigPage.vue +36 -5
- package/src/style.css +711 -123
- package/src/types.ts +7 -1
- package/src/components/help/HelpCenterDialog.vue +0 -160
- package/src/ui-refresh.css +0 -677
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { computed, ref, shallowRef, onScopeDispose, type Ref } from 'vue';
|
|
2
|
+
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
3
|
+
import * as api from '../api';
|
|
4
|
+
import type { SavedScript, ExecutionStep, RunScriptStreamEvent } from '../types';
|
|
5
|
+
|
|
6
|
+
export function useScriptExecution(selectedScript: Ref<SavedScript | undefined>, selectedScriptId: Ref<string>, playgroundDeviceId: Ref<string>, loadSavedScripts: (id?: string) => Promise<void>, getMidsceneModelConfigError: () => string) {
|
|
7
|
+
const isRunningScript = ref(false);
|
|
8
|
+
const isStoppingScript = ref(false);
|
|
9
|
+
const executionLog = ref('');
|
|
10
|
+
const executionProcess = ref<ExecutionStep[]>([]);
|
|
11
|
+
const lastRunStatus = ref('');
|
|
12
|
+
const runStartedAt = shallowRef<number | null>(null);
|
|
13
|
+
const runningElapsedNow = shallowRef(0);
|
|
14
|
+
let executionProgressTimer: number | null = null;
|
|
15
|
+
let scriptRunAbortController: AbortController | null = null;
|
|
16
|
+
const buildExecutionProcess = (script: SavedScript): ExecutionStep[] => {
|
|
17
|
+
const activeSteps = (script.steps || []).filter((step) => step.enabled !== false);
|
|
18
|
+
if (!activeSteps.length) {
|
|
19
|
+
return [
|
|
20
|
+
{
|
|
21
|
+
id: 'script',
|
|
22
|
+
sourceIndex: 0,
|
|
23
|
+
title: script.promptTitle || script.name,
|
|
24
|
+
method: 'script',
|
|
25
|
+
prompt: script.filePath,
|
|
26
|
+
status: 'pending',
|
|
27
|
+
detail: '等待执行脚本',
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return activeSteps
|
|
33
|
+
.map((step, index) => ({
|
|
34
|
+
id: step.id || `${script.id}-${index}`,
|
|
35
|
+
sourceIndex: index,
|
|
36
|
+
title: step.label || `步骤 ${index + 1}`,
|
|
37
|
+
method: step.type,
|
|
38
|
+
prompt: step.prompt || step.value || '',
|
|
39
|
+
status: 'pending' as const,
|
|
40
|
+
detail: '等待前置步骤完成',
|
|
41
|
+
}))
|
|
42
|
+
.filter((step) => step.title !== '统一处理弹窗');
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const markExecutionProcess = (success: boolean, output: string) => {
|
|
46
|
+
if (!executionProcess.value.length) return;
|
|
47
|
+
const runningIndex = executionProcess.value.findIndex((step) => step.status === 'running');
|
|
48
|
+
const errorIndex = executionProcess.value.findIndex((step) => step.status === 'error');
|
|
49
|
+
const failedIndex = success
|
|
50
|
+
? -1
|
|
51
|
+
: runningIndex >= 0
|
|
52
|
+
? runningIndex
|
|
53
|
+
: errorIndex >= 0
|
|
54
|
+
? errorIndex
|
|
55
|
+
: Math.max(0, executionProcess.value.length - 1);
|
|
56
|
+
executionProcess.value = executionProcess.value.map((step, index) => {
|
|
57
|
+
if (success || index < failedIndex) {
|
|
58
|
+
return { ...step, status: 'success', detail: '执行完成' };
|
|
59
|
+
}
|
|
60
|
+
if (index === failedIndex) {
|
|
61
|
+
return {
|
|
62
|
+
...step,
|
|
63
|
+
status: 'error',
|
|
64
|
+
detail: output || '执行失败',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return step;
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const formatElapsed = (startedAt: number) => `${Math.max(0, Math.floor((Date.now() - startedAt) / 1000))} 秒`;
|
|
72
|
+
const formatCompactDuration = (milliseconds: number) => {
|
|
73
|
+
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000));
|
|
74
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
75
|
+
const seconds = totalSeconds % 60;
|
|
76
|
+
return minutes ? `${minutes}m${seconds}s` : `${seconds}s`;
|
|
77
|
+
};
|
|
78
|
+
const runningElapsedText = computed(() => {
|
|
79
|
+
if (lastRunStatus.value !== '执行中' || !runStartedAt.value) return '';
|
|
80
|
+
return formatCompactDuration(runningElapsedNow.value - runStartedAt.value);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const updateRunningStepElapsed = () => {
|
|
84
|
+
if (runStartedAt.value) {
|
|
85
|
+
runningElapsedNow.value = Date.now();
|
|
86
|
+
}
|
|
87
|
+
executionProcess.value = executionProcess.value.map((step) => {
|
|
88
|
+
if (step.status !== 'running' || !step.startedAt) {
|
|
89
|
+
return step;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
...step,
|
|
93
|
+
detail: `执行中,已耗时 ${formatElapsed(step.startedAt)}`,
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const startExecutionProgressTimer = () => {
|
|
99
|
+
if (executionProgressTimer) {
|
|
100
|
+
window.clearInterval(executionProgressTimer);
|
|
101
|
+
}
|
|
102
|
+
executionProgressTimer = window.setInterval(updateRunningStepElapsed, 1000);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const stopExecutionProgressTimer = () => {
|
|
106
|
+
if (!executionProgressTimer) return;
|
|
107
|
+
window.clearInterval(executionProgressTimer);
|
|
108
|
+
executionProgressTimer = null;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const appendExecutionStepLog = (event: Extract<RunScriptStreamEvent, { type: 'step' }>) => {
|
|
112
|
+
const title = event.title || `步骤 ${event.index + 1}`;
|
|
113
|
+
if (event.status === 'start') {
|
|
114
|
+
executionLog.value += `[步骤 ${event.index + 1}] 开始:${title}\n`;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (event.status === 'success') {
|
|
118
|
+
executionLog.value += `[步骤 ${event.index + 1}] 完成:${title}\n`;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
executionLog.value += `[步骤 ${event.index + 1}] 失败:${title}${event.detail ? `\n${event.detail}` : ''}\n`;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const applyRunScriptEvent = (event: RunScriptStreamEvent) => {
|
|
125
|
+
if (event.type === 'output') {
|
|
126
|
+
executionLog.value += event.chunk;
|
|
127
|
+
if (!lastRunStatus.value) {
|
|
128
|
+
lastRunStatus.value = '执行中';
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (event.type === 'error') {
|
|
134
|
+
executionLog.value += `${event.message}\n`;
|
|
135
|
+
markExecutionProcess(false, event.message);
|
|
136
|
+
lastRunStatus.value = '执行失败';
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (event.type === 'step') {
|
|
141
|
+
appendExecutionStepLog(event);
|
|
142
|
+
executionProcess.value = executionProcess.value.map((step) => {
|
|
143
|
+
if (step.sourceIndex !== event.index) {
|
|
144
|
+
return step;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (event.status === 'start') {
|
|
148
|
+
const startedAt = Date.now();
|
|
149
|
+
return { ...step, status: 'running', startedAt, detail: `执行中,已耗时 ${formatElapsed(startedAt)}` };
|
|
150
|
+
}
|
|
151
|
+
if (event.status === 'success') {
|
|
152
|
+
return { ...step, status: 'success', startedAt: undefined, detail: '执行完成' };
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
...step,
|
|
156
|
+
status: 'error',
|
|
157
|
+
startedAt: undefined,
|
|
158
|
+
detail: event.detail || '执行失败',
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (event.type === 'done') {
|
|
165
|
+
if (event.output && !executionLog.value.trim()) {
|
|
166
|
+
executionLog.value = event.output;
|
|
167
|
+
}
|
|
168
|
+
markExecutionProcess(event.success, event.output || executionLog.value);
|
|
169
|
+
lastRunStatus.value = event.success ? '执行完成' : event.output.includes('脚本执行已停止') ? '已停止' : '执行失败';
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const runSelectedScript = async () => {
|
|
174
|
+
if (isRunningScript.value) return;
|
|
175
|
+
|
|
176
|
+
if (!selectedScript.value) {
|
|
177
|
+
lastRunStatus.value = '请先选择脚本';
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const modelConfigError = getMidsceneModelConfigError();
|
|
182
|
+
if (modelConfigError) {
|
|
183
|
+
lastRunStatus.value = '模型配置不完整';
|
|
184
|
+
executionLog.value = modelConfigError;
|
|
185
|
+
ElMessage.error('Midscene 模型配置不完整');
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
isRunningScript.value = true;
|
|
190
|
+
isStoppingScript.value = false;
|
|
191
|
+
runStartedAt.value = Date.now();
|
|
192
|
+
runningElapsedNow.value = runStartedAt.value;
|
|
193
|
+
executionLog.value = '';
|
|
194
|
+
lastRunStatus.value = '执行中';
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
await loadSavedScripts(selectedScriptId.value);
|
|
198
|
+
const script = selectedScript.value;
|
|
199
|
+
if (!script) {
|
|
200
|
+
throw new Error('脚本不存在');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
executionProcess.value = buildExecutionProcess(script);
|
|
204
|
+
if (executionProcess.value[0]) {
|
|
205
|
+
const startedAt = Date.now();
|
|
206
|
+
executionProcess.value[0].status = 'running';
|
|
207
|
+
executionProcess.value[0].startedAt = startedAt;
|
|
208
|
+
executionProcess.value[0].detail = `执行中,已耗时 ${formatElapsed(startedAt)}`;
|
|
209
|
+
}
|
|
210
|
+
startExecutionProgressTimer();
|
|
211
|
+
|
|
212
|
+
scriptRunAbortController = new AbortController();
|
|
213
|
+
await api.runScript(
|
|
214
|
+
{
|
|
215
|
+
code: script.code,
|
|
216
|
+
scriptName: script.name,
|
|
217
|
+
deviceId: playgroundDeviceId.value,
|
|
218
|
+
steps: script.steps || [],
|
|
219
|
+
signal: scriptRunAbortController.signal,
|
|
220
|
+
},
|
|
221
|
+
applyRunScriptEvent,
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
if (!lastRunStatus.value) {
|
|
225
|
+
lastRunStatus.value = '执行完成';
|
|
226
|
+
}
|
|
227
|
+
if (!executionLog.value.trim()) {
|
|
228
|
+
executionLog.value = '执行完成,无输出';
|
|
229
|
+
}
|
|
230
|
+
} catch (error) {
|
|
231
|
+
const isAbortError = error instanceof DOMException && error.name === 'AbortError';
|
|
232
|
+
executionLog.value = isAbortError ? '脚本执行已停止。' : error instanceof Error ? error.message : '执行失败';
|
|
233
|
+
markExecutionProcess(false, executionLog.value);
|
|
234
|
+
lastRunStatus.value = isAbortError ? '已停止' : '执行失败';
|
|
235
|
+
} finally {
|
|
236
|
+
stopExecutionProgressTimer();
|
|
237
|
+
isRunningScript.value = false;
|
|
238
|
+
isStoppingScript.value = false;
|
|
239
|
+
runStartedAt.value = null;
|
|
240
|
+
runningElapsedNow.value = 0;
|
|
241
|
+
scriptRunAbortController = null;
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const stopSelectedScript = async () => {
|
|
246
|
+
if (!isRunningScript.value || isStoppingScript.value) return;
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
await ElMessageBox.confirm(
|
|
250
|
+
'停止后当前脚本进程会立即终止,未完成步骤不会继续执行。确定停止吗?',
|
|
251
|
+
'确认停止执行',
|
|
252
|
+
{
|
|
253
|
+
confirmButtonText: '停止执行',
|
|
254
|
+
cancelButtonText: '继续执行',
|
|
255
|
+
type: 'warning',
|
|
256
|
+
confirmButtonClass: 'el-button--danger',
|
|
257
|
+
},
|
|
258
|
+
);
|
|
259
|
+
} catch {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
isStoppingScript.value = true;
|
|
264
|
+
lastRunStatus.value = '停止中';
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
await api.stopScript();
|
|
268
|
+
} catch (error) {
|
|
269
|
+
executionLog.value += `${error instanceof Error ? error.message : '停止执行失败'}\n`;
|
|
270
|
+
scriptRunAbortController?.abort();
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
onScopeDispose(() => { stopExecutionProgressTimer(); scriptRunAbortController?.abort(); });
|
|
276
|
+
return { isRunningScript, isStoppingScript, executionLog, executionProcess, lastRunStatus, runningElapsedText, runSelectedScript, stopSelectedScript };
|
|
277
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveReportSummary } from '../appium-recorder/report-summary';
|
|
2
|
-
import { onScopeDispose, shallowRef, watch } from 'vue';
|
|
2
|
+
import { computed, onScopeDispose, shallowRef, watch } from 'vue';
|
|
3
3
|
import type { ConfigForm } from '../types';
|
|
4
4
|
import { isHexColor } from '../appium-recorder/flow-appearance';
|
|
5
5
|
import { resolveAiDeduplication } from '../appium-recorder/ai-deduplication';
|
|
@@ -8,7 +8,10 @@ import { resolveAiDeduplication } from '../appium-recorder/ai-deduplication';
|
|
|
8
8
|
export function useAppiumAutoSave(config: ConfigForm, save: (value: Omit<ConfigForm['appium'], 'model' | 'promptOptimizer'>) => Promise<unknown>, onError: (error: unknown) => void) {
|
|
9
9
|
const saving = shallowRef(false);
|
|
10
10
|
const status = shallowRef('');
|
|
11
|
-
|
|
11
|
+
const saved = shallowRef('');
|
|
12
|
+
const initialized = shallowRef(false);
|
|
13
|
+
const failed = shallowRef(false);
|
|
14
|
+
let pending: string | undefined;
|
|
12
15
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
13
16
|
// 模型只能通过“测试并保存”提交,自动保存仅观察其余设置。
|
|
14
17
|
function snapshotSettings() {
|
|
@@ -19,7 +22,8 @@ export function useAppiumAutoSave(config: ConfigForm, save: (value: Omit<ConfigF
|
|
|
19
22
|
customPresets: reportSummary.customPresets,
|
|
20
23
|
} } : {}) });
|
|
21
24
|
}
|
|
22
|
-
function initialize() { saved = snapshotSettings(); initialized = true; }
|
|
25
|
+
function initialize() { saved.value = snapshotSettings(); initialized.value = true; }
|
|
26
|
+
const dirty = computed(() => initialized.value && snapshotSettings() !== saved.value);
|
|
23
27
|
async function flush() {
|
|
24
28
|
if (saving.value || pending === undefined) return;
|
|
25
29
|
saving.value = true;
|
|
@@ -27,22 +31,27 @@ export function useAppiumAutoSave(config: ConfigForm, save: (value: Omit<ConfigF
|
|
|
27
31
|
while (pending !== undefined) {
|
|
28
32
|
const snapshot = pending;
|
|
29
33
|
pending = undefined;
|
|
30
|
-
if (snapshot === saved) { status.value = '已自动保存'; continue; }
|
|
34
|
+
if (snapshot === saved.value) { status.value = '已自动保存'; continue; }
|
|
31
35
|
status.value = '正在自动保存…';
|
|
32
36
|
try {
|
|
33
37
|
await save(JSON.parse(snapshot));
|
|
34
|
-
saved = snapshot;
|
|
38
|
+
saved.value = snapshot;
|
|
39
|
+
failed.value = false;
|
|
35
40
|
if (snapshotSettings() === snapshot) status.value = '已自动保存';
|
|
36
41
|
} catch (error) {
|
|
37
|
-
status.value = '
|
|
42
|
+
status.value = '自动保存失败,修改已保留';
|
|
43
|
+
failed.value = true;
|
|
38
44
|
onError(error);
|
|
39
|
-
//
|
|
45
|
+
// 保留失败快照,等待显式重试或下次修改,避免离线时无限请求。
|
|
46
|
+
pending = pending ?? snapshot;
|
|
47
|
+
break;
|
|
40
48
|
}
|
|
41
49
|
}
|
|
42
50
|
} finally { saving.value = false; }
|
|
43
51
|
}
|
|
44
|
-
|
|
45
|
-
if (!initialized) return;
|
|
52
|
+
function queue(snapshot: string) {
|
|
53
|
+
if (!initialized.value) return;
|
|
54
|
+
failed.value = false;
|
|
46
55
|
clearTimeout(timer);
|
|
47
56
|
pending = undefined;
|
|
48
57
|
const value = config.appium;
|
|
@@ -56,7 +65,9 @@ export function useAppiumAutoSave(config: ConfigForm, save: (value: Omit<ConfigF
|
|
|
56
65
|
pending = snapshot;
|
|
57
66
|
status.value = '等待自动保存…';
|
|
58
67
|
timer = setTimeout(() => { void flush(); }, 350);
|
|
59
|
-
}
|
|
68
|
+
}
|
|
69
|
+
watch(snapshotSettings, queue, { flush: 'sync' });
|
|
70
|
+
function retry() { queue(snapshotSettings()); clearTimeout(timer); return flush(); }
|
|
60
71
|
onScopeDispose(() => { clearTimeout(timer); pending = undefined; });
|
|
61
|
-
return { initialize, saving, status };
|
|
72
|
+
return { initialize, saving, status, failed, dirty, retry };
|
|
62
73
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { reactive, ref, shallowRef } from 'vue';
|
|
2
|
+
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
3
|
+
import { resolveReportSummary } from '../appium-recorder/report-summary';
|
|
4
|
+
import { isAiRecognitionModelConfigured } from '../appium-recorder/ai-recognition';
|
|
5
|
+
import { resolveAiDeduplication } from '../appium-recorder/ai-deduplication';
|
|
6
|
+
import { resolveAiPromptPresets } from '../appium-recorder/ai-prompt-presets';
|
|
7
|
+
import { normalizeFlowBackground, normalizeFlowLineColor } from '../appium-recorder/flow-appearance';
|
|
8
|
+
import { selectMidsceneModelProvider, type MidsceneModelProvider } from './midscene-model-presets';
|
|
9
|
+
import { useAppiumAutoSave } from './use-appium-auto-save';
|
|
10
|
+
import type { ConfigForm } from '../types';
|
|
11
|
+
import * as api from '../api';
|
|
12
|
+
|
|
13
|
+
export function useModelConfig() {
|
|
14
|
+
const isSavingModelConfig = ref(false);
|
|
15
|
+
const testingModelKey = ref('');
|
|
16
|
+
const modelTestStatus = reactive({
|
|
17
|
+
midscene: '',
|
|
18
|
+
scriptOptimizer: '',
|
|
19
|
+
appium: '',
|
|
20
|
+
promptOptimizer: '',
|
|
21
|
+
});
|
|
22
|
+
const aiRecognitionModelConfigured = shallowRef(false);
|
|
23
|
+
const ready = shallowRef(false);
|
|
24
|
+
const configForm = reactive<ConfigForm>({
|
|
25
|
+
appium: { reportSummary: resolveReportSummary(), model: { baseUrl: '', apiKey: '', name: '' }, promptOptimizer: { model: { baseUrl: '', apiKey: '', name: '' } }, aiDeduplication: resolveAiDeduplication(), screenshotReport: false },
|
|
26
|
+
runtime: {
|
|
27
|
+
androidSdkPath: '',
|
|
28
|
+
reportOutputPath: '',
|
|
29
|
+
},
|
|
30
|
+
midscene: {
|
|
31
|
+
model: {
|
|
32
|
+
provider: 'custom',
|
|
33
|
+
baseUrl: '',
|
|
34
|
+
apiKey: '',
|
|
35
|
+
name: '',
|
|
36
|
+
family: '',
|
|
37
|
+
},
|
|
38
|
+
env: {},
|
|
39
|
+
},
|
|
40
|
+
scriptOptimizer: {
|
|
41
|
+
model: {
|
|
42
|
+
baseUrl: '',
|
|
43
|
+
apiKey: '',
|
|
44
|
+
name: '',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
const lastCustomMidsceneModel = reactive({
|
|
49
|
+
baseUrl: '',
|
|
50
|
+
apiKey: '',
|
|
51
|
+
name: '',
|
|
52
|
+
family: '',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const appiumAutoSave = useAppiumAutoSave(configForm, async value => {
|
|
56
|
+
await api.saveAppiumConfig(value);
|
|
57
|
+
}, error => {
|
|
58
|
+
ElMessage.error(error instanceof Error ? error.message : 'Appium 配置自动保存失败');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const loadConfig = async () => {
|
|
62
|
+
if (ready.value) return;
|
|
63
|
+
const payload = await api.getConfig();
|
|
64
|
+
Object.assign(configForm.runtime, payload.runtime || {});
|
|
65
|
+
Object.assign(configForm.midscene.model, payload.midscene.model);
|
|
66
|
+
Object.keys(configForm.midscene.env).forEach((key) => {
|
|
67
|
+
delete configForm.midscene.env[key];
|
|
68
|
+
});
|
|
69
|
+
Object.assign(configForm.midscene.env, payload.midscene.env || {});
|
|
70
|
+
Object.assign(configForm.scriptOptimizer.model, payload.scriptOptimizer.model);
|
|
71
|
+
Object.assign(configForm.appium.model, payload.appium?.model || { baseUrl: '', apiKey: '', name: '' });
|
|
72
|
+
Object.assign(configForm.appium.promptOptimizer!.model, payload.appium?.promptOptimizer?.model || { baseUrl: '', apiKey: '', name: '' });
|
|
73
|
+
configForm.appium.screenshotReport = payload.appium?.screenshotReport === true;
|
|
74
|
+
configForm.appium.reportSummary = resolveReportSummary(payload.appium?.reportSummary);
|
|
75
|
+
configForm.appium.aiDeduplication = resolveAiDeduplication(payload.appium?.aiDeduplication);
|
|
76
|
+
configForm.appium.aiPromptPresets = resolveAiPromptPresets(payload.appium?.aiPromptPresets);
|
|
77
|
+
configForm.appium.flowBackgroundColor = normalizeFlowBackground(payload.appium?.flowBackgroundColor);
|
|
78
|
+
configForm.appium.flowLineColor = normalizeFlowLineColor(payload.appium?.flowLineColor);
|
|
79
|
+
appiumAutoSave.initialize();
|
|
80
|
+
ready.value = true;
|
|
81
|
+
aiRecognitionModelConfigured.value = isAiRecognitionModelConfigured(configForm.appium.model);
|
|
82
|
+
if (configForm.midscene.model.provider !== 'codex') {
|
|
83
|
+
rememberCustomMidsceneModel();
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const rememberCustomMidsceneModel = () => {
|
|
88
|
+
if (configForm.midscene.model.provider === 'codex') return;
|
|
89
|
+
lastCustomMidsceneModel.baseUrl = configForm.midscene.model.baseUrl;
|
|
90
|
+
lastCustomMidsceneModel.apiKey = configForm.midscene.model.apiKey;
|
|
91
|
+
lastCustomMidsceneModel.name = configForm.midscene.model.name;
|
|
92
|
+
lastCustomMidsceneModel.family = configForm.midscene.model.family;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const updateMidsceneModelProvider = (provider: MidsceneModelProvider) => {
|
|
96
|
+
if (configForm.midscene.model.provider !== 'codex') {
|
|
97
|
+
rememberCustomMidsceneModel();
|
|
98
|
+
}
|
|
99
|
+
Object.assign(configForm.midscene.model, selectMidsceneModelProvider(provider, configForm.midscene.model, lastCustomMidsceneModel));
|
|
100
|
+
modelTestStatus.midscene = '';
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const getMidsceneModelConfigError = () => {
|
|
104
|
+
const model = configForm.midscene.model;
|
|
105
|
+
const missing = [];
|
|
106
|
+
|
|
107
|
+
if (!model.baseUrl.trim()) missing.push('Base URL');
|
|
108
|
+
if (!model.name.trim()) missing.push('Model Name');
|
|
109
|
+
if (!model.family.trim()) missing.push('Model Family');
|
|
110
|
+
if (model.provider !== 'codex' && !model.apiKey.trim()) missing.push('API Key');
|
|
111
|
+
|
|
112
|
+
if (!missing.length) return '';
|
|
113
|
+
return `Midscene 模型配置不完整:缺少 ${missing.join('、')}。请在“参数配置 > Midscene配置 > Midscene 模型”中选择“使用 Codex”,或补齐自定义提供方后测试并保存。`;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const saveModelConfig = async () => {
|
|
117
|
+
if (!ready.value || isSavingModelConfig.value) return;
|
|
118
|
+
isSavingModelConfig.value = true;
|
|
119
|
+
try {
|
|
120
|
+
await api.saveConfig({ runtime: { ...configForm.runtime } });
|
|
121
|
+
ElMessage.success('参数配置已保存');
|
|
122
|
+
} catch (error) {
|
|
123
|
+
const message = error instanceof Error ? error.message : '参数配置保存失败';
|
|
124
|
+
ElMessage.error(message);
|
|
125
|
+
} finally {
|
|
126
|
+
isSavingModelConfig.value = false;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const testModel = async (key: 'midscene' | 'scriptOptimizer' | 'appium' | 'promptOptimizer') => {
|
|
131
|
+
if (!ready.value || testingModelKey.value) return;
|
|
132
|
+
testingModelKey.value = key;
|
|
133
|
+
modelTestStatus[key] = '';
|
|
134
|
+
const model = { ...(key === 'promptOptimizer'
|
|
135
|
+
? configForm.appium.promptOptimizer!.model
|
|
136
|
+
: configForm[key].model) };
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const payload = await api.testModel({ modelKey: key, model, save: true });
|
|
140
|
+
if (!payload.saved) throw new Error('模型未保存,请重试');
|
|
141
|
+
const successMessage = `测试通过,配置已保存${payload.content ? `:${payload.content}` : ''}`;
|
|
142
|
+
modelTestStatus[key] = '';
|
|
143
|
+
if (key === 'appium') aiRecognitionModelConfigured.value = isAiRecognitionModelConfigured(model);
|
|
144
|
+
void ElMessageBox.alert(successMessage, '模型测试成功', {
|
|
145
|
+
type: 'success',
|
|
146
|
+
confirmButtonText: '确定',
|
|
147
|
+
}).catch(() => {});
|
|
148
|
+
} catch (error) {
|
|
149
|
+
modelTestStatus[key] = `未保存:${error instanceof Error ? error.message : '测试失败'}`;
|
|
150
|
+
} finally {
|
|
151
|
+
testingModelKey.value = '';
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
return { ready, configForm, appiumAutoSave, isSavingModelConfig, testingModelKey, modelTestStatus, aiRecognitionModelConfigured, loadConfig, updateMidsceneModelProvider, getMidsceneModelConfigError, saveModelConfig, testModel };
|
|
157
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { createApp } from 'vue';
|
|
2
2
|
import ElementPlus from 'element-plus';
|
|
3
|
+
import zhCn from 'element-plus/es/locale/lang/zh-cn';
|
|
3
4
|
import 'element-plus/dist/index.css';
|
|
4
5
|
import '@vue-flow/core/dist/style.css';
|
|
5
6
|
import '@vue-flow/core/dist/theme-default.css';
|
|
6
7
|
import App from './App.vue';
|
|
7
8
|
import './style.css';
|
|
8
|
-
import './ui-refresh.css';
|
|
9
9
|
|
|
10
|
-
createApp(App).use(ElementPlus).mount('#app');
|
|
10
|
+
createApp(App).use(ElementPlus, { locale: zhCn }).mount('#app');
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { shallowRef, watch } from 'vue';
|
|
3
|
+
import { Refresh } from '@element-plus/icons-vue';
|
|
4
|
+
import { getTestAnalytics } from '../api';
|
|
5
|
+
import type { AnalysisResponse } from '../analytics/summary';
|
|
6
|
+
import AnalysisCharts from '../components/analytics/AnalysisCharts.vue';
|
|
7
|
+
import ReportAnalysis from '../components/analytics/ReportAnalysis.vue';
|
|
8
|
+
const props = defineProps<{ active: boolean }>();
|
|
9
|
+
const days = shallowRef(0);
|
|
10
|
+
const data = shallowRef<AnalysisResponse | null>(null);
|
|
11
|
+
const loading = shallowRef(false);
|
|
12
|
+
const error = shallowRef('');
|
|
13
|
+
let requestId = 0;
|
|
14
|
+
async function refresh() {
|
|
15
|
+
const id = ++requestId;
|
|
16
|
+
loading.value = true;
|
|
17
|
+
error.value = '';
|
|
18
|
+
try { const result = await getTestAnalytics(days.value); if (id === requestId) data.value = result; }
|
|
19
|
+
catch (reason) { if (id === requestId) error.value = reason instanceof Error ? reason.message : '统计数据加载失败'; }
|
|
20
|
+
finally { if (id === requestId) loading.value = false; }
|
|
21
|
+
}
|
|
22
|
+
watch(() => props.active, active => { if (active) void refresh(); }, { immediate: true });
|
|
23
|
+
watch(days, () => { void refresh(); });
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<div class="analysis-page" :aria-busy="loading">
|
|
28
|
+
<div class="analysis-page__heading">
|
|
29
|
+
<div><h1>测试分析</h1><p>基于已保存脚本与测试执行记录,查看两种引擎的运行情况。</p></div>
|
|
30
|
+
<div class="analysis-page__actions">
|
|
31
|
+
<el-select v-model="days" aria-label="统计时间范围" style="width: 150px">
|
|
32
|
+
<el-option label="全部时间" :value="0" /><el-option label="最近 7 天" :value="7" /><el-option label="最近 30 天" :value="30" /><el-option label="最近 90 天" :value="90" />
|
|
33
|
+
</el-select>
|
|
34
|
+
<el-button :icon="Refresh" :loading="loading" @click="refresh">刷新统计</el-button>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
<el-alert v-if="error" type="error" :closable="false" show-icon :title="error"><el-button link type="primary" @click="refresh">重新加载</el-button></el-alert>
|
|
38
|
+
<div v-if="loading && !data" class="page-loading" role="status">正在汇总测试记录…</div>
|
|
39
|
+
<template v-if="data">
|
|
40
|
+
<div class="analysis-page__note">
|
|
41
|
+
<span>{{ data.days ? `最近 ${data.days} 天` : '全部时间' }} · 通过率 = 通过 ÷(通过 + 失败);平均耗时仅统计已完成测试,停止和运行中单独列出。</span>
|
|
42
|
+
<span>更新于 {{ new Date(data.generatedAt).toLocaleString('zh-CN', { hour12: false }) }}{{ loading ? ' · 更新中…' : '' }}</span>
|
|
43
|
+
</div>
|
|
44
|
+
<AnalysisCharts :data="data" />
|
|
45
|
+
<ReportAnalysis title="Midscene" :summary="data.midscene" />
|
|
46
|
+
<ReportAnalysis title="Appium" :summary="data.appium" />
|
|
47
|
+
<p class="analysis-page__footnote">统计来自数据库中的执行记录,不依赖 HTML 报告开关。Midscene 按历史脚本名称关联,Appium 按脚本 ID 关联;已删除或无法匹配的记录标记为“历史脚本”。未保存执行记录的测试不计入统计。</p>
|
|
48
|
+
</template>
|
|
49
|
+
</div>
|
|
50
|
+
</template>
|
|
51
|
+
|
|
52
|
+
<style scoped>
|
|
53
|
+
.analysis-page { display: flex; flex-direction: column; gap: 20px; min-width: 0; }
|
|
54
|
+
.analysis-page__heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; }
|
|
55
|
+
h1 { margin: 0; font-size: 22px; font-weight: 600; }
|
|
56
|
+
.analysis-page__heading p { margin: 8px 0 0; color: var(--ui-text-secondary); font-size: 13px; }
|
|
57
|
+
.analysis-page__actions { display: flex; gap: 12px; }
|
|
58
|
+
.analysis-page__note { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--ui-text-secondary); }
|
|
59
|
+
.analysis-page__footnote { margin: 0; color: var(--ui-text-secondary); font-size: 12px; line-height: 1.8; }
|
|
60
|
+
</style>
|
package/src/pages/ConfigPage.vue
CHANGED
|
@@ -14,12 +14,14 @@ defineProps<{
|
|
|
14
14
|
isSavingModelConfig: boolean;
|
|
15
15
|
isSavingAppPreset: boolean;
|
|
16
16
|
appiumSaveStatus?: string;
|
|
17
|
+
appiumSaveFailed?: boolean;
|
|
17
18
|
modelTestStatus: { midscene: string; scriptOptimizer: string; appium?: string; promptOptimizer?: string };
|
|
18
19
|
}>();
|
|
19
20
|
|
|
20
21
|
defineEmits<{
|
|
21
22
|
testModel: [key: 'midscene' | 'scriptOptimizer' | 'appium' | 'promptOptimizer'];
|
|
22
23
|
saveModelConfig: [];
|
|
24
|
+
retryAppiumSave: [];
|
|
23
25
|
saveAppPreset: [];
|
|
24
26
|
editAppPreset: [app: AppPreset];
|
|
25
27
|
deleteAppPreset: [id: string];
|
|
@@ -115,6 +117,8 @@ function handleTabKeydown(event: KeyboardEvent, index: number) {
|
|
|
115
117
|
:prompt-optimizer-test-status="modelTestStatus.promptOptimizer"
|
|
116
118
|
@test-prompt-optimizer-model="$emit('testModel', 'promptOptimizer')"
|
|
117
119
|
:save-status="appiumSaveStatus"
|
|
120
|
+
:save-failed="appiumSaveFailed"
|
|
121
|
+
@retry-save="$emit('retryAppiumSave')"
|
|
118
122
|
@test-model="$emit('testModel', 'appium')"
|
|
119
123
|
/>
|
|
120
124
|
</section>
|
|
@@ -123,13 +127,40 @@ function handleTabKeydown(event: KeyboardEvent, index: number) {
|
|
|
123
127
|
|
|
124
128
|
<style scoped>
|
|
125
129
|
.config-tabs {
|
|
130
|
+
display: flex;
|
|
131
|
+
width: 100%;
|
|
126
132
|
max-width: 100%;
|
|
127
133
|
flex-wrap: wrap;
|
|
134
|
+
gap: 28px;
|
|
135
|
+
margin: 0;
|
|
136
|
+
padding: 0 28px;
|
|
137
|
+
border: 0;
|
|
138
|
+
border-bottom: 1px solid var(--ui-border);
|
|
139
|
+
border-radius: 0;
|
|
140
|
+
background: var(--ui-surface);
|
|
128
141
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
:
|
|
132
|
-
|
|
133
|
-
|
|
142
|
+
.config-tabs .subnav__item {
|
|
143
|
+
min-height: 46px;
|
|
144
|
+
padding: 10px 2px;
|
|
145
|
+
border-bottom: 2px solid transparent;
|
|
146
|
+
border-radius: 0;
|
|
147
|
+
font-size: 14px;
|
|
148
|
+
}
|
|
149
|
+
.config-tabs .subnav__item--active {
|
|
150
|
+
border-bottom-color: var(--ui-primary);
|
|
151
|
+
background: transparent;
|
|
152
|
+
color: var(--ui-primary);
|
|
153
|
+
box-shadow: none;
|
|
154
|
+
}
|
|
155
|
+
.config-page > [role="tabpanel"] { padding: 24px; }
|
|
156
|
+
:deep(.config-module-card > .el-card__header) { padding: 18px 24px; font-size: 16px; font-weight: 500; }
|
|
157
|
+
:deep(.config-module-card > .el-card__body) { padding: 24px; background: var(--ui-surface); }
|
|
158
|
+
:deep(.el-form-item) { margin-bottom: 22px; }
|
|
159
|
+
:deep(.el-form-item__label) { margin-bottom: 8px; font-size: 14px; }
|
|
160
|
+
:deep(.el-input__wrapper), :deep(.el-select__wrapper) { min-height: 36px; }
|
|
161
|
+
:deep(.config-grid) { margin-bottom: 0; gap: 24px; }
|
|
162
|
+
@media (max-width: 1000px) {
|
|
163
|
+
:deep(.config-model-grid) { grid-template-columns: minmax(0, 1fr); }
|
|
164
|
+
:deep(.config-model-grid > section + section) { padding: 24px 0 0; border-left: 0; border-top: 1px solid var(--ui-border); }
|
|
134
165
|
}
|
|
135
166
|
</style>
|