适配讲解详情聚合接口

This commit is contained in:
lyf
2026-07-22 19:40:17 +08:00
parent dd29ead041
commit 4d8cc55a5c
10 changed files with 440 additions and 152 deletions

View File

@@ -259,7 +259,7 @@ const resume = () => {
}) })
} }
const switchLanguage = async (lang: AudioLanguage) => { const switchLanguage = async (lang: AudioLanguage, localAudio?: AudioItem | null) => {
if (pendingLanguage.value) { if (pendingLanguage.value) {
return false return false
} }
@@ -291,6 +291,35 @@ const switchLanguage = async (lang: AudioLanguage) => {
} }
try { try {
if (localAudio?.audioUrl) {
currentSource.value = nextSource
return await play(localAudio, {
source: nextSource,
retryOnError: retryOnError || undefined,
displayMode: preservedMode
})
}
if (localAudio) {
stopAudioElement()
currentSource.value = nextSource
currentAudio.value = {
...localAudio,
audioUrl: '',
duration: 0,
language: lang
}
visible.value = true
displayMode.value = preservedMode
playing.value = false
loading.value = false
currentTime.value = 0
duration.value = 0
error.value = '当前语言暂无语音讲解'
showToast(error.value)
return false
}
const playInfo = await audioPlayInfoRepository.getPlayInfo({ const playInfo = await audioPlayInfoRepository.getPlayInfo({
targetType: source.targetType, targetType: source.targetType,
targetId: source.targetId, targetId: source.targetId,

View File

@@ -42,11 +42,31 @@ export interface BackendGuideStopInfo {
hasText?: boolean hasText?: boolean
supportedLanguages?: string[] | null supportedLanguages?: string[] | null
audioStatus?: string | null audioStatus?: string | null
languageVariants?: BackendGuideStopLanguageVariant[] | null
reason?: string | null reason?: string | null
linkedExhibitCount?: number | string | null linkedExhibitCount?: number | string | null
isSharedStop?: boolean | null isSharedStop?: boolean | null
} }
export interface BackendGuideStopLanguageVariant {
lang?: string | null
enabled?: boolean | null
playable?: boolean | null
audioStatus?: string | null
playUrl?: string | null
duration?: number | string | null
format?: string | null
audioId?: string | number | null
narrationTier?: 'STANDARD' | 'EXTENDED' | string | null
hasText?: boolean | null
textAvailable?: boolean | null
text?: string | null
textLength?: number | string | null
textHash?: string | null
fallback?: boolean | null
reason?: string | null
}
export interface BackendAudioPlayInfo { export interface BackendAudioPlayInfo {
playable?: boolean playable?: boolean
targetType?: string | null targetType?: string | null
@@ -90,6 +110,25 @@ export interface GuideStopLinkedExhibit {
sortOrder?: number sortOrder?: number
} }
export interface GuideStopLanguageVariant {
lang: GuideAudioLanguage
enabled: boolean
playable: boolean
audioStatus: 'READY' | 'MISSING' | string
playUrl?: string
duration?: number
format?: string
audioId?: string
narrationTier?: 'STANDARD' | 'EXTENDED'
hasText: boolean
textAvailable: boolean
text?: string
textLength?: number
textHash?: string
fallback: boolean
reason?: string
}
export interface GuideStopInfo { export interface GuideStopInfo {
available: boolean available: boolean
targetType: AudioPlayTargetType targetType: AudioPlayTargetType
@@ -112,6 +151,7 @@ export interface GuideStopInfo {
hasAudio: boolean hasAudio: boolean
hasText: boolean hasText: boolean
supportedLanguages: GuideAudioLanguage[] supportedLanguages: GuideAudioLanguage[]
languageVariants: Record<GuideAudioLanguage, GuideStopLanguageVariant>
audioStatus: 'READY' | 'MISSING' | string audioStatus: 'READY' | 'MISSING' | string
reason?: string reason?: string
linkedExhibitCount?: number linkedExhibitCount?: number
@@ -177,16 +217,57 @@ export const normalizeGuideAudioLanguage = (
return 'zh-CN' return 'zh-CN'
} }
const isSupportedGuideAudioLanguage = (value: string | null | undefined) => (
['zh', 'zh-cn', 'en', 'en-us', 'yue', 'yue-cn', 'yue-hk'].includes(value?.trim().toLowerCase() || '')
)
const normalizeSupportedLanguages = (languages: string[] | null | undefined): GuideAudioLanguage[] => ( const normalizeSupportedLanguages = (languages: string[] | null | undefined): GuideAudioLanguage[] => (
Array.from(new Set((languages || []) Array.from(new Set((languages || [])
.map((language) => { .filter(isSupportedGuideAudioLanguage)
const normalized = language?.trim().toLowerCase() .map(normalizeGuideAudioLanguage))) as GuideAudioLanguage[]
if (!['zh', 'zh-cn', 'en', 'en-us', 'yue', 'yue-cn', 'yue-hk'].includes(normalized)) { )
return null
} const normalizeLanguageVariants = (
return normalizeGuideAudioLanguage(language) variants: BackendGuideStopLanguageVariant[] | null | undefined
}) ): Record<GuideAudioLanguage, GuideStopLanguageVariant> => {
.filter(Boolean))) as GuideAudioLanguage[] const normalizedVariants = {} as Record<GuideAudioLanguage, GuideStopLanguageVariant>
;(variants || []).forEach((variant) => {
if (!isSupportedGuideAudioLanguage(variant.lang)) return
const lang = normalizeGuideAudioLanguage(variant.lang)
const playUrl = normalizeSameOriginPublicUrl(variant.playUrl) || undefined
const playable = variant.playable === true && Boolean(playUrl)
const text = variant.text?.trim() || undefined
const textAvailable = variant.textAvailable === true || Boolean(text)
normalizedVariants[lang] = {
lang,
enabled: variant.enabled === true,
playable,
audioStatus: variant.audioStatus || (playable ? 'READY' : 'MISSING'),
playUrl,
duration: normalizeNumber(variant.duration),
format: variant.format?.trim() || undefined,
audioId: stringifyId(variant.audioId) || undefined,
narrationTier: normalizeNarrationTier(variant.narrationTier),
hasText: variant.hasText === true || textAvailable,
textAvailable,
text,
textLength: normalizeNumber(variant.textLength),
textHash: variant.textHash?.trim() || undefined,
fallback: variant.fallback === true,
reason: variant.reason?.trim() || undefined
}
})
return normalizedVariants
}
const supportedLanguagesFromVariants = (variants: Record<GuideAudioLanguage, GuideStopLanguageVariant>) => (
(Object.values(variants) as GuideStopLanguageVariant[])
.filter((variant) => variant.enabled && (variant.playable || variant.textAvailable))
.map((variant) => variant.lang)
) )
const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls'] | BackendGuideStopLinkedExhibit['galleryUrls']) => { const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls'] | BackendGuideStopLinkedExhibit['galleryUrls']) => {
@@ -255,6 +336,8 @@ export const toGuideStopInfo = (
const coverImageUrl = canUseStopImages const coverImageUrl = canUseStopImages
? normalizeSameOriginPublicUrl(source.coverImageUrl) || undefined ? normalizeSameOriginPublicUrl(source.coverImageUrl) || undefined
: undefined : undefined
const languageVariants = normalizeLanguageVariants(source.languageVariants)
const supportedLanguages = supportedLanguagesFromVariants(languageVariants)
return { return {
available: source.available === true, available: source.available === true,
@@ -277,7 +360,10 @@ export const toGuideStopInfo = (
playTargetId, playTargetId,
hasAudio: source.hasAudio === true, hasAudio: source.hasAudio === true,
hasText: source.hasText === true, hasText: source.hasText === true,
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages), supportedLanguages: supportedLanguages.length
? supportedLanguages
: normalizeSupportedLanguages(source.supportedLanguages),
languageVariants,
audioStatus: source.audioStatus || 'MISSING', audioStatus: source.audioStatus || 'MISSING',
reason: source.reason || undefined, reason: source.reason || undefined,
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount), linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),

View File

@@ -182,10 +182,21 @@ export interface MuseumExhibit {
audioVariants?: Record<string, { audioVariants?: Record<string, {
title?: string title?: string
text?: string text?: string
textAvailable?: boolean
textLength?: number
textHash?: string
audioUrl?: string audioUrl?: string
audioDuration?: number audioDuration?: number
format?: string
audioId?: string
narrationTier?: 'STANDARD' | 'EXTENDED'
hasText?: boolean hasText?: boolean
enabled?: boolean
playable?: boolean
available?: boolean available?: boolean
audioStatus?: string
fallback?: boolean
reason?: string
}> }>
imageStatus?: string imageStatus?: string
imageSource?: string imageSource?: string

View File

@@ -160,7 +160,8 @@ import {
type ExplainDetailPageViewModel type ExplainDetailPageViewModel
} from '@/view-models/explainViewModels' } from '@/view-models/explainViewModels'
import type { import type {
AudioPlayTargetType AudioPlayTargetType,
MuseumExhibit
} from '@/domain/museum' } from '@/domain/museum'
import type { import type {
AudioLanguage AudioLanguage
@@ -193,6 +194,7 @@ const defaultDetail: ExplainDetailPageViewModel = {
} }
const exhibit = ref<ExplainDetailPageViewModel>(defaultDetail) const exhibit = ref<ExplainDetailPageViewModel>(defaultDetail)
const detailSource = ref<MuseumExhibit | null>(null)
const detailState = ref<'idle' | 'loading' | 'ready' | 'missing' | 'error'>('idle') const detailState = ref<'idle' | 'loading' | 'ready' | 'missing' | 'error'>('idle')
const detailStateMessage = ref('请检查链接后重试。') const detailStateMessage = ref('请检查链接后重试。')
const globalAudioPlayer = useGlobalAudioPlayer() const globalAudioPlayer = useGlobalAudioPlayer()
@@ -201,7 +203,6 @@ const resolvedHallId = ref('')
const retryingAudio = ref(false) const retryingAudio = ref(false)
const languageSwitchLoading = ref(false) const languageSwitchLoading = ref(false)
const selectedAudioLanguage = ref<AudioLanguage>('zh-CN') const selectedAudioLanguage = ref<AudioLanguage>('zh-CN')
let languageSwitchSequence = 0
const detailEntryRequest = ref<{ const detailEntryRequest = ref<{
exhibitId: string exhibitId: string
targetType?: AudioPlayTargetType targetType?: AudioPlayTargetType
@@ -217,16 +218,6 @@ const detailTextByLanguage = ref<Record<AudioLanguage, string>>({
'yue-HK': '当前语言暂无讲解词。', 'yue-HK': '当前语言暂无讲解词。',
'en-US': 'English narration text is not available yet.' 'en-US': 'English narration text is not available yet.'
}) })
const detailTextLoadingByLanguage = ref<Record<AudioLanguage, boolean>>({
'zh-CN': false,
'yue-HK': false,
'en-US': false
})
const detailTextLoadedByLanguage = ref<Record<AudioLanguage, boolean>>({
'zh-CN': false,
'yue-HK': false,
'en-US': false
})
// 讲解详情页位置入口暂未开放,避免误导用户进入位置预览流程。 // 讲解详情页位置入口暂未开放,避免误导用户进入位置预览流程。
const isLocationActionVisible = false const isLocationActionVisible = false
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram()) const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
@@ -416,63 +407,22 @@ const applyDetailText = (lang: AudioLanguage, text?: string) => {
...detailTextByLanguage.value, ...detailTextByLanguage.value,
[lang]: normalized || fallbackTextForLanguage(lang) [lang]: normalized || fallbackTextForLanguage(lang)
} }
detailTextLoadedByLanguage.value = {
...detailTextLoadedByLanguage.value,
[lang]: true
}
} }
const buildTextSourceFromViewModel = (viewModel: ExplainDetailPageViewModel) => ({ const hydrateDetailTexts = (source: MuseumExhibit) => {
id: viewModel.id, const nextTexts: Record<AudioLanguage, string> = {
name: viewModel.title, 'zh-CN': fallbackTextForLanguage('zh-CN'),
hallId: viewModel.hallId, 'yue-HK': fallbackTextForLanguage('yue-HK'),
hallName: viewModel.hallName, 'en-US': fallbackTextForLanguage('en-US')
floorId: viewModel.floorId,
floorLabel: viewModel.floorLabel,
image: viewModel.coverImages[0],
description: viewModel.summary,
guideText: viewModel.summary,
audioLanguage: viewModel.audio.language,
audioHasText: viewModel.audio.hasText,
playTargetType: viewModel.audio.playTargetType,
playTargetId: viewModel.audio.playTargetId
})
const loadFullDetailTextForLanguage = async (
request: NonNullable<typeof detailEntryRequest.value>,
lang: AudioLanguage,
preferredViewModel?: ExplainDetailPageViewModel
) => {
detailTextLoadingByLanguage.value = {
...detailTextLoadingByLanguage.value,
[lang]: true
} }
try { ;(['zh-CN', 'yue-HK', 'en-US'] as AudioLanguage[]).forEach((lang) => {
const viewModel = preferredViewModel || toExplainDetailPageViewModel(await explainUseCase.enterExplainDetail({ const variant = source.audioVariants?.[lang]
...request, const text = variant?.text || (source.audioLanguage === lang ? source.guideText : undefined)
lang nextTexts[lang] = text?.trim() || fallbackTextForLanguage(lang)
})) })
if (viewModel.audio.hasText) { detailTextByLanguage.value = nextTexts
const selection = await explainUseCase.loadExplainDetailText(buildTextSourceFromViewModel(viewModel))
if (selection.available) {
const nextViewModel = toExplainDetailPageViewModel(selection.exhibit)
applyDetailText(lang, nextViewModel.body || nextViewModel.summary)
return
}
}
applyDetailText(lang, viewModel.body || viewModel.summary)
} catch (error) {
console.warn('讲解正文加载失败:', lang, error)
applyDetailText(lang)
} finally {
detailTextLoadingByLanguage.value = {
...detailTextLoadingByLanguage.value,
[lang]: false
}
}
} }
const loadExplainDetail = async ( const loadExplainDetail = async (
@@ -485,6 +435,8 @@ const loadExplainDetail = async (
lang lang
}) })
detailSource.value = exhibitData
hydrateDetailTexts(exhibitData)
exhibit.value = toExplainDetailPageViewModel(exhibitData) exhibit.value = toExplainDetailPageViewModel(exhibitData)
const resolvedLanguage = resolveSupportedDetailLanguage(lang) const resolvedLanguage = resolveSupportedDetailLanguage(lang)
selectedAudioLanguage.value = resolvedLanguage selectedAudioLanguage.value = resolvedLanguage
@@ -494,12 +446,13 @@ const loadExplainDetail = async (
} }
if (resolvedLanguage !== lang) { if (resolvedLanguage !== lang) {
const localizedExhibit = explainUseCase.selectExplainDetailLanguage(exhibitData, resolvedLanguage)
detailSource.value = localizedExhibit
exhibit.value = toExplainDetailPageViewModel(localizedExhibit)
selectedAudioLanguage.value = resolvedLanguage
replaceDetailRouteLanguage(resolvedLanguage) replaceDetailRouteLanguage(resolvedLanguage)
await loadExplainDetail(request, resolvedLanguage)
return
} }
void loadFullDetailTextForLanguage(request, resolvedLanguage, exhibit.value)
detailState.value = 'ready' detailState.value = 'ready'
} }
@@ -618,9 +571,7 @@ const splitDetailTextIntoParagraphs = (text: string): string[] => {
const currentDetailText = computed(() => detailTextFor(selectedAudioLanguage.value)) const currentDetailText = computed(() => detailTextFor(selectedAudioLanguage.value))
const currentDetailParagraphs = computed(() => splitDetailTextIntoParagraphs(currentDetailText.value)) const currentDetailParagraphs = computed(() => splitDetailTextIntoParagraphs(currentDetailText.value))
const currentDetailTextLoading = computed(() => ( const currentDetailTextLoading = computed(() => languageSwitchLoading.value)
languageSwitchLoading.value || detailTextLoadingByLanguage.value[selectedAudioLanguage.value]
))
const detailLoadingMessage = computed(() => ( const detailLoadingMessage = computed(() => (
languageSwitchLoading.value languageSwitchLoading.value
? `正在切换到${audioLanguageLabel(selectedAudioLanguage.value)}` ? `正在切换到${audioLanguageLabel(selectedAudioLanguage.value)}`
@@ -648,62 +599,40 @@ const handleLanguageChange = async (lang: AudioLanguage, options: { syncAudio?:
|| (supportedDetailLanguages.value.length > 0 && !supportedDetailLanguages.value.includes(lang)) || (supportedDetailLanguages.value.length > 0 && !supportedDetailLanguages.value.includes(lang))
) return ) return
const source = detailSource.value
if (!source) return
const shouldSyncAudio = options.syncAudio !== false && isCurrentDetailAudioTarget.value const shouldSyncAudio = options.syncAudio !== false && isCurrentDetailAudioTarget.value
const request = detailEntryRequest.value const localizedExhibit = explainUseCase.selectExplainDetailLanguage(source, lang)
const requestSequence = ++languageSwitchSequence const localizedViewModel = toExplainDetailPageViewModel(localizedExhibit)
selectedAudioLanguage.value = lang selectedAudioLanguage.value = lang
detailSource.value = localizedExhibit
exhibit.value = localizedViewModel
applyDetailText(lang, localizedExhibit.audioText || localizedExhibit.guideText || localizedViewModel.body)
replaceDetailRouteLanguage(lang) replaceDetailRouteLanguage(lang)
if (localizedExhibit.hallId) {
resolvedHallId.value = localizedExhibit.hallId
}
if (!shouldSyncAudio || globalAudioPlayer.currentSource.value?.lang === lang) return
languageSwitchLoading.value = true languageSwitchLoading.value = true
const audioSwitch = shouldSyncAudio && globalAudioPlayer.currentSource.value?.lang !== lang
? globalAudioPlayer.switchLanguage(lang)
: Promise.resolve(true)
try { try {
if (!request) { const nextAudio = localizedViewModel.audio.url
applyDetailText(lang) ? {
await audioSwitch id: `stop-${localizedViewModel.audio.playTargetType || 'ITEM'}-${localizedViewModel.audio.playTargetId || localizedViewModel.id}-${lang}`,
return name: localizedViewModel.title,
} audioUrl: localizedViewModel.audio.url,
image: heroImage.value,
const exhibitData = await explainUseCase.enterExplainDetail({ duration: localizedViewModel.audio.duration,
...request,
lang
})
if (requestSequence !== languageSwitchSequence) return
const nextViewModel = toExplainDetailPageViewModel(exhibitData)
exhibit.value = nextViewModel
if (exhibitData.hallId) {
resolvedHallId.value = exhibitData.hallId
}
void loadFullDetailTextForLanguage(request, lang, nextViewModel)
await audioSwitch
} catch (error) {
if (requestSequence !== languageSwitchSequence) return
console.warn('讲解语言切换失败:', lang, error)
exhibit.value = {
...exhibit.value,
audio: {
...exhibit.value.audio,
status: 'unavailable',
url: undefined,
duration: undefined,
language: lang, language: lang,
unavailableReason: '讲解服务暂不可用,请稍后重试' supportedLanguages: supportedDetailLanguages.value
} }
} : null
applyDetailText(lang) await globalAudioPlayer.switchLanguage(lang, nextAudio)
uni.showToast({
title: '讲解语言切换失败,请稍后重试',
icon: 'none'
})
} finally { } finally {
if (requestSequence === languageSwitchSequence) { languageSwitchLoading.value = false
languageSwitchLoading.value = false
}
} }
} }

View File

@@ -72,6 +72,7 @@ const reasonMessageMap: Record<string, string> = {
NO_GUIDE_STOP: '该展品暂未配置语音讲解', NO_GUIDE_STOP: '该展品暂未配置语音讲解',
NO_GUIDE_CONTENT: '该目标暂无讲解内容', NO_GUIDE_CONTENT: '该目标暂无讲解内容',
NO_TEXT: '当前语言暂无讲解词', NO_TEXT: '当前语言暂无讲解词',
LANGUAGE_DISABLED: '该语言暂未开放',
UNSUPPORTED_LANGUAGE: '不支持该语言', UNSUPPORTED_LANGUAGE: '不支持该语言',
UNSUPPORTED_TARGET_TYPE: '该目标类型暂不支持播放', UNSUPPORTED_TARGET_TYPE: '该目标类型暂不支持播放',
TARGET_NOT_FOUND: '该讲解音频暂不可用,当前提供图文讲解', TARGET_NOT_FOUND: '该讲解音频暂不可用,当前提供图文讲解',

View File

@@ -183,21 +183,29 @@ export class ExplainUseCase {
} }
} }
const playable = (variant.playable ?? variant.available) === true && Boolean(variant.audioUrl)
const textAvailable = variant.textAvailable ?? (variant.hasText === true || Boolean(variant.text))
const guideText = variant.text
|| (language === 'yue-HK' ? mandarinText : undefined)
|| `${this.languageLabel(language)}讲解词暂未配置。`
return { return {
...exhibit, ...exhibit,
guideTitle: variant.title || exhibit.guideTitle, guideTitle: variant.title || exhibit.guideTitle,
guideText: language === 'yue-HK' guideText,
? mandarinText || '当前讲解词暂未配置。' audioUrl: playable ? variant.audioUrl : undefined,
: variant.text || `${this.languageLabel(language)}讲解词暂未配置。`,
audioUrl: variant.audioUrl,
audioDuration: variant.audioDuration, audioDuration: variant.audioDuration,
audioLanguage: language, audioLanguage: language,
audioHasText: language === 'yue-HK' ? Boolean(mandarinText) : variant.hasText === true, audioHasText: textAvailable,
audioAvailable: variant.available === true && Boolean(variant.audioUrl), audioText: variant.text || undefined,
audioStatus: variant.available && variant.audioUrl ? 'READY' : 'MISSING', audioTextLength: variant.textLength,
audioUnavailableReason: variant.available && variant.audioUrl audioTextHash: variant.textHash,
audioNarrationTier: variant.narrationTier,
audioAvailable: playable,
audioStatus: variant.audioStatus || (playable ? 'READY' : 'MISSING'),
audioUnavailableReason: playable
? undefined ? undefined
: `当前讲解暂无${this.languageLabel(language)}音频`, : audioReasonToText(variant.reason || 'NO_PUBLISHED_AUDIO'),
supportedLanguages supportedLanguages
} }
} }
@@ -213,7 +221,27 @@ export class ExplainUseCase {
: undefined : undefined
const description = stopInfo.description || fallback?.description || '该讲解暂无简介。' const description = stopInfo.description || fallback?.description || '该讲解暂无简介。'
const audioTarget = this.resolveStopInfoAudioTarget(stopInfo) const audioTarget = this.resolveStopInfoAudioTarget(stopInfo)
const audioAvailable = stopInfo.audioStatus === 'READY' const audioVariants = Object.fromEntries(Object.values(stopInfo.languageVariants).map((variant) => [variant.lang, {
title: stopInfo.title,
text: variant.text,
textAvailable: variant.textAvailable,
textLength: variant.textLength,
textHash: variant.textHash,
audioUrl: variant.playUrl,
audioDuration: variant.duration,
format: variant.format,
audioId: variant.audioId,
narrationTier: variant.narrationTier,
hasText: variant.hasText,
enabled: variant.enabled,
playable: variant.playable,
available: variant.playable,
audioStatus: variant.audioStatus,
fallback: variant.fallback,
reason: variant.reason
}]))
const currentVariant = audioVariants[stopInfo.lang]
const audioAvailable = currentVariant?.playable === true && Boolean(currentVariant.audioUrl)
return { return {
...(fallback || {}), ...(fallback || {}),
@@ -235,21 +263,22 @@ export class ExplainUseCase {
size: fallback?.size, size: fallback?.size,
tags: fallback?.tags, tags: fallback?.tags,
guideTitle: stopInfo.title || fallback?.guideTitle, guideTitle: stopInfo.title || fallback?.guideTitle,
guideText: stopInfo.description || fallback?.guideText || fallback?.description, guideText: currentVariant?.text || stopInfo.description || fallback?.guideText || fallback?.description,
audioUrl: undefined, audioUrl: audioAvailable ? currentVariant?.audioUrl : undefined,
audioDuration: undefined, audioDuration: currentVariant?.audioDuration,
audioLanguage: stopInfo.lang, audioLanguage: stopInfo.lang,
audioHasText: stopInfo.hasText, audioHasText: currentVariant?.textAvailable || stopInfo.hasText,
audioText: undefined, audioText: currentVariant?.text,
audioTextLength: undefined, audioTextLength: currentVariant?.textLength,
audioTextHash: undefined, audioTextHash: currentVariant?.textHash,
audioNarrationTier: undefined, audioNarrationTier: currentVariant?.narrationTier,
audioUnavailableReason: audioAvailable audioUnavailableReason: audioAvailable
? undefined ? undefined
: audioReasonToText(stopInfo.reason || (stopInfo.audioStatus === 'MISSING' ? 'NO_PUBLISHED_AUDIO' : undefined)), : audioReasonToText(currentVariant?.reason || stopInfo.reason || (stopInfo.audioStatus === 'MISSING' ? 'NO_PUBLISHED_AUDIO' : undefined)),
audioAvailable, audioAvailable,
audioStatus: stopInfo.audioStatus, audioStatus: currentVariant?.audioStatus || stopInfo.audioStatus,
supportedLanguages: stopInfo.supportedLanguages, supportedLanguages: stopInfo.supportedLanguages,
audioVariants,
imageStatus: stopInfo.imageStatus, imageStatus: stopInfo.imageStatus,
imageSource: stopInfo.imageSource, imageSource: stopInfo.imageSource,
galleryUrls: stopInfo.imageStatus === 'READY' ? stopInfo.galleryUrls : [], galleryUrls: stopInfo.imageStatus === 'READY' ? stopInfo.galleryUrls : [],
@@ -434,6 +463,10 @@ export class ExplainUseCase {
throw stopInfoResult.error || new Error('讲解详情加载失败') throw stopInfoResult.error || new Error('讲解详情加载失败')
} }
selectExplainDetailLanguage(exhibit: MuseumExhibit, language: AudioLanguage) {
return this.applyLanguageVariant(exhibit, language)
}
async loadExplainDetailText(exhibit: MuseumExhibit): Promise<ExplainTextSelection> { async loadExplainDetailText(exhibit: MuseumExhibit): Promise<ExplainTextSelection> {
const targetType = exhibit.playTargetType || 'ITEM' const targetType = exhibit.playTargetType || 'ITEM'
const targetId = exhibit.playTargetId || exhibit.id const targetId = exhibit.playTargetId || exhibit.id

View File

@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
onHideHandler: null as null | (() => void), onHideHandler: null as null | (() => void),
onUnloadHandler: null as null | (() => void), onUnloadHandler: null as null | (() => void),
enterExplainDetail: vi.fn(), enterExplainDetail: vi.fn(),
selectExplainDetailLanguage: vi.fn(),
loadExplainDetailText: vi.fn(), loadExplainDetailText: vi.fn(),
selectAudioForExplainDetail: vi.fn(), selectAudioForExplainDetail: vi.fn(),
play: vi.fn(), play: vi.fn(),
@@ -43,6 +44,7 @@ vi.mock('@dcloudio/uni-app', () => ({
vi.mock('@/usecases/explainUseCase', () => ({ vi.mock('@/usecases/explainUseCase', () => ({
explainUseCase: { explainUseCase: {
enterExplainDetail: mocks.enterExplainDetail, enterExplainDetail: mocks.enterExplainDetail,
selectExplainDetailLanguage: mocks.selectExplainDetailLanguage,
loadExplainDetailText: mocks.loadExplainDetailText, loadExplainDetailText: mocks.loadExplainDetailText,
selectAudioForExplainDetail: mocks.selectAudioForExplainDetail selectAudioForExplainDetail: mocks.selectAudioForExplainDetail
} }
@@ -110,6 +112,10 @@ describe('讲解详情音频优先布局', () => {
audioState.currentTime.value = 0 audioState.currentTime.value = 0
audioState.duration.value = 0 audioState.duration.value = 0
mocks.enterExplainDetail.mockResolvedValue(exhibit) mocks.enterExplainDetail.mockResolvedValue(exhibit)
mocks.selectExplainDetailLanguage.mockImplementation((source, lang) => ({
...source,
audioLanguage: lang
}))
mocks.loadExplainDetailText.mockResolvedValue({ available: false }) mocks.loadExplainDetailText.mockResolvedValue({ available: false })
mocks.selectAudioForExplainDetail.mockResolvedValue({ mocks.selectAudioForExplainDetail.mockResolvedValue({
playable: true, playable: true,
@@ -191,7 +197,7 @@ describe('讲解详情音频优先布局', () => {
expect(wrapper.find('.detail-language-bar').exists()).toBe(false) expect(wrapper.find('.detail-language-bar').exists()).toBe(false)
}) })
it('深链语言不受支持时回退至中文并规范化地址', async () => { it('深链语言不受支持时本地回退至中文并规范化地址', async () => {
mocks.enterExplainDetail.mockImplementation(async (request: { lang: string }) => ({ mocks.enterExplainDetail.mockImplementation(async (request: { lang: string }) => ({
...exhibit, ...exhibit,
audioLanguage: request.lang, audioLanguage: request.lang,
@@ -205,8 +211,9 @@ describe('讲解详情音频优先布局', () => {
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' }) await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
await flushPromises() await flushPromises()
expect(mocks.enterExplainDetail).toHaveBeenNthCalledWith(1, expect.objectContaining({ lang: 'en-US' })) expect(mocks.enterExplainDetail).toHaveBeenCalledTimes(1)
expect(mocks.enterExplainDetail).toHaveBeenNthCalledWith(2, expect.objectContaining({ lang: 'zh-CN' })) expect(mocks.enterExplainDetail).toHaveBeenCalledWith(expect.objectContaining({ lang: 'en-US' }))
expect(mocks.selectExplainDetailLanguage).toHaveBeenCalledWith(expect.any(Object), 'zh-CN')
expect(wrapper.get('.language-option').classes()).toContain('active') expect(wrapper.get('.language-option').classes()).toContain('active')
expect(wrapper.get('.language-option-text').text()).toBe('中文') expect(wrapper.get('.language-option-text').text()).toBe('中文')
expect(replaceState).toHaveBeenCalledWith(null, '', expect.stringContaining('lang=zh-CN')) expect(replaceState).toHaveBeenCalledWith(null, '', expect.stringContaining('lang=zh-CN'))
@@ -244,6 +251,34 @@ describe('讲解详情音频优先布局', () => {
expect(wrapper.get('.detail-audio-play').attributes('disabled')).toBeDefined() expect(wrapper.get('.detail-audio-play').attributes('disabled')).toBeDefined()
}) })
it('切换语言时使用初次详情响应中的变体,不重复请求详情或正文', async () => {
const localizedExhibit = {
...exhibit,
guideText: '粤语通道使用普通话文案。',
audioUrl: '/assets/astrolabe-yue.mp3',
audioDuration: 83,
audioLanguage: 'yue-HK',
audioStatus: 'READY',
audioHasText: true,
supportedLanguages: ['zh-CN', 'yue-HK', 'en-US']
}
mocks.selectExplainDetailLanguage.mockReturnValue(localizedExhibit)
const wrapper = mount(ExhibitDetail, {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
await flushPromises()
await wrapper.findAll('.language-option').find((node) => node.text() === '粤语')?.trigger('tap')
await flushPromises()
expect(mocks.enterExplainDetail).toHaveBeenCalledTimes(1)
expect(mocks.loadExplainDetailText).not.toHaveBeenCalled()
expect(mocks.selectExplainDetailLanguage).toHaveBeenCalledWith(expect.any(Object), 'yue-HK')
expect(wrapper.get('.section-text').text()).toContain('粤语通道使用普通话文案')
expect(wrapper.get('.detail-audio-subtitle').text()).toContain('粤语')
})
it('无音频时禁用播放按钮并显示原因', async () => { it('无音频时禁用播放按钮并显示原因', async () => {
mocks.enterExplainDetail.mockResolvedValue({ mocks.enterExplainDetail.mockResolvedValue({
...exhibit, ...exhibit,

View File

@@ -25,6 +25,7 @@ const createStopInfo = (overrides: Partial<GuideStopInfo> = {}): GuideStopInfo =
hasAudio: false, hasAudio: false,
hasText: false, hasText: false,
supportedLanguages: [], supportedLanguages: [],
languageVariants: {},
audioStatus: 'MISSING', audioStatus: 'MISSING',
...overrides ...overrides
}) })
@@ -99,4 +100,60 @@ describe('ExplainUseCase stop image policy', () => {
expect(detail.image).toBe('/guide-stop.jpg') expect(detail.image).toBe('/guide-stop.jpg')
expect(detail.galleryUrls).toEqual(['/guide-stop-gallery.jpg']) expect(detail.galleryUrls).toEqual(['/guide-stop-gallery.jpg'])
}) })
it('uses stop-info language variants locally without requesting play-info or text-info', async () => {
const audio = {
getStopInfo: vi.fn().mockResolvedValue(createStopInfo({
hasAudio: true,
hasText: true,
supportedLanguages: ['zh-CN', 'yue-HK'],
languageVariants: {
'zh-CN': {
lang: 'zh-CN',
enabled: true,
playable: true,
playUrl: '/audio/zh.mp3',
duration: 50,
hasText: true,
textAvailable: true,
text: '普通话讲解词',
fallback: false,
audioStatus: 'READY'
},
'yue-HK': {
lang: 'yue-HK',
enabled: true,
playable: true,
playUrl: '/audio/yue.mp3',
duration: 52,
hasText: true,
textAvailable: true,
text: '粤语通道使用普通话文案',
fallback: false,
audioStatus: 'READY'
}
} as GuideStopInfo['languageVariants']
})),
getPlayInfo: vi.fn(),
getTextInfo: vi.fn()
} as unknown as AudioPlayInfoRepository
const explain = {
getExhibitById: vi.fn(),
listExplainExhibits: vi.fn()
} as unknown as ExplainRepository
const useCase = new ExplainUseCase(explain, audio)
const detail = await useCase.enterExplainDetail({
exhibitId: 'stop-1', targetType: 'STOP', targetId: 'stop-1', lang: 'zh-CN'
})
const cantonese = useCase.selectExplainDetailLanguage(detail, 'yue-HK')
expect(detail.guideText).toBe('普通话讲解词')
expect(detail.audioUrl).toBe('/audio/zh.mp3')
expect(cantonese.guideText).toBe('粤语通道使用普通话文案')
expect(cantonese.audioUrl).toBe('/audio/yue.mp3')
expect(cantonese.audioDuration).toBe(52)
expect((audio as any).getPlayInfo).not.toHaveBeenCalled()
expect((audio as any).getTextInfo).not.toHaveBeenCalled()
})
}) })

View File

@@ -35,7 +35,36 @@ describe('全局讲解播放器语言切换', () => {
vi.unstubAllGlobals() vi.unstubAllGlobals()
}) })
it('切换到粤语时立即中断旧音频,并使用 yue-HK 播放地址', async () => { it('详情页提供语言变体播放地址时直接切换,不请求 play-info', async () => {
await player.play({
id: 'mandarin-audio',
name: '测试讲解',
audioUrl: '/museum-assets/audio/mandarin.mp3',
language: 'zh-CN'
}, {
source: {
targetType: 'STOP',
targetId: '1823450596800289',
lang: 'zh-CN'
}
})
await expect(player.switchLanguage('yue-HK', {
id: 'cantonese-audio',
name: '测试讲解',
audioUrl: '/museum-assets/audio/cantonese.mp3',
language: 'yue-HK'
})).resolves.toBe(true)
expect(repositoryMocks.getPlayInfo).not.toHaveBeenCalled()
expect(player.currentSource.value?.lang).toBe('yue-HK')
expect(player.currentAudio.value).toEqual(expect.objectContaining({
language: 'yue-HK',
audioUrl: '/museum-assets/audio/cantonese.mp3'
}))
})
it('未提供详情语言变体时保留 play-info 刷新回退', async () => {
repositoryMocks.getPlayInfo.mockResolvedValue({ repositoryMocks.getPlayInfo.mockResolvedValue({
playable: true, playable: true,
targetType: 'STOP', targetType: 'STOP',

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import { toGuideStopInfo } from '@/data/adapters/guideStopInfoAdapter'
describe('guide stop-info adapter', () => {
it('normalizes aggregated language variants and exposes only displayable languages', () => {
const result = toGuideStopInfo({
available: true,
targetType: 'STOP',
targetId: '9001',
lang: 'zh-CN',
imageStatus: 'READY',
coverImageUrl: '/museum-assets/cover.webp',
languageVariants: [
{
lang: 'zh',
enabled: true,
playable: true,
audioStatus: 'READY',
playUrl: '/museum-assets/audio/zh.mp3',
duration: '50',
audioId: 12,
hasText: true,
textAvailable: true,
text: '普通话讲解词',
textLength: '6',
textHash: 'zh-hash'
},
{
lang: 'yue-HK',
enabled: true,
playable: false,
audioStatus: 'MISSING',
textAvailable: true,
text: '粤语使用普通话文案',
reason: 'NO_PUBLISHED_AUDIO'
},
{
lang: 'en-US',
enabled: false,
playable: true,
playUrl: '/museum-assets/audio/en.mp3'
}
]
}, {
targetType: 'STOP',
targetId: '9001',
lang: 'zh-CN'
})
expect(result.supportedLanguages).toEqual(['zh-CN', 'yue-HK'])
expect(result.languageVariants['zh-CN']).toMatchObject({
playable: true,
playUrl: '/museum-assets/audio/zh.mp3',
duration: 50,
audioId: '12',
text: '普通话讲解词',
textLength: 6
})
expect(result.languageVariants['yue-HK']).toMatchObject({
playable: false,
textAvailable: true,
reason: 'NO_PUBLISHED_AUDIO'
})
})
it('keeps supportedLanguages as the compatibility fallback when variants are absent', () => {
const result = toGuideStopInfo({
supportedLanguages: ['zh-CN', 'en-US']
}, {
targetType: 'STOP',
targetId: '9001',
lang: 'zh-CN'
})
expect(result.supportedLanguages).toEqual(['zh-CN', 'en-US'])
expect(result.languageVariants).toEqual({})
})
})