mirror of
http://36.133.248.69:3088/admin/RentWeAppFront.git
synced 2026-03-07 17:32:25 +08:00
438 lines
10 KiB
Vue
438 lines
10 KiB
Vue
|
|
<template>
|
|||
|
|
<view class="vr-page">
|
|||
|
|
<!-- 返回按钮 -->
|
|||
|
|
<view class="back-btn" @click="back">‹ 返回</view>
|
|||
|
|
|
|||
|
|
<!-- 主 Canvas -->
|
|||
|
|
<canvas canvas-id="vrCanvas" class="vr-canvas" @touchstart="onTouchStart" @touchmove="onTouchMove"
|
|||
|
|
@touchend="onTouchEnd"></canvas>
|
|||
|
|
|
|||
|
|
<!-- 左上角 3D mini preview -->
|
|||
|
|
<canvas v-if="currentSpace.type==='model'" canvas-id="miniCanvas" class="mini-canvas"></canvas>
|
|||
|
|
|
|||
|
|
<!-- Hotspot 点击由 Three.js raycaster 处理 -->
|
|||
|
|
|
|||
|
|
<!-- 底栏 -->
|
|||
|
|
<view class="bottom-bar">
|
|||
|
|
<!-- 左侧空间切换 -->
|
|||
|
|
<view class="left">
|
|||
|
|
<button @click="showSpaceList = !showSpaceList">
|
|||
|
|
<image src="/static/icons/space.png" />
|
|||
|
|
</button>
|
|||
|
|
</view>
|
|||
|
|
<!-- 右侧工具 -->
|
|||
|
|
<view class="right">
|
|||
|
|
<button @click="showTools = !showTools">
|
|||
|
|
<image src="/static/icons/tools.png" />
|
|||
|
|
</button>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
|
|||
|
|
<!-- 空间缩略图弹窗 -->
|
|||
|
|
<view v-if="showSpaceList" class="space-list">
|
|||
|
|
<view v-for="space in spaces" :key="space.id" class="space-item" @click="switchSpace(space.id)">
|
|||
|
|
<image :src="space.thumbnail" />
|
|||
|
|
<text>{{ space.name }}</text>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
|
|||
|
|
<!-- 工具栏弹窗 -->
|
|||
|
|
<view v-if="showTools" class="tools-panel">
|
|||
|
|
<button @click="resetView">重置视角</button>
|
|||
|
|
<button @click="toggleFullScreen">全屏</button>
|
|||
|
|
<button @click="takePhoto">拍照</button>
|
|||
|
|
</view>
|
|||
|
|
|
|||
|
|
<!-- 上一/下一空间按钮 -->
|
|||
|
|
<view class="switch-btn left" @click="prevSpace">‹</view>
|
|||
|
|
<view class="switch-btn right" @click="nextSpace">›</view>
|
|||
|
|
</view>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
import * as THREE from 'three'
|
|||
|
|
import {
|
|||
|
|
GLTFLoader
|
|||
|
|
} from 'three/examples/jsm/loaders/GLTFLoader.js'
|
|||
|
|
|
|||
|
|
export default {
|
|||
|
|
name: 'VRView',
|
|||
|
|
data() {
|
|||
|
|
return {
|
|||
|
|
spaces: [], // VR 空间数组
|
|||
|
|
currentIndex: 0,
|
|||
|
|
currentSpace: null,
|
|||
|
|
|
|||
|
|
// Three.js 主场景
|
|||
|
|
renderer: null,
|
|||
|
|
scene: null,
|
|||
|
|
camera: null,
|
|||
|
|
sphere: null,
|
|||
|
|
videoTexture: null,
|
|||
|
|
|
|||
|
|
// Mini 3D模型
|
|||
|
|
miniRenderer: null,
|
|||
|
|
miniScene: null,
|
|||
|
|
miniCamera: null,
|
|||
|
|
miniModel: null,
|
|||
|
|
|
|||
|
|
// Hotspot
|
|||
|
|
raycaster: null,
|
|||
|
|
mouse: new THREE.Vector2(),
|
|||
|
|
|
|||
|
|
// 触控
|
|||
|
|
isDragging: false,
|
|||
|
|
lastX: 0,
|
|||
|
|
lastY: 0,
|
|||
|
|
lon: 0,
|
|||
|
|
lat: 0,
|
|||
|
|
|
|||
|
|
// UI
|
|||
|
|
showSpaceList: false,
|
|||
|
|
showTools: false
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
onLoad(options) {
|
|||
|
|
if (options.vrList) {
|
|||
|
|
try {
|
|||
|
|
// JSON.parse(decodeURIComponent(options.spaces))
|
|||
|
|
this.spaces = options.vrList
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('解析 VR 空间失败', e)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
this.currentSpace = this.spaces[this.currentIndex]
|
|||
|
|
this.initThree()
|
|||
|
|
},
|
|||
|
|
methods: {
|
|||
|
|
back() {
|
|||
|
|
const pages = getCurrentPages();
|
|||
|
|
if (pages.length > 1) {
|
|||
|
|
uni.navigateBack();
|
|||
|
|
} else {
|
|||
|
|
uni.switchTab({
|
|||
|
|
url:'/pages/index/index'
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// ==================== Three.js 主场景 ====================
|
|||
|
|
initThree() {
|
|||
|
|
const canvasQuery = uni.createSelectorQuery()
|
|||
|
|
canvasQuery.select('#vrCanvas').node().exec(res => {
|
|||
|
|
const canvasNode = res[0].node
|
|||
|
|
const {
|
|||
|
|
windowWidth: width,
|
|||
|
|
windowHeight: height
|
|||
|
|
} = uni.getSystemInfoSync()
|
|||
|
|
|
|||
|
|
// 主 renderer
|
|||
|
|
this.renderer = new THREE.WebGLRenderer({
|
|||
|
|
canvas: canvasNode,
|
|||
|
|
antialias: true
|
|||
|
|
})
|
|||
|
|
this.renderer.setSize(width, height)
|
|||
|
|
|
|||
|
|
// scene & camera
|
|||
|
|
this.scene = new THREE.Scene()
|
|||
|
|
this.camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 2000)
|
|||
|
|
this.camera.position.set(0, 0, 0.01)
|
|||
|
|
|
|||
|
|
// raycaster
|
|||
|
|
this.raycaster = new THREE.Raycaster()
|
|||
|
|
|
|||
|
|
this.loadSpace(this.currentSpace)
|
|||
|
|
this.animate()
|
|||
|
|
})
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 加载空间
|
|||
|
|
loadSpace(space) {
|
|||
|
|
// 清空场景
|
|||
|
|
while (this.scene.children.length) this.scene.remove(this.scene.children[0])
|
|||
|
|
if (this.videoTexture) {
|
|||
|
|
this.videoTexture = null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 图片
|
|||
|
|
if (space.type === 'image') {
|
|||
|
|
const geometry = new THREE.SphereGeometry(500, 60, 40)
|
|||
|
|
geometry.scale(-1, 1, 1)
|
|||
|
|
const texture = new THREE.TextureLoader().load(space.src)
|
|||
|
|
const material = new THREE.MeshBasicMaterial({
|
|||
|
|
map: texture
|
|||
|
|
})
|
|||
|
|
this.sphere = new THREE.Mesh(geometry, material)
|
|||
|
|
this.scene.add(this.sphere)
|
|||
|
|
}
|
|||
|
|
// CubeMap
|
|||
|
|
else if (space.type === 'cubemap') {
|
|||
|
|
const urls = [space.cubemap.px, space.cubemap.nx, space.cubemap.py, space.cubemap.ny, space.cubemap.pz,
|
|||
|
|
space.cubemap.nz
|
|||
|
|
]
|
|||
|
|
const cubeTexture = new THREE.CubeTextureLoader().load(urls)
|
|||
|
|
this.scene.background = cubeTexture
|
|||
|
|
}
|
|||
|
|
// 视频
|
|||
|
|
else if (space.type === 'video') {
|
|||
|
|
const video = document.createElement('video')
|
|||
|
|
video.src = space.src
|
|||
|
|
video.crossOrigin = 'anonymous'
|
|||
|
|
video.loop = true
|
|||
|
|
video.muted = false
|
|||
|
|
video.autoplay = true
|
|||
|
|
video.play()
|
|||
|
|
this.videoTexture = new THREE.VideoTexture(video)
|
|||
|
|
const geometry = new THREE.SphereGeometry(500, 60, 40)
|
|||
|
|
geometry.scale(-1, 1, 1)
|
|||
|
|
const material = new THREE.MeshBasicMaterial({
|
|||
|
|
map: this.videoTexture
|
|||
|
|
})
|
|||
|
|
this.sphere = new THREE.Mesh(geometry, material)
|
|||
|
|
this.scene.add(this.sphere)
|
|||
|
|
}
|
|||
|
|
// 模型
|
|||
|
|
else if (space.type === 'model') {
|
|||
|
|
const loader = new GLTFLoader()
|
|||
|
|
loader.load(space.src, gltf => {
|
|||
|
|
this.sphere = gltf.scene
|
|||
|
|
this.sphere.scale.set(space.modelScale, space.modelScale, space.modelScale)
|
|||
|
|
this.scene.add(this.sphere)
|
|||
|
|
this.initMiniModel(gltf.scene.clone(), space.modelScale)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Hotspots
|
|||
|
|
if (space.hotspots && space.hotspots.length) {
|
|||
|
|
space.hotspots.forEach(h => {
|
|||
|
|
const spriteMap = new THREE.TextureLoader().load('/static/icons/hotspot.png')
|
|||
|
|
const spriteMaterial = new THREE.SpriteMaterial({
|
|||
|
|
map: spriteMap
|
|||
|
|
})
|
|||
|
|
const sprite = new THREE.Sprite(spriteMaterial)
|
|||
|
|
const phi = THREE.MathUtils.degToRad(90 - h.position.lat)
|
|||
|
|
const theta = THREE.MathUtils.degToRad(h.position.lon)
|
|||
|
|
const radius = 500
|
|||
|
|
sprite.position.set(
|
|||
|
|
radius * Math.sin(phi) * Math.cos(theta),
|
|||
|
|
radius * Math.cos(phi),
|
|||
|
|
radius * Math.sin(phi) * Math.sin(theta)
|
|||
|
|
)
|
|||
|
|
sprite.userData.targetId = h.targetId
|
|||
|
|
this.scene.add(sprite)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
animate() {
|
|||
|
|
requestAnimationFrame(this.animate)
|
|||
|
|
// 相机旋转
|
|||
|
|
const phi = THREE.MathUtils.degToRad(90 - this.lat)
|
|||
|
|
const theta = THREE.MathUtils.degToRad(this.lon)
|
|||
|
|
this.camera.target = new THREE.Vector3(
|
|||
|
|
500 * Math.sin(phi) * Math.cos(theta),
|
|||
|
|
500 * Math.cos(phi),
|
|||
|
|
500 * Math.sin(phi) * Math.sin(theta)
|
|||
|
|
)
|
|||
|
|
this.camera.lookAt(this.camera.target)
|
|||
|
|
this.renderer.render(this.scene, this.camera)
|
|||
|
|
|
|||
|
|
// mini 模型同步旋转
|
|||
|
|
if (this.currentSpace.type === 'model' && this.miniModel) {
|
|||
|
|
this.miniModel.rotation.y = this.camera.rotation.y
|
|||
|
|
this.miniRenderer.render(this.miniScene, this.miniCamera)
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// ==================== Mini 3D 模型 ====================
|
|||
|
|
initMiniModel(model, scale) {
|
|||
|
|
const canvasQuery = uni.createSelectorQuery()
|
|||
|
|
canvasQuery.select('#miniCanvas').node().exec(res => {
|
|||
|
|
const canvasNode = res[0].node
|
|||
|
|
this.miniRenderer = new THREE.WebGLRenderer({
|
|||
|
|
canvas: canvasNode,
|
|||
|
|
antialias: true,
|
|||
|
|
alpha: true
|
|||
|
|
})
|
|||
|
|
this.miniRenderer.setSize(150, 150)
|
|||
|
|
this.miniScene = new THREE.Scene()
|
|||
|
|
this.miniCamera = new THREE.PerspectiveCamera(75, 1, 0.1, 2000)
|
|||
|
|
this.miniCamera.position.set(0, 50, 100)
|
|||
|
|
this.miniModel = model
|
|||
|
|
this.miniScene.add(this.miniModel)
|
|||
|
|
})
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// ==================== 空间切换 ====================
|
|||
|
|
switchSpace(id) {
|
|||
|
|
const index = this.spaces.findIndex(s => s.id === id)
|
|||
|
|
if (index >= 0) {
|
|||
|
|
this.currentIndex = index
|
|||
|
|
this.currentSpace = this.spaces[index]
|
|||
|
|
this.loadSpace(this.currentSpace)
|
|||
|
|
this.showSpaceList = false
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
prevSpace() {
|
|||
|
|
this.currentIndex = (this.currentIndex - 1 + this.spaces.length) % this.spaces.length;
|
|||
|
|
this.switchSpace(this.spaces[this.currentIndex].id)
|
|||
|
|
},
|
|||
|
|
nextSpace() {
|
|||
|
|
this.currentIndex = (this.currentIndex + 1) % this.spaces.length;
|
|||
|
|
this.switchSpace(this.spaces[this.currentIndex].id)
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// ==================== 手势 ====================
|
|||
|
|
onTouchStart(e) {
|
|||
|
|
this.isDragging = true;
|
|||
|
|
const t = e.touches[0];
|
|||
|
|
this.lastX = t.clientX;
|
|||
|
|
this.lastY = t.clientY
|
|||
|
|
},
|
|||
|
|
onTouchMove(e) {
|
|||
|
|
if (!this.isDragging) return;
|
|||
|
|
const t = e.touches[0];
|
|||
|
|
const dx = t.clientX - this.lastX;
|
|||
|
|
const dy = t.clientY - this.lastY;
|
|||
|
|
this.lastX = t.clientX;
|
|||
|
|
this.lastY = t.clientY;
|
|||
|
|
this.lon -= dx * 0.1;
|
|||
|
|
this.lat += dy * 0.1;
|
|||
|
|
this.lat = Math.max(-85, Math.min(85, this.lat))
|
|||
|
|
},
|
|||
|
|
onTouchEnd() {
|
|||
|
|
this.isDragging = false
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// ==================== 工具栏功能示例 ====================
|
|||
|
|
resetView() {
|
|||
|
|
this.lon = 0;
|
|||
|
|
this.lat = 0
|
|||
|
|
},
|
|||
|
|
toggleFullScreen() {
|
|||
|
|
uni.setScreenBrightness({
|
|||
|
|
value: 1
|
|||
|
|
})
|
|||
|
|
}, // 示例
|
|||
|
|
takePhoto() {
|
|||
|
|
console.log('拍照功能可扩展')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.vr-page {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
background: #000;
|
|||
|
|
position: relative
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vr-canvas {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
display: block
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.mini-canvas {
|
|||
|
|
position: absolute;
|
|||
|
|
top: 20rpx;
|
|||
|
|
left: 20rpx;
|
|||
|
|
width: 150rpx;
|
|||
|
|
height: 150rpx;
|
|||
|
|
z-index: 10;
|
|||
|
|
border: 1px solid #ccc;
|
|||
|
|
border-radius: 6rpx
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.back-btn {
|
|||
|
|
position: absolute;
|
|||
|
|
top: 40rpx;
|
|||
|
|
left: 20rpx;
|
|||
|
|
color: #fff;
|
|||
|
|
font-size: 28rpx;
|
|||
|
|
z-index: 10
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.bottom-bar {
|
|||
|
|
position: absolute;
|
|||
|
|
bottom: 0;
|
|||
|
|
width: 100%;
|
|||
|
|
height: 80rpx;
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
padding: 0 20rpx;
|
|||
|
|
z-index: 10
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.bottom-bar .left,
|
|||
|
|
.bottom-bar .right {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.space-list {
|
|||
|
|
position: absolute;
|
|||
|
|
bottom: 80rpx;
|
|||
|
|
left: 0;
|
|||
|
|
width: 100%;
|
|||
|
|
height: 150rpx;
|
|||
|
|
background: rgba(0, 0, 0, 0.7);
|
|||
|
|
display: flex;
|
|||
|
|
overflow-x: scroll;
|
|||
|
|
padding: 10rpx
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.space-list .space-item {
|
|||
|
|
margin-right: 10rpx;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.space-list image {
|
|||
|
|
width: 120rpx;
|
|||
|
|
height: 80rpx;
|
|||
|
|
border-radius: 6rpx
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tools-panel {
|
|||
|
|
position: absolute;
|
|||
|
|
bottom: 80rpx;
|
|||
|
|
right: 0;
|
|||
|
|
width: 200rpx;
|
|||
|
|
background: rgba(0, 0, 0, 0.7);
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
padding: 10rpx
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tools-panel button {
|
|||
|
|
margin-bottom: 10rpx;
|
|||
|
|
color: #fff
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.switch-btn {
|
|||
|
|
position: absolute;
|
|||
|
|
top: 50%;
|
|||
|
|
transform: translateY(-50%);
|
|||
|
|
z-index: 10;
|
|||
|
|
color: #fff;
|
|||
|
|
font-size: 60rpx;
|
|||
|
|
background: rgba(0, 0, 0, 0.3);
|
|||
|
|
width: 80rpx;
|
|||
|
|
height: 80rpx;
|
|||
|
|
line-height: 80rpx;
|
|||
|
|
text-align: center;
|
|||
|
|
border-radius: 50%
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.switch-btn.left {
|
|||
|
|
left: 20rpx
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.switch-btn.right {
|
|||
|
|
right: 20rpx
|
|||
|
|
}
|
|||
|
|
</style>
|