优化界面
parent
4cee9414a5
commit
4a99b10578
Binary file not shown.
|
Before Width: | Height: | Size: 6.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
|
|
@ -1,6 +1,8 @@
|
||||||
|
import { useEffect } from 'react'
|
||||||
import { BrowserRouter, Routes, Route, Navigate, useParams, Outlet } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate, useParams, Outlet } from 'react-router-dom'
|
||||||
import { ConfigProvider } from 'antd'
|
import { ConfigProvider, theme } from 'antd'
|
||||||
import zhCN from 'antd/locale/zh_CN'
|
import zhCN from 'antd/locale/zh_CN'
|
||||||
|
import useThemeStore from '@/stores/themeStore'
|
||||||
import Login from '@/pages/Login/Login'
|
import Login from '@/pages/Login/Login'
|
||||||
import ProjectList from '@/pages/ProjectList/ProjectList'
|
import ProjectList from '@/pages/ProjectList/ProjectList'
|
||||||
import DocumentPage from '@/pages/Document/DocumentPage'
|
import DocumentPage from '@/pages/Document/DocumentPage'
|
||||||
|
|
@ -35,8 +37,23 @@ function LayoutWrapper() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const { isDarkMode } = useThemeStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDarkMode) {
|
||||||
|
document.body.classList.add('dark')
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove('dark')
|
||||||
|
}
|
||||||
|
}, [isDarkMode])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider locale={zhCN}>
|
<ConfigProvider
|
||||||
|
locale={zhCN}
|
||||||
|
theme={{
|
||||||
|
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.2 KiB |
|
|
@ -1,12 +1,13 @@
|
||||||
.app-header {
|
.app-header {
|
||||||
background: #fff;
|
background: var(--header-bg);
|
||||||
padding: 0 24px;
|
padding: 0 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||||
height: 64px;
|
height: 64px;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 左侧区域 */
|
/* 左侧区域 */
|
||||||
|
|
@ -25,26 +26,13 @@
|
||||||
transition: width 0.2s;
|
transition: width 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-small {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-full {
|
|
||||||
height: 32px;
|
|
||||||
width: auto;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger {
|
.trigger {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.3s;
|
transition: color 0.3s;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: rgba(0, 0, 0, 0.65);
|
color: var(--text-color-secondary);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
@ -61,67 +49,29 @@
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-search {
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-icon {
|
/* Icon Buttons */
|
||||||
font-size: 16px;
|
.header-icon-btn {
|
||||||
color: rgba(0, 0, 0, 0.65);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
.header-icon:hover {
|
border-radius: 8px;
|
||||||
color: #1677ff;
|
|
||||||
background: rgba(22, 119, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-link {
|
|
||||||
font-size: 14px;
|
|
||||||
color: rgba(0, 0, 0, 0.65);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s;
|
color: var(--text-color-secondary);
|
||||||
padding: 6px 12px;
|
transition: all 0.2s;
|
||||||
border-radius: 4px;
|
background: transparent;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-link:hover {
|
.header-icon-btn:hover {
|
||||||
color: #1677ff;
|
background-color: var(--item-hover-bg);
|
||||||
background: rgba(22, 119, 255, 0.08);
|
color: var(--text-color);
|
||||||
}
|
|
||||||
|
|
||||||
.user-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info:hover {
|
|
||||||
background: rgba(22, 119, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.username {
|
|
||||||
font-size: 14px;
|
|
||||||
color: rgba(0, 0, 0, 0.88);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 通知面板样式 */
|
/* 通知面板样式 */
|
||||||
|
|
@ -135,7 +85,7 @@
|
||||||
|
|
||||||
.popover-header {
|
.popover-header {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
border-bottom: 1px solid var(--border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -144,6 +94,7 @@
|
||||||
.popover-header .title {
|
.popover-header .title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-list {
|
.notification-list {
|
||||||
|
|
@ -155,23 +106,33 @@
|
||||||
padding: 12px 16px !important;
|
padding: 12px 16px !important;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.3s;
|
transition: background 0.3s;
|
||||||
|
background: var(--bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-item:hover {
|
.notification-item:hover {
|
||||||
background: #f5f5f5;
|
background: var(--item-hover-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-item.unread {
|
.notification-item.unread {
|
||||||
background: #e6f7ff;
|
background: #e6f7ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark mode adjustment for unread */
|
||||||
|
body.dark .notification-item.unread {
|
||||||
|
background: #111d2c;
|
||||||
|
}
|
||||||
|
|
||||||
.notification-item.unread:hover {
|
.notification-item.unread:hover {
|
||||||
background: #bae7ff;
|
background: #bae7ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.dark .notification-item.unread:hover {
|
||||||
|
background: #112a45;
|
||||||
|
}
|
||||||
|
|
||||||
.content-text {
|
.content-text {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #595959;
|
color: var(--text-color-secondary);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
|
|
@ -181,16 +142,13 @@
|
||||||
|
|
||||||
.time {
|
.time {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #bfbfbf;
|
color: var(--text-color-secondary);
|
||||||
|
opacity: 0.8;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popover-footer {
|
.popover-footer {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-top: 1px solid #f0f0f0;
|
border-top: 1px solid var(--border-color);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
|
||||||
|
|
||||||
.ml-1 {
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,68 +1,41 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Layout, Badge, Avatar, Dropdown, Space, Popover, List, Tabs, Button, Empty, Typography } from 'antd'
|
import { Layout, Badge, Avatar, Dropdown, Space, Popover, List, Tabs, Button, Empty, Typography, Segmented, Tooltip } from 'antd'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
BellOutlined,
|
BellOutlined,
|
||||||
QuestionCircleOutlined,
|
|
||||||
FileTextOutlined,
|
|
||||||
CustomerServiceOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
CheckOutlined,
|
|
||||||
ProjectOutlined,
|
ProjectOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
NotificationOutlined,
|
NotificationOutlined,
|
||||||
|
MoonOutlined,
|
||||||
|
SunOutlined,
|
||||||
|
GlobalOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import useUserStore from '@/stores/userStore'
|
import useUserStore from '@/stores/userStore'
|
||||||
import useNotificationStore from '@/stores/notificationStore'
|
import useNotificationStore from '@/stores/notificationStore'
|
||||||
|
import useThemeStore from '@/stores/themeStore'
|
||||||
import { getNotifications, getUnreadCount, markAsRead, markAllAsRead } from '@/api/notification'
|
import { getNotifications, getUnreadCount, markAsRead, markAllAsRead } from '@/api/notification'
|
||||||
import Toast from '@/components/Toast/Toast'
|
import Toast from '@/components/Toast/Toast'
|
||||||
import headerMenuData from '../../data/headerMenuData.json'
|
|
||||||
import './AppHeader.css'
|
import './AppHeader.css'
|
||||||
|
|
||||||
const { Header } = Layout
|
const { Header } = Layout
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
// 图标映射
|
function AppHeader({ collapsed, onToggle, showLogo = true }) {
|
||||||
const iconMap = {
|
|
||||||
QuestionCircleOutlined: <QuestionCircleOutlined />,
|
|
||||||
FileTextOutlined: <FileTextOutlined />,
|
|
||||||
CustomerServiceOutlined: <CustomerServiceOutlined />,
|
|
||||||
}
|
|
||||||
|
|
||||||
function AppHeader({ collapsed, onToggle }) {
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { user, logout } = useUserStore()
|
const { user } = useUserStore()
|
||||||
const { unreadCount, fetchUnreadCount, decrementUnreadCount, resetUnreadCount } = useNotificationStore()
|
const { unreadCount, fetchUnreadCount, decrementUnreadCount, resetUnreadCount } = useNotificationStore()
|
||||||
|
const { isDarkMode, toggleTheme } = useThemeStore()
|
||||||
// 用户下拉菜单
|
|
||||||
const userMenuItems = [
|
|
||||||
{
|
|
||||||
key: 'profile',
|
|
||||||
label: '个人中心',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'settings',
|
|
||||||
label: '账户设置',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'divider',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'logout',
|
|
||||||
label: '退出登录',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const [notifications, setNotifications] = useState([])
|
const [notifications, setNotifications] = useState([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [popoverVisible, setPopoverVisible] = useState(false)
|
const [popoverVisible, setPopoverVisible] = useState(false)
|
||||||
|
const [lang, setLang] = useState('zh')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
fetchUnreadCount()
|
fetchUnreadCount()
|
||||||
// 每 2 分钟轮询一次
|
|
||||||
const timer = setInterval(fetchUnreadCount, 120000)
|
const timer = setInterval(fetchUnreadCount, 120000)
|
||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer)
|
||||||
}
|
}
|
||||||
|
|
@ -83,7 +56,7 @@ function AppHeader({ collapsed, onToggle }) {
|
||||||
const handleMarkRead = async (id) => {
|
const handleMarkRead = async (id) => {
|
||||||
try {
|
try {
|
||||||
await markAsRead(id)
|
await markAsRead(id)
|
||||||
setNotifications(notifications.map(n => n.id === id ? { ...n, is_read: 1 } : n))
|
setNotifications(notifications.map(n => n.id === id ? { ...n, is_read: true } : n))
|
||||||
decrementUnreadCount()
|
decrementUnreadCount()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Mark read error:', error)
|
console.error('Mark read error:', error)
|
||||||
|
|
@ -93,7 +66,7 @@ function AppHeader({ collapsed, onToggle }) {
|
||||||
const handleMarkAllRead = async () => {
|
const handleMarkAllRead = async () => {
|
||||||
try {
|
try {
|
||||||
await markAllAsRead()
|
await markAllAsRead()
|
||||||
setNotifications(notifications.map(n => ({ ...n, is_read: 1 })))
|
setNotifications(notifications.map(n => ({ ...n, is_read: true })))
|
||||||
resetUnreadCount()
|
resetUnreadCount()
|
||||||
Toast.success('操作成功', '所有通知已标记为已读')
|
Toast.success('操作成功', '所有通知已标记为已读')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -161,73 +134,46 @@ function AppHeader({ collapsed, onToggle }) {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
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('开发中', '支持功能正在开发中')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取用户头像URL
|
|
||||||
const getUserAvatarUrl = () => {
|
|
||||||
if (!user?.avatar) return null
|
|
||||||
// avatar 字段存储的是相对路径,如:2/avatar/xxx.jpg
|
|
||||||
// 需要转换为 API 端点: /api/v1/auth/avatar/{user_id}/{filename}
|
|
||||||
const parts = user.avatar.split('/')
|
|
||||||
if (parts.length >= 3) {
|
|
||||||
const userId = parts[0]
|
|
||||||
const filename = parts[2]
|
|
||||||
return `/api/v1/auth/avatar/${userId}/${filename}`
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Header className="app-header">
|
<Header className="app-header" style={{ paddingLeft: showLogo ? 24 : 0 }}>
|
||||||
{/* 左侧:Logo + 折叠按钮 */}
|
{/* 左侧:Logo + 折叠按钮 */}
|
||||||
<div className="header-left">
|
{showLogo && (
|
||||||
{/* Logo 区域 */}
|
<div className="header-left">
|
||||||
<div className="header-logo">
|
{/* Logo 区域 */}
|
||||||
<img src="/favicon.svg" alt="logo" style={{ width: 32, height: 32, marginRight: 8 }} />
|
<div className="header-logo">
|
||||||
<h2 style={{ margin: 0, color: '#1677ff', fontWeight: 'bold' }}>NexDocus</h2>
|
<img src="/favicon.svg" alt="logo" style={{ width: 32, height: 32, marginRight: 8 }} />
|
||||||
</div>
|
<h2 style={{ margin: 0, color: '#1677ff', fontWeight: 'bold' }}>NexDocus</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 折叠按钮 */}
|
{/* 折叠按钮 */}
|
||||||
<div className="trigger" onClick={onToggle}>
|
<div className="trigger" onClick={onToggle}>
|
||||||
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
{!showLogo && <div />} {/* Spacer if left is empty */}
|
||||||
|
|
||||||
{/* 右侧:功能按钮 + 用户信息 */}
|
{/* 右侧:功能按钮 */}
|
||||||
<div className="header-right">
|
<div className="header-right">
|
||||||
{/* 功能图标 */}
|
|
||||||
<Space size={16} className="header-actions">
|
<Space size={16} className="header-actions">
|
||||||
{/* 动态渲染 header 菜单 */}
|
|
||||||
{headerMenuData.map((item) => (
|
{/* 1. 主题切换 */}
|
||||||
<div
|
<div className="header-icon-btn" title="切换主题" onClick={toggleTheme}>
|
||||||
key={item.key}
|
{isDarkMode ? <SunOutlined style={{ fontSize: 18 }} /> : <MoonOutlined style={{ fontSize: 18 }} />}
|
||||||
className="header-link"
|
</div>
|
||||||
title={item.label}
|
|
||||||
onClick={() => handleHeaderMenuClick(item.key)}
|
|
||||||
>
|
|
||||||
{iconMap[item.icon]}
|
|
||||||
<span className="ml-1">{item.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* 消息中心 */}
|
{/* 2. 语言切换 */}
|
||||||
|
<Segmented
|
||||||
|
value={lang}
|
||||||
|
onChange={setLang}
|
||||||
|
options={[
|
||||||
|
{ label: '中', value: 'zh' },
|
||||||
|
{ label: 'EN', value: 'en' },
|
||||||
|
]}
|
||||||
|
style={{ fontWeight: 500 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 3. 消息通知 */}
|
||||||
<Popover
|
<Popover
|
||||||
content={notificationContent}
|
content={notificationContent}
|
||||||
trigger="click"
|
trigger="click"
|
||||||
|
|
@ -241,31 +187,17 @@ function AppHeader({ collapsed, onToggle }) {
|
||||||
placement="bottomRight"
|
placement="bottomRight"
|
||||||
overlayClassName="header-notification-popover"
|
overlayClassName="header-notification-popover"
|
||||||
>
|
>
|
||||||
<div className="header-link" title="消息中心">
|
<div className="header-icon-btn" title="消息中心">
|
||||||
<Badge count={unreadCount} size="small" offset={[4, -2]}>
|
<Badge count={unreadCount} size="small" offset={[2, -2]}>
|
||||||
<BellOutlined />
|
<BellOutlined style={{ fontSize: 18 }} />
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="ml-1">消息</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
{/* 用户下拉菜单 */}
|
|
||||||
<Dropdown
|
|
||||||
menu={{
|
|
||||||
items: userMenuItems,
|
|
||||||
onClick: handleUserMenuClick,
|
|
||||||
}}
|
|
||||||
placement="bottomRight"
|
|
||||||
>
|
|
||||||
<div className="user-info">
|
|
||||||
<Avatar size={32} icon={<UserOutlined />} src={getUserAvatarUrl()} />
|
|
||||||
<span className="username">{user?.nickname || user?.username || 'User'}</span>
|
|
||||||
</div>
|
|
||||||
</Dropdown>
|
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
</Header>
|
</Header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AppHeader
|
export default AppHeader
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Layout, Menu, Badge, message } from 'antd'
|
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
|
|
@ -14,34 +13,40 @@ import {
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
SafetyOutlined,
|
SafetyOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
|
ProjectOutlined,
|
||||||
|
RocketOutlined,
|
||||||
|
ReadOutlined,
|
||||||
|
BookOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
import { message } from 'antd'
|
||||||
import { getUserMenus } from '@/api/menu'
|
import { getUserMenus } from '@/api/menu'
|
||||||
import './AppSider.css'
|
import useUserStore from '@/stores/userStore'
|
||||||
|
import ModernSidebar from '../ModernSidebar/ModernSidebar'
|
||||||
const { Sider } = Layout
|
|
||||||
|
|
||||||
// 图标映射
|
// 图标映射
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
DashboardOutlined,
|
DashboardOutlined: <DashboardOutlined />,
|
||||||
DesktopOutlined,
|
DesktopOutlined: <DesktopOutlined />,
|
||||||
GlobalOutlined,
|
GlobalOutlined: <GlobalOutlined />,
|
||||||
CloudServerOutlined,
|
CloudServerOutlined: <CloudServerOutlined />,
|
||||||
UserOutlined,
|
UserOutlined: <UserOutlined />,
|
||||||
AppstoreOutlined,
|
AppstoreOutlined: <AppstoreOutlined />,
|
||||||
SettingOutlined,
|
SettingOutlined: <SettingOutlined />,
|
||||||
BlockOutlined,
|
BlockOutlined: <BlockOutlined />,
|
||||||
FolderOutlined,
|
FolderOutlined: <FolderOutlined />,
|
||||||
FileTextOutlined,
|
FileTextOutlined: <FileTextOutlined />,
|
||||||
SafetyOutlined,
|
SafetyOutlined: <SafetyOutlined />,
|
||||||
TeamOutlined,
|
TeamOutlined: <TeamOutlined />,
|
||||||
|
ProjectOutlined: <ProjectOutlined />,
|
||||||
|
ReadOutlined: <ReadOutlined />,
|
||||||
|
BookOutlined: <BookOutlined />,
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppSider({ collapsed, onToggle }) {
|
function AppSider({ collapsed, onToggle }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [openKeys, setOpenKeys] = useState([])
|
const { user, logout } = useUserStore()
|
||||||
const [menuData, setMenuData] = useState([])
|
const [menuGroups, setMenuGroups] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
// 加载菜单数据
|
// 加载菜单数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -52,120 +57,137 @@ function AppSider({ collapsed, onToggle }) {
|
||||||
try {
|
try {
|
||||||
const res = await getUserMenus()
|
const res = await getUserMenus()
|
||||||
if (res.data) {
|
if (res.data) {
|
||||||
setMenuData(res.data)
|
// 过滤菜单:只显示 type=1 (目录) 和 type=2 (菜单)
|
||||||
|
const validMenus = res.data.filter(item => [1, 2].includes(item.menu_type))
|
||||||
|
transformMenuData(validMenus)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Load menus error:', error)
|
console.error('Load menus error:', error)
|
||||||
message.error('加载菜单失败')
|
message.error('加载菜单失败')
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据当前路径获取应该打开的父菜单
|
const transformMenuData = (data) => {
|
||||||
const getDefaultOpenKeys = () => {
|
const groups = []
|
||||||
|
|
||||||
|
// 默认组 (用于存放一级菜单即是叶子节点的情况)
|
||||||
|
const defaultGroup = {
|
||||||
|
title: '', // 空标题或 '通用'
|
||||||
|
items: []
|
||||||
|
}
|
||||||
|
|
||||||
|
data.forEach(item => {
|
||||||
|
// 检查是否有子菜单
|
||||||
|
const validChildren = item.children ? item.children.filter(child => [1, 2].includes(child.menu_type)) : []
|
||||||
|
|
||||||
|
if (validChildren.length > 0) {
|
||||||
|
// 一级菜单作为组标题
|
||||||
|
const groupItems = validChildren.map(child => {
|
||||||
|
const icon = typeof child.icon === 'string' ? (iconMap[child.icon] || <AppstoreOutlined />) : child.icon
|
||||||
|
return {
|
||||||
|
key: child.menu_code,
|
||||||
|
label: child.menu_name,
|
||||||
|
icon: icon,
|
||||||
|
path: child.path
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
title: item.menu_name, // e.g. "系统管理"
|
||||||
|
items: groupItems
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 一级菜单是叶子节点,放入默认组
|
||||||
|
const icon = typeof item.icon === 'string' ? (iconMap[item.icon] || <AppstoreOutlined />) : item.icon
|
||||||
|
defaultGroup.items.push({
|
||||||
|
key: item.menu_code,
|
||||||
|
label: item.menu_name,
|
||||||
|
icon: icon,
|
||||||
|
path: item.path
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 如果默认组有内容,放在最前面
|
||||||
|
if (defaultGroup.items.length > 0) {
|
||||||
|
groups.unshift(defaultGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
setMenuGroups(groups)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNavigate = (key, item) => {
|
||||||
|
if (item.path) {
|
||||||
|
navigate(item.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout()
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleProfileClick = () => {
|
||||||
|
navigate('/profile')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前激活的 key
|
||||||
|
// 简单匹配 path
|
||||||
|
const getActiveKey = () => {
|
||||||
const path = location.pathname
|
const path = location.pathname
|
||||||
for (const item of menuData) {
|
// 遍历所有 items 找匹配
|
||||||
if (item.children) {
|
for (const group of menuGroups) {
|
||||||
const hasChild = item.children.some((c) => c.path === path)
|
for (const item of group.items) {
|
||||||
if (hasChild) {
|
if (item.path === path) return item.key
|
||||||
return [item.menu_code]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听菜单数据加载完成,初始化打开的父菜单
|
|
||||||
useEffect(() => {
|
|
||||||
if (menuData.length > 0 && openKeys.length === 0) {
|
|
||||||
const defaultKeys = getDefaultOpenKeys()
|
|
||||||
setOpenKeys(defaultKeys)
|
|
||||||
}
|
|
||||||
}, [menuData])
|
|
||||||
|
|
||||||
const handleMenuClick = ({ key }) => {
|
|
||||||
// 查找对应的路径
|
|
||||||
for (const item of menuData) {
|
|
||||||
if (item.menu_code === key && item.path) {
|
|
||||||
navigate(item.path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (item.children) {
|
|
||||||
const child = item.children.find((c) => c.menu_code === key)
|
|
||||||
if (child && child.path) {
|
|
||||||
navigate(child.path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOpenChange = (keys) => {
|
|
||||||
setOpenKeys(keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取当前选中的菜单项
|
|
||||||
const getSelectedKey = () => {
|
|
||||||
const path = location.pathname
|
|
||||||
for (const item of menuData) {
|
|
||||||
if (item.path === path) return item.menu_code
|
|
||||||
if (item.children) {
|
|
||||||
const child = item.children.find((c) => c.path === path)
|
|
||||||
if (child) return child.menu_code
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成菜单项配置
|
const logoNode = (
|
||||||
const getMenuItems = () => {
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, paddingLeft: 8 }}>
|
||||||
return menuData.map((item) => {
|
<img src="/favicon.svg" alt="logo" style={{ width: 32, height: 32 }} />
|
||||||
const IconComponent = iconMap[item.icon]
|
{!collapsed && (
|
||||||
const icon = IconComponent ? <IconComponent /> : null
|
<span style={{ fontSize: 18, fontWeight: 'bold', color: '#111' }}>NexDocus</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
// 如果有子菜单
|
// 获取用户头像URL
|
||||||
if (item.children && item.children.length > 0) {
|
const getUserAvatarUrl = () => {
|
||||||
return {
|
if (!user?.avatar) return null
|
||||||
key: item.menu_code,
|
// avatar 字段存储的是相对路径,如:2/avatar/xxx.jpg
|
||||||
icon: icon,
|
// 需要转换为 API 端点: /api/v1/auth/avatar/{user_id}/{filename}
|
||||||
label: item.menu_name,
|
// 如果已经是 http 开头(第三方),则直接返回
|
||||||
popupClassName: 'sider-submenu-popup',
|
if (user.avatar.startsWith('http')) return user.avatar
|
||||||
children: item.children.map((child) => ({
|
|
||||||
key: child.menu_code,
|
const parts = user.avatar.split('/')
|
||||||
label: child.menu_name,
|
if (parts.length >= 3) {
|
||||||
})),
|
const userId = parts[0]
|
||||||
}
|
const filename = parts[2]
|
||||||
}
|
return `/api/v1/auth/avatar/${userId}/${filename}`
|
||||||
|
}
|
||||||
// 普通菜单项
|
return null
|
||||||
return {
|
|
||||||
key: item.menu_code,
|
|
||||||
icon: icon,
|
|
||||||
label: item.menu_name,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userObj = user ? {
|
||||||
|
name: user.nickname || user.username,
|
||||||
|
role: user.role_name || 'Admin',
|
||||||
|
avatar: getUserAvatarUrl()
|
||||||
|
} : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sider
|
<ModernSidebar
|
||||||
className="app-sider"
|
logo={logoNode}
|
||||||
|
menuGroups={menuGroups}
|
||||||
|
activeKey={getActiveKey()}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
user={userObj}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
onProfileClick={handleProfileClick}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
width={200}
|
onCollapse={onToggle}
|
||||||
collapsedWidth={64}
|
/>
|
||||||
trigger={null}
|
|
||||||
>
|
|
||||||
{/* 菜单 */}
|
|
||||||
<Menu
|
|
||||||
mode="inline"
|
|
||||||
selectedKeys={[getSelectedKey()]}
|
|
||||||
openKeys={collapsed ? [] : openKeys}
|
|
||||||
onOpenChange={handleOpenChange}
|
|
||||||
onClick={handleMenuClick}
|
|
||||||
className="sider-menu"
|
|
||||||
items={getMenuItems()}
|
|
||||||
/>
|
|
||||||
</Sider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,27 @@
|
||||||
.main-layout {
|
.main-layout {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row; /* Changed to row for Sider-Left layout */
|
||||||
background: #fafafa;
|
background: var(--bg-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content-wrapper {
|
.main-content-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: calc(100vh - 64px);
|
height: 100vh;
|
||||||
background: #fafafa;
|
background: var(--bg-color-secondary);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
background: #f5f5f5;
|
background: var(--bg-color-secondary);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-wrapper {
|
.content-wrapper {
|
||||||
padding: 8px;
|
padding: 0;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
@ -14,10 +14,10 @@ function MainLayout({ children }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout className="main-layout">
|
<Layout className="main-layout" hasSider>
|
||||||
<AppHeader collapsed={collapsed} onToggle={toggleCollapsed} />
|
<AppSider collapsed={collapsed} onToggle={toggleCollapsed} />
|
||||||
<Layout className="main-content-wrapper">
|
<Layout className="main-content-wrapper">
|
||||||
<AppSider collapsed={collapsed} onToggle={toggleCollapsed} />
|
<AppHeader collapsed={collapsed} onToggle={toggleCollapsed} showLogo={false} />
|
||||||
<Content className="main-content">
|
<Content className="main-content">
|
||||||
<div className="content-wrapper">
|
<div className="content-wrapper">
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,221 @@
|
||||||
|
.modern-sidebar {
|
||||||
|
height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
background: var(--sider-bg) !important;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modern-sidebar .ant-layout-sider-children {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.modern-sidebar-header {
|
||||||
|
padding: 24px 20px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 80px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapse Trigger */
|
||||||
|
.collapse-trigger {
|
||||||
|
position: absolute;
|
||||||
|
right: -12px;
|
||||||
|
top: 32px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-trigger:hover {
|
||||||
|
color: #1677ff;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu Area */
|
||||||
|
.modern-sidebar-menu {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modern-sidebar-menu::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modern-sidebar-menu::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu Group */
|
||||||
|
.menu-group {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-left: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu Item */
|
||||||
|
.modern-sidebar-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 12px; /* Rounded corners */
|
||||||
|
transition: all 0.2s;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modern-sidebar-item:hover {
|
||||||
|
background-color: var(--item-hover-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modern-sidebar-item.active {
|
||||||
|
background-color: #2563eb; /* Royal Blue */
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-label {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-arrow {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsed State */
|
||||||
|
.modern-sidebar-item.collapsed {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.modern-sidebar-footer {
|
||||||
|
padding: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--sider-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-left: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link:hover {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link.collapsed {
|
||||||
|
justify-content: center;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User Card */
|
||||||
|
.user-card {
|
||||||
|
background-color: var(--bg-color-secondary); /* Light gray background */
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn:hover {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
color: #ef4444; /* Red for logout */
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Layout, Avatar, Tooltip, Button } from 'antd';
|
||||||
|
import {
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
MenuFoldOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
QuestionCircleOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
LeftOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import './ModernSidebar.css';
|
||||||
|
|
||||||
|
const { Sider } = Layout;
|
||||||
|
|
||||||
|
const ModernSidebar = ({
|
||||||
|
logo,
|
||||||
|
menuGroups = [],
|
||||||
|
activeKey,
|
||||||
|
onNavigate,
|
||||||
|
user,
|
||||||
|
onLogout,
|
||||||
|
onProfileClick,
|
||||||
|
collapsed,
|
||||||
|
onCollapse,
|
||||||
|
width = 260,
|
||||||
|
collapsedWidth = 80,
|
||||||
|
className = '',
|
||||||
|
style = {}
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const handleItemClick = (item) => {
|
||||||
|
if (onNavigate) {
|
||||||
|
onNavigate(item.key, item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMenuItem = (item) => {
|
||||||
|
const isActive = activeKey === item.key;
|
||||||
|
|
||||||
|
// 如果是折叠状态,只显示图标,并使用Tooltip
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<Tooltip title={item.label} placement="right" key={item.key}>
|
||||||
|
<div
|
||||||
|
className={`modern-sidebar-item collapsed ${isActive ? 'active' : ''}`}
|
||||||
|
onClick={() => handleItemClick(item)}
|
||||||
|
>
|
||||||
|
<div className="item-icon">{item.icon}</div>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
className={`modern-sidebar-item ${isActive ? 'active' : ''}`}
|
||||||
|
onClick={() => handleItemClick(item)}
|
||||||
|
>
|
||||||
|
<div className="item-content">
|
||||||
|
<div className="item-icon">{item.icon}</div>
|
||||||
|
<span className="item-label">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
{isActive && <RightOutlined className="item-arrow" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sider
|
||||||
|
width={width}
|
||||||
|
collapsed={collapsed}
|
||||||
|
collapsedWidth={collapsedWidth}
|
||||||
|
trigger={null}
|
||||||
|
className={`modern-sidebar ${className}`}
|
||||||
|
theme="light"
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{/* 顶部 Logo 区域 */}
|
||||||
|
<div className="modern-sidebar-header">
|
||||||
|
<div className="logo-container">
|
||||||
|
{logo}
|
||||||
|
</div>
|
||||||
|
{/* 折叠按钮 - 悬浮在边缘 */}
|
||||||
|
<div
|
||||||
|
className="collapse-trigger"
|
||||||
|
onClick={() => onCollapse && onCollapse(!collapsed)}
|
||||||
|
>
|
||||||
|
{collapsed ? <RightOutlined /> : <LeftOutlined />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 菜单列表区域 */}
|
||||||
|
<div className="modern-sidebar-menu">
|
||||||
|
{menuGroups.map((group, index) => (
|
||||||
|
<div key={index} className="menu-group">
|
||||||
|
{!collapsed && group.title && (
|
||||||
|
<div className="group-title">{group.title}</div>
|
||||||
|
)}
|
||||||
|
<div className="group-items">
|
||||||
|
{group.items.map(item => renderMenuItem(item))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部区域 */}
|
||||||
|
<div className="modern-sidebar-footer">
|
||||||
|
{/* 帮助支持 */}
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="footer-link">
|
||||||
|
<QuestionCircleOutlined />
|
||||||
|
<span>帮助支持</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{collapsed && (
|
||||||
|
<div className="footer-link collapsed">
|
||||||
|
<QuestionCircleOutlined />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 用户卡片 */}
|
||||||
|
<div className="user-card">
|
||||||
|
<div
|
||||||
|
className="user-info"
|
||||||
|
onClick={onProfileClick}
|
||||||
|
style={{ cursor: onProfileClick ? 'pointer' : 'default' }}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
size={collapsed ? 32 : 40}
|
||||||
|
src={user?.avatar}
|
||||||
|
style={{ backgroundColor: '#1677ff' }}
|
||||||
|
>
|
||||||
|
{user?.name?.[0]?.toUpperCase() || 'U'}
|
||||||
|
</Avatar>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="user-details">
|
||||||
|
<div className="user-name">{user?.name || 'User'}</div>
|
||||||
|
<div className="user-role">{user?.role || 'Member'}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="logout-btn" onClick={onLogout} title="退出登录">
|
||||||
|
<LogoutOutlined />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Sider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModernSidebar;
|
||||||
|
|
@ -1,3 +1,32 @@
|
||||||
|
:root {
|
||||||
|
--bg-color: #fff;
|
||||||
|
--bg-color-secondary: #fafafa;
|
||||||
|
--text-color: #000000e0;
|
||||||
|
--text-color-secondary: #00000073;
|
||||||
|
--border-color: #f0f0f0;
|
||||||
|
--header-bg: #fff;
|
||||||
|
--sider-bg: #fff;
|
||||||
|
--item-hover-bg: #f5f5f5;
|
||||||
|
--card-bg: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark {
|
||||||
|
--bg-color: #141414;
|
||||||
|
--bg-color-secondary: #1f1f1f;
|
||||||
|
--text-color: #ffffffd9;
|
||||||
|
--text-color-secondary: #ffffff73;
|
||||||
|
--border-color: #303030;
|
||||||
|
--header-bg: #141414;
|
||||||
|
--sider-bg: #141414;
|
||||||
|
--item-hover-bg: #1f1f1f;
|
||||||
|
--card-bg: #1f1f1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
.search-highlight {
|
.search-highlight {
|
||||||
background-color: #ffd54f !important;
|
background-color: #ffd54f !important;
|
||||||
color: black !important;
|
color: black !important;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware'
|
||||||
|
|
||||||
|
const useThemeStore = create(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
isDarkMode: false,
|
||||||
|
toggleTheme: () => set((state) => ({ isDarkMode: !state.isDarkMode })),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'theme-storage',
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
export default useThemeStore
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* 用户状态管理 Store
|
* 用户状态管理 Store
|
||||||
*/
|
*/
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist, createJSONStorage } from 'zustand/middleware'
|
||||||
|
|
||||||
const useUserStore = create(
|
const useUserStore = create(
|
||||||
persist(
|
persist(
|
||||||
|
|
@ -26,7 +26,7 @@ const useUserStore = create(
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'user-storage',
|
name: 'user-storage',
|
||||||
getStorage: () => localStorage,
|
storage: createJSONStorage(() => localStorage),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue