refactor: 重构数据层支持 SGS SDK 集成

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
lyf
2026-06-26 04:21:12 +08:00
parent a7413ee037
commit 15fbbec12d
16 changed files with 762 additions and 54 deletions

View File

@@ -44,6 +44,7 @@ const copyStaticSubtree = (subtree) => {
copyStaticSubtree('nav-assets')
copyStaticSubtree('guide-data')
copyStaticSubtree('sgs-map-sdk')
copyStaticSubtree('three')
if (fs.existsSync(faviconSource)) {
fs.copyFileSync(faviconSource, faviconTarget)

View File

@@ -8,6 +8,7 @@
:scale="mapScale"
:markers="markers"
:polygons="polygons"
:polylines="displayPolylines"
:show-location="!isH5"
:enable-3D="false"
:enable-overlooking="false"
@@ -59,6 +60,7 @@
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { getMuseumEntranceLocation } from '@/services/tencent/TencentMapService'
const isH5 = typeof window !== 'undefined'
@@ -94,6 +96,13 @@ interface Polygon {
zIndex: number
}
interface Polyline {
points: Array<{ latitude: number; longitude: number }>
color: string
width: number
dottedLine?: boolean
}
interface MapFloor {
id: string
label: string
@@ -102,15 +111,18 @@ interface MapFloor {
const props = withDefaults(defineProps<{
activeFloor?: string
floors?: MapFloor[]
polylines?: Polyline[]
}>(), {
activeFloor: '1F',
floors: () => [] as MapFloor[]
floors: () => [] as MapFloor[],
polylines: () => [] as Polyline[]
})
const emit = defineEmits<{
markerClick: [markerId: number]
floorChange: [floor: string]
enter3DMode: []
mapTap: [location: { latitude: number; longitude: number }]
}>()
// 深圳自然博物馆实际坐标
@@ -157,9 +169,8 @@ const polygons = ref<Polygon[]>([
}
])
// 地图标记点(博物馆入口和主要位置
const markers = ref<Marker[]>([
{
// 博物馆标记点(初始占位API 获取后更新
const museumMarker = ref<Marker>({
id: 0,
latitude: 22.692763,
longitude: 114.363487,
@@ -176,8 +187,10 @@ const markers = ref<Marker[]>([
color: '#000000',
fontSize: 14
}
},
{
})
// 主入口标记点
const entranceMarker = ref<Marker>({
id: 1,
latitude: 22.692363,
longitude: 114.363487,
@@ -194,8 +207,81 @@ const markers = ref<Marker[]>([
color: '#FFFFFF',
fontSize: 12
}
},
])
})
// 地图标记点列表
const markers = computed(() => [museumMarker.value, entranceMarker.value])
// 显示的路线列表(转换格式以适配 uni-app map 组件)
const displayPolylines = computed(() => {
return props.polylines.map((polyline, index) => ({
...polyline,
id: index,
// 确保点格式正确
points: polyline.points.map((p) => ({
latitude: p.latitude,
longitude: p.longitude
}))
}))
})
/**
* 更新用户位置标记
*/
const updateUserLocation = (location: { latitude: number; longitude: number }) => {
const userMarkerIndex = markers.value.findIndex((m) => m.id === 999)
const userMarker: Marker = {
id: 999,
latitude: location.latitude,
longitude: location.longitude,
iconPath: '/static/icons/marker-user-location.svg',
width: 40,
height: 40,
title: '我的位置',
callout: {
content: '您在这里',
display: 'BYCLICK',
padding: 10,
borderRadius: 5,
bgColor: '#000000',
color: '#FFFFFF',
fontSize: 12
}
}
if (userMarkerIndex >= 0) {
markers.value[userMarkerIndex] = userMarker
} else {
markers.value.push(userMarker)
}
}
/**
* 清除用户位置标记
*/
const clearUserLocation = () => {
const userMarkerIndex = markers.value.findIndex((m) => m.id === 999)
if (userMarkerIndex >= 0) {
markers.value.splice(userMarkerIndex, 1)
}
}
/**
* 移动地图中心到指定位置
*/
const moveTo = (location: { latitude: number; longitude: number }) => {
mapCenter.value = {
latitude: location.latitude,
longitude: location.longitude
}
}
// 暴露方法给父组件
defineExpose({
updateUserLocation,
clearUserLocation,
moveTo
})
// 处理标记点点击
const handleMarkerTap = (e: any) => {
@@ -214,6 +300,10 @@ const handleRegionChange = (e: any) => {
// 处理地图点击
const handleMapTap = (e: any) => {
console.log('点击地图:', e.detail)
const { latitude, longitude } = e.detail || {}
if (latitude !== undefined && longitude !== undefined) {
emit('mapTap', { latitude, longitude })
}
}
// 处理楼层点击
@@ -294,8 +384,20 @@ const handleEnter3D = () => {
emit('enter3DMode')
}
onMounted(() => {
console.log('地图组件已挂载')
onMounted(async () => {
console.log('地图组件已挂载,开始获取入口位置...')
// 从腾讯地图 API 获取入口位置
const entranceLocation = await getMuseumEntranceLocation()
if (entranceLocation) {
entranceMarker.value = {
...entranceMarker.value,
latitude: entranceLocation.lat,
longitude: entranceLocation.lng
}
console.log('已更新入口位置:', entranceLocation)
}
})
</script>

View File

@@ -17,7 +17,7 @@ interface Position {
y: number
}
const props = withDefaults(defineProps<{
withDefaults(defineProps<{
position: Position
label?: string
type?: 'exhibit' | 'hall' | 'facility' | 'location'

View File

@@ -30,7 +30,7 @@
<script setup lang="ts">
import { ref } from 'vue'
const props = withDefaults(defineProps<{
withDefaults(defineProps<{
modelValue?: string
placeholder?: string
}>(), {

View File

@@ -6,6 +6,8 @@ const allowedGuideContentModes = new Set<GuideContentDataSourceMode>(['static',
const defaultSdkScriptUrl = '/static/sgs-map-sdk/index.global.js'
const defaultSgsEngineUrl = '/h5-sdk'
const defaultSdkTimeoutMs = 5000
const defaultApiBaseUrl = '/app-api'
const defaultSgsMapId = '1'
const defaultGuideStaticDataBaseUrl = '/static/guide-data'
const defaultAudioApiBaseUrl = '/yudao-server'
@@ -52,12 +54,17 @@ export const dataSourceConfig = {
import.meta.env.VITE_GUIDE_STATIC_DATA_BASE_URL,
defaultGuideStaticDataBaseUrl
),
apiBaseUrl: import.meta.env.VITE_API_BASE_URL?.trim() || '',
audioApiBaseUrl: normalizeUrl(
import.meta.env.VITE_AUDIO_API_BASE_URL || import.meta.env.VITE_API_BASE_URL,
defaultAudioApiBaseUrl
),
apiBaseUrl: normalizeUrl(import.meta.env.VITE_API_BASE_URL, defaultApiBaseUrl),
audioLanguage: import.meta.env.VITE_AUDIO_LANGUAGE === 'en-US' ? 'en-US' : 'zh-CN',
sgsApiBaseUrl: normalizeUrl(
import.meta.env.VITE_SGS_API_BASE_URL || import.meta.env.VITE_API_BASE_URL,
defaultApiBaseUrl
),
sgsMapId: normalizeUrl(import.meta.env.VITE_SGS_MAP_ID, defaultSgsMapId),
sgsSdkScriptUrl: normalizeUrl(import.meta.env.VITE_SGS_SDK_SCRIPT_URL, defaultSdkScriptUrl),
sgsMapEngineUrl: normalizeUrl(import.meta.env.VITE_SGS_H5_ENGINE_URL, defaultSgsEngineUrl),
sgsSdkTargetOrigin: import.meta.env.VITE_SGS_SDK_ORIGIN?.trim() || '',

View File

@@ -4,6 +4,68 @@ export interface MuseumFloor {
order: number
}
export type GuideDiagnosticsStatus = 'OK' | 'WARN' | 'ERROR'
export type GuideDataIssueSeverity = 'info' | 'warn' | 'error'
export interface GuideFloorDetail extends MuseumFloor {
sourceCode?: string
sourceName?: string
modelReady?: boolean
poiCount?: number
spaceCount?: number
guideStopCount?: number
navigablePlaceCount?: number
routeNodeCount?: number
routeEdgeCount?: number
routePlanningReady?: boolean
warnings: string[]
}
export interface GuideMapDiagnosticsSummary {
floorCount: number
modelReadyFloorCount: number
poiCount: number
spaceCount: number
guideStopCount: number
navigablePlaceCount: number
routeNodeCount: number
routeEdgeCount: number
}
export interface GuideMapDiagnostics {
mapId: string
mapName: string
sdkVersion?: string
status: GuideDiagnosticsStatus
floors: GuideFloorDetail[]
summary: GuideMapDiagnosticsSummary
warnings: string[]
}
export interface GuideDataIntegrityIssue {
id: string
scope: 'floor' | 'poi'
severity: GuideDataIssueSeverity
message: string
floorId?: string
floorLabel?: string
poiId?: string
fields?: string[]
}
export interface GuideDataIntegrityReport {
status: GuideDiagnosticsStatus
summary: {
floorCount: number
poiCount: number
issueCount: number
errorCount: number
warningCount: number
}
issues: GuideDataIntegrityIssue[]
warnings: string[]
}
export interface MuseumCategory {
id: string
label: string

3
src/env.d.ts vendored
View File

@@ -7,10 +7,13 @@ interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
readonly VITE_AUDIO_API_BASE_URL?: string
readonly VITE_AUDIO_LANGUAGE?: 'zh-CN' | 'en-US'
readonly VITE_SGS_API_BASE_URL?: string
readonly VITE_SGS_MAP_ID?: string
readonly VITE_SGS_SDK_SCRIPT_URL?: string
readonly VITE_SGS_H5_ENGINE_URL?: string
readonly VITE_SGS_SDK_ORIGIN?: string
readonly VITE_SGS_SDK_TIMEOUT_MS?: string
readonly VITE_TENCENT_MAP_KEY?: string
}
interface ImportMeta {

View File

@@ -14,6 +14,8 @@
:floors="guideFloors"
:normalize-floor-id="normalizeGuideFloorId"
:target-focus-request="targetFocusRequest"
:route-preview="activeRoutePreview"
:show-route="Boolean(activeRoutePreview)"
@search-tap="handleSearchTap"
@mode-change="handleModeChange"
@floor-change="handleFloorChange"
@@ -108,6 +110,34 @@
</view>
</view>
</view>
<view
v-else-if="routeViewState === 'preview'"
class="indoor-guide-card"
>
<text class="indoor-guide-title">{{ indoorGuideTitle }}</text>
<text class="indoor-guide-desc">{{ indoorGuideDesc }}</text>
<view v-if="routePlanError" class="indoor-guide-error">
<text class="indoor-guide-error-text">{{ routePlanError }}</text>
</view>
<view class="indoor-guide-actions">
<view
class="indoor-guide-btn secondary"
@tap="handleReturnToPreview"
>
<text class="indoor-guide-btn-text">查看位置</text>
</view>
<view
class="indoor-guide-btn primary"
:class="{ disabled: !routeReady || routePlanning }"
@tap="handleStartIndoorGuide"
>
<text class="indoor-guide-btn-text">
{{ routePlanning ? '规划中' : routeReady ? '开始导览' : '导航不可用' }}
</text>
</view>
</view>
</view>
</GuideMapShell>
</GuidePageFrame>
</template>
@@ -127,9 +157,13 @@ import {
import type {
GuideLocationPreview,
GuideLocationPreviewPlan,
GuideRouteResult,
GuideStartLocation,
MuseumFloor
} from '@/domain/museum'
import {
guideRouteUseCase
} from '@/usecases/guideRouteUseCase'
import {
navigateToGuideTopTab,
saveLastGuideLocationPreviewUrl,
@@ -166,6 +200,11 @@ const routeViewState = ref<RouteViewState>('preview')
const activeFloor = ref('1F')
const targetFocusRequest = ref<TargetPoiFocusRequest | null>(null)
const targetFocusRequestId = ref(0)
const routeReady = ref(false)
const routeReadinessMessage = ref('')
const routePlanning = ref(false)
const routePlanError = ref('')
const activeRoutePreview = ref<GuideRouteResult | null>(null)
const manualFloors = computed(() => guideFloors.value.map((floor) => floor.label))
const guideFloorLabel = (floorId: string) => (
guideFloors.value.find((floor) => floor.id === floorId)?.label || floorId
@@ -263,7 +302,7 @@ const shellConfig = computed(() => {
}
return {
searchText: `位置预览:${route.value.target}`,
searchText: routeReady.value ? `室内导览:${route.value.target}` : `位置预览:${route.value.target}`,
searchTop: '60px',
activeMode: '3d' as GuideMode,
activeFloor: activeFloor.value,
@@ -281,6 +320,20 @@ const shellConfig = computed(() => {
}
})
const indoorGuideTitle = computed(() => (
routeReady.value ? `室内导览:${route.value.target}` : `位置预览:${route.value.target}`
))
const indoorGuideDesc = computed(() => {
if (activeRoutePreview.value) {
return `已规划 ${activeRoutePreview.value.start.name}${activeRoutePreview.value.end.name},约 ${Math.round(activeRoutePreview.value.distanceMeters)} 米。`
}
return routeReady.value
? 'SGS 路线数据已就绪,可从已选起点开始规划馆内路线。'
: routeReadinessMessage.value || '当前暂不支持正式导航,可查看位置预览。'
})
const safeDecode = (value: string) => {
try {
return decodeURIComponent(value)
@@ -328,6 +381,19 @@ const saveCurrentLocationPreviewUrl = () => {
saveLastGuideLocationPreviewUrl(`/pages/route/detail?${params.toString()}`)
}
const refreshRouteReadinessState = async () => {
try {
const readiness = await guideRouteUseCase.getRouteReadiness()
routeReady.value = readiness.ready
routeReadinessMessage.value = readiness.message
routePlanError.value = readiness.ready ? '' : readiness.message
} catch (error) {
routeReady.value = false
routeReadinessMessage.value = error instanceof Error ? error.message : '路线能力状态读取失败'
routePlanError.value = routeReadinessMessage.value
}
}
const createStartLocationFromOptions = (options: Record<string, unknown>) => {
const label = normalizeStringOption(options.startLabel)
@@ -373,6 +439,7 @@ const requestTargetFocus = (target: Partial<RouteTargetLocation> | null, fallbac
const applyRouteOptions = async (options: Record<string, unknown> = {}) => {
await loadGuideFloors()
await refreshRouteReadinessState()
const facilityId = normalizeStringOption(options.facilityId)
const target = normalizeStringOption(options.target)
@@ -385,6 +452,7 @@ const applyRouteOptions = async (options: Record<string, unknown> = {}) => {
routeViewState.value = 'preview'
activeFloor.value = defaultGuideFloorLabel()
targetFocusRequest.value = null
activeRoutePreview.value = null
isManualLocationPanelOpen.value = false
return false
}
@@ -397,6 +465,7 @@ const applyRouteOptions = async (options: Record<string, unknown> = {}) => {
routeViewState.value = 'preview'
activeFloor.value = startLocation?.floor || defaultGuideFloorLabel()
targetFocusRequest.value = null
activeRoutePreview.value = null
isManualLocationPanelOpen.value = false
return false
}
@@ -485,6 +554,7 @@ const handleTargetFocus = (result: TargetPoiFocusResult) => {
const handleReturnToPreview = () => {
isManualLocationPanelOpen.value = false
activeRoutePreview.value = null
routeViewState.value = 'preview'
requestTargetFocus(route.value.targetLocation)
saveCurrentLocationPreviewUrl()
@@ -527,11 +597,67 @@ const handleConfirmLocation = async () => {
)
activeFloor.value = startLocation.floor || activeFloor.value
isManualLocationPanelOpen.value = false
activeRoutePreview.value = null
routeViewState.value = 'preview'
requestTargetFocus(route.value.targetLocation)
saveCurrentLocationPreviewUrl()
}
const handleStartIndoorGuide = async () => {
if (routePlanning.value || !route.value.facilityId) return
await refreshRouteReadinessState()
if (!routeReady.value) return
routePlanning.value = true
routePlanError.value = ''
try {
const targets = await guideRouteUseCase.listTargets()
const startName = route.value.startLocation?.label || ''
const startFloor = route.value.startLocation?.floor || ''
const targetLocation = route.value.targetLocation
const fallbackStart = targets.find((target) => (
startName
? target.name.includes(startName) || startName.includes(target.name)
: false
)) || targets.find((target) => (
startFloor
? target.floorLabel === startFloor || target.floorId === normalizeGuideFloorId(startFloor)
: false
)) || targets.find((target) => target.poiId !== route.value.facilityId)
const endTarget = targets.find((target) => target.poiId === route.value.facilityId)
|| (targetLocation
? targets.find((target) => target.floorId === targetLocation.floorId && target.name === targetLocation.name)
: null)
if (!fallbackStart || !endTarget) {
routePlanError.value = '未找到可用于路线规划的起点或终点'
return
}
const result = await guideRouteUseCase.planRoute({
startPoiId: fallbackStart.poiId,
endPoiId: endTarget.poiId
})
if (!result.route) {
routePlanError.value = result.error || '室内导览路线规划失败'
activeRoutePreview.value = null
return
}
activeRoutePreview.value = result.route
activeFloor.value = result.route.start.floorLabel
} catch (error) {
routePlanError.value = error instanceof Error ? error.message : '室内导览路线规划失败'
activeRoutePreview.value = null
} finally {
routePlanning.value = false
}
}
const handleModeChange = (mode: GuideMode) => {
if (routeViewState.value === 'outdoor-preview' && mode === '3d') {
routeViewState.value = 'preview'
@@ -606,6 +732,94 @@ const handlePageBack = () => {
z-index: 45;
}
.indoor-guide-card {
position: absolute;
left: 16px;
right: 16px;
bottom: calc(env(safe-area-inset-bottom) + 34px);
min-height: 156px;
padding: 18px 18px 16px;
display: flex;
flex-direction: column;
gap: 10px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.96);
border: 1px solid #e5e6de;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(36, 49, 42, 0.14);
z-index: 45;
}
.indoor-guide-title {
font-size: 17px;
line-height: 24px;
font-weight: 700;
color: #151713;
}
.indoor-guide-desc {
font-size: 13px;
line-height: 19px;
color: #545861;
}
.indoor-guide-error {
padding: 7px 9px;
background: #fff4f2;
border: 1px solid #ffd2c9;
border-radius: 8px;
}
.indoor-guide-error-text {
font-size: 12px;
line-height: 17px;
color: #b44b42;
}
.indoor-guide-actions {
display: flex;
gap: 10px;
margin-top: auto;
}
.indoor-guide-btn {
flex: 1;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 8px;
}
.indoor-guide-btn.secondary {
background: #f5f7f2;
border: 1px solid #e4e5df;
}
.indoor-guide-btn.primary {
background: #151713;
}
.indoor-guide-btn.primary.disabled {
background: #d8dbd2;
}
.indoor-guide-btn-text {
font-size: 14px;
line-height: 19px;
font-weight: 500;
color: #151713;
}
.indoor-guide-btn.primary .indoor-guide-btn-text {
color: var(--museum-accent);
}
.indoor-guide-btn.primary.disabled .indoor-guide-btn-text {
color: #8b8b84;
}
.target-required-title {
font-size: 18px;
line-height: 24px;

View File

@@ -4,6 +4,18 @@ import type {
GuideModelSource,
GuideRenderPoi
} from '@/domain/guideModel'
import {
dataSourceConfig
} from '@/config/dataSource'
import {
formatSgsFloorLabel,
toMuseumPoiFromSgs
} from '@/data/adapters/sgsSdkGuideAdapter'
import {
defaultSgsSdkApiProvider,
type SgsSdkApiProvider,
type SgsSdkFloorSummaryPayload
} from '@/data/providers/sgsSdkApiProvider'
import {
formatNavFloorLabel
} from '@/data/adapters/navAssetsAdapter'
@@ -24,6 +36,38 @@ const toRenderPoi = (poi: StaticNavPoiPayload): GuideRenderPoi => ({
sourceObjectName: poi.sourceObjectName
})
const toSgsRenderPoi = (
poi: ReturnType<typeof toMuseumPoiFromSgs>
): GuideRenderPoi => ({
id: poi.id,
name: poi.name,
floorId: poi.floorId,
primaryCategory: poi.primaryCategory.id,
primaryCategoryZh: poi.primaryCategory.label,
iconType: poi.primaryCategory.iconType || poi.primaryCategory.id,
positionGltf: poi.positionGltf,
sourceObjectName: poi.sourceObjectName
})
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, '')
const resolveSgsAssetUrl = (url?: string | null) => {
const normalizedUrl = url?.trim()
if (!normalizedUrl) return ''
if (/^https?:\/\//i.test(normalizedUrl)) return normalizedUrl
if (!normalizedUrl.startsWith('/')) return normalizedUrl
const baseUrl = trimTrailingSlash(dataSourceConfig.sgsApiBaseUrl || dataSourceConfig.apiBaseUrl || '')
const origin = baseUrl.endsWith('/app-api')
? baseUrl.slice(0, -'/app-api'.length)
: baseUrl
return origin ? `${origin}${normalizedUrl}` : normalizedUrl
}
const sortSgsFloors = (floors: SgsSdkFloorSummaryPayload[]) => [...floors]
.sort((a, b) => Number(a.sortOrder || 0) - Number(b.sortOrder || 0))
export interface GuideModelRepository extends GuideModelSource {}
export class StaticGuideModelRepository implements GuideModelRepository {
@@ -63,4 +107,67 @@ export class StaticGuideModelRepository implements GuideModelRepository {
}
}
export const guideModelRepository = new StaticGuideModelRepository()
export class SgsSdkGuideModelRepository implements GuideModelRepository {
private floorsCache: SgsSdkFloorSummaryPayload[] | null = null
constructor(private readonly provider: SgsSdkApiProvider = defaultSgsSdkApiProvider) {}
async loadPackage(): Promise<GuideModelRenderPackage> {
const manifest = await this.provider.getManifest()
const floors = sortSgsFloors(manifest.floors)
this.floorsCache = floors
const bundles = await Promise.all(
floors.map((floor) => this.provider.getFloorBundle(String(floor.floorId)))
)
const floorAssets: GuideModelFloorAsset[] = floors
.map((floor, index) => {
const bundle = bundles[index]
const modelUrl = resolveSgsAssetUrl(bundle.model?.modelUrl || bundle.model?.fallbackModelUrl)
return {
floorId: String(floor.floorId),
label: formatSgsFloorLabel(floor.floorCode, floor.floorName),
order: Number(floor.sortOrder || 0),
modelUrl,
sharedModelAsset: false
}
})
.filter((asset) => asset.modelUrl)
const overviewFloorIndex = floors.findIndex((floor) => floor.floorCode === 'EXTERIOR')
const overviewModelUrl = floorAssets[overviewFloorIndex]?.modelUrl || floorAssets[0]?.modelUrl || ''
return {
overviewModelUrl,
floors: floorAssets
}
}
async loadFloorPois(floorId: string) {
const manifest = await this.provider.getManifest()
this.floorsCache = sortSgsFloors(manifest.floors)
const matchedFloor = this.floorsCache.find((floor) => (
String(floor.floorId) === floorId
|| floor.floorCode === floorId
|| formatSgsFloorLabel(floor.floorCode, floor.floorName) === floorId
))
if (!matchedFloor) return []
const pois = await this.provider.getFloorPois(String(matchedFloor.floorId))
return pois
.map((poi) => toSgsRenderPoi(toMuseumPoiFromSgs(poi, manifest.floors)))
.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
}
}
export const createGuideModelRepository = (): GuideModelRepository => {
if (dataSourceConfig.mode === 'api' || dataSourceConfig.mode === 'sdk') {
return new SgsSdkGuideModelRepository()
}
return new StaticGuideModelRepository()
}
export const guideModelRepository = createGuideModelRepository()

View File

@@ -1,5 +1,8 @@
import type {
GuideDataIntegrityReport,
GuideFloorDetail,
GuideLocationPreview,
GuideMapDiagnostics,
GuideRouteReadiness,
MuseumFloor,
MuseumPoi
@@ -16,6 +19,20 @@ import {
defaultStaticNavAssetsProvider,
type StaticNavAssetsProvider
} from '@/data/providers/staticNavAssetsProvider'
import {
defaultSgsSdkApiProvider,
type SgsSdkApiProvider
} from '@/data/providers/sgsSdkApiProvider'
import {
buildSgsFloorAliases,
createGuideDataIntegrityReport,
toGuideFloorDetailFromSgs,
toGuideMapDiagnostics,
toLocationPreviewFromPoi,
toMuseumFloorFromSgs,
toMuseumPoiFromSgs,
toRouteReadinessFromSgsDiagnostics
} from '@/data/adapters/sgsSdkGuideAdapter'
const searchableCategories = new Set([
'touring_poi',
@@ -60,6 +77,9 @@ export interface GuideRepository {
searchPois(keyword?: string): Promise<MuseumPoi[]>
getLocationPreview(poiId: string): Promise<GuideLocationPreview | null>
getRouteReadiness(): Promise<GuideRouteReadiness>
getFloorDetail?(floorId: string): Promise<GuideFloorDetail | null>
getMapDiagnostics?(): Promise<GuideMapDiagnostics>
checkDataIntegrity?(): Promise<GuideDataIntegrityReport>
}
export class StaticGuideRepository implements GuideRepository {
@@ -120,6 +140,161 @@ export class StaticGuideRepository implements GuideRepository {
return this.provider.loadRouteReadiness()
.catch(() => NAV_ROUTE_READINESS)
}
async getFloorDetail(floorId: string) {
const floors = await this.getFloors()
const normalizedFloorId = this.normalizeFloorId(floorId)
const floor = floors.find((item) => item.id === normalizedFloorId)
return floor
? {
...floor,
warnings: []
}
: null
}
async getMapDiagnostics(): Promise<GuideMapDiagnostics> {
const [floors, pois] = await Promise.all([
this.getFloors(),
this.listPois()
])
return {
mapId: 'static',
mapName: '本地静态导览数据',
status: 'WARN',
floors: floors.map((floor) => ({
...floor,
poiCount: pois.filter((poi) => poi.floorId === floor.id).length,
warnings: []
})),
summary: {
floorCount: floors.length,
modelReadyFloorCount: floors.length,
poiCount: pois.length,
spaceCount: 0,
guideStopCount: 0,
navigablePlaceCount: 0,
routeNodeCount: 0,
routeEdgeCount: 0
},
warnings: ['static 模式仅提供本地导览资源基础检查']
}
}
async checkDataIntegrity() {
const [floors, pois, diagnostics] = await Promise.all([
this.getFloors(),
this.listPois(),
this.getMapDiagnostics()
])
return createGuideDataIntegrityReport(diagnostics, floors, pois)
}
}
export const guideRepository = new StaticGuideRepository()
export class SgsSdkGuideRepository implements GuideRepository {
private floorsCache: MuseumFloor[] | null = null
private poiCache: MuseumPoi[] | null = null
private floorAliases: Map<string, string> | null = null
constructor(private readonly provider: SgsSdkApiProvider = defaultSgsSdkApiProvider) {}
getAssetBaseUrl() {
return ''
}
async getFloors() {
if (this.floorsCache) return this.floorsCache
const manifest = await this.provider.getManifest()
this.floorAliases = buildSgsFloorAliases(manifest.floors)
this.floorsCache = [...manifest.floors]
.sort((a, b) => Number(a.sortOrder || 0) - Number(b.sortOrder || 0))
.map(toMuseumFloorFromSgs)
return this.floorsCache
}
normalizeFloorId(labelOrId: string) {
const normalized = labelOrId.trim()
const aliases = this.floorAliases
if (!aliases) return normalized
return aliases.get(normalized) || aliases.get(normalized.toLowerCase()) || normalized
}
async listPois() {
if (this.poiCache) return this.poiCache
const manifest = await this.provider.getManifest()
this.floorAliases = buildSgsFloorAliases(manifest.floors)
const poiGroups = await Promise.all(
manifest.floors.map((floor) => this.provider.getFloorPois(String(floor.floorId)))
)
this.poiCache = poiGroups
.flat()
.map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
.filter((poi) => poi.id && poi.name)
return this.poiCache
}
async getPoiById(id: string) {
const pois = await this.listPois()
return pois.find((poi) => poi.id === id) || null
}
async searchPois(keyword = '') {
const pois = await this.listPois()
const normalizedKeyword = keyword.trim().toLowerCase()
if (!normalizedKeyword) return pois
return pois.filter((poi) => toSearchText(poi).includes(normalizedKeyword))
}
async getLocationPreview(poiId: string) {
const poi = await this.getPoiById(poiId)
return poi ? toLocationPreviewFromPoi(poi) : null
}
async getRouteReadiness() {
return this.provider.getMapDiagnostics()
.then(toRouteReadinessFromSgsDiagnostics)
.catch(() => NAV_ROUTE_READINESS)
}
async getFloorDetail(floorId: string) {
await this.getFloors()
const normalizedFloorId = this.normalizeFloorId(floorId)
try {
const diagnostics = await this.provider.getFloorDiagnostics(normalizedFloorId)
return toGuideFloorDetailFromSgs(diagnostics)
} catch {
const manifest = await this.provider.getManifest()
const floor = manifest.floors.find((item) => String(item.floorId) === normalizedFloorId)
return floor ? toGuideFloorDetailFromSgs(floor) : null
}
}
async getMapDiagnostics() {
const [manifest, diagnostics] = await Promise.all([
this.provider.getManifest(),
this.provider.getMapDiagnostics()
])
return toGuideMapDiagnostics(diagnostics, manifest)
}
async checkDataIntegrity() {
const [floors, pois, diagnostics] = await Promise.all([
this.getFloors(),
this.listPois(),
this.getMapDiagnostics()
])
return createGuideDataIntegrityReport(diagnostics, floors, pois)
}
}

View File

@@ -5,9 +5,11 @@ import type {
SearchIndexItem
} from '@/domain/museum'
import {
guideRepository,
type GuideRepository
} from '@/repositories/GuideRepository'
import {
guideRepository
} from '@/repositories/createGuideRepository'
import {
staticMuseumContentProvider,
type MuseumContentProvider

View File

@@ -20,6 +20,8 @@ export interface SgsTargetFocusResult {
message?: string
}
export type SgsFloorId = string | number
export const toNumericFloorId = (floorId: string | number | undefined) => {
if (typeof floorId === 'number') return floorId
if (!floorId) return 1
@@ -29,6 +31,17 @@ export const toNumericFloorId = (floorId: string | number | undefined) => {
return Number.isFinite(numeric) && numeric > 0 ? numeric : 1
}
export const toSgsFloorId = (floorId: string | number | undefined): SgsFloorId => {
if (typeof floorId === 'number') return floorId
if (!floorId) return 1
const normalized = floorId.toString().trim()
if (/^\d{15,}$/.test(normalized)) return normalized
if (/^\d+$/.test(normalized)) return Number(normalized)
return toNumericFloorId(normalized)
}
export const toAppFloorId = (floorId: string | number | undefined) => `L${toNumericFloorId(floorId)}`
export const toFloorLabel = (floorId: string | number | undefined) => `${toNumericFloorId(floorId)}F`

View File

@@ -14,7 +14,7 @@ interface SgsMapServiceOptions {
scriptUrl: string
sdkUrl: string
targetOrigin: string
floorId: number
floorId: string | number
timeout: number
}
@@ -116,7 +116,7 @@ export class SgsMapService {
return this.sdk
}
async changeFloor(floorId: number) {
async changeFloor(floorId: string | number) {
const sdk = await this.getReadySdk()
return sdk.changeFloor(floorId, this.options.timeout)
}

View File

@@ -1,5 +1,5 @@
export type SgsMapNode =
| { type: 'coord'; floorId: number; x: number; z: number; y?: number }
| { type: 'coord'; floorId: string | number; x: number; z: number; y?: number }
| { type: 'poiId'; value: number }
| { type: 'nodeName'; value: string }
@@ -27,14 +27,14 @@ export interface SgsMapSDKOptions {
container: string | HTMLElement
sdkUrl?: string
targetOrigin?: string
floorId?: number
floorId?: string | number
timeout?: number
}
export interface SgsMapEvents {
ready: { engineVersion: string; protocolVersion: number }
poiClick: { id?: number; poiId?: number; name: string; type: string; floorId?: number; description?: string }
floorChanged: { floorId: number }
poiClick: { id?: number; poiId?: number; name: string; type: string; floorId?: string | number; description?: string }
floorChanged: { floorId: string | number }
error: { code: string; message: string }
loadError: { type: string; detail: string }
}
@@ -63,9 +63,9 @@ export interface SgsMapSDKInstance {
on<K extends keyof SgsMapEvents>(event: K, listener: (payload: SgsMapEvents[K]) => void): void
off<K extends keyof SgsMapEvents>(event: K, listener: (payload: SgsMapEvents[K]) => void): void
getIsReady(): boolean
getCurrentFloor(): number
getCurrentFloor(): string | number
focusTo(node: SgsMapNode | string): void
changeFloor(floorId: number, timeout?: number): Promise<{ success: boolean; floorId: number }>
changeFloor(floorId: string | number, timeout?: number): Promise<{ success: boolean; floorId: string | number }>
addMarker(config: SgsMarkerConfig): void
removeMarker(markerId: string): void
clearMarkers(): void

View File

@@ -5,12 +5,11 @@ import type {
} from '@/domain/museum'
import {
GuideRouteError,
guideRouteRepository,
type GuideRouteRepository
} from '@/repositories/GuideRouteRepository'
import {
applyRouteReadinessGate
} from '@/domain/guideReadiness'
createGuideRouteRepository
} from '@/repositories/createGuideRouteRepository'
const toSearchText = (target: GuideRouteTarget) => [
target.name,
@@ -36,12 +35,12 @@ export class GuideRouteUseCase {
private targetsCache: GuideRouteTarget[] | null = null
private readinessCache: GuideRouteReadiness | null = null
constructor(private readonly repository: GuideRouteRepository = guideRouteRepository) {}
constructor(private readonly repository: GuideRouteRepository = createGuideRouteRepository()) {}
async getRouteReadiness() {
if (this.readinessCache) return this.readinessCache
this.readinessCache = applyRouteReadinessGate(await this.repository.getRouteReadiness())
this.readinessCache = await this.repository.getRouteReadiness()
return this.readinessCache
}
@@ -89,7 +88,7 @@ export class GuideRouteUseCase {
return error.message
}
return error instanceof Error ? error.message : '路线预览生成失败'
return error instanceof Error ? error.message : '室内导览位置关系生成失败'
}
}

View File

@@ -1,14 +1,19 @@
import type {
GuideDataIntegrityReport,
GuideFloorDetail,
GuideRouteReadiness,
GuideLocationPreviewPlan,
GuideMapDiagnostics,
GuidePreviewStep,
GuideStartLocation,
MuseumPoi
} from '@/domain/museum'
import {
guideRepository,
type GuideRepository
} from '@/repositories/GuideRepository'
import {
guideRepository
} from '@/repositories/createGuideRepository'
import {
guideModelRepository,
type GuideModelRepository
@@ -114,6 +119,24 @@ export class GuideUseCase {
return this.guide.getPoiById(id)
}
getFloorDetail(floorId: string): Promise<GuideFloorDetail | null> {
return this.guide.getFloorDetail
? this.guide.getFloorDetail(floorId)
: Promise.resolve(null)
}
getMapDiagnostics(): Promise<GuideMapDiagnostics | null> {
return this.guide.getMapDiagnostics
? this.guide.getMapDiagnostics()
: Promise.resolve(null)
}
checkDataIntegrity(): Promise<GuideDataIntegrityReport | null> {
return this.guide.checkDataIntegrity
? this.guide.checkDataIntegrity()
: Promise.resolve(null)
}
async createLocationPreviewPlan(
facilityId: string,
target: string,