feat: 支持讲解详情页粤语语音切换
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-14 18:25:45 +08:00
parent 04c6ccd40b
commit d813f0b375
12 changed files with 291 additions and 70 deletions

View File

@@ -119,6 +119,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import type { AudioLanguage as RepositoryAudioLanguage } from '@/repositories/AudioPlayInfoRepository'
export interface AudioItem { export interface AudioItem {
id: string id: string
name: string name: string
@@ -129,7 +130,7 @@ export interface AudioItem {
supportedLanguages?: AudioLanguage[] supportedLanguages?: AudioLanguage[]
} }
export type AudioLanguage = 'zh-CN' | 'en-US' export type AudioLanguage = RepositoryAudioLanguage
export type AudioDisplayMode = 'expanded' | 'mini' | 'floating' export type AudioDisplayMode = 'expanded' | 'mini' | 'floating'
const props = defineProps<{ const props = defineProps<{
@@ -202,7 +203,11 @@ const displayMode = computed<AudioDisplayMode>(() => props.mode || 'mini')
const closable = computed(() => props.closable !== false) const closable = computed(() => props.closable !== false)
const expandable = computed(() => props.expandable !== false) const expandable = computed(() => props.expandable !== false)
const displayAudioLanguage = computed<AudioLanguage>(() => ( const displayAudioLanguage = computed<AudioLanguage>(() => (
displayAudio.value?.language === 'en-US' ? 'en-US' : 'zh-CN' displayAudio.value?.language === 'en-US'
? 'en-US'
: displayAudio.value?.language === 'yue-HK'
? 'yue-HK'
: 'zh-CN'
)) ))
const formattedCurrentTime = computed(() => formatTime(displayCurrentTime.value)) const formattedCurrentTime = computed(() => formatTime(displayCurrentTime.value))
const formattedDuration = computed(() => formatTime(resolvedDuration.value)) const formattedDuration = computed(() => formatTime(resolvedDuration.value))
@@ -210,7 +215,9 @@ const expandedSubtitle = computed(() => {
if (displayPlaybackError.value) return displayPlaybackError.value if (displayPlaybackError.value) return displayPlaybackError.value
if (displayLoading.value) return '音频加载中' if (displayLoading.value) return '音频加载中'
if (!displayAudio.value?.audioUrl) return '当前语言暂无语音讲解' if (!displayAudio.value?.audioUrl) return '当前语言暂无语音讲解'
return displayAudioLanguage.value === 'en-US' ? '英文语音讲解' : '中文语音讲解' if (displayAudioLanguage.value === 'en-US') return '英文语音讲解'
if (displayAudioLanguage.value === 'yue-HK') return '粤语语音讲解'
return '中文语音讲解'
}) })
// 格式化时间 // 格式化时间

View File

@@ -5,6 +5,7 @@ import type {
} from '@/components/audio/AudioPlayer.vue' } from '@/components/audio/AudioPlayer.vue'
import { import {
audioPlayInfoRepository, audioPlayInfoRepository,
audioReasonToText,
type AudioLanguage type AudioLanguage
} from '@/repositories/AudioPlayInfoRepository' } from '@/repositories/AudioPlayInfoRepository'
import type { AudioPlayTargetType } from '@/domain/museum' import type { AudioPlayTargetType } from '@/domain/museum'
@@ -276,6 +277,10 @@ const switchLanguage = async (lang: AudioLanguage) => {
const preservedMode = displayMode.value const preservedMode = displayMode.value
pendingLanguage.value = lang pendingLanguage.value = lang
stopAudioElement()
playing.value = false
currentTime.value = 0
duration.value = 0
loading.value = true loading.value = true
error.value = '' error.value = ''
@@ -311,7 +316,7 @@ const switchLanguage = async (lang: AudioLanguage) => {
loading.value = false loading.value = false
currentTime.value = 0 currentTime.value = 0
duration.value = 0 duration.value = 0
error.value = '当前语言暂无语音讲解' error.value = audioReasonToText(playInfo.reason || 'NO_PUBLISHED_AUDIO')
showToast(error.value) showToast(error.value)
return false return false
} }
@@ -341,7 +346,7 @@ const switchLanguage = async (lang: AudioLanguage) => {
loading.value = false loading.value = false
currentTime.value = 0 currentTime.value = 0
duration.value = 0 duration.value = 0
error.value = '当前语言暂无语音讲解' error.value = '语音服务暂不可用,请稍后重试'
showToast(error.value) showToast(error.value)
return false return false
} finally { } finally {

View File

@@ -1,5 +1,6 @@
export type DataSourceMode = 'static' | 'api' | 'sdk' export type DataSourceMode = 'static' | 'api' | 'sdk'
export type GuideContentDataSourceMode = 'static' | 'remote' | 'mock' export type GuideContentDataSourceMode = 'static' | 'remote' | 'mock'
export type ConfiguredAudioLanguage = 'zh-CN' | 'yue-HK' | 'en-US'
const allowedModes = new Set<DataSourceMode>(['static', 'api', 'sdk']) const allowedModes = new Set<DataSourceMode>(['static', 'api', 'sdk'])
const allowedGuideContentModes = new Set<GuideContentDataSourceMode>(['static', 'remote', 'mock']) const allowedGuideContentModes = new Set<GuideContentDataSourceMode>(['static', 'remote', 'mock'])
@@ -27,6 +28,11 @@ const normalizeGuideContentMode = (mode: string | undefined): GuideContentDataSo
return 'static' return 'static'
} }
const normalizeAudioLanguage = (language: string | undefined): ConfiguredAudioLanguage => {
if (language === 'en-US' || language === 'yue-HK') return language
return 'zh-CN'
}
const normalizeBoolean = (value: string | undefined) => ( const normalizeBoolean = (value: string | undefined) => (
['1', 'true', 'yes', 'on'].includes(value?.trim().toLowerCase() || '') ['1', 'true', 'yes', 'on'].includes(value?.trim().toLowerCase() || '')
) )
@@ -74,7 +80,7 @@ export const dataSourceConfig = {
defaultAudioApiBaseUrl defaultAudioApiBaseUrl
), ),
apiBaseUrl: normalizeUrl(import.meta.env.VITE_API_BASE_URL, defaultApiBaseUrl), apiBaseUrl: normalizeUrl(import.meta.env.VITE_API_BASE_URL, defaultApiBaseUrl),
audioLanguage: import.meta.env.VITE_AUDIO_LANGUAGE === 'en-US' ? 'en-US' : 'zh-CN', audioLanguage: normalizeAudioLanguage(import.meta.env.VITE_AUDIO_LANGUAGE),
sgsApiBaseUrl: normalizeUrl( sgsApiBaseUrl: normalizeUrl(
import.meta.env.VITE_SGS_API_BASE_URL || import.meta.env.VITE_API_BASE_URL, import.meta.env.VITE_SGS_API_BASE_URL || import.meta.env.VITE_API_BASE_URL,
defaultApiBaseUrl defaultApiBaseUrl

View File

@@ -14,6 +14,10 @@ import {
EXHIBIT_PLACEHOLDER_IMAGE, EXHIBIT_PLACEHOLDER_IMAGE,
HALL_PLACEHOLDER_IMAGE HALL_PLACEHOLDER_IMAGE
} from '@/utils/placeholders' } from '@/utils/placeholders'
import {
normalizeGuideAudioLanguage,
type GuideAudioLanguage
} from '@/data/adapters/guideStopInfoAdapter'
export interface BackendGuideContent { export interface BackendGuideContent {
id?: string | number | null id?: string | number | null
@@ -251,8 +255,12 @@ const buildTags = (exhibit: BackendExhibit) => Array.from(new Set([
firstText(exhibit.origin) firstText(exhibit.origin)
].filter(Boolean))) ].filter(Boolean)))
const normalizeSupportedLanguages = (languages: string[] | null | undefined) => ( const normalizeSupportedLanguages = (languages: string[] | null | undefined): GuideAudioLanguage[] => (
Array.isArray(languages) ? languages.map(String).filter(Boolean) : [] Array.from(new Set((languages || [])
.map(String)
.filter((language) => ['zh', 'zh-cn', 'en', 'en-us', 'yue', 'yue-cn', 'yue-hk']
.includes(language.trim().toLowerCase()))
.map(normalizeGuideAudioLanguage)))
) )
const normalizeCatalogAudioStatus = (status?: string | null) => ( const normalizeCatalogAudioStatus = (status?: string | null) => (
@@ -496,7 +504,8 @@ export const toBackendMuseumExhibit = (
const audioUrl = options.includeDetail ? resolveGuideAudioUrl(guide, source) : normalizeSameOriginPublicUrl(firstText(source.audioUrl)) const audioUrl = options.includeDetail ? resolveGuideAudioUrl(guide, source) : normalizeSameOriginPublicUrl(firstText(source.audioUrl))
const targetType = normalizeAudioTargetType(guide?.targetType || source.playTargetType) const targetType = normalizeAudioTargetType(guide?.targetType || source.playTargetType)
const targetId = stringifyId(guide?.targetId || source.playTargetId) const targetId = stringifyId(guide?.targetId || source.playTargetId)
const supportedLanguage = source.supportedLanguages?.find((language) => language === 'zh-CN' || language === 'en-US') const supportedLanguages = normalizeSupportedLanguages(source.supportedLanguages)
const supportedLanguage = supportedLanguages[0]
const metadataSuggestsAudio = source.hasAudio === true && isAudioReady(source.audioStatus) const metadataSuggestsAudio = source.hasAudio === true && isAudioReady(source.audioStatus)
const audioAvailable = Boolean(audioUrl) const audioAvailable = Boolean(audioUrl)
const sourcePoiId = stringifyId(source.poiId) const sourcePoiId = stringifyId(source.poiId)
@@ -528,7 +537,7 @@ export const toBackendMuseumExhibit = (
audioHasText: options.includeDetail ? Boolean(guideText) : undefined, audioHasText: options.includeDetail ? Boolean(guideText) : undefined,
audioAvailable: audioAvailable || metadataSuggestsAudio, audioAvailable: audioAvailable || metadataSuggestsAudio,
audioStatus: source.audioStatus || undefined, audioStatus: source.audioStatus || undefined,
supportedLanguages: source.supportedLanguages || undefined, supportedLanguages: supportedLanguages.length ? supportedLanguages : undefined,
audioUnavailableReason: audioAvailable || metadataSuggestsAudio audioUnavailableReason: audioAvailable || metadataSuggestsAudio
? undefined ? undefined
: '该讲解暂无已发布音频,当前提供图文讲解。', : '该讲解暂无已发布音频,当前提供图文讲解。',

View File

@@ -5,6 +5,8 @@ import {
normalizeSameOriginPublicUrl normalizeSameOriginPublicUrl
} from '@/utils/publicUrl' } from '@/utils/publicUrl'
export type GuideAudioLanguage = 'zh-CN' | 'yue-HK' | 'en-US'
export interface BackendGuideStopLinkedExhibit { export interface BackendGuideStopLinkedExhibit {
id?: string | number | null id?: string | number | null
name?: string | null name?: string | null
@@ -97,7 +99,7 @@ export interface GuideStopInfo {
floorId?: string floorId?: string
mapX?: number mapX?: number
mapY?: number mapY?: number
lang: 'zh-CN' | 'en-US' lang: GuideAudioLanguage
title: string title: string
description?: string description?: string
coverImageUrl?: string coverImageUrl?: string
@@ -109,7 +111,7 @@ export interface GuideStopInfo {
playTargetId: string playTargetId: string
hasAudio: boolean hasAudio: boolean
hasText: boolean hasText: boolean
supportedLanguages: string[] supportedLanguages: GuideAudioLanguage[]
audioStatus: 'READY' | 'MISSING' | string audioStatus: 'READY' | 'MISSING' | string
reason?: string reason?: string
linkedExhibitCount?: number linkedExhibitCount?: number
@@ -120,7 +122,7 @@ export interface GuideAudioPlayInfo {
playable: boolean playable: boolean
targetType: AudioPlayTargetType targetType: AudioPlayTargetType
targetId: string targetId: string
lang: 'zh-CN' | 'en-US' lang: GuideAudioLanguage
narrationTier?: 'STANDARD' | 'EXTENDED' narrationTier?: 'STANDARD' | 'EXTENDED'
audioId?: string audioId?: string
title?: string title?: string
@@ -139,7 +141,7 @@ export interface GuideAudioTextInfo {
available: boolean available: boolean
targetType: AudioPlayTargetType targetType: AudioPlayTargetType
targetId: string targetId: string
lang: 'zh-CN' | 'en-US' lang: GuideAudioLanguage
narrationTier?: 'STANDARD' | 'EXTENDED' narrationTier?: 'STANDARD' | 'EXTENDED'
title?: string title?: string
text?: string text?: string
@@ -166,8 +168,25 @@ const normalizeTargetType = (value: string | null | undefined, fallback: AudioPl
return normalized === 'ITEM' || normalized === 'STOP' ? normalized : fallback return normalized === 'ITEM' || normalized === 'STOP' ? normalized : fallback
} }
const normalizeLanguage = (value: string | null | undefined): 'zh-CN' | 'en-US' => ( export const normalizeGuideAudioLanguage = (
value === 'en-US' ? 'en-US' : 'zh-CN' value: string | null | undefined
): GuideAudioLanguage => {
const normalized = value?.trim().toLowerCase()
if (normalized === 'en' || normalized === 'en-us') return 'en-US'
if (normalized === 'yue' || normalized === 'yue-cn' || normalized === 'yue-hk') return 'yue-HK'
return 'zh-CN'
}
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[]
) )
const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls'] | BackendGuideStopLinkedExhibit['galleryUrls']) => { const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls'] | BackendGuideStopLinkedExhibit['galleryUrls']) => {
@@ -223,7 +242,7 @@ export const toGuideStopInfo = (
fallback: { fallback: {
targetType: AudioPlayTargetType targetType: AudioPlayTargetType
targetId: string targetId: string
lang: 'zh-CN' | 'en-US' lang: GuideAudioLanguage
} }
): GuideStopInfo => { ): GuideStopInfo => {
const targetType = normalizeTargetType(source.targetType, fallback.targetType) const targetType = normalizeTargetType(source.targetType, fallback.targetType)
@@ -246,7 +265,7 @@ export const toGuideStopInfo = (
floorId: stringifyId(source.floorId) || undefined, floorId: stringifyId(source.floorId) || undefined,
mapX: normalizeNumber(source.mapX), mapX: normalizeNumber(source.mapX),
mapY: normalizeNumber(source.mapY), mapY: normalizeNumber(source.mapY),
lang: normalizeLanguage(source.lang || fallback.lang), lang: normalizeGuideAudioLanguage(source.lang || fallback.lang),
title: source.title?.trim() || '讲解内容', title: source.title?.trim() || '讲解内容',
description: source.description?.trim() || undefined, description: source.description?.trim() || undefined,
coverImageUrl, coverImageUrl,
@@ -258,7 +277,7 @@ export const toGuideStopInfo = (
playTargetId, playTargetId,
hasAudio: source.hasAudio === true, hasAudio: source.hasAudio === true,
hasText: source.hasText === true, hasText: source.hasText === true,
supportedLanguages: source.supportedLanguages || [], supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
audioStatus: source.audioStatus || 'MISSING', audioStatus: source.audioStatus || 'MISSING',
reason: source.reason || undefined, reason: source.reason || undefined,
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount), linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
@@ -271,7 +290,7 @@ export const toGuideAudioPlayInfo = (
fallback: { fallback: {
targetType: AudioPlayTargetType targetType: AudioPlayTargetType
targetId: string targetId: string
lang: 'zh-CN' | 'en-US' lang: GuideAudioLanguage
} }
): GuideAudioPlayInfo => { ): GuideAudioPlayInfo => {
const targetType = normalizeTargetType(source.targetType, fallback.targetType) const targetType = normalizeTargetType(source.targetType, fallback.targetType)
@@ -281,7 +300,7 @@ export const toGuideAudioPlayInfo = (
playable: source.playable === true, playable: source.playable === true,
targetType, targetType,
targetId, targetId,
lang: normalizeLanguage(source.lang || fallback.lang), lang: normalizeGuideAudioLanguage(source.lang || fallback.lang),
narrationTier: normalizeNarrationTier(source.narrationTier), narrationTier: normalizeNarrationTier(source.narrationTier),
audioId: stringifyId(source.audioId) || undefined, audioId: stringifyId(source.audioId) || undefined,
title: source.title?.trim() || undefined, title: source.title?.trim() || undefined,
@@ -302,7 +321,7 @@ export const toGuideAudioTextInfo = (
fallback: { fallback: {
targetType: AudioPlayTargetType targetType: AudioPlayTargetType
targetId: string targetId: string
lang: 'zh-CN' | 'en-US' lang: GuideAudioLanguage
} }
): GuideAudioTextInfo => { ): GuideAudioTextInfo => {
const targetType = normalizeTargetType(source.targetType, fallback.targetType) const targetType = normalizeTargetType(source.targetType, fallback.targetType)
@@ -312,7 +331,7 @@ export const toGuideAudioTextInfo = (
available: source.available === true, available: source.available === true,
targetType, targetType,
targetId, targetId,
lang: normalizeLanguage(source.lang || fallback.lang), lang: normalizeGuideAudioLanguage(source.lang || fallback.lang),
narrationTier: normalizeNarrationTier(source.narrationTier), narrationTier: normalizeNarrationTier(source.narrationTier),
title: source.title?.trim() || undefined, title: source.title?.trim() || undefined,
text: source.text || undefined, text: source.text || undefined,

View File

@@ -73,9 +73,7 @@ const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject
const normalizeKeyword = (keyword = '') => keyword.trim() const normalizeKeyword = (keyword = '') => keyword.trim()
const catalogLang = () => ( const catalogLang = () => dataSourceConfig.audioLanguage
dataSourceConfig.audioLanguage === 'en-US' ? 'en-US' : 'zh-CN'
)
const cacheKey = (...parts: string[]) => [catalogLang(), ...parts].join(':') const cacheKey = (...parts: string[]) => [catalogLang(), ...parts].join(':')

2
src/env.d.ts vendored
View File

@@ -15,7 +15,7 @@ interface ImportMetaEnv {
readonly VITE_GUIDE_STATIC_DATA_BASE_URL?: string readonly VITE_GUIDE_STATIC_DATA_BASE_URL?: string
readonly VITE_API_BASE_URL?: string readonly VITE_API_BASE_URL?: string
readonly VITE_AUDIO_API_BASE_URL?: string readonly VITE_AUDIO_API_BASE_URL?: string
readonly VITE_AUDIO_LANGUAGE?: 'zh-CN' | 'en-US' readonly VITE_AUDIO_LANGUAGE?: 'zh-CN' | 'yue-HK' | 'en-US'
readonly VITE_SGS_API_BASE_URL?: string readonly VITE_SGS_API_BASE_URL?: string
readonly VITE_SGS_MAP_ID?: string readonly VITE_SGS_MAP_ID?: string
readonly VITE_SGS_SDK_SCRIPT_URL?: string readonly VITE_SGS_SDK_SCRIPT_URL?: string

View File

@@ -32,7 +32,10 @@
v-for="option in contentLanguageOptions" v-for="option in contentLanguageOptions"
:key="option.value" :key="option.value"
class="language-anchor-option" class="language-anchor-option"
:class="{ active: selectedAudioLanguage === option.value }" :class="{
active: selectedAudioLanguage === option.value,
disabled: languageSwitchLoading
}"
@tap="handleLanguageChange(option.value)" @tap="handleLanguageChange(option.value)"
> >
<text class="language-anchor-text">{{ option.label }}</text> <text class="language-anchor-text">{{ option.label }}</text>
@@ -52,7 +55,10 @@
{{ paragraph }} {{ paragraph }}
</text> </text>
</template> </template>
<text v-if="currentDetailTextLoading" class="section-hint">正在加载讲解正文</text> <text v-if="currentDetailTextLoading" class="section-hint">{{ detailLoadingMessage }}</text>
<text v-else-if="audioAvailabilityMessage" class="section-hint audio-status-hint">
{{ audioAvailabilityMessage }}
</text>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
@@ -129,6 +135,7 @@ import {
EXPLAIN_DETAIL_PLACEHOLDER_IMAGE EXPLAIN_DETAIL_PLACEHOLDER_IMAGE
} from '@/utils/placeholders' } from '@/utils/placeholders'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment' import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
import { normalizeGuideAudioLanguage } from '@/data/adapters/guideStopInfoAdapter'
const defaultDetail: ExplainDetailPageViewModel = { const defaultDetail: ExplainDetailPageViewModel = {
id: '', id: '',
@@ -151,7 +158,9 @@ const globalAudioPlayer = useGlobalAudioPlayer()
const activeTopTab = ref<GuideTopTab>('explain') const activeTopTab = ref<GuideTopTab>('explain')
const resolvedHallId = ref('') const resolvedHallId = ref('')
const retryingAudio = ref(false) const retryingAudio = 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
@@ -159,14 +168,17 @@ const detailEntryRequest = ref<{
} | null>(null) } | null>(null)
const detailTextByLanguage = ref<Record<AudioLanguage, string>>({ const detailTextByLanguage = ref<Record<AudioLanguage, string>>({
'zh-CN': defaultDetail.body, 'zh-CN': defaultDetail.body,
'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>>({ const detailTextLoadingByLanguage = ref<Record<AudioLanguage, boolean>>({
'zh-CN': false, 'zh-CN': false,
'yue-HK': false,
'en-US': false 'en-US': false
}) })
const detailTextLoadedByLanguage = ref<Record<AudioLanguage, boolean>>({ const detailTextLoadedByLanguage = ref<Record<AudioLanguage, boolean>>({
'zh-CN': false, 'zh-CN': false,
'yue-HK': false,
'en-US': false 'en-US': false
}) })
// 讲解详情页位置入口暂未开放,避免误导用户进入位置预览流程。 // 讲解详情页位置入口暂未开放,避免误导用户进入位置预览流程。
@@ -181,6 +193,10 @@ const contentLanguageOptions: Array<{
value: 'zh-CN', value: 'zh-CN',
label: '中文' label: '中文'
}, },
{
value: 'yue-HK',
label: '粤语'
},
{ {
value: 'en-US', value: 'en-US',
label: 'English' label: 'English'
@@ -188,9 +204,15 @@ const contentLanguageOptions: Array<{
] ]
const isAudioLanguage = (value: unknown): value is AudioLanguage => ( const isAudioLanguage = (value: unknown): value is AudioLanguage => (
value === 'zh-CN' || value === 'en-US' value === 'zh-CN' || value === 'yue-HK' || value === 'en-US'
) )
const audioLanguageLabel = (lang: AudioLanguage) => {
if (lang === 'en-US') return 'English'
if (lang === 'yue-HK') return '粤语'
return '中文'
}
const isRealHeroImage = (image?: string) => ( const isRealHeroImage = (image?: string) => (
Boolean(image && image !== EXPLAIN_DETAIL_PLACEHOLDER_IMAGE) Boolean(image && image !== EXPLAIN_DETAIL_PLACEHOLDER_IMAGE)
) )
@@ -222,7 +244,7 @@ const isCurrentDetailAudioTarget = computed(() => {
) )
}) })
const isDetailAudioDockVisible = computed(() => ( const isDetailAudioDockVisible = computed(() => (
exhibit.value.audio.status === 'playable' !languageSwitchLoading.value && exhibit.value.audio.status === 'playable'
)) ))
const audioDockTitle = computed(() => exhibit.value.title || '讲解内容') const audioDockTitle = computed(() => exhibit.value.title || '讲解内容')
const formatDetailAudioTime = (seconds?: number) => { const formatDetailAudioTime = (seconds?: number) => {
@@ -249,7 +271,7 @@ const detailAudioTimeLabel = computed(() => (
`${formatDetailAudioTime(detailAudioCurrentSeconds.value)} / ${formatDetailAudioTime(detailAudioDurationSeconds.value)}` `${formatDetailAudioTime(detailAudioCurrentSeconds.value)} / ${formatDetailAudioTime(detailAudioDurationSeconds.value)}`
)) ))
const audioDockSubtitle = computed(() => [ const audioDockSubtitle = computed(() => [
selectedAudioLanguage.value === 'en-US' ? 'English' : '中文', audioLanguageLabel(selectedAudioLanguage.value),
detailAudioTimeLabel.value detailAudioTimeLabel.value
].filter(Boolean).join(' · ') || '讲解') ].filter(Boolean).join(' · ') || '讲解')
const detailAudioPlaying = computed(() => ( const detailAudioPlaying = computed(() => (
@@ -346,26 +368,6 @@ const loadFullDetailTextForLanguage = async (
} }
} }
const loadBilingualDetailText = async (
request: NonNullable<typeof detailEntryRequest.value>,
currentLang: AudioLanguage,
currentViewModel: ExplainDetailPageViewModel
) => {
await loadFullDetailTextForLanguage(request, currentLang, currentViewModel)
const nextLang: AudioLanguage = currentLang === 'zh-CN' ? 'en-US' : 'zh-CN'
void loadFullDetailTextForLanguage(request, nextLang)
}
const ensureDetailTextForLanguage = async (lang: AudioLanguage) => {
if (detailTextLoadedByLanguage.value[lang] || detailTextLoadingByLanguage.value[lang]) return
if (!detailEntryRequest.value) {
applyDetailText(lang)
return
}
await loadFullDetailTextForLanguage(detailEntryRequest.value, lang)
}
const loadExplainDetail = async ( const loadExplainDetail = async (
request: NonNullable<typeof detailEntryRequest.value>, request: NonNullable<typeof detailEntryRequest.value>,
lang: AudioLanguage lang: AudioLanguage
@@ -382,7 +384,7 @@ const loadExplainDetail = async (
resolvedHallId.value = exhibitData.hallId resolvedHallId.value = exhibitData.hallId
} }
void loadBilingualDetailText(request, lang, exhibit.value) void loadFullDetailTextForLanguage(request, lang, exhibit.value)
} }
onLoad(async (options: any = {}) => { onLoad(async (options: any = {}) => {
@@ -405,7 +407,9 @@ onLoad(async (options: any = {}) => {
? String(exhibitId) ? String(exhibitId)
: rawTargetId ? String(rawTargetId) : undefined : rawTargetId ? String(rawTargetId) : undefined
const rawLang = Array.isArray(options.lang) ? options.lang[0] : options.lang const rawLang = Array.isArray(options.lang) ? options.lang[0] : options.lang
const lang = isAudioLanguage(rawLang) ? rawLang : 'zh-CN' const lang = typeof rawLang === 'string'
? normalizeGuideAudioLanguage(rawLang)
: 'zh-CN'
try { try {
detailEntryRequest.value = { detailEntryRequest.value = {
@@ -481,7 +485,19 @@ 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(() => detailTextLoadingByLanguage.value[selectedAudioLanguage.value]) const currentDetailTextLoading = computed(() => (
languageSwitchLoading.value || detailTextLoadingByLanguage.value[selectedAudioLanguage.value]
))
const detailLoadingMessage = computed(() => (
languageSwitchLoading.value
? `正在切换到${audioLanguageLabel(selectedAudioLanguage.value)}`
: '正在加载讲解正文'
))
const audioAvailabilityMessage = computed(() => (
exhibit.value.audio.status === 'playable'
? ''
: exhibit.value.audio.unavailableReason || '当前语言暂无语音讲解'
))
const replaceDetailRouteLanguage = (lang: AudioLanguage) => { const replaceDetailRouteLanguage = (lang: AudioLanguage) => {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
@@ -493,15 +509,64 @@ const replaceDetailRouteLanguage = (lang: AudioLanguage) => {
} }
const handleLanguageChange = async (lang: AudioLanguage, options: { syncAudio?: boolean } = {}) => { const handleLanguageChange = async (lang: AudioLanguage, options: { syncAudio?: boolean } = {}) => {
if (lang === selectedAudioLanguage.value) return if (lang === selectedAudioLanguage.value || languageSwitchLoading.value) return
const shouldSyncAudio = options.syncAudio !== false && isCurrentDetailAudioTarget.value const shouldSyncAudio = options.syncAudio !== false && isCurrentDetailAudioTarget.value
const request = detailEntryRequest.value
const requestSequence = ++languageSwitchSequence
selectedAudioLanguage.value = lang selectedAudioLanguage.value = lang
replaceDetailRouteLanguage(lang) replaceDetailRouteLanguage(lang)
void ensureDetailTextForLanguage(lang) languageSwitchLoading.value = true
if (shouldSyncAudio && globalAudioPlayer.currentSource.value?.lang !== lang) { const audioSwitch = shouldSyncAudio && globalAudioPlayer.currentSource.value?.lang !== lang
await globalAudioPlayer.switchLanguage(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,
language: lang,
unavailableReason: '讲解服务暂不可用,请稍后重试'
}
}
applyDetailText(lang)
uni.showToast({
title: '讲解语言切换失败,请稍后重试',
icon: 'none'
})
} finally {
if (requestSequence === languageSwitchSequence) {
languageSwitchLoading.value = false
}
} }
} }
@@ -517,9 +582,7 @@ watch(
&& source?.targetType === targetType && source?.targetType === targetType
&& source.targetId === targetId && source.targetId === targetId
) { ) {
selectedAudioLanguage.value = lang void handleLanguageChange(lang, { syncAudio: false })
replaceDetailRouteLanguage(lang)
void ensureDetailTextForLanguage(lang)
} }
} }
) )
@@ -1178,6 +1241,15 @@ const handleBack = () => {
color: #0f140d; color: #0f140d;
} }
.language-anchor-option.disabled {
opacity: 0.62;
pointer-events: none;
}
.language-anchor-option.active.disabled {
opacity: 1;
}
.language-anchor-text { .language-anchor-text {
max-width: 100%; max-width: 100%;
font-size: 18px; font-size: 18px;
@@ -1261,9 +1333,17 @@ const handleBack = () => {
} }
.section-hint { .section-hint {
display: block;
margin-top: 16px;
font-size: 15px;
line-height: 22px;
color: #6d7568; color: #6d7568;
} }
.audio-status-hint {
color: #7a5a22;
}
.detail-audio-dock { .detail-audio-dock {
position: fixed; position: fixed;
left: 50%; left: 50%;

View File

@@ -13,10 +13,11 @@ import {
type BackendGuideStopInfo, type BackendGuideStopInfo,
type GuideAudioPlayInfo, type GuideAudioPlayInfo,
type GuideAudioTextInfo, type GuideAudioTextInfo,
type GuideAudioLanguage,
type GuideStopInfo type GuideStopInfo
} from '@/data/adapters/guideStopInfoAdapter' } from '@/data/adapters/guideStopInfoAdapter'
export type AudioLanguage = 'zh-CN' | 'en-US' export type AudioLanguage = GuideAudioLanguage
export interface AudioPlayInfoRequest { export interface AudioPlayInfoRequest {
targetType: AudioPlayTargetType targetType: AudioPlayTargetType

View File

@@ -144,24 +144,32 @@ export class ExplainUseCase {
} }
private languageLabel(language: AudioLanguage) { private languageLabel(language: AudioLanguage) {
return language === 'en-US' ? '英文' : '文' if (language === 'en-US') return '文'
if (language === 'yue-HK') return '粤语'
return '中文'
} }
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 mandarinText = exhibit.audioVariants?.['zh-CN']?.text
|| exhibit.guideText
|| exhibit.description
const supportedLanguages = exhibit.supportedLanguages?.length const supportedLanguages = exhibit.supportedLanguages?.length
? exhibit.supportedLanguages ? exhibit.supportedLanguages
: exhibit.audioVariants ? Object.keys(exhibit.audioVariants) : [language] : exhibit.audioVariants ? Object.keys(exhibit.audioVariants) : [language]
if (!variant) { if (!variant) {
const label = this.languageLabel(language) const label = this.languageLabel(language)
const guideText = language === 'yue-HK'
? mandarinText || '当前讲解词暂未配置。'
: `${label}讲解词暂未配置。`
return { return {
...exhibit, ...exhibit,
guideText: `${label}讲解词暂未配置。`, guideText,
audioUrl: undefined, audioUrl: undefined,
audioDuration: undefined, audioDuration: undefined,
audioLanguage: language, audioLanguage: language,
audioHasText: false, audioHasText: language === 'yue-HK' && Boolean(mandarinText),
audioAvailable: false, audioAvailable: false,
audioStatus: 'MISSING', audioStatus: 'MISSING',
audioUnavailableReason: `当前讲解暂无${label}音频`, audioUnavailableReason: `当前讲解暂无${label}音频`,
@@ -172,11 +180,13 @@ export class ExplainUseCase {
return { return {
...exhibit, ...exhibit,
guideTitle: variant.title || exhibit.guideTitle, guideTitle: variant.title || exhibit.guideTitle,
guideText: variant.text || `${this.languageLabel(language)}讲解词暂未配置。`, guideText: language === 'yue-HK'
? mandarinText || '当前讲解词暂未配置。'
: variant.text || `${this.languageLabel(language)}讲解词暂未配置。`,
audioUrl: variant.audioUrl, audioUrl: variant.audioUrl,
audioDuration: variant.audioDuration, audioDuration: variant.audioDuration,
audioLanguage: language, audioLanguage: language,
audioHasText: variant.hasText === true, audioHasText: language === 'yue-HK' ? Boolean(mandarinText) : variant.hasText === true,
audioAvailable: variant.available === true && Boolean(variant.audioUrl), audioAvailable: variant.available === true && Boolean(variant.audioUrl),
audioStatus: variant.available && variant.audioUrl ? 'READY' : 'MISSING', audioStatus: variant.available && variant.audioUrl ? 'READY' : 'MISSING',
audioUnavailableReason: variant.available && variant.audioUrl audioUnavailableReason: variant.available && variant.audioUrl
@@ -413,12 +423,13 @@ export class ExplainUseCase {
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
const lang = (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage)
try { try {
const textInfo = await this.audioPlayInfo.getTextInfo({ const textInfo = await this.audioPlayInfo.getTextInfo({
targetType, targetType,
targetId, targetId,
lang: (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage) lang
}) })
const nextExhibit = this.applyTextInfo(exhibit, textInfo) const nextExhibit = this.applyTextInfo(exhibit, textInfo)
@@ -436,7 +447,7 @@ export class ExplainUseCase {
available: false, available: false,
targetType, targetType,
targetId, targetId,
lang: dataSourceConfig.audioLanguage as AudioLanguage, lang,
reason: 'SERVICE_ERROR' reason: 'SERVICE_ERROR'
}, },
available: false, available: false,

View File

@@ -137,6 +137,7 @@ const formatDuration = (duration?: number) => (
const languageLabelFor = (language?: string) => { const languageLabelFor = (language?: string) => {
if (language === 'en-US') return '英文' if (language === 'en-US') return '英文'
if (language === 'yue-HK') return '粤语'
if (language === 'zh-CN') return '中文' if (language === 'zh-CN') return '中文'
return language || undefined return language || undefined
} }

View File

@@ -0,0 +1,84 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const repositoryMocks = vi.hoisted(() => ({
getPlayInfo: vi.fn()
}))
vi.mock('@/repositories/AudioPlayInfoRepository', () => ({
audioPlayInfoRepository: {
getPlayInfo: repositoryMocks.getPlayInfo
},
audioReasonToText: () => '当前语言暂无语音讲解'
}))
import { useGlobalAudioPlayer } from '@/composables/useGlobalAudioPlayer'
describe('全局讲解播放器语言切换', () => {
const player = useGlobalAudioPlayer()
beforeEach(() => {
player.close()
repositoryMocks.getPlayInfo.mockReset()
vi.stubGlobal('uni', {
showToast: vi.fn()
})
vi.spyOn(window.HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined)
vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined)
vi.spyOn(window.HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined)
})
afterEach(() => {
player.close()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('切换到粤语时立即中断旧音频,并使用 yue-HK 播放地址', async () => {
repositoryMocks.getPlayInfo.mockResolvedValue({
playable: true,
targetType: 'STOP',
targetId: '1823450596800289',
lang: 'yue-HK',
audioId: 'cantonese-audio',
playUrl: '/museum-assets/audio/cantonese.mp3',
hasText: true,
fallback: false
})
await player.play({
id: 'mandarin-audio',
name: '测试讲解',
audioUrl: '/museum-assets/audio/mandarin.mp3',
language: 'zh-CN'
}, {
source: {
targetType: 'STOP',
targetId: '1823450596800289',
lang: 'zh-CN'
}
})
const pauseSpy = vi.mocked(window.HTMLMediaElement.prototype.pause)
pauseSpy.mockClear()
const switching = player.switchLanguage('yue-HK')
expect(pauseSpy).toHaveBeenCalledTimes(1)
expect(player.playing.value).toBe(false)
expect(player.currentTime.value).toBe(0)
expect(repositoryMocks.getPlayInfo).toHaveBeenCalledWith(expect.objectContaining({
targetType: 'STOP',
targetId: '1823450596800289',
lang: 'yue-HK',
refresh: true
}))
await expect(switching).resolves.toBe(true)
expect(player.currentSource.value?.lang).toBe('yue-HK')
expect(player.currentAudio.value).toEqual(expect.objectContaining({
language: 'yue-HK',
audioUrl: '/museum-assets/audio/cantonese.mp3'
}))
})
})