web-map/src/pages/scene-editor.vue

484 lines
14 KiB
Vue

<script setup lang="ts">
import { message, Modal } from 'ant-design-vue';
import { computed, onMounted, onUnmounted, provide, ref, type ShallowRef, shallowRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { getSceneById, importBinTaskExcel, pushSceneById, saveSceneById } from '../apis/scene';
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 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';
import { editorStore } from '../stores/editor.store';
const EDITOR_KEY = Symbol('editor-key');
type Props = {
id: string;
};
const props = defineProps<Props>();
const { t } = useI18n();
const { isConverting, convertSmapToScene, exportSceneToSmap, convertSceneToIray } = useMapConversion();
//#region 接口
const readScene = async () => {
const res = await getSceneById(props.id);
title.value = res?.label ?? '';
editor.value?.load(res?.json, editable.value);
};
const saveScene = async () => {
const json = editor.value?.save();
if (!json) return Promise.reject('无法获取场景数据');
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 = [];
}
message.success(t('场景保存成功'));
return Promise.resolve();
};
const pushScene = async () => {
const res = await pushSceneById(props.id);
if (!res) return Promise.reject();
message.success(t('场景推送成功'));
return Promise.resolve();
};
//#endregion
const title = ref<string>('');
onMounted(() => {
document.title = '场景编辑器';
});
watch(
() => props.id,
async () => {
await readScene();
await handleAutoSaveAndRestoreViewState();
},
{ immediate: true, flush: 'post' },
);
const container = shallowRef<HTMLDivElement>();
const editor = shallowRef<EditorService>();
const leftSiderEl = shallowRef<HTMLElement>();
provide(EDITOR_KEY, editor);
const autoCreateStorageVisible = ref(false);
const autoCreateStorageData = ref<{
areaName: string;
actionPoints: any[];
}>({
areaName: '',
actionPoints: [],
});
onMounted(() => {
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;
const tag = node.tagName?.toLowerCase();
return tag === 'input' || tag === 'textarea' || node.isContentEditable === true;
};
const focusFindKeydownHandler = (event: KeyboardEvent) => {
const isFindKey = event.key === 'f' || event.key === 'F';
if ((event.ctrlKey || event.metaKey) && isFindKey && !isTypingElement(event.target)) {
const raw: any = leftSiderEl.value as any;
const sider: HTMLElement | null = raw && raw.$el ? (raw.$el as HTMLElement) : (raw as HTMLElement | null);
if (sider) {
const input = sider.querySelector('.search input, .search .ant-input') as HTMLInputElement | null;
if (input) {
event.preventDefault();
input.focus();
try {
input.select?.();
} catch {
//
}
}
}
}
};
onMounted(() => {
document.addEventListener('keydown', focusFindKeydownHandler);
});
onUnmounted(() => {
document.removeEventListener('keydown', focusFindKeydownHandler);
});
const editable = ref<boolean>(false);
watch(editable, (v) => editor.value?.setState(v));
const leftCollapsed = ref<boolean>(false);
const toPush = () => {
Modal.confirm({
class: 'confirm',
title: t('是否保存并推送该场景文件?'),
content: t('推送前会先保存当前场景的所有修改。'),
centered: true,
cancelText: t('取消'),
okText: t('保存并推送'),
onOk: async () => {
try {
await saveScene();
await pushScene();
} catch (error) {
console.error('保存或推送失败:', error);
}
},
});
};
// --- Import/Update Logic ---
const importScene = async () => {
const file = await selectFile('.scene');
if (!file) return;
const json = await decodeTextFile(file);
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 exportScene = () => {
const json = editor.value?.save();
if (!json) return;
const blob = textToBlob(json);
if (!blob?.size) return;
const url = URL.createObjectURL(blob);
downloadFile(url, `${title.value || 'unknown'}.scene`);
URL.revokeObjectURL(url);
};
const exportSmap = async () => {
const sceneJson = editor.value?.save();
if (!sceneJson) {
message.error('无法获取当前场景数据,请确保场景不为空');
return;
}
message.info('请选择一个基础 SMAP 文件用于导出');
const smapFile = await selectFile('.smap');
if (!smapFile) return;
const sceneBlob = new Blob([sceneJson], { type: 'application/json' });
const sceneFile = new File([sceneBlob], `${title.value || 'current'}.scene`, {
type: 'application/json',
});
await exportSceneToSmap(sceneFile, smapFile, smapFile.name.replace(/\.smap$/i, ''));
};
const exportAsIray = async () => {
const json = editor.value?.save();
if (!json) {
message.error('无法获取当前场景数据,请确保场景不为空');
return;
}
message.info('请选择一个 SMAP 文件用于 IRAY 导出');
const smapFile = await selectFile('.smap');
if (!smapFile) return;
await convertSceneToIray(json, smapFile, title.value || 'unknown');
};
const show = ref<boolean>(true);
const current = ref<{ type: 'robot' | 'point' | 'line' | 'area'; id: string }>();
watch(
() => editor.value?.selected.value[0],
(v) => {
const pen = editor.value?.getPenById(v);
if (pen?.id) {
current.value = { type: <'point' | 'line' | 'area'>pen.name, id: pen.id };
return;
}
if (current.value?.type === 'robot') return;
current.value = undefined;
},
);
const isRobot = computed(() => current.value?.type === 'robot');
const isPoint = computed(() => current.value?.type === 'point');
const isRoute = computed(() => current.value?.type === 'line');
const isArea = computed(() => current.value?.type === 'area');
const selectRobot = (id: string) => {
current.value = { type: 'robot', id };
editor.value?.inactive();
};
const { saveViewState, autoSaveAndRestoreViewState, isSaving } = useViewState();
const handleSaveViewState = async () => {
if (!editor.value) return;
try {
await saveViewState(editor.value, props.id);
message.success('视图状态保存成功');
} catch {
message.error('视图状态保存失败');
}
};
const handleAutoSaveAndRestoreViewState = async () => {
if (!editor.value) return;
await autoSaveAndRestoreViewState(editor.value, props.id);
};
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;
};
</script>
<template>
<a-layout class="full">
<a-layout-header class="p-16" style="height: 64px">
<a-flex justify="space-between" align="center">
<a-typography-text class="title">{{ title }} --场景编辑</a-typography-text>
<a-space align="center">
<a-button @click="backToCards"> 返回 </a-button>
<a-button type="primary" :loading="isSaving" @click="handleSaveViewState"> 保存比例 </a-button>
<a-button v-if="editable" class="warning" @click="editable = false">
<i class="icon exit size-18 mr-8" />
<span>{{ $t('退出编辑器') }}</span>
</a-button>
<a-button v-else type="primary" @click="editable = true">
<i class="icon edit size-18 mr-8" />
<span>{{ $t('启用编辑器') }}</span>
</a-button>
<a-button @click="toPush">{{ $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="exportSmap">导出为 SMAP (.smap)</a-menu-item>
<a-menu-item key="2" @click="exportAsIray">导出为 IRAY (.zip)</a-menu-item>
</a-menu>
</template>
</a-dropdown-button>
</a-space>
</a-flex>
</a-layout-header>
<a-layout class="p-16 main-layout">
<img
:src="leftCollapsed ? expandIcon : foldIcon"
class="sider-toggle"
alt="toggle-sider"
@click="leftCollapsed = !leftCollapsed"
/>
<a-layout-sider
class="left-sider"
:width="leftCollapsed ? 0 : 320"
:style="{ minWidth: leftCollapsed ? '0px' : '320px', overflow: 'hidden' }"
ref="leftSiderEl"
>
<a-tabs type="card" v-show="!leftCollapsed">
<a-tab-pane key="1" :tab="$t('机器人')">
<RobotGroups
v-if="editor"
:token="EDITOR_KEY"
:sid="id"
:editable="editable"
:current="current?.id"
@change="selectRobot"
show-group-edit
/>
</a-tab-pane>
<a-tab-pane key="2" :tab="$t('库位')">
<PenGroups v-if="editor" :token="EDITOR_KEY" :current="current?.id" only-storage />
</a-tab-pane>
<a-tab-pane key="3" :tab="$t('高级组')">
<PenGroups v-if="editor" :token="EDITOR_KEY" :current="current?.id" />
</a-tab-pane>
</a-tabs>
</a-layout-sider>
<a-layout-content>
<div ref="container" class="editor-container full"></div>
</a-layout-content>
</a-layout>
</a-layout>
<div v-if="editable" class="toolbar-container">
<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" />
<AutoCreateStorageModal
v-model:visible="autoCreateStorageVisible"
:area-name="autoCreateStorageData.areaName"
:action-points="autoCreateStorageData.actionPoints"
@confirm="handleAutoCreateStorageConfirm"
@cancel="handleAutoCreateStorageCancel"
/>
<template v-if="current?.id">
<a-float-button style="top: 80px; right: 16px" shape="square" @click="show = !show">
<template #icon><i class="icon detail" /></template>
</a-float-button>
<div v-if="show" class="card-container">
<RobotDetailCard v-if="isRobot" :token="EDITOR_KEY" :current="current.id" />
<template v-if="isPoint">
<PointEditCard v-if="editable" :token="EDITOR_KEY" :id="current.id" />
<PointDetailCard v-else :token="EDITOR_KEY" :current="current.id" />
</template>
<template v-if="isRoute">
<RouteEditCard v-if="editable" :token="EDITOR_KEY" :id="current.id" />
<RouteDetailCard v-else :token="EDITOR_KEY" :current="current.id" />
</template>
<template v-if="isArea">
<AreaEditCard v-if="editable" :token="EDITOR_KEY" :id="current.id" />
<AreaDetailCard v-else :token="EDITOR_KEY" :current="current.id" />
</template>
</div>
</template>
</template>
<style scoped lang="scss">
.editor-container {
background-color: transparent !important;
}
.toolbar-container {
position: fixed;
bottom: 40px;
left: 50%;
z-index: 100;
transform: translateX(-50%);
}
.card-container {
position: fixed;
top: 80px;
right: 64px;
z-index: 100;
width: 320px;
height: calc(100% - 96px);
overflow: visible;
overflow-y: auto;
pointer-events: none;
& > * {
pointer-events: all;
}
}
.main-layout {
position: relative;
}
.left-sider {
position: relative;
left: 36px;
}
.sider-toggle {
position: absolute;
top: 20px;
left: 8px;
width: 36px;
height: 36px;
padding: 8px;
cursor: pointer;
z-index: 10;
background-color: #fff;
border-radius: 4px;
body[data-theme='dark'] & {
background-color: #000;
}
}
</style>