feat: switch explain flow to API data
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-06 23:18:51 +08:00
parent e2b6a331ba
commit 50902eca82
21 changed files with 1417 additions and 632 deletions

View File

@@ -61,12 +61,22 @@ export interface BackendExhibit {
export interface BackendHall {
id?: string | number | null
name?: string | null
poiId?: string | number | null
hallCode?: string | null
nameEn?: string | null
subtitle?: string | null
description?: string | null
coverImageUrl?: string | null
floorId?: string | number | null
floorCode?: string | null
exhibitCount?: number | null
outlineCount?: number | null
stopCount?: number | null
linkedExhibitCount?: number | null
audioReadyStopCount?: number | null
hasAudio?: boolean | null
audioStatus?: string | null
supportedLanguages?: string[] | null
}
export interface BackendGuideStop {
@@ -87,6 +97,61 @@ export interface BackendGuideStop {
sort?: number | null
}
export interface BackendCatalogHallItem extends BackendHall {
mapId?: string | number | null
areaSqm?: string | number | null
sortOrder?: number | null
}
export interface BackendCatalogOutlineItem {
id?: string | number | null
parentId?: string | number | null
hallId?: string | number | null
name?: string | null
code?: string | null
description?: string | null
sort?: number | null
level?: number | null
children?: BackendCatalogOutlineItem[] | null
stopCount?: number | null
linkedExhibitCount?: number | null
audioReadyStopCount?: number | null
hasAudio?: boolean | null
audioStatus?: string | null
supportedLanguages?: string[] | null
}
export interface BackendCatalogLinkedExhibitItem {
id?: string | number | null
name?: string | null
nameEn?: string | null
code?: string | null
exhibitCode?: string | null
coverImageUrl?: string | null
sortOrder?: number | null
}
export interface BackendCatalogStopItem {
stopId?: string | number | null
id?: string | number | null
outlineId?: string | number | null
name?: string | null
guideLevel?: string | null
description?: string | null
sort?: number | null
coverImageUrl?: string | null
imageStatus?: string | null
linkedExhibitCount?: number | null
isSharedStop?: boolean | null
linkedExhibits?: BackendCatalogLinkedExhibitItem[] | null
hasAudio?: boolean | null
audioStatus?: string | null
supportedLanguages?: string[] | null
hasTextRecord?: boolean | null
playTargetType?: string | null
playTargetId?: string | number | null
}
export interface BackendExplainAdapterResult {
exhibit: MuseumExhibit
track: ExplainTrack
@@ -130,6 +195,15 @@ const normalizeAudioTargetType = (targetType: string | null | undefined): AudioP
return normalized === 'ITEM' || normalized === 'STOP' ? normalized : undefined
}
const normalizeNumber = (value: number | string | null | undefined) => {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim()) {
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : undefined
}
return undefined
}
const isAudioReady = (value?: string | null) => (
!value || ['READY', 'PUBLISHED', 'AVAILABLE'].includes(value.toUpperCase())
)
@@ -170,6 +244,230 @@ const buildTags = (exhibit: BackendExhibit) => Array.from(new Set([
firstText(exhibit.origin)
].filter(Boolean)))
const normalizeSupportedLanguages = (languages: string[] | null | undefined) => (
Array.isArray(languages) ? languages.map(String).filter(Boolean) : []
)
const normalizeCatalogAudioStatus = (status?: string | null) => (
firstText(status) || 'MISSING'
)
export const toCatalogHall = (
source: BackendCatalogHallItem,
fallback?: MuseumHall | null
): MuseumHall => {
const id = stringifyId(source.id) || fallback?.id || firstText(source.hallCode, source.name)
const name = firstText(source.name, fallback?.name, source.hallCode, '展厅')
const area = firstText(source.areaSqm)
return {
id,
name,
floorId: stringifyId(source.floorId) || fallback?.floorId,
floorLabel: firstText(source.floorCode, fallback?.floorLabel) || undefined,
description: firstText(source.description, source.subtitle, fallback?.description, '该展厅暂无介绍。'),
image: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || fallback?.image || HALL_PLACEHOLDER_IMAGE,
exhibitCount: normalizeNumber(source.exhibitCount) ?? fallback?.exhibitCount ?? 0,
outlineCount: normalizeNumber(source.outlineCount),
stopCount: normalizeNumber(source.stopCount),
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
audioReadyStopCount: normalizeNumber(source.audioReadyStopCount),
hasAudio: source.hasAudio === true,
audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
area: area ? `${area}` : fallback?.area,
poiId: stringifyId(source.poiId) || fallback?.poiId,
location: fallback?.location
}
}
export const toCatalogLinkedExhibit = (
source: BackendCatalogLinkedExhibitItem,
hall?: MuseumHall | null
): MuseumExhibit | null => {
const id = stringifyId(source.id)
if (!id) return null
return {
id,
name: firstText(source.name, source.exhibitCode, source.code, `展品${id}`),
hallId: hall?.id,
hallName: hall?.name,
floorId: hall?.floorId,
floorLabel: hall?.floorLabel,
image: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined,
size: firstText(source.exhibitCode, source.code) || undefined
}
}
const toCatalogLinkedExhibitSummary = (source: BackendCatalogLinkedExhibitItem) => {
const id = stringifyId(source.id)
if (!id) return null
return {
id,
name: firstText(source.name, source.exhibitCode, source.code, `展品 ${id}`),
nameEn: firstText(source.nameEn) || undefined,
exhibitCode: firstText(source.exhibitCode, source.code) || undefined,
coverImageUrl: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined
}
}
export const toCatalogGuideStop = (
source: BackendCatalogStopItem,
hall?: MuseumHall | null,
outline?: Pick<ExplainBusinessUnit, 'id' | 'name'> | null
): ExplainGuideStop | null => {
const id = stringifyId(source.stopId) || stringifyId(source.id)
if (!id) return null
const playTargetType = normalizeAudioTargetType(source.playTargetType) || 'STOP'
const playTargetId = stringifyId(source.playTargetId) || id
const imageStatus = firstText(source.imageStatus) || 'MISSING'
const canUseStopImage = imageStatus === 'READY'
return {
id,
name: firstText(source.name, `讲解点${id}`),
hallId: hall?.id,
hallName: hall?.name,
floorId: hall?.floorId,
targetType: playTargetType,
targetId: playTargetId,
coverImageUrl: canUseStopImage
? normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined
: undefined,
description: firstText(source.description) || undefined,
hasAudio: source.hasAudio === true,
audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
hasTextRecord: source.hasTextRecord === true,
outlineId: stringifyId(source.outlineId) || outline?.id,
outlineName: outline?.name,
sort: typeof source.sort === 'number' ? source.sort : undefined,
guideLevel: firstText(source.guideLevel) || undefined,
imageStatus,
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
isSharedStop: source.isSharedStop === true,
linkedExhibits: (source.linkedExhibits || [])
.map(toCatalogLinkedExhibitSummary)
.filter(Boolean) as NonNullable<ExplainGuideStop['linkedExhibits']>,
playTargetType,
playTargetId
}
}
export const toCatalogMuseumExhibitFromStop = (
stop: ExplainGuideStop,
hall?: MuseumHall | null
): MuseumExhibit => {
const linkedPrimary = stop.linkedExhibits?.[0]
const audioReady = stop.audioStatus === 'READY'
return {
id: stop.id,
name: stop.name,
hallId: stop.hallId || hall?.id,
hallName: stop.hallName || hall?.name,
floorId: stop.floorId || hall?.floorId,
floorLabel: hall?.floorLabel,
image: stop.coverImageUrl || undefined,
description: stop.description || linkedPrimary?.name || '该讲解点暂无简介。',
tags: [stop.outlineName, linkedPrimary?.exhibitCode].filter(Boolean) as string[],
guideTitle: stop.name,
guideText: stop.description,
audioAvailable: audioReady,
audioStatus: stop.audioStatus,
supportedLanguages: stop.supportedLanguages,
audioHasText: stop.hasTextRecord,
imageStatus: stop.imageStatus,
linkedExhibitCount: stop.linkedExhibitCount,
isSharedStop: stop.isSharedStop,
linkedExhibits: stop.linkedExhibits,
stopInfoAvailable: true,
resolvedStopId: stop.id,
playTargetType: stop.playTargetType || stop.targetType || 'STOP',
playTargetId: stop.playTargetId || stop.targetId || stop.id
}
}
const outlineKey = (outlineId?: string | null) => outlineId || '__unassigned__'
export const flattenCatalogOutlines = (
outlines: BackendCatalogOutlineItem[]
): BackendCatalogOutlineItem[] => {
const flattened: BackendCatalogOutlineItem[] = []
const visit = (items: BackendCatalogOutlineItem[]) => {
items
.slice()
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
.forEach((item) => {
flattened.push(item)
if (Array.isArray(item.children) && item.children.length) {
visit(item.children)
}
})
}
visit(outlines)
return flattened
}
export const toCatalogBusinessUnits = (
hallId: string,
outlines: BackendCatalogOutlineItem[],
stops: ExplainGuideStop[]
): ExplainBusinessUnit[] => {
const flatOutlines = flattenCatalogOutlines(outlines)
const stopMap = new Map<string, ExplainGuideStop[]>()
stops.forEach((stop) => {
const key = outlineKey(stop.outlineId)
stopMap.set(key, [...(stopMap.get(key) || []), stop])
})
const outlineChildren = new Set(
flatOutlines
.map((item) => stringifyId(item.parentId))
.filter((parentId) => parentId && parentId !== hallId)
)
const units = flatOutlines
.map<ExplainBusinessUnit | null>((outline) => {
const id = stringifyId(outline.id)
if (!id) return null
const directStops = (stopMap.get(id) || [])
.slice()
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
const hasChildOutlines = outlineChildren.has(id)
const rawStopCount = normalizeNumber(outline.stopCount) ?? 0
if (!directStops.length && hasChildOutlines) return null
return {
id,
name: firstText(outline.name, outline.code, `业务单元${id}`),
hallId: stringifyId(outline.hallId) || hallId,
parentId: stringifyId(outline.parentId) || undefined,
code: firstText(outline.code) || undefined,
description: firstText(outline.description) || undefined,
sort: typeof outline.sort === 'number' ? outline.sort : undefined,
level: typeof outline.level === 'number' ? outline.level : undefined,
guideStopCount: directStops.length || rawStopCount,
linkedExhibitCount: normalizeNumber(outline.linkedExhibitCount),
audioReadyStopCount: normalizeNumber(outline.audioReadyStopCount),
hasAudio: outline.hasAudio === true,
audioStatus: normalizeCatalogAudioStatus(outline.audioStatus),
supportedLanguages: normalizeSupportedLanguages(outline.supportedLanguages),
stops: directStops
}
})
.filter(Boolean) as ExplainBusinessUnit[]
if (units.length) return units
return groupGuideStopsByOutline(hallId, stops)
}
export const toBackendMuseumExhibit = (
source: BackendExhibit,
hall?: MuseumHall | null,
@@ -277,15 +575,22 @@ export const toBackendHallFromList = (source: BackendHall, fallback?: MuseumHall
return {
id,
name,
floorId: fallback?.floorId,
floorLabel: fallback?.floorLabel,
floorId: stringifyId(source.floorId) || fallback?.floorId,
floorLabel: firstText(source.floorCode, fallback?.floorLabel) || undefined,
description: firstText(source.description, source.subtitle, fallback?.description, '该展厅暂无介绍。'),
image: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || fallback?.image || HALL_PLACEHOLDER_IMAGE,
exhibitCount: typeof source.exhibitCount === 'number'
? source.exhibitCount
: fallback?.exhibitCount || 0,
outlineCount: normalizeNumber(source.outlineCount),
stopCount: normalizeNumber(source.stopCount),
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
audioReadyStopCount: normalizeNumber(source.audioReadyStopCount),
hasAudio: source.hasAudio === true,
audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
area: fallback?.area,
poiId: fallback?.poiId,
poiId: stringifyId(source.poiId) || fallback?.poiId,
location: fallback?.location
}
}