暂存本地

This commit is contained in:
2026-04-27 09:57:00 +08:00
commit 4ac99bb417
88 changed files with 11520 additions and 0 deletions

30
src/App.vue Normal file
View File

@@ -0,0 +1,30 @@
<template>
<div id="app">
<router-view />
</div>
</template>
<script>
export default {
name: 'App'
}
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

View File

@@ -0,0 +1,232 @@
<template>
<div class="check-table">
<el-tabs v-model="activeTab" type="border-card">
<!-- 总体外观检查 -->
<el-tab-pane label="总体外观检查" name="appearance">
<div class="table-content">
<div v-for="(item, index) in appearanceCheck" :key="index" class="check-item">
<h4>{{ item.name }}</h4>
<ul>
<li v-for="(content, idx) in item.content" :key="idx">{{ content }}</li>
</ul>
</div>
</div>
</el-tab-pane>
<!-- 主要参数检查 -->
<el-tab-pane label="主要参数检查" name="mainData">
<el-table :data="mainDataCheck" border stripe>
<el-table-column prop="name" label="检查项目" min-width="150"></el-table-column>
<el-table-column label="参数要求" min-width="200">
<template slot-scope="scope">
<div v-for="(content, idx) in scope.row.content" :key="idx">
{{ content }}
</div>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 关键元器件检查 -->
<el-tab-pane label="关键元器件检查" name="component">
<div class="component-list">
<el-collapse v-model="activeComponents" accordion>
<el-collapse-item
v-for="(item, index) in componentCheck"
:key="index"
:name="index"
:class="{ 'highlighted': highlightedIndex === index }"
>
<template slot="title">
<span class="component-title">
<i class="el-icon-document"></i>
{{ index + 1 }}. {{ item.name }}
</span>
</template>
<div class="component-content">
<div class="requirement-title">检查要求</div>
<ol>
<li v-for="(content, idx) in item.content" :key="idx">
{{ content }}
</li>
</ol>
</div>
</el-collapse-item>
</el-collapse>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script>
export default {
name: 'CheckTable',
props: {
appearanceCheck: {
type: Array,
default: () => []
},
mainDataCheck: {
type: Array,
default: () => []
},
componentCheck: {
type: Array,
default: () => []
},
highlightedIndex: {
type: Number,
default: -1
}
},
data() {
return {
activeTab: 'appearance',
activeComponents: null
}
},
watch: {
highlightedIndex(newVal) {
if (newVal >= 0) {
this.activeTab = 'component'
this.activeComponents = newVal
this.$nextTick(() => {
this.scrollToHighlighted()
})
}
}
},
methods: {
scrollToHighlighted() {
const highlightedEl = this.$el.querySelector('.highlighted')
if (highlightedEl) {
highlightedEl.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
}
}
</script>
<style scoped>
.check-table {
width: 100%;
height: 100%;
overflow: hidden;
}
.el-tabs {
height: 100%;
display: flex;
flex-direction: column;
}
.el-tabs >>> .el-tabs__content {
flex: 1;
overflow-y: auto;
padding: 15px;
}
.table-content {
padding: 10px 0;
}
.check-item {
margin-bottom: 20px;
padding: 15px;
background: #f9f9f9;
border-radius: 4px;
}
.check-item h4 {
margin: 0 0 10px 0;
color: #303133;
font-size: 15px;
}
.check-item ul {
margin: 0;
padding-left: 20px;
}
.check-item li {
margin: 5px 0;
color: #606266;
line-height: 1.6;
}
.component-list {
padding: 10px 0;
}
.component-title {
font-weight: 500;
color: #303133;
}
.component-title i {
margin-right: 8px;
color: #409eff;
}
.component-content {
padding: 15px;
background: #f9f9f9;
border-radius: 4px;
}
.requirement-title {
font-weight: 500;
color: #409eff;
margin-bottom: 10px;
font-size: 14px;
}
.component-content ol {
margin: 0;
padding-left: 20px;
}
.component-content li {
margin: 8px 0;
color: #606266;
line-height: 1.8;
}
.highlighted {
animation: highlight-pulse 1s ease-in-out;
}
.highlighted >>> .el-collapse-item__header {
background: #ecf5ff;
color: #409eff;
font-weight: 500;
}
@keyframes highlight-pulse {
0%, 100% {
background: transparent;
}
50% {
background: #ecf5ff;
}
}
@media (max-width: 768px) {
.el-tabs >>> .el-tabs__content {
padding: 10px;
}
.check-item {
padding: 10px;
}
.component-content {
padding: 10px;
}
.component-content li {
font-size: 13px;
}
}
</style>

View File

@@ -0,0 +1,544 @@
<template>
<div class="three-viewer">
<div ref="container" class="canvas-container"></div>
<div class="annotations-container" ref="annotationsContainer"></div>
</div>
</template>
<script>
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; // 导入 GLTFLoader 用于加载 GLTF 模型
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; // 导入 OrbitControls 用于控制相机
// 高亮
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { OutlinePass } from "three/examples/jsm/postprocessing/OutlinePass.js";
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";
import TextureName from '../../public/models/textureName.json'
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer'
export default {
name: 'ThreeModel',
props: {
modelPath: {
type: [String, Number],
default: '',
},
annotations: {
type: Array,
default: () => []
}
},
computed: {
textureObj() {
return this.$store && this.$store.state.textureObj || {}
},
},
watch: {
modelPath: {
handler(v) {
if(v) {
if (this.scene) {
this.scene.clear();
this.scene = null;
}
// 2. 销毁渲染器
if (this.renderer) {
this.renderer.dispose();
this.renderer.forceContextLoss();
this.renderer = null;
}
// 3. 销毁相机
this.camera = null;
// 4. 清空画布DOM容器的内容
if(this.sceneDomElement){
this.sceneDomElement.innerHTML = '';
}
setTimeout(() => {
this.init()
}, 300)
}
},
immediate: true
},
},
data() {
return {
scene: null, // Three.js场景
camera: null, // 相机
renderer: null, // 渲染器
controls: null, // 轨道控制器
model: null, // 加载的3D模型
frameId: null, // 动画帧ID
sceneDomElement: null, // 容器DOM元素
parent: null, // 容器
maxDim: null,
highlightColor: null, //高亮颜色
composer: null,
pmremGenerator: null,
labelRenderer: null,
annotationObjects: [],
selectedPart: null
};
},
mounted () {
// this.onWindowResize(); // 初始化后立即调用一次
// 添加窗口大小变化监听
// window.addEventListener('resize', this.onWindowResize);
this.resizeObserver = new ResizeObserver(this.onWindowResize);
this.resizeObserver.observe(this.$refs.container);
window.addEventListener('click', this.onMouseClick, false);
this.highlightColor = new THREE.Color(0xff0000); // 高亮颜色为红色
},
beforeDestroy() {
// 移除事件监听
// window.removeEventListener('resize', this.onWindowResize);
window.removeEventListener('resize', this.onWindowResize);
// 断开ResizeObserver
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
if (this.labelRenderer) {
this.labelRenderer.domElement.remove()
}
},
destroyed() {
cancelAnimationFrame(this.frameId);
if (this.sceneDomElement && this.renderer) {
this.sceneDomElement.removeChild(this.renderer.domElement);
}
if (this.renderer) {
this.renderer.dispose();
}
if (this.scene) {
this.scene.clear();
}
},
methods: {
init() {
if (this.sceneDomElement && this.renderer) {
this.sceneDomElement.removeChild(this.renderer.domElement);
}
// 获取容器元素
this.sceneDomElement = this.$refs.container;
// 1. 创建一个新的场景
this.scene = new THREE.Scene();
// 2. 创建相机
const width = this.sceneDomElement.clientWidth;
const height = this.sceneDomElement.clientHeight;
this.camera = new THREE.PerspectiveCamera(55, width / height, 0.1, 1000);
// 3. 创建渲染器
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
});
// CSS2D渲染器用于标注
this.labelRenderer = new CSS2DRenderer()
this.labelRenderer.setSize(width, height)
this.labelRenderer.domElement.style.position = 'absolute'
this.labelRenderer.domElement.style.top = '0'
this.labelRenderer.domElement.style.pointerEvents = 'none'
this.$refs.annotationsContainer.appendChild(this.labelRenderer.domElement)
// 设置画布分辨率 提高画质
this.renderer.setPixelRatio(window.devicePixelRatio);
// 渲染器开启阴影效果
this.renderer.shadowMap.enabled = true
this.renderer.setSize(width, height);
// this.renderer.setClearColor(0xcccccc);
this.renderer.outputEncoding = THREE.sRGBEncoding; // 设置颜色编码
this.sceneDomElement.appendChild(this.renderer.domElement);
//4. 添加光源
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); // 创建环境光
this.scene.add(ambientLight); // 将环境光添加到场景中
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.6); // 平行光
directionalLight.position.set(1, 1, 1);
this.scene.add(directionalLight);
directionalLight.castShadow = true;
// 环境光
this.pmremGenerator = new THREE.PMREMGenerator(this.renderer)
this.pmremGenerator.compileEquirectangularShader()
const env = this.pmremGenerator.fromScene(new RoomEnvironment(this.renderer), 0.04).texture
this.scene.environment = env
// 3. 添加地面(接收阴影)
const groundGeometry = new THREE.PlaneGeometry(8, 8);
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0xcccccc, side: THREE.DoubleSide });
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -1;
ground.receiveShadow = true; // ⭐ 关键:地面接收阴影
this.scene.add(ground);
// 5. 添加轨道控制器
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true; // 启用阻尼效果
this.controls.dampingFactor = 0.25; // 阻尼系数
// 6. 加载3D模型
this.loadModel();
// 7. 开始动画循环
this.animate();
},
loadModel() {
const loader = new GLTFLoader();
const textureLoader = new THREE.TextureLoader();
console.log(this.modelPath);
loader.load(
this.modelPath,
// 'https://skeps-bucket.oss-cn-shenzhen.aliyuncs.com/2026%2FFile%2F4d3a9525-c415-4ab2-b916-d87b4b86ebcbSK010020001001BLQ_%E4%BA%A4%E6%B5%81%E9%81%BF%E9%9B%B7%E5%99%A8%2C%E5%B8%A6%E6%94%AF%E6%92%91%E4%BB%B6%E5%9B%BA%E5%AE%9A%E9%97%B4%E9%9A%99.glb',
(gltf) => {
// 移除旧模型
if (this.model) {
this.scene.remove(this.model);
}
this.model = gltf.scene;
this.scene.add(this.model);
// 遍历模型所有子元素
this.model.traverse((child) => {
if (child.isMesh) {
console.log(7777, child)
child.material.emissiveMap = child.material.map;
child.castShadow = true; // 让模型产生阴影
child.receiveShadow = true; // 让模型接受阴影
// child.material.emissive = child.material.color;
let name = child.material.name.split('.')[0]
let path = TextureName[name]
if(path) {
if(this.textureObj[name]) {
child.material = this.textureObj[name]
child.material.needsUpdate = true;
} else {
let texture = textureLoader.load(`/models/texture/${encodeURIComponent(path)}.jpg`)
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set( 1, 1 );
let material = null
if(name.includes('金属')) {
material = new THREE.MeshStandardMaterial({
map: texture,
// color: 0x049EF4,
// emissive: 0x000000,
metalness: 0.55, // 金属感
roughness: 0.05 // 粗糙度0为镜面反射
})
} else {
material = new THREE.MeshPhongMaterial( {
// ...child.material,
map: texture,
emissiveMap: child.material.map,
shininess: 0,
} );
}
child.material = material
child.material.needsUpdate = true;
this.textureObj[name] = material
// this.$store.commit('set_textureObj', this.textureObj)
}
}
}
});
// 计算模型包围盒并居中
const box = new THREE.Box3().setFromObject(this.model);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
// 根据模型大小调整相机
const maxDim = Math.max(size.x, size.y, size.z);
this.maxDim = maxDim
this.camera.position.z = this.maxDim * 1;
this.camera.near = maxDim / 100;
this.camera.far = maxDim * 100;
this.camera.updateProjectionMatrix();
// 创建一个父对象用于旋转
this.parent = new THREE.Object3D(); // 创建一个新的 Object3D 作为父对象
this.parent.add(this.model); // 将模型添加到父对象
this.scene.add(this.parent); // 将父对象添加到场景
// 让控制器聚焦到模型中心 聚焦中心
this.controls.target.copy(center);
this.controls.enableRotate = true;
this.controls.minPolarAngle = Math.PI / 2; // 限制向下旋转的角度
this.controls.maxPolarAngle = Math.PI / 2; // 限制向上旋转的角度
this.controls.update();
this.createAnnotations()
this.$emit('model-loaded')
},
(progress) => {
const percent = (progress.loaded / progress.total) * 100
this.$emit('loading-progress', percent)
},
(error) => {
console.error('模型加载失败:', error);
this.$emit('model-error', error)
}
);
},
animate() {
this.frameId = requestAnimationFrame(this.animate);
if(this.parent && this.isPlay) {
this.parent.rotation.y += 0.001; // 沿 Y 轴旋转父对象
}
// 更新控制器
if (this.controls) {
this.controls.update();
}
// 渲染场景
if (this.renderer && this.scene && this.camera) {
this.renderer.render(this.scene, this.camera);
}
if (this.composer) {
this.composer.render(this.scene, this.camera);
}
if(this.labelRenderer && this.scene && this.camera) {
this.labelRenderer.render(this.scene, this.camera)
}
},
// 响应式调整大小
onWindowResize() {
if (this.camera && this.renderer) {
const width = this.sceneDomElement.clientWidth;
const height = this.sceneDomElement.clientHeight;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
}
},
// 点击射线
onMouseClick(event) {
console.log('我点击了')
// const raycaster = new THREE.Raycaster();
// const mouse = new THREE.Vector2();
// // 计算鼠标在屏幕上的位置并转换为Normalized device coordinates (NDC)
// console.log(this.sceneDomElement.clientWidth, window.innerWidth)
// mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
// mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
// // 更新Raycaster的射线位置和方向
// raycaster.setFromCamera(mouse, this.camera);
if(!this.sceneDomElement) return
let getBoundingClientRect = this.sceneDomElement.getBoundingClientRect()
// 屏幕坐标转标准设备坐标
let x = ((event.clientX - getBoundingClientRect .left) / this.sceneDomElement.offsetWidth) * 2 - 1;// 标准设备横坐标
let y = -((event.clientY - getBoundingClientRect .top) / this.sceneDomElement.offsetHeight) * 2 + 1;// 标准设备纵坐标
let standardVector = new THREE.Vector3(x, y, 1);// 标准设备坐标
// 标准设备坐标转世界坐标
let worldVector = standardVector.unproject(this.camera);
// 射线投射方向单位向量(worldVector坐标减相机位置坐标)
let ray = worldVector.sub(this.camera.position).normalize();
// 创建射线投射器对象
let rayCaster = new THREE.Raycaster(this.camera.position, ray);
// 计算物体和射线的交点
const intersects = rayCaster.intersectObjects(this.model.children, true);
if (intersects.length !== 0 && intersects[0].object instanceof THREE.Mesh) {
// this.controls.target.copy(intersects[0].point);
let selectedObject = intersects[0].object;
let selectedObjects = [];
selectedObjects.push(selectedObject.parent);
this.outlineObj(selectedObjects);
} else {
console.log('没找到', this.composer)
this.composer = null
}
},
outlineObj(selectedObjects) {
let outlinePass;
let renderPass;
let unrealBloomPass;
// 创建一个EffectComposer效果组合器对象然后在该对象上添加后期处理通道。
// 用于模型边缘高亮
this.composer = new EffectComposer(this.renderer);
this.composer.renderTarget1.texture.outputColorSpace = THREE.sRGBEncoding;
this.composer.renderTarget2.texture.outputColorSpace = THREE.sRGBEncoding;
this.composer.renderTarget1.texture.encoding = THREE.sRGBEncoding;
this.composer.renderTarget2.texture.encoding = THREE.sRGBEncoding;
// 新建一个场景通道 为了覆盖到原来的场景上
renderPass = new RenderPass(this.scene, this.camera);
this.composer.addPass(renderPass);
// 物体边缘发光通道
outlinePass = new OutlinePass(
new THREE.Vector2(this.sceneDomElement.clientWidth, this.sceneDomElement.clientHeight),
this.scene,
this.camera,
selectedObjects
);
outlinePass.selectedObjects = selectedObjects;
outlinePass.edgeStrength = 10.0; // 边框的亮度
outlinePass.edgeGlow = 0.5; // 光晕[0,1]
outlinePass.usePatternTexture = false; // 是否使用父级的材质
outlinePass.edgeThickness = 1.0; // 边框宽度
outlinePass.downSampleRatio = 1; // 边框弯曲度
outlinePass.pulsePeriod = 5; // 呼吸闪烁的速度
outlinePass.visibleEdgeColor.set(parseInt(0x00ff00)); // 呼吸显示的颜色
outlinePass.hiddenEdgeColor = new THREE.Color(0, 0, 0); // 呼吸消失的颜色
outlinePass.clear = true;
this.composer.addPass(outlinePass);
// 自定义的着色器通道 作为参数
// effectFXAA = new ShaderPass(FXAAShader);
// effectFXAA.uniforms.resolution.value.set(
// 1 / window.innerWidth,
// 1 / window.innerHeight
// );
// effectFXAA.renderToScreen = true;
// composer.addPass(effectFXAA);
// // 抗锯齿
// smaaPass = new SMAAPass();
// composer.addPass(smaaPass);
// // 发光效果
unrealBloomPass = new UnrealBloomPass();
unrealBloomPass.strength = 0.1;
unrealBloomPass.radius = 0;
unrealBloomPass.threshold = 1;
this.composer.addPass(unrealBloomPass);
// this.scene.background = new THREE.Color(0x1b1824);
},
// 创建标签
createAnnotations() {
// 清除旧标注
this.annotationObjects.forEach(obj => {
this.scene.remove(obj)
})
this.annotationObjects = []
// 创建标注点的预设位置(圆形分布)
const radius = 2
const angleStep = (Math.PI * 2) / this.annotations.length
this.annotations.forEach((annotation, index) => {
const angle = angleStep * index
const x = Math.cos(angle) * radius
const z = Math.sin(angle) * radius
const y = 1 + Math.sin(index) * 0.5
console.log(666, annotation.name, x, y, z)
// 创建标注标签
const labelDiv = document.createElement('div')
labelDiv.className = 'annotation-label'
labelDiv.textContent = `${index + 1} ${annotation.name}`
labelDiv.style.cssText = `
background: rgba(64, 158, 255, 0.9);
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
pointer-events: auto;
`
labelDiv.addEventListener('click', () => {
this.onAnnotationClick(annotation, index)
})
const label = new CSS2DObject(labelDiv)
label.position.set(x/10, y/10, z/10)
label.userData = { annotation, index }
this.scene.add(label)
this.annotationObjects.push(label)
// 创建连接线
const points = [
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(x/10, y/10, z/10)
// new THREE.Vector3(0.1, 0.1, 0.1)
]
const geometry = new THREE.BufferGeometry().setFromPoints(points)
const material = new THREE.LineBasicMaterial({
color: 0x409eff,
transparent: true,
opacity: 0.5
})
const line = new THREE.Line(geometry, material)
this.scene.add(line)
this.annotationObjects.push(line)
})
},
onAnnotationClick(annotation, index) {
this.highlightPart(index)
this.$emit('annotation-click', annotation, index)
},
highlightPart(index) {
// 重置之前的高亮
if (this.selectedPart !== null) {
this.annotationObjects.forEach((obj, i) => {
if (obj.isLine) {
obj.material.opacity = 0.5
} else if (obj.element) {
obj.element.style.background = 'rgba(64, 158, 255, 0.9)'
obj.element.style.transform = 'scale(1)'
}
})
}
// 高亮选中的部件
this.selectedPart = index
this.annotationObjects.forEach((obj, i) => {
const objIndex = Math.floor(i / 2)
if (objIndex === index) {
if (obj.isLine) {
obj.material.opacity = 1
obj.material.color.setHex(0xff6b00)
} else if (obj.element) {
obj.element.style.background = 'rgba(255, 107, 0, 0.95)'
obj.element.style.transform = 'scale(1.1)'
}
}
})
},
}
}
</script>
<style scoped>
.three-viewer {
width: 100%;
height: 100%;
position: relative;
}
.canvas-container {
width: 100%;
height: 100%;
}
.annotations-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,577 @@
<template>
<div class="three-viewer">
<div ref="container" class="canvas-container"></div>
<div class="annotations-container" ref="annotationsContainer"></div>
</div>
</template>
<script>
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; // 导入 GLTFLoader 用于加载 GLTF 模型
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; // 导入 OrbitControls 用于控制相机
// 高亮
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { OutlinePass } from "three/examples/jsm/postprocessing/OutlinePass.js";
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";
import TextureName from '../../public/models/textureName.json'
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer'
export default {
name: 'ThreeModel',
props: {
modelPath: {
type: [String, Number],
default: '',
},
annotations: {
type: Array,
default: () => []
}
},
computed: {
textureObj() {
return this.$store && this.$store.state.textureObj || {}
},
},
watch: {
modelPath: {
handler(v) {
if(v) {
if (this.scene) {
this.scene.clear();
this.scene = null;
}
// 2. 销毁渲染器
if (this.renderer) {
this.renderer.dispose();
this.renderer.forceContextLoss();
this.renderer = null;
}
// 3. 销毁相机
this.camera = null;
// 4. 清空画布DOM容器的内容
if(this.sceneDomElement){
this.sceneDomElement.innerHTML = '';
}
setTimeout(() => {
this.init()
}, 300)
}
},
immediate: true
},
},
data() {
return {
scene: null, // Three.js场景
camera: null, // 相机
renderer: null, // 渲染器
controls: null, // 轨道控制器
model: null, // 加载的3D模型
frameId: null, // 动画帧ID
sceneDomElement: null, // 容器DOM元素
parent: null, // 容器
maxDim: null,
highlightColor: null, //高亮颜色
composer: null,
pmremGenerator: null,
labelRenderer: null,
annotationObjects: [],
selectedPart: null
};
},
mounted () {
// this.onWindowResize(); // 初始化后立即调用一次
// 添加窗口大小变化监听
// window.addEventListener('resize', this.onWindowResize);
this.resizeObserver = new ResizeObserver(this.onWindowResize);
this.resizeObserver.observe(this.$refs.container);
// window.addEventListener('click', this.onMouseClick, false);
this.highlightColor = new THREE.Color(0xff0000); // 高亮颜色为红色
},
beforeDestroy() {
// 移除事件监听
// window.removeEventListener('resize', this.onWindowResize);
window.removeEventListener('resize', this.onWindowResize);
// 断开ResizeObserver
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
if (this.labelRenderer) {
this.labelRenderer.domElement.remove()
}
},
destroyed() {
cancelAnimationFrame(this.frameId);
if (this.sceneDomElement && this.renderer) {
this.sceneDomElement.removeChild(this.renderer.domElement);
}
if (this.renderer) {
this.renderer.dispose();
}
if (this.scene) {
this.scene.clear();
}
},
methods: {
init() {
if (this.sceneDomElement && this.renderer) {
this.sceneDomElement.removeChild(this.renderer.domElement);
}
// 获取容器元素
this.sceneDomElement = this.$refs.container;
// 1. 创建一个新的场景
this.scene = new THREE.Scene();
// 2. 创建相机
const width = this.sceneDomElement.clientWidth;
const height = this.sceneDomElement.clientHeight;
this.camera = new THREE.PerspectiveCamera(55, width / height, 0.1, 1000);
// 3. 创建渲染器
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
});
this.renderer.shadowMap.enabled = true
// CSS2D渲染器用于标注
this.labelRenderer = new CSS2DRenderer()
this.labelRenderer.setSize(width, height)
this.labelRenderer.domElement.style.position = 'absolute'
this.labelRenderer.domElement.style.top = '0'
this.labelRenderer.domElement.style.pointerEvents = 'none'
this.$refs.annotationsContainer.appendChild(this.labelRenderer.domElement)
// 设置画布分辨率 提高画质
this.renderer.setPixelRatio(window.devicePixelRatio);
// 渲染器开启阴影效果
this.renderer.shadowMap.enabled = true
this.renderer.setSize(width, height);
// this.renderer.setClearColor(0xcccccc);
this.renderer.outputEncoding = THREE.sRGBEncoding; // 设置颜色编码
this.sceneDomElement.appendChild(this.renderer.domElement);
//4. 添加光源
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); // 创建环境光
this.scene.add(ambientLight); // 将环境光添加到场景中
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); // 平行光
directionalLight.position.set(5, 10, 7);
directionalLight.castShadow = true // 让平行光产生阴影
// 配置阴影属性 - 提高质量减少锯齿
directionalLight.shadow.mapSize.width = 4096
directionalLight.shadow.mapSize.height = 4096
directionalLight.shadow.camera.near = 0.5
directionalLight.shadow.camera.far = 50
directionalLight.shadow.camera.left = -10
directionalLight.shadow.camera.right = 10
directionalLight.shadow.camera.top = 10
directionalLight.shadow.camera.bottom = -10
directionalLight.shadow.bias = -0.0001 // 减少阴影失真
directionalLight.shadow.normalBias = 0.02 // 减少阴影痤疮
this.scene.add(directionalLight);
// 环境光
this.pmremGenerator = new THREE.PMREMGenerator(this.renderer)
this.pmremGenerator.compileEquirectangularShader()
const env = this.pmremGenerator.fromScene(new RoomEnvironment(this.renderer), 0.04).texture
this.scene.environment = env
// 添加地面
this.createGround()
// 5. 添加轨道控制器
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true; // 启用阻尼效果
this.controls.dampingFactor = 0.25; // 阻尼系数
// 6. 加载3D模型
this.loadModel();
// 7. 开始动画循环
this.animate();
},
loadModel() {
const loader = new GLTFLoader();
const textureLoader = new THREE.TextureLoader();
loader.load(
this.modelPath,
// 'https://skeps-bucket.oss-cn-shenzhen.aliyuncs.com/2026%2FFile%2F4d3a9525-c415-4ab2-b916-d87b4b86ebcbSK010020001001BLQ_%E4%BA%A4%E6%B5%81%E9%81%BF%E9%9B%B7%E5%99%A8%2C%E5%B8%A6%E6%94%AF%E6%92%91%E4%BB%B6%E5%9B%BA%E5%AE%9A%E9%97%B4%E9%9A%99.glb',
(gltf) => {
// 移除旧模型
if (this.model) {
this.scene.remove(this.model);
}
this.model = gltf.scene;
this.scene.add(this.model);
// 遍历模型所有子元素
this.model.traverse((child) => {
if (child.isMesh) {
// console.log(7777)
child.material.emissiveMap = child.material.map;
child.castShadow = true; // 让模型产生阴影
child.receiveShadow = true; // 让模型接受阴影
// child.material.emissive = child.material.color;
let name = child.material.name.split('.')[0]
let path = TextureName[name]
if(path) {
if(this.textureObj[name]) {
child.material = this.textureObj[name]
child.material.needsUpdate = true;
} else {
let texture = textureLoader.load(`/models/texture/${encodeURIComponent(path)}.jpg`)
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set( 1, 1 );
let material = null
if(name.includes('金属')) {
material = new THREE.MeshStandardMaterial({
map: texture,
// color: 0x049EF4,
// emissive: 0x000000,
metalness: 0.55, // 金属感
roughness: 0.05 // 粗糙度0为镜面反射
})
} else {
material = new THREE.MeshPhongMaterial( {
// ...child.material,
map: texture,
emissiveMap: child.material.map,
shininess: 0,
} );
}
child.material = material
child.material.needsUpdate = true;
this.textureObj[name] = material
// this.$store.commit('set_textureObj', this.textureObj)
}
}
}
});
// 计算模型包围盒并居中
const box = new THREE.Box3().setFromObject(this.model);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
// 根据模型大小调整相机
const maxDim = Math.max(size.x, size.y, size.z);
this.maxDim = maxDim
this.camera.position.z = this.maxDim * 1;
this.camera.near = maxDim / 100;
this.camera.far = maxDim * 100;
this.camera.updateProjectionMatrix();
// 创建一个父对象用于旋转
this.parent = new THREE.Object3D(); // 创建一个新的 Object3D 作为父对象
this.parent.add(this.model); // 将模型添加到父对象
this.scene.add(this.parent); // 将父对象添加到场景
// 让控制器聚焦到模型中心 聚焦中心
this.controls.target.copy(center);
this.controls.enableRotate = true;
// this.controls.minPolarAngle = Math.PI / 2; // 限制向下旋转的角度
// this.controls.maxPolarAngle = Math.PI / 2; // 限制向上旋转的角度
this.controls.update();
// 调整地面位置到模型底部
if (this.ground) {
this.ground.position.y = box.min.y - 0.01
}
this.createAnnotations()
this.$emit('model-loaded')
},
(progress) => {
const percent = (progress.loaded / progress.total) * 100
this.$emit('loading-progress', percent)
},
(error) => {
console.error('模型加载失败:', error);
this.$emit('model-error', error)
}
);
},
animate() {
this.frameId = requestAnimationFrame(this.animate);
if(this.parent && this.isPlay) {
this.parent.rotation.y += 0.001; // 沿 Y 轴旋转父对象
}
// 更新控制器
if (this.controls) {
this.controls.update();
}
// 渲染场景
if (this.renderer && this.scene && this.camera) {
this.renderer.render(this.scene, this.camera);
}
if (this.composer) {
this.composer.render(this.scene, this.camera);
}
if(this.labelRenderer && this.scene && this.camera) {
this.labelRenderer.render(this.scene, this.camera)
}
},
// 响应式调整大小
onWindowResize() {
if (this.camera && this.renderer) {
const width = this.sceneDomElement.clientWidth;
const height = this.sceneDomElement.clientHeight;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
}
},
// 点击射线
onMouseClick(event) {
console.log('我点击了')
// const raycaster = new THREE.Raycaster();
// const mouse = new THREE.Vector2();
// // 计算鼠标在屏幕上的位置并转换为Normalized device coordinates (NDC)
// console.log(this.sceneDomElement.clientWidth, window.innerWidth)
// mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
// mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
// // 更新Raycaster的射线位置和方向
// raycaster.setFromCamera(mouse, this.camera);
if(!this.sceneDomElement) return
let getBoundingClientRect = this.sceneDomElement.getBoundingClientRect()
// 屏幕坐标转标准设备坐标
let x = ((event.clientX - getBoundingClientRect .left) / this.sceneDomElement.offsetWidth) * 2 - 1;// 标准设备横坐标
let y = -((event.clientY - getBoundingClientRect .top) / this.sceneDomElement.offsetHeight) * 2 + 1;// 标准设备纵坐标
let standardVector = new THREE.Vector3(x, y, 1);// 标准设备坐标
// 标准设备坐标转世界坐标
let worldVector = standardVector.unproject(this.camera);
// 射线投射方向单位向量(worldVector坐标减相机位置坐标)
let ray = worldVector.sub(this.camera.position).normalize();
// 创建射线投射器对象
let rayCaster = new THREE.Raycaster(this.camera.position, ray);
// 计算物体和射线的交点
const intersects = rayCaster.intersectObjects(this.model.children, true);
if (intersects.length !== 0 && intersects[0].object instanceof THREE.Mesh) {
// this.controls.target.copy(intersects[0].point);
let selectedObject = intersects[0].object;
let selectedObjects = [];
selectedObjects.push(selectedObject.parent);
this.outlineObj(selectedObjects);
} else {
console.log('没找到', this.composer)
this.composer = null
}
},
outlineObj(selectedObjects) {
let outlinePass;
let renderPass;
let unrealBloomPass;
// 创建一个EffectComposer效果组合器对象然后在该对象上添加后期处理通道。
// 用于模型边缘高亮
this.composer = new EffectComposer(this.renderer);
this.composer.renderTarget1.texture.outputColorSpace = THREE.sRGBEncoding;
this.composer.renderTarget2.texture.outputColorSpace = THREE.sRGBEncoding;
this.composer.renderTarget1.texture.encoding = THREE.sRGBEncoding;
this.composer.renderTarget2.texture.encoding = THREE.sRGBEncoding;
// 新建一个场景通道 为了覆盖到原来的场景上
renderPass = new RenderPass(this.scene, this.camera);
this.composer.addPass(renderPass);
// 物体边缘发光通道
outlinePass = new OutlinePass(
new THREE.Vector2(this.sceneDomElement.clientWidth, this.sceneDomElement.clientHeight),
this.scene,
this.camera,
selectedObjects
);
outlinePass.selectedObjects = selectedObjects;
outlinePass.edgeStrength = 10.0; // 边框的亮度
outlinePass.edgeGlow = 0.5; // 光晕[0,1]
outlinePass.usePatternTexture = false; // 是否使用父级的材质
outlinePass.edgeThickness = 1.0; // 边框宽度
outlinePass.downSampleRatio = 1; // 边框弯曲度
outlinePass.pulsePeriod = 5; // 呼吸闪烁的速度
outlinePass.visibleEdgeColor.set(parseInt(0x00ff00)); // 呼吸显示的颜色
outlinePass.hiddenEdgeColor = new THREE.Color(0, 0, 0); // 呼吸消失的颜色
outlinePass.clear = true;
this.composer.addPass(outlinePass);
// 自定义的着色器通道 作为参数
// effectFXAA = new ShaderPass(FXAAShader);
// effectFXAA.uniforms.resolution.value.set(
// 1 / window.innerWidth,
// 1 / window.innerHeight
// );
// effectFXAA.renderToScreen = true;
// composer.addPass(effectFXAA);
// // 抗锯齿
// smaaPass = new SMAAPass();
// composer.addPass(smaaPass);
// // 发光效果
unrealBloomPass = new UnrealBloomPass();
unrealBloomPass.strength = 0.1;
unrealBloomPass.radius = 0;
unrealBloomPass.threshold = 1;
this.composer.addPass(unrealBloomPass);
// this.scene.background = new THREE.Color(0x1b1824);
},
// 创建标签
createAnnotations() {
// 清除旧标注
this.annotationObjects.forEach(obj => {
this.scene.remove(obj)
})
this.annotationObjects = []
// 创建标注点的预设位置(圆形分布)
const radius = 2
const angleStep = (Math.PI * 2) / this.annotations.length
this.annotations.forEach((annotation, index) => {
const angle = angleStep * index
const x = Math.cos(angle) * radius
const z = Math.sin(angle) * radius
const y = 1 + Math.sin(index) * 0.5
console.log(666, annotation.name, x, y, z)
// 创建标注标签
const labelDiv = document.createElement('div')
labelDiv.className = 'annotation-label'
labelDiv.textContent = `${index + 1} ${annotation.name}`
labelDiv.style.cssText = `
background: rgba(64, 158, 255, 0.9);
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
pointer-events: auto;
`
labelDiv.addEventListener('click', () => {
this.onAnnotationClick(annotation, index)
})
const label = new CSS2DObject(labelDiv)
label.position.set(0, 0, 0)
label.userData = { annotation, index }
this.scene.add(label)
this.annotationObjects.push(label)
// 创建连接线
const points = [
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(x/10, y/10, z/10)
// new THREE.Vector3(0.1, 0.1, 0.1)
]
const geometry = new THREE.BufferGeometry().setFromPoints(points)
const material = new THREE.LineBasicMaterial({
color: 0x409eff,
transparent: true,
opacity: 0.5
})
const line = new THREE.Line(geometry, material)
this.scene.add(line)
this.annotationObjects.push(line)
})
},
onAnnotationClick(annotation, index) {
this.highlightPart(index)
this.$emit('annotation-click', annotation, index)
},
// 创建地面
createGround() {
// 创建圆形地面几何体,避免菱形格子
const groundGeometry = new THREE.CircleGeometry(25, 64)
// 创建地面材质 - 使用 ShadowMaterial 只显示阴影
const groundMaterial = new THREE.ShadowMaterial({
opacity: 0.25,
color: 0x000000
})
// 创建地面网格
this.ground = new THREE.Mesh(groundGeometry, groundMaterial)
this.ground.rotation.x = -Math.PI / 2 // 旋转地面使其水平
this.ground.position.y = 0
this.ground.receiveShadow = true // 地面接收阴影
this.scene.add(this.ground)
},
highlightPart(index) {
// 重置之前的高亮
if (this.selectedPart !== null) {
this.annotationObjects.forEach((obj, i) => {
if (obj.isLine) {
obj.material.opacity = 0.5
} else if (obj.element) {
obj.element.style.background = 'rgba(64, 158, 255, 0.9)'
obj.element.style.transform = 'scale(1)'
}
})
}
// 高亮选中的部件
this.selectedPart = index
this.annotationObjects.forEach((obj, i) => {
const objIndex = Math.floor(i / 2)
if (objIndex === index) {
if (obj.isLine) {
obj.material.opacity = 1
obj.material.color.setHex(0xff6b00)
} else if (obj.element) {
obj.element.style.background = 'rgba(255, 107, 0, 0.95)'
obj.element.style.transform = 'scale(1.1)'
}
}
})
},
}
}
</script>
<style scoped>
.three-viewer {
width: 100%;
height: 100%;
position: relative;
}
.canvas-container {
width: 100%;
height: 100%;
}
.annotations-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,277 @@
<template>
<div class="three-viewer">
<div ref="container" class="canvas-container"></div>
<div class="annotations-container" ref="annotationsContainer"></div>
</div>
</template>
<script>
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer'
export default {
name: 'ThreeViewer',
props: {
modelPath: {
type: String,
required: true
},
annotations: {
type: Array,
default: () => []
}
},
data() {
return {
scene: null,
camera: null,
renderer: null,
labelRenderer: null,
controls: null,
model: null,
annotationObjects: [],
selectedPart: null
}
},
mounted() {
this.initThree()
this.loadModel()
this.animate()
window.addEventListener('resize', this.onWindowResize)
},
beforeDestroy() {
window.removeEventListener('resize', this.onWindowResize)
if (this.renderer) {
this.renderer.dispose()
}
if (this.labelRenderer) {
this.labelRenderer.domElement.remove()
}
},
methods: {
initThree() {
// 场景
this.scene = new THREE.Scene()
this.scene.background = new THREE.Color(0xf0f0f0)
// 相机
const container = this.$refs.container
const width = container.clientWidth
const height = container.clientHeight
this.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000)
this.camera.position.set(5, 5, 5)
// 渲染器
this.renderer = new THREE.WebGLRenderer({ antialias: true })
this.renderer.setSize(width, height)
this.renderer.setPixelRatio(window.devicePixelRatio)
this.renderer.shadowMap.enabled = true
container.appendChild(this.renderer.domElement)
// CSS2D渲染器用于标注
this.labelRenderer = new CSS2DRenderer()
this.labelRenderer.setSize(width, height)
this.labelRenderer.domElement.style.position = 'absolute'
this.labelRenderer.domElement.style.top = '0'
this.labelRenderer.domElement.style.pointerEvents = 'none'
this.$refs.annotationsContainer.appendChild(this.labelRenderer.domElement)
// 控制器
this.controls = new OrbitControls(this.camera, this.renderer.domElement)
this.controls.enableDamping = true
this.controls.dampingFactor = 0.05
// 光照
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
this.scene.add(ambientLight)
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
directionalLight.position.set(5, 10, 5)
directionalLight.castShadow = true
this.scene.add(directionalLight)
// 网格地面
// const gridHelper = new THREE.GridHelper(10, 10)
// this.scene.add(gridHelper)
},
loadModel() {
const loader = new GLTFLoader()
console.log(this.modelPath)
loader.load(
this.modelPath,
(gltf) => {
this.model = gltf.scene
// 计算模型边界并居中
const box = new THREE.Box3().setFromObject(this.model)
const center = box.getCenter(new THREE.Vector3())
const size = box.getSize(new THREE.Vector3())
this.model.position.sub(center)
// 调整相机位置
const maxDim = Math.max(size.x, size.y, size.z)
const fov = this.camera.fov * (Math.PI / 180)
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2))
cameraZ *= 2.5
this.camera.position.set(cameraZ, cameraZ, cameraZ)
this.camera.lookAt(0, 0, 0)
this.controls.target.set(0, 0, 0)
this.scene.add(this.model)
this.createAnnotations()
this.$emit('model-loaded')
},
(progress) => {
const percent = (progress.loaded / progress.total) * 100
this.$emit('loading-progress', percent)
},
(error) => {
console.error('模型加载失败:', error)
this.$emit('model-error', error)
}
)
},
createAnnotations() {
// 清除旧标注
this.annotationObjects.forEach(obj => {
this.scene.remove(obj)
})
this.annotationObjects = []
// 创建标注点的预设位置(圆形分布)
const radius = 2
const angleStep = (Math.PI * 2) / this.annotations.length
this.annotations.forEach((annotation, index) => {
const angle = angleStep * index
const x = Math.cos(angle) * radius
const z = Math.sin(angle) * radius
const y = 1 + Math.sin(index) * 0.5
// 创建标注标签
const labelDiv = document.createElement('div')
labelDiv.className = 'annotation-label'
labelDiv.textContent = `${index + 1} ${annotation.name}`
labelDiv.style.cssText = `
background: rgba(64, 158, 255, 0.9);
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
pointer-events: auto;
`
labelDiv.addEventListener('click', () => {
this.onAnnotationClick(annotation, index)
})
const label = new CSS2DObject(labelDiv)
label.position.set(x, y, z)
label.userData = { annotation, index }
this.scene.add(label)
this.annotationObjects.push(label)
// 创建连接线
const points = [
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(x, y, z)
]
const geometry = new THREE.BufferGeometry().setFromPoints(points)
const material = new THREE.LineBasicMaterial({
color: 0x409eff,
transparent: true,
opacity: 0.5
})
const line = new THREE.Line(geometry, material)
this.scene.add(line)
this.annotationObjects.push(line)
})
},
onAnnotationClick(annotation, index) {
this.highlightPart(index)
this.$emit('annotation-click', annotation, index)
},
highlightPart(index) {
// 重置之前的高亮
if (this.selectedPart !== null) {
this.annotationObjects.forEach((obj, i) => {
if (obj.isLine) {
obj.material.opacity = 0.5
} else if (obj.element) {
obj.element.style.background = 'rgba(64, 158, 255, 0.9)'
obj.element.style.transform = 'scale(1)'
}
})
}
// 高亮选中的部件
this.selectedPart = index
this.annotationObjects.forEach((obj, i) => {
const objIndex = Math.floor(i / 2)
if (objIndex === index) {
if (obj.isLine) {
obj.material.opacity = 1
obj.material.color.setHex(0xff6b00)
} else if (obj.element) {
obj.element.style.background = 'rgba(255, 107, 0, 0.95)'
obj.element.style.transform = 'scale(1.1)'
}
}
})
},
animate() {
requestAnimationFrame(this.animate)
this.controls.update()
this.renderer.render(this.scene, this.camera)
this.labelRenderer.render(this.scene, this.camera)
},
onWindowResize() {
const container = this.$refs.container
const width = container.clientWidth
const height = container.clientHeight
this.camera.aspect = width / height
this.camera.updateProjectionMatrix()
this.renderer.setSize(width, height)
this.labelRenderer.setSize(width, height)
}
}
}
</script>
<style scoped>
.three-viewer {
width: 100%;
height: 100%;
position: relative;
}
.canvas-container {
width: 100%;
height: 100%;
}
.annotations-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,597 @@
<template>
<div class="three-viewer">
<div ref="container" class="canvas-container"></div>
<div class="annotations-container" ref="annotationsContainer"></div>
<!-- 调试面板 -->
<div v-if="debugMode" class="debug-panel">
<h3>调试模式</h3>
<div class="debug-info">
<p>点击模型查看部件信息</p>
<div v-if="selectedMeshInfo">
<p><strong>部件名称:</strong> {{ selectedMeshInfo.name || '未命名' }}</p>
<p><strong>坐标:</strong></p>
<pre>{{ JSON.stringify(selectedMeshInfo.position, null, 2) }}</pre>
<button @click="copyPosition">复制坐标</button>
</div>
</div>
<div class="mesh-list">
<h4>模型部件列表:</h4>
<ul>
<li v-for="(mesh, index) in meshList" :key="index">
{{ mesh.name || `未命名_${index}` }}
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer';
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { OutlinePass } from "three/examples/jsm/postprocessing/OutlinePass.js";
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
export default {
name: 'ThreeViewerDebug',
props: {
modelPath: {
type: String,
required: true
},
annotations: {
type: Array,
default: () => []
},
debugMode: {
type: Boolean,
default: false
}
},
data() {
return {
scene: null,
camera: null,
renderer: null,
labelRenderer: null,
controls: null,
model: null,
composer: null,
outlinePass: null,
frameId: null,
sceneDomElement: null,
annotationObjects: [],
selectedPart: null,
partMeshMap: {},
meshList: [],
selectedMeshInfo: null,
raycaster: new THREE.Raycaster(),
mouse: new THREE.Vector2()
};
},
watch: {
modelPath: {
handler(v) {
if (v) {
this.cleanup();
setTimeout(() => {
this.init();
}, 300);
}
},
immediate: true
}
},
mounted() {
this.resizeObserver = new ResizeObserver(this.onWindowResize);
this.resizeObserver.observe(this.$refs.container);
// 添加点击事件
window.addEventListener('click', this.onMouseClick, false);
},
beforeDestroy() {
this.cleanup();
window.removeEventListener('click', this.onMouseClick, false);
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
},
methods: {
cleanup() {
if (this.frameId) {
cancelAnimationFrame(this.frameId);
}
if (this.scene) {
this.scene.clear();
this.scene = null;
}
if (this.renderer) {
this.renderer.dispose();
this.renderer = null;
}
if (this.labelRenderer) {
this.labelRenderer.domElement.remove();
this.labelRenderer = null;
}
if (this.sceneDomElement) {
this.sceneDomElement.innerHTML = '';
}
},
init() {
this.sceneDomElement = this.$refs.container;
// 创建场景
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0xf0f0f0);
// 创建相机
const width = this.sceneDomElement.clientWidth;
const height = this.sceneDomElement.clientHeight;
this.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
this.camera.position.set(5, 5, 5);
// WebGL渲染器
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setSize(width, height);
this.renderer.shadowMap.enabled = true;
this.renderer.outputEncoding = THREE.sRGBEncoding;
this.sceneDomElement.appendChild(this.renderer.domElement);
// CSS2D渲染器
this.labelRenderer = new CSS2DRenderer();
this.labelRenderer.setSize(width, height);
this.labelRenderer.domElement.style.position = 'absolute';
this.labelRenderer.domElement.style.top = '0';
this.labelRenderer.domElement.style.pointerEvents = 'none';
this.$refs.annotationsContainer.appendChild(this.labelRenderer.domElement);
// 光照
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
this.scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(5, 10, 5);
directionalLight.castShadow = true;
this.scene.add(directionalLight);
// 环境光
const pmremGenerator = new THREE.PMREMGenerator(this.renderer);
const env = pmremGenerator.fromScene(new RoomEnvironment(this.renderer), 0.04).texture;
this.scene.environment = env;
// 控制器
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.05;
// 加载模型
this.loadModel();
// 开始动画
this.animate();
},
loadModel() {
const loader = new GLTFLoader();
loader.load(
this.modelPath,
(gltf) => {
this.model = gltf.scene;
// 收集所有mesh信息
this.partMeshMap = {};
this.meshList = [];
this.model.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
// 存储mesh引用
const meshInfo = {
name: child.name,
mesh: child,
parent: child.parent
};
this.meshList.push(meshInfo);
if (child.name) {
this.partMeshMap[child.name] = child;
}
}
});
console.log('模型部件列表:', this.meshList.map(m => m.name || '未命名'));
console.log('部件映射表:', Object.keys(this.partMeshMap));
// 计算模型边界并居中
const box = new THREE.Box3().setFromObject(this.model);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
this.model.position.sub(center);
// 调整相机
const maxDim = Math.max(size.x, size.y, size.z);
const fov = this.camera.fov * (Math.PI / 180);
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
cameraZ *= 2.5;
this.camera.position.set(cameraZ, cameraZ, cameraZ);
this.camera.lookAt(0, 0, 0);
this.controls.target.set(0, 0, 0);
this.scene.add(this.model);
// 创建标注
this.createAnnotations();
this.$emit('model-loaded', {
meshList: this.meshList,
partMeshMap: Object.keys(this.partMeshMap)
});
},
(progress) => {
const percent = (progress.loaded / progress.total) * 100;
this.$emit('loading-progress', percent);
},
(error) => {
console.error('模型加载失败:', error);
this.$emit('model-error', error);
}
);
},
createAnnotations() {
// 清除旧标注
this.annotationObjects.forEach(obj => {
this.scene.remove(obj);
});
this.annotationObjects = [];
this.annotations.forEach((annotation, index) => {
// 尝试通过名称匹配找到对应的mesh
const mesh = this.findMeshByName(annotation.name);
let position = new THREE.Vector3();
if (mesh) {
// 如果找到对应mesh使用其位置
mesh.getWorldPosition(position);
// 获取mesh的包围盒标注放在上方
const box = new THREE.Box3().setFromObject(mesh);
const size = box.getSize(new THREE.Vector3());
position.y += size.y / 2 + 0.3;
console.log(`找到匹配: ${annotation.name} -> ${mesh.name}`, position);
} else {
// 如果没找到,使用圆形分布
const radius = 2;
const angleStep = (Math.PI * 2) / this.annotations.length;
const angle = angleStep * index;
position.set(
Math.cos(angle) * radius,
1 + Math.sin(index) * 0.5,
Math.sin(angle) * radius
);
console.warn(`未找到匹配: ${annotation.name},使用默认位置`);
}
// 创建标注标签
const labelDiv = document.createElement('div');
labelDiv.className = 'annotation-label';
labelDiv.textContent = `${index + 1} ${annotation.name}`;
labelDiv.style.cssText = `
background: rgba(64, 158, 255, 0.9);
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
pointer-events: auto;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
`;
labelDiv.addEventListener('click', () => {
this.onAnnotationClick(annotation, index, mesh);
});
const label = new CSS2DObject(labelDiv);
label.position.copy(position);
label.userData = { annotation, index, mesh };
this.scene.add(label);
this.annotationObjects.push(label);
// 创建连接线
if (mesh) {
const meshPos = new THREE.Vector3();
mesh.getWorldPosition(meshPos);
const points = [meshPos, position];
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({
color: 0x409eff,
transparent: true,
opacity: 0.6
});
const line = new THREE.Line(geometry, material);
this.scene.add(line);
this.annotationObjects.push(line);
}
});
},
findMeshByName(annotationName) {
// 精确匹配
if (this.partMeshMap[annotationName]) {
return this.partMeshMap[annotationName];
}
// 模糊匹配
for (let meshName in this.partMeshMap) {
if (meshName.includes(annotationName) || annotationName.includes(meshName)) {
return this.partMeshMap[meshName];
}
}
// 尝试匹配关键词
const keywords = annotationName.split(/[,、\s]+/);
for (let keyword of keywords) {
if (keyword.length > 1) {
for (let meshName in this.partMeshMap) {
if (meshName.includes(keyword)) {
return this.partMeshMap[meshName];
}
}
}
}
return null;
},
onAnnotationClick(annotation, index, mesh) {
this.highlightPart(index, mesh);
this.$emit('annotation-click', annotation, index);
},
highlightPart(index, mesh) {
// 重置之前的高亮
this.annotationObjects.forEach((obj) => {
if (obj.isLine) {
obj.material.opacity = 0.6;
obj.material.color.setHex(0x409eff);
} else if (obj.element) {
obj.element.style.background = 'rgba(64, 158, 255, 0.9)';
obj.element.style.transform = 'scale(1)';
}
});
// 高亮选中的标注
this.selectedPart = index;
this.annotationObjects.forEach((obj, i) => {
const objIndex = Math.floor(i / 2);
if (objIndex === index) {
if (obj.isLine) {
obj.material.opacity = 1;
obj.material.color.setHex(0xff6b00);
} else if (obj.element) {
obj.element.style.background = 'rgba(255, 107, 0, 0.95)';
obj.element.style.transform = 'scale(1.1)';
}
}
});
// 高亮3D部件
if (mesh) {
this.highlightMesh(mesh);
}
},
highlightMesh(mesh) {
// 创建高亮效果
if (!this.composer) {
this.composer = new EffectComposer(this.renderer);
const renderPass = new RenderPass(this.scene, this.camera);
this.composer.addPass(renderPass);
this.outlinePass = new OutlinePass(
new THREE.Vector2(this.sceneDomElement.clientWidth, this.sceneDomElement.clientHeight),
this.scene,
this.camera
);
this.outlinePass.edgeStrength = 5.0;
this.outlinePass.edgeGlow = 0.5;
this.outlinePass.edgeThickness = 2.0;
this.outlinePass.pulsePeriod = 2;
this.outlinePass.visibleEdgeColor.set(0xff6b00);
this.composer.addPass(this.outlinePass);
}
this.outlinePass.selectedObjects = [mesh.parent || mesh];
},
onMouseClick(event) {
if (!this.debugMode || !this.sceneDomElement) return;
const rect = this.sceneDomElement.getBoundingClientRect();
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
this.raycaster.setFromCamera(this.mouse, this.camera);
const intersects = this.raycaster.intersectObjects(this.model.children, true);
if (intersects.length > 0) {
const mesh = intersects[0].object;
const point = intersects[0].point;
this.selectedMeshInfo = {
name: mesh.name,
position: {
x: parseFloat(point.x.toFixed(3)),
y: parseFloat(point.y.toFixed(3)),
z: parseFloat(point.z.toFixed(3))
}
};
console.log('点击的部件:', this.selectedMeshInfo);
}
},
copyPosition() {
if (this.selectedMeshInfo) {
const text = JSON.stringify(this.selectedMeshInfo.position, null, 2);
navigator.clipboard.writeText(text).then(() => {
this.$message.success('坐标已复制到剪贴板');
});
}
},
animate() {
this.frameId = requestAnimationFrame(this.animate);
if (this.controls) {
this.controls.update();
}
if (this.renderer && this.scene && this.camera) {
if (this.composer && this.outlinePass && this.outlinePass.selectedObjects.length > 0) {
this.composer.render();
} else {
this.renderer.render(this.scene, this.camera);
}
}
if (this.labelRenderer && this.scene && this.camera) {
this.labelRenderer.render(this.scene, this.camera);
}
},
onWindowResize() {
if (this.camera && this.renderer && this.sceneDomElement) {
const width = this.sceneDomElement.clientWidth;
const height = this.sceneDomElement.clientHeight;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
this.labelRenderer.setSize(width, height);
if (this.composer) {
this.composer.setSize(width, height);
}
}
}
}
}
</script>
<style scoped>
.three-viewer {
width: 100%;
height: 100%;
position: relative;
}
.canvas-container {
width: 100%;
height: 100%;
}
.annotations-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.debug-panel {
position: absolute;
top: 10px;
right: 10px;
background: rgba(255, 255, 255, 0.95);
padding: 15px;
border-radius: 8px;
max-width: 300px;
max-height: 80%;
overflow-y: auto;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
pointer-events: auto;
}
.debug-panel h3 {
margin: 0 0 10px 0;
font-size: 16px;
color: #303133;
}
.debug-panel h4 {
margin: 10px 0 5px 0;
font-size: 14px;
color: #606266;
}
.debug-info {
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #dcdfe6;
}
.debug-info p {
margin: 5px 0;
font-size: 13px;
color: #606266;
}
.debug-info pre {
background: #f5f7fa;
padding: 8px;
border-radius: 4px;
font-size: 12px;
margin: 5px 0;
}
.debug-info button {
margin-top: 8px;
padding: 5px 12px;
background: #409eff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.debug-info button:hover {
background: #66b1ff;
}
.mesh-list ul {
margin: 0;
padding-left: 20px;
max-height: 300px;
overflow-y: auto;
}
.mesh-list li {
font-size: 12px;
color: #606266;
margin: 3px 0;
word-break: break-all;
}
</style>

