android-midscene-automation 0.2.1 → 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.
Files changed (43) hide show
  1. package/CHANGELOG.md +9 -1
  2. package/README.md +9 -1
  3. package/package.json +1 -1
  4. package/server/analytics.test.ts +46 -0
  5. package/server/analytics.ts +24 -0
  6. package/server/api-safety-analytics.test.ts +57 -0
  7. package/server/appium-auto-save.test.ts +6 -0
  8. package/server/appium-recorder/appium-version.test.ts +55 -0
  9. package/server/appium-recorder/managed-appium.ts +37 -1
  10. package/server/appium-recorder/routes.ts +2 -12
  11. package/server/appium-recorder/run-history.ts +11 -0
  12. package/server/config-access.test.ts +27 -0
  13. package/server/config-access.ts +27 -0
  14. package/server/http-api.ts +63 -54
  15. package/server/operations/repository.ts +8 -0
  16. package/server/remote-agents/routes.ts +2 -12
  17. package/server/request-body.test.ts +40 -0
  18. package/server/request-body.ts +48 -0
  19. package/src/App.vue +96 -758
  20. package/src/analytics/summary.ts +75 -0
  21. package/src/api.ts +11 -0
  22. package/src/appium-recorder/AppiumPage.vue +4 -1
  23. package/src/components/AppSidebar.vue +31 -0
  24. package/src/components/analytics/AnalysisCharts.vue +189 -0
  25. package/src/components/analytics/ReportAnalysis.vue +78 -0
  26. package/src/components/config/AppiumConfigPanel.vue +4 -1
  27. package/src/components/config/AppiumModelSettings.vue +1 -1
  28. package/src/components/config/BaseConfigPanel.vue +6 -2
  29. package/src/components/config/MidsceneConfigPanel.vue +4 -5
  30. package/src/components/help/HelpCenter.vue +185 -0
  31. package/src/composables/use-device-preview.ts +279 -0
  32. package/src/composables/use-initial-resource.ts +17 -0
  33. package/src/composables/use-script-execution.ts +277 -0
  34. package/src/config/credentials.ts +2 -0
  35. package/src/config/use-appium-auto-save.ts +22 -11
  36. package/src/config/use-model-config.ts +157 -0
  37. package/src/main.ts +2 -2
  38. package/src/pages/AnalysisPage.vue +60 -0
  39. package/src/pages/ConfigPage.vue +36 -5
  40. package/src/style.css +711 -123
  41. package/src/types.ts +7 -1
  42. package/src/components/help/HelpCenterDialog.vue +0 -160
  43. 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 (!props.active || !hasUnsavedChanges.value) return;
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
@@ -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>
@@ -12,8 +12,10 @@ defineProps<{
12
12
  testStatus?: string;
13
13
  promptOptimizerTestStatus?: string;
14
14
  saveStatus?: string;
15
+ saveFailed?: boolean;
15
16
  }>();
16
- defineEmits<{ testModel: []; testPromptOptimizerModel: [] }>();
17
+ defineEmits<{
18
+ retrySave: []; testModel: []; testPromptOptimizerModel: [] }>();
17
19
  </script>
18
20
 
19
21
  <template>
@@ -22,6 +24,7 @@ defineEmits<{ testModel: []; testPromptOptimizerModel: [] }>();
22
24
  <header class="appium-section-heading">
23
25
  <div><h2 id="appium-models-title">模型服务</h2><p>不同任务使用独立模型,配置修改需测试通过后保存。</p></div>
24
26
  <span v-if="saveStatus" class="config-save-status" role="status">{{ saveStatus }}</span>
27
+ <el-button v-if="saveFailed" link type="primary" @click="$emit('retrySave')">重试保存</el-button>
25
28
  </header>
26
29
  <div class="appium-model-grid">
27
30
  <AppiumModelSettings v-model="configForm.appium.model" title="AI 识别模型" badge="视觉"
@@ -27,7 +27,7 @@ defineEmits<{ test: [] }>();
27
27
  <p class="appium-model-description">{{ description }}</p>
28
28
  <el-form label-position="top" :disabled="!!testingModelKey">
29
29
  <el-form-item label="Base URL"><el-input v-model="model.baseUrl" placeholder="兼容 OpenAI 的 API 地址" /></el-form-item>
30
- <el-form-item label="API Key"><el-input v-model="model.apiKey" type="password" show-password /></el-form-item>
30
+ <el-form-item label="API Key"><el-input v-model="model.apiKey" type="password" placeholder="已保存的 Key 显示为掩码,可输入新 Key 替换" show-password /></el-form-item>
31
31
  <el-form-item label="Model Name"><el-input v-model="model.name" :placeholder="namePlaceholder" /></el-form-item>
