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,120 @@
import type {
ExplainTrack,
MuseumExhibit,
MuseumHall,
SearchIndexItem
} from '@/domain/museum'
import {
mediaRepository,
type MediaRepository
} from '@/repositories/MediaRepository'
import {
museumContentRepository,
type MuseumContentRepository
} from '@/repositories/MuseumContentRepository'
export interface ExplainRepository {
listExhibits(): Promise<MuseumExhibit[]>
getExhibitById(id: string): Promise<MuseumExhibit | null>
listHalls(): Promise<MuseumHall[]>
getHallById(id: string): Promise<MuseumHall | null>
listTracks(): Promise<ExplainTrack[]>
getTrackByExhibitId(exhibitId: string): Promise<ExplainTrack | null>
searchExplain(keyword?: string): Promise<SearchIndexItem[]>
}
const trackTitleFor = (exhibit: MuseumExhibit) => `${exhibit.name}讲解`
export class DefaultExplainRepository implements ExplainRepository {
constructor(
private readonly content: MuseumContentRepository = museumContentRepository,
private readonly media: MediaRepository = mediaRepository
) {}
listExhibits() {
return this.content.listExhibits()
}
getExhibitById(id: string) {
return this.content.getExhibitById(id)
}
listHalls() {
return this.content.listHalls()
}
getHallById(id: string) {
return this.content.getHallById(id)
}
async listTracks() {
const exhibits = await this.content.listExhibits()
return Promise.all(exhibits.map(async (exhibit): Promise<ExplainTrack> => {
const media = await this.media.getMediaForExplainTrack(exhibit.id)
return {
id: `track-${exhibit.id}`,
exhibitId: exhibit.id,
hallId: exhibit.hallId,
title: trackTitleFor(exhibit),
summary: exhibit.description,
mediaId: media?.id,
coverImage: exhibit.image,
poiId: exhibit.poiId,
floorId: exhibit.floorId,
available: media?.available === true
}
}))
}
async getTrackByExhibitId(exhibitId: string) {
const tracks = await this.listTracks()
return tracks.find((track) => track.exhibitId === exhibitId) || null
}
async searchExplain(keyword = '') {
const normalizedKeyword = keyword.trim().toLowerCase()
const [exhibits, halls] = await Promise.all([
this.content.listExhibits(),
this.content.listHalls()
])
const exhibitItems = exhibits
.filter((exhibit) => !normalizedKeyword || [
exhibit.name,
exhibit.hallName,
exhibit.floorLabel,
...(exhibit.tags || [])
].filter(Boolean).join(' ').toLowerCase().includes(normalizedKeyword))
.map<SearchIndexItem>((exhibit) => ({
id: exhibit.id,
name: exhibit.name,
desc: `${exhibit.hallName || '展项'} · ${exhibit.floorLabel || ''}`.trim(),
type: 'exhibit',
exhibitId: exhibit.id,
hallId: exhibit.hallId,
poiId: exhibit.poiId,
floorId: exhibit.floorId
}))
const hallItems = halls
.filter((hall) => !normalizedKeyword || [
hall.name,
hall.floorLabel,
hall.description
].filter(Boolean).join(' ').toLowerCase().includes(normalizedKeyword))
.map<SearchIndexItem>((hall) => ({
id: hall.id,
name: hall.name,
desc: `${hall.floorLabel || ''} · ${hall.exhibitCount || 0}件展项`.trim(),
type: 'hall',
hallId: hall.id,
poiId: hall.poiId,
floorId: hall.floorId
}))
return [...exhibitItems, ...hallItems]
}
}
export const explainRepository = new DefaultExplainRepository()

View File

