Compare commits

...

5 Commits

5 changed files with 843 additions and 44 deletions

View File

@ -0,0 +1,176 @@
<script setup lang="ts">
import { CloudUploadOutlined } from '@ant-design/icons-vue';
import type { UploadProps } from 'ant-design-vue';
import { message } from 'ant-design-vue';
import { computed, reactive, type Ref, ref, watch } from 'vue';
// --- Type Definitions ---
interface IrayParams {
mapWidth: number | null;
mapHeight: number | null;
xAttrMin: number | null;
yAttrMin: number | null;
}
export type ExportFormat = 'smap' | 'iray';
export interface ExportConfirmPayload {
format: ExportFormat;
smapFile: File;
irayParams?: IrayParams;
}
// --- Props and Emits ---
type Props = {
visible: boolean;
};
const props = defineProps<Props>();
const emit = defineEmits(['update:visible', 'confirm']);
// --- Component State ---
const format = ref<ExportFormat>('smap');
const fileList: Ref<UploadProps['fileList']> = ref([]);
const irayFormState = reactive<IrayParams>({
mapWidth: 94744,
mapHeight: 79960,
xAttrMin: -46762,
yAttrMin: -63362,
});
// --- Computed Properties ---
const isOkButtonDisabled = computed(() => {
if (!fileList.value || fileList.value.length === 0) {
return true;
}
if (format.value === 'iray') {
return Object.values(irayFormState).some((v) => v === null || v === undefined);
}
return false;
});
// --- Logic ---
const resetState = () => {
format.value = 'smap';
fileList.value = [];
Object.assign(irayFormState, {
mapWidth: 94744,
mapHeight: 79960,
xAttrMin: -46762,
yAttrMin: -63362,
});
};
watch(
() => props.visible,
(newVal) => {
if (!newVal) {
// Use setTimeout to allow closing animation to finish before reset
setTimeout(resetState, 300);
}
},
);
const handleOk = () => {
if (fileList.value && fileList.value.length > 0) {
const smapFile = fileList.value[0].originFileObj as File;
const payload: ExportConfirmPayload = {
format: format.value,
smapFile,
};
if (format.value === 'iray') {
payload.irayParams = { ...irayFormState };
}
emit('confirm', payload);
emit('update:visible', false);
} else {
message.error('请选择一个基础 SMAP 文件');
}
};
const handleCancel = () => {
emit('update:visible', false);
};
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
fileList.value = [file];
return false; // Prevent automatic upload
};
</script>
<template>
<a-modal
:visible="props.visible"
title="导出为其他格式"
ok-text="开始导出"
cancel-text="取消"
:ok-button-props="{ disabled: isOkButtonDisabled }"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form layout="vertical">
<a-form-item label="选择导出格式">
<a-radio-group v-model:value="format" button-style="solid" style="display: flex; width: 100%">
<a-radio-button value="smap" style="flex: 1; text-align: center; white-space: nowrap"
>导出为 SMAP (.smap)</a-radio-button
>
<a-radio-button value="iray" style="flex: 1; text-align: center; white-space: nowrap"
>导出为 IRAY (.zip)</a-radio-button
>
</a-radio-group>
</a-form-item>
<a-form-item label="选择基础 SMAP 文件">
<a-upload-dragger
v-model:fileList="fileList"
name="file"
:multiple="false"
accept=".smap"
:before-upload="beforeUpload"
>
<p class="ant-upload-drag-icon">
<cloud-upload-outlined />
</p>
<p class="ant-upload-text">点击或拖拽 SMAP 文件到此区域</p>
<p class="ant-upload-hint">必须提供一个基础 .smap 文件用于导出</p>
</a-upload-dragger>
</a-form-item>
<div v-if="format === 'iray'">
<a-divider>IRAY 转换参数 (必填)</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="地图宽度 (mm)">
<a-input-number v-model:value="irayFormState.mapWidth" placeholder="例如: 94744" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="地图高度 (mm)">
<a-input-number v-model:value="irayFormState.mapHeight" placeholder="例如: 79960" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="X最小值 (mm)">
<a-input-number v-model:value="irayFormState.xAttrMin" placeholder="例如: -46762" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="Y最小值 (mm)">
<a-input-number v-model:value="irayFormState.yAttrMin" placeholder="例如: -63362" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
</div>
</a-form>
</a-modal>
</template>
<style lang="scss" scoped>
.ant-radio-button-wrapper-checked {
background-color: #0dbb8a !important;
border: #0dbb8a;
}
.ant-radio-button-wrapper:hover {
color: #0dbb8a !important;
}
</style>

