diff --git a/.env.development b/.env.development
index f7c07b2..bf46c85 100644
--- a/.env.development
+++ b/.env.development
@@ -3,10 +3,11 @@
VITE_APP_PUBLIC_BASE=/
VITE_DATA_SOURCE_MODE=sdk
-VITE_GUIDE_CONTENT_SOURCE_MODE=static
+VITE_GUIDE_CONTENT_SOURCE_MODE=remote
+VITE_EXPLAIN_ALLOW_STATIC_FALLBACK=false
VITE_GUIDE_STATIC_DATA_BASE_URL=/static/guide-data
VITE_API_BASE_URL=/app-api
-VITE_AUDIO_API_BASE_URL=/nhm/audio
+VITE_AUDIO_API_BASE_URL=/app-api
VITE_AUDIO_LANGUAGE=zh-CN
VITE_SGS_API_BASE_URL=/app-api
VITE_SGS_MAP_ID=1
diff --git a/.env.example b/.env.example
index 5fe63ac..91b1e7c 100644
--- a/.env.example
+++ b/.env.example
@@ -3,10 +3,10 @@
VITE_APP_PUBLIC_BASE=/
VITE_DATA_SOURCE_MODE=sdk
-VITE_GUIDE_CONTENT_SOURCE_MODE=static
+VITE_GUIDE_CONTENT_SOURCE_MODE=remote
VITE_GUIDE_STATIC_DATA_BASE_URL=/static/guide-data
VITE_API_BASE_URL=/app-api
-VITE_AUDIO_API_BASE_URL=/yudao-server
+VITE_AUDIO_API_BASE_URL=/app-api
VITE_AUDIO_LANGUAGE=zh-CN
VITE_SGS_API_BASE_URL=/app-api
VITE_SGS_MAP_ID=1
diff --git a/.env.production b/.env.production
index a979d22..58680a4 100644
--- a/.env.production
+++ b/.env.production
@@ -3,10 +3,10 @@
VITE_APP_PUBLIC_BASE=/
VITE_DATA_SOURCE_MODE=sdk
-VITE_GUIDE_CONTENT_SOURCE_MODE=static
+VITE_GUIDE_CONTENT_SOURCE_MODE=remote
VITE_GUIDE_STATIC_DATA_BASE_URL=/static/guide-data
VITE_API_BASE_URL=/app-api
-VITE_AUDIO_API_BASE_URL=/yudao-server
+VITE_AUDIO_API_BASE_URL=/app-api
VITE_AUDIO_LANGUAGE=zh-CN
VITE_SGS_API_BASE_URL=/app-api
VITE_SGS_MAP_ID=1
diff --git a/.env.test b/.env.test
index 6cf4cc1..dcd03f5 100644
--- a/.env.test
+++ b/.env.test
@@ -3,10 +3,10 @@
VITE_APP_PUBLIC_BASE=/
VITE_DATA_SOURCE_MODE=sdk
-VITE_GUIDE_CONTENT_SOURCE_MODE=static
+VITE_GUIDE_CONTENT_SOURCE_MODE=remote
VITE_GUIDE_STATIC_DATA_BASE_URL=/static/guide-data
VITE_API_BASE_URL=/app-api
-VITE_AUDIO_API_BASE_URL=/yudao-server
+VITE_AUDIO_API_BASE_URL=/app-api
VITE_AUDIO_LANGUAGE=zh-CN
VITE_SGS_API_BASE_URL=/app-api
VITE_SGS_MAP_ID=1
diff --git a/src/components/explain/ExplainHallSelect.vue b/src/components/explain/ExplainHallSelect.vue
index 7568b06..b3f0ad2 100644
--- a/src/components/explain/ExplainHallSelect.vue
+++ b/src/components/explain/ExplainHallSelect.vue
@@ -101,11 +101,16 @@
- {{ stop.name }}
+
+ {{ stop.name }}
+
+ {{ stop.guideLevel }}
+
+
{{ stop.description }}
- {{ stop.hasAudio ? '可讲解' : '图文' }}
+ {{ stop.audioStatus === 'READY' ? '可讲解' : '图文' }}
{{ stop.floorId || selectedHallName || '楼层待补充' }}
@@ -158,6 +163,8 @@ export interface ExplainGuideStopSelectItem {
coverImageUrl?: string
description?: string
hasAudio?: boolean
+ audioStatus?: string
+ guideLevel?: string
}
export type ExplainSelectStage = 'hall' | 'unit' | 'stop'
@@ -422,6 +429,7 @@ const handleBack = () => {
}
.hall-name {
+ min-width: 0;
font-size: 17px;
line-height: 24px;
font-weight: 800;
@@ -431,6 +439,38 @@ const handleBack = () => {
white-space: nowrap;
}
+.stop-title-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.guide-level-badge {
+ flex-shrink: 0;
+ max-width: 92px;
+ height: 22px;
+ padding: 0 7px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ background: #151713;
+ border-radius: 6px;
+}
+
+.guide-level-text {
+ display: block;
+ max-width: 100%;
+ font-size: 12px;
+ line-height: 16px;
+ font-weight: 700;
+ color: #e0df00;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
.hall-meta-row {
margin-top: 10px;
display: flex;
diff --git a/src/components/search/PoiSearchPanel.vue b/src/components/search/PoiSearchPanel.vue
index 50a402f..ec2254d 100644
--- a/src/components/search/PoiSearchPanel.vue
+++ b/src/components/search/PoiSearchPanel.vue
@@ -62,20 +62,22 @@
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ {{ item.label }}
- {{ item.label }}
@@ -182,14 +184,36 @@ const emit = defineEmits<{
}>()
const primaryFacilities: ShortcutItem[] = [
- { id: 'service', label: '服务台', icon: 'service', keywords: ['服务台', 'service_desk'] },
+ { id: 'exhibition', label: '展览', icon: 'exhibition', keywords: ['展览', '展厅', '展馆', 'exhibition_hall', 'temp_exhibition'] },
+ { id: 'cinema', label: '影院', icon: 'cinema', keywords: ['影院', '剧场', '影厅', 'cinema', 'theater'] },
+ { id: 'restroom', label: '洗手间', icon: 'restroom', keywords: ['洗手间', '卫生间', 'toilet', 'accessible_toilet'] },
+ { id: 'restaurant', label: '餐厅', icon: 'restaurant', keywords: ['餐厅', '餐饮', '咖啡', 'restaurant', 'commercial'] },
+ { id: 'ticket-office', label: '售票处', icon: 'ticket', keywords: ['售票处', '售票', '票务', 'ticket_office'] },
+ { id: 'nursing', label: '母婴室', icon: 'nursing', keywords: ['母婴室', 'mother_baby_room'] },
+ { id: 'service-center', label: '服务中心', icon: 'service', keywords: ['服务中心', '服务台', '咨询台', 'service_desk', 'service_space'] },
{ id: 'elevator', label: '电梯', icon: 'elevator', keywords: ['电梯', 'elevator'] },
{ id: 'escalator', label: '扶梯', icon: 'escalator', keywords: ['扶梯', 'escalator'] },
- { id: 'restroom', label: '洗手间', icon: 'restroom', keywords: ['洗手间', '卫生间', 'toilet', 'accessible_toilet'] },
- { id: 'nursing', label: '母婴室', icon: 'nursing', keywords: ['母婴室', 'mother_baby_room'] },
- { id: 'entrance', label: '出入口', icon: 'entrance', keywords: ['出入口', '入口', '出口', 'entrance_exit'] }
+ { id: 'stairs', label: '楼梯', icon: 'stairs', keywords: ['楼梯', 'stairs', 'stair'] }
]
+const primaryFacilityVisibleColumnCount = 4
+const primaryFacilityRowCount = 2
+
+const primaryFacilityColumns = computed(() => {
+ const firstScreenItems = primaryFacilities.slice(0, primaryFacilityVisibleColumnCount * primaryFacilityRowCount)
+ const overflowItems = primaryFacilities.slice(primaryFacilityVisibleColumnCount * primaryFacilityRowCount)
+ const orderedItems: ShortcutItem[] = []
+
+ for (let columnIndex = 0; columnIndex < primaryFacilityVisibleColumnCount; columnIndex += 1) {
+ const topItem = firstScreenItems[columnIndex]
+ const bottomItem = firstScreenItems[columnIndex + primaryFacilityVisibleColumnCount]
+ if (topItem) orderedItems.push(topItem)
+ if (bottomItem) orderedItems.push(bottomItem)
+ }
+
+ return orderedItems.concat(overflowItems)
+})
+
const serviceFacilities: ShortcutItem[] = [
{ id: 'drink', label: '饮料售卖机', icon: 'drink', keywords: ['饮料售卖机', '饮料', '售卖机', 'vending'] },
{ id: 'ticket', label: '票务售卖机', icon: 'ticket', keywords: ['票务售卖机', '票务', '售票', 'ticket_office'] },
@@ -678,9 +702,11 @@ defineExpose({
.poi-search-panel {
width: 100%;
+ max-width: 100%;
box-sizing: border-box;
color: #262421;
font-family: '鸿蒙黑体', 'HarmonyOS Sans SC', 'HarmonyOS Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ overflow-x: hidden;
}
.variant-home.expanded {
@@ -758,6 +784,8 @@ defineExpose({
}
.variant-home .search-box {
+ background: #f5f5ed;
+ border-color: rgba(224, 225, 0, 0.72);
box-shadow: none;
}
@@ -768,6 +796,8 @@ defineExpose({
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
+ overscroll-behavior-x: contain;
+ -webkit-overflow-scrolling: touch;
}
.home-category-strip::-webkit-scrollbar {
@@ -775,18 +805,50 @@ defineExpose({
}
.home-category-chip {
- min-width: 70px;
- height: 42px;
- flex: 0 0 auto;
+ min-width: 112px;
+ height: 58px;
+ flex: 0 0 112px;
display: flex;
align-items: center;
- justify-content: center;
- gap: 6px;
- padding: 0 10px;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 0 11px;
box-sizing: border-box;
- background: #f4f5ef;
- border: 1px solid #dde5df;
+ background: #e0df00;
+ border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 8px;
+ background-image:
+ linear-gradient(116deg, rgba(255, 255, 255, 0.22) 0 32%, rgba(255, 255, 255, 0) 33%),
+ linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(0, 0, 0, 0.02));
+ box-shadow: 0 8px 18px rgba(0, 0, 0, 0.16);
+}
+
+.home-category-chip:nth-child(2) {
+ background-color: #18c2b6;
+}
+
+.home-category-chip:nth-child(3) {
+ background-color: #b99cf0;
+}
+
+.home-category-chip:nth-child(4) {
+ background-color: #8ad9ae;
+}
+
+.home-category-chip:nth-child(5) {
+ background-color: #ffc0a1;
+}
+
+.home-category-chip:nth-child(6) {
+ background-color: #5ed0e4;
+}
+
+.home-category-chip:nth-child(7) {
+ background-color: #c9a463;
+}
+
+.home-category-chip:nth-child(8) {
+ background-color: #f4f5ef;
}
.home-category-chip:active {
@@ -794,14 +856,16 @@ defineExpose({
}
.home-category-icon {
- width: 20px;
- height: 19px;
- flex: 0 0 20px;
+ width: 24px;
+ height: 23px;
+ flex: 0 0 24px;
+ order: 2;
+ color: #050704;
}
.home-category-icon.line-icon {
- width: 20px;
- height: 19px;
+ width: 24px;
+ height: 23px;
}
.home-category-icon .icon-part {
@@ -810,11 +874,12 @@ defineExpose({
}
.home-category-label {
- max-width: 58px;
- font-size: 12px;
- line-height: 16px;
- font-weight: 700;
- color: #151713;
+ min-width: 0;
+ max-width: 70px;
+ font-size: 14px;
+ line-height: 18px;
+ font-weight: 800;
+ color: #050704;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -919,6 +984,7 @@ defineExpose({
}
.variant-page .poi-search-content {
+ margin-top: 14px;
flex: 1;
min-height: 0;
display: flex;
@@ -959,30 +1025,63 @@ defineExpose({
padding-right: 0;
}
+.category-scroll {
+ width: 100%;
+ max-width: 100%;
+ overflow-x: auto;
+ overflow-y: hidden;
+ padding-bottom: 4px;
+ scrollbar-width: none;
+ overscroll-behavior-x: contain;
+ -webkit-overflow-scrolling: touch;
+ touch-action: pan-x;
+}
+
+.category-scroll::-webkit-scrollbar {
+ display: none;
+}
+
.category-grid {
+ --category-gap: 8px;
display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 8px;
+ grid-auto-flow: column;
+ grid-template-rows: repeat(2, minmax(70px, auto));
+ grid-auto-columns: calc((100% - 32px) / 5);
+ gap: var(--category-gap);
+ width: calc(125% + 2px);
+ min-width: calc(125% + 2px);
}
.category-item {
min-width: 0;
min-height: 70px;
- padding: 9px 6px;
+ position: relative;
+ padding: 9px 6px 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 7px;
box-sizing: border-box;
- background: rgba(255, 255, 255, 0.92);
- border: 1px solid #e2e4da;
+ overflow: hidden;
+ background: #fffef2;
+ border: 1px solid #ecebd2;
border-radius: 8px;
}
.category-item.active {
- background: #151713;
- border-color: #151713;
+ background: #fffddc;
+ border-color: #d8d900;
+}
+
+.category-item.active::after {
+ content: '';
+ position: absolute;
+ left: 14px;
+ right: 14px;
+ bottom: 0;
+ height: 4px;
+ background: var(--museum-accent);
}
.line-icon {
@@ -993,7 +1092,7 @@ defineExpose({
}
.category-item.active .line-icon {
- color: var(--museum-accent);
+ color: #151713;
}
.icon-part {
@@ -1002,6 +1101,8 @@ defineExpose({
}
.icon-service .icon-part.a,
+.icon-exhibition .icon-part.a,
+.icon-cinema .icon-part.a,
.icon-ticket .icon-part.a,
.icon-drink .icon-part.a,
.icon-bag .icon-part.a {
@@ -1014,6 +1115,8 @@ defineExpose({
}
.icon-service .icon-part.b,
+.icon-exhibition .icon-part.b,
+.icon-cinema .icon-part.b,
.icon-ticket .icon-part.b,
.icon-drink .icon-part.b,
.icon-bag .icon-part.b {
@@ -1030,6 +1133,8 @@ defineExpose({
.icon-escalator .icon-part.a,
.icon-restroom .icon-part.a,
.icon-nursing .icon-part.a,
+.icon-restaurant .icon-part.a,
+.icon-stairs .icon-part.a,
.icon-entrance .icon-part.a {
left: 4px;
right: 4px;
@@ -1069,6 +1174,63 @@ defineExpose({
box-shadow: 8px 0 0 currentColor;
}
+.icon-exhibition .icon-part.c {
+ left: 6px;
+ right: 6px;
+ bottom: 2px;
+ height: 2px;
+ background: currentColor;
+ border-radius: 2px;
+}
+
+.icon-cinema .icon-part.b {
+ left: 2px;
+ right: 2px;
+ top: 4px;
+ height: 4px;
+ border: 0;
+ border-top: 2px solid currentColor;
+ border-bottom: 2px solid currentColor;
+ border-radius: 0;
+}
+
+.icon-restaurant .icon-part.b {
+ left: 7px;
+ top: 6px;
+ width: 2px;
+ height: 12px;
+ background: currentColor;
+ box-shadow: 5px 0 0 currentColor, 10px 0 0 currentColor;
+}
+
+.icon-restaurant .icon-part.c {
+ right: 3px;
+ top: 5px;
+ width: 7px;
+ height: 15px;
+ border-left: 2px solid currentColor;
+ border-bottom: 2px solid currentColor;
+ border-radius: 0 0 0 5px;
+}
+
+.icon-stairs .icon-part.b {
+ left: 6px;
+ top: 7px;
+ width: 14px;
+ height: 12px;
+ border-left: 2px solid currentColor;
+ border-bottom: 2px solid currentColor;
+}
+
+.icon-stairs .icon-part.c {
+ left: 10px;
+ top: 11px;
+ width: 10px;
+ height: 8px;
+ border-left: 2px solid currentColor;
+ border-bottom: 2px solid currentColor;
+}
+
.category-label {
max-width: 100%;
font-size: 12px;
@@ -1082,12 +1244,24 @@ defineExpose({
}
.category-item.active .category-label {
- color: var(--museum-accent);
+ color: #151713;
}
.section-title-row {
- margin-top: 14px;
- margin-bottom: 8px;
+ position: relative;
+ margin-top: 16px;
+ margin-bottom: 10px;
+ padding-left: 12px;
+}
+
+.section-title-row::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 3px;
+ width: 4px;
+ height: 16px;
+ background: var(--museum-accent);
}
.section-title {
@@ -1113,19 +1287,30 @@ defineExpose({
.facility-chip {
height: 34px;
flex: 0 0 auto;
+ position: relative;
display: flex;
align-items: center;
gap: 7px;
padding: 0 12px;
box-sizing: border-box;
- background: rgba(255, 255, 255, 0.94);
- border: 1px solid #e2e4da;
+ background: #fffef6;
+ border: 1px solid #e8e7d2;
border-radius: 8px;
}
.facility-chip.active {
- background: #151713;
- border-color: #151713;
+ background: #fffddc;
+ border-color: #d6d700;
+}
+
+.facility-chip.active::after {
+ content: '';
+ position: absolute;
+ left: 12px;
+ right: 12px;
+ bottom: -1px;
+ height: 3px;
+ background: var(--museum-accent);
}
.chip-icon {
@@ -1140,7 +1325,7 @@ defineExpose({
}
.facility-chip.active .chip-icon {
- color: var(--museum-accent);
+ color: #151713;
}
.facility-chip-text {
@@ -1152,26 +1337,27 @@ defineExpose({
}
.facility-chip.active .facility-chip-text {
- color: var(--museum-accent);
+ color: #151713;
}
.result-card {
- margin-top: 12px;
+ margin-top: 14px;
overflow: hidden;
- background: rgba(255, 255, 255, 0.96);
- border: 1px solid #e2e4da;
+ background: #fffef7;
+ border: 1px solid #ecebd8;
border-radius: 8px;
+ box-shadow: 0 10px 24px rgba(75, 78, 60, 0.08);
}
.result-card-header {
- height: 42px;
- padding: 0 12px;
+ height: 46px;
+ padding: 0 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
box-sizing: border-box;
- border-bottom: 1px solid #eceee6;
+ border-bottom: 1px solid #efeedf;
}
.museum-title,
@@ -1196,14 +1382,15 @@ defineExpose({
}
.floor-tabs {
- width: 58px;
- flex: 0 0 58px;
- padding: 8px 0;
+ width: 62px;
+ flex: 0 0 62px;
+ padding: 9px 0;
box-sizing: border-box;
- background: #f3f4ee;
+ background: #f6f5e8;
}
.floor-tab {
+ position: relative;
height: 31px;
display: flex;
align-items: center;
@@ -1216,6 +1403,16 @@ defineExpose({
font-weight: 800;
}
+.floor-tab.active::after {
+ content: '';
+ position: absolute;
+ left: 8px;
+ right: 8px;
+ bottom: 2px;
+ height: 3px;
+ background: var(--museum-accent);
+}
+
.floor-tab-text {
font-size: 12px;
line-height: 16px;
@@ -1227,6 +1424,7 @@ defineExpose({
}
.variant-page .category-grid,
+.variant-page .category-scroll,
.variant-page .section-title-row,
.variant-page .facility-chip-row {
flex: 0 0 auto;
@@ -1257,6 +1455,7 @@ defineExpose({
}
.variant-home.expanded .category-grid,
+.variant-home.expanded .category-scroll,
.variant-home.expanded .section-title-row,
.variant-home.expanded .facility-chip-row {
flex: 0 0 auto;
@@ -1282,13 +1481,13 @@ defineExpose({
}
.result-row {
- min-height: 54px;
+ min-height: 58px;
display: flex;
align-items: center;
gap: 9px;
- padding: 9px 10px;
+ padding: 10px 12px;
box-sizing: border-box;
- border-bottom: 1px solid #edf0e8;
+ border-bottom: 1px solid #efefe1;
}
.result-row:last-child {
@@ -1377,12 +1576,7 @@ defineExpose({
@media (max-width: 360px) {
.category-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- .variant-page .category-grid,
- .variant-home.expanded .category-grid {
- grid-template-columns: repeat(3, minmax(0, 1fr));
+ grid-template-rows: repeat(2, minmax(62px, auto));
}
.variant-page .category-item,
diff --git a/src/config/dataSource.ts b/src/config/dataSource.ts
index 8dbf892..a18d6ae 100644
--- a/src/config/dataSource.ts
+++ b/src/config/dataSource.ts
@@ -9,7 +9,7 @@ const defaultSdkTimeoutMs = 5000
const defaultApiBaseUrl = '/app-api'
const defaultSgsMapId = '1'
const defaultGuideStaticDataBaseUrl = '/static/guide-data'
-const defaultAudioApiBaseUrl = '/yudao-server'
+const defaultAudioApiBaseUrl = '/app-api'
const normalizeMode = (mode: string | undefined): DataSourceMode => {
if (mode && allowedModes.has(mode as DataSourceMode)) {
@@ -24,9 +24,13 @@ const normalizeGuideContentMode = (mode: string | undefined): GuideContentDataSo
return mode as GuideContentDataSourceMode
}
- return 'static'
+ return 'remote'
}
+const normalizeBoolean = (value: string | undefined) => (
+ ['1', 'true', 'yes', 'on'].includes(value?.trim().toLowerCase() || '')
+)
+
const normalizeUrl = (url: string | undefined, fallback: string) => {
const normalized = url?.trim()
return normalized || fallback
@@ -50,6 +54,8 @@ const inferOrigin = (url: string) => {
export const dataSourceConfig = {
mode: normalizeMode(import.meta.env.VITE_DATA_SOURCE_MODE),
guideContentMode: normalizeGuideContentMode(import.meta.env.VITE_GUIDE_CONTENT_SOURCE_MODE),
+ allowExplainStaticFallback: import.meta.env.DEV
+ && normalizeBoolean(import.meta.env.VITE_EXPLAIN_ALLOW_STATIC_FALLBACK),
guideStaticDataBaseUrl: normalizeUrl(
import.meta.env.VITE_GUIDE_STATIC_DATA_BASE_URL,
defaultGuideStaticDataBaseUrl
diff --git a/src/data/adapters/backendExplainDataAdapter.ts b/src/data/adapters/backendExplainDataAdapter.ts
index 83763fd..0f1d5ba 100644
--- a/src/data/adapters/backendExplainDataAdapter.ts
+++ b/src/data/adapters/backendExplainDataAdapter.ts
@@ -61,12 +61,22 @@ export interface BackendExhibit {
export interface BackendHall {
id?: string | number | null
name?: string | null
+ poiId?: string | number | null
hallCode?: string | null
nameEn?: string | null
subtitle?: string | null
description?: string | null
coverImageUrl?: string | null
+ floorId?: string | number | null
+ floorCode?: string | null
exhibitCount?: number | null
+ outlineCount?: number | null
+ stopCount?: number | null
+ linkedExhibitCount?: number | null
+ audioReadyStopCount?: number | null
+ hasAudio?: boolean | null
+ audioStatus?: string | null
+ supportedLanguages?: string[] | null
}
export interface BackendGuideStop {
@@ -87,6 +97,61 @@ export interface BackendGuideStop {
sort?: number | null
}
+export interface BackendCatalogHallItem extends BackendHall {
+ mapId?: string | number | null
+ areaSqm?: string | number | null
+ sortOrder?: number | null
+}
+
+export interface BackendCatalogOutlineItem {
+ id?: string | number | null
+ parentId?: string | number | null
+ hallId?: string | number | null
+ name?: string | null
+ code?: string | null
+ description?: string | null
+ sort?: number | null
+ level?: number | null
+ children?: BackendCatalogOutlineItem[] | null
+ stopCount?: number | null
+ linkedExhibitCount?: number | null
+ audioReadyStopCount?: number | null
+ hasAudio?: boolean | null
+ audioStatus?: string | null
+ supportedLanguages?: string[] | null
+}
+
+export interface BackendCatalogLinkedExhibitItem {
+ id?: string | number | null
+ name?: string | null
+ nameEn?: string | null
+ code?: string | null
+ exhibitCode?: string | null
+ coverImageUrl?: string | null
+ sortOrder?: number | null
+}
+
+export interface BackendCatalogStopItem {
+ stopId?: string | number | null
+ id?: string | number | null
+ outlineId?: string | number | null
+ name?: string | null
+ guideLevel?: string | null
+ description?: string | null
+ sort?: number | null
+ coverImageUrl?: string | null
+ imageStatus?: string | null
+ linkedExhibitCount?: number | null
+ isSharedStop?: boolean | null
+ linkedExhibits?: BackendCatalogLinkedExhibitItem[] | null
+ hasAudio?: boolean | null
+ audioStatus?: string | null
+ supportedLanguages?: string[] | null
+ hasTextRecord?: boolean | null
+ playTargetType?: string | null
+ playTargetId?: string | number | null
+}
+
export interface BackendExplainAdapterResult {
exhibit: MuseumExhibit
track: ExplainTrack
@@ -130,6 +195,15 @@ const normalizeAudioTargetType = (targetType: string | null | undefined): AudioP
return normalized === 'ITEM' || normalized === 'STOP' ? normalized : undefined
}
+const normalizeNumber = (value: number | string | null | undefined) => {
+ if (typeof value === 'number' && Number.isFinite(value)) return value
+ if (typeof value === 'string' && value.trim()) {
+ const numeric = Number(value)
+ return Number.isFinite(numeric) ? numeric : undefined
+ }
+ return undefined
+}
+
const isAudioReady = (value?: string | null) => (
!value || ['READY', 'PUBLISHED', 'AVAILABLE'].includes(value.toUpperCase())
)
@@ -170,6 +244,230 @@ const buildTags = (exhibit: BackendExhibit) => Array.from(new Set([
firstText(exhibit.origin)
].filter(Boolean)))
+const normalizeSupportedLanguages = (languages: string[] | null | undefined) => (
+ Array.isArray(languages) ? languages.map(String).filter(Boolean) : []
+)
+
+const normalizeCatalogAudioStatus = (status?: string | null) => (
+ firstText(status) || 'MISSING'
+)
+
+export const toCatalogHall = (
+ source: BackendCatalogHallItem,
+ fallback?: MuseumHall | null
+): MuseumHall => {
+ const id = stringifyId(source.id) || fallback?.id || firstText(source.hallCode, source.name)
+ const name = firstText(source.name, fallback?.name, source.hallCode, '展厅')
+ const area = firstText(source.areaSqm)
+
+ return {
+ id,
+ name,
+ floorId: stringifyId(source.floorId) || fallback?.floorId,
+ floorLabel: firstText(source.floorCode, fallback?.floorLabel) || undefined,
+ description: firstText(source.description, source.subtitle, fallback?.description, '该展厅暂无介绍。'),
+ image: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || fallback?.image || HALL_PLACEHOLDER_IMAGE,
+ exhibitCount: normalizeNumber(source.exhibitCount) ?? fallback?.exhibitCount ?? 0,
+ outlineCount: normalizeNumber(source.outlineCount),
+ stopCount: normalizeNumber(source.stopCount),
+ linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
+ audioReadyStopCount: normalizeNumber(source.audioReadyStopCount),
+ hasAudio: source.hasAudio === true,
+ audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
+ supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
+ area: area ? `${area}㎡` : fallback?.area,
+ poiId: stringifyId(source.poiId) || fallback?.poiId,
+ location: fallback?.location
+ }
+}
+
+export const toCatalogLinkedExhibit = (
+ source: BackendCatalogLinkedExhibitItem,
+ hall?: MuseumHall | null
+): MuseumExhibit | null => {
+ const id = stringifyId(source.id)
+ if (!id) return null
+
+ return {
+ id,
+ name: firstText(source.name, source.exhibitCode, source.code, `展品${id}`),
+ hallId: hall?.id,
+ hallName: hall?.name,
+ floorId: hall?.floorId,
+ floorLabel: hall?.floorLabel,
+ image: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined,
+ size: firstText(source.exhibitCode, source.code) || undefined
+ }
+}
+
+const toCatalogLinkedExhibitSummary = (source: BackendCatalogLinkedExhibitItem) => {
+ const id = stringifyId(source.id)
+ if (!id) return null
+
+ return {
+ id,
+ name: firstText(source.name, source.exhibitCode, source.code, `展品 ${id}`),
+ nameEn: firstText(source.nameEn) || undefined,
+ exhibitCode: firstText(source.exhibitCode, source.code) || undefined,
+ coverImageUrl: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined
+ }
+}
+
+export const toCatalogGuideStop = (
+ source: BackendCatalogStopItem,
+ hall?: MuseumHall | null,
+ outline?: Pick | null
+): ExplainGuideStop | null => {
+ const id = stringifyId(source.stopId) || stringifyId(source.id)
+ if (!id) return null
+
+ const playTargetType = normalizeAudioTargetType(source.playTargetType) || 'STOP'
+ const playTargetId = stringifyId(source.playTargetId) || id
+ const imageStatus = firstText(source.imageStatus) || 'MISSING'
+ const canUseStopImage = imageStatus === 'READY'
+
+ return {
+ id,
+ name: firstText(source.name, `讲解点${id}`),
+ hallId: hall?.id,
+ hallName: hall?.name,
+ floorId: hall?.floorId,
+ targetType: playTargetType,
+ targetId: playTargetId,
+ coverImageUrl: canUseStopImage
+ ? normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined
+ : undefined,
+ description: firstText(source.description) || undefined,
+ hasAudio: source.hasAudio === true,
+ audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
+ supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
+ hasTextRecord: source.hasTextRecord === true,
+ outlineId: stringifyId(source.outlineId) || outline?.id,
+ outlineName: outline?.name,
+ sort: typeof source.sort === 'number' ? source.sort : undefined,
+ guideLevel: firstText(source.guideLevel) || undefined,
+ imageStatus,
+ linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
+ isSharedStop: source.isSharedStop === true,
+ linkedExhibits: (source.linkedExhibits || [])
+ .map(toCatalogLinkedExhibitSummary)
+ .filter(Boolean) as NonNullable,
+ playTargetType,
+ playTargetId
+ }
+}
+
+export const toCatalogMuseumExhibitFromStop = (
+ stop: ExplainGuideStop,
+ hall?: MuseumHall | null
+): MuseumExhibit => {
+ const linkedPrimary = stop.linkedExhibits?.[0]
+ const audioReady = stop.audioStatus === 'READY'
+
+ return {
+ id: stop.id,
+ name: stop.name,
+ hallId: stop.hallId || hall?.id,
+ hallName: stop.hallName || hall?.name,
+ floorId: stop.floorId || hall?.floorId,
+ floorLabel: hall?.floorLabel,
+ image: stop.coverImageUrl || undefined,
+ description: stop.description || linkedPrimary?.name || '该讲解点暂无简介。',
+ tags: [stop.outlineName, linkedPrimary?.exhibitCode].filter(Boolean) as string[],
+ guideTitle: stop.name,
+ guideText: stop.description,
+ audioAvailable: audioReady,
+ audioStatus: stop.audioStatus,
+ supportedLanguages: stop.supportedLanguages,
+ audioHasText: stop.hasTextRecord,
+ imageStatus: stop.imageStatus,
+ linkedExhibitCount: stop.linkedExhibitCount,
+ isSharedStop: stop.isSharedStop,
+ linkedExhibits: stop.linkedExhibits,
+ stopInfoAvailable: true,
+ resolvedStopId: stop.id,
+ playTargetType: stop.playTargetType || stop.targetType || 'STOP',
+ playTargetId: stop.playTargetId || stop.targetId || stop.id
+ }
+}
+
+const outlineKey = (outlineId?: string | null) => outlineId || '__unassigned__'
+
+export const flattenCatalogOutlines = (
+ outlines: BackendCatalogOutlineItem[]
+): BackendCatalogOutlineItem[] => {
+ const flattened: BackendCatalogOutlineItem[] = []
+
+ const visit = (items: BackendCatalogOutlineItem[]) => {
+ items
+ .slice()
+ .sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
+ .forEach((item) => {
+ flattened.push(item)
+ if (Array.isArray(item.children) && item.children.length) {
+ visit(item.children)
+ }
+ })
+ }
+
+ visit(outlines)
+ return flattened
+}
+
+export const toCatalogBusinessUnits = (
+ hallId: string,
+ outlines: BackendCatalogOutlineItem[],
+ stops: ExplainGuideStop[]
+): ExplainBusinessUnit[] => {
+ const flatOutlines = flattenCatalogOutlines(outlines)
+ const stopMap = new Map()
+ stops.forEach((stop) => {
+ const key = outlineKey(stop.outlineId)
+ stopMap.set(key, [...(stopMap.get(key) || []), stop])
+ })
+
+ const outlineChildren = new Set(
+ flatOutlines
+ .map((item) => stringifyId(item.parentId))
+ .filter((parentId) => parentId && parentId !== hallId)
+ )
+
+ const units = flatOutlines
+ .map((outline) => {
+ const id = stringifyId(outline.id)
+ if (!id) return null
+
+ const directStops = (stopMap.get(id) || [])
+ .slice()
+ .sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
+ const hasChildOutlines = outlineChildren.has(id)
+ const rawStopCount = normalizeNumber(outline.stopCount) ?? 0
+ if (!directStops.length && hasChildOutlines) return null
+
+ return {
+ id,
+ name: firstText(outline.name, outline.code, `业务单元${id}`),
+ hallId: stringifyId(outline.hallId) || hallId,
+ parentId: stringifyId(outline.parentId) || undefined,
+ code: firstText(outline.code) || undefined,
+ description: firstText(outline.description) || undefined,
+ sort: typeof outline.sort === 'number' ? outline.sort : undefined,
+ level: typeof outline.level === 'number' ? outline.level : undefined,
+ guideStopCount: directStops.length || rawStopCount,
+ linkedExhibitCount: normalizeNumber(outline.linkedExhibitCount),
+ audioReadyStopCount: normalizeNumber(outline.audioReadyStopCount),
+ hasAudio: outline.hasAudio === true,
+ audioStatus: normalizeCatalogAudioStatus(outline.audioStatus),
+ supportedLanguages: normalizeSupportedLanguages(outline.supportedLanguages),
+ stops: directStops
+ }
+ })
+ .filter(Boolean) as ExplainBusinessUnit[]
+
+ if (units.length) return units
+ return groupGuideStopsByOutline(hallId, stops)
+}
+
export const toBackendMuseumExhibit = (
source: BackendExhibit,
hall?: MuseumHall | null,
@@ -277,15 +575,22 @@ export const toBackendHallFromList = (source: BackendHall, fallback?: MuseumHall
return {
id,
name,
- floorId: fallback?.floorId,
- floorLabel: fallback?.floorLabel,
+ floorId: stringifyId(source.floorId) || fallback?.floorId,
+ floorLabel: firstText(source.floorCode, fallback?.floorLabel) || undefined,
description: firstText(source.description, source.subtitle, fallback?.description, '该展厅暂无介绍。'),
image: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || fallback?.image || HALL_PLACEHOLDER_IMAGE,
exhibitCount: typeof source.exhibitCount === 'number'
? source.exhibitCount
: fallback?.exhibitCount || 0,
+ outlineCount: normalizeNumber(source.outlineCount),
+ stopCount: normalizeNumber(source.stopCount),
+ linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
+ audioReadyStopCount: normalizeNumber(source.audioReadyStopCount),
+ hasAudio: source.hasAudio === true,
+ audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
+ supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
area: fallback?.area,
- poiId: fallback?.poiId,
+ poiId: stringifyId(source.poiId) || fallback?.poiId,
location: fallback?.location
}
}
diff --git a/src/data/adapters/guideStopInfoAdapter.ts b/src/data/adapters/guideStopInfoAdapter.ts
index 3c03948..732598f 100644
--- a/src/data/adapters/guideStopInfoAdapter.ts
+++ b/src/data/adapters/guideStopInfoAdapter.ts
@@ -18,6 +18,7 @@ export interface BackendGuideStopInfo {
targetType?: string | null
targetId?: string | number | null
resolvedStopId?: string | number | null
+ stopId?: string | number | null
lang?: string | null
title?: string | null
description?: string | null
@@ -33,6 +34,40 @@ export interface BackendGuideStopInfo {
supportedLanguages?: string[] | null
audioStatus?: string | null
reason?: string | null
+ linkedExhibitCount?: number | string | null
+ isSharedStop?: boolean | null
+}
+
+export interface BackendAudioPlayInfo {
+ playable?: boolean
+ targetType?: string | null
+ targetId?: string | number | null
+ lang?: string | null
+ narrationTier?: 'STANDARD' | 'EXTENDED' | string | null
+ audioId?: string | number | null
+ title?: string | null
+ duration?: number | null
+ format?: string | null
+ playUrl?: string | null
+ expiresAt?: string | null
+ subtitleUrl?: string | null
+ hasText?: boolean
+ fallback?: boolean
+ fallbackReason?: string | null
+ reason?: string | null
+}
+
+export interface BackendAudioTextInfo {
+ available?: boolean
+ targetType?: string | null
+ targetId?: string | number | null
+ lang?: string | null
+ narrationTier?: 'STANDARD' | 'EXTENDED' | string | null
+ title?: string | null
+ text?: string | null
+ textLength?: number | null
+ textHash?: string | null
+ reason?: string | null
}
export interface GuideStopLinkedExhibit {
@@ -63,12 +98,55 @@ export interface GuideStopInfo {
supportedLanguages: string[]
audioStatus: 'READY' | 'MISSING' | string
reason?: string
+ linkedExhibitCount?: number
+ isSharedStop?: boolean
+}
+
+export interface GuideAudioPlayInfo {
+ playable: boolean
+ targetType: AudioPlayTargetType
+ targetId: string
+ lang: 'zh-CN' | 'en-US'
+ narrationTier?: 'STANDARD' | 'EXTENDED'
+ audioId?: string
+ title?: string
+ duration?: number
+ format?: string
+ playUrl?: string
+ expiresAt?: string
+ subtitleUrl?: string
+ hasText: boolean
+ fallback: boolean
+ fallbackReason?: string
+ reason?: string
+}
+
+export interface GuideAudioTextInfo {
+ available: boolean
+ targetType: AudioPlayTargetType
+ targetId: string
+ lang: 'zh-CN' | 'en-US'
+ narrationTier?: 'STANDARD' | 'EXTENDED'
+ title?: string
+ text?: string
+ textLength?: number
+ textHash?: string
+ reason?: string
}
const stringifyId = (value: string | number | null | undefined) => (
value === null || typeof value === 'undefined' ? '' : String(value)
)
+const normalizeNumber = (value: number | string | null | undefined) => {
+ if (typeof value === 'number' && Number.isFinite(value)) return value
+ if (typeof value === 'string' && value.trim()) {
+ const numeric = Number(value)
+ return Number.isFinite(numeric) ? numeric : undefined
+ }
+ return undefined
+}
+
const normalizeTargetType = (value: string | null | undefined, fallback: AudioPlayTargetType): AudioPlayTargetType => {
const normalized = value?.toUpperCase()
return normalized === 'ITEM' || normalized === 'STOP' ? normalized : fallback
@@ -95,13 +173,15 @@ const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls']) => {
.filter(Boolean)
}
} catch {
- // 兼容后端历史逗号拼接字段。
+ return []
}
- return trimmed
- .split(',')
- .map((entry) => normalizeSameOriginPublicUrl(entry.trim()))
- .filter(Boolean)
+ return []
+}
+
+const normalizeNarrationTier = (value: string | null | undefined) => {
+ const normalized = value?.toUpperCase()
+ return normalized === 'STANDARD' || normalized === 'EXTENDED' ? normalized : undefined
}
const normalizeLinkedExhibits = (items: BackendGuideStopLinkedExhibit[] | null | undefined) => (
@@ -144,7 +224,7 @@ export const toGuideStopInfo = (
available: source.available === true,
targetType,
targetId,
- resolvedStopId: stringifyId(source.resolvedStopId) || undefined,
+ resolvedStopId: stringifyId(source.resolvedStopId) || stringifyId(source.stopId) || undefined,
lang: normalizeLanguage(source.lang || fallback.lang),
title: source.title?.trim() || '讲解内容',
description: source.description?.trim() || undefined,
@@ -159,6 +239,64 @@ export const toGuideStopInfo = (
hasText: source.hasText === true,
supportedLanguages: source.supportedLanguages || [],
audioStatus: source.audioStatus || 'MISSING',
+ reason: source.reason || undefined,
+ linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
+ isSharedStop: source.isSharedStop === true
+ }
+}
+
+export const toGuideAudioPlayInfo = (
+ source: BackendAudioPlayInfo,
+ fallback: {
+ targetType: AudioPlayTargetType
+ targetId: string
+ lang: 'zh-CN' | 'en-US'
+ }
+): GuideAudioPlayInfo => {
+ const targetType = normalizeTargetType(source.targetType, fallback.targetType)
+ const targetId = stringifyId(source.targetId) || fallback.targetId
+
+ return {
+ playable: source.playable === true,
+ targetType,
+ targetId,
+ lang: normalizeLanguage(source.lang || fallback.lang),
+ narrationTier: normalizeNarrationTier(source.narrationTier),
+ audioId: stringifyId(source.audioId) || undefined,
+ title: source.title?.trim() || undefined,
+ duration: typeof source.duration === 'number' && Number.isFinite(source.duration) ? source.duration : undefined,
+ format: source.format?.trim() || undefined,
+ playUrl: normalizeSameOriginPublicUrl(source.playUrl) || undefined,
+ expiresAt: source.expiresAt || undefined,
+ subtitleUrl: normalizeSameOriginPublicUrl(source.subtitleUrl) || undefined,
+ hasText: source.hasText === true,
+ fallback: source.fallback === true,
+ fallbackReason: source.fallbackReason || undefined,
+ reason: source.reason || undefined
+ }
+}
+
+export const toGuideAudioTextInfo = (
+ source: BackendAudioTextInfo,
+ fallback: {
+ targetType: AudioPlayTargetType
+ targetId: string
+ lang: 'zh-CN' | 'en-US'
+ }
+): GuideAudioTextInfo => {
+ const targetType = normalizeTargetType(source.targetType, fallback.targetType)
+ const targetId = stringifyId(source.targetId) || fallback.targetId
+
+ return {
+ available: source.available === true,
+ targetType,
+ targetId,
+ lang: normalizeLanguage(source.lang || fallback.lang),
+ narrationTier: normalizeNarrationTier(source.narrationTier),
+ title: source.title?.trim() || undefined,
+ text: source.text || undefined,
+ textLength: typeof source.textLength === 'number' && Number.isFinite(source.textLength) ? source.textLength : undefined,
+ textHash: source.textHash || undefined,
reason: source.reason || undefined
}
}
diff --git a/src/data/providers/backendExplainContentProvider.ts b/src/data/providers/backendExplainContentProvider.ts
index b037528..0a2b2e1 100644
--- a/src/data/providers/backendExplainContentProvider.ts
+++ b/src/data/providers/backendExplainContentProvider.ts
@@ -11,21 +11,15 @@ import type {
ExplainContentProvider
} from '@/data/providers/staticMuseumContentProvider'
import {
- defaultSgsSdkApiProvider,
- type SgsSdkApiProvider
-} from '@/data/providers/sgsSdkApiProvider'
-import {
- toBackendExplainTrack,
- toBackendHallFromList,
- toBackendMediaAsset,
- toBackendMuseumExhibit,
- groupGuideStopsByOutline,
- type BackendExhibit,
- type BackendHall
+ toCatalogBusinessUnits,
+ toCatalogGuideStop,
+ toCatalogHall,
+ toCatalogMuseumExhibitFromStop,
+ flattenCatalogOutlines,
+ type BackendCatalogHallItem,
+ type BackendCatalogOutlineItem,
+ type BackendCatalogStopItem
} from '@/data/adapters/backendExplainDataAdapter'
-import {
- toExplainGuideStopFromSgs
-} from '@/data/adapters/sgsSdkGuideAdapter'
interface CommonResult {
code: number
@@ -79,234 +73,414 @@ const requestJson = (url: string): Promise => new Promise((resolve, reject
const normalizeKeyword = (keyword = '') => keyword.trim()
+const catalogLang = () => (
+ dataSourceConfig.audioLanguage === 'en-US' ? 'en-US' : 'zh-CN'
+)
+
+const cacheKey = (...parts: string[]) => [catalogLang(), ...parts].join(':')
+
+const requireArrayData = (response: CommonResult, message: string) => {
+ if (response.code !== 0) {
+ throw new Error(response.msg || message)
+ }
+
+ return Array.isArray(response.data) ? response.data : []
+}
+
export class BackendExplainContentProvider implements ExplainContentProvider {
- private readonly searchCache = new Map()
- private readonly searchInflight = new Map>()
+ private readonly explainExhibitCache = new Map()
+ private readonly explainExhibitInflight = new Map>()
private readonly detailCache = new Map()
- private readonly detailInflight = new Map>()
- private hallListCache: MuseumHall[] | null = null
- private hallListInflight: Promise | null = null
+ private readonly hallListCache = new Map()
+ private readonly hallListInflight = new Map>()
private readonly guideStopCache = new Map()
private readonly guideStopInflight = new Map>()
+ private readonly outlineCache = new Map()
+ private readonly outlineInflight = new Map>()
+ private readonly businessUnitCache = new Map()
+ private readonly businessUnitInflight = new Map>()
constructor(
- private readonly fallbackProvider: ExplainContentProvider,
- private readonly sgsSdkApiProvider: SgsSdkApiProvider = defaultSgsSdkApiProvider
+ private readonly fallbackProvider?: ExplainContentProvider
) {}
- private async requestStaticExplainExhibits() {
- const cached = this.searchCache.get('')
- if (cached) return cached
-
- const exhibits = await this.fallbackProvider.listExplainExhibits()
- this.searchCache.set('', exhibits)
- return exhibits
- }
-
- private async searchStaticExplainExhibits(keyword = '') {
- const normalizedKeyword = normalizeKeyword(keyword).toLowerCase()
- const exhibits = await this.requestStaticExplainExhibits()
- if (!normalizedKeyword) return exhibits
-
- return exhibits.filter((exhibit) => [
- exhibit.name,
- exhibit.hallName,
- exhibit.floorLabel,
- exhibit.description,
- ...(exhibit.tags || [])
- ].filter(Boolean).join(' ').toLowerCase().includes(normalizedKeyword))
- }
-
- private async requestSearch(keyword = '') {
- const normalizedKeyword = normalizeKeyword(keyword)
- if (!normalizedKeyword) return this.requestStaticExplainExhibits()
-
- const cacheKey = normalizedKeyword.toLowerCase()
- const cached = this.searchCache.get(cacheKey)
- if (cached) return cached
-
- const inflight = this.searchInflight.get(cacheKey)
- if (inflight) return inflight
-
- const promise = (async () => {
- const params = new URLSearchParams({ keyword: normalizedKeyword })
- const url = `${resolveAppApiBaseUrl()}/gis/exhibit/search?${params.toString()}`
- const response = await requestJson>(url)
- if (response.code !== 0 || !Array.isArray(response.data)) {
- throw new Error(response.msg || '后端讲解搜索失败')
- }
-
- const fallbackHalls = await this.safeFallbackHalls()
- const fallbackHallById = new Map(fallbackHalls.map((hall) => [hall.id, hall]))
- const exhibits = response.data.map((item) => toBackendMuseumExhibit(
- item,
- fallbackHallById.get(String(item.hallId || '')),
- { includeDetail: false }
- ))
-
- this.searchCache.set(cacheKey, exhibits)
- return exhibits
- })()
-
- this.searchInflight.set(cacheKey, promise)
- try {
- return await promise
- } finally {
- this.searchInflight.delete(cacheKey)
- }
- }
-
- private async requestDetail(id: string) {
- const cached = this.detailCache.get(id)
- if (cached) return cached
-
- const inflight = this.detailInflight.get(id)
- if (inflight) return inflight
-
- const promise = (async () => {
- const params = new URLSearchParams({ id })
- const url = `${resolveAppApiBaseUrl()}/gis/exhibit/get?${params.toString()}`
- const response = await requestJson>(url)
- if (response.code !== 0 || !response.data) {
- throw new Error(response.msg || '后端讲解详情失败')
- }
-
- const fallbackHalls = await this.safeFallbackHalls()
- const fallbackHall = fallbackHalls.find((hall) => hall.id === String(response.data?.hallId || ''))
- const detail = toBackendMuseumExhibit(response.data, fallbackHall, { includeDetail: true })
- this.detailCache.set(id, detail)
- return detail
- })().catch(async (error) => {
- console.warn('后端讲解详情加载失败,将使用静态讲解兜底:', error)
- return this.fallbackProvider.listExhibits()
- .then((items) => items.find((item) => item.id === id) || null)
- .catch(() => null)
- })
-
- this.detailInflight.set(id, promise)
- try {
- return await promise
- } finally {
- this.detailInflight.delete(id)
- }
- }
-
private async safeFallbackHalls() {
- return this.fallbackProvider.listHalls().catch(() => [])
+ if (!this.isStaticFallbackEnabled()) return []
+
+ return this.fallbackProvider?.listHalls().catch(() => []) || []
}
- private async requestHallList() {
- if (this.hallListCache) return this.hallListCache
- if (this.hallListInflight) return this.hallListInflight
+ private isStaticFallbackEnabled() {
+ return Boolean(this.fallbackProvider && dataSourceConfig.allowExplainStaticFallback)
+ }
- this.hallListInflight = (async () => {
- const url = `${resolveAppApiBaseUrl()}/gis/hall/list`
- const response = await requestJson>(url)
- if (response.code !== 0 || !Array.isArray(response.data)) {
- throw new Error(response.msg || '后端展厅列表加载失败')
- }
+ private async fallbackOrThrow(
+ error: unknown,
+ message: string,
+ fallback: () => Promise
+ ): Promise {
+ if (this.isStaticFallbackEnabled()) {
+ console.warn(`${message},将使用显式静态讲解兜底:`, error)
+ return fallback()
+ }
+ console.error(message, error)
+ throw error
+ }
+
+ private async requestCatalogHalls() {
+ const key = cacheKey('halls')
+ const cached = this.hallListCache.get(key)
+ if (cached) return cached
+
+ const inflight = this.hallListInflight.get(key)
+ if (inflight) return inflight
+
+ const promise = (async () => {
+ const params = new URLSearchParams({ lang: catalogLang() })
+ const url = `${resolveAppApiBaseUrl()}/gis/guide/catalog/halls?${params.toString()}`
+ const response = await requestJson>(url)
+ const items = requireArrayData(response, '讲解展厅目录加载失败')
const fallbackHalls = await this.safeFallbackHalls()
const fallbackHallById = new Map(fallbackHalls.map((hall) => [hall.id, hall]))
const fallbackHallByName = new Map(fallbackHalls.map((hall) => [hall.name, hall]))
- const halls = response.data
- .map((item) => toBackendHallFromList(
+ const halls = items
+ .map((item) => toCatalogHall(
item,
fallbackHallById.get(String(item.id || '')) || fallbackHallByName.get(String(item.name || ''))
))
.filter((hall) => hall.id)
- this.hallListCache = halls
+ this.hallListCache.set(key, halls)
return halls
})()
- try {
- return await this.hallListInflight
- } finally {
- this.hallListInflight = null
- }
- }
-
- async listGuideStopsByHall(hallId: string) {
- const normalizedHallId = hallId.trim()
- if (!normalizedHallId) return []
-
- const cached = this.guideStopCache.get(normalizedHallId)
- if (cached) return cached
-
- const inflight = this.guideStopInflight.get(normalizedHallId)
- if (inflight) return inflight
-
- const promise = (async () => {
- const stops = (await this.sgsSdkApiProvider.getGuideStopsByHall(normalizedHallId))
- .map(toExplainGuideStopFromSgs)
- .filter(Boolean) as ExplainGuideStop[]
-
- this.guideStopCache.set(normalizedHallId, stops)
- return stops
- })()
-
- this.guideStopInflight.set(normalizedHallId, promise)
+ this.hallListInflight.set(key, promise)
try {
return await promise
} finally {
- this.guideStopInflight.delete(normalizedHallId)
+ this.hallListInflight.delete(key)
+ }
+ }
+
+ private async requestCatalogOutlinesByHall(hallId: string) {
+ const normalizedHallId = hallId.trim()
+ if (!normalizedHallId) return []
+
+ const key = cacheKey('hall', normalizedHallId, 'outlines')
+ const cached = this.outlineCache.get(key)
+ if (cached) return cached
+
+ const inflight = this.outlineInflight.get(key)
+ if (inflight) return inflight
+
+ const promise = (async () => {
+ const params = new URLSearchParams({
+ includeChildren: 'true',
+ lang: catalogLang()
+ })
+ const url = `${resolveAppApiBaseUrl()}/gis/guide/catalog/halls/${encodeURIComponent(normalizedHallId)}/outlines?${params.toString()}`
+ const response = await requestJson>(url)
+ const outlines = requireArrayData(response, '讲解单元目录加载失败')
+ this.outlineCache.set(key, outlines)
+ return outlines
+ })()
+
+ this.outlineInflight.set(key, promise)
+ try {
+ return await promise
+ } finally {
+ this.outlineInflight.delete(key)
+ }
+ }
+
+ private async requestCatalogStopsByHall(hallId: string) {
+ const normalizedHallId = hallId.trim()
+ if (!normalizedHallId) return []
+
+ const key = cacheKey('hall', normalizedHallId, 'stops')
+ const cached = this.guideStopCache.get(key)
+ if (cached) return cached
+
+ const inflight = this.guideStopInflight.get(key)
+ if (inflight) return inflight
+
+ const promise = (async () => {
+ const [halls, outlines] = await Promise.all([
+ this.requestCatalogHalls(),
+ this.requestCatalogOutlinesByHall(normalizedHallId).catch(() => [])
+ ])
+ const hall = halls.find((item) => item.id === normalizedHallId) || null
+ const outlineById = new Map(flattenCatalogOutlines(outlines).map((outline) => [String(outline.id || ''), outline]))
+
+ const params = new URLSearchParams({ lang: catalogLang() })
+ const url = `${resolveAppApiBaseUrl()}/gis/guide/catalog/halls/${encodeURIComponent(normalizedHallId)}/stops?${params.toString()}`
+ const response = await requestJson>(url)
+ const stops = requireArrayData(response, '讲解点目录加载失败')
+ .map((item) => {
+ const outline = outlineById.get(String(item.outlineId || ''))
+ return toCatalogGuideStop(item, hall, outline
+ ? {
+ id: String(outline.id || ''),
+ name: String(outline.name || '')
+ }
+ : null)
+ })
+ .filter(Boolean) as ExplainGuideStop[]
+
+ this.guideStopCache.set(key, stops)
+ return stops
+ })()
+
+ this.guideStopInflight.set(key, promise)
+ try {
+ return await promise
+ } finally {
+ this.guideStopInflight.delete(key)
+ }
+ }
+
+ private async requestCatalogStopsByOutline(hallId: string, outlineId: string) {
+ const normalizedHallId = hallId.trim()
+ const normalizedOutlineId = outlineId.trim()
+ if (!normalizedOutlineId) return []
+
+ const hallStopsKey = cacheKey('hall', normalizedHallId, 'stops')
+ const cachedHallStops = this.guideStopCache.get(hallStopsKey)
+ if (cachedHallStops) {
+ return cachedHallStops.filter((stop) => stop.outlineId === normalizedOutlineId)
+ }
+
+ const key = cacheKey('outline', normalizedOutlineId, 'stops')
+ const cached = this.guideStopCache.get(key)
+ if (cached) return cached
+
+ const inflight = this.guideStopInflight.get(key)
+ if (inflight) return inflight
+
+ const promise = (async () => {
+ const [halls, outlines] = await Promise.all([
+ this.requestCatalogHalls(),
+ normalizedHallId ? this.requestCatalogOutlinesByHall(normalizedHallId).catch(() => []) : Promise.resolve([])
+ ])
+ const outline = flattenCatalogOutlines(outlines).find((item) => String(item.id || '') === normalizedOutlineId)
+ const hall = halls.find((item) => item.id === (String(outline?.hallId || '') || normalizedHallId)) || null
+
+ const params = new URLSearchParams({ lang: catalogLang() })
+ const url = `${resolveAppApiBaseUrl()}/gis/guide/catalog/outlines/${encodeURIComponent(normalizedOutlineId)}/stops?${params.toString()}`
+ const response = await requestJson>(url)
+ const stops = requireArrayData(response, '单元讲解点目录加载失败')
+ .map((item) => toCatalogGuideStop(item, hall, outline
+ ? {
+ id: String(outline.id || ''),
+ name: String(outline.name || '')
+ }
+ : null))
+ .filter(Boolean) as ExplainGuideStop[]
+
+ this.guideStopCache.set(key, stops)
+ return stops
+ })()
+
+ this.guideStopInflight.set(key, promise)
+ try {
+ return await promise
+ } finally {
+ this.guideStopInflight.delete(key)
+ }
+ }
+
+ private async requestCatalogExhibits() {
+ const key = cacheKey('exhibits')
+ const cached = this.explainExhibitCache.get(key)
+ if (cached) return cached
+
+ const inflight = this.explainExhibitInflight.get(key)
+ if (inflight) return inflight
+
+ const promise = (async () => {
+ const halls = await this.requestCatalogHalls()
+ const stopsByHall = await Promise.all(halls.map(async (hall) => {
+ const stops = await this.requestCatalogStopsByHall(hall.id)
+ return stops.map((stop) => toCatalogMuseumExhibitFromStop(stop, hall))
+ }))
+ const exhibits = stopsByHall.flat()
+ this.explainExhibitCache.set(key, exhibits)
+ exhibits.forEach((exhibit) => {
+ this.detailCache.set(exhibit.id, exhibit)
+ })
+ return exhibits
+ })()
+
+ this.explainExhibitInflight.set(key, promise)
+ try {
+ return await promise
+ } finally {
+ this.explainExhibitInflight.delete(key)
+ }
+ }
+
+ private async searchCatalogExhibits(keyword = '') {
+ const normalizedKeyword = normalizeKeyword(keyword).toLowerCase()
+ const exhibits = await this.requestCatalogExhibits()
+ if (!normalizedKeyword) return exhibits
+
+ return exhibits.filter((exhibit) => [
+ exhibit.id,
+ exhibit.name,
+ exhibit.hallName,
+ exhibit.floorLabel,
+ exhibit.description,
+ exhibit.playTargetId,
+ ...(exhibit.tags || []),
+ ...(exhibit.linkedExhibits || []).flatMap((item) => [item.id, item.name, item.exhibitCode])
+ ].filter(Boolean).join(' ').toLowerCase().includes(normalizedKeyword))
+ }
+
+ async listGuideStopsByHall(hallId: string) {
+ try {
+ return await this.requestCatalogStopsByHall(hallId)
+ } catch (error) {
+ return this.fallbackOrThrow(error, '讲解点目录加载失败', async () => (
+ this.fallbackProvider?.listGuideStopsByHall?.(hallId) || []
+ ))
}
}
async listTemporaryBusinessUnitsByHall(hallId: string): Promise {
- const stops = await this.listGuideStopsByHall(hallId)
- return groupGuideStopsByOutline(hallId, stops)
+ const normalizedHallId = hallId.trim()
+ if (!normalizedHallId) return []
+
+ const key = cacheKey('hall', normalizedHallId, 'units')
+ const cached = this.businessUnitCache.get(key)
+ if (cached) return cached
+
+ const inflight = this.businessUnitInflight.get(key)
+ if (inflight) return inflight
+
+ const promise = (async () => {
+ const [outlines, stops] = await Promise.all([
+ this.requestCatalogOutlinesByHall(normalizedHallId),
+ this.requestCatalogStopsByHall(normalizedHallId)
+ ])
+ const units = toCatalogBusinessUnits(normalizedHallId, outlines, stops)
+
+ await Promise.all(units.map(async (unit) => {
+ if (unit.stops.length) return
+ unit.stops = await this.requestCatalogStopsByOutline(normalizedHallId, unit.id).catch(() => [])
+ unit.guideStopCount = unit.stops.length || unit.guideStopCount
+ }))
+
+ this.businessUnitCache.set(key, units)
+ return units
+ })().catch((error) => {
+ return this.fallbackOrThrow(error, '讲解单元目录加载失败', async () => (
+ this.fallbackProvider?.listTemporaryBusinessUnitsByHall?.(normalizedHallId) || []
+ ))
+ })
+
+ this.businessUnitInflight.set(key, promise)
+ try {
+ return await promise
+ } finally {
+ this.businessUnitInflight.delete(key)
+ }
}
async listExplainExhibits() {
- return this.requestStaticExplainExhibits()
+ try {
+ return await this.requestCatalogExhibits()
+ } catch (error) {
+ return this.fallbackOrThrow(error, '讲解目录展项加载失败', async () => (
+ this.fallbackProvider?.listExplainExhibits() || []
+ ))
+ }
}
listExhibits() {
return this.listExplainExhibits()
}
- getExhibitById(id: string) {
- return this.requestDetail(id)
+ async getExhibitById(id: string) {
+ const normalizedId = id.trim()
+ if (!normalizedId) return null
+
+ const cached = this.detailCache.get(normalizedId)
+ if (cached) return cached
+
+ const catalogExhibits = await this.listExplainExhibits().catch(() => [])
+ const matched = catalogExhibits.find((exhibit) => (
+ exhibit.id === normalizedId
+ || exhibit.playTargetId === normalizedId
+ || exhibit.resolvedStopId === normalizedId
+ || exhibit.linkedExhibits?.some((linked) => linked.id === normalizedId)
+ ))
+ if (matched) {
+ this.detailCache.set(normalizedId, matched)
+ return matched
+ }
+
+ if (!this.isStaticFallbackEnabled()) return null
+
+ return this.fallbackProvider?.getExhibitById?.(normalizedId)
+ || this.fallbackProvider?.listExhibits()
+ .then((items) => items.find((item) => item.id === normalizedId) || null)
+ .catch(() => null)
+ || null
}
async listHalls() {
try {
- return await this.requestHallList()
+ return await this.requestCatalogHalls()
} catch (error) {
- console.warn('后端展厅列表加载失败,将使用静态展厅兜底:', error)
- return this.fallbackProvider.listHalls()
+ return this.fallbackOrThrow(error, '讲解展厅目录加载失败', async () => (
+ this.fallbackProvider?.listHalls() || []
+ ))
}
}
async listTracks() {
const exhibits = await this.listExplainExhibits()
- return exhibits.map(toBackendExplainTrack)
+ return exhibits.map((exhibit) => ({
+ id: `track-${exhibit.playTargetId || exhibit.id}`,
+ exhibitId: exhibit.id,
+ hallId: exhibit.hallId,
+ title: exhibit.guideTitle || `${exhibit.name}讲解`,
+ summary: exhibit.guideText || exhibit.description,
+ coverImage: exhibit.image,
+ poiId: exhibit.poiId,
+ floorId: exhibit.floorId,
+ available: exhibit.audioStatus === 'READY',
+ playTargetType: exhibit.playTargetType,
+ playTargetId: exhibit.playTargetId
+ }))
}
- async getMediaById(id: string) {
- const normalizedId = id.replace(/^media-/, '')
- const cachedDetail = Array.from(this.detailCache.values()).find((exhibit) => (
- exhibit.id === normalizedId || exhibit.guideContentId === normalizedId
- ))
- if (cachedDetail) return toBackendMediaAsset(cachedDetail)
-
- const detail = await this.requestDetail(normalizedId).catch(() => null)
- return detail ? toBackendMediaAsset(detail) : null
+ async getMediaById() {
+ return null
}
- async getMediaForExplainTrack(trackId: string) {
- return this.getMediaById(`media-${trackId.replace(/^track-/, '')}`)
+ async getMediaForExplainTrack() {
+ return null
}
searchExplainExhibits(keyword = '') {
const normalizedKeyword = normalizeKeyword(keyword)
- if (!normalizedKeyword) return this.requestStaticExplainExhibits()
+ if (!normalizedKeyword) return this.listExplainExhibits()
- return this.requestSearch(normalizedKeyword).catch((error) => {
- console.warn('后端讲解搜索失败,将使用静态讲解兜底:', error)
- return this.searchStaticExplainExhibits(normalizedKeyword)
+ return this.searchCatalogExhibits(normalizedKeyword).catch((error) => {
+ return this.fallbackOrThrow(error, '讲解目录搜索失败', async () => (
+ this.fallbackProvider?.searchExplainExhibits?.(normalizedKeyword)
+ || this.fallbackProvider?.listExplainExhibits()
+ .then((items) => items.filter((exhibit) => [
+ exhibit.name,
+ exhibit.hallName,
+ exhibit.floorLabel,
+ exhibit.description,
+ ...(exhibit.tags || [])
+ ].filter(Boolean).join(' ').toLowerCase().includes(normalizedKeyword.toLowerCase())))
+ || []
+ ))
})
}
}
diff --git a/src/data/providers/staticMuseumContentProvider.ts b/src/data/providers/staticMuseumContentProvider.ts
index 50631d2..78d7ede 100644
--- a/src/data/providers/staticMuseumContentProvider.ts
+++ b/src/data/providers/staticMuseumContentProvider.ts
@@ -268,7 +268,9 @@ export const createMuseumContentProvider = (): ExplainContentProvider => {
}
if (isGuideContentRemoteMode()) {
- return new BackendExplainContentProvider(new StaticGuideContentProvider())
+ return new BackendExplainContentProvider(
+ dataSourceConfig.allowExplainStaticFallback ? new StaticGuideContentProvider() : undefined
+ )
}
if (isGuideContentMockMode()) {
@@ -278,4 +280,5 @@ export const createMuseumContentProvider = (): ExplainContentProvider => {
return new StaticGuideContentProvider()
}
-export const staticMuseumContentProvider = createMuseumContentProvider()
+export const explainContentProvider = createMuseumContentProvider()
+export const staticMuseumContentProvider = explainContentProvider
diff --git a/src/domain/museum.ts b/src/domain/museum.ts
index 7de59c2..35067a8 100644
--- a/src/domain/museum.ts
+++ b/src/domain/museum.ts
@@ -129,6 +129,13 @@ export interface MuseumHall {
description?: string
image?: string
exhibitCount?: number
+ outlineCount?: number
+ stopCount?: number
+ linkedExhibitCount?: number
+ audioReadyStopCount?: number
+ hasAudio?: boolean
+ audioStatus?: string
+ supportedLanguages?: string[]
area?: string
poiId?: string
location?: GuideLocationResolution
@@ -170,6 +177,8 @@ export interface MuseumExhibit {
imageStatus?: string
imageSource?: string
galleryUrls?: string[]
+ linkedExhibitCount?: number
+ isSharedStop?: boolean
linkedExhibits?: Array<{
id: string
name: string
@@ -195,18 +204,44 @@ export interface ExplainGuideStop {
coverImageUrl?: string
description?: string
hasAudio?: boolean
+ audioStatus?: string
+ supportedLanguages?: string[]
+ hasTextRecord?: boolean
poiId?: string
outlineId?: string
outlineName?: string
sort?: number
+ guideLevel?: string
+ imageStatus?: string
+ linkedExhibitCount?: number
+ isSharedStop?: boolean
+ linkedExhibits?: Array<{
+ id: string
+ name: string
+ nameEn?: string
+ exhibitCode?: string
+ coverImageUrl?: string
+ }>
+ playTargetType?: AudioPlayTargetType
+ playTargetId?: string
}
export interface ExplainBusinessUnit {
id: string
name: string
hallId: string
+ parentId?: string
+ code?: string
+ description?: string
+ sort?: number
+ level?: number
previewImageUrl?: string
guideStopCount: number
+ linkedExhibitCount?: number
+ audioReadyStopCount?: number
+ hasAudio?: boolean
+ audioStatus?: string
+ supportedLanguages?: string[]
stops: ExplainGuideStop[]
}
diff --git a/src/pages/exhibit/detail.vue b/src/pages/exhibit/detail.vue
index 507d9b2..aa8ee1e 100644
--- a/src/pages/exhibit/detail.vue
+++ b/src/pages/exhibit/detail.vue
@@ -24,9 +24,14 @@
讲解
-
- {{ exhibit.title }}
- {{ detailMeta }}
+
+ {{ exhibit.title }}
+ {{ detailMeta }}
+
+
+ {{ exhibit.linkedExhibitStatusText }}
+
+
{
audioLanguage: exhibit.value.audio.language,
playTargetType: exhibit.value.audio.playTargetType,
playTargetId: exhibit.value.audio.playTargetId
+ }, {
+ refreshPlayInfo: true
})
if (selection.playable && selection.media?.url) {
@@ -491,6 +498,46 @@ const handleBack = () => {
color: #555c51;
}
+.detail-chip-row {
+ margin-top: 12px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.detail-chip {
+ max-width: 100%;
+ height: 28px;
+ padding: 0 10px;
+ display: flex;
+ align-items: center;
+ box-sizing: border-box;
+ background: #f4f6ef;
+ border: 1px solid #dde2d3;
+ border-radius: 8px;
+}
+
+.detail-chip.shared {
+ background: #151713;
+ border-color: #151713;
+}
+
+.detail-chip-text {
+ display: block;
+ max-width: 100%;
+ font-size: 13px;
+ line-height: 18px;
+ font-weight: 700;
+ color: #555c51;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.detail-chip.shared .detail-chip-text {
+ color: #e0df00;
+}
+
.audio-panel {
height: 76px;
margin-top: 24px;
diff --git a/src/pages/explain/list.vue b/src/pages/explain/list.vue
index 3ef55a2..9560e29 100644
--- a/src/pages/explain/list.vue
+++ b/src/pages/explain/list.vue
@@ -109,7 +109,9 @@ const explainGuideStopItems = computed(() => (
floorId: stop.floorId,
coverImageUrl: stop.coverImageUrl,
description: stop.description,
- hasAudio: stop.hasAudio
+ hasAudio: stop.hasAudio,
+ audioStatus: stop.audioStatus,
+ guideLevel: stop.guideLevel
})) || []
))
diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue
index 5e99b79..9438bc8 100644
--- a/src/pages/index/index.vue
+++ b/src/pages/index/index.vue
@@ -708,7 +708,9 @@ const explainGuideStopItems = computed(() => (
floorId: stop.floorId,
coverImageUrl: stop.coverImageUrl,
description: stop.description,
- hasAudio: stop.hasAudio
+ hasAudio: stop.hasAudio,
+ audioStatus: stop.audioStatus,
+ guideLevel: stop.guideLevel
})) || []
))
@@ -995,19 +997,26 @@ const resolveSelectedPoiHall = async (): Promise => {
if (!poi) return null
const halls = await explainUseCase.listHalls()
- const hallId = poi.hallId
- if (hallId) {
- const matchedById = halls.find((hall) => hall.id === hallId || hall.poiId === hallId)
- if (matchedById) return matchedById
- }
-
const matchedByPoiId = halls.find((hall) => (
hall.poiId === poi.id
|| hall.location?.poiId === poi.id
- || (poi.entrances || []).some((entrance) => hall.poiId === entrance.sourcePlaceId)
))
if (matchedByPoiId) return matchedByPoiId
+ const hallId = poi.hallId
+ if (hallId) {
+ const matchedById = halls.find((hall) => hall.id === hallId)
+ if (matchedById) return matchedById
+ }
+
+ const matchedByEntrance = halls.find((hall) => (
+ (poi.entrances || []).some((entrance) => (
+ hall.poiId === entrance.sourcePlaceId
+ || hall.poiId === entrance.id
+ ))
+ ))
+ if (matchedByEntrance) return matchedByEntrance
+
const poiHallName = normalizeHallMatchText(poi.hallName || poi.name)
return halls.find((hall) => {
const hallName = normalizeHallMatchText(hall.name)
@@ -1932,17 +1941,30 @@ const handleExplainBack = () => {
left: 50%;
right: auto;
width: calc(var(--museum-h5-page-width) - 24px);
- bottom: calc(env(safe-area-inset-bottom) + 16px);
- padding: 12px;
+ bottom: calc(env(safe-area-inset-bottom) + 14px);
+ padding: 10px;
box-sizing: border-box;
- background: rgba(255, 255, 255, 0.96);
- border: 1px solid rgba(255, 255, 255, 0.96);
- border-radius: 16px;
- box-shadow: 0 10px 28px rgba(36, 49, 42, 0.16);
+ overflow: hidden;
+ background:
+ linear-gradient(180deg, rgba(224, 225, 0, 0.22) 0, rgba(224, 225, 0, 0) 58px),
+ #060704;
+ border: 1px solid rgba(224, 225, 0, 0.34);
+ border-radius: 8px;
+ box-shadow: 0 18px 38px rgba(0, 0, 0, 0.34);
transform: translateX(-50%);
z-index: 1003;
}
+.guide-home-dock::before {
+ content: '';
+ position: absolute;
+ left: 10px;
+ right: 10px;
+ top: 0;
+ height: 4px;
+ background: var(--museum-accent);
+}
+
.guide-home-dock.expanded {
position: fixed;
left: 50%;
@@ -1964,10 +1986,14 @@ const handleExplainBack = () => {
transform: translateX(-50%);
overflow: hidden;
overscroll-behavior: contain;
- touch-action: pan-y;
+ touch-action: pan-x pan-y;
z-index: 1100;
}
+.guide-home-dock.expanded::before {
+ display: none;
+}
+
/* #ifdef H5 */
.guide-home-dock.expanded {
min-height: 100vh;
@@ -1988,18 +2014,18 @@ const handleExplainBack = () => {
.guide-quick-actions {
position: absolute;
- top: clamp(96px, 14vh, 118px);
- right: 18px;
- width: 48px;
+ top: clamp(88px, 13vh, 112px);
+ right: 14px;
+ width: 52px;
display: flex;
flex-direction: column;
- gap: 8px;
+ gap: 7px;
z-index: 1002;
}
.guide-quick-action {
- width: 48px;
- min-height: 56px;
+ width: 52px;
+ min-height: 58px;
padding: 7px 4px 6px;
display: flex;
flex-direction: column;
@@ -2007,15 +2033,16 @@ const handleExplainBack = () => {
justify-content: center;
gap: 4px;
box-sizing: border-box;
- background: rgba(255, 255, 255, 0.96);
- border: 1px solid rgba(230, 233, 224, 0.96);
+ background: rgba(245, 245, 237, 0.94);
+ border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 8px;
- box-shadow: 0 8px 22px rgba(36, 49, 42, 0.14);
+ box-shadow: 0 12px 28px rgba(0, 0, 0, 0.2);
+ backdrop-filter: blur(10px);
}
.guide-quick-action.primary {
- background: #151713;
- border-color: #151713;
+ background: var(--museum-accent);
+ border-color: var(--museum-accent);
}
.guide-quick-action:active {
@@ -2031,7 +2058,7 @@ const handleExplainBack = () => {
}
.guide-quick-action.primary .guide-quick-icon {
- color: var(--museum-accent);
+ color: #0a0b08;
}
.guide-quick-icon-floor {
@@ -2113,7 +2140,7 @@ const handleExplainBack = () => {
}
.guide-quick-action-text.primary {
- color: var(--museum-accent);
+ color: #0a0b08;
}
.route-sim-top-pill {
diff --git a/src/pages/search/index.vue b/src/pages/search/index.vue
index 32fe6fd..f0b8967 100644
--- a/src/pages/search/index.vue
+++ b/src/pages/search/index.vue
@@ -80,16 +80,15 @@ onUnmounted(() => {