feat: 临时改造讲解页方案 B 链路

接入展厅列表、展厅讲解点分组生成临时业务单元,并迁移讲解详情播放正文到新接口链路。
This commit is contained in:
lyf
2026-07-02 00:51:27 +08:00
parent e0aeafe062
commit 9efcef5190
14 changed files with 1429 additions and 288 deletions

View File

@@ -1,5 +1,7 @@
import type {
AudioPlayTargetType,
ExplainBusinessUnit,
ExplainGuideStop,
ExplainTrack,
MediaAsset,
MuseumExhibit,
@@ -56,6 +58,35 @@ export interface BackendExhibit {
guideContents?: BackendGuideContent[] | null
}
export interface BackendHall {
id?: string | number | null
name?: string | null
hallCode?: string | null
nameEn?: string | null
subtitle?: string | null
description?: string | null
coverImageUrl?: string | null
exhibitCount?: number | null
}
export interface BackendGuideStop {
id?: string | number | null
name?: string | null
floorId?: string | number | 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 BackendExplainAdapterResult {
exhibit: MuseumExhibit
track: ExplainTrack
@@ -180,7 +211,9 @@ export const toBackendMuseumExhibit = (
audioLanguage: supportedLanguage,
audioText: options.includeDetail && guideText ? guideText : undefined,
audioHasText: options.includeDetail ? Boolean(guideText) : undefined,
audioAvailable,
audioAvailable: audioAvailable || metadataSuggestsAudio,
audioStatus: source.audioStatus || undefined,
supportedLanguages: source.supportedLanguages || undefined,
audioUnavailableReason: audioAvailable || metadataSuggestsAudio
? undefined
: '该讲解暂无已发布音频,当前提供图文讲解。',
@@ -199,7 +232,7 @@ export const toBackendExplainTrack = (exhibit: MuseumExhibit): ExplainTrack => (
coverImage: exhibit.image,
poiId: exhibit.poiId,
floorId: exhibit.floorId,
available: Boolean(exhibit.audioUrl),
available: Boolean(exhibit.audioUrl) || exhibit.audioAvailable === true,
playTargetType: exhibit.playTargetType,
playTargetId: exhibit.playTargetId
})
@@ -236,3 +269,78 @@ export const toBackendHall = (
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: fallback?.floorId,
floorLabel: fallback?.floorLabel,
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,
area: fallback?.area,
poiId: fallback?.poiId,
location: fallback?.location
}
}
export const toBackendGuideStop = (source: BackendGuideStop): ExplainGuideStop | null => {
const id = stringifyId(source.id)
if (!id) return null
const targetType = normalizeAudioTargetType(source.targetType) || 'STOP'
const targetId = stringifyId(source.targetId) || id
return {
id,
name: firstText(source.name, `讲解点${id}`),
hallId: stringifyId(source.hallId) || undefined,
hallName: firstText(source.hallName) || undefined,
floorId: stringifyId(source.floorId) || undefined,
targetType,
targetId,
coverImageUrl: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined,
description: firstText(source.description) || undefined,
hasAudio: source.hasAudio === true,
poiId: stringifyId(source.poiId) || undefined,
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
}))
}

View File

