android-midscene-automation 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +15 -1
  2. package/README.md +15 -1
  3. package/package.json +1 -1
  4. package/remote-agent/index.ts +50 -22
  5. package/server/analytics.test.ts +46 -0
  6. package/server/analytics.ts +24 -0
  7. package/server/api-safety-analytics.test.ts +57 -0
  8. package/server/appium-auto-save.test.ts +6 -0
  9. package/server/appium-recorder/appium-runner.ts +30 -9
  10. package/server/appium-recorder/appium-version.test.ts +55 -0
  11. package/server/appium-recorder/managed-appium.ts +37 -1
  12. package/server/appium-recorder/managed-cleanup.test.ts +38 -8
  13. package/server/appium-recorder/replay-lifecycle.test.ts +123 -0
  14. package/server/appium-recorder/routes.ts +8 -18
  15. package/server/appium-recorder/run-history.ts +11 -0
  16. package/server/config-access.test.ts +27 -0
  17. package/server/config-access.ts +27 -0
  18. package/server/http-api.ts +63 -54
  19. package/server/operations/repository.ts +8 -0
  20. package/server/remote-agents/protocol.ts +1 -0
  21. package/server/remote-agents/registry.test.ts +65 -0
  22. package/server/remote-agents/registry.ts +60 -5
  23. package/server/remote-agents/replay-integration.test.ts +79 -0
  24. package/server/remote-agents/routes.ts +12 -15
  25. package/server/request-body.test.ts +40 -0
  26. package/server/request-body.ts +48 -0
  27. package/src/App.vue +96 -758
  28. package/src/analytics/summary.ts +75 -0
  29. package/src/api.ts +11 -0
  30. package/src/appium-recorder/AppiumPage.vue +4 -1
  31. package/src/components/AppSidebar.vue +31 -0
  32. package/src/components/analytics/AnalysisCharts.vue +189 -0
  33. package/src/components/analytics/ReportAnalysis.vue +78 -0
  34. package/src/components/config/AppiumConfigPanel.vue +4 -1
  35. package/src/components/config/AppiumModelSettings.vue +1 -1
  36. package/src/components/config/BaseConfigPanel.vue +6 -2
  37. package/src/components/config/MidsceneConfigPanel.vue +4 -5
  38. package/src/components/help/HelpCenter.vue +185 -0
  39. package/src/composables/use-device-preview.ts +279 -0
  40. package/src/composables/use-initial-resource.ts +17 -0
  41. package/src/composables/use-script-execution.ts +277 -0
  42. package/src/config/credentials.ts +2 -0
  43. package/src/config/use-appium-auto-save.ts +22 -11
  44. package/src/config/use-model-config.ts +157 -0
  45. package/src/main.ts +2 -2
  46. package/src/pages/AnalysisPage.vue +60 -0
  47. package/src/pages/ConfigPage.vue +36 -5
  48. package/src/style.css +711 -123
  49. package/src/types.ts +7 -1
  50. package/src/components/help/HelpCenterDialog.vue +0 -160
  51. package/src/ui-refresh.css +0 -677
