Files
CheckInApp/frontend/src/components/Navbar.vue
T

470 lines
17 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="navbar-wrapper sticky top-0 z-50"
:style="{
backgroundColor: isDark ? '#1c1b1f' : '#ffffff',
boxShadow: isDark
? '0 2px 8px rgba(0, 0, 0, 0.6), 0 4px 16px rgba(0, 0, 0, 0.4)'
: '0 2px 8px rgba(0, 0, 0, 0.15), 0 4px 16px rgba(0, 0, 0, 0.1)',
borderBottom: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.08)',
}"
>
<nav class="max-w-7xl mx-auto px-6 py-4">
<div class="flex items-center justify-between">
<!-- Logo and Brand -->
<div class="flex items-center space-x-8">
<router-link to="/" class="flex items-center space-x-3 group">
<div
class="w-10 h-10 bg-gradient-to-br from-primary-500 to-secondary-500 rounded-md3 flex items-center justify-center transform group-hover:scale-110 transition-transform"
>
<CheckCircleOutlined class="text-white text-xl" />
</div>
<span class="text-xl font-bold text-gradient">接龙自动打卡</span>
</router-link>
<!-- Desktop Navigation Links -->
<div v-if="!isMobile" class="hidden md:flex items-center space-x-2">
<router-link v-slot="{ isActive }" to="/dashboard" custom>
<a
:class="[
'nav-button px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
isActive
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
: 'text-on-surface',
]"
@click="router.push('/dashboard')"
>
<div class="flex items-center space-x-2">
<HomeOutlined />
<span>仪表盘</span>
</div>
</a>
</router-link>
<router-link v-slot="{ isActive }" to="/tasks" custom>
<a
:class="[
'nav-button px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
isActive
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
: 'text-on-surface',
]"
@click="router.push('/tasks')"
>
<div class="flex items-center space-x-2">
<FileTextOutlined />
<span>任务管理</span>
</div>
</a>
</router-link>
<router-link v-slot="{ isActive }" to="/records" custom>
<a
:class="[
'nav-button px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
isActive
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
: 'text-on-surface',
]"
@click="router.push('/records')"
>
<div class="flex items-center space-x-2">
<UnorderedListOutlined />
<span>打卡记录</span>
</div>
</a>
</router-link>
<!-- Admin Dropdown Menu -->
<a-dropdown v-if="authStore.isAdmin" :trigger="['hover']">
<a
:class="[
'admin-nav-button px-4 py-2 rounded-full font-medium transition-all flex items-center space-x-2 cursor-pointer',
isAdminPath
? 'bg-secondary-100 dark:bg-secondary-900/30 text-secondary-700 dark:text-secondary-400'
: 'text-on-surface',
]"
>
<SettingOutlined />
<span>管理后台</span>
<DownOutlined class="text-xs" />
</a>
<template #overlay>
<a-menu>
<a-menu-item key="users" @click="router.push('/admin/users')">
<UserOutlined />
<span class="ml-2">用户管理</span>
</a-menu-item>
<a-menu-item key="templates" @click="router.push('/admin/templates')">
<FileOutlined />
<span class="ml-2">模板管理</span>
</a-menu-item>
<a-menu-item key="records" @click="router.push('/admin/records')">
<CheckSquareOutlined />
<span class="ml-2">打卡记录</span>
</a-menu-item>
<a-menu-item key="stats" @click="router.push('/admin/stats')">
<BarChartOutlined />
<span class="ml-2">统计信息</span>
</a-menu-item>
<a-menu-item key="logs" @click="router.push('/admin/logs')">
<FileTextOutlined />
<span class="ml-2">系统日志</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
<!-- User Menu & Mobile Hamburger -->
<div class="flex items-center space-x-2 md:space-x-4">
<!-- Token Status Indicator (Desktop & Mobile) -->
<a-tooltip v-if="showTokenStatus" :title="tokenStatusTooltip">
<div
class="navbar-item px-2 md:px-3 py-1.5 rounded-full cursor-pointer transition-all flex items-center space-x-1 md:space-x-2"
@click="handleTokenStatusClick"
>
<a-badge :status="tokenBadgeStatus" />
<ClockCircleOutlined :class="[tokenIconClass, 'text-sm md:text-base']" />
<span class="text-xs md:text-sm hidden sm:inline">{{ tokenBadgeText }}</span>
<!-- 过期时显示刷新按钮响应式设计 -->
<a-button
v-if="remainingMinutes !== null && remainingMinutes < 0"
type="primary"
size="small"
class="!text-xs !px-2 md:!px-3"
@click.stop="handleRefreshToken"
>
<span class="hidden sm:inline">刷新</span>
<ReloadOutlined class="sm:hidden" />
</a-button>
</div>
</a-tooltip>
<!-- Theme Toggle Button -->
<a-tooltip :title="isDark ? '切换到亮色模式' : '切换到暗色模式'" placement="bottom">
<button
type="button"
class="navbar-item w-10 h-10 rounded-full flex items-center justify-center transition-all"
@click="toggleTheme"
>
<BulbFilled v-if="isDark" class="text-xl text-yellow-400" />
<BulbOutlined v-else class="text-xl text-on-surface" />
</button>
</a-tooltip>
<!-- Desktop User Menu -->
<a-dropdown v-if="!isMobile" :trigger="['hover']">
<a
class="navbar-item flex items-center space-x-3 px-4 py-2 rounded-full transition-all cursor-pointer"
>
<a-avatar :style="{ backgroundColor: '#f56a00' }">
{{ userInitial }}
</a-avatar>
<span class="hidden md:block font-medium text-on-surface">{{
authStore.user?.alias || '用户'
}}</span>
<DownOutlined class="text-xs text-on-surface-variant" />
</a>
<template #overlay>
<a-menu>
<a-menu-item key="info" disabled>
<div class="px-2 py-1">
<p class="text-sm font-medium text-on-surface">{{ authStore.user?.alias }}</p>
<p class="text-xs text-on-surface-variant mt-1">
{{ authStore.isAdmin ? '管理员' : '普通用户' }}
</p>
</div>
</a-menu-item>
<a-menu-divider />
<a-menu-item key="settings" @click="router.push('/settings')">
<SettingOutlined />
<span class="ml-2">个人设置</span>
</a-menu-item>
<a-menu-item key="logout" danger @click="handleLogout">
<LogoutOutlined />
<span class="ml-2">退出登录</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<!-- Mobile Hamburger Button -->
<button
v-if="isMobile"
type="button"
class="navbar-item w-10 h-10 rounded-full flex items-center justify-center transition-all"
@click="drawerVisible = true"
>
<MenuOutlined class="text-xl text-on-surface" />
</button>
</div>
</div>
</nav>
<!-- Mobile Drawer -->
<a-drawer v-model:open="drawerVisible" placement="left" :width="280" title="菜单">
<!-- User Info in Drawer -->
<div class="mb-6 pb-4 border-b border-outline-variant">
<div class="flex items-center space-x-3">
<a-avatar :size="48" :style="{ backgroundColor: '#f56a00' }">
{{ userInitial }}
</a-avatar>
<div>
<p class="font-medium text-on-surface">{{ authStore.user?.alias || '用户' }}</p>
<p class="text-xs text-on-surface-variant">
{{ authStore.isAdmin ? '管理员' : '普通用户' }}
</p>
</div>
</div>
</div>
<!-- Mobile Navigation Menu -->
<a-menu mode="inline" :selected-keys="[currentMenuKey]" @click="handleMenuClick">
<a-menu-item key="dashboard">
<template #icon><HomeOutlined /></template>
仪表盘
</a-menu-item>
<a-menu-item key="tasks">
<template #icon><FileTextOutlined /></template>
任务管理
</a-menu-item>
<a-menu-item key="records">
<template #icon><UnorderedListOutlined /></template>
打卡记录
</a-menu-item>
<!-- Admin Menu Group -->
<a-sub-menu v-if="authStore.isAdmin" key="admin">
<template #icon><SettingOutlined /></template>
<template #title>管理后台</template>
<a-menu-item key="admin-users">
<template #icon><UserOutlined /></template>
用户管理
</a-menu-item>
<a-menu-item key="admin-templates">
<template #icon><FileOutlined /></template>
模板管理
</a-menu-item>
<a-menu-item key="admin-records">
<template #icon><CheckSquareOutlined /></template>
打卡记录
</a-menu-item>
<a-menu-item key="admin-stats">
<template #icon><BarChartOutlined /></template>
统计信息
</a-menu-item>
<a-menu-item key="admin-logs">
<template #icon><FileTextOutlined /></template>
系统日志
</a-menu-item>
</a-sub-menu>
<a-menu-divider />
<a-menu-item key="settings">
<template #icon><SettingOutlined /></template>
个人设置
</a-menu-item>
<a-menu-item key="logout" danger>
<template #icon><LogoutOutlined /></template>
退出登录
</a-menu-item>
</a-menu>
</a-drawer>
<!-- Token 刷新 QR 码模态框 -->
<QRCodeModal
v-model:visible="qrcodeModalVisible"
:alias="authStore.user?.alias || ''"
@success="handleQRCodeSuccess"
@error="handleQRCodeError"
/>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { useUserStore } from '@/stores/user';
import { useTokenMonitor } from '@/composables/useTokenMonitor';
import { useBreakpoint } from '@/composables/useBreakpoint';
import { useTheme } from '@/composables/useTheme';
import { Modal, message } from 'ant-design-vue';
import QRCodeModal from './QRCodeModal.vue';
import {
MenuOutlined,
HomeOutlined,
FileTextOutlined,
UnorderedListOutlined,
SettingOutlined,
UserOutlined,
FileOutlined,
CheckSquareOutlined,
BarChartOutlined,
LogoutOutlined,
DownOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
BulbOutlined,
BulbFilled,
ReloadOutlined,
} from '@ant-design/icons-vue';
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const userStore = useUserStore();
const { isMobile } = useBreakpoint();
const { getRemainingMinutes, tokenStatus } = useTokenMonitor();
const { isDark, toggleTheme } = useTheme();
const drawerVisible = ref(false);
const qrcodeModalVisible = ref(false);
const isAdminPath = computed(() => route.path.startsWith('/admin'));
const userInitial = computed(() => {
const name = authStore.user?.alias || 'U';
return name.charAt(0).toUpperCase();
});
// Token 状态计算
const remainingMinutes = computed(() => {
return getRemainingMinutes();
});
const showTokenStatus = computed(() => {
if (!authStore.isAuthenticated || !tokenStatus.value) return false;
const mins = remainingMinutes.value;
// 显示条件:Token 即将过期(60分钟内)或已过期(5分钟内)
if (mins === null) return false;
return mins <= 60 || (mins < 0 && Math.abs(mins) <= 5);
});
const tokenBadgeStatus = computed(() => {
const mins = remainingMinutes.value;
if (mins === null) return 'default';
if (mins < 0) return 'error'; // 已过期
if (mins <= 10) return 'error'; // 10分钟内过期
if (mins <= 30) return 'warning'; // 30分钟内过期
return 'processing'; // 正常但快过期
});
const tokenBadgeText = computed(() => {
const mins = remainingMinutes.value;
if (mins === null) return '';
if (mins < 0) return 'Token 已过期';
if (mins < 60) return `Token 剩余:${mins}分钟`;
return '';
});
const tokenIconClass = computed(() => {
const mins = remainingMinutes.value;
if (mins === null) return 'text-on-surface-variant';
if (mins < 0) return 'text-red-500 dark:text-red-400'; // 已过期
if (mins <= 10) return 'text-red-500 dark:text-red-400 animate-pulse'; // 10分钟内,闪烁
if (mins <= 30) return 'text-orange-500 dark:text-orange-400'; // 30分钟内
return 'text-blue-500 dark:text-blue-400'; // 正常
});
const tokenStatusTooltip = computed(() => {
const mins = remainingMinutes.value;
if (mins === null) return 'Token 状态未知';
if (mins < 0) {
const expiredMins = Math.abs(mins);
return `登录凭证已过期 ${expiredMins} 分钟,点击右侧按钮刷新`;
}
if (mins < 60) {
return `Token 剩余时间:${mins} 分钟,过期后可刷新)`;
}
return 'Token 状态正常';
});
const handleTokenStatusClick = () => {
const mins = remainingMinutes.value;
// Token 已过期时提醒刷新
if (mins !== null && mins < 0) {
message.info({ content: 'Token 已过期,请进行刷新', duration: 3 });
}
// Token 未过期时,点击无效果
};
const currentMenuKey = computed(() => {
const path = route.path;
if (path.startsWith('/admin/users')) return 'admin-users';
if (path.startsWith('/admin/templates')) return 'admin-templates';
if (path.startsWith('/admin/records')) return 'admin-records';
if (path.startsWith('/admin/stats')) return 'admin-stats';
if (path.startsWith('/admin/logs')) return 'admin-logs';
if (path.startsWith('/dashboard')) return 'dashboard';
if (path.startsWith('/tasks')) return 'tasks';
if (path.startsWith('/records')) return 'records';
if (path.startsWith('/settings')) return 'settings';
return '';
});
const handleMenuClick = ({ key }) => {
const routes = {
dashboard: '/dashboard',
tasks: '/tasks',
records: '/records',
'admin-users': '/admin/users',
'admin-templates': '/admin/templates',
'admin-records': '/admin/records',
'admin-stats': '/admin/stats',
'admin-logs': '/admin/logs',
settings: '/settings',
};
if (key === 'logout') {
handleLogout();
} else if (routes[key]) {
router.push(routes[key]);
drawerVisible.value = false;
}
};
const handleLogout = () => {
Modal.confirm({
title: '提示',
content: '确定要退出登录吗?',
okText: '确定',
cancelText: '取消',
onOk() {
authStore.logout();
router.push('/login');
drawerVisible.value = false;
},
});
};
// 处理 Token 刷新
const handleRefreshToken = () => {
qrcodeModalVisible.value = true;
};
// 处理 QR 码扫码成功
const handleQRCodeSuccess = async () => {
message.success({ content: 'Token 刷新成功', duration: 3 });
qrcodeModalVisible.value = false;
// 刷新用户信息和 Token 状态
try {
await authStore.fetchCurrentUser();
await userStore.fetchTokenStatus();
} catch (error) {
console.error('刷新用户信息失败:', error);
}
};
// 处理 QR 码扫码失败
const handleQRCodeError = error => {
message.error({ content: error?.message || 'Token 刷新失败', duration: 4 });
};
</script>