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_APP_PUBLIC_BASE=/
VITE_DATA_SOURCE_MODE=sdk 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_GUIDE_STATIC_DATA_BASE_URL=/static/guide-data
VITE_API_BASE_URL=/app-api 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_AUDIO_LANGUAGE=zh-CN
VITE_SGS_API_BASE_URL=/app-api VITE_SGS_API_BASE_URL=/app-api
VITE_SGS_MAP_ID=1 VITE_SGS_MAP_ID=1

View File

@@ -3,10 +3,10 @@
VITE_APP_PUBLIC_BASE=/ VITE_APP_PUBLIC_BASE=/
VITE_DATA_SOURCE_MODE=sdk 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_GUIDE_STATIC_DATA_BASE_URL=/static/guide-data
VITE_API_BASE_URL=/app-api 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_AUDIO_LANGUAGE=zh-CN
VITE_SGS_API_BASE_URL=/app-api VITE_SGS_API_BASE_URL=/app-api
VITE_SGS_MAP_ID=1 VITE_SGS_MAP_ID=1

View File

@@ -3,10 +3,10 @@
VITE_APP_PUBLIC_BASE=/ VITE_APP_PUBLIC_BASE=/
VITE_DATA_SOURCE_MODE=sdk 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_GUIDE_STATIC_DATA_BASE_URL=/static/guide-data
VITE_API_BASE_URL=/app-api 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_AUDIO_LANGUAGE=zh-CN
VITE_SGS_API_BASE_URL=/app-api VITE_SGS_API_BASE_URL=/app-api
VITE_SGS_MAP_ID=1 VITE_SGS_MAP_ID=1

View File

@@ -3,10 +3,10 @@
VITE_APP_PUBLIC_BASE=/ VITE_APP_PUBLIC_BASE=/
VITE_DATA_SOURCE_MODE=sdk 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_GUIDE_STATIC_DATA_BASE_URL=/static/guide-data
VITE_API_BASE_URL=/app-api 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_AUDIO_LANGUAGE=zh-CN
VITE_SGS_API_BASE_URL=/app-api VITE_SGS_API_BASE_URL=/app-api
VITE_SGS_MAP_ID=1 VITE_SGS_MAP_ID=1

View File

