From d86845afeb1323dfe7bdc92c4c3bbc78460e976c Mon Sep 17 00:00:00 2001 From: lyf <2514544224@qq.com> Date: Thu, 16 Jul 2026 14:43:57 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=AE=B2=E8=A7=A3=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E8=AF=B7=E6=B1=82=E4=B8=8E=E7=BC=93=E5=AD=98=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 + src/config/dataSource.ts | 14 +- .../backendExplainContentProvider.ts | 104 ++++++---- .../providers/staticMuseumContentProvider.ts | 2 + src/env.d.ts | 2 + src/pages/exhibit/detail.vue | 17 +- src/pages/explain/guide-stop-list.vue | 16 +- src/repositories/AudioPlayInfoRepository.ts | 177 ++++++++++-------- src/repositories/ExplainRepository.ts | 18 ++ src/usecases/explainUseCase.ts | 42 +++-- tests/unit/ExhibitDetailAudioFirst.spec.ts | 1 + 11 files changed, 259 insertions(+), 137 deletions(-) diff --git a/.env.example b/.env.example index 4656d72..54feb89 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,9 @@ VITE_GUIDE_CONTENT_SOURCE_MODE=remote VITE_GUIDE_STATIC_DATA_BASE_URL=/static/guide-data VITE_API_BASE_URL=/app-api VITE_AUDIO_API_BASE_URL=/app-api +# Optional explain request timeouts. Legacy deployments continue to use 8000ms. +VITE_EXPLAIN_API_TIMEOUT_MS=8000 +VITE_AUDIO_API_TIMEOUT_MS=8000 VITE_AUDIO_LANGUAGE=zh-CN VITE_SGS_API_BASE_URL=/app-api VITE_SGS_MAP_ID=1 diff --git a/src/config/dataSource.ts b/src/config/dataSource.ts index 37040c3..3f352f3 100644 --- a/src/config/dataSource.ts +++ b/src/config/dataSource.ts @@ -12,6 +12,7 @@ const defaultApiBaseUrl = '/app-api' const defaultSgsMapId = '1' const defaultGuideStaticDataBaseUrl = '/static/guide-data' const defaultAudioApiBaseUrl = '/app-api' +const defaultExplainRequestTimeoutMs = 8000 const normalizeMode = (mode: string | undefined): DataSourceMode => { if (mode && allowedModes.has(mode as DataSourceMode)) { @@ -43,9 +44,9 @@ const normalizeUrl = (url: string | undefined, fallback: string) => { return normalized || fallback } -const normalizeTimeout = (timeoutValue: string | undefined) => { +const normalizeTimeout = (timeoutValue: string | undefined, fallback = defaultSdkTimeoutMs) => { const timeout = Number(timeoutValue) - return Number.isFinite(timeout) && timeout > 0 ? timeout : defaultSdkTimeoutMs + return Number.isFinite(timeout) && timeout > 0 ? timeout : fallback } const inferOrigin = (url: string) => { @@ -91,6 +92,15 @@ export const dataSourceConfig = { sgsMapEngineUrl: normalizeUrl(import.meta.env.VITE_SGS_H5_ENGINE_URL, defaultSgsEngineUrl), sgsSdkTargetOrigin: import.meta.env.VITE_SGS_SDK_ORIGIN?.trim() || '', sgsSdkTimeoutMs: normalizeTimeout(import.meta.env.VITE_SGS_SDK_TIMEOUT_MS), + // Keep the historical SGS timeout independent from explain/catalog requests. + explainApiTimeoutMs: normalizeTimeout( + import.meta.env.VITE_EXPLAIN_API_TIMEOUT_MS || import.meta.env.VITE_GUIDE_CONTENT_TIMEOUT_MS, + defaultExplainRequestTimeoutMs + ), + audioApiTimeoutMs: normalizeTimeout( + import.meta.env.VITE_AUDIO_API_TIMEOUT_MS || import.meta.env.VITE_EXPLAIN_API_TIMEOUT_MS, + defaultExplainRequestTimeoutMs + ), publicSameOriginAssetHost: import.meta.env.VITE_PUBLIC_SAME_ORIGIN_ASSET_HOST?.trim() || '', publicLegacyAudioHost: import.meta.env.VITE_PUBLIC_LEGACY_AUDIO_HOST?.trim() || '' } diff --git a/src/data/providers/backendExplainContentProvider.ts b/src/data/providers/backendExplainContentProvider.ts index 4c96fda..8d5f3b2 100644 --- a/src/data/providers/backendExplainContentProvider.ts +++ b/src/data/providers/backendExplainContentProvider.ts @@ -53,7 +53,7 @@ const requestJson = (url: string): Promise => new Promise((resolve, reject uni.request({ url, method: 'GET', - timeout: dataSourceConfig.sgsSdkTimeoutMs, + timeout: dataSourceConfig.explainApiTimeoutMs, success: (response) => { const statusCode = Number(response.statusCode || 0) if (statusCode < 200 || statusCode >= 300) { @@ -76,6 +76,13 @@ const normalizeKeyword = (keyword = '') => keyword.trim() const catalogLang = () => dataSourceConfig.audioLanguage const cacheKey = (...parts: string[]) => [catalogLang(), ...parts].join(':') +const CATALOG_CACHE_TTL_MS = 60_000 +const CATALOG_CACHE_MAX_ENTRIES = 80 + +interface TimedCacheEntry { + value: T + expiresAt: number +} const requireArrayData = (response: CommonResult, message: string) => { if (response.code !== 0) { @@ -86,22 +93,45 @@ const requireArrayData = (response: CommonResult, message: string) => { } export class BackendExplainContentProvider implements ExplainContentProvider { - private readonly explainExhibitCache = new Map() + private readonly explainExhibitCache = new Map>() private readonly explainExhibitInflight = new Map>() - private readonly detailCache = new Map() - private readonly hallListCache = new Map() + private readonly detailCache = new Map>() + private readonly hallListCache = new Map>() private readonly hallListInflight = new Map>() - private readonly guideStopCache = new Map() + private readonly guideStopCache = new Map>() private readonly guideStopInflight = new Map>() - private readonly outlineCache = new Map() + private readonly outlineCache = new Map>() private readonly outlineInflight = new Map>() - private readonly businessUnitCache = new Map() + private readonly businessUnitCache = new Map>() private readonly businessUnitInflight = new Map>() constructor( private readonly fallbackProvider?: ExplainContentProvider ) {} + private getCached(cache: Map>, key: string): T | null { + const entry = cache.get(key) + if (!entry) return null + if (entry.expiresAt <= Date.now()) { + cache.delete(key) + return null + } + // Read promotes LRU position. + cache.delete(key) + cache.set(key, entry) + return entry.value + } + + private setCached(cache: Map>, key: string, value: T) { + cache.delete(key) + cache.set(key, { value, expiresAt: Date.now() + CATALOG_CACHE_TTL_MS }) + while (cache.size > CATALOG_CACHE_MAX_ENTRIES) { + const oldestKey = cache.keys().next().value + if (!oldestKey) break + cache.delete(oldestKey) + } + } + private async safeFallbackHalls() { if (!this.isStaticFallbackEnabled()) return [] @@ -128,7 +158,7 @@ export class BackendExplainContentProvider implements ExplainContentProvider { private async requestCatalogHalls() { const key = cacheKey('halls') - const cached = this.hallListCache.get(key) + const cached = this.getCached(this.hallListCache, key) if (cached) return cached const inflight = this.hallListInflight.get(key) @@ -149,7 +179,7 @@ export class BackendExplainContentProvider implements ExplainContentProvider { )) .filter((hall) => hall.id) - this.hallListCache.set(key, halls) + this.setCached(this.hallListCache, key, halls) return halls })() @@ -166,7 +196,7 @@ export class BackendExplainContentProvider implements ExplainContentProvider { if (!normalizedHallId) return [] const key = cacheKey('hall', normalizedHallId, 'outlines') - const cached = this.outlineCache.get(key) + const cached = this.getCached(this.outlineCache, key) if (cached) return cached const inflight = this.outlineInflight.get(key) @@ -180,7 +210,7 @@ export class BackendExplainContentProvider implements ExplainContentProvider { const url = `${resolveAppApiBaseUrl()}/gis/guide/catalog/halls/${encodeURIComponent(normalizedHallId)}/outlines?${params.toString()}` const response = await requestJson>(url) const outlines = requireArrayData(response, '讲解单元目录加载失败') - this.outlineCache.set(key, outlines) + this.setCached(this.outlineCache, key, outlines) return outlines })() @@ -197,7 +227,7 @@ export class BackendExplainContentProvider implements ExplainContentProvider { if (!normalizedHallId) return [] const key = cacheKey('hall', normalizedHallId, 'stops') - const cached = this.guideStopCache.get(key) + const cached = this.getCached(this.guideStopCache, key) if (cached) return cached const inflight = this.guideStopInflight.get(key) @@ -226,7 +256,7 @@ export class BackendExplainContentProvider implements ExplainContentProvider { }) .filter(Boolean) as ExplainGuideStop[] - this.guideStopCache.set(key, stops) + this.setCached(this.guideStopCache, key, stops) return stops })() @@ -244,13 +274,13 @@ export class BackendExplainContentProvider implements ExplainContentProvider { if (!normalizedOutlineId) return [] const hallStopsKey = cacheKey('hall', normalizedHallId, 'stops') - const cachedHallStops = this.guideStopCache.get(hallStopsKey) + const cachedHallStops = this.getCached(this.guideStopCache, hallStopsKey) if (cachedHallStops) { return cachedHallStops.filter((stop) => stop.outlineId === normalizedOutlineId) } const key = cacheKey('outline', normalizedOutlineId, 'stops') - const cached = this.guideStopCache.get(key) + const cached = this.getCached(this.guideStopCache, key) if (cached) return cached const inflight = this.guideStopInflight.get(key) @@ -276,7 +306,7 @@ export class BackendExplainContentProvider implements ExplainContentProvider { : null)) .filter(Boolean) as ExplainGuideStop[] - this.guideStopCache.set(key, stops) + this.setCached(this.guideStopCache, key, stops) return stops })() @@ -290,7 +320,7 @@ export class BackendExplainContentProvider implements ExplainContentProvider { private async requestCatalogExhibits() { const key = cacheKey('exhibits') - const cached = this.explainExhibitCache.get(key) + const cached = this.getCached(this.explainExhibitCache, key) if (cached) return cached const inflight = this.explainExhibitInflight.get(key) @@ -303,9 +333,9 @@ export class BackendExplainContentProvider implements ExplainContentProvider { return stops.map((stop) => toCatalogMuseumExhibitFromStop(stop, hall)) })) const exhibits = stopsByHall.flat() - this.explainExhibitCache.set(key, exhibits) + this.setCached(this.explainExhibitCache, key, exhibits) exhibits.forEach((exhibit) => { - this.detailCache.set(exhibit.id, exhibit) + this.setCached(this.detailCache, exhibit.id, exhibit) }) return exhibits })() @@ -350,26 +380,17 @@ export class BackendExplainContentProvider implements ExplainContentProvider { if (!normalizedHallId) return [] const key = cacheKey('hall', normalizedHallId, 'units') - const cached = this.businessUnitCache.get(key) + const cached = this.getCached(this.businessUnitCache, key) if (cached) return cached const inflight = this.businessUnitInflight.get(key) if (inflight) return inflight const promise = (async () => { - const [outlines, stops] = await Promise.all([ - this.requestCatalogOutlinesByHall(normalizedHallId), - this.requestCatalogStopsByHall(normalizedHallId) - ]) - const units = toCatalogBusinessUnits(normalizedHallId, outlines, stops) - - await Promise.all(units.map(async (unit) => { - if (unit.stops.length) return - unit.stops = await this.requestCatalogStopsByOutline(normalizedHallId, unit.id).catch(() => []) - unit.guideStopCount = unit.stops.length || unit.guideStopCount - })) - - this.businessUnitCache.set(key, units) + const outlines = await this.requestCatalogOutlinesByHall(normalizedHallId) + // The outline response carries authoritative counts. Stops are loaded only after unit selection. + const units = toCatalogBusinessUnits(normalizedHallId, outlines, []) + this.setCached(this.businessUnitCache, key, units) return units })().catch((error) => { return this.fallbackOrThrow(error, '讲解单元目录加载失败', async () => ( @@ -395,6 +416,19 @@ export class BackendExplainContentProvider implements ExplainContentProvider { } } + async listExplainExhibitsByHall(hallId: string) { + const [halls, stops] = await Promise.all([ + this.listHalls(), + this.listGuideStopsByHall(hallId) + ]) + const hall = halls.find((item) => item.id === hallId) || null + return stops.map((stop) => toCatalogMuseumExhibitFromStop(stop, hall || undefined)) + } + + listGuideStopsByBusinessUnit(hallId: string, unitId: string) { + return this.requestCatalogStopsByOutline(hallId, unitId) + } + listExhibits() { return this.listExplainExhibits() } @@ -403,7 +437,7 @@ export class BackendExplainContentProvider implements ExplainContentProvider { const normalizedId = id.trim() if (!normalizedId) return null - const cached = this.detailCache.get(normalizedId) + const cached = this.getCached(this.detailCache, normalizedId) if (cached) return cached const catalogExhibits = await this.listExplainExhibits().catch(() => []) @@ -414,7 +448,7 @@ export class BackendExplainContentProvider implements ExplainContentProvider { || exhibit.linkedExhibits?.some((linked) => linked.id === normalizedId) )) if (matched) { - this.detailCache.set(normalizedId, matched) + this.setCached(this.detailCache, normalizedId, matched) return matched } diff --git a/src/data/providers/staticMuseumContentProvider.ts b/src/data/providers/staticMuseumContentProvider.ts index 0ecada9..c19faa8 100644 --- a/src/data/providers/staticMuseumContentProvider.ts +++ b/src/data/providers/staticMuseumContentProvider.ts @@ -41,8 +41,10 @@ export interface MuseumContentProvider { export interface ExplainContentProvider extends MuseumContentProvider { listExplainExhibits(): Promise + listExplainExhibitsByHall?(hallId: string): Promise searchExplainExhibits?(keyword?: string): Promise listGuideStopsByHall?(hallId: string): Promise + listGuideStopsByBusinessUnit?(hallId: string, unitId: string): Promise listTemporaryBusinessUnitsByHall?(hallId: string): Promise listTracks(): Promise getMediaById(id: string): Promise diff --git a/src/env.d.ts b/src/env.d.ts index 84a9c2f..4d56781 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -15,6 +15,8 @@ interface ImportMetaEnv { readonly VITE_GUIDE_STATIC_DATA_BASE_URL?: string readonly VITE_API_BASE_URL?: string readonly VITE_AUDIO_API_BASE_URL?: string + readonly VITE_EXPLAIN_API_TIMEOUT_MS?: string + readonly VITE_AUDIO_API_TIMEOUT_MS?: string readonly VITE_AUDIO_LANGUAGE?: 'zh-CN' | 'yue-HK' | 'en-US' readonly VITE_SGS_API_BASE_URL?: string readonly VITE_SGS_MAP_ID?: string diff --git a/src/pages/exhibit/detail.vue b/src/pages/exhibit/detail.vue index ce365c5..ba8beeb 100644 --- a/src/pages/exhibit/detail.vue +++ b/src/pages/exhibit/detail.vue @@ -77,8 +77,8 @@