修复搜索与点位定位闭环
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-12 03:07:49 +08:00
parent e006333c0a
commit d8420fc7c2
18 changed files with 2961 additions and 621 deletions

View File

@@ -329,6 +329,7 @@ const props = withDefaults(defineProps<{
initialView?: ViewMode
showControls?: boolean
showPoi?: boolean
visiblePoiIds?: string[] | null
targetFocus?: TargetPoiFocusRequest | null
touchGestureMode?: TouchGestureMode
targetFocusDistanceFactor?: number
@@ -345,6 +346,7 @@ const props = withDefaults(defineProps<{
initialView: 'overview',
showControls: true,
showPoi: false,
visiblePoiIds: null,
targetFocus: null,
touchGestureMode: 'pan',
targetFocusDistanceFactor: 0.36,
@@ -395,7 +397,21 @@ const floors = computed<FloorOption[]>(() => (
label: formatFloorLabel(floor.floorId)
}))
))
const shouldRenderPoiMarkers = computed(() => props.showPoi || props.showControls || Boolean(props.targetFocus))
const shouldRenderPoiMarkers = computed(() => (
props.showPoi
|| props.showControls
|| props.visiblePoiIds !== null
|| Boolean(props.targetFocus)
))
const visiblePoiIdFilter = computed(() => (
props.visiblePoiIds === null
? null
: new Set(props.visiblePoiIds)
))
const isPoiIncludedByVisibleFilter = (poi: RenderPoi) => (
visiblePoiIdFilter.value === null || visiblePoiIdFilter.value.has(poi.id)
)
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
@@ -1480,8 +1496,28 @@ const updatePoiVisibilityByDistance = () => {
.sort((a, b) => (b.poi ? getPoiPriority(b.poi) : 0) - (a.poi ? getPoiPriority(a.poi) : 0))
candidates.forEach(({ sprite, poi, screenPosition }) => {
if (!poi) {
if (!poi || !isPoiIncludedByVisibleFilter(poi)) {
sprite.visible = false
const hitTarget = getPoiSpriteUserData(sprite).hitTarget
const labelSprite = getPoiSpriteUserData(sprite).labelSprite
if (hitTarget) {
hitTarget.visible = false
}
if (labelSprite) {
labelSprite.visible = false
}
return
}
// 分类结果模式直接展示列表对应点位,避免密度策略再次裁剪结果。
if (visiblePoiIdFilter.value !== null) {
sprite.visible = true
const hitTarget = getPoiSpriteUserData(sprite).hitTarget
if (hitTarget) {
hitTarget.visible = true
}
acceptedPositions.push(screenPosition)
visibleCount += 1
return
}
@@ -2182,7 +2218,12 @@ const getPoiHitTargets = () => {
const sprites: THREE.Sprite[] = []
poiGroup?.traverse((child) => {
if (isPoiHitTargetSprite(child)) {
const poi = child.userData.poi as RenderPoi | undefined
if (
isPoiHitTargetSprite(child)
&& child.visible
&& Boolean(poi && isPoiIncludedByVisibleFilter(poi))
) {
sprites.push(child)
}
})
@@ -4513,7 +4554,11 @@ const createPoiMarkerGroup = (
labelSprite.position.copy(getPoiAmbientLabelPosition(displayPosition, markerSize))
labelSprite.visible = false
}
sprite.visible = shouldShowPoiAtDistance(poi, getPoiVisibilityTier())
sprite.visible = isPoiIncludedByVisibleFilter(poi)
&& (
visiblePoiIdFilter.value !== null
|| shouldShowPoiAtDistance(poi, getPoiVisibilityTier())
)
hitTarget.visible = sprite.visible
group.add(sprite)
group.add(hitTarget)
@@ -5294,6 +5339,12 @@ watch(() => props.targetFocus, (request) => {
immediate: true
})
watch(() => props.visiblePoiIds, () => {
refreshPoiVisibilityByDistance()
}, {
deep: true
})
watch(() => [props.routePreview, props.showRoute], () => {
renderRoutePreview()
}, {

View File

@@ -13,6 +13,7 @@
:initial-view="indoorView"
:show-controls="false"
:show-poi="shouldShowIndoorPois"
:visible-poi-ids="visiblePoiIds"
:target-focus="targetFocusRequest"
:touch-gesture-mode="touchGestureMode"
:target-focus-distance-factor="targetFocusDistanceFactor"
@@ -351,6 +352,7 @@ const props = withDefaults(defineProps<{
normalizeFloorId?: (labelOrId: string) => string
dimmed?: boolean
targetFocusRequest?: TargetPoiFocusRequest | null
visiblePoiIds?: string[] | null
touchGestureMode?: TouchGestureMode
targetFocusDistanceFactor?: number
routePreview?: GuideRouteResult | null
@@ -403,6 +405,7 @@ const props = withDefaults(defineProps<{
normalizeFloorId: (labelOrId: string) => labelOrId,
dimmed: false,
targetFocusRequest: null,
visiblePoiIds: null,
touchGestureMode: 'pan',
targetFocusDistanceFactor: 0.36,
routePreview: null,

File diff suppressed because it is too large Load Diff

View File

@@ -15,9 +15,12 @@ import type {
import {
NAV_ROUTE_UNAVAILABLE_MESSAGE
} from '@/domain/guideReadiness'
import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
isPoiSearchCategorySupported
} from '@/domain/poiCategories'
import type {
SgsFloorDiagnosticsPayload,
SgsGuideStopPayload,
@@ -194,20 +197,22 @@ 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 optionalNumber = (value: number | null | undefined) => (
value !== null && typeof value !== '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 normalizePositionSource = (source: SgsPositionPayload): [number, number, number] | undefined => {
const x = optionalNumber(source.x)
const y = optionalNumber(source.y)
const z = optionalNumber(source.z)
if (typeof x === 'undefined' || typeof y === 'undefined' || typeof z === 'undefined') return undefined
return [x, y, z]
}
const normalizeStatus = (status?: string): GuideDiagnosticsStatus => {
if (status === 'OK' || status === 'WARN' || status === 'ERROR') return status
@@ -354,7 +359,7 @@ const searchableSpaceText = (space: SgsSpacePayload) => [
const parseBoundaryWktCenter = (
boundaryWkt?: string | null,
fallbackY = 0
fallbackY?: number
): [number, number, number] | undefined => {
const matches = [...(boundaryWkt || '').matchAll(/(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g)]
const points = matches
@@ -377,12 +382,11 @@ const normalizePositionSourceWithFallbackY = (
): [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)
const x = optionalNumber(source.x)
const y = optionalNumber(source.y) ?? optionalNumber(fallbackY)
const z = optionalNumber(source.z)
if (![x, y, z].every(Number.isFinite)) return undefined
if (typeof x === 'undefined' || typeof y === 'undefined' || typeof z === 'undefined') return undefined
return [x, y, z]
}
@@ -513,13 +517,16 @@ const kindForSgsPoi = (poi: SgsPoiPayload, category: MuseumCategory) => {
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)
export const toMuseumPoiFromSgs = (
poi: SgsPoiPayload,
floors: SgsSdkFloorSummaryPayload[]
): MuseumPoi => {
const sourceFloorId = stringifyId(poi.floorId)
const matchedFloor = floors.find((floor) => stringifyId(floor.floorId) === sourceFloorId || floor.floorCode === poi.floorCode)
const floorId = stringifyId(matchedFloor?.floorId)
|| sourceFloorId
|| stringifyId(poi.floorCode)
const category = categoryFor(poi)
return {
id: stringifyId(poi.id),
@@ -983,14 +990,19 @@ const addIssue = (
})
}
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
}
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.floorLabel) fields.push('floorLabel')
if (
!Array.isArray(poi.positionGltf)
|| poi.positionGltf.length !== 3
|| poi.positionGltf.some((value) => !Number.isFinite(value))
) fields.push('position')
return fields
}
export const createGuideDataIntegrityReport = (
diagnostics: GuideMapDiagnostics,
@@ -1065,7 +1077,7 @@ export const createGuideDataIntegrityReport = (
}
if (poi.id) poiIds.add(poi.id)
if (poi.floorId && !floorIds.has(poi.floorId)) {
if (poi.floorId && !floorIds.has(poi.floorId)) {
addIssue(issues, {
scope: 'poi',
severity: 'error',
@@ -1074,9 +1086,21 @@ export const createGuideDataIntegrityReport = (
floorLabel: poi.floorLabel,
poiId: poi.id,
fields: ['floorId']
})
}
})
})
}
if (!isPoiSearchCategorySupported(poi)) {
addIssue(issues, {
scope: 'poi',
severity: 'warn',
message: `${poi.name || poi.id} 缺少有效搜索分类`,
floorId: poi.floorId,
floorLabel: poi.floorLabel,
poiId: poi.id,
fields: ['primaryCategory']
})
}
})
const errorCount = issues.filter((issue) => issue.severity === 'error').length
const warningCount = issues.filter((issue) => issue.severity === 'warn').length

288
src/domain/poiCategories.ts Normal file
View File

@@ -0,0 +1,288 @@
import type {
MuseumCategory,
MuseumPoi
} from '@/domain/museum'
export type PoiCategoryId =
| 'exhibition-hall'
| 'cinema'
| 'ticket-office'
| 'service-center'
| 'dining'
| 'souvenir'
| 'nursing-room'
| 'restroom'
| 'elevator'
| 'stairs'
| 'escalator'
export interface PoiCategoryDefinition {
id: PoiCategoryId
label: string
icon: string
order: number
homeVisible: boolean
keywords: readonly string[]
categoryIds: readonly string[]
iconTypes: readonly string[]
}
const definitions: PoiCategoryDefinition[] = [
{
id: 'exhibition-hall',
label: '展厅',
icon: 'exhibition',
order: 1,
homeVisible: true,
keywords: ['展厅', '展览', '展馆', 'exhibition_hall', 'touring_poi', 'hall'],
categoryIds: ['exhibition_hall', 'exhibition_hall_entrance', 'touring_poi'],
iconTypes: ['exhibition_hall', 'hall_entrance', 'exhibition']
},
{
id: 'cinema',
label: '影院',
icon: 'cinema',
order: 2,
homeVisible: false,
keywords: ['影院', '影厅', '剧场', '报告厅', 'cinema', 'theater'],
categoryIds: ['space_theater'],
iconTypes: ['cinema', 'theater']
},
{
id: 'ticket-office',
label: '售票处',
icon: 'ticket',
order: 3,
homeVisible: false,
keywords: ['售票处', '售票', '票务', 'ticket_office'],
categoryIds: [],
iconTypes: ['ticket_office']
},
{
id: 'service-center',
label: '服务中心',
icon: 'service',
order: 4,
homeVisible: false,
keywords: ['服务中心', '服务台', '咨询台', 'service_desk'],
categoryIds: [],
iconTypes: ['service_desk']
},
{
id: 'dining',
label: '餐饮',
icon: 'restaurant',
order: 5,
homeVisible: false,
keywords: ['餐饮', '餐厅', '快餐', '咖啡', 'restaurant', 'dining', 'food', 'cafe'],
categoryIds: [],
iconTypes: ['restaurant', 'dining', 'food', 'cafe']
},
{
id: 'souvenir',
label: '文创',
icon: 'bag',
order: 6,
homeVisible: false,
keywords: ['文创', '纪念品', '商店', 'souvenir', 'gift', 'shop'],
categoryIds: [],
iconTypes: ['souvenir', 'gift', 'shop', 'cultural_creative']
},
{
id: 'nursing-room',
label: '母婴室',
icon: 'nursing',
order: 7,
homeVisible: true,
keywords: ['母婴室', '母婴间', 'mother_baby_room', 'nursing_room'],
categoryIds: [],
iconTypes: ['mother_baby_room', 'nursing_room']
},
{
id: 'restroom',
label: '卫生间',
icon: 'restroom',
order: 8,
homeVisible: true,
keywords: ['卫生间', '洗手间', '厕所', 'toilet', 'accessible_toilet', 'restroom'],
categoryIds: [],
iconTypes: ['toilet', 'accessible_toilet', 'restroom', 'restroom_accessible']
},
{
id: 'elevator',
label: '电梯',
icon: 'elevator',
order: 9,
homeVisible: true,
keywords: ['电梯', 'elevator'],
categoryIds: [],
iconTypes: ['elevator']
},
{
id: 'stairs',
label: '楼梯',
icon: 'stairs',
order: 10,
homeVisible: true,
keywords: ['楼梯', 'stairs', 'stair'],
categoryIds: [],
iconTypes: ['stairs', 'stair']
},
{
id: 'escalator',
label: '扶梯',
icon: 'escalator',
order: 11,
homeVisible: true,
keywords: ['扶梯', 'escalator'],
categoryIds: [],
iconTypes: ['escalator']
}
]
export const POI_CATEGORIES = Object.freeze(
[...definitions].sort((left, right) => left.order - right.order)
)
const homeCategoryOrder: readonly PoiCategoryId[] = [
'exhibition-hall',
'restroom',
'nursing-room',
'elevator',
'stairs',
'escalator'
]
export const HOME_POI_CATEGORIES = Object.freeze(
homeCategoryOrder.map((categoryId) => (
POI_CATEGORIES.find((category) => category.id === categoryId)!
))
)
type PoiCategorySource = Pick<MuseumPoi, 'name' | 'primaryCategory' | 'categories'>
const normalizeValue = (value?: string | null) => (value || '').trim().toLowerCase()
const categoryValues = (categories: MuseumCategory[]) => categories.flatMap((category) => [
category.id,
category.label,
category.iconType || ''
])
export const matchesPoiCategory = (
poi: PoiCategorySource,
definition: PoiCategoryDefinition
) => {
const categories = [poi.primaryCategory, ...(poi.categories || [])]
const normalizedCategoryIds = new Set(categories.map((category) => normalizeValue(category.id)))
const normalizedIconTypes = new Set(categories.map((category) => normalizeValue(category.iconType)))
if (definition.categoryIds.some((categoryId) => normalizedCategoryIds.has(normalizeValue(categoryId)))) {
return true
}
if (definition.iconTypes.some((iconType) => normalizedIconTypes.has(normalizeValue(iconType)))) {
return true
}
const searchableText = [
poi.name,
...categoryValues(categories)
]
.map(normalizeValue)
.filter(Boolean)
.join(' ')
return definition.keywords.some((keyword) => searchableText.includes(normalizeValue(keyword)))
}
export const getPoiCategoryById = (categoryId?: string | null) => (
POI_CATEGORIES.find((category) => category.id === categoryId) || null
)
export const findPoiCategoryByKeyword = (keyword: string) => {
const normalizedKeyword = normalizeValue(keyword)
if (!normalizedKeyword) return null
return POI_CATEGORIES.find((category) => (
normalizeValue(category.label) === normalizedKeyword
|| category.keywords.some((item) => normalizeValue(item) === normalizedKeyword)
)) || null
}
export const resolvePoiCategory = (poi: PoiCategorySource) => (
POI_CATEGORIES.find((category) => matchesPoiCategory(poi, category)) || null
)
export const isPoiSearchCategorySupported = (poi: PoiCategorySource) => Boolean(resolvePoiCategory(poi))
export type PoiDataIssueCode =
| 'missing-id'
| 'missing-name'
| 'missing-floor'
| 'missing-position'
| 'invalid-position'
| 'unsupported-category'
export interface PoiDataIssue {
code: PoiDataIssueCode
poiId: string
poiName: string
}
const hasFinitePosition = (position?: [number, number, number]) => (
Array.isArray(position)
&& position.length === 3
&& position.every((value) => Number.isFinite(value))
)
export const getPoiDataIssues = (poi: MuseumPoi): PoiDataIssue[] => {
const issues: PoiDataIssue[] = []
const issueBase = {
poiId: poi.id || '',
poiName: poi.name || ''
}
if (!poi.id?.trim()) issues.push({ code: 'missing-id', ...issueBase })
if (!poi.name?.trim()) issues.push({ code: 'missing-name', ...issueBase })
if (!poi.floorId?.trim() || !poi.floorLabel?.trim()) issues.push({ code: 'missing-floor', ...issueBase })
if (!poi.positionGltf) {
issues.push({ code: 'missing-position', ...issueBase })
} else if (!hasFinitePosition(poi.positionGltf)) {
issues.push({ code: 'invalid-position', ...issueBase })
}
if (!isPoiSearchCategorySupported(poi)) issues.push({ code: 'unsupported-category', ...issueBase })
return issues
}
export interface PoiCollectionInspection {
issues: PoiDataIssue[]
duplicateIds: string[]
}
export const inspectPoiCollection = (pois: MuseumPoi[]): PoiCollectionInspection => {
const seenIds = new Set<string>()
const duplicateIds = new Set<string>()
pois.forEach((poi) => {
if (!poi.id) return
if (seenIds.has(poi.id)) duplicateIds.add(poi.id)
seenIds.add(poi.id)
})
return {
issues: pois.flatMap(getPoiDataIssues),
duplicateIds: Array.from(duplicateIds)
}
}
export const warnPoiCollectionIssues = (source: string, pois: MuseumPoi[]) => {
if (!import.meta.env.DEV) return
const inspection = inspectPoiCollection(pois)
if (!inspection.issues.length && !inspection.duplicateIds.length) return
// 开发期集中输出数据契约问题,避免在页面组件中散落源数据校验。
console.warn(`[POI 数据校验] ${source}`, inspection)
}

29
src/domain/poiSearch.ts Normal file
View File

@@ -0,0 +1,29 @@
import type {
MuseumPoi
} from '@/domain/museum'
import type {
PoiCategoryId
} from '@/domain/poiCategories'
export type PoiSearchOrigin = 'home' | 'page'
export interface PoiSearchContext {
origin: PoiSearchOrigin
keyword: string
categoryId: PoiCategoryId | ''
floorId: string
floorLabel: string
resultCount: number
floorResultCount: number
visiblePoiIds: string[]
listScrollTop: number
}
export interface PoiSearchSelection {
poi: MuseumPoi
context: PoiSearchContext
}
export interface PoiCategoryResultState extends PoiSearchContext {
active: boolean
}

View File

@@ -28,39 +28,46 @@
show-zoom-controls
zoom-controls-top="calc(100vh - 252px)"
@floor-change="handleFloorChange"
@target-focus="handleTargetFocus"
>
<view v-if="isDetailPanelVisible" class="detail-sheet">
<view class="detail-sheet" data-testid="facility-detail-sheet">
<view class="detail-title-row">
<view class="target-dot"></view>
<view class="detail-title-copy">
<text class="detail-title">{{ facility.name }}</text>
<text class="detail-subtitle">{{ facility.location }}</text>
</view>
<view class="detail-close" @tap.stop="handleCloseDetailPanel">
<view
class="detail-close"
data-testid="facility-detail-close"
aria-label="关闭点位详情"
@tap.stop="handleCloseDetailPanel"
>
<text class="detail-close-text">×</text>
</view>
</view>
<view class="tag-row">
<view
v-for="(tag, index) in facility.tags"
:key="tag"
class="detail-tag"
:class="{ active: index === 0 }"
>
<text class="detail-tag-text">{{ tag }}</text>
<view class="detail-meta" data-testid="facility-detail-meta">
<view class="detail-meta-item">
<text class="detail-meta-label">类别</text>
<text class="detail-meta-value">{{ facility.category }}</text>
</view>
<view class="detail-meta-item">
<text class="detail-meta-label">楼层</text>
<text class="detail-meta-value">{{ facility.floor }}</text>
</view>
</view>
<text class="detail-hint">已在对应楼层模型中标出点位</text>
</view>
<view
v-else
class="detail-restore"
@tap.stop="handleOpenDetailPanel"
>
<view class="target-dot small"></view>
<text class="detail-restore-text">点位</text>
<text v-if="facility.description" class="detail-description">
{{ facility.description }}
</text>
<view
v-if="locationErrorMessage"
class="detail-location-error"
data-testid="facility-location-error"
>
<text class="detail-location-error-text">{{ locationErrorMessage }}</text>
</view>
</view>
</GuideMapShell>
</GuidePageFrame>
@@ -78,8 +85,10 @@ import {
explainUseCase
} from '@/usecases/explainUseCase'
import {
parseFacilityDetailSearchContext,
toFacilityDetailViewModel,
toTargetFocusRequestViewModel,
type FacilityDetailSearchContext,
type TargetPoiFocusRequestViewModel,
type FacilityDetailViewModel
} from '@/view-models/guideViewModels'
@@ -106,17 +115,29 @@ const activeFloor = ref('1F')
const requestedTargetFloorId = ref('')
const targetFocusRequest = ref<TargetPoiFocusRequestViewModel | null>(null)
const targetFocusRequestId = ref(0)
const isDetailPanelVisible = ref(true)
const locationErrorMessage = ref('')
const resolutionLoadFailed = ref(false)
interface TargetPoiFocusResult {
requestId: number | string
poiId: string
floorId: string
status: 'focused' | 'missing' | 'error'
message?: string
}
const facility = ref<FacilityDetailViewModel>({
id: '',
name: '目标位置',
status: '可预览',
location: '正在定位三维点位',
traffic: guideUseCase.getRouteReadinessSnapshot().message,
tags: ['三维位置', '位置预览']
category: '正在加载',
floor: '正在加载',
description: ''
})
const searchContext = ref<FacilityDetailSearchContext>(
parseFacilityDetailSearchContext()
)
const loadGuideFloors = async () => {
try {
guideFloors.value = await guideUseCase.getFloors()
@@ -136,8 +157,12 @@ const loadGuideFloors = async () => {
}
const focusFacilityPoi = (poi: MuseumPoi) => {
if (!poi.floorId) return
if (!poi.floorId) {
locationErrorMessage.value = '该点位缺少楼层信息,暂时无法在地图中定位。'
return
}
locationErrorMessage.value = ''
activeMode.value = '3d'
activeFloor.value = poi.floorId
targetFocusRequestId.value += 1
@@ -153,8 +178,12 @@ const focusFacilityPoi = (poi: MuseumPoi) => {
}
const focusRenderPoi = (poi: GuideRenderPoi) => {
if (!poi.floorId) return
if (!poi.floorId) {
locationErrorMessage.value = '该点位缺少楼层信息,暂时无法在地图中定位。'
return
}
locationErrorMessage.value = ''
const floorLabel = guideFloors.value.find((floor) => floor.id === poi.floorId)?.label || poi.floorId
activeMode.value = '3d'
activeFloor.value = poi.floorId
@@ -177,20 +206,15 @@ const focusRenderPoi = (poi: GuideRenderPoi) => {
const getRenderPoiFloorLabel = (poi: GuideRenderPoi) =>
guideFloors.value.find((floor) => floor.id === poi.floorId)?.label || poi.floorId
const applyRenderPoiToFacility = (poi: GuideRenderPoi, routeMessage: string) => {
const applyRenderPoiToFacility = (poi: GuideRenderPoi) => {
const floorLabel = getRenderPoiFloorLabel(poi)
const categoryLabel = poi.primaryCategoryZh || '三维位置'
const categoryLabel = poi.primaryCategoryZh || '未分类'
facility.value = {
id: poi.id,
name: poi.name,
status: '可预览',
location: `${floorLabel} · ${categoryLabel}`,
traffic: routeMessage,
tags: [
floorLabel,
categoryLabel,
'展示点位'
]
category: categoryLabel,
floor: floorLabel || '楼层信息缺失',
description: ''
}
focusRenderPoi(poi)
}
@@ -201,7 +225,15 @@ const normalizePoiName = (value: string) => value
.toLowerCase()
const decodeRouteText = (value: unknown) => (
typeof value === 'string' ? decodeURIComponent(value) : ''
typeof value === 'string'
? (() => {
try {
return decodeURIComponent(value)
} catch {
return value
}
})()
: ''
)
const isPoiOnRequestedFloor = (poi: Pick<MuseumPoi | GuideRenderPoi, 'floorId'>) => (
@@ -222,6 +254,9 @@ const resolveFacilityPoi = async () => {
: null
if (poiById) return poiById
// 搜索结果必须按稳定 ID 解析,避免同名点位被定位到错误楼层。
if (searchContext.value.source === 'search') return null
const poiByHall = await resolvePoiFromHall()
if (poiByHall) return poiByHall
@@ -244,9 +279,10 @@ const resolveFacilityPoi = async () => {
}) || candidatesToMatch[0] || null
}
const resolveRenderPoiByName = async () => {
const resolveRenderPoi = async () => {
const resolveByExactId = searchContext.value.source === 'search' && Boolean(facility.value.id)
const normalizedTarget = normalizePoiName(facility.value.name)
if (!normalizedTarget) return null
if (!resolveByExactId && !normalizedTarget) return null
const modelPackage = await indoorModelSource.loadPackage()
const floorsToSearch = requestedTargetFloorId.value
@@ -255,8 +291,17 @@ const resolveRenderPoiByName = async () => {
for (const floor of floorsToSearch) {
const floorId = floor.floorId
const pois = await indoorModelSource.loadFloorPois(floorId).catch(() => [])
let pois: GuideRenderPoi[] = []
try {
pois = await indoorModelSource.loadFloorPois(floorId)
} catch (error) {
resolutionLoadFailed.value = true
console.warn(`楼层 ${floorId} 的模型点位加载失败:`, error)
continue
}
const matchedPoi = pois.find((poi) => {
if (resolveByExactId) return poi.id === facility.value.id
const poiName = normalizePoiName(poi.name)
const hallName = normalizePoiName(poi.hallName || '')
const sourceObjectName = normalizePoiName(poi.sourceObjectName || '')
@@ -276,8 +321,9 @@ const resolveRenderPoiByName = async () => {
const tryResolveRenderPoiByName = async () => {
try {
return await resolveRenderPoiByName()
return await resolveRenderPoi()
} catch (error) {
resolutionLoadFailed.value = true
console.warn('当前模型源点位解析失败,尝试静态资源兜底:', error)
return null
}
@@ -287,12 +333,15 @@ const tryResolveFacilityPoi = async () => {
try {
return await resolveFacilityPoi()
} catch (error) {
resolutionLoadFailed.value = true
console.warn('内容点位解析失败,继续使用模型点位预览:', error)
return null
}
}
onLoad(async (options: any) => {
onLoad(async (options: Record<string, unknown> = {}) => {
searchContext.value = parseFacilityDetailSearchContext(options)
resolutionLoadFailed.value = false
await loadGuideFloors()
if (options.id) {
@@ -303,40 +352,50 @@ onLoad(async (options: any) => {
facility.value.name = decodeRouteText(options.target)
}
if (options.floorId) {
requestedTargetFloorId.value = normalizeGuideFloorId(decodeRouteText(options.floorId))
const requestedFloorId = decodeRouteText(options.floorId)
|| searchContext.value.searchFloorId
if (requestedFloorId) {
requestedTargetFloorId.value = normalizeGuideFloorId(requestedFloorId)
if (requestedTargetFloorId.value) {
activeFloor.value = requestedTargetFloorId.value
}
}
if (options.floorLabel && requestedTargetFloorId.value) {
const floorLabel = decodeRouteText(options.floorLabel)
facility.value.location = `${floorLabel} · 位置预览`
facility.value.tags = [floorLabel, '三维位置', '位置预览']
const requestedFloorLabel = decodeRouteText(options.floorLabel)
|| searchContext.value.searchFloorLabel
if (requestedFloorLabel) {
facility.value.floor = requestedFloorLabel
}
if (!facility.value.id && !normalizePoiName(facility.value.name)) return
if (!facility.value.id && !options.target) {
locationErrorMessage.value = '未提供可定位的点位信息。'
return
}
try {
const routeReadinessMessage = guideUseCase.getRouteReadinessSnapshot().message
const poi = await tryResolveFacilityPoi()
if (poi) {
facility.value = toFacilityDetailViewModel(
poi,
routeReadinessMessage
)
facility.value = toFacilityDetailViewModel(poi)
focusFacilityPoi(poi)
return
}
const renderPoi = await tryResolveRenderPoiByName()
if (renderPoi) {
applyRenderPoiToFacility(renderPoi, routeReadinessMessage)
applyRenderPoiToFacility(renderPoi)
return
}
facility.value.category = facility.value.category === '正在加载'
? '未分类'
: facility.value.category
facility.value.floor = requestedFloorLabel || '楼层信息缺失'
locationErrorMessage.value = resolutionLoadFailed.value
? '位置数据加载失败,请稍后重试。'
: '该点位缺少可用的位置数据,暂时无法在地图中定位。'
} catch (error) {
console.error('加载设施位置详情失败:', error)
locationErrorMessage.value = '位置数据加载失败,请稍后重试。'
}
})
@@ -344,12 +403,19 @@ const handleFloorChange = (floor: string) => {
activeFloor.value = floor
}
const handleCloseDetailPanel = () => {
isDetailPanelVisible.value = false
const handleTargetFocus = (result: TargetPoiFocusResult) => {
if (result.status === 'focused') {
locationErrorMessage.value = ''
return
}
locationErrorMessage.value = result.status === 'missing'
? '该点位缺少可用的位置数据,暂时无法在地图中定位。'
: '地图定位暂不可用,请稍后重试。'
}
const handleOpenDetailPanel = () => {
isDetailPanelVisible.value = true
const handleCloseDetailPanel = () => {
handlePageBack()
}
const handleTopTabChange = (tab: GuideTopTab) => {
@@ -357,6 +423,13 @@ const handleTopTabChange = (tab: GuideTopTab) => {
}
const handlePageBack = () => {
if (searchContext.value.source === 'search' && searchContext.value.resultCount === 1) {
uni.reLaunch({
url: '/pages/index/index'
})
return
}
uni.navigateBack({
delta: 1,
fail: () => {
@@ -441,91 +514,62 @@ const handlePageBack = () => {
text-overflow: ellipsis;
}
.detail-subtitle {
.detail-meta {
margin-top: 12px;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
}
.detail-meta-item {
min-width: 0;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 2px;
box-sizing: border-box;
background: #f7f8f3;
border: 1px solid #e5e6de;
border-radius: 8px;
}
.detail-meta-label {
font-size: 12px;
line-height: 17px;
line-height: 16px;
color: #696962;
}
.detail-meta-value {
min-width: 0;
font-size: 14px;
line-height: 20px;
font-weight: 600;
color: #1f2329;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tag-row {
margin-top: 12px;
display: flex;
gap: 8px;
}
.detail-tag {
height: 26px;
min-width: 68px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #e5e6de;
border-radius: 8px;
}
.detail-tag.active {
background: #151713;
border-color: #151713;
}
.detail-tag-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #1f2329;
}
.detail-tag.active .detail-tag-text {
color: var(--museum-accent);
}
.detail-hint {
.detail-description {
display: block;
margin-top: 10px;
font-size: 12px;
line-height: 17px;
font-weight: 500;
color: #1a7f37;
font-size: 13px;
line-height: 19px;
color: #424754;
}
.detail-restore {
position: absolute;
right: 18px;
bottom: calc(env(safe-area-inset-bottom) + 152px);
height: 40px;
min-width: 74px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.94);
border: 1px solid #dde5df;
.detail-location-error {
margin-top: 10px;
padding: 9px 10px;
background: #fff4e8;
border: 1px solid #f1c99e;
border-radius: 8px;
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
z-index: 45;
}
.target-dot.small {
width: 10px;
height: 10px;
margin-top: 0;
border-width: 2px;
box-shadow: none;
}
.detail-restore-text {
.detail-location-error-text {
font-size: 13px;
line-height: 18px;
font-weight: 700;
color: #151713;
color: #7a3e05;
}
@media (max-width: 360px) {
@@ -534,9 +578,8 @@ const handlePageBack = () => {
right: 12px;
}
.detail-tag {
min-width: 0;
padding: 0 9px;
.detail-meta {
gap: 8px;
}
}
</style>

View File

@@ -28,6 +28,8 @@
:indoor-view="indoorView"
:layer-mode="indoorLayerMode"
:active-floor="activeGuideFloor"
:visible-poi-ids="searchVisiblePoiIds"
:target-focus-request="searchTargetFocusRequest"
layer-mode-toggle-top="154px"
floor-bottom="193px"
floor-max-height="206px"
@@ -162,17 +164,20 @@
></view>
<view
v-if="showGuideHomeDock"
v-show="showGuideHomeDock"
class="guide-home-dock"
:class="{ expanded: homeSearchExpanded }"
@mousedown.capture="handleHomeDockActivate"
@click.capture="handleHomeDockActivate"
@tap.capture="handleHomeDockActivate"
>
<PoiSearchPanel
ref="homeSearchPanelRef"
variant="home"
:current-floor-id="activeGuideFloor"
:current-floor-label="getGuideFloorLabel(activeGuideFloor)"
@expanded-change="handleHomeSearchExpandedChange"
@category-mode-change="handleHomeCategoryModeChange"
@results-change="handleHomeSearchResultsChange"
@result-tap="handleHomeSearchResultTap"
@cancel="handleHomeSearchCancel"
/>
</view>
@@ -299,8 +304,8 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
import RoutePlannerPanel from '@/components/navigation/RoutePlannerPanel.vue'
@@ -326,12 +331,17 @@ import {
import {
explainUseCase
} from '@/usecases/explainUseCase'
import {
normalizeExplainDetailTargetFromGuideStop
} from '@/domain/explainDetailTarget'
import type {
GuideRenderPoi
} from '@/domain/guideModel'
import type {
PoiCategoryResultState,
PoiSearchContext
} from '@/domain/poiSearch'
import {
toTargetFocusRequestViewModel,
type TargetPoiFocusRequestViewModel
} from '@/view-models/guideViewModels'
import {
ARRIVAL_TARGETS_BY_TYPE,
type ArrivalSearchTarget,
@@ -341,7 +351,8 @@ import type {
GuideRouteResult,
GuideRouteTarget,
MuseumFloor,
MuseumHall
MuseumHall,
MuseumPoi
} from '@/domain/museum'
type GuideIndoorView = 'overview' | 'floor' | 'multi'
@@ -424,10 +435,15 @@ const guideMapShellRef = ref<{
showOverview?: () => Promise<void> | void
} | null>(null)
const homeSearchPanelRef = ref<{
expandHomePanel?: () => void
collapseHomePanel?: () => void
resetSearchState?: () => void
restoreResultListScroll?: () => Promise<void>
} | null>(null)
const homeSearchExpanded = ref(false)
const homeCategoryModeActive = ref(false)
const searchVisiblePoiIds = ref<string[] | null>(null)
const searchTargetFocusRequest = ref<TargetPoiFocusRequestViewModel | null>(null)
let searchTargetFocusRequestId = 0
let launchOverlayFallbackTimer: ReturnType<typeof setTimeout> | null = null
let launchOverlayFadeTimer: ReturnType<typeof setTimeout> | null = null
@@ -732,7 +748,7 @@ const selectedGuidePoiIsHall = computed(() => {
})
const selectedGuidePoiCollapsedSubtitle = computed(() => (
selectedGuidePoiIsHall.value ? '已定位展厅 · 点按展开操作' : '已定位点位'
selectedGuidePoiIsHall.value ? '点按展开查看展厅' : selectedGuidePoiSubtitle.value
))
const routeSimulationStepText = computed(() => {
@@ -999,7 +1015,7 @@ const handleGuidePoiClick = (poi: GuideRenderPoi) => {
isSimulatingRoute.value = false
activeGuideFloor.value = poi.floorId
renderedFloorId.value = poi.floorId
showIndoorHint('已在当前楼层标出点位', 3000)
showIndoorHint(`${poi.name} · ${getGuideFloorLabel(poi.floorId)}`, 3000)
}
const handleGuideSelectionClear = () => {
@@ -1390,6 +1406,11 @@ const closeHomeSearchDock = () => {
homeSearchExpanded.value = false
}
onShow(async () => {
await nextTick()
await homeSearchPanelRef.value?.restoreResultListScroll?.()
})
const handleMoreOutdoorNav = () => {
if (showArrivalPanel.value && showArrivalPanelCollapsed.value) {
arrivalPanelRef.value?.expandPanel()
@@ -1417,15 +1438,110 @@ const handleHomeSearchExpandedChange = (expanded: boolean) => {
homeSearchExpanded.value = expanded
}
const handleHomeSearchOutsideTap = () => {
closeHomeSearchDock()
const clearHomeSearchMapState = () => {
homeCategoryModeActive.value = false
searchVisiblePoiIds.value = null
searchTargetFocusRequest.value = null
}
const handleHomeDockActivate = () => {
if (homeSearchExpanded.value) return
const handleHomeCategoryModeChange = (active: boolean) => {
homeCategoryModeActive.value = active
if (!active) {
searchVisiblePoiIds.value = null
return
}
homeSearchPanelRef.value?.expandHomePanel?.()
homeSearchExpanded.value = true
closeArrivalPanel()
showRoutePlanner.value = false
isSimulatingRoute.value = false
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
is3DMode.value = true
indoorView.value = 'floor'
}
const handleHomeSearchResultsChange = (state: PoiCategoryResultState) => {
if (!state.active) {
searchVisiblePoiIds.value = null
return
}
searchVisiblePoiIds.value = [...state.visiblePoiIds]
if (state.floorId) {
activeGuideFloor.value = state.floorId
}
}
const createSearchDetailUrl = (poi: MuseumPoi, context: PoiSearchContext) => {
const resultCount = homeCategoryModeActive.value
? context.floorResultCount
: context.resultCount
const params = new URLSearchParams({
source: 'search',
searchOrigin: context.origin,
searchKeyword: context.keyword,
searchCategoryId: context.categoryId,
searchFloorId: context.floorId,
searchFloorLabel: context.floorLabel,
resultCount: String(Math.max(1, resultCount)),
floorResultCount: String(context.floorResultCount),
visiblePoiIds: context.visiblePoiIds.join(','),
listScrollTop: String(context.listScrollTop),
id: poi.id,
target: poi.name,
floorId: poi.floorId,
floorLabel: poi.floorLabel
})
return `/pages/facility/detail?${params.toString()}`
}
const handleHomeSearchResultTap = async (poi: MuseumPoi, context: PoiSearchContext) => {
closeArrivalPanel()
showRoutePlanner.value = false
isSimulatingRoute.value = false
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
is3DMode.value = true
indoorView.value = 'floor'
activeGuideFloor.value = poi.floorId
searchTargetFocusRequestId += 1
searchTargetFocusRequest.value = toTargetFocusRequestViewModel({
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
}, searchTargetFocusRequestId)
// 先把楼层与目标请求交给地图,再进入详情页;返回时列表组件仍保持原状态。
await nextTick()
uni.navigateTo({
url: createSearchDetailUrl(poi, context),
fail: () => {
uni.showToast({
title: '点位详情打开失败,请重试',
icon: 'none'
})
}
})
}
const handleHomeSearchCancel = () => {
clearHomeSearchMapState()
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
homeSearchExpanded.value = false
}
const handleHomeSearchOutsideTap = () => {
closeHomeSearchDock()
}
const guideShellMode = computed(() => (is3DMode.value ? '3d' : '2d'))

View File

@@ -12,6 +12,7 @@
<view class="search-panel-shell">
<PoiSearchPanel
ref="searchPanelRef"
:initial-keyword="initialKeyword"
variant="page"
autofocus
@@ -21,13 +22,16 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import PoiSearchPanel from '@/components/search/PoiSearchPanel.vue'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
const initialKeyword = ref('')
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
const searchPanelRef = ref<{
restoreResultListScroll?: () => Promise<void>
} | null>(null)
onLoad((options: any) => {
const keyword = typeof options.keyword === 'string'
@@ -37,14 +41,14 @@ onLoad((options: any) => {
initialKeyword.value = keyword
})
onShow(async () => {
await nextTick()
await searchPanelRef.value?.restoreResultListScroll?.()
})
const handleBackToMap = () => {
uni.navigateBack({
delta: 1,
fail: () => {
uni.reLaunch({
url: '/pages/index/index?tab=guide'
})
}
uni.reLaunch({
url: '/pages/index/index?tab=guide'
})
}

View File

@@ -15,6 +15,9 @@ import {
isIndoorNavigableFloor,
isPoiOnIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
warnPoiCollectionIssues
} from '@/domain/poiCategories'
import {
formatNavFloorLabel,
isStaticIndoorNavigableFloorId,
@@ -127,6 +130,91 @@ const dedupePoisById = (pois: MuseumPoi[]) => {
})
}
const getPoiSpaceIdentity = (poi: MuseumPoi) => poi.sourceSpaceId || poi.spaceId || ''
const mergeSearchablePois = (pois: MuseumPoi[], spacePoints: MuseumPoi[]) => {
const hallPoisBySpaceId = new Map(
pois
.map((poi) => [getPoiSpaceIdentity(poi), poi] as const)
.filter(([spaceId]) => Boolean(spaceId))
)
const spacePointsBySpaceId = new Map(
spacePoints
.map((poi) => [getPoiSpaceIdentity(poi), poi] as const)
.filter(([spaceId]) => Boolean(spaceId))
)
const filteredPois = pois.filter((poi) => {
const spaceId = getPoiSpaceIdentity(poi)
const matchingSpacePoint = spaceId ? spacePointsBySpaceId.get(spaceId) : null
if (!matchingSpacePoint) return true
return matchingSpacePoint.primaryCategory.id === 'exhibition_hall'
})
const filteredSpacePoints = spacePoints.filter((poi) => {
const spaceId = getPoiSpaceIdentity(poi)
const matchingHallPoi = spaceId ? hallPoisBySpaceId.get(spaceId) : null
if (!matchingHallPoi) return true
return poi.primaryCategory.id !== 'exhibition_hall'
})
return dedupePoisById([
...filteredPois,
...filteredSpacePoints
])
}
const getMedianPoiY = (pois: MuseumPoi[]) => {
const values = pois
.map((poi) => poi.positionGltf?.[1])
.filter((value): value is number => Number.isFinite(value))
.sort((left, right) => left - right)
if (!values.length) return undefined
const middle = Math.floor(values.length / 2)
return values.length % 2 === 0
? (values[middle - 1] + values[middle]) / 2
: values[middle]
}
type SgsPoiSourceName = 'pois' | 'businessPois' | 'spaces' | 'navigablePlaces'
interface SgsPoiSourceFailure {
floorId: string
source: SgsPoiSourceName
message: string
}
const poiDataLoadErrorMessage = '点位数据加载失败,请检查网络后重试'
const toErrorMessage = (reason: unknown) => (
reason instanceof Error ? reason.message : String(reason || '未知错误')
)
const collectSgsSourceResult = <T>(
result: PromiseSettledResult<T>,
floorId: string,
source: SgsPoiSourceName,
failures: SgsPoiSourceFailure[]
) => {
if (result.status === 'fulfilled') return result.value
failures.push({
floorId,
source,
message: toErrorMessage(result.reason)
})
return null
}
const warnSgsSourceFailures = (scope: string, failures: SgsPoiSourceFailure[]) => {
if (!import.meta.env.DEV || !failures.length) return
// 开发期保留来源与楼层信息,便于区分真实空结果和接口失败。
console.warn(`[SGS 点位数据] ${scope}`, failures)
}
export interface GuideRepository {
getAssetBaseUrl(): string
getFloors(): Promise<MuseumFloor[]>
@@ -171,9 +259,11 @@ export class StaticGuideRepository implements GuideRepository {
if (this.poiCache) return this.poiCache
const pois = await this.provider.loadPoiIndex()
this.poiCache = pois
.map(toMuseumPoi)
const adaptedPois = pois.map(toMuseumPoi)
warnPoiCollectionIssues('static-nav-assets', adaptedPois)
this.poiCache = dedupePoisById(adaptedPois
.filter(isSearchableIndoorPoi)
)
return this.poiCache
}
@@ -299,30 +389,55 @@ export class SgsSdkGuideRepository implements GuideRepository {
const indoorFloors = manifest.floors.filter(isIndoorNavigableFloor)
const indoorFloorIds = new Set(indoorFloors.map((floor) => String(floor.floorId)))
this.floorAliases = buildSgsFloorAliases(indoorFloors)
const failures: SgsPoiSourceFailure[] = []
let successfulCoreSourceCount = 0
const floorDataGroups = await Promise.all(
indoorFloors.map(async (floor) => {
const floorId = String(floor.floorId)
const [pois, businessPois, spaces, navigablePlaces] = await Promise.all([
this.provider.getFloorPois(floorId).catch(() => []),
this.provider.getFloorBusinessPois(floorId).catch(() => []),
this.provider.getFloorSpaces(floorId).catch(() => []),
this.provider.getNavigablePlaces(floorId).catch(() => [])
const [poisResult, businessPoisResult, spacesResult, navigablePlacesResult] = await Promise.allSettled([
this.provider.getFloorPois(floorId),
this.provider.getFloorBusinessPois(floorId),
this.provider.getFloorSpaces(floorId),
this.provider.getNavigablePlaces(floorId)
])
const pois = collectSgsSourceResult(poisResult, floorId, 'pois', failures) || []
const businessPois = collectSgsSourceResult(businessPoisResult, floorId, 'businessPois', failures) || []
const spaces = collectSgsSourceResult(spacesResult, floorId, 'spaces', failures) || []
const navigablePlaces = collectSgsSourceResult(
navigablePlacesResult,
floorId,
'navigablePlaces',
failures
) || []
if (poisResult.status === 'fulfilled') successfulCoreSourceCount += 1
if (spacesResult.status === 'fulfilled') successfulCoreSourceCount += 1
const adaptedPois = [
...pois,
...businessPois
].map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
const fallbackY = getMedianPoiY(adaptedPois)
return {
floorId,
pois: [
...pois,
...businessPois
],
hallPois: toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, floorId)
pois: adaptedPois,
hallPois: toMuseumHallPoisFromSgs(
spaces,
navigablePlaces,
manifest.floors,
floorId,
{ fallbackY }
)
}
})
)
warnSgsSourceFailures('楼层点位聚合', failures)
if (indoorFloors.length && successfulCoreSourceCount === 0) {
throw new Error(poiDataLoadErrorMessage)
}
const ordinaryPois = floorDataGroups
.flatMap((group) => group.pois)
.map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
.filter((poi) => poi.id && poi.name)
.filter((poi) => indoorFloorIds.has(String(poi.floorId)) || isPoiOnIndoorNavigableFloor(poi))
@@ -330,12 +445,18 @@ export class SgsSdkGuideRepository implements GuideRepository {
.flatMap((group) => group.hallPois)
.filter((poi) => indoorFloorIds.has(String(poi.floorId)) || isPoiOnIndoorNavigableFloor(poi))
this.poiCache = dedupePoisById([
const adaptedPois = [
...hallPois,
...ordinaryPois
])
]
warnPoiCollectionIssues('sgs-guide-pois-before-dedupe', adaptedPois)
const result = dedupePoisById(adaptedPois)
const hasCoreFailures = failures.some((failure) => (
failure.source === 'pois' || failure.source === 'spaces'
))
if (!hasCoreFailures) this.poiCache = result
return this.poiCache
return result
}
async listSpacePoints() {
@@ -345,48 +466,100 @@ export class SgsSdkGuideRepository implements GuideRepository {
const indoorFloors = manifest.floors.filter(isIndoorNavigableFloor)
const indoorFloorIds = new Set(indoorFloors.map((floor) => String(floor.floorId)))
this.floorAliases = buildSgsFloorAliases(indoorFloors)
const failures: SgsPoiSourceFailure[] = []
let successfulSpaceSourceCount = 0
const floorDataGroups = await Promise.all(
indoorFloors.map(async (floor) => {
const floorId = String(floor.floorId)
const spaces = await this.provider.getFloorSpaces(floorId).catch(() => [])
const [spacesResult, floorPoisResult] = await Promise.allSettled([
this.provider.getFloorSpaces(floorId),
this.provider.getFloorPois(floorId)
])
const spaces = collectSgsSourceResult(spacesResult, floorId, 'spaces', failures) || []
const floorPois = collectSgsSourceResult(floorPoisResult, floorId, 'pois', failures) || []
if (spacesResult.status === 'fulfilled') successfulSpaceSourceCount += 1
const fallbackY = getMedianPoiY(
floorPois.map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
)
return {
floorId,
spaces
spaces,
fallbackY
}
})
)
warnSgsSourceFailures('空间点位聚合', failures)
if (indoorFloors.length && successfulSpaceSourceCount === 0) {
throw new Error(poiDataLoadErrorMessage)
}
const spacePoints = floorDataGroups
.flatMap((group) => group.spaces.map((space) => toMuseumSpacePointFromSgs(space, manifest.floors, group.floorId)))
.flatMap((group) => group.spaces.map((space) => toMuseumSpacePointFromSgs(
space,
manifest.floors,
group.floorId,
{ fallbackY: group.fallbackY }
)))
.filter((poi): poi is MuseumPoi => Boolean(poi))
.filter((poi) => indoorFloorIds.has(String(poi.floorId)) || isPoiOnIndoorNavigableFloor(poi))
this.spacePointCache = dedupePoisById(spacePoints)
warnPoiCollectionIssues('sgs-space-points-before-dedupe', spacePoints)
const result = dedupePoisById(spacePoints)
const hasSpaceFailures = failures.some((failure) => failure.source === 'spaces')
const floorsWithSpaces = new Set(
floorDataGroups
.filter((group) => group.spaces.length > 0)
.map((group) => group.floorId)
)
const hasPoiHeightFallbackFailures = failures.some((failure) => (
failure.source === 'pois' && floorsWithSpaces.has(failure.floorId)
))
if (!result.length && floorsWithSpaces.size && hasPoiHeightFallbackFailures) {
throw new Error(poiDataLoadErrorMessage)
}
if (!hasSpaceFailures && !hasPoiHeightFallbackFailures) this.spacePointCache = result
return this.spacePointCache
return result
}
async getPoiById(id: string) {
const pois = await this.listPois()
const matchedPoi = pois.find((poi) => poi.id === id)
const pois = await this.searchPois()
return pois.find((poi) => poi.id === id || poi.spaceId === id || poi.sourceSpaceId === id)
|| findPoiByNavIdNameFallback(pois, id)
if (matchedPoi) return matchedPoi
const spacePoints = await this.listSpacePoints()
return spacePoints.find((poi) => poi.id === id || poi.spaceId === id || poi.sourceSpaceId === id)
|| null
}
async searchPois(keyword = '') {
const pois = await this.listPois()
const sourceFailures: unknown[] = []
let pois: MuseumPoi[] = []
let spacePoints: MuseumPoi[] = []
try {
pois = await this.listPois()
} catch (error) {
sourceFailures.push(error)
}
try {
spacePoints = await this.listSpacePoints()
} catch (error) {
sourceFailures.push(error)
}
const searchablePois = mergeSearchablePois(pois, spacePoints)
if (sourceFailures.length && !searchablePois.length) {
throw new Error(poiDataLoadErrorMessage)
}
if (import.meta.env.DEV && sourceFailures.length) {
console.warn('[SGS 点位数据] 搜索使用部分可用数据', sourceFailures.map(toErrorMessage))
}
const normalizedKeyword = keyword.trim().toLowerCase()
if (!normalizedKeyword) return pois
if (!normalizedKeyword) return searchablePois
return pois.filter((poi) => toSearchText(poi).includes(normalizedKeyword))
return searchablePois.filter((poi) => toSearchText(poi).includes(normalizedKeyword))
}
async getLocationPreview(poiId: string) {
@@ -425,7 +598,7 @@ export class SgsSdkGuideRepository implements GuideRepository {
async checkDataIntegrity() {
const [floors, pois, diagnostics] = await Promise.all([
this.getFloors(),
this.listPois(),
this.searchPois(),
this.getMapDiagnostics()
])

View File

@@ -140,7 +140,7 @@ export class GuideUseCase {
}
getInitialSearchSpacePoints() {
return this.guide.listSpacePoints()
return this.guide.searchPois()
}
getPoiById(id: string) {

View File

@@ -3,6 +3,9 @@ import type {
GuideLocationPreviewPlan,
MuseumPoi
} from '@/domain/museum'
import {
resolvePoiCategory
} from '@/domain/poiCategories'
export interface FacilityResultViewModel {
id: string
@@ -16,10 +19,22 @@ export interface FacilityResultViewModel {
export interface FacilityDetailViewModel {
id: string
name: string
status: string
location: string
traffic: string
tags: string[]
category: string
floor: string
description: string
}
export interface FacilityDetailSearchContext {
source: 'search' | 'direct'
searchOrigin: 'home' | 'page' | ''
searchKeyword: string
searchCategoryId: string
searchFloorId: string
searchFloorLabel: string
resultCount: number
floorResultCount: number
visiblePoiIds: string[]
listScrollTop: number
}
export interface TargetPoiFocusRequestViewModel extends GuideLocationPreview {
@@ -42,21 +57,53 @@ export const toFacilityResultViewModel = (poi: MuseumPoi): FacilityResultViewMod
})
export const toFacilityDetailViewModel = (
poi: MuseumPoi,
routeUnavailableMessage: string
poi: MuseumPoi
): FacilityDetailViewModel => ({
id: poi.id,
name: poi.name,
status: '可预览',
location: `${poi.floorLabel} · ${poi.primaryCategory.label}`,
traffic: routeUnavailableMessage,
tags: [
poi.floorLabel,
poi.primaryCategory.label,
poi.accessible ? '无障碍相关' : '展示点位'
]
category: resolvePoiCategory(poi)?.label || poi.primaryCategory.label || '未分类',
floor: poi.floorLabel || '楼层信息缺失',
description: ''
})
const decodeQueryText = (value: unknown) => {
if (typeof value !== 'string') return ''
try {
return decodeURIComponent(value)
} catch {
return value
}
}
const parseNonNegativeInteger = (value: unknown) => {
const parsed = Number.parseInt(decodeQueryText(value), 10)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
}
export const parseFacilityDetailSearchContext = (
options: Record<string, unknown> = {}
): FacilityDetailSearchContext => {
const searchOrigin = decodeQueryText(options.searchOrigin)
const visiblePoiIds = decodeQueryText(options.visiblePoiIds)
.split(',')
.map((id) => id.trim())
.filter(Boolean)
return {
source: decodeQueryText(options.source) === 'search' ? 'search' : 'direct',
searchOrigin: searchOrigin === 'home' || searchOrigin === 'page' ? searchOrigin : '',
searchKeyword: decodeQueryText(options.searchKeyword),
searchCategoryId: decodeQueryText(options.searchCategoryId),
searchFloorId: decodeQueryText(options.searchFloorId),
searchFloorLabel: decodeQueryText(options.searchFloorLabel),
resultCount: parseNonNegativeInteger(options.resultCount),
floorResultCount: parseNonNegativeInteger(options.floorResultCount),
visiblePoiIds: Array.from(new Set(visiblePoiIds)),
listScrollTop: parseNonNegativeInteger(options.listScrollTop)
}
}
export const toTargetFocusRequestViewModel = (
targetLocation: GuideLocationPreview,
requestId: number | string