feat: wire guide data source and POI focus UI

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lyf
2026-06-12 09:25:22 +08:00
parent a6bfda30e1
commit 2055b13b90
58 changed files with 5768 additions and 1898 deletions

View File

@@ -0,0 +1,123 @@
import type {
MuseumExhibit,
MuseumHall
} from '@/domain/museum'
export interface MuseumContentProvider {
listExhibits(): Promise<MuseumExhibit[]>
listHalls(): Promise<MuseumHall[]>
}
const transitionalHalls: MuseumHall[] = [
{
id: 'hall-evolution',
name: '演化厅',
floorId: 'L-1',
floorLabel: 'B1',
description: '自然演化主题内容。当前为内容仓储过渡数据,待 CMS/API 接入后替换。',
image: '/static/hall-placeholder.jpg',
exhibitCount: 3,
area: '待补充'
},
{
id: 'hall-dinosaur',
name: '恐龙厅',
floorId: 'L2',
floorLabel: '2F',
description: '恐龙与古生物主题内容。当前为内容仓储过渡数据,待 CMS/API 接入后替换。',
image: '/static/hall-placeholder.jpg',
exhibitCount: 2,
area: '待补充'
},
{
id: 'hall-biology',
name: '生物厅',
floorId: 'L3',
floorLabel: '3F',
description: '生物多样性主题内容。当前为内容仓储过渡数据,待 CMS/API 接入后替换。',
image: '/static/hall-placeholder.jpg',
exhibitCount: 2,
area: '待补充'
}
]
const transitionalExhibits: MuseumExhibit[] = [
{
id: 'exhibit-ginkgo-fossil',
name: '银杏化石',
hallId: 'hall-evolution',
hallName: '演化厅',
floorId: 'L-1',
floorLabel: 'B1',
image: '/static/exhibit-placeholder.jpg',
description: '银杏化石内容待由正式内容库补充。当前仅用于讲解业务架构迁移。',
tags: ['化石', '古植物']
},
{
id: 'exhibit-trilobite-fossil',
name: '三叶虫化石',
hallId: 'hall-evolution',
hallName: '演化厅',
floorId: 'L-1',
floorLabel: 'B1',
image: '/static/exhibit-placeholder.jpg',
description: '三叶虫化石内容待由正式内容库补充。当前仅用于讲解业务架构迁移。',
tags: ['化石', '古生物']
},
{
id: 'exhibit-brachiosaurus',
name: '腕龙骨骼',
hallId: 'hall-dinosaur',
hallName: '恐龙厅',
floorId: 'L2',
floorLabel: '2F',
image: '/static/exhibit-placeholder.jpg',
description: '腕龙骨骼内容待由正式内容库补充。当前仅用于讲解业务架构迁移。',
tags: ['恐龙', '骨骼']
},
{
id: 'exhibit-archaeopteryx',
name: '始祖鸟标本',
hallId: 'hall-evolution',
hallName: '演化厅',
floorId: 'L-1',
floorLabel: 'B1',
image: '/static/exhibit-placeholder.jpg',
description: '始祖鸟标本内容待由正式内容库补充。当前仅用于讲解业务架构迁移。',
tags: ['演化', '标本']
},
{
id: 'exhibit-mammoth-tusk',
name: '猛犸象象牙',
hallId: 'hall-biology',
hallName: '生物厅',
floorId: 'L3',
floorLabel: '3F',
image: '/static/exhibit-placeholder.jpg',
description: '猛犸象象牙内容待由正式内容库补充。当前仅用于讲解业务架构迁移。',
tags: ['古生物', '标本']
},
{
id: 'exhibit-coral-reef',
name: '珊瑚礁标本',
hallId: 'hall-biology',
hallName: '生物厅',
floorId: 'L3',
floorLabel: '3F',
image: '/static/exhibit-placeholder.jpg',
description: '珊瑚礁标本内容待由正式内容库补充。当前仅用于讲解业务架构迁移。',
tags: ['海洋生物', '标本']
}
]
export class StaticMuseumContentProvider implements MuseumContentProvider {
async listExhibits() {
return transitionalExhibits
}
async listHalls() {
return transitionalHalls
}
}
export const staticMuseumContentProvider = new StaticMuseumContentProvider()

View File

