接入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 SgsSceneExhibitMock,
|
||||||
type SgsSceneSpaceMock
|
type SgsSceneSpaceMock
|
||||||
} from '@/data/mock/sgsSceneExplainData'
|
} from '@/data/mock/sgsSceneExplainData'
|
||||||
|
import {
|
||||||
|
BackendExplainContentProvider
|
||||||
|
} from '@/data/providers/backendExplainContentProvider'
|
||||||
|
|
||||||
export interface MuseumContentProvider {
|
export interface MuseumContentProvider {
|
||||||
listExhibits(): Promise<MuseumExhibit[]>
|
listExhibits(): Promise<MuseumExhibit[]>
|
||||||
|
getExhibitById?(id: string): Promise<MuseumExhibit | null>
|
||||||
listHalls(): Promise<MuseumHall[]>
|
listHalls(): Promise<MuseumHall[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExplainContentProvider extends MuseumContentProvider {
|
export interface ExplainContentProvider extends MuseumContentProvider {
|
||||||
listExplainExhibits(): Promise<MuseumExhibit[]>
|
listExplainExhibits(): Promise<MuseumExhibit[]>
|
||||||
|
searchExplainExhibits?(keyword?: string): Promise<MuseumExhibit[]>
|
||||||
listTracks(): Promise<ExplainTrack[]>
|
listTracks(): Promise<ExplainTrack[]>
|
||||||
getMediaById(id: string): Promise<MediaAsset | null>
|
getMediaById(id: string): Promise<MediaAsset | null>
|
||||||
getMediaForExplainTrack(trackId: string): Promise<MediaAsset | null>
|
getMediaForExplainTrack(trackId: string): Promise<MediaAsset | null>
|
||||||
@@ -232,7 +237,7 @@ export const createMuseumContentProvider = (): ExplainContentProvider => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isGuideContentRemoteMode()) {
|
if (isGuideContentRemoteMode()) {
|
||||||
return new RemoteGuideContentProvider()
|
return new BackendExplainContentProvider(new StaticGuideContentProvider())
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isGuideContentMockMode()) {
|
if (isGuideContentMockMode()) {
|
||||||
|
|||||||
@@ -156,6 +156,14 @@ export interface MuseumExhibit {
|
|||||||
guideText?: string
|
guideText?: string
|
||||||
audioUrl?: string
|
audioUrl?: string
|
||||||
audioDuration?: number
|
audioDuration?: number
|
||||||
|
audioLanguage?: string
|
||||||
|
audioSubtitleUrl?: string
|
||||||
|
audioHasText?: boolean
|
||||||
|
audioText?: string
|
||||||
|
audioTextLength?: number
|
||||||
|
audioTextHash?: string
|
||||||
|
audioNarrationTier?: 'STANDARD' | 'EXTENDED'
|
||||||
|
audioUnavailableReason?: string
|
||||||
audioAvailable?: boolean
|
audioAvailable?: boolean
|
||||||
playTargetType?: AudioPlayTargetType
|
playTargetType?: AudioPlayTargetType
|
||||||
playTargetId?: string
|
playTargetId?: string
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
ref="audioPlayerRef"
|
ref="audioPlayerRef"
|
||||||
:visible="showAudioPlayer"
|
:visible="showAudioPlayer"
|
||||||
:audio="currentAudio"
|
:audio="currentAudio"
|
||||||
:auto-play="true"
|
:auto-play="false"
|
||||||
@update:visible="handleAudioVisibleChange"
|
@update:visible="handleAudioVisibleChange"
|
||||||
@play="handleAudioPlay"
|
@play="handleAudioPlay"
|
||||||
@pause="handleAudioPause"
|
@pause="handleAudioPause"
|
||||||
@@ -189,6 +189,18 @@ onLoad(async (options: any = {}) => {
|
|||||||
if (exhibitData.hallId) {
|
if (exhibitData.hallId) {
|
||||||
resolvedHallId.value = 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
|
showAudioPlayer.value = true
|
||||||
currentAudio.value = audio
|
currentAudio.value = audio
|
||||||
await nextTick()
|
audioPlayerRef.value?.play(audio)
|
||||||
|
|
||||||
if (!isPlaying.value) {
|
|
||||||
audioPlayerRef.value?.play(audio)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,10 +255,10 @@ const handlePlayAudio = async () => {
|
|||||||
showAudioPlayer.value = true
|
showAudioPlayer.value = true
|
||||||
currentAudio.value = audio
|
currentAudio.value = audio
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
uni.showToast({
|
||||||
if (!isPlaying.value) {
|
title: '音频已准备好,请点击底部播放按钮',
|
||||||
audioPlayerRef.value?.play(audio)
|
icon: 'none'
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAudioVisibleChange = (visible: boolean) => {
|
const handleAudioVisibleChange = (visible: boolean) => {
|
||||||
|
|||||||
@@ -26,9 +26,10 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<AudioPlayer
|
<AudioPlayer
|
||||||
|
ref="audioPlayerRef"
|
||||||
:visible="showAudioPlayer"
|
:visible="showAudioPlayer"
|
||||||
:audio="currentAudio"
|
:audio="currentAudio"
|
||||||
:auto-play="true"
|
:auto-play="false"
|
||||||
avoid-bottom-nav
|
avoid-bottom-nav
|
||||||
@update:visible="handleAudioVisibleChange"
|
@update:visible="handleAudioVisibleChange"
|
||||||
@play="handleAudioPlay"
|
@play="handleAudioPlay"
|
||||||
@@ -66,8 +67,14 @@ const explainError = ref('')
|
|||||||
const showAudioPlayer = ref(false)
|
const showAudioPlayer = ref(false)
|
||||||
const isAudioPlaying = ref(false)
|
const isAudioPlaying = ref(false)
|
||||||
const currentAudio = ref<AudioItem | null>(null)
|
const currentAudio = ref<AudioItem | null>(null)
|
||||||
|
const audioPlayerRef = ref<AudioPlayerExpose | null>(null)
|
||||||
let explainLoadPromise: Promise<void> | null = null
|
let explainLoadPromise: Promise<void> | null = null
|
||||||
|
|
||||||
|
interface AudioPlayerExpose {
|
||||||
|
play: (audio: AudioItem) => void
|
||||||
|
pause: () => void
|
||||||
|
}
|
||||||
|
|
||||||
const loadExplainExhibits = async () => {
|
const loadExplainExhibits = async () => {
|
||||||
if (explainExhibits.value.length) return
|
if (explainExhibits.value.length) return
|
||||||
if (explainLoadPromise) return explainLoadPromise
|
if (explainLoadPromise) return explainLoadPromise
|
||||||
@@ -129,14 +136,23 @@ const handleExplainHallClick = (hallId: string) => {
|
|||||||
|
|
||||||
const handleAudioClick = async (exhibit: Exhibit) => {
|
const handleAudioClick = async (exhibit: Exhibit) => {
|
||||||
if (exhibit.audioUrl) {
|
if (exhibit.audioUrl) {
|
||||||
currentAudio.value = {
|
const audio: AudioItem = {
|
||||||
id: `media-${exhibit.id}`,
|
id: `media-${exhibit.id}`,
|
||||||
name: exhibit.title,
|
name: exhibit.title,
|
||||||
audioUrl: exhibit.audioUrl,
|
audioUrl: exhibit.audioUrl,
|
||||||
image: exhibit.coverImage,
|
image: exhibit.coverImage,
|
||||||
duration: exhibit.audioDuration
|
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
|
showAudioPlayer.value = true
|
||||||
|
audioPlayerRef.value?.play(audio)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +174,10 @@ const handleAudioClick = async (exhibit: Exhibit) => {
|
|||||||
duration: selection.media.duration
|
duration: selection.media.duration
|
||||||
}
|
}
|
||||||
showAudioPlayer.value = true
|
showAudioPlayer.value = true
|
||||||
|
uni.showToast({
|
||||||
|
title: '音频已准备好,请点击底部播放按钮',
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearAudioState = () => {
|
const clearAudioState = () => {
|
||||||
|
|||||||
@@ -125,6 +125,26 @@ const normalizePoiName = (value: string) => value
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|
||||||
// 展厅数据里可能没有直接写入 poiId,优先走内容层映射,再按展厅名称兜底匹配导览点位。
|
// 展厅数据里可能没有直接写入 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 resolveHallGuidePoi = async () => {
|
||||||
const directPoiId = hall.value.location?.poiId || hall.value.poiId
|
const directPoiId = hall.value.location?.poiId || hall.value.poiId
|
||||||
if (directPoiId) {
|
if (directPoiId) {
|
||||||
@@ -148,6 +168,14 @@ const resolveHallGuidePoi = async () => {
|
|||||||
}) || candidates[0] || null
|
}) || candidates[0] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveHallGuidePoiInBackground = () => {
|
||||||
|
void resolveHallGuidePoi()
|
||||||
|
.then(applyMatchedHallPoi)
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('展厅三维位置后台匹配失败:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
onLoad(async (options: any = {}) => {
|
onLoad(async (options: any = {}) => {
|
||||||
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
|
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
|
||||||
if (isGuideTopTab(tab)) {
|
if (isGuideTopTab(tab)) {
|
||||||
@@ -173,25 +201,8 @@ onLoad(async (options: any = {}) => {
|
|||||||
hall.value = toHallDetailViewModel(hallData)
|
hall.value = toHallDetailViewModel(hallData)
|
||||||
const hallExhibits = await explainUseCase.listExhibitsByHallId(hallData.id)
|
const hallExhibits = await explainUseCase.listExhibitsByHallId(hallData.id)
|
||||||
exhibits.value = hallExhibits.map(toExplainExhibitViewModel)
|
exhibits.value = hallExhibits.map(toExplainExhibitViewModel)
|
||||||
|
loading.value = false
|
||||||
const matchedPoi = await resolveHallGuidePoi()
|
resolveHallGuidePoiInBackground()
|
||||||
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: '已定位到该展厅的三维点位。'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('加载展厅讲解失败:', err)
|
console.error('加载展厅讲解失败:', err)
|
||||||
error.value = '展厅讲解加载失败,请稍后重试'
|
error.value = '展厅讲解加载失败,请稍后重试'
|
||||||
@@ -224,20 +235,7 @@ const handleNavigate = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resolvedHallPoiId.value = matchedPoi.id
|
resolvedHallPoiId.value = matchedPoi.id
|
||||||
hall.value = {
|
applyMatchedHallPoi(matchedPoi)
|
||||||
...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: '已定位到该展厅的三维点位。'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages/route/detail?facilityId=${encodeURIComponent(matchedPoi.id)}&target=${encodeURIComponent(hall.value.name)}&state=preview`
|
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
|
playUrl?: string | null
|
||||||
expiresAt?: string | null
|
expiresAt?: string | null
|
||||||
subtitleUrl?: string | null
|
subtitleUrl?: string | null
|
||||||
|
hasText?: boolean
|
||||||
fallback?: boolean
|
fallback?: boolean
|
||||||
fallbackReason?: string | null
|
fallbackReason?: string | null
|
||||||
reason?: string | null
|
reason?: string | null
|
||||||
@@ -40,8 +41,34 @@ interface AudioPlayInfoResponse {
|
|||||||
data?: AudioPlayInfo
|
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 {
|
export interface AudioPlayInfoRepository {
|
||||||
getPlayInfo(request: AudioPlayInfoRequest): Promise<AudioPlayInfo>
|
getPlayInfo(request: AudioPlayInfoRequest): Promise<AudioPlayInfo>
|
||||||
|
getTextInfo(request: AudioTextInfoRequest): Promise<AudioTextInfo>
|
||||||
clearCache(lang?: AudioLanguage): void
|
clearCache(lang?: AudioLanguage): void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +94,7 @@ const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject
|
|||||||
uni.request({
|
uni.request({
|
||||||
url,
|
url,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
timeout: 8000,
|
||||||
success: (response) => {
|
success: (response) => {
|
||||||
const statusCode = Number(response.statusCode || 0)
|
const statusCode = Number(response.statusCode || 0)
|
||||||
if (statusCode < 200 || statusCode >= 300) {
|
if (statusCode < 200 || statusCode >= 300) {
|
||||||
@@ -95,6 +123,10 @@ const audioKey = ({ targetType, targetId, lang }: Required<AudioPlayInfoRequest>
|
|||||||
`${targetType}:${targetId}:${lang}`
|
`${targetType}:${targetId}:${lang}`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const audioTextKey = ({ targetType, targetId, lang }: Required<AudioTextInfoRequest>) => (
|
||||||
|
`${targetType}:${targetId}:${lang}`
|
||||||
|
)
|
||||||
|
|
||||||
const isExpired = (expiresAt?: string | null) => (
|
const isExpired = (expiresAt?: string | null) => (
|
||||||
Boolean(expiresAt) && new Date(expiresAt as string).getTime() <= Date.now()
|
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 {
|
export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
||||||
private readonly cache = new Map<string, AudioPlayInfo>()
|
private readonly cache = new Map<string, AudioPlayInfo>()
|
||||||
|
private readonly textCache = new Map<string, AudioTextInfo>()
|
||||||
|
|
||||||
async getPlayInfo(request: AudioPlayInfoRequest): Promise<AudioPlayInfo> {
|
async getPlayInfo(request: AudioPlayInfoRequest): Promise<AudioPlayInfo> {
|
||||||
const normalizedRequest: Required<AudioPlayInfoRequest> = {
|
const normalizedRequest: Required<AudioPlayInfoRequest> = {
|
||||||
@@ -140,9 +173,42 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
|||||||
return response.data
|
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) {
|
clearCache(lang?: AudioLanguage) {
|
||||||
if (!lang) {
|
if (!lang) {
|
||||||
this.cache.clear()
|
this.cache.clear()
|
||||||
|
this.textCache.clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +217,11 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
|||||||
this.cache.delete(key)
|
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) {
|
getExhibitById(id: string) {
|
||||||
return this.content.getExhibitById(id)
|
return this.explainContent.getExhibitById?.(id) || this.content.getExhibitById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
listHalls() {
|
listHalls() {
|
||||||
@@ -81,11 +81,43 @@ export class DefaultExplainRepository implements ExplainRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getTrackByExhibitId(exhibitId: string) {
|
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()
|
const tracks = await this.listTracks()
|
||||||
return tracks.find((track) => track.exhibitId === exhibitId) || null
|
return tracks.find((track) => track.exhibitId === exhibitId) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchExplain(keyword = '') {
|
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 normalizedKeyword = keyword.trim().toLowerCase()
|
||||||
const [exhibits, halls] = await Promise.all([
|
const [exhibits, halls] = await Promise.all([
|
||||||
this.explainContent.listExplainExhibits(),
|
this.explainContent.listExplainExhibits(),
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ export class DefaultMuseumContentRepository implements MuseumContentRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getExhibitById(id: string) {
|
async getExhibitById(id: string) {
|
||||||
|
if (this.contentProvider.getExhibitById) {
|
||||||
|
return this.contentProvider.getExhibitById(id)
|
||||||
|
}
|
||||||
|
|
||||||
const exhibits = await this.contentProvider.listExhibits()
|
const exhibits = await this.contentProvider.listExhibits()
|
||||||
return exhibits.find((exhibit) => exhibit.id === id) || null
|
return exhibits.find((exhibit) => exhibit.id === id) || null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,12 +18,16 @@ import {
|
|||||||
audioPlayInfoRepository,
|
audioPlayInfoRepository,
|
||||||
audioReasonToText,
|
audioReasonToText,
|
||||||
type AudioPlayInfo,
|
type AudioPlayInfo,
|
||||||
type AudioPlayInfoRepository
|
type AudioPlayInfoRepository,
|
||||||
|
type AudioTextInfo
|
||||||
} from '@/repositories/AudioPlayInfoRepository'
|
} from '@/repositories/AudioPlayInfoRepository'
|
||||||
import {
|
import {
|
||||||
publishedExhibitAudioRepository,
|
publishedExhibitAudioRepository,
|
||||||
type PublishedExhibitAudioRepository
|
type PublishedExhibitAudioRepository
|
||||||
} from '@/repositories/PublishedExhibitAudioRepository'
|
} from '@/repositories/PublishedExhibitAudioRepository'
|
||||||
|
import {
|
||||||
|
dataSourceConfig
|
||||||
|
} from '@/config/dataSource'
|
||||||
|
|
||||||
export interface ExplainAudioSelection {
|
export interface ExplainAudioSelection {
|
||||||
exhibit: MuseumExhibit
|
exhibit: MuseumExhibit
|
||||||
@@ -34,6 +38,11 @@ export interface ExplainAudioSelection {
|
|||||||
playInfo?: AudioPlayInfo
|
playInfo?: AudioPlayInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExhibitDetailAudioOptions {
|
||||||
|
enrichAudio?: boolean
|
||||||
|
includeText?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export class ExplainUseCase {
|
export class ExplainUseCase {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly explain: ExplainRepository = explainRepository,
|
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() {
|
private async getTrackByExhibitIdMap() {
|
||||||
const tracks = await this.explain.listTracks()
|
const tracks = await this.explain.listTracks()
|
||||||
const trackByExhibitId = new Map<string, ExplainTrack>()
|
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)
|
const exhibit = await this.explain.getExhibitById(id)
|
||||||
if (!exhibit) return null
|
if (!exhibit) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const track = await this.explain.getTrackByExhibitId(id)
|
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) {
|
} catch (error) {
|
||||||
console.warn('讲解详情音频状态加载失败,将使用展项原始音频状态:', error)
|
console.warn('讲解详情音频状态加载失败,将使用展项原始音频状态:', error)
|
||||||
return exhibit
|
return exhibit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async enrichExhibitDetailAudio(id: string) {
|
||||||
|
return this.getExhibitById(id, {
|
||||||
|
enrichAudio: true,
|
||||||
|
includeText: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
listHalls(): Promise<MuseumHall[]> {
|
listHalls(): Promise<MuseumHall[]> {
|
||||||
return this.explain.listHalls()
|
return this.explain.listHalls()
|
||||||
}
|
}
|
||||||
@@ -130,15 +249,20 @@ export class ExplainUseCase {
|
|||||||
async selectAudioForExhibit(exhibitId: string): Promise<ExplainAudioSelection | null> {
|
async selectAudioForExhibit(exhibitId: string): Promise<ExplainAudioSelection | null> {
|
||||||
const track = await this.explain.getTrackByExhibitId(exhibitId)
|
const track = await this.explain.getTrackByExhibitId(exhibitId)
|
||||||
const explainExhibits = await this.explain.listExplainExhibits()
|
const explainExhibits = await this.explain.listExplainExhibits()
|
||||||
const exhibit = explainExhibits.find((item) => item.id === exhibitId)
|
const summaryExhibit = explainExhibits.find((item) => item.id === exhibitId)
|
||||||
|| await this.explain.getExhibitById(exhibitId)
|
const detailExhibit = await this.explain.getExhibitById(exhibitId).catch(() => null)
|
||||||
|
const exhibit = detailExhibit || summaryExhibit
|
||||||
if (!exhibit) return null
|
if (!exhibit) return null
|
||||||
|
|
||||||
const fallbackMedia = track?.mediaId
|
const fallbackMedia = await (track?.mediaId
|
||||||
? await this.media.getMediaById(track.mediaId)
|
? this.media.getMediaById(track.mediaId)
|
||||||
: exhibit.guideContentId
|
: exhibit.guideContentId
|
||||||
? await this.media.getMediaById(`media-${exhibit.guideContentId}`)
|
? this.media.getMediaById(`media-${exhibit.guideContentId}`)
|
||||||
: null
|
: Promise.resolve(null)
|
||||||
|
).catch((error) => {
|
||||||
|
console.warn('静态讲解媒体读取失败,将继续尝试播放接口:', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
const targetType: AudioPlayTargetType = track?.playTargetType
|
const targetType: AudioPlayTargetType = track?.playTargetType
|
||||||
|| exhibit.playTargetType
|
|| exhibit.playTargetType
|
||||||
|| 'ITEM'
|
|| '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 {
|
try {
|
||||||
const initialPlayInfo = await this.audioPlayInfo.getPlayInfo({ targetType, targetId })
|
const initialPlayInfo = await this.audioPlayInfo.getPlayInfo({ targetType, targetId })
|
||||||
|
|
||||||
@@ -222,9 +355,6 @@ export class ExplainUseCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const directMediaSelection = toDirectMediaSelection(initialPlayInfo)
|
|
||||||
if (directMediaSelection) return directMediaSelection
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -237,13 +367,23 @@ export class ExplainUseCase {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('语音播放解析失败:', 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 {
|
return {
|
||||||
exhibit,
|
exhibit,
|
||||||
track,
|
track,
|
||||||
media: null,
|
media: null,
|
||||||
playable: false,
|
playable: false,
|
||||||
unavailableMessage: '语音服务暂不可用,请稍后重试'
|
playInfo: serviceErrorPlayInfo,
|
||||||
|
unavailableMessage: '语音服务暂不可用,当前提供图文讲解'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,18 @@ const buildBadges = (exhibit: MuseumExhibit) => {
|
|||||||
return Array.from(new Set(badges)).slice(0, 3)
|
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 => {
|
export const toExplainCardViewModel = (exhibit: MuseumExhibit): ExplainCardViewModel => {
|
||||||
const hasPlayableAudio = exhibit.audioAvailable === true || Boolean(exhibit.audioUrl)
|
const hasPlayableAudio = exhibit.audioAvailable === true || Boolean(exhibit.audioUrl)
|
||||||
const audioStatus: ExplainAudioStatus = hasPlayableAudio ? 'playable' : 'unavailable'
|
const audioStatus: ExplainAudioStatus = hasPlayableAudio ? 'playable' : 'unavailable'
|
||||||
@@ -111,10 +123,10 @@ export const toExplainCardViewModel = (exhibit: MuseumExhibit): ExplainCardViewM
|
|||||||
hallId: exhibit.hallId,
|
hallId: exhibit.hallId,
|
||||||
hallName: exhibit.hallName,
|
hallName: exhibit.hallName,
|
||||||
floorLabel: exhibit.floorLabel,
|
floorLabel: exhibit.floorLabel,
|
||||||
durationLabel: exhibit.audioDuration ? `${Math.round(exhibit.audioDuration)}秒` : undefined,
|
durationLabel: formatDuration(exhibit.audioDuration),
|
||||||
audioUrl: exhibit.audioUrl,
|
audioUrl: exhibit.audioUrl,
|
||||||
audioDuration: exhibit.audioDuration,
|
audioDuration: exhibit.audioDuration,
|
||||||
languageLabel: '中文',
|
languageLabel: languageLabelFor(exhibit.audioLanguage),
|
||||||
audioStatus,
|
audioStatus,
|
||||||
audioStatusText: hasPlayableAudio ? '可播放' : '图文讲解',
|
audioStatusText: hasPlayableAudio ? '可播放' : '图文讲解',
|
||||||
badges: buildBadges(exhibit),
|
badges: buildBadges(exhibit),
|
||||||
@@ -134,8 +146,9 @@ export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDet
|
|||||||
exhibit.year ? `年代:${exhibit.year}` : '',
|
exhibit.year ? `年代:${exhibit.year}` : '',
|
||||||
exhibit.material ? `来源:${exhibit.material}` : ''
|
exhibit.material ? `来源:${exhibit.material}` : ''
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
|
const transcript = exhibit.audioText || exhibit.guideText || exhibit.description
|
||||||
const body = [
|
const body = [
|
||||||
exhibit.guideText || exhibit.description || '该展项暂无讲解文稿。',
|
transcript || '该展项暂无讲解文稿。',
|
||||||
metadataLines.join('\n')
|
metadataLines.join('\n')
|
||||||
].filter(Boolean).join('\n\n')
|
].filter(Boolean).join('\n\n')
|
||||||
|
|
||||||
@@ -148,14 +161,16 @@ export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDet
|
|||||||
hallId: exhibit.hallId,
|
hallId: exhibit.hallId,
|
||||||
hallName: exhibit.hallName,
|
hallName: exhibit.hallName,
|
||||||
floorLabel: exhibit.floorLabel,
|
floorLabel: exhibit.floorLabel,
|
||||||
summary: exhibit.guideText || exhibit.description || '该展项暂无讲解文稿。',
|
summary: transcript || '该展项暂无讲解文稿。',
|
||||||
body,
|
body,
|
||||||
audio: {
|
audio: {
|
||||||
status: hasPlayableAudio ? 'playable' : 'unavailable',
|
status: hasPlayableAudio ? 'playable' : 'unavailable',
|
||||||
url: exhibit.audioUrl,
|
url: exhibit.audioUrl,
|
||||||
durationLabel: exhibit.audioDuration ? `${Math.round(exhibit.audioDuration)}秒` : undefined,
|
durationLabel: formatDuration(exhibit.audioDuration),
|
||||||
language: 'zh-CN',
|
language: exhibit.audioLanguage,
|
||||||
unavailableReason: hasPlayableAudio ? undefined : '该展项暂无已发布音频,当前提供图文讲解。'
|
unavailableReason: hasPlayableAudio
|
||||||
|
? undefined
|
||||||
|
: exhibit.audioUnavailableReason || '该展项暂无已发布音频,当前提供图文讲解。'
|
||||||
},
|
},
|
||||||
chapters: metadataLines.length
|
chapters: metadataLines.length
|
||||||
? metadataLines.map((line, index) => ({
|
? metadataLines.map((line, index) => ({
|
||||||
@@ -163,7 +178,7 @@ export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDet
|
|||||||
title: line
|
title: line
|
||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
transcript: exhibit.guideText || exhibit.description,
|
transcript,
|
||||||
location: exhibit.location,
|
location: exhibit.location,
|
||||||
relatedItems: []
|
relatedItems: []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import uni from '@dcloudio/vite-plugin-uni'
|
|||||||
|
|
||||||
const sgsDevServer = 'http://1.92.206.90:3001'
|
const sgsDevServer = 'http://1.92.206.90:3001'
|
||||||
const sgsAssetServer = 'http://1.92.206.90:9000'
|
const sgsAssetServer = 'http://1.92.206.90:9000'
|
||||||
|
const legacyAudioServer = 'http://47.120.48.148:19000'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [uni()],
|
plugins: [uni()],
|
||||||
@@ -28,6 +29,10 @@ export default defineConfig({
|
|||||||
'/minio': {
|
'/minio': {
|
||||||
target: sgsDevServer,
|
target: sgsDevServer,
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/nhm/audio': {
|
||||||
|
target: legacyAudioServer,
|
||||||
|
changeOrigin: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user