feat(scene-editor): 对接地图转换服务,新增 OriginalProperties 采集与处理逻辑,优化导入功能
This commit is contained in:
parent
c4a098d137
commit
ad71dc1744
215
src/pages/API_MAP_CONVERTER_README.md
Normal file
215
src/pages/API_MAP_CONVERTER_README.md
Normal 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内容格式正确
|
||||
|
||||
## 日志查看
|
||||
|
||||
服务运行日志会输出到控制台,包含详细的处理信息和错误堆栈,可用于问题排查。
|
||||
@ -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>
|
||||
|
||||
132
src/services/map-converter.service.ts
Normal file
132
src/services/map-converter.service.ts
Normal 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 };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user