接入H5讲解后端数据闭环
This commit is contained in:
228
src/data/adapters/backendExplainDataAdapter.ts
Normal file
228
src/data/adapters/backendExplainDataAdapter.ts
Normal 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
|
||||
}
|
||||
}
|
||||
213
src/data/providers/backendExplainContentProvider.ts
Normal file
213
src/data/providers/backendExplainContentProvider.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
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) => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: '语音服务暂不可用,当前提供图文讲解'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: []
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user