接入H5讲解后端数据闭环

This commit is contained in:
lyf
2026-06-30 17:22:20 +08:00
parent 1c2cc788d1
commit e3682ed3ec
13 changed files with 815 additions and 68 deletions

View File

@@ -0,0 +1,228 @@
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
}
}