@@ -11,6 +11,9 @@ VITE_GUIDE_CONTENT_SOURCE_MODE=remote
|
|||||||
VITE_GUIDE_STATIC_DATA_BASE_URL=/static/guide-data
|
VITE_GUIDE_STATIC_DATA_BASE_URL=/static/guide-data
|
||||||
VITE_API_BASE_URL=/app-api
|
VITE_API_BASE_URL=/app-api
|
||||||
VITE_AUDIO_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_AUDIO_LANGUAGE=zh-CN
|
||||||
VITE_SGS_API_BASE_URL=/app-api
|
VITE_SGS_API_BASE_URL=/app-api
|
||||||
VITE_SGS_MAP_ID=1
|
VITE_SGS_MAP_ID=1
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const defaultApiBaseUrl = '/app-api'
|
|||||||
const defaultSgsMapId = '1'
|
const defaultSgsMapId = '1'
|
||||||
const defaultGuideStaticDataBaseUrl = '/static/guide-data'
|
const defaultGuideStaticDataBaseUrl = '/static/guide-data'
|
||||||
const defaultAudioApiBaseUrl = '/app-api'
|
const defaultAudioApiBaseUrl = '/app-api'
|
||||||
|
const defaultExplainRequestTimeoutMs = 8000
|
||||||
|
|
||||||
const normalizeMode = (mode: string | undefined): DataSourceMode => {
|
const normalizeMode = (mode: string | undefined): DataSourceMode => {
|
||||||
if (mode && allowedModes.has(mode as DataSourceMode)) {
|
if (mode && allowedModes.has(mode as DataSourceMode)) {
|
||||||
@@ -43,9 +44,9 @@ const normalizeUrl = (url: string | undefined, fallback: string) => {
|
|||||||
return normalized || fallback
|
return normalized || fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeTimeout = (timeoutValue: string | undefined) => {
|
const normalizeTimeout = (timeoutValue: string | undefined, fallback = defaultSdkTimeoutMs) => {
|
||||||
const timeout = Number(timeoutValue)
|
const timeout = Number(timeoutValue)
|
||||||
return Number.isFinite(timeout) && timeout > 0 ? timeout : defaultSdkTimeoutMs
|
return Number.isFinite(timeout) && timeout > 0 ? timeout : fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
const inferOrigin = (url: string) => {
|
const inferOrigin = (url: string) => {
|
||||||
@@ -91,6 +92,15 @@ export const dataSourceConfig = {
|
|||||||
sgsMapEngineUrl: normalizeUrl(import.meta.env.VITE_SGS_H5_ENGINE_URL, defaultSgsEngineUrl),
|
sgsMapEngineUrl: normalizeUrl(import.meta.env.VITE_SGS_H5_ENGINE_URL, defaultSgsEngineUrl),
|
||||||
sgsSdkTargetOrigin: import.meta.env.VITE_SGS_SDK_ORIGIN?.trim() || '',
|
sgsSdkTargetOrigin: import.meta.env.VITE_SGS_SDK_ORIGIN?.trim() || '',
|
||||||
sgsSdkTimeoutMs: normalizeTimeout(import.meta.env.VITE_SGS_SDK_TIMEOUT_MS),
|
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() || '',
|
publicSameOriginAssetHost: import.meta.env.VITE_PUBLIC_SAME_ORIGIN_ASSET_HOST?.trim() || '',
|
||||||
publicLegacyAudioHost: import.meta.env.VITE_PUBLIC_LEGACY_AUDIO_HOST?.trim() || ''
|
publicLegacyAudioHost: import.meta.env.VITE_PUBLIC_LEGACY_AUDIO_HOST?.trim() || ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject
|
|||||||
uni.request({
|
uni.request({
|
||||||
url,
|
url,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
timeout: dataSourceConfig.sgsSdkTimeoutMs,
|
timeout: dataSourceConfig.explainApiTimeoutMs,
|
||||||
success: (response) => {
|
success: (response) => {
|
||||||
const statusCode = Number(response.statusCode || 0)
|
const statusCode = Number(response.statusCode || 0)
|
||||||
if (statusCode < 200 || statusCode >= 300) {
|
if (statusCode < 200 || statusCode >= 300) {
|
||||||
@@ -76,6 +76,13 @@ const normalizeKeyword = (keyword = '') => keyword.trim()
|
|||||||
const catalogLang = () => dataSourceConfig.audioLanguage
|
const catalogLang = () => dataSourceConfig.audioLanguage
|
||||||
|
|
||||||
const cacheKey = (...parts: string[]) => [catalogLang(), ...parts].join(':')
|
const cacheKey = (...parts: string[]) => [catalogLang(), ...parts].join(':')
|
||||||
|
const CATALOG_CACHE_TTL_MS = 60_000
|
||||||
|
const CATALOG_CACHE_MAX_ENTRIES = 80
|
||||||
|
|
||||||
|
interface TimedCacheEntry<T> {
|
||||||
|
value: T
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
const requireArrayData = <T>(response: CommonResult<T[]>, message: string) => {
|
const requireArrayData = <T>(response: CommonResult<T[]>, message: string) => {
|
||||||
if (response.code !== 0) {
|
if (response.code !== 0) {
|
||||||
@@ -86,22 +93,45 @@ const requireArrayData = <T>(response: CommonResult<T[]>, message: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class BackendExplainContentProvider implements ExplainContentProvider {
|
export class BackendExplainContentProvider implements ExplainContentProvider {
|
||||||
private readonly explainExhibitCache = new Map<string, MuseumExhibit[]>()
|
private readonly explainExhibitCache = new Map<string, TimedCacheEntry<MuseumExhibit[]>>()
|
||||||
private readonly explainExhibitInflight = new Map<string, Promise<MuseumExhibit[]>>()
|
private readonly explainExhibitInflight = new Map<string, Promise<MuseumExhibit[]>>()
|
||||||
private readonly detailCache = new Map<string, MuseumExhibit>()
|
private readonly detailCache = new Map<string, TimedCacheEntry<MuseumExhibit>>()
|
||||||
private readonly hallListCache = new Map<string, MuseumHall[]>()
|
private readonly hallListCache = new Map<string, TimedCacheEntry<MuseumHall[]>>()
|
||||||
private readonly hallListInflight = new Map<string, Promise<MuseumHall[]>>()
|
private readonly hallListInflight = new Map<string, Promise<MuseumHall[]>>()
|
||||||
private readonly guideStopCache = new Map<string, ExplainGuideStop[]>()
|
private readonly guideStopCache = new Map<string, TimedCacheEntry<ExplainGuideStop[]>>()
|
||||||
private readonly guideStopInflight = new Map<string, Promise<ExplainGuideStop[]>>()
|
private readonly guideStopInflight = new Map<string, Promise<ExplainGuideStop[]>>()
|
||||||
private readonly outlineCache = new Map<string, BackendCatalogOutlineItem[]>()
|
private readonly outlineCache = new Map<string, TimedCacheEntry<BackendCatalogOutlineItem[]>>()
|
||||||
private readonly outlineInflight = new Map<string, Promise<BackendCatalogOutlineItem[]>>()
|
private readonly outlineInflight = new Map<string, Promise<BackendCatalogOutlineItem[]>>()
|
||||||
private readonly businessUnitCache = new Map<string, ExplainBusinessUnit[]>()
|
private readonly businessUnitCache = new Map<string, TimedCacheEntry<ExplainBusinessUnit[]>>()
|
||||||
private readonly businessUnitInflight = new Map<string, Promise<ExplainBusinessUnit[]>>()
|
private readonly businessUnitInflight = new Map<string, Promise<ExplainBusinessUnit[]>>()
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly fallbackProvider?: ExplainContentProvider
|
private readonly fallbackProvider?: ExplainContentProvider
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private getCached<T>(cache: Map<string, TimedCacheEntry<T>>, 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<T>(cache: Map<string, TimedCacheEntry<T>>, 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() {
|
private async safeFallbackHalls() {
|
||||||
if (!this.isStaticFallbackEnabled()) return []
|
if (!this.isStaticFallbackEnabled()) return []
|
||||||
|
|
||||||
@@ -128,7 +158,7 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
|||||||
|
|
||||||
private async requestCatalogHalls() {
|
private async requestCatalogHalls() {
|
||||||
const key = cacheKey('halls')
|
const key = cacheKey('halls')
|
||||||
const cached = this.hallListCache.get(key)
|
const cached = this.getCached(this.hallListCache, key)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
|
|
||||||
const inflight = this.hallListInflight.get(key)
|
const inflight = this.hallListInflight.get(key)
|
||||||
@@ -149,7 +179,7 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
|||||||
))
|
))
|
||||||
.filter((hall) => hall.id)
|
.filter((hall) => hall.id)
|
||||||
|
|
||||||
this.hallListCache.set(key, halls)
|
this.setCached(this.hallListCache, key, halls)
|
||||||
return halls
|
return halls
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@@ -166,7 +196,7 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
|||||||
if (!normalizedHallId) return []
|
if (!normalizedHallId) return []
|
||||||
|
|
||||||
const key = cacheKey('hall', normalizedHallId, 'outlines')
|
const key = cacheKey('hall', normalizedHallId, 'outlines')
|
||||||
const cached = this.outlineCache.get(key)
|
const cached = this.getCached(this.outlineCache, key)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
|
|
||||||
const inflight = this.outlineInflight.get(key)
|
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 url = `${resolveAppApiBaseUrl()}/gis/guide/catalog/halls/${encodeURIComponent(normalizedHallId)}/outlines?${params.toString()}`
|
||||||
const response = await requestJson<CommonResult<BackendCatalogOutlineItem[]>>(url)
|
const response = await requestJson<CommonResult<BackendCatalogOutlineItem[]>>(url)
|
||||||
const outlines = requireArrayData(response, '讲解单元目录加载失败')
|
const outlines = requireArrayData(response, '讲解单元目录加载失败')
|
||||||
this.outlineCache.set(key, outlines)
|
this.setCached(this.outlineCache, key, outlines)
|
||||||
return outlines
|
return outlines
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@@ -197,7 +227,7 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
|||||||
if (!normalizedHallId) return []
|
if (!normalizedHallId) return []
|
||||||
|
|
||||||
const key = cacheKey('hall', normalizedHallId, 'stops')
|
const key = cacheKey('hall', normalizedHallId, 'stops')
|
||||||
const cached = this.guideStopCache.get(key)
|
const cached = this.getCached(this.guideStopCache, key)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
|
|
||||||
const inflight = this.guideStopInflight.get(key)
|
const inflight = this.guideStopInflight.get(key)
|
||||||
@@ -226,7 +256,7 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
|||||||
})
|
})
|
||||||
.filter(Boolean) as ExplainGuideStop[]
|
.filter(Boolean) as ExplainGuideStop[]
|
||||||
|
|
||||||
this.guideStopCache.set(key, stops)
|
this.setCached(this.guideStopCache, key, stops)
|
||||||
return stops
|
return stops
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@@ -244,13 +274,13 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
|||||||
if (!normalizedOutlineId) return []
|
if (!normalizedOutlineId) return []
|
||||||
|
|
||||||
const hallStopsKey = cacheKey('hall', normalizedHallId, 'stops')
|
const hallStopsKey = cacheKey('hall', normalizedHallId, 'stops')
|
||||||
const cachedHallStops = this.guideStopCache.get(hallStopsKey)
|
const cachedHallStops = this.getCached(this.guideStopCache, hallStopsKey)
|
||||||
if (cachedHallStops) {
|
if (cachedHallStops) {
|
||||||
return cachedHallStops.filter((stop) => stop.outlineId === normalizedOutlineId)
|
return cachedHallStops.filter((stop) => stop.outlineId === normalizedOutlineId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = cacheKey('outline', normalizedOutlineId, 'stops')
|
const key = cacheKey('outline', normalizedOutlineId, 'stops')
|
||||||
const cached = this.guideStopCache.get(key)
|
const cached = this.getCached(this.guideStopCache, key)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
|
|
||||||
const inflight = this.guideStopInflight.get(key)
|
const inflight = this.guideStopInflight.get(key)
|
||||||
@@ -276,7 +306,7 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
|||||||
: null))
|
: null))
|
||||||
.filter(Boolean) as ExplainGuideStop[]
|
.filter(Boolean) as ExplainGuideStop[]
|
||||||
|
|
||||||
this.guideStopCache.set(key, stops)
|
this.setCached(this.guideStopCache, key, stops)
|
||||||
return stops
|
return stops
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@@ -290,7 +320,7 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
|||||||
|
|
||||||
private async requestCatalogExhibits() {
|
private async requestCatalogExhibits() {
|
||||||
const key = cacheKey('exhibits')
|
const key = cacheKey('exhibits')
|
||||||
const cached = this.explainExhibitCache.get(key)
|
const cached = this.getCached(this.explainExhibitCache, key)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
|
|
||||||
const inflight = this.explainExhibitInflight.get(key)
|
const inflight = this.explainExhibitInflight.get(key)
|
||||||
@@ -303,9 +333,9 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
|||||||
return stops.map((stop) => toCatalogMuseumExhibitFromStop(stop, hall))
|
return stops.map((stop) => toCatalogMuseumExhibitFromStop(stop, hall))
|
||||||
}))
|
}))
|
||||||
const exhibits = stopsByHall.flat()
|
const exhibits = stopsByHall.flat()
|
||||||
this.explainExhibitCache.set(key, exhibits)
|
this.setCached(this.explainExhibitCache, key, exhibits)
|
||||||
exhibits.forEach((exhibit) => {
|
exhibits.forEach((exhibit) => {
|
||||||
this.detailCache.set(exhibit.id, exhibit)
|
this.setCached(this.detailCache, exhibit.id, exhibit)
|
||||||
})
|
})
|
||||||
return exhibits
|
return exhibits
|
||||||
})()
|
})()
|
||||||
@@ -350,26 +380,17 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
|||||||
if (!normalizedHallId) return []
|
if (!normalizedHallId) return []
|
||||||
|
|
||||||
const key = cacheKey('hall', normalizedHallId, 'units')
|
const key = cacheKey('hall', normalizedHallId, 'units')
|
||||||
const cached = this.businessUnitCache.get(key)
|
const cached = this.getCached(this.businessUnitCache, key)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
|
|
||||||
const inflight = this.businessUnitInflight.get(key)
|
const inflight = this.businessUnitInflight.get(key)
|
||||||
if (inflight) return inflight
|
if (inflight) return inflight
|
||||||
|
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
const [outlines, stops] = await Promise.all([
|
const outlines = await this.requestCatalogOutlinesByHall(normalizedHallId)
|
||||||
this.requestCatalogOutlinesByHall(normalizedHallId),
|
// The outline response carries authoritative counts. Stops are loaded only after unit selection.
|
||||||
this.requestCatalogStopsByHall(normalizedHallId)
|
const units = toCatalogBusinessUnits(normalizedHallId, outlines, [])
|
||||||
])
|
this.setCached(this.businessUnitCache, key, units)
|
||||||
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)
|
|
||||||
return units
|
return units
|
||||||
})().catch((error) => {
|
})().catch((error) => {
|
||||||
return this.fallbackOrThrow(error, '讲解单元目录加载失败', async () => (
|
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() {
|
listExhibits() {
|
||||||
return this.listExplainExhibits()
|
return this.listExplainExhibits()
|
||||||
}
|
}
|
||||||
@@ -403,7 +437,7 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
|||||||
const normalizedId = id.trim()
|
const normalizedId = id.trim()
|
||||||
if (!normalizedId) return null
|
if (!normalizedId) return null
|
||||||
|
|
||||||
const cached = this.detailCache.get(normalizedId)
|
const cached = this.getCached(this.detailCache, normalizedId)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
|
|
||||||
const catalogExhibits = await this.listExplainExhibits().catch(() => [])
|
const catalogExhibits = await this.listExplainExhibits().catch(() => [])
|
||||||
@@ -414,7 +448,7 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
|||||||
|| exhibit.linkedExhibits?.some((linked) => linked.id === normalizedId)
|
|| exhibit.linkedExhibits?.some((linked) => linked.id === normalizedId)
|
||||||
))
|
))
|
||||||
if (matched) {
|
if (matched) {
|
||||||
this.detailCache.set(normalizedId, matched)
|
this.setCached(this.detailCache, normalizedId, matched)
|
||||||
return matched
|
return matched
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,10 @@ export interface MuseumContentProvider {
|
|||||||
|
|
||||||
export interface ExplainContentProvider extends MuseumContentProvider {
|
export interface ExplainContentProvider extends MuseumContentProvider {
|
||||||
listExplainExhibits(): Promise<MuseumExhibit[]>
|
listExplainExhibits(): Promise<MuseumExhibit[]>
|
||||||
|
listExplainExhibitsByHall?(hallId: string): Promise<MuseumExhibit[]>
|
||||||
searchExplainExhibits?(keyword?: string): Promise<MuseumExhibit[]>
|
searchExplainExhibits?(keyword?: string): Promise<MuseumExhibit[]>
|
||||||
listGuideStopsByHall?(hallId: string): Promise<ExplainGuideStop[]>
|
listGuideStopsByHall?(hallId: string): Promise<ExplainGuideStop[]>
|
||||||
|
listGuideStopsByBusinessUnit?(hallId: string, unitId: string): Promise<ExplainGuideStop[]>
|
||||||
listTemporaryBusinessUnitsByHall?(hallId: string): Promise<ExplainBusinessUnit[]>
|
listTemporaryBusinessUnitsByHall?(hallId: string): Promise<ExplainBusinessUnit[]>
|
||||||
listTracks(): Promise<ExplainTrack[]>
|
listTracks(): Promise<ExplainTrack[]>
|
||||||
getMediaById(id: string): Promise<MediaAsset | null>
|
getMediaById(id: string): Promise<MediaAsset | null>
|
||||||
|
|||||||
2
src/env.d.ts
vendored
2
src/env.d.ts
vendored
@@ -15,6 +15,8 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_GUIDE_STATIC_DATA_BASE_URL?: string
|
readonly VITE_GUIDE_STATIC_DATA_BASE_URL?: string
|
||||||
readonly VITE_API_BASE_URL?: string
|
readonly VITE_API_BASE_URL?: string
|
||||||
readonly VITE_AUDIO_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_AUDIO_LANGUAGE?: 'zh-CN' | 'yue-HK' | 'en-US'
|
||||||
readonly VITE_SGS_API_BASE_URL?: string
|
readonly VITE_SGS_API_BASE_URL?: string
|
||||||
readonly VITE_SGS_MAP_ID?: string
|
readonly VITE_SGS_MAP_ID?: string
|
||||||
|
|||||||
@@ -77,8 +77,8 @@
|
|||||||
<view class="detail-audio-dock-main">
|
<view class="detail-audio-dock-main">
|
||||||
<button
|
<button
|
||||||
class="detail-audio-play"
|
class="detail-audio-play"
|
||||||
:class="{ disabled: audioDockState === 'unavailable' }"
|
:class="{ disabled: audioDockState === 'unavailable' || audioDockState === 'loading' }"
|
||||||
:disabled="audioDockState === 'unavailable'"
|
:disabled="audioDockState === 'unavailable' || audioDockState === 'loading'"
|
||||||
:aria-label="detailAudioActionLabel"
|
:aria-label="detailAudioActionLabel"
|
||||||
@tap="handlePlayAudio"
|
@tap="handlePlayAudio"
|
||||||
>
|
>
|
||||||
@@ -193,6 +193,11 @@ const detailEntryRequest = ref<{
|
|||||||
exhibitId: string
|
exhibitId: string
|
||||||
targetType?: AudioPlayTargetType
|
targetType?: AudioPlayTargetType
|
||||||
targetId?: string
|
targetId?: string
|
||||||
|
hallId?: string
|
||||||
|
hallName?: string
|
||||||
|
floorId?: string
|
||||||
|
floorLabel?: string
|
||||||
|
poiId?: string
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const detailTextByLanguage = ref<Record<AudioLanguage, string>>({
|
const detailTextByLanguage = ref<Record<AudioLanguage, string>>({
|
||||||
'zh-CN': defaultDetail.body,
|
'zh-CN': defaultDetail.body,
|
||||||
@@ -478,7 +483,11 @@ onLoad(async (options: any = {}) => {
|
|||||||
detailEntryRequest.value = {
|
detailEntryRequest.value = {
|
||||||
exhibitId,
|
exhibitId,
|
||||||
targetType,
|
targetType,
|
||||||
targetId
|
targetId,
|
||||||
|
hallId: Array.isArray(options.hallId) ? options.hallId[0] : options.hallId,
|
||||||
|
hallName: Array.isArray(options.hallName) ? options.hallName[0] : options.hallName,
|
||||||
|
floorId: Array.isArray(options.floorId) ? options.floorId[0] : options.floorId,
|
||||||
|
poiId: Array.isArray(options.poiId) ? options.poiId[0] : options.poiId
|
||||||
}
|
}
|
||||||
await loadExplainDetail(detailEntryRequest.value, lang)
|
await loadExplainDetail(detailEntryRequest.value, lang)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -737,7 +746,7 @@ const playDetailAudio = async (audio: AudioItem) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handlePlayAudio = async (options: { forceRefresh?: boolean } = {}) => {
|
const handlePlayAudio = async (options: { forceRefresh?: boolean } = {}) => {
|
||||||
if (audioDockState.value === 'unavailable') return
|
if (audioDockState.value === 'unavailable' || audioDockState.value === 'loading') return
|
||||||
|
|
||||||
if (isCurrentDetailAudio.value && !options.forceRefresh) {
|
if (isCurrentDetailAudio.value && !options.forceRefresh) {
|
||||||
if (globalAudioPlayer.playing.value) {
|
if (globalAudioPlayer.playing.value) {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
explainUseCase
|
explainUseCase
|
||||||
} from '@/usecases/explainUseCase'
|
} from '@/usecases/explainUseCase'
|
||||||
import type {
|
import type {
|
||||||
ExplainBusinessUnit
|
ExplainGuideStop
|
||||||
} from '@/domain/museum'
|
} from '@/domain/museum'
|
||||||
import {
|
import {
|
||||||
normalizeExplainDetailTargetFromGuideStop
|
normalizeExplainDetailTargetFromGuideStop
|
||||||
@@ -49,8 +49,8 @@ const explainGuideStopItems = ref<ExplainGuideStopSelectItem[]>([])
|
|||||||
const explainLoading = ref(false)
|
const explainLoading = ref(false)
|
||||||
const explainError = ref('')
|
const explainError = ref('')
|
||||||
|
|
||||||
const toGuideStopItems = (unit: ExplainBusinessUnit): ExplainGuideStopSelectItem[] => (
|
const toGuideStopItems = (stops: ExplainGuideStop[]): ExplainGuideStopSelectItem[] => (
|
||||||
unit.stops.map((stop) => ({
|
stops.map((stop) => ({
|
||||||
id: stop.id,
|
id: stop.id,
|
||||||
name: stop.name,
|
name: stop.name,
|
||||||
hallId: stop.hallId,
|
hallId: stop.hallId,
|
||||||
@@ -99,7 +99,11 @@ onLoad(async (options: any = {}) => {
|
|||||||
}
|
}
|
||||||
selectedExplainBusinessUnitName.value = unit.name || selectedExplainBusinessUnitName.value
|
selectedExplainBusinessUnitName.value = unit.name || selectedExplainBusinessUnitName.value
|
||||||
syncPageTitle(selectedExplainBusinessUnitName.value)
|
syncPageTitle(selectedExplainBusinessUnitName.value)
|
||||||
explainGuideStopItems.value = toGuideStopItems(unit)
|
const stops = await explainUseCase.listGuideStopsByBusinessUnit(
|
||||||
|
selectedExplainHallId.value,
|
||||||
|
selectedExplainBusinessUnitId.value
|
||||||
|
)
|
||||||
|
explainGuideStopItems.value = toGuideStopItems(stops)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
explainError.value = '讲解点加载失败,请稍后重试'
|
explainError.value = '讲解点加载失败,请稍后重试'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -118,6 +122,10 @@ const handleExplainGuideStopClick = (stop: ExplainGuideStopSelectItem) => {
|
|||||||
targetType: target.targetType,
|
targetType: target.targetType,
|
||||||
targetId: target.targetId
|
targetId: target.targetId
|
||||||
})
|
})
|
||||||
|
if (stop.hallId) params.set('hallId', stop.hallId)
|
||||||
|
if (stop.hallName) params.set('hallName', stop.hallName)
|
||||||
|
if (stop.floorId) params.set('floorId', stop.floorId)
|
||||||
|
if (stop.poiId) params.set('poiId', stop.poiId)
|
||||||
|
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages/exhibit/detail?${params.toString()}`
|
url: `/pages/exhibit/detail?${params.toString()}`
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject
|
|||||||
uni.request({
|
uni.request({
|
||||||
url,
|
url,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
timeout: 8000,
|
timeout: dataSourceConfig.audioApiTimeoutMs,
|
||||||
success: (response) => {
|
success: (response) => {
|
||||||
const statusCode = Number(response.statusCode || 0)
|
const statusCode = Number(response.statusCode || 0)
|
||||||
if (statusCode < 200 || statusCode >= 300) {
|
if (statusCode < 200 || statusCode >= 300) {
|
||||||
@@ -142,9 +142,16 @@ const audioTextKey = ({ targetType, targetId, lang }: RequiredAudioTextInfoReque
|
|||||||
`${targetType}:${targetId}:${lang}`
|
`${targetType}:${targetId}:${lang}`
|
||||||
)
|
)
|
||||||
|
|
||||||
const isExpired = (expiresAt?: string | null) => (
|
const STOP_INFO_TTL_MS = 10 * 60_000
|
||||||
Boolean(expiresAt) && new Date(expiresAt as string).getTime() <= Date.now()
|
const PLAY_INFO_TTL_MS = 10 * 60_000
|
||||||
)
|
const TEXT_INFO_TTL_MS = 10 * 60_000
|
||||||
|
const CACHE_MAX_ENTRIES = 80
|
||||||
|
const EXPIRES_AT_SAFETY_MS = 30_000
|
||||||
|
|
||||||
|
interface CacheEntry<T> {
|
||||||
|
value: T
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
const isMissingRouteResponse = (response: { code: number; msg?: string }) => (
|
const isMissingRouteResponse = (response: { code: number; msg?: string }) => (
|
||||||
response.code === 404 && /请求地址不存在/.test(response.msg || '')
|
response.code === 404 && /请求地址不存在/.test(response.msg || '')
|
||||||
@@ -164,9 +171,51 @@ export const audioReasonToText = (reason?: string | null) => (
|
|||||||
)
|
)
|
||||||
|
|
||||||
export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
||||||
private readonly stopInfoCache = new Map<string, GuideStopInfo>()
|
private readonly stopInfoCache = new Map<string, CacheEntry<GuideStopInfo>>()
|
||||||
private readonly cache = new Map<string, AudioPlayInfo>()
|
private readonly cache = new Map<string, CacheEntry<AudioPlayInfo>>()
|
||||||
private readonly textCache = new Map<string, AudioTextInfo>()
|
private readonly textCache = new Map<string, CacheEntry<AudioTextInfo>>()
|
||||||
|
private readonly stopInfoInflight = new Map<string, Promise<GuideStopInfo>>()
|
||||||
|
private readonly playInfoInflight = new Map<string, Promise<AudioPlayInfo>>()
|
||||||
|
private readonly textInfoInflight = new Map<string, Promise<AudioTextInfo>>()
|
||||||
|
private epoch = 0
|
||||||
|
|
||||||
|
private getCached<T>(cache: Map<string, CacheEntry<T>>, key: string): T | null {
|
||||||
|
const entry = cache.get(key)
|
||||||
|
if (!entry) return null
|
||||||
|
if (entry.expiresAt <= Date.now()) {
|
||||||
|
cache.delete(key)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
cache.delete(key)
|
||||||
|
cache.set(key, entry)
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCached<T>(cache: Map<string, CacheEntry<T>>, key: string, value: T, ttlMs: number) {
|
||||||
|
cache.delete(key)
|
||||||
|
cache.set(key, { value, expiresAt: Date.now() + ttlMs })
|
||||||
|
while (cache.size > CACHE_MAX_ENTRIES) {
|
||||||
|
const oldestKey = cache.keys().next().value
|
||||||
|
if (!oldestKey) break
|
||||||
|
cache.delete(oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async coalesce<T>(
|
||||||
|
inflight: Map<string, Promise<T>>,
|
||||||
|
key: string,
|
||||||
|
loader: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const existing = inflight.get(key)
|
||||||
|
if (existing) return existing
|
||||||
|
const promise = loader()
|
||||||
|
inflight.set(key, promise)
|
||||||
|
try {
|
||||||
|
return await promise
|
||||||
|
} finally {
|
||||||
|
if (inflight.get(key) === promise) inflight.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getStopInfo(request: GuideStopInfoRequest): Promise<GuideStopInfo> {
|
async getStopInfo(request: GuideStopInfoRequest): Promise<GuideStopInfo> {
|
||||||
if (unavailableAudioApiEndpoints.has('stopInfo')) {
|
if (unavailableAudioApiEndpoints.has('stopInfo')) {
|
||||||
@@ -179,28 +228,20 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
|||||||
lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
|
lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
|
||||||
}
|
}
|
||||||
const key = stopInfoKey(normalizedRequest)
|
const key = stopInfoKey(normalizedRequest)
|
||||||
const cached = this.stopInfoCache.get(key)
|
const cached = this.getCached(this.stopInfoCache, key)
|
||||||
|
if (cached) return cached
|
||||||
if (cached) {
|
return this.coalesce(this.stopInfoInflight, key, async () => {
|
||||||
return cached
|
const epoch = this.epoch
|
||||||
}
|
const params = new URLSearchParams(normalizedRequest)
|
||||||
|
const response = await requestJson<GuideStopInfoResponse>(`${resolveAudioApiBaseUrl()}/gis/guide/stop/info?${params.toString()}`)
|
||||||
const params = new URLSearchParams({
|
if (response.code !== 0 || !response.data) {
|
||||||
targetType: normalizedRequest.targetType,
|
markGuideAudioApiUnavailable('stopInfo', response)
|
||||||
targetId: normalizedRequest.targetId,
|
throw new Error(response.msg || '讲解点展示信息加载失败')
|
||||||
lang: normalizedRequest.lang
|
}
|
||||||
|
const stopInfo = toGuideStopInfo(response.data, normalizedRequest)
|
||||||
|
if (epoch === this.epoch) this.setCached(this.stopInfoCache, key, stopInfo, STOP_INFO_TTL_MS)
|
||||||
|
return stopInfo
|
||||||
})
|
})
|
||||||
const url = `${resolveAudioApiBaseUrl()}/gis/guide/stop/info?${params.toString()}`
|
|
||||||
const response = await requestJson<GuideStopInfoResponse>(url)
|
|
||||||
|
|
||||||
if (response.code !== 0 || !response.data) {
|
|
||||||
markGuideAudioApiUnavailable('stopInfo', response)
|
|
||||||
throw new Error(response.msg || '讲解点展示信息加载失败')
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopInfo = toGuideStopInfo(response.data, normalizedRequest)
|
|
||||||
this.stopInfoCache.set(key, stopInfo)
|
|
||||||
return stopInfo
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPlayInfo(request: AudioPlayInfoRequest): Promise<AudioPlayInfo> {
|
async getPlayInfo(request: AudioPlayInfoRequest): Promise<AudioPlayInfo> {
|
||||||
@@ -222,31 +263,23 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cached = this.cache.get(key)
|
const cached = this.getCached(this.cache, key)
|
||||||
|
if (!request.refresh && cached) return cached
|
||||||
if (!request.refresh && cached && !isExpired(cached.expiresAt)) {
|
// Force refresh bypasses completed cache, while the same concurrent refresh still shares this Promise.
|
||||||
return cached
|
return this.coalesce(this.playInfoInflight, key, async () => {
|
||||||
}
|
const epoch = this.epoch
|
||||||
|
const params = new URLSearchParams(normalizedRequest)
|
||||||
const params = new URLSearchParams({
|
const response = await requestJson<AudioPlayInfoResponse>(`${resolveAudioApiBaseUrl()}/gis/guide/audio/play-info?${params.toString()}`)
|
||||||
targetType: normalizedRequest.targetType,
|
if (response.code !== 0 || !response.data) {
|
||||||
targetId: normalizedRequest.targetId,
|
markGuideAudioApiUnavailable('playInfo', response)
|
||||||
lang: normalizedRequest.lang
|
throw new Error(response.msg || '语音播放解析失败')
|
||||||
|
}
|
||||||
|
const playInfo = toGuideAudioPlayInfo(response.data, normalizedRequest)
|
||||||
|
const serverExpiry = playInfo.expiresAt ? new Date(playInfo.expiresAt).getTime() - EXPIRES_AT_SAFETY_MS : Infinity
|
||||||
|
const ttlMs = Math.max(0, Math.min(PLAY_INFO_TTL_MS, serverExpiry - Date.now()))
|
||||||
|
if (epoch === this.epoch && playInfo.playable && ttlMs > 0) this.setCached(this.cache, key, playInfo, ttlMs)
|
||||||
|
return playInfo
|
||||||
})
|
})
|
||||||
const url = `${resolveAudioApiBaseUrl()}/gis/guide/audio/play-info?${params.toString()}`
|
|
||||||
const response = await requestJson<AudioPlayInfoResponse>(url)
|
|
||||||
|
|
||||||
if (response.code !== 0 || !response.data) {
|
|
||||||
markGuideAudioApiUnavailable('playInfo', response)
|
|
||||||
throw new Error(response.msg || '语音播放解析失败')
|
|
||||||
}
|
|
||||||
|
|
||||||
const playInfo = toGuideAudioPlayInfo(response.data, normalizedRequest)
|
|
||||||
|
|
||||||
if (playInfo.playable || playInfo.reason !== 'TARGET_NOT_FOUND') {
|
|
||||||
this.cache.set(key, playInfo)
|
|
||||||
}
|
|
||||||
return playInfo
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTextInfo(request: AudioTextInfoRequest): Promise<AudioTextInfo> {
|
async getTextInfo(request: AudioTextInfoRequest): Promise<AudioTextInfo> {
|
||||||
@@ -266,35 +299,24 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cached = this.textCache.get(key)
|
const cached = this.getCached(this.textCache, key)
|
||||||
|
if (cached) return cached
|
||||||
if (cached) {
|
return this.coalesce(this.textInfoInflight, key, async () => {
|
||||||
return cached
|
const epoch = this.epoch
|
||||||
}
|
const params = new URLSearchParams(normalizedRequest)
|
||||||
|
const response = await requestJson<AudioTextInfoResponse>(`${resolveAudioApiBaseUrl()}/gis/guide/audio/text-info?${params.toString()}`)
|
||||||
const params = new URLSearchParams({
|
if (response.code !== 0 || !response.data) {
|
||||||
targetType: normalizedRequest.targetType,
|
markGuideAudioApiUnavailable('textInfo', response)
|
||||||
targetId: normalizedRequest.targetId,
|
throw new Error(response.msg || '讲解词正文解析失败')
|
||||||
lang: normalizedRequest.lang
|
}
|
||||||
|
const textInfo = toGuideAudioTextInfo(response.data, normalizedRequest)
|
||||||
|
if (epoch === this.epoch && textInfo.available) this.setCached(this.textCache, key, textInfo, TEXT_INFO_TTL_MS)
|
||||||
|
return textInfo
|
||||||
})
|
})
|
||||||
const url = `${resolveAudioApiBaseUrl()}/gis/guide/audio/text-info?${params.toString()}`
|
|
||||||
const response = await requestJson<AudioTextInfoResponse>(url)
|
|
||||||
|
|
||||||
if (response.code !== 0 || !response.data) {
|
|
||||||
markGuideAudioApiUnavailable('textInfo', response)
|
|
||||||
throw new Error(response.msg || '讲解词正文解析失败')
|
|
||||||
}
|
|
||||||
|
|
||||||
const textInfo = toGuideAudioTextInfo(response.data, normalizedRequest)
|
|
||||||
|
|
||||||
if (textInfo.available) {
|
|
||||||
this.textCache.set(key, textInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
return textInfo
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearCache(lang?: AudioLanguage) {
|
clearCache(lang?: AudioLanguage) {
|
||||||
|
this.epoch += 1
|
||||||
if (!lang) {
|
if (!lang) {
|
||||||
this.stopInfoCache.clear()
|
this.stopInfoCache.clear()
|
||||||
this.cache.clear()
|
this.cache.clear()
|
||||||
@@ -318,6 +340,7 @@ export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
|||||||
this.textCache.delete(key)
|
this.textCache.delete(key)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
unavailableAudioApiEndpoints.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ import {
|
|||||||
|
|
||||||
export interface ExplainRepository {
|
export interface ExplainRepository {
|
||||||
listExplainExhibits(): Promise<MuseumExhibit[]>
|
listExplainExhibits(): Promise<MuseumExhibit[]>
|
||||||
|
listExplainExhibitsByHall(hallId: string): Promise<MuseumExhibit[]>
|
||||||
listExhibits(): Promise<MuseumExhibit[]>
|
listExhibits(): Promise<MuseumExhibit[]>
|
||||||
getExhibitById(id: string): Promise<MuseumExhibit | null>
|
getExhibitById(id: string): Promise<MuseumExhibit | null>
|
||||||
listHalls(): Promise<MuseumHall[]>
|
listHalls(): Promise<MuseumHall[]>
|
||||||
getHallById(id: string): Promise<MuseumHall | null>
|
getHallById(id: string): Promise<MuseumHall | null>
|
||||||
listGuideStopsByHall(hallId: string): Promise<ExplainGuideStop[]>
|
listGuideStopsByHall(hallId: string): Promise<ExplainGuideStop[]>
|
||||||
|
listGuideStopsByBusinessUnit(hallId: string, unitId: string): Promise<ExplainGuideStop[]>
|
||||||
listTemporaryBusinessUnitsByHall(hallId: string): Promise<ExplainBusinessUnit[]>
|
listTemporaryBusinessUnitsByHall(hallId: string): Promise<ExplainBusinessUnit[]>
|
||||||
listTracks(): Promise<ExplainTrack[]>
|
listTracks(): Promise<ExplainTrack[]>
|
||||||
getTrackByExhibitId(exhibitId: string): Promise<ExplainTrack | null>
|
getTrackByExhibitId(exhibitId: string): Promise<ExplainTrack | null>
|
||||||
@@ -45,6 +47,14 @@ export class DefaultExplainRepository implements ExplainRepository {
|
|||||||
return this.explainContent.listExplainExhibits()
|
return this.explainContent.listExplainExhibits()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listExplainExhibitsByHall(hallId: string) {
|
||||||
|
if (this.explainContent.listExplainExhibitsByHall) {
|
||||||
|
return this.explainContent.listExplainExhibitsByHall(hallId)
|
||||||
|
}
|
||||||
|
const exhibits = await this.listExplainExhibits()
|
||||||
|
return exhibits.filter((exhibit) => exhibit.hallId === hallId)
|
||||||
|
}
|
||||||
|
|
||||||
listExhibits() {
|
listExhibits() {
|
||||||
return this.content.listExhibits()
|
return this.content.listExhibits()
|
||||||
}
|
}
|
||||||
@@ -66,6 +76,14 @@ export class DefaultExplainRepository implements ExplainRepository {
|
|||||||
return this.explainContent.listGuideStopsByHall?.(hallId) || []
|
return this.explainContent.listGuideStopsByHall?.(hallId) || []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listGuideStopsByBusinessUnit(hallId: string, unitId: string) {
|
||||||
|
if (this.explainContent.listGuideStopsByBusinessUnit) {
|
||||||
|
return this.explainContent.listGuideStopsByBusinessUnit(hallId, unitId)
|
||||||
|
}
|
||||||
|
const units = await this.listTemporaryBusinessUnitsByHall(hallId)
|
||||||
|
return units.find((unit) => unit.id === unitId)?.stops || []
|
||||||
|
}
|
||||||
|
|
||||||
async listTemporaryBusinessUnitsByHall(hallId: string) {
|
async listTemporaryBusinessUnitsByHall(hallId: string) {
|
||||||
if (this.explainContent.listTemporaryBusinessUnitsByHall) {
|
if (this.explainContent.listTemporaryBusinessUnitsByHall) {
|
||||||
return this.explainContent.listTemporaryBusinessUnitsByHall(hallId)
|
return this.explainContent.listTemporaryBusinessUnitsByHall(hallId)
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ export interface ExplainDetailEntryRequest {
|
|||||||
targetType?: AudioPlayTargetType
|
targetType?: AudioPlayTargetType
|
||||||
targetId?: string
|
targetId?: string
|
||||||
lang?: AudioLanguage
|
lang?: AudioLanguage
|
||||||
|
// Navigation context is non-authoritative and keeps deep links independent of catalog loading.
|
||||||
|
hallId?: string
|
||||||
|
hallName?: string
|
||||||
|
floorId?: string
|
||||||
|
floorLabel?: string
|
||||||
|
poiId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExplainTextSelection {
|
export interface ExplainTextSelection {
|
||||||
@@ -198,11 +204,13 @@ export class ExplainUseCase {
|
|||||||
|
|
||||||
private toExhibitFromStopInfo(
|
private toExhibitFromStopInfo(
|
||||||
stopInfo: GuideStopInfo,
|
stopInfo: GuideStopInfo,
|
||||||
fallback?: MuseumExhibit | null
|
fallback?: MuseumExhibit | null,
|
||||||
|
navigationContext?: ExplainDetailEntryRequest
|
||||||
): MuseumExhibit {
|
): MuseumExhibit {
|
||||||
const linkedPrimary = stopInfo.linkedExhibits[0]
|
const linkedPrimary = stopInfo.linkedExhibits[0]
|
||||||
const linkedCoverImage = stopInfo.linkedExhibits.find((item) => item.coverImageUrl)?.coverImageUrl
|
const coverImage = stopInfo.imageStatus === 'READY'
|
||||||
const coverImage = stopInfo.coverImageUrl || linkedCoverImage || fallback?.image
|
? stopInfo.coverImageUrl
|
||||||
|
: undefined
|
||||||
const description = stopInfo.description || fallback?.description || '该讲解暂无简介。'
|
const description = stopInfo.description || fallback?.description || '该讲解暂无简介。'
|
||||||
const audioTarget = this.resolveStopInfoAudioTarget(stopInfo)
|
const audioTarget = this.resolveStopInfoAudioTarget(stopInfo)
|
||||||
const audioAvailable = stopInfo.audioStatus === 'READY'
|
const audioAvailable = stopInfo.audioStatus === 'READY'
|
||||||
@@ -211,13 +219,13 @@ export class ExplainUseCase {
|
|||||||
...(fallback || {}),
|
...(fallback || {}),
|
||||||
id: fallback?.id || linkedPrimary?.id || stopInfo.targetId,
|
id: fallback?.id || linkedPrimary?.id || stopInfo.targetId,
|
||||||
name: stopInfo.title || fallback?.name || linkedPrimary?.name || '讲解内容',
|
name: stopInfo.title || fallback?.name || linkedPrimary?.name || '讲解内容',
|
||||||
hallId: fallback?.hallId,
|
hallId: fallback?.hallId || navigationContext?.hallId,
|
||||||
hallName: fallback?.hallName,
|
hallName: fallback?.hallName || navigationContext?.hallName,
|
||||||
floorId: stopInfo.floorId || fallback?.floorId,
|
floorId: stopInfo.floorId || fallback?.floorId || navigationContext?.floorId,
|
||||||
floorLabel: fallback?.floorLabel,
|
floorLabel: fallback?.floorLabel || navigationContext?.floorLabel,
|
||||||
image: coverImage,
|
image: coverImage,
|
||||||
description,
|
description,
|
||||||
poiId: stopInfo.poiId || fallback?.poiId,
|
poiId: stopInfo.poiId || fallback?.poiId || navigationContext?.poiId,
|
||||||
sourcePoiId: stopInfo.poiId || fallback?.sourcePoiId,
|
sourcePoiId: stopInfo.poiId || fallback?.sourcePoiId,
|
||||||
mapX: stopInfo.mapX ?? fallback?.mapX,
|
mapX: stopInfo.mapX ?? fallback?.mapX,
|
||||||
mapY: stopInfo.mapY ?? fallback?.mapY,
|
mapY: stopInfo.mapY ?? fallback?.mapY,
|
||||||
@@ -244,7 +252,7 @@ export class ExplainUseCase {
|
|||||||
supportedLanguages: stopInfo.supportedLanguages,
|
supportedLanguages: stopInfo.supportedLanguages,
|
||||||
imageStatus: stopInfo.imageStatus,
|
imageStatus: stopInfo.imageStatus,
|
||||||
imageSource: stopInfo.imageSource,
|
imageSource: stopInfo.imageSource,
|
||||||
galleryUrls: stopInfo.galleryUrls,
|
galleryUrls: stopInfo.imageStatus === 'READY' ? stopInfo.galleryUrls : [],
|
||||||
linkedExhibitCount: stopInfo.linkedExhibitCount,
|
linkedExhibitCount: stopInfo.linkedExhibitCount,
|
||||||
isSharedStop: stopInfo.isSharedStop,
|
isSharedStop: stopInfo.isSharedStop,
|
||||||
linkedExhibits: stopInfo.linkedExhibits,
|
linkedExhibits: stopInfo.linkedExhibits,
|
||||||
@@ -403,9 +411,15 @@ export class ExplainUseCase {
|
|||||||
|
|
||||||
async enterExplainDetail(request: ExplainDetailEntryRequest) {
|
async enterExplainDetail(request: ExplainDetailEntryRequest) {
|
||||||
const entryTarget = await this.resolveDetailEntryTarget(request)
|
const entryTarget = await this.resolveDetailEntryTarget(request)
|
||||||
|
// Remote stop-info is the authoritative detail source. Never make its first paint wait for catalog.
|
||||||
|
if (dataSourceConfig.explainContentMode === 'remote') {
|
||||||
|
const stopInfo = await this.audioPlayInfo.getStopInfo(entryTarget)
|
||||||
|
return this.toExhibitFromStopInfo(stopInfo, null, request)
|
||||||
|
}
|
||||||
|
|
||||||
const fallbackExhibit = await this.resolveStaticDetailFallback(request, entryTarget)
|
const fallbackExhibit = await this.resolveStaticDetailFallback(request, entryTarget)
|
||||||
|
|
||||||
if (fallbackExhibit && dataSourceConfig.explainContentMode !== 'remote') {
|
if (fallbackExhibit) {
|
||||||
return this.applyLanguageVariant(fallbackExhibit, entryTarget.lang)
|
return this.applyLanguageVariant(fallbackExhibit, entryTarget.lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,7 +428,7 @@ export class ExplainUseCase {
|
|||||||
.catch((error) => ({ stopInfo: null, error }))
|
.catch((error) => ({ stopInfo: null, error }))
|
||||||
|
|
||||||
if (stopInfoResult.stopInfo) {
|
if (stopInfoResult.stopInfo) {
|
||||||
return this.toExhibitFromStopInfo(stopInfoResult.stopInfo, fallbackExhibit)
|
return this.toExhibitFromStopInfo(stopInfoResult.stopInfo, fallbackExhibit, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
throw stopInfoResult.error || new Error('讲解详情加载失败')
|
throw stopInfoResult.error || new Error('讲解详情加载失败')
|
||||||
@@ -522,8 +536,7 @@ export class ExplainUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listGuideStopsByBusinessUnit(hallId: string, unitId: string): Promise<ExplainGuideStop[]> {
|
async listGuideStopsByBusinessUnit(hallId: string, unitId: string): Promise<ExplainGuideStop[]> {
|
||||||
const unit = await this.selectBusinessUnit(hallId, unitId)
|
return this.explain.listGuideStopsByBusinessUnit(hallId, unitId)
|
||||||
return unit?.stops || []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
openGuideStopDetail(stopId: string) {
|
openGuideStopDetail(stopId: string) {
|
||||||
@@ -536,8 +549,7 @@ export class ExplainUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listExhibitsByHallId(hallId: string) {
|
async listExhibitsByHallId(hallId: string) {
|
||||||
const exhibits = await this.listExhibits()
|
return this.explain.listExplainExhibitsByHall(hallId)
|
||||||
return exhibits.filter((exhibit) => exhibit.hallId === hallId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
searchExplain(keyword?: string): Promise<SearchIndexItem[]> {
|
searchExplain(keyword?: string): Promise<SearchIndexItem[]> {
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-loading')
|
expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-loading')
|
||||||
expect(wrapper.find('.audio-loading-spinner').exists()).toBe(true)
|
expect(wrapper.find('.audio-loading-spinner').exists()).toBe(true)
|
||||||
expect(wrapper.find('.detail-audio-progress.is-indeterminate').exists()).toBe(true)
|
expect(wrapper.find('.detail-audio-progress.is-indeterminate').exists()).toBe(true)
|
||||||
|
expect(wrapper.get('.detail-audio-play').attributes('disabled')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('无音频时禁用播放按钮并显示原因', async () => {
|
it('无音频时禁用播放按钮并显示原因', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user