747 lines
24 KiB
TypeScript
747 lines
24 KiB
TypeScript
import type {
|
|
AudioPlayTargetType,
|
|
ExplainBusinessUnit,
|
|
ExplainGuideStop,
|
|
ExplainGuideStopPage,
|
|
ExplainTrack,
|
|
MediaAsset,
|
|
MuseumExhibit,
|
|
MuseumHall,
|
|
SearchIndexItem
|
|
} from '@/domain/museum'
|
|
import {
|
|
explainRepository,
|
|
type ExplainRepository
|
|
} from '@/repositories/ExplainRepository'
|
|
import {
|
|
audioPlayInfoRepository,
|
|
audioReasonToText,
|
|
type AudioLanguage,
|
|
type AudioPlayInfo,
|
|
type AudioPlayInfoRepository,
|
|
type GuideStopInfoRequest,
|
|
type AudioTextInfo
|
|
} from '@/repositories/AudioPlayInfoRepository'
|
|
import type {
|
|
GuideStopInfo
|
|
} from '@/data/adapters/guideStopInfoAdapter'
|
|
import {
|
|
dataSourceConfig
|
|
} from '@/config/dataSource'
|
|
import {
|
|
normalizeExplainDetailTargetFromGuideStop
|
|
} from '@/domain/explainDetailTarget'
|
|
|
|
export interface ExplainAudioSelection {
|
|
exhibit: MuseumExhibit
|
|
track: ExplainTrack | null
|
|
media: MediaAsset | null
|
|
playable: boolean
|
|
unavailableMessage?: string
|
|
playInfo?: AudioPlayInfo
|
|
}
|
|
|
|
export interface ExplainDetailEntryRequest {
|
|
exhibitId: string
|
|
targetType?: AudioPlayTargetType
|
|
targetId?: string
|
|
lang?: AudioLanguage
|
|
// Navigation context is non-authoritative and keeps deep links independent of catalog loading.
|
|
hallId?: string
|
|
hallName?: string
|
|
floorId?: string
|
|
floorLabel?: string
|
|
poiId?: string
|
|
}
|
|
|
|
export interface ExplainTextSelection {
|
|
exhibit: MuseumExhibit
|
|
textInfo: AudioTextInfo
|
|
available: boolean
|
|
unavailableMessage?: string
|
|
}
|
|
|
|
export interface ExhibitDetailAudioOptions {
|
|
enrichAudio?: boolean
|
|
includeText?: boolean
|
|
}
|
|
|
|
export interface ExplainHallSummary {
|
|
hallId: string
|
|
guideStopCount: number
|
|
}
|
|
|
|
export class ExplainUseCase {
|
|
constructor(
|
|
private readonly explain: ExplainRepository = explainRepository,
|
|
private readonly audioPlayInfo: AudioPlayInfoRepository = audioPlayInfoRepository
|
|
) {}
|
|
|
|
private applyTrackAudioState(exhibit: MuseumExhibit, track?: ExplainTrack | null): MuseumExhibit {
|
|
const hasPlayableAudioUrl = Boolean(exhibit.audioUrl?.trim())
|
|
const hasTrackTarget = Boolean(track?.playTargetType || track?.playTargetId)
|
|
|
|
if (!hasPlayableAudioUrl && !hasTrackTarget) return exhibit
|
|
|
|
return {
|
|
...exhibit,
|
|
audioAvailable: hasPlayableAudioUrl,
|
|
playTargetType: track?.playTargetType || exhibit.playTargetType,
|
|
playTargetId: track?.playTargetId || exhibit.playTargetId
|
|
}
|
|
}
|
|
|
|
private resolveAudioTarget(exhibit: MuseumExhibit, track?: ExplainTrack | null) {
|
|
const targetType: AudioPlayTargetType = track?.playTargetType
|
|
|| exhibit.playTargetType
|
|
|| 'ITEM'
|
|
const targetId = track?.playTargetId
|
|
|| exhibit.playTargetId
|
|
|| exhibit.id
|
|
|
|
return { targetType, targetId }
|
|
}
|
|
|
|
private resolveStopInfoAudioTarget(stopInfo: GuideStopInfo) {
|
|
return {
|
|
targetType: stopInfo.playTargetType || stopInfo.targetType,
|
|
targetId: stopInfo.playTargetId || stopInfo.targetId
|
|
}
|
|
}
|
|
|
|
private async resolveDetailEntryTarget(request: ExplainDetailEntryRequest): Promise<Required<GuideStopInfoRequest>> {
|
|
const lang = request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
|
|
if (request.targetType && request.targetId) {
|
|
return {
|
|
targetType: request.targetType,
|
|
targetId: request.targetId,
|
|
lang
|
|
}
|
|
}
|
|
|
|
return {
|
|
targetType: 'ITEM',
|
|
targetId: request.exhibitId,
|
|
lang
|
|
}
|
|
}
|
|
|
|
private async resolveStaticDetailFallback(
|
|
request: ExplainDetailEntryRequest,
|
|
entryTarget: Required<GuideStopInfoRequest>
|
|
): Promise<MuseumExhibit | null> {
|
|
if (entryTarget.targetType !== 'ITEM' && entryTarget.targetType !== 'STOP') {
|
|
return null
|
|
}
|
|
|
|
const candidateIds = Array.from(new Set([
|
|
request.exhibitId,
|
|
entryTarget.targetId
|
|
].map((id) => id?.trim()).filter(Boolean)))
|
|
|
|
for (const candidateId of candidateIds) {
|
|
const exhibit = await this.explain.getExhibitById(candidateId).catch(() => null)
|
|
if (exhibit) return exhibit
|
|
}
|
|
|
|
return this.explain.listExplainExhibits()
|
|
.then((items) => items.find((item) => candidateIds.includes(item.id)) || null)
|
|
.catch(() => null)
|
|
}
|
|
|
|
private languageLabel(language: AudioLanguage) {
|
|
if (language === 'en-US') return '英文'
|
|
if (language === 'yue-HK') return '粤语'
|
|
return '中文'
|
|
}
|
|
|
|
private applyLanguageVariant(exhibit: MuseumExhibit, language: AudioLanguage): MuseumExhibit {
|
|
const variant = exhibit.audioVariants?.[language]
|
|
const mandarinText = exhibit.audioVariants?.['zh-CN']?.text
|
|
|| exhibit.guideText
|
|
|| exhibit.description
|
|
const supportedLanguages = exhibit.supportedLanguages?.length
|
|
? exhibit.supportedLanguages
|
|
: exhibit.audioVariants ? Object.keys(exhibit.audioVariants) : [language]
|
|
|
|
if (!variant) {
|
|
const label = this.languageLabel(language)
|
|
const guideText = language === 'yue-HK'
|
|
? mandarinText || '当前讲解词暂未配置。'
|
|
: `${label}讲解词暂未配置。`
|
|
return {
|
|
...exhibit,
|
|
guideText,
|
|
audioUrl: undefined,
|
|
audioDuration: undefined,
|
|
audioLanguage: language,
|
|
audioHasText: language === 'yue-HK' && Boolean(mandarinText),
|
|
audioAvailable: false,
|
|
audioStatus: 'MISSING',
|
|
audioUnavailableReason: `当前讲解暂无${label}音频`,
|
|
supportedLanguages
|
|
}
|
|
}
|
|
|
|
const playable = (variant.playable ?? variant.available) === true && Boolean(variant.audioUrl)
|
|
const textAvailable = variant.textAvailable ?? (variant.hasText === true || Boolean(variant.text))
|
|
const guideText = variant.text
|
|
|| (language === 'yue-HK' ? mandarinText : undefined)
|
|
|| `${this.languageLabel(language)}讲解词暂未配置。`
|
|
|
|
return {
|
|
...exhibit,
|
|
guideTitle: variant.title || exhibit.guideTitle,
|
|
guideText,
|
|
audioUrl: playable ? variant.audioUrl : undefined,
|
|
audioDuration: variant.audioDuration,
|
|
audioLanguage: language,
|
|
audioHasText: textAvailable,
|
|
audioText: variant.text || undefined,
|
|
audioTextLength: variant.textLength,
|
|
audioTextHash: variant.textHash,
|
|
audioNarrationTier: variant.narrationTier,
|
|
audioAvailable: playable,
|
|
audioStatus: variant.audioStatus || (playable ? 'READY' : 'MISSING'),
|
|
audioUnavailableReason: playable
|
|
? undefined
|
|
: audioReasonToText(variant.reason || 'NO_PUBLISHED_AUDIO'),
|
|
supportedLanguages
|
|
}
|
|
}
|
|
|
|
private toExhibitFromStopInfo(
|
|
stopInfo: GuideStopInfo,
|
|
fallback?: MuseumExhibit | null,
|
|
navigationContext?: ExplainDetailEntryRequest
|
|
): MuseumExhibit {
|
|
const linkedPrimary = stopInfo.linkedExhibits[0]
|
|
const coverImage = stopInfo.imageStatus === 'READY'
|
|
? stopInfo.coverImageUrl
|
|
: undefined
|
|
const description = stopInfo.description || fallback?.description || '该讲解暂无简介。'
|
|
const audioTarget = this.resolveStopInfoAudioTarget(stopInfo)
|
|
const audioVariants = Object.fromEntries(Object.values(stopInfo.languageVariants).map((variant) => [variant.lang, {
|
|
title: stopInfo.title,
|
|
text: variant.text,
|
|
textAvailable: variant.textAvailable,
|
|
textLength: variant.textLength,
|
|
textHash: variant.textHash,
|
|
audioUrl: variant.playUrl,
|
|
audioDuration: variant.duration,
|
|
format: variant.format,
|
|
audioId: variant.audioId,
|
|
narrationTier: variant.narrationTier,
|
|
hasText: variant.hasText,
|
|
enabled: variant.enabled,
|
|
playable: variant.playable,
|
|
available: variant.playable,
|
|
audioStatus: variant.audioStatus,
|
|
fallback: variant.fallback,
|
|
reason: variant.reason
|
|
}]))
|
|
const currentVariant = audioVariants[stopInfo.lang]
|
|
const audioAvailable = currentVariant?.playable === true && Boolean(currentVariant.audioUrl)
|
|
|
|
return {
|
|
...(fallback || {}),
|
|
id: fallback?.id || linkedPrimary?.id || stopInfo.targetId,
|
|
name: stopInfo.title || fallback?.name || linkedPrimary?.name || '讲解内容',
|
|
hallId: fallback?.hallId || navigationContext?.hallId,
|
|
hallName: fallback?.hallName || navigationContext?.hallName,
|
|
floorId: stopInfo.floorId || fallback?.floorId || navigationContext?.floorId,
|
|
floorLabel: fallback?.floorLabel || navigationContext?.floorLabel,
|
|
image: coverImage,
|
|
description,
|
|
poiId: stopInfo.poiId || fallback?.poiId || navigationContext?.poiId,
|
|
sourcePoiId: stopInfo.poiId || fallback?.sourcePoiId,
|
|
mapX: stopInfo.mapX ?? fallback?.mapX,
|
|
mapY: stopInfo.mapY ?? fallback?.mapY,
|
|
location: fallback?.location,
|
|
year: fallback?.year,
|
|
material: fallback?.material,
|
|
size: fallback?.size,
|
|
tags: fallback?.tags,
|
|
guideTitle: stopInfo.title || fallback?.guideTitle,
|
|
guideText: currentVariant?.text || stopInfo.description || fallback?.guideText || fallback?.description,
|
|
audioUrl: audioAvailable ? currentVariant?.audioUrl : undefined,
|
|
audioDuration: currentVariant?.audioDuration,
|
|
audioLanguage: stopInfo.lang,
|
|
audioHasText: currentVariant?.textAvailable || stopInfo.hasText,
|
|
audioText: currentVariant?.text,
|
|
audioTextLength: currentVariant?.textLength,
|
|
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,
|
|
audioVariants,
|
|
imageStatus: stopInfo.imageStatus,
|
|
imageSource: stopInfo.imageSource,
|
|
galleryUrls: stopInfo.imageStatus === 'READY' ? stopInfo.galleryUrls : [],
|
|
linkedExhibitCount: stopInfo.linkedExhibitCount,
|
|
isSharedStop: stopInfo.isSharedStop,
|
|
linkedExhibits: stopInfo.linkedExhibits,
|
|
stopInfoAvailable: stopInfo.available,
|
|
stopInfoReason: stopInfo.reason,
|
|
resolvedStopId: stopInfo.resolvedStopId,
|
|
playTargetType: audioTarget.targetType,
|
|
playTargetId: audioTarget.targetId
|
|
}
|
|
}
|
|
|
|
private applyPlayInfo(
|
|
exhibit: MuseumExhibit,
|
|
playInfo: AudioPlayInfo
|
|
): MuseumExhibit {
|
|
const apiPlayable = playInfo.playable === true && Boolean(playInfo.playUrl)
|
|
const nextAudioUrl = apiPlayable ? playInfo.playUrl || undefined : undefined
|
|
const nextAudioDuration = typeof playInfo.duration === 'number'
|
|
? playInfo.duration
|
|
: exhibit.audioDuration
|
|
|
|
return {
|
|
...exhibit,
|
|
guideTitle: playInfo.title || exhibit.guideTitle,
|
|
audioUrl: nextAudioUrl,
|
|
audioDuration: nextAudioDuration,
|
|
audioLanguage: playInfo.lang || exhibit.audioLanguage,
|
|
audioSubtitleUrl: playInfo.subtitleUrl || exhibit.audioSubtitleUrl,
|
|
audioHasText: playInfo.hasText === true || exhibit.audioHasText,
|
|
audioNarrationTier: playInfo.narrationTier || exhibit.audioNarrationTier,
|
|
audioUnavailableReason: apiPlayable ? undefined : audioReasonToText(playInfo.reason),
|
|
audioAvailable: apiPlayable,
|
|
audioStatus: apiPlayable ? 'READY' : 'MISSING'
|
|
}
|
|
}
|
|
|
|
private applyTextInfo(exhibit: MuseumExhibit, textInfo: AudioTextInfo | null): MuseumExhibit {
|
|
if (!textInfo?.available || !textInfo.text) return exhibit
|
|
|
|
return {
|
|
...exhibit,
|
|
guideTitle: textInfo.title || exhibit.guideTitle,
|
|
guideText: textInfo.text,
|
|
audioText: textInfo.text,
|
|
audioTextLength: textInfo.textLength || exhibit.audioTextLength,
|
|
audioTextHash: textInfo.textHash || exhibit.audioTextHash,
|
|
audioLanguage: textInfo.lang || exhibit.audioLanguage,
|
|
audioNarrationTier: textInfo.narrationTier || exhibit.audioNarrationTier,
|
|
audioHasText: true
|
|
}
|
|
}
|
|
|
|
private async enrichExhibitAudio(
|
|
exhibit: MuseumExhibit,
|
|
options: {
|
|
includeText?: boolean
|
|
track?: ExplainTrack | null
|
|
} = {}
|
|
): Promise<MuseumExhibit> {
|
|
const track = options.track ?? await this.explain.getTrackByExhibitId(exhibit.id).catch(() => null)
|
|
const baseExhibit = this.applyTrackAudioState(exhibit, track)
|
|
const { targetType, targetId } = this.resolveAudioTarget(baseExhibit, track)
|
|
|
|
try {
|
|
const playInfo = await this.audioPlayInfo.getPlayInfo({ targetType, targetId })
|
|
let nextExhibit = this.applyPlayInfo(baseExhibit, playInfo)
|
|
|
|
if (options.includeText && playInfo.hasText) {
|
|
try {
|
|
const textInfo = await this.audioPlayInfo.getTextInfo({
|
|
targetType,
|
|
targetId,
|
|
lang: playInfo.lang
|
|
})
|
|
nextExhibit = this.applyTextInfo(nextExhibit, textInfo)
|
|
} catch (error) {
|
|
console.warn('讲解词正文加载失败,将使用静态讲解文稿:', error)
|
|
}
|
|
}
|
|
|
|
return nextExhibit
|
|
} catch (error) {
|
|
console.warn('讲解播放信息加载失败,将使用静态讲解数据:', error)
|
|
return baseExhibit
|
|
}
|
|
}
|
|
|
|
private async getTrackByExhibitIdMap() {
|
|
const tracks = await this.explain.listTracks()
|
|
const trackByExhibitId = new Map<string, ExplainTrack>()
|
|
|
|
tracks.forEach((track) => {
|
|
if (track.exhibitId && !trackByExhibitId.has(track.exhibitId)) {
|
|
trackByExhibitId.set(track.exhibitId, track)
|
|
}
|
|
})
|
|
|
|
return trackByExhibitId
|
|
}
|
|
|
|
async listExhibits() {
|
|
return this.listExplainExhibits()
|
|
}
|
|
|
|
async listExplainExhibits() {
|
|
const exhibits = await this.explain.listExplainExhibits()
|
|
|
|
try {
|
|
const trackByExhibitId = await this.getTrackByExhibitIdMap()
|
|
return exhibits.map((exhibit) => this.applyTrackAudioState(exhibit, trackByExhibitId.get(exhibit.id)))
|
|
} catch (error) {
|
|
console.warn('讲解音频状态加载失败,将使用讲解列表原始音频状态:', error)
|
|
return exhibits
|
|
}
|
|
}
|
|
|
|
async listFullExhibits() {
|
|
const exhibits = await this.explain.listExhibits()
|
|
|
|
try {
|
|
const trackByExhibitId = await this.getTrackByExhibitIdMap()
|
|
return exhibits.map((exhibit) => this.applyTrackAudioState(exhibit, trackByExhibitId.get(exhibit.id)))
|
|
} catch (error) {
|
|
console.warn('讲解音频状态加载失败,将使用展项原始音频状态:', error)
|
|
return exhibits
|
|
}
|
|
}
|
|
|
|
async getExhibitById(id: string, options: ExhibitDetailAudioOptions = {}) {
|
|
const exhibit = await this.explain.getExhibitById(id)
|
|
if (!exhibit) return null
|
|
|
|
try {
|
|
const track = await this.explain.getTrackByExhibitId(id)
|
|
const baseExhibit = this.applyTrackAudioState(exhibit, track)
|
|
if (!options.enrichAudio) {
|
|
return baseExhibit
|
|
}
|
|
|
|
return this.enrichExhibitAudio(exhibit, {
|
|
includeText: options.includeText,
|
|
track
|
|
})
|
|
} catch (error) {
|
|
console.warn('讲解详情音频状态加载失败,将使用展项原始音频状态:', error)
|
|
return exhibit
|
|
}
|
|
}
|
|
|
|
async enrichExhibitDetailAudio(id: string) {
|
|
return this.getExhibitById(id, {
|
|
enrichAudio: true,
|
|
includeText: true
|
|
})
|
|
}
|
|
|
|
async enterExplainDetail(request: ExplainDetailEntryRequest) {
|
|
const entryTarget = await this.resolveDetailEntryTarget(request)
|
|
// Remote stop-info is the authoritative detail source. Never make its first paint wait for catalog.
|
|
if (dataSourceConfig.explainContentMode === 'remote') {
|
|
const stopInfo = await this.audioPlayInfo.getStopInfo(entryTarget)
|
|
return this.toExhibitFromStopInfo(stopInfo, null, request)
|
|
}
|
|
|
|
const fallbackExhibit = await this.resolveStaticDetailFallback(request, entryTarget)
|
|
|
|
if (fallbackExhibit) {
|
|
return this.applyLanguageVariant(fallbackExhibit, entryTarget.lang)
|
|
}
|
|
|
|
const stopInfoResult = await this.audioPlayInfo.getStopInfo(entryTarget)
|
|
.then((stopInfo) => ({ stopInfo, error: null }))
|
|
.catch((error) => ({ stopInfo: null, error }))
|
|
|
|
if (stopInfoResult.stopInfo) {
|
|
return this.toExhibitFromStopInfo(stopInfoResult.stopInfo, fallbackExhibit, request)
|
|
}
|
|
|
|
throw stopInfoResult.error || new Error('讲解详情加载失败')
|
|
}
|
|
|
|
selectExplainDetailLanguage(exhibit: MuseumExhibit, language: AudioLanguage) {
|
|
return this.applyLanguageVariant(exhibit, language)
|
|
}
|
|
|
|
async loadExplainDetailText(exhibit: MuseumExhibit): Promise<ExplainTextSelection> {
|
|
const targetType = exhibit.playTargetType || 'ITEM'
|
|
const targetId = exhibit.playTargetId || exhibit.id
|
|
const lang = (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage)
|
|
|
|
try {
|
|
const textInfo = await this.audioPlayInfo.getTextInfo({
|
|
targetType,
|
|
targetId,
|
|
lang
|
|
})
|
|
const nextExhibit = this.applyTextInfo(exhibit, textInfo)
|
|
|
|
return {
|
|
exhibit: nextExhibit,
|
|
textInfo,
|
|
available: textInfo.available === true && Boolean(textInfo.text),
|
|
unavailableMessage: textInfo.available ? undefined : audioReasonToText(textInfo.reason || 'NO_TEXT')
|
|
}
|
|
} catch (error) {
|
|
console.error('讲解词正文解析失败:', error)
|
|
return {
|
|
exhibit,
|
|
textInfo: {
|
|
available: false,
|
|
targetType,
|
|
targetId,
|
|
lang,
|
|
reason: 'SERVICE_ERROR'
|
|
},
|
|
available: false,
|
|
unavailableMessage: '讲解词服务暂不可用,请稍后重试'
|
|
}
|
|
}
|
|
}
|
|
|
|
listHalls(): Promise<MuseumHall[]> {
|
|
return this.explain.listHalls()
|
|
}
|
|
|
|
loadExplainHalls(): Promise<MuseumHall[]> {
|
|
return this.listHalls()
|
|
}
|
|
|
|
async loadExplainHallSummaries(hallIds: string[]): Promise<Record<string, ExplainHallSummary>> {
|
|
const uniqueHallIds = Array.from(new Set(hallIds.map((id) => id.trim()).filter(Boolean)))
|
|
const halls = await this.listHalls().catch(() => [])
|
|
const summariesFromHallList = new Map(halls.map((hall) => [hall.id, hall]))
|
|
const entries = await Promise.all(uniqueHallIds.map(async (hallId) => {
|
|
const hall = summariesFromHallList.get(hallId)
|
|
if (hall && (typeof hall.outlineCount === 'number' || typeof hall.stopCount === 'number')) {
|
|
return [hallId, {
|
|
hallId,
|
|
guideStopCount: hall.stopCount || 0
|
|
}] as const
|
|
}
|
|
|
|
try {
|
|
const page = await this.listGuideStopsPageByHall(hallId, 1, 1)
|
|
return [hallId, {
|
|
hallId,
|
|
guideStopCount: page.total
|
|
}] as const
|
|
} catch (error) {
|
|
console.warn('讲解展厅统计加载失败:', hallId, error)
|
|
return [hallId, {
|
|
hallId,
|
|
guideStopCount: 0
|
|
}] as const
|
|
}
|
|
}))
|
|
|
|
return Object.fromEntries(entries)
|
|
}
|
|
|
|
listGuideStopsPageByHall(hallId: string, pageNo: number, pageSize: number): Promise<ExplainGuideStopPage> {
|
|
return this.explain.listGuideStopsPageByHall(hallId, pageNo, pageSize)
|
|
}
|
|
|
|
getHallById(id: string) {
|
|
return this.explain.getHallById(id)
|
|
}
|
|
|
|
loadTemporaryBusinessUnitsByHall(hallId: string): Promise<ExplainBusinessUnit[]> {
|
|
return this.explain.listTemporaryBusinessUnitsByHall(hallId)
|
|
}
|
|
|
|
async selectHall(hallId: string) {
|
|
const [hall, units] = await Promise.all([
|
|
this.getHallById(hallId),
|
|
this.loadTemporaryBusinessUnitsByHall(hallId)
|
|
])
|
|
|
|
return { hall, units }
|
|
}
|
|
|
|
async selectBusinessUnit(hallId: string, unitId: string) {
|
|
const units = await this.loadTemporaryBusinessUnitsByHall(hallId)
|
|
return units.find((unit) => unit.id === unitId) || null
|
|
}
|
|
|
|
async listGuideStopsByBusinessUnit(hallId: string, unitId: string): Promise<ExplainGuideStop[]> {
|
|
return this.explain.listGuideStopsByBusinessUnit(hallId, unitId)
|
|
}
|
|
|
|
openGuideStopDetail(stopId: string) {
|
|
const target = normalizeExplainDetailTargetFromGuideStop({ id: stopId })
|
|
return this.enterExplainDetail({
|
|
exhibitId: target.targetId,
|
|
targetType: target.targetType,
|
|
targetId: target.targetId
|
|
})
|
|
}
|
|
|
|
async listExhibitsByHallId(hallId: string) {
|
|
return this.explain.listExplainExhibitsByHall(hallId)
|
|
}
|
|
|
|
searchExplain(keyword?: string): Promise<SearchIndexItem[]> {
|
|
return this.explain.searchExplain(keyword)
|
|
}
|
|
|
|
async selectAudioForExhibit(exhibitId: string): Promise<ExplainAudioSelection | null> {
|
|
const track = await this.explain.getTrackByExhibitId(exhibitId)
|
|
const explainExhibits = await this.explain.listExplainExhibits()
|
|
const summaryExhibit = explainExhibits.find((item) => item.id === exhibitId)
|
|
const detailExhibit = await this.explain.getExhibitById(exhibitId).catch(() => null)
|
|
const exhibit = detailExhibit || summaryExhibit
|
|
if (!exhibit) return null
|
|
|
|
const targetType: AudioPlayTargetType = track?.playTargetType
|
|
|| exhibit.playTargetType
|
|
|| 'ITEM'
|
|
const targetId = track?.playTargetId
|
|
|| exhibit.playTargetId
|
|
|| exhibit.id
|
|
|
|
const toPlayableSelection = (
|
|
playInfo: AudioPlayInfo,
|
|
requestTargetType: AudioPlayTargetType,
|
|
requestTargetId: string
|
|
): ExplainAudioSelection => {
|
|
const media: MediaAsset = {
|
|
id: `play-${playInfo.audioId || `${requestTargetType}-${requestTargetId}`}`,
|
|
type: 'audio',
|
|
url: playInfo.playUrl || undefined,
|
|
duration: typeof playInfo.duration === 'number' ? playInfo.duration : undefined,
|
|
language: playInfo.lang,
|
|
available: true
|
|
}
|
|
|
|
return {
|
|
exhibit,
|
|
track,
|
|
media,
|
|
playable: true,
|
|
playInfo
|
|
}
|
|
}
|
|
|
|
try {
|
|
const initialPlayInfo = await this.audioPlayInfo.getPlayInfo({ targetType, targetId })
|
|
|
|
if (initialPlayInfo.playable && initialPlayInfo.playUrl) {
|
|
return toPlayableSelection(initialPlayInfo, targetType, targetId)
|
|
}
|
|
|
|
return {
|
|
exhibit,
|
|
track,
|
|
media: null,
|
|
playable: false,
|
|
playInfo: initialPlayInfo,
|
|
unavailableMessage: audioReasonToText(initialPlayInfo.reason)
|
|
}
|
|
} catch (error) {
|
|
console.error('语音播放解析失败:', error)
|
|
const serviceErrorPlayInfo: AudioPlayInfo = {
|
|
playable: false,
|
|
targetType,
|
|
targetId,
|
|
lang: dataSourceConfig.audioLanguage as AudioPlayInfo['lang'],
|
|
hasText: false,
|
|
fallback: false,
|
|
reason: 'SERVICE_ERROR'
|
|
}
|
|
return {
|
|
exhibit,
|
|
track,
|
|
media: null,
|
|
playable: false,
|
|
playInfo: serviceErrorPlayInfo,
|
|
unavailableMessage: '语音服务暂不可用,当前提供图文讲解'
|
|
}
|
|
}
|
|
}
|
|
|
|
async selectAudioForExplainDetail(
|
|
exhibit: MuseumExhibit,
|
|
options: { refreshPlayInfo?: boolean } = {}
|
|
): Promise<ExplainAudioSelection> {
|
|
const targetType = exhibit.playTargetType || 'ITEM'
|
|
const targetId = exhibit.playTargetId || exhibit.id
|
|
|
|
if (!options.refreshPlayInfo && exhibit.audioUrl?.trim()) {
|
|
return {
|
|
exhibit,
|
|
track: null,
|
|
media: {
|
|
id: `static-${targetType}-${targetId}-${exhibit.audioLanguage || dataSourceConfig.audioLanguage}`,
|
|
type: 'audio',
|
|
url: exhibit.audioUrl,
|
|
duration: exhibit.audioDuration,
|
|
language: exhibit.audioLanguage,
|
|
available: true
|
|
},
|
|
playable: true
|
|
}
|
|
}
|
|
|
|
if (exhibit.audioStatus && exhibit.audioStatus !== 'READY') {
|
|
return {
|
|
exhibit,
|
|
track: null,
|
|
media: null,
|
|
playable: false,
|
|
unavailableMessage: audioReasonToText(exhibit.stopInfoReason || 'NO_PUBLISHED_AUDIO')
|
|
}
|
|
}
|
|
|
|
try {
|
|
const playInfo = await this.audioPlayInfo.getPlayInfo({
|
|
targetType,
|
|
targetId,
|
|
lang: (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage),
|
|
refresh: options.refreshPlayInfo === true
|
|
})
|
|
|
|
if (!playInfo.playable || !playInfo.playUrl) {
|
|
return {
|
|
exhibit,
|
|
track: null,
|
|
media: null,
|
|
playable: false,
|
|
playInfo,
|
|
unavailableMessage: audioReasonToText(playInfo.reason)
|
|
}
|
|
}
|
|
|
|
return {
|
|
exhibit,
|
|
track: null,
|
|
media: {
|
|
id: `play-${playInfo.audioId || `${targetType}-${targetId}`}`,
|
|
type: 'audio',
|
|
url: playInfo.playUrl,
|
|
duration: typeof playInfo.duration === 'number' ? playInfo.duration : undefined,
|
|
language: playInfo.lang,
|
|
available: true
|
|
},
|
|
playable: true,
|
|
playInfo
|
|
}
|
|
} catch (error) {
|
|
console.error('语音播放解析失败:', error)
|
|
return {
|
|
exhibit,
|
|
track: null,
|
|
media: null,
|
|
playable: false,
|
|
unavailableMessage: '语音服务暂不可用,请稍后重试'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export const explainUseCase = new ExplainUseCase()
|