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,593 @@
import type {
ExplainTrack,
GuideLocationResolution,
GuideLocationResolutionStatus,
MediaAsset,
MuseumExhibit,
MuseumHall,
AudioPlayTargetType
} from '@/domain/museum'
import {
formatNavFloorLabel
} from '@/data/adapters/navAssetsAdapter'
import {
normalizeSameOriginPublicUrl
} from '@/utils/publicUrl'
import type {
GuideStaticContentPayload,
GuideStaticDataset,
GuideStaticExplainDataset,
GuideStaticExhibitPayload,
GuideStaticHallPayload,
GuideStaticOutlinePayload,
GuideStaticPoiBridgeEntryPayload,
GuideStaticPoiPayload,
GuideStaticStopPayload,
} from '@/data/providers/staticGuideDataProvider'
export interface GuideContentDataAdapterResult {
halls: MuseumHall[]
exhibits: MuseumExhibit[]
tracks: ExplainTrack[]
mediaAssets: MediaAsset[]
}
export interface GuideExplainDataAdapterResult {
halls: MuseumHall[]
exhibits: MuseumExhibit[]
tracks: ExplainTrack[]
mediaAssets: MediaAsset[]
}
const stringifyId = (value: string | number | null | undefined) => (
value === null || typeof value === 'undefined' ? undefined : String(value)
)
const isTruthyDeleted = (value: unknown) => value === true || value === 1 || value === '1'
const isEnabledStatus = (value: unknown) => value === undefined || value === null || value === true || value === 1 || value === '1'
const isVisibleRecord = (record: { deleted?: unknown; status?: unknown }) => (
!isTruthyDeleted(record.deleted) && isEnabledStatus(record.status)
)
const compact = (values: Array<string | null | undefined>) => values
.map((value) => value?.trim())
.filter(Boolean) as string[]
const firstText = (...values: Array<string | null | undefined>) => (
compact(values).find(Boolean) || ''
)
const parseJsonArray = (value: string | null | undefined): string[] => {
if (!value) return []
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
: []
} catch {
return value.split(',').map((item) => item.trim()).filter(Boolean)
}
}
const resolveImage = (...values: Array<string | null | undefined>) => normalizeSameOriginPublicUrl(firstText(
...values,
...values.flatMap(parseJsonArray)
))
const formatFloorId = (hall: GuideStaticHallPayload | undefined, poi?: GuideStaticPoiPayload) => (
firstText(
hall?.floorCode,
stringifyId(hall?.floorId),
stringifyId(poi?.floorId)
) || undefined
)
const formatFloorLabel = (floorId: string | undefined) => (
floorId
? floorId.startsWith('L') ? formatNavFloorLabel(floorId) : floorId
: undefined
)
const formatArea = (area?: number | null) => (
typeof area === 'number' && Number.isFinite(area) ? `${area}` : undefined
)
const locationActionText = (status: GuideLocationResolutionStatus) => {
if (status === 'hallFallback') return '查看所属展厅'
if (status === 'candidate') return '候选位置'
if (status === 'exact') return '查看三维位置'
return '暂无位置'
}
const locationNote = (status: GuideLocationResolutionStatus) => {
if (status === 'hallFallback') return '当前展示所属展厅位置,展品精确点位待补充。'
if (status === 'candidate') return '当前仅有候选点位,需人工确认后再用于正式位置预览。'
if (status === 'exact') return '当前支持三维位置预览,馆内路线规划待开放。'
return '暂无可用三维位置数据。'
}
const toLocationResolution = (
entry: GuideStaticPoiBridgeEntryPayload | undefined,
status: Exclude<GuideLocationResolutionStatus, 'unavailable'>,
sourcePoiId?: string
): GuideLocationResolution | undefined => {
const poiId = stringifyId(entry?.navPoiId)
if (!poiId) return undefined
const floorId = stringifyId(entry?.navFloorId)
return {
status,
poiId,
sourcePoiId: stringifyId(entry?.sourcePoiId) || sourcePoiId,
actionText: locationActionText(status),
previewOnly: true,
confidence: entry?.confidence || undefined,
method: entry?.method || undefined,
floorId,
floorLabel: formatFloorLabel(floorId),
note: locationNote(status)
}
}
const guideTargetKey = (guide: GuideStaticContentPayload) => {
const targetType = guide.targetType?.toUpperCase()
const targetId = stringifyId(guide.targetId)
return targetType && targetId ? `${targetType}:${targetId}` : ''
}
const normalizeAudioTargetType = (targetType: string | null | undefined): AudioPlayTargetType | undefined => {
const normalizedType = targetType?.toUpperCase()
if (normalizedType === 'STOP' || normalizedType === 'ITEM') {
return normalizedType
}
return undefined
}
const resolveAudioPlayTarget = (
guide: GuideStaticContentPayload | undefined,
exhibitId?: string
) => {
const guideTargetType = normalizeAudioTargetType(guide?.targetType)
const guideTargetId = stringifyId(guide?.targetId)
if (guideTargetType && guideTargetId) {
return {
playTargetType: guideTargetType,
playTargetId: guideTargetId
}
}
return exhibitId
? {
playTargetType: 'ITEM' as AudioPlayTargetType,
playTargetId: exhibitId
}
: {}
}
const primaryAudioUrl = (guide: GuideStaticContentPayload) => normalizeSameOriginPublicUrl(firstText(
guide.standardAudioUrl,
guide.extendedAudioUrl,
guide.audioUrl,
guide.standardAudioUrlEn,
guide.extendedAudioUrlEn
))
const primaryAudioDuration = (guide: GuideStaticContentPayload) => (
guide.standardAudioDuration
|| guide.extendedAudioDuration
|| guide.audioDuration
|| guide.standardAudioDurationEn
|| guide.extendedAudioDurationEn
|| undefined
)
const primaryGuideText = (guide: GuideStaticContentPayload | undefined, exhibit: GuideStaticExhibitPayload) => firstText(
guide?.standardText,
guide?.extendedText,
exhibit.description,
exhibit.narrationText,
guide?.standardTextEn,
guide?.extendedTextEn,
exhibit.descriptionEn
)
const toGuideLevelTags = (guide: GuideStaticContentPayload | undefined, exhibit: GuideStaticExhibitPayload) => {
const parsedTags = parseJsonArray(guide?.aiTags)
return compact([
exhibit.category,
exhibit.exhibitLevel,
...parsedTags,
guide ? '真实讲解词' : '展品档案'
])
}
const visibleHallsFrom = (halls: GuideStaticHallPayload[]) => halls.filter(isVisibleRecord)
const visibleOutlinesFrom = (outlines: GuideStaticOutlinePayload[]) => outlines.filter((outline) => !isTruthyDeleted(outline.deleted))
const visibleStopsFrom = (stops: GuideStaticStopPayload[]) => stops.filter((stop) => !isTruthyDeleted(stop.deleted))
const visibleGuidesFrom = (guides: GuideStaticContentPayload[]) => guides.filter(isVisibleRecord)
const toHallModels = (
sourceHalls: GuideStaticHallPayload[],
hallExhibitCounts: Map<string, number>,
hallBridgeById: Record<string, GuideStaticPoiBridgeEntryPayload>,
sgsPoiBridgeById: Record<string, GuideStaticPoiBridgeEntryPayload>
) => sourceHalls.map((hall) => {
const id = stringifyId(hall.id) || ''
const sourcePoiId = stringifyId(hall.poiId)
const bridgedHall = id ? toLocationResolution(hallBridgeById[id], 'exact', sourcePoiId) : undefined
const location = bridgedHall || (() => {
if (!sourcePoiId) return undefined
return toLocationResolution(sgsPoiBridgeById[sourcePoiId], 'exact', sourcePoiId)
})()
const floorId = location?.floorId || formatFloorId(hall)
const description = firstText(hall.description, hall.narrationText, hall.subtitle)
return {
id,
name: hall.name || hall.hallCode || `展厅 ${id}`,
floorId,
floorLabel: formatFloorLabel(floorId),
description,
image: resolveImage(hall.coverImageUrl, hall.galleryUrls),
exhibitCount: hallExhibitCounts.get(id) || hall.exhibitCount || 0,
area: formatArea(hall.areaSqm),
poiId: location?.poiId,
location
}
})
export const createGuideExplainDataAdapter = (dataset: GuideStaticExplainDataset): GuideExplainDataAdapterResult => {
const visibleHalls = visibleHallsFrom(dataset.halls)
const visibleOutlines = visibleOutlinesFrom(dataset.outlines)
const visibleStops = visibleStopsFrom(dataset.guideStops)
const visibleGuides = visibleGuidesFrom(dataset.guideContents)
const hallById = new Map(visibleHalls.map((hall) => [stringifyId(hall.id), hall]))
const outlineById = new Map(visibleOutlines.map((outline) => [stringifyId(outline.id), outline]))
const stopById = new Map(visibleStops.map((stop) => [stringifyId(stop.id), stop]))
const hallBridgeById = dataset.poiBridge?.hallToNavPoiId || {}
const sgsPoiBridgeById = dataset.poiBridge?.sgsPoiToNavPoiId || {}
const findHallForOutline = (outline: GuideStaticOutlinePayload | undefined) => {
let current = outline
let guard = 0
while (current && guard < 16) {
const parentId = stringifyId(current.parentId)
if (parentId && hallById.has(parentId)) {
return hallById.get(parentId)
}
current = parentId ? outlineById.get(parentId) : undefined
guard += 1
}
return undefined
}
const findHallForStop = (stop: GuideStaticStopPayload | undefined) => (
findHallForOutline(outlineById.get(stringifyId(stop?.outlineId)))
)
const resolveHallLocation = (
hall: GuideStaticHallPayload | undefined,
status: Exclude<GuideLocationResolutionStatus, 'unavailable'>
) => {
const hallId = stringifyId(hall?.id)
const sourcePoiId = stringifyId(hall?.poiId)
const bridgedHall = hallId ? toLocationResolution(hallBridgeById[hallId], status, sourcePoiId) : undefined
if (bridgedHall) return bridgedHall
return sourcePoiId ? toLocationResolution(sgsPoiBridgeById[sourcePoiId], status, sourcePoiId) : undefined
}
const resolveSgsPoiLocation = (
sourcePoiIds: Array<string | number | null | undefined>,
status: Exclude<GuideLocationResolutionStatus, 'unavailable'>
) => {
for (const rawId of sourcePoiIds) {
const sourcePoiId = stringifyId(rawId)
if (!sourcePoiId) continue
const location = toLocationResolution(sgsPoiBridgeById[sourcePoiId], status, sourcePoiId)
if (location) return location
}
return undefined
}
const hallGuideCounts = new Map<string, number>()
visibleGuides.forEach((guide) => {
const stop = guide.targetType?.toUpperCase() === 'STOP'
? stopById.get(stringifyId(guide.targetId))
: undefined
const hall = findHallForStop(stop)
const hallId = stringifyId(hall?.id)
if (hallId) {
hallGuideCounts.set(hallId, (hallGuideCounts.get(hallId) || 0) + 1)
}
})
const halls = toHallModels(visibleHalls, hallGuideCounts, hallBridgeById, sgsPoiBridgeById)
const mediaAssets: MediaAsset[] = visibleGuides.map((guide) => {
const id = `media-${stringifyId(guide.id) || 'unknown'}`
const url = primaryAudioUrl(guide)
const title = guide.title || '该讲解'
return {
id,
type: 'audio',
url: url || undefined,
duration: primaryAudioDuration(guide),
language: 'zh-CN',
available: Boolean(url),
unavailableReason: url ? undefined : `${title}暂无已发布音频,当前提供图文讲解。`
}
})
const mediaByGuideId = new Map(mediaAssets.map((media) => [media.id.replace(/^media-/, ''), media]))
const exhibits: MuseumExhibit[] = visibleGuides.map((guide) => {
const id = stringifyId(guide.exhibitId) || `guide-${stringifyId(guide.id) || ''}`
const guideId = stringifyId(guide.id) || ''
const stop = guide.targetType?.toUpperCase() === 'STOP'
? stopById.get(stringifyId(guide.targetId))
: undefined
const outline = outlineById.get(stringifyId(stop?.outlineId))
const hall = findHallForStop(stop)
const sourcePoiId = stringifyId(guide.poiId || stop?.poiId || hall?.poiId)
const preciseLocation = resolveSgsPoiLocation([guide.poiId, stop?.poiId], 'exact')
const location = preciseLocation || resolveHallLocation(hall, 'hallFallback')
const floorId = location?.floorId || formatFloorId(hall, undefined) || stop?.floorCode || stringifyId(stop?.floorId)
const audioUrl = primaryAudioUrl(guide)
const audioPlayTarget = resolveAudioPlayTarget(guide, id)
const title = firstText(guide.title, stop?.name)?.replace(/\s*讲解\s*$/, '') || `讲解 ${guideId || id}`
return {
id,
name: title,
hallId: stringifyId(hall?.id),
hallName: hall?.name || undefined,
floorId,
floorLabel: formatFloorLabel(floorId),
image: resolveImage(stop?.coverImageUrl, guide.mediaGallery, hall?.coverImageUrl, hall?.galleryUrls),
description: firstText(guide.standardText, guide.extendedText, stop?.name, guide.standardTextEn, guide.extendedTextEn),
poiId: location?.poiId,
sourcePoiId,
location,
tags: compact([
hall?.name,
outline?.name,
guide.contentType || undefined,
guide ? '真实讲解词' : undefined
]),
guideContentId: guideId,
guideTitle: guide.title || undefined,
guideText: firstText(guide.standardText, guide.extendedText, guide.standardTextEn, guide.extendedTextEn) || undefined,
audioUrl: audioUrl || undefined,
audioDuration: primaryAudioDuration(guide),
audioAvailable: Boolean(audioUrl),
...audioPlayTarget
}
})
const tracks: ExplainTrack[] = visibleGuides.map((guide) => {
const id = stringifyId(guide.id) || ''
const exhibitId = stringifyId(guide.exhibitId) || `guide-${id}`
const stop = guide.targetType?.toUpperCase() === 'STOP'
? stopById.get(stringifyId(guide.targetId))
: undefined
const hall = findHallForStop(stop)
const sourcePoiId = stringifyId(guide.poiId || stop?.poiId || hall?.poiId)
const preciseLocation = resolveSgsPoiLocation([guide.poiId, stop?.poiId], 'exact')
const location = preciseLocation || resolveHallLocation(hall, 'hallFallback')
const floorId = location?.floorId || formatFloorId(hall, undefined) || stop?.floorCode || stringifyId(stop?.floorId)
const media = mediaByGuideId.get(id)
const audioPlayTarget = resolveAudioPlayTarget(guide, exhibitId)
return {
id: `track-${id}`,
exhibitId,
hallId: stringifyId(hall?.id),
title: guide.title || stop?.name || `讲解 ${id}`,
summary: firstText(guide.standardText, guide.extendedText, stop?.name),
mediaId: media?.id,
coverImage: resolveImage(stop?.coverImageUrl, guide.mediaGallery, hall?.coverImageUrl, hall?.galleryUrls),
poiId: location?.poiId,
sourcePoiId,
location,
floorId,
available: media?.available === true,
...audioPlayTarget
}
})
return {
halls,
exhibits,
tracks,
mediaAssets
}
}
export const createGuideContentDataAdapter = (dataset: GuideStaticDataset): GuideContentDataAdapterResult => {
const visibleHalls = visibleHallsFrom(dataset.halls)
const visibleOutlines = visibleOutlinesFrom(dataset.outlines)
const visibleStops = visibleStopsFrom(dataset.guideStops)
const visibleExhibits = dataset.exhibits.filter(isVisibleRecord)
const visibleGuides = visibleGuidesFrom(dataset.guideContents)
const hallById = new Map(visibleHalls.map((hall) => [stringifyId(hall.id), hall]))
const outlineById = new Map(visibleOutlines.map((outline) => [stringifyId(outline.id), outline]))
const stopById = new Map(visibleStops.map((stop) => [stringifyId(stop.id), stop]))
const poiById = new Map(dataset.pois.map((poi) => [stringifyId(poi.id), poi]))
const exhibitById = new Map(visibleExhibits.map((exhibit) => [stringifyId(exhibit.id), exhibit]))
const hallBridgeById = dataset.poiBridge?.hallToNavPoiId || {}
const sgsPoiBridgeById = dataset.poiBridge?.sgsPoiToNavPoiId || {}
const resolveSgsPoiLocation = (
sourcePoiIds: Array<string | number | null | undefined>,
status: Exclude<GuideLocationResolutionStatus, 'unavailable'>
) => {
for (const rawId of sourcePoiIds) {
const sourcePoiId = stringifyId(rawId)
if (!sourcePoiId) continue
const location = toLocationResolution(sgsPoiBridgeById[sourcePoiId], status, sourcePoiId)
if (location) return location
}
return undefined
}
const resolveHallLocation = (
hall: GuideStaticHallPayload | undefined,
status: Exclude<GuideLocationResolutionStatus, 'unavailable'>
) => {
const hallId = stringifyId(hall?.id)
const sourcePoiId = stringifyId(hall?.poiId)
const bridgedHall = hallId ? toLocationResolution(hallBridgeById[hallId], status, sourcePoiId) : undefined
return bridgedHall || resolveSgsPoiLocation([sourcePoiId], status)
}
const guideByExhibitId = new Map<string, GuideStaticContentPayload>()
const guideByTarget = new Map<string, GuideStaticContentPayload>()
for (const guide of visibleGuides) {
const key = guideTargetKey(guide)
if (key && !guideByTarget.has(key)) {
guideByTarget.set(key, guide)
}
const exhibitId = stringifyId(guide.exhibitId)
if (exhibitId && !guideByExhibitId.has(exhibitId)) {
guideByExhibitId.set(exhibitId, guide)
}
if (guide.targetType?.toUpperCase() === 'ITEM') {
const targetId = stringifyId(guide.targetId)
if (targetId && !guideByExhibitId.has(targetId)) {
guideByExhibitId.set(targetId, guide)
}
}
}
const hallExhibitCounts = new Map<string, number>()
for (const exhibit of visibleExhibits) {
const hallId = stringifyId(exhibit.hallId)
if (hallId) {
hallExhibitCounts.set(hallId, (hallExhibitCounts.get(hallId) || 0) + 1)
}
}
const halls = toHallModels(visibleHalls, hallExhibitCounts, hallBridgeById, sgsPoiBridgeById)
const exhibits: MuseumExhibit[] = visibleExhibits.map((exhibit) => {
const id = stringifyId(exhibit.id) || ''
const hall = hallById.get(stringifyId(exhibit.hallId))
const outline = outlineById.get(stringifyId(exhibit.outlineId))
const stop = stopById.get(stringifyId(exhibit.stopId))
const itemGuide = guideByExhibitId.get(id)
const stopGuide = stop ? guideByTarget.get(`STOP:${stringifyId(stop.id)}`) : undefined
const guide = itemGuide || stopGuide
const audioUrl = guide ? primaryAudioUrl(guide) : ''
const audioPlayTarget = resolveAudioPlayTarget(guide, id)
const sourcePoiId = stringifyId(exhibit.poiId || stop?.poiId || guide?.poiId || hall?.poiId)
const sourcePoi = sourcePoiId ? poiById.get(sourcePoiId) : undefined
const preciseLocation = resolveSgsPoiLocation([exhibit.poiId, stop?.poiId, guide?.poiId], 'exact')
const location = preciseLocation || resolveHallLocation(hall, 'hallFallback')
const floorId = location?.floorId || formatFloorId(hall, sourcePoi)
const description = primaryGuideText(guide, exhibit)
return {
id,
name: exhibit.name || exhibit.exhibitCode || guide?.title || `展品 ${id}`,
hallId: stringifyId(exhibit.hallId),
hallName: hall?.name || undefined,
floorId,
floorLabel: formatFloorLabel(floorId),
image: resolveImage(exhibit.coverImageUrl, exhibit.galleryUrls, guide?.mediaGallery),
description,
poiId: location?.poiId,
sourcePoiId,
location,
year: exhibit.era || undefined,
material: exhibit.origin || undefined,
size: exhibit.exhibitCode || exhibit.dimensions || undefined,
tags: compact([
hall?.name,
outline?.name,
stop?.name,
...toGuideLevelTags(guide, exhibit)
]),
guideContentId: stringifyId(guide?.id),
guideTitle: guide?.title || undefined,
guideText: description || undefined,
audioUrl: audioUrl || undefined,
audioDuration: guide ? primaryAudioDuration(guide) : undefined,
audioAvailable: Boolean(audioUrl),
...audioPlayTarget
}
})
const mediaAssets: MediaAsset[] = visibleGuides.map((guide) => {
const id = `media-${stringifyId(guide.id) || 'unknown'}`
const url = primaryAudioUrl(guide)
const title = guide.title || '该讲解'
return {
id,
type: 'audio',
url: url || undefined,
duration: primaryAudioDuration(guide),
language: 'zh-CN',
available: Boolean(url),
unavailableReason: url ? undefined : `${title}暂无已发布音频,当前提供图文讲解。`
}
})
const mediaByGuideId = new Map(mediaAssets.map((media) => [media.id.replace(/^media-/, ''), media]))
const tracks: ExplainTrack[] = visibleGuides.map((guide) => {
const id = stringifyId(guide.id) || ''
const targetType = guide.targetType?.toUpperCase()
const targetId = stringifyId(guide.targetId)
const exhibit = stringifyId(guide.exhibitId)
? exhibitById.get(stringifyId(guide.exhibitId))
: targetType === 'ITEM' && targetId ? exhibitById.get(targetId) : undefined
const stop = targetType === 'STOP' && targetId ? stopById.get(targetId) : undefined
const hall = exhibit ? hallById.get(stringifyId(exhibit.hallId)) : undefined
const sourcePoiId = stringifyId(exhibit?.poiId || stop?.poiId || guide.poiId || hall?.poiId)
const sourcePoi = sourcePoiId ? poiById.get(sourcePoiId) : undefined
const preciseLocation = resolveSgsPoiLocation([exhibit?.poiId, stop?.poiId, guide.poiId], 'exact')
const location = preciseLocation || resolveHallLocation(hall, 'hallFallback')
const floorId = location?.floorId || formatFloorId(hall, sourcePoi)
const media = mediaByGuideId.get(id)
const audioPlayTarget = resolveAudioPlayTarget(guide, stringifyId(exhibit?.id))
return {
id: `track-${id}`,
exhibitId: stringifyId(exhibit?.id),
hallId: stringifyId(hall?.id),
title: guide.title || exhibit?.name || stop?.name || `讲解 ${id}`,
summary: firstText(guide.standardText, guide.extendedText, exhibit?.description, stop?.name),
mediaId: media?.id,
coverImage: resolveImage(exhibit?.coverImageUrl, exhibit?.galleryUrls, stop?.coverImageUrl, guide.mediaGallery),
poiId: location?.poiId,
sourcePoiId,
location,
floorId,
available: media?.available === true,
...audioPlayTarget
}
})
return {
halls,
exhibits,
tracks,
mediaAssets
}
}

View File

@@ -0,0 +1,254 @@
import type {
GuideRouteConnectorPoint,
GuideRouteEndpoint,
GuideRouteFloorSegment,
GuideRoutePoint,
GuideRouteResult,
GuideRouteTarget
} from '@/domain/museum'
import {
formatNavFloorLabel
} from '@/data/adapters/navAssetsAdapter'
import type {
StaticNavAnchorPayload,
StaticNavDataPayload,
StaticNavPoiPayload,
StaticNavRouteEdgePayload,
StaticNavRouteGraphPayload,
StaticNavRouteNodePayload
} from '@/data/providers/staticNavAssetsProvider'
export interface NavRouteNode {
id: string
floorId: string
position: [number, number, number]
kind?: string
connectorType?: string
}
export interface NavRouteEdge {
id: string
fromNodeId: string
toNodeId: string
weight: number
edgeType: string
floorIds: string[]
}
export interface NavRouteAnchor {
poiId: string
name: string
floorId: string
floorLabel: string
position: [number, number, number]
routeNodeId: string
baseRouteNodeId?: string
connectorType?: string
}
export interface NavRouteDataset {
nodes: Map<string, NavRouteNode>
edges: NavRouteEdge[]
anchorsByPoiId: Map<string, NavRouteAnchor>
targets: GuideRouteTarget[]
}
const isCoordinate = (value: unknown): value is [number, number, number] => (
Array.isArray(value)
&& value.length === 3
&& value.every((item) => Number.isFinite(item))
)
const toGltfCoordinate = (coordinate: [number, number, number]): [number, number, number] => [
coordinate[0],
coordinate[2],
-coordinate[1]
]
const getNodeFloorId = (node: StaticNavRouteNodePayload) => (
node.floorId || node.floor || ''
)
const getNodePosition = (node: StaticNavRouteNodePayload) => {
if (isCoordinate(node.routePosition)) return node.routePosition
if (isCoordinate(node.position)) return node.position
if (isCoordinate(node.displayPosition)) return node.displayPosition
return null
}
const toRouteNode = (node: StaticNavRouteNodePayload): NavRouteNode | null => {
const floorId = getNodeFloorId(node)
const position = getNodePosition(node)
if (!node.id || !floorId || !position) return null
return {
id: node.id,
floorId,
position: toGltfCoordinate(position),
kind: node.kind,
connectorType: node.connectorType || undefined
}
}
const toRouteEdge = (edge: StaticNavRouteEdgePayload, nodes: Map<string, NavRouteNode>): NavRouteEdge | null => {
if (!edge.id || !edge.fromNodeId || !edge.toNodeId) return null
if (!nodes.has(edge.fromNodeId) || !nodes.has(edge.toNodeId)) return null
const weight = Number(edge.weight ?? edge.distance3D)
if (!Number.isFinite(weight) || weight <= 0) return null
return {
id: edge.id,
fromNodeId: edge.fromNodeId,
toNodeId: edge.toNodeId,
weight,
edgeType: edge.edgeType || 'route_edge',
floorIds: edge.floorIds || []
}
}
const getAnchorPosition = (anchor: StaticNavAnchorPayload) => {
if (isCoordinate(anchor.routePosition)) return anchor.routePosition
if (isCoordinate(anchor.navPosition)) return anchor.navPosition
if (isCoordinate(anchor.displayPosition)) return anchor.displayPosition
if (isCoordinate(anchor.position)) return anchor.position
return null
}
const toRouteAnchor = (
anchor: StaticNavAnchorPayload,
nodes: Map<string, NavRouteNode>
): NavRouteAnchor | null => {
const floorId = anchor.floorId || anchor.floor || ''
const routeNodeId = anchor.routeNodeId || anchor.baseRouteNodeId || ''
const position = getAnchorPosition(anchor)
if (!anchor.poiId || !floorId || !routeNodeId || !position) return null
if (!nodes.has(routeNodeId)) return null
return {
poiId: anchor.poiId,
name: anchor.displayName || anchor.name || anchor.poiId,
floorId,
floorLabel: formatNavFloorLabel(floorId),
position: toGltfCoordinate(position),
routeNodeId,
baseRouteNodeId: anchor.baseRouteNodeId,
connectorType: anchor.connectorType || undefined
}
}
const toTarget = (
anchor: NavRouteAnchor,
poi?: StaticNavPoiPayload
): GuideRouteTarget => ({
poiId: anchor.poiId,
name: poi?.name || anchor.name,
floorId: anchor.floorId,
floorLabel: anchor.floorLabel,
categoryLabel: poi?.primaryCategoryZh,
positionGltf: poi?.positionGltf || anchor.position,
routeNodeId: anchor.routeNodeId
})
export const createNavRouteDataset = (
navData: StaticNavDataPayload,
routeGraph: StaticNavRouteGraphPayload,
pois: StaticNavPoiPayload[]
): NavRouteDataset => {
const nodes = new Map<string, NavRouteNode>()
routeGraph.routeNodes
.map(toRouteNode)
.filter((node): node is NavRouteNode => Boolean(node))
.forEach((node) => {
nodes.set(node.id, node)
})
const edges = routeGraph.routeEdges
.map((edge) => toRouteEdge(edge, nodes))
.filter((edge): edge is NavRouteEdge => Boolean(edge))
const poiById = new Map(pois.map((poi) => [poi.id, poi]))
const anchorsByPoiId = new Map<string, NavRouteAnchor>()
navData.navAnchors
.map((anchor) => toRouteAnchor(anchor, nodes))
.filter((anchor): anchor is NavRouteAnchor => Boolean(anchor))
.forEach((anchor) => {
anchorsByPoiId.set(anchor.poiId, anchor)
})
const targets = [...anchorsByPoiId.values()]
.map((anchor) => toTarget(anchor, poiById.get(anchor.poiId)))
.sort((a, b) => (
a.floorId.localeCompare(b.floorId, undefined, { numeric: true })
|| a.name.localeCompare(b.name, 'zh-Hans-CN')
))
return {
nodes,
edges,
anchorsByPoiId,
targets
}
}
export const toRoutePoint = (node: NavRouteNode): GuideRoutePoint => ({
nodeId: node.id,
floorId: node.floorId,
position: node.position
})
export const createRouteResult = (
start: NavRouteAnchor,
end: NavRouteAnchor,
nodes: NavRouteNode[],
totalWeight: number
): GuideRouteResult => {
const points = nodes.map(toRoutePoint)
const floorSegments: GuideRouteFloorSegment[] = []
points.forEach((point) => {
const currentSegment = floorSegments[floorSegments.length - 1]
if (currentSegment?.floorId === point.floorId) {
currentSegment.points.push(point)
return
}
floorSegments.push({
floorId: point.floorId,
floorLabel: formatNavFloorLabel(point.floorId),
points: [point]
})
})
const connectorPoints: GuideRouteConnectorPoint[] = nodes
.filter((node) => node.kind?.includes('connector') || Boolean(node.connectorType))
.map((node) => ({
nodeId: node.id,
floorId: node.floorId,
floorLabel: formatNavFloorLabel(node.floorId),
position: node.position,
connectorType: node.connectorType
}))
const toEndpoint = (anchor: NavRouteAnchor): GuideRouteEndpoint => ({
poiId: anchor.poiId,
name: anchor.name,
floorId: anchor.floorId,
floorLabel: anchor.floorLabel,
routeNodeId: anchor.routeNodeId,
position: anchor.position
})
return {
id: `route-${start.poiId}-${end.poiId}`,
start: toEndpoint(start),
end: toEndpoint(end),
distanceMeters: Number(totalWeight.toFixed(1)),
nodeIds: nodes.map((node) => node.id),
points,
floorSegments,
connectorPoints
}
}

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
}
}