提交室内导览交互与讲解优化

This commit is contained in:
lyf
2026-06-14 23:48:13 +08:00
parent a7c1879f60
commit feb7310a46
33 changed files with 3257 additions and 361 deletions

View File

@@ -3,172 +3,109 @@ import type {
MuseumHall
} from '@/domain/museum'
import {
formatNavFloorLabel
} from '@/data/adapters/navAssetsAdapter'
import {
defaultStaticNavAssetsProvider,
type StaticNavAssetsProvider,
type StaticNavPoiPayload
} from '@/data/providers/staticNavAssetsProvider'
SGS_SCENE_EXPLAIN_DATASET,
type SgsSceneExhibitMock,
type SgsSceneSpaceMock
} from '@/data/mock/sgsSceneExplainData'
export interface MuseumContentProvider {
listExhibits(): Promise<MuseumExhibit[]>
listHalls(): Promise<MuseumHall[]>
}
const contentImage = '/static/exhibit-placeholder.jpg'
const hallImage = '/static/hall-placeholder.jpg'
const normalizeContentId = (prefix: string, value: string | number) => `${prefix}-sgs-${value}`
const cleanPoiName = (poi: StaticNavPoiPayload) => poi.name
.replace(new RegExp(`^${poi.floorId}\\s*`), '')
.replace(/^L\d+(?:\.\d+)?\s+/, '')
.replace(/^L-\d+(?:\.\d+)?\s+/, '')
.replace(/^L\d+(?:\.\d+)?\s+/, '')
.trim()
const normalizeContentId = (prefix: string, value: string) => `${prefix}-${value}`
.toLowerCase()
.replace(/[^a-z0-9一-龥]+/g, '-')
.replace(/^-+|-+$/g, '')
const isTouringPoi = (poi: StaticNavPoiPayload) => (
poi.primaryCategory === 'touring_poi'
|| poi.categories?.some((category) => category.topCategory === 'touring_poi') === true
)
const isHallPoi = (poi: StaticNavPoiPayload) => {
const name = cleanPoiName(poi)
return /展厅|影院|剧场|报告厅|活动室/.test(name)
&& !/展柜|装饰/.test(name)
const categoryLabelMap: Record<SgsSceneSpaceMock['spaceCategory'], string> = {
EXHIBITION: '展陈空间',
EXPERIENCE: '体验空间',
SERVICE: '服务空间',
COMMERCIAL: '商业空间',
EDUCATION: '教育空间',
CIRCULATION: '公共交通空间',
BACK_OFFICE: '后勤空间'
}
const isExhibitCandidate = (poi: StaticNavPoiPayload) => /展柜|装饰/.test(cleanPoiName(poi))
const hallKeyFromName = (name: string) => {
const match = name.match(/(展厅\s*\d+\s*[^\s展柜装饰]+|临展厅\d+|[^\s]+厅|[^\s]+影院|自然剧场|学术报告厅|博物馆之友活动室)/)
return (match?.[1] || name)
.replace(/\s+/g, '')
.replace(/L\d+\s*/g, '')
const boundaryLabelMap: Record<NonNullable<SgsSceneSpaceMock['boundaryStatus']>, string> = {
DEFINED: '已定义空间边界',
UNDEFINED: '空间边界待完善'
}
const exhibitNameFromPoi = (poi: StaticNavPoiPayload) => cleanPoiName(poi)
.replace(/\s+/g, ' ')
.replace(/^(展厅\s*\d+\s*)/, '')
.trim()
const publicSceneSpaces = SGS_SCENE_EXPLAIN_DATASET.spaces.filter((space) => space.isPublic)
const publicSpaceById = new Map(publicSceneSpaces.map((space) => [space.id, space]))
const hallDescription = (hallName: string, floorLabel: string) => (
`${hallName}位于${floorLabel},来源于当前导览资源包中的游览 POI。当前内容用于讲解业务结构占位正式展陈文案、音频和图文媒体待 CMS/API 接入后替换。`
)
const formatArea = (area?: number) => (typeof area === 'number' ? `${area}` : undefined)
const exhibitDescription = (exhibitName: string, hallName: string, floorLabel: string) => (
`${exhibitName}${hallName}的讲解点,位置关联到${floorLabel}的导览 POI。当前仅展示内容结构和位置关联不代表正式展陈文案或可播放音频。`
)
const hallDescription = (space: SgsSceneSpaceMock) => {
const category = categoryLabelMap[space.spaceCategory]
const area = formatArea(space.area)
const boundary = space.boundaryStatus ? boundaryLabelMap[space.boundaryStatus] : undefined
const facts = [space.floorLabel, category, area, boundary].filter(Boolean).join(' · ')
return `${space.name}来自 SGS 前端地图项目“场景设置”的空间管理数据。${facts ? `当前空间信息:${facts}` : ''}讲解业务据此展示展厅/展区内容,并通过稳定空间、展品与 POI ID 关联到位置预览。`
}
const exhibitDescription = (exhibit: SgsSceneExhibitMock, hall: SgsSceneSpaceMock | undefined) => {
const facts = [
exhibit.category,
exhibit.era,
exhibit.origin,
exhibit.zoneName
].filter(Boolean).join(' · ')
return [
exhibit.description,
facts ? `展陈信息:${facts}` : '',
hall ? `所属空间:${hall.name}${hall.floorLabel})。` : ''
].filter(Boolean).join('\n\n')
}
const tagsForExhibit = (exhibit: SgsSceneExhibitMock, hall: SgsSceneSpaceMock | undefined) => [
'SGS场景设置',
exhibit.category,
exhibit.zoneName,
exhibit.audioUrl ? '源数据含音频地址' : '图文讲解',
hall ? categoryLabelMap[hall.spaceCategory] : undefined
].filter(Boolean) as string[]
const toMuseumHall = (space: SgsSceneSpaceMock): MuseumHall => ({
id: normalizeContentId('hall', space.id),
name: space.name,
floorId: space.floorId,
floorLabel: space.floorLabel,
description: hallDescription(space),
image: '',
exhibitCount: SGS_SCENE_EXPLAIN_DATASET.exhibits.filter((exhibit) => exhibit.spaceId === space.id).length || space.exhibitCount || 0,
area: formatArea(space.area),
poiId: space.poiId
})
const toMuseumExhibit = (exhibit: SgsSceneExhibitMock): MuseumExhibit => {
const hall = publicSpaceById.get(exhibit.spaceId)
return {
id: normalizeContentId('exhibit', exhibit.id),
name: exhibit.name,
hallId: hall ? normalizeContentId('hall', hall.id) : undefined,
hallName: hall?.name || exhibit.spaceName,
floorId: hall?.floorId || SGS_SCENE_EXPLAIN_DATASET.floorId,
floorLabel: hall?.floorLabel || SGS_SCENE_EXPLAIN_DATASET.floorLabel,
image: '',
description: exhibitDescription(exhibit, hall),
poiId: exhibit.poiId || hall?.poiId,
year: exhibit.era,
material: exhibit.origin,
size: exhibit.code,
tags: tagsForExhibit(exhibit, hall)
}
}
export class StaticMuseumContentProvider implements MuseumContentProvider {
private hallCache: MuseumHall[] | null = null
private exhibitCache: MuseumExhibit[] | null = null
private loading: Promise<void> | null = null
constructor(
private readonly navAssets: StaticNavAssetsProvider = defaultStaticNavAssetsProvider
) {}
async listExhibits() {
await this.ensureLoaded()
return this.exhibitCache || []
return SGS_SCENE_EXPLAIN_DATASET.exhibits.map(toMuseumExhibit)
}
async listHalls() {
await this.ensureLoaded()
return this.hallCache || []
}
private async ensureLoaded() {
if (this.hallCache && this.exhibitCache) return
if (this.loading) return this.loading
this.loading = this.navAssets.loadPoiIndex()
.then((pois) => {
const touringPois = pois.filter(isTouringPoi)
const hallPois = touringPois.filter(isHallPoi)
const hallByKey = new Map<string, MuseumHall>()
hallPois.forEach((poi) => {
const hallName = cleanPoiName(poi)
const key = `${poi.floorId}:${hallKeyFromName(hallName)}`
const floorLabel = formatNavFloorLabel(poi.floorId)
hallByKey.set(key, {
id: normalizeContentId('hall', poi.id),
name: hallName,
floorId: poi.floorId,
floorLabel,
description: hallDescription(hallName, floorLabel),
image: hallImage,
exhibitCount: 0,
area: '以导览资源包 POI 为准',
poiId: poi.id
})
})
const fallbackHallFor = (poi: StaticNavPoiPayload) => {
const poiName = cleanPoiName(poi)
const key = `${poi.floorId}:${hallKeyFromName(poiName)}`
const floorLabel = formatNavFloorLabel(poi.floorId)
const hallName = hallKeyFromName(poiName)
const existing = hallByKey.get(key)
if (existing) return existing
const hall: MuseumHall = {
id: normalizeContentId('hall', `${poi.floorId}-${hallName}`),
name: hallName,
floorId: poi.floorId,
floorLabel,
description: hallDescription(hallName, floorLabel),
image: hallImage,
exhibitCount: 0,
area: '以导览资源包 POI 为准'
}
hallByKey.set(key, hall)
return hall
}
const exhibits = touringPois
.filter(isExhibitCandidate)
.map((poi): MuseumExhibit => {
const hall = fallbackHallFor(poi)
const exhibitName = exhibitNameFromPoi(poi)
return {
id: normalizeContentId('exhibit', poi.id),
name: exhibitName,
hallId: hall.id,
hallName: hall.name,
floorId: poi.floorId,
floorLabel: formatNavFloorLabel(poi.floorId),
image: contentImage,
description: exhibitDescription(exhibitName, hall.name, formatNavFloorLabel(poi.floorId)),
poiId: poi.id,
tags: ['资源包POI', poi.primaryCategoryZh, poi.iconType || '展项'].filter(Boolean)
}
})
const exhibitCountByHallId = exhibits.reduce((result, exhibit) => {
if (exhibit.hallId) {
result.set(exhibit.hallId, (result.get(exhibit.hallId) || 0) + 1)
}
return result
}, new Map<string, number>())
this.hallCache = Array.from(hallByKey.values()).map((hall) => ({
...hall,
exhibitCount: exhibitCountByHallId.get(hall.id) || hall.exhibitCount || 0
}))
this.exhibitCache = exhibits
})
.finally(() => {
this.loading = null
})
return this.loading
return publicSceneSpaces.map(toMuseumHall)
}
}