android-midscene-automation 0.1.43 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/{AI_RECOGNITION_GUIDE.md → AI/350/257/206/345/210/253/346/223/215/344/275/234/350/257/264/346/230/216.md} +78 -6
  2. package/CHANGELOG.md +13 -0
  3. package/FAQ.md +8 -8
  4. package/README.md +143 -17
  5. package/USAGE.md +120 -8
  6. package/package.json +3 -2
  7. package/server/analytics.test.ts +46 -0
  8. package/server/analytics.ts +24 -0
  9. package/server/api-safety-analytics.test.ts +57 -0
  10. package/server/appium-auto-save.test.ts +6 -0
  11. package/server/appium-recorder/ai-recognition.test.ts +27 -0
  12. package/server/appium-recorder/ai-recognition.ts +16 -4
  13. package/server/appium-recorder/appium-runner.ts +11 -1
  14. package/server/appium-recorder/appium-version.test.ts +55 -0
  15. package/server/appium-recorder/managed-appium.ts +37 -1
  16. package/server/appium-recorder/repository.ts +6 -2
  17. package/server/appium-recorder/routes.ts +2 -12
  18. package/server/appium-recorder/run-history.ts +11 -0
  19. package/server/config-access.test.ts +27 -0
  20. package/server/config-access.ts +27 -0
  21. package/server/http-api.ts +63 -54
  22. package/server/operations/repository.ts +8 -0
  23. package/server/remote-agents/routes.ts +2 -12
  24. package/server/request-body.test.ts +40 -0
  25. package/server/request-body.ts +48 -0
  26. package/src/App.vue +98 -742
  27. package/src/analytics/summary.ts +75 -0
  28. package/src/api.ts +11 -0
  29. package/src/appium-recorder/AppiumPage.vue +4 -1
  30. package/src/appium-recorder/ai-recognition.ts +7 -0
  31. package/src/appium-recorder/components/AiRecognitionSettings.vue +27 -0
  32. package/src/appium-recorder/flow-labels.ts +1 -1
  33. package/src/appium-recorder/types.ts +2 -1
  34. package/src/components/AppSidebar.vue +31 -0
  35. package/src/components/analytics/AnalysisCharts.vue +189 -0
  36. package/src/components/analytics/ReportAnalysis.vue +78 -0
  37. package/src/components/config/AiPromptPresetsSettings.vue +55 -5
  38. package/src/components/config/AppiumConfigPanel.vue +4 -1
  39. package/src/components/config/AppiumModelSettings.vue +1 -1
  40. package/src/components/config/BaseConfigPanel.vue +6 -2
  41. package/src/components/config/MidsceneConfigPanel.vue +4 -5
  42. package/src/components/help/HelpCenter.vue +185 -0
  43. package/src/components/help/MarkdownDocument.vue +139 -0
  44. package/src/composables/use-device-preview.ts +279 -0
  45. package/src/composables/use-initial-resource.ts +17 -0
  46. package/src/composables/use-script-execution.ts +277 -0
  47. package/src/config/credentials.ts +2 -0
  48. package/src/config/use-appium-auto-save.ts +22 -11
  49. package/src/config/use-model-config.ts +157 -0
  50. package/src/main.ts +2 -2
  51. package/src/pages/AnalysisPage.vue +60 -0
  52. package/src/pages/ConfigPage.vue +36 -5
  53. package/src/style.css +716 -91
  54. package/src/types.ts +7 -1
  55. package//346/265/201/347/250/213/350/212/202/347/202/271/346/223/215/344/275/234/350/257/264/346/230/216.md +408 -0
  56. package/src/ui-refresh.css +0 -677
