适配讲解详情聚合接口
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: '该讲解音频暂不可用,当前提供图文讲解',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
78
tests/unit/GuideStopInfoAdapter.spec.ts
Normal file
78
tests/unit/GuideStopInfoAdapter.spec.ts
Normal 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({})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user