chore: freeze guide and explain update

This commit is contained in:
lyf
2026-06-24 18:00:25 +08:00
parent feb7310a46
commit 67c6609ae6
104 changed files with 3203572 additions and 40713 deletions

View File

@@ -0,0 +1,444 @@
import {
dataSourceConfig
} from '@/config/dataSource'
export interface GuideStaticPackagePayload<T> {
schemaVersion: string
sourceTable?: string
rowCount?: number
rows: T[]
}
export interface GuideStaticManifestPayload {
schemaVersion: string
generatedAt: string
source: {
type: string
host: string
port: string
schema: string
configPath: string
}
files: {
halls: string
outlines: string
guideStops: string
exhibits: string
guideContents: string
pois: string
indexes: string
poiBridge?: string
}
counts: Record<string, number>
bridgeStats?: Record<string, number>
}
export interface GuideStaticHallPayload {
id: number | string
mapId?: number | string | null
floorId?: number | string | null
floorCode?: string | null
poiId?: number | string | null
hallCode?: string | null
name?: string | null
nameEn?: string | null
subtitle?: string | null
description?: string | null
descriptionEn?: string | null
narrationText?: string | null
coverImageUrl?: string | null
galleryUrls?: string | null
audioUrl?: string | null
audioDuration?: number | null
videoUrl?: string | null
areaSqm?: number | null
exhibitCount?: number | null
openTime?: string | null
sortOrder?: number | null
status?: number | boolean | null
deleted?: number | boolean | null
}
export interface GuideStaticOutlinePayload {
id: number | string
parentId?: number | string | null
name?: string | null
code?: string | null
description?: string | null
sort?: number | null
deleted?: number | boolean | null
}
export interface GuideStaticStopPayload {
id: number | string
outlineId?: number | string | null
name?: string | null
sort?: number | null
coverImageUrl?: string | null
audioUrl?: string | null
poiId?: number | string | null
status?: number | boolean | null
mapX?: number | null
mapY?: number | null
floorId?: number | string | null
floorCode?: string | null
deleted?: number | boolean | null
}
export interface GuideStaticExhibitPayload {
id: number | string
hallId?: number | string | null
outlineId?: number | string | null
stopId?: number | string | null
spatialNodeId?: number | string | null
poiId?: number | string | null
exhibitCode?: string | null
name?: string | null
nameEn?: string | null
scientificName?: string | null
category?: string | null
era?: string | null
origin?: string | null
dimensions?: string | null
description?: string | null
descriptionEn?: string | null
narrationText?: string | null
coverImageUrl?: string | null
galleryUrls?: string | null
audioUrl?: string | null
audioDuration?: number | null
videoUrl?: string | null
model3dUrl?: string | null
qrCodeUrl?: string | null
isHighlight?: boolean | number | null
exhibitLevel?: string | null
dataSource?: string | null
collectionId?: string | null
viewCount?: number | null
sortOrder?: number | null
status?: number | boolean | null
deleted?: number | boolean | null
}
export interface GuideStaticContentPayload {
id: number | string
targetType?: string | null
targetId?: number | string | null
exhibitId?: number | string | null
poiId?: number | string | null
title?: string | null
contentType?: string | null
standardText?: string | null
standardTextEn?: string | null
extendedText?: string | null
extendedTextEn?: string | null
standardAudioUrl?: string | null
standardAudioUrlEn?: string | null
standardAudioDuration?: number | null
standardAudioDurationEn?: number | null
extendedAudioUrl?: string | null
extendedAudioUrlEn?: string | null
extendedAudioDuration?: number | null
extendedAudioDurationEn?: number | null
audioUrl?: string | null
audioDuration?: number | null
videoUrl?: string | null
mediaGallery?: string | null
aiTags?: string | null
sortOrder?: number | null
status?: number | boolean | null
deleted?: number | boolean | null
}
export interface GuideStaticPoiPayload {
id: number | string
mapId?: number | string | null
floorId?: number | string | null
name?: string | null
nameEn?: string | null
type?: string | null
exhibitCode?: string | null
x?: number | null
y?: number | null
z?: number | null
longitude?: number | null
latitude?: number | null
address?: string | null
iconUrl?: string | null
coverImageUrl?: string | null
description?: string | null
descriptionEn?: string | null
extParams?: string | null
externalUrl?: string | null
images?: string | null
spatialAreaId?: number | string | null
spatialAreaName?: string | null
sortOrder?: number | null
isGuidePoint?: boolean | number | null
status?: number | boolean | null
deleted?: number | boolean | null
}
export interface GuideStaticIndexesPayload {
schemaVersion: string
outlinesByHallId: Record<string, Array<number | string>>
stopsByOutlineId: Record<string, Array<number | string>>
exhibitsByHallId: Record<string, Array<number | string>>
exhibitsByOutlineId: Record<string, Array<number | string>>
exhibitsByStopId: Record<string, Array<number | string>>
guidesByExhibitId: Record<string, Array<number | string>>
guidesByTarget: Record<string, Array<number | string>>
poiById: Record<string, GuideStaticPoiPayload>
}
export interface GuideStaticPoiBridgeEntryPayload {
navPoiId?: string | null
navPoiName?: string | null
navFloorId?: string | null
method?: string | null
confidence?: string | null
distanceMeters?: number | null
sourcePoiId?: string | number | null
sourcePoiName?: string | null
}
export interface GuideStaticPoiBridgePayload {
schemaVersion: string
generatedAt?: string
source?: Record<string, unknown>
stats?: Record<string, number>
sgsFloorIdToNavFloorId?: Record<string, string>
hallToNavPoiId: Record<string, GuideStaticPoiBridgeEntryPayload>
sgsPoiToNavPoiId: Record<string, GuideStaticPoiBridgeEntryPayload>
sgsPoiCandidates: Record<string, GuideStaticPoiBridgeEntryPayload>
}
export interface GuideStaticDataset {
manifest: GuideStaticManifestPayload
halls: GuideStaticHallPayload[]
outlines: GuideStaticOutlinePayload[]
guideStops: GuideStaticStopPayload[]
exhibits: GuideStaticExhibitPayload[]
guideContents: GuideStaticContentPayload[]
pois: GuideStaticPoiPayload[]
indexes: GuideStaticIndexesPayload
poiBridge: GuideStaticPoiBridgePayload
}
export interface GuideStaticExplainDataset {
manifest: GuideStaticManifestPayload
halls: GuideStaticHallPayload[]
outlines: GuideStaticOutlinePayload[]
guideStops: GuideStaticStopPayload[]
guideContents: GuideStaticContentPayload[]
poiBridge: GuideStaticPoiBridgePayload
}
const emptyPoiBridge: GuideStaticPoiBridgePayload = {
schemaVersion: 'sgs-guide-poi-bridge/empty',
hallToNavPoiId: {},
sgsPoiToNavPoiId: {},
sgsPoiCandidates: {}
}
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 requestJsonByFetch = async <T>(url: string): Promise<T> => {
const response = await fetch(url, {
credentials: 'same-origin'
})
if (!response.ok) {
throw new Error(`导览静态数据读取失败: ${response.status} ${url}`)
}
return parseJsonPayload<T>(await response.text())
}
const requestJsonByUni = <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
})
})
const requestJson = <T>(url: string): Promise<T> => {
if (typeof fetch === 'function') {
return requestJsonByFetch<T>(url)
}
return requestJsonByUni<T>(url)
}
export interface StaticGuideDataProvider {
readonly baseUrl: string
assetUrl(relativePath: string): string
loadManifest(): Promise<GuideStaticManifestPayload>
loadIndexes(): Promise<GuideStaticIndexesPayload>
loadExplainDataset(): Promise<GuideStaticExplainDataset>
loadDataset(): Promise<GuideStaticDataset>
}
export const createStaticGuideDataProvider = (
baseUrl = dataSourceConfig.guideStaticDataBaseUrl
): StaticGuideDataProvider => {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl)
let manifestCache: GuideStaticManifestPayload | null = null
let manifestRequest: Promise<GuideStaticManifestPayload> | null = null
let indexesCache: GuideStaticIndexesPayload | null = null
let indexesRequest: Promise<GuideStaticIndexesPayload> | null = null
let explainDatasetCache: GuideStaticExplainDataset | null = null
let explainDatasetRequest: Promise<GuideStaticExplainDataset> | null = null
let datasetCache: GuideStaticDataset | null = null
let datasetRequest: Promise<GuideStaticDataset> | null = null
const provider: StaticGuideDataProvider = {
baseUrl: normalizedBaseUrl,
assetUrl(relativePath: string) {
return `${normalizedBaseUrl}/${relativePath.replace(/^\/+/, '')}`
},
async loadManifest() {
if (manifestCache) return manifestCache
if (manifestRequest) return manifestRequest
manifestRequest = requestJson<GuideStaticManifestPayload>(provider.assetUrl('manifest.json'))
.then((manifest) => {
manifestCache = manifest
return manifest
})
.finally(() => {
manifestRequest = null
})
return manifestRequest
},
async loadIndexes() {
if (indexesCache) return indexesCache
if (indexesRequest) return indexesRequest
indexesRequest = provider.loadManifest()
.then(async (manifest) => {
indexesCache = await requestJson<GuideStaticIndexesPayload>(provider.assetUrl(manifest.files.indexes))
return indexesCache
})
.finally(() => {
indexesRequest = null
})
return indexesRequest
},
async loadExplainDataset() {
if (explainDatasetCache) return explainDatasetCache
if (datasetCache) {
explainDatasetCache = {
manifest: datasetCache.manifest,
halls: datasetCache.halls,
outlines: datasetCache.outlines,
guideStops: datasetCache.guideStops,
guideContents: datasetCache.guideContents,
poiBridge: datasetCache.poiBridge
}
return explainDatasetCache
}
if (explainDatasetRequest) return explainDatasetRequest
explainDatasetRequest = provider.loadManifest()
.then(async (manifest) => {
const [
halls,
outlines,
guideStops,
guideContents,
poiBridge
] = await Promise.all([
requestJson<GuideStaticPackagePayload<GuideStaticHallPayload>>(provider.assetUrl(manifest.files.halls)),
requestJson<GuideStaticPackagePayload<GuideStaticOutlinePayload>>(provider.assetUrl(manifest.files.outlines)),
requestJson<GuideStaticPackagePayload<GuideStaticStopPayload>>(provider.assetUrl(manifest.files.guideStops)),
requestJson<GuideStaticPackagePayload<GuideStaticContentPayload>>(provider.assetUrl(manifest.files.guideContents)),
manifest.files.poiBridge
? requestJson<GuideStaticPoiBridgePayload>(provider.assetUrl(manifest.files.poiBridge))
: Promise.resolve(emptyPoiBridge)
])
explainDatasetCache = {
manifest,
halls: halls.rows,
outlines: outlines.rows,
guideStops: guideStops.rows,
guideContents: guideContents.rows,
poiBridge
}
return explainDatasetCache
})
.finally(() => {
explainDatasetRequest = null
})
return explainDatasetRequest
},
async loadDataset() {
if (datasetCache) return datasetCache
if (datasetRequest) return datasetRequest
datasetRequest = provider.loadExplainDataset()
.then(async (explainDataset) => {
const manifest = explainDataset.manifest
const [
exhibits,
pois,
indexes
] = await Promise.all([
requestJson<GuideStaticPackagePayload<GuideStaticExhibitPayload>>(provider.assetUrl(manifest.files.exhibits)),
requestJson<GuideStaticPackagePayload<GuideStaticPoiPayload>>(provider.assetUrl(manifest.files.pois)),
provider.loadIndexes()
])
datasetCache = {
manifest,
halls: explainDataset.halls,
outlines: explainDataset.outlines,
guideStops: explainDataset.guideStops,
exhibits: exhibits.rows,
guideContents: explainDataset.guideContents,
pois: pois.rows,
indexes,
poiBridge: explainDataset.poiBridge
}
return datasetCache
})
.finally(() => {
datasetRequest = null
})
return datasetRequest
}
}
return provider
}
export const staticGuideDataProvider = createStaticGuideDataProvider()

