提交H5业务闭环修复代码
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
ExplainTrack,
|
MuseumExhibit
|
||||||
MediaAsset,
|
|
||||||
MuseumExhibit,
|
|
||||||
MuseumHall
|
|
||||||
} from '@/domain/museum'
|
} from '@/domain/museum'
|
||||||
import {
|
import {
|
||||||
dataSourceConfig
|
dataSourceConfig
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 馆外导航处理函数
|
// 馆外导航处理函数
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 = '') {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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 (
|
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}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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 |