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
})
}