chore: freeze guide and explain update
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AudioPlayTargetType,
|
||||
ExplainTrack,
|
||||
MediaAsset,
|
||||
MuseumExhibit,
|
||||
@@ -13,6 +14,16 @@ import {
|
||||
mediaRepository,
|
||||
type MediaRepository
|
||||
} from '@/repositories/MediaRepository'
|
||||
import {
|
||||
audioPlayInfoRepository,
|
||||
audioReasonToText,
|
||||
type AudioPlayInfo,
|
||||
type AudioPlayInfoRepository
|
||||
} from '@/repositories/AudioPlayInfoRepository'
|
||||
import {
|
||||
publishedExhibitAudioRepository,
|
||||
type PublishedExhibitAudioRepository
|
||||
} from '@/repositories/PublishedExhibitAudioRepository'
|
||||
|
||||
export interface ExplainAudioSelection {
|
||||
exhibit: MuseumExhibit
|
||||
@@ -20,20 +31,83 @@ export interface ExplainAudioSelection {
|
||||
media: MediaAsset | null
|
||||
playable: boolean
|
||||
unavailableMessage?: string
|
||||
playInfo?: AudioPlayInfo
|
||||
}
|
||||
|
||||
export class ExplainUseCase {
|
||||
constructor(
|
||||
private readonly explain: ExplainRepository = explainRepository,
|
||||
private readonly media: MediaRepository = mediaRepository
|
||||
private readonly media: MediaRepository = mediaRepository,
|
||||
private readonly audioPlayInfo: AudioPlayInfoRepository = audioPlayInfoRepository,
|
||||
private readonly publishedAudio: PublishedExhibitAudioRepository = publishedExhibitAudioRepository
|
||||
) {}
|
||||
|
||||
listExhibits() {
|
||||
return this.explain.listExhibits()
|
||||
private applyTrackAudioState(exhibit: MuseumExhibit, track?: ExplainTrack | null): MuseumExhibit {
|
||||
const hasExhibitAudio = exhibit.audioAvailable === true || Boolean(exhibit.audioUrl)
|
||||
const hasTrackAudio = track?.available === true
|
||||
|
||||
if (!hasExhibitAudio && !hasTrackAudio) return exhibit
|
||||
|
||||
return {
|
||||
...exhibit,
|
||||
audioAvailable: true,
|
||||
playTargetType: track?.playTargetType || exhibit.playTargetType,
|
||||
playTargetId: track?.playTargetId || exhibit.playTargetId
|
||||
}
|
||||
}
|
||||
|
||||
getExhibitById(id: string) {
|
||||
return this.explain.getExhibitById(id)
|
||||
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) {
|
||||
const exhibit = await this.explain.getExhibitById(id)
|
||||
if (!exhibit) return null
|
||||
|
||||
try {
|
||||
const track = await this.explain.getTrackByExhibitId(id)
|
||||
return this.applyTrackAudioState(exhibit, track)
|
||||
} catch (error) {
|
||||
console.warn('讲解详情音频状态加载失败,将使用展项原始音频状态:', error)
|
||||
return exhibit
|
||||
}
|
||||
}
|
||||
|
||||
listHalls(): Promise<MuseumHall[]> {
|
||||
@@ -45,7 +119,7 @@ export class ExplainUseCase {
|
||||
}
|
||||
|
||||
async listExhibitsByHallId(hallId: string) {
|
||||
const exhibits = await this.explain.listExhibits()
|
||||
const exhibits = await this.listExhibits()
|
||||
return exhibits.filter((exhibit) => exhibit.hallId === hallId)
|
||||
}
|
||||
|
||||
@@ -54,20 +128,123 @@ export class ExplainUseCase {
|
||||
}
|
||||
|
||||
async selectAudioForExhibit(exhibitId: string): Promise<ExplainAudioSelection | null> {
|
||||
const exhibit = await this.explain.getExhibitById(exhibitId)
|
||||
const track = await this.explain.getTrackByExhibitId(exhibitId)
|
||||
const explainExhibits = await this.explain.listExplainExhibits()
|
||||
const exhibit = explainExhibits.find((item) => item.id === exhibitId)
|
||||
|| await this.explain.getExhibitById(exhibitId)
|
||||
if (!exhibit) return null
|
||||
|
||||
const track = await this.explain.getTrackByExhibitId(exhibitId)
|
||||
const media = track?.mediaId
|
||||
const fallbackMedia = track?.mediaId
|
||||
? await this.media.getMediaById(track.mediaId)
|
||||
: null
|
||||
: exhibit.guideContentId
|
||||
? await this.media.getMediaById(`media-${exhibit.guideContentId}`)
|
||||
: null
|
||||
const targetType: AudioPlayTargetType = track?.playTargetType
|
||||
|| exhibit.playTargetType
|
||||
|| 'ITEM'
|
||||
const targetId = track?.playTargetId
|
||||
|| exhibit.playTargetId
|
||||
|| exhibit.id
|
||||
|
||||
return {
|
||||
exhibit,
|
||||
track,
|
||||
media,
|
||||
playable: media?.available === true && Boolean(media.url),
|
||||
unavailableMessage: media?.unavailableReason || '该展项暂无讲解音频'
|
||||
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 : fallbackMedia?.duration,
|
||||
language: playInfo.lang,
|
||||
available: true
|
||||
}
|
||||
|
||||
return {
|
||||
exhibit,
|
||||
track,
|
||||
media,
|
||||
playable: true,
|
||||
playInfo
|
||||
}
|
||||
}
|
||||
|
||||
const toDirectMediaSelection = (playInfo: AudioPlayInfo): ExplainAudioSelection | null => {
|
||||
const directMedia = fallbackMedia?.available && fallbackMedia.url
|
||||
? fallbackMedia
|
||||
: exhibit.audioUrl
|
||||
? {
|
||||
id: `media-${exhibit.guideContentId || exhibit.id}`,
|
||||
type: 'audio' as const,
|
||||
url: exhibit.audioUrl,
|
||||
duration: exhibit.audioDuration,
|
||||
language: playInfo.lang,
|
||||
available: true
|
||||
}
|
||||
: null
|
||||
|
||||
if (!directMedia?.url) return null
|
||||
|
||||
return {
|
||||
exhibit,
|
||||
track,
|
||||
media: directMedia,
|
||||
playable: true,
|
||||
playInfo: {
|
||||
...playInfo,
|
||||
playable: true,
|
||||
audioId: directMedia.id,
|
||||
title: track?.title || exhibit.guideTitle || exhibit.name,
|
||||
duration: directMedia.duration || playInfo.duration,
|
||||
playUrl: directMedia.url,
|
||||
fallback: true,
|
||||
fallbackReason: playInfo.reason || 'DIRECT_MEDIA_FALLBACK'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const initialPlayInfo = await this.audioPlayInfo.getPlayInfo({ targetType, targetId })
|
||||
|
||||
if (initialPlayInfo.playable && initialPlayInfo.playUrl) {
|
||||
return toPlayableSelection(initialPlayInfo, targetType, targetId)
|
||||
}
|
||||
|
||||
if (initialPlayInfo.reason === 'TARGET_NOT_FOUND') {
|
||||
const publishedResolution = await this.publishedAudio.resolveByExhibitName(exhibit.name, initialPlayInfo.lang)
|
||||
if (publishedResolution?.targets.length) {
|
||||
for (const candidate of publishedResolution.targets) {
|
||||
if (candidate.targetType === targetType && candidate.targetId === targetId) continue
|
||||
|
||||
const playInfo = await this.audioPlayInfo.getPlayInfo(candidate)
|
||||
if (playInfo.playable && playInfo.playUrl) {
|
||||
return toPlayableSelection(playInfo, candidate.targetType, candidate.targetId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const directMediaSelection = toDirectMediaSelection(initialPlayInfo)
|
||||
if (directMediaSelection) return directMediaSelection
|
||||
}
|
||||
|
||||
return {
|
||||
exhibit,
|
||||
track,
|
||||
media: null,
|
||||
playable: false,
|
||||
playInfo: initialPlayInfo,
|
||||
unavailableMessage: audioReasonToText(initialPlayInfo.reason)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('语音播放解析失败:', error)
|
||||
|
||||
return {
|
||||
exhibit,
|
||||
track,
|
||||
media: null,
|
||||
playable: false,
|
||||
unavailableMessage: '语音服务暂不可用,请稍后重试'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
93
src/usecases/guideRouteUseCase.ts
Normal file
93
src/usecases/guideRouteUseCase.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type {
|
||||
GuideRouteReadiness,
|
||||
GuideRouteResult,
|
||||
GuideRouteTarget
|
||||
} from '@/domain/museum'
|
||||
import {
|
||||
GuideRouteError,
|
||||
guideRouteRepository,
|
||||
type GuideRouteRepository
|
||||
} from '@/repositories/GuideRouteRepository'
|
||||
|
||||
const toSearchText = (target: GuideRouteTarget) => [
|
||||
target.name,
|
||||
target.floorId,
|
||||
target.floorLabel,
|
||||
target.categoryLabel
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
export interface GuideRoutePlanRequest {
|
||||
startPoiId: string
|
||||
endPoiId: string
|
||||
}
|
||||
|
||||
export interface GuideRoutePlanResult {
|
||||
route: GuideRouteResult | null
|
||||
error: string
|
||||
}
|
||||
|
||||
export class GuideRouteUseCase {
|
||||
private targetsCache: GuideRouteTarget[] | null = null
|
||||
private readinessCache: GuideRouteReadiness | null = null
|
||||
|
||||
constructor(private readonly repository: GuideRouteRepository = guideRouteRepository) {}
|
||||
|
||||
async getRouteReadiness() {
|
||||
if (this.readinessCache) return this.readinessCache
|
||||
|
||||
this.readinessCache = await this.repository.getRouteReadiness()
|
||||
return this.readinessCache
|
||||
}
|
||||
|
||||
async listTargets() {
|
||||
if (this.targetsCache) return this.targetsCache
|
||||
|
||||
this.targetsCache = await this.repository.listRouteTargets()
|
||||
return this.targetsCache
|
||||
}
|
||||
|
||||
async searchTargets(keyword = '') {
|
||||
const targets = await this.listTargets()
|
||||
const normalizedKeyword = keyword.trim().toLowerCase()
|
||||
|
||||
if (!normalizedKeyword) return targets
|
||||
|
||||
return targets.filter((target) => toSearchText(target).includes(normalizedKeyword))
|
||||
}
|
||||
|
||||
async planRoute(request: GuideRoutePlanRequest): Promise<GuideRoutePlanResult> {
|
||||
try {
|
||||
const readiness = await this.getRouteReadiness()
|
||||
if (!readiness.ready) {
|
||||
return {
|
||||
route: null,
|
||||
error: readiness.message || '路线数据暂不可用'
|
||||
}
|
||||
}
|
||||
|
||||
const route = await this.repository.findRoute(request.startPoiId, request.endPoiId)
|
||||
return {
|
||||
route,
|
||||
error: ''
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
route: null,
|
||||
error: this.toUserMessage(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private toUserMessage(error: unknown) {
|
||||
if (error instanceof GuideRouteError) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
return error instanceof Error ? error.message : '路线预览生成失败'
|
||||
}
|
||||
}
|
||||
|
||||
export const guideRouteUseCase = new GuideRouteUseCase()
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
GuideRouteReadiness,
|
||||
GuideLocationPreviewPlan,
|
||||
GuidePreviewStep,
|
||||
GuideStartLocation,
|
||||
@@ -12,6 +13,9 @@ import {
|
||||
guideModelRepository,
|
||||
type GuideModelRepository
|
||||
} from '@/repositories/GuideModelRepository'
|
||||
import {
|
||||
NAV_ROUTE_READINESS
|
||||
} from '@/domain/guideReadiness'
|
||||
|
||||
const startSourceLabels: Record<string, string> = {
|
||||
'facility-detail': '设施详情选择',
|
||||
@@ -35,7 +39,7 @@ const formatStartStepText = (startLocation: GuideStartLocation) => {
|
||||
const createPreviewSteps = (
|
||||
poi: MuseumPoi | null,
|
||||
startLocation: GuideStartLocation | null,
|
||||
routeUnavailableMessage: string
|
||||
routeReadiness: GuideRouteReadiness
|
||||
): GuidePreviewStep[] => {
|
||||
if (startLocation) {
|
||||
return [
|
||||
@@ -47,7 +51,7 @@ const createPreviewSteps = (
|
||||
id: 'target',
|
||||
text: poi
|
||||
? `目标:${poi.floorLabel} · ${poi.primaryCategory.label} · 位置预览`
|
||||
: '目标来自 clean 导览数据,暂仅支持位置预览'
|
||||
: '目标来自三维导览点位数据'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -55,23 +59,34 @@ const createPreviewSteps = (
|
||||
return [
|
||||
{
|
||||
id: 'floor',
|
||||
text: poi ? `目标位于 ${poi.floorLabel} · ${poi.primaryCategory.label}` : '目标来自 clean 导览数据'
|
||||
text: poi ? `目标位于 ${poi.floorLabel} · ${poi.primaryCategory.label}` : '目标来自三维导览点位数据'
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
text: routeUnavailableMessage
|
||||
text: routeReadiness.message
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export class GuideUseCase {
|
||||
private routeReadinessCache: GuideRouteReadiness | null = null
|
||||
|
||||
constructor(
|
||||
private readonly guide: GuideRepository = guideRepository,
|
||||
private readonly guideModel: GuideModelRepository = guideModelRepository
|
||||
) {}
|
||||
|
||||
getRouteReadiness() {
|
||||
return this.guide.getRouteReadiness()
|
||||
async getRouteReadiness(): Promise<GuideRouteReadiness> {
|
||||
if (this.routeReadinessCache) return this.routeReadinessCache
|
||||
|
||||
const routeReadiness = await this.guide.getRouteReadiness()
|
||||
.catch(() => NAV_ROUTE_READINESS)
|
||||
this.routeReadinessCache = NAV_ROUTE_READINESS.ready ? routeReadiness : NAV_ROUTE_READINESS
|
||||
return this.routeReadinessCache
|
||||
}
|
||||
|
||||
getRouteReadinessSnapshot(): GuideRouteReadiness {
|
||||
return this.routeReadinessCache || NAV_ROUTE_READINESS
|
||||
}
|
||||
|
||||
getFloors() {
|
||||
@@ -104,7 +119,7 @@ export class GuideUseCase {
|
||||
startLocation: GuideStartLocation | null = null
|
||||
): Promise<GuideLocationPreviewPlan> {
|
||||
const poi = facilityId ? await this.guide.getPoiById(facilityId) : null
|
||||
const routeReadiness = this.guide.getRouteReadiness()
|
||||
const routeReadiness = await this.getRouteReadiness()
|
||||
const targetLocation = poi ? await this.guide.getLocationPreview(poi.id) : null
|
||||
|
||||
return {
|
||||
@@ -112,9 +127,9 @@ export class GuideUseCase {
|
||||
facilityId,
|
||||
target: target || poi?.name || '目标地点',
|
||||
summary: poi
|
||||
? `${poi.floorLabel} · ${poi.primaryCategory.label} · 位置预览`
|
||||
: '位置预览 · 尚未接入正式路线图',
|
||||
steps: createPreviewSteps(poi, startLocation, routeReadiness.message),
|
||||
? `${poi.floorLabel} · ${poi.primaryCategory.label} · 三维位置预览`
|
||||
: '位置预览 · 请选择可查看的三维点位',
|
||||
steps: createPreviewSteps(poi, startLocation, routeReadiness),
|
||||
targetLocation,
|
||||
startLocation
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user