This commit is contained in:
@@ -78,7 +78,6 @@ export interface BackendHall {
|
||||
stopCount?: number | null
|
||||
linkedExhibitCount?: number | null
|
||||
audioReadyStopCount?: number | null
|
||||
audioOptionCount?: number | null
|
||||
hasAudio?: boolean | null
|
||||
audioStatus?: string | null
|
||||
supportedLanguages?: string[] | null
|
||||
@@ -159,7 +158,6 @@ export interface BackendCatalogStopItem {
|
||||
hasAudio?: boolean | null
|
||||
audioStatus?: string | null
|
||||
supportedLanguages?: string[] | null
|
||||
audioOptionCount?: number | null
|
||||
hasTextRecord?: boolean | null
|
||||
playTargetType?: string | null
|
||||
playTargetId?: string | number | null
|
||||
@@ -289,7 +287,6 @@ export const toCatalogHall = (
|
||||
stopCount: normalizeNumber(source.stopCount),
|
||||
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
|
||||
audioReadyStopCount: normalizeNumber(source.audioReadyStopCount),
|
||||
audioOptionCount: normalizeNumber(source.audioOptionCount),
|
||||
hasAudio: source.hasAudio === true,
|
||||
audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
|
||||
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
|
||||
@@ -362,7 +359,6 @@ export const toCatalogGuideStop = (
|
||||
hasAudio: source.hasAudio === true,
|
||||
audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
|
||||
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
|
||||
audioOptionCount: normalizeNumber(source.audioOptionCount),
|
||||
hasTextRecord: source.hasTextRecord === true,
|
||||
poiId: stringifyId(source.poiId) || undefined,
|
||||
mapX: normalizeNumber(source.mapX),
|
||||
@@ -589,7 +585,7 @@ export const toBackendHall = (
|
||||
name: firstText(first?.hallName, fallback?.name, '展厅'),
|
||||
floorId: firstText(first?.floorId, fallback?.floorId) || undefined,
|
||||
floorLabel: firstText(first?.floorLabel, fallback?.floorLabel, '楼层待补充'),
|
||||
description: fallback?.description || '该展厅讲解内容来自后端展品接口。',
|
||||
description: fallback?.description || '该免费讲解内容来自后端展品接口。',
|
||||
image: fallback?.image || HALL_PLACEHOLDER_IMAGE,
|
||||
exhibitCount: exhibits.length,
|
||||
area: fallback?.area,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type {
|
||||
AudioPlayTargetType
|
||||
AudioPlayTargetType,
|
||||
GuideAudioGender,
|
||||
MuseumGuideAudioOption
|
||||
} from '@/domain/museum'
|
||||
import {
|
||||
normalizeSameOriginPublicUrl
|
||||
} from '@/utils/publicUrl'
|
||||
|
||||
export type GuideAudioLanguage = 'zh-CN' | 'yue-HK' | 'en-US'
|
||||
export type GuideAudioVoiceGender = 'female' | 'male'
|
||||
|
||||
export interface BackendGuideStopLinkedExhibit {
|
||||
id?: string | number | null
|
||||
@@ -19,6 +20,20 @@ export interface BackendGuideStopLinkedExhibit {
|
||||
sortOrder?: number | string | null
|
||||
}
|
||||
|
||||
export interface BackendGuideAudioOption {
|
||||
channelCode?: string | null
|
||||
displayName?: string | null
|
||||
version?: string | null
|
||||
languageCode?: string | null
|
||||
languageName?: string | null
|
||||
gender?: string | null
|
||||
playUrl?: string | null
|
||||
duration?: number | string | null
|
||||
format?: string | null
|
||||
isDefault?: boolean | null
|
||||
sortOrder?: number | string | null
|
||||
}
|
||||
|
||||
export interface BackendGuideStopInfo {
|
||||
available?: boolean
|
||||
targetType?: string | null
|
||||
@@ -43,45 +58,13 @@ export interface BackendGuideStopInfo {
|
||||
hasText?: boolean
|
||||
supportedLanguages?: string[] | null
|
||||
audioStatus?: string | null
|
||||
audioOptions?: BackendGuideStopAudioOption[] | null
|
||||
languageVariants?: BackendGuideStopLanguageVariant[] | null
|
||||
audioOptions?: BackendGuideAudioOption[] | null
|
||||
audioOptionCount?: number | string | null
|
||||
reason?: string | null
|
||||
linkedExhibitCount?: number | string | null
|
||||
isSharedStop?: boolean | null
|
||||
}
|
||||
|
||||
export interface BackendGuideStopAudioOption {
|
||||
channelCode?: string | null
|
||||
displayName?: string | null
|
||||
languageCode?: string | null
|
||||
languageName?: string | null
|
||||
gender?: string | null
|
||||
playUrl?: string | null
|
||||
duration?: number | string | null
|
||||
format?: string | null
|
||||
isDefault?: boolean | null
|
||||
sortOrder?: number | string | null
|
||||
}
|
||||
|
||||
export interface BackendGuideStopLanguageVariant {
|
||||
lang?: string | null
|
||||
enabled?: boolean | null
|
||||
playable?: boolean | null
|
||||
audioStatus?: string | null
|
||||
playUrl?: string | null
|
||||
duration?: number | string | null
|
||||
format?: string | null
|
||||
audioId?: string | number | null
|
||||
narrationTier?: 'STANDARD' | 'EXTENDED' | string | null
|
||||
hasText?: boolean | null
|
||||
textAvailable?: boolean | null
|
||||
text?: string | null
|
||||
textLength?: number | string | null
|
||||
textHash?: string | null
|
||||
fallback?: boolean | null
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
export interface BackendAudioPlayInfo {
|
||||
playable?: boolean
|
||||
targetType?: string | null
|
||||
@@ -125,38 +108,6 @@ export interface GuideStopLinkedExhibit {
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface GuideStopLanguageVariant {
|
||||
lang: GuideAudioLanguage
|
||||
enabled: boolean
|
||||
playable: boolean
|
||||
audioStatus: 'READY' | 'MISSING' | string
|
||||
playUrl?: string
|
||||
duration?: number
|
||||
format?: string
|
||||
audioId?: string
|
||||
narrationTier?: 'STANDARD' | 'EXTENDED'
|
||||
hasText: boolean
|
||||
textAvailable: boolean
|
||||
text?: string
|
||||
textLength?: number
|
||||
textHash?: string
|
||||
fallback: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface GuideStopAudioOption {
|
||||
channelCode: string
|
||||
displayName: string
|
||||
languageCode: GuideAudioLanguage
|
||||
languageName?: string
|
||||
gender: GuideAudioVoiceGender
|
||||
playUrl: string
|
||||
duration?: number
|
||||
format?: string
|
||||
isDefault: boolean
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface GuideStopInfo {
|
||||
available: boolean
|
||||
targetType: AudioPlayTargetType
|
||||
@@ -179,9 +130,9 @@ export interface GuideStopInfo {
|
||||
hasAudio: boolean
|
||||
hasText: boolean
|
||||
supportedLanguages: GuideAudioLanguage[]
|
||||
audioOptions: GuideStopAudioOption[]
|
||||
languageVariants: Record<GuideAudioLanguage, GuideStopLanguageVariant>
|
||||
audioStatus: 'READY' | 'MISSING' | string
|
||||
audioOptions: MuseumGuideAudioOption[]
|
||||
audioOptionCount?: number
|
||||
reason?: string
|
||||
linkedExhibitCount?: number
|
||||
isSharedStop?: boolean
|
||||
@@ -246,102 +197,47 @@ export const normalizeGuideAudioLanguage = (
|
||||
return 'zh-CN'
|
||||
}
|
||||
|
||||
const isSupportedGuideAudioLanguage = (value: string | null | undefined) => (
|
||||
['zh', 'zh-cn', 'en', 'en-us', 'yue', 'yue-cn', 'yue-hk'].includes(value?.trim().toLowerCase() || '')
|
||||
)
|
||||
|
||||
const normalizeSupportedLanguages = (languages: string[] | null | undefined): GuideAudioLanguage[] => (
|
||||
Array.from(new Set((languages || [])
|
||||
.filter(isSupportedGuideAudioLanguage)
|
||||
.map(normalizeGuideAudioLanguage))) as GuideAudioLanguage[]
|
||||
.map((language) => {
|
||||
const normalized = language?.trim().toLowerCase()
|
||||
if (!['zh', 'zh-cn', 'en', 'en-us', 'yue', 'yue-cn', 'yue-hk'].includes(normalized)) {
|
||||
return null
|
||||
}
|
||||
return normalizeGuideAudioLanguage(language)
|
||||
})
|
||||
.filter(Boolean))) as GuideAudioLanguage[]
|
||||
)
|
||||
|
||||
const normalizeLanguageVariants = (
|
||||
variants: BackendGuideStopLanguageVariant[] | null | undefined
|
||||
): Record<GuideAudioLanguage, GuideStopLanguageVariant> => {
|
||||
const normalizedVariants = {} as Record<GuideAudioLanguage, GuideStopLanguageVariant>
|
||||
|
||||
;(variants || []).forEach((variant) => {
|
||||
if (!isSupportedGuideAudioLanguage(variant.lang)) return
|
||||
|
||||
const lang = normalizeGuideAudioLanguage(variant.lang)
|
||||
const playUrl = normalizeSameOriginPublicUrl(variant.playUrl) || undefined
|
||||
const playable = variant.playable === true && Boolean(playUrl)
|
||||
const text = variant.text?.trim() || undefined
|
||||
const textAvailable = variant.textAvailable === true || Boolean(text)
|
||||
|
||||
normalizedVariants[lang] = {
|
||||
lang,
|
||||
enabled: variant.enabled === true,
|
||||
playable,
|
||||
audioStatus: variant.audioStatus || (playable ? 'READY' : 'MISSING'),
|
||||
playUrl,
|
||||
duration: normalizeNumber(variant.duration),
|
||||
format: variant.format?.trim() || undefined,
|
||||
audioId: stringifyId(variant.audioId) || undefined,
|
||||
narrationTier: normalizeNarrationTier(variant.narrationTier),
|
||||
hasText: variant.hasText === true || textAvailable,
|
||||
textAvailable,
|
||||
text,
|
||||
textLength: normalizeNumber(variant.textLength),
|
||||
textHash: variant.textHash?.trim() || undefined,
|
||||
fallback: variant.fallback === true,
|
||||
reason: variant.reason?.trim() || undefined
|
||||
}
|
||||
})
|
||||
|
||||
return normalizedVariants
|
||||
}
|
||||
|
||||
const normalizeVoiceGender = (value: string | null | undefined): GuideAudioVoiceGender | undefined => {
|
||||
const normalizeAudioGender = (value: string | null | undefined): GuideAudioGender | null => {
|
||||
const normalized = value?.trim().toLowerCase()
|
||||
return normalized === 'female' || normalized === 'male' ? normalized : undefined
|
||||
return normalized === 'male' || normalized === 'female' ? normalized : null
|
||||
}
|
||||
|
||||
const normalizeAudioOptions = (
|
||||
options: BackendGuideStopAudioOption[] | null | undefined
|
||||
): GuideStopAudioOption[] => (
|
||||
(options || [])
|
||||
.map<GuideStopAudioOption | null>((option) => {
|
||||
if (!isSupportedGuideAudioLanguage(option.languageCode)) return null
|
||||
|
||||
const channelCode = option.channelCode?.trim()
|
||||
const gender = normalizeVoiceGender(option.gender)
|
||||
const playUrl = normalizeSameOriginPublicUrl(option.playUrl) || undefined
|
||||
const normalizeAudioOptions = (items: BackendGuideAudioOption[] | null | undefined): MuseumGuideAudioOption[] => (
|
||||
(items || [])
|
||||
.map<MuseumGuideAudioOption | null>((item) => {
|
||||
const channelCode = item.channelCode?.trim()
|
||||
const languageCode = normalizeGuideAudioLanguage(item.languageCode)
|
||||
const gender = normalizeAudioGender(item.gender)
|
||||
const playUrl = normalizeSameOriginPublicUrl(item.playUrl)
|
||||
if (!channelCode || !gender || !playUrl) return null
|
||||
|
||||
const languageCode = normalizeGuideAudioLanguage(option.languageCode)
|
||||
const displayName = option.displayName?.trim()
|
||||
|| `${languageCode === 'en-US' ? '英文' : languageCode === 'yue-HK' ? '粤语' : '普通话'}${gender === 'female' ? '女声' : '男声'}`
|
||||
|
||||
return {
|
||||
channelCode,
|
||||
displayName,
|
||||
displayName: item.displayName?.trim() || undefined,
|
||||
version: item.version?.trim() || undefined,
|
||||
languageCode,
|
||||
languageName: option.languageName?.trim() || undefined,
|
||||
languageName: item.languageName?.trim() || undefined,
|
||||
gender,
|
||||
playUrl,
|
||||
duration: normalizeNumber(option.duration),
|
||||
format: option.format?.trim() || undefined,
|
||||
isDefault: option.isDefault === true,
|
||||
sortOrder: normalizeNumber(option.sortOrder)
|
||||
duration: normalizeNumber(item.duration),
|
||||
format: item.format?.trim() || undefined,
|
||||
isDefault: item.isDefault === true,
|
||||
sortOrder: normalizeNumber(item.sortOrder)
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => (
|
||||
(left!.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right!.sortOrder ?? Number.MAX_SAFE_INTEGER)
|
||||
|| left!.channelCode.localeCompare(right!.channelCode)
|
||||
)) as GuideStopAudioOption[]
|
||||
)
|
||||
|
||||
const supportedLanguagesFromVariants = (variants: Record<GuideAudioLanguage, GuideStopLanguageVariant>) => (
|
||||
(Object.values(variants) as GuideStopLanguageVariant[])
|
||||
.filter((variant) => variant.enabled && (variant.playable || variant.textAvailable))
|
||||
.map((variant) => variant.lang)
|
||||
)
|
||||
|
||||
const supportedLanguagesFromAudioOptions = (audioOptions: GuideStopAudioOption[]) => (
|
||||
Array.from(new Set(audioOptions.map((option) => option.languageCode)))
|
||||
.filter(Boolean) as MuseumGuideAudioOption[]
|
||||
)
|
||||
|
||||
const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls'] | BackendGuideStopLinkedExhibit['galleryUrls']) => {
|
||||
@@ -410,11 +306,11 @@ export const toGuideStopInfo = (
|
||||
const coverImageUrl = canUseStopImages
|
||||
? normalizeSameOriginPublicUrl(source.coverImageUrl) || undefined
|
||||
: undefined
|
||||
const languageVariants = normalizeLanguageVariants(source.languageVariants)
|
||||
const audioOptions = normalizeAudioOptions(source.audioOptions)
|
||||
const supportedLanguages = audioOptions.length
|
||||
? supportedLanguagesFromAudioOptions(audioOptions)
|
||||
: supportedLanguagesFromVariants(languageVariants)
|
||||
const supportedLanguages = Array.from(new Set([
|
||||
...normalizeSupportedLanguages(source.supportedLanguages),
|
||||
...audioOptions.map((option) => option.languageCode as GuideAudioLanguage)
|
||||
]))
|
||||
|
||||
return {
|
||||
available: source.available === true,
|
||||
@@ -437,12 +333,10 @@ export const toGuideStopInfo = (
|
||||
playTargetId,
|
||||
hasAudio: source.hasAudio === true,
|
||||
hasText: source.hasText === true,
|
||||
supportedLanguages: supportedLanguages.length
|
||||
? supportedLanguages
|
||||
: normalizeSupportedLanguages(source.supportedLanguages),
|
||||
audioOptions,
|
||||
languageVariants,
|
||||
supportedLanguages,
|
||||
audioStatus: source.audioStatus || 'MISSING',
|
||||
audioOptions,
|
||||
audioOptionCount: normalizeNumber(source.audioOptionCount),
|
||||
reason: source.reason || undefined,
|
||||
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
|
||||
isSharedStop: source.isSharedStop === true
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
isIndoorNavigableFloor
|
||||
} from '@/domain/guideFloor'
|
||||
import {
|
||||
isVisitorRestrictedPlaceName,
|
||||
normalizePoiSemanticValue
|
||||
} from '@/domain/poiCategories'
|
||||
|
||||
@@ -194,16 +193,6 @@ const isPoiAccessible = (poi: StaticNavPoiPayload) => (
|
||||
|| poi.categories?.some((category) => category.topCategory === 'accessibility_special_service') === true
|
||||
)
|
||||
|
||||
const resolveStaticPoiVisitorVisible = (
|
||||
poi: StaticNavPoiPayload,
|
||||
semanticType: string
|
||||
) => (
|
||||
typeof poi.visitorVisible === 'boolean'
|
||||
? poi.visitorVisible
|
||||
: semanticType !== 'service_space'
|
||||
&& ![poi.name, poi.sourceObjectName].some(isVisitorRestrictedPlaceName)
|
||||
)
|
||||
|
||||
export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => {
|
||||
const categoryFallbackIconType = poi.categories?.[0]?.iconType
|
||||
const iconType = getStaticPoiSemanticType(poi, poi.iconType || categoryFallbackIconType)
|
||||
@@ -225,7 +214,6 @@ export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => {
|
||||
sourceObjectName: poi.sourceObjectName,
|
||||
sourceConfidence: poi.sourceConfidence,
|
||||
navigationReadiness: poi.navigationReadiness,
|
||||
visitorVisible: resolveStaticPoiVisitorVisible(poi, iconType),
|
||||
accessible: isPoiAccessible(poi),
|
||||
kind,
|
||||
hallName: kind === 'hall' ? poi.name : undefined
|
||||
|
||||
@@ -143,6 +143,7 @@ const toTarget = (
|
||||
anchor: NavRouteAnchor,
|
||||
poi?: StaticNavPoiPayload
|
||||
): GuideRouteTarget => ({
|
||||
routeTargetId: `${anchor.poiId}:${anchor.routeNodeId}`,
|
||||
poiId: anchor.poiId,
|
||||
name: poi?.name || anchor.name,
|
||||
floorId: anchor.floorId,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,8 @@ import type {
|
||||
GuideRouteFloorSegment,
|
||||
GuideRoutePoint,
|
||||
GuideRouteResult,
|
||||
GuideRouteTarget
|
||||
GuideRouteTarget,
|
||||
GuideRouteTransition
|
||||
} from '@/domain/museum'
|
||||
import type {
|
||||
SgsRoutePathPointPayload,
|
||||
@@ -13,7 +14,6 @@ import type {
|
||||
import type {
|
||||
SgsRouteResult as SgsSdkRuntimeRouteResult
|
||||
} from '@/types/sgs-map-sdk'
|
||||
import { toAppFloorId } from '@/services/sgs/SgsMapEventAdapter'
|
||||
|
||||
type SgsRoutePathNode = {
|
||||
nodeId?: string | number
|
||||
@@ -42,6 +42,87 @@ const stringifyRouteId = (value: unknown, fallback = '') => {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Route responses can mix the published database floor id with a floor code
|
||||
* such as `L5` or `5F`. The renderer uses the published id as its model key,
|
||||
* so route data must be resolved to that id at the adapter boundary.
|
||||
*/
|
||||
const canonicalFloorKey = (value: unknown) => {
|
||||
const normalized = stringifyRouteId(value).trim().toUpperCase()
|
||||
if (!normalized || /^\d{6,}$/.test(normalized)) return ''
|
||||
|
||||
if (normalized === 'EXTERIOR' || normalized.includes('室外') || normalized.includes('外观')) {
|
||||
return 'exterior'
|
||||
}
|
||||
|
||||
const basementMatch = normalized.match(/^(?:B|L-?|负)\s*(\d+(?:\.\d+)?)(?:层|F)?$/)
|
||||
if (basementMatch && (normalized.startsWith('B') || normalized.startsWith('L-') || normalized.startsWith('负'))) {
|
||||
return `l-${Number(basementMatch[1])}`
|
||||
}
|
||||
|
||||
if (normalized === 'MF') return 'l1.5'
|
||||
|
||||
const floorMatch = normalized.match(/^(?:L|F)?\s*(\d+(?:\.\d+)?)(?:F|层)?$/)
|
||||
if (floorMatch) return `l${Number(floorMatch[1])}`
|
||||
|
||||
const chineseFloorMatch = normalized.match(/^(?:负\s*)?(\d+(?:\.\d+)?)\s*层$/)
|
||||
if (chineseFloorMatch) {
|
||||
return normalized.startsWith('负')
|
||||
? `l-${Number(chineseFloorMatch[1])}`
|
||||
: `l${Number(chineseFloorMatch[1])}`
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
type RouteFloorIdResolver = (value: unknown, fallback?: string, hints?: unknown[]) => string
|
||||
|
||||
const createRouteFloorIdResolver = (
|
||||
startTarget: GuideRouteTarget,
|
||||
endTarget: GuideRouteTarget,
|
||||
backendSegments: SgsRoutePlanResponsePayload['segments'] = []
|
||||
): RouteFloorIdResolver => {
|
||||
const aliases = new Map<string, string>()
|
||||
|
||||
const addAlias = (value: unknown, floorId: string) => {
|
||||
const raw = stringifyRouteId(value).trim()
|
||||
if (!raw || aliases.has(raw.toLowerCase())) return
|
||||
aliases.set(raw.toLowerCase(), floorId)
|
||||
const canonical = canonicalFloorKey(raw)
|
||||
if (canonical && !aliases.has(canonical)) aliases.set(canonical, floorId)
|
||||
}
|
||||
|
||||
const registerFloor = (floorId: unknown, ...tokens: unknown[]) => {
|
||||
const resolvedFloorId = stringifyRouteId(floorId)
|
||||
if (!resolvedFloorId) return
|
||||
addAlias(resolvedFloorId, resolvedFloorId)
|
||||
tokens.forEach((token) => addAlias(token, resolvedFloorId))
|
||||
}
|
||||
|
||||
registerFloor(startTarget.floorId, startTarget.floorLabel)
|
||||
registerFloor(endTarget.floorId, endTarget.floorLabel)
|
||||
;(backendSegments || []).forEach((segment) => {
|
||||
registerFloor(segment.floorId, segment.floorCode, segment.floorName)
|
||||
registerFloor(segment.targetFloorId)
|
||||
registerFloor(segment.fromFloorId)
|
||||
})
|
||||
|
||||
return (value, fallback = '', hints = []) => {
|
||||
const raw = stringifyRouteId(value)
|
||||
if (raw && aliases.has(raw.toLowerCase())) return aliases.get(raw.toLowerCase()) || raw
|
||||
|
||||
const candidates = [raw, ...hints]
|
||||
.map((candidate) => canonicalFloorKey(candidate))
|
||||
.filter(Boolean)
|
||||
for (const candidate of candidates) {
|
||||
const resolved = aliases.get(candidate)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
|
||||
return raw || fallback
|
||||
}
|
||||
}
|
||||
|
||||
const samePosition = (
|
||||
a: [number, number, number],
|
||||
b: [number, number, number]
|
||||
@@ -53,10 +134,13 @@ const pointPosition = (point: SgsRoutePathNode): [number, number, number] => [
|
||||
finiteNumber(point.z) ?? 0
|
||||
]
|
||||
|
||||
const targetEndpoint = (target: GuideRouteTarget): GuideRouteEndpoint => ({
|
||||
const targetEndpoint = (
|
||||
target: GuideRouteTarget,
|
||||
resolveFloorId: RouteFloorIdResolver
|
||||
): GuideRouteEndpoint => ({
|
||||
poiId: target.poiId,
|
||||
name: target.name,
|
||||
floorId: target.floorId,
|
||||
floorId: resolveFloorId(target.floorId),
|
||||
floorLabel: target.floorLabel,
|
||||
routeNodeId: target.routeNodeId,
|
||||
position: target.positionGltf || [0, 0, 0]
|
||||
@@ -65,7 +149,8 @@ const targetEndpoint = (target: GuideRouteTarget): GuideRouteEndpoint => ({
|
||||
const normalizePath = (
|
||||
route: CompatibleSgsRouteResult,
|
||||
startFloorId: string,
|
||||
endFloorId: string
|
||||
endFloorId: string,
|
||||
resolveFloorId: RouteFloorIdResolver
|
||||
): GuideRoutePoint[] => {
|
||||
const pathPoints = route.pathPoints ?? route.path ?? []
|
||||
|
||||
@@ -76,15 +161,15 @@ const normalizePath = (
|
||||
if (route.path?.length) {
|
||||
return route.path.map((point, index) => ({
|
||||
nodeId: String(point.nodeId || `sdk-route-point-${index}`),
|
||||
floorId: toAppFloorId(point.floorId ?? startFloorId),
|
||||
floorId: resolveFloorId(point.floorId, startFloorId),
|
||||
position: pointPosition(point)
|
||||
}))
|
||||
}
|
||||
|
||||
const isCrossFloor = startFloorId !== endFloorId
|
||||
const totalPoints = pathPoints.length
|
||||
const normalizedStartFloorId = toAppFloorId(startFloorId)
|
||||
const normalizedEndFloorId = toAppFloorId(endFloorId)
|
||||
const normalizedStartFloorId = resolveFloorId(startFloorId, startFloorId)
|
||||
const normalizedEndFloorId = resolveFloorId(endFloorId, endFloorId)
|
||||
|
||||
return pathPoints.map((point, index) => {
|
||||
const nodeId = `sdk-pp-${index}`
|
||||
@@ -113,10 +198,11 @@ const normalizePath = (
|
||||
const createFloorSegments = (
|
||||
points: GuideRoutePoint[],
|
||||
start: GuideRouteTarget,
|
||||
end: GuideRouteTarget
|
||||
end: GuideRouteTarget,
|
||||
resolveFloorId: RouteFloorIdResolver
|
||||
): GuideRouteFloorSegment[] => {
|
||||
const normalizedStartFloorId = toAppFloorId(start.floorId)
|
||||
const normalizedEndFloorId = toAppFloorId(end.floorId)
|
||||
const normalizedStartFloorId = resolveFloorId(start.floorId, start.floorId)
|
||||
const normalizedEndFloorId = resolveFloorId(end.floorId, end.floorId)
|
||||
|
||||
const labels = new Map([
|
||||
[normalizedStartFloorId, start.floorLabel],
|
||||
@@ -125,15 +211,16 @@ const createFloorSegments = (
|
||||
|
||||
return points.reduce<GuideRouteFloorSegment[]>((segments, point) => {
|
||||
const current = segments[segments.length - 1]
|
||||
if (current && current.floorId === point.floorId) {
|
||||
const pointFloorId = resolveFloorId(point.floorId, normalizedStartFloorId)
|
||||
if (current && current.floorId === pointFloorId) {
|
||||
current.points.push(point)
|
||||
return segments
|
||||
}
|
||||
|
||||
segments.push({
|
||||
floorId: point.floorId,
|
||||
floorLabel: labels.get(point.floorId) || point.floorId,
|
||||
points: [point]
|
||||
floorId: pointFloorId,
|
||||
floorLabel: labels.get(pointFloorId) || pointFloorId,
|
||||
points: [{ ...point, floorId: pointFloorId }]
|
||||
})
|
||||
return segments
|
||||
}, [])
|
||||
@@ -144,23 +231,25 @@ export const toGuideRouteResultFromSgs = (
|
||||
startTarget: GuideRouteTarget,
|
||||
endTarget: GuideRouteTarget
|
||||
): GuideRouteResult => {
|
||||
const start = targetEndpoint(startTarget)
|
||||
const end = targetEndpoint(endTarget)
|
||||
const resolveFloorId = createRouteFloorIdResolver(startTarget, endTarget)
|
||||
const start = targetEndpoint(startTarget, resolveFloorId)
|
||||
const end = targetEndpoint(endTarget, resolveFloorId)
|
||||
const points = normalizePath(
|
||||
route as CompatibleSgsRouteResult,
|
||||
startTarget.floorId,
|
||||
endTarget.floorId
|
||||
start.floorId,
|
||||
end.floorId,
|
||||
resolveFloorId
|
||||
)
|
||||
const routePoints = points.length
|
||||
? points
|
||||
: [
|
||||
{
|
||||
nodeId: start.routeNodeId,
|
||||
nodeId: start.routeNodeId || `${start.poiId}-start`,
|
||||
floorId: start.floorId,
|
||||
position: start.position
|
||||
},
|
||||
{
|
||||
nodeId: end.routeNodeId,
|
||||
nodeId: end.routeNodeId || `${end.poiId}-end`,
|
||||
floorId: end.floorId,
|
||||
position: end.position
|
||||
}
|
||||
@@ -173,27 +262,28 @@ export const toGuideRouteResultFromSgs = (
|
||||
distanceMeters: Number(route.distance || 0),
|
||||
nodeIds: routePoints.map((point) => point.nodeId),
|
||||
points: routePoints,
|
||||
floorSegments: createFloorSegments(routePoints, startTarget, endTarget),
|
||||
floorSegments: createFloorSegments(routePoints, startTarget, endTarget, resolveFloorId),
|
||||
connectorPoints: []
|
||||
}
|
||||
}
|
||||
|
||||
const floorLabelFor = (
|
||||
floorId: string,
|
||||
startTarget: GuideRouteTarget,
|
||||
endTarget: GuideRouteTarget,
|
||||
start: Pick<GuideRouteEndpoint, 'floorId' | 'floorLabel'>,
|
||||
end: Pick<GuideRouteEndpoint, 'floorId' | 'floorLabel'>,
|
||||
fallbackLabel?: string | null
|
||||
) => {
|
||||
if (fallbackLabel) return fallbackLabel
|
||||
if (floorId === startTarget.floorId) return startTarget.floorLabel
|
||||
if (floorId === endTarget.floorId) return endTarget.floorLabel
|
||||
if (floorId === start.floorId) return start.floorLabel
|
||||
if (floorId === end.floorId) return end.floorLabel
|
||||
return floorId
|
||||
}
|
||||
|
||||
const pointFromBackendNode = (
|
||||
node: NonNullable<SgsRoutePlanResponsePayload['nodePaths']>[number],
|
||||
index: number,
|
||||
fallbackFloorId: string
|
||||
fallbackFloorId: string,
|
||||
resolveFloorId: RouteFloorIdResolver
|
||||
): GuideRoutePoint | null => {
|
||||
const x = finiteNumber(node.x)
|
||||
const z = finiteNumber(node.y)
|
||||
@@ -201,7 +291,7 @@ const pointFromBackendNode = (
|
||||
|
||||
return {
|
||||
nodeId: stringifyRouteId(node.id, `sgs-route-node-${index}`),
|
||||
floorId: stringifyRouteId(node.floorId, fallbackFloorId),
|
||||
floorId: resolveFloorId(node.floorId, fallbackFloorId),
|
||||
position: [
|
||||
x,
|
||||
finiteNumber(node.z) ?? 0,
|
||||
@@ -304,23 +394,63 @@ const appendIfDifferent = (
|
||||
|
||||
const createPlanFloorSegments = (
|
||||
points: GuideRoutePoint[],
|
||||
startTarget: GuideRouteTarget,
|
||||
endTarget: GuideRouteTarget
|
||||
start: GuideRouteEndpoint,
|
||||
end: GuideRouteEndpoint,
|
||||
resolveFloorId: RouteFloorIdResolver
|
||||
): GuideRouteFloorSegment[] => points.reduce<GuideRouteFloorSegment[]>((segments, point) => {
|
||||
const floorId = resolveFloorId(point.floorId, start.floorId)
|
||||
const current = segments[segments.length - 1]
|
||||
if (current && current.floorId === point.floorId) {
|
||||
current.points.push(point)
|
||||
if (current && current.floorId === floorId) {
|
||||
current.points.push({ ...point, floorId })
|
||||
return segments
|
||||
}
|
||||
|
||||
segments.push({
|
||||
floorId: point.floorId,
|
||||
floorLabel: floorLabelFor(point.floorId, startTarget, endTarget),
|
||||
points: [point]
|
||||
floorId,
|
||||
floorLabel: floorLabelFor(floorId, start, end),
|
||||
points: [{ ...point, floorId }]
|
||||
})
|
||||
return segments
|
||||
}, [])
|
||||
|
||||
const createRouteTransitions = (
|
||||
floorSegments: GuideRouteFloorSegment[],
|
||||
backendSegments: SgsRoutePlanResponsePayload['segments'],
|
||||
resolveFloorId: RouteFloorIdResolver
|
||||
): GuideRouteTransition[] => {
|
||||
const transferSegments = (backendSegments || []).filter((segment) => !isWalkSegment(segment))
|
||||
const transitions: GuideRouteTransition[] = []
|
||||
|
||||
for (let index = 1; index < floorSegments.length; index += 1) {
|
||||
const fromSegment = floorSegments[index - 1]
|
||||
const toSegment = floorSegments[index]
|
||||
const fromPoint = fromSegment.points[fromSegment.points.length - 1]
|
||||
const toPoint = toSegment.points[0]
|
||||
if (!fromPoint || !toPoint || fromSegment.floorId === toSegment.floorId) continue
|
||||
|
||||
const transfer = transferSegments[transitions.length]
|
||||
const explicitFromFloorId = resolveFloorId(transfer?.fromFloorId, fromSegment.floorId)
|
||||
const explicitToFloorId = resolveFloorId(transfer?.targetFloorId, toSegment.floorId)
|
||||
|
||||
// Prefer the formal transfer contract. The adjacent WALK segments are
|
||||
// retained only as a fallback for route responses generated before it.
|
||||
if (explicitFromFloorId !== fromSegment.floorId) continue
|
||||
if (explicitToFloorId !== toSegment.floorId) continue
|
||||
|
||||
transitions.push({
|
||||
id: `transition-${fromPoint.nodeId}-${toPoint.nodeId}`,
|
||||
fromFloorId: explicitFromFloorId,
|
||||
toFloorId: explicitToFloorId,
|
||||
fromPosition: fromPoint.position,
|
||||
toPosition: toPoint.position,
|
||||
transferType: stringifyRouteId(transfer?.transferType || transfer?.segmentType || transfer?.type),
|
||||
connectorName: transfer?.connectorName || transfer?.startNodeName || transfer?.endNodeName || undefined
|
||||
})
|
||||
}
|
||||
|
||||
return transitions
|
||||
}
|
||||
|
||||
const isWalkSegment = (
|
||||
segment: NonNullable<SgsRoutePlanResponsePayload['segments']>[number]
|
||||
) => {
|
||||
@@ -334,12 +464,17 @@ const createSegmentFloorSegments = (
|
||||
end: GuideRouteEndpoint,
|
||||
startTarget: GuideRouteTarget,
|
||||
endTarget: GuideRouteTarget,
|
||||
nodePoints: GuideRoutePoint[]
|
||||
nodePoints: GuideRoutePoint[],
|
||||
resolveFloorId: RouteFloorIdResolver
|
||||
): GuideRouteFloorSegment[] => {
|
||||
const segments = (route.segments || [])
|
||||
.filter(isWalkSegment)
|
||||
.map<GuideRouteFloorSegment | null>((segment, segmentIndex) => {
|
||||
const floorId = stringifyRouteId(segment.floorId, start.floorId)
|
||||
const floorId = resolveFloorId(
|
||||
segment.floorId,
|
||||
start.floorId,
|
||||
[segment.floorCode, segment.floorName]
|
||||
)
|
||||
const nodeIdPrefix = `${floorId}-segment-${segmentIndex}`
|
||||
const nodePathIds = new Set(
|
||||
(segment.nodePathIds || [])
|
||||
@@ -353,7 +488,7 @@ const createSegmentFloorSegments = (
|
||||
const fallbackPoints = geoPoints.length
|
||||
? geoPoints
|
||||
: nodePoints.filter((point) => (
|
||||
point.floorId === floorId
|
||||
resolveFloorId(point.floorId, start.floorId) === floorId
|
||||
&& (!nodePathIds.size || nodePathIds.has(point.nodeId))
|
||||
))
|
||||
|
||||
@@ -361,7 +496,7 @@ const createSegmentFloorSegments = (
|
||||
|
||||
return {
|
||||
floorId,
|
||||
floorLabel: floorLabelFor(floorId, startTarget, endTarget, segment.floorName),
|
||||
floorLabel: floorLabelFor(floorId, start, end, segment.floorName),
|
||||
points: fallbackPoints.map((point, index) => ({
|
||||
...point,
|
||||
nodeId: point.nodeId || `${nodeIdPrefix}-${index}`
|
||||
@@ -397,12 +532,13 @@ export const toGuideRouteResultFromSgsPlan = (
|
||||
startTarget: GuideRouteTarget,
|
||||
endTarget: GuideRouteTarget
|
||||
): GuideRouteResult => {
|
||||
const start = targetEndpoint(startTarget)
|
||||
const end = targetEndpoint(endTarget)
|
||||
const resolveFloorId = createRouteFloorIdResolver(startTarget, endTarget, route.segments)
|
||||
const start = targetEndpoint(startTarget, resolveFloorId)
|
||||
const end = targetEndpoint(endTarget, resolveFloorId)
|
||||
const startPoint = routePointFromEndpoint(start, 'start')
|
||||
const endPoint = routePointFromEndpoint(end, 'end')
|
||||
const nodePoints = (route.nodePaths || [])
|
||||
.map((node, index) => pointFromBackendNode(node, index, start.floorId))
|
||||
.map((node, index) => pointFromBackendNode(node, index, start.floorId, resolveFloorId))
|
||||
.filter((point): point is GuideRoutePoint => Boolean(point))
|
||||
const segmentFloorSegments = createSegmentFloorSegments(
|
||||
route,
|
||||
@@ -410,7 +546,8 @@ export const toGuideRouteResultFromSgsPlan = (
|
||||
end,
|
||||
startTarget,
|
||||
endTarget,
|
||||
nodePoints
|
||||
nodePoints,
|
||||
resolveFloorId
|
||||
)
|
||||
const routeGeoPoints = pointsFromGeoJson(route.pathGeoJson, start.floorId, 'sgs-route')
|
||||
const fallbackPoints = nodePoints.length
|
||||
@@ -420,8 +557,9 @@ export const toGuideRouteResultFromSgsPlan = (
|
||||
: [startPoint, endPoint]
|
||||
const floorSegments = segmentFloorSegments.length
|
||||
? segmentFloorSegments
|
||||
: createPlanFloorSegments(fallbackPoints, startTarget, endTarget)
|
||||
: createPlanFloorSegments(fallbackPoints, start, end, resolveFloorId)
|
||||
const routePoints = floorSegments.flatMap((segment) => segment.points)
|
||||
const transitions = createRouteTransitions(floorSegments, route.segments, resolveFloorId)
|
||||
|
||||
return {
|
||||
id: `sgs-api-route-${start.poiId}-${end.poiId}`,
|
||||
@@ -431,21 +569,22 @@ export const toGuideRouteResultFromSgsPlan = (
|
||||
nodeIds: routePoints.map((point) => point.nodeId),
|
||||
points: routePoints,
|
||||
floorSegments,
|
||||
connectorPoints: extractFloorConnectorPoints(routePoints, startTarget, endTarget)
|
||||
connectorPoints: extractFloorConnectorPoints(routePoints, start, end),
|
||||
transitions
|
||||
}
|
||||
}
|
||||
|
||||
const extractFloorConnectorPoints = (
|
||||
points: GuideRoutePoint[],
|
||||
startTarget: GuideRouteTarget,
|
||||
endTarget: GuideRouteTarget
|
||||
start: GuideRouteEndpoint,
|
||||
end: GuideRouteEndpoint
|
||||
): GuideRouteConnectorPoint[] => {
|
||||
if (points.length < 2) return []
|
||||
|
||||
const connectors: GuideRouteConnectorPoint[] = []
|
||||
const labels = new Map<string, string>([
|
||||
[startTarget.floorId, startTarget.floorLabel],
|
||||
[endTarget.floorId, endTarget.floorLabel]
|
||||
[start.floorId, start.floorLabel],
|
||||
[end.floorId, end.floorLabel]
|
||||
])
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
type BackendCatalogOutlineItem,
|
||||
type BackendCatalogStopItem
|
||||
} from '@/data/adapters/backendExplainDataAdapter'
|
||||
import {
|
||||
readPersistentJsonCache,
|
||||
writePersistentJsonCache
|
||||
} from '@/utils/persistentJsonCache'
|
||||
|
||||
interface CommonResult<T> {
|
||||
code: number
|
||||
@@ -78,6 +82,7 @@ const catalogLang = () => dataSourceConfig.audioLanguage
|
||||
|
||||
const cacheKey = (...parts: string[]) => [catalogLang(), ...parts].join(':')
|
||||
const CATALOG_CACHE_TTL_MS = 60_000
|
||||
const CATALOG_PERSISTENT_CACHE_TTL_MS = 5 * 60_000
|
||||
const CATALOG_CACHE_MAX_ENTRIES = 80
|
||||
|
||||
interface TimedCacheEntry<T> {
|
||||
@@ -148,6 +153,24 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private persistentCacheKey(key: string) {
|
||||
return [
|
||||
'sgs-mobile',
|
||||
'explain-catalog',
|
||||
'v1',
|
||||
dataSourceConfig.apiBaseUrl,
|
||||
key
|
||||
].map((part) => encodeURIComponent(part)).join(':')
|
||||
}
|
||||
|
||||
private readPersistent<T>(key: string, allowExpired = false) {
|
||||
return readPersistentJsonCache<T>(this.persistentCacheKey(key), allowExpired)
|
||||
}
|
||||
|
||||
private writePersistent<T>(key: string, value: T) {
|
||||
writePersistentJsonCache(this.persistentCacheKey(key), value, CATALOG_PERSISTENT_CACHE_TTL_MS)
|
||||
}
|
||||
|
||||
private async safeFallbackHalls() {
|
||||
if (!this.isStaticFallbackEnabled()) return []
|
||||
|
||||
@@ -177,6 +200,12 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
||||
const cached = this.getCached(this.hallListCache, key)
|
||||
if (cached) return cached
|
||||
|
||||
const persistent = this.readPersistent<MuseumHall[]>(key)
|
||||
if (persistent) {
|
||||
this.setCached(this.hallListCache, key, persistent)
|
||||
return persistent
|
||||
}
|
||||
|
||||
const inflight = this.hallListInflight.get(key)
|
||||
if (inflight) return inflight
|
||||
|
||||
@@ -196,8 +225,13 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
||||
.filter((hall) => hall.id)
|
||||
|
||||
this.setCached(this.hallListCache, key, halls)
|
||||
this.writePersistent(key, halls)
|
||||
return halls
|
||||
})()
|
||||
})().catch((error) => {
|
||||
const stale = this.readPersistent<MuseumHall[]>(key, true)
|
||||
if (stale) return stale
|
||||
throw error
|
||||
})
|
||||
|
||||
this.hallListInflight.set(key, promise)
|
||||
try {
|
||||
@@ -215,6 +249,12 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
||||
const cached = this.getCached(this.outlineCache, key)
|
||||
if (cached) return cached
|
||||
|
||||
const persistent = this.readPersistent<BackendCatalogOutlineItem[]>(key)
|
||||
if (persistent) {
|
||||
this.setCached(this.outlineCache, key, persistent)
|
||||
return persistent
|
||||
}
|
||||
|
||||
const inflight = this.outlineInflight.get(key)
|
||||
if (inflight) return inflight
|
||||
|
||||
@@ -227,8 +267,13 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
||||
const response = await requestJson<CommonResult<BackendCatalogOutlineItem[]>>(url)
|
||||
const outlines = requireArrayData(response, '讲解单元目录加载失败')
|
||||
this.setCached(this.outlineCache, key, outlines)
|
||||
this.writePersistent(key, outlines)
|
||||
return outlines
|
||||
})()
|
||||
})().catch((error) => {
|
||||
const stale = this.readPersistent<BackendCatalogOutlineItem[]>(key, true)
|
||||
if (stale) return stale
|
||||
throw error
|
||||
})
|
||||
|
||||
this.outlineInflight.set(key, promise)
|
||||
try {
|
||||
@@ -248,6 +293,12 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
||||
const cached = this.getCached(this.guideStopPageCache, key)
|
||||
if (cached) return cached
|
||||
|
||||
const persistent = this.readPersistent<ExplainGuideStopPage>(key)
|
||||
if (persistent) {
|
||||
this.setCached(this.guideStopPageCache, key, persistent)
|
||||
return persistent
|
||||
}
|
||||
|
||||
const inflight = this.guideStopPageInflight.get(key)
|
||||
if (inflight) return inflight
|
||||
|
||||
@@ -268,8 +319,13 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
||||
hasMore: normalizedPageNo * normalizedPageSize < data.total
|
||||
}
|
||||
this.setCached(this.guideStopPageCache, key, page)
|
||||
this.writePersistent(key, page)
|
||||
return page
|
||||
})()
|
||||
})().catch((error) => {
|
||||
const stale = this.readPersistent<ExplainGuideStopPage>(key, true)
|
||||
if (stale) return stale
|
||||
throw error
|
||||
})
|
||||
|
||||
this.guideStopPageInflight.set(key, promise)
|
||||
try {
|
||||
@@ -286,6 +342,12 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
||||
const cached = this.getCached(this.guideStopCache, key)
|
||||
if (cached) return cached
|
||||
|
||||
const persistent = this.readPersistent<ExplainGuideStop[]>(key)
|
||||
if (persistent) {
|
||||
this.setCached(this.guideStopCache, key, persistent)
|
||||
return persistent
|
||||
}
|
||||
|
||||
const inflight = this.guideStopInflight.get(key)
|
||||
if (inflight) return inflight
|
||||
|
||||
@@ -303,8 +365,13 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
||||
if (!page.hasMore || stops.length >= page.total) break
|
||||
}
|
||||
this.setCached(this.guideStopCache, key, stops)
|
||||
this.writePersistent(key, stops)
|
||||
return stops
|
||||
})()
|
||||
})().catch((error) => {
|
||||
const stale = this.readPersistent<ExplainGuideStop[]>(key, true)
|
||||
if (stale) return stale
|
||||
throw error
|
||||
})
|
||||
|
||||
this.guideStopInflight.set(key, promise)
|
||||
try {
|
||||
@@ -329,6 +396,12 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
||||
const cached = this.getCached(this.guideStopCache, key)
|
||||
if (cached) return cached
|
||||
|
||||
const persistent = this.readPersistent<ExplainGuideStop[]>(key)
|
||||
if (persistent) {
|
||||
this.setCached(this.guideStopCache, key, persistent)
|
||||
return persistent
|
||||
}
|
||||
|
||||
const inflight = this.guideStopInflight.get(key)
|
||||
if (inflight) return inflight
|
||||
|
||||
@@ -353,8 +426,13 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
||||
.filter(Boolean) as ExplainGuideStop[]
|
||||
|
||||
this.setCached(this.guideStopCache, key, stops)
|
||||
this.writePersistent(key, stops)
|
||||
return stops
|
||||
})()
|
||||
})().catch((error) => {
|
||||
const stale = this.readPersistent<ExplainGuideStop[]>(key, true)
|
||||
if (stale) return stale
|
||||
throw error
|
||||
})
|
||||
|
||||
this.guideStopInflight.set(key, promise)
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import {
|
||||
dataSourceConfig
|
||||
} from '@/config/dataSource'
|
||||
import {
|
||||
startGuidePerformance
|
||||
} from '@/services/performance/guidePerformance'
|
||||
import {
|
||||
readPersistentJsonCache,
|
||||
writePersistentJsonCache
|
||||
} from '@/utils/persistentJsonCache'
|
||||
|
||||
export type SgsDiagnosticsStatusPayload = 'OK' | 'WARN' | 'ERROR'
|
||||
|
||||
@@ -10,6 +17,11 @@ export interface SgsSdkFloorSummaryPayload {
|
||||
floorName?: string | null
|
||||
sortOrder?: number | null
|
||||
modelSizeBytes?: number | null
|
||||
/** Manifest 首屏模型元数据,避免仅为模型地址读取整份楼层 Bundle。 */
|
||||
modelUrl?: string | null
|
||||
fallbackModelUrl?: string | null
|
||||
compressionType?: 'draco' | 'none' | string | null
|
||||
modelVersion?: string | null
|
||||
poiCount?: number | null
|
||||
spaceCount?: number | null
|
||||
}
|
||||
@@ -23,9 +35,23 @@ export interface SgsSdkManifestPayload {
|
||||
updatedAt?: string | null
|
||||
coordinateSystem?: string | null
|
||||
floors: SgsSdkFloorSummaryPayload[]
|
||||
routeAssets?: SgsRouteAssetPayload[]
|
||||
capabilities?: Record<string, boolean | undefined>
|
||||
}
|
||||
|
||||
export interface SgsRouteAssetPayload {
|
||||
id?: string | number | null
|
||||
floorId?: string | number | null
|
||||
floorCode?: string | null
|
||||
assetRole?: string | null
|
||||
modelUrl?: string | null
|
||||
sourceFileName?: string | null
|
||||
sourceNodeName?: string | null
|
||||
modelVersion?: string | null
|
||||
coverageFloorCodes?: string[] | null
|
||||
sortOrder?: number | null
|
||||
}
|
||||
|
||||
export type SgsPoiGroupPayload = 'SERVICE' | 'BUSINESS' | 'OTHER'
|
||||
|
||||
export type SgsBusinessPoiTypePayload =
|
||||
@@ -60,13 +86,16 @@ export interface SgsPoiPayload {
|
||||
typeName?: string | null
|
||||
floorCode?: string | null
|
||||
floorId?: string | number | null
|
||||
meshCenter?: SgsPositionPayload | null
|
||||
position?: SgsPositionPayload | null
|
||||
labelPosition?: SgsPositionPayload | null
|
||||
x?: number | null
|
||||
y?: number | null
|
||||
z?: number | null
|
||||
status?: string | null
|
||||
visitorVisible?: boolean | null
|
||||
anchorNodeName?: string | null
|
||||
sourceNodeName?: string | null
|
||||
extParams?: string | Record<string, unknown> | null
|
||||
description?: string | null
|
||||
iconUrl?: string | null
|
||||
poiGroup?: SgsPoiGroupPayload | string | null
|
||||
@@ -82,13 +111,19 @@ export interface SgsPoiPayload {
|
||||
export interface SgsSpacePayload {
|
||||
id: string | number
|
||||
name?: string | null
|
||||
displayName?: string | null
|
||||
type?: string | null
|
||||
typeName?: string | null
|
||||
floorId?: string | number | null
|
||||
floorCode?: string | null
|
||||
boundaryWkt?: string | null
|
||||
labelPosition?: SgsPositionPayload | null
|
||||
meshCenter?: SgsPositionPayload | null
|
||||
position?: SgsPositionPayload | null
|
||||
center?: SgsPositionPayload | null
|
||||
centerPoint?: SgsPositionPayload | null
|
||||
sourceNodeName?: string | null
|
||||
status?: string | null
|
||||
visitorVisible?: boolean | null
|
||||
colorHex?: string | null
|
||||
}
|
||||
|
||||
@@ -107,6 +142,9 @@ export interface SgsNavigablePlacePayload {
|
||||
y?: number | null
|
||||
z?: number | null
|
||||
nodeId?: string | number | null
|
||||
anchorId?: string | null
|
||||
anchorType?: string | null
|
||||
sourceId?: string | number | null
|
||||
ownerName?: string | null
|
||||
}
|
||||
|
||||
@@ -182,6 +220,8 @@ export interface SgsFloorBundlePayload {
|
||||
model?: SgsModelInfoPayload | null
|
||||
pois?: SgsPoiPayload[]
|
||||
spaces?: SgsSpacePayload[]
|
||||
businessPois?: SgsPoiPayload[]
|
||||
navigablePlaces?: SgsNavigablePlacePayload[]
|
||||
guideStops?: SgsGuideStopPayload[]
|
||||
routeSummary?: {
|
||||
hasRouteNetwork?: boolean
|
||||
@@ -213,6 +253,8 @@ export interface SgsRoutePlanRequestPayload {
|
||||
endY: number
|
||||
endNodeId?: number | string | null
|
||||
wheelchair: 0 | 1
|
||||
/** Public visitor navigation avoids stairs when an elevator or escalator route exists. */
|
||||
verticalTransferPolicy?: 'PREFER_ELEVATOR_ESCALATOR'
|
||||
}
|
||||
|
||||
export interface SgsRouteStepNodePayload {
|
||||
@@ -233,6 +275,7 @@ export interface SgsRoutePathPointPayload {
|
||||
|
||||
export interface SgsRouteSegmentPayload {
|
||||
floorId?: string | number | null
|
||||
fromFloorId?: string | number | null
|
||||
floorCode?: string | null
|
||||
floorName?: string | null
|
||||
startNodeId?: string | number | null
|
||||
@@ -244,6 +287,8 @@ export interface SgsRouteSegmentPayload {
|
||||
transferType?: string | null
|
||||
distance?: number | null
|
||||
duration?: number | null
|
||||
connectorName?: string | null
|
||||
targetFloorId?: string | number | null
|
||||
pathGeoJson?: string | null
|
||||
pathPoints?: SgsRoutePathPointPayload[]
|
||||
nodePathIds?: Array<string | number | null>
|
||||
@@ -374,6 +419,9 @@ const parseJsonPayload = <T>(payload: unknown, requestUrl: string, contentType:
|
||||
}
|
||||
|
||||
const inFlightRequests = new Map<string, Promise<unknown>>()
|
||||
// v3 invalidates floor metadata cached before mutable spaces became network-first.
|
||||
const SGS_SDK_PERSISTENT_CACHE_PREFIX = 'sgs-mobile:sdk-read:v3'
|
||||
const SGS_SDK_PERSISTENT_CACHE_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
const requestJson = <T>(
|
||||
path: string,
|
||||
@@ -382,6 +430,7 @@ const requestJson = <T>(
|
||||
const requestKey = `${options.method || 'GET'}:${path}:${JSON.stringify(options.data || {})}`
|
||||
const existing = inFlightRequests.get(requestKey)
|
||||
if (existing) return existing as Promise<T>
|
||||
const finishPerformance = startGuidePerformance('api', `${options.method || 'GET'} ${path}`)
|
||||
|
||||
const request = new Promise<T>((resolve, reject) => {
|
||||
const baseUrl = resolveAppApiBaseUrl()
|
||||
@@ -401,6 +450,7 @@ const requestJson = <T>(
|
||||
const statusCode = Number(response.statusCode || 0)
|
||||
const contentType = getHeaderValue(response.header as Record<string, string> | undefined, 'content-type')
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
finishPerformance('failure', { statusCode })
|
||||
reject(new Error(`SGS 数据接口请求失败: ${statusCode} ${requestUrl} content-type=${contentType || 'unknown'} body="${previewPayload(response.data)}"`))
|
||||
return
|
||||
}
|
||||
@@ -408,16 +458,24 @@ const requestJson = <T>(
|
||||
try {
|
||||
const body = parseJsonPayload<CommonResult<T>>(response.data, requestUrl, contentType)
|
||||
if (!body || body.code !== 0) {
|
||||
finishPerformance('failure', { statusCode, code: body?.code, message: body?.msg || '' })
|
||||
reject(new Error(`SGS 数据接口业务失败: ${requestUrl} code=${body?.code} msg=${body?.msg || ''}`))
|
||||
return
|
||||
}
|
||||
|
||||
finishPerformance('success', { statusCode })
|
||||
resolve(body.data as T)
|
||||
} catch (error) {
|
||||
finishPerformance('failure', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
fail: (error) => {
|
||||
finishPerformance('failure', {
|
||||
error: JSON.stringify(error)
|
||||
})
|
||||
reject(new Error(`SGS 数据接口网络失败: ${path} ${JSON.stringify(error)}`))
|
||||
}
|
||||
})
|
||||
@@ -427,6 +485,67 @@ const requestJson = <T>(
|
||||
return request
|
||||
}
|
||||
|
||||
const persistentCacheKeyFor = (path: string) => (
|
||||
`${SGS_SDK_PERSISTENT_CACHE_PREFIX}:${encodeURIComponent(resolveAppApiBaseUrl())}:${path}`
|
||||
)
|
||||
|
||||
const requestCachedJson = async <T>(path: string): Promise<T> => {
|
||||
const cacheKey = persistentCacheKeyFor(path)
|
||||
const cached = readPersistentJsonCache<T>(cacheKey)
|
||||
if (cached !== null) {
|
||||
const finishPerformance = startGuidePerformance('api', `GET ${path}`)
|
||||
finishPerformance('cache-hit', { layer: 'persistent' })
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await requestJson<T>(path)
|
||||
writePersistentJsonCache(cacheKey, data, SGS_SDK_PERSISTENT_CACHE_TTL_MS)
|
||||
return data
|
||||
} catch (error) {
|
||||
const stale = readPersistentJsonCache<T>(cacheKey, true)
|
||||
if (stale !== null) {
|
||||
const finishPerformance = startGuidePerformance('api', `GET ${path}`)
|
||||
finishPerformance('stale-fallback', { layer: 'persistent' })
|
||||
return stale
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const requestFreshJson = async <T>(path: string): Promise<T> => {
|
||||
const cacheKey = persistentCacheKeyFor(path)
|
||||
|
||||
try {
|
||||
const data = await requestJson<T>(path)
|
||||
writePersistentJsonCache(cacheKey, data, SGS_SDK_PERSISTENT_CACHE_TTL_MS)
|
||||
return data
|
||||
} catch (error) {
|
||||
const stale = readPersistentJsonCache<T>(cacheKey, true)
|
||||
if (stale !== null) {
|
||||
const finishPerformance = startGuidePerformance('api', `GET ${path}`)
|
||||
finishPerformance('stale-fallback', { layer: 'persistent' })
|
||||
return stale
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const requestCachedCollectionJson = async <T>(path: string): Promise<T[]> => {
|
||||
const cacheKey = persistentCacheKeyFor(path)
|
||||
const cached = readPersistentJsonCache<T[]>(cacheKey)
|
||||
if (cached !== null && cached.length > 0) {
|
||||
const finishPerformance = startGuidePerformance('api', `GET ${path}`)
|
||||
finishPerformance('cache-hit', { layer: 'persistent' })
|
||||
return cached
|
||||
}
|
||||
|
||||
// Empty mutable collections are not authoritative across deployments. A
|
||||
// fresh floor request can upgrade an old persisted [] without broadening the
|
||||
// query to other floors.
|
||||
return requestFreshJson<T[]>(path)
|
||||
}
|
||||
|
||||
const buildQueryString = (params: object) => {
|
||||
const query = new URLSearchParams()
|
||||
|
||||
@@ -467,7 +586,7 @@ export const createSgsSdkApiProvider = (): SgsSdkApiProvider => {
|
||||
const cached = manifestCache.get(normalizedMapId)
|
||||
if (cached) return cached
|
||||
|
||||
const manifest = await requestJson<SgsSdkManifestPayload>(
|
||||
const manifest = await requestCachedJson<SgsSdkManifestPayload>(
|
||||
`/gis/sdk/maps/${encodeURIComponent(normalizedMapId)}/manifest`
|
||||
)
|
||||
manifestCache.set(normalizedMapId, manifest)
|
||||
@@ -478,7 +597,7 @@ export const createSgsSdkApiProvider = (): SgsSdkApiProvider => {
|
||||
const cached = mapDiagnosticsCache.get(normalizedMapId)
|
||||
if (cached) return cached
|
||||
|
||||
const diagnostics = await requestJson<SgsMapDiagnosticsPayload>(
|
||||
const diagnostics = await requestCachedJson<SgsMapDiagnosticsPayload>(
|
||||
`/gis/sdk/maps/${encodeURIComponent(normalizedMapId)}/diagnostics`
|
||||
)
|
||||
mapDiagnosticsCache.set(normalizedMapId, diagnostics)
|
||||
@@ -488,7 +607,7 @@ export const createSgsSdkApiProvider = (): SgsSdkApiProvider => {
|
||||
const cached = floorDiagnosticsCache.get(floorId)
|
||||
if (cached) return cached
|
||||
|
||||
const diagnostics = await requestJson<SgsFloorDiagnosticsPayload>(
|
||||
const diagnostics = await requestCachedJson<SgsFloorDiagnosticsPayload>(
|
||||
`/gis/sdk/floors/${floorIdFor(floorId)}/diagnostics`
|
||||
)
|
||||
floorDiagnosticsCache.set(floorId, diagnostics)
|
||||
@@ -498,37 +617,51 @@ export const createSgsSdkApiProvider = (): SgsSdkApiProvider => {
|
||||
const cached = floorBundleCache.get(floorId)
|
||||
if (cached) return cached
|
||||
|
||||
const bundle = await requestJson<SgsFloorBundlePayload>(
|
||||
const bundle = await requestCachedJson<SgsFloorBundlePayload>(
|
||||
`/gis/sdk/floors/${floorIdFor(floorId)}/bundle`
|
||||
)
|
||||
floorBundleCache.set(floorId, bundle)
|
||||
// The floor model and search panel share these authoritative collections.
|
||||
// Seed the per-floor caches so opening search after a model switch does
|
||||
// not repeat the two slowest SDK requests.
|
||||
if (bundle.pois?.length) floorPoiCache.set(floorId, bundle.pois)
|
||||
if (bundle.spaces?.length) floorSpaceCache.set(floorId, bundle.spaces)
|
||||
// Bundle snapshots are authoritative even for an empty collection. This
|
||||
// differs from the standalone mutable endpoints, where an empty response
|
||||
// remains retryable to recover from a stale browser cache.
|
||||
if (Array.isArray(bundle.businessPois)) {
|
||||
floorBusinessPoiCache.set(`${floorId}:${stableCacheKey({})}`, bundle.businessPois)
|
||||
}
|
||||
if (Array.isArray(bundle.navigablePlaces)) {
|
||||
navigablePlaceCache.set(floorId, bundle.navigablePlaces)
|
||||
}
|
||||
return bundle
|
||||
},
|
||||
async getFloorPois(floorId) {
|
||||
const cached = floorPoiCache.get(floorId)
|
||||
if (cached) return cached
|
||||
if (cached?.length) return cached
|
||||
|
||||
const pois = await requestJson<SgsPoiPayload[]>(
|
||||
const pois = await requestCachedCollectionJson<SgsPoiPayload>(
|
||||
`/gis/sdk/floors/${floorIdFor(floorId)}/pois`
|
||||
)
|
||||
floorPoiCache.set(floorId, pois)
|
||||
if (pois.length) floorPoiCache.set(floorId, pois)
|
||||
return pois
|
||||
},
|
||||
async getFloorSpaces(floorId) {
|
||||
const cached = floorSpaceCache.get(floorId)
|
||||
if (cached) return cached
|
||||
if (cached?.length) return cached
|
||||
|
||||
const spaces = await requestJson<SgsSpacePayload[]>(
|
||||
const spaces = await requestFreshJson<SgsSpacePayload[]>(
|
||||
`/gis/sdk/floors/${floorIdFor(floorId)}/spaces`
|
||||
)
|
||||
floorSpaceCache.set(floorId, spaces)
|
||||
if (spaces.length) floorSpaceCache.set(floorId, spaces)
|
||||
return spaces
|
||||
},
|
||||
async getGuideStops(floorId) {
|
||||
const cached = floorGuideStopCache.get(floorId)
|
||||
if (cached) return cached
|
||||
|
||||
const guideStops = await requestJson<SgsGuideStopPayload[]>(
|
||||
const guideStops = await requestCachedJson<SgsGuideStopPayload[]>(
|
||||
`/gis/sdk/floors/${floorIdFor(floorId)}/guide-stops`
|
||||
)
|
||||
floorGuideStopCache.set(floorId, guideStops)
|
||||
@@ -538,31 +671,29 @@ export const createSgsSdkApiProvider = (): SgsSdkApiProvider => {
|
||||
const cached = hallGuideStopCache.get(hallId)
|
||||
if (cached) return cached
|
||||
|
||||
const guideStops = await requestJson<SgsGuideStopPayload[]>(
|
||||
const guideStops = await requestCachedJson<SgsGuideStopPayload[]>(
|
||||
`/gis/sdk/halls/${hallIdFor(hallId)}/guide-stops`
|
||||
)
|
||||
hallGuideStopCache.set(hallId, guideStops)
|
||||
return guideStops
|
||||
},
|
||||
async getNavigablePlaces(floorId) {
|
||||
const cached = navigablePlaceCache.get(floorId)
|
||||
if (cached) return cached
|
||||
if (navigablePlaceCache.has(floorId)) return navigablePlaceCache.get(floorId) || []
|
||||
|
||||
const places = await requestJson<SgsNavigablePlacePayload[]>(
|
||||
const places = await requestCachedCollectionJson<SgsNavigablePlacePayload>(
|
||||
`/gis/sdk/floors/${floorIdFor(floorId)}/navigable-places`
|
||||
)
|
||||
navigablePlaceCache.set(floorId, places)
|
||||
if (places.length) navigablePlaceCache.set(floorId, places)
|
||||
return places
|
||||
},
|
||||
async getFloorBusinessPois(floorId, options = {}) {
|
||||
const cacheKey = `${floorId}:${stableCacheKey(options)}`
|
||||
const cached = floorBusinessPoiCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
if (floorBusinessPoiCache.has(cacheKey)) return floorBusinessPoiCache.get(cacheKey) || []
|
||||
|
||||
const pois = await requestJson<SgsPoiPayload[]>(
|
||||
const pois = await requestCachedCollectionJson<SgsPoiPayload>(
|
||||
`/gis/sdk/floors/${floorIdFor(floorId)}/business-pois${buildQueryString(options)}`
|
||||
)
|
||||
floorBusinessPoiCache.set(cacheKey, pois)
|
||||
if (pois.length) floorBusinessPoiCache.set(cacheKey, pois)
|
||||
return pois
|
||||
},
|
||||
async queryPois(params) {
|
||||
@@ -570,7 +701,7 @@ export const createSgsSdkApiProvider = (): SgsSdkApiProvider => {
|
||||
const cached = poiQueryCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const pois = await requestJson<SgsPoiPayload[]>(
|
||||
const pois = await requestCachedJson<SgsPoiPayload[]>(
|
||||
`/gis/sdk/pois${buildQueryString(params)}`
|
||||
)
|
||||
poiQueryCache.set(cacheKey, pois)
|
||||
@@ -582,7 +713,7 @@ export const createSgsSdkApiProvider = (): SgsSdkApiProvider => {
|
||||
const cached = featuredRoutesCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const routes = await requestJson<SgsFeaturedRouteSummaryPayload[]>(
|
||||
const routes = await requestCachedJson<SgsFeaturedRouteSummaryPayload[]>(
|
||||
`/gis/sdk/maps/${encodeURIComponent(normalizedMapId)}/featured-routes${buildQueryString(options)}`
|
||||
)
|
||||
featuredRoutesCache.set(cacheKey, routes)
|
||||
@@ -592,7 +723,7 @@ export const createSgsSdkApiProvider = (): SgsSdkApiProvider => {
|
||||
const cached = featuredRouteDetailCache.get(routeId)
|
||||
if (cached) return cached
|
||||
|
||||
const route = await requestJson<SgsFeaturedRouteDetailPayload>(
|
||||
const route = await requestCachedJson<SgsFeaturedRouteDetailPayload>(
|
||||
`/gis/sdk/featured-routes/${encodeURIComponent(routeId)}`
|
||||
)
|
||||
featuredRouteDetailCache.set(routeId, route)
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface StaticNavPoiPayload {
|
||||
sourceObjectName?: string
|
||||
navigationReadiness?: string
|
||||
sourceConfidence?: string
|
||||
visitorVisible?: boolean | null
|
||||
}
|
||||
|
||||
export interface StaticNavManifestFloorModelPayload {
|
||||
|
||||
Reference in New Issue
Block a user