Преглед на файлове

fix: 修改文件名大小写

nahida преди 10 месеца
родител
ревизия
485384749b
променени са 2 файла, в които са добавени 2331 реда и са изтрити 1010 реда
  1. 602 1010
      src/views/ar/edit.vue
  2. 1729 0
      src/views/ar/preview.vue

+ 602 - 1010
src/views/ar/edit.vue

@@ -1,1129 +1,721 @@
 <script setup lang="ts">
-import { ref, onMounted, onUnmounted, watch } from 'vue'
-import * as THREE from 'three'
-import { ElMessage, ElForm, ElFormItem, ElInput, ElSlider, ElSelect, ElOption, ElColorPicker } from 'element-plus'
-
-// 场景相关变量
-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 isAnimating = false
-
-// 加载状态管理
-const isLoading = ref(true)
-const loadingText = ref('正在初始化...')
-const loadingProgress = ref(0)
-
-// 编辑模式状态
-const isEditMode = ref(false)
-const selectedHotspotIndex = ref(-1)
-
-// 当前场景索引
-const currentSceneIndex = ref(0)
-
-// 选中的热点配置
-const selectedHotspotConfig = ref({
-  position: { x: 0, y: 0, z: 3 },
-  rotation: { x: 0, y: 0, z: 0 },
-  label: '',
-  targetScene: 0,
-  width: 0.8,
-  height: 0.4,
-  color: '#4f46e5'
-})
-
-// 全景场景数据 - 现在从远程获取
-const scenes = ref([])
-
-// 模拟远程数据获取
-const fetchScenesData = async () => {
-  loadingText.value = '正在获取场景数据...'
-  loadingProgress.value = 20
-
-  // 模拟网络延迟
-  await new Promise(resolve => setTimeout(resolve, 1000))
-
-  // 模拟远程数据
-  const remoteData = [
-    {
-      name: '客厅',
-      texture: '/img1.jpg',
-      hotspots: [
-        {
-          position: { x: -2, y: 0, z: 3 },
-          rotation: { x: 0, y: 0, z: 0 },
-          label: '卧室',
-          targetScene: 1,
-          width: 0.8,
-          height: 0.4,
-          color: '#4f46e5'
-        },
-        {
-          position: { x: 3, y: -1, z: 2 },
-          rotation: { x: 0, y: 15, z: 0 },
-          label: '厨房',
-          targetScene: 2,
-          width: 0.8,
-          height: 0.4,
-          color: '#059669'
-        }
-      ]
-    },
-    {
-      name: '卧室',
-      texture: '/img2.jpg',
-      hotspots: [
-        {
-          position: { x: 2, y: 0, z: -3 },
-          rotation: { x: 0, y: 0, z: 0 },
-          label: '客厅',
-          targetScene: 0,
-          width: 0.8,
-          height: 0.4,
-          color: '#dc2626'
-        },
-        {
-          position: { x: -1, y: 1, z: 4 },
-          rotation: { x: 10, y: 0, z: -5 },
-          label: '阳台',
-          targetScene: 2,
-          width: 0.8,
-          height: 0.4,
-          color: '#7c3aed'
-        }
-      ]
-    },
-    {
-      name: '厨房/阳台',
-      texture: '/img3.jpg',
-      hotspots: [
-        {
-          position: { x: 0, y: 0, z: -4 },
-          rotation: { x: 0, y: 0, z: 0 },
-          label: '客厅',
-          targetScene: 0,
-          width: 0.8,
-          height: 0.4,
-          color: '#ea580c'
-        },
-        {
-          position: { x: -3, y: 1, z: 1 },
-          rotation: { x: -10, y: 20, z: 0 },
-          label: '卧室',
-          targetScene: 1,
-          width: 0.8,
-          height: 0.4,
-          color: '#0891b2'
-        }
-      ]
-    }
-  ]
-
-  loadingProgress.value = 50
-  scenes.value = remoteData
-
-  console.log('场景数据获取完成:', remoteData)
+import { clientGet, clientPost, clientPostFormData } from '@/utils/request.ts'
+import { computed, onMounted, reactive, ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import {
+  Delete,
+  Edit,
+  HomeFilled,
+  MapLocation,
+  Platform,
+  Plus,
+  Search,
+  Setting,
+  View,
+} from '@element-plus/icons-vue'
+import { useRouter } from 'vue-router'
+
+const MINIO_BASE_URL = import.meta.env.VITE_MINIO_BASE_URL
+
+interface HouseType {
+  id: string
+  accountNumber?: string
+  address?: string
+  area?: string
+  accountUrl?: string
+  createTime?: Date
+  createBy?: string
+  updateTime?: Date
+  updateBy?: string
 }
 
-// 模拟其他配置数据获取
-const fetchConfigData = async () => {
-  loadingText.value = '正在获取配置数据...'
-  loadingProgress.value = 60
+interface PageType<T> {
+  records: T[]
+  total: number
+  size: number
+  current: number
+  pages: number
+}
 
-  // 模拟网络延迟
-  await new Promise(resolve => setTimeout(resolve, 800))
+interface BaseResponse {
+  code: number
+  msg: string
+  data?: any
+}
 
-  // 这里可以获取其他配置数据
-  console.log('配置数据获取完成')
-  loadingProgress.value = 70
+interface HouseTypeResponse extends BaseResponse {
+  data: HouseType[]
 }
 
-// 监听选中热点配置的变化,实时更新
-watch(selectedHotspotConfig, (newConfig) => {
-  if (selectedHotspotIndex.value >= 0 && isEditMode.value) {
-    // 更新场景数据
-    scenes.value[currentSceneIndex.value].hotspots[selectedHotspotIndex.value] = { ...newConfig }
-    // 重新创建热点
-    updateSelectedHotspot()
-  }
-}, { deep: true })
+interface Room {
+  id?: string // 主键
+  houseTypeId: string // 户型主键
+  roomNumber: string // 房间
+  roomPictureUrl?: string // 房间图片url
+  attribute?: string // 按钮属性
+  createTime?: Date // 创建时间
+  createBy?: string // 创建人
+  updateTime?: Date // 修改时间
+  updateBy?: string // 修改人
+}
 
-// 初始化Three.js场景
-const initThreeJS = async () => {
-  if (!containerRef.value) return
+interface RoomListResponse extends BaseResponse {
+  data: Room[]
+}
 
-  loadingText.value = '正在初始化3D场景...'
-  loadingProgress.value = 75
+interface HouseTypeOneResponse extends BaseResponse {
+  data: HouseType
+}
 
-  // 创建场景
-  scene = new THREE.Scene()
+interface HouseTypeRequest {
+  pageNum: number
+  pageSize: number
+  accountNumber?: string // 添加可选的搜索字段
+}
 
-  // 创建摄像机
-  camera = new THREE.PerspectiveCamera(
-    75,
-    containerRef.value.clientWidth / containerRef.value.clientHeight,
-    0.1,
-    1000
-  )
-  camera.position.set(0, 0, 0)
+interface AddAndUpdateHouseType {
+  area: string
+  address: string
+  accountNumber: string
+  multipartFile: File
+}
 
-  // 创建渲染器
-  renderer = new THREE.WebGLRenderer({ antialias: true })
-  renderer.setSize(containerRef.value.clientWidth, containerRef.value.clientHeight)
-  renderer.setPixelRatio(window.devicePixelRatio)
-  containerRef.value.appendChild(renderer.domElement)
+interface HouseTypePageResponse extends BaseResponse {
+  data: PageType<HouseType>
+}
 
-  loadingProgress.value = 80
+// 响应式数据
+const houseTypeList = ref<HouseType[]>([])
+const loading = ref(false)
+const dialogVisible = ref(false)
+const dialogTitle = ref('新增全景图')
+const currentEditId = ref('')
+const searchKeyword = ref('')
+const selectedIds = ref<string[]>([])
+const previewVisible = ref(false)
+const previewImageUrl = ref('')
+const localPreviewUrl = ref('') // 本地预览URL
+const currentHouseType = ref<HouseType | null>(null) // 当前编辑的户型数据
+const router = useRouter()
+
+// 分页数据
+const pagination = reactive({
+  pageNum: 1,
+  pageSize: 12,
+  total: 0,
+})
 
-  // 创建球体几何体
-  const geometry = new THREE.SphereGeometry(5, 60, 40)
-  // 翻转球体内表面
-  geometry.scale(-1, 1, 1)
+// 表单数据
+const formData = reactive<AddAndUpdateHouseType>({
+  area: '',
+  address: '',
+  accountNumber: '',
+  multipartFile: null as any,
+})
 
-  // 创建材质
-  const material = new THREE.MeshBasicMaterial()
-  sphere = new THREE.Mesh(geometry, material)
-  scene.add(sphere)
+// 动态表单规则 - 编辑时为选填,新增时为必填
+const formRules = computed(() => {
+  const isEdit = !!currentEditId.value
 
-  loadingProgress.value = 85
+  return {
+    accountNumber: isEdit ? [] : [{ required: true, message: '请输入户号', trigger: 'blur' }],
+    address: isEdit ? [] : [{ required: true, message: '请输入位置', trigger: 'blur' }],
+    area: isEdit ? [] : [{ required: true, message: '请输入面积', trigger: 'blur' }],
+  }
+})
 
-  // 初始化轨道控制器
-  initControls()
+const formRef = ref()
+const uploadRef = ref()
 
-  // 初始化射线投射器
-  raycaster = new THREE.Raycaster()
-  mouse = new THREE.Vector2()
+// 分页获取数据
+const getList = async () => {
+  loading.value = true
+  try {
+    const params: HouseTypeRequest = {
+      pageNum: pagination.pageNum,
+      pageSize: pagination.pageSize,
+    }
 
-  loadingProgress.value = 90
+    // 如果有搜索关键词,添加到请求参数中
+    if (searchKeyword.value.trim()) {
+      params.accountNumber = searchKeyword.value.trim()
+    }
 
-  // 加载初始场景
-  await loadScene(currentSceneIndex.value)
+    const res = await clientGet<HouseTypeRequest, HouseTypePageResponse>('/houseType/findByPage', {
+      params,
+    })
 
-  loadingProgress.value = 95
+    if (res.code !== 200) {
+      ElMessage.error(res.msg)
+      return
+    }
 
-  // 添加事件监听
-  window.addEventListener('resize', onWindowResize)
-  renderer.domElement.addEventListener('click', onMouseClick)
-  renderer.domElement.addEventListener('mousemove', onMouseMove)
+    // 修改数据获取方式
+    houseTypeList.value = res.data.records || []
+    pagination.total = res.data.total || 0
+  } catch (error) {
+    ElMessage.error('获取数据失败')
+  } finally {
+    loading.value = false
+  }
+}
 
-  // 开始渲染循环
-  animate()
+// 批量删除
+const delBatch = async (ids: string[]) => {
+  try {
+    await ElMessageBox.confirm('确定要删除选中的全景图吗?', '提示', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning',
+    })
 
-  loadingProgress.value = 100
-  loadingText.value = '加载完成!'
+    const res = await clientPost<string, BaseResponse>(
+      '/houseType/deleteBatch',
+      JSON.stringify(ids),
+    )
 
-  // 延迟一点时间让用户看到100%
-  setTimeout(() => {
-    isLoading.value = false
-  }, 500)
-}
+    if (res.code !== 200) {
+      ElMessage.error(res.msg)
+      return
+    }
 
-// 初始化轨道控制器(简化版实现)
-const initControls = () => {
-  let isMouseDown = false
-  let mouseX = 0
-  let mouseY = 0
-  let lon = 0
-  let lat = 0
-  let phi = 0
-  let theta = 0
-
-  const onMouseDown = (event: MouseEvent) => {
-    if (isAnimating) return
-    isMouseDown = true
-    mouseX = event.clientX
-    mouseY = event.clientY
+    ElMessage.success(res.msg)
+    selectedIds.value = []
+    getList()
+  } catch (error) {
+    // 用户取消删除
   }
+}
 
-  const onMouseUp = () => {
-    isMouseDown = false
+// 新增
+const add = async (houseType: AddAndUpdateHouseType) => {
+  const houseTypeFormData = new FormData()
+  houseTypeFormData.append('area', houseType.area)
+  houseTypeFormData.append('address', houseType.address)
+  houseTypeFormData.append('accountNumber', houseType.accountNumber)
+  houseTypeFormData.append('multipartFile', houseType.multipartFile)
+
+  const res = await clientPostFormData<FormData, BaseResponse>('/houseType/save', houseTypeFormData)
+  if (res.code !== 200) {
+    ElMessage.error(res.msg)
+    return
   }
 
-  const onMouseMove = (event: MouseEvent) => {
-    if (!isMouseDown || isAnimating) return
-
-    const deltaX = event.clientX - mouseX
-    const deltaY = event.clientY - mouseY
-
-    mouseX = event.clientX
-    mouseY = event.clientY
-
-    lon -= deltaX * 0.1
-    lat += deltaY * 0.1
-
-    lat = Math.max(-85, Math.min(85, lat))
+  ElMessage.success(res.msg)
+  getList()
+}
 
-    phi = THREE.MathUtils.degToRad(90 - lat)
-    theta = THREE.MathUtils.degToRad(lon)
+// 根据id查单个
+const findById = async (id: string) => {
+  const res = await clientGet<string, HouseTypeOneResponse>('/houseType/getById/' + id)
 
-    camera.lookAt(
-      Math.sin(phi) * Math.cos(theta),
-      Math.cos(phi),
-      Math.sin(phi) * Math.sin(theta)
-    )
+  if (res.code !== 200) {
+    ElMessage.error(res.msg)
+    return null
   }
-
-  renderer.domElement.addEventListener('mousedown', onMouseDown)
-  renderer.domElement.addEventListener('mouseup', onMouseUp)
-  renderer.domElement.addEventListener('mousemove', onMouseMove)
+  return res.data
 }
 
-// 加载场景 - 现在支持异步加载
-const loadScene = async (sceneIndex: number) => {
-  const sceneData = scenes.value[sceneIndex]
-  if (!sceneData) return
-
-  // 加载全景贴图
-  const textureLoader = new THREE.TextureLoader()
-
-  return new Promise((resolve, reject) => {
-    textureLoader.load(
-      sceneData.texture,
-      (texture) => {
-        sphere.material.map = texture
-        sphere.material.needsUpdate = true
-
-        // 清除旧的热点
-        hotspots.forEach(hotspot => {
-          scene.remove(hotspot)
-        })
-        hotspots = []
-
-        // 添加新的热点
-        sceneData.hotspots.forEach((hotspotData, index) => {
-          const hotspot = createHotspot(hotspotData, index)
-          hotspots.push(hotspot)
-          scene.add(hotspot)
-        })
-
-        currentSceneIndex.value = sceneIndex
-        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
-
-        // 仍然添加热点
-        hotspots.forEach(hotspot => {
-          scene.remove(hotspot)
-        })
-        hotspots = []
-
-        sceneData.hotspots.forEach((hotspotData, index) => {
-          const hotspot = createHotspot(hotspotData, index)
-          hotspots.push(hotspot)
-          scene.add(hotspot)
-        })
-
-        currentSceneIndex.value = sceneIndex
-        selectedHotspotIndex.value = -1
-        resolve(null)
-      }
-    )
-  })
-}
-
-// 创建热点框框
-const createHotspot = (hotspotData: any, 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)
-
-  // 使用PlaneGeometry
-  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 // 增加z位置,确保在背景之上
-
-  // 创建缩小的点击区域(用于射线检测)
-  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
+// 修改
+const update = async (id: string, houseType: AddAndUpdateHouseType) => {
+  const houseTypeFormData = new FormData()
+  houseTypeFormData.append('id', id)
+  houseTypeFormData.append('area', houseType.area)
+  houseTypeFormData.append('address', houseType.address)
+  houseTypeFormData.append('accountNumber', houseType.accountNumber)
+  houseTypeFormData.append('multipartFile', houseType.multipartFile)
+
+  const res = await clientPostFormData<FormData, BaseResponse>(
+    '/houseType/update',
+    houseTypeFormData,
   )
+  if (res.code !== 200) {
+    ElMessage.error(res.msg)
+    return
+  }
 
-  // 首先让热点面向摄像机(位于原点)
-  group.lookAt(camera.position)
-
-  // 然后在此基础上应用用户设置的旋转偏移
-  if (hotspotData.rotation) {
-    // 保存当前的旋转(面向摄像机的旋转)
-    const currentRotation = group.rotation.clone()
+  ElMessage.success(res.msg)
+  getList()
+}
 
-    // 创建用户设置的旋转
-    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)
-    )
+// 打开新增对话框
+const openAddDialog = () => {
+  dialogTitle.value = '新增全景图'
+  currentEditId.value = ''
+  currentHouseType.value = null
+  resetForm()
+  dialogVisible.value = true
+}
 
-    // 将用户旋转应用到当前旋转上
-    group.rotation.x = currentRotation.x + userRotation.x
-    group.rotation.y = currentRotation.y + userRotation.y
-    group.rotation.z = currentRotation.z + userRotation.z
+// 打开编辑对话框
+const openEditDialog = async (item: HouseType) => {
+  dialogTitle.value = '编辑全景图'
+  currentEditId.value = item.id
+  currentHouseType.value = item
+
+  const data = await findById(item.id)
+  if (data) {
+    formData.area = data.area || ''
+    formData.address = data.address || ''
+    formData.accountNumber = data.accountNumber || ''
   }
 
-  // 存储热点数据和索引
-  group.userData = { ...hotspotData, index }
+  dialogVisible.value = true
+}
 
-  return group
+// 重置表单
+const resetForm = () => {
+  formData.area = ''
+  formData.address = ''
+  formData.accountNumber = ''
+  formData.multipartFile = null as any
+  localPreviewUrl.value = ''
+  formRef.value?.clearValidate()
+  uploadRef.value?.clearFiles()
 }
 
-// 拉伸穿梭动画函数
-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)
-
-    // 使用easeInQuart缓动函数,产生加速拉伸效果
-    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) {
-      // 前70%时间:局部拉伸效果
-      const stretchIntensity = easedProgress * 0.3 // 最大拉伸30%
-      const scaleX = 1 + stretchIntensity
-      const scaleY = 1 + stretchIntensity
-
-      // 计算变形中心点(相对于canvas中心的偏移,基于鼠标点击位置)
-      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 {
-      // 后30%时间:整体放大并淡出
-      const fadeProgress = (progress - 0.7) / 0.3
-      const finalScale = 1.3 + fadeProgress * 0.5
+// 提交表单
+const submitForm = async () => {
+  try {
+    await formRef.value.validate()
 
-      canvas.style.transform = `scale(${finalScale})`
-      canvas.style.opacity = (1 - fadeProgress * 0.8).toString()
+    // 新增时必须上传图片,编辑时可选
+    if (!currentEditId.value && !formData.multipartFile) {
+      ElMessage.warning('请上传全景图')
+      return
     }
 
-    if (progress < 1) {
-      requestAnimationFrame(animateStretch)
+    if (currentEditId.value) {
+      await update(currentEditId.value, formData)
     } else {
-      // 动画完成,清理和切换场景
-      setTimeout(() => {
-        // 恢复canvas样式
-        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
+      await add(formData)
     }
 
-    // 更新用户数据
-    hotspot.userData = { ...config, index: selectedHotspotIndex.value }
+    dialogVisible.value = false
+    resetForm()
+  } catch (error) {
+    console.error('表单验证失败', error)
   }
 }
 
-// 鼠标点击事件
-const onMouseClick = (event: MouseEvent) => {
-  if (isAnimating) return // 动画进行中时忽略点击
+// 文件上传处理
+const handleFileChange = (file: any) => {
+  // 检查文件大小(10MB = 10 * 1024 * 1024 bytes)
+  const maxSize = 10 * 1024 * 1024
+  if (file.raw && file.raw.size > maxSize) {
+    ElMessage.error('上传文件大小不能超过 10MB!')
+    return false
+  }
 
-  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
+  formData.multipartFile = file.raw
 
-  raycaster.setFromCamera(mouse, camera)
+  // 创建本地预览URL
+  if (file.raw) {
+    // 清理之前的URL
+    if (localPreviewUrl.value) {
+      URL.revokeObjectURL(localPreviewUrl.value)
+    }
+    localPreviewUrl.value = URL.createObjectURL(file.raw)
+  }
 
-  // 只检测点击区域(最后一个子元素)
-  const clickAreas = hotspots.map(hotspot => hotspot.children[hotspot.children.length - 1])
-  const intersects = raycaster.intersectObjects(clickAreas)
+  return false // 阻止自动上传
+}
 
-  if (intersects.length > 0) {
-    const clickedObject = intersects[0].object
-    const hotspotGroup = clickedObject.parent
+// 删除单个项目
+const deleteItem = (item: HouseType) => {
+  delBatch([item.id])
+}
 
-    if (isEditMode.value) {
-      // 编辑模式:选中热点进行配置
-      const hotspotIndex = hotspotGroup.userData.index
-      selectedHotspotIndex.value = hotspotIndex
-      selectedHotspotConfig.value = { ...scenes.value[currentSceneIndex.value].hotspots[hotspotIndex] }
-      ElMessage.info(`已选中热点: ${hotspotGroup.userData.label}`)
-    } else {
-      // 预览模式:播放拉伸穿梭动画然后跳转场景
-      const targetScene = hotspotGroup.userData.targetScene
-      // 使用实际的鼠标点击位置(相对于页面的绝对坐标)
-      const clickPosition = { x: event.clientX, y: event.clientY }
-
-      // 播放拉伸穿梭动画,动画完成后切换场景
-      animateStretchTransition(hotspotGroup, clickPosition, () => {
-        ElMessage.success(`切换到${scenes.value[targetScene].name}`)
-        loadScene(targetScene)
-      })
-    }
-  } else if (isEditMode.value) {
-    // 点击空白处取消选中
-    selectedHotspotIndex.value = -1
+// 批量删除选中项
+const deleteSelected = () => {
+  if (selectedIds.value.length === 0) {
+    ElMessage.warning('请选择要删除的项目')
+    return
   }
+  delBatch(selectedIds.value)
 }
 
-// 鼠标移动事件(用于悬停效果)
-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 && 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 || 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'
+// 选择项目
+const toggleSelection = (id: string) => {
+  const index = selectedIds.value.indexOf(id)
+  if (index > -1) {
+    selectedIds.value.splice(index, 1)
   } else {
-    renderer.domElement.style.cursor = isEditMode.value ? 'crosshair' : 'grab'
+    selectedIds.value.push(id)
   }
 }
 
-// 窗口大小调整
-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 toggleSelectAll = () => {
+  if (selectedIds.value.length === houseTypeList.value.length) {
+    selectedIds.value = []
+  } else {
+    selectedIds.value = houseTypeList.value.map((item) => item.id)
+  }
 }
 
-// 动画循环
-const animate = () => {
-  requestAnimationFrame(animate)
-  renderer.render(scene, camera)
+// 分页改变
+const handlePageChange = (page: number) => {
+  pagination.pageNum = page
+  getList()
 }
 
-// 切换场景
-const switchScene = async (sceneIndex: number) => {
-  if (sceneIndex !== currentSceneIndex.value && !isAnimating) {
-    // 显示小loading用于场景切换
-    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)
-  }
+// 页面大小改变
+const handleSizeChange = (size: number) => {
+  pagination.pageSize = size
+  pagination.pageNum = 1
+  getList()
 }
 
-// 切换编辑模式
-const toggleEditMode = () => {
-  if (isAnimating) return
-
-  isEditMode.value = !isEditMode.value
-  selectedHotspotIndex.value = -1
+// 搜索功能
+const handleSearch = () => {
+  pagination.pageNum = 1 // 搜索时重置到第一页
+  getList()
+}
 
-  if (isEditMode.value) {
-    ElMessage.info('已进入编辑模式,点击热点进行配置')
+// 查看封面
+const viewHouseType = (item: HouseType) => {
+  if (item.accountUrl) {
+    previewImageUrl.value = MINIO_BASE_URL + item.accountUrl
+    previewVisible.value = true
   } else {
-    ElMessage.info('已退出编辑模式')
+    ElMessage.warning('该全景图暂无封面')
   }
 }
 
-// 添加新热点
-const addNewHotspot = () => {
-  const newHotspot = {
-    position: { x: 0, y: 0, z: 3 },
-    rotation: { x: 0, y: 0, z: 0 },
-    label: '新热点',
-    targetScene: 0,
-    width: 0.8,
-    height: 0.4,
-    color: '#4f46e5'
-  }
-
-  scenes.value[currentSceneIndex.value].hotspots.push(newHotspot)
-  loadScene(currentSceneIndex.value)
-  ElMessage.success('已添加新热点')
+// 进入编辑房间
+const enterEditRoom = (item: HouseType) => {
+  router.push({ path: '/ar/room', query: { houseTypeId: item.id } })
 }
 
-// 删除选中热点
-const deleteSelectedHotspot = () => {
-  if (selectedHotspotIndex.value >= 0) {
-    scenes.value[currentSceneIndex.value].hotspots.splice(selectedHotspotIndex.value, 1)
-    selectedHotspotIndex.value = -1
-    loadScene(currentSceneIndex.value)
-    ElMessage.success('已删除热点')
+//进入AR看房
+const previewRoom = async (item: HouseType) => {
+  //判断内部是否有房间
+  const res = await clientGet<string, RoomListResponse>('/aroom/getById/' + item.id)
+  if (res.data.length === 0) {
+    ElMessage.warning('该户型暂无房间,请添加房间后再进行查看')
+    return
   }
+  router.push({ path: '/ar/preview', query: { houseTypeId: item.id } })
 }
 
-// 保存配置
-const saveConfiguration = () => {
-  // 暂时打印配置数据,等接口准备好后再实现
-  console.log('保存配置数据:', {
-    scenes: scenes.value,
-    currentScene: currentSceneIndex.value,
-    timestamp: new Date().toISOString()
-  })
-
-  ElMessage.success('配置已保存到控制台(接口开发中...)')
+// 清理URL对象
+const cleanupPreviewUrl = () => {
+  if (localPreviewUrl.value) {
+    URL.revokeObjectURL(localPreviewUrl.value)
+    localPreviewUrl.value = ''
+  }
 }
 
-// 初始化应用
-const initApp = async () => {
-  try {
-    // 1. 获取基础数据
-    await fetchScenesData()
-
-    // 2. 获取配置数据
-    await fetchConfigData()
-
-    // 3. 初始化Three.js
-    await initThreeJS()
+// 关闭对话框时清理资源
+const handleDialogClose = () => {
+  cleanupPreviewUrl()
+  resetForm()
+}
 
-  } catch (error) {
-    console.error('应用初始化失败:', error)
-    ElMessage.error('应用初始化失败,请刷新页面重试')
-    isLoading.value = false
-  }
+const init = () => {
+  getList()
 }
 
-// 组件挂载
 onMounted(() => {
-  initApp()
-})
-
-// 组件卸载
-onUnmounted(() => {
-  window.removeEventListener('resize', onWindowResize)
-  if (renderer) {
-    renderer.dispose()
-  }
+  init()
 })
 </script>
 
 <template>
-  <div class="relative w-screen h-screen 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 class="house-type-page h-screen bg-gray-50 p-6 flex flex-col overflow-hidden">
+    <!-- 页面头部 -->
+    <div class="mb-6">
+      <div class="flex items-center justify-between mb-4">
+        <div class="flex items-center gap-3">
+          <el-icon class="text-2xl text-blue-600"><HomeFilled /></el-icon>
+          <h1 class="text-2xl font-bold text-gray-800">全景图管理</h1>
         </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 class="flex items-center gap-3">
+          <el-input
+            v-model="searchKeyword"
+            placeholder="搜索户号、位置..."
+            class="w-64"
+            clearable
+            @keyup.enter="handleSearch"
+            @clear="handleSearch"
+          >
+            <template #suffix>
+              <el-icon class="cursor-pointer" @click="handleSearch">
+                <Search />
+              </el-icon>
+            </template>
+          </el-input>
+          <el-button type="primary" :icon="Plus" @click="openAddDialog"> 新增全景图 </el-button>
         </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 class="flex items-center justify-between">
+        <div class="flex items-center gap-3">
+          <el-checkbox
+            :indeterminate="selectedIds.length > 0 && selectedIds.length < houseTypeList.length"
+            :model-value="selectedIds.length === houseTypeList.length && houseTypeList.length > 0"
+            @change="toggleSelectAll"
+          >
+            全选
+          </el-checkbox>
+          <span class="text-sm text-gray-600"> 已选择 {{ selectedIds.length }} 项 </span>
+          <el-button
+            v-if="selectedIds.length > 0"
+            type="danger"
+            size="small"
+            :icon="Delete"
+            @click="deleteSelected"
+          >
+            批量删除
+          </el-button>
         </div>
+        <div class="text-sm text-gray-600">共 {{ pagination.total }} 条记录</div>
       </div>
     </div>
 
-    <!-- 3D场景容器 -->
-    <div ref="containerRef" :class="isEditMode ? 'w-4/5' : 'w-full'" class="h-full transition-all duration-300"></div>
+    <!-- 全景图列表 -->
+    <div v-loading="loading" class="flex-1 overflow-y-auto mb-6">
+      <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
+        <div
+          v-for="item in houseTypeList"
+          :key="item.id"
+          class="bg-white m-5px rounded-lg shadow-md hover:shadow-lg transition-all duration-300 overflow-hidden cursor-pointer group relative"
+          :class="{ 'ring-2 ring-blue-500': selectedIds.includes(item.id) }"
+        >
+          <!-- 全景图图片 -->
+          <div class="relative h-48 bg-gray-200 overflow-hidden">
+            <!-- 图片区域的遮罩和操作按钮 -->
+            <div
+              class="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-20"
+            >
+              <ElRow :gutter="24" class="w-full max-w-md px-4">
+                <ElCol :span="12" class="text-center mb-2">
+                  <el-button type="primary" round :icon="View" @click.stop="viewHouseType(item)">
+                    查看封面
+                  </el-button>
+                </ElCol>
+                <ElCol :span="12" class="text-center mb-2">
+                  <el-button type="success" round :icon="Setting" @click.stop="enterEditRoom(item)">
+                    编辑房间
+                  </el-button>
+                </ElCol>
+                <ElCol :span="24" class="text-center">
+                  <el-button color="#626aef" round :icon="Platform" @click.stop="previewRoom(item)">
+                    AR看房
+                  </el-button>
+                </ElCol>
+              </ElRow>
+            </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 || '加载中...' }}</h3>
-        <p class="m-0 mb-4 text-xs text-gray-600 leading-relaxed">
-          {{ isEditMode ? '编辑模式:点击热点进行配置' : '预览模式:点击热点拉伸穿梭' }}
-        </p>
-      </div>
+            <img
+              v-if="item.accountUrl"
+              :src="MINIO_BASE_URL + item.accountUrl"
+              :alt="item.accountNumber"
+              class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
+            />
+            <div v-else class="w-full h-full flex items-center justify-center text-gray-400">
+              <el-icon class="text-4xl"><HomeFilled /></el-icon>
+            </div>
+          </div>
 
-      <div class="flex gap-2 flex-wrap mb-3">
-        <button
-          v-for="(scene, index) in scenes"
-          :key="index"
-          @click="switchScene(index)"
-          :disabled="isEditMode || 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 || isAnimating) ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
-          ]"
-        >
-          {{ scene.name }}
-        </button>
+          <!-- 户型信息 -->
+          <div class="p-4">
+            <!-- 选择框移到这里 -->
+            <div class="flex items-center justify-between mb-3">
+              <el-checkbox
+                :model-value="selectedIds.includes(item.id)"
+                @change="toggleSelection(item.id)"
+                @click.stop
+              >
+                <span class="text-sm text-gray-600">选择</span>
+              </el-checkbox>
+            </div>
+
+            <div class="flex items-center justify-between mb-2">
+              <h3 class="text-lg font-semibold text-gray-800 truncate">
+                {{ item.accountNumber || '未命名' }}
+              </h3>
+              <span class="text-sm bg-blue-100 text-blue-600 px-2 py-1 rounded">
+                {{ item.area }}㎡
+              </span>
+            </div>
+
+            <div class="flex items-center text-gray-600 text-sm mb-3">
+              <el-icon class="mr-1"><MapLocation /></el-icon>
+              <span class="truncate">{{ item.address || '位置未知' }}</span>
+            </div>
+
+            <div class="flex items-center justify-between text-xs text-gray-500 mb-3">
+              <span>创建时间</span>
+              <span>{{
+                item.createTime ? new Date(item.createTime).toLocaleDateString() : '-'
+              }}</span>
+            </div>
+
+            <!-- 内容框内的操作按钮 -->
+            <div class="flex justify-end gap-2">
+              <el-button
+                type="primary"
+                size="small"
+                round
+                :icon="Edit"
+                @click.stop="openEditDialog(item)"
+              >
+                编辑
+              </el-button>
+              <el-button
+                type="danger"
+                size="small"
+                round
+                :icon="Delete"
+                @click.stop="deleteItem(item)"
+              >
+                删除
+              </el-button>
+            </div>
+          </div>
+        </div>
       </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
-          @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"
-          @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 v-if="!loading && houseTypeList.length === 0" class="text-center py-12">
+        <el-icon class="text-6xl text-gray-300 mb-4"><HomeFilled /></el-icon>
+        <p class="text-gray-500 text-lg mb-4">暂无全景图数据</p>
+        <el-button type="primary" :icon="Plus" @click="openAddDialog"> 添加第一个全景图 </el-button>
       </div>
     </div>
 
-    <!-- 右侧配置面板 -->
-    <div
-      v-if="isEditMode"
-      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 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>
-        </div>
+    <!-- 分页 -->
+    <div v-if="houseTypeList.length > 0" class="flex justify-center">
+      <el-pagination
+        v-model:current-page="pagination.pageNum"
+        v-model:page-size="pagination.pageSize"
+        :page-sizes="[12, 24, 48, 96]"
+        :total="pagination.total"
+        layout="total, sizes, prev, pager, next, jumper"
+        @size-change="handleSizeChange"
+        @current-change="handlePageChange"
+      />
+    </div>
 
-        <ElForm :model="selectedHotspotConfig" label-width="60px" size="small">
-          <ElFormItem label="标签">
-            <ElInput v-model="selectedHotspotConfig.label" placeholder="热点标签" />
-          </ElFormItem>
-
-          <ElFormItem label="目标">
-            <ElSelect v-model="selectedHotspotConfig.targetScene" placeholder="目标场景" class="w-full">
-              <ElOption
-                v-for="(scene, index) in scenes"
-                :key="index"
-                :label="scene.name"
-                :value="index"
-              />
-            </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"
+    <!-- 新增/编辑对话框 -->
+    <el-dialog
+      v-model="dialogVisible"
+      :title="dialogTitle"
+      width="600px"
+      :close-on-click-modal="false"
+      @close="handleDialogClose"
+    >
+      <el-form
+        ref="formRef"
+        :model="formData"
+        :rules="formRules"
+        label-width="80px"
+        label-position="left"
+      >
+        <el-form-item label="户号" prop="accountNumber">
+          <el-input v-model="formData.accountNumber" placeholder="请输入户号" clearable />
+        </el-form-item>
+
+        <el-form-item label="位置" prop="address">
+          <el-input v-model="formData.address" placeholder="请输入位置" clearable />
+        </el-form-item>
+
+        <el-form-item label="面积" prop="area">
+          <el-input v-model="formData.area" placeholder="请输入面积(平方米)" clearable>
+            <template #suffix>㎡</template>
+          </el-input>
+        </el-form-item>
+
+        <el-form-item :label="currentEditId ? '全景图' : '全景图'" :required="!currentEditId">
+          <div class="w-full">
+            <!-- 当前图片预览(编辑模式下显示原图) -->
+            <div
+              v-if="currentEditId && currentHouseType?.accountUrl && !localPreviewUrl"
+              class="mb-4"
+            >
+              <div class="text-sm text-gray-600 mb-2">当前全景图:</div>
+              <div class="w-32 h-32 border border-gray-300 rounded-lg overflow-hidden">
+                <img
+                  :src="MINIO_BASE_URL + currentHouseType.accountUrl"
+                  :alt="currentHouseType.accountNumber"
+                  class="w-full h-full object-cover"
                 />
               </div>
-            </ElFormItem>
-          </div>
+            </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 v-if="localPreviewUrl" class="mb-4">
+              <div class="text-sm text-gray-600 mb-2">
+                {{ currentEditId ? '新上传的图片:' : '预览图片:' }}
               </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 class="w-32 h-32 border border-gray-300 rounded-lg overflow-hidden">
+                <img :src="localPreviewUrl" alt="预览图片" class="w-full h-full object-cover" />
               </div>
-            </ElFormItem>
-          </div>
+            </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"
-                />
+            <!-- 上传组件 -->
+            <el-upload
+              ref="uploadRef"
+              :auto-upload="false"
+              :show-file-list="true"
+              :limit="1"
+              accept="image/*"
+              :on-change="handleFileChange"
+              drag
+            >
+              <el-icon class="text-4xl text-gray-400 mb-2"><Plus /></el-icon>
+              <div class="text-gray-600">
+                {{ currentEditId ? '点击或拖拽替换全景图' : '点击或拖拽上传全景图' }}
               </div>
-            </ElFormItem>
+              <template #tip>
+                <div class="text-xs text-gray-500 mt-2">
+                  支持 jpg、png 格式,文件大小不能超过 10MB
+                  {{ currentEditId ? '(不上传则保持原图不变)' : '' }}
+                </div>
+              </template>
+            </el-upload>
           </div>
+        </el-form-item>
+      </el-form>
 
-          <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">
-        <p>点击场景中的热点进行配置</p>
-      </div>
-
-      <!-- 当前场景热点列表 -->
-      <div class="mt-6" v-if="scenes[currentSceneIndex]">
-        <h4 class="text-md font-semibold mb-3 text-gray-300">当前场景热点</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">→ {{ scenes[hotspot.targetScene]?.name || '未知场景' }}</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.rotation.x }}°, {{ hotspot.rotation.y }}°, {{ hotspot.rotation.z }}°)
-              </p>
-            </div>
-          </div>
+      <template #footer>
+        <div class="flex justify-end gap-3">
+          <el-button @click="dialogVisible = false">取消</el-button>
+          <el-button type="primary" @click="submitForm">确定</el-button>
         </div>
+      </template>
+    </el-dialog>
+
+    <!-- 图片预览对话框 -->
+    <el-dialog
+      v-model="previewVisible"
+      title="全景图预览"
+      width="80%"
+      :close-on-click-modal="true"
+      append-to-body
+    >
+      <div class="flex justify-center">
+        <img :src="previewImageUrl" alt="全景图预览" class="max-w-full max-h-96 object-contain" />
       </div>
-    </div>
-
-    <!-- 底部提示 -->
-    <div v-if="!isEditMode" 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>
+    </el-dialog>
   </div>
 </template>
+
+<style scoped>
+.house-type-page {
+  font-family:
+    'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑',
+    Arial, sans-serif;
+}
+
+.el-card {
+  transition: all 0.3s ease;
+}
+
+.el-card:hover {
+  transform: translateY(-2px);
+}
+</style>

+ 1729 - 0
src/views/ar/preview.vue

@@ -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>