@@ -0,0 +1,164 @@
import type {
AudioPlayTargetType
} from '@/domain/museum'
import {
normalizeSameOriginPublicUrl
} from '@/utils/publicUrl'
export interface BackendGuideStopLinkedExhibit {
id?: string | number | null
name?: string | null
nameEn?: string | null
exhibitCode?: string | null
coverImageUrl?: string | null
}
export interface BackendGuideStopInfo {
available?: boolean
targetType?: string | null
targetId?: string | number | null
resolvedStopId?: string | number | null
lang?: string | null
title?: string | null
description?: string | null
coverImageUrl?: string | null
galleryUrls?: string | string[] | null
imageStatus?: string | null
imageSource?: string | null
linkedExhibits?: BackendGuideStopLinkedExhibit[] | null
playTargetType?: string | null
playTargetId?: string | number | null
hasAudio?: boolean
hasText?: boolean
supportedLanguages?: string[] | null
audioStatus?: string | null
reason?: string | null
}
export interface GuideStopLinkedExhibit {
id: string
name: string
nameEn?: string
exhibitCode?: string
coverImageUrl?: string
}
export interface GuideStopInfo {
available: boolean
targetType: AudioPlayTargetType
targetId: string
resolvedStopId?: string
lang: 'zh-CN' | 'en-US'
title: string
description?: string
coverImageUrl?: string
galleryUrls: string[]
imageStatus: 'READY' | 'MISSING' | string
imageSource?: string
linkedExhibits: GuideStopLinkedExhibit[]
playTargetType: AudioPlayTargetType
playTargetId: string
hasAudio: boolean
hasText: boolean
supportedLanguages: string[]
audioStatus: 'READY' | 'MISSING' | string
reason?: string
}
const stringifyId = (value: string | number | null | undefined) => (
value === null || typeof value === 'undefined' ? '' : String(value)
)
const normalizeTargetType = (value: string | null | undefined, fallback: AudioPlayTargetType): AudioPlayTargetType => {
const normalized = value?.toUpperCase()
return normalized === 'ITEM' || normalized === 'STOP' ? normalized : fallback
}
const normalizeLanguage = (value: string | null | undefined): 'zh-CN' | 'en-US' => (
value === 'en-US' ? 'en-US' : 'zh-CN'
)
const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls']) => {
if (!value) return []
if (Array.isArray(value)) {
return value.map((entry) => normalizeSameOriginPublicUrl(String(entry))).filter(Boolean)
}
const trimmed = value.trim()
if (!trimmed) return []
try {
const parsed = JSON.parse(trimmed)
if (Array.isArray(parsed)) {
return parsed
.map((entry) => normalizeSameOriginPublicUrl(String(entry)))
.filter(Boolean)
}
} catch {
// 兼容后端历史逗号拼接字段。
}
return trimmed
.split(',')
.map((entry) => normalizeSameOriginPublicUrl(entry.trim()))
.filter(Boolean)
}
const normalizeLinkedExhibits = (items: BackendGuideStopLinkedExhibit[] | null | undefined) => (
(items || [])
.map<GuideStopLinkedExhibit | null>((item) => {
const id = stringifyId(item.id)
if (!id) return null
return {
id,
name: item.name?.trim() || item.exhibitCode?.trim() || `展品 ${id}`,
nameEn: item.nameEn?.trim() || undefined,
exhibitCode: item.exhibitCode?.trim() || undefined,
coverImageUrl: normalizeSameOriginPublicUrl(item.coverImageUrl) || undefined
}
})
.filter(Boolean) as GuideStopLinkedExhibit[]
)
export const toGuideStopInfo = (
source: BackendGuideStopInfo,
fallback: {
targetType: AudioPlayTargetType
targetId: string
lang: 'zh-CN' | 'en-US'
}
): GuideStopInfo => {
const targetType = normalizeTargetType(source.targetType, fallback.targetType)
const targetId = stringifyId(source.targetId) || fallback.targetId
const playTargetType = normalizeTargetType(source.playTargetType, targetType)
const playTargetId = stringifyId(source.playTargetId) || targetId
const imageStatus = source.imageStatus || 'MISSING'
const canUseStopImages = imageStatus === 'READY'
const galleryUrls = canUseStopImages ? parseGalleryUrls(source.galleryUrls) : []
const coverImageUrl = canUseStopImages
? normalizeSameOriginPublicUrl(source.coverImageUrl) || undefined
: undefined
return {
available: source.available === true,
targetType,
targetId,
resolvedStopId: stringifyId(source.resolvedStopId) || undefined,
lang: normalizeLanguage(source.lang || fallback.lang),
title: source.title?.trim() || '讲解内容',
description: source.description?.trim() || undefined,
coverImageUrl,
galleryUrls,
imageStatus,
imageSource: source.imageSource || undefined,
linkedExhibits: normalizeLinkedExhibits(source.linkedExhibits),
playTargetType,
playTargetId,
hasAudio: source.hasAudio === true,
hasText: source.hasText === true,
supportedLanguages: source.supportedLanguages || [],
audioStatus: source.audioStatus || 'MISSING',
reason: source.reason || undefined
}
}

View File

