管理后台初始化,登录,团队管理,报价单管理 完成

This commit is contained in:
‘Liammcl’
2024-12-15 17:39:58 +08:00
commit 5882bf9548
91 changed files with 16260 additions and 0 deletions

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

View 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>
);

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

View 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);

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

View 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}
/>
);

View 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>
);
};

View 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>
);

View 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>
);

View 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'
}}
/>
);
};

View 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>
);

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

View 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);