返回笔记首页

VR全景看房系统

主题配置
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>VR全景看房系统</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@3.3.4/dist/vue.global.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/three@0.156.1/build/three.min.js"></script>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      background: #000;
      color: white;
      overflow: hidden;
    }
    #app {
      width: 100vw;
      height: 100vh;
      position: relative;
    }
    #canvas-container {
      width: 100%;
      height: 100%;
      cursor: grab;
    }
    #canvas-container:active {
      cursor: grabbing;
    }
    .loading-screen {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: #000;
      display: flex;
      align-items: center;
      justify-content: center;
      z-index: 100;
      transition: opacity 0.5s;
    }
    .loading-screen.hidden {
      opacity: 0;
      pointer-events: none;
    }
    .loading-content {
      text-align: center;
    }
    .loading-spinner {
      width: 60px;
      height: 60px;
      border: 4px solid rgba(255,255,255,0.1);
      border-top-color: #fff;
      border-radius: 50%;
      animation: spin 1s linear infinite;
      margin: 0 auto 20px;
    }
    @keyframes spin {
      to { transform: rotate(360deg); }
    }
    .loading-text {
      font-size: 18px;
      color: rgba(255,255,255,0.8);
    }
    .top-bar {
      position: absolute;
      top: 0;
      left: 0;
      right: 0;
      padding: 25px 30px;
      background: linear-gradient(180deg, rgba(0,0,0,0.8) 0%, transparent 100%);
      z-index: 10;
    }
    .top-bar h1 {
      font-size: 24px;
      margin-bottom: 8px;
      text-shadow: 0 2px 10px rgba(0,0,0,0.5);
    }
    .top-bar p {
      font-size: 14px;
      color: rgba(255,255,255,0.8);
      text-shadow: 0 2px 10px rgba(0,0,0,0.5);
    }
    .room-switcher {
      position: absolute;
      left: 20px;
      top: 50%;
      transform: translateY(-50%);
      background: rgba(0,0,0,0.7);
      backdrop-filter: blur(10px);
      border-radius: 12px;
      padding: 20px;
      width: 280px;
      max-height: 70vh;
      overflow-y: auto;
      z-index: 10;
    }
    .room-switcher h3 {
      font-size: 16px;
      margin-bottom: 15px;
      color: #4a9eff;
    }
    .room-item {
      display: flex;
      align-items: center;
      gap: 15px;
      padding: 12px;
      margin-bottom: 10px;
      background: rgba(255,255,255,0.05);
      border-radius: 8px;
      cursor: pointer;
      transition: all 0.3s;
      border: 2px solid transparent;
    }
    .room-item:hover {
      background: rgba(255,255,255,0.1);
      transform: translateX(5px);
    }
    .room-item.active {
      background: rgba(74,158,255,0.2);
      border-color: #4a9eff;
    }
    .room-icon {
      width: 50px;
      height: 50px;
      background: rgba(74,158,255,0.2);
      border-radius: 8px;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 24px;
      flex-shrink: 0;
    }
    .room-info {
      flex: 1;
    }
    .room-name {
      font-size: 14px;
      font-weight: 600;
      margin-bottom: 4px;
    }
    .room-desc {
      font-size: 12px;
      color: rgba(255,255,255,0.6);
    }
    .controls-panel {
      position: absolute;
      right: 20px;
      bottom: 30px;
      display: flex;
      gap: 15px;
      z-index: 10;
    }
    .control-btn {
      width: 50px;
      height: 50px;
      background: rgba(0,0,0,0.7);
      backdrop-filter: blur(10px);
      border: 2px solid rgba(255,255,255,0.2);
      border-radius: 50%;
      display: flex;
      align-items: center;
      justify-content: center;
      cursor: pointer;
      transition: all 0.3s;
      color: white;
      font-size: 20px;
    }
    .control-btn:hover {
      background: rgba(255,255,255,0.1);
      border-color: #4a9eff;
      transform: translateY(-3px);
      box-shadow: 0 5px 15px rgba(74,158,255,0.3);
    }
    .control-btn.active {
      background: rgba(74,158,255,0.3);
      border-color: #4a9eff;
    }
    .mini-map {
      position: absolute;
      left: 20px;
      bottom: 30px;
      width: 280px;
      height: 180px;
      background: rgba(0,0,0,0.7);
      backdrop-filter: blur(10px);
      border-radius: 12px;
      padding: 15px;
      z-index: 10;
    }
    .mini-map h4 {
      font-size: 14px;
      margin-bottom: 10px;
      color: #4a9eff;
    }
    .floor-plan {
      width: 100%;
      height: 120px;
      background: rgba(255,255,255,0.05);
      border-radius: 8px;
      position: relative;
      overflow: hidden;
    }
    .room-marker {
      position: absolute;
      width: 40px;
      height: 40px;
      background: rgba(74,158,255,0.3);
      border: 2px solid #4a9eff;
      border-radius: 4px;
      cursor: pointer;
      transition: all 0.3s;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 10px;
    }
    .room-marker:hover {
      background: rgba(74,158,255,0.5);
      transform: scale(1.1);
    }
    .room-marker.current {
      background: #4a9eff;
      box-shadow: 0 0 20px rgba(74,158,255,0.6);
    }
    .hotspot {
      position: absolute;
      width: 60px;
      height: 60px;
      border-radius: 50%;
      background: rgba(74,158,255,0.3);
      border: 3px solid #4a9eff;
      cursor: pointer;
      transition: all 0.3s;
      display: flex;
      align-items: center;
      justify-content: center;
      animation: pulse-hotspot 2s ease-in-out infinite;
      z-index: 5;
    }
    .hotspot:hover {
      background: rgba(74,158,255,0.5);
      transform: scale(1.2);
    }
    .hotspot::before {
      content: '→';
      font-size: 24px;
      color: white;
      font-weight: bold;
    }
    @keyframes pulse-hotspot {
      0%, 100% {
        box-shadow: 0 0 0 0 rgba(74,158,255,0.7);
      }
      50% {
        box-shadow: 0 0 0 15px rgba(74,158,255,0);
      }
    }
    .info-panel {
      position: absolute;
      top: 50%;
      right: 20px;
      transform: translateY(-50%);
      width: 300px;
      background: rgba(0,0,0,0.8);
      backdrop-filter: blur(10px);
      border-radius: 12px;
      padding: 20px;
      z-index: 10;
      max-height: 60vh;
      overflow-y: auto;
    }
    .info-panel h3 {
      font-size: 18px;
      margin-bottom: 15px;
      color: #4a9eff;
    }
    .info-item {
      margin-bottom: 15px;
    }
    .info-label {
      font-size: 12px;
      color: rgba(255,255,255,0.6);
      margin-bottom: 5px;
    }
    .info-value {
      font-size: 14px;
      color: white;
    }
    ::-webkit-scrollbar {
      width: 6px;
    }
    ::-webkit-scrollbar-track {
      background: rgba(255,255,255,0.05);
    }
    ::-webkit-scrollbar-thumb {
      background: rgba(74,158,255,0.5);
      border-radius: 3px;
    }
  </style>
