|
|
@@ -0,0 +1,1729 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import { onMounted, onUnmounted, ref, toRaw, watch } from 'vue'
|
|
|
+import * as THREE from 'three'
|
|
|
+import {
|
|
|
+ ElColorPicker,
|
|
|
+ ElForm,
|
|
|
+ ElFormItem,
|
|
|
+ ElInput,
|
|
|
+ ElMessage,
|
|
|
+ ElOption,
|
|
|
+ ElSelect,
|
|
|
+ ElSlider,
|
|
|
+} from 'element-plus'
|
|
|
+import { clientGet, clientPost } from '@/utils/request.ts'
|
|
|
+import { useRoute, useRouter } from 'vue-router'
|
|
|
+
|
|
|
+// MINIO 基础URL
|
|
|
+const MINIO_BASE_URL = import.meta.env.VITE_MINIO_BASE_URL
|
|
|
+
|
|
|
+// 指引接口定义
|
|
|
+interface HotspotPosition {
|
|
|
+ x: number
|
|
|
+ y: number
|
|
|
+ z: number
|
|
|
+}
|
|
|
+
|
|
|
+interface HotspotRotation {
|
|
|
+ x: number // 度数
|
|
|
+ y: number // 度数
|
|
|
+ z: number // 度数
|
|
|
+}
|
|
|
+
|
|
|
+interface Hotspot {
|
|
|
+ position: HotspotPosition
|
|
|
+ rotation: HotspotRotation
|
|
|
+ label: string
|
|
|
+ targetRoomId: string // 改为使用房间ID
|
|
|
+ width: number
|
|
|
+ height: number
|
|
|
+ color: string
|
|
|
+}
|
|
|
+
|
|
|
+// 场景接口定义
|
|
|
+interface Scene {
|
|
|
+ id: string // 添加场景ID,对应房间ID
|
|
|
+ name: string
|
|
|
+ texture: string
|
|
|
+ hotspots: Hotspot[]
|
|
|
+}
|
|
|
+
|
|
|
+interface Room {
|
|
|
+ id: string // 改为必须的
|
|
|
+ houseTypeId: string
|
|
|
+ roomNumber: string
|
|
|
+ roomPictureUrl?: string
|
|
|
+ attribute?: string
|
|
|
+ createTime?: Date
|
|
|
+ createBy?: string
|
|
|
+ updateTime?: Date
|
|
|
+ updateBy?: string
|
|
|
+}
|
|
|
+
|
|
|
+interface RoomListResponse extends BaseResponse {
|
|
|
+ data: Room[]
|
|
|
+}
|
|
|
+
|
|
|
+// 简化的错误状态
|
|
|
+const hasError = ref(false)
|
|
|
+const errorMessage = ref('')
|
|
|
+
|
|
|
+// 场景相关变量
|
|
|
+const containerRef = ref<HTMLElement>()
|
|
|
+let scene: THREE.Scene
|
|
|
+let camera: THREE.PerspectiveCamera
|
|
|
+let renderer: THREE.WebGLRenderer
|
|
|
+let sphere: THREE.Mesh
|
|
|
+let raycaster: THREE.Raycaster
|
|
|
+let mouse: THREE.Vector2
|
|
|
+let hotspots: THREE.Group[] = []
|
|
|
+
|
|
|
+// 辅助线相关
|
|
|
+let axesHelpers: THREE.AxesHelper[] = []
|
|
|
+let rotationHelpers: THREE.Group[] = []
|
|
|
+
|
|
|
+// 动画相关
|
|
|
+let isAnimating = false
|
|
|
+
|
|
|
+// 加载状态管理
|
|
|
+const isLoading = ref(true)
|
|
|
+const loadingText = ref('正在初始化...')
|
|
|
+const loadingProgress = ref(0)
|
|
|
+
|
|
|
+// 编辑模式状态
|
|
|
+const isEditMode = ref(false)
|
|
|
+const selectedHotspotIndex = ref(-1)
|
|
|
+
|
|
|
+// 新增:预览模式状态
|
|
|
+const isPreviewMode = ref(false)
|
|
|
+
|
|
|
+// 新增:鼠标点击添加模式
|
|
|
+const isAddingHotspotMode = ref(false)
|
|
|
+
|
|
|
+// 当前场景索引和ID
|
|
|
+const currentSceneIndex = ref(0)
|
|
|
+const currentSceneId = ref('')
|
|
|
+
|
|
|
+const route = useRoute()
|
|
|
+const router = useRouter()
|
|
|
+
|
|
|
+// 选中的指引配置
|
|
|
+const selectedHotspotConfig = ref<Hotspot>({
|
|
|
+ position: { x: 0, y: 0, z: 3 },
|
|
|
+ rotation: { x: 0, y: 0, z: 0 },
|
|
|
+ label: '',
|
|
|
+ targetRoomId: '', // 改为房间ID
|
|
|
+ width: 0.8,
|
|
|
+ height: 0.4,
|
|
|
+ color: '#4f46e5',
|
|
|
+})
|
|
|
+
|
|
|
+// 全景场景数据
|
|
|
+const scenes = ref<Scene[]>([])
|
|
|
+
|
|
|
+const houseId = ref(route.query.houseTypeId)
|
|
|
+
|
|
|
+// 摄像机控制相关变量 - 修复抖动问题
|
|
|
+let isMouseDown = false
|
|
|
+let mouseX = 0
|
|
|
+let mouseY = 0
|
|
|
+let lon = 0
|
|
|
+let lat = 0
|
|
|
+let phi = 0
|
|
|
+let theta = 0
|
|
|
+let isControlsInitialized = false // 新增:控制器初始化标志
|
|
|
+
|
|
|
+// 辅助函数:构建完整的贴图URL
|
|
|
+const buildTextureUrl = (url: string): string => {
|
|
|
+ if (!url) return '/img1.jpg' // 默认图片
|
|
|
+
|
|
|
+ // 如果已经是完整URL(http/https开头),直接返回
|
|
|
+ if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
|
+ return url
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果是相对路径,加上MINIO前缀
|
|
|
+ if (MINIO_BASE_URL) {
|
|
|
+ // 确保MINIO_BASE_URL末尾没有斜杠,url开头没有斜杠
|
|
|
+ const baseUrl = MINIO_BASE_URL.endsWith('/') ? MINIO_BASE_URL.slice(0, -1) : MINIO_BASE_URL
|
|
|
+ const path = url.startsWith('/') ? url : '/' + url
|
|
|
+ return baseUrl + path
|
|
|
+ }
|
|
|
+
|
|
|
+ return url
|
|
|
+}
|
|
|
+
|
|
|
+// 辅助函数:通过房间ID查找场景索引
|
|
|
+const findSceneIndexById = (roomId: string): number => {
|
|
|
+ return scenes.value.findIndex((scene) => scene.id === roomId)
|
|
|
+}
|
|
|
+
|
|
|
+// 辅助函数:通过房间ID查找场景
|
|
|
+const findSceneById = (roomId: string): Scene | undefined => {
|
|
|
+ return scenes.value.find((scene) => scene.id === roomId)
|
|
|
+}
|
|
|
+
|
|
|
+// 辅助函数:获取当前场景
|
|
|
+const getCurrentScene = (): Scene | undefined => {
|
|
|
+ return scenes.value[currentSceneIndex.value]
|
|
|
+}
|
|
|
+
|
|
|
+// 辅助函数:将屏幕坐标转换为3D世界坐标(球体表面)
|
|
|
+const screenToWorldPosition = (screenX: number, screenY: number): THREE.Vector3 | null => {
|
|
|
+ // 将屏幕坐标转换为标准化设备坐标
|
|
|
+ const rect = renderer.domElement.getBoundingClientRect()
|
|
|
+ const x = ((screenX - rect.left) / rect.width) * 2 - 1
|
|
|
+ const y = -((screenY - rect.top) / rect.height) * 2 + 1
|
|
|
+
|
|
|
+ // 创建射线
|
|
|
+ const tempRaycaster = new THREE.Raycaster()
|
|
|
+ tempRaycaster.setFromCamera(new THREE.Vector2(x, y), camera)
|
|
|
+
|
|
|
+ // 与球体相交
|
|
|
+ const intersects = tempRaycaster.intersectObject(sphere)
|
|
|
+
|
|
|
+ if (intersects.length > 0) {
|
|
|
+ return intersects[0].point
|
|
|
+ }
|
|
|
+
|
|
|
+ return null
|
|
|
+}
|
|
|
+
|
|
|
+// 创建xyz轴辅助线
|
|
|
+const createAxesHelper = (hotspot: THREE.Group, index: number): THREE.AxesHelper => {
|
|
|
+ const axesHelper = new THREE.AxesHelper(1.5)
|
|
|
+ axesHelper.position.copy(hotspot.position)
|
|
|
+ axesHelper.rotation.copy(hotspot.rotation)
|
|
|
+ axesHelper.visible = false
|
|
|
+ axesHelper.userData = { hotspotIndex: index, type: 'axes' }
|
|
|
+ return axesHelper
|
|
|
+}
|
|
|
+
|
|
|
+// 创建旋转辅助线
|
|
|
+const createRotationHelper = (hotspot: THREE.Group, index: number): THREE.Group => {
|
|
|
+ const rotationGroup = new THREE.Group()
|
|
|
+
|
|
|
+ // X轴旋转圆环(红色)
|
|
|
+ const xRingGeometry = new THREE.RingGeometry(0.8, 0.85, 32)
|
|
|
+ const xRingMaterial = new THREE.MeshBasicMaterial({
|
|
|
+ color: 0xff0000,
|
|
|
+ transparent: true,
|
|
|
+ opacity: 0.6,
|
|
|
+ side: THREE.DoubleSide,
|
|
|
+ })
|
|
|
+ const xRing = new THREE.Mesh(xRingGeometry, xRingMaterial)
|
|
|
+ xRing.rotation.y = Math.PI / 2
|
|
|
+ rotationGroup.add(xRing)
|
|
|
+
|
|
|
+ // Y轴旋转圆环(绿色)
|
|
|
+ const yRingGeometry = new THREE.RingGeometry(0.9, 0.95, 32)
|
|
|
+ const yRingMaterial = new THREE.MeshBasicMaterial({
|
|
|
+ color: 0x00ff00,
|
|
|
+ transparent: true,
|
|
|
+ opacity: 0.6,
|
|
|
+ side: THREE.DoubleSide,
|
|
|
+ })
|
|
|
+ const yRing = new THREE.Mesh(yRingGeometry, yRingMaterial)
|
|
|
+ rotationGroup.add(yRing)
|
|
|
+
|
|
|
+ // Z轴旋转圆环(蓝色)
|
|
|
+ const zRingGeometry = new THREE.RingGeometry(1.0, 1.05, 32)
|
|
|
+ const zRingMaterial = new THREE.MeshBasicMaterial({
|
|
|
+ color: 0x0000ff,
|
|
|
+ transparent: true,
|
|
|
+ opacity: 0.6,
|
|
|
+ side: THREE.DoubleSide,
|
|
|
+ })
|
|
|
+ const zRing = new THREE.Mesh(zRingGeometry, zRingMaterial)
|
|
|
+ zRing.rotation.x = Math.PI / 2
|
|
|
+ rotationGroup.add(zRing)
|
|
|
+
|
|
|
+ rotationGroup.position.copy(hotspot.position)
|
|
|
+ rotationGroup.rotation.copy(hotspot.rotation)
|
|
|
+ rotationGroup.visible = false
|
|
|
+ rotationGroup.userData = { hotspotIndex: index, type: 'rotation' }
|
|
|
+
|
|
|
+ return rotationGroup
|
|
|
+}
|
|
|
+
|
|
|
+// 更新辅助线显示状态
|
|
|
+const updateHelpersVisibility = () => {
|
|
|
+ // 预览模式下隐藏所有辅助线
|
|
|
+ const shouldShowHelpers = isEditMode.value && !isPreviewMode.value
|
|
|
+
|
|
|
+ axesHelpers.forEach((helper, index) => {
|
|
|
+ helper.visible =
|
|
|
+ shouldShowHelpers &&
|
|
|
+ (selectedHotspotIndex.value === -1 || selectedHotspotIndex.value === index)
|
|
|
+ })
|
|
|
+
|
|
|
+ rotationHelpers.forEach((helper, index) => {
|
|
|
+ helper.visible =
|
|
|
+ shouldShowHelpers &&
|
|
|
+ (selectedHotspotIndex.value === -1 || selectedHotspotIndex.value === index)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// 更新选中指引的辅助线
|
|
|
+const updateSelectedHotspotHelpers = () => {
|
|
|
+ if (selectedHotspotIndex.value >= 0) {
|
|
|
+ const hotspot = hotspots[selectedHotspotIndex.value]
|
|
|
+ if (
|
|
|
+ hotspot &&
|
|
|
+ axesHelpers[selectedHotspotIndex.value] &&
|
|
|
+ rotationHelpers[selectedHotspotIndex.value]
|
|
|
+ ) {
|
|
|
+ axesHelpers[selectedHotspotIndex.value].position.copy(hotspot.position)
|
|
|
+ axesHelpers[selectedHotspotIndex.value].rotation.copy(hotspot.rotation)
|
|
|
+
|
|
|
+ rotationHelpers[selectedHotspotIndex.value].position.copy(hotspot.position)
|
|
|
+ rotationHelpers[selectedHotspotIndex.value].rotation.copy(hotspot.rotation)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const convertJSON2Properties = (json: string | undefined): Hotspot[] => {
|
|
|
+ if (!json) {
|
|
|
+ return []
|
|
|
+ }
|
|
|
+ const q: Hotspot[] = JSON.parse(json)
|
|
|
+
|
|
|
+ return q.map((j) => ({
|
|
|
+ color: j.color,
|
|
|
+ height: j.height,
|
|
|
+ label: j.label,
|
|
|
+ position: {
|
|
|
+ x: j.position.x,
|
|
|
+ y: j.position.y,
|
|
|
+ z: j.position.z,
|
|
|
+ },
|
|
|
+ rotation: {
|
|
|
+ x: j.rotation.x,
|
|
|
+ y: j.rotation.y,
|
|
|
+ z: j.rotation.z,
|
|
|
+ },
|
|
|
+ targetRoomId: j.targetRoomId,
|
|
|
+ width: j.width,
|
|
|
+ }))
|
|
|
+}
|
|
|
+
|
|
|
+// 远程数据获取
|
|
|
+const fetchScenesData = async () => {
|
|
|
+ loadingText.value = '正在获取场景数据...'
|
|
|
+ loadingProgress.value = 20
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await clientGet<string, RoomListResponse>('/aroom/getById/' + houseId.value)
|
|
|
+ if (res.code !== 200) {
|
|
|
+ throw new Error(res.msg || '获取场景数据失败')
|
|
|
+ }
|
|
|
+
|
|
|
+ // 转换API数据为场景数据格式
|
|
|
+ const remoteData: Scene[] = res.data.map((room: Room) => ({
|
|
|
+ id: room.id, // 使用房间ID作为场景ID
|
|
|
+ name: room.roomNumber || `房间${room.id}`,
|
|
|
+ texture: buildTextureUrl(room.roomPictureUrl || ''), // 使用构建函数处理贴图URL
|
|
|
+ hotspots: convertJSON2Properties(room.attribute),
|
|
|
+ }))
|
|
|
+
|
|
|
+ scenes.value = remoteData
|
|
|
+
|
|
|
+ // 设置当前场景ID
|
|
|
+ if (remoteData.length > 0) {
|
|
|
+ currentSceneId.value = remoteData[0].id
|
|
|
+ }
|
|
|
+
|
|
|
+ loadingProgress.value = 50
|
|
|
+ console.log('场景数据获取成功:', remoteData)
|
|
|
+ } catch (error: any) {
|
|
|
+ console.error('获取场景数据失败:', error)
|
|
|
+ hasError.value = true
|
|
|
+ errorMessage.value = error.message || '数据获取失败,请稍后重试'
|
|
|
+ isLoading.value = false
|
|
|
+ throw error
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 模拟其他配置数据获取
|
|
|
+const fetchConfigData = async () => {
|
|
|
+ loadingText.value = '正在获取配置数据...'
|
|
|
+ loadingProgress.value = 60
|
|
|
+
|
|
|
+ try {
|
|
|
+ // await new Promise(resolve => setTimeout(resolve, 800))
|
|
|
+ console.log('配置数据获取完成')
|
|
|
+ loadingProgress.value = 70
|
|
|
+ } catch (error) {
|
|
|
+ console.warn('配置数据获取失败,使用默认配置')
|
|
|
+ loadingProgress.value = 70
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 初始化应用
|
|
|
+const initApp = async () => {
|
|
|
+ try {
|
|
|
+ await fetchScenesData()
|
|
|
+ await fetchConfigData()
|
|
|
+ await initThreeJS()
|
|
|
+ } catch (error) {
|
|
|
+ console.error('应用初始化失败:', error)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 监听选中指引配置的变化,实时更新
|
|
|
+watch(
|
|
|
+ selectedHotspotConfig,
|
|
|
+ (newConfig) => {
|
|
|
+ if (selectedHotspotIndex.value >= 0 && isEditMode.value && !isPreviewMode.value) {
|
|
|
+ const currentScene = getCurrentScene()
|
|
|
+ if (currentScene) {
|
|
|
+ currentScene.hotspots[selectedHotspotIndex.value] = { ...newConfig }
|
|
|
+ updateSelectedHotspot()
|
|
|
+ updateSelectedHotspotHelpers()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ { deep: true },
|
|
|
+)
|
|
|
+
|
|
|
+// 监听编辑模式变化
|
|
|
+watch(isEditMode, () => {
|
|
|
+ updateHelpersVisibility()
|
|
|
+})
|
|
|
+
|
|
|
+// 监听预览模式变化
|
|
|
+watch(isPreviewMode, () => {
|
|
|
+ updateHelpersVisibility()
|
|
|
+ // 预览模式下退出添加热点模式
|
|
|
+ if (isPreviewMode.value) {
|
|
|
+ isAddingHotspotMode.value = false
|
|
|
+ selectedHotspotIndex.value = -1
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+// 监听选中指引变化
|
|
|
+watch(selectedHotspotIndex, () => {
|
|
|
+ updateHelpersVisibility()
|
|
|
+})
|
|
|
+
|
|
|
+// 监听添加热点模式变化
|
|
|
+watch(isAddingHotspotMode, (newValue) => {
|
|
|
+ if (newValue) {
|
|
|
+ ElMessage.info('进入热点添加模式,点击场景中的位置添加热点')
|
|
|
+ // 改变鼠标样式
|
|
|
+ if (renderer && renderer.domElement) {
|
|
|
+ renderer.domElement.style.cursor = 'crosshair'
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ ElMessage.info('退出热点添加模式')
|
|
|
+ // 恢复鼠标样式
|
|
|
+ if (renderer && renderer.domElement) {
|
|
|
+ renderer.domElement.style.cursor = 'grab'
|
|
|
+ }
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+// 初始化Three.js场景
|
|
|
+const initThreeJS = async () => {
|
|
|
+ if (!containerRef.value) return
|
|
|
+
|
|
|
+ loadingText.value = '正在初始化3D场景...'
|
|
|
+ loadingProgress.value = 75
|
|
|
+
|
|
|
+ // 创建场景
|
|
|
+ scene = new THREE.Scene()
|
|
|
+
|
|
|
+ // 创建摄像机
|
|
|
+ camera = new THREE.PerspectiveCamera(
|
|
|
+ 75,
|
|
|
+ containerRef.value.clientWidth / containerRef.value.clientHeight,
|
|
|
+ 0.1,
|
|
|
+ 1000,
|
|
|
+ )
|
|
|
+ camera.position.set(0, 0, 0)
|
|
|
+
|
|
|
+ // 创建渲染器
|
|
|
+ renderer = new THREE.WebGLRenderer({ antialias: true })
|
|
|
+ renderer.setSize(containerRef.value.clientWidth, containerRef.value.clientHeight)
|
|
|
+ renderer.setPixelRatio(window.devicePixelRatio)
|
|
|
+ containerRef.value.appendChild(renderer.domElement)
|
|
|
+
|
|
|
+ loadingProgress.value = 80
|
|
|
+
|
|
|
+ // 创建球体几何体
|
|
|
+ const geometry = new THREE.SphereGeometry(7, 60, 40)
|
|
|
+ geometry.scale(-1, 1, 1)
|
|
|
+
|
|
|
+ // 创建材质
|
|
|
+ const material = new THREE.MeshStandardMaterial({
|
|
|
+ lightMapIntensity: 1,
|
|
|
+ color: 0xcccccc,
|
|
|
+ })
|
|
|
+ sphere = new THREE.Mesh(geometry, material)
|
|
|
+ scene.add(sphere)
|
|
|
+
|
|
|
+ const ambientLight = new THREE.AmbientLight(0x404040)
|
|
|
+ ambientLight.intensity = 70
|
|
|
+ scene.add(ambientLight)
|
|
|
+
|
|
|
+ loadingProgress.value = 85
|
|
|
+
|
|
|
+ // 初始化轨道控制器
|
|
|
+ initControls()
|
|
|
+
|
|
|
+ // 初始化射线投射器
|
|
|
+ raycaster = new THREE.Raycaster()
|
|
|
+ mouse = new THREE.Vector2()
|
|
|
+
|
|
|
+ loadingProgress.value = 90
|
|
|
+
|
|
|
+ // 加载初始场景
|
|
|
+ await loadScene(currentSceneIndex.value)
|
|
|
+
|
|
|
+ loadingProgress.value = 95
|
|
|
+
|
|
|
+ // 添加事件监听
|
|
|
+ window.addEventListener('resize', onWindowResize)
|
|
|
+ renderer.domElement.addEventListener('click', onMouseClick)
|
|
|
+ renderer.domElement.addEventListener('mousemove', onMouseMove)
|
|
|
+
|
|
|
+ // 开始渲染循环
|
|
|
+ animate()
|
|
|
+
|
|
|
+ loadingProgress.value = 100
|
|
|
+ loadingText.value = '加载完成!'
|
|
|
+
|
|
|
+ setTimeout(() => {
|
|
|
+ isLoading.value = false
|
|
|
+ }, 500)
|
|
|
+}
|
|
|
+
|
|
|
+// 修复后的初始化轨道控制器
|
|
|
+const initControls = () => {
|
|
|
+ // 初始化摄像机朝向 - 防止抖动
|
|
|
+ lon = 0
|
|
|
+ lat = 0
|
|
|
+ phi = THREE.MathUtils.degToRad(90 - lat)
|
|
|
+ theta = THREE.MathUtils.degToRad(lon)
|
|
|
+
|
|
|
+ // 设置初始朝向
|
|
|
+ camera.lookAt(Math.sin(phi) * Math.cos(theta), Math.cos(phi), Math.sin(phi) * Math.sin(theta))
|
|
|
+
|
|
|
+ const onMouseDown = (event: MouseEvent) => {
|
|
|
+ if (isAnimating) return
|
|
|
+
|
|
|
+ // 防止在添加热点模式下拖拽摄像机
|
|
|
+ if (isAddingHotspotMode.value) return
|
|
|
+
|
|
|
+ isMouseDown = true
|
|
|
+ mouseX = event.clientX
|
|
|
+ mouseY = event.clientY
|
|
|
+
|
|
|
+ // 标记控制器已初始化
|
|
|
+ isControlsInitialized = true
|
|
|
+ }
|
|
|
+
|
|
|
+ const onMouseUp = () => {
|
|
|
+ isMouseDown = false
|
|
|
+ }
|
|
|
+
|
|
|
+ const onMouseMoveControl = (event: MouseEvent) => {
|
|
|
+ if (!isMouseDown || isAnimating) return
|
|
|
+
|
|
|
+ // 防止在添加热点模式下拖拽摄像机
|
|
|
+ if (isAddingHotspotMode.value) return
|
|
|
+
|
|
|
+ const deltaX = event.clientX - mouseX
|
|
|
+ const deltaY = event.clientY - mouseY
|
|
|
+
|
|
|
+ mouseX = event.clientX
|
|
|
+ mouseY = event.clientY
|
|
|
+
|
|
|
+ // 只有在控制器初始化后才应用变化,防止初始抖动
|
|
|
+ if (isControlsInitialized) {
|
|
|
+ lon -= deltaX * 0.1
|
|
|
+ lat += deltaY * 0.1
|
|
|
+
|
|
|
+ lat = Math.max(-85, Math.min(85, lat))
|
|
|
+
|
|
|
+ phi = THREE.MathUtils.degToRad(90 - lat)
|
|
|
+ theta = THREE.MathUtils.degToRad(lon)
|
|
|
+
|
|
|
+ camera.lookAt(Math.sin(phi) * Math.cos(theta), Math.cos(phi), Math.sin(phi) * Math.sin(theta))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ renderer.domElement.addEventListener('mousedown', onMouseDown)
|
|
|
+ renderer.domElement.addEventListener('mouseup', onMouseUp)
|
|
|
+ renderer.domElement.addEventListener('mousemove', onMouseMoveControl)
|
|
|
+}
|
|
|
+
|
|
|
+// 加载场景
|
|
|
+const loadScene = async (sceneIndex: number) => {
|
|
|
+ console.log(sceneIndex, 321312)
|
|
|
+ const sceneData = scenes.value[sceneIndex]
|
|
|
+ if (!sceneData) return
|
|
|
+
|
|
|
+ // 清除旧的指引和辅助线
|
|
|
+ hotspots.forEach((hotspot) => {
|
|
|
+ scene.remove(hotspot)
|
|
|
+ })
|
|
|
+ axesHelpers.forEach((helper) => {
|
|
|
+ scene.remove(helper)
|
|
|
+ })
|
|
|
+ rotationHelpers.forEach((helper) => {
|
|
|
+ scene.remove(helper)
|
|
|
+ })
|
|
|
+
|
|
|
+ hotspots = []
|
|
|
+ axesHelpers = []
|
|
|
+ rotationHelpers = []
|
|
|
+
|
|
|
+ // 加载全景贴图
|
|
|
+ const textureLoader = new THREE.TextureLoader()
|
|
|
+
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ textureLoader.load(
|
|
|
+ sceneData.texture,
|
|
|
+ (texture) => {
|
|
|
+ sphere.material.map = texture
|
|
|
+ sphere.material.needsUpdate = true
|
|
|
+
|
|
|
+ // 添加新的指引和辅助线
|
|
|
+ sceneData.hotspots.forEach((hotspotData, index) => {
|
|
|
+ const hotspot = createHotspot(hotspotData, index)
|
|
|
+ hotspots.push(hotspot)
|
|
|
+ scene.add(hotspot)
|
|
|
+
|
|
|
+ const axesHelper = createAxesHelper(hotspot, index)
|
|
|
+ const rotationHelper = createRotationHelper(hotspot, index)
|
|
|
+
|
|
|
+ axesHelpers.push(axesHelper)
|
|
|
+ rotationHelpers.push(rotationHelper)
|
|
|
+
|
|
|
+ scene.add(axesHelper)
|
|
|
+ scene.add(rotationHelper)
|
|
|
+ })
|
|
|
+
|
|
|
+ updateHelpersVisibility()
|
|
|
+
|
|
|
+ currentSceneIndex.value = sceneIndex
|
|
|
+ currentSceneId.value = sceneData.id
|
|
|
+ selectedHotspotIndex.value = -1
|
|
|
+ resolve(texture)
|
|
|
+ },
|
|
|
+ (progress) => {
|
|
|
+ console.log('贴图加载进度:', (progress.loaded / progress.total) * 100 + '%')
|
|
|
+ },
|
|
|
+ (error) => {
|
|
|
+ console.error('贴图加载失败:', error)
|
|
|
+ sphere.material.color = new THREE.Color(0x87ceeb)
|
|
|
+ sphere.material.needsUpdate = true
|
|
|
+
|
|
|
+ sceneData.hotspots.forEach((hotspotData, index) => {
|
|
|
+ const hotspot = createHotspot(hotspotData, index)
|
|
|
+ hotspots.push(hotspot)
|
|
|
+ scene.add(hotspot)
|
|
|
+
|
|
|
+ const axesHelper = createAxesHelper(hotspot, index)
|
|
|
+ const rotationHelper = createRotationHelper(hotspot, index)
|
|
|
+
|
|
|
+ axesHelpers.push(axesHelper)
|
|
|
+ rotationHelpers.push(rotationHelper)
|
|
|
+
|
|
|
+ scene.add(axesHelper)
|
|
|
+ scene.add(rotationHelper)
|
|
|
+ })
|
|
|
+
|
|
|
+ updateHelpersVisibility()
|
|
|
+ currentSceneIndex.value = sceneIndex
|
|
|
+ currentSceneId.value = sceneData.id
|
|
|
+ selectedHotspotIndex.value = -1
|
|
|
+ resolve(null)
|
|
|
+ },
|
|
|
+ )
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// 创建指引框框
|
|
|
+const createHotspot = (hotspotData: Hotspot, index: number) => {
|
|
|
+ const group = new THREE.Group()
|
|
|
+
|
|
|
+ const frameGeometry = new THREE.BoxGeometry(
|
|
|
+ hotspotData.width || 0.8,
|
|
|
+ hotspotData.height || 0.4,
|
|
|
+ 0.02,
|
|
|
+ )
|
|
|
+
|
|
|
+ const frameEdges = new THREE.EdgesGeometry(frameGeometry)
|
|
|
+ const frameMaterial = new THREE.LineBasicMaterial({
|
|
|
+ color: hotspotData.color || '#4f46e5',
|
|
|
+ linewidth: 3,
|
|
|
+ })
|
|
|
+ const frameLines = new THREE.LineSegments(frameEdges, frameMaterial)
|
|
|
+
|
|
|
+ const backgroundMaterial = new THREE.MeshBasicMaterial({
|
|
|
+ color: hotspotData.color || '#4f46e5',
|
|
|
+ transparent: true,
|
|
|
+ opacity: 0.8,
|
|
|
+ })
|
|
|
+ const background = new THREE.Mesh(frameGeometry, backgroundMaterial)
|
|
|
+
|
|
|
+ const canvas = document.createElement('canvas')
|
|
|
+ const context = canvas.getContext('2d')!
|
|
|
+ canvas.width = 256
|
|
|
+ canvas.height = 128
|
|
|
+
|
|
|
+ context.clearRect(0, 0, canvas.width, canvas.height)
|
|
|
+ context.fillStyle = '#000000'
|
|
|
+ context.font = 'bold 24px Arial'
|
|
|
+ context.textAlign = 'center'
|
|
|
+ context.textBaseline = 'middle'
|
|
|
+ context.fillText(hotspotData.label, canvas.width / 2, canvas.height / 2)
|
|
|
+
|
|
|
+ const texture = new THREE.CanvasTexture(canvas)
|
|
|
+
|
|
|
+ const textGeometry = new THREE.PlaneGeometry(hotspotData.width || 0.8, hotspotData.height || 0.4)
|
|
|
+ const textMaterial = new THREE.MeshBasicMaterial({
|
|
|
+ map: texture,
|
|
|
+ transparent: true,
|
|
|
+ side: THREE.DoubleSide,
|
|
|
+ })
|
|
|
+ const textMesh = new THREE.Mesh(textGeometry, textMaterial)
|
|
|
+ textMesh.position.z = 0.05
|
|
|
+
|
|
|
+ const clickAreaGeometry = new THREE.BoxGeometry(
|
|
|
+ (hotspotData.width || 0.8) * 0.7,
|
|
|
+ (hotspotData.height || 0.4) * 0.7,
|
|
|
+ 0.02,
|
|
|
+ )
|
|
|
+ const clickAreaMaterial = new THREE.MeshBasicMaterial({
|
|
|
+ transparent: true,
|
|
|
+ opacity: 0,
|
|
|
+ visible: false,
|
|
|
+ })
|
|
|
+ const clickArea = new THREE.Mesh(clickAreaGeometry, clickAreaMaterial)
|
|
|
+
|
|
|
+ group.add(background)
|
|
|
+ group.add(frameLines)
|
|
|
+ group.add(textMesh)
|
|
|
+ group.add(clickArea)
|
|
|
+
|
|
|
+ group.position.set(hotspotData.position.x, hotspotData.position.y, hotspotData.position.z)
|
|
|
+
|
|
|
+ group.lookAt(camera.position)
|
|
|
+
|
|
|
+ if (hotspotData.rotation) {
|
|
|
+ const currentRotation = group.rotation.clone()
|
|
|
+ const userRotation = new THREE.Euler(
|
|
|
+ THREE.MathUtils.degToRad(hotspotData.rotation.x || 0),
|
|
|
+ THREE.MathUtils.degToRad(hotspotData.rotation.y || 0),
|
|
|
+ THREE.MathUtils.degToRad(hotspotData.rotation.z || 0),
|
|
|
+ )
|
|
|
+
|
|
|
+ group.rotation.x = currentRotation.x + userRotation.x
|
|
|
+ group.rotation.y = currentRotation.y + userRotation.y
|
|
|
+ group.rotation.z = currentRotation.z + userRotation.z
|
|
|
+ }
|
|
|
+
|
|
|
+ group.userData = { ...hotspotData, index }
|
|
|
+ return group
|
|
|
+}
|
|
|
+
|
|
|
+// 在鼠标位置创建新指引
|
|
|
+const createHotspotAtMousePosition = (event: MouseEvent) => {
|
|
|
+ const worldPosition = screenToWorldPosition(event.clientX, event.clientY)
|
|
|
+
|
|
|
+ if (!worldPosition) {
|
|
|
+ ElMessage.error('无法确定指引位置')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const currentScene = getCurrentScene()
|
|
|
+ if (!currentScene) {
|
|
|
+ ElMessage.error('当前场景不存在')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 默认目标为第一个不同的房间
|
|
|
+ const defaultTargetRoomId =
|
|
|
+ scenes.value.find((s) => s.id !== currentScene.id)?.id || scenes.value[0]?.id || ''
|
|
|
+
|
|
|
+ // 生成随机颜色
|
|
|
+ const colors = ['#4f46e5', '#dc2626', '#059669', '#d97706', '#7c3aed', '#db2777']
|
|
|
+ const randomColor = colors[Math.floor(Math.random() * colors.length)]
|
|
|
+
|
|
|
+ const newHotspot: Hotspot = {
|
|
|
+ position: {
|
|
|
+ x: worldPosition.x,
|
|
|
+ y: worldPosition.y,
|
|
|
+ z: worldPosition.z,
|
|
|
+ },
|
|
|
+ rotation: { x: 0, y: 0, z: 0 },
|
|
|
+ label: `指引${currentScene.hotspots.length + 1}`,
|
|
|
+ targetRoomId: defaultTargetRoomId,
|
|
|
+ width: 0.8,
|
|
|
+ height: 0.4,
|
|
|
+ color: randomColor,
|
|
|
+ }
|
|
|
+
|
|
|
+ // 添加到当前场景
|
|
|
+ currentScene.hotspots.push(newHotspot)
|
|
|
+
|
|
|
+ // 重新加载场景以显示新指引
|
|
|
+ loadScene(currentSceneIndex.value).then(() => {
|
|
|
+ // 自动选中新创建的指引
|
|
|
+ const newIndex = currentScene.hotspots.length - 1
|
|
|
+ selectedHotspotIndex.value = newIndex
|
|
|
+ selectedHotspotConfig.value = { ...newHotspot }
|
|
|
+
|
|
|
+ // 退出添加模式
|
|
|
+ isAddingHotspotMode.value = false
|
|
|
+
|
|
|
+ ElMessage.success(`已在鼠标位置创建新指引: ${newHotspot.label}`)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// 拉伸穿梭动画函数
|
|
|
+const animateStretchTransition = (
|
|
|
+ hotspot: THREE.Group,
|
|
|
+ clickPosition: { x: number; y: number },
|
|
|
+ callback: () => void,
|
|
|
+) => {
|
|
|
+ if (isAnimating) return
|
|
|
+
|
|
|
+ isAnimating = true
|
|
|
+ const duration = 1000
|
|
|
+ const startTime = Date.now()
|
|
|
+
|
|
|
+ const centerX = clickPosition.x
|
|
|
+ const centerY = clickPosition.y
|
|
|
+
|
|
|
+ const stretchOverlay = document.createElement('div')
|
|
|
+ stretchOverlay.style.position = 'fixed'
|
|
|
+ stretchOverlay.style.top = '0'
|
|
|
+ stretchOverlay.style.left = '0'
|
|
|
+ stretchOverlay.style.width = '100%'
|
|
|
+ stretchOverlay.style.height = '100%'
|
|
|
+ stretchOverlay.style.pointerEvents = 'none'
|
|
|
+ stretchOverlay.style.zIndex = '1000'
|
|
|
+ stretchOverlay.style.overflow = 'hidden'
|
|
|
+
|
|
|
+ const stretchCircle = document.createElement('div')
|
|
|
+ stretchCircle.style.position = 'absolute'
|
|
|
+ stretchCircle.style.borderRadius = '50%'
|
|
|
+ stretchCircle.style.background = `radial-gradient(circle, transparent 0%, ${hotspot.userData.color}22 30%, ${hotspot.userData.color}66 70%, ${hotspot.userData.color} 100%)`
|
|
|
+ stretchCircle.style.transform = 'translate(-50%, -50%)'
|
|
|
+ stretchCircle.style.left = centerX + 'px'
|
|
|
+ stretchCircle.style.top = centerY + 'px'
|
|
|
+ stretchCircle.style.width = '0px'
|
|
|
+ stretchCircle.style.height = '0px'
|
|
|
+ stretchCircle.style.transition = 'none'
|
|
|
+
|
|
|
+ stretchOverlay.appendChild(stretchCircle)
|
|
|
+ document.body.appendChild(stretchOverlay)
|
|
|
+
|
|
|
+ const canvas = renderer.domElement
|
|
|
+ const originalTransform = canvas.style.transform
|
|
|
+ const canvasRect = canvas.getBoundingClientRect()
|
|
|
+
|
|
|
+ const animateStretch = () => {
|
|
|
+ const elapsed = Date.now() - startTime
|
|
|
+ const progress = Math.min(elapsed / duration, 1)
|
|
|
+
|
|
|
+ const easeInQuart = (t: number) => t * t * t * t
|
|
|
+ const easedProgress = easeInQuart(progress)
|
|
|
+
|
|
|
+ const maxRadius = Math.max(window.innerWidth, window.innerHeight) * 1.5
|
|
|
+ const currentRadius = easedProgress * maxRadius
|
|
|
+
|
|
|
+ stretchCircle.style.width = currentRadius + 'px'
|
|
|
+ stretchCircle.style.height = currentRadius + 'px'
|
|
|
+
|
|
|
+ if (progress < 0.7) {
|
|
|
+ const stretchIntensity = easedProgress * 0.3
|
|
|
+ const scaleX = 1 + stretchIntensity
|
|
|
+ const scaleY = 1 + stretchIntensity
|
|
|
+
|
|
|
+ const offsetX = ((centerX - canvasRect.left - canvasRect.width / 2) / canvasRect.width) * 100
|
|
|
+ const offsetY = ((centerY - canvasRect.top - canvasRect.height / 2) / canvasRect.height) * 100
|
|
|
+
|
|
|
+ canvas.style.transform = `scale(${scaleX}, ${scaleY})`
|
|
|
+ canvas.style.transformOrigin = `${50 + offsetX}% ${50 + offsetY}%`
|
|
|
+ } else {
|
|
|
+ const fadeProgress = (progress - 0.7) / 0.3
|
|
|
+ const finalScale = 1.3 + fadeProgress * 0.5
|
|
|
+
|
|
|
+ canvas.style.transform = `scale(${finalScale})`
|
|
|
+ canvas.style.opacity = (1 - fadeProgress * 0.8).toString()
|
|
|
+ }
|
|
|
+
|
|
|
+ if (progress < 1) {
|
|
|
+ requestAnimationFrame(animateStretch)
|
|
|
+ } else {
|
|
|
+ setTimeout(() => {
|
|
|
+ canvas.style.transform = originalTransform
|
|
|
+ canvas.style.transformOrigin = 'center'
|
|
|
+ canvas.style.opacity = '1'
|
|
|
+ document.body.removeChild(stretchOverlay)
|
|
|
+ isAnimating = false
|
|
|
+ callback()
|
|
|
+ }, 100)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ animateStretch()
|
|
|
+}
|
|
|
+
|
|
|
+// 更新选中的指引
|
|
|
+const updateSelectedHotspot = () => {
|
|
|
+ if (selectedHotspotIndex.value >= 0 && hotspots[selectedHotspotIndex.value]) {
|
|
|
+ const hotspot = hotspots[selectedHotspotIndex.value]
|
|
|
+ const config = selectedHotspotConfig.value
|
|
|
+
|
|
|
+ hotspot.position.set(config.position.x, config.position.y, config.position.z)
|
|
|
+ hotspot.lookAt(camera.position)
|
|
|
+
|
|
|
+ if (config.rotation) {
|
|
|
+ const currentRotation = hotspot.rotation.clone()
|
|
|
+ const userRotation = new THREE.Euler(
|
|
|
+ THREE.MathUtils.degToRad(config.rotation.x || 0),
|
|
|
+ THREE.MathUtils.degToRad(config.rotation.y || 0),
|
|
|
+ THREE.MathUtils.degToRad(config.rotation.z || 0),
|
|
|
+ )
|
|
|
+
|
|
|
+ hotspot.rotation.x = currentRotation.x + userRotation.x
|
|
|
+ hotspot.rotation.y = currentRotation.y + userRotation.y
|
|
|
+ hotspot.rotation.z = currentRotation.z + userRotation.z
|
|
|
+ }
|
|
|
+
|
|
|
+ hotspot.userData = { ...config, index: selectedHotspotIndex.value }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 鼠标点击事件
|
|
|
+const onMouseClick = (event: MouseEvent) => {
|
|
|
+ if (isAnimating) return
|
|
|
+
|
|
|
+ const rect = renderer.domElement.getBoundingClientRect()
|
|
|
+ mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
|
|
|
+ mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
|
|
|
+
|
|
|
+ raycaster.setFromCamera(mouse, camera)
|
|
|
+
|
|
|
+ const clickAreas = hotspots.map((hotspot) => hotspot.children[hotspot.children.length - 1])
|
|
|
+ const intersects = raycaster.intersectObjects(clickAreas)
|
|
|
+
|
|
|
+ if (intersects.length > 0) {
|
|
|
+ // 点击到了指引
|
|
|
+ const clickedObject = intersects[0].object
|
|
|
+ const hotspotGroup = clickedObject.parent
|
|
|
+
|
|
|
+ if (isEditMode.value && !isPreviewMode.value) {
|
|
|
+ // 编辑模式且非预览模式
|
|
|
+ if (isAddingHotspotMode.value) {
|
|
|
+ // 添加热点模式下,不处理热点点击
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const hotspotIndex = hotspotGroup.userData.index
|
|
|
+ selectedHotspotIndex.value = hotspotIndex
|
|
|
+ const currentScene = getCurrentScene()
|
|
|
+ if (currentScene) {
|
|
|
+ selectedHotspotConfig.value = { ...currentScene.hotspots[hotspotIndex] }
|
|
|
+ }
|
|
|
+ ElMessage.info(`已选中指引: ${hotspotGroup.userData.label}`)
|
|
|
+ } else {
|
|
|
+ // 预览模式或非编辑模式 - 执行跳转
|
|
|
+ const targetRoomId = hotspotGroup.userData.targetRoomId
|
|
|
+ const targetSceneIndex = findSceneIndexById(targetRoomId)
|
|
|
+
|
|
|
+ if (targetSceneIndex >= 0) {
|
|
|
+ const clickPosition = { x: event.clientX, y: event.clientY }
|
|
|
+ const targetScene = scenes.value[targetSceneIndex]
|
|
|
+
|
|
|
+ animateStretchTransition(hotspotGroup, clickPosition, () => {
|
|
|
+ ElMessage.success(`切换到${targetScene.name}`)
|
|
|
+ loadScene(targetSceneIndex)
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ ElMessage.error('目标场景不存在')
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else if (isEditMode.value && !isPreviewMode.value) {
|
|
|
+ // 编辑模式下点击空白处
|
|
|
+ if (isAddingHotspotMode.value) {
|
|
|
+ // 添加热点模式:在鼠标位置创建新指引
|
|
|
+ createHotspotAtMousePosition(event)
|
|
|
+ } else if (selectedHotspotIndex.value >= 0) {
|
|
|
+ // 如果有选中的指引,先取消选中
|
|
|
+ selectedHotspotIndex.value = -1
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 鼠标移动事件
|
|
|
+const onMouseMove = (event: MouseEvent) => {
|
|
|
+ if (isAnimating) return
|
|
|
+
|
|
|
+ const rect = renderer.domElement.getBoundingClientRect()
|
|
|
+ mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
|
|
|
+ mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
|
|
|
+
|
|
|
+ raycaster.setFromCamera(mouse, camera)
|
|
|
+
|
|
|
+ const clickAreas = hotspots.map((hotspot) => hotspot.children[hotspot.children.length - 1])
|
|
|
+ const intersects = raycaster.intersectObjects(clickAreas)
|
|
|
+
|
|
|
+ hotspots.forEach((hotspot, index) => {
|
|
|
+ const isSelected =
|
|
|
+ isEditMode.value && !isPreviewMode.value && index === selectedHotspotIndex.value
|
|
|
+ hotspot.scale.set(isSelected ? 1.2 : 1, isSelected ? 1.2 : 1, isSelected ? 1.2 : 1)
|
|
|
+
|
|
|
+ hotspot.children.forEach((child, childIndex) => {
|
|
|
+ if (childIndex === hotspot.children.length - 1) return
|
|
|
+
|
|
|
+ if (child.material) {
|
|
|
+ if (child.material.opacity !== undefined) {
|
|
|
+ child.material.opacity = isSelected ? 1 : 0.8
|
|
|
+ }
|
|
|
+ if (child.material.color && isSelected) {
|
|
|
+ child.material.color.setHex(0xffff00)
|
|
|
+ } else if (child.material.color) {
|
|
|
+ child.material.color.setHex(parseInt(hotspot.userData.color.replace('#', '0x')))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ if (intersects.length > 0) {
|
|
|
+ const clickedObject = intersects[0].object
|
|
|
+ const hotspotGroup = clickedObject.parent
|
|
|
+ const hotspotIndex = hotspotGroup.userData.index
|
|
|
+
|
|
|
+ if (!isEditMode.value || isPreviewMode.value || hotspotIndex !== selectedHotspotIndex.value) {
|
|
|
+ hotspotGroup.scale.set(1.1, 1.1, 1.1)
|
|
|
+ hotspotGroup.children.forEach((child, childIndex) => {
|
|
|
+ if (childIndex === hotspotGroup.children.length - 1) return
|
|
|
+
|
|
|
+ if (child.material && child.material.opacity !== undefined) {
|
|
|
+ child.material.opacity = 1
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ renderer.domElement.style.cursor = 'pointer'
|
|
|
+ } else {
|
|
|
+ // 根据模式设置不同的鼠标样式
|
|
|
+ if (isAddingHotspotMode.value) {
|
|
|
+ renderer.domElement.style.cursor = 'crosshair'
|
|
|
+ } else if (isEditMode.value && !isPreviewMode.value) {
|
|
|
+ renderer.domElement.style.cursor = selectedHotspotIndex.value >= 0 ? 'crosshair' : 'grab'
|
|
|
+ } else {
|
|
|
+ renderer.domElement.style.cursor = 'grab'
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 窗口大小调整
|
|
|
+const onWindowResize = () => {
|
|
|
+ if (!containerRef.value) return
|
|
|
+
|
|
|
+ camera.aspect = containerRef.value.clientWidth / containerRef.value.clientHeight
|
|
|
+ camera.updateProjectionMatrix()
|
|
|
+ renderer.setSize(containerRef.value.clientWidth, containerRef.value.clientHeight)
|
|
|
+}
|
|
|
+
|
|
|
+// 动画循环
|
|
|
+const animate = () => {
|
|
|
+ requestAnimationFrame(animate)
|
|
|
+ renderer.render(scene, camera)
|
|
|
+}
|
|
|
+
|
|
|
+// 切换场景(通过索引)
|
|
|
+const switchScene = async (sceneIndex: number) => {
|
|
|
+ if (sceneIndex !== currentSceneIndex.value && !isAnimating) {
|
|
|
+ loadingText.value = `正在切换到${scenes.value[sceneIndex].name}...`
|
|
|
+ isLoading.value = true
|
|
|
+ loadingProgress.value = 50
|
|
|
+
|
|
|
+ await loadScene(sceneIndex)
|
|
|
+
|
|
|
+ loadingProgress.value = 100
|
|
|
+ setTimeout(() => {
|
|
|
+ isLoading.value = false
|
|
|
+ ElMessage.success(`切换到${scenes.value[sceneIndex].name}`)
|
|
|
+ }, 300)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 切换场景(通过房间ID)
|
|
|
+const switchSceneById = async (roomId: string) => {
|
|
|
+ const sceneIndex = findSceneIndexById(roomId)
|
|
|
+ if (sceneIndex >= 0) {
|
|
|
+ await switchScene(sceneIndex)
|
|
|
+ } else {
|
|
|
+ ElMessage.error('场景不存在')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 切换编辑模式
|
|
|
+const toggleEditMode = () => {
|
|
|
+ if (isAnimating) return
|
|
|
+
|
|
|
+ isEditMode.value = !isEditMode.value
|
|
|
+ selectedHotspotIndex.value = -1
|
|
|
+
|
|
|
+ // 退出编辑模式时,同时退出预览模式和添加热点模式
|
|
|
+ if (!isEditMode.value) {
|
|
|
+ isPreviewMode.value = false
|
|
|
+ isAddingHotspotMode.value = false
|
|
|
+ }
|
|
|
+
|
|
|
+ if (isEditMode.value) {
|
|
|
+ ElMessage.info('已进入编辑模式,点击空白处创建指引,点击指引进行配置')
|
|
|
+ } else {
|
|
|
+ ElMessage.info('已退出编辑模式')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 新增:切换预览模式
|
|
|
+const togglePreviewMode = () => {
|
|
|
+ if (!isEditMode.value) return
|
|
|
+
|
|
|
+ isPreviewMode.value = !isPreviewMode.value
|
|
|
+
|
|
|
+ if (isPreviewMode.value) {
|
|
|
+ // 进入预览模式时退出添加热点模式
|
|
|
+ isAddingHotspotMode.value = false
|
|
|
+ selectedHotspotIndex.value = -1
|
|
|
+ ElMessage.info('已进入预览模式,编辑功能已隐藏')
|
|
|
+ } else {
|
|
|
+ ElMessage.info('已退出预览模式,编辑功能已恢复')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 新增:切换添加热点模式
|
|
|
+const toggleAddingHotspotMode = () => {
|
|
|
+ if (!isEditMode.value || isPreviewMode.value) return
|
|
|
+
|
|
|
+ isAddingHotspotMode.value = !isAddingHotspotMode.value
|
|
|
+}
|
|
|
+
|
|
|
+// 添加新指引(按钮方式)
|
|
|
+const addNewHotspot = () => {
|
|
|
+ const currentScene = getCurrentScene()
|
|
|
+ if (!currentScene) return
|
|
|
+
|
|
|
+ // 默认目标为第一个不同的房间
|
|
|
+ const defaultTargetRoomId =
|
|
|
+ scenes.value.find((s) => s.id !== currentScene.id)?.id || scenes.value[0]?.id || ''
|
|
|
+
|
|
|
+ const newHotspot: Hotspot = {
|
|
|
+ position: { x: 0, y: 0, z: 3 },
|
|
|
+ rotation: { x: 0, y: 0, z: 0 },
|
|
|
+ label: `指引${currentScene.hotspots.length + 1}`,
|
|
|
+ targetRoomId: defaultTargetRoomId,
|
|
|
+ width: 0.8,
|
|
|
+ height: 0.4,
|
|
|
+ color: '#4f46e5',
|
|
|
+ }
|
|
|
+
|
|
|
+ currentScene.hotspots.push(newHotspot)
|
|
|
+ loadScene(currentSceneIndex.value)
|
|
|
+ ElMessage.success('已添加新指引')
|
|
|
+}
|
|
|
+
|
|
|
+// 删除选中指引
|
|
|
+const deleteSelectedHotspot = () => {
|
|
|
+ if (selectedHotspotIndex.value >= 0) {
|
|
|
+ const currentScene = getCurrentScene()
|
|
|
+ if (currentScene) {
|
|
|
+ currentScene.hotspots.splice(selectedHotspotIndex.value, 1)
|
|
|
+ selectedHotspotIndex.value = -1
|
|
|
+ loadScene(currentSceneIndex.value)
|
|
|
+ ElMessage.success('已删除指引')
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 保存配置
|
|
|
+const saveConfiguration = async () => {
|
|
|
+ const v = toRaw(scenes.value)
|
|
|
+
|
|
|
+ const res = await clientPost<string, BaseResponse>(
|
|
|
+ '/aroom/updateBatch',
|
|
|
+ JSON.stringify(
|
|
|
+ v.map((item) => ({
|
|
|
+ id: item.id,
|
|
|
+ attribute: JSON.stringify(item.hotspots),
|
|
|
+ })),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ if (res.code !== 200) {
|
|
|
+ ElMessage.error(res.msg)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ ElMessage.success('保存成功')
|
|
|
+}
|
|
|
+
|
|
|
+const goBack = () => {
|
|
|
+ router.back()
|
|
|
+}
|
|
|
+
|
|
|
+// 组件挂载
|
|
|
+onMounted(() => {
|
|
|
+ initApp()
|
|
|
+})
|
|
|
+
|
|
|
+// 组件卸载
|
|
|
+onUnmounted(() => {
|
|
|
+ window.removeEventListener('resize', onWindowResize)
|
|
|
+ if (renderer) {
|
|
|
+ renderer.dispose()
|
|
|
+ }
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <div class="relative w-89vw h-90vh overflow-hidden bg-black flex">
|
|
|
+ <!-- 全屏Loading -->
|
|
|
+ <div
|
|
|
+ v-if="isLoading"
|
|
|
+ class="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50"
|
|
|
+ >
|
|
|
+ <div class="text-center">
|
|
|
+ <!-- Loading动画 -->
|
|
|
+ <div class="relative mb-8">
|
|
|
+ <div
|
|
|
+ class="w-20 h-20 border-4 border-blue-200 border-t-blue-600 rounded-full animate-spin mx-auto"
|
|
|
+ ></div>
|
|
|
+ <div class="absolute inset-0 flex items-center justify-center">
|
|
|
+ <span class="text-blue-600 font-bold text-sm">{{ Math.round(loadingProgress) }}%</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Loading文字 -->
|
|
|
+ <h2 class="text-white text-xl font-semibold mb-4">全景看房系统</h2>
|
|
|
+ <p class="text-gray-300 text-sm mb-6">{{ loadingText }}</p>
|
|
|
+
|
|
|
+ <!-- 进度条 -->
|
|
|
+ <div class="w-80 bg-gray-700 rounded-full h-2 mx-auto">
|
|
|
+ <div
|
|
|
+ class="bg-gradient-to-r from-blue-500 to-blue-600 h-2 rounded-full transition-all duration-300 ease-out"
|
|
|
+ :style="{ width: loadingProgress + '%' }"
|
|
|
+ ></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 进度百分比 -->
|
|
|
+ <p class="text-gray-400 text-xs mt-3">{{ Math.round(loadingProgress) }}% 完成</p>
|
|
|
+
|
|
|
+ <!-- Loading提示 -->
|
|
|
+ <div class="mt-8 flex items-center justify-center gap-2 text-gray-400 text-xs">
|
|
|
+ <div class="flex gap-1">
|
|
|
+ <div class="w-1 h-1 bg-blue-500 rounded-full animate-pulse"></div>
|
|
|
+ <div
|
|
|
+ class="w-1 h-1 bg-blue-500 rounded-full animate-pulse"
|
|
|
+ style="animation-delay: 0.2s"
|
|
|
+ ></div>
|
|
|
+ <div
|
|
|
+ class="w-1 h-1 bg-blue-500 rounded-full animate-pulse"
|
|
|
+ style="animation-delay: 0.4s"
|
|
|
+ ></div>
|
|
|
+ </div>
|
|
|
+ <span>正在加载中,请稍候...</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 错误状态页面 -->
|
|
|
+ <div
|
|
|
+ v-if="hasError && !isLoading"
|
|
|
+ class="fixed inset-0 bg-gray-100 flex items-center justify-center z-50"
|
|
|
+ >
|
|
|
+ <div class="text-center max-w-md mx-4">
|
|
|
+ <!-- 错误图标 -->
|
|
|
+ <div
|
|
|
+ class="w-24 h-24 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-6"
|
|
|
+ >
|
|
|
+ <svg
|
|
|
+ class="w-12 h-12 text-gray-400"
|
|
|
+ fill="none"
|
|
|
+ stroke="currentColor"
|
|
|
+ viewBox="0 0 24 24"
|
|
|
+ >
|
|
|
+ <path
|
|
|
+ stroke-linecap="round"
|
|
|
+ stroke-linejoin="round"
|
|
|
+ stroke-width="2"
|
|
|
+ d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6-4h6m2 5.291A7.962 7.962 0 0112 15c-2.34 0-4.29-1.009-5.824-2.562M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 14.142M5.636 5.636a9 9 0 000 14.142"
|
|
|
+ ></path>
|
|
|
+ </svg>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 错误标题 -->
|
|
|
+ <h2 class="text-2xl font-bold text-gray-800 mb-4">数据获取失败</h2>
|
|
|
+
|
|
|
+ <!-- 错误信息 -->
|
|
|
+ <p class="text-gray-600 mb-8 leading-relaxed">{{ errorMessage }}</p>
|
|
|
+
|
|
|
+ <!-- 返回按钮 -->
|
|
|
+ <button
|
|
|
+ @click="goBack"
|
|
|
+ class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-8 rounded-lg transition-colors duration-200 flex items-center justify-center gap-2 mx-auto"
|
|
|
+ >
|
|
|
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
+ <path
|
|
|
+ stroke-linecap="round"
|
|
|
+ stroke-linejoin="round"
|
|
|
+ stroke-width="2"
|
|
|
+ d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
|
|
+ ></path>
|
|
|
+ </svg>
|
|
|
+ 返回上一级
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 3D场景容器 -->
|
|
|
+ <div
|
|
|
+ ref="containerRef"
|
|
|
+ :class="isEditMode && !isPreviewMode ? 'w-4/5' : 'w-full'"
|
|
|
+ class="h-full transition-all duration-300"
|
|
|
+ ></div>
|
|
|
+
|
|
|
+ <!-- 左侧控制面板 -->
|
|
|
+ <div
|
|
|
+ class="absolute top-5 left-5 bg-white/90 text-black p-5 rounded-lg backdrop-blur-md max-w-75 z-10 border border-gray-200"
|
|
|
+ >
|
|
|
+ <div class="mb-4">
|
|
|
+ <h3 class="m-0 mb-2.5 text-lg text-black font-semibold">
|
|
|
+ {{ scenes[currentSceneIndex]?.name || '加载中...' }}
|
|
|
+ <span v-if="hasError" class="text-xs bg-orange-100 text-orange-600 px-2 py-1 rounded ml-2"
|
|
|
+ >演示模式</span
|
|
|
+ >
|
|
|
+ <span
|
|
|
+ v-if="isPreviewMode"
|
|
|
+ class="text-xs bg-green-100 text-green-600 px-2 py-1 rounded ml-2"
|
|
|
+ >预览模式</span
|
|
|
+ >
|
|
|
+ <span
|
|
|
+ v-if="isAddingHotspotMode"
|
|
|
+ class="text-xs bg-purple-100 text-purple-600 px-2 py-1 rounded ml-2"
|
|
|
+ >添加模式</span
|
|
|
+ >
|
|
|
+ </h3>
|
|
|
+ <p class="m-0 mb-4 text-xs text-gray-600 leading-relaxed">
|
|
|
+ {{
|
|
|
+ isPreviewMode
|
|
|
+ ? '预览模式:点击指引拉伸穿梭,编辑功能已隐藏'
|
|
|
+ : isAddingHotspotMode
|
|
|
+ ? '添加模式:点击场景位置添加热点'
|
|
|
+ : isEditMode
|
|
|
+ ? '编辑模式:点击空白处创建指引,点击指引配置'
|
|
|
+ : '预览模式:点击指引拉伸穿梭'
|
|
|
+ }}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="flex gap-2 flex-wrap mb-3">
|
|
|
+ <button
|
|
|
+ v-for="(scene, index) in scenes"
|
|
|
+ :key="scene.id"
|
|
|
+ @click="switchScene(index)"
|
|
|
+ :disabled="(isEditMode && !isPreviewMode) || isAnimating"
|
|
|
+ :class="[
|
|
|
+ 'px-3 py-1.5 text-sm rounded border transition-all duration-200',
|
|
|
+ index === currentSceneIndex
|
|
|
+ ? 'bg-blue-600 text-white border-blue-600'
|
|
|
+ : 'bg-transparent text-black border-black/30 hover:border-black/60 hover:bg-black/10',
|
|
|
+ (isEditMode && !isPreviewMode) || isAnimating
|
|
|
+ ? 'opacity-50 cursor-not-allowed'
|
|
|
+ : 'cursor-pointer',
|
|
|
+ ]"
|
|
|
+ >
|
|
|
+ {{ scene.name }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="flex gap-2 flex-wrap">
|
|
|
+ <button
|
|
|
+ @click="toggleEditMode"
|
|
|
+ :disabled="isAnimating"
|
|
|
+ :class="[
|
|
|
+ 'px-3 py-1.5 text-sm rounded border transition-all duration-200',
|
|
|
+ isEditMode
|
|
|
+ ? 'bg-orange-600 text-white border-orange-600'
|
|
|
+ : 'bg-transparent text-black border-black/30 hover:border-black/60 hover:bg-black/10',
|
|
|
+ isAnimating ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
|
|
+ ]"
|
|
|
+ >
|
|
|
+ {{ isEditMode ? '退出编辑' : '进入编辑' }}
|
|
|
+ </button>
|
|
|
+
|
|
|
+ <!-- 预览模式按钮 - 只在编辑模式下显示 -->
|
|
|
+ <button
|
|
|
+ v-if="isEditMode"
|
|
|
+ @click="togglePreviewMode"
|
|
|
+ :disabled="isAnimating"
|
|
|
+ :class="[
|
|
|
+ 'px-3 py-1.5 text-sm rounded border transition-all duration-200',
|
|
|
+ isPreviewMode
|
|
|
+ ? 'bg-green-600 text-white border-green-600'
|
|
|
+ : 'bg-transparent text-black border-black/30 hover:border-black/60 hover:bg-black/10',
|
|
|
+ isAnimating ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
|
|
+ ]"
|
|
|
+ >
|
|
|
+ {{ isPreviewMode ? '退出预览' : '进入预览' }}
|
|
|
+ </button>
|
|
|
+
|
|
|
+ <!-- 保存配置按钮 - 预览模式下隐藏 -->
|
|
|
+ <button
|
|
|
+ v-if="!isPreviewMode"
|
|
|
+ @click="saveConfiguration"
|
|
|
+ :disabled="isAnimating"
|
|
|
+ :class="[
|
|
|
+ 'px-3 py-1.5 text-sm rounded border transition-all duration-200',
|
|
|
+ 'bg-green-600 text-white border-green-600 hover:bg-green-700',
|
|
|
+ isAnimating ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
|
|
+ ]"
|
|
|
+ >
|
|
|
+ 保存配置
|
|
|
+ </button>
|
|
|
+
|
|
|
+ <!-- 点击添加热点按钮 - 只在编辑模式且非预览模式下显示 -->
|
|
|
+ <button
|
|
|
+ v-if="isEditMode && !isPreviewMode"
|
|
|
+ @click="toggleAddingHotspotMode"
|
|
|
+ :disabled="isAnimating"
|
|
|
+ :class="[
|
|
|
+ 'px-3 py-1.5 text-sm rounded border transition-all duration-200',
|
|
|
+ isAddingHotspotMode
|
|
|
+ ? 'bg-purple-600 text-white border-purple-600'
|
|
|
+ : 'bg-transparent text-black border-black/30 hover:border-black/60 hover:bg-black/10',
|
|
|
+ isAnimating ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
|
|
+ ]"
|
|
|
+ >
|
|
|
+ {{ isAddingHotspotMode ? '退出添加' : '点击添加' }}
|
|
|
+ </button>
|
|
|
+
|
|
|
+ <!-- 传统添加指引按钮 - 只在编辑模式且非预览模式且非添加模式下显示 -->
|
|
|
+ <button
|
|
|
+ v-if="isEditMode && !isPreviewMode && !isAddingHotspotMode"
|
|
|
+ @click="addNewHotspot"
|
|
|
+ :disabled="isAnimating"
|
|
|
+ :class="[
|
|
|
+ 'px-3 py-1.5 text-sm rounded border transition-all duration-200',
|
|
|
+ 'bg-transparent text-black border-black/30 hover:border-black/60 hover:bg-black/10',
|
|
|
+ isAnimating ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
|
|
+ ]"
|
|
|
+ >
|
|
|
+ 添加指引
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 右侧配置面板 - 预览模式下隐藏 -->
|
|
|
+ <div
|
|
|
+ v-if="isEditMode && !isPreviewMode"
|
|
|
+ class="w-1/5 bg-gray-900 text-white p-5 overflow-y-auto transition-all duration-300"
|
|
|
+ >
|
|
|
+ <h3 class="text-lg font-bold mb-4 text-white">指引配置</h3>
|
|
|
+
|
|
|
+ <!-- 编辑模式说明 -->
|
|
|
+ <div class="bg-gray-800 p-3 rounded-lg mb-4">
|
|
|
+ <p class="text-xs text-gray-300 mb-2">编辑模式操作:</p>
|
|
|
+ <div class="space-y-1 text-xs">
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
|
|
+ <span class="text-gray-400">点击空白处创建指引</span>
|
|
|
+ </div>
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <div class="w-2 h-2 bg-blue-500 rounded-full"></div>
|
|
|
+ <span class="text-gray-400">点击指引进行配置</span>
|
|
|
+ </div>
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <div class="w-2 h-2 bg-purple-500 rounded-full"></div>
|
|
|
+ <span class="text-gray-400">点击添加模式精确定位</span>
|
|
|
+ </div>
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <div class="w-2 h-2 bg-yellow-500 rounded-full"></div>
|
|
|
+ <span class="text-gray-400">显示辅助线和坐标轴</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 辅助线说明 -->
|
|
|
+ <div class="bg-gray-800 p-3 rounded-lg mb-4">
|
|
|
+ <p class="text-xs text-gray-300 mb-2">辅助线说明:</p>
|
|
|
+ <div class="space-y-1 text-xs">
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <div class="w-3 h-1 bg-red-500"></div>
|
|
|
+ <span class="text-gray-400">X轴 (红色)</span>
|
|
|
+ </div>
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <div class="w-3 h-1 bg-green-500"></div>
|
|
|
+ <span class="text-gray-400">Y轴 (绿色)</span>
|
|
|
+ </div>
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <div class="w-3 h-1 bg-blue-500"></div>
|
|
|
+ <span class="text-gray-400">Z轴 (蓝色)</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="selectedHotspotIndex >= 0" class="space-y-4">
|
|
|
+ <div class="bg-gray-800 p-3 rounded-lg mb-4">
|
|
|
+ <p class="text-sm text-gray-300">已选中指引 #{{ selectedHotspotIndex + 1 }}</p>
|
|
|
+ <p class="text-xs text-gray-500 mt-1">
|
|
|
+ 位置: ({{ selectedHotspotConfig.position.x.toFixed(2) }},
|
|
|
+ {{ selectedHotspotConfig.position.y.toFixed(2) }},
|
|
|
+ {{ selectedHotspotConfig.position.z.toFixed(2) }})
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <ElForm :model="selectedHotspotConfig" label-width="60px" size="small">
|
|
|
+ <ElFormItem label="标签">
|
|
|
+ <ElInput v-model="selectedHotspotConfig.label" placeholder="指引标签" />
|
|
|
+ </ElFormItem>
|
|
|
+
|
|
|
+ <ElFormItem label="目标房间">
|
|
|
+ <ElSelect
|
|
|
+ v-model="selectedHotspotConfig.targetRoomId"
|
|
|
+ placeholder="选择目标房间"
|
|
|
+ class="w-full"
|
|
|
+ >
|
|
|
+ <ElOption
|
|
|
+ v-for="scene in scenes"
|
|
|
+ :key="scene.id"
|
|
|
+ :label="`${scene.name} (${scene.id})`"
|
|
|
+ :value="scene.id"
|
|
|
+ />
|
|
|
+ </ElSelect>
|
|
|
+ </ElFormItem>
|
|
|
+
|
|
|
+ <div class="border-t border-gray-700 pt-3 mt-3">
|
|
|
+ <p class="text-sm text-gray-400 mb-3">位置设置</p>
|
|
|
+
|
|
|
+ <ElFormItem label="位置X">
|
|
|
+ <div class="w-full">
|
|
|
+ <ElSlider
|
|
|
+ v-model="selectedHotspotConfig.position.x"
|
|
|
+ :min="-5"
|
|
|
+ :max="5"
|
|
|
+ :step="0.1"
|
|
|
+ show-input
|
|
|
+ :show-input-controls="false"
|
|
|
+ input-size="small"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </ElFormItem>
|
|
|
+
|
|
|
+ <ElFormItem label="位置Y">
|
|
|
+ <div class="w-full">
|
|
|
+ <ElSlider
|
|
|
+ v-model="selectedHotspotConfig.position.y"
|
|
|
+ :min="-5"
|
|
|
+ :max="5"
|
|
|
+ :step="0.1"
|
|
|
+ show-input
|
|
|
+ :show-input-controls="false"
|
|
|
+ input-size="small"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </ElFormItem>
|
|
|
+
|
|
|
+ <ElFormItem label="位置Z">
|
|
|
+ <div class="w-full">
|
|
|
+ <ElSlider
|
|
|
+ v-model="selectedHotspotConfig.position.z"
|
|
|
+ :min="-5"
|
|
|
+ :max="5"
|
|
|
+ :step="0.1"
|
|
|
+ show-input
|
|
|
+ :show-input-controls="false"
|
|
|
+ input-size="small"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </ElFormItem>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="border-t border-gray-700 pt-3 mt-3">
|
|
|
+ <p class="text-sm text-gray-400 mb-3">旋转微调 (度)</p>
|
|
|
+ <p class="text-xs text-gray-500 mb-2">基于面向摄像机的角度进行微调</p>
|
|
|
+
|
|
|
+ <ElFormItem label="旋转X">
|
|
|
+ <div class="w-full">
|
|
|
+ <ElSlider
|
|
|
+ v-model="selectedHotspotConfig.rotation.x"
|
|
|
+ :min="-45"
|
|
|
+ :max="45"
|
|
|
+ :step="5"
|
|
|
+ show-input
|
|
|
+ :show-input-controls="false"
|
|
|
+ input-size="small"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </ElFormItem>
|
|
|
+
|
|
|
+ <ElFormItem label="旋转Y">
|
|
|
+ <div class="w-full">
|
|
|
+ <ElSlider
|
|
|
+ v-model="selectedHotspotConfig.rotation.y"
|
|
|
+ :min="-45"
|
|
|
+ :max="45"
|
|
|
+ :step="5"
|
|
|
+ show-input
|
|
|
+ :show-input-controls="false"
|
|
|
+ input-size="small"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </ElFormItem>
|
|
|
+
|
|
|
+ <ElFormItem label="旋转Z">
|
|
|
+ <div class="w-full">
|
|
|
+ <ElSlider
|
|
|
+ v-model="selectedHotspotConfig.rotation.z"
|
|
|
+ :min="-45"
|
|
|
+ :max="45"
|
|
|
+ :step="5"
|
|
|
+ show-input
|
|
|
+ :show-input-controls="false"
|
|
|
+ input-size="small"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </ElFormItem>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="border-t border-gray-700 pt-3 mt-3">
|
|
|
+ <p class="text-sm text-gray-400 mb-3">尺寸设置</p>
|
|
|
+
|
|
|
+ <ElFormItem label="宽度">
|
|
|
+ <div class="w-full">
|
|
|
+ <ElSlider
|
|
|
+ v-model="selectedHotspotConfig.width"
|
|
|
+ :min="0.2"
|
|
|
+ :max="2"
|
|
|
+ :step="0.1"
|
|
|
+ show-input
|
|
|
+ :show-input-controls="false"
|
|
|
+ input-size="small"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </ElFormItem>
|
|
|
+
|
|
|
+ <ElFormItem label="高度">
|
|
|
+ <div class="w-full">
|
|
|
+ <ElSlider
|
|
|
+ v-model="selectedHotspotConfig.height"
|
|
|
+ :min="0.2"
|
|
|
+ :max="2"
|
|
|
+ :step="0.1"
|
|
|
+ show-input
|
|
|
+ :show-input-controls="false"
|
|
|
+ input-size="small"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </ElFormItem>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <ElFormItem label="背景色">
|
|
|
+ <ElColorPicker v-model="selectedHotspotConfig.color" />
|
|
|
+ <p class="text-xs text-gray-500 mt-1">背景色,文字保持黑色</p>
|
|
|
+ </ElFormItem>
|
|
|
+ </ElForm>
|
|
|
+
|
|
|
+ <button
|
|
|
+ @click="deleteSelectedHotspot"
|
|
|
+ :disabled="isAnimating"
|
|
|
+ :class="[
|
|
|
+ 'w-full px-3 py-2 text-sm rounded border transition-all duration-200',
|
|
|
+ 'bg-transparent text-red-400 border-red-400/50 hover:border-red-400 hover:bg-red-400/10',
|
|
|
+ isAnimating ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
|
|
+ ]"
|
|
|
+ >
|
|
|
+ 删除指引
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else class="text-center text-gray-400 mt-8">
|
|
|
+ <div class="mb-4">
|
|
|
+ <svg
|
|
|
+ class="w-16 h-16 mx-auto text-gray-600"
|
|
|
+ fill="none"
|
|
|
+ stroke="currentColor"
|
|
|
+ viewBox="0 0 24 24"
|
|
|
+ >
|
|
|
+ <path
|
|
|
+ stroke-linecap="round"
|
|
|
+ stroke-linejoin="round"
|
|
|
+ stroke-width="1"
|
|
|
+ d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
|
|
+ ></path>
|
|
|
+ </svg>
|
|
|
+ </div>
|
|
|
+ <p class="text-sm">{{ isAddingHotspotMode ? '点击场景位置' : '点击场景中的空白处' }}</p>
|
|
|
+ <p class="text-sm">{{ isAddingHotspotMode ? '添加新热点' : '创建新指引' }}</p>
|
|
|
+ <p class="text-xs mt-2 text-gray-500">或点击现有指引进行配置</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 当前场景指引列表 -->
|
|
|
+ <div class="mt-6" v-if="scenes[currentSceneIndex]">
|
|
|
+ <h4 class="text-md font-semibold mb-3 text-gray-300">
|
|
|
+ 当前场景指引 ({{ scenes[currentSceneIndex].hotspots.length }})
|
|
|
+ </h4>
|
|
|
+ <div class="space-y-2">
|
|
|
+ <div
|
|
|
+ v-for="(hotspot, index) in scenes[currentSceneIndex].hotspots"
|
|
|
+ :key="index"
|
|
|
+ :class="[
|
|
|
+ 'p-2 rounded cursor-pointer transition-colors border',
|
|
|
+ index === selectedHotspotIndex
|
|
|
+ ? 'bg-blue-600/20 border-blue-600/50'
|
|
|
+ : 'bg-gray-700/50 border-gray-600/50 hover:bg-gray-600/50 hover:border-gray-500/50',
|
|
|
+ ]"
|
|
|
+ @click="
|
|
|
+ selectedHotspotIndex = index
|
|
|
+ selectedHotspotConfig = { ...hotspot }
|
|
|
+ "
|
|
|
+ >
|
|
|
+ <p class="text-sm font-medium text-white">{{ hotspot.label }}</p>
|
|
|
+ <p class="text-xs text-gray-400">
|
|
|
+ → {{ findSceneById(hotspot.targetRoomId)?.name || '未知房间' }}
|
|
|
+ </p>
|
|
|
+ <p class="text-xs text-gray-500">目标ID: {{ hotspot.targetRoomId }}</p>
|
|
|
+ <div class="flex items-center gap-2 mt-1">
|
|
|
+ <div class="w-3 h-3 rounded" :style="{ backgroundColor: hotspot.color }"></div>
|
|
|
+ <p class="text-xs text-gray-500">
|
|
|
+ 位置: ({{ hotspot.position.x.toFixed(1) }}, {{ hotspot.position.y.toFixed(1) }},
|
|
|
+ {{ hotspot.position.z.toFixed(1) }})
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 底部提示 -->
|
|
|
+ <div
|
|
|
+ v-if="!isEditMode || isPreviewMode"
|
|
|
+ class="absolute bottom-5 right-5 bg-white/90 text-black p-4 rounded-lg backdrop-blur-md border border-gray-200"
|
|
|
+ >
|
|
|
+ <div class="flex items-center gap-2.5 text-xs">
|
|
|
+ <div class="w-3 h-3 bg-indigo-500 rounded animate-pulse"></div>
|
|
|
+ <span>点击指引进入</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 添加热点模式提示 -->
|
|
|
+ <div
|
|
|
+ v-if="isAddingHotspotMode"
|
|
|
+ class="absolute bottom-5 right-5 bg-purple-100 text-purple-800 p-4 rounded-lg backdrop-blur-md border border-purple-200"
|
|
|
+ >
|
|
|
+ <div class="flex items-center gap-2.5 text-xs">
|
|
|
+ <div class="w-3 h-3 bg-purple-500 rounded animate-pulse"></div>
|
|
|
+ <span>点击场景位置添加热点</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|