refactor: 重构数据层支持 SGS SDK 集成
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -44,6 +44,7 @@ const copyStaticSubtree = (subtree) => {
|
|||||||
copyStaticSubtree('nav-assets')
|
copyStaticSubtree('nav-assets')
|
||||||
copyStaticSubtree('guide-data')
|
copyStaticSubtree('guide-data')
|
||||||
copyStaticSubtree('sgs-map-sdk')
|
copyStaticSubtree('sgs-map-sdk')
|
||||||
|
copyStaticSubtree('three')
|
||||||
|
|
||||||
if (fs.existsSync(faviconSource)) {
|
if (fs.existsSync(faviconSource)) {
|
||||||
fs.copyFileSync(faviconSource, faviconTarget)
|
fs.copyFileSync(faviconSource, faviconTarget)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
:scale="mapScale"
|
:scale="mapScale"
|
||||||
:markers="markers"
|
:markers="markers"
|
||||||
:polygons="polygons"
|
:polygons="polygons"
|
||||||
|
:polylines="displayPolylines"
|
||||||
:show-location="!isH5"
|
:show-location="!isH5"
|
||||||
:enable-3D="false"
|
:enable-3D="false"
|
||||||
:enable-overlooking="false"
|
:enable-overlooking="false"
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted } from 'vue'
|
import { computed, ref, onMounted } from 'vue'
|
||||||
|
import { getMuseumEntranceLocation } from '@/services/tencent/TencentMapService'
|
||||||
|
|
||||||
const isH5 = typeof window !== 'undefined'
|
const isH5 = typeof window !== 'undefined'
|
||||||
|
|
||||||
@@ -94,6 +96,13 @@ interface Polygon {
|
|||||||
zIndex: number
|
zIndex: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Polyline {
|
||||||
|
points: Array<{ latitude: number; longitude: number }>
|
||||||
|
color: string
|
||||||
|
width: number
|
||||||
|
dottedLine?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface MapFloor {
|
interface MapFloor {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
@@ -102,15 +111,18 @@ interface MapFloor {
|
|||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
activeFloor?: string
|
activeFloor?: string
|
||||||
floors?: MapFloor[]
|
floors?: MapFloor[]
|
||||||
|
polylines?: Polyline[]
|
||||||
}>(), {
|
}>(), {
|
||||||
activeFloor: '1F',
|
activeFloor: '1F',
|
||||||
floors: () => [] as MapFloor[]
|
floors: () => [] as MapFloor[],
|
||||||
|
polylines: () => [] as Polyline[]
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
markerClick: [markerId: number]
|
markerClick: [markerId: number]
|
||||||
floorChange: [floor: string]
|
floorChange: [floor: string]
|
||||||
enter3DMode: []
|
enter3DMode: []
|
||||||
|
mapTap: [location: { latitude: number; longitude: number }]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// 深圳自然博物馆实际坐标
|
// 深圳自然博物馆实际坐标
|
||||||
@@ -157,36 +169,77 @@ const polygons = ref<Polygon[]>([
|
|||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
// 地图标记点(博物馆入口和主要位置)
|
// 博物馆标记点(初始占位,API 获取后更新)
|
||||||
const markers = ref<Marker[]>([
|
const museumMarker = ref<Marker>({
|
||||||
{
|
id: 0,
|
||||||
id: 0,
|
latitude: 22.692763,
|
||||||
latitude: 22.692763,
|
longitude: 114.363487,
|
||||||
longitude: 114.363487,
|
iconPath: '/static/icons/marker-museum.svg',
|
||||||
iconPath: '/static/icons/marker-museum.svg',
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
title: '深圳自然博物馆',
|
||||||
|
callout: {
|
||||||
|
content: '深圳自然博物馆',
|
||||||
|
display: 'ALWAYS',
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
bgColor: '#E0E100',
|
||||||
|
color: '#000000',
|
||||||
|
fontSize: 14
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 主入口标记点
|
||||||
|
const entranceMarker = ref<Marker>({
|
||||||
|
id: 1,
|
||||||
|
latitude: 22.692363,
|
||||||
|
longitude: 114.363487,
|
||||||
|
iconPath: '/static/icons/marker-entrance.svg',
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
title: '主入口',
|
||||||
|
callout: {
|
||||||
|
content: '主入口',
|
||||||
|
display: 'BYCLICK',
|
||||||
|
padding: 10,
|
||||||
|
borderRadius: 5,
|
||||||
|
bgColor: '#000000',
|
||||||
|
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,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
title: '深圳自然博物馆',
|
title: '我的位置',
|
||||||
callout: {
|
callout: {
|
||||||
content: '深圳自然博物馆',
|
content: '您在这里',
|
||||||
display: 'ALWAYS',
|
|
||||||
padding: 12,
|
|
||||||
borderRadius: 6,
|
|
||||||
bgColor: '#E0E100',
|
|
||||||
color: '#000000',
|
|
||||||
fontSize: 14
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
latitude: 22.692363,
|
|
||||||
longitude: 114.363487,
|
|
||||||
iconPath: '/static/icons/marker-entrance.svg',
|
|
||||||
width: 30,
|
|
||||||
height: 30,
|
|
||||||
title: '主入口',
|
|
||||||
callout: {
|
|
||||||
content: '主入口',
|
|
||||||
display: 'BYCLICK',
|
display: 'BYCLICK',
|
||||||
padding: 10,
|
padding: 10,
|
||||||
borderRadius: 5,
|
borderRadius: 5,
|
||||||
@@ -194,8 +247,41 @@ const markers = ref<Marker[]>([
|
|||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
fontSize: 12
|
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) => {
|
const handleMarkerTap = (e: any) => {
|
||||||
@@ -214,6 +300,10 @@ const handleRegionChange = (e: any) => {
|
|||||||
// 处理地图点击
|
// 处理地图点击
|
||||||
const handleMapTap = (e: any) => {
|
const handleMapTap = (e: any) => {
|
||||||
console.log('点击地图:', e.detail)
|
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')
|
emit('enter3DMode')
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
console.log('地图组件已挂载')
|
console.log('地图组件已挂载,开始获取入口位置...')
|
||||||
|
|
||||||
|
// 从腾讯地图 API 获取入口位置
|
||||||
|
const entranceLocation = await getMuseumEntranceLocation()
|
||||||
|
|
||||||
|
if (entranceLocation) {
|
||||||
|
entranceMarker.value = {
|
||||||
|
...entranceMarker.value,
|
||||||
|
latitude: entranceLocation.lat,
|
||||||
|
longitude: entranceLocation.lng
|
||||||
|
}
|
||||||
|
console.log('已更新入口位置:', entranceLocation)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ interface Position {
|
|||||||
y: number
|
y: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
withDefaults(defineProps<{
|
||||||
position: Position
|
position: Position
|
||||||
label?: string
|
label?: string
|
||||||
type?: 'exhibit' | 'hall' | 'facility' | 'location'
|
type?: 'exhibit' | 'hall' | 'facility' | 'location'
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
withDefaults(defineProps<{
|
||||||
modelValue?: string
|
modelValue?: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const allowedGuideContentModes = new Set<GuideContentDataSourceMode>(['static',
|
|||||||
const defaultSdkScriptUrl = '/static/sgs-map-sdk/index.global.js'
|
const defaultSdkScriptUrl = '/static/sgs-map-sdk/index.global.js'
|
||||||
const defaultSgsEngineUrl = '/h5-sdk'
|
const defaultSgsEngineUrl = '/h5-sdk'
|
||||||
const defaultSdkTimeoutMs = 5000
|
const defaultSdkTimeoutMs = 5000
|
||||||
|
const defaultApiBaseUrl = '/app-api'
|
||||||
|
const defaultSgsMapId = '1'
|
||||||
const defaultGuideStaticDataBaseUrl = '/static/guide-data'
|
const defaultGuideStaticDataBaseUrl = '/static/guide-data'
|
||||||
const defaultAudioApiBaseUrl = '/yudao-server'
|
const defaultAudioApiBaseUrl = '/yudao-server'
|
||||||
|
|
||||||
@@ -52,12 +54,17 @@ export const dataSourceConfig = {
|
|||||||
import.meta.env.VITE_GUIDE_STATIC_DATA_BASE_URL,
|
import.meta.env.VITE_GUIDE_STATIC_DATA_BASE_URL,
|
||||||
defaultGuideStaticDataBaseUrl
|
defaultGuideStaticDataBaseUrl
|
||||||
),
|
),
|
||||||
apiBaseUrl: import.meta.env.VITE_API_BASE_URL?.trim() || '',
|
|
||||||
audioApiBaseUrl: normalizeUrl(
|
audioApiBaseUrl: normalizeUrl(
|
||||||
import.meta.env.VITE_AUDIO_API_BASE_URL || import.meta.env.VITE_API_BASE_URL,
|
import.meta.env.VITE_AUDIO_API_BASE_URL || import.meta.env.VITE_API_BASE_URL,
|
||||||
defaultAudioApiBaseUrl
|
defaultAudioApiBaseUrl
|
||||||
),
|
),
|
||||||
|
apiBaseUrl: normalizeUrl(import.meta.env.VITE_API_BASE_URL, defaultApiBaseUrl),
|
||||||
audioLanguage: import.meta.env.VITE_AUDIO_LANGUAGE === 'en-US' ? 'en-US' : 'zh-CN',
|
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),
|
sgsSdkScriptUrl: normalizeUrl(import.meta.env.VITE_SGS_SDK_SCRIPT_URL, defaultSdkScriptUrl),
|
||||||
sgsMapEngineUrl: normalizeUrl(import.meta.env.VITE_SGS_H5_ENGINE_URL, defaultSgsEngineUrl),
|
sgsMapEngineUrl: normalizeUrl(import.meta.env.VITE_SGS_H5_ENGINE_URL, defaultSgsEngineUrl),
|
||||||
sgsSdkTargetOrigin: import.meta.env.VITE_SGS_SDK_ORIGIN?.trim() || '',
|
sgsSdkTargetOrigin: import.meta.env.VITE_SGS_SDK_ORIGIN?.trim() || '',
|
||||||
|
|||||||
@@ -4,6 +4,68 @@ export interface MuseumFloor {
|
|||||||
order: number
|
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 {
|
export interface MuseumCategory {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
|
|||||||
3
src/env.d.ts
vendored
3
src/env.d.ts
vendored
@@ -7,10 +7,13 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_API_BASE_URL?: string
|
readonly VITE_API_BASE_URL?: string
|
||||||
readonly VITE_AUDIO_API_BASE_URL?: string
|
readonly VITE_AUDIO_API_BASE_URL?: string
|
||||||
readonly VITE_AUDIO_LANGUAGE?: 'zh-CN' | 'en-US'
|
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_SDK_SCRIPT_URL?: string
|
||||||
readonly VITE_SGS_H5_ENGINE_URL?: string
|
readonly VITE_SGS_H5_ENGINE_URL?: string
|
||||||
readonly VITE_SGS_SDK_ORIGIN?: string
|
readonly VITE_SGS_SDK_ORIGIN?: string
|
||||||
readonly VITE_SGS_SDK_TIMEOUT_MS?: string
|
readonly VITE_SGS_SDK_TIMEOUT_MS?: string
|
||||||
|
readonly VITE_TENCENT_MAP_KEY?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
:floors="guideFloors"
|
:floors="guideFloors"
|
||||||
:normalize-floor-id="normalizeGuideFloorId"
|
:normalize-floor-id="normalizeGuideFloorId"
|
||||||
:target-focus-request="targetFocusRequest"
|
:target-focus-request="targetFocusRequest"
|
||||||
|
:route-preview="activeRoutePreview"
|
||||||
|
:show-route="Boolean(activeRoutePreview)"
|
||||||
@search-tap="handleSearchTap"
|
@search-tap="handleSearchTap"
|
||||||
@mode-change="handleModeChange"
|
@mode-change="handleModeChange"
|
||||||
@floor-change="handleFloorChange"
|
@floor-change="handleFloorChange"
|
||||||
@@ -108,6 +110,34 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</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>
|
</GuideMapShell>
|
||||||
</GuidePageFrame>
|
</GuidePageFrame>
|
||||||
</template>
|
</template>
|
||||||
@@ -127,9 +157,13 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
GuideLocationPreview,
|
GuideLocationPreview,
|
||||||
GuideLocationPreviewPlan,
|
GuideLocationPreviewPlan,
|
||||||
|
GuideRouteResult,
|
||||||
GuideStartLocation,
|
GuideStartLocation,
|
||||||
MuseumFloor
|
MuseumFloor
|
||||||
} from '@/domain/museum'
|
} from '@/domain/museum'
|
||||||
|
import {
|
||||||
|
guideRouteUseCase
|
||||||
|
} from '@/usecases/guideRouteUseCase'
|
||||||
import {
|
import {
|
||||||
navigateToGuideTopTab,
|
navigateToGuideTopTab,
|
||||||
saveLastGuideLocationPreviewUrl,
|
saveLastGuideLocationPreviewUrl,
|
||||||
@@ -166,6 +200,11 @@ const routeViewState = ref<RouteViewState>('preview')
|
|||||||
const activeFloor = ref('1F')
|
const activeFloor = ref('1F')
|
||||||
const targetFocusRequest = ref<TargetPoiFocusRequest | null>(null)
|
const targetFocusRequest = ref<TargetPoiFocusRequest | null>(null)
|
||||||
const targetFocusRequestId = ref(0)
|
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 manualFloors = computed(() => guideFloors.value.map((floor) => floor.label))
|
||||||
const guideFloorLabel = (floorId: string) => (
|
const guideFloorLabel = (floorId: string) => (
|
||||||
guideFloors.value.find((floor) => floor.id === floorId)?.label || floorId
|
guideFloors.value.find((floor) => floor.id === floorId)?.label || floorId
|
||||||
@@ -263,7 +302,7 @@ const shellConfig = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
searchText: `位置预览:${route.value.target}`,
|
searchText: routeReady.value ? `室内导览:${route.value.target}` : `位置预览:${route.value.target}`,
|
||||||
searchTop: '60px',
|
searchTop: '60px',
|
||||||
activeMode: '3d' as GuideMode,
|
activeMode: '3d' as GuideMode,
|
||||||
activeFloor: activeFloor.value,
|
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) => {
|
const safeDecode = (value: string) => {
|
||||||
try {
|
try {
|
||||||
return decodeURIComponent(value)
|
return decodeURIComponent(value)
|
||||||
@@ -328,6 +381,19 @@ const saveCurrentLocationPreviewUrl = () => {
|
|||||||
saveLastGuideLocationPreviewUrl(`/pages/route/detail?${params.toString()}`)
|
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 createStartLocationFromOptions = (options: Record<string, unknown>) => {
|
||||||
const label = normalizeStringOption(options.startLabel)
|
const label = normalizeStringOption(options.startLabel)
|
||||||
|
|
||||||
@@ -373,6 +439,7 @@ const requestTargetFocus = (target: Partial<RouteTargetLocation> | null, fallbac
|
|||||||
|
|
||||||
const applyRouteOptions = async (options: Record<string, unknown> = {}) => {
|
const applyRouteOptions = async (options: Record<string, unknown> = {}) => {
|
||||||
await loadGuideFloors()
|
await loadGuideFloors()
|
||||||
|
await refreshRouteReadinessState()
|
||||||
|
|
||||||
const facilityId = normalizeStringOption(options.facilityId)
|
const facilityId = normalizeStringOption(options.facilityId)
|
||||||
const target = normalizeStringOption(options.target)
|
const target = normalizeStringOption(options.target)
|
||||||
@@ -385,6 +452,7 @@ const applyRouteOptions = async (options: Record<string, unknown> = {}) => {
|
|||||||
routeViewState.value = 'preview'
|
routeViewState.value = 'preview'
|
||||||
activeFloor.value = defaultGuideFloorLabel()
|
activeFloor.value = defaultGuideFloorLabel()
|
||||||
targetFocusRequest.value = null
|
targetFocusRequest.value = null
|
||||||
|
activeRoutePreview.value = null
|
||||||
isManualLocationPanelOpen.value = false
|
isManualLocationPanelOpen.value = false
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -397,6 +465,7 @@ const applyRouteOptions = async (options: Record<string, unknown> = {}) => {
|
|||||||
routeViewState.value = 'preview'
|
routeViewState.value = 'preview'
|
||||||
activeFloor.value = startLocation?.floor || defaultGuideFloorLabel()
|
activeFloor.value = startLocation?.floor || defaultGuideFloorLabel()
|
||||||
targetFocusRequest.value = null
|
targetFocusRequest.value = null
|
||||||
|
activeRoutePreview.value = null
|
||||||
isManualLocationPanelOpen.value = false
|
isManualLocationPanelOpen.value = false
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -485,6 +554,7 @@ const handleTargetFocus = (result: TargetPoiFocusResult) => {
|
|||||||
|
|
||||||
const handleReturnToPreview = () => {
|
const handleReturnToPreview = () => {
|
||||||
isManualLocationPanelOpen.value = false
|
isManualLocationPanelOpen.value = false
|
||||||
|
activeRoutePreview.value = null
|
||||||
routeViewState.value = 'preview'
|
routeViewState.value = 'preview'
|
||||||
requestTargetFocus(route.value.targetLocation)
|
requestTargetFocus(route.value.targetLocation)
|
||||||
saveCurrentLocationPreviewUrl()
|
saveCurrentLocationPreviewUrl()
|
||||||
@@ -527,11 +597,67 @@ const handleConfirmLocation = async () => {
|
|||||||
)
|
)
|
||||||
activeFloor.value = startLocation.floor || activeFloor.value
|
activeFloor.value = startLocation.floor || activeFloor.value
|
||||||
isManualLocationPanelOpen.value = false
|
isManualLocationPanelOpen.value = false
|
||||||
|
activeRoutePreview.value = null
|
||||||
routeViewState.value = 'preview'
|
routeViewState.value = 'preview'
|
||||||
requestTargetFocus(route.value.targetLocation)
|
requestTargetFocus(route.value.targetLocation)
|
||||||
saveCurrentLocationPreviewUrl()
|
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) => {
|
const handleModeChange = (mode: GuideMode) => {
|
||||||
if (routeViewState.value === 'outdoor-preview' && mode === '3d') {
|
if (routeViewState.value === 'outdoor-preview' && mode === '3d') {
|
||||||
routeViewState.value = 'preview'
|
routeViewState.value = 'preview'
|
||||||
@@ -606,6 +732,94 @@ const handlePageBack = () => {
|
|||||||
z-index: 45;
|
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 {
|
.target-required-title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
|
|||||||
@@ -4,6 +4,18 @@ import type {
|
|||||||
GuideModelSource,
|
GuideModelSource,
|
||||||
GuideRenderPoi
|
GuideRenderPoi
|
||||||
} from '@/domain/guideModel'
|
} 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 {
|
import {
|
||||||
formatNavFloorLabel
|
formatNavFloorLabel
|
||||||
} from '@/data/adapters/navAssetsAdapter'
|
} from '@/data/adapters/navAssetsAdapter'
|
||||||
@@ -24,6 +36,38 @@ const toRenderPoi = (poi: StaticNavPoiPayload): GuideRenderPoi => ({
|
|||||||
sourceObjectName: poi.sourceObjectName
|
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 interface GuideModelRepository extends GuideModelSource {}
|
||||||
|
|
||||||
export class StaticGuideModelRepository implements GuideModelRepository {
|
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()
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
|
GuideDataIntegrityReport,
|
||||||
|
GuideFloorDetail,
|
||||||
GuideLocationPreview,
|
GuideLocationPreview,
|
||||||
|
GuideMapDiagnostics,
|
||||||
GuideRouteReadiness,
|
GuideRouteReadiness,
|
||||||
MuseumFloor,
|
MuseumFloor,
|
||||||
MuseumPoi
|
MuseumPoi
|
||||||
@@ -16,6 +19,20 @@ import {
|
|||||||
defaultStaticNavAssetsProvider,
|
defaultStaticNavAssetsProvider,
|
||||||
type StaticNavAssetsProvider
|
type StaticNavAssetsProvider
|
||||||
} from '@/data/providers/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([
|
const searchableCategories = new Set([
|
||||||
'touring_poi',
|
'touring_poi',
|
||||||
@@ -60,6 +77,9 @@ export interface GuideRepository {
|
|||||||
searchPois(keyword?: string): Promise<MuseumPoi[]>
|
searchPois(keyword?: string): Promise<MuseumPoi[]>
|
||||||
getLocationPreview(poiId: string): Promise<GuideLocationPreview | null>
|
getLocationPreview(poiId: string): Promise<GuideLocationPreview | null>
|
||||||
getRouteReadiness(): Promise<GuideRouteReadiness>
|
getRouteReadiness(): Promise<GuideRouteReadiness>
|
||||||
|
getFloorDetail?(floorId: string): Promise<GuideFloorDetail | null>
|
||||||
|
getMapDiagnostics?(): Promise<GuideMapDiagnostics>
|
||||||
|
checkDataIntegrity?(): Promise<GuideDataIntegrityReport>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StaticGuideRepository implements GuideRepository {
|
export class StaticGuideRepository implements GuideRepository {
|
||||||
@@ -120,6 +140,161 @@ export class StaticGuideRepository implements GuideRepository {
|
|||||||
return this.provider.loadRouteReadiness()
|
return this.provider.loadRouteReadiness()
|
||||||
.catch(() => NAV_ROUTE_READINESS)
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import type {
|
|||||||
SearchIndexItem
|
SearchIndexItem
|
||||||
} from '@/domain/museum'
|
} from '@/domain/museum'
|
||||||
import {
|
import {
|
||||||
guideRepository,
|
|
||||||
type GuideRepository
|
type GuideRepository
|
||||||
} from '@/repositories/GuideRepository'
|
} from '@/repositories/GuideRepository'
|
||||||
|
import {
|
||||||
|
guideRepository
|
||||||
|
} from '@/repositories/createGuideRepository'
|
||||||
import {
|
import {
|
||||||
staticMuseumContentProvider,
|
staticMuseumContentProvider,
|
||||||
type MuseumContentProvider
|
type MuseumContentProvider
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface SgsTargetFocusResult {
|
|||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SgsFloorId = string | number
|
||||||
|
|
||||||
export const toNumericFloorId = (floorId: string | number | undefined) => {
|
export const toNumericFloorId = (floorId: string | number | undefined) => {
|
||||||
if (typeof floorId === 'number') return floorId
|
if (typeof floorId === 'number') return floorId
|
||||||
if (!floorId) return 1
|
if (!floorId) return 1
|
||||||
@@ -29,6 +31,17 @@ export const toNumericFloorId = (floorId: string | number | undefined) => {
|
|||||||
return Number.isFinite(numeric) && numeric > 0 ? numeric : 1
|
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 toAppFloorId = (floorId: string | number | undefined) => `L${toNumericFloorId(floorId)}`
|
||||||
|
|
||||||
export const toFloorLabel = (floorId: string | number | undefined) => `${toNumericFloorId(floorId)}F`
|
export const toFloorLabel = (floorId: string | number | undefined) => `${toNumericFloorId(floorId)}F`
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface SgsMapServiceOptions {
|
|||||||
scriptUrl: string
|
scriptUrl: string
|
||||||
sdkUrl: string
|
sdkUrl: string
|
||||||
targetOrigin: string
|
targetOrigin: string
|
||||||
floorId: number
|
floorId: string | number
|
||||||
timeout: number
|
timeout: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ export class SgsMapService {
|
|||||||
return this.sdk
|
return this.sdk
|
||||||
}
|
}
|
||||||
|
|
||||||
async changeFloor(floorId: number) {
|
async changeFloor(floorId: string | number) {
|
||||||
const sdk = await this.getReadySdk()
|
const sdk = await this.getReadySdk()
|
||||||
return sdk.changeFloor(floorId, this.options.timeout)
|
return sdk.changeFloor(floorId, this.options.timeout)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export type SgsMapNode =
|
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: 'poiId'; value: number }
|
||||||
| { type: 'nodeName'; value: string }
|
| { type: 'nodeName'; value: string }
|
||||||
|
|
||||||
@@ -27,14 +27,14 @@ export interface SgsMapSDKOptions {
|
|||||||
container: string | HTMLElement
|
container: string | HTMLElement
|
||||||
sdkUrl?: string
|
sdkUrl?: string
|
||||||
targetOrigin?: string
|
targetOrigin?: string
|
||||||
floorId?: number
|
floorId?: string | number
|
||||||
timeout?: number
|
timeout?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SgsMapEvents {
|
export interface SgsMapEvents {
|
||||||
ready: { engineVersion: string; protocolVersion: number }
|
ready: { engineVersion: string; protocolVersion: number }
|
||||||
poiClick: { id?: number; poiId?: number; name: string; type: string; floorId?: number; description?: string }
|
poiClick: { id?: number; poiId?: number; name: string; type: string; floorId?: string | number; description?: string }
|
||||||
floorChanged: { floorId: number }
|
floorChanged: { floorId: string | number }
|
||||||
error: { code: string; message: string }
|
error: { code: string; message: string }
|
||||||
loadError: { type: string; detail: 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
|
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
|
off<K extends keyof SgsMapEvents>(event: K, listener: (payload: SgsMapEvents[K]) => void): void
|
||||||
getIsReady(): boolean
|
getIsReady(): boolean
|
||||||
getCurrentFloor(): number
|
getCurrentFloor(): string | number
|
||||||
focusTo(node: SgsMapNode | string): void
|
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
|
addMarker(config: SgsMarkerConfig): void
|
||||||
removeMarker(markerId: string): void
|
removeMarker(markerId: string): void
|
||||||
clearMarkers(): void
|
clearMarkers(): void
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import type {
|
|||||||
} from '@/domain/museum'
|
} from '@/domain/museum'
|
||||||
import {
|
import {
|
||||||
GuideRouteError,
|
GuideRouteError,
|
||||||
guideRouteRepository,
|
|
||||||
type GuideRouteRepository
|
type GuideRouteRepository
|
||||||
} from '@/repositories/GuideRouteRepository'
|
} from '@/repositories/GuideRouteRepository'
|
||||||
import {
|
import {
|
||||||
applyRouteReadinessGate
|
createGuideRouteRepository
|
||||||
} from '@/domain/guideReadiness'
|
} from '@/repositories/createGuideRouteRepository'
|
||||||
|
|
||||||
const toSearchText = (target: GuideRouteTarget) => [
|
const toSearchText = (target: GuideRouteTarget) => [
|
||||||
target.name,
|
target.name,
|
||||||
@@ -36,12 +35,12 @@ export class GuideRouteUseCase {
|
|||||||
private targetsCache: GuideRouteTarget[] | null = null
|
private targetsCache: GuideRouteTarget[] | null = null
|
||||||
private readinessCache: GuideRouteReadiness | null = null
|
private readinessCache: GuideRouteReadiness | null = null
|
||||||
|
|
||||||
constructor(private readonly repository: GuideRouteRepository = guideRouteRepository) {}
|
constructor(private readonly repository: GuideRouteRepository = createGuideRouteRepository()) {}
|
||||||
|
|
||||||
async getRouteReadiness() {
|
async getRouteReadiness() {
|
||||||
if (this.readinessCache) return this.readinessCache
|
if (this.readinessCache) return this.readinessCache
|
||||||
|
|
||||||
this.readinessCache = applyRouteReadinessGate(await this.repository.getRouteReadiness())
|
this.readinessCache = await this.repository.getRouteReadiness()
|
||||||
return this.readinessCache
|
return this.readinessCache
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +88,7 @@ export class GuideRouteUseCase {
|
|||||||
return error.message
|
return error.message
|
||||||
}
|
}
|
||||||
|
|
||||||
return error instanceof Error ? error.message : '路线预览生成失败'
|
return error instanceof Error ? error.message : '室内导览位置关系生成失败'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import type {
|
import type {
|
||||||
|
GuideDataIntegrityReport,
|
||||||
|
GuideFloorDetail,
|
||||||
GuideRouteReadiness,
|
GuideRouteReadiness,
|
||||||
GuideLocationPreviewPlan,
|
GuideLocationPreviewPlan,
|
||||||
|
GuideMapDiagnostics,
|
||||||
GuidePreviewStep,
|
GuidePreviewStep,
|
||||||
GuideStartLocation,
|
GuideStartLocation,
|
||||||
MuseumPoi
|
MuseumPoi
|
||||||
} from '@/domain/museum'
|
} from '@/domain/museum'
|
||||||
import {
|
import {
|
||||||
guideRepository,
|
|
||||||
type GuideRepository
|
type GuideRepository
|
||||||
} from '@/repositories/GuideRepository'
|
} from '@/repositories/GuideRepository'
|
||||||
|
import {
|
||||||
|
guideRepository
|
||||||
|
} from '@/repositories/createGuideRepository'
|
||||||
import {
|
import {
|
||||||
guideModelRepository,
|
guideModelRepository,
|
||||||
type GuideModelRepository
|
type GuideModelRepository
|
||||||
@@ -114,6 +119,24 @@ export class GuideUseCase {
|
|||||||
return this.guide.getPoiById(id)
|
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(
|
async createLocationPreviewPlan(
|
||||||
facilityId: string,
|
facilityId: string,
|
||||||
target: string,
|
target: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user