@@ -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
+ }
@@ -0,0 +1,277 @@
1
+ import { computed, ref, shallowRef, onScopeDispose, type Ref } from 'vue';
2
+ import { ElMessage, ElMessageBox } from 'element-plus';
3
+ import * as api from '../api';
4
+ import type { SavedScript, ExecutionStep, RunScriptStreamEvent } from '../types';
5
+
6
+ export function useScriptExecution(selectedScript: Ref<SavedScript | undefined>, selectedScriptId: Ref<string>, playgroundDeviceId: Ref<string>, loadSavedScripts: (id?: string) => Promise<void>, getMidsceneModelConfigError: () => string) {
7
+ const isRunningScript = ref(false);
8
+ const isStoppingScript = ref(false);
9
+ const executionLog = ref('');
10
+ const executionProcess = ref<ExecutionStep[]>([]);
11
+ const lastRunStatus = ref('');
12
+ const runStartedAt = shallowRef<number | null>(null);
13
+ const runningElapsedNow = shallowRef(0);
14
+ let executionProgressTimer: number | null = null;
15
+ let scriptRunAbortController: AbortController | null = null;
16
+ const buildExecutionProcess = (script: SavedScript): ExecutionStep[] => {
17
+ const activeSteps = (script.steps || []).filter((step) => step.enabled !== false);
18
+ if (!activeSteps.length) {
19
+ return [
20
+ {
21
+ id: 'script',
22
+ sourceIndex: 0,
23
+ title: script.promptTitle || script.name,
24
+ method: 'script',
25
+ prompt: script.filePath,
26
+ status: 'pending',
27
+ detail: '等待执行脚本',
28
+ },
29
+ ];
30
+ }
31
+
32
+ return activeSteps
33
+ .map((step, index) => ({
34
+ id: step.id || `${script.id}-${index}`,
35
+ sourceIndex: index,
36
+ title: step.label || `步骤 ${index + 1}`,
37
+ method: step.type,
38
+ prompt: step.prompt || step.value || '',
39
+ status: 'pending' as const,
40
+ detail: '等待前置步骤完成',
41
+ }))
42
+ .filter((step) => step.title !== '统一处理弹窗');
43
+ };
44
+
45
+ const markExecutionProcess = (success: boolean, output: string) => {
46
+ if (!executionProcess.value.length) return;
47
+ const runningIndex = executionProcess.value.findIndex((step) => step.status === 'running');
48
+ const errorIndex = executionProcess.value.findIndex((step) => step.status === 'error');
49
+ const failedIndex = success
50
+ ? -1
51
+ : runningIndex >= 0
52
+ ? runningIndex
53
+ : errorIndex >= 0
54
+ ? errorIndex
55
+ : Math.max(0, executionProcess.value.length - 1);
56
+ executionProcess.value = executionProcess.value.map((step, index) => {
57
+ if (success || index < failedIndex) {
58
+ return { ...step, status: 'success', detail: '执行完成' };
59
+ }
60
+ if (index === failedIndex) {
61
+ return {
62
+ ...step,
63
+ status: 'error',
64
+ detail: output || '执行失败',
65
+ };
66
+ }
67
+ return step;
68
+ });
69
+ };
70
+
71
+ const formatElapsed = (startedAt: number) => `${Math.max(0, Math.floor((Date.now() - startedAt) / 1000))} 秒`;
72
+ const formatCompactDuration = (milliseconds: number) => {
73
+ const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000));
74
+ const minutes = Math.floor(totalSeconds / 60);
75
+ const seconds = totalSeconds % 60;
76
+ return minutes ? `${minutes}m${seconds}s` : `${seconds}s`;
77
+ };
78
+ const runningElapsedText = computed(() => {
79
+ if (lastRunStatus.value !== '执行中' || !runStartedAt.value) return '';
80
+ return formatCompactDuration(runningElapsedNow.value - runStartedAt.value);
81
+ });
82
+
83
+ const updateRunningStepElapsed = () => {
84
+ if (runStartedAt.value) {
85
+ runningElapsedNow.value = Date.now();
86
+ }
87
+ executionProcess.value = executionProcess.value.map((step) => {
88
+ if (step.status !== 'running' || !step.startedAt) {
89
+ return step;
90
+ }
91
+ return {
92
+ ...step,
93
+ detail: `执行中,已耗时 ${formatElapsed(step.startedAt)}`,
94
+ };
95
+ });
96
+ };
97
+
98
+ const startExecutionProgressTimer = () => {
99
+ if (executionProgressTimer) {
100
+ window.clearInterval(executionProgressTimer);
101
+ }
102
+ executionProgressTimer = window.setInterval(updateRunningStepElapsed, 1000);
103
+ };
104
+
105
+ const stopExecutionProgressTimer = () => {
106
+ if (!executionProgressTimer) return;
107
+ window.clearInterval(executionProgressTimer);
108
+ executionProgressTimer = null;
109
+ };
110
+
111
+ const appendExecutionStepLog = (event: Extract<RunScriptStreamEvent, { type: 'step' }>) => {
112
+ const title = event.title || `步骤 ${event.index + 1}`;
113
+ if (event.status === 'start') {
114
+ executionLog.value += `[步骤 ${event.index + 1}] 开始:${title}\n`;
115
+ return;
116
+ }
117
+ if (event.status === 'success') {
118
+ executionLog.value += `[步骤 ${event.index + 1}] 完成:${title}\n`;
119
+ return;
120
+ }
121
+ executionLog.value += `[步骤 ${event.index + 1}] 失败:${title}${event.detail ? `\n${event.detail}` : ''}\n`;
122
+ };
123
+
124
+ const applyRunScriptEvent = (event: RunScriptStreamEvent) => {
125
+ if (event.type === 'output') {
126
+ executionLog.value += event.chunk;
127
+ if (!lastRunStatus.value) {
128
+ lastRunStatus.value = '执行中';
129
+ }
130
+ return;
131
+ }
132
+
133
+ if (event.type === 'error') {
134
+ executionLog.value += `${event.message}\n`;
135
+ markExecutionProcess(false, event.message);
136
+ lastRunStatus.value = '执行失败';
137
+ return;
138
+ }
139
+
140
+ if (event.type === 'step') {
141
+ appendExecutionStepLog(event);
142
+ executionProcess.value = executionProcess.value.map((step) => {
143
+ if (step.sourceIndex !== event.index) {
144
+ return step;
145
+ }
146
+
147
+ if (event.status === 'start') {
148
+ const startedAt = Date.now();
149
+ return { ...step, status: 'running', startedAt, detail: `执行中,已耗时 ${formatElapsed(startedAt)}` };
150
+ }
151
+ if (event.status === 'success') {
152
+ return { ...step, status: 'success', startedAt: undefined, detail: '执行完成' };
153
+ }
154
+ return {
155
+ ...step,
156
+ status: 'error',
157
+ startedAt: undefined,
158
+ detail: event.detail || '执行失败',
159
+ };
160
+ });
161
+ return;
162
+ }
163
+
164
+ if (event.type === 'done') {
165
+ if (event.output && !executionLog.value.trim()) {
166
+ executionLog.value = event.output;
167
+ }
168
+ markExecutionProcess(event.success, event.output || executionLog.value);
169
+ lastRunStatus.value = event.success ? '执行完成' : event.output.includes('脚本执行已停止') ? '已停止' : '执行失败';
170
+ }
171
+ };
172
+
173
+ const runSelectedScript = async () => {
174
+ if (isRunningScript.value) return;
175
+
176
+ if (!selectedScript.value) {
177
+ lastRunStatus.value = '请先选择脚本';
178
+ return;
179
+ }
180
+
181
+ const modelConfigError = getMidsceneModelConfigError();
182
+ if (modelConfigError) {
183
+ lastRunStatus.value = '模型配置不完整';
184
+ executionLog.value = modelConfigError;
185
+ ElMessage.error('Midscene 模型配置不完整');
186
+ return;
187
+ }
188
+
189
+ isRunningScript.value = true;
190
+ isStoppingScript.value = false;
191
+ runStartedAt.value = Date.now();
192
+ runningElapsedNow.value = runStartedAt.value;
193
+ executionLog.value = '';
194
+ lastRunStatus.value = '执行中';
195
+
196
+ try {
197
+ await loadSavedScripts(selectedScriptId.value);
198
+ const script = selectedScript.value;
199
+ if (!script) {
200
+ throw new Error('脚本不存在');
201
+ }
202
+
203
+ executionProcess.value = buildExecutionProcess(script);
204
+ if (executionProcess.value[0]) {
205
+ const startedAt = Date.now();
206
+ executionProcess.value[0].status = 'running';
207
+ executionProcess.value[0].startedAt = startedAt;
208
+ executionProcess.value[0].detail = `执行中,已耗时 ${formatElapsed(startedAt)}`;
209
+ }
210
+ startExecutionProgressTimer();
211
+
212
+ scriptRunAbortController = new AbortController();
213
+ await api.runScript(
214
+ {
215
+ code: script.code,
216
+ scriptName: script.name,
217
+ deviceId: playgroundDeviceId.value,
218
+ steps: script.steps || [],
219
+ signal: scriptRunAbortController.signal,
220
+ },
221
+ applyRunScriptEvent,
222
+ );
223
+
224
+ if (!lastRunStatus.value) {
225
+ lastRunStatus.value = '执行完成';
226
+ }
227
+ if (!executionLog.value.trim()) {
228
+ executionLog.value = '执行完成,无输出';
229
+ }
230
+ } catch (error) {
231
+ const isAbortError = error instanceof DOMException && error.name === 'AbortError';
232
+ executionLog.value = isAbortError ? '脚本执行已停止。' : error instanceof Error ? error.message : '执行失败';
233
+ markExecutionProcess(false, executionLog.value);
234
+ lastRunStatus.value = isAbortError ? '已停止' : '执行失败';
235
+ } finally {
236
+ stopExecutionProgressTimer();
237
+ isRunningScript.value = false;
238
+ isStoppingScript.value = false;
239
+ runStartedAt.value = null;
240
+ runningElapsedNow.value = 0;
241
+ scriptRunAbortController = null;
242
+ }
243
+ };
244
+
245
+ const stopSelectedScript = async () => {
246
+ if (!isRunningScript.value || isStoppingScript.value) return;
247
+
248
+ try {
249
+ await ElMessageBox.confirm(
250
+ '停止后当前脚本进程会立即终止,未完成步骤不会继续执行。确定停止吗?',
251
+ '确认停止执行',
252
+ {
253
+ confirmButtonText: '停止执行',
254
+ cancelButtonText: '继续执行',
255
+ type: 'warning',
256
+ confirmButtonClass: 'el-button--danger',
257
+ },
258
+ );
259
+ } catch {
260
+ return;
261
+ }
262
+
263
+ isStoppingScript.value = true;
264
+ lastRunStatus.value = '停止中';
265
+
266
+ try {
267
+ await api.stopScript();
268
+ } catch (error) {
269
+ executionLog.value += `${error instanceof Error ? error.message : '停止执行失败'}\n`;
270
+ scriptRunAbortController?.abort();
271
+ }
272
+ };
273
+
274
+
275
+ onScopeDispose(() => { stopExecutionProgressTimer(); scriptRunAbortController?.abort(); });
276
+ return { isRunningScript, isStoppingScript, executionLog, executionProcess, lastRunStatus, runningElapsedText, runSelectedScript, stopSelectedScript };
277
+ }
@@ -0,0 +1,2 @@
1
+ // The browser receives this marker instead of a saved API key.
2
+ export const SAVED_API_KEY = '••••••••';
@@ -1,5 +1,5 @@
1
1
  import { resolveReportSummary } from '../appium-recorder/report-summary';
