deotalandAi/apps/frontend/src/components/layout/AppHeader.vue

810 lines
18 KiB
Vue

<template>
<header class="app-header" :class="headerClasses">
<!-- 移动端汉堡菜单按钮 -->
<button
v-if="isMobile"
class="mobile-menu-button"
@click="$emit('toggle-sidebar')"
:aria-label="t('header.toggleSidebar')"
>
<MenuIcon :class="{ 'icon-active': sidebarVisible }" />
</button>
<!-- 品牌标识区域 -->
<div class="brand-section">
<router-link to="/" class="brand-link">
<div class="brand-logo">
<div class="logo-icon">
<img src="@/assets/logo.png" alt="Logo" class="logo-image" />
</div>
<span class="brand-name">{{ t('app.title') }}</span>
</div>
</router-link>
</div>
<!-- 功能区域 -->
<div class="actions-section">
<!-- 移动端隐藏的操作按钮 -->
<div class="header-actions" v-if="!isMobile">
<!-- 搜索按钮 -->
<!-- <button
class="action-button search-button"
@click="toggleSearch"
:aria-label="t('header.search')"
>
<SearchIcon />
</button> -->
<!-- 通知按钮 -->
<!-- <button
class="action-button notification-button"
:aria-label="t('header.notifications')"
@click="toggleNotifications"
>
<NotificationIcon />
<span v-if="notificationCount > 0" class="notification-badge">
{{ notificationCount }}
</span>
</button> -->
<!-- 用户菜单 -->
<div class="user-menu" v-if="currentUser">
<el-dropdown trigger="click" @command="handleUserCommand">
<div class="user-avatar">
<el-avatar :size="32" :src="currentUser.avatarUrl">
<el-icon><UserIcon /></el-icon>
</el-avatar>
<span class="user-name">{{ currentUser.nickname || currentUser.email }}</span>
<ChevronDownIcon class="dropdown-icon" />
</div>
<template #dropdown>
<el-dropdown-menu>
<!-- <el-dropdown-item command="profile">
<UserIcon class="dropdown-item-icon" />
{{ t('header.profile') }}
</el-dropdown-item>
<el-dropdown-item command="settings">
{{ t('header.settings') }}
</el-dropdown-item> -->
<el-dropdown-item divided command="logout">
<LogoutIcon class="dropdown-item-icon" />
{{ t('header.logout') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 主题切换 -->
<ThemeToggle />
<!-- 语言切换 -->
<LanguageToggle />
<!-- 移动端用户操作 -->
<div v-if="isMobile && currentUser" class="mobile-user-menu">
<el-dropdown trigger="click" @command="handleUserCommand">
<el-avatar :size="32" :src="currentUser.avatarUrl" class="mobile-avatar">
<el-icon><UserIcon /></el-icon>
</el-avatar>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<UserIcon class="dropdown-item-icon" />
{{ t('header.profile') }}
</el-dropdown-item>
<el-dropdown-item command="settings">
{{ t('header.settings') }}
</el-dropdown-item>
<el-dropdown-item divided command="logout">
{{ t('header.logout') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 搜索下拉框 -->
<div v-if="searchVisible" class="search-dropdown" @click.stop>
<div class="search-input-container">
<SearchIcon class="search-icon" />
<input
ref="searchInput"
v-model="searchQuery"
type="text"
:placeholder="t('header.searchPlaceholder')"
class="search-input"
@keyup.enter="performSearch"
@keyup.esc="closeSearch"
/>
<button v-if="searchQuery" @click="clearSearch" class="clear-search-button">
<XIcon />
</button>
</div>
<div class="search-suggestions" v-if="searchSuggestions.length > 0">
<div
v-for="suggestion in searchSuggestions"
:key="suggestion.id"
class="search-suggestion"
@click="selectSuggestion(suggestion)"
>
<component :is="suggestion.icon" class="suggestion-icon" />
<span class="suggestion-text">{{ suggestion.text }}</span>
</div>
</div>
</div>
<!-- 通知面板 -->
<div v-if="notificationsVisible" class="notifications-panel" @click.stop>
<div class="notifications-header">
<h3>{{ t('header.notifications') }}</h3>
<button @click="markAllAsRead" class="mark-all-read">
{{ t('header.markAllRead') }}
</button>
</div>
<div class="notifications-list">
<div
v-for="notification in notifications"
:key="notification.id"
:class="['notification-item', { 'unread': !notification.read }]"
@click="markAsRead(notification.id)"
>
<div class="notification-icon">
<component :is="notification.icon" />
</div>
<div class="notification-content">
<p class="notification-text">{{ notification.text }}</p>
<span class="notification-time">{{ formatTime(notification.time) }}</span>
</div>
</div>
<div v-if="notifications.length === 0" class="no-notifications">
{{ t('header.noNotifications') }}
</div>
</div>
</div>
</header>
</template>
<script>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
import LanguageToggle from '@/components/ui/LanguageToggle.vue'
// 图标组件
import {
Menu as MenuIcon,
Search as SearchIcon,
Bell as NotificationIcon,
User as UserIcon,
Right as LogoutIcon,
ArrowDown as ChevronDownIcon,
Close as XIcon,
Cpu as BrainIcon
} from '@element-plus/icons-vue'
export default {
name: 'AppHeader',
components: {
ThemeToggle,
LanguageToggle,
MenuIcon,
SearchIcon,
NotificationIcon,
UserIcon,
LogoutIcon,
ChevronDownIcon,
XIcon,
BrainIcon
},
props: {
sidebarVisible: {
type: Boolean,
default: true
}
},
emits: ['toggle-sidebar'],
setup(props, { emit }) {
const { t } = useI18n()
const router = useRouter()
const authStore = useAuthStore()
// 响应式状态
const isMobile = ref(window.innerWidth < 768)
const searchVisible = ref(false)
const notificationsVisible = ref(false)
const searchQuery = ref('')
const searchInput = ref(null)
// 计算属性
const currentUser = computed(() => authStore.user)
const notificationCount = ref(3) // 模拟通知数量
const headerClasses = computed(() => ({
'mobile-header': isMobile.value,
'desktop-header': !isMobile.value
}))
// 模拟搜索建议
const searchSuggestions = ref([
{ id: 1, text: 'Dashboard', icon: 'UserIcon' },
{ id: 2, text: 'Projects', icon: 'UserIcon' },
{ id: 3, text: 'Settings', icon: 'UserIcon' }
])
// 模拟通知数据
const notifications = ref([
{
id: 1,
text: '新项目已创建完成',
time: new Date(),
icon: 'UserIcon',
read: false
},
{
id: 2,
text: '您的作品获得了新的点赞',
time: new Date(Date.now() - 1000 * 60 * 30),
icon: 'UserIcon',
read: false
}
])
// 窗口大小变化处理
const handleResize = () => {
isMobile.value = window.innerWidth < 768
}
// 切换搜索
const toggleSearch = async () => {
searchVisible.value = !searchVisible.value
if (searchVisible.value) {
await nextTick()
searchInput.value?.focus()
}
}
const closeSearch = () => {
searchVisible.value = false
searchQuery.value = ''
}
const clearSearch = () => {
searchQuery.value = ''
searchInput.value?.focus()
}
// 搜索操作
const performSearch = () => {
if (searchQuery.value.trim()) {
console.log('搜索:', searchQuery.value)
router.push(`/search?q=${encodeURIComponent(searchQuery.value)}`)
closeSearch()
}
}
const selectSuggestion = (suggestion) => {
searchQuery.value = suggestion.text
performSearch()
}
// 通知操作
const toggleNotifications = () => {
notificationsVisible.value = !notificationsVisible.value
}
const markAsRead = (id) => {
const notification = notifications.value.find(n => n.id === id)
if (notification) {
notification.read = true
notificationCount.value = Math.max(0, notificationCount.value - 1)
}
}
const markAllAsRead = () => {
notifications.value.forEach(n => n.read = true)
notificationCount.value = 0
}
// 用户菜单操作
const handleUserCommand = async (command) => {
switch (command) {
case 'profile':
router.push('/profile')
break
case 'settings':
router.push('/settings')
break
case 'logout':
try {
await authStore.logout(()=>{
router.push('/login')
})
} catch (error) {
console.error('登出失败:', error)
}
break
}
}
// 时间格式化
const formatTime = (time) => {
const now = new Date()
const diff = now - time
const minutes = Math.floor(diff / (1000 * 60))
const hours = Math.floor(diff / (1000 * 60 * 60))
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
return `${days}天前`
}
// 点击外部关闭下拉菜单
const handleClickOutside = (event) => {
if (!event.target.closest('.app-header')) {
searchVisible.value = false
notificationsVisible.value = false
}
}
onMounted(() => {
window.addEventListener('resize', handleResize)
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
document.removeEventListener('click', handleClickOutside)
})
return {
t,
isMobile,
searchVisible,
notificationsVisible,
searchQuery,
searchInput,
searchSuggestions,
notifications,
notificationCount,
currentUser,
headerClasses,
toggleSearch,
closeSearch,
clearSearch,
performSearch,
selectSuggestion,
toggleNotifications,
markAsRead,
markAllAsRead,
handleUserCommand,
formatTime
}
}
}
</script>
<style scoped>
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
padding: 0 24px;
background: var(--header-bg, #ffffff);
border-bottom: 1px solid var(--border-color, #e5e7eb);
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
position: relative;
z-index: 200;
}
/* 移动端汉堡菜单按钮 */
.mobile-menu-button {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
background: transparent;
color: var(--text-primary, #1f2937);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.mobile-menu-button:hover {
background: var(--hover-bg, #f3f4f6);
}
.mobile-menu-button .icon-active {
transform: rotate(90deg);
}
/* 品牌标识区域 */
.brand-section {
flex: 1;
}
.brand-link {
display: flex;
align-items: center;
text-decoration: none;
color: inherit;
}
.brand-logo {
display: flex;
align-items: center;
gap: 12px;
}
.logo-icon {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
box-shadow: 0 4px 12px rgba(167, 139, 250, 0.4);
}
.logo-image {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 12px;
}
.brand-name {
font-size: 20px;
font-weight: 700;
background: linear-gradient(135deg, #6B46C1 0%, #A78BFA 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -0.025em;
}
/* 功能区域 */
.actions-section {
display: flex;
align-items: center;
gap: 16px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
/* 操作按钮 */
.action-button {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
background: transparent;
color: var(--text-secondary, #6b7280);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.action-button:hover {
background: var(--hover-bg, #f3f4f6);
color: var(--text-primary, #1f2937);
}
/* 通知徽章 */
.notification-badge {
position: absolute;
top: 6px;
right: 6px;
min-width: 16px;
height: 16px;
background: #ef4444;
color: white;
font-size: 10px;
font-weight: 600;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
/* 用户菜单 */
.user-menu {
margin-left: 8px;
}
.user-avatar {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.user-avatar:hover {
background: var(--hover-bg, #f3f4f6);
}
.user-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary, #1f2937);
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-icon {
width: 16px;
height: 16px;
color: var(--text-secondary, #6b7280);
}
/* 移动端用户菜单 */
.mobile-user-menu {
margin-left: 8px;
}
.mobile-avatar {
cursor: pointer;
transition: transform 0.2s ease;
}
.mobile-avatar:hover {
transform: scale(1.05);
}
/* 下拉菜单图标 */
.dropdown-item-icon {
width: 16px;
height: 16px;
margin-right: 8px;
}
/* 搜索下拉框 */
.search-dropdown {
position: absolute;
top: 100%;
left: 24px;
right: 24px;
background: var(--header-bg, #ffffff);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
padding: 16px;
margin-top: 8px;
z-index: 1000;
}
.search-input-container {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.search-icon {
width: 20px;
height: 20px;
color: var(--text-secondary, #6b7280);
}
.search-input {
flex: 1;
border: none;
outline: none;
font-size: 14px;
color: var(--text-primary, #1f2937);
background: transparent;
}
.clear-search-button {
border: none;
background: transparent;
color: var(--text-secondary, #6b7280);
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.clear-search-button:hover {
background: var(--hover-bg, #f3f4f6);
}
/* 搜索建议 */
.search-suggestions {
max-height: 200px;
overflow-y: auto;
}
.search-suggestion {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.search-suggestion:hover {
background: var(--hover-bg, #f3f4f6);
}
.suggestion-icon {
width: 16px;
height: 16px;
color: var(--text-secondary, #6b7280);
}
.suggestion-text {
font-size: 14px;
color: var(--text-primary, #1f2937);
}
/* 通知面板 */
.notifications-panel {
position: absolute;
top: 100%;
right: 24px;
width: 320px;
background: var(--header-bg, #ffffff);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
margin-top: 8px;
z-index: 1000;
max-height: 400px;
display: flex;
flex-direction: column;
}
.notifications-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
.notifications-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #1f2937);
}
.mark-all-read {
font-size: 12px;
color: #6B46C1;
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.mark-all-read:hover {
background: rgba(107, 70, 193, 0.1);
}
.notifications-list {
flex: 1;
overflow-y: auto;
}
.notification-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.2s ease;
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
.notification-item:hover {
background: var(--hover-bg, #f3f4f6);
}
.notification-item.unread {
background: rgba(107, 70, 193, 0.05);
}
.notification-item:last-child {
border-bottom: none;
}
.notification-icon {
width: 32px;
height: 32px;
background: linear-gradient(135deg, #7C3AED, #6B46C1);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
flex-shrink: 0;
}
.notification-content {
flex: 1;
min-width: 0;
}
.notification-text {
margin: 0 0 4px 0;
font-size: 14px;
color: var(--text-primary, #1f2937);
line-height: 1.4;
}
.notification-time {
font-size: 12px;
color: var(--text-secondary, #6b7280);
}
.no-notifications {
padding: 24px;
text-align: center;
color: var(--text-secondary, #6b7280);
font-size: 14px;
}
/* 移动端样式 */
@media (max-width: 767px) {
.app-header {
padding: 0 16px;
}
.brand-name {
display: none;
}
.header-actions {
display: none;
}
.search-dropdown {
left: 16px;
right: 16px;
}
.notifications-panel {
left: 16px;
right: 16px;
width: auto;
}
}
/* 深色主题 */
.dark .app-header {
--header-bg: #1f2937;
--text-primary: #f9fafb;
--text-secondary: #d1d5db;
--border-color: #374151;
--hover-bg: rgba(255, 255, 255, 0.1);
}
.dark .logo-icon {
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.4);
}
</style>