diff --git a/src/data/adapters/backendExplainDataAdapter.ts b/src/data/adapters/backendExplainDataAdapter.ts new file mode 100644 index 0000000..c71de88 --- /dev/null +++ b/src/data/adapters/backendExplainDataAdapter.ts @@ -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) => ( + 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 + } +} diff --git a/src/data/providers/backendExplainContentProvider.ts b/src/data/providers/backendExplainContentProvider.ts new file mode 100644 index 0000000..2f1ffa6 --- /dev/null +++ b/src/data/providers/backendExplainContentProvider.ts @@ -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 { + 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 = (payload: unknown): T => { + if (typeof payload === 'string') { + return JSON.parse(payload) as T + } + + return payload as T +} + +const requestJson = (url: string): Promise => 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(response.data)) + } catch (error) { + reject(error) + } + }, + fail: reject + }) +}) + +const normalizeKeyword = (keyword = '') => keyword.trim() + +export class BackendExplainContentProvider implements ExplainContentProvider { + private readonly searchCache = new Map() + private readonly searchInflight = new Map>() + private readonly detailCache = new Map() + private readonly detailInflight = new Map>() + + 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>(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>(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() + + 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) + } +} diff --git a/src/data/providers/staticMuseumContentProvider.ts b/src/data/providers/staticMuseumContentProvider.ts index be8fbb3..f6da34c 100644 --- a/src/data/providers/staticMuseumContentProvider.ts +++ b/src/data/providers/staticMuseumContentProvider.ts @@ -25,14 +25,19 @@ import { type SgsSceneExhibitMock, type SgsSceneSpaceMock } from '@/data/mock/sgsSceneExplainData' +import { + BackendExplainContentProvider +} from '@/data/providers/backendExplainContentProvider' export interface MuseumContentProvider { listExhibits(): Promise + getExhibitById?(id: string): Promise listHalls(): Promise } export interface ExplainContentProvider extends MuseumContentProvider { listExplainExhibits(): Promise + searchExplainExhibits?(keyword?: string): Promise listTracks(): Promise getMediaById(id: string): Promise getMediaForExplainTrack(trackId: string): Promise @@ -232,7 +237,7 @@ export const createMuseumContentProvider = (): ExplainContentProvider => { } if (isGuideContentRemoteMode()) { - return new RemoteGuideContentProvider() + return new BackendExplainContentProvider(new StaticGuideContentProvider()) } if (isGuideContentMockMode()) { diff --git a/src/domain/museum.ts b/src/domain/museum.ts index 22c9613..f724b28 100644 --- a/src/domain/museum.ts +++ b/src/domain/museum.ts @@ -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 diff --git a/src/pages/exhibit/detail.vue b/src/pages/exhibit/detail.vue index 15bd5d6..1b1bcb1 100644 --- a/src/pages/exhibit/detail.vue +++ b/src/pages/exhibit/detail.vue @@ -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) => { diff --git a/src/pages/explain/list.vue b/src/pages/explain/list.vue index 760adb4..9a9aa86 100644 --- a/src/pages/explain/list.vue +++ b/src/pages/explain/list.vue @@ -26,9 +26,10 @@ /> (null) +const audioPlayerRef = ref(null) let explainLoadPromise: Promise | 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 = () => { diff --git a/src/pages/hall/detail.vue b/src/pages/hall/detail.vue index 209f407..6f828f3 100644 --- a/src/pages/hall/detail.vue +++ b/src/pages/hall/detail.vue @@ -125,6 +125,26 @@ const normalizePoiName = (value: string) => value .toLowerCase() // 展厅数据里可能没有直接写入 poiId,优先走内容层映射,再按展厅名称兜底匹配导览点位。 +const applyMatchedHallPoi = (matchedPoi: Awaited>) => { + 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` diff --git a/src/repositories/AudioPlayInfoRepository.ts b/src/repositories/AudioPlayInfoRepository.ts index e6ba03e..11fae8d 100644 --- a/src/repositories/AudioPlayInfoRepository.ts +++ b/src/repositories/AudioPlayInfoRepository.ts @@ -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 + getTextInfo(request: AudioTextInfoRequest): Promise clearCache(lang?: AudioLanguage): void } @@ -67,6 +94,7 @@ const requestJson = (url: string): Promise => 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 `${targetType}:${targetId}:${lang}` ) +const audioTextKey = ({ targetType, targetId, lang }: Required) => ( + `${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() + private readonly textCache = new Map() async getPlayInfo(request: AudioPlayInfoRequest): Promise { const normalizedRequest: Required = { @@ -140,9 +173,42 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository { return response.data } + async getTextInfo(request: AudioTextInfoRequest): Promise { + const normalizedRequest: Required = { + 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(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) + } + }) } } diff --git a/src/repositories/ExplainRepository.ts b/src/repositories/ExplainRepository.ts index 83e48ec..e6f780b 100644 --- a/src/repositories/ExplainRepository.ts +++ b/src/repositories/ExplainRepository.ts @@ -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((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(), diff --git a/src/repositories/MuseumContentRepository.ts b/src/repositories/MuseumContentRepository.ts index 8d6da2e..0048441 100644 --- a/src/repositories/MuseumContentRepository.ts +++ b/src/repositories/MuseumContentRepository.ts @@ -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 } diff --git a/src/usecases/explainUseCase.ts b/src/usecases/explainUseCase.ts index 8bf82e8..d7ced28 100644 --- a/src/usecases/explainUseCase.ts +++ b/src/usecases/explainUseCase.ts @@ -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 { + 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() @@ -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 { return this.explain.listHalls() } @@ -130,15 +249,20 @@ export class ExplainUseCase { async selectAudioForExhibit(exhibitId: string): Promise { 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: '语音服务暂不可用,当前提供图文讲解' } } } diff --git a/src/view-models/explainViewModels.ts b/src/view-models/explainViewModels.ts index b8fa262..21549b6 100644 --- a/src/view-models/explainViewModels.ts +++ b/src/view-models/explainViewModels.ts @@ -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: [] } diff --git a/vite.config.ts b/vite.config.ts index 0680837..f37f11e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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 } } },