修复 SGS SDK 导览数据源与模型加载

升级 SDK API 数据契约,补齐 guideStops、业务 POI、诊断与路线数据接口。

修复 SDK 模式下 POI/模型/楼层混用静态数据的问题,并为 ThreeMap 模型加载增加短退避重试。
This commit is contained in:
lyf
2026-07-02 10:26:52 +08:00
parent 9efcef5190
commit 7cda427de9
13 changed files with 966 additions and 104 deletions

View File

@@ -350,6 +350,7 @@ const floorExitConfirmThresholdMultiplier = 1.34
const floorExitZoomOutIntentMinDeltaRatio = 0.015
const floorExitAccumulatedZoomOutRatio = 0.26
const floorExitCandidateHoldMs = 900
const modelLoadRetryDelaysMs = [300, 900]
const syncControlInteractionOptions = () => {
if (!controls) return
@@ -2165,7 +2166,11 @@ const clearSceneData = () => {
clearPoiGroupChildren()
}
const loadModel = (
const wait = (delayMs: number) => new Promise<void>((resolve) => {
window.setTimeout(resolve, delayMs)
})
const loadModelOnce = (
url: string,
label: string,
loadToken?: number,
@@ -2193,6 +2198,41 @@ const loadModel = (
)
})
const loadModel = async (
url: string,
label: string,
loadToken?: number,
options: LoadModelOptions = {}
) => {
let lastError: unknown = null
for (let attempt = 0; attempt <= modelLoadRetryDelaysMs.length; attempt += 1) {
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
throw createStaleModelLoadError()
}
try {
return await loadModelOnce(url, label, loadToken, options)
} catch (error) {
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
throw createStaleModelLoadError()
}
lastError = error
const retryDelay = modelLoadRetryDelaysMs[attempt]
if (retryDelay === undefined) break
if (!options.suppressProgress) {
setProgress(18, `${label} 加载失败,正在重试 ${attempt + 1}/${modelLoadRetryDelaysMs.length}`)
}
await wait(retryDelay)
}
}
throw lastError instanceof Error ? lastError : new Error('模型资源加载失败')
}
const prepareModel = (model: THREE.Object3D) => {
model.traverse((child) => {
if (child instanceof THREE.Mesh) {

View File

@@ -6,10 +6,12 @@ import type {
GuideLocationPreview,
GuideMapDiagnostics,
GuideRouteReadiness,
MuseumCategory,
MuseumFloor,
MuseumPoi
} from '@/domain/museum'
MuseumCategory,
MuseumFloor,
MuseumPoi,
ExplainGuideStop,
AudioPlayTargetType
} from '@/domain/museum'
import {
NAV_ROUTE_UNAVAILABLE_MESSAGE
} from '@/domain/guideReadiness'
@@ -17,23 +19,30 @@ import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import type {
SgsFloorDiagnosticsPayload,
SgsMapDiagnosticsPayload,
SgsNavigablePlacePayload,
SgsPoiPayload,
SgsFloorDiagnosticsPayload,
SgsGuideStopPayload,
SgsMapDiagnosticsPayload,
SgsNavigablePlacePayload,
SgsPoiPayload,
SgsPositionPayload,
SgsSdkFloorSummaryPayload,
SgsSdkManifestPayload,
SgsSpacePayload
} from '@/data/providers/sgsSdkApiProvider'
const defaultCategory: MuseumCategory = {
id: 'operation_experience',
label: '导览点位',
iconType: 'poi'
}
const hallCategory: MuseumCategory = {
const defaultCategory: MuseumCategory = {
id: 'operation_experience',
label: '导览点位',
iconType: 'poi'
}
const businessCategory: MuseumCategory = {
id: 'business_poi',
label: '运营服务',
iconType: 'business'
}
const hallCategory: MuseumCategory = {
id: 'exhibition_hall',
label: '展厅',
iconType: 'exhibition_hall'
@@ -45,7 +54,7 @@ const hallEntranceCategory: MuseumCategory = {
iconType: 'hall_entrance'
}
const categoryBySgsType: Record<string, MuseumCategory & { accessible?: boolean }> = {
const categoryBySgsType: Record<string, MuseumCategory & { accessible?: boolean }> = {
toilet: {
id: 'basic_service_facility',
label: '卫生间',
@@ -237,6 +246,16 @@ export interface SgsHallPoiDiagnostics {
skippedPlaceCount: number
}
const businessTypeLabels: Record<string, string> = {
shop: '商店',
restaurant: '餐饮',
cafe: '咖啡',
vending: '售卖机',
bookstore: '书店',
cultural_shop: '文创商店',
photo_spot: '打卡点'
}
interface SgsHallPoiBuildOptions {
fallbackY?: number
}
@@ -381,13 +400,23 @@ const normalizePlacePosition = (
fallbackY
)
const categoryFor = (poi: SgsPoiPayload): MuseumCategory & { accessible?: boolean } => {
const normalizedType = poi.type?.trim()
if (normalizedType && categoryBySgsType[normalizedType]) {
return categoryBySgsType[normalizedType]
}
if (poi.typeName) {
const categoryFor = (poi: SgsPoiPayload): MuseumCategory & { accessible?: boolean } => {
const normalizedType = poi.type?.trim().toLowerCase()
const normalizedBusinessType = poi.businessType?.trim().toLowerCase()
if (String(poi.poiGroup || '').toUpperCase() === 'BUSINESS') {
return {
...businessCategory,
label: normalizedBusinessType ? businessTypeLabels[normalizedBusinessType] || poi.typeName || businessCategory.label : poi.typeName || businessCategory.label,
iconType: normalizedBusinessType || businessCategory.iconType
}
}
if (normalizedType && categoryBySgsType[normalizedType]) {
return categoryBySgsType[normalizedType]
}
if (poi.typeName) {
return {
...defaultCategory,
label: poi.typeName,
@@ -395,8 +424,15 @@ const categoryFor = (poi: SgsPoiPayload): MuseumCategory & { accessible?: boolea
}
}
return defaultCategory
}
return defaultCategory
}
const kindForSgsPoi = (poi: SgsPoiPayload, category: MuseumCategory) => {
if (category.id === 'business_poi') return 'facility'
if (category.id === 'operation_experience') return 'guide'
return 'facility'
}
export const toMuseumPoiFromSgs = (
poi: SgsPoiPayload,
@@ -425,12 +461,41 @@ export const toMuseumPoiFromSgs = (
],
positionGltf: normalizePosition(poi),
sourceObjectName: poi.anchorNodeName || undefined,
sourceConfidence: 'backend-sgs-sdk',
navigationReadiness: '位置预览',
accessible: category.accessible === true,
kind: category.id === 'operation_experience' ? 'guide' : 'facility'
}
}
sourceConfidence: 'backend-sgs-sdk',
navigationReadiness: '位置预览',
accessible: category.accessible === true,
kind: kindForSgsPoi(poi, category)
}
}
const normalizeAudioTargetType = (targetType?: string | null): AudioPlayTargetType | undefined => {
const normalized = targetType?.toUpperCase()
if (normalized === 'ITEM' || normalized === 'STOP') return normalized
return undefined
}
export const toExplainGuideStopFromSgs = (stop: SgsGuideStopPayload): ExplainGuideStop | null => {
const id = stringifyId(stop.id)
if (!id) return null
return {
id,
name: normalizedText(stop.name) || `讲解点${id}`,
hallId: stringifyId(stop.hallId) || undefined,
hallName: normalizedText(stop.hallName) || undefined,
floorId: stringifyId(stop.floorId) || undefined,
targetType: normalizeAudioTargetType(stop.targetType) || 'STOP',
targetId: stringifyId(stop.targetId) || id,
coverImageUrl: normalizedText(stop.coverImageUrl) || undefined,
description: normalizedText(stop.description) || undefined,
hasAudio: stop.hasAudio === true || Boolean(normalizedText(stop.audioUrl)),
poiId: stringifyId(stop.poiId) || undefined,
outlineId: stringifyId(stop.outlineId || stop.routeId) || undefined,
outlineName: normalizedText(stop.outlineName || stop.routeName) || undefined,
sort: optionalNumber(stop.sort ?? stop.seqOrder)
}
}
export const toLocationPreviewFromPoi = (poi: MuseumPoi): GuideLocationPreview => ({
poiId: poi.id,

View File

@@ -10,17 +10,22 @@ import {
import type {
ExplainContentProvider
} from '@/data/providers/staticMuseumContentProvider'
import {
defaultSgsSdkApiProvider,
type SgsSdkApiProvider
} from '@/data/providers/sgsSdkApiProvider'
import {
toBackendExplainTrack,
toBackendGuideStop,
toBackendHallFromList,
toBackendMediaAsset,
toBackendMuseumExhibit,
groupGuideStopsByOutline,
type BackendExhibit,
type BackendGuideStop,
type BackendHall
} from '@/data/adapters/backendExplainDataAdapter'
import {
toExplainGuideStopFromSgs
} from '@/data/adapters/sgsSdkGuideAdapter'
interface CommonResult<T> {
code: number
@@ -28,7 +33,14 @@ interface CommonResult<T> {
data?: T
}
const normalizeBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, '')
const normalizeBaseUrl = (baseUrl: string) => {
const trimmed = baseUrl.trim().replace(/\/+$/, '')
if (!trimmed || /^https?:\/\//i.test(trimmed) || trimmed.startsWith('/')) {
return trimmed
}
return `/${trimmed}`
}
const resolveAppApiBaseUrl = () => {
const baseUrl = normalizeBaseUrl(dataSourceConfig.apiBaseUrl)
@@ -77,7 +89,10 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
private readonly guideStopCache = new Map<string, ExplainGuideStop[]>()
private readonly guideStopInflight = new Map<string, Promise<ExplainGuideStop[]>>()
constructor(private readonly fallbackProvider: ExplainContentProvider) {}
constructor(
private readonly fallbackProvider: ExplainContentProvider,
private readonly sgsSdkApiProvider: SgsSdkApiProvider = defaultSgsSdkApiProvider
) {}
private async requestStaticExplainExhibits() {
const cached = this.searchCache.get('')
@@ -223,14 +238,8 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
if (inflight) return inflight
const promise = (async () => {
const url = `${resolveAppApiBaseUrl()}/gis/sdk/halls/${encodeURIComponent(normalizedHallId)}/guide-stops`
const response = await requestJson<CommonResult<BackendGuideStop[]>>(url)
if (response.code !== 0 || !Array.isArray(response.data)) {
throw new Error(response.msg || '后端展厅讲解点加载失败')
}
const stops = response.data
.map(toBackendGuideStop)
const stops = (await this.sgsSdkApiProvider.getGuideStopsByHall(normalizedHallId))
.map(toExplainGuideStopFromSgs)
.filter(Boolean) as ExplainGuideStop[]
this.guideStopCache.set(normalizedHallId, stops)

View File

@@ -26,6 +26,27 @@ export interface SgsSdkManifestPayload {
capabilities?: Record<string, boolean | undefined>
}
export type SgsPoiGroupPayload = 'SERVICE' | 'BUSINESS' | 'OTHER'
export type SgsBusinessPoiTypePayload =
| 'shop'
| 'restaurant'
| 'cafe'
| 'vending'
| 'bookstore'
| 'cultural_shop'
| 'photo_spot'
| string
export interface SgsPoiQueryParamsPayload {
mapId?: string | number
floorId?: string | number
group?: SgsPoiGroupPayload
types?: string[]
keyword?: string
limit?: number
}
export interface SgsPositionPayload {
x?: number | null
y?: number | null
@@ -47,6 +68,14 @@ export interface SgsPoiPayload {
anchorNodeName?: string | null
description?: string | null
iconUrl?: string | null
poiGroup?: SgsPoiGroupPayload | string | null
businessType?: SgsBusinessPoiTypePayload | null
coverImageUrl?: string | null
phone?: string | null
businessHours?: string | null
sortOrder?: number | null
spatialAreaId?: string | number | null
spatialAreaName?: string | null
}
export interface SgsSpacePayload {
@@ -79,6 +108,36 @@ export interface SgsNavigablePlacePayload {
ownerName?: string | null
}
export interface SgsGuideStopPayload {
id: string | number
name?: string | null
type?: string | null
typeName?: string | null
floorId?: string | number | null
position?: SgsPositionPayload | null
x?: number | null
y?: number | null
z?: number | null
targetType?: string | null
targetId?: string | number | null
audioUrl?: string | null
coverImageUrl?: string | null
description?: string | null
status?: string | null
located?: boolean | null
hasAudio?: boolean | null
poiId?: string | number | null
outlineId?: string | number | null
outlineName?: string | null
hallId?: string | number | null
hallName?: string | null
sort?: number | null
routeId?: string | number | null
routeName?: string | null
seqOrder?: number | null
stayMinutes?: number | null
}
export interface SgsFloorDiagnosticsPayload {
floorId: string | number
floorCode?: string | null
@@ -121,7 +180,7 @@ export interface SgsFloorBundlePayload {
model?: SgsModelInfoPayload | null
pois?: SgsPoiPayload[]
spaces?: SgsSpacePayload[]
guideStops?: unknown[]
guideStops?: SgsGuideStopPayload[]
routeSummary?: {
hasRouteNetwork?: boolean
nodeCount?: number
@@ -196,6 +255,54 @@ export interface SgsRoutePlanResponsePayload {
segments?: SgsRouteSegmentPayload[]
}
export interface SgsRouteEndpointPayload {
id?: string | number | null
name?: string | null
floorId?: string | number | null
floorCode?: string | null
floorName?: string | null
x?: number | null
y?: number | null
z?: number | null
}
export interface SgsFeaturedRouteSummaryPayload {
id: string | number
mapId?: string | number | null
routeName?: string | null
routeNameEn?: string | null
routeType?: string | null
theme?: string | null
description?: string | null
coverImageUrl?: string | null
estimatedDuration?: number | null
distanceMeters?: number | null
waypointCount?: number | null
sortOrder?: number | null
status?: string | number | null
}
export interface SgsFeaturedRouteWaypointPayload {
order?: number | null
spaceId?: string | number | null
spaceName?: string | null
guideStopId?: string | number | null
guideStopName?: string | null
floorId?: string | number | null
floorCode?: string | null
floorName?: string | null
x?: number | null
y?: number | null
z?: number | null
stayMinutes?: number | null
}
export interface SgsFeaturedRouteDetailPayload extends SgsFeaturedRouteSummaryPayload {
startEntrance?: SgsRouteEndpointPayload | null
endEntrance?: SgsRouteEndpointPayload | null
waypoints?: SgsFeaturedRouteWaypointPayload[]
}
interface CommonResult<T> {
code: number
msg?: string
@@ -209,7 +316,19 @@ export interface SgsSdkApiProvider {
getFloorBundle(floorId: string): Promise<SgsFloorBundlePayload>
getFloorPois(floorId: string): Promise<SgsPoiPayload[]>
getFloorSpaces(floorId: string): Promise<SgsSpacePayload[]>
getGuideStops(floorId: string): Promise<SgsGuideStopPayload[]>
getGuideStopsByHall(hallId: string): Promise<SgsGuideStopPayload[]>
getNavigablePlaces(floorId: string): Promise<SgsNavigablePlacePayload[]>
getFloorBusinessPois(
floorId: string,
options?: { types?: string[]; keyword?: string; limit?: number }
): Promise<SgsPoiPayload[]>
queryPois(params: SgsPoiQueryParamsPayload): Promise<SgsPoiPayload[]>
getFeaturedRoutes(
mapId?: string,
options?: { limit?: number }
): Promise<SgsFeaturedRouteSummaryPayload[]>
getFeaturedRoute(routeId: string): Promise<SgsFeaturedRouteDetailPayload>
planRoute(payload: SgsRoutePlanRequestPayload): Promise<SgsRoutePlanResponsePayload>
}
@@ -295,6 +414,21 @@ const requestJson = <T>(
})
})
const buildQueryString = (params: object) => {
const query = new URLSearchParams()
Object.entries(params as Record<string, unknown>).forEach(([key, value]) => {
if (value === null || typeof value === 'undefined' || value === '') return
query.set(key, Array.isArray(value) ? value.join(',') : String(value))
})
const queryString = query.toString()
return queryString ? `?${queryString}` : ''
}
const stableCacheKey = (value: unknown) => JSON.stringify(value)
export const createSgsSdkApiProvider = (): SgsSdkApiProvider => {
const manifestCache = new Map<string, SgsSdkManifestPayload>()
const mapDiagnosticsCache = new Map<string, SgsMapDiagnosticsPayload>()
@@ -302,10 +436,17 @@ export const createSgsSdkApiProvider = (): SgsSdkApiProvider => {
const floorBundleCache = new Map<string, SgsFloorBundlePayload>()
const floorPoiCache = new Map<string, SgsPoiPayload[]>()
const floorSpaceCache = new Map<string, SgsSpacePayload[]>()
const floorGuideStopCache = new Map<string, SgsGuideStopPayload[]>()
const hallGuideStopCache = new Map<string, SgsGuideStopPayload[]>()
const navigablePlaceCache = new Map<string, SgsNavigablePlacePayload[]>()
const floorBusinessPoiCache = new Map<string, SgsPoiPayload[]>()
const poiQueryCache = new Map<string, SgsPoiPayload[]>()
const featuredRoutesCache = new Map<string, SgsFeaturedRouteSummaryPayload[]>()
const featuredRouteDetailCache = new Map<string, SgsFeaturedRouteDetailPayload>()
const mapIdFor = (mapId?: string) => mapId || dataSourceConfig.sgsMapId
const floorIdFor = (floorId: string) => encodeURIComponent(floorId)
const hallIdFor = (hallId: string) => encodeURIComponent(hallId)
return {
async getManifest(mapId) {
@@ -370,6 +511,26 @@ export const createSgsSdkApiProvider = (): SgsSdkApiProvider => {
floorSpaceCache.set(floorId, spaces)
return spaces
},
async getGuideStops(floorId) {
const cached = floorGuideStopCache.get(floorId)
if (cached) return cached
const guideStops = await requestJson<SgsGuideStopPayload[]>(
`/gis/sdk/floors/${floorIdFor(floorId)}/guide-stops`
)
floorGuideStopCache.set(floorId, guideStops)
return guideStops
},
async getGuideStopsByHall(hallId) {
const cached = hallGuideStopCache.get(hallId)
if (cached) return cached
const guideStops = await requestJson<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
@@ -380,6 +541,50 @@ export const createSgsSdkApiProvider = (): SgsSdkApiProvider => {
navigablePlaceCache.set(floorId, places)
return places
},
async getFloorBusinessPois(floorId, options = {}) {
const cacheKey = `${floorId}:${stableCacheKey(options)}`
const cached = floorBusinessPoiCache.get(cacheKey)
if (cached) return cached
const pois = await requestJson<SgsPoiPayload[]>(
`/gis/sdk/floors/${floorIdFor(floorId)}/business-pois${buildQueryString(options)}`
)
floorBusinessPoiCache.set(cacheKey, pois)
return pois
},
async queryPois(params) {
const cacheKey = stableCacheKey(params)
const cached = poiQueryCache.get(cacheKey)
if (cached) return cached
const pois = await requestJson<SgsPoiPayload[]>(
`/gis/sdk/pois${buildQueryString(params)}`
)
poiQueryCache.set(cacheKey, pois)
return pois
},
async getFeaturedRoutes(mapId, options = {}) {
const normalizedMapId = mapIdFor(mapId)
const cacheKey = `${normalizedMapId}:${stableCacheKey(options)}`
const cached = featuredRoutesCache.get(cacheKey)
if (cached) return cached
const routes = await requestJson<SgsFeaturedRouteSummaryPayload[]>(
`/gis/sdk/maps/${encodeURIComponent(normalizedMapId)}/featured-routes${buildQueryString(options)}`
)
featuredRoutesCache.set(cacheKey, routes)
return routes
},
async getFeaturedRoute(routeId) {
const cached = featuredRouteDetailCache.get(routeId)
if (cached) return cached
const route = await requestJson<SgsFeaturedRouteDetailPayload>(
`/gis/sdk/featured-routes/${encodeURIComponent(routeId)}`
)
featuredRouteDetailCache.set(routeId, route)
return route
},
async planRoute(payload) {
return requestJson<SgsRoutePlanResponsePayload>(
'/gis/sdk/routes/plan',

View File

@@ -96,12 +96,8 @@ import type {
GuideModelSource,
GuideRenderPoi
} from '@/domain/guideModel'
import {
StaticGuideModelRepository
} from '@/repositories/GuideModelRepository'
const fallbackIndoorModelSource = new StaticGuideModelRepository()
const indoorModelSource = ref<GuideModelSource>(fallbackIndoorModelSource)
const indoorModelSource: GuideModelSource = guideUseCase.getModelSource()
const guideFloors = ref<MuseumFloor[]>([])
const normalizeGuideFloorId = (labelOrId: string) =>
guideFloors.value.find((floor) => floor.id === labelOrId || floor.label === labelOrId)?.id
@@ -123,19 +119,16 @@ const facility = ref<FacilityDetailViewModel>({
const loadGuideFloors = async () => {
try {
const modelPackage = await fallbackIndoorModelSource.loadPackage()
guideFloors.value = modelPackage.floors.map((floor) => ({
id: floor.floorId,
label: floor.label,
order: floor.order
}))
guideFloors.value = await guideUseCase.getFloors()
const floors = guideFloors.value
const defaultFloor = floors.find((floor) => floor.label === activeFloor.value)?.label
|| floors.find((floor) => floor.id === 'L1')?.label
|| floors[0]?.label
const normalizedActiveFloorId = activeFloor.value
? normalizeGuideFloorId(activeFloor.value)
: ''
const defaultFloor = floors.find((floor) => floor.id === normalizedActiveFloorId)?.id
|| floors.find((floor) => floor.label === '1F')?.id
|| floors[0]?.id
|| activeFloor.value
activeFloor.value = floors.find((floor) => floor.label === activeFloor.value)?.label
|| defaultFloor
activeFloor.value = defaultFloor
} catch (error) {
console.error('加载导览楼层失败:', error)
guideFloors.value = []
@@ -146,7 +139,7 @@ const focusFacilityPoi = (poi: MuseumPoi) => {
if (!poi.floorId) return
activeMode.value = '3d'
activeFloor.value = poi.floorLabel
activeFloor.value = poi.floorId
targetFocusRequestId.value += 1
targetFocusRequest.value = toTargetFocusRequestViewModel({
poiId: poi.id,
@@ -164,7 +157,7 @@ const focusRenderPoi = (poi: GuideRenderPoi) => {
const floorLabel = guideFloors.value.find((floor) => floor.id === poi.floorId)?.label || poi.floorId
activeMode.value = '3d'
activeFloor.value = floorLabel
activeFloor.value = poi.floorId
targetFocusRequestId.value += 1
targetFocusRequest.value = toTargetFocusRequestViewModel({
poiId: poi.id,
@@ -242,10 +235,10 @@ const resolveRenderPoiByName = async () => {
const normalizedTarget = normalizePoiName(facility.value.name)
if (!normalizedTarget) return null
const modelPackage = await indoorModelSource.value.loadPackage()
const modelPackage = await indoorModelSource.loadPackage()
for (const floor of modelPackage.floors) {
const floorId = floor.floorId
const pois = await indoorModelSource.value.loadFloorPois(floorId).catch(() => [])
const pois = await indoorModelSource.loadFloorPois(floorId).catch(() => [])
const matchedPoi = pois.find((poi) => {
const poiName = normalizePoiName(poi.name)
const hallName = normalizePoiName(poi.hallName || '')

View File

@@ -255,7 +255,6 @@ export class SgsSdkGuideRepository implements GuideRepository {
private floorsCache: MuseumFloor[] | null = null
private poiCache: MuseumPoi[] | null = null
private floorAliases: Map<string, string> | null = null
private readonly staticFallback = new StaticGuideRepository()
constructor(private readonly provider: SgsSdkApiProvider = defaultSgsSdkApiProvider) {}
@@ -331,7 +330,7 @@ export class SgsSdkGuideRepository implements GuideRepository {
const pois = await this.listPois()
return pois.find((poi) => poi.id === id)
|| findPoiByNavIdNameFallback(pois, id)
|| this.staticFallback.getPoiById(id)
|| null
}
async searchPois(keyword = '') {

View File

@@ -14,6 +14,7 @@ import {
} from '@/repositories/GuideRouteRepository'
import {
defaultSgsSdkApiProvider,
type SgsFloorDiagnosticsPayload,
type SgsNavigablePlacePayload,
type SgsSdkApiProvider
} from '@/data/providers/sgsSdkApiProvider'
@@ -29,13 +30,40 @@ const createUnavailableReadiness = (message: string): GuideRouteReadiness => ({
})
const logSdkRouteReadinessIssue = (message: string, payload: Record<string, unknown>) => {
console.error(`[SDKRouteReadiness] ${message}`, payload)
console.warn(`[SDKRouteReadiness] ${message}`, payload)
}
const toNumber = (value: unknown) => (
Number.isFinite(Number(value)) ? Number(value) : undefined
)
const isPositiveNumber = (value: unknown) => {
const numberValue = toNumber(value)
return typeof numberValue === 'number' && numberValue > 0
}
const hasRouteNodeId = (place: SgsNavigablePlacePayload) => (
place.nodeId !== null && place.nodeId !== undefined && place.nodeId !== ''
)
const hasRouteGraphEvidence = (floor: Pick<SgsFloorDiagnosticsPayload, 'routePlanningReady' | 'routeNodeCount' | 'routeEdgeCount'>) => (
floor.routePlanningReady === true
|| (isPositiveNumber(floor.routeNodeCount) && isPositiveNumber(floor.routeEdgeCount))
)
const hasRouteTargetEvidence = (
floor: Pick<SgsFloorDiagnosticsPayload, 'navigablePlaceCount'>,
routeTargetCount?: number
) => {
if (typeof routeTargetCount === 'number') return routeTargetCount > 0
return floor.navigablePlaceCount === undefined || isPositiveNumber(floor.navigablePlaceCount)
}
const isRouteReadyFromDiagnostics = (
floor: Pick<SgsFloorDiagnosticsPayload, 'routePlanningReady' | 'routeNodeCount' | 'routeEdgeCount' | 'navigablePlaceCount'>,
routeTargetCount?: number
) => hasRouteGraphEvidence(floor) && hasRouteTargetEvidence(floor, routeTargetCount)
const positionFromPlace = (place: SgsNavigablePlacePayload): [number, number, number] | undefined => {
const source = place.position || {
x: place.x,
@@ -70,36 +98,42 @@ export class SdkGuideRouteRepository {
return createUnavailableReadiness('SDK 地图楼层数据为空')
}
// 收集有路线数据的楼层
const readyFloors = floors.filter(
(floor) =>
floor.routePlanningReady &&
floor.routeNodeCount &&
floor.routeNodeCount > 0 &&
floor.routeEdgeCount &&
floor.routeEdgeCount > 0
)
// 地图级 diagnostics 的楼层摘要只保证 routePlanningReady/navigablePlaceCount
// routeNodeCount/routeEdgeCount 可能只出现在 floor diagnostics 或 summary 中。
let readyFloors = floors.filter((floor) => isRouteReadyFromDiagnostics(floor))
let notReadyFloors = floors.filter((floor) => !isRouteReadyFromDiagnostics(floor))
if (readyFloors.length === 0) {
const floorEvidence = await Promise.all(
floors.map((floor) => this.loadFloorRouteEvidence(floor))
)
readyFloors = floorEvidence
.filter((item) => item.ready)
.map((item) => item.floor)
notReadyFloors = floorEvidence
.filter((item) => !item.ready)
.map((item) => item.floor)
}
// 没有楼层有路线数据时不可用
if (readyFloors.length === 0) {
logSdkRouteReadinessIssue('SDK 路线数据不完整', {
logSdkRouteReadinessIssue('SDK 路线数据未达到位置关系生成条件', {
floorCount: floors.length,
readyFloorCount: readyFloors.length,
summary: diagnostics.summary,
floors: floors.map((floor) => ({
floorId: floor.floorId,
floorName: floor.floorName,
floorCode: floor.floorCode,
routePlanningReady: floor.routePlanningReady,
routeNodeCount: floor.routeNodeCount,
routeEdgeCount: floor.routeEdgeCount
routeEdgeCount: floor.routeEdgeCount,
navigablePlaceCount: floor.navigablePlaceCount
}))
})
return createUnavailableReadiness('无法定位,当前仅支持点位位置预览')
}
// 收集未就绪的楼层
const notReadyFloors = floors.filter((floor) => !floor.routePlanningReady)
// 如果有楼层未就绪,给出警告但仍然可用
if (notReadyFloors.length > 0) {
const floorLabels = notReadyFloors
@@ -125,6 +159,25 @@ export class SdkGuideRouteRepository {
}
}
private async loadFloorRouteEvidence(floor: SgsFloorDiagnosticsPayload) {
const rawFloorId = stringifyId(floor.floorId)
const [floorDiagnostics, navigablePlaces] = await Promise.all([
this.sdkApiProvider.getFloorDiagnostics(rawFloorId).catch(() => null),
this.sdkApiProvider.getNavigablePlaces(rawFloorId).catch(() => [])
])
const routeTargetCount = navigablePlaces.filter(hasRouteNodeId).length
const evidence = floorDiagnostics || floor
return {
floor: {
...floor,
...floorDiagnostics
},
ready: isRouteReadyFromDiagnostics(evidence, routeTargetCount),
routeTargetCount
}
}
async listRouteTargets(): Promise<GuideRouteTarget[]> {
if (this.targetsCache) return this.targetsCache
@@ -141,7 +194,7 @@ export class SdkGuideRouteRepository {
for (const place of places) {
// 过滤掉没有 nodeId 的点位,这些无法用于路线规划
if (place.nodeId === null || place.nodeId === undefined || place.nodeId === '') {
if (!hasRouteNodeId(place)) {
continue
}

View File

@@ -1,7 +1,7 @@
export type SgsMapNode =
| { type: 'coord'; floorId: string | number; x: number; z: number; y?: number }
| { type: 'poiId'; value: number }
| { type: 'nodeName'; value: string }
| { type: 'poiId'; value: string | number; floorId?: string | number }
| { type: 'nodeName'; value: string; floorId?: string | number }
export interface SgsSnapDetail {
original: { x: number; z: number }
@@ -14,13 +14,14 @@ export interface SgsRouteResult {
distance: number
duration: number
pathPoints: Array<{ x: number; y: number; z: number }>
startSnap: SgsSnapDetail
endSnap: SgsSnapDetail
computeTimeMs: number
polyCount: number
startSnap?: SgsSnapDetail
endSnap?: SgsSnapDetail
computeTimeMs?: number
polyCount?: number
warnings: string[]
errorCode?: string
message?: string
segments?: SgsRouteSegment[]
}
export interface SgsMapSDKOptions {
@@ -28,17 +29,39 @@ export interface SgsMapSDKOptions {
sdkUrl?: string
targetOrigin?: string
floorId?: string | number
mapId?: string | number
timeout?: number
readyTimeout?: number
}
export interface SgsMapEvents {
ready: { engineVersion: string; protocolVersion: number }
poiClick: { id?: number; poiId?: number; name: string; type: string; floorId?: string | number; description?: string }
poiClick: { id: string | number; poiId?: string | number; name: string; type: string; floorId?: string | number; description?: string }
floorChanged: { floorId: string | number }
error: { code: string; message: string }
loadError: { type: string; detail: string }
routeTransfer: {
currentFloorId: string | number
targetFloorId: string | number
fromFloorId: string | number
fromNodeName: string
toNodeName: string
transferType: SgsRouteTransferType
connectorName?: string
segmentIndex: number
totalSegments: number
routeId?: string
}
modelLoading: { floorId: string | number; modelUrl: string }
modelReady: { floorId: string | number; parseTimeMs: number }
modelError: { floorId: string | number; error: string; fallbackUsed: boolean }
floorBundleReady: { floorId: string | number; dataVersion: string }
routeReady: { routeId: string; distance: number; duration: number; segmentCount?: number; hasTransfer?: boolean }
}
export type SgsRouteTransferType = 'ELEVATOR' | 'STAIRS' | 'ESCALATOR'
export type SgsRouteSegmentType = 'WALK' | 'TRANSFER'
export type SgsErrorCode =
| 'ERR_TIMEOUT'
| 'ERR_NO_PATH'
@@ -50,6 +73,9 @@ export type SgsErrorCode =
| 'ERR_UNAUTHORIZED'
| 'ERR_UNSUPPORTED'
| 'ERR_FAILED'
| 'ERR_DESTROYED'
| 'ERR_IFRAME_LOAD'
| 'ERR_READY_TIMEOUT'
export interface SgsMarkerConfig {
id: string
@@ -64,21 +90,316 @@ export interface SgsMapSDKInstance {
off<K extends keyof SgsMapEvents>(event: K, listener: (payload: SgsMapEvents[K]) => void): void
getIsReady(): boolean
getCurrentFloor(): string | number
getMapId(): string | number
getVersion(): string
whenReady(): Promise<unknown>
ready(): Promise<unknown>
focusTo(node: SgsMapNode | string): void
changeFloor(floorId: string | number, timeout?: number): Promise<{ success: boolean; floorId: string | number }>
addMarker(config: SgsMarkerConfig): void
removeMarker(markerId: string): void
clearMarkers(): void
highlightPolygon(nodeName: string): void
highlightPolygon(payload: string | object): void
planRoute(from: SgsMapNode | string, to: SgsMapNode | string, options?: unknown, timeout?: number): Promise<SgsRouteResult>
planRouteV2(from: SgsMapNode, to: SgsMapNode, options?: unknown, timeout?: number): Promise<SgsRouteResult>
clearRoute(): void
getState(): Promise<unknown>
resetView(): void
setViewMode(mode: '2d' | '3d'): void
resetView(timeout?: number): Promise<{ success: boolean }>
setViewMode(mode: '2d' | '3d', timeout?: number): Promise<{ success: boolean; mode: string }>
toggleLayer(types: string[], visible: boolean, timeout?: number): Promise<{ success: boolean; visibleTypes: string[] }>
setVisibleLayers(types: string[], timeout?: number): Promise<{ success: boolean; visibleTypes: string[] }>
setRouteViewActive(active: boolean, timeout?: number): Promise<{ success: boolean; active: boolean }>
getManifest(timeout?: number): Promise<SgsSdkManifest>
loadFloorBundle(floorId: string | number, timeout?: number): Promise<SgsFloorBundle>
getFloorPois(floorId: string | number, timeout?: number): Promise<SgsPoi[]>
getNavigablePlaces(floorId: string | number, timeout?: number): Promise<SgsNavigablePlace[]>
getPois(floorCode: string, timeout?: number): Promise<SgsPoi[]>
getSpaces(floorId: string | number, timeout?: number): Promise<SgsSpaceArea[]>
getGuideStops(floorId: string | number, timeout?: number): Promise<SgsGuideStop[]>
getGuideStopsByHall(hallId: string | number, timeout?: number): Promise<SgsGuideStop[]>
preloadFloor(floorId: string | number, timeout?: number): Promise<{ cached: boolean }>
getPerformanceStats(timeout?: number): Promise<SgsPerformanceStats>
getDiagnostics(timeout?: number): Promise<SgsMapDiagnostics>
getFloorDiagnostics(floorId: string | number, timeout?: number): Promise<SgsFloorDiagnostics>
clearModelCache(): void
getBusinessPois(floorId: string | number, options?: { types?: string[]; keyword?: string; limit?: number }, timeout?: number): Promise<SgsPoi[]>
queryPois(params: SgsPoiQueryParams, timeout?: number): Promise<SgsPoi[]>
getFeaturedRoutes(mapId?: string | number, options?: { limit?: number }, timeout?: number): Promise<SgsFeaturedRouteSummary[]>
getFeaturedRoute(routeId: string | number, timeout?: number): Promise<SgsFeaturedRouteDetail>
showFlowline(routeId: string | number, options?: SgsFlowlineOptions, timeout?: number): Promise<{ success: boolean; routeId: string | number }>
clearFlowline(): void
destroy(): void
}
export type SgsPoiGroup = 'SERVICE' | 'BUSINESS' | 'OTHER'
export type SgsBusinessPoiType =
| 'shop'
| 'restaurant'
| 'cafe'
| 'vending'
| 'bookstore'
| 'cultural_shop'
| 'photo_spot'
| string
export interface SgsPoiQueryParams {
mapId?: string | number
floorId?: string | number
group?: SgsPoiGroup
types?: string[]
keyword?: string
limit?: number
}
export interface SgsPoi {
id: string | number
name: string
type: string
typeName: string
floorCode: string
floorId?: string | number
position: { x: number; y: number; z: number }
status: 'ACTIVE' | 'INACTIVE'
anchorNodeName?: string | null
description?: string
iconUrl?: string
poiGroup?: SgsPoiGroup
businessType?: SgsBusinessPoiType
coverImageUrl?: string | null
phone?: string | null
businessHours?: string | null
sortOrder?: number | null
spatialAreaId?: string | number | null
spatialAreaName?: string | null
}
export interface SgsSdkManifest {
mapId: string | number
mapName: string
sdkVersion: string
protocolVersion: number
dataVersion: string
updatedAt: string
coordinateSystem: string
floors: SgsSdkFloorSummary[]
capabilities: Record<string, boolean | undefined>
}
export interface SgsSdkFloorSummary {
floorId: string | number
floorCode: string
floorName: string
sortOrder: number
modelSizeBytes: number
poiCount: number
spaceCount: number
}
export interface SgsFloorBundle {
floor: SgsSdkFloorSummary
model: {
modelUrl: string
fallbackModelUrl: string
compressionType: 'draco' | 'none'
modelVersion: string
nodeCount: number
sizeBytes: number
originSizeBytes: number
}
pois: SgsPoi[]
spaces: SgsSpaceArea[]
guideStops: SgsGuideStop[]
routeSummary: {
hasRouteNetwork: boolean
nodeCount: number
edgeCount: number
}
hiddenSceneNodeNames: string[]
dataVersion: string
updatedAt: string
}
export interface SgsSpaceArea {
id: string | number
name: string
type: string
floorId: string | number
boundaryWkt: string
center: { x: number; y: number; z: number }
sourceNodeName?: string
status: 'ACTIVE' | 'INACTIVE'
colorHex?: string
}
export interface SgsGuideStop {
id: string | number
name: string
type: string
typeName?: string
floorId: string | number
position: { x: number; y: number; z: number }
targetType: string
targetId?: string | number
audioUrl?: string
coverImageUrl?: string
description?: string
status: 'ACTIVE' | 'INACTIVE'
located?: boolean
hasAudio?: boolean
poiId?: string | number
outlineId?: string | number
outlineName?: string
hallId?: string | number
hallName?: string
sort?: number
routeId?: string | number
routeName?: string
seqOrder?: number
stayMinutes?: number
}
export interface SgsNavigablePlace {
id: string | number
name: string
category: 'door' | 'poi' | 'space' | 'guide_stop'
floorId: string | number
floorCode: string
floorName?: string
x: number
z: number
nodeId?: string | number
ownerName?: string
typeCode?: string
typeName?: string
}
export interface SgsRouteSegment {
segmentIndex: number
floorId?: string | number | null
floorCode: string
floorName: string
segmentType?: SgsRouteSegmentType
type: 'WALK' | SgsRouteTransferType
transferType?: 'WALK' | SgsRouteTransferType
distance: number
duration: number
pathPoints: Array<{ x: number; y: number; z: number }>
points?: Array<{ x: number; y: number; z: number }>
fromNodeName?: string | null
toNodeName?: string | null
startNodeName?: string | null
endNodeName?: string | null
connectorName?: string | null
}
export interface SgsPerformanceStats {
sdkCreatedAt?: number
iframeDomReadyMs?: number
engineReadyMs?: number
manifestLoadMs?: number
bundleLoadMs?: number
modelDownloadMs?: number
modelDownloadAndParseMs?: number
modelParseMs?: number
firstRenderMs?: number
routePlanMs?: number
dracoUsed?: boolean
fallbackUsed?: boolean
currentFloorId?: string | number
}
export interface SgsMapDiagnostics {
mapId: string | number
mapName: string
sdkVersion: string
status: 'OK' | 'WARN' | 'ERROR'
floors: SgsFloorDiagnosticsSummary[]
summary: {
floorCount: number
modelReadyFloorCount: number
poiCount: number
spaceCount: number
guideStopCount: number
navigablePlaceCount: number
routeNodeCount: number
routeEdgeCount: number
}
warnings: string[]
}
export interface SgsFloorDiagnosticsSummary {
floorId: string | number
floorCode: string
floorName: string
status: 'OK' | 'WARN' | 'ERROR'
modelReady: boolean
routePlanningReady: boolean
navigablePlaceCount: number
}
export interface SgsFloorDiagnostics extends SgsFloorDiagnosticsSummary {
poiCount: number
spaceCount: number
guideStopCount: number
routeNodeCount: number
routeEdgeCount: number
warnings: string[]
}
export interface SgsFeaturedRouteSummary {
id: string | number
mapId: string | number
routeName: string
routeNameEn?: string | null
description?: string | null
descriptionEn?: string | null
coverImageUrl?: string | null
routeColor?: string | null
estimatedMinutes?: number | null
displayOrder?: number | null
waypointCount: number
startEntranceId?: string | number | null
endEntranceId?: string | number | null
updatedAt?: string | null
}
export interface SgsRouteEndpoint {
id: string | number
name: string
floorId: string | number
floorCode?: string | null
position?: { x: number; y: number; z: number } | null
}
export interface SgsFeaturedRouteWaypoint {
order: number
spaceId: string | number
spaceName?: string | null
floorId?: string | number | null
floorCode?: string | null
position: { x: number; y: number; z: number }
title?: string | null
subtitle?: string | null
description?: string | null
imageUrl?: string | null
audioUrl?: string | null
staySeconds?: number | null
tags?: string[]
highlight?: boolean
}
export interface SgsFeaturedRouteDetail extends SgsFeaturedRouteSummary {
startEntrance?: SgsRouteEndpoint | null
endEntrance?: SgsRouteEndpoint | null
waypoints: SgsFeaturedRouteWaypoint[]
}
export interface SgsFlowlineOptions {
autoFit?: boolean
showLabels?: boolean
animate?: boolean
}
export type SgsMapSDKConstructor = new (options: SgsMapSDKOptions) => SgsMapSDKInstance
declare global {