Files
EmailBill/Web/src/components/GlassBottomNav.vue
SunCheng 51172e8c5a
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 4m27s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
fix
2026-02-11 13:00:01 +08:00

314 lines
7.8 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>
<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%;
// 基础高度 + 安全区域确保在PWA中正确贴底
height: calc(95px + env(safe-area-inset-bottom, 0px));
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>