From b5a3fd761cfed27be7951a7517495aa87ea5be8b Mon Sep 17 00:00:00 2001 From: xudan Date: Thu, 25 Sep 2025 15:08:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9C=A8=E5=9C=BA=E6=99=AF=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E4=B8=AD=E6=B7=BB=E5=8A=A0=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E5=92=8C=E5=AF=BC=E5=87=BA=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20SMAP=20=E5=92=8C=20IRAY=20=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E7=BC=96=E8=BE=91=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E6=9E=84=E9=80=A0=E5=87=BD=E6=95=B0=E4=BB=A5=E6=8E=A5=E6=94=B6?= =?UTF-8?q?=E5=9C=BA=E6=99=AFID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/modal/MapConverterModal.vue | 348 +++++++++++++++++++++ src/hooks/useMapConversion.ts | 115 +++++++ src/pages/scene-editor.vue | 108 ++++--- 3 files changed, 527 insertions(+), 44 deletions(-) create mode 100644 src/components/modal/MapConverterModal.vue create mode 100644 src/hooks/useMapConversion.ts diff --git a/src/components/modal/MapConverterModal.vue b/src/components/modal/MapConverterModal.vue new file mode 100644 index 0000000..ddc24b8 --- /dev/null +++ b/src/components/modal/MapConverterModal.vue @@ -0,0 +1,348 @@ + + + + + diff --git a/src/hooks/useMapConversion.ts b/src/hooks/useMapConversion.ts new file mode 100644 index 0000000..1a83e7e --- /dev/null +++ b/src/hooks/useMapConversion.ts @@ -0,0 +1,115 @@ +import { message } from 'ant-design-vue'; +import axios from 'axios'; +import { ref } from 'vue'; + +import { downloadFile } from '../services/utils'; + +const vwedApi = '/vwedApi'; +const API_BASE_URL = '' + vwedApi + '/api/vwed-map-converter'; + +// Create a new Axios instance specifically for map conversion APIs +// to avoid conflicts with the global instance's baseURL. +const mapConverterHttp = axios.create(); + +export function useMapConversion() { + const isConverting = ref(false); + + const convertSmapToScene = async (smapFile: File): Promise => { + isConverting.value = true; + try { + const formData = new FormData(); + formData.append('smap_file', smapFile); + const response = await mapConverterHttp.post(`${API_BASE_URL}/smap-to-scene`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + // Note: axios wraps the response in a `data` object. + if (response.data.code === 200 || response.data.success) { + message.success('SMAP 转换为 Scene 成功!'); + return JSON.stringify(response.data.data, null, 2); + } else { + throw new Error(response.data.message || '转换失败'); + } + } catch (error: any) { + const errorMessage = error.response?.data?.message || error.message || 'SMAP 转换为 Scene 失败'; + message.error(errorMessage); + return null; + } finally { + isConverting.value = false; + } + }; + + const convertSceneToSmap = async (sceneJson: string, filename: string) => { + isConverting.value = true; + try { + const formData = new FormData(); + const sceneBlob = new Blob([sceneJson], { type: 'application/json' }); + formData.append('scene_file', sceneBlob, `${filename}.scene`); + + const response = await mapConverterHttp.post(`${API_BASE_URL}/scene-to-smap`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + + if (response.data.code === 200 || response.data.success) { + const smapContent = JSON.stringify(response.data?.data, null, 2); + const blob = new Blob([smapContent], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const outputFilename = response.data?.output_file + ? response.data.output_file.split(/[/\\]/).pop() + : `${filename}.smap`; + downloadFile(url, outputFilename); + URL.revokeObjectURL(url); + message.success('Scene 转换为 SMAP 并下载成功!'); + } else { + throw new Error(response.data.message || '转换失败'); + } + } catch (error: any) { + const errorMessage = error.response?.data?.message || error.message || 'Scene 转换为 SMAP 失败'; + message.error(errorMessage); + } finally { + isConverting.value = false; + } + }; + + const convertSceneToIray = async (sceneJson: string, filename: string) => { + isConverting.value = true; + try { + const formData = new FormData(); + const sceneBlob = new Blob([sceneJson], { type: 'application/json' }); + formData.append('scene_file', sceneBlob, `${filename}.scene`); + + const response = await mapConverterHttp.post(`${API_BASE_URL}/scene-to-iray`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + responseType: 'blob', + }); + + const blob = response?.data as unknown as Blob; + if (!blob || blob.size === 0) { + throw new Error('返回的二进制文件为空'); + } + + const disposition = response?.headers?.['content-disposition'] || ''; + let downloadFilename = `${filename}.zip`; // Default + const filenameMatch = disposition.match(/filename=([^;]+)/i); + if (filenameMatch && filenameMatch[1]) { + downloadFilename = decodeURIComponent(filenameMatch[1].replace(/"/g, '')); + } + + const url = URL.createObjectURL(blob); + downloadFile(url, downloadFilename); + URL.revokeObjectURL(url); + message.success('Scene 转换为 IRAY 并下载成功!'); + } catch (error: any) { + const errorMessage = error.response?.data?.message || error.message || 'Scene 转换为 IRAY 失败'; + message.error(errorMessage); + } finally { + isConverting.value = false; + } + }; + + return { + isConverting, + convertSmapToScene, + convertSceneToSmap, + convertSceneToIray, + }; +} diff --git a/src/pages/scene-editor.vue b/src/pages/scene-editor.vue index 40386aa..58df98f 100644 --- a/src/pages/scene-editor.vue +++ b/src/pages/scene-editor.vue @@ -8,6 +8,7 @@ import expandIcon from '../assets/icons/png/expand.png'; import foldIcon from '../assets/icons/png/fold.png'; import BatchEditToolbar from '../components/batch-edit-toolbar.vue'; import AutoCreateStorageModal from '../components/modal/auto-create-storage-modal.vue'; +import { useMapConversion } from '../hooks/useMapConversion'; import { EditorService } from '../services/editor.service'; import { useViewState } from '../services/useViewState'; import { decodeTextFile, downloadFile, selectFile, textToBlob } from '../services/utils'; @@ -21,6 +22,7 @@ type Props = { const props = defineProps(); const { t } = useI18n(); +const { isConverting, convertSmapToScene, convertSceneToSmap, convertSceneToIray } = useMapConversion(); //#region 接口 const readScene = async () => { @@ -35,7 +37,6 @@ const saveScene = async () => { const res = await saveSceneById(props.id, json); if (!res) return Promise.reject('保存失败'); - // 保存成功后重置历史记录状态,表示当前场景已保存 if (editor.value?.store) { editor.value.store.historyIndex = undefined; editor.value.store.histories = []; @@ -56,7 +57,6 @@ const pushScene = async () => { const title = ref(''); -// 监听标题变化,动态更新页面标题 onMounted(() => { document.title = '场景编辑器'; }); @@ -65,7 +65,6 @@ watch( () => props.id, async () => { await readScene(); - // 在场景加载完成后自动保存和恢复视图状态 await handleAutoSaveAndRestoreViewState(); }, { immediate: true, flush: 'post' }, @@ -73,11 +72,9 @@ watch( const container = shallowRef(); const editor = shallowRef(); -// 左侧边栏元素引用(用于 Ctrl/Cmd+F 聚焦搜索框) const leftSiderEl = shallowRef(); provide(EDITOR_KEY, editor); -// 自动生成库位对话框相关状态 const autoCreateStorageVisible = ref(false); const autoCreateStorageData = ref<{ areaName: string; @@ -88,19 +85,14 @@ const autoCreateStorageData = ref<{ }); onMounted(() => { - editor.value = new EditorService(container.value!); - - // 将 editor 存储到 store 中 + editor.value = new EditorService(container.value!, props.id); editorStore.setEditor(editor as ShallowRef); - - // 监听自动生成库位对话框事件 editor.value?.on('autoCreateStorageDialog', (data: any) => { autoCreateStorageData.value = data; autoCreateStorageVisible.value = true; }); }); -// 判断事件目标是否在可编辑区域(输入框/文本域/可编辑元素) const isTypingElement = (el: EventTarget | null) => { const node = el as HTMLElement | null; if (!node) return false; @@ -108,7 +100,6 @@ const isTypingElement = (el: EventTarget | null) => { return tag === 'input' || tag === 'textarea' || node.isContentEditable === true; }; -// Ctrl/Cmd + F 快捷键聚焦左侧搜索输入框 const focusFindKeydownHandler = (event: KeyboardEvent) => { const isFindKey = event.key === 'f' || event.key === 'F'; if ((event.ctrlKey || event.metaKey) && isFindKey && !isTypingElement(event.target)) { @@ -121,14 +112,15 @@ const focusFindKeydownHandler = (event: KeyboardEvent) => { input.focus(); try { input.select?.(); - } catch {} + } catch { + // + } } } } }; onMounted(() => { - // 监听 Ctrl/Cmd+F 聚焦左侧搜索框 document.addEventListener('keydown', focusFindKeydownHandler); }); @@ -139,7 +131,6 @@ onUnmounted(() => { const editable = ref(false); watch(editable, (v) => editor.value?.setState(v)); -// 左侧侧边栏折叠状态 const leftCollapsed = ref(false); const toPush = () => { @@ -161,12 +152,39 @@ const toPush = () => { }); }; +// --- Import Logic --- const importScene = async () => { const file = await selectFile('.scene'); if (!file || !file.size) return; const json = await decodeTextFile(file); - editor.value?.load(json, editable.value, undefined, true); // 第四个参数isImport=true + editor.value?.load(json, editable.value, undefined, true); }; + +const importSmap = async () => { + const file = await selectFile('.smap'); + if (!file || !file.size) return; + const sceneJson = await convertSmapToScene(file); + if (sceneJson) { + editor.value?.load(sceneJson, editable.value, undefined, true); + } +}; + +const importBinTask = async () => { + try { + const file = await selectFile('.xlsx,.xls'); + if (!file || !file.size) return; + const success = await importBinTaskExcel(props.id, file); + if (success) { + message.success('Bintask导入成功'); + } else { + message.error('Bintask导入失败'); + } + } catch (error: any) { + message.error(error.message || 'Bintask导入失败'); + } +}; + +// --- Export Logic --- const exportScene = () => { const json = editor.value?.save(); if (!json) return; @@ -177,21 +195,16 @@ const exportScene = () => { URL.revokeObjectURL(url); }; -// 导入Bintask Excel文件 -const importBinTask = async () => { - try { - const file = await selectFile('.xlsx,.xls'); - if (!file || !file.size) return; +const exportAsSmap = async () => { + const json = editor.value?.save(); + if (!json) return; + await convertSceneToSmap(json, title.value || 'unknown'); +}; - const success = await importBinTaskExcel(props.id, file); - if (success) { - message.success('Bintask导入成功'); - } else { - message.error('Bintask导入失败'); - } - } catch (error: any) { - message.error(error.message || 'Bintask导入失败'); - } +const exportAsIray = async () => { + const json = editor.value?.save(); + if (!json) return; + await convertSceneToIray(json, title.value || 'unknown'); }; const show = ref(true); @@ -218,13 +231,10 @@ const selectRobot = (id: string) => { editor.value?.inactive(); }; -// 视图状态管理 const { saveViewState, autoSaveAndRestoreViewState, isSaving } = useViewState(); -// 保存当前视图状态 const handleSaveViewState = async () => { if (!editor.value) return; - try { await saveViewState(editor.value, props.id); message.success('视图状态保存成功'); @@ -233,26 +243,21 @@ const handleSaveViewState = async () => { } }; -// 自动保存和恢复视图状态 const handleAutoSaveAndRestoreViewState = async () => { if (!editor.value) return; - await autoSaveAndRestoreViewState(editor.value, props.id); }; -// 返回到父级 iframe 的场景卡片 + const backToCards = () => { window.parent?.postMessage({ type: 'scene_return_to_cards' }, '*'); }; -// 处理自动生成库位确认 const handleAutoCreateStorageConfirm = (actionPoints: any[]) => { if (!editor.value) return; - editor.value.autoCreateStorageLocations(autoCreateStorageData.value.areaName, actionPoints); message.success(`已为 ${actionPoints.length} 个动作点自动生成库位`); }; -// 处理自动生成库位取消 const handleAutoCreateStorageCancel = () => { autoCreateStorageVisible.value = false; }; @@ -275,9 +280,26 @@ const handleAutoCreateStorageCancel = () => { {{ $t('启用编辑器') }} {{ $t('推送') }} - {{ $t('导入') }} - 导入Bintask - {{ $t('导出') }} + + + {{ $t('导入') }} + + + + + {{ $t('导出') }} + + @@ -325,10 +347,8 @@ const handleAutoCreateStorageCancel = () => { - -