@@ -0,0 +1,185 @@
1
+ <script setup lang="ts">
2
+ import { computed, shallowRef, watch } from 'vue';
3
+ import { InfoFilled, QuestionFilled, Tools, Refresh, Monitor, Cpu, Cellphone } from '@element-plus/icons-vue';
4
+ import { getAppiumVersion } from '../../api';
5
+ import type { AppiumVersionInfo } from '../../types';
6
+ import changelog from '../../../CHANGELOG.md?raw';
7
+ import faq from '../../../FAQ.md?raw';
8
+ import aiRecognitionGuide from '../../../AI识别操作说明.md?raw';
9
+ import appiumGuide from '../../../流程节点操作说明.md?raw';
10
+ import operationGuide from '../../../USAGE.md?raw';
11
+ import packageText from '../../../package.json?raw';
12
+ import MarkdownDocument from './MarkdownDocument.vue';
13
+
14
+ type PackageInfo = { version?: string; dependencies?: Record<string, string> };
15
+ type OperationSection = { id: string; title: string; content: string };
16
+
17
+ const props = defineProps<{ active: boolean }>();
18
+ const appiumVersion = shallowRef<AppiumVersionInfo | null>(null);
19
+ const loadingVersion = shallowRef(false);
20
+ const versionError = shallowRef('');
21
+ let checkedAt = 0;
22
+ async function refreshVersion(force = true) {
23
+ if (!force && Date.now() - checkedAt < 60_000) return;
24
+ if (loadingVersion.value) return;
25
+ loadingVersion.value = true;
26
+ versionError.value = '';
27
+ try {
28
+ appiumVersion.value = await getAppiumVersion();
29
+ checkedAt = Date.now();
30
+ } catch {
31
+ versionError.value = '无法读取版本信息,请检查后端连接后刷新';
32
+ } finally {
33
+ loadingVersion.value = false;
34
+ }
35
+ }
36
+ watch(() => props.active, active => { if (active) void refreshVersion(false); }, { immediate: true });
37
+
38
+ const activeTab = shallowRef('about');
39
+ type OperationType = 'midscene' | 'appium' | 'aiRecognition' | 'faq';
40
+
41
+ const operationType = shallowRef<OperationType>('midscene');
42
+ const activeOperation = shallowRef('');
43
+ const packageInfo = JSON.parse(packageText) as PackageInfo;
44
+ const dependencyVersion = (name: string) => packageInfo.dependencies?.[name]?.match(/(\d+\.\d+\.\d+)/)?.[1] ?? '未知';
45
+ const appVersion = packageInfo.version ?? '未知';
46
+ const midsceneVersion = dependencyVersion('@midscene/core');
47
+
48
+ const versions = computed(() => [
49
+ { name: '应用版本', version: `v${appVersion}`, description: '移动自动化控制台', icon: Monitor },
50
+ { name: 'Midscene', version: `v${midsceneVersion}`, description: 'AI 驱动的界面理解与自动化', icon: Cpu },
51
+ { name: 'Appium', version: appiumVersion.value?.version ? `v${appiumVersion.value.version}` : loadingVersion.value ? '检测中…' : '未获取',
52
+ description: loadingVersion.value ? '正在读取当前环境版本' : versionError.value || appiumVersion.value?.message || '等待检测', icon: Cellphone },
53
+ ]);
54
+
55
+ const technologies = computed(() => [
56
+ { name: 'Vue', version: dependencyVersion('vue'), description: '界面与交互框架' },
57
+ { name: 'Element Plus', version: dependencyVersion('element-plus'), description: '界面组件库' },
58
+ { name: 'Vue Flow', version: dependencyVersion('@vue-flow/core'), description: 'Appium 流程画布' },
59
+ { name: 'OpenCV.js', version: dependencyVersion('@techstark/opencv-js'), description: '本地图像检测' },
60
+ { name: 'Vite', version: dependencyVersion('vite'), description: '开发与构建工具' },
61
+ { name: 'TypeScript', version: dependencyVersion('typescript'), description: '类型与工程开发语言' },
62
+ ]);
63
+
64
+ const extractOperationSections = (startHeading: string, endHeading: string) => {
65
+ const start = operationGuide.indexOf(`## ${startHeading}`);
66
+ const end = operationGuide.indexOf(`## ${endHeading}`, start + 1);
67
+ if (start < 0) return [];
68
+ const source = operationGuide.slice(start, end < 0 ? undefined : end);
69
+ const matches = [...source.matchAll(/^###\s+(.+)$/gm)];
70
+ return matches.map<OperationSection>((match, index) => ({
71
+ id: `${startHeading}-${index}`,
72
+ title: match[1]!.trim(),
73
+ content: source.slice(match.index! + match[0].length, matches[index + 1]?.index).trim(),
74
+ }));
75
+ };
76
+
77
+ const extractDocumentSections = (source: string, prefix: string) => {
78
+ const matches = [...source.matchAll(/^##\s+(.+)$/gm)];
79
+ return matches.map<OperationSection>((match, index) => ({
80
+ id: `${prefix}-${index}`,
81
+ title: match[1]!.trim(),
82
+ content: source.slice(match.index! + match[0].length, matches[index + 1]?.index).trim(),
83
+ }));
84
+ };
85
+
86
+ const midsceneOperations = computed(() => extractOperationSections('Midscene 操作说明', 'Appium 使用前准备'));
87
+ const appiumOperations = computed(() => extractDocumentSections(appiumGuide, 'appium'));
88
+ const aiRecognitionOperations = computed(() => extractDocumentSections(aiRecognitionGuide, 'ai-recognition'));
89
+ const faqOperations = computed(() => extractDocumentSections(faq, 'faq'));
90
+ const visibleOperations = computed(() => {
91
+ if (operationType.value === 'midscene') return midsceneOperations.value;
92
+ if (operationType.value === 'appium') return appiumOperations.value;
93
+ if (operationType.value === 'aiRecognition') return aiRecognitionOperations.value;
94
+ return faqOperations.value;
95
+ });
96
+
97
+ const switchOperationType = (value: OperationType) => {
98
+ operationType.value = value;
99
+ activeOperation.value = '';
100
+ };
101
+ </script>
102
+
103
+ <template>
104
+ <section class="help-center-page" aria-label="关于">
105
+ <el-tabs v-model="activeTab" class="help-center">
106
+ <el-tab-pane name="about">
107
+ <template #label><el-icon><InfoFilled /></el-icon><span>关于</span></template>
108
+ <div class="help-center__content">
109
+ <div class="help-center__version-heading">
110
+ <h2>版本信息</h2>
111
+ <el-button text :icon="Refresh" :loading="loadingVersion" @click="refreshVersion(true)">刷新版本</el-button>
112
+ </div>
113
+ <div class="help-center__versions" aria-live="polite" :aria-busy="loadingVersion">
114
+ <article v-for="item in versions" :key="item.name" class="help-center__version-card">
115
+ <div class="help-center__version-label"><el-icon><component :is="item.icon" /></el-icon><span>{{ item.name }}</span></div>
116
+ <strong>{{ item.version }}</strong>
117
+ <p>{{ item.description }}</p>
118
+ </article>
119
+ </div>
120
+ <h3 class="help-center__section-title">技术栈</h3>
121
+ <div class="help-center__technology-grid">
122
+ <div v-for="item in technologies" :key="item.name" class="help-center__technology">
123
+ <div><strong>{{ item.name }}</strong><el-tag size="small" effect="plain">{{ item.version }}</el-tag></div>
124
+ <p>{{ item.description }}</p>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ </el-tab-pane>
129
+ <el-tab-pane name="guide">
130
+ <template #label><el-icon><QuestionFilled /></el-icon><span>操作说明</span></template>
131
+ <div class="help-center__operations">
132
+ <el-radio-group :model-value="operationType" class="help-center__operation-types" @change="switchOperationType">
133
+ <el-radio-button value="midscene">Midscene</el-radio-button>
134
+ <el-radio-button value="appium">Appium</el-radio-button>
135
+ <el-radio-button value="aiRecognition">AI 识别</el-radio-button>
136
+ <el-radio-button value="faq">常见问题</el-radio-button>
137
+ </el-radio-group>
138
+ <el-collapse v-model="activeOperation" accordion class="help-center__operation-menu">
139
+ <el-collapse-item v-for="item in visibleOperations" :key="item.id" :name="item.id">
140
+ <template #title><span class="help-center__operation-title">{{ item.title }}</span></template>
141
+ <MarkdownDocument :content="item.content" />
142
+ </el-collapse-item>
143
+ </el-collapse>
144
+ </div>
145
+ </el-tab-pane>
146
+ <el-tab-pane name="changelog">
147
+ <template #label><el-icon><Tools /></el-icon><span>更新记录</span></template>
148
+ <div class="help-center__document"><MarkdownDocument :content="changelog" /></div>
149
+ </el-tab-pane>
150
+ </el-tabs>
151
+ </section>
152
+ </template>
153
+
154
+ <style scoped>
155
+ .help-center-page { padding: 12px 24px 24px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); }
156
+
157
+ .help-center :deep(.el-tabs__header) { margin-bottom: 0; }
158
+ .help-center :deep(.el-tabs__item) { display: inline-flex; align-items: center; gap: 6px; }
159
+ .help-center__content,
160
+ .help-center__document { padding: 22px 6px 18px 2px; }
161
+ .help-center__version-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
162
+ .help-center__version-heading h2 { margin: 0; font-size: 17px; font-weight: 600; }
163
+ .help-center__versions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
164
+ .help-center__version-card { min-width: 0; padding: 20px; border: 1px solid var(--ui-border); border-radius: 10px; background: var(--ui-surface); }
165
+ .help-center__version-label { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; color: var(--ui-text-secondary); font-size: 14px; }
166
+ .help-center__version-label .el-icon { padding: 8px; box-sizing: content-box; border-radius: 8px; background: var(--el-color-primary-light-9); color: var(--ui-primary); font-size: 18px; }
167
+ .help-center__version-card strong { display: block; color: var(--ui-text); font-size: 28px; font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; }
168
+ .help-center__version-card p { margin: 10px 0 0; color: var(--ui-text-secondary); font-size: 12px; line-height: 1.6; }
169
+ .help-center__section-title { margin: 24px 0 12px; color: var(--ui-text); font-size: 17px; }
170
+ .help-center__technology-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
171
+ .help-center__technology { padding: 14px 16px; border: 1px solid var(--ui-border); border-radius: 9px; background: #fff; }
172
+ .help-center__technology > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
173
+ .help-center__technology p { margin: 6px 0 0; color: var(--ui-text-secondary); font-size: 13px; }
174
+ .help-center__operations { padding: 18px 6px 18px 2px; }
175
+ .help-center__operation-types { margin-bottom: 16px; }
176
+ .help-center__operation-menu { border-top: 1px solid var(--ui-border); }
177
+ .help-center__operation-title { color: var(--ui-text); font-size: 14px; font-weight: 600; }
178
+ .help-center__operation-menu :deep(.el-collapse-item__header) { min-height: 48px; padding: 0 14px; border-right: 1px solid var(--ui-border); border-left: 1px solid var(--ui-border); }
179
+ .help-center__operation-menu :deep(.el-collapse-item__wrap) { border-right: 1px solid var(--ui-border); border-left: 1px solid var(--ui-border); }
180
+ .help-center__operation-menu :deep(.el-collapse-item__content) { padding: 4px 18px 18px; }
181
+ @media (max-width: 760px) {
182
+ .help-center__versions,
183
+ .help-center__technology-grid { grid-template-columns: 1fr; }
184
+ }
185
+ </style>
@@ -0,0 +1,279 @@
1
+ import { ref, shallowRef, reactive, watch, onMounted, onScopeDispose, type Ref } from 'vue';
2
+ import * as api from '../api';
3
+ import type { AndroidDevice, MenuKey } from '../types';
4
+
5
+ export function useDevicePreview(activeMenu: Ref<MenuKey>) {
6
+ const connectionError = shallowRef('');
7
+ const reconnecting = shallowRef(false);
8
+ const playgroundAvailable = ref(false);
9
+ const playgroundPreviewError = ref('');
10
+ const playgroundDeviceId = ref('');
11
+ const playgroundFrameUrl = ref('');
12
+ const playgroundFrameSignature = ref('');
13
+ const restartingPlaygroundPreview = shallowRef(false);
14
+ const devicePreviewUrl = ref('');
15
+ const devicePreviewMode = ref<'stream' | 'screenshot' | ''>('');
16
+ const backendOffline = shallowRef(false);
17
+ const androidDevices = ref<AndroidDevice[]>([]);
18
+ const deviceInterfaceSize = reactive({ width: 0, height: 0 });
19
+ const deviceDebug = reactive({
20
+ rawX: 0,
21
+ rawY: 0,
22
+ mappedX: 0,
23
+ mappedY: 0,
24
+ action: '',
25
+ status: 'idle',
26
+ message: '',
27
+ });
28
+ let playgroundPollTimer: number | null = null;
29
+ let devicePreviewTimer: number | null = null;
30
+
31
+ const isBackendFetchError = (error: unknown) => {
32
+ const message = error instanceof Error ? error.message : String(error || '');
33
+ return error instanceof TypeError || /Failed to fetch|NetworkError|Load failed/i.test(message);
34
+ };
35
+
36
+ const stopPlaygroundPollTimer = () => {
37
+ if (!playgroundPollTimer) return;
38
+ window.clearInterval(playgroundPollTimer);
39
+ playgroundPollTimer = null;
40
+ };
41
+
42
+ const loadAndroidDevices = async () => {
43
+ try {
44
+ const payload = await api.getAndroidDevices();
45
+ backendOffline.value = false;
46
+ connectionError.value = "";
47
+ androidDevices.value = payload.devices || [];
48
+ playgroundDeviceId.value = payload.currentDeviceId || '';
49
+ playgroundAvailable.value = androidDevices.value.some((item) => item.status === 'device');
50
+ } catch (error) {
51
+ if (isBackendFetchError(error)) {
52
+ markBackendOffline();
53
+ return;
54
+ }
55
+ connectionError.value = error instanceof Error ? error.message : '读取设备列表失败';
56
+ }
57
+ };
58
+
59
+ const loadDeviceInterface = async () => {
60
+ try {
61
+ const payload = await api.getAndroidDisplayInfo(playgroundDeviceId.value);
62
+ deviceInterfaceSize.width = Number(payload.width) || 0;
63
+ deviceInterfaceSize.height = Number(payload.height) || 0;
64
+ } catch {
65
+ deviceInterfaceSize.width = 0;
66
+ deviceInterfaceSize.height = 0;
67
+ }
68
+ };
69
+
70
+ const loadAdbPreview = () => {
71
+ if (backendOffline.value) return;
72
+ if (!playgroundDeviceId.value) {
73
+ playgroundFrameUrl.value = '';
74
+ devicePreviewUrl.value = '';
75
+ devicePreviewMode.value = '';
76
+ return;
77
+ }
78
+
79
+ playgroundFrameUrl.value = '';
80
+ devicePreviewUrl.value = `${api.APP_BASE}/api/android-preview?deviceId=${encodeURIComponent(playgroundDeviceId.value)}&t=${Date.now()}`;
81
+ devicePreviewMode.value = 'screenshot';
82
+ };
83
+
84
+ const refreshAdbPreviewAfterInput = () => {
85
+ if (devicePreviewMode.value === 'screenshot' && devicePreviewUrl.value) {
86
+ window.setTimeout(loadAdbPreview, 120);
87
+ }
88
+ };
89
+
90
+ const refreshDevicePreview = async () => {
91
+ if (devicePreviewMode.value === 'stream' && playgroundDeviceId.value) {
92
+ if (restartingPlaygroundPreview.value) return;
93
+ restartingPlaygroundPreview.value = true;
94
+ playgroundFrameUrl.value = '';
95
+ playgroundFrameSignature.value = '';
96
+ try {
97
+ await api.restartPlaygroundPreview({ deviceId: playgroundDeviceId.value });
98
+ restartingPlaygroundPreview.value = false;
99
+ await loadPlaygroundStatus();
100
+ } catch (error) {
101
+ playgroundPreviewError.value = error instanceof Error ? error.message : '重启实时预览失败';
102
+ } finally {
103
+ restartingPlaygroundPreview.value = false;
104
+ }
105
+ return;
106
+ }
107
+ loadAdbPreview();
108
+ };
109
+
110
+ const startDevicePreviewTimer = () => {
111
+ if (backendOffline.value) return;
112
+ if (devicePreviewTimer) return;
113
+ loadAdbPreview();
114
+ devicePreviewTimer = window.setInterval(loadAdbPreview, 1000);
115
+ };
116
+
117
+ const stopDevicePreviewTimer = () => {
118
+ if (!devicePreviewTimer) return;
119
+ window.clearInterval(devicePreviewTimer);
120
+ devicePreviewTimer = null;
121
+ };
122
+
123
+ const markBackendOffline = () => {
124
+ if (backendOffline.value) return;
125
+ backendOffline.value = true;
126
+ playgroundAvailable.value = false;
127
+ playgroundFrameUrl.value = '';
128
+ playgroundFrameSignature.value = '';
129
+ devicePreviewUrl.value = '';
130
+ devicePreviewMode.value = '';
131
+ playgroundPreviewError.value = '本地服务已断开,正在尝试重连。恢复连接后可继续编辑。';
132
+ connectionError.value = playgroundPreviewError.value;
133
+ stopDevicePreviewTimer();
134
+ };
135
+
136
+ const loadPlaygroundStatus = async () => {
137
+ if (backendOffline.value || restartingPlaygroundPreview.value) return;
138
+ if (!playgroundDeviceId.value) {
139
+ stopDevicePreviewTimer();
140
+ playgroundAvailable.value = false;
141
+ playgroundPreviewError.value = '';
142
+ playgroundFrameUrl.value = '';
143
+ playgroundFrameSignature.value = '';
144
+ devicePreviewUrl.value = '';
145
+ devicePreviewMode.value = '';
146
+ return;
147
+ }
148
+
149
+ try {
150
+ await loadDeviceInterface();
151
+ if (restartingPlaygroundPreview.value) return;
152
+ const payload = await api.getPlaygroundStatus();
153
+ if (restartingPlaygroundPreview.value) return;
154
+
155
+ const matchedDevice = !payload.deviceId || payload.deviceId === playgroundDeviceId.value;
156
+ const hasRealtimeStream = payload.previewKind === 'scrcpy' && payload.sessionConnected === true;
157
+ const hasPlayground = Boolean(payload.available) && matchedDevice && hasRealtimeStream;
158
+ playgroundAvailable.value = androidDevices.value.some((item) => item.status === 'device');
159
+ playgroundPreviewError.value =
160
+ matchedDevice && !hasRealtimeStream
161
+ ? payload.previewError || payload.setupState || ''
162
+ : `未找到设备 ${playgroundDeviceId.value} 对应的 Playground 实例`;
163
+
164
+ const nextSignature = `${payload.url || ''}::${playgroundDeviceId.value}`;
165
+ if (hasPlayground) {
166
+ stopDevicePreviewTimer();
167
+ if (nextSignature !== playgroundFrameSignature.value || !playgroundFrameUrl.value) {
168
+ playgroundFrameSignature.value = nextSignature;
169
+ playgroundFrameUrl.value = `${api.APP_BASE}/__android_playground__/?ts=${Date.now()}`;
170
+ }
171
+ devicePreviewUrl.value = '';
172
+ devicePreviewMode.value = 'stream';
173
+ playgroundPreviewError.value = '';
174
+ }
175
+
176
+ if (!hasPlayground) {
177
+ playgroundFrameUrl.value = '';
178
+ playgroundFrameSignature.value = '';
179
+ loadAdbPreview();
180
+ if (activeMenu.value === 'appium' || activeMenu.value === 'automation') startDevicePreviewTimer();
181
+ }
182
+ } catch (error) {
183
+ if (isBackendFetchError(error)) {
184
+ markBackendOffline();
185
+ return;
186
+ }
187
+ playgroundAvailable.value = false;
188
+ playgroundFrameUrl.value = '';
189
+ playgroundFrameSignature.value = '';
190
+ loadAdbPreview();
191
+ if (activeMenu.value === 'appium' || activeMenu.value === 'automation') startDevicePreviewTimer();
192
+ playgroundPreviewError.value = error instanceof Error ? error.message : '设备预览不可用';
193
+ }
194
+ };
195
+
196
+ const tapDevice = async (x: number, y: number) => {
197
+ deviceDebug.action = 'tap';
198
+ deviceDebug.status = 'pending';
199
+ try {
200
+ await api.tapAndroid({
201
+ deviceId: playgroundDeviceId.value,
202
+ x,
203
+ y,
204
+ });
205
+ deviceDebug.status = 'ok';
206
+ deviceDebug.message = 'tap sent';
207
+ refreshAdbPreviewAfterInput();
208
+ } catch (error) {
209
+ deviceDebug.status = 'error';
210
+ deviceDebug.message = error instanceof Error ? error.message : '点击失败';
211
+ throw error;
212
+ }
213
+ };
214
+
215
+ const swipeDevice = async (startX: number, startY: number, endX: number, endY: number, duration = 120) => {
216
+ deviceDebug.action = 'swipe';
217
+ deviceDebug.status = 'pending';
218
+ try {
219
+ await api.swipeAndroid({
220
+ deviceId: playgroundDeviceId.value,
221
+ startX,
222
+ startY,
223
+ endX,
224
+ endY,
225
+ duration,
226
+ });
227
+ deviceDebug.status = 'ok';
228
+ deviceDebug.message = 'swipe sent';
229
+ refreshAdbPreviewAfterInput();
230
+ } catch (error) {
231
+ deviceDebug.status = 'error';
232
+ deviceDebug.message = error instanceof Error ? error.message : '滑动失败';
233
+ throw error;
234
+ }
235
+ };
236
+
237
+ const switchAndroidDevice = async (deviceId: string) => {
238
+ try {
239
+ const payload = await api.setAndroidDevice({ deviceId });
240
+ playgroundDeviceId.value = payload.currentDeviceId || deviceId;
241
+ await loadPlaygroundStatus();
242
+ } catch (error) {
243
+ playgroundPreviewError.value = error instanceof Error ? error.message : '切换设备失败';
244
+ }
245
+ };
246
+
247
+ const triggerDeviceKey = async (keyCode: number) => {
248
+ if (!playgroundDeviceId.value) {
249
+ return;
250
+ }
251
+ try {
252
+ await api.sendAndroidKeyevent({
253
+ deviceId: playgroundDeviceId.value,
254
+ keyCode,
255
+ });
256
+ refreshAdbPreviewAfterInput();
257
+ } catch (error) {
258
+ playgroundPreviewError.value = error instanceof Error ? error.message : '操作失败';
259
+ }
260
+ };
261
+
262
+
263
+ const reconnect = async () => {
264
+ if (reconnecting.value) return;
265
+ reconnecting.value = true;
266
+ try { await loadAndroidDevices(); await loadPlaygroundStatus(); }
267
+ finally { reconnecting.value = false; }
268
+ };
269
+ watch(activeMenu, () => {
270
+ stopDevicePreviewTimer();
271
+ if (activeMenu.value === 'appium' || activeMenu.value === 'automation') void loadPlaygroundStatus();
272
+ });
273
+ onMounted(() => {
274
+ void reconnect();
275
+ playgroundPollTimer = window.setInterval(() => { void reconnect(); }, 5000);
276
+ });
277
+ onScopeDispose(() => { stopPlaygroundPollTimer(); stopDevicePreviewTimer(); });
278
+ return { backendOffline, connectionError, reconnecting, reconnect, playgroundAvailable, playgroundPreviewError, playgroundDeviceId, playgroundFrameUrl, devicePreviewUrl, androidDevices, deviceInterfaceSize, refreshDevicePreview, tapDevice, swipeDevice, switchAndroidDevice, triggerDeviceKey };
279
+ }
@@ -0,0 +1,17 @@
1
+ import { shallowRef } from 'vue';
2
+
3
+ // Retry only a failed resource, so reconnecting never replaces an edited form.
4
+ export function useInitialResource(name: string, fetch: () => Promise<unknown>) {
5
+ const loading = shallowRef(false);
6
+ const error = shallowRef('');
7
+ const ready = shallowRef(false);
8
+ async function load() {
9
+ if (loading.value || ready.value) return;
10
+ loading.value = true;
11
+ error.value = '';
12
+ try { await fetch(); ready.value = true; }
13
+ catch (reason) { error.value = reason instanceof Error ? reason.message : '请检查服务连接后重试'; }
14
+ finally { loading.value = false; }
15
+ }
16
+ return { name, loading, error, ready, load };
17
+ }