feat: switch explain flow to API data
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-06 23:18:51 +08:00
parent e2b6a331ba
commit 50902eca82
21 changed files with 1417 additions and 632 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -101,11 +101,16 @@
</view>
<view class="hall-main">
<text class="hall-name">{{ stop.name }}</text>
<view class="stop-title-row">
<text class="hall-name">{{ stop.name }}</text>
<view v-if="stop.guideLevel" class="guide-level-badge">
<text class="guide-level-text">{{ stop.guideLevel }}</text>
</view>
</view>
<text v-if="stop.description" class="stop-desc">{{ stop.description }}</text>
<view class="hall-meta-row">
<view class="floor-badge">
<text class="floor-badge-text">{{ stop.hasAudio ? '可讲解' : '图文' }}</text>
<text class="floor-badge-text">{{ stop.audioStatus === 'READY' ? '可讲解' : '图文' }}</text>
</view>
<text class="hall-meta-text">{{ stop.floorId || selectedHallName || '楼层待补充' }}</text>
</view>
@@ -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;

View File

@@ -62,20 +62,22 @@
<view class="home-collapse-bar"></view>
</view>
<view class="category-grid">
<view
v-for="item in primaryFacilities"
:key="item.id"
class="category-item"
:class="{ active: searchKeyword === item.label }"
@tap="handleFacilityShortcut(item)"
>
<view class="line-icon" :class="`icon-${item.icon}`">
<view class="icon-part a"></view>
<view class="icon-part b"></view>
<view class="icon-part c"></view>
<view class="category-scroll">
<view class="category-grid">
<view
v-for="item in primaryFacilityColumns"
:key="item.id"
class="category-item"
:class="{ active: searchKeyword === item.label }"
@tap="handleFacilityShortcut(item)"
>
<view class="line-icon" :class="`icon-${item.icon}`">
<view class="icon-part a"></view>
<view class="icon-part b"></view>
<view class="icon-part c"></view>
</view>
<text class="category-label">{{ item.label }}</text>
</view>
<text class="category-label">{{ item.label }}</text>
</view>
</view>
@@ -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,

View File

@@ -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

View File

@@ -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<ExplainBusinessUnit, 'id' | 'name'> | 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<ExplainGuideStop['linkedExhibits']>,
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<string, ExplainGuideStop[]>()
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<ExplainBusinessUnit | null>((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
}
}

View File

@@ -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
}
}

View File

@@ -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<T> {
code: number
@@ -79,234 +73,414 @@ const requestJson = <T>(url: string): Promise<T> => 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 = <T>(response: CommonResult<T[]>, 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<string, MuseumExhibit[]>()
private readonly searchInflight = new Map<string, Promise<MuseumExhibit[]>>()
private readonly explainExhibitCache = new Map<string, MuseumExhibit[]>()
private readonly explainExhibitInflight = new Map<string, Promise<MuseumExhibit[]>>()
private readonly detailCache = new Map<string, MuseumExhibit>()
private readonly detailInflight = new Map<string, Promise<MuseumExhibit | null>>()
private hallListCache: MuseumHall[] | null = null
private hallListInflight: Promise<MuseumHall[]> | null = null
private readonly hallListCache = new Map<string, MuseumHall[]>()
private readonly hallListInflight = new Map<string, Promise<MuseumHall[]>>()
private readonly guideStopCache = new Map<string, ExplainGuideStop[]>()
private readonly guideStopInflight = new Map<string, Promise<ExplainGuideStop[]>>()
private readonly outlineCache = new Map<string, BackendCatalogOutlineItem[]>()
private readonly outlineInflight = new Map<string, Promise<BackendCatalogOutlineItem[]>>()
private readonly businessUnitCache = new Map<string, ExplainBusinessUnit[]>()
private readonly businessUnitInflight = new Map<string, Promise<ExplainBusinessUnit[]>>()
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<CommonResult<BackendExhibit[]>>(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<CommonResult<BackendExhibit>>(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<CommonResult<BackendHall[]>>(url)
if (response.code !== 0 || !Array.isArray(response.data)) {
throw new Error(response.msg || '后端展厅列表加载失败')
}
private async fallbackOrThrow<T>(
error: unknown,
message: string,
fallback: () => Promise<T>
): Promise<T> {
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<CommonResult<BackendCatalogHallItem[]>>(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<CommonResult<BackendCatalogOutlineItem[]>>(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<CommonResult<BackendCatalogStopItem[]>>(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<CommonResult<BackendCatalogStopItem[]>>(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<ExplainBusinessUnit[]> {
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())))
|| []
))
})
}
}

View File

@@ -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

View File

@@ -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[]
}

