From ad71dc1744a70c4d6709da35b25052df2f005b0e Mon Sep 17 00:00:00 2001 From: xudan Date: Mon, 20 Oct 2025 17:01:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(scene-editor):=20=E5=AF=B9=E6=8E=A5?= =?UTF-8?q?=E5=9C=B0=E5=9B=BE=E8=BD=AC=E6=8D=A2=E6=9C=8D=E5=8A=A1=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20OriginalProperties=20=E9=87=87=E9=9B=86?= =?UTF-8?q?=E4=B8=8E=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=AF=BC=E5=85=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/API_MAP_CONVERTER_README.md | 215 ++++++++++++++++++++++++++ src/pages/scene-editor.vue | 74 ++++++++- src/services/map-converter.service.ts | 132 ++++++++++++++++ 3 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 src/pages/API_MAP_CONVERTER_README.md create mode 100644 src/services/map-converter.service.ts diff --git a/src/pages/API_MAP_CONVERTER_README.md b/src/pages/API_MAP_CONVERTER_README.md new file mode 100644 index 0000000..24a405a --- /dev/null +++ b/src/pages/API_MAP_CONVERTER_README.md @@ -0,0 +1,215 @@ +# 地图转换API接口使用说明 + +## 接口地址 +``` +POST /api/map-converter/process-json-list +``` + +## 请求格式 +请求体应为一个JSON数组,每个数组元素代表一个楼层的地图信息: + +```json +[ + { + "floors": 1, // 楼层 (int, 必填, 不可重复) + "type": "smap", // 地图类型 (string, 必填, smap或scene) + "placeholder1": "...", // 地图文件内容 (string, 必填) + "placeholder2": "...", // 地图文件内容 (string, 选填) + "OriginalProperties": "...." // 原始地图内容 (string, 必填) + } +] +``` + +### 字段说明 + +| 字段名 | 说明 | 类型 | 是否必填 | 其他要求 | +|--------|------|------|----------|----------| +| floors | 楼层 | int | 必填 | 不可重复,必须连续 | +| type | 地图类型 | string | 必填 | 可填smap或scene,在一层楼中placeholder有多个时,type必须为smap | +| placeholder1 | 上传的地图文件内容 | string | 必填 | 在一层楼中placeholder所加载的地图类型必须与type一致 | +| placeholder2 | 上传的地图文件内容 | string | 选填 | 在一层楼中placeholder所加载的地图类型必须与type一致 | +| placeholderX | 上传的地图文件内容 | string | 选填 | 在一层楼中placeholder所加载的地图类型必须与type一致 | +| OriginalProperties | 原始地图内容 | string | 必填 | 可以为空,用于保留原场景属性 | + +### 请求验证规则 + +1. **楼层验证**: + - 楼层编号必须唯一且连续 + - 如果存在楼层1、2、4,则缺少楼层3,请求将被拒绝 + +2. **机器人组验证**: + - 同一机器人ID不能出现在不同组中(包括跨楼层情况) + - 如果发现重复,将返回错误信息 + +3. **地图类型验证**: + - 当一层楼有多个placeholder时,type必须为"smap" + - 所有placeholder的内容类型必须与type一致 + +## 响应格式 + +成功响应可能有两种形式: + +### 1. ZIP文件流响应(主要响应形式) +API处理成功后,默认返回ZIP文件流,包含处理后的地图文件: + +```http +Content-Type: application/zip +Content-Disposition: attachment; filename=converted_map.zip +``` + +### 2. JSON格式响应(仅在特定情况下) +```json +{ + "success": true, + "message": "请求处理成功", + "timestamp": "2023-05-15T10:30:45.123456", + "data": { + // 处理结果数据 + } +} +``` + +## 错误响应 +```json +{ + "success": false, + "message": "错误描述", + "timestamp": "2023-05-15T10:30:45.123456", + "error": "详细错误信息" +} +``` + +## 处理逻辑 + +### 单楼层处理 +1. **单个placeholder**: + - 如果type为"scene"且OriginalProperties有值,将执行场景丰富化 + - 最终返回包含处理后scene文件的ZIP + +2. **多个placeholder**: + - 如果type为"smap",将执行地图拼接 + - 最终返回包含拼接后scene文件的ZIP + +### 多楼层处理 +1. 所有楼层必须连续 +2. 每个楼层按照单楼层逻辑处理 +3. 最终返回包含所有处理后文件的ZIP + +## 使用示例 + +### Python requests示例 +```python +import requests +import json + +# 准备请求数据 +data = [ + { + "floors": 1, + "type": "smap", + "placeholder1": '{"version": "1.0", "mapData": "..."}', + "OriginalProperties": "" + } +] + +# 发送请求 +response = requests.post( + "http://localhost:8000/api/map-converter/process-json-list", + json=data +) + +# 处理响应 +if response.headers.get('content-type', '').startswith('application/json'): + # JSON响应 + result = response.json() + print(json.dumps(result, indent=2, ensure_ascii=False)) +else: + # ZIP文件流响应 + with open('result.zip', 'wb') as f: + f.write(response.content) + print("文件已保存为 result.zip") + + # 解压ZIP文件(可选) + import zipfile + import os + + output_dir = "outputmap" + os.makedirs(output_dir, exist_ok=True) + + with zipfile.ZipFile('result.zip', 'r') as zip_ref: + zip_ref.extractall(output_dir) + print(f"ZIP文件已解压到 {output_dir} 目录") +``` + +### cURL示例 +```bash +curl -X POST "http://localhost:8000/api/map-converter/process-json-list" \ +-H "Content-Type: application/json" \ +-d '[ + { + "floors": 1, + "type": "scene", + "placeholder1": "{\"version\": \"1.0\", \"mapData\": \"...\"}", + "OriginalProperties": "{\"robotGroups\": [...]}" + } +]' \ +--output result.zip +``` + +## 启动服务 + +### 基本启动 +```bash +cd d:\CodeProject\map-converter +python start_map_service.py +``` + +### 高级启动选项 +```bash +# 指定主机和端口 +python start_map_service.py --host 0.0.0.0 --port 8080 + +# 开发模式(启用热重载) +python start_map_service.py --reload +``` + +### 服务状态检查 +```bash +# 检查服务是否运行 +curl http://localhost:8000/ +``` + +服务默认运行在 `http://127.0.0.1:8000` + +## 测试工具 + +项目提供了模拟API请求的脚本,可用于测试: + +```bash +python simulate_api_request.py +``` + +该脚本会: +1. 检查服务状态 +2. 读取intput.json文件中的测试数据 +3. 发送API请求 +4. 处理并保存响应结果 + +## 注意事项 + +1. **文件大小限制**:大文件可能需要调整服务器配置 +2. **超时设置**:复杂地图处理可能需要较长时间,建议设置适当的客户端超时 +3. **编码格式**:所有文本内容应使用UTF-8编码 +4. **JSON格式**:确保所有JSON内容格式正确,特别注意尾随逗号问题 +5. **临时文件**:处理过程中会创建临时文件,系统会自动清理 + +## 错误排查 + +1. **楼层不连续错误**:检查floors字段是否连续 +2. **机器人组重复错误**:检查OriginalProperties中的robotGroups配置 +3. **地图类型不匹配错误**:确保placeholder内容与type字段一致 +4. **JSON解析错误**:验证所有JSON内容格式正确 + +## 日志查看 + +服务运行日志会输出到控制台,包含详细的处理信息和错误堆栈,可用于问题排查。 \ No newline at end of file diff --git a/src/pages/scene-editor.vue b/src/pages/scene-editor.vue index 7111fa1..627f2a3 100644 --- a/src/pages/scene-editor.vue +++ b/src/pages/scene-editor.vue @@ -12,6 +12,12 @@ import ExportConverterModal, { type ExportConfirmPayload } from '../components/m import ImportSmapModal from '../components/modal/ImportSmapModal.vue'; import { useMapConversion } from '../hooks/useMapConversion'; import { EditorService } from '../services/editor.service'; +import { + buildProcessJsonListPayload, + collectOriginalProperties, + postProcessJsonList, + type ProcessJsonListItem, +} from '../services/map-converter.service'; import { useViewState } from '../services/useViewState'; import { decodeTextFile, downloadFile, selectFile, textToBlob } from '../services/utils'; import { editorStore } from '../stores/editor.store'; @@ -261,6 +267,7 @@ const importMode = ref<'normal' | 'floor'>('normal'); const normalImportFileList = ref([]); const normalImportKeepProperties = ref(true); const floorImportList = ref([{ name: 'F1', fileList: [], keepProperties: true }]); +const OriginalProperties = ref([]); const fileTableColumns = [ { title: '文件名', dataIndex: 'name', key: 'name' }, @@ -310,6 +317,7 @@ const mergeSceneObjects = (scenes: any[]): any => { const openImportModal = () => { importMode.value = 'normal'; importModalVisible.value = true; + OriginalProperties.value = []; }; const handleImportConfirm = async () => { @@ -355,6 +363,61 @@ const handleImportConfirm = async () => { } await processAndLoadSceneData(JSON.parse(sceneJsonString)); } else if (importMode.value === 'floor') { + // === 新增:对接地图转换服务(含 OriginalProperties 采集) === + try { + if (floorImportList.value.some((f) => f.fileList.length === 0)) { + message.warn('每个楼层都需要至少选择一个文件'); + return; + } + + // 1) 采集 OriginalProperties(与每个楼层索引对齐) + OriginalProperties.value = await collectOriginalProperties( + floorScenes.value as any, + floorImportList.value as any, + normalizeSceneJson, + ); + while (OriginalProperties.value.length < floorImportList.value.length) { + OriginalProperties.value.push(''); + } + + // 2) 构建转换载荷 + const payload: ProcessJsonListItem[] = await buildProcessJsonListPayload( + floorImportList.value as any, + OriginalProperties.value, + ); + // 打印请求参数,便于联调核对 + try { + console.log('[MapConverter] OriginalProperties by floor:', OriginalProperties.value); + console.log('[MapConverter] Request payload (object):', payload); + console.log('[MapConverter] Request payload (JSON):', JSON.stringify(payload, null, 2)); + message.info('已在控制台打印请求参数'); + } catch {} + + // 3) 调用转换服务 + const result = await postProcessJsonList(payload); + if (result.kind === 'zip') { + const url = URL.createObjectURL(result.blob); + const filename = result.filename || 'converted_map.zip'; + downloadFile(url, filename); + URL.revokeObjectURL(url); + message.success('转换成功,已下载ZIP结果'); + } else { + message.success(result?.data?.message || '转换成功'); + console.log('MapConverter JSON 响应:', result.data); + } + + // 4) 关闭弹窗并重置临时状态 + importModalVisible.value = false; + normalImportFileList.value = []; + floorImportList.value = [{ name: 'F1', fileList: [], keepProperties: true }]; + OriginalProperties.value = []; + return; + } catch (error) { + console.error('地图转换失败:', error); + message.error((error as Error)?.message || '地图转换失败'); + return; + } + /* LEGACY_FLOOR_IMPORT_START if (floorImportList.value.some((f) => f.fileList.length === 0)) { message.warn('每个楼层都需要选择至少一个文件'); return; @@ -375,6 +438,7 @@ const handleImportConfirm = async () => { }), ); await processAndLoadSceneData(floorJsons); + LEGACY_FLOOR_IMPORT_END */ } importModalVisible.value = false; @@ -705,6 +769,7 @@ const handleFloorChange = async (value: any) => { v-model:fileList="normalImportFileList" name="file" :multiple="false" + accept=".scene,.smap" @change="handleNormalFileChange" :before-upload="() => false" > @@ -734,6 +799,9 @@ const handleFloorChange = async (value: any) => { 保留原属性 +
+ 提示:支持 .scene 或 .smap。若需上传多个文件,请使用“分楼层/批量”模式。 +
@@ -743,6 +811,7 @@ const handleFloorChange = async (value: any) => { v-model:fileList="floor.fileList" name="file" :multiple="true" + accept=".scene,.smap" @change="(info) => handleFloorFileChange(info, index)" :before-upload="() => false" > @@ -772,12 +841,15 @@ const handleFloorChange = async (value: any) => { 保留原属性 +
+ 单文件支持 .scene 或 .smap;当选择多个文件时,仅支持 .smap。 +
+ 添加楼层
- *非scene格式文件将转为scene导入场景中 + * 多文件仅支持 smap;请确保 floors 连续且类型与后缀一致 取消 导入 diff --git a/src/services/map-converter.service.ts b/src/services/map-converter.service.ts new file mode 100644 index 0000000..68058bf --- /dev/null +++ b/src/services/map-converter.service.ts @@ -0,0 +1,132 @@ +import axios from 'axios'; +import { decodeTextFile } from './utils'; + +export type ProcessJsonListItem = { + floors: number; + type: 'smap' | 'scene'; + [key: string]: any; + OriginalProperties: string; +}; + +export type ProcessJsonListResult = + | { kind: 'zip'; blob: Blob; filename: string | null; headers: Record } + | { kind: 'json'; data: any; headers: Record }; + +export type FloorImportItem = { name: string; fileList: any[]; keepProperties: boolean }; + +export async function collectOriginalProperties( + currentFloorScenes: any[] | any, + floorImportList: FloorImportItem[], + normalizeSceneJson: (raw: unknown) => unknown, +): Promise { + const currentScenesArray: any[] = Array.isArray(currentFloorScenes) + ? currentFloorScenes + : currentFloorScenes + ? [currentFloorScenes] + : []; + + const list = await Promise.all( + floorImportList.map(async (_floor, idx) => { + const keep = !!_floor.keepProperties; + if (!keep) return ''; + let source: any = currentScenesArray[idx]; + if (source == null && currentScenesArray.length === 1) { + source = currentScenesArray[0]; + } + if (source == null) return ''; + try { + const normalized = normalizeSceneJson(source) as any; + if (!normalized || typeof normalized !== 'object' || Array.isArray(normalized)) { + return ''; + } + return JSON.stringify([normalized]); + } catch { + return ''; + } + }), + ); + return list; +} + +export async function buildProcessJsonListPayload( + floorImportList: FloorImportItem[], + originalProperties: string[], +): Promise { + const payload: ProcessJsonListItem[] = []; + for (let i = 0; i < floorImportList.length; i += 1) { + const floor = floorImportList[i]; + const files = (floor.fileList || []) as any[]; + if (!files.length) throw new Error(`楼层 ${floor.name} 需要至少选择一个文件`); + + const placeholders: string[] = []; + const exts: string[] = []; + for (const fileInfo of files) { + const file = fileInfo.originFileObj as File; + const content = await decodeTextFile(file); + if (!content) throw new Error(`楼层 ${floor.name} 的文件 ${file?.name} 读取为空`); + placeholders.push(content); + const ext = String(file?.name || '').toLowerCase().split('.').pop() || ''; + exts.push(ext); + } + + // 校验 + if (placeholders.length > 1) { + const hasNonSmap = exts.some((e) => e !== 'smap'); + if (hasNonSmap) { + throw new Error(`楼层 ${floor.name} 上传了多个文件,但包含非 .smap 文件,请仅上传 .smap 文件`); + } + } else { + const ext0 = exts[0]; + if (ext0 !== 'smap' && ext0 !== 'scene') { + throw new Error(`楼层 ${floor.name} 仅支持 .scene 或 .smap 文件`); + } + } + + // 类型推断 + let type: 'scene' | 'smap' = 'scene'; + const hasSmap = exts.some((e) => e === 'smap'); + if (placeholders.length > 1 || hasSmap) { + type = 'smap'; + } else { + type = exts[0] === 'smap' ? 'smap' : 'scene'; + } + + const item: ProcessJsonListItem = { + floors: i + 1, + type, + OriginalProperties: originalProperties[i] || '', + } as ProcessJsonListItem; + + placeholders.forEach((p, idx) => { + (item as any)[`placeholder${idx + 1}`] = p; + }); + + payload.push(item); + } + + return payload; +} + +export async function postProcessJsonList(payload: ProcessJsonListItem[]): Promise { + const client = axios.create(); + const url = '/api/map-converter/process-json-list'; + const response = await client.post(url, payload, { responseType: 'blob' }); + + const contentType = (response.headers?.['content-type'] || '').toString().toLowerCase(); + const headers: Record = {}; + Object.entries(response.headers || {}).forEach(([k, v]) => (headers[k] = String(v))); + + if (contentType.includes('application/json')) { + const text = await (response.data as Blob).text(); + const data = JSON.parse(text); + return { kind: 'json', data, headers }; + } + + let filename: string | null = null; + const disposition = headers['content-disposition'] || ''; + const match = disposition.match(/filename=([^;]+)/i); + if (match && match[1]) { + filename = decodeURIComponent(match[1].replace(/\"/g, '')); + } + return { kind: 'zip', blob: response.data as Blob, filename, headers }; +}