增加审批组件

This commit is contained in:
dengqichen 2025-10-24 20:30:02 +08:00
parent 6acc7f339f
commit 2aada4632e
3 changed files with 401 additions and 322 deletions

View File

@ -1,200 +1,234 @@
import React, {useEffect, useState, useCallback} from 'react';
import {Dropdown, Modal, message, Spin, Space, Tooltip} from 'antd';
import {useNavigate, useLocation, Outlet} from 'react-router-dom';
import {useDispatch, useSelector} from 'react-redux';
import React, { useEffect, useState, useCallback } from 'react';
import { Dropdown, Spin, Space, Tooltip } from 'antd';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import {
UserOutlined,
LogoutOutlined,
ExclamationCircleOutlined,
ClockCircleOutlined,
CloudOutlined
UserOutlined,
LogoutOutlined,
ClockCircleOutlined,
CloudOutlined,
} from '@ant-design/icons';
import {logout, setMenus} from '../store/userSlice';
import type {MenuProps} from 'antd';
import {getCurrentUserMenus} from '@/pages/System/Menu/service';
import {getWeather} from '../services/weather';
import type {RootState} from '../store';
import { logout, setMenus } from '../store/userSlice';
import type { MenuProps } from 'antd';
import { getCurrentUserMenus } from '@/pages/System/Menu/service';
import { getWeather } from '../services/weather';
import type { RootState } from '../store';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import { AppMenu } from './AppMenu';
import { Layout, LayoutContent, Header, Main } from '@/components/ui/layout';
const {confirm} = Modal;
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useToast } from '@/components/ui/use-toast';
import { AlertCircle } from 'lucide-react';
// 设置中文语言
dayjs.locale('zh-cn');
const BasicLayout: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const dispatch = useDispatch();
const userInfo = useSelector((state: RootState) => state.user.userInfo);
const [loading, setLoading] = useState(true);
const [currentTime, setCurrentTime] = useState(dayjs());
const [weather, setWeather] = useState({temp: '--', weather: '未知', city: '未知'});
// 根据当前路径获取需要展开的父级菜单
const getDefaultOpenKeys = () => {
const pathSegments = location.pathname.split('/').filter(Boolean);
const openKeys: string[] = [];
let currentPath = '';
pathSegments.forEach(segment => {
currentPath += `/${segment}`;
openKeys.push(currentPath);
});
return openKeys;
};
const navigate = useNavigate();
const location = useLocation();
const dispatch = useDispatch();
const { toast } = useToast();
const userInfo = useSelector((state: RootState) => state.user.userInfo);
const [loading, setLoading] = useState(true);
const [currentTime, setCurrentTime] = useState(dayjs());
const [weather, setWeather] = useState({ temp: '--', weather: '未知', city: '未知' });
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false);
// 设置默认展开的菜单
const [openKeys, setOpenKeys] = useState<string[]>(getDefaultOpenKeys());
// 根据当前路径获取需要展开的父级菜单
const getDefaultOpenKeys = () => {
const pathSegments = location.pathname.split('/').filter(Boolean);
const openKeys: string[] = [];
let currentPath = '';
// 当路由变化时,自动展开对应的父级菜单
useEffect(() => {
const newOpenKeys = getDefaultOpenKeys();
setOpenKeys(prevKeys => {
const mergedKeys = [...new Set([...prevKeys, ...newOpenKeys])];
return mergedKeys;
});
}, [location.pathname]);
pathSegments.forEach((segment) => {
currentPath += `/${segment}`;
openKeys.push(currentPath);
});
// 将天气获取逻辑提取为useCallback
const fetchWeather = useCallback(async () => {
try {
const data = await getWeather();
setWeather(data);
} catch (error) {
console.error('获取天气信息失败:', error);
}
}, []);
return openKeys;
};
// 初始化用户数据
useEffect(() => {
const initializeUserData = async () => {
setLoading(true);
try {
const menuData = await getCurrentUserMenus();
dispatch(setMenus(menuData));
} catch (error) {
message.error('获取菜单数据失败');
console.error('获取菜单数据失败:', error);
} finally {
setLoading(false);
}
};
// 设置默认展开的菜单
const [openKeys, setOpenKeys] = useState<string[]>(getDefaultOpenKeys());
if (!userInfo) {
initializeUserData();
} else {
setLoading(false);
}
}, [dispatch, userInfo]);
// 当路由变化时,自动展开对应的父级菜单
useEffect(() => {
const newOpenKeys = getDefaultOpenKeys();
setOpenKeys((prevKeys) => {
const mergedKeys = [...new Set([...prevKeys, ...newOpenKeys])];
return mergedKeys;
});
}, [location.pathname]);
// 处理时间和天气更新
useEffect(() => {
// 每秒更新时间
const timer = setInterval(() => {
setCurrentTime(dayjs());
}, 1000);
// 初始化天气并设置定时更新
fetchWeather();
const weatherTimer = setInterval(fetchWeather, 30 * 60 * 1000);
return () => {
clearInterval(timer);
clearInterval(weatherTimer);
};
}, [fetchWeather]);
const handleLogout = () => {
confirm({
title: '确认退出',
icon: <ExclamationCircleOutlined/>,
content: '确定要退出系统吗?',
okText: '确定',
cancelText: '取消',
onOk: () => {
dispatch(logout());
message.success('退出成功');
navigate('/login', {replace: true});
}
});
};
const userMenuItems: MenuProps['items'] = [
{
key: 'userInfo',
icon: <UserOutlined/>,
label: (
<div style={{padding: '4px 0'}}>
<div>{userInfo?.nickname || userInfo?.username}</div>
{userInfo?.email && <div style={{fontSize: '12px', color: '#999'}}>{userInfo.email}</div>}
</div>
),
disabled: true
},
{
type: 'divider'
},
{
key: 'logout',
icon: <LogoutOutlined/>,
label: '退出登录',
onClick: handleLogout
}
];
// 处理菜单展开/收起
const handleOpenChange = (keys: string[]) => {
setOpenKeys(keys);
};
if (loading) {
return (
<div className="flex h-screen w-screen flex-col items-center justify-center bg-slate-50">
<Spin size="large"/>
<div className="mt-6 text-center text-slate-500">
<p className="text-base"></p>
<p className="text-sm">...</p>
</div>
</div>
);
// 将天气获取逻辑提取为useCallback
const fetchWeather = useCallback(async () => {
try {
const data = await getWeather();
setWeather(data);
} catch (error) {
console.error('获取天气信息失败:', error);
}
}, []);
// 初始化用户数据
useEffect(() => {
const initializeUserData = async () => {
setLoading(true);
try {
const menuData = await getCurrentUserMenus();
dispatch(setMenus(menuData));
} catch (error) {
toast({
title: '加载失败',
description: '获取菜单数据失败',
variant: 'destructive',
});
console.error('获取菜单数据失败:', error);
} finally {
setLoading(false);
}
};
if (!userInfo) {
initializeUserData();
} else {
setLoading(false);
}
}, [dispatch, userInfo, toast]);
// 处理时间和天气更新
useEffect(() => {
// 每秒更新时间
const timer = setInterval(() => {
setCurrentTime(dayjs());
}, 1000);
// 初始化天气并设置定时更新
fetchWeather();
const weatherTimer = setInterval(fetchWeather, 30 * 60 * 1000);
return () => {
clearInterval(timer);
clearInterval(weatherTimer);
};
}, [fetchWeather]);
const handleLogout = () => {
setLogoutDialogOpen(true);
};
const confirmLogout = () => {
dispatch(logout());
toast({
title: '退出成功',
description: '已成功退出系统',
});
navigate('/login', { replace: true });
};
const userMenuItems: MenuProps['items'] = [
{
key: 'userInfo',
icon: <UserOutlined />,
label: (
<div style={{ padding: '4px 0' }}>
<div>{userInfo?.nickname || userInfo?.username}</div>
{userInfo?.email && <div style={{ fontSize: '12px', color: '#999' }}>{userInfo.email}</div>}
</div>
),
disabled: true,
},
{
type: 'divider',
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: handleLogout,
},
];
// 处理菜单展开/收起
const handleOpenChange = (keys: string[]) => {
setOpenKeys(keys);
};
if (loading) {
return (
<Layout>
<AppMenu openKeys={openKeys} onOpenChange={handleOpenChange} />
<LayoutContent>
<Header className="justify-end">
<Space size={24}>
<Space size={16}>
<span className="inline-flex items-center text-sm text-slate-600">
<ClockCircleOutlined className="mr-2" />
{currentTime.format('YYYY年MM月DD日 HH:mm:ss')} {currentTime.format('dd')}
</span>
<Tooltip title={`${weather.city}`}>
<span className="inline-flex items-center text-sm text-slate-600">
<CloudOutlined className="mr-2" />
{weather.weather} {weather.temp}
</span>
</Tooltip>
</Space>
<Dropdown menu={{items: userMenuItems}} trigger={['hover']}>
<Space className="cursor-pointer">
<UserOutlined/>
<span>{userInfo?.nickname || userInfo?.username}</span>
</Space>
</Dropdown>
</Space>
</Header>
<Main className="bg-white">
<Outlet/>
</Main>
</LayoutContent>
</Layout>
<div className="flex h-screen w-screen flex-col items-center justify-center bg-slate-50">
<Spin size="large" />
<div className="mt-6 text-center text-slate-500">
<p className="text-base"></p>
<p className="text-sm">...</p>
</div>
</div>
);
}
}
export default BasicLayout;
return (
<Layout>
<AppMenu openKeys={openKeys} onOpenChange={handleOpenChange} />
<LayoutContent>
<Header className="justify-end">
<Space size={24}>
<Space size={16}>
<span className="inline-flex items-center text-sm text-slate-600">
<ClockCircleOutlined className="mr-2" />
{currentTime.format('YYYY年MM月DD日 HH:mm:ss')} {currentTime.format('dd')}
</span>
<Tooltip title={`${weather.city}`}>
<span className="inline-flex items-center text-sm text-slate-600">
<CloudOutlined className="mr-2" />
{weather.weather} {weather.temp}
</span>
</Tooltip>
</Space>
<Dropdown menu={{ items: userMenuItems }} trigger={['hover']}>
<Space className="cursor-pointer">
<UserOutlined />
<span>{userInfo?.nickname || userInfo?.username}</span>
</Space>
</Dropdown>
</Space>
</Header>
<Main className="bg-white">
<Outlet />
</Main>
</LayoutContent>
{/* 退出登录确认对话框 */}
<AlertDialog open={logoutDialogOpen} onOpenChange={setLogoutDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-amber-500" />
退
</AlertDialogTitle>
<AlertDialogDescription>
退
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setLogoutDialogOpen(false)}>
</AlertDialogCancel>
<AlertDialogAction onClick={confirmLogout}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Layout>
);
};
export default BasicLayout;

