提交H5业务闭环修复代码
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import type {
|
||||
ExplainTrack,
|
||||
MediaAsset,
|
||||
MuseumExhibit,
|
||||
MuseumHall
|
||||
MuseumExhibit
|
||||
} from '@/domain/museum'
|
||||
import {
|
||||
dataSourceConfig
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 馆外导航处理函数
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = '') {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
2
src/utils/placeholders.ts
Normal 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'
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
BIN
static/exhibit-placeholder.jpg
Normal file
|
After Width: | Height: | Size: 928 B |
BIN
static/hall-placeholder.jpg
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/icons/halls/animal.jpg
Normal file
|
After Width: | Height: | Size: 928 B |
BIN
static/icons/halls/biology.jpg
Normal file
|
After Width: | Height: | Size: 928 B |
BIN
static/icons/halls/dinosaur.jpg
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/icons/halls/earth.jpg
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/icons/halls/ecology.jpg
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/icons/halls/evolution.jpg
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/icons/halls/homeland.jpg
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/icons/halls/human.jpg
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
static/icons/halls/universe.jpg
Normal file
|
After Width: | Height: | Size: 1.3 KiB |