This commit is contained in:
xuqssq
2024-12-28 15:39:48 +08:00
parent b3ac241354
commit 6e8334ecaf
18 changed files with 5017 additions and 420 deletions

View File

@@ -25,6 +25,7 @@
"lodash": "^4.17.21",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.4.0",
"react-infinite-scroll-component": "^6.1.0",
"react-router-dom": "^6.18.0",
"recharts": "^2.9.0",

View File

@@ -1,33 +1,34 @@
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 '../Layout/MenuTrigger';
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 "../Layout/MenuTrigger";
const { Header: AntHeader } = Layout;
import { LuSun } from "react-icons/lu";
import { GoMoon } from "react-icons/go";
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);
console.error("Logout error:", error);
}
};
const userMenuItems = [
{
key: 'profile',
key: "profile",
icon: <UserOutlined />,
label: '个人信息',
label: "个人信息",
},
{
key: 'logout',
key: "logout",
icon: <LogoutOutlined />,
label: '退出登录',
label: "退出登录",
onClick: handleLogout,
},
];
@@ -36,10 +37,10 @@ const Header = ({ collapsed, setCollapsed }) => {
<AntHeader
style={{
padding: 0,
background: isDarkMode ? '#141414' : '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
background: isDarkMode ? "#141414" : "#fff",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div className="flex items-center">
@@ -50,26 +51,37 @@ const Header = ({ collapsed, setCollapsed }) => {
/>
</div>
<div className="flex items-center gap-4 mr-6">
<Switch
checked={isDarkMode}
onChange={toggleTheme}
checkedChildren="🌙"
unCheckedChildren="☀️"
/>
<div className="flex items-center gap-4 mr-10 h-full select-none">
<div className="flex items-center justify-center cursor-pointer h-full" onClick={toggleTheme}>
{isDarkMode ? (
<GoMoon className="text-2xl" />
) : (
<LuSun className="text-2xl" />
)}
</div>
<Dropdown
menu={{ items: userMenuItems }}
placement="bottomRight"
trigger={['click']}
trigger={["click"]}
>
<Button
type="text"
icon={<UserOutlined />}
className="flex items-center"
>
{user?.email}
</Button>
<div className="flex gap-2 items-center justify-center cursor-pointer h-full">
{user?.user_metadata?.picture ? (
<img
src={user?.user_metadata?.picture}
alt="picture"
className="w-8 h-8 rounded-full"
/>
) : (
<div className="text-2xl bg-gray-400 dark:bg-gray-600 rounded-full flex justify-center items-center w-8 h-8 text-white pb-1">
{user?.email.slice(0, 1)}
</div>
)}
<p className="max-w-20 truncate">
{!user?.user_metadata?.name || user?.email?.split("@")[0]}
</p>
</div>
</Dropdown>
</div>
</AntHeader>

View File

@@ -1,9 +1,10 @@
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/Layout/Logo';
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/Layout/Logo";
import { useAuth } from "@/contexts/AuthContext";
const { Sider } = Layout;
@@ -11,13 +12,18 @@ const Sidebar = ({ collapsed }) => {
const navigate = useNavigate();
const location = useLocation();
const { isDarkMode } = useTheme();
const { user } = useAuth();
const menuItems = useMemo(() => getMenuItems(), []);
const menuItems = useMemo(() => {
if (!user?.id) return [];
const { adminRole: role } = user;
return getMenuItems(role);
}, [user]);
const defaultOpenKeys = useMemo(() => {
const pathSegments = location.pathname.split('/').filter(Boolean);
const pathSegments = location.pathname.split("/").filter(Boolean);
return pathSegments.reduce((acc, _, index) => {
const path = `/${pathSegments.slice(0, index + 1).join('/')}`;
const path = `/${pathSegments.slice(0, index + 1).join("/")}`;
acc.push(path);
return acc;
}, []);
@@ -33,14 +39,14 @@ const Sidebar = ({ collapsed }) => {
trigger={null}
collapsible
collapsed={collapsed}
theme={isDarkMode ? 'dark' : 'light'}
theme={isDarkMode ? "dark" : "light"}
width={256}
collapsedWidth={80} // 添加这个属性
className={`app-sidebar ${collapsed ? 'collapsed' : ''}`} // 添加collapsed类名
className={`app-sidebar ${collapsed ? "collapsed" : ""}`} // 添加collapsed类名
>
<Logo collapsed={collapsed} isDarkMode={isDarkMode} />
<Menu
theme={isDarkMode ? 'dark' : 'light'}
theme={isDarkMode ? "dark" : "light"}
mode="inline"
selectedKeys={[location.pathname]}
defaultOpenKeys={defaultOpenKeys}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { Navigate,useLocation } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { Spin } from 'antd';
import React from "react";
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "@/contexts/AuthContext";
import { Spin } from "antd";
export const ProtectedRoute = ({ children }) => {
const { user, loading } = useAuth();
@@ -14,8 +14,10 @@ export const ProtectedRoute = ({ children }) => {
);
}
if (!user) {
return <Navigate to={`/login`} replace />;
if (!user?.id) {
return (
<Navigate to={`/login?redirectTo=${location.pathname || ""}`} replace />
);
}
return children;

View File

@@ -2,6 +2,8 @@ import React, { createContext, useContext, useState, useEffect } from "react";
import { supabase } from "@/config/supabase";
import { message } from "antd";
import { useNavigate, useSearchParams, useLocation } from "react-router-dom";
import { supabaseService } from "@/hooks/supabaseService";
import { v4 as uuidv4 } from "uuid";
const AuthContext = createContext({});
@@ -9,7 +11,7 @@ export const AuthProvider = ({ children }) => {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
const [user, setUser] = useState(null);
const [user, setUser] = useState({});
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -37,7 +39,9 @@ export const AuthProvider = ({ children }) => {
data: { session },
error,
} = await supabase.auth.getSession();
setUser(session?.user ?? null);
const role = await checkInTeam(session?.user ?? null);
setUser({ ...session?.user, adminRole: role });
} catch (error) {
console.error("Error getting session:", error);
} finally {
@@ -46,17 +50,6 @@ export const AuthProvider = ({ children }) => {
};
initSession();
// 订阅认证状态变化
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null);
});
return () => {
subscription?.unsubscribe();
};
}, []);
// useEffect(() => {
@@ -66,6 +59,45 @@ export const AuthProvider = ({ children }) => {
// }
// }, [location.pathname]);
//检查时候在管理模块团队中,没有就自动加入
const checkInTeam = async (user) => {
if (!user) return null;
try {
const { data: teamData, error: teamError } = await supabase
.from("teams")
.select("*")
.eq("attributes->>type", "uppetaAdmin")
.single();
if (teamData) {
const { data: teamMembers, error: teamMembersError } = await supabase
.from("team_membership")
.select("*")
.eq("user_id", user.id)
.eq("team_id", teamData.id)
.single();
if (!teamMembers) {
// 自动加入团队
const { data: teamMembershipData, error: teamMembershipError } =
await supabaseService.insert("team_membership", {
id: uuidv4(),
user_id: user.id,
team_id: teamData.id,
role: "MEMBER",
is_creator: false,
});
return "MEMBER";
} else {
return teamMembers.role;
}
} else {
return "MEMBER";
}
} catch (error) {
console.error("Error checking in team:", error);
return "MEMBER";
}
};
// 邮箱密码登录
const login = async (email, password) => {
try {
@@ -79,7 +111,6 @@ export const AuthProvider = ({ children }) => {
message.error(error.message || "登录失败,请稍后重试");
return;
}
setUser(data.user);
return data;
} catch (error) {
@@ -152,7 +183,7 @@ export const AuthProvider = ({ children }) => {
message.error(error.message || "登出失败,请稍后重试");
return;
}
setUser(null);
setUser({});
message.success("已成功登出");
navigate(`/login?redirectTo=${location.pathname}`, { replace: true });
} catch (error) {

View File

@@ -1,143 +1,194 @@
import React, { createContext,useContext, useState, useEffect, } from 'react';
import React, { createContext, useContext, useState, useEffect } from "react";
import { ConfigProvider, theme } from "antd";
const ThemeContext = createContext();
export const ThemeProvider = ({ children }) => {
const [isDarkMode, setIsDarkMode] = useState(() => {
const savedTheme = localStorage.getItem('theme');
return savedTheme === 'dark';
const savedTheme = localStorage.getItem("theme");
return savedTheme === "dark";
});
useEffect(() => {
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
document.documentElement.classList.toggle('dark', isDarkMode);
localStorage.setItem("theme", isDarkMode ? "dark" : "light");
document.documentElement.classList.toggle("dark", isDarkMode);
}, [isDarkMode]);
const toggleTheme = () => {
setIsDarkMode(!isDarkMode);
const handleTheme = (event) => {
const { clientX, clientY } = event;
let outIsDark;
const toggleTheme = () => {
setIsDarkMode((prev) => {
const newPrev = !prev;
outIsDark = newPrev;
return newPrev;
});
};
if ("startViewTransition" in document) {
const transition = document.startViewTransition(() => {
toggleTheme();
});
transition.ready.then(() => {
const radius = Math.hypot(
Math.max(clientX, innerWidth - clientX),
Math.max(clientY, innerHeight - clientY)
);
const clipPath = [
`circle(0px at ${clientX}px ${clientY}px)`,
`circle(${radius}px at ${clientX}px ${clientY}px)`,
];
document.documentElement.animate(
{ clipPath: outIsDark ? clipPath.reverse() : clipPath },
{
duration: 300,
easing: "ease-in",
pseudoElement: outIsDark
? "::view-transition-old(root)"
: "::view-transition-new(root)",
}
);
});
} else {
toggleTheme();
}
};
return (
<ThemeContext.Provider value={{ isDarkMode, toggleTheme }}>
<ConfigProvider
componentSize="small"
theme={{
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: {
// 主色调 - Google Blue
colorPrimary: '#1a73e8',
colorPrimaryHover: '#1557b0',
colorPrimaryActive: '#174ea6',
<ThemeContext.Provider value={{ isDarkMode, toggleTheme: handleTheme }}>
<ConfigProvider
componentSize="small"
theme={{
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: {
// 主色调 - Google Blue
colorPrimary: "#1a73e8",
colorPrimaryHover: "#1557b0",
colorPrimaryActive: "#174ea6",
// 成功色 - Google Green
colorSuccess: '#1e8e3e',
colorSuccessHover: '#188130',
colorSuccessActive: '#137333',
// 成功色 - Google Green
colorSuccess: "#1e8e3e",
colorSuccessHover: "#188130",
colorSuccessActive: "#137333",
// 警告色 - Google Yellow
colorWarning: '#f9ab00',
colorWarningHover: '#f29900',
colorWarningActive: '#ea8600',
// 警告色 - Google Yellow
colorWarning: "#f9ab00",
colorWarningHover: "#f29900",
colorWarningActive: "#ea8600",
// 错误色 - Google Red
colorError: '#d93025',
colorErrorHover: '#c5221f',
colorErrorActive: '#a50e0e',
// 错误色 - Google Red
colorError: "#d93025",
colorErrorHover: "#c5221f",
colorErrorActive: "#a50e0e",
// 文字颜色
colorText: isDarkMode ? 'rgba(255, 255, 255, 0.87)' : 'rgba(0, 0, 0, 0.87)',
colorTextSecondary: isDarkMode ? 'rgba(255, 255, 255, 0.60)' : 'rgba(0, 0, 0, 0.60)',
colorTextTertiary: isDarkMode ? 'rgba(255, 255, 255, 0.38)' : 'rgba(0, 0, 0, 0.38)',
// 文字颜色
colorText: isDarkMode
? "rgba(255, 255, 255, 0.87)"
: "rgba(0, 0, 0, 0.87)",
colorTextSecondary: isDarkMode
? "rgba(255, 255, 255, 0.60)"
: "rgba(0, 0, 0, 0.60)",
colorTextTertiary: isDarkMode
? "rgba(255, 255, 255, 0.38)"
: "rgba(0, 0, 0, 0.38)",
// 背景色
colorBgContainer: isDarkMode ? '#1f1f1f' : '#ffffff',
colorBgElevated: isDarkMode ? '#2d2d2d' : '#ffffff',
colorBgLayout: isDarkMode ? '#121212' : '#f8f9fa',
// 背景色
colorBgContainer: isDarkMode ? "#1f1f1f" : "#ffffff",
colorBgElevated: isDarkMode ? "#2d2d2d" : "#ffffff",
colorBgLayout: isDarkMode ? "#121212" : "#f8f9fa",
// 边框
borderRadius: 8,
borderRadiusLG: 12,
borderRadiusSM: 4,
colorBorder: isDarkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.12)',
// 阴影
boxShadow: '0 1px 3px 0 rgba(60,64,67,0.3), 0 4px 8px 3px rgba(60,64,67,0.15)',
boxShadowSecondary: '0 1px 2px 0 rgba(60,64,67,0.3), 0 1px 3px 1px rgba(60,64,67,0.15)',
// 字体
fontFamily: 'Google Sans, Roboto, Arial, sans-serif',
fontSize: 14,
// 控件尺寸
controlHeight: 36,
controlHeightSM: 32,
controlHeightLG: 44,
},
components: {
Button: {
algorithm: true,
borderRadius: 20, // Google 风格的圆角按钮
controlHeight: 36,
controlHeightSM: 32,
controlHeightLG: 44,
paddingContentHorizontal: 24,
},
Input: {
algorithm: true,
controlHeight: 36,
controlHeightSM: 32,
controlHeightLG: 44,
paddingContentHorizontal: 16,
borderRadius: 4,
controlOutline: 'none',
controlOutlineWidth: 0,
controlPaddingHorizontal: 16,
controlPaddingHorizontalSM: 12,
addonBg: 'transparent',
},
Select: {
algorithm: true,
controlHeight: 36,
controlHeightSM: 32,
controlHeightLG: 44,
borderRadius: 4,
controlOutline: 'none',
controlOutlineWidth: 0,
selectorBg: 'transparent',
},
Card: {
algorithm: true,
borderRadiusLG: 8,
boxShadow: '0 1px 2px 0 rgba(60,64,67,0.3), 0 1px 3px 1px rgba(60,64,67,0.15)',
},
Menu: {
algorithm: true,
itemBorderRadius: 20, // Google 风格的圆角菜单项
itemHeight: 36,
itemHeightSM: 32,
},
Tabs: {
algorithm: true,
inkBarColor: '#1a73e8',
itemSelectedColor: '#1a73e8',
itemHoverColor: '#174ea6',
},
Table: {
algorithm: true,
// 边框
borderRadius: 8,
headerBg: isDarkMode ? '#2d2d2d' : '#f8f9fa',
borderRadiusLG: 12,
borderRadiusSM: 4,
colorBorder: isDarkMode
? "rgba(255, 255, 255, 0.12)"
: "rgba(0, 0, 0, 0.12)",
// 阴影
boxShadow:
"0 1px 3px 0 rgba(60,64,67,0.3), 0 4px 8px 3px rgba(60,64,67,0.15)",
boxShadowSecondary:
"0 1px 2px 0 rgba(60,64,67,0.3), 0 1px 3px 1px rgba(60,64,67,0.15)",
// 字体
fontFamily: "Google Sans, Roboto, Arial, sans-serif",
fontSize: 14,
// 控件尺寸
controlHeight: 36,
controlHeightSM: 32,
controlHeightLG: 44,
},
Modal: {
algorithm: true,
borderRadius: 28, // Google 风格的大圆角对话框
paddingContentHorizontal: 24,
paddingContentVertical: 24,
}
}
}}
>
{ children }
</ConfigProvider>
components: {
Button: {
algorithm: true,
borderRadius: 20, // Google 风格的圆角按钮
controlHeight: 36,
controlHeightSM: 32,
controlHeightLG: 44,
paddingContentHorizontal: 24,
},
Input: {
algorithm: true,
controlHeight: 36,
controlHeightSM: 32,
controlHeightLG: 44,
paddingContentHorizontal: 16,
borderRadius: 4,
controlOutline: "none",
controlOutlineWidth: 0,
controlPaddingHorizontal: 16,
controlPaddingHorizontalSM: 12,
addonBg: "transparent",
},
Select: {
algorithm: true,
controlHeight: 36,
controlHeightSM: 32,
controlHeightLG: 44,
borderRadius: 4,
controlOutline: "none",
controlOutlineWidth: 0,
selectorBg: "transparent",
},
Card: {
algorithm: true,
borderRadiusLG: 8,
boxShadow:
"0 1px 2px 0 rgba(60,64,67,0.3), 0 1px 3px 1px rgba(60,64,67,0.15)",
},
Menu: {
algorithm: true,
itemBorderRadius: 20, // Google 风格的圆角菜单项
itemHeight: 36,
itemHeightSM: 32,
},
Tabs: {
algorithm: true,
inkBarColor: "#1a73e8",
itemSelectedColor: "#1a73e8",
itemHoverColor: "#174ea6",
},
Table: {
algorithm: true,
borderRadius: 8,
headerBg: isDarkMode ? "#2d2d2d" : "#f8f9fa",
},
Modal: {
algorithm: true,
borderRadius: 28, // Google 风格的大圆角对话框
paddingContentHorizontal: 24,
paddingContentVertical: 24,
},
},
}}
>
{children}
</ConfigProvider>
</ThemeContext.Provider>
);
};
@@ -145,7 +196,7 @@ export const ThemeProvider = ({ children }) => {
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
};

View File

@@ -1,50 +1,55 @@
import { supabase } from '@/config/supabase'
import { supabase } from "@/config/supabase";
class SupabaseService {
async select(table, options = {}) {
try {
let query = supabase
.from(table)
.select(options.select || '*', { count: 'exact' })
.select(options.select || "*", { count: "exact" });
// 处理精确匹配条件
if (options.match) {
query = query.match(options.match)
query = query.match(options.match);
}
// 处理过滤条件
if (options.filter) {
Object.entries(options.filter).forEach(([key, condition]) => {
Object.entries(condition).forEach(([operator, value]) => {
query = query.filter(key, operator, value)
})
})
query = query.filter(key, operator, value);
});
});
}
// 处理排序
if (options.order) {
query = query.order(options.order.column, {
ascending: options.order.ascending
})
ascending: options.order.ascending,
});
}
// 处理分页
if (options.page && options.pageSize) {
const from = (options.page - 1) * options.pageSize
const to = from + options.pageSize - 1
query = query.range(from, to)
const from = (options.page - 1) * options.pageSize;
const to = from + options.pageSize - 1;
query = query.range(from, to);
}
const { data, error, count } = await query
// 如果需要单条数据
if (options.single) {
query = query.single();
}
if (error) throw error
const { data, error, count } = await query;
if (error) throw error;
return {
data,
total: count || 0
}
total: count || 0,
};
} catch (error) {
console.error(`Error fetching from ${table}:`, error.message)
throw error
console.error(`Error fetching from ${table}:`, error.message);
throw error;
}
}
@@ -54,29 +59,27 @@ class SupabaseService {
const { data: result, error } = await supabase
.from(table)
.insert(data)
.select()
.select();
if (error) throw error
return result
if (error) throw error;
return result;
} catch (error) {
console.error(`Error inserting into ${table}:`, error.message)
throw error
console.error(`Error inserting into ${table}:`, error.message);
throw error;
}
}
// 优化 UPDATE 请求
async update(table, match, updates) {
try {
let query = supabase
.from(table)
.update(updates);
let query = supabase.from(table).update(updates);
// 如果是对象,使用 match 方法
if (typeof match === 'object') {
if (typeof match === "object") {
query = query.match(match);
} else {
// 如果是单个 id使用 eq 方法
query = query.eq('id', match);
query = query.eq("id", match);
}
const { data, error } = await query.select();
@@ -92,38 +95,32 @@ class SupabaseService {
// 通用 DELETE 请求
async delete(table, match) {
try {
const { error } = await supabase
.from(table)
.delete()
.match(match)
const { error } = await supabase.from(table).delete().match(match);
if (error) throw error
return true
if (error) throw error;
return true;
} catch (error) {
console.error(`Error deleting from ${table}:`, error.message)
throw error
console.error(`Error deleting from ${table}:`, error.message);
throw error;
}
}
// 通用 UPSERT 请求
async upsert(table, data, onConflict) {
// 通用 UPSERT 请求
async upsert(table, data, onConflict) {
try {
let query = supabase
.from(table)
.upsert(data)
.select()
let query = supabase.from(table).upsert(data).select();
if (onConflict) {
query = query.onConflict(onConflict)
query = query.onConflict(onConflict);
}
const { data: result, error } = await query
if (error) throw error
return result
const { data: result, error } = await query;
if (error) throw error;
return result;
} catch (error) {
console.error(`Error upserting into ${table}:`, error.message)
throw error
console.error(`Error upserting into ${table}:`, error.message);
throw error;
}
}
}
export const supabaseService = new SupabaseService()
export const supabaseService = new SupabaseService();

View File

@@ -14,100 +14,97 @@ const Login = () => {
login(values.email, values.password);
};
return (
<div className="min-h-screen flex items-center justify-center bg-[#f5f8ff] dark:bg-gray-900">
<div className="w-full max-w-[1200px] mx-auto flex p-4 md:p-0">
{/* 左侧登录表单 */}
<div className="w-full md:w-1/2 bg-white dark:bg-gray-800 p-8 md:p-12 rounded-3xl shadow-2xl dark:shadow-gray-800/50">
<div className="mb-10 text-center">
<h1 className="text-4xl font-bold mb-3 bg-gradient-to-r from-blue-600 to-cyan-500 bg-clip-text text-transparent">
Uppeta
</h1>
<p className="text-gray-500 dark:text-gray-400">
欢迎回来请登录您的账户
</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
prefix={<MailOutlined className="text-gray-400" />}
placeholder="邮箱"
className="h-12 rounded-xl dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: "请输入密码!" }]}
>
<Input.Password
prefix={<LockOutlined className="text-gray-400" />}
placeholder="密码"
className="h-12 rounded-xl dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100"
/>
</Form.Item>
<div className="flex justify-end mb-6">
<Link
to="/forgot-password"
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 text-sm"
>
忘记密码
</Link>
<div className="w-full max-w-[1200px] mx-auto flex p-4 md:p-0">
{/* 左侧登录表单 */}
<div className="w-full md:w-1/2 bg-white dark:bg-gray-800 p-8 md:p-12 rounded-3xl shadow-2xl dark:shadow-gray-800/50">
<div className="mb-10 text-center">
<h1 className="text-4xl font-bold mb-3 bg-gradient-to-r from-blue-600 to-cyan-500 bg-clip-text text-transparent">
Uppeta
</h1>
<p className="text-gray-500 dark:text-gray-400">
欢迎回来请登录您的账户
</p>
</div>
<Form.Item>
<Button
type="primary"
htmlType="submit"
block
loading={emailLoading}
disabled={googleLoading}
className="h-12 rounded-xl text-base font-medium bg-gradient-to-r from-blue-600 to-cyan-500 border-0 hover:from-blue-700 hover:to-cyan-600"
>
登录
</Button>
</Form.Item>
<Divider className="dark:border-gray-700">
<span className="text-gray-400"></span>
</Divider>
<Button
icon={<GoogleOutlined />}
block
onClick={signInWithGoogle}
loading={googleLoading}
disabled={emailLoading}
className="h-12 rounded-xl text-base font-medium mb-6 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600"
<Form
form={form}
name="login"
onFinish={handleLogin}
layout="vertical"
size="large"
>
使用 Google 账号登录
</Button>
<Form.Item
name="email"
rules={[
{ required: true, message: "请输入邮箱!" },
{ type: "email", message: "请输入有效的邮箱地址!" },
]}
>
<Input
prefix={<MailOutlined className="text-gray-400" />}
placeholder="邮箱"
className="h-12 rounded-xl dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: "请输入密码!" }]}
>
<Input.Password
prefix={<LockOutlined className="text-gray-400" />}
placeholder="密码"
className="h-12 rounded-xl dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100"
/>
</Form.Item>
</Form>
</div>
<div className="flex justify-end mb-6">
<Link
to="/forgot-password"
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 text-sm"
>
忘记密码
</Link>
</div>
{/* 右侧图片 */}
<div className="hidden md:flex md:w-1/2 items-center justify-center p-12">
<div className="w-full h-full rounded-3xl bg-[url('https://uppeta.com/img/svg/main.svg')] bg-center bg-contain bg-no-repeat dark:opacity-90 animate-pulse-slow transform hover:scale-105 transition-all duration-500" />
<Form.Item>
<Button
type="primary"
htmlType="submit"
block
loading={emailLoading}
disabled={googleLoading}
className="h-12 rounded-xl text-base font-medium bg-gradient-to-r from-blue-600 to-cyan-500 border-0 hover:from-blue-700 hover:to-cyan-600"
>
登录
</Button>
</Form.Item>
<Divider className="dark:border-gray-700">
<span className="text-gray-400"></span>
</Divider>
<Button
icon={<GoogleOutlined />}
block
onClick={signInWithGoogle}
loading={googleLoading}
disabled={emailLoading}
className="h-12 rounded-xl text-base font-medium mb-6 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600"
>
使用 Google 账号登录
</Button>
</Form>
</div>
{/* 右侧图片 */}
<div className="hidden md:flex md:w-1/2 items-center justify-center p-12">
<div className="w-full h-full rounded-3xl bg-[url('https://uppeta.com/img/svg/main.svg')] bg-center bg-contain bg-no-repeat dark:opacity-90 animate-pulse-slow transform hover:scale-105 transition-all duration-500" />
</div>
</div>
</div>
</div>
);
};

View File

@@ -229,7 +229,7 @@ const ServicePage = () => {
return (
<div className="bg-gray-50 p-4 rounded-lg">
{record.attributes.sections.map((section) => (
<div key={section.key} className="mb-6 bg-white rounded-lg shadow-sm p-4">
<div key={section.key} className="mb-6 rounded-lg shadow-sm p-4">
<div className="flex items-center justify-between mb-3 border-b pb-2">
<h3 className="text-lg font-medium text-gray-800">{section.sectionName}</h3>
<Popconfirm
@@ -586,7 +586,7 @@ const ServicePage = () => {
pageSize: 10,
showTotal: (total) => `${total}`,
}}
className="bg-white rounded-lg"
className="rounded-lg"
/>
{/* 添加模板类型选择弹窗 */}

View File

@@ -264,7 +264,7 @@ const Classify = ({activeType,typeList}) => {
</div>
</div>
<div className="bg-white rounded-lg shadow-sm">
<div className="rounded-lg shadow-sm">
<Form form={form}>
<Table
scroll={{ x: true }}

View File

@@ -41,7 +41,7 @@ const ResourceManagement = () => {
activeKey={activeType}
onChange={setActiveType}
type="card"
className="bg-white rounded-lg shadow-sm"
className="rounded-lg shadow-sm"
items={TEMPLATE_TYPES.map(type => ({
key: type.key,
label: (

View File

@@ -223,7 +223,7 @@ const UnitManagement = ({ activeType, typeList }) => {
return (
<div className="p-6 bg-gray-50">
<div className="bg-white rounded-lg shadow-sm mb-6 p-4">
<div className="rounded-lg shadow-sm mb-6 p-4">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="flex items-center gap-2">
<Button
@@ -261,7 +261,7 @@ const UnitManagement = ({ activeType, typeList }) => {
</div>
</div>
<div className="bg-white rounded-lg shadow-sm">
<div className="rounded-lg shadow-sm">
<Form form={form}>
<Table
scroll={{ x: true }}

View File

@@ -460,7 +460,7 @@ const StorageManager = () => {
// 修改文件列表渲染
const renderFileList = () => (
<div
className="flex-1 overflow-y-auto bg-white rounded-lg shadow-sm"
className="flex-1 overflow-y-auto rounded-lg shadow-sm"
id="scrollableDiv"
>
{/* 面包屑导航 */}
@@ -600,7 +600,7 @@ const StorageManager = () => {
return (
<div className="flex h-screen bg-gray-50">
<div className="w-1/3 p-4 flex flex-col space-y-4">
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<div className="rounded-lg shadow-sm overflow-hidden">
<Dragger
{...uploadProps}
className="px-6 py-8 hover:bg-gray-50 transition-colors group"

View File

@@ -2,7 +2,7 @@ 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 '@/routes/routes';
import { generateRoutes } from '@/routes/routes';
import Login from '@/pages/auth/Login';
import NotFound from '@/pages/notFound';
import Dashboard from '@/pages/Dashboard';
@@ -48,13 +48,14 @@ const renderRoutes = (routes) => {
const AppRoutes = () => {
const { user } = useAuth();
const { adminRole: role } = user;
return (
<Routes>
<Route
path="/login"
element={
user ? <Navigate to="/company/serviceTemplate" replace /> : <Login />
user?.id ? <Navigate to="/company/serviceTemplate" replace /> : <Login />
}
/>
@@ -70,7 +71,7 @@ const AppRoutes = () => {
index
element={<Navigate to="/company/serviceTemplate" replace />}
/>
{renderRoutes(routes)}
{renderRoutes(generateRoutes(role))}
<Route path="*" element={<NotFound />} />
</Route>
</Routes>

View File

@@ -1,6 +1,5 @@
import { lazy } from "react";
// Resource Management routes
const resourceRoutes = [
{
@@ -8,24 +7,28 @@ const resourceRoutes = [
component: lazy(() => import("@/pages/resource/team")),
name: "团队管理",
icon: "team",
roles: ["OWNER"],
},
{
path: "bucket",
component: lazy(() => import("@/pages/resource/bucket")),
name: "对象存储",
icon: "shop",
roles: ["OWNER"],
},
{
path: "task",
component: lazy(() => import("@/pages/resource/resourceTask")),
name: "任务管理",
icon: "appstore",
roles: ["OWNER"],
},
{
path: "task/edit/:id?",
component: lazy(() => import("@/pages/resource/resourceTask/edit")),
hidden: true,
name: "新增/编辑任务",
roles: ["OWNER"],
},
];
@@ -36,6 +39,7 @@ const companyRoutes = [
component: lazy(() => import("@/pages/company/quotation")),
name: "报价单",
icon: "file",
roles: ["ADMIN", "OWNER"],
},
{
path: "quotaInfo/:id?", // 添加可选的 id 参数
@@ -43,18 +47,21 @@ const companyRoutes = [
component: lazy(() => import("@/pages/company/quotation/detail")),
name: "报价单详情",
icon: "file",
roles: ["ADMIN", "OWNER"],
},
{
path: "serviceTemplate",
component: lazy(() => import("@/pages/company/service")),
name: "服务管理",
icon: "container",
roles: ["ADMIN", "OWNER"],
},
{
path: "templateItemManage",
component: lazy(() => import("@/pages/company/service/itemsManange")),
name: "资源类型",
icon: "container",
roles: ["ADMIN", "OWNER"],
},
{
path: "serviceTemplateInfo/:id?",
@@ -62,6 +69,7 @@ const companyRoutes = [
component: lazy(() => import("@/pages/company/service/detail")),
name: "服务模版详情",
icon: "container",
roles: ["ADMIN", "OWNER"],
},
{
path: "quotaInfo/preview/:id?", // 添加可选的 id 参数
@@ -69,12 +77,14 @@ const companyRoutes = [
component: lazy(() => import("@/pages/company/quotation/view")),
name: "报价单预览",
icon: "file",
roles: ["ADMIN", "OWNER"],
},
{
path: "customer",
component: lazy(() => import("@/pages/company/customer")),
name: "客户管理",
icon: "user",
roles: ["ADMIN", "OWNER"],
},
{
path: "customerInfo/:id?",
@@ -82,12 +92,14 @@ const companyRoutes = [
component: lazy(() => import("@/pages/company/customer/detail")),
name: "客户详情",
icon: "user",
roles: ["ADMIN", "OWNER"],
},
{
path: "supplier",
component: lazy(() => import("@/pages/company/supplier")),
name: "供应商管理",
icon: "branches",
roles: ["ADMIN", "OWNER"],
},
{
path: "supplierInfo/:id?",
@@ -95,37 +107,63 @@ const companyRoutes = [
component: lazy(() => import("@/pages/company/supplier/detail")),
name: "供应商详情",
icon: "branches",
roles: ["ADMIN", "OWNER"],
},
];
const marketingRoutes = [];
export const routes = [
// {
// path: "dashboard",
// component: lazy(() => import("@/pages/Dashboard")),
// name: "仪表盘",
// icon: "dashboard",
// },
const roleRoutes = [
{
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,
path: "role",
component: lazy(() => import("@/pages/role")),
name: "角色管理",
icon: "setting",
roles: ["ADMIN", "OWNER"],
},
];
export const generateRoutes = (role) => {
return [
{
path: "dashboard",
component: lazy(() => import("@/pages/Dashboard")),
name: "仪表盘",
icon: "dashboard",
roles: ["ADMIN", "OWNER", "MEMBER"],
},
{
path: "resource",
component: lazy(() => import("@/pages/resource")),
name: "资源管理",
icon: "appstore",
children: resourceRoutes.filter((route) => route.roles.includes(role)),
roles: ["OWNER"],
},
{
path: "company",
component: lazy(() => import("@/pages/company")),
name: "公司管理",
icon: "bank",
children: companyRoutes.filter((route) => route.roles.includes(role)),
roles: ["ADMIN", "OWNER"],
},
{
path: "marketing",
component: lazy(() => import("@/pages/marketing")),
name: "行销中心",
icon: "shopping",
children: marketingRoutes.filter((route) => route.roles.includes(role)),
roles: ["ADMIN", "OWNER"],
},
// {
// path: "role",
// component: lazy(() => import("@/pages/role")),
// name: "权限管理",
// icon: "setting",
// children: roleRoutes.filter((route) => route.roles.includes(role)),
// roles: ["ADMIN", "OWNER"],
// },
].filter((route) => route.roles.includes(role));
};

View File

@@ -2,6 +2,7 @@
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&display=swap');
:root {
--primary-color: #1677ff;
--primary-gradient: linear-gradient(45deg, #1677ff, #36cff0);
@@ -40,7 +41,7 @@ body {
line-height: 44px;
margin-bottom: 8px;
.ant-menu-item-icon + span {
.ant-menu-item-icon+span {
opacity: 1;
transition: opacity 0.2s;
}
@@ -56,6 +57,7 @@ body {
}
&.ant-menu-inline-collapsed {
.ant-menu-item,
.ant-menu-submenu .ant-menu-submenu-title {
padding: 0 !important;
@@ -75,7 +77,7 @@ body {
.ant-menu-submenu {
.ant-menu-submenu-title {
@apply rounded-lg;
@apply rounded-lg;
height: 44px;
line-height: 44px;
@@ -85,7 +87,7 @@ body {
}
&.ant-menu-submenu-open {
> .ant-menu-submenu-title {
>.ant-menu-submenu-title {
color: var(--primary-color);
}
}
@@ -121,12 +123,12 @@ body {
.ant-table {
background: #1f1f1f;
.ant-table-thead > tr > th {
.ant-table-thead>tr>th {
background: #141414;
color: rgba(255, 255, 255, 0.85);
}
.ant-table-tbody > tr > td {
.ant-table-tbody>tr>td {
border-bottom: 1px solid #303030;
}
}
@@ -148,29 +150,36 @@ body {
@apply text-2xl font-semibold mt-2;
}
}
/* 滚动条基础样式 */
::-webkit-scrollbar {
width: 8px; /* 垂直滚动条宽度 */
height: 8px; /* 水平滚动条度 */
width: 8px;
/* 垂直滚动条度 */
height: 8px;
/* 水平滚动条高度 */
}
/* 亮色模式滚动条样式 */
::-webkit-scrollbar-track {
@apply bg-gray-100; /* 轨道背景色 */
@apply bg-gray-100;
/* 轨道背景色 */
}
::-webkit-scrollbar-thumb {
@apply bg-gray-300 rounded-full hover:bg-gray-400 transition-colors; /* 滑块样式 */
@apply bg-gray-300 rounded-full hover:bg-gray-400 transition-colors;
/* 滑块样式 */
}
/* 暗色模式滚动条样式 */
.dark {
::-webkit-scrollbar-track {
@apply bg-gray-800; /* 暗色模式轨道背景 */
@apply bg-gray-800;
/* 暗色模式轨道背景 */
}
::-webkit-scrollbar-thumb {
@apply bg-gray-600 hover:bg-gray-500; /* 暗色模式滑块样式 */
@apply bg-gray-600 hover:bg-gray-500;
/* 暗色模式滑块样式 */
}
}
@@ -184,3 +193,17 @@ body {
scrollbar-color: theme('colors.gray.600') theme('colors.gray.800');
}
::view-transition-new(root),
::view-transition-old(root) {
animation: none;
}
html.dark::view-transition-old(root) {
z-index: 9999;
}
.ant-menu {
height: 100%;
}

View File

@@ -1,9 +1,7 @@
import React from 'react';
import { routes } from '@/routes/routes';
import * as AntIcons from '@ant-design/icons';
import { ColorIcon } from '@/components/Layout/ColorIcon';
import React from "react";
import { generateRoutes } from "@/routes/routes";
import * as AntIcons from "@ant-design/icons";
import { ColorIcon } from "@/components/Layout/ColorIcon";
const getAntIcon = (iconName) => {
const iconKey = `${iconName.charAt(0).toUpperCase()}${iconName.slice(
@@ -12,23 +10,25 @@ const getAntIcon = (iconName) => {
return AntIcons[iconKey] ? React.createElement(AntIcons[iconKey]) : null;
};
const generateMenuItems = (routes, parentPath = '') => {
return routes.filter(route => !route.hidden).map((route) => {
const fullPath = `${parentPath}/${route.path}`.replace(/\/+/g, '/');
const icon = route.icon && <ColorIcon icon={getAntIcon(route.icon)} />;
const generateMenuItems = (routes, parentPath = "") => {
return routes
.filter((route) => !route.hidden)
.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,
};
const menuItem = {
key: fullPath,
icon,
label: route.name,
};
if (route.children) {
menuItem.children = generateMenuItems(route.children, fullPath);
}
if (route.children) {
menuItem.children = generateMenuItems(route.children, fullPath);
}
return menuItem;
});
return menuItem;
});
};
export const getMenuItems = () => generateMenuItems(routes);
export const getMenuItems = (role) => generateMenuItems(generateRoutes(role));

4438
yarn.lock Normal file

File diff suppressed because it is too large Load Diff