完成大体功能和样式

This commit is contained in:
2026-01-15 17:18:24 +08:00
parent 036eb3a206
commit 44a4b33502
211 changed files with 5480 additions and 7826 deletions

438
pages-biz/vr/vr.vue Normal file
View File

@@ -0,0 +1,438 @@
<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>