chore: initialize frontend miniapp repository

This commit is contained in:
lyf
2026-06-09 21:08:45 +08:00
commit a90f63cef0
107 changed files with 60454 additions and 0 deletions

View File

@@ -0,0 +1,831 @@
<template>
<view class="three-map-container">
<view ref="containerRef" class="three-canvas-wrapper"></view>
<view v-if="isLoading && !loadError" class="loading-overlay">
<view class="loading-content">
<view class="loading-spinner"></view>
<text class="loading-text">{{ loadingMessage }}</text>
<view class="loading-progress">
<view class="loading-bar" :style="{ width: `${loadingProgress}%` }"></view>
</view>
</view>
</view>
<view v-else-if="loadError" class="loading-overlay error-overlay">
<view class="loading-content error-content">
<text class="error-title">3D 模型加载失败</text>
<text class="error-text">{{ loadingMessage }}</text>
<view class="retry-btn" @tap="retryLoad">
<text class="retry-text">重新加载</text>
</view>
</view>
</view>
<view v-if="showControls && floors.length" class="map-toolbar">
<view class="overview-btn" :class="{ active: activeView === 'overview' }" @tap="showOverview">
<text class="overview-text">全馆</text>
</view>
</view>
<FloorSwitcher
v-if="showControls && floors.length"
:floors="floors"
:current-floor="currentFloor"
@floor-change="handleFloorChange"
/>
<view v-if="showControls && selectedPOI" class="poi-detail-popup">
<view class="poi-content">
<text class="poi-name">{{ selectedPOI.name }}</text>
<text class="poi-floor">{{ formatFloorLabel(selectedPOI.floorId) }} · {{ selectedPOI.primaryCategoryZh }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import * as THREE from 'three'
import { GLTFLoader, type GLTF } from 'three/addons/loaders/GLTFLoader.js'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import FloorSwitcher from './FloorSwitcher.vue'
import { NAV_ASSET_BASE_URL } from '@/services/navAssets'
type ViewMode = 'overview' | 'floor'
interface ManifestFloorModel {
floorId: string
order: number
asset: string
}
interface CleanNavManifest {
status: string
assets: {
overviewModel: {
asset: string
}
floorModels: ManifestFloorModel[]
}
data: {
floorIndex: string
}
}
interface FloorIndexItem {
floorId: string
order: number
modelAsset: string
poiDataAsset: string
poiCount: number
}
interface FloorIndexData {
status: string
floors: FloorIndexItem[]
}
interface FloorOption {
id: string
label: string
}
interface CleanPoi {
id: string
name: string
floorId: string
primaryCategory: string
primaryCategoryZh: string
iconType: string
positionGltf?: [number, number, number]
}
interface FloorPoiData {
status: string
floorId: string
pois: CleanPoi[]
}
type ContainerRef = HTMLElement | { $el?: HTMLElement } | null
const props = withDefaults(defineProps<{
assetBaseUrl?: string
initialFloorId?: string
initialView?: ViewMode
showControls?: boolean
showPoi?: boolean
}>(), {
assetBaseUrl: NAV_ASSET_BASE_URL,
initialFloorId: 'L1',
initialView: 'overview',
showControls: true,
showPoi: false
})
const emit = defineEmits<{
floorChange: [floorId: string]
poiClick: [poi: CleanPoi]
}>()
const containerRef = ref<ContainerRef>(null)
const isLoading = ref(true)
const loadError = ref(false)
const loadingProgress = ref(0)
const loadingMessage = ref('正在读取室内导览资源...')
const activeView = ref<ViewMode>(props.initialView)
const currentFloor = ref(props.initialFloorId)
const selectedPOI = ref<CleanPoi | null>(null)
const floorIndex = ref<FloorIndexItem[]>([])
const floors = computed<FloorOption[]>(() => (
[...floorIndex.value]
.sort((a, b) => b.order - a.order)
.map((floor) => ({
id: floor.floorId,
label: formatFloorLabel(floor.floorId)
}))
))
let manifest: CleanNavManifest | null = null
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let renderer: THREE.WebGLRenderer | null = null
let controls: OrbitControls | null = null
let loader: GLTFLoader | null = null
let activeModel: THREE.Object3D | null = null
let poiGroup: THREE.Group | null = null
let animationId = 0
let resizeObserver: ResizeObserver | null = null
let isDisposed = false
const normalizeBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, '')
const assetUrl = (relativePath: string) => `${normalizeBaseUrl(props.assetBaseUrl)}/${relativePath.replace(/^\/+/, '')}`
const getContainerElement = () => {
const container = containerRef.value
if (!container) return null
if (typeof HTMLElement === 'undefined') return null
if (container instanceof HTMLElement) {
return container
}
const element = '$el' in container ? container.$el : null
return element instanceof HTMLElement ? element : null
}
const formatFloorLabel = (floorId: string) => {
if (floorId === 'L-2') return 'B2'
if (floorId === 'L-1') return 'B1'
if (floorId === 'L1.5') return '1.5F'
if (floorId.startsWith('L')) return `${floorId.slice(1)}F`
return floorId
}
const setProgress = (progress: number, message: string) => {
loadingProgress.value = Math.min(100, Math.max(0, progress))
loadingMessage.value = message
}
const fetchJson = async <T,>(url: string): Promise<T> => {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`资源读取失败: ${response.status} ${url}`)
}
return response.json() as Promise<T>
}
const waitForContainer = async () => {
await nextTick()
for (let index = 0; index < 20; index += 1) {
const container = getContainerElement()
if (container && container.clientWidth > 0 && container.clientHeight > 0) {
return container
}
await new Promise((resolve) => setTimeout(resolve, 50))
}
return getContainerElement()
}
const initThree = async () => {
const container = await waitForContainer()
if (!container) {
throw new Error('3D 容器未就绪')
}
scene = new THREE.Scene()
scene.background = new THREE.Color(0xf1f3ee)
const width = Math.max(1, container.clientWidth)
const height = Math.max(1, container.clientHeight)
camera = new THREE.PerspectiveCamera(42, width / height, 0.1, 10000)
camera.position.set(80, 80, 120)
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: 'high-performance'
})
renderer.setSize(width, height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 1.05
container.appendChild(renderer.domElement)
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.08
controls.enablePan = props.showControls
controls.enableZoom = true
controls.minDistance = 6
controls.maxDistance = 1200
loader = new GLTFLoader()
poiGroup = new THREE.Group()
poiGroup.name = 'CleanPackagePOI'
scene.add(poiGroup)
const ambientLight = new THREE.AmbientLight(0xffffff, 1.6)
scene.add(ambientLight)
const keyLight = new THREE.DirectionalLight(0xffffff, 2.2)
keyLight.position.set(80, 120, 60)
scene.add(keyLight)
const fillLight = new THREE.DirectionalLight(0xdfeaff, 0.9)
fillLight.position.set(-80, 60, -80)
scene.add(fillLight)
resizeObserver = new ResizeObserver(handleResize)
resizeObserver.observe(container)
container.addEventListener('pointerdown', handlePointerDown)
startRenderLoop()
}
const startRenderLoop = () => {
const render = () => {
if (isDisposed || !renderer || !scene || !camera) return
controls?.update()
renderer.render(scene, camera)
animationId = window.requestAnimationFrame(render)
}
render()
}
const handleResize = () => {
const container = getContainerElement()
if (!container || !camera || !renderer) return
const width = Math.max(1, container.clientWidth)
const height = Math.max(1, container.clientHeight)
camera.aspect = width / height
camera.updateProjectionMatrix()
renderer.setSize(width, height)
}
const disposeObject = (object: THREE.Object3D) => {
object.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry?.dispose()
const materials = Array.isArray(child.material) ? child.material : [child.material]
materials.forEach((material) => material?.dispose())
}
if (child instanceof THREE.Sprite) {
child.material.map?.dispose()
child.material.dispose()
}
})
}
const clearSceneData = () => {
if (activeModel && scene) {
scene.remove(activeModel)
disposeObject(activeModel)
}
activeModel = null
selectedPOI.value = null
if (poiGroup) {
while (poiGroup.children.length) {
const child = poiGroup.children[0]
poiGroup.remove(child)
disposeObject(child)
}
}
}
const loadModel = (url: string, label: string) => new Promise<GLTF>((resolve, reject) => {
if (!loader) {
reject(new Error('GLTF 加载器未初始化'))
return
}
loader.load(
url,
resolve,
(event) => {
if (event.total > 0) {
const modelProgress = Math.round((event.loaded / event.total) * 70)
setProgress(20 + modelProgress, `${label}: ${Math.round((event.loaded / event.total) * 100)}%`)
} else {
setProgress(45, label)
}
},
reject
)
})
const prepareModel = (model: THREE.Object3D) => {
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = false
child.receiveShadow = true
child.frustumCulled = false
}
})
}
const fitCameraToObject = (object: THREE.Object3D) => {
if (!camera || !controls) return
const box = new THREE.Box3().setFromObject(object)
const center = box.getCenter(new THREE.Vector3())
const size = box.getSize(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z, 1)
const fov = camera.fov * (Math.PI / 180)
const distance = Math.abs(maxDim / Math.sin(fov / 2)) * 0.58
const direction = new THREE.Vector3(0.85, 0.62, 1).normalize()
camera.near = Math.max(0.1, maxDim / 1000)
camera.far = Math.max(10000, maxDim * 20)
camera.position.copy(center).add(direction.multiplyScalar(distance))
camera.updateProjectionMatrix()
controls.target.copy(center)
controls.minDistance = Math.max(2, maxDim * 0.08)
controls.maxDistance = Math.max(20, maxDim * 8)
controls.update()
}
const loadOverview = async () => {
if (!manifest || !scene) return
activeView.value = 'overview'
clearSceneData()
setProgress(18, '正在加载全馆三维模型...')
const gltf = await loadModel(assetUrl(manifest.assets.overviewModel.asset), '正在加载全馆三维模型')
activeModel = gltf.scene
activeModel.name = 'CleanOverviewModel'
prepareModel(activeModel)
scene.add(activeModel)
fitCameraToObject(activeModel)
}
const loadFloor = async (floorId: string) => {
const floor = floorIndex.value.find((item) => item.floorId === floorId)
if (!floor || !scene) return
activeView.value = 'floor'
currentFloor.value = floor.floorId
clearSceneData()
setProgress(18, `正在加载 ${formatFloorLabel(floor.floorId)} 模型...`)
const gltf = await loadModel(assetUrl(floor.modelAsset), `正在加载 ${formatFloorLabel(floor.floorId)} 模型`)
activeModel = gltf.scene
activeModel.name = `CleanFloorModel_${floor.floorId}`
prepareModel(activeModel)
scene.add(activeModel)
fitCameraToObject(activeModel)
if (props.showPoi || props.showControls) {
await loadFloorPOIs(floor)
}
}
const createPoiMaterial = (poi: CleanPoi) => {
const canvas = document.createElement('canvas')
canvas.width = 96
canvas.height = 96
const context = canvas.getContext('2d')
if (context) {
const color = getPoiColor(poi.primaryCategory)
context.clearRect(0, 0, canvas.width, canvas.height)
context.beginPath()
context.arc(48, 42, 24, 0, Math.PI * 2)
context.fillStyle = color
context.fill()
context.lineWidth = 6
context.strokeStyle = '#ffffff'
context.stroke()
context.beginPath()
context.moveTo(48, 76)
context.lineTo(36, 54)
context.lineTo(60, 54)
context.closePath()
context.fillStyle = color
context.fill()
}
const texture = new THREE.CanvasTexture(canvas)
texture.colorSpace = THREE.SRGBColorSpace
return new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: false,
depthWrite: false
})
}
const getPoiColor = (category: string) => {
const colorMap: Record<string, string> = {
touring_poi: '#2f6fed',
basic_service_facility: '#1f8f5f',
transport_circulation: '#d77b20',
accessibility_special_service: '#8a5cf6',
operation_experience: '#cf3f59'
}
return colorMap[category] || '#1f2329'
}
const loadFloorPOIs = async (floor: FloorIndexItem) => {
if (!poiGroup) return
const data = await fetchJson<FloorPoiData>(assetUrl(floor.poiDataAsset))
const validPois = data.pois.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
const markerSize = activeModel ? Math.max(new THREE.Box3().setFromObject(activeModel).getSize(new THREE.Vector3()).length() * 0.012, 2.4) : 3
validPois.forEach((poi) => {
const [x, y, z] = poi.positionGltf!
const sprite = new THREE.Sprite(createPoiMaterial(poi))
sprite.position.set(x, y + markerSize * 0.5, z)
sprite.scale.set(markerSize, markerSize, markerSize)
sprite.renderOrder = 10
sprite.userData.poi = poi
poiGroup!.add(sprite)
})
}
const handlePointerDown = (event: PointerEvent) => {
if (!props.showControls || !camera || !renderer || !poiGroup || !getContainerElement()) return
const rect = renderer.domElement.getBoundingClientRect()
const pointer = new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1
)
const raycaster = new THREE.Raycaster()
raycaster.setFromCamera(pointer, camera)
const hits = raycaster.intersectObjects(poiGroup.children, false)
if (hits[0]?.object.userData.poi) {
selectedPOI.value = hits[0].object.userData.poi as CleanPoi
emit('poiClick', selectedPOI.value)
}
}
const loadCleanPackage = async () => {
setProgress(8, '正在读取室内导览资源...')
manifest = await fetchJson<CleanNavManifest>(assetUrl('app_nav_manifest.json'))
if (manifest.status !== 'pass') {
throw new Error('室内导览资源包状态不是 pass')
}
setProgress(14, '正在读取楼层索引...')
const floorData = await fetchJson<FloorIndexData>(assetUrl(manifest.data.floorIndex))
floorIndex.value = [...floorData.floors].sort((a, b) => a.order - b.order)
if (!floorIndex.value.some((floor) => floor.floorId === currentFloor.value)) {
currentFloor.value = floorIndex.value.find((floor) => floor.floorId === 'L1')?.floorId || floorIndex.value[0]?.floorId || 'L1'
}
if (props.initialView === 'floor') {
await loadFloor(currentFloor.value)
return
}
await loadOverview()
}
const init3DScene = async () => {
try {
disposeScene()
isDisposed = false
isLoading.value = true
loadError.value = false
selectedPOI.value = null
setProgress(0, '正在初始化三维场景...')
await initThree()
await loadCleanPackage()
setProgress(100, '室内三维模型加载完成')
isLoading.value = false
} catch (error) {
console.error('室内 3D clean package 加载失败:', error)
loadError.value = true
isLoading.value = false
setProgress(0, error instanceof Error ? error.message : '请检查模型资源或网络状态后重试')
}
}
const handleFloorChange = async (floorId: string) => {
try {
isLoading.value = true
loadError.value = false
await loadFloor(floorId)
isLoading.value = false
emit('floorChange', floorId)
} catch (error) {
console.error('楼层模型加载失败:', error)
loadError.value = true
isLoading.value = false
setProgress(0, error instanceof Error ? error.message : '楼层模型加载失败')
}
}
const showOverview = async () => {
try {
isLoading.value = true
loadError.value = false
await loadOverview()
isLoading.value = false
} catch (error) {
console.error('全馆模型加载失败:', error)
loadError.value = true
isLoading.value = false
setProgress(0, error instanceof Error ? error.message : '全馆模型加载失败')
}
}
const retryLoad = () => {
init3DScene()
}
const disposeScene = () => {
isDisposed = true
if (animationId) {
window.cancelAnimationFrame(animationId)
animationId = 0
}
const container = getContainerElement()
container?.removeEventListener('pointerdown', handlePointerDown)
resizeObserver?.disconnect()
resizeObserver = null
controls?.dispose()
controls = null
clearSceneData()
scene?.clear()
scene = null
camera = null
loader = null
poiGroup = null
if (renderer) {
renderer.dispose()
renderer.forceContextLoss()
renderer.domElement.remove()
}
renderer = null
}
defineExpose({
switchFloor: handleFloorChange,
showOverview,
clearNavigation: () => {
selectedPOI.value = null
}
})
watch(() => props.assetBaseUrl, () => {
init3DScene()
})
onMounted(() => {
init3DScene()
})
onUnmounted(() => {
disposeScene()
})
</script>
<style scoped lang="scss">
.three-map-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #f1f3ee;
}
.three-canvas-wrapper {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
:deep(canvas) {
width: 100% !important;
height: 100% !important;
display: block;
touch-action: none;
}
}
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(241, 243, 238, 0.9);
z-index: 20;
}
.loading-content {
width: min(260px, calc(100vw - 80px));
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.loading-spinner {
width: 34px;
height: 34px;
border: 3px solid rgba(31, 35, 41, 0.16);
border-top-color: #1f2329;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
font-size: 13px;
line-height: 18px;
color: #1f2329;
text-align: center;
}
.loading-progress {
width: 100%;
height: 4px;
overflow: hidden;
background: rgba(31, 35, 41, 0.12);
border-radius: 2px;
}
.loading-bar {
height: 100%;
background: #1f8f5f;
transition: width 0.2s ease;
}
.error-overlay {
background: rgba(31, 35, 41, 0.28);
}
.error-content {
padding: 18px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(31, 35, 41, 0.1);
border-radius: 8px;
}
.error-title {
font-size: 16px;
line-height: 22px;
font-weight: 700;
color: #1f2329;
}
.error-text {
font-size: 13px;
line-height: 18px;
color: #5f666d;
text-align: center;
}
.retry-btn {
min-width: 96px;
height: 34px;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #1f2329;
border-radius: 6px;
}
.retry-text {
font-size: 13px;
line-height: 18px;
font-weight: 600;
color: #ffffff;
}
.map-toolbar {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
z-index: 12;
}
.overview-btn {
height: 34px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(31, 35, 41, 0.12);
border-radius: 6px;
box-shadow: 0 4px 14px rgba(31, 35, 41, 0.12);
}
.overview-btn.active {
background: #1f2329;
}
.overview-text {
font-size: 12px;
line-height: 16px;
font-weight: 700;
color: #1f2329;
}
.overview-btn.active .overview-text {
color: #ffffff;
}
.poi-detail-popup {
position: absolute;
left: 50%;
bottom: 118px;
width: min(280px, calc(100vw - 64px));
padding: 12px 14px;
transform: translateX(-50%);
box-sizing: border-box;
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(31, 35, 41, 0.1);
border-radius: 8px;
box-shadow: 0 10px 28px rgba(31, 35, 41, 0.16);
z-index: 14;
}
.poi-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.poi-name {
font-size: 15px;
line-height: 20px;
font-weight: 700;
color: #1f2329;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.poi-floor {
font-size: 12px;
line-height: 16px;
color: #5f666d;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>