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