View File

@ -0,0 +1,75 @@
<script setup lang="ts">
import { ref, watch, type Ref } from 'vue';
import { CloudUploadOutlined } from '@ant-design/icons-vue';
import type { UploadProps } from 'ant-design-vue';
import { message } from 'ant-design-vue';
type Props = {
visible: boolean;
};
const props = defineProps<Props>();
const emit = defineEmits(['update:visible', 'confirm']);
const fileList: Ref<UploadProps['fileList']> = ref([]);
const keepProperties = ref(false);
watch(
() => props.visible,
(newVal) => {
if (!newVal) {
// Reset state when modal is closed
fileList.value = [];
keepProperties.value = false;
}
},
);
const handleOk = () => {
if (fileList.value && fileList.value.length > 0) {
const smapFile = fileList.value[0].originFileObj as File;
emit('confirm', { smapFile, keepProperties: keepProperties.value });
emit('update:visible', false);
} else {
message.error('请选择一个 SMAP 文件');
}
};
const handleCancel = () => {
emit('update:visible', false);
};
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
// Ensure only one file is in the list
fileList.value = [file];
// Prevent automatic upload
return false;
};
</script>
<template>
<a-modal
:visible="props.visible"
title="从 SMAP 导入场景"
ok-text="开始导入"
cancel-text="取消"
:ok-button-props="{ disabled: !fileList || fileList.length === 0 }"
@ok="handleOk"
@cancel="handleCancel"
>
<a-upload-dragger
v-model:fileList="fileList"
name="file"
:multiple="false"
accept=".smap"
:before-upload="beforeUpload"
>
<p class="ant-upload-drag-icon">
<cloud-upload-outlined />
</p>
<p class="ant-upload-text">点击或拖拽 SMAP 文件到此区域</p>
<p class="ant-upload-hint">仅支持单个 .smap 文件的导入</p>
</a-upload-dragger>
<a-checkbox v-model:checked="keepProperties" style="margin-top: 16px"> 保留原属性 </a-checkbox>
</a-modal>
</template>

View File

