From f94af2de4d1208c144459582549b3777ea3d168d Mon Sep 17 00:00:00 2001 From: lyf <2514544224@qq.com> Date: Sun, 12 Jul 2026 04:10:34 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=90=9C=E7=B4=A2=E7=82=B9?= =?UTF-8?q?=E4=BD=8D=E5=88=86=E7=B1=BB=E4=B8=8E=E7=A8=B3=E5=AE=9A=E5=AE=9A?= =?UTF-8?q?=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/search/PoiSearchPanel.vue | 14 ++- src/data/adapters/sgsSdkGuideAdapter.ts | 37 +++++--- src/repositories/GuideModelRepository.ts | 20 +++-- src/repositories/GuideRepository.ts | 51 ++++++++--- tests/unit/GuideRepository.spec.ts | 103 ++++++++++++++++++++++- 5 files changed, 188 insertions(+), 37 deletions(-) diff --git a/src/components/search/PoiSearchPanel.vue b/src/components/search/PoiSearchPanel.vue index 964d8d4..884b60c 100644 --- a/src/components/search/PoiSearchPanel.vue +++ b/src/components/search/PoiSearchPanel.vue @@ -296,7 +296,7 @@ const floorLoadError = ref('') const excludedPoiCount = ref(0) const duplicatePoiCount = ref(0) const resultListScrollTop = ref(0) -const resultListRef = ref(null) +const resultListRef = ref(null) let cachedPoiPool: PreparedPoiResults | null = null let searchRequestSeq = 0 @@ -807,8 +807,15 @@ const handleResultListScroll = (event: any) => { resultListScrollTop.value = Number.isFinite(scrollTop) ? Math.max(0, scrollTop) : 0 } +const getResultListElement = (): HTMLElement | null => { + const target = resultListRef.value + if (!target) return null + if ('$el' in target) return target.$el || null + return target as HTMLElement +} + const captureResultListScroll = () => { - const scrollTop = Number(resultListRef.value?.scrollTop) + const scrollTop = Number(getResultListElement()?.scrollTop) if (Number.isFinite(scrollTop)) resultListScrollTop.value = Math.max(0, scrollTop) } @@ -817,7 +824,8 @@ const restoreResultListScroll = async () => { if (scrollTop <= 0) return await nextTick() - if (resultListRef.value) resultListRef.value.scrollTop = scrollTop + const resultListElement = getResultListElement() + if (resultListElement) resultListElement.scrollTop = scrollTop } const poiDisplayName = (poi: MuseumPoi) => poi.name?.trim() || '未命名点位' diff --git a/src/data/adapters/sgsSdkGuideAdapter.ts b/src/data/adapters/sgsSdkGuideAdapter.ts index dde8230..814ada3 100644 --- a/src/data/adapters/sgsSdkGuideAdapter.ts +++ b/src/data/adapters/sgsSdkGuideAdapter.ts @@ -672,6 +672,13 @@ const categoryForSgsSpace = (space: SgsSpacePayload): MuseumCategory => { return spaceCategory } +const createCanonicalSpacePoiId = (space: SgsSpacePayload) => { + const spaceId = stringifyId(space.id) + if (!spaceId) return '' + + return `${isExhibitionHallSpace(space) ? 'hall' : 'space'}-${spaceId}` +} + export const toMuseumSpacePointFromSgs = ( space: SgsSpacePayload, floors: SgsSdkFloorSummaryPayload[], @@ -686,9 +693,11 @@ export const toMuseumSpacePointFromSgs = ( if (!spaceId || !name || !spacePosition) return null const category = categoryForSgsSpace(space) + const poiId = createCanonicalSpacePoiId(space) return { - id: `space-${spaceId}`, + // 可生成展厅类 marker 的空间统一使用 hall ID,确保弱网回退结果仍可直接定位。 + id: poiId, name, floorId, floorLabel: floorLabelForSgs(floorId, floors), @@ -734,8 +743,8 @@ const createHallEntrance = ( const createHallPoiId = ( space: SgsSpacePayload ) => { - const spaceId = stringifyId(space.id) - if (spaceId) return `hall-${spaceId}` + const canonicalId = createCanonicalSpacePoiId(space) + if (canonicalId) return canonicalId const ownerName = normalizedHallName(space.name) const floorIdentity = normalizedHallName(stringifyId(space.floorId) || 'unknown-floor') @@ -755,16 +764,17 @@ const createSpaceFallbackHallPoi = ( const position = spacePosition?.position const hallId = stringifyId(space.id) - if (!hallId || !normalizedText(space.name) || !position) return null - - return { + if (!hallId || !normalizedText(space.name) || !position) return null + const category = categoryForSgsSpace(space) + + return { id: `hall-${hallId}`, name: normalizedText(space.name), floorId, floorLabel: floorLabelForSgs(floorId, floors), - primaryCategory: hallCategory, - categories: [ - hallCategory + primaryCategory: category, + categories: [ + category ], positionGltf: position, sourceObjectName: space.sourceNodeName || undefined, @@ -807,7 +817,8 @@ export const toMuseumHallPoisFromSgs = ( const hallId = stringifyId(matchedSpace?.id) || undefined const poiId = createHallPoiId(matchedSpace) const hallName = normalizedText(matchedSpace?.name) || placeHallName(place) || entrance.name - const existing = halls.get(poiId) + const category = categoryForSgsSpace(matchedSpace) + const existing = halls.get(poiId) if (existing) { existing.entrances = [ @@ -843,10 +854,10 @@ export const toMuseumHallPoisFromSgs = ( name: hallName, floorId: usesSpaceCenter ? fallbackFloorIdForPoi : entrance.floorId, floorLabel: usesSpaceCenter ? fallbackFloorLabel : entrance.floorLabel, - primaryCategory: hallCategory, + primaryCategory: category, categories: [ - hallCategory, - hallEntranceCategory + category, + ...(category.id === hallCategory.id ? [hallEntranceCategory] : []) ], positionGltf, sourceObjectName: usesSpaceCenter diff --git a/src/repositories/GuideModelRepository.ts b/src/repositories/GuideModelRepository.ts index 811c0a7..52db895 100644 --- a/src/repositories/GuideModelRepository.ts +++ b/src/repositories/GuideModelRepository.ts @@ -86,13 +86,17 @@ const toGuideRenderPoi = (poi: MuseumPoi): GuideRenderPoi => ({ const toSgsRenderPoi = (poi: ReturnType) => toGuideRenderPoi(poi) -const getRenderPoiDedupeKeys = (poi: GuideRenderPoi) => [ - poi.id ? `id:${poi.id}` : '', - poi.sourceSpaceId ? `space:${poi.floorId}:${poi.sourceSpaceId}` : '', - poi.spaceId ? `space:${poi.floorId}:${poi.spaceId}` : '', - poi.sourcePlaceId ? `place:${poi.floorId}:${poi.sourcePlaceId}` : '', - poi.sourceObjectName ? `object:${poi.floorId}:${poi.sourceObjectName}` : '' -].filter(Boolean) +const getRenderPoiDedupeKeys = (poi: GuideRenderPoi) => { + const stableKeys = [ + poi.id ? `id:${poi.id}` : '', + poi.sourceSpaceId ? `space:${poi.floorId}:${poi.sourceSpaceId}` : '', + poi.spaceId ? `space:${poi.floorId}:${poi.spaceId}` : '', + poi.sourcePlaceId ? `place:${poi.floorId}:${poi.sourcePlaceId}` : '' + ].filter(Boolean) + + if (stableKeys.length) return stableKeys + return poi.sourceObjectName ? [`object:${poi.floorId}:${poi.sourceObjectName}`] : [] +} const dedupeRenderPoisById = (pois: GuideRenderPoi[]) => { const seen = new Set() @@ -523,7 +527,7 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository { fallbackHallY: floorPoiMedianY ?? null }) - if (hallDiagnostics.hallPoiWithPositionCount > 0 && !renderPois.some((poi) => poi.primaryCategory === 'exhibition_hall')) { + if (hallDiagnostics.hallPoiWithPositionCount > 0 && !renderHallPois.length) { warnSgsGuideModelDiagnostics('render POI filter removed all hall POIs', { floorId: resolvedFloorId, ...hallDiagnostics, diff --git a/src/repositories/GuideRepository.ts b/src/repositories/GuideRepository.ts index e937c18..3dc2afd 100644 --- a/src/repositories/GuideRepository.ts +++ b/src/repositories/GuideRepository.ts @@ -4,6 +4,7 @@ import type { GuideLocationPreview, GuideMapDiagnostics, GuideRouteReadiness, + MuseumCategory, MuseumFloor, MuseumPoi } from '@/domain/museum' @@ -132,9 +133,28 @@ const dedupePoisById = (pois: MuseumPoi[]) => { const getPoiSpaceIdentity = (poi: MuseumPoi) => poi.sourceSpaceId || poi.spaceId || '' +const getPoiLookupIdentities = (id: string) => { + const normalizedId = id.trim() + return new Set([ + normalizedId, + normalizedId.replace(/^(?:hall|space)-/, '') + ].filter(Boolean)) +} + +const mergePoiCategories = (...categoryGroups: MuseumCategory[][]) => { + const seenIds = new Set() + return categoryGroups.flat().filter((category) => { + const categoryId = category.id.trim() + if (!categoryId || seenIds.has(categoryId)) return false + seenIds.add(categoryId) + return true + }) +} + const mergeSearchablePois = (pois: MuseumPoi[], spacePoints: MuseumPoi[]) => { const hallPoisBySpaceId = new Map( pois + .filter((poi) => poi.kind === 'hall' || Boolean(poi.hallId)) .map((poi) => [getPoiSpaceIdentity(poi), poi] as const) .filter(([spaceId]) => Boolean(spaceId)) ) @@ -144,24 +164,30 @@ const mergeSearchablePois = (pois: MuseumPoi[], spacePoints: MuseumPoi[]) => { .filter(([spaceId]) => Boolean(spaceId)) ) - const filteredPois = pois.filter((poi) => { + const mergedPois = pois.map((poi) => { const spaceId = getPoiSpaceIdentity(poi) const matchingSpacePoint = spaceId ? spacePointsBySpaceId.get(spaceId) : null - if (!matchingSpacePoint) return true + const matchingHallPoi = spaceId ? hallPoisBySpaceId.get(spaceId) : null + if (!matchingSpacePoint || matchingHallPoi?.id !== poi.id) return poi - return matchingSpacePoint.primaryCategory.id === 'exhibition_hall' + return { + ...poi, + primaryCategory: matchingSpacePoint.primaryCategory, + categories: mergePoiCategories( + [matchingSpacePoint.primaryCategory], + matchingSpacePoint.categories + ) + } }) - const filteredSpacePoints = spacePoints.filter((poi) => { + const unmatchedSpacePoints = 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 !matchingHallPoi }) return dedupePoisById([ - ...filteredPois, - ...filteredSpacePoints + ...mergedPois, + ...unmatchedSpacePoints ]) } @@ -527,7 +553,12 @@ export class SgsSdkGuideRepository implements GuideRepository { async getPoiById(id: string) { const pois = await this.searchPois() - return pois.find((poi) => poi.id === id || poi.spaceId === id || poi.sourceSpaceId === id) + const lookupIdentities = getPoiLookupIdentities(id) + return pois.find((poi) => ( + lookupIdentities.has(poi.id) + || lookupIdentities.has(poi.spaceId || '') + || lookupIdentities.has(poi.sourceSpaceId || '') + )) || findPoiByNavIdNameFallback(pois, id) } diff --git a/tests/unit/GuideRepository.spec.ts b/tests/unit/GuideRepository.spec.ts index b49f300..230aaab 100644 --- a/tests/unit/GuideRepository.spec.ts +++ b/tests/unit/GuideRepository.spec.ts @@ -4,7 +4,14 @@ import type { SgsSdkManifestPayload } from '@/data/providers/sgsSdkApiProvider' import { SgsSdkGuideRepository } from '@/repositories/GuideRepository' +import { SgsSdkGuideModelRepository } from '@/repositories/GuideModelRepository' import { toMuseumPoiFromSgs } from '@/data/adapters/sgsSdkGuideAdapter' +import { + getPoiCategoryById, + matchesPoiCategory, + resolvePoiCategory +} from '@/domain/poiCategories' +import { toFacilityDetailViewModel } from '@/view-models/guideViewModels' const manifest: SgsSdkManifestPayload = { mapId: 'museum', @@ -88,7 +95,7 @@ describe('SgsSdkGuideRepository 点位搜索', () => { expect(theaterResults).toHaveLength(1) expect(theaterResults[0]).toMatchObject({ - id: 'space-space-theater', + id: 'hall-space-theater', floorId: '101', floorLabel: '1F', primaryCategory: { @@ -96,9 +103,99 @@ describe('SgsSdkGuideRepository 点位搜索', () => { }, positionGltf: [5, 12, 5] }) + expect(resolvePoiCategory(theaterResults[0])?.id).toBe('cinema') + expect(matchesPoiCategory( + theaterResults[0], + getPoiCategoryById('cinema')! + )).toBe(true) + expect(matchesPoiCategory( + theaterResults[0], + getPoiCategoryById('exhibition-hall')! + )).toBe(false) + expect(toFacilityDetailViewModel(theaterResults[0]).category).toBe('影院') expect(results.some((poi) => poi.id === 'poi-toilet')).toBe(true) }) + it('搜索可定位 ID 与同楼层地图 marker ID 保持一致', async () => { + const provider = createProvider() + const guideRepository = new SgsSdkGuideRepository(provider) + const modelRepository = new SgsSdkGuideModelRepository(provider, guideRepository) + + const [searchResults, renderPois] = await Promise.all([ + guideRepository.searchPois(), + modelRepository.loadFloorPois('101') + ]) + const renderPoiIds = new Set(renderPois.map((poi) => poi.id)) + const locatableSearchIds = searchResults + .filter((poi) => poi.floorId === '101' && poi.positionGltf) + .map((poi) => poi.id) + + expect(locatableSearchIds.length).toBeGreaterThan(0) + expect(locatableSearchIds.every((poiId) => renderPoiIds.has(poiId))).toBe(true) + expect(renderPois.find((poi) => poi.id === 'hall-space-theater')).toMatchObject({ + primaryCategory: 'space_theater', + iconType: 'theater' + }) + }) + + it('核心点位来源短暂失败时仍返回可直接定位的稳定空间 ID', async () => { + const getFloorPois = vi.fn() + .mockResolvedValueOnce([floorPoi]) + .mockRejectedValueOnce(new Error('pois offline')) + .mockResolvedValue([floorPoi]) + const getFloorSpaces = vi.fn() + .mockResolvedValueOnce([theaterSpace]) + .mockRejectedValueOnce(new Error('spaces offline')) + .mockResolvedValue([theaterSpace]) + const provider = createProvider({ getFloorPois, getFloorSpaces }) + const guideRepository = new SgsSdkGuideRepository(provider) + const modelRepository = new SgsSdkGuideModelRepository(provider, guideRepository) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await guideRepository.listSpacePoints() + const fallbackResults = await guideRepository.searchPois() + const fallbackTheater = fallbackResults.find((poi) => poi.name === '球幕影院') + + expect(fallbackTheater?.id).toBe('hall-space-theater') + const recoveredTheater = (await guideRepository.searchPois()) + .find((poi) => poi.name === '球幕影院') + expect(recoveredTheater?.id).toBe(fallbackTheater?.id) + await expect(guideRepository.getPoiById('space-space-theater')) + .resolves.toMatchObject({ id: 'hall-space-theater' }) + + const renderPois = await modelRepository.loadFloorPois('101') + expect(renderPois.some((poi) => poi.id === fallbackTheater?.id)).toBe(true) + }) + + it('相同模型对象名不会删除不同稳定 ID 和坐标的普通点位', async () => { + const provider = createProvider({ + getFloorSpaces: vi.fn().mockResolvedValue([]), + getFloorPois: vi.fn().mockResolvedValue([ + { + ...floorPoi, + id: '5625', + name: '男卫生间001', + sourceNodeName: 'L2_男卫生间001', + position: { x: 1, y: 12, z: 1 } + }, + { + ...floorPoi, + id: '5629', + name: '卫生间01', + sourceNodeName: 'L2_男卫生间001', + position: { x: 9, y: 12, z: 9 } + } + ]) + }) + const guideRepository = new SgsSdkGuideRepository(provider) + const modelRepository = new SgsSdkGuideModelRepository(provider, guideRepository) + + const renderPois = await modelRepository.loadFloorPois('101') + + expect(renderPois.filter((poi) => poi.id === '5625' || poi.id === '5629')) + .toHaveLength(2) + }) + it('部分来源失败时继续返回可用点位,并在后续搜索重试失败来源', async () => { const getFloorSpaces = vi.fn() .mockRejectedValueOnce(new Error('spaces offline')) @@ -112,7 +209,7 @@ describe('SgsSdkGuideRepository 点位搜索', () => { expect(firstResults.map((poi) => poi.id)).toEqual(['poi-toilet']) const secondResults = await repository.searchPois() - expect(secondResults.some((poi) => poi.id === 'space-space-theater')).toBe(true) + expect(secondResults.some((poi) => poi.id === 'hall-space-theater')).toBe(true) expect(getFloorSpaces).toHaveBeenCalledTimes(4) }) @@ -148,7 +245,7 @@ describe('SgsSdkGuideRepository 点位搜索', () => { await expect(repository.searchPois()).rejects.toThrow('点位数据加载失败,请检查网络后重试') const recoveredResults = await repository.searchPois() - expect(recoveredResults.find((poi) => poi.id === 'space-space-theater')?.positionGltf) + expect(recoveredResults.find((poi) => poi.id === 'hall-space-theater')?.positionGltf) .toEqual([5, 12, 5]) expect(getFloorPois).toHaveBeenCalledTimes(4) })