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

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"> <view class="floor-badge">
<text class="floor-badge-text">{{ hall.floorLabel || '楼层待补充' }}</text> <text class="floor-badge-text">{{ hall.floorLabel || '楼层待补充' }}</text>
</view> </view>
<text class="hall-meta-text">{{ hall.explainCount > 0 ? `${hall.explainCount} 条讲解` : '暂无讲解' }}</text> <text class="hall-meta-text">{{ hallMetaText(hall) }}</text>
</view> </view>
</view> </view>
@@ -130,6 +130,8 @@ export interface ExplainHallSelectItem {
floorLabel?: string floorLabel?: string
image?: string image?: string
explainCount: number explainCount: number
businessUnitCount?: number
guideStopCount?: number
searchText: string searchText: string
} }
@@ -227,6 +229,12 @@ const hallIconUrl = (hall: ExplainHallSelectItem) => {
const hallIconText = (name: string) => name.trim().slice(0, 1) || '讲' 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) => { const handleHallClick = (hallId: string) => {
emit('hallClick', hallId) emit('hallClick', hallId)
} }

View File

@@ -294,17 +294,14 @@ export const toBackendGuideStop = (source: BackendGuideStop): ExplainGuideStop |
const id = stringifyId(source.id) const id = stringifyId(source.id)
if (!id) return null if (!id) return null
const targetType = normalizeAudioTargetType(source.targetType) || 'STOP'
const targetId = stringifyId(source.targetId) || id
return { return {
id, id,
name: firstText(source.name, `讲解点${id}`), name: firstText(source.name, `讲解点${id}`),
hallId: stringifyId(source.hallId) || undefined, hallId: stringifyId(source.hallId) || undefined,
hallName: firstText(source.hallName) || undefined, hallName: firstText(source.hallName) || undefined,
floorId: stringifyId(source.floorId) || undefined, floorId: stringifyId(source.floorId) || undefined,
targetType, targetType: 'STOP',
targetId, targetId: id,
coverImageUrl: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined, coverImageUrl: normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined,
description: firstText(source.description) || undefined, description: firstText(source.description) || undefined,
hasAudio: source.hasAudio === true, 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 if (!exhibitId) return
const rawTargetType = Array.isArray(options.targetType) ? options.targetType[0] : options.targetType const rawTargetType = Array.isArray(options.targetType) ? options.targetType[0] : options.targetType
const targetType: AudioPlayTargetType | undefined = rawTargetType === 'ITEM' || rawTargetType === 'STOP' const targetType: AudioPlayTargetType | undefined = rawTargetType === 'ITEM'
? rawTargetType ? 'ITEM'
: undefined : rawTargetType === 'STOP' || rawTargetType === 'GUIDE_STOP'
? 'STOP'
: undefined
const rawTargetId = Array.isArray(options.targetId) ? options.targetId[0] : options.targetId 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 { try {
const exhibitData = await explainUseCase.enterExplainDetail({ const exhibitData = await explainUseCase.enterExplainDetail({

View File

@@ -41,6 +41,9 @@ import ExplainHallSelect, {
import { import {
explainUseCase explainUseCase
} from '@/usecases/explainUseCase' } from '@/usecases/explainUseCase'
import {
normalizeExplainDetailTargetFromGuideStop
} from '@/domain/explainDetailTarget'
import type { import type {
ExplainBusinessUnit, ExplainBusinessUnit,
MuseumHall MuseumHall
@@ -63,13 +66,18 @@ let explainLoadPromise: Promise<void> | null = null
const normalizeExplainKeyword = (value?: string) => (value || '').trim().toLowerCase() 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) => ({ halls.map((hall) => ({
id: hall.id, id: hall.id,
name: hall.name, name: hall.name,
floorLabel: hall.floorLabel, floorLabel: hall.floorLabel,
image: hall.image, image: hall.image,
explainCount: hall.exhibitCount || 0, explainCount: hall.exhibitCount || 0,
businessUnitCount: summaries[hall.id]?.businessUnitCount,
guideStopCount: summaries[hall.id]?.guideStopCount,
searchText: normalizeExplainKeyword([ searchText: normalizeExplainKeyword([
hall.name, hall.name,
hall.floorLabel, hall.floorLabel,
@@ -115,7 +123,8 @@ const loadExplainHalls = async () => {
explainLoadPromise = (async () => { explainLoadPromise = (async () => {
try { try {
const halls = await explainUseCase.loadExplainHalls() 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) { } catch (error) {
console.error('加载讲解展厅失败:', error) console.error('加载讲解展厅失败:', error)
explainError.value = '讲解展厅加载失败,请稍后重试' explainError.value = '讲解展厅加载失败,请稍后重试'
@@ -178,11 +187,12 @@ const handleExplainBusinessUnitClick = (unitId: string) => {
} }
const handleExplainGuideStopClick = (stopId: string) => { const handleExplainGuideStopClick = (stopId: string) => {
const target = normalizeExplainDetailTargetFromGuideStop({ id: stopId })
const params = new URLSearchParams({ const params = new URLSearchParams({
id: stopId, id: target.targetId,
tab: 'explain', tab: 'explain',
targetType: 'STOP', targetType: target.targetType,
targetId: stopId targetId: target.targetId
}) })
uni.navigateTo({ uni.navigateTo({
url: `/pages/exhibit/detail?${params.toString()}` url: `/pages/exhibit/detail?${params.toString()}`

View File

@@ -250,6 +250,9 @@ import {
import { import {
explainUseCase explainUseCase
} from '@/usecases/explainUseCase' } from '@/usecases/explainUseCase'
import {
normalizeExplainDetailTargetFromGuideStop
} from '@/domain/explainDetailTarget'
import type { import type {
GuideRenderPoi GuideRenderPoi
} from '@/domain/guideModel' } from '@/domain/guideModel'
@@ -604,7 +607,8 @@ const loadExplainHalls = async () => {
explainLoadPromise = (async () => { explainLoadPromise = (async () => {
try { try {
const halls = await explainUseCase.loadExplainHalls() 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) { } catch (error) {
console.error('加载讲解内容失败:', error) console.error('加载讲解内容失败:', error)
explainError.value = '讲解内容加载失败,请稍后重试' explainError.value = '讲解内容加载失败,请稍后重试'
@@ -621,7 +625,8 @@ const loadExplainHalls = async () => {
const normalizeExplainKeyword = (value?: string) => (value || '').trim().toLowerCase() const normalizeExplainKeyword = (value?: string) => (value || '').trim().toLowerCase()
const buildExplainHallItems = ( const buildExplainHallItems = (
halls: Awaited<ReturnType<typeof explainUseCase.loadExplainHalls>> halls: Awaited<ReturnType<typeof explainUseCase.loadExplainHalls>>,
summaries: Awaited<ReturnType<typeof explainUseCase.loadExplainHallSummaries>> = {}
): ExplainHallSelectItem[] => { ): ExplainHallSelectItem[] => {
return halls.map((hall) => { return halls.map((hall) => {
const keywordParts = [ const keywordParts = [
@@ -637,6 +642,8 @@ const buildExplainHallItems = (
floorLabel: hall.floorLabel, floorLabel: hall.floorLabel,
image: hall.image, image: hall.image,
explainCount: hall.exhibitCount || 0, explainCount: hall.exhibitCount || 0,
businessUnitCount: summaries[hall.id]?.businessUnitCount,
guideStopCount: summaries[hall.id]?.guideStopCount,
searchText: normalizeExplainKeyword(keywordParts.join(' ')) searchText: normalizeExplainKeyword(keywordParts.join(' '))
} }
}) })
@@ -1251,11 +1258,12 @@ const handleExplainBusinessUnitClick = (unitId: string) => {
} }
const handleExplainGuideStopClick = (stopId: string) => { const handleExplainGuideStopClick = (stopId: string) => {
const target = normalizeExplainDetailTargetFromGuideStop({ id: stopId })
const params = new URLSearchParams({ const params = new URLSearchParams({
id: stopId, id: target.targetId,
tab: 'explain', tab: 'explain',
targetType: 'STOP', targetType: target.targetType,
targetId: stopId targetId: target.targetId
}) })
uni.navigateTo({ uni.navigateTo({
url: `/pages/exhibit/detail?${params.toString()}` 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 resolveAudioApiBaseUrl = () => {
const baseUrl = normalizeBaseUrl(dataSourceConfig.audioApiBaseUrl) const baseUrl = normalizeBaseUrl(dataSourceConfig.audioApiBaseUrl)
@@ -160,9 +167,12 @@ const isMissingRouteResponse = (response: { code: number; msg?: string }) => (
response.code === 404 && /请求地址不存在/.test(response.msg || '') 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)) { if (isMissingRouteResponse(response)) {
guideAudioApiUnavailable = true unavailableAudioApiEndpoints.add(endpoint)
} }
} }
@@ -176,7 +186,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
private readonly textCache = new Map<string, AudioTextInfo>() private readonly textCache = new Map<string, AudioTextInfo>()
async getStopInfo(request: GuideStopInfoRequest): Promise<GuideStopInfo> { async getStopInfo(request: GuideStopInfoRequest): Promise<GuideStopInfo> {
if (guideAudioApiUnavailable) { if (unavailableAudioApiEndpoints.has('stopInfo')) {
throw new Error('讲解展示信息接口暂不可用') throw new Error('讲解展示信息接口暂不可用')
} }
@@ -201,7 +211,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
const response = await requestJson<GuideStopInfoResponse>(url) const response = await requestJson<GuideStopInfoResponse>(url)
if (response.code !== 0 || !response.data) { if (response.code !== 0 || !response.data) {
markGuideAudioApiUnavailable(response) markGuideAudioApiUnavailable('stopInfo', response)
throw new Error(response.msg || '讲解点展示信息加载失败') throw new Error(response.msg || '讲解点展示信息加载失败')
} }
@@ -217,7 +227,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage) lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
} }
const key = audioKey(normalizedRequest) const key = audioKey(normalizedRequest)
if (guideAudioApiUnavailable) { if (unavailableAudioApiEndpoints.has('playInfo')) {
return { return {
playable: false, playable: false,
targetType: normalizedRequest.targetType, targetType: normalizedRequest.targetType,
@@ -242,7 +252,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
const response = await requestJson<AudioPlayInfoResponse>(url) const response = await requestJson<AudioPlayInfoResponse>(url)
if (response.code !== 0 || !response.data) { if (response.code !== 0 || !response.data) {
markGuideAudioApiUnavailable(response) markGuideAudioApiUnavailable('playInfo', response)
throw new Error(response.msg || '语音播放解析失败') throw new Error(response.msg || '语音播放解析失败')
} }
@@ -262,7 +272,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage) lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
} }
const key = audioTextKey(normalizedRequest) const key = audioTextKey(normalizedRequest)
if (guideAudioApiUnavailable) { if (unavailableAudioApiEndpoints.has('textInfo')) {
return { return {
available: false, available: false,
targetType: normalizedRequest.targetType, targetType: normalizedRequest.targetType,
@@ -287,7 +297,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
const response = await requestJson<AudioTextInfoResponse>(url) const response = await requestJson<AudioTextInfoResponse>(url)
if (response.code !== 0 || !response.data) { if (response.code !== 0 || !response.data) {
markGuideAudioApiUnavailable(response) markGuideAudioApiUnavailable('textInfo', response)
throw new Error(response.msg || '讲解词正文解析失败') throw new Error(response.msg || '讲解词正文解析失败')
} }
@@ -303,7 +313,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
this.stopInfoCache.clear() this.stopInfoCache.clear()
this.cache.clear() this.cache.clear()
this.textCache.clear() this.textCache.clear()
guideAudioApiUnavailable = false unavailableAudioApiEndpoints.clear()
return return
} }

View File

@@ -35,6 +35,9 @@ import {
import { import {
dataSourceConfig dataSourceConfig
} from '@/config/dataSource' } from '@/config/dataSource'
import {
normalizeExplainDetailTargetFromGuideStop
} from '@/domain/explainDetailTarget'
export interface ExplainAudioSelection { export interface ExplainAudioSelection {
exhibit: MuseumExhibit exhibit: MuseumExhibit
@@ -63,6 +66,12 @@ export interface ExhibitDetailAudioOptions {
includeText?: boolean includeText?: boolean
} }
export interface ExplainHallSummary {
hallId: string
businessUnitCount: number
guideStopCount: number
}
export class ExplainUseCase { export class ExplainUseCase {
constructor( constructor(
private readonly explain: ExplainRepository = explainRepository, private readonly explain: ExplainRepository = explainRepository,
@@ -355,14 +364,17 @@ export class ExplainUseCase {
async enterExplainDetail(request: ExplainDetailEntryRequest) { async enterExplainDetail(request: ExplainDetailEntryRequest) {
const entryTarget = await this.resolveDetailEntryTarget(request) const entryTarget = await this.resolveDetailEntryTarget(request)
const shouldLoadFallbackExhibit = entryTarget.targetType === 'ITEM'
const [stopInfoResult, fallbackExhibit] = await Promise.all([ const [stopInfoResult, fallbackExhibit] = await Promise.all([
this.audioPlayInfo.getStopInfo(entryTarget) this.audioPlayInfo.getStopInfo(entryTarget)
.then((stopInfo) => ({ stopInfo, error: null })) .then((stopInfo) => ({ stopInfo, error: null }))
.catch((error) => ({ stopInfo: null, error })), .catch((error) => ({ stopInfo: null, error })),
this.explain.getExhibitById(request.exhibitId) shouldLoadFallbackExhibit
.catch(() => this.explain.listExplainExhibits() ? this.explain.getExhibitById(request.exhibitId)
.then((items) => items.find((item) => item.id === request.exhibitId) || null) .catch(() => this.explain.listExplainExhibits()
.catch(() => null)) .then((items) => items.find((item) => item.id === request.exhibitId) || null)
.catch(() => null))
: Promise.resolve(null)
]) ])
if (stopInfoResult.stopInfo) { if (stopInfoResult.stopInfo) {
@@ -419,6 +431,30 @@ export class ExplainUseCase {
return this.listHalls() 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) { getHallById(id: string) {
return this.explain.getHallById(id) return this.explain.getHallById(id)
} }
@@ -447,10 +483,11 @@ export class ExplainUseCase {
} }
openGuideStopDetail(stopId: string) { openGuideStopDetail(stopId: string) {
const target = normalizeExplainDetailTargetFromGuideStop({ id: stopId })
return this.enterExplainDetail({ return this.enterExplainDetail({
exhibitId: stopId, exhibitId: target.targetId,
targetType: 'STOP', targetType: target.targetType,
targetId: stopId targetId: target.targetId
}) })
} }