</head>
<body>
  <div id="app">
    <div class="loading-screen" :class="{ hidden: !loading }">
      <div class="loading-content">
        <div class="loading-spinner"></div>
        <div class="loading-text">正在加载全景...</div>
      </div>
    </div>

    <div id="canvas-container"></div>

    <div class="top-bar">
      <h1>VR全景看房</h1>
      <p>拖动查看360°全景 | 点击热点切换房间</p>
    </div>

    <div class="room-switcher">
      <h3>房间列表</h3>
      <div
        v-for="room in rooms"
        :key="room.id"
        class="room-item"
        :class="{ active: currentRoom === room.id }"
        @click="switchRoom(room.id)"
      >
        <div class="room-icon">{{ room.icon }}</div>
        <div class="room-info">
          <div class="room-name">{{ room.name }}</div>
          <div class="room-desc">{{ room.area }}</div>
        </div>
      </div>
    </div>

    <div class="mini-map">
      <h4>户型图导航</h4>
      <div class="floor-plan">
        <div
          v-for="room in rooms"
          :key="room.id"
          class="room-marker"
          :class="{ current: currentRoom === room.id }"
          :style="{ left: room.mapPos.x + '%', top: room.mapPos.y + '%' }"
          @click="switchRoom(room.id)"
          :title="room.name"
        >
          {{ room.icon }}
        </div>
      </div>
    </div>

    <div class="controls-panel">
      <div class="control-btn" @click="toggleGyro" :class="{ active: gyroEnabled }" title="陀螺仪">
        🧭
      </div>
      <div class="control-btn" @click="toggleAutoRotate" :class="{ active: autoRotate }" title="自动旋转">
        ↻
      </div>
      <div class="control-btn" @click="toggleInfo" :class="{ active: showInfo }" title="房间信息">
        ℹ
      </div>
      <div class="control-btn" @click="resetView" title="重置视角">
        ⟲
      </div>
    </div>

    <div class="info-panel" v-if="showInfo && currentRoomData">
      <h3>{{ currentRoomData.name }}</h3>
      <div class="info-item">
        <div class="info-label">面积</div>
        <div class="info-value">{{ currentRoomData.area }}</div>
      </div>
      <div class="info-item">
        <div class="info-label">朝向</div>
        <div class="info-value">{{ currentRoomData.direction }}</div>
      </div>
      <div class="info-item">
        <div class="info-label">特点</div>
        <div class="info-value">{{ currentRoomData.features }}</div>
      </div>
    </div>

    <div
      v-for="hotspot in visibleHotspots"
      :key="hotspot.id"
      class="hotspot"
      :style="{ left: hotspot.x + 'px', top: hotspot.y + 'px' }"
      @click="switchRoom(hotspot.targetRoom)"
      :title="'前往 ' + getRoomName(hotspot.targetRoom)"
    ></div>
  </div>

  <script>
    const { createApp } = Vue;
    const { Scene, PerspectiveCamera, WebGLRenderer, SphereGeometry,
            MeshBasicMaterial, Mesh, TextureLoader, sRGBEncoding, Vector3 } = THREE;

    createApp({
      data() {
        return {
          loading: true,
          scene: null,
          camera: null,
          renderer: null,
          sphere: null,
          animationId: null,

          currentRoom: 1,
          autoRotate: false,
          gyroEnabled: false,
          showInfo: false,

          isUserInteracting: false,
          onPointerDownMouseX: 0,
          onPointerDownMouseY: 0,
          lon: 0,
          onPointerDownLon: 0,
          lat: 0,
          onPointerDownLat: 0,
          phi: 0,
          theta: 0,

          rooms: [
            {
              id: 1,
              name: '客厅',
              icon: '🛋️',
              area: '38㎡',
              direction: '南向',
              features: '采光充足,视野开阔',
              texture: '#87CEEB',
              mapPos: { x: 15, y: 40 },
              hotspots: [
                { id: 'h1', targetRoom: 2, lon: 90, lat: 0 },
                { id: 'h2', targetRoom: 4, lon: -90, lat: 0 }
              ]
            },
            {
              id: 2,
              name: '主卧',
              icon: '🛏️',
              area: '22㎡',
              direction: '南向',
              features: '带独立卫浴,衣帽间',
              texture: '#FFB6C1',
              mapPos: { x: 60, y: 30 },
              hotspots: [
                { id: 'h3', targetRoom: 1, lon: -90, lat: 0 },
                { id: 'h4', targetRoom: 3, lon: 180, lat: 0 }
              ]
            },
            {
              id: 3,
              name: '次卧',
              icon: '🛏️',
              area: '15㎡',
              direction: '北向',
              features: '温馨舒适',
              texture: '#98FB98',
              mapPos: { x: 60, y: 70 },
              hotspots: [
                { id: 'h5', targetRoom: 2, lon: 0, lat: 0 },
                { id: 'h6', targetRoom: 4, lon: -90, lat: 0 }
              ]
            },
            {
              id: 4,
              name: '厨房',
              icon: '🍳',
              area: '12㎡',
              direction: '北向',
              features: '现代化厨具',
              texture: '#FFDAB9',
              mapPos: { x: 15, y: 70 },
              hotspots: [
                { id: 'h7', targetRoom: 1, lon: 90, lat: 0 },
                { id: 'h8', targetRoom: 3, lon: 0, lat: 0 }
              ]
            }
          ],

          visibleHotspots: [],
          gyroAlpha: 0,
          gyroBeta: 0,
          gyroGamma: 0
        };
      },

      computed: {
        currentRoomData() {
          return this.rooms.find(r => r.id === this.currentRoom);
        }
      },

      mounted() {
        this.initScene();
        this.loadPanorama();
        this.animate();
        this.setupEventListeners();

        setTimeout(() => {
          this.loading = false;
        }, 1000);
      },

      beforeUnmount() {
        this.removeEventListeners();
        if (this.animationId) {
          cancelAnimationFrame(this.animationId);
        }
        this.dispose();
      },

      methods: {
        initScene() {
          const container = document.getElementById('canvas-container');
          const width = container.clientWidth;
          const height = container.clientHeight;

          this.scene = new Scene();

          this.camera = new PerspectiveCamera(75, width / height, 0.1, 1000);
          this.camera.position.set(0, 0, 0.1);

          this.renderer = new WebGLRenderer({ antialias: true });
          this.renderer.setSize(width, height);
          this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
          this.renderer.outputEncoding = sRGBEncoding;
          container.appendChild(this.renderer.domElement);
        },

        loadPanorama() {
          const geometry = new SphereGeometry(500, 60, 40);
          geometry.scale(-1, 1, 1); // 反转球体

          // 使用纯色代替纹理图片
          const material = new MeshBasicMaterial({
            color: this.currentRoomData.texture
          });

          if (this.sphere) {
            this.scene.remove(this.sphere);
            this.sphere.geometry.dispose();
            this.sphere.material.dispose();
          }

          this.sphere = new Mesh(geometry, material);
          this.scene.add(this.sphere);

          this.updateHotspots();
        },

        setupEventListeners() {
          const container = document.getElementById('canvas-container');
          container.addEventListener('pointerdown', this.onPointerDown);
          document.addEventListener('pointermove', this.onPointerMove);
          document.addEventListener('pointerup', this.onPointerUp);
          window.addEventListener('resize', this.onWindowResize);

          if (window.DeviceOrientationEvent) {
            window.addEventListener('deviceorientation', this.onDeviceOrientation);
          }
        },

        removeEventListeners() {
          const container = document.getElementById('canvas-container');
          if (container) {
            container.removeEventListener('pointerdown', this.onPointerDown);
          }
          document.removeEventListener('pointermove', this.onPointerMove);
          document.removeEventListener('pointerup', this.onPointerUp);
          window.removeEventListener('resize', this.onWindowResize);

          if (window.DeviceOrientationEvent) {
            window.removeEventListener('deviceorientation', this.onDeviceOrientation);
          }
        },

        onPointerDown(event) {
          this.isUserInteracting = true;
          this.onPointerDownMouseX = event.clientX;
          this.onPointerDownMouseY = event.clientY;
          this.onPointerDownLon = this.lon;
          this.onPointerDownLat = this.lat;
        },

        onPointerMove(event) {
          if (this.isUserInteracting) {
            this.lon = (this.onPointerDownMouseX - event.clientX) * 0.1 + this.onPointerDownLon;
            this.lat = (event.clientY - this.onPointerDownMouseY) * 0.1 + this.onPointerDownLat;
          }
        },

        onPointerUp() {
          this.isUserInteracting = false;
        },

        onDeviceOrientation(event) {
          if (!this.gyroEnabled) return;

          this.gyroAlpha = event.alpha || 0;
          this.gyroBeta = event.beta || 0;
          this.gyroGamma = event.gamma || 0;

          this.lon = -this.gyroAlpha;
          this.lat = this.gyroBeta - 90;
        },

        switchRoom(roomId) {
          this.currentRoom = roomId;
          this.loadPanorama();
        },

        toggleGyro() {
          this.gyroEnabled = !this.gyroEnabled;
          if (this.gyroEnabled && typeof DeviceOrientationEvent.requestPermission === 'function') {
            DeviceOrientationEvent.requestPermission()
              .then(permissionState => {
                if (permissionState !== 'granted') {
                  this.gyroEnabled = false;
                  alert('需要授权陀螺仪权限');
                }
              })
              .catch(() => {
                this.gyroEnabled = false;
              });
          }
        },

        toggleAutoRotate() {
          this.autoRotate = !this.autoRotate;
        },

        toggleInfo() {
          this.showInfo = !this.showInfo;
        },

        resetView() {
          this.lon = 0;
          this.lat = 0;
        },

        getRoomName(roomId) {
          const room = this.rooms.find(r => r.id === roomId);
          return room ? room.name : '';
        },

        updateHotspots() {
          this.visibleHotspots = [];

          if (!this.currentRoomData || !this.currentRoomData.hotspots) return;

          this.currentRoomData.hotspots.forEach(hotspot => {
            const vector = new Vector3();
            const radius = 500;
            const phi = THREE.MathUtils.degToRad(90 - hotspot.lat);
            const theta = THREE.MathUtils.degToRad(hotspot.lon);

            vector.x = radius * Math.sin(phi) * Math.cos(theta);
            vector.y = radius * Math.cos(phi);
            vector.z = radius * Math.sin(phi) * Math.sin(theta);

            vector.project(this.camera);

            const x = (vector.x * 0.5 + 0.5) * window.innerWidth;
            const y = (-vector.y * 0.5 + 0.5) * window.innerHeight;

            if (vector.z < 1) {
              this.visibleHotspots.push({
                ...hotspot,
                x: x - 30,
                y: y - 30
              });
            }
          });
        },

        animate() {
          this.animationId = requestAnimationFrame(this.animate);

          if (this.autoRotate && !this.isUserInteracting) {
            this.lon += 0.1;
          }

          this.lat = Math.max(-85, Math.min(85, this.lat));
          this.phi = THREE.MathUtils.degToRad(90 - this.lat);
          this.theta = THREE.MathUtils.degToRad(this.lon);

          const x = 500 * Math.sin(this.phi) * Math.cos(this.theta);
          const y = 500 * Math.cos(this.phi);
          const z = 500 * Math.sin(this.phi) * Math.sin(this.theta);

          this.camera.lookAt(x, y, z);

          this.updateHotspots();

          if (this.renderer && this.scene && this.camera) {
            this.renderer.render(this.scene, this.camera);
          }
        },

        onWindowResize() {
          const container = document.getElementById('canvas-container');
          if (!container) return;

          const width = container.clientWidth;
          const height = container.clientHeight;

          if (this.camera) {
            this.camera.aspect = width / height;
            this.camera.updateProjectionMatrix();
          }

          if (this.renderer) {
            this.renderer.setSize(width, height);
          }
        },

        dispose() {
          if (this.sphere) {
            this.sphere.geometry.dispose();
            this.sphere.material.dispose();
          }
          if (this.renderer) {
            this.renderer.dispose();
          }
        }
      }
    }).mount('#app');
  </script>
</body>
</html>