模型上传管理和绑定构件,构建树形

This commit is contained in:
cjh
2026-06-10 17:02:45 +08:00
parent 3ac2fe48dd
commit 965bb309db
27 changed files with 2476 additions and 94 deletions

View File

@@ -1 +1 @@
VITE_API_BASE=http://syliang.nat100.top
VITE_API_BASE=http://localhost:8099

12
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,12 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/
.idea/

8
.idea/bim-admin.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/bim-admin.iml" filepath="$PROJECT_DIR$/.idea/bim-admin.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

0
public/basis/.gitkeep Normal file
View File

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -8,20 +8,25 @@
>
<DevSidebar v-if="showDevSidebar" v-model:collapsed="appStore.devSidebarCollapsed" />
<div class="app-content">
<AssistantFabs />
<AssistantFabs v-if="!hideGlobalOverlays" />
<ModelCenter v-if="!hideGlobalOverlays" />
<RouterView />
</div>
</div>
</template>
<script setup>
import { RouterView } from "vue-router";
import { computed } from "vue";
import { RouterView, useRoute } from "vue-router";
import AssistantFabs from "./components/assistant-fabs/index.vue";
import DevSidebar from "./components/dev/DevSidebar.vue";
import ModelCenter from "./components/model-center/index.vue";
import { useAppStore } from "./stores/app.js";
const appStore = useAppStore();
const route = useRoute();
const showDevSidebar = import.meta.env.DEV;
const hideGlobalOverlays = computed(() => route.path === "/progress/binding");
</script>
<style>

View File

