2026-01-06 10:04:06 +00:00
|
|
|
|
import { useState, useEffect } from 'react'
|
|
|
|
|
|
import { Layout, Badge, Avatar, Dropdown, Space, Popover, List, Tabs, Button, Empty, Typography } from 'antd'
|
2025-12-20 11:18:59 +00:00
|
|
|
|
import { useNavigate } from 'react-router-dom'
|
|
|
|
|
|
import {
|
|
|
|
|
|
MenuFoldOutlined,
|
|
|
|
|
|
MenuUnfoldOutlined,
|
|
|
|
|
|
BellOutlined,
|
|
|
|
|
|
QuestionCircleOutlined,
|
|
|
|
|
|
FileTextOutlined,
|
|
|
|
|
|
CustomerServiceOutlined,
|
|
|
|
|
|
UserOutlined,
|
2026-01-06 10:04:06 +00:00
|
|
|
|
CheckOutlined,
|
|
|
|
|
|
ProjectOutlined,
|
|
|
|
|
|
TeamOutlined,
|
|
|
|
|
|
NotificationOutlined,
|
2025-12-20 11:18:59 +00:00
|
|
|
|
} from '@ant-design/icons'
|
|
|
|
|
|
import useUserStore from '@/stores/userStore'
|
2026-01-06 10:04:06 +00:00
|
|
|
|
import { getNotifications, getUnreadCount, markAsRead, markAllAsRead } from '@/api/notification'
|
2025-12-20 11:18:59 +00:00
|
|
|
|
import Toast from '@/components/Toast/Toast'
|
|
|
|
|
|
import headerMenuData from '../../data/headerMenuData.json'
|
|
|
|
|
|
import './AppHeader.css'
|
|
|
|
|
|
|
|
|
|
|
|
const { Header } = Layout
|
2026-01-06 10:04:06 +00:00
|
|
|
|
const { Text } = Typography
|
2025-12-20 11:18:59 +00:00
|
|
|
|
|
|
|
|
|
|
// 图标映射
|
|
|
|
|
|
const iconMap = {
|
|
|
|
|
|
QuestionCircleOutlined: <QuestionCircleOutlined />,
|
|
|
|
|
|
FileTextOutlined: <FileTextOutlined />,
|
|
|
|
|
|
CustomerServiceOutlined: <CustomerServiceOutlined />,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function AppHeader({ collapsed, onToggle }) {
|
|
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
|
const { user, logout } = useUserStore()
|
|
|
|
|
|
|
|
|
|
|
|
// 用户下拉菜单
|
|
|
|
|
|
const userMenuItems = [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'profile',
|
|
|
|
|
|
label: '个人中心',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'settings',
|
|
|
|
|
|
label: '账户设置',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'divider',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'logout',
|
|
|
|
|
|
label: '退出登录',
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-01-06 10:04:06 +00:00
|
|
|
|
const [notifications, setNotifications] = useState([])
|
|
|
|
|
|
const [unreadCount, setUnreadCount] = useState(0)
|
|
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
|
|
const [popoverVisible, setPopoverVisible] = useState(false)
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (user) {
|
|
|
|
|
|
fetchUnreadCount()
|
|
|
|
|
|
// 每 2 分钟轮询一次
|
|
|
|
|
|
const timer = setInterval(fetchUnreadCount, 120000)
|
|
|
|
|
|
return () => clearInterval(timer)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [user])
|
|
|
|
|
|
|
|
|
|
|
|
const fetchUnreadCount = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await getUnreadCount()
|
|
|
|
|
|
setUnreadCount(res.data.unread_count)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Fetch unread count error:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const fetchNotifications = async () => {
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await getNotifications({ page: 1, page_size: 5 })
|
|
|
|
|
|
setNotifications(res.data || [])
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Fetch notifications error:', error)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleMarkRead = async (id) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await markAsRead(id)
|
|
|
|
|
|
setNotifications(notifications.map(n => n.id === id ? { ...n, is_read: 1 } : n))
|
|
|
|
|
|
fetchUnreadCount()
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Mark read error:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleMarkAllRead = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await markAllAsRead()
|
|
|
|
|
|
setNotifications(notifications.map(n => ({ ...n, is_read: 1 })))
|
|
|
|
|
|
setUnreadCount(0)
|
|
|
|
|
|
Toast.success('操作成功', '所有通知已标记为已读')
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Mark all read error:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleNotificationClick = (n) => {
|
|
|
|
|
|
if (n.is_read === 0) {
|
|
|
|
|
|
handleMarkRead(n.id)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (n.link) {
|
|
|
|
|
|
navigate(n.link)
|
|
|
|
|
|
setPopoverVisible(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const getCategoryIcon = (category) => {
|
|
|
|
|
|
switch (category) {
|
|
|
|
|
|
case 'project': return <ProjectOutlined style={{ color: '#1890ff' }} />
|
|
|
|
|
|
case 'collaboration': return <TeamOutlined style={{ color: '#52c41a' }} />
|
|
|
|
|
|
default: return <NotificationOutlined style={{ color: '#faad14' }} />
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const notificationContent = (
|
|
|
|
|
|
<div className="notification-popover">
|
|
|
|
|
|
<div className="popover-header">
|
|
|
|
|
|
<span className="title">消息通知</span>
|
|
|
|
|
|
{unreadCount > 0 && (
|
|
|
|
|
|
<Button type="link" size="small" onClick={handleMarkAllRead}>
|
|
|
|
|
|
全部已读
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<List
|
|
|
|
|
|
className="notification-list"
|
|
|
|
|
|
loading={loading}
|
|
|
|
|
|
itemLayout="horizontal"
|
|
|
|
|
|
dataSource={notifications}
|
|
|
|
|
|
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无新消息" /> }}
|
|
|
|
|
|
renderItem={(item) => (
|
|
|
|
|
|
<List.Item
|
|
|
|
|
|
className={`notification-item ${item.is_read === 0 ? 'unread' : ''}`}
|
|
|
|
|
|
onClick={() => handleNotificationClick(item)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<List.Item.Meta
|
|
|
|
|
|
avatar={<Avatar icon={getCategoryIcon(item.category)} />}
|
|
|
|
|
|
title={<Text strong={item.is_read === 0}>{item.title}</Text>}
|
|
|
|
|
|
description={
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="content-text">{item.content}</div>
|
|
|
|
|
|
<div className="time">{new Date(item.created_at).toLocaleString('zh-CN')}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</List.Item>
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="popover-footer">
|
|
|
|
|
|
<Button type="link" block onClick={() => { navigate('/notifications'); setPopoverVisible(false); }}>
|
|
|
|
|
|
查看全部消息
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-20 11:18:59 +00:00
|
|
|
|
const handleUserMenuClick = ({ key }) => {
|
|
|
|
|
|
if (key === 'logout') {
|
|
|
|
|
|
logout()
|
|
|
|
|
|
Toast.success('退出成功', '您已安全退出')
|
|
|
|
|
|
navigate('/login')
|
|
|
|
|
|
} else if (key === 'profile') {
|
|
|
|
|
|
navigate('/profile')
|
|
|
|
|
|
} else if (key === 'settings') {
|
|
|
|
|
|
Toast.info('开发中', '账户设置功能正在开发中')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleHeaderMenuClick = (key) => {
|
|
|
|
|
|
console.log('Header menu clicked:', key)
|
|
|
|
|
|
if (key === 'support') {
|
|
|
|
|
|
Toast.info('开发中', '支持功能正在开发中')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Header className="app-header">
|
|
|
|
|
|
{/* 左侧:Logo + 折叠按钮 */}
|
|
|
|
|
|
<div className="header-left">
|
|
|
|
|
|
{/* Logo 区域 */}
|
|
|
|
|
|
<div className="header-logo">
|
2026-01-01 14:41:10 +00:00
|
|
|
|
<h2 style={{ margin: 0, color: '#1677ff', fontWeight: 'bold' }}>NEX Docus</h2>
|
2025-12-20 11:18:59 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 折叠按钮 */}
|
|
|
|
|
|
<div className="trigger" onClick={onToggle}>
|
|
|
|
|
|
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-25 04:22:35 +00:00
|
|
|
|
{/* 右侧:功能按钮 + 用户信息 */}
|
2025-12-20 11:18:59 +00:00
|
|
|
|
<div className="header-right">
|
|
|
|
|
|
{/* 功能图标 */}
|
|
|
|
|
|
<Space size={16} className="header-actions">
|
|
|
|
|
|
{/* 动态渲染 header 菜单 */}
|
|
|
|
|
|
{headerMenuData.map((item) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={item.key}
|
|
|
|
|
|
className="header-link"
|
|
|
|
|
|
title={item.label}
|
|
|
|
|
|
onClick={() => handleHeaderMenuClick(item.key)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{iconMap[item.icon]}
|
|
|
|
|
|
<span className="ml-1">{item.label}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 消息中心 */}
|
2026-01-06 10:04:06 +00:00
|
|
|
|
<Popover
|
|
|
|
|
|
content={notificationContent}
|
|
|
|
|
|
trigger="click"
|
|
|
|
|
|
open={popoverVisible}
|
|
|
|
|
|
onOpenChange={(visible) => {
|
|
|
|
|
|
setPopoverVisible(visible)
|
|
|
|
|
|
if (visible) {
|
|
|
|
|
|
fetchNotifications()
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
placement="bottomRight"
|
|
|
|
|
|
overlayClassName="header-notification-popover"
|
2026-01-01 14:41:10 +00:00
|
|
|
|
>
|
2026-01-06 10:04:06 +00:00
|
|
|
|
<div className="header-link" title="消息中心">
|
|
|
|
|
|
<Badge count={unreadCount} size="small" offset={[4, -2]}>
|
|
|
|
|
|
<BellOutlined />
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
<span className="ml-1">消息</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Popover>
|
2025-12-20 11:18:59 +00:00
|
|
|
|
|
|
|
|
|
|
{/* 用户下拉菜单 */}
|
|
|
|
|
|
<Dropdown
|
|
|
|
|
|
menu={{
|
|
|
|
|
|
items: userMenuItems,
|
|
|
|
|
|
onClick: handleUserMenuClick,
|
|
|
|
|
|
}}
|
|
|
|
|
|
placement="bottomRight"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="user-info">
|
|
|
|
|
|
<Avatar size={32} icon={<UserOutlined />} />
|
|
|
|
|
|
<span className="username">{user?.nickname || user?.username || 'User'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Dropdown>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Header>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default AppHeader
|