@@ -0,0 +1,214 @@
import {
NAV_ASSET_BASE_URL
} from '@/domain/guideReadiness'
export interface StaticNavPoiCategoryPayload {
topCategory: string
topCategoryZh: string
subcategory: string
iconType: string
reason?: string
}
export interface StaticNavPoiPayload {
id: string
name: string
floorId: string
primaryCategory: string
primaryCategoryZh: string
categories?: StaticNavPoiCategoryPayload[]
iconType?: string
positionGltf?: [number, number, number]
navigationReadiness?: string
sourceConfidence?: string
}
export interface StaticNavManifestFloorModelPayload {
floorId: string
order: number
asset: string
}
export interface StaticNavManifestPayload {
status: string
assets: {
overviewModel: {
asset: string
}
floorModels: StaticNavManifestFloorModelPayload[]
}
data: {
floorIndex: string
}
}
export interface StaticNavFloorIndexItemPayload {
floorId: string
order: number
modelAsset: string
poiDataAsset: string
poiCount: number
}
export interface StaticNavFloorIndexPayload {
status: string
floors: StaticNavFloorIndexItemPayload[]
}
interface StaticFloorPoiPayload {
status: string
floorId: string
pois: StaticNavPoiPayload[]
}
interface StaticPoiIndexPayload {
status: string
pois: StaticNavPoiPayload[]
}
const normalizeBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, '')
const parseJsonPayload = <T>(payload: unknown): T => {
if (typeof payload === 'string') {
return JSON.parse(payload) as T
}
return payload as T
}
const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject) => {
uni.request({
url,
method: 'GET',
success: (response) => {
const statusCode = Number(response.statusCode || 0)
if (statusCode < 200 || statusCode >= 300) {
reject(new Error(`导览资源读取失败: ${statusCode} ${url}`))
return
}
try {
resolve(parseJsonPayload<T>(response.data))
} catch (error) {
reject(error)
}
},
fail: reject
})
})
export interface StaticNavAssetsProvider {
readonly baseUrl: string
assetUrl(relativePath: string): string
loadManifest(): Promise<StaticNavManifestPayload>
loadFloorIndex(): Promise<StaticNavFloorIndexPayload>
loadPoiIndex(): Promise<StaticNavPoiPayload[]>
loadFloorPois(relativePath: string): Promise<StaticNavPoiPayload[]>
}
export const createStaticNavAssetsProvider = (
baseUrl = NAV_ASSET_BASE_URL
): StaticNavAssetsProvider => {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl)
let poiCache: StaticNavPoiPayload[] | null = null
let poiRequest: Promise<StaticNavPoiPayload[]> | null = null
let manifestCache: StaticNavManifestPayload | null = null
let manifestRequest: Promise<StaticNavManifestPayload> | null = null
let floorIndexCache: StaticNavFloorIndexPayload | null = null
let floorIndexRequest: Promise<StaticNavFloorIndexPayload> | null = null
const floorPoiCache = new Map<string, StaticNavPoiPayload[]>()
const floorPoiRequests = new Map<string, Promise<StaticNavPoiPayload[]>>()
const provider: StaticNavAssetsProvider = {
baseUrl: normalizedBaseUrl,
assetUrl(relativePath: string) {
return `${normalizedBaseUrl}/${relativePath.replace(/^\/+/, '')}`
},
async loadManifest() {
if (manifestCache) return manifestCache
if (manifestRequest) return manifestRequest
manifestRequest = requestJson<StaticNavManifestPayload>(provider.assetUrl('app_nav_manifest.json'))
.then((data) => {
if (data.status !== 'pass') {
throw new Error('室内导览资源包状态不是 pass')
}
manifestCache = data
return manifestCache
})
.finally(() => {
manifestRequest = null
})
return manifestRequest
},
async loadFloorIndex() {
if (floorIndexCache) return floorIndexCache
if (floorIndexRequest) return floorIndexRequest
floorIndexRequest = provider.loadManifest()
.then((manifest) => requestJson<StaticNavFloorIndexPayload>(provider.assetUrl(manifest.data.floorIndex)))
.then((data) => {
if (data.status !== 'pass') {
throw new Error('导览楼层索引状态不是 pass')
}
floorIndexCache = data
return floorIndexCache
})
.finally(() => {
floorIndexRequest = null
})
return floorIndexRequest
},
async loadPoiIndex() {
if (poiCache) return poiCache
if (poiRequest) return poiRequest
poiRequest = requestJson<StaticPoiIndexPayload>(provider.assetUrl('data/poi_all.json'))
.then((data) => {
if (data.status !== 'pass') {
throw new Error('导览 POI 数据状态不是 pass')
}
poiCache = data.pois
return poiCache
})
.finally(() => {
poiRequest = null
})
return poiRequest
},
async loadFloorPois(relativePath: string) {
const cacheKey = relativePath.replace(/^\/+/, '')
const cached = floorPoiCache.get(cacheKey)
if (cached) return cached
const pending = floorPoiRequests.get(cacheKey)
if (pending) return pending
const request = requestJson<StaticFloorPoiPayload>(provider.assetUrl(cacheKey))
.then((data) => {
if (data.status !== 'pass') {
throw new Error('导览楼层 POI 数据状态不是 pass')
}
floorPoiCache.set(cacheKey, data.pois)
return data.pois
})
.finally(() => {
floorPoiRequests.delete(cacheKey)
})
floorPoiRequests.set(cacheKey, request)
return request
}
}
return provider
}
export const defaultStaticNavAssetsProvider = createStaticNavAssetsProvider()