feat: 临时改造讲解页方案 B 链路
接入展厅列表、展厅讲解点分组生成临时业务单元,并迁移讲解详情播放正文到新接口链路。
This commit is contained in:
@@ -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
|
||||
}))
|
||||
}
|
||||
|
||||
164
src/data/adapters/guideStopInfoAdapter.ts
Normal file
164
src/data/adapters/guideStopInfoAdapter.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user