<!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>
返回笔记首页
VR全景看房系统
主题配置