feat(scene-editor): 对接地图转换服务,新增 OriginalProperties 采集与处理逻辑,优化导入功能

This commit is contained in:
xudan 2025-10-20 17:01:23 +08:00
parent c4a098d137
commit ad71dc1744
3 changed files with 420 additions and 1 deletions

View File

@ -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内容格式正确
## 日志查看
服务运行日志会输出到控制台,包含详细的处理信息和错误堆栈,可用于问题排查。

View File

@ -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<string[]>([]);
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) => {
</template>
</a-table>
<a-checkbox v-model:checked="normalImportKeepProperties" class="mt-16"> 保留原属性 </a-checkbox>
<div style="margin-top: 8px; color: #999; font-size: 12px">
提示支持 .scene .smap若需上传多个文件请使用分楼层/批量模式
</div>
</div>
<!-- 分区域/楼层模式的内容将在这里添加 -->
<div v-else>
@ -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) => {
</template>
</a-table>
<a-checkbox v-model:checked="floor.keepProperties" class="mt-16"> 保留原属性 </a-checkbox>
<div style="margin-top: 8px; color: #999; font-size: 12px">
单文件支持 .scene .smap当选择多个文件时仅支持 .smap
</div>
</div>
<a-button @click="addFloor" class="mt-16"> + 添加楼层 </a-button>
</div>
</div>
<div class="action-buttons">
<span class="hint-text">*非scene格式文件将转为scene导入场景中</span>
<span class="hint-text">* 多文件仅支持 smap请确保 floors 连续且类型与后缀一致</span>
<a-space>
<a-button @click="importModalVisible = false">取消</a-button>
<a-button type="primary" @click="handleImportConfirm">导入</a-button>

View File

@ -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<string, string> }
| { kind: 'json'; data: any; headers: Record<string, string> };
export type FloorImportItem = { name: string; fileList: any[]; keepProperties: boolean };
export async function collectOriginalProperties(
currentFloorScenes: any[] | any,
floorImportList: FloorImportItem[],
normalizeSceneJson: (raw: unknown) => unknown,
): Promise<string[]> {
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<ProcessJsonListItem[]> {
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<ProcessJsonListResult> {
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<string, string> = {};
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 };
}