@ -0,0 +1,348 @@
<template>
<a-modal :visible="visible" title="地图格式转换" @cancel="handleCancel" :footer="null" width="600px" height="600px">
<div class="converter-container">
<a-form layout="vertical">
<a-form-item label="选择转换类型">
<a-radio-group v-model:value="convertType" button-style="solid" style="width: 100%">
<a-radio-button value="scene" style="width: 33.33%">SMAP转Scene</a-radio-button>
<a-radio-button value="iray" style="width: 33.33%">SMAP转IRAY</a-radio-button>
<a-radio-button value="scene-to-smap" style="width: 33.33%">Scene更新SMAP</a-radio-button>
</a-radio-group>
</a-form-item>
<a-form-item :label="fileInputLabel">
<a-upload-dragger
:before-upload="() => false"
@change="handleFile1Change"
:file-list="fileList1"
:max-count="1"
>
<p class="ant-upload-drag-icon">
<inbox-outlined />
</p>
<p class="ant-upload-text">点击或拖拽文件到这里上传</p>
</a-upload-dragger>
</a-form-item>
<a-form-item :label="sceneInputLabel">
<a-upload-dragger
:before-upload="() => false"
@change="handleFile2Change"
:file-list="fileList2"
:max-count="1"
>
<p class="ant-upload-drag-icon">
<inbox-outlined />
</p>
<p class="ant-upload-text">点击或拖拽文件到这里上传</p>
</a-upload-dragger>
</a-form-item>
<div v-if="convertType === 'iray'" class="iray-params">
<p class="param-section-title">地图参数设置 (IRAY转换必填)</p>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="地图宽度 (mm)">
<a-input-number v-model:value="irayParams.mapWidth" placeholder="94744" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="地图高度 (mm)">
<a-input-number v-model:value="irayParams.mapHeight" placeholder="79960" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="X最小值 (mm)">
<a-input-number v-model:value="irayParams.xAttrMin" placeholder="-46762" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="Y最小值 (mm)">
<a-input-number v-model:value="irayParams.yAttrMin" placeholder="-63362" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
</div>
<a-form-item>
<a-button type="primary" @click="handleConvert" :loading="loading" block>{{
convertType === 'iray' ? '上传、转换并下载' : '上传并转换'
}}</a-button>
</a-form-item>
<a-form-item v-if="downloadUrl">
<a-button type="primary" block @click="handleDownload">下载转换后的文件</a-button>
</a-form-item>
<a-form-item label="转换结果" v-if="result">
<a-textarea :value="result" :rows="8" read-only />
</a-form-item>
</a-form>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { InboxOutlined } from '@ant-design/icons-vue';
import http from '@core/http';
import { message, type UploadChangeParam } from 'ant-design-vue';
import { computed, reactive, ref, watch } from 'vue';
/**
* 调用地图转换API
* @param convertType 转换类型
* @param formData 包含文件和参数的FormData
* @returns 转换结果
*/
const convertMap = async (convertType: string, formData: FormData) => {
let apiUrl = '';
switch (convertType) {
case 'iray':
apiUrl = import.meta.env.VWED_API_BASE_URL + '/api/vwed-map-converter/smap-to-iray';
break;
case 'scene-to-smap':
apiUrl = import.meta.env.VWED_API_BASE_URL + '/api/vwed-map-converter/scene-to-smap';
break;
default:
apiUrl = import.meta.env.VWED_API_BASE_URL + '/api/vwed-map-converter/smap-to-scene';
}
// iray
if (convertType === 'iray') {
const response = await http.post(apiUrl, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
responseType: 'blob',
});
const blob = response?.data as unknown as Blob;
if (!blob) {
throw new Error('返回的二进制文件为空');
}
const disposition = response?.headers?.['content-disposition'] || '';
let filename = 'map_package.zip'; //
const filenameMatch = disposition.match(/filename=([^;]+)/i);
if (filenameMatch && filenameMatch[1]) {
filename = decodeURIComponent(filenameMatch[1].replace(/"/g, ''));
}
return { blob, filename };
} else {
// JSON
return http.post<any>(apiUrl, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
};
// Modal visibility
const visible = ref(false);
const handleCancel = () => {
visible.value = false;
};
const showModal = () => {
visible.value = true;
resetState();
};
defineExpose({ showModal });
// Component state
const loading = ref(false);
const convertType = ref('scene');
const file1 = ref<File | null>(null);
const file2 = ref<File | null>(null);
const fileList1 = ref([]);
const fileList2 = ref([]);
const result = ref('');
const downloadUrl = ref('');
const downloadFilename = ref('');
const irayParams = reactive({
mapWidth: 94744,
mapHeight: 79960,
xAttrMin: -46762,
yAttrMin: -63362,
});
// UI labels based on convertType
const fileInputLabel = computed(() => {
if (convertType.value === 'scene-to-smap') {
return '选择 .scene 文件 (必选)';
}
return '选择 .smap 文件 (必选)';
});
const sceneInputLabel = computed(() => {
if (convertType.value === 'iray') {
return '选择 .scene 文件 (必选)';
}
if (convertType.value === 'scene-to-smap') {
return '选择 .smap 文件 (必选)';
}
return '选择 .scene 文件 (可选)';
});
// File handling
const handleFile1Change = (info: UploadChangeParam) => {
file1.value = (info.file as unknown as File) || null;
fileList1.value = info.fileList as never[];
};
const handleFile2Change = (info: UploadChangeParam) => {
file2.value = (info.file as unknown as File) || null;
fileList2.value = info.fileList as never[];
};
const resetState = () => {
loading.value = false;
convertType.value = 'scene';
file1.value = null;
file2.value = null;
fileList1.value = [];
fileList2.value = [];
result.value = '';
downloadUrl.value = '';
downloadFilename.value = '';
};
watch(convertType, () => {
file1.value = null;
file2.value = null;
fileList1.value = [];
fileList2.value = [];
result.value = '';
revokeDownloadUrl();
downloadUrl.value = '';
downloadFilename.value = '';
});
const revokeDownloadUrl = () => {
if (downloadUrl.value) {
URL.revokeObjectURL(downloadUrl.value);
}
};
const handleDownload = () => {
if (!downloadUrl.value) return;
const link = document.createElement('a');
link.href = downloadUrl.value;
link.download = downloadFilename.value;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// Conversion logic
const handleConvert = async () => {
// Validation
if (!file1.value) {
message.error('请选择第一个文件');
return;
}
if ((convertType.value === 'iray' || convertType.value === 'scene-to-smap') && !file2.value) {
message.error('请选择第二个文件');
return;
}
if (convertType.value === 'iray') {
if (!irayParams.mapWidth || !irayParams.mapHeight || !irayParams.xAttrMin || !irayParams.yAttrMin) {
message.error('IRAY转换需要填写所有地图参数');
return;
}
}
// Revoke previous URL before creating a new one
revokeDownloadUrl();
const formData = new FormData();
if (convertType.value === 'scene-to-smap') {
formData.append('scene_file', file1.value);
if (file2.value) formData.append('smap_file', file2.value);
} else {
formData.append('smap_file', file1.value);
if (file2.value) formData.append('scene_file', file2.value);
}
if (convertType.value === 'iray') {
formData.append('map_width', String(irayParams.mapWidth));
formData.append('map_height', String(irayParams.mapHeight));
formData.append('x_attr_min', String(irayParams.xAttrMin));
formData.append('y_attr_min', String(irayParams.yAttrMin));
}
loading.value = true;
result.value = '正在上传并转换...';
downloadUrl.value = '';
try {
// SMAPIRAYAPI
if (convertType.value === 'iray') {
// convertMapresponseTypeblobfilename
const { blob, filename } = await convertMap(convertType.value, formData);
console.log('blob', blob);
if (blob && blob.size > 0) {
revokeDownloadUrl(); // URL
downloadUrl.value = URL.createObjectURL(blob);
downloadFilename.value = filename;
result.value = `转换成功!文件 ${filename} 已开始自动下载。`;
message.success('IRAY地图包已生成将自动开始下载。');
//
handleDownload();
//
setTimeout(() => {
downloadUrl.value = '';
}, 100);
} else {
throw new Error('返回的二进制文件为空');
}
} else {
// JSON
const data = await convertMap(convertType.value, formData);
if (data.code == 200 || data.success) {
result.value = '转换成功!';
if (convertType.value === 'scene' && data.data) {
const sceneContent = JSON.stringify(data.data, null, 2);
const blob = new Blob([sceneContent], { type: 'application/json' });
downloadUrl.value = URL.createObjectURL(blob);
downloadFilename.value = data.data.output_filename || 'converted.scene';
} else if (convertType.value === 'scene-to-smap' && data.data) {
const smapContent = JSON.stringify(data.data?.data, null, 2);
const blob = new Blob([smapContent], { type: 'application/json' });
downloadUrl.value = URL.createObjectURL(blob);
downloadFilename.value = data.data?.output_file ? data.data.output_file.split(/[/\\]/).pop() : 'updated.smap';
}
} else {
result.value = `转换失败: ${data.message || '未知错误'}`;
message.error(`转换失败: ${data.message || '未知错误'}`);
}
}
} catch (err) {
result.value = '请求失败:' + err;
message.error('转换失败');
} finally {
loading.value = false;
}
};
</script>
<style lang="less" scoped>
.converter-container {
padding: 16px;
max-height: calc(700px - 110px);
overflow-y: auto;
}
.iray-params {
margin-top: 18px;
padding: 16px;
border-radius: 8px;
margin-bottom: 16px;
}
.param-section-title {
font-weight: 600;
color: #495057;
margin-bottom: 10px;
font-size: 14px;
}
.download-link {
color: inherit !important;
text-decoration: none;
}
.download-link:hover {
color: inherit !important;
}
</style>

View File

@ -0,0 +1,149 @@
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';
const mapConverterHttp = axios.create();
export function useMapConversion() {
const isConverting = ref(false);
/**
* Imports or updates a Scene using an SMAP file.
* Calls the smap-to-scene API endpoint.
* @param smapFile The .smap file.
* @param sceneFile Optional .scene file for updates. If not provided, a new scene is created.
* @returns The resulting scene JSON string, or null if failed.
*/
const convertSmapToScene = async (smapFile: File, sceneFile?: File): Promise<string | null> => {
isConverting.value = true;
const isUpdate = !!sceneFile;
const actionText = isUpdate ? '更新' : '新建';
try {
const formData = new FormData();
formData.append('smap_file', smapFile);
if (sceneFile) {
formData.append('scene_file', sceneFile);
}
const response = await mapConverterHttp.post<any>(`${API_BASE_URL}/smap-to-scene`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
if (response.data.code === 200 || response.data.success) {
message.success(`通过 SMAP ${actionText} Scene 成功!`);
return JSON.stringify(response.data.data, null, 2);
} else {
throw new Error(response.data.message || `${actionText}失败`);
}
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || `通过 SMAP ${actionText} Scene 失败`;
message.error(errorMessage);
return null;
} finally {
isConverting.value = false;
}
};
/**
* Exports a Scene to an SMAP file format.
* Requires both a scene and a base smap file.
* Calls the scene-to-smap API endpoint.
* @param sceneFile The .scene file to export.
* @param smapFile The base .smap file to update.
* @param filename The base name for the downloaded file.
*/
const exportSceneToSmap = async (sceneFile: File, smapFile: File, filename: string) => {
isConverting.value = true;
try {
const formData = new FormData();
formData.append('scene_file', sceneFile);
formData.append('smap_file', smapFile);
const response = await mapConverterHttp.post<any>(`${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('导出到 SMAP 文件成功!');
} else {
throw new Error(response.data.message || '导出失败');
}
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || '导出到 SMAP 文件失败';
message.error(errorMessage);
} finally {
isConverting.value = false;
}
};
const convertSceneToIray = async (
sceneJson: string,
smapFile: File,
filename: string,
irayParams?: { mapWidth: number; mapHeight: number; xAttrMin: number; yAttrMin: number },
) => {
isConverting.value = true;
try {
const formData = new FormData();
const sceneBlob = new Blob([sceneJson], { type: 'application/json' });
formData.append('scene_file', sceneBlob, `${filename}.scene`);
formData.append('smap_file', smapFile);
// Append IRAY parameters if they exist
if (irayParams) {
formData.append('map_width', String(irayParams.mapWidth));
formData.append('map_height', String(irayParams.mapHeight));
formData.append('x_attr_min', String(irayParams.xAttrMin));
formData.append('y_attr_min', String(irayParams.yAttrMin));
}
const response = await mapConverterHttp.post(`${API_BASE_URL}/smap-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`;
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,
exportSceneToSmap,
convertSceneToIray,
};
}

View File

@ -8,6 +8,9 @@ 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 ExportConverterModal, { type ExportConfirmPayload } from '../components/modal/ExportConverterModal.vue';
import ImportSmapModal from '../components/modal/ImportSmapModal.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 +24,7 @@ type Props = {
const props = defineProps<Props>();
const { t } = useI18n();
const { isConverting, convertSmapToScene, exportSceneToSmap, convertSceneToIray } = useMapConversion();
//#region
const readScene = async () => {
@ -35,7 +39,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 +59,6 @@ const pushScene = async () => {
const title = ref<string>('');
//
onMounted(() => {
document.title = '场景编辑器';
});
@ -65,7 +67,6 @@ watch(
() => props.id,
async () => {
await readScene();
//
await handleAutoSaveAndRestoreViewState();
},
{ immediate: true, flush: 'post' },
@ -73,11 +74,9 @@ watch(
const container = shallowRef<HTMLDivElement>();
const editor = shallowRef<EditorService>();
// Ctrl/Cmd+F
const leftSiderEl = shallowRef<HTMLElement>();
provide(EDITOR_KEY, editor);
//
const autoCreateStorageVisible = ref(false);
const autoCreateStorageData = ref<{
areaName: string;
@ -88,19 +87,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<EditorService>);
//
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 +102,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 +114,15 @@ const focusFindKeydownHandler = (event: KeyboardEvent) => {
input.focus();
try {
input.select?.();
} catch {}
} catch {
//
}
}
}
}
};
onMounted(() => {
// Ctrl/Cmd+F
document.addEventListener('keydown', focusFindKeydownHandler);
});
@ -139,7 +133,6 @@ onUnmounted(() => {
const editable = ref<boolean>(false);
watch(editable, (v) => editor.value?.setState(v));
//
const leftCollapsed = ref<boolean>(false);
const toPush = () => {
@ -161,12 +154,58 @@ const toPush = () => {
});
};
// --- Import/Update Logic ---
const importScene = async () => {
const file = await selectFile('.scene');
if (!file || !file.size) return;
if (!file) 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 importSmapModalVisible = ref(false);
const handleImportSmapConfirm = async ({ smapFile, keepProperties }: { smapFile: File; keepProperties: boolean }) => {
let sceneJson: string | null = null;
if (keepProperties) {
// Update mode
const currentSceneJson = editor.value?.save();
if (!currentSceneJson) {
message.error('无法获取当前场景数据,请确保场景不为空');
return;
}
const sceneBlob = new Blob([currentSceneJson], { type: 'application/json' });
const sceneFile = new File([sceneBlob], `${title.value || 'current'}.scene`, {
type: 'application/json',
});
sceneJson = await convertSmapToScene(smapFile, sceneFile);
} else {
// Create mode
sceneJson = await convertSmapToScene(smapFile);
}
if (sceneJson) {
editor.value?.load(sceneJson, editable.value, undefined, true);
}
};
const importBinTask = async () => {
try {
const file = await selectFile('.xlsx,.xls');
if (!file) 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 exportModalVisible = ref(false);
const exportScene = () => {
const json = editor.value?.save();
if (!json) return;
@ -177,20 +216,22 @@ const exportScene = () => {
URL.revokeObjectURL(url);
};
// Bintask Excel
const importBinTask = async () => {
try {
const file = await selectFile('.xlsx,.xls');
if (!file || !file.size) return;
const handleExportConfirm = async (payload: ExportConfirmPayload) => {
const sceneJson = editor.value?.save();
if (!sceneJson) {
message.error('无法获取当前场景数据,请确保场景不为空');
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导入失败');
if (payload.format === 'smap') {
const sceneBlob = new Blob([sceneJson], { type: 'application/json' });
const sceneFile = new File([sceneBlob], `${title.value || 'current'}.scene`, {
type: 'application/json',
});
await exportSceneToSmap(sceneFile, payload.smapFile, payload.smapFile.name.replace(/\.smap$/i, ''));
} else if (payload.format === 'iray') {
// @ts-ignore
await convertSceneToIray(sceneJson, payload.smapFile, title.value || 'unknown', payload.irayParams);
}
};
@ -218,13 +259,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 +271,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 +308,25 @@ const handleAutoCreateStorageCancel = () => {
<span>{{ $t('启用编辑器') }}</span>
</a-button>
<a-button @click="toPush">{{ $t('推送') }}</a-button>
<a-button @click="importScene">{{ $t('导入') }}</a-button>
<a-button @click="importBinTask">导入Bintask</a-button>
<a-button @click="exportScene">{{ $t('导出') }}</a-button>
<a-dropdown-button @click="importScene" :loading="isConverting">
{{ $t('导入') }}
<template #overlay>
<a-menu>
<a-menu-item key="1" @click="importSmapModalVisible = true">导入其他格式 (.smap)</a-menu-item>
<a-menu-item key="2" @click="importBinTask">导入 Bintask (.xlsx)</a-menu-item>
</a-menu>
</template>
</a-dropdown-button>
<a-dropdown-button @click="exportScene" :loading="isConverting">
{{ $t('导出') }}
<template #overlay>
<a-menu>
<a-menu-item key="1" @click="exportModalVisible = true">导出为其他格式</a-menu-item>
</a-menu>
</template>
</a-dropdown-button>
</a-space>
</a-flex>
</a-layout-header>
@ -325,10 +374,12 @@ const handleAutoCreateStorageCancel = () => {
<EditorToolbar v-if="editor" :token="EDITOR_KEY" :id="id" />
</div>
<!-- 批量编辑工具栏 - 只在编辑模式下显示 -->
<BatchEditToolbar v-if="editable && editor" :token="EDITOR_KEY" />
<!-- 自动生成库位对话框 -->
<ImportSmapModal v-model:visible="importSmapModalVisible" @confirm="handleImportSmapConfirm" />
<ExportConverterModal v-model:visible="exportModalVisible" @confirm="handleExportConfirm" />
<AutoCreateStorageModal
v-model:visible="autoCreateStorageVisible"
:area-name="autoCreateStorageData.areaName"