feat: wire guide data source and POI focus UI

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lyf
2026-06-12 09:25:22 +08:00
parent a6bfda30e1
commit 2055b13b90
58 changed files with 5768 additions and 1898 deletions

View File

@@ -0,0 +1,75 @@
import type {
ExplainTrack,
MediaAsset,
MuseumExhibit,
MuseumHall,
SearchIndexItem
} from '@/domain/museum'
import {
explainRepository,
type ExplainRepository
} from '@/repositories/ExplainRepository'
import {
mediaRepository,
type MediaRepository
} from '@/repositories/MediaRepository'
export interface ExplainAudioSelection {
exhibit: MuseumExhibit
track: ExplainTrack | null
media: MediaAsset | null
playable: boolean
unavailableMessage?: string
}
export class ExplainUseCase {
constructor(
private readonly explain: ExplainRepository = explainRepository,
private readonly media: MediaRepository = mediaRepository
) {}
listExhibits() {
return this.explain.listExhibits()
}
getExhibitById(id: string) {
return this.explain.getExhibitById(id)
}
listHalls(): Promise<MuseumHall[]> {
return this.explain.listHalls()
}
getHallById(id: string) {
return this.explain.getHallById(id)
}
async listExhibitsByHallId(hallId: string) {
const exhibits = await this.explain.listExhibits()
return exhibits.filter((exhibit) => exhibit.hallId === hallId)
}
searchExplain(keyword?: string): Promise<SearchIndexItem[]> {
return this.explain.searchExplain(keyword)
}
async selectAudioForExhibit(exhibitId: string): Promise<ExplainAudioSelection | null> {
const exhibit = await this.explain.getExhibitById(exhibitId)
if (!exhibit) return null
const track = await this.explain.getTrackByExhibitId(exhibitId)
const media = track?.mediaId
? await this.media.getMediaById(track.mediaId)
: null
return {
exhibit,
track,
media,
playable: media?.available === true && Boolean(media.url),
unavailableMessage: media?.unavailableReason || '该展项暂无讲解音频'
}
}
}
export const explainUseCase = new ExplainUseCase()

View File

@@ -0,0 +1,124 @@
import type {
GuideLocationPreviewPlan,
GuidePreviewStep,
GuideStartLocation,
MuseumPoi
} from '@/domain/museum'
import {
guideRepository,
type GuideRepository
} from '@/repositories/GuideRepository'
import {
guideModelRepository,
type GuideModelRepository
} from '@/repositories/GuideModelRepository'
const startSourceLabels: Record<string, string> = {
'facility-detail': '设施详情选择',
manual: '手动选择'
}
const formatStartSourceLabel = (source?: string) => (
source ? startSourceLabels[source] || source : ''
)
const formatStartStepText = (startLocation: GuideStartLocation) => {
const sourceLabel = formatStartSourceLabel(startLocation.source)
const floorSuffix = startLocation.floor && !startLocation.label.includes(startLocation.floor)
? ` · ${startLocation.floor}`
: ''
const sourceSuffix = sourceLabel ? `${sourceLabel}` : ''
return `起点:${startLocation.label}${floorSuffix}${sourceSuffix}`
}
const createPreviewSteps = (
poi: MuseumPoi | null,
startLocation: GuideStartLocation | null,
routeUnavailableMessage: string
): GuidePreviewStep[] => {
if (startLocation) {
return [
{
id: 'start',
text: formatStartStepText(startLocation)
},
{
id: 'target',
text: poi
? `目标:${poi.floorLabel} · ${poi.primaryCategory.label} · 位置预览`
: '目标来自 clean 导览数据,暂仅支持位置预览'
}
]
}
return [
{
id: 'floor',
text: poi ? `目标位于 ${poi.floorLabel} · ${poi.primaryCategory.label}` : '目标来自 clean 导览数据'
},
{
id: 'status',
text: routeUnavailableMessage
}
]
}
export class GuideUseCase {
constructor(
private readonly guide: GuideRepository = guideRepository,
private readonly guideModel: GuideModelRepository = guideModelRepository
) {}
getRouteReadiness() {
return this.guide.getRouteReadiness()
}
getFloors() {
return this.guide.getFloors()
}
getAssetBaseUrl() {
return this.guide.getAssetBaseUrl()
}
getModelSource() {
return this.guideModel
}
normalizeFloorId(labelOrId: string) {
return this.guide.normalizeFloorId(labelOrId)
}
searchPois(keyword?: string) {
return this.guide.searchPois(keyword)
}
getPoiById(id: string) {
return this.guide.getPoiById(id)
}
async createLocationPreviewPlan(
facilityId: string,
target: string,
startLocation: GuideStartLocation | null = null
): Promise<GuideLocationPreviewPlan> {
const poi = facilityId ? await this.guide.getPoiById(facilityId) : null
const routeReadiness = this.guide.getRouteReadiness()
const targetLocation = poi ? await this.guide.getLocationPreview(poi.id) : null
return {
id: `route-${facilityId || 'custom'}`,
facilityId,
target: target || poi?.name || '目标地点',
summary: poi
? `${poi.floorLabel} · ${poi.primaryCategory.label} · 位置预览`
: '位置预览 · 尚未接入正式路线图',
steps: createPreviewSteps(poi, startLocation, routeReadiness.message),
targetLocation,
startLocation
}
}
}
export const guideUseCase = new GuideUseCase()