From 9efcef5190f0c73131617914252a4708b572abe1 Mon Sep 17 00:00:00 2001 From: lyf <2514544224@qq.com> Date: Thu, 2 Jul 2026 00:51:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=B4=E6=97=B6=E6=94=B9=E9=80=A0?= =?UTF-8?q?=E8=AE=B2=E8=A7=A3=E9=A1=B5=E6=96=B9=E6=A1=88=20B=20=E9=93=BE?= =?UTF-8?q?=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接入展厅列表、展厅讲解点分组生成临时业务单元,并迁移讲解详情播放正文到新接口链路。 --- src/components/explain/ExplainHallSelect.vue | 173 ++++++++++- .../adapters/backendExplainDataAdapter.ts | 112 ++++++- src/data/adapters/guideStopInfoAdapter.ts | 164 ++++++++++ .../backendExplainContentProvider.ts | 143 +++++++-- .../providers/staticMuseumContentProvider.ts | 4 + src/domain/museum.ts | 40 +++ src/pages/exhibit/detail.vue | 199 +++++++++--- src/pages/explain/list.vue | 284 ++++++++---------- src/pages/hall/detail.vue | 12 +- src/pages/index/index.vue | 155 ++++++---- src/repositories/AudioPlayInfoRepository.ts | 100 ++++++ src/repositories/ExplainRepository.ts | 16 + src/usecases/explainUseCase.ts | 275 +++++++++++++++++ src/view-models/explainViewModels.ts | 40 ++- 14 files changed, 1429 insertions(+), 288 deletions(-) create mode 100644 src/data/adapters/guideStopInfoAdapter.ts 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 @@ 返回 - 讲解 + {{ headerTitle }} - 正在加载展厅讲解 - 稍后将展示可选择的展厅。 + {{ loadingTitle }} + {{ loadingDescription }} @@ -24,7 +24,7 @@ @@ -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}`,