对齐讲解业务单元静态数据口径

- 刷新讲解静态数据包并保留完整 outline 树
- 按后台顶层业务单元口径归并讲解点统计
- 更新讲解静态数据刷新操作文档

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lyf
2026-07-03 02:05:02 +08:00
parent 0cd8d43684
commit c146beba9e
14 changed files with 12266 additions and 8796 deletions

View File

@@ -1,4 +1,6 @@
import type {
ExplainBusinessUnit,
ExplainGuideStop,
ExplainTrack,
GuideLocationResolution,
GuideLocationResolutionStatus,
@@ -17,6 +19,7 @@ import type {
GuideStaticContentPayload,
GuideStaticDataset,
GuideStaticExplainDataset,
GuideStaticExplainNavigationDataset,
GuideStaticExhibitPayload,
GuideStaticHallPayload,
GuideStaticOutlinePayload,
@@ -32,11 +35,20 @@ export interface GuideContentDataAdapterResult {
mediaAssets: MediaAsset[]
}
export interface GuideExplainNavigationAdapterResult {
halls: MuseumHall[]
guideStops: ExplainGuideStop[]
businessUnitsByHallId: Record<string, ExplainBusinessUnit[]>
}
export interface GuideExplainDataAdapterResult {
halls: MuseumHall[]
exhibits: MuseumExhibit[]
tracks: ExplainTrack[]
mediaAssets: MediaAsset[]
guideStops: ExplainGuideStop[]
businessUnitsByHallId: Record<string, ExplainBusinessUnit[]>
exhibitByStopId: Record<string, MuseumExhibit>
}
const stringifyId = (value: string | number | null | undefined) => (
@@ -239,6 +251,176 @@ const toHallModels = (
}
})
const toExplainBusinessUnitsByHallId = (
stops: ExplainGuideStop[],
topOutlines: Array<{ id: string; name: string; hallId: string; sort?: number }> = []
) => {
const unitsByHallId: Record<string, ExplainBusinessUnit[]> = {}
const stopGroupsByHall = new Map<string, Map<string, ExplainGuideStop[]>>()
const unitNameByKey = new Map<string, string>()
const unitOrderByKey = new Map<string, number>()
topOutlines.forEach((outline) => {
const hallGroups = stopGroupsByHall.get(outline.hallId) || new Map<string, ExplainGuideStop[]>()
if (!hallGroups.has(outline.id)) {
hallGroups.set(outline.id, [])
}
stopGroupsByHall.set(outline.hallId, hallGroups)
unitNameByKey.set(`${outline.hallId}:${outline.id}`, outline.name)
unitOrderByKey.set(`${outline.hallId}:${outline.id}`, outline.sort ?? 0)
})
stops
.slice()
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
.forEach((stop) => {
const hallId = stop.hallId || ''
if (!hallId) return
const unitId = stop.outlineId || `ungrouped-${hallId}`
const unitName = stop.outlineName || '其他讲解'
const hallGroups = stopGroupsByHall.get(hallId) || new Map<string, ExplainGuideStop[]>()
const groupStops = hallGroups.get(unitId) || []
groupStops.push(stop)
hallGroups.set(unitId, groupStops)
stopGroupsByHall.set(hallId, hallGroups)
if (!unitNameByKey.has(`${hallId}:${unitId}`)) {
unitNameByKey.set(`${hallId}:${unitId}`, unitName)
}
if (!unitOrderByKey.has(`${hallId}:${unitId}`)) {
unitOrderByKey.set(`${hallId}:${unitId}`, stop.sort ?? 0)
}
})
stopGroupsByHall.forEach((groupMap, hallId) => {
unitsByHallId[hallId] = Array.from(groupMap.entries())
.map(([unitId, groupStops]) => ({
id: unitId,
name: unitNameByKey.get(`${hallId}:${unitId}`) || '其他讲解',
hallId,
guideStopCount: groupStops.length,
stops: groupStops
}))
.sort((a, b) => (
(unitOrderByKey.get(`${hallId}:${a.id}`) ?? 0) - (unitOrderByKey.get(`${hallId}:${b.id}`) ?? 0)
))
})
return unitsByHallId
}
const createExplainNavigationAdapter = (
dataset: GuideStaticExplainNavigationDataset,
hallGuideCounts = new Map<string, number>(),
poiBridge: {
hallToNavPoiId?: Record<string, GuideStaticPoiBridgeEntryPayload>
sgsPoiToNavPoiId?: Record<string, GuideStaticPoiBridgeEntryPayload>
} = {}
): GuideExplainNavigationAdapterResult => {
const visibleHalls = visibleHallsFrom(dataset.halls)
const visibleOutlines = visibleOutlinesFrom(dataset.outlines)
const visibleStops = visibleStopsFrom(dataset.guideStops)
const hallById = new Map(visibleHalls.map((hall) => [stringifyId(hall.id), hall]))
const outlineById = new Map(visibleOutlines.map((outline) => [stringifyId(outline.id), outline]))
const hallBridgeById = poiBridge.hallToNavPoiId || {}
const sgsPoiBridgeById = poiBridge.sgsPoiToNavPoiId || {}
const findHallForOutline = (outline: GuideStaticOutlinePayload | undefined) => {
let current = outline
let guard = 0
while (current && guard < 16) {
const parentId = stringifyId(current.parentId)
if (parentId && hallById.has(parentId)) {
return hallById.get(parentId)
}
current = parentId ? outlineById.get(parentId) : undefined
guard += 1
}
return undefined
}
const findHallForStop = (stop: GuideStaticStopPayload | undefined) => (
findHallForOutline(outlineById.get(stringifyId(stop?.outlineId)))
)
const findTopOutlineForStop = (stop: GuideStaticStopPayload | undefined) => {
let current = outlineById.get(stringifyId(stop?.outlineId))
let guard = 0
while (current && guard < 16) {
const parentId = stringifyId(current.parentId)
if (parentId && hallById.has(parentId)) {
return current
}
current = parentId ? outlineById.get(parentId) : undefined
guard += 1
}
return outlineById.get(stringifyId(stop?.outlineId))
}
const topOutlines = visibleOutlines.flatMap((outline) => {
const id = stringifyId(outline.id)
const parentId = stringifyId(outline.parentId)
if (!id || !parentId || !hallById.has(parentId)) return []
return [{
id,
name: outline.name || `业务单元 ${id}`,
hallId: parentId,
sort: typeof outline.sort === 'number' ? outline.sort : undefined
}]
})
const resolveHallLocation = (hall: GuideStaticHallPayload | undefined) => {
const hallId = stringifyId(hall?.id)
const sourcePoiId = stringifyId(hall?.poiId)
const bridgedHall = hallId ? toLocationResolution(hallBridgeById[hallId], 'exact', sourcePoiId) : undefined
if (bridgedHall) return bridgedHall
return sourcePoiId ? toLocationResolution(sgsPoiBridgeById[sourcePoiId], 'exact', sourcePoiId) : undefined
}
const halls = toHallModels(visibleHalls, hallGuideCounts, hallBridgeById, sgsPoiBridgeById)
const guideStops: ExplainGuideStop[] = visibleStops.map((stop) => {
const topOutline = findTopOutlineForStop(stop)
const hall = findHallForStop(stop)
const location = resolveHallLocation(hall)
const floorId = location?.floorId || formatFloorId(hall, undefined) || stop.floorCode || stringifyId(stop.floorId)
return {
id: stringifyId(stop.id) || '',
name: firstText(stop.name) || `讲解点 ${stringifyId(stop.id) || ''}`,
hallId: stringifyId(hall?.id),
hallName: hall?.name || undefined,
floorId,
targetType: 'STOP',
targetId: stringifyId(stop.id) || '',
coverImageUrl: resolveImage(stop.coverImageUrl, hall?.coverImageUrl, hall?.galleryUrls),
description: firstText(stop.name),
hasAudio: Boolean(stop.audioUrl),
poiId: location?.poiId,
outlineId: stringifyId(topOutline?.id),
outlineName: topOutline?.name || undefined,
sort: typeof stop.sort === 'number' ? stop.sort : undefined
}
})
return {
halls,
guideStops,
businessUnitsByHallId: toExplainBusinessUnitsByHallId(guideStops, topOutlines)
}
}
export const createGuideExplainNavigationAdapter = (
dataset: GuideStaticExplainNavigationDataset
): GuideExplainNavigationAdapterResult => createExplainNavigationAdapter(dataset)
export const createGuideExplainDataAdapter = (dataset: GuideStaticExplainDataset): GuideExplainDataAdapterResult => {
const visibleHalls = visibleHallsFrom(dataset.halls)
const visibleOutlines = visibleOutlinesFrom(dataset.outlines)
@@ -272,6 +454,36 @@ export const createGuideExplainDataAdapter = (dataset: GuideStaticExplainDataset
findHallForOutline(outlineById.get(stringifyId(stop?.outlineId)))
)
const findTopOutlineForStop = (stop: GuideStaticStopPayload | undefined) => {
let current = outlineById.get(stringifyId(stop?.outlineId))
let guard = 0
while (current && guard < 16) {
const parentId = stringifyId(current.parentId)
if (parentId && hallById.has(parentId)) {
return current
}
current = parentId ? outlineById.get(parentId) : undefined
guard += 1
}
return outlineById.get(stringifyId(stop?.outlineId))
}
const topOutlines = visibleOutlines.flatMap((outline) => {
const id = stringifyId(outline.id)
const parentId = stringifyId(outline.parentId)
if (!id || !parentId || !hallById.has(parentId)) return []
return [{
id,
name: outline.name || `业务单元 ${id}`,
hallId: parentId,
sort: typeof outline.sort === 'number' ? outline.sort : undefined
}]
})
const resolveHallLocation = (
hall: GuideStaticHallPayload | undefined,
status: Exclude<GuideLocationResolutionStatus, 'unavailable'>
@@ -313,6 +525,36 @@ export const createGuideExplainDataAdapter = (dataset: GuideStaticExplainDataset
const halls = toHallModels(visibleHalls, hallGuideCounts, hallBridgeById, sgsPoiBridgeById)
const guideStops: ExplainGuideStop[] = visibleStops.map((stop) => {
const topOutline = findTopOutlineForStop(stop)
const hall = findHallForStop(stop)
const preciseLocation = resolveSgsPoiLocation([stop.poiId], 'exact')
const location = preciseLocation || resolveHallLocation(hall, 'hallFallback')
const floorId = location?.floorId || formatFloorId(hall, undefined) || stop.floorCode || stringifyId(stop.floorId)
const stopGuide = visibleGuides.find((guide) => (
guide.targetType?.toUpperCase() === 'STOP'
&& stringifyId(guide.targetId) === stringifyId(stop.id)
))
return {
id: stringifyId(stop.id) || '',
name: firstText(stop.name, stopGuide?.title) || `讲解点 ${stringifyId(stop.id) || ''}`,
hallId: stringifyId(hall?.id),
hallName: hall?.name || undefined,
floorId,
targetType: 'STOP',
targetId: stringifyId(stop.id) || '',
coverImageUrl: resolveImage(stop.coverImageUrl, stopGuide?.mediaGallery, hall?.coverImageUrl, hall?.galleryUrls),
description: firstText(stopGuide?.standardText, stopGuide?.extendedText, stop.name),
hasAudio: stopGuide ? Boolean(primaryAudioUrl(stopGuide)) : Boolean(stop.audioUrl),
poiId: location?.poiId,
outlineId: stringifyId(topOutline?.id),
outlineName: topOutline?.name || undefined,
sort: typeof stop.sort === 'number' ? stop.sort : undefined
}
})
const businessUnitsByHallId = toExplainBusinessUnitsByHallId(guideStops, topOutlines)
const mediaAssets: MediaAsset[] = visibleGuides.map((guide) => {
const id = `media-${stringifyId(guide.id) || 'unknown'}`
const url = primaryAudioUrl(guide)
@@ -374,6 +616,13 @@ export const createGuideExplainDataAdapter = (dataset: GuideStaticExplainDataset
}
})
const exhibitByStopId = exhibits.reduce<Record<string, MuseumExhibit>>((result, exhibit) => {
if (exhibit.playTargetType === 'STOP' && exhibit.playTargetId) {
result[exhibit.playTargetId] = exhibit
}
return result
}, {})
const tracks: ExplainTrack[] = visibleGuides.map((guide) => {
const id = stringifyId(guide.id) || ''
const exhibitId = stringifyId(guide.exhibitId) || `guide-${id}`
@@ -409,7 +658,10 @@ export const createGuideExplainDataAdapter = (dataset: GuideStaticExplainDataset
halls,
exhibits,
tracks,
mediaAssets
mediaAssets,
guideStops,
businessUnitsByHallId,
exhibitByStopId
}
}

View File

@@ -225,6 +225,13 @@ export interface GuideStaticDataset {
poiBridge: GuideStaticPoiBridgePayload
}
export interface GuideStaticExplainNavigationDataset {
manifest: GuideStaticManifestPayload
halls: GuideStaticHallPayload[]
outlines: GuideStaticOutlinePayload[]
guideStops: GuideStaticStopPayload[]
}
export interface GuideStaticExplainDataset {
manifest: GuideStaticManifestPayload
halls: GuideStaticHallPayload[]
@@ -297,6 +304,7 @@ export interface StaticGuideDataProvider {
assetUrl(relativePath: string): string
loadManifest(): Promise<GuideStaticManifestPayload>
loadIndexes(): Promise<GuideStaticIndexesPayload>
loadExplainNavigationDataset(): Promise<GuideStaticExplainNavigationDataset>
loadExplainDataset(): Promise<GuideStaticExplainDataset>
loadDataset(): Promise<GuideStaticDataset>
}
@@ -309,6 +317,8 @@ export const createStaticGuideDataProvider = (
let manifestRequest: Promise<GuideStaticManifestPayload> | null = null
let indexesCache: GuideStaticIndexesPayload | null = null
let indexesRequest: Promise<GuideStaticIndexesPayload> | null = null
let explainNavigationDatasetCache: GuideStaticExplainNavigationDataset | null = null
let explainNavigationDatasetRequest: Promise<GuideStaticExplainNavigationDataset> | null = null
let explainDatasetCache: GuideStaticExplainDataset | null = null
let explainDatasetRequest: Promise<GuideStaticExplainDataset> | null = null
let datasetCache: GuideStaticDataset | null = null
@@ -349,6 +359,46 @@ export const createStaticGuideDataProvider = (
return indexesRequest
},
async loadExplainNavigationDataset() {
if (explainNavigationDatasetCache) return explainNavigationDatasetCache
if (datasetCache) {
explainNavigationDatasetCache = {
manifest: datasetCache.manifest,
halls: datasetCache.halls,
outlines: datasetCache.outlines,
guideStops: datasetCache.guideStops
}
return explainNavigationDatasetCache
}
if (explainNavigationDatasetRequest) return explainNavigationDatasetRequest
explainNavigationDatasetRequest = provider.loadManifest()
.then(async (manifest) => {
const [
halls,
outlines,
guideStops
] = await Promise.all([
requestJson<GuideStaticPackagePayload<GuideStaticHallPayload>>(provider.assetUrl(manifest.files.halls)),
requestJson<GuideStaticPackagePayload<GuideStaticOutlinePayload>>(provider.assetUrl(manifest.files.outlines)),
requestJson<GuideStaticPackagePayload<GuideStaticStopPayload>>(provider.assetUrl(manifest.files.guideStops))
])
explainNavigationDatasetCache = {
manifest,
halls: halls.rows,
outlines: outlines.rows,
guideStops: guideStops.rows
}
return explainNavigationDatasetCache
})
.finally(() => {
explainNavigationDatasetRequest = null
})
return explainNavigationDatasetRequest
},
async loadExplainDataset() {
if (explainDatasetCache) return explainDatasetCache
if (datasetCache) {
@@ -364,29 +414,20 @@ export const createStaticGuideDataProvider = (
}
if (explainDatasetRequest) return explainDatasetRequest
explainDatasetRequest = provider.loadManifest()
.then(async (manifest) => {
explainDatasetRequest = provider.loadExplainNavigationDataset()
.then(async (navigationDataset) => {
const [
halls,
outlines,
guideStops,
guideContents,
poiBridge
] = await Promise.all([
requestJson<GuideStaticPackagePayload<GuideStaticHallPayload>>(provider.assetUrl(manifest.files.halls)),
requestJson<GuideStaticPackagePayload<GuideStaticOutlinePayload>>(provider.assetUrl(manifest.files.outlines)),
requestJson<GuideStaticPackagePayload<GuideStaticStopPayload>>(provider.assetUrl(manifest.files.guideStops)),
requestJson<GuideStaticPackagePayload<GuideStaticContentPayload>>(provider.assetUrl(manifest.files.guideContents)),
manifest.files.poiBridge
? requestJson<GuideStaticPoiBridgePayload>(provider.assetUrl(manifest.files.poiBridge))
requestJson<GuideStaticPackagePayload<GuideStaticContentPayload>>(provider.assetUrl(navigationDataset.manifest.files.guideContents)),
navigationDataset.manifest.files.poiBridge
? requestJson<GuideStaticPoiBridgePayload>(provider.assetUrl(navigationDataset.manifest.files.poiBridge))
: Promise.resolve(emptyPoiBridge)
])
explainDatasetCache = {
manifest,
halls: halls.rows,
outlines: outlines.rows,
guideStops: guideStops.rows,
...navigationDataset,
guideContents: guideContents.rows,
poiBridge
}

View File

@@ -14,9 +14,11 @@ import {
} from '@/config/dataSource'
import {
createGuideExplainDataAdapter,
createGuideExplainNavigationAdapter,
createGuideContentDataAdapter,
type GuideContentDataAdapterResult,
type GuideExplainDataAdapterResult
type GuideExplainDataAdapterResult,
type GuideExplainNavigationAdapterResult
} from '@/data/adapters/guideDataAdapter'
import {
staticGuideDataProvider,
@@ -112,12 +114,20 @@ const unavailableMockAudio = (id: string): MediaAsset => ({
export class StaticGuideContentProvider implements ExplainContentProvider {
private adapterCache: GuideContentDataAdapterResult | null = null
private explainAdapterCache: GuideExplainDataAdapterResult | null = null
private explainNavigationAdapterCache: GuideExplainNavigationAdapterResult | null = null
constructor(private readonly provider: StaticGuideDataProvider = staticGuideDataProvider) {}
private async loadExplainNavigationAdapter() {
if (this.explainNavigationAdapterCache) return this.explainNavigationAdapterCache
const dataset = await this.provider.loadExplainNavigationDataset()
this.explainNavigationAdapterCache = createGuideExplainNavigationAdapter(dataset)
return this.explainNavigationAdapterCache
}
private async loadExplainAdapter() {
if (this.explainAdapterCache) return this.explainAdapterCache
if (this.adapterCache) return this.adapterCache
const dataset = await this.provider.loadExplainDataset()
this.explainAdapterCache = createGuideExplainDataAdapter(dataset)
@@ -137,13 +147,30 @@ export class StaticGuideContentProvider implements ExplainContentProvider {
return adapter.exhibits
}
async getExhibitById(id: string) {
const adapter = await this.loadExplainAdapter()
return adapter.exhibits.find((exhibit) => exhibit.id === id)
|| adapter.exhibitByStopId[id]
|| null
}
async listGuideStopsByHall(hallId: string) {
const adapter = await this.loadExplainNavigationAdapter()
return adapter.guideStops.filter((stop) => stop.hallId === hallId)
}
async listTemporaryBusinessUnitsByHall(hallId: string) {
const adapter = await this.loadExplainNavigationAdapter()
return adapter.businessUnitsByHallId[hallId] || []
}
async listExhibits() {
const adapter = await this.loadAdapter()
return adapter.exhibits
}
async listHalls() {
const adapter = await this.loadExplainAdapter()
const adapter = await this.loadExplainNavigationAdapter()
return adapter.halls
}