管理后台初始化,登录,团队管理,报价单管理 完成
This commit is contained in:
80
src/components/Layout/Header.jsx
Normal file
80
src/components/Layout/Header.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { Layout, Switch, Button, Dropdown } from 'antd';
|
||||
import { UserOutlined, LogoutOutlined } from '@ant-design/icons';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { MenuTrigger } from '../common/MenuTrigger';
|
||||
|
||||
const { Header: AntHeader } = Layout;
|
||||
|
||||
const Header = ({ collapsed, setCollapsed }) => {
|
||||
const { isDarkMode, toggleTheme } = useTheme();
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const userMenuItems = [
|
||||
{
|
||||
key: 'profile',
|
||||
icon: <UserOutlined />,
|
||||
label: '个人信息',
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
onClick: handleLogout,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<AntHeader
|
||||
style={{
|
||||
padding: 0,
|
||||
background: isDarkMode ? '#141414' : '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<MenuTrigger
|
||||
collapsed={collapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mr-6">
|
||||
<Switch
|
||||
checked={isDarkMode}
|
||||
onChange={toggleTheme}
|
||||
checkedChildren="🌙"
|
||||
unCheckedChildren="☀️"
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
menu={{ items: userMenuItems }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<UserOutlined />}
|
||||
className="flex items-center"
|
||||
>
|
||||
{user?.email}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</AntHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
19
src/components/Layout/Logo.jsx
Normal file
19
src/components/Layout/Logo.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { RocketOutlined } from '@ant-design/icons';
|
||||
|
||||
export const Logo = ({ collapsed, isDarkMode }) => (
|
||||
<div className="logo">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<RocketOutlined className="text-2xl text-primary-500" />
|
||||
{!collapsed && (
|
||||
<h1 className="text-lg font-semibold m-0" style={{
|
||||
background: 'var(--primary-gradient)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent'
|
||||
}}>
|
||||
Uppeta
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
36
src/components/Layout/MainLayout.jsx
Normal file
36
src/components/Layout/MainLayout.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React, { useState, Suspense } from 'react';
|
||||
import { Layout, Spin } from 'antd';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Header from './Header';
|
||||
import Sidebar from './Sidebar';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
const MainLayout = () => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sidebar collapsed={collapsed} />
|
||||
<Layout>
|
||||
<Header collapsed={collapsed} setCollapsed={setCollapsed} />
|
||||
<Content
|
||||
style={{
|
||||
margin: '24px 16px',
|
||||
padding: 24,
|
||||
background: isDarkMode ? '#141414' : '#fff',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<Spin size="large" />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
54
src/components/Layout/Sidebar.jsx
Normal file
54
src/components/Layout/Sidebar.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Layout, Menu } from 'antd';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { getMenuItems } from '@/utils/menuUtils';
|
||||
import { Logo } from '@/components/common/Logo';
|
||||
|
||||
const { Sider } = Layout;
|
||||
|
||||
const Sidebar = ({ collapsed }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
const menuItems = useMemo(() => getMenuItems(), []);
|
||||
|
||||
const defaultOpenKeys = useMemo(() => {
|
||||
const pathSegments = location.pathname.split('/').filter(Boolean);
|
||||
return pathSegments.reduce((acc, _, index) => {
|
||||
const path = `/${pathSegments.slice(0, index + 1).join('/')}`;
|
||||
acc.push(path);
|
||||
return acc;
|
||||
}, []);
|
||||
}, [location.pathname]);
|
||||
|
||||
// Handle menu item click
|
||||
const handleMenuClick = ({ key }) => {
|
||||
navigate(key);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
theme={isDarkMode ? 'dark' : 'light'}
|
||||
width={256}
|
||||
collapsedWidth={80} // 添加这个属性
|
||||
className={`app-sidebar ${collapsed ? 'collapsed' : ''}`} // 添加collapsed类名
|
||||
>
|
||||
<Logo collapsed={collapsed} isDarkMode={isDarkMode} />
|
||||
<Menu
|
||||
theme={isDarkMode ? 'dark' : 'light'}
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
defaultOpenKeys={defaultOpenKeys}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
/>
|
||||
</Sider>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Sidebar);
|
||||
22
src/components/auth/ProtectedRoute.jsx
Normal file
22
src/components/auth/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Spin } from 'antd';
|
||||
|
||||
export const ProtectedRoute = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
18
src/components/common/BaseTable.jsx
Normal file
18
src/components/common/BaseTable.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Table } from 'antd';
|
||||
|
||||
export const BaseTable = ({
|
||||
columns,
|
||||
dataSource,
|
||||
loading = false,
|
||||
rowKey = 'id',
|
||||
...props
|
||||
}) => (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
loading={loading}
|
||||
rowKey={rowKey}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
26
src/components/common/ColorIcon.jsx
Normal file
26
src/components/common/ColorIcon.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
||||
export const ColorIcon = ({ icon: Icon, background }) => {
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
inline-flex items-center justify-center
|
||||
w-8 h-8 rounded-lg transition-all duration-300
|
||||
${isDarkMode ? 'bg-opacity-25' : 'bg-opacity-15'}
|
||||
hover:${isDarkMode ? 'bg-opacity-35' : 'bg-opacity-25'}
|
||||
`}
|
||||
style={{ backgroundColor: background }}
|
||||
>
|
||||
<span
|
||||
className="text-base transition-colors duration-300"
|
||||
style={{ color: background }}
|
||||
>
|
||||
{Icon}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
17
src/components/common/Icon.jsx
Normal file
17
src/components/common/Icon.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Icon = ({ name, className = '', style = {} }) => (
|
||||
<span
|
||||
className={`material-symbols-rounded ${className}`}
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
background: 'var(--primary-gradient)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
fontVariationSettings: "'FILL' 1, 'wght' 400, 'GRAD' 200, 'opsz' 20",
|
||||
...style
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
21
src/components/common/Logo.jsx
Normal file
21
src/components/common/Logo.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Logo = ({ collapsed, isDarkMode }) => (
|
||||
<div className="logo">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="material-symbols-rounded text-primary-500">
|
||||
rocket_launch
|
||||
</span>
|
||||
<h1
|
||||
style={{
|
||||
color: isDarkMode ? '#fff' : '#000',
|
||||
fontSize: collapsed ? '14px' : '18px',
|
||||
margin: 0,
|
||||
display: collapsed ? 'none' : 'block',
|
||||
}}
|
||||
>
|
||||
Uppeta
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
19
src/components/common/MenuTrigger.jsx
Normal file
19
src/components/common/MenuTrigger.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
|
||||
|
||||
export const MenuTrigger = ({ collapsed, setCollapsed, isDarkMode }) => {
|
||||
const Icon = collapsed ? MenuUnfoldOutlined : MenuFoldOutlined;
|
||||
|
||||
return (
|
||||
<Icon
|
||||
className="trigger"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
padding: '0 24px',
|
||||
cursor: 'pointer',
|
||||
color: isDarkMode ? '#fff' : '#000'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
14
src/components/common/PageHeader.jsx
Normal file
14
src/components/common/PageHeader.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Button } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
export const PageHeader = ({ title, onAdd, addButtonText = '新增' }) => (
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
{onAdd && (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
||||
{addButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
44
src/components/dashboard/StatCard.jsx
Normal file
44
src/components/dashboard/StatCard.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Card } from 'antd';
|
||||
import { formatCurrency } from '@/utils/format';
|
||||
|
||||
const StatCard = ({ icon, title, count, amount, color = '#1677ff' }) => {
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<Card
|
||||
styles={{
|
||||
body: {
|
||||
padding: '20px',
|
||||
height: '100%',
|
||||
background: 'var(--color-bg-container)',
|
||||
}
|
||||
}}
|
||||
className="h-full hover:shadow-md transition-shadow duration-300"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center text-lg"
|
||||
style={{
|
||||
backgroundColor: `${color}15`,
|
||||
}}
|
||||
>
|
||||
<span style={{ color }}>{icon}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base text-gray-500 dark:text-gray-400 m-0">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-400 dark:text-gray-500 mt-1">
|
||||
{count} invoices
|
||||
</div>
|
||||
<div className="text-xl font-semibold mt-2 dark:text-white">
|
||||
{formatCurrency(amount)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatCard;
|
||||
53
src/components/dashboard/StatisticsOverview.jsx
Normal file
53
src/components/dashboard/StatisticsOverview.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { FileTextOutlined, CheckCircleOutlined, ClockCircleOutlined, WarningOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import StatCard from './StatCard';
|
||||
|
||||
const stats = [
|
||||
{
|
||||
icon: <FileTextOutlined className="text-lg" />,
|
||||
title: 'Total',
|
||||
count: 20,
|
||||
amount: 46218.04,
|
||||
color: '#1677ff',
|
||||
},
|
||||
{
|
||||
icon: <CheckCircleOutlined className="text-lg" />,
|
||||
title: 'Paid',
|
||||
count: 10,
|
||||
amount: 23110.23,
|
||||
color: '#52c41a',
|
||||
},
|
||||
{
|
||||
icon: <ClockCircleOutlined className="text-lg" />,
|
||||
title: 'Pending',
|
||||
count: 6,
|
||||
amount: 13825.05,
|
||||
color: '#faad14',
|
||||
},
|
||||
{
|
||||
icon: <WarningOutlined className="text-lg" />,
|
||||
title: 'Overdue',
|
||||
count: 2,
|
||||
amount: 4655.63,
|
||||
color: '#ff4d4f',
|
||||
},
|
||||
{
|
||||
icon: <EditOutlined className="text-lg" />,
|
||||
title: 'Draft',
|
||||
count: 2,
|
||||
amount: 4627.13,
|
||||
color: '#8c8c8c',
|
||||
},
|
||||
];
|
||||
|
||||
const StatisticsOverview = () => {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
{stats.map((stat, index) => (
|
||||
<StatCard key={index} {...stat} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(StatisticsOverview);
|
||||
Reference in New Issue
Block a user