268
src/data/data.json Normal file
View File

@@ -0,0 +1,268 @@
[
{
"modelName": "交流避雷器AC10kV,13kV, 硅橡胶40kV, 带间隙。外间隙,带支撑件间隙,不兼做绝缘子",
"modelPath": "1",
"appearanceCheck": [
{
"name": "交流避雷器AC10kV,13kV, 硅橡胶40kV, 带间隙。外间隙 (带支撑件固定间隙,带故障指示)",
"content": [
"硅橡胶外套",
"带支撑件固定间隙",
"带故障指示",
"不兼做绝缘子"
]
}
],
"mainDataCheck": [
{
"name": "额定电压",
"content": [
"13kV"
]
},
{
"name": "标称放电电流",
"content": [
"10kA"
]
},
{
"name": "直流 1mA 参考电压 (不小于)",
"content": [
"20kV"
]
},
{
"name": "0.75 倍直流 1mA 参考电压下漏电流",
"content": [
"≤50μA"
]
},
{
"name": "10kA 雷电冲击电流下的最大残压 (峰值,不大于)",
"content": [
"40kV"
]
},
{
"name": "大电流冲击耐受能力4/10μs (2 次)",
"content": [
"电阻片≥100kA; 整只避雷器≥100kA"
]
}
],
"componentCheck": [
{
"name": "避雷器与支撑件结构",
"content": [
"避雷器与支撑件并列结构,二者互不影响,非一体化结构,避雷器损坏后可方便更换,但是接触应良好可靠。",
"避雷器在本体失效或损坏后,应具有明显的故障指示功能,供运维人员在地面清楚可见。",
"避雷器的电极应采用热浸镀锌钢(镀锌层厚度应不小于 86µm或 316L 不锈钢以防腐防锈。"
]
},
{
"name": "引线",
"content": [
"不低于 80cm直径不低于 16mm 的软铜线",
"一次端引线应采用绝缘铜线,引线绝缘层的工频电压绝缘耐受水平不低于 18kV"
]
},
{
"name": "铭牌",
"content": [
"铭牌应采用 316L 不锈钢等防腐蚀材料"
]
},
{
"name": "螺栓螺母",
"content": [
"避雷器的金属材料(连接板、螺栓螺母紧固件等金属部位)均应采用热浸镀锌钢(镀锌层厚度应不小于 86µm或 316L 不锈钢以防腐防锈"
]
},
{
"name": "线夹",
"content": [
"配置 JLG 挂钩引流线夹"
]
},
{
"name": "绝缘罩",
"content": [
"引线两端需要配置绝缘罩,位置 1避雷器与引线连接位置支柱绝缘子与引线连接位置",
"位置 2引线与导线连接位置JLG 挂钩引流线夹位置)"
]
}
]
},
{
"modelName": "交流棒形悬式复合绝缘子FXBW-10/70",
"modelPath": "2",
"appearanceCheck": [
{
"name": "交流棒形悬式复合绝缘子FXBW-10/70",
"content": [
"悬式",
"复合材质",
"表面光滑"
]
}
],
"mainDataCheck": [
{
"name": "公称结构高度",
"content": [
"390mm"
]
},
{
"name": "公称爬电距离",
"content": [
"490mm"
]
},
{
"name": "规定机械破坏负荷",
"content": [
"70kN"
]
},
{
"name": "逐个拉伸试验负荷",
"content": [
"35kN"
]
},
{
"name": "雷电全波冲击耐受电压",
"content": [
"75kV"
]
}
],
"componentCheck": [
{
"name": "本体",
"content": [
"护套与芯棒之间以及伞裙与护套之间的界面应是永久性粘接",
"粘接部分应牢固密实,没有气泡和缝隙,防止污秽物和水汽进入"
]
},
{
"name": "锁紧销",
"content": [
"采用锡青铜、黄铜、奥氏体不锈钢或其他耐锈蚀性材料制作,不采用有防腐蚀表层而本身不耐锈蚀的材料,与绝缘子成套供应",
"销腿末端弯曲部分尺寸严格满足标准规定,末端分开到 180° 再扳回原位无裂纹"
]
},
{
"name": "铭牌",
"content": [
"标记制造厂名或商标、制造日期、额定电压、额定机械负荷、结构高度和产品编号"
]
}
]
},
{
"modelName": "10kV 三相隔离开关",
"modelPath": "3",
"appearanceCheck": [
{
"name": "10kV 三相隔离开关结构方案",
"content": [
"瓷绝缘子:实心高强瓷绝缘子,两端外胶装,外层涂抗紫外线灰釉,接线端配绝缘罩",
"复合绝缘子:高品质硅橡胶材料复合绝缘子,接线端配绝缘罩"
]
}
],
"mainDataCheck": [
{
"name": "额定电压 Ur",
"content": [
"12kV"
]
},
{
"name": "额定电流 Ir",
"content": [
"630A"
]
},
{
"name": "额定频率 fr",
"content": [
"50Hz"
]
},
{
"name": "短时耐受电流 Ik/tk",
"content": [
"20kA/4s"
]
},
{
"name": "额定峰值耐受电流 Ip",
"content": [
"50kA"
]
},
{
"name": "额定工频耐受电压 Ud",
"content": [
"42kV"
]
},
{
"name": "额定雷电冲击耐受电压 Up",
"content": [
"75kV"
]
}
],
"componentCheck": [
{
"name": "基座材质",
"content": [
"不锈钢 (不低于 S304厚度不小于 5mm) 或热镀锌钢板 (镀层不小于 70μm厚度不小于 3mm)"
]
},
{
"name": "干弧距离",
"content": [
"不小于 300mm"
]
},
{
"name": "爬电距离",
"content": [
"不小于 550mm"
]
},
{
"name": "触头导电板镀层",
"content": [
"接触部位镀银 (厚度不小于 20μm硬度不小于 120HV)",
"其余部位镀银 (厚度不小于 8μm)"
]
},
{
"name": "紧固件材质",
"content": [
"本体螺栓、螺母、垫片全部采用 316L 不锈钢",
"瓷瓶上下固定螺栓和弹簧采用 304 不锈钢"
]
},
{
"name": "配套紧固件",
"content": [
"配套两侧接线板连接紧固件 4 组,含 M12 螺栓、螺帽及垫片,材质 316L 不锈钢"
]
},
{
"name": "铭牌",
"content": [
"标有产品标准规定必要信息,与招标要求一致"
]
}
]
}
]

