229 lines
8.2 KiB
TypeScript
229 lines
8.2 KiB
TypeScript
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<string | number | null | undefined>) => (
|
|
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
|
|
}
|
|
}
|