接入H5讲解后端数据闭环

This commit is contained in:
lyf
2026-06-30 17:22:20 +08:00
parent 1c2cc788d1
commit e3682ed3ec
13 changed files with 815 additions and 68 deletions

View File

@@ -0,0 +1,228 @@
import type {
AudioPlayTargetType,
ExplainTrack,
MediaAsset,
MuseumExhibit,
MuseumHall
} from '@/domain/museum'
import {
normalizeSameOriginPublicUrl
} from '@/utils/publicUrl'
export interface BackendGuideContent {
id?: string | number | null
title?: string | null
targetType?: string | null
targetId?: string | number | null
standardText?: string | null
extendedText?: string | null
standardAudioUrl?: string | null
extendedAudioUrl?: string | null
audioUrl?: string | null
standardAudioDuration?: number | null
extendedAudioDuration?: number | null
audioDuration?: number | null
}
export interface BackendExhibit {
id?: string | number | null
hallId?: string | number | null
hallName?: string | null
floorId?: string | number | null
floorLabel?: string | null
poiId?: string | number | null
exhibitCode?: string | null
name?: string | null
scientificName?: string | null
category?: string | null
era?: string | null
origin?: string | null
dimensions?: string | null
description?: string | null
narrationText?: string | null
coverImageUrl?: string | null
galleryUrls?: string | string[] | null
audioUrl?: string | null
audioDuration?: number | null
playTargetType?: string | null
playTargetId?: string | number | null
hasAudio?: boolean | null
supportedLanguages?: string[] | null
audioStatus?: string | null
guideContents?: BackendGuideContent[] | null
}
export interface BackendExplainAdapterResult {
exhibit: MuseumExhibit
track: ExplainTrack
media: MediaAsset | null
}
const stringifyId = (value: string | number | null | undefined) => (
value === null || typeof value === 'undefined' ? '' : String(value)
)
const firstText = (...values: Array<string | number | null | undefined>) => (
values
.map((value) => (value === null || typeof value === 'undefined' ? '' : String(value).trim()))
.find(Boolean) || ''
)
const parseGalleryUrls = (value: BackendExhibit['galleryUrls']) => {
if (!value) return []
if (Array.isArray(value)) return value.map(String).filter(Boolean)
const trimmed = value.trim()
if (!trimmed) return []
try {
const parsed = JSON.parse(trimmed)
if (Array.isArray(parsed)) {
return parsed
.flatMap((entry) => String(entry).split(','))
.map((entry) => entry.trim())
.filter(Boolean)
}
} catch {
// 兼容后端历史逗号拼接字段。
}
return trimmed.split(',').map((entry) => entry.trim()).filter(Boolean)
}
const normalizeAudioTargetType = (targetType: string | null | undefined): AudioPlayTargetType | undefined => {
const normalized = targetType?.toUpperCase()
return normalized === 'ITEM' || normalized === 'STOP' ? normalized : undefined
}
const isAudioReady = (value?: string | null) => (
!value || ['READY', 'PUBLISHED', 'AVAILABLE'].includes(value.toUpperCase())
)
const resolveImage = (exhibit: BackendExhibit) => normalizeSameOriginPublicUrl(
firstText(exhibit.coverImageUrl, parseGalleryUrls(exhibit.galleryUrls)[0])
)
const resolveGuideAudioUrl = (guide?: BackendGuideContent | null, exhibit?: BackendExhibit) => normalizeSameOriginPublicUrl(
firstText(guide?.standardAudioUrl, guide?.audioUrl, guide?.extendedAudioUrl, exhibit?.audioUrl)
)
const resolveGuideAudioDuration = (guide?: BackendGuideContent | null, exhibit?: BackendExhibit) => {
const duration = guide?.standardAudioDuration
|| guide?.audioDuration
|| guide?.extendedAudioDuration
|| exhibit?.audioDuration
return typeof duration === 'number' && Number.isFinite(duration) && duration > 0 ? duration : undefined
}
const resolveGuideText = (guide?: BackendGuideContent | null, exhibit?: BackendExhibit) => (
firstText(guide?.standardText, guide?.extendedText, exhibit?.narrationText, exhibit?.description)
)
const selectPrimaryGuideContent = (exhibit: BackendExhibit) => {
const guides = exhibit.guideContents || []
return guides.find((guide) => Boolean(resolveGuideAudioUrl(guide, exhibit)))
|| guides.find((guide) => Boolean(resolveGuideText(guide, exhibit)))
|| guides[0]
|| null
}
const buildTags = (exhibit: BackendExhibit) => Array.from(new Set([
firstText(exhibit.category),
firstText(exhibit.scientificName),
firstText(exhibit.exhibitCode),
firstText(exhibit.era),
firstText(exhibit.origin)
].filter(Boolean)))
export const toBackendMuseumExhibit = (
source: BackendExhibit,
hall?: MuseumHall | null,
options: { includeDetail?: boolean } = {}
): MuseumExhibit => {
const id = stringifyId(source.id)
const guide = options.includeDetail ? selectPrimaryGuideContent(source) : null
const guideText = options.includeDetail ? resolveGuideText(guide, source) : firstText(source.narrationText, source.description)
const audioUrl = options.includeDetail ? resolveGuideAudioUrl(guide, source) : normalizeSameOriginPublicUrl(firstText(source.audioUrl))
const targetType = normalizeAudioTargetType(guide?.targetType || source.playTargetType)
const targetId = stringifyId(guide?.targetId || source.playTargetId)
const supportedLanguage = source.supportedLanguages?.find((language) => language === 'zh-CN' || language === 'en-US')
const hasAudioSummary = source.hasAudio === true && isAudioReady(source.audioStatus)
const audioAvailable = Boolean(audioUrl) || hasAudioSummary
return {
id,
name: firstText(source.name, source.scientificName, source.exhibitCode, `展品${id}`),
hallId: stringifyId(source.hallId) || hall?.id,
hallName: firstText(source.hallName, hall?.name),
floorId: stringifyId(source.floorId) || hall?.floorId,
floorLabel: firstText(source.floorLabel, hall?.floorLabel),
image: resolveImage(source) || '/static/exhibit-placeholder.jpg',
description: firstText(source.description, source.narrationText, source.scientificName, '该展项暂无讲解文稿。'),
poiId: stringifyId(source.poiId) || undefined,
year: firstText(source.era) || undefined,
material: firstText(source.origin) || undefined,
size: firstText(source.exhibitCode, source.dimensions) || undefined,
tags: buildTags(source),
guideContentId: stringifyId(guide?.id) || undefined,
guideTitle: firstText(guide?.title) || undefined,
guideText: guideText || undefined,
audioUrl: audioUrl || undefined,
audioDuration: resolveGuideAudioDuration(guide, source),
audioLanguage: supportedLanguage,
audioText: options.includeDetail && guideText ? guideText : undefined,
audioHasText: options.includeDetail ? Boolean(guideText) : undefined,
audioAvailable,
audioUnavailableReason: audioAvailable ? undefined : '该讲解暂无已发布音频,当前提供图文讲解。',
playTargetType: targetType,
playTargetId: targetId || undefined
}
}
export const toBackendExplainTrack = (exhibit: MuseumExhibit): ExplainTrack => ({
id: `track-${exhibit.guideContentId || exhibit.id}`,
exhibitId: exhibit.id,
hallId: exhibit.hallId,
title: exhibit.guideTitle || `${exhibit.name}讲解`,
summary: exhibit.guideText || exhibit.description,
mediaId: exhibit.audioUrl ? `media-${exhibit.guideContentId || exhibit.id}` : undefined,
coverImage: exhibit.image,
poiId: exhibit.poiId,
floorId: exhibit.floorId,
available: Boolean(exhibit.audioUrl) || exhibit.audioAvailable === true,
playTargetType: exhibit.playTargetType,
playTargetId: exhibit.playTargetId
})
export const toBackendMediaAsset = (exhibit: MuseumExhibit): MediaAsset | null => {
if (!exhibit.audioUrl) return null
return {
id: `media-${exhibit.guideContentId || exhibit.id}`,
type: 'audio',
url: exhibit.audioUrl,
duration: exhibit.audioDuration,
language: exhibit.audioLanguage,
available: true
}
}
export const toBackendHall = (
hallId: string,
exhibits: MuseumExhibit[],
fallback?: MuseumHall | null
): MuseumHall => {
const first = exhibits[0]
return {
id: hallId,
name: firstText(first?.hallName, fallback?.name, '展厅'),
floorId: firstText(first?.floorId, fallback?.floorId) || undefined,
floorLabel: firstText(first?.floorLabel, fallback?.floorLabel, '楼层待补充'),
description: fallback?.description || '该展厅讲解内容来自后端展品接口。',
image: fallback?.image || '/static/hall-placeholder.jpg',
exhibitCount: exhibits.length,
area: fallback?.area,
poiId: fallback?.poiId,
location: fallback?.location
}
}

View File

@@ -0,0 +1,213 @@
import type {
ExplainTrack,
MediaAsset,
MuseumExhibit,
MuseumHall
} from '@/domain/museum'
import {
dataSourceConfig
} from '@/config/dataSource'
import type {
ExplainContentProvider
} from '@/data/providers/staticMuseumContentProvider'
import {
toBackendExplainTrack,
toBackendHall,
toBackendMediaAsset,
toBackendMuseumExhibit,
type BackendExhibit
} from '@/data/adapters/backendExplainDataAdapter'
interface CommonResult<T> {
code: number
msg?: string
data?: T
}
const normalizeBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, '')
const resolveAppApiBaseUrl = () => {
const baseUrl = normalizeBaseUrl(dataSourceConfig.apiBaseUrl)
return baseUrl.endsWith('/app-api') ? baseUrl : `${baseUrl}/app-api`
}
const parseJsonPayload = <T>(payload: unknown): T => {
if (typeof payload === 'string') {
return JSON.parse(payload) as T
}
return payload as T
}
const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject) => {
uni.request({
url,
method: 'GET',
timeout: 10000,
success: (response) => {
const statusCode = Number(response.statusCode || 0)
if (statusCode < 200 || statusCode >= 300) {
reject(new Error(`后端讲解接口请求失败: ${statusCode}`))
return
}
try {
resolve(parseJsonPayload<T>(response.data))
} catch (error) {
reject(error)
}
},
fail: reject
})
})
const normalizeKeyword = (keyword = '') => keyword.trim()
export class BackendExplainContentProvider implements ExplainContentProvider {
private readonly searchCache = new Map<string, MuseumExhibit[]>()
private readonly searchInflight = new Map<string, Promise<MuseumExhibit[]>>()
private readonly detailCache = new Map<string, MuseumExhibit>()
private readonly detailInflight = new Map<string, Promise<MuseumExhibit | null>>()
constructor(private readonly fallbackProvider: ExplainContentProvider) {}
private async requestSearch(keyword = '') {
const normalizedKeyword = normalizeKeyword(keyword)
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(() => [])
}
async listExplainExhibits() {
try {
return await this.requestSearch('')
} catch (error) {
console.warn('后端讲解列表加载失败,将使用静态讲解兜底:', error)
return this.fallbackProvider.listExplainExhibits()
}
}
listExhibits() {
return this.listExplainExhibits()
}
getExhibitById(id: string) {
return this.requestDetail(id)
}
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))
))
} catch (error) {
console.warn('后端展厅聚合加载失败,将使用静态展厅兜底:', error)
return this.fallbackProvider.listHalls()
}
}
async listTracks() {
const exhibits = await this.listExplainExhibits()
return exhibits.map(toBackendExplainTrack)
}
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 getMediaForExplainTrack(trackId: string) {
return this.getMediaById(`media-${trackId.replace(/^track-/, '')}`)
}
searchExplainExhibits(keyword = '') {
return this.requestSearch(keyword)
}
}

View File

@@ -25,14 +25,19 @@ import {
type SgsSceneExhibitMock,
type SgsSceneSpaceMock
} from '@/data/mock/sgsSceneExplainData'
import {
BackendExplainContentProvider
} from '@/data/providers/backendExplainContentProvider'
export interface MuseumContentProvider {
listExhibits(): Promise<MuseumExhibit[]>
getExhibitById?(id: string): Promise<MuseumExhibit | null>
listHalls(): Promise<MuseumHall[]>
}
export interface ExplainContentProvider extends MuseumContentProvider {
listExplainExhibits(): Promise<MuseumExhibit[]>
searchExplainExhibits?(keyword?: string): Promise<MuseumExhibit[]>
listTracks(): Promise<ExplainTrack[]>
getMediaById(id: string): Promise<MediaAsset | null>
getMediaForExplainTrack(trackId: string): Promise<MediaAsset | null>
@@ -232,7 +237,7 @@ export const createMuseumContentProvider = (): ExplainContentProvider => {
}
if (isGuideContentRemoteMode()) {
return new RemoteGuideContentProvider()
return new BackendExplainContentProvider(new StaticGuideContentProvider())
}
if (isGuideContentMockMode()) {