android-midscene-automation 0.1.43 → 0.2.2
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/{AI_RECOGNITION_GUIDE.md → AI/350/257/206/345/210/253/346/223/215/344/275/234/350/257/264/346/230/216.md} +78 -6
- package/CHANGELOG.md +13 -0
- package/FAQ.md +8 -8
- package/README.md +143 -17
- package/USAGE.md +120 -8
- package/package.json +3 -2
- 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/ai-recognition.test.ts +27 -0
- package/server/appium-recorder/ai-recognition.ts +16 -4
- package/server/appium-recorder/appium-runner.ts +11 -1
- package/server/appium-recorder/appium-version.test.ts +55 -0
- package/server/appium-recorder/managed-appium.ts +37 -1
- package/server/appium-recorder/repository.ts +6 -2
- package/server/appium-recorder/routes.ts +2 -12
- 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/routes.ts +2 -12
- package/server/request-body.test.ts +40 -0
- package/server/request-body.ts +48 -0
- package/src/App.vue +98 -742
- package/src/analytics/summary.ts +75 -0
- package/src/api.ts +11 -0
- package/src/appium-recorder/AppiumPage.vue +4 -1
- package/src/appium-recorder/ai-recognition.ts +7 -0
- package/src/appium-recorder/components/AiRecognitionSettings.vue +27 -0
- package/src/appium-recorder/flow-labels.ts +1 -1
- package/src/appium-recorder/types.ts +2 -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/AiPromptPresetsSettings.vue +55 -5
- 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/components/help/MarkdownDocument.vue +139 -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 +716 -91
- package/src/types.ts +7 -1
- package//346/265/201/347/250/213/350/212/202/347/202/271/346/223/215/344/275/234/350/257/264/346/230/216.md +408 -0
- package/src/ui-refresh.css +0 -677
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export type TestStatus = 'passed' | 'failed' | 'stopped' | 'running';
|
|
2
|
+
export type TestRun = { scriptId: string; scriptName: string; startedAt: string; durationMs: number | null; status: TestStatus };
|
|
3
|
+
export type ScriptInfo = { id: string; name: string };
|
|
4
|
+
export type AnalysisRow = {
|
|
5
|
+
id: string; name: string; archived: boolean;
|
|
6
|
+
runs: number; passed: number; failed: number; stopped: number; running: number;
|
|
7
|
+
passRate: number | null; averageDurationMs: number | null;
|
|
8
|
+
lastRunAt: string; lastStatus: TestStatus | null;
|
|
9
|
+
};
|
|
10
|
+
export type TrendPoint = { date: string; runs: number };
|
|
11
|
+
export type AnalysisSummary = {
|
|
12
|
+
scripts: number; testedScripts: number; runs: number; passed: number; failed: number; stopped: number; running: number;
|
|
13
|
+
passRate: number | null; rows: AnalysisRow[]; daily: TrendPoint[]; monthly: TrendPoint[];
|
|
14
|
+
};
|
|
15
|
+
export type AnalysisResponse = { generatedAt: string; days: number; midscene: AnalysisSummary; appium: AnalysisSummary };
|
|
16
|
+
|
|
17
|
+
// Stopped/running executions do not count as completed tests. Missing history is never a pass.
|
|
18
|
+
export function summarizeTests(scripts: ScriptInfo[], runs: TestRun[], since = 0, now = Date.now()): AnalysisSummary {
|
|
19
|
+
const dateKey = (value: Date) => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
|
|
20
|
+
const daily = new Map<string, number>();
|
|
21
|
+
const monthly = new Map<string, number>();
|
|
22
|
+
// Bound the all-time chart while retaining all historical records in the totals/tables.
|
|
23
|
+
const firstDay = since ? new Date(since) : new Date(now);
|
|
24
|
+
if (!since) firstDay.setDate(firstDay.getDate() - 29);
|
|
25
|
+
firstDay.setHours(0, 0, 0, 0);
|
|
26
|
+
for (const cursor = new Date(firstDay); cursor.getTime() <= now; cursor.setDate(cursor.getDate() + 1)) daily.set(dateKey(cursor), 0);
|
|
27
|
+
const firstMonth = since ? new Date(since) : new Date(now);
|
|
28
|
+
firstMonth.setDate(1);
|
|
29
|
+
firstMonth.setHours(0, 0, 0, 0);
|
|
30
|
+
if (!since) firstMonth.setMonth(firstMonth.getMonth() - 11);
|
|
31
|
+
for (const cursor = new Date(firstMonth); cursor.getTime() <= now; cursor.setMonth(cursor.getMonth() + 1)) monthly.set(dateKey(cursor).slice(0, 7), 0);
|
|
32
|
+
const rows = new Map<string, AnalysisRow>();
|
|
33
|
+
const durations = new Map<string, { total: number; count: number }>();
|
|
34
|
+
const makeRow = (id: string, name: string, archived = false): AnalysisRow => ({
|
|
35
|
+
id, name, archived, runs: 0, passed: 0, failed: 0, stopped: 0, running: 0,
|
|
36
|
+
passRate: null, averageDurationMs: null, lastRunAt: '', lastStatus: null,
|
|
37
|
+
});
|
|
38
|
+
for (const script of scripts) rows.set(script.id, makeRow(script.id, script.name));
|
|
39
|
+
for (const run of runs) {
|
|
40
|
+
const time = Date.parse(run.startedAt);
|
|
41
|
+
if (since && (!Number.isFinite(time) || time < since)) continue;
|
|
42
|
+
if (Number.isFinite(time)) {
|
|
43
|
+
const day = dateKey(new Date(time));
|
|
44
|
+
const month = day.slice(0, 7);
|
|
45
|
+
if (daily.has(day)) daily.set(day, daily.get(day)! + 1);
|
|
46
|
+
if (monthly.has(month)) monthly.set(month, monthly.get(month)! + 1);
|
|
47
|
+
}
|
|
48
|
+
const row = rows.get(run.scriptId) ?? makeRow(run.scriptId, run.scriptName || '未命名脚本', true);
|
|
49
|
+
row.runs++;
|
|
50
|
+
row[run.status]++;
|
|
51
|
+
if (!row.lastRunAt || time >= Date.parse(row.lastRunAt)) {
|
|
52
|
+
row.lastRunAt = run.startedAt;
|
|
53
|
+
row.lastStatus = run.status;
|
|
54
|
+
}
|
|
55
|
+
if ((run.status === 'passed' || run.status === 'failed') && run.durationMs !== null && Number.isFinite(run.durationMs) && run.durationMs >= 0) {
|
|
56
|
+
const duration = durations.get(row.id) ?? { total: 0, count: 0 };
|
|
57
|
+
duration.total += run.durationMs;
|
|
58
|
+
duration.count++;
|
|
59
|
+
durations.set(row.id, duration);
|
|
60
|
+
}
|
|
61
|
+
rows.set(row.id, row);
|
|
62
|
+
}
|
|
63
|
+
const result: AnalysisSummary = { scripts: scripts.length, testedScripts: 0, runs: 0, passed: 0, failed: 0, stopped: 0, running: 0, passRate: null, rows: [], daily: [...daily].map(([date, runs]) => ({ date, runs })), monthly: [...monthly].map(([date, runs]) => ({ date, runs })) };
|
|
64
|
+
for (const row of rows.values()) {
|
|
65
|
+
row.passRate = row.passed + row.failed ? row.passed / (row.passed + row.failed) : null;
|
|
66
|
+
const duration = durations.get(row.id);
|
|
67
|
+
row.averageDurationMs = duration ? duration.total / duration.count : null;
|
|
68
|
+
if (!row.archived && row.runs) result.testedScripts++;
|
|
69
|
+
for (const key of ['runs', 'passed', 'failed', 'stopped', 'running'] as const) result[key] += row[key];
|
|
70
|
+
result.rows.push(row);
|
|
71
|
+
}
|
|
72
|
+
result.passRate = result.passed + result.failed ? result.passed / (result.passed + result.failed) : null;
|
|
73
|
+
result.rows.sort((a, b) => b.lastRunAt.localeCompare(a.lastRunAt) || a.name.localeCompare(b.name));
|
|
74
|
+
return result;
|
|
75
|
+
}
|
package/src/api.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ScriptStep } from './script-generator';
|
|
|
2
2
|
import type { MidsceneModelProvider } from './config/midscene-model-presets';
|
|
3
3
|
import type {
|
|
4
4
|
AndroidDevice,
|
|
5
|
+
AppiumVersionInfo,
|
|
5
6
|
AppPreset,
|
|
6
7
|
ConfigForm,
|
|
7
8
|
DeviceLock,
|
|
@@ -42,6 +43,11 @@ async function postJson<T>(url: string, body?: unknown, options: { signal?: Abor
|
|
|
42
43
|
return readJson<T>(response);
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
export async function getAppiumVersion() {
|
|
47
|
+
const response = await fetch(`${APP_BASE}/api/appium-version`, { signal: AbortSignal.timeout(8000) });
|
|
48
|
+
return readJson<AppiumVersionInfo>(response);
|
|
49
|
+
}
|
|
50
|
+
|
|
45
51
|
// 读取已保存的脚本列表,自动化测试页面左侧脚本列表使用。
|
|
46
52
|
export async function getScripts() {
|
|
47
53
|
const response = await fetch(`${APP_BASE}/api/scripts`);
|
|
@@ -302,3 +308,8 @@ export async function runScript(
|
|
|
302
308
|
onEvent(JSON.parse(buffer) as RunScriptStreamEvent);
|
|
303
309
|
}
|
|
304
310
|
}
|
|
311
|
+
|
|
312
|
+
export async function getTestAnalytics(days = 0) {
|
|
313
|
+
const response = await fetch(`${APP_BASE}/api/analytics?days=${days}`, { signal: AbortSignal.timeout(15000) });
|
|
314
|
+
return readJson<import('./analytics/summary').AnalysisResponse>(response);
|
|
315
|
+
}
|
|
@@ -239,7 +239,7 @@ function onVariablesSaved(id: string, variables: TestVariable[]) {
|
|
|
239
239
|
}
|
|
240
240
|
|
|
241
241
|
function warnBeforeUnload(event: BeforeUnloadEvent) {
|
|
242
|
-
if (!
|
|
242
|
+
if (!hasUnsavedChanges.value) return;
|
|
243
243
|
event.preventDefault();
|
|
244
244
|
event.returnValue = '';
|
|
245
245
|
}
|
|
@@ -2232,6 +2232,7 @@ watch(
|
|
|
2232
2232
|
|
|
2233
2233
|
<template>
|
|
2234
2234
|
<section class="appium-recorder-page">
|
|
2235
|
+
<Teleport to="#appium-header-actions">
|
|
2235
2236
|
<div v-if="active" class="appium-header-actions">
|
|
2236
2237
|
<el-select
|
|
2237
2238
|
v-model="selectedScriptId"
|
|
@@ -2281,6 +2282,8 @@ watch(
|
|
|
2281
2282
|
</el-button>
|
|
2282
2283
|
</div>
|
|
2283
2284
|
|
|
2285
|
+
</Teleport>
|
|
2286
|
+
|
|
2284
2287
|
<RecorderWorkspace ref="workspaceRef">
|
|
2285
2288
|
<template #preview>
|
|
2286
2289
|
<DevicePreviewPanel
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AiDeduplicationConfig } from './ai-deduplication';
|
|
2
2
|
|
|
3
3
|
export type AiRecognitionModel = { baseUrl: string; apiKey: string; name: string };
|
|
4
|
+
export type AiInvalidResultBranch = 'yes' | 'no';
|
|
4
5
|
export type AiObservationConfig = { durationMs: number; intervalMs: number; mode?: 'batch' | 'untilMatch' };
|
|
5
6
|
export type AiObservationFrame = { elapsedMs: number; imageDataUrl: string };
|
|
6
7
|
export type AiRecognitionResult = {
|
|
@@ -21,6 +22,12 @@ export function validateAiObservation(value: unknown): AiObservationConfig | und
|
|
|
21
22
|
return { durationMs, intervalMs, ...(mode ? { mode } : {}) };
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
export function validateAiInvalidResultBranch(value: unknown): AiInvalidResultBranch | undefined {
|
|
26
|
+
if (value === undefined || value === null || value === '') return undefined;
|
|
27
|
+
if (value !== 'yes' && value !== 'no') throw new Error('AI 无有效结果兜底分支无效');
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
24
31
|
// 未保存此字段的是旧版判断节点,保留已有分支;新节点显式保存 false。
|
|
25
32
|
export function isAiBranchEnabled(step: { aiBranchEnabled?: boolean }) {
|
|
26
33
|
return step.aiBranchEnabled !== false;
|
|
@@ -3,6 +3,7 @@ import { computed, inject, onBeforeUnmount, ref, shallowRef, watch } from 'vue';
|
|
|
3
3
|
import { Loading, QuestionFilled } from '@element-plus/icons-vue';
|
|
4
4
|
import { isAiBranchEnabled, validateAiRecognitionPrompt, DEFAULT_AI_OBSERVATION, validateAiObservation, type AiObservationConfig } from '../ai-recognition';
|
|
5
5
|
import { optimizeAppiumPrompt, validateAiBranchQuestion } from '../api';
|
|
6
|
+
import { flowBranchLabel } from '../flow-labels';
|
|
6
7
|
import type { AppiumRecordedStep } from '../types';
|
|
7
8
|
import { aiPromptPresetsKey, DEFAULT_AI_PROMPT_PRESETS } from '../ai-prompt-presets';
|
|
8
9
|
import AiRecognitionPromptInput from './AiRecognitionPromptInput.vue';
|
|
@@ -17,7 +18,9 @@ const error = ref('');
|
|
|
17
18
|
const branch = computed(() => isAiBranchEnabled(props.step));
|
|
18
19
|
const untilMatch = computed(() => props.step.aiObservation?.mode === 'untilMatch');
|
|
19
20
|
const branchChoice = ref(branch.value);
|
|
21
|
+
const fallbackEnabled = shallowRef(Boolean(props.step.aiInvalidResultBranch));
|
|
20
22
|
watch(branch, value => { branchChoice.value = value; });
|
|
23
|
+
watch(() => props.step.aiInvalidResultBranch, value => { fallbackEnabled.value = Boolean(value); });
|
|
21
24
|
let controller: AbortController | undefined;
|
|
22
25
|
let optimizeController: AbortController | undefined;
|
|
23
26
|
watch(() => props.step.value, value => { prompt.value = value || ''; });
|
|
@@ -37,6 +40,12 @@ async function toggleBranch(enabled: boolean) {
|
|
|
37
40
|
branchChoice.value = branch.value;
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
function toggleFallback(enabled: boolean) {
|
|
44
|
+
fallbackEnabled.value = enabled;
|
|
45
|
+
error.value = '';
|
|
46
|
+
if (!enabled) emit('update', { aiInvalidResultBranch: undefined });
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
async function optimizePrompt() {
|
|
41
50
|
if (busy.value || optimizingPrompt.value || props.disabled) return;
|
|
42
51
|
error.value = '';
|
|
@@ -148,6 +157,24 @@ async function save(enabled: boolean, observation?: AiObservationConfig) {
|
|
|
148
157
|
正在验证…
|
|
149
158
|
</span>
|
|
150
159
|
</el-form-item>
|
|
160
|
+
<el-form-item v-if="branch">
|
|
161
|
+
<el-checkbox :model-value="fallbackEnabled" :disabled="disabled || busy" @update:model-value="toggleFallback($event === true)">启用兜底</el-checkbox>
|
|
162
|
+
<el-tooltip placement="top" :show-after="200">
|
|
163
|
+
<template #content><div class="recognition-help-content">仅在模型返回空内容、无效格式、无法判断或没有返回 true/false 时生效。正常返回布尔结果时仍按识别结果进入分支;请求失败和超时不使用此兜底。</div></template>
|
|
164
|
+
<el-button class="recognition-help" text :icon="QuestionFilled" aria-label="AI 识别兜底说明" @click.prevent />
|
|
165
|
+
</el-tooltip>
|
|
166
|
+
</el-form-item>
|
|
167
|
+
<el-form-item v-if="branch && fallbackEnabled" label="兜底分支">
|
|
168
|
+
<el-select
|
|
169
|
+
:model-value="step.aiInvalidResultBranch || ''"
|
|
170
|
+
:disabled="disabled || busy"
|
|
171
|
+
placeholder="请选择左右分支"
|
|
172
|
+
@update:model-value="emit('update', { aiInvalidResultBranch: $event })"
|
|
173
|
+
>
|
|
174
|
+
<el-option :label="`进入左侧分支(${flowBranchLabel(step, 'yes')})`" value="yes" />
|
|
175
|
+
<el-option :label="`进入右侧分支(${flowBranchLabel(step, 'no')})`" value="no" />
|
|
176
|
+
</el-select>
|
|
177
|
+
</el-form-item>
|
|
151
178
|
<el-alert v-if="error" :title="error" type="warning" :closable="false" show-icon />
|
|
152
179
|
</template>
|
|
153
180
|
|
|
@@ -85,7 +85,7 @@ export function flowStepMeta(step: AppiumRecordedStep) {
|
|
|
85
85
|
if (step.type === 'breakLoop') return step.breakLoopTargetId ? '退出指定循环,继续循环结束后的流程' : '退出当前循环,继续循环结束后的流程';
|
|
86
86
|
if (step.type === 'continueLoop') return step.continueLoopTargetId ? '结束本轮,进入指定循环的下一轮' : '结束本轮,进入当前循环的下一轮';
|
|
87
87
|
if (step.type === 'log') return `${step.logPrefix ?? DEFAULT_LOG_PREFIX}:${step.value || ''}`;
|
|
88
|
-
if (step.type === 'aiRecognition') return `AI ${step.aiObservation?.mode === 'untilMatch' ? '命中即结束' : step.aiObservation ? '持续观察' : '识别'} · ${step.value || '未填写识别内容'}`;
|
|
88
|
+
if (step.type === 'aiRecognition') return `AI ${step.aiObservation?.mode === 'untilMatch' ? '命中即结束' : step.aiObservation ? '持续观察' : '识别'} · ${step.value || '未填写识别内容'}${step.aiInvalidResultBranch ? ` · 无有效结果→${step.aiInvalidResultBranch === 'yes' ? 'true' : 'false'}` : ''}`;
|
|
89
89
|
if (step.type === 'longPress') {
|
|
90
90
|
const target = longPressMode(step) === 'element'
|
|
91
91
|
? `元素 ${step.selector?.strategy || ''} ${step.selector?.value || ''}`
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AiObservationConfig } from './ai-recognition';
|
|
1
|
+
import type { AiInvalidResultBranch, AiObservationConfig } from './ai-recognition';
|
|
2
2
|
import type { TestVariable, VariableExtraction, ScriptReturn } from './variables';
|
|
3
3
|
import type { ImageCheckConfig } from './image-check';
|
|
4
4
|
|
|
@@ -125,6 +125,7 @@ export type AppiumRecordedStep = {
|
|
|
125
125
|
timeoutMs?: number;
|
|
126
126
|
aiTimeoutEnabled?: boolean;
|
|
127
127
|
aiBranchEnabled?: boolean;
|
|
128
|
+
aiInvalidResultBranch?: AiInvalidResultBranch;
|
|
128
129
|
aiObservation?: AiObservationConfig;
|
|
129
130
|
timeoutBranch?: 'stop' | 'yes' | 'no';
|
|
130
131
|
longPressMode?: 'element' | 'coordinates';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { DataAnalysis, Grid, Monitor, Operation, InfoFilled, Setting } from '@element-plus/icons-vue';
|
|
3
|
+
import type { MenuKey } from '../types';
|
|
4
|
+
|
|
5
|
+
defineProps<{ activeMenu: MenuKey; collapsed: boolean }>();
|
|
6
|
+
defineEmits<{ select: [key: string] }>();
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<aside class="layout-sidebar" aria-label="主导航">
|
|
11
|
+
<div class="layout-sidebar__top">
|
|
12
|
+
<div class="layout-sidebar__logo" aria-hidden="true">M</div>
|
|
13
|
+
<span v-if="!collapsed" class="layout-sidebar__brand">Midscene <span>Admin</span></span>
|
|
14
|
+
</div>
|
|
15
|
+
<el-menu :default-active="activeMenu" :default-openeds="['overview', 'midscene']" :collapse="collapsed"
|
|
16
|
+
:collapse-transition="false" class="layout-menu" @select="$emit('select', $event)">
|
|
17
|
+
<el-sub-menu index="overview" aria-label="概览">
|
|
18
|
+
<template #title><el-icon><Grid /></el-icon><span>概览</span></template>
|
|
19
|
+
<el-menu-item index="analysis" aria-label="分析页"><el-icon><DataAnalysis /></el-icon><template #title>分析页</template></el-menu-item>
|
|
20
|
+
</el-sub-menu>
|
|
21
|
+
<el-sub-menu index="midscene" aria-label="Midscene">
|
|
22
|
+
<template #title><el-icon><Operation /></el-icon><span>Midscene</span></template>
|
|
23
|
+
<el-menu-item index="generator" aria-label="测试脚本生成"><el-icon><Operation /></el-icon><template #title>测试脚本生成</template></el-menu-item>
|
|
24
|
+
<el-menu-item index="automation" aria-label="自动化测试"><el-icon><Monitor /></el-icon><template #title>自动化测试</template></el-menu-item>
|
|
25
|
+
</el-sub-menu>
|
|
26
|
+
<el-menu-item index="appium" aria-label="Appium"><el-icon><Monitor /></el-icon><template #title>Appium</template></el-menu-item>
|
|
27
|
+
<el-menu-item index="config" aria-label="参数配置"><el-icon><Setting /></el-icon><template #title>参数配置</template></el-menu-item>
|
|
28
|
+
<el-menu-item index="about" aria-label="关于"><el-icon><InfoFilled /></el-icon><template #title>关于</template></el-menu-item>
|
|
29
|
+
</el-menu>
|
|
30
|
+
</aside>
|
|
31
|
+
</template>
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, watch } from 'vue';
|
|
3
|
+
import { Collection, VideoPlay, CircleCheck, Warning } from '@element-plus/icons-vue';
|
|
4
|
+
import type { AnalysisResponse } from '../../analytics/summary';
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{ data: AnalysisResponse }>();
|
|
7
|
+
const mode = ref<'daily' | 'monthly'>('daily');
|
|
8
|
+
const shown = ref({ midscene: true, appium: true });
|
|
9
|
+
const selectedPoint = ref<number | null>(null);
|
|
10
|
+
const engineColors = { midscene: '#4ea9ee', appium: '#13b8a6' };
|
|
11
|
+
const total = computed(() => {
|
|
12
|
+
const { midscene: m, appium: a } = props.data;
|
|
13
|
+
return { scripts: m.scripts + a.scripts, runs: m.runs + a.runs, passed: m.passed + a.passed, failed: m.failed + a.failed, stopped: m.stopped + a.stopped, running: m.running + a.running };
|
|
14
|
+
});
|
|
15
|
+
const passRate = computed(() => total.value.passed + total.value.failed ? `${(100 * total.value.passed / (total.value.passed + total.value.failed)).toFixed(1)}%` : '—');
|
|
16
|
+
const cards = computed(() => [
|
|
17
|
+
{ name: '测试脚本', value: total.value.scripts, note: `Midscene ${props.data.midscene.scripts} · Appium ${props.data.appium.scripts}`, icon: Collection, color: '#006be6' },
|
|
18
|
+
{ name: '执行次数', value: total.value.runs, note: `Midscene ${props.data.midscene.runs} · Appium ${props.data.appium.runs}`, icon: VideoPlay, color: '#13b8a6' },
|
|
19
|
+
{ name: '测试通过率', value: passRate.value, note: `通过 ${total.value.passed} 次 / 完成 ${total.value.passed + total.value.failed} 次`, icon: CircleCheck, color: '#67b92e' },
|
|
20
|
+
{ name: '失败次数', value: total.value.failed, note: `已停止 ${total.value.stopped} 次 · 运行中 ${total.value.running} 次`, icon: Warning, color: '#ed6a5e' },
|
|
21
|
+
]);
|
|
22
|
+
const points = computed(() => {
|
|
23
|
+
const appium = new Map(props.data.appium[mode.value].map(point => [point.date, point.runs]));
|
|
24
|
+
return props.data.midscene[mode.value].map(point => ({ date: point.date, midscene: point.runs, appium: appium.get(point.date) || 0 }));
|
|
25
|
+
});
|
|
26
|
+
const chartDescription = computed(() => props.data.days ? `所选最近 ${props.data.days} 天,按${mode.value === 'daily' ? '日' : '月'}汇总` : mode.value === 'daily' ? '最近 30 天,按日汇总' : '最近 12 个月,按月汇总');
|
|
27
|
+
const maxY = computed(() => Math.max(4, Math.ceil(Math.max(0, ...points.value.flatMap(point => [shown.value.midscene ? point.midscene : 0, shown.value.appium ? point.appium : 0])) / 4) * 4));
|
|
28
|
+
const x = (index: number) => 52 + index * 920 / Math.max(1, points.value.length - 1);
|
|
29
|
+
const y = (value: number) => 242 - value / maxY.value * 210;
|
|
30
|
+
function line(engine: 'midscene' | 'appium') {
|
|
31
|
+
return points.value.map((point, index) => index === 0 ? `M ${x(index)} ${y(point[engine])}` : `C ${(x(index - 1) + x(index)) / 2} ${y(points.value[index - 1][engine])}, ${(x(index - 1) + x(index)) / 2} ${y(point[engine])}, ${x(index)} ${y(point[engine])}`).join(' ');
|
|
32
|
+
}
|
|
33
|
+
const labelIndices = computed(() => points.value.map((_, i) => i).filter(i => i % Math.max(1, Math.ceil((points.value.length - 1) / 10)) === 0 || i === points.value.length - 1));
|
|
34
|
+
const selected = computed(() => selectedPoint.value === null ? null : points.value[selectedPoint.value]);
|
|
35
|
+
watch(points, () => { selectedPoint.value = null; });
|
|
36
|
+
function navigatePoint(event: KeyboardEvent) {
|
|
37
|
+
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
|
38
|
+
event.preventDefault();
|
|
39
|
+
selectedPoint.value = event.key === 'Home' ? 0 : event.key === 'End' ? points.value.length - 1 : Math.max(0, Math.min(points.value.length - 1, (selectedPoint.value ?? -1) + (event.key === 'ArrowRight' ? 1 : -1)));
|
|
40
|
+
}
|
|
41
|
+
const distributions = computed(() => [
|
|
42
|
+
{ title: '引擎执行占比', center: '总执行次数', entries: [
|
|
43
|
+
{ name: 'Midscene', value: props.data.midscene.runs, color: engineColors.midscene },
|
|
44
|
+
{ name: 'Appium', value: props.data.appium.runs, color: engineColors.appium },
|
|
45
|
+
] },
|
|
46
|
+
{ title: '测试结果分布', center: '总执行次数', entries: [
|
|
47
|
+
{ name: '通过', value: total.value.passed, color: '#13b8a6' },
|
|
48
|
+
{ name: '失败', value: total.value.failed, color: '#ed6a5e' },
|
|
49
|
+
{ name: '已停止', value: total.value.stopped, color: '#a3a8b3' },
|
|
50
|
+
{ name: '运行中', value: total.value.running, color: '#f0b44b' },
|
|
51
|
+
] },
|
|
52
|
+
]);
|
|
53
|
+
const failures = computed(() => [
|
|
54
|
+
...props.data.midscene.rows.map(row => ({ ...row, engine: 'Midscene' })),
|
|
55
|
+
...props.data.appium.rows.map(row => ({ ...row, engine: 'Appium' })),
|
|
56
|
+
].filter(row => row.failed > 0).sort((a, b) => b.failed - a.failed).slice(0, 5));
|
|
57
|
+
function ringStyle(entries: { value: number; color: string }[]) {
|
|
58
|
+
if (!total.value.runs) return { background: 'var(--ui-bg)' };
|
|
59
|
+
let angle = 0;
|
|
60
|
+
return { background: `conic-gradient(${entries.map(entry => {
|
|
61
|
+
const start = angle;
|
|
62
|
+
angle += entry.value / total.value.runs * 360;
|
|
63
|
+
return `${entry.color} ${start}deg ${angle}deg`;
|
|
64
|
+
}).join(', ')})` };
|
|
65
|
+
}
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<template>
|
|
69
|
+
<section class="analysis-charts" aria-label="测试数据图表">
|
|
70
|
+
<div class="analysis-metrics">
|
|
71
|
+
<article v-for="card in cards" :key="card.name" class="analysis-chart-card metric-card">
|
|
72
|
+
<h2>{{ card.name }}</h2>
|
|
73
|
+
<div class="metric-card__value"><strong>{{ card.value }}</strong><el-icon :style="{ color: card.color, backgroundColor: `${card.color}16` }"><component :is="card.icon" /></el-icon></div>
|
|
74
|
+
<p>{{ card.note }}</p>
|
|
75
|
+
</article>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<section class="analysis-chart-card trend-card" aria-label="测试执行趋势">
|
|
79
|
+
<div class="chart-heading">
|
|
80
|
+
<div class="trend-tabs" role="group" aria-label="趋势图时间粒度">
|
|
81
|
+
<button :class="{ active: mode === 'daily' }" :aria-pressed="mode === 'daily'" @click="mode = 'daily'">执行趋势</button>
|
|
82
|
+
<button :class="{ active: mode === 'monthly' }" :aria-pressed="mode === 'monthly'" @click="mode = 'monthly'">月执行量</button>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="trend-legend">
|
|
85
|
+
<button v-for="engine in (['midscene', 'appium'] as const)" :key="engine" :aria-pressed="shown[engine]" :class="{ muted: !shown[engine] }" @click="shown[engine] = !shown[engine]">
|
|
86
|
+
<i :style="{ background: engineColors[engine] }" />{{ engine === 'midscene' ? 'Midscene' : 'Appium' }}
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="chart-caption"><span>{{ chartDescription }} · 执行次数</span><span role="status">{{ selected ? `${selected.date} · Midscene ${selected.midscene} 次 · Appium ${selected.appium} 次` : '悬停查看数据,聚焦图表后可用左右方向键切换' }}</span></div>
|
|
91
|
+
<div class="trend-chart-scroll">
|
|
92
|
+
<svg class="trend-chart" viewBox="0 0 1000 284" role="img" aria-label="Midscene 与 Appium 执行趋势,左右方向键查看日期数据" tabindex="0" @keydown="navigatePoint">
|
|
93
|
+
<defs>
|
|
94
|
+
<linearGradient id="midscene-trend-fill" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#4ea9ee" stop-opacity=".45" /><stop offset="100%" stop-color="#4ea9ee" stop-opacity=".05" /></linearGradient>
|
|
95
|
+
<linearGradient id="appium-trend-fill" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#13b8a6" stop-opacity=".4" /><stop offset="100%" stop-color="#13b8a6" stop-opacity=".05" /></linearGradient>
|
|
96
|
+
</defs>
|
|
97
|
+
<g v-for="tick in [0, 1, 2, 3, 4]" :key="tick">
|
|
98
|
+
<rect v-if="tick < 4 && tick % 2 === 0" x="52" :y="y((tick + 1) * maxY / 4)" width="920" height="52.5" fill="var(--ui-bg-soft)" />
|
|
99
|
+
<line x1="52" x2="972" :y1="y(tick * maxY / 4)" :y2="y(tick * maxY / 4)" stroke="var(--ui-border)" />
|
|
100
|
+
<text x="42" :y="y(tick * maxY / 4) + 4" text-anchor="end">{{ tick * maxY / 4 }}</text>
|
|
101
|
+
</g>
|
|
102
|
+
<g v-for="index in labelIndices" :key="index">
|
|
103
|
+
<line :x1="x(index)" :x2="x(index)" y1="32" y2="242" stroke="var(--ui-border)" stroke-dasharray="3 4" />
|
|
104
|
+
<text :x="x(index)" y="267" text-anchor="middle">{{ points[index].date.slice(mode === 'daily' ? 5 : 2) }}</text>
|
|
105
|
+
</g>
|
|
106
|
+
<template v-for="engine in (['midscene', 'appium'] as const)" :key="engine">
|
|
107
|
+
<g v-if="shown[engine] && points.length">
|
|
108
|
+
<path :d="`${line(engine)} L ${x(points.length - 1)} 242 L 52 242 Z`" :fill="`url(#${engine}-trend-fill)`" />
|
|
109
|
+
<path :d="line(engine)" fill="none" :stroke="engineColors[engine]" stroke-width="2.5" />
|
|
110
|
+
<circle v-for="(point, index) in points" :key="point.date" :cx="x(index)" :cy="y(point[engine])" :r="selectedPoint === index ? 4 : 2.5" fill="white" :stroke="engineColors[engine]" stroke-width="1.5" />
|
|
111
|
+
</g>
|
|
112
|
+
</template>
|
|
113
|
+
<line v-if="selectedPoint !== null" :x1="x(selectedPoint)" :x2="x(selectedPoint)" y1="32" y2="242" stroke="var(--ui-text-muted)" stroke-dasharray="4 4" />
|
|
114
|
+
<rect v-for="(point, index) in points" :key="point.date" :x="Math.max(40, x(index) - 460 / Math.max(1, points.length - 1))" y="24" :width="920 / Math.max(1, points.length - 1)" height="224" fill="transparent" @mouseenter="selectedPoint = index">
|
|
115
|
+
<title>{{ point.date }}:Midscene {{ point.midscene }} 次,Appium {{ point.appium }} 次</title>
|
|
116
|
+
</rect>
|
|
117
|
+
<text v-if="!points.some(point => point.midscene || point.appium)" x="512" y="126" text-anchor="middle">此时间范围暂无执行记录</text>
|
|
118
|
+
</svg>
|
|
119
|
+
</div>
|
|
120
|
+
</section>
|
|
121
|
+
|
|
122
|
+
<div class="analysis-distributions">
|
|
123
|
+
<section v-for="chart in distributions" :key="chart.title" class="analysis-chart-card distribution-card" :aria-label="chart.title">
|
|
124
|
+
<h2>{{ chart.title }}</h2>
|
|
125
|
+
<div class="distribution-card__body">
|
|
126
|
+
<div class="donut" :style="ringStyle(chart.entries)" role="img" :aria-label="chart.entries.map(entry => `${entry.name} ${entry.value} 次`).join(',')">
|
|
127
|
+
<div class="donut__center"><strong>{{ total.runs }}</strong><span>{{ total.runs ? chart.center : '暂无记录' }}</span></div>
|
|
128
|
+
</div>
|
|
129
|
+
<ul class="distribution-legend"><li v-for="entry in chart.entries" :key="entry.name"><span><i :style="{ background: entry.color }" />{{ entry.name }}</span><strong>{{ entry.value }}</strong><small>{{ total.runs ? `${(entry.value / total.runs * 100).toFixed(1)}%` : '—' }}</small></li></ul>
|
|
130
|
+
</div>
|
|
131
|
+
</section>
|
|
132
|
+
<section class="analysis-chart-card failures-card" aria-label="失败次数排行">
|
|
133
|
+
<h2>失败次数排行 <small>TOP 5</small></h2>
|
|
134
|
+
<div v-if="!failures.length" class="chart-empty">所选范围暂无失败记录</div>
|
|
135
|
+
<ol v-else class="failure-bars"><li v-for="(row, index) in failures" :key="`${row.engine}-${row.id}`">
|
|
136
|
+
<div><span :title="row.name">{{ index + 1 }}. {{ row.name }}</span><strong>{{ row.failed }} 次</strong></div>
|
|
137
|
+
<div class="failure-bars__track"><span :style="{ width: `${row.failed / failures[0].failed * 100}%` }" /></div>
|
|
138
|
+
<small>{{ row.engine }}</small>
|
|
139
|
+
</li></ol>
|
|
140
|
+
</section>
|
|
141
|
+
</div>
|
|
142
|
+
</section>
|
|
143
|
+
</template>
|
|
144
|
+
|
|
145
|
+
<style scoped>
|
|
146
|
+
.analysis-charts { display: flex; flex-direction: column; gap: 20px; min-width: 0; }
|
|
147
|
+
.analysis-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; }
|
|
148
|
+
.analysis-chart-card { padding: 22px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); min-width: 0; }
|
|
149
|
+
h2 { margin: 0; font-size: 16px; font-weight: 600; }
|
|
150
|
+
.metric-card__value { display: flex; justify-content: space-between; align-items: center; margin: 24px 0; }
|
|
151
|
+
.metric-card__value > strong { font-size: 30px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
152
|
+
.metric-card__value .el-icon { width: 44px; height: 44px; border-radius: 12px; font-size: 24px; }
|
|
153
|
+
.metric-card p { margin: 0; color: var(--ui-text-secondary); font-size: 12px; }
|
|
154
|
+
.chart-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
|
155
|
+
.trend-tabs { display: flex; padding: 4px; border-radius: 6px; background: var(--ui-bg); }
|
|
156
|
+
.trend-tabs button, .trend-legend button { cursor: pointer; border: 0; background: transparent; color: var(--ui-text-secondary); font-size: 13px; padding: 6px 12px; }
|
|
157
|
+
.trend-tabs button.active { background: var(--ui-surface); color: var(--ui-text); border-radius: 4px; box-shadow: 0 1px 3px #00000012; }
|
|
158
|
+
.trend-legend { display: flex; gap: 8px; }
|
|
159
|
+
.trend-legend button { display: flex; align-items: center; gap: 8px; }
|
|
160
|
+
.trend-legend .muted { opacity: .4; }
|
|
161
|
+
i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; flex: none; }
|
|
162
|
+
.chart-caption { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; color: var(--ui-text-secondary); font-size: 12px; margin-top: 18px; min-height: 18px; }
|
|
163
|
+
.trend-chart-scroll { overflow-x: auto; }
|
|
164
|
+
.trend-chart { display: block; width: 100%; min-width: 600px; margin-top: 8px; }
|
|
165
|
+
.trend-chart text { fill: var(--ui-text-secondary); font-size: 12px; }
|
|
166
|
+
.trend-chart:focus-visible { outline: 2px solid var(--ui-primary); outline-offset: -2px; border-radius: 4px; }
|
|
167
|
+
.analysis-distributions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; }
|
|
168
|
+
.distribution-card__body { display: flex; align-items: center; flex-direction: column; gap: 22px; padding-top: 24px; }
|
|
169
|
+
.donut { display: grid; place-items: center; width: 176px; aspect-ratio: 1; border-radius: 50%; }
|
|
170
|
+
.donut__center { width: 124px; height: 124px; background: var(--ui-surface); border-radius: 50%; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 4px; }
|
|
171
|
+
.donut__center strong { font-size: 27px; font-weight: 600; }
|
|
172
|
+
.donut__center span { font-size: 12px; color: var(--ui-text-secondary); }
|
|
173
|
+
.distribution-legend { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; width: 100%; max-width: 260px; }
|
|
174
|
+
.distribution-legend li { display: grid; grid-template-columns: 1fr 36px 54px; gap: 8px; align-items: center; font-size: 13px; }
|
|
175
|
+
.distribution-legend li > span { display: flex; align-items: center; gap: 8px; }
|
|
176
|
+
.distribution-legend strong, .distribution-legend small { text-align: right; font-variant-numeric: tabular-nums; }
|
|
177
|
+
.distribution-legend small, .failure-bars small { color: var(--ui-text-secondary); }
|
|
178
|
+
.failures-card h2 small { margin-left: 6px; font-size: 11px; font-weight: 400; color: var(--ui-text-muted); }
|
|
179
|
+
.failure-bars { padding: 0; margin: 26px 0 0; list-style: none; display: grid; gap: 16px; }
|
|
180
|
+
.failure-bars li > div:first-child { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; }
|
|
181
|
+
.failure-bars li > div:first-child > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
182
|
+
.failure-bars strong { font-size: 12px; white-space: nowrap; font-weight: 500; }
|
|
183
|
+
.failure-bars__track { height: 6px; border-radius: 3px; background: var(--ui-bg); margin: 7px 0 3px; overflow: hidden; }
|
|
184
|
+
.failure-bars__track > span { display: block; height: 100%; background: #4ea9ee; border-radius: inherit; }
|
|
185
|
+
.failure-bars small { font-size: 11px; }
|
|
186
|
+
.chart-empty { display: grid; place-items: center; min-height: 240px; color: var(--ui-text-muted); font-size: 13px; }
|
|
187
|
+
@media (max-width: 1200px) { .analysis-metrics { gap: 12px; } .analysis-chart-card { padding: 18px; } .analysis-distributions { gap: 12px; } }
|
|
188
|
+
@media (max-width: 1000px) { .analysis-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .analysis-distributions { grid-template-columns: 1fr; } .distribution-card__body { flex-direction: row; justify-content: space-evenly; flex-wrap: wrap; } }
|
|
189
|
+
</style>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, watch } from 'vue';
|
|
3
|
+
import type { AnalysisSummary, AnalysisRow } from '../../analytics/summary';
|
|
4
|
+
const props = defineProps<{ title: string; summary: AnalysisSummary }>();
|
|
5
|
+
const search = ref('');
|
|
6
|
+
const page = ref(1);
|
|
7
|
+
const sort = ref<{ prop: keyof AnalysisRow; order: string | null }>({ prop: 'lastRunAt', order: 'descending' });
|
|
8
|
+
const filteredRows = computed(() => {
|
|
9
|
+
const rows = props.summary.rows.filter(row => row.name.toLowerCase().includes(search.value.trim().toLowerCase()));
|
|
10
|
+
const { prop, order } = sort.value;
|
|
11
|
+
if (order) rows.sort((a, b) => {
|
|
12
|
+
const left = a[prop], right = b[prop];
|
|
13
|
+
if (left == null) return right == null ? 0 : 1;
|
|
14
|
+
if (right == null) return -1;
|
|
15
|
+
const value = typeof left === 'number' && typeof right === 'number' ? left - right : String(left).localeCompare(String(right));
|
|
16
|
+
return order === 'ascending' ? value : -value;
|
|
17
|
+
});
|
|
18
|
+
return rows;
|
|
19
|
+
});
|
|
20
|
+
const rows = computed(() => filteredRows.value.slice((page.value - 1) * 10, page.value * 10));
|
|
21
|
+
watch([search, () => props.summary, sort], () => { page.value = 1; });
|
|
22
|
+
const rate = (value: number | null) => value === null ? '—' : `${(value * 100).toFixed(1)}%`;
|
|
23
|
+
const duration = (value: number | null) => value === null ? '—' : value < 1000 ? `${Math.round(value)} 毫秒` : `${(value / 1000).toFixed(1)} 秒`;
|
|
24
|
+
const statusText = { passed: '通过', failed: '失败', stopped: '已停止', running: '运行中' };
|
|
25
|
+
const statusType = { passed: 'success', failed: 'danger', stopped: 'info', running: 'warning' } as const;
|
|
26
|
+
const date = (value: string) => value && Number.isFinite(Date.parse(value)) ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—';
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<template>
|
|
30
|
+
<section class="report-analysis" :aria-label="`${title} 测试报告分析`">
|
|
31
|
+
<div class="report-analysis__heading">
|
|
32
|
+
<div><h2>{{ title }} 测试报告</h2><p>现有脚本 {{ summary.scripts }} 个 · 其中 {{ summary.testedScripts }} 个在所选范围内有执行记录</p></div>
|
|
33
|
+
<el-input v-model="search" clearable placeholder="搜索脚本名称" :aria-label="`搜索 ${title} 脚本`" class="report-analysis__search" />
|
|
34
|
+
</div>
|
|
35
|
+
<div class="report-analysis__metrics">
|
|
36
|
+
<div><span>执行次数</span><strong>{{ summary.runs }}</strong></div>
|
|
37
|
+
<div><span>通过率</span><strong class="metric-primary">{{ rate(summary.passRate) }}</strong></div>
|
|
38
|
+
<div><span>通过 / 失败</span><strong><span class="metric-success">{{ summary.passed }}</span> / <span :class="{ 'metric-danger': summary.failed > 0 }">{{ summary.failed }}</span></strong></div>
|
|
39
|
+
<div><span>停止 / 运行中</span><strong>{{ summary.stopped }} / {{ summary.running }}</strong></div>
|
|
40
|
+
</div>
|
|
41
|
+
<el-table :data="rows" row-key="id" stripe :default-sort="{ prop: 'lastRunAt', order: 'descending' }"
|
|
42
|
+
:empty-text="search ? '没有匹配的脚本' : '暂无脚本或测试记录'" @sort-change="sort = $event">
|
|
43
|
+
<el-table-column prop="name" label="脚本名称" min-width="200" sortable="custom">
|
|
44
|
+
<template #default="{ row }"><span>{{ row.name }}</span><el-tag v-if="row.archived" size="small" type="info" class="report-analysis__historical">历史脚本</el-tag></template>
|
|
45
|
+
</el-table-column>
|
|
46
|
+
<el-table-column prop="runs" label="执行次数" width="110" sortable="custom" />
|
|
47
|
+
<el-table-column prop="passed" label="通过" width="85" sortable="custom" />
|
|
48
|
+
<el-table-column prop="failed" label="失败" width="85" sortable="custom" />
|
|
49
|
+
<el-table-column label="停止 / 运行中" width="120"><template #default="{ row }">{{ row.stopped }} / {{ row.running }}</template></el-table-column>
|
|
50
|
+
<el-table-column prop="passRate" label="通过率" width="105" sortable="custom"><template #default="{ row }">{{ rate(row.passRate) }}</template></el-table-column>
|
|
51
|
+
<el-table-column prop="averageDurationMs" label="平均耗时" width="125" sortable="custom"><template #default="{ row }">{{ duration(row.averageDurationMs) }}</template></el-table-column>
|
|
52
|
+
<el-table-column label="最近状态" width="105"><template #default="{ row }"><el-tag v-if="row.lastStatus" :type="statusType[row.lastStatus as keyof typeof statusType]" effect="light">{{ statusText[row.lastStatus as keyof typeof statusText] }}</el-tag><span v-else class="report-analysis__muted">未执行</span></template></el-table-column>
|
|
53
|
+
<el-table-column prop="lastRunAt" label="最近执行" min-width="180" sortable="custom"><template #default="{ row }">{{ date(row.lastRunAt) }}</template></el-table-column>
|
|
54
|
+
</el-table>
|
|
55
|
+
<el-pagination v-if="filteredRows.length > 10" v-model:current-page="page" :total="filteredRows.length" :page-size="10" layout="total, prev, pager, next" class="report-analysis__pagination" />
|
|
56
|
+
</section>
|
|
57
|
+
</template>
|
|
58
|
+
|
|
59
|
+
<style scoped>
|
|
60
|
+
.report-analysis { padding: 20px; background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: var(--ui-radius); min-width: 0; }
|
|
61
|
+
.report-analysis__heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
|
62
|
+
h2 { margin: 0; font-size: 17px; font-weight: 600; }
|
|
63
|
+
p { margin: 8px 0 0; color: var(--ui-text-secondary); font-size: 12px; }
|
|
64
|
+
.report-analysis__search { width: 220px; }
|
|
65
|
+
.report-analysis__metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; padding: 24px 0; }
|
|
66
|
+
.report-analysis__metrics > div { border-right: 1px solid var(--ui-border); padding-left: 16px; }
|
|
67
|
+
.report-analysis__metrics > div:first-child { padding-left: 0; }
|
|
68
|
+
.report-analysis__metrics > div:last-child { border-right: 0; }
|
|
69
|
+
.report-analysis__metrics > div > span { display: block; color: var(--ui-text-secondary); font-size: 13px; margin-bottom: 8px; }
|
|
70
|
+
.report-analysis__metrics strong { font-size: 27px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
71
|
+
.metric-primary { color: var(--ui-primary); }
|
|
72
|
+
.metric-success { color: var(--ui-success); }
|
|
73
|
+
.metric-danger { color: var(--ui-danger); }
|
|
74
|
+
.report-analysis__historical { margin-left: 8px; }
|
|
75
|
+
.report-analysis__muted { color: var(--ui-text-muted); }
|
|
76
|
+
.report-analysis__pagination { margin-top: 16px; justify-content: flex-end; }
|
|
77
|
+
@media (max-width: 800px) { .report-analysis__metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .report-analysis__metrics > div { border-right: 0; padding-left: 0; } }
|
|
78
|
+
</style>
|