import type { GuideDataIntegrityIssue, GuideDataIntegrityReport, GuideDiagnosticsStatus, GuideFloorDetail, GuideLocationPreview, GuideMapDiagnostics, GuideRouteReadiness, MuseumCategory, MuseumFloor, MuseumPoi, ExplainGuideStop, AudioPlayTargetType } from '@/domain/museum' import { NAV_ROUTE_UNAVAILABLE_MESSAGE } from '@/domain/guideReadiness' import { isIndoorNavigableFloor } from '@/domain/guideFloor' import type { SgsFloorDiagnosticsPayload, SgsGuideStopPayload, SgsMapDiagnosticsPayload, SgsNavigablePlacePayload, SgsPoiPayload, SgsPositionPayload, SgsSdkFloorSummaryPayload, SgsSdkManifestPayload, SgsSpacePayload } from '@/data/providers/sgsSdkApiProvider' const defaultCategory: MuseumCategory = { id: 'poi', label: '点位', iconType: 'poi' } const businessCategory: MuseumCategory = { id: 'business_poi', label: '运营服务', iconType: 'business' } const hallCategory: MuseumCategory = { id: 'exhibition_hall', label: '展厅', iconType: 'exhibition_hall' } const hallEntranceCategory: MuseumCategory = { id: 'exhibition_hall_entrance', label: '展厅出入口', iconType: 'hall_entrance' } const categoryBySgsType: Record = { poi: { id: 'poi', label: '点位', iconType: 'poi' }, device_terminal: { id: 'poi', label: '点位', iconType: 'poi' }, operation_experience: { id: 'operation_experience', label: '导览点位', iconType: 'poi' }, 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 normalizedText = (value?: string | null) => (value || '').trim() const normalizePositionSource = (source: SgsPositionPayload): [number, number, number] | undefined => { 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 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() floors.forEach((floor) => { if (!isIndoorNavigableFloor(floor)) return const floorId = stringifyId(floor.floorId) if (!floorId) return const label = formatSgsFloorLabel(floor.floorCode, floor.floorName) aliases.set(floorId, floorId) aliases.set(floorId.toLowerCase(), floorId) if (floor.floorCode) { aliases.set(String(floor.floorCode), floorId) aliases.set(String(floor.floorCode).toLowerCase(), floorId) } if (floor.floorName) { aliases.set(String(floor.floorName), floorId) aliases.set(String(floor.floorName).toLowerCase(), floorId) } aliases.set(label, floorId) aliases.set(label.toLowerCase(), floorId) }) return aliases } const exhibitionSpaceTypeWhitelist = new Set([ 'exhibition_hall', 'theater', 'education_activity', 'ramp' ]) const exhibitionHallKeywords = [ '展厅', '临展', '展览', '影院', '球幕', '巨幕', '报告厅', '活动室', '科普', '实验室', '展览坡道', '宇宙', '地球', '演化', '恐龙', '人类', '生物', '生态', '家园' ] const nonHallNavigablePlaceTypes = new Set([ 'elevator', 'stairs', 'stair', 'escalator', 'lift', 'toilet', 'accessible_toilet', 'restroom', '卫生间', '洗手间', '无障碍卫生间', '电梯', '楼梯', '扶梯' ]) export interface SgsHallPoiDiagnostics { spaceCount: number eligibleSpaceCount: number navigablePlaceCount: number hallPlaceCount: number hallPoiCount: number hallPoiWithPositionCount: number skippedSpaceCount: number skippedPlaceCount: number } const businessTypeLabels: Record = { shop: '商店', restaurant: '餐饮', cafe: '咖啡', vending: '售卖机', bookstore: '书店', cultural_shop: '文创商店', photo_spot: '打卡点' } interface SgsHallPoiBuildOptions { fallbackY?: number } const sgsSpaceCenterConfidence = 'backend-sgs-sdk-space-center' const sgsSpaceBoundaryCenterConfidence = 'backend-sgs-sdk-space-boundary-center' const sgsHallEntranceConfidence = 'backend-sgs-sdk-hall-entrance' const hasExhibitionKeyword = (value?: string | null) => ( exhibitionHallKeywords.some((keyword) => normalizedText(value).includes(keyword)) ) const searchableSpaceText = (space: SgsSpacePayload) => [ space.name, space.type, space.sourceNodeName ] .map((value) => normalizedText(value)) .filter(Boolean) .join(' ') const parseBoundaryWktCenter = ( boundaryWkt?: string | null, fallbackY?: number ): [number, number, number] | undefined => { const matches = [...(boundaryWkt || '').matchAll(/(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g)] const points = matches .map((match) => [Number(match[1]), Number(match[2])] as const) .filter(([x, z]) => Number.isFinite(x) && Number.isFinite(z)) if (!points.length || !Number.isFinite(fallbackY)) return undefined const xs = points.map(([x]) => x) const zs = points.map(([, z]) => z) const x = (Math.min(...xs) + Math.max(...xs)) / 2 const z = (Math.min(...zs) + Math.max(...zs)) / 2 return [x, fallbackY as number, z] } const normalizePositionSourceWithFallbackY = ( source: SgsPositionPayload | null | undefined, fallbackY?: number ): [number, number, number] | undefined => { if (!source) return undefined const x = Number(source.x) const rawY = Number(source.y) const y = Number.isFinite(rawY) ? rawY : Number(fallbackY) const z = Number(source.z) if (![x, y, z].every(Number.isFinite)) return undefined return [x, y, z] } const normalizeSpacePosition = ( space: SgsSpacePayload, fallbackY?: number ) => { const centerPosition = normalizePositionSourceWithFallbackY(space.center, fallbackY) if (centerPosition) { return { position: centerPosition, sourceConfidence: sgsSpaceCenterConfidence } } const boundaryCenter = parseBoundaryWktCenter(space.boundaryWkt, fallbackY) return boundaryCenter ? { position: boundaryCenter, sourceConfidence: sgsSpaceBoundaryCenterConfidence } : undefined } export const isExhibitionHallSpace = (space: SgsSpacePayload) => { const type = normalizedText(space.type).toLowerCase() const name = normalizedText(space.name) const searchableText = searchableSpaceText(space) if (!exhibitionSpaceTypeWhitelist.has(type)) return false if (type === 'exhibition_hall') return true if (type === 'ramp') return name.includes('展览坡道') return hasExhibitionKeyword(searchableText) } export const isExhibitionHallNavigablePlace = (place: SgsNavigablePlacePayload) => { const placeTypes = [ place.category, place.type, place.typeCode, place.typeName ] .map((value) => normalizedText(value).toLowerCase()) .filter(Boolean) if (placeTypes.some((type) => nonHallNavigablePlaceTypes.has(type))) return false const searchableText = [ place.name, place.ownerName, place.category, place.type, place.typeCode, place.typeName ] .map((value) => normalizedText(value)) .filter(Boolean) .join(' ') return hasExhibitionKeyword(searchableText) } const normalizedHallName = (value?: string | null) => normalizedText(value) .replace(/[\s()()【】[\]_-]/g, '') const normalizePosition = (poi: SgsPoiPayload): [number, number, number] | undefined => normalizePositionSource( poi.position || { x: poi.x, y: poi.y, z: poi.z } ) const normalizePlacePosition = ( place: SgsNavigablePlacePayload, fallbackY?: number ): [number, number, number] | undefined => normalizePositionSourceWithFallbackY( place.position ? { x: place.position.x, y: place.position.y, z: place.position.z } : { x: place.x, y: place.y, z: place.z }, fallbackY ) 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 (String(poi.poiGroup || '').toUpperCase() === 'OTHER') { return categoryBySgsType[normalizedType || ''] || defaultCategory } if (normalizedType && categoryBySgsType[normalizedType]) { return categoryBySgsType[normalizedType] } if (poi.typeName) { return { ...defaultCategory, label: poi.typeName, iconType: normalizedType || defaultCategory.iconType } } 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, 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, 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, name: poi.name, floorId: poi.floorId, floorLabel: poi.floorLabel, primaryCategoryZh: poi.primaryCategory.label, positionGltf: poi.positionGltf, sourceObjectName: poi.sourceObjectName, kind: poi.kind, hallId: poi.hallId, hallName: poi.hallName, entrances: poi.entrances }) const placeHallName = (place: SgsNavigablePlacePayload) => ( normalizedText(place.ownerName) || normalizedText(place.name) .replace(/(?:主)?(?:出入口|入口|出口|门)\s*[A-Za-z0-9一二三四五六七八九十号-]*$/u, '') .trim() || normalizedText(place.name) ) const findMatchedHallSpace = ( place: SgsNavigablePlacePayload, spaces: SgsSpacePayload[] ) => { const placeName = normalizedHallName(placeHallName(place)) if (!placeName) return undefined const rankedMatches = spaces .map((space) => { const spaceName = normalizedHallName(space.name) if (!spaceName) return null const exact = placeName === spaceName const overlaps = exact || placeName.includes(spaceName) || spaceName.includes(placeName) if (!overlaps) return null return { space, exact, specificity: spaceName.length } }) .filter((match): match is { space: SgsSpacePayload exact: boolean specificity: number } => Boolean(match)) .sort((left, right) => { if (left.exact !== right.exact) return left.exact ? -1 : 1 return right.specificity - left.specificity }) return rankedMatches[0]?.space } const floorLabelForSgs = ( floorId: string, floors: SgsSdkFloorSummaryPayload[], floorCode?: string | null, floorName?: string | null ) => { const matchedFloor = floors.find((floor) => stringifyId(floor.floorId) === floorId || floor.floorCode === floorCode) return formatSgsFloorLabel( matchedFloor?.floorCode || floorCode, matchedFloor?.floorName || floorName ) } const createHallEntrance = ( place: SgsNavigablePlacePayload, floors: SgsSdkFloorSummaryPayload[], fallbackFloorId: string, fallbackY?: number ) => { const floorId = stringifyId(place.floorId) || fallbackFloorId const placeId = stringifyId(place.id || place.nodeId) const routeNodeId = stringifyId(place.nodeId) || undefined return { id: placeId ? `hall-entrance-${placeId}` : `hall-entrance-${floorId}-${normalizedHallName(place.name)}`, name: normalizedText(place.name) || normalizedText(place.ownerName) || '展厅出入口', floorId, floorLabel: floorLabelForSgs(floorId, floors, place.floorCode, place.floorName), positionGltf: normalizePlacePosition(place, fallbackY), sourceObjectName: routeNodeId || undefined, sourcePlaceId: routeNodeId ? placeId || undefined : undefined, routeNodeId } } const createHallPoiId = ( space: SgsSpacePayload ) => { const spaceId = stringifyId(space.id) if (spaceId) return `hall-${spaceId}` const ownerName = normalizedHallName(space.name) const floorIdentity = normalizedHallName(stringifyId(space.floorId) || 'unknown-floor') if (ownerName) return `hall-space-${floorIdentity}-${ownerName}` return `hall-space-${floorIdentity}-unknown` } const createSpaceFallbackHallPoi = ( space: SgsSpacePayload, floors: SgsSdkFloorSummaryPayload[], fallbackFloorId: string, fallbackY?: number ): MuseumPoi | null => { const floorId = stringifyId(space.floorId) || fallbackFloorId const spacePosition = normalizeSpacePosition(space, fallbackY) const position = spacePosition?.position const hallId = stringifyId(space.id) if (!hallId || !normalizedText(space.name) || !position) return null return { id: `hall-${hallId}`, name: normalizedText(space.name), floorId, floorLabel: floorLabelForSgs(floorId, floors), primaryCategory: hallCategory, categories: [ hallCategory ], positionGltf: position, sourceObjectName: space.sourceNodeName || undefined, sourceConfidence: spacePosition.sourceConfidence, navigationReadiness: '位置预览', accessible: false, kind: 'hall', hallId, hallName: normalizedText(space.name), spaceId: hallId, sourceSpaceId: hallId, entrances: [] } } export const toMuseumHallPoisFromSgs = ( spaces: SgsSpacePayload[], navigablePlaces: SgsNavigablePlacePayload[], floors: SgsSdkFloorSummaryPayload[], fallbackFloorId: string, options: SgsHallPoiBuildOptions = {} ): MuseumPoi[] => { const hallSpaces = spaces.filter(isExhibitionHallSpace) const hallPlaces = navigablePlaces.filter(isExhibitionHallNavigablePlace) const halls = new Map() hallPlaces.forEach((place) => { const matchedSpace = findMatchedHallSpace(place, hallSpaces) if (!matchedSpace) return const entrance = createHallEntrance(place, floors, fallbackFloorId, options.fallbackY) const spacePosition = matchedSpace ? normalizeSpacePosition(matchedSpace, options.fallbackY) : undefined const spaceCenter = spacePosition?.position const entrancePosition = entrance.positionGltf const fallbackFloorIdForPoi = stringifyId(matchedSpace?.floorId) || entrance.floorId || fallbackFloorId const fallbackFloorLabel = floorLabelForSgs( fallbackFloorIdForPoi, floors ) const hallId = stringifyId(matchedSpace?.id) || undefined const poiId = createHallPoiId(matchedSpace) const hallName = normalizedText(matchedSpace?.name) || placeHallName(place) || entrance.name const existing = halls.get(poiId) if (existing) { existing.entrances = [ ...(existing.entrances || []), entrance ] if (spaceCenter) { existing.positionGltf = spaceCenter existing.floorId = fallbackFloorIdForPoi existing.floorLabel = fallbackFloorLabel existing.sourceObjectName = matchedSpace?.sourceNodeName || undefined existing.sourceConfidence = spacePosition.sourceConfidence return } if (!existing.positionGltf && entrancePosition) { existing.positionGltf = entrancePosition existing.floorId = entrance.floorId existing.floorLabel = entrance.floorLabel existing.sourceObjectName = entrance.sourceObjectName existing.sourcePlaceId = entrance.sourcePlaceId existing.sourceConfidence = sgsHallEntranceConfidence } return } const usesSpaceCenter = Boolean(spaceCenter) const positionGltf = spaceCenter || entrancePosition if (!positionGltf) return halls.set(poiId, { id: poiId, name: hallName, floorId: usesSpaceCenter ? fallbackFloorIdForPoi : entrance.floorId, floorLabel: usesSpaceCenter ? fallbackFloorLabel : entrance.floorLabel, primaryCategory: hallCategory, categories: [ hallCategory, hallEntranceCategory ], positionGltf, sourceObjectName: usesSpaceCenter ? matchedSpace?.sourceNodeName || undefined : entrance.sourceObjectName, sourceConfidence: usesSpaceCenter ? spacePosition?.sourceConfidence || sgsSpaceCenterConfidence : sgsHallEntranceConfidence, navigationReadiness: '位置预览', accessible: false, kind: 'hall', hallId, hallName, spaceId: hallId, sourcePlaceId: usesSpaceCenter ? undefined : entrance.sourcePlaceId, sourceSpaceId: hallId, entrances: [entrance] }) }) hallSpaces.forEach((space) => { const hallId = stringifyId(space.id) if (!hallId || halls.has(`hall-${hallId}`)) return const fallbackPoi = createSpaceFallbackHallPoi(space, floors, fallbackFloorId, options.fallbackY) if (fallbackPoi) { halls.set(fallbackPoi.id, fallbackPoi) } }) return Array.from(halls.values()) .filter((poi) => poi.id && poi.name && poi.positionGltf) } export const createSgsHallPoiDiagnostics = ( spaces: SgsSpacePayload[], navigablePlaces: SgsNavigablePlacePayload[], hallPois: MuseumPoi[] ): SgsHallPoiDiagnostics => { const eligibleHallSpaces = spaces.filter(isExhibitionHallSpace) const eligibleSpaceCount = eligibleHallSpaces.length const hallPlaceCount = navigablePlaces.filter(isExhibitionHallNavigablePlace).length const hallPoiIds = new Set(hallPois.map((poi) => poi.id)) const hallPoiWithPositionCount = hallPois.filter((poi) => Boolean(poi.positionGltf)).length return { spaceCount: spaces.length, eligibleSpaceCount, navigablePlaceCount: navigablePlaces.length, hallPlaceCount, hallPoiCount: hallPois.length, hallPoiWithPositionCount, skippedSpaceCount: eligibleHallSpaces.filter((space) => !hallPoiIds.has(`hall-${stringifyId(space.id)}`)).length, skippedPlaceCount: Math.max(hallPlaceCount - hallPois.reduce((count, poi) => count + (poi.entrances?.length || 0), 0), 0) } } 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) const indoorFloors = floors.filter(isIndoorNavigableFloor) return { mapId: stringifyId(diagnostics.mapId || manifest?.mapId), mapName: diagnostics.mapName || manifest?.mapName || 'SGS 地图', sdkVersion: diagnostics.sdkVersion || manifest?.sdkVersion || undefined, status: normalizeStatus(diagnostics.status), floors: indoorFloors, summary: { floorCount: indoorFloors.length, modelReadyFloorCount: indoorFloors.filter((floor) => floor.modelReady === true).length || 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 || []).filter(isIndoorNavigableFloor) 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 ) => { 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() 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 } }