This commit is contained in:
@@ -61,12 +61,22 @@ export interface BackendExhibit {
|
||||
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 {
|
||||
@@ -87,6 +97,61 @@ export interface BackendGuideStop {
|
||||
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
|
||||
sortOrder?: number | null
|
||||
}
|
||||
|
||||
export interface BackendCatalogStopItem {
|
||||
stopId?: string | number | null
|
||||
id?: string | number | null
|
||||
outlineId?: string | number | 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
|
||||
hasTextRecord?: boolean | null
|
||||
playTargetType?: string | null
|
||||
playTargetId?: string | number | null
|
||||
}
|
||||
|
||||
export interface BackendExplainAdapterResult {
|
||||
exhibit: MuseumExhibit
|
||||
track: ExplainTrack
|
||||
@@ -130,6 +195,15 @@ const normalizeAudioTargetType = (targetType: string | null | undefined): AudioP
|
||||
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())
|
||||
)
|
||||
@@ -170,6 +244,230 @@ const buildTags = (exhibit: BackendExhibit) => Array.from(new Set([
|
||||
firstText(exhibit.origin)
|
||||
].filter(Boolean)))
|
||||
|
||||
const normalizeSupportedLanguages = (languages: string[] | null | undefined) => (
|
||||
Array.isArray(languages) ? languages.map(String).filter(Boolean) : []
|
||||
)
|
||||
|
||||
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,
|
||||
exhibitCode: firstText(source.exhibitCode, source.code) || undefined,
|
||||
coverImageUrl: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined
|
||||
}
|
||||
}
|
||||
|
||||
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 playTargetType = normalizeAudioTargetType(source.playTargetType) || 'STOP'
|
||||
const playTargetId = stringifyId(source.playTargetId) || id
|
||||
const imageStatus = firstText(source.imageStatus) || 'MISSING'
|
||||
const canUseStopImage = imageStatus === 'READY'
|
||||
|
||||
return {
|
||||
id,
|
||||
name: firstText(source.name, `讲解点${id}`),
|
||||
hallId: hall?.id,
|
||||
hallName: hall?.name,
|
||||
floorId: hall?.floorId,
|
||||
targetType: playTargetType,
|
||||
targetId: playTargetId,
|
||||
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),
|
||||
hasTextRecord: source.hasTextRecord === true,
|
||||
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']>,
|
||||
playTargetType,
|
||||
playTargetId
|
||||
}
|
||||
}
|
||||
|
||||
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[],
|
||||
guideTitle: stop.name,
|
||||
guideText: stop.description,
|
||||
audioAvailable: audioReady,
|
||||
audioStatus: stop.audioStatus,
|
||||
supportedLanguages: stop.supportedLanguages,
|
||||
audioHasText: stop.hasTextRecord,
|
||||
imageStatus: stop.imageStatus,
|
||||
linkedExhibitCount: stop.linkedExhibitCount,
|
||||
isSharedStop: stop.isSharedStop,
|
||||
linkedExhibits: stop.linkedExhibits,
|
||||
stopInfoAvailable: true,
|
||||
resolvedStopId: stop.id,
|
||||
playTargetType: stop.playTargetType || stop.targetType || 'STOP',
|
||||
playTargetId: stop.playTargetId || stop.targetId || 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,
|
||||
@@ -277,15 +575,22 @@ export const toBackendHallFromList = (source: BackendHall, fallback?: MuseumHall
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
floorId: fallback?.floorId,
|
||||
floorLabel: fallback?.floorLabel,
|
||||
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: fallback?.poiId,
|
||||
poiId: stringifyId(source.poiId) || fallback?.poiId,
|
||||
location: fallback?.location
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface BackendGuideStopInfo {
|
||||
targetType?: string | null
|
||||
targetId?: string | number | null
|
||||
resolvedStopId?: string | number | null
|
||||
stopId?: string | number | null
|
||||
lang?: string | null
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
@@ -33,6 +34,40 @@ export interface BackendGuideStopInfo {
|
||||
supportedLanguages?: string[] | null
|
||||
audioStatus?: string | null
|
||||
reason?: string | null
|
||||
linkedExhibitCount?: number | string | null
|
||||
isSharedStop?: boolean | null
|
||||
}
|
||||
|
||||
export interface BackendAudioPlayInfo {
|
||||
playable?: boolean
|
||||
targetType?: string | null
|
||||
targetId?: string | number | null
|
||||
lang?: string | null
|
||||
narrationTier?: 'STANDARD' | 'EXTENDED' | string | null
|
||||
audioId?: string | number | null
|
||||
title?: string | null
|
||||
duration?: number | null
|
||||
format?: string | null
|
||||
playUrl?: string | null
|
||||
expiresAt?: string | null
|
||||
subtitleUrl?: string | null
|
||||
hasText?: boolean
|
||||
fallback?: boolean
|
||||
fallbackReason?: string | null
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
export interface BackendAudioTextInfo {
|
||||
available?: boolean
|
||||
targetType?: string | null
|
||||
targetId?: string | number | null
|
||||
lang?: string | null
|
||||
narrationTier?: 'STANDARD' | 'EXTENDED' | string | null
|
||||
title?: string | null
|
||||
text?: string | null
|
||||
textLength?: number | null
|
||||
textHash?: string | null
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
export interface GuideStopLinkedExhibit {
|
||||
@@ -63,12 +98,55 @@ export interface GuideStopInfo {
|
||||
supportedLanguages: string[]
|
||||
audioStatus: 'READY' | 'MISSING' | string
|
||||
reason?: string
|
||||
linkedExhibitCount?: number
|
||||
isSharedStop?: boolean
|
||||
}
|
||||
|
||||
export interface GuideAudioPlayInfo {
|
||||
playable: boolean
|
||||
targetType: AudioPlayTargetType
|
||||
targetId: string
|
||||
lang: 'zh-CN' | 'en-US'
|
||||
narrationTier?: 'STANDARD' | 'EXTENDED'
|
||||
audioId?: string
|
||||
title?: string
|
||||
duration?: number
|
||||
format?: string
|
||||
playUrl?: string
|
||||
expiresAt?: string
|
||||
subtitleUrl?: string
|
||||
hasText: boolean
|
||||
fallback: boolean
|
||||
fallbackReason?: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface GuideAudioTextInfo {
|
||||
available: boolean
|
||||
targetType: AudioPlayTargetType
|
||||
targetId: string
|
||||
lang: 'zh-CN' | 'en-US'
|
||||
narrationTier?: 'STANDARD' | 'EXTENDED'
|
||||
title?: string
|
||||
text?: string
|
||||
textLength?: number
|
||||
textHash?: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
const stringifyId = (value: string | number | null | undefined) => (
|
||||
value === null || typeof value === 'undefined' ? '' : String(value)
|
||||
)
|
||||
|
||||
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 normalizeTargetType = (value: string | null | undefined, fallback: AudioPlayTargetType): AudioPlayTargetType => {
|
||||
const normalized = value?.toUpperCase()
|
||||
return normalized === 'ITEM' || normalized === 'STOP' ? normalized : fallback
|
||||
@@ -95,13 +173,15 @@ const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls']) => {
|
||||
.filter(Boolean)
|
||||
}
|
||||
} catch {
|
||||
// 兼容后端历史逗号拼接字段。
|
||||
return []
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.split(',')
|
||||
.map((entry) => normalizeSameOriginPublicUrl(entry.trim()))
|
||||
.filter(Boolean)
|
||||
return []
|
||||
}
|
||||
|
||||
const normalizeNarrationTier = (value: string | null | undefined) => {
|
||||
const normalized = value?.toUpperCase()
|
||||
return normalized === 'STANDARD' || normalized === 'EXTENDED' ? normalized : undefined
|
||||
}
|
||||
|
||||
const normalizeLinkedExhibits = (items: BackendGuideStopLinkedExhibit[] | null | undefined) => (
|
||||
@@ -144,7 +224,7 @@ export const toGuideStopInfo = (
|
||||
available: source.available === true,
|
||||
targetType,
|
||||
targetId,
|
||||
resolvedStopId: stringifyId(source.resolvedStopId) || undefined,
|
||||
resolvedStopId: stringifyId(source.resolvedStopId) || stringifyId(source.stopId) || undefined,
|
||||
lang: normalizeLanguage(source.lang || fallback.lang),
|
||||
title: source.title?.trim() || '讲解内容',
|
||||
description: source.description?.trim() || undefined,
|
||||
@@ -159,6 +239,64 @@ export const toGuideStopInfo = (
|
||||
hasText: source.hasText === true,
|
||||
supportedLanguages: source.supportedLanguages || [],
|
||||
audioStatus: source.audioStatus || 'MISSING',
|
||||
reason: source.reason || undefined,
|
||||
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
|
||||
isSharedStop: source.isSharedStop === true
|
||||
}
|
||||
}
|
||||
|
||||
export const toGuideAudioPlayInfo = (
|
||||
source: BackendAudioPlayInfo,
|
||||
fallback: {
|
||||
targetType: AudioPlayTargetType
|
||||
targetId: string
|
||||
lang: 'zh-CN' | 'en-US'
|
||||
}
|
||||
): GuideAudioPlayInfo => {
|
||||
const targetType = normalizeTargetType(source.targetType, fallback.targetType)
|
||||
const targetId = stringifyId(source.targetId) || fallback.targetId
|
||||
|
||||
return {
|
||||
playable: source.playable === true,
|
||||
targetType,
|
||||
targetId,
|
||||
lang: normalizeLanguage(source.lang || fallback.lang),
|
||||
narrationTier: normalizeNarrationTier(source.narrationTier),
|
||||
audioId: stringifyId(source.audioId) || undefined,
|
||||
title: source.title?.trim() || undefined,
|
||||
duration: typeof source.duration === 'number' && Number.isFinite(source.duration) ? source.duration : undefined,
|
||||
format: source.format?.trim() || undefined,
|
||||
playUrl: normalizeSameOriginPublicUrl(source.playUrl) || undefined,
|
||||
expiresAt: source.expiresAt || undefined,
|
||||
subtitleUrl: normalizeSameOriginPublicUrl(source.subtitleUrl) || undefined,
|
||||
hasText: source.hasText === true,
|
||||
fallback: source.fallback === true,
|
||||
fallbackReason: source.fallbackReason || undefined,
|
||||
reason: source.reason || undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const toGuideAudioTextInfo = (
|
||||
source: BackendAudioTextInfo,
|
||||
fallback: {
|
||||
targetType: AudioPlayTargetType
|
||||
targetId: string
|
||||
lang: 'zh-CN' | 'en-US'
|
||||
}
|
||||
): GuideAudioTextInfo => {
|
||||
const targetType = normalizeTargetType(source.targetType, fallback.targetType)
|
||||
const targetId = stringifyId(source.targetId) || fallback.targetId
|
||||
|
||||
return {
|
||||
available: source.available === true,
|
||||
targetType,
|
||||
targetId,
|
||||
lang: normalizeLanguage(source.lang || fallback.lang),
|
||||
narrationTier: normalizeNarrationTier(source.narrationTier),
|
||||
title: source.title?.trim() || undefined,
|
||||
text: source.text || undefined,
|
||||
textLength: typeof source.textLength === 'number' && Number.isFinite(source.textLength) ? source.textLength : undefined,
|
||||
textHash: source.textHash || undefined,
|
||||
reason: source.reason || undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,21 +11,15 @@ import type {
|
||||
ExplainContentProvider
|
||||
} from '@/data/providers/staticMuseumContentProvider'
|
||||
import {
|
||||
defaultSgsSdkApiProvider,
|
||||
type SgsSdkApiProvider
|
||||
} from '@/data/providers/sgsSdkApiProvider'
|
||||
import {
|
||||
toBackendExplainTrack,
|
||||
toBackendHallFromList,
|
||||
toBackendMediaAsset,
|
||||
toBackendMuseumExhibit,
|
||||
groupGuideStopsByOutline,
|
||||
type BackendExhibit,
|
||||
type BackendHall
|
||||
toCatalogBusinessUnits,
|
||||
toCatalogGuideStop,
|
||||
toCatalogHall,
|
||||
toCatalogMuseumExhibitFromStop,
|
||||
flattenCatalogOutlines,
|
||||
type BackendCatalogHallItem,
|
||||
type BackendCatalogOutlineItem,
|
||||
type BackendCatalogStopItem
|
||||
} from '@/data/adapters/backendExplainDataAdapter'
|
||||
import {
|
||||
toExplainGuideStopFromSgs
|
||||
} from '@/data/adapters/sgsSdkGuideAdapter'
|
||||
|
||||
interface CommonResult<T> {
|
||||
code: number
|
||||
@@ -79,234 +73,414 @@ const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject
|
||||
|
||||
const normalizeKeyword = (keyword = '') => keyword.trim()
|
||||
|
||||
const catalogLang = () => (
|
||||
dataSourceConfig.audioLanguage === 'en-US' ? 'en-US' : 'zh-CN'
|
||||
)
|
||||
|
||||
const cacheKey = (...parts: string[]) => [catalogLang(), ...parts].join(':')
|
||||
|
||||
const requireArrayData = <T>(response: CommonResult<T[]>, message: string) => {
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || message)
|
||||
}
|
||||
|
||||
return Array.isArray(response.data) ? response.data : []
|
||||
}
|
||||
|
||||
export class BackendExplainContentProvider implements ExplainContentProvider {
|
||||
private readonly searchCache = new Map<string, MuseumExhibit[]>()
|
||||
private readonly searchInflight = new Map<string, Promise<MuseumExhibit[]>>()
|
||||
private readonly explainExhibitCache = new Map<string, MuseumExhibit[]>()
|
||||
private readonly explainExhibitInflight = new Map<string, Promise<MuseumExhibit[]>>()
|
||||
private readonly detailCache = new Map<string, MuseumExhibit>()
|
||||
private readonly detailInflight = new Map<string, Promise<MuseumExhibit | null>>()
|
||||
private hallListCache: MuseumHall[] | null = null
|
||||
private hallListInflight: Promise<MuseumHall[]> | null = null
|
||||
private readonly hallListCache = new Map<string, MuseumHall[]>()
|
||||
private readonly hallListInflight = new Map<string, Promise<MuseumHall[]>>()
|
||||
private readonly guideStopCache = new Map<string, ExplainGuideStop[]>()
|
||||
private readonly guideStopInflight = new Map<string, Promise<ExplainGuideStop[]>>()
|
||||
private readonly outlineCache = new Map<string, BackendCatalogOutlineItem[]>()
|
||||
private readonly outlineInflight = new Map<string, Promise<BackendCatalogOutlineItem[]>>()
|
||||
private readonly businessUnitCache = new Map<string, ExplainBusinessUnit[]>()
|
||||
private readonly businessUnitInflight = new Map<string, Promise<ExplainBusinessUnit[]>>()
|
||||
|
||||
constructor(
|
||||
private readonly fallbackProvider: ExplainContentProvider,
|
||||
private readonly sgsSdkApiProvider: SgsSdkApiProvider = defaultSgsSdkApiProvider
|
||||
private readonly fallbackProvider?: ExplainContentProvider
|
||||
) {}
|
||||
|
||||
private async requestStaticExplainExhibits() {
|
||||
const cached = this.searchCache.get('')
|
||||
if (cached) return cached
|
||||
|
||||
const exhibits = await this.fallbackProvider.listExplainExhibits()
|
||||
this.searchCache.set('', exhibits)
|
||||
return exhibits
|
||||
}
|
||||
|
||||
private async searchStaticExplainExhibits(keyword = '') {
|
||||
const normalizedKeyword = normalizeKeyword(keyword).toLowerCase()
|
||||
const exhibits = await this.requestStaticExplainExhibits()
|
||||
if (!normalizedKeyword) return exhibits
|
||||
|
||||
return exhibits.filter((exhibit) => [
|
||||
exhibit.name,
|
||||
exhibit.hallName,
|
||||
exhibit.floorLabel,
|
||||
exhibit.description,
|
||||
...(exhibit.tags || [])
|
||||
].filter(Boolean).join(' ').toLowerCase().includes(normalizedKeyword))
|
||||
}
|
||||
|
||||
private async requestSearch(keyword = '') {
|
||||
const normalizedKeyword = normalizeKeyword(keyword)
|
||||
if (!normalizedKeyword) return this.requestStaticExplainExhibits()
|
||||
|
||||
const cacheKey = normalizedKeyword.toLowerCase()
|
||||
const cached = this.searchCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const inflight = this.searchInflight.get(cacheKey)
|
||||
if (inflight) return inflight
|
||||
|
||||
const promise = (async () => {
|
||||
const params = new URLSearchParams({ keyword: normalizedKeyword })
|
||||
const url = `${resolveAppApiBaseUrl()}/gis/exhibit/search?${params.toString()}`
|
||||
const response = await requestJson<CommonResult<BackendExhibit[]>>(url)
|
||||
if (response.code !== 0 || !Array.isArray(response.data)) {
|
||||
throw new Error(response.msg || '后端讲解搜索失败')
|
||||
}
|
||||
|
||||
const fallbackHalls = await this.safeFallbackHalls()
|
||||
const fallbackHallById = new Map(fallbackHalls.map((hall) => [hall.id, hall]))
|
||||
const exhibits = response.data.map((item) => toBackendMuseumExhibit(
|
||||
item,
|
||||
fallbackHallById.get(String(item.hallId || '')),
|
||||
{ includeDetail: false }
|
||||
))
|
||||
|
||||
this.searchCache.set(cacheKey, exhibits)
|
||||
return exhibits
|
||||
})()
|
||||
|
||||
this.searchInflight.set(cacheKey, promise)
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
this.searchInflight.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
private async requestDetail(id: string) {
|
||||
const cached = this.detailCache.get(id)
|
||||
if (cached) return cached
|
||||
|
||||
const inflight = this.detailInflight.get(id)
|
||||
if (inflight) return inflight
|
||||
|
||||
const promise = (async () => {
|
||||
const params = new URLSearchParams({ id })
|
||||
const url = `${resolveAppApiBaseUrl()}/gis/exhibit/get?${params.toString()}`
|
||||
const response = await requestJson<CommonResult<BackendExhibit>>(url)
|
||||
if (response.code !== 0 || !response.data) {
|
||||
throw new Error(response.msg || '后端讲解详情失败')
|
||||
}
|
||||
|
||||
const fallbackHalls = await this.safeFallbackHalls()
|
||||
const fallbackHall = fallbackHalls.find((hall) => hall.id === String(response.data?.hallId || ''))
|
||||
const detail = toBackendMuseumExhibit(response.data, fallbackHall, { includeDetail: true })
|
||||
this.detailCache.set(id, detail)
|
||||
return detail
|
||||
})().catch(async (error) => {
|
||||
console.warn('后端讲解详情加载失败,将使用静态讲解兜底:', error)
|
||||
return this.fallbackProvider.listExhibits()
|
||||
.then((items) => items.find((item) => item.id === id) || null)
|
||||
.catch(() => null)
|
||||
})
|
||||
|
||||
this.detailInflight.set(id, promise)
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
this.detailInflight.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private async safeFallbackHalls() {
|
||||
return this.fallbackProvider.listHalls().catch(() => [])
|
||||
if (!this.isStaticFallbackEnabled()) return []
|
||||
|
||||
return this.fallbackProvider?.listHalls().catch(() => []) || []
|
||||
}
|
||||
|
||||
private async requestHallList() {
|
||||
if (this.hallListCache) return this.hallListCache
|
||||
if (this.hallListInflight) return this.hallListInflight
|
||||
private isStaticFallbackEnabled() {
|
||||
return Boolean(this.fallbackProvider && dataSourceConfig.allowExplainStaticFallback)
|
||||
}
|
||||
|
||||
this.hallListInflight = (async () => {
|
||||
const url = `${resolveAppApiBaseUrl()}/gis/hall/list`
|
||||
const response = await requestJson<CommonResult<BackendHall[]>>(url)
|
||||
if (response.code !== 0 || !Array.isArray(response.data)) {
|
||||
throw new Error(response.msg || '后端展厅列表加载失败')
|
||||
}
|
||||
private async fallbackOrThrow<T>(
|
||||
error: unknown,
|
||||
message: string,
|
||||
fallback: () => Promise<T>
|
||||
): Promise<T> {
|
||||
if (this.isStaticFallbackEnabled()) {
|
||||
console.warn(`${message},将使用显式静态讲解兜底:`, error)
|
||||
return fallback()
|
||||
}
|
||||
|
||||
console.error(message, error)
|
||||
throw error
|
||||
}
|
||||
|
||||
private async requestCatalogHalls() {
|
||||
const key = cacheKey('halls')
|
||||
const cached = this.hallListCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const inflight = this.hallListInflight.get(key)
|
||||
if (inflight) return inflight
|
||||
|
||||
const promise = (async () => {
|
||||
const params = new URLSearchParams({ lang: catalogLang() })
|
||||
const url = `${resolveAppApiBaseUrl()}/gis/guide/catalog/halls?${params.toString()}`
|
||||
const response = await requestJson<CommonResult<BackendCatalogHallItem[]>>(url)
|
||||
const items = requireArrayData(response, '讲解展厅目录加载失败')
|
||||
const fallbackHalls = await this.safeFallbackHalls()
|
||||
const fallbackHallById = new Map(fallbackHalls.map((hall) => [hall.id, hall]))
|
||||
const fallbackHallByName = new Map(fallbackHalls.map((hall) => [hall.name, hall]))
|
||||
const halls = response.data
|
||||
.map((item) => toBackendHallFromList(
|
||||
const halls = items
|
||||
.map((item) => toCatalogHall(
|
||||
item,
|
||||
fallbackHallById.get(String(item.id || '')) || fallbackHallByName.get(String(item.name || ''))
|
||||
))
|
||||
.filter((hall) => hall.id)
|
||||
|
||||
this.hallListCache = halls
|
||||
this.hallListCache.set(key, halls)
|
||||
return halls
|
||||
})()
|
||||
|
||||
try {
|
||||
return await this.hallListInflight
|
||||
} finally {
|
||||
this.hallListInflight = null
|
||||
}
|
||||
}
|
||||
|
||||
async listGuideStopsByHall(hallId: string) {
|
||||
const normalizedHallId = hallId.trim()
|
||||
if (!normalizedHallId) return []
|
||||
|
||||
const cached = this.guideStopCache.get(normalizedHallId)
|
||||
if (cached) return cached
|
||||
|
||||
const inflight = this.guideStopInflight.get(normalizedHallId)
|
||||
if (inflight) return inflight
|
||||
|
||||
const promise = (async () => {
|
||||
const stops = (await this.sgsSdkApiProvider.getGuideStopsByHall(normalizedHallId))
|
||||
.map(toExplainGuideStopFromSgs)
|
||||
.filter(Boolean) as ExplainGuideStop[]
|
||||
|
||||
this.guideStopCache.set(normalizedHallId, stops)
|
||||
return stops
|
||||
})()
|
||||
|
||||
this.guideStopInflight.set(normalizedHallId, promise)
|
||||
this.hallListInflight.set(key, promise)
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
this.guideStopInflight.delete(normalizedHallId)
|
||||
this.hallListInflight.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
private async requestCatalogOutlinesByHall(hallId: string) {
|
||||
const normalizedHallId = hallId.trim()
|
||||
if (!normalizedHallId) return []
|
||||
|
||||
const key = cacheKey('hall', normalizedHallId, 'outlines')
|
||||
const cached = this.outlineCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const inflight = this.outlineInflight.get(key)
|
||||
if (inflight) return inflight
|
||||
|
||||
const promise = (async () => {
|
||||
const params = new URLSearchParams({
|
||||
includeChildren: 'true',
|
||||
lang: catalogLang()
|
||||
})
|
||||
const url = `${resolveAppApiBaseUrl()}/gis/guide/catalog/halls/${encodeURIComponent(normalizedHallId)}/outlines?${params.toString()}`
|
||||
const response = await requestJson<CommonResult<BackendCatalogOutlineItem[]>>(url)
|
||||
const outlines = requireArrayData(response, '讲解单元目录加载失败')
|
||||
this.outlineCache.set(key, outlines)
|
||||
return outlines
|
||||
})()
|
||||
|
||||
this.outlineInflight.set(key, promise)
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
this.outlineInflight.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
private async requestCatalogStopsByHall(hallId: string) {
|
||||
const normalizedHallId = hallId.trim()
|
||||
if (!normalizedHallId) return []
|
||||
|
||||
const key = cacheKey('hall', normalizedHallId, 'stops')
|
||||
const cached = this.guideStopCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const inflight = this.guideStopInflight.get(key)
|
||||
if (inflight) return inflight
|
||||
|
||||
const promise = (async () => {
|
||||
const [halls, outlines] = await Promise.all([
|
||||
this.requestCatalogHalls(),
|
||||
this.requestCatalogOutlinesByHall(normalizedHallId).catch(() => [])
|
||||
])
|
||||
const hall = halls.find((item) => item.id === normalizedHallId) || null
|
||||
const outlineById = new Map(flattenCatalogOutlines(outlines).map((outline) => [String(outline.id || ''), outline]))
|
||||
|
||||
const params = new URLSearchParams({ lang: catalogLang() })
|
||||
const url = `${resolveAppApiBaseUrl()}/gis/guide/catalog/halls/${encodeURIComponent(normalizedHallId)}/stops?${params.toString()}`
|
||||
const response = await requestJson<CommonResult<BackendCatalogStopItem[]>>(url)
|
||||
const stops = requireArrayData(response, '讲解点目录加载失败')
|
||||
.map((item) => {
|
||||
const outline = outlineById.get(String(item.outlineId || ''))
|
||||
return toCatalogGuideStop(item, hall, outline
|
||||
? {
|
||||
id: String(outline.id || ''),
|
||||
name: String(outline.name || '')
|
||||
}
|
||||
: null)
|
||||
})
|
||||
.filter(Boolean) as ExplainGuideStop[]
|
||||
|
||||
this.guideStopCache.set(key, stops)
|
||||
return stops
|
||||
})()
|
||||
|
||||
this.guideStopInflight.set(key, promise)
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
this.guideStopInflight.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
private async requestCatalogStopsByOutline(hallId: string, outlineId: string) {
|
||||
const normalizedHallId = hallId.trim()
|
||||
const normalizedOutlineId = outlineId.trim()
|
||||
if (!normalizedOutlineId) return []
|
||||
|
||||
const hallStopsKey = cacheKey('hall', normalizedHallId, 'stops')
|
||||
const cachedHallStops = this.guideStopCache.get(hallStopsKey)
|
||||
if (cachedHallStops) {
|
||||
return cachedHallStops.filter((stop) => stop.outlineId === normalizedOutlineId)
|
||||
}
|
||||
|
||||
const key = cacheKey('outline', normalizedOutlineId, 'stops')
|
||||
const cached = this.guideStopCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const inflight = this.guideStopInflight.get(key)
|
||||
if (inflight) return inflight
|
||||
|
||||
const promise = (async () => {
|
||||
const [halls, outlines] = await Promise.all([
|
||||
this.requestCatalogHalls(),
|
||||
normalizedHallId ? this.requestCatalogOutlinesByHall(normalizedHallId).catch(() => []) : Promise.resolve([])
|
||||
])
|
||||
const outline = flattenCatalogOutlines(outlines).find((item) => String(item.id || '') === normalizedOutlineId)
|
||||
const hall = halls.find((item) => item.id === (String(outline?.hallId || '') || normalizedHallId)) || null
|
||||
|
||||
const params = new URLSearchParams({ lang: catalogLang() })
|
||||
const url = `${resolveAppApiBaseUrl()}/gis/guide/catalog/outlines/${encodeURIComponent(normalizedOutlineId)}/stops?${params.toString()}`
|
||||
const response = await requestJson<CommonResult<BackendCatalogStopItem[]>>(url)
|
||||
const stops = requireArrayData(response, '单元讲解点目录加载失败')
|
||||
.map((item) => toCatalogGuideStop(item, hall, outline
|
||||
? {
|
||||
id: String(outline.id || ''),
|
||||
name: String(outline.name || '')
|
||||
}
|
||||
: null))
|
||||
.filter(Boolean) as ExplainGuideStop[]
|
||||
|
||||
this.guideStopCache.set(key, stops)
|
||||
return stops
|
||||
})()
|
||||
|
||||
this.guideStopInflight.set(key, promise)
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
this.guideStopInflight.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
private async requestCatalogExhibits() {
|
||||
const key = cacheKey('exhibits')
|
||||
const cached = this.explainExhibitCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const inflight = this.explainExhibitInflight.get(key)
|
||||
if (inflight) return inflight
|
||||
|
||||
const promise = (async () => {
|
||||
const halls = await this.requestCatalogHalls()
|
||||
const stopsByHall = await Promise.all(halls.map(async (hall) => {
|
||||
const stops = await this.requestCatalogStopsByHall(hall.id)
|
||||
return stops.map((stop) => toCatalogMuseumExhibitFromStop(stop, hall))
|
||||
}))
|
||||
const exhibits = stopsByHall.flat()
|
||||
this.explainExhibitCache.set(key, exhibits)
|
||||
exhibits.forEach((exhibit) => {
|
||||
this.detailCache.set(exhibit.id, exhibit)
|
||||
})
|
||||
return exhibits
|
||||
})()
|
||||
|
||||
this.explainExhibitInflight.set(key, promise)
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
this.explainExhibitInflight.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
private async searchCatalogExhibits(keyword = '') {
|
||||
const normalizedKeyword = normalizeKeyword(keyword).toLowerCase()
|
||||
const exhibits = await this.requestCatalogExhibits()
|
||||
if (!normalizedKeyword) return exhibits
|
||||
|
||||
return exhibits.filter((exhibit) => [
|
||||
exhibit.id,
|
||||
exhibit.name,
|
||||
exhibit.hallName,
|
||||
exhibit.floorLabel,
|
||||
exhibit.description,
|
||||
exhibit.playTargetId,
|
||||
...(exhibit.tags || []),
|
||||
...(exhibit.linkedExhibits || []).flatMap((item) => [item.id, item.name, item.exhibitCode])
|
||||
].filter(Boolean).join(' ').toLowerCase().includes(normalizedKeyword))
|
||||
}
|
||||
|
||||
async listGuideStopsByHall(hallId: string) {
|
||||
try {
|
||||
return await this.requestCatalogStopsByHall(hallId)
|
||||
} catch (error) {
|
||||
return this.fallbackOrThrow(error, '讲解点目录加载失败', async () => (
|
||||
this.fallbackProvider?.listGuideStopsByHall?.(hallId) || []
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async listTemporaryBusinessUnitsByHall(hallId: string): Promise<ExplainBusinessUnit[]> {
|
||||
const stops = await this.listGuideStopsByHall(hallId)
|
||||
return groupGuideStopsByOutline(hallId, stops)
|
||||
const normalizedHallId = hallId.trim()
|
||||
if (!normalizedHallId) return []
|
||||
|
||||
const key = cacheKey('hall', normalizedHallId, 'units')
|
||||
const cached = this.businessUnitCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const inflight = this.businessUnitInflight.get(key)
|
||||
if (inflight) return inflight
|
||||
|
||||
const promise = (async () => {
|
||||
const [outlines, stops] = await Promise.all([
|
||||
this.requestCatalogOutlinesByHall(normalizedHallId),
|
||||
this.requestCatalogStopsByHall(normalizedHallId)
|
||||
])
|
||||
const units = toCatalogBusinessUnits(normalizedHallId, outlines, stops)
|
||||
|
||||
await Promise.all(units.map(async (unit) => {
|
||||
if (unit.stops.length) return
|
||||
unit.stops = await this.requestCatalogStopsByOutline(normalizedHallId, unit.id).catch(() => [])
|
||||
unit.guideStopCount = unit.stops.length || unit.guideStopCount
|
||||
}))
|
||||
|
||||
this.businessUnitCache.set(key, units)
|
||||
return units
|
||||
})().catch((error) => {
|
||||
return this.fallbackOrThrow(error, '讲解单元目录加载失败', async () => (
|
||||
this.fallbackProvider?.listTemporaryBusinessUnitsByHall?.(normalizedHallId) || []
|
||||
))
|
||||
})
|
||||
|
||||
this.businessUnitInflight.set(key, promise)
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
this.businessUnitInflight.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
async listExplainExhibits() {
|
||||
return this.requestStaticExplainExhibits()
|
||||
try {
|
||||
return await this.requestCatalogExhibits()
|
||||
} catch (error) {
|
||||
return this.fallbackOrThrow(error, '讲解目录展项加载失败', async () => (
|
||||
this.fallbackProvider?.listExplainExhibits() || []
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
listExhibits() {
|
||||
return this.listExplainExhibits()
|
||||
}
|
||||
|
||||
getExhibitById(id: string) {
|
||||
return this.requestDetail(id)
|
||||
async getExhibitById(id: string) {
|
||||
const normalizedId = id.trim()
|
||||
if (!normalizedId) return null
|
||||
|
||||
const cached = this.detailCache.get(normalizedId)
|
||||
if (cached) return cached
|
||||
|
||||
const catalogExhibits = await this.listExplainExhibits().catch(() => [])
|
||||
const matched = catalogExhibits.find((exhibit) => (
|
||||
exhibit.id === normalizedId
|
||||
|| exhibit.playTargetId === normalizedId
|
||||
|| exhibit.resolvedStopId === normalizedId
|
||||
|| exhibit.linkedExhibits?.some((linked) => linked.id === normalizedId)
|
||||
))
|
||||
if (matched) {
|
||||
this.detailCache.set(normalizedId, matched)
|
||||
return matched
|
||||
}
|
||||
|
||||
if (!this.isStaticFallbackEnabled()) return null
|
||||
|
||||
return this.fallbackProvider?.getExhibitById?.(normalizedId)
|
||||
|| this.fallbackProvider?.listExhibits()
|
||||
.then((items) => items.find((item) => item.id === normalizedId) || null)
|
||||
.catch(() => null)
|
||||
|| null
|
||||
}
|
||||
|
||||
async listHalls() {
|
||||
try {
|
||||
return await this.requestHallList()
|
||||
return await this.requestCatalogHalls()
|
||||
} catch (error) {
|
||||
console.warn('后端展厅列表加载失败,将使用静态展厅兜底:', error)
|
||||
return this.fallbackProvider.listHalls()
|
||||
return this.fallbackOrThrow(error, '讲解展厅目录加载失败', async () => (
|
||||
this.fallbackProvider?.listHalls() || []
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async listTracks() {
|
||||
const exhibits = await this.listExplainExhibits()
|
||||
return exhibits.map(toBackendExplainTrack)
|
||||
return exhibits.map((exhibit) => ({
|
||||
id: `track-${exhibit.playTargetId || exhibit.id}`,
|
||||
exhibitId: exhibit.id,
|
||||
hallId: exhibit.hallId,
|
||||
title: exhibit.guideTitle || `${exhibit.name}讲解`,
|
||||
summary: exhibit.guideText || exhibit.description,
|
||||
coverImage: exhibit.image,
|
||||
poiId: exhibit.poiId,
|
||||
floorId: exhibit.floorId,
|
||||
available: exhibit.audioStatus === 'READY',
|
||||
playTargetType: exhibit.playTargetType,
|
||||
playTargetId: exhibit.playTargetId
|
||||
}))
|
||||
}
|
||||
|
||||
async getMediaById(id: string) {
|
||||
const normalizedId = id.replace(/^media-/, '')
|
||||
const cachedDetail = Array.from(this.detailCache.values()).find((exhibit) => (
|
||||
exhibit.id === normalizedId || exhibit.guideContentId === normalizedId
|
||||
))
|
||||
if (cachedDetail) return toBackendMediaAsset(cachedDetail)
|
||||
|
||||
const detail = await this.requestDetail(normalizedId).catch(() => null)
|
||||
return detail ? toBackendMediaAsset(detail) : null
|
||||
async getMediaById() {
|
||||
return null
|
||||
}
|
||||
|
||||
async getMediaForExplainTrack(trackId: string) {
|
||||
return this.getMediaById(`media-${trackId.replace(/^track-/, '')}`)
|
||||
async getMediaForExplainTrack() {
|
||||
return null
|
||||
}
|
||||
|
||||
searchExplainExhibits(keyword = '') {
|
||||
const normalizedKeyword = normalizeKeyword(keyword)
|
||||
if (!normalizedKeyword) return this.requestStaticExplainExhibits()
|
||||
if (!normalizedKeyword) return this.listExplainExhibits()
|
||||
|
||||
return this.requestSearch(normalizedKeyword).catch((error) => {
|
||||
console.warn('后端讲解搜索失败,将使用静态讲解兜底:', error)
|
||||
return this.searchStaticExplainExhibits(normalizedKeyword)
|
||||
return this.searchCatalogExhibits(normalizedKeyword).catch((error) => {
|
||||
return this.fallbackOrThrow(error, '讲解目录搜索失败', async () => (
|
||||
this.fallbackProvider?.searchExplainExhibits?.(normalizedKeyword)
|
||||
|| this.fallbackProvider?.listExplainExhibits()
|
||||
.then((items) => items.filter((exhibit) => [
|
||||
exhibit.name,
|
||||
exhibit.hallName,
|
||||
exhibit.floorLabel,
|
||||
exhibit.description,
|
||||
...(exhibit.tags || [])
|
||||
].filter(Boolean).join(' ').toLowerCase().includes(normalizedKeyword.toLowerCase())))
|
||||
|| []
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +268,9 @@ export const createMuseumContentProvider = (): ExplainContentProvider => {
|
||||
}
|
||||
|
||||
if (isGuideContentRemoteMode()) {
|
||||
return new BackendExplainContentProvider(new StaticGuideContentProvider())
|
||||
return new BackendExplainContentProvider(
|
||||
dataSourceConfig.allowExplainStaticFallback ? new StaticGuideContentProvider() : undefined
|
||||
)
|
||||
}
|
||||
|
||||
if (isGuideContentMockMode()) {
|
||||
@@ -278,4 +280,5 @@ export const createMuseumContentProvider = (): ExplainContentProvider => {
|
||||
return new StaticGuideContentProvider()
|
||||
}
|
||||
|
||||
export const staticMuseumContentProvider = createMuseumContentProvider()
|
||||
export const explainContentProvider = createMuseumContentProvider()
|
||||
export const staticMuseumContentProvider = explainContentProvider
|
||||
|
||||
Reference in New Issue
Block a user