chore: initialize frontend miniapp repository

This commit is contained in:
lyf
2026-06-09 21:08:45 +08:00
commit a90f63cef0
107 changed files with 60454 additions and 0 deletions

View File

@@ -0,0 +1,937 @@
<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'
export interface Exhibit {
id: string
name: string
artist?: string
hall?: string
floor?: string
image?: string
hasAudio?: boolean
audioUrl?: string
}
interface SearchResult {
id: string
name: string
desc: string
type: 'exhibit' | 'hall' | 'facility'
}
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 = ['恐龙化石', '银杏', '海洋生物', '霸王龙', '古植物', '矿石标本', '恐龙', '化石']
// 搜索历史
const searchHistory = ref<string[]>(['霸王龙', '化石', '恐龙'])
// 搜索数据
const allData: SearchResult[] = [
{ id: '1', name: '霸王龙化石骨架', desc: '恐龙厅 · 2F', type: 'exhibit' },
{ id: '2', name: '银杏化石', desc: '演化厅 · B1', type: 'exhibit' },
{ id: '3', name: '三叶虫化石', desc: '演化厅 · B1', type: 'exhibit' },
{ id: '4', name: '腕龙骨骼', desc: '恐龙厅 · 2F', type: 'exhibit' },
{ id: '5', name: '猛犸象象牙', desc: '人类厅 · 3F', type: 'exhibit' },
{ id: 'h1', name: '恐龙厅', desc: '2F · 25件展品', type: 'hall' },
{ id: 'h2', name: '演化厅', desc: 'B1 · 30件展品', type: 'hall' },
{ id: 'h3', name: '生物厅', desc: '3F · 18件展品', type: 'hall' },
{ id: 'f1', name: '洗手间', desc: '1F', type: 'facility' },
{ id: 'f2', name: '咖啡厅', desc: '1F', type: 'facility' },
]
// 搜索结果计算
const searchResults = computed(() => {
if (!searchInputValue.value.trim()) return []
const kw = searchInputValue.value.toLowerCase()
return allData.filter(item =>
item.name.toLowerCase().includes(kw) ||
item.desc.toLowerCase().includes(kw)
)
})
// 展品列表
const exhibits = ref<Exhibit[]>([
{ id: '1', name: '银杏化石', hall: '演化厅 B1', hasAudio: true, audioUrl: '' },
{ id: '2', name: '三叶虫化石', hall: '演化厅 B1', hasAudio: true, audioUrl: '' },
{ id: '3', name: '腕龙骨骼', hall: '恐龙厅 2F', hasAudio: true, audioUrl: '' },
{ id: '4', name: '始祖鸟标本', hall: '演化厅 B1', hasAudio: false, audioUrl: '' },
{ id: '5', name: '猛犸象象牙', hall: '人类厅 3F', hasAudio: true, audioUrl: '' },
{ id: '6', name: '珊瑚礁标本', hall: '生物厅 3F', hasAudio: false, audioUrl: '' },
{ id: '7', name: '剑龙化石', hall: '恐龙厅 2F', hasAudio: true, audioUrl: '' },
{ id: '8', name: '鹦鹉螺化石', hall: '演化厅 B1', hasAudio: true, audioUrl: '' }
])
// 过滤展品
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>