@@ -1,5 +1,8 @@
import type {
MuseumExhibit
ExplainBusinessUnit,
ExplainGuideStop,
MuseumExhibit,
MuseumHall
} from '@/domain/museum'
import {
dataSourceConfig
@@ -9,10 +12,14 @@ import type {
} from '@/data/providers/staticMuseumContentProvider'
import {
toBackendExplainTrack,
toBackendHall,
toBackendGuideStop,
toBackendHallFromList,
toBackendMediaAsset,
toBackendMuseumExhibit,
type BackendExhibit
groupGuideStopsByOutline,
type BackendExhibit,
type BackendGuideStop,
type BackendHall
} from '@/data/adapters/backendExplainDataAdapter'
interface CommonResult<T> {
@@ -65,11 +72,40 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
private readonly searchInflight = 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 guideStopCache = new Map<string, ExplainGuideStop[]>()
private readonly guideStopInflight = new Map<string, Promise<ExplainGuideStop[]>>()
constructor(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
@@ -144,15 +180,80 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
return this.fallbackProvider.listHalls().catch(() => [])
}
async listExplainExhibits() {
private async requestHallList() {
if (this.hallListCache) return this.hallListCache
if (this.hallListInflight) return this.hallListInflight
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 || '后端展厅列表加载失败')
}
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(
item,
fallbackHallById.get(String(item.id || '')) || fallbackHallByName.get(String(item.name || ''))
))
.filter((hall) => hall.id)
this.hallListCache = halls
return halls
})()
try {
return await this.requestSearch('')
} catch (error) {
console.warn('后端讲解列表加载失败,将使用静态讲解兜底:', error)
return this.fallbackProvider.listExplainExhibits()
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 url = `${resolveAppApiBaseUrl()}/gis/sdk/halls/${encodeURIComponent(normalizedHallId)}/guide-stops`
const response = await requestJson<CommonResult<BackendGuideStop[]>>(url)
if (response.code !== 0 || !Array.isArray(response.data)) {
throw new Error(response.msg || '后端展厅讲解点加载失败')
}
const stops = response.data
.map(toBackendGuideStop)
.filter(Boolean) as ExplainGuideStop[]
this.guideStopCache.set(normalizedHallId, stops)
return stops
})()
this.guideStopInflight.set(normalizedHallId, promise)
try {
return await promise
} finally {
this.guideStopInflight.delete(normalizedHallId)
}
}
async listTemporaryBusinessUnitsByHall(hallId: string): Promise<ExplainBusinessUnit[]> {
const stops = await this.listGuideStopsByHall(hallId)
return groupGuideStopsByOutline(hallId, stops)
}
async listExplainExhibits() {
return this.requestStaticExplainExhibits()
}
listExhibits() {
return this.listExplainExhibits()
}
@@ -163,23 +264,9 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
async listHalls() {
try {
const exhibits = await this.requestSearch('')
const fallbackHalls = await this.safeFallbackHalls()
const fallbackHallById = new Map(fallbackHalls.map((hall) => [hall.id, hall]))
const exhibitsByHallId = new Map<string, MuseumExhibit[]>()
exhibits.forEach((exhibit) => {
const hallId = exhibit.hallId || 'unknown'
const items = exhibitsByHallId.get(hallId) || []
items.push(exhibit)
exhibitsByHallId.set(hallId, items)
})
return Array.from(exhibitsByHallId.entries()).map(([hallId, hallExhibits]) => (
toBackendHall(hallId, hallExhibits, fallbackHallById.get(hallId))
))
return await this.requestHallList()
} catch (error) {
console.warn('后端展厅聚合加载失败,将使用静态展厅兜底:', error)
console.warn('后端展厅列表加载失败,将使用静态展厅兜底:', error)
return this.fallbackProvider.listHalls()
}
}
@@ -205,6 +292,12 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
}
searchExplainExhibits(keyword = '') {
return this.requestSearch(keyword)
const normalizedKeyword = normalizeKeyword(keyword)
if (!normalizedKeyword) return this.requestStaticExplainExhibits()
return this.requestSearch(normalizedKeyword).catch((error) => {
console.warn('后端讲解搜索失败,将使用静态讲解兜底:', error)
return this.searchStaticExplainExhibits(normalizedKeyword)
})
}
}

View File

@@ -1,4 +1,6 @@
import type {
ExplainBusinessUnit,
ExplainGuideStop,
ExplainTrack,
MediaAsset,
MuseumExhibit,
@@ -38,6 +40,8 @@ export interface MuseumContentProvider {
export interface ExplainContentProvider extends MuseumContentProvider {
listExplainExhibits(): Promise<MuseumExhibit[]>
searchExplainExhibits?(keyword?: string): Promise<MuseumExhibit[]>
listGuideStopsByHall?(hallId: string): Promise<ExplainGuideStop[]>
listTemporaryBusinessUnitsByHall?(hallId: string): Promise<ExplainBusinessUnit[]>
listTracks(): Promise<ExplainTrack[]>
getMediaById(id: string): Promise<MediaAsset | null>
getMediaForExplainTrack(trackId: string): Promise<MediaAsset | null>