13
src/main.js Normal file
View File

@@ -0,0 +1,13 @@
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
Vue.use(ElementUI)
Vue.config.productionTip = false
new Vue({
router,
render: h => h(App)
}).$mount('#app')

30
src/router/index.js Normal file
View File

@@ -0,0 +1,30 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import ModelList from '../views/ModelList.vue'
import ModelDetail from '../views/ModelDetail.vue'
Vue.use(VueRouter)
const routes = [
{
path: '/',
redirect: '/list'
},
{
path: '/list',
name: 'ModelList',
component: ModelList
},
{
path: '/detail/:id',
name: 'ModelDetail',
component: ModelDetail
}
]
const router = new VueRouter({
mode: 'hash',
routes
})
export default router

325
src/views/ModelDetail.vue Normal file
View File

@@ -0,0 +1,325 @@
<template>
<div class="model-detail" :class="{ 'mobile': isMobile }">
<!-- 顶部标题栏 -->
<div class="header">
<el-button
icon="el-icon-arrow-left"
circle
size="small"
@click="goBack"
class="back-btn"
></el-button>
<h2 class="title">{{ modelData.modelName }}</h2>
</div>
<!-- 主内容区 -->
<div class="content">
<!-- PC端布局左右分栏 -->
<template v-if="!isMobile">
<div class="viewer-section">
<three-viewer
v-if="modelPath"
:model-path="modelPath"
:annotations="modelData.componentCheck"
@annotation-click="handleAnnotationClick"
@model-loaded="handleModelLoaded"
@model-error="handleModelError"
/>
<!-- <three-viewer-debug
:model-path="modelPath"
:annotations="modelData.componentCheck"
:debug-mode="true"
@annotation-click="handleAnnotationClick"
@model-loaded="handleModelLoaded"
/> -->
<div v-if="loading" class="loading-overlay">
<el-progress
type="circle"
:percentage="loadingProgress"
:width="80"
></el-progress>
<p>加载模型中...</p>
</div>
</div>
<div class="table-section">
<check-table
:appearance-check="modelData.appearanceCheck"
:main-data-check="modelData.mainDataCheck"
:component-check="modelData.componentCheck"
:highlighted-index="selectedComponentIndex"
/>
</div>
</template>
<!-- 移动端布局上中下 -->
<template v-else>
<div class="viewer-section-mobile">
<three-viewer
v-if="modelPath"
:model-path="modelPath"
:annotations="modelData.componentCheck"
@annotation-click="handleAnnotationClick"
@model-loaded="handleModelLoaded"
@model-error="handleModelError"
/>
<div v-if="loading" class="loading-overlay">
<el-progress
type="circle"
:percentage="loadingProgress"
:width="60"
></el-progress>
<p>加载中...</p>
</div>
</div>
<div class="table-section-mobile">
<check-table
:appearance-check="modelData.appearanceCheck"
:main-data-check="modelData.mainDataCheck"
:component-check="modelData.componentCheck"
:highlighted-index="selectedComponentIndex"
/>
</div>
</template>
</div>
<!-- 移动端点击标注后的详情抽屉 -->
<el-drawer
:visible.sync="drawerVisible"
direction="btt"
size="60%"
:with-header="false"
v-if="isMobile && selectedComponent"
>
<div class="drawer-content">
<h3>{{ selectedComponent.name }}</h3>
<div class="requirement-title">检查要求</div>
<ol>
<li v-for="(content, idx) in selectedComponent.content" :key="idx">
{{ content }}
</li>
</ol>
</div>
</el-drawer>
</div>
</template>
<script>
import ThreeViewer from '../components/ThreeViewer.vue'
import ThreeViewerDebug from '../components/ThreeViewerDebug.vue'
import CheckTable from '../components/CheckTable.vue'
import modelData from '../data/data.json'
export default {
name: 'ModelDetail',
components: {
ThreeViewer,
CheckTable,
ThreeViewerDebug
},
data() {
return {
modelData: {},
modelPath: '',
loading: true,
loadingProgress: 0,
selectedComponentIndex: -1,
selectedComponent: null,
drawerVisible: false,
isMobile: false
}
},
created() {
this.checkDevice()
this.loadModelData()
window.addEventListener('resize', this.checkDevice)
},
beforeDestroy() {
window.removeEventListener('resize', this.checkDevice)
},
methods: {
checkDevice() {
this.isMobile = window.innerWidth <= 768
},
loadModelData() {
const id = parseInt(this.$route.params.id)
if (id >= 0 && id < modelData.length) {
this.modelData = modelData[id]
// 构建模型路径
this.modelPath = `/models/${this.modelData.modelPath}.glb`
} else {
this.$message.error('模型不存在')
this.goBack()
}
},
handleAnnotationClick(annotation, index) {
this.selectedComponentIndex = index
this.selectedComponent = annotation
if (this.isMobile) {
this.drawerVisible = true
}
},
handleModelLoaded() {
this.loading = false
this.$message.success('模型加载完成')
},
handleModelError(error) {
this.loading = false
this.$message.error('模型加载失败,请检查模型文件是否存在')
console.error('模型加载错误:', error)
},
goBack() {
this.$router.push({ name: 'ModelList' })
}
}
}
</script>
<style scoped>
.model-detail {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: #fff;
}
.header {
height: 60px;
background: #409eff;
color: white;
display: flex;
align-items: center;
padding: 0 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
position: relative;
}
.back-btn {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
margin-right: 15px;
}
.back-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.title {
margin: 0;
font-size: 18px;
font-weight: 500;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.content {
flex: 1;
display: flex;
overflow: hidden;
}
/* PC端布局 */
.viewer-section {
flex: 1;
position: relative;
border-right: 1px solid #e4e7ed;
}
.table-section {
width: 450px;
overflow: hidden;
}
/* 移动端布局 */
.mobile .content {
flex-direction: column;
}
.viewer-section-mobile {
height: 40%;
position: relative;
border-bottom: 1px solid #e4e7ed;
}
.table-section-mobile {
flex: 1;
overflow: hidden;
}
/* 加载状态 */
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 10;
}
.loading-overlay p {
margin-top: 15px;
color: #606266;
font-size: 14px;
}
/* 抽屉内容 */
.drawer-content {
padding: 20px;
}
.drawer-content h3 {
margin: 0 0 15px 0;
color: #303133;
font-size: 18px;
padding-bottom: 10px;
border-bottom: 2px solid #409eff;
}
.drawer-content .requirement-title {
font-weight: 500;
color: #409eff;
margin: 15px 0 10px 0;
font-size: 15px;
}
.drawer-content ol {
margin: 0;
padding-left: 20px;
}
.drawer-content li {
margin: 10px 0;
color: #606266;
line-height: 1.8;
font-size: 14px;
}
@media (max-width: 768px) {
.header {
height: 50px;
padding: 0 15px;
}
.title {
font-size: 16px;
}
.back-btn {
margin-right: 10px;
}
}
</style>

