整改点位搜索数据加载与分类逻辑

This commit is contained in:
cxk
2026-07-20 21:30:55 +08:00
parent 65ba9720f8
commit 7ae539accc
14 changed files with 2352 additions and 812 deletions

View File

@@ -4,17 +4,15 @@ import type {
} from '@/domain/museum'
export type PoiCategoryId =
| 'itinerary'
| 'exhibition-hall'
| 'cinema'
| 'ticket-office'
| 'service-center'
| 'dining'
| 'souvenir'
| 'nursing-room'
| 'shopping'
| 'service-center'
| 'restroom'
| 'nursing-room'
| 'elevator'
| 'stairs'
| 'escalator'
export interface PoiCategoryDefinition {
@@ -31,33 +29,22 @@ export interface PoiCategoryDefinition {
const definitions: PoiCategoryDefinition[] = [
{
id: 'itinerary',
label: '行程',
icon: 'itinerary',
id: 'exhibition-hall',
label: '展厅',
icon: 'exhibition',
order: 1,
homeVisible: true,
keywords: ['行程', '导览', '参观', 'touring_poi'],
categoryIds: ['touring_poi'],
iconTypes: ['touring', 'guide']
},
{
id: 'exhibition-hall',
label: '展览',
icon: 'exhibition',
order: 2,
homeVisible: true,
keywords: ['展厅', '展览', '展馆', 'exhibition_hall', 'hall'],
categoryIds: ['exhibition_hall', 'exhibition_hall_entrance'],
iconTypes: ['exhibition_hall', 'hall_entrance', 'exhibition'],
excludedCategoryIds: ['touring_poi']
categoryIds: ['exhibition_hall'],
iconTypes: ['exhibition_hall', 'exhibition']
},
{
id: 'cinema',
label: '影院',
icon: 'cinema',
order: 3,
order: 2,
homeVisible: true,
keywords: ['影院', '影厅', '剧场', '报告厅', 'cinema', 'theater'],
keywords: ['影院', '影厅', '剧场', '报告厅', 'cinema', 'theater', '影院空间'],
categoryIds: ['space_theater'],
iconTypes: ['cinema', 'theater']
},
@@ -65,47 +52,47 @@ const definitions: PoiCategoryDefinition[] = [
id: 'ticket-office',
label: '售票处',
icon: 'ticket',
order: 6,
order: 3,
homeVisible: true,
keywords: ['售票处', '售票', '票务', 'ticket_office'],
categoryIds: [],
iconTypes: ['ticket_office']
},
{
id: 'service-center',
label: '服务中心',
icon: 'service',
order: 8,
homeVisible: true,
keywords: ['服务中心', '服务台', '咨询台', 'service_desk'],
categoryIds: [],
iconTypes: ['service_desk']
},
{
id: 'dining',
label: '餐',
label: '餐',
icon: 'restaurant',
order: 5,
order: 4,
homeVisible: true,
keywords: ['餐饮', '餐厅', '快餐', '咖啡', 'restaurant', 'dining', 'food', 'cafe'],
categoryIds: [],
iconTypes: ['restaurant', 'dining', 'food', 'cafe']
},
{
id: 'souvenir',
label: '文创',
id: 'shopping',
label: '购物',
icon: 'bag',
order: 11,
homeVisible: false,
keywords: ['文创', '纪念品', '商店', 'souvenir', 'gift', 'shop'],
order: 5,
homeVisible: true,
keywords: ['购物', '商店', '文创', '纪念品', '零售', 'souvenir', 'gift', 'shop', 'shopping', 'store'],
categoryIds: [],
iconTypes: ['souvenir', 'gift', 'shop', 'cultural_creative']
iconTypes: ['souvenir', 'gift', 'shop', 'store', 'retail', 'cultural_shop', 'bookstore', 'vending']
},
{
id: 'service-center',
label: '服务',
icon: 'service',
order: 6,
homeVisible: true,
keywords: ['服务', '服务中心', '服务台', '咨询台', '租赁', '寄存', 'service_desk', 'rental_service', 'locker'],
categoryIds: [],
iconTypes: ['service_desk', 'service_center', 'rental_service', 'locker']
},
{
id: 'nursing-room',
label: '母婴',
label: '母婴',
icon: 'nursing',
order: 7,
order: 8,
homeVisible: true,
keywords: ['母婴室', '母婴间', 'mother_baby_room', 'nursing_room'],
categoryIds: [],
@@ -115,7 +102,7 @@ const definitions: PoiCategoryDefinition[] = [
id: 'restroom',
label: '洗手间',
icon: 'restroom',
order: 4,
order: 7,
homeVisible: true,
keywords: ['卫生间', '洗手间', '厕所', 'toilet', 'accessible_toilet', 'restroom'],
categoryIds: [],
@@ -131,16 +118,6 @@ const definitions: PoiCategoryDefinition[] = [
categoryIds: [],
iconTypes: ['elevator']
},
{
id: 'stairs',
label: '楼梯',
icon: 'stairs',
order: 12,
homeVisible: false,
keywords: ['楼梯', 'stairs', 'stair'],
categoryIds: [],
iconTypes: ['stairs', 'stair']
},
{
id: 'escalator',
label: '扶梯',
@@ -160,11 +137,12 @@ export const POI_CATEGORIES = Object.freeze(
const homeCategoryOrder: readonly PoiCategoryId[] = [
'exhibition-hall',
'cinema',
'restroom',
'dining',
'ticket-office',
'nursing-room',
'dining',
'shopping',
'service-center',
'restroom',
'nursing-room',
'elevator',
'escalator'
]
@@ -176,17 +154,15 @@ export const HOME_POI_CATEGORIES = Object.freeze(
)
const POI_CATEGORY_ICON_SYMBOLS: Readonly<Record<PoiCategoryId, string>> = {
itinerary: 'poi-itinerary',
'exhibition-hall': 'poi-exhibition-hall',
cinema: 'poi-cinema',
'ticket-office': 'poi-ticket-office',
'service-center': 'poi-service-center',
dining: 'poi-dining',
souvenir: 'poi-souvenir',
'nursing-room': 'poi-nursing-room',
shopping: 'poi-souvenir',
'service-center': 'poi-service-center',
restroom: 'poi-restroom',
'nursing-room': 'poi-nursing-room',
elevator: 'poi-elevator',
stairs: 'poi-stairs',
escalator: 'poi-escalator'
}
@@ -194,52 +170,218 @@ export const getPoiCategoryIconHref = (categoryId: PoiCategoryId) => (
`/static/icons/poi/shortcut-icons.svg#${POI_CATEGORY_ICON_SYMBOLS[categoryId]}`
)
type PoiCategorySource = Pick<MuseumPoi, 'name' | 'primaryCategory' | 'categories'>
export type PoiCategorySource = Pick<
MuseumPoi,
| 'id'
| 'name'
| 'floorId'
| 'primaryCategory'
| 'categories'
| 'kind'
| 'hallId'
| 'spaceId'
| 'sourcePlaceId'
| 'sourceSpaceId'
| 'sourceObjectName'
>
const normalizeValue = (value?: string | null) => (value || '').trim().toLowerCase()
const normalizeValue = (value?: string | null) => (value || '')
.trim()
.normalize('NFKC')
.toLowerCase()
.replace(/[\s-]+/g, '_')
.replace(/^_+|_+$/g, '')
const categoryValues = (categories: MuseumCategory[]) => categories.flatMap((category) => [
category.id,
category.label,
category.iconType || ''
])
const poiSemanticAliases: Readonly<Record<string, string>> = {
exhibition: 'exhibition_hall',
exhibition_hall: 'exhibition_hall',
hall: 'exhibition_hall',
: 'exhibition_hall',
: 'exhibition_hall',
: 'exhibition_hall',
cinema: 'theater',
theater: 'theater',
: 'theater',
: 'theater',
: 'theater',
ticket_office: 'ticket_office',
ticketing: 'ticket_office',
: 'ticket_office',
: 'ticket_office',
: 'ticket_office',
service_desk: 'service_desk',
service_center: 'service_desk',
information_desk: 'service_desk',
: 'service_desk',
: 'service_desk',
: 'service_desk',
restaurant: 'restaurant',
dining: 'restaurant',
food: 'restaurant',
: 'restaurant',
: 'restaurant',
: 'restaurant',
: 'restaurant',
cafe: 'cafe',
coffee: 'cafe',
: 'cafe',
commercial: 'shop',
shop: 'shop',
store: 'shop',
retail: 'shop',
: 'shop',
: 'shop',
: 'shop',
: 'shop',
cultural_shop: 'cultural_shop',
cultural_creative: 'cultural_shop',
: 'cultural_shop',
: 'cultural_shop',
bookstore: 'bookstore',
: 'bookstore',
vending: 'vending',
: 'vending',
toilet: 'toilet',
restroom: 'toilet',
: 'toilet',
: 'toilet',
: 'toilet',
accessible_toilet: 'accessible_toilet',
: 'accessible_toilet',
elevator: 'elevator',
lift: 'elevator',
: 'elevator',
escalator: 'escalator',
: 'escalator',
: 'escalator',
stairs: 'stairs',
stair: 'stairs',
: 'stairs',
mother_baby_room: 'mother_baby_room',
nursing_room: 'mother_baby_room',
: 'mother_baby_room',
: 'mother_baby_room',
: 'mother_baby_room',
entrance_exit: 'entrance_exit',
entrance: 'entrance_exit',
exit: 'entrance_exit',
door: 'entrance_exit',
: 'entrance_exit',
: 'entrance_exit',
: 'entrance_exit',
hall_entrance: 'hall_entrance',
entrance_anchor: 'entrance_anchor',
: 'entrance_anchor',
route_node: 'route_node',
navigation_node: 'route_node',
线: 'route_node',
navigable_place: 'navigable_place',
operation_experience: 'operation_experience',
guide_point: 'operation_experience',
: 'operation_experience'
}
/** Normalize source-specific English/Chinese type labels before domain matching. */
export const normalizePoiSemanticValue = (value?: string | null) => {
const normalized = normalizeValue(value)
return poiSemanticAliases[normalized] || normalized
}
const genericSourceCategoryValues = new Set([
'poi',
'basic_service_facility',
'transport_circulation',
'accessibility_special_service',
'business_poi',
'operation_experience',
'navigation_anchor',
'服务设施',
'交通流线',
'无障碍服务',
'运营服务',
'导览点位'
].map(normalizePoiSemanticValue))
/** Source grouping is not a visitor-facing search term. */
export const isGenericPoiSourceCategory = (category: Pick<MuseumCategory, 'id'>) => (
genericSourceCategoryValues.has(normalizePoiSemanticValue(category.id))
)
const categorySearchValues = (categories: MuseumCategory[]) => categories.flatMap((category) => {
// A generic source group such as "基础服务设施" describes the data source,
// not a visitor-facing service destination. Keep all of its labels out of
// free-text category matching so a restroom cannot become a service result.
if (isGenericPoiSourceCategory(category)) return []
return [category.id, category.label, category.iconType || '']
.map(normalizePoiSemanticValue)
.filter(Boolean)
})
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)))
const normalizedCategoryIds = new Set(categories.map((category) => normalizePoiSemanticValue(category.id)))
const normalizedIconTypes = new Set(categories.map((category) => normalizePoiSemanticValue(category.iconType)))
if (definition.excludedCategoryIds?.some((categoryId) => (
normalizedCategoryIds.has(normalizeValue(categoryId))
normalizedCategoryIds.has(normalizePoiSemanticValue(categoryId))
))) {
return false
}
if (definition.categoryIds.some((categoryId) => normalizedCategoryIds.has(normalizeValue(categoryId)))) {
if (definition.categoryIds.some((categoryId) => normalizedCategoryIds.has(normalizePoiSemanticValue(categoryId)))) {
return true
}
if (definition.iconTypes.some((iconType) => normalizedIconTypes.has(normalizeValue(iconType)))) {
if (definition.iconTypes.some((iconType) => normalizedIconTypes.has(normalizePoiSemanticValue(iconType)))) {
return true
}
// A recognized icon/category is a stronger signal than a word embedded in
// the place name. For example, "展厅售票点" is a ticket office, not an
// exhibition hall merely because its name contains "展厅".
const belongsToAnotherExplicitCategory = POI_CATEGORIES.some((candidate) => {
if (candidate.id === definition.id) return false
return candidate.categoryIds.some((categoryId) => (
normalizedCategoryIds.has(normalizePoiSemanticValue(categoryId))
)) || candidate.iconTypes.some((iconType) => (
normalizedIconTypes.has(normalizePoiSemanticValue(iconType))
))
})
if (belongsToAnotherExplicitCategory) return false
const searchableText = [
poi.name,
...categoryValues(categories)
...categorySearchValues(categories)
]
.map(normalizeValue)
.filter(Boolean)
.join(' ')
return definition.keywords.some((keyword) => searchableText.includes(normalizeValue(keyword)))
return definition.keywords.some((keyword) => {
const normalizedKeyword = normalizeValue(keyword)
const semanticKeyword = normalizePoiSemanticValue(keyword)
return searchableText.includes(normalizedKeyword)
|| searchableText.includes(semanticKeyword)
})
}
export const getPoiCategoryById = (categoryId?: string | null) => (
POI_CATEGORIES.find((category) => category.id === categoryId) || null
)
const legacyCategoryAliases: Readonly<Record<string, PoiCategoryId>> = {
souvenir: 'shopping',
exhibition_hall: 'exhibition-hall',
ticket_office: 'ticket-office',
service_center: 'service-center',
nursing_room: 'nursing-room'
}
export const getPoiCategoryById = (categoryId?: string | null) => {
const categoryIdValue = (categoryId || '').trim().toLowerCase()
const resolvedCategoryId = legacyCategoryAliases[normalizeValue(categoryIdValue)] || categoryIdValue
return POI_CATEGORIES.find((category) => category.id === resolvedCategoryId) || null
}
export const findPoiCategoryByKeyword = (keyword: string) => {
const normalizedKeyword = normalizeValue(keyword)
@@ -255,7 +397,89 @@ export const resolvePoiCategory = (poi: PoiCategorySource) => (
POI_CATEGORIES.find((category) => matchesPoiCategory(poi, category)) || null
)
export const isPoiSearchCategorySupported = (poi: PoiCategorySource) => Boolean(resolvePoiCategory(poi))
const hiddenVisitorPrimaryTypes = new Set([
'entrance_exit',
'hall_entrance',
'entrance_anchor',
'route_node',
'navigable_place',
'operation_experience'
])
const primaryTypeValues = (poi: PoiCategorySource) => [
poi.primaryCategory.id,
poi.primaryCategory.label,
poi.primaryCategory.iconType || ''
]
.map(normalizePoiSemanticValue)
.filter(Boolean)
/** A visitor result must never be a guide point, door, entrance anchor, or route node. */
export const isVisitorSearchPoi = (poi: PoiCategorySource) => {
if (poi.kind === 'guide' || poi.kind === 'hall_entrance') return false
return !primaryTypeValues(poi).some((value) => hiddenVisitorPrimaryTypes.has(value))
}
/** Default floor browse only contains canonical halls and valid destination spaces. */
export const isDefaultSearchDestinationPoi = (poi: PoiCategorySource) => {
if (!isVisitorSearchPoi(poi)) return false
if (poi.kind) return poi.kind === 'hall' || poi.kind === 'space'
const primaryCategoryId = normalizePoiSemanticValue(poi.primaryCategory.id)
return primaryCategoryId === 'exhibition_hall'
|| primaryCategoryId === 'touring_poi'
|| primaryCategoryId.startsWith('space_')
}
export const isQuickFindPoi = (
poi: PoiCategorySource,
definition: PoiCategoryDefinition
) => isVisitorSearchPoi(poi) && matchesPoiCategory(poi, definition)
export const isFacilitySearchPoi = (poi: PoiCategorySource) => (
isVisitorSearchPoi(poi) && !isDefaultSearchDestinationPoi(poi)
)
/**
* A strong, source-stable identity for source-level deduplication. Linked space
* IDs are deliberately exposed separately because a facility inside a hall is
* not automatically a duplicate of that hall.
*/
export const getPoiStablePlaceIdentity = (poi: PoiCategorySource) => {
const primaryCategoryId = normalizePoiSemanticValue(poi.primaryCategory.id)
const canUseLinkedSpace = poi.kind === 'hall'
|| poi.kind === 'space'
|| primaryCategoryId === 'business_poi'
|| primaryCategoryId.startsWith('space_')
const sourceSpaceId = canUseLinkedSpace
? poi.sourceSpaceId?.trim() || poi.spaceId?.trim() || poi.hallId?.trim()
: ''
if (sourceSpaceId) return `space:${sourceSpaceId}`
const sourcePlaceId = poi.sourcePlaceId?.trim()
if (sourcePlaceId) return `place:${sourcePlaceId}`
const poiId = poi.id?.trim()
if (poiId) return `poi:${poiId}`
const normalizedName = normalizeValue(poi.name)
return normalizedName ? `name:${poi.floorId || ''}:${normalizedName}` : ''
}
export const getPoiLinkedSpaceIdentity = (poi: PoiCategorySource) => {
const primaryCategoryId = normalizePoiSemanticValue(poi.primaryCategory.id)
const canUseLinkedSpace = poi.kind === 'hall'
|| poi.kind === 'space'
|| primaryCategoryId === 'business_poi'
|| primaryCategoryId.startsWith('space_')
const spaceId = canUseLinkedSpace
? poi.sourceSpaceId?.trim() || poi.spaceId?.trim() || poi.hallId?.trim()
: ''
return spaceId ? `space:${spaceId}` : ''
}
/** Existing consumers use this for generic search eligibility, not quick-find eligibility. */
export const isPoiSearchCategorySupported = (poi: PoiCategorySource) => isVisitorSearchPoi(poi)
export type PoiDataIssueCode =
| 'missing-id'