Compare commits

...

2 Commits

Author SHA1 Message Date
cxk
b90c58451c 修复首页快捷入口与三维点位同步
Some checks failed
CI / verify (push) Has been cancelled
2026-07-21 00:40:06 +08:00
cxk
1f89e8b3e0 修复导览点位可见性与构建门禁 2026-07-20 23:54:19 +08:00
21 changed files with 756 additions and 184 deletions

View File

@@ -32,7 +32,7 @@ jobs:
run: pnpm lint run: pnpm lint
- name: Build H5 - name: Build H5
run: pnpm build:h5 run: pnpm build:test:h5
- name: Build WeChat Mini Program - name: Build WeChat Mini Program
run: pnpm build:mp-weixin run: pnpm build:mp-weixin

View File

@@ -0,0 +1,61 @@
const { loadEnv } = require('vite')
const tencentMapKeyPlaceholder = '__REPLACE_WITH_TENCENT_MAP_WEB_KEY__'
const tencentMapKeyPattern = /^[A-Z0-9]{5}(?:-[A-Z0-9]{5}){4,6}$/i
const isUsableTencentMapKey = (value) => (
tencentMapKeyPattern.test(value)
&& value !== tencentMapKeyPlaceholder
)
const resolveH5BuildPolicy = ({
args = [],
projectRoot,
environment = process.env,
loadProductionEnv = (root) => loadEnv('production', root, '')
}) => {
const flags = new Set(args)
const requireTencentMapKey = flags.has('--require-tencent-map-key')
const allowPlaceholder = flags.has('--allow-placeholder')
if (requireTencentMapKey && allowPlaceholder) {
throw new Error('H5 build cannot require a Tencent map key and allow its placeholder at the same time.')
}
if (!requireTencentMapKey) {
return {
allowPlaceholder,
requireTencentMapKey,
tencentMapKey: ''
}
}
const productionEnv = loadProductionEnv(projectRoot)
const tencentMapKey = (
environment.VITE_TENCENT_MAP_KEY
|| productionEnv.VITE_TENCENT_MAP_KEY
|| ''
).trim()
if (!isUsableTencentMapKey(tencentMapKey)) {
throw new Error([
'Production H5 build requires a well-formed non-placeholder VITE_TENCENT_MAP_KEY.',
'Inject it through CI or an ignored .env.production.local file.'
].join(' '))
}
return {
allowPlaceholder,
requireTencentMapKey,
tencentMapKey
}
}
const isTextBuildFile = (filePath) => /\.(?:js|mjs|html|json|css|txt|svg|xml|map)$/i.test(filePath)
module.exports = {
isTextBuildFile,
isUsableTencentMapKey,
resolveH5BuildPolicy,
tencentMapKeyPlaceholder
}

View File

@@ -1,29 +1,36 @@
const fs = require('node:fs') const fs = require('node:fs')
const path = require('node:path') const path = require('node:path')
const { spawnSync } = require('node:child_process') const { spawnSync } = require('node:child_process')
const {
require('./copy-h5-nav-assets.cjs') isTextBuildFile,
resolveH5BuildPolicy,
tencentMapKeyPlaceholder
} = require('./finalize-h5-build-policy.cjs')
const projectRoot = path.resolve(__dirname, '..') const projectRoot = path.resolve(__dirname, '..')
const h5Root = path.join(projectRoot, 'dist', 'build', 'h5') const h5Root = path.join(projectRoot, 'dist', 'build', 'h5')
const placeholder = '__REPLACE_WITH_TENCENT_MAP_WEB_KEY__' const placeholder = tencentMapKeyPlaceholder
const badPatterns = [ const badPatterns = [
'http://1.92.206.90:9000', 'http://1.92.206.90:9000',
'1.92.206.90', '1.92.206.90',
'guide.whaoyue.com', 'guide.whaoyue.com',
placeholder placeholder
] ]
const args = new Set(process.argv.slice(2)) const {
const requireTencentMapKey = args.has('--require-tencent-map-key') allowPlaceholder,
const allowPlaceholder = args.has('--allow-placeholder') requireTencentMapKey,
const tencentMapKey = (process.env.VITE_TENCENT_MAP_KEY || '').trim() tencentMapKey
} = resolveH5BuildPolicy({
args: process.argv.slice(2),
projectRoot
})
require('./copy-h5-nav-assets.cjs')
if (!fs.existsSync(h5Root)) { if (!fs.existsSync(h5Root)) {
throw new Error(`H5 build output not found: ${h5Root}`) throw new Error(`H5 build output not found: ${h5Root}`)
} }
const isTextFile = (filePath) => /\.(?:js|mjs|html|json|css|txt|svg|xml)$/i.test(filePath)
const walkFiles = (dir, files = []) => { const walkFiles = (dir, files = []) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const filePath = path.join(dir, entry.name) const filePath = path.join(dir, entry.name)
@@ -42,16 +49,21 @@ const walkFiles = (dir, files = []) => {
} }
const allFiles = walkFiles(h5Root) const allFiles = walkFiles(h5Root)
const textFiles = allFiles.filter(isTextFile) const textFiles = allFiles.filter(isTextBuildFile)
const placeholderFiles = textFiles.filter((file) => fs.readFileSync(file, 'utf8').includes(placeholder)) const placeholderFiles = textFiles.filter((file) => fs.readFileSync(file, 'utf8').includes(placeholder))
const hasUsableTencentMapKey = tencentMapKey.length > 0 && tencentMapKey !== placeholder const hasUsableTencentMapKey = tencentMapKey.length > 0
const missingTencentMapKeyMessage = [
'Production H5 build requires a well-formed non-placeholder VITE_TENCENT_MAP_KEY.',
'Inject it through CI or an ignored .env.production.local file.'
].join(' ')
if (requireTencentMapKey && !hasUsableTencentMapKey) {
throw new Error(missingTencentMapKeyMessage)
}
if (placeholderFiles.length > 0) { if (placeholderFiles.length > 0) {
if (!hasUsableTencentMapKey) { if (!hasUsableTencentMapKey) {
const message = [ const message = `H5 build output still contains Tencent map key placeholder. ${missingTencentMapKeyMessage}`
'H5 build output still contains Tencent map key placeholder.',
'Please set VITE_TENCENT_MAP_KEY before production build.'
].join(' ')
if (requireTencentMapKey && !allowPlaceholder) { if (requireTencentMapKey && !allowPlaceholder) {
throw new Error(message) throw new Error(message)
@@ -108,8 +120,8 @@ for (const file of textFiles) {
} }
if (badMatches.length > 0) { if (badMatches.length > 0) {
if (allowPlaceholder && badMatches.every((item) => item.pattern === placeholder)) { if (allowPlaceholder) {
console.warn('[finalize-h5-build] Tencent map key placeholder is allowed for this build.') console.warn('[finalize-h5-build] Test H5 build allows non-production source hosts and the Tencent map key placeholder.')
} else { } else {
console.error(JSON.stringify(badMatches.slice(0, 20), null, 2)) console.error(JSON.stringify(badMatches.slice(0, 20), null, 2))
throw new Error('H5 build output contains forbidden test host, old domain, or placeholder.') throw new Error('H5 build output contains forbidden test host, old domain, or placeholder.')

View File

@@ -583,17 +583,21 @@ const markFloorSwitchFailedIfUnrendered = (floorId: string, requestSeq: number)
}) })
} }
const handleFloorChange = (floor: { id: string; label: string }) => { const requestFloorSwitch = (
floor: { id: string; label: string },
options: { force?: boolean } = {}
) => {
const floorId = floor.id const floorId = floor.id
if (!floorId || loadingFloorId.value) return if (!floorId) return Promise.resolve()
if (loadingFloorId.value === floorId) return Promise.resolve()
if ( if (
floorId === renderedFloorId.value !options.force && floorId === renderedFloorId.value
&& activeFloorId.value === floorId && activeFloorId.value === floorId
&& props.indoorView === 'floor' && props.indoorView === 'floor'
&& props.layerMode !== 'multi' && props.layerMode !== 'multi'
) { ) {
emit('floorChange', floorId) emit('floorChange', floorId)
return return Promise.resolve()
} }
requestedFloorId.value = floorId requestedFloorId.value = floorId
@@ -607,7 +611,7 @@ const handleFloorChange = (floor: { id: string; label: string }) => {
floorId, floorId,
floorLabel: floor.label floorLabel: floor.label
}) })
Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId)) return Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId))
.then(() => { .then(() => {
markFloorSwitchFailedIfUnrendered(floorId, requestSeq) markFloorSwitchFailedIfUnrendered(floorId, requestSeq)
}) })
@@ -623,6 +627,10 @@ const handleFloorChange = (floor: { id: string; label: string }) => {
}) })
} }
const handleFloorChange = (floor: { id: string; label: string }) => {
void requestFloorSwitch(floor)
}
const handleLayerModeChange = (mode: LayerDisplayMode) => { const handleLayerModeChange = (mode: LayerDisplayMode) => {
// 手动切换展示层数时使用统一的短保护期。 // 手动切换展示层数时使用统一的短保护期。
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs) indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs)
@@ -789,7 +797,11 @@ defineExpose({
indoorRendererRef.value?.clearRoute?.() indoorRendererRef.value?.clearRoute?.()
}, },
// 仅发起切换;父级必须以 floor-change 作为已提交的唯一依据。 // 仅发起切换;父级必须以 floor-change 作为已提交的唯一依据。
switchFloor: (floorId: string) => indoorRendererRef.value?.switchFloor?.(floorId), switchFloor: (floorId: string) => {
const floor = findFloorItemById(floorId)
if (!floor) return Promise.resolve()
return requestFloorSwitch(floor, { force: true })
},
showOverview: handleShowOverview, showOverview: handleShowOverview,
resetToViewBaseline: (options: { resetToViewBaseline: (options: {
view: 'overview' | 'floor' view: 'overview' | 'floor'

View File

@@ -248,6 +248,7 @@ import type {
PoiCategoryResultState, PoiCategoryResultState,
PoiSearchContext PoiSearchContext
} from '@/domain/poiSearch' } from '@/domain/poiSearch'
import { nextHomeSearchResultVersion } from './homeSearchResultVersion'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment' import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
@@ -397,7 +398,8 @@ const emitResultsState = () => {
emit('results-change', { emit('results-change', {
...context, ...context,
visiblePoiIds: active ? context.visiblePoiIds : [], visiblePoiIds: active ? context.visiblePoiIds : [],
active active,
requestId: props.variant === 'home' ? nextHomeSearchResultVersion() : undefined
}) })
} }
@@ -411,7 +413,8 @@ const emitPendingHomeSearchResults = (floor?: Pick<MuseumFloor, 'id' | 'label'>)
floorLabel: floor?.label || context.floorLabel, floorLabel: floor?.label || context.floorLabel,
visiblePoiIds: [], visiblePoiIds: [],
active: true, active: true,
pending: true pending: true,
requestId: nextHomeSearchResultVersion()
}) })
} }

