android-midscene-automation 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +9 -1
  2. package/README.md +9 -1
  3. package/package.json +1 -1
  4. package/server/analytics.test.ts +46 -0
  5. package/server/analytics.ts +24 -0
  6. package/server/api-safety-analytics.test.ts +57 -0
  7. package/server/appium-auto-save.test.ts +6 -0
  8. package/server/appium-recorder/appium-version.test.ts +55 -0
  9. package/server/appium-recorder/managed-appium.ts +37 -1
  10. package/server/appium-recorder/routes.ts +2 -12
  11. package/server/appium-recorder/run-history.ts +11 -0
  12. package/server/config-access.test.ts +27 -0
  13. package/server/config-access.ts +27 -0
  14. package/server/http-api.ts +63 -54
  15. package/server/operations/repository.ts +8 -0
  16. package/server/remote-agents/routes.ts +2 -12
  17. package/server/request-body.test.ts +40 -0
  18. package/server/request-body.ts +48 -0
  19. package/src/App.vue +96 -758
  20. package/src/analytics/summary.ts +75 -0
  21. package/src/api.ts +11 -0
  22. package/src/appium-recorder/AppiumPage.vue +4 -1
  23. package/src/components/AppSidebar.vue +31 -0
  24. package/src/components/analytics/AnalysisCharts.vue +189 -0
  25. package/src/components/analytics/ReportAnalysis.vue +78 -0
  26. package/src/components/config/AppiumConfigPanel.vue +4 -1
  27. package/src/components/config/AppiumModelSettings.vue +1 -1
  28. package/src/components/config/BaseConfigPanel.vue +6 -2
  29. package/src/components/config/MidsceneConfigPanel.vue +4 -5
  30. package/src/components/help/HelpCenter.vue +185 -0
  31. package/src/composables/use-device-preview.ts +279 -0
  32. package/src/composables/use-initial-resource.ts +17 -0
  33. package/src/composables/use-script-execution.ts +277 -0
  34. package/src/config/credentials.ts +2 -0
  35. package/src/config/use-appium-auto-save.ts +22 -11
  36. package/src/config/use-model-config.ts +157 -0
  37. package/src/main.ts +2 -2
  38. package/src/pages/AnalysisPage.vue +60 -0
  39. package/src/pages/ConfigPage.vue +36 -5
  40. package/src/style.css +711 -123
  41. package/src/types.ts +7 -1
  42. package/src/components/help/HelpCenterDialog.vue +0 -160
  43. package/src/ui-refresh.css +0 -677
package/src/App.vue CHANGED
@@ -1,18 +1,18 @@
1
1
  <script setup lang="ts">
