优化界面

main
mula.liu 2026-01-30 13:00:57 +08:00
parent 4cee9414a5
commit 4a99b10578
16 changed files with 672 additions and 319 deletions

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

View File

@ -1,6 +1,8 @@
import { useEffect } from 'react'
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 useThemeStore from '@/stores/themeStore'
import Login from '@/pages/Login/Login'
import ProjectList from '@/pages/ProjectList/ProjectList'
import DocumentPage from '@/pages/Document/DocumentPage'
@ -35,8 +37,23 @@ function LayoutWrapper() {
}
function App() {
const { isDarkMode } = useThemeStore()
useEffect(() => {
if (isDarkMode) {
document.body.classList.add('dark')
} else {
document.body.classList.remove('dark')
}
}, [isDarkMode])
return (
<ConfigProvider locale={zhCN}>
<ConfigProvider
locale={zhCN}
theme={{
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}
>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -1,12 +1,13 @@
.app-header {
background: #fff;
background: var(--header-bg);
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
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;
}
.logo-small {
width: 40px;
height: 40px;
border-radius: 8px;
transition: all 0.2s;
}
.logo-full {
height: 32px;
width: auto;
transition: all 0.2s;
}
.trigger {
font-size: 18px;
cursor: pointer;
transition: color 0.3s;
padding: 8px;
border-radius: 4px;
color: rgba(0, 0, 0, 0.65);
color: var(--text-color-secondary);
display: flex;
align-items: center;
}
@ -61,67 +49,29 @@
gap: 16px;
}
.header-search {
border-radius: 16px;
}
.header-actions {
display: flex;
align-items: center;
gap: 16px;
}
.header-icon {
font-size: 16px;
color: rgba(0, 0, 0, 0.65);
cursor: pointer;
transition: all 0.3s;
padding: 8px;
border-radius: 4px;
/* Icon Buttons */
.header-icon-btn {
display: flex;
align-items: center;
justify-content: center;
}
.header-icon:hover {
color: #1677ff;
background: rgba(22, 119, 255, 0.08);
}
.header-link {
font-size: 14px;
color: rgba(0, 0, 0, 0.65);
width: 40px;
height: 40px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
padding: 6px 12px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 4px;
color: var(--text-color-secondary);
transition: all 0.2s;
background: transparent;
}
.header-link:hover {
color: #1677ff;
background: rgba(22, 119, 255, 0.08);
}
.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;
.header-icon-btn:hover {
background-color: var(--item-hover-bg);
color: var(--text-color);
}
/* 通知面板样式 */
@ -135,7 +85,7 @@
.popover-header {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
@ -144,6 +94,7 @@
.popover-header .title {
font-weight: 600;
font-size: 16px;
color: var(--text-color);
}
.notification-list {
@ -155,23 +106,33 @@
padding: 12px 16px !important;
cursor: pointer;
transition: background 0.3s;
background: var(--bg-color);
}
.notification-item:hover {
background: #f5f5f5;
background: var(--item-hover-bg);
}
.notification-item.unread {
background: #e6f7ff;
}
/* Dark mode adjustment for unread */
body.dark .notification-item.unread {
background: #111d2c;
}
.notification-item.unread:hover {
background: #bae7ff;
}
body.dark .notification-item.unread:hover {
background: #112a45;
}
.content-text {
font-size: 13px;
color: #595959;
color: var(--text-color-secondary);
margin-top: 4px;
display: -webkit-box;
-webkit-line-clamp: 2;
@ -181,16 +142,13 @@
.time {
font-size: 12px;
color: #bfbfbf;
color: var(--text-color-secondary);
opacity: 0.8;
margin-top: 4px;
}
.popover-footer {
padding: 8px;
border-top: 1px solid #f0f0f0;
border-top: 1px solid var(--border-color);
text-align: center;
}
.ml-1 {
margin-left: 4px;
}

View File

@ -1,68 +1,41 @@
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 {
MenuFoldOutlined,
MenuUnfoldOutlined,
BellOutlined,
QuestionCircleOutlined,
FileTextOutlined,
CustomerServiceOutlined,
UserOutlined,
CheckOutlined,
ProjectOutlined,
TeamOutlined,
NotificationOutlined,
MoonOutlined,
SunOutlined,
GlobalOutlined
} from '@ant-design/icons'
import useUserStore from '@/stores/userStore'
import useNotificationStore from '@/stores/notificationStore'
import useThemeStore from '@/stores/themeStore'
import { getNotifications, getUnreadCount, markAsRead, markAllAsRead } from '@/api/notification'
import Toast from '@/components/Toast/Toast'
import headerMenuData from '../../data/headerMenuData.json'
import './AppHeader.css'
const { Header } = Layout
const { Text } = Typography
//
const iconMap = {
QuestionCircleOutlined: <QuestionCircleOutlined />,
FileTextOutlined: <FileTextOutlined />,
CustomerServiceOutlined: <CustomerServiceOutlined />,
}
function AppHeader({ collapsed, onToggle }) {
function AppHeader({ collapsed, onToggle, showLogo = true }) {
const navigate = useNavigate()
const { user, logout } = useUserStore()
const { user } = useUserStore()
const { unreadCount, fetchUnreadCount, decrementUnreadCount, resetUnreadCount } = useNotificationStore()
//
const userMenuItems = [
{
key: 'profile',
label: '个人中心',
},
{
key: 'settings',
label: '账户设置',
},
{
type: 'divider',
},
{
key: 'logout',
label: '退出登录',
},
]
const { isDarkMode, toggleTheme } = useThemeStore()
const [notifications, setNotifications] = useState([])
const [loading, setLoading] = useState(false)
const [popoverVisible, setPopoverVisible] = useState(false)
const [lang, setLang] = useState('zh')
useEffect(() => {
if (user) {
fetchUnreadCount()
// 2
const timer = setInterval(fetchUnreadCount, 120000)
return () => clearInterval(timer)
}
@ -83,7 +56,7 @@ function AppHeader({ collapsed, onToggle }) {
const handleMarkRead = async (id) => {
try {
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()
} catch (error) {
console.error('Mark read error:', error)
@ -93,7 +66,7 @@ function AppHeader({ collapsed, onToggle }) {
const handleMarkAllRead = async () => {
try {
await markAllAsRead()
setNotifications(notifications.map(n => ({ ...n, is_read: 1 })))
setNotifications(notifications.map(n => ({ ...n, is_read: true })))
resetUnreadCount()
Toast.success('操作成功', '所有通知已标记为已读')
} catch (error) {
@ -161,73 +134,46 @@ function AppHeader({ collapsed, onToggle }) {
</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 (
<Header className="app-header">
<Header className="app-header" style={{ paddingLeft: showLogo ? 24 : 0 }}>
{/* 左侧Logo + 折叠按钮 */}
<div className="header-left">
{/* Logo 区域 */}
<div className="header-logo">
<img src="/favicon.svg" alt="logo" style={{ width: 32, height: 32, marginRight: 8 }} />
<h2 style={{ margin: 0, color: '#1677ff', fontWeight: 'bold' }}>NexDocus</h2>
</div>
{showLogo && (
<div className="header-left">
{/* Logo 区域 */}
<div className="header-logo">
<img src="/favicon.svg" alt="logo" style={{ width: 32, height: 32, marginRight: 8 }} />
<h2 style={{ margin: 0, color: '#1677ff', fontWeight: 'bold' }}>NexDocus</h2>
</div>
{/* 折叠按钮 */}
<div className="trigger" onClick={onToggle}>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
{/* 折叠按钮 */}
<div className="trigger" onClick={onToggle}>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
</div>
</div>
)}
{!showLogo && <div />} {/* Spacer if left is empty */}
{/* 右侧:功能按钮 + 用户信息 */}
{/* 右侧:功能按钮 */}
<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>
))}
{/* 消息中心 */}
{/* 1. 主题切换 */}
<div className="header-icon-btn" title="切换主题" onClick={toggleTheme}>
{isDarkMode ? <SunOutlined style={{ fontSize: 18 }} /> : <MoonOutlined style={{ fontSize: 18 }} />}
</div>
{/* 2. 语言切换 */}
<Segmented
value={lang}
onChange={setLang}
options={[
{ label: '中', value: 'zh' },
{ label: 'EN', value: 'en' },
]}
style={{ fontWeight: 500 }}
/>
{/* 3. 消息通知 */}
<Popover
content={notificationContent}
trigger="click"
@ -241,27 +187,13 @@ function AppHeader({ collapsed, onToggle }) {
placement="bottomRight"
overlayClassName="header-notification-popover"
>
<div className="header-link" title="消息中心">
<Badge count={unreadCount} size="small" offset={[4, -2]}>
<BellOutlined />
<div className="header-icon-btn" title="消息中心">
<Badge count={unreadCount} size="small" offset={[2, -2]}>
<BellOutlined style={{ fontSize: 18 }} />
</Badge>
<span className="ml-1">消息</span>
</div>
</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>
</div>
</Header>

View File

@ -1,5 +1,4 @@
import { useState, useEffect } from 'react'
import { Layout, Menu, Badge, message } from 'antd'
import { useNavigate, useLocation } from 'react-router-dom'
import {
DashboardOutlined,
@ -14,34 +13,40 @@ import {
FileTextOutlined,
SafetyOutlined,
TeamOutlined,
ProjectOutlined,
RocketOutlined,
ReadOutlined,
BookOutlined,
} from '@ant-design/icons'
import { message } from 'antd'
import { getUserMenus } from '@/api/menu'
import './AppSider.css'
const { Sider } = Layout
import useUserStore from '@/stores/userStore'
import ModernSidebar from '../ModernSidebar/ModernSidebar'
//
const iconMap = {
DashboardOutlined,
DesktopOutlined,
GlobalOutlined,
CloudServerOutlined,
UserOutlined,
AppstoreOutlined,
SettingOutlined,
BlockOutlined,
FolderOutlined,
FileTextOutlined,
SafetyOutlined,
TeamOutlined,
DashboardOutlined: <DashboardOutlined />,
DesktopOutlined: <DesktopOutlined />,
GlobalOutlined: <GlobalOutlined />,
CloudServerOutlined: <CloudServerOutlined />,
UserOutlined: <UserOutlined />,
AppstoreOutlined: <AppstoreOutlined />,
SettingOutlined: <SettingOutlined />,
BlockOutlined: <BlockOutlined />,
FolderOutlined: <FolderOutlined />,
FileTextOutlined: <FileTextOutlined />,
SafetyOutlined: <SafetyOutlined />,
TeamOutlined: <TeamOutlined />,
ProjectOutlined: <ProjectOutlined />,
ReadOutlined: <ReadOutlined />,
BookOutlined: <BookOutlined />,
}
function AppSider({ collapsed, onToggle }) {
const navigate = useNavigate()
const location = useLocation()
const [openKeys, setOpenKeys] = useState([])
const [menuData, setMenuData] = useState([])
const [loading, setLoading] = useState(true)
const { user, logout } = useUserStore()
const [menuGroups, setMenuGroups] = useState([])
//
useEffect(() => {
@ -52,120 +57,137 @@ function AppSider({ collapsed, onToggle }) {
try {
const res = await getUserMenus()
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) {
console.error('Load menus error:', error)
message.error('加载菜单失败')
} finally {
setLoading(false)
}
}
//
const getDefaultOpenKeys = () => {
const transformMenuData = (data) => {
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
for (const item of menuData) {
if (item.children) {
const hasChild = item.children.some((c) => c.path === path)
if (hasChild) {
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
// items
for (const group of menuGroups) {
for (const item of group.items) {
if (item.path === path) return item.key
}
}
return ''
}
//
const getMenuItems = () => {
return menuData.map((item) => {
const IconComponent = iconMap[item.icon]
const icon = IconComponent ? <IconComponent /> : null
const logoNode = (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, paddingLeft: 8 }}>
<img src="/favicon.svg" alt="logo" style={{ width: 32, height: 32 }} />
{!collapsed && (
<span style={{ fontSize: 18, fontWeight: 'bold', color: '#111' }}>NexDocus</span>
)}
</div>
)
//
if (item.children && item.children.length > 0) {
return {
key: item.menu_code,
icon: icon,
label: item.menu_name,
popupClassName: 'sider-submenu-popup',
children: item.children.map((child) => ({
key: child.menu_code,
label: child.menu_name,
})),
}
}
// URL
const getUserAvatarUrl = () => {
if (!user?.avatar) return null
// avatar 2/avatar/xxx.jpg
// API : /api/v1/auth/avatar/{user_id}/{filename}
// http
if (user.avatar.startsWith('http')) return user.avatar
//
return {
key: item.menu_code,
icon: icon,
label: item.menu_name,
}
})
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
}
const userObj = user ? {
name: user.nickname || user.username,
role: user.role_name || 'Admin',
avatar: getUserAvatarUrl()
} : null
return (
<Sider
className="app-sider"
<ModernSidebar
logo={logoNode}
menuGroups={menuGroups}
activeKey={getActiveKey()}
onNavigate={handleNavigate}
user={userObj}
onLogout={handleLogout}
onProfileClick={handleProfileClick}
collapsed={collapsed}
width={200}
collapsedWidth={64}
trigger={null}
>
{/* 菜单 */}
<Menu
mode="inline"
selectedKeys={[getSelectedKey()]}
openKeys={collapsed ? [] : openKeys}
onOpenChange={handleOpenChange}
onClick={handleMenuClick}
className="sider-menu"
items={getMenuItems()}
/>
</Sider>
onCollapse={onToggle}
/>
)
}

View File

@ -1,24 +1,27 @@
.main-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
background: #fafafa;
flex-direction: row; /* Changed to row for Sider-Left layout */
background: var(--bg-color-secondary);
}
.main-content-wrapper {
display: flex;
flex-direction: column;
flex: 1;
height: calc(100vh - 64px);
background: #fafafa;
height: 100vh;
background: var(--bg-color-secondary);
overflow: hidden;
}
.main-content {
background: #f5f5f5;
background: var(--bg-color-secondary);
overflow-y: auto;
flex: 1;
padding: 16px;
}
.content-wrapper {
padding: 8px;
padding: 0;
min-height: 100%;
}

View File

@ -14,10 +14,10 @@ function MainLayout({ children }) {
}
return (
<Layout className="main-layout">
<AppHeader collapsed={collapsed} onToggle={toggleCollapsed} />
<Layout className="main-layout" hasSider>
<AppSider collapsed={collapsed} onToggle={toggleCollapsed} />
<Layout className="main-content-wrapper">
<AppSider collapsed={collapsed} onToggle={toggleCollapsed} />
<AppHeader collapsed={collapsed} onToggle={toggleCollapsed} showLogo={false} />
<Content className="main-content">
<div className="content-wrapper">
{children}

View File

@ -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 */
}

View File

@ -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;

View File

@ -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 {
background-color: #ffd54f !important;
color: black !important;

View File

@ -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

View File

@ -2,7 +2,7 @@
* 用户状态管理 Store
*/
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { persist, createJSONStorage } from 'zustand/middleware'
const useUserStore = create(
persist(
@ -26,7 +26,7 @@ const useUserStore = create(
}),
{
name: 'user-storage',
getStorage: () => localStorage,
storage: createJSONStorage(() => localStorage),
}
)
)