feat: 临时改造讲解页方案 B 链路
接入展厅列表、展厅讲解点分组生成临时业务单元,并迁移讲解详情播放正文到新接口链路。
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
<text class="header-back-icon">‹</text>
|
<text class="header-back-icon">‹</text>
|
||||||
<text class="header-back-text">返回</text>
|
<text class="header-back-text">返回</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="header-title">讲解</text>
|
<text class="header-title">{{ headerTitle }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<scroll-view
|
<scroll-view
|
||||||
@@ -14,8 +14,8 @@
|
|||||||
:show-scrollbar="false"
|
:show-scrollbar="false"
|
||||||
>
|
>
|
||||||
<view v-if="loading" class="state-block">
|
<view v-if="loading" class="state-block">
|
||||||
<text class="state-title">正在加载展厅讲解</text>
|
<text class="state-title">{{ loadingTitle }}</text>
|
||||||
<text class="state-desc">稍后将展示可选择的展厅。</text>
|
<text class="state-desc">{{ loadingDescription }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-else-if="error" class="state-block error">
|
<view v-else-if="error" class="state-block error">
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<view v-if="filteredHalls.length" class="hall-list">
|
<view v-if="stage === 'hall' && filteredHalls.length" class="hall-list">
|
||||||
<view
|
<view
|
||||||
v-for="hall in filteredHalls"
|
v-for="hall in filteredHalls"
|
||||||
:key="hall.id"
|
:key="hall.id"
|
||||||
@@ -55,9 +55,66 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="stage === 'unit' && businessUnits.length" class="hall-list">
|
||||||
|
<view
|
||||||
|
v-for="unit in businessUnits"
|
||||||
|
:key="unit.id"
|
||||||
|
class="hall-card unit-card"
|
||||||
|
@tap="handleBusinessUnitClick(unit.id)"
|
||||||
|
>
|
||||||
|
<view class="unit-mark">
|
||||||
|
<text class="unit-mark-text">{{ unit.name.slice(0, 1) || '讲' }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="hall-main">
|
||||||
|
<text class="hall-name">{{ unit.name }}</text>
|
||||||
|
<view class="hall-meta-row">
|
||||||
|
<view class="floor-badge">
|
||||||
|
<text class="floor-badge-text">业务单元</text>
|
||||||
|
</view>
|
||||||
|
<text class="hall-meta-text">{{ unit.guideStopCount }} 个讲解点</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text class="hall-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="stage === 'stop' && guideStops.length" class="hall-list">
|
||||||
|
<view
|
||||||
|
v-for="stop in guideStops"
|
||||||
|
:key="stop.id"
|
||||||
|
class="hall-card stop-card"
|
||||||
|
@tap="handleGuideStopClick(stop.id)"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
v-if="stop.coverImageUrl"
|
||||||
|
class="hall-thumb"
|
||||||
|
:src="stop.coverImageUrl"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view v-else class="hall-thumb placeholder">
|
||||||
|
<text class="hall-thumb-text">{{ hallIconText(stop.name) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="hall-main">
|
||||||
|
<text class="hall-name">{{ stop.name }}</text>
|
||||||
|
<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>
|
||||||
|
</view>
|
||||||
|
<text class="hall-meta-text">{{ stop.floorId || selectedHallName || '楼层待补充' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text class="hall-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view v-else class="state-block empty">
|
<view v-else class="state-block empty">
|
||||||
<text class="state-title">未找到相关展厅</text>
|
<text class="state-title">{{ emptyTitle }}</text>
|
||||||
<text class="state-desc">暂无可选择的展厅讲解。</text>
|
<text class="state-desc">{{ emptyDescription }}</text>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
@@ -76,18 +133,50 @@ export interface ExplainHallSelectItem {
|
|||||||
searchText: string
|
searchText: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExplainBusinessUnitSelectItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
hallId: string
|
||||||
|
guideStopCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExplainGuideStopSelectItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
hallId?: string
|
||||||
|
hallName?: string
|
||||||
|
floorId?: string
|
||||||
|
coverImageUrl?: string
|
||||||
|
description?: string
|
||||||
|
hasAudio?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExplainSelectStage = 'hall' | 'unit' | 'stop'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
halls?: ExplainHallSelectItem[]
|
halls?: ExplainHallSelectItem[]
|
||||||
|
businessUnits?: ExplainBusinessUnitSelectItem[]
|
||||||
|
guideStops?: ExplainGuideStopSelectItem[]
|
||||||
|
stage?: ExplainSelectStage
|
||||||
|
selectedHallName?: string
|
||||||
|
selectedBusinessUnitName?: string
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
error?: string
|
error?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
halls: () => [],
|
halls: () => [],
|
||||||
|
businessUnits: () => [],
|
||||||
|
guideStops: () => [],
|
||||||
|
stage: 'hall',
|
||||||
|
selectedHallName: '',
|
||||||
|
selectedBusinessUnitName: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
error: ''
|
error: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
hallClick: [hallId: string]
|
hallClick: [hallId: string]
|
||||||
|
businessUnitClick: [unitId: string]
|
||||||
|
guideStopClick: [stopId: string]
|
||||||
back: []
|
back: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -106,6 +195,31 @@ const hallIconMap: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filteredHalls = computed(() => props.halls)
|
const filteredHalls = computed(() => props.halls)
|
||||||
|
const headerTitle = computed(() => {
|
||||||
|
if (props.stage === 'unit') return props.selectedHallName || '选择业务单元'
|
||||||
|
if (props.stage === 'stop') return props.selectedBusinessUnitName || '选择讲解点'
|
||||||
|
return '讲解'
|
||||||
|
})
|
||||||
|
const loadingTitle = computed(() => {
|
||||||
|
if (props.stage === 'unit') return '正在加载业务单元'
|
||||||
|
if (props.stage === 'stop') return '正在加载讲解点'
|
||||||
|
return '正在加载展厅讲解'
|
||||||
|
})
|
||||||
|
const loadingDescription = computed(() => {
|
||||||
|
if (props.stage === 'unit') return '正在按讲解点所属 outline 分组生成临时业务单元。'
|
||||||
|
if (props.stage === 'stop') return '稍后将展示该业务单元下的讲解点。'
|
||||||
|
return '稍后将展示可选择的展厅。'
|
||||||
|
})
|
||||||
|
const emptyTitle = computed(() => {
|
||||||
|
if (props.stage === 'unit') return '该展厅暂无已标定讲解点'
|
||||||
|
if (props.stage === 'stop') return '该业务单元暂无讲解点'
|
||||||
|
return '未找到相关展厅'
|
||||||
|
})
|
||||||
|
const emptyDescription = computed(() => {
|
||||||
|
if (props.stage === 'unit') return '方案 B 只展示已在地图上标定的讲解点。'
|
||||||
|
if (props.stage === 'stop') return '可返回选择其他业务单元。'
|
||||||
|
return '暂无可选择的展厅讲解。'
|
||||||
|
})
|
||||||
|
|
||||||
const hallIconUrl = (hall: ExplainHallSelectItem) => {
|
const hallIconUrl = (hall: ExplainHallSelectItem) => {
|
||||||
return hallIconMap[hall.name.trim()] || hall.image || ''
|
return hallIconMap[hall.name.trim()] || hall.image || ''
|
||||||
@@ -117,6 +231,14 @@ const handleHallClick = (hallId: string) => {
|
|||||||
emit('hallClick', hallId)
|
emit('hallClick', hallId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleBusinessUnitClick = (unitId: string) => {
|
||||||
|
emit('businessUnitClick', unitId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGuideStopClick = (stopId: string) => {
|
||||||
|
emit('guideStopClick', stopId)
|
||||||
|
}
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
emit('back')
|
emit('back')
|
||||||
}
|
}
|
||||||
@@ -165,10 +287,11 @@ const handleBack = () => {
|
|||||||
left: 12px;
|
left: 12px;
|
||||||
top: 0;
|
top: 0;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
min-width: 74px;
|
width: 82px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-back-icon {
|
.header-back-icon {
|
||||||
@@ -185,10 +308,17 @@ const handleBack = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-title {
|
.header-title {
|
||||||
|
max-width: calc(100% - 188px);
|
||||||
|
padding: 0 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 25px;
|
line-height: 25px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #151713;
|
color: #151713;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hall-list {
|
.hall-list {
|
||||||
@@ -238,11 +368,40 @@ const handleBack = () => {
|
|||||||
color: #83927a;
|
color: #83927a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.unit-mark {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 68px;
|
||||||
|
height: 68px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #151713;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-mark-text {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 30px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--museum-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.hall-main {
|
.hall-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stop-desc {
|
||||||
|
display: block;
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: #68725d;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.hall-name {
|
.hall-name {
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
AudioPlayTargetType,
|
AudioPlayTargetType,
|
||||||
|
ExplainBusinessUnit,
|
||||||
|
ExplainGuideStop,
|
||||||
ExplainTrack,
|
ExplainTrack,
|
||||||
MediaAsset,
|
MediaAsset,
|
||||||
MuseumExhibit,
|
MuseumExhibit,
|
||||||
@@ -56,6 +58,35 @@ export interface BackendExhibit {
|
|||||||
guideContents?: BackendGuideContent[] | null
|
guideContents?: BackendGuideContent[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BackendHall {
|
||||||
|
id?: string | number | null
|
||||||
|
name?: string | null
|
||||||
|
hallCode?: string | null
|
||||||
|
nameEn?: string | null
|
||||||
|
subtitle?: string | null
|
||||||
|
description?: string | null
|
||||||
|
coverImageUrl?: string | null
|
||||||
|
exhibitCount?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendGuideStop {
|
||||||
|
id?: string | number | null
|
||||||
|
name?: string | null
|
||||||
|
floorId?: string | number | null
|
||||||
|
targetType?: string | null
|
||||||
|
targetId?: string | number | null
|
||||||
|
coverImageUrl?: string | null
|
||||||
|
description?: string | null
|
||||||
|
hasAudio?: boolean | null
|
||||||
|
audioUrl?: string | null
|
||||||
|
poiId?: string | number | null
|
||||||
|
outlineId?: string | number | null
|
||||||
|
outlineName?: string | null
|
||||||
|
hallId?: string | number | null
|
||||||
|
hallName?: string | null
|
||||||
|
sort?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackendExplainAdapterResult {
|
export interface BackendExplainAdapterResult {
|
||||||
exhibit: MuseumExhibit
|
exhibit: MuseumExhibit
|
||||||
track: ExplainTrack
|
track: ExplainTrack
|
||||||
@@ -180,7 +211,9 @@ export const toBackendMuseumExhibit = (
|
|||||||
audioLanguage: supportedLanguage,
|
audioLanguage: supportedLanguage,
|
||||||
audioText: options.includeDetail && guideText ? guideText : undefined,
|
audioText: options.includeDetail && guideText ? guideText : undefined,
|
||||||
audioHasText: options.includeDetail ? Boolean(guideText) : undefined,
|
audioHasText: options.includeDetail ? Boolean(guideText) : undefined,
|
||||||
audioAvailable,
|
audioAvailable: audioAvailable || metadataSuggestsAudio,
|
||||||
|
audioStatus: source.audioStatus || undefined,
|
||||||
|
supportedLanguages: source.supportedLanguages || undefined,
|
||||||
audioUnavailableReason: audioAvailable || metadataSuggestsAudio
|
audioUnavailableReason: audioAvailable || metadataSuggestsAudio
|
||||||
? undefined
|
? undefined
|
||||||
: '该讲解暂无已发布音频,当前提供图文讲解。',
|
: '该讲解暂无已发布音频,当前提供图文讲解。',
|
||||||
@@ -199,7 +232,7 @@ export const toBackendExplainTrack = (exhibit: MuseumExhibit): ExplainTrack => (
|
|||||||
coverImage: exhibit.image,
|
coverImage: exhibit.image,
|
||||||
poiId: exhibit.poiId,
|
poiId: exhibit.poiId,
|
||||||
floorId: exhibit.floorId,
|
floorId: exhibit.floorId,
|
||||||
available: Boolean(exhibit.audioUrl),
|
available: Boolean(exhibit.audioUrl) || exhibit.audioAvailable === true,
|
||||||
playTargetType: exhibit.playTargetType,
|
playTargetType: exhibit.playTargetType,
|
||||||
playTargetId: exhibit.playTargetId
|
playTargetId: exhibit.playTargetId
|
||||||
})
|
})
|
||||||
@@ -236,3 +269,78 @@ export const toBackendHall = (
|
|||||||
location: fallback?.location
|
location: fallback?.location
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const toBackendHallFromList = (source: BackendHall, 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, '展厅')
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
floorId: fallback?.floorId,
|
||||||
|
floorLabel: fallback?.floorLabel,
|
||||||
|
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,
|
||||||
|
area: fallback?.area,
|
||||||
|
poiId: fallback?.poiId,
|
||||||
|
location: fallback?.location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toBackendGuideStop = (source: BackendGuideStop): ExplainGuideStop | null => {
|
||||||
|
const id = stringifyId(source.id)
|
||||||
|
if (!id) return null
|
||||||
|
|
||||||
|
const targetType = normalizeAudioTargetType(source.targetType) || 'STOP'
|
||||||
|
const targetId = stringifyId(source.targetId) || id
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: firstText(source.name, `讲解点${id}`),
|
||||||
|
hallId: stringifyId(source.hallId) || undefined,
|
||||||
|
hallName: firstText(source.hallName) || undefined,
|
||||||
|
floorId: stringifyId(source.floorId) || undefined,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
coverImageUrl: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined,
|
||||||
|
description: firstText(source.description) || undefined,
|
||||||
|
hasAudio: source.hasAudio === true,
|
||||||
|
poiId: stringifyId(source.poiId) || undefined,
|
||||||
|
outlineId: stringifyId(source.outlineId) || undefined,
|
||||||
|
outlineName: firstText(source.outlineName) || undefined,
|
||||||
|
sort: typeof source.sort === 'number' ? source.sort : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const groupGuideStopsByOutline = (
|
||||||
|
hallId: string,
|
||||||
|
stops: ExplainGuideStop[]
|
||||||
|
): ExplainBusinessUnit[] => {
|
||||||
|
const groupMap = new Map<string, ExplainGuideStop[]>()
|
||||||
|
const nameMap = new Map<string, string>()
|
||||||
|
|
||||||
|
stops
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
|
||||||
|
.forEach((stop) => {
|
||||||
|
const key = stop.outlineId || `ungrouped-${hallId}`
|
||||||
|
const name = stop.outlineName || '其他讲解'
|
||||||
|
const items = groupMap.get(key) || []
|
||||||
|
items.push(stop)
|
||||||
|
groupMap.set(key, items)
|
||||||
|
if (!nameMap.has(key)) {
|
||||||
|
nameMap.set(key, name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(groupMap.entries()).map(([id, groupStops]) => ({
|
||||||
|
id,
|
||||||
|
name: nameMap.get(id) || '其他讲解',
|
||||||
|
hallId,
|
||||||
|
guideStopCount: groupStops.length,
|
||||||
|
stops: groupStops
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
164
src/data/adapters/guideStopInfoAdapter.ts
Normal file
164
src/data/adapters/guideStopInfoAdapter.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import type {
|
||||||
|
AudioPlayTargetType
|
||||||
|
} from '@/domain/museum'
|
||||||
|
import {
|
||||||
|
normalizeSameOriginPublicUrl
|
||||||
|
} from '@/utils/publicUrl'
|
||||||
|
|
||||||
|
export interface BackendGuideStopLinkedExhibit {
|
||||||
|
id?: string | number | null
|
||||||
|
name?: string | null
|
||||||
|
nameEn?: string | null
|
||||||
|
exhibitCode?: string | null
|
||||||
|
coverImageUrl?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendGuideStopInfo {
|
||||||
|
available?: boolean
|
||||||
|
targetType?: string | null
|
||||||
|
targetId?: string | number | null
|
||||||
|
resolvedStopId?: string | number | null
|
||||||
|
lang?: string | null
|
||||||
|
title?: string | null
|
||||||
|
description?: string | null
|
||||||
|
coverImageUrl?: string | null
|
||||||
|
galleryUrls?: string | string[] | null
|
||||||
|
imageStatus?: string | null
|
||||||
|
imageSource?: string | null
|
||||||
|
linkedExhibits?: BackendGuideStopLinkedExhibit[] | null
|
||||||
|
playTargetType?: string | null
|
||||||
|
playTargetId?: string | number | null
|
||||||
|
hasAudio?: boolean
|
||||||
|
hasText?: boolean
|
||||||
|
supportedLanguages?: string[] | null
|
||||||
|
audioStatus?: string | null
|
||||||
|
reason?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuideStopLinkedExhibit {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
nameEn?: string
|
||||||
|
exhibitCode?: string
|
||||||
|
coverImageUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuideStopInfo {
|
||||||
|
available: boolean
|
||||||
|
targetType: AudioPlayTargetType
|
||||||
|
targetId: string
|
||||||
|
resolvedStopId?: string
|
||||||
|
lang: 'zh-CN' | 'en-US'
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
coverImageUrl?: string
|
||||||
|
galleryUrls: string[]
|
||||||
|
imageStatus: 'READY' | 'MISSING' | string
|
||||||
|
imageSource?: string
|
||||||
|
linkedExhibits: GuideStopLinkedExhibit[]
|
||||||
|
playTargetType: AudioPlayTargetType
|
||||||
|
playTargetId: string
|
||||||
|
hasAudio: boolean
|
||||||
|
hasText: boolean
|
||||||
|
supportedLanguages: string[]
|
||||||
|
audioStatus: 'READY' | 'MISSING' | string
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringifyId = (value: string | number | null | undefined) => (
|
||||||
|
value === null || typeof value === 'undefined' ? '' : String(value)
|
||||||
|
)
|
||||||
|
|
||||||
|
const normalizeTargetType = (value: string | null | undefined, fallback: AudioPlayTargetType): AudioPlayTargetType => {
|
||||||
|
const normalized = value?.toUpperCase()
|
||||||
|
return normalized === 'ITEM' || normalized === 'STOP' ? normalized : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeLanguage = (value: string | null | undefined): 'zh-CN' | 'en-US' => (
|
||||||
|
value === 'en-US' ? 'en-US' : 'zh-CN'
|
||||||
|
)
|
||||||
|
|
||||||
|
const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls']) => {
|
||||||
|
if (!value) return []
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((entry) => normalizeSameOriginPublicUrl(String(entry))).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed)
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed
|
||||||
|
.map((entry) => normalizeSameOriginPublicUrl(String(entry)))
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 兼容后端历史逗号拼接字段。
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => normalizeSameOriginPublicUrl(entry.trim()))
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeLinkedExhibits = (items: BackendGuideStopLinkedExhibit[] | null | undefined) => (
|
||||||
|
(items || [])
|
||||||
|
.map<GuideStopLinkedExhibit | null>((item) => {
|
||||||
|
const id = stringifyId(item.id)
|
||||||
|
if (!id) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: item.name?.trim() || item.exhibitCode?.trim() || `展品 ${id}`,
|
||||||
|
nameEn: item.nameEn?.trim() || undefined,
|
||||||
|
exhibitCode: item.exhibitCode?.trim() || undefined,
|
||||||
|
coverImageUrl: normalizeSameOriginPublicUrl(item.coverImageUrl) || undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean) as GuideStopLinkedExhibit[]
|
||||||
|
)
|
||||||
|
|
||||||
|
export const toGuideStopInfo = (
|
||||||
|
source: BackendGuideStopInfo,
|
||||||
|
fallback: {
|
||||||
|
targetType: AudioPlayTargetType
|
||||||
|
targetId: string
|
||||||
|
lang: 'zh-CN' | 'en-US'
|
||||||
|
}
|
||||||
|
): GuideStopInfo => {
|
||||||
|
const targetType = normalizeTargetType(source.targetType, fallback.targetType)
|
||||||
|
const targetId = stringifyId(source.targetId) || fallback.targetId
|
||||||
|
const playTargetType = normalizeTargetType(source.playTargetType, targetType)
|
||||||
|
const playTargetId = stringifyId(source.playTargetId) || targetId
|
||||||
|
const imageStatus = source.imageStatus || 'MISSING'
|
||||||
|
const canUseStopImages = imageStatus === 'READY'
|
||||||
|
const galleryUrls = canUseStopImages ? parseGalleryUrls(source.galleryUrls) : []
|
||||||
|
const coverImageUrl = canUseStopImages
|
||||||
|
? normalizeSameOriginPublicUrl(source.coverImageUrl) || undefined
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: source.available === true,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
resolvedStopId: stringifyId(source.resolvedStopId) || undefined,
|
||||||
|
lang: normalizeLanguage(source.lang || fallback.lang),
|
||||||
|
title: source.title?.trim() || '讲解内容',
|
||||||
|
description: source.description?.trim() || undefined,
|
||||||
|
coverImageUrl,
|
||||||
|
galleryUrls,
|
||||||
|
imageStatus,
|
||||||
|
imageSource: source.imageSource || undefined,
|
||||||
|
linkedExhibits: normalizeLinkedExhibits(source.linkedExhibits),
|
||||||
|
playTargetType,
|
||||||
|
playTargetId,
|
||||||
|
hasAudio: source.hasAudio === true,
|
||||||
|
hasText: source.hasText === true,
|
||||||
|
supportedLanguages: source.supportedLanguages || [],
|
||||||
|
audioStatus: source.audioStatus || 'MISSING',
|
||||||
|
reason: source.reason || undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
MuseumExhibit
|
ExplainBusinessUnit,
|
||||||
|
ExplainGuideStop,
|
||||||
|
MuseumExhibit,
|
||||||
|
MuseumHall
|
||||||
} from '@/domain/museum'
|
} from '@/domain/museum'
|
||||||
import {
|
import {
|
||||||
dataSourceConfig
|
dataSourceConfig
|
||||||
@@ -9,10 +12,14 @@ import type {
|
|||||||
} from '@/data/providers/staticMuseumContentProvider'
|
} from '@/data/providers/staticMuseumContentProvider'
|
||||||
import {
|
import {
|
||||||
toBackendExplainTrack,
|
toBackendExplainTrack,
|
||||||
toBackendHall,
|
toBackendGuideStop,
|
||||||
|
toBackendHallFromList,
|
||||||
toBackendMediaAsset,
|
toBackendMediaAsset,
|
||||||
toBackendMuseumExhibit,
|
toBackendMuseumExhibit,
|
||||||
type BackendExhibit
|
groupGuideStopsByOutline,
|
||||||
|
type BackendExhibit,
|
||||||
|
type BackendGuideStop,
|
||||||
|
type BackendHall
|
||||||
} from '@/data/adapters/backendExplainDataAdapter'
|
} from '@/data/adapters/backendExplainDataAdapter'
|
||||||
|
|
||||||
interface CommonResult<T> {
|
interface CommonResult<T> {
|
||||||
@@ -65,11 +72,40 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
|||||||
private readonly searchInflight = new Map<string, Promise<MuseumExhibit[]>>()
|
private readonly searchInflight = new Map<string, Promise<MuseumExhibit[]>>()
|
||||||
private readonly detailCache = new Map<string, MuseumExhibit>()
|
private readonly detailCache = new Map<string, MuseumExhibit>()
|
||||||
private readonly detailInflight = new Map<string, Promise<MuseumExhibit | null>>()
|
private readonly detailInflight = new Map<string, Promise<MuseumExhibit | null>>()
|
||||||
|
private hallListCache: MuseumHall[] | null = null
|
||||||
|
private hallListInflight: Promise<MuseumHall[]> | null = null
|
||||||
|
private readonly guideStopCache = new Map<string, ExplainGuideStop[]>()
|
||||||
|
private readonly guideStopInflight = new Map<string, Promise<ExplainGuideStop[]>>()
|
||||||
|
|
||||||
constructor(private readonly fallbackProvider: ExplainContentProvider) {}
|
constructor(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 = '') {
|
private async requestSearch(keyword = '') {
|
||||||
const normalizedKeyword = normalizeKeyword(keyword)
|
const normalizedKeyword = normalizeKeyword(keyword)
|
||||||
|
if (!normalizedKeyword) return this.requestStaticExplainExhibits()
|
||||||
|
|
||||||
const cacheKey = normalizedKeyword.toLowerCase()
|
const cacheKey = normalizedKeyword.toLowerCase()
|
||||||
const cached = this.searchCache.get(cacheKey)
|
const cached = this.searchCache.get(cacheKey)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
@@ -144,13 +180,78 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
|||||||
return this.fallbackProvider.listHalls().catch(() => [])
|
return this.fallbackProvider.listHalls().catch(() => [])
|
||||||
}
|
}
|
||||||
|
|
||||||
async listExplainExhibits() {
|
private async requestHallList() {
|
||||||
try {
|
if (this.hallListCache) return this.hallListCache
|
||||||
return await this.requestSearch('')
|
if (this.hallListInflight) return this.hallListInflight
|
||||||
} catch (error) {
|
|
||||||
console.warn('后端讲解列表加载失败,将使用静态讲解兜底:', error)
|
this.hallListInflight = (async () => {
|
||||||
return this.fallbackProvider.listExplainExhibits()
|
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 || '后端展厅列表加载失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
item,
|
||||||
|
fallbackHallById.get(String(item.id || '')) || fallbackHallByName.get(String(item.name || ''))
|
||||||
|
))
|
||||||
|
.filter((hall) => hall.id)
|
||||||
|
|
||||||
|
this.hallListCache = 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 url = `${resolveAppApiBaseUrl()}/gis/sdk/halls/${encodeURIComponent(normalizedHallId)}/guide-stops`
|
||||||
|
const response = await requestJson<CommonResult<BackendGuideStop[]>>(url)
|
||||||
|
if (response.code !== 0 || !Array.isArray(response.data)) {
|
||||||
|
throw new Error(response.msg || '后端展厅讲解点加载失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const stops = response.data
|
||||||
|
.map(toBackendGuideStop)
|
||||||
|
.filter(Boolean) as ExplainGuideStop[]
|
||||||
|
|
||||||
|
this.guideStopCache.set(normalizedHallId, stops)
|
||||||
|
return stops
|
||||||
|
})()
|
||||||
|
|
||||||
|
this.guideStopInflight.set(normalizedHallId, promise)
|
||||||
|
try {
|
||||||
|
return await promise
|
||||||
|
} finally {
|
||||||
|
this.guideStopInflight.delete(normalizedHallId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTemporaryBusinessUnitsByHall(hallId: string): Promise<ExplainBusinessUnit[]> {
|
||||||
|
const stops = await this.listGuideStopsByHall(hallId)
|
||||||
|
return groupGuideStopsByOutline(hallId, stops)
|
||||||
|
}
|
||||||
|
|
||||||
|
async listExplainExhibits() {
|
||||||
|
return this.requestStaticExplainExhibits()
|
||||||
}
|
}
|
||||||
|
|
||||||
listExhibits() {
|
listExhibits() {
|
||||||
@@ -163,23 +264,9 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
|||||||
|
|
||||||
async listHalls() {
|
async listHalls() {
|
||||||
try {
|
try {
|
||||||
const exhibits = await this.requestSearch('')
|
return await this.requestHallList()
|
||||||
const fallbackHalls = await this.safeFallbackHalls()
|
|
||||||
const fallbackHallById = new Map(fallbackHalls.map((hall) => [hall.id, hall]))
|
|
||||||
const exhibitsByHallId = new Map<string, MuseumExhibit[]>()
|
|
||||||
|
|
||||||
exhibits.forEach((exhibit) => {
|
|
||||||
const hallId = exhibit.hallId || 'unknown'
|
|
||||||
const items = exhibitsByHallId.get(hallId) || []
|
|
||||||
items.push(exhibit)
|
|
||||||
exhibitsByHallId.set(hallId, items)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Array.from(exhibitsByHallId.entries()).map(([hallId, hallExhibits]) => (
|
|
||||||
toBackendHall(hallId, hallExhibits, fallbackHallById.get(hallId))
|
|
||||||
))
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('后端展厅聚合加载失败,将使用静态展厅兜底:', error)
|
console.warn('后端展厅列表加载失败,将使用静态展厅兜底:', error)
|
||||||
return this.fallbackProvider.listHalls()
|
return this.fallbackProvider.listHalls()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,6 +292,12 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
searchExplainExhibits(keyword = '') {
|
searchExplainExhibits(keyword = '') {
|
||||||
return this.requestSearch(keyword)
|
const normalizedKeyword = normalizeKeyword(keyword)
|
||||||
|
if (!normalizedKeyword) return this.requestStaticExplainExhibits()
|
||||||
|
|
||||||
|
return this.requestSearch(normalizedKeyword).catch((error) => {
|
||||||
|
console.warn('后端讲解搜索失败,将使用静态讲解兜底:', error)
|
||||||
|
return this.searchStaticExplainExhibits(normalizedKeyword)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ExplainBusinessUnit,
|
||||||
|
ExplainGuideStop,
|
||||||
ExplainTrack,
|
ExplainTrack,
|
||||||
MediaAsset,
|
MediaAsset,
|
||||||
MuseumExhibit,
|
MuseumExhibit,
|
||||||
@@ -38,6 +40,8 @@ export interface MuseumContentProvider {
|
|||||||
export interface ExplainContentProvider extends MuseumContentProvider {
|
export interface ExplainContentProvider extends MuseumContentProvider {
|
||||||
listExplainExhibits(): Promise<MuseumExhibit[]>
|
listExplainExhibits(): Promise<MuseumExhibit[]>
|
||||||
searchExplainExhibits?(keyword?: string): Promise<MuseumExhibit[]>
|
searchExplainExhibits?(keyword?: string): Promise<MuseumExhibit[]>
|
||||||
|
listGuideStopsByHall?(hallId: string): Promise<ExplainGuideStop[]>
|
||||||
|
listTemporaryBusinessUnitsByHall?(hallId: string): Promise<ExplainBusinessUnit[]>
|
||||||
listTracks(): Promise<ExplainTrack[]>
|
listTracks(): Promise<ExplainTrack[]>
|
||||||
getMediaById(id: string): Promise<MediaAsset | null>
|
getMediaById(id: string): Promise<MediaAsset | null>
|
||||||
getMediaForExplainTrack(trackId: string): Promise<MediaAsset | null>
|
getMediaForExplainTrack(trackId: string): Promise<MediaAsset | null>
|
||||||
|
|||||||
@@ -165,10 +165,50 @@ export interface MuseumExhibit {
|
|||||||
audioNarrationTier?: 'STANDARD' | 'EXTENDED'
|
audioNarrationTier?: 'STANDARD' | 'EXTENDED'
|
||||||
audioUnavailableReason?: string
|
audioUnavailableReason?: string
|
||||||
audioAvailable?: boolean
|
audioAvailable?: boolean
|
||||||
|
audioStatus?: string
|
||||||
|
supportedLanguages?: string[]
|
||||||
|
imageStatus?: string
|
||||||
|
imageSource?: string
|
||||||
|
galleryUrls?: string[]
|
||||||
|
linkedExhibits?: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
nameEn?: string
|
||||||
|
exhibitCode?: string
|
||||||
|
coverImageUrl?: string
|
||||||
|
}>
|
||||||
|
stopInfoAvailable?: boolean
|
||||||
|
stopInfoReason?: string
|
||||||
|
resolvedStopId?: string
|
||||||
playTargetType?: AudioPlayTargetType
|
playTargetType?: AudioPlayTargetType
|
||||||
playTargetId?: string
|
playTargetId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExplainGuideStop {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
hallId?: string
|
||||||
|
hallName?: string
|
||||||
|
floorId?: string
|
||||||
|
targetType?: AudioPlayTargetType
|
||||||
|
targetId?: string
|
||||||
|
coverImageUrl?: string
|
||||||
|
description?: string
|
||||||
|
hasAudio?: boolean
|
||||||
|
poiId?: string
|
||||||
|
outlineId?: string
|
||||||
|
outlineName?: string
|
||||||
|
sort?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExplainBusinessUnit {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
hallId: string
|
||||||
|
guideStopCount: number
|
||||||
|
stops: ExplainGuideStop[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface GuideLocationPreview {
|
export interface GuideLocationPreview {
|
||||||
poiId: string
|
poiId: string
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -54,6 +54,15 @@
|
|||||||
<view class="content-section">
|
<view class="content-section">
|
||||||
<text class="section-title">讲解内容</text>
|
<text class="section-title">讲解内容</text>
|
||||||
<text class="section-text">{{ exhibit.body || exhibit.summary }}</text>
|
<text class="section-text">{{ exhibit.body || exhibit.summary }}</text>
|
||||||
|
<view
|
||||||
|
v-if="canExpandText"
|
||||||
|
class="text-expand-btn"
|
||||||
|
:class="{ disabled: textLoading }"
|
||||||
|
@tap="handleExpandText"
|
||||||
|
>
|
||||||
|
<text class="text-expand-label">{{ textLoading ? '正在加载讲解词' : '展开讲解词全文' }}</text>
|
||||||
|
</view>
|
||||||
|
<text v-if="textError" class="text-error">{{ textError }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
@@ -102,6 +111,9 @@ import {
|
|||||||
toExplainDetailPageViewModel,
|
toExplainDetailPageViewModel,
|
||||||
type ExplainDetailPageViewModel
|
type ExplainDetailPageViewModel
|
||||||
} from '@/view-models/explainViewModels'
|
} from '@/view-models/explainViewModels'
|
||||||
|
import type {
|
||||||
|
AudioPlayTargetType
|
||||||
|
} from '@/domain/museum'
|
||||||
import {
|
import {
|
||||||
isGuideTopTab,
|
isGuideTopTab,
|
||||||
type GuideTopTab
|
type GuideTopTab
|
||||||
@@ -117,7 +129,7 @@ const defaultDetail: ExplainDetailPageViewModel = {
|
|||||||
body: '该讲解内容待补充。',
|
body: '该讲解内容待补充。',
|
||||||
audio: {
|
audio: {
|
||||||
status: 'unavailable',
|
status: 'unavailable',
|
||||||
unavailableReason: '该讲解暂无已发布音频,当前提供图文讲解。'
|
unavailableReason: '当前语言暂无语音讲解。'
|
||||||
},
|
},
|
||||||
chapters: [],
|
chapters: [],
|
||||||
relatedItems: []
|
relatedItems: []
|
||||||
@@ -135,6 +147,10 @@ const currentAudio = ref<AudioItem | null>(null)
|
|||||||
const isPlaying = ref(false)
|
const isPlaying = ref(false)
|
||||||
const activeTopTab = ref<GuideTopTab>('explain')
|
const activeTopTab = ref<GuideTopTab>('explain')
|
||||||
const resolvedHallId = ref('')
|
const resolvedHallId = ref('')
|
||||||
|
const textLoading = ref(false)
|
||||||
|
const textExpanded = ref(false)
|
||||||
|
const textError = ref('')
|
||||||
|
const retryingAudio = ref(false)
|
||||||
|
|
||||||
const heroImage = computed(() => exhibit.value.coverImages[0])
|
const heroImage = computed(() => exhibit.value.coverImages[0])
|
||||||
const detailMeta = computed(() => [
|
const detailMeta = computed(() => [
|
||||||
@@ -142,6 +158,11 @@ const detailMeta = computed(() => [
|
|||||||
exhibit.value.floorLabel
|
exhibit.value.floorLabel
|
||||||
].filter(Boolean).join(' · '))
|
].filter(Boolean).join(' · '))
|
||||||
const hallLocationId = computed(() => exhibit.value.hallId || resolvedHallId.value)
|
const hallLocationId = computed(() => exhibit.value.hallId || resolvedHallId.value)
|
||||||
|
const canExpandText = computed(() => (
|
||||||
|
exhibit.value.audio.hasText === true
|
||||||
|
&& !textExpanded.value
|
||||||
|
&& !textLoading.value
|
||||||
|
))
|
||||||
|
|
||||||
// 讲解详情页只定位到所属展厅,不再追踪具体展品点位。
|
// 讲解详情页只定位到所属展厅,不再追踪具体展品点位。
|
||||||
const resolveHallGuidePoi = async () => {
|
const resolveHallGuidePoi = async () => {
|
||||||
@@ -163,51 +184,44 @@ onLoad(async (options: any = {}) => {
|
|||||||
const exhibitId = Array.isArray(options.id) ? options.id[0] : options.id
|
const exhibitId = Array.isArray(options.id) ? options.id[0] : options.id
|
||||||
if (!exhibitId) return
|
if (!exhibitId) return
|
||||||
|
|
||||||
const exhibitData = await explainUseCase.getExhibitById(exhibitId)
|
const rawTargetType = Array.isArray(options.targetType) ? options.targetType[0] : options.targetType
|
||||||
if (exhibitData) {
|
const targetType: AudioPlayTargetType | undefined = rawTargetType === 'ITEM' || rawTargetType === 'STOP'
|
||||||
|
? rawTargetType
|
||||||
|
: undefined
|
||||||
|
const rawTargetId = Array.isArray(options.targetId) ? options.targetId[0] : options.targetId
|
||||||
|
const targetId = rawTargetId ? String(rawTargetId) : undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exhibitData = await explainUseCase.enterExplainDetail({
|
||||||
|
exhibitId,
|
||||||
|
targetType,
|
||||||
|
targetId
|
||||||
|
})
|
||||||
exhibit.value = toExplainDetailPageViewModel(exhibitData)
|
exhibit.value = toExplainDetailPageViewModel(exhibitData)
|
||||||
if (exhibitData.hallId) {
|
if (exhibitData.hallId) {
|
||||||
resolvedHallId.value = exhibitData.hallId
|
resolvedHallId.value = exhibitData.hallId
|
||||||
}
|
}
|
||||||
void explainUseCase.enrichExhibitDetailAudio(exhibitId)
|
} catch (error) {
|
||||||
.then((enrichedExhibit) => {
|
console.error('讲解详情加载失败:', error)
|
||||||
if (!enrichedExhibit || exhibit.value.id !== exhibitId) return
|
uni.showToast({
|
||||||
|
title: '讲解详情加载失败,请稍后重试',
|
||||||
exhibit.value = toExplainDetailPageViewModel(enrichedExhibit)
|
icon: 'none'
|
||||||
if (enrichedExhibit.hallId) {
|
|
||||||
resolvedHallId.value = enrichedExhibit.hallId
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.warn('讲解详情音频正文后台补充失败:', error)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const handlePlayAudio = async () => {
|
const handlePlayAudio = async () => {
|
||||||
if (exhibit.value.audio.url) {
|
|
||||||
const audio: AudioItem = {
|
|
||||||
id: `media-${exhibit.value.id}`,
|
|
||||||
name: exhibit.value.title,
|
|
||||||
audioUrl: exhibit.value.audio.url,
|
|
||||||
image: heroImage.value,
|
|
||||||
duration: undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSameAudio = currentAudio.value?.id === audio.id
|
|
||||||
if (showAudioPlayer.value && isSameAudio && isPlaying.value) {
|
|
||||||
audioPlayerRef.value?.pause()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
showAudioPlayer.value = true
|
|
||||||
currentAudio.value = audio
|
|
||||||
audioPlayerRef.value?.play(audio)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const selection = exhibit.value.id
|
const selection = exhibit.value.id
|
||||||
? await explainUseCase.selectAudioForExhibit(exhibit.value.id)
|
? await explainUseCase.selectAudioForExplainDetail({
|
||||||
|
id: exhibit.value.id,
|
||||||
|
name: exhibit.value.title,
|
||||||
|
image: heroImage.value,
|
||||||
|
audioStatus: exhibit.value.audio.status === 'playable' ? 'READY' : 'MISSING',
|
||||||
|
audioLanguage: exhibit.value.audio.language,
|
||||||
|
audioUnavailableReason: exhibit.value.audio.unavailableReason,
|
||||||
|
playTargetType: exhibit.value.audio.playTargetType,
|
||||||
|
playTargetId: exhibit.value.audio.playTargetId
|
||||||
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
if (!selection?.playable || !selection.media?.url) {
|
if (!selection?.playable || !selection.media?.url) {
|
||||||
@@ -220,9 +234,9 @@ const handlePlayAudio = async () => {
|
|||||||
|
|
||||||
const audio: AudioItem = {
|
const audio: AudioItem = {
|
||||||
id: selection.media.id,
|
id: selection.media.id,
|
||||||
name: selection.track?.title || selection.exhibit.name,
|
name: selection.playInfo?.title || selection.exhibit.name,
|
||||||
audioUrl: selection.media.url,
|
audioUrl: selection.media.url,
|
||||||
image: selection.exhibit.image || heroImage.value,
|
image: heroImage.value,
|
||||||
duration: selection.media.duration
|
duration: selection.media.duration
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,10 +249,41 @@ const handlePlayAudio = async () => {
|
|||||||
showAudioPlayer.value = true
|
showAudioPlayer.value = true
|
||||||
currentAudio.value = audio
|
currentAudio.value = audio
|
||||||
await nextTick()
|
await nextTick()
|
||||||
uni.showToast({
|
audioPlayerRef.value?.play(audio)
|
||||||
title: '音频已准备好,请点击底部播放按钮',
|
}
|
||||||
icon: 'none'
|
|
||||||
|
const handleExpandText = async () => {
|
||||||
|
if (!exhibit.value.id || textLoading.value || textExpanded.value) return
|
||||||
|
|
||||||
|
textLoading.value = true
|
||||||
|
textError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const selection = await explainUseCase.loadExplainDetailText({
|
||||||
|
id: exhibit.value.id,
|
||||||
|
name: exhibit.value.title,
|
||||||
|
image: heroImage.value,
|
||||||
|
audioLanguage: exhibit.value.audio.language,
|
||||||
|
audioStatus: exhibit.value.audio.status === 'playable' ? 'READY' : 'MISSING',
|
||||||
|
hallId: exhibit.value.hallId,
|
||||||
|
hallName: exhibit.value.hallName,
|
||||||
|
floorLabel: exhibit.value.floorLabel,
|
||||||
|
location: exhibit.value.location,
|
||||||
|
playTargetType: exhibit.value.audio.playTargetType,
|
||||||
|
playTargetId: exhibit.value.audio.playTargetId,
|
||||||
|
audioHasText: exhibit.value.audio.hasText
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!selection.available) {
|
||||||
|
textError.value = selection.unavailableMessage || '当前语言暂无讲解词'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exhibit.value = toExplainDetailPageViewModel(selection.exhibit)
|
||||||
|
textExpanded.value = true
|
||||||
|
} finally {
|
||||||
|
textLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAudioVisibleChange = (visible: boolean) => {
|
const handleAudioVisibleChange = (visible: boolean) => {
|
||||||
@@ -250,6 +295,7 @@ const handleAudioVisibleChange = (visible: boolean) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAudioPlay = () => {
|
const handleAudioPlay = () => {
|
||||||
|
retryingAudio.value = false
|
||||||
isPlaying.value = true
|
isPlaying.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,11 +307,43 @@ const handleAudioEnded = () => {
|
|||||||
isPlaying.value = false
|
isPlaying.value = false
|
||||||
showAudioPlayer.value = false
|
showAudioPlayer.value = false
|
||||||
currentAudio.value = null
|
currentAudio.value = null
|
||||||
|
retryingAudio.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAudioError = (_audio: AudioItem | null, message: string) => {
|
const handleAudioError = async (_audio: AudioItem | null, message: string) => {
|
||||||
console.warn('详情音频不可播放:', message)
|
console.warn('详情音频不可播放:', message)
|
||||||
isPlaying.value = false
|
isPlaying.value = false
|
||||||
|
|
||||||
|
if (!retryingAudio.value && exhibit.value.audio.status === 'playable') {
|
||||||
|
retryingAudio.value = true
|
||||||
|
const selection = await explainUseCase.selectAudioForExplainDetail({
|
||||||
|
id: exhibit.value.id,
|
||||||
|
name: exhibit.value.title,
|
||||||
|
image: heroImage.value,
|
||||||
|
audioStatus: 'READY',
|
||||||
|
audioLanguage: exhibit.value.audio.language,
|
||||||
|
playTargetType: exhibit.value.audio.playTargetType,
|
||||||
|
playTargetId: exhibit.value.audio.playTargetId
|
||||||
|
})
|
||||||
|
|
||||||
|
if (selection.playable && selection.media?.url) {
|
||||||
|
const retryAudio: AudioItem = {
|
||||||
|
id: selection.media.id,
|
||||||
|
name: selection.playInfo?.title || selection.exhibit.name,
|
||||||
|
audioUrl: selection.media.url,
|
||||||
|
image: heroImage.value,
|
||||||
|
duration: selection.media.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
currentAudio.value = retryAudio
|
||||||
|
showAudioPlayer.value = true
|
||||||
|
await nextTick()
|
||||||
|
audioPlayerRef.value?.play(retryAudio)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
retryingAudio.value = false
|
||||||
showAudioPlayer.value = false
|
showAudioPlayer.value = false
|
||||||
currentAudio.value = null
|
currentAudio.value = null
|
||||||
exhibit.value = {
|
exhibit.value = {
|
||||||
@@ -485,6 +563,39 @@ const handleBack = () => {
|
|||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-expand-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 46px;
|
||||||
|
margin-top: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #151713;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-expand-btn.disabled {
|
||||||
|
opacity: 0.56;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-expand-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 21px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-error {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: #8a4a31;
|
||||||
|
}
|
||||||
|
|
||||||
.action-bar {
|
.action-bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
@@ -10,73 +10,103 @@
|
|||||||
@back="handleBack"
|
@back="handleBack"
|
||||||
>
|
>
|
||||||
<view class="explain-all-page">
|
<view class="explain-all-page">
|
||||||
<ExplainList
|
<ExplainHallSelect
|
||||||
:cards="explainExhibits"
|
:halls="explainHallItems"
|
||||||
|
:business-units="explainBusinessUnitItems"
|
||||||
|
:guide-stops="explainGuideStopItems"
|
||||||
|
:stage="explainStage"
|
||||||
|
:selected-hall-name="selectedExplainHallName"
|
||||||
|
:selected-business-unit-name="selectedExplainBusinessUnitName"
|
||||||
:loading="explainLoading"
|
:loading="explainLoading"
|
||||||
:error="explainError"
|
:error="explainError"
|
||||||
list-title="全部讲解"
|
|
||||||
page-mode="all"
|
|
||||||
:initial-render-limit="48"
|
|
||||||
:page-size="48"
|
|
||||||
@exhibit-click="handleExplainExhibitClick"
|
|
||||||
@featured-click="handleExplainFeaturedClick"
|
|
||||||
@audio-click="handleAudioClick"
|
|
||||||
@location-click="handleExplainLocationClick"
|
|
||||||
@hall-click="handleExplainHallClick"
|
@hall-click="handleExplainHallClick"
|
||||||
/>
|
@business-unit-click="handleExplainBusinessUnitClick"
|
||||||
|
@guide-stop-click="handleExplainGuideStopClick"
|
||||||
<AudioPlayer
|
@back="handlePanelBack"
|
||||||
ref="audioPlayerRef"
|
|
||||||
:visible="showAudioPlayer"
|
|
||||||
:audio="currentAudio"
|
|
||||||
:auto-play="false"
|
|
||||||
avoid-bottom-nav
|
|
||||||
@update:visible="handleAudioVisibleChange"
|
|
||||||
@play="handleAudioPlay"
|
|
||||||
@pause="handleAudioPause"
|
|
||||||
@ended="handleAudioEnded"
|
|
||||||
@error="handleAudioError"
|
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
</GuidePageFrame>
|
</GuidePageFrame>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { onLoad } from '@dcloudio/uni-app'
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
||||||
import ExplainList, { type Exhibit } from '@/components/explain/ExplainList.vue'
|
import ExplainHallSelect, {
|
||||||
import AudioPlayer, { type AudioItem } from '@/components/audio/AudioPlayer.vue'
|
type ExplainBusinessUnitSelectItem,
|
||||||
|
type ExplainGuideStopSelectItem,
|
||||||
|
type ExplainHallSelectItem,
|
||||||
|
type ExplainSelectStage
|
||||||
|
} from '@/components/explain/ExplainHallSelect.vue'
|
||||||
import {
|
import {
|
||||||
explainUseCase
|
explainUseCase
|
||||||
} from '@/usecases/explainUseCase'
|
} from '@/usecases/explainUseCase'
|
||||||
import {
|
import type {
|
||||||
guideUseCase
|
ExplainBusinessUnit,
|
||||||
} from '@/usecases/guideUseCase'
|
MuseumHall
|
||||||
import {
|
} from '@/domain/museum'
|
||||||
toExplainCardViewModel
|
|
||||||
} from '@/view-models/explainViewModels'
|
|
||||||
import {
|
import {
|
||||||
navigateToGuideTopTab,
|
navigateToGuideTopTab,
|
||||||
type GuideTopTab
|
type GuideTopTab
|
||||||
} from '@/utils/guideTopTabs'
|
} from '@/utils/guideTopTabs'
|
||||||
|
|
||||||
const explainExhibits = ref<Exhibit[]>([])
|
const explainHallItems = ref<ExplainHallSelectItem[]>([])
|
||||||
|
const explainBusinessUnits = ref<ExplainBusinessUnit[]>([])
|
||||||
|
const explainStage = ref<ExplainSelectStage>('hall')
|
||||||
|
const selectedExplainHallId = ref('')
|
||||||
|
const selectedExplainHallName = ref('')
|
||||||
|
const selectedExplainBusinessUnitId = ref('')
|
||||||
|
const selectedExplainBusinessUnitName = ref('')
|
||||||
const explainLoading = ref(false)
|
const explainLoading = ref(false)
|
||||||
const explainError = ref('')
|
const explainError = ref('')
|
||||||
const showAudioPlayer = ref(false)
|
|
||||||
const isAudioPlaying = ref(false)
|
|
||||||
const currentAudio = ref<AudioItem | null>(null)
|
|
||||||
const audioPlayerRef = ref<AudioPlayerExpose | null>(null)
|
|
||||||
let explainLoadPromise: Promise<void> | null = null
|
let explainLoadPromise: Promise<void> | null = null
|
||||||
|
|
||||||
interface AudioPlayerExpose {
|
const normalizeExplainKeyword = (value?: string) => (value || '').trim().toLowerCase()
|
||||||
play: (audio: AudioItem) => void
|
|
||||||
pause: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadExplainExhibits = async () => {
|
const buildExplainHallItems = (halls: MuseumHall[]): ExplainHallSelectItem[] => (
|
||||||
if (explainExhibits.value.length) return
|
halls.map((hall) => ({
|
||||||
|
id: hall.id,
|
||||||
|
name: hall.name,
|
||||||
|
floorLabel: hall.floorLabel,
|
||||||
|
image: hall.image,
|
||||||
|
explainCount: hall.exhibitCount || 0,
|
||||||
|
searchText: normalizeExplainKeyword([
|
||||||
|
hall.name,
|
||||||
|
hall.floorLabel,
|
||||||
|
hall.description,
|
||||||
|
hall.area
|
||||||
|
].filter(Boolean).join(' '))
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedExplainBusinessUnit = computed(() => (
|
||||||
|
explainBusinessUnits.value.find((unit) => unit.id === selectedExplainBusinessUnitId.value) || null
|
||||||
|
))
|
||||||
|
|
||||||
|
const explainBusinessUnitItems = computed<ExplainBusinessUnitSelectItem[]>(() => (
|
||||||
|
explainBusinessUnits.value.map((unit) => ({
|
||||||
|
id: unit.id,
|
||||||
|
name: unit.name,
|
||||||
|
hallId: unit.hallId,
|
||||||
|
guideStopCount: unit.guideStopCount
|
||||||
|
}))
|
||||||
|
))
|
||||||
|
|
||||||
|
const explainGuideStopItems = computed<ExplainGuideStopSelectItem[]>(() => (
|
||||||
|
selectedExplainBusinessUnit.value?.stops.map((stop) => ({
|
||||||
|
id: stop.id,
|
||||||
|
name: stop.name,
|
||||||
|
hallId: stop.hallId,
|
||||||
|
hallName: stop.hallName,
|
||||||
|
floorId: stop.floorId,
|
||||||
|
coverImageUrl: stop.coverImageUrl,
|
||||||
|
description: stop.description,
|
||||||
|
hasAudio: stop.hasAudio
|
||||||
|
})) || []
|
||||||
|
))
|
||||||
|
|
||||||
|
const loadExplainHalls = async () => {
|
||||||
|
if (explainHallItems.value.length) return
|
||||||
if (explainLoadPromise) return explainLoadPromise
|
if (explainLoadPromise) return explainLoadPromise
|
||||||
|
|
||||||
explainLoading.value = true
|
explainLoading.value = true
|
||||||
@@ -84,12 +114,12 @@ const loadExplainExhibits = async () => {
|
|||||||
|
|
||||||
explainLoadPromise = (async () => {
|
explainLoadPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const exhibits = await explainUseCase.listExplainExhibits()
|
const halls = await explainUseCase.loadExplainHalls()
|
||||||
explainExhibits.value = exhibits.map(toExplainCardViewModel)
|
explainHallItems.value = buildExplainHallItems(halls)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载全部讲解内容失败:', error)
|
console.error('加载讲解展厅失败:', error)
|
||||||
explainError.value = '讲解内容加载失败,请稍后重试'
|
explainError.value = '讲解展厅加载失败,请稍后重试'
|
||||||
explainExhibits.value = []
|
explainHallItems.value = []
|
||||||
} finally {
|
} finally {
|
||||||
explainLoading.value = false
|
explainLoading.value = false
|
||||||
explainLoadPromise = null
|
explainLoadPromise = null
|
||||||
@@ -100,7 +130,7 @@ const loadExplainExhibits = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onLoad(() => {
|
onLoad(() => {
|
||||||
void loadExplainExhibits()
|
void loadExplainHalls()
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
@@ -118,120 +148,68 @@ const handleTopTabChange = (tab: GuideTopTab) => {
|
|||||||
navigateToGuideTopTab(tab)
|
navigateToGuideTopTab(tab)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExplainExhibitClick = (exhibit: Exhibit) => {
|
const handleExplainHallClick = async (hallId: string) => {
|
||||||
|
const hall = explainHallItems.value.find((item) => item.id === hallId)
|
||||||
|
selectedExplainHallId.value = hallId
|
||||||
|
selectedExplainHallName.value = hall?.name || '业务单元'
|
||||||
|
selectedExplainBusinessUnitId.value = ''
|
||||||
|
selectedExplainBusinessUnitName.value = ''
|
||||||
|
explainBusinessUnits.value = []
|
||||||
|
explainLoading.value = true
|
||||||
|
explainError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
explainBusinessUnits.value = await explainUseCase.loadTemporaryBusinessUnitsByHall(hallId)
|
||||||
|
explainStage.value = 'unit'
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载展厅讲解点失败:', error)
|
||||||
|
explainError.value = '展厅讲解点加载失败,请稍后重试'
|
||||||
|
explainStage.value = 'unit'
|
||||||
|
} finally {
|
||||||
|
explainLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExplainBusinessUnitClick = (unitId: string) => {
|
||||||
|
const unit = explainBusinessUnits.value.find((item) => item.id === unitId)
|
||||||
|
selectedExplainBusinessUnitId.value = unitId
|
||||||
|
selectedExplainBusinessUnitName.value = unit?.name || '讲解点'
|
||||||
|
explainStage.value = 'stop'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExplainGuideStopClick = (stopId: string) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
id: stopId,
|
||||||
|
tab: 'explain',
|
||||||
|
targetType: 'STOP',
|
||||||
|
targetId: stopId
|
||||||
|
})
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages/exhibit/detail?id=${exhibit.id}&tab=explain`
|
url: `/pages/exhibit/detail?${params.toString()}`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExplainFeaturedClick = async (exhibit: Exhibit) => {
|
const handlePanelBack = () => {
|
||||||
await handleAudioClick(exhibit)
|
if (explainStage.value === 'stop') {
|
||||||
}
|
explainStage.value = 'unit'
|
||||||
|
selectedExplainBusinessUnitId.value = ''
|
||||||
const handleExplainHallClick = (hallId: string) => {
|
selectedExplainBusinessUnitName.value = ''
|
||||||
uni.navigateTo({
|
explainError.value = ''
|
||||||
url: `/pages/hall/detail?id=${encodeURIComponent(hallId)}&tab=explain`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAudioClick = async (exhibit: Exhibit) => {
|
|
||||||
if (exhibit.audioUrl) {
|
|
||||||
const audio: AudioItem = {
|
|
||||||
id: `media-${exhibit.id}`,
|
|
||||||
name: exhibit.title,
|
|
||||||
audioUrl: exhibit.audioUrl,
|
|
||||||
image: exhibit.coverImage,
|
|
||||||
duration: exhibit.audioDuration
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSameAudio = currentAudio.value?.id === audio.id
|
|
||||||
if (showAudioPlayer.value && isSameAudio && isAudioPlaying.value) {
|
|
||||||
audioPlayerRef.value?.pause()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
currentAudio.value = audio
|
if (explainStage.value === 'unit') {
|
||||||
showAudioPlayer.value = true
|
explainStage.value = 'hall'
|
||||||
audioPlayerRef.value?.play(audio)
|
selectedExplainHallId.value = ''
|
||||||
|
selectedExplainHallName.value = ''
|
||||||
|
selectedExplainBusinessUnitId.value = ''
|
||||||
|
selectedExplainBusinessUnitName.value = ''
|
||||||
|
explainBusinessUnits.value = []
|
||||||
|
explainError.value = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const selection = await explainUseCase.selectAudioForExhibit(exhibit.id)
|
handleBack()
|
||||||
|
|
||||||
if (!selection?.playable || !selection.media?.url) {
|
|
||||||
uni.showToast({
|
|
||||||
title: selection?.unavailableMessage || exhibit.audioStatusText || '该讲解暂无可播放音频',
|
|
||||||
icon: 'none'
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
currentAudio.value = {
|
|
||||||
id: selection.media.id,
|
|
||||||
name: selection.track?.title || selection.exhibit.name,
|
|
||||||
audioUrl: selection.media.url,
|
|
||||||
image: selection.exhibit.image,
|
|
||||||
duration: selection.media.duration
|
|
||||||
}
|
|
||||||
showAudioPlayer.value = true
|
|
||||||
uni.showToast({
|
|
||||||
title: '音频已准备好,请点击底部播放按钮',
|
|
||||||
icon: 'none'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearAudioState = () => {
|
|
||||||
showAudioPlayer.value = false
|
|
||||||
isAudioPlaying.value = false
|
|
||||||
currentAudio.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAudioVisibleChange = (visible: boolean) => {
|
|
||||||
showAudioPlayer.value = visible
|
|
||||||
if (!visible) {
|
|
||||||
isAudioPlaying.value = false
|
|
||||||
currentAudio.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAudioPlay = () => {
|
|
||||||
isAudioPlaying.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAudioPause = () => {
|
|
||||||
isAudioPlaying.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAudioEnded = () => {
|
|
||||||
clearAudioState()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAudioError = (_audio: AudioItem | null, message: string) => {
|
|
||||||
console.warn('全部讲解音频不可播放:', message)
|
|
||||||
clearAudioState()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleExplainLocationClick = async (exhibit: Exhibit) => {
|
|
||||||
if (!exhibit.poiId) {
|
|
||||||
uni.showToast({
|
|
||||||
title: '该讲解暂无三维位置数据',
|
|
||||||
icon: 'none'
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchedPoi = await guideUseCase.getPoiById(exhibit.poiId)
|
|
||||||
if (!matchedPoi) {
|
|
||||||
uni.showToast({
|
|
||||||
title: '该讲解位置尚未映射到当前三维导览资源',
|
|
||||||
icon: 'none'
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
uni.navigateTo({
|
|
||||||
url: `/pages/route/detail?facilityId=${encodeURIComponent(exhibit.poiId)}&target=${encodeURIComponent(exhibit.title)}&state=preview`
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -203,8 +203,18 @@ const itemMeta = (exhibit: ExplainExhibitViewModel) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleExhibitClick = (exhibit: ExplainExhibitViewModel) => {
|
const handleExhibitClick = (exhibit: ExplainExhibitViewModel) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
id: exhibit.id,
|
||||||
|
tab: activeTopTab.value
|
||||||
|
})
|
||||||
|
|
||||||
|
if (exhibit.playTargetType && exhibit.playTargetId) {
|
||||||
|
params.set('targetType', exhibit.playTargetType)
|
||||||
|
params.set('targetId', exhibit.playTargetId)
|
||||||
|
}
|
||||||
|
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages/exhibit/detail?id=${encodeURIComponent(exhibit.id)}&tab=${activeTopTab.value}`
|
url: `/pages/exhibit/detail?${params.toString()}`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -204,9 +204,16 @@
|
|||||||
<view v-else-if="currentTab === 'explain'" class="explain-page">
|
<view v-else-if="currentTab === 'explain'" class="explain-page">
|
||||||
<ExplainHallSelect
|
<ExplainHallSelect
|
||||||
:halls="explainHallItems"
|
:halls="explainHallItems"
|
||||||
|
:business-units="explainBusinessUnitItems"
|
||||||
|
:guide-stops="explainGuideStopItems"
|
||||||
|
:stage="explainStage"
|
||||||
|
:selected-hall-name="selectedExplainHallName"
|
||||||
|
:selected-business-unit-name="selectedExplainBusinessUnitName"
|
||||||
:loading="explainLoading"
|
:loading="explainLoading"
|
||||||
:error="explainError"
|
:error="explainError"
|
||||||
@hall-click="handleExplainHallClick"
|
@hall-click="handleExplainHallClick"
|
||||||
|
@business-unit-click="handleExplainBusinessUnitClick"
|
||||||
|
@guide-stop-click="handleExplainGuideStopClick"
|
||||||
@back="handleExplainBack"
|
@back="handleExplainBack"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
@@ -224,6 +231,9 @@ import type {
|
|||||||
RoutePointOption
|
RoutePointOption
|
||||||
} from '@/components/navigation/RoutePointPicker.vue'
|
} from '@/components/navigation/RoutePointPicker.vue'
|
||||||
import ExplainHallSelect, {
|
import ExplainHallSelect, {
|
||||||
|
type ExplainBusinessUnitSelectItem,
|
||||||
|
type ExplainGuideStopSelectItem,
|
||||||
|
type ExplainSelectStage,
|
||||||
type ExplainHallSelectItem
|
type ExplainHallSelectItem
|
||||||
} from '@/components/explain/ExplainHallSelect.vue'
|
} from '@/components/explain/ExplainHallSelect.vue'
|
||||||
import {
|
import {
|
||||||
@@ -246,7 +256,7 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
GuideRouteResult,
|
GuideRouteResult,
|
||||||
GuideRouteTarget,
|
GuideRouteTarget,
|
||||||
MuseumExhibit,
|
ExplainBusinessUnit,
|
||||||
MuseumFloor,
|
MuseumFloor,
|
||||||
MuseumHall
|
MuseumHall
|
||||||
} from '@/domain/museum'
|
} from '@/domain/museum'
|
||||||
@@ -435,10 +445,40 @@ const routeSimulationStepText = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const explainHallItems = ref<ExplainHallSelectItem[]>([])
|
const explainHallItems = ref<ExplainHallSelectItem[]>([])
|
||||||
|
const explainBusinessUnits = ref<ExplainBusinessUnit[]>([])
|
||||||
|
const explainStage = ref<ExplainSelectStage>('hall')
|
||||||
|
const selectedExplainHallId = ref('')
|
||||||
|
const selectedExplainHallName = ref('')
|
||||||
|
const selectedExplainBusinessUnitId = ref('')
|
||||||
|
const selectedExplainBusinessUnitName = ref('')
|
||||||
const explainLoading = ref(false)
|
const explainLoading = ref(false)
|
||||||
const explainError = ref('')
|
const explainError = ref('')
|
||||||
let explainLoadPromise: Promise<void> | null = null
|
let explainLoadPromise: Promise<void> | null = null
|
||||||
|
|
||||||
|
const selectedExplainBusinessUnit = computed(() => (
|
||||||
|
explainBusinessUnits.value.find((unit) => unit.id === selectedExplainBusinessUnitId.value) || null
|
||||||
|
))
|
||||||
|
const explainBusinessUnitItems = computed<ExplainBusinessUnitSelectItem[]>(() => (
|
||||||
|
explainBusinessUnits.value.map((unit) => ({
|
||||||
|
id: unit.id,
|
||||||
|
name: unit.name,
|
||||||
|
hallId: unit.hallId,
|
||||||
|
guideStopCount: unit.guideStopCount
|
||||||
|
}))
|
||||||
|
))
|
||||||
|
const explainGuideStopItems = computed<ExplainGuideStopSelectItem[]>(() => (
|
||||||
|
selectedExplainBusinessUnit.value?.stops.map((stop) => ({
|
||||||
|
id: stop.id,
|
||||||
|
name: stop.name,
|
||||||
|
hallId: stop.hallId,
|
||||||
|
hallName: stop.hallName,
|
||||||
|
floorId: stop.floorId,
|
||||||
|
coverImageUrl: stop.coverImageUrl,
|
||||||
|
description: stop.description,
|
||||||
|
hasAudio: stop.hasAudio
|
||||||
|
})) || []
|
||||||
|
))
|
||||||
|
|
||||||
// Tab 切换处理
|
// Tab 切换处理
|
||||||
const syncTopTabToUrl = (tabId: GuideTopTab) => {
|
const syncTopTabToUrl = (tabId: GuideTopTab) => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
@@ -452,7 +492,7 @@ const syncTopTabToUrl = (tabId: GuideTopTab) => {
|
|||||||
|
|
||||||
const loadTopTabData = (tabId: GuideTopTab) => {
|
const loadTopTabData = (tabId: GuideTopTab) => {
|
||||||
if (tabId === 'explain') {
|
if (tabId === 'explain') {
|
||||||
void loadExplainExhibits()
|
void loadExplainHalls()
|
||||||
} else {
|
} else {
|
||||||
void loadGuideFloors()
|
void loadGuideFloors()
|
||||||
}
|
}
|
||||||
@@ -554,7 +594,7 @@ const loadGuideFloors = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadExplainExhibits = async () => {
|
const loadExplainHalls = async () => {
|
||||||
if (explainHallItems.value.length) return
|
if (explainHallItems.value.length) return
|
||||||
if (explainLoadPromise) return explainLoadPromise
|
if (explainLoadPromise) return explainLoadPromise
|
||||||
|
|
||||||
@@ -563,11 +603,8 @@ const loadExplainExhibits = async () => {
|
|||||||
|
|
||||||
explainLoadPromise = (async () => {
|
explainLoadPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const [halls, exhibits] = await Promise.all([
|
const halls = await explainUseCase.loadExplainHalls()
|
||||||
explainUseCase.listHalls(),
|
explainHallItems.value = buildExplainHallItems(halls)
|
||||||
explainUseCase.listExplainExhibits()
|
|
||||||
])
|
|
||||||
explainHallItems.value = buildExplainHallItems(halls, exhibits)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载讲解内容失败:', error)
|
console.error('加载讲解内容失败:', error)
|
||||||
explainError.value = '讲解内容加载失败,请稍后重试'
|
explainError.value = '讲解内容加载失败,请稍后重试'
|
||||||
@@ -584,52 +621,14 @@ const loadExplainExhibits = async () => {
|
|||||||
const normalizeExplainKeyword = (value?: string) => (value || '').trim().toLowerCase()
|
const normalizeExplainKeyword = (value?: string) => (value || '').trim().toLowerCase()
|
||||||
|
|
||||||
const buildExplainHallItems = (
|
const buildExplainHallItems = (
|
||||||
halls: Awaited<ReturnType<typeof explainUseCase.listHalls>>,
|
halls: Awaited<ReturnType<typeof explainUseCase.loadExplainHalls>>
|
||||||
exhibits: MuseumExhibit[]
|
|
||||||
): ExplainHallSelectItem[] => {
|
): ExplainHallSelectItem[] => {
|
||||||
const countsByHallId = new Map<string, number>()
|
|
||||||
const countsByHallName = new Map<string, number>()
|
|
||||||
const keywordsByHallId = new Map<string, Set<string>>()
|
|
||||||
const keywordsByHallName = new Map<string, Set<string>>()
|
|
||||||
|
|
||||||
exhibits.forEach((exhibit) => {
|
|
||||||
const keywordParts = [
|
|
||||||
exhibit.name,
|
|
||||||
exhibit.hallName,
|
|
||||||
exhibit.floorLabel,
|
|
||||||
exhibit.description,
|
|
||||||
exhibit.guideTitle,
|
|
||||||
exhibit.guideText,
|
|
||||||
...(exhibit.tags || [])
|
|
||||||
].filter(Boolean) as string[]
|
|
||||||
|
|
||||||
if (exhibit.hallId) {
|
|
||||||
countsByHallId.set(exhibit.hallId, (countsByHallId.get(exhibit.hallId) || 0) + 1)
|
|
||||||
const keywords = keywordsByHallId.get(exhibit.hallId) || new Set<string>()
|
|
||||||
keywordParts.forEach((part) => keywords.add(part))
|
|
||||||
keywordsByHallId.set(exhibit.hallId, keywords)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exhibit.hallName) {
|
|
||||||
countsByHallName.set(exhibit.hallName, (countsByHallName.get(exhibit.hallName) || 0) + 1)
|
|
||||||
const keywords = keywordsByHallName.get(exhibit.hallName) || new Set<string>()
|
|
||||||
keywordParts.forEach((part) => keywords.add(part))
|
|
||||||
keywordsByHallName.set(exhibit.hallName, keywords)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return halls.map((hall) => {
|
return halls.map((hall) => {
|
||||||
const explainCount = countsByHallId.get(hall.id)
|
|
||||||
|| countsByHallName.get(hall.name)
|
|
||||||
|| hall.exhibitCount
|
|
||||||
|| 0
|
|
||||||
const keywordParts = [
|
const keywordParts = [
|
||||||
hall.name,
|
hall.name,
|
||||||
hall.floorLabel,
|
hall.floorLabel,
|
||||||
hall.description,
|
hall.description,
|
||||||
hall.area,
|
hall.area
|
||||||
...Array.from(keywordsByHallId.get(hall.id) || []),
|
|
||||||
...Array.from(keywordsByHallName.get(hall.name) || [])
|
|
||||||
].filter(Boolean) as string[]
|
].filter(Boolean) as string[]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -637,7 +636,7 @@ const buildExplainHallItems = (
|
|||||||
name: hall.name,
|
name: hall.name,
|
||||||
floorLabel: hall.floorLabel,
|
floorLabel: hall.floorLabel,
|
||||||
image: hall.image,
|
image: hall.image,
|
||||||
explainCount,
|
explainCount: hall.exhibitCount || 0,
|
||||||
searchText: normalizeExplainKeyword(keywordParts.join(' '))
|
searchText: normalizeExplainKeyword(keywordParts.join(' '))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1222,13 +1221,67 @@ const guideSearchText = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 讲解页面处理
|
// 讲解页面处理
|
||||||
const handleExplainHallClick = (hallId: string) => {
|
const handleExplainHallClick = async (hallId: string) => {
|
||||||
|
const hall = explainHallItems.value.find((item) => item.id === hallId)
|
||||||
|
selectedExplainHallId.value = hallId
|
||||||
|
selectedExplainHallName.value = hall?.name || '业务单元'
|
||||||
|
selectedExplainBusinessUnitId.value = ''
|
||||||
|
selectedExplainBusinessUnitName.value = ''
|
||||||
|
explainBusinessUnits.value = []
|
||||||
|
explainError.value = ''
|
||||||
|
explainLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
explainBusinessUnits.value = await explainUseCase.loadTemporaryBusinessUnitsByHall(hallId)
|
||||||
|
explainStage.value = 'unit'
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载展厅讲解点失败:', error)
|
||||||
|
explainError.value = '展厅讲解点加载失败,请稍后重试'
|
||||||
|
explainStage.value = 'unit'
|
||||||
|
} finally {
|
||||||
|
explainLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExplainBusinessUnitClick = (unitId: string) => {
|
||||||
|
const unit = explainBusinessUnits.value.find((item) => item.id === unitId)
|
||||||
|
selectedExplainBusinessUnitId.value = unitId
|
||||||
|
selectedExplainBusinessUnitName.value = unit?.name || '讲解点'
|
||||||
|
explainStage.value = 'stop'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExplainGuideStopClick = (stopId: string) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
id: stopId,
|
||||||
|
tab: 'explain',
|
||||||
|
targetType: 'STOP',
|
||||||
|
targetId: stopId
|
||||||
|
})
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages/hall/detail?id=${encodeURIComponent(hallId)}&tab=explain`
|
url: `/pages/exhibit/detail?${params.toString()}`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExplainBack = () => {
|
const handleExplainBack = () => {
|
||||||
|
if (explainStage.value === 'stop') {
|
||||||
|
explainStage.value = 'unit'
|
||||||
|
selectedExplainBusinessUnitId.value = ''
|
||||||
|
selectedExplainBusinessUnitName.value = ''
|
||||||
|
explainError.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (explainStage.value === 'unit') {
|
||||||
|
explainStage.value = 'hall'
|
||||||
|
selectedExplainHallId.value = ''
|
||||||
|
selectedExplainHallName.value = ''
|
||||||
|
selectedExplainBusinessUnitId.value = ''
|
||||||
|
selectedExplainBusinessUnitName.value = ''
|
||||||
|
explainBusinessUnits.value = []
|
||||||
|
explainError.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
currentTab.value = 'guide'
|
currentTab.value = 'guide'
|
||||||
syncTopTabToUrl('guide')
|
syncTopTabToUrl('guide')
|
||||||
loadTopTabData('guide')
|
loadTopTabData('guide')
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
AudioPlayTargetType
|
AudioPlayTargetType
|
||||||
} from '@/domain/museum'
|
} from '@/domain/museum'
|
||||||
|
import {
|
||||||
|
toGuideStopInfo,
|
||||||
|
type BackendGuideStopInfo,
|
||||||
|
type GuideStopInfo
|
||||||
|
} from '@/data/adapters/guideStopInfoAdapter'
|
||||||
|
|
||||||
export type AudioLanguage = 'zh-CN' | 'en-US'
|
export type AudioLanguage = 'zh-CN' | 'en-US'
|
||||||
|
|
||||||
@@ -16,6 +21,18 @@ export interface AudioPlayInfoRequest {
|
|||||||
lang?: AudioLanguage
|
lang?: AudioLanguage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GuideStopInfoRequest {
|
||||||
|
targetType: AudioPlayTargetType
|
||||||
|
targetId: string
|
||||||
|
lang?: AudioLanguage
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GuideStopInfoResponse {
|
||||||
|
code: number
|
||||||
|
msg?: string
|
||||||
|
data?: BackendGuideStopInfo
|
||||||
|
}
|
||||||
|
|
||||||
export interface AudioPlayInfo {
|
export interface AudioPlayInfo {
|
||||||
playable: boolean
|
playable: boolean
|
||||||
targetType: AudioPlayTargetType
|
targetType: AudioPlayTargetType
|
||||||
@@ -67,6 +84,7 @@ interface AudioTextInfoResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AudioPlayInfoRepository {
|
export interface AudioPlayInfoRepository {
|
||||||
|
getStopInfo(request: GuideStopInfoRequest): Promise<GuideStopInfo>
|
||||||
getPlayInfo(request: AudioPlayInfoRequest): Promise<AudioPlayInfo>
|
getPlayInfo(request: AudioPlayInfoRequest): Promise<AudioPlayInfo>
|
||||||
getTextInfo(request: AudioTextInfoRequest): Promise<AudioTextInfo>
|
getTextInfo(request: AudioTextInfoRequest): Promise<AudioTextInfo>
|
||||||
clearCache(lang?: AudioLanguage): void
|
clearCache(lang?: AudioLanguage): void
|
||||||
@@ -76,6 +94,7 @@ const reasonMessageMap: Record<string, string> = {
|
|||||||
NO_PUBLISHED_AUDIO: '当前语言暂无语音讲解',
|
NO_PUBLISHED_AUDIO: '当前语言暂无语音讲解',
|
||||||
NO_GUIDE_STOP: '该展品暂未配置语音讲解',
|
NO_GUIDE_STOP: '该展品暂未配置语音讲解',
|
||||||
NO_GUIDE_CONTENT: '该目标暂无讲解内容',
|
NO_GUIDE_CONTENT: '该目标暂无讲解内容',
|
||||||
|
NO_TEXT: '当前语言暂无讲解词',
|
||||||
UNSUPPORTED_LANGUAGE: '不支持该语言',
|
UNSUPPORTED_LANGUAGE: '不支持该语言',
|
||||||
UNSUPPORTED_TARGET_TYPE: '该目标类型暂不支持播放',
|
UNSUPPORTED_TARGET_TYPE: '该目标类型暂不支持播放',
|
||||||
TARGET_NOT_FOUND: '该讲解音频暂不可用,当前提供图文讲解',
|
TARGET_NOT_FOUND: '该讲解音频暂不可用,当前提供图文讲解',
|
||||||
@@ -112,6 +131,8 @@ const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let guideAudioApiUnavailable = false
|
||||||
|
|
||||||
const normalizeBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, '')
|
const normalizeBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, '')
|
||||||
|
|
||||||
const resolveAudioApiBaseUrl = () => {
|
const resolveAudioApiBaseUrl = () => {
|
||||||
@@ -123,6 +144,10 @@ const audioKey = ({ targetType, targetId, lang }: Required<AudioPlayInfoRequest>
|
|||||||
`${targetType}:${targetId}:${lang}`
|
`${targetType}:${targetId}:${lang}`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const stopInfoKey = ({ targetType, targetId, lang }: Required<GuideStopInfoRequest>) => (
|
||||||
|
`${targetType}:${targetId}:${lang}`
|
||||||
|
)
|
||||||
|
|
||||||
const audioTextKey = ({ targetType, targetId, lang }: Required<AudioTextInfoRequest>) => (
|
const audioTextKey = ({ targetType, targetId, lang }: Required<AudioTextInfoRequest>) => (
|
||||||
`${targetType}:${targetId}:${lang}`
|
`${targetType}:${targetId}:${lang}`
|
||||||
)
|
)
|
||||||
@@ -131,14 +156,60 @@ const isExpired = (expiresAt?: string | null) => (
|
|||||||
Boolean(expiresAt) && new Date(expiresAt as string).getTime() <= Date.now()
|
Boolean(expiresAt) && new Date(expiresAt as string).getTime() <= Date.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const isMissingRouteResponse = (response: { code: number; msg?: string }) => (
|
||||||
|
response.code === 404 && /请求地址不存在/.test(response.msg || '')
|
||||||
|
)
|
||||||
|
|
||||||
|
const markGuideAudioApiUnavailable = (response: { code: number; msg?: string }) => {
|
||||||
|
if (isMissingRouteResponse(response)) {
|
||||||
|
guideAudioApiUnavailable = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const audioReasonToText = (reason?: string | null) => (
|
export const audioReasonToText = (reason?: string | null) => (
|
||||||
reason ? reasonMessageMap[reason] || '该讲解暂无可播放音频' : '该讲解暂无可播放音频'
|
reason ? reasonMessageMap[reason] || '该讲解暂无可播放音频' : '该讲解暂无可播放音频'
|
||||||
)
|
)
|
||||||
|
|
||||||
export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
||||||
|
private readonly stopInfoCache = new Map<string, GuideStopInfo>()
|
||||||
private readonly cache = new Map<string, AudioPlayInfo>()
|
private readonly cache = new Map<string, AudioPlayInfo>()
|
||||||
private readonly textCache = new Map<string, AudioTextInfo>()
|
private readonly textCache = new Map<string, AudioTextInfo>()
|
||||||
|
|
||||||
|
async getStopInfo(request: GuideStopInfoRequest): Promise<GuideStopInfo> {
|
||||||
|
if (guideAudioApiUnavailable) {
|
||||||
|
throw new Error('讲解展示信息接口暂不可用')
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedRequest: Required<GuideStopInfoRequest> = {
|
||||||
|
targetType: request.targetType,
|
||||||
|
targetId: request.targetId,
|
||||||
|
lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
|
||||||
|
}
|
||||||
|
const key = stopInfoKey(normalizedRequest)
|
||||||
|
const cached = this.stopInfoCache.get(key)
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
targetType: normalizedRequest.targetType,
|
||||||
|
targetId: normalizedRequest.targetId,
|
||||||
|
lang: normalizedRequest.lang
|
||||||
|
})
|
||||||
|
const url = `${resolveAudioApiBaseUrl()}/gis/guide/stop/info?${params.toString()}`
|
||||||
|
const response = await requestJson<GuideStopInfoResponse>(url)
|
||||||
|
|
||||||
|
if (response.code !== 0 || !response.data) {
|
||||||
|
markGuideAudioApiUnavailable(response)
|
||||||
|
throw new Error(response.msg || '讲解点展示信息加载失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopInfo = toGuideStopInfo(response.data, normalizedRequest)
|
||||||
|
this.stopInfoCache.set(key, stopInfo)
|
||||||
|
return stopInfo
|
||||||
|
}
|
||||||
|
|
||||||
async getPlayInfo(request: AudioPlayInfoRequest): Promise<AudioPlayInfo> {
|
async getPlayInfo(request: AudioPlayInfoRequest): Promise<AudioPlayInfo> {
|
||||||
const normalizedRequest: Required<AudioPlayInfoRequest> = {
|
const normalizedRequest: Required<AudioPlayInfoRequest> = {
|
||||||
targetType: request.targetType,
|
targetType: request.targetType,
|
||||||
@@ -146,6 +217,16 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
|||||||
lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
|
lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
|
||||||
}
|
}
|
||||||
const key = audioKey(normalizedRequest)
|
const key = audioKey(normalizedRequest)
|
||||||
|
if (guideAudioApiUnavailable) {
|
||||||
|
return {
|
||||||
|
playable: false,
|
||||||
|
targetType: normalizedRequest.targetType,
|
||||||
|
targetId: normalizedRequest.targetId,
|
||||||
|
lang: normalizedRequest.lang,
|
||||||
|
reason: 'SERVICE_ERROR'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const cached = this.cache.get(key)
|
const cached = this.cache.get(key)
|
||||||
|
|
||||||
if (cached && !isExpired(cached.expiresAt)) {
|
if (cached && !isExpired(cached.expiresAt)) {
|
||||||
@@ -161,6 +242,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
|||||||
const response = await requestJson<AudioPlayInfoResponse>(url)
|
const response = await requestJson<AudioPlayInfoResponse>(url)
|
||||||
|
|
||||||
if (response.code !== 0 || !response.data) {
|
if (response.code !== 0 || !response.data) {
|
||||||
|
markGuideAudioApiUnavailable(response)
|
||||||
throw new Error(response.msg || '语音播放解析失败')
|
throw new Error(response.msg || '语音播放解析失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +262,16 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
|||||||
lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
|
lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
|
||||||
}
|
}
|
||||||
const key = audioTextKey(normalizedRequest)
|
const key = audioTextKey(normalizedRequest)
|
||||||
|
if (guideAudioApiUnavailable) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
targetType: normalizedRequest.targetType,
|
||||||
|
targetId: normalizedRequest.targetId,
|
||||||
|
lang: normalizedRequest.lang,
|
||||||
|
reason: 'SERVICE_ERROR'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const cached = this.textCache.get(key)
|
const cached = this.textCache.get(key)
|
||||||
|
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -195,6 +287,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
|||||||
const response = await requestJson<AudioTextInfoResponse>(url)
|
const response = await requestJson<AudioTextInfoResponse>(url)
|
||||||
|
|
||||||
if (response.code !== 0 || !response.data) {
|
if (response.code !== 0 || !response.data) {
|
||||||
|
markGuideAudioApiUnavailable(response)
|
||||||
throw new Error(response.msg || '讲解词正文解析失败')
|
throw new Error(response.msg || '讲解词正文解析失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,11 +300,18 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
|||||||
|
|
||||||
clearCache(lang?: AudioLanguage) {
|
clearCache(lang?: AudioLanguage) {
|
||||||
if (!lang) {
|
if (!lang) {
|
||||||
|
this.stopInfoCache.clear()
|
||||||
this.cache.clear()
|
this.cache.clear()
|
||||||
this.textCache.clear()
|
this.textCache.clear()
|
||||||
|
guideAudioApiUnavailable = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Array.from(this.stopInfoCache.keys()).forEach((key) => {
|
||||||
|
if (key.endsWith(`:${lang}`)) {
|
||||||
|
this.stopInfoCache.delete(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
Array.from(this.cache.keys()).forEach((key) => {
|
Array.from(this.cache.keys()).forEach((key) => {
|
||||||
if (key.endsWith(`:${lang}`)) {
|
if (key.endsWith(`:${lang}`)) {
|
||||||
this.cache.delete(key)
|
this.cache.delete(key)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ExplainBusinessUnit,
|
||||||
|
ExplainGuideStop,
|
||||||
ExplainTrack,
|
ExplainTrack,
|
||||||
MuseumExhibit,
|
MuseumExhibit,
|
||||||
MuseumHall,
|
MuseumHall,
|
||||||
@@ -23,6 +25,8 @@ export interface ExplainRepository {
|
|||||||
getExhibitById(id: string): Promise<MuseumExhibit | null>
|
getExhibitById(id: string): Promise<MuseumExhibit | null>
|
||||||
listHalls(): Promise<MuseumHall[]>
|
listHalls(): Promise<MuseumHall[]>
|
||||||
getHallById(id: string): Promise<MuseumHall | null>
|
getHallById(id: string): Promise<MuseumHall | null>
|
||||||
|
listGuideStopsByHall(hallId: string): Promise<ExplainGuideStop[]>
|
||||||
|
listTemporaryBusinessUnitsByHall(hallId: string): Promise<ExplainBusinessUnit[]>
|
||||||
listTracks(): Promise<ExplainTrack[]>
|
listTracks(): Promise<ExplainTrack[]>
|
||||||
getTrackByExhibitId(exhibitId: string): Promise<ExplainTrack | null>
|
getTrackByExhibitId(exhibitId: string): Promise<ExplainTrack | null>
|
||||||
searchExplain(keyword?: string): Promise<SearchIndexItem[]>
|
searchExplain(keyword?: string): Promise<SearchIndexItem[]>
|
||||||
@@ -57,6 +61,18 @@ export class DefaultExplainRepository implements ExplainRepository {
|
|||||||
return this.content.getHallById(id)
|
return this.content.getHallById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listGuideStopsByHall(hallId: string) {
|
||||||
|
return this.explainContent.listGuideStopsByHall?.(hallId) || []
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTemporaryBusinessUnitsByHall(hallId: string) {
|
||||||
|
if (this.explainContent.listTemporaryBusinessUnitsByHall) {
|
||||||
|
return this.explainContent.listTemporaryBusinessUnitsByHall(hallId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
async listTracks() {
|
async listTracks() {
|
||||||
const tracks = await this.explainContent.listTracks()
|
const tracks = await this.explainContent.listTracks()
|
||||||
if (tracks.length) return tracks
|
if (tracks.length) return tracks
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
AudioPlayTargetType,
|
AudioPlayTargetType,
|
||||||
|
ExplainBusinessUnit,
|
||||||
|
ExplainGuideStop,
|
||||||
ExplainTrack,
|
ExplainTrack,
|
||||||
MediaAsset,
|
MediaAsset,
|
||||||
MuseumExhibit,
|
MuseumExhibit,
|
||||||
@@ -17,10 +19,15 @@ import {
|
|||||||
import {
|
import {
|
||||||
audioPlayInfoRepository,
|
audioPlayInfoRepository,
|
||||||
audioReasonToText,
|
audioReasonToText,
|
||||||
|
type AudioLanguage,
|
||||||
type AudioPlayInfo,
|
type AudioPlayInfo,
|
||||||
type AudioPlayInfoRepository,
|
type AudioPlayInfoRepository,
|
||||||
|
type GuideStopInfoRequest,
|
||||||
type AudioTextInfo
|
type AudioTextInfo
|
||||||
} from '@/repositories/AudioPlayInfoRepository'
|
} from '@/repositories/AudioPlayInfoRepository'
|
||||||
|
import type {
|
||||||
|
GuideStopInfo
|
||||||
|
} from '@/data/adapters/guideStopInfoAdapter'
|
||||||
import {
|
import {
|
||||||
publishedExhibitAudioRepository,
|
publishedExhibitAudioRepository,
|
||||||
type PublishedExhibitAudioRepository
|
type PublishedExhibitAudioRepository
|
||||||
@@ -38,6 +45,19 @@ export interface ExplainAudioSelection {
|
|||||||
playInfo?: AudioPlayInfo
|
playInfo?: AudioPlayInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExplainDetailEntryRequest {
|
||||||
|
exhibitId: string
|
||||||
|
targetType?: AudioPlayTargetType
|
||||||
|
targetId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExplainTextSelection {
|
||||||
|
exhibit: MuseumExhibit
|
||||||
|
textInfo: AudioTextInfo
|
||||||
|
available: boolean
|
||||||
|
unavailableMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExhibitDetailAudioOptions {
|
export interface ExhibitDetailAudioOptions {
|
||||||
enrichAudio?: boolean
|
enrichAudio?: boolean
|
||||||
includeText?: boolean
|
includeText?: boolean
|
||||||
@@ -76,6 +96,110 @@ export class ExplainUseCase {
|
|||||||
return { targetType, targetId }
|
return { targetType, targetId }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveStopInfoAudioTarget(stopInfo: GuideStopInfo) {
|
||||||
|
return {
|
||||||
|
targetType: stopInfo.playTargetType || stopInfo.targetType,
|
||||||
|
targetId: stopInfo.playTargetId || stopInfo.targetId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveDetailEntryTarget(request: ExplainDetailEntryRequest): Promise<Required<GuideStopInfoRequest>> {
|
||||||
|
const lang = dataSourceConfig.audioLanguage as AudioLanguage
|
||||||
|
if (request.targetType && request.targetId) {
|
||||||
|
return {
|
||||||
|
targetType: request.targetType,
|
||||||
|
targetId: request.targetId,
|
||||||
|
lang
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
targetType: 'ITEM',
|
||||||
|
targetId: request.exhibitId,
|
||||||
|
lang
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toExhibitFromStopInfo(
|
||||||
|
stopInfo: GuideStopInfo,
|
||||||
|
fallback?: MuseumExhibit | null
|
||||||
|
): MuseumExhibit {
|
||||||
|
const linkedPrimary = stopInfo.linkedExhibits[0]
|
||||||
|
const coverImage = stopInfo.coverImageUrl || fallback?.image
|
||||||
|
const description = stopInfo.description || fallback?.description || '该讲解暂无简介。'
|
||||||
|
const audioTarget = this.resolveStopInfoAudioTarget(stopInfo)
|
||||||
|
const audioAvailable = stopInfo.audioStatus === 'READY'
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(fallback || {}),
|
||||||
|
id: fallback?.id || linkedPrimary?.id || stopInfo.targetId,
|
||||||
|
name: stopInfo.title || fallback?.name || linkedPrimary?.name || '讲解内容',
|
||||||
|
hallId: fallback?.hallId,
|
||||||
|
hallName: fallback?.hallName,
|
||||||
|
floorId: fallback?.floorId,
|
||||||
|
floorLabel: fallback?.floorLabel,
|
||||||
|
image: coverImage,
|
||||||
|
description,
|
||||||
|
poiId: fallback?.poiId,
|
||||||
|
sourcePoiId: fallback?.sourcePoiId,
|
||||||
|
location: fallback?.location,
|
||||||
|
year: fallback?.year,
|
||||||
|
material: fallback?.material,
|
||||||
|
size: fallback?.size,
|
||||||
|
tags: fallback?.tags,
|
||||||
|
guideTitle: stopInfo.title || fallback?.guideTitle,
|
||||||
|
guideText: stopInfo.description || fallback?.guideText || fallback?.description,
|
||||||
|
audioUrl: undefined,
|
||||||
|
audioDuration: undefined,
|
||||||
|
audioLanguage: stopInfo.lang,
|
||||||
|
audioHasText: stopInfo.hasText,
|
||||||
|
audioText: undefined,
|
||||||
|
audioTextLength: undefined,
|
||||||
|
audioTextHash: undefined,
|
||||||
|
audioNarrationTier: undefined,
|
||||||
|
audioUnavailableReason: audioAvailable
|
||||||
|
? undefined
|
||||||
|
: audioReasonToText(stopInfo.reason || (stopInfo.audioStatus === 'MISSING' ? 'NO_PUBLISHED_AUDIO' : undefined)),
|
||||||
|
audioAvailable,
|
||||||
|
audioStatus: stopInfo.audioStatus,
|
||||||
|
supportedLanguages: stopInfo.supportedLanguages,
|
||||||
|
imageStatus: stopInfo.imageStatus,
|
||||||
|
imageSource: stopInfo.imageSource,
|
||||||
|
galleryUrls: stopInfo.galleryUrls,
|
||||||
|
linkedExhibits: stopInfo.linkedExhibits,
|
||||||
|
stopInfoAvailable: stopInfo.available,
|
||||||
|
stopInfoReason: stopInfo.reason,
|
||||||
|
resolvedStopId: stopInfo.resolvedStopId,
|
||||||
|
playTargetType: audioTarget.targetType,
|
||||||
|
playTargetId: audioTarget.targetId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toExhibitFromUnavailableStopInfo(
|
||||||
|
fallback: MuseumExhibit,
|
||||||
|
entryTarget: Required<GuideStopInfoRequest>
|
||||||
|
): MuseumExhibit {
|
||||||
|
return {
|
||||||
|
...fallback,
|
||||||
|
guideText: fallback.guideText || fallback.description,
|
||||||
|
audioUrl: undefined,
|
||||||
|
audioDuration: undefined,
|
||||||
|
audioLanguage: entryTarget.lang,
|
||||||
|
audioHasText: false,
|
||||||
|
audioText: undefined,
|
||||||
|
audioTextLength: undefined,
|
||||||
|
audioTextHash: undefined,
|
||||||
|
audioNarrationTier: undefined,
|
||||||
|
audioUnavailableReason: '讲解详情服务暂不可用,当前提供图文讲解',
|
||||||
|
audioAvailable: false,
|
||||||
|
audioStatus: 'SERVICE_ERROR',
|
||||||
|
stopInfoAvailable: false,
|
||||||
|
stopInfoReason: 'SERVICE_ERROR',
|
||||||
|
playTargetType: entryTarget.targetType,
|
||||||
|
playTargetId: entryTarget.targetId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private applyPlayInfo(
|
private applyPlayInfo(
|
||||||
exhibit: MuseumExhibit,
|
exhibit: MuseumExhibit,
|
||||||
playInfo: AudioPlayInfo,
|
playInfo: AudioPlayInfo,
|
||||||
@@ -229,14 +353,107 @@ export class ExplainUseCase {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async enterExplainDetail(request: ExplainDetailEntryRequest) {
|
||||||
|
const entryTarget = await this.resolveDetailEntryTarget(request)
|
||||||
|
const [stopInfoResult, fallbackExhibit] = await Promise.all([
|
||||||
|
this.audioPlayInfo.getStopInfo(entryTarget)
|
||||||
|
.then((stopInfo) => ({ stopInfo, error: null }))
|
||||||
|
.catch((error) => ({ stopInfo: null, error })),
|
||||||
|
this.explain.getExhibitById(request.exhibitId)
|
||||||
|
.catch(() => this.explain.listExplainExhibits()
|
||||||
|
.then((items) => items.find((item) => item.id === request.exhibitId) || null)
|
||||||
|
.catch(() => null))
|
||||||
|
])
|
||||||
|
|
||||||
|
if (stopInfoResult.stopInfo) {
|
||||||
|
return this.toExhibitFromStopInfo(stopInfoResult.stopInfo, fallbackExhibit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackExhibit) {
|
||||||
|
return this.toExhibitFromUnavailableStopInfo(fallbackExhibit, entryTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw stopInfoResult.error || new Error('讲解详情加载失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadExplainDetailText(exhibit: MuseumExhibit): Promise<ExplainTextSelection> {
|
||||||
|
const targetType = exhibit.playTargetType || 'ITEM'
|
||||||
|
const targetId = exhibit.playTargetId || exhibit.id
|
||||||
|
|
||||||
|
try {
|
||||||
|
const textInfo = await this.audioPlayInfo.getTextInfo({
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
lang: (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage)
|
||||||
|
})
|
||||||
|
const nextExhibit = this.applyTextInfo(exhibit, textInfo)
|
||||||
|
|
||||||
|
return {
|
||||||
|
exhibit: nextExhibit,
|
||||||
|
textInfo,
|
||||||
|
available: textInfo.available === true && Boolean(textInfo.text),
|
||||||
|
unavailableMessage: textInfo.available ? undefined : audioReasonToText(textInfo.reason || 'NO_TEXT')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('讲解词正文解析失败:', error)
|
||||||
|
return {
|
||||||
|
exhibit,
|
||||||
|
textInfo: {
|
||||||
|
available: false,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
lang: dataSourceConfig.audioLanguage as AudioLanguage,
|
||||||
|
reason: 'SERVICE_ERROR'
|
||||||
|
},
|
||||||
|
available: false,
|
||||||
|
unavailableMessage: '讲解词服务暂不可用,请稍后重试'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
listHalls(): Promise<MuseumHall[]> {
|
listHalls(): Promise<MuseumHall[]> {
|
||||||
return this.explain.listHalls()
|
return this.explain.listHalls()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadExplainHalls(): Promise<MuseumHall[]> {
|
||||||
|
return this.listHalls()
|
||||||
|
}
|
||||||
|
|
||||||
getHallById(id: string) {
|
getHallById(id: string) {
|
||||||
return this.explain.getHallById(id)
|
return this.explain.getHallById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadTemporaryBusinessUnitsByHall(hallId: string): Promise<ExplainBusinessUnit[]> {
|
||||||
|
return this.explain.listTemporaryBusinessUnitsByHall(hallId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectHall(hallId: string) {
|
||||||
|
const [hall, units] = await Promise.all([
|
||||||
|
this.getHallById(hallId),
|
||||||
|
this.loadTemporaryBusinessUnitsByHall(hallId)
|
||||||
|
])
|
||||||
|
|
||||||
|
return { hall, units }
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectBusinessUnit(hallId: string, unitId: string) {
|
||||||
|
const units = await this.loadTemporaryBusinessUnitsByHall(hallId)
|
||||||
|
return units.find((unit) => unit.id === unitId) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async listGuideStopsByBusinessUnit(hallId: string, unitId: string): Promise<ExplainGuideStop[]> {
|
||||||
|
const unit = await this.selectBusinessUnit(hallId, unitId)
|
||||||
|
return unit?.stops || []
|
||||||
|
}
|
||||||
|
|
||||||
|
openGuideStopDetail(stopId: string) {
|
||||||
|
return this.enterExplainDetail({
|
||||||
|
exhibitId: stopId,
|
||||||
|
targetType: 'STOP',
|
||||||
|
targetId: stopId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async listExhibitsByHallId(hallId: string) {
|
async listExhibitsByHallId(hallId: string) {
|
||||||
const exhibits = await this.listExhibits()
|
const exhibits = await this.listExhibits()
|
||||||
return exhibits.filter((exhibit) => exhibit.hallId === hallId)
|
return exhibits.filter((exhibit) => exhibit.hallId === hallId)
|
||||||
@@ -387,6 +604,64 @@ export class ExplainUseCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async selectAudioForExplainDetail(exhibit: MuseumExhibit): Promise<ExplainAudioSelection> {
|
||||||
|
const targetType = exhibit.playTargetType || 'ITEM'
|
||||||
|
const targetId = exhibit.playTargetId || exhibit.id
|
||||||
|
|
||||||
|
if (exhibit.audioStatus && exhibit.audioStatus !== 'READY') {
|
||||||
|
return {
|
||||||
|
exhibit,
|
||||||
|
track: null,
|
||||||
|
media: null,
|
||||||
|
playable: false,
|
||||||
|
unavailableMessage: audioReasonToText(exhibit.stopInfoReason || 'NO_PUBLISHED_AUDIO')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const playInfo = await this.audioPlayInfo.getPlayInfo({
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
lang: (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!playInfo.playable || !playInfo.playUrl) {
|
||||||
|
return {
|
||||||
|
exhibit,
|
||||||
|
track: null,
|
||||||
|
media: null,
|
||||||
|
playable: false,
|
||||||
|
playInfo,
|
||||||
|
unavailableMessage: audioReasonToText(playInfo.reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
exhibit,
|
||||||
|
track: null,
|
||||||
|
media: {
|
||||||
|
id: `play-${playInfo.audioId || `${targetType}-${targetId}`}`,
|
||||||
|
type: 'audio',
|
||||||
|
url: playInfo.playUrl,
|
||||||
|
duration: typeof playInfo.duration === 'number' ? playInfo.duration : undefined,
|
||||||
|
language: playInfo.lang,
|
||||||
|
available: true
|
||||||
|
},
|
||||||
|
playable: true,
|
||||||
|
playInfo
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('语音播放解析失败:', error)
|
||||||
|
return {
|
||||||
|
exhibit,
|
||||||
|
track: null,
|
||||||
|
media: null,
|
||||||
|
playable: false,
|
||||||
|
unavailableMessage: '语音服务暂不可用,请稍后重试'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const explainUseCase = new ExplainUseCase()
|
export const explainUseCase = new ExplainUseCase()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AudioPlayTargetType,
|
||||||
GuideLocationResolution,
|
GuideLocationResolution,
|
||||||
MuseumExhibit,
|
MuseumExhibit,
|
||||||
MuseumHall,
|
MuseumHall,
|
||||||
@@ -28,6 +29,8 @@ export interface ExplainCardViewModel {
|
|||||||
languageLabel?: string
|
languageLabel?: string
|
||||||
audioStatus: ExplainAudioStatus
|
audioStatus: ExplainAudioStatus
|
||||||
audioStatusText: string
|
audioStatusText: string
|
||||||
|
playTargetType?: AudioPlayTargetType
|
||||||
|
playTargetId?: string
|
||||||
badges: string[]
|
badges: string[]
|
||||||
progress?: number
|
progress?: number
|
||||||
poiId?: string
|
poiId?: string
|
||||||
@@ -52,7 +55,19 @@ export interface ExplainDetailPageViewModel {
|
|||||||
durationLabel?: string
|
durationLabel?: string
|
||||||
language?: string
|
language?: string
|
||||||
unavailableReason?: string
|
unavailableReason?: string
|
||||||
|
playTargetType?: AudioPlayTargetType
|
||||||
|
playTargetId?: string
|
||||||
|
hasText?: boolean
|
||||||
|
statusText?: string
|
||||||
}
|
}
|
||||||
|
imageStatus?: string
|
||||||
|
linkedExhibits?: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
nameEn?: string
|
||||||
|
exhibitCode?: string
|
||||||
|
coverImageUrl?: string
|
||||||
|
}>
|
||||||
chapters: Array<{
|
chapters: Array<{
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
@@ -112,10 +127,14 @@ const languageLabelFor = (language?: string) => {
|
|||||||
return language || undefined
|
return language || undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPlayableAudioUrl = (exhibit: MuseumExhibit) => Boolean(exhibit.audioUrl?.trim())
|
const isAudioReady = (exhibit: MuseumExhibit) => (
|
||||||
|
exhibit.audioStatus === 'READY'
|
||||||
|
|| exhibit.audioAvailable === true
|
||||||
|
|| Boolean(exhibit.audioUrl?.trim())
|
||||||
|
)
|
||||||
|
|
||||||
export const toExplainCardViewModel = (exhibit: MuseumExhibit): ExplainCardViewModel => {
|
export const toExplainCardViewModel = (exhibit: MuseumExhibit): ExplainCardViewModel => {
|
||||||
const hasPlayableAudio = hasPlayableAudioUrl(exhibit)
|
const hasPlayableAudio = isAudioReady(exhibit)
|
||||||
const audioStatus: ExplainAudioStatus = hasPlayableAudio ? 'playable' : 'unavailable'
|
const audioStatus: ExplainAudioStatus = hasPlayableAudio ? 'playable' : 'unavailable'
|
||||||
const location = exhibit.location
|
const location = exhibit.location
|
||||||
|
|
||||||
@@ -135,6 +154,8 @@ export const toExplainCardViewModel = (exhibit: MuseumExhibit): ExplainCardViewM
|
|||||||
languageLabel: languageLabelFor(exhibit.audioLanguage),
|
languageLabel: languageLabelFor(exhibit.audioLanguage),
|
||||||
audioStatus,
|
audioStatus,
|
||||||
audioStatusText: hasPlayableAudio ? '可播放' : '图文讲解',
|
audioStatusText: hasPlayableAudio ? '可播放' : '图文讲解',
|
||||||
|
playTargetType: exhibit.playTargetType,
|
||||||
|
playTargetId: exhibit.playTargetId,
|
||||||
badges: buildBadges(exhibit),
|
badges: buildBadges(exhibit),
|
||||||
progress: undefined,
|
progress: undefined,
|
||||||
poiId: location?.poiId || exhibit.poiId,
|
poiId: location?.poiId || exhibit.poiId,
|
||||||
@@ -146,7 +167,7 @@ export const toExplainCardViewModel = (exhibit: MuseumExhibit): ExplainCardViewM
|
|||||||
export const toExplainExhibitViewModel = (exhibit: MuseumExhibit): ExplainExhibitViewModel => toExplainCardViewModel(exhibit)
|
export const toExplainExhibitViewModel = (exhibit: MuseumExhibit): ExplainExhibitViewModel => toExplainCardViewModel(exhibit)
|
||||||
|
|
||||||
export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDetailPageViewModel => {
|
export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDetailPageViewModel => {
|
||||||
const hasPlayableAudio = hasPlayableAudioUrl(exhibit)
|
const hasPlayableAudio = isAudioReady(exhibit)
|
||||||
const metadataLines = [
|
const metadataLines = [
|
||||||
exhibit.size ? `展品编号:${exhibit.size}` : '',
|
exhibit.size ? `展品编号:${exhibit.size}` : '',
|
||||||
exhibit.year ? `年代:${exhibit.year}` : '',
|
exhibit.year ? `年代:${exhibit.year}` : '',
|
||||||
@@ -163,7 +184,10 @@ export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDet
|
|||||||
title: exhibit.name,
|
title: exhibit.name,
|
||||||
subtitle: buildSubtitle(exhibit),
|
subtitle: buildSubtitle(exhibit),
|
||||||
contentType: contentTypeFor(exhibit),
|
contentType: contentTypeFor(exhibit),
|
||||||
coverImages: [exhibit.image || EXHIBIT_PLACEHOLDER_IMAGE].filter(Boolean),
|
coverImages: [
|
||||||
|
exhibit.image || EXHIBIT_PLACEHOLDER_IMAGE,
|
||||||
|
...(exhibit.galleryUrls || [])
|
||||||
|
].filter(Boolean),
|
||||||
hallId: exhibit.hallId,
|
hallId: exhibit.hallId,
|
||||||
hallName: exhibit.hallName,
|
hallName: exhibit.hallName,
|
||||||
floorLabel: exhibit.floorLabel,
|
floorLabel: exhibit.floorLabel,
|
||||||
@@ -176,8 +200,14 @@ export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDet
|
|||||||
language: exhibit.audioLanguage,
|
language: exhibit.audioLanguage,
|
||||||
unavailableReason: hasPlayableAudio
|
unavailableReason: hasPlayableAudio
|
||||||
? undefined
|
? undefined
|
||||||
: exhibit.audioUnavailableReason || '该展项暂无已发布音频,当前提供图文讲解。'
|
: exhibit.audioUnavailableReason || '当前语言暂无语音讲解。',
|
||||||
|
playTargetType: exhibit.playTargetType,
|
||||||
|
playTargetId: exhibit.playTargetId,
|
||||||
|
hasText: exhibit.audioHasText,
|
||||||
|
statusText: hasPlayableAudio ? '可播放' : '图文讲解'
|
||||||
},
|
},
|
||||||
|
imageStatus: exhibit.imageStatus,
|
||||||
|
linkedExhibits: exhibit.linkedExhibits,
|
||||||
chapters: metadataLines.length
|
chapters: metadataLines.length
|
||||||
? metadataLines.map((line, index) => ({
|
? metadataLines.map((line, index) => ({
|
||||||
id: `${exhibit.id}-metadata-${index}`,
|
id: `${exhibit.id}-metadata-${index}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user