123
src/views/ModelList.vue Normal file
View File

@@ -0,0 +1,123 @@
<template>
<div class="model-list">
<div class="header">
<h1>模型检测系统</h1>
</div>
<div class="list-container">
<el-card
v-for="(item, index) in modelList"
:key="index"
class="model-card"
shadow="hover"
@click.native="goToDetail(index)"
>
<div class="card-content">
<i class="el-icon-box"></i>
<h3>{{ item.modelName }}</h3>
<p>点击查看详情</p>
</div>
</el-card>
</div>
</div>
</template>
<script>
import modelData from '../data/data.json'
export default {
name: 'ModelList',
data() {
return {
modelList: modelData
}
},
methods: {
goToDetail(id) {
this.$router.push({ name: 'ModelDetail', params: { id } })
}
}
}
</script>
<style scoped>
.model-list {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: #f5f7fa;
}
.header {
background: #409eff;
color: white;
padding: 20px;
text-align: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.header h1 {
margin: 0;
font-size: 24px;
font-weight: 500;
}
.list-container {
flex: 1;
overflow-y: auto;
padding: 20px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
align-content: start;
}
.model-card {
cursor: pointer;
transition: transform 0.3s;
}
.model-card:hover {
transform: translateY(-5px);
}
.card-content {
text-align: center;
padding: 20px;
}
.card-content i {
font-size: 48px;
color: #409eff;
margin-bottom: 15px;
}
.card-content h3 {
font-size: 16px;
color: #303133;
margin: 10px 0;
line-height: 1.5;
}
.card-content p {
font-size: 14px;
color: #909399;
margin: 10px 0 0;
}
@media (max-width: 768px) {
.header h1 {
font-size: 20px;
}
.list-container {
grid-template-columns: 1fr;
padding: 15px;
gap: 15px;
}
.card-content h3 {
font-size: 14px;
}
}
</style>