diff --git a/src/composables/useGlobalAudioPlayer.ts b/src/composables/useGlobalAudioPlayer.ts index 7dbc147..7f04225 100644 --- a/src/composables/useGlobalAudioPlayer.ts +++ b/src/composables/useGlobalAudioPlayer.ts @@ -259,7 +259,7 @@ const resume = () => { }) } -const switchLanguage = async (lang: AudioLanguage) => { +const switchLanguage = async (lang: AudioLanguage, localAudio?: AudioItem | null) => { if (pendingLanguage.value) { return false } @@ -291,6 +291,35 @@ const switchLanguage = async (lang: AudioLanguage) => { } 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({ targetType: source.targetType, targetId: source.targetId, diff --git a/src/data/adapters/guideStopInfoAdapter.ts b/src/data/adapters/guideStopInfoAdapter.ts index 8ca6621..1e3f3ba 100644 --- a/src/data/adapters/guideStopInfoAdapter.ts +++ b/src/data/adapters/guideStopInfoAdapter.ts @@ -42,11 +42,31 @@ export interface BackendGuideStopInfo { hasText?: boolean supportedLanguages?: string[] | null audioStatus?: string | null + languageVariants?: BackendGuideStopLanguageVariant[] | null reason?: string | null linkedExhibitCount?: number | string | 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 { playable?: boolean targetType?: string | null @@ -90,6 +110,25 @@ export interface GuideStopLinkedExhibit { 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 { available: boolean targetType: AudioPlayTargetType @@ -112,6 +151,7 @@ export interface GuideStopInfo { hasAudio: boolean hasText: boolean supportedLanguages: GuideAudioLanguage[] + languageVariants: Record audioStatus: 'READY' | 'MISSING' | string reason?: string linkedExhibitCount?: number @@ -177,16 +217,57 @@ export const normalizeGuideAudioLanguage = ( 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[] => ( Array.from(new Set((languages || []) - .map((language) => { - const normalized = language?.trim().toLowerCase() - if (!['zh', 'zh-cn', 'en', 'en-us', 'yue', 'yue-cn', 'yue-hk'].includes(normalized)) { - return null - } - return normalizeGuideAudioLanguage(language) - }) - .filter(Boolean))) as GuideAudioLanguage[] + .filter(isSupportedGuideAudioLanguage) + .map(normalizeGuideAudioLanguage))) as GuideAudioLanguage[] +) + +const normalizeLanguageVariants = ( + variants: BackendGuideStopLanguageVariant[] | null | undefined +): Record => { + const normalizedVariants = {} as Record + + ;(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) => ( + (Object.values(variants) as GuideStopLanguageVariant[]) + .filter((variant) => variant.enabled && (variant.playable || variant.textAvailable)) + .map((variant) => variant.lang) ) const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls'] | BackendGuideStopLinkedExhibit['galleryUrls']) => { @@ -255,6 +336,8 @@ export const toGuideStopInfo = ( const coverImageUrl = canUseStopImages ? normalizeSameOriginPublicUrl(source.coverImageUrl) || undefined : undefined + const languageVariants = normalizeLanguageVariants(source.languageVariants) + const supportedLanguages = supportedLanguagesFromVariants(languageVariants) return { available: source.available === true, @@ -277,7 +360,10 @@ export const toGuideStopInfo = ( playTargetId, hasAudio: source.hasAudio === true, hasText: source.hasText === true, - supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages), + supportedLanguages: supportedLanguages.length + ? supportedLanguages + : normalizeSupportedLanguages(source.supportedLanguages), + languageVariants, audioStatus: source.audioStatus || 'MISSING', reason: source.reason || undefined, linkedExhibitCount: normalizeNumber(source.linkedExhibitCount), diff --git a/src/domain/museum.ts b/src/domain/museum.ts index 827452b..fe021f7 100644 --- a/src/domain/museum.ts +++ b/src/domain/museum.ts @@ -182,10 +182,21 @@ export interface MuseumExhibit { audioVariants?: Record imageStatus?: string imageSource?: string diff --git a/src/pages/exhibit/detail.vue b/src/pages/exhibit/detail.vue index 87214c3..3443440 100644 --- a/src/pages/exhibit/detail.vue +++ b/src/pages/exhibit/detail.vue @@ -160,7 +160,8 @@ import { type ExplainDetailPageViewModel } from '@/view-models/explainViewModels' import type { - AudioPlayTargetType + AudioPlayTargetType, + MuseumExhibit } from '@/domain/museum' import type { AudioLanguage @@ -193,6 +194,7 @@ const defaultDetail: ExplainDetailPageViewModel = { } const exhibit = ref(defaultDetail) +const detailSource = ref(null) const detailState = ref<'idle' | 'loading' | 'ready' | 'missing' | 'error'>('idle') const detailStateMessage = ref('请检查链接后重试。') const globalAudioPlayer = useGlobalAudioPlayer() @@ -201,7 +203,6 @@ const resolvedHallId = ref('') const retryingAudio = ref(false) const languageSwitchLoading = ref(false) const selectedAudioLanguage = ref('zh-CN') -let languageSwitchSequence = 0 const detailEntryRequest = ref<{ exhibitId: string targetType?: AudioPlayTargetType @@ -217,16 +218,6 @@ const detailTextByLanguage = ref>({ 'yue-HK': '当前语言暂无讲解词。', 'en-US': 'English narration text is not available yet.' }) -const detailTextLoadingByLanguage = ref>({ - 'zh-CN': false, - 'yue-HK': false, - 'en-US': false -}) -const detailTextLoadedByLanguage = ref>({ - 'zh-CN': false, - 'yue-HK': false, - 'en-US': false -}) // 讲解详情页位置入口暂未开放,避免误导用户进入位置预览流程。 const isLocationActionVisible = false const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram()) @@ -416,63 +407,22 @@ const applyDetailText = (lang: AudioLanguage, text?: string) => { ...detailTextByLanguage.value, [lang]: normalized || fallbackTextForLanguage(lang) } - detailTextLoadedByLanguage.value = { - ...detailTextLoadedByLanguage.value, - [lang]: true - } } -const buildTextSourceFromViewModel = (viewModel: ExplainDetailPageViewModel) => ({ - id: viewModel.id, - name: viewModel.title, - hallId: viewModel.hallId, - hallName: viewModel.hallName, - 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, - lang: AudioLanguage, - preferredViewModel?: ExplainDetailPageViewModel -) => { - detailTextLoadingByLanguage.value = { - ...detailTextLoadingByLanguage.value, - [lang]: true +const hydrateDetailTexts = (source: MuseumExhibit) => { + const nextTexts: Record = { + 'zh-CN': fallbackTextForLanguage('zh-CN'), + 'yue-HK': fallbackTextForLanguage('yue-HK'), + 'en-US': fallbackTextForLanguage('en-US') } - try { - const viewModel = preferredViewModel || toExplainDetailPageViewModel(await explainUseCase.enterExplainDetail({ - ...request, - lang - })) + ;(['zh-CN', 'yue-HK', 'en-US'] as AudioLanguage[]).forEach((lang) => { + const variant = source.audioVariants?.[lang] + const text = variant?.text || (source.audioLanguage === lang ? source.guideText : undefined) + nextTexts[lang] = text?.trim() || fallbackTextForLanguage(lang) + }) - if (viewModel.audio.hasText) { - 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 - } - } + detailTextByLanguage.value = nextTexts } const loadExplainDetail = async ( @@ -485,6 +435,8 @@ const loadExplainDetail = async ( lang }) + detailSource.value = exhibitData + hydrateDetailTexts(exhibitData) exhibit.value = toExplainDetailPageViewModel(exhibitData) const resolvedLanguage = resolveSupportedDetailLanguage(lang) selectedAudioLanguage.value = resolvedLanguage @@ -494,12 +446,13 @@ const loadExplainDetail = async ( } if (resolvedLanguage !== lang) { + const localizedExhibit = explainUseCase.selectExplainDetailLanguage(exhibitData, resolvedLanguage) + detailSource.value = localizedExhibit + exhibit.value = toExplainDetailPageViewModel(localizedExhibit) + selectedAudioLanguage.value = resolvedLanguage replaceDetailRouteLanguage(resolvedLanguage) - await loadExplainDetail(request, resolvedLanguage) - return } - void loadFullDetailTextForLanguage(request, resolvedLanguage, exhibit.value) detailState.value = 'ready' } @@ -618,9 +571,7 @@ const splitDetailTextIntoParagraphs = (text: string): string[] => { const currentDetailText = computed(() => detailTextFor(selectedAudioLanguage.value)) const currentDetailParagraphs = computed(() => splitDetailTextIntoParagraphs(currentDetailText.value)) -const currentDetailTextLoading = computed(() => ( - languageSwitchLoading.value || detailTextLoadingByLanguage.value[selectedAudioLanguage.value] -)) +const currentDetailTextLoading = computed(() => languageSwitchLoading.value) const detailLoadingMessage = computed(() => ( languageSwitchLoading.value ? `正在切换到${audioLanguageLabel(selectedAudioLanguage.value)}` @@ -648,62 +599,40 @@ const handleLanguageChange = async (lang: AudioLanguage, options: { syncAudio?: || (supportedDetailLanguages.value.length > 0 && !supportedDetailLanguages.value.includes(lang)) ) return + const source = detailSource.value + if (!source) return + const shouldSyncAudio = options.syncAudio !== false && isCurrentDetailAudioTarget.value - const request = detailEntryRequest.value - const requestSequence = ++languageSwitchSequence + const localizedExhibit = explainUseCase.selectExplainDetailLanguage(source, lang) + const localizedViewModel = toExplainDetailPageViewModel(localizedExhibit) selectedAudioLanguage.value = lang + detailSource.value = localizedExhibit + exhibit.value = localizedViewModel + applyDetailText(lang, localizedExhibit.audioText || localizedExhibit.guideText || localizedViewModel.body) replaceDetailRouteLanguage(lang) + + if (localizedExhibit.hallId) { + resolvedHallId.value = localizedExhibit.hallId + } + + if (!shouldSyncAudio || globalAudioPlayer.currentSource.value?.lang === lang) return + languageSwitchLoading.value = true - - const audioSwitch = shouldSyncAudio && globalAudioPlayer.currentSource.value?.lang !== lang - ? globalAudioPlayer.switchLanguage(lang) - : Promise.resolve(true) - try { - if (!request) { - applyDetailText(lang) - await audioSwitch - return - } - - const exhibitData = await explainUseCase.enterExplainDetail({ - ...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, + const nextAudio = localizedViewModel.audio.url + ? { + id: `stop-${localizedViewModel.audio.playTargetType || 'ITEM'}-${localizedViewModel.audio.playTargetId || localizedViewModel.id}-${lang}`, + name: localizedViewModel.title, + audioUrl: localizedViewModel.audio.url, + image: heroImage.value, + duration: localizedViewModel.audio.duration, language: lang, - unavailableReason: '讲解服务暂不可用,请稍后重试' + supportedLanguages: supportedDetailLanguages.value } - } - applyDetailText(lang) - uni.showToast({ - title: '讲解语言切换失败,请稍后重试', - icon: 'none' - }) + : null + await globalAudioPlayer.switchLanguage(lang, nextAudio) } finally { - if (requestSequence === languageSwitchSequence) { - languageSwitchLoading.value = false - } + languageSwitchLoading.value = false } } diff --git a/src/repositories/AudioPlayInfoRepository.ts b/src/repositories/AudioPlayInfoRepository.ts index 79ce9b1..bcca911 100644 --- a/src/repositories/AudioPlayInfoRepository.ts +++ b/src/repositories/AudioPlayInfoRepository.ts @@ -72,6 +72,7 @@ const reasonMessageMap: Record = { NO_GUIDE_STOP: '该展品暂未配置语音讲解', NO_GUIDE_CONTENT: '该目标暂无讲解内容', NO_TEXT: '当前语言暂无讲解词', + LANGUAGE_DISABLED: '该语言暂未开放', UNSUPPORTED_LANGUAGE: '不支持该语言', UNSUPPORTED_TARGET_TYPE: '该目标类型暂不支持播放', TARGET_NOT_FOUND: '该讲解音频暂不可用,当前提供图文讲解', diff --git a/src/usecases/explainUseCase.ts b/src/usecases/explainUseCase.ts index 4babe86..7655e90 100644 --- a/src/usecases/explainUseCase.ts +++ b/src/usecases/explainUseCase.ts @@ -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 { ...exhibit, guideTitle: variant.title || exhibit.guideTitle, - guideText: language === 'yue-HK' - ? mandarinText || '当前讲解词暂未配置。' - : variant.text || `${this.languageLabel(language)}讲解词暂未配置。`, - audioUrl: variant.audioUrl, + guideText, + audioUrl: playable ? variant.audioUrl : undefined, audioDuration: variant.audioDuration, audioLanguage: language, - audioHasText: language === 'yue-HK' ? Boolean(mandarinText) : variant.hasText === true, - audioAvailable: variant.available === true && Boolean(variant.audioUrl), - audioStatus: variant.available && variant.audioUrl ? 'READY' : 'MISSING', - audioUnavailableReason: variant.available && variant.audioUrl + audioHasText: textAvailable, + audioText: variant.text || undefined, + audioTextLength: variant.textLength, + audioTextHash: variant.textHash, + audioNarrationTier: variant.narrationTier, + audioAvailable: playable, + audioStatus: variant.audioStatus || (playable ? 'READY' : 'MISSING'), + audioUnavailableReason: playable ? undefined - : `当前讲解暂无${this.languageLabel(language)}音频`, + : audioReasonToText(variant.reason || 'NO_PUBLISHED_AUDIO'), supportedLanguages } } @@ -213,7 +221,27 @@ export class ExplainUseCase { : undefined const description = stopInfo.description || fallback?.description || '该讲解暂无简介。' 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 { ...(fallback || {}), @@ -235,21 +263,22 @@ export class ExplainUseCase { size: fallback?.size, tags: fallback?.tags, guideTitle: stopInfo.title || fallback?.guideTitle, - guideText: stopInfo.description || fallback?.guideText || fallback?.description, - audioUrl: undefined, - audioDuration: undefined, + guideText: currentVariant?.text || stopInfo.description || fallback?.guideText || fallback?.description, + audioUrl: audioAvailable ? currentVariant?.audioUrl : undefined, + audioDuration: currentVariant?.audioDuration, audioLanguage: stopInfo.lang, - audioHasText: stopInfo.hasText, - audioText: undefined, - audioTextLength: undefined, - audioTextHash: undefined, - audioNarrationTier: undefined, + audioHasText: currentVariant?.textAvailable || stopInfo.hasText, + audioText: currentVariant?.text, + audioTextLength: currentVariant?.textLength, + audioTextHash: currentVariant?.textHash, + audioNarrationTier: currentVariant?.narrationTier, audioUnavailableReason: audioAvailable ? undefined - : audioReasonToText(stopInfo.reason || (stopInfo.audioStatus === 'MISSING' ? 'NO_PUBLISHED_AUDIO' : undefined)), + : audioReasonToText(currentVariant?.reason || stopInfo.reason || (stopInfo.audioStatus === 'MISSING' ? 'NO_PUBLISHED_AUDIO' : undefined)), audioAvailable, - audioStatus: stopInfo.audioStatus, + audioStatus: currentVariant?.audioStatus || stopInfo.audioStatus, supportedLanguages: stopInfo.supportedLanguages, + audioVariants, imageStatus: stopInfo.imageStatus, imageSource: stopInfo.imageSource, galleryUrls: stopInfo.imageStatus === 'READY' ? stopInfo.galleryUrls : [], @@ -434,6 +463,10 @@ export class ExplainUseCase { throw stopInfoResult.error || new Error('讲解详情加载失败') } + selectExplainDetailLanguage(exhibit: MuseumExhibit, language: AudioLanguage) { + return this.applyLanguageVariant(exhibit, language) + } + async loadExplainDetailText(exhibit: MuseumExhibit): Promise { const targetType = exhibit.playTargetType || 'ITEM' const targetId = exhibit.playTargetId || exhibit.id diff --git a/tests/unit/ExhibitDetailAudioFirst.spec.ts b/tests/unit/ExhibitDetailAudioFirst.spec.ts index bf6d8e7..fec43f5 100644 --- a/tests/unit/ExhibitDetailAudioFirst.spec.ts +++ b/tests/unit/ExhibitDetailAudioFirst.spec.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ onHideHandler: null as null | (() => void), onUnloadHandler: null as null | (() => void), enterExplainDetail: vi.fn(), + selectExplainDetailLanguage: vi.fn(), loadExplainDetailText: vi.fn(), selectAudioForExplainDetail: vi.fn(), play: vi.fn(), @@ -43,6 +44,7 @@ vi.mock('@dcloudio/uni-app', () => ({ vi.mock('@/usecases/explainUseCase', () => ({ explainUseCase: { enterExplainDetail: mocks.enterExplainDetail, + selectExplainDetailLanguage: mocks.selectExplainDetailLanguage, loadExplainDetailText: mocks.loadExplainDetailText, selectAudioForExplainDetail: mocks.selectAudioForExplainDetail } @@ -110,6 +112,10 @@ describe('讲解详情音频优先布局', () => { audioState.currentTime.value = 0 audioState.duration.value = 0 mocks.enterExplainDetail.mockResolvedValue(exhibit) + mocks.selectExplainDetailLanguage.mockImplementation((source, lang) => ({ + ...source, + audioLanguage: lang + })) mocks.loadExplainDetailText.mockResolvedValue({ available: false }) mocks.selectAudioForExplainDetail.mockResolvedValue({ playable: true, @@ -191,7 +197,7 @@ describe('讲解详情音频优先布局', () => { expect(wrapper.find('.detail-language-bar').exists()).toBe(false) }) - it('深链语言不受支持时回退至中文并规范化地址', async () => { + it('深链语言不受支持时本地回退至中文并规范化地址', async () => { mocks.enterExplainDetail.mockImplementation(async (request: { lang: string }) => ({ ...exhibit, audioLanguage: request.lang, @@ -205,8 +211,9 @@ describe('讲解详情音频优先布局', () => { await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' }) await flushPromises() - expect(mocks.enterExplainDetail).toHaveBeenNthCalledWith(1, expect.objectContaining({ lang: 'en-US' })) - expect(mocks.enterExplainDetail).toHaveBeenNthCalledWith(2, expect.objectContaining({ lang: 'zh-CN' })) + expect(mocks.enterExplainDetail).toHaveBeenCalledTimes(1) + 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-text').text()).toBe('中文') expect(replaceState).toHaveBeenCalledWith(null, '', expect.stringContaining('lang=zh-CN')) @@ -244,6 +251,34 @@ describe('讲解详情音频优先布局', () => { 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 () => { mocks.enterExplainDetail.mockResolvedValue({ ...exhibit, diff --git a/tests/unit/ExplainUseCase.spec.ts b/tests/unit/ExplainUseCase.spec.ts index 3a07dd4..769e1a9 100644 --- a/tests/unit/ExplainUseCase.spec.ts +++ b/tests/unit/ExplainUseCase.spec.ts @@ -25,6 +25,7 @@ const createStopInfo = (overrides: Partial = {}): GuideStopInfo = hasAudio: false, hasText: false, supportedLanguages: [], + languageVariants: {}, audioStatus: 'MISSING', ...overrides }) @@ -99,4 +100,60 @@ describe('ExplainUseCase stop image policy', () => { expect(detail.image).toBe('/guide-stop.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() + }) }) diff --git a/tests/unit/GlobalAudioPlayerLanguage.spec.ts b/tests/unit/GlobalAudioPlayerLanguage.spec.ts index f63c804..9e9aaaa 100644 --- a/tests/unit/GlobalAudioPlayerLanguage.spec.ts +++ b/tests/unit/GlobalAudioPlayerLanguage.spec.ts @@ -35,7 +35,36 @@ describe('全局讲解播放器语言切换', () => { 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({ playable: true, targetType: 'STOP', diff --git a/tests/unit/GuideStopInfoAdapter.spec.ts b/tests/unit/GuideStopInfoAdapter.spec.ts new file mode 100644 index 0000000..fac13dd --- /dev/null +++ b/tests/unit/GuideStopInfoAdapter.spec.ts @@ -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({}) + }) +})