chore: freeze guide and explain update

This commit is contained in:
lyf
2026-06-24 18:00:25 +08:00
parent feb7310a46
commit 67c6609ae6
104 changed files with 3203572 additions and 40713 deletions

View 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()

View File

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

View File

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

View File

@@ -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)
}
}

View 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()

View File

@@ -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)
}
}

View 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()