chore: freeze guide and explain update
This commit is contained in:
593
src/data/adapters/guideDataAdapter.ts
Normal file
593
src/data/adapters/guideDataAdapter.ts
Normal 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
|
||||
}
|
||||
}
|
||||
254
src/data/adapters/navRouteAdapter.ts
Normal file
254
src/data/adapters/navRouteAdapter.ts
Normal 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
|
||||
}
|
||||
}
|
||||
444
src/data/providers/staticGuideDataProvider.ts
Normal file
444
src/data/providers/staticGuideDataProvider.ts
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user