feat: switch explain flow to API data
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-06 23:18:51 +08:00
parent e2b6a331ba
commit 50902eca82
21 changed files with 1417 additions and 632 deletions

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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())))
|| []
))
})
}
}

View File

@@ -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