feat: 新增通用转换器组件,支持STP转GLTF、IFC转STP及IFC批量转STP功能,并添加批量任务管理器和相关文档。

This commit is contained in:
sladro 2026-03-01 19:32:25 +08:00
parent ce85d70758
commit a837baef95
4 changed files with 738 additions and 65 deletions

168
CAD_API_集成文档.md Normal file
View File

@ -0,0 +1,168 @@
# CAD 软件模型处理 API 集成文档
本文档详细说明了不同 CAD 软件Creo, Revit, PDMS在本项目中的 API 接入规范,包括基础配置、核心模型操作以及类似 Shrink Wrap几何优化轻量化功能的接口文档。请按照以下说明进行前后端集成。
---
## 1. Creo Parametric
- **CAD 软件名称**: Creo Parametric (版本 9.0)
- **Base URL**: `http://localhost:12345`
- **端口**: `12345`
### 1.1. 打开模型 API
- **接口路径**: `/api/model/open`
- **请求方式**: `POST`
- **基础描述**: 在 Creo 软件中加载指定的模型文件。
- **接入说明**:
- **请求头**: `Content-Type: application/json`
- **请求体 (Body)**:
```json
{
"filePath": "C:\\path\\to\\your\\model.prt" // 字符串,必须是绝对路径
}
```
- **响应格式**:
```json
{
"status": "success", // 或 "error"
"message": "模型加载成功",
"data": null
}
```
- **常见错误**: 若路劲不存在或 Creo 实例未启动status 会返回 error。
### 1.2. 几何优化分析 API (类似 Shrink Wrap 功能)
- **接口路径**: `/api/creo/shrinkwrap/shell` (及 `/api/analysis/shell-analysis`)
- **请求方式**: `POST`
- **基础描述**: 对打开的模型执行几何外壳提取或包络处理,用于轻量化。
- **接入说明**:
- **请求头**: `Content-Type: application/json`
- **请求体 (Body - 参数示例)**:
```json
{
"method": "outer_shell", // 提取方法:外壳
"quality": 5, // 提取质量 (1-10)
"chord_height": 0.15, // 弦高公差
"fill_holes": false, // 是否填充孔洞
"ignore_small_surfaces": false, // 是否忽略小曲面
"output_type": "solid_surface" // 输出类型
}
```
- **响应格式**:
```json
{
"status": "success",
"message": "几何优化完成",
"data": {
"output_file": "C:\\path\\to\\optimized_model.prt"
}
}
```
### 1.3. 关闭模型 API
- **接口路径**: `[占位待实现] /api/model/close`
- **请求方式**: `POST`
- **基础描述**: 关闭当前在 Cero 中处于激活状态的模型,释放内存。
- **接入说明**:
- 当前后端尚未提供明确的单独关闭接口。
- **未来预期请求体**:
```json
{
"clearMemory": true // 是否连带清除缓存
}
```
---
## 2. Revit
- **CAD 软件名称**: Revit (版本 2024)
- **Base URL**: `http://localhost:9000`
- **端口**: `9000`
### 2.1. 打开模型 API
- **接口路径**: `/api/open`
- **请求方式**: `POST`
- **基础描述**: 打开指定的 Revit 项目文件 (.rvt)。
- **接入说明**:
- **请求格式**: `application/json`
- **请求体 (Body)**:
```json
{
"filePath": "C:\\path\\to\\project.rvt"
}
```
### 2.2. 轻量化提取外壳 API (类似 Shrink Wrap 功能)
- **接口路径**: `/api/shell/analyze` (提取分析) / `/api/shell/execute` (执行处理)
- **请求方式**: `POST`
- **基础描述**: 分析 Revit 建筑模型并剥离内部不需要的实体细节。
- **接入说明**:
- **请求体 (Body)**:
```json
{
"detailLevel": "coarse", // 粗略级别
"removeInternal": true // 是否移除内部构件
}
```
- **响应格式**:
```json
{
"status": "success",
"data": {
"taskId": "task-123456" // 大模型处理可能较慢,建议通过 /api/task 轮询进度
}
}
```
### 2.3. 关闭模型 API
- **接口路径**: `[占位待实现] /api/close`
- **请求方式**: `POST`
- **基础描述**: 关闭 Revit 当前工程并释放资源。
- **接入说明**:
- 当前未列出明确接口,前端可作为占位按钮。
---
## 3. PDMS
- **CAD 软件名称**: PDMS (版本 12.1)
- **Base URL**: `http://localhost:9001`
- **端口**: `9001`
### 3.1. 打开模型 API
- **接口路径**: `/api/project/open` (打开工程) / `/api/mdb/open` (打开 MDB 数据库)
- **请求方式**: `POST`
- **基础描述**: 载入 PDMS 的工程或特定 MDB 库。
- **接入说明**:
- **请求体 (Body)**:
```json
{
"projectName": "SAM",
"username": "SYSTEM",
"password": "XXXXXX",
"systemName": "CATA"
}
```
### 3.2. 模型简化与包裹 API (类似 Shrink Wrap 功能)
- **接口路径**: `/api/model/shrinkwrap` (包裹) / `/api/model/simplify` (参数化简化)
- **请求方式**: `POST`
- **基础描述**: 将复杂的管道、设备利用包围盒或简化几何进行替代。
- **接入说明**:
- **请求体 (Body) - Simplify 示例**:
```json
{
"targetElements": ["/EQUIP-1", "/PIPE-2"],
"simplifyMethod": "bounding_box"
}
```
- **响应**: 处理成功后通常会通过 WebSocket 推送更新或直接返回简化的模型状态数据。
### 3.3. 关闭模型 API
- **接口路径**: `[占位待实现] /api/project/close`
- **请求方式**: `POST`
- **基础描述**: 退出工程并回收当前资源。
- **接入说明**:
- 占位状态,开发集成时需要确保该请求不会导致整个 PDMS 进程意外崩溃。

