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(() => {