View File

@@ -24,9 +24,14 @@
<text class="hero-placeholder-text">讲解</text>
</view>
<view class="detail-body">
<text class="detail-title">{{ exhibit.title }}</text>
<text v-if="detailMeta" class="detail-meta">{{ detailMeta }}</text>
<view class="detail-body">
<text class="detail-title">{{ exhibit.title }}</text>
<text v-if="detailMeta" class="detail-meta">{{ detailMeta }}</text>
<view v-if="exhibit.linkedExhibitStatusText" class="detail-chip-row">
<view class="detail-chip" :class="{ shared: exhibit.isSharedStop }">
<text class="detail-chip-text">{{ exhibit.linkedExhibitStatusText }}</text>
</view>
</view>
<view
v-if="exhibit.audio.status === 'playable'"
@@ -328,6 +333,8 @@ const handleAudioError = async (_audio: AudioItem | null, message: string) => {
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;

View File

@@ -109,7 +109,9 @@ const explainGuideStopItems = computed<ExplainGuideStopSelectItem[]>(() => (
floorId: stop.floorId,
coverImageUrl: stop.coverImageUrl,
description: stop.description,
hasAudio: stop.hasAudio
hasAudio: stop.hasAudio,
audioStatus: stop.audioStatus,
guideLevel: stop.guideLevel
})) || []
))

View File

