feat: 新增 SGS SDK 数据适配器与腾讯地图服务
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
415
src/data/adapters/sgsSdkGuideAdapter.ts
Normal file
415
src/data/adapters/sgsSdkGuideAdapter.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
import type {
|
||||
GuideDataIntegrityIssue,
|
||||
GuideDataIntegrityReport,
|
||||
GuideDiagnosticsStatus,
|
||||
GuideFloorDetail,
|
||||
GuideLocationPreview,
|
||||
GuideMapDiagnostics,
|
||||
GuideRouteReadiness,
|
||||
MuseumCategory,
|
||||
MuseumFloor,
|
||||
MuseumPoi
|
||||
} from '@/domain/museum'
|
||||
import {
|
||||
NAV_ROUTE_UNAVAILABLE_MESSAGE
|
||||
} from '@/domain/guideReadiness'
|
||||
import type {
|
||||
SgsFloorDiagnosticsPayload,
|
||||
SgsMapDiagnosticsPayload,
|
||||
SgsPoiPayload,
|
||||
SgsPositionPayload,
|
||||
SgsSdkFloorSummaryPayload,
|
||||
SgsSdkManifestPayload
|
||||
} from '@/data/providers/sgsSdkApiProvider'
|
||||
|
||||
const defaultCategory: MuseumCategory = {
|
||||
id: 'operation_experience',
|
||||
label: '导览点位',
|
||||
iconType: 'poi'
|
||||
}
|
||||
|
||||
const categoryBySgsType: Record<string, MuseumCategory & { accessible?: boolean }> = {
|
||||
toilet: {
|
||||
id: 'basic_service_facility',
|
||||
label: '卫生间',
|
||||
iconType: 'toilet'
|
||||
},
|
||||
accessible_toilet: {
|
||||
id: 'accessibility_special_service',
|
||||
label: '无障碍卫生间',
|
||||
iconType: 'accessible_toilet',
|
||||
accessible: true
|
||||
},
|
||||
elevator: {
|
||||
id: 'transport_circulation',
|
||||
label: '电梯',
|
||||
iconType: 'elevator',
|
||||
accessible: true
|
||||
},
|
||||
stairs: {
|
||||
id: 'transport_circulation',
|
||||
label: '楼梯',
|
||||
iconType: 'stairs'
|
||||
},
|
||||
escalator: {
|
||||
id: 'transport_circulation',
|
||||
label: '扶梯',
|
||||
iconType: 'escalator'
|
||||
},
|
||||
entrance_exit: {
|
||||
id: 'transport_circulation',
|
||||
label: '出入口',
|
||||
iconType: 'entrance_exit'
|
||||
},
|
||||
service_desk: {
|
||||
id: 'basic_service_facility',
|
||||
label: '服务台',
|
||||
iconType: 'service_desk'
|
||||
},
|
||||
mother_baby_room: {
|
||||
id: 'basic_service_facility',
|
||||
label: '母婴室',
|
||||
iconType: 'mother_baby_room',
|
||||
accessible: true
|
||||
},
|
||||
locker: {
|
||||
id: 'basic_service_facility',
|
||||
label: '存包处',
|
||||
iconType: 'locker'
|
||||
},
|
||||
rental_service: {
|
||||
id: 'basic_service_facility',
|
||||
label: '租赁服务',
|
||||
iconType: 'rental_service',
|
||||
accessible: true
|
||||
},
|
||||
ticket_office: {
|
||||
id: 'basic_service_facility',
|
||||
label: '售票处',
|
||||
iconType: 'ticket_office'
|
||||
}
|
||||
}
|
||||
|
||||
export const stringifyId = (value: string | number | null | undefined) => (
|
||||
value === null || typeof value === 'undefined' ? '' : String(value)
|
||||
)
|
||||
|
||||
const toNumber = (value: number | null | undefined, fallback = 0) => (
|
||||
Number.isFinite(Number(value)) ? Number(value) : fallback
|
||||
)
|
||||
|
||||
const optionalNumber = (value: number | null | undefined) => (
|
||||
Number.isFinite(Number(value)) ? Number(value) : undefined
|
||||
)
|
||||
|
||||
const normalizeStatus = (status?: string): GuideDiagnosticsStatus => {
|
||||
if (status === 'OK' || status === 'WARN' || status === 'ERROR') return status
|
||||
return 'WARN'
|
||||
}
|
||||
|
||||
export const formatSgsFloorLabel = (floorCode?: string | null, floorName?: string | null) => {
|
||||
if (floorCode === 'EXTERIOR') return '室外'
|
||||
|
||||
const basementMatch = floorCode?.match(/^L-(\d+(?:\.\d+)?)$/)
|
||||
if (basementMatch) return `B${basementMatch[1]}`
|
||||
|
||||
const floorMatch = floorCode?.match(/^L(\d+(?:\.\d+)?)$/)
|
||||
if (floorMatch) return `${floorMatch[1]}F`
|
||||
|
||||
return floorName || floorCode || '未知楼层'
|
||||
}
|
||||
|
||||
export const toMuseumFloorFromSgs = (floor: SgsSdkFloorSummaryPayload): MuseumFloor => ({
|
||||
id: stringifyId(floor.floorId),
|
||||
label: formatSgsFloorLabel(floor.floorCode, floor.floorName),
|
||||
order: toNumber(floor.sortOrder)
|
||||
})
|
||||
|
||||
export const buildSgsFloorAliases = (floors: SgsSdkFloorSummaryPayload[]) => {
|
||||
const aliases = new Map<string, string>()
|
||||
|
||||
floors.forEach((floor) => {
|
||||
const floorId = stringifyId(floor.floorId)
|
||||
if (!floorId) return
|
||||
|
||||
const label = formatSgsFloorLabel(floor.floorCode, floor.floorName)
|
||||
aliases.set(floorId, floorId)
|
||||
if (floor.floorCode) aliases.set(String(floor.floorCode), floorId)
|
||||
if (floor.floorName) aliases.set(String(floor.floorName), floorId)
|
||||
aliases.set(label, floorId)
|
||||
aliases.set(label.toLowerCase(), floorId)
|
||||
})
|
||||
|
||||
return aliases
|
||||
}
|
||||
|
||||
const normalizePosition = (poi: SgsPoiPayload): [number, number, number] | undefined => {
|
||||
const source: SgsPositionPayload = poi.position || {
|
||||
x: poi.x,
|
||||
y: poi.y,
|
||||
z: poi.z
|
||||
}
|
||||
const x = Number(source.x)
|
||||
const y = Number(source.y)
|
||||
const z = Number(source.z)
|
||||
|
||||
if (![x, y, z].every(Number.isFinite)) return undefined
|
||||
return [x, y, z]
|
||||
}
|
||||
|
||||
const categoryFor = (poi: SgsPoiPayload): MuseumCategory & { accessible?: boolean } => {
|
||||
const normalizedType = poi.type?.trim()
|
||||
if (normalizedType && categoryBySgsType[normalizedType]) {
|
||||
return categoryBySgsType[normalizedType]
|
||||
}
|
||||
|
||||
if (poi.typeName) {
|
||||
return {
|
||||
...defaultCategory,
|
||||
label: poi.typeName,
|
||||
iconType: normalizedType || defaultCategory.iconType
|
||||
}
|
||||
}
|
||||
|
||||
return defaultCategory
|
||||
}
|
||||
|
||||
export const toMuseumPoiFromSgs = (
|
||||
poi: SgsPoiPayload,
|
||||
floors: SgsSdkFloorSummaryPayload[]
|
||||
): MuseumPoi => {
|
||||
const floorId = stringifyId(poi.floorId)
|
||||
const matchedFloor = floors.find((floor) => stringifyId(floor.floorId) === floorId || floor.floorCode === poi.floorCode)
|
||||
const category = categoryFor(poi)
|
||||
|
||||
return {
|
||||
id: stringifyId(poi.id),
|
||||
name: poi.name?.trim() || `未命名点位 ${stringifyId(poi.id)}`,
|
||||
floorId,
|
||||
floorLabel: formatSgsFloorLabel(matchedFloor?.floorCode || poi.floorCode, matchedFloor?.floorName),
|
||||
primaryCategory: {
|
||||
id: category.id,
|
||||
label: category.label,
|
||||
iconType: category.iconType
|
||||
},
|
||||
categories: [
|
||||
{
|
||||
id: category.id,
|
||||
label: category.label,
|
||||
iconType: category.iconType
|
||||
}
|
||||
],
|
||||
positionGltf: normalizePosition(poi),
|
||||
sourceObjectName: poi.anchorNodeName || undefined,
|
||||
sourceConfidence: 'backend-sgs-sdk',
|
||||
navigationReadiness: '位置预览',
|
||||
accessible: category.accessible === true
|
||||
}
|
||||
}
|
||||
|
||||
export const toLocationPreviewFromPoi = (poi: MuseumPoi): GuideLocationPreview => ({
|
||||
poiId: poi.id,
|
||||
name: poi.name,
|
||||
floorId: poi.floorId,
|
||||
floorLabel: poi.floorLabel,
|
||||
primaryCategoryZh: poi.primaryCategory.label,
|
||||
positionGltf: poi.positionGltf,
|
||||
sourceObjectName: poi.sourceObjectName
|
||||
})
|
||||
|
||||
export const toGuideFloorDetailFromSgs = (
|
||||
floor: SgsSdkFloorSummaryPayload | SgsFloorDiagnosticsPayload
|
||||
): GuideFloorDetail => ({
|
||||
...toMuseumFloorFromSgs(floor),
|
||||
sourceCode: floor.floorCode || undefined,
|
||||
sourceName: floor.floorName || undefined,
|
||||
modelReady: 'modelReady' in floor ? floor.modelReady === true : undefined,
|
||||
poiCount: optionalNumber(floor.poiCount),
|
||||
spaceCount: optionalNumber(floor.spaceCount),
|
||||
guideStopCount: 'guideStopCount' in floor ? toNumber(floor.guideStopCount, 0) : undefined,
|
||||
navigablePlaceCount: 'navigablePlaceCount' in floor ? toNumber(floor.navigablePlaceCount, 0) : undefined,
|
||||
routeNodeCount: 'routeNodeCount' in floor ? toNumber(floor.routeNodeCount, 0) : undefined,
|
||||
routeEdgeCount: 'routeEdgeCount' in floor ? toNumber(floor.routeEdgeCount, 0) : undefined,
|
||||
routePlanningReady: 'routePlanningReady' in floor ? floor.routePlanningReady === true : undefined,
|
||||
warnings: 'warnings' in floor ? floor.warnings || [] : []
|
||||
})
|
||||
|
||||
export const toGuideMapDiagnostics = (
|
||||
diagnostics: SgsMapDiagnosticsPayload,
|
||||
manifest?: SgsSdkManifestPayload
|
||||
): GuideMapDiagnostics => {
|
||||
const summary = diagnostics.summary || {}
|
||||
const floors = diagnostics.floors?.length
|
||||
? diagnostics.floors.map(toGuideFloorDetailFromSgs)
|
||||
: (manifest?.floors || []).map(toGuideFloorDetailFromSgs)
|
||||
|
||||
return {
|
||||
mapId: stringifyId(diagnostics.mapId || manifest?.mapId),
|
||||
mapName: diagnostics.mapName || manifest?.mapName || 'SGS 地图',
|
||||
sdkVersion: diagnostics.sdkVersion || manifest?.sdkVersion || undefined,
|
||||
status: normalizeStatus(diagnostics.status),
|
||||
floors,
|
||||
summary: {
|
||||
floorCount: toNumber(summary.floorCount, floors.length),
|
||||
modelReadyFloorCount: toNumber(summary.modelReadyFloorCount),
|
||||
poiCount: toNumber(summary.poiCount),
|
||||
spaceCount: toNumber(summary.spaceCount),
|
||||
guideStopCount: toNumber(summary.guideStopCount),
|
||||
navigablePlaceCount: toNumber(summary.navigablePlaceCount),
|
||||
routeNodeCount: toNumber(summary.routeNodeCount),
|
||||
routeEdgeCount: toNumber(summary.routeEdgeCount)
|
||||
},
|
||||
warnings: diagnostics.warnings || []
|
||||
}
|
||||
}
|
||||
|
||||
export const toRouteReadinessFromSgsDiagnostics = (
|
||||
diagnostics: SgsMapDiagnosticsPayload
|
||||
): GuideRouteReadiness => {
|
||||
const warnings = diagnostics.warnings || []
|
||||
const floors = diagnostics.floors || []
|
||||
const notReadyFloors = floors.filter((floor) => (
|
||||
floor.modelReady !== true
|
||||
|| floor.routePlanningReady !== true
|
||||
|| toNumber(floor.routeNodeCount) <= 0
|
||||
|| toNumber(floor.routeEdgeCount) <= 0
|
||||
|| toNumber(floor.navigablePlaceCount) <= 0
|
||||
))
|
||||
|
||||
const ready = diagnostics.status === 'OK' && notReadyFloors.length === 0 && warnings.length === 0
|
||||
|
||||
return {
|
||||
ready,
|
||||
message: ready
|
||||
? 'SGS 后端路线数据已就绪'
|
||||
: NAV_ROUTE_UNAVAILABLE_MESSAGE,
|
||||
requiredData: ready
|
||||
? []
|
||||
: [
|
||||
...warnings,
|
||||
...notReadyFloors.map((floor) => `${formatSgsFloorLabel(floor.floorCode, floor.floorName)} 数据未完全就绪`)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const addIssue = (
|
||||
issues: GuideDataIntegrityIssue[],
|
||||
issue: Omit<GuideDataIntegrityIssue, 'id'>
|
||||
) => {
|
||||
issues.push({
|
||||
id: `issue-${issues.length + 1}`,
|
||||
...issue
|
||||
})
|
||||
}
|
||||
|
||||
const missingFieldsForPoi = (poi: MuseumPoi) => {
|
||||
const fields: string[] = []
|
||||
if (!poi.id) fields.push('id')
|
||||
if (!poi.name) fields.push('name')
|
||||
if (!poi.floorId) fields.push('floorId')
|
||||
if (!poi.positionGltf) fields.push('position')
|
||||
return fields
|
||||
}
|
||||
|
||||
export const createGuideDataIntegrityReport = (
|
||||
diagnostics: GuideMapDiagnostics,
|
||||
floors: MuseumFloor[],
|
||||
pois: MuseumPoi[]
|
||||
): GuideDataIntegrityReport => {
|
||||
const issues: GuideDataIntegrityIssue[] = []
|
||||
const floorIds = new Set(floors.map((floor) => floor.id))
|
||||
const poiIds = new Set<string>()
|
||||
|
||||
floors.forEach((floor) => {
|
||||
if (!floor.id || !floor.label) {
|
||||
addIssue(issues, {
|
||||
scope: 'floor',
|
||||
severity: 'error',
|
||||
message: '楼层缺少稳定 ID 或显示名称',
|
||||
floorId: floor.id,
|
||||
floorLabel: floor.label,
|
||||
fields: ['id', 'label'].filter((field) => !floor[field as keyof MuseumFloor])
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
diagnostics.floors.forEach((floor) => {
|
||||
if (floor.modelReady === false) {
|
||||
addIssue(issues, {
|
||||
scope: 'floor',
|
||||
severity: 'warn',
|
||||
message: `${floor.label} 模型未就绪`,
|
||||
floorId: floor.id,
|
||||
floorLabel: floor.label,
|
||||
fields: ['modelReady']
|
||||
})
|
||||
}
|
||||
|
||||
if (floor.navigablePlaceCount === 0) {
|
||||
addIssue(issues, {
|
||||
scope: 'floor',
|
||||
severity: 'warn',
|
||||
message: `${floor.label} 缺少可导航目的地`,
|
||||
floorId: floor.id,
|
||||
floorLabel: floor.label,
|
||||
fields: ['navigablePlaceCount']
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
pois.forEach((poi) => {
|
||||
const missingFields = missingFieldsForPoi(poi)
|
||||
if (missingFields.length) {
|
||||
addIssue(issues, {
|
||||
scope: 'poi',
|
||||
severity: 'error',
|
||||
message: `${poi.name || poi.id || '未知点位'} 缺少必要字段`,
|
||||
floorId: poi.floorId,
|
||||
floorLabel: poi.floorLabel,
|
||||
poiId: poi.id,
|
||||
fields: missingFields
|
||||
})
|
||||
}
|
||||
|
||||
if (poi.id && poiIds.has(poi.id)) {
|
||||
addIssue(issues, {
|
||||
scope: 'poi',
|
||||
severity: 'error',
|
||||
message: `${poi.name || poi.id} 点位 ID 重复`,
|
||||
floorId: poi.floorId,
|
||||
floorLabel: poi.floorLabel,
|
||||
poiId: poi.id,
|
||||
fields: ['id']
|
||||
})
|
||||
}
|
||||
if (poi.id) poiIds.add(poi.id)
|
||||
|
||||
if (poi.floorId && !floorIds.has(poi.floorId)) {
|
||||
addIssue(issues, {
|
||||
scope: 'poi',
|
||||
severity: 'error',
|
||||
message: `${poi.name || poi.id} 关联的楼层不存在`,
|
||||
floorId: poi.floorId,
|
||||
floorLabel: poi.floorLabel,
|
||||
poiId: poi.id,
|
||||
fields: ['floorId']
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const errorCount = issues.filter((issue) => issue.severity === 'error').length
|
||||
const warningCount = issues.filter((issue) => issue.severity === 'warn').length
|
||||
|
||||
return {
|
||||
status: errorCount > 0 ? 'ERROR' : warningCount > 0 || diagnostics.status === 'WARN' ? 'WARN' : 'OK',
|
||||
summary: {
|
||||
floorCount: floors.length,
|
||||
poiCount: pois.length,
|
||||
issueCount: issues.length,
|
||||
errorCount,
|
||||
warningCount
|
||||
},
|
||||
issues,
|
||||
warnings: diagnostics.warnings
|
||||
}
|
||||
}
|
||||
318
src/data/adapters/sgsSdkRouteAdapter.ts
Normal file
318
src/data/adapters/sgsSdkRouteAdapter.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import type {
|
||||
GuideRouteConnectorPoint,
|
||||
GuideRouteEndpoint,
|
||||
GuideRouteFloorSegment,
|
||||
GuideRoutePoint,
|
||||
GuideRouteResult,
|
||||
GuideRouteTarget
|
||||
} from '@/domain/museum'
|
||||
import type {
|
||||
SgsRoutePlanResponsePayload
|
||||
} from '@/data/providers/sgsSdkApiProvider'
|
||||
import type {
|
||||
SgsRouteResult as SgsSdkRuntimeRouteResult
|
||||
} from '@/types/sgs-map-sdk'
|
||||
import { toAppFloorId } from '@/services/sgs/SgsMapEventAdapter'
|
||||
|
||||
type SgsRoutePathNode = {
|
||||
nodeId?: string | number
|
||||
floorId?: string | number
|
||||
x: number
|
||||
y?: number
|
||||
z: number
|
||||
}
|
||||
|
||||
type CompatibleSgsRouteResult = SgsSdkRuntimeRouteResult & {
|
||||
path?: SgsRoutePathNode[]
|
||||
}
|
||||
|
||||
type GeoJsonLineString = {
|
||||
type?: string
|
||||
coordinates?: Array<[number, number] | [number, number, number]>
|
||||
}
|
||||
|
||||
const pointPosition = (point: SgsRoutePathNode): [number, number, number] => [
|
||||
Number(point.x || 0),
|
||||
Number(point.y || 0),
|
||||
Number(point.z || 0)
|
||||
]
|
||||
|
||||
const targetEndpoint = (target: GuideRouteTarget): GuideRouteEndpoint => ({
|
||||
poiId: target.poiId,
|
||||
name: target.name,
|
||||
floorId: target.floorId,
|
||||
floorLabel: target.floorLabel,
|
||||
routeNodeId: target.routeNodeId,
|
||||
position: target.positionGltf || [0, 0, 0]
|
||||
})
|
||||
|
||||
const normalizePath = (
|
||||
route: CompatibleSgsRouteResult,
|
||||
startFloorId: string,
|
||||
endFloorId: string
|
||||
): GuideRoutePoint[] => {
|
||||
const pathPoints = route.pathPoints ?? route.path ?? []
|
||||
|
||||
if (!pathPoints.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (route.path?.length) {
|
||||
return route.path.map((point, index) => ({
|
||||
nodeId: String(point.nodeId || `sdk-route-point-${index}`),
|
||||
floorId: toAppFloorId(point.floorId ?? startFloorId),
|
||||
position: pointPosition(point)
|
||||
}))
|
||||
}
|
||||
|
||||
const isCrossFloor = startFloorId !== endFloorId
|
||||
const totalPoints = pathPoints.length
|
||||
const normalizedStartFloorId = toAppFloorId(startFloorId)
|
||||
const normalizedEndFloorId = toAppFloorId(endFloorId)
|
||||
|
||||
return pathPoints.map((point, index) => {
|
||||
const nodeId = `sdk-pp-${index}`
|
||||
let floorId: string
|
||||
|
||||
if (isCrossFloor) {
|
||||
if (index === 0) {
|
||||
floorId = normalizedStartFloorId
|
||||
} else if (index === totalPoints - 1) {
|
||||
floorId = normalizedEndFloorId
|
||||
} else {
|
||||
floorId = normalizedStartFloorId
|
||||
}
|
||||
} else {
|
||||
floorId = normalizedStartFloorId
|
||||
}
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
floorId,
|
||||
position: pointPosition(point)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const createFloorSegments = (
|
||||
points: GuideRoutePoint[],
|
||||
start: GuideRouteTarget,
|
||||
end: GuideRouteTarget
|
||||
): GuideRouteFloorSegment[] => {
|
||||
const normalizedStartFloorId = toAppFloorId(start.floorId)
|
||||
const normalizedEndFloorId = toAppFloorId(end.floorId)
|
||||
|
||||
const labels = new Map([
|
||||
[normalizedStartFloorId, start.floorLabel],
|
||||
[normalizedEndFloorId, end.floorLabel]
|
||||
])
|
||||
|
||||
return points.reduce<GuideRouteFloorSegment[]>((segments, point) => {
|
||||
const current = segments[segments.length - 1]
|
||||
if (current && current.floorId === point.floorId) {
|
||||
current.points.push(point)
|
||||
return segments
|
||||
}
|
||||
|
||||
segments.push({
|
||||
floorId: point.floorId,
|
||||
floorLabel: labels.get(point.floorId) || point.floorId,
|
||||
points: [point]
|
||||
})
|
||||
return segments
|
||||
}, [])
|
||||
}
|
||||
|
||||
export const toGuideRouteResultFromSgs = (
|
||||
route: SgsSdkRuntimeRouteResult,
|
||||
startTarget: GuideRouteTarget,
|
||||
endTarget: GuideRouteTarget
|
||||
): GuideRouteResult => {
|
||||
const start = targetEndpoint(startTarget)
|
||||
const end = targetEndpoint(endTarget)
|
||||
const points = normalizePath(
|
||||
route as CompatibleSgsRouteResult,
|
||||
startTarget.floorId,
|
||||
endTarget.floorId
|
||||
)
|
||||
const routePoints = points.length
|
||||
? points
|
||||
: [
|
||||
{
|
||||
nodeId: start.routeNodeId,
|
||||
floorId: start.floorId,
|
||||
position: start.position
|
||||
},
|
||||
{
|
||||
nodeId: end.routeNodeId,
|
||||
floorId: end.floorId,
|
||||
position: end.position
|
||||
}
|
||||
]
|
||||
|
||||
return {
|
||||
id: `sdk-route-${start.poiId}-${end.poiId}`,
|
||||
start,
|
||||
end,
|
||||
distanceMeters: Number(route.distance || 0),
|
||||
nodeIds: routePoints.map((point) => point.nodeId),
|
||||
points: routePoints,
|
||||
floorSegments: createFloorSegments(routePoints, startTarget, endTarget),
|
||||
connectorPoints: []
|
||||
}
|
||||
}
|
||||
|
||||
const floorLabelFor = (
|
||||
floorId: string,
|
||||
startTarget: GuideRouteTarget,
|
||||
endTarget: GuideRouteTarget
|
||||
) => {
|
||||
if (floorId === startTarget.floorId) return startTarget.floorLabel
|
||||
if (floorId === endTarget.floorId) return endTarget.floorLabel
|
||||
return floorId
|
||||
}
|
||||
|
||||
const pointFromBackendNode = (
|
||||
node: NonNullable<SgsRoutePlanResponsePayload['nodePaths']>[number],
|
||||
index: number,
|
||||
fallbackFloorId: string
|
||||
): GuideRoutePoint => ({
|
||||
nodeId: String(node.id || `sgs-route-node-${index}`),
|
||||
floorId: toAppFloorId(node.floorId ?? fallbackFloorId),
|
||||
position: [
|
||||
Number(node.x || 0),
|
||||
0,
|
||||
Number(node.y || 0)
|
||||
]
|
||||
})
|
||||
|
||||
const pointsFromGeoJson = (
|
||||
geoJson: string | null | undefined,
|
||||
floorId: string
|
||||
): GuideRoutePoint[] => {
|
||||
if (!geoJson) return []
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(geoJson) as GeoJsonLineString
|
||||
const coordinates = parsed.type === 'LineString' && Array.isArray(parsed.coordinates)
|
||||
? parsed.coordinates
|
||||
: []
|
||||
|
||||
return coordinates.map((coordinate, index) => ({
|
||||
nodeId: `${floorId}-geo-${index}`,
|
||||
floorId,
|
||||
position: [
|
||||
Number(coordinate[0] || 0),
|
||||
0,
|
||||
Number(coordinate[1] || 0)
|
||||
]
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const toGuideRouteResultFromSgsPlan = (
|
||||
route: SgsRoutePlanResponsePayload,
|
||||
startTarget: GuideRouteTarget,
|
||||
endTarget: GuideRouteTarget
|
||||
): GuideRouteResult => {
|
||||
const start = targetEndpoint(startTarget)
|
||||
const end = targetEndpoint(endTarget)
|
||||
const nodePoints = (route.nodePaths || []).map((node, index) => (
|
||||
pointFromBackendNode(node, index, start.floorId)
|
||||
))
|
||||
const routePoints = nodePoints.length
|
||||
? [
|
||||
{
|
||||
nodeId: start.routeNodeId,
|
||||
floorId: start.floorId,
|
||||
position: start.position
|
||||
},
|
||||
...nodePoints,
|
||||
{
|
||||
nodeId: end.routeNodeId,
|
||||
floorId: end.floorId,
|
||||
position: end.position
|
||||
}
|
||||
]
|
||||
: pointsFromGeoJson(route.pathGeoJson, start.floorId)
|
||||
|
||||
const fallbackPoints = routePoints.length
|
||||
? routePoints
|
||||
: [
|
||||
{
|
||||
nodeId: start.routeNodeId,
|
||||
floorId: start.floorId,
|
||||
position: start.position
|
||||
},
|
||||
{
|
||||
nodeId: end.routeNodeId,
|
||||
floorId: end.floorId,
|
||||
position: end.position
|
||||
}
|
||||
]
|
||||
|
||||
const floorSegments = route.segments?.length
|
||||
? route.segments.map((segment, segmentIndex) => {
|
||||
const floorId = String(segment.floorId || start.floorId)
|
||||
const points = pointsFromGeoJson(segment.pathGeoJson, floorId)
|
||||
|
||||
return {
|
||||
floorId,
|
||||
floorLabel: segment.floorName || floorLabelFor(floorId, startTarget, endTarget),
|
||||
points: points.length
|
||||
? points
|
||||
: fallbackPoints.filter((point) => point.floorId === floorId).map((point, index) => ({
|
||||
...point,
|
||||
nodeId: point.nodeId || `${floorId}-segment-${segmentIndex}-${index}`
|
||||
}))
|
||||
}
|
||||
}).filter((segment) => segment.points.length)
|
||||
: createFloorSegments(fallbackPoints, startTarget, endTarget)
|
||||
|
||||
return {
|
||||
id: `sgs-api-route-${start.poiId}-${end.poiId}`,
|
||||
start,
|
||||
end,
|
||||
distanceMeters: Number(route.distance || 0),
|
||||
nodeIds: fallbackPoints.map((point) => point.nodeId),
|
||||
points: fallbackPoints,
|
||||
floorSegments,
|
||||
connectorPoints: extractFloorConnectorPoints(fallbackPoints, startTarget, endTarget)
|
||||
}
|
||||
}
|
||||
|
||||
const extractFloorConnectorPoints = (
|
||||
points: GuideRoutePoint[],
|
||||
startTarget: GuideRouteTarget,
|
||||
endTarget: GuideRouteTarget
|
||||
): GuideRouteConnectorPoint[] => {
|
||||
if (points.length < 2) return []
|
||||
|
||||
const connectors: GuideRouteConnectorPoint[] = []
|
||||
const labels = new Map<string, string>([
|
||||
[startTarget.floorId, startTarget.floorLabel],
|
||||
[endTarget.floorId, endTarget.floorLabel]
|
||||
])
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const currentFloorId = toAppFloorId(points[i].floorId)
|
||||
const nextFloorId = toAppFloorId(points[i + 1].floorId)
|
||||
|
||||
if (currentFloorId !== nextFloorId) {
|
||||
const fromLabel = labels.get(currentFloorId) || currentFloorId
|
||||
const toLabel = labels.get(nextFloorId) || nextFloorId
|
||||
|
||||
connectors.push({
|
||||
nodeId: `connector-${points[i].nodeId}-${nextFloorId}`,
|
||||
floorId: nextFloorId,
|
||||
floorLabel: `经 ${fromLabel} 至 ${toLabel}`,
|
||||
position: points[i].position,
|
||||
connectorType: 'floor_change'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return connectors
|
||||
}
|
||||
358
src/data/providers/sgsSdkApiProvider.ts
Normal file
358
src/data/providers/sgsSdkApiProvider.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import {
|
||||
dataSourceConfig
|
||||
} from '@/config/dataSource'
|
||||
|
||||
export type SgsDiagnosticsStatusPayload = 'OK' | 'WARN' | 'ERROR'
|
||||
|
||||
export interface SgsSdkFloorSummaryPayload {
|
||||
floorId: string | number
|
||||
floorCode?: string | null
|
||||
floorName?: string | null
|
||||
sortOrder?: number | null
|
||||
modelSizeBytes?: number | null
|
||||
poiCount?: number | null
|
||||
spaceCount?: number | null
|
||||
}
|
||||
|
||||
export interface SgsSdkManifestPayload {
|
||||
mapId: string | number
|
||||
mapName?: string | null
|
||||
sdkVersion?: string | null
|
||||
protocolVersion?: number | null
|
||||
dataVersion?: string | null
|
||||
updatedAt?: string | null
|
||||
coordinateSystem?: string | null
|
||||
floors: SgsSdkFloorSummaryPayload[]
|
||||
capabilities?: Record<string, boolean | undefined>
|
||||
}
|
||||
|
||||
export interface SgsPositionPayload {
|
||||
x?: number | null
|
||||
y?: number | null
|
||||
z?: number | null
|
||||
}
|
||||
|
||||
export interface SgsPoiPayload {
|
||||
id: string | number
|
||||
name?: string | null
|
||||
type?: string | null
|
||||
typeName?: string | null
|
||||
floorCode?: string | null
|
||||
floorId?: string | number | null
|
||||
position?: SgsPositionPayload | null
|
||||
x?: number | null
|
||||
y?: number | null
|
||||
z?: number | null
|
||||
status?: string | null
|
||||
anchorNodeName?: string | null
|
||||
description?: string | null
|
||||
iconUrl?: string | null
|
||||
}
|
||||
|
||||
export interface SgsSpacePayload {
|
||||
id: string | number
|
||||
name?: string | null
|
||||
type?: string | null
|
||||
floorId?: string | number | null
|
||||
boundaryWkt?: string | null
|
||||
center?: SgsPositionPayload | null
|
||||
sourceNodeName?: string | null
|
||||
status?: string | null
|
||||
colorHex?: string | null
|
||||
}
|
||||
|
||||
export interface SgsNavigablePlacePayload {
|
||||
id?: string | number | null
|
||||
name?: string | null
|
||||
category?: string | null
|
||||
type?: string | null
|
||||
typeCode?: string | null
|
||||
typeName?: string | null
|
||||
floorId?: string | number | null
|
||||
floorCode?: string | null
|
||||
floorName?: string | null
|
||||
position?: SgsPositionPayload | null
|
||||
x?: number | null
|
||||
y?: number | null
|
||||
z?: number | null
|
||||
nodeId?: string | number | null
|
||||
ownerName?: string | null
|
||||
}
|
||||
|
||||
export interface SgsFloorDiagnosticsPayload {
|
||||
floorId: string | number
|
||||
floorCode?: string | null
|
||||
floorName?: string | null
|
||||
status: SgsDiagnosticsStatusPayload
|
||||
modelReady?: boolean
|
||||
poiCount?: number
|
||||
spaceCount?: number
|
||||
guideStopCount?: number
|
||||
navigablePlaceCount?: number
|
||||
routeNodeCount?: number
|
||||
routeEdgeCount?: number
|
||||
routePlanningReady?: boolean
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export interface SgsFloorDiagnosticsSummaryPayload extends SgsFloorDiagnosticsPayload {}
|
||||
|
||||
export interface SgsMapDiagnosticsPayload {
|
||||
mapId: string | number
|
||||
mapName?: string | null
|
||||
sdkVersion?: string | null
|
||||
status: SgsDiagnosticsStatusPayload
|
||||
floors: SgsFloorDiagnosticsSummaryPayload[]
|
||||
summary?: {
|
||||
floorCount?: number
|
||||
modelReadyFloorCount?: number
|
||||
poiCount?: number
|
||||
spaceCount?: number
|
||||
guideStopCount?: number
|
||||
navigablePlaceCount?: number
|
||||
routeNodeCount?: number
|
||||
routeEdgeCount?: number
|
||||
}
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export interface SgsFloorBundlePayload {
|
||||
floor?: SgsSdkFloorSummaryPayload | null
|
||||
model?: SgsModelInfoPayload | null
|
||||
pois?: SgsPoiPayload[]
|
||||
spaces?: SgsSpacePayload[]
|
||||
guideStops?: unknown[]
|
||||
routeSummary?: {
|
||||
hasRouteNetwork?: boolean
|
||||
nodeCount?: number
|
||||
edgeCount?: number
|
||||
} | null
|
||||
hiddenSceneNodeNames?: string[]
|
||||
dataVersion?: string | null
|
||||
updatedAt?: string | null
|
||||
}
|
||||
|
||||
export interface SgsModelInfoPayload {
|
||||
modelUrl?: string | null
|
||||
fallbackModelUrl?: string | null
|
||||
compressionType?: string | null
|
||||
modelVersion?: string | null
|
||||
nodeCount?: number | null
|
||||
sizeBytes?: number | null
|
||||
originSizeBytes?: number | null
|
||||
}
|
||||
|
||||
export interface SgsRoutePlanRequestPayload {
|
||||
startFloorId: number | string
|
||||
startX: number
|
||||
startY: number
|
||||
startNodeId?: number | string | null
|
||||
endFloorId: number | string
|
||||
endX: number
|
||||
endY: number
|
||||
endNodeId?: number | string | null
|
||||
wheelchair: 0 | 1
|
||||
}
|
||||
|
||||
export interface SgsRouteStepNodePayload {
|
||||
id?: string | number | null
|
||||
nodeName?: string | null
|
||||
floorId?: string | number | null
|
||||
nodeType?: string | null
|
||||
x?: number | null
|
||||
y?: number | null
|
||||
}
|
||||
|
||||
export interface SgsRouteSegmentPayload {
|
||||
floorId?: string | number | null
|
||||
floorName?: string | null
|
||||
startNodeId?: string | number | null
|
||||
endNodeId?: string | number | null
|
||||
startNodeName?: string | null
|
||||
endNodeName?: string | null
|
||||
transferType?: string | null
|
||||
distance?: number | null
|
||||
duration?: number | null
|
||||
pathGeoJson?: string | null
|
||||
nodePathIds?: Array<string | number | null>
|
||||
}
|
||||
|
||||
export interface SgsRoutePlanResponsePayload {
|
||||
distance?: number | null
|
||||
duration?: number | null
|
||||
pathGeoJson?: string | null
|
||||
nodePaths?: SgsRouteStepNodePayload[]
|
||||
segments?: SgsRouteSegmentPayload[]
|
||||
}
|
||||
|
||||
interface CommonResult<T> {
|
||||
code: number
|
||||
msg?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
export interface SgsSdkApiProvider {
|
||||
getManifest(mapId?: string): Promise<SgsSdkManifestPayload>
|
||||
getMapDiagnostics(mapId?: string): Promise<SgsMapDiagnosticsPayload>
|
||||
getFloorDiagnostics(floorId: string): Promise<SgsFloorDiagnosticsPayload>
|
||||
getFloorBundle(floorId: string): Promise<SgsFloorBundlePayload>
|
||||
getFloorPois(floorId: string): Promise<SgsPoiPayload[]>
|
||||
getFloorSpaces(floorId: string): Promise<SgsSpacePayload[]>
|
||||
getNavigablePlaces(floorId: string): Promise<SgsNavigablePlacePayload[]>
|
||||
planRoute(payload: SgsRoutePlanRequestPayload): Promise<SgsRoutePlanResponsePayload>
|
||||
}
|
||||
|
||||
const normalizeBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, '')
|
||||
|
||||
const resolveAppApiBaseUrl = () => {
|
||||
const baseUrl = normalizeBaseUrl(dataSourceConfig.sgsApiBaseUrl || dataSourceConfig.apiBaseUrl || '/app-api')
|
||||
return baseUrl.endsWith('/app-api') ? baseUrl : `${baseUrl}/app-api`
|
||||
}
|
||||
|
||||
const parseJsonPayload = <T>(payload: unknown): T => {
|
||||
if (typeof payload === 'string') {
|
||||
return JSON.parse(payload) as T
|
||||
}
|
||||
|
||||
return payload as T
|
||||
}
|
||||
|
||||
const requestJson = <T>(
|
||||
path: string,
|
||||
options: { method?: 'GET' | 'POST'; data?: unknown } = {}
|
||||
): Promise<T> => new Promise((resolve, reject) => {
|
||||
const baseUrl = resolveAppApiBaseUrl()
|
||||
|
||||
uni.request({
|
||||
url: `${baseUrl}${path}`,
|
||||
method: options.method || 'GET',
|
||||
data: options.method === 'POST' ? JSON.stringify(options.data || {}) : undefined,
|
||||
header: options.method === 'POST'
|
||||
? {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
: undefined,
|
||||
timeout: dataSourceConfig.sgsSdkTimeoutMs,
|
||||
success: (response) => {
|
||||
const statusCode = Number(response.statusCode || 0)
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
reject(new Error(`SGS 数据接口请求失败: ${statusCode} ${path}`))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const body = parseJsonPayload<CommonResult<T>>(response.data)
|
||||
if (!body || body.code !== 0) {
|
||||
reject(new Error(`SGS 数据接口业务失败: ${path} code=${body?.code} msg=${body?.msg || ''}`))
|
||||
return
|
||||
}
|
||||
|
||||
resolve(body.data as T)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
fail: (error) => {
|
||||
reject(new Error(`SGS 数据接口网络失败: ${path} ${JSON.stringify(error)}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export const createSgsSdkApiProvider = (): SgsSdkApiProvider => {
|
||||
const manifestCache = new Map<string, SgsSdkManifestPayload>()
|
||||
const mapDiagnosticsCache = new Map<string, SgsMapDiagnosticsPayload>()
|
||||
const floorDiagnosticsCache = new Map<string, SgsFloorDiagnosticsPayload>()
|
||||
const floorBundleCache = new Map<string, SgsFloorBundlePayload>()
|
||||
const floorPoiCache = new Map<string, SgsPoiPayload[]>()
|
||||
const floorSpaceCache = new Map<string, SgsSpacePayload[]>()
|
||||
const navigablePlaceCache = new Map<string, SgsNavigablePlacePayload[]>()
|
||||
|
||||
const mapIdFor = (mapId?: string) => mapId || dataSourceConfig.sgsMapId
|
||||
const floorIdFor = (floorId: string) => encodeURIComponent(floorId)
|
||||
|
||||
return {
|
||||
async getManifest(mapId) {
|
||||
const normalizedMapId = mapIdFor(mapId)
|
||||
const cached = manifestCache.get(normalizedMapId)
|
||||
if (cached) return cached
|
||||
|
||||
const manifest = await requestJson<SgsSdkManifestPayload>(
|
||||
`/gis/sdk/maps/${encodeURIComponent(normalizedMapId)}/manifest`
|
||||
)
|
||||
manifestCache.set(normalizedMapId, manifest)
|
||||
return manifest
|
||||
},
|
||||
async getMapDiagnostics(mapId) {
|
||||
const normalizedMapId = mapIdFor(mapId)
|
||||
const cached = mapDiagnosticsCache.get(normalizedMapId)
|
||||
if (cached) return cached
|
||||
|
||||
const diagnostics = await requestJson<SgsMapDiagnosticsPayload>(
|
||||
`/gis/sdk/maps/${encodeURIComponent(normalizedMapId)}/diagnostics`
|
||||
)
|
||||
mapDiagnosticsCache.set(normalizedMapId, diagnostics)
|
||||
return diagnostics
|
||||
},
|
||||
async getFloorDiagnostics(floorId) {
|
||||
const cached = floorDiagnosticsCache.get(floorId)
|
||||
if (cached) return cached
|
||||
|
||||
const diagnostics = await requestJson<SgsFloorDiagnosticsPayload>(
|
||||
`/gis/sdk/floors/${floorIdFor(floorId)}/diagnostics`
|
||||
)
|
||||
floorDiagnosticsCache.set(floorId, diagnostics)
|
||||
return diagnostics
|
||||
},
|
||||
async getFloorBundle(floorId) {
|
||||
const cached = floorBundleCache.get(floorId)
|
||||
if (cached) return cached
|
||||
|
||||
const bundle = await requestJson<SgsFloorBundlePayload>(
|
||||
`/gis/sdk/floors/${floorIdFor(floorId)}/bundle`
|
||||
)
|
||||
floorBundleCache.set(floorId, bundle)
|
||||
return bundle
|
||||
},
|
||||
async getFloorPois(floorId) {
|
||||
const cached = floorPoiCache.get(floorId)
|
||||
if (cached) return cached
|
||||
|
||||
const pois = await requestJson<SgsPoiPayload[]>(
|
||||
`/gis/sdk/floors/${floorIdFor(floorId)}/pois`
|
||||
)
|
||||
floorPoiCache.set(floorId, pois)
|
||||
return pois
|
||||
},
|
||||
async getFloorSpaces(floorId) {
|
||||
const cached = floorSpaceCache.get(floorId)
|
||||
if (cached) return cached
|
||||
|
||||
const spaces = await requestJson<SgsSpacePayload[]>(
|
||||
`/gis/sdk/floors/${floorIdFor(floorId)}/spaces`
|
||||
)
|
||||
floorSpaceCache.set(floorId, spaces)
|
||||
return spaces
|
||||
},
|
||||
async getNavigablePlaces(floorId) {
|
||||
const cached = navigablePlaceCache.get(floorId)
|
||||
if (cached) return cached
|
||||
|
||||
const places = await requestJson<SgsNavigablePlacePayload[]>(
|
||||
`/gis/sdk/floors/${floorIdFor(floorId)}/navigable-places`
|
||||
)
|
||||
navigablePlaceCache.set(floorId, places)
|
||||
return places
|
||||
},
|
||||
async planRoute(payload) {
|
||||
return requestJson<SgsRoutePlanResponsePayload>(
|
||||
'/gis/sdk/routes/plan',
|
||||
{
|
||||
method: 'POST',
|
||||
data: payload
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const defaultSgsSdkApiProvider = createSgsSdkApiProvider()
|
||||
Reference in New Issue
Block a user