优化界面

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 { .search-highlight {
background-color: #ffd54f !important; background-color: #ffd54f !important;
color: black !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 * 用户状态管理 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),
} }
) )
) )