155
docs/cad-batch-plan.md Normal file
View File

@ -0,0 +1,155 @@
# CAD 批处理串行执行框架(设计与开发进度)
## 1. 文档目的
本文件用于同步两类信息:
1. 总体批处理设计(长期有效)
2. 当前代码开发进度(持续更新)
---
## 2. 总体设计(已定稿)
### 2.1 目标
- 前端提交批次任务(多模型、多步骤按条目表达)
- 后端按模型扩展名路由到对应 CAD 插件
- 全局严格 FIFO 串行执行(跨软件也串行)
- 插件通过 HTTP 接口执行,结果通过回调闭环
- 单任务失败后按重试策略处理,最终失败不阻塞后续任务
### 2.2 执行策略
- 队列:全局单队列 + 单 worker
- 顺序:严格按提交顺序
- 状态机:`queued -> dispatching -> waiting_callback -> succeeded/failed`
- 失败策略:重试后失败,继续执行后续任务
- 存储策略:首版内存态(重启不恢复)
### 2.3 架构模块
- 模型层:`app/models/cad_batch.py`
- 路由层:`app/core/cad_task_router.py`
- 插件下发层:`app/core/plugin_http_client.py`
- 回调等待层:`app/core/plugin_callback_registry.py`
- 串行执行层:`app/core/serial_batch_executor.py`
- 业务管理层:`app/core/cad_batch_manager.py`
- 插件回调接口:`app/api/v1/plugin_callbacks.py`
- 前端通道WebSocket`app/api/v1/websocket.py`
---
## 3. 公共接口设计
### 3.1 WebSocket 消息
- `submit_batch_tasks`:提交批次
- `get_batch_status`:查询批次
- `get_batch_item_status`:查询条目
### 3.2 WebSocket 推送事件
- `batch_created`
- `batch_item_update`
- `batch_completed`
### 3.3 插件回调 HTTP
- `POST /api/v1/plugin-callbacks/task-result`
- 核心字段:
- `execution_id`
- `software_id`
- `status` (`success|failed`)
- `error_message`
- `result`
- `finished_at`
- `token/signature`(当前实现为 token 校验)
---
## 4. 配置设计
### 4.1 已支持配置
- `routing.extension_to_software`
- `plugins.{software_id}.base_url`
- `plugins.{software_id}.request_timeout_sec`
- `plugins.{software_id}.callback_timeout_sec`
- `plugins.{software_id}.max_retries`
- `plugins.{software_id}.retry_backoff_sec`
- `plugins.{software_id}.callback_token`
- `plugins.{software_id}.tasks.{task_type}.path`
- `plugins.{software_id}.tasks.{task_type}.body_mode`
- `plugins.{software_id}.tasks.{task_type}.completion_mode`
### 4.2 body_mode 语义
- `file_path`:发送 `filePath + task_params + callback元信息`
- `task_params_only`:发送 `task_params + callback元信息`
- `project_open`:发送 `task_params + callback元信息`
- `creo_close_model`:发送 `{ software_type: "creo", force_close: boolean }`
- `empty`:发送空请求体 `{}`
- `passthrough`:原样透传
### 4.3 completion_mode 语义
- `callback`:下发后进入 `waiting_callback`,等待插件回调判定结果
- `sync`:以接口同步返回结果直接判定成功/失败,不等待回调
---
## 5. 当前开发进度(截至本次更新)
### 5.1 已完成
- [x] 计划文档落盘:`cad-batch-plan.md`
- [x] 批处理核心模型、执行器、管理器已实现
- [x] 插件异步回调机制与幂等处理已实现
- [x] 主应用 `startup/shutdown` 已接入批处理执行器
- [x] WebSocket 提交/查询批次能力已接入
- [x] 插件任务映射已支持按 `software_id + task_type` 路由
- [x] 单元测试已覆盖路由/执行器/回调/API 映射
### 5.2 已集成的 CAD 插件资料
#### Creo (localhost:12345)
- `open_model` -> `/api/model/open` (`file_path`)
- `shrinkwrap_shell` -> `/api/creo/shrinkwrap/shell` (`task_params_only`)
- `shell_analysis` -> `/api/analysis/shell-analysis` (`task_params_only`)
- `close_model` -> `/api/model/close` (`creo_close_model`, `sync`, 已接入)
#### Revit (localhost:9000)
- `open_model` -> `/api/open` (`file_path`)
- `shell_analyze` -> `/api/shell/analyze` (`task_params_only`)
- `shell_execute` -> `/api/shell/execute` (`task_params_only`)
- `close_model` -> `/api/close` (`empty`, `sync`, 已接入)
#### PDMS (localhost:9001)
- `open_project` -> `/api/project/open` (`project_open`)
- `open_mdb` -> `/api/mdb/open` (`project_open`)
- `shrinkwrap_model` -> `/api/model/shrinkwrap` (`task_params_only`)
- `simplify_model` -> `/api/model/simplify` (`task_params_only`)
- `close_project` -> `/api/project/close` (`task_params_only`, 占位)
### 5.3 当前测试状态
- 已通过:`13 passed`
- 覆盖文件:
- `tests/test_cad_task_router.py`
- `tests/test_serial_batch_executor.py`
- `tests/test_plugin_callback_api.py`
- `tests/test_plugin_http_client_mapping.py`
- `tests/test_creo_close_model_sync_flow.py`
---
## 6. 待补资料与后续工作
### 6.1 你后续补充后可继续对接
1. 每个 `task_type` 的最终命名标准(是否沿用当前命名)
2. Revit 大任务轮询接口细节(如 `/api/task` 的请求/返回)
3. PDMS `close_project` 是否真实可用(当前仍按占位映射)
4. 回调安全方案最终版token 还是 signature签名算法与字段
5. 各 API 的完整错误码与失败语义(用于统一重试/终止策略)
### 6.2 下一步建议
- 基于真实插件联调一轮 `open -> 处理 -> close`
- 优先确认 PDMS `close_project` 实际行为与容错策略
- 增加端到端集成测试(含回调超时、插件离线、重复回调场景)
---
## 7. 验收标准(当前版本)
- 可提交批次并进入全局串行队列
- 可按扩展名路由并向插件正确下发
- 可通过回调驱动状态闭环
- 单任务失败不会阻塞后续任务
- 可通过 WebSocket 查询批次与条目状态

