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

View File

@@ -156,6 +156,14 @@ export interface MuseumExhibit {
guideText?: string
audioUrl?: string
audioDuration?: number
audioLanguage?: string
audioSubtitleUrl?: string
audioHasText?: boolean
audioText?: string
audioTextLength?: number
audioTextHash?: string
audioNarrationTier?: 'STANDARD' | 'EXTENDED'
audioUnavailableReason?: string
audioAvailable?: boolean
playTargetType?: AudioPlayTargetType
playTargetId?: string

View File

@@ -76,7 +76,7 @@
ref="audioPlayerRef"
:visible="showAudioPlayer"
:audio="currentAudio"
:auto-play="true"
:auto-play="false"
@update:visible="handleAudioVisibleChange"
@play="handleAudioPlay"
@pause="handleAudioPause"
@@ -189,6 +189,18 @@ onLoad(async (options: any = {}) => {
if (exhibitData.hallId) {
resolvedHallId.value = exhibitData.hallId
}
void explainUseCase.enrichExhibitDetailAudio(exhibitId)
.then((enrichedExhibit) => {
if (!enrichedExhibit || exhibit.value.id !== exhibitId) return
exhibit.value = toExplainDetailPageViewModel(enrichedExhibit)
if (enrichedExhibit.hallId) {
resolvedHallId.value = enrichedExhibit.hallId
}
})
.catch((error) => {
console.warn('讲解详情音频正文后台补充失败:', error)
})
}
})
@@ -210,11 +222,7 @@ const handlePlayAudio = async () => {
showAudioPlayer.value = true
currentAudio.value = audio
await nextTick()
if (!isPlaying.value) {
audioPlayerRef.value?.play(audio)
}
return
}
@@ -247,10 +255,10 @@ const handlePlayAudio = async () => {
showAudioPlayer.value = true
currentAudio.value = audio
await nextTick()
if (!isPlaying.value) {
audioPlayerRef.value?.play(audio)
}
uni.showToast({
title: '音频已准备好,请点击底部播放按钮',
icon: 'none'
})
}
const handleAudioVisibleChange = (visible: boolean) => {

View File

@@ -26,9 +26,10 @@
/>
<AudioPlayer
ref="audioPlayerRef"
:visible="showAudioPlayer"
:audio="currentAudio"
:auto-play="true"
:auto-play="false"
avoid-bottom-nav
@update:visible="handleAudioVisibleChange"
@play="handleAudioPlay"
@@ -66,8 +67,14 @@ const explainError = ref('')
const showAudioPlayer = ref(false)
const isAudioPlaying = ref(false)
const currentAudio = ref<AudioItem | null>(null)
const audioPlayerRef = ref<AudioPlayerExpose | null>(null)
let explainLoadPromise: Promise<void> | null = null
interface AudioPlayerExpose {
play: (audio: AudioItem) => void
pause: () => void
}
const loadExplainExhibits = async () => {
if (explainExhibits.value.length) return
if (explainLoadPromise) return explainLoadPromise
@@ -129,14 +136,23 @@ const handleExplainHallClick = (hallId: string) => {
const handleAudioClick = async (exhibit: Exhibit) => {
if (exhibit.audioUrl) {
currentAudio.value = {
const audio: AudioItem = {
id: `media-${exhibit.id}`,
name: exhibit.title,
audioUrl: exhibit.audioUrl,
image: exhibit.coverImage,
duration: exhibit.audioDuration
}
const isSameAudio = currentAudio.value?.id === audio.id
if (showAudioPlayer.value && isSameAudio && isAudioPlaying.value) {
audioPlayerRef.value?.pause()
return
}
currentAudio.value = audio
showAudioPlayer.value = true
audioPlayerRef.value?.play(audio)
return
}
@@ -158,6 +174,10 @@ const handleAudioClick = async (exhibit: Exhibit) => {
duration: selection.media.duration
}
showAudioPlayer.value = true
uni.showToast({
title: '音频已准备好,请点击底部播放按钮',
icon: 'none'
})
}
const clearAudioState = () => {

View File

@@ -125,6 +125,26 @@ const normalizePoiName = (value: string) => value
.toLowerCase()
// 展厅数据里可能没有直接写入 poiId优先走内容层映射再按展厅名称兜底匹配导览点位。
const applyMatchedHallPoi = (matchedPoi: Awaited<ReturnType<typeof guideUseCase.getPoiById>>) => {
if (!matchedPoi) return
resolvedHallPoiId.value = matchedPoi.id
hall.value = {
...hall.value,
poiId: matchedPoi.id,
location: hall.value.location || {
status: 'exact',
poiId: matchedPoi.id,
sourcePoiId: matchedPoi.id,
actionText: '查看三维位置',
previewOnly: true,
floorId: matchedPoi.floorId,
floorLabel: matchedPoi.floorLabel,
note: '已定位到该展厅的三维点位。'
}
}
}
const resolveHallGuidePoi = async () => {
const directPoiId = hall.value.location?.poiId || hall.value.poiId
if (directPoiId) {
@@ -148,6 +168,14 @@ const resolveHallGuidePoi = async () => {
}) || candidates[0] || null
}
const resolveHallGuidePoiInBackground = () => {
void resolveHallGuidePoi()
.then(applyMatchedHallPoi)
.catch((err) => {
console.warn('展厅三维位置后台匹配失败:', err)
})
}
onLoad(async (options: any = {}) => {
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
if (isGuideTopTab(tab)) {
@@ -173,25 +201,8 @@ onLoad(async (options: any = {}) => {
hall.value = toHallDetailViewModel(hallData)
const hallExhibits = await explainUseCase.listExhibitsByHallId(hallData.id)
exhibits.value = hallExhibits.map(toExplainExhibitViewModel)
const matchedPoi = await resolveHallGuidePoi()
if (matchedPoi) {
resolvedHallPoiId.value = matchedPoi.id
hall.value = {
...hall.value,
poiId: matchedPoi.id,
location: hall.value.location || {
status: 'exact',
poiId: matchedPoi.id,
sourcePoiId: matchedPoi.id,
actionText: '查看三维位置',
previewOnly: true,
floorId: matchedPoi.floorId,
floorLabel: matchedPoi.floorLabel,
note: '已定位到该展厅的三维点位。'
}
}
}
loading.value = false
resolveHallGuidePoiInBackground()
} catch (err) {
console.error('加载展厅讲解失败:', err)
error.value = '展厅讲解加载失败,请稍后重试'
@@ -224,20 +235,7 @@ const handleNavigate = async () => {
}
resolvedHallPoiId.value = matchedPoi.id
hall.value = {
...hall.value,
poiId: matchedPoi.id,
location: hall.value.location || {
status: 'exact',
poiId: matchedPoi.id,
sourcePoiId: matchedPoi.id,
actionText: '查看三维位置',
previewOnly: true,
floorId: matchedPoi.floorId,
floorLabel: matchedPoi.floorLabel,
note: '已定位到该展厅的三维点位。'
}
}
applyMatchedHallPoi(matchedPoi)
uni.navigateTo({
url: `/pages/route/detail?facilityId=${encodeURIComponent(matchedPoi.id)}&target=${encodeURIComponent(hall.value.name)}&state=preview`

View File

@@ -29,6 +29,7 @@ export interface AudioPlayInfo {
playUrl?: string | null
expiresAt?: string | null
subtitleUrl?: string | null
hasText?: boolean
fallback?: boolean
fallbackReason?: string | null
reason?: string | null
@@ -40,8 +41,34 @@ interface AudioPlayInfoResponse {
data?: AudioPlayInfo
}
export interface AudioTextInfoRequest {
targetType: AudioPlayTargetType
targetId: string
lang?: AudioLanguage
}
export interface AudioTextInfo {
available: boolean
targetType: AudioPlayTargetType
targetId: string | number
lang: AudioLanguage
narrationTier?: 'STANDARD' | 'EXTENDED'
title?: string | null
text?: string | null
textLength?: number | null
textHash?: string | null
reason?: string | null
}
interface AudioTextInfoResponse {
code: number
msg?: string
data?: AudioTextInfo
}
export interface AudioPlayInfoRepository {
getPlayInfo(request: AudioPlayInfoRequest): Promise<AudioPlayInfo>
getTextInfo(request: AudioTextInfoRequest): Promise<AudioTextInfo>
clearCache(lang?: AudioLanguage): void
}
@@ -67,6 +94,7 @@ const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject
uni.request({
url,
method: 'GET',
timeout: 8000,
success: (response) => {
const statusCode = Number(response.statusCode || 0)
if (statusCode < 200 || statusCode >= 300) {
@@ -95,6 +123,10 @@ const audioKey = ({ targetType, targetId, lang }: Required<AudioPlayInfoRequest>
`${targetType}:${targetId}:${lang}`
)
const audioTextKey = ({ targetType, targetId, lang }: Required<AudioTextInfoRequest>) => (
`${targetType}:${targetId}:${lang}`
)
const isExpired = (expiresAt?: string | null) => (
Boolean(expiresAt) && new Date(expiresAt as string).getTime() <= Date.now()
)
@@ -105,6 +137,7 @@ export const audioReasonToText = (reason?: string | null) => (
export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
private readonly cache = new Map<string, AudioPlayInfo>()
private readonly textCache = new Map<string, AudioTextInfo>()
async getPlayInfo(request: AudioPlayInfoRequest): Promise<AudioPlayInfo> {
const normalizedRequest: Required<AudioPlayInfoRequest> = {
@@ -140,9 +173,42 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
return response.data
}
async getTextInfo(request: AudioTextInfoRequest): Promise<AudioTextInfo> {
const normalizedRequest: Required<AudioTextInfoRequest> = {
targetType: request.targetType,
targetId: request.targetId,
lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
}
const key = audioTextKey(normalizedRequest)
const cached = this.textCache.get(key)
if (cached) {
return cached
}
const params = new URLSearchParams({
targetType: normalizedRequest.targetType,
targetId: normalizedRequest.targetId,
lang: normalizedRequest.lang
})
const url = `${resolveAudioApiBaseUrl()}/gis/guide/audio/text-info?${params.toString()}`
const response = await requestJson<AudioTextInfoResponse>(url)
if (response.code !== 0 || !response.data) {
throw new Error(response.msg || '讲解词正文解析失败')
}
if (response.data.available) {
this.textCache.set(key, response.data)
}
return response.data
}
clearCache(lang?: AudioLanguage) {
if (!lang) {
this.cache.clear()
this.textCache.clear()
return
}
@@ -151,6 +217,11 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
this.cache.delete(key)
}
})
Array.from(this.textCache.keys()).forEach((key) => {
if (key.endsWith(`:${lang}`)) {
this.textCache.delete(key)
}
})
}
}

View File

@@ -46,7 +46,7 @@ export class DefaultExplainRepository implements ExplainRepository {
}
getExhibitById(id: string) {
return this.content.getExhibitById(id)
return this.explainContent.getExhibitById?.(id) || this.content.getExhibitById(id)
}
listHalls() {
@@ -81,11 +81,43 @@ export class DefaultExplainRepository implements ExplainRepository {
}
async getTrackByExhibitId(exhibitId: string) {
const detail = await this.getExhibitById(exhibitId).catch(() => null)
if (detail) {
return {
id: `track-${detail.guideContentId || detail.id}`,
exhibitId: detail.id,
hallId: detail.hallId,
title: detail.guideTitle || trackTitleFor(detail),
summary: detail.guideText || detail.description,
mediaId: detail.audioUrl ? `media-${detail.guideContentId || detail.id}` : undefined,
coverImage: detail.image,
poiId: detail.poiId,
floorId: detail.floorId,
available: Boolean(detail.audioUrl) || detail.audioAvailable === true,
playTargetType: detail.playTargetType,
playTargetId: detail.playTargetId
}
}
const tracks = await this.listTracks()
return tracks.find((track) => track.exhibitId === exhibitId) || null
}
async searchExplain(keyword = '') {
if (this.explainContent.searchExplainExhibits) {
const exhibits = await this.explainContent.searchExplainExhibits(keyword)
return exhibits.map<SearchIndexItem>((exhibit) => ({
id: exhibit.id,
name: exhibit.name,
desc: `${exhibit.hallName || '展品讲解'} · ${exhibit.floorLabel || ''}`.trim(),
type: 'exhibit',
exhibitId: exhibit.id,
hallId: exhibit.hallId,
poiId: exhibit.poiId,
floorId: exhibit.floorId
}))
}
const normalizedKeyword = keyword.trim().toLowerCase()
const [exhibits, halls] = await Promise.all([
this.explainContent.listExplainExhibits(),

View File

@@ -54,6 +54,10 @@ export class DefaultMuseumContentRepository implements MuseumContentRepository {
}
async getExhibitById(id: string) {
if (this.contentProvider.getExhibitById) {
return this.contentProvider.getExhibitById(id)
}
const exhibits = await this.contentProvider.listExhibits()
return exhibits.find((exhibit) => exhibit.id === id) || null
}

View File

@@ -18,12 +18,16 @@ import {
audioPlayInfoRepository,
audioReasonToText,
type AudioPlayInfo,
type AudioPlayInfoRepository
type AudioPlayInfoRepository,
type AudioTextInfo
} from '@/repositories/AudioPlayInfoRepository'
import {
publishedExhibitAudioRepository,
type PublishedExhibitAudioRepository
} from '@/repositories/PublishedExhibitAudioRepository'
import {
dataSourceConfig
} from '@/config/dataSource'
export interface ExplainAudioSelection {
exhibit: MuseumExhibit
@@ -34,6 +38,11 @@ export interface ExplainAudioSelection {
playInfo?: AudioPlayInfo
}
export interface ExhibitDetailAudioOptions {
enrichAudio?: boolean
includeText?: boolean
}
export class ExplainUseCase {
constructor(
private readonly explain: ExplainRepository = explainRepository,
@@ -56,6 +65,100 @@ export class ExplainUseCase {
}
}
private resolveAudioTarget(exhibit: MuseumExhibit, track?: ExplainTrack | null) {
const targetType: AudioPlayTargetType = track?.playTargetType
|| exhibit.playTargetType
|| 'ITEM'
const targetId = track?.playTargetId
|| exhibit.playTargetId
|| exhibit.id
return { targetType, targetId }
}
private applyPlayInfo(
exhibit: MuseumExhibit,
playInfo: AudioPlayInfo,
options: { preserveStaticFallback?: boolean } = {}
): MuseumExhibit {
const apiPlayable = playInfo.playable === true && Boolean(playInfo.playUrl)
const canUseStaticAudioFallback = !playInfo.reason || playInfo.reason === 'TARGET_NOT_FOUND'
const fallbackAudioUrl = options.preserveStaticFallback && canUseStaticAudioFallback && exhibit.audioUrl
? exhibit.audioUrl
: undefined
const nextAudioUrl = apiPlayable ? playInfo.playUrl || undefined : fallbackAudioUrl
const nextAudioDuration = typeof playInfo.duration === 'number'
? playInfo.duration
: exhibit.audioDuration
return {
...exhibit,
guideTitle: playInfo.title || exhibit.guideTitle,
audioUrl: nextAudioUrl,
audioDuration: nextAudioDuration,
audioLanguage: playInfo.lang || exhibit.audioLanguage,
audioSubtitleUrl: playInfo.subtitleUrl || exhibit.audioSubtitleUrl,
audioHasText: playInfo.hasText === true || exhibit.audioHasText,
audioNarrationTier: playInfo.narrationTier || exhibit.audioNarrationTier,
audioUnavailableReason: apiPlayable ? undefined : audioReasonToText(playInfo.reason),
audioAvailable: apiPlayable || Boolean(fallbackAudioUrl)
}
}
private applyTextInfo(exhibit: MuseumExhibit, textInfo: AudioTextInfo | null): MuseumExhibit {
if (!textInfo?.available || !textInfo.text) return exhibit
return {
...exhibit,
guideTitle: textInfo.title || exhibit.guideTitle,
guideText: textInfo.text,
audioText: textInfo.text,
audioTextLength: textInfo.textLength || exhibit.audioTextLength,
audioTextHash: textInfo.textHash || exhibit.audioTextHash,
audioLanguage: textInfo.lang || exhibit.audioLanguage,
audioNarrationTier: textInfo.narrationTier || exhibit.audioNarrationTier,
audioHasText: true
}
}
private async enrichExhibitAudio(
exhibit: MuseumExhibit,
options: {
includeText?: boolean
preserveStaticFallback?: boolean
track?: ExplainTrack | null
} = {}
): Promise<MuseumExhibit> {
const track = options.track ?? await this.explain.getTrackByExhibitId(exhibit.id).catch(() => null)
const baseExhibit = this.applyTrackAudioState(exhibit, track)
const { targetType, targetId } = this.resolveAudioTarget(baseExhibit, track)
try {
const playInfo = await this.audioPlayInfo.getPlayInfo({ targetType, targetId })
let nextExhibit = this.applyPlayInfo(baseExhibit, playInfo, {
preserveStaticFallback: options.preserveStaticFallback
})
if (options.includeText && playInfo.hasText) {
try {
const textInfo = await this.audioPlayInfo.getTextInfo({
targetType,
targetId,
lang: playInfo.lang
})
nextExhibit = this.applyTextInfo(nextExhibit, textInfo)
} catch (error) {
console.warn('讲解词正文加载失败,将使用静态讲解文稿:', error)
}
}
return nextExhibit
} catch (error) {
console.warn('讲解播放信息加载失败,将使用静态讲解数据:', error)
return baseExhibit
}
}
private async getTrackByExhibitIdMap() {
const tracks = await this.explain.listTracks()
const trackByExhibitId = new Map<string, ExplainTrack>()
@@ -97,19 +200,35 @@ export class ExplainUseCase {
}
}
async getExhibitById(id: string) {
async getExhibitById(id: string, options: ExhibitDetailAudioOptions = {}) {
const exhibit = await this.explain.getExhibitById(id)
if (!exhibit) return null
try {
const track = await this.explain.getTrackByExhibitId(id)
return this.applyTrackAudioState(exhibit, track)
const baseExhibit = this.applyTrackAudioState(exhibit, track)
if (!options.enrichAudio) {
return baseExhibit
}
return this.enrichExhibitAudio(exhibit, {
includeText: options.includeText,
preserveStaticFallback: true,
track
})
} catch (error) {
console.warn('讲解详情音频状态加载失败,将使用展项原始音频状态:', error)
return exhibit
}
}
async enrichExhibitDetailAudio(id: string) {
return this.getExhibitById(id, {
enrichAudio: true,
includeText: true
})
}
listHalls(): Promise<MuseumHall[]> {
return this.explain.listHalls()
}
@@ -130,15 +249,20 @@ export class ExplainUseCase {
async selectAudioForExhibit(exhibitId: string): Promise<ExplainAudioSelection | null> {
const track = await this.explain.getTrackByExhibitId(exhibitId)
const explainExhibits = await this.explain.listExplainExhibits()
const exhibit = explainExhibits.find((item) => item.id === exhibitId)
|| await this.explain.getExhibitById(exhibitId)
const summaryExhibit = explainExhibits.find((item) => item.id === exhibitId)
const detailExhibit = await this.explain.getExhibitById(exhibitId).catch(() => null)
const exhibit = detailExhibit || summaryExhibit
if (!exhibit) return null
const fallbackMedia = track?.mediaId
? await this.media.getMediaById(track.mediaId)
const fallbackMedia = await (track?.mediaId
? this.media.getMediaById(track.mediaId)
: exhibit.guideContentId
? await this.media.getMediaById(`media-${exhibit.guideContentId}`)
: null
? this.media.getMediaById(`media-${exhibit.guideContentId}`)
: Promise.resolve(null)
).catch((error) => {
console.warn('静态讲解媒体读取失败,将继续尝试播放接口:', error)
return null
})
const targetType: AudioPlayTargetType = track?.playTargetType
|| exhibit.playTargetType
|| 'ITEM'
@@ -203,6 +327,15 @@ export class ExplainUseCase {
}
}
const directMediaSelection = toDirectMediaSelection({
playable: false,
targetType,
targetId,
lang: dataSourceConfig.audioLanguage as AudioPlayInfo['lang'],
reason: 'DIRECT_MEDIA_FALLBACK'
})
if (directMediaSelection) return directMediaSelection
try {
const initialPlayInfo = await this.audioPlayInfo.getPlayInfo({ targetType, targetId })
@@ -222,9 +355,6 @@ export class ExplainUseCase {
}
}
}
const directMediaSelection = toDirectMediaSelection(initialPlayInfo)
if (directMediaSelection) return directMediaSelection
}
return {
@@ -237,13 +367,23 @@ export class ExplainUseCase {
}
} catch (error) {
console.error('语音播放解析失败:', error)
const serviceErrorPlayInfo: AudioPlayInfo = {
playable: false,
targetType,
targetId,
lang: dataSourceConfig.audioLanguage as AudioPlayInfo['lang'],
reason: 'SERVICE_ERROR'
}
const directMediaSelection = toDirectMediaSelection(serviceErrorPlayInfo)
if (directMediaSelection) return directMediaSelection
return {
exhibit,
track,
media: null,
playable: false,
unavailableMessage: '语音服务暂不可用,请稍后重试'
playInfo: serviceErrorPlayInfo,
unavailableMessage: '语音服务暂不可用,当前提供图文讲解'
}
}
}

View File

@@ -96,6 +96,18 @@ const buildBadges = (exhibit: MuseumExhibit) => {
return Array.from(new Set(badges)).slice(0, 3)
}
const formatDuration = (duration?: number) => (
typeof duration === 'number' && Number.isFinite(duration) && duration > 0
? `${Math.round(duration)}`
: undefined
)
const languageLabelFor = (language?: string) => {
if (language === 'en-US') return '英文'
if (language === 'zh-CN') return '中文'
return language || undefined
}
export const toExplainCardViewModel = (exhibit: MuseumExhibit): ExplainCardViewModel => {
const hasPlayableAudio = exhibit.audioAvailable === true || Boolean(exhibit.audioUrl)
const audioStatus: ExplainAudioStatus = hasPlayableAudio ? 'playable' : 'unavailable'
@@ -111,10 +123,10 @@ export const toExplainCardViewModel = (exhibit: MuseumExhibit): ExplainCardViewM
hallId: exhibit.hallId,
hallName: exhibit.hallName,
floorLabel: exhibit.floorLabel,
durationLabel: exhibit.audioDuration ? `${Math.round(exhibit.audioDuration)}` : undefined,
durationLabel: formatDuration(exhibit.audioDuration),
audioUrl: exhibit.audioUrl,
audioDuration: exhibit.audioDuration,
languageLabel: '中文',
languageLabel: languageLabelFor(exhibit.audioLanguage),
audioStatus,
audioStatusText: hasPlayableAudio ? '可播放' : '图文讲解',
badges: buildBadges(exhibit),
@@ -134,8 +146,9 @@ export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDet
exhibit.year ? `年代:${exhibit.year}` : '',
exhibit.material ? `来源:${exhibit.material}` : ''
].filter(Boolean)
const transcript = exhibit.audioText || exhibit.guideText || exhibit.description
const body = [
exhibit.guideText || exhibit.description || '该展项暂无讲解文稿。',
transcript || '该展项暂无讲解文稿。',
metadataLines.join('\n')
].filter(Boolean).join('\n\n')
@@ -148,14 +161,16 @@ export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDet
hallId: exhibit.hallId,
hallName: exhibit.hallName,
floorLabel: exhibit.floorLabel,
summary: exhibit.guideText || exhibit.description || '该展项暂无讲解文稿。',
summary: transcript || '该展项暂无讲解文稿。',
body,
audio: {
status: hasPlayableAudio ? 'playable' : 'unavailable',
url: exhibit.audioUrl,
durationLabel: exhibit.audioDuration ? `${Math.round(exhibit.audioDuration)}` : undefined,
language: 'zh-CN',
unavailableReason: hasPlayableAudio ? undefined : '该展项暂无已发布音频,当前提供图文讲解。'
durationLabel: formatDuration(exhibit.audioDuration),
language: exhibit.audioLanguage,
unavailableReason: hasPlayableAudio
? undefined
: exhibit.audioUnavailableReason || '该展项暂无已发布音频,当前提供图文讲解。'
},
chapters: metadataLines.length
? metadataLines.map((line, index) => ({
@@ -163,7 +178,7 @@ export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDet
title: line
}))
: [],
transcript: exhibit.guideText || exhibit.description,
transcript,
location: exhibit.location,
relatedItems: []
}

View File

@@ -3,6 +3,7 @@ import uni from '@dcloudio/vite-plugin-uni'
const sgsDevServer = 'http://1.92.206.90:3001'
const sgsAssetServer = 'http://1.92.206.90:9000'
const legacyAudioServer = 'http://47.120.48.148:19000'
export default defineConfig({
plugins: [uni()],
@@ -28,6 +29,10 @@ export default defineConfig({
'/minio': {
target: sgsDevServer,
changeOrigin: true
},
'/nhm/audio': {
target: legacyAudioServer,
changeOrigin: true
}
}
},