@@ -708,7 +708,9 @@ const explainGuideStopItems = computed<ExplainGuideStopSelectItem[]>(() => (
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<MuseumHall | null> => {
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 {

View File

@@ -80,16 +80,15 @@ onUnmounted(() => {
<style scoped lang="scss">
.poi-search-page {
--museum-h5-page-width: min(100vw, 430px);
position: relative;
width: 100%;
height: 100vh;
height: 100dvh;
min-height: 100vh;
min-height: 100dvh;
padding: calc(env(safe-area-inset-top) + 28px) 14px calc(env(safe-area-inset-bottom) + 16px);
padding: calc(env(safe-area-inset-top) + 18px) 0 0;
box-sizing: border-box;
background:
linear-gradient(180deg, rgba(224, 225, 0, 0.22) 0, rgba(245, 245, 237, 0) 146px),
#f5f5ed;
background: #f5f5ed;
color: #262421;
font-family: '鸿蒙黑体', 'HarmonyOS Sans SC', 'HarmonyOS Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
display: flex;
@@ -97,6 +96,19 @@ onUnmounted(() => {
overflow: hidden;
}
.poi-search-page::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
height: 210px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0 64px, rgba(0, 0, 0, 0.18) 65px, rgba(0, 0, 0, 0.5) 100%),
url('/static/guide/app-launch-loading.webp') center top / cover no-repeat;
pointer-events: none;
}
/* #ifdef H5 */
.poi-search-page {
height: var(--poi-search-page-height, 100dvh);
@@ -108,20 +120,29 @@ onUnmounted(() => {
/* #endif */
.search-header {
min-height: 46px;
position: relative;
z-index: 1;
min-height: 48px;
flex: 0 0 auto;
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 0 2px 16px;
margin: 0 18px 118px;
}
.search-panel-shell {
position: relative;
z-index: 1;
min-height: 0;
flex: 1;
display: flex;
padding: 18px 14px calc(env(safe-area-inset-bottom) + 14px);
box-sizing: border-box;
background: #fffef0;
border-radius: 22px 22px 0 0;
overflow: hidden;
box-shadow: 0 -10px 28px rgba(0, 0, 0, 0.08);
}
.search-header-copy {
@@ -132,31 +153,31 @@ onUnmounted(() => {
}
.page-title {
font-size: 26px;
line-height: 31px;
font-size: 24px;
line-height: 30px;
font-weight: 800;
letter-spacing: 0;
color: #000000;
color: #050704;
}
.page-subtitle {
font-size: 11px;
line-height: 15px;
font-weight: 500;
color: #5d5b50;
color: #4f5149;
white-space: nowrap;
}
.map-link {
min-width: 74px;
height: 32px;
min-width: 76px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 10px;
box-sizing: border-box;
background: #000000;
border: 1px solid rgba(0, 0, 0, 0.88);
background: #050704;
border: 1px solid #050704;
border-radius: 8px;
}
@@ -177,13 +198,16 @@ onUnmounted(() => {
@media (max-width: 360px) {
.poi-search-page {
padding-top: calc(env(safe-area-inset-top) + 22px);
padding-left: 12px;
padding-right: 12px;
padding-top: calc(env(safe-area-inset-top) + 14px);
}
.search-header {
margin-bottom: 12px;
margin: 0 14px 108px;
}
.search-panel-shell {
padding-left: 12px;
padding-right: 12px;
}
.page-title {

View File

@@ -2,14 +2,17 @@ import {
dataSourceConfig
} from '@/config/dataSource'
import {
normalizeSameOriginPublicUrl
} from '@/utils/publicUrl'
import type {
AudioPlayTargetType
type AudioPlayTargetType
} from '@/domain/museum'
import {
toGuideAudioPlayInfo,
toGuideAudioTextInfo,
toGuideStopInfo,
type BackendAudioPlayInfo,
type BackendAudioTextInfo,
type BackendGuideStopInfo,
type GuideAudioPlayInfo,
type GuideAudioTextInfo,
type GuideStopInfo
} from '@/data/adapters/guideStopInfoAdapter'
@@ -19,6 +22,7 @@ export interface AudioPlayInfoRequest {
targetType: AudioPlayTargetType
targetId: string
lang?: AudioLanguage
refresh?: boolean
}
export interface GuideStopInfoRequest {
@@ -33,29 +37,12 @@ interface GuideStopInfoResponse {
data?: BackendGuideStopInfo
}
export interface AudioPlayInfo {
playable: boolean
targetType: AudioPlayTargetType
targetId: string | number
lang: AudioLanguage
narrationTier?: 'STANDARD' | 'EXTENDED'
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 AudioPlayInfo extends GuideAudioPlayInfo {}
interface AudioPlayInfoResponse {
code: number
msg?: string
data?: AudioPlayInfo
data?: BackendAudioPlayInfo
}
export interface AudioTextInfoRequest {
@@ -64,23 +51,12 @@ export interface AudioTextInfoRequest {
lang?: AudioLanguage
}
export interface AudioTextInfo {
available: boolean
targetType: AudioPlayTargetType
targetId: string | number
lang: AudioLanguage
narrationTier?: 'STANDARD' | 'EXTENDED'
title?: string | null
text?: string | null
textLength?: number | null
textHash?: string | null
reason?: string | null
}
export interface AudioTextInfo extends GuideAudioTextInfo {}
interface AudioTextInfoResponse {
code: number
msg?: string
data?: AudioTextInfo
data?: BackendAudioTextInfo
}
export interface AudioPlayInfoRepository {
@@ -143,19 +119,23 @@ const normalizeBaseUrl = (baseUrl: string) => {
}
const resolveAudioApiBaseUrl = () => {
const baseUrl = normalizeBaseUrl(dataSourceConfig.audioApiBaseUrl)
const baseUrl = normalizeBaseUrl(dataSourceConfig.apiBaseUrl)
return baseUrl.endsWith('/app-api') ? baseUrl : `${baseUrl}/app-api`
}
const audioKey = ({ targetType, targetId, lang }: Required<AudioPlayInfoRequest>) => (
type RequiredAudioPlayInfoRequest = Required<Omit<AudioPlayInfoRequest, 'refresh'>>
type RequiredGuideStopInfoRequest = Required<GuideStopInfoRequest>
type RequiredAudioTextInfoRequest = Required<AudioTextInfoRequest>
const audioKey = ({ targetType, targetId, lang }: RequiredAudioPlayInfoRequest) => (
`${targetType}:${targetId}:${lang}`
)
const stopInfoKey = ({ targetType, targetId, lang }: Required<GuideStopInfoRequest>) => (
const stopInfoKey = ({ targetType, targetId, lang }: RequiredGuideStopInfoRequest) => (
`${targetType}:${targetId}:${lang}`
)
const audioTextKey = ({ targetType, targetId, lang }: Required<AudioTextInfoRequest>) => (
const audioTextKey = ({ targetType, targetId, lang }: RequiredAudioTextInfoRequest) => (
`${targetType}:${targetId}:${lang}`
)
@@ -190,7 +170,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
throw new Error('讲解展示信息接口暂不可用')
}
const normalizedRequest: Required<GuideStopInfoRequest> = {
const normalizedRequest: RequiredGuideStopInfoRequest = {
targetType: request.targetType,
targetId: request.targetId,
lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
@@ -221,7 +201,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
}
async getPlayInfo(request: AudioPlayInfoRequest): Promise<AudioPlayInfo> {
const normalizedRequest: Required<AudioPlayInfoRequest> = {
const normalizedRequest: RequiredAudioPlayInfoRequest = {
targetType: request.targetType,
targetId: request.targetId,
lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
@@ -233,13 +213,15 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
targetType: normalizedRequest.targetType,
targetId: normalizedRequest.targetId,
lang: normalizedRequest.lang,
hasText: false,
fallback: false,
reason: 'SERVICE_ERROR'
}
}
const cached = this.cache.get(key)
if (cached && !isExpired(cached.expiresAt)) {
if (!request.refresh && cached && !isExpired(cached.expiresAt)) {
return cached
}
@@ -256,17 +238,16 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
throw new Error(response.msg || '语音播放解析失败')
}
response.data.playUrl = normalizeSameOriginPublicUrl(response.data.playUrl)
response.data.subtitleUrl = normalizeSameOriginPublicUrl(response.data.subtitleUrl)
const playInfo = toGuideAudioPlayInfo(response.data, normalizedRequest)
if (response.data.playable || response.data.reason !== 'TARGET_NOT_FOUND') {
this.cache.set(key, response.data)
if (playInfo.playable || playInfo.reason !== 'TARGET_NOT_FOUND') {
this.cache.set(key, playInfo)
}
return response.data
return playInfo
}
async getTextInfo(request: AudioTextInfoRequest): Promise<AudioTextInfo> {
const normalizedRequest: Required<AudioTextInfoRequest> = {
const normalizedRequest: RequiredAudioTextInfoRequest = {
targetType: request.targetType,
targetId: request.targetId,
lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
@@ -301,11 +282,13 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
throw new Error(response.msg || '讲解词正文解析失败')
}
if (response.data.available) {
this.textCache.set(key, response.data)
const textInfo = toGuideAudioTextInfo(response.data, normalizedRequest)
if (textInfo.available) {
this.textCache.set(key, textInfo)
}
return response.data
return textInfo
}
clearCache(lang?: AudioLanguage) {

View File

@@ -15,7 +15,7 @@ import {
type MuseumContentRepository
} from '@/repositories/MuseumContentRepository'
import {
staticMuseumContentProvider,
explainContentProvider,
type ExplainContentProvider
} from '@/data/providers/staticMuseumContentProvider'
@@ -38,7 +38,7 @@ export class DefaultExplainRepository implements ExplainRepository {
constructor(
private readonly content: MuseumContentRepository = museumContentRepository,
private readonly media: MediaRepository = mediaRepository,
private readonly explainContent: ExplainContentProvider = staticMuseumContentProvider
private readonly explainContent: ExplainContentProvider = explainContentProvider
) {}
listExplainExhibits() {
@@ -54,11 +54,12 @@ export class DefaultExplainRepository implements ExplainRepository {
}
listHalls() {
return this.content.listHalls()
return this.explainContent.listHalls()
}
getHallById(id: string) {
return this.content.getHallById(id)
async getHallById(id: string) {
const halls = await this.explainContent.listHalls()
return halls.find((hall) => hall.id === id || hall.poiId === id) || null
}
async listGuideStopsByHall(hallId: string) {

View File

@@ -1,39 +1,8 @@
import {
dataSourceConfig
} from '@/config/dataSource'
import type {
AudioPlayTargetType
} from '@/domain/museum'
import type {
AudioLanguage,
AudioPlayInfoRequest
} from '@/repositories/AudioPlayInfoRepository'
interface CommonResult<T> {
code: number
msg?: string
data?: T
}
interface PublishedGuideContent {
id?: string | number | null
title?: string | null
targetType?: string | null
targetId?: string | number | null
}
interface PublishedExhibitAudioSummary {
id?: string | number | null
name?: string | null
hasAudio?: boolean
supportedLanguages?: string[]
audioStatus?: string | null
playTargetType?: string | null
playTargetId?: string | number | null
audioDuration?: number | null
guideContents?: PublishedGuideContent[]
}
export interface PublishedExhibitAudioResolution {
targets: AudioPlayInfoRequest[]
}
@@ -42,120 +11,10 @@ export interface PublishedExhibitAudioRepository {
resolveByExhibitName(exhibitName: string, lang?: AudioLanguage): Promise<PublishedExhibitAudioResolution | null>
}
const stringifyId = (value: string | number | null | undefined) => (
value === null || typeof value === 'undefined' ? '' : String(value)
)
const normalizeBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, '')
const resolveAppApiBaseUrl = () => {
const baseUrl = normalizeBaseUrl(dataSourceConfig.audioApiBaseUrl || dataSourceConfig.apiBaseUrl)
return baseUrl.endsWith('/app-api') ? baseUrl : `${baseUrl}/app-api`
}
const parseJsonPayload = <T>(payload: unknown): T => {
if (typeof payload === 'string') {
return JSON.parse(payload) as T
}
return payload as T
}
const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject) => {
uni.request({
url,
method: 'GET',
timeout: 5000,
success: (response) => {
const statusCode = Number(response.statusCode || 0)
if (statusCode < 200 || statusCode >= 300) {
reject(new Error(`展品音频发布接口请求失败: ${statusCode}`))
return
}
try {
resolve(parseJsonPayload<T>(response.data))
} catch (error) {
reject(error)
}
},
fail: reject
})
})
const normalizeAudioTargetType = (targetType: string | null | undefined): AudioPlayTargetType | null => {
const normalizedType = targetType?.toUpperCase()
return normalizedType === 'STOP' || normalizedType === 'ITEM'
? normalizedType
: null
}
const addTarget = (
targets: AudioPlayInfoRequest[],
targetType: string | null | undefined,
targetId: string | number | null | undefined,
lang: AudioLanguage
) => {
const normalizedType = normalizeAudioTargetType(targetType)
const normalizedId = stringifyId(targetId)
if (!normalizedType || !normalizedId) return
const exists = targets.some((target) => (
target.targetType === normalizedType && target.targetId === normalizedId && target.lang === lang
))
if (exists) return
targets.push({
targetType: normalizedType,
targetId: normalizedId,
lang
})
}
const buildResolution = (
exhibit: PublishedExhibitAudioSummary,
lang: AudioLanguage
): PublishedExhibitAudioResolution => {
const targets: AudioPlayInfoRequest[] = []
addTarget(targets, exhibit.playTargetType, exhibit.playTargetId, lang)
exhibit.guideContents?.forEach((guide) => {
addTarget(targets, guide.targetType, guide.targetId, lang)
})
addTarget(targets, 'ITEM', exhibit.id, lang)
return { targets }
}
export class DefaultPublishedExhibitAudioRepository implements PublishedExhibitAudioRepository {
async resolveByExhibitName(exhibitName: string, lang: AudioLanguage = dataSourceConfig.audioLanguage as AudioLanguage) {
const keyword = exhibitName.trim()
if (!keyword) return null
const searchUrl = `${resolveAppApiBaseUrl()}/gis/exhibit/search?keyword=${encodeURIComponent(keyword)}`
const searchResponse = await requestJson<CommonResult<PublishedExhibitAudioSummary[]>>(searchUrl)
if (searchResponse.code !== 0 || !searchResponse.data?.length) return null
const exactMatches = searchResponse.data.filter((item) => item.name === keyword)
const matched = (
exactMatches.find((item) => item.hasAudio && item.supportedLanguages?.includes(lang))
|| exactMatches.find((item) => item.hasAudio)
|| exactMatches[0]
|| searchResponse.data[0]
)
const exhibitId = stringifyId(matched.id)
let detail = matched
if (exhibitId) {
const detailUrl = `${resolveAppApiBaseUrl()}/gis/exhibit/get?id=${encodeURIComponent(exhibitId)}`
const detailResponse = await requestJson<CommonResult<PublishedExhibitAudioSummary>>(detailUrl)
if (detailResponse.code === 0 && detailResponse.data) {
detail = detailResponse.data
}
}
return buildResolution(detail, lang)
export class DisabledPublishedExhibitAudioRepository implements PublishedExhibitAudioRepository {
async resolveByExhibitName() {
return null
}
}
export const publishedExhibitAudioRepository = new DefaultPublishedExhibitAudioRepository()
export const publishedExhibitAudioRepository = new DisabledPublishedExhibitAudioRepository()

View File

@@ -12,10 +12,6 @@ import {
explainRepository,
type ExplainRepository
} from '@/repositories/ExplainRepository'
import {
mediaRepository,
type MediaRepository
} from '@/repositories/MediaRepository'
import {
audioPlayInfoRepository,
audioReasonToText,
@@ -28,10 +24,6 @@ import {
import type {
GuideStopInfo
} from '@/data/adapters/guideStopInfoAdapter'
import {
publishedExhibitAudioRepository,
type PublishedExhibitAudioRepository
} from '@/repositories/PublishedExhibitAudioRepository'
import {
dataSourceConfig
} from '@/config/dataSource'
@@ -75,9 +67,7 @@ export interface ExplainHallSummary {
export class ExplainUseCase {
constructor(
private readonly explain: ExplainRepository = explainRepository,
private readonly media: MediaRepository = mediaRepository,
private readonly audioPlayInfo: AudioPlayInfoRepository = audioPlayInfoRepository,
private readonly publishedAudio: PublishedExhibitAudioRepository = publishedExhibitAudioRepository
private readonly audioPlayInfo: AudioPlayInfoRepository = audioPlayInfoRepository
) {}
private applyTrackAudioState(exhibit: MuseumExhibit, track?: ExplainTrack | null): MuseumExhibit {
@@ -134,7 +124,7 @@ export class ExplainUseCase {
fallback?: MuseumExhibit | null
): MuseumExhibit {
const linkedPrimary = stopInfo.linkedExhibits[0]
const coverImage = stopInfo.coverImageUrl || fallback?.image
const coverImage = stopInfo.coverImageUrl || (stopInfo.imageStatus === 'READY' ? fallback?.image : undefined)
const description = stopInfo.description || fallback?.description || '该讲解暂无简介。'
const audioTarget = this.resolveStopInfoAudioTarget(stopInfo)
const audioAvailable = stopInfo.audioStatus === 'READY'
@@ -175,6 +165,8 @@ export class ExplainUseCase {
imageStatus: stopInfo.imageStatus,
imageSource: stopInfo.imageSource,
galleryUrls: stopInfo.galleryUrls,
linkedExhibitCount: stopInfo.linkedExhibitCount,
isSharedStop: stopInfo.isSharedStop,
linkedExhibits: stopInfo.linkedExhibits,
stopInfoAvailable: stopInfo.available,
stopInfoReason: stopInfo.reason,
@@ -211,15 +203,10 @@ export class ExplainUseCase {
private applyPlayInfo(
exhibit: MuseumExhibit,
playInfo: AudioPlayInfo,
options: { preserveStaticFallback?: boolean } = {}
playInfo: AudioPlayInfo
): MuseumExhibit {
const apiPlayable = playInfo.playable === true && Boolean(playInfo.playUrl)
const canUseStaticAudioFallback = !playInfo.reason || playInfo.reason === 'TARGET_NOT_FOUND'
const fallbackAudioUrl = options.preserveStaticFallback && canUseStaticAudioFallback && exhibit.audioUrl
? exhibit.audioUrl
: undefined
const nextAudioUrl = apiPlayable ? playInfo.playUrl || undefined : fallbackAudioUrl
const nextAudioUrl = apiPlayable ? playInfo.playUrl || undefined : undefined
const nextAudioDuration = typeof playInfo.duration === 'number'
? playInfo.duration
: exhibit.audioDuration
@@ -234,7 +221,8 @@ export class ExplainUseCase {
audioHasText: playInfo.hasText === true || exhibit.audioHasText,
audioNarrationTier: playInfo.narrationTier || exhibit.audioNarrationTier,
audioUnavailableReason: apiPlayable ? undefined : audioReasonToText(playInfo.reason),
audioAvailable: apiPlayable || Boolean(fallbackAudioUrl)
audioAvailable: apiPlayable,
audioStatus: apiPlayable ? 'READY' : 'MISSING'
}
}
@@ -258,7 +246,6 @@ export class ExplainUseCase {
exhibit: MuseumExhibit,
options: {
includeText?: boolean
preserveStaticFallback?: boolean
track?: ExplainTrack | null
} = {}
): Promise<MuseumExhibit> {
@@ -268,9 +255,7 @@ export class ExplainUseCase {
try {
const playInfo = await this.audioPlayInfo.getPlayInfo({ targetType, targetId })
let nextExhibit = this.applyPlayInfo(baseExhibit, playInfo, {
preserveStaticFallback: options.preserveStaticFallback
})
let nextExhibit = this.applyPlayInfo(baseExhibit, playInfo)
if (options.includeText && playInfo.hasText) {
try {
@@ -346,7 +331,6 @@ export class ExplainUseCase {
return this.enrichExhibitAudio(exhibit, {
includeText: options.includeText,
preserveStaticFallback: true,
track
})
} catch (error) {
@@ -433,7 +417,18 @@ export class ExplainUseCase {
async loadExplainHallSummaries(hallIds: string[]): Promise<Record<string, ExplainHallSummary>> {
const uniqueHallIds = Array.from(new Set(hallIds.map((id) => id.trim()).filter(Boolean)))
const halls = await this.listHalls().catch(() => [])
const summariesFromHallList = new Map(halls.map((hall) => [hall.id, hall]))
const entries = await Promise.all(uniqueHallIds.map(async (hallId) => {
const hall = summariesFromHallList.get(hallId)
if (hall && (typeof hall.outlineCount === 'number' || typeof hall.stopCount === 'number')) {
return [hallId, {
hallId,
businessUnitCount: hall.outlineCount || 0,
guideStopCount: hall.stopCount || 0
}] as const
}
try {
const units = await this.loadTemporaryBusinessUnitsByHall(hallId)
const guideStopCount = units.reduce((total, unit) => total + unit.guideStopCount, 0)
@@ -508,15 +503,6 @@ export class ExplainUseCase {
const exhibit = detailExhibit || summaryExhibit
if (!exhibit) return null
const fallbackMedia = await (track?.mediaId
? this.media.getMediaById(track.mediaId)
: exhibit.guideContentId
? this.media.getMediaById(`media-${exhibit.guideContentId}`)
: Promise.resolve(null)
).catch((error) => {
console.warn('静态讲解媒体读取失败,将继续尝试播放接口:', error)
return null
})
const targetType: AudioPlayTargetType = track?.playTargetType
|| exhibit.playTargetType
|| 'ITEM'
@@ -533,7 +519,7 @@ export class ExplainUseCase {
id: `play-${playInfo.audioId || `${requestTargetType}-${requestTargetId}`}`,
type: 'audio',
url: playInfo.playUrl || undefined,
duration: typeof playInfo.duration === 'number' ? playInfo.duration : fallbackMedia?.duration,
duration: typeof playInfo.duration === 'number' ? playInfo.duration : undefined,
language: playInfo.lang,
available: true
}
@@ -547,49 +533,6 @@ export class ExplainUseCase {
}
}
const toDirectMediaSelection = (playInfo: AudioPlayInfo): ExplainAudioSelection | null => {
const directMedia = fallbackMedia?.available && fallbackMedia.url
? fallbackMedia
: exhibit.audioUrl
? {
id: `media-${exhibit.guideContentId || exhibit.id}`,
type: 'audio' as const,
url: exhibit.audioUrl,
duration: exhibit.audioDuration,
language: playInfo.lang,
available: true
}
: null
if (!directMedia?.url) return null
return {
exhibit,
track,
media: directMedia,
playable: true,
playInfo: {
...playInfo,
playable: true,
audioId: directMedia.id,
title: track?.title || exhibit.guideTitle || exhibit.name,
duration: directMedia.duration || playInfo.duration,
playUrl: directMedia.url,
fallback: true,
fallbackReason: playInfo.reason || 'DIRECT_MEDIA_FALLBACK'
}
}
}
const directMediaSelection = toDirectMediaSelection({
playable: false,
targetType,
targetId,
lang: dataSourceConfig.audioLanguage as AudioPlayInfo['lang'],
reason: 'DIRECT_MEDIA_FALLBACK'
})
if (directMediaSelection) return directMediaSelection
try {
const initialPlayInfo = await this.audioPlayInfo.getPlayInfo({ targetType, targetId })
@@ -597,20 +540,6 @@ export class ExplainUseCase {
return toPlayableSelection(initialPlayInfo, targetType, targetId)
}
if (initialPlayInfo.reason === 'TARGET_NOT_FOUND') {
const publishedResolution = await this.publishedAudio.resolveByExhibitName(exhibit.name, initialPlayInfo.lang)
if (publishedResolution?.targets.length) {
for (const candidate of publishedResolution.targets) {
if (candidate.targetType === targetType && candidate.targetId === targetId) continue
const playInfo = await this.audioPlayInfo.getPlayInfo(candidate)
if (playInfo.playable && playInfo.playUrl) {
return toPlayableSelection(playInfo, candidate.targetType, candidate.targetId)
}
}
}
}
return {
exhibit,
track,
@@ -626,11 +555,10 @@ export class ExplainUseCase {
targetType,
targetId,
lang: dataSourceConfig.audioLanguage as AudioPlayInfo['lang'],
hasText: false,
fallback: false,
reason: 'SERVICE_ERROR'
}
const directMediaSelection = toDirectMediaSelection(serviceErrorPlayInfo)
if (directMediaSelection) return directMediaSelection
return {
exhibit,
track,
@@ -642,7 +570,10 @@ export class ExplainUseCase {
}
}
async selectAudioForExplainDetail(exhibit: MuseumExhibit): Promise<ExplainAudioSelection> {
async selectAudioForExplainDetail(
exhibit: MuseumExhibit,
options: { refreshPlayInfo?: boolean } = {}
): Promise<ExplainAudioSelection> {
const targetType = exhibit.playTargetType || 'ITEM'
const targetId = exhibit.playTargetId || exhibit.id
@@ -660,7 +591,8 @@ export class ExplainUseCase {
const playInfo = await this.audioPlayInfo.getPlayInfo({
targetType,
targetId,
lang: (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage)
lang: (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage),
refresh: options.refreshPlayInfo === true
})
if (!playInfo.playable || !playInfo.playUrl) {

View File

@@ -61,6 +61,9 @@ export interface ExplainDetailPageViewModel {
statusText?: string
}
imageStatus?: string
linkedExhibitCount?: number
isSharedStop?: boolean
linkedExhibitStatusText?: string
linkedExhibits?: Array<{
id: string
name: string
@@ -128,9 +131,9 @@ const languageLabelFor = (language?: string) => {
}
const isAudioReady = (exhibit: MuseumExhibit) => (
exhibit.audioStatus === 'READY'
|| exhibit.audioAvailable === true
|| Boolean(exhibit.audioUrl?.trim())
exhibit.audioStatus
? exhibit.audioStatus === 'READY'
: exhibit.audioAvailable === true || Boolean(exhibit.audioUrl?.trim())
)
export const toExplainCardViewModel = (exhibit: MuseumExhibit): ExplainCardViewModel => {
@@ -153,7 +156,7 @@ export const toExplainCardViewModel = (exhibit: MuseumExhibit): ExplainCardViewM
audioDuration: exhibit.audioDuration,
languageLabel: languageLabelFor(exhibit.audioLanguage),
audioStatus,
audioStatusText: hasPlayableAudio ? '可播放' : '图文讲解',
audioStatusText: hasPlayableAudio ? '可播放' : exhibit.audioUnavailableReason || '当前语言暂无语音讲解',
playTargetType: exhibit.playTargetType,
playTargetId: exhibit.playTargetId,
badges: buildBadges(exhibit),
@@ -168,6 +171,14 @@ export const toExplainExhibitViewModel = (exhibit: MuseumExhibit): ExplainExhibi
export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDetailPageViewModel => {
const hasPlayableAudio = isAudioReady(exhibit)
const linkedExhibitCount = typeof exhibit.linkedExhibitCount === 'number'
? exhibit.linkedExhibitCount
: exhibit.linkedExhibits?.length
const linkedExhibitStatusText = linkedExhibitCount
? exhibit.isSharedStop
? `${linkedExhibitCount} 件展品共用此讲解点`
: `关联 ${linkedExhibitCount} 件展品`
: undefined
const metadataLines = [
exhibit.size ? `展品编号:${exhibit.size}` : '',
exhibit.year ? `年代:${exhibit.year}` : '',
@@ -204,9 +215,12 @@ export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDet
playTargetType: exhibit.playTargetType,
playTargetId: exhibit.playTargetId,
hasText: exhibit.audioHasText,
statusText: hasPlayableAudio ? '可播放' : '图文讲解'
statusText: hasPlayableAudio ? '可播放' : '当前语言暂无语音讲解'
},
imageStatus: exhibit.imageStatus,
linkedExhibitCount,
isSharedStop: exhibit.isSharedStop,
linkedExhibitStatusText,
linkedExhibits: exhibit.linkedExhibits,
chapters: metadataLines.length
? metadataLines.map((line, index) => ({