chore: freeze guide and explain update
This commit is contained in:
157
src/repositories/AudioPlayInfoRepository.ts
Normal file
157
src/repositories/AudioPlayInfoRepository.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
dataSourceConfig
|
||||
} from '@/config/dataSource'
|
||||
import {
|
||||
normalizeSameOriginPublicUrl
|
||||
} from '@/utils/publicUrl'
|
||||
import type {
|
||||
AudioPlayTargetType
|
||||
} from '@/domain/museum'
|
||||
|
||||
export type AudioLanguage = 'zh-CN' | 'en-US'
|
||||
|
||||
export interface AudioPlayInfoRequest {
|
||||
targetType: AudioPlayTargetType
|
||||
targetId: string
|
||||
lang?: AudioLanguage
|
||||
}
|
||||
|
||||
export interface AudioPlayInfo {
|
||||
playable: boolean
|
||||
targetType: AudioPlayTargetType
|
||||
targetId: string | number
|
||||
lang: AudioLanguage
|
||||
narrationTier?: 'STANDARD' | 'EXTENDED'
|
||||
audioId?: string | number | null
|
||||
title?: string | null
|
||||
duration?: number | null
|
||||
format?: string | null
|
||||
playUrl?: string | null
|
||||
expiresAt?: string | null
|
||||
subtitleUrl?: string | null
|
||||
fallback?: boolean
|
||||
fallbackReason?: string | null
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
interface AudioPlayInfoResponse {
|
||||
code: number
|
||||
msg?: string
|
||||
data?: AudioPlayInfo
|
||||
}
|
||||
|
||||
export interface AudioPlayInfoRepository {
|
||||
getPlayInfo(request: AudioPlayInfoRequest): Promise<AudioPlayInfo>
|
||||
clearCache(lang?: AudioLanguage): void
|
||||
}
|
||||
|
||||
const reasonMessageMap: Record<string, string> = {
|
||||
NO_PUBLISHED_AUDIO: '当前语言暂无语音讲解',
|
||||
NO_GUIDE_STOP: '该展品暂未配置语音讲解',
|
||||
NO_GUIDE_CONTENT: '该目标暂无讲解内容',
|
||||
UNSUPPORTED_LANGUAGE: '不支持该语言',
|
||||
UNSUPPORTED_TARGET_TYPE: '该目标类型暂不支持播放',
|
||||
TARGET_NOT_FOUND: '该讲解音频暂不可用,当前提供图文讲解',
|
||||
SERVICE_ERROR: '语音服务暂不可用,请稍后重试'
|
||||
}
|
||||
|
||||
const parseJsonPayload = <T>(payload: unknown): T => {
|
||||
if (typeof payload === 'string') {
|
||||
return JSON.parse(payload) as T
|
||||
}
|
||||
|
||||
return payload as T
|
||||
}
|
||||
|
||||
const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url,
|
||||
method: 'GET',
|
||||
success: (response) => {
|
||||
const statusCode = Number(response.statusCode || 0)
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
reject(new Error(`语音播放解析接口请求失败: ${statusCode}`))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(parseJsonPayload<T>(response.data))
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
|
||||
const normalizeBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, '')
|
||||
|
||||
const resolveAudioApiBaseUrl = () => {
|
||||
const baseUrl = normalizeBaseUrl(dataSourceConfig.audioApiBaseUrl)
|
||||
return baseUrl.endsWith('/app-api') ? baseUrl : `${baseUrl}/app-api`
|
||||
}
|
||||
|
||||
const audioKey = ({ targetType, targetId, lang }: Required<AudioPlayInfoRequest>) => (
|
||||
`${targetType}:${targetId}:${lang}`
|
||||
)
|
||||
|
||||
const isExpired = (expiresAt?: string | null) => (
|
||||
Boolean(expiresAt) && new Date(expiresAt as string).getTime() <= Date.now()
|
||||
)
|
||||
|
||||
export const audioReasonToText = (reason?: string | null) => (
|
||||
reason ? reasonMessageMap[reason] || '该讲解暂无可播放音频' : '该讲解暂无可播放音频'
|
||||
)
|
||||
|
||||
export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository {
|
||||
private readonly cache = new Map<string, AudioPlayInfo>()
|
||||
|
||||
async getPlayInfo(request: AudioPlayInfoRequest): Promise<AudioPlayInfo> {
|
||||
const normalizedRequest: Required<AudioPlayInfoRequest> = {
|
||||
targetType: request.targetType,
|
||||
targetId: request.targetId,
|
||||
lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage)
|
||||
}
|
||||
const key = audioKey(normalizedRequest)
|
||||
const cached = this.cache.get(key)
|
||||
|
||||
if (cached && !isExpired(cached.expiresAt)) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
targetType: normalizedRequest.targetType,
|
||||
targetId: normalizedRequest.targetId,
|
||||
lang: normalizedRequest.lang
|
||||
})
|
||||
const url = `${resolveAudioApiBaseUrl()}/gis/guide/audio/play-info?${params.toString()}`
|
||||
const response = await requestJson<AudioPlayInfoResponse>(url)
|
||||
|
||||
if (response.code !== 0 || !response.data) {
|
||||
throw new Error(response.msg || '语音播放解析失败')
|
||||
}
|
||||
|
||||
response.data.playUrl = normalizeSameOriginPublicUrl(response.data.playUrl)
|
||||
response.data.subtitleUrl = normalizeSameOriginPublicUrl(response.data.subtitleUrl)
|
||||
|
||||
if (response.data.playable || response.data.reason !== 'TARGET_NOT_FOUND') {
|
||||
this.cache.set(key, response.data)
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
|
||||
clearCache(lang?: AudioLanguage) {
|
||||
if (!lang) {
|
||||
this.cache.clear()
|
||||
return
|
||||
}
|
||||
|
||||
Array.from(this.cache.keys()).forEach((key) => {
|
||||
if (key.endsWith(`:${lang}`)) {
|
||||
this.cache.delete(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const audioPlayInfoRepository = new DefaultAudioPlayInfoRepository()
|
||||
@@ -12,8 +12,13 @@ import {
|
||||
museumContentRepository,
|
||||
type MuseumContentRepository
|
||||
} from '@/repositories/MuseumContentRepository'
|
||||
import {
|
||||
staticMuseumContentProvider,
|
||||
type ExplainContentProvider
|
||||
} from '@/data/providers/staticMuseumContentProvider'
|
||||
|
||||
export interface ExplainRepository {
|
||||
listExplainExhibits(): Promise<MuseumExhibit[]>
|
||||
listExhibits(): Promise<MuseumExhibit[]>
|
||||
getExhibitById(id: string): Promise<MuseumExhibit | null>
|
||||
listHalls(): Promise<MuseumHall[]>
|
||||
@@ -28,9 +33,14 @@ const trackTitleFor = (exhibit: MuseumExhibit) => `${exhibit.name}讲解`
|
||||
export class DefaultExplainRepository implements ExplainRepository {
|
||||
constructor(
|
||||
private readonly content: MuseumContentRepository = museumContentRepository,
|
||||
private readonly media: MediaRepository = mediaRepository
|
||||
private readonly media: MediaRepository = mediaRepository,
|
||||
private readonly explainContent: ExplainContentProvider = staticMuseumContentProvider
|
||||
) {}
|
||||
|
||||
listExplainExhibits() {
|
||||
return this.explainContent.listExplainExhibits()
|
||||
}
|
||||
|
||||
listExhibits() {
|
||||
return this.content.listExhibits()
|
||||
}
|
||||
@@ -48,6 +58,9 @@ export class DefaultExplainRepository implements ExplainRepository {
|
||||
}
|
||||
|
||||
async listTracks() {
|
||||
const tracks = await this.explainContent.listTracks()
|
||||
if (tracks.length) return tracks
|
||||
|
||||
const exhibits = await this.content.listExhibits()
|
||||
|
||||
return Promise.all(exhibits.map(async (exhibit): Promise<ExplainTrack> => {
|
||||
@@ -75,8 +88,8 @@ export class DefaultExplainRepository implements ExplainRepository {
|
||||
async searchExplain(keyword = '') {
|
||||
const normalizedKeyword = keyword.trim().toLowerCase()
|
||||
const [exhibits, halls] = await Promise.all([
|
||||
this.content.listExhibits(),
|
||||
this.content.listHalls()
|
||||
this.explainContent.listExplainExhibits(),
|
||||
this.explainContent.listHalls()
|
||||
])
|
||||
|
||||
const exhibitItems = exhibits
|
||||
|
||||
@@ -40,7 +40,8 @@ export class StaticGuideModelRepository implements GuideModelRepository {
|
||||
floorId: floor.floorId,
|
||||
label: formatNavFloorLabel(floor.floorId),
|
||||
order: floor.order,
|
||||
modelUrl: this.provider.assetUrl(floor.modelAsset)
|
||||
modelUrl: this.provider.assetUrl(floor.modelAsset),
|
||||
sharedModelAsset: floor.sharedModelAsset
|
||||
}))
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
GuideLocationPreview,
|
||||
GuideRouteReadiness,
|
||||
MuseumFloor,
|
||||
MuseumPoi
|
||||
} from '@/domain/museum'
|
||||
@@ -57,7 +58,7 @@ export interface GuideRepository {
|
||||
getPoiById(id: string): Promise<MuseumPoi | null>
|
||||
searchPois(keyword?: string): Promise<MuseumPoi[]>
|
||||
getLocationPreview(poiId: string): Promise<GuideLocationPreview | null>
|
||||
getRouteReadiness(): typeof NAV_ROUTE_READINESS
|
||||
getRouteReadiness(): Promise<GuideRouteReadiness>
|
||||
}
|
||||
|
||||
export class StaticGuideRepository implements GuideRepository {
|
||||
@@ -114,8 +115,9 @@ export class StaticGuideRepository implements GuideRepository {
|
||||
return poi ? toLocationPreview(poi) : null
|
||||
}
|
||||
|
||||
getRouteReadiness() {
|
||||
return NAV_ROUTE_READINESS
|
||||
async getRouteReadiness() {
|
||||
return this.provider.loadRouteReadiness()
|
||||
.catch(() => NAV_ROUTE_READINESS)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
309
src/repositories/GuideRouteRepository.ts
Normal file
309
src/repositories/GuideRouteRepository.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import type {
|
||||
GuideRouteReadiness,
|
||||
GuideRouteResult,
|
||||
GuideRouteTarget
|
||||
} from '@/domain/museum'
|
||||
import {
|
||||
NAV_ROUTE_READINESS
|
||||
} from '@/domain/guideReadiness'
|
||||
import {
|
||||
createNavRouteDataset,
|
||||
createRouteResult,
|
||||
type NavRouteDataset,
|
||||
type NavRouteEdge,
|
||||
type NavRouteNode
|
||||
} from '@/data/adapters/navRouteAdapter'
|
||||
import {
|
||||
defaultStaticNavAssetsProvider,
|
||||
type StaticNavAssetsProvider
|
||||
} from '@/data/providers/staticNavAssetsProvider'
|
||||
|
||||
export type GuideRouteErrorCode =
|
||||
| 'ROUTE_DATA_UNAVAILABLE'
|
||||
| 'ROUTE_TARGET_MISSING'
|
||||
| 'ROUTE_TARGET_UNANCHORED'
|
||||
| 'ROUTE_NOT_FOUND'
|
||||
|
||||
export class GuideRouteError extends Error {
|
||||
constructor(
|
||||
readonly code: GuideRouteErrorCode,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'GuideRouteError'
|
||||
}
|
||||
}
|
||||
|
||||
interface RouteAdjacencyEdge {
|
||||
toNodeId: string
|
||||
weight: number
|
||||
}
|
||||
|
||||
interface RouteSearchResult {
|
||||
nodeIds: string[]
|
||||
distance: number
|
||||
}
|
||||
|
||||
export interface GuideRouteRepository {
|
||||
getRouteReadiness(): Promise<GuideRouteReadiness>
|
||||
listRouteTargets(): Promise<GuideRouteTarget[]>
|
||||
findRoute(startPoiId: string, endPoiId: string): Promise<GuideRouteResult>
|
||||
}
|
||||
|
||||
class MinHeapItem {
|
||||
constructor(
|
||||
readonly nodeId: string,
|
||||
readonly distance: number
|
||||
) {}
|
||||
}
|
||||
|
||||
class MinHeap {
|
||||
private items: MinHeapItem[] = []
|
||||
|
||||
get size() {
|
||||
return this.items.length
|
||||
}
|
||||
|
||||
push(item: MinHeapItem) {
|
||||
this.items.push(item)
|
||||
this.bubbleUp(this.items.length - 1)
|
||||
}
|
||||
|
||||
pop() {
|
||||
if (!this.items.length) return null
|
||||
|
||||
const root = this.items[0]
|
||||
const last = this.items.pop()
|
||||
|
||||
if (last && this.items.length) {
|
||||
this.items[0] = last
|
||||
this.bubbleDown(0)
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
private bubbleUp(index: number) {
|
||||
let current = index
|
||||
|
||||
while (current > 0) {
|
||||
const parent = Math.floor((current - 1) / 2)
|
||||
if (this.items[parent].distance <= this.items[current].distance) break
|
||||
|
||||
this.swap(parent, current)
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
private bubbleDown(index: number) {
|
||||
let current = index
|
||||
|
||||
while (current < this.items.length) {
|
||||
const left = current * 2 + 1
|
||||
const right = current * 2 + 2
|
||||
let smallest = current
|
||||
|
||||
if (left < this.items.length && this.items[left].distance < this.items[smallest].distance) {
|
||||
smallest = left
|
||||
}
|
||||
|
||||
if (right < this.items.length && this.items[right].distance < this.items[smallest].distance) {
|
||||
smallest = right
|
||||
}
|
||||
|
||||
if (smallest === current) break
|
||||
|
||||
this.swap(current, smallest)
|
||||
current = smallest
|
||||
}
|
||||
}
|
||||
|
||||
private swap(a: number, b: number) {
|
||||
const next = this.items[a]
|
||||
this.items[a] = this.items[b]
|
||||
this.items[b] = next
|
||||
}
|
||||
}
|
||||
|
||||
const createUnavailableReadiness = (message: string): GuideRouteReadiness => ({
|
||||
ready: false,
|
||||
message,
|
||||
requiredData: ['route_graph', 'nav_data']
|
||||
})
|
||||
|
||||
export class StaticGuideRouteRepository implements GuideRouteRepository {
|
||||
private datasetCache: NavRouteDataset | null = null
|
||||
private datasetRequest: Promise<NavRouteDataset> | null = null
|
||||
private adjacencyCache: Map<string, RouteAdjacencyEdge[]> | null = null
|
||||
|
||||
constructor(private readonly provider: StaticNavAssetsProvider = defaultStaticNavAssetsProvider) {}
|
||||
|
||||
async getRouteReadiness() {
|
||||
const manifestReadiness = await this.provider.loadRouteReadiness()
|
||||
.catch(() => NAV_ROUTE_READINESS)
|
||||
|
||||
if (!manifestReadiness.ready) return manifestReadiness
|
||||
|
||||
try {
|
||||
const dataset = await this.loadDataset()
|
||||
if (!dataset.nodes.size || !dataset.edges.length || !dataset.anchorsByPoiId.size) {
|
||||
return createUnavailableReadiness('路线数据不完整,暂不可查看路径')
|
||||
}
|
||||
|
||||
return manifestReadiness
|
||||
} catch (error) {
|
||||
return createUnavailableReadiness(error instanceof Error ? error.message : '路线数据加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
async listRouteTargets() {
|
||||
const dataset = await this.loadDataset()
|
||||
return dataset.targets
|
||||
}
|
||||
|
||||
async findRoute(startPoiId: string, endPoiId: string) {
|
||||
if (!startPoiId || !endPoiId) {
|
||||
throw new GuideRouteError('ROUTE_TARGET_MISSING', '请选择起点和终点')
|
||||
}
|
||||
|
||||
if (startPoiId === endPoiId) {
|
||||
throw new GuideRouteError('ROUTE_TARGET_MISSING', '起点和终点不能相同')
|
||||
}
|
||||
|
||||
const dataset = await this.loadDataset()
|
||||
const startAnchor = dataset.anchorsByPoiId.get(startPoiId)
|
||||
const endAnchor = dataset.anchorsByPoiId.get(endPoiId)
|
||||
|
||||
if (!startAnchor) {
|
||||
throw new GuideRouteError('ROUTE_TARGET_UNANCHORED', '起点暂无可用路径锚点')
|
||||
}
|
||||
|
||||
if (!endAnchor) {
|
||||
throw new GuideRouteError('ROUTE_TARGET_UNANCHORED', '终点暂无可用路径锚点')
|
||||
}
|
||||
|
||||
const searchResult = this.findShortestPath(startAnchor.routeNodeId, endAnchor.routeNodeId)
|
||||
if (!searchResult) {
|
||||
throw new GuideRouteError('ROUTE_NOT_FOUND', '未找到可通行路线')
|
||||
}
|
||||
|
||||
const nodes = searchResult.nodeIds
|
||||
.map((nodeId) => dataset.nodes.get(nodeId))
|
||||
.filter((node): node is NavRouteNode => Boolean(node))
|
||||
|
||||
return createRouteResult(startAnchor, endAnchor, nodes, searchResult.distance)
|
||||
}
|
||||
|
||||
private async loadDataset() {
|
||||
if (this.datasetCache) return this.datasetCache
|
||||
if (this.datasetRequest) return this.datasetRequest
|
||||
|
||||
this.datasetRequest = Promise.all([
|
||||
this.provider.loadNavData(),
|
||||
this.provider.loadRouteGraph(),
|
||||
this.provider.loadPoiIndex()
|
||||
])
|
||||
.then(([navData, routeGraph, pois]) => {
|
||||
const dataset = createNavRouteDataset(navData, routeGraph, pois)
|
||||
|
||||
if (!dataset.nodes.size) {
|
||||
throw new GuideRouteError('ROUTE_DATA_UNAVAILABLE', '路线节点为空')
|
||||
}
|
||||
|
||||
if (!dataset.edges.length) {
|
||||
throw new GuideRouteError('ROUTE_DATA_UNAVAILABLE', '路线边为空')
|
||||
}
|
||||
|
||||
if (!dataset.anchorsByPoiId.size) {
|
||||
throw new GuideRouteError('ROUTE_DATA_UNAVAILABLE', '路线锚点为空')
|
||||
}
|
||||
|
||||
this.datasetCache = dataset
|
||||
this.adjacencyCache = this.createAdjacency(dataset.edges)
|
||||
return dataset
|
||||
})
|
||||
.finally(() => {
|
||||
this.datasetRequest = null
|
||||
})
|
||||
|
||||
return this.datasetRequest
|
||||
}
|
||||
|
||||
private createAdjacency(edges: NavRouteEdge[]) {
|
||||
const adjacency = new Map<string, RouteAdjacencyEdge[]>()
|
||||
|
||||
const addEdge = (fromNodeId: string, toNodeId: string, weight: number) => {
|
||||
const items = adjacency.get(fromNodeId) || []
|
||||
items.push({ toNodeId, weight })
|
||||
adjacency.set(fromNodeId, items)
|
||||
}
|
||||
|
||||
edges.forEach((edge) => {
|
||||
addEdge(edge.fromNodeId, edge.toNodeId, edge.weight)
|
||||
addEdge(edge.toNodeId, edge.fromNodeId, edge.weight)
|
||||
})
|
||||
|
||||
return adjacency
|
||||
}
|
||||
|
||||
private findShortestPath(startNodeId: string, endNodeId: string): RouteSearchResult | null {
|
||||
const adjacency = this.adjacencyCache
|
||||
if (!adjacency) return null
|
||||
|
||||
const heap = new MinHeap()
|
||||
const distances = new Map<string, number>()
|
||||
const previous = new Map<string, string>()
|
||||
const visited = new Set<string>()
|
||||
|
||||
distances.set(startNodeId, 0)
|
||||
heap.push(new MinHeapItem(startNodeId, 0))
|
||||
|
||||
while (heap.size) {
|
||||
const current = heap.pop()
|
||||
if (!current) break
|
||||
if (visited.has(current.nodeId)) continue
|
||||
|
||||
visited.add(current.nodeId)
|
||||
|
||||
if (current.nodeId === endNodeId) {
|
||||
return {
|
||||
nodeIds: this.reconstructPath(previous, startNodeId, endNodeId),
|
||||
distance: current.distance
|
||||
}
|
||||
}
|
||||
|
||||
const nextEdges = adjacency.get(current.nodeId) || []
|
||||
nextEdges.forEach((edge) => {
|
||||
if (visited.has(edge.toNodeId)) return
|
||||
|
||||
const nextDistance = current.distance + edge.weight
|
||||
const knownDistance = distances.get(edge.toNodeId)
|
||||
|
||||
if (knownDistance !== undefined && knownDistance <= nextDistance) return
|
||||
|
||||
distances.set(edge.toNodeId, nextDistance)
|
||||
previous.set(edge.toNodeId, current.nodeId)
|
||||
heap.push(new MinHeapItem(edge.toNodeId, nextDistance))
|
||||
})
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private reconstructPath(previous: Map<string, string>, startNodeId: string, endNodeId: string) {
|
||||
const path = [endNodeId]
|
||||
let current = endNodeId
|
||||
|
||||
while (current !== startNodeId) {
|
||||
const parent = previous.get(current)
|
||||
if (!parent) return []
|
||||
|
||||
path.push(parent)
|
||||
current = parent
|
||||
}
|
||||
|
||||
return path.reverse()
|
||||
}
|
||||
}
|
||||
|
||||
export const guideRouteRepository = new StaticGuideRouteRepository()
|
||||
@@ -2,44 +2,24 @@ import type {
|
||||
MediaAsset
|
||||
} from '@/domain/museum'
|
||||
import {
|
||||
SGS_SCENE_EXPLAIN_DATASET
|
||||
} from '@/data/mock/sgsSceneExplainData'
|
||||
staticMuseumContentProvider,
|
||||
type ExplainContentProvider
|
||||
} from '@/data/providers/staticMuseumContentProvider'
|
||||
|
||||
export interface MediaRepository {
|
||||
getMediaById(id: string): Promise<MediaAsset | null>
|
||||
getMediaForExplainTrack(trackId: string): Promise<MediaAsset | null>
|
||||
}
|
||||
|
||||
const normalizeExhibitIdFromTrack = (trackId: string) => trackId
|
||||
.replace(/^track-/, '')
|
||||
.replace(/^exhibit-sgs-/, '')
|
||||
|
||||
const unavailableAudio = (id: string, reason = 'SGS 场景设置音频资源尚未同步到当前 H5 项目'): MediaAsset => ({
|
||||
id,
|
||||
type: 'audio',
|
||||
available: false,
|
||||
unavailableReason: reason
|
||||
})
|
||||
|
||||
const findSourceAudioById = (id: string) => {
|
||||
const normalizedId = id.replace(/^media-/, '')
|
||||
const exhibitSourceId = normalizeExhibitIdFromTrack(normalizedId)
|
||||
return SGS_SCENE_EXPLAIN_DATASET.exhibits.find((exhibit) => String(exhibit.id) === exhibitSourceId)
|
||||
}
|
||||
|
||||
export class DefaultMediaRepository implements MediaRepository {
|
||||
async getMediaById(id: string) {
|
||||
const source = findSourceAudioById(id)
|
||||
constructor(private readonly contentProvider: ExplainContentProvider = staticMuseumContentProvider) {}
|
||||
|
||||
if (source?.audioUrl) {
|
||||
return unavailableAudio(id, `SGS 场景设置源数据包含音频地址 ${source.audioUrl},但该静态音频尚未同步到当前 H5 项目。`)
|
||||
}
|
||||
|
||||
return unavailableAudio(id, '该讲解在 SGS 场景设置源数据中暂无音频地址')
|
||||
getMediaById(id: string) {
|
||||
return this.contentProvider.getMediaById(id)
|
||||
}
|
||||
|
||||
async getMediaForExplainTrack(trackId: string) {
|
||||
return this.getMediaById(`media-${trackId}`)
|
||||
getMediaForExplainTrack(trackId: string) {
|
||||
return this.contentProvider.getMediaForExplainTrack(trackId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
160
src/repositories/PublishedExhibitAudioRepository.ts
Normal file
160
src/repositories/PublishedExhibitAudioRepository.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
dataSourceConfig
|
||||
} from '@/config/dataSource'
|
||||
import type {
|
||||
AudioPlayTargetType
|
||||
} from '@/domain/museum'
|
||||
import type {
|
||||
AudioLanguage,
|
||||
AudioPlayInfoRequest
|
||||
} from '@/repositories/AudioPlayInfoRepository'
|
||||
|
||||
interface CommonResult<T> {
|
||||
code: number
|
||||
msg?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
interface PublishedGuideContent {
|
||||
id?: string | number | null
|
||||
title?: string | null
|
||||
targetType?: string | null
|
||||
targetId?: string | number | null
|
||||
}
|
||||
|
||||
interface PublishedExhibitAudioSummary {
|
||||
id?: string | number | null
|
||||
name?: string | null
|
||||
hasAudio?: boolean
|
||||
supportedLanguages?: string[]
|
||||
audioStatus?: string | null
|
||||
playTargetType?: string | null
|
||||
playTargetId?: string | number | null
|
||||
audioDuration?: number | null
|
||||
guideContents?: PublishedGuideContent[]
|
||||
}
|
||||
|
||||
export interface PublishedExhibitAudioResolution {
|
||||
targets: AudioPlayInfoRequest[]
|
||||
}
|
||||
|
||||
export interface PublishedExhibitAudioRepository {
|
||||
resolveByExhibitName(exhibitName: string, lang?: AudioLanguage): Promise<PublishedExhibitAudioResolution | null>
|
||||
}
|
||||
|
||||
const stringifyId = (value: string | number | null | undefined) => (
|
||||
value === null || typeof value === 'undefined' ? '' : String(value)
|
||||
)
|
||||
|
||||
const normalizeBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, '')
|
||||
|
||||
const resolveAppApiBaseUrl = () => {
|
||||
const baseUrl = normalizeBaseUrl(dataSourceConfig.audioApiBaseUrl || dataSourceConfig.apiBaseUrl)
|
||||
return baseUrl.endsWith('/app-api') ? baseUrl : `${baseUrl}/app-api`
|
||||
}
|
||||
|
||||
const parseJsonPayload = <T>(payload: unknown): T => {
|
||||
if (typeof payload === 'string') {
|
||||
return JSON.parse(payload) as T
|
||||
}
|
||||
|
||||
return payload as T
|
||||
}
|
||||
|
||||
const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url,
|
||||
method: 'GET',
|
||||
success: (response) => {
|
||||
const statusCode = Number(response.statusCode || 0)
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
reject(new Error(`展品音频发布接口请求失败: ${statusCode}`))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(parseJsonPayload<T>(response.data))
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
|
||||
const normalizeAudioTargetType = (targetType: string | null | undefined): AudioPlayTargetType | null => {
|
||||
const normalizedType = targetType?.toUpperCase()
|
||||
return normalizedType === 'STOP' || normalizedType === 'ITEM'
|
||||
? normalizedType
|
||||
: null
|
||||
}
|
||||
|
||||
const addTarget = (
|
||||
targets: AudioPlayInfoRequest[],
|
||||
targetType: string | null | undefined,
|
||||
targetId: string | number | null | undefined,
|
||||
lang: AudioLanguage
|
||||
) => {
|
||||
const normalizedType = normalizeAudioTargetType(targetType)
|
||||
const normalizedId = stringifyId(targetId)
|
||||
if (!normalizedType || !normalizedId) return
|
||||
|
||||
const exists = targets.some((target) => (
|
||||
target.targetType === normalizedType && target.targetId === normalizedId && target.lang === lang
|
||||
))
|
||||
if (exists) return
|
||||
|
||||
targets.push({
|
||||
targetType: normalizedType,
|
||||
targetId: normalizedId,
|
||||
lang
|
||||
})
|
||||
}
|
||||
|
||||
const buildResolution = (
|
||||
exhibit: PublishedExhibitAudioSummary,
|
||||
lang: AudioLanguage
|
||||
): PublishedExhibitAudioResolution => {
|
||||
const targets: AudioPlayInfoRequest[] = []
|
||||
|
||||
addTarget(targets, exhibit.playTargetType, exhibit.playTargetId, lang)
|
||||
exhibit.guideContents?.forEach((guide) => {
|
||||
addTarget(targets, guide.targetType, guide.targetId, lang)
|
||||
})
|
||||
addTarget(targets, 'ITEM', exhibit.id, lang)
|
||||
|
||||
return { targets }
|
||||
}
|
||||
|
||||
export class DefaultPublishedExhibitAudioRepository implements PublishedExhibitAudioRepository {
|
||||
async resolveByExhibitName(exhibitName: string, lang: AudioLanguage = dataSourceConfig.audioLanguage as AudioLanguage) {
|
||||
const keyword = exhibitName.trim()
|
||||
if (!keyword) return null
|
||||
|
||||
const searchUrl = `${resolveAppApiBaseUrl()}/gis/exhibit/search?keyword=${encodeURIComponent(keyword)}`
|
||||
const searchResponse = await requestJson<CommonResult<PublishedExhibitAudioSummary[]>>(searchUrl)
|
||||
if (searchResponse.code !== 0 || !searchResponse.data?.length) return null
|
||||
|
||||
const exactMatches = searchResponse.data.filter((item) => item.name === keyword)
|
||||
const matched = (
|
||||
exactMatches.find((item) => item.hasAudio && item.supportedLanguages?.includes(lang))
|
||||
|| exactMatches.find((item) => item.hasAudio)
|
||||
|| exactMatches[0]
|
||||
|| searchResponse.data[0]
|
||||
)
|
||||
const exhibitId = stringifyId(matched.id)
|
||||
let detail = matched
|
||||
|
||||
if (exhibitId) {
|
||||
const detailUrl = `${resolveAppApiBaseUrl()}/gis/exhibit/get?id=${encodeURIComponent(exhibitId)}`
|
||||
const detailResponse = await requestJson<CommonResult<PublishedExhibitAudioSummary>>(detailUrl)
|
||||
if (detailResponse.code === 0 && detailResponse.data) {
|
||||
detail = detailResponse.data
|
||||
}
|
||||
}
|
||||
|
||||
return buildResolution(detail, lang)
|
||||
}
|
||||
}
|
||||
|
||||
export const publishedExhibitAudioRepository = new DefaultPublishedExhibitAudioRepository()
|
||||
Reference in New Issue
Block a user