View File

@ -54,11 +54,38 @@
<el-option label="全部" value="all" />
<el-option label="运行中" value="running" />
<el-option label="排队中" value="queued" />
<el-option label="已完成" value="completed" />
<el-option label="已完成" value="succeeded" />
<el-option label="失败/异常" value="failed" />
</el-select>
</div>
</div>
<el-table :data="filteredJobs" style="width: 100%" stripe>
<!-- 展开项显示具体的子条目 -->
<el-table-column type="expand">
<template #default="scope">
<div style="padding: 10px 40px;">
<h4 style="margin-top: 0; margin-bottom: 15px; color: var(--color-text-secondary);">批次明细 (模型条目)</h4>
<el-table :data="scope.row.items" size="small" border>
<el-table-column prop="modelPath" label="模型路径" show-overflow-tooltip />
<el-table-column prop="status" label="执行状态" width="150">
<template #default="itemScope">
<el-tag :type="getItemStatusType(itemScope.row.status)" size="small">
{{ getItemStatusLabel(itemScope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="finishedAt" label="完成时间" width="160" />
<el-table-column prop="errorMessage" label="错误信息" show-overflow-tooltip>
<template #default="itemScope">
<span v-if="itemScope.row.errorMessage" style="color: #F56C6C">{{ itemScope.row.errorMessage }}</span>
<span v-else class="text-gray-400">-</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="batchId" label="批次ID" width="120" />
<el-table-column prop="strategy" label="执行策略" width="150" />
<el-table-column prop="priority" label="优先级" width="100">
@ -84,12 +111,11 @@
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" />
<el-table-column label="操作" width="200" fixed="right">
<el-table-column label="操作" width="160" fixed="right">
<template #default="scope">
<el-button v-if="scope.row.status === 'running'" type="warning" size="small" link @click="pauseJob(scope.row)">暂停</el-button>
<el-button v-if="scope.row.status === 'paused'" type="success" size="small" link @click="resumeJob(scope.row)">继续</el-button>
<el-button v-if="['running', 'queued', 'paused'].includes(scope.row.status)" type="danger" size="small" link @click="cancelJob(scope.row)">取消</el-button>
<el-button v-if="scope.row.status === 'completed'" type="primary" size="small" link @click="viewDetails(scope.row)">详情与报告</el-button>
<!-- 取消已支持如果不是终态则允许取消 -->
<el-button v-if="['running', 'queued'].includes(scope.row.status)" type="danger" size="small" link @click="cancelJob(scope.row)">取消批次</el-button>
<el-button v-if="['succeeded', 'failed'].includes(scope.row.status)" type="primary" size="small" link @click="viewDetails(scope.row)">详情与报告</el-button>
</template>
</el-table-column>
</el-table>
@ -100,18 +126,18 @@
<el-dialog v-model="wizardVisible" title="创建批量任务" width="700px" destroy-on-close>
<el-steps :active="wizardStep" finish-status="success" align-center class="wizard-steps">
<el-step title="选择模型" description="选择要处理的文件/目录"></el-step>
<el-step title="配置策略" description="选择分析或导出策略"></el-step>
<el-step title="调度设置" description="设置优先级与并发"></el-step>
<el-step title="配置策略" description="选择批处理动作指令"></el-step>
<el-step title="调度设置" description="设置优先级与排队模式"></el-step>
</el-steps>
<div class="wizard-content">
<!-- 步骤1选择模型 -->
<div v-show="wizardStep === 0" class="step-panel">
<p class="step-desc">请输入模型路径并点击增加模型构建本次批处理的模型列表</p>
<p class="step-desc">请输入模型路径并点击增加模型构建本次批处理的模型列表支持 .prt, .rvt 扩展名</p>
<div class="model-path-input-row">
<el-input
v-model="newModelPath"
placeholder="例如:D:\\CAD\\project\\part1.prt"
placeholder="例如:C:\CAD\project\part1.prt 或 D:\BIM\model.rvt"
@keyup.enter="addModelPath"
/>
<el-button type="primary" @click="addModelPath">
@ -132,15 +158,16 @@
<!-- 步骤2配置策略 -->
<div v-show="wizardStep === 1" class="step-panel">
<el-form :model="taskForm" label-width="120px">
<el-form-item label="执行策略">
<el-form-item label="执行策略序列">
<el-select v-model="taskForm.strategy" placeholder="请选择策略" style="width: 100%">
<el-option label="批量模型轻量化分析 (多算法融合)" value="shrinkwrap_analysis" />
<el-option label="批量格式转换 (转STP/中性格式)" value="format_export" />
<el-option label="批量层级检索与清理" value="hierarchy_cleanup" />
<el-option label="模型打开 -> Shrinkwrap外壳 -> 关闭模型 (Creo)" value="creo_shrinkwrap" />
<el-option label="模型打开 -> 建筑模型剥壳 -> 关闭模型 (Revit)" value="revit_shell" />
<el-option label="工程打开 -> 模型包裹化防泄密 -> 占位关闭 (PDMS)" value="pdms_shrinkwrap" />
<el-option label="工程打开 -> 模型参数化简化 -> 占位关闭 (PDMS)" value="pdms_simplify" />
</el-select>
</el-form-item>
<el-form-item label="导出路径">
<el-input v-model="taskForm.exportPath" placeholder="输出目录路径 (如: C:\Output)" />
<el-form-item label="策略说明">
<span class="form-tip" style="margin-left:0;">提交时会将策略转化为相应的 task_type 序述串通过 WebSocket 下发</span>
</el-form-item>
</el-form>
</div>
@ -156,12 +183,8 @@
<el-radio label="紧急">紧急</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="最大并发数">
<el-input-number v-model="taskForm.maxConcurrency" :min="1" :max="10" />
<span class="form-tip">控制同时执行的任务数量</span>
</el-form-item>
<el-form-item label="计划执行时间">
<el-date-picker v-model="taskForm.scheduleAt" type="datetime" placeholder="立即执行可留空" />
<el-form-item label="排队模式">
<el-tag type="info">全局严格单队列 FIFO 串行执行</el-tag>
</el-form-item>
</el-form>
</div>
@ -171,7 +194,7 @@
<div class="dialog-footer">
<el-button v-if="wizardStep > 0" @click="wizardStep--">上一步</el-button>
<el-button v-if="wizardStep < 2" type="primary" @click="wizardStep++">下一步</el-button>
<el-button v-if="wizardStep === 2" type="success" @click="submitTask">提交任务组</el-button>
<el-button v-if="wizardStep === 2" type="success" @click="submitTask">提交调度批次</el-button>
</div>
</template>
</el-dialog>
@ -179,7 +202,7 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
//
@ -187,21 +210,58 @@ const statusFilter = ref('all')
const wizardVisible = ref(false)
const wizardStep = ref(0)
const taskForm = ref({
strategy: '',
exportPath: '',
strategy: 'creo_shrinkwrap',
priority: '普通',
maxConcurrency: 3,
scheduleAt: '',
modelPaths: []
})
const newModelPath = ref('')
//
// - queued -> dispatching -> waiting_callback -> succeeded/failed
const jobs = ref([
{ batchId: 'B-20261001', strategy: '批量格式转换', priority: '普通', progress: 45, status: 'running', totalCount: 10, completedCount: 4, createdAt: '2026-02-26 14:00' },
{ batchId: 'B-20261002', strategy: '薄壳化(多算法)', priority: '高', progress: 100, status: 'completed', totalCount: 50, completedCount: 50, createdAt: '2026-02-26 10:30' },
{ batchId: 'B-20261003', strategy: '层级清理', priority: '低', progress: 0, status: 'queued', totalCount: 25, completedCount: 0, createdAt: '2026-02-26 15:20' },
{ batchId: 'B-20261004', strategy: '批量格式转换', priority: '紧急', progress: 80, status: 'paused', totalCount: 100, completedCount: 80, createdAt: '2026-02-25 09:15' }
{
batchId: 'B-20261001',
strategy: 'creo_shrinkwrap',
priority: '普通',
progress: 40,
status: 'running',
totalCount: 5,
completedCount: 2,
createdAt: '2026-02-26 14:00',
items: [
{ modelPath: 'C:\\Models\\engine_block.prt', status: 'succeeded', errorMessage: '', finishedAt: '2026-02-26 14:05' },
{ modelPath: 'C:\\Models\\gear_box.prt', status: 'failed', errorMessage: '模型几何损坏无法抽取外壳', finishedAt: '2026-02-26 14:10' },
{ modelPath: 'C:\\Models\\shaft.prt', status: 'waiting_callback', errorMessage: '', finishedAt: '-' },
{ modelPath: 'C:\\Models\\bearing.prt', status: 'queued', errorMessage: '', finishedAt: '-' },
{ modelPath: 'C:\\Models\\casing.prt', status: 'queued', errorMessage: '', finishedAt: '-' }
]
},
{
batchId: 'B-20261002',
strategy: 'revit_shell',
priority: '高',
progress: 100,
status: 'succeeded',
totalCount: 2,
completedCount: 2,
createdAt: '2026-02-26 10:30',
items: [
{ modelPath: 'D:\\BIM\\A_Tower.rvt', status: 'succeeded', errorMessage: '', finishedAt: '2026-02-26 10:45' },
{ modelPath: 'D:\\BIM\\B_Tower.rvt', status: 'succeeded', errorMessage: '', finishedAt: '2026-02-26 11:00' }
]
},
{
batchId: 'B-20261003',
strategy: 'pdms_shrinkwrap',
priority: '低',
progress: 0,
status: 'queued',
totalCount: 1,
completedCount: 0,
createdAt: '2026-02-26 15:20',
items: [
{ modelPath: 'SAM/CATA', status: 'queued', errorMessage: '', finishedAt: '-' }
]
}
])
const filteredJobs = computed(() => {
@ -209,15 +269,22 @@ const filteredJobs = computed(() => {
return jobs.value.filter(j => j.status === statusFilter.value)
})
// === WebSocket ===
// WebSocket submit_batch_tasks / batch_item_update
onMounted(() => {
// console.log(' WebSocket batch_created, batch_item_update, batch_completed ')
})
onUnmounted(() => {
// console.log(' WebSocket')
})
//
const openWizard = () => {
wizardStep.value = 0
taskForm.value = {
strategy: '',
exportPath: '',
strategy: 'creo_shrinkwrap',
priority: '普通',
maxConcurrency: 3,
scheduleAt: '',
modelPaths: []
}
wizardVisible.value = true
@ -230,24 +297,46 @@ const submitTask = () => {
return
}
ElMessage.success('批量任务已成功加入调度队列!')
// submit_batch_tasks WebSocket Request
// const payload = { ... }
// ws.send(JSON.stringify({ type: 'submit_batch_tasks', payload }))
ElMessage.success('批量任务已成功提交至全局串行队列!')
wizardVisible.value = false
// mock ( create job )
// mock batch_created
jobs.value.unshift({
batchId: `B-${Date.now().toString().slice(-8)}`,
strategy: taskForm.value.strategy === 'format_export' ? '批量格式转换' : '综合分析',
strategy: taskForm.value.strategy,
priority: taskForm.value.priority,
progress: 0,
status: 'queued',
totalCount: taskForm.value.modelPaths.length,
completedCount: 0,
createdAt: new Date().toLocaleString()
createdAt: new Date().toLocaleString(),
items: taskForm.value.modelPaths.map(p => ({
modelPath: p,
status: 'queued',
errorMessage: '',
finishedAt: '-'
}))
})
}
const addModelPath = () => {
const path = newModelPath.value.trim()
if (!path) return
//
const lowerPath = path.toLowerCase();
const validExtensions = ['.prt', '.asm', '.rvt', '.rfa', '.ifc', 'sam', 'mdb']
const isValid = validExtensions.some(ext => lowerPath.endsWith(ext) || lowerPath.includes(ext))
if (!isValid && !taskForm.value.strategy.includes('pdms')) {
ElMessage.warning('不支持的扩展名。目前支持的路由扩展名例如 .prt, .rvt')
//
}
if (taskForm.value.modelPaths.includes(path)) {
ElMessage.warning('该模型路径已存在')
return
@ -262,11 +351,11 @@ const removeModelPath = (index) => {
//
const getStatusLabel = (status) => {
const map = { running: '执行中', completed: '已完成', queued: '排队中', paused: '已暂停', failed: '异常' }
const map = { running: '执行中', succeeded: '已完成', queued: '排队中', failed: '失败/异常' }
return map[status] || status
}
const getStatusType = (status) => {
const map = { running: 'primary', completed: 'success', queued: 'info', paused: 'warning', failed: 'danger' }
const map = { running: 'primary', succeeded: 'success', queued: 'info', failed: 'danger' }
return map[status] || 'info'
}
const getPriorityType = (priority) => {
@ -274,23 +363,43 @@ const getPriorityType = (priority) => {
return map[priority] || 'info'
}
const getProgressStatus = (status) => {
if (status === 'completed') return 'success'
if (status === 'succeeded') return 'success'
if (status === 'failed') return 'exception'
if (status === 'paused') return 'warning'
return ''
return '' /* running/queued 无特殊状态效果 */
}
// (API)
const pauseJob = (row) => { row.status = 'paused' }
const resumeJob = (row) => { row.status = 'running' }
//
const getItemStatusLabel = (status) => {
const map = {
queued: '排队中',
dispatching: '正在下发',
waiting_callback: '执行中(等待回调)',
succeeded: '成功',
failed: '失败'
}
return map[status] || status
}
const getItemStatusType = (status) => {
const map = {
queued: 'info',
dispatching: 'primary',
waiting_callback: 'warning',
succeeded: 'success',
failed: 'danger'
}
return map[status] || 'info'
}
//
const cancelJob = (row) => {
ElMessageBox.confirm('确定取消该批量任务?', '警告', { type: 'warning' }).then(() => {
ElMessageBox.confirm('确定取消该批次任务? 正在执行的条目可能无法撤回。', '警告', { type: 'warning' }).then(() => {
// ws
jobs.value = jobs.value.filter(j => j.batchId !== row.batchId)
ElMessage.success('任务已取消')
ElMessage.success('批次任务已取消')
}).catch(()=> {})
}
const viewDetails = (row) => {
ElMessage.info(`跳转至报告视图,查看 ${row.batchId} 详情日志`)
ElMessage.info(`跳转至报告视图,查看 ${row.batchId} 详情日志及完整失败日志`)
}
</script>
@ -488,6 +597,11 @@ const viewDetails = (row) => {
background-color: rgba(255, 255, 255, 0.12);
}
/* 展开行内容区域背景 */
:deep(.el-table__expanded-cell) {
background-color: rgba(0, 0, 0, 0.2) !important;
}
/* 固定列默认会有独立背景,保持与行背景一致,避免发白 */
:deep(.el-table .el-table-fixed-column--left),
:deep(.el-table .el-table-fixed-column--right) {

View File

@ -31,6 +31,14 @@
<i class="fas fa-building"></i>
<span>IFC STP</span>
</div>
<div
class="tab-item"
:class="{ active: currentMode === 'ifc-to-stp-batch' }"
@click="currentMode = 'ifc-to-stp-batch'"
>
<i class="fas fa-layer-group"></i>
<span>IFC 批量转 STP</span>
</div>
</div>
<div class="conversion-flow">
@ -39,9 +47,11 @@
<i class="fas fa-file-upload"></i>
</div>
<h3 v-if="currentMode === 'stp-to-gltf'">1. 选择STP文件</h3>
<h3 v-else>1. 选择IFC文件</h3>
<h3 v-else-if="currentMode === 'ifc-to-stp'">1. 选择IFC文件</h3>
<h3 v-else>1. 选择多个IFC</h3>
<p v-if="currentMode === 'stp-to-gltf'">选择要转换的STEP格式文件</p>
<p v-else>选择要转换的IFC格式文件</p>
<p v-else-if="currentMode === 'ifc-to-stp'">选择要转换的IFC格式文件</p>
<p v-else>输入多个要转换的IFC格式文件</p>
</div>
<div class="flow-arrow">
<i class="fas fa-arrow-right"></i>
@ -52,7 +62,8 @@
</div>
<h3>2. 转换处理</h3>
<p v-if="currentMode === 'stp-to-gltf'">自动处理几何数据和材质</p>
<p v-else>解析BIM数据并重建BREP几何</p>
<p v-else-if="currentMode === 'ifc-to-stp'">解析BIM数据并重建BREP几何</p>
<p v-else>批量解析BIM数据并重建BREP几何</p>
</div>
<div class="flow-arrow">
<i class="fas fa-arrow-right"></i>
@ -62,9 +73,11 @@
<i class="fas fa-download"></i>
</div>
<h3 v-if="currentMode === 'stp-to-gltf'">3. 下载GLTF</h3>
<h3 v-else>3. 下载STP</h3>
<h3 v-else-if="currentMode === 'ifc-to-stp'">3. 获取STP</h3>
<h3 v-else>3. 获取多个STP</h3>
<p v-if="currentMode === 'stp-to-gltf'">获取Web优化的3D模型文件</p>
<p v-else>获取通用的STEP实体模型文件</p>
<p v-else-if="currentMode === 'ifc-to-stp'">获取通用的STEP实体模型文件</p>
<p v-else>获取批量的STEP实体模型文件</p>
</div>
</div>
@ -82,7 +95,7 @@
</div>
</div>
<div class="path-input-section" v-else>
<div class="path-input-section" v-else-if="currentMode === 'ifc-to-stp'">
<div class="input-group">
<label><i class="fas fa-file-import"></i> 输入IFC绝对路径</label>
<input type="text" v-model="ifcPath" placeholder="例如D:/CAD/input.ifc" class="path-input">
@ -93,6 +106,33 @@
</div>
</div>
<div class="batch-input-section" v-else-if="currentMode === 'ifc-to-stp-batch'">
<div class="batch-header">
<h4><i class="fas fa-list-ol"></i> 批量转换列表</h4>
<button class="add-batch-btn" @click="addBatchItem">
<i class="fas fa-plus"></i> 添加项
</button>
</div>
<div class="batch-list">
<div class="batch-item" v-for="(item, index) in batchItems" :key="index">
<div class="batch-index">{{ index + 1 }}</div>
<div class="batch-inputs">
<div class="batch-input-field">
<span class="batch-label">IFC 路径:</span>
<input type="text" v-model="item.ifcPath" placeholder="例如D:/CAD/input1.ifc" class="path-input batch-input">
</div>
<div class="batch-input-field">
<span class="batch-label">STP 路径:</span>
<input type="text" v-model="item.stpPath" placeholder="例如D:/CAD/output1.stp" class="path-input batch-input">
</div>
</div>
<button class="remove-batch-btn" @click="removeBatchItem(index)" :disabled="batchItems.length <= 1" title="移除该项">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
</div>
<div class="conversion-options">
<h4>转换选项</h4>
<!-- STP to GLTF Options -->
@ -127,6 +167,12 @@
<!-- IFC to STP Options -->
<div class="options-grid" v-else>
<div class="option-item" v-if="currentMode === 'ifc-to-stp-batch'">
<label>
<input type="checkbox" v-model="continueOnError">
出错时继续转换其余项
</label>
</div>
<div class="option-item">
<label>STEP版本:</label>
<select id="stp-version">
@ -195,6 +241,24 @@ const currentMode = ref('stp-to-gltf')
// IFC to STP
const ifcPath = ref('')
const stpPath = ref('')
// IFC STP
const batchItems = ref([
{ ifcPath: '', stpPath: '' },
{ ifcPath: '', stpPath: '' }
])
const continueOnError = ref(true)
const addBatchItem = () => {
batchItems.value.push({ ifcPath: '', stpPath: '' })
}
const removeBatchItem = (index) => {
if (batchItems.value.length > 1) {
batchItems.value.splice(index, 1)
}
}
const isConverting = ref(false)
const convertResult = ref('') // 'success', 'error', ''
const progressTitle = ref('准备转换...')
@ -203,6 +267,8 @@ const progressStatus = ref('')
const canConvert = computed(() => {
if (currentMode.value === 'ifc-to-stp') {
return ifcPath.value.trim() !== '' && stpPath.value.trim() !== ''
} else if (currentMode.value === 'ifc-to-stp-batch') {
return batchItems.value.length > 0 && batchItems.value.every(item => item.ifcPath.trim() !== '' && item.stpPath.trim() !== '')
}
return false // STP to GLTF
})
@ -214,6 +280,11 @@ const selectFile = () => {
const resetForm = () => {
ifcPath.value = ''
stpPath.value = ''
batchItems.value = [
{ ifcPath: '', stpPath: '' },
{ ifcPath: '', stpPath: '' }
]
continueOnError.value = true
convertResult.value = ''
progressTitle.value = '准备转换...'
progressStatus.value = ''
@ -231,22 +302,48 @@ const startConversion = () => {
ifc_path: ifcPath.value.trim(),
stp_path: stpPath.value.trim()
})
} else if (currentMode.value === 'ifc-to-stp-batch') {
isConverting.value = true
convertResult.value = ''
progressTitle.value = '正在批量转换...'
progressStatus.value = `已发送 ${batchItems.value.length} 个文件的请求,处理中...`
websocketService.send({
type: "convert_ifc_to_stp_batch",
continue_on_error: continueOnError.value,
items: batchItems.value.map(item => ({
ifc_path: item.ifcPath.trim(),
stp_path: item.stpPath.trim()
}))
})
}
}
const handleWsMessage = (message) => {
if (!isConverting.value) return
// msg.type === "info" msg.data.stp_path
if (message.type === "info" && message.data?.stp_path) {
isConverting.value = false
convertResult.value = 'success'
progressTitle.value = '转换成功!'
progressStatus.value = `文件已保存至: ${message.data.stp_path} (耗时: ${message.data.duration_ms}ms)`
// msg.type === "info"
if (message.type === "info") {
if (message.data?.items && Array.isArray(message.data.items)) {
isConverting.value = false
convertResult.value = 'success'
progressTitle.value = '批量转换完成!'
let successCount = message.data.items.filter(i => i.status === 'success' || i.success).length || batchItems.value.length
progressStatus.value = `处理完成收工 (成功: ${successCount}项, 耗时: ${message.data.duration_ms || '?'}ms)`
} else if (message.data?.stp_path) {
isConverting.value = false
convertResult.value = 'success'
progressTitle.value = '转换成功!'
progressStatus.value = `文件已保存至: ${message.data.stp_path} (耗时: ${message.data.duration_ms}ms)`
} else {
isConverting.value = false
convertResult.value = 'success'
progressTitle.value = '转换完成!'
progressStatus.value = `处理完成收工 (耗时: ${message.data?.duration_ms || '?'}ms)`
}
}
// msg.type === "error"
else if (message.type === "error") {
// error
isConverting.value = false
convertResult.value = 'error'
progressTitle.value = '转换失败'
@ -633,6 +730,145 @@ onUnmounted(() => {
display: none;
}
/* 批量输入样式 */
.batch-input-section {
display: flex;
flex-direction: column;
gap: 16px;
background: var(--color-bg-tertiary);
padding: 24px;
border-radius: 12px;
border: 1px solid var(--color-border-primary);
margin-bottom: 24px;
}
.batch-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--color-border-primary);
padding-bottom: 12px;
}
.batch-header h4 {
color: var(--color-text-primary);
margin: 0;
font-size: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.batch-header h4 i {
color: var(--color-primary);
}
.add-batch-btn {
background: var(--color-bg-secondary);
border: 1px dashed var(--color-primary);
color: var(--color-primary);
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.3s;
}
.add-batch-btn:hover {
background: var(--color-primary-rgb-2);
}
.batch-list {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 300px;
overflow-y: auto;
padding-right: 8px;
}
.batch-list::-webkit-scrollbar {
width: 6px;
}
.batch-list::-webkit-scrollbar-thumb {
background: var(--color-border-primary);
border-radius: 3px;
}
.batch-item {
display: flex;
align-items: center;
gap: 12px;
background: var(--color-bg-secondary);
padding: 12px;
border-radius: 8px;
border: 1px solid var(--color-border-primary);
}
.batch-index {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--color-primary-rgb-2);
color: var(--color-primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
flex-shrink: 0;
}
.batch-inputs {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.batch-input-field {
display: flex;
align-items: center;
gap: 8px;
}
.batch-label {
width: 70px;
color: var(--color-text-secondary);
font-size: 13px;
text-align: right;
flex-shrink: 0;
}
.batch-input {
flex: 1;
padding: 8px 12px;
font-size: 13px;
}
.remove-batch-btn {
background: transparent;
border: none;
color: var(--color-danger);
width: 32px;
height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
}
.remove-batch-btn:hover:not(:disabled) {
background: rgba(245, 108, 108, 0.1);
}
.remove-batch-btn:disabled {
color: var(--color-text-tertiary);
cursor: not-allowed;
}
/* 路径输入样式 */
.path-input-section {
display: flex;