574 lines
15 KiB
Vue
574 lines
15 KiB
Vue
|
|
<!--
|
|||
|
|
Model3DView.vue
|
|||
|
|
3D模型视图组件
|
|||
|
|
负责加载、渲染和交互3D模型
|
|||
|
|
-->
|
|||
|
|
<template>
|
|||
|
|
<div ref="containerRef" class="w-full h-full relative overflow-hidden">
|
|||
|
|
<!-- 加载遮罩 -->
|
|||
|
|
<LoadingOverlay :is-loading="isLoading" />
|
|||
|
|
|
|||
|
|
<!-- 工具栏 -->
|
|||
|
|
<ModelToolbar
|
|||
|
|
:auto-rotate="autoRotate"
|
|||
|
|
:is-maximized="isMaximized || false"
|
|||
|
|
:lighting-mode="lightingMode"
|
|||
|
|
:double-sided="doubleSided"
|
|||
|
|
:show-download="isFullMode"
|
|||
|
|
@reset-view="resetView"
|
|||
|
|
@toggle-auto-rotate="toggleAutoRotate"
|
|||
|
|
@toggle-maximize="emit('toggleMaximize')"
|
|||
|
|
@toggle-lighting="toggleLighting"
|
|||
|
|
@toggle-double-side="toggleDoubleSide"
|
|||
|
|
@export-model="exportModel"
|
|||
|
|
@export-hierarchy="exportHierarchy"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<!-- 视图控制 -->
|
|||
|
|
<ViewControls @set-standard-view="setStandardView" />
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
/**
|
|||
|
|
* Model3DView 组件
|
|||
|
|
* 3D模型的主视图组件,负责模型的加载、渲染和交互
|
|||
|
|
*/
|
|||
|
|
import * as THREE from 'three'
|
|||
|
|
import { nextTick } from 'vue'
|
|||
|
|
import { useInteraction } from '~/composables/powerStation/useInteraction'
|
|||
|
|
import { useModelManager } from '~/composables/powerStation/useModelManager'
|
|||
|
|
import { useThreeScene } from '~/composables/powerStation/useThreeScene'
|
|||
|
|
import { usePowerStationStore } from '~/stores/powerStation'
|
|||
|
|
import LoadingOverlay from './Model3D/LoadingOverlay.vue'
|
|||
|
|
import ModelToolbar from './Model3D/ModelToolbar.vue'
|
|||
|
|
import ViewControls from './Model3D/ViewControls.vue'
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// Props 和 Emits
|
|||
|
|
// ============================================================
|
|||
|
|
|
|||
|
|
const props = defineProps<{
|
|||
|
|
/** 是否最大化 */
|
|||
|
|
isMaximized?: boolean
|
|||
|
|
}>()
|
|||
|
|
|
|||
|
|
const emit = defineEmits(['toggleMaximize'])
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// 状态和引用
|
|||
|
|
// ============================================================
|
|||
|
|
|
|||
|
|
const route = useRoute()
|
|||
|
|
const store = usePowerStationStore()
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// Three.js 场景系统
|
|||
|
|
// ============================================================
|
|||
|
|
|
|||
|
|
const {
|
|||
|
|
containerRef,
|
|||
|
|
initThree,
|
|||
|
|
getScene,
|
|||
|
|
getCamera,
|
|||
|
|
getRenderer,
|
|||
|
|
getControls,
|
|||
|
|
getComposer,
|
|||
|
|
addAnimateCallback,
|
|||
|
|
addResizeCallback,
|
|||
|
|
onWindowResize,
|
|||
|
|
setStandardView,
|
|||
|
|
} = useThreeScene()
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// 模型管理系统
|
|||
|
|
// ============================================================
|
|||
|
|
|
|||
|
|
const {
|
|||
|
|
modelGroup,
|
|||
|
|
isLoading,
|
|||
|
|
lightingMode,
|
|||
|
|
objectMap,
|
|||
|
|
initModelSystem,
|
|||
|
|
loadModel,
|
|||
|
|
processModel,
|
|||
|
|
fitView,
|
|||
|
|
toggleLighting,
|
|||
|
|
archetypeModel,
|
|||
|
|
findNodeById,
|
|||
|
|
loadModelByFileName,
|
|||
|
|
} = useModelManager(getScene, getCamera, getControls, getRenderer)
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// 交互系统
|
|||
|
|
// ============================================================
|
|||
|
|
|
|||
|
|
const {
|
|||
|
|
initInteraction,
|
|||
|
|
highlightClick,
|
|||
|
|
updatePassSize,
|
|||
|
|
dispose: disposeInteraction,
|
|||
|
|
} = useInteraction(
|
|||
|
|
getScene,
|
|||
|
|
getCamera,
|
|||
|
|
getComposer,
|
|||
|
|
() => containerRef.value,
|
|||
|
|
modelGroup,
|
|||
|
|
() => archetypeModel.value
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// 本地状态
|
|||
|
|
// ============================================================
|
|||
|
|
|
|||
|
|
/** 自动旋转状态 */
|
|||
|
|
const autoRotate = ref(false)
|
|||
|
|
/** 双面渲染状态 */
|
|||
|
|
const doubleSided = ref(false)
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// 计算属性
|
|||
|
|
// ============================================================
|
|||
|
|
|
|||
|
|
/** 是否为完整模式 */
|
|||
|
|
const isFullMode = computed(() => store.loadMode)
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// 视图控制方法
|
|||
|
|
// ============================================================
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 切换自动旋转
|
|||
|
|
*/
|
|||
|
|
const toggleAutoRotate = () => {
|
|||
|
|
autoRotate.value = !autoRotate.value
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 切换双面渲染
|
|||
|
|
* 使用 shader 方式实现,通过修改材质的 side 属性
|
|||
|
|
*/
|
|||
|
|
const toggleDoubleSide = () => {
|
|||
|
|
doubleSided.value = !doubleSided.value
|
|||
|
|
const scene = getScene()
|
|||
|
|
if (!scene) return
|
|||
|
|
|
|||
|
|
// 遍历场景中所有物体,修改材质的渲染面属性
|
|||
|
|
scene.traverse((child: THREE.Object3D) => {
|
|||
|
|
if ((child as THREE.Mesh).isMesh) {
|
|||
|
|
const mesh = child as THREE.Mesh
|
|||
|
|
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
|||
|
|
|
|||
|
|
materials.forEach((mat: THREE.Material) => {
|
|||
|
|
if (doubleSided.value) {
|
|||
|
|
// 保存原始的 side 值,然后设置为 DoubleSide
|
|||
|
|
if (mat.userData.originalSide === undefined) {
|
|||
|
|
mat.userData.originalSide = mat.side
|
|||
|
|
}
|
|||
|
|
mat.side = THREE.DoubleSide
|
|||
|
|
} else {
|
|||
|
|
// 恢复原始的 side 值
|
|||
|
|
if (mat.userData.originalSide !== undefined) {
|
|||
|
|
mat.side = mat.userData.originalSide
|
|||
|
|
} else {
|
|||
|
|
mat.side = THREE.FrontSide
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
mat.needsUpdate = true
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 重置视图
|
|||
|
|
*/
|
|||
|
|
const resetView = async () => {
|
|||
|
|
const rootPath = store.currentNodeId || archetypeModel.value?.name || 'root'
|
|||
|
|
await navigateTo({
|
|||
|
|
query: {
|
|||
|
|
...route.query,
|
|||
|
|
currentNodeId: rootPath,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// 导出功能
|
|||
|
|
// ============================================================
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 导出GLB模型
|
|||
|
|
*/
|
|||
|
|
const exportModel = async () => {
|
|||
|
|
try {
|
|||
|
|
if (!modelGroup.children.length) {
|
|||
|
|
ElMessage.warning('没有可导出的模型')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const currentModel = modelGroup.children[0]
|
|||
|
|
if (!currentModel) {
|
|||
|
|
ElMessage.warning('未找到当前模型')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 克隆模型用于导出
|
|||
|
|
const exportModelClone = currentModel.clone()
|
|||
|
|
|
|||
|
|
// 清理循环引用
|
|||
|
|
cleanCircularReferences(exportModelClone)
|
|||
|
|
|
|||
|
|
// 转换材质为GLTF兼容格式
|
|||
|
|
convertMaterialsToGLTFCompatible(exportModelClone)
|
|||
|
|
|
|||
|
|
// 生成模型名称
|
|||
|
|
const modelName = store.currentNodeId || '模型'
|
|||
|
|
|
|||
|
|
// 创建导出场景
|
|||
|
|
const exportScene = new THREE.Scene()
|
|||
|
|
exportScene.add(exportModelClone)
|
|||
|
|
|
|||
|
|
// 使用GLTFExporter导出
|
|||
|
|
const { GLTFExporter } = await import('three/examples/jsm/exporters/GLTFExporter.js')
|
|||
|
|
const exporter = new GLTFExporter()
|
|||
|
|
|
|||
|
|
exporter.parse(
|
|||
|
|
exportScene,
|
|||
|
|
gltf => {
|
|||
|
|
const glbData = new Blob([gltf], { type: 'model/gltf-binary' })
|
|||
|
|
downloadBlob(glbData, `${modelName}.glb`)
|
|||
|
|
ElMessage.success(`模型 ${modelName}.glb 导出成功`)
|
|||
|
|
},
|
|||
|
|
error => {
|
|||
|
|
console.error('导出失败:', error)
|
|||
|
|
ElMessage.error('模型导出失败')
|
|||
|
|
},
|
|||
|
|
{ binary: true }
|
|||
|
|
)
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('导出错误:', error)
|
|||
|
|
ElMessage.error('导出过程中发生错误')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 导出层级关系JSON
|
|||
|
|
*/
|
|||
|
|
const exportHierarchy = () => {
|
|||
|
|
try {
|
|||
|
|
if (!modelGroup.children.length) {
|
|||
|
|
ElMessage.warning('没有可导出的模型层级')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!archetypeModel.value) {
|
|||
|
|
ElMessage.warning('未找到archetypeModel')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const modelName = store.currentNodeId || '模型'
|
|||
|
|
const hierarchyData = buildHierarchy(archetypeModel.value)
|
|||
|
|
const jsonData = JSON.stringify(hierarchyData, null, 2)
|
|||
|
|
|
|||
|
|
const blob = new Blob([jsonData], { type: 'application/json' })
|
|||
|
|
downloadBlob(blob, `${modelName}~hierarchy.json`)
|
|||
|
|
|
|||
|
|
ElMessage.success(`物体层级关系 ${modelName}~hierarchy.json 导出成功`)
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('导出层级关系错误:', error)
|
|||
|
|
ElMessage.error('导出层级关系失败')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// 导出辅助函数
|
|||
|
|
// ============================================================
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 清理循环引用
|
|||
|
|
*/
|
|||
|
|
const cleanCircularReferences = (object: THREE.Object3D) => {
|
|||
|
|
object.traverse(child => {
|
|||
|
|
if ((child as THREE.Mesh).isMesh) {
|
|||
|
|
const mesh = child as THREE.Mesh
|
|||
|
|
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
|||
|
|
|
|||
|
|
materials.forEach(mat => {
|
|||
|
|
if (mat.userData) {
|
|||
|
|
delete mat.userData.lambertPartner
|
|||
|
|
delete mat.userData.pbrPartner
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 转换材质为GLTF兼容格式
|
|||
|
|
*/
|
|||
|
|
const convertMaterialsToGLTFCompatible = (object: THREE.Object3D) => {
|
|||
|
|
object.traverse(child => {
|
|||
|
|
if ((child as THREE.Mesh).isMesh) {
|
|||
|
|
const mesh = child as THREE.Mesh
|
|||
|
|
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
|||
|
|
|
|||
|
|
const newMaterials = materials.map(mat => {
|
|||
|
|
if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshBasicMaterial') {
|
|||
|
|
return mat
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const originalMap = (mat as any).map || null
|
|||
|
|
const useAlpha = originalMap !== null
|
|||
|
|
|
|||
|
|
const standardMat = new THREE.MeshStandardMaterial({
|
|||
|
|
color: (mat as any).color || new THREE.Color(0xaaaaaa),
|
|||
|
|
map: originalMap,
|
|||
|
|
alphaMap: originalMap,
|
|||
|
|
normalMap: (mat as any).normalMap || null,
|
|||
|
|
transparent: useAlpha || (mat as any).transparent || false,
|
|||
|
|
opacity: useAlpha ? 1 : (mat as any).opacity || 1,
|
|||
|
|
side: mat.side || THREE.FrontSide,
|
|||
|
|
metalness: 0.6,
|
|||
|
|
roughness: 0.6,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if (mat.userData) {
|
|||
|
|
standardMat.userData = { ...mat.userData }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return standardMat
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
mesh.material = Array.isArray(mesh.material) ? newMaterials : newMaterials[0]
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 构建层级结构
|
|||
|
|
*/
|
|||
|
|
const buildHierarchy = (object: THREE.Object3D): any => {
|
|||
|
|
const obj: any = {
|
|||
|
|
id: object.uuid,
|
|||
|
|
name: object.name,
|
|||
|
|
type: object.type,
|
|||
|
|
children: [],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
object.children.forEach(child => {
|
|||
|
|
obj.children.push(buildHierarchy(child))
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return obj
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 下载Blob文件
|
|||
|
|
*/
|
|||
|
|
const downloadBlob = (blob: Blob, filename: string) => {
|
|||
|
|
const url = URL.createObjectURL(blob)
|
|||
|
|
const link = document.createElement('a')
|
|||
|
|
link.href = url
|
|||
|
|
link.download = filename
|
|||
|
|
document.body.appendChild(link)
|
|||
|
|
link.click()
|
|||
|
|
document.body.removeChild(link)
|
|||
|
|
URL.revokeObjectURL(url)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// 监听器
|
|||
|
|
// ============================================================
|
|||
|
|
|
|||
|
|
// 监听最大化状态变化
|
|||
|
|
watch(
|
|||
|
|
() => props.isMaximized,
|
|||
|
|
() => {
|
|||
|
|
setTimeout(() => {
|
|||
|
|
onWindowResize()
|
|||
|
|
fitView()
|
|||
|
|
}, 350)
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// 路由监听器(核心逻辑入口)
|
|||
|
|
watch(
|
|||
|
|
() => route.query.currentNodeId,
|
|||
|
|
newId => {
|
|||
|
|
const id = newId as string
|
|||
|
|
console.log('路由currentNodeId变化:', id)
|
|||
|
|
|
|||
|
|
// 确保store的currentNodeId与路由参数一致
|
|||
|
|
if (id) {
|
|||
|
|
store.currentNodeId = id
|
|||
|
|
store.selectNode({ id })
|
|||
|
|
} else {
|
|||
|
|
store.currentNodeId = ''
|
|||
|
|
store.selectNode({ id: '' })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (isFullMode.value) {
|
|||
|
|
// 完整模式:处理模型
|
|||
|
|
if (archetypeModel.value) {
|
|||
|
|
processModel(id, true)
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// 简化模式:直接加载对应文件
|
|||
|
|
if (id) {
|
|||
|
|
loadModelByFileName(id)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{ immediate: true }
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// 选择监听器
|
|||
|
|
watch(
|
|||
|
|
() => store.currentNodeId,
|
|||
|
|
async newId => {
|
|||
|
|
console.log('选择监听器触发:', newId)
|
|||
|
|
|
|||
|
|
if (!newId) {
|
|||
|
|
highlightClick(null)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const currentRouteNodeId = route.query.currentNodeId as string
|
|||
|
|
|
|||
|
|
// 等待模型加载完成
|
|||
|
|
let retryCount = 0
|
|||
|
|
const maxRetries = 5
|
|||
|
|
while (isLoading.value && retryCount < maxRetries) {
|
|||
|
|
console.log(`模型加载中,等待加载完成后再处理高亮 (重试 ${retryCount + 1}/${maxRetries})`)
|
|||
|
|
await nextTick()
|
|||
|
|
await new Promise(resolve => setTimeout(resolve, 100))
|
|||
|
|
retryCount++
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let obj = objectMap.get(newId)
|
|||
|
|
console.log('直接找到物体:', obj ? obj.name : 'null')
|
|||
|
|
|
|||
|
|
// 如果未找到精确对象,尝试查找可见的祖先
|
|||
|
|
if (!obj && archetypeModel.value) {
|
|||
|
|
console.log('搜索可见祖先...')
|
|||
|
|
let current = findNodeById(archetypeModel.value, newId)
|
|||
|
|
while (current) {
|
|||
|
|
console.log('检查祖先:', current.name, current.userData.id)
|
|||
|
|
if (current.userData.id && objectMap.has(current.userData.id)) {
|
|||
|
|
obj = objectMap.get(current.userData.id)
|
|||
|
|
console.log('找到可见祖先:', obj?.name)
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
current = current.parent
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (obj) {
|
|||
|
|
highlightClick(obj)
|
|||
|
|
} else {
|
|||
|
|
console.warn('未找到ID对应的对象:', newId)
|
|||
|
|
highlightClick(null)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (newId === currentRouteNodeId) {
|
|||
|
|
highlightClick(null)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{ immediate: true }
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// 聚焦事件处理
|
|||
|
|
// ============================================================
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处理聚焦事件
|
|||
|
|
*/
|
|||
|
|
const handleFocusEvent = (nodeId: string) => {
|
|||
|
|
console.log('聚焦事件触发:', nodeId)
|
|||
|
|
|
|||
|
|
let obj = objectMap.get(nodeId)
|
|||
|
|
|
|||
|
|
// 如果未找到精确对象,尝试查找可见的祖先
|
|||
|
|
if (!obj && archetypeModel.value) {
|
|||
|
|
console.log('搜索可见祖先...')
|
|||
|
|
let current = findNodeById(archetypeModel.value, nodeId)
|
|||
|
|
while (current) {
|
|||
|
|
console.log('检查祖先:', current.name, current.userData.id)
|
|||
|
|
if (current.userData.id && objectMap.has(current.userData.id)) {
|
|||
|
|
obj = objectMap.get(current.userData.id)
|
|||
|
|
console.log('找到可见祖先:', obj?.name)
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
current = current.parent
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (obj) {
|
|||
|
|
highlightClick(obj)
|
|||
|
|
fitView(obj, true)
|
|||
|
|
console.log('已聚焦到物体:', obj.name)
|
|||
|
|
} else {
|
|||
|
|
console.warn('未找到ID对应的对象:', nodeId)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// 生命周期
|
|||
|
|
// ============================================================
|
|||
|
|
|
|||
|
|
onMounted(() => {
|
|||
|
|
// 1. 初始化Three.js
|
|||
|
|
initThree()
|
|||
|
|
|
|||
|
|
// 2. 初始化模型系统
|
|||
|
|
initModelSystem()
|
|||
|
|
|
|||
|
|
// 3. 初始化交互
|
|||
|
|
initInteraction()
|
|||
|
|
|
|||
|
|
const currentId = route.query.currentNodeId as string
|
|||
|
|
|
|||
|
|
// 4. 根据模式加载模型
|
|||
|
|
if (isFullMode.value) {
|
|||
|
|
loadModel(`/3DModels/${currentId}.glb`, () => {
|
|||
|
|
const currentId =
|
|||
|
|
(route.query.currentNodeId as string) ||
|
|||
|
|
store.currentNodeId ||
|
|||
|
|
archetypeModel.value?.name ||
|
|||
|
|
'root'
|
|||
|
|
processModel(currentId, true)
|
|||
|
|
})
|
|||
|
|
} else {
|
|||
|
|
loadModelByFileName(currentId)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 5. 设置动画循环钩子
|
|||
|
|
addAnimateCallback(() => {
|
|||
|
|
const controls = getControls()
|
|||
|
|
if (controls) {
|
|||
|
|
controls.autoRotate = autoRotate.value
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 6. 设置尺寸调整钩子
|
|||
|
|
addResizeCallback(() => {
|
|||
|
|
if (containerRef.value) {
|
|||
|
|
const width = containerRef.value.clientWidth
|
|||
|
|
const height = containerRef.value.clientHeight
|
|||
|
|
updatePassSize(width, height)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 7. 添加聚焦事件监听器
|
|||
|
|
store.addFocusListener(handleFocusEvent)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
onUnmounted(() => {
|
|||
|
|
disposeInteraction()
|
|||
|
|
store.removeFocusListener(handleFocusEvent)
|
|||
|
|
})
|
|||
|
|
</script>
|