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

819
src/pages/index/index.vue Normal file
View File

@@ -0,0 +1,819 @@
<template>
<view class="page-container">
<view class="content-tabs">
<view
v-for="tab in contentTabs"
:key="tab.id"
class="content-tab"
:class="{ active: currentTab === tab.id }"
@tap="handleTabChange(tab.id)"
>
<view v-if="currentTab === tab.id" class="content-tab-indicator"></view>
<text class="content-tab-label">{{ tab.label }}</text>
</view>
</view>
<GuideMapShell
v-if="currentTab === 'guide'"
:search-text="guideSearchText"
:active-mode="guideShellMode"
:mode-layout="'status'"
:mode-status="guideStatusLabel"
mode-status-tone="glass"
search-top="60px"
mode-top="104px"
:map-type="guideMapType"
:outdoor-variant="guideOutdoorVariant"
:indoor-asset-base-url="indoorNavAssetBaseUrl"
:show-floor="false"
:tools="[]"
@search-tap="handleGuideSearchTap"
@mode-change="handleModeChange"
>
<template #overlay>
<view v-if="guideOutdoorState === 'entrance' && !is3DMode" class="entrance-tip">
<text class="entrance-tip-text">主入口 4 分钟</text>
</view>
</template>
<view v-if="guideOutdoorState === 'home' && !is3DMode" class="home-shortcuts-card">
<view
v-for="item in shortcutItems"
:key="item.id"
class="home-shortcut-item"
:class="{ active: selectedAreaId === item.id }"
@tap="handleShortcutSelect(item)"
>
<view class="home-shortcut-icon">
<text class="home-shortcut-abbr">{{ item.abbr }}</text>
</view>
<text class="home-shortcut-label">{{ item.name }}</text>
</view>
</view>
<view
v-if="guideOutdoorState === 'home' || is3DMode"
class="guide-task-card"
:class="{ 'has-subtitle': guideTaskSubtitle }"
>
<text class="task-title">{{ guideTaskTitle }}</text>
<text v-if="guideTaskSubtitle" class="task-subtitle">{{ guideTaskSubtitle }}</text>
<view class="task-primary-btn" @tap="handleGuidePrimaryAction">
<text class="task-primary-text">{{ guidePrimaryAction }}</text>
</view>
</view>
<view v-if="guideOutdoorState === 'entrance' && !is3DMode" class="entrance-bottom-card">
<text class="entrance-card-title">主入口</text>
<text class="entrance-card-desc">
可到达入口后自动切换也可手动进入室内3D并选择楼层/区域
</text>
<view class="entrance-actions">
<view class="entrance-btn secondary" @tap="handleSwitchEntrance">
<text class="entrance-btn-text">切换入口</text>
</view>
<view class="entrance-btn primary" @tap="handleEnter3DMode">
<text class="entrance-btn-text">进入室内3D</text>
</view>
</view>
</view>
</GuideMapShell>
<view v-else-if="currentTab === 'explain'" class="explain-page">
<ExplainList
@exhibit-click="handleExplainExhibitClick"
@featured-click="handleExplainFeaturedClick"
@audio-click="handleAudioClick"
/>
<FloatingAudioButton
:is-playing="isAudioPlaying"
@click="handleFloatingButtonClick"
/>
</view>
<!-- 音频播放器 -->
<AudioPlayer
:visible="showAudioPlayer"
:audio="currentAudio"
:auto-play="true"
@update:visible="showAudioPlayer = $event"
@play="handleAudioPlay"
@pause="handleAudioPause"
@ended="handleAudioEnded"
/>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
import ExplainList from '@/components/explain/ExplainList.vue'
import FloatingAudioButton from '@/components/audio/FloatingAudioButton.vue'
import AudioPlayer, { type AudioItem } from '@/components/audio/AudioPlayer.vue'
import {
NAV_ASSET_BASE_URL,
NAV_ROUTE_UNAVAILABLE_MESSAGE
} from '@/services/navAssets'
// 3D 模式状态
const is3DMode = ref(false)
const guideOutdoorState = ref<'home' | 'entrance'>('home')
// 状态
const searchKeyword = ref('')
const currentTab = ref('guide')
const searchBarFocused = ref(false)
const showMarkerDetail = ref(false)
const showAreaSelector = ref(false)
const selectedAreaId = ref('service')
const contentTabs = [
{ id: 'guide', label: '导览' },
{ id: 'explain', label: '讲解' }
]
const indoorNavAssetBaseUrl = NAV_ASSET_BASE_URL
const shortcutItems = [
{ id: 'toilet', name: '卫生间', abbr: '卫' },
{ id: 'elevator', name: '电梯', abbr: '电' },
{ id: 'service', name: '服务台', abbr: '服' },
{ id: 'entrance', name: '入口', abbr: '入' },
{ id: 'accessible', name: '无障碍', abbr: '无' }
]
// 音频播放器状态
const showAudioPlayer = ref(false)
const isAudioPlaying = ref(false)
const currentAudio = ref<AudioItem | null>(null)
// 选中的标记详情
interface MarkerDetail {
id: string | number
name: string
subtitle?: string
floor?: string
hall?: string
exhibitCount?: number
image?: string
type: 'exhibit' | 'hall' | 'facility'
audioUrl?: string
}
const selectedMarker = ref<MarkerDetail | null>(null)
// 地图标记数据 (与 TencentMap.vue 中的 markers 对应)
const markerDataMap: Record<string, MarkerDetail> = {
0: { id: 0, name: '深圳自然博物馆', type: 'hall', floor: '全馆' },
1: { id: 1, name: '主入口', type: 'facility', floor: '1F' },
'entrance-1': { id: 'entrance-1', name: '主入口', type: 'facility', floor: '1F' }
}
// Tab 切换处理
const handleTabChange = (tabId: string) => {
currentTab.value = tabId
console.log('切换标签:', tabId)
// 关闭可能打开的面板
closeAreaSelector()
closeMarkerDetail()
searchBarFocused.value = false
}
// 搜索处理
const handleSearchFocus = () => {
searchBarFocused.value = true
}
const handleSearchBlur = () => {
searchBarFocused.value = false
}
const handleSearch = (keyword: string) => {
console.log('搜索:', keyword)
}
const handleGuideSearchTap = () => {
uni.navigateTo({
url: '/pages/search/index'
})
}
const closeSearchPanel = () => {
searchBarFocused.value = false
showAreaSelector.value = false
}
// 区域选择处理
const handleAreaClick = (area: any) => {
console.log('选择区域:', area)
}
const handleShortcutSelect = (area: { id: string; name: string; abbr: string }) => {
selectedAreaId.value = area.id
uni.navigateTo({
url: `/pages/search/index?keyword=${encodeURIComponent(area.name)}`
})
}
const handleAreaChange = (areaId: string) => {
console.log('切换设施快捷入口:', areaId)
}
const handleAreaFloorChange = (floor: string) => {
console.log('切换楼层:', floor)
}
const closeAreaSelector = () => {
showAreaSelector.value = false
searchBarFocused.value = false
}
// 地图标记处理
const handleMarkerClick = (markerId: number) => {
console.log('点击地图标记:', markerId)
// 获取标记详情数据
const markerInfo = markerDataMap[markerId]
if (markerInfo) {
selectedMarker.value = markerInfo
showMarkerDetail.value = true
}
}
const closeMarkerDetail = () => {
showMarkerDetail.value = false
selectedMarker.value = null
}
const handleNavigateToMarker = (marker: MarkerDetail) => {
console.log('导航到标记位置:', marker)
uni.showToast({
title: NAV_ROUTE_UNAVAILABLE_MESSAGE,
icon: 'none'
})
}
const handleViewMarkerDetail = (marker: MarkerDetail) => {
console.log('查看标记详情:', marker)
if (marker.type === 'exhibit') {
uni.navigateTo({
url: `/pages/exhibit/detail?id=${marker.id}`
})
} else if (marker.type === 'hall') {
uni.navigateTo({
url: `/pages/hall/detail?id=${marker.id}`
})
}
closeMarkerDetail()
}
const handlePlayMarkerAudio = (marker: MarkerDetail) => {
console.log('播放标记音频:', marker)
uni.showToast({
title: '开始播放讲解',
icon: 'none'
})
}
const handleCollectMarker = (marker: MarkerDetail) => {
console.log('收藏标记:', marker)
}
const handleFloorChange = (floor: string) => {
console.log('切换楼层:', floor)
}
// 进入 3D 室内模式
const handleEnter3DMode = () => {
console.log('进入 3D 室内模式')
is3DMode.value = true
guideOutdoorState.value = 'home'
}
const handleModeChange = (mode: '2d' | '3d') => {
is3DMode.value = mode === '3d'
if (mode === '2d') {
guideOutdoorState.value = 'home'
}
}
const guideStatusLabel = computed(() => {
if (is3DMode.value) {
return '馆内定位'
}
if (guideOutdoorState.value === 'entrance') {
return '推荐入口'
}
return '馆外定位'
})
const guideTaskTitle = computed(() => {
if (is3DMode.value) {
return '规划馆内路线'
}
return '从室外进入馆内'
})
const guideTaskSubtitle = computed(() => {
if (is3DMode.value) {
return '选择目标设施后,系统会优先推荐最近路径'
}
return ''
})
const guidePrimaryAction = computed(() => {
if (is3DMode.value) {
return '选择目标地点'
}
return '查看推荐入口'
})
const handleGuidePrimaryAction = () => {
if (is3DMode.value) {
uni.navigateTo({
url: '/pages/search/index'
})
return
}
guideOutdoorState.value = 'entrance'
}
const handleSwitchEntrance = () => {
guideOutdoorState.value = 'home'
}
const guideShellMode = computed(() => (is3DMode.value ? '3d' : '2d'))
const guideMapType = computed(() => (is3DMode.value ? 'indoor' : 'outdoor'))
const guideOutdoorVariant = computed(() => (
guideOutdoorState.value === 'entrance' ? 'entrance' : 'home'
))
const guideSearchText = computed(() => {
if (is3DMode.value) {
return '搜索设施、展厅、入口'
}
if (guideOutdoorState.value === 'entrance') {
return '选择入口或停车位置'
}
return '搜索设施、展厅、入口'
})
// 音频相关处理
const handleAudioClick = (exhibit: any) => {
console.log('点击音频播放:', exhibit)
currentAudio.value = {
id: exhibit.id,
name: exhibit.name,
audioUrl: exhibit.audioUrl || 'https://example.com/audio.mp3',
image: exhibit.image
}
showAudioPlayer.value = true
}
const handleFloatingButtonClick = () => {
if (showAudioPlayer.value) {
showAudioPlayer.value = false
} else if (currentAudio.value) {
showAudioPlayer.value = true
} else {
uni.showToast({
title: '请先选择一个展品',
icon: 'none'
})
}
}
const handleAudioPlay = (audio: AudioItem) => {
console.log('开始播放:', audio)
isAudioPlaying.value = true
}
const handleAudioPause = (audio: AudioItem) => {
console.log('暂停播放:', audio)
isAudioPlaying.value = false
}
const handleAudioEnded = (audio: AudioItem) => {
console.log('播放结束:', audio)
isAudioPlaying.value = false
}
// 讲解页面处理
const handleExplainExhibitClick = (exhibit: any) => {
console.log('点击讲解展品:', exhibit)
uni.navigateTo({
url: `/pages/exhibit/detail?id=${exhibit.id}`
})
}
const handleExplainFeaturedClick = (exhibit: any) => {
console.log('点击推荐讲解:', exhibit)
// 播放音频讲解
if (exhibit.hasAudio) {
currentAudio.value = {
id: exhibit.id,
name: exhibit.name,
audioUrl: exhibit.audioUrl || 'https://example.com/audio.mp3',
image: exhibit.image
}
showAudioPlayer.value = true
} else {
uni.showToast({
title: '该展品暂无讲解音频',
icon: 'none'
})
}
}
</script>
<style scoped lang="scss">
.page-container {
position: relative;
width: 100%;
height: 100vh;
height: 100dvh;
overflow: hidden;
isolation: isolate;
background: var(--museum-bg-map);
}
.explain-page {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #f6f7f4;
z-index: 10;
}
.entrance-tip {
position: absolute;
top: 154px;
left: 30px;
width: 168px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.96);
border: 1px solid #e5e6de;
border-radius: 12px;
z-index: 45;
}
.entrance-tip-text {
font-size: 14px;
line-height: 19px;
font-weight: 500;
color: #1f2329;
}
.home-shortcuts-card {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 134px);
height: 84px;
padding: 16px 10px 8px;
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.96);
border: 1px solid #ffffff;
border-radius: 16px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08);
z-index: 45;
}
.home-shortcut-item {
width: 62px;
height: 60px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 4px;
}
.home-shortcut-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #d9dcd2;
border-radius: 10px;
}
.home-shortcut-abbr {
font-size: 15px;
line-height: 20px;
font-weight: 700;
color: #1f2329;
}
.home-shortcut-label {
font-size: 12px;
line-height: 16px;
color: #1f2329;
text-align: center;
}
.home-shortcut-item.active {
.home-shortcut-icon {
background: #000000;
border-color: #000000;
}
.home-shortcut-abbr {
color: var(--museum-accent);
}
}
.content-tabs {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.96);
border-bottom: 1px solid #eceee8;
box-sizing: border-box;
z-index: 2000;
transform: translateZ(0);
backface-visibility: hidden;
will-change: transform;
}
.content-tab {
position: relative;
width: 75px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
.content-tab-label {
position: relative;
z-index: 2;
font-size: 16px;
line-height: 22px;
font-weight: 400;
color: #6b7280;
}
.content-tab.active .content-tab-label {
font-weight: 500;
color: #1f2329;
}
.content-tab-indicator {
position: absolute;
left: 50%;
top: 26px;
width: 39px;
height: 6px;
background: var(--museum-accent);
border-radius: 3px;
transform: translateX(-50%);
z-index: 1;
}
.search-bar-wrapper {
position: absolute;
top: 44px;
left: 0;
right: 0;
z-index: 1003;
}
.guide-mode-row {
position: absolute;
top: 104px;
left: 16px;
right: 16px;
height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
z-index: 1003;
}
.mode-switch {
width: 220px;
height: 34px;
display: flex;
align-items: center;
padding: 4px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(230, 230, 230, 0.5);
border-radius: 17px;
}
.mode-option {
width: 106px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 13px;
}
.mode-option.active {
background: #000000;
}
.mode-label {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #1f2329;
}
.mode-option.active .mode-label {
color: var(--museum-accent);
}
.mode-status-chip {
width: 111px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.42);
border-radius: 14px;
}
.mode-status-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #696962;
}
.area-selector-wrapper {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 134px);
z-index: 1003;
}
.guide-task-card {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 30px);
height: 94px;
padding: 16px 16px 10px;
display: flex;
flex-direction: column;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #ffffff;
border-radius: 16px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08);
z-index: 1003;
}
.guide-task-card.has-subtitle {
height: auto;
min-height: 128px;
}
.task-title {
flex-shrink: 0;
font-size: 17px;
line-height: 24px;
font-weight: 700;
color: #000000;
}
.task-subtitle {
margin-top: 4px;
min-height: 20px;
font-size: 12px;
line-height: 18px;
color: #696962;
}
.task-primary-btn {
flex-shrink: 0;
margin-top: 4px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
background: #000000;
border-radius: 8px;
}
.task-primary-text {
font-size: 14px;
line-height: 19px;
font-weight: 500;
color: var(--museum-accent);
}
.entrance-bottom-card {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 34px);
height: 158px;
padding: 18px 16px 14px;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #ffffff;
border-radius: 16px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08);
z-index: 45;
}
.entrance-card-title {
display: block;
font-size: 17px;
line-height: 24px;
font-weight: 700;
color: #000000;
}
.entrance-card-desc {
display: block;
width: 305px;
margin-top: 10px;
font-size: 13px;
line-height: 18px;
color: #6b7178;
}
.entrance-actions {
position: absolute;
left: 16px;
right: 16px;
bottom: 14px;
display: flex;
gap: 12px;
}
.entrance-btn {
height: 40px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 8px;
}
.entrance-btn.secondary {
width: 126px;
background: #ffffff;
border: 1px solid #000000;
}
.entrance-btn.primary {
width: 181px;
background: #000000;
border: 1px solid #000000;
}
.entrance-btn-text {
font-size: 14px;
line-height: 19px;
font-weight: 500;
color: #000000;
}
.entrance-btn.primary .entrance-btn-text {
color: var(--museum-accent);
}
@media (min-width: 768px) {
.page-container {
max-width: 430px;
margin: 0 auto;
box-shadow: 0 12px 36px rgba(36, 49, 42, 0.16);
}
}
</style>