Files
frontend-miniapp/src/data/adapters/guideDataAdapter.ts

850 lines
31 KiB
TypeScript

import type {
ExplainBusinessUnit,
ExplainGuideStop,
ExplainTrack,
GuideLocationResolution,
GuideLocationResolutionStatus,
MediaAsset,
MuseumExhibit,
MuseumHall,
AudioPlayTargetType
} from '@/domain/museum'
import {
formatNavFloorLabel
} from '@/data/adapters/navAssetsAdapter'
import {
normalizeSameOriginPublicUrl
} from '@/utils/publicUrl'
import {
getExplainUnitPreviewImage
} from '@/utils/explainUnitPreviews'
import type {
GuideStaticContentPayload,
GuideStaticDataset,
GuideStaticExplainDataset,
GuideStaticExplainNavigationDataset,
GuideStaticExhibitPayload,
GuideStaticHallPayload,
GuideStaticOutlinePayload,
GuideStaticPoiBridgeEntryPayload,
GuideStaticPoiPayload,
GuideStaticStopPayload,
} from '@/data/providers/staticGuideDataProvider'
export interface GuideContentDataAdapterResult {
halls: MuseumHall[]
exhibits: MuseumExhibit[]
tracks: ExplainTrack[]
mediaAssets: MediaAsset[]
}
export interface GuideExplainNavigationAdapterResult {
halls: MuseumHall[]
guideStops: ExplainGuideStop[]
businessUnitsByHallId: Record<string, ExplainBusinessUnit[]>
}
export interface GuideExplainDataAdapterResult {
halls: MuseumHall[]
exhibits: MuseumExhibit[]
tracks: ExplainTrack[]
mediaAssets: MediaAsset[]
guideStops: ExplainGuideStop[]
businessUnitsByHallId: Record<string, ExplainBusinessUnit[]>
exhibitByStopId: Record<string, MuseumExhibit>
}
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
}
})
const toExplainBusinessUnitsByHallId = (
stops: ExplainGuideStop[],
topOutlines: Array<{ id: string; name: string; hallId: string; sort?: number }> = []
) => {
const unitsByHallId: Record<string, ExplainBusinessUnit[]> = {}
const stopGroupsByHall = new Map<string, Map<string, ExplainGuideStop[]>>()
const unitNameByKey = new Map<string, string>()
const unitOrderByKey = new Map<string, number>()
topOutlines.forEach((outline) => {
const hallGroups = stopGroupsByHall.get(outline.hallId) || new Map<string, ExplainGuideStop[]>()
if (!hallGroups.has(outline.id)) {
hallGroups.set(outline.id, [])
}
stopGroupsByHall.set(outline.hallId, hallGroups)
unitNameByKey.set(`${outline.hallId}:${outline.id}`, outline.name)
unitOrderByKey.set(`${outline.hallId}:${outline.id}`, outline.sort ?? 0)
})
stops
.slice()
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
.forEach((stop) => {
const hallId = stop.hallId || ''
if (!hallId) return
const unitId = stop.outlineId || `ungrouped-${hallId}`
const unitName = stop.outlineName || '其他讲解'
const hallGroups = stopGroupsByHall.get(hallId) || new Map<string, ExplainGuideStop[]>()
const groupStops = hallGroups.get(unitId) || []
groupStops.push(stop)
hallGroups.set(unitId, groupStops)
stopGroupsByHall.set(hallId, hallGroups)
if (!unitNameByKey.has(`${hallId}:${unitId}`)) {
unitNameByKey.set(`${hallId}:${unitId}`, unitName)
}
if (!unitOrderByKey.has(`${hallId}:${unitId}`)) {
unitOrderByKey.set(`${hallId}:${unitId}`, stop.sort ?? 0)
}
})
stopGroupsByHall.forEach((groupMap, hallId) => {
unitsByHallId[hallId] = Array.from(groupMap.entries())
.map(([unitId, groupStops]) => ({
id: unitId,
name: unitNameByKey.get(`${hallId}:${unitId}`) || '其他讲解',
hallId,
previewImageUrl: getExplainUnitPreviewImage(unitId),
guideStopCount: groupStops.length,
stops: groupStops
}))
.sort((a, b) => (
(unitOrderByKey.get(`${hallId}:${a.id}`) ?? 0) - (unitOrderByKey.get(`${hallId}:${b.id}`) ?? 0)
))
})
return unitsByHallId
}
const createExplainNavigationAdapter = (
dataset: GuideStaticExplainNavigationDataset,
hallGuideCounts = new Map<string, number>(),
poiBridge: {
hallToNavPoiId?: Record<string, GuideStaticPoiBridgeEntryPayload>
sgsPoiToNavPoiId?: Record<string, GuideStaticPoiBridgeEntryPayload>
} = {}
): GuideExplainNavigationAdapterResult => {
const visibleHalls = visibleHallsFrom(dataset.halls)
const visibleOutlines = visibleOutlinesFrom(dataset.outlines)
const visibleStops = visibleStopsFrom(dataset.guideStops)
const hallById = new Map(visibleHalls.map((hall) => [stringifyId(hall.id), hall]))
const outlineById = new Map(visibleOutlines.map((outline) => [stringifyId(outline.id), outline]))
const hallBridgeById = poiBridge.hallToNavPoiId || {}
const sgsPoiBridgeById = 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 findTopOutlineForStop = (stop: GuideStaticStopPayload | undefined) => {
let current = outlineById.get(stringifyId(stop?.outlineId))
let guard = 0
while (current && guard < 16) {
const parentId = stringifyId(current.parentId)
if (parentId && hallById.has(parentId)) {
return current
}
current = parentId ? outlineById.get(parentId) : undefined
guard += 1
}
return outlineById.get(stringifyId(stop?.outlineId))
}
const topOutlines = visibleOutlines.flatMap((outline) => {
const id = stringifyId(outline.id)
const parentId = stringifyId(outline.parentId)
if (!id || !parentId || !hallById.has(parentId)) return []
return [{
id,
name: outline.name || `业务单元 ${id}`,
hallId: parentId,
sort: typeof outline.sort === 'number' ? outline.sort : undefined
}]
})
const resolveHallLocation = (hall: GuideStaticHallPayload | undefined) => {
const hallId = stringifyId(hall?.id)
const sourcePoiId = stringifyId(hall?.poiId)
const bridgedHall = hallId ? toLocationResolution(hallBridgeById[hallId], 'exact', sourcePoiId) : undefined
if (bridgedHall) return bridgedHall
return sourcePoiId ? toLocationResolution(sgsPoiBridgeById[sourcePoiId], 'exact', sourcePoiId) : undefined
}
const halls = toHallModels(visibleHalls, hallGuideCounts, hallBridgeById, sgsPoiBridgeById)
const guideStops: ExplainGuideStop[] = visibleStops.map((stop) => {
const topOutline = findTopOutlineForStop(stop)
const hall = findHallForStop(stop)
const location = resolveHallLocation(hall)
const floorId = location?.floorId || formatFloorId(hall, undefined) || stop.floorCode || stringifyId(stop.floorId)
return {
id: stringifyId(stop.id) || '',
name: firstText(stop.name) || `讲解点 ${stringifyId(stop.id) || ''}`,
hallId: stringifyId(hall?.id),
hallName: hall?.name || undefined,
floorId,
targetType: 'STOP',
targetId: stringifyId(stop.id) || '',
coverImageUrl: resolveImage(stop.coverImageUrl, hall?.coverImageUrl, hall?.galleryUrls),
description: firstText(stop.name),
hasAudio: Boolean(stop.audioUrl),
poiId: location?.poiId,
outlineId: stringifyId(topOutline?.id),
outlineName: topOutline?.name || undefined,
sort: typeof stop.sort === 'number' ? stop.sort : undefined
}
})
return {
halls,
guideStops,
businessUnitsByHallId: toExplainBusinessUnitsByHallId(guideStops, topOutlines)
}
}
export const createGuideExplainNavigationAdapter = (
dataset: GuideStaticExplainNavigationDataset
): GuideExplainNavigationAdapterResult => createExplainNavigationAdapter(dataset)
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 findTopOutlineForStop = (stop: GuideStaticStopPayload | undefined) => {
let current = outlineById.get(stringifyId(stop?.outlineId))
let guard = 0
while (current && guard < 16) {
const parentId = stringifyId(current.parentId)
if (parentId && hallById.has(parentId)) {
return current
}
current = parentId ? outlineById.get(parentId) : undefined
guard += 1
}
return outlineById.get(stringifyId(stop?.outlineId))
}
const topOutlines = visibleOutlines.flatMap((outline) => {
const id = stringifyId(outline.id)
const parentId = stringifyId(outline.parentId)
if (!id || !parentId || !hallById.has(parentId)) return []
return [{
id,
name: outline.name || `业务单元 ${id}`,
hallId: parentId,
sort: typeof outline.sort === 'number' ? outline.sort : 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
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 guideStops: ExplainGuideStop[] = visibleStops.map((stop) => {
const topOutline = findTopOutlineForStop(stop)
const hall = findHallForStop(stop)
const preciseLocation = resolveSgsPoiLocation([stop.poiId], 'exact')
const location = preciseLocation || resolveHallLocation(hall, 'hallFallback')
const floorId = location?.floorId || formatFloorId(hall, undefined) || stop.floorCode || stringifyId(stop.floorId)
const stopGuide = visibleGuides.find((guide) => (
guide.targetType?.toUpperCase() === 'STOP'
&& stringifyId(guide.targetId) === stringifyId(stop.id)
))
return {
id: stringifyId(stop.id) || '',
name: firstText(stop.name, stopGuide?.title) || `讲解点 ${stringifyId(stop.id) || ''}`,
hallId: stringifyId(hall?.id),
hallName: hall?.name || undefined,
floorId,
targetType: 'STOP',
targetId: stringifyId(stop.id) || '',
coverImageUrl: resolveImage(stop.coverImageUrl, stopGuide?.mediaGallery, hall?.coverImageUrl, hall?.galleryUrls),
description: firstText(stopGuide?.standardText, stopGuide?.extendedText, stop.name),
hasAudio: stopGuide ? Boolean(primaryAudioUrl(stopGuide)) : Boolean(stop.audioUrl),
poiId: location?.poiId,
outlineId: stringifyId(topOutline?.id),
outlineName: topOutline?.name || undefined,
sort: typeof stop.sort === 'number' ? stop.sort : undefined
}
})
const businessUnitsByHallId = toExplainBusinessUnitsByHallId(guideStops, topOutlines)
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 exhibitByStopId = exhibits.reduce<Record<string, MuseumExhibit>>((result, exhibit) => {
if (exhibit.playTargetType === 'STOP' && exhibit.playTargetId) {
result[exhibit.playTargetId] = exhibit
}
return result
}, {})
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,
guideStops,
businessUnitsByHallId,
exhibitByStopId
}
}
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
}
}