修复讲解详情接口并展示展厅统计

This commit is contained in:
lyf
2026-07-02 17:22:11 +08:00
parent 9b1f855515
commit ccd37bcd81
8 changed files with 127 additions and 38 deletions

View File

@@ -47,7 +47,7 @@
<view class="floor-badge">
<text class="floor-badge-text">{{ hall.floorLabel || '楼层待补充' }}</text>
</view>
<text class="hall-meta-text">{{ hall.explainCount > 0 ? `${hall.explainCount} 条讲解` : '暂无讲解' }}</text>
<text class="hall-meta-text">{{ hallMetaText(hall) }}</text>
</view>
</view>
@@ -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)
}

View File

@@ -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,

View File

@@ -0,0 +1,15 @@
import type {
ExplainGuideStop
} from './museum'
export interface ExplainDetailTarget {
targetType: 'STOP'
targetId: string
}
export const normalizeExplainDetailTargetFromGuideStop = (
guideStop: Pick<ExplainGuideStop, 'id'>
): ExplainDetailTarget => ({
targetType: 'STOP',
targetId: String(guideStop.id)
})

View File

@@ -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({

View File

@@ -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<void> | null = null
const normalizeExplainKeyword = (value?: string) => (value || '').trim().toLowerCase()
const buildExplainHallItems = (halls: MuseumHall[]): ExplainHallSelectItem[] => (
const buildExplainHallItems = (
halls: MuseumHall[],
summaries: Awaited<ReturnType<typeof explainUseCase.loadExplainHallSummaries>> = {}
): 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()}`

View File

@@ -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<ReturnType<typeof explainUseCase.loadExplainHalls>>
halls: Awaited<ReturnType<typeof explainUseCase.loadExplainHalls>>,
summaries: Awaited<ReturnType<typeof explainUseCase.loadExplainHallSummaries>> = {}
): 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()}`

View File

@@ -131,9 +131,16 @@ const requestJson = <T>(url: string): Promise<T> => 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<string, AudioTextInfo>()
async getStopInfo(request: GuideStopInfoRequest): Promise<GuideStopInfo> {
if (guideAudioApiUnavailable) {
if (unavailableAudioApiEndpoints.has('stopInfo')) {
throw new Error('讲解展示信息接口暂不可用')
}
@@ -201,7 +211,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
const response = await requestJson<GuideStopInfoResponse>(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<AudioPlayInfoResponse>(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<AudioTextInfoResponse>(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
}

View File

@@ -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<Record<string, ExplainHallSummary>> {
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
})
}