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

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

2
.env Normal file
View 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
View File

@@ -0,0 +1,2 @@
VITE_SUPABASE_URL=your_supabase_url
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key

36
.gitignore vendored Normal file
View 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
View 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
View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

6990
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

45
src/App.jsx Normal file
View 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;

View File

@@ -0,0 +1,80 @@
import React from 'react';
import { Layout, Switch, Button, Dropdown } from 'antd';
import { UserOutlined, LogoutOutlined } from '@ant-design/icons';
import { useTheme } from '@/contexts/ThemeContext';
import { useAuth } from '@/contexts/AuthContext';
import { MenuTrigger } from '../common/MenuTrigger';
const { Header: AntHeader } = Layout;
const Header = ({ collapsed, setCollapsed }) => {
const { isDarkMode, toggleTheme } = useTheme();
const { user, logout } = useAuth();
const handleLogout = async () => {
try {
await logout();
} catch (error) {
console.error('Logout error:', error);
}
};
const userMenuItems = [
{
key: 'profile',
icon: <UserOutlined />,
label: '个人信息',
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: handleLogout,
},
];
return (
<AntHeader
style={{
padding: 0,
background: isDarkMode ? '#141414' : '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}
>
<div className="flex items-center">
<MenuTrigger
collapsed={collapsed}
setCollapsed={setCollapsed}
isDarkMode={isDarkMode}
/>
</div>
<div className="flex items-center gap-4 mr-6">
<Switch
checked={isDarkMode}
onChange={toggleTheme}
checkedChildren="🌙"
unCheckedChildren="☀️"
/>
<Dropdown
menu={{ items: userMenuItems }}
placement="bottomRight"
trigger={['click']}
>
<Button
type="text"
icon={<UserOutlined />}
className="flex items-center"
>
{user?.email}
</Button>
</Dropdown>
</div>
</AntHeader>
);
};
export default Header;

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { RocketOutlined } from '@ant-design/icons';
export const Logo = ({ collapsed, isDarkMode }) => (
<div className="logo">
<div className="flex items-center justify-center gap-2">
<RocketOutlined className="text-2xl text-primary-500" />
{!collapsed && (
<h1 className="text-lg font-semibold m-0" style={{
background: 'var(--primary-gradient)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}}>
Uppeta
</h1>
)}
</div>
</div>
);

View File

@@ -0,0 +1,36 @@
import React, { useState, Suspense } from 'react';
import { Layout, Spin } from 'antd';
import { Outlet } from 'react-router-dom';
import Header from './Header';
import Sidebar from './Sidebar';
import { useTheme } from '@/contexts/ThemeContext';
const { Content } = Layout;
const MainLayout = () => {
const [collapsed, setCollapsed] = useState(false);
const { isDarkMode } = useTheme();
return (
<Layout style={{ minHeight: '100vh' }}>
<Sidebar collapsed={collapsed} />
<Layout>
<Header collapsed={collapsed} setCollapsed={setCollapsed} />
<Content
style={{
margin: '24px 16px',
padding: 24,
background: isDarkMode ? '#141414' : '#fff',
borderRadius: '4px',
}}
>
<Suspense fallback={<Spin size="large" />}>
<Outlet />
</Suspense>
</Content>
</Layout>
</Layout>
);
};
export default MainLayout;

View File

@@ -0,0 +1,54 @@
import React, { useMemo } from 'react';
import { Layout, Menu } from 'antd';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTheme } from '@/contexts/ThemeContext';
import { getMenuItems } from '@/utils/menuUtils';
import { Logo } from '@/components/common/Logo';
const { Sider } = Layout;
const Sidebar = ({ collapsed }) => {
const navigate = useNavigate();
const location = useLocation();
const { isDarkMode } = useTheme();
const menuItems = useMemo(() => getMenuItems(), []);
const defaultOpenKeys = useMemo(() => {
const pathSegments = location.pathname.split('/').filter(Boolean);
return pathSegments.reduce((acc, _, index) => {
const path = `/${pathSegments.slice(0, index + 1).join('/')}`;
acc.push(path);
return acc;
}, []);
}, [location.pathname]);
// Handle menu item click
const handleMenuClick = ({ key }) => {
navigate(key);
};
return (
<Sider
trigger={null}
collapsible
collapsed={collapsed}
theme={isDarkMode ? 'dark' : 'light'}
width={256}
collapsedWidth={80} // 添加这个属性
className={`app-sidebar ${collapsed ? 'collapsed' : ''}`} // 添加collapsed类名
>
<Logo collapsed={collapsed} isDarkMode={isDarkMode} />
<Menu
theme={isDarkMode ? 'dark' : 'light'}
mode="inline"
selectedKeys={[location.pathname]}
defaultOpenKeys={defaultOpenKeys}
items={menuItems}
onClick={handleMenuClick}
/>
</Sider>
);
};
export default React.memo(Sidebar);

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { Spin } from 'antd';
export const ProtectedRoute = ({ children }) => {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Spin size="large" />
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
return children;
};

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { Table } from 'antd';
export const BaseTable = ({
columns,
dataSource,
loading = false,
rowKey = 'id',
...props
}) => (
<Table
columns={columns}
dataSource={dataSource}
loading={loading}
rowKey={rowKey}
{...props}
/>
);

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { useTheme } from '@/contexts/ThemeContext';
export const ColorIcon = ({ icon: Icon, background }) => {
const { isDarkMode } = useTheme();
return (
<div
className={`
inline-flex items-center justify-center
w-8 h-8 rounded-lg transition-all duration-300
${isDarkMode ? 'bg-opacity-25' : 'bg-opacity-15'}
hover:${isDarkMode ? 'bg-opacity-35' : 'bg-opacity-25'}
`}
style={{ backgroundColor: background }}
>
<span
className="text-base transition-colors duration-300"
style={{ color: background }}
>
{Icon}
</span>
</div>
);
};

View File

@@ -0,0 +1,17 @@
import React from 'react';
export const Icon = ({ name, className = '', style = {} }) => (
<span
className={`material-symbols-rounded ${className}`}
style={{
fontSize: '20px',
background: 'var(--primary-gradient)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
fontVariationSettings: "'FILL' 1, 'wght' 400, 'GRAD' 200, 'opsz' 20",
...style
}}
>
{name}
</span>
);

View File

@@ -0,0 +1,21 @@
import React from 'react';
export const Logo = ({ collapsed, isDarkMode }) => (
<div className="logo">
<div className="flex items-center justify-center gap-2">
<span className="material-symbols-rounded text-primary-500">
rocket_launch
</span>
<h1
style={{
color: isDarkMode ? '#fff' : '#000',
fontSize: collapsed ? '14px' : '18px',
margin: 0,
display: collapsed ? 'none' : 'block',
}}
>
Uppeta
</h1>
</div>
</div>
);

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
export const MenuTrigger = ({ collapsed, setCollapsed, isDarkMode }) => {
const Icon = collapsed ? MenuUnfoldOutlined : MenuFoldOutlined;
return (
<Icon
className="trigger"
onClick={() => setCollapsed(!collapsed)}
style={{
fontSize: '18px',
padding: '0 24px',
cursor: 'pointer',
color: isDarkMode ? '#fff' : '#000'
}}
/>
);
};

View File

@@ -0,0 +1,14 @@
import React from 'react';
import { Button } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
export const PageHeader = ({ title, onAdd, addButtonText = '新增' }) => (
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold">{title}</h1>
{onAdd && (
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
{addButtonText}
</Button>
)}
</div>
);

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { Card } from 'antd';
import { formatCurrency } from '@/utils/format';
const StatCard = ({ icon, title, count, amount, color = '#1677ff' }) => {
return (
<div className="flex-1">
<Card
styles={{
body: {
padding: '20px',
height: '100%',
background: 'var(--color-bg-container)',
}
}}
className="h-full hover:shadow-md transition-shadow duration-300"
>
<div className="flex items-start gap-4">
<div
className="w-12 h-12 rounded-full flex items-center justify-center text-lg"
style={{
backgroundColor: `${color}15`,
}}
>
<span style={{ color }}>{icon}</span>
</div>
<div className="flex-1">
<h3 className="text-base text-gray-500 dark:text-gray-400 m-0">
{title}
</h3>
<div className="text-sm text-gray-400 dark:text-gray-500 mt-1">
{count} invoices
</div>
<div className="text-xl font-semibold mt-2 dark:text-white">
{formatCurrency(amount)}
</div>
</div>
</div>
</Card>
</div>
);
};
export default StatCard;

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { FileTextOutlined, CheckCircleOutlined, ClockCircleOutlined, WarningOutlined, EditOutlined } from '@ant-design/icons';
import StatCard from './StatCard';
const stats = [
{
icon: <FileTextOutlined className="text-lg" />,
title: 'Total',
count: 20,
amount: 46218.04,
color: '#1677ff',
},
{
icon: <CheckCircleOutlined className="text-lg" />,
title: 'Paid',
count: 10,
amount: 23110.23,
color: '#52c41a',
},
{
icon: <ClockCircleOutlined className="text-lg" />,
title: 'Pending',
count: 6,
amount: 13825.05,
color: '#faad14',
},
{
icon: <WarningOutlined className="text-lg" />,
title: 'Overdue',
count: 2,
amount: 4655.63,
color: '#ff4d4f',
},
{
icon: <EditOutlined className="text-lg" />,
title: 'Draft',
count: 2,
amount: 4627.13,
color: '#8c8c8c',
},
];
const StatisticsOverview = () => {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
{stats.map((stat, index) => (
<StatCard key={index} {...stat} />
))}
</div>
);
};
export default React.memo(StatisticsOverview);

69
src/config/routes.js Normal file
View 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
View 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
View 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',
};

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

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

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

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

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

14
src/main.jsx Normal file
View 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
View 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
View 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
View 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
View 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
View 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;

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

View File

@@ -0,0 +1,8 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
const CompanyManagement = () => {
return <Outlet />;
};
export default CompanyManagement;

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

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

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

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

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

View File

@@ -0,0 +1,8 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
const MarketingCampaign = () => {
return <Outlet />;
};
export default MarketingCampaign;

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

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

View File

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

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

View File

@@ -0,0 +1,8 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
const Communication = () => {
return <Outlet />;
};
export default Communication;

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

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

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

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

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

View File

@@ -0,0 +1,8 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
const MarketingCenter = () => {
return <Outlet />;
};
export default MarketingCenter;

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

View File

@@ -0,0 +1,8 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
const ResourceManagement = () => {
return <Outlet />;
};
export default ResourceManagement;

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

View File

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

View 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' },
];

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

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

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

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

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

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

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

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

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