@@ -0,0 +1,64 @@
import type {
GuideModelFloorAsset,
GuideModelRenderPackage,
GuideModelSource,
GuideRenderPoi
} from '@/domain/guideModel'
import {
formatNavFloorLabel
} from '@/data/adapters/navAssetsAdapter'
import {
defaultStaticNavAssetsProvider,
type StaticNavAssetsProvider,
type StaticNavPoiPayload
} from '@/data/providers/staticNavAssetsProvider'
const toRenderPoi = (poi: StaticNavPoiPayload): GuideRenderPoi => ({
id: poi.id,
name: poi.name,
floorId: poi.floorId,
primaryCategory: poi.primaryCategory,
primaryCategoryZh: poi.primaryCategoryZh,
iconType: poi.iconType || poi.primaryCategory,
positionGltf: poi.positionGltf
})
export interface GuideModelRepository extends GuideModelSource {}
export class StaticGuideModelRepository implements GuideModelRepository {
constructor(private readonly provider: StaticNavAssetsProvider = defaultStaticNavAssetsProvider) {}
async loadPackage(): Promise<GuideModelRenderPackage> {
const [manifest, floorIndex] = await Promise.all([
this.provider.loadManifest(),
this.provider.loadFloorIndex()
])
const floors: GuideModelFloorAsset[] = [...floorIndex.floors]
.sort((a, b) => a.order - b.order)
.map((floor) => ({
floorId: floor.floorId,
label: formatNavFloorLabel(floor.floorId),
order: floor.order,
modelUrl: this.provider.assetUrl(floor.modelAsset)
}))
return {
overviewModelUrl: this.provider.assetUrl(manifest.assets.overviewModel.asset),
floors
}
}
async loadFloorPois(floorId: string) {
const floorIndex = await this.provider.loadFloorIndex()
const floor = floorIndex.floors.find((item) => item.floorId === floorId)
if (!floor) return []
const pois = await this.provider.loadFloorPois(floor.poiDataAsset)
return pois
.map(toRenderPoi)
.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
}
}
export const guideModelRepository = new StaticGuideModelRepository()

View File

@@ -0,0 +1,122 @@
import type {
GuideLocationPreview,
MuseumFloor,
MuseumPoi
} from '@/domain/museum'
import {
NAV_ROUTE_READINESS
} from '@/domain/guideReadiness'
import {
formatNavFloorLabel,
navFloorIdFromLabel,
toMuseumPoi
} from '@/data/adapters/navAssetsAdapter'
import {
defaultStaticNavAssetsProvider,
type StaticNavAssetsProvider
} from '@/data/providers/staticNavAssetsProvider'
const searchableCategories = new Set([
'touring_poi',
'basic_service_facility',
'transport_circulation',
'accessibility_special_service',
'operation_experience'
])
const toSearchText = (poi: MuseumPoi) => [
poi.name,
poi.floorId,
poi.floorLabel,
poi.primaryCategory.label,
poi.primaryCategory.iconType,
...poi.categories.flatMap((category) => [
category.label,
category.id,
category.iconType
])
]
.filter(Boolean)
.join(' ')
.toLowerCase()
const toLocationPreview = (poi: MuseumPoi): GuideLocationPreview => ({
poiId: poi.id,
name: poi.name,
floorId: poi.floorId,
floorLabel: poi.floorLabel,
primaryCategoryZh: poi.primaryCategory.label,
positionGltf: poi.positionGltf
})
export interface GuideRepository {
getAssetBaseUrl(): string
getFloors(): Promise<MuseumFloor[]>
normalizeFloorId(labelOrId: string): string
listPois(): Promise<MuseumPoi[]>
getPoiById(id: string): Promise<MuseumPoi | null>
searchPois(keyword?: string): Promise<MuseumPoi[]>
getLocationPreview(poiId: string): Promise<GuideLocationPreview | null>
getRouteReadiness(): typeof NAV_ROUTE_READINESS
}
export class StaticGuideRepository implements GuideRepository {
private poiCache: MuseumPoi[] | null = null
constructor(private readonly provider: StaticNavAssetsProvider = defaultStaticNavAssetsProvider) {}
getAssetBaseUrl() {
return this.provider.baseUrl
}
async getFloors() {
const floorIndex = await this.provider.loadFloorIndex()
return [...floorIndex.floors]
.sort((a, b) => b.order - a.order)
.map((floor) => ({
id: floor.floorId,
label: formatNavFloorLabel(floor.floorId),
order: floor.order
}))
}
normalizeFloorId(labelOrId: string) {
return navFloorIdFromLabel(labelOrId)
}
async listPois() {
if (this.poiCache) return this.poiCache
const pois = await this.provider.loadPoiIndex()
this.poiCache = pois
.filter((poi) => searchableCategories.has(poi.primaryCategory))
.map(toMuseumPoi)
return this.poiCache
}
async getPoiById(id: string) {
const pois = await this.listPois()
return pois.find((poi) => poi.id === id) || null
}
async searchPois(keyword = '') {
const pois = await this.listPois()
const normalizedKeyword = keyword.trim().toLowerCase()
if (!normalizedKeyword) return pois
return pois.filter((poi) => toSearchText(poi).includes(normalizedKeyword))
}
async getLocationPreview(poiId: string) {
const poi = await this.getPoiById(poiId)
return poi ? toLocationPreview(poi) : null
}
getRouteReadiness() {
return NAV_ROUTE_READINESS
}
}
export const guideRepository = new StaticGuideRepository()

