android-midscene-automation 0.1.0
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/README.md +160 -0
- package/bin/android-midscene-automation.js +27 -0
- package/index.html +12 -0
- package/package.json +49 -0
- package/remote-agent/index.ts +206 -0
- package/server/appium-recorder/appium-runner.ts +427 -0
- package/server/appium-recorder/repository.ts +228 -0
- package/server/appium-recorder/routes.ts +219 -0
- package/server/config-store.ts +167 -0
- package/server/config.ts +130 -0
- package/server/device-locks/repository.ts +147 -0
- package/server/device-locks/service.ts +72 -0
- package/server/device-locks/types.ts +22 -0
- package/server/device-sessions/repository.ts +169 -0
- package/server/device-sessions/service.ts +59 -0
- package/server/device-sessions/types.ts +24 -0
- package/server/http-api.ts +1389 -0
- package/server/model-call-usage-importer.ts +108 -0
- package/server/model-tester.ts +104 -0
- package/server/model-usage-repository.ts +131 -0
- package/server/operations/repository.ts +218 -0
- package/server/operations/service.ts +84 -0
- package/server/operations/types.ts +27 -0
- package/server/paths.ts +27 -0
- package/server/remote-agents/protocol.ts +38 -0
- package/server/remote-agents/registry.ts +136 -0
- package/server/remote-agents/routes.ts +89 -0
- package/server/script-agent.ts +284 -0
- package/server/script-db.ts +281 -0
- package/server/script-runner.ts +551 -0
- package/server/storage/sqlite.ts +49 -0
- package/server/test-case-import/formatter.ts +28 -0
- package/server/test-case-import/parsers/excel.ts +69 -0
- package/server/test-case-import/parsers/txt.ts +11 -0
- package/server/test-case-import/parsers/word.ts +9 -0
- package/server/test-case-import/service.ts +58 -0
- package/server/test-case-import/text-normalizer.ts +98 -0
- package/server/test-case-import/types.ts +24 -0
- package/server/test-case-import/validator.ts +34 -0
- package/src/App.vue +1450 -0
- package/src/api.ts +290 -0
- package/src/appium-recorder/AppiumPage.vue +894 -0
- package/src/appium-recorder/api.ts +64 -0
- package/src/appium-recorder/components/ComponentTree.vue +44 -0
- package/src/appium-recorder/components/NodeDetail.vue +152 -0
- package/src/appium-recorder/components/RecordedSteps.vue +79 -0
- package/src/appium-recorder/tree.ts +129 -0
- package/src/appium-recorder/types.ts +88 -0
- package/src/assets/device-actions/back.svg +5 -0
- package/src/assets/device-actions/home.svg +3 -0
- package/src/assets/device-actions/power.svg +5 -0
- package/src/assets/device-actions/tasks.svg +3 -0
- package/src/assets/device-actions/volume-down.svg +3 -0
- package/src/assets/device-actions/volume-up.svg +3 -0
- package/src/components/config/ModelUsageChart.vue +188 -0
- package/src/components/device/DevicePreviewPanel.vue +266 -0
- package/src/components/generator/GeneratedCodePanel.vue +70 -0
- package/src/components/generator/TestCaseFileUpload.vue +97 -0
- package/src/config/midscene-model-presets.ts +75 -0
- package/src/config/prompt-example.ts +6 -0
- package/src/main.ts +7 -0
- package/src/pages/AiGeneratorPage.vue +90 -0
- package/src/pages/AutomationPage.vue +161 -0
- package/src/pages/ConfigPage.vue +273 -0
- package/src/pages/GeneratorPage.vue +97 -0
- package/src/pages/ManualStepsPage.vue +179 -0
- package/src/script-generator/codegen.ts +126 -0
- package/src/script-generator/index.ts +4 -0
- package/src/script-generator/presets.ts +37 -0
- package/src/script-generator/step-options.ts +52 -0
- package/src/script-generator/types.ts +27 -0
- package/src/style.css +1983 -0
- package/src/types.ts +157 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.app.json +8 -0
- package/tsconfig.json +11 -0
- package/tsconfig.node.json +16 -0
- package/vite.config.ts +28 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import type { ModelUsageRecord } from '../../types';
|
|
4
|
+
|
|
5
|
+
const CHART_WIDTH = 720;
|
|
6
|
+
const CHART_HEIGHT = 180;
|
|
7
|
+
const CHART_PADDING = 28;
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
records: ModelUsageRecord[];
|
|
11
|
+
}>();
|
|
12
|
+
|
|
13
|
+
type ChartPoint = {
|
|
14
|
+
x: number;
|
|
15
|
+
y: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const recentRecords = computed(() => props.records.slice(-20));
|
|
19
|
+
const displayRecords = computed(() => recentRecords.value.slice().reverse());
|
|
20
|
+
const latestRecord = computed(() => recentRecords.value[recentRecords.value.length - 1]);
|
|
21
|
+
const tokenRecords = computed(() =>
|
|
22
|
+
recentRecords.value.filter((record) => typeof record.totalTokens === 'number'),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const maxDurationMs = computed(() =>
|
|
26
|
+
Math.max(1, ...recentRecords.value.map((record) => record.durationMs)),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const maxTotalTokens = computed(() =>
|
|
30
|
+
Math.max(
|
|
31
|
+
1,
|
|
32
|
+
...tokenRecords.value.flatMap((record) => [
|
|
33
|
+
record.promptTokens || 0,
|
|
34
|
+
record.completionTokens || 0,
|
|
35
|
+
record.totalTokens || 0,
|
|
36
|
+
]),
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const buildPoints = (records: ModelUsageRecord[], valueOf: (record: ModelUsageRecord) => number, maxValue: number) => {
|
|
41
|
+
const plotWidth = CHART_WIDTH - CHART_PADDING * 2;
|
|
42
|
+
const plotHeight = CHART_HEIGHT - CHART_PADDING * 2;
|
|
43
|
+
return records.map<ChartPoint>((record, index) => {
|
|
44
|
+
const count = Math.max(1, records.length - 1);
|
|
45
|
+
return {
|
|
46
|
+
x: CHART_PADDING + (plotWidth * index) / count,
|
|
47
|
+
y: CHART_HEIGHT - CHART_PADDING - (plotHeight * valueOf(record)) / maxValue,
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const durationPoints = computed(() =>
|
|
53
|
+
buildPoints(recentRecords.value, (record) => record.durationMs, maxDurationMs.value),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const promptTokenPoints = computed(() =>
|
|
57
|
+
buildPoints(tokenRecords.value, (record) => record.promptTokens || 0, maxTotalTokens.value),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const completionTokenPoints = computed(() =>
|
|
61
|
+
buildPoints(tokenRecords.value, (record) => record.completionTokens || 0, maxTotalTokens.value),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const toPolyline = (points: ChartPoint[]) =>
|
|
65
|
+
points.map((point) => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ');
|
|
66
|
+
|
|
67
|
+
const durationPolyline = computed(() => toPolyline(durationPoints.value));
|
|
68
|
+
const promptTokenPolyline = computed(() => toPolyline(promptTokenPoints.value));
|
|
69
|
+
const completionTokenPolyline = computed(() => toPolyline(completionTokenPoints.value));
|
|
70
|
+
|
|
71
|
+
const tokenLabel = (value?: number) => (typeof value === 'number' ? String(value) : '未返回');
|
|
72
|
+
|
|
73
|
+
const formatTime = (isoTime: string) =>
|
|
74
|
+
new Intl.DateTimeFormat('zh-CN', {
|
|
75
|
+
hour: '2-digit',
|
|
76
|
+
minute: '2-digit',
|
|
77
|
+
second: '2-digit',
|
|
78
|
+
}).format(new Date(isoTime));
|
|
79
|
+
</script>
|
|
80
|
+
|
|
81
|
+
<template>
|
|
82
|
+
<section class="model-usage-chart">
|
|
83
|
+
<div class="panel-header panel-header--sub">
|
|
84
|
+
<div class="panel-header__title">
|
|
85
|
+
<span>测试消耗统计</span>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<div v-if="recentRecords.length" class="model-usage-chart__body">
|
|
90
|
+
<div class="model-usage-chart__summary">
|
|
91
|
+
<span>最近 {{ recentRecords.length }} 次</span>
|
|
92
|
+
<span>最新耗时 {{ latestRecord?.durationMs || 0 }}ms</span>
|
|
93
|
+
<span>输入 {{ tokenLabel(latestRecord?.promptTokens) }}</span>
|
|
94
|
+
<span>输出 {{ tokenLabel(latestRecord?.completionTokens) }}</span>
|
|
95
|
+
<span>总计 {{ tokenLabel(latestRecord?.totalTokens) }}</span>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<svg
|
|
99
|
+
class="model-usage-chart__svg"
|
|
100
|
+
:viewBox="`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`"
|
|
101
|
+
role="img"
|
|
102
|
+
aria-label="测试模型消耗统计曲线"
|
|
103
|
+
>
|
|
104
|
+
<line
|
|
105
|
+
:x1="CHART_PADDING"
|
|
106
|
+
:y1="CHART_HEIGHT - CHART_PADDING"
|
|
107
|
+
:x2="CHART_WIDTH - CHART_PADDING"
|
|
108
|
+
:y2="CHART_HEIGHT - CHART_PADDING"
|
|
109
|
+
class="model-usage-chart__axis"
|
|
110
|
+
/>
|
|
111
|
+
<line
|
|
112
|
+
:x1="CHART_PADDING"
|
|
113
|
+
:y1="CHART_PADDING"
|
|
114
|
+
:x2="CHART_PADDING"
|
|
115
|
+
:y2="CHART_HEIGHT - CHART_PADDING"
|
|
116
|
+
class="model-usage-chart__axis"
|
|
117
|
+
/>
|
|
118
|
+
<polyline
|
|
119
|
+
v-if="promptTokenPolyline"
|
|
120
|
+
:points="promptTokenPolyline"
|
|
121
|
+
class="model-usage-chart__line model-usage-chart__line--prompt"
|
|
122
|
+
/>
|
|
123
|
+
<polyline
|
|
124
|
+
v-if="completionTokenPolyline"
|
|
125
|
+
:points="completionTokenPolyline"
|
|
126
|
+
class="model-usage-chart__line model-usage-chart__line--completion"
|
|
127
|
+
/>
|
|
128
|
+
<polyline
|
|
129
|
+
:points="durationPolyline"
|
|
130
|
+
class="model-usage-chart__line model-usage-chart__line--duration"
|
|
131
|
+
/>
|
|
132
|
+
<circle
|
|
133
|
+
v-for="point in promptTokenPoints"
|
|
134
|
+
:key="`prompt-${point.x}-${point.y}`"
|
|
135
|
+
:cx="point.x"
|
|
136
|
+
:cy="point.y"
|
|
137
|
+
r="3"
|
|
138
|
+
class="model-usage-chart__point model-usage-chart__point--prompt"
|
|
139
|
+
/>
|
|
140
|
+
<circle
|
|
141
|
+
v-for="point in completionTokenPoints"
|
|
142
|
+
:key="`completion-${point.x}-${point.y}`"
|
|
143
|
+
:cx="point.x"
|
|
144
|
+
:cy="point.y"
|
|
145
|
+
r="3"
|
|
146
|
+
class="model-usage-chart__point model-usage-chart__point--completion"
|
|
147
|
+
/>
|
|
148
|
+
<circle
|
|
149
|
+
v-for="point in durationPoints"
|
|
150
|
+
:key="`duration-${point.x}-${point.y}`"
|
|
151
|
+
:cx="point.x"
|
|
152
|
+
:cy="point.y"
|
|
153
|
+
r="3"
|
|
154
|
+
class="model-usage-chart__point model-usage-chart__point--duration"
|
|
155
|
+
/>
|
|
156
|
+
</svg>
|
|
157
|
+
|
|
158
|
+
<div class="model-usage-chart__legend">
|
|
159
|
+
<span><i class="model-usage-chart__swatch model-usage-chart__swatch--prompt"></i>输入 tokens</span>
|
|
160
|
+
<span><i class="model-usage-chart__swatch model-usage-chart__swatch--completion"></i>输出 tokens</span>
|
|
161
|
+
<span><i class="model-usage-chart__swatch model-usage-chart__swatch--duration"></i>耗时 ms</span>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<div class="model-usage-chart__records">
|
|
165
|
+
<div class="model-usage-chart__record model-usage-chart__record--head">
|
|
166
|
+
<span>时间</span>
|
|
167
|
+
<span>状态</span>
|
|
168
|
+
<span>模型</span>
|
|
169
|
+
<span>输入</span>
|
|
170
|
+
<span>输出</span>
|
|
171
|
+
<span>总计</span>
|
|
172
|
+
<span>耗时</span>
|
|
173
|
+
</div>
|
|
174
|
+
<div v-for="record in displayRecords" :key="record.id" class="model-usage-chart__record">
|
|
175
|
+
<span>{{ formatTime(record.createdAt) }}</span>
|
|
176
|
+
<span>{{ record.success ? '成功' : '失败' }}</span>
|
|
177
|
+
<span>{{ record.modelName }}</span>
|
|
178
|
+
<span>{{ tokenLabel(record.promptTokens) }}</span>
|
|
179
|
+
<span>{{ tokenLabel(record.completionTokens) }}</span>
|
|
180
|
+
<span>{{ tokenLabel(record.totalTokens) }}</span>
|
|
181
|
+
<span>{{ record.durationMs }}ms</span>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<el-empty v-else description="测试 AI 生成模型或生成脚本后显示消耗曲线" />
|
|
187
|
+
</section>
|
|
188
|
+
</template>
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, shallowRef } from 'vue';
|
|
3
|
+
import { Cpu, Refresh } from '@element-plus/icons-vue';
|
|
4
|
+
import type { AndroidDevice, DeviceAction } from '../../types';
|
|
5
|
+
|
|
6
|
+
type DeviceOverlayBounds = {
|
|
7
|
+
id: string;
|
|
8
|
+
left: number;
|
|
9
|
+
top: number;
|
|
10
|
+
right: number;
|
|
11
|
+
bottom: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
available: boolean;
|
|
16
|
+
devices: AndroidDevice[];
|
|
17
|
+
selectedDeviceId: string;
|
|
18
|
+
frameUrl?: string;
|
|
19
|
+
imageUrl?: string;
|
|
20
|
+
previewError?: string;
|
|
21
|
+
actions?: readonly DeviceAction[];
|
|
22
|
+
overlayBounds?: readonly DeviceOverlayBounds[];
|
|
23
|
+
selectedBounds?: DeviceOverlayBounds;
|
|
24
|
+
deviceWidth?: number;
|
|
25
|
+
deviceHeight?: number;
|
|
26
|
+
}>();
|
|
27
|
+
|
|
28
|
+
const emit = defineEmits<{
|
|
29
|
+
switchDevice: [deviceId: string];
|
|
30
|
+
triggerKey: [keyCode: number];
|
|
31
|
+
previewLoaded: [size: { width: number; height: number }];
|
|
32
|
+
previewError: [];
|
|
33
|
+
refreshPreview: [];
|
|
34
|
+
tap: [point: { x: number; y: number }];
|
|
35
|
+
swipe: [gesture: {
|
|
36
|
+
startX: number;
|
|
37
|
+
startY: number;
|
|
38
|
+
endX: number;
|
|
39
|
+
endY: number;
|
|
40
|
+
duration: number;
|
|
41
|
+
}];
|
|
42
|
+
}>();
|
|
43
|
+
|
|
44
|
+
const imageSize = shallowRef({ width: 0, height: 0 });
|
|
45
|
+
let pointerSession: {
|
|
46
|
+
pointerId: number;
|
|
47
|
+
start: { x: number; y: number };
|
|
48
|
+
startClientX: number;
|
|
49
|
+
startClientY: number;
|
|
50
|
+
} | null = null;
|
|
51
|
+
const hasPreview = computed(() => Boolean(props.frameUrl || props.imageUrl));
|
|
52
|
+
const imageBoxStyle = computed(() => {
|
|
53
|
+
if (props.frameUrl) return {};
|
|
54
|
+
const width = props.deviceWidth || imageSize.value.width;
|
|
55
|
+
const height = props.deviceHeight || imageSize.value.height;
|
|
56
|
+
return { aspectRatio: width && height ? `${width} / ${height}` : '9 / 20' };
|
|
57
|
+
});
|
|
58
|
+
const overlayViewBox = computed(() => {
|
|
59
|
+
const width = props.deviceWidth || imageSize.value.width;
|
|
60
|
+
const height = props.deviceHeight || imageSize.value.height;
|
|
61
|
+
return width && height ? `0 0 ${width} ${height}` : '';
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
function updateImageSize(event: Event) {
|
|
65
|
+
const image = event.target as HTMLImageElement;
|
|
66
|
+
imageSize.value = { width: image.naturalWidth, height: image.naturalHeight };
|
|
67
|
+
emit('previewLoaded', imageSize.value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getDevicePoint(target: HTMLElement, clientX: number, clientY: number) {
|
|
71
|
+
const width = props.deviceWidth || imageSize.value.width;
|
|
72
|
+
const height = props.deviceHeight || imageSize.value.height;
|
|
73
|
+
if (!width || !height) return;
|
|
74
|
+
const rect = target.getBoundingClientRect();
|
|
75
|
+
if (!rect.width || !rect.height) return;
|
|
76
|
+
|
|
77
|
+
const deviceAspect = width / height;
|
|
78
|
+
const containerAspect = rect.width / rect.height;
|
|
79
|
+
let drawWidth = rect.width;
|
|
80
|
+
let drawHeight = rect.height;
|
|
81
|
+
let offsetX = 0;
|
|
82
|
+
let offsetY = 0;
|
|
83
|
+
|
|
84
|
+
if (containerAspect > deviceAspect) {
|
|
85
|
+
drawWidth = rect.height * deviceAspect;
|
|
86
|
+
offsetX = (rect.width - drawWidth) / 2;
|
|
87
|
+
} else {
|
|
88
|
+
drawHeight = rect.width / deviceAspect;
|
|
89
|
+
offsetY = (rect.height - drawHeight) / 2;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const localX = Math.max(0, Math.min(drawWidth, clientX - rect.left - offsetX));
|
|
93
|
+
const localY = Math.max(0, Math.min(drawHeight, clientY - rect.top - offsetY));
|
|
94
|
+
return {
|
|
95
|
+
x: Math.round((localX / drawWidth) * width),
|
|
96
|
+
y: Math.round((localY / drawHeight) * height),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function handlePointerDown(event: PointerEvent) {
|
|
101
|
+
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
|
102
|
+
const target = event.currentTarget as HTMLElement;
|
|
103
|
+
const point = getDevicePoint(target, event.clientX, event.clientY);
|
|
104
|
+
if (!point) return;
|
|
105
|
+
target.setPointerCapture(event.pointerId);
|
|
106
|
+
pointerSession = {
|
|
107
|
+
pointerId: event.pointerId,
|
|
108
|
+
start: point,
|
|
109
|
+
startClientX: event.clientX,
|
|
110
|
+
startClientY: event.clientY,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function handlePointerUp(event: PointerEvent) {
|
|
115
|
+
if (!pointerSession || pointerSession.pointerId !== event.pointerId) return;
|
|
116
|
+
const target = event.currentTarget as HTMLElement;
|
|
117
|
+
const point = getDevicePoint(target, event.clientX, event.clientY);
|
|
118
|
+
const session = pointerSession;
|
|
119
|
+
pointerSession = null;
|
|
120
|
+
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
|
|
121
|
+
if (!point) return;
|
|
122
|
+
|
|
123
|
+
const moved = Math.hypot(
|
|
124
|
+
event.clientX - session.startClientX,
|
|
125
|
+
event.clientY - session.startClientY,
|
|
126
|
+
);
|
|
127
|
+
if (moved < 8) {
|
|
128
|
+
emit('tap', session.start);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
emit('swipe', {
|
|
133
|
+
startX: session.start.x,
|
|
134
|
+
startY: session.start.y,
|
|
135
|
+
endX: point.x,
|
|
136
|
+
endY: point.y,
|
|
137
|
+
duration: 120,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function handlePointerCancel(event: PointerEvent) {
|
|
142
|
+
if (pointerSession?.pointerId === event.pointerId) pointerSession = null;
|
|
143
|
+
}
|
|
144
|
+
</script>
|
|
145
|
+
|
|
146
|
+
<template>
|
|
147
|
+
<el-card shadow="never" class="automation-card device-preview-card">
|
|
148
|
+
<template #header>
|
|
149
|
+
<div class="panel-header">
|
|
150
|
+
<span>设备预览</span>
|
|
151
|
+
<div class="device-status">
|
|
152
|
+
<el-button
|
|
153
|
+
text
|
|
154
|
+
circle
|
|
155
|
+
:icon="Refresh"
|
|
156
|
+
:disabled="!selectedDeviceId"
|
|
157
|
+
title="刷新画面"
|
|
158
|
+
@click="emit('refreshPreview')"
|
|
159
|
+
/>
|
|
160
|
+
<el-tag :type="available ? 'success' : 'info'">
|
|
161
|
+
{{ available ? 'ADB 已连接' : '未检测到设备' }}
|
|
162
|
+
</el-tag>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
</template>
|
|
166
|
+
|
|
167
|
+
<div class="device-toolbar">
|
|
168
|
+
<el-select
|
|
169
|
+
:model-value="selectedDeviceId"
|
|
170
|
+
placeholder="选择设备"
|
|
171
|
+
class="device-select"
|
|
172
|
+
@change="emit('switchDevice', String($event))"
|
|
173
|
+
>
|
|
174
|
+
<el-option
|
|
175
|
+
v-for="device in devices"
|
|
176
|
+
:key="device.id"
|
|
177
|
+
:label="`${device.id}${device.description ? ` · ${device.description}` : ''}`"
|
|
178
|
+
:value="device.id"
|
|
179
|
+
/>
|
|
180
|
+
</el-select>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div v-if="actions?.length" class="device-actions">
|
|
184
|
+
<button
|
|
185
|
+
v-for="action in actions"
|
|
186
|
+
:key="action.key"
|
|
187
|
+
type="button"
|
|
188
|
+
class="device-action-button"
|
|
189
|
+
@click="emit('triggerKey', action.keyCode)"
|
|
190
|
+
>
|
|
191
|
+
<img :src="action.icon" :alt="action.label" class="device-action-button__icon" />
|
|
192
|
+
<span class="device-action-button__tooltip">{{ action.label }}</span>
|
|
193
|
+
</button>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<div
|
|
197
|
+
class="device-preview"
|
|
198
|
+
:class="{ 'device-preview--image': imageUrl && !frameUrl }"
|
|
199
|
+
>
|
|
200
|
+
<div
|
|
201
|
+
v-if="hasPreview"
|
|
202
|
+
class="device-preview__interactive"
|
|
203
|
+
:class="{ 'device-preview__interactive--image': !frameUrl }"
|
|
204
|
+
:style="imageBoxStyle"
|
|
205
|
+
>
|
|
206
|
+
<div
|
|
207
|
+
class="device-preview__surface"
|
|
208
|
+
@pointerdown="handlePointerDown"
|
|
209
|
+
@pointerup="handlePointerUp"
|
|
210
|
+
@pointercancel="handlePointerCancel"
|
|
211
|
+
/>
|
|
212
|
+
<iframe
|
|
213
|
+
v-if="frameUrl"
|
|
214
|
+
:key="frameUrl"
|
|
215
|
+
:src="frameUrl"
|
|
216
|
+
title="Android Device Preview"
|
|
217
|
+
sandbox="allow-scripts allow-same-origin allow-forms"
|
|
218
|
+
class="device-preview__frame"
|
|
219
|
+
/>
|
|
220
|
+
<img
|
|
221
|
+
v-else
|
|
222
|
+
:src="imageUrl"
|
|
223
|
+
alt="Android Device Preview"
|
|
224
|
+
class="device-preview__frame device-preview__image"
|
|
225
|
+
@load="updateImageSize"
|
|
226
|
+
@error="emit('previewError')"
|
|
227
|
+
/>
|
|
228
|
+
<svg
|
|
229
|
+
v-if="overlayViewBox && (overlayBounds?.length || selectedBounds)"
|
|
230
|
+
class="device-preview__overlay"
|
|
231
|
+
:viewBox="overlayViewBox"
|
|
232
|
+
preserveAspectRatio="xMidYMid meet"
|
|
233
|
+
aria-hidden="true"
|
|
234
|
+
>
|
|
235
|
+
<rect
|
|
236
|
+
v-for="bounds in overlayBounds"
|
|
237
|
+
:key="bounds.id"
|
|
238
|
+
class="device-preview__node-bound"
|
|
239
|
+
:x="bounds.left"
|
|
240
|
+
:y="bounds.top"
|
|
241
|
+
:width="Math.max(0, bounds.right - bounds.left)"
|
|
242
|
+
:height="Math.max(0, bounds.bottom - bounds.top)"
|
|
243
|
+
vector-effect="non-scaling-stroke"
|
|
244
|
+
/>
|
|
245
|
+
<rect
|
|
246
|
+
v-if="selectedBounds"
|
|
247
|
+
class="device-preview__selected-bound"
|
|
248
|
+
:x="selectedBounds.left"
|
|
249
|
+
:y="selectedBounds.top"
|
|
250
|
+
:width="Math.max(0, selectedBounds.right - selectedBounds.left)"
|
|
251
|
+
:height="Math.max(0, selectedBounds.bottom - selectedBounds.top)"
|
|
252
|
+
vector-effect="non-scaling-stroke"
|
|
253
|
+
/>
|
|
254
|
+
</svg>
|
|
255
|
+
</div>
|
|
256
|
+
<div v-else class="device-preview__empty">
|
|
257
|
+
<Cpu class="device-preview__icon" />
|
|
258
|
+
<p v-if="selectedDeviceId" class="device-preview__meta">
|
|
259
|
+
当前设备:{{ selectedDeviceId }}
|
|
260
|
+
</p>
|
|
261
|
+
<p v-if="previewError">{{ previewError }}</p>
|
|
262
|
+
<p v-else>请选择可用设备</p>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
</el-card>
|
|
266
|
+
</template>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { CopyDocument, Edit, RefreshLeft } from '@element-plus/icons-vue';
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
code: string;
|
|
6
|
+
showCode: boolean;
|
|
7
|
+
editing: boolean;
|
|
8
|
+
saving: boolean;
|
|
9
|
+
}>();
|
|
10
|
+
|
|
11
|
+
const draftCode = defineModel<string>('draftCode', { required: true });
|
|
12
|
+
|
|
13
|
+
defineEmits<{
|
|
14
|
+
copy: [];
|
|
15
|
+
edit: [];
|
|
16
|
+
undo: [];
|
|
17
|
+
save: [];
|
|
18
|
+
}>();
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<el-card shadow="never" class="generator-code-card">
|
|
23
|
+
<template #header>
|
|
24
|
+
<div class="panel-header">
|
|
25
|
+
<span>生成代码</span>
|
|
26
|
+
<div class="panel-header__actions">
|
|
27
|
+
<el-button
|
|
28
|
+
type="success"
|
|
29
|
+
:icon="CopyDocument"
|
|
30
|
+
:disabled="!showCode"
|
|
31
|
+
@click="$emit('copy')"
|
|
32
|
+
>
|
|
33
|
+
复制
|
|
34
|
+
</el-button>
|
|
35
|
+
<el-button
|
|
36
|
+
v-if="!editing"
|
|
37
|
+
type="primary"
|
|
38
|
+
:icon="Edit"
|
|
39
|
+
:disabled="!showCode"
|
|
40
|
+
@click="$emit('edit')"
|
|
41
|
+
>
|
|
42
|
+
编辑
|
|
43
|
+
</el-button>
|
|
44
|
+
<template v-else>
|
|
45
|
+
<el-button :icon="RefreshLeft" :disabled="saving" @click="$emit('undo')">
|
|
46
|
+
撤回修改
|
|
47
|
+
</el-button>
|
|
48
|
+
<el-button
|
|
49
|
+
type="primary"
|
|
50
|
+
:loading="saving"
|
|
51
|
+
@click="$emit('save')"
|
|
52
|
+
>
|
|
53
|
+
保存
|
|
54
|
+
</el-button>
|
|
55
|
+
</template>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</template>
|
|
59
|
+
|
|
60
|
+
<el-input
|
|
61
|
+
v-if="editing"
|
|
62
|
+
v-model="draftCode"
|
|
63
|
+
type="textarea"
|
|
64
|
+
resize="none"
|
|
65
|
+
spellcheck="false"
|
|
66
|
+
class="generator-code-editor"
|
|
67
|
+
/>
|
|
68
|
+
<pre v-else class="code-block generator-code-block"><code>{{ showCode ? code : '' }}</code></pre>
|
|
69
|
+
</el-card>
|
|
70
|
+
</template>
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Document, UploadFilled } from '@element-plus/icons-vue';
|
|
3
|
+
import { ElMessage } from 'element-plus';
|
|
4
|
+
import { useTemplateRef } from 'vue';
|
|
5
|
+
|
|
6
|
+
defineProps<{
|
|
7
|
+
uploading: boolean;
|
|
8
|
+
fileName: string;
|
|
9
|
+
}>();
|
|
10
|
+
|
|
11
|
+
const emit = defineEmits<{
|
|
12
|
+
upload: [file: File];
|
|
13
|
+
}>();
|
|
14
|
+
|
|
15
|
+
const fileInput = useTemplateRef<HTMLInputElement>('fileInput');
|
|
16
|
+
const allowedExtensions = new Set(['txt', 'xls', 'xlsx', 'doc', 'docx']);
|
|
17
|
+
const maxFileSize = 10 * 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
const openFilePicker = () => {
|
|
20
|
+
if (!fileInput.value) return;
|
|
21
|
+
fileInput.value.value = '';
|
|
22
|
+
fileInput.value.click();
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const handleFileChange = (event: Event) => {
|
|
26
|
+
const input = event.target as HTMLInputElement;
|
|
27
|
+
const file = input.files?.[0];
|
|
28
|
+
if (!file) return;
|
|
29
|
+
|
|
30
|
+
const extension = file.name.split('.').pop()?.toLowerCase() || '';
|
|
31
|
+
if (!allowedExtensions.has(extension)) {
|
|
32
|
+
ElMessage.error('仅支持 txt、xls、xlsx、doc、docx 格式');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (file.size > maxFileSize) {
|
|
36
|
+
ElMessage.error('文件大小不能超过 10MB');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
emit('upload', file);
|
|
41
|
+
};
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<template>
|
|
45
|
+
<div class="test-case-upload">
|
|
46
|
+
<el-tooltip v-if="fileName" :content="fileName" placement="top">
|
|
47
|
+
<span class="test-case-upload__file">
|
|
48
|
+
<el-icon><Document /></el-icon>
|
|
49
|
+
<span class="test-case-upload__name">{{ fileName }}</span>
|
|
50
|
+
</span>
|
|
51
|
+
</el-tooltip>
|
|
52
|
+
<el-button
|
|
53
|
+
size="small"
|
|
54
|
+
:icon="UploadFilled"
|
|
55
|
+
:loading="uploading"
|
|
56
|
+
@click="openFilePicker"
|
|
57
|
+
>
|
|
58
|
+
上传用例
|
|
59
|
+
</el-button>
|
|
60
|
+
<input
|
|
61
|
+
ref="fileInput"
|
|
62
|
+
class="test-case-upload__input"
|
|
63
|
+
type="file"
|
|
64
|
+
accept=".txt,.xls,.xlsx,.doc,.docx"
|
|
65
|
+
@change="handleFileChange"
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
</template>
|
|
69
|
+
|
|
70
|
+
<style scoped>
|
|
71
|
+
.test-case-upload {
|
|
72
|
+
display: flex;
|
|
73
|
+
align-items: center;
|
|
74
|
+
gap: 8px;
|
|
75
|
+
min-width: 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.test-case-upload__file {
|
|
79
|
+
display: inline-flex;
|
|
80
|
+
align-items: center;
|
|
81
|
+
gap: 4px;
|
|
82
|
+
min-width: 0;
|
|
83
|
+
max-width: 132px;
|
|
84
|
+
color: #606266;
|
|
85
|
+
font-size: 12px;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.test-case-upload__name {
|
|
89
|
+
overflow: hidden;
|
|
90
|
+
text-overflow: ellipsis;
|
|
91
|
+
white-space: nowrap;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.test-case-upload__input {
|
|
95
|
+
display: none;
|
|
96
|
+
}
|
|
97
|
+
</style>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export type MidsceneModelProvider = 'custom' | 'codex';
|
|
2
|
+
export type MidsceneModelPresetKey = 'gpt' | 'doubao';
|
|
3
|
+
|
|
4
|
+
export type MidsceneModelPreset = {
|
|
5
|
+
key: MidsceneModelPresetKey;
|
|
6
|
+
label: string;
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
modelName: string;
|
|
9
|
+
modelFamily: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type MidsceneModelOption = {
|
|
13
|
+
label: string;
|
|
14
|
+
value: string;
|
|
15
|
+
family: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const codexMidsceneModel = {
|
|
19
|
+
provider: 'codex' as const,
|
|
20
|
+
baseUrl: 'codex://app-server',
|
|
21
|
+
apiKey: '',
|
|
22
|
+
name: 'gpt-5.5',
|
|
23
|
+
family: 'gpt-5',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const codexMidsceneModelOptions: MidsceneModelOption[] = [
|
|
27
|
+
{ label: 'GPT-5.5(推荐)', value: 'gpt-5.5', family: 'gpt-5' },
|
|
28
|
+
{ label: 'GPT-5.6 Sol', value: 'gpt-5.6-sol', family: 'gpt-5' },
|
|
29
|
+
{ label: 'GPT-5.6 Terra', value: 'gpt-5.6-terra', family: 'gpt-5' },
|
|
30
|
+
{ label: 'GPT-5.6 Luna', value: 'gpt-5.6-luna', family: 'gpt-5' },
|
|
31
|
+
{ label: 'GPT-5.4', value: 'gpt-5.4', family: 'gpt-5' },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export const midsceneModelOptions: MidsceneModelOption[] = [
|
|
35
|
+
...codexMidsceneModelOptions,
|
|
36
|
+
{ label: 'Doubao Seed 2.1 Pro 260628', value: 'doubao-seed-2-1-pro-260628', family: 'doubao-seed' },
|
|
37
|
+
{ label: 'Doubao Seed 2.1 Turbo', value: 'doubao-seed-2.1-turbo', family: 'doubao-seed' },
|
|
38
|
+
{ label: 'Doubao Seed 2.0 Lite', value: 'doubao-seed-2.0-lite', family: 'doubao-seed' },
|
|
39
|
+
{ label: 'Doubao Seed 1.6 Vision', value: 'doubao-seed-1.6-vision', family: 'doubao-seed' },
|
|
40
|
+
{ label: 'Doubao Seed 1.8', value: 'doubao-seed-1.8', family: 'doubao-seed' },
|
|
41
|
+
{ label: 'Qwen 3.7 Plus', value: 'qwen3.7-plus', family: 'qwen3' },
|
|
42
|
+
{ label: 'Qwen 3.5 Plus', value: 'qwen3.5-plus', family: 'qwen3' },
|
|
43
|
+
{ label: 'Qwen 3.6 Plus', value: 'qwen3.6-plus', family: 'qwen3' },
|
|
44
|
+
{ label: 'Qwen 3 VL Plus', value: 'qwen3-vl-plus', family: 'qwen3-vl' },
|
|
45
|
+
{ label: 'Qwen VL Max Latest', value: 'qwen-vl-max-latest', family: 'qwen2.5-vl' },
|
|
46
|
+
{ label: 'Gemini 3.5 Flash', value: 'gemini-3.5-flash', family: 'gemini' },
|
|
47
|
+
{ label: 'Gemini 3 Flash Preview', value: 'gemini-3-flash-preview', family: 'gemini' },
|
|
48
|
+
{ label: 'Kimi K3', value: 'kimi-k3', family: 'kimi3' },
|
|
49
|
+
{ label: 'Kimi K2.5', value: 'kimi-k2.5', family: 'kimi' },
|
|
50
|
+
{ label: 'Kimi K2.6', value: 'kimi-k2.6', family: 'kimi' },
|
|
51
|
+
{ label: 'MiMo V2.5', value: 'mimo-v2.5', family: 'xiaomi-mimo' },
|
|
52
|
+
{ label: 'GLM 5V Turbo', value: 'glm-5v-turbo', family: 'glm-v' },
|
|
53
|
+
{ label: 'GLM 4.6V', value: 'glm-4.6v', family: 'glm-v' },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
export const midsceneModelFamilyOptions = Array.from(
|
|
57
|
+
new Set(midsceneModelOptions.map((option) => option.family)),
|
|
58
|
+
).map((family) => ({ label: family, value: family }));
|
|
59
|
+
|
|
60
|
+
export const midsceneModelPresets: MidsceneModelPreset[] = [
|
|
61
|
+
{
|
|
62
|
+
key: 'gpt',
|
|
63
|
+
label: 'GPT',
|
|
64
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
65
|
+
modelName: 'gpt-5.5',
|
|
66
|
+
modelFamily: 'gpt-5',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
key: 'doubao',
|
|
70
|
+
label: 'Doubao',
|
|
71
|
+
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
|
|
72
|
+
modelName: 'doubao-seed-2-1-pro-260628',
|
|
73
|
+
modelFamily: 'doubao-seed',
|
|
74
|
+
},
|
|
75
|
+
];
|