@@ -78,6 +78,7 @@ export interface BackendHall {
|
|||||||
stopCount?: number | null
|
stopCount?: number | null
|
||||||
linkedExhibitCount?: number | null
|
linkedExhibitCount?: number | null
|
||||||
audioReadyStopCount?: number | null
|
audioReadyStopCount?: number | null
|
||||||
|
audioOptionCount?: number | null
|
||||||
hasAudio?: boolean | null
|
hasAudio?: boolean | null
|
||||||
audioStatus?: string | null
|
audioStatus?: string | null
|
||||||
supportedLanguages?: string[] | null
|
supportedLanguages?: string[] | null
|
||||||
@@ -158,6 +159,7 @@ export interface BackendCatalogStopItem {
|
|||||||
hasAudio?: boolean | null
|
hasAudio?: boolean | null
|
||||||
audioStatus?: string | null
|
audioStatus?: string | null
|
||||||
supportedLanguages?: string[] | null
|
supportedLanguages?: string[] | null
|
||||||
|
audioOptionCount?: number | null
|
||||||
hasTextRecord?: boolean | null
|
hasTextRecord?: boolean | null
|
||||||
playTargetType?: string | null
|
playTargetType?: string | null
|
||||||
playTargetId?: string | number | null
|
playTargetId?: string | number | null
|
||||||
@@ -287,6 +289,7 @@ export const toCatalogHall = (
|
|||||||
stopCount: normalizeNumber(source.stopCount),
|
stopCount: normalizeNumber(source.stopCount),
|
||||||
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
|
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
|
||||||
audioReadyStopCount: normalizeNumber(source.audioReadyStopCount),
|
audioReadyStopCount: normalizeNumber(source.audioReadyStopCount),
|
||||||
|
audioOptionCount: normalizeNumber(source.audioOptionCount),
|
||||||
hasAudio: source.hasAudio === true,
|
hasAudio: source.hasAudio === true,
|
||||||
audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
|
audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
|
||||||
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
|
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
|
||||||
@@ -359,6 +362,7 @@ export const toCatalogGuideStop = (
|
|||||||
hasAudio: source.hasAudio === true,
|
hasAudio: source.hasAudio === true,
|
||||||
audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
|
audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
|
||||||
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
|
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
|
||||||
|
audioOptionCount: normalizeNumber(source.audioOptionCount),
|
||||||
hasTextRecord: source.hasTextRecord === true,
|
hasTextRecord: source.hasTextRecord === true,
|
||||||
poiId: stringifyId(source.poiId) || undefined,
|
poiId: stringifyId(source.poiId) || undefined,
|
||||||
mapX: normalizeNumber(source.mapX),
|
mapX: normalizeNumber(source.mapX),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '@/utils/publicUrl'
|
} from '@/utils/publicUrl'
|
||||||
|
|
||||||
export type GuideAudioLanguage = 'zh-CN' | 'yue-HK' | 'en-US'
|
export type GuideAudioLanguage = 'zh-CN' | 'yue-HK' | 'en-US'
|
||||||
|
export type GuideAudioVoiceGender = 'female' | 'male'
|
||||||
|
|
||||||
export interface BackendGuideStopLinkedExhibit {
|
export interface BackendGuideStopLinkedExhibit {
|
||||||
id?: string | number | null
|
id?: string | number | null
|
||||||
@@ -42,12 +43,26 @@ export interface BackendGuideStopInfo {
|
|||||||
hasText?: boolean
|
hasText?: boolean
|
||||||
supportedLanguages?: string[] | null
|
supportedLanguages?: string[] | null
|
||||||
audioStatus?: string | null
|
audioStatus?: string | null
|
||||||
|
audioOptions?: BackendGuideStopAudioOption[] | null
|
||||||
languageVariants?: BackendGuideStopLanguageVariant[] | 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 BackendGuideStopAudioOption {
|
||||||
|
channelCode?: string | null
|
||||||
|
displayName?: string | null
|
||||||
|
languageCode?: string | null
|
||||||
|
languageName?: string | null
|
||||||
|
gender?: string | null
|
||||||
|
playUrl?: string | null
|
||||||
|
duration?: number | string | null
|
||||||
|
format?: string | null
|
||||||
|
isDefault?: boolean | null
|
||||||
|
sortOrder?: number | string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackendGuideStopLanguageVariant {
|
export interface BackendGuideStopLanguageVariant {
|
||||||
lang?: string | null
|
lang?: string | null
|
||||||
enabled?: boolean | null
|
enabled?: boolean | null
|
||||||
@@ -129,6 +144,19 @@ export interface GuideStopLanguageVariant {
|
|||||||
reason?: string
|
reason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GuideStopAudioOption {
|
||||||
|
channelCode: string
|
||||||
|
displayName: string
|
||||||
|
languageCode: GuideAudioLanguage
|
||||||
|
languageName?: string
|
||||||
|
gender: GuideAudioVoiceGender
|
||||||
|
playUrl: string
|
||||||
|
duration?: number
|
||||||
|
format?: string
|
||||||
|
isDefault: boolean
|
||||||
|
sortOrder?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface GuideStopInfo {
|
export interface GuideStopInfo {
|
||||||
available: boolean
|
available: boolean
|
||||||
targetType: AudioPlayTargetType
|
targetType: AudioPlayTargetType
|
||||||
@@ -151,6 +179,7 @@ export interface GuideStopInfo {
|
|||||||
hasAudio: boolean
|
hasAudio: boolean
|
||||||
hasText: boolean
|
hasText: boolean
|
||||||
supportedLanguages: GuideAudioLanguage[]
|
supportedLanguages: GuideAudioLanguage[]
|
||||||
|
audioOptions: GuideStopAudioOption[]
|
||||||
languageVariants: Record<GuideAudioLanguage, GuideStopLanguageVariant>
|
languageVariants: Record<GuideAudioLanguage, GuideStopLanguageVariant>
|
||||||
audioStatus: 'READY' | 'MISSING' | string
|
audioStatus: 'READY' | 'MISSING' | string
|
||||||
reason?: string
|
reason?: string
|
||||||
@@ -264,12 +293,57 @@ const normalizeLanguageVariants = (
|
|||||||
return normalizedVariants
|
return normalizedVariants
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeVoiceGender = (value: string | null | undefined): GuideAudioVoiceGender | undefined => {
|
||||||
|
const normalized = value?.trim().toLowerCase()
|
||||||
|
return normalized === 'female' || normalized === 'male' ? normalized : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeAudioOptions = (
|
||||||
|
options: BackendGuideStopAudioOption[] | null | undefined
|
||||||
|
): GuideStopAudioOption[] => (
|
||||||
|
(options || [])
|
||||||
|
.map<GuideStopAudioOption | null>((option) => {
|
||||||
|
if (!isSupportedGuideAudioLanguage(option.languageCode)) return null
|
||||||
|
|
||||||
|
const channelCode = option.channelCode?.trim()
|
||||||
|
const gender = normalizeVoiceGender(option.gender)
|
||||||
|
const playUrl = normalizeSameOriginPublicUrl(option.playUrl) || undefined
|
||||||
|
if (!channelCode || !gender || !playUrl) return null
|
||||||
|
|
||||||
|
const languageCode = normalizeGuideAudioLanguage(option.languageCode)
|
||||||
|
const displayName = option.displayName?.trim()
|
||||||
|
|| `${languageCode === 'en-US' ? '英文' : languageCode === 'yue-HK' ? '粤语' : '普通话'}${gender === 'female' ? '女声' : '男声'}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
channelCode,
|
||||||
|
displayName,
|
||||||
|
languageCode,
|
||||||
|
languageName: option.languageName?.trim() || undefined,
|
||||||
|
gender,
|
||||||
|
playUrl,
|
||||||
|
duration: normalizeNumber(option.duration),
|
||||||
|
format: option.format?.trim() || undefined,
|
||||||
|
isDefault: option.isDefault === true,
|
||||||
|
sortOrder: normalizeNumber(option.sortOrder)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((left, right) => (
|
||||||
|
(left!.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right!.sortOrder ?? Number.MAX_SAFE_INTEGER)
|
||||||
|
|| left!.channelCode.localeCompare(right!.channelCode)
|
||||||
|
)) as GuideStopAudioOption[]
|
||||||
|
)
|
||||||
|
|
||||||
const supportedLanguagesFromVariants = (variants: Record<GuideAudioLanguage, GuideStopLanguageVariant>) => (
|
const supportedLanguagesFromVariants = (variants: Record<GuideAudioLanguage, GuideStopLanguageVariant>) => (
|
||||||
(Object.values(variants) as GuideStopLanguageVariant[])
|
(Object.values(variants) as GuideStopLanguageVariant[])
|
||||||
.filter((variant) => variant.enabled && (variant.playable || variant.textAvailable))
|
.filter((variant) => variant.enabled && (variant.playable || variant.textAvailable))
|
||||||
.map((variant) => variant.lang)
|
.map((variant) => variant.lang)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const supportedLanguagesFromAudioOptions = (audioOptions: GuideStopAudioOption[]) => (
|
||||||
|
Array.from(new Set(audioOptions.map((option) => option.languageCode)))
|
||||||
|
)
|
||||||
|
|
||||||
const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls'] | BackendGuideStopLinkedExhibit['galleryUrls']) => {
|
const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls'] | BackendGuideStopLinkedExhibit['galleryUrls']) => {
|
||||||
if (!value) return []
|
if (!value) return []
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
@@ -337,7 +411,10 @@ export const toGuideStopInfo = (
|
|||||||
? normalizeSameOriginPublicUrl(source.coverImageUrl) || undefined
|
? normalizeSameOriginPublicUrl(source.coverImageUrl) || undefined
|
||||||
: undefined
|
: undefined
|
||||||
const languageVariants = normalizeLanguageVariants(source.languageVariants)
|
const languageVariants = normalizeLanguageVariants(source.languageVariants)
|
||||||
const supportedLanguages = supportedLanguagesFromVariants(languageVariants)
|
const audioOptions = normalizeAudioOptions(source.audioOptions)
|
||||||
|
const supportedLanguages = audioOptions.length
|
||||||
|
? supportedLanguagesFromAudioOptions(audioOptions)
|
||||||
|
: supportedLanguagesFromVariants(languageVariants)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
available: source.available === true,
|
available: source.available === true,
|
||||||
@@ -363,6 +440,7 @@ export const toGuideStopInfo = (
|
|||||||
supportedLanguages: supportedLanguages.length
|
supportedLanguages: supportedLanguages.length
|
||||||
? supportedLanguages
|
? supportedLanguages
|
||||||
: normalizeSupportedLanguages(source.supportedLanguages),
|
: normalizeSupportedLanguages(source.supportedLanguages),
|
||||||
|
audioOptions,
|
||||||
languageVariants,
|
languageVariants,
|
||||||
audioStatus: source.audioStatus || 'MISSING',
|
audioStatus: source.audioStatus || 'MISSING',
|
||||||
reason: source.reason || undefined,
|
reason: source.reason || undefined,
|
||||||
|
|||||||
@@ -139,11 +139,27 @@ export interface MuseumHall {
|
|||||||
hasAudio?: boolean
|
hasAudio?: boolean
|
||||||
audioStatus?: string
|
audioStatus?: string
|
||||||
supportedLanguages?: string[]
|
supportedLanguages?: string[]
|
||||||
|
audioOptionCount?: number
|
||||||
area?: string
|
area?: string
|
||||||
poiId?: string
|
poiId?: string
|
||||||
location?: GuideLocationResolution
|
location?: GuideLocationResolution
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MuseumAudioVoiceGender = 'female' | 'male'
|
||||||
|
|
||||||
|
export interface MuseumAudioOption {
|
||||||
|
channelCode: string
|
||||||
|
displayName: string
|
||||||
|
languageCode: string
|
||||||
|
languageName?: string
|
||||||
|
gender: MuseumAudioVoiceGender
|
||||||
|
audioUrl: string
|
||||||
|
duration?: number
|
||||||
|
format?: string
|
||||||
|
isDefault: boolean
|
||||||
|
sortOrder?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface MuseumExhibit {
|
export interface MuseumExhibit {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -179,6 +195,10 @@ export interface MuseumExhibit {
|
|||||||
audioAvailable?: boolean
|
audioAvailable?: boolean
|
||||||
audioStatus?: string
|
audioStatus?: string
|
||||||
supportedLanguages?: string[]
|
supportedLanguages?: string[]
|
||||||
|
audioOptions?: MuseumAudioOption[]
|
||||||
|
audioChannelCode?: string
|
||||||
|
audioVoiceGender?: MuseumAudioVoiceGender
|
||||||
|
audioVoiceDisplayName?: string
|
||||||
audioVariants?: Record<string, {
|
audioVariants?: Record<string, {
|
||||||
title?: string
|
title?: string
|
||||||
text?: string
|
text?: string
|
||||||
@@ -233,6 +253,7 @@ export interface ExplainGuideStop {
|
|||||||
hasAudio?: boolean
|
hasAudio?: boolean
|
||||||
audioStatus?: string
|
audioStatus?: string
|
||||||
supportedLanguages?: string[]
|
supportedLanguages?: string[]
|
||||||
|
audioOptionCount?: number
|
||||||
hasTextRecord?: boolean
|
hasTextRecord?: boolean
|
||||||
poiId?: string
|
poiId?: string
|
||||||
mapX?: number
|
mapX?: number
|
||||||
|
|||||||
@@ -104,6 +104,16 @@
|
|||||||
<text class="detail-audio-title">{{ audioDockTitle }}</text>
|
<text class="detail-audio-title">{{ audioDockTitle }}</text>
|
||||||
<text class="detail-audio-subtitle">{{ audioDockSubtitle }}</text>
|
<text class="detail-audio-subtitle">{{ audioDockSubtitle }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<button
|
||||||
|
v-if="voiceOptionsForCurrentLanguage.length"
|
||||||
|
class="detail-audio-voice"
|
||||||
|
:class="{ disabled: voiceSwitchLoading || !canSelectAudioVoice }"
|
||||||
|
:disabled="voiceSwitchLoading || !canSelectAudioVoice"
|
||||||
|
:aria-label="canSelectAudioVoice ? `选择讲解声线,当前${currentVoiceLabel}` : `当前仅支持${currentVoiceLabel}`"
|
||||||
|
@tap.stop="handleVoiceSelection"
|
||||||
|
>
|
||||||
|
<text>{{ currentVoiceLabel }}</text>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="audioDockState === 'playable'"
|
v-if="audioDockState === 'playable'"
|
||||||
class="detail-audio-rate"
|
class="detail-audio-rate"
|
||||||
@@ -226,6 +236,7 @@ const activeTopTab = ref<GuideTopTab>('explain')
|
|||||||
const resolvedHallId = ref('')
|
const resolvedHallId = ref('')
|
||||||
const retryingAudio = ref(false)
|
const retryingAudio = ref(false)
|
||||||
const languageSwitchLoading = ref(false)
|
const languageSwitchLoading = ref(false)
|
||||||
|
const voiceSwitchLoading = ref(false)
|
||||||
const selectedAudioLanguage = ref<AudioLanguage>('zh-CN')
|
const selectedAudioLanguage = ref<AudioLanguage>('zh-CN')
|
||||||
const detailEntryRequest = ref<{
|
const detailEntryRequest = ref<{
|
||||||
exhibitId: string
|
exhibitId: string
|
||||||
@@ -277,6 +288,20 @@ const contentLanguageOptions = computed(() => {
|
|||||||
return languageOptionDefinitions.filter((option) => supportedLanguages.includes(option.value))
|
return languageOptionDefinitions.filter((option) => supportedLanguages.includes(option.value))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const voiceOptionsForCurrentLanguage = computed(() => (
|
||||||
|
(detailSource.value?.audioOptions || []).filter((option) => (
|
||||||
|
option.languageCode === selectedAudioLanguage.value && Boolean(option.audioUrl?.trim())
|
||||||
|
))
|
||||||
|
))
|
||||||
|
|
||||||
|
const canSelectAudioVoice = computed(() => (
|
||||||
|
new Set(voiceOptionsForCurrentLanguage.value.map((option) => option.gender)).size > 1
|
||||||
|
))
|
||||||
|
|
||||||
|
const currentVoiceLabel = computed(() => (
|
||||||
|
detailSource.value?.audioVoiceGender === 'male' ? '男声' : '女声'
|
||||||
|
))
|
||||||
|
|
||||||
const resolveSupportedDetailLanguage = (requestedLanguage: AudioLanguage) => {
|
const resolveSupportedDetailLanguage = (requestedLanguage: AudioLanguage) => {
|
||||||
const supportedLanguages = supportedDetailLanguages.value
|
const supportedLanguages = supportedDetailLanguages.value
|
||||||
if (!supportedLanguages.length || supportedLanguages.includes(requestedLanguage)) {
|
if (!supportedLanguages.length || supportedLanguages.includes(requestedLanguage)) {
|
||||||
@@ -410,6 +435,7 @@ const isCurrentDetailAudioError = computed(() => {
|
|||||||
})
|
})
|
||||||
const isDetailAudioLoading = computed(() => (
|
const isDetailAudioLoading = computed(() => (
|
||||||
languageSwitchLoading.value
|
languageSwitchLoading.value
|
||||||
|
|| voiceSwitchLoading.value
|
||||||
|| retryingAudio.value
|
|| retryingAudio.value
|
||||||
|| (isCurrentDetailAudioTarget.value && globalAudioPlayer.loading.value)
|
|| (isCurrentDetailAudioTarget.value && globalAudioPlayer.loading.value)
|
||||||
))
|
))
|
||||||
@@ -671,6 +697,51 @@ const handleLanguageChange = async (lang: AudioLanguage, options: { syncAudio?:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectVoiceOption = async (channelCode: string) => {
|
||||||
|
if (voiceSwitchLoading.value) return
|
||||||
|
|
||||||
|
const source = detailSource.value
|
||||||
|
if (!source) return
|
||||||
|
|
||||||
|
const localizedExhibit = explainUseCase.selectExplainDetailAudioOption(source, channelCode)
|
||||||
|
if (localizedExhibit === source) return
|
||||||
|
|
||||||
|
const shouldRestartAudio = isCurrentDetailAudio.value && globalAudioPlayer.playing.value
|
||||||
|
const shouldCloseAudio = isCurrentDetailAudioTarget.value
|
||||||
|
voiceSwitchLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (shouldCloseAudio) {
|
||||||
|
globalAudioPlayer.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
detailSource.value = localizedExhibit
|
||||||
|
exhibit.value = toExplainDetailPageViewModel(localizedExhibit)
|
||||||
|
|
||||||
|
if (shouldRestartAudio) {
|
||||||
|
voiceSwitchLoading.value = false
|
||||||
|
await handlePlayAudio()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
voiceSwitchLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVoiceSelection = () => {
|
||||||
|
const voiceOptions = voiceOptionsForCurrentLanguage.value
|
||||||
|
if (!canSelectAudioVoice.value || voiceSwitchLoading.value) return
|
||||||
|
|
||||||
|
uni.showActionSheet({
|
||||||
|
itemList: voiceOptions.map((option) => option.displayName),
|
||||||
|
success: ({ tapIndex }) => {
|
||||||
|
const selectedOption = voiceOptions[tapIndex]
|
||||||
|
if (selectedOption) {
|
||||||
|
void selectVoiceOption(selectedOption.channelCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => globalAudioPlayer.currentSource.value,
|
() => globalAudioPlayer.currentSource.value,
|
||||||
(source) => {
|
(source) => {
|
||||||
@@ -1270,6 +1341,7 @@ const handleBack = returnToExplainObjectList
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-audio-voice,
|
||||||
.detail-audio-rate,
|
.detail-audio-rate,
|
||||||
.detail-audio-mute {
|
.detail-audio-mute {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1285,6 +1357,36 @@ const handleBack = returnToExplainObjectList
|
|||||||
color: #192d83;
|
color: #192d83;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-audio-voice {
|
||||||
|
right: 94px;
|
||||||
|
width: 46px;
|
||||||
|
color: #31524a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-audio-voice text {
|
||||||
|
width: 42px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #d9ddd6;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-audio-voice.disabled {
|
||||||
|
color: #8b918c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-audio-voice.disabled text {
|
||||||
|
border-color: #e6e7e4;
|
||||||
|
background: #f8f8f7;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-audio-rate {
|
.detail-audio-rate {
|
||||||
right: 50px;
|
right: 50px;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
@@ -1313,6 +1415,7 @@ const handleBack = returnToExplainObjectList
|
|||||||
color: #7a807a;
|
color: #7a807a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-audio-voice::after,
|
||||||
.detail-audio-rate::after,
|
.detail-audio-rate::after,
|
||||||
.detail-audio-mute::after {
|
.detail-audio-mute::after {
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -1372,7 +1475,7 @@ const handleBack = returnToExplainObjectList
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-audio-dock.is-playable .detail-audio-progress-hit {
|
.detail-audio-dock.is-playable .detail-audio-progress-hit {
|
||||||
right: 88px;
|
right: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-audio-progress.is-indeterminate::after {
|
.detail-audio-progress.is-indeterminate::after {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
ExplainGuideStopPage,
|
ExplainGuideStopPage,
|
||||||
ExplainTrack,
|
ExplainTrack,
|
||||||
MediaAsset,
|
MediaAsset,
|
||||||
|
MuseumAudioOption,
|
||||||
MuseumExhibit,
|
MuseumExhibit,
|
||||||
MuseumHall,
|
MuseumHall,
|
||||||
SearchIndexItem
|
SearchIndexItem
|
||||||
@@ -155,14 +156,36 @@ export class ExplainUseCase {
|
|||||||
return '中文'
|
return '中文'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private audioOptionForLanguage(
|
||||||
|
exhibit: MuseumExhibit,
|
||||||
|
language: AudioLanguage
|
||||||
|
): MuseumAudioOption | undefined {
|
||||||
|
const options = exhibit.audioOptions?.filter((option) => (
|
||||||
|
option.languageCode === language && Boolean(option.audioUrl?.trim())
|
||||||
|
)) || []
|
||||||
|
|
||||||
|
return options.find((option) => option.isDefault) || options[0]
|
||||||
|
}
|
||||||
|
|
||||||
private applyLanguageVariant(exhibit: MuseumExhibit, language: AudioLanguage): MuseumExhibit {
|
private applyLanguageVariant(exhibit: MuseumExhibit, language: AudioLanguage): MuseumExhibit {
|
||||||
const variant = exhibit.audioVariants?.[language]
|
const variant = exhibit.audioVariants?.[language]
|
||||||
|
const audioOption = this.audioOptionForLanguage(exhibit, language)
|
||||||
|
const hasAudioOptions = Boolean(exhibit.audioOptions?.length)
|
||||||
const mandarinText = exhibit.audioVariants?.['zh-CN']?.text
|
const mandarinText = exhibit.audioVariants?.['zh-CN']?.text
|
||||||
|| exhibit.guideText
|
|| exhibit.guideText
|
||||||
|| exhibit.description
|
|| exhibit.description
|
||||||
const supportedLanguages = exhibit.supportedLanguages?.length
|
const supportedLanguages = exhibit.supportedLanguages?.length
|
||||||
? exhibit.supportedLanguages
|
? exhibit.supportedLanguages
|
||||||
: exhibit.audioVariants ? Object.keys(exhibit.audioVariants) : [language]
|
: exhibit.audioOptions?.length
|
||||||
|
? Array.from(new Set(exhibit.audioOptions.map((option) => option.languageCode)))
|
||||||
|
: exhibit.audioVariants ? Object.keys(exhibit.audioVariants) : [language]
|
||||||
|
const legacyPlayable = (variant?.playable ?? variant?.available) === true && Boolean(variant?.audioUrl)
|
||||||
|
const playable = hasAudioOptions ? Boolean(audioOption?.audioUrl) : legacyPlayable
|
||||||
|
const audioUrl = hasAudioOptions ? audioOption?.audioUrl : variant?.audioUrl
|
||||||
|
const audioDuration = hasAudioOptions ? audioOption?.duration : variant?.audioDuration
|
||||||
|
const audioStatus = hasAudioOptions
|
||||||
|
? playable ? 'READY' : 'MISSING'
|
||||||
|
: variant?.audioStatus || (playable ? 'READY' : 'MISSING')
|
||||||
|
|
||||||
if (!variant) {
|
if (!variant) {
|
||||||
const label = this.languageLabel(language)
|
const label = this.languageLabel(language)
|
||||||
@@ -172,18 +195,20 @@ export class ExplainUseCase {
|
|||||||
return {
|
return {
|
||||||
...exhibit,
|
...exhibit,
|
||||||
guideText,
|
guideText,
|
||||||
audioUrl: undefined,
|
audioUrl,
|
||||||
audioDuration: undefined,
|
audioDuration,
|
||||||
audioLanguage: language,
|
audioLanguage: language,
|
||||||
audioHasText: language === 'yue-HK' && Boolean(mandarinText),
|
audioHasText: language === 'yue-HK' && Boolean(mandarinText),
|
||||||
audioAvailable: false,
|
audioAvailable: playable,
|
||||||
audioStatus: 'MISSING',
|
audioStatus,
|
||||||
audioUnavailableReason: `当前讲解暂无${label}音频`,
|
audioUnavailableReason: playable ? undefined : `当前讲解暂无${label}音频`,
|
||||||
|
audioChannelCode: audioOption?.channelCode,
|
||||||
|
audioVoiceGender: audioOption?.gender,
|
||||||
|
audioVoiceDisplayName: audioOption?.displayName,
|
||||||
supportedLanguages
|
supportedLanguages
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const playable = (variant.playable ?? variant.available) === true && Boolean(variant.audioUrl)
|
|
||||||
const textAvailable = variant.textAvailable ?? (variant.hasText === true || Boolean(variant.text))
|
const textAvailable = variant.textAvailable ?? (variant.hasText === true || Boolean(variant.text))
|
||||||
const guideText = variant.text
|
const guideText = variant.text
|
||||||
|| (language === 'yue-HK' ? mandarinText : undefined)
|
|| (language === 'yue-HK' ? mandarinText : undefined)
|
||||||
@@ -193,8 +218,8 @@ export class ExplainUseCase {
|
|||||||
...exhibit,
|
...exhibit,
|
||||||
guideTitle: variant.title || exhibit.guideTitle,
|
guideTitle: variant.title || exhibit.guideTitle,
|
||||||
guideText,
|
guideText,
|
||||||
audioUrl: playable ? variant.audioUrl : undefined,
|
audioUrl: playable ? audioUrl : undefined,
|
||||||
audioDuration: variant.audioDuration,
|
audioDuration,
|
||||||
audioLanguage: language,
|
audioLanguage: language,
|
||||||
audioHasText: textAvailable,
|
audioHasText: textAvailable,
|
||||||
audioText: variant.text || undefined,
|
audioText: variant.text || undefined,
|
||||||
@@ -202,10 +227,13 @@ export class ExplainUseCase {
|
|||||||
audioTextHash: variant.textHash,
|
audioTextHash: variant.textHash,
|
||||||
audioNarrationTier: variant.narrationTier,
|
audioNarrationTier: variant.narrationTier,
|
||||||
audioAvailable: playable,
|
audioAvailable: playable,
|
||||||
audioStatus: variant.audioStatus || (playable ? 'READY' : 'MISSING'),
|
audioStatus,
|
||||||
audioUnavailableReason: playable
|
audioUnavailableReason: playable
|
||||||
? undefined
|
? undefined
|
||||||
: audioReasonToText(variant.reason || 'NO_PUBLISHED_AUDIO'),
|
: audioReasonToText(variant.reason || 'NO_PUBLISHED_AUDIO'),
|
||||||
|
audioChannelCode: audioOption?.channelCode,
|
||||||
|
audioVoiceGender: audioOption?.gender,
|
||||||
|
audioVoiceDisplayName: audioOption?.displayName,
|
||||||
supportedLanguages
|
supportedLanguages
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,10 +268,20 @@ export class ExplainUseCase {
|
|||||||
fallback: variant.fallback,
|
fallback: variant.fallback,
|
||||||
reason: variant.reason
|
reason: variant.reason
|
||||||
}]))
|
}]))
|
||||||
const currentVariant = audioVariants[stopInfo.lang]
|
const audioOptions: MuseumAudioOption[] = (stopInfo.audioOptions || []).map((option) => ({
|
||||||
const audioAvailable = currentVariant?.playable === true && Boolean(currentVariant.audioUrl)
|
channelCode: option.channelCode,
|
||||||
|
displayName: option.displayName,
|
||||||
|
languageCode: option.languageCode,
|
||||||
|
languageName: option.languageName,
|
||||||
|
gender: option.gender,
|
||||||
|
audioUrl: option.playUrl,
|
||||||
|
duration: option.duration,
|
||||||
|
format: option.format,
|
||||||
|
isDefault: option.isDefault,
|
||||||
|
sortOrder: option.sortOrder
|
||||||
|
}))
|
||||||
|
|
||||||
return {
|
const exhibit: MuseumExhibit = {
|
||||||
...(fallback || {}),
|
...(fallback || {}),
|
||||||
id: fallback?.id || linkedPrimary?.id || stopInfo.targetId,
|
id: fallback?.id || linkedPrimary?.id || stopInfo.targetId,
|
||||||
name: stopInfo.title || fallback?.name || linkedPrimary?.name || '讲解内容',
|
name: stopInfo.title || fallback?.name || linkedPrimary?.name || '讲解内容',
|
||||||
@@ -263,22 +301,16 @@ 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: currentVariant?.text || stopInfo.description || fallback?.guideText || fallback?.description,
|
guideText: stopInfo.description || fallback?.guideText || fallback?.description,
|
||||||
audioUrl: audioAvailable ? currentVariant?.audioUrl : undefined,
|
audioUrl: undefined,
|
||||||
audioDuration: currentVariant?.audioDuration,
|
audioDuration: undefined,
|
||||||
audioLanguage: stopInfo.lang,
|
audioLanguage: stopInfo.lang,
|
||||||
audioHasText: currentVariant?.textAvailable || stopInfo.hasText,
|
audioHasText: stopInfo.hasText,
|
||||||
audioText: currentVariant?.text,
|
audioAvailable: false,
|
||||||
audioTextLength: currentVariant?.textLength,
|
audioStatus: stopInfo.audioStatus,
|
||||||
audioTextHash: currentVariant?.textHash,
|
|
||||||
audioNarrationTier: currentVariant?.narrationTier,
|
|
||||||
audioUnavailableReason: audioAvailable
|
|
||||||
? undefined
|
|
||||||
: audioReasonToText(currentVariant?.reason || stopInfo.reason || (stopInfo.audioStatus === 'MISSING' ? 'NO_PUBLISHED_AUDIO' : undefined)),
|
|
||||||
audioAvailable,
|
|
||||||
audioStatus: currentVariant?.audioStatus || stopInfo.audioStatus,
|
|
||||||
supportedLanguages: stopInfo.supportedLanguages,
|
supportedLanguages: stopInfo.supportedLanguages,
|
||||||
audioVariants,
|
audioVariants,
|
||||||
|
audioOptions,
|
||||||
imageStatus: stopInfo.imageStatus,
|
imageStatus: stopInfo.imageStatus,
|
||||||
imageSource: stopInfo.imageSource,
|
imageSource: stopInfo.imageSource,
|
||||||
galleryUrls: stopInfo.imageStatus === 'READY' ? stopInfo.galleryUrls : [],
|
galleryUrls: stopInfo.imageStatus === 'READY' ? stopInfo.galleryUrls : [],
|
||||||
@@ -291,6 +323,8 @@ export class ExplainUseCase {
|
|||||||
playTargetType: audioTarget.targetType,
|
playTargetType: audioTarget.targetType,
|
||||||
playTargetId: audioTarget.targetId
|
playTargetId: audioTarget.targetId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return this.applyLanguageVariant(exhibit, stopInfo.lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyPlayInfo(
|
private applyPlayInfo(
|
||||||
@@ -467,6 +501,29 @@ export class ExplainUseCase {
|
|||||||
return this.applyLanguageVariant(exhibit, language)
|
return this.applyLanguageVariant(exhibit, language)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectExplainDetailAudioOption(exhibit: MuseumExhibit, channelCode: string): MuseumExhibit {
|
||||||
|
const language = exhibit.audioLanguage as AudioLanguage | undefined
|
||||||
|
const audioOption = exhibit.audioOptions?.find((option) => (
|
||||||
|
option.channelCode === channelCode
|
||||||
|
&& option.languageCode === language
|
||||||
|
&& Boolean(option.audioUrl?.trim())
|
||||||
|
))
|
||||||
|
if (!audioOption) return exhibit
|
||||||
|
|
||||||
|
return {
|
||||||
|
...exhibit,
|
||||||
|
audioUrl: audioOption.audioUrl,
|
||||||
|
audioDuration: audioOption.duration,
|
||||||
|
audioLanguage: audioOption.languageCode,
|
||||||
|
audioAvailable: true,
|
||||||
|
audioStatus: 'READY',
|
||||||
|
audioUnavailableReason: undefined,
|
||||||
|
audioChannelCode: audioOption.channelCode,
|
||||||
|
audioVoiceGender: audioOption.gender,
|
||||||
|
audioVoiceDisplayName: audioOption.displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -671,12 +728,12 @@ export class ExplainUseCase {
|
|||||||
const targetType = exhibit.playTargetType || 'ITEM'
|
const targetType = exhibit.playTargetType || 'ITEM'
|
||||||
const targetId = exhibit.playTargetId || exhibit.id
|
const targetId = exhibit.playTargetId || exhibit.id
|
||||||
|
|
||||||
if (!options.refreshPlayInfo && exhibit.audioUrl?.trim()) {
|
if ((!options.refreshPlayInfo || exhibit.audioChannelCode) && exhibit.audioUrl?.trim()) {
|
||||||
return {
|
return {
|
||||||
exhibit,
|
exhibit,
|
||||||
track: null,
|
track: null,
|
||||||
media: {
|
media: {
|
||||||
id: `static-${targetType}-${targetId}-${exhibit.audioLanguage || dataSourceConfig.audioLanguage}`,
|
id: `static-${exhibit.audioChannelCode || `${targetType}-${targetId}`}-${exhibit.audioLanguage || dataSourceConfig.audioLanguage}`,
|
||||||
type: 'audio',
|
type: 'audio',
|
||||||
url: exhibit.audioUrl,
|
url: exhibit.audioUrl,
|
||||||
duration: exhibit.audioDuration,
|
duration: exhibit.audioDuration,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
onUnloadHandler: null as null | (() => void),
|
onUnloadHandler: null as null | (() => void),
|
||||||
enterExplainDetail: vi.fn(),
|
enterExplainDetail: vi.fn(),
|
||||||
selectExplainDetailLanguage: vi.fn(),
|
selectExplainDetailLanguage: vi.fn(),
|
||||||
|
selectExplainDetailAudioOption: vi.fn(),
|
||||||
loadExplainDetailText: vi.fn(),
|
loadExplainDetailText: vi.fn(),
|
||||||
selectAudioForExplainDetail: vi.fn(),
|
selectAudioForExplainDetail: vi.fn(),
|
||||||
play: vi.fn(),
|
play: vi.fn(),
|
||||||
@@ -49,6 +50,7 @@ vi.mock('@/usecases/explainUseCase', () => ({
|
|||||||
explainUseCase: {
|
explainUseCase: {
|
||||||
enterExplainDetail: mocks.enterExplainDetail,
|
enterExplainDetail: mocks.enterExplainDetail,
|
||||||
selectExplainDetailLanguage: mocks.selectExplainDetailLanguage,
|
selectExplainDetailLanguage: mocks.selectExplainDetailLanguage,
|
||||||
|
selectExplainDetailAudioOption: mocks.selectExplainDetailAudioOption,
|
||||||
loadExplainDetailText: mocks.loadExplainDetailText,
|
loadExplainDetailText: mocks.loadExplainDetailText,
|
||||||
selectAudioForExplainDetail: mocks.selectAudioForExplainDetail
|
selectAudioForExplainDetail: mocks.selectAudioForExplainDetail
|
||||||
}
|
}
|
||||||
@@ -63,7 +65,11 @@ vi.mock('@/usecases/guideUseCase', () => ({
|
|||||||
vi.mock('@/composables/useGlobalAudioPlayer', () => ({
|
vi.mock('@/composables/useGlobalAudioPlayer', () => ({
|
||||||
useGlobalAudioPlayer: () => ({
|
useGlobalAudioPlayer: () => ({
|
||||||
...audioState,
|
...audioState,
|
||||||
isCurrentSource: () => false,
|
isCurrentSource: (source: { targetType?: string, targetId?: string, lang?: string }) => (
|
||||||
|
audioState.currentSource.value?.targetType === source.targetType
|
||||||
|
&& audioState.currentSource.value?.targetId === source.targetId
|
||||||
|
&& audioState.currentSource.value?.lang === source.lang
|
||||||
|
),
|
||||||
play: mocks.play,
|
play: mocks.play,
|
||||||
pause: mocks.pause,
|
pause: mocks.pause,
|
||||||
resume: mocks.resume,
|
resume: mocks.resume,
|
||||||
@@ -124,6 +130,7 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
...source,
|
...source,
|
||||||
audioLanguage: lang
|
audioLanguage: lang
|
||||||
}))
|
}))
|
||||||
|
mocks.selectExplainDetailAudioOption.mockImplementation((source, channelCode) => source)
|
||||||
mocks.loadExplainDetailText.mockResolvedValue({ available: false })
|
mocks.loadExplainDetailText.mockResolvedValue({ available: false })
|
||||||
mocks.selectAudioForExplainDetail.mockResolvedValue({
|
mocks.selectAudioForExplainDetail.mockResolvedValue({
|
||||||
playable: true,
|
playable: true,
|
||||||
@@ -139,6 +146,7 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
mocks.play.mockResolvedValue(undefined)
|
mocks.play.mockResolvedValue(undefined)
|
||||||
vi.stubGlobal('uni', {
|
vi.stubGlobal('uni', {
|
||||||
showToast: vi.fn(),
|
showToast: vi.fn(),
|
||||||
|
showActionSheet: vi.fn(),
|
||||||
navigateBack: vi.fn(),
|
navigateBack: vi.fn(),
|
||||||
redirectTo: vi.fn(),
|
redirectTo: vi.fn(),
|
||||||
reLaunch: vi.fn()
|
reLaunch: vi.fn()
|
||||||
@@ -222,6 +230,124 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
expect(wrapper.find('.detail-language-bar').exists()).toBe(false)
|
expect(wrapper.find('.detail-language-bar').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('仅在当前语言同时存在男女声时展示声线按钮并重播所选音频', async () => {
|
||||||
|
const voiceExhibit = {
|
||||||
|
...exhibit,
|
||||||
|
audioOptions: [
|
||||||
|
{
|
||||||
|
channelCode: 'standard.en-US.female',
|
||||||
|
displayName: '英文女声',
|
||||||
|
languageCode: 'en-US',
|
||||||
|
gender: 'female',
|
||||||
|
audioUrl: '/assets/astrolabe-en-female.mp3',
|
||||||
|
duration: 81,
|
||||||
|
isDefault: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channelCode: 'standard.en-US.male',
|
||||||
|
displayName: '英文男声',
|
||||||
|
languageCode: 'en-US',
|
||||||
|
gender: 'male',
|
||||||
|
audioUrl: '/assets/astrolabe-en-male.mp3',
|
||||||
|
duration: 82,
|
||||||
|
isDefault: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channelCode: 'standard.yue-HK.female',
|
||||||
|
displayName: '粤语女声',
|
||||||
|
languageCode: 'yue-HK',
|
||||||
|
gender: 'female',
|
||||||
|
audioUrl: '/assets/astrolabe-yue-female.mp3',
|
||||||
|
duration: 83,
|
||||||
|
isDefault: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
audioChannelCode: 'standard.en-US.female',
|
||||||
|
audioVoiceGender: 'female',
|
||||||
|
audioVoiceDisplayName: '英文女声',
|
||||||
|
audioUrl: '/assets/astrolabe-en-female.mp3'
|
||||||
|
}
|
||||||
|
const maleVoiceExhibit = {
|
||||||
|
...voiceExhibit,
|
||||||
|
audioUrl: '/assets/astrolabe-en-male.mp3',
|
||||||
|
audioDuration: 82,
|
||||||
|
audioChannelCode: 'standard.en-US.male',
|
||||||
|
audioVoiceGender: 'male',
|
||||||
|
audioVoiceDisplayName: '英文男声'
|
||||||
|
}
|
||||||
|
mocks.enterExplainDetail.mockResolvedValue(voiceExhibit)
|
||||||
|
mocks.selectExplainDetailAudioOption.mockReturnValue(maleVoiceExhibit)
|
||||||
|
mocks.selectAudioForExplainDetail.mockImplementation((source) => Promise.resolve({
|
||||||
|
playable: true,
|
||||||
|
exhibit: source,
|
||||||
|
media: {
|
||||||
|
id: source.audioChannelCode,
|
||||||
|
url: source.audioUrl,
|
||||||
|
duration: source.audioDuration,
|
||||||
|
language: source.audioLanguage
|
||||||
|
},
|
||||||
|
playInfo: { title: source.name }
|
||||||
|
}))
|
||||||
|
audioState.currentAudio.value = { id: 'audio-1', audioUrl: voiceExhibit.audioUrl }
|
||||||
|
audioState.currentSource.value = { targetType: 'STOP', targetId: 'stop-1', lang: 'en-US' }
|
||||||
|
audioState.playing.value = true
|
||||||
|
mocks.close.mockImplementation(() => {
|
||||||
|
audioState.currentAudio.value = null
|
||||||
|
audioState.currentSource.value = null
|
||||||
|
audioState.playing.value = false
|
||||||
|
})
|
||||||
|
vi.mocked(uni.showActionSheet).mockImplementation(({ success }) => success?.({ tapIndex: 1 } as any))
|
||||||
|
|
||||||
|
const wrapper = mount(ExhibitDetail, {
|
||||||
|
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
||||||
|
})
|
||||||
|
|
||||||
|
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.get('.detail-audio-voice').text()).toBe('女声')
|
||||||
|
|
||||||
|
await wrapper.get('.detail-audio-voice').trigger('tap')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(uni.showActionSheet).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
itemList: ['英文女声', '英文男声']
|
||||||
|
}))
|
||||||
|
expect(mocks.selectExplainDetailAudioOption).toHaveBeenCalledWith(voiceExhibit, 'standard.en-US.male')
|
||||||
|
expect(wrapper.get('.detail-audio-voice').text()).toBe('男声')
|
||||||
|
expect(mocks.close).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mocks.play).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
audioUrl: '/assets/astrolabe-en-male.mp3'
|
||||||
|
}), expect.any(Object))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('粤语只有女声时保留禁用的声线按钮', async () => {
|
||||||
|
mocks.enterExplainDetail.mockResolvedValue({
|
||||||
|
...exhibit,
|
||||||
|
audioLanguage: 'yue-HK',
|
||||||
|
audioVoiceGender: 'female',
|
||||||
|
audioOptions: [
|
||||||
|
{
|
||||||
|
channelCode: 'standard.yue-HK.female',
|
||||||
|
displayName: '粤语女声',
|
||||||
|
languageCode: 'yue-HK',
|
||||||
|
gender: 'female',
|
||||||
|
audioUrl: '/assets/astrolabe-yue-female.mp3',
|
||||||
|
duration: 83,
|
||||||
|
isDefault: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
const wrapper = mount(ExhibitDetail, {
|
||||||
|
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
||||||
|
})
|
||||||
|
|
||||||
|
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'yue-HK', targetType: 'STOP', targetId: 'stop-1' })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.get('.detail-audio-voice').text()).toBe('女声')
|
||||||
|
expect(wrapper.get('.detail-audio-voice').attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('深链语言不受支持时本地回退至中文并规范化地址', async () => {
|
it('深链语言不受支持时本地回退至中文并规范化地址', async () => {
|
||||||
mocks.enterExplainDetail.mockImplementation(async (request: { lang: string }) => ({
|
mocks.enterExplainDetail.mockImplementation(async (request: { lang: string }) => ({
|
||||||
...exhibit,
|
...exhibit,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const createStopInfo = (overrides: Partial<GuideStopInfo> = {}): GuideStopInfo =
|
|||||||
hasAudio: false,
|
hasAudio: false,
|
||||||
hasText: false,
|
hasText: false,
|
||||||
supportedLanguages: [],
|
supportedLanguages: [],
|
||||||
|
audioOptions: [],
|
||||||
languageVariants: {},
|
languageVariants: {},
|
||||||
audioStatus: 'MISSING',
|
audioStatus: 'MISSING',
|
||||||
...overrides
|
...overrides
|
||||||
@@ -156,4 +157,91 @@ describe('ExplainUseCase stop image policy', () => {
|
|||||||
expect((audio as any).getPlayInfo).not.toHaveBeenCalled()
|
expect((audio as any).getPlayInfo).not.toHaveBeenCalled()
|
||||||
expect((audio as any).getTextInfo).not.toHaveBeenCalled()
|
expect((audio as any).getTextInfo).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('prefers default audio options and switches the selected voice locally', async () => {
|
||||||
|
const useCase = createUseCase(createStopInfo({
|
||||||
|
hasAudio: true,
|
||||||
|
hasText: true,
|
||||||
|
supportedLanguages: ['zh-CN', 'en-US'],
|
||||||
|
languageVariants: {
|
||||||
|
'zh-CN': {
|
||||||
|
lang: 'zh-CN',
|
||||||
|
enabled: true,
|
||||||
|
playable: true,
|
||||||
|
playUrl: '/audio/legacy-zh.mp3',
|
||||||
|
textAvailable: true,
|
||||||
|
text: '普通话讲解词',
|
||||||
|
hasText: true,
|
||||||
|
fallback: false,
|
||||||
|
audioStatus: 'READY'
|
||||||
|
},
|
||||||
|
'en-US': {
|
||||||
|
lang: 'en-US',
|
||||||
|
enabled: true,
|
||||||
|
playable: true,
|
||||||
|
playUrl: '/audio/legacy-en.mp3',
|
||||||
|
textAvailable: true,
|
||||||
|
text: 'English narration',
|
||||||
|
hasText: true,
|
||||||
|
fallback: false,
|
||||||
|
audioStatus: 'READY'
|
||||||
|
}
|
||||||
|
} as GuideStopInfo['languageVariants'],
|
||||||
|
audioOptions: [
|
||||||
|
{
|
||||||
|
channelCode: 'standard.zh-CN.female',
|
||||||
|
displayName: '普通话女声',
|
||||||
|
languageCode: 'zh-CN',
|
||||||
|
gender: 'female',
|
||||||
|
playUrl: '/audio/zh-female.mp3',
|
||||||
|
duration: 50,
|
||||||
|
isDefault: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channelCode: 'standard.zh-CN.male',
|
||||||
|
displayName: '普通话男声',
|
||||||
|
languageCode: 'zh-CN',
|
||||||
|
gender: 'male',
|
||||||
|
playUrl: '/audio/zh-male.mp3',
|
||||||
|
duration: 51,
|
||||||
|
isDefault: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channelCode: 'standard.en-US.female',
|
||||||
|
displayName: '英文女声',
|
||||||
|
languageCode: 'en-US',
|
||||||
|
gender: 'female',
|
||||||
|
playUrl: '/audio/en-female.mp3',
|
||||||
|
duration: 49,
|
||||||
|
isDefault: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
const chinese = await useCase.enterExplainDetail({
|
||||||
|
exhibitId: 'stop-1', targetType: 'STOP', targetId: 'stop-1', lang: 'zh-CN'
|
||||||
|
})
|
||||||
|
const english = useCase.selectExplainDetailLanguage(chinese, 'en-US')
|
||||||
|
const chineseMale = useCase.selectExplainDetailAudioOption(chinese, 'standard.zh-CN.male')
|
||||||
|
|
||||||
|
expect(chinese).toMatchObject({
|
||||||
|
audioUrl: '/audio/zh-female.mp3',
|
||||||
|
audioDuration: 50,
|
||||||
|
audioChannelCode: 'standard.zh-CN.female',
|
||||||
|
audioVoiceGender: 'female',
|
||||||
|
guideText: '普通话讲解词'
|
||||||
|
})
|
||||||
|
expect(english).toMatchObject({
|
||||||
|
audioUrl: '/audio/en-female.mp3',
|
||||||
|
audioChannelCode: 'standard.en-US.female',
|
||||||
|
guideText: 'English narration'
|
||||||
|
})
|
||||||
|
expect(chineseMale).toMatchObject({
|
||||||
|
audioUrl: '/audio/zh-male.mp3',
|
||||||
|
audioDuration: 51,
|
||||||
|
audioChannelCode: 'standard.zh-CN.male',
|
||||||
|
audioVoiceGender: 'male',
|
||||||
|
guideText: '普通话讲解词'
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -75,4 +75,63 @@ describe('guide stop-info adapter', () => {
|
|||||||
expect(result.supportedLanguages).toEqual(['zh-CN', 'en-US'])
|
expect(result.supportedLanguages).toEqual(['zh-CN', 'en-US'])
|
||||||
expect(result.languageVariants).toEqual({})
|
expect(result.languageVariants).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses playable audio options as the primary language and voice contract', () => {
|
||||||
|
const result = toGuideStopInfo({
|
||||||
|
supportedLanguages: ['zh-CN', 'yue-HK'],
|
||||||
|
audioOptions: [
|
||||||
|
{
|
||||||
|
channelCode: 'standard.zh-CN.male',
|
||||||
|
displayName: '普通话男声',
|
||||||
|
languageCode: 'zh-CN',
|
||||||
|
gender: 'male',
|
||||||
|
playUrl: '/museum-assets/audio/zh-male.mp3',
|
||||||
|
duration: '51',
|
||||||
|
isDefault: false,
|
||||||
|
sortOrder: 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channelCode: 'standard.en-US.female',
|
||||||
|
displayName: '英文女声',
|
||||||
|
languageCode: 'en-US',
|
||||||
|
gender: 'female',
|
||||||
|
playUrl: '/museum-assets/audio/en-female.mp3',
|
||||||
|
duration: 49,
|
||||||
|
isDefault: true,
|
||||||
|
sortOrder: 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channelCode: 'standard.zh-CN.female',
|
||||||
|
displayName: '普通话女声',
|
||||||
|
languageCode: 'zh-CN',
|
||||||
|
gender: 'female',
|
||||||
|
playUrl: '/museum-assets/audio/zh-female.mp3',
|
||||||
|
isDefault: true,
|
||||||
|
sortOrder: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channelCode: 'invalid',
|
||||||
|
languageCode: 'zh-CN',
|
||||||
|
gender: 'female'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}, {
|
||||||
|
targetType: 'STOP',
|
||||||
|
targetId: '9001',
|
||||||
|
lang: 'zh-CN'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.supportedLanguages).toEqual(['zh-CN', 'en-US'])
|
||||||
|
expect(result.audioOptions.map((option) => option.channelCode)).toEqual([
|
||||||
|
'standard.zh-CN.female',
|
||||||
|
'standard.zh-CN.male',
|
||||||
|
'standard.en-US.female'
|
||||||
|
])
|
||||||
|
expect(result.audioOptions[0]).toMatchObject({
|
||||||
|
displayName: '普通话女声',
|
||||||
|
gender: 'female',
|
||||||
|
playUrl: '/museum-assets/audio/zh-female.mp3',
|
||||||
|
isDefault: true
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user