模型上传管理和绑定构件,构建树形
This commit is contained in:
@@ -1 +1 @@
|
||||
VITE_API_BASE=http://syliang.nat100.top
|
||||
VITE_API_BASE=http://localhost:8099
|
||||
|
||||
12
.idea/.gitignore
generated
vendored
Normal file
12
.idea/.gitignore
generated
vendored
Normal 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
8
.idea/bim-admin.iml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
0
public/basis/.gitkeep
Normal file
19
public/basis/basis_transcoder.js
Normal file
19
public/basis/basis_transcoder.js
Normal file
File diff suppressed because one or more lines are too long
BIN
public/basis/basis_transcoder.wasm
Normal file
BIN
public/basis/basis_transcoder.wasm
Normal file
Binary file not shown.
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
911
src/components/model-center/index.vue
Normal file
911
src/components/model-center/index.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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#墩" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
325
src/pages/progress/binding.vue
Normal file
325
src/pages/progress/binding.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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#墩" },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
42
src/service/api/modelManagement.js
Normal file
42
src/service/api/modelManagement.js
Normal 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 || "删除模型失败");
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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
214
src/stores/modelCenter.js
Normal 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,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user