Files
EmailBill/Web/src/components/GlassBottomNav.vue

314 lines
7.8 KiB
Vue
Raw Normal View History

2026-02-09 19:25:51 +08:00
<template>
<div class="glass-nav-container">
<!-- 渐变淡化效果 -->
<div class="gradient-fade">
<!-- 药丸形导航栏 -->
<div class="nav-pill">
<div
v-for="(item, index) in navItems"
:key="item.name"
class="nav-item"
:class="{ 'nav-item-active': activeTab === item.name }"
@click="handleTabClick(item, index)"
>
<van-icon
:name="item.icon"
size="24"
:color="getIconColor(activeTab === item.name)"
/>
<span
class="nav-label"
:class="{ 'nav-label-active': activeTab === item.name }"
>
{{ item.label }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
const props = defineProps({
modelValue: {
type: String,
default: 'statistics'
},
items: {
type: Array,
default () {
return [
{ name: 'calendar', label: '日历', icon: 'notes', path: '/calendar' },
{ name: 'statistics', label: '统计', icon: 'chart-trending-o', path: '/' },
{ name: 'balance', label: '账单', icon: 'balance-list', path: '/balance' },
{ name: 'budget', label: '预算', icon: 'bill-o', path: '/budget' },
{ name: 'setting', label: '设置', icon: 'setting', path: '/setting' }
]
}
}
})
const emit = defineEmits(['update:modelValue', 'tab-click'])
const router = useRouter()
const route = useRoute()
// 使用计算属性来获取导航项,优先使用传入的 props
const navItems = computed(() => props.items)
// 响应式的活动标签状态
const activeTab = ref(props.modelValue)
// 检测当前主题(暗色或亮色)
const isDarkMode = ref(false)
const updateTheme = () => {
isDarkMode.value = window.matchMedia('(prefers-color-scheme: dark)').matches
}
// 根据主题和激活状态计算图标颜色
const getIconColor = (isActive) => {
if (isActive) {
// 激活状态:暗色模式用浅色,亮色模式用深色
return isDarkMode.value ? '#FAFAF9' : '#1A1A1A'
} else {
// 非激活状态:暗色模式用灰色,亮色模式用浅灰色
return isDarkMode.value ? '#6B6B6F' : '#9CA3AF'
}
}
// 根据当前路由路径匹配对应的导航项
const getActiveTabFromRoute = (currentPath) => {
// 规范化路径: 去掉 -v2 后缀以支持版本切换
const normalizedPath = currentPath.replace(/-v2$/, '')
const matchedItem = navItems.value.find(item => {
if (!item.path) {return false}
// 完全匹配
if (item.path === currentPath || item.path === normalizedPath) {
return true
}
// 首页特殊处理: '/' 应该匹配 '/' 和 '/statistics*'
if (item.path === '/' && (currentPath === '/' || normalizedPath === '/statistics')) {
return true
}
return false
})
return matchedItem?.name || props.modelValue
}
// 更新激活状态的通用方法
const updateActiveTab = (newTab) => {
if (newTab && newTab !== activeTab.value) {
activeTab.value = newTab
emit('update:modelValue', newTab)
}
}
// 监听外部 modelValue 的变化
watch(() => props.modelValue, (newValue) => {
updateActiveTab(newValue)
}, { immediate: true })
// 监听路由变化,自动同步底部导航高亮状态
watch(() => route.path, (newPath) => {
const matchedTab = getActiveTabFromRoute(newPath)
updateActiveTab(matchedTab)
}, { immediate: true })
const handleTabClick = (item, index) => {
activeTab.value = item.name
emit('update:modelValue', item.name)
emit('tab-click', item, index)
// 如果有路径定义,则进行路由跳转
if (item.path) {
router.push(item.path).catch(err => {
// 忽略相同路由导航错误
if (err.name !== 'NavigationDuplicated') {
console.warn('Navigation error:', err)
}
})
}
}
// 组件挂载时确保状态正确
onMounted(() => {
const matchedTab = getActiveTabFromRoute(route.path)
updateActiveTab(matchedTab)
// 初始化主题检测
updateTheme()
// 监听系统主题变化
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', updateTheme)
// 组件卸载时清理监听器
const cleanup = () => {
mediaQuery.removeEventListener('change', updateTheme)
}
// Vue 3 中可以直接在 onMounted 中返回清理函数
return cleanup
})
</script>
<style scoped lang="scss">
.glass-nav-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
width: 100%;
2026-02-11 13:00:01 +08:00
// 基础高度 + 安全区域确保在PWA中正确贴底
height: calc(95px + env(safe-area-inset-bottom, 0px));
2026-02-09 19:25:51 +08:00
z-index: 1000;
pointer-events: none;
}
/* 亮色模式渐变(默认) */
.gradient-fade {
width: 100%;
height: 100%;
background: linear-gradient(180deg, rgba(246, 247, 248, 0) 0%, rgba(246, 247, 248, 1) 50%);
padding: 12px 21px 21px 21px;
pointer-events: none;
display: flex;
align-items: flex-end;
}
/* 亮色模式导航栏(默认) - 增强透明和毛玻璃效果 */
.nav-pill {
width: 100%;
height: 62px;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
border: 1px solid rgba(229, 231, 235, 0.6);
border-radius: 31px;
padding: 4px;
display: flex;
align-items: center;
justify-content: space-around;
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.08), 0 0 0 0.5px rgba(255, 255, 255, 0.5) inset;
pointer-events: auto;
}
.nav-item {
width: 56px;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 27px;
&:active {
transform: scale(0.95);
}
}
/* 亮色模式文字颜色(默认) */
.nav-label {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 10px;
font-weight: 500;
color: #9CA3AF;
transition: all 0.2s ease;
line-height: 1.2;
}
.nav-label-active {
font-weight: 600;
color: #1A1A1A;
}
/* 适配安全区域 */
@supports (padding-bottom: env(safe-area-inset-bottom)) {
.gradient-fade {
padding-bottom: calc(21px + env(safe-area-inset-bottom));
}
}
/* 响应式适配 */
@media (max-width: 375px) {
.gradient-fade {
padding-left: 16px;
padding-right: 16px;
}
.nav-item {
width: 52px;
}
}
/* 深色模式适配 - 增强透明和毛玻璃效果 */
@media (prefers-color-scheme: dark) {
.gradient-fade {
background: linear-gradient(180deg, rgba(11, 11, 14, 0) 0%, rgba(11, 11, 14, 1) 50%);
}
.nav-pill {
background: rgba(26, 26, 30, 0.75);
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
border-color: rgba(42, 42, 46, 0.6);
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.25), 0 0 0 0.5px rgba(255, 255, 255, 0.1) inset;
}
.nav-label {
color: #6B6B6F;
}
.nav-label-active {
color: #FAFAF9;
}
}
/* iOS 样式优化 */
.nav-pill {
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
/* 毛玻璃效果增强 - 根据浏览器支持调整透明度 */
@supports (backdrop-filter: blur(40px)) {
.nav-pill {
background: rgba(255, 255, 255, 0.6);
}
@media (prefers-color-scheme: dark) {
.nav-pill {
background: rgba(26, 26, 30, 0.65);
}
}
}
@supports not (backdrop-filter: blur(40px)) {
/* 浏览器不支持毛玻璃效果时,增加不透明度以确保可读性 */
.nav-pill {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
@media (prefers-color-scheme: dark) {
.nav-pill {
background: rgba(26, 26, 30, 0.95);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
}
}
</style>