@@ -122,7 +122,7 @@ onBeforeUnmount(() => {
.ai-fab,
.issue-fab {
position: absolute;
top: 30px;
top: 38px;
width: 54px;
height: 54px;
border-radius: 18px;
@@ -143,8 +143,8 @@ onBeforeUnmount(() => {
-webkit-backdrop-filter: blur(16px);
}
.ai-fab { left: 30px; }
.issue-fab { left: 100px; }
.ai-fab { left: 28px; }
.issue-fab { left: 88px; }
.ai-fab-icon,
.issue-fab-icon {

View File

@@ -2,7 +2,7 @@
<div class="page-canvas">
<header class="page-topbar">
<div class="topbar-center">
<div class="model-title">{{ title }}</div>
<div class="model-title">{{ displayTitle }}</div>
</div>
<div v-if="$slots['topbar-right']" class="topbar-right">
<slot name="topbar-right"></slot>
@@ -21,13 +21,18 @@
<script setup>
import ModelPlaceholder from "../model-placeholder/index.vue";
import { computed } from "vue";
import { useModelCenterStore } from "../../stores/modelCenter.js";
defineProps({
const props = defineProps({
title: {
type: String,
default: "XXX特大桥主体模型.rvt",
default: "",
},
});
const modelCenterStore = useModelCenterStore();
const displayTitle = computed(() => props.title || modelCenterStore.activeModelName);
</script>
<style scoped>

View File

@@ -0,0 +1,911 @@
<template>
<section class="model-context-bar" aria-label="全局模型上下文">
<div class="model-context-main">
<button class="model-context-trigger" type="button" aria-haspopup="dialog" :aria-expanded="switcherOpen" @click="toggleSwitcher">
<span class="model-context-trigger-meta">当前模型</span>
<span class="model-context-trigger-name">{{ store.activeModelName }}</span>
<span class="model-context-trigger-caret" aria-hidden="true"></span>
</button>
<button class="model-add-icon-btn" type="button" aria-label="添加模型" title="添加模型" @click="openDrawer">
<svg class="model-add-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 6v12M6 12h12" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" />
</svg>
</button>
</div>
<div v-if="switcherOpen" class="model-switcher-panel">
<div v-if="store.models.length" class="model-switcher-list" role="list">
<button
v-for="model in store.models"
:key="model.id"
class="model-switcher-option"
:class="{ 'is-active': model.id === store.activeId }"
type="button"
@click="selectModel(model.id)"
>
<span class="model-switcher-option-name">{{ model.name }}</span>
<span v-if="model.id === store.activeId" class="model-switcher-option-current">当前</span>
</button>
</div>
<div v-else class="model-switcher-empty">暂无可切换模型</div>
</div>
</section>
<section v-if="!store.models.length" class="model-empty-state" aria-label="无模型状态">
<div class="model-empty-title">当前项目暂无可用模型</div>
<div class="model-empty-text">上传 BIM 模型后才可查看首页详细数据业务联动卡片与模型明细当前项目已在外部选定无需再次选择项目</div>
<div class="model-empty-actions">
<button class="model-btn model-btn-accent model-empty-btn" type="button" @click="openDrawer">添加模型</button>
</div>
</section>
<div v-if="drawerOpen" class="model-drawer-backdrop" @click="closeDrawer"></div>
<aside v-if="drawerOpen" class="model-drawer" aria-label="模型上传与管理">
<header class="model-drawer-header">
<div class="model-drawer-title">模型中心</div>
<button class="model-drawer-close" type="button" aria-label="关闭" @click="closeDrawer">×</button>
</header>
<div class="model-drawer-body">
<section class="model-drawer-panel">
<div class="model-upload-title">上传模型</div>
<div class="model-upload-drop" :class="{ 'is-dragging': dragging }" @click="pickFile" @dragover.prevent="dragging = true" @dragleave.prevent="dragging = false" @drop.prevent="onDrop">
<div>
<div class="model-upload-drop-title">上传模型文件</div>
<div class="model-upload-drop-sub">点击选择文件支持 IFC / RVT / NWD / FBX</div>
</div>
<button class="model-btn model-btn-ghost model-upload-drop-btn" type="button" @click.stop="pickFile">选择文件</button>
<input ref="fileInputRef" type="file" accept=".ifc,.rvt,.nwd,.fbx" hidden @change="onFileChange" />
</div>
<div v-if="form.file" class="model-upload-file">
<div class="model-upload-file-name">{{ form.file.name }}</div>
<button class="model-upload-file-remove" type="button" aria-label="删除" @click="clearFile">×</button>
</div>
<div class="model-upload-fields">
<label class="model-upload-field">
<span class="k">模型名称</span>
<input v-model.trim="form.name" class="model-input" placeholder="例如:特大桥主体模型 V1" />
</label>
<label class="model-upload-field model-upload-type-field">
<span class="k">模型类型</span>
<select v-model="form.type" class="model-input">
<option value="bridge">桥梁主体</option>
<option value="building">建筑主体</option>
<option value="structure">结构模型</option>
<option value="mechanical">机电模型</option>
</select>
</label>
</div>
<div class="model-drawer-footer">
<button class="model-btn model-btn-accent model-upload-submit-btn" type="button" :disabled="!canUpload" @click="submitUpload">{{ uploading ? "上传中..." : "上传模型" }}</button>
</div>
</section>
<section class="model-drawer-panel model-center-list-section">
<div class="model-manage-title">已上传模型</div>
<div v-if="errorText" class="model-manage-error">{{ errorText }}</div>
<div v-if="loading" class="model-manage-empty">正在加载模型列表...</div>
<div v-if="store.models.length" class="model-manage-list">
<div class="model-manage-row is-head">
<div>模型名称</div>
<div>类型</div>
<div>文件</div>
<div>状态</div>
<div>操作</div>
</div>
<div v-for="model in store.models" :key="model.id" class="model-manage-row" :class="{ 'is-editing': editingId === model.id }">
<div class="model-manage-row-name">
<input v-if="editingId === model.id" v-model.trim="editForm.name" class="model-row-input" placeholder="请输入模型名称" />
<span v-else>{{ model.name }}</span>
</div>
<div class="model-manage-row-cell">
<select v-if="editingId === model.id" v-model="editForm.type" class="model-row-select">
<option v-for="option in modelTypeOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
<span v-else>{{ typeLabel(model.type) }}</span>
</div>
<div class="model-manage-row-cell model-manage-row-file">
<span>{{ model.fileName }}</span>
<small>{{ model.uploadedAt }} · {{ formatFileSize(model.fileSize) }}</small>
</div>
<div><span class="model-manage-row-status">{{ model.id === store.activeId ? "当前" : "可用" }}</span></div>
<div class="model-manage-row-actions">
<template v-if="editingId === model.id">
<button class="model-btn model-manage-row-btn" type="button" :disabled="operatingId === model.id || !editForm.name" @click="saveModelEdit(model.id)">保存</button>
<button class="model-manage-row-btn model-manage-row-cancel" type="button" :disabled="operatingId === model.id" @click="cancelModelEdit">取消</button>
</template>
<template v-else>
<button class="model-btn model-manage-row-btn" type="button" :disabled="operatingId === model.id" @click="selectModel(model.id)">切换</button>
<button class="model-btn model-manage-row-btn" type="button" :disabled="operatingId === model.id" @click="startModelEdit(model)">编辑</button>
<button class="model-manage-row-btn model-manage-row-delete" type="button" :disabled="operatingId === model.id" @click="removeModel(model.id)">删除</button>
</template>
</div>
</div>
</div>
<div v-else-if="!loading" class="model-manage-empty">暂无已上传模型请先在上方添加模型</div>
</section>
</div>
</aside>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import { useModelCenterStore } from "../../stores/modelCenter.js";
import { modelManagementApi } from "../../service/api/modelManagement.js";
const store = useModelCenterStore();
const drawerOpen = ref(false);
const switcherOpen = ref(false);
const dragging = ref(false);
const fileInputRef = ref(null);
const form = reactive({ file: null, name: "", type: "bridge" });
const loading = ref(false);
const uploading = ref(false);
const operatingId = ref("");
const editingId = ref("");
const errorText = ref("");
const editForm = reactive({ name: "", type: "bridge" });
const modelTypeOptions = [
{ value: "bridge", label: "桥梁主体" },
{ value: "building", label: "建筑主体" },
{ value: "structure", label: "结构模型" },
{ value: "mechanical", label: "机电模型" },
];
const canUpload = computed(() => !!form.file && !!form.name && !uploading.value);
onMounted(() => {
loadModels();
});
function openDrawer() {
switcherOpen.value = false;
drawerOpen.value = true;
loadModels();
}
function closeDrawer() {
cancelModelEdit();
drawerOpen.value = false;
}
function toggleSwitcher() {
switcherOpen.value = !switcherOpen.value;
}
async function loadModels() {
loading.value = true;
errorText.value = "";
try {
await store.loadModels();
} catch (e) {
console.error("加载模型列表失败:", e);
errorText.value = e?.message || "加载模型列表失败";
} finally {
loading.value = false;
}
}
async function selectModel(id) {
if (editingId.value) return;
operatingId.value = id;
errorText.value = "";
try {
await store.setActiveModel(id);
switcherOpen.value = false;
} catch (e) {
console.error("设置当前模型失败:", e);
errorText.value = e?.message || "设置当前模型失败";
} finally {
operatingId.value = "";
}
}
function resolveModelType(type) {
const text = String(type || "");
return modelTypeOptions.find((item) => item.value === text || item.label === text)?.value || "bridge";
}
function startModelEdit(model) {
editingId.value = model.id;
editForm.name = model.name;
editForm.type = resolveModelType(model.type);
errorText.value = "";
}
function cancelModelEdit() {
editingId.value = "";
editForm.name = "";
editForm.type = "bridge";
}
async function saveModelEdit(id) {
if (!editForm.name) return;
operatingId.value = id;
errorText.value = "";
try {
await saveModelUpdate(id);
cancelModelEdit();
} catch (e) {
console.error("更新模型失败:", e);
errorText.value = e?.message || "更新模型失败";
} finally {
operatingId.value = "";
}
}
async function saveModelUpdate(id) {
const payload = {
name: editForm.name,
type: editForm.type,
};
if (typeof store.updateModel === "function") {
await store.updateModel(id, payload);
return;
}
await modelManagementApi.update(id, {
modelName: payload.name,
modelType: payload.type,
});
await loadModels();
}
function pickFile() {
fileInputRef.value?.click();
}
function applyFile(file) {
if (!file) return;
form.file = file;
if (!form.name) form.name = file.name.replace(/\.[^.]+$/, "");
}
function onFileChange(event) {
applyFile(event.target.files?.[0]);
}
function onDrop(event) {
dragging.value = false;
applyFile(event.dataTransfer?.files?.[0]);
}
function clearFile() {
form.file = null;
if (fileInputRef.value) fileInputRef.value.value = "";
}
async function submitUpload() {
if (!canUpload.value) return;
uploading.value = true;
errorText.value = "";
try {
await store.addModel({
name: form.name,
type: form.type,
file: form.file,
});
form.name = "";
form.type = "bridge";
clearFile();
} catch (e) {
console.error("上传模型失败:", e);
errorText.value = e?.message || "上传模型失败";
} finally {
uploading.value = false;
}
}
async function removeModel(id) {
if (editingId.value) return;
operatingId.value = id;
errorText.value = "";
try {
await store.removeModel(id);
} catch (e) {
console.error("删除模型失败:", e);
errorText.value = e?.message || "删除模型失败";
} finally {
operatingId.value = "";
}
}
function typeLabel(type) {
const text = String(type || "");
return modelTypeOptions.find((item) => item.value === text || item.label === text)?.label || text || "模型";
}
function formatFileSize(size) {
const value = Number(size || 0);
if (!value) return "--";
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
</script>
<style scoped>
.model-context-bar {
position: absolute;
left: 184px;
top: 40px;
z-index: 80;
}
.model-context-main {
width: 236px;
min-width: 0;
max-width: calc(100vw - 56px);
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 18px;
border: 1px solid rgba(255, 187, 92, 0.18);
background: linear-gradient(180deg, rgba(16, 28, 42, 0.74), rgba(10, 18, 30, 0.62));
box-shadow: 0 14px 34px rgba(4, 10, 20, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.05);
backdrop-filter: blur(14px);
}
.model-context-trigger {
flex: 1;
min-width: 0;
height: 38px;
padding: 0 14px;
border-radius: 14px;
border: 1px solid rgba(255, 196, 118, 0.18);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02));
color: rgba(247, 249, 252, 0.96);
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.model-context-trigger:hover {
border-color: rgba(255, 196, 118, 0.34);
box-shadow: 0 0 0 3px rgba(255, 196, 118, 0.08);
}
.model-context-trigger-meta {
flex: none;
font-size: 12px;
font-weight: 800;
color: rgba(255, 214, 156, 0.82);
}
.model-context-trigger-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 900;
}
.model-context-trigger-caret {
flex: none;
font-size: 12px;
color: rgba(255, 214, 156, 0.88);
}
.model-add-icon-btn {
flex: none;
width: 38px;
height: 38px;
border-radius: 999px;
border: 1px solid rgba(83, 214, 206, 0.28);
background: radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.22), transparent 42%), linear-gradient(180deg, rgba(83, 214, 206, 0.30), rgba(43, 191, 178, 0.16));
color: rgba(230, 255, 252, 0.98);
display: grid;
place-items: center;
cursor: pointer;
}
.model-add-icon-btn:hover {
border-color: rgba(83, 214, 206, 0.48);
box-shadow: 0 0 0 3px rgba(83, 214, 206, 0.12);
}
.model-add-icon {
width: 18px;
height: 18px;
}
.model-switcher-panel {
position: absolute;
left: 0;
top: calc(100% + 10px);
width: min(320px, calc(100vw - 64px));
max-height: min(360px, calc(100vh - 150px));
overflow: auto;
z-index: 86;
border-radius: 16px;
border: 1px solid rgba(255, 196, 118, 0.22);
background: linear-gradient(180deg, rgba(10, 18, 30, 0.96), rgba(8, 14, 23, 0.94));
box-shadow: 0 22px 54px rgba(4, 10, 20, 0.52);
padding: 10px;
}
.model-switcher-list {
display: grid;
gap: 4px;
}
.model-switcher-option {
width: 100%;
min-height: 38px;
border: 0;
border-radius: 10px;
background: transparent;
color: rgba(238, 244, 249, 0.90);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
text-align: left;
cursor: pointer;
}
.model-switcher-option:hover,
.model-switcher-option.is-active {
background: rgba(255, 196, 118, 0.12);
color: rgba(255, 246, 232, 0.98);
}
.model-switcher-option-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 800;
}
.model-switcher-option-current {
flex: none;
color: rgba(255, 220, 164, 0.86);
font-size: 12px;
font-weight: 900;
}
.model-switcher-empty,
.model-manage-empty {
padding: 18px 14px;
border-radius: 16px;
border: 1px dashed rgba(255, 196, 118, 0.22);
text-align: center;
color: rgba(210, 220, 233, 0.72);
font-size: 13px;
}
.model-manage-error {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 120, 120, 0.20);
background: rgba(255, 120, 120, 0.08);
color: rgba(255, 200, 200, 0.94);
font-size: 12px;
font-weight: 800;
}
.model-empty-state {
position: absolute;
left: 50%;
top: 52%;
transform: translate(-50%, -50%);
z-index: 16;
width: min(640px, calc(100% - 40px));
text-align: center;
}
.model-empty-title {
font-size: 34px;
line-height: 1.2;
font-weight: 900;
color: rgba(17, 30, 46, 0.94);
text-shadow: 0 8px 28px rgba(255, 255, 255, 0.78);
}
.model-empty-text {
margin: 14px auto 0;
max-width: 560px;
font-size: 14px;
line-height: 1.8;
color: rgba(73, 88, 111, 0.78);
}
.model-empty-actions {
margin-top: 22px;
display: flex;
justify-content: center;
}
.model-drawer-backdrop {
position: fixed;
inset: 0;
z-index: 189;
background: rgba(3, 8, 16, 0.52);
backdrop-filter: blur(4px);
}
.model-drawer {
position: fixed;
left: 50%;
top: 50%;
width: min(940px, calc(100% - 48px));
height: min(760px, calc(100% - 80px));
z-index: 190;
transform: translate(-50%, -50%);
border: 1px solid rgba(255, 196, 118, 0.18);
border-radius: 24px;
background: radial-gradient(circle at top right, rgba(255, 196, 118, 0.10), transparent 28%), linear-gradient(180deg, rgba(8, 14, 24, 0.98), rgba(9, 14, 22, 0.96));
box-shadow: 0 28px 74px rgba(4, 10, 20, 0.54);
display: flex;
flex-direction: column;
overflow: hidden;
}
.model-drawer-header {
flex: none;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 22px 22px 18px;
border-bottom: 1px solid rgba(255, 196, 118, 0.10);
}
.model-drawer-title {
font-size: 22px;
font-weight: 900;
color: rgba(245, 248, 252, 0.96);
}
.model-drawer-close {
width: 30px;
height: 30px;
border-radius: 10px;
border: 1px solid rgba(255, 196, 118, 0.16);
background: rgba(255, 255, 255, 0.05);
color: rgba(247, 249, 252, 0.88);
cursor: pointer;
}
.model-drawer-body {
flex: 1;
min-height: 0;
overflow: hidden;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
padding: 14px 22px 22px;
}
.model-drawer-panel {
display: grid;
gap: 10px;
}
.model-upload-title,
.model-manage-title {
color: rgba(245, 248, 252, 0.95);
font-size: 15px;
font-weight: 900;
}
.model-upload-drop {
min-height: 76px;
border-radius: 16px;
border: 1px dashed rgba(255, 196, 118, 0.22);
background: rgba(255, 255, 255, 0.035);
padding: 14px 16px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
cursor: pointer;
}
.model-upload-drop.is-dragging {
border-color: rgba(83, 214, 206, 0.48);
background: rgba(83, 214, 206, 0.08);
}
.model-upload-drop-title {
font-size: 15px;
font-weight: 900;
color: rgba(245, 248, 252, 0.94);
}
.model-upload-drop-sub {
margin-top: 4px;
font-size: 12px;
color: rgba(205, 217, 229, 0.68);
}
.model-upload-file {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border-radius: 14px;
border: 1px solid rgba(255, 196, 118, 0.12);
background: rgba(255, 255, 255, 0.04);
}
.model-upload-file-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 800;
color: rgba(241, 246, 251, 0.92);
}
.model-upload-file-remove {
width: 28px;
height: 28px;
border-radius: 999px;
border: 1px solid rgba(255, 196, 118, 0.16);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 220, 164, 0.92);
cursor: pointer;
}
.model-upload-fields {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 14px;
}
.model-upload-field {
display: grid;
gap: 6px;
}
.model-upload-field .k {
color: rgba(215, 224, 232, 0.78);
font-size: 12px;
font-weight: 900;
}
.model-input {
height: 38px;
min-width: 0;
border-radius: 10px;
border: 1px solid rgba(255, 196, 118, 0.18);
background: rgba(255, 255, 255, 0.06);
color: rgba(245, 248, 252, 0.94);
padding: 0 10px;
outline: none;
}
.model-input option {
color: #111e2e;
}
.model-drawer-footer {
display: flex;
justify-content: flex-end;
}
.model-btn {
min-height: 32px;
border-radius: 10px;
border: 1px solid rgba(255, 196, 118, 0.16);
background: rgba(255, 255, 255, 0.05);
color: rgba(247, 249, 252, 0.9);
padding: 0 12px;
font-size: 12px;
font-weight: 900;
cursor: pointer;
}
.model-btn:disabled {
opacity: 0.48;
cursor: not-allowed;
}
.model-btn-accent {
border-color: rgba(83, 214, 206, 0.34);
background: linear-gradient(180deg, rgba(83, 214, 206, 0.34), rgba(43, 191, 178, 0.20));
color: rgba(230, 255, 252, 0.98);
}
.model-btn-ghost {
background: rgba(255, 255, 255, 0.04);
}
.model-center-list-section {
min-height: 0;
border-top: 1px solid rgba(255, 196, 118, 0.12);
padding-top: 12px;
}
.model-manage-list {
display: grid;
align-content: start;
height: 332px;
max-height: 332px;
border: 1px solid rgba(255, 196, 118, 0.12);
border-radius: 16px;
overflow: auto;
background: rgba(255, 255, 255, 0.025);
}
.model-manage-row {
display: grid;
grid-template-columns: minmax(160px, 1.15fr) 116px minmax(190px, 1fr) 72px 190px;
align-items: center;
gap: 10px;
min-height: 58px;
padding: 10px 16px;
border-top: 1px solid rgba(255, 196, 118, 0.10);
}
.model-manage-row:first-child {
border-top: 0;
}
.model-manage-row.is-head {
position: sticky;
top: 0;
z-index: 2;
min-height: 40px;
background: rgba(255, 196, 118, 0.08);
color: rgba(255, 220, 164, 0.86);
font-size: 12px;
font-weight: 900;
}
.model-manage-row-name,
.model-manage-row-cell {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-manage-row.is-editing {
background: rgba(83, 214, 206, 0.08);
}
.model-manage-row-name {
color: rgba(245, 248, 252, 0.94);
font-size: 13px;
font-weight: 900;
}
.model-manage-row-cell {
color: rgba(203, 217, 231, 0.72);
font-size: 12px;
}
.model-row-input,
.model-row-select {
width: 100%;
min-width: 0;
height: 34px;
border-radius: 9px;
border: 1px solid rgba(83, 214, 206, 0.22);
background: rgba(255, 255, 255, 0.08);
color: rgba(247, 249, 252, 0.94);
padding: 0 10px;
font-size: 12px;
font-weight: 800;
outline: none;
}
.model-row-select {
cursor: pointer;
}
.model-row-select option {
color: #12212a;
}
.model-manage-row-file {
display: grid;
gap: 3px;
}
.model-manage-row-file span,
.model-manage-row-file small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-manage-row-file small {
color: rgba(203, 217, 231, 0.52);
font-size: 11px;
}
.model-manage-row-status {
display: inline-flex;
padding: 4px 9px;
border-radius: 999px;
background: rgba(106, 230, 196, 0.10);
color: rgba(166, 255, 225, 0.92);
font-size: 11px;
font-weight: 900;
}
.model-manage-row-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.model-manage-row-btn {
min-width: 52px;
height: 30px;
}
.model-manage-row-delete {
border: 1px solid rgba(255, 120, 120, 0.18);
background: rgba(255, 120, 120, 0.08);
color: rgba(255, 190, 190, 0.92);
cursor: pointer;
}
.model-manage-row-cancel {
border: 1px solid rgba(255, 120, 120, 0.18);
background: rgba(255, 120, 120, 0.08);
color: rgba(255, 190, 190, 0.92);
cursor: pointer;
}
@media (max-width: 760px) {
.model-context-bar {
left: 156px;
top: 38px;
}
.model-context-main {
width: calc(100vw - 180px);
max-width: 236px;
}
.model-context-trigger {
min-width: 0;
}
.model-drawer {
width: calc(100% - 24px);
height: calc(100% - 48px);
}
.model-drawer-body {
overflow: auto;
grid-template-rows: auto auto;
padding: 14px;
}
.model-upload-fields,
.model-upload-drop,
.model-manage-row {
grid-template-columns: 1fr;
}
.model-manage-row.is-head {
display: none;
}
.model-manage-list {
height: auto;
max-height: none;
}
.model-manage-row-actions {
justify-content: flex-start;
}
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div class="model-viewer">
<div ref="containerRef" class="engine-container"></div>
<div ref="containerRef" class="engine-container" :class="{ 'is-hidden': hideEngine }"></div>
<div class="engine-state" v-if="stateText">{{ stateText }}</div>
<button v-if="showCodeBtn" class="model-code-btn" type="button" :disabled="encoding" @click="onModelCodeClick">
@@ -35,9 +35,10 @@
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { BimEngine } from "iflow-engin-xg";
import {progressApi} from "../../service/api/progress.js";
import { useModelCenterStore } from "../../stores/modelCenter.js";
const props = defineProps({
@@ -45,15 +46,21 @@ const props = defineProps({
type: String,
default: "",
},
showConstructTreeButton: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["componentBindEncoding","codeBindComponent"]);
const emit = defineEmits(["componentBindEncoding", "codeBindComponent", "modelLoaded"]);
const modelCenterStore = useModelCenterStore();
defineExpose({
highlightModels,
cancelLightModels,
otherLightModels,
getData,
getConstructTreeData,
});
let positionIdsCache = [];
@@ -87,9 +94,14 @@ function getData() {
return { positionIds: positionIdsCache };
}
function getConstructTreeData() {
return engine?.engine?.getConstructTreeData?.() || { level: [], type: [], major: [] };
}
const containerRef = ref(null);
const loading = ref(true);
const errorText = ref("");
const pendingText = ref("");
const encoding = ref(false);
const showCodeBtn = ref(false);
const showConfirm = ref(false);
@@ -99,21 +111,107 @@ let disposed = false;
let resizeObserver = null;
let unsubEvents = [];
let toastTimer = null;
let loadAttempt = 0;
let modelPollingTimer = null;
let loadedModelUrl = "";
let hasLoadedModel = false;
const prefetchedModelUrls = new Set();
const resolvedModelUrl = computed(() => {
const fromProp = String(props.modelUrl || "").trim();
if (fromProp) return fromProp;
const activeModel = modelCenterStore.activeModel;
if (activeModel) {
const fromStore = String(modelCenterStore.activeModelUrl || "").trim();
return fromStore;
}
const fromEnv = String(import.meta.env.VITE_BIM_MODEL_URL || "").trim();
if (fromEnv) return fromEnv;
return "https://lyz-1259524260.cos.ap-guangzhou.myqcloud.com/iflow/models/417664a3-76c8-4d94-9344-1337246a5d4e";
return "https://pub-8092fd0db2e14822a9f742ad0050750c.r2.dev/d6f7d87b-1376-4443-9a2d-0ac061ac35b8";
});
const hideEngine = computed(() => Boolean(pendingText.value));
const stateText = computed(() => {
if (errorText.value) return `模型加载失败:${errorText.value}`;
if (pendingText.value) return pendingText.value;
if (loading.value) return "模型加载中...";
return "";
});
function getActiveModelConvertStatus() {
const status = String(modelCenterStore.activeModel?.convertStatus || "").trim();
return status || "PENDING";
}
function updatePendingModelText() {
const modelName = modelCenterStore.activeModelName || "当前模型";
const status = getActiveModelConvertStatus();
const label = status === "FAILED" || status === "FAILD" ? "转换失败" : "模型转换中";
pendingText.value = `${label}${modelName},正在等待转换后的模型地址...`;
}
function stopModelPolling() {
if (modelPollingTimer) clearInterval(modelPollingTimer);
modelPollingTimer = null;
}
function startModelPolling() {
if (modelPollingTimer) return;
const pollingModelId = String(modelCenterStore.activeId || "");
updatePendingModelText();
modelPollingTimer = setInterval(async () => {
try {
await modelCenterStore.loadModels({ preferredActiveId: pollingModelId });
prefetchAvailableModels();
const stillCurrent = String(modelCenterStore.activeId || "") === pollingModelId;
if (stillCurrent && resolvedModelUrl.value) {
stopModelPolling();
pendingText.value = "";
await loadResolvedModel();
} else {
updatePendingModelText();
}
} catch (error) {
console.error("刷新模型转换状态失败:", error);
}
}, 5000);
}
function prefetchAvailableModels() {
for (const model of modelCenterStore.models || []) {
const url = String(model?.url || "").trim();
if (!url || prefetchedModelUrls.has(url) || url === loadedModelUrl) continue;
prefetchedModelUrls.add(url);
prefetchModelUrl(url);
}
}
function prefetchModelUrl(url) {
["info", "lod0"].forEach((path) => {
fetch(`${url}/${path}`, { cache: "force-cache" }).catch((error) => {
console.warn(`[model-placeholder] prefetch ${path} failed:`, url, error);
});
});
}
async function initializeEngineComponent() {
await Promise.resolve(
engine.engine.initialize({
backgroundColor: 0x333333,
showViewCube: true,
})
);
engine.constructTreeBtn?.setVisible(props.showConstructTreeButton);
}
async function resetEngineForModelSwitch(modelUrl) {
if (!hasLoadedModel || loadedModelUrl === modelUrl) return;
loadedModelUrl = "";
hasLoadedModel = false;
await initializeEngineComponent();
}
function getEngineComponent() {
return engine?.engine?.getEngineComponent?.();
}
@@ -137,6 +235,7 @@ function bindEncodingEvents() {
const componentWbsCodes = await progressApi.getCodeWbsMappings()
if (disposed) return;
handleModelLoadingCompleted();
emit("modelLoaded", getConstructTreeData());
console.log("加载模型2")
engine.engine?.setComponentWbsCodes(componentWbsCodes);
}),
@@ -199,6 +298,49 @@ function showToast(text) {
}, 2000);
}
async function loadResolvedModel() {
if (!engine?.engine || disposed) return;
const modelUrl = resolvedModelUrl.value;
if (!modelUrl) {
errorText.value = "";
updatePendingModelText();
startModelPolling();
loading.value = false;
return;
}
stopModelPolling();
pendingText.value = "";
if (modelUrl === loadedModelUrl) {
loading.value = false;
errorText.value = "";
return;
}
const attempt = ++loadAttempt;
loading.value = true;
errorText.value = "";
try {
await resetEngineForModelSwitch(modelUrl);
if (disposed || attempt !== loadAttempt) return;
console.log("[model-placeholder] loadModel:", modelUrl);
await Promise.resolve(
engine.engine.loadModel([modelUrl], {
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
})
);
if (disposed || attempt !== loadAttempt) return;
loadedModelUrl = modelUrl;
hasLoadedModel = true;
loading.value = false;
} catch (error) {
if (disposed || attempt !== loadAttempt) return;
const message = error instanceof Error ? error.message : "unknown error";
errorText.value = `${message}${modelUrl}`;
loading.value = false;
}
}
function onDebugCheck() {
const comp = getEngineComponent();
if (!comp) {
@@ -254,23 +396,17 @@ onMounted(async () => {
bindEncodingEvents();
await Promise.resolve(
engine.engine.initialize({
backgroundColor: 0x333333,
showViewCube: true,
})
);
await initializeEngineComponent();
if (disposed) return;
await Promise.resolve(
engine.engine.loadModel([resolvedModelUrl.value], {
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
})
);
engine.constructTreeBtn.setVisible(false);
await modelCenterStore.loadModels().catch((error) => {
console.error("加载模型中心列表失败:", error);
});
prefetchAvailableModels();
if (disposed) return;
await loadResolvedModel();
if (disposed) return;
resizeObserver = new ResizeObserver(() => {
@@ -278,16 +414,34 @@ onMounted(async () => {
engine?.resize?.();
});
resizeObserver.observe(containerRef.value);
loading.value = false;
} catch (error) {
errorText.value = error instanceof Error ? error.message : "unknown error";
loading.value = false;
}
});
watch(resolvedModelUrl, (next, previous) => {
if (!engine?.engine) return;
if (!next) {
startModelPolling();
return;
}
if (next === previous) return;
loadResolvedModel();
});
watch(
() => [modelCenterStore.activeId, modelCenterStore.activeModel?.convertStatus, modelCenterStore.activeModelUrl],
() => {
if (!engine?.engine) return;
loadResolvedModel();
}
);
onBeforeUnmount(() => {
disposed = true;
if (toastTimer) clearTimeout(toastTimer);
stopModelPolling();
unbindEncodingEvents();
resizeObserver?.disconnect?.();
resizeObserver = null;
@@ -310,12 +464,18 @@ onBeforeUnmount(() => {
inset: 0;
}
.engine-container.is-hidden {
opacity: 0;
pointer-events: none;
}
.engine-state {
position: absolute;
left: 50%;
top: 16px;
top: 52px;
transform: translateX(-50%);
z-index: 5;
max-width: min(560px, calc(100vw - 48px));
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(20, 28, 34, 0.72);
@@ -323,6 +483,10 @@ onBeforeUnmount(() => {
font-size: 12px;
font-weight: 700;
padding: 6px 10px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}

View File

@@ -1,7 +1,7 @@
<template>
<div class="change-page">
<div class="canvas">
<header class="topbar"><div class="topbar-center"><div class="model-title">XXX特大桥主体模型.rvt</div></div></header>
<header class="topbar"><div class="topbar-center"><div class="model-title">{{ modelCenterStore.activeModelName }}</div></div></header>
<section class="model-stage">
<ModelPlaceholder />
@@ -74,6 +74,9 @@
<script setup>
import { computed, ref } from "vue";
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
import { useModelCenterStore } from "../../stores/modelCenter.js";
const modelCenterStore = useModelCenterStore();
const structures = [
{ id: "S-001", name: "主桥-0#墩" }, { id: "S-002", name: "主桥-1#墩" }, { id: "S-003", name: "主桥-2#墩" },

View File

@@ -5,7 +5,7 @@
<header class="topbar">
<div class="topbar-left"></div>
<div class="topbar-center">
<div class="model-title">XXX特大桥主体模型.rvt</div>
<div class="model-title">{{ modelCenterStore.activeModelName }}</div>
</div>
<div class="topbar-right">
<div class="status-chip">
@@ -23,7 +23,7 @@
<section class="model-stage">
<div class="model-shell"></div>
<ModelPlaceholder />
<ModelPlaceholder show-construct-tree-button />
</section>
<button class="cards-toggle cards-toggle-left" type="button" aria-label="展开/收起左侧卡片" @click="leftOpen = !leftOpen">{{ leftOpen ? "" : "" }}</button>
@@ -119,6 +119,9 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
import { useModelCenterStore } from "../../stores/modelCenter.js";
const modelCenterStore = useModelCenterStore();
const mock = {
finishDate: "2027-12-30",

View File

@@ -1,7 +1,7 @@
<template>
<div class="inspection-page">
<div class="canvas">
<header class="topbar"><div class="topbar-center"><div class="model-title">XXX特大桥主体模型.rvt</div></div></header>
<header class="topbar"><div class="topbar-center"><div class="model-title">{{ modelCenterStore.activeModelName }}</div></div></header>
<section class="model-stage">
<ModelPlaceholder />
@@ -162,6 +162,9 @@
<script setup>
import { computed, ref } from "vue";
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
import { useModelCenterStore } from "../../stores/modelCenter.js";
const modelCenterStore = useModelCenterStore();
const leftCollapsed = ref(false);
const panoramaFileName = ref("");

View File

@@ -1,7 +1,7 @@
<template>
<div class="material-page">
<div class="canvas">
<header class="topbar"><div class="topbar-center"><div class="model-title">XXX特大桥主体模型.rvt</div></div></header>
<header class="topbar"><div class="topbar-center"><div class="model-title">{{ modelCenterStore.activeModelName }}</div></div></header>
<section class="model-stage">
<ModelPlaceholder />
@@ -60,6 +60,9 @@
<script setup>
import { computed, reactive, ref } from "vue";
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
import { useModelCenterStore } from "../../stores/modelCenter.js";
const modelCenterStore = useModelCenterStore();
const structures = [
{ id: "S-001", name: "主桥-0#墩" }, { id: "S-002", name: "主桥-1#墩" }, { id: "S-003", name: "主桥-2#墩" },
@@ -153,7 +156,7 @@ function onTreeRowClick(row) { if (row.leaf) selectedStructureId.value = row.id;
.model-title { font-size: 20px; letter-spacing: 1px; font-weight: 700; color: rgba(255,128,52,.95); text-shadow: 0 2px 10px rgba(0,0,0,.25); }
.model-stage { position: absolute; inset: 0; }
.module-topbar { position: absolute; left: 50%; top: 20px; transform: translateX(-50%); width: min(1060px, calc(100% - 180px)); z-index: 99; }
.module-topbar { position: absolute; left: 514px; top: 36px; width: min(1060px, calc(100% - 554px)); z-index: 99; }
.field { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; border-radius: 18px; border: 1px solid rgba(83,214,206,.26); background: radial-gradient(420px 120px at 18% 0%, rgba(83,214,206,.22), transparent 70%), linear-gradient(180deg, rgba(18,32,40,.94), rgba(15,25,32,.88)); box-shadow: 0 20px 56px rgba(0,0,0,.28), 0 0 0 1px rgba(83,214,206,.12) inset; padding: 10px 14px; }
.field-label { font-weight: 900; color: rgba(244,252,255,.92); font-size: 14px; white-space: nowrap; }
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border-radius: 999px; border: 1px solid rgba(255,255,255,.12); background: rgba(0,0,0,.2); color: rgba(255,255,255,.88); font-weight: 800; font-size: 13px; white-space: nowrap; }
@@ -197,7 +200,7 @@ function onTreeRowClick(row) { if (row.leaf) selectedStructureId.value = row.id;
.table tbody tr:hover td { background: linear-gradient(90deg, rgba(69,102,130,.36), rgba(56,86,113,.32)); }
@media (max-width: 1300px) {
.module-topbar { width: calc(100% - 32px); }
.module-topbar { left: 24px; right: 24px; top: 108px; width: auto; }
.field { flex-wrap: wrap; }
.sidepanel { width: 280px; }
.bottompanel { left: 330px; }

View File

@@ -234,10 +234,9 @@ function confirmPeriod() {
.module-topbar {
position: absolute;
left: 50%;
transform: translateX(-50%);
top: 20px;
max-width: calc(100% - 160px);
left: 514px;
top: 36px;
max-width: calc(100% - 554px);
z-index: 99;
}
@@ -507,9 +506,14 @@ function confirmPeriod() {
@media (max-width: 1300px) {
.module-topbar {
left: 50%;
max-width: calc(100% - 32px);
transform: translateX(-50%);
left: 24px;
right: 24px;
top: 108px;
max-width: none;
}
.field {
flex-wrap: wrap;
}
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div class="plan-page">
<div class="canvas">
<header class="topbar"><div class="topbar-center"><div class="model-title">XXX特大桥主体模型.rvt</div></div></header>
<header class="topbar"><div class="topbar-center"><div class="model-title">{{ modelCenterStore.activeModelName }}</div></div></header>
<section class="model-stage">
<ModelPlaceholder />
@@ -75,6 +75,9 @@
<script setup>
import { computed, reactive, ref } from "vue";
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
import { useModelCenterStore } from "../../stores/modelCenter.js";
const modelCenterStore = useModelCenterStore();
const structures = [
{ id: "S-001", name: "主桥-0#墩" }, { id: "S-002", name: "主桥-1#墩" }, { id: "S-003", name: "主桥-2#墩" },
@@ -172,7 +175,7 @@ function confirmPeriod() { periodId.value = periodDraft.value; periodModalOpen.v
.model-title { font-size: 20px; letter-spacing: 1px; font-weight: 700; color: rgba(255,128,52,.95); text-shadow: 0 2px 10px rgba(0,0,0,.25); }
.model-stage { position: absolute; inset: 0; }
.module-topbar { position: absolute; left: 50%; top: 20px; transform: translateX(-50%); max-width: calc(100% - 160px); z-index: 99; }
.module-topbar { position: absolute; left: 514px; top: 36px; max-width: calc(100% - 554px); z-index: 99; }
.field { display: flex; align-items: center; gap: 10px; flex-wrap: nowrap; border-radius: 18px; border: 1px solid rgba(83,214,206,.26); background: radial-gradient(420px 120px at 18% 0%, rgba(83,214,206,.22), transparent 70%), linear-gradient(180deg, rgba(18,32,40,.94), rgba(15,25,32,.88)); box-shadow: 0 20px 56px rgba(0,0,0,.28), 0 0 0 1px rgba(83,214,206,.12) inset; padding: 10px 14px; }
.field-label { font-weight: 900; color: rgba(244,252,255,.92); font-size: 14px; white-space: nowrap; }
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border-radius: 999px; border: 1px solid rgba(255,255,255,.12); background: rgba(0,0,0,.2); color: rgba(255,255,255,.88); font-weight: 800; font-size: 13px; white-space: nowrap; }
@@ -229,7 +232,8 @@ function confirmPeriod() { periodId.value = periodDraft.value; periodModalOpen.v
.period-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 12px; }
@media (max-width: 1300px) {
.module-topbar { left: 50%; max-width: calc(100% - 32px); transform: translateX(-50%); }
.module-topbar { left: 24px; right: 24px; top: 108px; max-width: none; }
.field { flex-wrap: wrap; }
.sidepanel { width: 280px; }
.bottompanel { left: 330px; }
}

View File

@@ -0,0 +1,325 @@
<template>
<div class="binding-page">
<ModelPlaceholder ref="modelRef" @modelLoaded="onModelLoaded" />
<aside class="binding-card">
<div class="binding-label">{{ modeLabel }}</div>
<div class="binding-title">{{ partName }}</div>
<div class="binding-count">已选择 {{ selectedComponents.length }} 个模型构件</div>
<div class="binding-divider"></div>
<button class="tree-toggle" type="button">构件树 <span></span></button>
<div class="tree-head"><span>快速选择模型构件</span><span>已选 {{ selectedComponents.length }} </span></div>
<input v-model.trim="treeQuery" class="tree-search" type="search" placeholder="搜索构件" autocomplete="off" />
<div class="tree-list">
<button
v-for="row in visibleRows"
:key="row.key"
class="tree-row"
:class="{ 'is-group': !row.leaf, 'is-selected': row.selected }"
type="button"
:style="{ paddingLeft: `${12 + row.level * 14}px` }"
@click="toggleRowSelection(row)"
>
<span class="tree-caret" @click.stop="onTreeCaretClick(row)">{{ row.leaf ? (row.selected ? '' : '') : row.open ? '' : '' }}</span>
<span class="tree-name">{{ row.name }}</span>
</button>
<div v-if="!visibleRows.length" class="tree-empty">暂无构件树数据请等待模型加载完成</div>
</div>
</aside>
<div v-if="isUnbind && boundComponents.length" class="binding-tip">已高亮当前工程部位已绑定构件可直接取消选择后确定解绑</div>
<div class="binding-actions">
<button class="btn-cancel" type="button" @click="cancel">取消</button>
<button class="btn-confirm" type="button" @click="confirm">{{ isUnbind ? '确定解绑' : '确定绑定' }}</button>
</div>
<div v-if="toastText" class="toast" :class="'toast-' + toastType">{{ toastText }}</div>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
import { progressApi } from "../../service/api/progress.js";
import { useModelCenterStore } from "../../stores/modelCenter.js";
const route = useRoute();
const router = useRouter();
const modelCenterStore = useModelCenterStore();
const modelRef = ref(null);
const constructTreeData = ref([]);
const expanded = ref(new Set());
const treeQuery = ref("");
const selectedComponents = ref([]);
const boundComponents = ref([]);
const toastText = ref("");
const toastType = ref("success");
let toastTimer = null;
const partId = computed(() => String(route.query.partId || ""));
const partName = computed(() => String(route.query.partName || partId.value || "--"));
const isUnbind = computed(() => route.query.mode === "unbind");
const modeLabel = computed(() => (isUnbind.value ? "解绑构件" : "绑定构件"));
const modelId = computed(() => {
const id = String(route.query.modelId || modelCenterStore.activeId || "").trim();
return /^\d+$/.test(id) ? id : undefined;
});
const partPayload = computed(() => [{
id: partId.value,
createDate: String(route.query.createDate || ""),
is_leaf: route.query.isLeaf === "true",
isLeaf: route.query.isLeaf === "true",
}]);
const visibleRows = computed(() => {
const rows = [];
const query = treeQuery.value.toLowerCase();
const selectedCodes = new Set(selectedComponents.value.map((item) => String(item.code)));
const matches = (node) => !query || String(node?.name || "").toLowerCase().includes(query) || childrenOf(node).some(matches);
const walk = (node, level, parentKey, inheritedUrl, index) => {
if (!matches(node)) return;
const key = `${parentKey}/${String(node?.name || "node")}-${String(node?.id || node?.url || index)}`;
const children = childrenOf(node);
const ids = idsDeep(node);
const leaf = !children.length;
const url = String(node?.url || inheritedUrl || "").trim();
const open = query ? true : expanded.value.has(key);
const selected = ids.length > 0 && ids.every((id) => selectedCodes.has(String(id)));
rows.push({ key, name: String(node?.name || node?.id || "未命名构件"), level, leaf, open, selected, ids, url });
if (!leaf && open) children.forEach((child, childIndex) => walk(child, level + 1, key, url, childIndex));
};
constructTreeData.value.forEach((node, index) => walk(node, 0, `root-${index}`, node?.url, index));
return rows;
});
onMounted(async () => {
if (!partId.value) {
showToast("缺少工程部位参数", "error");
return;
}
await modelCenterStore.loadModels().catch((error) => {
console.error("加载模型列表失败:", error);
});
await loadBoundComponents();
await nextTick();
refreshConstructTreeData();
setTimeout(refreshConstructTreeData, 1200);
});
function onModelLoaded(treeData) {
applyConstructTreeData(treeData);
highlightBoundComponents();
}
function refreshConstructTreeData() {
applyConstructTreeData(modelRef.value?.getConstructTreeData?.());
}
function applyConstructTreeData(raw) {
constructTreeData.value = Array.isArray(raw?.level) ? raw.level : [];
if (constructTreeData.value.length && expanded.value.size === 0) {
const next = new Set();
constructTreeData.value.forEach((node, index) => collectExpandedKeys(node, 0, `root-${index}`, index, next));
expanded.value = next;
}
}
function collectExpandedKeys(node, level, parentKey, index, target) {
const key = `${parentKey}/${String(node?.name || "node")}-${String(node?.id || node?.url || index)}`;
const children = childrenOf(node);
if (!children.length) return;
target.add(key);
children.forEach((child, childIndex) => collectExpandedKeys(child, level + 1, key, childIndex, target));
}
async function loadBoundComponents() {
try {
const data = await progressApi.getByPartId(partId.value, modelId.value);
boundComponents.value = (data || []).map(toBindingCodeItem).filter(Boolean);
selectedComponents.value = boundComponents.value.map((item) => ({ ...item }));
highlightBoundComponents();
} catch (error) {
console.error("获取已绑定构件失败:", error);
showToast("获取已绑定构件失败", "error");
}
}
function childrenOf(node) {
return Array.isArray(node?.children) ? node.children : [];
}
function idsOf(node) {
const ids = [];
if (Array.isArray(node?.ids)) ids.push(...node.ids);
if (node?.id != null) ids.push(node.id);
return Array.from(new Set(ids.map((id) => String(id)).filter(Boolean)));
}
function idsDeep(node) {
const ids = new Set(idsOf(node));
childrenOf(node).forEach((child) => idsDeep(child).forEach((id) => ids.add(id)));
return Array.from(ids);
}
function toggleExpand(row) {
if (!row || row.leaf) return;
const next = new Set(expanded.value);
if (next.has(row.key)) next.delete(row.key);
else next.add(row.key);
expanded.value = next;
}
function onTreeCaretClick(row) {
if (row?.leaf) {
toggleRowSelection(row);
return;
}
toggleExpand(row);
}
function toggleRowSelection(row) {
if (!row?.ids?.length) {
toggleExpand(row);
return;
}
const selectedCodes = new Set(selectedComponents.value.map((item) => String(item.code)));
const allSelected = row.ids.every((id) => selectedCodes.has(String(id)));
if (allSelected) {
selectedComponents.value = selectedComponents.value.filter((item) => !row.ids.includes(String(item.code)));
return;
}
const next = [...selectedComponents.value];
row.ids.forEach((id) => {
if (selectedCodes.has(String(id))) return;
next.push({ code: String(id), data: { url: row.url, id: String(id) } });
});
selectedComponents.value = next;
highlightRow(row);
}
function highlightRow(row) {
const ids = row.ids.map((id) => Number(id)).filter((id) => Number.isFinite(id));
if (row.url && ids.length) modelRef.value?.highlightModels?.([{ url: row.url, ids }]);
}
function highlightBoundComponents() {
const grouped = new Map();
selectedComponents.value.forEach((item) => {
const url = String(item?.data?.url || "").trim();
const id = Number(item?.code);
if (!url || !Number.isFinite(id)) return;
if (!grouped.has(url)) grouped.set(url, []);
grouped.get(url).push(id);
});
const refs = Array.from(grouped.entries()).map(([url, ids]) => ({ url, ids }));
if (refs.length) modelRef.value?.highlightModels?.(refs);
}
async function confirm() {
if (!selectedComponents.value.length && !isUnbind.value) {
showToast("请先选择需要绑定的模型构件", "error");
return;
}
try {
const selectedCodes = new Set(selectedComponents.value.map((item) => String(item.code)));
const codeIds = isUnbind.value
? boundComponents.value.filter((item) => !selectedCodes.has(String(item.code)))
: selectedComponents.value;
if (isUnbind.value && !codeIds.length) {
showToast("请取消选择需要解绑的构件", "error");
return;
}
const payload = { partIds: partPayload.value, codeIds, modelId: modelId.value };
const res = isUnbind.value
? await progressApi.deleteBatch(payload)
: await progressApi.saveBatch(payload);
if (res?.code === 200) {
showToast(isUnbind.value ? "解绑成功" : "绑定成功", "success");
setTimeout(() => router.push("/progress"), 450);
} else {
showToast(res?.message || "操作失败", "error");
}
} catch (error) {
console.error("保存绑定关系失败:", error);
showToast("操作失败", "error");
}
}
function cancel() {
router.push("/progress");
}
function toBindingCodeItem(item) {
const code = String(item?.codeId ?? item?.code ?? item?.id ?? "").trim();
if (!code) return null;
return { code, data: resolveComponentCodeDataPayload(item, code) };
}
function resolveComponentCodeDataPayload(item, code) {
const rawCodeData = String(item?.codeData || "").trim();
if (rawCodeData) {
const parsed = parseCodeData(rawCodeData);
if (parsed) return parsed;
}
const rawData = item?.data;
if (rawData && typeof rawData === "object") return { ...rawData, id: rawData.id ?? code };
return { id: code };
}
function parseCodeData(raw) {
try {
if (raw.startsWith("{")) {
const obj = {};
raw.replace(/{([\s\S]+)}/, "$1").split(", ").forEach((entry) => {
const [key, value] = entry.split("=");
if (key) obj[key] = value;
});
return obj;
}
} catch (error) {
return null;
}
return null;
}
function showToast(text, type = "success") {
toastText.value = text;
toastType.value = type;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toastText.value = ""; }, 2000);
}
</script>
<style scoped>
.binding-page { position: relative; min-height: 100vh; overflow: hidden; background: #8f9aa5; }
.binding-card { position: absolute; left: 24px; top: 28px; width: 300px; max-height: calc(100vh - 112px); overflow: hidden; border-radius: 12px; background: rgba(26, 38, 46, 0.96); box-shadow: 0 24px 60px rgba(0,0,0,.28); padding: 14px; color: rgba(244,250,252,.96); z-index: 20; }
.binding-label { font-size: 13px; font-weight: 900; }
.binding-title { margin-top: 8px; font-size: 18px; font-weight: 900; }
.binding-count { margin-top: 8px; font-size: 12px; font-weight: 800; color: rgba(224, 235, 240, 0.75); }
.binding-divider { height: 1px; margin: 14px 0 10px; background: rgba(255,255,255,.12); }
.tree-toggle { width: 100%; height: 38px; border-radius: 8px; border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.10); color: rgba(244,250,252,.95); display: flex; align-items: center; justify-content: space-between; padding: 0 12px; font-size: 13px; font-weight: 900; }
.tree-head { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; font-size: 12px; font-weight: 900; color: rgba(234,244,248,.84); }
.tree-search { width: 100%; height: 34px; margin-top: 12px; border-radius: 7px; border: 1px solid rgba(255,255,255,.14); background: rgba(255,255,255,.08); color: rgba(245,250,252,.94); padding: 0 10px; outline: none; }
.tree-list { margin-top: 14px; max-height: min(560px, calc(100vh - 330px)); overflow: auto; border-radius: 10px; border: 1px solid rgba(255,255,255,.08); padding: 8px; }
.tree-row { width: 100%; min-height: 34px; border: 0; border-radius: 7px; background: transparent; color: rgba(231,241,246,.86); display: grid; grid-template-columns: 20px minmax(0, 1fr); align-items: center; gap: 6px; text-align: left; cursor: pointer; }
.tree-row:hover { background: rgba(255,255,255,.07); }
.tree-row.is-group { color: rgba(210,226,233,.78); font-weight: 900; }
.tree-row.is-selected { color: rgba(245,255,253,.98); }
.tree-caret { width: 16px; height: 16px; border-radius: 4px; border: 1px solid rgba(203,226,236,.36); display: grid; place-items: center; color: rgba(83,214,206,.95); font-weight: 900; }
.tree-row.is-group .tree-caret { border: 0; }
.tree-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-empty { padding: 22px 8px; text-align: center; color: rgba(221,235,241,.58); font-size: 12px; }
.binding-tip { position: absolute; left: 50%; top: 120px; transform: translateX(-50%); z-index: 21; border-radius: 10px; background: rgba(245,250,255,.96); color: rgba(48,65,96,.9); padding: 10px 16px; font-size: 14px; font-weight: 800; box-shadow: 0 18px 44px rgba(30,42,60,.18); }
.binding-actions { position: absolute; right: 88px; bottom: 18px; z-index: 1002; display: flex; gap: 10px; padding: 10px; border-radius: 10px; background: rgba(18,31,39,.95); box-shadow: 0 16px 40px rgba(0,0,0,.28); }
.btn-cancel, .btn-confirm { min-width: 128px; height: 46px; border-radius: 8px; font-size: 14px; font-weight: 900; cursor: pointer; }
.btn-cancel { border: 0; background: rgba(255,255,255,.94); color: rgba(46,58,74,.82); }
.btn-confirm { border: 1px solid rgba(83,214,206,.34); background: linear-gradient(180deg, rgba(38,190,178,.95), rgba(21,149,142,.95)); color: white; }
.toast { position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%); z-index: 1000; padding: 10px 16px; border-radius: 10px; background: rgba(18,31,39,.94); color: white; font-weight: 900; }
.toast-error { background: rgba(154, 36, 44, 0.94); }
:deep(.homeViewWrapper) {
display: none !important;
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div class="progress-page">
<div class="canvas">
<header class="topbar"><div class="topbar-center"><div class="model-title">XXX特大桥主体模型.rvt</div></div></header>
<header class="topbar"><div class="topbar-center"><div class="model-title">{{ modelCenterStore.activeModelName }}</div></div></header>
<section class="model-stage">
<ModelPlaceholder ref="modelRef" @componentBindEncoding="onComponentBindEncoding" @codeBindComponent="codeBindComponent" />
@@ -42,8 +42,9 @@
</div>
<div v-if="showContextMenu" class="context-menu" :style="{ left: contextMenuX + 'px', top: contextMenuY + 'px' }" @click.stop>
<div class="context-menu-item" @click="onAddComponent">绑定编码</div>
<div class="context-menu-item" @click="onViewComponent">查看编辑</div>
<div class="context-menu-summary">已绑定 {{ contextBoundCount }}</div>
<button class="context-menu-action" type="button" @click="beginBindingTask('bind')">绑定构件</button>
<button class="context-menu-action" type="button" @click="beginBindingTask('unbind')">解绑构件</button>
</div>
<div v-if="showAddModal" class="add-modal" :style="modalPosition" @pointerdown.stop="onModalPointerDown">
@@ -73,6 +74,35 @@
</div>
</div>
</div>
<section class="construct-tree-panel">
<header class="construct-tree-head">
<div>
<div class="construct-tree-title">构件树</div>
<div class="construct-tree-sub">从模型构件树中选择需要绑定的构件</div>
</div>
<button class="construct-tree-refresh" type="button" @click="refreshConstructTreeData">刷新</button>
</header>
<div class="construct-tree-search">
<input v-model.trim="constructTreeQuery" type="search" placeholder="搜索构件" autocomplete="off" />
<span>已选 {{ componentList.length }} </span>
</div>
<div class="construct-tree-list">
<button
v-for="row in visibleConstructTreeRows"
:key="row.key"
class="construct-tree-row"
:class="{ 'is-group': !row.leaf, 'is-selected': row.selected }"
type="button"
:style="{ paddingLeft: `${12 + row.level * 14}px` }"
@click="onConstructTreeRowClick(row)"
>
<span class="construct-tree-caret" @click.stop="toggleConstructTreeExpand(row)">{{ row.leaf ? (row.selected ? '' : '') : row.open ? '' : '' }}</span>
<span class="construct-tree-name">{{ row.name }}</span>
<span v-if="row.count" class="construct-tree-count">{{ row.count }}</span>
</button>
<div v-if="!visibleConstructTreeRows.length" class="construct-tree-empty">暂无构件树数据请等待模型加载完成后刷新</div>
</div>
</section>
<div class="add-modal-footer">
<button class="add-modal-btn add-modal-btn-secondary" type="button" @click="showAddModalHandle">取消</button>
<button class="add-modal-btn add-modal-btn-primary" type="button" @click="onSaveComponent">保存</button>
@@ -114,6 +144,41 @@
</div>
</aside>
<section v-if="bindingTask.active" class="binding-task-page">
<aside class="binding-task-card">
<div class="binding-task-label">{{ bindingTask.mode === 'unbind' ? '解绑构件' : '绑定构件' }}</div>
<div class="binding-task-title">{{ getStructureName(bindingTask.part?.id) }}</div>
<div class="binding-task-count">已选择 {{ componentList.length }} 个模型构件</div>
<div class="binding-task-divider"></div>
<button class="binding-task-tree-toggle" type="button">构件树 <span></span></button>
<div class="binding-task-quick-head">
<span>快速选择模型构件</span>
<span>已选 {{ componentList.length }} </span>
</div>
<input v-model.trim="constructTreeQuery" class="binding-task-search" type="search" placeholder="搜索构件" autocomplete="off" />
<div class="binding-task-tree-list">
<button
v-for="row in visibleConstructTreeRows"
:key="row.key"
class="binding-task-tree-row"
:class="{ 'is-group': !row.leaf, 'is-selected': row.selected }"
type="button"
:style="{ paddingLeft: `${12 + row.level * 14}px` }"
@click="onConstructTreeRowClick(row)"
>
<span class="binding-task-check" @click.stop="toggleConstructTreeExpand(row)">{{ row.leaf ? (row.selected ? '' : '') : row.open ? '' : '' }}</span>
<span>{{ row.name }}</span>
</button>
<div v-if="!visibleConstructTreeRows.length" class="binding-task-empty">暂无构件树数据请等待模型加载完成后刷新</div>
</div>
</aside>
<div v-if="bindingTask.mode === 'unbind' && boundComponentList.length" class="binding-task-tip">已高亮当前工程部位已绑定构件可直接取消选择后确定解绑</div>
<div class="binding-task-actions-float">
<button class="binding-task-cancel" type="button" @click="cancelBindingTask">取消</button>
<button class="binding-task-confirm" type="button" @click="confirmBindingTask">{{ bindingTask.mode === 'unbind' ? '确定解绑' : '确定绑定' }}</button>
</div>
</section>
<section class="bottompanel" :class="{ 'is-collapsed': bottomCollapsed }">
<header class="bottompanel-header"><div class="tabs"><button class="tab is-on" type="button">项目日进度明细</button></div></header>
<button class="iconbtn bottompanel-toggle" type="button" @click="bottomCollapsed = !bottomCollapsed">{{ bottomCollapsed ? "" : "" }}</button>
@@ -140,11 +205,15 @@
</template>
<script setup>
import {computed, onMounted, reactive, ref} from "vue";
import {computed, nextTick, onMounted, reactive, ref} from "vue";
import { useRouter } from "vue-router";
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
import {progressApi} from "../../service/api/progress.js";
import { useModelCenterStore } from "../../stores/modelCenter.js";
const structures = ref([]);
const router = useRouter();
const modelCenterStore = useModelCenterStore();
const modelRef = ref(null);
const elementTree = ref([]);
const progressData = ref([]);
@@ -158,9 +227,15 @@ const showContextMenu = ref(false);
const contextMenuX = ref(0);
const contextMenuY = ref(0);
const contextMenuRow = ref(null);
const contextBoundCount = ref(0);
const showAddModal = ref(false);
const componentList = ref([]);
const partList = ref([]);
const boundComponentList = ref([]);
const constructTreeData = ref([]);
const constructTreeQuery = ref("");
const constructTreeExpanded = ref(new Set());
const bindingTask = reactive({ active: false, mode: "bind", part: null });
const showViewModal = ref(false);
const viewPartList = ref([]);
const viewComponentList = ref([]);
@@ -174,8 +249,13 @@ let modalDragging = false;
let modalStartX = 0, modalStartY = 0;
let modalStartLeft = 0, modalStartTop = 0;
function getActiveBackendModelId() {
const id = String(modelCenterStore.activeId || "").trim();
return /^\d+$/.test(id) ? id : undefined;
}
function onModalPointerDown(e) {
if (e.target.closest(".add-modal-close") || e.button !== 0) return;
if (e.target.closest(".add-modal-close, button, input, .construct-tree-panel, .add-modal-list") || e.button !== 0) return;
modalDragging = true;
modalStartX = e.clientX;
modalStartY = e.clientY;
@@ -255,9 +335,9 @@ const percentColorMap = {
const visibleTreeRows = computed(() => {
const rows = [];
const walk = (node, level) => {
const leaf = node.isLeaf === true;
const leaf = normalizeIsLeaf(node);
const open = expanded.value.has(node.id);
rows.push({ id: node.id, name: node.name, level, leaf, open, createDate: selectedPeriod.value, projectId: node.projectId });
rows.push({ id: node.id, name: node.name, level, leaf, isLeaf: leaf, is_leaf: leaf, open, createDate: selectedPeriod.value, projectId: node.projectId });
if (!leaf && open && node.children) {
node.children.forEach((c) => walk(c, level + 1));
}
@@ -307,6 +387,34 @@ const progressRows = computed(() => {
}));
});
const visibleConstructTreeRows = computed(() => {
const rows = [];
const query = constructTreeQuery.value.trim().toLowerCase();
const selectedCodes = new Set(componentList.value.map((item) => String(item?.code || "")));
const matches = (node) => {
if (!query) return true;
if (String(node?.name || "").toLowerCase().includes(query)) return true;
return getConstructChildren(node).some((child) => matches(child));
};
const walk = (node, level, parentKey, inheritedUrl) => {
if (!matches(node)) return;
const key = `${parentKey}/${String(node?.name || "node")}-${String(node?.id || node?.url || rows.length)}`;
const children = getConstructChildren(node);
const ids = getConstructNodeIdsDeep(node);
const leaf = !children.length;
const url = String(node?.url || inheritedUrl || "").trim();
const open = query ? true : constructTreeExpanded.value.has(key);
const selected = ids.length > 0 && ids.every((id) => selectedCodes.has(String(id)));
rows.push({ key, name: String(node?.name || node?.id || "未命名构件"), level, leaf, open, selected, ids, url, count: leaf ? ids.length : children.length });
if (!leaf && open) children.forEach((child) => walk(child, level + 1, key, url));
};
constructTreeData.value.forEach((node, index) => walk(node, 0, `root-${index}`, node?.url));
return rows;
});
async function loadWbsTree(parentId) {
loading.value = true;
try {
@@ -314,8 +422,9 @@ async function loadWbsTree(parentId) {
if (!parentId) {
elementTree.value = data.map((item) => ({
...item,
children: item.isLeaf ? [] : [],
isLeaf: !!item.isLeaf,
children: normalizeIsLeaf(item) ? [] : [],
isLeaf: normalizeIsLeaf(item),
is_leaf: normalizeIsLeaf(item),
}));
if (data.length > 0) {
const firstLeaf = findFirstLeaf(elementTree.value);
@@ -346,8 +455,9 @@ function updateChildren(nodes, parentId, children) {
if (n.id === parentId) {
n.children = children.map((item) => ({
...item,
children: item.isLeaf ? [] : [],
isLeaf: !!item.isLeaf,
children: normalizeIsLeaf(item) ? [] : [],
isLeaf: normalizeIsLeaf(item),
is_leaf: normalizeIsLeaf(item),
}));
return true;
}
@@ -367,6 +477,81 @@ function findNode(nodes, id) {
return null;
}
function getConstructChildren(node) {
return Array.isArray(node?.children) ? node.children : [];
}
function getConstructNodeIds(node) {
const ids = [];
if (Array.isArray(node?.ids)) ids.push(...node.ids);
if (node?.id != null) ids.push(node.id);
return Array.from(new Set(ids.map((id) => String(id)).filter(Boolean)));
}
function getConstructNodeIdsDeep(node) {
const ids = new Set(getConstructNodeIds(node));
getConstructChildren(node).forEach((child) => getConstructNodeIdsDeep(child).forEach((id) => ids.add(id)));
return Array.from(ids);
}
function normalizeConstructTreeData(raw) {
return Array.isArray(raw?.level) ? raw.level : [];
}
function refreshConstructTreeData() {
const raw = modelRef.value?.getConstructTreeData?.();
constructTreeData.value = normalizeConstructTreeData(raw);
if (constructTreeData.value.length > 0 && constructTreeExpanded.value.size === 0) {
constructTreeData.value.forEach((node, index) => expandConstructTreeFirstLevel(node, `root-${index}`, index));
}
}
function expandConstructTreeFirstLevel(node, parentKey, index) {
const key = `${parentKey}/${String(node?.name || "node")}-${String(node?.id || node?.url || index)}`;
constructTreeExpanded.value.add(key);
}
function removeComponentFromBindingList(code) {
const target = String(code || "");
componentList.value = componentList.value.filter((item) => String(item?.code || "") !== target);
}
function onConstructTreeRowClick(row) {
if (!row.ids.length) {
toggleConstructTreeExpand(row);
return;
}
const selectedCodes = new Set(componentList.value.map((item) => String(item?.code || "")));
const allSelected = row.ids.length > 0 && row.ids.every((id) => selectedCodes.has(String(id)));
if (allSelected) {
row.ids.forEach((id) => removeComponentFromBindingList(id));
} else {
row.ids.forEach((id) => addComponentToBindingList({ id, url: row.url, name: row.name }));
highlightConstructTreeRow(row);
}
}
function toggleConstructTreeExpand(row) {
if (!row || row.leaf) return;
const next = new Set(constructTreeExpanded.value);
if (next.has(row.key)) next.delete(row.key);
else next.add(row.key);
constructTreeExpanded.value = next;
}
function highlightConstructTreeRow(row) {
if (!row?.url || !row?.ids?.length) return;
const ids = row.ids.map((id) => Number(id)).filter((id) => Number.isFinite(id));
if (!ids.length) return;
modelRef.value?.highlightModels?.([{ url: row.url, ids }]);
}
function normalizeIsLeaf(node) {
const isTruthyLeaf = (value) => value === true || value === 1 || value === "1" || value === "true";
return isTruthyLeaf(node?.isLeaf) || isTruthyLeaf(node?.is_leaf);
}
onMounted(async() => {
await loadWbsTree();
window.addEventListener("click", closeContextMenu);
@@ -590,7 +775,7 @@ async function onTreeRowClick(row) {
}
modelRef.value?.cancelLightModels();
normalLight.value = [];
const data = await progressApi.getByPartId(row.id);
const data = await progressApi.getByPartId(row.id, getActiveBackendModelId());
if (showViewModal.value) {
applyViewModalData(row, data);
}
@@ -632,28 +817,135 @@ function getParams(str) {
return obj;
}
function onTreeRightClick(e, row) {
async function onTreeRightClick(e, row) {
// if (!row.leaf) return;
contextMenuX.value = e.clientX;
contextMenuY.value = e.clientY;
contextMenuRow.value = row;
contextBoundCount.value = 0;
showContextMenu.value = true;
try {
const data = await progressApi.getByPartId(row.id, getActiveBackendModelId());
contextBoundCount.value = Array.isArray(data) ? data.length : 0;
} catch (error) {
console.error("获取绑定数量失败:", error);
}
}
function addPartToBindingList(row) {
if (!row?.id) return;
const exists = partList.value.some((item) => item.id === row.id);
if (exists) return;
partList.value.push({ id: row.id, createDate: selectedPeriod.value });
const isLeaf = normalizeIsLeaf(row) || row.leaf === true;
partList.value.push({ id: row.id, createDate: selectedPeriod.value, is_leaf: isLeaf, isLeaf });
}
function onAddComponent() {
async function onAddComponent() {
// Start a new binding session from current right-click row.
partList.value = [];
addPartToBindingList(contextMenuRow.value);
componentList.value = [];
constructTreeQuery.value = "";
showAddModal.value = true;
showContextMenu.value = false;
await nextTick();
refreshConstructTreeData();
}
async function beginBindingTask(mode) {
const row = contextMenuRow.value;
if (!row?.id) return;
showContextMenu.value = false;
router.push({
path: "/progress/binding",
query: {
mode: mode === "unbind" ? "unbind" : "bind",
partId: row.id,
partName: row.name,
isLeaf: String(row.leaf === true || normalizeIsLeaf(row)),
createDate: selectedPeriod.value,
modelId: getActiveBackendModelId(),
},
});
return;
bindingTask.active = true;
bindingTask.mode = mode === "unbind" ? "unbind" : "bind";
bindingTask.part = row;
showContextMenu.value = false;
showAddModal.value = false;
showViewModal.value = false;
partList.value = [];
componentList.value = [];
boundComponentList.value = [];
constructTreeQuery.value = "";
addPartToBindingList(row);
await nextTick();
refreshConstructTreeData();
try {
const data = await progressApi.getByPartId(row.id, getActiveBackendModelId());
boundComponentList.value = (data || []).map((item) => toBindingCodeItem(item)).filter(Boolean);
componentList.value = boundComponentList.value.map((item) => ({ ...item }));
if (boundComponentList.value.length) {
highlightBoundComponents();
}
} catch (error) {
console.error("获取已绑定构件失败:", error);
showToast("获取已绑定构件失败", "error");
}
}
function cancelBindingTask() {
bindingTask.active = false;
bindingTask.part = null;
componentList.value = [];
boundComponentList.value = [];
partList.value = [];
constructTreeQuery.value = "";
modelRef.value?.cancelLightModels?.();
}
async function confirmBindingTask() {
if (!bindingTask.active || !bindingTask.part?.id) return;
if (!componentList.value.length && bindingTask.mode === "bind") {
showToast("请先选择需要绑定的模型构件", "error");
return;
}
try {
const selectedCodes = new Set(componentList.value.map((item) => String(item?.code || "")));
const codeIds = bindingTask.mode === "unbind"
? boundComponentList.value.filter((item) => !selectedCodes.has(String(item?.code || "")))
: componentList.value;
if (bindingTask.mode === "unbind" && !codeIds.length) {
showToast("请取消选择需要解绑的构件", "error");
return;
}
const payload = { partIds: partList.value, codeIds, modelId: getActiveBackendModelId() };
const res = bindingTask.mode === "unbind"
? await progressApi.deleteBatch(payload)
: await progressApi.saveBatch(payload);
if (res?.code === 200) {
showToast(bindingTask.mode === "unbind" ? "解绑成功!" : "绑定成功!", "success");
cancelBindingTask();
} else {
showToast(res?.message || "操作失败!", "error");
}
} catch (error) {
console.error("绑定任务保存失败:", error);
showToast("操作失败!", "error");
}
}
function highlightBoundComponents() {
const grouped = new Map();
boundComponentList.value.forEach((item) => {
const url = String(item?.data?.url || "").trim();
const id = Number(item?.code);
if (!url || !Number.isFinite(id)) return;
if (!grouped.has(url)) grouped.set(url, []);
grouped.get(url).push(id);
});
const refs = Array.from(grouped.entries()).map(([url, ids]) => ({ url, ids }));
if (refs.length) modelRef.value?.highlightModels?.(refs);
}
async function onViewComponent() {
@@ -661,7 +953,7 @@ async function onViewComponent() {
if (!partId) return;
showContextMenu.value = false;
try {
const data = await progressApi.getByPartId(partId);
const data = await progressApi.getByPartId(partId, getActiveBackendModelId());
applyViewModalData(contextMenuRow.value, data);
} catch (e) {
console.error("获取构件列表失败:", e);
@@ -759,7 +1051,7 @@ async function onDeleteComponent() {
const codeIds = viewComponentList.value;
if (!partIds.length || !codeIds.length) return;
try {
const res = await progressApi.deleteBatch({ partIds, codeIds });
const res = await progressApi.deleteBatch({ partIds, codeIds, modelId: getActiveBackendModelId() });
if (res?.code === 200) {
showToast("保存成功!", "success");
showViewModal.value = false;
@@ -799,10 +1091,12 @@ function onComponentBindEncoding(data) {
}
componentList.value = [];
partList.value = [];
constructTreeQuery.value = "";
showAddModal.value = true;
for (let component of data.components) {
addComponentToBindingList(component);
}
nextTick().then(() => refreshConstructTreeData());
}
async function codeBindComponent(data) {
const inBindMode = showAddModal.value;
@@ -838,7 +1132,7 @@ async function onSaveComponent() {
// const codeIds = componentList.value.filter((c) => c.code).map((c) => c.code);
if (!partIds.length || !codeIds.length) return;
try {
const res = await progressApi.saveBatch({ partIds, codeIds });
const res = await progressApi.saveBatch({ partIds, codeIds, modelId: getActiveBackendModelId() });
if (res.code === 200) {
showToast("保存成功!", "success");
showAddModal.value = false;
@@ -859,11 +1153,13 @@ function showToast(text, type = "success") {
async function showAddModalHandle() {
partList.value = [];
componentList.value = [];
constructTreeQuery.value = "";
showAddModal.value = false;
}
async function showAddModalClose() {
partList.value = [];
componentList.value = [];
constructTreeQuery.value = "";
showAddModal.value = false;
}
@@ -893,7 +1189,7 @@ async function loadProgressData(positionId,time) {
.model-title { font-size: 20px; letter-spacing: 1px; font-weight: 700; color: rgba(255,128,52,.95); text-shadow: 0 2px 10px rgba(0,0,0,.25); }
.model-stage { position: absolute; inset: 0; }
.module-topbar { position: absolute; left: 50%; top: 20px; transform: translateX(-50%); width: min(800px, calc(100% - 180px)); z-index: 99; }
.module-topbar { position: absolute; left: 514px; top: 36px; width: min(800px, calc(100% - 554px)); z-index: 99; }
.module-topbar-panel { border-radius: 18px; border: 1px solid rgba(83,214,206,.26); background: radial-gradient(420px 120px at 18% 0%, rgba(83,214,206,.22), transparent 70%), linear-gradient(180deg, rgba(18,32,40,.94), rgba(15,25,32,.88)); box-shadow: 0 20px 56px rgba(0,0,0,.28), 0 0 0 1px rgba(83,214,206,.12) inset; padding: 10px 14px; display: grid; gap: 10px; }
.field { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.field-time { flex-wrap: nowrap; }
@@ -957,7 +1253,7 @@ async function loadProgressData(positionId,time) {
.table tbody tr:hover td { background: linear-gradient(90deg, rgba(69,102,130,.36), rgba(56,86,113,.32)); }
@media (max-width: 1300px) {
.module-topbar { width: calc(100% - 32px); }
.module-topbar { left: 24px; right: 24px; top: 108px; width: auto; }
.field-time, .field-percent { flex-wrap: wrap; }
.kpi { margin-left: 0; }
.sidepanel { width: 280px; }
@@ -967,28 +1263,177 @@ async function loadProgressData(positionId,time) {
.context-menu {
position: fixed;
z-index: 9999;
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 10px;
border: 1px solid rgba(83, 214, 206, 0.25);
background: linear-gradient(180deg, rgba(20, 31, 37, 0.95), rgba(15, 23, 29, 0.92));
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
overflow: hidden;
border: 1px solid rgba(190, 215, 255, 0.76);
background: rgba(244, 249, 255, 0.96);
box-shadow: 0 18px 42px rgba(17, 34, 64, 0.22);
}
.context-menu-item {
padding: 10px 16px;
font-size: 13px;
font-weight: 700;
color: rgba(207, 247, 242, 0.9);
.context-menu-summary {
padding: 7px 8px;
border-right: 1px solid rgba(60, 92, 140, 0.16);
color: rgba(42, 64, 98, 0.82);
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.context-menu-action {
height: 30px;
border-radius: 7px;
border: 1px solid rgba(65, 116, 220, 0.28);
background: rgba(255, 255, 255, 0.92);
color: rgba(28, 55, 112, 0.92);
font-size: 12px;
font-weight: 900;
cursor: pointer;
}
.context-menu-item:hover {
background: rgba(83, 214, 206, 0.18);
.context-menu-action:hover {
background: rgba(226, 238, 255, 0.96);
}
.binding-task-page {
position: absolute;
inset: 0;
z-index: 120;
background: rgba(132, 143, 154, 0.88);
}
.binding-task-card {
position: absolute;
left: 22px;
top: 22px;
width: 300px;
max-height: calc(100% - 120px);
overflow: hidden;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(25, 37, 45, 0.96);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.28);
padding: 14px;
color: rgba(240, 247, 250, 0.96);
}
.binding-task-label { font-size: 13px; font-weight: 900; }
.binding-task-title { margin-top: 8px; font-size: 17px; font-weight: 900; }
.binding-task-count { margin-top: 8px; font-size: 12px; font-weight: 800; color: rgba(218, 231, 237, 0.76); }
.binding-task-divider { height: 1px; margin: 14px 0 10px; background: rgba(255,255,255,.12); }
.binding-task-tree-toggle {
width: 100%;
height: 38px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.10);
color: rgba(240,247,250,.95);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
font-size: 13px;
font-weight: 900;
}
.binding-task-quick-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
color: rgba(234, 244, 248, 0.86);
font-size: 12px;
font-weight: 900;
}
.binding-task-search {
width: 100%;
height: 34px;
margin-top: 12px;
border-radius: 7px;
border: 1px solid rgba(255,255,255,.14);
background: rgba(255,255,255,.08);
color: rgba(245,250,252,.94);
padding: 0 10px;
outline: none;
}
.binding-task-tree-list {
margin-top: 14px;
max-height: min(430px, calc(100vh - 360px));
overflow: auto;
border-radius: 10px;
border: 1px solid rgba(255,255,255,.08);
padding: 8px;
}
.binding-task-tree-row {
width: 100%;
min-height: 34px;
border: 0;
border-radius: 7px;
background: transparent;
color: rgba(231, 241, 246, 0.86);
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
align-items: center;
gap: 6px;
text-align: left;
cursor: pointer;
}
.binding-task-tree-row:hover { background: rgba(255,255,255,.07); }
.binding-task-tree-row.is-group { color: rgba(210, 226, 233, 0.78); font-weight: 900; }
.binding-task-tree-row.is-selected { color: rgba(245, 255, 253, 0.98); }
.binding-task-check { width: 16px; height: 16px; border-radius: 4px; border: 1px solid rgba(203, 226, 236, 0.36); display: grid; place-items: center; color: rgba(83,214,206,.95); font-weight: 900; }
.binding-task-tree-row.is-group .binding-task-check { border: 0; }
.binding-task-empty { padding: 22px 8px; text-align: center; color: rgba(221,235,241,.58); font-size: 12px; }
.binding-task-tip {
position: absolute;
left: 50%;
top: 120px;
transform: translateX(-50%);
border-radius: 10px;
background: rgba(245, 250, 255, 0.96);
color: rgba(48, 65, 96, 0.9);
padding: 10px 16px;
font-size: 14px;
font-weight: 800;
box-shadow: 0 18px 44px rgba(30, 42, 60, 0.18);
}
.binding-task-actions-float {
position: absolute;
right: 18px;
bottom: 18px;
display: flex;
gap: 10px;
padding: 10px;
border-radius: 10px;
background: rgba(18, 31, 39, 0.95);
box-shadow: 0 16px 40px rgba(0,0,0,.28);
}
.binding-task-cancel,
.binding-task-confirm {
min-width: 128px;
height: 46px;
border-radius: 8px;
font-size: 14px;
font-weight: 900;
cursor: pointer;
}
.binding-task-cancel { border: 0; background: rgba(255,255,255,.94); color: rgba(46,58,74,.82); }
.binding-task-confirm { border: 1px solid rgba(83,214,206,.34); background: linear-gradient(180deg, rgba(38,190,178,.95), rgba(21,149,142,.95)); color: white; }
.add-modal {
position: fixed;
width: min(520px, 86vw);
width: min(780px, 90vw);
border-radius: 16px;
border: 1px solid rgba(83, 214, 206, 0.25);
background: linear-gradient(180deg, rgba(20, 31, 37, 0.95), rgba(15, 23, 29, 0.92));
@@ -1032,8 +1477,8 @@ async function loadProgressData(positionId,time) {
.add-modal-body {
padding: 18px;
min-height: 300px;
max-height: 400px;
min-height: 520px;
max-height: min(720px, 82vh);
overflow: auto;
}
@@ -1065,6 +1510,145 @@ async function loadProgressData(positionId,time) {
flex: 1;
}
.construct-tree-panel {
margin-top: 16px;
border-radius: 14px;
border: 1px solid rgba(83, 214, 206, 0.18);
background: rgba(0, 0, 0, 0.16);
overflow: hidden;
}
.construct-tree-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid rgba(83, 214, 206, 0.14);
background: rgba(83, 214, 206, 0.08);
}
.construct-tree-title {
font-size: 13px;
font-weight: 900;
color: rgba(207, 247, 242, 0.95);
}
.construct-tree-sub {
margin-top: 3px;
font-size: 11px;
color: rgba(203, 230, 236, 0.58);
}
.construct-tree-refresh {
flex: none;
height: 30px;
border-radius: 8px;
border: 1px solid rgba(83, 214, 206, 0.28);
background: rgba(83, 214, 206, 0.12);
color: rgba(207, 247, 242, 0.9);
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.construct-tree-search {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid rgba(83, 214, 206, 0.12);
}
.construct-tree-search input {
flex: 1;
height: 32px;
min-width: 0;
border-radius: 8px;
border: 1px solid rgba(83, 214, 206, 0.22);
background: rgba(0, 0, 0, 0.2);
color: rgba(236, 246, 250, 0.92);
padding: 0 10px;
outline: none;
}
.construct-tree-search span {
flex: none;
color: rgba(203, 230, 236, 0.62);
font-size: 12px;
font-weight: 800;
}
.construct-tree-list {
display: grid;
align-content: start;
gap: 4px;
max-height: 250px;
overflow: auto;
padding: 8px;
}
.construct-tree-row {
width: 100%;
min-height: 32px;
border: 0;
border-radius: 8px;
background: transparent;
color: rgba(222, 238, 244, 0.86);
display: grid;
grid-template-columns: 22px minmax(0, 1fr) auto;
align-items: center;
gap: 6px;
text-align: left;
cursor: pointer;
}
.construct-tree-row:hover {
background: rgba(83, 214, 206, 0.10);
}
.construct-tree-row.is-group {
color: rgba(255, 220, 164, 0.9);
font-weight: 900;
}
.construct-tree-row.is-selected {
background: rgba(83, 214, 206, 0.18);
color: rgba(230, 255, 252, 0.98);
}
.construct-tree-caret {
color: rgba(83, 214, 206, 0.92);
font-weight: 900;
}
.construct-tree-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
font-weight: 800;
}
.construct-tree-count {
min-width: 22px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: rgba(203, 230, 236, 0.68);
font-size: 11px;
font-weight: 900;
text-align: center;
padding: 2px 6px;
}
.construct-tree-empty {
padding: 22px 12px;
color: rgba(203, 230, 236, 0.58);
font-size: 12px;
text-align: center;
}
.add-modal-tag {
display: flex;
align-items: center;

View File

@@ -3,7 +3,7 @@
<div class="canvas">
<header class="topbar">
<div class="topbar-center">
<div class="model-title">XXX特大桥主体模型.rvt</div>
<div class="model-title">{{ modelCenterStore.activeModelName }}</div>
</div>
</header>
@@ -131,6 +131,9 @@
<script setup>
import { computed, ref } from "vue";
import ModelPlaceholder from "../../components/model-placeholder/index.vue";
import { useModelCenterStore } from "../../stores/modelCenter.js";
const modelCenterStore = useModelCenterStore();
const structures = [
{ id: "S-001", name: "主桥-0#墩" },

View File

@@ -6,6 +6,7 @@ import SubcontractPage from "../pages/subcontract/index.vue";
import MeasurementPage from "../pages/measurement/index.vue";
import PlanPage from "../pages/plan/index.vue";
import ProgressPage from "../pages/progress/index.vue";
import ProgressBindingPage from "../pages/progress/binding.vue";
import ChangePage from "../pages/change/index.vue";
import MaterialPage from "../pages/material/index.vue";
import InspectionPage from "../pages/inspection/index.vue";
@@ -19,6 +20,7 @@ const routes = [
{ path: "/measurement", component: MeasurementPage },
{ path: "/plan", component: PlanPage },
{ path: "/progress", component: ProgressPage },
{ path: "/progress/binding", component: ProgressBindingPage },
{ path: "/change", component: ChangePage },
{ path: "/material", component: MaterialPage },
{ path: "/inspection", component: InspectionPage },

View File

@@ -5,13 +5,62 @@ const MOCK_DELAY = 300;
const mock = (data, delay = MOCK_DELAY) =>
new Promise((resolve) => setTimeout(() => resolve(data), delay));
function parseJsonPreserveLongIds(text) {
if (!text) return null;
let result = "";
let inString = false;
let escaped = false;
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
if (inString) {
result += char;
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === "\"") {
inString = false;
}
continue;
}
if (char === "\"") {
inString = true;
result += char;
continue;
}
const startsNumber = char === "-" || (char >= "0" && char <= "9");
if (!startsNumber) {
result += char;
continue;
}
const start = i;
if (char === "-") i += 1;
while (i < text.length && text[i] >= "0" && text[i] <= "9") i += 1;
const numberText = text.slice(start, i);
const nextChar = text[i];
const isInteger = nextChar !== "." && nextChar !== "e" && nextChar !== "E";
const digitCount = numberText.startsWith("-") ? numberText.length - 1 : numberText.length;
result += isInteger && digitCount >= 16 ? `"${numberText}"` : numberText;
i -= 1;
}
return JSON.parse(result);
}
export const fetchApi = async (url, options = {}) => {
const fullUrl = url.startsWith("http") ? url : BASE_URL + url;
const token = localStorage.getItem("token");
const isFormData = options.body instanceof FormData;
const config = {
headers: {
"Content-Type": "application/json",
...(isFormData ? {} : { "Content-Type": "application/json" }),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
@@ -20,7 +69,8 @@ export const fetchApi = async (url, options = {}) => {
try {
const res = await fetch(fullUrl, config);
const data = await res.json();
const raw = await res.text();
const data = parseJsonPreserveLongIds(raw);
if (!res.ok) {
throw new Error(data.message || `请求失败: ${res.status}`);
}
@@ -45,6 +95,13 @@ export const post = (url, data, options = {}) =>
body: JSON.stringify(data),
});
export const postForm = (url, formData, options = {}) =>
fetchApi(url, {
...options,
method: "POST",
body: formData,
});
export const put = (url, data, options = {}) =>
fetchApi(url, {
...options,

View File

@@ -0,0 +1,42 @@
import { del, get, postForm, put } from "./api.js";
export const modelManagementApi = {
list(params = {}) {
return get("/api/model-management/list", params).then((res) => {
if (res?.code === 200) return res?.data || [];
throw new Error(res?.message || "获取模型列表失败");
});
},
upload({ file, modelName, modelType }) {
const formData = new FormData();
formData.append("file", file);
formData.append("modelName", modelName);
formData.append("modelType", modelType);
return postForm("/api/model-management/upload", formData).then((res) => {
if (res?.code === 200) return res?.data;
throw new Error(res?.message || "上传模型失败");
});
},
switchCurrent(modelId) {
return put(`/api/model-management/${modelId}/switch`, {}).then((res) => {
if (res?.code === 200) return res?.data;
throw new Error(res?.message || "设置当前模型失败");
});
},
update(modelId, { modelName, modelType }) {
return put("/api/model-management", { modelId, modelName, modelType }).then((res) => {
if (res?.code === 200) return res?.data;
throw new Error(res?.message || "更新模型失败");
});
},
remove(modelIds) {
return del(`/api/model-management/${modelIds}`).then((res) => {
if (res?.code === 200) return res?.data;
throw new Error(res?.message || "删除模型失败");
});
},
};

View File

@@ -25,20 +25,17 @@ export const progressApi = {
});
},
saveBatch({ partIds, codeIds }) {
return post("/api/partCode/batch", { partIds, codeIds }).then((res) => res);
saveBatch({ partIds, codeIds, modelId }) {
return post("/api/partCode/batch", { partIds, codeIds, modelId }).then((res) => res);
},
deleteBatch({ partIds, codeIds }) {
console.log("deleteBatch:", partIds, codeIds);
return del("/api/partCode/batch", { partIds, codeIds }).then((res) => {
console.log("deleteBatch响应:", res);
return res;
});
deleteBatch({ partIds, codeIds, modelId }) {
return del("/api/partCode/batch", { partIds, codeIds, modelId }).then((res) => res);
},
getByPartId(partId) {
return get(`/api/partCode/byPart/${partId}`).then((res) => {
getByPartId(partId, modelId) {
const params = modelId ? { modelId } : undefined;
return get(`/api/partCode/byPart/${partId}`, params).then((res) => {
return res?.data?.data || res?.data || [];
});
},

214
src/stores/modelCenter.js Normal file
View File

@@ -0,0 +1,214 @@
import { computed, ref, watch } from "vue";
import { defineStore } from "pinia";
import { modelManagementApi } from "../service/api/modelManagement.js";
const STORAGE_KEY = "bimAdmin.modelCenter.v1";
const DEFAULT_MODEL_URL = "https://lyz-1259524260.cos.ap-guangzhou.myqcloud.com/iflow/models/417664a3-76c8-4d94-9344-1337246a5d4e";
function createSeedModel() {
const url = String(import.meta.env.VITE_BIM_MODEL_URL || DEFAULT_MODEL_URL).trim();
return {
id: "model-seed",
name: "XXX特大桥主体模型.rvt",
type: "bridge",
url,
fileName: "XXX特大桥主体模型.rvt",
fileSize: 0,
uploadedAt: "2026-06-09 09:00",
};
}
function formatTimestamp(date = new Date()) {
const pad = (value) => String(value).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function safeParse(raw) {
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function resolveModelUrl(item) {
const codeData = typeof item?.codeData === "string" ? safeParse(item.codeData) : item?.codeData;
const isBackendModel = item?.isBackendModel === true || item?.modelId != null || item?.ossId != null || item?.codeData != null;
if (isBackendModel) {
if (String(item?.convertStatus ?? item?.convert_status ?? "").trim() !== "SUCCESS") return "";
return String(codeData?.convertedUrl ?? codeData?.converted_url ?? item?.convertedUrl ?? item?.converted_url ?? "").trim();
}
return String(
item?.url ??
item?.fileUrl ??
"",
).trim();
}
function normalizeModelType(type) {
const text = String(type || "");
return ({
bridge: "bridge",
building: "building",
structure: "structure",
mechanical: "mechanical",
桥梁主体: "bridge",
建筑主体: "building",
结构模型: "structure",
机电模型: "mechanical",
})[text] || text || "bridge";
}
function normalizeModel(item, index) {
if (!item || typeof item !== "object") return null;
const id = String(item.id ?? item.modelId ?? `model-${index + 1}`);
const name = String(item.name ?? item.modelName ?? item.originalName ?? item.fileName ?? "未命名模型");
const fileName = String(item.originalName ?? item.fileName ?? name);
const url = resolveModelUrl(item);
const isBackendModel = item.isBackendModel === true || item.modelId != null || item.ossId != null || item.codeData != null;
return {
id,
name,
type: normalizeModelType(item.type ?? item.modelType),
url,
isBackendModel,
fileName,
fileSize: Number(item.fileSize || 0),
uploadedAt: String(item.uploadedAt ?? item.createTime ?? formatTimestamp()).replace("T", " ").slice(0, 16),
status: String(item.status ?? "1"),
convertStatus: String(item.convertStatus ?? item.convert_status ?? ""),
};
}
function loadState() {
const seed = createSeedModel();
const raw = localStorage.getItem(STORAGE_KEY);
const parsed = raw ? safeParse(raw) : null;
const models = Array.isArray(parsed?.models)
? parsed.models.map(normalizeModel).filter(Boolean)
: [seed];
if (!models.length) models.push(seed);
const activeId = models.some((item) => item.id === parsed?.activeId)
? String(parsed.activeId)
: models[0].id;
return { models, activeId };
}
export const useModelCenterStore = defineStore("modelCenter", () => {
const loaded = loadState();
const models = ref(loaded.models);
const activeId = ref(loaded.activeId);
const activeModel = computed(() => models.value.find((item) => item.id === activeId.value) || models.value[0] || null);
const activeModelName = computed(() => activeModel.value?.name || "未选择模型");
const activeModelUrl = computed(() => activeModel.value?.url || "");
function persist() {
const payload = {
activeId: activeId.value,
models: models.value.map((item) => ({
id: item.id,
name: item.name,
type: item.type,
url: item.url,
isBackendModel: item.isBackendModel,
convertedUrl: item.convertedUrl,
fileName: item.fileName,
fileSize: item.fileSize,
uploadedAt: item.uploadedAt,
status: item.status,
convertStatus: item.convertStatus,
})),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
}
function applyModels(list, preferredActiveId = activeId.value) {
const nextModels = Array.isArray(list)
? list.map(normalizeModel).filter(Boolean)
: [];
if (!nextModels.length) {
models.value = [];
activeId.value = "";
return;
}
models.value = nextModels;
const preferredId = String(preferredActiveId || "");
activeId.value = nextModels.some((item) => item.id === preferredId)
? preferredId
: nextModels.find((item) => item.status === "0")?.id || nextModels[0].id;
}
async function loadModels(options = {}) {
const list = await modelManagementApi.list();
applyModels(list, options.preferredActiveId ?? activeId.value);
return models.value;
}
async function addModel(payload) {
const uploaded = await modelManagementApi.upload({
file: payload.file,
modelName: payload.name,
modelType: payload.type,
});
const model = normalizeModel(uploaded, models.value.length);
if (model?.id) {
activeId.value = model.id;
await modelManagementApi.switchCurrent(model.id);
await loadModels({ preferredActiveId: model.id });
}
return model;
}
async function setActiveModel(id) {
if (!models.value.some((item) => item.id === id)) return;
await modelManagementApi.switchCurrent(id);
activeId.value = id;
models.value = models.value.map((item) => ({ ...item, status: item.id === id ? "0" : "1" }));
}
async function updateModel(id, payload) {
const updated = await modelManagementApi.update(id, {
modelName: payload.name,
modelType: payload.type,
});
const normalized = normalizeModel(updated, models.value.findIndex((item) => item.id === id));
models.value = models.value.map((item) => (
item.id === id
? { ...item, ...(normalized || {}), id, name: payload.name, type: payload.type }
: item
));
await loadModels();
}
async function removeModel(id) {
const index = models.value.findIndex((item) => item.id === id);
if (index < 0) return;
const removingActive = activeId.value === id;
await modelManagementApi.remove(id);
models.value.splice(index, 1);
if (removingActive) {
const nextId = models.value[0]?.id || "";
if (nextId) await setActiveModel(nextId);
else activeId.value = "";
}
}
watch([models, activeId], persist, { deep: true });
return {
models,
activeId,
activeModel,
activeModelName,
activeModelUrl,
loadModels,
addModel,
setActiveModel,
updateModel,
removeModel,
};
});