View File

@@ -0,0 +1,27 @@
import type {
MediaAsset
} from '@/domain/museum'
export interface MediaRepository {
getMediaById(id: string): Promise<MediaAsset | null>
getMediaForExplainTrack(trackId: string): Promise<MediaAsset | null>
}
const unavailableAudio = (id: string): MediaAsset => ({
id,
type: 'audio',
available: false,
unavailableReason: '正式讲解音频尚未接入媒体仓储'
})
export class DefaultMediaRepository implements MediaRepository {
async getMediaById(id: string) {
return unavailableAudio(id)
}
async getMediaForExplainTrack(trackId: string) {
return unavailableAudio(`media-${trackId}`)
}
}
export const mediaRepository = new DefaultMediaRepository()

View File

@@ -0,0 +1,124 @@
import type {
MuseumExhibit,
MuseumHall,
MuseumPoi,
SearchIndexItem
} from '@/domain/museum'
import {
guideRepository,
type GuideRepository
} from '@/repositories/GuideRepository'
import {
staticMuseumContentProvider,
type MuseumContentProvider
} from '@/data/providers/staticMuseumContentProvider'
export interface MuseumContentRepository {
listFacilities(): Promise<MuseumPoi[]>
getFacilityById(id: string): Promise<MuseumPoi | null>
searchFacilities(keyword?: string): Promise<MuseumPoi[]>
listExhibits(): Promise<MuseumExhibit[]>
getExhibitById(id: string): Promise<MuseumExhibit | null>
listHalls(): Promise<MuseumHall[]>
getHallById(id: string): Promise<MuseumHall | null>
searchContent(keyword?: string): Promise<SearchIndexItem[]>
}
const normalize = (value: string) => value.trim().toLowerCase()
const includesKeyword = (values: Array<string | undefined>, keyword: string) => (
values.filter(Boolean).join(' ').toLowerCase().includes(keyword)
)
export class DefaultMuseumContentRepository implements MuseumContentRepository {
constructor(
private readonly contentProvider: MuseumContentProvider = staticMuseumContentProvider,
private readonly guide: GuideRepository = guideRepository
) {}
listFacilities() {
return this.guide.listPois()
}
getFacilityById(id: string) {
return this.guide.getPoiById(id)
}
searchFacilities(keyword = '') {
return this.guide.searchPois(keyword)
}
listExhibits() {
return this.contentProvider.listExhibits()
}
async getExhibitById(id: string) {
const exhibits = await this.contentProvider.listExhibits()
return exhibits.find((exhibit) => exhibit.id === id) || null
}
listHalls() {
return this.contentProvider.listHalls()
}
async getHallById(id: string) {
const halls = await this.contentProvider.listHalls()
return halls.find((hall) => hall.id === id) || null
}
async searchContent(keyword = '') {
const normalizedKeyword = normalize(keyword)
const facilities = await this.searchFacilities(keyword)
const exhibits = await this.listExhibits()
const halls = await this.listHalls()
const exhibitItems = exhibits
.filter((exhibit) => !normalizedKeyword || includesKeyword([
exhibit.name,
exhibit.hallName,
exhibit.floorLabel,
...(exhibit.tags || [])
], normalizedKeyword))
.map<SearchIndexItem>((exhibit) => ({
id: exhibit.id,
name: exhibit.name,
desc: `${exhibit.hallName || '展项'} · ${exhibit.floorLabel || ''}`.trim(),
type: 'exhibit',
exhibitId: exhibit.id,
poiId: exhibit.poiId,
floorId: exhibit.floorId
}))
const hallItems = halls
.filter((hall) => !normalizedKeyword || includesKeyword([
hall.name,
hall.floorLabel,
hall.description
], normalizedKeyword))
.map<SearchIndexItem>((hall) => ({
id: hall.id,
name: hall.name,
desc: `${hall.floorLabel || ''} · ${hall.exhibitCount || 0}件展项`.trim(),
type: 'hall',
hallId: hall.id,
poiId: hall.poiId,
floorId: hall.floorId
}))
const facilityItems = facilities.map<SearchIndexItem>((poi) => ({
id: poi.id,
name: poi.name,
desc: `${poi.floorLabel} · ${poi.primaryCategory.label}`,
type: 'facility',
poiId: poi.id,
floorId: poi.floorId
}))
return [
...exhibitItems,
...hallItems,
...facilityItems
]
}
}
export const museumContentRepository = new DefaultMuseumContentRepository()