696 lines
25 KiB
TypeScript
696 lines
25 KiB
TypeScript
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<string | number | null | undefined>) => (
|
|
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<ExplainBusinessUnit, 'id' | 'name'> | 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<ExplainGuideStop['linkedExhibits']>,
|
|
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<ExplainGuideStop['audioOptions']>
|
|
}
|
|
}
|
|
|
|
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<string, ExplainGuideStop[]>()
|
|
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<ExplainBusinessUnit | null>((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<string, ExplainGuideStop[]>()
|
|
const nameMap = new Map<string, string>()
|
|
|
|
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
|
|
}))
|
|
}
|