2
- import { onScopeDispose, shallowRef, watch } from 'vue';
2
+ import { computed, onScopeDispose, shallowRef, watch } from 'vue';
3
3
  import type { ConfigForm } from '../types';
4
4
  import { isHexColor } from '../appium-recorder/flow-appearance';
5
5
  import { resolveAiDeduplication } from '../appium-recorder/ai-deduplication';
@@ -8,7 +8,10 @@ import { resolveAiDeduplication } from '../appium-recorder/ai-deduplication';
8
8
  export function useAppiumAutoSave(config: ConfigForm, save: (value: Omit<ConfigForm['appium'], 'model' | 'promptOptimizer'>) => Promise<unknown>, onError: (error: unknown) => void) {
9
9
  const saving = shallowRef(false);
10
10
  const status = shallowRef('');
11
- let initialized = false, saved = '', pending: string | undefined;
11
+ const saved = shallowRef('');
12
+ const initialized = shallowRef(false);
13
+ const failed = shallowRef(false);
14
+ let pending: string | undefined;
12
15
  let timer: ReturnType<typeof setTimeout> | undefined;
13
16
  // 模型只能通过“测试并保存”提交,自动保存仅观察其余设置。
14
17
  function snapshotSettings() {
@@ -19,7 +22,8 @@ export function useAppiumAutoSave(config: ConfigForm, save: (value: Omit<ConfigF
19
22
  customPresets: reportSummary.customPresets,
20
23
  } } : {}) });
