diff --git a/src/components/explain/ExplainHallSelect.vue b/src/components/explain/ExplainHallSelect.vue
index b713227..3790785 100644
--- a/src/components/explain/ExplainHallSelect.vue
+++ b/src/components/explain/ExplainHallSelect.vue
@@ -5,7 +5,7 @@
-
+
- 正在加载展厅讲解
- 稍后将展示可选择的展厅。
+ {{ loadingTitle }}
+ {{ loadingDescription }}
@@ -24,7 +24,7 @@
-
+
+
+
+
+ {{ unit.name.slice(0, 1) || '讲' }}
+
+
+
+ {{ unit.name }}
+
+
+ 业务单元
+
+ {{ unit.guideStopCount }} 个讲解点
+
+
+
+ ›
+
+
+
+
+
+
+
+ {{ hallIconText(stop.name) }}
+
+
+
+ {{ stop.name }}
+ {{ stop.description }}
+
+
+ {{ stop.hasAudio ? '可讲解' : '图文' }}
+
+ {{ stop.floorId || selectedHallName || '楼层待补充' }}
+
+
+
+ ›
+
+
+
- 未找到相关展厅
- 暂无可选择的展厅讲解。
+ {{ emptyTitle }}
+ {{ emptyDescription }}
@@ -76,18 +133,50 @@ export interface ExplainHallSelectItem {
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<{
halls?: ExplainHallSelectItem[]
+ businessUnits?: ExplainBusinessUnitSelectItem[]
+ guideStops?: ExplainGuideStopSelectItem[]
+ stage?: ExplainSelectStage
+ selectedHallName?: string
+ selectedBusinessUnitName?: string
loading?: boolean
error?: string
}>(), {
halls: () => [],
+ businessUnits: () => [],
+ guideStops: () => [],
+ stage: 'hall',
+ selectedHallName: '',
+ selectedBusinessUnitName: '',
loading: false,
error: ''
})
const emit = defineEmits<{
hallClick: [hallId: string]
+ businessUnitClick: [unitId: string]
+ guideStopClick: [stopId: string]
back: []
}>()
@@ -106,6 +195,31 @@ const hallIconMap: Record = {
}
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) => {
return hallIconMap[hall.name.trim()] || hall.image || ''
@@ -117,6 +231,14 @@ const handleHallClick = (hallId: string) => {
emit('hallClick', hallId)
}
+const handleBusinessUnitClick = (unitId: string) => {
+ emit('businessUnitClick', unitId)
+}
+
+const handleGuideStopClick = (stopId: string) => {
+ emit('guideStopClick', stopId)
+}
+
const handleBack = () => {
emit('back')
}
@@ -165,10 +287,11 @@ const handleBack = () => {
left: 12px;
top: 0;
height: 56px;
- min-width: 74px;
+ width: 82px;
display: flex;
align-items: center;
gap: 2px;
+ z-index: 1;
}
.header-back-icon {
@@ -185,10 +308,17 @@ const handleBack = () => {
}
.header-title {
+ max-width: calc(100% - 188px);
+ padding: 0 8px;
+ box-sizing: border-box;
font-size: 18px;
line-height: 25px;
font-weight: 800;
color: #151713;
+ text-align: center;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.hall-list {
@@ -238,11 +368,40 @@ const handleBack = () => {
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 {
flex: 1;
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 {
font-size: 17px;
line-height: 24px;
diff --git a/src/data/adapters/backendExplainDataAdapter.ts b/src/data/adapters/backendExplainDataAdapter.ts
index ac75c83..e3e7483 100644
--- a/src/data/adapters/backendExplainDataAdapter.ts
+++ b/src/data/adapters/backendExplainDataAdapter.ts
@@ -1,5 +1,7 @@
import type {
AudioPlayTargetType,
+ ExplainBusinessUnit,
+ ExplainGuideStop,
ExplainTrack,
MediaAsset,
MuseumExhibit,
@@ -56,6 +58,35 @@ export interface BackendExhibit {
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 {
exhibit: MuseumExhibit
track: ExplainTrack
@@ -180,7 +211,9 @@ export const toBackendMuseumExhibit = (
audioLanguage: supportedLanguage,
audioText: options.includeDetail && guideText ? guideText : undefined,
audioHasText: options.includeDetail ? Boolean(guideText) : undefined,
- audioAvailable,
+ audioAvailable: audioAvailable || metadataSuggestsAudio,
+ audioStatus: source.audioStatus || undefined,
+ supportedLanguages: source.supportedLanguages || undefined,
audioUnavailableReason: audioAvailable || metadataSuggestsAudio
? undefined
: '该讲解暂无已发布音频,当前提供图文讲解。',
@@ -199,7 +232,7 @@ export const toBackendExplainTrack = (exhibit: MuseumExhibit): ExplainTrack => (
coverImage: exhibit.image,
poiId: exhibit.poiId,
floorId: exhibit.floorId,
- available: Boolean(exhibit.audioUrl),
+ available: Boolean(exhibit.audioUrl) || exhibit.audioAvailable === true,
playTargetType: exhibit.playTargetType,
playTargetId: exhibit.playTargetId
})
@@ -236,3 +269,78 @@ export const toBackendHall = (
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()
+ const nameMap = new Map()
+
+ 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
+ }))
+}
diff --git a/src/data/adapters/guideStopInfoAdapter.ts b/src/data/adapters/guideStopInfoAdapter.ts
new file mode 100644
index 0000000..3c03948
--- /dev/null
+++ b/src/data/adapters/guideStopInfoAdapter.ts
@@ -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((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
+ }
+}
diff --git a/src/data/providers/backendExplainContentProvider.ts b/src/data/providers/backendExplainContentProvider.ts
index 33c418d..7d155d4 100644
--- a/src/data/providers/backendExplainContentProvider.ts
+++ b/src/data/providers/backendExplainContentProvider.ts
@@ -1,5 +1,8 @@
import type {
- MuseumExhibit
+ ExplainBusinessUnit,
+ ExplainGuideStop,
+ MuseumExhibit,
+ MuseumHall
} from '@/domain/museum'
import {
dataSourceConfig
@@ -9,10 +12,14 @@ import type {
} from '@/data/providers/staticMuseumContentProvider'
import {
toBackendExplainTrack,
- toBackendHall,
+ toBackendGuideStop,
+ toBackendHallFromList,
toBackendMediaAsset,
toBackendMuseumExhibit,
- type BackendExhibit
+ groupGuideStopsByOutline,
+ type BackendExhibit,
+ type BackendGuideStop,
+ type BackendHall
} from '@/data/adapters/backendExplainDataAdapter'
interface CommonResult {
@@ -65,11 +72,40 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
private readonly searchInflight = new Map>()
private readonly detailCache = new Map()
private readonly detailInflight = new Map>()
+ private hallListCache: MuseumHall[] | null = null
+ private hallListInflight: Promise | null = null
+ private readonly guideStopCache = new Map()
+ private readonly guideStopInflight = new Map>()
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 = '') {
const normalizedKeyword = normalizeKeyword(keyword)
+ if (!normalizedKeyword) return this.requestStaticExplainExhibits()
+
const cacheKey = normalizedKeyword.toLowerCase()
const cached = this.searchCache.get(cacheKey)
if (cached) return cached
@@ -144,15 +180,80 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
return this.fallbackProvider.listHalls().catch(() => [])
}
- async listExplainExhibits() {
+ private async requestHallList() {
+ if (this.hallListCache) return this.hallListCache
+ if (this.hallListInflight) return this.hallListInflight
+
+ this.hallListInflight = (async () => {
+ const url = `${resolveAppApiBaseUrl()}/gis/hall/list`
+ const response = await requestJson>(url)
+ if (response.code !== 0 || !Array.isArray(response.data)) {
+ throw new Error(response.msg || '后端展厅列表加载失败')
+ }
+
+ 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.requestSearch('')
- } catch (error) {
- console.warn('后端讲解列表加载失败,将使用静态讲解兜底:', error)
- return this.fallbackProvider.listExplainExhibits()
+ 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>(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 {
+ const stops = await this.listGuideStopsByHall(hallId)
+ return groupGuideStopsByOutline(hallId, stops)
+ }
+
+ async listExplainExhibits() {
+ return this.requestStaticExplainExhibits()
+ }
+
listExhibits() {
return this.listExplainExhibits()
}
@@ -163,23 +264,9 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
async listHalls() {
try {
- const exhibits = await this.requestSearch('')
- const fallbackHalls = await this.safeFallbackHalls()
- const fallbackHallById = new Map(fallbackHalls.map((hall) => [hall.id, hall]))
- const exhibitsByHallId = new Map()
-
- 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))
- ))
+ return await this.requestHallList()
} catch (error) {
- console.warn('后端展厅聚合加载失败,将使用静态展厅兜底:', error)
+ console.warn('后端展厅列表加载失败,将使用静态展厅兜底:', error)
return this.fallbackProvider.listHalls()
}
}
@@ -205,6 +292,12 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
}
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)
+ })
}
}
diff --git a/src/data/providers/staticMuseumContentProvider.ts b/src/data/providers/staticMuseumContentProvider.ts
index f6da34c..d8588f4 100644
--- a/src/data/providers/staticMuseumContentProvider.ts
+++ b/src/data/providers/staticMuseumContentProvider.ts
@@ -1,4 +1,6 @@
import type {
+ ExplainBusinessUnit,
+ ExplainGuideStop,
ExplainTrack,
MediaAsset,
MuseumExhibit,
@@ -38,6 +40,8 @@ export interface MuseumContentProvider {
export interface ExplainContentProvider extends MuseumContentProvider {
listExplainExhibits(): Promise
searchExplainExhibits?(keyword?: string): Promise
+ listGuideStopsByHall?(hallId: string): Promise
+ listTemporaryBusinessUnitsByHall?(hallId: string): Promise
listTracks(): Promise
getMediaById(id: string): Promise
getMediaForExplainTrack(trackId: string): Promise
diff --git a/src/domain/museum.ts b/src/domain/museum.ts
index f724b28..3de03b2 100644
--- a/src/domain/museum.ts
+++ b/src/domain/museum.ts
@@ -165,10 +165,50 @@ export interface MuseumExhibit {
audioNarrationTier?: 'STANDARD' | 'EXTENDED'
audioUnavailableReason?: string
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
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 {
poiId: string
name: string
diff --git a/src/pages/exhibit/detail.vue b/src/pages/exhibit/detail.vue
index 019ddb3..ddbe3cd 100644
--- a/src/pages/exhibit/detail.vue
+++ b/src/pages/exhibit/detail.vue
@@ -54,6 +54,15 @@
讲解内容
{{ exhibit.body || exhibit.summary }}
+
+ {{ textLoading ? '正在加载讲解词' : '展开讲解词全文' }}
+
+ {{ textError }}
@@ -102,6 +111,9 @@ import {
toExplainDetailPageViewModel,
type ExplainDetailPageViewModel
} from '@/view-models/explainViewModels'
+import type {
+ AudioPlayTargetType
+} from '@/domain/museum'
import {
isGuideTopTab,
type GuideTopTab
@@ -117,7 +129,7 @@ const defaultDetail: ExplainDetailPageViewModel = {
body: '该讲解内容待补充。',
audio: {
status: 'unavailable',
- unavailableReason: '该讲解暂无已发布音频,当前提供图文讲解。'
+ unavailableReason: '当前语言暂无语音讲解。'
},
chapters: [],
relatedItems: []
@@ -135,6 +147,10 @@ const currentAudio = ref(null)
const isPlaying = ref(false)
const activeTopTab = ref('explain')
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 detailMeta = computed(() => [
@@ -142,6 +158,11 @@ const detailMeta = computed(() => [
exhibit.value.floorLabel
].filter(Boolean).join(' · '))
const hallLocationId = computed(() => exhibit.value.hallId || resolvedHallId.value)
+const canExpandText = computed(() => (
+ exhibit.value.audio.hasText === true
+ && !textExpanded.value
+ && !textLoading.value
+))
// 讲解详情页只定位到所属展厅,不再追踪具体展品点位。
const resolveHallGuidePoi = async () => {
@@ -163,51 +184,44 @@ onLoad(async (options: any = {}) => {
const exhibitId = Array.isArray(options.id) ? options.id[0] : options.id
if (!exhibitId) return
- const exhibitData = await explainUseCase.getExhibitById(exhibitId)
- if (exhibitData) {
+ const rawTargetType = Array.isArray(options.targetType) ? options.targetType[0] : options.targetType
+ 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)
if (exhibitData.hallId) {
resolvedHallId.value = exhibitData.hallId
}
- void explainUseCase.enrichExhibitDetailAudio(exhibitId)
- .then((enrichedExhibit) => {
- if (!enrichedExhibit || exhibit.value.id !== exhibitId) return
-
- exhibit.value = toExplainDetailPageViewModel(enrichedExhibit)
- if (enrichedExhibit.hallId) {
- resolvedHallId.value = enrichedExhibit.hallId
- }
- })
- .catch((error) => {
- console.warn('讲解详情音频正文后台补充失败:', error)
- })
+ } catch (error) {
+ console.error('讲解详情加载失败:', error)
+ uni.showToast({
+ title: '讲解详情加载失败,请稍后重试',
+ icon: 'none'
+ })
}
})
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
- ? 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
if (!selection?.playable || !selection.media?.url) {
@@ -220,9 +234,9 @@ const handlePlayAudio = async () => {
const audio: AudioItem = {
id: selection.media.id,
- name: selection.track?.title || selection.exhibit.name,
+ name: selection.playInfo?.title || selection.exhibit.name,
audioUrl: selection.media.url,
- image: selection.exhibit.image || heroImage.value,
+ image: heroImage.value,
duration: selection.media.duration
}
@@ -235,10 +249,41 @@ const handlePlayAudio = async () => {
showAudioPlayer.value = true
currentAudio.value = audio
await nextTick()
- uni.showToast({
- title: '音频已准备好,请点击底部播放按钮',
- icon: 'none'
- })
+ audioPlayerRef.value?.play(audio)
+}
+
+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) => {
@@ -250,6 +295,7 @@ const handleAudioVisibleChange = (visible: boolean) => {
}
const handleAudioPlay = () => {
+ retryingAudio.value = false
isPlaying.value = true
}
@@ -261,11 +307,43 @@ const handleAudioEnded = () => {
isPlaying.value = false
showAudioPlayer.value = false
currentAudio.value = null
+ retryingAudio.value = false
}
-const handleAudioError = (_audio: AudioItem | null, message: string) => {
+const handleAudioError = async (_audio: AudioItem | null, message: string) => {
console.warn('详情音频不可播放:', message)
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
currentAudio.value = null
exhibit.value = {
@@ -485,6 +563,39 @@ const handleBack = () => {
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 {
position: absolute;
left: 0;
diff --git a/src/pages/explain/list.vue b/src/pages/explain/list.vue
index 9a9aa86..68370b3 100644
--- a/src/pages/explain/list.vue
+++ b/src/pages/explain/list.vue
@@ -10,73 +10,103 @@
@back="handleBack"
>
-
-
-
diff --git a/src/pages/hall/detail.vue b/src/pages/hall/detail.vue
index 550704c..07cdfc8 100644
--- a/src/pages/hall/detail.vue
+++ b/src/pages/hall/detail.vue
@@ -203,8 +203,18 @@ const itemMeta = (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({
- url: `/pages/exhibit/detail?id=${encodeURIComponent(exhibit.id)}&tab=${activeTopTab.value}`
+ url: `/pages/exhibit/detail?${params.toString()}`
})
}
diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue
index 4db2cf5..39bad8a 100644
--- a/src/pages/index/index.vue
+++ b/src/pages/index/index.vue
@@ -204,9 +204,16 @@
@@ -224,6 +231,9 @@ import type {
RoutePointOption
} from '@/components/navigation/RoutePointPicker.vue'
import ExplainHallSelect, {
+ type ExplainBusinessUnitSelectItem,
+ type ExplainGuideStopSelectItem,
+ type ExplainSelectStage,
type ExplainHallSelectItem
} from '@/components/explain/ExplainHallSelect.vue'
import {
@@ -246,7 +256,7 @@ import type {
import type {
GuideRouteResult,
GuideRouteTarget,
- MuseumExhibit,
+ ExplainBusinessUnit,
MuseumFloor,
MuseumHall
} from '@/domain/museum'
@@ -435,10 +445,40 @@ const routeSimulationStepText = computed(() => {
})
const explainHallItems = ref([])
+const explainBusinessUnits = ref([])
+const explainStage = ref('hall')
+const selectedExplainHallId = ref('')
+const selectedExplainHallName = ref('')
+const selectedExplainBusinessUnitId = ref('')
+const selectedExplainBusinessUnitName = ref('')
const explainLoading = ref(false)
const explainError = ref('')
let explainLoadPromise: Promise | null = null
+const selectedExplainBusinessUnit = computed(() => (
+ explainBusinessUnits.value.find((unit) => unit.id === selectedExplainBusinessUnitId.value) || null
+))
+const explainBusinessUnitItems = computed(() => (
+ explainBusinessUnits.value.map((unit) => ({
+ id: unit.id,
+ name: unit.name,
+ hallId: unit.hallId,
+ guideStopCount: unit.guideStopCount
+ }))
+))
+const explainGuideStopItems = computed(() => (
+ 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 切换处理
const syncTopTabToUrl = (tabId: GuideTopTab) => {
if (typeof window === 'undefined') return
@@ -452,7 +492,7 @@ const syncTopTabToUrl = (tabId: GuideTopTab) => {
const loadTopTabData = (tabId: GuideTopTab) => {
if (tabId === 'explain') {
- void loadExplainExhibits()
+ void loadExplainHalls()
} else {
void loadGuideFloors()
}
@@ -554,7 +594,7 @@ const loadGuideFloors = async () => {
}
}
-const loadExplainExhibits = async () => {
+const loadExplainHalls = async () => {
if (explainHallItems.value.length) return
if (explainLoadPromise) return explainLoadPromise
@@ -563,11 +603,8 @@ const loadExplainExhibits = async () => {
explainLoadPromise = (async () => {
try {
- const [halls, exhibits] = await Promise.all([
- explainUseCase.listHalls(),
- explainUseCase.listExplainExhibits()
- ])
- explainHallItems.value = buildExplainHallItems(halls, exhibits)
+ const halls = await explainUseCase.loadExplainHalls()
+ explainHallItems.value = buildExplainHallItems(halls)
} catch (error) {
console.error('加载讲解内容失败:', error)
explainError.value = '讲解内容加载失败,请稍后重试'
@@ -584,52 +621,14 @@ const loadExplainExhibits = async () => {
const normalizeExplainKeyword = (value?: string) => (value || '').trim().toLowerCase()
const buildExplainHallItems = (
- halls: Awaited>,
- exhibits: MuseumExhibit[]
+ halls: Awaited>
): ExplainHallSelectItem[] => {
- const countsByHallId = new Map()
- const countsByHallName = new Map()
- const keywordsByHallId = new Map>()
- const keywordsByHallName = new Map>()
-
- 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()
- 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()
- keywordParts.forEach((part) => keywords.add(part))
- keywordsByHallName.set(exhibit.hallName, keywords)
- }
- })
-
return halls.map((hall) => {
- const explainCount = countsByHallId.get(hall.id)
- || countsByHallName.get(hall.name)
- || hall.exhibitCount
- || 0
const keywordParts = [
hall.name,
hall.floorLabel,
hall.description,
- hall.area,
- ...Array.from(keywordsByHallId.get(hall.id) || []),
- ...Array.from(keywordsByHallName.get(hall.name) || [])
+ hall.area
].filter(Boolean) as string[]
return {
@@ -637,7 +636,7 @@ const buildExplainHallItems = (
name: hall.name,
floorLabel: hall.floorLabel,
image: hall.image,
- explainCount,
+ explainCount: hall.exhibitCount || 0,
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({
- url: `/pages/hall/detail?id=${encodeURIComponent(hallId)}&tab=explain`
+ url: `/pages/exhibit/detail?${params.toString()}`
})
}
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'
syncTopTabToUrl('guide')
loadTopTabData('guide')
diff --git a/src/repositories/AudioPlayInfoRepository.ts b/src/repositories/AudioPlayInfoRepository.ts
index 11fae8d..18a24a6 100644
--- a/src/repositories/AudioPlayInfoRepository.ts
+++ b/src/repositories/AudioPlayInfoRepository.ts
@@ -7,6 +7,11 @@ import {
import type {
AudioPlayTargetType
} from '@/domain/museum'
+import {
+ toGuideStopInfo,
+ type BackendGuideStopInfo,
+ type GuideStopInfo
+} from '@/data/adapters/guideStopInfoAdapter'
export type AudioLanguage = 'zh-CN' | 'en-US'
@@ -16,6 +21,18 @@ export interface AudioPlayInfoRequest {
lang?: AudioLanguage
}
+export interface GuideStopInfoRequest {
+ targetType: AudioPlayTargetType
+ targetId: string
+ lang?: AudioLanguage
+}
+
+interface GuideStopInfoResponse {
+ code: number
+ msg?: string
+ data?: BackendGuideStopInfo
+}
+
export interface AudioPlayInfo {
playable: boolean
targetType: AudioPlayTargetType
@@ -67,6 +84,7 @@ interface AudioTextInfoResponse {
}
export interface AudioPlayInfoRepository {
+ getStopInfo(request: GuideStopInfoRequest): Promise
getPlayInfo(request: AudioPlayInfoRequest): Promise
getTextInfo(request: AudioTextInfoRequest): Promise
clearCache(lang?: AudioLanguage): void
@@ -76,6 +94,7 @@ const reasonMessageMap: Record = {
NO_PUBLISHED_AUDIO: '当前语言暂无语音讲解',
NO_GUIDE_STOP: '该展品暂未配置语音讲解',
NO_GUIDE_CONTENT: '该目标暂无讲解内容',
+ NO_TEXT: '当前语言暂无讲解词',
UNSUPPORTED_LANGUAGE: '不支持该语言',
UNSUPPORTED_TARGET_TYPE: '该目标类型暂不支持播放',
TARGET_NOT_FOUND: '该讲解音频暂不可用,当前提供图文讲解',
@@ -112,6 +131,8 @@ const requestJson = (url: string): Promise => new Promise((resolve, reject
})
})
+let guideAudioApiUnavailable = false
+
const normalizeBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, '')
const resolveAudioApiBaseUrl = () => {
@@ -123,6 +144,10 @@ const audioKey = ({ targetType, targetId, lang }: Required
`${targetType}:${targetId}:${lang}`
)
+const stopInfoKey = ({ targetType, targetId, lang }: Required) => (
+ `${targetType}:${targetId}:${lang}`
+)
+
const audioTextKey = ({ targetType, targetId, lang }: Required) => (
`${targetType}:${targetId}:${lang}`
)
@@ -131,14 +156,60 @@ const isExpired = (expiresAt?: string | null) => (
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) => (
reason ? reasonMessageMap[reason] || '该讲解暂无可播放音频' : '该讲解暂无可播放音频'
)
export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
+ private readonly stopInfoCache = new Map()
private readonly cache = new Map()
private readonly textCache = new Map()
+ async getStopInfo(request: GuideStopInfoRequest): Promise {
+ if (guideAudioApiUnavailable) {
+ throw new Error('讲解展示信息接口暂不可用')
+ }
+
+ const normalizedRequest: Required = {
+ 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(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 {
const normalizedRequest: Required = {
targetType: request.targetType,
@@ -146,6 +217,16 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
}
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)
if (cached && !isExpired(cached.expiresAt)) {
@@ -161,6 +242,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
const response = await requestJson(url)
if (response.code !== 0 || !response.data) {
+ markGuideAudioApiUnavailable(response)
throw new Error(response.msg || '语音播放解析失败')
}
@@ -180,6 +262,16 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
}
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)
if (cached) {
@@ -195,6 +287,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
const response = await requestJson(url)
if (response.code !== 0 || !response.data) {
+ markGuideAudioApiUnavailable(response)
throw new Error(response.msg || '讲解词正文解析失败')
}
@@ -207,11 +300,18 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
clearCache(lang?: AudioLanguage) {
if (!lang) {
+ this.stopInfoCache.clear()
this.cache.clear()
this.textCache.clear()
+ guideAudioApiUnavailable = false
return
}
+ Array.from(this.stopInfoCache.keys()).forEach((key) => {
+ if (key.endsWith(`:${lang}`)) {
+ this.stopInfoCache.delete(key)
+ }
+ })
Array.from(this.cache.keys()).forEach((key) => {
if (key.endsWith(`:${lang}`)) {
this.cache.delete(key)
diff --git a/src/repositories/ExplainRepository.ts b/src/repositories/ExplainRepository.ts
index e6f780b..49e479c 100644
--- a/src/repositories/ExplainRepository.ts
+++ b/src/repositories/ExplainRepository.ts
@@ -1,4 +1,6 @@
import type {
+ ExplainBusinessUnit,
+ ExplainGuideStop,
ExplainTrack,
MuseumExhibit,
MuseumHall,
@@ -23,6 +25,8 @@ export interface ExplainRepository {
getExhibitById(id: string): Promise
listHalls(): Promise
getHallById(id: string): Promise
+ listGuideStopsByHall(hallId: string): Promise
+ listTemporaryBusinessUnitsByHall(hallId: string): Promise
listTracks(): Promise
getTrackByExhibitId(exhibitId: string): Promise
searchExplain(keyword?: string): Promise
@@ -57,6 +61,18 @@ export class DefaultExplainRepository implements ExplainRepository {
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() {
const tracks = await this.explainContent.listTracks()
if (tracks.length) return tracks
diff --git a/src/usecases/explainUseCase.ts b/src/usecases/explainUseCase.ts
index fcf2e41..3e2f2b4 100644
--- a/src/usecases/explainUseCase.ts
+++ b/src/usecases/explainUseCase.ts
@@ -1,5 +1,7 @@
import type {
AudioPlayTargetType,
+ ExplainBusinessUnit,
+ ExplainGuideStop,
ExplainTrack,
MediaAsset,
MuseumExhibit,
@@ -17,10 +19,15 @@ import {
import {
audioPlayInfoRepository,
audioReasonToText,
+ type AudioLanguage,
type AudioPlayInfo,
type AudioPlayInfoRepository,
+ type GuideStopInfoRequest,
type AudioTextInfo
} from '@/repositories/AudioPlayInfoRepository'
+import type {
+ GuideStopInfo
+} from '@/data/adapters/guideStopInfoAdapter'
import {
publishedExhibitAudioRepository,
type PublishedExhibitAudioRepository
@@ -38,6 +45,19 @@ export interface ExplainAudioSelection {
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 {
enrichAudio?: boolean
includeText?: boolean
@@ -76,6 +96,110 @@ export class ExplainUseCase {
return { targetType, targetId }
}
+ private resolveStopInfoAudioTarget(stopInfo: GuideStopInfo) {
+ return {
+ targetType: stopInfo.playTargetType || stopInfo.targetType,
+ targetId: stopInfo.playTargetId || stopInfo.targetId
+ }
+ }
+
+ private async resolveDetailEntryTarget(request: ExplainDetailEntryRequest): Promise> {
+ 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
+ ): 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(
exhibit: MuseumExhibit,
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 {
+ 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 {
return this.explain.listHalls()
}
+ loadExplainHalls(): Promise {
+ return this.listHalls()
+ }
+
getHallById(id: string) {
return this.explain.getHallById(id)
}
+ loadTemporaryBusinessUnitsByHall(hallId: string): Promise {
+ 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 {
+ 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) {
const exhibits = await this.listExhibits()
return exhibits.filter((exhibit) => exhibit.hallId === hallId)
@@ -387,6 +604,64 @@ export class ExplainUseCase {
}
}
}
+
+ async selectAudioForExplainDetail(exhibit: MuseumExhibit): Promise {
+ 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()
diff --git a/src/view-models/explainViewModels.ts b/src/view-models/explainViewModels.ts
index 2354e7f..8a6e910 100644
--- a/src/view-models/explainViewModels.ts
+++ b/src/view-models/explainViewModels.ts
@@ -1,4 +1,5 @@
import type {
+ AudioPlayTargetType,
GuideLocationResolution,
MuseumExhibit,
MuseumHall,
@@ -28,6 +29,8 @@ export interface ExplainCardViewModel {
languageLabel?: string
audioStatus: ExplainAudioStatus
audioStatusText: string
+ playTargetType?: AudioPlayTargetType
+ playTargetId?: string
badges: string[]
progress?: number
poiId?: string
@@ -52,7 +55,19 @@ export interface ExplainDetailPageViewModel {
durationLabel?: string
language?: 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<{
id: string
title: string
@@ -112,10 +127,14 @@ const languageLabelFor = (language?: string) => {
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 => {
- const hasPlayableAudio = hasPlayableAudioUrl(exhibit)
+ const hasPlayableAudio = isAudioReady(exhibit)
const audioStatus: ExplainAudioStatus = hasPlayableAudio ? 'playable' : 'unavailable'
const location = exhibit.location
@@ -135,6 +154,8 @@ export const toExplainCardViewModel = (exhibit: MuseumExhibit): ExplainCardViewM
languageLabel: languageLabelFor(exhibit.audioLanguage),
audioStatus,
audioStatusText: hasPlayableAudio ? '可播放' : '图文讲解',
+ playTargetType: exhibit.playTargetType,
+ playTargetId: exhibit.playTargetId,
badges: buildBadges(exhibit),
progress: undefined,
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 toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDetailPageViewModel => {
- const hasPlayableAudio = hasPlayableAudioUrl(exhibit)
+ const hasPlayableAudio = isAudioReady(exhibit)
const metadataLines = [
exhibit.size ? `展品编号:${exhibit.size}` : '',
exhibit.year ? `年代:${exhibit.year}` : '',
@@ -163,7 +184,10 @@ export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDet
title: exhibit.name,
subtitle: buildSubtitle(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,
hallName: exhibit.hallName,
floorLabel: exhibit.floorLabel,
@@ -176,8 +200,14 @@ export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDet
language: exhibit.audioLanguage,
unavailableReason: hasPlayableAudio
? 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
? metadataLines.map((line, index) => ({
id: `${exhibit.id}-metadata-${index}`,