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,90 @@
<template>
<view class="bottom-tab-bar">
<view
v-for="tab in tabs"
:key="tab.id"
class="tab-item"
:class="{ active: currentTab === tab.id }"
@tap="handleTabClick(tab.id)"
>
<view class="tab-icon">
<text class="icon">{{ tab.icon }}</text>
</view>
<text class="tab-label">{{ tab.label }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface Tab {
id: string
label: string
icon: string
}
const props = defineProps<{
current?: string
}>()
const emit = defineEmits<{
change: [tabId: string]
}>()
const currentTab = ref(props.current || 'hall')
const tabs: Tab[] = [
{ id: 'hall', label: '展厅', icon: '🏛️' },
{ id: 'exhibit', label: '展品', icon: '🖼️' },
{ id: 'facility', label: '设施', icon: '🚻' },
{ id: 'route', label: '路线', icon: '🗺️' }
]
const handleTabClick = (tabId: string) => {
currentTab.value = tabId
emit('change', tabId)
}
</script>
<style scoped lang="scss">
.bottom-tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 50px;
background-color: var(--museum-bg-surface);
border-top: 1px solid var(--museum-border-light);
display: flex;
align-items: center;
justify-content: space-around;
padding-bottom: env(safe-area-inset-bottom);
z-index: 1000;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
cursor: pointer;
transition: all 0.3s;
}
.tab-icon {
font-size: 20px;
}
.tab-label {
font-size: 12px;
color: var(--museum-text-secondary);
}
.tab-item.active .tab-label {
color: var(--museum-accent);
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,514 @@
<template>
<view class="guide-map-shell">
<view class="map-layer" :class="`map-${mapType}`">
<template v-if="mapType === 'indoor'">
<!-- #ifdef H5 -->
<ThreeMap
ref="threeMapRef"
class="indoor-three-map"
:asset-base-url="indoorAssetBaseUrl"
:show-controls="false"
/>
<!-- #endif -->
<!-- #ifndef H5 -->
<view class="indoor-platform-fallback">
<text class="indoor-platform-title">室内三维导览</text>
<text class="indoor-platform-desc">当前端暂不支持 WebGL 三维展示请在 H5 端查看</text>
</view>
<!-- #endif -->
</template>
<view v-else class="outdoor-map-bg">
<TencentMap class="outdoor-map" />
</view>
<view v-if="dimmed" class="dim-layer"></view>
</view>
<view v-if="showSearch" class="guide-search-field" :style="searchFieldStyle" @tap="handleSearchTap">
<text class="search-field-text">{{ searchText }}</text>
<view class="search-icon">
<view class="search-icon-circle"></view>
<view class="search-icon-handle"></view>
</view>
</view>
<view
class="guide-mode-row"
:class="`layout-${modeLayout}`"
:style="modeRowStyle"
>
<template v-if="modeLayout === 'full'">
<view
class="mode-option"
:class="{ active: activeMode === '2d' }"
@tap="handleModeChange('2d')"
>
<text class="mode-label">室外2D</text>
</view>
<view
class="mode-option"
:class="{ active: activeMode === '3d' }"
@tap="handleModeChange('3d')"
>
<text class="mode-label">室内3D</text>
</view>
</template>
<template v-else>
<view class="compact-mode-switch">
<view
class="mode-option"
:class="{ active: activeMode === '2d' }"
@tap="handleModeChange('2d')"
>
<text class="mode-label">室外2D</text>
</view>
<view
class="mode-option"
:class="{ active: activeMode === '3d' }"
@tap="handleModeChange('3d')"
>
<text class="mode-label">室内3D</text>
</view>
</view>
<view class="mode-status-chip" :class="`tone-${modeStatusTone}`">
<text class="mode-status-text">{{ modeStatus }}</text>
</view>
</template>
</view>
<slot name="overlay"></slot>
<view v-if="showFloor" class="floor-switcher" :style="floorSwitcherStyle">
<view
v-for="floor in floors"
:key="floor"
class="floor-item"
:class="{ active: activeFloor === floor }"
@tap="handleFloorChange(floor)"
>
<text class="floor-label">{{ floor }}</text>
</view>
</view>
<view v-if="tools.length" class="tool-stack" :style="toolStackStyle">
<view
v-for="tool in tools"
:key="tool"
class="tool-btn"
@tap="handleToolClick(tool)"
>
<text class="tool-label">{{ tool }}</text>
</view>
</view>
<slot></slot>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import TencentMap from '@/components/map/TencentMap.vue'
// #ifdef H5
import ThreeMap from '@/components/map/ThreeMap.vue'
// #endif
import {
NAV_ASSET_BASE_URL,
NAV_FLOOR_OPTIONS,
navFloorIdFromLabel
} from '@/services/navAssets'
const props = withDefaults(defineProps<{
searchText?: string
activeMode?: '2d' | '3d'
activeFloor?: string
searchTop?: string
floorTop?: string
toolsTop?: string
tools?: string[]
showSearch?: boolean
showFloor?: boolean
modeTop?: string
modeLayout?: 'full' | 'status'
modeStatus?: string
modeStatusTone?: 'solid' | 'glass'
mapType?: 'indoor' | 'outdoor'
outdoorVariant?: 'home' | 'entrance'
indoorAssetBaseUrl?: string
dimmed?: boolean
}>(), {
searchText: '搜索设施、展厅、入口',
activeMode: '3d',
activeFloor: '1F',
searchTop: '16px',
floorTop: '164px',
toolsTop: '406px',
tools: () => [] as string[],
showSearch: true,
showFloor: true,
modeTop: '60px',
modeLayout: 'full',
modeStatus: '',
modeStatusTone: 'solid',
mapType: 'indoor',
outdoorVariant: 'home',
indoorAssetBaseUrl: NAV_ASSET_BASE_URL,
dimmed: false
})
const emit = defineEmits<{
searchTap: []
modeChange: [mode: '2d' | '3d']
floorChange: [floor: string]
toolClick: [tool: string]
}>()
const threeMapRef = ref<{ switchFloor?: (floorId: string) => Promise<void> | void } | null>(null)
const floors = NAV_FLOOR_OPTIONS.map((floor) => floor.label)
const searchFieldStyle = computed(() => ({
top: props.searchTop
}))
const floorSwitcherStyle = computed(() => ({
top: props.floorTop
}))
const modeRowStyle = computed(() => ({
top: props.modeTop
}))
const toolStackStyle = computed(() => ({
top: props.toolsTop
}))
const handleSearchTap = () => {
emit('searchTap')
}
const handleModeChange = (mode: '2d' | '3d') => {
emit('modeChange', mode)
}
const handleFloorChange = (floor: string) => {
const floorId = navFloorIdFromLabel(floor)
void threeMapRef.value?.switchFloor?.(floorId)
emit('floorChange', floor)
}
const handleToolClick = (tool: string) => {
emit('toolClick', tool)
}
</script>
<style scoped lang="scss">
.guide-map-shell {
position: relative;
z-index: 0;
width: 100%;
height: 100vh;
height: 100dvh;
overflow: hidden;
background: #ffffff;
color: #000000;
}
.map-layer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
background: var(--museum-bg-map);
border: 0;
box-sizing: border-box;
z-index: 0;
}
.indoor-three-map,
.outdoor-map {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.dim-layer {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.28);
z-index: 2;
}
.outdoor-map-bg {
position: absolute;
inset: 0;
overflow: hidden;
background: #f6f8f5;
}
.indoor-platform-fallback {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px;
box-sizing: border-box;
background: #f1f3ee;
text-align: center;
}
.indoor-platform-title {
font-size: 18px;
line-height: 24px;
font-weight: 700;
color: #1f2329;
}
.indoor-platform-desc {
max-width: 260px;
font-size: 13px;
line-height: 20px;
color: #626a73;
}
.guide-search-field {
position: absolute;
left: 16px;
right: 16px;
height: 36px;
display: flex;
align-items: center;
padding: 0 10px;
box-sizing: border-box;
background: var(--museum-bg-cream);
border: 1px solid #ffffff;
border-radius: 6px;
z-index: 40;
}
.search-field-text {
flex: 1;
min-width: 0;
font-size: 14px;
line-height: 19px;
color: #8b8b84;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.search-icon {
position: relative;
width: 16px;
height: 16px;
flex-shrink: 0;
}
.search-icon-circle {
position: absolute;
top: 2px;
left: 1px;
width: 9px;
height: 9px;
border: 1.4px solid #1f2329;
border-radius: 50%;
box-sizing: border-box;
}
.search-icon-handle {
position: absolute;
left: 10px;
top: 10px;
width: 8px;
height: 1.4px;
background: #1f2329;
border-radius: 2px;
transform: rotate(45deg);
transform-origin: left center;
}
.guide-mode-row {
position: absolute;
left: 16px;
right: 16px;
height: 34px;
z-index: 40;
}
.guide-mode-row.layout-full {
display: flex;
align-items: center;
padding: 3px;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #e6e6dd;
border-radius: 17px;
}
.guide-mode-row.layout-status {
display: flex;
align-items: center;
justify-content: space-between;
}
.compact-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 {
flex: 1;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 13px;
}
.layout-status .mode-option {
width: 106px;
flex: 0 0 106px;
}
.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;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #e6e6e6;
border-radius: 14px;
}
.mode-status-chip.tone-glass {
background: rgba(255, 255, 255, 0.42);
border-color: transparent;
}
.mode-status-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #696962;
}
.floor-switcher {
position: absolute;
right: 16px;
width: 46px;
height: 218px;
padding: 1px;
box-sizing: border-box;
overflow: hidden;
background: rgba(255, 255, 255, 0.84);
border: 1px solid #dde5df;
border-radius: 10px;
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
z-index: 35;
}
.floor-item {
position: relative;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
}
.floor-item:first-child {
border-radius: 7px 7px 0 0;
}
.floor-item:last-child {
border-radius: 0 0 7px 7px;
}
.floor-item:not(:last-child)::after {
content: '';
position: absolute;
left: 7px;
right: 7px;
bottom: 0;
height: 1px;
background: #ecede7;
}
.floor-item.active {
background: #000000;
}
.floor-label {
font-size: 13px;
line-height: 18px;
font-weight: 500;
color: #545861;
}
.floor-item.active .floor-label {
color: var(--museum-accent);
}
.tool-stack {
position: absolute;
right: 18px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 35;
}
.tool-btn {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.92);
border: 1px solid #e4e5df;
border-radius: 8px;
box-sizing: border-box;
}
.tool-label {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #1f2329;
}
@media (min-width: 768px) {
.guide-map-shell {
max-width: 430px;
margin: 0 auto;
box-shadow: 0 12px 36px rgba(36, 49, 42, 0.16);
}
}
</style>