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.
Files changed (78) hide show
  1. package/README.md +160 -0
  2. package/bin/android-midscene-automation.js +27 -0
  3. package/index.html +12 -0
  4. package/package.json +49 -0
  5. package/remote-agent/index.ts +206 -0
  6. package/server/appium-recorder/appium-runner.ts +427 -0
  7. package/server/appium-recorder/repository.ts +228 -0
  8. package/server/appium-recorder/routes.ts +219 -0
  9. package/server/config-store.ts +167 -0
  10. package/server/config.ts +130 -0
  11. package/server/device-locks/repository.ts +147 -0
  12. package/server/device-locks/service.ts +72 -0
  13. package/server/device-locks/types.ts +22 -0
  14. package/server/device-sessions/repository.ts +169 -0
  15. package/server/device-sessions/service.ts +59 -0
  16. package/server/device-sessions/types.ts +24 -0
  17. package/server/http-api.ts +1389 -0
  18. package/server/model-call-usage-importer.ts +108 -0
  19. package/server/model-tester.ts +104 -0
  20. package/server/model-usage-repository.ts +131 -0
  21. package/server/operations/repository.ts +218 -0
  22. package/server/operations/service.ts +84 -0
  23. package/server/operations/types.ts +27 -0
  24. package/server/paths.ts +27 -0
  25. package/server/remote-agents/protocol.ts +38 -0
  26. package/server/remote-agents/registry.ts +136 -0
  27. package/server/remote-agents/routes.ts +89 -0
  28. package/server/script-agent.ts +284 -0
  29. package/server/script-db.ts +281 -0
  30. package/server/script-runner.ts +551 -0
  31. package/server/storage/sqlite.ts +49 -0
  32. package/server/test-case-import/formatter.ts +28 -0
  33. package/server/test-case-import/parsers/excel.ts +69 -0
  34. package/server/test-case-import/parsers/txt.ts +11 -0
  35. package/server/test-case-import/parsers/word.ts +9 -0
  36. package/server/test-case-import/service.ts +58 -0
  37. package/server/test-case-import/text-normalizer.ts +98 -0
  38. package/server/test-case-import/types.ts +24 -0
  39. package/server/test-case-import/validator.ts +34 -0
  40. package/src/App.vue +1450 -0
  41. package/src/api.ts +290 -0
  42. package/src/appium-recorder/AppiumPage.vue +894 -0
  43. package/src/appium-recorder/api.ts +64 -0
  44. package/src/appium-recorder/components/ComponentTree.vue +44 -0
  45. package/src/appium-recorder/components/NodeDetail.vue +152 -0
  46. package/src/appium-recorder/components/RecordedSteps.vue +79 -0
  47. package/src/appium-recorder/tree.ts +129 -0
  48. package/src/appium-recorder/types.ts +88 -0
  49. package/src/assets/device-actions/back.svg +5 -0
  50. package/src/assets/device-actions/home.svg +3 -0
  51. package/src/assets/device-actions/power.svg +5 -0
  52. package/src/assets/device-actions/tasks.svg +3 -0
  53. package/src/assets/device-actions/volume-down.svg +3 -0
  54. package/src/assets/device-actions/volume-up.svg +3 -0
  55. package/src/components/config/ModelUsageChart.vue +188 -0
  56. package/src/components/device/DevicePreviewPanel.vue +266 -0
  57. package/src/components/generator/GeneratedCodePanel.vue +70 -0
  58. package/src/components/generator/TestCaseFileUpload.vue +97 -0
  59. package/src/config/midscene-model-presets.ts +75 -0
  60. package/src/config/prompt-example.ts +6 -0
  61. package/src/main.ts +7 -0
  62. package/src/pages/AiGeneratorPage.vue +90 -0
  63. package/src/pages/AutomationPage.vue +161 -0
  64. package/src/pages/ConfigPage.vue +273 -0
  65. package/src/pages/GeneratorPage.vue +97 -0
  66. package/src/pages/ManualStepsPage.vue +179 -0
  67. package/src/script-generator/codegen.ts +126 -0
  68. package/src/script-generator/index.ts +4 -0
  69. package/src/script-generator/presets.ts +37 -0
  70. package/src/script-generator/step-options.ts +52 -0
  71. package/src/script-generator/types.ts +27 -0
  72. package/src/style.css +1983 -0
  73. package/src/types.ts +157 -0
  74. package/src/vite-env.d.ts +1 -0
  75. package/tsconfig.app.json +8 -0
  76. package/tsconfig.json +11 -0
  77. package/tsconfig.node.json +16 -0
  78. package/vite.config.ts +28 -0