2
- import { resolveReportSummary } from './appium-recorder/report-summary';
3
- import { isAiRecognitionModelConfigured } from './appium-recorder/ai-recognition';
4
- import { resolveAiDeduplication } from './appium-recorder/ai-deduplication';
5
- import { resolveAiPromptPresets } from './appium-recorder/ai-prompt-presets';
6
- import { computed, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from 'vue';
2
+ import { computed, defineAsyncComponent, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from 'vue';
7
3
  import { ElMessage, ElMessageBox } from 'element-plus';
8
4
  import {
9
5
  Check,
6
+ Close,
7
+ DataAnalysis,
10
8
  CopyDocument,
11
9
  Edit,
12
10
  Loading,
13
11
  Monitor,
14
12
  Operation,
15
- QuestionFilled,
13
+ InfoFilled,
14
+ Fold,
15
+ Expand,
16
16
  RefreshLeft,
17
17
  Setting,
18
18
  VideoPause,
@@ -30,31 +30,27 @@ import {
30
30
  buildScript,
31
31
  type ScriptStep,
32
32
  } from './script-generator';
33
- import {
34
- selectMidsceneModelProvider,
35
- type MidsceneModelProvider,
36
- } from './config/midscene-model-presets';
37
33
  import * as api from './api';
38
- import { useAppiumAutoSave } from './config/use-appium-auto-save';
39
- import AutomationPage from './pages/AutomationPage.vue';
40
- import AppiumPage from './appium-recorder/AppiumPage.vue';
41
- import { normalizeFlowBackground, normalizeFlowLineColor } from './appium-recorder/flow-appearance';
42
- import ConfigPage from './pages/ConfigPage.vue';
43
- import GeneratorPage from './pages/GeneratorPage.vue';
44
- import HelpCenterDialog from './components/help/HelpCenterDialog.vue';
34
+ import { useModelConfig } from './config/use-model-config';
35
+ import { useDevicePreview } from './composables/use-device-preview';
36
+ import { useScriptExecution } from './composables/use-script-execution';
37
+ import { useInitialResource } from './composables/use-initial-resource';
38
+ import AppSidebar from './components/AppSidebar.vue';
39
+ const AnalysisPage = defineAsyncComponent(() => import('./pages/AnalysisPage.vue'));
40
+ const AutomationPage = defineAsyncComponent(() => import('./pages/AutomationPage.vue'));
41
+ const AppiumPage = defineAsyncComponent(() => import('./appium-recorder/AppiumPage.vue'));
42
+ const ConfigPage = defineAsyncComponent(() => import('./pages/ConfigPage.vue'));
43
+ const GeneratorPage = defineAsyncComponent(() => import('./pages/GeneratorPage.vue'));
44
+ const HelpCenter = defineAsyncComponent(() => import('./components/help/HelpCenter.vue'));
45
45
  import type {
46
- AndroidDevice,
47
- ConfigForm,
48
46
  AppPreset,
49
- ExecutionStep,
50
47
  GeneratorMode,
51
48
  MenuKey,
52
- RunScriptStreamEvent,
53
49
  SavedScript,
54
50
  } from './types';
55
51
 
56
52
  const ACTIVE_MENU_STORAGE_KEY = 'android-midscene-automation:active-menu';
57
- const menuKeys: MenuKey[] = ['generator', 'automation', 'config', 'appium'];
53
+ const menuKeys: MenuKey[] = ['analysis', 'generator', 'automation', 'config', 'appium', 'about'];
58
54
  const storedMenu = window.localStorage.getItem(ACTIVE_MENU_STORAGE_KEY) as MenuKey | null;
59
55
  const activeMenu = ref<MenuKey>(storedMenu && menuKeys.includes(storedMenu) ? storedMenu : 'generator');
60
56
  const activeGeneratorMode = ref<GeneratorMode>('ai');
@@ -64,54 +60,17 @@ const promptPresetId = shallowRef(defaultPromptPresetId);
64
60
  const steps = ref<ScriptStep[]>([]);
65
61
  const errorMessage = ref('');
66
62
  const isGenerating = ref(false);
67
- const isSavingModelConfig = ref(false);
68
63
  const isSavingAppPreset = ref(false);
69
- const isRunningScript = ref(false);
70
- const isStoppingScript = ref(false);
71
64
  const showGeneratedCode = ref(false);
72
- const helpCenterVisible = shallowRef(false);
73
65
  const generatedCodeOverride = shallowRef<string | null>(null);
74
66
  const generatedCodeDraft = shallowRef('');
75
67
  const generatedCodeEditing = shallowRef(false);
76
68
  const generatedCodeEditSaving = shallowRef(false);
77
69
  const importingTestCase = shallowRef(false);
78
70
  const importedTestCaseFileName = shallowRef('');
79
- const testingModelKey = ref('');
80
- const playgroundAvailable = ref(false);
81
- const playgroundPreviewError = ref('');
82
- const playgroundDeviceId = ref('');
83
- const playgroundFrameUrl = ref('');
84
- const playgroundFrameSignature = ref('');
85
- const restartingPlaygroundPreview = shallowRef(false);
86
- const devicePreviewUrl = ref('');
87
- const devicePreviewMode = ref<'stream' | 'screenshot' | ''>('');
88
- const backendOffline = shallowRef(false);
89
- const androidDevices = ref<AndroidDevice[]>([]);
90
- const deviceInterfaceSize = reactive({ width: 0, height: 0 });
91
- const deviceDebug = reactive({
92
- rawX: 0,
93
- rawY: 0,
94
- mappedX: 0,
95
- mappedY: 0,
96
- action: '',
97
- status: 'idle',
98
- message: '',
99
- });
100
71
  const selectedScriptId = ref('');
101
- const executionLog = ref('');
102
- const executionProcess = ref<ExecutionStep[]>([]);
103
- const lastRunStatus = ref('');
104
- const runStartedAt = shallowRef<number | null>(null);
105
- const runningElapsedNow = shallowRef(0);
106
72
  const savedScripts = ref<SavedScript[]>([]);
107
73
  const appPresets = ref<AppPreset[]>([]);
108
- const modelTestStatus = reactive({
109
- midscene: '',
110
- scriptOptimizer: '',
111
- appium: '',
112
- promptOptimizer: '',
113
- });
114
- const aiRecognitionModelConfigured = shallowRef(false);
115
74
  const actionDialog = reactive({
116
75
  visible: false,
117
76
  title: '',
@@ -131,22 +90,7 @@ const codeDialog = reactive({
131
90
  editing: false,
132
91
  saving: false,
133
92
  });
134
- let playgroundPollTimer: number | null = null;
135
- let devicePreviewTimer: number | null = null;
136
- let executionProgressTimer: number | null = null;
137
93
  let aiGenerateAbortController: AbortController | null = null;
138
- let scriptRunAbortController: AbortController | null = null;
139
-
140
- const isBackendFetchError = (error: unknown) => {
141
- const message = error instanceof Error ? error.message : String(error || '');
142
- return error instanceof TypeError || /Failed to fetch|NetworkError|Load failed/i.test(message);
143
- };
144
-
145
- const stopPlaygroundPollTimer = () => {
146
- if (!playgroundPollTimer) return;
147
- window.clearInterval(playgroundPollTimer);
148
- playgroundPollTimer = null;
149
- };
150
94
 
151
95
  const form = reactive({
152
96
  promptTitle: '',
@@ -160,50 +104,29 @@ const appPresetForm = reactive({
160
104
  packageName: '',
161
105
  });
162
106
 
163
- const configForm = reactive<ConfigForm>({
164
- appium: { reportSummary: resolveReportSummary(), model: { baseUrl: '', apiKey: '', name: '' }, promptOptimizer: { model: { baseUrl: '', apiKey: '', name: '' } }, aiDeduplication: resolveAiDeduplication(), screenshotReport: false },
165
- runtime: {
166
- androidSdkPath: '',
167
- reportOutputPath: '',
168
- },
169
- midscene: {
170
- model: {
171
- provider: 'custom',
172
- baseUrl: '',
173
- apiKey: '',
174
- name: '',
175
- family: '',
176
- },
177
- env: {},
178
- },
179
- scriptOptimizer: {
180
- model: {
181
- baseUrl: '',
182
- apiKey: '',
183
- name: '',
184
- },
185
- },
186
- });
187
- const lastCustomMidsceneModel = reactive({
188
- baseUrl: '',
189
- apiKey: '',
190
- name: '',
191
- family: '',
192
- });
193
-
194
- const appiumAutoSave = useAppiumAutoSave(configForm, async value => {
195
- await api.saveAppiumConfig(value);
196
- }, error => {
197
- void ElMessageBox.alert(error instanceof Error ? error.message : 'Appium 配置自动保存失败', '自动保存失败', { type: 'error' }).catch(() => {});
198
- });
107
+ const { ready: configReady, configForm, appiumAutoSave, isSavingModelConfig, testingModelKey, modelTestStatus, aiRecognitionModelConfigured, loadConfig, updateMidsceneModelProvider, getMidsceneModelConfigError, saveModelConfig, testModel } = useModelConfig();
108
+ const { connectionError, reconnecting, reconnect, playgroundAvailable, playgroundPreviewError, playgroundDeviceId, playgroundFrameUrl, devicePreviewUrl, androidDevices, deviceInterfaceSize, refreshDevicePreview, tapDevice, swipeDevice, switchAndroidDevice, triggerDeviceKey } = useDevicePreview(activeMenu);
109
+ const { isRunningScript, isStoppingScript, executionLog, executionProcess, lastRunStatus, runningElapsedText, runSelectedScript, stopSelectedScript } = useScriptExecution(computed(() => selectedScript.value), selectedScriptId, playgroundDeviceId, id => loadSavedScripts(id), getMidsceneModelConfigError);
110
+ const visitedMenus = reactive(new Set<MenuKey>([activeMenu.value]));
111
+ const openMenus = ref<MenuKey[]>([...menuKeys]);
112
+ const visibleMenuItems = computed(() => openMenus.value.map(key => menuItems.find(item => item.key === key)!));
113
+ function closeMenu(key: MenuKey) {
114
+ if (openMenus.value.length === 1) return;
115
+ const index = openMenus.value.indexOf(key);
116
+ openMenus.value.splice(index, 1);
117
+ if (activeMenu.value === key) activeMenu.value = openMenus.value[Math.max(0, index - 1)];
118
+ }
199
119
 
120
+ const sidebarCollapsed = shallowRef(false);
200
121
  const menuItems = [
122
+ { key: 'analysis', label: '分析页', icon: DataAnalysis },
201
123
  { key: 'generator', label: '测试脚本生成', icon: Operation },
202
124
  { key: 'automation', label: '自动化测试', icon: Monitor },
125
+ { key: 'appium', label: 'Appium', icon: Monitor },
126
+ { key: 'config', label: '参数配置', icon: Setting },
127
+ { key: 'about', label: '关于', icon: InfoFilled },
203
128
  ] as const;
204
129
  const activeMenuLabel = computed(() => {
205
- if (activeMenu.value === 'appium') return 'Appium';
206
- if (activeMenu.value === 'config') return '参数配置';
207
130
  return menuItems.find((item) => item.key === activeMenu.value)?.label || 'Midscene';
208
131
  });
209
132
 
@@ -666,76 +589,6 @@ const generateWithModel = async () => {
666
589
  }
667
590
  };
668
591
 
669
- const loadConfig = async () => {
670
- const payload = await api.getConfig();
671
- Object.assign(configForm.runtime, payload.runtime || {});
672
- Object.assign(configForm.midscene.model, payload.midscene.model);
673
- Object.keys(configForm.midscene.env).forEach((key) => {
674
- delete configForm.midscene.env[key];
675
- });
676
- Object.assign(configForm.midscene.env, payload.midscene.env || {});
677
- Object.assign(configForm.scriptOptimizer.model, payload.scriptOptimizer.model);
678
- Object.assign(configForm.appium.model, payload.appium?.model || { baseUrl: '', apiKey: '', name: '' });
679
- Object.assign(configForm.appium.promptOptimizer!.model, payload.appium?.promptOptimizer?.model || { baseUrl: '', apiKey: '', name: '' });
680
- configForm.appium.screenshotReport = payload.appium?.screenshotReport === true;
681
- configForm.appium.reportSummary = resolveReportSummary(payload.appium?.reportSummary);
682
- configForm.appium.aiDeduplication = resolveAiDeduplication(payload.appium?.aiDeduplication);
683
- configForm.appium.aiPromptPresets = resolveAiPromptPresets(payload.appium?.aiPromptPresets);
684
- configForm.appium.flowBackgroundColor = normalizeFlowBackground(payload.appium?.flowBackgroundColor);
685
- configForm.appium.flowLineColor = normalizeFlowLineColor(payload.appium?.flowLineColor);
686
- appiumAutoSave.initialize();
687
- aiRecognitionModelConfigured.value = isAiRecognitionModelConfigured(configForm.appium.model);
688
- if (configForm.midscene.model.provider !== 'codex') {
689
- rememberCustomMidsceneModel();
690
- }
691
- };
692
-
693
- const rememberCustomMidsceneModel = () => {
694
- if (configForm.midscene.model.provider === 'codex') return;
695
- lastCustomMidsceneModel.baseUrl = configForm.midscene.model.baseUrl;
696
- lastCustomMidsceneModel.apiKey = configForm.midscene.model.apiKey;
697
- lastCustomMidsceneModel.name = configForm.midscene.model.name;
698
- lastCustomMidsceneModel.family = configForm.midscene.model.family;
699
- };
700
-
701
- const updateMidsceneModelProvider = (provider: MidsceneModelProvider) => {
702
- if (configForm.midscene.model.provider !== 'codex') {
703
- rememberCustomMidsceneModel();
704
- }
705
- Object.assign(configForm.midscene.model, selectMidsceneModelProvider(provider, configForm.midscene.model, lastCustomMidsceneModel));
706
- modelTestStatus.midscene = '';
707
- };
708
-
709
- const getMidsceneModelConfigError = () => {
710
- const model = configForm.midscene.model;
711
- const missing = [];
712
-
713
- if (!model.baseUrl.trim()) missing.push('Base URL');
714
- if (!model.name.trim()) missing.push('Model Name');
715
- if (!model.family.trim()) missing.push('Model Family');
716
- if (model.provider !== 'codex' && !model.apiKey.trim()) missing.push('API Key');
717
-
718
- if (!missing.length) return '';
719
- return `Midscene 模型配置不完整:缺少 ${missing.join('、')}。请在“参数配置 > Midscene配置 > Midscene 模型”中选择“使用 Codex”,或补齐自定义提供方后测试并保存。`;
720
- };
721
-
722
- const saveModelConfig = async () => {
723
- isSavingModelConfig.value = true;
724
- errorMessage.value = '';
725
- openActionDialog('保存参数配置');
726
- try {
727
- await api.saveConfig({ runtime: { ...configForm.runtime } });
728
- closeActionDialog();
729
- ElMessage.success('参数配置已保存');
730
- } catch (error) {
731
- const message = error instanceof Error ? error.message : '参数配置保存失败';
732
- errorMessage.value = message;
733
- failActionDialog(message);
734
- } finally {
735
- isSavingModelConfig.value = false;
736
- }
737
- };
738
-
739
592
  const loadAppPresets = async () => {
740
593
  const payload = await api.getAppPresets();
741
594
  appPresets.value = payload.apps || [];
@@ -801,511 +654,6 @@ const removeAppPreset = async (id: string) => {
801
654
  }
802
655
  };
803
656
 
804
- const testModel = async (key: 'midscene' | 'scriptOptimizer' | 'appium' | 'promptOptimizer') => {
805
- if (testingModelKey.value) return;
806
- testingModelKey.value = key;
807
- errorMessage.value = '';
808
- modelTestStatus[key] = '';
809
- const model = { ...(key === 'promptOptimizer'
810
- ? configForm.appium.promptOptimizer!.model
811
- : configForm[key].model) };
812
-
813
- try {
814
- const payload = await api.testModel({ modelKey: key, model, save: true });
815
- if (!payload.saved) throw new Error('模型未保存,请重试');
816
- const successMessage = `测试通过,配置已保存${payload.content ? `:${payload.content}` : ''}`;
817
- modelTestStatus[key] = '';
818
- if (key === 'appium') aiRecognitionModelConfigured.value = isAiRecognitionModelConfigured(model);
819
- void ElMessageBox.alert(successMessage, '模型测试成功', {
820
- type: 'success',
821
- confirmButtonText: '确定',
822
- }).catch(() => {});
823
- } catch (error) {
824
- modelTestStatus[key] = `未保存:${error instanceof Error ? error.message : '测试失败'}`;
825
- } finally {
826
- testingModelKey.value = '';
827
- }
828
- };
829
-
830
- const loadAndroidDevices = async () => {
831
- if (backendOffline.value) return;
832
- try {
833
- const payload = await api.getAndroidDevices();
834
- backendOffline.value = false;
835
- androidDevices.value = payload.devices || [];
836
- playgroundDeviceId.value = payload.currentDeviceId || '';
837
- playgroundAvailable.value = androidDevices.value.some((item) => item.status === 'device');
838
- } catch (error) {
839
- if (isBackendFetchError(error)) {
840
- markBackendOffline();
841
- return;
842
- }
843
- throw error;
844
- }
845
- };
846
-
847
- const loadDeviceInterface = async () => {
848
- try {
849
- const payload = await api.getAndroidDisplayInfo(playgroundDeviceId.value);
850
- deviceInterfaceSize.width = Number(payload.width) || 0;
851
- deviceInterfaceSize.height = Number(payload.height) || 0;
852
- } catch {
853
- deviceInterfaceSize.width = 0;
854
- deviceInterfaceSize.height = 0;
855
- }
856
- };
857
-
858
- const loadAdbPreview = () => {
859
- if (backendOffline.value) return;
860
- if (!playgroundDeviceId.value) {
861
- playgroundFrameUrl.value = '';
862
- devicePreviewUrl.value = '';
863
- devicePreviewMode.value = '';
864
- return;
865
- }
866
-
867
- playgroundFrameUrl.value = '';
868
- devicePreviewUrl.value = `${api.APP_BASE}/api/android-preview?deviceId=${encodeURIComponent(playgroundDeviceId.value)}&t=${Date.now()}`;
869
- devicePreviewMode.value = 'screenshot';
870
- };
871
-
872
- const refreshAdbPreviewAfterInput = () => {
873
- if (devicePreviewMode.value === 'screenshot' && devicePreviewUrl.value) {
874
- window.setTimeout(loadAdbPreview, 120);
875
- }
876
- };
877
-
878
- const refreshDevicePreview = async () => {
879
- if (devicePreviewMode.value === 'stream' && playgroundDeviceId.value) {
880
- if (restartingPlaygroundPreview.value) return;
881
- restartingPlaygroundPreview.value = true;
882
- playgroundFrameUrl.value = '';
883
- playgroundFrameSignature.value = '';
884
- try {
885
- await api.restartPlaygroundPreview({ deviceId: playgroundDeviceId.value });
886
- restartingPlaygroundPreview.value = false;
887
- await loadPlaygroundStatus();
888
- } catch (error) {
889
- playgroundPreviewError.value = error instanceof Error ? error.message : '重启实时预览失败';
890
- } finally {
891
- restartingPlaygroundPreview.value = false;
892
- }
893
- return;
894
- }
895
- loadAdbPreview();
896
- };
897
-
898
- const startDevicePreviewTimer = () => {
899
- if (backendOffline.value) return;
900
- if (devicePreviewTimer) return;
901
- loadAdbPreview();
902
- devicePreviewTimer = window.setInterval(loadAdbPreview, 1000);
903
- };
904
-
905
- const stopDevicePreviewTimer = () => {
906
- if (!devicePreviewTimer) return;
907
- window.clearInterval(devicePreviewTimer);
908
- devicePreviewTimer = null;
909
- };
910
-
911
- const markBackendOffline = () => {
912
- if (backendOffline.value) return;
913
- backendOffline.value = true;
914
- playgroundAvailable.value = false;
915
- playgroundFrameUrl.value = '';
916
- playgroundFrameSignature.value = '';
917
- devicePreviewUrl.value = '';
918
- devicePreviewMode.value = '';
919
- playgroundPreviewError.value = '本地服务已断开,请重新运行启动命令后刷新页面。';
920
- errorMessage.value = playgroundPreviewError.value;
921
- stopPlaygroundPollTimer();
922
- stopDevicePreviewTimer();
923
- };
924
-
925
- const loadPlaygroundStatus = async () => {
926
- if (backendOffline.value || restartingPlaygroundPreview.value) return;
927
- if (!playgroundDeviceId.value) {
928
- stopDevicePreviewTimer();
929
- playgroundAvailable.value = false;
930
- playgroundPreviewError.value = '';
931
- playgroundFrameUrl.value = '';
932
- playgroundFrameSignature.value = '';
933
- devicePreviewUrl.value = '';
934
- devicePreviewMode.value = '';
935
- return;
936
- }
937
-
938
- try {
939
- await loadDeviceInterface();
940
- if (restartingPlaygroundPreview.value) return;
941
- const payload = await api.getPlaygroundStatus();
942
- if (restartingPlaygroundPreview.value) return;
943
-
944
- const matchedDevice = !payload.deviceId || payload.deviceId === playgroundDeviceId.value;
945
- const hasRealtimeStream = payload.previewKind === 'scrcpy' && payload.sessionConnected === true;
946
- const hasPlayground = Boolean(payload.available) && matchedDevice && hasRealtimeStream;
947
- playgroundAvailable.value = androidDevices.value.some((item) => item.status === 'device');
948
- playgroundPreviewError.value =
949
- matchedDevice && !hasRealtimeStream
950
- ? payload.previewError || payload.setupState || ''
951
- : `未找到设备 ${playgroundDeviceId.value} 对应的 Playground 实例`;
952
-
953
- const nextSignature = `${payload.url || ''}::${playgroundDeviceId.value}`;
954
- if (hasPlayground) {
955
- stopDevicePreviewTimer();
956
- if (nextSignature !== playgroundFrameSignature.value || !playgroundFrameUrl.value) {
957
- playgroundFrameSignature.value = nextSignature;
958
- playgroundFrameUrl.value = `${api.APP_BASE}/__android_playground__/?ts=${Date.now()}`;
959
- }
960
- devicePreviewUrl.value = '';
961
- devicePreviewMode.value = 'stream';
962
- playgroundPreviewError.value = '';
963
- }
964
-
965
- if (!hasPlayground) {
966
- playgroundFrameUrl.value = '';
967
- playgroundFrameSignature.value = '';
968
- loadAdbPreview();
969
- if (activeMenu.value === 'appium' || activeMenu.value === 'automation') startDevicePreviewTimer();
970
- }
971
- } catch (error) {
972
- if (isBackendFetchError(error)) {
973
- markBackendOffline();
974
- return;
975
- }
976
- playgroundAvailable.value = false;
977
- playgroundFrameUrl.value = '';
978
- playgroundFrameSignature.value = '';
979
- loadAdbPreview();
980
- if (activeMenu.value === 'appium' || activeMenu.value === 'automation') startDevicePreviewTimer();
981
- playgroundPreviewError.value = error instanceof Error ? error.message : '设备预览不可用';
982
- }
983
- };
984
-
985
- const tapDevice = async (x: number, y: number) => {
986
- deviceDebug.action = 'tap';
987
- deviceDebug.status = 'pending';
988
- try {
989
- await api.tapAndroid({
990
- deviceId: playgroundDeviceId.value,
991
- x,
992
- y,
993
- });
994
- deviceDebug.status = 'ok';
995
- deviceDebug.message = 'tap sent';
996
- refreshAdbPreviewAfterInput();
997
- } catch (error) {
998
- deviceDebug.status = 'error';
999
- deviceDebug.message = error instanceof Error ? error.message : '点击失败';
1000
- throw error;
1001
- }
1002
- };
1003
-
1004
- const swipeDevice = async (startX: number, startY: number, endX: number, endY: number, duration = 120) => {
1005
- deviceDebug.action = 'swipe';
1006
- deviceDebug.status = 'pending';
1007
- try {
1008
- await api.swipeAndroid({
1009
- deviceId: playgroundDeviceId.value,
1010
- startX,
1011
- startY,
1012
- endX,
1013
- endY,
1014
- duration,
1015
- });
1016
- deviceDebug.status = 'ok';
1017
- deviceDebug.message = 'swipe sent';
1018
- refreshAdbPreviewAfterInput();
1019
- } catch (error) {
1020
- deviceDebug.status = 'error';
1021
- deviceDebug.message = error instanceof Error ? error.message : '滑动失败';
1022
- throw error;
1023
- }
1024
- };
1025
-
1026
- const switchAndroidDevice = async (deviceId: string) => {
1027
- try {
1028
- const payload = await api.setAndroidDevice({ deviceId });
1029
- playgroundDeviceId.value = payload.currentDeviceId || deviceId;
1030
- await loadPlaygroundStatus();
1031
- } catch (error) {
1032
- playgroundPreviewError.value = error instanceof Error ? error.message : '切换设备失败';
1033
- }
1034
- };
1035
-
1036
- const triggerDeviceKey = async (keyCode: number) => {
1037
- if (!playgroundDeviceId.value) {
1038
- return;
1039
- }
1040
- try {
1041
- await api.sendAndroidKeyevent({
1042
- deviceId: playgroundDeviceId.value,
1043
- keyCode,
1044
- });
1045
- refreshAdbPreviewAfterInput();
1046
- } catch (error) {
1047
- playgroundPreviewError.value = error instanceof Error ? error.message : '操作失败';
1048
- }
1049
- };
1050
-
1051
- const buildExecutionProcess = (script: SavedScript): ExecutionStep[] => {
1052
- const activeSteps = (script.steps || []).filter((step) => step.enabled !== false);
1053
- if (!activeSteps.length) {
1054
- return [
1055
- {
1056
- id: 'script',
1057
- sourceIndex: 0,
1058
- title: script.promptTitle || script.name,
1059
- method: 'script',
1060
- prompt: script.filePath,
1061
- status: 'pending',
1062
- detail: '等待执行脚本',
1063
- },
1064
- ];
1065
- }
1066
-
1067
- return activeSteps
1068
- .map((step, index) => ({
1069
- id: step.id || `${script.id}-${index}`,
1070
- sourceIndex: index,
1071
- title: step.label || `步骤 ${index + 1}`,
1072
- method: step.type,
1073
- prompt: step.prompt || step.value || '',
1074
- status: 'pending' as const,
1075
- detail: '等待前置步骤完成',
1076
- }))
1077
- .filter((step) => step.title !== '统一处理弹窗');
1078
- };
1079
-
1080
- const markExecutionProcess = (success: boolean, output: string) => {
1081
- if (!executionProcess.value.length) return;
1082
- const runningIndex = executionProcess.value.findIndex((step) => step.status === 'running');
1083
- const errorIndex = executionProcess.value.findIndex((step) => step.status === 'error');
1084
- const failedIndex = success
1085
- ? -1
1086
- : runningIndex >= 0
1087
- ? runningIndex
1088
- : errorIndex >= 0
1089
- ? errorIndex
1090
- : Math.max(0, executionProcess.value.length - 1);
1091
- executionProcess.value = executionProcess.value.map((step, index) => {
1092
- if (success || index < failedIndex) {
1093
- return { ...step, status: 'success', detail: '执行完成' };
1094
- }
1095
- if (index === failedIndex) {
1096
- return {
1097
- ...step,
1098
- status: 'error',
1099
- detail: output || '执行失败',
1100
- };
1101
- }
1102
- return step;
1103
- });
1104
- };
1105
-
1106
- const formatElapsed = (startedAt: number) => `${Math.max(0, Math.floor((Date.now() - startedAt) / 1000))} 秒`;
1107
- const formatCompactDuration = (milliseconds: number) => {
1108
- const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000));
1109
- const minutes = Math.floor(totalSeconds / 60);
1110
- const seconds = totalSeconds % 60;
1111
- return minutes ? `${minutes}m${seconds}s` : `${seconds}s`;
1112
- };
1113
- const runningElapsedText = computed(() => {
1114
- if (lastRunStatus.value !== '执行中' || !runStartedAt.value) return '';
1115
- return formatCompactDuration(runningElapsedNow.value - runStartedAt.value);
1116
- });
1117
-
1118
- const updateRunningStepElapsed = () => {
1119
- if (runStartedAt.value) {
1120
- runningElapsedNow.value = Date.now();
1121
- }
1122
- executionProcess.value = executionProcess.value.map((step) => {
1123
- if (step.status !== 'running' || !step.startedAt) {
1124
- return step;
1125
- }
1126
- return {
1127
- ...step,
1128
- detail: `执行中,已耗时 ${formatElapsed(step.startedAt)}`,
1129
- };
1130
- });
1131
- };
1132
-
1133
- const startExecutionProgressTimer = () => {
1134
- if (executionProgressTimer) {
1135
- window.clearInterval(executionProgressTimer);
1136
- }
1137
- executionProgressTimer = window.setInterval(updateRunningStepElapsed, 1000);
1138
- };
1139
-
1140
- const stopExecutionProgressTimer = () => {
1141
- if (!executionProgressTimer) return;
1142
- window.clearInterval(executionProgressTimer);
1143
- executionProgressTimer = null;
1144
- };
1145
-
1146
- const appendExecutionStepLog = (event: Extract<RunScriptStreamEvent, { type: 'step' }>) => {
1147
- const title = event.title || `步骤 ${event.index + 1}`;
1148
- if (event.status === 'start') {
1149
- executionLog.value += `[步骤 ${event.index + 1}] 开始:${title}\n`;
1150
- return;
1151
- }
1152
- if (event.status === 'success') {
1153
- executionLog.value += `[步骤 ${event.index + 1}] 完成:${title}\n`;
1154
- return;
1155
- }
1156
- executionLog.value += `[步骤 ${event.index + 1}] 失败:${title}${event.detail ? `\n${event.detail}` : ''}\n`;
1157
- };
1158
-
1159
- const applyRunScriptEvent = (event: RunScriptStreamEvent) => {
1160
- if (event.type === 'output') {
1161
- executionLog.value += event.chunk;
1162
- if (!lastRunStatus.value) {
1163
- lastRunStatus.value = '执行中';
1164
- }
1165
- return;
1166
- }
1167
-
1168
- if (event.type === 'error') {
1169
- executionLog.value += `${event.message}\n`;
1170
- markExecutionProcess(false, event.message);
1171
- lastRunStatus.value = '执行失败';
1172
- return;
1173
- }
1174
-
1175
- if (event.type === 'step') {
1176
- appendExecutionStepLog(event);
1177
- executionProcess.value = executionProcess.value.map((step) => {
1178
- if (step.sourceIndex !== event.index) {
1179
- return step;
1180
- }
1181
-
1182
- if (event.status === 'start') {
1183
- const startedAt = Date.now();
1184
- return { ...step, status: 'running', startedAt, detail: `执行中,已耗时 ${formatElapsed(startedAt)}` };
1185
- }
1186
- if (event.status === 'success') {
1187
- return { ...step, status: 'success', startedAt: undefined, detail: '执行完成' };
1188
- }
1189
- return {
1190
- ...step,
1191
- status: 'error',
1192
- startedAt: undefined,
1193
- detail: event.detail || '执行失败',
1194
- };
1195
- });
1196
- return;
1197
- }
1198
-
1199
- if (event.type === 'done') {
1200
- if (event.output && !executionLog.value.trim()) {
1201
- executionLog.value = event.output;
1202
- }
1203
- markExecutionProcess(event.success, event.output || executionLog.value);
1204
- lastRunStatus.value = event.success ? '执行完成' : event.output.includes('脚本执行已停止') ? '已停止' : '执行失败';
1205
- }
1206
- };
1207
-
1208
- const runSelectedScript = async () => {
1209
- if (isRunningScript.value) return;
1210
-
1211
- if (!selectedScript.value) {
1212
- lastRunStatus.value = '请先选择脚本';
1213
- return;
1214
- }
1215
-
1216
- const modelConfigError = getMidsceneModelConfigError();
1217
- if (modelConfigError) {
1218
- lastRunStatus.value = '模型配置不完整';
1219
- executionLog.value = modelConfigError;
1220
- ElMessage.error('Midscene 模型配置不完整');
1221
- return;
1222
- }
1223
-
1224
- isRunningScript.value = true;
1225
- isStoppingScript.value = false;
1226
- runStartedAt.value = Date.now();
1227
- runningElapsedNow.value = runStartedAt.value;
1228
- executionLog.value = '';
1229
- lastRunStatus.value = '执行中';
1230
-
1231
- try {
1232
- await loadSavedScripts(selectedScriptId.value);
1233
- const script = selectedScript.value;
1234
- if (!script) {
1235
- throw new Error('脚本不存在');
1236
- }
1237
-
1238
- executionProcess.value = buildExecutionProcess(script);
1239
- if (executionProcess.value[0]) {
1240
- const startedAt = Date.now();
1241
- executionProcess.value[0].status = 'running';
1242
- executionProcess.value[0].startedAt = startedAt;
1243
- executionProcess.value[0].detail = `执行中,已耗时 ${formatElapsed(startedAt)}`;
1244
- }
1245
- startExecutionProgressTimer();
1246
-
1247
- scriptRunAbortController = new AbortController();
1248
- await api.runScript(
1249
- {
1250
- code: script.code,
1251
- scriptName: script.name,
1252
- deviceId: playgroundDeviceId.value,
1253
- steps: script.steps || [],
1254
- signal: scriptRunAbortController.signal,
1255
- },
1256
- applyRunScriptEvent,
1257
- );
1258
-
1259
- if (!lastRunStatus.value) {
1260
- lastRunStatus.value = '执行完成';
1261
- }
1262
- if (!executionLog.value.trim()) {
1263
- executionLog.value = '执行完成,无输出';
1264
- }
1265
- } catch (error) {
1266
- const isAbortError = error instanceof DOMException && error.name === 'AbortError';
1267
- executionLog.value = isAbortError ? '脚本执行已停止。' : error instanceof Error ? error.message : '执行失败';
1268
- markExecutionProcess(false, executionLog.value);
1269
- lastRunStatus.value = isAbortError ? '已停止' : '执行失败';
1270
- } finally {
1271
- stopExecutionProgressTimer();
1272
- isRunningScript.value = false;
1273
- isStoppingScript.value = false;
1274
- runStartedAt.value = null;
1275
- runningElapsedNow.value = 0;
1276
- scriptRunAbortController = null;
1277
- }
1278
- };
1279
-
1280
- const stopSelectedScript = async () => {
1281
- if (!isRunningScript.value || isStoppingScript.value) return;
1282
-
1283
- try {
1284
- await ElMessageBox.confirm(
1285
- '停止后当前脚本进程会立即终止,未完成步骤不会继续执行。确定停止吗?',
1286
- '确认停止执行',
1287
- {
1288
- confirmButtonText: '停止执行',
1289
- cancelButtonText: '继续执行',
1290
- type: 'warning',
1291
- confirmButtonClass: 'el-button--danger',
1292
- },
1293
- );
1294
- } catch {
1295
- return;
1296
- }
1297
-
1298
- isStoppingScript.value = true;
1299
- lastRunStatus.value = '停止中';
1300
-
1301
- try {
1302
- await api.stopScript();
1303
- } catch (error) {
1304
- executionLog.value += `${error instanceof Error ? error.message : '停止执行失败'}\n`;
1305
- scriptRunAbortController?.abort();
1306
- }
1307
- };
1308
-
1309
657
  watch(
1310
658
  () => generatedCode.value,
1311
659
  () => {
@@ -1315,46 +663,35 @@ watch(
1315
663
  },
1316
664
  );
1317
665
 
1318
- watch(activeMenu, (menu) => {
666
+ const configResource = useInitialResource('参数配置', loadConfig);
667
+ const presetsResource = useInitialResource('应用预设', loadAppPresets);
668
+ const scriptsResource = useInitialResource('脚本列表', loadSavedScripts);
669
+ const initialResources = [configResource, presetsResource, scriptsResource];
670
+ const failedResources = computed(() => initialResources.filter(resource => resource.error.value));
671
+ watch(activeMenu, menu => {
1319
672
  window.localStorage.setItem(ACTIVE_MENU_STORAGE_KEY, menu);
1320
- if (menu === 'appium' || menu === 'automation') {
1321
- stopDevicePreviewTimer();
1322
- void loadPlaygroundStatus();
1323
- } else {
1324
- stopDevicePreviewTimer();
1325
- }
1326
- }, { immediate: true });
1327
-
673
+ visitedMenus.add(menu);
674
+ if (!openMenus.value.includes(menu)) openMenus.value.push(menu);
675
+ }, { flush: 'sync' });
676
+ const warnBeforeUnload = (event: BeforeUnloadEvent) => {
677
+ if (!appiumAutoSave.dirty.value) return;
678
+ event.preventDefault();
679
+ event.returnValue = '';
680
+ };
1328
681
  onMounted(async () => {
1329
- try {
1330
- await migrateLegacySavedScripts();
1331
- } catch (error) {
1332
- console.warn('脚本缓存迁移失败', error);
1333
- }
1334
-
1335
- await Promise.allSettled([loadConfig(), loadAppPresets(), loadAndroidDevices(), loadSavedScripts()]);
1336
- await loadPlaygroundStatus();
1337
- playgroundPollTimer = window.setInterval(() => {
1338
- if (backendOffline.value) {
1339
- stopPlaygroundPollTimer();
1340
- return;
1341
- }
1342
- void loadAndroidDevices();
1343
- void loadPlaygroundStatus();
1344
- }, 2500);
682
+ window.addEventListener('beforeunload', warnBeforeUnload);
683
+ try { await migrateLegacySavedScripts(); }
684
+ catch (error) { ElMessage.warning(error instanceof Error ? error.message : '脚本缓存迁移失败'); }
685
+ await Promise.all(initialResources.map(resource => resource.load()));
1345
686
  });
1346
-
1347
687
  onUnmounted(() => {
1348
- stopPlaygroundPollTimer();
1349
- stopDevicePreviewTimer();
1350
- stopExecutionProgressTimer();
688
+ window.removeEventListener('beforeunload', warnBeforeUnload);
1351
689
  aiGenerateAbortController?.abort();
1352
- scriptRunAbortController?.abort();
1353
690
  });
1354
691
  </script>
1355
692
 
1356
693
  <template>
1357
- <div class="layout">
694
+ <div class="layout" :class="{ 'layout--collapsed': sidebarCollapsed }">
1358
695
  <el-dialog
1359
696
  v-model="actionDialog.visible"
1360
697
  :title="actionDialog.title"
@@ -1430,50 +767,20 @@ onUnmounted(() => {
1430
767
  <pre v-else class="code-block code-dialog__body"><code>{{ codeDialog.code || '暂无代码' }}</code></pre>
1431
768
  </el-dialog>
1432
769
 
1433
- <aside class="layout-sidebar">
1434
- <div class="layout-sidebar__top">
1435
- <div class="layout-sidebar__logo">MS</div>
1436
- </div>
1437
- <el-menu :default-active="activeMenu" :default-openeds="['midscene']" class="layout-menu" @select="selectMenu">
1438
- <el-sub-menu index="midscene">
1439
- <template #title>
1440
- <el-icon><Operation /></el-icon>
1441
- <span>Midscene</span>
1442
- </template>
1443
- <el-menu-item v-for="item in menuItems" :key="item.key" :index="item.key">
1444
- <el-icon><component :is="item.icon" /></el-icon>
1445
- <span>{{ item.label }}</span>
1446
- </el-menu-item>
1447
- </el-sub-menu>
1448
- <el-menu-item index="appium">
1449
- <el-icon><Monitor /></el-icon>
1450
- <span>Appium</span>
1451
- </el-menu-item>
1452
- <el-menu-item index="config">
1453
- <el-icon><Setting /></el-icon>
1454
- <span>参数配置</span>
1455
- </el-menu-item>
1456
- </el-menu>
1457
- <div class="layout-sidebar__footer">
1458
- <el-tooltip content="帮助与版本" placement="right">
1459
- <button
1460
- type="button"
1461
- class="layout-sidebar__help"
1462
- aria-label="打开帮助与版本信息"
1463
- @click="helpCenterVisible = true"
1464
- >
1465
- <el-icon><QuestionFilled /></el-icon>
1466
- </button>
1467
- </el-tooltip>
1468
- </div>
1469
- </aside>
770
+ <AppSidebar :active-menu="activeMenu" :collapsed="sidebarCollapsed"
771
+ @select="selectMenu" />
1470
772
 
1471
773
  <div class="layout-main">
1472
774
  <header class="layout-header">
1473
775
  <div class="layout-header__title">
1474
- {{ activeMenuLabel }}
776
+ <el-button text class="layout-collapse" :icon="sidebarCollapsed ? Expand : Fold"
777
+ :aria-label="sidebarCollapsed ? '展开导航' : '收起导航'" :aria-expanded="!sidebarCollapsed"
778
+ @click="sidebarCollapsed = !sidebarCollapsed" />
779
+ <span class="layout-header__breadcrumb">工作空间 <span>/</span></span>
780
+ <span>{{ activeMenuLabel }}</span>
1475
781
  </div>
1476
782
  <div class="layout-header__actions">
783
+ <div id="appium-header-actions" v-show="activeMenu === 'appium'"></div>
1477
784
  <el-button
1478
785
  v-if="activeMenu === 'generator'"
1479
786
  type="primary"
@@ -1511,18 +818,42 @@ onUnmounted(() => {
1511
818
  </div>
1512
819
  </header>
1513
820
 
821
+ <nav class="layout-page-tabs" aria-label="页面导航">
822
+ <div v-for="item in visibleMenuItems" :key="item.key" class="layout-page-tab" :class="{ 'is-active': activeMenu === item.key }">
823
+ <button type="button" class="layout-page-tab__select"
824
+ :aria-current="activeMenu === item.key ? 'page' : undefined" @click="selectMenu(item.key)">
825
+ <el-icon><component :is="item.icon" /></el-icon>{{ item.label }}
826
+ </button>
827
+ <button type="button" class="layout-page-tab__close" :aria-label="`关闭${item.label}页签`"
828
+ :disabled="openMenus.length === 1" :title="openMenus.length === 1 ? '至少保留一个页签' : '关闭页签,编辑状态仍保留'" @click="closeMenu(item.key)">
829
+ <el-icon><Close /></el-icon>
830
+ </button>
831
+ </div>
832
+ </nav>
833
+
834
+ <div v-if="connectionError" class="connection-status" role="status">
835
+ <span>{{ connectionError }}</span><el-button link type="primary" :loading="reconnecting" @click="reconnect">重新连接</el-button>
836
+ </div>
837
+ <div v-for="resource in failedResources" :key="resource.name" class="connection-status" role="alert">
838
+ <span>{{ resource.name }}加载失败:{{ resource.error.value }}</span>
839
+ <el-button link type="primary" :loading="resource.loading.value" @click="resource.load">重试</el-button>
840
+ </div>
1514
841
  <main
1515
842
  class="page-body"
1516
843
  :class="{
1517
844
  'page-body--fixed': activeMenu === 'generator' && activeGeneratorMode === 'ai',
1518
845
  'page-body--appium': activeMenu === 'appium',
846
+ 'page-body--config': activeMenu === 'config',
1519
847
  }"
1520
848
  >
1521
- <el-alert v-if="activeMenu !== 'appium' && activeMenu !== 'config' && errorMessage" type="error" :closable="false" show-icon class="page-alert">
849
+ <el-alert v-if="activeMenu !== 'appium' && activeMenu !== 'config' && activeMenu !== 'about' && errorMessage" type="error" :closable="false" show-icon class="page-alert">
1522
850
  <template #title>{{ errorMessage }}</template>
1523
851
  </el-alert>
1524
852
 
853
+ <AnalysisPage v-if="visitedMenus.has('analysis')" v-show="activeMenu === 'analysis'" :active="activeMenu === 'analysis'" />
854
+
1525
855
  <GeneratorPage
856
+ v-if="visitedMenus.has('generator')"
1526
857
  v-show="activeMenu === 'generator'"
1527
858
  v-model:mode="activeGeneratorMode"
1528
859
  v-model:source-prompt="sourcePrompt"
@@ -1550,7 +881,10 @@ onUnmounted(() => {
1550
881
  @clear-all="clearGeneratorPage"
1551
882
  />
1552
883
 
884
+ <div v-if="activeMenu === 'config' && !configReady && !configResource.error.value" class="page-loading" role="status">正在加载参数配置…</div>
885
+
1553
886
  <ConfigPage
887
+ v-if="visitedMenus.has('config') && configReady"
1554
888
  v-show="activeMenu === 'config'"
1555
889
  :config-form="configForm"
1556
890
  :app-presets="appPresets"
@@ -1560,6 +894,8 @@ onUnmounted(() => {
1560
894
  :is-saving-app-preset="isSavingAppPreset"
1561
895
  :model-test-status="modelTestStatus"
1562
896
  :appium-save-status="appiumAutoSave.status.value"
897
+ :appium-save-failed="appiumAutoSave.failed.value"
898
+ @retry-appium-save="appiumAutoSave.retry"
1563
899
  @test-model="testModel"
1564
900
  @save-model-config="saveModelConfig"
1565
901
  @save-app-preset="saveAppPreset"
@@ -1570,6 +906,7 @@ onUnmounted(() => {
1570
906
  />
1571
907
 
1572
908
  <AutomationPage
909
+ v-if="visitedMenus.has('automation')"
1573
910
  v-show="activeMenu === 'automation'"
1574
911
  v-model:selected-script-id="selectedScriptId"
1575
912
  :saved-scripts="savedScripts"
@@ -1600,6 +937,7 @@ onUnmounted(() => {
1600
937
  />
1601
938
 
1602
939
  <AppiumPage
940
+ v-if="visitedMenus.has('appium')"
1603
941
  v-show="activeMenu === 'appium'"
1604
942
  :active="activeMenu === 'appium'"
1605
943
  :app-presets="appPresets"
@@ -1623,9 +961,9 @@ onUnmounted(() => {
1623
961
  :swipe-device="swipeDevice"
1624
962
  />
1625
963
 
964
+ <HelpCenter v-if="visitedMenus.has('about')" v-show="activeMenu === 'about'" :active="activeMenu === 'about'" />
1626
965
  </main>
1627
966
  </div>
1628
967
 
1629
- <HelpCenterDialog v-model="helpCenterVisible" />
1630
968
  </div>
1631
969
  </template>