提交H5业务闭环修复代码

This commit is contained in:
lyf
2026-07-01 16:02:21 +08:00
parent c43aa00038
commit e0aeafe062
26 changed files with 396 additions and 95 deletions

View File

@@ -41,10 +41,26 @@ const copyStaticSubtree = (subtree) => {
console.log(`Copied ${subtree} assets to ${path.relative(projectRoot, targetDir)}`)
}
const copyStaticFile = (fileName) => {
const sourceFile = path.join(projectRoot, 'static', fileName)
const targetFile = path.join(h5Root, 'static', fileName)
if (!fs.existsSync(sourceFile)) {
throw new Error(`Static file not found: ${sourceFile}`)
}
fs.mkdirSync(path.dirname(targetFile), { recursive: true })
fs.copyFileSync(sourceFile, targetFile)
console.log(`Copied ${fileName} to ${path.relative(projectRoot, targetFile)}`)
}
copyStaticSubtree('nav-assets')
copyStaticSubtree('guide-data')
copyStaticSubtree('icons')
copyStaticSubtree('sgs-map-sdk')
copyStaticSubtree('three')
copyStaticFile('exhibit-placeholder.jpg')
copyStaticFile('hall-placeholder.jpg')
if (fs.existsSync(faviconSource)) {
fs.copyFileSync(faviconSource, faviconTarget)

View File

@@ -11,7 +11,7 @@
<view class="player-cover">
<image
class="cover-image"
:src="currentAudio?.image || '/static/exhibit-placeholder.jpg'"
:src="currentAudio?.image || EXHIBIT_PLACEHOLDER_IMAGE"
mode="aspectFill"
/>
</view>
@@ -56,6 +56,9 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import {
EXHIBIT_PLACEHOLDER_IMAGE
} from '@/utils/placeholders'
export interface AudioItem {
id: string

View File

@@ -8,6 +8,10 @@ import type {
import {
normalizeSameOriginPublicUrl
} from '@/utils/publicUrl'
import {
EXHIBIT_PLACEHOLDER_IMAGE,
HALL_PLACEHOLDER_IMAGE
} from '@/utils/placeholders'
export interface BackendGuideContent {
id?: string | number | null
@@ -147,8 +151,10 @@ export const toBackendMuseumExhibit = (
const targetType = normalizeAudioTargetType(guide?.targetType || source.playTargetType)
const targetId = stringifyId(guide?.targetId || source.playTargetId)
const supportedLanguage = source.supportedLanguages?.find((language) => language === 'zh-CN' || language === 'en-US')
const hasAudioSummary = source.hasAudio === true && isAudioReady(source.audioStatus)
const audioAvailable = Boolean(audioUrl) || hasAudioSummary
const metadataSuggestsAudio = source.hasAudio === true && isAudioReady(source.audioStatus)
const audioAvailable = Boolean(audioUrl)
const sourcePoiId = stringifyId(source.poiId)
const location = hall?.location
return {
id,
@@ -157,9 +163,11 @@ export const toBackendMuseumExhibit = (
hallName: firstText(source.hallName, hall?.name),
floorId: stringifyId(source.floorId) || hall?.floorId,
floorLabel: firstText(source.floorLabel, hall?.floorLabel),
image: resolveImage(source) || '/static/exhibit-placeholder.jpg',
image: resolveImage(source) || EXHIBIT_PLACEHOLDER_IMAGE,
description: firstText(source.description, source.narrationText, source.scientificName, '该展项暂无讲解文稿。'),
poiId: stringifyId(source.poiId) || undefined,
poiId: sourcePoiId || location?.poiId || undefined,
sourcePoiId: sourcePoiId || location?.sourcePoiId,
location,
year: firstText(source.era) || undefined,
material: firstText(source.origin) || undefined,
size: firstText(source.exhibitCode, source.dimensions) || undefined,
@@ -173,7 +181,9 @@ export const toBackendMuseumExhibit = (
audioText: options.includeDetail && guideText ? guideText : undefined,
audioHasText: options.includeDetail ? Boolean(guideText) : undefined,
audioAvailable,
audioUnavailableReason: audioAvailable ? undefined : '该讲解暂无已发布音频,当前提供图文讲解。',
audioUnavailableReason: audioAvailable || metadataSuggestsAudio
? undefined
: '该讲解暂无已发布音频,当前提供图文讲解。',
playTargetType: targetType,
playTargetId: targetId || undefined
}
@@ -189,7 +199,7 @@ export const toBackendExplainTrack = (exhibit: MuseumExhibit): ExplainTrack => (
coverImage: exhibit.image,
poiId: exhibit.poiId,
floorId: exhibit.floorId,
available: Boolean(exhibit.audioUrl) || exhibit.audioAvailable === true,
available: Boolean(exhibit.audioUrl),
playTargetType: exhibit.playTargetType,
playTargetId: exhibit.playTargetId
})
@@ -219,7 +229,7 @@ export const toBackendHall = (
floorId: firstText(first?.floorId, fallback?.floorId) || undefined,
floorLabel: firstText(first?.floorLabel, fallback?.floorLabel, '楼层待补充'),
description: fallback?.description || '该展厅讲解内容来自后端展品接口。',
image: fallback?.image || '/static/hall-placeholder.jpg',
image: fallback?.image || HALL_PLACEHOLDER_IMAGE,
exhibitCount: exhibits.length,
area: fallback?.area,
poiId: fallback?.poiId,

View File

@@ -1,8 +1,5 @@
import type {
ExplainTrack,
MediaAsset,
MuseumExhibit,
MuseumHall
MuseumExhibit
} from '@/domain/museum'
import {
dataSourceConfig

View File

@@ -143,35 +143,15 @@ const detailMeta = computed(() => [
].filter(Boolean).join(' · '))
const hallLocationId = computed(() => exhibit.value.hallId || resolvedHallId.value)
const normalizePoiName = (value: string) => value
.trim()
.replace(/[\s()【】[\]_-]/g, '')
.toLowerCase()
// 讲解详情页只定位到所属展厅,不再追踪具体展品点位。
const resolveHallGuidePoi = async () => {
const hallId = exhibit.value.hallId
if (hallId) {
const hallPoi = await guideUseCase.getPoiById(hallId)
if (hallPoi?.kind === 'hall' || hallPoi?.primaryCategory.id === 'exhibition_hall') {
return hallPoi
}
}
const normalizedTarget = normalizePoiName(exhibit.value.hallName || '')
if (!normalizedTarget) return null
const candidates = await guideUseCase.searchPois(exhibit.value.hallName || exhibit.value.title || '')
return candidates.find((candidate) => {
const candidateName = normalizePoiName(candidate.name)
const candidateHallName = normalizePoiName(candidate.hallName || '')
return candidate.kind === 'hall'
|| candidate.primaryCategory.id === 'exhibition_hall'
|| candidateName === normalizedTarget
|| candidateHallName === normalizedTarget
|| candidateName.includes(normalizedTarget)
|| normalizedTarget.includes(candidateName)
}) || candidates[0] || null
return guideUseCase.resolveContentLocationPreviewTarget({
directPoiId: exhibit.value.location?.poiId,
location: exhibit.value.location,
hallId: exhibit.value.hallId,
hallName: exhibit.value.hallName,
targetName: exhibit.value.title
})
}
onLoad(async (options: any = {}) => {
@@ -288,6 +268,15 @@ const handleAudioError = (_audio: AudioItem | null, message: string) => {
isPlaying.value = false
showAudioPlayer.value = false
currentAudio.value = null
exhibit.value = {
...exhibit.value,
audio: {
...exhibit.value.audio,
status: 'unavailable',
url: undefined,
unavailableReason: message || '音频加载失败,当前提供图文讲解。'
}
}
}
const handleNavigate = async () => {

View File

@@ -95,6 +95,9 @@ import {
isGuideTopTab,
type GuideTopTab
} from '@/utils/guideTopTabs'
import {
HALL_PLACEHOLDER_IMAGE
} from '@/utils/placeholders'
const activeTopTab = ref<GuideTopTab>('explain')
const loading = ref(false)
@@ -106,7 +109,7 @@ const hall = ref<HallDetailViewModel>({
name: '展厅讲解',
floorLabel: '楼层待补充',
description: '该展厅暂无介绍文案。',
image: '/static/hall-placeholder.jpg',
image: HALL_PLACEHOLDER_IMAGE,
exhibitCount: 0,
area: '待补充'
})
@@ -119,12 +122,6 @@ const explainCountText = computed(() => (
const hallLocationPoiId = computed(() => hall.value.poiId || resolvedHallPoiId.value)
const normalizePoiName = (value: string) => value
.trim()
.replace(/[\s()【】[\]_-]/g, '')
.toLowerCase()
// 展厅数据里可能没有直接写入 poiId优先走内容层映射再按展厅名称兜底匹配导览点位。
const applyMatchedHallPoi = (matchedPoi: Awaited<ReturnType<typeof guideUseCase.getPoiById>>) => {
if (!matchedPoi) return
@@ -146,26 +143,13 @@ const applyMatchedHallPoi = (matchedPoi: Awaited<ReturnType<typeof guideUseCase.
}
const resolveHallGuidePoi = async () => {
const directPoiId = hall.value.location?.poiId || hall.value.poiId
if (directPoiId) {
const directPoi = await guideUseCase.getPoiById(directPoiId)
if (directPoi) return directPoi
}
const normalizedTarget = normalizePoiName(hall.value.name)
if (!normalizedTarget) return null
const candidates = await guideUseCase.searchPois(hall.value.name)
return candidates.find((candidate) => {
const candidateName = normalizePoiName(candidate.name)
const candidateHallName = normalizePoiName(candidate.hallName || '')
return candidate.kind === 'hall'
|| candidate.primaryCategory.id === 'exhibition_hall'
|| candidateName === normalizedTarget
|| candidateHallName === normalizedTarget
|| candidateName.includes(normalizedTarget)
|| normalizedTarget.includes(candidateName)
}) || candidates[0] || null
return guideUseCase.resolveContentLocationPreviewTarget({
directPoiId: hall.value.poiId,
location: hall.value.location,
hallId: hall.value.id,
hallName: hall.value.name,
targetName: hall.value.name
})
}
const resolveHallGuidePoiInBackground = () => {

View File

@@ -34,7 +34,7 @@
floor-side="left"
show-floor-header
:show-layer-mode-toggle="false"
:show-floor="is3DMode"
:show-floor="is3DMode && !showRoutePlanner"
show-zoom-controls
zoom-controls-top="calc(100vh - 252px)"
:route-preview="activeRoutePreview"
@@ -858,20 +858,6 @@ const refreshRouteReadinessState = async () => {
}
}
const openRoutePlanner = () => {
closeOutdoorNavPanel()
showRoutePlanner.value = true
is3DMode.value = true
indoorView.value = indoorView.value === 'overview' ? 'floor' : indoorView.value
guideOutdoorState.value = 'home'
isPoiCardCollapsed.value = false
isSimulatingRoute.value = false
if (!routePointOptions.value.length) {
void loadRouteTargets()
}
void refreshRouteReadinessState()
}
const clearRoutePreviewState = () => {
guideMapShellRef.value?.clearRoute?.()
activeRoutePreview.value = null
@@ -1036,7 +1022,15 @@ const handleSwitchEntrance = () => {
}
const handleMoreRouteGuide = () => {
openRoutePlanner()
closeOutdoorNavPanel()
showRoutePlanner.value = false
is3DMode.value = true
indoorView.value = 'floor'
guideOutdoorState.value = 'home'
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
isSimulatingRoute.value = false
showIndoorHint(`当前楼层:${getGuideFloorLabel(renderedFloorId.value || activeGuideFloor.value) || '馆内单层'}`, 3200)
}
// 馆外导航处理函数

View File

@@ -114,9 +114,20 @@
</view>
<view
v-else-if="routeViewState === 'preview'"
v-else-if="routeViewState === 'preview' && !isIndoorGuidePanelHidden"
class="indoor-guide-card"
:class="{ dragging: isIndoorGuideDragging }"
:style="indoorGuideCardStyle"
@touchstart.stop="handleIndoorGuideGestureStart"
@touchmove.stop="handleIndoorGuideGestureMove"
@touchend.stop="handleIndoorGuideGestureEnd"
@touchcancel.stop="handleIndoorGuideGestureEnd"
@mousedown.stop="handleIndoorGuideMouseStart"
@mousemove.stop="handleIndoorGuideMouseMove"
@mouseup.stop="handleIndoorGuideMouseEnd"
@mouseleave.stop="handleIndoorGuideMouseEnd"
>
<view class="indoor-guide-handle" @tap.stop="hideIndoorGuidePanel"></view>
<text class="indoor-guide-title">{{ indoorGuideTitle }}</text>
<text class="indoor-guide-desc">{{ indoorGuideDesc }}</text>
<view v-if="routePlanError" class="indoor-guide-error">
@@ -140,6 +151,15 @@
</view>
</view>
</view>
<view
v-else-if="routeViewState === 'preview' && isIndoorGuidePanelHidden"
class="indoor-guide-peek"
@tap="showIndoorGuidePanel"
>
<view class="indoor-guide-peek-handle"></view>
<text class="indoor-guide-peek-text">点位预览</text>
</view>
</GuideMapShell>
</GuidePageFrame>
</template>
@@ -207,6 +227,14 @@ const routeReadinessMessage = ref('')
const routePlanning = ref(false)
const routePlanError = ref('')
const activeRoutePreview = ref<GuideRouteResult | null>(null)
const isIndoorGuidePanelHidden = ref(false)
const indoorGuideTouchStartY = ref(0)
const indoorGuideTouchCurrentY = ref(0)
const indoorGuideTouchStartTime = ref(0)
const isIndoorGuideDragging = ref(false)
const indoorGuideHideDistance = 60
const indoorGuideFastSwipeDistance = 24
const indoorGuideFastSwipeVelocity = 0.5
const manualFloors = computed(() => guideFloors.value.map((floor) => floor.label))
const guideFloorLabel = (floorId: string) => (
guideFloors.value.find((floor) => floor.id === floorId)?.label || floorId
@@ -336,6 +364,17 @@ const indoorGuideDesc = computed(() => {
: routeReadinessMessage.value || '当前可查看位置预览,暂不提供正式室内导航。'
})
const indoorGuideDragOffset = computed(() => Math.max(
0,
Math.min(120, indoorGuideTouchCurrentY.value - indoorGuideTouchStartY.value)
))
const indoorGuideCardStyle = computed(() => (
indoorGuideDragOffset.value > 0
? { transform: `translateY(${indoorGuideDragOffset.value}px)` }
: {}
))
const safeDecode = (value: string) => {
try {
return decodeURIComponent(value)
@@ -456,6 +495,7 @@ const applyRouteOptions = async (options: Record<string, unknown> = {}) => {
targetFocusRequest.value = null
activeRoutePreview.value = null
isManualLocationPanelOpen.value = false
isIndoorGuidePanelHidden.value = false
return false
}
@@ -469,6 +509,7 @@ const applyRouteOptions = async (options: Record<string, unknown> = {}) => {
targetFocusRequest.value = null
activeRoutePreview.value = null
isManualLocationPanelOpen.value = false
isIndoorGuidePanelHidden.value = false
return false
}
@@ -481,6 +522,7 @@ const applyRouteOptions = async (options: Record<string, unknown> = {}) => {
hasRouteTarget.value = true
routeViewState.value = isRouteViewState(state) ? state : 'preview'
isManualLocationPanelOpen.value = false
isIndoorGuidePanelHidden.value = false
if (routeViewState.value === 'preview') {
requestTargetFocus(route.value.targetLocation, matchedPoi.name)
@@ -556,12 +598,95 @@ const handleTargetFocus = (result: TargetPoiFocusResult) => {
const handleReturnToPreview = () => {
isManualLocationPanelOpen.value = false
isIndoorGuidePanelHidden.value = false
activeRoutePreview.value = null
routeViewState.value = 'preview'
requestTargetFocus(route.value.targetLocation)
saveCurrentLocationPreviewUrl()
}
const hideIndoorGuidePanel = () => {
isIndoorGuidePanelHidden.value = true
isIndoorGuideDragging.value = false
indoorGuideTouchStartY.value = 0
indoorGuideTouchCurrentY.value = 0
indoorGuideTouchStartTime.value = 0
}
const showIndoorGuidePanel = () => {
isIndoorGuidePanelHidden.value = false
indoorGuideTouchStartY.value = 0
indoorGuideTouchCurrentY.value = 0
indoorGuideTouchStartTime.value = 0
}
const getIndoorGuideGestureClientY = (event: TouchEvent | MouseEvent) => {
if ('changedTouches' in event) {
return event.changedTouches?.[0]?.clientY
?? event.touches?.[0]?.clientY
?? 0
}
return event.clientY
}
const startIndoorGuidePanelDrag = (clientY: number) => {
isIndoorGuideDragging.value = true
indoorGuideTouchStartY.value = clientY
indoorGuideTouchCurrentY.value = clientY
indoorGuideTouchStartTime.value = Date.now()
}
const moveIndoorGuidePanelDrag = (clientY: number) => {
if (!isIndoorGuideDragging.value) return
indoorGuideTouchCurrentY.value = clientY
}
const finishIndoorGuidePanelDrag = (clientY: number) => {
if (!isIndoorGuideDragging.value) return
indoorGuideTouchCurrentY.value = clientY
const distance = indoorGuideTouchCurrentY.value - indoorGuideTouchStartY.value
const duration = Math.max(Date.now() - indoorGuideTouchStartTime.value, 1)
const velocity = distance / duration
const shouldHideByDistance = distance > indoorGuideHideDistance
const shouldHideBySpeed = distance > indoorGuideFastSwipeDistance && velocity > indoorGuideFastSwipeVelocity
if (shouldHideByDistance || shouldHideBySpeed) {
hideIndoorGuidePanel()
return
}
isIndoorGuideDragging.value = false
indoorGuideTouchStartY.value = 0
indoorGuideTouchCurrentY.value = 0
indoorGuideTouchStartTime.value = 0
}
const handleIndoorGuideGestureStart = (event: TouchEvent) => {
startIndoorGuidePanelDrag(getIndoorGuideGestureClientY(event))
}
const handleIndoorGuideGestureMove = (event: TouchEvent) => {
moveIndoorGuidePanelDrag(getIndoorGuideGestureClientY(event))
}
const handleIndoorGuideGestureEnd = (event: TouchEvent) => {
finishIndoorGuidePanelDrag(getIndoorGuideGestureClientY(event))
}
const handleIndoorGuideMouseStart = (event: MouseEvent) => {
startIndoorGuidePanelDrag(getIndoorGuideGestureClientY(event))
}
const handleIndoorGuideMouseMove = (event: MouseEvent) => {
moveIndoorGuidePanelDrag(getIndoorGuideGestureClientY(event))
}
const handleIndoorGuideMouseEnd = (event: MouseEvent) => {
finishIndoorGuidePanelDrag(getIndoorGuideGestureClientY(event))
}
const handleRelocate = () => {
isManualLocationPanelOpen.value = false
routeViewState.value = 'preview'
@@ -740,7 +865,7 @@ const handlePageBack = () => {
right: 16px;
bottom: calc(env(safe-area-inset-bottom) + 116px);
min-height: 156px;
padding: 18px 18px 16px;
padding: 24px 18px 16px;
display: flex;
flex-direction: column;
gap: 10px;
@@ -750,6 +875,56 @@ const handlePageBack = () => {
border-radius: 8px;
box-shadow: 0 10px 24px rgba(36, 49, 42, 0.14);
z-index: 45;
transition: transform 0.18s ease;
touch-action: pan-y;
}
.indoor-guide-card.dragging {
transition: none;
}
.indoor-guide-handle {
position: absolute;
top: 8px;
left: 50%;
width: 42px;
height: 4px;
transform: translateX(-50%);
background: #d5d8cf;
border-radius: 999px;
}
.indoor-guide-peek {
position: absolute;
left: 50%;
bottom: calc(env(safe-area-inset-bottom) + 104px);
width: 132px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
box-sizing: border-box;
transform: translateX(-50%);
background: rgba(255, 255, 255, 0.96);
border: 1px solid #e5e6de;
border-radius: 8px;
box-shadow: 0 8px 18px rgba(36, 49, 42, 0.14);
z-index: 45;
}
.indoor-guide-peek-handle {
width: 22px;
height: 4px;
background: #151713;
border-radius: 999px;
}
.indoor-guide-peek-text {
font-size: 13px;
line-height: 18px;
font-weight: 600;
color: #151713;
}
.indoor-guide-title {

View File

@@ -68,6 +68,35 @@ const toSearchText = (poi: MuseumPoi) => [
.join(' ')
.toLowerCase()
const normalizePoiLookupText = (value: string) => value
.trim()
.replace(/[\s()【】[\]_-]/g, '')
.toLowerCase()
const extractNameFromNavPoiId = (id: string) => {
const normalized = id.trim()
if (!normalized.startsWith('poi_')) return ''
const segments = normalized.split('_')
if (segments.length < 3) return ''
return segments.slice(2).join('_')
}
const findPoiByNavIdNameFallback = (pois: MuseumPoi[], id: string) => {
const targetName = normalizePoiLookupText(extractNameFromNavPoiId(id))
if (!targetName) return null
return pois.find((poi) => {
const name = normalizePoiLookupText(poi.name)
const hallName = normalizePoiLookupText(poi.hallName || '')
return name === targetName
|| hallName === targetName
|| (name && (name.includes(targetName) || targetName.includes(name)))
|| (hallName && (hallName.includes(targetName) || targetName.includes(hallName)))
}) || null
}
const toLocationPreview = (poi: MuseumPoi): GuideLocationPreview => ({
poiId: poi.id,
name: poi.name,
@@ -148,7 +177,7 @@ export class StaticGuideRepository implements GuideRepository {
async getPoiById(id: string) {
const pois = await this.listPois()
return pois.find((poi) => poi.id === id) || null
return pois.find((poi) => poi.id === id) || findPoiByNavIdNameFallback(pois, id)
}
async searchPois(keyword = '') {
@@ -226,6 +255,7 @@ export class SgsSdkGuideRepository implements GuideRepository {
private floorsCache: MuseumFloor[] | null = null
private poiCache: MuseumPoi[] | null = null
private floorAliases: Map<string, string> | null = null
private readonly staticFallback = new StaticGuideRepository()
constructor(private readonly provider: SgsSdkApiProvider = defaultSgsSdkApiProvider) {}
@@ -299,7 +329,9 @@ export class SgsSdkGuideRepository implements GuideRepository {
async getPoiById(id: string) {
const pois = await this.listPois()
return pois.find((poi) => poi.id === id) || null
return pois.find((poi) => poi.id === id)
|| findPoiByNavIdNameFallback(pois, id)
|| this.staticFallback.getPoiById(id)
}
async searchPois(keyword = '') {

View File

@@ -65,6 +65,7 @@ const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject
uni.request({
url,
method: 'GET',
timeout: 5000,
success: (response) => {
const statusCode = Number(response.statusCode || 0)
if (statusCode < 200 || statusCode >= 300) {

View File

@@ -52,14 +52,14 @@ export class ExplainUseCase {
) {}
private applyTrackAudioState(exhibit: MuseumExhibit, track?: ExplainTrack | null): MuseumExhibit {
const hasExhibitAudio = exhibit.audioAvailable === true || Boolean(exhibit.audioUrl)
const hasTrackAudio = track?.available === true
const hasPlayableAudioUrl = Boolean(exhibit.audioUrl?.trim())
const hasTrackTarget = Boolean(track?.playTargetType || track?.playTargetId)
if (!hasExhibitAudio && !hasTrackAudio) return exhibit
if (!hasPlayableAudioUrl && !hasTrackTarget) return exhibit
return {
...exhibit,
audioAvailable: true,
audioAvailable: hasPlayableAudioUrl,
playTargetType: track?.playTargetType || exhibit.playTargetType,
playTargetId: track?.playTargetId || exhibit.playTargetId
}

View File

@@ -1,6 +1,7 @@
import type {
GuideDataIntegrityReport,
GuideFloorDetail,
GuideLocationResolution,
GuideRouteReadiness,
GuideLocationPreviewPlan,
GuideMapDiagnostics,
@@ -42,6 +43,21 @@ const formatStartStepText = (startLocation: GuideStartLocation) => {
return `起点:${startLocation.label}${floorSuffix}${sourceSuffix}`
}
const normalizeLocationText = (value: string) => value
.trim()
.replace(/[\s()【】[\]_-]/g, '')
.toLowerCase()
const isHallLikePoi = (poi: MuseumPoi) => (
poi.kind === 'hall'
|| poi.primaryCategory.id === 'exhibition_hall'
|| poi.primaryCategory.id === 'touring_poi'
)
const uniqueStrings = (values: Array<string | undefined>) => Array.from(new Set(
values.map((value) => value?.trim()).filter(Boolean) as string[]
))
const createPreviewSteps = (
poi: MuseumPoi | null,
startLocation: GuideStartLocation | null,
@@ -74,6 +90,14 @@ const createPreviewSteps = (
]
}
export interface GuideContentLocationRequest {
directPoiId?: string
location?: GuideLocationResolution | null
hallId?: string
hallName?: string
targetName?: string
}
export class GuideUseCase {
private routeReadinessCache: GuideRouteReadiness | null = null
@@ -119,6 +143,71 @@ export class GuideUseCase {
return this.guide.getPoiById(id)
}
private async getPoiByIdIfResolvable(id: string, requireHall = false) {
const poi = await this.guide.getPoiById(id)
if (!poi) return null
if (requireHall && !isHallLikePoi(poi)) return null
return poi
}
private async searchBestPoiByName(name: string, requireHall = false) {
const normalizedTarget = normalizeLocationText(name)
if (!normalizedTarget) return null
const candidates = await this.guide.searchPois(name)
const allowedCandidates = requireHall
? candidates.filter(isHallLikePoi)
: candidates
return allowedCandidates.find((candidate) => {
const candidateName = normalizeLocationText(candidate.name)
const candidateHallName = normalizeLocationText(candidate.hallName || '')
return candidateName === normalizedTarget
|| candidateHallName === normalizedTarget
|| candidateName.includes(normalizedTarget)
|| normalizedTarget.includes(candidateName)
|| candidateHallName.includes(normalizedTarget)
|| normalizedTarget.includes(candidateHallName)
}) || allowedCandidates[0] || null
}
async resolveContentLocationPreviewTarget(request: GuideContentLocationRequest) {
const exactLocationPoiId = request.location?.status === 'exact'
? request.location.poiId
: undefined
const directIds = uniqueStrings([exactLocationPoiId, request.directPoiId])
for (const poiId of directIds) {
const poi = await this.getPoiByIdIfResolvable(poiId)
if (poi) return poi
}
const bridgedIds = uniqueStrings([request.location?.poiId])
for (const poiId of bridgedIds) {
const poi = await this.getPoiByIdIfResolvable(poiId)
if (poi) return poi
}
const hallIds = uniqueStrings([request.hallId])
for (const hallId of hallIds) {
const poi = await this.getPoiByIdIfResolvable(hallId, true)
if (poi) return poi
}
const hallNames = uniqueStrings([request.hallName])
for (const hallName of hallNames) {
const poi = await this.searchBestPoiByName(hallName, true)
if (poi) return poi
}
const targetNames = uniqueStrings([request.targetName])
for (const targetName of targetNames) {
const poi = await this.searchBestPoiByName(targetName)
if (poi) return poi
}
return null
}
getFloorDetail(floorId: string): Promise<GuideFloorDetail | null> {
return this.guide.getFloorDetail
? this.guide.getFloorDetail(floorId)

View File

@@ -0,0 +1,2 @@
export const EXHIBIT_PLACEHOLDER_IMAGE = '/static/icons/marker-exhibit.svg'
export const HALL_PLACEHOLDER_IMAGE = '/static/icons/marker-hall.svg'

View File

@@ -14,7 +14,10 @@ export const normalizeSameOriginPublicUrl = (url: string | null | undefined) =>
if (
isSameServer
&& parsed.port === '9000'
&& parsed.pathname.startsWith('/museum-assets/')
&& (
parsed.pathname.startsWith('/museum-assets/')
|| parsed.pathname.startsWith('/tts-audio/')
)
) {
return `${parsed.pathname}${parsed.search}${parsed.hash}`
}

View File

@@ -4,6 +4,10 @@ import type {
MuseumHall,
SearchIndexItem
} from '@/domain/museum'
import {
EXHIBIT_PLACEHOLDER_IMAGE,
HALL_PLACEHOLDER_IMAGE
} from '@/utils/placeholders'
export type ExplainContentType = 'hall' | 'zone' | 'exhibit' | 'theme'
export type ExplainAudioStatus = 'playable' | 'unavailable' | 'none'
@@ -108,8 +112,10 @@ const languageLabelFor = (language?: string) => {
return language || undefined
}
const hasPlayableAudioUrl = (exhibit: MuseumExhibit) => Boolean(exhibit.audioUrl?.trim())
export const toExplainCardViewModel = (exhibit: MuseumExhibit): ExplainCardViewModel => {
const hasPlayableAudio = exhibit.audioAvailable === true || Boolean(exhibit.audioUrl)
const hasPlayableAudio = hasPlayableAudioUrl(exhibit)
const audioStatus: ExplainAudioStatus = hasPlayableAudio ? 'playable' : 'unavailable'
const location = exhibit.location
@@ -140,7 +146,7 @@ export const toExplainCardViewModel = (exhibit: MuseumExhibit): ExplainCardViewM
export const toExplainExhibitViewModel = (exhibit: MuseumExhibit): ExplainExhibitViewModel => toExplainCardViewModel(exhibit)
export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDetailPageViewModel => {
const hasPlayableAudio = exhibit.audioAvailable === true || Boolean(exhibit.audioUrl)
const hasPlayableAudio = hasPlayableAudioUrl(exhibit)
const metadataLines = [
exhibit.size ? `展品编号:${exhibit.size}` : '',
exhibit.year ? `年代:${exhibit.year}` : '',
@@ -157,7 +163,7 @@ export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDet
title: exhibit.name,
subtitle: buildSubtitle(exhibit),
contentType: contentTypeFor(exhibit),
coverImages: [exhibit.image || '/static/exhibit-placeholder.jpg'].filter(Boolean),
coverImages: [exhibit.image || EXHIBIT_PLACEHOLDER_IMAGE].filter(Boolean),
hallId: exhibit.hallId,
hallName: exhibit.hallName,
floorLabel: exhibit.floorLabel,
@@ -191,7 +197,7 @@ export const toHallDetailViewModel = (hall: MuseumHall): HallDetailViewModel =>
name: hall.name,
floorLabel: hall.floorLabel || '楼层待补充',
description: hall.description || '该展厅暂无介绍文案。',
image: hall.image || '/static/hall-placeholder.jpg',
image: hall.image || HALL_PLACEHOLDER_IMAGE,
exhibitCount: hall.exhibitCount || 0,
area: hall.area,
poiId: hall.location?.poiId || hall.poiId,

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 B

BIN
static/hall-placeholder.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB