import type { AudioPlayTargetType, ExplainTrack, MediaAsset, MuseumExhibit, MuseumHall } from '@/domain/museum' import { normalizeSameOriginPublicUrl } from '@/utils/publicUrl' 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 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']) => { 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 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))) 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 supportedLanguage = source.supportedLanguages?.find((language) => language === 'zh-CN' || language === 'en-US') const hasAudioSummary = source.hasAudio === true && isAudioReady(source.audioStatus) const audioAvailable = Boolean(audioUrl) || hasAudioSummary 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) || '/static/exhibit-placeholder.jpg', description: firstText(source.description, source.narrationText, source.scientificName, '该展项暂无讲解文稿。'), poiId: stringifyId(source.poiId) || undefined, 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, audioUnavailableReason: audioAvailable ? 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 || '/static/hall-placeholder.jpg', exhibitCount: exhibits.length, area: fallback?.area, poiId: fallback?.poiId, location: fallback?.location } }