View File

@@ -0,0 +1,7 @@
// This version survives a PoiSearchPanel remount, unlike its local request sequence.
let latestHomeSearchResultVersion = 0
export const nextHomeSearchResultVersion = () => {
latestHomeSearchResultVersion += 1
return latestHomeSearchResultVersion
}

View File

@@ -10,6 +10,7 @@ import {
isIndoorNavigableFloor isIndoorNavigableFloor
} from '@/domain/guideFloor' } from '@/domain/guideFloor'
import { import {
isVisitorRestrictedPlaceName,
normalizePoiSemanticValue normalizePoiSemanticValue
} from '@/domain/poiCategories' } from '@/domain/poiCategories'
@@ -193,6 +194,16 @@ const isPoiAccessible = (poi: StaticNavPoiPayload) => (
|| poi.categories?.some((category) => category.topCategory === 'accessibility_special_service') === true || poi.categories?.some((category) => category.topCategory === 'accessibility_special_service') === true
) )
const resolveStaticPoiVisitorVisible = (
poi: StaticNavPoiPayload,
semanticType: string
) => (
typeof poi.visitorVisible === 'boolean'
? poi.visitorVisible
: semanticType !== 'service_space'
&& ![poi.name, poi.sourceObjectName].some(isVisitorRestrictedPlaceName)
)
export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => { export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => {
const categoryFallbackIconType = poi.categories?.[0]?.iconType const categoryFallbackIconType = poi.categories?.[0]?.iconType
const iconType = getStaticPoiSemanticType(poi, poi.iconType || categoryFallbackIconType) const iconType = getStaticPoiSemanticType(poi, poi.iconType || categoryFallbackIconType)
@@ -214,6 +225,7 @@ export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => {
sourceObjectName: poi.sourceObjectName, sourceObjectName: poi.sourceObjectName,
sourceConfidence: poi.sourceConfidence, sourceConfidence: poi.sourceConfidence,
navigationReadiness: poi.navigationReadiness, navigationReadiness: poi.navigationReadiness,
visitorVisible: resolveStaticPoiVisitorVisible(poi, iconType),
accessible: isPoiAccessible(poi), accessible: isPoiAccessible(poi),
kind, kind,
hallName: kind === 'hall' ? poi.name : undefined hallName: kind === 'hall' ? poi.name : undefined

View File

@@ -19,6 +19,7 @@ import {
isIndoorNavigableFloor isIndoorNavigableFloor
} from '@/domain/guideFloor' } from '@/domain/guideFloor'
import { import {
isVisitorRestrictedPlaceName,
isPoiSearchCategorySupported, isPoiSearchCategorySupported,
normalizePoiSemanticValue normalizePoiSemanticValue
} from '@/domain/poiCategories' } from '@/domain/poiCategories'
@@ -146,6 +147,7 @@ const spaceCategoryBySgsType: Record<string, MuseumCategory> = {
const categoryBySgsType: Record<string, MuseumCategory & { accessible?: boolean }> = { const categoryBySgsType: Record<string, MuseumCategory & { accessible?: boolean }> = {
exhibition_hall: hallCategory, exhibition_hall: hallCategory,
theater: spaceCategoryBySgsType.theater, theater: spaceCategoryBySgsType.theater,
service_space: spaceCategoryBySgsType.service_space,
commercial: spaceCategoryBySgsType.commercial, commercial: spaceCategoryBySgsType.commercial,
restaurant: spaceCategoryBySgsType.restaurant, restaurant: spaceCategoryBySgsType.restaurant,
cafe: spaceCategoryBySgsType.cafe, cafe: spaceCategoryBySgsType.cafe,
@@ -414,6 +416,25 @@ const categoryForBusinessType = (businessType?: string | null): MuseumCategory =
} }
} }
const resolveSgsPoiVisitorVisible = (
source: {
visitorVisible?: boolean | null
name?: string | null
sourceNodeName?: string | null
anchorNodeName?: string | null
},
category: MuseumCategory
) => (
typeof source.visitorVisible === 'boolean'
? source.visitorVisible
: category.id !== 'space_service'
&& ![
source.name,
source.sourceNodeName,
source.anchorNodeName
].some(isVisitorRestrictedPlaceName)
)
interface SgsHallPoiBuildOptions { interface SgsHallPoiBuildOptions {
fallbackY?: number fallbackY?: number
} }
@@ -658,9 +679,10 @@ export const toMuseumPoiFromSgs = (
} }
], ],
positionGltf: normalizePosition(poi), positionGltf: normalizePosition(poi),
sourceObjectName: poi.anchorNodeName || undefined, sourceObjectName: poi.anchorNodeName || undefined,
sourceConfidence: 'backend-sgs-sdk', sourceConfidence: 'backend-sgs-sdk',
navigationReadiness: '位置预览', navigationReadiness: '位置预览',
visitorVisible: resolveSgsPoiVisitorVisible(poi, category),
accessible: category.accessible === true, accessible: category.accessible === true,
kind, kind,
hallId: kind === 'hall' ? spatialAreaId || stringifyId(poi.id) : undefined, hallId: kind === 'hall' ? spatialAreaId || stringifyId(poi.id) : undefined,
@@ -825,6 +847,7 @@ export const toMuseumSpacePointFromSgs = (
sourceObjectName: space.sourceNodeName || undefined, sourceObjectName: space.sourceNodeName || undefined,
sourceConfidence: spacePosition.sourceConfidence, sourceConfidence: spacePosition.sourceConfidence,
navigationReadiness: '位置预览', navigationReadiness: '位置预览',
visitorVisible: resolveSgsPoiVisitorVisible(space, category),
accessible: false, accessible: false,
kind: 'space', kind: 'space',
hallId: category.id === hallCategory.id ? spaceId : undefined, hallId: category.id === hallCategory.id ? spaceId : undefined,
@@ -895,8 +918,9 @@ const createSpaceFallbackHallPoi = (
positionGltf: position, positionGltf: position,
sourceObjectName: space.sourceNodeName || undefined, sourceObjectName: space.sourceNodeName || undefined,
sourceConfidence: spacePosition.sourceConfidence, sourceConfidence: spacePosition.sourceConfidence,
navigationReadiness: '位置预览', navigationReadiness: '位置预览',
accessible: false, visitorVisible: resolveSgsPoiVisitorVisible(space, category),
accessible: false,
kind: 'hall', kind: 'hall',
hallId, hallId,
hallName: normalizedText(space.name), hallName: normalizedText(space.name),
@@ -983,6 +1007,7 @@ export const toMuseumHallPoisFromSgs = (
? spacePosition?.sourceConfidence || sgsSpaceCenterConfidence ? spacePosition?.sourceConfidence || sgsSpaceCenterConfidence
: sgsHallEntranceConfidence, : sgsHallEntranceConfidence,
navigationReadiness: '位置预览', navigationReadiness: '位置预览',
visitorVisible: resolveSgsPoiVisitorVisible(matchedSpace, category),
accessible: false, accessible: false,
kind: 'hall', kind: 'hall',
hallId, hallId,

View File

@@ -65,6 +65,7 @@ export interface SgsPoiPayload {
y?: number | null y?: number | null
z?: number | null z?: number | null
status?: string | null status?: string | null
visitorVisible?: boolean | null
anchorNodeName?: string | null anchorNodeName?: string | null
description?: string | null description?: string | null
iconUrl?: string | null iconUrl?: string | null
@@ -87,6 +88,7 @@ export interface SgsSpacePayload {
center?: SgsPositionPayload | null center?: SgsPositionPayload | null
sourceNodeName?: string | null sourceNodeName?: string | null
status?: string | null status?: string | null
visitorVisible?: boolean | null
colorHex?: string | null colorHex?: string | null
} }

View File

@@ -27,6 +27,7 @@ export interface StaticNavPoiPayload {
sourceObjectName?: string sourceObjectName?: string
navigationReadiness?: string navigationReadiness?: string
sourceConfidence?: string sourceConfidence?: string
visitorVisible?: boolean | null
} }
export interface StaticNavManifestFloorModelPayload { export interface StaticNavManifestFloorModelPayload {

View File

@@ -96,6 +96,8 @@ export interface MuseumPoi {
sourceObjectName?: string sourceObjectName?: string
sourceConfidence?: string sourceConfidence?: string
navigationReadiness?: string navigationReadiness?: string
/** Explicit visitor-search eligibility supplied by the source or adapter policy. */
visitorVisible?: boolean
accessible: boolean accessible: boolean
kind?: MuseumPoiKind kind?: MuseumPoiKind
hallId?: string hallId?: string

View File

@@ -183,6 +183,7 @@ export type PoiCategorySource = Pick<
| 'sourcePlaceId' | 'sourcePlaceId'
| 'sourceSpaceId' | 'sourceSpaceId'
| 'sourceObjectName' | 'sourceObjectName'
| 'visitorVisible'
> >
const normalizeValue = (value?: string | null) => (value || '') const normalizeValue = (value?: string | null) => (value || '')
@@ -192,6 +193,13 @@ const normalizeValue = (value?: string | null) => (value || '')
.replace(/[\s-]+/g, '_') .replace(/[\s-]+/g, '_')
.replace(/^_+|_+$/g, '') .replace(/^_+|_+$/g, '')
const visitorRestrictedPlaceNamePattern = /(?:贵宾|vip|员工|职工|后勤|办公|行政|库房|仓库|机房|设备间|配电|弱电|强电|保洁|值班|消防控制|监控室|staff|employee|back[_ -]?of[_ -]?house|maintenance)/i
/** Legacy sources without an explicit flag must not expose staff-only places. */
export const isVisitorRestrictedPlaceName = (value?: string | null) => (
visitorRestrictedPlaceNamePattern.test(normalizeValue(value))
)
const poiSemanticAliases: Readonly<Record<string, string>> = { const poiSemanticAliases: Readonly<Record<string, string>> = {
exhibition: 'exhibition_hall', exhibition: 'exhibition_hall',
exhibition_hall: 'exhibition_hall', exhibition_hall: 'exhibition_hall',
@@ -397,7 +405,7 @@ export const resolvePoiCategory = (poi: PoiCategorySource) => (
POI_CATEGORIES.find((category) => matchesPoiCategory(poi, category)) || null POI_CATEGORIES.find((category) => matchesPoiCategory(poi, category)) || null
) )
const hiddenVisitorPrimaryTypes = new Set([ const hiddenVisitorTypes = new Set([
'entrance_exit', 'entrance_exit',
'hall_entrance', 'hall_entrance',
'entrance_anchor', 'entrance_anchor',
@@ -406,7 +414,7 @@ const hiddenVisitorPrimaryTypes = new Set([
'operation_experience' 'operation_experience'
]) ])
const primaryTypeValues = (poi: PoiCategorySource) => [ const visitorTypeValues = (poi: PoiCategorySource) => [
poi.primaryCategory.id, poi.primaryCategory.id,
poi.primaryCategory.label, poi.primaryCategory.label,
poi.primaryCategory.iconType || '' poi.primaryCategory.iconType || ''
@@ -414,10 +422,18 @@ const primaryTypeValues = (poi: PoiCategorySource) => [
.map(normalizePoiSemanticValue) .map(normalizePoiSemanticValue)
.filter(Boolean) .filter(Boolean)
const isHiddenVisitorType = (value: string) => {
const normalizedValue = normalizePoiSemanticValue(value)
const sourceWrappedValue = normalizedValue.replace(/^(?:space|poi|facility|business)_/, '')
return hiddenVisitorTypes.has(normalizedValue)
|| hiddenVisitorTypes.has(sourceWrappedValue)
}
/** A visitor result must never be a guide point, door, entrance anchor, or route node. */ /** A visitor result must never be a guide point, door, entrance anchor, or route node. */
export const isVisitorSearchPoi = (poi: PoiCategorySource) => { export const isVisitorSearchPoi = (poi: PoiCategorySource) => {
if (poi.visitorVisible === false) return false
if (poi.kind === 'guide' || poi.kind === 'hall_entrance') return false if (poi.kind === 'guide' || poi.kind === 'hall_entrance') return false
return !primaryTypeValues(poi).some((value) => hiddenVisitorPrimaryTypes.has(value)) return !visitorTypeValues(poi).some(isHiddenVisitorType)
} }
/** Default floor browse only contains canonical halls and valid destination spaces. */ /** Default floor browse only contains canonical halls and valid destination spaces. */
@@ -516,8 +532,6 @@ export const getPoiDataIssues = (poi: MuseumPoi): PoiDataIssue[] => {
} else if (!hasFinitePosition(poi.positionGltf)) { } else if (!hasFinitePosition(poi.positionGltf)) {
issues.push({ code: 'invalid-position', ...issueBase }) issues.push({ code: 'invalid-position', ...issueBase })
} }
if (!isPoiSearchCategorySupported(poi)) issues.push({ code: 'unsupported-category', ...issueBase })
return issues return issues
} }
@@ -548,6 +562,6 @@ export const warnPoiCollectionIssues = (source: string, pois: MuseumPoi[]) => {
const inspection = inspectPoiCollection(pois) const inspection = inspectPoiCollection(pois)
if (!inspection.issues.length && !inspection.duplicateIds.length) return if (!inspection.issues.length && !inspection.duplicateIds.length) return
// 开发期集中输出数据契约问题,避免在页面组件中散落源数据校验。 // This is an aggregate development diagnostic, not a visitor-facing failure.
console.warn(`[POI 数据校验] ${source}`, inspection) console.debug(`[POI 数据诊断] ${source}`, inspection)
} }

View File

@@ -28,6 +28,8 @@ export interface PoiSearchSelection {
export interface PoiCategoryResultState extends PoiSearchContext { export interface PoiCategoryResultState extends PoiSearchContext {
active: boolean active: boolean
pending?: boolean pending?: boolean
/** Monotonic home-result event version used to reject stale cross-component state. */
requestId?: number
} }
export type GuidePoiSearchMode = 'default' | 'category' | 'keyword' export type GuidePoiSearchMode = 'default' | 'category' | 'keyword'

View File

@@ -373,6 +373,8 @@ const requestedFloorId = ref('')
const requestedFloorLabel = ref('') const requestedFloorLabel = ref('')
const loadingFloorId = ref('') const loadingFloorId = ref('')
const renderedFloorId = ref('') const renderedFloorId = ref('')
// Only GuideMapShell's floor-change confirms that the Three.js floor is usable.
const committedFloorId = ref('')
const failedFloorId = ref('') const failedFloorId = ref('')
const selectedGuidePoi = ref<GuideRenderPoi | null>(null) const selectedGuidePoi = ref<GuideRenderPoi | null>(null)
const selectedGuidePoiDetailTarget = ref<PoiDetailTarget | null>(null) const selectedGuidePoiDetailTarget = ref<PoiDetailTarget | null>(null)
@@ -432,6 +434,8 @@ const homeSearchExpanded = ref(false)
const homeCategoryModeActive = ref(false) const homeCategoryModeActive = ref(false)
const searchVisiblePoiIds = ref<string[] | null>(null) const searchVisiblePoiIds = ref<string[] | null>(null)
const searchTargetFocusRequest = ref<TargetPoiFocusRequestViewModel | null>(null) const searchTargetFocusRequest = ref<TargetPoiFocusRequestViewModel | null>(null)
const homeSearchMapState = ref<PoiCategoryResultState | null>(null)
let latestHomeSearchMapRequestId = 0
type PoiSearchOverlayHistoryState = { type PoiSearchOverlayHistoryState = {
museumGuideOverlay?: 'poi-search' museumGuideOverlay?: 'poi-search'
[key: string]: unknown [key: string]: unknown
@@ -853,6 +857,9 @@ const handleTabChange = (tabId: GuideTopTab) => {
} }
} }
if (tabId !== currentTab.value) {
invalidateHomeSearchFloorCommit()
}
currentTab.value = tabId currentTab.value = tabId
syncTopTabToUrl(tabId) syncTopTabToUrl(tabId)
loadTopTabData(tabId) loadTopTabData(tabId)
@@ -875,6 +882,7 @@ const syncTopTabFromHash = () => {
currentTab.value = tab currentTab.value = tab
loadTopTabData(tab) loadTopTabData(tab)
if (tabChanged) { if (tabChanged) {
invalidateHomeSearchFloorCommit()
showRoutePlanner.value = false showRoutePlanner.value = false
isSimulatingRoute.value = false isSimulatingRoute.value = false
isPoiCardCollapsed.value = false isPoiCardCollapsed.value = false
@@ -1000,11 +1008,19 @@ const handleFloorRequest = ({ floorId, floorLabel }: { floorId: string; floorLab
requestedFloorLabel.value = floorLabel requestedFloorLabel.value = floorLabel
loadingFloorId.value = floorId loadingFloorId.value = floorId
failedFloorId.value = '' failedFloorId.value = ''
if (homeSearchMapState.value?.active) {
searchVisiblePoiIds.value = []
}
} }
const handleFloorChange = (floorId: string) => { const handleFloorChange = (floorId: string) => {
activeGuideFloor.value = floorId activeGuideFloor.value = floorId
renderedFloorId.value = floorId renderedFloorId.value = floorId
committedFloorId.value = floorId
if (requestedFloorId.value === floorId) {
requestedFloorId.value = ''
requestedFloorLabel.value = ''
}
if (loadingFloorId.value === floorId) { if (loadingFloorId.value === floorId) {
loadingFloorId.value = '' loadingFloorId.value = ''
} }
@@ -1014,6 +1030,7 @@ const handleFloorChange = (floorId: string) => {
indoorView.value = 'floor' indoorView.value = 'floor'
selectedGuidePoi.value = null selectedGuidePoi.value = null
isPoiCardCollapsed.value = false isPoiCardCollapsed.value = false
syncHomeSearchMarkersForCommittedFloor(floorId)
showIndoorHint(`已切换到 ${getGuideFloorLabel(floorId)},单指平移、双指可缩放`, 3200) showIndoorHint(`已切换到 ${getGuideFloorLabel(floorId)},单指平移、双指可缩放`, 3200)
console.log('楼层渲染完成:', floorId) console.log('楼层渲染完成:', floorId)
} }
@@ -1023,6 +1040,13 @@ const handleFloorSwitchFailed = ({ floorId, floorLabel }: { floorId: string; flo
loadingFloorId.value = '' loadingFloorId.value = ''
} }
failedFloorId.value = floorId failedFloorId.value = floorId
if (requestedFloorId.value === floorId) {
requestedFloorId.value = ''
requestedFloorLabel.value = ''
}
if (homeSearchMapState.value?.floorId === floorId) {
searchVisiblePoiIds.value = []
}
showIndoorHint(`${floorLabel || getGuideFloorLabel(floorId)} 加载失败,请稍后重试`, 3600) showIndoorHint(`${floorLabel || getGuideFloorLabel(floorId)} 加载失败,请稍后重试`, 3600)
console.warn('楼层渲染失败:', floorId) console.warn('楼层渲染失败:', floorId)
} }
@@ -1030,6 +1054,8 @@ const handleFloorSwitchFailed = ({ floorId, floorLabel }: { floorId: string; flo
const handleIndoorViewChange = (view: GuideIndoorView) => { const handleIndoorViewChange = (view: GuideIndoorView) => {
indoorView.value = view indoorView.value = view
if (view !== 'floor') { if (view !== 'floor') {
committedFloorId.value = ''
syncHomeSearchMarkersForCommittedFloor()
selectedGuidePoi.value = null selectedGuidePoi.value = null
isPoiCardCollapsed.value = false isPoiCardCollapsed.value = false
} }
@@ -1045,6 +1071,8 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
console.log('自动切换视图:', event) console.log('自动切换视图:', event)
indoorView.value = event.to indoorView.value = event.to
if (event.to !== 'floor') { if (event.to !== 'floor') {
committedFloorId.value = ''
syncHomeSearchMarkersForCommittedFloor()
selectedGuidePoi.value = null selectedGuidePoi.value = null
isPoiCardCollapsed.value = false isPoiCardCollapsed.value = false
} }
@@ -1053,6 +1081,11 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
const handleInitialModelReady = (event: { view: GuideIndoorView; floorId?: string; elapsedMs?: number }) => { const handleInitialModelReady = (event: { view: GuideIndoorView; floorId?: string; elapsedMs?: number }) => {
const readyFloorId = event.floorId || activeGuideFloor.value const readyFloorId = event.floorId || activeGuideFloor.value
indoorView.value = event.view
if (event.view !== 'floor') {
committedFloorId.value = ''
syncHomeSearchMarkersForCommittedFloor()
}
if (event.view === 'overview' && readyFloorId) { if (event.view === 'overview' && readyFloorId) {
activeGuideFloor.value = readyFloorId activeGuideFloor.value = readyFloorId
renderedFloorId.value = readyFloorId renderedFloorId.value = readyFloorId
@@ -1092,6 +1125,7 @@ const handleGuidePoiClick = (poi: GuideRenderPoi) => {
isSimulatingRoute.value = false isSimulatingRoute.value = false
activeGuideFloor.value = poi.floorId activeGuideFloor.value = poi.floorId
renderedFloorId.value = poi.floorId renderedFloorId.value = poi.floorId
committedFloorId.value = poi.floorId
showIndoorHint(`${poi.name} · ${getGuideFloorLabel(poi.floorId)}`, 3000) showIndoorHint(`${poi.name} · ${getGuideFloorLabel(poi.floorId)}`, 3000)
} }
@@ -1519,6 +1553,7 @@ const resetGuideModelToFloorBaseline = async ({
if (!guideModelState.isCurrentRequest(resetRequest.requestId)) return if (!guideModelState.isCurrentRequest(resetRequest.requestId)) return
if (resetResult === 'applied') { if (resetResult === 'applied') {
renderedFloorId.value = activeGuideFloor.value renderedFloorId.value = activeGuideFloor.value
committedFloorId.value = activeGuideFloor.value
guideModelState.completeViewBaselineReset(resetRequest) guideModelState.completeViewBaselineReset(resetRequest)
console.info('馆内三维模型已恢复楼层视觉基线:', { rendererReason }) console.info('馆内三维模型已恢复楼层视觉基线:', { rendererReason })
} else if (resetResult === 'not-ready' || resetResult === undefined) { } else if (resetResult === 'not-ready' || resetResult === undefined) {
@@ -1589,12 +1624,20 @@ const clearHomeSearchMapState = () => {
homeCategoryModeActive.value = false homeCategoryModeActive.value = false
searchVisiblePoiIds.value = null searchVisiblePoiIds.value = null
searchTargetFocusRequest.value = null searchTargetFocusRequest.value = null
homeSearchMapState.value = null
}
const invalidateHomeSearchFloorCommit = () => {
clearHomeSearchMapState()
committedFloorId.value = ''
indoorView.value = 'overview'
} }
const handleHomeCategoryModeChange = (active: boolean) => { const handleHomeCategoryModeChange = (active: boolean) => {
homeCategoryModeActive.value = active homeCategoryModeActive.value = active
if (!active) { if (!active) {
searchVisiblePoiIds.value = null searchVisiblePoiIds.value = null
homeSearchMapState.value = null
return return
} }
@@ -1604,19 +1647,72 @@ const handleHomeCategoryModeChange = (active: boolean) => {
selectedGuidePoi.value = null selectedGuidePoi.value = null
isPoiCardCollapsed.value = false isPoiCardCollapsed.value = false
is3DMode.value = true is3DMode.value = true
indoorView.value = 'floor' searchVisiblePoiIds.value = []
} }
const handleHomeSearchResultsChange = (state: PoiCategoryResultState) => { const isHomeSearchFloorCommitted = (floorId: string) => (
if (!state.active) { Boolean(floorId)
searchVisiblePoiIds.value = null && committedFloorId.value === floorId
&& indoorView.value === 'floor'
)
const requestHomeSearchFloor = (floorId: string, floorLabel = '') => {
if (!floorId || requestedFloorId.value === floorId || loadingFloorId.value === floorId) return
requestedFloorId.value = floorId
requestedFloorLabel.value = floorLabel || getGuideFloorLabel(floorId)
loadingFloorId.value = floorId
failedFloorId.value = ''
void Promise.resolve(guideMapShellRef.value?.switchFloor?.(floorId))
.catch((error) => {
console.error('快捷入口楼层切换失败:', error)
})
}
const syncHomeSearchMarkersForCommittedFloor = (floorId = committedFloorId.value) => {
const state = homeSearchMapState.value
if (
!state?.active
|| state.pending
|| state.floorId !== floorId
|| indoorView.value !== 'floor'
) {
searchVisiblePoiIds.value = state?.active ? [] : null
return return
} }
searchVisiblePoiIds.value = [...state.visiblePoiIds] searchVisiblePoiIds.value = [...state.visiblePoiIds]
if (state.floorId && !state.pending) { }
activeGuideFloor.value = state.floorId
const handleHomeSearchResultsChange = (state: PoiCategoryResultState) => {
if (
typeof state.requestId === 'number'
&& state.requestId < latestHomeSearchMapRequestId
) return
if (typeof state.requestId === 'number') {
latestHomeSearchMapRequestId = state.requestId
} }
if (!state.active) {
clearHomeSearchMapState()
return
}
homeSearchMapState.value = {
...state,
visiblePoiIds: [...state.visiblePoiIds]
}
if (state.pending) {
searchVisiblePoiIds.value = []
}
if (!isHomeSearchFloorCommitted(state.floorId)) {
searchVisiblePoiIds.value = []
requestHomeSearchFloor(state.floorId, state.floorLabel)
return
}
syncHomeSearchMarkersForCommittedFloor(state.floorId)
} }
const handleHomeSearchResultTap = async (poi: MuseumPoi, context: PoiSearchContext) => { const handleHomeSearchResultTap = async (poi: MuseumPoi, context: PoiSearchContext) => {
@@ -1672,6 +1768,7 @@ const handleExplainHallClick = async (hallId: string) => {
} }
const handleExplainBack = () => { const handleExplainBack = () => {
invalidateHomeSearchFloorCommit()
currentTab.value = 'guide' currentTab.value = 'guide'
syncTopTabToUrl('guide') syncTopTabToUrl('guide')
loadTopTabData('guide') loadTopTabData('guide')

View File

@@ -22,10 +22,7 @@ import {
} from '@/domain/guideFloor' } from '@/domain/guideFloor'
import { import {
buildSgsFloorAliases, buildSgsFloorAliases,
createSgsHallPoiDiagnostics, formatSgsFloorLabel
formatSgsFloorLabel,
toMuseumHallPoisFromSgs,
toMuseumPoiFromSgs
} from '@/data/adapters/sgsSdkGuideAdapter' } from '@/data/adapters/sgsSdkGuideAdapter'
import { import {
defaultSgsSdkApiProvider, defaultSgsSdkApiProvider,
@@ -47,6 +44,9 @@ import {
import type { import type {
GuideRepository GuideRepository
} from '@/repositories/GuideRepository' } from '@/repositories/GuideRepository'
import {
canonicalizeVisitorSearchPois
} from '@/repositories/GuideRepository'
import { import {
guideRepository guideRepository
} from '@/repositories/createGuideRepository' } from '@/repositories/createGuideRepository'
@@ -84,37 +84,6 @@ const toGuideRenderPoi = (poi: MuseumPoi): GuideRenderPoi => ({
}) })
}) })
const toSgsRenderPoi = (poi: ReturnType<typeof toMuseumPoiFromSgs>) => toGuideRenderPoi(poi)
const getRenderPoiDedupeKeys = (poi: GuideRenderPoi) => {
// A facility can be located inside the same spatial area as other facilities.
// That relationship must not collapse distinct visitor markers (for example,
// a restroom and an elevator in one service zone). Space identity is only a
// canonical-place key for halls, spaces, and business-place representations.
const canDedupeBySpace = poi.kind === 'hall'
|| poi.kind === 'space'
|| poi.primaryCategory === 'business_poi'
const stableKeys = [
poi.id ? `id:${poi.id}` : '',
canDedupeBySpace && poi.sourceSpaceId ? `space:${poi.floorId}:${poi.sourceSpaceId}` : '',
canDedupeBySpace && 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<string>()
return pois.filter((poi) => {
const keys = getRenderPoiDedupeKeys(poi)
if (!keys.length || keys.some((key) => seen.has(key))) return false
keys.forEach((key) => seen.add(key))
return true
})
}
const countByValue = <T>( const countByValue = <T>(
items: T[], items: T[],
selector: (item: T) => string | number | null | undefined selector: (item: T) => string | number | null | undefined
@@ -124,17 +93,6 @@ const countByValue = <T>(
return counts return counts
}, {}) }, {})
const countDroppedRenderPoiCategories = (
sourcePois: GuideRenderPoi[],
keptPois: GuideRenderPoi[]
) => {
const keptIds = new Set(keptPois.map((poi) => poi.id))
return countByValue(
sourcePois.filter((poi) => !keptIds.has(poi.id)),
(poi) => poi.primaryCategory
)
}
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, '') const trimTrailingSlash = (value: string) => value.replace(/\/+$/, '')
const resolveSgsAssetUrl = (url?: string | null) => { const resolveSgsAssetUrl = (url?: string | null) => {
@@ -234,20 +192,6 @@ const summarizeYValues = (pois: GuideRenderPoi[]) => {
} }
} }
const getMedianPoiY = (pois: GuideRenderPoi[]) => {
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
? values[middle]
: (values[middle - 1] + values[middle]) / 2
}
const getSgsFloorModelMatchKeys = (floor: SgsSdkFloorSummaryPayload, label: string) => ( const getSgsFloorModelMatchKeys = (floor: SgsSdkFloorSummaryPayload, label: string) => (
[ [
String(floor.floorId), String(floor.floorId),
@@ -442,7 +386,7 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository {
const resolvedFloorId = String(matchedFloor.floorId) const resolvedFloorId = String(matchedFloor.floorId)
const loadFloorData = async <T>( const loadFloorData = async <T>(
endpoint: 'pois' | 'spaces' | 'navigablePlaces' | 'guidePois' | 'guideSpacePoints', endpoint: 'guideSearch',
loader: () => Promise<T[]> loader: () => Promise<T[]>
) => { ) => {
try { try {
@@ -457,88 +401,36 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository {
} }
} }
const [pois, spaces, navigablePlaces, guidePois, guideSpacePoints] = await Promise.all([ const visitorSearchPois = await loadFloorData(
loadFloorData('pois', () => this.provider.getFloorPois(resolvedFloorId)), 'guideSearch',
loadFloorData('spaces', () => this.provider.getFloorSpaces(resolvedFloorId)), () => this.guide.searchPois('', resolvedFloorId)
loadFloorData('navigablePlaces', () => this.provider.getNavigablePlaces(resolvedFloorId)), )
loadFloorData('guidePois', () => this.guide.listPois()), const canonicalPois = canonicalizeVisitorSearchPois(visitorSearchPois)
loadFloorData('guideSpacePoints', () => this.guide.listSpacePoints()) const renderPois = canonicalPois
]) .filter((poi) => poi.floorId === resolvedFloorId)
const ordinaryPois = pois
.map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
.filter(isVisitorSearchPoi)
.map(toSgsRenderPoi)
const repositoryBusinessPois = guidePois
.filter((poi) => (
poi.floorId === resolvedFloorId
&& poi.primaryCategory.id === 'business_poi'
&& isVisitorSearchPoi(poi)
))
.map(toGuideRenderPoi) .map(toGuideRenderPoi)
const repositorySpacePois = guideSpacePoints
.filter((poi) => poi.floorId === resolvedFloorId && isVisitorSearchPoi(poi))
.map(toGuideRenderPoi)
const floorPoiMedianY = getMedianPoiY(ordinaryPois)
const museumHallPois = toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, resolvedFloorId, {
fallbackY: floorPoiMedianY
})
const hallDiagnostics = createSgsHallPoiDiagnostics(spaces, navigablePlaces, museumHallPois)
if (hallDiagnostics.eligibleSpaceCount > 0 && hallDiagnostics.hallPoiWithPositionCount === 0) {
warnSgsGuideModelDiagnostics('eligible hall spaces produced no renderable hall POIs', {
floorId: resolvedFloorId,
...hallDiagnostics
})
}
const hallPois = museumHallPois
.filter(isVisitorSearchPoi)
.map(toSgsRenderPoi)
const adaptedPois = dedupeRenderPoisById([
...hallPois,
...ordinaryPois,
...repositorySpacePois,
...repositoryBusinessPois
])
const floorMatchedPois = adaptedPois.filter((poi) => poi.floorId === resolvedFloorId)
const renderPois = floorMatchedPois
.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3) .filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
const renderHallPois = renderPois.filter((poi) => poi.kind === 'hall' || poi.primaryCategory === 'exhibition_hall') const renderHallPois = renderPois.filter((poi) => poi.kind === 'hall' || poi.primaryCategory === 'exhibition_hall')
logSgsGuideModelDiagnostics('floor POI category diagnostics', { logSgsGuideModelDiagnostics('floor POI category diagnostics', {
floorId: resolvedFloorId, floorId: resolvedFloorId,
floorCode: matchedFloor.floorCode, floorCode: matchedFloor.floorCode,
rawPoiCount: pois.length, canonicalPoiCount: canonicalPois.length,
rawRepositoryBusinessPoiCount: repositoryBusinessPois.length, canonicalCategoryCounts: countByValue(canonicalPois, (poi) => poi.primaryCategory.id),
rawRepositorySpacePoiCount: repositorySpacePois.length, canonicalPoiCategoryCount: canonicalPois.filter((poi) => poi.primaryCategory.id === 'poi').length,
rawPoiTypeCounts: countByValue(pois, (poi) => poi.type),
rawPoiGroupCounts: countByValue(pois, (poi) => poi.poiGroup),
adaptedPoiCount: adaptedPois.length,
adaptedCategoryCounts: countByValue(adaptedPois, (poi) => poi.primaryCategory),
adaptedPoiCategoryCount: adaptedPois.filter((poi) => poi.primaryCategory === 'poi').length,
renderPoiCount: renderPois.length, renderPoiCount: renderPois.length,
renderPoiCategoryCount: renderPois.filter((poi) => poi.primaryCategory === 'poi').length, renderPoiCategoryCount: renderPois.filter((poi) => poi.primaryCategory === 'poi').length
repositoryDroppedCategoryCounts: countDroppedRenderPoiCategories(adaptedPois, renderPois)
}) })
warnSgsGuideModelDiagnostics('floor render POI diagnostics', { logSgsGuideModelDiagnostics('floor render POI diagnostics', {
floorId: resolvedFloorId, floorId: resolvedFloorId,
floorCode: matchedFloor.floorCode, floorCode: matchedFloor.floorCode,
poiCount: renderPois.length, poiCount: renderPois.length,
hallPoiCount: renderHallPois.length, hallPoiCount: renderHallPois.length,
hallPoiWithPositionCount: renderHallPois.filter((poi) => Boolean(poi.positionGltf)).length, hallPoiWithPositionCount: renderHallPois.filter((poi) => Boolean(poi.positionGltf)).length,
poiY: summarizeYValues(renderPois), poiY: summarizeYValues(renderPois),
hallPoiY: summarizeYValues(renderHallPois), hallPoiY: summarizeYValues(renderHallPois)
fallbackHallY: floorPoiMedianY ?? null
}) })
if (hallDiagnostics.hallPoiWithPositionCount > 0 && !renderHallPois.length) {
warnSgsGuideModelDiagnostics('render POI filter removed all hall POIs', {
floorId: resolvedFloorId,
...hallDiagnostics,
renderPoiCount: renderPois.length
})
}
return renderPois return renderPois
} }
} }

View File

@@ -159,6 +159,17 @@ const isVisitorSearchResult = (poi: MuseumPoi) => (
&& hasRenderablePosition(poi) && hasRenderablePosition(poi)
) )
// Explicitly hidden source records must still participate in canonical-place
// merging so a private space cannot leak through an unflagged POI duplicate.
const isCanonicalSearchCandidate = (poi: MuseumPoi) => (
isPoiOnIndoorNavigableFloor(poi)
&& hasRenderablePosition(poi)
&& isVisitorSearchPoi({
...poi,
visitorVisible: true
})
)
const normalizePlaceName = (value: string) => value const normalizePlaceName = (value: string) => value
.trim() .trim()
.normalize('NFKC') .normalize('NFKC')
@@ -188,6 +199,11 @@ const canonicalMergeKeys = (poi: MuseumPoi) => {
keys.push(`space:${poi.floorId}:${linkedSpace}:${categoryId || 'other'}`) keys.push(`space:${poi.floorId}:${linkedSpace}:${categoryId || 'other'}`)
} }
const sourcePlaceId = poi.sourcePlaceId?.trim()
if (sourcePlaceId) {
keys.push(`place:${poi.floorId}:${categoryId || 'other'}:${sourcePlaceId}`)
}
const normalizedName = normalizePlaceName(poi.name) const normalizedName = normalizePlaceName(poi.name)
const location = positionKey(poi) const location = positionKey(poi)
// A space, regular POI, and business POI can describe the same visitor // A space, regular POI, and business POI can describe the same visitor
@@ -246,7 +262,10 @@ const mergeCanonicalPoi = (left: MuseumPoi, right: MuseumPoi) => {
spaceId: primary.spaceId || secondary.spaceId, spaceId: primary.spaceId || secondary.spaceId,
sourceSpaceId: primary.sourceSpaceId || secondary.sourceSpaceId, sourceSpaceId: primary.sourceSpaceId || secondary.sourceSpaceId,
sourcePlaceId: primary.sourcePlaceId || secondary.sourcePlaceId, sourcePlaceId: primary.sourcePlaceId || secondary.sourcePlaceId,
entrances: primary.entrances || secondary.entrances entrances: primary.entrances || secondary.entrances,
visitorVisible: primary.visitorVisible === false || secondary.visitorVisible === false
? false
: primary.visitorVisible ?? secondary.visitorVisible
} }
} }
@@ -254,11 +273,11 @@ const mergeCanonicalPoi = (left: MuseumPoi, right: MuseumPoi) => {
* Search queries return a single map-renderable representation of each real * Search queries return a single map-renderable representation of each real
* visitor place. Source collections remain intact for the renderer itself. * visitor place. Source collections remain intact for the renderer itself.
*/ */
const mergeCanonicalSearchPois = (pois: MuseumPoi[]) => { export const canonicalizeVisitorSearchPois = (pois: MuseumPoi[]) => {
const canonicalByKey = new Map<string, MuseumPoi>() const canonicalByKey = new Map<string, MuseumPoi>()
pois pois
.filter(isVisitorSearchResult) .filter(isCanonicalSearchCandidate)
.forEach((poi) => { .forEach((poi) => {
const keys = canonicalMergeKeys(poi) const keys = canonicalMergeKeys(poi)
if (!keys.length) keys.push(`poi:${poi.id}`) if (!keys.length) keys.push(`poi:${poi.id}`)
@@ -282,10 +301,11 @@ const mergeCanonicalSearchPois = (pois: MuseumPoi[]) => {
}) })
return dedupePoisById(Array.from(canonicalByKey.values())) return dedupePoisById(Array.from(canonicalByKey.values()))
.filter(isVisitorSearchResult)
} }
const mergeSearchablePois = (pois: MuseumPoi[], spacePoints: MuseumPoi[]) => ( const mergeSearchablePois = (pois: MuseumPoi[], spacePoints: MuseumPoi[]) => (
mergeCanonicalSearchPois([ canonicalizeVisitorSearchPois([
...pois, ...pois,
...spacePoints ...spacePoints
]) ])
@@ -426,7 +446,7 @@ export class StaticGuideRepository implements GuideRepository {
this.listPois(), this.listPois(),
this.listSpacePoints() this.listSpacePoints()
]) ])
this.visitorSearchPoolCache = mergeCanonicalSearchPois([ this.visitorSearchPoolCache = canonicalizeVisitorSearchPois([
...pois, ...pois,
...spacePoints ...spacePoints
]) ])
@@ -470,8 +490,8 @@ export class StaticGuideRepository implements GuideRepository {
} }
async getPoiById(id: string) { async getPoiById(id: string) {
const pois = await this.listPois() const visitorPois = (await this.listPois()).filter(isVisitorSearchPoi)
return pois.find((poi) => poi.id === id) || findPoiByNavIdNameFallback(pois, id) return visitorPois.find((poi) => poi.id === id) || findPoiByNavIdNameFallback(visitorPois, id)
} }
async searchPois(keyword = '', floorId?: string) { async searchPois(keyword = '', floorId?: string) {
@@ -735,7 +755,7 @@ export class SgsSdkGuideRepository implements GuideRepository {
sourceFailures.push(error) sourceFailures.push(error)
} }
const searchPool = mergeCanonicalSearchPois([ const searchPool = canonicalizeVisitorSearchPois([
...pois, ...pois,
...spacePoints ...spacePoints
]) ])
@@ -796,6 +816,7 @@ export class SgsSdkGuideRepository implements GuideRepository {
]) ])
const direct = directPois const direct = directPois
.map((poi) => toMuseumPoiFromSgs(poi, manifest.floors)) .map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
.filter(isVisitorSearchPoi)
.find((poi) => poi.id === normalizedId || getPoiSourceLookupIdentity(normalizedId) === poi.spaceId || getPoiSourceLookupIdentity(normalizedId) === poi.sourceSpaceId) .find((poi) => poi.id === normalizedId || getPoiSourceLookupIdentity(normalizedId) === poi.spaceId || getPoiSourceLookupIdentity(normalizedId) === poi.sourceSpaceId)
if (direct) return direct if (direct) return direct
} catch (error) { } catch (error) {

View File

@@ -5,7 +5,11 @@ import {
} from '@/domain/poiCategories' } from '@/domain/poiCategories'
import type { SgsSdkApiProvider, SgsSdkManifestPayload } from '@/data/providers/sgsSdkApiProvider' import type { SgsSdkApiProvider, SgsSdkManifestPayload } from '@/data/providers/sgsSdkApiProvider'
import type { StaticNavAssetsProvider } from '@/data/providers/staticNavAssetsProvider' import type { StaticNavAssetsProvider } from '@/data/providers/staticNavAssetsProvider'
import { SgsSdkGuideRepository, StaticGuideRepository } from '@/repositories/GuideRepository' import {
canonicalizeVisitorSearchPois,
SgsSdkGuideRepository,
StaticGuideRepository
} from '@/repositories/GuideRepository'
import { import {
SgsSdkGuideModelRepository, SgsSdkGuideModelRepository,
StaticGuideModelRepository StaticGuideModelRepository
@@ -40,7 +44,17 @@ const floorSpaces = [
{ id: 'space-hall', name: '地球展厅', type: 'exhibition_hall', floorId: 'L1', center: { x: 1, y: 12, z: 1 } }, { id: 'space-hall', name: '地球展厅', type: 'exhibition_hall', floorId: 'L1', center: { x: 1, y: 12, z: 1 } },
{ id: 'space-cinema', name: '穹幕影院', type: 'theater', floorId: 'L1', center: { x: 2, y: 12, z: 2 } }, { id: 'space-cinema', name: '穹幕影院', type: 'theater', floorId: 'L1', center: { x: 2, y: 12, z: 2 } },
{ id: 'space-dining', name: '餐饮区', type: 'restaurant', floorId: 'L1', center: { x: 3, y: 12, z: 3 } }, { id: 'space-dining', name: '餐饮区', type: 'restaurant', floorId: 'L1', center: { x: 3, y: 12, z: 3 } },
{ id: 'space-shopping', name: '文创商店', type: 'commercial', floorId: 'L1', center: { x: 4, y: 12, z: 4 } } { id: 'space-shopping', name: '文创商店', type: 'commercial', floorId: 'L1', center: { x: 4, y: 12, z: 4 } },
{ id: 'space-private-tea', name: '茶水间', type: 'service_space', floorId: 'L1', center: { x: 5, y: 12, z: 5 } },
{ id: 'space-private-vip', name: '贵宾接待区', type: 'service_space', floorId: 'L1', center: { x: 6, y: 12, z: 6 } },
{
id: 'space-public-rest',
name: '游客休息区',
type: 'service_space',
floorId: 'L1',
center: { x: 7, y: 12, z: 7 },
visitorVisible: true
}
] ]
const floorPois = [ const floorPois = [
@@ -120,17 +134,25 @@ describe('GuideRepository visitor search contracts', () => {
const provider = createSgsProvider() const provider = createSgsProvider()
const repository = new SgsSdkGuideRepository(provider) const repository = new SgsSdkGuideRepository(provider)
const [defaultResults, availability] = await Promise.all([ const [defaultResults, availability, privateKeyword, publicKeyword] = await Promise.all([
repository.listDestinationPois('L1'), repository.listDestinationPois('L1'),
repository.getQuickFindCategoryAvailability('L1') repository.getQuickFindCategoryAvailability('L1'),
repository.searchPois('茶水间', 'L1'),
repository.searchPois('游客休息区', 'L1')
]) ])
expect(defaultResults.map((poi) => poi.id)).toEqual(expect.arrayContaining([ expect(defaultResults.map((poi) => poi.id)).toEqual(expect.arrayContaining([
'hall-space-hall', 'hall-space-hall',
'hall-space-cinema', 'hall-space-cinema',
'space-space-dining', 'space-space-dining',
'space-space-shopping' 'space-space-shopping',
'space-space-public-rest'
])) ]))
for (const privatePoiId of ['space-space-private-tea', 'space-space-private-vip']) {
expect(defaultResults.map((poi) => poi.id)).not.toContain(privatePoiId)
}
expect(privateKeyword).toEqual([])
expect(publicKeyword.map((poi) => poi.id)).toEqual(['space-space-public-rest'])
expect(defaultResults.map((poi) => poi.id)).not.toEqual(expect.arrayContaining([ expect(defaultResults.map((poi) => poi.id)).not.toEqual(expect.arrayContaining([
'restroom', 'restroom',
'elevator', 'elevator',
@@ -160,9 +182,13 @@ describe('GuideRepository visitor search contracts', () => {
// survive model marker deduplication. // survive model marker deduplication.
expect(renderPoiIds.has('restroom')).toBe(true) expect(renderPoiIds.has('restroom')).toBe(true)
expect(renderPoiIds.has('elevator')).toBe(true) expect(renderPoiIds.has('elevator')).toBe(true)
expect(renderPoiIds.has('space-space-public-rest')).toBe(true)
for (const hiddenPoiId of ['door', 'anchor', 'route-node', 'guide-stop']) { for (const hiddenPoiId of ['door', 'anchor', 'route-node', 'guide-stop']) {
expect(renderPoiIds.has(hiddenPoiId)).toBe(false) expect(renderPoiIds.has(hiddenPoiId)).toBe(false)
} }
for (const privatePoiId of ['space-space-private-tea', 'space-space-private-vip']) {
expect(renderPoiIds.has(privatePoiId)).toBe(false)
}
const quickResultIds = (await Promise.all(allQuickCategoryIds.map((categoryId) => ( const quickResultIds = (await Promise.all(allQuickCategoryIds.map((categoryId) => (
repository.listQuickFindPois(categoryId, 'L1') repository.listQuickFindPois(categoryId, 'L1')
)))).flat().map((poi) => poi.id) )))).flat().map((poi) => poi.id)
@@ -188,6 +214,115 @@ describe('GuideRepository visitor search contracts', () => {
expect(hiddenKeyword).toEqual([]) expect(hiddenKeyword).toEqual([])
}) })
it('does not resolve private direct IDs outside the visitor search pool', async () => {
const repository = new SgsSdkGuideRepository(createSgsProvider({
queryPois: vi.fn().mockResolvedValue([{
id: 'private-direct-space',
name: '茶水间',
type: 'service_space',
floorId: 'L1',
visitorVisible: false,
position: { x: 30, y: 12, z: 30 }
}])
}))
await expect(repository.getPoiById('private-direct-space')).resolves.toBeNull()
})
it('keeps private and structural space duplicates out of search lists and map markers', async () => {
const provider = createSgsProvider({
getFloorPois: vi.fn().mockResolvedValue([
{
id: 'poi-private-dining',
name: '西侧餐饮区',
type: 'restaurant',
floorId: 'L1',
spatialAreaId: 'space-private-dining',
position: { x: 30, y: 12, z: 30 }
},
{
id: 'poi-service-space',
name: '服务空间',
type: 'service_space',
floorId: 'L1',
position: { x: 31, y: 12, z: 31 }
}
]),
getFloorBusinessPois: vi.fn().mockResolvedValue([]),
getFloorSpaces: vi.fn().mockResolvedValue([
{
id: 'space-private-dining',
name: '西侧餐饮区',
type: 'restaurant',
floorId: 'L1',
visitorVisible: false,
center: { x: 30, y: 12, z: 30 }
},
{
id: 'space-private-cinema',
name: '私享影院',
type: 'theater',
floorId: 'L1',
visitorVisible: false,
center: { x: 32, y: 12, z: 32 }
},
{
id: 'space-route-node',
name: '导航区域',
type: 'navigable_place',
floorId: 'L1',
center: { x: 33, y: 12, z: 33 }
}
]),
getNavigablePlaces: vi.fn().mockResolvedValue([])
})
const repository = new SgsSdkGuideRepository(provider)
const modelRepository = new SgsSdkGuideModelRepository(provider, repository)
const [defaultResults, dining, cinema, privateKeyword, serviceKeyword, structuralKeyword, renderPois] = await Promise.all([
repository.listDestinationPois('L1'),
repository.listQuickFindPois('dining', 'L1'),
repository.listQuickFindPois('cinema', 'L1'),
repository.searchPois('西侧餐饮区', 'L1'),
repository.searchPois('服务空间', 'L1'),
repository.searchPois('导航区域', 'L1'),
modelRepository.loadFloorPois('L1')
])
expect(defaultResults).toEqual([])
expect(dining).toEqual([])
expect(cinema).toEqual([])
expect(privateKeyword).toEqual([])
expect(serviceKeyword).toEqual([])
expect(structuralKeyword).toEqual([])
expect(renderPois).toEqual([])
})
it('merges source-place duplicates before returning canonical visitor IDs', () => {
const basePoi = {
floorId: 'L1',
floorLabel: '1F',
primaryCategory: { id: 'space_restaurant', label: '餐饮', iconType: 'restaurant' },
categories: [{ id: 'space_restaurant', label: '餐饮', iconType: 'restaurant' }],
positionGltf: [40, 12, 40] as [number, number, number],
accessible: false,
kind: 'facility' as const,
sourcePlaceId: 'place-cafe'
}
const results = canonicalizeVisitorSearchPois([
{ ...basePoi, id: 'poi-cafe-a', name: '中庭咖啡' },
{
...basePoi,
id: 'poi-cafe-b',
name: '中庭咖啡取餐台',
positionGltf: [41, 12, 41]
}
])
expect(results.map((poi) => poi.id)).toEqual(['poi-cafe-a'])
})
it('deduplicates co-located sources when they do not provide a shared spatial area ID', async () => { it('deduplicates co-located sources when they do not provide a shared spatial area ID', async () => {
const repository = new SgsSdkGuideRepository(createSgsProvider({ const repository = new SgsSdkGuideRepository(createSgsProvider({
getFloorPois: vi.fn().mockResolvedValue([{ getFloorPois: vi.fn().mockResolvedValue([{
@@ -348,6 +483,24 @@ describe('GuideRepository visitor search contracts', () => {
primaryCategoryZh: '基础服务设施', primaryCategoryZh: '基础服务设施',
iconType: 'restroom', iconType: 'restroom',
positionGltf: position(4) positionGltf: position(4)
},
{
id: 'static-vip-reception',
name: '贵宾接待区',
floorId: 'L1',
primaryCategory: 'basic_service_facility',
primaryCategoryZh: '基础服务设施',
iconType: 'restroom',
positionGltf: position(5)
},
{
id: 'static-vip-restroom',
name: '贵宾卫生间',
floorId: 'L1',
primaryCategory: 'basic_service_facility',
primaryCategoryZh: '基础服务设施',
iconType: 'restroom',
positionGltf: position(6)
} }
]) ])
} as unknown as StaticNavAssetsProvider } as unknown as StaticNavAssetsProvider
@@ -362,6 +515,10 @@ describe('GuideRepository visitor search contracts', () => {
await expect(repository.searchPois('洗手间', 'L1')).resolves.toMatchObject([ await expect(repository.searchPois('洗手间', 'L1')).resolves.toMatchObject([
{ id: 'static-restroom' } { id: 'static-restroom' }
]) ])
await expect(repository.searchPois('茶水间', 'L1')).resolves.toEqual([])
await expect(repository.searchPois('贵宾', 'L1')).resolves.toEqual([])
await expect(repository.getPoiById('static-tea-room')).resolves.toBeNull()
await expect(repository.getPoiById('static-vip-restroom')).resolves.toBeNull()
await expect(repository.searchPois('服务', 'L1')).resolves.toEqual([ await expect(repository.searchPois('服务', 'L1')).resolves.toEqual([
expect.objectContaining({ id: 'static-service-desk' }) expect.objectContaining({ id: 'static-service-desk' })
]) ])
@@ -397,6 +554,24 @@ describe('GuideRepository visitor search contracts', () => {
primaryCategoryZh: '讲解点', primaryCategoryZh: '讲解点',
iconType: 'guide', iconType: 'guide',
positionGltf: position(2) positionGltf: position(2)
},
{
id: 'static-private-tea',
name: '茶水间',
floorId: 'L1',
primaryCategory: 'basic_service_facility',
primaryCategoryZh: '基础服务设施',
iconType: 'restroom',
positionGltf: position(3)
},
{
id: 'static-vip-restroom',
name: '贵宾卫生间',
floorId: 'L1',
primaryCategory: 'basic_service_facility',
primaryCategoryZh: '基础服务设施',
iconType: 'restroom',
positionGltf: position(4)
} }
]) ])
} as unknown as StaticNavAssetsProvider } as unknown as StaticNavAssetsProvider

View File

@@ -18,6 +18,7 @@ const testState = vi.hoisted(() => ({
searchRestoreCount: 0, searchRestoreCount: 0,
searchCollapseAfterDetailCount: 0, searchCollapseAfterDetailCount: 0,
searchResetCount: 0, searchResetCount: 0,
floorSwitchRequests: [] as string[],
onShowHandler: null as null | (() => Promise<void> | void), onShowHandler: null as null | (() => Promise<void> | void),
halls: [] as Array<{ id: string; poiId?: string; location?: { poiId?: string }; name: string }>, halls: [] as Array<{ id: string; poiId?: string; location?: { poiId?: string }; name: string }>,
hallResolutionPromises: [] as Array<Promise<Array<{ hallResolutionPromises: [] as Array<Promise<Array<{
@@ -102,7 +103,7 @@ const GuideMapShellStub = defineComponent({
visiblePoiIds: { type: Array, default: null }, visiblePoiIds: { type: Array, default: null },
targetFocusRequest: { type: Object, default: null } targetFocusRequest: { type: Object, default: null }
}, },
emits: ['floor-change', 'poi-click', 'selection-clear', 'indoor-view-change'], emits: ['floor-change', 'floor-request', 'floor-switch-failed', 'initial-model-ready', 'poi-click', 'selection-clear', 'indoor-view-change'],
setup() { setup() {
onMounted(() => { onMounted(() => {
testState.mapMountCount += 1 testState.mapMountCount += 1
@@ -113,6 +114,10 @@ const GuideMapShellStub = defineComponent({
return {} return {}
}, },
methods: { methods: {
switchFloor(floorId: string) {
testState.floorSwitchRequests.push(floorId)
return Promise.resolve()
},
resetToViewBaseline(options: { resetToViewBaseline(options: {
view: 'overview' | 'floor' view: 'overview' | 'floor'
floorId?: string floorId?: string
@@ -217,6 +222,7 @@ beforeEach(() => {
testState.searchRestoreCount = 0 testState.searchRestoreCount = 0
testState.searchCollapseAfterDetailCount = 0 testState.searchCollapseAfterDetailCount = 0
testState.searchResetCount = 0 testState.searchResetCount = 0
testState.floorSwitchRequests = []
testState.onShowHandler = null testState.onShowHandler = null
testState.halls = [] testState.halls = []
testState.hallResolutionPromises = [] testState.hallResolutionPromises = []
@@ -355,7 +361,7 @@ describe('首页搜索与地图闭环', () => {
expect(wrapper.findAll('.guide-quick-action')[0]?.text()).toBe('导览') expect(wrapper.findAll('.guide-quick-action')[0]?.text()).toBe('导览')
}) })
it('向搜索面板传入当前楼层,并将分类结果 ID 精确传给地图', async () => { it('等待渲染器提交目标楼层后,才将快捷分类结果 ID 传给地图', async () => {
const wrapper = await mountIndex() const wrapper = await mountIndex()
const search = wrapper.getComponent(PoiSearchPanelStub) const search = wrapper.getComponent(PoiSearchPanelStub)
const map = wrapper.getComponent(GuideMapShellStub) const map = wrapper.getComponent(GuideMapShellStub)
@@ -370,6 +376,13 @@ describe('首页搜索与地图闭环', () => {
}) })
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(testState.floorSwitchRequests).toEqual(['L2'])
expect(map.props('activeFloor')).toBe('L1')
expect(map.props('visiblePoiIds')).toEqual([])
map.vm.$emit('floor-change', 'L2')
await wrapper.vm.$nextTick()
expect(map.props('activeFloor')).toBe('L2') expect(map.props('activeFloor')).toBe('L2')
expect(map.props('indoorView')).toBe('floor') expect(map.props('indoorView')).toBe('floor')
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds) expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
@@ -388,6 +401,78 @@ describe('首页搜索与地图闭环', () => {
expect(map.props('visiblePoiIds')).toEqual([]) expect(map.props('visiblePoiIds')).toEqual([])
}) })
it('三维模型重新以总览就绪后,不将旧单层状态误作楼层提交', async () => {
const wrapper = await mountIndex()
const search = wrapper.getComponent(PoiSearchPanelStub)
const map = wrapper.getComponent(GuideMapShellStub)
map.vm.$emit('floor-change', 'L1')
await wrapper.vm.$nextTick()
expect(map.props('indoorView')).toBe('floor')
map.vm.$emit('initial-model-ready', { view: 'overview', floorId: 'L1' })
await wrapper.vm.$nextTick()
expect(map.props('indoorView')).toBe('overview')
search.vm.$emit('category-mode-change', true)
search.vm.$emit('results-change', {
...categoryContext,
floorId: 'L1',
floorLabel: '1F',
active: true,
requestId: 20
})
await wrapper.vm.$nextTick()
expect(testState.floorSwitchRequests).toEqual(['L1'])
expect(map.props('visiblePoiIds')).toEqual([])
map.vm.$emit('floor-change', 'L1')
await wrapper.vm.$nextTick()
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
})
it('忽略过期搜索结果,不允许旧楼层覆盖当前待提交地图状态', async () => {
const wrapper = await mountIndex()
const search = wrapper.getComponent(PoiSearchPanelStub)
const map = wrapper.getComponent(GuideMapShellStub)
search.vm.$emit('category-mode-change', true)
search.vm.$emit('results-change', { ...categoryContext, active: true, requestId: 8 })
search.vm.$emit('results-change', {
...categoryContext,
floorId: 'L1',
floorLabel: '1F',
visiblePoiIds: ['stale-l1'],
active: true,
requestId: 7
})
await wrapper.vm.$nextTick()
expect(testState.floorSwitchRequests).toEqual(['L2'])
map.vm.$emit('floor-change', 'L2')
await wrapper.vm.$nextTick()
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
})
it('楼层切换失败时清空快捷分类标记,重试提交后恢复与列表一致的 ID', async () => {
const wrapper = await mountIndex()
const search = wrapper.getComponent(PoiSearchPanelStub)
const map = wrapper.getComponent(GuideMapShellStub)
search.vm.$emit('category-mode-change', true)
search.vm.$emit('results-change', { ...categoryContext, active: true, requestId: 12 })
await wrapper.vm.$nextTick()
map.vm.$emit('floor-switch-failed', { floorId: 'L2', floorLabel: '2F' })
await wrapper.vm.$nextTick()
expect(map.props('activeFloor')).toBe('L1')
expect(map.props('visiblePoiIds')).toEqual([])
map.vm.$emit('floor-change', 'L2')
await wrapper.vm.$nextTick()
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
})
it('楼层数据按高到低返回时,首次进入仍将 1F 作为自动缩放的默认目标', async () => { it('楼层数据按高到低返回时,首次进入仍将 1F 作为自动缩放的默认目标', async () => {
const originalFloors = [...floors] const originalFloors = [...floors]
floors.splice(0, floors.length, floors.splice(0, floors.length,
@@ -417,6 +502,8 @@ describe('首页搜索与地图闭环', () => {
...categoryContext, ...categoryContext,
active: true active: true
}) })
map.vm.$emit('floor-change', 'L2')
await wrapper.vm.$nextTick()
search.vm.$emit('result-tap', poi, categoryContext) search.vm.$emit('result-tap', poi, categoryContext)
await flushPromises() await flushPromises()
@@ -486,6 +573,8 @@ describe('首页搜索与地图闭环', () => {
search.vm.$emit('category-mode-change', true) search.vm.$emit('category-mode-change', true)
search.vm.$emit('results-change', { ...categoryContext, active: true }) search.vm.$emit('results-change', { ...categoryContext, active: true })
map.vm.$emit('floor-change', 'L2')
await wrapper.vm.$nextTick()
search.vm.$emit('result-tap', poi, categoryContext) search.vm.$emit('result-tap', poi, categoryContext)
await flushPromises() await flushPromises()

View File

@@ -149,6 +149,7 @@ const lastResultState = (wrapper: ReturnType<typeof mount>) => (
categoryId: string categoryId: string
keyword: string keyword: string
pending?: boolean pending?: boolean
requestId?: number
} }
) )
@@ -323,6 +324,36 @@ describe('PoiSearchPanel 搜索状态契约', () => {
wrapper.unmount() wrapper.unmount()
}) })
it('首页分类结果在组件重挂载后仍发送递增的跨组件版本号', async () => {
const firstWrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await firstWrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
const firstRequestId = lastResultState(firstWrapper).requestId
firstWrapper.unmount()
const secondWrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await secondWrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
expect(firstRequestId).toEqual(expect.any(Number))
expect(lastResultState(secondWrapper).requestId).toBeGreaterThan(firstRequestId as number)
secondWrapper.unmount()
})
it('首页快捷分类请求期间清空列表并下发 pending 空地图 ID', async () => { it('首页快捷分类请求期间清空列表并下发 pending 空地图 ID', async () => {
const initialState = createSearchState() const initialState = createSearchState()
const deferredCategory = createDeferred<GuidePoiSearchViewState>() const deferredCategory = createDeferred<GuidePoiSearchViewState>()

View File

@@ -0,0 +1,86 @@
import { createRequire } from 'node:module'
import { describe, expect, it, vi } from 'vitest'
const require = createRequire(import.meta.url)
const {
isTextBuildFile,
resolveH5BuildPolicy,
tencentMapKeyPlaceholder
} = require('../../scripts/finalize-h5-build-policy.cjs') as {
isTextBuildFile: (filePath: string) => boolean
resolveH5BuildPolicy: (options: {
args?: string[]
projectRoot: string
environment?: Record<string, string | undefined>
loadProductionEnv?: (root: string) => Record<string, string | undefined>
}) => {
allowPlaceholder: boolean
requireTencentMapKey: boolean
tencentMapKey: string
}
tencentMapKeyPlaceholder: string
}
const projectRoot = 'C:/museum-guide'
const validTencentMapKey = 'ABCDE-FGHIJ-KLMNO-PQRST-UVWXY-12345'
describe('finalize H5 build policy', () => {
it('keeps test builds isolated from production map credentials', () => {
const loadProductionEnv = vi.fn(() => ({
VITE_TENCENT_MAP_KEY: validTencentMapKey
}))
expect(resolveH5BuildPolicy({
args: ['--allow-placeholder'],
projectRoot,
environment: { VITE_TENCENT_MAP_KEY: validTencentMapKey },
loadProductionEnv
})).toEqual({
allowPlaceholder: true,
requireTencentMapKey: false,
tencentMapKey: ''
})
expect(loadProductionEnv).not.toHaveBeenCalled()
})
it('loads a well-formed production key only for the production gate', () => {
const loadProductionEnv = vi.fn(() => ({
VITE_TENCENT_MAP_KEY: validTencentMapKey
}))
expect(resolveH5BuildPolicy({
args: ['--require-tencent-map-key'],
projectRoot,
environment: {},
loadProductionEnv
})).toEqual({
allowPlaceholder: false,
requireTencentMapKey: true,
tencentMapKey: validTencentMapKey
})
expect(loadProductionEnv).toHaveBeenCalledWith(projectRoot)
})
it('rejects conflicting flags, placeholders, and malformed production keys', () => {
expect(() => resolveH5BuildPolicy({
args: ['--allow-placeholder', '--require-tencent-map-key'],
projectRoot,
environment: {}
})).toThrow('cannot require a Tencent map key')
for (const key of ['', tencentMapKeyPlaceholder, 'fake-key']) {
expect(() => resolveH5BuildPolicy({
args: ['--require-tencent-map-key'],
projectRoot,
environment: { VITE_TENCENT_MAP_KEY: key },
loadProductionEnv: () => ({})
})).toThrow('well-formed non-placeholder')
}
})
it('scans source maps as text build artifacts', () => {
expect(isTextBuildFile('assets/index.js.map')).toBe(true)
expect(isTextBuildFile('assets/index.js')).toBe(true)
expect(isTextBuildFile('assets/model.glb')).toBe(false)
})
})

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { toMuseumPoi } from '@/data/adapters/navAssetsAdapter' import { toMuseumPoi } from '@/data/adapters/navAssetsAdapter'
import { resolvePoiCategory } from '@/domain/poiCategories' import { isVisitorSearchPoi, resolvePoiCategory } from '@/domain/poiCategories'
import type { StaticNavPoiPayload } from '@/data/providers/staticNavAssetsProvider' import type { StaticNavPoiPayload } from '@/data/providers/staticNavAssetsProvider'
const createStaticPoi = (name: string, iconType = 'elevator'): StaticNavPoiPayload => ({ const createStaticPoi = (name: string, iconType = 'elevator'): StaticNavPoiPayload => ({
@@ -52,10 +52,23 @@ describe('static nav POI adapter', () => {
const serviceDesk = toMuseumPoi(createStaticPoi('服务台', 'restroom')) const serviceDesk = toMuseumPoi(createStaticPoi('服务台', 'restroom'))
const locker = toMuseumPoi(createStaticPoi('存包处', 'restroom')) const locker = toMuseumPoi(createStaticPoi('存包处', 'restroom'))
const teaRoom = toMuseumPoi(createStaticPoi('茶水间', 'restroom')) const teaRoom = toMuseumPoi(createStaticPoi('茶水间', 'restroom'))
const vipReception = toMuseumPoi(createStaticPoi('贵宾接待区', 'restroom'))
const vipRestroom = toMuseumPoi(createStaticPoi('贵宾卫生间', 'restroom'))
expect(resolvePoiCategory(serviceDesk)?.id).toBe('service-center') expect(resolvePoiCategory(serviceDesk)?.id).toBe('service-center')
expect(resolvePoiCategory(locker)?.id).toBe('service-center') expect(resolvePoiCategory(locker)?.id).toBe('service-center')
expect(resolvePoiCategory(teaRoom)).toBeNull() expect(resolvePoiCategory(teaRoom)).toBeNull()
expect(teaRoom.visitorVisible).toBe(false)
expect(vipReception.visitorVisible).toBe(false)
expect(vipRestroom.visitorVisible).toBe(false)
expect(toMuseumPoi({
...createStaticPoi('茶水间', 'restroom'),
visitorVisible: true
}).visitorVisible).toBe(true)
expect(toMuseumPoi({
...createStaticPoi('贵宾卫生间', 'restroom'),
visitorVisible: true
}).visitorVisible).toBe(true)
}) })
it('splits generic touring fallback records into their visitor-facing semantic kinds', () => { it('splits generic touring fallback records into their visitor-facing semantic kinds', () => {
@@ -73,4 +86,19 @@ describe('static nav POI adapter', () => {
}) })
expect(resolvePoiCategory(ticketOffice)?.id).toBe('ticket-office') expect(resolvePoiCategory(ticketOffice)?.id).toBe('ticket-office')
}) })
it('keeps a hall visible when its secondary category describes an entrance', () => {
const hall = toMuseumPoi(createTouringPoi('地球展厅'))
hall.kind = 'hall'
hall.categories = [
hall.primaryCategory,
{
id: 'exhibition_hall_entrance',
label: '展厅出入口',
iconType: 'hall_entrance'
}
]
expect(isVisitorSearchPoi(hall)).toBe(true)
})
}) })