diff --git a/src/components/explain/ExplainHallSelect.vue b/src/components/explain/ExplainHallSelect.vue index 3790785..0e60936 100644 --- a/src/components/explain/ExplainHallSelect.vue +++ b/src/components/explain/ExplainHallSelect.vue @@ -47,7 +47,7 @@ {{ hall.floorLabel || '楼层待补充' }} - {{ hall.explainCount > 0 ? `${hall.explainCount} 条讲解` : '暂无讲解' }} + {{ hallMetaText(hall) }} @@ -130,6 +130,8 @@ export interface ExplainHallSelectItem { floorLabel?: string image?: string explainCount: number + businessUnitCount?: number + guideStopCount?: number searchText: string } @@ -227,6 +229,12 @@ const hallIconUrl = (hall: ExplainHallSelectItem) => { const hallIconText = (name: string) => name.trim().slice(0, 1) || '讲' +const hallMetaText = (hall: ExplainHallSelectItem) => ( + typeof hall.businessUnitCount === 'number' || typeof hall.guideStopCount === 'number' + ? `${hall.businessUnitCount || 0} 个业务单元 · ${hall.guideStopCount || 0} 个讲解点` + : hall.explainCount > 0 ? `${hall.explainCount} 个展项` : '展厅' +) + const handleHallClick = (hallId: string) => { emit('hallClick', hallId) } diff --git a/src/data/adapters/backendExplainDataAdapter.ts b/src/data/adapters/backendExplainDataAdapter.ts index e3e7483..83763fd 100644 --- a/src/data/adapters/backendExplainDataAdapter.ts +++ b/src/data/adapters/backendExplainDataAdapter.ts @@ -294,17 +294,14 @@ export const toBackendGuideStop = (source: BackendGuideStop): ExplainGuideStop | 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, + targetType: 'STOP', + targetId: id, coverImageUrl: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined, description: firstText(source.description) || undefined, hasAudio: source.hasAudio === true, diff --git a/src/domain/explainDetailTarget.ts b/src/domain/explainDetailTarget.ts new file mode 100644 index 0000000..34ff80a --- /dev/null +++ b/src/domain/explainDetailTarget.ts @@ -0,0 +1,15 @@ +import type { + ExplainGuideStop +} from './museum' + +export interface ExplainDetailTarget { + targetType: 'STOP' + targetId: string +} + +export const normalizeExplainDetailTargetFromGuideStop = ( + guideStop: Pick +): ExplainDetailTarget => ({ + targetType: 'STOP', + targetId: String(guideStop.id) +}) diff --git a/src/pages/exhibit/detail.vue b/src/pages/exhibit/detail.vue index ddbe3cd..9b509b4 100644 --- a/src/pages/exhibit/detail.vue +++ b/src/pages/exhibit/detail.vue @@ -185,11 +185,15 @@ onLoad(async (options: any = {}) => { if (!exhibitId) return const rawTargetType = Array.isArray(options.targetType) ? options.targetType[0] : options.targetType - const targetType: AudioPlayTargetType | undefined = rawTargetType === 'ITEM' || rawTargetType === 'STOP' - ? rawTargetType - : undefined + const targetType: AudioPlayTargetType | undefined = rawTargetType === 'ITEM' + ? 'ITEM' + : rawTargetType === 'STOP' || rawTargetType === 'GUIDE_STOP' + ? 'STOP' + : undefined const rawTargetId = Array.isArray(options.targetId) ? options.targetId[0] : options.targetId - const targetId = rawTargetId ? String(rawTargetId) : undefined + const targetId = rawTargetType === 'GUIDE_STOP' + ? String(exhibitId) + : rawTargetId ? String(rawTargetId) : undefined try { const exhibitData = await explainUseCase.enterExplainDetail({ diff --git a/src/pages/explain/list.vue b/src/pages/explain/list.vue index 68370b3..5858ced 100644 --- a/src/pages/explain/list.vue +++ b/src/pages/explain/list.vue @@ -41,6 +41,9 @@ import ExplainHallSelect, { import { explainUseCase } from '@/usecases/explainUseCase' +import { + normalizeExplainDetailTargetFromGuideStop +} from '@/domain/explainDetailTarget' import type { ExplainBusinessUnit, MuseumHall @@ -63,13 +66,18 @@ let explainLoadPromise: Promise | null = null const normalizeExplainKeyword = (value?: string) => (value || '').trim().toLowerCase() -const buildExplainHallItems = (halls: MuseumHall[]): ExplainHallSelectItem[] => ( +const buildExplainHallItems = ( + halls: MuseumHall[], + summaries: Awaited> = {} +): ExplainHallSelectItem[] => ( halls.map((hall) => ({ id: hall.id, name: hall.name, floorLabel: hall.floorLabel, image: hall.image, explainCount: hall.exhibitCount || 0, + businessUnitCount: summaries[hall.id]?.businessUnitCount, + guideStopCount: summaries[hall.id]?.guideStopCount, searchText: normalizeExplainKeyword([ hall.name, hall.floorLabel, @@ -115,7 +123,8 @@ const loadExplainHalls = async () => { explainLoadPromise = (async () => { try { const halls = await explainUseCase.loadExplainHalls() - explainHallItems.value = buildExplainHallItems(halls) + const summaries = await explainUseCase.loadExplainHallSummaries(halls.map((hall) => hall.id)) + explainHallItems.value = buildExplainHallItems(halls, summaries) } catch (error) { console.error('加载讲解展厅失败:', error) explainError.value = '讲解展厅加载失败,请稍后重试' @@ -178,11 +187,12 @@ const handleExplainBusinessUnitClick = (unitId: string) => { } const handleExplainGuideStopClick = (stopId: string) => { + const target = normalizeExplainDetailTargetFromGuideStop({ id: stopId }) const params = new URLSearchParams({ - id: stopId, + id: target.targetId, tab: 'explain', - targetType: 'STOP', - targetId: stopId + targetType: target.targetType, + targetId: target.targetId }) uni.navigateTo({ url: `/pages/exhibit/detail?${params.toString()}` diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index 39bad8a..c6bcc56 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -250,6 +250,9 @@ import { import { explainUseCase } from '@/usecases/explainUseCase' +import { + normalizeExplainDetailTargetFromGuideStop +} from '@/domain/explainDetailTarget' import type { GuideRenderPoi } from '@/domain/guideModel' @@ -604,7 +607,8 @@ const loadExplainHalls = async () => { explainLoadPromise = (async () => { try { const halls = await explainUseCase.loadExplainHalls() - explainHallItems.value = buildExplainHallItems(halls) + const summaries = await explainUseCase.loadExplainHallSummaries(halls.map((hall) => hall.id)) + explainHallItems.value = buildExplainHallItems(halls, summaries) } catch (error) { console.error('加载讲解内容失败:', error) explainError.value = '讲解内容加载失败,请稍后重试' @@ -621,7 +625,8 @@ const loadExplainHalls = async () => { const normalizeExplainKeyword = (value?: string) => (value || '').trim().toLowerCase() const buildExplainHallItems = ( - halls: Awaited> + halls: Awaited>, + summaries: Awaited> = {} ): ExplainHallSelectItem[] => { return halls.map((hall) => { const keywordParts = [ @@ -637,6 +642,8 @@ const buildExplainHallItems = ( floorLabel: hall.floorLabel, image: hall.image, explainCount: hall.exhibitCount || 0, + businessUnitCount: summaries[hall.id]?.businessUnitCount, + guideStopCount: summaries[hall.id]?.guideStopCount, searchText: normalizeExplainKeyword(keywordParts.join(' ')) } }) @@ -1251,11 +1258,12 @@ const handleExplainBusinessUnitClick = (unitId: string) => { } const handleExplainGuideStopClick = (stopId: string) => { + const target = normalizeExplainDetailTargetFromGuideStop({ id: stopId }) const params = new URLSearchParams({ - id: stopId, + id: target.targetId, tab: 'explain', - targetType: 'STOP', - targetId: stopId + targetType: target.targetType, + targetId: target.targetId }) uni.navigateTo({ url: `/pages/exhibit/detail?${params.toString()}` diff --git a/src/repositories/AudioPlayInfoRepository.ts b/src/repositories/AudioPlayInfoRepository.ts index 18a24a6..59e85e9 100644 --- a/src/repositories/AudioPlayInfoRepository.ts +++ b/src/repositories/AudioPlayInfoRepository.ts @@ -131,9 +131,16 @@ const requestJson = (url: string): Promise => new Promise((resolve, reject }) }) -let guideAudioApiUnavailable = false +const unavailableAudioApiEndpoints = new Set<'stopInfo' | 'playInfo' | 'textInfo'>() -const normalizeBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, '') +const normalizeBaseUrl = (baseUrl: string) => { + const trimmed = baseUrl.trim().replace(/\/+$/, '') + if (!trimmed || /^https?:\/\//i.test(trimmed) || trimmed.startsWith('/')) { + return trimmed + } + + return `/${trimmed}` +} const resolveAudioApiBaseUrl = () => { const baseUrl = normalizeBaseUrl(dataSourceConfig.audioApiBaseUrl) @@ -160,9 +167,12 @@ const isMissingRouteResponse = (response: { code: number; msg?: string }) => ( response.code === 404 && /请求地址不存在/.test(response.msg || '') ) -const markGuideAudioApiUnavailable = (response: { code: number; msg?: string }) => { +const markGuideAudioApiUnavailable = ( + endpoint: 'stopInfo' | 'playInfo' | 'textInfo', + response: { code: number; msg?: string } +) => { if (isMissingRouteResponse(response)) { - guideAudioApiUnavailable = true + unavailableAudioApiEndpoints.add(endpoint) } } @@ -176,7 +186,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository { private readonly textCache = new Map() async getStopInfo(request: GuideStopInfoRequest): Promise { - if (guideAudioApiUnavailable) { + if (unavailableAudioApiEndpoints.has('stopInfo')) { throw new Error('讲解展示信息接口暂不可用') } @@ -201,7 +211,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository { const response = await requestJson(url) if (response.code !== 0 || !response.data) { - markGuideAudioApiUnavailable(response) + markGuideAudioApiUnavailable('stopInfo', response) throw new Error(response.msg || '讲解点展示信息加载失败') } @@ -217,7 +227,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository { lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage) } const key = audioKey(normalizedRequest) - if (guideAudioApiUnavailable) { + if (unavailableAudioApiEndpoints.has('playInfo')) { return { playable: false, targetType: normalizedRequest.targetType, @@ -242,7 +252,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository { const response = await requestJson(url) if (response.code !== 0 || !response.data) { - markGuideAudioApiUnavailable(response) + markGuideAudioApiUnavailable('playInfo', response) throw new Error(response.msg || '语音播放解析失败') } @@ -262,7 +272,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository { lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage) } const key = audioTextKey(normalizedRequest) - if (guideAudioApiUnavailable) { + if (unavailableAudioApiEndpoints.has('textInfo')) { return { available: false, targetType: normalizedRequest.targetType, @@ -287,7 +297,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository { const response = await requestJson(url) if (response.code !== 0 || !response.data) { - markGuideAudioApiUnavailable(response) + markGuideAudioApiUnavailable('textInfo', response) throw new Error(response.msg || '讲解词正文解析失败') } @@ -303,7 +313,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository { this.stopInfoCache.clear() this.cache.clear() this.textCache.clear() - guideAudioApiUnavailable = false + unavailableAudioApiEndpoints.clear() return } diff --git a/src/usecases/explainUseCase.ts b/src/usecases/explainUseCase.ts index 3e2f2b4..ee9e886 100644 --- a/src/usecases/explainUseCase.ts +++ b/src/usecases/explainUseCase.ts @@ -35,6 +35,9 @@ import { import { dataSourceConfig } from '@/config/dataSource' +import { + normalizeExplainDetailTargetFromGuideStop +} from '@/domain/explainDetailTarget' export interface ExplainAudioSelection { exhibit: MuseumExhibit @@ -63,6 +66,12 @@ export interface ExhibitDetailAudioOptions { includeText?: boolean } +export interface ExplainHallSummary { + hallId: string + businessUnitCount: number + guideStopCount: number +} + export class ExplainUseCase { constructor( private readonly explain: ExplainRepository = explainRepository, @@ -355,14 +364,17 @@ export class ExplainUseCase { async enterExplainDetail(request: ExplainDetailEntryRequest) { const entryTarget = await this.resolveDetailEntryTarget(request) + const shouldLoadFallbackExhibit = entryTarget.targetType === 'ITEM' 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)) + shouldLoadFallbackExhibit + ? this.explain.getExhibitById(request.exhibitId) + .catch(() => this.explain.listExplainExhibits() + .then((items) => items.find((item) => item.id === request.exhibitId) || null) + .catch(() => null)) + : Promise.resolve(null) ]) if (stopInfoResult.stopInfo) { @@ -419,6 +431,30 @@ export class ExplainUseCase { return this.listHalls() } + async loadExplainHallSummaries(hallIds: string[]): Promise> { + const uniqueHallIds = Array.from(new Set(hallIds.map((id) => id.trim()).filter(Boolean))) + const entries = await Promise.all(uniqueHallIds.map(async (hallId) => { + try { + const units = await this.loadTemporaryBusinessUnitsByHall(hallId) + const guideStopCount = units.reduce((total, unit) => total + unit.guideStopCount, 0) + return [hallId, { + hallId, + businessUnitCount: units.length, + guideStopCount + }] as const + } catch (error) { + console.warn('讲解展厅统计加载失败:', hallId, error) + return [hallId, { + hallId, + businessUnitCount: 0, + guideStopCount: 0 + }] as const + } + })) + + return Object.fromEntries(entries) + } + getHallById(id: string) { return this.explain.getHallById(id) } @@ -447,10 +483,11 @@ export class ExplainUseCase { } openGuideStopDetail(stopId: string) { + const target = normalizeExplainDetailTargetFromGuideStop({ id: stopId }) return this.enterExplainDetail({ - exhibitId: stopId, - targetType: 'STOP', - targetId: stopId + exhibitId: target.targetId, + targetType: target.targetType, + targetId: target.targetId }) }