优化导览预览与讲解展示层
This commit is contained in:
339
src/components/explain/ExplainHallSelect.vue
Normal file
339
src/components/explain/ExplainHallSelect.vue
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
<template>
|
||||||
|
<view class="explain-hall-select">
|
||||||
|
<view class="explain-page-header">
|
||||||
|
<view class="header-back" @tap="handleBack">
|
||||||
|
<text class="header-back-icon">‹</text>
|
||||||
|
<text class="header-back-text">返回</text>
|
||||||
|
</view>
|
||||||
|
<text class="header-title">讲解</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view
|
||||||
|
class="explain-scroll"
|
||||||
|
scroll-y
|
||||||
|
:show-scrollbar="false"
|
||||||
|
>
|
||||||
|
<view v-if="loading" class="state-block">
|
||||||
|
<text class="state-title">正在加载展厅讲解</text>
|
||||||
|
<text class="state-desc">稍后将展示可选择的展厅。</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="error" class="state-block error">
|
||||||
|
<text class="state-title">讲解内容加载失败</text>
|
||||||
|
<text class="state-desc">{{ error }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<view class="section-heading">
|
||||||
|
<text class="section-title">请选择展厅</text>
|
||||||
|
<text class="section-count">{{ filteredHalls.length }} 个展厅</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="filteredHalls.length" class="hall-list">
|
||||||
|
<view
|
||||||
|
v-for="hall in filteredHalls"
|
||||||
|
:key="hall.id"
|
||||||
|
class="hall-card"
|
||||||
|
@tap="handleHallClick(hall.id)"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
v-if="hall.image"
|
||||||
|
class="hall-thumb"
|
||||||
|
:src="hall.image"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view v-else class="hall-thumb placeholder">
|
||||||
|
<text class="hall-thumb-text">{{ hallIconText(hall.name) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="hall-main">
|
||||||
|
<text class="hall-name">{{ hall.name }}</text>
|
||||||
|
<view class="hall-meta-row">
|
||||||
|
<view class="floor-badge">
|
||||||
|
<text class="floor-badge-text">{{ hall.floorLabel || '楼层待补充' }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="hall-meta-text">{{ hall.explainCount > 0 ? `${hall.explainCount} 条讲解` : '暂无讲解' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text class="hall-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="state-block empty">
|
||||||
|
<text class="state-title">未找到相关展厅</text>
|
||||||
|
<text class="state-desc">暂无可选择的展厅讲解。</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
export interface ExplainHallSelectItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
floorLabel?: string
|
||||||
|
image?: string
|
||||||
|
explainCount: number
|
||||||
|
searchText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
halls?: ExplainHallSelectItem[]
|
||||||
|
loading?: boolean
|
||||||
|
error?: string
|
||||||
|
}>(), {
|
||||||
|
halls: () => [],
|
||||||
|
loading: false,
|
||||||
|
error: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
hallClick: [hallId: string]
|
||||||
|
back: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const filteredHalls = computed(() => props.halls)
|
||||||
|
|
||||||
|
const hallIconText = (name: string) => name.trim().slice(0, 1) || '讲'
|
||||||
|
|
||||||
|
const handleHallClick = (hallId: string) => {
|
||||||
|
emit('hallClick', hallId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
emit('back')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.explain-hall-select {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-scroll {
|
||||||
|
height: 100%;
|
||||||
|
padding: 76px 20px calc(28px + env(safe-area-inset-bottom));
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title,
|
||||||
|
.section-count,
|
||||||
|
.state-title,
|
||||||
|
.state-desc,
|
||||||
|
.hall-name,
|
||||||
|
.hall-meta-text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-page-header {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #ffffff;
|
||||||
|
border-bottom: 1px solid #eceee8;
|
||||||
|
z-index: 2001;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-back {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 0;
|
||||||
|
height: 56px;
|
||||||
|
min-width: 74px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-back-icon {
|
||||||
|
font-size: 27px;
|
||||||
|
line-height: 27px;
|
||||||
|
color: #151713;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-back-text {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 21px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #151713;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 25px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #151713;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 27px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #151713;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-count {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: #7b8275;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-card {
|
||||||
|
min-height: 112px;
|
||||||
|
padding: 14px 13px 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e4e6df;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 18px rgba(36, 49, 42, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-card:active {
|
||||||
|
background: #f7f8f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-thumb {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 68px;
|
||||||
|
height: 68px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f1f2ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-thumb.placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-thumb-text {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 32px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #83927a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-name {
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #151713;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-meta-row {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floor-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 28px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e0df00;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floor-badge-text {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #aaa900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-meta-text {
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 21px;
|
||||||
|
color: #555c51;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
color: #151713;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-block {
|
||||||
|
margin: 34px 0;
|
||||||
|
padding: 22px 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: center;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e4e6df;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-title {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 21px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #151713;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-desc {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: #68725d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-block.error {
|
||||||
|
border-color: #e5c2c2;
|
||||||
|
background: #fff7f7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -64,6 +64,7 @@ import type {
|
|||||||
|
|
||||||
type ViewMode = 'overview' | 'floor' | 'multi'
|
type ViewMode = 'overview' | 'floor' | 'multi'
|
||||||
type CameraPreset = 'top' | 'oblique'
|
type CameraPreset = 'top' | 'oblique'
|
||||||
|
type TouchGestureMode = 'orbit' | 'pan'
|
||||||
|
|
||||||
type FloorIndexItem = GuideModelFloorAsset
|
type FloorIndexItem = GuideModelFloorAsset
|
||||||
|
|
||||||
@@ -79,12 +80,23 @@ type PoiVisibilityTier = 'tight' | 'balanced' | 'full'
|
|||||||
interface PoiSpriteUserData {
|
interface PoiSpriteUserData {
|
||||||
poi?: RenderPoi
|
poi?: RenderPoi
|
||||||
baseScale?: number
|
baseScale?: number
|
||||||
|
labelBaseScaleX?: number
|
||||||
|
labelBaseScaleY?: number
|
||||||
isPoiLabel?: boolean
|
isPoiLabel?: boolean
|
||||||
isPoiPulse?: boolean
|
isPoiPulse?: boolean
|
||||||
isPoiBase?: boolean
|
isPoiBase?: boolean
|
||||||
isCorePoi?: boolean
|
isCorePoi?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FocusHallMaterialState {
|
||||||
|
material: THREE.Material
|
||||||
|
color?: THREE.Color
|
||||||
|
emissive?: THREE.Color
|
||||||
|
emissiveIntensity?: number
|
||||||
|
opacity: number
|
||||||
|
transparent: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface TargetPoiFocusRequest {
|
interface TargetPoiFocusRequest {
|
||||||
requestId: number | string
|
requestId: number | string
|
||||||
poiId: string
|
poiId: string
|
||||||
@@ -93,6 +105,7 @@ interface TargetPoiFocusRequest {
|
|||||||
floorLabel?: string
|
floorLabel?: string
|
||||||
primaryCategoryZh?: string
|
primaryCategoryZh?: string
|
||||||
positionGltf?: [number, number, number]
|
positionGltf?: [number, number, number]
|
||||||
|
sourceObjectName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TargetPoiFocusResult {
|
interface TargetPoiFocusResult {
|
||||||
@@ -120,6 +133,8 @@ const props = withDefaults(defineProps<{
|
|||||||
showControls?: boolean
|
showControls?: boolean
|
||||||
showPoi?: boolean
|
showPoi?: boolean
|
||||||
targetFocus?: TargetPoiFocusRequest | null
|
targetFocus?: TargetPoiFocusRequest | null
|
||||||
|
touchGestureMode?: TouchGestureMode
|
||||||
|
targetFocusDistanceFactor?: number
|
||||||
routePreview?: GuideRouteResult | null
|
routePreview?: GuideRouteResult | null
|
||||||
showRoute?: boolean
|
showRoute?: boolean
|
||||||
autoSwitch?: boolean
|
autoSwitch?: boolean
|
||||||
@@ -133,6 +148,8 @@ const props = withDefaults(defineProps<{
|
|||||||
showControls: true,
|
showControls: true,
|
||||||
showPoi: false,
|
showPoi: false,
|
||||||
targetFocus: null,
|
targetFocus: null,
|
||||||
|
touchGestureMode: 'orbit',
|
||||||
|
targetFocusDistanceFactor: 0.22,
|
||||||
routePreview: null,
|
routePreview: null,
|
||||||
showRoute: false,
|
showRoute: false,
|
||||||
autoSwitch: true,
|
autoSwitch: true,
|
||||||
@@ -183,6 +200,8 @@ let routeGroup: THREE.Group | null = null
|
|||||||
let activeFocusLabelSprite: THREE.Sprite | null = null
|
let activeFocusLabelSprite: THREE.Sprite | null = null
|
||||||
let activeFocusPulseSprite: THREE.Sprite | null = null
|
let activeFocusPulseSprite: THREE.Sprite | null = null
|
||||||
let activeFocusBaseSprite: THREE.Sprite | null = null
|
let activeFocusBaseSprite: THREE.Sprite | null = null
|
||||||
|
let activeFocusHallGlowMesh: THREE.Mesh | null = null
|
||||||
|
let activeFocusHallMaterialStates: FocusHallMaterialState[] = []
|
||||||
let animationId = 0
|
let animationId = 0
|
||||||
let resizeObserver: ResizeObserver | null = null
|
let resizeObserver: ResizeObserver | null = null
|
||||||
let isDisposed = false
|
let isDisposed = false
|
||||||
@@ -201,6 +220,15 @@ let autoSwitchDisableTimer: ReturnType<typeof setTimeout> | null = null
|
|||||||
let isProgrammaticCameraChange = false
|
let isProgrammaticCameraChange = false
|
||||||
let programmaticCameraTimer: ReturnType<typeof setTimeout> | null = null
|
let programmaticCameraTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const syncControlInteractionOptions = () => {
|
||||||
|
if (!controls) return
|
||||||
|
|
||||||
|
const shouldEnablePan = props.showControls || props.touchGestureMode === 'pan'
|
||||||
|
controls.enablePan = shouldEnablePan
|
||||||
|
controls.touches.ONE = props.touchGestureMode === 'pan' ? THREE.TOUCH.PAN : THREE.TOUCH.ROTATE
|
||||||
|
controls.touches.TWO = THREE.TOUCH.DOLLY_PAN
|
||||||
|
}
|
||||||
|
|
||||||
const corePoiCategories = new Set([
|
const corePoiCategories = new Set([
|
||||||
'basic_service_facility',
|
'basic_service_facility',
|
||||||
'transport_circulation',
|
'transport_circulation',
|
||||||
@@ -276,6 +304,61 @@ const getModelNodeNames = (object: THREE.Object3D) => {
|
|||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeModelMatchKey = (value: string) => (
|
||||||
|
value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s_\-./\\]/g, '')
|
||||||
|
)
|
||||||
|
|
||||||
|
const getPoiModelMatchKeys = (poi: RenderPoi) => (
|
||||||
|
[
|
||||||
|
poi.sourceObjectName,
|
||||||
|
`${poi.floorId}_${poi.name}`,
|
||||||
|
poi.name
|
||||||
|
]
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
.map(normalizeModelMatchKey)
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
|
||||||
|
const isPoiModelNodeMatch = (object: THREE.Object3D, poi: RenderPoi) => {
|
||||||
|
const keys = getPoiModelMatchKeys(poi)
|
||||||
|
if (!keys.length) return false
|
||||||
|
|
||||||
|
return getModelNodeNames(object)
|
||||||
|
.map(normalizeModelMatchKey)
|
||||||
|
.some((name) => keys.some((key) => name === key || name.includes(key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const findPoiModelRoot = (poi: RenderPoi): THREE.Object3D | null => {
|
||||||
|
if (!activeModel) return null
|
||||||
|
|
||||||
|
let matchedRoot: THREE.Object3D | null = null
|
||||||
|
|
||||||
|
activeModel.traverse((child) => {
|
||||||
|
if (matchedRoot || child === activeModel) return
|
||||||
|
|
||||||
|
const childName = child.name ? normalizeModelMatchKey(child.name) : ''
|
||||||
|
const keys = getPoiModelMatchKeys(poi)
|
||||||
|
if (childName && keys.some((key) => childName === key || childName.includes(key))) {
|
||||||
|
matchedRoot = child
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (matchedRoot) return matchedRoot
|
||||||
|
|
||||||
|
activeModel.traverse((child) => {
|
||||||
|
if (matchedRoot || !(child instanceof THREE.Mesh) || !child.visible) return
|
||||||
|
|
||||||
|
if (isPoiModelNodeMatch(child, poi)) {
|
||||||
|
matchedRoot = child.parent && child.parent !== activeModel ? child.parent : child
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return matchedRoot
|
||||||
|
}
|
||||||
|
|
||||||
const isExteriorModelNode = (object: THREE.Object3D) => (
|
const isExteriorModelNode = (object: THREE.Object3D) => (
|
||||||
getModelNodeNames(object).some((name) => (
|
getModelNodeNames(object).some((name) => (
|
||||||
floorExteriorNameKeywords.some((keyword) => name.includes(keyword))
|
floorExteriorNameKeywords.some((keyword) => name.includes(keyword))
|
||||||
@@ -591,12 +674,12 @@ const initThree = async () => {
|
|||||||
controls = new OrbitControls(camera, renderer.domElement)
|
controls = new OrbitControls(camera, renderer.domElement)
|
||||||
controls.enableDamping = true
|
controls.enableDamping = true
|
||||||
controls.dampingFactor = 0.08
|
controls.dampingFactor = 0.08
|
||||||
controls.enablePan = props.showControls
|
|
||||||
controls.enableZoom = true
|
controls.enableZoom = true
|
||||||
controls.minDistance = 6
|
controls.minDistance = 6
|
||||||
controls.maxDistance = 1200
|
controls.maxDistance = 1200
|
||||||
controls.minPolarAngle = 0.08
|
controls.minPolarAngle = 0.08
|
||||||
controls.maxPolarAngle = Math.PI * 0.48
|
controls.maxPolarAngle = Math.PI * 0.48
|
||||||
|
syncControlInteractionOptions()
|
||||||
|
|
||||||
loader = new GLTFLoader()
|
loader = new GLTFLoader()
|
||||||
poiGroup = new THREE.Group()
|
poiGroup = new THREE.Group()
|
||||||
@@ -636,6 +719,7 @@ const startRenderLoop = () => {
|
|||||||
if (isDisposed || !renderer || !scene || !camera) return
|
if (isDisposed || !renderer || !scene || !camera) return
|
||||||
|
|
||||||
controls?.update()
|
controls?.update()
|
||||||
|
updateFocusLabelScale()
|
||||||
renderer.render(scene, camera)
|
renderer.render(scene, camera)
|
||||||
animationId = window.requestAnimationFrame(render)
|
animationId = window.requestAnimationFrame(render)
|
||||||
}
|
}
|
||||||
@@ -675,6 +759,174 @@ const disposeObject = (object: THREE.Object3D) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getMaterialColor = (material: THREE.Material) => {
|
||||||
|
const candidate = material as THREE.Material & { color?: unknown }
|
||||||
|
return candidate.color instanceof THREE.Color ? candidate.color : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMaterialEmissive = (material: THREE.Material) => {
|
||||||
|
const candidate = material as THREE.Material & { emissive?: unknown }
|
||||||
|
return candidate.emissive instanceof THREE.Color ? candidate.emissive : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMaterialEmissiveIntensity = (material: THREE.Material) => {
|
||||||
|
const candidate = material as THREE.Material & { emissiveIntensity?: unknown }
|
||||||
|
return typeof candidate.emissiveIntensity === 'number'
|
||||||
|
? candidate.emissiveIntensity
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const setMaterialEmissiveIntensity = (material: THREE.Material, value: number) => {
|
||||||
|
const candidate = material as THREE.Material & { emissiveIntensity?: number }
|
||||||
|
if (typeof candidate.emissiveIntensity === 'number') {
|
||||||
|
candidate.emissiveIntensity = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearFocusHallHighlight = () => {
|
||||||
|
activeFocusHallMaterialStates.forEach((state) => {
|
||||||
|
const color = getMaterialColor(state.material)
|
||||||
|
const emissive = getMaterialEmissive(state.material)
|
||||||
|
|
||||||
|
if (color && state.color) {
|
||||||
|
color.copy(state.color)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emissive && state.emissive) {
|
||||||
|
emissive.copy(state.emissive)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof state.emissiveIntensity === 'number') {
|
||||||
|
setMaterialEmissiveIntensity(state.material, state.emissiveIntensity)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.material.opacity = state.opacity
|
||||||
|
state.material.transparent = state.transparent
|
||||||
|
state.material.needsUpdate = true
|
||||||
|
})
|
||||||
|
|
||||||
|
activeFocusHallMaterialStates = []
|
||||||
|
|
||||||
|
if (activeFocusHallGlowMesh) {
|
||||||
|
activeFocusHallGlowMesh.parent?.remove(activeFocusHallGlowMesh)
|
||||||
|
const material = activeFocusHallGlowMesh.material
|
||||||
|
if (!Array.isArray(material)) {
|
||||||
|
const glowMaterial = material as THREE.MeshBasicMaterial
|
||||||
|
glowMaterial.map?.dispose()
|
||||||
|
}
|
||||||
|
disposeObject(activeFocusHallGlowMesh)
|
||||||
|
activeFocusHallGlowMesh = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyFocusHallMaterial = (material: THREE.Material, seenMaterials: Set<THREE.Material>) => {
|
||||||
|
if (seenMaterials.has(material)) return
|
||||||
|
seenMaterials.add(material)
|
||||||
|
|
||||||
|
const color = getMaterialColor(material)
|
||||||
|
const emissive = getMaterialEmissive(material)
|
||||||
|
const emissiveIntensity = getMaterialEmissiveIntensity(material)
|
||||||
|
|
||||||
|
activeFocusHallMaterialStates.push({
|
||||||
|
material,
|
||||||
|
color: color?.clone(),
|
||||||
|
emissive: emissive?.clone(),
|
||||||
|
emissiveIntensity,
|
||||||
|
opacity: material.opacity,
|
||||||
|
transparent: material.transparent
|
||||||
|
})
|
||||||
|
|
||||||
|
const glowColor = new THREE.Color('#e0df00')
|
||||||
|
|
||||||
|
if (color) {
|
||||||
|
color.lerp(glowColor, 0.34)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emissive) {
|
||||||
|
emissive.copy(glowColor)
|
||||||
|
setMaterialEmissiveIntensity(material, Math.max(emissiveIntensity || 0, 0.76))
|
||||||
|
}
|
||||||
|
|
||||||
|
material.opacity = Math.max(material.opacity, 0.96)
|
||||||
|
material.transparent = true
|
||||||
|
material.needsUpdate = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const createFocusHallGlowMesh = (poi: RenderPoi) => {
|
||||||
|
if (!poi.positionGltf) return null
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = 256
|
||||||
|
canvas.height = 256
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
|
||||||
|
if (context) {
|
||||||
|
const gradient = context.createRadialGradient(128, 128, 16, 128, 128, 118)
|
||||||
|
gradient.addColorStop(0, 'rgba(224, 223, 0, 0.52)')
|
||||||
|
gradient.addColorStop(0.45, 'rgba(224, 223, 0, 0.34)')
|
||||||
|
gradient.addColorStop(0.78, 'rgba(224, 223, 0, 0.16)')
|
||||||
|
gradient.addColorStop(1, 'rgba(224, 223, 0, 0)')
|
||||||
|
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
context.fillStyle = gradient
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
const texture = new THREE.CanvasTexture(canvas)
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace
|
||||||
|
const material = new THREE.MeshBasicMaterial({
|
||||||
|
map: texture,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.78,
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
side: THREE.DoubleSide
|
||||||
|
})
|
||||||
|
const hallGlowSize = Math.max(getPoiMarkerSize() * 14, getActiveModelSpan() * 0.14)
|
||||||
|
const geometry = new THREE.PlaneGeometry(hallGlowSize, hallGlowSize * 0.72)
|
||||||
|
const mesh = new THREE.Mesh(geometry, material)
|
||||||
|
const [x, y, z] = poi.positionGltf
|
||||||
|
|
||||||
|
mesh.name = 'GuideFocusHallGlow'
|
||||||
|
mesh.position.set(x, y + 0.08, z)
|
||||||
|
mesh.rotation.x = -Math.PI / 2
|
||||||
|
mesh.renderOrder = 7
|
||||||
|
|
||||||
|
return mesh
|
||||||
|
}
|
||||||
|
|
||||||
|
const showFocusHallHighlight = (poi: RenderPoi) => {
|
||||||
|
clearFocusHallHighlight()
|
||||||
|
if (!activeModel) return
|
||||||
|
|
||||||
|
activeFocusHallGlowMesh = createFocusHallGlowMesh(poi)
|
||||||
|
if (activeFocusHallGlowMesh) {
|
||||||
|
poiGroup?.add(activeFocusHallGlowMesh)
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelRoot = findPoiModelRoot(poi)
|
||||||
|
if (!modelRoot) return
|
||||||
|
|
||||||
|
const seenMaterials = new Set<THREE.Material>()
|
||||||
|
let matchedMeshCount = 0
|
||||||
|
|
||||||
|
modelRoot.traverse((child) => {
|
||||||
|
if (!(child instanceof THREE.Mesh) || !child.visible) return
|
||||||
|
|
||||||
|
matchedMeshCount += 1
|
||||||
|
const materials = Array.isArray(child.material) ? child.material : [child.material]
|
||||||
|
materials.forEach((material) => {
|
||||||
|
if (material) {
|
||||||
|
applyFocusHallMaterial(material, seenMaterials)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!matchedMeshCount) {
|
||||||
|
clearFocusHallHighlight()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const disposeCachedOverviewModel = () => {
|
const disposeCachedOverviewModel = () => {
|
||||||
if (!cachedOverviewModel) return
|
if (!cachedOverviewModel) return
|
||||||
|
|
||||||
@@ -840,6 +1092,7 @@ const renderRoutePreview = () => {
|
|||||||
|
|
||||||
const clearSceneData = () => {
|
const clearSceneData = () => {
|
||||||
if (activeModel && scene) {
|
if (activeModel && scene) {
|
||||||
|
clearFocusHallHighlight()
|
||||||
scene.remove(activeModel)
|
scene.remove(activeModel)
|
||||||
if (activeModel === cachedOverviewModel) {
|
if (activeModel === cachedOverviewModel) {
|
||||||
cachedOverviewModel.visible = false
|
cachedOverviewModel.visible = false
|
||||||
@@ -1390,8 +1643,10 @@ const createPoiLabelSprite = (poi: RenderPoi, markerSize: number) => {
|
|||||||
depthWrite: false
|
depthWrite: false
|
||||||
}))
|
}))
|
||||||
sprite.userData.isPoiLabel = true
|
sprite.userData.isPoiLabel = true
|
||||||
|
sprite.userData.labelBaseScaleX = markerSize * 5.6
|
||||||
|
sprite.userData.labelBaseScaleY = markerSize * 1.68
|
||||||
sprite.renderOrder = 30
|
sprite.renderOrder = 30
|
||||||
sprite.scale.set(markerSize * 5.6, markerSize * 1.68, 1)
|
sprite.scale.set(sprite.userData.labelBaseScaleX, sprite.userData.labelBaseScaleY, 1)
|
||||||
|
|
||||||
return sprite
|
return sprite
|
||||||
}
|
}
|
||||||
@@ -1501,6 +1756,7 @@ const showFocusPoiLabel = (poi: RenderPoi) => {
|
|||||||
const [x, y, z] = poi.positionGltf
|
const [x, y, z] = poi.positionGltf
|
||||||
activeFocusLabelSprite = createPoiLabelSprite(poi, markerSize)
|
activeFocusLabelSprite = createPoiLabelSprite(poi, markerSize)
|
||||||
activeFocusLabelSprite.position.set(x, y + markerSize * 2.35, z)
|
activeFocusLabelSprite.position.set(x, y + markerSize * 2.35, z)
|
||||||
|
updateFocusLabelScale()
|
||||||
poiGroup.add(activeFocusLabelSprite)
|
poiGroup.add(activeFocusLabelSprite)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1529,6 +1785,7 @@ const showFocusPoiPulse = (poi: RenderPoi) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showFocusPoiAffordances = (poi: RenderPoi) => {
|
const showFocusPoiAffordances = (poi: RenderPoi) => {
|
||||||
|
showFocusHallHighlight(poi)
|
||||||
showFocusPoiBase(poi)
|
showFocusPoiBase(poi)
|
||||||
showFocusPoiPulse(poi)
|
showFocusPoiPulse(poi)
|
||||||
showFocusPoiLabel(poi)
|
showFocusPoiLabel(poi)
|
||||||
@@ -1552,6 +1809,28 @@ const getPoiMarkerSize = () => (
|
|||||||
: 3
|
: 3
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const getFocusLabelScaleBoost = () => {
|
||||||
|
if (!controls || !activeModel) return 1
|
||||||
|
|
||||||
|
const distanceRatio = controls.getDistance() / getActiveModelSpan()
|
||||||
|
return Math.min(2.25, Math.max(1, distanceRatio / 0.36))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFocusLabelScale = () => {
|
||||||
|
if (!activeFocusLabelSprite) return
|
||||||
|
|
||||||
|
const userData = getPoiSpriteUserData(activeFocusLabelSprite)
|
||||||
|
const baseScaleX = typeof userData.labelBaseScaleX === 'number'
|
||||||
|
? userData.labelBaseScaleX
|
||||||
|
: activeFocusLabelSprite.scale.x
|
||||||
|
const baseScaleY = typeof userData.labelBaseScaleY === 'number'
|
||||||
|
? userData.labelBaseScaleY
|
||||||
|
: activeFocusLabelSprite.scale.y
|
||||||
|
const scaleBoost = getFocusLabelScaleBoost()
|
||||||
|
|
||||||
|
activeFocusLabelSprite.scale.set(baseScaleX * scaleBoost, baseScaleY * scaleBoost, 1)
|
||||||
|
}
|
||||||
|
|
||||||
const setPoiSpriteFocusStyle = (sprite: THREE.Sprite, focused: boolean) => {
|
const setPoiSpriteFocusStyle = (sprite: THREE.Sprite, focused: boolean) => {
|
||||||
const userData = getPoiSpriteUserData(sprite)
|
const userData = getPoiSpriteUserData(sprite)
|
||||||
const baseScale = typeof userData.baseScale === 'number'
|
const baseScale = typeof userData.baseScale === 'number'
|
||||||
@@ -1603,7 +1882,8 @@ const createPoiFromFocusRequest = (request: TargetPoiFocusRequest): RenderPoi |
|
|||||||
primaryCategory: 'target_preview',
|
primaryCategory: 'target_preview',
|
||||||
primaryCategoryZh: request.primaryCategoryZh || '位置预览',
|
primaryCategoryZh: request.primaryCategoryZh || '位置预览',
|
||||||
iconType: 'target_preview',
|
iconType: 'target_preview',
|
||||||
positionGltf: request.positionGltf
|
positionGltf: request.positionGltf,
|
||||||
|
sourceObjectName: request.sourceObjectName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1654,6 +1934,7 @@ const loadFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => {
|
|||||||
const clearMapSelection = (shouldEmit = true) => {
|
const clearMapSelection = (shouldEmit = true) => {
|
||||||
activeFocusPoiId.value = ''
|
activeFocusPoiId.value = ''
|
||||||
selectedPOI.value = null
|
selectedPOI.value = null
|
||||||
|
clearFocusHallHighlight()
|
||||||
disposeFocusLabel()
|
disposeFocusLabel()
|
||||||
disposeFocusPulse()
|
disposeFocusPulse()
|
||||||
disposeFocusBase()
|
disposeFocusBase()
|
||||||
@@ -1779,7 +2060,12 @@ const focusCameraOnPoi = (poi: RenderPoi) => {
|
|||||||
? new THREE.Box3().setFromObject(activeModel).getSize(new THREE.Vector3())
|
? new THREE.Box3().setFromObject(activeModel).getSize(new THREE.Vector3())
|
||||||
: new THREE.Vector3(80, 80, 80)
|
: new THREE.Vector3(80, 80, 80)
|
||||||
const maxDim = Math.max(modelSize.x, modelSize.y, modelSize.z, 1)
|
const maxDim = Math.max(modelSize.x, modelSize.y, modelSize.z, 1)
|
||||||
const distance = Math.min(Math.max(maxDim * 0.22, 28), Math.max(maxDim * 0.55, 80))
|
const focusDistanceFactor = Math.max(props.targetFocusDistanceFactor, 0.1)
|
||||||
|
const focusMaxDistanceFactor = Math.max(0.55, focusDistanceFactor * 1.4)
|
||||||
|
const distance = Math.min(
|
||||||
|
Math.max(maxDim * focusDistanceFactor, 28),
|
||||||
|
Math.max(maxDim * focusMaxDistanceFactor, 80)
|
||||||
|
)
|
||||||
const direction = new THREE.Vector3(0.72, 0.58, 1).normalize()
|
const direction = new THREE.Vector3(0.72, 0.58, 1).normalize()
|
||||||
|
|
||||||
camera.position.copy(target).add(direction.multiplyScalar(distance))
|
camera.position.copy(target).add(direction.multiplyScalar(distance))
|
||||||
@@ -2036,6 +2322,10 @@ watch(() => props.modelSource, () => {
|
|||||||
init3DScene()
|
init3DScene()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(() => [props.showControls, props.touchGestureMode], () => {
|
||||||
|
syncControlInteractionOptions()
|
||||||
|
})
|
||||||
|
|
||||||
watch(() => props.targetFocus, (request) => {
|
watch(() => props.targetFocus, (request) => {
|
||||||
queueTargetFocus(request || null)
|
queueTargetFocus(request || null)
|
||||||
}, {
|
}, {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
:show-controls="false"
|
:show-controls="false"
|
||||||
:show-poi="shouldShowIndoorPois"
|
:show-poi="shouldShowIndoorPois"
|
||||||
:target-focus="targetFocusRequest"
|
:target-focus="targetFocusRequest"
|
||||||
|
:touch-gesture-mode="touchGestureMode"
|
||||||
|
:target-focus-distance-factor="targetFocusDistanceFactor"
|
||||||
:route-preview="routePreview"
|
:route-preview="routePreview"
|
||||||
:show-route="showRoute"
|
:show-route="showRoute"
|
||||||
@floor-change="handleThreeFloorChange"
|
@floor-change="handleThreeFloorChange"
|
||||||
@@ -216,6 +218,7 @@ interface TargetPoiFocusRequest {
|
|||||||
floorLabel?: string
|
floorLabel?: string
|
||||||
primaryCategoryZh?: string
|
primaryCategoryZh?: string
|
||||||
positionGltf?: [number, number, number]
|
positionGltf?: [number, number, number]
|
||||||
|
sourceObjectName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TargetPoiFocusResult {
|
interface TargetPoiFocusResult {
|
||||||
@@ -228,6 +231,7 @@ interface TargetPoiFocusResult {
|
|||||||
|
|
||||||
type IndoorViewMode = 'overview' | 'floor' | 'multi'
|
type IndoorViewMode = 'overview' | 'floor' | 'multi'
|
||||||
type LayerDisplayMode = 'single' | 'multi'
|
type LayerDisplayMode = 'single' | 'multi'
|
||||||
|
type TouchGestureMode = 'orbit' | 'pan'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
searchText?: string
|
searchText?: string
|
||||||
@@ -267,6 +271,8 @@ const props = withDefaults(defineProps<{
|
|||||||
normalizeFloorId?: (labelOrId: string) => string
|
normalizeFloorId?: (labelOrId: string) => string
|
||||||
dimmed?: boolean
|
dimmed?: boolean
|
||||||
targetFocusRequest?: TargetPoiFocusRequest | null
|
targetFocusRequest?: TargetPoiFocusRequest | null
|
||||||
|
touchGestureMode?: TouchGestureMode
|
||||||
|
targetFocusDistanceFactor?: number
|
||||||
routePreview?: GuideRouteResult | null
|
routePreview?: GuideRouteResult | null
|
||||||
showRoute?: boolean
|
showRoute?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
@@ -307,6 +313,8 @@ const props = withDefaults(defineProps<{
|
|||||||
normalizeFloorId: (labelOrId: string) => labelOrId,
|
normalizeFloorId: (labelOrId: string) => labelOrId,
|
||||||
dimmed: false,
|
dimmed: false,
|
||||||
targetFocusRequest: null,
|
targetFocusRequest: null,
|
||||||
|
touchGestureMode: 'orbit',
|
||||||
|
targetFocusDistanceFactor: 0.22,
|
||||||
routePreview: null,
|
routePreview: null,
|
||||||
showRoute: false
|
showRoute: false
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="guide-page-frame" :class="`variant-${variant}`">
|
<view
|
||||||
|
class="guide-page-frame"
|
||||||
|
:class="[`variant-${variant}`, { 'no-top-tabs': !showTopTabs }]"
|
||||||
|
>
|
||||||
<GuideTopTabs
|
<GuideTopTabs
|
||||||
|
v-if="showTopTabs"
|
||||||
:active-tab="activeTab"
|
:active-tab="activeTab"
|
||||||
:variant="topTabsVariant"
|
:variant="topTabsVariant"
|
||||||
:top="topTabsTop"
|
:top="topTabsTop"
|
||||||
@@ -40,6 +44,7 @@ withDefaults(defineProps<{
|
|||||||
variant?: 'overlay' | 'static'
|
variant?: 'overlay' | 'static'
|
||||||
topTabsVariant?: 'underline' | 'segmented'
|
topTabsVariant?: 'underline' | 'segmented'
|
||||||
topTabsTop?: string
|
topTabsTop?: string
|
||||||
|
showTopTabs?: boolean
|
||||||
showBack?: boolean
|
showBack?: boolean
|
||||||
showCancel?: boolean
|
showCancel?: boolean
|
||||||
backLabel?: string
|
backLabel?: string
|
||||||
@@ -49,6 +54,7 @@ withDefaults(defineProps<{
|
|||||||
variant: 'overlay',
|
variant: 'overlay',
|
||||||
topTabsVariant: 'underline',
|
topTabsVariant: 'underline',
|
||||||
topTabsTop: '0',
|
topTabsTop: '0',
|
||||||
|
showTopTabs: true,
|
||||||
showBack: false,
|
showBack: false,
|
||||||
showCancel: false,
|
showCancel: false,
|
||||||
backLabel: '返回',
|
backLabel: '返回',
|
||||||
@@ -102,6 +108,10 @@ const handleCancelTap = () => {
|
|||||||
background: var(--museum-bg-light);
|
background: var(--museum-bg-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.variant-static.no-top-tabs .guide-page-frame-body {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.guide-page-frame-actions {
|
.guide-page-frame-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => ({
|
|||||||
},
|
},
|
||||||
categories: (poi.categories || []).map(toCategory),
|
categories: (poi.categories || []).map(toCategory),
|
||||||
positionGltf: poi.positionGltf,
|
positionGltf: poi.positionGltf,
|
||||||
|
sourceObjectName: poi.sourceObjectName,
|
||||||
sourceConfidence: poi.sourceConfidence,
|
sourceConfidence: poi.sourceConfidence,
|
||||||
navigationReadiness: poi.navigationReadiness,
|
navigationReadiness: poi.navigationReadiness,
|
||||||
accessible: isPoiAccessible(poi)
|
accessible: isPoiAccessible(poi)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface StaticNavPoiPayload {
|
|||||||
categories?: StaticNavPoiCategoryPayload[]
|
categories?: StaticNavPoiCategoryPayload[]
|
||||||
iconType?: string
|
iconType?: string
|
||||||
positionGltf?: [number, number, number]
|
positionGltf?: [number, number, number]
|
||||||
|
sourceObjectName?: string
|
||||||
navigationReadiness?: string
|
navigationReadiness?: string
|
||||||
sourceConfidence?: string
|
sourceConfidence?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface GuideRenderPoi {
|
|||||||
primaryCategoryZh: string
|
primaryCategoryZh: string
|
||||||
iconType: string
|
iconType: string
|
||||||
positionGltf?: [number, number, number]
|
positionGltf?: [number, number, number]
|
||||||
|
sourceObjectName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GuideModelFloorAsset {
|
export interface GuideModelFloorAsset {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface MuseumPoi {
|
|||||||
primaryCategory: MuseumCategory
|
primaryCategory: MuseumCategory
|
||||||
categories: MuseumCategory[]
|
categories: MuseumCategory[]
|
||||||
positionGltf?: [number, number, number]
|
positionGltf?: [number, number, number]
|
||||||
|
sourceObjectName?: string
|
||||||
sourceConfidence?: string
|
sourceConfidence?: string
|
||||||
navigationReadiness?: string
|
navigationReadiness?: string
|
||||||
accessible: boolean
|
accessible: boolean
|
||||||
@@ -85,6 +86,7 @@ export interface GuideLocationPreview {
|
|||||||
floorLabel: string
|
floorLabel: string
|
||||||
primaryCategoryZh?: string
|
primaryCategoryZh?: string
|
||||||
positionGltf?: [number, number, number]
|
positionGltf?: [number, number, number]
|
||||||
|
sourceObjectName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GuideStartLocation {
|
export interface GuideStartLocation {
|
||||||
|
|||||||
@@ -2,147 +2,73 @@
|
|||||||
<GuidePageFrame
|
<GuidePageFrame
|
||||||
:active-tab="activeTopTab"
|
:active-tab="activeTopTab"
|
||||||
variant="static"
|
variant="static"
|
||||||
|
:show-top-tabs="false"
|
||||||
show-back
|
show-back
|
||||||
@tab-change="handleTopTabChange"
|
|
||||||
@back="handleBack"
|
@back="handleBack"
|
||||||
>
|
>
|
||||||
<view class="detail-page" :class="{ 'with-audio-player': showAudioPlayer }">
|
<view class="explain-detail-page" :class="{ 'with-audio-player': showAudioPlayer }">
|
||||||
<scroll-view class="content" scroll-y>
|
<scroll-view class="content" scroll-y :show-scrollbar="false">
|
||||||
<view class="detail-hero">
|
<image
|
||||||
<image
|
v-if="heroImage"
|
||||||
v-if="heroImage"
|
class="hero-image"
|
||||||
class="hero-image"
|
:src="heroImage"
|
||||||
:src="heroImage"
|
mode="aspectFill"
|
||||||
mode="aspectFill"
|
/>
|
||||||
/>
|
|
||||||
<view v-else class="hero-placeholder">
|
<view v-else class="hero-placeholder">
|
||||||
<text class="hero-placeholder-text">讲解</text>
|
<text class="hero-placeholder-text">讲解</text>
|
||||||
</view>
|
|
||||||
<view
|
|
||||||
v-if="exhibit.audio.status === 'playable'"
|
|
||||||
class="audio-control"
|
|
||||||
@tap="handlePlayAudio"
|
|
||||||
>
|
|
||||||
<svg v-if="!isPlaying" width="28" height="28" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
<svg v-else width="28" height="28" viewBox="0 0 24 24" fill="none">
|
|
||||||
<rect x="6" y="5" width="4" height="14" rx="1" fill="currentColor"/>
|
|
||||||
<rect x="14" y="5" width="4" height="14" rx="1" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
</view>
|
|
||||||
<view v-else class="audio-status">
|
|
||||||
<text class="audio-status-text">{{ audioStatusText }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="detail-info">
|
<view class="detail-body">
|
||||||
<text class="content-type">{{ contentTypeText }}</text>
|
|
||||||
<text class="detail-title">{{ exhibit.title }}</text>
|
<text class="detail-title">{{ exhibit.title }}</text>
|
||||||
<text v-if="exhibit.subtitle" class="detail-subtitle">{{ exhibit.subtitle }}</text>
|
<text v-if="detailMeta" class="detail-meta">{{ detailMeta }}</text>
|
||||||
|
|
||||||
<view class="meta-grid">
|
<view
|
||||||
<view class="meta-item">
|
v-if="exhibit.audio.status === 'playable'"
|
||||||
<text class="meta-label">内容类型</text>
|
class="audio-panel"
|
||||||
<text class="meta-value">{{ contentTypeText }}</text>
|
@tap="handlePlayAudio"
|
||||||
|
>
|
||||||
|
<view class="audio-play">
|
||||||
|
<svg v-if="!isPlaying" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<rect x="6" y="5" width="4" height="14" rx="1" fill="currentColor"/>
|
||||||
|
<rect x="14" y="5" width="4" height="14" rx="1" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="exhibit.hallName" class="meta-item">
|
<text class="audio-time">00:00</text>
|
||||||
<text class="meta-label">所属展厅</text>
|
<view class="audio-track">
|
||||||
<text class="meta-value">{{ exhibit.hallName }}</text>
|
<view class="audio-dot"></view>
|
||||||
</view>
|
|
||||||
<view v-if="exhibit.floorLabel" class="meta-item">
|
|
||||||
<text class="meta-label">所在楼层</text>
|
|
||||||
<text class="meta-value">{{ exhibit.floorLabel }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="meta-item">
|
|
||||||
<text class="meta-label">讲解状态</text>
|
|
||||||
<text class="meta-value">{{ audioStatusText }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
|
<text class="audio-time">{{ exhibit.audio.durationLabel || '音频讲解' }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="exhibit.audio.status !== 'playable'" class="audio-note">
|
<view v-else class="text-status-panel">
|
||||||
<text class="audio-note-title">{{ audioNoteTitle }}</text>
|
<text class="text-status-title">图文讲解</text>
|
||||||
<text class="audio-note-desc">
|
<text class="text-status-desc">
|
||||||
{{ exhibit.audio.unavailableReason || '当前仅提供图文讲解,正式音频接入后将显示播放入口。' }}
|
{{ exhibit.audio.unavailableReason || '该讲解暂无已发布音频,当前提供图文讲解。' }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="content-section">
|
<view class="content-section">
|
||||||
<text class="section-title">讲解摘要</text>
|
<text class="section-title">讲解内容</text>
|
||||||
<text class="section-text">{{ exhibit.summary }}</text>
|
<text class="section-text">{{ exhibit.body || exhibit.summary }}</text>
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="content-section">
|
|
||||||
<text class="section-title">讲解介绍</text>
|
|
||||||
<text class="section-text">{{ exhibit.body }}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="content-section">
|
|
||||||
<view class="section-header">
|
|
||||||
<text class="section-title">章节</text>
|
|
||||||
<text class="section-state">{{ exhibit.chapters.length ? `${exhibit.chapters.length} 段` : '待开放' }}</text>
|
|
||||||
</view>
|
|
||||||
<view v-if="exhibit.chapters.length" class="chapter-list">
|
|
||||||
<view
|
|
||||||
v-for="chapter in exhibit.chapters"
|
|
||||||
:key="chapter.id"
|
|
||||||
class="chapter-item"
|
|
||||||
>
|
|
||||||
<text class="chapter-title">{{ chapter.title }}</text>
|
|
||||||
<text v-if="typeof chapter.startTime === 'number'" class="chapter-time">
|
|
||||||
{{ formatChapterTime(chapter.startTime) }}
|
|
||||||
</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<text v-else class="section-text muted">正式章节数据接入后将在这里展示。</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="content-section">
|
|
||||||
<view class="section-header">
|
|
||||||
<text class="section-title">讲解文稿</text>
|
|
||||||
<text class="section-state">{{ exhibit.transcript ? '已接入' : '待开放' }}</text>
|
|
||||||
</view>
|
|
||||||
<text class="section-text muted">
|
|
||||||
{{ exhibit.transcript || '正式文稿数据接入后将在这里展示。' }}
|
|
||||||
</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-if="exhibit.location" class="location-note">
|
|
||||||
<text class="location-title">位置说明</text>
|
|
||||||
<text class="location-desc">{{ locationDescription }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
<view class="action-bar">
|
<view class="action-bar">
|
||||||
<view
|
<view
|
||||||
class="action-btn primary"
|
class="location-btn"
|
||||||
:class="{ disabled: !exhibit.location }"
|
:class="{ disabled: !exhibit.location }"
|
||||||
@tap="handleNavigate"
|
@tap="handleNavigate"
|
||||||
>
|
>
|
||||||
<svg class="btn-icon" width="18" height="18" viewBox="0 0 24 24" fill="none">
|
<svg class="location-icon" width="19" height="19" viewBox="0 0 24 24" fill="none">
|
||||||
<path d="M12 21C12 21 18 14.9 18 9.8C18 6.5 15.3 4 12 4C8.7 4 6 6.5 6 9.8C6 14.9 12 21 12 21Z" stroke="currentColor" stroke-width="1.7"/>
|
<path d="M12 21C12 21 18 14.9 18 9.8C18 6.5 15.3 4 12 4C8.7 4 6 6.5 6 9.8C6 14.9 12 21 12 21Z" stroke="currentColor" stroke-width="1.7"/>
|
||||||
<circle cx="12" cy="10" r="2.2" stroke="currentColor" stroke-width="1.7"/>
|
<circle cx="12" cy="10" r="2.2" stroke="currentColor" stroke-width="1.7"/>
|
||||||
</svg>
|
</svg>
|
||||||
<text class="btn-text">{{ exhibit.location?.actionText || '暂无位置' }}</text>
|
<text class="location-text">查看位置</text>
|
||||||
</view>
|
|
||||||
<view class="action-btn" @tap="handleCollect">
|
|
||||||
<svg class="btn-icon" width="18" height="18" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path
|
|
||||||
:fill="isCollected ? 'currentColor' : 'none'"
|
|
||||||
d="M12 20.2L10.8 19.1C6.4 15.1 3.5 12.5 3.5 9.2C3.5 6.6 5.6 4.5 8.2 4.5C9.7 4.5 11.1 5.2 12 6.3C12.9 5.2 14.3 4.5 15.8 4.5C18.4 4.5 20.5 6.6 20.5 9.2C20.5 12.5 17.6 15.1 13.2 19.1L12 20.2Z"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<text class="btn-text">收藏</text>
|
|
||||||
</view>
|
|
||||||
<view class="action-btn" @tap="handleShare">
|
|
||||||
<svg class="btn-icon" width="18" height="18" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M8 12H6.8C5.8 12 5 12.8 5 13.8V18.2C5 19.2 5.8 20 6.8 20H17.2C18.2 20 19 19.2 19 18.2V13.8C19 12.8 18.2 12 17.2 12H16" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
|
|
||||||
<path d="M12 15V4M12 4L8.5 7.5M12 4L15.5 7.5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
<text class="btn-text">分享</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -174,12 +100,10 @@ import {
|
|||||||
} from '@/usecases/guideUseCase'
|
} from '@/usecases/guideUseCase'
|
||||||
import {
|
import {
|
||||||
toExplainDetailPageViewModel,
|
toExplainDetailPageViewModel,
|
||||||
type ExplainContentType,
|
|
||||||
type ExplainDetailPageViewModel
|
type ExplainDetailPageViewModel
|
||||||
} from '@/view-models/explainViewModels'
|
} from '@/view-models/explainViewModels'
|
||||||
import {
|
import {
|
||||||
isGuideTopTab,
|
isGuideTopTab,
|
||||||
navigateToGuideTopTab,
|
|
||||||
type GuideTopTab
|
type GuideTopTab
|
||||||
} from '@/utils/guideTopTabs'
|
} from '@/utils/guideTopTabs'
|
||||||
|
|
||||||
@@ -188,12 +112,12 @@ const defaultDetail: ExplainDetailPageViewModel = {
|
|||||||
title: '讲解内容',
|
title: '讲解内容',
|
||||||
subtitle: '位置待补充',
|
subtitle: '位置待补充',
|
||||||
contentType: 'exhibit',
|
contentType: 'exhibit',
|
||||||
coverImages: ['/static/exhibit-placeholder.jpg'],
|
coverImages: [],
|
||||||
summary: '该讲解内容待补充。',
|
summary: '该讲解内容待补充。',
|
||||||
body: '该讲解内容待补充。',
|
body: '该讲解内容待补充。',
|
||||||
audio: {
|
audio: {
|
||||||
status: 'unavailable',
|
status: 'unavailable',
|
||||||
unavailableReason: '该展项暂无已发布音频,当前提供图文讲解。'
|
unavailableReason: '该讲解暂无已发布音频,当前提供图文讲解。'
|
||||||
},
|
},
|
||||||
chapters: [],
|
chapters: [],
|
||||||
relatedItems: []
|
relatedItems: []
|
||||||
@@ -209,59 +133,28 @@ const audioPlayerRef = ref<AudioPlayerExpose | null>(null)
|
|||||||
const showAudioPlayer = ref(false)
|
const showAudioPlayer = ref(false)
|
||||||
const currentAudio = ref<AudioItem | null>(null)
|
const currentAudio = ref<AudioItem | null>(null)
|
||||||
const isPlaying = ref(false)
|
const isPlaying = ref(false)
|
||||||
const isCollected = ref(false)
|
const activeTopTab = ref<GuideTopTab>('explain')
|
||||||
const activeTopTab = ref<GuideTopTab>('guide')
|
|
||||||
|
|
||||||
const heroImage = computed(() => exhibit.value.coverImages[0])
|
const heroImage = computed(() => exhibit.value.coverImages[0])
|
||||||
const audioStatusText = computed(() => {
|
const detailMeta = computed(() => [
|
||||||
if (exhibit.value.audio.status === 'playable') {
|
exhibit.value.hallName,
|
||||||
return exhibit.value.audio.durationLabel || '可播放'
|
exhibit.value.floorLabel
|
||||||
}
|
].filter(Boolean).join(' · '))
|
||||||
if (exhibit.value.audio.status === 'none') {
|
|
||||||
return '图文讲解'
|
|
||||||
}
|
|
||||||
return '图文讲解'
|
|
||||||
})
|
|
||||||
|
|
||||||
const audioNoteTitle = computed(() => (
|
|
||||||
exhibit.value.audio.status === 'playable'
|
|
||||||
? '音频讲解'
|
|
||||||
: '图文讲解'
|
|
||||||
))
|
|
||||||
|
|
||||||
const contentTypeText = computed(() => {
|
|
||||||
const map: Record<ExplainContentType, string> = {
|
|
||||||
hall: '空间讲解',
|
|
||||||
zone: '展区讲解',
|
|
||||||
exhibit: '展品讲解',
|
|
||||||
theme: '主题讲解'
|
|
||||||
}
|
|
||||||
return map[exhibit.value.contentType]
|
|
||||||
})
|
|
||||||
|
|
||||||
const locationDescription = computed(() => (
|
|
||||||
exhibit.value.location?.note || '当前支持三维位置预览。'
|
|
||||||
))
|
|
||||||
|
|
||||||
onLoad(async (options: any) => {
|
|
||||||
if (options.id) {
|
|
||||||
const exhibitData = await explainUseCase.getExhibitById(options.id)
|
|
||||||
if (exhibitData) {
|
|
||||||
exhibit.value = toExplainDetailPageViewModel(exhibitData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
onLoad(async (options: any = {}) => {
|
||||||
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
|
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
|
||||||
if (isGuideTopTab(tab)) {
|
if (isGuideTopTab(tab)) {
|
||||||
activeTopTab.value = tab
|
activeTopTab.value = tab
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const formatChapterTime = (seconds: number) => {
|
const exhibitId = Array.isArray(options.id) ? options.id[0] : options.id
|
||||||
const minute = Math.floor(seconds / 60)
|
if (!exhibitId) return
|
||||||
const second = Math.floor(seconds % 60)
|
|
||||||
return `${minute}:${String(second).padStart(2, '0')}`
|
const exhibitData = await explainUseCase.getExhibitById(exhibitId)
|
||||||
}
|
if (exhibitData) {
|
||||||
|
exhibit.value = toExplainDetailPageViewModel(exhibitData)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const handlePlayAudio = async () => {
|
const handlePlayAudio = async () => {
|
||||||
if (exhibit.value.audio.url) {
|
if (exhibit.value.audio.url) {
|
||||||
@@ -376,22 +269,6 @@ const handleNavigate = async () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCollect = () => {
|
|
||||||
isCollected.value = !isCollected.value
|
|
||||||
uni.showToast({
|
|
||||||
title: isCollected.value ? '已加入本地收藏' : '已取消本地收藏',
|
|
||||||
icon: 'none'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleShare = () => {
|
|
||||||
uni.showShareMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTopTabChange = (tab: GuideTopTab) => {
|
|
||||||
navigateToGuideTopTab(tab)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
uni.navigateBack({
|
uni.navigateBack({
|
||||||
delta: 1,
|
delta: 1,
|
||||||
@@ -405,285 +282,208 @@ const handleBack = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.detail-page {
|
.explain-detail-page {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: var(--museum-bg-light);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: #f9fafb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
height: 100%;
|
||||||
overflow-y: auto;
|
padding: 84px 20px calc(104px + env(safe-area-inset-bottom));
|
||||||
}
|
box-sizing: border-box;
|
||||||
|
|
||||||
.detail-hero {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 250px;
|
|
||||||
background: #edf0ea;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-image,
|
.hero-image,
|
||||||
.hero-placeholder {
|
.hero-placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 250px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-image {
|
||||||
|
background: #e9ece5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-placeholder {
|
.hero-placeholder {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #e6eadf;
|
background: #e9ece5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-placeholder-text {
|
.hero-placeholder-text {
|
||||||
font-size: 18px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
line-height: 28px;
|
||||||
color: #67705f;
|
font-weight: 800;
|
||||||
|
color: #68725d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-control {
|
.detail-body {
|
||||||
position: absolute;
|
padding-top: 22px;
|
||||||
right: 20px;
|
|
||||||
bottom: 18px;
|
|
||||||
width: 58px;
|
|
||||||
height: 58px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background-color: var(--museum-accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
|
|
||||||
color: #151713;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-status {
|
|
||||||
position: absolute;
|
|
||||||
right: 16px;
|
|
||||||
bottom: 16px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: rgba(21, 23, 19, 0.86);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-status-text {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-info {
|
|
||||||
padding: 22px 16px 116px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-type,
|
|
||||||
.detail-title,
|
.detail-title,
|
||||||
.detail-subtitle,
|
.detail-meta,
|
||||||
|
.audio-time,
|
||||||
|
.text-status-title,
|
||||||
|
.text-status-desc,
|
||||||
.section-title,
|
.section-title,
|
||||||
.section-text,
|
.section-text,
|
||||||
.audio-note-title,
|
.location-text {
|
||||||
.audio-note-desc,
|
|
||||||
.location-title,
|
|
||||||
.location-desc {
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-type {
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 16px;
|
|
||||||
color: #68725d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-title {
|
.detail-title {
|
||||||
margin-top: 4px;
|
font-size: 29px;
|
||||||
font-size: 24px;
|
line-height: 38px;
|
||||||
line-height: 32px;
|
font-weight: 800;
|
||||||
font-weight: 700;
|
|
||||||
color: #151713;
|
color: #151713;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-subtitle {
|
.detail-meta {
|
||||||
margin-top: 6px;
|
margin-top: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: #555c51;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-panel {
|
||||||
|
height: 76px;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 0 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #0b0c0a;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-play {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #151713;
|
||||||
|
background: #e0df00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-time {
|
||||||
|
flex-shrink: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
color: #68725d;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-grid {
|
.audio-track {
|
||||||
margin-top: 18px;
|
position: relative;
|
||||||
display: grid;
|
flex: 1;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
height: 3px;
|
||||||
gap: 10px;
|
background: rgba(255, 255, 255, 0.24);
|
||||||
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-item {
|
.audio-dot {
|
||||||
min-height: 62px;
|
position: absolute;
|
||||||
padding: 10px 12px;
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: #e0df00;
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-status-panel {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 14px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: #f7f8f3;
|
background: #ffffff;
|
||||||
border: 1px solid #ebece5;
|
border: 1px solid #e4e6df;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-label,
|
.text-status-title {
|
||||||
.meta-value {
|
font-size: 15px;
|
||||||
display: block;
|
line-height: 21px;
|
||||||
}
|
font-weight: 800;
|
||||||
|
|
||||||
.meta-label {
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 16px;
|
|
||||||
color: #818a7a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-value {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 20px;
|
|
||||||
color: #151713;
|
color: #151713;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-note,
|
.text-status-desc {
|
||||||
.location-note {
|
margin-top: 5px;
|
||||||
margin-top: 14px;
|
font-size: 13px;
|
||||||
padding: 12px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: #f1f4ea;
|
|
||||||
border: 1px solid #e2e7d7;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-note-title,
|
|
||||||
.location-title {
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
font-weight: 700;
|
|
||||||
color: #151713;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-note-desc,
|
|
||||||
.location-desc {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 18px;
|
|
||||||
color: #5d6656;
|
color: #5d6656;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-section {
|
.content-section {
|
||||||
margin-top: 22px;
|
margin-top: 28px;
|
||||||
}
|
|
||||||
|
|
||||||
.section-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 17px;
|
font-size: 19px;
|
||||||
line-height: 24px;
|
line-height: 27px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
color: #151713;
|
color: #151713;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-state {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #818a7a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-text {
|
.section-text {
|
||||||
margin-top: 10px;
|
margin-top: 13px;
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
line-height: 24px;
|
line-height: 28px;
|
||||||
color: #333a2f;
|
color: #2f352d;
|
||||||
}
|
white-space: pre-line;
|
||||||
|
|
||||||
.section-text.muted {
|
|
||||||
color: #818a7a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-list {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-item {
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 0 12px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: #f7f8f3;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-title {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #151713;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-time {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #818a7a;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-bar {
|
.action-bar {
|
||||||
flex-shrink: 0;
|
position: absolute;
|
||||||
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
|
left: 0;
|
||||||
display: grid;
|
right: 0;
|
||||||
grid-template-columns: 1.4fr 1fr 1fr;
|
bottom: 0;
|
||||||
gap: 8px;
|
padding: 12px 20px calc(18px + env(safe-area-inset-bottom));
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: rgba(255, 255, 255, 0.96);
|
background: rgba(249, 250, 251, 0.96);
|
||||||
border-top: 1px solid #e7e9e0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-page.with-audio-player .action-bar {
|
.explain-detail-page.with-audio-player .action-bar {
|
||||||
margin-bottom: 88px;
|
bottom: 88px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.location-btn {
|
||||||
height: 46px;
|
height: 52px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: #f7f8f3;
|
background: #ffffff;
|
||||||
border: 1px solid #e5e8df;
|
border: 1px solid #151713;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #151713;
|
color: #151713;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn.primary {
|
.location-btn.disabled {
|
||||||
background: #151713;
|
color: #8d9488;
|
||||||
border-color: #151713;
|
border-color: #d8dcd3;
|
||||||
color: var(--museum-accent);
|
background: #f2f4ef;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn.disabled {
|
.location-icon {
|
||||||
background: #f1f3ed;
|
|
||||||
border-color: #e5e8df;
|
|
||||||
color: #8b9385;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-text {
|
.location-text {
|
||||||
font-size: 13px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
line-height: 22px;
|
||||||
|
font-weight: 700;
|
||||||
color: currentColor;
|
color: currentColor;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,70 +2,83 @@
|
|||||||
<GuidePageFrame
|
<GuidePageFrame
|
||||||
:active-tab="activeTopTab"
|
:active-tab="activeTopTab"
|
||||||
variant="static"
|
variant="static"
|
||||||
|
:show-top-tabs="false"
|
||||||
show-back
|
show-back
|
||||||
@tab-change="handleTopTabChange"
|
|
||||||
@back="handleBack"
|
@back="handleBack"
|
||||||
>
|
>
|
||||||
<view class="detail-page">
|
<view class="hall-explain-page">
|
||||||
<scroll-view class="content" scroll-y>
|
<scroll-view class="content" scroll-y :show-scrollbar="false">
|
||||||
<!-- 展厅封面 -->
|
<view class="page-head">
|
||||||
<view class="hall-hero">
|
<text class="hall-title">{{ hall.name }}</text>
|
||||||
<image class="hero-image" :src="hall.image" mode="aspectFill" />
|
<view class="hall-meta-row">
|
||||||
<view class="hall-badge">
|
<text class="hall-meta">{{ hall.floorLabel }} · {{ explainCountText }}</text>
|
||||||
<text class="badge-text">{{ hall.floorLabel }}</text>
|
<view
|
||||||
|
class="location-action"
|
||||||
|
:class="{ disabled: !hall.poiId }"
|
||||||
|
@tap="handleNavigate"
|
||||||
|
>
|
||||||
|
<svg class="location-icon" width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12 21C12 21 18 14.9 18 9.8C18 6.5 15.3 4 12 4C8.7 4 6 6.5 6 9.8C6 14.9 12 21 12 21Z" stroke="currentColor" stroke-width="1.7"/>
|
||||||
|
<circle cx="12" cy="10" r="2.2" stroke="currentColor" stroke-width="1.7"/>
|
||||||
|
</svg>
|
||||||
|
<text class="location-text">查看展厅位置</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 展厅信息 -->
|
<view v-if="loading" class="state-block">
|
||||||
<view class="hall-info">
|
<text class="state-title">正在加载讲解列表</text>
|
||||||
<text class="hall-title">{{ hall.name }}</text>
|
<text class="state-desc">稍后将展示该展厅下的讲解内容。</text>
|
||||||
<text class="hall-description">{{ hall.description }}</text>
|
</view>
|
||||||
|
|
||||||
<view class="stats-row">
|
<view v-else-if="error" class="state-block error">
|
||||||
<view class="stat-item">
|
<text class="state-title">讲解列表加载失败</text>
|
||||||
<text class="stat-value">{{ hall.exhibitCount }}</text>
|
<text class="state-desc">{{ error }}</text>
|
||||||
<text class="stat-label">讲解内容</text>
|
</view>
|
||||||
</view>
|
|
||||||
<view class="stat-item">
|
|
||||||
<text class="stat-value">{{ hall.floorLabel }}</text>
|
|
||||||
<text class="stat-label">所在楼层</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 讲解列表 -->
|
<view v-else-if="exhibits.length" class="explain-list">
|
||||||
<view class="exhibits-section">
|
<view
|
||||||
<text class="section-title">空间讲解内容</text>
|
v-for="exhibit in exhibits"
|
||||||
<view class="exhibits-grid">
|
:key="exhibit.id"
|
||||||
<ExhibitCard
|
class="explain-item"
|
||||||
v-for="exhibit in exhibits"
|
@tap="handleExhibitClick(exhibit)"
|
||||||
:key="exhibit.id"
|
>
|
||||||
:exhibit="exhibit"
|
<view
|
||||||
@click="handleExhibitClick"
|
class="explain-icon"
|
||||||
/>
|
:class="{ playable: exhibit.audioStatus === 'playable' }"
|
||||||
|
>
|
||||||
|
<svg v-if="exhibit.audioStatus === 'playable'" width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M7 3.5H14.5L18 7V20.5H7V3.5Z" fill="#747970"/>
|
||||||
|
<path d="M14.5 3.5V7H18" fill="#b8bcb4"/>
|
||||||
|
<path d="M9.5 11H15.5M9.5 14H15.5M9.5 17H13.5" stroke="white" stroke-width="1.2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="explain-main">
|
||||||
|
<text class="explain-title">{{ exhibit.title }}</text>
|
||||||
|
<text class="explain-meta">{{ itemMeta(exhibit) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text class="item-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="state-block empty">
|
||||||
|
<text class="state-title">暂无讲解内容</text>
|
||||||
|
<text class="state-desc">该展厅暂未接入可展示的讲解条目。</text>
|
||||||
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
<!-- 底部操作栏 -->
|
|
||||||
<view class="action-bar">
|
|
||||||
<view
|
|
||||||
class="action-btn primary"
|
|
||||||
:class="{ disabled: !hall.location?.poiId }"
|
|
||||||
@tap="handleNavigate"
|
|
||||||
>
|
|
||||||
<text class="btn-text">{{ hall.location?.actionText || '暂无位置' }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</GuidePageFrame>
|
</GuidePageFrame>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { onLoad } from '@dcloudio/uni-app'
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
||||||
import ExhibitCard from '@/components/content/ExhibitCard.vue'
|
|
||||||
import {
|
import {
|
||||||
explainUseCase
|
explainUseCase
|
||||||
} from '@/usecases/explainUseCase'
|
} from '@/usecases/explainUseCase'
|
||||||
@@ -80,15 +93,16 @@ import {
|
|||||||
} from '@/view-models/explainViewModels'
|
} from '@/view-models/explainViewModels'
|
||||||
import {
|
import {
|
||||||
isGuideTopTab,
|
isGuideTopTab,
|
||||||
navigateToGuideTopTab,
|
|
||||||
type GuideTopTab
|
type GuideTopTab
|
||||||
} from '@/utils/guideTopTabs'
|
} from '@/utils/guideTopTabs'
|
||||||
|
|
||||||
const activeTopTab = ref<GuideTopTab>('guide')
|
const activeTopTab = ref<GuideTopTab>('explain')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
const hall = ref<HallDetailViewModel>({
|
const hall = ref<HallDetailViewModel>({
|
||||||
id: '',
|
id: '',
|
||||||
name: '展厅内容',
|
name: '展厅讲解',
|
||||||
floorLabel: '楼层待补充',
|
floorLabel: '楼层待补充',
|
||||||
description: '该展厅暂无介绍文案。',
|
description: '该展厅暂无介绍文案。',
|
||||||
image: '/static/hall-placeholder.jpg',
|
image: '/static/hall-placeholder.jpg',
|
||||||
@@ -98,25 +112,53 @@ const hall = ref<HallDetailViewModel>({
|
|||||||
|
|
||||||
const exhibits = ref<ExplainExhibitViewModel[]>([])
|
const exhibits = ref<ExplainExhibitViewModel[]>([])
|
||||||
|
|
||||||
onLoad(async (options: any) => {
|
const explainCountText = computed(() => (
|
||||||
if (options.id) {
|
exhibits.value.length > 0 ? `${exhibits.value.length} 条讲解` : '暂无讲解'
|
||||||
const hallData = await explainUseCase.getHallById(options.id)
|
))
|
||||||
if (hallData) {
|
|
||||||
hall.value = toHallDetailViewModel(hallData)
|
|
||||||
const hallExhibits = await explainUseCase.listExhibitsByHallId(hallData.id)
|
|
||||||
exhibits.value = hallExhibits.map(toExplainExhibitViewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
onLoad(async (options: any = {}) => {
|
||||||
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
|
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
|
||||||
if (isGuideTopTab(tab)) {
|
if (isGuideTopTab(tab)) {
|
||||||
activeTopTab.value = tab
|
activeTopTab.value = tab
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hallId = Array.isArray(options.id) ? options.id[0] : options.id
|
||||||
|
if (!hallId) {
|
||||||
|
error.value = '缺少展厅信息'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hallData = await explainUseCase.getHallById(hallId)
|
||||||
|
if (!hallData) {
|
||||||
|
error.value = '未找到该展厅'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hall.value = toHallDetailViewModel(hallData)
|
||||||
|
const hallExhibits = await explainUseCase.listExhibitsByHallId(hallData.id)
|
||||||
|
exhibits.value = hallExhibits.map(toExplainExhibitViewModel)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载展厅讲解失败:', err)
|
||||||
|
error.value = '展厅讲解加载失败,请稍后重试'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleExhibitClick = (exhibit: any) => {
|
const itemMeta = (exhibit: ExplainExhibitViewModel) => {
|
||||||
|
if (exhibit.audioStatus === 'playable') {
|
||||||
|
return [exhibit.durationLabel, '音频讲解'].filter(Boolean).join(' · ') || '音频讲解'
|
||||||
|
}
|
||||||
|
return '图文讲解'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExhibitClick = (exhibit: ExplainExhibitViewModel) => {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages/exhibit/detail?id=${exhibit.id}&tab=${activeTopTab.value}`
|
url: `/pages/exhibit/detail?id=${encodeURIComponent(exhibit.id)}&tab=${activeTopTab.value}`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +174,7 @@ const handleNavigate = async () => {
|
|||||||
const matchedPoi = await guideUseCase.getPoiById(hall.value.poiId)
|
const matchedPoi = await guideUseCase.getPoiById(hall.value.poiId)
|
||||||
if (!matchedPoi) {
|
if (!matchedPoi) {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: '该空间位置尚未映射到当前三维导览资源',
|
title: '该展厅位置尚未映射到当前三维导览资源',
|
||||||
icon: 'none'
|
icon: 'none'
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -143,10 +185,6 @@ const handleNavigate = async () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTopTabChange = (tab: GuideTopTab) => {
|
|
||||||
navigateToGuideTopTab(tab)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
uni.navigateBack({
|
uni.navigateBack({
|
||||||
delta: 1,
|
delta: 1,
|
||||||
@@ -160,148 +198,186 @@ const handleBack = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.detail-page {
|
.hall-explain-page {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-x: hidden;
|
overflow: hidden;
|
||||||
background-color: var(--museum-bg-light);
|
background: #f9fafb;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hall-hero {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 240px;
|
|
||||||
background-color: var(--museum-bg-map);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-image {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
padding: 84px 20px calc(28px + env(safe-area-inset-bottom));
|
||||||
|
|
||||||
.hall-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: 16px;
|
|
||||||
right: 16px;
|
|
||||||
background-color: rgba(0, 0, 0, 0.6);
|
|
||||||
backdrop-filter: var(--blur-medium);
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-text {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--museum-bg-surface);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hall-info {
|
|
||||||
padding: 24px 16px;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background-color: var(--museum-bg-surface);
|
}
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
padding-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-title,
|
||||||
|
.hall-meta,
|
||||||
|
.location-text,
|
||||||
|
.state-title,
|
||||||
|
.state-desc,
|
||||||
|
.explain-title,
|
||||||
|
.explain-meta {
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hall-title {
|
.hall-title {
|
||||||
font-size: 24px;
|
font-size: 29px;
|
||||||
font-weight: 700;
|
line-height: 38px;
|
||||||
color: var(--museum-text-primary);
|
font-weight: 800;
|
||||||
margin-bottom: 12px;
|
color: #151713;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hall-description {
|
.hall-meta-row {
|
||||||
font-size: 14px;
|
margin-top: 10px;
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--museum-text-secondary);
|
|
||||||
margin-bottom: 24px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 24px;
|
|
||||||
padding: 16px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background-color: var(--museum-bg-light);
|
|
||||||
border-radius: var(--radius-card);
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-item {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
justify-content: space-between;
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--museum-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--museum-text-disabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exhibits-section {
|
|
||||||
margin-top: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--museum-text-primary);
|
|
||||||
margin-bottom: 16px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exhibits-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-bar {
|
.hall-meta {
|
||||||
background-color: var(--museum-bg-surface);
|
min-width: 0;
|
||||||
border-top: 1px solid var(--museum-border-light);
|
flex: 1;
|
||||||
padding: 12px 16px;
|
|
||||||
padding-bottom: calc(12px + env(safe-area-inset-bottom));
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
width: 100%;
|
|
||||||
padding: 14px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-radius: var(--radius-button);
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn.primary {
|
|
||||||
background-color: var(--museum-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn:active {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-text {
|
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
line-height: 22px;
|
||||||
color: var(--museum-text-primary);
|
color: #4f574b;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-action {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 11px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d8dcd3;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #151713;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-action.disabled {
|
||||||
|
color: #8d9488;
|
||||||
|
background: #f2f4ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-text {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-item {
|
||||||
|
min-height: 96px;
|
||||||
|
padding: 14px 13px 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e4e6df;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 18px rgba(36, 49, 42, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-item:active {
|
||||||
|
background: #f7f8f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #747970;
|
||||||
|
background: #f0f2ed;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-icon.playable {
|
||||||
|
color: #151713;
|
||||||
|
background: #e0df00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-title {
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #151713;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explain-meta {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 21px;
|
||||||
|
color: #555c51;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
color: #151713;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-block {
|
||||||
|
margin: 28px 0;
|
||||||
|
padding: 22px 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: center;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e4e6df;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-title {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 21px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #151713;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-desc {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: #68725d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-block.error {
|
||||||
|
border-color: #e5c2c2;
|
||||||
|
background: #fff7f7;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<GuidePageFrame
|
<GuidePageFrame
|
||||||
:active-tab="currentTab"
|
:active-tab="currentTab"
|
||||||
:variant="currentTab === 'explain' ? 'static' : 'overlay'"
|
variant="overlay"
|
||||||
:top-tabs-variant="currentTab === 'guide' ? 'segmented' : 'underline'"
|
:top-tabs-variant="currentTab === 'guide' ? 'segmented' : 'underline'"
|
||||||
:top-tabs-top="currentTab === 'guide' ? '108px' : '0'"
|
:top-tabs-top="currentTab === 'guide' ? '80px' : '0'"
|
||||||
|
:show-top-tabs="currentTab === 'guide'"
|
||||||
@tab-change="handleTabChange"
|
@tab-change="handleTabChange"
|
||||||
>
|
>
|
||||||
<GuideMapShell
|
<GuideMapShell
|
||||||
@@ -13,10 +14,10 @@
|
|||||||
:mode-layout="'status'"
|
:mode-layout="'status'"
|
||||||
:mode-status="guideStatusLabel"
|
:mode-status="guideStatusLabel"
|
||||||
mode-status-tone="glass"
|
mode-status-tone="glass"
|
||||||
search-top="46px"
|
search-top="20px"
|
||||||
search-variant="home"
|
search-variant="home"
|
||||||
:show-mode-row="false"
|
:show-mode-row="false"
|
||||||
mode-top="104px"
|
mode-top="80px"
|
||||||
:map-type="guideMapType"
|
:map-type="guideMapType"
|
||||||
:outdoor-variant="guideOutdoorVariant"
|
:outdoor-variant="guideOutdoorVariant"
|
||||||
:indoor-asset-base-url="indoorNavAssetBaseUrl"
|
:indoor-asset-base-url="indoorNavAssetBaseUrl"
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
show-floor-header
|
show-floor-header
|
||||||
:show-layer-mode-toggle="false"
|
:show-layer-mode-toggle="false"
|
||||||
:show-floor="is3DMode"
|
:show-floor="is3DMode"
|
||||||
tools-top="154px"
|
tools-top="130px"
|
||||||
show-zoom-controls
|
show-zoom-controls
|
||||||
zoom-controls-top="560px"
|
zoom-controls-top="560px"
|
||||||
show-location-control
|
show-location-control
|
||||||
@@ -184,37 +185,14 @@
|
|||||||
</GuideMapShell>
|
</GuideMapShell>
|
||||||
|
|
||||||
<view v-else-if="currentTab === 'explain'" class="explain-page">
|
<view v-else-if="currentTab === 'explain'" class="explain-page">
|
||||||
<ExplainList
|
<ExplainHallSelect
|
||||||
:cards="explainExhibits"
|
:halls="explainHallItems"
|
||||||
:loading="explainLoading"
|
:loading="explainLoading"
|
||||||
:error="explainError"
|
:error="explainError"
|
||||||
show-browse-all
|
|
||||||
@exhibit-click="handleExplainExhibitClick"
|
|
||||||
@featured-click="handleExplainFeaturedClick"
|
|
||||||
@audio-click="handleAudioClick"
|
|
||||||
@location-click="handleExplainLocationClick"
|
|
||||||
@hall-click="handleExplainHallClick"
|
@hall-click="handleExplainHallClick"
|
||||||
@browse-all="handleExplainBrowseAll"
|
@back="handleExplainBack"
|
||||||
/>
|
|
||||||
|
|
||||||
<FloatingAudioButton
|
|
||||||
v-if="currentAudio"
|
|
||||||
:is-playing="isAudioPlaying"
|
|
||||||
@click="handleFloatingButtonClick"
|
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 音频播放器 -->
|
|
||||||
<AudioPlayer
|
|
||||||
:visible="showAudioPlayer"
|
|
||||||
:audio="currentAudio"
|
|
||||||
:auto-play="true"
|
|
||||||
@update:visible="handleAudioVisibleChange"
|
|
||||||
@play="handleAudioPlay"
|
|
||||||
@pause="handleAudioPause"
|
|
||||||
@ended="handleAudioEnded"
|
|
||||||
@error="handleAudioError"
|
|
||||||
/>
|
|
||||||
</GuidePageFrame>
|
</GuidePageFrame>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -227,9 +205,9 @@ import RoutePlannerPanel from '@/components/navigation/RoutePlannerPanel.vue'
|
|||||||
import type {
|
import type {
|
||||||
RoutePointOption
|
RoutePointOption
|
||||||
} from '@/components/navigation/RoutePointPicker.vue'
|
} from '@/components/navigation/RoutePointPicker.vue'
|
||||||
import ExplainList, { type Exhibit } from '@/components/explain/ExplainList.vue'
|
import ExplainHallSelect, {
|
||||||
import FloatingAudioButton from '@/components/audio/FloatingAudioButton.vue'
|
type ExplainHallSelectItem
|
||||||
import AudioPlayer, { type AudioItem } from '@/components/audio/AudioPlayer.vue'
|
} from '@/components/explain/ExplainHallSelect.vue'
|
||||||
import {
|
import {
|
||||||
getLastGuideLocationPreviewUrl,
|
getLastGuideLocationPreviewUrl,
|
||||||
isGuideTopTab,
|
isGuideTopTab,
|
||||||
@@ -244,15 +222,13 @@ import {
|
|||||||
import {
|
import {
|
||||||
explainUseCase
|
explainUseCase
|
||||||
} from '@/usecases/explainUseCase'
|
} from '@/usecases/explainUseCase'
|
||||||
import {
|
|
||||||
toExplainCardViewModel
|
|
||||||
} from '@/view-models/explainViewModels'
|
|
||||||
import type {
|
import type {
|
||||||
GuideRenderPoi
|
GuideRenderPoi
|
||||||
} from '@/domain/guideModel'
|
} from '@/domain/guideModel'
|
||||||
import type {
|
import type {
|
||||||
GuideRouteResult,
|
GuideRouteResult,
|
||||||
GuideRouteTarget,
|
GuideRouteTarget,
|
||||||
|
MuseumExhibit,
|
||||||
MuseumFloor
|
MuseumFloor
|
||||||
} from '@/domain/museum'
|
} from '@/domain/museum'
|
||||||
|
|
||||||
@@ -355,11 +331,7 @@ const routeSimulationStepText = computed(() => {
|
|||||||
return hasConnector ? '路线示意 · 跨楼层连接点' : '路线示意 · 同层位置关系'
|
return hasConnector ? '路线示意 · 跨楼层连接点' : '路线示意 · 同层位置关系'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 音频播放器状态
|
const explainHallItems = ref<ExplainHallSelectItem[]>([])
|
||||||
const showAudioPlayer = ref(false)
|
|
||||||
const isAudioPlaying = ref(false)
|
|
||||||
const currentAudio = ref<AudioItem | null>(null)
|
|
||||||
const explainExhibits = ref<Exhibit[]>([])
|
|
||||||
const explainLoading = ref(false)
|
const explainLoading = ref(false)
|
||||||
const explainError = ref('')
|
const explainError = ref('')
|
||||||
let explainLoadPromise: Promise<void> | null = null
|
let explainLoadPromise: Promise<void> | null = null
|
||||||
@@ -469,7 +441,7 @@ const loadGuideFloors = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadExplainExhibits = async () => {
|
const loadExplainExhibits = async () => {
|
||||||
if (explainExhibits.value.length) return
|
if (explainHallItems.value.length) return
|
||||||
if (explainLoadPromise) return explainLoadPromise
|
if (explainLoadPromise) return explainLoadPromise
|
||||||
|
|
||||||
explainLoading.value = true
|
explainLoading.value = true
|
||||||
@@ -477,12 +449,15 @@ const loadExplainExhibits = async () => {
|
|||||||
|
|
||||||
explainLoadPromise = (async () => {
|
explainLoadPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const exhibits = await explainUseCase.listExplainExhibits()
|
const [halls, exhibits] = await Promise.all([
|
||||||
explainExhibits.value = exhibits.map(toExplainCardViewModel)
|
explainUseCase.listHalls(),
|
||||||
|
explainUseCase.listExplainExhibits()
|
||||||
|
])
|
||||||
|
explainHallItems.value = buildExplainHallItems(halls, exhibits)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载讲解内容失败:', error)
|
console.error('加载讲解内容失败:', error)
|
||||||
explainError.value = '讲解内容加载失败,请稍后重试'
|
explainError.value = '讲解内容加载失败,请稍后重试'
|
||||||
explainExhibits.value = []
|
explainHallItems.value = []
|
||||||
} finally {
|
} finally {
|
||||||
explainLoading.value = false
|
explainLoading.value = false
|
||||||
explainLoadPromise = null
|
explainLoadPromise = null
|
||||||
@@ -492,6 +467,68 @@ const loadExplainExhibits = async () => {
|
|||||||
return explainLoadPromise
|
return explainLoadPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeExplainKeyword = (value?: string) => (value || '').trim().toLowerCase()
|
||||||
|
|
||||||
|
const buildExplainHallItems = (
|
||||||
|
halls: Awaited<ReturnType<typeof explainUseCase.listHalls>>,
|
||||||
|
exhibits: MuseumExhibit[]
|
||||||
|
): ExplainHallSelectItem[] => {
|
||||||
|
const countsByHallId = new Map<string, number>()
|
||||||
|
const countsByHallName = new Map<string, number>()
|
||||||
|
const keywordsByHallId = new Map<string, Set<string>>()
|
||||||
|
const keywordsByHallName = new Map<string, Set<string>>()
|
||||||
|
|
||||||
|
exhibits.forEach((exhibit) => {
|
||||||
|
const keywordParts = [
|
||||||
|
exhibit.name,
|
||||||
|
exhibit.hallName,
|
||||||
|
exhibit.floorLabel,
|
||||||
|
exhibit.description,
|
||||||
|
exhibit.guideTitle,
|
||||||
|
exhibit.guideText,
|
||||||
|
...(exhibit.tags || [])
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
|
||||||
|
if (exhibit.hallId) {
|
||||||
|
countsByHallId.set(exhibit.hallId, (countsByHallId.get(exhibit.hallId) || 0) + 1)
|
||||||
|
const keywords = keywordsByHallId.get(exhibit.hallId) || new Set<string>()
|
||||||
|
keywordParts.forEach((part) => keywords.add(part))
|
||||||
|
keywordsByHallId.set(exhibit.hallId, keywords)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exhibit.hallName) {
|
||||||
|
countsByHallName.set(exhibit.hallName, (countsByHallName.get(exhibit.hallName) || 0) + 1)
|
||||||
|
const keywords = keywordsByHallName.get(exhibit.hallName) || new Set<string>()
|
||||||
|
keywordParts.forEach((part) => keywords.add(part))
|
||||||
|
keywordsByHallName.set(exhibit.hallName, keywords)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return halls.map((hall) => {
|
||||||
|
const explainCount = countsByHallId.get(hall.id)
|
||||||
|
|| countsByHallName.get(hall.name)
|
||||||
|
|| hall.exhibitCount
|
||||||
|
|| 0
|
||||||
|
const keywordParts = [
|
||||||
|
hall.name,
|
||||||
|
hall.floorLabel,
|
||||||
|
hall.description,
|
||||||
|
hall.area,
|
||||||
|
...Array.from(keywordsByHallId.get(hall.id) || []),
|
||||||
|
...Array.from(keywordsByHallName.get(hall.name) || [])
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: hall.id,
|
||||||
|
name: hall.name,
|
||||||
|
floorLabel: hall.floorLabel,
|
||||||
|
image: hall.image,
|
||||||
|
explainCount,
|
||||||
|
searchText: normalizeExplainKeyword(keywordParts.join(' '))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleGuideSearchTap = () => {
|
const handleGuideSearchTap = () => {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: '/pages/search/index'
|
url: '/pages/search/index'
|
||||||
@@ -843,131 +880,21 @@ const guideSearchText = computed(() => {
|
|||||||
return '请输入地点进行搜索'
|
return '请输入地点进行搜索'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 音频相关处理
|
|
||||||
const handleAudioClick = async (exhibit: Exhibit) => {
|
|
||||||
if (exhibit.audioUrl) {
|
|
||||||
currentAudio.value = {
|
|
||||||
id: `media-${exhibit.id}`,
|
|
||||||
name: exhibit.title,
|
|
||||||
audioUrl: exhibit.audioUrl,
|
|
||||||
image: exhibit.coverImage,
|
|
||||||
duration: exhibit.audioDuration
|
|
||||||
}
|
|
||||||
showAudioPlayer.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const selection = await explainUseCase.selectAudioForExhibit(exhibit.id)
|
|
||||||
|
|
||||||
if (!selection?.playable || !selection.media?.url) {
|
|
||||||
uni.showToast({
|
|
||||||
title: selection?.unavailableMessage || exhibit.audioStatusText || '该讲解暂无可播放音频',
|
|
||||||
icon: 'none'
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
currentAudio.value = {
|
|
||||||
id: selection.media.id,
|
|
||||||
name: selection.track?.title || selection.exhibit.name,
|
|
||||||
audioUrl: selection.media.url,
|
|
||||||
image: selection.exhibit.image,
|
|
||||||
duration: selection.media.duration
|
|
||||||
}
|
|
||||||
showAudioPlayer.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFloatingButtonClick = () => {
|
|
||||||
if (showAudioPlayer.value) {
|
|
||||||
showAudioPlayer.value = false
|
|
||||||
} else if (currentAudio.value) {
|
|
||||||
showAudioPlayer.value = true
|
|
||||||
} else {
|
|
||||||
uni.showToast({
|
|
||||||
title: '请先选择一个讲解内容',
|
|
||||||
icon: 'none'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearAudioState = () => {
|
|
||||||
showAudioPlayer.value = false
|
|
||||||
isAudioPlaying.value = false
|
|
||||||
currentAudio.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAudioVisibleChange = (visible: boolean) => {
|
|
||||||
showAudioPlayer.value = visible
|
|
||||||
if (!visible) {
|
|
||||||
isAudioPlaying.value = false
|
|
||||||
currentAudio.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAudioPlay = (audio: AudioItem) => {
|
|
||||||
console.log('开始播放:', audio)
|
|
||||||
isAudioPlaying.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAudioPause = (audio: AudioItem) => {
|
|
||||||
console.log('暂停播放:', audio)
|
|
||||||
isAudioPlaying.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAudioEnded = (audio: AudioItem) => {
|
|
||||||
console.log('播放结束:', audio)
|
|
||||||
clearAudioState()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAudioError = (_audio: AudioItem | null, message: string) => {
|
|
||||||
console.warn('讲解音频不可播放:', message)
|
|
||||||
clearAudioState()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 讲解页面处理
|
// 讲解页面处理
|
||||||
const handleExplainExhibitClick = (exhibit: Exhibit) => {
|
|
||||||
uni.navigateTo({
|
|
||||||
url: `/pages/exhibit/detail?id=${exhibit.id}&tab=explain`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleExplainFeaturedClick = async (exhibit: Exhibit) => {
|
|
||||||
await handleAudioClick(exhibit)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleExplainHallClick = (hallId: string) => {
|
const handleExplainHallClick = (hallId: string) => {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages/hall/detail?id=${encodeURIComponent(hallId)}&tab=explain`
|
url: `/pages/hall/detail?id=${encodeURIComponent(hallId)}&tab=explain`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExplainBrowseAll = () => {
|
const handleExplainBack = () => {
|
||||||
uni.navigateTo({
|
currentTab.value = 'guide'
|
||||||
url: '/pages/explain/list'
|
syncTopTabToUrl('guide')
|
||||||
})
|
loadTopTabData('guide')
|
||||||
}
|
showGuideMoreMenu.value = false
|
||||||
|
showRoutePlanner.value = false
|
||||||
const handleExplainLocationClick = async (exhibit: Exhibit) => {
|
isSimulatingRoute.value = false
|
||||||
if (!exhibit.poiId) {
|
isPoiCardCollapsed.value = false
|
||||||
uni.showToast({
|
|
||||||
title: '该讲解暂无三维位置数据',
|
|
||||||
icon: 'none'
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchedPoi = await guideUseCase.getPoiById(exhibit.poiId)
|
|
||||||
if (!matchedPoi) {
|
|
||||||
uni.showToast({
|
|
||||||
title: '该讲解位置尚未映射到当前三维导览资源',
|
|
||||||
icon: 'none'
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
uni.navigateTo({
|
|
||||||
url: `/pages/route/detail?facilityId=${encodeURIComponent(exhibit.poiId)}&target=${encodeURIComponent(exhibit.title)}&state=preview`
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1187,7 +1114,7 @@ const handleExplainLocationClick = async (exhibit: Exhibit) => {
|
|||||||
|
|
||||||
.route-sim-top-pill {
|
.route-sim-top-pill {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 154px;
|
top: 126px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
max-width: calc(100% - 40px);
|
max-width: calc(100% - 40px);
|
||||||
height: 34px;
|
height: 34px;
|
||||||
@@ -1263,7 +1190,7 @@ const handleExplainLocationClick = async (exhibit: Exhibit) => {
|
|||||||
|
|
||||||
.entrance-tip {
|
.entrance-tip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 154px;
|
top: 126px;
|
||||||
left: 30px;
|
left: 30px;
|
||||||
width: 168px;
|
width: 168px;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
|
|||||||
@@ -94,15 +94,6 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<LocationPreview
|
|
||||||
v-else-if="routeViewState === 'preview'"
|
|
||||||
:summary="route.summary"
|
|
||||||
:steps="route.steps"
|
|
||||||
:target-location="route.targetLocation"
|
|
||||||
@view-outdoor-map="handleViewOutdoorMap"
|
|
||||||
@show-target-location="handleShowTargetLocation"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<view v-else-if="routeViewState === 'outdoor-preview'" class="outdoor-preview-card">
|
<view v-else-if="routeViewState === 'outdoor-preview'" class="outdoor-preview-card">
|
||||||
<text class="outdoor-preview-title">室外地图预览:{{ route.target }}</text>
|
<text class="outdoor-preview-title">室外地图预览:{{ route.target }}</text>
|
||||||
<text class="outdoor-preview-desc">
|
<text class="outdoor-preview-desc">
|
||||||
@@ -126,7 +117,6 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|||||||
import { onLoad } from '@dcloudio/uni-app'
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
||||||
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
|
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
|
||||||
import LocationPreview from '@/components/navigation/LocationPreview.vue'
|
|
||||||
import {
|
import {
|
||||||
guideUseCase
|
guideUseCase
|
||||||
} from '@/usecases/guideUseCase'
|
} from '@/usecases/guideUseCase'
|
||||||
@@ -226,6 +216,7 @@ const shellConfig = computed(() => {
|
|||||||
tools: [],
|
tools: [],
|
||||||
showSearch: true,
|
showSearch: true,
|
||||||
showFloor: false,
|
showFloor: false,
|
||||||
|
showModeRow: false,
|
||||||
modeTop: '104px',
|
modeTop: '104px',
|
||||||
modeLayout: 'full' as const,
|
modeLayout: 'full' as const,
|
||||||
mapType: 'indoor' as const
|
mapType: 'indoor' as const
|
||||||
@@ -243,6 +234,7 @@ const shellConfig = computed(() => {
|
|||||||
tools: [],
|
tools: [],
|
||||||
showSearch: true,
|
showSearch: true,
|
||||||
showFloor: false,
|
showFloor: false,
|
||||||
|
showModeRow: false,
|
||||||
modeTop: '104px',
|
modeTop: '104px',
|
||||||
modeLayout: 'full' as const,
|
modeLayout: 'full' as const,
|
||||||
mapType: 'indoor' as const,
|
mapType: 'indoor' as const,
|
||||||
@@ -261,6 +253,7 @@ const shellConfig = computed(() => {
|
|||||||
tools: [],
|
tools: [],
|
||||||
showSearch: true,
|
showSearch: true,
|
||||||
showFloor: false,
|
showFloor: false,
|
||||||
|
showModeRow: false,
|
||||||
modeTop: '104px',
|
modeTop: '104px',
|
||||||
modeLayout: 'status' as const,
|
modeLayout: 'status' as const,
|
||||||
modeStatus: '室外参考',
|
modeStatus: '室外参考',
|
||||||
@@ -279,9 +272,12 @@ const shellConfig = computed(() => {
|
|||||||
tools: ['回正'],
|
tools: ['回正'],
|
||||||
showSearch: true,
|
showSearch: true,
|
||||||
showFloor: true,
|
showFloor: true,
|
||||||
|
showModeRow: false,
|
||||||
modeTop: '104px',
|
modeTop: '104px',
|
||||||
modeLayout: 'full' as const,
|
modeLayout: 'full' as const,
|
||||||
mapType: 'indoor' as const
|
mapType: 'indoor' as const,
|
||||||
|
touchGestureMode: 'pan' as const,
|
||||||
|
targetFocusDistanceFactor: 0.86
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -360,7 +356,8 @@ const requestTargetFocus = (target: Partial<RouteTargetLocation> | null, fallbac
|
|||||||
floorId: target.floorId,
|
floorId: target.floorId,
|
||||||
floorLabel,
|
floorLabel,
|
||||||
primaryCategoryZh: target.primaryCategoryZh,
|
primaryCategoryZh: target.primaryCategoryZh,
|
||||||
positionGltf: target.positionGltf
|
positionGltf: target.positionGltf,
|
||||||
|
sourceObjectName: target.sourceObjectName
|
||||||
}
|
}
|
||||||
|
|
||||||
activeFloor.value = floorLabel
|
activeFloor.value = floorLabel
|
||||||
@@ -477,29 +474,6 @@ const handleSearchTap = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleViewOutdoorMap = () => {
|
|
||||||
isManualLocationPanelOpen.value = false
|
|
||||||
routeViewState.value = 'outdoor-preview'
|
|
||||||
saveCurrentLocationPreviewUrl()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleShowTargetLocation = (target: Partial<RouteTargetLocation> | null) => {
|
|
||||||
const focused = requestTargetFocus(target || route.value.targetLocation)
|
|
||||||
|
|
||||||
if (!focused) {
|
|
||||||
uni.showToast({
|
|
||||||
title: '目标暂无三维位置数据',
|
|
||||||
icon: 'none'
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
uni.showToast({
|
|
||||||
title: '已显示三维位置',
|
|
||||||
icon: 'none'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTargetFocus = (result: TargetPoiFocusResult) => {
|
const handleTargetFocus = (result: TargetPoiFocusResult) => {
|
||||||
if (result.status === 'focused') return
|
if (result.status === 'focused') return
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ const toRenderPoi = (poi: StaticNavPoiPayload): GuideRenderPoi => ({
|
|||||||
primaryCategory: poi.primaryCategory,
|
primaryCategory: poi.primaryCategory,
|
||||||
primaryCategoryZh: poi.primaryCategoryZh,
|
primaryCategoryZh: poi.primaryCategoryZh,
|
||||||
iconType: poi.iconType || poi.primaryCategory,
|
iconType: poi.iconType || poi.primaryCategory,
|
||||||
positionGltf: poi.positionGltf
|
positionGltf: poi.positionGltf,
|
||||||
|
sourceObjectName: poi.sourceObjectName
|
||||||
})
|
})
|
||||||
|
|
||||||
export interface GuideModelRepository extends GuideModelSource {}
|
export interface GuideModelRepository extends GuideModelSource {}
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ const toLocationPreview = (poi: MuseumPoi): GuideLocationPreview => ({
|
|||||||
floorId: poi.floorId,
|
floorId: poi.floorId,
|
||||||
floorLabel: poi.floorLabel,
|
floorLabel: poi.floorLabel,
|
||||||
primaryCategoryZh: poi.primaryCategory.label,
|
primaryCategoryZh: poi.primaryCategory.label,
|
||||||
positionGltf: poi.positionGltf
|
positionGltf: poi.positionGltf,
|
||||||
|
sourceObjectName: poi.sourceObjectName
|
||||||
})
|
})
|
||||||
|
|
||||||
export interface GuideRepository {
|
export interface GuideRepository {
|
||||||
|
|||||||
@@ -45,57 +45,57 @@
|
|||||||
},
|
},
|
||||||
"assets": {
|
"assets": {
|
||||||
"overviewModel": {
|
"overviewModel": {
|
||||||
"asset": "web_model/current_session.glb",
|
"asset": "web_model/current_session.display.glb",
|
||||||
"bytes": 32345596,
|
"bytes": 14924720,
|
||||||
"sharedModelAsset": true
|
"sharedModelAsset": true
|
||||||
},
|
},
|
||||||
"floorModels": [
|
"floorModels": [
|
||||||
{
|
{
|
||||||
"floorId": "L-2",
|
"floorId": "L-2",
|
||||||
"order": 0,
|
"order": 0,
|
||||||
"asset": "web_model/current_session.glb",
|
"asset": "web_model/current_session.display.glb",
|
||||||
"sharedModelAsset": true
|
"sharedModelAsset": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"floorId": "L-1",
|
"floorId": "L-1",
|
||||||
"order": 1,
|
"order": 1,
|
||||||
"asset": "web_model/current_session.glb",
|
"asset": "web_model/current_session.display.glb",
|
||||||
"sharedModelAsset": true
|
"sharedModelAsset": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"floorId": "L1",
|
"floorId": "L1",
|
||||||
"order": 2,
|
"order": 2,
|
||||||
"asset": "web_model/current_session.glb",
|
"asset": "web_model/current_session.display.glb",
|
||||||
"sharedModelAsset": true
|
"sharedModelAsset": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"floorId": "L1.5",
|
"floorId": "L1.5",
|
||||||
"order": 3,
|
"order": 3,
|
||||||
"asset": "web_model/current_session.glb",
|
"asset": "web_model/current_session.display.glb",
|
||||||
"sharedModelAsset": true
|
"sharedModelAsset": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"floorId": "L2",
|
"floorId": "L2",
|
||||||
"order": 4,
|
"order": 4,
|
||||||
"asset": "web_model/current_session.glb",
|
"asset": "web_model/current_session.display.glb",
|
||||||
"sharedModelAsset": true
|
"sharedModelAsset": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"floorId": "L3",
|
"floorId": "L3",
|
||||||
"order": 5,
|
"order": 5,
|
||||||
"asset": "web_model/current_session.glb",
|
"asset": "web_model/current_session.display.glb",
|
||||||
"sharedModelAsset": true
|
"sharedModelAsset": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"floorId": "L4",
|
"floorId": "L4",
|
||||||
"order": 6,
|
"order": 6,
|
||||||
"asset": "web_model/current_session.glb",
|
"asset": "web_model/current_session.display.glb",
|
||||||
"sharedModelAsset": true
|
"sharedModelAsset": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"floorId": "L5",
|
"floorId": "L5",
|
||||||
"order": 7,
|
"order": 7,
|
||||||
"asset": "web_model/current_session.glb",
|
"asset": "web_model/current_session.display.glb",
|
||||||
"sharedModelAsset": true
|
"sharedModelAsset": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
"notesZh": [
|
"notesZh": [
|
||||||
"该包由 2026-06-21 finalxy 中心线路网 run 生成,nav_data.json 与 route_graph.json 为权威数据源。",
|
"该包由 2026-06-21 finalxy 中心线路网 run 生成,nav_data.json 与 route_graph.json 为权威数据源。",
|
||||||
"前端兼容 JSON 由 nav_data.buildingPois/navAnchors 派生,页面仍通过 Provider / Adapter / Repository 读取 domain models。",
|
"前端兼容 JSON 由 nav_data.buildingPois/navAnchors 派生,页面仍通过 Provider / Adapter / Repository 读取 domain models。",
|
||||||
"current_session.glb 是本包唯一三维模型;楼层切换复用同一模型并按楼层加载 POI。",
|
"current_session.display.glb 是用户端展示模型,已移除中心线路网辅助网格;原始 current_session.glb 保留给 nav_data.json 与 route_graph.json 的数据生成/审计链路使用。",
|
||||||
"POI positionGltf 已按 [x, y, z] -> [x, z, -y] 转换到 Three.js/GLB 坐标。",
|
"POI positionGltf 已按 [x, y, z] -> [x, z, -y] 转换到 Three.js/GLB 坐标。",
|
||||||
"connector_machine_verification 已记录 finalVerticalEndpointXYGate,通过的 102 个垂直连接候选才进入最终图。"
|
"connector_machine_verification 已记录 finalVerticalEndpointXYGate,通过的 102 个垂直连接候选才进入最终图。"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"name": "L-2",
|
"name": "L-2",
|
||||||
"label": "B2",
|
"label": "B2",
|
||||||
"zRepresentative": -14.244266,
|
"zRepresentative": -14.244266,
|
||||||
"modelAsset": "web_model/current_session.glb",
|
"modelAsset": "web_model/current_session.display.glb",
|
||||||
"sharedModelAsset": true,
|
"sharedModelAsset": true,
|
||||||
"poiDataAsset": "data/poi_by_floor/L-2.json",
|
"poiDataAsset": "data/poi_by_floor/L-2.json",
|
||||||
"poiCount": 59
|
"poiCount": 59
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"name": "L-1",
|
"name": "L-1",
|
||||||
"label": "B1",
|
"label": "B1",
|
||||||
"zRepresentative": -10.673778,
|
"zRepresentative": -10.673778,
|
||||||
"modelAsset": "web_model/current_session.glb",
|
"modelAsset": "web_model/current_session.display.glb",
|
||||||
"sharedModelAsset": true,
|
"sharedModelAsset": true,
|
||||||
"poiDataAsset": "data/poi_by_floor/L-1.json",
|
"poiDataAsset": "data/poi_by_floor/L-1.json",
|
||||||
"poiCount": 47
|
"poiCount": 47
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
"name": "L1",
|
"name": "L1",
|
||||||
"label": "1F",
|
"label": "1F",
|
||||||
"zRepresentative": 0.170108,
|
"zRepresentative": 0.170108,
|
||||||
"modelAsset": "web_model/current_session.glb",
|
"modelAsset": "web_model/current_session.display.glb",
|
||||||
"sharedModelAsset": true,
|
"sharedModelAsset": true,
|
||||||
"poiDataAsset": "data/poi_by_floor/L1.json",
|
"poiDataAsset": "data/poi_by_floor/L1.json",
|
||||||
"poiCount": 74
|
"poiCount": 74
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
"name": "L1.5",
|
"name": "L1.5",
|
||||||
"label": "1.5F",
|
"label": "1.5F",
|
||||||
"zRepresentative": 5.1561,
|
"zRepresentative": 5.1561,
|
||||||
"modelAsset": "web_model/current_session.glb",
|
"modelAsset": "web_model/current_session.display.glb",
|
||||||
"sharedModelAsset": true,
|
"sharedModelAsset": true,
|
||||||
"poiDataAsset": "data/poi_by_floor/L1.5.json",
|
"poiDataAsset": "data/poi_by_floor/L1.5.json",
|
||||||
"poiCount": 21
|
"poiCount": 21
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
"name": "L2",
|
"name": "L2",
|
||||||
"label": "2F",
|
"label": "2F",
|
||||||
"zRepresentative": 14.75636,
|
"zRepresentative": 14.75636,
|
||||||
"modelAsset": "web_model/current_session.glb",
|
"modelAsset": "web_model/current_session.display.glb",
|
||||||
"sharedModelAsset": true,
|
"sharedModelAsset": true,
|
||||||
"poiDataAsset": "data/poi_by_floor/L2.json",
|
"poiDataAsset": "data/poi_by_floor/L2.json",
|
||||||
"poiCount": 46
|
"poiCount": 46
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
"name": "L3",
|
"name": "L3",
|
||||||
"label": "3F",
|
"label": "3F",
|
||||||
"zRepresentative": 16.577412,
|
"zRepresentative": 16.577412,
|
||||||
"modelAsset": "web_model/current_session.glb",
|
"modelAsset": "web_model/current_session.display.glb",
|
||||||
"sharedModelAsset": true,
|
"sharedModelAsset": true,
|
||||||
"poiDataAsset": "data/poi_by_floor/L3.json",
|
"poiDataAsset": "data/poi_by_floor/L3.json",
|
||||||
"poiCount": 15
|
"poiCount": 15
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
"name": "L4",
|
"name": "L4",
|
||||||
"label": "4F",
|
"label": "4F",
|
||||||
"zRepresentative": 22.86525,
|
"zRepresentative": 22.86525,
|
||||||
"modelAsset": "web_model/current_session.glb",
|
"modelAsset": "web_model/current_session.display.glb",
|
||||||
"sharedModelAsset": true,
|
"sharedModelAsset": true,
|
||||||
"poiDataAsset": "data/poi_by_floor/L4.json",
|
"poiDataAsset": "data/poi_by_floor/L4.json",
|
||||||
"poiCount": 25
|
"poiCount": 25
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
"name": "L5",
|
"name": "L5",
|
||||||
"label": "5F",
|
"label": "5F",
|
||||||
"zRepresentative": 27.10755,
|
"zRepresentative": 27.10755,
|
||||||
"modelAsset": "web_model/current_session.glb",
|
"modelAsset": "web_model/current_session.display.glb",
|
||||||
"sharedModelAsset": true,
|
"sharedModelAsset": true,
|
||||||
"poiDataAsset": "data/poi_by_floor/L5.json",
|
"poiDataAsset": "data/poi_by_floor/L5.json",
|
||||||
"poiCount": 11
|
"poiCount": 11
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user