@@ -101,11 +101,16 @@
</view> </view>
<view class="hall-main"> <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> <text v-if="stop.description" class="stop-desc">{{ stop.description }}</text>
<view class="hall-meta-row"> <view class="hall-meta-row">
<view class="floor-badge"> <view class="floor-badge">
<text class="floor-badge-text">{{ stop.hasAudio ? '可讲解' : '图文' }}</text> <text class="floor-badge-text">{{ stop.audioStatus === 'READY' ? '可讲解' : '图文' }}</text>
</view> </view>
<text class="hall-meta-text">{{ stop.floorId || selectedHallName || '楼层待补充' }}</text> <text class="hall-meta-text">{{ stop.floorId || selectedHallName || '楼层待补充' }}</text>
</view> </view>
@@ -158,6 +163,8 @@ export interface ExplainGuideStopSelectItem {
coverImageUrl?: string coverImageUrl?: string
description?: string description?: string
hasAudio?: boolean hasAudio?: boolean
audioStatus?: string
guideLevel?: string
} }
export type ExplainSelectStage = 'hall' | 'unit' | 'stop' export type ExplainSelectStage = 'hall' | 'unit' | 'stop'
@@ -422,6 +429,7 @@ const handleBack = () => {
} }
.hall-name { .hall-name {
min-width: 0;
font-size: 17px; font-size: 17px;
line-height: 24px; line-height: 24px;
font-weight: 800; font-weight: 800;
@@ -431,6 +439,38 @@ const handleBack = () => {
white-space: nowrap; 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 { .hall-meta-row {
margin-top: 10px; margin-top: 10px;
display: flex; display: flex;

View File

@@ -62,20 +62,22 @@
<view class="home-collapse-bar"></view> <view class="home-collapse-bar"></view>
</view> </view>
<view class="category-grid"> <view class="category-scroll">
<view <view class="category-grid">
v-for="item in primaryFacilities" <view
:key="item.id" v-for="item in primaryFacilityColumns"
class="category-item" :key="item.id"
:class="{ active: searchKeyword === item.label }" class="category-item"
@tap="handleFacilityShortcut(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="line-icon" :class="`icon-${item.icon}`">
<view class="icon-part b"></view> <view class="icon-part a"></view>
<view class="icon-part c"></view> <view class="icon-part b"></view>
<view class="icon-part c"></view>
</view>
<text class="category-label">{{ item.label }}</text>
</view> </view>
<text class="category-label">{{ item.label }}</text>
</view> </view>
</view> </view>
@@ -182,14 +184,36 @@ const emit = defineEmits<{
}>() }>()
const primaryFacilities: ShortcutItem[] = [ 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: 'elevator', label: '电梯', icon: 'elevator', keywords: ['电梯', 'elevator'] },
{ id: 'escalator', label: '扶梯', icon: 'escalator', keywords: ['扶梯', 'escalator'] }, { id: 'escalator', label: '扶梯', icon: 'escalator', keywords: ['扶梯', 'escalator'] },
{ id: 'restroom', label: '洗手间', icon: 'restroom', keywords: ['洗手间', '卫生间', 'toilet', 'accessible_toilet'] }, { id: 'stairs', label: '楼梯', icon: 'stairs', keywords: ['楼梯', 'stairs', 'stair'] }
{ id: 'nursing', label: '母婴室', icon: 'nursing', keywords: ['母婴室', 'mother_baby_room'] },
{ id: 'entrance', label: '出入口', icon: 'entrance', keywords: ['出入口', '入口', '出口', 'entrance_exit'] }
] ]
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[] = [ const serviceFacilities: ShortcutItem[] = [
{ id: 'drink', label: '饮料售卖机', icon: 'drink', keywords: ['饮料售卖机', '饮料', '售卖机', 'vending'] }, { id: 'drink', label: '饮料售卖机', icon: 'drink', keywords: ['饮料售卖机', '饮料', '售卖机', 'vending'] },
{ id: 'ticket', label: '票务售卖机', icon: 'ticket', keywords: ['票务售卖机', '票务', '售票', 'ticket_office'] }, { id: 'ticket', label: '票务售卖机', icon: 'ticket', keywords: ['票务售卖机', '票务', '售票', 'ticket_office'] },
@@ -678,9 +702,11 @@ defineExpose({
.poi-search-panel { .poi-search-panel {
width: 100%; width: 100%;
max-width: 100%;
box-sizing: border-box; box-sizing: border-box;
color: #262421; color: #262421;
font-family: '鸿蒙黑体', 'HarmonyOS Sans SC', 'HarmonyOS Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-family: '鸿蒙黑体', 'HarmonyOS Sans SC', 'HarmonyOS Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
overflow-x: hidden;
} }
.variant-home.expanded { .variant-home.expanded {
@@ -758,6 +784,8 @@ defineExpose({
} }
.variant-home .search-box { .variant-home .search-box {
background: #f5f5ed;
border-color: rgba(224, 225, 0, 0.72);
box-shadow: none; box-shadow: none;
} }
@@ -768,6 +796,8 @@ defineExpose({
overflow-x: auto; overflow-x: auto;
padding-bottom: 2px; padding-bottom: 2px;
scrollbar-width: none; scrollbar-width: none;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
} }
.home-category-strip::-webkit-scrollbar { .home-category-strip::-webkit-scrollbar {
@@ -775,18 +805,50 @@ defineExpose({
} }
.home-category-chip { .home-category-chip {
min-width: 70px; min-width: 112px;
height: 42px; height: 58px;
flex: 0 0 auto; flex: 0 0 112px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: space-between;
gap: 6px; gap: 8px;
padding: 0 10px; padding: 0 11px;
box-sizing: border-box; box-sizing: border-box;
background: #f4f5ef; background: #e0df00;
border: 1px solid #dde5df; border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 8px; 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 { .home-category-chip:active {
@@ -794,14 +856,16 @@ defineExpose({
} }
.home-category-icon { .home-category-icon {
width: 20px; width: 24px;
height: 19px; height: 23px;
flex: 0 0 20px; flex: 0 0 24px;
order: 2;
color: #050704;
} }
.home-category-icon.line-icon { .home-category-icon.line-icon {
width: 20px; width: 24px;
height: 19px; height: 23px;
} }
.home-category-icon .icon-part { .home-category-icon .icon-part {
@@ -810,11 +874,12 @@ defineExpose({
} }
.home-category-label { .home-category-label {
max-width: 58px; min-width: 0;
font-size: 12px; max-width: 70px;
line-height: 16px; font-size: 14px;
font-weight: 700; line-height: 18px;
color: #151713; font-weight: 800;
color: #050704;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -919,6 +984,7 @@ defineExpose({
} }
.variant-page .poi-search-content { .variant-page .poi-search-content {
margin-top: 14px;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: flex; display: flex;
@@ -959,30 +1025,63 @@ defineExpose({
padding-right: 0; 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-grid {
--category-gap: 8px;
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-auto-flow: column;
gap: 8px; 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 { .category-item {
min-width: 0; min-width: 0;
min-height: 70px; min-height: 70px;
padding: 9px 6px; position: relative;
padding: 9px 6px 10px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 7px; gap: 7px;
box-sizing: border-box; box-sizing: border-box;
background: rgba(255, 255, 255, 0.92); overflow: hidden;
border: 1px solid #e2e4da; background: #fffef2;
border: 1px solid #ecebd2;
border-radius: 8px; border-radius: 8px;
} }
.category-item.active { .category-item.active {
background: #151713; background: #fffddc;
border-color: #151713; border-color: #d8d900;
}
.category-item.active::after {
content: '';
position: absolute;
left: 14px;
right: 14px;
bottom: 0;
height: 4px;
background: var(--museum-accent);
} }
.line-icon { .line-icon {
@@ -993,7 +1092,7 @@ defineExpose({
} }
.category-item.active .line-icon { .category-item.active .line-icon {
color: var(--museum-accent); color: #151713;
} }
.icon-part { .icon-part {
@@ -1002,6 +1101,8 @@ defineExpose({
} }
.icon-service .icon-part.a, .icon-service .icon-part.a,
.icon-exhibition .icon-part.a,
.icon-cinema .icon-part.a,
.icon-ticket .icon-part.a, .icon-ticket .icon-part.a,
.icon-drink .icon-part.a, .icon-drink .icon-part.a,
.icon-bag .icon-part.a { .icon-bag .icon-part.a {
@@ -1014,6 +1115,8 @@ defineExpose({
} }
.icon-service .icon-part.b, .icon-service .icon-part.b,
.icon-exhibition .icon-part.b,
.icon-cinema .icon-part.b,
.icon-ticket .icon-part.b, .icon-ticket .icon-part.b,
.icon-drink .icon-part.b, .icon-drink .icon-part.b,
.icon-bag .icon-part.b { .icon-bag .icon-part.b {
@@ -1030,6 +1133,8 @@ defineExpose({
.icon-escalator .icon-part.a, .icon-escalator .icon-part.a,
.icon-restroom .icon-part.a, .icon-restroom .icon-part.a,
.icon-nursing .icon-part.a, .icon-nursing .icon-part.a,
.icon-restaurant .icon-part.a,
.icon-stairs .icon-part.a,
.icon-entrance .icon-part.a { .icon-entrance .icon-part.a {
left: 4px; left: 4px;
right: 4px; right: 4px;
@@ -1069,6 +1174,63 @@ defineExpose({
box-shadow: 8px 0 0 currentColor; 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 { .category-label {
max-width: 100%; max-width: 100%;
font-size: 12px; font-size: 12px;
@@ -1082,12 +1244,24 @@ defineExpose({
} }
.category-item.active .category-label { .category-item.active .category-label {
color: var(--museum-accent); color: #151713;
} }
.section-title-row { .section-title-row {
margin-top: 14px; position: relative;
margin-bottom: 8px; 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 { .section-title {
@@ -1113,19 +1287,30 @@ defineExpose({
.facility-chip { .facility-chip {
height: 34px; height: 34px;
flex: 0 0 auto; flex: 0 0 auto;
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 7px; gap: 7px;
padding: 0 12px; padding: 0 12px;
box-sizing: border-box; box-sizing: border-box;
background: rgba(255, 255, 255, 0.94); background: #fffef6;
border: 1px solid #e2e4da; border: 1px solid #e8e7d2;
border-radius: 8px; border-radius: 8px;
} }
.facility-chip.active { .facility-chip.active {
background: #151713; background: #fffddc;
border-color: #151713; border-color: #d6d700;
}
.facility-chip.active::after {
content: '';
position: absolute;
left: 12px;
right: 12px;
bottom: -1px;
height: 3px;
background: var(--museum-accent);
} }
.chip-icon { .chip-icon {
@@ -1140,7 +1325,7 @@ defineExpose({
} }
.facility-chip.active .chip-icon { .facility-chip.active .chip-icon {
color: var(--museum-accent); color: #151713;
} }
.facility-chip-text { .facility-chip-text {
@@ -1152,26 +1337,27 @@ defineExpose({
} }
.facility-chip.active .facility-chip-text { .facility-chip.active .facility-chip-text {
color: var(--museum-accent); color: #151713;
} }
.result-card { .result-card {
margin-top: 12px; margin-top: 14px;
overflow: hidden; overflow: hidden;
background: rgba(255, 255, 255, 0.96); background: #fffef7;
border: 1px solid #e2e4da; border: 1px solid #ecebd8;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 10px 24px rgba(75, 78, 60, 0.08);
} }
.result-card-header { .result-card-header {
height: 42px; height: 46px;
padding: 0 12px; padding: 0 14px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
box-sizing: border-box; box-sizing: border-box;
border-bottom: 1px solid #eceee6; border-bottom: 1px solid #efeedf;
} }
.museum-title, .museum-title,
@@ -1196,14 +1382,15 @@ defineExpose({
} }
.floor-tabs { .floor-tabs {
width: 58px; width: 62px;
flex: 0 0 58px; flex: 0 0 62px;
padding: 8px 0; padding: 9px 0;
box-sizing: border-box; box-sizing: border-box;
background: #f3f4ee; background: #f6f5e8;
} }
.floor-tab { .floor-tab {
position: relative;
height: 31px; height: 31px;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1216,6 +1403,16 @@ defineExpose({
font-weight: 800; 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 { .floor-tab-text {
font-size: 12px; font-size: 12px;
line-height: 16px; line-height: 16px;
@@ -1227,6 +1424,7 @@ defineExpose({
} }
.variant-page .category-grid, .variant-page .category-grid,
.variant-page .category-scroll,
.variant-page .section-title-row, .variant-page .section-title-row,
.variant-page .facility-chip-row { .variant-page .facility-chip-row {
flex: 0 0 auto; flex: 0 0 auto;
@@ -1257,6 +1455,7 @@ defineExpose({
} }
.variant-home.expanded .category-grid, .variant-home.expanded .category-grid,
.variant-home.expanded .category-scroll,
.variant-home.expanded .section-title-row, .variant-home.expanded .section-title-row,
.variant-home.expanded .facility-chip-row { .variant-home.expanded .facility-chip-row {
flex: 0 0 auto; flex: 0 0 auto;
@@ -1282,13 +1481,13 @@ defineExpose({
} }
.result-row { .result-row {
min-height: 54px; min-height: 58px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 9px; gap: 9px;
padding: 9px 10px; padding: 10px 12px;
box-sizing: border-box; box-sizing: border-box;
border-bottom: 1px solid #edf0e8; border-bottom: 1px solid #efefe1;
} }
.result-row:last-child { .result-row:last-child {
@@ -1377,12 +1576,7 @@ defineExpose({
@media (max-width: 360px) { @media (max-width: 360px) {
.category-grid { .category-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: repeat(2, minmax(62px, auto));
}
.variant-page .category-grid,
.variant-home.expanded .category-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.variant-page .category-item, .variant-page .category-item,

View File

@@ -9,7 +9,7 @@ const defaultSdkTimeoutMs = 5000
const defaultApiBaseUrl = '/app-api' const defaultApiBaseUrl = '/app-api'
const defaultSgsMapId = '1' const defaultSgsMapId = '1'
const defaultGuideStaticDataBaseUrl = '/static/guide-data' const defaultGuideStaticDataBaseUrl = '/static/guide-data'
const defaultAudioApiBaseUrl = '/yudao-server' const defaultAudioApiBaseUrl = '/app-api'
const normalizeMode = (mode: string | undefined): DataSourceMode => { const normalizeMode = (mode: string | undefined): DataSourceMode => {
if (mode && allowedModes.has(mode as DataSourceMode)) { if (mode && allowedModes.has(mode as DataSourceMode)) {
@@ -24,9 +24,13 @@ const normalizeGuideContentMode = (mode: string | undefined): GuideContentDataSo
return mode as GuideContentDataSourceMode 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 normalizeUrl = (url: string | undefined, fallback: string) => {
const normalized = url?.trim() const normalized = url?.trim()
return normalized || fallback return normalized || fallback
@@ -50,6 +54,8 @@ const inferOrigin = (url: string) => {
export const dataSourceConfig = { export const dataSourceConfig = {
mode: normalizeMode(import.meta.env.VITE_DATA_SOURCE_MODE), mode: normalizeMode(import.meta.env.VITE_DATA_SOURCE_MODE),
guideContentMode: normalizeGuideContentMode(import.meta.env.VITE_GUIDE_CONTENT_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( guideStaticDataBaseUrl: normalizeUrl(
import.meta.env.VITE_GUIDE_STATIC_DATA_BASE_URL, import.meta.env.VITE_GUIDE_STATIC_DATA_BASE_URL,
defaultGuideStaticDataBaseUrl defaultGuideStaticDataBaseUrl

View File

@@ -61,12 +61,22 @@ export interface BackendExhibit {
export interface BackendHall { export interface BackendHall {
id?: string | number | null id?: string | number | null
name?: string | null name?: string | null
poiId?: string | number | null
hallCode?: string | null hallCode?: string | null
nameEn?: string | null nameEn?: string | null
subtitle?: string | null subtitle?: string | null
description?: string | null description?: string | null
coverImageUrl?: string | null coverImageUrl?: string | null
floorId?: string | number | null
floorCode?: string | null
exhibitCount?: number | 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 { export interface BackendGuideStop {
@@ -87,6 +97,61 @@ export interface BackendGuideStop {
sort?: number | null 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 { export interface BackendExplainAdapterResult {
exhibit: MuseumExhibit exhibit: MuseumExhibit
track: ExplainTrack track: ExplainTrack
@@ -130,6 +195,15 @@ const normalizeAudioTargetType = (targetType: string | null | undefined): AudioP
return normalized === 'ITEM' || normalized === 'STOP' ? normalized : undefined 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) => ( const isAudioReady = (value?: string | null) => (
!value || ['READY', 'PUBLISHED', 'AVAILABLE'].includes(value.toUpperCase()) !value || ['READY', 'PUBLISHED', 'AVAILABLE'].includes(value.toUpperCase())
) )
@@ -170,6 +244,230 @@ const buildTags = (exhibit: BackendExhibit) => Array.from(new Set([
firstText(exhibit.origin) firstText(exhibit.origin)
].filter(Boolean))) ].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 = ( export const toBackendMuseumExhibit = (
source: BackendExhibit, source: BackendExhibit,
hall?: MuseumHall | null, hall?: MuseumHall | null,
@@ -277,15 +575,22 @@ export const toBackendHallFromList = (source: BackendHall, fallback?: MuseumHall
return { return {
id, id,
name, name,
floorId: fallback?.floorId, floorId: stringifyId(source.floorId) || fallback?.floorId,
floorLabel: fallback?.floorLabel, floorLabel: firstText(source.floorCode, fallback?.floorLabel) || undefined,
description: firstText(source.description, source.subtitle, fallback?.description, '该展厅暂无介绍。'), description: firstText(source.description, source.subtitle, fallback?.description, '该展厅暂无介绍。'),
image: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || fallback?.image || HALL_PLACEHOLDER_IMAGE, image: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || fallback?.image || HALL_PLACEHOLDER_IMAGE,
exhibitCount: typeof source.exhibitCount === 'number' exhibitCount: typeof source.exhibitCount === 'number'
? source.exhibitCount ? source.exhibitCount
: fallback?.exhibitCount || 0, : 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, area: fallback?.area,
poiId: fallback?.poiId, poiId: stringifyId(source.poiId) || fallback?.poiId,
location: fallback?.location location: fallback?.location
} }
} }

View File

@@ -18,6 +18,7 @@ export interface BackendGuideStopInfo {
targetType?: string | null targetType?: string | null
targetId?: string | number | null targetId?: string | number | null
resolvedStopId?: string | number | null resolvedStopId?: string | number | null
stopId?: string | number | null
lang?: string | null lang?: string | null
title?: string | null title?: string | null
description?: string | null description?: string | null
@@ -33,6 +34,40 @@ export interface BackendGuideStopInfo {
supportedLanguages?: string[] | null supportedLanguages?: string[] | null
audioStatus?: string | null audioStatus?: string | null
reason?: 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 { export interface GuideStopLinkedExhibit {
@@ -63,12 +98,55 @@ export interface GuideStopInfo {
supportedLanguages: string[] supportedLanguages: string[]
audioStatus: 'READY' | 'MISSING' | string audioStatus: 'READY' | 'MISSING' | string
reason?: 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) => ( const stringifyId = (value: string | number | null | undefined) => (
value === null || typeof value === 'undefined' ? '' : String(value) 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 normalizeTargetType = (value: string | null | undefined, fallback: AudioPlayTargetType): AudioPlayTargetType => {
const normalized = value?.toUpperCase() const normalized = value?.toUpperCase()
return normalized === 'ITEM' || normalized === 'STOP' ? normalized : fallback return normalized === 'ITEM' || normalized === 'STOP' ? normalized : fallback
@@ -95,13 +173,15 @@ const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls']) => {
.filter(Boolean) .filter(Boolean)
} }
} catch { } catch {
// 兼容后端历史逗号拼接字段。 return []
} }
return trimmed return []
.split(',') }
.map((entry) => normalizeSameOriginPublicUrl(entry.trim()))
.filter(Boolean) const normalizeNarrationTier = (value: string | null | undefined) => {
const normalized = value?.toUpperCase()
return normalized === 'STANDARD' || normalized === 'EXTENDED' ? normalized : undefined
} }
const normalizeLinkedExhibits = (items: BackendGuideStopLinkedExhibit[] | null | undefined) => ( const normalizeLinkedExhibits = (items: BackendGuideStopLinkedExhibit[] | null | undefined) => (
@@ -144,7 +224,7 @@ export const toGuideStopInfo = (
available: source.available === true, available: source.available === true,
targetType, targetType,
targetId, targetId,
resolvedStopId: stringifyId(source.resolvedStopId) || undefined, resolvedStopId: stringifyId(source.resolvedStopId) || stringifyId(source.stopId) || undefined,
lang: normalizeLanguage(source.lang || fallback.lang), lang: normalizeLanguage(source.lang || fallback.lang),
title: source.title?.trim() || '讲解内容', title: source.title?.trim() || '讲解内容',
description: source.description?.trim() || undefined, description: source.description?.trim() || undefined,
@@ -159,6 +239,64 @@ export const toGuideStopInfo = (
hasText: source.hasText === true, hasText: source.hasText === true,
supportedLanguages: source.supportedLanguages || [], supportedLanguages: source.supportedLanguages || [],
audioStatus: source.audioStatus || 'MISSING', 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 reason: source.reason || undefined
} }
} }

View File

@@ -11,21 +11,15 @@ import type {
ExplainContentProvider ExplainContentProvider
} from '@/data/providers/staticMuseumContentProvider' } from '@/data/providers/staticMuseumContentProvider'
import { import {
defaultSgsSdkApiProvider, toCatalogBusinessUnits,
type SgsSdkApiProvider toCatalogGuideStop,
} from '@/data/providers/sgsSdkApiProvider' toCatalogHall,
import { toCatalogMuseumExhibitFromStop,
toBackendExplainTrack, flattenCatalogOutlines,
toBackendHallFromList, type BackendCatalogHallItem,
toBackendMediaAsset, type BackendCatalogOutlineItem,
toBackendMuseumExhibit, type BackendCatalogStopItem
groupGuideStopsByOutline,
type BackendExhibit,
type BackendHall
} from '@/data/adapters/backendExplainDataAdapter' } from '@/data/adapters/backendExplainDataAdapter'
import {
toExplainGuideStopFromSgs
} from '@/data/adapters/sgsSdkGuideAdapter'
interface CommonResult<T> { interface CommonResult<T> {
code: number code: number
@@ -79,234 +73,414 @@ const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject
const normalizeKeyword = (keyword = '') => keyword.trim() 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 { export class BackendExplainContentProvider implements ExplainContentProvider {
private readonly searchCache = new Map<string, MuseumExhibit[]>() private readonly explainExhibitCache = new Map<string, MuseumExhibit[]>()
private readonly searchInflight = new Map<string, Promise<MuseumExhibit[]>>() private readonly explainExhibitInflight = new Map<string, Promise<MuseumExhibit[]>>()
private readonly detailCache = new Map<string, MuseumExhibit>() private readonly detailCache = new Map<string, MuseumExhibit>()
private readonly detailInflight = new Map<string, Promise<MuseumExhibit | null>>() private readonly hallListCache = new Map<string, MuseumHall[]>()
private hallListCache: MuseumHall[] | null = null private readonly hallListInflight = new Map<string, Promise<MuseumHall[]>>()
private hallListInflight: Promise<MuseumHall[]> | null = null
private readonly guideStopCache = new Map<string, ExplainGuideStop[]>() private readonly guideStopCache = new Map<string, ExplainGuideStop[]>()
private readonly guideStopInflight = new Map<string, Promise<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( constructor(
private readonly fallbackProvider: ExplainContentProvider, private readonly fallbackProvider?: ExplainContentProvider
private readonly sgsSdkApiProvider: SgsSdkApiProvider = defaultSgsSdkApiProvider
) {} ) {}
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() { private async safeFallbackHalls() {
return this.fallbackProvider.listHalls().catch(() => []) if (!this.isStaticFallbackEnabled()) return []
return this.fallbackProvider?.listHalls().catch(() => []) || []
} }
private async requestHallList() { private isStaticFallbackEnabled() {
if (this.hallListCache) return this.hallListCache return Boolean(this.fallbackProvider && dataSourceConfig.allowExplainStaticFallback)
if (this.hallListInflight) return this.hallListInflight }
this.hallListInflight = (async () => { private async fallbackOrThrow<T>(
const url = `${resolveAppApiBaseUrl()}/gis/hall/list` error: unknown,
const response = await requestJson<CommonResult<BackendHall[]>>(url) message: string,
if (response.code !== 0 || !Array.isArray(response.data)) { fallback: () => Promise<T>
throw new Error(response.msg || '后端展厅列表加载失败') ): 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 fallbackHalls = await this.safeFallbackHalls()
const fallbackHallById = new Map(fallbackHalls.map((hall) => [hall.id, hall])) const fallbackHallById = new Map(fallbackHalls.map((hall) => [hall.id, hall]))
const fallbackHallByName = new Map(fallbackHalls.map((hall) => [hall.name, hall])) const fallbackHallByName = new Map(fallbackHalls.map((hall) => [hall.name, hall]))
const halls = response.data const halls = items
.map((item) => toBackendHallFromList( .map((item) => toCatalogHall(
item, item,
fallbackHallById.get(String(item.id || '')) || fallbackHallByName.get(String(item.name || '')) fallbackHallById.get(String(item.id || '')) || fallbackHallByName.get(String(item.name || ''))
)) ))
.filter((hall) => hall.id) .filter((hall) => hall.id)
this.hallListCache = halls this.hallListCache.set(key, halls)
return halls return halls
})() })()
try { this.hallListInflight.set(key, promise)
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)
try { try {
return await promise return await promise
} finally { } 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[]> { async listTemporaryBusinessUnitsByHall(hallId: string): Promise<ExplainBusinessUnit[]> {
const stops = await this.listGuideStopsByHall(hallId) const normalizedHallId = hallId.trim()
return groupGuideStopsByOutline(hallId, stops) 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() { async listExplainExhibits() {
return this.requestStaticExplainExhibits() try {
return await this.requestCatalogExhibits()
} catch (error) {
return this.fallbackOrThrow(error, '讲解目录展项加载失败', async () => (
this.fallbackProvider?.listExplainExhibits() || []
))
}
} }
listExhibits() { listExhibits() {
return this.listExplainExhibits() return this.listExplainExhibits()
} }
getExhibitById(id: string) { async getExhibitById(id: string) {
return this.requestDetail(id) 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() { async listHalls() {
try { try {
return await this.requestHallList() return await this.requestCatalogHalls()
} catch (error) { } catch (error) {
console.warn('后端展厅列表加载失败,将使用静态展厅兜底:', error) return this.fallbackOrThrow(error, '讲解展厅目录加载失败', async () => (
return this.fallbackProvider.listHalls() this.fallbackProvider?.listHalls() || []
))
} }
} }
async listTracks() { async listTracks() {
const exhibits = await this.listExplainExhibits() 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) { async getMediaById() {
const normalizedId = id.replace(/^media-/, '') return null
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 getMediaForExplainTrack(trackId: string) { async getMediaForExplainTrack() {
return this.getMediaById(`media-${trackId.replace(/^track-/, '')}`) return null
} }
searchExplainExhibits(keyword = '') { searchExplainExhibits(keyword = '') {
const normalizedKeyword = normalizeKeyword(keyword) const normalizedKeyword = normalizeKeyword(keyword)
if (!normalizedKeyword) return this.requestStaticExplainExhibits() if (!normalizedKeyword) return this.listExplainExhibits()
return this.requestSearch(normalizedKeyword).catch((error) => { return this.searchCatalogExhibits(normalizedKeyword).catch((error) => {
console.warn('后端讲解搜索失败,将使用静态讲解兜底:', error) return this.fallbackOrThrow(error, '讲解目录搜索失败', async () => (
return this.searchStaticExplainExhibits(normalizedKeyword) 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()) { if (isGuideContentRemoteMode()) {
return new BackendExplainContentProvider(new StaticGuideContentProvider()) return new BackendExplainContentProvider(
dataSourceConfig.allowExplainStaticFallback ? new StaticGuideContentProvider() : undefined
)
} }
if (isGuideContentMockMode()) { if (isGuideContentMockMode()) {
@@ -278,4 +280,5 @@ export const createMuseumContentProvider = (): ExplainContentProvider => {
return new StaticGuideContentProvider() 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 description?: string
image?: string image?: string
exhibitCount?: number exhibitCount?: number
outlineCount?: number
stopCount?: number
linkedExhibitCount?: number
audioReadyStopCount?: number
hasAudio?: boolean
audioStatus?: string
supportedLanguages?: string[]
area?: string area?: string
poiId?: string poiId?: string
location?: GuideLocationResolution location?: GuideLocationResolution
@@ -170,6 +177,8 @@ export interface MuseumExhibit {
imageStatus?: string imageStatus?: string
imageSource?: string imageSource?: string
galleryUrls?: string[] galleryUrls?: string[]
linkedExhibitCount?: number
isSharedStop?: boolean
linkedExhibits?: Array<{ linkedExhibits?: Array<{
id: string id: string
name: string name: string
@@ -195,18 +204,44 @@ export interface ExplainGuideStop {
coverImageUrl?: string coverImageUrl?: string
description?: string description?: string
hasAudio?: boolean hasAudio?: boolean
audioStatus?: string
supportedLanguages?: string[]
hasTextRecord?: boolean
poiId?: string poiId?: string
outlineId?: string outlineId?: string
outlineName?: string outlineName?: string
sort?: number 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 { export interface ExplainBusinessUnit {
id: string id: string
name: string name: string
hallId: string hallId: string
parentId?: string
code?: string
description?: string
sort?: number
level?: number
previewImageUrl?: string previewImageUrl?: string
guideStopCount: number guideStopCount: number
linkedExhibitCount?: number
audioReadyStopCount?: number
hasAudio?: boolean
audioStatus?: string
supportedLanguages?: string[]
stops: ExplainGuideStop[] stops: ExplainGuideStop[]
} }

View File

@@ -24,9 +24,14 @@
<text class="hero-placeholder-text">讲解</text> <text class="hero-placeholder-text">讲解</text>
</view> </view>
<view class="detail-body"> <view class="detail-body">
<text class="detail-title">{{ exhibit.title }}</text> <text class="detail-title">{{ exhibit.title }}</text>
<text v-if="detailMeta" class="detail-meta">{{ detailMeta }}</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 <view
v-if="exhibit.audio.status === 'playable'" v-if="exhibit.audio.status === 'playable'"
@@ -328,6 +333,8 @@ const handleAudioError = async (_audio: AudioItem | null, message: string) => {
audioLanguage: exhibit.value.audio.language, audioLanguage: exhibit.value.audio.language,
playTargetType: exhibit.value.audio.playTargetType, playTargetType: exhibit.value.audio.playTargetType,
playTargetId: exhibit.value.audio.playTargetId playTargetId: exhibit.value.audio.playTargetId
}, {
refreshPlayInfo: true
}) })
if (selection.playable && selection.media?.url) { if (selection.playable && selection.media?.url) {
@@ -491,6 +498,46 @@ const handleBack = () => {
color: #555c51; 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 { .audio-panel {
height: 76px; height: 76px;
margin-top: 24px; margin-top: 24px;

View File

@@ -109,7 +109,9 @@ const explainGuideStopItems = computed<ExplainGuideStopSelectItem[]>(() => (
floorId: stop.floorId, floorId: stop.floorId,
coverImageUrl: stop.coverImageUrl, coverImageUrl: stop.coverImageUrl,
description: stop.description, 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, floorId: stop.floorId,
coverImageUrl: stop.coverImageUrl, coverImageUrl: stop.coverImageUrl,
description: stop.description, 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 if (!poi) return null
const halls = await explainUseCase.listHalls() 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) => ( const matchedByPoiId = halls.find((hall) => (
hall.poiId === poi.id hall.poiId === poi.id
|| hall.location?.poiId === poi.id || hall.location?.poiId === poi.id
|| (poi.entrances || []).some((entrance) => hall.poiId === entrance.sourcePlaceId)
)) ))
if (matchedByPoiId) return matchedByPoiId 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) const poiHallName = normalizeHallMatchText(poi.hallName || poi.name)
return halls.find((hall) => { return halls.find((hall) => {
const hallName = normalizeHallMatchText(hall.name) const hallName = normalizeHallMatchText(hall.name)
@@ -1932,17 +1941,30 @@ const handleExplainBack = () => {
left: 50%; left: 50%;
right: auto; right: auto;
width: calc(var(--museum-h5-page-width) - 24px); width: calc(var(--museum-h5-page-width) - 24px);
bottom: calc(env(safe-area-inset-bottom) + 16px); bottom: calc(env(safe-area-inset-bottom) + 14px);
padding: 12px; padding: 10px;
box-sizing: border-box; box-sizing: border-box;
background: rgba(255, 255, 255, 0.96); overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.96); background:
border-radius: 16px; linear-gradient(180deg, rgba(224, 225, 0, 0.22) 0, rgba(224, 225, 0, 0) 58px),
box-shadow: 0 10px 28px rgba(36, 49, 42, 0.16); #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%); transform: translateX(-50%);
z-index: 1003; 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 { .guide-home-dock.expanded {
position: fixed; position: fixed;
left: 50%; left: 50%;
@@ -1964,10 +1986,14 @@ const handleExplainBack = () => {
transform: translateX(-50%); transform: translateX(-50%);
overflow: hidden; overflow: hidden;
overscroll-behavior: contain; overscroll-behavior: contain;
touch-action: pan-y; touch-action: pan-x pan-y;
z-index: 1100; z-index: 1100;
} }
.guide-home-dock.expanded::before {
display: none;
}
/* #ifdef H5 */ /* #ifdef H5 */
.guide-home-dock.expanded { .guide-home-dock.expanded {
min-height: 100vh; min-height: 100vh;
@@ -1988,18 +2014,18 @@ const handleExplainBack = () => {
.guide-quick-actions { .guide-quick-actions {
position: absolute; position: absolute;
top: clamp(96px, 14vh, 118px); top: clamp(88px, 13vh, 112px);
right: 18px; right: 14px;
width: 48px; width: 52px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 7px;
z-index: 1002; z-index: 1002;
} }
.guide-quick-action { .guide-quick-action {
width: 48px; width: 52px;
min-height: 56px; min-height: 58px;
padding: 7px 4px 6px; padding: 7px 4px 6px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -2007,15 +2033,16 @@ const handleExplainBack = () => {
justify-content: center; justify-content: center;
gap: 4px; gap: 4px;
box-sizing: border-box; box-sizing: border-box;
background: rgba(255, 255, 255, 0.96); background: rgba(245, 245, 237, 0.94);
border: 1px solid rgba(230, 233, 224, 0.96); border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 8px; 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 { .guide-quick-action.primary {
background: #151713; background: var(--museum-accent);
border-color: #151713; border-color: var(--museum-accent);
} }
.guide-quick-action:active { .guide-quick-action:active {
@@ -2031,7 +2058,7 @@ const handleExplainBack = () => {
} }
.guide-quick-action.primary .guide-quick-icon { .guide-quick-action.primary .guide-quick-icon {
color: var(--museum-accent); color: #0a0b08;
} }
.guide-quick-icon-floor { .guide-quick-icon-floor {
@@ -2113,7 +2140,7 @@ const handleExplainBack = () => {
} }
.guide-quick-action-text.primary { .guide-quick-action-text.primary {
color: var(--museum-accent); color: #0a0b08;
} }
.route-sim-top-pill { .route-sim-top-pill {

View File

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

View File

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

View File

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

View File

@@ -1,39 +1,8 @@
import {
dataSourceConfig
} from '@/config/dataSource'
import type {
AudioPlayTargetType
} from '@/domain/museum'
import type { import type {
AudioLanguage, AudioLanguage,
AudioPlayInfoRequest AudioPlayInfoRequest
} from '@/repositories/AudioPlayInfoRepository' } 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 { export interface PublishedExhibitAudioResolution {
targets: AudioPlayInfoRequest[] targets: AudioPlayInfoRequest[]
} }
@@ -42,120 +11,10 @@ export interface PublishedExhibitAudioRepository {
resolveByExhibitName(exhibitName: string, lang?: AudioLanguage): Promise<PublishedExhibitAudioResolution | null> resolveByExhibitName(exhibitName: string, lang?: AudioLanguage): Promise<PublishedExhibitAudioResolution | null>
} }
const stringifyId = (value: string | number | null | undefined) => ( export class DisabledPublishedExhibitAudioRepository implements PublishedExhibitAudioRepository {
value === null || typeof value === 'undefined' ? '' : String(value) async resolveByExhibitName() {
) return null
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 const publishedExhibitAudioRepository = new DefaultPublishedExhibitAudioRepository() export const publishedExhibitAudioRepository = new DisabledPublishedExhibitAudioRepository()

View File

@@ -12,10 +12,6 @@ import {
explainRepository, explainRepository,
type ExplainRepository type ExplainRepository
} from '@/repositories/ExplainRepository' } from '@/repositories/ExplainRepository'
import {
mediaRepository,
type MediaRepository
} from '@/repositories/MediaRepository'
import { import {
audioPlayInfoRepository, audioPlayInfoRepository,
audioReasonToText, audioReasonToText,
@@ -28,10 +24,6 @@ import {
import type { import type {
GuideStopInfo GuideStopInfo
} from '@/data/adapters/guideStopInfoAdapter' } from '@/data/adapters/guideStopInfoAdapter'
import {
publishedExhibitAudioRepository,
type PublishedExhibitAudioRepository
} from '@/repositories/PublishedExhibitAudioRepository'
import { import {
dataSourceConfig dataSourceConfig
} from '@/config/dataSource' } from '@/config/dataSource'
@@ -75,9 +67,7 @@ export interface ExplainHallSummary {
export class ExplainUseCase { export class ExplainUseCase {
constructor( constructor(
private readonly explain: ExplainRepository = explainRepository, private readonly explain: ExplainRepository = explainRepository,
private readonly media: MediaRepository = mediaRepository, private readonly audioPlayInfo: AudioPlayInfoRepository = audioPlayInfoRepository
private readonly audioPlayInfo: AudioPlayInfoRepository = audioPlayInfoRepository,
private readonly publishedAudio: PublishedExhibitAudioRepository = publishedExhibitAudioRepository
) {} ) {}
private applyTrackAudioState(exhibit: MuseumExhibit, track?: ExplainTrack | null): MuseumExhibit { private applyTrackAudioState(exhibit: MuseumExhibit, track?: ExplainTrack | null): MuseumExhibit {
@@ -134,7 +124,7 @@ export class ExplainUseCase {
fallback?: MuseumExhibit | null fallback?: MuseumExhibit | null
): MuseumExhibit { ): MuseumExhibit {
const linkedPrimary = stopInfo.linkedExhibits[0] 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 description = stopInfo.description || fallback?.description || '该讲解暂无简介。'
const audioTarget = this.resolveStopInfoAudioTarget(stopInfo) const audioTarget = this.resolveStopInfoAudioTarget(stopInfo)
const audioAvailable = stopInfo.audioStatus === 'READY' const audioAvailable = stopInfo.audioStatus === 'READY'
@@ -175,6 +165,8 @@ export class ExplainUseCase {
imageStatus: stopInfo.imageStatus, imageStatus: stopInfo.imageStatus,
imageSource: stopInfo.imageSource, imageSource: stopInfo.imageSource,
galleryUrls: stopInfo.galleryUrls, galleryUrls: stopInfo.galleryUrls,
linkedExhibitCount: stopInfo.linkedExhibitCount,
isSharedStop: stopInfo.isSharedStop,
linkedExhibits: stopInfo.linkedExhibits, linkedExhibits: stopInfo.linkedExhibits,
stopInfoAvailable: stopInfo.available, stopInfoAvailable: stopInfo.available,
stopInfoReason: stopInfo.reason, stopInfoReason: stopInfo.reason,
@@ -211,15 +203,10 @@ export class ExplainUseCase {
private applyPlayInfo( private applyPlayInfo(
exhibit: MuseumExhibit, exhibit: MuseumExhibit,
playInfo: AudioPlayInfo, playInfo: AudioPlayInfo
options: { preserveStaticFallback?: boolean } = {}
): MuseumExhibit { ): MuseumExhibit {
const apiPlayable = playInfo.playable === true && Boolean(playInfo.playUrl) const apiPlayable = playInfo.playable === true && Boolean(playInfo.playUrl)
const canUseStaticAudioFallback = !playInfo.reason || playInfo.reason === 'TARGET_NOT_FOUND' const nextAudioUrl = apiPlayable ? playInfo.playUrl || undefined : undefined
const fallbackAudioUrl = options.preserveStaticFallback && canUseStaticAudioFallback && exhibit.audioUrl
? exhibit.audioUrl
: undefined
const nextAudioUrl = apiPlayable ? playInfo.playUrl || undefined : fallbackAudioUrl
const nextAudioDuration = typeof playInfo.duration === 'number' const nextAudioDuration = typeof playInfo.duration === 'number'
? playInfo.duration ? playInfo.duration
: exhibit.audioDuration : exhibit.audioDuration
@@ -234,7 +221,8 @@ export class ExplainUseCase {
audioHasText: playInfo.hasText === true || exhibit.audioHasText, audioHasText: playInfo.hasText === true || exhibit.audioHasText,
audioNarrationTier: playInfo.narrationTier || exhibit.audioNarrationTier, audioNarrationTier: playInfo.narrationTier || exhibit.audioNarrationTier,
audioUnavailableReason: apiPlayable ? undefined : audioReasonToText(playInfo.reason), 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, exhibit: MuseumExhibit,
options: { options: {
includeText?: boolean includeText?: boolean
preserveStaticFallback?: boolean
track?: ExplainTrack | null track?: ExplainTrack | null
} = {} } = {}
): Promise<MuseumExhibit> { ): Promise<MuseumExhibit> {
@@ -268,9 +255,7 @@ export class ExplainUseCase {
try { try {
const playInfo = await this.audioPlayInfo.getPlayInfo({ targetType, targetId }) const playInfo = await this.audioPlayInfo.getPlayInfo({ targetType, targetId })
let nextExhibit = this.applyPlayInfo(baseExhibit, playInfo, { let nextExhibit = this.applyPlayInfo(baseExhibit, playInfo)
preserveStaticFallback: options.preserveStaticFallback
})
if (options.includeText && playInfo.hasText) { if (options.includeText && playInfo.hasText) {
try { try {
@@ -346,7 +331,6 @@ export class ExplainUseCase {
return this.enrichExhibitAudio(exhibit, { return this.enrichExhibitAudio(exhibit, {
includeText: options.includeText, includeText: options.includeText,
preserveStaticFallback: true,
track track
}) })
} catch (error) { } catch (error) {
@@ -433,7 +417,18 @@ export class ExplainUseCase {
async loadExplainHallSummaries(hallIds: string[]): Promise<Record<string, ExplainHallSummary>> { async loadExplainHallSummaries(hallIds: string[]): Promise<Record<string, ExplainHallSummary>> {
const uniqueHallIds = Array.from(new Set(hallIds.map((id) => id.trim()).filter(Boolean))) 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 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 { try {
const units = await this.loadTemporaryBusinessUnitsByHall(hallId) const units = await this.loadTemporaryBusinessUnitsByHall(hallId)
const guideStopCount = units.reduce((total, unit) => total + unit.guideStopCount, 0) const guideStopCount = units.reduce((total, unit) => total + unit.guideStopCount, 0)
@@ -508,15 +503,6 @@ export class ExplainUseCase {
const exhibit = detailExhibit || summaryExhibit const exhibit = detailExhibit || summaryExhibit
if (!exhibit) return null 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 const targetType: AudioPlayTargetType = track?.playTargetType
|| exhibit.playTargetType || exhibit.playTargetType
|| 'ITEM' || 'ITEM'
@@ -533,7 +519,7 @@ export class ExplainUseCase {
id: `play-${playInfo.audioId || `${requestTargetType}-${requestTargetId}`}`, id: `play-${playInfo.audioId || `${requestTargetType}-${requestTargetId}`}`,
type: 'audio', type: 'audio',
url: playInfo.playUrl || undefined, url: playInfo.playUrl || undefined,
duration: typeof playInfo.duration === 'number' ? playInfo.duration : fallbackMedia?.duration, duration: typeof playInfo.duration === 'number' ? playInfo.duration : undefined,
language: playInfo.lang, language: playInfo.lang,
available: true 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 { try {
const initialPlayInfo = await this.audioPlayInfo.getPlayInfo({ targetType, targetId }) const initialPlayInfo = await this.audioPlayInfo.getPlayInfo({ targetType, targetId })
@@ -597,20 +540,6 @@ export class ExplainUseCase {
return toPlayableSelection(initialPlayInfo, targetType, targetId) 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 { return {
exhibit, exhibit,
track, track,
@@ -626,11 +555,10 @@ export class ExplainUseCase {
targetType, targetType,
targetId, targetId,
lang: dataSourceConfig.audioLanguage as AudioPlayInfo['lang'], lang: dataSourceConfig.audioLanguage as AudioPlayInfo['lang'],
hasText: false,
fallback: false,
reason: 'SERVICE_ERROR' reason: 'SERVICE_ERROR'
} }
const directMediaSelection = toDirectMediaSelection(serviceErrorPlayInfo)
if (directMediaSelection) return directMediaSelection
return { return {
exhibit, exhibit,
track, 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 targetType = exhibit.playTargetType || 'ITEM'
const targetId = exhibit.playTargetId || exhibit.id const targetId = exhibit.playTargetId || exhibit.id
@@ -660,7 +591,8 @@ export class ExplainUseCase {
const playInfo = await this.audioPlayInfo.getPlayInfo({ const playInfo = await this.audioPlayInfo.getPlayInfo({
targetType, targetType,
targetId, 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) { if (!playInfo.playable || !playInfo.playUrl) {

View File

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