View File

@@ -1,7 +1,25 @@
import type {
ExplainTrack,
MediaAsset,
MuseumExhibit,
MuseumHall
} from '@/domain/museum'
import {
dataSourceConfig,
isGuideContentMockMode,
isGuideContentRemoteMode,
isGuideContentStaticMode
} from '@/config/dataSource'
import {
createGuideExplainDataAdapter,
createGuideContentDataAdapter,
type GuideContentDataAdapterResult,
type GuideExplainDataAdapterResult
} from '@/data/adapters/guideDataAdapter'
import {
staticGuideDataProvider,
type StaticGuideDataProvider
} from '@/data/providers/staticGuideDataProvider'
import {
SGS_SCENE_EXPLAIN_DATASET,
type SgsSceneExhibitMock,
@@ -13,6 +31,13 @@ export interface MuseumContentProvider {
listHalls(): Promise<MuseumHall[]>
}
export interface ExplainContentProvider extends MuseumContentProvider {
listExplainExhibits(): Promise<MuseumExhibit[]>
listTracks(): Promise<ExplainTrack[]>
getMediaById(id: string): Promise<MediaAsset | null>
getMediaForExplainTrack(trackId: string): Promise<MediaAsset | null>
}
const normalizeContentId = (prefix: string, value: string | number) => `${prefix}-sgs-${value}`
const categoryLabelMap: Record<SgsSceneSpaceMock['spaceCategory'], string> = {
@@ -25,61 +50,24 @@ const categoryLabelMap: Record<SgsSceneSpaceMock['spaceCategory'], string> = {
BACK_OFFICE: '后勤空间'
}
const boundaryLabelMap: Record<NonNullable<SgsSceneSpaceMock['boundaryStatus']>, string> = {
DEFINED: '已定义空间边界',
UNDEFINED: '空间边界待完善'
}
const publicSceneSpaces = SGS_SCENE_EXPLAIN_DATASET.spaces.filter((space) => space.isPublic)
const publicSpaceById = new Map(publicSceneSpaces.map((space) => [space.id, space]))
const formatArea = (area?: number) => (typeof area === 'number' ? `${area}` : undefined)
const hallDescription = (space: SgsSceneSpaceMock) => {
const category = categoryLabelMap[space.spaceCategory]
const area = formatArea(space.area)
const boundary = space.boundaryStatus ? boundaryLabelMap[space.boundaryStatus] : undefined
const facts = [space.floorLabel, category, area, boundary].filter(Boolean).join(' · ')
return `${space.name}来自 SGS 前端地图项目“场景设置”的空间管理数据。${facts ? `当前空间信息:${facts}` : ''}讲解业务据此展示展厅/展区内容,并通过稳定空间、展品与 POI ID 关联到位置预览。`
}
const exhibitDescription = (exhibit: SgsSceneExhibitMock, hall: SgsSceneSpaceMock | undefined) => {
const facts = [
exhibit.category,
exhibit.era,
exhibit.origin,
exhibit.zoneName
].filter(Boolean).join(' · ')
return [
exhibit.description,
facts ? `展陈信息:${facts}` : '',
hall ? `所属空间:${hall.name}${hall.floorLabel})。` : ''
].filter(Boolean).join('\n\n')
}
const tagsForExhibit = (exhibit: SgsSceneExhibitMock, hall: SgsSceneSpaceMock | undefined) => [
'SGS场景设置',
exhibit.category,
exhibit.zoneName,
exhibit.audioUrl ? '源数据含音频地址' : '图文讲解',
hall ? categoryLabelMap[hall.spaceCategory] : undefined
].filter(Boolean) as string[]
const toMuseumHall = (space: SgsSceneSpaceMock): MuseumHall => ({
const toMockMuseumHall = (space: SgsSceneSpaceMock): MuseumHall => ({
id: normalizeContentId('hall', space.id),
name: space.name,
floorId: space.floorId,
floorLabel: space.floorLabel,
description: hallDescription(space),
description: `${space.name}为显式开发 mock 数据,仅在 VITE_GUIDE_CONTENT_SOURCE_MODE=mock 且开发环境启用。`,
image: '',
exhibitCount: SGS_SCENE_EXPLAIN_DATASET.exhibits.filter((exhibit) => exhibit.spaceId === space.id).length || space.exhibitCount || 0,
area: formatArea(space.area),
poiId: space.poiId
})
const toMuseumExhibit = (exhibit: SgsSceneExhibitMock): MuseumExhibit => {
const toMockMuseumExhibit = (exhibit: SgsSceneExhibitMock): MuseumExhibit => {
const hall = publicSpaceById.get(exhibit.spaceId)
return {
@@ -90,23 +78,168 @@ const toMuseumExhibit = (exhibit: SgsSceneExhibitMock): MuseumExhibit => {
floorId: hall?.floorId || SGS_SCENE_EXPLAIN_DATASET.floorId,
floorLabel: hall?.floorLabel || SGS_SCENE_EXPLAIN_DATASET.floorLabel,
image: '',
description: exhibitDescription(exhibit, hall),
description: exhibit.description,
poiId: exhibit.poiId || hall?.poiId,
year: exhibit.era,
material: exhibit.origin,
size: exhibit.code,
tags: tagsForExhibit(exhibit, hall)
tags: [
'显式开发mock',
exhibit.category,
exhibit.zoneName,
exhibit.audioUrl ? '源数据含音频地址' : '图文讲解',
hall ? categoryLabelMap[hall.spaceCategory] : undefined
].filter(Boolean) as string[]
}
}
export class StaticMuseumContentProvider implements MuseumContentProvider {
const unavailableMockAudio = (id: string): MediaAsset => ({
id,
type: 'audio',
available: false,
unavailableReason: '显式开发 mock 数据暂无可播放音频。'
})
export class StaticGuideContentProvider implements ExplainContentProvider {
private adapterCache: GuideContentDataAdapterResult | null = null
private explainAdapterCache: GuideExplainDataAdapterResult | null = null
constructor(private readonly provider: StaticGuideDataProvider = staticGuideDataProvider) {}
private async loadExplainAdapter() {
if (this.explainAdapterCache) return this.explainAdapterCache
if (this.adapterCache) return this.adapterCache
const dataset = await this.provider.loadExplainDataset()
this.explainAdapterCache = createGuideExplainDataAdapter(dataset)
return this.explainAdapterCache
}
private async loadAdapter() {
if (this.adapterCache) return this.adapterCache
const dataset = await this.provider.loadDataset()
this.adapterCache = createGuideContentDataAdapter(dataset)
return this.adapterCache
}
async listExplainExhibits() {
const adapter = await this.loadExplainAdapter()
return adapter.exhibits
}
async listExhibits() {
return SGS_SCENE_EXPLAIN_DATASET.exhibits.map(toMuseumExhibit)
const adapter = await this.loadAdapter()
return adapter.exhibits
}
async listHalls() {
return publicSceneSpaces.map(toMuseumHall)
const adapter = await this.loadExplainAdapter()
return adapter.halls
}
async listTracks() {
const adapter = await this.loadExplainAdapter()
return adapter.tracks
}
async getMediaById(id: string) {
const explainAdapter = await this.loadExplainAdapter()
const explainMedia = explainAdapter.mediaAssets.find((media) => media.id === id)
if (explainMedia) return explainMedia
const adapter = await this.loadAdapter()
return adapter.mediaAssets.find((media) => media.id === id) || null
}
async getMediaForExplainTrack(trackId: string) {
const normalizedTrackId = trackId.replace(/^track-/, '')
return this.getMediaById(`media-${normalizedTrackId}`)
}
}
export const staticMuseumContentProvider = new StaticMuseumContentProvider()
export class RemoteGuideContentProvider implements ExplainContentProvider {
private readonly notReady = () => new Error(
`远程讲解数据源尚未接入。当前 VITE_GUIDE_CONTENT_SOURCE_MODE=${dataSourceConfig.guideContentMode},请切回 static 或实现 remote provider。`
)
async listExhibits(): Promise<MuseumExhibit[]> {
throw this.notReady()
}
async listExplainExhibits(): Promise<MuseumExhibit[]> {
throw this.notReady()
}
async listHalls(): Promise<MuseumHall[]> {
throw this.notReady()
}
async listTracks(): Promise<ExplainTrack[]> {
throw this.notReady()
}
async getMediaById(): Promise<MediaAsset | null> {
throw this.notReady()
}
async getMediaForExplainTrack(): Promise<MediaAsset | null> {
throw this.notReady()
}
}
export class ExplicitMockMuseumContentProvider implements ExplainContentProvider {
listExplainExhibits() {
return this.listExhibits()
}
async listExhibits() {
return SGS_SCENE_EXPLAIN_DATASET.exhibits.map(toMockMuseumExhibit)
}
async listHalls() {
return publicSceneSpaces.map(toMockMuseumHall)
}
async listTracks() {
const exhibits = await this.listExhibits()
return exhibits.map<ExplainTrack>((exhibit) => ({
id: `track-${exhibit.id}`,
exhibitId: exhibit.id,
hallId: exhibit.hallId,
title: `${exhibit.name}讲解`,
summary: exhibit.description,
mediaId: `media-${exhibit.id}`,
coverImage: exhibit.image,
poiId: exhibit.poiId,
floorId: exhibit.floorId,
available: false
}))
}
async getMediaById(id: string) {
return unavailableMockAudio(id)
}
async getMediaForExplainTrack(trackId: string) {
return unavailableMockAudio(`media-${trackId.replace(/^track-/, '')}`)
}
}
export const createMuseumContentProvider = (): ExplainContentProvider => {
if (isGuideContentStaticMode()) {
return new StaticGuideContentProvider()
}
if (isGuideContentRemoteMode()) {
return new RemoteGuideContentProvider()
}
if (isGuideContentMockMode()) {
return new ExplicitMockMuseumContentProvider()
}
return new StaticGuideContentProvider()
}
export const staticMuseumContentProvider = createMuseumContentProvider()

View File

@@ -1,6 +1,10 @@
import {
NAV_ASSET_BASE_URL
NAV_ASSET_BASE_URL,
NAV_ROUTE_READINESS
} from '@/domain/guideReadiness'
import type {
GuideRouteReadiness
} from '@/domain/museum'
export interface StaticNavPoiCategoryPayload {
topCategory: string
@@ -27,18 +31,26 @@ export interface StaticNavManifestFloorModelPayload {
floorId: string
order: number
asset: string
sharedModelAsset?: boolean
}
export interface StaticNavManifestPayload {
status: string
routeReadiness?: GuideRouteReadiness
assets: {
overviewModel: {
asset: string
sharedModelAsset?: boolean
}
floorModels: StaticNavManifestFloorModelPayload[]
}
data: {
floorIndex: string
navData?: string
routeGraph?: string
routeResult?: string
routeQualityAudit?: string
routeClearanceAudit?: string
}
}
@@ -46,6 +58,7 @@ export interface StaticNavFloorIndexItemPayload {
floorId: string
order: number
modelAsset: string
sharedModelAsset?: boolean
poiDataAsset: string
poiCount: number
}
@@ -66,6 +79,83 @@ interface StaticPoiIndexPayload {
pois: StaticNavPoiPayload[]
}
export interface StaticNavRouteNodePayload {
id: string
kind?: string
name?: string
floorId?: string
floor?: string
routePosition?: [number, number, number]
position?: [number, number, number]
displayPosition?: [number, number, number]
connectorType?: string | null
}
export interface StaticNavRouteEdgePayload {
id: string
fromNodeId: string
toNodeId: string
edgeType?: string
floorIds?: string[]
weight?: number
distance3D?: number
}
export interface StaticNavRouteGraphPayload {
schemaVersion: string
generatedAt?: string
routeNodes: StaticNavRouteNodePayload[]
routeEdges: StaticNavRouteEdgePayload[]
qualityGate?: {
status?: string
allowFindPath?: boolean
failureCount?: number
}
stats?: Record<string, unknown>
}
export interface StaticNavAnchorPayload {
id: string
poiId: string
name?: string
displayName?: string
floor?: string
floorId?: string
position?: [number, number, number]
navPosition?: [number, number, number]
routePosition?: [number, number, number]
displayPosition?: [number, number, number]
baseRouteNodeId?: string
routeNodeId?: string
snapStatus?: string
connectorType?: string | null
}
export interface StaticNavBuildingPoiPayload {
id: string
name?: string
displayName?: string
floor?: string
floorId?: string
position?: [number, number, number]
displayPosition?: [number, number, number]
routeAnchorId?: string
}
export interface StaticNavDataPayload {
schemaVersion: string
generatedAt?: string
floors?: unknown[] | Record<string, unknown>
walkableAreas?: unknown[]
buildingPois: StaticNavBuildingPoiPayload[]
navAnchors: StaticNavAnchorPayload[]
qualityGate?: {
status?: string
failureCount?: number
}
stats?: Record<string, unknown>
}
const normalizeBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, '')
const parseJsonPayload = <T>(payload: unknown): T => {
@@ -104,6 +194,9 @@ export interface StaticNavAssetsProvider {
loadFloorIndex(): Promise<StaticNavFloorIndexPayload>
loadPoiIndex(): Promise<StaticNavPoiPayload[]>
loadFloorPois(relativePath: string): Promise<StaticNavPoiPayload[]>
loadNavData(): Promise<StaticNavDataPayload>
loadRouteGraph(): Promise<StaticNavRouteGraphPayload>
loadRouteReadiness(): Promise<GuideRouteReadiness>
}
export const createStaticNavAssetsProvider = (
@@ -116,6 +209,10 @@ export const createStaticNavAssetsProvider = (
let manifestRequest: Promise<StaticNavManifestPayload> | null = null
let floorIndexCache: StaticNavFloorIndexPayload | null = null
let floorIndexRequest: Promise<StaticNavFloorIndexPayload> | null = null
let navDataCache: StaticNavDataPayload | null = null
let navDataRequest: Promise<StaticNavDataPayload> | null = null
let routeGraphCache: StaticNavRouteGraphPayload | null = null
let routeGraphRequest: Promise<StaticNavRouteGraphPayload> | null = null
const floorPoiCache = new Map<string, StaticNavPoiPayload[]>()
const floorPoiRequests = new Map<string, Promise<StaticNavPoiPayload[]>>()
@@ -205,6 +302,50 @@ export const createStaticNavAssetsProvider = (
floorPoiRequests.set(cacheKey, request)
return request
},
async loadNavData() {
if (navDataCache) return navDataCache
if (navDataRequest) return navDataRequest
navDataRequest = provider.loadManifest()
.then((manifest) => requestJson<StaticNavDataPayload>(provider.assetUrl(manifest.data.navData || 'nav_data.json')))
.then((data) => {
if (data.qualityGate?.status && data.qualityGate.status !== 'pass') {
throw new Error('导览 nav_data 质量门未通过')
}
navDataCache = data
return navDataCache
})
.finally(() => {
navDataRequest = null
})
return navDataRequest
},
async loadRouteGraph() {
if (routeGraphCache) return routeGraphCache
if (routeGraphRequest) return routeGraphRequest
routeGraphRequest = provider.loadManifest()
.then((manifest) => requestJson<StaticNavRouteGraphPayload>(provider.assetUrl(manifest.data.routeGraph || 'route_graph.json')))
.then((data) => {
if (data.qualityGate?.status && data.qualityGate.status !== 'pass') {
throw new Error('导览 route_graph 质量门未通过')
}
routeGraphCache = data
return routeGraphCache
})
.finally(() => {
routeGraphRequest = null
})
return routeGraphRequest
},
async loadRouteReadiness() {
const manifest = await provider.loadManifest()
return manifest.routeReadiness || NAV_ROUTE_READINESS
}
}