import type { AudioPlayTargetType, ExplainBusinessUnit, ExplainGuideStop, ExplainTrack, MediaAsset, MuseumExhibit, MuseumHall } from '@/domain/museum' import { normalizeSameOriginPublicUrl } from '@/utils/publicUrl' import { EXHIBIT_PLACEHOLDER_IMAGE, HALL_PLACEHOLDER_IMAGE } from '@/utils/placeholders' import { normalizeGuideAudioLanguage, type GuideAudioLanguage } from '@/data/adapters/guideStopInfoAdapter' export interface BackendGuideContent { id?: string | number | null title?: string | null targetType?: string | null targetId?: string | number | null standardText?: string | null extendedText?: string | null standardAudioUrl?: string | null extendedAudioUrl?: string | null audioUrl?: string | null standardAudioDuration?: number | null extendedAudioDuration?: number | null audioDuration?: number | null } export interface BackendExhibit { id?: string | number | null hallId?: string | number | null hallName?: string | null floorId?: string | number | null floorLabel?: string | null poiId?: string | number | null exhibitCode?: string | null name?: string | null scientificName?: string | null category?: string | null era?: string | null origin?: string | null dimensions?: string | null description?: string | null narrationText?: string | null coverImageUrl?: string | null galleryUrls?: string | string[] | null audioUrl?: string | null audioDuration?: number | null playTargetType?: string | null playTargetId?: string | number | null hasAudio?: boolean | null supportedLanguages?: string[] | null audioStatus?: string | null guideContents?: BackendGuideContent[] | null } export interface BackendHall { id?: string | number | null name?: string | null poiId?: string | number | null hallCode?: string | null nameEn?: string | null subtitle?: string | null description?: string | null coverImageUrl?: string | null floorId?: string | number | null floorCode?: string | null exhibitCount?: number | null outlineCount?: number | null stopCount?: number | null linkedExhibitCount?: number | null audioReadyStopCount?: number | null hasAudio?: boolean | null audioStatus?: string | null supportedLanguages?: string[] | null } export interface BackendGuideStop { id?: string | number | null name?: string | null floorId?: string | number | null mapX?: number | string | null mapY?: number | string | null targetType?: string | null targetId?: string | number | null coverImageUrl?: string | null description?: string | null hasAudio?: boolean | null audioUrl?: string | null poiId?: string | number | null outlineId?: string | number | null outlineName?: string | null hallId?: string | number | null hallName?: string | null sort?: number | null } export interface BackendCatalogHallItem extends BackendHall { mapId?: string | number | null areaSqm?: string | number | null sortOrder?: number | null } export interface BackendCatalogOutlineItem { id?: string | number | null parentId?: string | number | null hallId?: string | number | null name?: string | null code?: string | null description?: string | null sort?: number | null level?: number | null children?: BackendCatalogOutlineItem[] | null stopCount?: number | null linkedExhibitCount?: number | null audioReadyStopCount?: number | null hasAudio?: boolean | null audioStatus?: string | null supportedLanguages?: string[] | null } export interface BackendCatalogLinkedExhibitItem { id?: string | number | null name?: string | null nameEn?: string | null code?: string | null exhibitCode?: string | null coverImageUrl?: string | null galleryUrls?: string | string[] | null sortOrder?: number | null } export interface BackendCatalogStopItem { stopId?: string | number | null id?: string | number | null outlineId?: string | number | null poiId?: string | number | null floorId?: string | number | null mapX?: number | string | null mapY?: number | string | null name?: string | null guideLevel?: string | null description?: string | null sort?: number | null coverImageUrl?: string | null imageStatus?: string | null linkedExhibitCount?: number | null isSharedStop?: boolean | null linkedExhibits?: BackendCatalogLinkedExhibitItem[] | null hasAudio?: boolean | null audioStatus?: string | null supportedLanguages?: string[] | null audioOptions?: Array<{ version?: string | null languageCode?: string | null languageName?: string | null gender?: string | null displayName?: string | null isDefault?: boolean | null }> | null } export interface BackendExplainAdapterResult { exhibit: MuseumExhibit track: ExplainTrack media: MediaAsset | null } const stringifyId = (value: string | number | null | undefined) => ( value === null || typeof value === 'undefined' ? '' : String(value) ) const firstText = (...values: Array) => ( values .map((value) => (value === null || typeof value === 'undefined' ? '' : String(value).trim())) .find(Boolean) || '' ) const parseGalleryUrls = (value: BackendExhibit['galleryUrls'] | string[]) => { if (!value) return [] if (Array.isArray(value)) return value.map(String).filter(Boolean) const trimmed = value.trim() if (!trimmed) return [] try { const parsed = JSON.parse(trimmed) if (Array.isArray(parsed)) { return parsed .flatMap((entry) => String(entry).split(',')) .map((entry) => entry.trim()) .filter(Boolean) } } catch { // 兼容后端历史逗号拼接字段。 } return trimmed.split(',').map((entry) => entry.trim()).filter(Boolean) } const normalizeAudioTargetType = (targetType: string | null | undefined): AudioPlayTargetType | undefined => { const normalized = targetType?.toUpperCase() return normalized === 'ITEM' || normalized === 'STOP' ? normalized : undefined } const normalizeNumber = (value: number | string | null | undefined) => { if (typeof value === 'number' && Number.isFinite(value)) return value if (typeof value === 'string' && value.trim()) { const numeric = Number(value) return Number.isFinite(numeric) ? numeric : undefined } return undefined } const isAudioReady = (value?: string | null) => ( !value || ['READY', 'PUBLISHED', 'AVAILABLE'].includes(value.toUpperCase()) ) const resolveImage = (exhibit: BackendExhibit) => normalizeSameOriginPublicUrl( firstText(exhibit.coverImageUrl, parseGalleryUrls(exhibit.galleryUrls)[0]) ) const resolveGuideAudioUrl = (guide?: BackendGuideContent | null, exhibit?: BackendExhibit) => normalizeSameOriginPublicUrl( firstText(guide?.standardAudioUrl, guide?.audioUrl, guide?.extendedAudioUrl, exhibit?.audioUrl) ) const resolveGuideAudioDuration = (guide?: BackendGuideContent | null, exhibit?: BackendExhibit) => { const duration = guide?.standardAudioDuration || guide?.audioDuration || guide?.extendedAudioDuration || exhibit?.audioDuration return typeof duration === 'number' && Number.isFinite(duration) && duration > 0 ? duration : undefined } const resolveGuideText = (guide?: BackendGuideContent | null, exhibit?: BackendExhibit) => ( firstText(guide?.standardText, guide?.extendedText, exhibit?.narrationText, exhibit?.description) ) const selectPrimaryGuideContent = (exhibit: BackendExhibit) => { const guides = exhibit.guideContents || [] return guides.find((guide) => Boolean(resolveGuideAudioUrl(guide, exhibit))) || guides.find((guide) => Boolean(resolveGuideText(guide, exhibit))) || guides[0] || null } const buildTags = (exhibit: BackendExhibit) => Array.from(new Set([ firstText(exhibit.category), firstText(exhibit.scientificName), firstText(exhibit.exhibitCode), firstText(exhibit.era), firstText(exhibit.origin) ].filter(Boolean))) const normalizeSupportedLanguages = (languages: string[] | null | undefined): GuideAudioLanguage[] => ( Array.from(new Set((languages || []) .map(String) .filter((language) => ['zh', 'zh-cn', 'en', 'en-us', 'yue', 'yue-cn', 'yue-hk'] .includes(language.trim().toLowerCase())) .map(normalizeGuideAudioLanguage))) ) const normalizeCatalogAudioStatus = (status?: string | null) => ( firstText(status) || 'MISSING' ) export const toCatalogHall = ( source: BackendCatalogHallItem, fallback?: MuseumHall | null ): MuseumHall => { const id = stringifyId(source.id) || fallback?.id || firstText(source.hallCode, source.name) const name = firstText(source.name, fallback?.name, source.hallCode, '展厅') const area = firstText(source.areaSqm) return { id, name, floorId: stringifyId(source.floorId) || fallback?.floorId, floorLabel: firstText(source.floorCode, fallback?.floorLabel) || undefined, description: firstText(source.description, source.subtitle, fallback?.description, '该展厅暂无介绍。'), image: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || fallback?.image || HALL_PLACEHOLDER_IMAGE, exhibitCount: normalizeNumber(source.exhibitCount) ?? fallback?.exhibitCount ?? 0, outlineCount: normalizeNumber(source.outlineCount), stopCount: normalizeNumber(source.stopCount), linkedExhibitCount: normalizeNumber(source.linkedExhibitCount), audioReadyStopCount: normalizeNumber(source.audioReadyStopCount), hasAudio: source.hasAudio === true, audioStatus: normalizeCatalogAudioStatus(source.audioStatus), supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages), area: area ? `${area}㎡` : fallback?.area, poiId: stringifyId(source.poiId) || fallback?.poiId, location: fallback?.location } } export const toCatalogLinkedExhibit = ( source: BackendCatalogLinkedExhibitItem, hall?: MuseumHall | null ): MuseumExhibit | null => { const id = stringifyId(source.id) if (!id) return null return { id, name: firstText(source.name, source.exhibitCode, source.code, `展品${id}`), hallId: hall?.id, hallName: hall?.name, floorId: hall?.floorId, floorLabel: hall?.floorLabel, image: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined, size: firstText(source.exhibitCode, source.code) || undefined } } const toCatalogLinkedExhibitSummary = (source: BackendCatalogLinkedExhibitItem) => { const id = stringifyId(source.id) if (!id) return null return { id, name: firstText(source.name, source.exhibitCode, source.code, `展品 ${id}`), nameEn: firstText(source.nameEn) || undefined, code: firstText(source.code, source.exhibitCode) || undefined, exhibitCode: firstText(source.exhibitCode, source.code) || undefined, coverImageUrl: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined, galleryUrls: parseGalleryUrls(source.galleryUrls), sortOrder: normalizeNumber(source.sortOrder) } } export const toCatalogGuideStop = ( source: BackendCatalogStopItem, hall?: MuseumHall | null, outline?: Pick | null ): ExplainGuideStop | null => { const id = stringifyId(source.stopId) || stringifyId(source.id) if (!id) return null const imageStatus = firstText(source.imageStatus) || 'MISSING' const canUseStopImage = imageStatus === 'READY' return { id, name: firstText(source.name, `讲解点${id}`), hallId: hall?.id, hallName: hall?.name, floorId: stringifyId(source.floorId) || hall?.floorId, coverImageUrl: canUseStopImage ? normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined : undefined, description: firstText(source.description) || undefined, hasAudio: source.hasAudio === true, audioStatus: normalizeCatalogAudioStatus(source.audioStatus), supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages), poiId: stringifyId(source.poiId) || undefined, mapX: normalizeNumber(source.mapX), mapY: normalizeNumber(source.mapY), outlineId: stringifyId(source.outlineId) || outline?.id, outlineName: outline?.name, sort: typeof source.sort === 'number' ? source.sort : undefined, guideLevel: firstText(source.guideLevel) || undefined, imageStatus, linkedExhibitCount: normalizeNumber(source.linkedExhibitCount), isSharedStop: source.isSharedStop === true, linkedExhibits: (source.linkedExhibits || []) .map(toCatalogLinkedExhibitSummary) .filter(Boolean) as NonNullable, stopId: id, // Catalog audio metadata is intentionally display-only: playback always // comes from the unified stop detail and therefore has no URL here. audioOptions: (source.audioOptions || []) .map((option) => { const gender = option.gender?.trim().toLowerCase() if (gender !== 'male' && gender !== 'female') return null const languageCode = normalizeGuideAudioLanguage(option.languageCode) const version = option.version?.trim().toLowerCase() === 'extended' ? 'extended' : 'standard' return { channelCode: `${version}.${languageCode}.${gender}`, version, languageCode, languageName: firstText(option.languageName) || undefined, gender, displayName: firstText(option.displayName) || undefined, playUrl: '', isDefault: option.isDefault === true } }) .filter(Boolean) as NonNullable } } export const toCatalogMuseumExhibitFromStop = ( stop: ExplainGuideStop, hall?: MuseumHall | null ): MuseumExhibit => { const linkedPrimary = stop.linkedExhibits?.[0] const audioReady = stop.audioStatus === 'READY' return { id: stop.id, name: stop.name, hallId: stop.hallId || hall?.id, hallName: stop.hallName || hall?.name, floorId: stop.floorId || hall?.floorId, floorLabel: hall?.floorLabel, image: stop.coverImageUrl || undefined, description: stop.description || linkedPrimary?.name || '该讲解点暂无简介。', tags: [stop.outlineName, linkedPrimary?.exhibitCode].filter(Boolean) as string[], poiId: stop.poiId, sourcePoiId: stop.poiId, mapX: stop.mapX, mapY: stop.mapY, guideTitle: stop.name, guideText: stop.description, audioAvailable: audioReady, audioStatus: stop.audioStatus, supportedLanguages: stop.supportedLanguages, // List summaries do not carry authoritative text availability. It is read // from the unified detail together with the full text variants. audioHasText: false, imageStatus: stop.imageStatus, linkedExhibitCount: stop.linkedExhibitCount, isSharedStop: stop.isSharedStop, linkedExhibits: stop.linkedExhibits, stopInfoAvailable: true, resolvedStopId: stop.id } } const outlineKey = (outlineId?: string | null) => outlineId || '__unassigned__' export const flattenCatalogOutlines = ( outlines: BackendCatalogOutlineItem[] ): BackendCatalogOutlineItem[] => { const flattened: BackendCatalogOutlineItem[] = [] const visit = (items: BackendCatalogOutlineItem[]) => { items .slice() .sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)) .forEach((item) => { flattened.push(item) if (Array.isArray(item.children) && item.children.length) { visit(item.children) } }) } visit(outlines) return flattened } export const toCatalogBusinessUnits = ( hallId: string, outlines: BackendCatalogOutlineItem[], stops: ExplainGuideStop[] ): ExplainBusinessUnit[] => { const flatOutlines = flattenCatalogOutlines(outlines) const stopMap = new Map() stops.forEach((stop) => { const key = outlineKey(stop.outlineId) stopMap.set(key, [...(stopMap.get(key) || []), stop]) }) const outlineChildren = new Set( flatOutlines .map((item) => stringifyId(item.parentId)) .filter((parentId) => parentId && parentId !== hallId) ) const units = flatOutlines .map((outline) => { const id = stringifyId(outline.id) if (!id) return null const directStops = (stopMap.get(id) || []) .slice() .sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)) const hasChildOutlines = outlineChildren.has(id) const rawStopCount = normalizeNumber(outline.stopCount) ?? 0 if (!directStops.length && hasChildOutlines) return null return { id, name: firstText(outline.name, outline.code, `业务单元${id}`), hallId: stringifyId(outline.hallId) || hallId, parentId: stringifyId(outline.parentId) || undefined, code: firstText(outline.code) || undefined, description: firstText(outline.description) || undefined, sort: typeof outline.sort === 'number' ? outline.sort : undefined, level: typeof outline.level === 'number' ? outline.level : undefined, guideStopCount: directStops.length || rawStopCount, linkedExhibitCount: normalizeNumber(outline.linkedExhibitCount), audioReadyStopCount: normalizeNumber(outline.audioReadyStopCount), hasAudio: outline.hasAudio === true, audioStatus: normalizeCatalogAudioStatus(outline.audioStatus), supportedLanguages: normalizeSupportedLanguages(outline.supportedLanguages), stops: directStops } }) .filter(Boolean) as ExplainBusinessUnit[] if (units.length) return units return groupGuideStopsByOutline(hallId, stops) } export const toBackendMuseumExhibit = ( source: BackendExhibit, hall?: MuseumHall | null, options: { includeDetail?: boolean } = {} ): MuseumExhibit => { const id = stringifyId(source.id) const guide = options.includeDetail ? selectPrimaryGuideContent(source) : null const guideText = options.includeDetail ? resolveGuideText(guide, source) : firstText(source.narrationText, source.description) const audioUrl = options.includeDetail ? resolveGuideAudioUrl(guide, source) : normalizeSameOriginPublicUrl(firstText(source.audioUrl)) const targetType = normalizeAudioTargetType(guide?.targetType || source.playTargetType) const targetId = stringifyId(guide?.targetId || source.playTargetId) const supportedLanguages = normalizeSupportedLanguages(source.supportedLanguages) const supportedLanguage = supportedLanguages[0] const metadataSuggestsAudio = source.hasAudio === true && isAudioReady(source.audioStatus) const audioAvailable = Boolean(audioUrl) const sourcePoiId = stringifyId(source.poiId) const location = hall?.location return { id, name: firstText(source.name, source.scientificName, source.exhibitCode, `展品${id}`), hallId: stringifyId(source.hallId) || hall?.id, hallName: firstText(source.hallName, hall?.name), floorId: stringifyId(source.floorId) || hall?.floorId, floorLabel: firstText(source.floorLabel, hall?.floorLabel), image: resolveImage(source) || EXHIBIT_PLACEHOLDER_IMAGE, description: firstText(source.description, source.narrationText, source.scientificName, '该展项暂无讲解文稿。'), poiId: sourcePoiId || location?.poiId || undefined, sourcePoiId: sourcePoiId || location?.sourcePoiId, location, year: firstText(source.era) || undefined, material: firstText(source.origin) || undefined, size: firstText(source.exhibitCode, source.dimensions) || undefined, tags: buildTags(source), guideContentId: stringifyId(guide?.id) || undefined, guideTitle: firstText(guide?.title) || undefined, guideText: guideText || undefined, audioUrl: audioUrl || undefined, audioDuration: resolveGuideAudioDuration(guide, source), audioLanguage: supportedLanguage, audioText: options.includeDetail && guideText ? guideText : undefined, audioHasText: options.includeDetail ? Boolean(guideText) : undefined, audioAvailable: audioAvailable || metadataSuggestsAudio, audioStatus: source.audioStatus || undefined, supportedLanguages: supportedLanguages.length ? supportedLanguages : undefined, audioUnavailableReason: audioAvailable || metadataSuggestsAudio ? undefined : '该讲解暂无已发布音频,当前提供图文讲解。', playTargetType: targetType, playTargetId: targetId || undefined } } export const toBackendExplainTrack = (exhibit: MuseumExhibit): ExplainTrack => ({ id: `track-${exhibit.guideContentId || exhibit.id}`, exhibitId: exhibit.id, hallId: exhibit.hallId, title: exhibit.guideTitle || `${exhibit.name}讲解`, summary: exhibit.guideText || exhibit.description, mediaId: exhibit.audioUrl ? `media-${exhibit.guideContentId || exhibit.id}` : undefined, coverImage: exhibit.image, poiId: exhibit.poiId, floorId: exhibit.floorId, available: Boolean(exhibit.audioUrl) || exhibit.audioAvailable === true, playTargetType: exhibit.playTargetType, playTargetId: exhibit.playTargetId }) export const toBackendMediaAsset = (exhibit: MuseumExhibit): MediaAsset | null => { if (!exhibit.audioUrl) return null return { id: `media-${exhibit.guideContentId || exhibit.id}`, type: 'audio', url: exhibit.audioUrl, duration: exhibit.audioDuration, language: exhibit.audioLanguage, available: true } } export const toBackendHall = ( hallId: string, exhibits: MuseumExhibit[], fallback?: MuseumHall | null ): MuseumHall => { const first = exhibits[0] return { id: hallId, name: firstText(first?.hallName, fallback?.name, '展厅'), floorId: firstText(first?.floorId, fallback?.floorId) || undefined, floorLabel: firstText(first?.floorLabel, fallback?.floorLabel, '楼层待补充'), description: fallback?.description || '该免费讲解内容来自后端展品接口。', image: fallback?.image || HALL_PLACEHOLDER_IMAGE, exhibitCount: exhibits.length, area: fallback?.area, poiId: fallback?.poiId, location: fallback?.location } } export const toBackendHallFromList = (source: BackendHall, fallback?: MuseumHall | null): MuseumHall => { const id = stringifyId(source.id) || fallback?.id || firstText(source.hallCode, source.name) const name = firstText(source.name, fallback?.name, source.hallCode, '展厅') return { id, name, floorId: stringifyId(source.floorId) || fallback?.floorId, floorLabel: firstText(source.floorCode, fallback?.floorLabel) || undefined, description: firstText(source.description, source.subtitle, fallback?.description, '该展厅暂无介绍。'), image: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || fallback?.image || HALL_PLACEHOLDER_IMAGE, exhibitCount: typeof source.exhibitCount === 'number' ? source.exhibitCount : fallback?.exhibitCount || 0, outlineCount: normalizeNumber(source.outlineCount), stopCount: normalizeNumber(source.stopCount), linkedExhibitCount: normalizeNumber(source.linkedExhibitCount), audioReadyStopCount: normalizeNumber(source.audioReadyStopCount), hasAudio: source.hasAudio === true, audioStatus: normalizeCatalogAudioStatus(source.audioStatus), supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages), area: fallback?.area, poiId: stringifyId(source.poiId) || fallback?.poiId, location: fallback?.location } } export const toBackendGuideStop = (source: BackendGuideStop): ExplainGuideStop | null => { const id = stringifyId(source.id) if (!id) return null return { id, name: firstText(source.name, `讲解点${id}`), hallId: stringifyId(source.hallId) || undefined, hallName: firstText(source.hallName) || undefined, floorId: stringifyId(source.floorId) || undefined, targetType: 'STOP', targetId: id, coverImageUrl: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined, description: firstText(source.description) || undefined, hasAudio: source.hasAudio === true, poiId: stringifyId(source.poiId) || undefined, mapX: normalizeNumber(source.mapX), mapY: normalizeNumber(source.mapY), outlineId: stringifyId(source.outlineId) || undefined, outlineName: firstText(source.outlineName) || undefined, sort: typeof source.sort === 'number' ? source.sort : undefined } } export const groupGuideStopsByOutline = ( hallId: string, stops: ExplainGuideStop[] ): ExplainBusinessUnit[] => { const groupMap = new Map() const nameMap = new Map() stops .slice() .sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)) .forEach((stop) => { const key = stop.outlineId || `ungrouped-${hallId}` const name = stop.outlineName || '其他讲解' const items = groupMap.get(key) || [] items.push(stop) groupMap.set(key, items) if (!nameMap.has(key)) { nameMap.set(key, name) } }) return Array.from(groupMap.entries()).map(([id, groupStops]) => ({ id, name: nameMap.get(id) || '其他讲解', hallId, guideStopCount: groupStops.length, stops: groupStops })) }