chore: freeze guide and explain update

This commit is contained in:
lyf
2026-06-24 18:00:25 +08:00
parent feb7310a46
commit 67c6609ae6
104 changed files with 3203572 additions and 40713 deletions

View File

@@ -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: '语音服务暂不可用,请稍后重试'
}
}
}
}

View 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()

View File

@@ -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
}