32
32
  <el-alert v-if="testStatus" class="appium-model-status" :title="testStatus"
33
33
  :type="testStatus.startsWith('测试通过') ? 'success' : 'warning'" :closable="false" show-icon />
@@ -31,7 +31,7 @@ defineEmits<{
31
31
  </div>
32
32
  </template>
33
33
 
34
- <el-form label-position="top">
34
+ <el-form label-position="top" class="runtime-config-form">
35
35
  <el-form-item label="Android SDK 路径">
36
36
  <el-input
37
37
  v-model="configForm.runtime.androidSdkPath"
@@ -69,7 +69,7 @@ defineEmits<{
69
69
  </el-form-item>
70
70
  <div class="app-preset-actions">
71
71
  <el-button v-if="appPresetForm.id" :disabled="isSavingAppPreset" @click="$emit('cancelAppPresetEdit')">取消编辑</el-button>
72
- <el-button native-type="submit" :loading="isSavingAppPreset">{{ appPresetForm.id ? '保存修改' : '添加' }}</el-button>
72
+ <el-button type="primary" native-type="submit" :loading="isSavingAppPreset">{{ appPresetForm.id ? '保存修改' : '添加' }}</el-button>
73
73
  </div>
74
74
  </el-form>
75
75
 
@@ -92,6 +92,10 @@ defineEmits<{
92
92
  </template>
93
93
 
94
94
  <style scoped>
95
+ .runtime-config-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
96
+ .runtime-config-form :deep(.el-form-item) { margin-bottom: 0; }
97
+ @media (max-width: 1000px) { .runtime-config-form { grid-template-columns: minmax(0, 1fr); } }
98
+
95
99
  .app-preset-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 24px; align-items: start; }
96
100
  .app-preset-actions { display: flex; justify-content: flex-end; margin-top: 4px; }
97
101
  .app-preset-layout .app-preset-list { margin-top: 0; padding-left: 24px; border-left: 1px solid var(--ui-border); }
@@ -89,7 +89,7 @@ const openModelConfigGuide = () => {
89
89
  <section>
90
90
  <div class="panel-header panel-header--sub">
91
91
  <span>Midscene 模型</span>
92
- <el-button
92
+ <el-button type="primary" plain
93
93
  :loading="testingModelKey === 'midscene'"
94
94
  :disabled="!!testingModelKey"
95
95
  @click="$emit('testModel', 'midscene')"
@@ -118,7 +118,7 @@ const openModelConfigGuide = () => {
118
118
  />
119
119
  </el-form-item>
120
120
  <el-form-item label="API Key">
121
- <el-input v-model="configForm.midscene.model.apiKey" show-password />
121
+ <el-input v-model="configForm.midscene.model.apiKey" placeholder="已保存的 Key 显示为掩码,可输入新 Key 替换" show-password />
122
122
  </el-form-item>
123
123
  </template>
124
124
  <template v-else>
@@ -168,7 +168,7 @@ const openModelConfigGuide = () => {
168
168
  <section>
169
169
  <div class="panel-header panel-header--sub">
170
170
  <span>脚本优化模型</span>
171
- <el-button
171
+ <el-button type="primary" plain
172
172
  :loading="testingModelKey === 'scriptOptimizer'"
173
173
  :disabled="!!testingModelKey"
174
174
  @click="$emit('testModel', 'scriptOptimizer')"
@@ -181,7 +181,7 @@ const openModelConfigGuide = () => {
181
181
  <el-input v-model="configForm.scriptOptimizer.model.baseUrl" />
182
182
  </el-form-item>
183
183
  <el-form-item label="API Key">
184
- <el-input v-model="configForm.scriptOptimizer.model.apiKey" show-password />
184
+ <el-input v-model="configForm.scriptOptimizer.model.apiKey" placeholder="已保存的 Key 显示为掩码,可输入新 Key 替换" show-password />
185
185
  </el-form-item>
186
186
  <el-form-item label="Model Name">
187
187
  <el-input v-model="configForm.scriptOptimizer.model.name" />
@@ -197,6 +197,5 @@ const openModelConfigGuide = () => {
197
197
  </template>
198
198
 
199
199
  <style scoped>
200
- .config-midscene-card :deep(.el-card__body) { background: var(--ui-bg-soft, #f8f9fb); }
201
200
  .config-model-input { width: 100%; }
202
201
  </style>