chore: freeze guide and explain update

This commit is contained in:
lyf
2026-06-24 18:00:25 +08:00
parent feb7310a46
commit 67c6609ae6
104 changed files with 3203572 additions and 40713 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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: '返回',

View File

@@ -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%;

View File

@@ -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>

View 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>

View 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>