@@ -0,0 +1,894 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, reactive, shallowRef, watch } from 'vue';
3
+ import { ElMessage, ElMessageBox } from 'element-plus';
4
+ import { Check, Delete, Refresh, VideoPlay } from '@element-plus/icons-vue';
5
+ import type { AndroidDevice, AppPreset, DeviceAction } from '../types';
6
+ import DevicePreviewPanel from '../components/device/DevicePreviewPanel.vue';
7
+ import {
8
+ deleteAppiumScript,
9
+ getAppiumScripts,
10
+ getAppiumTree,
11
+ pressAppiumDeviceKey,
12
+ replayAppiumScript,
13
+ saveAppiumScript,
14
+ tapAppiumDevice,
15
+ } from './api';
16
+ import ComponentTree from './components/ComponentTree.vue';
17
+ import NodeDetail from './components/NodeDetail.vue';
18
+ import RecordedSteps from './components/RecordedSteps.vue';
19
+ import { findSmallestNodeAtPoint, flattenNodes, parseWindowHierarchy } from './tree';
20
+ import type { AppiumNode, AppiumRecordedScript, AppiumRecordedStep } from './types';
21
+
22
+ type NodeStepType = 'tap' | 'input' | 'assertExists';
23
+ type RecorderAction =
24
+ | 'delay'
25
+ | NodeStepType
26
+ | 'keyBack'
27
+ | 'keyHome'
28
+ | 'keyRecent'
29
+ | 'keyPower'
30
+ | 'waitActivity'
31
+ | 'swipe'
32
+ | 'clearInput'
33
+ | 'coordinateTap'
34
+ | 'launchApp'
35
+ | 'waitDisappear'
36
+ | 'assertText'
37
+ | 'longPress'
38
+ | 'pinch';
39
+
40
+ const props = defineProps<{
41
+ active: boolean;
42
+ appPresets: AppPreset[];
43
+ deviceActions: readonly DeviceAction[];
44
+ playgroundAvailable: boolean;
45
+ playgroundDeviceId: string;
46
+ playgroundFrameUrl: string;
47
+ playgroundPreviewError: string;
48
+ devicePreviewUrl: string;
49
+ androidDevices: AndroidDevice[];
50
+ deviceWidth: number;
51
+ deviceHeight: number;
52
+ switchAndroidDevice: (deviceId: string) => Promise<void>;
53
+ triggerDeviceKey: (keyCode: number) => Promise<void>;
54
+ refreshDevicePreview: () => void;
55
+ swipeDevice: (
56
+ startX: number,
57
+ startY: number,
58
+ endX: number,
59
+ endY: number,
60
+ duration?: number,
61
+ ) => Promise<void>;
62
+ }>();
63
+
64
+ const selectedDeviceId = computed(() => props.playgroundDeviceId);
65
+ const tree = shallowRef<AppiumNode | null>(null);
66
+ const selectedNode = shallowRef<AppiumNode | null>(null);
67
+ const scripts = shallowRef<AppiumRecordedScript[]>([]);
68
+ const selectedScriptId = shallowRef('');
69
+ const activeWorkbenchTab = shallowRef<'recording' | 'scripts'>('recording');
70
+ const steps = shallowRef<AppiumRecordedStep[]>([]);
71
+ const rawXml = shallowRef('');
72
+ const currentActivity = shallowRef('');
73
+ const replayOutput = shallowRef('');
74
+ const loadingTree = shallowRef(false);
75
+ const saving = shallowRef(false);
76
+ const deletingScriptId = shallowRef('');
77
+ const replaying = shallowRef(false);
78
+ const recordingTap = shallowRef(false);
79
+ const resolvingNavigation = shallowRef(false);
80
+ const maxReplayOutputLength = 20_000;
81
+ const pendingNavigation = shallowRef<{
82
+ beforeActivity: string;
83
+ afterActivity: string;
84
+ beforeSignature: string;
85
+ afterSignature: string;
86
+ } | null>(null);
87
+
88
+ const form = reactive({
89
+ name: '',
90
+ appPackage: '',
91
+ appActivity: '',
92
+ });
93
+
94
+ const selectedScript = computed(() => scripts.value.find((script) => script.id === selectedScriptId.value) || null);
95
+ const visibleReplayOutput = computed(() => replayOutput.value);
96
+ const hasSelectedAppPackage = computed(() => props.appPresets.some((app) => app.packageName === form.appPackage));
97
+ const recordingLocked = computed(() => recordingTap.value || resolvingNavigation.value || Boolean(pendingNavigation.value));
98
+ const overlayBounds = computed(() => flattenNodes(tree.value).flatMap((node) => (
99
+ node.bounds ? [{ id: node.id, ...node.bounds }] : []
100
+ )));
101
+ const selectedBounds = computed(() => {
102
+ const bounds = selectedNode.value?.bounds;
103
+ return bounds && selectedNode.value ? { id: selectedNode.value.id, ...bounds } : undefined;
104
+ });
105
+
106
+ function createStep(type: NodeStepType, node: AppiumNode): AppiumRecordedStep {
107
+ const labelMap = {
108
+ tap: '点击',
109
+ input: '输入',
110
+ assertExists: '断言存在',
111
+ };
112
+ return {
113
+ id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
114
+ type,
115
+ label: `${labelMap[type]} ${node.label}`,
116
+ selector: node.selector,
117
+ fallback: node.bounds ? { strategy: 'bounds', centerX: node.bounds.centerX, centerY: node.bounds.centerY } : undefined,
118
+ timeoutMs: type === 'assertExists' ? 10000 : undefined,
119
+ snapshot: {
120
+ text: node.text,
121
+ resourceId: node.resourceId,
122
+ contentDesc: node.contentDesc,
123
+ className: node.className,
124
+ },
125
+ };
126
+ }
127
+
128
+ function trimReplayOutput(output: string) {
129
+ if (output.length <= maxReplayOutputLength) return output;
130
+ return `... 已截断前 ${output.length - maxReplayOutputLength} 个字符,仅显示最后 ${maxReplayOutputLength} 个字符\n${output.slice(-maxReplayOutputLength)}`;
131
+ }
132
+
133
+ function createDelayStep(timeoutMs: number): AppiumRecordedStep {
134
+ return {
135
+ id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
136
+ type: 'delay',
137
+ label: `延时 ${timeoutMs}ms`,
138
+ timeoutMs,
139
+ };
140
+ }
141
+
142
+ function createNodeActionStep(
143
+ type: AppiumRecordedStep['type'],
144
+ label: string,
145
+ node: AppiumNode,
146
+ options: Pick<AppiumRecordedStep, 'timeoutMs' | 'value'> = {},
147
+ ): AppiumRecordedStep {
148
+ return {
149
+ id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
150
+ type,
151
+ label: `${label} ${node.label}`,
152
+ selector: node.selector,
153
+ fallback: node.bounds ? { strategy: 'bounds', centerX: node.bounds.centerX, centerY: node.bounds.centerY } : undefined,
154
+ ...options,
155
+ snapshot: {
156
+ text: node.text,
157
+ resourceId: node.resourceId,
158
+ contentDesc: node.contentDesc,
159
+ className: node.className,
160
+ },
161
+ };
162
+ }
163
+
164
+ function createKeyStep(keyCode: number, label = '按返回键'): AppiumRecordedStep {
165
+ return {
166
+ id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
167
+ type: 'key',
168
+ label,
169
+ keyCode,
170
+ };
171
+ }
172
+
173
+ function createSwipeStep(direction: string): AppiumRecordedStep {
174
+ const normalizedDirection = ({ 上: 'up', 下: 'down', 左: 'left', 右: 'right' } as Record<string, string>)[direction] || direction;
175
+ const width = props.deviceWidth || 1080;
176
+ const height = props.deviceHeight || 1920;
177
+ const centerX = Math.round(width * 0.5);
178
+ const centerY = Math.round(height * 0.5);
179
+ const distanceX = Math.round(width * 0.35);
180
+ const distanceY = Math.round(height * 0.35);
181
+ const swipeMap: Record<string, AppiumRecordedStep['swipe']> = {
182
+ up: { startX: centerX, startY: centerY + distanceY, endX: centerX, endY: centerY - distanceY, duration: 500 },
183
+ down: { startX: centerX, startY: centerY - distanceY, endX: centerX, endY: centerY + distanceY, duration: 500 },
184
+ left: { startX: centerX + distanceX, startY: centerY, endX: centerX - distanceX, endY: centerY, duration: 500 },
185
+ right: { startX: centerX - distanceX, startY: centerY, endX: centerX + distanceX, endY: centerY, duration: 500 },
186
+ };
187
+ const labelMap: Record<string, string> = { up: '上滑', down: '下滑', left: '左滑', right: '右滑' };
188
+ return {
189
+ id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
190
+ type: 'swipe',
191
+ label: labelMap[normalizedDirection] || '滑动',
192
+ swipe: swipeMap[normalizedDirection] || swipeMap.up,
193
+ };
194
+ }
195
+
196
+ function createWaitActivityStep(activity: string): AppiumRecordedStep {
197
+ return {
198
+ id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
199
+ type: 'waitActivity',
200
+ label: `等待 Activity ${activity}`,
201
+ value: activity,
202
+ timeoutMs: 10000,
203
+ };
204
+ }
205
+
206
+ function wait(ms: number) {
207
+ return new Promise((resolve) => setTimeout(resolve, ms));
208
+ }
209
+
210
+ function treeSignature(root: AppiumNode | null) {
211
+ return flattenNodes(root)
212
+ .slice(0, 300)
213
+ .map((node) => [
214
+ node.resourceId,
215
+ node.text,
216
+ node.contentDesc,
217
+ node.className,
218
+ node.xpath,
219
+ ].join('|'))
220
+ .join('\n');
221
+ }
222
+
223
+ function ensureAppPackageSelected() {
224
+ if (hasSelectedAppPackage.value) return true;
225
+ ElMessage.warning('请先从预设 App 参数中选择 App 包名');
226
+ return false;
227
+ }
228
+
229
+ function getSelectedNode() {
230
+ if (selectedNode.value) return selectedNode.value;
231
+ ElMessage.warning('请先选择组件');
232
+ return null;
233
+ }
234
+
235
+ async function loadTreeSnapshot(clearSelection = true) {
236
+ const payload = await getAppiumTree(selectedDeviceId.value);
237
+ const parsedTree = parseWindowHierarchy(payload.xml);
238
+ rawXml.value = payload.xml;
239
+ currentActivity.value = payload.activity || '';
240
+ tree.value = parsedTree;
241
+ if (clearSelection) selectedNode.value = null;
242
+ return {
243
+ activity: payload.activity || '',
244
+ signature: treeSignature(parsedTree),
245
+ tree: parsedTree,
246
+ };
247
+ }
248
+
249
+ async function loadScripts() {
250
+ const payload = await getAppiumScripts();
251
+ scripts.value = payload.scripts || [];
252
+ if (selectedScriptId.value && !scripts.value.some((script) => script.id === selectedScriptId.value)) {
253
+ selectedScriptId.value = '';
254
+ }
255
+ if (!selectedScriptId.value) {
256
+ selectedScriptId.value = scripts.value[0]?.id || '';
257
+ }
258
+ }
259
+
260
+ async function switchDevice(deviceId: string) {
261
+ tree.value = null;
262
+ selectedNode.value = null;
263
+ currentActivity.value = '';
264
+ await props.switchAndroidDevice(deviceId);
265
+ }
266
+
267
+ async function triggerDeviceKey(keyCode: number) {
268
+ if (!selectedDeviceId.value) return;
269
+ try {
270
+ await props.triggerDeviceKey(keyCode);
271
+ } catch (error) {
272
+ ElMessage.error(error instanceof Error ? error.message : '设备操作失败');
273
+ }
274
+ }
275
+
276
+ async function refreshTree() {
277
+ if (loadingTree.value) return;
278
+ if (!selectedDeviceId.value) {
279
+ ElMessage.warning('请选择设备');
280
+ return;
281
+ }
282
+ loadingTree.value = true;
283
+ try {
284
+ await loadTreeSnapshot();
285
+ props.refreshDevicePreview();
286
+ } catch (error) {
287
+ ElMessage.error(error instanceof Error ? error.message : '刷新组件树失败');
288
+ } finally {
289
+ loadingTree.value = false;
290
+ }
291
+ }
292
+
293
+ function selectNodeFromPoint(point: { x: number; y: number }) {
294
+ const node = findSmallestNodeAtPoint(tree.value, point.x, point.y);
295
+ if (node) {
296
+ selectedNode.value = node;
297
+ return;
298
+ }
299
+ ElMessage.warning('未命中组件树节点');
300
+ }
301
+
302
+ async function swipePreview(gesture: {
303
+ startX: number;
304
+ startY: number;
305
+ endX: number;
306
+ endY: number;
307
+ duration: number;
308
+ }) {
309
+ if (!selectedDeviceId.value) return;
310
+ try {
311
+ await props.swipeDevice(
312
+ gesture.startX,
313
+ gesture.startY,
314
+ gesture.endX,
315
+ gesture.endY,
316
+ gesture.duration,
317
+ );
318
+ } catch (error) {
319
+ ElMessage.error(error instanceof Error ? error.message : '滑动失败');
320
+ }
321
+ }
322
+
323
+ async function recordTapStep() {
324
+ if (!selectedNode.value || recordingTap.value) return;
325
+ if (!ensureAppPackageSelected()) return;
326
+ if (!selectedDeviceId.value) {
327
+ ElMessage.warning('请选择设备');
328
+ return;
329
+ }
330
+ const node = selectedNode.value;
331
+ const bounds = node.bounds;
332
+ if (!bounds) {
333
+ ElMessage.warning('当前组件没有可点击坐标');
334
+ return;
335
+ }
336
+ const beforeActivity = currentActivity.value;
337
+ const beforeSignature = treeSignature(tree.value);
338
+ const step = createStep('tap', node);
339
+ steps.value = [...steps.value, step];
340
+ recordingTap.value = true;
341
+ try {
342
+ await tapAppiumDevice({ deviceId: selectedDeviceId.value, x: bounds.centerX, y: bounds.centerY });
343
+ await wait(1200);
344
+ const after = await loadTreeSnapshot();
345
+ const activityChanged = Boolean(beforeActivity && after.activity && beforeActivity !== after.activity);
346
+ const treeChanged = Boolean(beforeSignature && after.signature && beforeSignature !== after.signature);
347
+ if (activityChanged || treeChanged) {
348
+ pendingNavigation.value = {
349
+ beforeActivity,
350
+ afterActivity: after.activity,
351
+ beforeSignature,
352
+ afterSignature: after.signature,
353
+ };
354
+ return;
355
+ }
356
+ ElMessage.success('已录制点击');
357
+ } catch (error) {
358
+ ElMessage.error(error instanceof Error ? error.message : '录制点击失败');
359
+ } finally {
360
+ recordingTap.value = false;
361
+ }
362
+ }
363
+
364
+ async function addStep(type: NodeStepType) {
365
+ if (recordingLocked.value) return;
366
+ if (!selectedNode.value) return;
367
+ if (!ensureAppPackageSelected()) return;
368
+ if (type === 'tap') {
369
+ await recordTapStep();
370
+ return;
371
+ }
372
+ const step = createStep(type, selectedNode.value);
373
+ if (type === 'input') {
374
+ const input = await ElMessageBox.prompt('', '录制输入', {
375
+ inputValue: '',
376
+ confirmButtonText: '添加',
377
+ cancelButtonText: '取消',
378
+ center: true,
379
+ }).catch(() => null);
380
+ if (!input) return;
381
+ step.value = input.value;
382
+ }
383
+ steps.value = [...steps.value, step];
384
+ }
385
+
386
+ async function addDelayStep(index?: number) {
387
+ if (recordingLocked.value) return;
388
+ const input = await ElMessageBox.prompt('请输入延时时间,单位毫秒', '添加延时', {
389
+ inputValue: '1000',
390
+ inputPattern: /^[1-9]\d{0,5}$/,
391
+ inputErrorMessage: '请输入 1 到 999999 之间的整数',
392
+ confirmButtonText: '添加',
393
+ cancelButtonText: '取消',
394
+ }).catch(() => null);
395
+ if (!input) return;
396
+ const timeoutMs = Number(input.value);
397
+ const step = createDelayStep(timeoutMs);
398
+ const nextSteps = [...steps.value];
399
+ nextSteps.splice(typeof index === 'number' ? index + 1 : nextSteps.length, 0, step);
400
+ steps.value = nextSteps;
401
+ }
402
+
403
+ async function addAction(action: RecorderAction) {
404
+ if (recordingLocked.value) return;
405
+ if (!ensureAppPackageSelected()) return;
406
+ if (action === 'delay') {
407
+ await addDelayStep();
408
+ return;
409
+ }
410
+ if (action === 'tap' || action === 'input' || action === 'assertExists') {
411
+ await addStep(action);
412
+ return;
413
+ }
414
+ if (action === 'keyBack') {
415
+ steps.value = [...steps.value, createKeyStep(4, '返回键')];
416
+ return;
417
+ }
418
+ if (action === 'keyHome') {
419
+ steps.value = [...steps.value, createKeyStep(3, 'Home 键')];
420
+ return;
421
+ }
422
+ if (action === 'keyRecent') {
423
+ steps.value = [...steps.value, createKeyStep(187, '最近任务')];
424
+ return;
425
+ }
426
+ if (action === 'keyPower') {
427
+ steps.value = [...steps.value, createKeyStep(26, '电源键')];
428
+ return;
429
+ }
430
+ if (action === 'waitActivity') {
431
+ const input = await ElMessageBox.prompt('请输入目标 Activity', '等待 Activity', {
432
+ inputValue: currentActivity.value,
433
+ confirmButtonText: '添加',
434
+ cancelButtonText: '取消',
435
+ }).catch(() => null);
436
+ if (input?.value) steps.value = [...steps.value, createWaitActivityStep(input.value)];
437
+ return;
438
+ }
439
+ if (action === 'swipe') {
440
+ const input = await ElMessageBox.prompt('请输入滑动方向:上 / 下 / 左 / 右', '添加滑动', {
441
+ inputValue: '上',
442
+ inputPattern: /^(上|下|左|右|up|down|left|right)$/,
443
+ inputErrorMessage: '只能输入 上、下、左、右',
444
+ confirmButtonText: '添加',
445
+ cancelButtonText: '取消',
446
+ }).catch(() => null);
447
+ if (input?.value) steps.value = [...steps.value, createSwipeStep(input.value)];
448
+ return;
449
+ }
450
+ if (action === 'pinch') {
451
+ const input = await ElMessageBox.prompt('请输入缩放方向:放大 / 缩小', '添加双指缩放', {
452
+ inputValue: '放大',
453
+ inputPattern: /^(放大|缩小|out|in)$/,
454
+ inputErrorMessage: '只能输入 放大 或 缩小',
455
+ confirmButtonText: '添加',
456
+ cancelButtonText: '取消',
457
+ }).catch(() => null);
458
+ if (!input?.value) return;
459
+ const direction = input.value === '缩小' || input.value === 'in' ? 'in' : 'out';
460
+ steps.value = [...steps.value, {
461
+ id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
462
+ type: 'pinch',
463
+ label: direction === 'out' ? '双指放大' : '双指缩小',
464
+ pinch: {
465
+ direction,
466
+ centerX: Math.round((props.deviceWidth || 1080) / 2),
467
+ centerY: Math.round((props.deviceHeight || 1920) / 2),
468
+ percent: 0.5,
469
+ },
470
+ }];
471
+ return;
472
+ }
473
+ if (action === 'launchApp') {
474
+ steps.value = [...steps.value, {
475
+ id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
476
+ type: 'launchApp',
477
+ label: `启动 App ${form.appPackage}`,
478
+ value: form.appPackage,
479
+ }];
480
+ return;
481
+ }
482
+ const node = getSelectedNode();
483
+ if (!node) return;
484
+ if (action === 'clearInput') {
485
+ steps.value = [...steps.value, createNodeActionStep('clearInput', '清空输入', node)];
486
+ return;
487
+ }
488
+ if (action === 'coordinateTap') {
489
+ if (!node.bounds) {
490
+ ElMessage.warning('当前组件没有可点击坐标');
491
+ return;
492
+ }
493
+ steps.value = [...steps.value, {
494
+ id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
495
+ type: 'coordinateTap',
496
+ label: `点击坐标 ${node.bounds.centerX},${node.bounds.centerY}`,
497
+ fallback: { strategy: 'bounds', centerX: node.bounds.centerX, centerY: node.bounds.centerY },
498
+ }];
499
+ return;
500
+ }
501
+ if (action === 'longPress') {
502
+ if (!node.bounds) {
503
+ ElMessage.warning('当前组件没有可长按坐标');
504
+ return;
505
+ }
506
+ steps.value = [...steps.value, {
507
+ id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
508
+ type: 'longPress',
509
+ label: `长按 ${node.label}`,
510
+ fallback: { strategy: 'bounds', centerX: node.bounds.centerX, centerY: node.bounds.centerY },
511
+ timeoutMs: 800,
512
+ }];
513
+ return;
514
+ }
515
+ if (action === 'waitDisappear') {
516
+ steps.value = [...steps.value, createNodeActionStep('waitDisappear', '等待元素消失', node, { timeoutMs: 10000 })];
517
+ return;
518
+ }
519
+ if (action === 'assertText') {
520
+ const input = await ElMessageBox.prompt('请输入要断言的文本', '断言文本', {
521
+ inputValue: node.text,
522
+ confirmButtonText: '添加',
523
+ cancelButtonText: '取消',
524
+ }).catch(() => null);
525
+ if (input?.value) steps.value = [...steps.value, createNodeActionStep('assertText', '断言文本', node, { value: input.value })];
526
+ }
527
+ }
528
+
529
+ async function returnToPreviousPage() {
530
+ const pending = pendingNavigation.value;
531
+ if (!pending || resolvingNavigation.value) return;
532
+ resolvingNavigation.value = true;
533
+ try {
534
+ await pressAppiumDeviceKey({ deviceId: selectedDeviceId.value, keyCode: 4 });
535
+ let returned = false;
536
+ for (let index = 0; index < 6; index += 1) {
537
+ await wait(700);
538
+ const snapshot = await loadTreeSnapshot();
539
+ const activityReturned = !pending.beforeActivity || snapshot.activity === pending.beforeActivity;
540
+ const leftDestination = !pending.afterSignature || snapshot.signature !== pending.afterSignature;
541
+ if (activityReturned && leftDestination) {
542
+ returned = true;
543
+ break;
544
+ }
545
+ }
546
+ if (!returned) throw new Error('设备页面未返回,请确认当前页面支持系统返回键');
547
+ steps.value = [
548
+ ...steps.value,
549
+ createKeyStep(4),
550
+ ...(pending.beforeActivity ? [createWaitActivityStep(pending.beforeActivity)] : []),
551
+ ];
552
+ pendingNavigation.value = null;
553
+ ElMessage.success('已返回原页面,可继续录制');
554
+ } catch (error) {
555
+ ElMessage.error(error instanceof Error ? error.message : '返回原页面失败');
556
+ } finally {
557
+ resolvingNavigation.value = false;
558
+ }
559
+ }
560
+
561
+ function stayOnCurrentPage() {
562
+ if (resolvingNavigation.value) return;
563
+ pendingNavigation.value = null;
564
+ ElMessage.success('继续在当前页面录制');
565
+ }
566
+
567
+ async function saveAsBranchScript() {
568
+ const pending = pendingNavigation.value;
569
+ if (!pending || resolvingNavigation.value) return;
570
+ if (!ensureAppPackageSelected()) return;
571
+ resolvingNavigation.value = true;
572
+ const activityName = (pending.afterActivity || 'branch').split(/[/.]/).filter(Boolean).pop() || 'branch';
573
+ const branchName = `${form.name || 'Appium脚本'}-${activityName}-${Date.now().toString(36).slice(-4)}`;
574
+ try {
575
+ const payload = await saveAppiumScript({
576
+ name: branchName,
577
+ appPackage: form.appPackage,
578
+ appActivity: form.appActivity,
579
+ deviceId: selectedDeviceId.value,
580
+ steps: steps.value,
581
+ });
582
+ await loadScripts();
583
+ selectedScriptId.value = payload.script.id;
584
+ form.name = payload.script.name;
585
+ pendingNavigation.value = null;
586
+ ElMessage.success('已保存为新用例');
587
+ } catch (error) {
588
+ ElMessage.error(error instanceof Error ? error.message : '保存新用例失败');
589
+ } finally {
590
+ resolvingNavigation.value = false;
591
+ }
592
+ }
593
+
594
+ function removeStep(index: number) {
595
+ steps.value = steps.value.filter((_step, stepIndex) => stepIndex !== index);
596
+ }
597
+
598
+ async function editInputStep(index: number) {
599
+ const step = steps.value[index];
600
+ if (!step || step.type !== 'input' || recordingLocked.value) return;
601
+ const input = await ElMessageBox.prompt('请输入新的录制文本', '修改输入内容', {
602
+ inputValue: step.value || '',
603
+ confirmButtonText: '保存',
604
+ cancelButtonText: '取消',
605
+ center: true,
606
+ }).catch(() => null);
607
+ if (!input) return;
608
+ const nextSteps = [...steps.value];
609
+ nextSteps[index] = { ...step, value: input.value };
610
+ steps.value = nextSteps;
611
+ }
612
+
613
+ function loadScript(script: AppiumRecordedScript) {
614
+ selectedScriptId.value = script.id;
615
+ form.name = script.name;
616
+ form.appPackage = script.appPackage;
617
+ form.appActivity = script.appActivity;
618
+ if (script.deviceId && script.deviceId !== selectedDeviceId.value) {
619
+ void switchDevice(script.deviceId);
620
+ }
621
+ steps.value = script.steps || [];
622
+ activeWorkbenchTab.value = 'recording';
623
+ }
624
+
625
+ function loadSelectedScript() {
626
+ if (selectedScript.value) loadScript(selectedScript.value);
627
+ }
628
+
629
+ function resetCurrentScript() {
630
+ selectedScriptId.value = '';
631
+ form.name = '';
632
+ form.appActivity = '';
633
+ steps.value = [];
634
+ replayOutput.value = '';
635
+ }
636
+
637
+ function formatScriptTime(value: string) {
638
+ if (!value) return '-';
639
+ const date = new Date(value);
640
+ return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
641
+ }
642
+
643
+ async function removeScript(script: AppiumRecordedScript) {
644
+ try {
645
+ await ElMessageBox.confirm(`确定删除脚本「${script.name}」吗?`, '删除脚本', {
646
+ confirmButtonText: '删除',
647
+ cancelButtonText: '取消',
648
+ type: 'warning',
649
+ confirmButtonClass: 'el-button--danger',
650
+ });
651
+ } catch {
652
+ return;
653
+ }
654
+
655
+ deletingScriptId.value = script.id;
656
+ const wasCurrent = selectedScriptId.value === script.id;
657
+ try {
658
+ await deleteAppiumScript(script.id);
659
+ await loadScripts();
660
+ if (wasCurrent) {
661
+ resetCurrentScript();
662
+ }
663
+ ElMessage.success('脚本已删除');
664
+ } catch (error) {
665
+ ElMessage.error(error instanceof Error ? error.message : '删除脚本失败');
666
+ } finally {
667
+ deletingScriptId.value = '';
668
+ }
669
+ }
670
+
671
+ async function saveScript() {
672
+ if (!ensureAppPackageSelected()) return;
673
+ saving.value = true;
674
+ try {
675
+ const payload = await saveAppiumScript({
676
+ id: selectedScript.value?.id,
677
+ name: form.name,
678
+ appPackage: form.appPackage,
679
+ appActivity: form.appActivity,
680
+ deviceId: selectedDeviceId.value,
681
+ steps: steps.value,
682
+ });
683
+ await loadScripts();
684
+ selectedScriptId.value = payload.script.id;
685
+ ElMessage.success('Appium 脚本已保存');
686
+ } catch (error) {
687
+ ElMessage.error(error instanceof Error ? error.message : '保存失败');
688
+ } finally {
689
+ saving.value = false;
690
+ }
691
+ }
692
+
693
+ async function replayScript() {
694
+ if (!selectedScript.value) {
695
+ ElMessage.warning('请选择已保存脚本');
696
+ return;
697
+ }
698
+ replaying.value = true;
699
+ replayOutput.value = '';
700
+ try {
701
+ const result = await replayAppiumScript({ id: selectedScript.value.id, deviceId: selectedDeviceId.value });
702
+ replayOutput.value = trimReplayOutput(result.output);
703
+ if (result.success) ElMessage.success('回放完成');
704
+ } catch (error) {
705
+ replayOutput.value = trimReplayOutput(error instanceof Error ? error.message : '回放失败');
706
+ ElMessage.error('回放失败,详情见回放输出');
707
+ } finally {
708
+ replaying.value = false;
709
+ }
710
+ }
711
+
712
+ onMounted(loadScripts);
713
+
714
+ watch(
715
+ () => [props.active, props.playgroundDeviceId] as const,
716
+ ([active, deviceId]) => {
717
+ if (active && deviceId) void refreshTree();
718
+ },
719
+ { immediate: true },
720
+ );
721
+ </script>
722
+
723
+ <template>
724
+ <section class="appium-recorder-page">
725
+ <div v-if="active" class="appium-header-actions">
726
+ <el-select
727
+ v-model="selectedScriptId"
728
+ placeholder="选择脚本"
729
+ class="appium-header-actions__script"
730
+ @change="loadSelectedScript"
731
+ >
732
+ <el-option v-for="script in scripts" :key="script.id" :label="script.name" :value="script.id" />
733
+ </el-select>
734
+ <el-button :icon="Check" :loading="saving" @click="saveScript">保存</el-button>
735
+ <el-button
736
+ type="primary"
737
+ :icon="VideoPlay"
738
+ :loading="replaying"
739
+ :disabled="!selectedScript"
740
+ @click="replayScript"
741
+ >
742
+ 回放
743
+ </el-button>
744
+ </div>
745
+
746
+ <div class="appium-recorder-layout">
747
+ <el-card shadow="never" class="appium-recorder-card">
748
+ <template #header>
749
+ <div class="panel-header">
750
+ <span>App 组件树</span>
751
+ <el-button :icon="Refresh" :loading="loadingTree" @click="refreshTree">
752
+ 刷新组件树
753
+ </el-button>
754
+ </div>
755
+ </template>
756
+ <div class="appium-tree-panel">
757
+ <ComponentTree :tree="tree" :selected-id="selectedNode?.id || ''" @select="selectedNode = $event" />
758
+ <div class="appium-current-activity">
759
+ <span>当前 Activity</span>
760
+ <code>{{ currentActivity || '-' }}</code>
761
+ </div>
762
+ </div>
763
+ </el-card>
764
+
765
+ <DevicePreviewPanel
766
+ :available="playgroundAvailable"
767
+ :devices="androidDevices"
768
+ :selected-device-id="selectedDeviceId"
769
+ :frame-url="playgroundFrameUrl"
770
+ :image-url="devicePreviewUrl"
771
+ :preview-error="playgroundPreviewError"
772
+ :actions="deviceActions"
773
+ :overlay-bounds="overlayBounds"
774
+ :selected-bounds="selectedBounds"
775
+ :device-width="deviceWidth"
776
+ :device-height="deviceHeight"
777
+ @switch-device="switchDevice"
778
+ @trigger-key="triggerDeviceKey"
779
+ @refresh-preview="refreshDevicePreview"
780
+ @tap="selectNodeFromPoint"
781
+ @swipe="swipePreview"
782
+ />
783
+
784
+ <el-card shadow="never" class="appium-recorder-card appium-recorder-card--workbench">
785
+ <template #header>录制与脚本</template>
786
+ <el-tabs v-model="activeWorkbenchTab" class="appium-workbench-tabs">
787
+ <el-tab-pane label="当前录制" name="recording">
788
+ <div class="appium-workbench">
789
+ <section class="appium-workbench__section">
790
+ <h3>节点与脚本</h3>
791
+ <div class="appium-side-form">
792
+ <el-input v-model="form.name" placeholder="脚本名称" />
793
+ <el-select
794
+ v-model="form.appPackage"
795
+ filterable
796
+ placeholder="选择预设 App 参数"
797
+ >
798
+ <el-option
799
+ v-for="app in appPresets"
800
+ :key="app.id"
801
+ :label="`${app.name} · ${app.packageName}`"
802
+ :value="app.packageName"
803
+ />
804
+ </el-select>
805
+ </div>
806
+ <NodeDetail
807
+ :node="selectedNode"
808
+ :disabled="recordingLocked"
809
+ :loading="recordingTap"
810
+ @add-action="addAction"
811
+ />
812
+ </section>
813
+
814
+ <section class="appium-workbench__section">
815
+ <h3>录制步骤</h3>
816
+ <RecordedSteps
817
+ :steps="steps"
818
+ :disabled="recordingLocked"
819
+ @remove="removeStep"
820
+ @add-delay="addDelayStep"
821
+ @edit-input="editInputStep"
822
+ />
823
+ </section>
824
+
825
+ <section class="appium-workbench__section">
826
+ <h3>回放输出</h3>
827
+ <pre class="appium-replay-output">{{ visibleReplayOutput }}</pre>
828
+ </section>
829
+ </div>
830
+ </el-tab-pane>
831
+
832
+ <el-tab-pane label="脚本列表" name="scripts">
833
+ <div class="appium-script-list">
834
+ <el-empty v-if="!scripts.length" description="暂无录制脚本" />
835
+ <template v-else>
836
+ <article
837
+ v-for="script in scripts"
838
+ :key="script.id"
839
+ class="appium-script-list__item"
840
+ :class="{ 'appium-script-list__item--active': script.id === selectedScriptId }"
841
+ >
842
+ <div class="appium-script-list__main">
843
+ <strong>{{ script.name }}</strong>
844
+ <span>{{ script.appPackage }}</span>
845
+ <small>{{ script.steps.length }} 步 · {{ formatScriptTime(script.updatedAt) }}</small>
846
+ </div>
847
+ <div class="appium-script-list__actions">
848
+ <el-button size="small" @click="loadScript(script)">加载</el-button>
849
+ <el-button
850
+ size="small"
851
+ type="danger"
852
+ :icon="Delete"
853
+ :loading="deletingScriptId === script.id"
854
+ @click="removeScript(script)"
855
+ />
856
+ </div>
857
+ </article>
858
+ </template>
859
+ </div>
860
+ </el-tab-pane>
861
+ </el-tabs>
862
+ </el-card>
863
+ </div>
864
+
865
+ <el-dialog
866
+ :model-value="Boolean(pendingNavigation)"
867
+ title="检测到页面变化"
868
+ width="680px"
869
+ :close-on-click-modal="false"
870
+ @close="stayOnCurrentPage"
871
+ >
872
+ <div class="appium-navigation-dialog">
873
+ <p>录制点击后检测到页面已变化。</p>
874
+ <dl>
875
+ <div>
876
+ <dt>点击前</dt>
877
+ <dd>{{ pendingNavigation?.beforeActivity || '-' }}</dd>
878
+ </div>
879
+ <div>
880
+ <dt>点击后</dt>
881
+ <dd>{{ pendingNavigation?.afterActivity || '-' }}</dd>
882
+ </div>
883
+ </dl>
884
+ </div>
885
+ <template #footer>
886
+ <div class="appium-navigation-footer">
887
+ <el-button :disabled="resolvingNavigation" @click="stayOnCurrentPage">留在当前页面继续录制</el-button>
888
+ <el-button :loading="resolvingNavigation" @click="saveAsBranchScript">保存为新用例</el-button>
889
+ <el-button type="primary" :loading="resolvingNavigation" @click="returnToPreviousPage">添加返回键继续录制</el-button>
890
+ </div>
891
+ </template>
892
+ </el-dialog>
893
+ </section>
894
+ </template>