21
24
  }
22
- function initialize() { saved = snapshotSettings(); initialized = true; }
25
+ function initialize() { saved.value = snapshotSettings(); initialized.value = true; }
26
+ const dirty = computed(() => initialized.value && snapshotSettings() !== saved.value);
23
27
  async function flush() {
24
28
  if (saving.value || pending === undefined) return;
25
29
  saving.value = true;
@@ -27,22 +31,27 @@ export function useAppiumAutoSave(config: ConfigForm, save: (value: Omit<ConfigF
27
31
  while (pending !== undefined) {
28
32
  const snapshot = pending;
29
33
  pending = undefined;
30
- if (snapshot === saved) { status.value = '已自动保存'; continue; }
34
+ if (snapshot === saved.value) { status.value = '已自动保存'; continue; }
31
35
  status.value = '正在自动保存…';
32
36
  try {
33
37
  await save(JSON.parse(snapshot));
34
- saved = snapshot;
38
+ saved.value = snapshot;
39
+ failed.value = false;
35
40
  if (snapshotSettings() === snapshot) status.value = '已自动保存';
36
41
  } catch (error) {
37
- status.value = '自动保存失败,请重新修改后重试';
42
+ status.value = '自动保存失败,修改已保留';
43
+ failed.value = true;
38
44
  onError(error);
39
- // 不无限重试失败请求;若用户期间有新修改,继续保存最新快照。
45
+ // 保留失败快照,等待显式重试或下次修改,避免离线时无限请求。
46
+ pending = pending ?? snapshot;
47
+ break;
40
48
  }
41
49
  }
42
50
  } finally { saving.value = false; }
43
51
  }
44
- watch(snapshotSettings, snapshot => {
45
- if (!initialized) return;
52
+ function queue(snapshot: string) {
53
+ if (!initialized.value) return;
54
+ failed.value = false;
46
55
  clearTimeout(timer);
47
56
  pending = undefined;
48
57
  const value = config.appium;
@@ -56,7 +65,9 @@ export function useAppiumAutoSave(config: ConfigForm, save: (value: Omit<ConfigF
56
65
  pending = snapshot;
57
66
  status.value = '等待自动保存…';
58
67
  timer = setTimeout(() => { void flush(); }, 350);
59
- }, { flush: 'sync' });
68
+ }
69
+ watch(snapshotSettings, queue, { flush: 'sync' });
70
+ function retry() { queue(snapshotSettings()); clearTimeout(timer); return flush(); }
60
71
  onScopeDispose(() => { clearTimeout(timer); pending = undefined; });
61
- return { initialize, saving, status };
72
+ return { initialize, saving, status, failed, dirty, retry };
62
73
  }