View File

@ -1,161 +1,206 @@
import React, {useEffect, useState} from 'react';
import {Form, Input, Button, message, Select} from 'antd';
import {useNavigate} from 'react-router-dom';
import {useDispatch} from 'react-redux';
import {login} from './service';
import {getEnabledTenants} from '@/pages/System/Tenant/service';
import {setToken, setUserInfo, setMenus} from '../../store/userSlice';
import {getCurrentUserMenus} from '@/pages/System/Menu/service';
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { login } from './service';
import { getEnabledTenants } from '@/pages/System/Tenant/service';
import { setToken, setUserInfo, setMenus } from '../../store/userSlice';
import { getCurrentUserMenus } from '@/pages/System/Menu/service';
import type { TenantResponse } from '@/pages/System/Tenant/types';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useToast } from '@/components/ui/use-toast';
import { Loader2 } from 'lucide-react';
import styles from './index.module.css';
interface LoginForm {
tenantId: string;
username: string;
password: string;
}
const loginFormSchema = z.object({
tenantId: z.string().min(1, '请选择租户'),
username: z.string().min(1, '请输入用户名'),
password: z.string().min(1, '请输入密码'),
});
type LoginFormValues = z.infer<typeof loginFormSchema>;
const Login: React.FC = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
const [form] = Form.useForm<LoginForm>();
const [loading, setLoading] = useState(false);
const [tenants, setTenants] = useState<TenantResponse[]>([]);
const navigate = useNavigate();
const dispatch = useDispatch();
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [tenants, setTenants] = useState<TenantResponse[]>([]);
useEffect(() => {
const fetchTenants = async () => {
try {
const data = await getEnabledTenants();
setTenants(data);
} catch (error) {
console.error('获取租户列表失败:', error);
}
};
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<LoginFormValues>({
resolver: zodResolver(loginFormSchema),
defaultValues: {
tenantId: '',
username: '',
password: '',
},
});
fetchTenants();
}, []);
const selectedTenantId = watch('tenantId');
// 加载菜单数据
const loadMenuData = async () => {
try {
const menuData = await getCurrentUserMenus();
if (menuData && menuData.length > 0) {
dispatch(setMenus(menuData));
}
} catch (error) {
console.error('获取菜单数据失败:', error);
}
useEffect(() => {
const fetchTenants = async () => {
try {
const data = await getEnabledTenants();
setTenants(data);
} catch (error) {
console.error('获取租户列表失败:', error);
}
};
const handleFinish = async (values: LoginForm) => {
setLoading(true);
try {
// 1. 登录获取 token 和用户信息
const loginData = await login(values);
dispatch(setToken(loginData.token));
dispatch(setUserInfo({
id: loginData.id,
username: loginData.username,
nickname: loginData.nickname,
email: loginData.email,
phone: loginData.phone
}));
fetchTenants();
}, []);
// 2. 获取菜单数据
await loadMenuData();
// 加载菜单数据
const loadMenuData = async () => {
try {
const menuData = await getCurrentUserMenus();
if (menuData && menuData.length > 0) {
dispatch(setMenus(menuData));
}
} catch (error) {
console.error('获取菜单数据失败:', error);
}
};
message.success('登录成功');
navigate('/');
} catch (error) {
console.error('登录失败:', error);
} finally {
setLoading(false);
}
};
const onSubmit = async (values: LoginFormValues) => {
setLoading(true);
try {
// 1. 登录获取 token 和用户信息
const loginData = await login(values);
dispatch(setToken(loginData.token));
dispatch(
setUserInfo({
id: loginData.id,
username: loginData.username,
nickname: loginData.nickname,
email: loginData.email,
phone: loginData.phone,
})
);
return (
<div className={styles.loginContainer}>
{/* 左侧区域 */}
<div className={styles.leftSection}>
<div>
<h1 className="text-xl font-bold">DevOps门户管理系统</h1>
</div>
<div className="space-y-4">
<blockquote className="text-2xl font-medium">
"平台帮助我们显著提升了部署效率,让团队可以更专注于业务开发。"
</blockquote>
</div>
</div>
// 2. 获取菜单数据
await loadMenuData();
{/* 右侧登录区域 */}
<div className={styles.rightSection}>
<div className={styles.loginBox}>
<div className={styles.logo}>
<h1>Deploy Ease Platform</h1>
<p className="text-gray-500 mt-2"></p>
</div>
<Form<LoginForm>
form={form}
name="login"
onFinish={handleFinish}
autoComplete="off"
size="large"
layout="vertical"
>
<Form.Item
name="tenantId"
rules={[{required: true, message: '请选择租户!'}]}
>
<Select
placeholder="系统管理租户"
options={tenants.map(tenant => ({
label: tenant.name,
value: tenant.code
}))}
className={styles.input}
dropdownStyle={{
padding: '8px',
borderRadius: '8px',
}}
/>
</Form.Item>
toast({
title: '登录成功',
description: '欢迎回来!',
});
navigate('/');
} catch (error) {
console.error('登录失败:', error);
toast({
title: '登录失败',
description: error instanceof Error ? error.message : '用户名或密码错误',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
<Form.Item
name="username"
rules={[{required: true, message: '请输入用户名!'}]}
>
<Input
placeholder="admin"
className={styles.input}
/>
</Form.Item>
<Form.Item
name="password"
rules={[{required: true, message: '请输入密码!'}]}
>
<Input.Password
placeholder="········"
className={styles.input}
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
block
loading={loading}
className={styles.loginButton}
>
</Button>
</Form.Item>
</Form>
</div>
</div>
return (
<div className={styles.loginContainer}>
{/* 左侧区域 */}
<div className={styles.leftSection}>
<div>
<h1 className="text-xl font-bold">DevOps门户管理系统</h1>
</div>
);
<div className="space-y-4">
<blockquote className="text-2xl font-medium">
"平台帮助我们显著提升了部署效率,让团队可以更专注于业务开发。"
</blockquote>
</div>
</div>
{/* 右侧登录区域 */}
<div className={styles.rightSection}>
<div className={styles.loginBox}>
<div className={styles.logo}>
<h1>Deploy Ease Platform</h1>
<p className="text-gray-500 mt-2"></p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* 租户选择 */}
<div className="space-y-2">
<Label htmlFor="tenantId"></Label>
<Select
value={selectedTenantId}
onValueChange={(value) => setValue('tenantId', value, { shouldValidate: true })}
>
<SelectTrigger id="tenantId" className="h-10">
<SelectValue placeholder="系统管理租户" />
</SelectTrigger>
<SelectContent>
{tenants.map((tenant) => (
<SelectItem key={tenant.code} value={tenant.code}>
{tenant.name}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.tenantId && (
<p className="text-sm text-destructive">{errors.tenantId.message}</p>
)}
</div>
{/* 用户名 */}
<div className="space-y-2">
<Label htmlFor="username"></Label>
<Input
id="username"
placeholder="admin"
className="h-10"
{...register('username')}
/>
{errors.username && (
<p className="text-sm text-destructive">{errors.username.message}</p>
)}
</div>
{/* 密码 */}
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
placeholder="········"
className="h-10"
{...register('password')}
/>
{errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p>
)}
</div>
{/* 登录按钮 */}
<Button type="submit" className="w-full h-10" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</form>
</div>
</div>
</div>
);
};
export default Login;
export default Login;

View File

@ -1,4 +1,4 @@
import type { BaseResponse } from '@/types/base/response';
import type { BaseResponse } from '@/types/base';
// 租户响应类型
export interface TenantResponse extends BaseResponse {