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 } export interface GuideExplainDataAdapterResult { halls: MuseumHall[] exhibits: MuseumExhibit[] tracks: ExplainTrack[] mediaAssets: MediaAsset[] guideStops: ExplainGuideStop[] businessUnitsByHallId: Record exhibitByStopId: Record } 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) => values .map((value) => value?.trim()) .filter(Boolean) as string[] const firstText = (...values: Array) => ( 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) => 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, 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, hallBridgeById: Record, sgsPoiBridgeById: Record ) => 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 = {} const stopGroupsByHall = new Map>() const unitNameByKey = new Map() const unitOrderByKey = new Map() topOutlines.forEach((outline) => { const hallGroups = stopGroupsByHall.get(outline.hallId) || new Map() 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() 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(), poiBridge: { hallToNavPoiId?: Record sgsPoiToNavPoiId?: Record } = {} ): 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 ) => { 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, status: Exclude ) => { 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() 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>((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, status: Exclude ) => { 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 ) => { 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() const guideByTarget = new Map() 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() 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 } }