Files
frontend-miniapp/src/components/explain/ExplainList.vue
2026-06-12 09:25:22 +08:00

929 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="explain-list">
<!-- 顶部筛选器 -->
<view class="explain-header">
<!-- 搜索栏触发按钮 -->
<view class="search-bar-trigger" @tap="handleOpenSearch">
<view class="search-input-wrapper">
<svg class="search-icon" width="20" height="20" viewBox="0 0 20 20" fill="none">
<circle cx="8.5" cy="8.5" r="5.5" stroke="currentColor" stroke-width="1.5"/>
<path d="M12.5 12.5L17 17" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<text class="search-placeholder">{{ searchKeyword || '搜索展品、展厅、设施...' }}</text>
</view>
</view>
<!-- 筛选按钮 -->
<view class="filter-buttons">
<view
class="filter-btn"
:class="{ active: activeFilter === 'hall' }"
@tap="handleFilterChange('hall')"
>
<text class="filter-text">按展厅</text>
</view>
<view
class="filter-btn"
:class="{ active: activeFilter === 'theme' }"
@tap="handleFilterChange('theme')"
>
<text class="filter-text">按主题</text>
</view>
</view>
</view>
<!-- 抽屉遮罩层 -->
<view
v-if="drawerVisible && !searchPanelOpen"
class="drawer-overlay"
@tap="handleCloseDrawer"
></view>
<!-- 底部可拖拽抽屉 -->
<view
class="drawer-container"
:class="{
'search-mode': searchPanelOpen,
'drawer-visible': drawerVisible,
'drawer-collapsed': !drawerVisible && !searchPanelOpen
}"
:style="drawerStyle"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<!-- 抽屉把手 -->
<view class="drawer-handle" @tap="handleToggleDrawer">
<view class="handle-bar"></view>
</view>
<!-- 抽屉内容 -->
<scroll-view
class="drawer-content"
:scroll-y="true"
:scroll-top="searchScrollTop"
:show-scrollbar="false"
>
<!-- 搜索模式内容 -->
<view v-if="searchPanelOpen" class="search-content">
<!-- 搜索框 -->
<view class="search-box">
<svg class="search-box-icon" width="20" height="20" viewBox="0 0 20 20" fill="none">
<circle cx="8.5" cy="8.5" r="5.5" stroke="currentColor" stroke-width="1.5"/>
<path d="M12.5 12.5L17 17" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<input
class="search-box-input"
type="text"
placeholder="搜索展品、展厅、设施..."
v-model="searchInputValue"
@input="handleSearchInput"
@confirm="handleSearchConfirm"
/>
<view v-if="searchInputValue" class="clear-btn" @tap="handleClearSearch">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="6" fill="#999"/>
<path d="M4.5 4.5L9.5 9.5M9.5 4.5L4.5 9.5" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</view>
</view>
<!-- 搜索结果 -->
<view v-if="searchInputValue" class="results-section">
<view v-if="searchResults.length" class="results-list">
<view
v-for="item in searchResults"
:key="item.id"
class="result-item"
@tap="handleResultClick(item)"
>
<view class="result-icon" :class="item.type">
<text>{{ getResultIcon(item.type) }}</text>
</view>
<view class="result-info">
<text class="result-name">{{ item.name }}</text>
<text class="result-desc">{{ item.desc }}</text>
</view>
</view>
</view>
<view v-else class="no-results">
<text>未找到 "{{ searchInputValue }}" 相关结果</text>
</view>
</view>
<!-- 热门搜索 (无关键词时显示) -->
<view v-if="!searchInputValue" class="hot-section">
<view class="section-header">
<text class="section-title">热门搜索</text>
</view>
<view class="hot-tags">
<view
v-for="tag in hotSearches"
:key="tag"
class="hot-tag"
@tap="handleHotTagClick(tag)"
>
<text class="hot-tag-text">{{ tag }}</text>
</view>
</view>
</view>
<!-- 搜索历史 (无关键词时显示) -->
<view v-if="!searchInputValue && searchHistory.length" class="history-section">
<view class="section-header">
<text class="section-title">搜索历史</text>
<text class="clear-history" @tap="handleClearHistory">清除</text>
</view>
<view class="history-list">
<view
v-for="(item, index) in searchHistory"
:key="index"
class="history-item"
@tap="handleHistoryClick(item)"
>
<text class="history-text">{{ item }}</text>
</view>
</view>
</view>
<!-- 关闭搜索按钮 -->
<view class="close-search" @tap="handleCloseSearch">
<text>关闭</text>
</view>
</view>
<!-- 正常模式内容 - 展品列表 -->
<view v-else class="exhibits-list">
<view
v-for="exhibit in filteredExhibits"
:key="exhibit.id"
class="exhibit-card"
@tap="handleExhibitClick(exhibit)"
>
<!-- 左侧展品图片 -->
<view class="exhibit-image">
<image
v-if="exhibit.image"
class="image"
:src="exhibit.image"
mode="aspectFill"
/>
<view v-else class="image-placeholder">
<svg class="placeholder-icon" width="24" height="24" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="18" height="18" rx="3" stroke="currentColor" stroke-width="1.5"/>
<circle cx="8.5" cy="8.5" r="2" fill="currentColor"/>
<path d="M3 16L7 12L10 15L14 11L21 18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</view>
</view>
<!-- 中间展品信息 -->
<view class="exhibit-info">
<text class="exhibit-name">{{ exhibit.name }}</text>
<text class="exhibit-floor">{{ exhibit.hall || exhibit.floor }}</text>
</view>
<!-- 右侧音频指示器 -->
<view v-if="exhibit.hasAudio" class="audio-indicator" @tap.stop="handleAudioClick(exhibit)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M12 3C12 3 14 5 14 9C14 13 12 15 12 15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M12 15C12 15 14 17 14 21C14 21 10 21 10 21C10 21 10 17 12 15Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 9C6.5 9 5 10.5 5 12C5 13.5 6.5 15 8 15C9 15 9.5 14.5 9.5 14.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M16 7C13.5 7 11.5 9 11.5 11.5C11.5 14 13.5 16 16 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</view>
</view>
</view>
<!-- 底部加载完毕提示 -->
<view class="loading-end">
<text class="end-text"> 已加载全部 </text>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type {
ExplainExhibitViewModel
} from '@/view-models/explainViewModels'
export interface Exhibit extends ExplainExhibitViewModel {
audioUrl?: string
}
interface SearchResult {
id: string
name: string
desc: string
type: 'exhibit' | 'hall' | 'facility'
}
const props = withDefaults(defineProps<{
exhibits?: Exhibit[]
hotSearches?: string[]
initialSearchHistory?: string[]
}>(), {
exhibits: () => [],
hotSearches: () => ['恐龙化石', '银杏', '海洋生物', '霸王龙', '古植物', '矿石标本', '恐龙', '化石'],
initialSearchHistory: () => ['霸王龙', '化石', '恐龙']
})
const emit = defineEmits<{
exhibitClick: [exhibit: Exhibit]
audioClick: [exhibit: Exhibit]
filterChange: [filter: 'hall' | 'theme']
search: [keyword: string]
}>()
// 状态
const searchKeyword = ref('')
const searchPanelOpen = ref(false)
const searchInputValue = ref('')
const searchScrollTop = ref(0)
const activeFilter = ref<'hall' | 'theme'>('hall') // 默认选中"按展厅"
const drawerVisible = ref(false) // 抽屉默认收起
const drawerOffset = ref(0)
// 抽屉拖拽样式 - 将 drawerOffset 绑定到 transform
const drawerStyle = computed(() => {
if (!drawerVisible.value && !searchPanelOpen.value) {
return {} // 收起状态不需要 transform
}
return {
transform: `translateY(${drawerOffset.value}px)`
}
})
// 抽屉拖拽相关
const DRAWER_MIN_OFFSET = 0
const DRAWER_MAX_OFFSET = 200
const DRAWER_SNAP_THRESHOLD = 100
let touchStartY = 0
let touchCurrentY = 0
// 热门搜索
const hotSearches = computed(() => props.hotSearches)
// 搜索历史
const searchHistory = ref<string[]>([...props.initialSearchHistory])
// 搜索数据
const allData = computed<SearchResult[]>(() => props.exhibits.map((exhibit) => ({
id: exhibit.id,
name: exhibit.name,
desc: exhibit.hall || exhibit.floor || '展项内容',
type: 'exhibit'
})))
// 搜索结果计算
const searchResults = computed(() => {
if (!searchInputValue.value.trim()) return []
const kw = searchInputValue.value.toLowerCase()
return allData.value.filter(item =>
item.name.toLowerCase().includes(kw) ||
item.desc.toLowerCase().includes(kw)
)
})
// 展品列表
const exhibits = computed(() => props.exhibits)
// 过滤展品
const filteredExhibits = computed(() => {
if (!searchKeyword.value.trim()) {
return exhibits.value
}
const keyword = searchKeyword.value.toLowerCase()
return exhibits.value.filter(exhibit =>
exhibit.name.toLowerCase().includes(keyword) ||
(exhibit.hall && exhibit.hall.toLowerCase().includes(keyword))
)
})
// 事件处理
const handleExhibitClick = (exhibit: Exhibit) => {
emit('exhibitClick', exhibit)
}
const handleAudioClick = (exhibit: Exhibit) => {
emit('audioClick', exhibit)
}
// 搜索相关方法
const handleOpenSearch = () => {
searchPanelOpen.value = true
searchScrollTop.value = 0
}
const handleCloseSearch = () => {
searchPanelOpen.value = false
searchInputValue.value = ''
}
const handleSearchInput = () => {
// 实时搜索
}
const handleSearchConfirm = () => {
if (searchInputValue.value.trim()) {
addToHistory(searchInputValue.value)
searchKeyword.value = searchInputValue.value
emit('search', searchInputValue.value)
}
}
const handleClearSearch = () => {
searchInputValue.value = ''
}
const handleHotTagClick = (tag: string) => {
searchInputValue.value = tag
handleSearchConfirm()
}
const handleHistoryClick = (history: string) => {
searchInputValue.value = history
handleSearchConfirm()
}
const handleClearHistory = () => {
searchHistory.value = []
}
const handleResultClick = (item: SearchResult) => {
if (item.type === 'exhibit') {
const exhibit = exhibits.value.find(e => e.id === item.id)
if (exhibit) {
emit('exhibitClick', exhibit)
}
}
searchPanelOpen.value = false
searchInputValue.value = ''
}
const addToHistory = (keyword: string) => {
if (!searchHistory.value.includes(keyword)) {
searchHistory.value.unshift(keyword)
if (searchHistory.value.length > 10) {
searchHistory.value.pop()
}
}
}
const getResultIcon = (type: string): string => {
const icons: Record<string, string> = {
exhibit: '🖼️',
hall: '🏛️',
facility: '📍'
}
return icons[type] || '📍'
}
const handleFilterChange = (filter: 'hall' | 'theme') => {
if (activeFilter.value === filter && drawerVisible.value) {
// 再次点击同一筛选项,收起抽屉
handleCloseDrawer()
} else {
// 切换筛选项或展开抽屉
activeFilter.value = filter
drawerVisible.value = true
drawerOffset.value = 0
emit('filterChange', filter)
}
}
// 收起抽屉
const handleCloseDrawer = () => {
drawerVisible.value = false
drawerOffset.value = 0
}
// 切换抽屉展开/收起
const handleToggleDrawer = () => {
if (drawerVisible.value) {
handleCloseDrawer()
} else {
drawerVisible.value = true
drawerOffset.value = 0
emit('filterChange', activeFilter.value)
}
}
// 抽屉拖拽处理
const handleTouchStart = (e: TouchEvent) => {
// 搜索模式下或收起状态禁用抽屉拖拽
if (searchPanelOpen.value || !drawerVisible.value) return
touchStartY = e.touches[0].clientY
touchCurrentY = touchStartY
}
const handleTouchMove = (e: TouchEvent) => {
// 搜索模式下或收起状态禁用抽屉拖拽
if (searchPanelOpen.value || !drawerVisible.value) return
touchCurrentY = e.touches[0].clientY
const deltaY = touchCurrentY - touchStartY
let newOffset = drawerOffset.value + deltaY
newOffset = Math.max(DRAWER_MIN_OFFSET, Math.min(DRAWER_MAX_OFFSET, newOffset))
drawerOffset.value = newOffset
touchStartY = touchCurrentY
}
const handleTouchEnd = () => {
// 搜索模式下或收起状态禁用抽屉拖拽
if (searchPanelOpen.value || !drawerVisible.value) return
if (drawerOffset.value < DRAWER_SNAP_THRESHOLD) {
drawerOffset.value = DRAWER_MIN_OFFSET
} else {
// 向下拖拽超过阈值,收起抽屉
handleCloseDrawer()
}
}
</script>
<style scoped lang="scss">
.explain-list {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
position: relative;
background: transparent;
// 非交互区域不透光穿透,让触摸事件落到地图上
pointer-events: none;
}
.explain-header {
width: 100%;
box-sizing: border-box;
padding: 56px 16px 12px;
background-color: transparent;
position: relative;
z-index: 10;
pointer-events: auto;
}
.search-bar-trigger {
margin-bottom: 12px;
}
.search-input-wrapper {
box-sizing: border-box;
position: relative;
display: flex;
align-items: center;
width: 100%;
background: var(--museum-bg-cream);
border: 1px solid white;
border-radius: var(--radius-mini);
padding: 9px 12px;
}
.search-icon {
flex-shrink: 0;
margin-right: 8px;
color: #333333;
}
.search-placeholder {
font-size: 14px;
color: var(--museum-text-disabled);
}
.filter-buttons {
display: flex;
gap: 8px;
position: relative;
z-index: 11;
width: 100%;
}
.filter-btn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
height: 34px;
padding: 0;
border-radius: 6px;
background-color: rgba(0, 0, 0, 0.06);
border: 1px solid transparent;
transition: all 0.2s;
&:active {
opacity: 0.7;
}
}
.filter-btn.active {
background-color: #1a1a1a;
border-color: #1a1a1a;
}
.filter-text {
font-size: 13px;
font-weight: 400;
color: #333333;
line-height: 1;
}
.filter-btn.active .filter-text {
color: #E0E100;
font-weight: 500;
}
// 抽屉遮罩层
.drawer-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.3);
z-index: 99;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
// 抽屉容器
.drawer-container {
position: absolute;
// 默认展开高度:屏幕一半
top: 50vh;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
background-color: #FFFFFF;
border-radius: 24px 24px 0 0;
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.08);
transition: top 0.35s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.3s ease;
z-index: 100;
}
// 收起状态:隐藏到屏幕底部
.drawer-container.drawer-collapsed {
top: 100vh;
opacity: 0;
pointer-events: none;
}
// 展开状态
.drawer-container.drawer-visible {
top: 50vh;
opacity: 1;
pointer-events: auto;
}
.drawer-container.search-mode {
top: 44px;
border-radius: 0;
opacity: 1;
pointer-events: auto;
}
.drawer-handle {
display: flex;
justify-content: center;
padding: 8px 0;
}
.handle-bar {
width: 66px;
height: 5px;
background-color: #E5E5E5;
border-radius: 2.5px;
}
.drawer-content {
flex: 1;
width: 100%;
box-sizing: border-box;
// 移除 overflow-y: auto避免与 scroll-view 自身滚动机制冲突
padding: 12px 16px 0;
height: 0;
::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
color: transparent;
}
}
// 搜索内容样式
.search-content {
padding-bottom: calc(20px + env(safe-area-inset-bottom));
}
.search-box {
box-sizing: border-box;
position: relative;
display: flex;
align-items: center;
width: 100%;
background: var(--museum-bg-cream);
border: 1px solid white;
border-radius: var(--radius-mini);
padding: 9px 12px;
margin-bottom: 16px;
}
.search-box-icon {
flex-shrink: 0;
margin-right: 8px;
color: #333333;
}
.search-box-input {
flex: 1;
font-size: 14px;
color: var(--museum-text-primary);
background: transparent;
border: none;
outline: none;
&::placeholder {
color: var(--museum-text-disabled);
}
}
.clear-btn {
flex-shrink: 0;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.results-section {
margin-bottom: 16px;
}
.results-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.result-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 12px;
&:active {
background-color: rgba(0, 0, 0, 0.04);
}
}
.result-icon {
width: 32px;
height: 32px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.04);
border-radius: 8px;
font-size: 16px;
}
.result-icon.exhibit {
background-color: rgba(224, 225, 0, 0.15);
}
.result-info {
flex: 1;
min-width: 0;
}
.result-name {
font-size: 15px;
font-weight: 500;
color: #1A1A1A;
display: block;
margin-bottom: 2px;
}
.result-desc {
font-size: 12px;
color: #999999;
display: block;
}
.no-results {
padding: 40px 20px;
text-align: center;
color: #999999;
font-size: 14px;
}
.hot-section {
margin-bottom: 16px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #1A1A1A;
}
.clear-history {
font-size: 12px;
color: #999;
}
.hot-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.hot-tag {
padding: 8px 14px;
background-color: rgba(0, 0, 0, 0.04);
border-radius: 18px;
&:active {
background-color: rgba(224, 225, 0, 0.3);
}
}
.hot-tag-text {
font-size: 13px;
color: #333333;
}
.history-section {
margin-bottom: 16px;
}
.history-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.history-item {
padding: 8px 14px;
background-color: rgba(0, 0, 0, 0.04);
border-radius: 18px;
&:active {
background-color: rgba(224, 225, 0, 0.3);
}
}
.history-text {
font-size: 13px;
color: #333333;
}
.close-search {
text-align: center;
padding: 12px;
margin-top: 8px;
text {
font-size: 14px;
color: #666666;
}
&:active {
text {
color: #333333;
}
}
}
// 展品列表样式
.exhibits-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.exhibit-card {
display: flex;
align-items: center;
width: 100%;
height: 64px;
background-color: #F3F3F3;
border-radius: 12px;
padding: 0 12px;
box-sizing: border-box;
transition: all 0.2s ease;
&:active {
transform: scale(0.98);
opacity: 0.9;
}
}
.exhibit-image {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 8px;
overflow: hidden;
background-color: #F0F0F0;
}
.image {
width: 100%;
height: 100%;
}
.image-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #F0F0F0;
}
.placeholder-icon {
color: #CCCCCC;
opacity: 0.8;
}
.exhibit-info {
flex: 1;
margin-left: 12px;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
}
.exhibit-name {
font-size: 15px;
font-weight: 500;
color: #1A1A1A;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exhibit-floor {
font-size: 12px;
color: #999999;
margin-top: 2px;
}
.audio-indicator {
flex-shrink: 0;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background-color: #E0E100;
border-radius: 50%;
margin-left: 12px;
&:active {
transform: scale(0.9);
}
svg {
width: 18px;
height: 18px;
color: #1A1A1A;
}
}
.loading-end {
padding: 24px 16px calc(24px + env(safe-area-inset-bottom));
text-align: center;
}
.end-text {
font-size: 12px;
color: #CCCCCC;
}
</style>