This commit is contained in:
@@ -3,10 +3,11 @@
|
||||
|
||||
VITE_APP_PUBLIC_BASE=/
|
||||
VITE_DATA_SOURCE_MODE=sdk
|
||||
VITE_GUIDE_CONTENT_SOURCE_MODE=static
|
||||
VITE_GUIDE_CONTENT_SOURCE_MODE=remote
|
||||
VITE_EXPLAIN_ALLOW_STATIC_FALLBACK=false
|
||||
VITE_GUIDE_STATIC_DATA_BASE_URL=/static/guide-data
|
||||
VITE_API_BASE_URL=/app-api
|
||||
VITE_AUDIO_API_BASE_URL=/nhm/audio
|
||||
VITE_AUDIO_API_BASE_URL=/app-api
|
||||
VITE_AUDIO_LANGUAGE=zh-CN
|
||||
VITE_SGS_API_BASE_URL=/app-api
|
||||
VITE_SGS_MAP_ID=1
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
|
||||
VITE_APP_PUBLIC_BASE=/
|
||||
VITE_DATA_SOURCE_MODE=sdk
|
||||
VITE_GUIDE_CONTENT_SOURCE_MODE=static
|
||||
VITE_GUIDE_CONTENT_SOURCE_MODE=remote
|
||||
VITE_GUIDE_STATIC_DATA_BASE_URL=/static/guide-data
|
||||
VITE_API_BASE_URL=/app-api
|
||||
VITE_AUDIO_API_BASE_URL=/yudao-server
|
||||
VITE_AUDIO_API_BASE_URL=/app-api
|
||||
VITE_AUDIO_LANGUAGE=zh-CN
|
||||
VITE_SGS_API_BASE_URL=/app-api
|
||||
VITE_SGS_MAP_ID=1
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
|
||||
VITE_APP_PUBLIC_BASE=/
|
||||
VITE_DATA_SOURCE_MODE=sdk
|
||||
VITE_GUIDE_CONTENT_SOURCE_MODE=static
|
||||
VITE_GUIDE_CONTENT_SOURCE_MODE=remote
|
||||
VITE_GUIDE_STATIC_DATA_BASE_URL=/static/guide-data
|
||||
VITE_API_BASE_URL=/app-api
|
||||
VITE_AUDIO_API_BASE_URL=/yudao-server
|
||||
VITE_AUDIO_API_BASE_URL=/app-api
|
||||
VITE_AUDIO_LANGUAGE=zh-CN
|
||||
VITE_SGS_API_BASE_URL=/app-api
|
||||
VITE_SGS_MAP_ID=1
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
|
||||
VITE_APP_PUBLIC_BASE=/
|
||||
VITE_DATA_SOURCE_MODE=sdk
|
||||
VITE_GUIDE_CONTENT_SOURCE_MODE=static
|
||||
VITE_GUIDE_CONTENT_SOURCE_MODE=remote
|
||||
VITE_GUIDE_STATIC_DATA_BASE_URL=/static/guide-data
|
||||
VITE_API_BASE_URL=/app-api
|
||||
VITE_AUDIO_API_BASE_URL=/yudao-server
|
||||
VITE_AUDIO_API_BASE_URL=/app-api
|
||||
VITE_AUDIO_LANGUAGE=zh-CN
|
||||
VITE_SGS_API_BASE_URL=/app-api
|
||||
VITE_SGS_MAP_ID=1
|
||||
|
||||
@@ -101,11 +101,16 @@
|
||||
</view>
|
||||
|
||||
<view class="hall-main">
|
||||
<text class="hall-name">{{ stop.name }}</text>
|
||||
<view class="stop-title-row">
|
||||
<text class="hall-name">{{ stop.name }}</text>
|
||||
<view v-if="stop.guideLevel" class="guide-level-badge">
|
||||
<text class="guide-level-text">{{ stop.guideLevel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="stop.description" class="stop-desc">{{ stop.description }}</text>
|
||||
<view class="hall-meta-row">
|
||||
<view class="floor-badge">
|
||||
<text class="floor-badge-text">{{ stop.hasAudio ? '可讲解' : '图文' }}</text>
|
||||
<text class="floor-badge-text">{{ stop.audioStatus === 'READY' ? '可讲解' : '图文' }}</text>
|
||||
</view>
|
||||
<text class="hall-meta-text">{{ stop.floorId || selectedHallName || '楼层待补充' }}</text>
|
||||
</view>
|
||||
@@ -158,6 +163,8 @@ export interface ExplainGuideStopSelectItem {
|
||||
coverImageUrl?: string
|
||||
description?: string
|
||||
hasAudio?: boolean
|
||||
audioStatus?: string
|
||||
guideLevel?: string
|
||||
}
|
||||
|
||||
export type ExplainSelectStage = 'hall' | 'unit' | 'stop'
|
||||
@@ -422,6 +429,7 @@ const handleBack = () => {
|
||||
}
|
||||
|
||||
.hall-name {
|
||||
min-width: 0;
|
||||
font-size: 17px;
|
||||
line-height: 24px;
|
||||
font-weight: 800;
|
||||
@@ -431,6 +439,38 @@ const handleBack = () => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stop-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.guide-level-badge {
|
||||
flex-shrink: 0;
|
||||
max-width: 92px;
|
||||
height: 22px;
|
||||
padding: 0 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: #151713;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.guide-level-text {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 700;
|
||||
color: #e0df00;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hall-meta-row {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
|
||||
@@ -62,20 +62,22 @@
|
||||
<view class="home-collapse-bar"></view>
|
||||
</view>
|
||||
|
||||
<view class="category-grid">
|
||||
<view
|
||||
v-for="item in primaryFacilities"
|
||||
:key="item.id"
|
||||
class="category-item"
|
||||
:class="{ active: searchKeyword === item.label }"
|
||||
@tap="handleFacilityShortcut(item)"
|
||||
>
|
||||
<view class="line-icon" :class="`icon-${item.icon}`">
|
||||
<view class="icon-part a"></view>
|
||||
<view class="icon-part b"></view>
|
||||
<view class="icon-part c"></view>
|
||||
<view class="category-scroll">
|
||||
<view class="category-grid">
|
||||
<view
|
||||
v-for="item in primaryFacilityColumns"
|
||||
:key="item.id"
|
||||
class="category-item"
|
||||
:class="{ active: searchKeyword === item.label }"
|
||||
@tap="handleFacilityShortcut(item)"
|
||||
>
|
||||
<view class="line-icon" :class="`icon-${item.icon}`">
|
||||
<view class="icon-part a"></view>
|
||||
<view class="icon-part b"></view>
|
||||
<view class="icon-part c"></view>
|
||||
</view>
|
||||
<text class="category-label">{{ item.label }}</text>
|
||||
</view>
|
||||
<text class="category-label">{{ item.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -182,14 +184,36 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const primaryFacilities: ShortcutItem[] = [
|
||||
{ id: 'service', label: '服务台', icon: 'service', keywords: ['服务台', 'service_desk'] },
|
||||
{ id: 'exhibition', label: '展览', icon: 'exhibition', keywords: ['展览', '展厅', '展馆', 'exhibition_hall', 'temp_exhibition'] },
|
||||
{ id: 'cinema', label: '影院', icon: 'cinema', keywords: ['影院', '剧场', '影厅', 'cinema', 'theater'] },
|
||||
{ id: 'restroom', label: '洗手间', icon: 'restroom', keywords: ['洗手间', '卫生间', 'toilet', 'accessible_toilet'] },
|
||||
{ id: 'restaurant', label: '餐厅', icon: 'restaurant', keywords: ['餐厅', '餐饮', '咖啡', 'restaurant', 'commercial'] },
|
||||
{ id: 'ticket-office', label: '售票处', icon: 'ticket', keywords: ['售票处', '售票', '票务', 'ticket_office'] },
|
||||
{ id: 'nursing', label: '母婴室', icon: 'nursing', keywords: ['母婴室', 'mother_baby_room'] },
|
||||
{ id: 'service-center', label: '服务中心', icon: 'service', keywords: ['服务中心', '服务台', '咨询台', 'service_desk', 'service_space'] },
|
||||
{ id: 'elevator', label: '电梯', icon: 'elevator', keywords: ['电梯', 'elevator'] },
|
||||
{ id: 'escalator', label: '扶梯', icon: 'escalator', keywords: ['扶梯', 'escalator'] },
|
||||
{ id: 'restroom', label: '洗手间', icon: 'restroom', keywords: ['洗手间', '卫生间', 'toilet', 'accessible_toilet'] },
|
||||
{ id: 'nursing', label: '母婴室', icon: 'nursing', keywords: ['母婴室', 'mother_baby_room'] },
|
||||
{ id: 'entrance', label: '出入口', icon: 'entrance', keywords: ['出入口', '入口', '出口', 'entrance_exit'] }
|
||||
{ id: 'stairs', label: '楼梯', icon: 'stairs', keywords: ['楼梯', 'stairs', 'stair'] }
|
||||
]
|
||||
|
||||
const primaryFacilityVisibleColumnCount = 4
|
||||
const primaryFacilityRowCount = 2
|
||||
|
||||
const primaryFacilityColumns = computed(() => {
|
||||
const firstScreenItems = primaryFacilities.slice(0, primaryFacilityVisibleColumnCount * primaryFacilityRowCount)
|
||||
const overflowItems = primaryFacilities.slice(primaryFacilityVisibleColumnCount * primaryFacilityRowCount)
|
||||
const orderedItems: ShortcutItem[] = []
|
||||
|
||||
for (let columnIndex = 0; columnIndex < primaryFacilityVisibleColumnCount; columnIndex += 1) {
|
||||
const topItem = firstScreenItems[columnIndex]
|
||||
const bottomItem = firstScreenItems[columnIndex + primaryFacilityVisibleColumnCount]
|
||||
if (topItem) orderedItems.push(topItem)
|
||||
if (bottomItem) orderedItems.push(bottomItem)
|
||||
}
|
||||
|
||||
return orderedItems.concat(overflowItems)
|
||||
})
|
||||
|
||||
const serviceFacilities: ShortcutItem[] = [
|
||||
{ id: 'drink', label: '饮料售卖机', icon: 'drink', keywords: ['饮料售卖机', '饮料', '售卖机', 'vending'] },
|
||||
{ id: 'ticket', label: '票务售卖机', icon: 'ticket', keywords: ['票务售卖机', '票务', '售票', 'ticket_office'] },
|
||||
@@ -678,9 +702,11 @@ defineExpose({
|
||||
|
||||
.poi-search-panel {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
color: #262421;
|
||||
font-family: '鸿蒙黑体', 'HarmonyOS Sans SC', 'HarmonyOS Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.variant-home.expanded {
|
||||
@@ -758,6 +784,8 @@ defineExpose({
|
||||
}
|
||||
|
||||
.variant-home .search-box {
|
||||
background: #f5f5ed;
|
||||
border-color: rgba(224, 225, 0, 0.72);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -768,6 +796,8 @@ defineExpose({
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
overscroll-behavior-x: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.home-category-strip::-webkit-scrollbar {
|
||||
@@ -775,18 +805,50 @@ defineExpose({
|
||||
}
|
||||
|
||||
.home-category-chip {
|
||||
min-width: 70px;
|
||||
height: 42px;
|
||||
flex: 0 0 auto;
|
||||
min-width: 112px;
|
||||
height: 58px;
|
||||
flex: 0 0 112px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 11px;
|
||||
box-sizing: border-box;
|
||||
background: #f4f5ef;
|
||||
border: 1px solid #dde5df;
|
||||
background: #e0df00;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 8px;
|
||||
background-image:
|
||||
linear-gradient(116deg, rgba(255, 255, 255, 0.22) 0 32%, rgba(255, 255, 255, 0) 33%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(0, 0, 0, 0.02));
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.home-category-chip:nth-child(2) {
|
||||
background-color: #18c2b6;
|
||||
}
|
||||
|
||||
.home-category-chip:nth-child(3) {
|
||||
background-color: #b99cf0;
|
||||
}
|
||||
|
||||
.home-category-chip:nth-child(4) {
|
||||
background-color: #8ad9ae;
|
||||
}
|
||||
|
||||
.home-category-chip:nth-child(5) {
|
||||
background-color: #ffc0a1;
|
||||
}
|
||||
|
||||
.home-category-chip:nth-child(6) {
|
||||
background-color: #5ed0e4;
|
||||
}
|
||||
|
||||
.home-category-chip:nth-child(7) {
|
||||
background-color: #c9a463;
|
||||
}
|
||||
|
||||
.home-category-chip:nth-child(8) {
|
||||
background-color: #f4f5ef;
|
||||
}
|
||||
|
||||
.home-category-chip:active {
|
||||
@@ -794,14 +856,16 @@ defineExpose({
|
||||
}
|
||||
|
||||
.home-category-icon {
|
||||
width: 20px;
|
||||
height: 19px;
|
||||
flex: 0 0 20px;
|
||||
width: 24px;
|
||||
height: 23px;
|
||||
flex: 0 0 24px;
|
||||
order: 2;
|
||||
color: #050704;
|
||||
}
|
||||
|
||||
.home-category-icon.line-icon {
|
||||
width: 20px;
|
||||
height: 19px;
|
||||
width: 24px;
|
||||
height: 23px;
|
||||
}
|
||||
|
||||
.home-category-icon .icon-part {
|
||||
@@ -810,11 +874,12 @@ defineExpose({
|
||||
}
|
||||
|
||||
.home-category-label {
|
||||
max-width: 58px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 700;
|
||||
color: #151713;
|
||||
min-width: 0;
|
||||
max-width: 70px;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
font-weight: 800;
|
||||
color: #050704;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -919,6 +984,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.variant-page .poi-search-content {
|
||||
margin-top: 14px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
@@ -959,30 +1025,63 @@ defineExpose({
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.category-scroll {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 4px;
|
||||
scrollbar-width: none;
|
||||
overscroll-behavior-x: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-x;
|
||||
}
|
||||
|
||||
.category-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.category-grid {
|
||||
--category-gap: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
grid-auto-flow: column;
|
||||
grid-template-rows: repeat(2, minmax(70px, auto));
|
||||
grid-auto-columns: calc((100% - 32px) / 5);
|
||||
gap: var(--category-gap);
|
||||
width: calc(125% + 2px);
|
||||
min-width: calc(125% + 2px);
|
||||
}
|
||||
|
||||
.category-item {
|
||||
min-width: 0;
|
||||
min-height: 70px;
|
||||
padding: 9px 6px;
|
||||
position: relative;
|
||||
padding: 9px 6px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid #e2e4da;
|
||||
overflow: hidden;
|
||||
background: #fffef2;
|
||||
border: 1px solid #ecebd2;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.category-item.active {
|
||||
background: #151713;
|
||||
border-color: #151713;
|
||||
background: #fffddc;
|
||||
border-color: #d8d900;
|
||||
}
|
||||
|
||||
.category-item.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
bottom: 0;
|
||||
height: 4px;
|
||||
background: var(--museum-accent);
|
||||
}
|
||||
|
||||
.line-icon {
|
||||
@@ -993,7 +1092,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.category-item.active .line-icon {
|
||||
color: var(--museum-accent);
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.icon-part {
|
||||
@@ -1002,6 +1101,8 @@ defineExpose({
|
||||
}
|
||||
|
||||
.icon-service .icon-part.a,
|
||||
.icon-exhibition .icon-part.a,
|
||||
.icon-cinema .icon-part.a,
|
||||
.icon-ticket .icon-part.a,
|
||||
.icon-drink .icon-part.a,
|
||||
.icon-bag .icon-part.a {
|
||||
@@ -1014,6 +1115,8 @@ defineExpose({
|
||||
}
|
||||
|
||||
.icon-service .icon-part.b,
|
||||
.icon-exhibition .icon-part.b,
|
||||
.icon-cinema .icon-part.b,
|
||||
.icon-ticket .icon-part.b,
|
||||
.icon-drink .icon-part.b,
|
||||
.icon-bag .icon-part.b {
|
||||
@@ -1030,6 +1133,8 @@ defineExpose({
|
||||
.icon-escalator .icon-part.a,
|
||||
.icon-restroom .icon-part.a,
|
||||
.icon-nursing .icon-part.a,
|
||||
.icon-restaurant .icon-part.a,
|
||||
.icon-stairs .icon-part.a,
|
||||
.icon-entrance .icon-part.a {
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
@@ -1069,6 +1174,63 @@ defineExpose({
|
||||
box-shadow: 8px 0 0 currentColor;
|
||||
}
|
||||
|
||||
.icon-exhibition .icon-part.c {
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
bottom: 2px;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.icon-cinema .icon-part.b {
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
top: 4px;
|
||||
height: 4px;
|
||||
border: 0;
|
||||
border-top: 2px solid currentColor;
|
||||
border-bottom: 2px solid currentColor;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.icon-restaurant .icon-part.b {
|
||||
left: 7px;
|
||||
top: 6px;
|
||||
width: 2px;
|
||||
height: 12px;
|
||||
background: currentColor;
|
||||
box-shadow: 5px 0 0 currentColor, 10px 0 0 currentColor;
|
||||
}
|
||||
|
||||
.icon-restaurant .icon-part.c {
|
||||
right: 3px;
|
||||
top: 5px;
|
||||
width: 7px;
|
||||
height: 15px;
|
||||
border-left: 2px solid currentColor;
|
||||
border-bottom: 2px solid currentColor;
|
||||
border-radius: 0 0 0 5px;
|
||||
}
|
||||
|
||||
.icon-stairs .icon-part.b {
|
||||
left: 6px;
|
||||
top: 7px;
|
||||
width: 14px;
|
||||
height: 12px;
|
||||
border-left: 2px solid currentColor;
|
||||
border-bottom: 2px solid currentColor;
|
||||
}
|
||||
|
||||
.icon-stairs .icon-part.c {
|
||||
left: 10px;
|
||||
top: 11px;
|
||||
width: 10px;
|
||||
height: 8px;
|
||||
border-left: 2px solid currentColor;
|
||||
border-bottom: 2px solid currentColor;
|
||||
}
|
||||
|
||||
.category-label {
|
||||
max-width: 100%;
|
||||
font-size: 12px;
|
||||
@@ -1082,12 +1244,24 @@ defineExpose({
|
||||
}
|
||||
|
||||
.category-item.active .category-label {
|
||||
color: var(--museum-accent);
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.section-title-row {
|
||||
margin-top: 14px;
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 10px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.section-title-row::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 3px;
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background: var(--museum-accent);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@@ -1113,19 +1287,30 @@ defineExpose({
|
||||
.facility-chip {
|
||||
height: 34px;
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 12px;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid #e2e4da;
|
||||
background: #fffef6;
|
||||
border: 1px solid #e8e7d2;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.facility-chip.active {
|
||||
background: #151713;
|
||||
border-color: #151713;
|
||||
background: #fffddc;
|
||||
border-color: #d6d700;
|
||||
}
|
||||
|
||||
.facility-chip.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: -1px;
|
||||
height: 3px;
|
||||
background: var(--museum-accent);
|
||||
}
|
||||
|
||||
.chip-icon {
|
||||
@@ -1140,7 +1325,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.facility-chip.active .chip-icon {
|
||||
color: var(--museum-accent);
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.facility-chip-text {
|
||||
@@ -1152,26 +1337,27 @@ defineExpose({
|
||||
}
|
||||
|
||||
.facility-chip.active .facility-chip-text {
|
||||
color: var(--museum-accent);
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
margin-top: 12px;
|
||||
margin-top: 14px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border: 1px solid #e2e4da;
|
||||
background: #fffef7;
|
||||
border: 1px solid #ecebd8;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 24px rgba(75, 78, 60, 0.08);
|
||||
}
|
||||
|
||||
.result-card-header {
|
||||
height: 42px;
|
||||
padding: 0 12px;
|
||||
height: 46px;
|
||||
padding: 0 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid #eceee6;
|
||||
border-bottom: 1px solid #efeedf;
|
||||
}
|
||||
|
||||
.museum-title,
|
||||
@@ -1196,14 +1382,15 @@ defineExpose({
|
||||
}
|
||||
|
||||
.floor-tabs {
|
||||
width: 58px;
|
||||
flex: 0 0 58px;
|
||||
padding: 8px 0;
|
||||
width: 62px;
|
||||
flex: 0 0 62px;
|
||||
padding: 9px 0;
|
||||
box-sizing: border-box;
|
||||
background: #f3f4ee;
|
||||
background: #f6f5e8;
|
||||
}
|
||||
|
||||
.floor-tab {
|
||||
position: relative;
|
||||
height: 31px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1216,6 +1403,16 @@ defineExpose({
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.floor-tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 2px;
|
||||
height: 3px;
|
||||
background: var(--museum-accent);
|
||||
}
|
||||
|
||||
.floor-tab-text {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
@@ -1227,6 +1424,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.variant-page .category-grid,
|
||||
.variant-page .category-scroll,
|
||||
.variant-page .section-title-row,
|
||||
.variant-page .facility-chip-row {
|
||||
flex: 0 0 auto;
|
||||
@@ -1257,6 +1455,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.variant-home.expanded .category-grid,
|
||||
.variant-home.expanded .category-scroll,
|
||||
.variant-home.expanded .section-title-row,
|
||||
.variant-home.expanded .facility-chip-row {
|
||||
flex: 0 0 auto;
|
||||
@@ -1282,13 +1481,13 @@ defineExpose({
|
||||
}
|
||||
|
||||
.result-row {
|
||||
min-height: 54px;
|
||||
min-height: 58px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 9px 10px;
|
||||
padding: 10px 12px;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid #edf0e8;
|
||||
border-bottom: 1px solid #efefe1;
|
||||
}
|
||||
|
||||
.result-row:last-child {
|
||||
@@ -1377,12 +1576,7 @@ defineExpose({
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.category-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.variant-page .category-grid,
|
||||
.variant-home.expanded .category-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, minmax(62px, auto));
|
||||
}
|
||||
|
||||
.variant-page .category-item,
|
||||
|
||||
@@ -9,7 +9,7 @@ const defaultSdkTimeoutMs = 5000
|
||||
const defaultApiBaseUrl = '/app-api'
|
||||
const defaultSgsMapId = '1'
|
||||
const defaultGuideStaticDataBaseUrl = '/static/guide-data'
|
||||
const defaultAudioApiBaseUrl = '/yudao-server'
|
||||
const defaultAudioApiBaseUrl = '/app-api'
|
||||
|
||||
const normalizeMode = (mode: string | undefined): DataSourceMode => {
|
||||
if (mode && allowedModes.has(mode as DataSourceMode)) {
|
||||
@@ -24,9 +24,13 @@ const normalizeGuideContentMode = (mode: string | undefined): GuideContentDataSo
|
||||
return mode as GuideContentDataSourceMode
|
||||
}
|
||||
|
||||
return 'static'
|
||||
return 'remote'
|
||||
}
|
||||
|
||||
const normalizeBoolean = (value: string | undefined) => (
|
||||
['1', 'true', 'yes', 'on'].includes(value?.trim().toLowerCase() || '')
|
||||
)
|
||||
|
||||
const normalizeUrl = (url: string | undefined, fallback: string) => {
|
||||
const normalized = url?.trim()
|
||||
return normalized || fallback
|
||||
@@ -50,6 +54,8 @@ const inferOrigin = (url: string) => {
|
||||
export const dataSourceConfig = {
|
||||
mode: normalizeMode(import.meta.env.VITE_DATA_SOURCE_MODE),
|
||||
guideContentMode: normalizeGuideContentMode(import.meta.env.VITE_GUIDE_CONTENT_SOURCE_MODE),
|
||||
allowExplainStaticFallback: import.meta.env.DEV
|
||||
&& normalizeBoolean(import.meta.env.VITE_EXPLAIN_ALLOW_STATIC_FALLBACK),
|
||||
guideStaticDataBaseUrl: normalizeUrl(
|
||||
import.meta.env.VITE_GUIDE_STATIC_DATA_BASE_URL,
|
||||
defaultGuideStaticDataBaseUrl
|
||||
|
||||
@@ -61,12 +61,22 @@ export interface BackendExhibit {
|
||||
export interface BackendHall {
|
||||
id?: string | number | null
|
||||
name?: string | null
|
||||
poiId?: string | number | null
|
||||
hallCode?: string | null
|
||||
nameEn?: string | null
|
||||
subtitle?: string | null
|
||||
description?: string | null
|
||||
coverImageUrl?: string | null
|
||||
floorId?: string | number | null
|
||||
floorCode?: string | null
|
||||
exhibitCount?: number | null
|
||||
outlineCount?: number | null
|
||||
stopCount?: number | null
|
||||
linkedExhibitCount?: number | null
|
||||
audioReadyStopCount?: number | null
|
||||
hasAudio?: boolean | null
|
||||
audioStatus?: string | null
|
||||
supportedLanguages?: string[] | null
|
||||
}
|
||||
|
||||
export interface BackendGuideStop {
|
||||
@@ -87,6 +97,61 @@ export interface BackendGuideStop {
|
||||
sort?: number | null
|
||||
}
|
||||
|
||||
export interface BackendCatalogHallItem extends BackendHall {
|
||||
mapId?: string | number | null
|
||||
areaSqm?: string | number | null
|
||||
sortOrder?: number | null
|
||||
}
|
||||
|
||||
export interface BackendCatalogOutlineItem {
|
||||
id?: string | number | null
|
||||
parentId?: string | number | null
|
||||
hallId?: string | number | null
|
||||
name?: string | null
|
||||
code?: string | null
|
||||
description?: string | null
|
||||
sort?: number | null
|
||||
level?: number | null
|
||||
children?: BackendCatalogOutlineItem[] | null
|
||||
stopCount?: number | null
|
||||
linkedExhibitCount?: number | null
|
||||
audioReadyStopCount?: number | null
|
||||
hasAudio?: boolean | null
|
||||
audioStatus?: string | null
|
||||
supportedLanguages?: string[] | null
|
||||
}
|
||||
|
||||
export interface BackendCatalogLinkedExhibitItem {
|
||||
id?: string | number | null
|
||||
name?: string | null
|
||||
nameEn?: string | null
|
||||
code?: string | null
|
||||
exhibitCode?: string | null
|
||||
coverImageUrl?: string | null
|
||||
sortOrder?: number | null
|
||||
}
|
||||
|
||||
export interface BackendCatalogStopItem {
|
||||
stopId?: string | number | null
|
||||
id?: string | number | null
|
||||
outlineId?: string | number | null
|
||||
name?: string | null
|
||||
guideLevel?: string | null
|
||||
description?: string | null
|
||||
sort?: number | null
|
||||
coverImageUrl?: string | null
|
||||
imageStatus?: string | null
|
||||
linkedExhibitCount?: number | null
|
||||
isSharedStop?: boolean | null
|
||||
linkedExhibits?: BackendCatalogLinkedExhibitItem[] | null
|
||||
hasAudio?: boolean | null
|
||||
audioStatus?: string | null
|
||||
supportedLanguages?: string[] | null
|
||||
hasTextRecord?: boolean | null
|
||||
playTargetType?: string | null
|
||||
playTargetId?: string | number | null
|
||||
}
|
||||
|
||||
export interface BackendExplainAdapterResult {
|
||||
exhibit: MuseumExhibit
|
||||
track: ExplainTrack
|
||||
@@ -130,6 +195,15 @@ const normalizeAudioTargetType = (targetType: string | null | undefined): AudioP
|
||||
return normalized === 'ITEM' || normalized === 'STOP' ? normalized : undefined
|
||||
}
|
||||
|
||||
const normalizeNumber = (value: number | string | null | undefined) => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const numeric = Number(value)
|
||||
return Number.isFinite(numeric) ? numeric : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const isAudioReady = (value?: string | null) => (
|
||||
!value || ['READY', 'PUBLISHED', 'AVAILABLE'].includes(value.toUpperCase())
|
||||
)
|
||||
@@ -170,6 +244,230 @@ const buildTags = (exhibit: BackendExhibit) => Array.from(new Set([
|
||||
firstText(exhibit.origin)
|
||||
].filter(Boolean)))
|
||||
|
||||
const normalizeSupportedLanguages = (languages: string[] | null | undefined) => (
|
||||
Array.isArray(languages) ? languages.map(String).filter(Boolean) : []
|
||||
)
|
||||
|
||||
const normalizeCatalogAudioStatus = (status?: string | null) => (
|
||||
firstText(status) || 'MISSING'
|
||||
)
|
||||
|
||||
export const toCatalogHall = (
|
||||
source: BackendCatalogHallItem,
|
||||
fallback?: MuseumHall | null
|
||||
): MuseumHall => {
|
||||
const id = stringifyId(source.id) || fallback?.id || firstText(source.hallCode, source.name)
|
||||
const name = firstText(source.name, fallback?.name, source.hallCode, '展厅')
|
||||
const area = firstText(source.areaSqm)
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
floorId: stringifyId(source.floorId) || fallback?.floorId,
|
||||
floorLabel: firstText(source.floorCode, fallback?.floorLabel) || undefined,
|
||||
description: firstText(source.description, source.subtitle, fallback?.description, '该展厅暂无介绍。'),
|
||||
image: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || fallback?.image || HALL_PLACEHOLDER_IMAGE,
|
||||
exhibitCount: normalizeNumber(source.exhibitCount) ?? fallback?.exhibitCount ?? 0,
|
||||
outlineCount: normalizeNumber(source.outlineCount),
|
||||
stopCount: normalizeNumber(source.stopCount),
|
||||
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
|
||||
audioReadyStopCount: normalizeNumber(source.audioReadyStopCount),
|
||||
hasAudio: source.hasAudio === true,
|
||||
audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
|
||||
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
|
||||
area: area ? `${area}㎡` : fallback?.area,
|
||||
poiId: stringifyId(source.poiId) || fallback?.poiId,
|
||||
location: fallback?.location
|
||||
}
|
||||
}
|
||||
|
||||
export const toCatalogLinkedExhibit = (
|
||||
source: BackendCatalogLinkedExhibitItem,
|
||||
hall?: MuseumHall | null
|
||||
): MuseumExhibit | null => {
|
||||
const id = stringifyId(source.id)
|
||||
if (!id) return null
|
||||
|
||||
return {
|
||||
id,
|
||||
name: firstText(source.name, source.exhibitCode, source.code, `展品${id}`),
|
||||
hallId: hall?.id,
|
||||
hallName: hall?.name,
|
||||
floorId: hall?.floorId,
|
||||
floorLabel: hall?.floorLabel,
|
||||
image: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined,
|
||||
size: firstText(source.exhibitCode, source.code) || undefined
|
||||
}
|
||||
}
|
||||
|
||||
const toCatalogLinkedExhibitSummary = (source: BackendCatalogLinkedExhibitItem) => {
|
||||
const id = stringifyId(source.id)
|
||||
if (!id) return null
|
||||
|
||||
return {
|
||||
id,
|
||||
name: firstText(source.name, source.exhibitCode, source.code, `展品 ${id}`),
|
||||
nameEn: firstText(source.nameEn) || undefined,
|
||||
exhibitCode: firstText(source.exhibitCode, source.code) || undefined,
|
||||
coverImageUrl: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const toCatalogGuideStop = (
|
||||
source: BackendCatalogStopItem,
|
||||
hall?: MuseumHall | null,
|
||||
outline?: Pick<ExplainBusinessUnit, 'id' | 'name'> | null
|
||||
): ExplainGuideStop | null => {
|
||||
const id = stringifyId(source.stopId) || stringifyId(source.id)
|
||||
if (!id) return null
|
||||
|
||||
const playTargetType = normalizeAudioTargetType(source.playTargetType) || 'STOP'
|
||||
const playTargetId = stringifyId(source.playTargetId) || id
|
||||
const imageStatus = firstText(source.imageStatus) || 'MISSING'
|
||||
const canUseStopImage = imageStatus === 'READY'
|
||||
|
||||
return {
|
||||
id,
|
||||
name: firstText(source.name, `讲解点${id}`),
|
||||
hallId: hall?.id,
|
||||
hallName: hall?.name,
|
||||
floorId: hall?.floorId,
|
||||
targetType: playTargetType,
|
||||
targetId: playTargetId,
|
||||
coverImageUrl: canUseStopImage
|
||||
? normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined
|
||||
: undefined,
|
||||
description: firstText(source.description) || undefined,
|
||||
hasAudio: source.hasAudio === true,
|
||||
audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
|
||||
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
|
||||
hasTextRecord: source.hasTextRecord === true,
|
||||
outlineId: stringifyId(source.outlineId) || outline?.id,
|
||||
outlineName: outline?.name,
|
||||
sort: typeof source.sort === 'number' ? source.sort : undefined,
|
||||
guideLevel: firstText(source.guideLevel) || undefined,
|
||||
imageStatus,
|
||||
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
|
||||
isSharedStop: source.isSharedStop === true,
|
||||
linkedExhibits: (source.linkedExhibits || [])
|
||||
.map(toCatalogLinkedExhibitSummary)
|
||||
.filter(Boolean) as NonNullable<ExplainGuideStop['linkedExhibits']>,
|
||||
playTargetType,
|
||||
playTargetId
|
||||
}
|
||||
}
|
||||
|
||||
export const toCatalogMuseumExhibitFromStop = (
|
||||
stop: ExplainGuideStop,
|
||||
hall?: MuseumHall | null
|
||||
): MuseumExhibit => {
|
||||
const linkedPrimary = stop.linkedExhibits?.[0]
|
||||
const audioReady = stop.audioStatus === 'READY'
|
||||
|
||||
return {
|
||||
id: stop.id,
|
||||
name: stop.name,
|
||||
hallId: stop.hallId || hall?.id,
|
||||
hallName: stop.hallName || hall?.name,
|
||||
floorId: stop.floorId || hall?.floorId,
|
||||
floorLabel: hall?.floorLabel,
|
||||
image: stop.coverImageUrl || undefined,
|
||||
description: stop.description || linkedPrimary?.name || '该讲解点暂无简介。',
|
||||
tags: [stop.outlineName, linkedPrimary?.exhibitCode].filter(Boolean) as string[],
|
||||
guideTitle: stop.name,
|
||||
guideText: stop.description,
|
||||
audioAvailable: audioReady,
|
||||
audioStatus: stop.audioStatus,
|
||||
supportedLanguages: stop.supportedLanguages,
|
||||
audioHasText: stop.hasTextRecord,
|
||||
imageStatus: stop.imageStatus,
|
||||
linkedExhibitCount: stop.linkedExhibitCount,
|
||||
isSharedStop: stop.isSharedStop,
|
||||
linkedExhibits: stop.linkedExhibits,
|
||||
stopInfoAvailable: true,
|
||||
resolvedStopId: stop.id,
|
||||
playTargetType: stop.playTargetType || stop.targetType || 'STOP',
|
||||
playTargetId: stop.playTargetId || stop.targetId || stop.id
|
||||
}
|
||||
}
|
||||
|
||||
const outlineKey = (outlineId?: string | null) => outlineId || '__unassigned__'
|
||||
|
||||
export const flattenCatalogOutlines = (
|
||||
outlines: BackendCatalogOutlineItem[]
|
||||
): BackendCatalogOutlineItem[] => {
|
||||
const flattened: BackendCatalogOutlineItem[] = []
|
||||
|
||||
const visit = (items: BackendCatalogOutlineItem[]) => {
|
||||
items
|
||||
.slice()
|
||||
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
|
||||
.forEach((item) => {
|
||||
flattened.push(item)
|
||||
if (Array.isArray(item.children) && item.children.length) {
|
||||
visit(item.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
visit(outlines)
|
||||
return flattened
|
||||
}
|
||||
|
||||
export const toCatalogBusinessUnits = (
|
||||
hallId: string,
|
||||
outlines: BackendCatalogOutlineItem[],
|
||||
stops: ExplainGuideStop[]
|
||||
): ExplainBusinessUnit[] => {
|
||||
const flatOutlines = flattenCatalogOutlines(outlines)
|
||||
const stopMap = new Map<string, ExplainGuideStop[]>()
|
||||
stops.forEach((stop) => {
|
||||
const key = outlineKey(stop.outlineId)
|
||||
stopMap.set(key, [...(stopMap.get(key) || []), stop])
|
||||
})
|
||||
|
||||
const outlineChildren = new Set(
|
||||
flatOutlines
|
||||
.map((item) => stringifyId(item.parentId))
|
||||
.filter((parentId) => parentId && parentId !== hallId)
|
||||
)
|
||||
|
||||
const units = flatOutlines
|
||||
.map<ExplainBusinessUnit | null>((outline) => {
|
||||
const id = stringifyId(outline.id)
|
||||
if (!id) return null
|
||||
|
||||
const directStops = (stopMap.get(id) || [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
|
||||
const hasChildOutlines = outlineChildren.has(id)
|
||||
const rawStopCount = normalizeNumber(outline.stopCount) ?? 0
|
||||
if (!directStops.length && hasChildOutlines) return null
|
||||
|
||||
return {
|
||||
id,
|
||||
name: firstText(outline.name, outline.code, `业务单元${id}`),
|
||||
hallId: stringifyId(outline.hallId) || hallId,
|
||||
parentId: stringifyId(outline.parentId) || undefined,
|
||||
code: firstText(outline.code) || undefined,
|
||||
description: firstText(outline.description) || undefined,
|
||||
sort: typeof outline.sort === 'number' ? outline.sort : undefined,
|
||||
level: typeof outline.level === 'number' ? outline.level : undefined,
|
||||
guideStopCount: directStops.length || rawStopCount,
|
||||
linkedExhibitCount: normalizeNumber(outline.linkedExhibitCount),
|
||||
audioReadyStopCount: normalizeNumber(outline.audioReadyStopCount),
|
||||
hasAudio: outline.hasAudio === true,
|
||||
audioStatus: normalizeCatalogAudioStatus(outline.audioStatus),
|
||||
supportedLanguages: normalizeSupportedLanguages(outline.supportedLanguages),
|
||||
stops: directStops
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as ExplainBusinessUnit[]
|
||||
|
||||
if (units.length) return units
|
||||
return groupGuideStopsByOutline(hallId, stops)
|
||||
}
|
||||
|
||||
export const toBackendMuseumExhibit = (
|
||||
source: BackendExhibit,
|
||||
hall?: MuseumHall | null,
|
||||
@@ -277,15 +575,22 @@ export const toBackendHallFromList = (source: BackendHall, fallback?: MuseumHall
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
floorId: fallback?.floorId,
|
||||
floorLabel: fallback?.floorLabel,
|
||||
floorId: stringifyId(source.floorId) || fallback?.floorId,
|
||||
floorLabel: firstText(source.floorCode, fallback?.floorLabel) || undefined,
|
||||
description: firstText(source.description, source.subtitle, fallback?.description, '该展厅暂无介绍。'),
|
||||
image: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || fallback?.image || HALL_PLACEHOLDER_IMAGE,
|
||||
exhibitCount: typeof source.exhibitCount === 'number'
|
||||
? source.exhibitCount
|
||||
: fallback?.exhibitCount || 0,
|
||||
outlineCount: normalizeNumber(source.outlineCount),
|
||||
stopCount: normalizeNumber(source.stopCount),
|
||||
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
|
||||
audioReadyStopCount: normalizeNumber(source.audioReadyStopCount),
|
||||
hasAudio: source.hasAudio === true,
|
||||
audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
|
||||
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
|
||||
area: fallback?.area,
|
||||
poiId: fallback?.poiId,
|
||||
poiId: stringifyId(source.poiId) || fallback?.poiId,
|
||||
location: fallback?.location
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface BackendGuideStopInfo {
|
||||
targetType?: string | null
|
||||
targetId?: string | number | null
|
||||
resolvedStopId?: string | number | null
|
||||
stopId?: string | number | null
|
||||
lang?: string | null
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
@@ -33,6 +34,40 @@ export interface BackendGuideStopInfo {
|
||||
supportedLanguages?: string[] | null
|
||||
audioStatus?: string | null
|
||||
reason?: string | null
|
||||
linkedExhibitCount?: number | string | null
|
||||
isSharedStop?: boolean | null
|
||||
}
|
||||
|
||||
export interface BackendAudioPlayInfo {
|
||||
playable?: boolean
|
||||
targetType?: string | null
|
||||
targetId?: string | number | null
|
||||
lang?: string | null
|
||||
narrationTier?: 'STANDARD' | 'EXTENDED' | string | null
|
||||
audioId?: string | number | null
|
||||
title?: string | null
|
||||
duration?: number | null
|
||||
format?: string | null
|
||||
playUrl?: string | null
|
||||
expiresAt?: string | null
|
||||
subtitleUrl?: string | null
|
||||
hasText?: boolean
|
||||
fallback?: boolean
|
||||
fallbackReason?: string | null
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
export interface BackendAudioTextInfo {
|
||||
available?: boolean
|
||||
targetType?: string | null
|
||||
targetId?: string | number | null
|
||||
lang?: string | null
|
||||
narrationTier?: 'STANDARD' | 'EXTENDED' | string | null
|
||||
title?: string | null
|
||||
text?: string | null
|
||||
textLength?: number | null
|
||||
textHash?: string | null
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
export interface GuideStopLinkedExhibit {
|
||||
@@ -63,12 +98,55 @@ export interface GuideStopInfo {
|
||||
supportedLanguages: string[]
|
||||
audioStatus: 'READY' | 'MISSING' | string
|
||||
reason?: string
|
||||
linkedExhibitCount?: number
|
||||
isSharedStop?: boolean
|
||||
}
|
||||
|
||||
export interface GuideAudioPlayInfo {
|
||||
playable: boolean
|
||||
targetType: AudioPlayTargetType
|
||||
targetId: string
|
||||
lang: 'zh-CN' | 'en-US'
|
||||
narrationTier?: 'STANDARD' | 'EXTENDED'
|
||||
audioId?: string
|
||||
title?: string
|
||||
duration?: number
|
||||
format?: string
|
||||
playUrl?: string
|
||||
expiresAt?: string
|
||||
subtitleUrl?: string
|
||||
hasText: boolean
|
||||
fallback: boolean
|
||||
fallbackReason?: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface GuideAudioTextInfo {
|
||||
available: boolean
|
||||
targetType: AudioPlayTargetType
|
||||
targetId: string
|
||||
lang: 'zh-CN' | 'en-US'
|
||||
narrationTier?: 'STANDARD' | 'EXTENDED'
|
||||
title?: string
|
||||
text?: string
|
||||
textLength?: number
|
||||
textHash?: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
const stringifyId = (value: string | number | null | undefined) => (
|
||||
value === null || typeof value === 'undefined' ? '' : String(value)
|
||||
)
|
||||
|
||||
const normalizeNumber = (value: number | string | null | undefined) => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const numeric = Number(value)
|
||||
return Number.isFinite(numeric) ? numeric : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const normalizeTargetType = (value: string | null | undefined, fallback: AudioPlayTargetType): AudioPlayTargetType => {
|
||||
const normalized = value?.toUpperCase()
|
||||
return normalized === 'ITEM' || normalized === 'STOP' ? normalized : fallback
|
||||
@@ -95,13 +173,15 @@ const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls']) => {
|
||||
.filter(Boolean)
|
||||
}
|
||||
} catch {
|
||||
// 兼容后端历史逗号拼接字段。
|
||||
return []
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.split(',')
|
||||
.map((entry) => normalizeSameOriginPublicUrl(entry.trim()))
|
||||
.filter(Boolean)
|
||||
return []
|
||||
}
|
||||
|
||||
const normalizeNarrationTier = (value: string | null | undefined) => {
|
||||
const normalized = value?.toUpperCase()
|
||||
return normalized === 'STANDARD' || normalized === 'EXTENDED' ? normalized : undefined
|
||||
}
|
||||
|
||||
const normalizeLinkedExhibits = (items: BackendGuideStopLinkedExhibit[] | null | undefined) => (
|
||||
@@ -144,7 +224,7 @@ export const toGuideStopInfo = (
|
||||
available: source.available === true,
|
||||
targetType,
|
||||
targetId,
|
||||
resolvedStopId: stringifyId(source.resolvedStopId) || undefined,
|
||||
resolvedStopId: stringifyId(source.resolvedStopId) || stringifyId(source.stopId) || undefined,
|
||||
lang: normalizeLanguage(source.lang || fallback.lang),
|
||||
title: source.title?.trim() || '讲解内容',
|
||||
description: source.description?.trim() || undefined,
|
||||
@@ -159,6 +239,64 @@ export const toGuideStopInfo = (
|
||||
hasText: source.hasText === true,
|
||||
supportedLanguages: source.supportedLanguages || [],
|
||||
audioStatus: source.audioStatus || 'MISSING',
|
||||
reason: source.reason || undefined,
|
||||
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
|
||||
isSharedStop: source.isSharedStop === true
|
||||
}
|
||||
}
|
||||
|
||||
export const toGuideAudioPlayInfo = (
|
||||
source: BackendAudioPlayInfo,
|
||||
fallback: {
|
||||
targetType: AudioPlayTargetType
|
||||
targetId: string
|
||||
lang: 'zh-CN' | 'en-US'
|
||||
}
|
||||
): GuideAudioPlayInfo => {
|
||||
const targetType = normalizeTargetType(source.targetType, fallback.targetType)
|
||||
const targetId = stringifyId(source.targetId) || fallback.targetId
|
||||
|
||||
return {
|
||||
playable: source.playable === true,
|
||||
targetType,
|
||||
targetId,
|
||||
lang: normalizeLanguage(source.lang || fallback.lang),
|
||||
narrationTier: normalizeNarrationTier(source.narrationTier),
|
||||
audioId: stringifyId(source.audioId) || undefined,
|
||||
title: source.title?.trim() || undefined,
|
||||
duration: typeof source.duration === 'number' && Number.isFinite(source.duration) ? source.duration : undefined,
|
||||
format: source.format?.trim() || undefined,
|
||||
playUrl: normalizeSameOriginPublicUrl(source.playUrl) || undefined,
|
||||
expiresAt: source.expiresAt || undefined,
|
||||
subtitleUrl: normalizeSameOriginPublicUrl(source.subtitleUrl) || undefined,
|
||||
hasText: source.hasText === true,
|
||||
fallback: source.fallback === true,
|
||||
fallbackReason: source.fallbackReason || undefined,
|
||||
reason: source.reason || undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const toGuideAudioTextInfo = (
|
||||
source: BackendAudioTextInfo,
|
||||
fallback: {
|
||||
targetType: AudioPlayTargetType
|
||||
targetId: string
|
||||
lang: 'zh-CN' | 'en-US'
|
||||
}
|
||||
): GuideAudioTextInfo => {
|
||||
const targetType = normalizeTargetType(source.targetType, fallback.targetType)
|
||||
const targetId = stringifyId(source.targetId) || fallback.targetId
|
||||
|
||||
return {
|
||||
available: source.available === true,
|
||||
targetType,
|
||||
targetId,
|
||||
lang: normalizeLanguage(source.lang || fallback.lang),
|
||||
narrationTier: normalizeNarrationTier(source.narrationTier),
|
||||
title: source.title?.trim() || undefined,
|
||||
text: source.text || undefined,
|
||||
textLength: typeof source.textLength === 'number' && Number.isFinite(source.textLength) ? source.textLength : undefined,
|
||||
textHash: source.textHash || undefined,
|
||||
reason: source.reason || undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,21 +11,15 @@ import type {
|
||||
ExplainContentProvider
|
||||
} from '@/data/providers/staticMuseumContentProvider'
|
||||
import {
|
||||
defaultSgsSdkApiProvider,
|
||||
type SgsSdkApiProvider
|
||||
} from '@/data/providers/sgsSdkApiProvider'
|
||||
import {
|
||||
toBackendExplainTrack,
|
||||
toBackendHallFromList,
|
||||
toBackendMediaAsset,
|
||||
toBackendMuseumExhibit,
|
||||
groupGuideStopsByOutline,
|
||||
type BackendExhibit,
|
||||
type BackendHall
|
||||
toCatalogBusinessUnits,
|
||||
toCatalogGuideStop,
|
||||
toCatalogHall,
|
||||
toCatalogMuseumExhibitFromStop,
|
||||
flattenCatalogOutlines,
|
||||
type BackendCatalogHallItem,
|
||||
type BackendCatalogOutlineItem,
|
||||
type BackendCatalogStopItem
|
||||
} from '@/data/adapters/backendExplainDataAdapter'
|
||||
import {
|
||||
toExplainGuideStopFromSgs
|
||||
} from '@/data/adapters/sgsSdkGuideAdapter'
|
||||
|
||||
interface CommonResult<T> {
|
||||
code: number
|
||||
@@ -79,234 +73,414 @@ const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject
|
||||
|
||||
const normalizeKeyword = (keyword = '') => keyword.trim()
|
||||
|
||||
const catalogLang = () => (
|
||||
dataSourceConfig.audioLanguage === 'en-US' ? 'en-US' : 'zh-CN'
|
||||
)
|
||||
|
||||
const cacheKey = (...parts: string[]) => [catalogLang(), ...parts].join(':')
|
||||
|
||||
const requireArrayData = <T>(response: CommonResult<T[]>, message: string) => {
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || message)
|
||||
}
|
||||
|
||||
return Array.isArray(response.data) ? response.data : []
|
||||
}
|
||||
|
||||
export class BackendExplainContentProvider implements ExplainContentProvider {
|
||||
private readonly searchCache = new Map<string, MuseumExhibit[]>()
|
||||
private readonly searchInflight = new Map<string, Promise<MuseumExhibit[]>>()
|
||||
private readonly explainExhibitCache = new Map<string, MuseumExhibit[]>()
|
||||
private readonly explainExhibitInflight = new Map<string, Promise<MuseumExhibit[]>>()
|
||||
private readonly detailCache = new Map<string, MuseumExhibit>()
|
||||
private readonly detailInflight = new Map<string, Promise<MuseumExhibit | null>>()
|
||||
private hallListCache: MuseumHall[] | null = null
|
||||
private hallListInflight: Promise<MuseumHall[]> | null = null
|
||||
private readonly hallListCache = new Map<string, MuseumHall[]>()
|
||||
private readonly hallListInflight = new Map<string, Promise<MuseumHall[]>>()
|
||||
private readonly guideStopCache = new Map<string, ExplainGuideStop[]>()
|
||||
private readonly guideStopInflight = new Map<string, Promise<ExplainGuideStop[]>>()
|
||||
private readonly outlineCache = new Map<string, BackendCatalogOutlineItem[]>()
|
||||
private readonly outlineInflight = new Map<string, Promise<BackendCatalogOutlineItem[]>>()
|
||||
private readonly businessUnitCache = new Map<string, ExplainBusinessUnit[]>()
|
||||
private readonly businessUnitInflight = new Map<string, Promise<ExplainBusinessUnit[]>>()
|
||||
|
||||
constructor(
|
||||
private readonly fallbackProvider: ExplainContentProvider,
|
||||
private readonly sgsSdkApiProvider: SgsSdkApiProvider = defaultSgsSdkApiProvider
|
||||
private readonly fallbackProvider?: ExplainContentProvider
|
||||
) {}
|
||||
|
||||
private async requestStaticExplainExhibits() {
|
||||
const cached = this.searchCache.get('')
|
||||
if (cached) return cached
|
||||
|
||||
const exhibits = await this.fallbackProvider.listExplainExhibits()
|
||||
this.searchCache.set('', exhibits)
|
||||
return exhibits
|
||||
}
|
||||
|
||||
private async searchStaticExplainExhibits(keyword = '') {
|
||||
const normalizedKeyword = normalizeKeyword(keyword).toLowerCase()
|
||||
const exhibits = await this.requestStaticExplainExhibits()
|
||||
if (!normalizedKeyword) return exhibits
|
||||
|
||||
return exhibits.filter((exhibit) => [
|
||||
exhibit.name,
|
||||
exhibit.hallName,
|
||||
exhibit.floorLabel,
|
||||
exhibit.description,
|
||||
...(exhibit.tags || [])
|
||||
].filter(Boolean).join(' ').toLowerCase().includes(normalizedKeyword))
|
||||
}
|
||||
|
||||
private async requestSearch(keyword = '') {
|
||||
const normalizedKeyword = normalizeKeyword(keyword)
|
||||
if (!normalizedKeyword) return this.requestStaticExplainExhibits()
|
||||
|
||||
const cacheKey = normalizedKeyword.toLowerCase()
|
||||
const cached = this.searchCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const inflight = this.searchInflight.get(cacheKey)
|
||||
if (inflight) return inflight
|
||||
|
||||
const promise = (async () => {
|
||||
const params = new URLSearchParams({ keyword: normalizedKeyword })
|
||||
const url = `${resolveAppApiBaseUrl()}/gis/exhibit/search?${params.toString()}`
|
||||
const response = await requestJson<CommonResult<BackendExhibit[]>>(url)
|
||||
if (response.code !== 0 || !Array.isArray(response.data)) {
|
||||
throw new Error(response.msg || '后端讲解搜索失败')
|
||||
}
|
||||
|
||||
const fallbackHalls = await this.safeFallbackHalls()
|
||||
const fallbackHallById = new Map(fallbackHalls.map((hall) => [hall.id, hall]))
|
||||
const exhibits = response.data.map((item) => toBackendMuseumExhibit(
|
||||
item,
|
||||
fallbackHallById.get(String(item.hallId || '')),
|
||||
{ includeDetail: false }
|
||||
))
|
||||
|
||||
this.searchCache.set(cacheKey, exhibits)
|
||||
return exhibits
|
||||
})()
|
||||
|
||||
this.searchInflight.set(cacheKey, promise)
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
this.searchInflight.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
private async requestDetail(id: string) {
|
||||
const cached = this.detailCache.get(id)
|
||||
if (cached) return cached
|
||||
|
||||
const inflight = this.detailInflight.get(id)
|
||||
if (inflight) return inflight
|
||||
|
||||
const promise = (async () => {
|
||||
const params = new URLSearchParams({ id })
|
||||
const url = `${resolveAppApiBaseUrl()}/gis/exhibit/get?${params.toString()}`
|
||||
const response = await requestJson<CommonResult<BackendExhibit>>(url)
|
||||
if (response.code !== 0 || !response.data) {
|
||||
throw new Error(response.msg || '后端讲解详情失败')
|
||||
}
|
||||
|
||||
const fallbackHalls = await this.safeFallbackHalls()
|
||||
const fallbackHall = fallbackHalls.find((hall) => hall.id === String(response.data?.hallId || ''))
|
||||
const detail = toBackendMuseumExhibit(response.data, fallbackHall, { includeDetail: true })
|
||||
this.detailCache.set(id, detail)
|
||||
return detail
|
||||
})().catch(async (error) => {
|
||||
console.warn('后端讲解详情加载失败,将使用静态讲解兜底:', error)
|
||||
return this.fallbackProvider.listExhibits()
|
||||
.then((items) => items.find((item) => item.id === id) || null)
|
||||
.catch(() => null)
|
||||
})
|
||||
|
||||
this.detailInflight.set(id, promise)
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
this.detailInflight.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private async safeFallbackHalls() {
|
||||
return this.fallbackProvider.listHalls().catch(() => [])
|
||||
if (!this.isStaticFallbackEnabled()) return []
|
||||
|
||||
return this.fallbackProvider?.listHalls().catch(() => []) || []
|
||||
}
|
||||
|
||||
private async requestHallList() {
|
||||
if (this.hallListCache) return this.hallListCache
|
||||
if (this.hallListInflight) return this.hallListInflight
|
||||
private isStaticFallbackEnabled() {
|
||||
return Boolean(this.fallbackProvider && dataSourceConfig.allowExplainStaticFallback)
|
||||
}
|
||||
|
||||
this.hallListInflight = (async () => {
|
||||
const url = `${resolveAppApiBaseUrl()}/gis/hall/list`
|
||||
const response = await requestJson<CommonResult<BackendHall[]>>(url)
|
||||
if (response.code !== 0 || !Array.isArray(response.data)) {
|
||||
throw new Error(response.msg || '后端展厅列表加载失败')
|
||||
}
|
||||
private async fallbackOrThrow<T>(
|
||||
error: unknown,
|
||||
message: string,
|
||||
fallback: () => Promise<T>
|
||||
): Promise<T> {
|
||||
if (this.isStaticFallbackEnabled()) {
|
||||
console.warn(`${message},将使用显式静态讲解兜底:`, error)
|
||||
return fallback()
|
||||
}
|
||||
|
||||
console.error(message, error)
|
||||
throw error
|
||||
}
|
||||
|
||||
private async requestCatalogHalls() {
|
||||
const key = cacheKey('halls')
|
||||
const cached = this.hallListCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const inflight = this.hallListInflight.get(key)
|
||||
if (inflight) return inflight
|
||||
|
||||
const promise = (async () => {
|
||||
const params = new URLSearchParams({ lang: catalogLang() })
|
||||
const url = `${resolveAppApiBaseUrl()}/gis/guide/catalog/halls?${params.toString()}`
|
||||
const response = await requestJson<CommonResult<BackendCatalogHallItem[]>>(url)
|
||||
const items = requireArrayData(response, '讲解展厅目录加载失败')
|
||||
const fallbackHalls = await this.safeFallbackHalls()
|
||||
const fallbackHallById = new Map(fallbackHalls.map((hall) => [hall.id, hall]))
|
||||
const fallbackHallByName = new Map(fallbackHalls.map((hall) => [hall.name, hall]))
|
||||
const halls = response.data
|
||||
.map((item) => toBackendHallFromList(
|
||||
const halls = items
|
||||
.map((item) => toCatalogHall(
|
||||
item,
|
||||
fallbackHallById.get(String(item.id || '')) || fallbackHallByName.get(String(item.name || ''))
|
||||
))
|
||||
.filter((hall) => hall.id)
|
||||
|
||||
this.hallListCache = halls
|
||||
this.hallListCache.set(key, halls)
|
||||
return halls
|
||||
})()
|
||||
|
||||
try {
|
||||
return await this.hallListInflight
|
||||
} finally {
|
||||
this.hallListInflight = null
|
||||
}
|
||||
}
|
||||
|
||||
async listGuideStopsByHall(hallId: string) {
|
||||
const normalizedHallId = hallId.trim()
|
||||
if (!normalizedHallId) return []
|
||||
|
||||
const cached = this.guideStopCache.get(normalizedHallId)
|
||||
if (cached) return cached
|
||||
|
||||
const inflight = this.guideStopInflight.get(normalizedHallId)
|
||||
if (inflight) return inflight
|
||||
|
||||
const promise = (async () => {
|
||||
const stops = (await this.sgsSdkApiProvider.getGuideStopsByHall(normalizedHallId))
|
||||
.map(toExplainGuideStopFromSgs)
|
||||
.filter(Boolean) as ExplainGuideStop[]
|
||||
|
||||
this.guideStopCache.set(normalizedHallId, stops)
|
||||
return stops
|
||||
})()
|
||||
|
||||
this.guideStopInflight.set(normalizedHallId, promise)
|
||||
this.hallListInflight.set(key, promise)
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
this.guideStopInflight.delete(normalizedHallId)
|
||||
this.hallListInflight.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
private async requestCatalogOutlinesByHall(hallId: string) {
|
||||
const normalizedHallId = hallId.trim()
|
||||
if (!normalizedHallId) return []
|
||||
|
||||
const key = cacheKey('hall', normalizedHallId, 'outlines')
|
||||
const cached = this.outlineCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const inflight = this.outlineInflight.get(key)
|
||||
if (inflight) return inflight
|
||||
|
||||
const promise = (async () => {
|
||||
const params = new URLSearchParams({
|
||||
includeChildren: 'true',
|
||||
lang: catalogLang()
|
||||
})
|
||||
const url = `${resolveAppApiBaseUrl()}/gis/guide/catalog/halls/${encodeURIComponent(normalizedHallId)}/outlines?${params.toString()}`
|
||||
const response = await requestJson<CommonResult<BackendCatalogOutlineItem[]>>(url)
|
||||
const outlines = requireArrayData(response, '讲解单元目录加载失败')
|
||||
this.outlineCache.set(key, outlines)
|
||||
return outlines
|
||||
})()
|
||||
|
||||
this.outlineInflight.set(key, promise)
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
this.outlineInflight.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
private async requestCatalogStopsByHall(hallId: string) {
|
||||
const normalizedHallId = hallId.trim()
|
||||
if (!normalizedHallId) return []
|
||||
|
||||
const key = cacheKey('hall', normalizedHallId, 'stops')
|
||||
const cached = this.guideStopCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const inflight = this.guideStopInflight.get(key)
|
||||
if (inflight) return inflight
|
||||
|
||||
const promise = (async () => {
|
||||
const [halls, outlines] = await Promise.all([
|
||||
this.requestCatalogHalls(),
|
||||
this.requestCatalogOutlinesByHall(normalizedHallId).catch(() => [])
|
||||
])
|
||||
const hall = halls.find((item) => item.id === normalizedHallId) || null
|
||||
const outlineById = new Map(flattenCatalogOutlines(outlines).map((outline) => [String(outline.id || ''), outline]))
|
||||
|
||||
const params = new URLSearchParams({ lang: catalogLang() })
|
||||
const url = `${resolveAppApiBaseUrl()}/gis/guide/catalog/halls/${encodeURIComponent(normalizedHallId)}/stops?${params.toString()}`
|
||||
const response = await requestJson<CommonResult<BackendCatalogStopItem[]>>(url)
|
||||
const stops = requireArrayData(response, '讲解点目录加载失败')
|
||||
.map((item) => {
|
||||
const outline = outlineById.get(String(item.outlineId || ''))
|
||||
return toCatalogGuideStop(item, hall, outline
|
||||
? {
|
||||
id: String(outline.id || ''),
|
||||
name: String(outline.name || '')
|
||||
}
|
||||
: null)
|
||||
})
|
||||
.filter(Boolean) as ExplainGuideStop[]
|
||||
|
||||
this.guideStopCache.set(key, stops)
|
||||
return stops
|
||||
})()
|
||||
|
||||
this.guideStopInflight.set(key, promise)
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
this.guideStopInflight.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
private async requestCatalogStopsByOutline(hallId: string, outlineId: string) {
|
||||
const normalizedHallId = hallId.trim()
|
||||
const normalizedOutlineId = outlineId.trim()
|
||||
if (!normalizedOutlineId) return []
|
||||
|
||||
const hallStopsKey = cacheKey('hall', normalizedHallId, 'stops')
|
||||
const cachedHallStops = this.guideStopCache.get(hallStopsKey)
|
||||
if (cachedHallStops) {
|
||||
return cachedHallStops.filter((stop) => stop.outlineId === normalizedOutlineId)
|
||||
}
|
||||
|
||||
const key = cacheKey('outline', normalizedOutlineId, 'stops')
|
||||
const cached = this.guideStopCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const inflight = this.guideStopInflight.get(key)
|
||||
if (inflight) return inflight
|
||||
|
||||
const promise = (async () => {
|
||||
const [halls, outlines] = await Promise.all([
|
||||
this.requestCatalogHalls(),
|
||||
normalizedHallId ? this.requestCatalogOutlinesByHall(normalizedHallId).catch(() => []) : Promise.resolve([])
|
||||
])
|
||||
const outline = flattenCatalogOutlines(outlines).find((item) => String(item.id || '') === normalizedOutlineId)
|
||||
const hall = halls.find((item) => item.id === (String(outline?.hallId || '') || normalizedHallId)) || null
|
||||
|
||||
const params = new URLSearchParams({ lang: catalogLang() })
|
||||
const url = `${resolveAppApiBaseUrl()}/gis/guide/catalog/outlines/${encodeURIComponent(normalizedOutlineId)}/stops?${params.toString()}`
|
||||
const response = await requestJson<CommonResult<BackendCatalogStopItem[]>>(url)
|
||||
const stops = requireArrayData(response, '单元讲解点目录加载失败')
|
||||
.map((item) => toCatalogGuideStop(item, hall, outline
|
||||
? {
|
||||
id: String(outline.id || ''),
|
||||
name: String(outline.name || '')
|
||||
}
|
||||
: null))
|
||||
.filter(Boolean) as ExplainGuideStop[]
|
||||
|
||||
this.guideStopCache.set(key, stops)
|
||||
return stops
|
||||
})()
|
||||
|
||||
this.guideStopInflight.set(key, promise)
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
this.guideStopInflight.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
private async requestCatalogExhibits() {
|
||||
const key = cacheKey('exhibits')
|
||||
const cached = this.explainExhibitCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const inflight = this.explainExhibitInflight.get(key)
|
||||
if (inflight) return inflight
|
||||
|
||||
const promise = (async () => {
|
||||
const halls = await this.requestCatalogHalls()
|
||||
const stopsByHall = await Promise.all(halls.map(async (hall) => {
|
||||
const stops = await this.requestCatalogStopsByHall(hall.id)
|
||||
return stops.map((stop) => toCatalogMuseumExhibitFromStop(stop, hall))
|
||||
}))
|
||||
const exhibits = stopsByHall.flat()
|
||||
this.explainExhibitCache.set(key, exhibits)
|
||||
exhibits.forEach((exhibit) => {
|
||||
this.detailCache.set(exhibit.id, exhibit)
|
||||
})
|
||||
return exhibits
|
||||
})()
|
||||
|
||||
this.explainExhibitInflight.set(key, promise)
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
this.explainExhibitInflight.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
private async searchCatalogExhibits(keyword = '') {
|
||||
const normalizedKeyword = normalizeKeyword(keyword).toLowerCase()
|
||||
const exhibits = await this.requestCatalogExhibits()
|
||||
if (!normalizedKeyword) return exhibits
|
||||
|
||||
return exhibits.filter((exhibit) => [
|
||||
exhibit.id,
|
||||
exhibit.name,
|
||||
exhibit.hallName,
|
||||
exhibit.floorLabel,
|
||||
exhibit.description,
|
||||
exhibit.playTargetId,
|
||||
...(exhibit.tags || []),
|
||||
...(exhibit.linkedExhibits || []).flatMap((item) => [item.id, item.name, item.exhibitCode])
|
||||
].filter(Boolean).join(' ').toLowerCase().includes(normalizedKeyword))
|
||||
}
|
||||
|
||||
async listGuideStopsByHall(hallId: string) {
|
||||
try {
|
||||
return await this.requestCatalogStopsByHall(hallId)
|
||||
} catch (error) {
|
||||
return this.fallbackOrThrow(error, '讲解点目录加载失败', async () => (
|
||||
this.fallbackProvider?.listGuideStopsByHall?.(hallId) || []
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async listTemporaryBusinessUnitsByHall(hallId: string): Promise<ExplainBusinessUnit[]> {
|
||||
const stops = await this.listGuideStopsByHall(hallId)
|
||||
return groupGuideStopsByOutline(hallId, stops)
|
||||
const normalizedHallId = hallId.trim()
|
||||
if (!normalizedHallId) return []
|
||||
|
||||
const key = cacheKey('hall', normalizedHallId, 'units')
|
||||
const cached = this.businessUnitCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const inflight = this.businessUnitInflight.get(key)
|
||||
if (inflight) return inflight
|
||||
|
||||
const promise = (async () => {
|
||||
const [outlines, stops] = await Promise.all([
|
||||
this.requestCatalogOutlinesByHall(normalizedHallId),
|
||||
this.requestCatalogStopsByHall(normalizedHallId)
|
||||
])
|
||||
const units = toCatalogBusinessUnits(normalizedHallId, outlines, stops)
|
||||
|
||||
await Promise.all(units.map(async (unit) => {
|
||||
if (unit.stops.length) return
|
||||
unit.stops = await this.requestCatalogStopsByOutline(normalizedHallId, unit.id).catch(() => [])
|
||||
unit.guideStopCount = unit.stops.length || unit.guideStopCount
|
||||
}))
|
||||
|
||||
this.businessUnitCache.set(key, units)
|
||||
return units
|
||||
})().catch((error) => {
|
||||
return this.fallbackOrThrow(error, '讲解单元目录加载失败', async () => (
|
||||
this.fallbackProvider?.listTemporaryBusinessUnitsByHall?.(normalizedHallId) || []
|
||||
))
|
||||
})
|
||||
|
||||
this.businessUnitInflight.set(key, promise)
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
this.businessUnitInflight.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
async listExplainExhibits() {
|
||||
return this.requestStaticExplainExhibits()
|
||||
try {
|
||||
return await this.requestCatalogExhibits()
|
||||
} catch (error) {
|
||||
return this.fallbackOrThrow(error, '讲解目录展项加载失败', async () => (
|
||||
this.fallbackProvider?.listExplainExhibits() || []
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
listExhibits() {
|
||||
return this.listExplainExhibits()
|
||||
}
|
||||
|
||||
getExhibitById(id: string) {
|
||||
return this.requestDetail(id)
|
||||
async getExhibitById(id: string) {
|
||||
const normalizedId = id.trim()
|
||||
if (!normalizedId) return null
|
||||
|
||||
const cached = this.detailCache.get(normalizedId)
|
||||
if (cached) return cached
|
||||
|
||||
const catalogExhibits = await this.listExplainExhibits().catch(() => [])
|
||||
const matched = catalogExhibits.find((exhibit) => (
|
||||
exhibit.id === normalizedId
|
||||
|| exhibit.playTargetId === normalizedId
|
||||
|| exhibit.resolvedStopId === normalizedId
|
||||
|| exhibit.linkedExhibits?.some((linked) => linked.id === normalizedId)
|
||||
))
|
||||
if (matched) {
|
||||
this.detailCache.set(normalizedId, matched)
|
||||
return matched
|
||||
}
|
||||
|
||||
if (!this.isStaticFallbackEnabled()) return null
|
||||
|
||||
return this.fallbackProvider?.getExhibitById?.(normalizedId)
|
||||
|| this.fallbackProvider?.listExhibits()
|
||||
.then((items) => items.find((item) => item.id === normalizedId) || null)
|
||||
.catch(() => null)
|
||||
|| null
|
||||
}
|
||||
|
||||
async listHalls() {
|
||||
try {
|
||||
return await this.requestHallList()
|
||||
return await this.requestCatalogHalls()
|
||||
} catch (error) {
|
||||
console.warn('后端展厅列表加载失败,将使用静态展厅兜底:', error)
|
||||
return this.fallbackProvider.listHalls()
|
||||
return this.fallbackOrThrow(error, '讲解展厅目录加载失败', async () => (
|
||||
this.fallbackProvider?.listHalls() || []
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async listTracks() {
|
||||
const exhibits = await this.listExplainExhibits()
|
||||
return exhibits.map(toBackendExplainTrack)
|
||||
return exhibits.map((exhibit) => ({
|
||||
id: `track-${exhibit.playTargetId || exhibit.id}`,
|
||||
exhibitId: exhibit.id,
|
||||
hallId: exhibit.hallId,
|
||||
title: exhibit.guideTitle || `${exhibit.name}讲解`,
|
||||
summary: exhibit.guideText || exhibit.description,
|
||||
coverImage: exhibit.image,
|
||||
poiId: exhibit.poiId,
|
||||
floorId: exhibit.floorId,
|
||||
available: exhibit.audioStatus === 'READY',
|
||||
playTargetType: exhibit.playTargetType,
|
||||
playTargetId: exhibit.playTargetId
|
||||
}))
|
||||
}
|
||||
|
||||
async getMediaById(id: string) {
|
||||
const normalizedId = id.replace(/^media-/, '')
|
||||
const cachedDetail = Array.from(this.detailCache.values()).find((exhibit) => (
|
||||
exhibit.id === normalizedId || exhibit.guideContentId === normalizedId
|
||||
))
|
||||
if (cachedDetail) return toBackendMediaAsset(cachedDetail)
|
||||
|
||||
const detail = await this.requestDetail(normalizedId).catch(() => null)
|
||||
return detail ? toBackendMediaAsset(detail) : null
|
||||
async getMediaById() {
|
||||
return null
|
||||
}
|
||||
|
||||
async getMediaForExplainTrack(trackId: string) {
|
||||
return this.getMediaById(`media-${trackId.replace(/^track-/, '')}`)
|
||||
async getMediaForExplainTrack() {
|
||||
return null
|
||||
}
|
||||
|
||||
searchExplainExhibits(keyword = '') {
|
||||
const normalizedKeyword = normalizeKeyword(keyword)
|
||||
if (!normalizedKeyword) return this.requestStaticExplainExhibits()
|
||||
if (!normalizedKeyword) return this.listExplainExhibits()
|
||||
|
||||
return this.requestSearch(normalizedKeyword).catch((error) => {
|
||||
console.warn('后端讲解搜索失败,将使用静态讲解兜底:', error)
|
||||
return this.searchStaticExplainExhibits(normalizedKeyword)
|
||||
return this.searchCatalogExhibits(normalizedKeyword).catch((error) => {
|
||||
return this.fallbackOrThrow(error, '讲解目录搜索失败', async () => (
|
||||
this.fallbackProvider?.searchExplainExhibits?.(normalizedKeyword)
|
||||
|| this.fallbackProvider?.listExplainExhibits()
|
||||
.then((items) => items.filter((exhibit) => [
|
||||
exhibit.name,
|
||||
exhibit.hallName,
|
||||
exhibit.floorLabel,
|
||||
exhibit.description,
|
||||
...(exhibit.tags || [])
|
||||
].filter(Boolean).join(' ').toLowerCase().includes(normalizedKeyword.toLowerCase())))
|
||||
|| []
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +268,9 @@ export const createMuseumContentProvider = (): ExplainContentProvider => {
|
||||
}
|
||||
|
||||
if (isGuideContentRemoteMode()) {
|
||||
return new BackendExplainContentProvider(new StaticGuideContentProvider())
|
||||
return new BackendExplainContentProvider(
|
||||
dataSourceConfig.allowExplainStaticFallback ? new StaticGuideContentProvider() : undefined
|
||||
)
|
||||
}
|
||||
|
||||
if (isGuideContentMockMode()) {
|
||||
@@ -278,4 +280,5 @@ export const createMuseumContentProvider = (): ExplainContentProvider => {
|
||||
return new StaticGuideContentProvider()
|
||||
}
|
||||
|
||||
export const staticMuseumContentProvider = createMuseumContentProvider()
|
||||
export const explainContentProvider = createMuseumContentProvider()
|
||||
export const staticMuseumContentProvider = explainContentProvider
|
||||
|
||||
@@ -129,6 +129,13 @@ export interface MuseumHall {
|
||||
description?: string
|
||||
image?: string
|
||||
exhibitCount?: number
|
||||
outlineCount?: number
|
||||
stopCount?: number
|
||||
linkedExhibitCount?: number
|
||||
audioReadyStopCount?: number
|
||||
hasAudio?: boolean
|
||||
audioStatus?: string
|
||||
supportedLanguages?: string[]
|
||||
area?: string
|
||||
poiId?: string
|
||||
location?: GuideLocationResolution
|
||||
@@ -170,6 +177,8 @@ export interface MuseumExhibit {
|
||||
imageStatus?: string
|
||||
imageSource?: string
|
||||
galleryUrls?: string[]
|
||||
linkedExhibitCount?: number
|
||||
isSharedStop?: boolean
|
||||
linkedExhibits?: Array<{
|
||||
id: string
|
||||
name: string
|
||||
@@ -195,18 +204,44 @@ export interface ExplainGuideStop {
|
||||
coverImageUrl?: string
|
||||
description?: string
|
||||
hasAudio?: boolean
|
||||
audioStatus?: string
|
||||
supportedLanguages?: string[]
|
||||
hasTextRecord?: boolean
|
||||
poiId?: string
|
||||
outlineId?: string
|
||||
outlineName?: string
|
||||
sort?: number
|
||||
guideLevel?: string
|
||||
imageStatus?: string
|
||||
linkedExhibitCount?: number
|
||||
isSharedStop?: boolean
|
||||
linkedExhibits?: Array<{
|
||||
id: string
|
||||
name: string
|
||||
nameEn?: string
|
||||
exhibitCode?: string
|
||||
coverImageUrl?: string
|
||||
}>
|
||||
playTargetType?: AudioPlayTargetType
|
||||
playTargetId?: string
|
||||
}
|
||||
|
||||
export interface ExplainBusinessUnit {
|
||||
id: string
|
||||
name: string
|
||||
hallId: string
|
||||
parentId?: string
|
||||
code?: string
|
||||
description?: string
|
||||
sort?: number
|
||||
level?: number
|
||||
previewImageUrl?: string
|
||||
guideStopCount: number
|
||||
linkedExhibitCount?: number
|
||||
audioReadyStopCount?: number
|
||||
hasAudio?: boolean
|
||||
audioStatus?: string
|
||||
supportedLanguages?: string[]
|
||||
stops: ExplainGuideStop[]
|
||||
}
|
||||
|
||||
|
||||
@@ -24,9 +24,14 @@
|
||||
<text class="hero-placeholder-text">讲解</text>
|
||||
</view>
|
||||
|
||||
<view class="detail-body">
|
||||
<text class="detail-title">{{ exhibit.title }}</text>
|
||||
<text v-if="detailMeta" class="detail-meta">{{ detailMeta }}</text>
|
||||
<view class="detail-body">
|
||||
<text class="detail-title">{{ exhibit.title }}</text>
|
||||
<text v-if="detailMeta" class="detail-meta">{{ detailMeta }}</text>
|
||||
<view v-if="exhibit.linkedExhibitStatusText" class="detail-chip-row">
|
||||
<view class="detail-chip" :class="{ shared: exhibit.isSharedStop }">
|
||||
<text class="detail-chip-text">{{ exhibit.linkedExhibitStatusText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="exhibit.audio.status === 'playable'"
|
||||
@@ -328,6 +333,8 @@ const handleAudioError = async (_audio: AudioItem | null, message: string) => {
|
||||
audioLanguage: exhibit.value.audio.language,
|
||||
playTargetType: exhibit.value.audio.playTargetType,
|
||||
playTargetId: exhibit.value.audio.playTargetId
|
||||
}, {
|
||||
refreshPlayInfo: true
|
||||
})
|
||||
|
||||
if (selection.playable && selection.media?.url) {
|
||||
@@ -491,6 +498,46 @@ const handleBack = () => {
|
||||
color: #555c51;
|
||||
}
|
||||
|
||||
.detail-chip-row {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-chip {
|
||||
max-width: 100%;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
background: #f4f6ef;
|
||||
border: 1px solid #dde2d3;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.detail-chip.shared {
|
||||
background: #151713;
|
||||
border-color: #151713;
|
||||
}
|
||||
|
||||
.detail-chip-text {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-weight: 700;
|
||||
color: #555c51;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-chip.shared .detail-chip-text {
|
||||
color: #e0df00;
|
||||
}
|
||||
|
||||
.audio-panel {
|
||||
height: 76px;
|
||||
margin-top: 24px;
|
||||
|
||||
@@ -109,7 +109,9 @@ const explainGuideStopItems = computed<ExplainGuideStopSelectItem[]>(() => (
|
||||
floorId: stop.floorId,
|
||||
coverImageUrl: stop.coverImageUrl,
|
||||
description: stop.description,
|
||||
hasAudio: stop.hasAudio
|
||||
hasAudio: stop.hasAudio,
|
||||
audioStatus: stop.audioStatus,
|
||||
guideLevel: stop.guideLevel
|
||||
})) || []
|
||||
))
|
||||
|
||||
|
||||
@@ -708,7 +708,9 @@ const explainGuideStopItems = computed<ExplainGuideStopSelectItem[]>(() => (
|
||||
floorId: stop.floorId,
|
||||
coverImageUrl: stop.coverImageUrl,
|
||||
description: stop.description,
|
||||
hasAudio: stop.hasAudio
|
||||
hasAudio: stop.hasAudio,
|
||||
audioStatus: stop.audioStatus,
|
||||
guideLevel: stop.guideLevel
|
||||
})) || []
|
||||
))
|
||||
|
||||
@@ -995,19 +997,26 @@ const resolveSelectedPoiHall = async (): Promise<MuseumHall | null> => {
|
||||
if (!poi) return null
|
||||
|
||||
const halls = await explainUseCase.listHalls()
|
||||
const hallId = poi.hallId
|
||||
if (hallId) {
|
||||
const matchedById = halls.find((hall) => hall.id === hallId || hall.poiId === hallId)
|
||||
if (matchedById) return matchedById
|
||||
}
|
||||
|
||||
const matchedByPoiId = halls.find((hall) => (
|
||||
hall.poiId === poi.id
|
||||
|| hall.location?.poiId === poi.id
|
||||
|| (poi.entrances || []).some((entrance) => hall.poiId === entrance.sourcePlaceId)
|
||||
))
|
||||
if (matchedByPoiId) return matchedByPoiId
|
||||
|
||||
const hallId = poi.hallId
|
||||
if (hallId) {
|
||||
const matchedById = halls.find((hall) => hall.id === hallId)
|
||||
if (matchedById) return matchedById
|
||||
}
|
||||
|
||||
const matchedByEntrance = halls.find((hall) => (
|
||||
(poi.entrances || []).some((entrance) => (
|
||||
hall.poiId === entrance.sourcePlaceId
|
||||
|| hall.poiId === entrance.id
|
||||
))
|
||||
))
|
||||
if (matchedByEntrance) return matchedByEntrance
|
||||
|
||||
const poiHallName = normalizeHallMatchText(poi.hallName || poi.name)
|
||||
return halls.find((hall) => {
|
||||
const hallName = normalizeHallMatchText(hall.name)
|
||||
@@ -1932,17 +1941,30 @@ const handleExplainBack = () => {
|
||||
left: 50%;
|
||||
right: auto;
|
||||
width: calc(var(--museum-h5-page-width) - 24px);
|
||||
bottom: calc(env(safe-area-inset-bottom) + 16px);
|
||||
padding: 12px;
|
||||
bottom: calc(env(safe-area-inset-bottom) + 14px);
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.96);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 28px rgba(36, 49, 42, 0.16);
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(224, 225, 0, 0.22) 0, rgba(224, 225, 0, 0) 58px),
|
||||
#060704;
|
||||
border: 1px solid rgba(224, 225, 0, 0.34);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 18px 38px rgba(0, 0, 0, 0.34);
|
||||
transform: translateX(-50%);
|
||||
z-index: 1003;
|
||||
}
|
||||
|
||||
.guide-home-dock::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
top: 0;
|
||||
height: 4px;
|
||||
background: var(--museum-accent);
|
||||
}
|
||||
|
||||
.guide-home-dock.expanded {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
@@ -1964,10 +1986,14 @@ const handleExplainBack = () => {
|
||||
transform: translateX(-50%);
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.guide-home-dock.expanded::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* #ifdef H5 */
|
||||
.guide-home-dock.expanded {
|
||||
min-height: 100vh;
|
||||
@@ -1988,18 +2014,18 @@ const handleExplainBack = () => {
|
||||
|
||||
.guide-quick-actions {
|
||||
position: absolute;
|
||||
top: clamp(96px, 14vh, 118px);
|
||||
right: 18px;
|
||||
width: 48px;
|
||||
top: clamp(88px, 13vh, 112px);
|
||||
right: 14px;
|
||||
width: 52px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 7px;
|
||||
z-index: 1002;
|
||||
}
|
||||
|
||||
.guide-quick-action {
|
||||
width: 48px;
|
||||
min-height: 56px;
|
||||
width: 52px;
|
||||
min-height: 58px;
|
||||
padding: 7px 4px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2007,15 +2033,16 @@ const handleExplainBack = () => {
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border: 1px solid rgba(230, 233, 224, 0.96);
|
||||
background: rgba(245, 245, 237, 0.94);
|
||||
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 22px rgba(36, 49, 42, 0.14);
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.guide-quick-action.primary {
|
||||
background: #151713;
|
||||
border-color: #151713;
|
||||
background: var(--museum-accent);
|
||||
border-color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.guide-quick-action:active {
|
||||
@@ -2031,7 +2058,7 @@ const handleExplainBack = () => {
|
||||
}
|
||||
|
||||
.guide-quick-action.primary .guide-quick-icon {
|
||||
color: var(--museum-accent);
|
||||
color: #0a0b08;
|
||||
}
|
||||
|
||||
.guide-quick-icon-floor {
|
||||
@@ -2113,7 +2140,7 @@ const handleExplainBack = () => {
|
||||
}
|
||||
|
||||
.guide-quick-action-text.primary {
|
||||
color: var(--museum-accent);
|
||||
color: #0a0b08;
|
||||
}
|
||||
|
||||
.route-sim-top-pill {
|
||||
|
||||
@@ -80,16 +80,15 @@ onUnmounted(() => {
|
||||
<style scoped lang="scss">
|
||||
.poi-search-page {
|
||||
--museum-h5-page-width: min(100vw, 430px);
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding: calc(env(safe-area-inset-top) + 28px) 14px calc(env(safe-area-inset-bottom) + 16px);
|
||||
padding: calc(env(safe-area-inset-top) + 18px) 0 0;
|
||||
box-sizing: border-box;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(224, 225, 0, 0.22) 0, rgba(245, 245, 237, 0) 146px),
|
||||
#f5f5ed;
|
||||
background: #f5f5ed;
|
||||
color: #262421;
|
||||
font-family: '鸿蒙黑体', 'HarmonyOS Sans SC', 'HarmonyOS Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
display: flex;
|
||||
@@ -97,6 +96,19 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.poi-search-page::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 210px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0 64px, rgba(0, 0, 0, 0.18) 65px, rgba(0, 0, 0, 0.5) 100%),
|
||||
url('/static/guide/app-launch-loading.webp') center top / cover no-repeat;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* #ifdef H5 */
|
||||
.poi-search-page {
|
||||
height: var(--poi-search-page-height, 100dvh);
|
||||
@@ -108,20 +120,29 @@ onUnmounted(() => {
|
||||
/* #endif */
|
||||
|
||||
.search-header {
|
||||
min-height: 46px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 48px;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 0 2px 16px;
|
||||
margin: 0 18px 118px;
|
||||
}
|
||||
|
||||
.search-panel-shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
padding: 18px 14px calc(env(safe-area-inset-bottom) + 14px);
|
||||
box-sizing: border-box;
|
||||
background: #fffef0;
|
||||
border-radius: 22px 22px 0 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 -10px 28px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.search-header-copy {
|
||||
@@ -132,31 +153,31 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 26px;
|
||||
line-height: 31px;
|
||||
font-size: 24px;
|
||||
line-height: 30px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
color: #000000;
|
||||
color: #050704;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
font-weight: 500;
|
||||
color: #5d5b50;
|
||||
color: #4f5149;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.map-link {
|
||||
min-width: 74px;
|
||||
height: 32px;
|
||||
min-width: 76px;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 10px;
|
||||
box-sizing: border-box;
|
||||
background: #000000;
|
||||
border: 1px solid rgba(0, 0, 0, 0.88);
|
||||
background: #050704;
|
||||
border: 1px solid #050704;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -177,13 +198,16 @@ onUnmounted(() => {
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.poi-search-page {
|
||||
padding-top: calc(env(safe-area-inset-top) + 22px);
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
padding-top: calc(env(safe-area-inset-top) + 14px);
|
||||
}
|
||||
|
||||
.search-header {
|
||||
margin-bottom: 12px;
|
||||
margin: 0 14px 108px;
|
||||
}
|
||||
|
||||
.search-panel-shell {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
|
||||
@@ -2,14 +2,17 @@ import {
|
||||
dataSourceConfig
|
||||
} from '@/config/dataSource'
|
||||
import {
|
||||
normalizeSameOriginPublicUrl
|
||||
} from '@/utils/publicUrl'
|
||||
import type {
|
||||
AudioPlayTargetType
|
||||
type AudioPlayTargetType
|
||||
} from '@/domain/museum'
|
||||
import {
|
||||
toGuideAudioPlayInfo,
|
||||
toGuideAudioTextInfo,
|
||||
toGuideStopInfo,
|
||||
type BackendAudioPlayInfo,
|
||||
type BackendAudioTextInfo,
|
||||
type BackendGuideStopInfo,
|
||||
type GuideAudioPlayInfo,
|
||||
type GuideAudioTextInfo,
|
||||
type GuideStopInfo
|
||||
} from '@/data/adapters/guideStopInfoAdapter'
|
||||
|
||||
@@ -19,6 +22,7 @@ export interface AudioPlayInfoRequest {
|
||||
targetType: AudioPlayTargetType
|
||||
targetId: string
|
||||
lang?: AudioLanguage
|
||||
refresh?: boolean
|
||||
}
|
||||
|
||||
export interface GuideStopInfoRequest {
|
||||
@@ -33,29 +37,12 @@ interface GuideStopInfoResponse {
|
||||
data?: BackendGuideStopInfo
|
||||
}
|
||||
|
||||
export interface AudioPlayInfo {
|
||||
playable: boolean
|
||||
targetType: AudioPlayTargetType
|
||||
targetId: string | number
|
||||
lang: AudioLanguage
|
||||
narrationTier?: 'STANDARD' | 'EXTENDED'
|
||||
audioId?: string | number | null
|
||||
title?: string | null
|
||||
duration?: number | null
|
||||
format?: string | null
|
||||
playUrl?: string | null
|
||||
expiresAt?: string | null
|
||||
subtitleUrl?: string | null
|
||||
hasText?: boolean
|
||||
fallback?: boolean
|
||||
fallbackReason?: string | null
|
||||
reason?: string | null
|
||||
}
|
||||
export interface AudioPlayInfo extends GuideAudioPlayInfo {}
|
||||
|
||||
interface AudioPlayInfoResponse {
|
||||
code: number
|
||||
msg?: string
|
||||
data?: AudioPlayInfo
|
||||
data?: BackendAudioPlayInfo
|
||||
}
|
||||
|
||||
export interface AudioTextInfoRequest {
|
||||
@@ -64,23 +51,12 @@ export interface AudioTextInfoRequest {
|
||||
lang?: AudioLanguage
|
||||
}
|
||||
|
||||
export interface AudioTextInfo {
|
||||
available: boolean
|
||||
targetType: AudioPlayTargetType
|
||||
targetId: string | number
|
||||
lang: AudioLanguage
|
||||
narrationTier?: 'STANDARD' | 'EXTENDED'
|
||||
title?: string | null
|
||||
text?: string | null
|
||||
textLength?: number | null
|
||||
textHash?: string | null
|
||||
reason?: string | null
|
||||
}
|
||||
export interface AudioTextInfo extends GuideAudioTextInfo {}
|
||||
|
||||
interface AudioTextInfoResponse {
|
||||
code: number
|
||||
msg?: string
|
||||
data?: AudioTextInfo
|
||||
data?: BackendAudioTextInfo
|
||||
}
|
||||
|
||||
export interface AudioPlayInfoRepository {
|
||||
@@ -143,19 +119,23 @@ const normalizeBaseUrl = (baseUrl: string) => {
|
||||
}
|
||||
|
||||
const resolveAudioApiBaseUrl = () => {
|
||||
const baseUrl = normalizeBaseUrl(dataSourceConfig.audioApiBaseUrl)
|
||||
const baseUrl = normalizeBaseUrl(dataSourceConfig.apiBaseUrl)
|
||||
return baseUrl.endsWith('/app-api') ? baseUrl : `${baseUrl}/app-api`
|
||||
}
|
||||
|
||||
const audioKey = ({ targetType, targetId, lang }: Required<AudioPlayInfoRequest>) => (
|
||||
type RequiredAudioPlayInfoRequest = Required<Omit<AudioPlayInfoRequest, 'refresh'>>
|
||||
type RequiredGuideStopInfoRequest = Required<GuideStopInfoRequest>
|
||||
type RequiredAudioTextInfoRequest = Required<AudioTextInfoRequest>
|
||||
|
||||
const audioKey = ({ targetType, targetId, lang }: RequiredAudioPlayInfoRequest) => (
|
||||
`${targetType}:${targetId}:${lang}`
|
||||
)
|
||||
|
||||
const stopInfoKey = ({ targetType, targetId, lang }: Required<GuideStopInfoRequest>) => (
|
||||
const stopInfoKey = ({ targetType, targetId, lang }: RequiredGuideStopInfoRequest) => (
|
||||
`${targetType}:${targetId}:${lang}`
|
||||
)
|
||||
|
||||
const audioTextKey = ({ targetType, targetId, lang }: Required<AudioTextInfoRequest>) => (
|
||||
const audioTextKey = ({ targetType, targetId, lang }: RequiredAudioTextInfoRequest) => (
|
||||
`${targetType}:${targetId}:${lang}`
|
||||
)
|
||||
|
||||
@@ -190,7 +170,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
||||
throw new Error('讲解展示信息接口暂不可用')
|
||||
}
|
||||
|
||||
const normalizedRequest: Required<GuideStopInfoRequest> = {
|
||||
const normalizedRequest: RequiredGuideStopInfoRequest = {
|
||||
targetType: request.targetType,
|
||||
targetId: request.targetId,
|
||||
lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
|
||||
@@ -221,7 +201,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
||||
}
|
||||
|
||||
async getPlayInfo(request: AudioPlayInfoRequest): Promise<AudioPlayInfo> {
|
||||
const normalizedRequest: Required<AudioPlayInfoRequest> = {
|
||||
const normalizedRequest: RequiredAudioPlayInfoRequest = {
|
||||
targetType: request.targetType,
|
||||
targetId: request.targetId,
|
||||
lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
|
||||
@@ -233,13 +213,15 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
||||
targetType: normalizedRequest.targetType,
|
||||
targetId: normalizedRequest.targetId,
|
||||
lang: normalizedRequest.lang,
|
||||
hasText: false,
|
||||
fallback: false,
|
||||
reason: 'SERVICE_ERROR'
|
||||
}
|
||||
}
|
||||
|
||||
const cached = this.cache.get(key)
|
||||
|
||||
if (cached && !isExpired(cached.expiresAt)) {
|
||||
if (!request.refresh && cached && !isExpired(cached.expiresAt)) {
|
||||
return cached
|
||||
}
|
||||
|
||||
@@ -256,17 +238,16 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
||||
throw new Error(response.msg || '语音播放解析失败')
|
||||
}
|
||||
|
||||
response.data.playUrl = normalizeSameOriginPublicUrl(response.data.playUrl)
|
||||
response.data.subtitleUrl = normalizeSameOriginPublicUrl(response.data.subtitleUrl)
|
||||
const playInfo = toGuideAudioPlayInfo(response.data, normalizedRequest)
|
||||
|
||||
if (response.data.playable || response.data.reason !== 'TARGET_NOT_FOUND') {
|
||||
this.cache.set(key, response.data)
|
||||
if (playInfo.playable || playInfo.reason !== 'TARGET_NOT_FOUND') {
|
||||
this.cache.set(key, playInfo)
|
||||
}
|
||||
return response.data
|
||||
return playInfo
|
||||
}
|
||||
|
||||
async getTextInfo(request: AudioTextInfoRequest): Promise<AudioTextInfo> {
|
||||
const normalizedRequest: Required<AudioTextInfoRequest> = {
|
||||
const normalizedRequest: RequiredAudioTextInfoRequest = {
|
||||
targetType: request.targetType,
|
||||
targetId: request.targetId,
|
||||
lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
|
||||
@@ -301,11 +282,13 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
||||
throw new Error(response.msg || '讲解词正文解析失败')
|
||||
}
|
||||
|
||||
if (response.data.available) {
|
||||
this.textCache.set(key, response.data)
|
||||
const textInfo = toGuideAudioTextInfo(response.data, normalizedRequest)
|
||||
|
||||
if (textInfo.available) {
|
||||
this.textCache.set(key, textInfo)
|
||||
}
|
||||
|
||||
return response.data
|
||||
return textInfo
|
||||
}
|
||||
|
||||
clearCache(lang?: AudioLanguage) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type MuseumContentRepository
|
||||
} from '@/repositories/MuseumContentRepository'
|
||||
import {
|
||||
staticMuseumContentProvider,
|
||||
explainContentProvider,
|
||||
type ExplainContentProvider
|
||||
} from '@/data/providers/staticMuseumContentProvider'
|
||||
|
||||
@@ -38,7 +38,7 @@ export class DefaultExplainRepository implements ExplainRepository {
|
||||
constructor(
|
||||
private readonly content: MuseumContentRepository = museumContentRepository,
|
||||
private readonly media: MediaRepository = mediaRepository,
|
||||
private readonly explainContent: ExplainContentProvider = staticMuseumContentProvider
|
||||
private readonly explainContent: ExplainContentProvider = explainContentProvider
|
||||
) {}
|
||||
|
||||
listExplainExhibits() {
|
||||
@@ -54,11 +54,12 @@ export class DefaultExplainRepository implements ExplainRepository {
|
||||
}
|
||||
|
||||
listHalls() {
|
||||
return this.content.listHalls()
|
||||
return this.explainContent.listHalls()
|
||||
}
|
||||
|
||||
getHallById(id: string) {
|
||||
return this.content.getHallById(id)
|
||||
async getHallById(id: string) {
|
||||
const halls = await this.explainContent.listHalls()
|
||||
return halls.find((hall) => hall.id === id || hall.poiId === id) || null
|
||||
}
|
||||
|
||||
async listGuideStopsByHall(hallId: string) {
|
||||
|
||||
@@ -1,39 +1,8 @@
|
||||
import {
|
||||
dataSourceConfig
|
||||
} from '@/config/dataSource'
|
||||
import type {
|
||||
AudioPlayTargetType
|
||||
} from '@/domain/museum'
|
||||
import type {
|
||||
AudioLanguage,
|
||||
AudioPlayInfoRequest
|
||||
} from '@/repositories/AudioPlayInfoRepository'
|
||||
|
||||
interface CommonResult<T> {
|
||||
code: number
|
||||
msg?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
interface PublishedGuideContent {
|
||||
id?: string | number | null
|
||||
title?: string | null
|
||||
targetType?: string | null
|
||||
targetId?: string | number | null
|
||||
}
|
||||
|
||||
interface PublishedExhibitAudioSummary {
|
||||
id?: string | number | null
|
||||
name?: string | null
|
||||
hasAudio?: boolean
|
||||
supportedLanguages?: string[]
|
||||
audioStatus?: string | null
|
||||
playTargetType?: string | null
|
||||
playTargetId?: string | number | null
|
||||
audioDuration?: number | null
|
||||
guideContents?: PublishedGuideContent[]
|
||||
}
|
||||
|
||||
export interface PublishedExhibitAudioResolution {
|
||||
targets: AudioPlayInfoRequest[]
|
||||
}
|
||||
@@ -42,120 +11,10 @@ export interface PublishedExhibitAudioRepository {
|
||||
resolveByExhibitName(exhibitName: string, lang?: AudioLanguage): Promise<PublishedExhibitAudioResolution | null>
|
||||
}
|
||||
|
||||
const stringifyId = (value: string | number | null | undefined) => (
|
||||
value === null || typeof value === 'undefined' ? '' : String(value)
|
||||
)
|
||||
|
||||
const normalizeBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, '')
|
||||
|
||||
const resolveAppApiBaseUrl = () => {
|
||||
const baseUrl = normalizeBaseUrl(dataSourceConfig.audioApiBaseUrl || dataSourceConfig.apiBaseUrl)
|
||||
return baseUrl.endsWith('/app-api') ? baseUrl : `${baseUrl}/app-api`
|
||||
}
|
||||
|
||||
const parseJsonPayload = <T>(payload: unknown): T => {
|
||||
if (typeof payload === 'string') {
|
||||
return JSON.parse(payload) as T
|
||||
}
|
||||
|
||||
return payload as T
|
||||
}
|
||||
|
||||
const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url,
|
||||
method: 'GET',
|
||||
timeout: 5000,
|
||||
success: (response) => {
|
||||
const statusCode = Number(response.statusCode || 0)
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
reject(new Error(`展品音频发布接口请求失败: ${statusCode}`))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(parseJsonPayload<T>(response.data))
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
|
||||
const normalizeAudioTargetType = (targetType: string | null | undefined): AudioPlayTargetType | null => {
|
||||
const normalizedType = targetType?.toUpperCase()
|
||||
return normalizedType === 'STOP' || normalizedType === 'ITEM'
|
||||
? normalizedType
|
||||
: null
|
||||
}
|
||||
|
||||
const addTarget = (
|
||||
targets: AudioPlayInfoRequest[],
|
||||
targetType: string | null | undefined,
|
||||
targetId: string | number | null | undefined,
|
||||
lang: AudioLanguage
|
||||
) => {
|
||||
const normalizedType = normalizeAudioTargetType(targetType)
|
||||
const normalizedId = stringifyId(targetId)
|
||||
if (!normalizedType || !normalizedId) return
|
||||
|
||||
const exists = targets.some((target) => (
|
||||
target.targetType === normalizedType && target.targetId === normalizedId && target.lang === lang
|
||||
))
|
||||
if (exists) return
|
||||
|
||||
targets.push({
|
||||
targetType: normalizedType,
|
||||
targetId: normalizedId,
|
||||
lang
|
||||
})
|
||||
}
|
||||
|
||||
const buildResolution = (
|
||||
exhibit: PublishedExhibitAudioSummary,
|
||||
lang: AudioLanguage
|
||||
): PublishedExhibitAudioResolution => {
|
||||
const targets: AudioPlayInfoRequest[] = []
|
||||
|
||||
addTarget(targets, exhibit.playTargetType, exhibit.playTargetId, lang)
|
||||
exhibit.guideContents?.forEach((guide) => {
|
||||
addTarget(targets, guide.targetType, guide.targetId, lang)
|
||||
})
|
||||
addTarget(targets, 'ITEM', exhibit.id, lang)
|
||||
|
||||
return { targets }
|
||||
}
|
||||
|
||||
export class DefaultPublishedExhibitAudioRepository implements PublishedExhibitAudioRepository {
|
||||
async resolveByExhibitName(exhibitName: string, lang: AudioLanguage = dataSourceConfig.audioLanguage as AudioLanguage) {
|
||||
const keyword = exhibitName.trim()
|
||||
if (!keyword) return null
|
||||
|
||||
const searchUrl = `${resolveAppApiBaseUrl()}/gis/exhibit/search?keyword=${encodeURIComponent(keyword)}`
|
||||
const searchResponse = await requestJson<CommonResult<PublishedExhibitAudioSummary[]>>(searchUrl)
|
||||
if (searchResponse.code !== 0 || !searchResponse.data?.length) return null
|
||||
|
||||
const exactMatches = searchResponse.data.filter((item) => item.name === keyword)
|
||||
const matched = (
|
||||
exactMatches.find((item) => item.hasAudio && item.supportedLanguages?.includes(lang))
|
||||
|| exactMatches.find((item) => item.hasAudio)
|
||||
|| exactMatches[0]
|
||||
|| searchResponse.data[0]
|
||||
)
|
||||
const exhibitId = stringifyId(matched.id)
|
||||
let detail = matched
|
||||
|
||||
if (exhibitId) {
|
||||
const detailUrl = `${resolveAppApiBaseUrl()}/gis/exhibit/get?id=${encodeURIComponent(exhibitId)}`
|
||||
const detailResponse = await requestJson<CommonResult<PublishedExhibitAudioSummary>>(detailUrl)
|
||||
if (detailResponse.code === 0 && detailResponse.data) {
|
||||
detail = detailResponse.data
|
||||
}
|
||||
}
|
||||
|
||||
return buildResolution(detail, lang)
|
||||
export class DisabledPublishedExhibitAudioRepository implements PublishedExhibitAudioRepository {
|
||||
async resolveByExhibitName() {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const publishedExhibitAudioRepository = new DefaultPublishedExhibitAudioRepository()
|
||||
export const publishedExhibitAudioRepository = new DisabledPublishedExhibitAudioRepository()
|
||||
|
||||
@@ -12,10 +12,6 @@ import {
|
||||
explainRepository,
|
||||
type ExplainRepository
|
||||
} from '@/repositories/ExplainRepository'
|
||||
import {
|
||||
mediaRepository,
|
||||
type MediaRepository
|
||||
} from '@/repositories/MediaRepository'
|
||||
import {
|
||||
audioPlayInfoRepository,
|
||||
audioReasonToText,
|
||||
@@ -28,10 +24,6 @@ import {
|
||||
import type {
|
||||
GuideStopInfo
|
||||
} from '@/data/adapters/guideStopInfoAdapter'
|
||||
import {
|
||||
publishedExhibitAudioRepository,
|
||||
type PublishedExhibitAudioRepository
|
||||
} from '@/repositories/PublishedExhibitAudioRepository'
|
||||
import {
|
||||
dataSourceConfig
|
||||
} from '@/config/dataSource'
|
||||
@@ -75,9 +67,7 @@ export interface ExplainHallSummary {
|
||||
export class ExplainUseCase {
|
||||
constructor(
|
||||
private readonly explain: ExplainRepository = explainRepository,
|
||||
private readonly media: MediaRepository = mediaRepository,
|
||||
private readonly audioPlayInfo: AudioPlayInfoRepository = audioPlayInfoRepository,
|
||||
private readonly publishedAudio: PublishedExhibitAudioRepository = publishedExhibitAudioRepository
|
||||
private readonly audioPlayInfo: AudioPlayInfoRepository = audioPlayInfoRepository
|
||||
) {}
|
||||
|
||||
private applyTrackAudioState(exhibit: MuseumExhibit, track?: ExplainTrack | null): MuseumExhibit {
|
||||
@@ -134,7 +124,7 @@ export class ExplainUseCase {
|
||||
fallback?: MuseumExhibit | null
|
||||
): MuseumExhibit {
|
||||
const linkedPrimary = stopInfo.linkedExhibits[0]
|
||||
const coverImage = stopInfo.coverImageUrl || fallback?.image
|
||||
const coverImage = stopInfo.coverImageUrl || (stopInfo.imageStatus === 'READY' ? fallback?.image : undefined)
|
||||
const description = stopInfo.description || fallback?.description || '该讲解暂无简介。'
|
||||
const audioTarget = this.resolveStopInfoAudioTarget(stopInfo)
|
||||
const audioAvailable = stopInfo.audioStatus === 'READY'
|
||||
@@ -175,6 +165,8 @@ export class ExplainUseCase {
|
||||
imageStatus: stopInfo.imageStatus,
|
||||
imageSource: stopInfo.imageSource,
|
||||
galleryUrls: stopInfo.galleryUrls,
|
||||
linkedExhibitCount: stopInfo.linkedExhibitCount,
|
||||
isSharedStop: stopInfo.isSharedStop,
|
||||
linkedExhibits: stopInfo.linkedExhibits,
|
||||
stopInfoAvailable: stopInfo.available,
|
||||
stopInfoReason: stopInfo.reason,
|
||||
@@ -211,15 +203,10 @@ export class ExplainUseCase {
|
||||
|
||||
private applyPlayInfo(
|
||||
exhibit: MuseumExhibit,
|
||||
playInfo: AudioPlayInfo,
|
||||
options: { preserveStaticFallback?: boolean } = {}
|
||||
playInfo: AudioPlayInfo
|
||||
): MuseumExhibit {
|
||||
const apiPlayable = playInfo.playable === true && Boolean(playInfo.playUrl)
|
||||
const canUseStaticAudioFallback = !playInfo.reason || playInfo.reason === 'TARGET_NOT_FOUND'
|
||||
const fallbackAudioUrl = options.preserveStaticFallback && canUseStaticAudioFallback && exhibit.audioUrl
|
||||
? exhibit.audioUrl
|
||||
: undefined
|
||||
const nextAudioUrl = apiPlayable ? playInfo.playUrl || undefined : fallbackAudioUrl
|
||||
const nextAudioUrl = apiPlayable ? playInfo.playUrl || undefined : undefined
|
||||
const nextAudioDuration = typeof playInfo.duration === 'number'
|
||||
? playInfo.duration
|
||||
: exhibit.audioDuration
|
||||
@@ -234,7 +221,8 @@ export class ExplainUseCase {
|
||||
audioHasText: playInfo.hasText === true || exhibit.audioHasText,
|
||||
audioNarrationTier: playInfo.narrationTier || exhibit.audioNarrationTier,
|
||||
audioUnavailableReason: apiPlayable ? undefined : audioReasonToText(playInfo.reason),
|
||||
audioAvailable: apiPlayable || Boolean(fallbackAudioUrl)
|
||||
audioAvailable: apiPlayable,
|
||||
audioStatus: apiPlayable ? 'READY' : 'MISSING'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,7 +246,6 @@ export class ExplainUseCase {
|
||||
exhibit: MuseumExhibit,
|
||||
options: {
|
||||
includeText?: boolean
|
||||
preserveStaticFallback?: boolean
|
||||
track?: ExplainTrack | null
|
||||
} = {}
|
||||
): Promise<MuseumExhibit> {
|
||||
@@ -268,9 +255,7 @@ export class ExplainUseCase {
|
||||
|
||||
try {
|
||||
const playInfo = await this.audioPlayInfo.getPlayInfo({ targetType, targetId })
|
||||
let nextExhibit = this.applyPlayInfo(baseExhibit, playInfo, {
|
||||
preserveStaticFallback: options.preserveStaticFallback
|
||||
})
|
||||
let nextExhibit = this.applyPlayInfo(baseExhibit, playInfo)
|
||||
|
||||
if (options.includeText && playInfo.hasText) {
|
||||
try {
|
||||
@@ -346,7 +331,6 @@ export class ExplainUseCase {
|
||||
|
||||
return this.enrichExhibitAudio(exhibit, {
|
||||
includeText: options.includeText,
|
||||
preserveStaticFallback: true,
|
||||
track
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -433,7 +417,18 @@ export class ExplainUseCase {
|
||||
|
||||
async loadExplainHallSummaries(hallIds: string[]): Promise<Record<string, ExplainHallSummary>> {
|
||||
const uniqueHallIds = Array.from(new Set(hallIds.map((id) => id.trim()).filter(Boolean)))
|
||||
const halls = await this.listHalls().catch(() => [])
|
||||
const summariesFromHallList = new Map(halls.map((hall) => [hall.id, hall]))
|
||||
const entries = await Promise.all(uniqueHallIds.map(async (hallId) => {
|
||||
const hall = summariesFromHallList.get(hallId)
|
||||
if (hall && (typeof hall.outlineCount === 'number' || typeof hall.stopCount === 'number')) {
|
||||
return [hallId, {
|
||||
hallId,
|
||||
businessUnitCount: hall.outlineCount || 0,
|
||||
guideStopCount: hall.stopCount || 0
|
||||
}] as const
|
||||
}
|
||||
|
||||
try {
|
||||
const units = await this.loadTemporaryBusinessUnitsByHall(hallId)
|
||||
const guideStopCount = units.reduce((total, unit) => total + unit.guideStopCount, 0)
|
||||
@@ -508,15 +503,6 @@ export class ExplainUseCase {
|
||||
const exhibit = detailExhibit || summaryExhibit
|
||||
if (!exhibit) return null
|
||||
|
||||
const fallbackMedia = await (track?.mediaId
|
||||
? this.media.getMediaById(track.mediaId)
|
||||
: exhibit.guideContentId
|
||||
? this.media.getMediaById(`media-${exhibit.guideContentId}`)
|
||||
: Promise.resolve(null)
|
||||
).catch((error) => {
|
||||
console.warn('静态讲解媒体读取失败,将继续尝试播放接口:', error)
|
||||
return null
|
||||
})
|
||||
const targetType: AudioPlayTargetType = track?.playTargetType
|
||||
|| exhibit.playTargetType
|
||||
|| 'ITEM'
|
||||
@@ -533,7 +519,7 @@ export class ExplainUseCase {
|
||||
id: `play-${playInfo.audioId || `${requestTargetType}-${requestTargetId}`}`,
|
||||
type: 'audio',
|
||||
url: playInfo.playUrl || undefined,
|
||||
duration: typeof playInfo.duration === 'number' ? playInfo.duration : fallbackMedia?.duration,
|
||||
duration: typeof playInfo.duration === 'number' ? playInfo.duration : undefined,
|
||||
language: playInfo.lang,
|
||||
available: true
|
||||
}
|
||||
@@ -547,49 +533,6 @@ export class ExplainUseCase {
|
||||
}
|
||||
}
|
||||
|
||||
const toDirectMediaSelection = (playInfo: AudioPlayInfo): ExplainAudioSelection | null => {
|
||||
const directMedia = fallbackMedia?.available && fallbackMedia.url
|
||||
? fallbackMedia
|
||||
: exhibit.audioUrl
|
||||
? {
|
||||
id: `media-${exhibit.guideContentId || exhibit.id}`,
|
||||
type: 'audio' as const,
|
||||
url: exhibit.audioUrl,
|
||||
duration: exhibit.audioDuration,
|
||||
language: playInfo.lang,
|
||||
available: true
|
||||
}
|
||||
: null
|
||||
|
||||
if (!directMedia?.url) return null
|
||||
|
||||
return {
|
||||
exhibit,
|
||||
track,
|
||||
media: directMedia,
|
||||
playable: true,
|
||||
playInfo: {
|
||||
...playInfo,
|
||||
playable: true,
|
||||
audioId: directMedia.id,
|
||||
title: track?.title || exhibit.guideTitle || exhibit.name,
|
||||
duration: directMedia.duration || playInfo.duration,
|
||||
playUrl: directMedia.url,
|
||||
fallback: true,
|
||||
fallbackReason: playInfo.reason || 'DIRECT_MEDIA_FALLBACK'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const directMediaSelection = toDirectMediaSelection({
|
||||
playable: false,
|
||||
targetType,
|
||||
targetId,
|
||||
lang: dataSourceConfig.audioLanguage as AudioPlayInfo['lang'],
|
||||
reason: 'DIRECT_MEDIA_FALLBACK'
|
||||
})
|
||||
if (directMediaSelection) return directMediaSelection
|
||||
|
||||
try {
|
||||
const initialPlayInfo = await this.audioPlayInfo.getPlayInfo({ targetType, targetId })
|
||||
|
||||
@@ -597,20 +540,6 @@ export class ExplainUseCase {
|
||||
return toPlayableSelection(initialPlayInfo, targetType, targetId)
|
||||
}
|
||||
|
||||
if (initialPlayInfo.reason === 'TARGET_NOT_FOUND') {
|
||||
const publishedResolution = await this.publishedAudio.resolveByExhibitName(exhibit.name, initialPlayInfo.lang)
|
||||
if (publishedResolution?.targets.length) {
|
||||
for (const candidate of publishedResolution.targets) {
|
||||
if (candidate.targetType === targetType && candidate.targetId === targetId) continue
|
||||
|
||||
const playInfo = await this.audioPlayInfo.getPlayInfo(candidate)
|
||||
if (playInfo.playable && playInfo.playUrl) {
|
||||
return toPlayableSelection(playInfo, candidate.targetType, candidate.targetId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
exhibit,
|
||||
track,
|
||||
@@ -626,11 +555,10 @@ export class ExplainUseCase {
|
||||
targetType,
|
||||
targetId,
|
||||
lang: dataSourceConfig.audioLanguage as AudioPlayInfo['lang'],
|
||||
hasText: false,
|
||||
fallback: false,
|
||||
reason: 'SERVICE_ERROR'
|
||||
}
|
||||
const directMediaSelection = toDirectMediaSelection(serviceErrorPlayInfo)
|
||||
if (directMediaSelection) return directMediaSelection
|
||||
|
||||
return {
|
||||
exhibit,
|
||||
track,
|
||||
@@ -642,7 +570,10 @@ export class ExplainUseCase {
|
||||
}
|
||||
}
|
||||
|
||||
async selectAudioForExplainDetail(exhibit: MuseumExhibit): Promise<ExplainAudioSelection> {
|
||||
async selectAudioForExplainDetail(
|
||||
exhibit: MuseumExhibit,
|
||||
options: { refreshPlayInfo?: boolean } = {}
|
||||
): Promise<ExplainAudioSelection> {
|
||||
const targetType = exhibit.playTargetType || 'ITEM'
|
||||
const targetId = exhibit.playTargetId || exhibit.id
|
||||
|
||||
@@ -660,7 +591,8 @@ export class ExplainUseCase {
|
||||
const playInfo = await this.audioPlayInfo.getPlayInfo({
|
||||
targetType,
|
||||
targetId,
|
||||
lang: (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage)
|
||||
lang: (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage),
|
||||
refresh: options.refreshPlayInfo === true
|
||||
})
|
||||
|
||||
if (!playInfo.playable || !playInfo.playUrl) {
|
||||
|
||||
@@ -61,6 +61,9 @@ export interface ExplainDetailPageViewModel {
|
||||
statusText?: string
|
||||
}
|
||||
imageStatus?: string
|
||||
linkedExhibitCount?: number
|
||||
isSharedStop?: boolean
|
||||
linkedExhibitStatusText?: string
|
||||
linkedExhibits?: Array<{
|
||||
id: string
|
||||
name: string
|
||||
@@ -128,9 +131,9 @@ const languageLabelFor = (language?: string) => {
|
||||
}
|
||||
|
||||
const isAudioReady = (exhibit: MuseumExhibit) => (
|
||||
exhibit.audioStatus === 'READY'
|
||||
|| exhibit.audioAvailable === true
|
||||
|| Boolean(exhibit.audioUrl?.trim())
|
||||
exhibit.audioStatus
|
||||
? exhibit.audioStatus === 'READY'
|
||||
: exhibit.audioAvailable === true || Boolean(exhibit.audioUrl?.trim())
|
||||
)
|
||||
|
||||
export const toExplainCardViewModel = (exhibit: MuseumExhibit): ExplainCardViewModel => {
|
||||
@@ -153,7 +156,7 @@ export const toExplainCardViewModel = (exhibit: MuseumExhibit): ExplainCardViewM
|
||||
audioDuration: exhibit.audioDuration,
|
||||
languageLabel: languageLabelFor(exhibit.audioLanguage),
|
||||
audioStatus,
|
||||
audioStatusText: hasPlayableAudio ? '可播放' : '图文讲解',
|
||||
audioStatusText: hasPlayableAudio ? '可播放' : exhibit.audioUnavailableReason || '当前语言暂无语音讲解',
|
||||
playTargetType: exhibit.playTargetType,
|
||||
playTargetId: exhibit.playTargetId,
|
||||
badges: buildBadges(exhibit),
|
||||
@@ -168,6 +171,14 @@ export const toExplainExhibitViewModel = (exhibit: MuseumExhibit): ExplainExhibi
|
||||
|
||||
export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDetailPageViewModel => {
|
||||
const hasPlayableAudio = isAudioReady(exhibit)
|
||||
const linkedExhibitCount = typeof exhibit.linkedExhibitCount === 'number'
|
||||
? exhibit.linkedExhibitCount
|
||||
: exhibit.linkedExhibits?.length
|
||||
const linkedExhibitStatusText = linkedExhibitCount
|
||||
? exhibit.isSharedStop
|
||||
? `${linkedExhibitCount} 件展品共用此讲解点`
|
||||
: `关联 ${linkedExhibitCount} 件展品`
|
||||
: undefined
|
||||
const metadataLines = [
|
||||
exhibit.size ? `展品编号:${exhibit.size}` : '',
|
||||
exhibit.year ? `年代:${exhibit.year}` : '',
|
||||
@@ -204,9 +215,12 @@ export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDet
|
||||
playTargetType: exhibit.playTargetType,
|
||||
playTargetId: exhibit.playTargetId,
|
||||
hasText: exhibit.audioHasText,
|
||||
statusText: hasPlayableAudio ? '可播放' : '图文讲解'
|
||||
statusText: hasPlayableAudio ? '可播放' : '当前语言暂无语音讲解'
|
||||
},
|
||||
imageStatus: exhibit.imageStatus,
|
||||
linkedExhibitCount,
|
||||
isSharedStop: exhibit.isSharedStop,
|
||||
linkedExhibitStatusText,
|
||||
linkedExhibits: exhibit.linkedExhibits,
|
||||
chapters: metadataLines.length
|
||||
? metadataLines.map((line, index) => ({
|
||||
|
||||
Reference in New Issue
Block a user