chore: freeze guide and explain update
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<view class="audio-player" v-if="visible">
|
||||
<view class="audio-player-host">
|
||||
<view class="audio-player" v-if="visible && currentAudio">
|
||||
<!-- 迷你播放器 (底部固定) -->
|
||||
<view class="mini-player" @tap="handleExpand">
|
||||
<!-- 封面图 -->
|
||||
@@ -14,7 +15,7 @@
|
||||
<!-- 播放信息 -->
|
||||
<view class="player-info">
|
||||
<text class="player-title">{{ currentAudio?.name || '讲解音频' }}</text>
|
||||
<text class="player-subtitle">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</text>
|
||||
<text class="player-subtitle" :class="{ error: playbackError }">{{ playerSubtitle }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 播放/暂停按钮 -->
|
||||
@@ -46,6 +47,7 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -71,6 +73,7 @@ const emit = defineEmits<{
|
||||
pause: [audio: AudioItem]
|
||||
ended: [audio: AudioItem]
|
||||
timeUpdate: [currentTime: number]
|
||||
error: [audio: AudioItem | null, message: string]
|
||||
}>()
|
||||
|
||||
// 状态
|
||||
@@ -79,14 +82,34 @@ const isPlaying = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const playbackError = ref('')
|
||||
const h5AudioElement = ref<HTMLAudioElement | null>(null)
|
||||
|
||||
// 内部音频上下文
|
||||
let innerAudioContext: any = null
|
||||
let isStoppingAudio = false
|
||||
let cleanupH5Audio: (() => void) | null = null
|
||||
|
||||
// 计算进度百分比
|
||||
const progressPercent = computed(() => {
|
||||
if (duration.value === 0) return 0
|
||||
return (currentTime.value / duration.value) * 100
|
||||
const totalDuration = resolvedDuration.value
|
||||
if (totalDuration === 0) return 0
|
||||
return (currentTime.value / totalDuration) * 100
|
||||
})
|
||||
|
||||
const resolvedDuration = computed(() => {
|
||||
if (duration.value > 0) return duration.value
|
||||
return currentAudio.value?.duration || 0
|
||||
})
|
||||
|
||||
const playerSubtitle = computed(() => {
|
||||
if (playbackError.value) return playbackError.value
|
||||
if (isLoading.value) return '音频加载中'
|
||||
if (!currentAudio.value) return '请选择讲解'
|
||||
if (resolvedDuration.value > 0) {
|
||||
return `${formatTime(currentTime.value)} / ${formatTime(resolvedDuration.value)}`
|
||||
}
|
||||
return isPlaying.value ? '正在播放' : '点击播放按钮开始'
|
||||
})
|
||||
|
||||
// 格式化时间
|
||||
@@ -98,12 +121,18 @@ const formatTime = (seconds: number): string => {
|
||||
|
||||
// 初始化音频上下文
|
||||
const initAudio = () => {
|
||||
if (h5AudioElement.value) return
|
||||
|
||||
if (typeof uni !== 'undefined' && uni.createInnerAudioContext) {
|
||||
innerAudioContext = uni.createInnerAudioContext()
|
||||
|
||||
innerAudioContext.onPlay(() => {
|
||||
isPlaying.value = true
|
||||
isLoading.value = false
|
||||
playbackError.value = ''
|
||||
if (currentAudio.value) {
|
||||
emit('play', currentAudio.value)
|
||||
}
|
||||
})
|
||||
|
||||
innerAudioContext.onPause(() => {
|
||||
@@ -131,12 +160,7 @@ const initAudio = () => {
|
||||
|
||||
innerAudioContext.onError((err: any) => {
|
||||
console.error('音频播放错误:', err)
|
||||
isPlaying.value = false
|
||||
isLoading.value = false
|
||||
uni.showToast({
|
||||
title: '音频加载失败',
|
||||
icon: 'none'
|
||||
})
|
||||
handlePlaybackError('音频加载失败,当前提供图文讲解。', true)
|
||||
})
|
||||
|
||||
innerAudioContext.onWaiting(() => {
|
||||
@@ -145,22 +169,143 @@ const initAudio = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const initH5Audio = () => {
|
||||
if (h5AudioElement.value || typeof window === 'undefined' || !window.Audio) return
|
||||
|
||||
const audio = new window.Audio()
|
||||
audio.preload = 'metadata'
|
||||
audio.addEventListener('loadedmetadata', handleH5LoadedMetadata)
|
||||
audio.addEventListener('play', handleH5Play)
|
||||
audio.addEventListener('pause', handleH5Pause)
|
||||
audio.addEventListener('ended', handleH5Ended)
|
||||
audio.addEventListener('timeupdate', handleH5TimeUpdate)
|
||||
audio.addEventListener('waiting', handleH5Waiting)
|
||||
audio.addEventListener('canplay', handleH5CanPlay)
|
||||
audio.addEventListener('error', handleH5AudioError)
|
||||
h5AudioElement.value = audio
|
||||
|
||||
cleanupH5Audio = () => {
|
||||
audio.removeEventListener('loadedmetadata', handleH5LoadedMetadata)
|
||||
audio.removeEventListener('play', handleH5Play)
|
||||
audio.removeEventListener('pause', handleH5Pause)
|
||||
audio.removeEventListener('ended', handleH5Ended)
|
||||
audio.removeEventListener('timeupdate', handleH5TimeUpdate)
|
||||
audio.removeEventListener('waiting', handleH5Waiting)
|
||||
audio.removeEventListener('canplay', handleH5CanPlay)
|
||||
audio.removeEventListener('error', handleH5AudioError)
|
||||
audio.pause()
|
||||
audio.removeAttribute('src')
|
||||
audio.load()
|
||||
h5AudioElement.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const isAutoplayBlocked = (error: unknown) => {
|
||||
const name = typeof error === 'object' && error && 'name' in error
|
||||
? String((error as { name?: unknown }).name)
|
||||
: ''
|
||||
const message = error instanceof Error ? error.message : String(error || '')
|
||||
|
||||
return name === 'NotAllowedError' || /user didn't interact|not allowed|gesture/i.test(message)
|
||||
}
|
||||
|
||||
const showToast = (title: string) => {
|
||||
if (typeof uni !== 'undefined') {
|
||||
uni.showToast({
|
||||
title,
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handlePlaybackError = (message: string, shouldClose = false) => {
|
||||
isPlaying.value = false
|
||||
isLoading.value = false
|
||||
playbackError.value = message
|
||||
emit('error', currentAudio.value, message)
|
||||
showToast(message)
|
||||
|
||||
if (shouldClose) {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetPlaybackState = (clearAudio = true) => {
|
||||
isPlaying.value = false
|
||||
isLoading.value = false
|
||||
currentTime.value = 0
|
||||
duration.value = 0
|
||||
playbackError.value = ''
|
||||
|
||||
if (clearAudio) {
|
||||
currentAudio.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 播放音频
|
||||
const playAudio = (audio: AudioItem) => {
|
||||
const playAudio = async (audio: AudioItem) => {
|
||||
if (!audio.audioUrl) {
|
||||
currentAudio.value = audio
|
||||
handlePlaybackError('该讲解暂无已发布音频,当前提供图文讲解。', true)
|
||||
return false
|
||||
}
|
||||
|
||||
const isNewAudio = currentAudio.value?.id !== audio.id
|
||||
currentAudio.value = audio
|
||||
playbackError.value = ''
|
||||
isLoading.value = true
|
||||
|
||||
if (isNewAudio) {
|
||||
currentTime.value = 0
|
||||
duration.value = audio.duration || 0
|
||||
}
|
||||
|
||||
const h5Audio = h5AudioElement.value
|
||||
if (h5Audio) {
|
||||
if (h5Audio.getAttribute('src') !== audio.audioUrl) {
|
||||
h5Audio.src = audio.audioUrl
|
||||
h5Audio.load()
|
||||
}
|
||||
|
||||
try {
|
||||
await h5Audio.play()
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('H5 音频播放被拦截或失败:', error)
|
||||
isLoading.value = false
|
||||
isPlaying.value = false
|
||||
|
||||
if (isAutoplayBlocked(error)) {
|
||||
playbackError.value = '点击播放按钮开始播放'
|
||||
return false
|
||||
}
|
||||
|
||||
handlePlaybackError('音频暂时无法播放,当前提供图文讲解。', true)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (!innerAudioContext) {
|
||||
initAudio()
|
||||
}
|
||||
|
||||
if (innerAudioContext) {
|
||||
currentAudio.value = audio
|
||||
innerAudioContext.src = audio.audioUrl
|
||||
innerAudioContext.play()
|
||||
emit('play', audio)
|
||||
return true
|
||||
}
|
||||
|
||||
handlePlaybackError('当前环境暂不支持音频播放。', true)
|
||||
return false
|
||||
}
|
||||
|
||||
// 暂停音频
|
||||
const pauseAudio = () => {
|
||||
if (h5AudioElement.value) {
|
||||
h5AudioElement.value.pause()
|
||||
return
|
||||
}
|
||||
|
||||
if (innerAudioContext) {
|
||||
innerAudioContext.pause()
|
||||
if (currentAudio.value) {
|
||||
@@ -171,6 +316,11 @@ const pauseAudio = () => {
|
||||
|
||||
// 恢复播放
|
||||
const resumeAudio = () => {
|
||||
if (currentAudio.value) {
|
||||
void playAudio(currentAudio.value)
|
||||
return
|
||||
}
|
||||
|
||||
if (innerAudioContext) {
|
||||
innerAudioContext.play()
|
||||
}
|
||||
@@ -178,12 +328,22 @@ const resumeAudio = () => {
|
||||
|
||||
// 停止音频
|
||||
const stopAudio = () => {
|
||||
const h5Audio = h5AudioElement.value
|
||||
if (h5Audio) {
|
||||
isStoppingAudio = true
|
||||
h5Audio.pause()
|
||||
h5Audio.removeAttribute('src')
|
||||
h5Audio.load()
|
||||
setTimeout(() => {
|
||||
isStoppingAudio = false
|
||||
}, 0)
|
||||
}
|
||||
|
||||
if (innerAudioContext) {
|
||||
innerAudioContext.stop()
|
||||
currentAudio.value = null
|
||||
currentTime.value = 0
|
||||
duration.value = 0
|
||||
}
|
||||
|
||||
resetPlaybackState()
|
||||
}
|
||||
|
||||
// 切换播放/暂停
|
||||
@@ -199,8 +359,18 @@ const togglePlay = () => {
|
||||
|
||||
// 跳转到指定位置
|
||||
const seekTo = (percent: number) => {
|
||||
if (innerAudioContext && duration.value > 0) {
|
||||
const targetTime = (percent / 100) * duration.value
|
||||
const totalDuration = resolvedDuration.value
|
||||
if (totalDuration <= 0) return
|
||||
|
||||
const targetTime = (percent / 100) * totalDuration
|
||||
|
||||
if (h5AudioElement.value) {
|
||||
h5AudioElement.value.currentTime = targetTime
|
||||
currentTime.value = targetTime
|
||||
return
|
||||
}
|
||||
|
||||
if (innerAudioContext) {
|
||||
innerAudioContext.seek(targetTime)
|
||||
currentTime.value = targetTime
|
||||
}
|
||||
@@ -226,10 +396,65 @@ const handleClose = () => {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
const handleH5LoadedMetadata = () => {
|
||||
const audio = h5AudioElement.value
|
||||
if (!audio) return
|
||||
duration.value = Number.isFinite(audio.duration) ? audio.duration : currentAudio.value?.duration || 0
|
||||
}
|
||||
|
||||
const handleH5Play = () => {
|
||||
isPlaying.value = true
|
||||
isLoading.value = false
|
||||
playbackError.value = ''
|
||||
if (currentAudio.value) {
|
||||
emit('play', currentAudio.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleH5Pause = () => {
|
||||
isPlaying.value = false
|
||||
if (currentAudio.value) {
|
||||
emit('pause', currentAudio.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleH5Ended = () => {
|
||||
isPlaying.value = false
|
||||
isLoading.value = false
|
||||
currentTime.value = 0
|
||||
if (currentAudio.value) {
|
||||
emit('ended', currentAudio.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleH5TimeUpdate = () => {
|
||||
const audio = h5AudioElement.value
|
||||
if (!audio) return
|
||||
currentTime.value = audio.currentTime || 0
|
||||
duration.value = Number.isFinite(audio.duration) ? audio.duration : duration.value
|
||||
emit('timeUpdate', currentTime.value)
|
||||
}
|
||||
|
||||
const handleH5Waiting = () => {
|
||||
isLoading.value = true
|
||||
}
|
||||
|
||||
const handleH5CanPlay = () => {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
const handleH5AudioError = () => {
|
||||
if (isStoppingAudio || !currentAudio.value?.audioUrl) return
|
||||
handlePlaybackError('音频加载失败,当前提供图文讲解。', true)
|
||||
}
|
||||
|
||||
// 监听外部 audio 属性变化
|
||||
watch(() => props.audio, (newAudio) => {
|
||||
if (newAudio && props.visible) {
|
||||
playAudio(newAudio)
|
||||
if (newAudio && props.visible && props.autoPlay && currentAudio.value?.id !== newAudio.id) {
|
||||
void playAudio(newAudio)
|
||||
} else if (newAudio && currentAudio.value?.id !== newAudio.id) {
|
||||
currentAudio.value = newAudio
|
||||
duration.value = newAudio.duration || 0
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
@@ -241,10 +466,14 @@ watch(() => props.visible, (visible) => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initH5Audio()
|
||||
initAudio()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanupH5Audio?.()
|
||||
cleanupH5Audio = null
|
||||
|
||||
if (innerAudioContext) {
|
||||
innerAudioContext.destroy()
|
||||
innerAudioContext = null
|
||||
@@ -264,17 +493,39 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.audio-player-host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.native-audio {
|
||||
position: fixed;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.audio-player {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--museum-bg-surface);
|
||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.audio-player {
|
||||
left: 50%;
|
||||
right: auto;
|
||||
width: 430px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.mini-player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -319,6 +570,10 @@ defineExpose({
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.player-subtitle.error {
|
||||
color: #9b3d2e;
|
||||
}
|
||||
|
||||
.player-controls {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
|
||||
@@ -30,9 +30,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
withDefaults(defineProps<{
|
||||
isPlaying?: boolean
|
||||
showIndicator?: boolean
|
||||
}>(), {
|
||||
@@ -61,6 +59,12 @@ const handleClick = () => {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.fab-container {
|
||||
right: calc((100vw - 430px) / 2 + 16px);
|
||||
}
|
||||
}
|
||||
|
||||
.playing-indicator {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
@@ -1,38 +1,30 @@
|
||||
<template>
|
||||
<view class="explain-list">
|
||||
<view class="spatial-stage">
|
||||
<view class="stage-copy">
|
||||
<text class="stage-kicker">SGS 场景讲解</text>
|
||||
<text class="stage-title">{{ activeFloorLabel }} · {{ filteredCards.length }} 个讲解内容</text>
|
||||
<text class="stage-desc">
|
||||
{{ hallSummaries.length }} 个场景空间 · {{ unavailableAudioCount }} 个音频待同步
|
||||
</text>
|
||||
</view>
|
||||
<view class="stage-map">
|
||||
<view class="stage-floor">
|
||||
<text class="stage-floor-text">{{ activeFloorLabel }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(marker, index) in previewMarkers"
|
||||
:key="marker.id"
|
||||
class="stage-marker"
|
||||
:class="`marker-${index + 1}`"
|
||||
></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="content-panel">
|
||||
<view class="panel-handle"></view>
|
||||
|
||||
<view class="panel-summary">
|
||||
<view class="summary-main">
|
||||
<text class="summary-title">当前楼层 {{ activeFloorLabel }}</text>
|
||||
<text class="summary-subtitle">
|
||||
{{ filteredCards.length }} 个讲解内容 · {{ unavailableAudioCount }} 个音频待同步
|
||||
<scroll-view
|
||||
class="explain-scroll"
|
||||
:scroll-y="true"
|
||||
:show-scrollbar="false"
|
||||
:lower-threshold="120"
|
||||
@scrolltolower="handleScrollToLower"
|
||||
>
|
||||
<view class="explain-hero">
|
||||
<view class="hero-copy">
|
||||
<text class="hero-kicker">{{ isAllListMode ? '全部讲解' : '馆内讲解' }}</text>
|
||||
<text class="hero-title">{{ heroTitle }}</text>
|
||||
<text class="hero-desc">
|
||||
{{ heroDescription }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="summary-chip">
|
||||
<text class="summary-chip-text">位置预览</text>
|
||||
<view class="hero-meta-row">
|
||||
<view v-if="showBrowseAll && !isAllListMode" class="hero-browse-all" @tap="handleBrowseAll">
|
||||
<text class="hero-browse-all-text">全部</text>
|
||||
</view>
|
||||
<view class="hero-chip">
|
||||
<text class="hero-chip-text">位置预览</text>
|
||||
</view>
|
||||
<view class="hero-chip muted">
|
||||
<text class="hero-chip-text">{{ textOnlyCount }} 个图文讲解</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -57,19 +49,18 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="panel-scroll" :scroll-y="true" :show-scrollbar="false">
|
||||
<view v-if="loading" class="state-block">
|
||||
<text class="state-title">正在加载讲解内容</text>
|
||||
<text class="state-desc">稍后将展示当前楼层的场景空间与展品讲解。</text>
|
||||
</view>
|
||||
<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>
|
||||
<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 v-if="featuredCard" class="featured-section">
|
||||
<template v-else>
|
||||
<view v-if="featuredCard && !isAllListMode" class="featured-section">
|
||||
<view class="section-heading">
|
||||
<text class="section-title">今日推荐讲解</text>
|
||||
<text class="section-count">{{ featuredCard.audioStatusText }}</text>
|
||||
@@ -87,7 +78,7 @@
|
||||
<view class="featured-body">
|
||||
<text class="featured-title">{{ featuredCard.title }}</text>
|
||||
<text class="featured-meta">{{ cardMeta(featuredCard) }}</text>
|
||||
<text class="featured-summary">{{ featuredCard.summary || '讲解文案来自 SGS 场景设置数据,正式 CMS 接入后可继续替换。' }}</text>
|
||||
<text class="featured-summary">{{ featuredCard.summary || '该讲解暂无文稿。' }}</text>
|
||||
<view class="featured-actions">
|
||||
<view
|
||||
v-if="featuredCard.audioStatus === 'playable'"
|
||||
@@ -100,11 +91,11 @@
|
||||
<text class="action-text disabled">{{ featuredCard.audioStatusText }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="featuredCard.poiId"
|
||||
v-if="featuredCard.poiId && featuredCard.locationActionText"
|
||||
class="action-btn secondary"
|
||||
@tap.stop="handleLocationClick(featuredCard)"
|
||||
>
|
||||
<text class="action-text">查看位置</text>
|
||||
<text class="action-text">{{ featuredCard.locationActionText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -124,28 +115,38 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="hallSummaries.length" class="hall-section">
|
||||
<view v-if="hallSummaries.length && (!isAllListMode || activeFilter === 'hall')" class="hall-section">
|
||||
<view class="section-heading">
|
||||
<text class="section-title">场景空间</text>
|
||||
<text class="section-count">{{ hallSummaries.length }} 个空间</text>
|
||||
<text class="section-count">{{ hallSummaries.length }} 个展厅</text>
|
||||
</view>
|
||||
<view class="hall-grid">
|
||||
<view
|
||||
v-for="hall in hallSummaries"
|
||||
:key="hall.name"
|
||||
class="hall-chip"
|
||||
:class="{ active: activeHallName === hall.name }"
|
||||
@tap="handleHallFilter(hall.name)"
|
||||
>
|
||||
<text class="hall-name">{{ hall.name }}</text>
|
||||
<text class="hall-count">{{ hall.count }}</text>
|
||||
<view class="hall-chip-main">
|
||||
<text class="hall-name">{{ hall.name }}</text>
|
||||
<text class="hall-count">{{ hall.count }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="hall.id"
|
||||
class="hall-detail-action"
|
||||
@tap.stop="handleHallDetailClick(hall.id)"
|
||||
>
|
||||
<text class="hall-detail-text">查看空间</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="list-section">
|
||||
<view class="section-heading">
|
||||
<text class="section-title">讲解列表</text>
|
||||
<text class="section-count">{{ displayedCards.length }} 条</text>
|
||||
<text class="section-title">{{ listTitle }}</text>
|
||||
<text class="section-count">{{ listCountText }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="displayedCards.length" class="card-list">
|
||||
@@ -186,14 +187,34 @@
|
||||
<text class="small-action-text">详情</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="card.poiId"
|
||||
v-if="card.poiId && card.locationActionText"
|
||||
class="small-action"
|
||||
@tap.stop="handleLocationClick(card)"
|
||||
>
|
||||
<text class="small-action-text">位置</text>
|
||||
<text class="small-action-text">{{ card.locationActionText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="hasMoreCards" class="list-more">
|
||||
<view
|
||||
v-if="showBrowseAll"
|
||||
class="more-action primary"
|
||||
@tap="handleBrowseAll"
|
||||
>
|
||||
<text class="more-action-text primary">查看全部 {{ filteredCards.length }} 条</text>
|
||||
</view>
|
||||
<view
|
||||
v-else
|
||||
class="more-action"
|
||||
@tap="handleLoadMore"
|
||||
>
|
||||
<text class="more-action-text">加载更多 {{ nextLoadCount }} 条</text>
|
||||
</view>
|
||||
<text class="more-desc">
|
||||
已展示 {{ displayedCards.length }} 条,剩余 {{ remainingCardsCount }} 条
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="state-block empty">
|
||||
@@ -201,9 +222,8 @@
|
||||
<text class="state-desc">试试空间名称、标本名称、楼层或主题关键词。</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -228,13 +248,23 @@ const props = withDefaults(defineProps<{
|
||||
loading?: boolean
|
||||
error?: string
|
||||
hotSearches?: string[]
|
||||
listTitle?: string
|
||||
initialRenderLimit?: number
|
||||
pageSize?: number
|
||||
showBrowseAll?: boolean
|
||||
pageMode?: 'home' | 'all'
|
||||
}>(), {
|
||||
cards: () => [],
|
||||
exhibits: () => [],
|
||||
filters: () => [],
|
||||
loading: false,
|
||||
error: '',
|
||||
hotSearches: () => []
|
||||
hotSearches: () => [],
|
||||
listTitle: '讲解列表',
|
||||
initialRenderLimit: 16,
|
||||
pageSize: 24,
|
||||
showBrowseAll: false,
|
||||
pageMode: 'home'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -242,87 +272,19 @@ const emit = defineEmits<{
|
||||
featuredClick: [card: ExplainCardViewModel]
|
||||
audioClick: [card: ExplainCardViewModel]
|
||||
locationClick: [card: ExplainCardViewModel]
|
||||
hallClick: [hallId: string]
|
||||
filterChange: [filter: ExplainFilterOption['id']]
|
||||
search: [keyword: string]
|
||||
browseAll: []
|
||||
}>()
|
||||
|
||||
const searchInputValue = ref('')
|
||||
const activeFilter = ref<ExplainFilterOption['id']>('all')
|
||||
const activeHallName = ref('')
|
||||
const visibleCardLimit = ref(props.initialRenderLimit)
|
||||
|
||||
const cards = computed(() => props.cards.length ? props.cards : props.exhibits)
|
||||
|
||||
const playableCount = computed(() => cards.value.filter((card) => card.audioStatus === 'playable').length)
|
||||
const unavailableAudioCount = computed(() => cards.value.filter((card) => card.audioStatus === 'unavailable').length)
|
||||
|
||||
const hallSummaries = computed(() => {
|
||||
const result = new Map<string, number>()
|
||||
cards.value.forEach((card) => {
|
||||
const hallName = card.hallName || '未分配展厅'
|
||||
result.set(hallName, (result.get(hallName) || 0) + 1)
|
||||
})
|
||||
return Array.from(result.entries())
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
})
|
||||
|
||||
const activeFloorLabel = computed(() => {
|
||||
const counts = new Map<string, number>()
|
||||
cards.value.forEach((card) => {
|
||||
if (card.floorLabel) {
|
||||
counts.set(card.floorLabel, (counts.get(card.floorLabel) || 0) + 1)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(counts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] || '全馆'
|
||||
})
|
||||
|
||||
const previewMarkers = computed(() => cards.value.slice(0, 5))
|
||||
|
||||
const resolvedFilters = computed<ExplainFilterOption[]>(() => props.filters.length ? props.filters : [
|
||||
{ id: 'all', label: '全部', count: cards.value.length },
|
||||
{ id: 'hall', label: '展厅', count: hallSummaries.value.length },
|
||||
{ id: 'theme', label: '主题', count: props.hotSearches.length || hallSummaries.value.length },
|
||||
{ id: 'nearby', label: '附近', count: cards.value.filter((card) => card.floorLabel === activeFloorLabel.value).length },
|
||||
{ id: 'playable', label: '可播放', count: playableCount.value }
|
||||
])
|
||||
|
||||
const keywordMatchedCards = computed(() => {
|
||||
const keyword = searchInputValue.value.trim().toLowerCase()
|
||||
if (!keyword) return cards.value
|
||||
|
||||
return cards.value.filter((card) => [
|
||||
card.title,
|
||||
card.subtitle,
|
||||
card.summary,
|
||||
card.hallName,
|
||||
card.floorLabel,
|
||||
card.audioStatusText,
|
||||
contentTypeText(card.contentType),
|
||||
...card.badges
|
||||
].filter(Boolean).join(' ').toLowerCase().includes(keyword))
|
||||
})
|
||||
|
||||
const filteredCards = computed(() => {
|
||||
let result = keywordMatchedCards.value
|
||||
|
||||
if (activeHallName.value) {
|
||||
result = result.filter((card) => card.hallName === activeHallName.value)
|
||||
}
|
||||
|
||||
if (activeFilter.value === 'nearby') {
|
||||
result = result.filter((card) => card.floorLabel === activeFloorLabel.value)
|
||||
}
|
||||
|
||||
if (activeFilter.value === 'playable') {
|
||||
result = result.filter((card) => card.audioStatus === 'playable')
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const displayedCards = computed(() => filteredCards.value)
|
||||
const featuredCard = computed(() => cards.value.find((card) => card.audioStatus === 'playable') || cards.value[0])
|
||||
const isAllListMode = computed(() => props.pageMode === 'all')
|
||||
|
||||
const contentTypeText = (type: ExplainCardViewModel['contentType']) => {
|
||||
const textMap: Record<ExplainCardViewModel['contentType'], string> = {
|
||||
@@ -334,6 +296,133 @@ const contentTypeText = (type: ExplainCardViewModel['contentType']) => {
|
||||
return textMap[type]
|
||||
}
|
||||
|
||||
const searchableCards = computed(() => cards.value.map((card) => ({
|
||||
card,
|
||||
searchText: [
|
||||
card.title,
|
||||
card.subtitle,
|
||||
card.summary,
|
||||
card.hallName,
|
||||
card.floorLabel,
|
||||
card.audioStatusText,
|
||||
contentTypeText(card.contentType),
|
||||
...card.badges
|
||||
].filter(Boolean).join(' ').toLowerCase()
|
||||
})))
|
||||
|
||||
const summarizeHalls = (sourceCards: ExplainCardViewModel[]) => {
|
||||
const result = new Map<string, { id?: string, count: number }>()
|
||||
sourceCards.forEach((card) => {
|
||||
if (!card.hallName) return
|
||||
|
||||
const hallName = card.hallName
|
||||
const existing = result.get(hallName)
|
||||
result.set(hallName, {
|
||||
id: existing?.id || card.hallId,
|
||||
count: (existing?.count || 0) + 1
|
||||
})
|
||||
})
|
||||
return Array.from(result.entries())
|
||||
.map(([name, summary]) => ({ name, id: summary.id, count: summary.count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
}
|
||||
|
||||
const getDominantFloorLabel = (sourceCards: ExplainCardViewModel[]) => {
|
||||
const counts = new Map<string, number>()
|
||||
sourceCards.forEach((card) => {
|
||||
if (card.floorLabel) {
|
||||
counts.set(card.floorLabel, (counts.get(card.floorLabel) || 0) + 1)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(counts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] || '全馆'
|
||||
}
|
||||
|
||||
const keywordMatchedCards = computed(() => {
|
||||
const keyword = searchInputValue.value.trim().toLowerCase()
|
||||
if (!keyword) return cards.value
|
||||
|
||||
return searchableCards.value
|
||||
.filter((item) => item.searchText.includes(keyword))
|
||||
.map((item) => item.card)
|
||||
})
|
||||
|
||||
const hallSummaries = computed(() => summarizeHalls(keywordMatchedCards.value))
|
||||
const baseFilteredCards = computed(() => {
|
||||
let result = keywordMatchedCards.value
|
||||
|
||||
if (activeHallName.value) {
|
||||
result = result.filter((card) => card.hallName === activeHallName.value)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
const dominantFloorLabel = computed(() => getDominantFloorLabel(baseFilteredCards.value))
|
||||
const trimmedSearchKeyword = computed(() => searchInputValue.value.trim())
|
||||
|
||||
const filteredCards = computed(() => {
|
||||
let result = baseFilteredCards.value
|
||||
|
||||
if (activeFilter.value === 'nearby') {
|
||||
result = result.filter((card) => card.floorLabel === dominantFloorLabel.value)
|
||||
}
|
||||
|
||||
if (activeFilter.value === 'playable') {
|
||||
result = result.filter((card) => card.audioStatus === 'playable')
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const activeFloorLabel = computed(() => getDominantFloorLabel(filteredCards.value))
|
||||
const heroTitle = computed(() => {
|
||||
if (trimmedSearchKeyword.value) {
|
||||
return `搜索“${trimmedSearchKeyword.value}” · ${filteredCards.value.length} 条结果`
|
||||
}
|
||||
if (activeFilter.value === 'playable') {
|
||||
return `可播放讲解 · ${filteredCards.value.length} 条`
|
||||
}
|
||||
if (activeFilter.value === 'hall' && activeHallName.value) {
|
||||
return `${activeHallName.value} · ${filteredCards.value.length} 条`
|
||||
}
|
||||
if (activeFilter.value === 'nearby') {
|
||||
return `${activeFloorLabel.value}附近 · ${filteredCards.value.length} 条`
|
||||
}
|
||||
if (isAllListMode.value) {
|
||||
return `${filteredCards.value.length} 条讲解`
|
||||
}
|
||||
return `馆内讲解 · ${filteredCards.value.length} 条`
|
||||
})
|
||||
const heroDescription = computed(() => (
|
||||
isAllListMode.value
|
||||
? `${playableCount.value} 个可播放音频 · ${textOnlyCount.value} 个图文讲解`
|
||||
: `${hallSummaries.value.length} 个展厅 · ${playableCount.value} 个可播放音频`
|
||||
))
|
||||
const playableCount = computed(() => filteredCards.value.filter((card) => card.audioStatus === 'playable').length)
|
||||
const textOnlyCount = computed(() => filteredCards.value.filter((card) => card.audioStatus !== 'playable').length)
|
||||
const displayedCards = computed(() => filteredCards.value.slice(0, visibleCardLimit.value))
|
||||
const hasMoreCards = computed(() => displayedCards.value.length < filteredCards.value.length)
|
||||
const remainingCardsCount = computed(() => Math.max(filteredCards.value.length - displayedCards.value.length, 0))
|
||||
const nextLoadCount = computed(() => Math.min(props.pageSize, remainingCardsCount.value))
|
||||
const listCountText = computed(() => (
|
||||
hasMoreCards.value
|
||||
? `${displayedCards.value.length}/${filteredCards.value.length} 条`
|
||||
: `${filteredCards.value.length} 条`
|
||||
))
|
||||
const defaultFilters = computed<ExplainFilterOption[]>(() => [
|
||||
{ id: 'all', label: '全部', count: keywordMatchedCards.value.length },
|
||||
{ id: 'hall', label: '展厅', count: hallSummaries.value.length },
|
||||
{ id: 'nearby', label: '附近', count: baseFilteredCards.value.filter((card) => card.floorLabel === dominantFloorLabel.value).length },
|
||||
{ id: 'playable', label: '可播放', count: baseFilteredCards.value.filter((card) => card.audioStatus === 'playable').length }
|
||||
])
|
||||
const resolvedFilters = computed<ExplainFilterOption[]>(() => (
|
||||
props.filters.length ? props.filters : defaultFilters.value
|
||||
).filter((filter) => filter.id !== 'theme'))
|
||||
const featuredCard = computed(() => (
|
||||
filteredCards.value.find((card) => card.audioStatus === 'playable')
|
||||
|| filteredCards.value[0]
|
||||
))
|
||||
|
||||
const cardMeta = (card: ExplainCardViewModel) => [
|
||||
card.hallName,
|
||||
card.floorLabel,
|
||||
@@ -347,34 +436,57 @@ const normalizedBadges = (card: ExplainCardViewModel) => {
|
||||
return badges.slice(0, 3)
|
||||
}
|
||||
|
||||
const resetDisplayedCards = () => {
|
||||
visibleCardLimit.value = props.initialRenderLimit
|
||||
}
|
||||
|
||||
const handleSearchInput = () => {
|
||||
resetDisplayedCards()
|
||||
emit('search', searchInputValue.value)
|
||||
}
|
||||
|
||||
const handleSearchConfirm = () => {
|
||||
resetDisplayedCards()
|
||||
emit('search', searchInputValue.value.trim())
|
||||
}
|
||||
|
||||
const handleClearSearch = () => {
|
||||
searchInputValue.value = ''
|
||||
activeHallName.value = ''
|
||||
resetDisplayedCards()
|
||||
emit('search', '')
|
||||
}
|
||||
|
||||
const handleFilterChange = (filter: ExplainFilterOption['id']) => {
|
||||
if (filter === 'theme') {
|
||||
activeFilter.value = 'all'
|
||||
activeHallName.value = ''
|
||||
resetDisplayedCards()
|
||||
emit('filterChange', filter)
|
||||
return
|
||||
}
|
||||
|
||||
activeFilter.value = filter
|
||||
if (filter !== 'hall') {
|
||||
if (filter === 'hall') {
|
||||
activeHallName.value = activeHallName.value || hallSummaries.value[0]?.name || ''
|
||||
} else {
|
||||
activeHallName.value = ''
|
||||
}
|
||||
resetDisplayedCards()
|
||||
emit('filterChange', filter)
|
||||
}
|
||||
|
||||
const handleHallFilter = (hallName: string) => {
|
||||
activeFilter.value = 'hall'
|
||||
activeHallName.value = activeHallName.value === hallName ? '' : hallName
|
||||
resetDisplayedCards()
|
||||
emit('filterChange', 'hall')
|
||||
}
|
||||
|
||||
const handleHallDetailClick = (hallId: string) => {
|
||||
emit('hallClick', hallId)
|
||||
}
|
||||
|
||||
const handleCardClick = (card: ExplainCardViewModel) => {
|
||||
emit('exhibitClick', card)
|
||||
}
|
||||
@@ -386,6 +498,21 @@ const handleAudioClick = (card: ExplainCardViewModel) => {
|
||||
const handleLocationClick = (card: ExplainCardViewModel) => {
|
||||
emit('locationClick', card)
|
||||
}
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!hasMoreCards.value) return
|
||||
visibleCardLimit.value += props.pageSize
|
||||
}
|
||||
|
||||
const handleScrollToLower = () => {
|
||||
if (!props.showBrowseAll) {
|
||||
handleLoadMore()
|
||||
}
|
||||
}
|
||||
|
||||
const handleBrowseAll = () => {
|
||||
emit('browseAll')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -394,39 +521,37 @@ const handleLocationClick = (card: ExplainCardViewModel) => {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #edf0ea;
|
||||
background: #f6f7f4;
|
||||
}
|
||||
|
||||
.spatial-stage {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 298px;
|
||||
padding: 18px 16px 0;
|
||||
.explain-scroll {
|
||||
height: 100%;
|
||||
padding: 0 16px calc(28px + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
background: #dfe5dc;
|
||||
}
|
||||
|
||||
.stage-copy {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 220px;
|
||||
.explain-hero {
|
||||
padding: 18px 0 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.stage-kicker,
|
||||
.stage-title,
|
||||
.stage-desc {
|
||||
.hero-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-kicker,
|
||||
.hero-title,
|
||||
.hero-desc {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stage-kicker {
|
||||
.hero-kicker {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: #5e6658;
|
||||
}
|
||||
|
||||
.stage-title {
|
||||
.hero-title {
|
||||
margin-top: 4px;
|
||||
font-size: 22px;
|
||||
line-height: 30px;
|
||||
@@ -434,174 +559,67 @@ const handleLocationClick = (card: ExplainCardViewModel) => {
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.stage-desc {
|
||||
.hero-desc {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: #5e6658;
|
||||
}
|
||||
|
||||
.stage-map {
|
||||
position: absolute;
|
||||
left: 26px;
|
||||
right: 26px;
|
||||
bottom: 38px;
|
||||
height: 128px;
|
||||
border: 1px solid rgba(88, 95, 78, 0.2);
|
||||
border-radius: 8px;
|
||||
background: #f4f5ef;
|
||||
overflow: hidden;
|
||||
.hero-meta-row {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.stage-map::before,
|
||||
.stage-map::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 32px;
|
||||
right: 32px;
|
||||
height: 1px;
|
||||
background: rgba(88, 95, 78, 0.2);
|
||||
}
|
||||
|
||||
.stage-map::before {
|
||||
top: 42px;
|
||||
}
|
||||
|
||||
.stage-map::after {
|
||||
top: 84px;
|
||||
}
|
||||
|
||||
.stage-floor {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 12px;
|
||||
min-width: 44px;
|
||||
.hero-chip {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
padding: 0 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
background: #151713;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.stage-floor-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.stage-marker {
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #2f7d59;
|
||||
border: 3px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.marker-1 {
|
||||
left: 28%;
|
||||
top: 36%;
|
||||
}
|
||||
|
||||
.marker-2 {
|
||||
left: 48%;
|
||||
top: 54%;
|
||||
}
|
||||
|
||||
.marker-3 {
|
||||
left: 62%;
|
||||
top: 30%;
|
||||
}
|
||||
|
||||
.marker-4 {
|
||||
left: 74%;
|
||||
top: 62%;
|
||||
}
|
||||
|
||||
.marker-5 {
|
||||
left: 38%;
|
||||
top: 68%;
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 232px;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0 -8px 28px rgba(25, 28, 21, 0.08);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.panel-handle {
|
||||
align-self: center;
|
||||
width: 64px;
|
||||
height: 4px;
|
||||
margin-top: 8px;
|
||||
border-radius: 2px;
|
||||
background: #d9ddd3;
|
||||
}
|
||||
|
||||
.panel-summary {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 16px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.summary-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.summary-title,
|
||||
.summary-subtitle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-size: 18px;
|
||||
line-height: 25px;
|
||||
font-weight: 700;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.summary-subtitle {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: #697064;
|
||||
}
|
||||
|
||||
.summary-chip {
|
||||
flex-shrink: 0;
|
||||
.hero-browse-all {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
padding: 0 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #eef1e8;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8dbd1;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.summary-chip-text {
|
||||
.hero-browse-all-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.hero-chip.muted {
|
||||
background: #eef1e8;
|
||||
}
|
||||
|
||||
.hero-chip-text {
|
||||
font-size: 12px;
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.hero-chip.muted .hero-chip-text {
|
||||
color: #495143;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex-shrink: 0;
|
||||
margin: 0 16px 12px;
|
||||
margin: 0 0 16px;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -636,13 +654,6 @@ const handleLocationClick = (card: ExplainCardViewModel) => {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.panel-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
padding: 0 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin: 12px 0 10px;
|
||||
display: flex;
|
||||
@@ -830,32 +841,106 @@ const handleLocationClick = (card: ExplainCardViewModel) => {
|
||||
|
||||
.hall-chip {
|
||||
width: calc(50% - 4px);
|
||||
height: 42px;
|
||||
min-height: 54px;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
background: #f7f8f3;
|
||||
border: 1px solid #ebece5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.hall-chip.active {
|
||||
background: #eef4e8;
|
||||
border-color: rgba(103, 118, 90, 0.28);
|
||||
}
|
||||
|
||||
.hall-chip-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hall-name {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: #151713;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hall-count {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-weight: 700;
|
||||
color: #68725d;
|
||||
}
|
||||
|
||||
.hall-detail-action {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 8px;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dfe5d5;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.hall-detail-text {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: #546548;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
padding-bottom: calc(28px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.list-more {
|
||||
padding: 6px 0 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.more-action {
|
||||
min-width: 156px;
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8dbd1;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.more-action.primary {
|
||||
background: #151713;
|
||||
border-color: #151713;
|
||||
}
|
||||
|
||||
.more-action-text {
|
||||
font-size: 13px;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.more-action-text.primary {
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.more-desc {
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: #788072;
|
||||
}
|
||||
|
||||
.explain-card {
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,8 @@
|
||||
:show-controls="false"
|
||||
:show-poi="shouldShowIndoorPois"
|
||||
:target-focus="targetFocusRequest"
|
||||
:route-preview="routePreview"
|
||||
:show-route="showRoute"
|
||||
@floor-change="handleThreeFloorChange"
|
||||
@poi-click="handlePoiClick"
|
||||
@selection-clear="handleSelectionClear"
|
||||
@@ -49,15 +51,31 @@
|
||||
<view v-if="dimmed" class="dim-layer"></view>
|
||||
</view>
|
||||
|
||||
<view v-if="showSearch" class="guide-search-field" :style="searchFieldStyle" @tap="handleSearchTap">
|
||||
<view
|
||||
v-if="showSearch"
|
||||
class="guide-search-field"
|
||||
:class="`variant-${searchVariant}`"
|
||||
:style="searchFieldStyle"
|
||||
@tap="handleSearchTap"
|
||||
>
|
||||
<view v-if="searchVariant === 'home'" class="search-icon leading">
|
||||
<view class="search-icon-circle"></view>
|
||||
<view class="search-icon-handle"></view>
|
||||
</view>
|
||||
<text class="search-field-text">{{ searchText }}</text>
|
||||
<view class="search-icon">
|
||||
<view v-if="showVoiceIcon" class="voice-icon">
|
||||
<view class="voice-head"></view>
|
||||
<view class="voice-stem"></view>
|
||||
<view class="voice-base"></view>
|
||||
</view>
|
||||
<view v-else-if="searchVariant !== 'home'" class="search-icon">
|
||||
<view class="search-icon-circle"></view>
|
||||
<view class="search-icon-handle"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="showModeRow"
|
||||
class="guide-mode-row"
|
||||
:class="`layout-${modeLayout}`"
|
||||
:style="modeRowStyle"
|
||||
@@ -112,7 +130,16 @@
|
||||
<text class="layer-mode-label">{{ layerModeActionLabel }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="showIndoorRightControls && showFloor" class="floor-switcher" :style="floorSwitcherStyle">
|
||||
<view
|
||||
v-if="showIndoorRightControls && showFloor"
|
||||
class="floor-switcher"
|
||||
:class="`side-${floorSide}`"
|
||||
:style="floorSwitcherStyle"
|
||||
>
|
||||
<view v-if="showFloorHeader" class="floor-header">
|
||||
<text class="floor-header-icon">▱</text>
|
||||
<text class="floor-header-label">楼层</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="floor in floorLabels"
|
||||
:key="floor"
|
||||
@@ -124,6 +151,25 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="showZoomControls" class="zoom-controls" :style="zoomControlsStyle">
|
||||
<view class="zoom-btn" @tap="handleZoomClick('in')">
|
||||
<text class="zoom-text">+</text>
|
||||
</view>
|
||||
<view class="zoom-divider"></view>
|
||||
<view class="zoom-btn" @tap="handleZoomClick('out')">
|
||||
<text class="zoom-text">−</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="showLocationControl" class="location-control" :style="locationControlStyle" @tap="handleResetView">
|
||||
<view class="location-ring"></view>
|
||||
<view class="location-dot"></view>
|
||||
</view>
|
||||
|
||||
<view v-if="showMoreControl" class="more-control" :style="moreControlStyle" @tap="handleMoreTap">
|
||||
<text class="more-control-text">更多</text>
|
||||
</view>
|
||||
|
||||
<view v-if="tools.length" class="tool-stack" :style="toolStackStyle">
|
||||
<view
|
||||
v-for="tool in tools"
|
||||
@@ -153,6 +199,9 @@ import type {
|
||||
GuideModelSource,
|
||||
GuideRenderPoi
|
||||
} from '@/domain/guideModel'
|
||||
import type {
|
||||
GuideRouteResult
|
||||
} from '@/domain/museum'
|
||||
|
||||
interface GuideFloorOption {
|
||||
id: string
|
||||
@@ -192,6 +241,17 @@ const props = withDefaults(defineProps<{
|
||||
layerModeToggleTop?: string
|
||||
toolsTop?: string
|
||||
tools?: string[]
|
||||
searchVariant?: 'default' | 'home'
|
||||
showVoiceIcon?: boolean
|
||||
showModeRow?: boolean
|
||||
showFloorHeader?: boolean
|
||||
showZoomControls?: boolean
|
||||
showLocationControl?: boolean
|
||||
showMoreControl?: boolean
|
||||
floorSide?: 'left' | 'right'
|
||||
zoomControlsTop?: string
|
||||
locationControlBottom?: string
|
||||
moreControlBottom?: string
|
||||
showSearch?: boolean
|
||||
showFloor?: boolean
|
||||
showLayerModeToggle?: boolean
|
||||
@@ -207,6 +267,8 @@ const props = withDefaults(defineProps<{
|
||||
normalizeFloorId?: (labelOrId: string) => string
|
||||
dimmed?: boolean
|
||||
targetFocusRequest?: TargetPoiFocusRequest | null
|
||||
routePreview?: GuideRouteResult | null
|
||||
showRoute?: boolean
|
||||
}>(), {
|
||||
searchText: '搜索设施、展厅、入口',
|
||||
activeMode: '3d',
|
||||
@@ -219,6 +281,17 @@ const props = withDefaults(defineProps<{
|
||||
layerModeToggleTop: '154px',
|
||||
toolsTop: '406px',
|
||||
tools: () => [] as string[],
|
||||
searchVariant: 'default',
|
||||
showVoiceIcon: false,
|
||||
showModeRow: true,
|
||||
showFloorHeader: false,
|
||||
showZoomControls: false,
|
||||
showLocationControl: false,
|
||||
showMoreControl: false,
|
||||
floorSide: 'right',
|
||||
zoomControlsTop: '520px',
|
||||
locationControlBottom: '34px',
|
||||
moreControlBottom: '34px',
|
||||
showSearch: true,
|
||||
showFloor: true,
|
||||
showLayerModeToggle: false,
|
||||
@@ -233,7 +306,9 @@ const props = withDefaults(defineProps<{
|
||||
floors: () => [] as GuideFloorOption[],
|
||||
normalizeFloorId: (labelOrId: string) => labelOrId,
|
||||
dimmed: false,
|
||||
targetFocusRequest: null
|
||||
targetFocusRequest: null,
|
||||
routePreview: null,
|
||||
showRoute: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -241,6 +316,7 @@ const emit = defineEmits<{
|
||||
modeChange: [mode: '2d' | '3d']
|
||||
floorChange: [floor: string]
|
||||
toolClick: [tool: string]
|
||||
moreClick: []
|
||||
indoorViewChange: [view: IndoorViewMode]
|
||||
layerModeChange: [mode: LayerDisplayMode]
|
||||
poiClick: [poi: GuideRenderPoi]
|
||||
@@ -290,6 +366,18 @@ const toolStackStyle = computed(() => ({
|
||||
top: props.toolsTop
|
||||
}))
|
||||
|
||||
const zoomControlsStyle = computed(() => ({
|
||||
top: props.zoomControlsTop
|
||||
}))
|
||||
|
||||
const locationControlStyle = computed(() => ({
|
||||
bottom: props.locationControlBottom
|
||||
}))
|
||||
|
||||
const moreControlStyle = computed(() => ({
|
||||
bottom: props.moreControlBottom
|
||||
}))
|
||||
|
||||
const shouldShowIndoorPois = computed(() => (
|
||||
props.mapType === 'indoor'
|
||||
) || Boolean(props.targetFocusRequest))
|
||||
@@ -354,6 +442,19 @@ const handleToolClick = (tool: string) => {
|
||||
emit('toolClick', tool)
|
||||
}
|
||||
|
||||
const handleResetView = () => {
|
||||
indoorRendererRef.value?.resetCamera?.()
|
||||
emit('toolClick', '重置')
|
||||
}
|
||||
|
||||
const handleZoomClick = (direction: 'in' | 'out') => {
|
||||
emit('toolClick', direction === 'in' ? '放大' : '缩小')
|
||||
}
|
||||
|
||||
const handleMoreTap = () => {
|
||||
emit('moreClick')
|
||||
}
|
||||
|
||||
const handleThreeFloorChange = (floorId: string) => {
|
||||
const floor = props.floors.find((item) => item.id === floorId)
|
||||
emit('floorChange', floor?.label || floorId)
|
||||
@@ -475,6 +576,17 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.guide-search-field.variant-home {
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
height: 48px;
|
||||
padding: 0 14px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border: 1px solid #d7dad3;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 22px rgba(36, 49, 42, 0.08);
|
||||
}
|
||||
|
||||
.search-field-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -486,6 +598,13 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.variant-home .search-field-text {
|
||||
padding: 0 10px;
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
color: #8b9097;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: relative;
|
||||
width: 16px;
|
||||
@@ -493,6 +612,64 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-icon.leading {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.search-icon.leading .search-icon-circle {
|
||||
top: 2px;
|
||||
left: 1px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.search-icon.leading .search-icon-handle {
|
||||
left: 14px;
|
||||
top: 15px;
|
||||
width: 10px;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.voice-icon {
|
||||
position: relative;
|
||||
width: 22px;
|
||||
height: 24px;
|
||||
flex: 0 0 22px;
|
||||
}
|
||||
|
||||
.voice-head {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 1px;
|
||||
width: 10px;
|
||||
height: 15px;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid #151713;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.voice-stem {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 15px;
|
||||
width: 2px;
|
||||
height: 6px;
|
||||
background: #151713;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.voice-base {
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
bottom: 0;
|
||||
width: 12px;
|
||||
height: 2px;
|
||||
background: #151713;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.search-icon-circle {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
@@ -607,7 +784,6 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
|
||||
|
||||
.floor-switcher {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
width: 46px;
|
||||
height: auto;
|
||||
max-height: calc(100vh - 260px);
|
||||
@@ -621,6 +797,49 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
|
||||
z-index: 35;
|
||||
}
|
||||
|
||||
.floor-switcher.side-right {
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.floor-switcher.side-left {
|
||||
left: 18px;
|
||||
}
|
||||
|
||||
.floor-header {
|
||||
min-height: 50px;
|
||||
padding: 7px 4px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.floor-header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
right: 7px;
|
||||
bottom: 0;
|
||||
height: 1px;
|
||||
background: #ecede7;
|
||||
}
|
||||
|
||||
.floor-header-icon {
|
||||
font-size: 16px;
|
||||
line-height: 16px;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.floor-header-label {
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
color: #545861;
|
||||
}
|
||||
|
||||
.layer-mode-toggle {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
@@ -718,6 +937,110 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
width: 48px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid #dde5df;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
|
||||
z-index: 35;
|
||||
}
|
||||
|
||||
.zoom-btn {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.zoom-divider {
|
||||
height: 1px;
|
||||
margin: 0 9px;
|
||||
background: #ecede7;
|
||||
}
|
||||
|
||||
.zoom-text {
|
||||
font-size: 28px;
|
||||
line-height: 32px;
|
||||
font-weight: 300;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.location-control,
|
||||
.more-control {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid #dde5df;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
|
||||
z-index: 35;
|
||||
}
|
||||
|
||||
.location-control {
|
||||
left: 18px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.location-ring {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid #151713;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.location-ring::before,
|
||||
.location-ring::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: #151713;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.location-ring::before {
|
||||
left: 23px;
|
||||
top: 10px;
|
||||
width: 2px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.location-ring::after {
|
||||
left: 10px;
|
||||
top: 23px;
|
||||
width: 28px;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.location-dot {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #151713;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.more-control {
|
||||
right: 18px;
|
||||
min-width: 54px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.more-control-text {
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.guide-map-shell {
|
||||
max-width: 430px;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<view class="guide-page-frame" :class="`variant-${variant}`">
|
||||
<GuideTopTabs
|
||||
:active-tab="activeTab"
|
||||
:variant="topTabsVariant"
|
||||
:top="topTabsTop"
|
||||
@tab-change="handleTabChange"
|
||||
/>
|
||||
<view
|
||||
@@ -36,6 +38,8 @@ import type { GuideTopTab } from '@/utils/guideTopTabs'
|
||||
withDefaults(defineProps<{
|
||||
activeTab?: GuideTopTab
|
||||
variant?: 'overlay' | 'static'
|
||||
topTabsVariant?: 'underline' | 'segmented'
|
||||
topTabsTop?: string
|
||||
showBack?: boolean
|
||||
showCancel?: boolean
|
||||
backLabel?: string
|
||||
@@ -43,6 +47,8 @@ withDefaults(defineProps<{
|
||||
}>(), {
|
||||
activeTab: 'guide',
|
||||
variant: 'overlay',
|
||||
topTabsVariant: 'underline',
|
||||
topTabsTop: '0',
|
||||
showBack: false,
|
||||
showCancel: false,
|
||||
backLabel: '返回',
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<view class="guide-top-tabs">
|
||||
<view
|
||||
class="guide-top-tabs"
|
||||
:class="`variant-${variant}`"
|
||||
:style="{ top }"
|
||||
>
|
||||
<view
|
||||
v-for="tab in GUIDE_TOP_TABS"
|
||||
:key="tab.id"
|
||||
@@ -7,7 +11,10 @@
|
||||
:class="{ active: activeTab === tab.id }"
|
||||
@tap="handleTabTap(tab.id)"
|
||||
>
|
||||
<view v-if="activeTab === tab.id" class="guide-top-tab-indicator"></view>
|
||||
<view
|
||||
v-if="activeTab === tab.id && variant === 'underline'"
|
||||
class="guide-top-tab-indicator"
|
||||
></view>
|
||||
<text class="guide-top-tab-label">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -21,8 +28,12 @@ import {
|
||||
|
||||
withDefaults(defineProps<{
|
||||
activeTab?: GuideTopTab
|
||||
variant?: 'underline' | 'segmented'
|
||||
top?: string
|
||||
}>(), {
|
||||
activeTab: 'guide'
|
||||
activeTab: 'guide',
|
||||
variant: 'underline',
|
||||
top: '0'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -37,7 +48,6 @@ const handleTabTap = (tab: GuideTopTab) => {
|
||||
<style scoped lang="scss">
|
||||
.guide-top-tabs {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 44px;
|
||||
@@ -53,6 +63,19 @@ const handleTabTap = (tab: GuideTopTab) => {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.guide-top-tabs.variant-segmented {
|
||||
left: 50%;
|
||||
right: auto;
|
||||
width: 174px;
|
||||
height: 38px;
|
||||
padding: 2px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #151713;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 20px rgba(36, 49, 42, 0.08);
|
||||
transform: translateX(-50%) translateZ(0);
|
||||
}
|
||||
|
||||
.guide-top-tab {
|
||||
position: relative;
|
||||
width: 75px;
|
||||
@@ -62,6 +85,17 @@ const handleTabTap = (tab: GuideTopTab) => {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.variant-segmented .guide-top-tab {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.variant-segmented .guide-top-tab.active {
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.guide-top-tab-label {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
@@ -76,6 +110,16 @@ const handleTabTap = (tab: GuideTopTab) => {
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.variant-segmented .guide-top-tab-label {
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.variant-segmented .guide-top-tab.active .guide-top-tab-label {
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.guide-top-tab-indicator {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
|
||||
@@ -22,10 +22,10 @@
|
||||
|
||||
<view class="action-row">
|
||||
<view class="action-btn secondary" @tap="emit('viewOutdoorMap')">
|
||||
<text class="action-text">查看室外地图</text>
|
||||
<text class="action-text">室外入口参考</text>
|
||||
</view>
|
||||
<view class="action-btn primary" @tap="handleShowTargetLocation">
|
||||
<text class="action-text">重新定位目标</text>
|
||||
<text class="action-text">查看三维位置</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
421
src/components/navigation/RoutePlannerPanel.vue
Normal file
421
src/components/navigation/RoutePlannerPanel.vue
Normal file
@@ -0,0 +1,421 @@
|
||||
<template>
|
||||
<view v-if="visible" class="route-planner-panel">
|
||||
<view class="panel-handle"></view>
|
||||
<view class="panel-header">
|
||||
<view class="panel-title-group">
|
||||
<text class="panel-title">室内到室内</text>
|
||||
<text v-if="summary" class="panel-summary">{{ summary }}</text>
|
||||
</view>
|
||||
<view class="panel-clear" @tap="handleClear">
|
||||
<text class="panel-clear-text">清除</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="point-row">
|
||||
<view class="point-card" @tap="openPicker('start')">
|
||||
<view class="point-dot start"></view>
|
||||
<text class="point-label">起点</text>
|
||||
<text class="point-name" :class="{ placeholder: !startPoint }">
|
||||
{{ startPoint?.name || '选择起点' }}
|
||||
</text>
|
||||
<text v-if="startPoint" class="point-meta">{{ pointMeta(startPoint) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="swap-btn" @tap="handleSwap">
|
||||
<text class="swap-text">⇅</text>
|
||||
</view>
|
||||
|
||||
<view class="point-card" @tap="openPicker('end')">
|
||||
<view class="point-dot end"></view>
|
||||
<text class="point-label">终点</text>
|
||||
<text class="point-name" :class="{ placeholder: !endPoint }">
|
||||
{{ endPoint?.name || '选择终点' }}
|
||||
</text>
|
||||
<text v-if="endPoint" class="point-meta">{{ pointMeta(endPoint) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading || error || summary" class="panel-status">
|
||||
<text v-if="loading" class="panel-status-text">路径加载中</text>
|
||||
<text v-else-if="error" class="panel-status-text error">{{ error }}</text>
|
||||
<text v-else class="panel-status-text">{{ summary }}</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="view-route-btn"
|
||||
:class="{ disabled: !canViewRoute }"
|
||||
@tap="handlePrimaryAction"
|
||||
>
|
||||
<text class="view-route-text">{{ primaryActionText }}</text>
|
||||
</view>
|
||||
|
||||
<RoutePointPicker
|
||||
:visible="pickerMode !== ''"
|
||||
:title="pickerTitle"
|
||||
:placeholder="pickerPlaceholder"
|
||||
:options="pointOptions"
|
||||
:selected-poi-id="activeSelectedPoiId"
|
||||
:loading="pickerLoading"
|
||||
:error="pickerError"
|
||||
:empty-text="pickerEmptyText"
|
||||
@close="closePicker"
|
||||
@select="handlePointSelect"
|
||||
@search="handlePickerSearch"
|
||||
@keyword-change="handlePickerKeywordChange"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import RoutePointPicker, {
|
||||
type RoutePointOption
|
||||
} from '@/components/navigation/RoutePointPicker.vue'
|
||||
|
||||
type PickerMode = '' | 'start' | 'end'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
visible?: boolean
|
||||
startPoint?: RoutePointOption | null
|
||||
endPoint?: RoutePointOption | null
|
||||
pointOptions?: RoutePointOption[]
|
||||
hasRoutePreview?: boolean
|
||||
loading?: boolean
|
||||
error?: string
|
||||
summary?: string
|
||||
pickerLoading?: boolean
|
||||
pickerError?: string
|
||||
pickerEmptyText?: string
|
||||
}>(), {
|
||||
visible: true,
|
||||
startPoint: null,
|
||||
endPoint: null,
|
||||
pointOptions: () => [] as RoutePointOption[],
|
||||
hasRoutePreview: false,
|
||||
loading: false,
|
||||
error: '',
|
||||
summary: '',
|
||||
pickerLoading: false,
|
||||
pickerError: '',
|
||||
pickerEmptyText: '暂无匹配地点'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:startPoint': [point: RoutePointOption | null]
|
||||
'update:endPoint': [point: RoutePointOption | null]
|
||||
startChange: [point: RoutePointOption]
|
||||
endChange: [point: RoutePointOption]
|
||||
pickerOpen: [mode: 'start' | 'end']
|
||||
pickerClose: []
|
||||
search: [payload: { mode: 'start' | 'end'; keyword: string }]
|
||||
swap: []
|
||||
clear: []
|
||||
viewRoute: [payload: { startPoint: RoutePointOption; endPoint: RoutePointOption }]
|
||||
simulateGuide: []
|
||||
}>()
|
||||
|
||||
const pickerMode = ref<PickerMode>('')
|
||||
|
||||
const pickerTitle = computed(() => (
|
||||
pickerMode.value === 'start' ? '选择起点' : '选择终点'
|
||||
))
|
||||
|
||||
const pickerPlaceholder = computed(() => (
|
||||
pickerMode.value === 'start' ? '搜索起点' : '搜索终点'
|
||||
))
|
||||
|
||||
const activeSelectedPoiId = computed(() => (
|
||||
pickerMode.value === 'start'
|
||||
? props.startPoint?.poiId || ''
|
||||
: props.endPoint?.poiId || ''
|
||||
))
|
||||
|
||||
const canViewRoute = computed(() => Boolean(
|
||||
props.startPoint
|
||||
&& props.endPoint
|
||||
&& !props.loading
|
||||
&& props.startPoint.poiId !== props.endPoint.poiId
|
||||
))
|
||||
|
||||
const primaryActionText = computed(() => (
|
||||
props.hasRoutePreview ? '模拟导览' : '生成线路'
|
||||
))
|
||||
|
||||
const pointMeta = (point: RoutePointOption) => {
|
||||
return point.categoryLabel
|
||||
? `${point.floorLabel} · ${point.categoryLabel}`
|
||||
: point.floorLabel
|
||||
}
|
||||
|
||||
const openPicker = (mode: 'start' | 'end') => {
|
||||
pickerMode.value = mode
|
||||
emit('search', { mode, keyword: '' })
|
||||
emit('pickerOpen', mode)
|
||||
}
|
||||
|
||||
const closePicker = () => {
|
||||
pickerMode.value = ''
|
||||
emit('pickerClose')
|
||||
}
|
||||
|
||||
const handlePointSelect = (point: RoutePointOption) => {
|
||||
if (pickerMode.value === 'start') {
|
||||
emit('update:startPoint', point)
|
||||
emit('startChange', point)
|
||||
} else if (pickerMode.value === 'end') {
|
||||
emit('update:endPoint', point)
|
||||
emit('endChange', point)
|
||||
}
|
||||
|
||||
closePicker()
|
||||
}
|
||||
|
||||
const handlePickerSearch = (keyword: string) => {
|
||||
if (!pickerMode.value) return
|
||||
emit('search', { mode: pickerMode.value, keyword })
|
||||
}
|
||||
|
||||
const handlePickerKeywordChange = (keyword: string) => {
|
||||
if (!pickerMode.value) return
|
||||
emit('search', { mode: pickerMode.value, keyword })
|
||||
}
|
||||
|
||||
const handleSwap = () => {
|
||||
emit('swap')
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
emit('clear')
|
||||
}
|
||||
|
||||
const handleViewRoute = () => {
|
||||
if (!canViewRoute.value || !props.startPoint || !props.endPoint) return
|
||||
|
||||
emit('viewRoute', {
|
||||
startPoint: props.startPoint,
|
||||
endPoint: props.endPoint
|
||||
})
|
||||
}
|
||||
|
||||
const handlePrimaryAction = () => {
|
||||
if (!canViewRoute.value) return
|
||||
if (props.hasRoutePreview) {
|
||||
emit('simulateGuide')
|
||||
return
|
||||
}
|
||||
|
||||
handleViewRoute()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.route-planner-panel {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: calc(env(safe-area-inset-bottom) + 30px);
|
||||
padding: 10px 14px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 28px rgba(36, 49, 42, 0.12);
|
||||
z-index: 1003;
|
||||
}
|
||||
|
||||
.panel-handle {
|
||||
width: 38px;
|
||||
height: 4px;
|
||||
margin: 0 auto 10px;
|
||||
background: #d8dbd2;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-title-group {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 17px;
|
||||
line-height: 24px;
|
||||
font-weight: 700;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.panel-summary {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #696962;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.panel-clear {
|
||||
flex: 0 0 auto;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: #f5f7f2;
|
||||
border: 1px solid #e4e5df;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.panel-clear-text {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
color: #545861;
|
||||
}
|
||||
|
||||
.point-row {
|
||||
position: relative;
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-right: 52px;
|
||||
}
|
||||
|
||||
.point-card {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 52px;
|
||||
padding: 8px 10px 8px 34px;
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
column-gap: 8px;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
background: #f6f7f4;
|
||||
border: 1px solid #e5e6de;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.point-dot {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 21px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.point-dot.start {
|
||||
background: #1fbf6b;
|
||||
}
|
||||
|
||||
.point-dot.end {
|
||||
background: #ef5552;
|
||||
}
|
||||
|
||||
.point-label {
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
color: #8b8b84;
|
||||
}
|
||||
|
||||
.point-name {
|
||||
margin-top: 0;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.point-name.placeholder {
|
||||
color: #545861;
|
||||
}
|
||||
|
||||
.point-meta {
|
||||
grid-column: 2;
|
||||
margin-top: -2px;
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
color: #696962;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.swap-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 42px;
|
||||
height: 112px;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d7dad3;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.swap-text {
|
||||
font-size: 22px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.panel-status {
|
||||
margin-top: 10px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.panel-status-text {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #696962;
|
||||
}
|
||||
|
||||
.panel-status-text.error {
|
||||
color: #b44b42;
|
||||
}
|
||||
|
||||
.view-route-btn {
|
||||
margin-top: 12px;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #000000;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.view-route-btn.disabled {
|
||||
background: #d8dbd2;
|
||||
}
|
||||
|
||||
.view-route-text {
|
||||
font-size: 14px;
|
||||
line-height: 19px;
|
||||
font-weight: 500;
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.view-route-btn.disabled .view-route-text {
|
||||
color: #8b8b84;
|
||||
}
|
||||
</style>
|
||||
328
src/components/navigation/RoutePointPicker.vue
Normal file
328
src/components/navigation/RoutePointPicker.vue
Normal file
@@ -0,0 +1,328 @@
|
||||
<template>
|
||||
<view v-if="visible" class="route-point-picker">
|
||||
<view class="picker-mask" @tap="handleClose"></view>
|
||||
<view class="picker-panel">
|
||||
<view class="picker-header">
|
||||
<text class="picker-title">{{ title }}</text>
|
||||
<view class="picker-close" @tap="handleClose">
|
||||
<text class="picker-close-text">关闭</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="picker-search">
|
||||
<input
|
||||
class="picker-search-input"
|
||||
:value="keyword"
|
||||
:placeholder="placeholder"
|
||||
placeholder-class="picker-search-placeholder"
|
||||
confirm-type="search"
|
||||
@input="handleKeywordInput"
|
||||
@confirm="handleSearchConfirm"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="picker-state">
|
||||
<text class="picker-state-text">加载中</text>
|
||||
</view>
|
||||
<view v-else-if="error" class="picker-state">
|
||||
<text class="picker-state-text error">{{ error }}</text>
|
||||
</view>
|
||||
<view v-else-if="filteredOptions.length === 0" class="picker-state">
|
||||
<text class="picker-state-text">{{ emptyText }}</text>
|
||||
</view>
|
||||
<scroll-view v-else class="picker-list" scroll-y>
|
||||
<view
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.poiId"
|
||||
class="picker-option"
|
||||
:class="{ active: selectedPoiId === option.poiId }"
|
||||
@tap="handleSelect(option)"
|
||||
>
|
||||
<view class="option-main">
|
||||
<text class="option-name">{{ option.name }}</text>
|
||||
<text class="option-meta">{{ formatMeta(option) }}</text>
|
||||
</view>
|
||||
<view v-if="selectedPoiId === option.poiId" class="option-check">
|
||||
<text class="option-check-text">已选</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
export interface RoutePointOption {
|
||||
poiId: string
|
||||
name: string
|
||||
floorId: string
|
||||
floorLabel: string
|
||||
categoryLabel?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
visible?: boolean
|
||||
title?: string
|
||||
placeholder?: string
|
||||
options?: RoutePointOption[]
|
||||
selectedPoiId?: string
|
||||
initialKeyword?: string
|
||||
loading?: boolean
|
||||
error?: string
|
||||
emptyText?: string
|
||||
}>(), {
|
||||
visible: false,
|
||||
title: '选择目标',
|
||||
placeholder: '搜索目标列表',
|
||||
options: () => [] as RoutePointOption[],
|
||||
selectedPoiId: '',
|
||||
initialKeyword: '',
|
||||
loading: false,
|
||||
error: '',
|
||||
emptyText: '暂无匹配地点'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
select: [option: RoutePointOption]
|
||||
search: [keyword: string]
|
||||
keywordChange: [keyword: string]
|
||||
}>()
|
||||
|
||||
const keyword = ref(props.initialKeyword)
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
keyword.value = props.initialKeyword
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.initialKeyword,
|
||||
(value) => {
|
||||
if (!props.visible) {
|
||||
keyword.value = value
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const normalizedKeyword = computed(() => keyword.value.trim().toLowerCase())
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
if (!normalizedKeyword.value) return props.options
|
||||
|
||||
return props.options.filter((option) => {
|
||||
const fields = [
|
||||
option.name,
|
||||
option.floorId,
|
||||
option.floorLabel,
|
||||
option.categoryLabel || ''
|
||||
]
|
||||
|
||||
return fields.some((field) => field.toLowerCase().includes(normalizedKeyword.value))
|
||||
})
|
||||
})
|
||||
|
||||
const formatMeta = (option: RoutePointOption) => {
|
||||
return option.categoryLabel
|
||||
? `${option.floorLabel} · ${option.categoryLabel}`
|
||||
: option.floorLabel
|
||||
}
|
||||
|
||||
const handleKeywordInput = (event: Event) => {
|
||||
const detailValue = (event as Event & { detail?: { value?: string } }).detail?.value
|
||||
const targetValue = (event.target as HTMLInputElement | null)?.value
|
||||
|
||||
keyword.value = detailValue ?? targetValue ?? ''
|
||||
emit('keywordChange', keyword.value)
|
||||
}
|
||||
|
||||
const handleSearchConfirm = () => {
|
||||
emit('search', keyword.value)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleSelect = (option: RoutePointOption) => {
|
||||
emit('select', option)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.route-point-picker {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
.picker-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.picker-panel {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: calc(env(safe-area-inset-bottom) + 12px);
|
||||
max-height: 72vh;
|
||||
padding: 16px 14px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
|
||||
.picker-header {
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.picker-title {
|
||||
min-width: 0;
|
||||
font-size: 17px;
|
||||
line-height: 24px;
|
||||
font-weight: 700;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.picker-close {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: #f5f7f2;
|
||||
border: 1px solid #e4e5df;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.picker-close-text {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
color: #545861;
|
||||
}
|
||||
|
||||
.picker-search {
|
||||
margin-top: 14px;
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
box-sizing: border-box;
|
||||
background: var(--museum-bg-cream);
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.picker-search-input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.picker-search-placeholder {
|
||||
color: #8b8b84;
|
||||
}
|
||||
|
||||
.picker-list {
|
||||
margin-top: 10px;
|
||||
max-height: 48vh;
|
||||
}
|
||||
|
||||
.picker-option {
|
||||
min-height: 62px;
|
||||
padding: 12px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #ecede7;
|
||||
}
|
||||
|
||||
.picker-option.active {
|
||||
background: #f6f7f4;
|
||||
}
|
||||
|
||||
.option-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.option-name {
|
||||
font-size: 15px;
|
||||
line-height: 21px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.option-meta {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: #696962;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.option-check {
|
||||
flex: 0 0 auto;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #000000;
|
||||
border-radius: 13px;
|
||||
}
|
||||
|
||||
.option-check-text {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.picker-state {
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.picker-state-text {
|
||||
max-width: 260px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: #696962;
|
||||
}
|
||||
|
||||
.picker-state-text.error {
|
||||
color: #b44b42;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user