管理后台初始化,登录,团队管理,报价单管理 完成
This commit is contained in:
2
.env
Normal file
2
.env
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_SUPABASE_URL=https://base.uppmkt.com
|
||||
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q
|
||||
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_SUPABASE_URL=your_supabase_url
|
||||
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
17
index.html
Normal file
17
index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Uppeta</title>
|
||||
<!-- Material Icons -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" rel="stylesheet">
|
||||
<!-- Poppins Font -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
jsconfig.json
Normal file
8
jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
6990
package-lock.json
generated
Normal file
6990
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "spa-theme-switcher",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"antd": "^5.11.0",
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"react-router-dom": "^6.18.0",
|
||||
"@supabase/supabase-js": "^2.38.4",
|
||||
"styled-components": "^6.1.0",
|
||||
"recharts": "^2.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@vitejs/plugin-react": "^4.0.3",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.3",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.31",
|
||||
"sass": "^1.69.5",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"vite": "^4.4.5"
|
||||
}
|
||||
}
|
||||
4854
pnpm-lock.yaml
generated
Normal file
4854
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
45
src/App.jsx
Normal file
45
src/App.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ConfigProvider, theme } from 'antd';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import AppRoutes from './routes/AppRoutes';
|
||||
import { useTheme } from './contexts/ThemeContext';
|
||||
|
||||
const ThemedApp = () => {
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
borderRadius: 4,
|
||||
colorBgContainer: isDarkMode ? '#141414' : '#ffffff',
|
||||
colorBgElevated: isDarkMode ? '#1f1f1f' : '#ffffff',
|
||||
colorText: isDarkMode ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.85)',
|
||||
colorTextSecondary: isDarkMode ? 'rgba(255, 255, 255, 0.45)' : 'rgba(0, 0, 0, 0.45)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className={isDarkMode ? 'dark' : ''}>
|
||||
<AppRoutes />
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
<ThemedApp />
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
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);
|
||||
69
src/config/routes.js
Normal file
69
src/config/routes.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
// Dashboard route
|
||||
const dashboardRoute = {
|
||||
path: 'dashboard',
|
||||
component: lazy(() => import('@/pages/Dashboard')),
|
||||
name: '仪表盘',
|
||||
icon: 'dashboard',
|
||||
};
|
||||
|
||||
// Resource Management routes
|
||||
const resourceRoutes = [
|
||||
{
|
||||
path: 'team',
|
||||
component: lazy(() => import('@/pages/resource/team')),
|
||||
name: '团队管理',
|
||||
icon: 'team',
|
||||
},
|
||||
{
|
||||
path: 'bucket',
|
||||
component: lazy(() => import('@/pages/resource/team')),
|
||||
name: '对象存储',
|
||||
icon: 'team',
|
||||
},
|
||||
];
|
||||
|
||||
// Company routes
|
||||
const companyRoutes = [
|
||||
{
|
||||
path: 'quotation',
|
||||
component: lazy(() => import('@/pages/company/quotation')),
|
||||
name: '报价单',
|
||||
icon: 'file',
|
||||
},
|
||||
{
|
||||
path: 'customer',
|
||||
component: lazy(() => import('@/pages/company/customer')),
|
||||
name: '客户管理',
|
||||
icon: 'user',
|
||||
},
|
||||
];
|
||||
|
||||
const marketingRoutes = [
|
||||
];
|
||||
|
||||
export const routes = [
|
||||
dashboardRoute,
|
||||
{
|
||||
path: 'resource',
|
||||
component: lazy(() => import('@/pages/resource')),
|
||||
name: '资源管理',
|
||||
icon: 'appstore',
|
||||
children: resourceRoutes,
|
||||
},
|
||||
{
|
||||
path: 'company',
|
||||
component: lazy(() => import('@/pages/company')),
|
||||
name: '公司管理',
|
||||
icon: 'bank',
|
||||
children: companyRoutes,
|
||||
},
|
||||
{
|
||||
path: 'marketing',
|
||||
component: lazy(() => import('@/pages/marketing')),
|
||||
name: '行销中心',
|
||||
icon: 'shopping',
|
||||
children: marketingRoutes,
|
||||
},
|
||||
];
|
||||
36
src/config/supabase.js
Normal file
36
src/config/supabase.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
let supabaseInstance = null;
|
||||
|
||||
export const createSupabase = () => {
|
||||
if (!supabaseInstance) {
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error('Missing Supabase environment variables');
|
||||
}
|
||||
|
||||
supabaseInstance = createClient(
|
||||
supabaseUrl,
|
||||
supabaseAnonKey,
|
||||
{
|
||||
auth: {
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
db: {
|
||||
schema: 'limq'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
return supabaseInstance;
|
||||
};
|
||||
|
||||
export const supabase = createSupabase();
|
||||
|
||||
export const clearSupabaseInstance = () => {
|
||||
supabaseInstance = null;
|
||||
};
|
||||
13
src/constants/status.js
Normal file
13
src/constants/status.js
Normal file
@@ -0,0 +1,13 @@
|
||||
export const STATUS = {
|
||||
ACTIVE: '进行中',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
PENDING: '待处理',
|
||||
};
|
||||
|
||||
export const STATUS_COLORS = {
|
||||
[STATUS.ACTIVE]: 'green',
|
||||
[STATUS.COMPLETED]: 'blue',
|
||||
[STATUS.CANCELLED]: 'red',
|
||||
[STATUS.PENDING]: 'orange',
|
||||
};
|
||||
153
src/contexts/AuthContext.jsx
Normal file
153
src/contexts/AuthContext.jsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { supabase } from '@/config/supabase';
|
||||
import { message } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const navigate = useNavigate();
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 监听认证状态变化
|
||||
useEffect(() => {
|
||||
// 获取初始会话状态
|
||||
const initSession = async () => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
setUser(session?.user ?? null);
|
||||
} catch (error) {
|
||||
console.error('Error getting session:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initSession();
|
||||
|
||||
// 订阅认证状态变化
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setUser(session?.user ?? null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription?.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 邮箱密码登录
|
||||
const login = async (email, password) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setUser(data.user);
|
||||
return data;
|
||||
} catch (error) {
|
||||
message.error(error.message || '登录失败');
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Google 登录
|
||||
const signInWithGoogle = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/auth/callback`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
message.error(error.message || 'Google 登录失败');
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 注册
|
||||
const register = async (email, password) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/auth/callback`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
message.success('注册成功!请查收验证邮件。');
|
||||
return data;
|
||||
} catch (error) {
|
||||
message.error(error.message || '注册失败');
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 登出
|
||||
const logout = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { error } = await supabase.auth.signOut({
|
||||
scope: 'local'
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
message.success('已成功登出');
|
||||
navigate('/login', { replace: true });
|
||||
|
||||
} catch (error) {
|
||||
message.error(error.message || '登出失败');
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
register,
|
||||
signInWithGoogle,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
33
src/contexts/ThemeContext.jsx
Normal file
33
src/contexts/ThemeContext.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
const ThemeContext = createContext();
|
||||
|
||||
export const ThemeProvider = ({ children }) => {
|
||||
const [isDarkMode, setIsDarkMode] = useState(() => {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
return savedTheme === 'dark' || (savedTheme === null && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
|
||||
document.documentElement.classList.toggle('dark', isDarkMode);
|
||||
}, [isDarkMode]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setIsDarkMode(!isDarkMode);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ isDarkMode, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
96
src/hooks/resource/useResource.js
Normal file
96
src/hooks/resource/useResource.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { resourceService } from '@/services/supabase/resource';
|
||||
|
||||
export const useResources = (initialPagination, initialSorter) => {
|
||||
const [resources, setResources] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPagination, setCurrentPagination] = useState(initialPagination);
|
||||
const [currentSorter, setCurrentSorter] = useState(initialSorter);
|
||||
|
||||
const fetchResources = useCallback(async (params = {}) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const newPagination = {
|
||||
current: params.current || currentPagination.current,
|
||||
pageSize: params.pageSize || currentPagination.pageSize
|
||||
};
|
||||
const newSorter = {
|
||||
field: params.field || currentSorter.field,
|
||||
order: params.order || currentSorter.order
|
||||
};
|
||||
|
||||
setCurrentPagination(newPagination);
|
||||
setCurrentSorter(newSorter);
|
||||
|
||||
const { data, total: newTotal } = await resourceService.getResources({
|
||||
page: newPagination.current,
|
||||
pageSize: newPagination.pageSize,
|
||||
orderBy: newSorter.field,
|
||||
ascending: newSorter.order === 'ascend',
|
||||
...(params?.search !== '' ? { searchQuery: params.search } : {})
|
||||
});
|
||||
|
||||
setResources(data || []);
|
||||
setTotal(newTotal || 0);
|
||||
|
||||
return { data, total: newTotal };
|
||||
} catch (error) {
|
||||
console.error('获取资源列表失败:', error);
|
||||
message.error('获取资源列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentPagination, currentSorter]);
|
||||
|
||||
const createResource = async (values) => {
|
||||
try {
|
||||
const newResource = await resourceService.createResource(values);
|
||||
await fetchResources({ current: 1 });
|
||||
message.success('创建资源成功');
|
||||
return newResource;
|
||||
} catch (error) {
|
||||
message.error('创建资源失败');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const updateResource = async (id, values) => {
|
||||
try {
|
||||
const updatedResource = await resourceService.updateResource(id, values);
|
||||
await fetchResources({ current: currentPagination.current });
|
||||
message.success('更新资源成功');
|
||||
return updatedResource;
|
||||
} catch (error) {
|
||||
message.error('更新资源失败');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteResource = async (id) => {
|
||||
try {
|
||||
await resourceService.deleteResource(id);
|
||||
const newCurrent = resources.length === 1 && currentPagination.current > 1
|
||||
? currentPagination.current - 1
|
||||
: currentPagination.current;
|
||||
await fetchResources({ current: newCurrent });
|
||||
message.success('删除资源成功');
|
||||
} catch (error) {
|
||||
message.error('删除资源失败');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
resources,
|
||||
loading,
|
||||
total,
|
||||
currentPagination,
|
||||
currentSorter,
|
||||
fetchResources,
|
||||
createResource,
|
||||
updateResource,
|
||||
deleteResource,
|
||||
};
|
||||
};
|
||||
72
src/hooks/team/useTeamMemberships.js
Normal file
72
src/hooks/team/useTeamMemberships.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { teamMembershipService } from '@/services/supabase/teamMembership';
|
||||
|
||||
export const useTeamMemberships = (teamId) => {
|
||||
const [memberships, setMemberships] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchMemberships = useCallback(async () => {
|
||||
if (!teamId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await teamMembershipService.getMemberships(teamId);
|
||||
setMemberships(data);
|
||||
} catch (error) {
|
||||
message.error('Failed to fetch team memberships');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [teamId]);
|
||||
|
||||
const createMembership = async (values) => {
|
||||
try {
|
||||
const newMembership = await teamMembershipService.createMembership({
|
||||
...values,
|
||||
teamId,
|
||||
});
|
||||
setMemberships(prev => [...prev, newMembership]);
|
||||
message.success('Member added successfully');
|
||||
return newMembership;
|
||||
} catch (error) {
|
||||
message.error('Failed to add member');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const updateMembership = async (id, values) => {
|
||||
try {
|
||||
const updatedMembership = await teamMembershipService.updateMembership(id, values);
|
||||
setMemberships(prev => prev.map(membership =>
|
||||
membership.id === id ? updatedMembership : membership
|
||||
));
|
||||
message.success('Member updated successfully');
|
||||
return updatedMembership;
|
||||
} catch (error) {
|
||||
message.error('Failed to update member');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMembership = async (id) => {
|
||||
try {
|
||||
await teamMembershipService.deleteMembership(id);
|
||||
setMemberships(prev => prev.filter(membership => membership.id !== id));
|
||||
message.success('Member removed successfully');
|
||||
} catch (error) {
|
||||
message.error('Failed to remove member');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
memberships,
|
||||
loading,
|
||||
fetchMemberships,
|
||||
createMembership,
|
||||
updateMembership,
|
||||
deleteMembership,
|
||||
};
|
||||
};
|
||||
71
src/hooks/team/useTeams.js
Normal file
71
src/hooks/team/useTeams.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { teamService } from '@/services/supabase/team';
|
||||
|
||||
export const useTeams = (pagination, sorter) => {
|
||||
const [teams, setTeams] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchTeams = useCallback(async (params = {}) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data, total } = await teamService.getTeams({
|
||||
page: params.current || pagination.current,
|
||||
pageSize: params.pageSize || pagination.pageSize,
|
||||
orderBy: params.field || sorter.field,
|
||||
ascending: params.order === 'ascend',
|
||||
...(params?.search!==''?{searchQuery:params.search}:{})
|
||||
});
|
||||
setTeams(data);
|
||||
return { data, total };
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('获取团队列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pagination.current, pagination.pageSize, sorter.field, sorter.order]);
|
||||
|
||||
const createTeam = async (values) => {
|
||||
try {
|
||||
const newTeam = await teamService.createTeam(values);
|
||||
setTeams(prev => [...prev, newTeam]);
|
||||
return newTeam;
|
||||
} catch (error) {
|
||||
message.error('Failed to create team');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const updateTeam = async (id, values) => {
|
||||
try {
|
||||
const updatedTeam = await teamService.updateTeam(id, values);
|
||||
setTeams(prev => prev.map(team =>
|
||||
team.id === id ? updatedTeam : team
|
||||
));
|
||||
return updatedTeam;
|
||||
} catch (error) {
|
||||
message.error('Failed to update team');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTeam = async (id) => {
|
||||
try {
|
||||
await teamService.deleteTeam(id);
|
||||
setTeams(prev => prev.filter(team => team.id !== id));
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
teams,
|
||||
loading,
|
||||
fetchTeams,
|
||||
createTeam,
|
||||
updateTeam,
|
||||
deleteTeam,
|
||||
};
|
||||
};
|
||||
0
src/index.css
Normal file
0
src/index.css
Normal file
14
src/main.jsx
Normal file
14
src/main.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import App from './App';
|
||||
import './styles/main.scss';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<App />
|
||||
</ConfigProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
23
src/pages/Dashboard.jsx
Normal file
23
src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { Card } from 'antd';
|
||||
import StatisticsOverview from '@/components/dashboard/StatisticsOverview';
|
||||
|
||||
const Dashboard = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card
|
||||
title="Statistics Overview"
|
||||
bordered={false}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '24px',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StatisticsOverview />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
24
src/pages/Profile.jsx
Normal file
24
src/pages/Profile.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Card, Avatar, Typography, Descriptions } from 'antd';
|
||||
import { UserOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const Profile = () => {
|
||||
return (
|
||||
<Card>
|
||||
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
|
||||
<Avatar size={64} icon={<UserOutlined />} />
|
||||
<Title level={3} style={{ marginTop: '16px' }}>John Doe</Title>
|
||||
</div>
|
||||
<Descriptions bordered>
|
||||
<Descriptions.Item label="Username">johndoe</Descriptions.Item>
|
||||
<Descriptions.Item label="Email">john@example.com</Descriptions.Item>
|
||||
<Descriptions.Item label="Role">Administrator</Descriptions.Item>
|
||||
<Descriptions.Item label="Status">Active</Descriptions.Item>
|
||||
<Descriptions.Item label="Join Date">2023-10-25</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
40
src/pages/Settings.jsx
Normal file
40
src/pages/Settings.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Form, Input, Button, Card, message } from 'antd';
|
||||
|
||||
const Settings = () => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const onFinish = (values) => {
|
||||
console.log('Success:', values);
|
||||
message.success('Settings updated successfully');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="Settings">
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onFinish}
|
||||
initialValues={{
|
||||
email: 'john@example.com',
|
||||
notifications: true,
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
label="Email"
|
||||
name="email"
|
||||
rules={[{ required: true, type: 'email' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
Save Changes
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
102
src/pages/auth/Login.jsx
Normal file
102
src/pages/auth/Login.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { Form, Input, Button, Divider, message } from 'antd';
|
||||
import { GoogleOutlined } from '@ant-design/icons';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
const Login = () => {
|
||||
const navigate = useNavigate();
|
||||
const { login, signInWithGoogle } = useAuth();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleLogin = async (values) => {
|
||||
try {
|
||||
await login(values.email, values.password);
|
||||
message.success('登录成功!');
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
try {
|
||||
await signInWithGoogle();
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
console.error('Google login error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-gradient-to-br from-[#f5f7fa] to-[#c3cfe2]">
|
||||
<div className="w-full max-w-[1200px] mx-auto flex p-8 gap-16">
|
||||
<div className="flex-1 bg-white p-12 rounded-[20px] shadow-[0_10px_30px_rgba(0,0,0,0.1)]">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-3xl font-bold mb-2 bg-gradient-to-r from-primary-500 to-[#36cff0] bg-clip-text text-transparent">
|
||||
Uppeta
|
||||
</h1>
|
||||
<p className="text-gray-500">欢迎回来!请登录您的账户。</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
name="login"
|
||||
onFinish={handleLogin}
|
||||
layout="vertical"
|
||||
size="large"
|
||||
>
|
||||
<Form.Item
|
||||
name="email"
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱!' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址!' }
|
||||
]}
|
||||
>
|
||||
<Input placeholder="邮箱" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码!' }
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="密码" />
|
||||
</Form.Item>
|
||||
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<Form.Item name="remember" valuePropName="checked" noStyle>
|
||||
<Link to="/forgot-password" className="text-primary-500 hover:text-primary-600">
|
||||
忘记密码?
|
||||
</Link>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<Divider>或</Divider>
|
||||
|
||||
<Button
|
||||
icon={<GoogleOutlined />}
|
||||
block
|
||||
onClick={handleGoogleLogin}
|
||||
className="mb-6"
|
||||
>
|
||||
使用 Google 账号登录
|
||||
</Button>
|
||||
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 hidden md:block rounded-[20px] bg-[url('https://uppeta.com/img/svg/main.svg')] bg-center bg-contain bg-no-repeat" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
120
src/pages/auth/Register.jsx
Normal file
120
src/pages/auth/Register.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import { Form, Input, Button, Divider, message } from 'antd';
|
||||
import { GoogleOutlined } from '@ant-design/icons';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
const Register = () => {
|
||||
const navigate = useNavigate();
|
||||
const { register, signInWithGoogle } = useAuth();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleRegister = async (values) => {
|
||||
try {
|
||||
await register(values.email, values.password);
|
||||
message.success('注册成功!请查看您的邮箱进行验证。');
|
||||
navigate('/login');
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleSignUp = async () => {
|
||||
try {
|
||||
await signInWithGoogle();
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
console.error('Google sign up error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-gradient-to-br from-[#f5f7fa] to-[#c3cfe2]">
|
||||
<div className="w-full max-w-[1200px] mx-auto flex p-8 gap-16">
|
||||
{/* Register Form */}
|
||||
<div className="flex-1 bg-white p-12 rounded-[20px] shadow-[0_10px_30px_rgba(0,0,0,0.1)]">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-3xl font-bold mb-2 bg-gradient-to-r from-primary-500 to-[#36cff0] bg-clip-text text-transparent">
|
||||
创建账户
|
||||
</h1>
|
||||
<p className="text-gray-500">加入 Uppeta,开启您的管理之旅。</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
name="register"
|
||||
onFinish={handleRegister}
|
||||
layout="vertical"
|
||||
size="large"
|
||||
>
|
||||
<Form.Item
|
||||
name="email"
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱!' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址!' }
|
||||
]}
|
||||
>
|
||||
<Input placeholder="邮箱" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码!' },
|
||||
{ min: 6, message: '密码长度至少为 6 位!' }
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
dependencies={['password']}
|
||||
rules={[
|
||||
{ required: true, message: '请确认密码!' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致!'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="确认密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
注册
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<Divider>或</Divider>
|
||||
|
||||
<Button
|
||||
icon={<GoogleOutlined />}
|
||||
block
|
||||
onClick={handleGoogleSignUp}
|
||||
className="mb-6"
|
||||
>
|
||||
使用 Google 账号注册
|
||||
</Button>
|
||||
|
||||
<div className="text-center">
|
||||
<Link to="/login" className="text-primary-500 hover:text-primary-600">
|
||||
已有账户?立即登录
|
||||
</Link>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* Register Image */}
|
||||
<div className="flex-1 hidden md:block rounded-[20px] bg-[url('/auth-bg.png')] bg-center bg-cover" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
43
src/pages/company/customer/index.jsx
Normal file
43
src/pages/company/customer/index.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { Card, Table, Button } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
const CustomerPage = () => {
|
||||
const columns = [
|
||||
{
|
||||
title: '客户名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '联系人',
|
||||
dataIndex: 'contact',
|
||||
key: 'contact',
|
||||
},
|
||||
{
|
||||
title: '电话',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="客户管理"
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />}>
|
||||
新增客户
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table columns={columns} dataSource={[]} rowKey="id" />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerPage;
|
||||
8
src/pages/company/index.jsx
Normal file
8
src/pages/company/index.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
const CompanyManagement = () => {
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
export default CompanyManagement;
|
||||
43
src/pages/company/project/index.jsx
Normal file
43
src/pages/company/project/index.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { Card, Table, Button } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
const ProjectPage = () => {
|
||||
const columns = [
|
||||
{
|
||||
title: '项目名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '负责人',
|
||||
dataIndex: 'manager',
|
||||
key: 'manager',
|
||||
},
|
||||
{
|
||||
title: '开始日期',
|
||||
dataIndex: 'startDate',
|
||||
key: 'startDate',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="专案管理"
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />}>
|
||||
新增专案
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table columns={columns} dataSource={[]} rowKey="id" />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectPage;
|
||||
208
src/pages/company/quotation/index.jsx
Normal file
208
src/pages/company/quotation/index.jsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Table, Button, Modal, Form, Input, message, Popconfirm } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { useResources } from '@/hooks/resource/useResource';
|
||||
|
||||
const QuotationPage = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 10 });
|
||||
const [sorter, setSorter] = useState({ field: 'created_at', order: 'descend' });
|
||||
|
||||
const {
|
||||
resources: quotations,
|
||||
loading,
|
||||
total,
|
||||
fetchResources: fetchQuotations,
|
||||
createResource: createQuotation,
|
||||
updateResource: updateQuotation,
|
||||
deleteResource: deleteQuotation
|
||||
} = useResources(pagination, sorter);
|
||||
|
||||
useEffect(() => {
|
||||
fetchQuotations();
|
||||
}, []);
|
||||
|
||||
const handleTableChange = (pagination, filters, sorter) => {
|
||||
setPagination(pagination);
|
||||
setSorter(sorter);
|
||||
fetchQuotations({
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
field: sorter.field,
|
||||
order: sorter.order,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
try {
|
||||
const quotationData = {
|
||||
external_id: values.id,
|
||||
attributes: {
|
||||
customerName: values.customerName,
|
||||
status: values.status || '新建',
|
||||
},
|
||||
type: 'quota'
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
await updateQuotation(editingId, quotationData);
|
||||
} else {
|
||||
await createQuotation(quotationData);
|
||||
}
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
fetchQuotations();
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await deleteQuotation(id);
|
||||
fetchQuotations();
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '报价单号',
|
||||
dataIndex: ['external_id'],
|
||||
key: 'external_id',
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: '客户名称',
|
||||
dataIndex: ['attributes', 'customerName'],
|
||||
key: 'customerName',
|
||||
},
|
||||
{
|
||||
title: '创建日期',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
sorter: true,
|
||||
render: (text) => new Date(text).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: ['attributes', 'status'],
|
||||
key: 'status',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<span>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => {
|
||||
setEditingId(record.id);
|
||||
form.setFieldsValue({
|
||||
id: record.external_id,
|
||||
customerName: record.attributes?.customerName,
|
||||
status: record.attributes?.status,
|
||||
});
|
||||
setVisible(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个报价单吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="报价单管理"
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
form.resetFields();
|
||||
setVisible(true);
|
||||
}}
|
||||
>
|
||||
新增报价单
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={quotations}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
...pagination,
|
||||
total,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingId ? '编辑报价单' : '新增报价单'}
|
||||
open={visible}
|
||||
onCancel={() => {
|
||||
setVisible(false);
|
||||
setEditingId(null);
|
||||
form.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
>
|
||||
<Form.Item
|
||||
name="id"
|
||||
label="报价单号"
|
||||
rules={[{ required: true, message: '请输入报价单号' }]}
|
||||
>
|
||||
<Input placeholder="请输入报价单号" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="customerName"
|
||||
label="客户名称"
|
||||
rules={[{ required: true, message: '请输入客户名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入客户名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
initialValue="新建"
|
||||
>
|
||||
<Input placeholder="请输入状态" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
提交
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuotationPage;
|
||||
43
src/pages/company/service/index.jsx
Normal file
43
src/pages/company/service/index.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { Card, Table, Button } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
const ServicePage = () => {
|
||||
const columns = [
|
||||
{
|
||||
title: '服务名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '服务类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
},
|
||||
{
|
||||
title: '价格',
|
||||
dataIndex: 'price',
|
||||
key: 'price',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="服务管理"
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />}>
|
||||
新增服务
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table columns={columns} dataSource={[]} rowKey="id" />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServicePage;
|
||||
43
src/pages/company/supplier/index.jsx
Normal file
43
src/pages/company/supplier/index.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { Card, Table, Button } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
const SupplierPage = () => {
|
||||
const columns = [
|
||||
{
|
||||
title: '供应商名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '联系人',
|
||||
dataIndex: 'contact',
|
||||
key: 'contact',
|
||||
},
|
||||
{
|
||||
title: '电话',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="供应商管理"
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />}>
|
||||
新增供应商
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table columns={columns} dataSource={[]} rowKey="id" />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupplierPage;
|
||||
43
src/pages/company/task/index.jsx
Normal file
43
src/pages/company/task/index.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { Card, Table, Button } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
const TaskPage = () => {
|
||||
const columns = [
|
||||
{
|
||||
title: '任务名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '负责人',
|
||||
dataIndex: 'assignee',
|
||||
key: 'assignee',
|
||||
},
|
||||
{
|
||||
title: '截止日期',
|
||||
dataIndex: 'dueDate',
|
||||
key: 'dueDate',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="任务管理"
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />}>
|
||||
新增任务
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table columns={columns} dataSource={[]} rowKey="id" />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskPage;
|
||||
8
src/pages/marketing/campaign/index.jsx
Normal file
8
src/pages/marketing/campaign/index.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
const MarketingCampaign = () => {
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
export default MarketingCampaign;
|
||||
34
src/pages/marketing/campaign/performance/index.jsx
Normal file
34
src/pages/marketing/campaign/performance/index.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { Card, DatePicker } from 'antd';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
|
||||
const CampaignPerformance = () => {
|
||||
const data = [
|
||||
{ name: '1月', 点击率: 400, 转化率: 240 },
|
||||
{ name: '2月', 点击率: 300, 转化率: 139 },
|
||||
{ name: '3月', 点击率: 200, 转化率: 980 },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="活动成效分析">
|
||||
<div className="mb-4">
|
||||
<DatePicker.RangePicker />
|
||||
</div>
|
||||
<div style={{ width: '100%', height: 400 }}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="点击率" stroke="#8884d8" />
|
||||
<Line type="monotone" dataKey="转化率" stroke="#82ca9d" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CampaignPerformance;
|
||||
48
src/pages/marketing/campaign/plan/index.jsx
Normal file
48
src/pages/marketing/campaign/plan/index.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { Card, Table, Button, Tag } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
const CampaignPlan = () => {
|
||||
const columns = [
|
||||
{
|
||||
title: '计划名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '开始时间',
|
||||
dataIndex: 'startTime',
|
||||
key: 'startTime',
|
||||
},
|
||||
{
|
||||
title: '结束时间',
|
||||
dataIndex: 'endTime',
|
||||
key: 'endTime',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: status => (
|
||||
<Tag color={status === '进行中' ? 'green' : 'default'}>
|
||||
{status}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="活动计划"
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />}>
|
||||
新增计划
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table columns={columns} dataSource={[]} rowKey="id" />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CampaignPlan;
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { Form, Input, DatePicker, Select } from 'antd';
|
||||
import { STATUS } from '@/constants/status';
|
||||
|
||||
export const ProjectForm = ({ form }) => (
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="专案名称"
|
||||
rules={[{ required: true, message: '请输入专案名称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="manager"
|
||||
label="负责人"
|
||||
rules={[{ required: true, message: '请选择负责人' }]}
|
||||
>
|
||||
<Select placeholder="请选择负责人" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="startDate"
|
||||
label="开始日期"
|
||||
rules={[{ required: true, message: '请选择开始日期' }]}
|
||||
>
|
||||
<DatePicker />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
rules={[{ required: true, message: '请选择状态' }]}
|
||||
>
|
||||
<Select>
|
||||
{Object.values(STATUS).map(status => (
|
||||
<Select.Option key={status} value={status}>
|
||||
{status}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
71
src/pages/marketing/campaign/project/index.jsx
Normal file
71
src/pages/marketing/campaign/project/index.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Modal, Form } from 'antd';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { BaseTable } from '@/components/common/BaseTable';
|
||||
import { ProjectForm } from './components/ProjectForm';
|
||||
import { useProjectData } from './hooks/useProjectData';
|
||||
import { getStatusColumn, getDateColumn } from '@/utils/tableColumns';
|
||||
|
||||
const CampaignProject = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { loading, data, pagination, loadData } = useProjectData();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '专案名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '负责人',
|
||||
dataIndex: 'manager',
|
||||
key: 'manager',
|
||||
},
|
||||
getDateColumn('开始日期', 'startDate'),
|
||||
getStatusColumn(),
|
||||
];
|
||||
|
||||
const handleAdd = () => {
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
console.log('Success:', values);
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<PageHeader
|
||||
title="行销活动专案"
|
||||
onAdd={handleAdd}
|
||||
addButtonText="新增专案"
|
||||
/>
|
||||
<BaseTable
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={pagination}
|
||||
onChange={loadData}
|
||||
/>
|
||||
<Modal
|
||||
title="新增专案"
|
||||
open={visible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setVisible(false)}
|
||||
>
|
||||
<ProjectForm form={form} />
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CampaignProject;
|
||||
8
src/pages/marketing/communication/index.jsx
Normal file
8
src/pages/marketing/communication/index.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
const Communication = () => {
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
export default Communication;
|
||||
40
src/pages/marketing/communication/journey/index.jsx
Normal file
40
src/pages/marketing/communication/journey/index.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Card, Timeline, Button } from 'antd';
|
||||
import { ClockCircleOutlined } from '@ant-design/icons';
|
||||
|
||||
const CommunicationJourney = () => {
|
||||
return (
|
||||
<Card
|
||||
title="沟通历程"
|
||||
extra={
|
||||
<Button type="primary">导出记录</Button>
|
||||
}
|
||||
>
|
||||
<Timeline
|
||||
mode="alternate"
|
||||
items={[
|
||||
{
|
||||
children: '创建营销活动 2023-10-25 10:00:00',
|
||||
},
|
||||
{
|
||||
children: '发送邮件通知 2023-10-25 10:30:00',
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
dot: <ClockCircleOutlined style={{ fontSize: '16px' }} />,
|
||||
children: '客户查看邮件 2023-10-25 11:00:00',
|
||||
},
|
||||
{
|
||||
color: 'red',
|
||||
children: '系统提醒跟进 2023-10-25 14:00:00',
|
||||
},
|
||||
{
|
||||
children: '完成跟进 2023-10-25 16:00:00',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommunicationJourney;
|
||||
48
src/pages/marketing/communication/list/index.jsx
Normal file
48
src/pages/marketing/communication/list/index.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { Card, Table, Button, Input } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
const CommunicationList = () => {
|
||||
const columns = [
|
||||
{
|
||||
title: '名单名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '联系人数量',
|
||||
dataIndex: 'contactCount',
|
||||
key: 'contactCount',
|
||||
},
|
||||
{
|
||||
title: '创建日期',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
},
|
||||
{
|
||||
title: '更新日期',
|
||||
dataIndex: 'updatedAt',
|
||||
key: 'updatedAt',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="名单管理"
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />}>
|
||||
新增名单
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<Search placeholder="搜索名单" allowClear enterButton />
|
||||
</div>
|
||||
<Table columns={columns} dataSource={[]} rowKey="id" />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommunicationList;
|
||||
42
src/pages/marketing/communication/preview/index.jsx
Normal file
42
src/pages/marketing/communication/preview/index.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Card, Tabs, Button } from 'antd';
|
||||
import { SendOutlined } from '@ant-design/icons';
|
||||
|
||||
const CommunicationPreview = () => {
|
||||
const items = [
|
||||
{
|
||||
key: '1',
|
||||
label: '邮件预览',
|
||||
children: (
|
||||
<div className="p-4 border rounded">
|
||||
<h2>邮件主题</h2>
|
||||
<div className="mt-4">邮件内容预览区域</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: '短信预览',
|
||||
children: (
|
||||
<div className="p-4 border rounded">
|
||||
<div className="mt-4">短信内容预览区域</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="消息预览"
|
||||
extra={
|
||||
<Button type="primary" icon={<SendOutlined />}>
|
||||
发送测试
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Tabs items={items} />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommunicationPreview;
|
||||
49
src/pages/marketing/communication/send/index.jsx
Normal file
49
src/pages/marketing/communication/send/index.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { Card, Steps, Form, Button, Select, DatePicker, Input } from 'antd';
|
||||
|
||||
const { Step } = Steps;
|
||||
const { Option } = Select;
|
||||
|
||||
const CommunicationSend = () => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
return (
|
||||
<Card title="发送管理">
|
||||
<Steps
|
||||
current={0}
|
||||
items={[
|
||||
{ title: '选择模板' },
|
||||
{ title: '设置参数' },
|
||||
{ title: '确认发送' },
|
||||
]}
|
||||
className="mb-8"
|
||||
/>
|
||||
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="template" label="选择模板">
|
||||
<Select placeholder="请选择模板">
|
||||
<Option value="template1">模板一</Option>
|
||||
<Option value="template2">模板二</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="sendTime" label="发送时间">
|
||||
<DatePicker showTime />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="targetList" label="目标名单">
|
||||
<Select placeholder="请选择目标名单">
|
||||
<Option value="list1">名单一</Option>
|
||||
<Option value="list2">名单二</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary">下一步</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommunicationSend;
|
||||
48
src/pages/marketing/communication/tasks/index.jsx
Normal file
48
src/pages/marketing/communication/tasks/index.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { Card, Table, Button, Tag } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
const CommunicationTasks = () => {
|
||||
const columns = [
|
||||
{
|
||||
title: '任务名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '负责人',
|
||||
dataIndex: 'assignee',
|
||||
key: 'assignee',
|
||||
},
|
||||
{
|
||||
title: '截止日期',
|
||||
dataIndex: 'dueDate',
|
||||
key: 'dueDate',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: status => (
|
||||
<Tag color={status === '进行中' ? 'green' : 'default'}>
|
||||
{status}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="沟通任务"
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />}>
|
||||
新增任务
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table columns={columns} dataSource={[]} rowKey="id" />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommunicationTasks;
|
||||
8
src/pages/marketing/index.jsx
Normal file
8
src/pages/marketing/index.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
const MarketingCenter = () => {
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
export default MarketingCenter;
|
||||
43
src/pages/marketing/template/index.jsx
Normal file
43
src/pages/marketing/template/index.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { Card, Table, Button } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
const TemplatePage = () => {
|
||||
const columns = [
|
||||
{
|
||||
title: '模版名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
},
|
||||
{
|
||||
title: '创建日期',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="模版管理"
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />}>
|
||||
新增模版
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table columns={columns} dataSource={[]} rowKey="id" />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplatePage;
|
||||
8
src/pages/resource/index.jsx
Normal file
8
src/pages/resource/index.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
const ResourceManagement = () => {
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
export default ResourceManagement;
|
||||
34
src/pages/resource/team/components/AddMemberModal.jsx
Normal file
34
src/pages/resource/team/components/AddMemberModal.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { Modal, Form } from 'antd';
|
||||
import { UserForm } from './UserForm';
|
||||
import { RoleSelect } from './RoleSelect';
|
||||
|
||||
export const AddMemberModal = ({
|
||||
visible,
|
||||
onOk,
|
||||
onCancel,
|
||||
form
|
||||
}) => (
|
||||
<Modal
|
||||
title="添加成员"
|
||||
open={visible}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="成员信息">
|
||||
<UserForm
|
||||
nameProps={{ name: ['user', 'name'] }}
|
||||
emailProps={{ name: ['user', 'email'] }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="role"
|
||||
label="角色"
|
||||
rules={[{ required: true, message: '请选择角色!' }]}
|
||||
>
|
||||
<RoleSelect />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { Form, Input, Select, Upload } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { supabase } from '@/config/supabase';
|
||||
|
||||
export const TeamForm = ({ form }) => {
|
||||
const handleUpload = async ({ file, onSuccess, onError }) => {
|
||||
try {
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const fileName = `${Math.random()}.${fileExt}`;
|
||||
const filePath = `team-avatars/${fileName}`;
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('avatars')
|
||||
.upload(filePath, file);
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('avatars')
|
||||
.getPublicUrl(filePath);
|
||||
|
||||
form.setFieldValue('avatarUrl', publicUrl);
|
||||
onSuccess(publicUrl);
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
onError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="团队名称"
|
||||
rules={[{ required: true, message: '请输入团队名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入团队名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="avatarUrl"
|
||||
label="团队头像"
|
||||
>
|
||||
<Upload
|
||||
customRequest={handleUpload}
|
||||
showUploadList={false}
|
||||
maxCount={1}
|
||||
listType="picture-card"
|
||||
>
|
||||
{form.getFieldValue('avatarUrl') ? (
|
||||
<img
|
||||
src={form.getFieldValue('avatarUrl')}
|
||||
alt="avatar"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<PlusOutlined />
|
||||
<div style={{ marginTop: 8 }}>上传头像</div>
|
||||
</div>
|
||||
)}
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="tags"
|
||||
label="标签"
|
||||
rules={[{ required: true, message: '请选择至少一个标签' }]}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder="请输入或选择标签"
|
||||
options={[
|
||||
{ label: '研发', value: 'development' },
|
||||
{ label: '设计', value: 'design' },
|
||||
{ label: '运营', value: 'operation' },
|
||||
{ label: '市场', value: 'marketing' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
32
src/pages/resource/team/components/CreateTeamModal/index.jsx
Normal file
32
src/pages/resource/team/components/CreateTeamModal/index.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Modal, Form } from 'antd';
|
||||
import { TeamForm } from './TeamForm';
|
||||
|
||||
const CreateTeamModal = ({ open, onCancel, onSubmit, confirmLoading }) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
await onSubmit(values);
|
||||
form.resetFields();
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="新增团队"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={handleSubmit}
|
||||
confirmLoading={confirmLoading}
|
||||
width={600}
|
||||
>
|
||||
<TeamForm form={form} />
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateTeamModal;
|
||||
36
src/pages/resource/team/components/EditableCell.jsx
Normal file
36
src/pages/resource/team/components/EditableCell.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { Form, Input } from 'antd';
|
||||
|
||||
export const EditableCell = ({
|
||||
editing,
|
||||
dataIndex,
|
||||
title,
|
||||
inputType,
|
||||
record,
|
||||
index,
|
||||
children,
|
||||
...restProps
|
||||
}) => {
|
||||
const inputNode = <Input />;
|
||||
|
||||
return (
|
||||
<td {...restProps}>
|
||||
{editing ? (
|
||||
<Form.Item
|
||||
name={dataIndex}
|
||||
style={{ margin: 0 }}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: `请输入${title}!`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{inputNode}
|
||||
</Form.Item>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Form } from 'antd';
|
||||
import { UserForm } from './UserForm';
|
||||
import { RoleSelect } from './RoleSelect';
|
||||
|
||||
export const EditableMembershipCell = ({
|
||||
editing,
|
||||
dataIndex,
|
||||
title,
|
||||
record,
|
||||
children,
|
||||
...restProps
|
||||
}) => {
|
||||
let inputNode;
|
||||
|
||||
if (dataIndex === 'role') {
|
||||
inputNode = <RoleSelect />;
|
||||
} else if (dataIndex === 'user') {
|
||||
inputNode = (
|
||||
<UserForm
|
||||
nameProps={{ name: ['user', 'name'] }}
|
||||
emailProps={{ name: ['user', 'email'] }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<td {...restProps}>
|
||||
{editing ? (
|
||||
<Form.Item
|
||||
name={dataIndex}
|
||||
style={{ margin: 0 }}
|
||||
rules={[{ required: true, message: `请输入${title}!` }]}
|
||||
>
|
||||
{inputNode}
|
||||
</Form.Item>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
};
|
||||
98
src/pages/resource/team/components/ExpandedMemberships.jsx
Normal file
98
src/pages/resource/team/components/ExpandedMemberships.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Form, Input, Select, message } from 'antd';
|
||||
import { MembershipTable } from './MembershipTable';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
export const ExpandedMemberships = ({ teamId, memberships: initialMemberships }) => {
|
||||
const [memberships, setMemberships] = useState(initialMemberships);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleUpdate = (id, values) => {
|
||||
setMemberships(prev =>
|
||||
prev.map(item => item.id === id ? { ...item, ...values } : item)
|
||||
);
|
||||
message.success('成员信息已更新');
|
||||
};
|
||||
|
||||
const handleDelete = (id) => {
|
||||
setMemberships(prev => prev.filter(item => item.id !== id));
|
||||
message.success('成员已删除');
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setIsModalVisible(true);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const handleModalOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const newMember = {
|
||||
id: `${Date.now()}`,
|
||||
teamId,
|
||||
isCreator: false,
|
||||
...values,
|
||||
};
|
||||
setMemberships(prev => [...prev, newMember]);
|
||||
setIsModalVisible(false);
|
||||
message.success('成员已添加');
|
||||
} catch (error) {
|
||||
console.error('Add failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
|
||||
<h3 className="text-lg font-medium mb-4">团队成员</h3>
|
||||
|
||||
<MembershipTable
|
||||
memberships={memberships}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
onAdd={handleAdd}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="添加成员"
|
||||
open={isModalVisible}
|
||||
onOk={handleModalOk}
|
||||
onCancel={() => setIsModalVisible(false)}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
name={['user', 'name']}
|
||||
label="姓名"
|
||||
rules={[{ required: true, message: '请输入姓名!' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['user', 'email']}
|
||||
label="邮箱"
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱!' },
|
||||
{ type: 'email', message: '请输入有效的邮箱!' }
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="role"
|
||||
label="角色"
|
||||
rules={[{ required: true, message: '请选择角色!' }]}
|
||||
>
|
||||
<Select>
|
||||
<Option value="ADMIN">Admin</Option>
|
||||
<Option value="MEMBER">Member</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
160
src/pages/resource/team/components/MembershipTable.jsx
Normal file
160
src/pages/resource/team/components/MembershipTable.jsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Table, Button, Space, Popconfirm, Tag, Form } from 'antd';
|
||||
import { EditOutlined, DeleteOutlined, SaveOutlined, CloseOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { EditableMembershipCell } from './EditableMembershipCell';
|
||||
import { roleColors } from '../constants/teamConstants';
|
||||
|
||||
export const MembershipTable = ({ memberships, onUpdate, onDelete, onAdd }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [editingKey, setEditingKey] = useState('');
|
||||
|
||||
const isEditing = (record) => record.id === editingKey;
|
||||
|
||||
const edit = (record) => {
|
||||
form.setFieldsValue({ ...record });
|
||||
setEditingKey(record.id);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setEditingKey('');
|
||||
};
|
||||
|
||||
const save = async (key) => {
|
||||
try {
|
||||
const row = await form.validateFields();
|
||||
setEditingKey('');
|
||||
onUpdate(key, row);
|
||||
} catch (error) {
|
||||
console.error('Save failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '成员',
|
||||
dataIndex: 'users',
|
||||
key: 'users',
|
||||
editable: true,
|
||||
render: (users) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{users.email}</span>
|
||||
<span className="text-gray-500 text-sm">{users.email}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
editable: true,
|
||||
render: (role) => (
|
||||
<Tag color={roleColors[role]}>
|
||||
{role.toLowerCase()}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建者',
|
||||
dataIndex: 'is_creator',
|
||||
key: 'is_creator',
|
||||
render: (is_creator) => (
|
||||
is_creator ? (
|
||||
<Tag color="green">是</Tag>
|
||||
) : (
|
||||
<Tag color="default">否</Tag>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_, record) => {
|
||||
const editable = isEditing(record);
|
||||
return editable ? (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<SaveOutlined />}
|
||||
onClick={() => save(record.id)}
|
||||
type="link"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CloseOutlined />}
|
||||
onClick={cancel}
|
||||
type="link"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Space>
|
||||
<Button
|
||||
disabled={editingKey !== '' || record.isCreator}
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => edit(record)}
|
||||
type="link"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
{!record.isCreator && (
|
||||
<Popconfirm
|
||||
title="确定要删除该成员吗?"
|
||||
onConfirm={() => onDelete(record.id)}
|
||||
>
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
type="link"
|
||||
danger
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mergedColumns = columns.map((col) => {
|
||||
if (!col.editable) {
|
||||
return col;
|
||||
}
|
||||
return {
|
||||
...col,
|
||||
onCell: (record) => ({
|
||||
record,
|
||||
dataIndex: col.dataIndex,
|
||||
title: col.title,
|
||||
editing: isEditing(record),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onAdd}
|
||||
className="mb-4"
|
||||
>
|
||||
添加成员
|
||||
</Button>
|
||||
<Form form={form} component={false}>
|
||||
<Table
|
||||
components={{
|
||||
body: {
|
||||
cell: EditableMembershipCell,
|
||||
},
|
||||
}}
|
||||
columns={mergedColumns}
|
||||
dataSource={memberships}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
/>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
13
src/pages/resource/team/components/RoleSelect.jsx
Normal file
13
src/pages/resource/team/components/RoleSelect.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { roles } from '../constants/teamConstants';
|
||||
|
||||
export const RoleSelect = ({ value, onChange, disabled }) => (
|
||||
<Select value={value} onChange={onChange} disabled={disabled}>
|
||||
{roles.map(({ label, value }) => (
|
||||
<Select.Option key={value} value={value}>
|
||||
{label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
59
src/pages/resource/team/components/TeamActions.jsx
Normal file
59
src/pages/resource/team/components/TeamActions.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Button, Space, Popconfirm } from 'antd';
|
||||
import { EditOutlined, DeleteOutlined, SaveOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
|
||||
export const TeamActions = ({
|
||||
record,
|
||||
editable,
|
||||
editingKey,
|
||||
onEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
onDelete,
|
||||
}) => {
|
||||
if (editable) {
|
||||
return (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<SaveOutlined />}
|
||||
onClick={() => onSave(record.id)}
|
||||
type="link"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onCancel}
|
||||
type="link"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Space>
|
||||
<Button
|
||||
disabled={editingKey !== ''}
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => onEdit(record)}
|
||||
type="link"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除该团队吗?"
|
||||
onConfirm={() => onDelete(record.id)}
|
||||
>
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
type="link"
|
||||
danger
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
82
src/pages/resource/team/components/TeamForm.jsx
Normal file
82
src/pages/resource/team/components/TeamForm.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { Form, Input, Upload, Select } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { supabase } from '@/config/supabase';
|
||||
|
||||
export const TeamForm = ({ form }) => {
|
||||
const normFile = (e) => {
|
||||
if (Array.isArray(e)) {
|
||||
return e;
|
||||
}
|
||||
return e?.fileList;
|
||||
};
|
||||
|
||||
const handleUpload = async ({ file, onSuccess, onError }) => {
|
||||
try {
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const fileName = `${Math.random()}.${fileExt}`;
|
||||
const filePath = `team-avatars/${fileName}`;
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('file')
|
||||
.upload(filePath, file);
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('file')
|
||||
.getPublicUrl(filePath);
|
||||
|
||||
form.setFieldsValue({ avatarUrl: publicUrl });
|
||||
onSuccess(publicUrl);
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
onError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="团队名称"
|
||||
rules={[{ required: true, message: '请输入团队名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入团队名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="avatarUrl"
|
||||
label="团队头像"
|
||||
valuePropName="fileList"
|
||||
getValueFromEvent={normFile}
|
||||
>
|
||||
<Upload
|
||||
customRequest={handleUpload}
|
||||
showUploadList={false}
|
||||
maxCount={1}
|
||||
listType="picture-card"
|
||||
>
|
||||
{form.getFieldValue('avatarUrl') ? (
|
||||
<img
|
||||
src={form.getFieldValue('avatarUrl')}
|
||||
alt="avatar"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<PlusOutlined />
|
||||
<div className="mt-2">上传头像</div>
|
||||
</div>
|
||||
)}
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
|
||||
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
17
src/pages/resource/team/components/TeamHeader.jsx
Normal file
17
src/pages/resource/team/components/TeamHeader.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { Input, Button } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
export const TeamHeader = ({ onSearch, onAdd }) => (
|
||||
<div className="flex justify-between mb-4">
|
||||
<Input.Search
|
||||
placeholder="搜索团队"
|
||||
allowClear
|
||||
onSearch={onSearch}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
||||
新增团队
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
197
src/pages/resource/team/components/TeamTable.jsx
Normal file
197
src/pages/resource/team/components/TeamTable.jsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Table, Form, Button, Space, Popconfirm, Tag, message } from 'antd';
|
||||
import { EditOutlined, DeleteOutlined, SaveOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import { EditableCell } from './EditableCell';
|
||||
import { ExpandedMemberships } from './ExpandedMemberships';
|
||||
export const TeamTable = ({ tableLoading,pagination,dataSource, onTableChange,onDelete ,onUpdate}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [editingKey, setEditingKey] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isEditing = (record) => record.id === editingKey;
|
||||
|
||||
const edit = (record) => {
|
||||
form.setFieldsValue({
|
||||
name: record.name,
|
||||
description: record.description
|
||||
});
|
||||
setEditingKey(record.id);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setEditingKey('');
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
// 保存编辑
|
||||
const save = async (key) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const row = await form.validateFields();
|
||||
await onUpdate(key, {
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
setEditingKey('');
|
||||
message.success('更新成功');
|
||||
onTableChange?.(); // 刷新数据
|
||||
} catch (error) {
|
||||
console.error('Save failed:', error);
|
||||
message.error('更新失败: ' + error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除团队
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await onDelete(id)
|
||||
message.success('删除成功');
|
||||
onTableChange?.(); // 刷新数据
|
||||
} catch (error) {
|
||||
console.error('Delete failed:', error);
|
||||
message.error('删除失败: ' + error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '团队名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
editable: true,
|
||||
render: (text, record) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{record.avatar_url && (
|
||||
<img
|
||||
src={record.avatar_url}
|
||||
alt={text}
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<span className="font-medium">{text}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
title: '成员数量',
|
||||
dataIndex: 'team_membership',
|
||||
key: 'team_membership',
|
||||
render: (team_membership) => team_membership?.length || 0,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
sorter:true,
|
||||
defaultSortOrder: 'descend',
|
||||
render: (text) => new Date(text).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_, record) => {
|
||||
const editable = isEditing(record);
|
||||
return editable ? (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<SaveOutlined />}
|
||||
onClick={() => save(record.id)}
|
||||
type="link"
|
||||
loading={loading}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CloseOutlined />}
|
||||
onClick={cancel}
|
||||
type="link"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Space>
|
||||
<Button
|
||||
disabled={editingKey !== ''}
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => edit(record)}
|
||||
type="link"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除该团队吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
type="link"
|
||||
danger
|
||||
loading={loading}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mergedColumns = columns.map((col) => {
|
||||
if (!col.editable) {
|
||||
return col;
|
||||
}
|
||||
return {
|
||||
...col,
|
||||
onCell: (record) => ({
|
||||
record,
|
||||
dataIndex: col.dataIndex,
|
||||
title: col.title,
|
||||
editing: isEditing(record),
|
||||
}),
|
||||
};
|
||||
});
|
||||
const handleTableChange = (pagination, filters, sorter, extra) => {
|
||||
onTableChange?.(pagination, filters, sorter, extra);
|
||||
|
||||
}
|
||||
return (
|
||||
<Form form={form} component={false}>
|
||||
<Table
|
||||
className='w-full'
|
||||
pagination={pagination}
|
||||
loading={loading||tableLoading}
|
||||
components={{
|
||||
body: {
|
||||
cell: EditableCell,
|
||||
},
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
dataSource={dataSource}
|
||||
columns={mergedColumns}
|
||||
rowKey="id"
|
||||
expandable={{
|
||||
expandedRowRender: (record) => (
|
||||
<ExpandedMemberships
|
||||
memberships={record.team_membership}
|
||||
teamId={record.id}
|
||||
onMembershipChange={onTableChange}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
24
src/pages/resource/team/components/UserForm.jsx
Normal file
24
src/pages/resource/team/components/UserForm.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Form, Input, Space } from 'antd';
|
||||
|
||||
export const UserForm = ({ nameProps, emailProps }) => (
|
||||
<Space.Compact block>
|
||||
<Form.Item
|
||||
{...nameProps}
|
||||
style={{ margin: 0, width: '50%' }}
|
||||
rules={[{ required: true, message: '请输入姓名!' }]}
|
||||
>
|
||||
<Input placeholder="姓名" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{...emailProps}
|
||||
style={{ margin: 0, width: '50%' }}
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱!' },
|
||||
{ type: 'email', message: '请输入有效的邮箱!' }
|
||||
]}
|
||||
>
|
||||
<Input placeholder="邮箱" />
|
||||
</Form.Item>
|
||||
</Space.Compact>
|
||||
);
|
||||
63
src/pages/resource/team/constants/mockData.js
Normal file
63
src/pages/resource/team/constants/mockData.js
Normal file
@@ -0,0 +1,63 @@
|
||||
export const mockTeams = [
|
||||
{
|
||||
id: '1',
|
||||
name: '研发团队',
|
||||
avatarUrl: 'https://api.dicebear.com/7.x/avatars/svg?seed=1',
|
||||
tags: ['前端', '后端', '设计'],
|
||||
memberships: [
|
||||
{
|
||||
id: '1',
|
||||
user: {
|
||||
email: 'john@example.com',
|
||||
name: 'John Doe'
|
||||
},
|
||||
role: 'OWNER',
|
||||
isCreator: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
user: {
|
||||
email: 'jane@example.com',
|
||||
name: 'Jane Smith'
|
||||
},
|
||||
role: 'ADMIN',
|
||||
isCreator: false,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
user: {
|
||||
email: 'bob@example.com',
|
||||
name: 'Bob Wilson'
|
||||
},
|
||||
role: 'MEMBER',
|
||||
isCreator: false,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '设计团队',
|
||||
avatarUrl: 'https://api.dicebear.com/7.x/avatars/svg?seed=2',
|
||||
tags: ['UI', 'UX', '平面设计'],
|
||||
memberships: [
|
||||
{
|
||||
id: '4',
|
||||
user: {
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice Johnson'
|
||||
},
|
||||
role: 'OWNER',
|
||||
isCreator: true,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
user: {
|
||||
email: 'charlie@example.com',
|
||||
name: 'Charlie Brown'
|
||||
},
|
||||
role: 'MEMBER',
|
||||
isCreator: false,
|
||||
}
|
||||
],
|
||||
}
|
||||
];
|
||||
11
src/pages/resource/team/constants/teamConstants.js
Normal file
11
src/pages/resource/team/constants/teamConstants.js
Normal file
@@ -0,0 +1,11 @@
|
||||
export const roleColors = {
|
||||
OWNER: 'gold',
|
||||
ADMIN: 'purple',
|
||||
MEMBER: 'blue',
|
||||
};
|
||||
|
||||
export const roles = [
|
||||
{ label: 'Owner', value: 'OWNER' },
|
||||
{ label: 'Admin', value: 'ADMIN' },
|
||||
{ label: 'Member', value: 'MEMBER' },
|
||||
];
|
||||
33
src/pages/resource/team/hooks/useMemberActions.js
Normal file
33
src/pages/resource/team/hooks/useMemberActions.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { message } from 'antd';
|
||||
|
||||
export const useMemberActions = (teamId) => {
|
||||
const handleUpdate = (id, values) => {
|
||||
// 模拟API调用
|
||||
console.log('Update member:', id, values);
|
||||
message.success('成员信息已更新');
|
||||
};
|
||||
|
||||
const handleDelete = (id) => {
|
||||
// 模拟API调用
|
||||
console.log('Delete member:', id);
|
||||
message.success('成员已删除');
|
||||
};
|
||||
|
||||
const handleAdd = (values) => {
|
||||
// 模拟API调用
|
||||
console.log('Add member:', values);
|
||||
message.success('成员已添加');
|
||||
return {
|
||||
id: Date.now().toString(),
|
||||
teamId,
|
||||
isCreator: false,
|
||||
...values,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
handleUpdate,
|
||||
handleDelete,
|
||||
handleAdd,
|
||||
};
|
||||
};
|
||||
31
src/pages/resource/team/hooks/useTeamActions.js
Normal file
31
src/pages/resource/team/hooks/useTeamActions.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { message } from 'antd';
|
||||
|
||||
export const useTeamActions = () => {
|
||||
const handleUpdate = (id, values) => {
|
||||
// 模拟API调用
|
||||
console.log('Update team:', id, values);
|
||||
message.success('团队信息已更新');
|
||||
};
|
||||
|
||||
const handleDelete = (id) => {
|
||||
// 模拟API调用
|
||||
console.log('Delete team:', id);
|
||||
message.success('团队已删除');
|
||||
};
|
||||
|
||||
const handleAdd = (values) => {
|
||||
// 模拟API调用
|
||||
console.log('Add team:', values);
|
||||
message.success('团队已创建');
|
||||
return {
|
||||
id: Date.now().toString(),
|
||||
...values,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
handleUpdate,
|
||||
handleDelete,
|
||||
handleAdd,
|
||||
};
|
||||
};
|
||||
104
src/pages/resource/team/hooks/useTeamData.js
Normal file
104
src/pages/resource/team/hooks/useTeamData.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { supabase } from '@/config/supabase';
|
||||
|
||||
export const useTeamData = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState([]);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const loadData = useCallback(async ({
|
||||
pagination = {},
|
||||
filters = {},
|
||||
sorter = {},
|
||||
search = ''
|
||||
} = {}) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let query = supabase
|
||||
.from('teams')
|
||||
.select(`
|
||||
*,
|
||||
memberships:team_memberships(
|
||||
id,
|
||||
role,
|
||||
user:users(id, email, name)
|
||||
),
|
||||
tags(id, name)
|
||||
`);
|
||||
|
||||
// Add search condition
|
||||
if (search) {
|
||||
query = query.ilike('name', `%${search}%`);
|
||||
}
|
||||
|
||||
// Add sorting
|
||||
if (sorter.field) {
|
||||
const order = sorter.order === 'descend' ? 'desc' : 'asc';
|
||||
query = query.order(sorter.field, { ascending: order === 'asc' });
|
||||
}
|
||||
|
||||
// Add pagination
|
||||
const from = ((pagination.current || 1) - 1) * (pagination.pageSize || 10);
|
||||
const to = from + (pagination.pageSize || 10) - 1;
|
||||
query = query.range(from, to);
|
||||
|
||||
const { data: teams, count } = await query;
|
||||
|
||||
setData(teams || []);
|
||||
setPagination({
|
||||
...pagination,
|
||||
total: count || 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch teams:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createTeam = async (values) => {
|
||||
const { data, error } = await supabase
|
||||
.from('teams')
|
||||
.insert([values])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
};
|
||||
|
||||
const updateTeam = async (id, values) => {
|
||||
const { data, error } = await supabase
|
||||
.from('teams')
|
||||
.update(values)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
};
|
||||
|
||||
const deleteTeam = async (id) => {
|
||||
const { error } = await supabase
|
||||
.from('teams')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (error) throw error;
|
||||
};
|
||||
|
||||
return {
|
||||
loading,
|
||||
data,
|
||||
pagination,
|
||||
loadData,
|
||||
createTeam,
|
||||
updateTeam,
|
||||
deleteTeam,
|
||||
};
|
||||
};
|
||||
73
src/pages/resource/team/hooks/useTeamMembership.js
Normal file
73
src/pages/resource/team/hooks/useTeamMembership.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { supabase } from '@/config/supabase';
|
||||
|
||||
export const useTeamMembership = (teamId) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [memberships, setMemberships] = useState([]);
|
||||
|
||||
const loadMemberships = useCallback(async () => {
|
||||
if (!teamId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('team_memberships')
|
||||
.select(`
|
||||
*,
|
||||
user:users(id, email, name)
|
||||
`)
|
||||
.eq('teamId', teamId);
|
||||
|
||||
if (error) throw error;
|
||||
setMemberships(data || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch memberships:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [teamId]);
|
||||
|
||||
const addMembership = async (values) => {
|
||||
const { data, error } = await supabase
|
||||
.from('team_memberships')
|
||||
.insert([{ ...values, teamId }])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
await loadMemberships();
|
||||
return data;
|
||||
};
|
||||
|
||||
const updateMembership = async (id, values) => {
|
||||
const { data, error } = await supabase
|
||||
.from('team_memberships')
|
||||
.update(values)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
await loadMemberships();
|
||||
return data;
|
||||
};
|
||||
|
||||
const deleteMembership = async (id) => {
|
||||
const { error } = await supabase
|
||||
.from('team_memberships')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (error) throw error;
|
||||
await loadMemberships();
|
||||
};
|
||||
|
||||
return {
|
||||
loading,
|
||||
memberships,
|
||||
loadMemberships,
|
||||
addMembership,
|
||||
updateMembership,
|
||||
deleteMembership,
|
||||
};
|
||||
};
|
||||
114
src/pages/resource/team/index.jsx
Normal file
114
src/pages/resource/team/index.jsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, App } from 'antd';
|
||||
import { TeamHeader } from './components/TeamHeader';
|
||||
import { TeamTable } from './components/TeamTable';
|
||||
import CreateTeamModal from './components/CreateTeamModal';
|
||||
import { useTeams } from '@/hooks/team/useTeams';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
const TeamManagement = () => {
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const [sorter, setSorter] = useState({
|
||||
field: 'created_at',
|
||||
order: 'descend',
|
||||
});
|
||||
|
||||
const {
|
||||
teams,
|
||||
loading,
|
||||
fetchTeams,
|
||||
createTeam,
|
||||
updateTeam,
|
||||
deleteTeam
|
||||
} = useTeams(pagination, sorter);
|
||||
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const loadTeams = async () => {
|
||||
const { total } = await fetchTeams();
|
||||
setPagination(prev => ({ ...prev, total }));
|
||||
};
|
||||
loadTeams();
|
||||
}, [fetchTeams]);
|
||||
|
||||
const handleTableChange = (newPagination, filters, newSorter) => {
|
||||
const params = {
|
||||
current: newPagination.current,
|
||||
pageSize: newPagination.pageSize,
|
||||
field: newSorter.field,
|
||||
order: newSorter.order,
|
||||
};
|
||||
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: params.current,
|
||||
pageSize: params.pageSize,
|
||||
}));
|
||||
|
||||
setSorter({
|
||||
field: params.field || sorter.field,
|
||||
order: params.order || sorter.order,
|
||||
});
|
||||
|
||||
fetchTeams(params).then(({ total }) => {
|
||||
setPagination(prev => ({ ...prev, total }));
|
||||
});
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleModalCancel = () => {
|
||||
setModalVisible(false);
|
||||
};
|
||||
|
||||
const handleModalSubmit = async (values) => {
|
||||
try {
|
||||
setConfirmLoading(true);
|
||||
await createTeam({ ...values, userId: user.id });
|
||||
setModalVisible(false);
|
||||
setPagination(prev => ({ ...prev, current: 1 }));
|
||||
const { total } = await fetchTeams({ current: 1 });
|
||||
setPagination(prev => ({ ...prev, total }));
|
||||
} catch (error) {
|
||||
console.error('Failed to create team:', error);
|
||||
} finally {
|
||||
setConfirmLoading(false);
|
||||
}
|
||||
};
|
||||
const onSearch=(value)=>{
|
||||
fetchTeams({search:value})
|
||||
}
|
||||
return (
|
||||
<App>
|
||||
<Card title="团队管理" bordered={false}>
|
||||
<TeamHeader onSearch={onSearch} onAdd={handleAdd} />
|
||||
<TeamTable
|
||||
tableLoading={loading}
|
||||
dataSource={teams}
|
||||
pagination={pagination}
|
||||
onTableChange={handleTableChange}
|
||||
onUpdate={updateTeam}
|
||||
onDelete={deleteTeam}
|
||||
/>
|
||||
<CreateTeamModal
|
||||
open={modalVisible}
|
||||
onCancel={handleModalCancel}
|
||||
onSubmit={handleModalSubmit}
|
||||
confirmLoading={confirmLoading}
|
||||
/>
|
||||
</Card>
|
||||
</App>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamManagement;
|
||||
85
src/routes/AppRoutes.jsx
Normal file
85
src/routes/AppRoutes.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Spin } from 'antd';
|
||||
import MainLayout from '@/components/Layout/MainLayout';
|
||||
import { routes } from '@/config/routes';
|
||||
import Login from '@/pages/auth/Login';
|
||||
import Register from '@/pages/auth/Register';
|
||||
import Dashboard from '@/pages/Dashboard';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
const LoadingComponent = () => (
|
||||
<div className="flex justify-center items-center min-h-[200px]">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderRoutes = (routes) => {
|
||||
return routes.map(route => {
|
||||
const Component = route.component;
|
||||
if (route.children) {
|
||||
return (
|
||||
<Route key={route.path} path={route.path} element={
|
||||
<Suspense fallback={<LoadingComponent />}>
|
||||
<Component />
|
||||
</Suspense>
|
||||
}>
|
||||
{renderRoutes(route.children)}
|
||||
</Route>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Route
|
||||
key={route.path}
|
||||
path={route.path}
|
||||
element={
|
||||
<Suspense fallback={<LoadingComponent />}>
|
||||
<Component />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const AppRoutes = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{/* 公开路由 */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
user ? <Navigate to="/dashboard" replace /> : <Login />
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 受保护的路由 */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route
|
||||
path="dashboard"
|
||||
element={
|
||||
<Suspense fallback={<LoadingComponent />}>
|
||||
<Dashboard />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
{renderRoutes(routes)}
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppRoutes;
|
||||
88
src/services/supabase/resource.js
Normal file
88
src/services/supabase/resource.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import { supabase } from '@/config/supabase';
|
||||
|
||||
export const resourceService = {
|
||||
async getResources({ page = 1, pageSize = 10, orderBy = 'created_at', ascending = false, searchQuery = '' }) {
|
||||
try {
|
||||
let query = supabase
|
||||
.from('resources')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('type','shorturl')
|
||||
|
||||
if (searchQuery) {
|
||||
query = query.or(`external_id.ilike.%${searchQuery}%`);
|
||||
}
|
||||
if (orderBy) {
|
||||
query = query.order(orderBy, { ascending });
|
||||
}
|
||||
const from = (page - 1) * pageSize;
|
||||
query = query.range(from, from + pageSize - 1);
|
||||
|
||||
const { data, count, error } = await query;
|
||||
if (error) throw error;
|
||||
|
||||
return {
|
||||
data,
|
||||
total: count || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async createResource(values) {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('resources')
|
||||
.insert([{
|
||||
...values,
|
||||
type: 'quota',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async updateResource(id, values) {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('resources')
|
||||
.update({
|
||||
...values,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('type', 'quota')
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteResource(id) {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('resources')
|
||||
.update({
|
||||
deleted_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('type', 'quota');
|
||||
|
||||
if (error) throw error;
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
169
src/services/supabase/team.js
Normal file
169
src/services/supabase/team.js
Normal file
@@ -0,0 +1,169 @@
|
||||
import { supabase } from '@/config/supabase';
|
||||
|
||||
export const teamService = {
|
||||
async getTeams({ page = 1, pageSize = 10, orderBy = 'created_at', ascending = false , searchQuery = ''
|
||||
} = {}) {
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
let query = supabase
|
||||
.from('teams')
|
||||
.select(`
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
attributes,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted_at,
|
||||
schema_version,
|
||||
avatar_url,
|
||||
team_membership(
|
||||
id,
|
||||
user_id,
|
||||
role,
|
||||
is_creator,
|
||||
users(
|
||||
id,
|
||||
email
|
||||
)
|
||||
)
|
||||
`, { count: 'exact' })
|
||||
.is('deleted_at', null)
|
||||
.order(orderBy, { ascending })
|
||||
.range(from, to);
|
||||
|
||||
if (searchQuery) {
|
||||
query = query.ilike('name', `%${searchQuery}%`);
|
||||
}
|
||||
|
||||
const { data, error, count } = await query
|
||||
.order(orderBy, { ascending })
|
||||
.range(from, to);
|
||||
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching teams:', error);
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
data,
|
||||
total: count || 0
|
||||
};
|
||||
},
|
||||
|
||||
// 创建团队
|
||||
async createTeam({ name, description, userId }) {
|
||||
const { data: team, error: teamError } = await supabase
|
||||
.from('teams')
|
||||
.insert([
|
||||
{
|
||||
name,
|
||||
description
|
||||
}
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (teamError) throw teamError;
|
||||
|
||||
// 创建团队成员关系(创建者)
|
||||
const { error: membershipError } = await supabase
|
||||
.from('team_membership')
|
||||
.insert([
|
||||
{
|
||||
id:team.id,
|
||||
team_id: team.id,
|
||||
user_id: userId,
|
||||
role: 'OWNER',
|
||||
is_creator: true
|
||||
}
|
||||
]);
|
||||
|
||||
if (membershipError) throw membershipError;
|
||||
|
||||
return team;
|
||||
},
|
||||
|
||||
// 获取单个团队详情
|
||||
async getTeamById(teamId) {
|
||||
const { data, error } = await supabase
|
||||
.from('teams')
|
||||
.select(`
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
attributes,
|
||||
created_at,
|
||||
updated_at,
|
||||
avatar_url,
|
||||
team_membership(
|
||||
id,
|
||||
user_id,
|
||||
role,
|
||||
is_creator,
|
||||
users(
|
||||
id,
|
||||
email
|
||||
)
|
||||
)
|
||||
`)
|
||||
.eq('id', teamId)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
// 更新团队信息
|
||||
async updateTeam(teamId, updates) {
|
||||
const { data, error } = await supabase
|
||||
.from('teams')
|
||||
.update(updates)
|
||||
.eq('id', teamId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
// 添加团队成员
|
||||
async addTeamMember(teamId, userId, role = 'MEMBER') {
|
||||
const { data, error } = await supabase
|
||||
.from('team_membership')
|
||||
.insert([
|
||||
{
|
||||
team_id: teamId,
|
||||
user_id: userId,
|
||||
role,
|
||||
is_creator: false
|
||||
}
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
// 更新成员角色
|
||||
async updateMemberRole(teamId, userId, role) {
|
||||
const { data, error } = await supabase
|
||||
.from('team_membership')
|
||||
.update({ role })
|
||||
.match({ team_id: teamId, user_id: userId })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
},
|
||||
async deleteTeam(teamId) {
|
||||
const { error: teamError } = await supabase
|
||||
.from('teams')
|
||||
.delete()
|
||||
.eq('id', teamId)
|
||||
.select()
|
||||
if (teamError) throw teamError;
|
||||
},
|
||||
};
|
||||
48
src/services/supabase/teamMembership.js
Normal file
48
src/services/supabase/teamMembership.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { supabase } from '@/config/supabase';
|
||||
|
||||
export const teamMembershipService = {
|
||||
async getMemberships(teamId) {
|
||||
const { data, error } = await supabase
|
||||
.from('team_memberships')
|
||||
.select(`
|
||||
*,
|
||||
user:users(id, email, name)
|
||||
`)
|
||||
.eq('teamId', teamId);
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async createMembership(membershipData) {
|
||||
const { data, error } = await supabase
|
||||
.from('team_memberships')
|
||||
.insert([membershipData])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateMembership(id, membershipData) {
|
||||
const { data, error } = await supabase
|
||||
.from('team_memberships')
|
||||
.update(membershipData)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async deleteMembership(id) {
|
||||
const { error } = await supabase
|
||||
.from('team_memberships')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
};
|
||||
150
src/styles/main.scss
Normal file
150
src/styles/main.scss
Normal file
@@ -0,0 +1,150 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--primary-color: #1677ff;
|
||||
--primary-gradient: linear-gradient(45deg, #1677ff, #36cff0);
|
||||
--success-color: #52c41a;
|
||||
--warning-color: #faad14;
|
||||
--error-color: #ff4d4f;
|
||||
--font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// Layout styles
|
||||
.app-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
@apply flex items-center justify-between px-6 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
@apply bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700;
|
||||
|
||||
.ant-menu {
|
||||
@apply border-0;
|
||||
|
||||
.ant-menu-item {
|
||||
@apply rounded-lg;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.ant-menu-item-icon + span {
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply bg-gray-100 dark:bg-gray-700;
|
||||
}
|
||||
|
||||
&.ant-menu-item-selected {
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-menu-inline-collapsed {
|
||||
.ant-menu-item,
|
||||
.ant-menu-submenu .ant-menu-submenu-title {
|
||||
padding: 0 !important;
|
||||
text-align: center;
|
||||
|
||||
.ant-menu-item-icon {
|
||||
line-height: 44px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ant-menu-title-content {
|
||||
opacity: 0;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-submenu {
|
||||
.ant-menu-submenu-title {
|
||||
@apply rounded-lg;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
|
||||
&:hover {
|
||||
@apply bg-gray-100 dark:bg-gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-menu-submenu-open {
|
||||
> .ant-menu-submenu-title {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Logo styles
|
||||
.logo {
|
||||
@apply flex items-center justify-center h-16 border-b border-gray-200 dark:border-gray-700;
|
||||
|
||||
h1 {
|
||||
@apply text-lg font-semibold m-0;
|
||||
background: var(--primary-gradient);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
// Dark mode styles
|
||||
.dark {
|
||||
@apply bg-gray-900;
|
||||
|
||||
.ant-card {
|
||||
background: #1f1f1f;
|
||||
|
||||
.ant-card-head {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
border-bottom-color: #303030;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
background: #1f1f1f;
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
background: #141414;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td {
|
||||
border-bottom: 1px solid #303030;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Statistics card styles
|
||||
.stat-card {
|
||||
@apply rounded-lg border border-gray-100 dark:border-gray-800 p-6;
|
||||
|
||||
.stat-icon {
|
||||
@apply w-12 h-12 rounded-full flex items-center justify-center mb-4;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
@apply text-gray-600 dark:text-gray-400 text-sm font-medium;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
@apply text-2xl font-semibold mt-2;
|
||||
}
|
||||
}
|
||||
8
src/utils/format.js
Normal file
8
src/utils/format.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
};
|
||||
38
src/utils/icons.js
Normal file
38
src/utils/icons.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// Material Icons mapping
|
||||
export const MaterialIcons = {
|
||||
dashboard: 'dashboard',
|
||||
quotation: 'receipt_long',
|
||||
customer: 'group',
|
||||
project: 'folder_special',
|
||||
service: 'build',
|
||||
task: 'task',
|
||||
supplier: 'inventory',
|
||||
template: 'article',
|
||||
campaign: 'campaign',
|
||||
communication: 'chat',
|
||||
performance: 'analytics',
|
||||
plan: 'event',
|
||||
preview: 'preview',
|
||||
send: 'send',
|
||||
journey: 'timeline',
|
||||
menu: 'menu',
|
||||
close: 'close',
|
||||
add: 'add',
|
||||
edit: 'edit',
|
||||
delete: 'delete',
|
||||
search: 'search',
|
||||
filter: 'filter_list',
|
||||
sort: 'sort',
|
||||
export: 'download',
|
||||
import: 'upload',
|
||||
refresh: 'refresh',
|
||||
more: 'more_vert',
|
||||
};
|
||||
|
||||
export const getIcon = (name) => {
|
||||
return (
|
||||
<span className="material-symbols-rounded">
|
||||
{MaterialIcons[name] || 'error'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
34
src/utils/menuUtils.jsx
Normal file
34
src/utils/menuUtils.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { routes } from '@/config/routes';
|
||||
import * as AntIcons from '@ant-design/icons';
|
||||
import { ColorIcon } from '@/components/common/ColorIcon';
|
||||
|
||||
|
||||
|
||||
const getAntIcon = (iconName) => {
|
||||
const iconKey = `${iconName.charAt(0).toUpperCase()}${iconName.slice(
|
||||
1
|
||||
)}Outlined`;
|
||||
return AntIcons[iconKey] ? React.createElement(AntIcons[iconKey]) : null;
|
||||
};
|
||||
|
||||
const generateMenuItems = (routes, parentPath = '') => {
|
||||
return routes.map((route) => {
|
||||
const fullPath = `${parentPath}/${route.path}`.replace(/\/+/g, '/');
|
||||
const icon = route.icon && <ColorIcon icon={getAntIcon(route.icon)} />;
|
||||
|
||||
const menuItem = {
|
||||
key: fullPath,
|
||||
icon,
|
||||
label: route.name,
|
||||
};
|
||||
|
||||
if (route.children) {
|
||||
menuItem.children = generateMenuItems(route.children, fullPath);
|
||||
}
|
||||
|
||||
return menuItem;
|
||||
});
|
||||
};
|
||||
|
||||
export const getMenuItems = () => generateMenuItems(routes);
|
||||
25
src/utils/request.js
Normal file
25
src/utils/request.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { message } from 'antd';
|
||||
|
||||
const handleResponse = async (response) => {
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || '请求失败');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const request = async (url, options = {}) => {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
return await handleResponse(response);
|
||||
} catch (error) {
|
||||
message.error(error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
22
src/utils/tableColumns.jsx
Normal file
22
src/utils/tableColumns.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Tag } from 'antd';
|
||||
|
||||
export const getStatusColumn = (options = {}) => ({
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status) => {
|
||||
const color = status === '进行中' ? 'green' :
|
||||
status === '已完成' ? 'blue' :
|
||||
status === '已取消' ? 'red' : 'default';
|
||||
return <Tag color={color}>{status}</Tag>;
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
export const getDateColumn = (title, dataIndex, options = {}) => ({
|
||||
title,
|
||||
dataIndex,
|
||||
key: dataIndex,
|
||||
render: (date) => new Date(date).toLocaleString(),
|
||||
...options,
|
||||
});
|
||||
37
tailwind.config.js
Normal file
37
tailwind.config.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#1677ff',
|
||||
dark: '#0958d9',
|
||||
},
|
||||
success: {
|
||||
DEFAULT: '#52c41a',
|
||||
dark: '#389e0d',
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: '#faad14',
|
||||
dark: '#d48806',
|
||||
},
|
||||
error: {
|
||||
DEFAULT: '#ff4d4f',
|
||||
dark: '#d9363e',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Poppins', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
corePlugins: {
|
||||
preflight: false,
|
||||
},
|
||||
}
|
||||
29
vite.config.js
Normal file
29
vite.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000, // 设置开发服务器端口为 3000
|
||||
open: true, // 自动打开浏览器
|
||||
host: true, // 监听所有地址,包括局域网和公网地址
|
||||
strictPort: true, // 如果端口被占用,直接退出
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['react', 'react-dom', 'react-router-dom', 'antd'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user