提交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)}`) 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('nav-assets')
copyStaticSubtree('guide-data') copyStaticSubtree('guide-data')
copyStaticSubtree('icons')
copyStaticSubtree('sgs-map-sdk') copyStaticSubtree('sgs-map-sdk')
copyStaticSubtree('three') copyStaticSubtree('three')
copyStaticFile('exhibit-placeholder.jpg')
copyStaticFile('hall-placeholder.jpg')
if (fs.existsSync(faviconSource)) { if (fs.existsSync(faviconSource)) {
fs.copyFileSync(faviconSource, faviconTarget) fs.copyFileSync(faviconSource, faviconTarget)

View File

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

View File

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

View File

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

View File

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

View File

@@ -95,6 +95,9 @@ import {
isGuideTopTab, isGuideTopTab,
type GuideTopTab type GuideTopTab
} from '@/utils/guideTopTabs' } from '@/utils/guideTopTabs'
import {
HALL_PLACEHOLDER_IMAGE
} from '@/utils/placeholders'
const activeTopTab = ref<GuideTopTab>('explain') const activeTopTab = ref<GuideTopTab>('explain')
const loading = ref(false) const loading = ref(false)
@@ -106,7 +109,7 @@ const hall = ref<HallDetailViewModel>({
name: '展厅讲解', name: '展厅讲解',
floorLabel: '楼层待补充', floorLabel: '楼层待补充',
description: '该展厅暂无介绍文案。', description: '该展厅暂无介绍文案。',
image: '/static/hall-placeholder.jpg', image: HALL_PLACEHOLDER_IMAGE,
exhibitCount: 0, exhibitCount: 0,
area: '待补充' area: '待补充'
}) })
@@ -119,12 +122,6 @@ const explainCountText = computed(() => (
const hallLocationPoiId = computed(() => hall.value.poiId || resolvedHallPoiId.value) 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>>) => { const applyMatchedHallPoi = (matchedPoi: Awaited<ReturnType<typeof guideUseCase.getPoiById>>) => {
if (!matchedPoi) return if (!matchedPoi) return
@@ -146,26 +143,13 @@ const applyMatchedHallPoi = (matchedPoi: Awaited<ReturnType<typeof guideUseCase.
} }
const resolveHallGuidePoi = async () => { const resolveHallGuidePoi = async () => {
const directPoiId = hall.value.location?.poiId || hall.value.poiId return guideUseCase.resolveContentLocationPreviewTarget({
if (directPoiId) { directPoiId: hall.value.poiId,
const directPoi = await guideUseCase.getPoiById(directPoiId) location: hall.value.location,
if (directPoi) return directPoi hallId: hall.value.id,
} hallName: hall.value.name,
targetName: hall.value.name
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
} }
const resolveHallGuidePoiInBackground = () => { const resolveHallGuidePoiInBackground = () => {

View File

@@ -34,7 +34,7 @@
floor-side="left" floor-side="left"
show-floor-header show-floor-header
:show-layer-mode-toggle="false" :show-layer-mode-toggle="false"
:show-floor="is3DMode" :show-floor="is3DMode && !showRoutePlanner"
show-zoom-controls show-zoom-controls
zoom-controls-top="calc(100vh - 252px)" zoom-controls-top="calc(100vh - 252px)"
:route-preview="activeRoutePreview" :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 = () => { const clearRoutePreviewState = () => {
guideMapShellRef.value?.clearRoute?.() guideMapShellRef.value?.clearRoute?.()
activeRoutePreview.value = null activeRoutePreview.value = null
@@ -1036,7 +1022,15 @@ const handleSwitchEntrance = () => {
} }
const handleMoreRouteGuide = () => { 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>
<view <view
v-else-if="routeViewState === 'preview'" v-else-if="routeViewState === 'preview' && !isIndoorGuidePanelHidden"
class="indoor-guide-card" 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-title">{{ indoorGuideTitle }}</text>
<text class="indoor-guide-desc">{{ indoorGuideDesc }}</text> <text class="indoor-guide-desc">{{ indoorGuideDesc }}</text>
<view v-if="routePlanError" class="indoor-guide-error"> <view v-if="routePlanError" class="indoor-guide-error">
@@ -140,6 +151,15 @@
</view> </view>
</view> </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> </GuideMapShell>
</GuidePageFrame> </GuidePageFrame>
</template> </template>
@@ -207,6 +227,14 @@ const routeReadinessMessage = ref('')
const routePlanning = ref(false) const routePlanning = ref(false)
const routePlanError = ref('') const routePlanError = ref('')
const activeRoutePreview = ref<GuideRouteResult | null>(null) 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 manualFloors = computed(() => guideFloors.value.map((floor) => floor.label))
const guideFloorLabel = (floorId: string) => ( const guideFloorLabel = (floorId: string) => (
guideFloors.value.find((floor) => floor.id === floorId)?.label || floorId guideFloors.value.find((floor) => floor.id === floorId)?.label || floorId
@@ -336,6 +364,17 @@ const indoorGuideDesc = computed(() => {
: routeReadinessMessage.value || '当前可查看位置预览,暂不提供正式室内导航。' : 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) => { const safeDecode = (value: string) => {
try { try {
return decodeURIComponent(value) return decodeURIComponent(value)
@@ -456,6 +495,7 @@ const applyRouteOptions = async (options: Record<string, unknown> = {}) => {
targetFocusRequest.value = null targetFocusRequest.value = null
activeRoutePreview.value = null activeRoutePreview.value = null
isManualLocationPanelOpen.value = false isManualLocationPanelOpen.value = false
isIndoorGuidePanelHidden.value = false
return false return false
} }
@@ -469,6 +509,7 @@ const applyRouteOptions = async (options: Record<string, unknown> = {}) => {
targetFocusRequest.value = null targetFocusRequest.value = null
activeRoutePreview.value = null activeRoutePreview.value = null
isManualLocationPanelOpen.value = false isManualLocationPanelOpen.value = false
isIndoorGuidePanelHidden.value = false
return false return false
} }
@@ -481,6 +522,7 @@ const applyRouteOptions = async (options: Record<string, unknown> = {}) => {
hasRouteTarget.value = true hasRouteTarget.value = true
routeViewState.value = isRouteViewState(state) ? state : 'preview' routeViewState.value = isRouteViewState(state) ? state : 'preview'
isManualLocationPanelOpen.value = false isManualLocationPanelOpen.value = false
isIndoorGuidePanelHidden.value = false
if (routeViewState.value === 'preview') { if (routeViewState.value === 'preview') {
requestTargetFocus(route.value.targetLocation, matchedPoi.name) requestTargetFocus(route.value.targetLocation, matchedPoi.name)
@@ -556,12 +598,95 @@ const handleTargetFocus = (result: TargetPoiFocusResult) => {
const handleReturnToPreview = () => { const handleReturnToPreview = () => {
isManualLocationPanelOpen.value = false isManualLocationPanelOpen.value = false
isIndoorGuidePanelHidden.value = false
activeRoutePreview.value = null activeRoutePreview.value = null
routeViewState.value = 'preview' routeViewState.value = 'preview'
requestTargetFocus(route.value.targetLocation) requestTargetFocus(route.value.targetLocation)
saveCurrentLocationPreviewUrl() 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 = () => { const handleRelocate = () => {
isManualLocationPanelOpen.value = false isManualLocationPanelOpen.value = false
routeViewState.value = 'preview' routeViewState.value = 'preview'
@@ -740,7 +865,7 @@ const handlePageBack = () => {
right: 16px; right: 16px;
bottom: calc(env(safe-area-inset-bottom) + 116px); bottom: calc(env(safe-area-inset-bottom) + 116px);
min-height: 156px; min-height: 156px;
padding: 18px 18px 16px; padding: 24px 18px 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
@@ -750,6 +875,56 @@ const handlePageBack = () => {
border-radius: 8px; border-radius: 8px;
box-shadow: 0 10px 24px rgba(36, 49, 42, 0.14); box-shadow: 0 10px 24px rgba(36, 49, 42, 0.14);
z-index: 45; 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 { .indoor-guide-title {

View File

@@ -68,6 +68,35 @@ const toSearchText = (poi: MuseumPoi) => [
.join(' ') .join(' ')
.toLowerCase() .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 => ({ const toLocationPreview = (poi: MuseumPoi): GuideLocationPreview => ({
poiId: poi.id, poiId: poi.id,
name: poi.name, name: poi.name,
@@ -148,7 +177,7 @@ export class StaticGuideRepository implements GuideRepository {
async getPoiById(id: string) { async getPoiById(id: string) {
const pois = await this.listPois() 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 = '') { async searchPois(keyword = '') {
@@ -226,6 +255,7 @@ export class SgsSdkGuideRepository implements GuideRepository {
private floorsCache: MuseumFloor[] | null = null private floorsCache: MuseumFloor[] | null = null
private poiCache: MuseumPoi[] | null = null private poiCache: MuseumPoi[] | null = null
private floorAliases: Map<string, string> | null = null private floorAliases: Map<string, string> | null = null
private readonly staticFallback = new StaticGuideRepository()
constructor(private readonly provider: SgsSdkApiProvider = defaultSgsSdkApiProvider) {} constructor(private readonly provider: SgsSdkApiProvider = defaultSgsSdkApiProvider) {}
@@ -299,7 +329,9 @@ export class SgsSdkGuideRepository implements GuideRepository {
async getPoiById(id: string) { async getPoiById(id: string) {
const pois = await this.listPois() 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 = '') { async searchPois(keyword = '') {

View File

@@ -65,6 +65,7 @@ const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject
uni.request({ uni.request({
url, url,
method: 'GET', method: 'GET',
timeout: 5000,
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) {

View File

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

View File

@@ -1,6 +1,7 @@
import type { import type {
GuideDataIntegrityReport, GuideDataIntegrityReport,
GuideFloorDetail, GuideFloorDetail,
GuideLocationResolution,
GuideRouteReadiness, GuideRouteReadiness,
GuideLocationPreviewPlan, GuideLocationPreviewPlan,
GuideMapDiagnostics, GuideMapDiagnostics,
@@ -42,6 +43,21 @@ const formatStartStepText = (startLocation: GuideStartLocation) => {
return `起点:${startLocation.label}${floorSuffix}${sourceSuffix}` 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 = ( const createPreviewSteps = (
poi: MuseumPoi | null, poi: MuseumPoi | null,
startLocation: GuideStartLocation | 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 { export class GuideUseCase {
private routeReadinessCache: GuideRouteReadiness | null = null private routeReadinessCache: GuideRouteReadiness | null = null
@@ -119,6 +143,71 @@ export class GuideUseCase {
return this.guide.getPoiById(id) 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> { getFloorDetail(floorId: string): Promise<GuideFloorDetail | null> {
return this.guide.getFloorDetail return this.guide.getFloorDetail
? this.guide.getFloorDetail(floorId) ? 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 ( if (
isSameServer isSameServer
&& parsed.port === '9000' && 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}` return `${parsed.pathname}${parsed.search}${parsed.hash}`
} }

View File

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