接入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,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()) {