增加前端代码。
This commit is contained in:
parent
f11564fa50
commit
c90e2526fe
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/frontend/.idea/
|
||||
/backend/.idea/
|
||||
/.idea/
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<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>管理系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4355
frontend/package-lock.json
generated
Normal file
4355
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
frontend/package.json
Normal file
35
frontend/package.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@reduxjs/toolkit": "^2.0.1",
|
||||
"antd": "^5.12.2",
|
||||
"axios": "^1.6.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^9.0.4",
|
||||
"react-router-dom": "^6.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.4",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
8
frontend/src/App.tsx
Normal file
8
frontend/src/App.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
const App: React.FC = () => {
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
export default App;
|
||||
44
frontend/src/api/request.ts
Normal file
44
frontend/src/api/request.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { message } from 'antd';
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
request.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
request.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResponse<any>>) => {
|
||||
const res = response.data;
|
||||
if (res.code !== 200) {
|
||||
message.error(res.message || '请求失败');
|
||||
return Promise.reject(new Error(res.message || '请求失败'));
|
||||
}
|
||||
return res.data;
|
||||
},
|
||||
(error) => {
|
||||
message.error(error.message || '请求失败');
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default request;
|
||||
47
frontend/src/api/user.ts
Normal file
47
frontend/src/api/user.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import request from '@/utils/request';
|
||||
import type { MenuDTO } from '@/pages/System/Menu/types';
|
||||
import type { UserDTO } from '@/pages/System/User/types';
|
||||
|
||||
export interface LoginParams {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
token: string;
|
||||
userId: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
export const login = async (data: LoginParams): Promise<LoginResult> => {
|
||||
return request.post('/api/auth/login', data);
|
||||
};
|
||||
|
||||
export const logout = async (): Promise<void> => {
|
||||
return request.post('/api/auth/logout');
|
||||
};
|
||||
|
||||
export const getUserMenus = async (): Promise<MenuDTO[]> => {
|
||||
return request.get('/api/system/user/menus');
|
||||
};
|
||||
|
||||
export const getUsers = async (params: any) => {
|
||||
return request.get('/api/system/users', { params });
|
||||
};
|
||||
|
||||
export const createUser = async (data: any) => {
|
||||
return request.post('/api/system/users', data);
|
||||
};
|
||||
|
||||
export const updateUser = async (id: number, data: any) => {
|
||||
return request.put(`/api/system/users/${id}`, data);
|
||||
};
|
||||
|
||||
export const deleteUser = async (id: number) => {
|
||||
return request.delete(`/api/system/users/${id}`);
|
||||
};
|
||||
|
||||
export const resetPassword = async (id: number) => {
|
||||
return request.post(`/api/system/users/${id}/reset-password`);
|
||||
};
|
||||
32
frontend/src/components/IconSelect/index.module.css
Normal file
32
frontend/src/components/IconSelect/index.module.css
Normal file
@ -0,0 +1,32 @@
|
||||
.iconGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.iconItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.iconItem:hover {
|
||||
background-color: #f5f5f5;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.iconName {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
}
|
||||
75
frontend/src/components/IconSelect/index.tsx
Normal file
75
frontend/src/components/IconSelect/index.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { Modal, Input, Space } from 'antd';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import * as AntdIcons from '@ant-design/icons';
|
||||
import styles from './index.module.css';
|
||||
|
||||
interface IconSelectProps {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
visible: boolean;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const IconSelect: React.FC<IconSelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
visible,
|
||||
onCancel
|
||||
}) => {
|
||||
const [search, setSearch] = React.useState('');
|
||||
|
||||
// 获取所有图标
|
||||
const iconList = React.useMemo(() => {
|
||||
return Object.keys(AntdIcons)
|
||||
.filter(key => key.endsWith('Outlined'))
|
||||
.map(key => {
|
||||
const IconComponent = AntdIcons[key as keyof typeof AntdIcons] as any;
|
||||
return {
|
||||
name: key.replace('Outlined', '').toLowerCase(),
|
||||
component: IconComponent
|
||||
};
|
||||
})
|
||||
.filter(icon =>
|
||||
search ? icon.name.toLowerCase().includes(search.toLowerCase()) : true
|
||||
);
|
||||
}, [search]);
|
||||
|
||||
const handleSelect = (iconName: string) => {
|
||||
onChange?.(iconName);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="选择图标"
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
width={800}
|
||||
footer={null}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索图标"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className={styles.iconGrid}>
|
||||
{iconList.map(({ name, component: Icon }) => (
|
||||
<div
|
||||
key={name}
|
||||
className={styles.iconItem}
|
||||
onClick={() => handleSelect(name)}
|
||||
>
|
||||
{React.createElement(Icon)}
|
||||
<div className={styles.iconName}>{name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconSelect;
|
||||
11
frontend/src/index.css
Normal file
11
frontend/src/index.css
Normal file
@ -0,0 +1,11 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
}
|
||||
229
frontend/src/layouts/BasicLayout.tsx
Normal file
229
frontend/src/layouts/BasicLayout.tsx
Normal file
@ -0,0 +1,229 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Layout, Menu, Dropdown, Modal, message, Spin, Space, Tooltip } from 'antd';
|
||||
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
DownOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloudOutlined
|
||||
} from '@ant-design/icons';
|
||||
import * as AntdIcons from '@ant-design/icons';
|
||||
import { logout, setMenus } from '../store/userSlice';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { getUserMenus } from '../api/user';
|
||||
import { getWeather } from '../services/weather';
|
||||
import type { MenuDTO } from '../pages/System/Menu/types';
|
||||
import type { RootState } from '../store';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
const { confirm } = Modal;
|
||||
|
||||
// 设置中文语言
|
||||
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 menus = useSelector((state: RootState) => state.user.menus);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentTime, setCurrentTime] = useState(dayjs());
|
||||
const [weather, setWeather] = useState({ temp: '--', weather: '未知', city: '未知' });
|
||||
|
||||
useEffect(() => {
|
||||
// 每秒更新时间
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(dayjs());
|
||||
}, 1000);
|
||||
|
||||
// 获取天气信息
|
||||
const fetchWeather = async () => {
|
||||
const data = await getWeather();
|
||||
setWeather(data);
|
||||
};
|
||||
|
||||
fetchWeather();
|
||||
// 每半小时更新一次天气
|
||||
const weatherTimer = setInterval(fetchWeather, 30 * 60 * 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
clearInterval(weatherTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchUserMenus = async () => {
|
||||
try {
|
||||
const data = await getUserMenus();
|
||||
dispatch(setMenus(data));
|
||||
} catch (error) {
|
||||
message.error('获取菜单失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserMenus();
|
||||
}, []);
|
||||
|
||||
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 getIcon = (iconName: string | undefined) => {
|
||||
if (!iconName) return null;
|
||||
const iconKey = `${iconName.charAt(0).toUpperCase() + iconName.slice(1)}Outlined`;
|
||||
const Icon = (AntdIcons as any)[iconKey];
|
||||
return Icon ? <Icon /> : null;
|
||||
};
|
||||
|
||||
// 将菜单数据转换为antd Menu需要的格式
|
||||
const getMenuItems = (menuList: MenuDTO[]): MenuProps['items'] => {
|
||||
return menuList.map(menu => ({
|
||||
key: menu.path || menu.id.toString(),
|
||||
icon: getIcon(menu.icon),
|
||||
label: menu.name,
|
||||
children: menu.children && menu.children.length > 0 ? getMenuItems(menu.children) : undefined
|
||||
}));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
background: '#f0f2f5'
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
<div style={{
|
||||
marginTop: 24,
|
||||
fontSize: 16,
|
||||
color: 'rgba(0, 0, 0, 0.45)',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<p>正在为您准备系统资源</p>
|
||||
<p style={{ fontSize: 14 }}>请稍候,马上就好...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider>
|
||||
<div style={{ height: 32, margin: 16, background: 'rgba(255, 255, 255, 0.2)' }} />
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={getMenuItems(menus)}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header style={{
|
||||
padding: '0 24px',
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
boxShadow: '0 1px 4px rgba(0,21,41,.08)'
|
||||
}}>
|
||||
<Space size={24}>
|
||||
<Space size={16}>
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
color: 'rgba(0, 0, 0, 0.65)',
|
||||
fontSize: 14
|
||||
}}>
|
||||
<ClockCircleOutlined style={{ marginRight: 8 }} />
|
||||
{currentTime.format('YYYY年MM月DD日 HH:mm:ss')} 星期{currentTime.format('dd')}
|
||||
</span>
|
||||
<Tooltip title={`${weather.city}`}>
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
color: 'rgba(0, 0, 0, 0.65)',
|
||||
fontSize: 14
|
||||
}}>
|
||||
<CloudOutlined style={{ marginRight: 8 }} />
|
||||
{weather.weather} {weather.temp}℃
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
<Dropdown menu={{ items: userMenuItems }} trigger={['hover']}>
|
||||
<span style={{
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
transition: 'all 0.3s',
|
||||
padding: '0 4px',
|
||||
borderRadius: 4,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0,0,0,0.025)'
|
||||
}
|
||||
}}>
|
||||
<UserOutlined style={{ fontSize: 16, marginRight: 8 }} />
|
||||
<span>{userInfo?.nickname || userInfo?.username}</span>
|
||||
<DownOutlined style={{ fontSize: 12, marginLeft: 6 }} />
|
||||
</span>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Header>
|
||||
<Content style={{ margin: '24px 16px', padding: 24, background: '#fff', minHeight: 280 }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default BasicLayout;
|
||||
19
frontend/src/main.tsx
Normal file
19
frontend/src/main.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import router from './router';
|
||||
import store from './store';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<RouterProvider router={router} />
|
||||
</ConfigProvider>
|
||||
</Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
41
frontend/src/pages/Dashboard/index.tsx
Normal file
41
frontend/src/pages/Dashboard/index.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { Card, Row, Col, Statistic } from 'antd';
|
||||
import { UserOutlined, ShoppingCartOutlined, FileOutlined } from '@ant-design/icons';
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="用户总数"
|
||||
value={112893}
|
||||
prefix={<UserOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="订单总数"
|
||||
value={93}
|
||||
prefix={<ShoppingCartOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="文件总数"
|
||||
value={1238}
|
||||
prefix={<FileOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
17
frontend/src/pages/Login/index.module.css
Normal file
17
frontend/src/pages/Login/index.module.css
Normal file
@ -0,0 +1,17 @@
|
||||
.container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
.loginCard {
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.loginCard :global(.ant-card-head-title) {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
120
frontend/src/pages/Login/index.tsx
Normal file
120
frontend/src/pages/Login/index.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
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 '../../api/user';
|
||||
import { getEnabledTenants } from '../../pages/System/Tenant/service';
|
||||
import { setToken, setUserInfo } from '../../store/userSlice';
|
||||
import type { LoginParams } from '../../types/user';
|
||||
import type { TenantDTO } from '../../pages/System/Tenant/types';
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tenants, setTenants] = useState<TenantDTO[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTenants();
|
||||
}, []);
|
||||
|
||||
const fetchTenants = async () => {
|
||||
try {
|
||||
const data = await getEnabledTenants();
|
||||
setTenants(data);
|
||||
} catch (error) {
|
||||
console.error('获取租户列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const onFinish = async (values: LoginParams & { tenantId?: string }) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { tenantId, ...loginParams } = values;
|
||||
const result = await login(loginParams);
|
||||
|
||||
// 保存租户ID到localStorage
|
||||
if (tenantId) {
|
||||
localStorage.setItem('tenantId', tenantId);
|
||||
}
|
||||
|
||||
dispatch(setToken(result.token));
|
||||
dispatch(setUserInfo({
|
||||
id: result.id,
|
||||
username: result.username,
|
||||
nickname: result.nickname,
|
||||
email: result.email,
|
||||
phone: result.phone
|
||||
}));
|
||||
|
||||
message.success('登录成功');
|
||||
navigate('/dashboard', { replace: true });
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
background: '#f0f2f5'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '400px',
|
||||
padding: '40px',
|
||||
background: '#fff',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||
}}>
|
||||
<h2 style={{ textAlign: 'center', marginBottom: '30px' }}>系统登录</h2>
|
||||
<Form
|
||||
form={form}
|
||||
name="login"
|
||||
onFinish={onFinish}
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
name="tenantId"
|
||||
rules={[{ required: true, message: '请选择租户!' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择租户"
|
||||
options={tenants.map(tenant => ({
|
||||
label: tenant.name,
|
||||
value: tenant.code
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名!' }]}
|
||||
>
|
||||
<Input placeholder="用户名" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码!' }]}
|
||||
>
|
||||
<Input.Password placeholder="密码" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block size="large" loading={loading}>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
238
frontend/src/pages/System/Department/index.tsx
Normal file
238
frontend/src/pages/System/Department/index.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Space, message, Switch, TreeSelect } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import type { DepartmentDTO } from './types';
|
||||
import { getDepartmentTree, createDepartment, updateDepartment, deleteDepartment, getNextSort } from './service.ts';
|
||||
|
||||
const DepartmentPage: React.FC = () => {
|
||||
const [departments, setDepartments] = useState<DepartmentDTO[]>([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingDepartment, setEditingDepartment] = useState<DepartmentDTO | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchDepartments = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getDepartmentTree();
|
||||
setDepartments(data || []);
|
||||
console.log("部门", data);
|
||||
} catch (error) {
|
||||
console.error('获取部门列表失败:', error);
|
||||
message.error('获取部门列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDepartments();
|
||||
}, []);
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingDepartment(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
enabled: true,
|
||||
sort: 0
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: DepartmentDTO) => {
|
||||
setEditingDepartment(record);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
parentId: record.parentId || undefined
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个部门吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteDepartment(id);
|
||||
message.success('删除成功');
|
||||
fetchDepartments();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (editingDepartment) {
|
||||
await updateDepartment(editingDepartment.id, {
|
||||
...values,
|
||||
version: editingDepartment.version
|
||||
});
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await createDepartment(values);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
fetchDepartments();
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleParentChange = async (value: number | undefined) => {
|
||||
try {
|
||||
const nextSort = await getNextSort(value);
|
||||
form.setFieldValue('sort', nextSort);
|
||||
} catch (error) {
|
||||
console.error('获取排序号失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '部门名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: '25%',
|
||||
},
|
||||
{
|
||||
title: '部门编码',
|
||||
dataIndex: 'code',
|
||||
key: 'code',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort',
|
||||
key: 'sort',
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
width: '15%',
|
||||
render: (enabled: boolean) => (
|
||||
<Switch checked={enabled} disabled />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: '25%',
|
||||
render: (_: any, record: DepartmentDTO) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDelete(record.id)}
|
||||
disabled={record.parentId === null}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const getTreeSelectData = (deps: DepartmentDTO[]): any[] => {
|
||||
return deps.map(dept => ({
|
||||
title: dept.name,
|
||||
value: dept.id,
|
||||
children: dept.children && dept.children.length > 0 ? getTreeSelectData(dept.children) : undefined,
|
||||
disabled: editingDepartment ? dept.id === editingDepartment.id : false
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
新增部门
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={departments}
|
||||
rowKey="id"
|
||||
defaultExpandAllRows
|
||||
pagination={false}
|
||||
size="middle"
|
||||
bordered
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingDepartment ? '编辑部门' : '新增部门'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={600}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="部门名称"
|
||||
rules={[{ required: true, message: '请输入部门名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入部门名称" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="部门编码"
|
||||
rules={[{ required: true, message: '请输入部门编码' }]}
|
||||
>
|
||||
<Input placeholder="请输入部门编码" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="parentId"
|
||||
label="上级部门"
|
||||
>
|
||||
<TreeSelect
|
||||
treeData={getTreeSelectData(departments)}
|
||||
placeholder="请选择上级部门"
|
||||
allowClear
|
||||
treeDefaultExpandAll
|
||||
onChange={handleParentChange}
|
||||
disabled={editingDepartment?.parentId === null}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="sort"
|
||||
label="排序"
|
||||
>
|
||||
<Input type="number" placeholder="排序号将自动生成" disabled />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="enabled"
|
||||
label="状态"
|
||||
valuePropName="checked"
|
||||
initialValue={true}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DepartmentPage;
|
||||
24
frontend/src/pages/System/Department/service.ts
Normal file
24
frontend/src/pages/System/Department/service.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import request from '@/utils/request';
|
||||
import type { DepartmentDTO } from './types';
|
||||
|
||||
export const getDepartmentTree = async (): Promise<DepartmentDTO[]> => {
|
||||
return request.get('/api/system/departments/tree');
|
||||
};
|
||||
|
||||
export const createDepartment = async (data: Partial<DepartmentDTO>): Promise<DepartmentDTO> => {
|
||||
return request.post('/api/system/departments', data);
|
||||
};
|
||||
|
||||
export const updateDepartment = async (id: number, data: Partial<DepartmentDTO>): Promise<DepartmentDTO> => {
|
||||
return request.put(`/api/system/departments/${id}`, data);
|
||||
};
|
||||
|
||||
export const deleteDepartment = async (id: number): Promise<void> => {
|
||||
return request.delete(`/api/system/departments/${id}`);
|
||||
};
|
||||
|
||||
export const getNextSort = async (parentId?: number): Promise<number> => {
|
||||
return request.get('/api/system/departments/next-sort', {
|
||||
params: { parentId }
|
||||
});
|
||||
};
|
||||
21
frontend/src/pages/System/Department/types.ts
Normal file
21
frontend/src/pages/System/Department/types.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export interface DepartmentDTO {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
parentId?: number;
|
||||
sort: number;
|
||||
enabled: boolean;
|
||||
leaderId?: number;
|
||||
leaderName?: string;
|
||||
version?: number;
|
||||
children?: DepartmentDTO[];
|
||||
}
|
||||
|
||||
export interface TreeSelectNode {
|
||||
title: string;
|
||||
value: number;
|
||||
key: number;
|
||||
disabled: boolean;
|
||||
children?: TreeSelectNode[];
|
||||
}
|
||||
@ -0,0 +1,140 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Drawer, List, Tag, Typography, Spin, Space, Pagination } from 'antd';
|
||||
import { SyncOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import * as jenkinsService from '../service';
|
||||
import type { JenkinsSyncHistoryDTO } from '../types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface SyncStatusDrawerProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
|
||||
const SyncStatusDrawer: React.FC<SyncStatusDrawerProps> = ({ visible, onClose }) => {
|
||||
const [syncHistories, setSyncHistories] = useState<JenkinsSyncHistoryDTO[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const fetchSyncHistories = async () => {
|
||||
try {
|
||||
const response = await jenkinsService.getSyncHistories();
|
||||
setSyncHistories(response || []);
|
||||
} catch (error) {
|
||||
console.error('获取同步历史失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setLoading(true);
|
||||
fetchSyncHistories().finally(() => setLoading(false));
|
||||
|
||||
const timer = setInterval(fetchSyncHistories, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const renderSyncType = (type: string) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
ALL: '全量同步',
|
||||
VIEW: '视图同步',
|
||||
JOB: '作业同步',
|
||||
BUILD: '构建同步'
|
||||
};
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
ALL: 'blue',
|
||||
VIEW: 'green',
|
||||
JOB: 'orange',
|
||||
BUILD: 'purple'
|
||||
};
|
||||
|
||||
return <Tag color={colorMap[type] || 'default'}>{typeMap[type] || type}</Tag>;
|
||||
};
|
||||
|
||||
const renderStatus = (status: string) => {
|
||||
switch (status) {
|
||||
case 'SUCCESS':
|
||||
return <Tag icon={<CheckCircleOutlined />} color="success">成功</Tag>;
|
||||
case 'FAILED':
|
||||
return <Tag icon={<CloseCircleOutlined />} color="error">失败</Tag>;
|
||||
case 'RUNNING':
|
||||
return <Tag icon={<SyncOutlined spin />} color="processing">同步中</Tag>;
|
||||
default:
|
||||
return <Tag>未知状态</Tag>;
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentPageData = () => {
|
||||
const startIndex = (currentPage - 1) * PAGE_SIZE;
|
||||
return syncHistories.slice(startIndex, startIndex + PAGE_SIZE);
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="历史同步记录"
|
||||
placement="right"
|
||||
onClose={onClose}
|
||||
open={visible}
|
||||
width={600}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{syncHistories.length === 0 ? (
|
||||
<Text>暂无同步记录</Text>
|
||||
) : (
|
||||
<>
|
||||
<List
|
||||
dataSource={getCurrentPageData()}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Space>
|
||||
{item.jenkinsName}
|
||||
{renderStatus(item.status)}
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<>
|
||||
<div>同步类型: {renderSyncType(item.syncType)}</div>
|
||||
<div>开始时间: {dayjs(item.startTime).format('YYYY-MM-DD HH:mm:ss')}</div>
|
||||
{item.endTime && (
|
||||
<div>结束时间: {dayjs(item.endTime).format('YYYY-MM-DD HH:mm:ss')}</div>
|
||||
)}
|
||||
{item.errorMessage && (
|
||||
<div style={{ color: '#ff4d4f' }}>错误信息: {item.errorMessage}</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
padding: '10px 0',
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
textAlign: 'right'
|
||||
}}>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
total={syncHistories.length}
|
||||
pageSize={PAGE_SIZE}
|
||||
onChange={setCurrentPage}
|
||||
size="small"
|
||||
showSizeChanger={false}
|
||||
showTotal={(total) => `共 ${total} 条`}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Spin>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SyncStatusDrawer;
|
||||
644
frontend/src/pages/System/Jenkins/index.tsx
Normal file
644
frontend/src/pages/System/Jenkins/index.tsx
Normal file
@ -0,0 +1,644 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Space, message, Card, Row, Col, Collapse, Tabs, Badge, Tooltip, Radio } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined, SyncOutlined,
|
||||
EyeOutlined, CodeOutlined, BuildOutlined, HistoryOutlined, FolderOutlined } from '@ant-design/icons';
|
||||
import type { JenkinsDTO, JenkinsQuery, JenkinsViewDTO, JenkinsJobDTO, JenkinsBuildDTO } from './types';
|
||||
import * as jenkinsService from './service';
|
||||
import SyncStatusDrawer from './components/SyncStatusDrawer';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Panel } = Collapse;
|
||||
const { TabPane } = Tabs;
|
||||
|
||||
interface JenkinsDetailDTO extends JenkinsDTO {
|
||||
views?: {
|
||||
viewName: string;
|
||||
viewUrl: string;
|
||||
description?: string;
|
||||
}[];
|
||||
jobs?: {
|
||||
jobName: string;
|
||||
jobUrl: string;
|
||||
lastBuildStatus?: string;
|
||||
lastBuildTime?: string;
|
||||
}[];
|
||||
builds?: {
|
||||
buildNumber: number;
|
||||
buildStatus: string;
|
||||
startTime: string;
|
||||
duration: number;
|
||||
triggerCause?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const JenkinsPage: React.FC = () => {
|
||||
const [jenkinsList, setJenkinsList] = useState<JenkinsDetailDTO[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncLoading, setSyncLoading] = useState(false);
|
||||
const [selectedJenkins, setSelectedJenkins] = useState<number>();
|
||||
const [syncStatusVisible, setSyncStatusVisible] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingJenkins, setEditingJenkins] = useState<JenkinsDetailDTO | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [searchForm] = Form.useForm();
|
||||
const [viewLoading, setViewLoading] = useState<Record<number, boolean>>({});
|
||||
const [viewData, setViewData] = useState<Record<number, JenkinsViewDTO[]>>({});
|
||||
const [jobLoading, setJobLoading] = useState<Record<number, boolean>>({});
|
||||
const [jobData, setJobData] = useState<Record<number, JenkinsJobDTO[]>>({});
|
||||
const [buildLoading, setBuildLoading] = useState<Record<string, boolean>>({});
|
||||
const [buildData, setBuildData] = useState<Record<string, JenkinsBuildDTO[]>>({});
|
||||
const [viewPagination, setViewPagination] = useState<Record<number, { current: number; pageSize: number }>>({});
|
||||
const [jobPagination, setJobPagination] = useState<Record<number, { current: number; pageSize: number }>>({});
|
||||
const [buildPagination, setBuildPagination] = useState<Record<string, { current: number; pageSize: number }>>({});
|
||||
const [selectedRow, setSelectedRow] = useState<JenkinsDTO>();
|
||||
const [selectedView, setSelectedView] = useState<string>();
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
const fetchJenkinsList = async (params?: JenkinsQuery) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await jenkinsService.getJenkinsList(params);
|
||||
setJenkinsList(response || []);
|
||||
} catch (error) {
|
||||
console.error('获取Jenkins列表失败:', error);
|
||||
message.error('获取Jenkins列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchJenkinsList();
|
||||
}, []);
|
||||
|
||||
const handleSearch = async () => {
|
||||
const values = await searchForm.validateFields();
|
||||
fetchJenkinsList(values);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
searchForm.resetFields();
|
||||
fetchJenkinsList();
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingJenkins(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
sort: 0
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: JenkinsDetailDTO) => {
|
||||
setEditingJenkins(record);
|
||||
form.setFieldsValue(record);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个Jenkins配置吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await jenkinsService.deleteJenkins(id);
|
||||
message.success('删除成功');
|
||||
fetchJenkinsList();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (editingJenkins) {
|
||||
await jenkinsService.updateJenkins(editingJenkins.id, values);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await jenkinsService.createJenkins(values);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
fetchJenkinsList();
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
try {
|
||||
const values = await form.validateFields(['url', 'username', 'password']);
|
||||
await jenkinsService.testConnection(values);
|
||||
message.success('连接测试成功');
|
||||
} catch (error) {
|
||||
message.error('连接测试失败,请检查配置');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async (id: number, type: 'ALL' | 'VIEW' | 'JOB' | 'BUILD') => {
|
||||
try {
|
||||
setSelectedJenkins(id);
|
||||
setSyncLoading(true);
|
||||
|
||||
if (!id) {
|
||||
message.error('Jenkins ID 不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
await jenkinsService.sync(id, type);
|
||||
message.success('同步任务已开始');
|
||||
setSyncStatusVisible(true);
|
||||
} catch (error: any) {
|
||||
if (error.response?.data?.message === '该Jenkins已有同步任务正在运行中') {
|
||||
message.warning('该Jenkins已有同步任务正在运行中');
|
||||
} else {
|
||||
message.error('同步失败');
|
||||
}
|
||||
} finally {
|
||||
setSyncLoading(false);
|
||||
setSelectedJenkins(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
setSyncStatusVisible(false);
|
||||
};
|
||||
|
||||
const fetchViewData = async (jenkinsId: number) => {
|
||||
if (viewData[jenkinsId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setViewLoading(prev => ({ ...prev, [jenkinsId]: true }));
|
||||
const response = await jenkinsService.getJenkinsViews(jenkinsId);
|
||||
setViewData(prev => ({ ...prev, [jenkinsId]: response || [] }));
|
||||
} catch (error) {
|
||||
console.error('获取视图列表失败:', error);
|
||||
message.error('获取视图列表失败');
|
||||
} finally {
|
||||
setViewLoading(prev => ({ ...prev, [jenkinsId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchJobData = async (jenkinsId: number) => {
|
||||
if (jobData[jenkinsId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setJobLoading(prev => ({ ...prev, [jenkinsId]: true }));
|
||||
const response = await jenkinsService.getJenkinsJobs(jenkinsId);
|
||||
setJobData(prev => ({ ...prev, [jenkinsId]: response || [] }));
|
||||
} catch (error) {
|
||||
console.error('获取作业列表失败:', error);
|
||||
message.error('获取作业列表失败');
|
||||
} finally {
|
||||
setJobLoading(prev => ({ ...prev, [jenkinsId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBuildData = async (jenkinsId: number, jobId: number) => {
|
||||
const key = `${jenkinsId}-${jobId}`;
|
||||
if (buildData[key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setBuildLoading(prev => ({ ...prev, [key]: true }));
|
||||
const response = await jenkinsService.getJenkinsBuilds(jenkinsId, jobId);
|
||||
setBuildData(prev => ({ ...prev, [key]: response || [] }));
|
||||
} catch (error) {
|
||||
console.error('获取构建历史失败:', error);
|
||||
message.error('获取构建历史失败');
|
||||
} finally {
|
||||
setBuildLoading(prev => ({ ...prev, [key]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Jenkins名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: '12%',
|
||||
},
|
||||
{
|
||||
title: 'Jenkins地址',
|
||||
dataIndex: 'url',
|
||||
key: 'url',
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
width: '8%',
|
||||
},
|
||||
{
|
||||
title: '最后全量同步时间',
|
||||
key: 'lastAllSyncTime',
|
||||
width: '15%',
|
||||
render: (_, record: JenkinsDTO) => (
|
||||
record.lastAllSyncTime ? dayjs(record.lastAllSyncTime).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '最后视图同步时间',
|
||||
key: 'lastViewSyncTime',
|
||||
width: '15%',
|
||||
render: (_, record: JenkinsDTO) => (
|
||||
record.lastViewSyncTime ? dayjs(record.lastViewSyncTime).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '最后作业同步时间',
|
||||
key: 'lastJobSyncTime',
|
||||
width: '15%',
|
||||
render: (_, record: JenkinsDTO) => (
|
||||
record.lastJobSyncTime ? dayjs(record.lastJobSyncTime).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '最后构建同步时间',
|
||||
key: 'lastBuildSyncTime',
|
||||
width: '12%',
|
||||
render: (_, record: JenkinsDTO) => (
|
||||
record.lastBuildSyncTime ? dayjs(record.lastBuildSyncTime).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: '20%',
|
||||
render: (_, record: JenkinsDTO) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<SyncOutlined />}
|
||||
loading={syncLoading && selectedJenkins === record.id}
|
||||
onClick={() => handleSync(record.id, 'ALL')}
|
||||
>
|
||||
全量同步
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDelete(record.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleExpand = async (expanded: boolean, record: JenkinsDetailDTO) => {
|
||||
if (expanded) {
|
||||
await Promise.all([
|
||||
fetchViewData(record.id),
|
||||
fetchJobData(record.id)
|
||||
]);
|
||||
|
||||
const jobs = jobData[record.id] || [];
|
||||
await Promise.all(
|
||||
jobs.map(job => fetchBuildData(record.id, job.id))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const expandedRowRender = (record: JenkinsDetailDTO) => {
|
||||
return (
|
||||
<div style={{ width: '100%', padding: '16px 0' }}>
|
||||
{/* 视图选择器 */}
|
||||
<Radio.Group
|
||||
value={selectedView}
|
||||
onChange={(e) => {
|
||||
setSelectedView(e.target.value);
|
||||
fetchJobData(record.id);
|
||||
}}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Radio.Button value={undefined}>全部视图</Radio.Button>
|
||||
{viewData[record.id]?.map(view => (
|
||||
<Radio.Button key={view.id} value={view.viewName}>
|
||||
<Space>
|
||||
<FolderOutlined />
|
||||
{view.viewName}
|
||||
</Space>
|
||||
</Radio.Button>
|
||||
))}
|
||||
</Radio.Group>
|
||||
|
||||
{/* 作业和构建历史 */}
|
||||
<Tabs
|
||||
tabPosition="left"
|
||||
style={{
|
||||
height: 600,
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: '8px',
|
||||
padding: '16px'
|
||||
}}
|
||||
items={
|
||||
jobData[record.id]
|
||||
?.filter(job => !selectedView || job.viewName === selectedView)
|
||||
?.map(job => ({
|
||||
label: (
|
||||
<Tooltip title={job.description} placement="right">
|
||||
<Space>
|
||||
<CodeOutlined />
|
||||
{job.jobName}
|
||||
{job.lastBuildStatus && (
|
||||
<Badge
|
||||
status={job.lastBuildStatus === 'SUCCESS' ? 'success' : job.lastBuildStatus === 'FAILURE' ? 'error' : 'default'}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Tooltip>
|
||||
),
|
||||
key: job.id.toString(),
|
||||
children: (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space>
|
||||
<span>最后构建状态:</span>
|
||||
{job.lastBuildStatus && (
|
||||
<Badge
|
||||
status={job.lastBuildStatus === 'SUCCESS' ? 'success' : job.lastBuildStatus === 'FAILURE' ? 'error' : 'default'}
|
||||
text={job.lastBuildStatus}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<SyncOutlined />}
|
||||
onClick={() => fetchBuildData(record.id, job.id)}
|
||||
loading={buildLoading[`${record.id}-${job.id}`]}
|
||||
>
|
||||
刷新构建历史
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
size="small"
|
||||
loading={buildLoading[`${record.id}-${job.id}`]}
|
||||
columns={[
|
||||
{
|
||||
title: '构建编号',
|
||||
dataIndex: 'buildNumber',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '构建状态',
|
||||
dataIndex: 'buildStatus',
|
||||
width: 120,
|
||||
render: (status: string) => (
|
||||
<Badge
|
||||
status={status === 'SUCCESS' ? 'success' : status === 'FAILURE' ? 'error' : 'default'}
|
||||
text={status || '-'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '开始时间',
|
||||
dataIndex: 'startTime',
|
||||
width: 180,
|
||||
render: (time: string) => time ? dayjs(time).format('YYYY-MM-DD HH:mm:ss') : '-',
|
||||
},
|
||||
{
|
||||
title: '持续时间',
|
||||
dataIndex: 'duration',
|
||||
width: 100,
|
||||
render: (duration: number) => {
|
||||
const seconds = Math.floor(duration / 1000);
|
||||
if (seconds < 60) return `${seconds}秒`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}分${remainingSeconds}秒`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '触发原因',
|
||||
dataIndex: 'triggerCause',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
fixed: 'right',
|
||||
render: (_, record: JenkinsBuildDTO) => (
|
||||
<a href={record.buildUrl} target="_blank" rel="noopener noreferrer">
|
||||
查看详情
|
||||
</a>
|
||||
),
|
||||
}
|
||||
]}
|
||||
dataSource={buildData[`${record.id}-${job.id}`] || []}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
size: 'small',
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<Card style={{ marginBottom: '24px' }}>
|
||||
<Form
|
||||
form={searchForm}
|
||||
layout="inline"
|
||||
onFinish={handleSearch}
|
||||
>
|
||||
<Row gutter={24} style={{ width: '100%' }}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="name" label="Jenkins名称">
|
||||
<Input placeholder="请输入Jenkins名称" allowClear />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="url" label="Jenkins地址">
|
||||
<Input placeholder="请输入Jenkins地址" allowClear />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Row justify="space-between" style={{ marginBottom: 16 }}>
|
||||
<Col>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
新增Jenkins
|
||||
</Button>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SyncOutlined />}
|
||||
onClick={() => selectedRow && handleSync(selectedRow.id, 'ALL')}
|
||||
disabled={!selectedRow}
|
||||
>
|
||||
全量同步
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => selectedRow && handleSync(selectedRow.id, 'VIEW')}
|
||||
disabled={!selectedRow}
|
||||
>
|
||||
视图同步
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CodeOutlined />}
|
||||
onClick={() => selectedRow && handleSync(selectedRow.id, 'JOB')}
|
||||
disabled={!selectedRow}
|
||||
>
|
||||
作业同步
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<BuildOutlined />}
|
||||
onClick={() => selectedRow && handleSync(selectedRow.id, 'BUILD')}
|
||||
disabled={!selectedRow}
|
||||
>
|
||||
构建同步
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<HistoryOutlined />}
|
||||
onClick={() => setSyncStatusVisible(true)}
|
||||
>
|
||||
查看历史同步记录
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={jenkinsList}
|
||||
rowKey="id"
|
||||
expandable={{
|
||||
expandedRowRender,
|
||||
expandRowByClick: true,
|
||||
onExpand: handleExpand
|
||||
}}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
bordered
|
||||
rowClassName={(record) => record.id === selectedRow?.id ? 'ant-table-row-selected' : ''}
|
||||
onRow={(record) => ({
|
||||
onClick: () => setSelectedRow(record)
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingJenkins ? '编辑Jenkins' : '新增Jenkins'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={600}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button key="test" onClick={handleTestConnection}>
|
||||
测试连接
|
||||
</Button>,
|
||||
<Button key="cancel" onClick={() => setModalVisible(false)}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button key="submit" type="primary" onClick={handleSubmit}>
|
||||
确定
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Jenkins名称"
|
||||
rules={[{ required: true, message: '请输入Jenkins名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入Jenkins名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="url"
|
||||
label="Jenkins地址"
|
||||
rules={[{ required: true, message: '请输入Jenkins地址' }]}
|
||||
>
|
||||
<Input placeholder="请输入Jenkins地址" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="用户名"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input placeholder="请输入用户名" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码/Token"
|
||||
rules={[{ required: true, message: '请输入密码或Token' }]}
|
||||
>
|
||||
<Input.Password placeholder="请输入密码或Token" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="remark"
|
||||
label="备"
|
||||
>
|
||||
<Input.TextArea rows={2} placeholder="请输入备注" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<SyncStatusDrawer
|
||||
visible={syncStatusVisible}
|
||||
onClose={() => setSyncStatusVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JenkinsPage;
|
||||
47
frontend/src/pages/System/Jenkins/service.ts
Normal file
47
frontend/src/pages/System/Jenkins/service.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import request from '@/utils/request';
|
||||
import type { BaseResponse } from '@/types/api';
|
||||
import type { JenkinsDTO, JenkinsQuery, TestConnectionDTO, JenkinsSyncHistoryDTO, JenkinsViewDTO, JenkinsJobDTO, JenkinsBuildDTO } from './types';
|
||||
|
||||
export const getJenkinsList = async (params?: JenkinsQuery) => {
|
||||
return await request.get<BaseResponse<JenkinsDTO[]>>('/api/system/jenkins', { params });
|
||||
};
|
||||
|
||||
export const createJenkins = async (data: Partial<JenkinsDTO>): ApiResponse<JenkinsDTO> => {
|
||||
return await request.post('/api/system/jenkins', data);
|
||||
};
|
||||
|
||||
export const updateJenkins = async (id: number, data: Partial<JenkinsDTO>): ApiResponse<JenkinsDTO> => {
|
||||
return await request.put(`/api/system/jenkins/${id}`, data);
|
||||
};
|
||||
|
||||
export const deleteJenkins = async (id: number): ApiResponse<void> => {
|
||||
return await request.delete(`/api/system/jenkins/${id}`);
|
||||
};
|
||||
|
||||
export const testConnection = async (data: TestConnectionDTO): ApiResponse<boolean> => {
|
||||
return await request.post('/api/system/jenkins/test-connection', data);
|
||||
};
|
||||
|
||||
export const syncAll = async (id: number): ApiResponse<number> => {
|
||||
return await request.post(`/api/system/jenkins/${id}/sync/all`);
|
||||
};
|
||||
|
||||
export const getSyncHistories = async () => {
|
||||
return await request.get<BaseResponse<JenkinsSyncHistoryDTO[]>>('/api/system/jenkins/sync/histories');
|
||||
};
|
||||
|
||||
export const sync = async (id: number, type: 'ALL' | 'VIEW' | 'JOB' | 'BUILD') => {
|
||||
return await request.post<BaseResponse<number>>(`/api/system/jenkins/${id}/sync/${type.toLowerCase()}`);
|
||||
};
|
||||
|
||||
export const getJenkinsViews = async (jenkinsId: number) => {
|
||||
return await request.get<BaseResponse<JenkinsViewDTO[]>>(`/api/system/jenkins/${jenkinsId}/views`);
|
||||
};
|
||||
|
||||
export const getJenkinsJobs = async (jenkinsId: number) => {
|
||||
return await request.get<BaseResponse<JenkinsJobDTO[]>>(`/api/system/jenkins/${jenkinsId}/jobs`);
|
||||
};
|
||||
|
||||
export const getJenkinsBuilds = async (jenkinsId: number, jobId: number) => {
|
||||
return await request.get<BaseResponse<JenkinsBuildDTO[]>>(`/api/system/jenkins/${jenkinsId}/jobs/${jobId}/builds`);
|
||||
};
|
||||
83
frontend/src/pages/System/Jenkins/types.ts
Normal file
83
frontend/src/pages/System/Jenkins/types.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import type { BaseResponse } from '@/types/api';
|
||||
|
||||
export interface JenkinsDTO {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
username: string;
|
||||
password?: string;
|
||||
sort: number;
|
||||
remark?: string;
|
||||
lastAllSyncTime?: string;
|
||||
lastViewSyncTime?: string;
|
||||
lastJobSyncTime?: string;
|
||||
lastBuildSyncTime?: string;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
version?: number;
|
||||
createBy?: string;
|
||||
updateBy?: string;
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
export interface JenkinsQuery {
|
||||
name?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface TestConnectionDTO {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface JenkinsSyncHistoryDTO {
|
||||
id: number;
|
||||
jenkinsId: number;
|
||||
jenkinsName: string;
|
||||
syncType: 'ALL' | 'VIEW' | 'JOB' | 'BUILD';
|
||||
status: 'SUCCESS' | 'FAILED' | 'RUNNING';
|
||||
startTime: string;
|
||||
endTime?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface JenkinsViewDTO {
|
||||
id: number;
|
||||
jenkinsId: number;
|
||||
viewName: string;
|
||||
viewUrl: string;
|
||||
description?: string;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
export interface JenkinsJobDTO {
|
||||
id: number;
|
||||
jenkinsId: number;
|
||||
jobName: string;
|
||||
jobUrl: string;
|
||||
description?: string;
|
||||
buildable: boolean;
|
||||
lastBuildNumber?: number;
|
||||
lastBuildTime?: string;
|
||||
lastBuildStatus?: string;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
export interface JenkinsBuildDTO {
|
||||
id: number;
|
||||
jenkinsId: number;
|
||||
jobId: number;
|
||||
buildNumber: number;
|
||||
buildUrl: string;
|
||||
buildStatus: string;
|
||||
startTime: string;
|
||||
duration: number;
|
||||
triggerCause?: string;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
export type ApiResponse<T> = Promise<BaseResponse<T>>;
|
||||
359
frontend/src/pages/System/Menu/index.tsx
Normal file
359
frontend/src/pages/System/Menu/index.tsx
Normal file
@ -0,0 +1,359 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Space, message, Switch, Select, TreeSelect, Tooltip, InputNumber } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import * as AntdIcons from '@ant-design/icons';
|
||||
import type { MenuDTO } from './types';
|
||||
import { MenuTypeEnum, MenuTypeNames } from './types';
|
||||
import { getMenuTree, createMenu, updateMenu, deleteMenu, getMenuTreeWithoutButtons } from './service';
|
||||
import IconSelect from '@/components/IconSelect';
|
||||
|
||||
const MenuPage: React.FC = () => {
|
||||
const [menus, setMenus] = useState<MenuDTO[]>([]);
|
||||
const [menuTreeData, setMenuTreeData] = useState<MenuDTO[]>([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [iconSelectVisible, setIconSelectVisible] = useState(false);
|
||||
const [editingMenu, setEditingMenu] = useState<MenuDTO | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [menuList, treeData] = await Promise.all([
|
||||
getMenuTree(),
|
||||
getMenuTreeWithoutButtons()
|
||||
]);
|
||||
setMenus(menuList);
|
||||
setMenuTreeData(treeData);
|
||||
} catch (error) {
|
||||
console.error('获取菜单列表失败:', error);
|
||||
message.error('获取菜单列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const getIcon = (iconName: string | undefined) => {
|
||||
if (!iconName) return null;
|
||||
const iconKey = `${iconName.charAt(0).toUpperCase() + iconName.slice(1)}Outlined`;
|
||||
const IconComponent = (AntdIcons as any)[iconKey];
|
||||
return IconComponent ? <IconComponent /> : null;
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingMenu(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
type: MenuTypeEnum.MENU,
|
||||
sort: 0,
|
||||
hidden: false,
|
||||
enabled: true
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: MenuDTO) => {
|
||||
setEditingMenu(record);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
parentId: record.parentId === 0 ? undefined : record.parentId
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个菜单吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteMenu(id);
|
||||
message.success('删除成功');
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const submitData = {
|
||||
...values,
|
||||
parentId: values.parentId || 0
|
||||
};
|
||||
|
||||
if (editingMenu) {
|
||||
await updateMenu(editingMenu.id, submitData);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await createMenu(submitData);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const getTreeSelectData = (menus: MenuDTO[]) => {
|
||||
const treeData = menus.map(menu => ({
|
||||
title: menu.name,
|
||||
value: menu.id,
|
||||
children: menu.children?.map(child => ({
|
||||
title: child.name,
|
||||
value: child.id,
|
||||
disabled: editingMenu?.id === child.id
|
||||
}))
|
||||
}));
|
||||
|
||||
return treeData;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '菜单名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: '200px',
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: '100px',
|
||||
render: (type: MenuTypeEnum) => MenuTypeNames[type],
|
||||
},
|
||||
{
|
||||
title: '权限标识',
|
||||
dataIndex: 'permission',
|
||||
key: 'permission',
|
||||
width: '150px',
|
||||
},
|
||||
{
|
||||
title: '路由地址',
|
||||
dataIndex: 'path',
|
||||
key: 'path',
|
||||
width: '150px',
|
||||
},
|
||||
{
|
||||
title: '组件路径',
|
||||
dataIndex: 'component',
|
||||
key: 'component',
|
||||
width: '150px',
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort',
|
||||
key: 'sort',
|
||||
width: '80px',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
width: '80px',
|
||||
render: (enabled: boolean) => (
|
||||
<Switch checked={enabled} disabled />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: '150px',
|
||||
render: (_: any, record: MenuDTO) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDelete(record.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
新增菜单
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={menus}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="middle"
|
||||
bordered
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingMenu ? '编辑菜单' : '新增菜单'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={600}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ type: MenuTypeEnum.MENU, sort: 0 }}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="菜单名称"
|
||||
rules={[{ required: true, message: '请输入菜单名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入菜单名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="type"
|
||||
label="菜单类型"
|
||||
rules={[{ required: true, message: '请选择菜单类型' }]}
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value={MenuTypeEnum.DIRECTORY}>目录</Select.Option>
|
||||
<Select.Option value={MenuTypeEnum.MENU}>菜单</Select.Option>
|
||||
<Select.Option value={MenuTypeEnum.BUTTON}>按钮</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="parentId"
|
||||
label={
|
||||
<span>
|
||||
上级菜单
|
||||
<Tooltip title="不选择则为顶级菜单">
|
||||
<QuestionCircleOutlined style={{ marginLeft: 4 }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<TreeSelect
|
||||
treeData={getTreeSelectData(menuTreeData)}
|
||||
placeholder="不选择则为顶级菜单"
|
||||
allowClear
|
||||
treeDefaultExpandAll
|
||||
showSearch
|
||||
treeNodeFilterProp="title"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{form.getFieldValue('type') !== MenuTypeEnum.BUTTON && (
|
||||
<>
|
||||
<Form.Item
|
||||
name="path"
|
||||
label={
|
||||
<span>
|
||||
路由地址
|
||||
<Tooltip title="目录示例: /system 菜单示例: /system/user">
|
||||
<QuestionCircleOutlined style={{ marginLeft: 4 }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
rules={[{ required: true, message: '请输入路由地址' }]}
|
||||
>
|
||||
<Input placeholder="请输入路由地址" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="component"
|
||||
label={
|
||||
<span>
|
||||
组件路径
|
||||
<Tooltip title="目录固定值: LAYOUT 菜单示例: /System/User/index">
|
||||
<QuestionCircleOutlined style={{ marginLeft: 4 }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
rules={[{ required: true, message: '请输入组件路径' }]}
|
||||
>
|
||||
<Input placeholder="请输入组件路径" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="icon"
|
||||
label="图标"
|
||||
>
|
||||
<Input
|
||||
placeholder="请选择图标"
|
||||
readOnly
|
||||
onClick={() => setIconSelectVisible(true)}
|
||||
suffix={form.getFieldValue('icon') && getIcon(form.getFieldValue('icon'))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{form.getFieldValue('type') === MenuTypeEnum.BUTTON && (
|
||||
<Form.Item
|
||||
name="permission"
|
||||
label={
|
||||
<span>
|
||||
权限标识
|
||||
<Tooltip title="示例: system:user:add">
|
||||
<QuestionCircleOutlined style={{ marginLeft: 4 }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
rules={[{ required: true, message: '请输入权限标识' }]}
|
||||
>
|
||||
<Input placeholder="请输入权限标识" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
name="sort"
|
||||
label="显示排序"
|
||||
rules={[{ required: true, message: '请输入显示排序' }]}
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} min={0} placeholder="请输入显示排序" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="hidden"
|
||||
label="是否隐藏"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<IconSelect
|
||||
visible={iconSelectVisible}
|
||||
onCancel={() => setIconSelectVisible(false)}
|
||||
value={form.getFieldValue('icon')}
|
||||
onChange={value => {
|
||||
form.setFieldValue('icon', value);
|
||||
setIconSelectVisible(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuPage;
|
||||
22
frontend/src/pages/System/Menu/service.ts
Normal file
22
frontend/src/pages/System/Menu/service.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import request from '@/utils/request';
|
||||
import type { MenuDTO, MenuQuery } from './types';
|
||||
|
||||
export const getMenuTree = async (): Promise<MenuDTO[]> => {
|
||||
return request.get('/api/system/menus/tree');
|
||||
};
|
||||
|
||||
export const getMenuTreeWithoutButtons = async (): Promise<MenuDTO[]> => {
|
||||
return request.get('/api/system/menus/tree/menu');
|
||||
};
|
||||
|
||||
export const createMenu = async (data: Partial<MenuDTO>): Promise<MenuDTO> => {
|
||||
return request.post('/api/system/menus', data);
|
||||
};
|
||||
|
||||
export const updateMenu = async (id: number, data: Partial<MenuDTO>): Promise<MenuDTO> => {
|
||||
return request.put(`/api/system/menus/${id}`, data);
|
||||
};
|
||||
|
||||
export const deleteMenu = async (id: number): Promise<void> => {
|
||||
return request.delete(`/api/system/menus/${id}`);
|
||||
};
|
||||
40
frontend/src/pages/System/Menu/types.ts
Normal file
40
frontend/src/pages/System/Menu/types.ts
Normal file
@ -0,0 +1,40 @@
|
||||
export interface MenuDTO {
|
||||
id: number;
|
||||
name: string;
|
||||
permission?: string;
|
||||
path?: string;
|
||||
component?: string;
|
||||
type: MenuTypeEnum;
|
||||
icon?: string;
|
||||
parentId?: number;
|
||||
sort: number;
|
||||
hidden: boolean;
|
||||
enabled: boolean;
|
||||
children?: MenuDTO[];
|
||||
}
|
||||
|
||||
export interface MenuQuery {
|
||||
name?: string;
|
||||
permission?: string;
|
||||
type?: MenuTypeEnum;
|
||||
parentId?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export enum MenuTypeEnum {
|
||||
DIRECTORY = 0,
|
||||
MENU = 1,
|
||||
BUTTON = 2
|
||||
}
|
||||
|
||||
export const MenuTypeNames: Record<MenuTypeEnum, string> = {
|
||||
[MenuTypeEnum.DIRECTORY]: '目录',
|
||||
[MenuTypeEnum.MENU]: '菜单',
|
||||
[MenuTypeEnum.BUTTON]: '按钮'
|
||||
};
|
||||
|
||||
export const MenuTypeOptions = [
|
||||
{ label: '目录', value: MenuTypeEnum.DIRECTORY },
|
||||
{ label: '菜单', value: MenuTypeEnum.MENU },
|
||||
{ label: '按钮', value: MenuTypeEnum.BUTTON }
|
||||
];
|
||||
@ -0,0 +1,194 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Drawer, List, Tag, Typography, Spin, Space, Pagination } from 'antd';
|
||||
import { getRunningSyncs, getSyncStatus } from '../service';
|
||||
import { RunningSyncDTO, RepositorySyncStatusDTO } from '../types';
|
||||
import dayjs from 'dayjs';
|
||||
import { SyncOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface SyncStatusDrawerProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
autoRefresh?: boolean;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 5; // 每页显示的数量
|
||||
|
||||
const SyncStatusDrawer: React.FC<SyncStatusDrawerProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
autoRefresh = false
|
||||
}) => {
|
||||
const [runningSyncs, setRunningSyncs] = useState<RunningSyncDTO[]>([]);
|
||||
const [syncStatuses, setSyncStatuses] = useState<Record<number, RepositorySyncStatusDTO>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const fetchRunningSyncs = async () => {
|
||||
try {
|
||||
const response = await getRunningSyncs();
|
||||
setRunningSyncs(response || []);
|
||||
|
||||
// 只为正在运行的任务获取最新状态
|
||||
response?.forEach(async (sync) => {
|
||||
if (sync.status === 'RUNNING') {
|
||||
const status = await getSyncStatus(sync.historyId);
|
||||
setSyncStatuses(prev => ({
|
||||
...prev,
|
||||
[sync.historyId]: status
|
||||
}));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取同步任务失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 只在抽屉可见时才获取数据
|
||||
if (visible) {
|
||||
setLoading(true);
|
||||
fetchRunningSyncs().finally(() => setLoading(false));
|
||||
|
||||
// 只在抽屉可见时启动定时刷新
|
||||
const timer = setInterval(fetchRunningSyncs, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
}, [visible]); // 移除 autoRefresh 依赖
|
||||
|
||||
// 计算当前页的数据
|
||||
const getCurrentPageData = () => {
|
||||
const startIndex = (currentPage - 1) * PAGE_SIZE;
|
||||
return runningSyncs.slice(startIndex, startIndex + PAGE_SIZE);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
const renderSyncType = (type: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
GROUP: 'blue',
|
||||
PROJECT: 'green',
|
||||
BRANCH: 'orange',
|
||||
};
|
||||
return <Tag color={colorMap[type] || 'default'}>{type}</Tag>;
|
||||
};
|
||||
|
||||
const renderStatus = (item: RunningSyncDTO) => {
|
||||
// 如果状态是 RUNNING,需要检查是否真正在运行
|
||||
if (item.status === 'RUNNING') {
|
||||
return item.actuallyRunning ? (
|
||||
<Tag icon={<SyncOutlined spin />} color="processing">同步中</Tag>
|
||||
) : (
|
||||
<Tag icon={<CloseCircleOutlined />} color="error">异常终止</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
// 其他状态正常显示
|
||||
switch (item.status) {
|
||||
case 'SUCCESS':
|
||||
return <Tag icon={<CheckCircleOutlined />} color="success">成功</Tag>;
|
||||
case 'FAILED':
|
||||
return <Tag icon={<CloseCircleOutlined />} color="error">失败</Tag>;
|
||||
default:
|
||||
return <Tag>未知状态</Tag>;
|
||||
}
|
||||
};
|
||||
|
||||
const renderErrorMessage = (item: RunningSyncDTO) => {
|
||||
// 优先使用实时错误信息,如果没有则使用历史记录中的错误信息
|
||||
const errorMessage = syncStatuses[item.historyId]?.errorMessage || item.errorMessage;
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<div style={{ color: '#ff4d4f', fontSize: '12px', marginTop: '4px' }}>
|
||||
错误信息: {errorMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderTip = () => {
|
||||
if (!visible && autoRefresh) {
|
||||
return (
|
||||
<div style={{ marginTop: 8, color: '#666' }}>
|
||||
同步任务正在后台运行,您可以随时打开此抽屉查看进度
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
title="历史同步记录"
|
||||
placement="right"
|
||||
onClose={onClose}
|
||||
open={visible}
|
||||
width={400}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%'
|
||||
}}>
|
||||
<Spin spinning={loading}>
|
||||
{runningSyncs.length === 0 ? (
|
||||
<Text>当前没有正在进行的同步任务</Text>
|
||||
) : (
|
||||
<>
|
||||
<List
|
||||
dataSource={getCurrentPageData()}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Space>
|
||||
{item.repositoryName}
|
||||
{renderStatus(item)}
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<>
|
||||
<div>同步类型: {renderSyncType(item.syncType)}</div>
|
||||
<div>开始时间: {dayjs(item.startTime).format('YYYY-MM-DD HH:mm:ss')}</div>
|
||||
{syncStatuses[item.historyId]?.endTime && (
|
||||
<div>结束时间: {dayjs(syncStatuses[item.historyId].endTime).format('YYYY-MM-DD HH:mm:ss')}</div>
|
||||
)}
|
||||
{renderErrorMessage(item)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
padding: '10px 0',
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
textAlign: 'right'
|
||||
}}>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
total={runningSyncs.length}
|
||||
pageSize={PAGE_SIZE}
|
||||
onChange={handlePageChange}
|
||||
size="small"
|
||||
showSizeChanger={false}
|
||||
showTotal={(total) => `共 ${total} 条`}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{renderTip()}
|
||||
</Spin>
|
||||
</div>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SyncStatusDrawer;
|
||||
344
frontend/src/pages/System/Repository/index.tsx
Normal file
344
frontend/src/pages/System/Repository/index.tsx
Normal file
@ -0,0 +1,344 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Space, message, Switch, Card, Row, Col, Tag } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SyncOutlined, SearchOutlined, ReloadOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
import type { RepositoryDTO, RepositoryQuery } from './types';
|
||||
import * as repositoryService from './service';
|
||||
import SyncStatusDrawer from './components/SyncStatusDrawer';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const RepositoryPage: React.FC = () => {
|
||||
const [repositories, setRepositories] = useState<RepositoryDTO[]>([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingRepository, setEditingRepository] = useState<RepositoryDTO | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncLoading, setSyncLoading] = useState(false);
|
||||
const [selectedRepository, setSelectedRepository] = useState<number>();
|
||||
const [form] = Form.useForm();
|
||||
const [searchForm] = Form.useForm();
|
||||
const [syncStatusVisible, setSyncStatusVisible] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
|
||||
const fetchRepositories = async (params?: RepositoryQuery) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await repositoryService.getRepositories(params);
|
||||
setRepositories(response || []);
|
||||
} catch (error) {
|
||||
console.error('获取代码库列表失败:', error);
|
||||
message.error('获取代码库列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRepositories();
|
||||
}, []);
|
||||
|
||||
const handleSearch = async () => {
|
||||
const values = await searchForm.validateFields();
|
||||
fetchRepositories(values);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
searchForm.resetFields();
|
||||
fetchRepositories();
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingRepository(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
enabled: true,
|
||||
sort: 0
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: RepositoryDTO) => {
|
||||
setEditingRepository(record);
|
||||
form.setFieldsValue(record);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个代码库吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await repositoryService.deleteRepository(id);
|
||||
message.success('删除成功');
|
||||
fetchRepositories();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSync = async (id: number) => {
|
||||
try {
|
||||
setSelectedRepository(id);
|
||||
setSyncLoading(true);
|
||||
await repositoryService.syncAll(id);
|
||||
message.success('同步任务已开始');
|
||||
setSyncStatusVisible(true);
|
||||
setAutoRefresh(true);
|
||||
} catch (error) {
|
||||
message.error('启动同步任务失败');
|
||||
} finally {
|
||||
setSyncLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
setSyncStatusVisible(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (editingRepository) {
|
||||
await repositoryService.updateRepository(editingRepository.id, values);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await repositoryService.createRepository(values);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
fetchRepositories();
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
try {
|
||||
const values = await form.validateFields(['url', 'accessToken', 'apiVersion']);
|
||||
await repositoryService.testConnection(values);
|
||||
message.success('连接测试成功');
|
||||
} catch (error) {
|
||||
message.error('连接测试失败,请检查配置');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '仓库名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
title: '仓库地址',
|
||||
dataIndex: 'url',
|
||||
key: 'url',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: 'API版本',
|
||||
dataIndex: 'apiVersion',
|
||||
key: 'apiVersion',
|
||||
width: '10%',
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
key: 'remark',
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
title: '最后同步时间',
|
||||
dataIndex: 'lastSyncTime',
|
||||
key: 'lastSyncTime',
|
||||
width: '15%',
|
||||
render: (text: string) => text ? dayjs(text).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||
},
|
||||
{
|
||||
title: '最后同步状态',
|
||||
dataIndex: 'lastSyncStatus',
|
||||
key: 'lastSyncStatus',
|
||||
width: '10%',
|
||||
render: (status: string) => {
|
||||
switch (status) {
|
||||
case 'SUCCESS':
|
||||
return <Tag icon={<CheckCircleOutlined />} color="success">成功</Tag>;
|
||||
case 'FAILED':
|
||||
return <Tag icon={<CloseCircleOutlined />} color="error">失败</Tag>;
|
||||
case 'RUNNING':
|
||||
return <Tag icon={<SyncOutlined spin />} color="processing">同步中</Tag>;
|
||||
default:
|
||||
return <Tag>未同步</Tag>;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: '15%',
|
||||
render: (_: any, record: RepositoryDTO) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<SyncOutlined />}
|
||||
loading={syncLoading && selectedRepository === record.id}
|
||||
onClick={() => handleSync(record.id)}
|
||||
>
|
||||
同步
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDelete(record.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<Card style={{ marginBottom: '24px' }}>
|
||||
<Form
|
||||
form={searchForm}
|
||||
layout="inline"
|
||||
onFinish={handleSearch}
|
||||
>
|
||||
<Row gutter={24} style={{ width: '100%' }}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="name" label="仓库名称">
|
||||
<Input placeholder="请输入仓库名称" allowClear />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="url" label="仓库地址">
|
||||
<Input placeholder="请输入仓库地址" allowClear />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Row justify="space-between" style={{ marginBottom: 16 }}>
|
||||
<Col>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
新增仓库
|
||||
</Button>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SyncOutlined />}
|
||||
onClick={() => setSyncStatusVisible(true)}
|
||||
>
|
||||
查看历史同步记录
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Table
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={repositories}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="middle"
|
||||
bordered
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingRepository ? '编辑仓库' : '新增仓库'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={600}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button key="test" onClick={handleTestConnection}>
|
||||
测试连接
|
||||
</Button>,
|
||||
<Button key="cancel" onClick={() => setModalVisible(false)}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button key="submit" type="primary" onClick={handleSubmit}>
|
||||
确定
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="仓库名称"
|
||||
rules={[{ required: true, message: '请输入仓库名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入仓库名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="url"
|
||||
label="仓库地址"
|
||||
rules={[{ required: true, message: '请输入仓库地址' }]}
|
||||
>
|
||||
<Input placeholder="请输入仓库地址" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="accessToken"
|
||||
label="访问令牌"
|
||||
rules={[{ required: true, message: '请输入访问令牌' }]}
|
||||
>
|
||||
<Input.Password placeholder="请输入访问令牌" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="apiVersion"
|
||||
label="API版本"
|
||||
>
|
||||
<Input placeholder="请输入API版本" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="remark"
|
||||
label="备注"
|
||||
>
|
||||
<Input.TextArea rows={2} placeholder="请输入备注" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<SyncStatusDrawer
|
||||
visible={syncStatusVisible}
|
||||
onClose={handleDrawerClose}
|
||||
autoRefresh={autoRefresh}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RepositoryPage;
|
||||
35
frontend/src/pages/System/Repository/service.ts
Normal file
35
frontend/src/pages/System/Repository/service.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import request from '@/utils/request';
|
||||
import type { BaseResponse } from '@/types/api';
|
||||
import type { RepositoryDTO, RepositoryQuery, TestConnectionDTO, RunningSyncDTO, RepositorySyncStatusDTO } from './types';
|
||||
|
||||
export const getRepositories = async (params?: RepositoryQuery) => {
|
||||
return await request.get<BaseResponse<RepositoryDTO[]>>('/api/system/repositories', { params });
|
||||
};
|
||||
|
||||
export const createRepository = async (data: Partial<RepositoryDTO>) => {
|
||||
return await request.post<BaseResponse<RepositoryDTO>>('/api/system/repositories', data);
|
||||
};
|
||||
|
||||
export const updateRepository = async (id: number, data: Partial<RepositoryDTO>) => {
|
||||
return await request.put<BaseResponse<RepositoryDTO>>(`/api/system/repositories/${id}`, data);
|
||||
};
|
||||
|
||||
export const deleteRepository = async (id: number) => {
|
||||
return await request.delete<BaseResponse<void>>(`/api/system/repositories/${id}`);
|
||||
};
|
||||
|
||||
export const testConnection = async (data: TestConnectionDTO) => {
|
||||
return await request.post<BaseResponse<boolean>>('/api/system/repositories/test-connection', data);
|
||||
};
|
||||
|
||||
export const syncAll = async (id: number) => {
|
||||
return await request.post<BaseResponse<number>>(`/api/system/repositories/${id}/sync/all`);
|
||||
};
|
||||
|
||||
export async function getRunningSyncs() {
|
||||
return await request.get<BaseResponse<RunningSyncDTO[]>>('/api/system/repositories/sync/running');
|
||||
}
|
||||
|
||||
export async function getSyncStatus(historyId: number) {
|
||||
return await request.get<BaseResponse<RepositorySyncStatusDTO>>(`/api/system/repositories/sync/${historyId}/status`);
|
||||
}
|
||||
47
frontend/src/pages/System/Repository/types.ts
Normal file
47
frontend/src/pages/System/Repository/types.ts
Normal file
@ -0,0 +1,47 @@
|
||||
export interface RepositoryDTO {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
accessToken: string;
|
||||
apiVersion?: string;
|
||||
sort: number;
|
||||
remark?: string;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
version?: number;
|
||||
createBy?: string;
|
||||
updateBy?: string;
|
||||
deleted: boolean;
|
||||
lastSyncTime?: string;
|
||||
lastSyncStatus?: 'SUCCESS' | 'FAILED' | 'RUNNING';
|
||||
}
|
||||
|
||||
export interface RepositoryQuery {
|
||||
name?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface TestConnectionDTO {
|
||||
url: string;
|
||||
accessToken: string;
|
||||
apiVersion?: string;
|
||||
}
|
||||
|
||||
export interface RunningSyncDTO {
|
||||
historyId: number;
|
||||
repositoryId: number;
|
||||
repositoryName: string;
|
||||
startTime: string;
|
||||
syncType: string;
|
||||
status: 'SUCCESS' | 'FAILED' | 'RUNNING';
|
||||
endTime?: string;
|
||||
errorMessage?: string;
|
||||
actuallyRunning: boolean;
|
||||
}
|
||||
|
||||
export interface RepositorySyncStatusDTO {
|
||||
status: 'SUCCESS' | 'FAILED' | 'RUNNING';
|
||||
startTime: string;
|
||||
endTime: string | null;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
110
frontend/src/pages/System/Role/components/PermissionModal.tsx
Normal file
110
frontend/src/pages/System/Role/components/PermissionModal.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Modal, Tree, message, Spin } from 'antd';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import type { MenuDTO } from '../../Menu/types';
|
||||
import { getMenuTree } from '../../Menu/service';
|
||||
import { getRoleMenuIds, updateRoleMenus } from '../service';
|
||||
|
||||
interface PermissionModalProps {
|
||||
roleId: number;
|
||||
visible: boolean;
|
||||
onCancel: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const PermissionModal: React.FC<PermissionModalProps> = ({
|
||||
roleId,
|
||||
visible,
|
||||
onCancel,
|
||||
onSuccess
|
||||
}) => {
|
||||
const [menuTree, setMenuTree] = useState<MenuDTO[]>([]);
|
||||
const [selectedKeys, setSelectedKeys] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedKeys, setExpandedKeys] = useState<number[]>([]);
|
||||
|
||||
// 获取菜单树和角色已有权限
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [menus, menuIds] = await Promise.all([
|
||||
getMenuTree(),
|
||||
getRoleMenuIds(roleId)
|
||||
]);
|
||||
setMenuTree(menus);
|
||||
setSelectedKeys(menuIds);
|
||||
// 设置展开的节点
|
||||
const keys: number[] = [];
|
||||
const getExpandedKeys = (items: MenuDTO[]) => {
|
||||
items.forEach(item => {
|
||||
if (item.type === 0 || item.type === 1) { // 展开目录和菜单
|
||||
keys.push(item.id);
|
||||
}
|
||||
if (item.children) {
|
||||
getExpandedKeys(item.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
getExpandedKeys(menus);
|
||||
setExpandedKeys(keys);
|
||||
} catch (error) {
|
||||
message.error('获取权限数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
fetchData();
|
||||
}
|
||||
}, [visible, roleId]);
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await updateRoleMenus(roleId, selectedKeys);
|
||||
message.success('权限更新成功');
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
message.error('权限更新失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 转换菜单数据为Tree组件需要的格式
|
||||
const getTreeData = (menus: MenuDTO[]): DataNode[] => {
|
||||
return menus.map(menu => ({
|
||||
title: menu.name,
|
||||
key: menu.id,
|
||||
children: menu.children ? getTreeData(menu.children) : undefined,
|
||||
disabled: menu.type === 0 // 目录不可选
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="分配权限"
|
||||
open={visible}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
width={600}
|
||||
confirmLoading={loading}
|
||||
destroyOnClose
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Tree
|
||||
checkable
|
||||
expandedKeys={expandedKeys}
|
||||
onExpand={(keys) => setExpandedKeys(keys as number[])}
|
||||
checkedKeys={selectedKeys}
|
||||
onCheck={(checked) => setSelectedKeys(checked as number[])}
|
||||
treeData={getTreeData(menuTree)}
|
||||
/>
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PermissionModal;
|
||||
228
frontend/src/pages/System/Role/index.tsx
Normal file
228
frontend/src/pages/System/Role/index.tsx
Normal file
@ -0,0 +1,228 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Space, message } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined } from '@ant-design/icons';
|
||||
import type { RoleDTO } from './types';
|
||||
import { getRoles, createRole, updateRole, deleteRole } from './service';
|
||||
import PermissionModal from './components/PermissionModal';
|
||||
|
||||
const RolePage: React.FC = () => {
|
||||
const [roles, setRoles] = useState<RoleDTO[]>([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingRole, setEditingRole] = useState<RoleDTO | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [permissionModalVisible, setPermissionModalVisible] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState<RoleDTO | null>(null);
|
||||
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getRoles();
|
||||
setRoles(data || []);
|
||||
} catch (error) {
|
||||
console.error('获取角色列表失败:', error);
|
||||
message.error('获取角色列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles();
|
||||
}, []);
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingRole(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
sort: 0
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: RoleDTO) => {
|
||||
setEditingRole(record);
|
||||
form.setFieldsValue(record);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个角色吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteRole(id);
|
||||
message.success('删除成功');
|
||||
fetchRoles();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (editingRole) {
|
||||
await updateRole(editingRole.id, {
|
||||
...values,
|
||||
version: editingRole.version
|
||||
});
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await createRole(values);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
fetchRoles();
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePermission = (record: RoleDTO) => {
|
||||
setSelectedRole(record);
|
||||
setPermissionModalVisible(true);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '角色名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: '角色编码',
|
||||
dataIndex: 'code',
|
||||
key: 'code',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
width: '35%',
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort',
|
||||
key: 'sort',
|
||||
width: '10%',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: '15%',
|
||||
render: (_: any, record: RoleDTO) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<KeyOutlined />}
|
||||
onClick={() => handlePermission(record)}
|
||||
disabled={record.code === 'ROLE_ADMIN'}
|
||||
>
|
||||
权限设置
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDelete(record.id)}
|
||||
disabled={record.code === 'ROLE_ADMIN'}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
新增角色
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={roles}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="middle"
|
||||
bordered
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingRole ? '编辑角色' : '新增角色'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={600}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="角色名称"
|
||||
rules={[{ required: true, message: '请输入角色名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入角色名称" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="角色编码"
|
||||
rules={[{ required: true, message: '请输入角色编码' }]}
|
||||
>
|
||||
<Input placeholder="请输入角色编码" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述"
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="请输入描述" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="sort"
|
||||
label="排序"
|
||||
>
|
||||
<Input type="number" placeholder="请输入排序号" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{selectedRole && (
|
||||
<PermissionModal
|
||||
roleId={selectedRole.id}
|
||||
visible={permissionModalVisible}
|
||||
onCancel={() => {
|
||||
setPermissionModalVisible(false);
|
||||
setSelectedRole(null);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setPermissionModalVisible(false);
|
||||
setSelectedRole(null);
|
||||
fetchRoles();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RolePage;
|
||||
26
frontend/src/pages/System/Role/service.ts
Normal file
26
frontend/src/pages/System/Role/service.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import request from '@/utils/request';
|
||||
import type { RoleDTO } from './types';
|
||||
|
||||
export const getRoles = async (): Promise<RoleDTO[]> => {
|
||||
return request.get('/api/system/roles');
|
||||
};
|
||||
|
||||
export const createRole = async (data: Partial<RoleDTO>): Promise<RoleDTO> => {
|
||||
return request.post('/api/system/roles', data);
|
||||
};
|
||||
|
||||
export const updateRole = async (id: number, data: Partial<RoleDTO>): Promise<RoleDTO> => {
|
||||
return request.put(`/api/system/roles/${id}`, data);
|
||||
};
|
||||
|
||||
export const deleteRole = async (id: number): Promise<void> => {
|
||||
return request.delete(`/api/system/roles/${id}`);
|
||||
};
|
||||
|
||||
export const getRoleMenuIds = async (roleId: number): Promise<number[]> => {
|
||||
return request.get(`/api/system/roles/${roleId}/menus`);
|
||||
};
|
||||
|
||||
export const updateRoleMenus = async (roleId: number, menuIds: number[]): Promise<void> => {
|
||||
return request.put(`/api/system/roles/${roleId}/menus`, menuIds);
|
||||
};
|
||||
17
frontend/src/pages/System/Role/types.ts
Normal file
17
frontend/src/pages/System/Role/types.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export interface RoleDTO {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
sort: number;
|
||||
version?: number;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
export interface RoleQuery {
|
||||
page: number;
|
||||
size: number;
|
||||
name?: string;
|
||||
code?: string;
|
||||
}
|
||||
292
frontend/src/pages/System/Tenant/index.tsx
Normal file
292
frontend/src/pages/System/Tenant/index.tsx
Normal file
@ -0,0 +1,292 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Space, message, Switch, DatePicker, Card, Row, Col } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import type { TenantDTO, TenantQuery } from './types';
|
||||
import { getTenants, createTenant, updateTenant, deleteTenant } from './service';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const TenantPage: React.FC = () => {
|
||||
const [tenants, setTenants] = useState<TenantDTO[]>([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingTenant, setEditingTenant] = useState<TenantDTO | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [searchForm] = Form.useForm();
|
||||
|
||||
const fetchTenants = async (params?: TenantQuery) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getTenants(params);
|
||||
setTenants(data || []);
|
||||
} catch (error) {
|
||||
console.error('获取租户列表失败:', error);
|
||||
message.error('获取租户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTenants();
|
||||
}, []);
|
||||
|
||||
const handleSearch = async () => {
|
||||
const values = await searchForm.validateFields();
|
||||
fetchTenants(values);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
searchForm.resetFields();
|
||||
fetchTenants();
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingTenant(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
enabled: true
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: TenantDTO) => {
|
||||
setEditingTenant(record);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
expireTime: record.expireTime ? dayjs(record.expireTime) : undefined
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个租户吗?删除后不可恢复!',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteTenant(id);
|
||||
message.success('删除成功');
|
||||
fetchTenants();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (editingTenant) {
|
||||
await updateTenant(editingTenant.id, {
|
||||
...values,
|
||||
expireTime: values.expireTime?.format('YYYY-MM-DD HH:mm:ss'),
|
||||
version: editingTenant.version
|
||||
});
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await createTenant({
|
||||
...values,
|
||||
expireTime: values.expireTime?.format('YYYY-MM-DD HH:mm:ss')
|
||||
});
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
fetchTenants();
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '租户名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
title: '租户编码',
|
||||
dataIndex: 'code',
|
||||
key: 'code',
|
||||
width: '10%',
|
||||
},
|
||||
{
|
||||
title: '联系人',
|
||||
dataIndex: 'contactName',
|
||||
key: 'contactName',
|
||||
width: '10%',
|
||||
},
|
||||
{
|
||||
title: '联系电话',
|
||||
dataIndex: 'contactPhone',
|
||||
key: 'contactPhone',
|
||||
width: '12%',
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
width: '8%',
|
||||
render: (enabled: boolean) => (
|
||||
<Switch checked={enabled} disabled />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: TenantDTO) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDelete(record.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<Card style={{ marginBottom: '24px' }}>
|
||||
<Form
|
||||
form={searchForm}
|
||||
layout="inline"
|
||||
onFinish={handleSearch}
|
||||
>
|
||||
<Row gutter={24} style={{ width: '100%' }}>
|
||||
<Col span={6}>
|
||||
<Form.Item name="name" label="租户名称">
|
||||
<Input placeholder="请输入租户名称" allowClear />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="code" label="租户编码">
|
||||
<Input placeholder="请输入租户编码" allowClear />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="enabled" label="状态">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
新增租户
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={tenants}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="middle"
|
||||
bordered
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingTenant ? '编辑租户' : '新增租户'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={600}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="租户名称"
|
||||
rules={[{ required: true, message: '请输入租户名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入租户名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="租户编码"
|
||||
rules={[{ required: true, message: '请输入租户编码' }]}
|
||||
>
|
||||
<Input placeholder="请输入租户编码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="contactName"
|
||||
label="联系人"
|
||||
>
|
||||
<Input placeholder="请输入联系人" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="contactPhone"
|
||||
label="联系电话"
|
||||
>
|
||||
<Input placeholder="请输入联系电话" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱"
|
||||
rules={[{ type: 'email', message: '请输入正确的邮箱格式' }]}
|
||||
>
|
||||
<Input placeholder="请输入邮箱" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="address"
|
||||
label="地址"
|
||||
>
|
||||
<Input.TextArea rows={2} placeholder="请输入地址" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="enabled"
|
||||
label="状态"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TenantPage;
|
||||
37
frontend/src/pages/System/Tenant/service.ts
Normal file
37
frontend/src/pages/System/Tenant/service.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import request from '@/utils/request';
|
||||
import type { TenantDTO, TenantQuery } from './types';
|
||||
|
||||
// 获取租户列表(分页)
|
||||
export const getTenants = async (params?: TenantQuery) => {
|
||||
return request.get<TenantDTO[]>('/api/system/tenants', { params });
|
||||
};
|
||||
|
||||
// 获取所有启用的租户(用于登录页面)
|
||||
export const getEnabledTenants = async () => {
|
||||
return request.get<TenantDTO[]>('/api/system/tenants/list');
|
||||
};
|
||||
|
||||
// 创建租户
|
||||
export const createTenant = async (data: Partial<TenantDTO>) => {
|
||||
return request.post<TenantDTO>('/api/system/tenants', data);
|
||||
};
|
||||
|
||||
// 更新租户
|
||||
export const updateTenant = async (id: number, data: Partial<TenantDTO>) => {
|
||||
return request.put<TenantDTO>(`/api/system/tenants/${id}`, data);
|
||||
};
|
||||
|
||||
// 删除租户
|
||||
export const deleteTenant = async (id: number) => {
|
||||
return request.delete(`/api/system/tenants/${id}`);
|
||||
};
|
||||
|
||||
// 检查租户编码是否存在
|
||||
export const checkTenantCode = async (code: string) => {
|
||||
return request.get<boolean>(`/api/system/tenants/check-code`, { params: { code } });
|
||||
};
|
||||
|
||||
// 检查租户名称是否存在
|
||||
export const checkTenantName = async (name: string) => {
|
||||
return request.get<boolean>(`/api/system/tenants/check-name`, { params: { name } });
|
||||
};
|
||||
19
frontend/src/pages/System/Tenant/types.ts
Normal file
19
frontend/src/pages/System/Tenant/types.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export interface TenantDTO {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
email?: string;
|
||||
address?: string;
|
||||
enabled: boolean;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export interface TenantQuery {
|
||||
name?: string;
|
||||
code?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
108
frontend/src/pages/System/User/components/RoleModal.tsx
Normal file
108
frontend/src/pages/System/User/components/RoleModal.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Modal, Transfer, message, Spin } from 'antd';
|
||||
import type { TransferDirection, TransferProps } from 'antd/es/transfer';
|
||||
import { getRoles } from '../../Role/service';
|
||||
import { getUserRoleIds, updateUserRoles } from '../service';
|
||||
import type { RoleDTO } from '../../Role/types';
|
||||
|
||||
interface RoleModalProps {
|
||||
userId: number;
|
||||
visible: boolean;
|
||||
onCancel: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
interface RoleTransferItem {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const RoleModal: React.FC<RoleModalProps> = ({
|
||||
userId,
|
||||
visible,
|
||||
onCancel,
|
||||
onSuccess
|
||||
}) => {
|
||||
const [roles, setRoles] = useState<RoleDTO[]>([]);
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [allRoles, userRoleIds] = await Promise.all([
|
||||
getRoles(),
|
||||
getUserRoleIds(userId)
|
||||
]);
|
||||
setRoles(allRoles);
|
||||
setSelectedKeys(userRoleIds.map(String));
|
||||
} catch (error) {
|
||||
message.error('获取角色数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
fetchData();
|
||||
}
|
||||
}, [visible, userId]);
|
||||
|
||||
const handleChange: TransferProps<RoleTransferItem>['onChange'] = (nextTargetKeys) => {
|
||||
setSelectedKeys(nextTargetKeys as string[]);
|
||||
};
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await updateUserRoles(userId, selectedKeys.map(Number));
|
||||
message.success('角色分配成功');
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
message.error('角色分配失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="分配角色"
|
||||
open={visible}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
width={600}
|
||||
confirmLoading={loading}
|
||||
destroyOnClose
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Transfer<RoleTransferItem>
|
||||
dataSource={roles.map(role => ({
|
||||
key: role.id.toString(),
|
||||
title: role.name,
|
||||
description: role.code,
|
||||
disabled: role.code === 'ROLE_ADMIN'
|
||||
}))}
|
||||
titles={['未选择', '已选择']}
|
||||
targetKeys={selectedKeys}
|
||||
onChange={handleChange}
|
||||
render={item => item.title}
|
||||
listStyle={{
|
||||
width: 250,
|
||||
height: 400,
|
||||
}}
|
||||
showSearch
|
||||
filterOption={(inputValue, item) =>
|
||||
(item.title?.toLowerCase().indexOf(inputValue.toLowerCase()) !== -1) ||
|
||||
(item.description?.toLowerCase().indexOf(inputValue.toLowerCase()) !== -1)
|
||||
}
|
||||
/>
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoleModal;
|
||||
367
frontend/src/pages/System/User/index.tsx
Normal file
367
frontend/src/pages/System/User/index.tsx
Normal file
@ -0,0 +1,367 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Space, message, Switch, TreeSelect } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import type { UserDTO } from './types';
|
||||
import type { DepartmentDTO } from '../Department/types';
|
||||
import { getUsers, createUser, updateUser, deleteUser, resetPassword } from './service';
|
||||
import { getDepartmentTree } from '../Department/service';
|
||||
import RoleModal from './components/RoleModal';
|
||||
|
||||
const UserPage: React.FC = () => {
|
||||
const [users, setUsers] = useState<UserDTO[]>([]);
|
||||
const [departments, setDepartments] = useState<DepartmentDTO[]>([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [roleModalVisible, setRoleModalVisible] = useState(false);
|
||||
const [resetPasswordModalVisible, setResetPasswordModalVisible] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<UserDTO | null>(null);
|
||||
const [selectedUser, setSelectedUser] = useState<UserDTO | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [passwordForm] = Form.useForm();
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getUsers({ page: 1, size: 100 });
|
||||
setUsers(data || []);
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error);
|
||||
message.error('获取用户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDepartments = async () => {
|
||||
try {
|
||||
const data = await getDepartmentTree();
|
||||
setDepartments(data || []);
|
||||
} catch (error) {
|
||||
console.error('获取部门列表失败:', error);
|
||||
message.error('获取部门列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
fetchDepartments();
|
||||
}, []);
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingUser(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
enabled: true
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: UserDTO) => {
|
||||
setEditingUser(record);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
password: undefined
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个用户吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteUser(id);
|
||||
message.success('删除成功');
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleResetPassword = (record: UserDTO) => {
|
||||
setEditingUser(record);
|
||||
passwordForm.resetFields();
|
||||
setResetPasswordModalVisible(true);
|
||||
};
|
||||
|
||||
const handleResetPasswordSubmit = async () => {
|
||||
try {
|
||||
const values = await passwordForm.validateFields();
|
||||
if (editingUser) {
|
||||
await resetPassword(editingUser.id, values.password);
|
||||
message.success('密码重置成功');
|
||||
setResetPasswordModalVisible(false);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('密码重置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (editingUser) {
|
||||
await updateUser(editingUser.id, {
|
||||
...values,
|
||||
version: editingUser.version
|
||||
});
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await createUser(values);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const getTreeData = (deps: DepartmentDTO[]): any[] => {
|
||||
return deps.map(dept => ({
|
||||
title: dept.name,
|
||||
value: dept.id,
|
||||
children: dept.children && dept.children.length > 0 ? getTreeData(dept.children) : undefined
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAssignRoles = (record: UserDTO) => {
|
||||
setSelectedUser(record);
|
||||
setRoleModalVisible(true);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
title: '昵称',
|
||||
dataIndex: 'nickname',
|
||||
key: 'nickname',
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
title: '所属部门',
|
||||
dataIndex: 'deptName',
|
||||
key: 'deptName',
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
width: '10%',
|
||||
render: (enabled: boolean) => (
|
||||
<Switch checked={enabled} disabled />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: '280px',
|
||||
render: (_: any, record: UserDTO) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<TeamOutlined />}
|
||||
onClick={() => handleAssignRoles(record)}
|
||||
disabled={record.username === 'admin'}
|
||||
>
|
||||
分配角色
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<KeyOutlined />}
|
||||
onClick={() => handleResetPassword(record)}
|
||||
>
|
||||
重置密码
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDelete(record.id)}
|
||||
disabled={record.username === 'admin'}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
新增用户
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="middle"
|
||||
bordered
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingUser ? '编辑用户' : '新增用户'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={600}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="用户名"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input placeholder="请输入用户名" disabled={!!editingUser} />
|
||||
</Form.Item>
|
||||
{!editingUser && (
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password placeholder="请输入密码" />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
name="nickname"
|
||||
label="昵称"
|
||||
>
|
||||
<Input placeholder="请输入昵称" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱"
|
||||
rules={[{ type: 'email', message: '请输入正确的邮箱格式' }]}
|
||||
>
|
||||
<Input placeholder="请输入邮箱" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="手机号"
|
||||
>
|
||||
<Input placeholder="请输入手机号" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="deptId"
|
||||
label="所属部门"
|
||||
>
|
||||
<TreeSelect
|
||||
treeData={getTreeData(departments)}
|
||||
placeholder="请选择所属部门"
|
||||
allowClear
|
||||
treeDefaultExpandAll
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="enabled"
|
||||
label="状态"
|
||||
valuePropName="checked"
|
||||
initialValue={true}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{selectedUser && (
|
||||
<RoleModal
|
||||
userId={selectedUser.id}
|
||||
visible={roleModalVisible}
|
||||
onCancel={() => {
|
||||
setRoleModalVisible(false);
|
||||
setSelectedUser(null);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setRoleModalVisible(false);
|
||||
setSelectedUser(null);
|
||||
fetchUsers();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title="重置密码"
|
||||
open={resetPasswordModalVisible}
|
||||
onOk={handleResetPasswordSubmit}
|
||||
onCancel={() => setResetPasswordModalVisible(false)}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form
|
||||
form={passwordForm}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="新密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 6, message: '密码长度不能小于6位' }
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="请输入新密码" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
label="确认密码"
|
||||
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>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserPage;
|
||||
30
frontend/src/pages/System/User/service.ts
Normal file
30
frontend/src/pages/System/User/service.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import request from '@/utils/request';
|
||||
import type { UserDTO, UserQuery } from './types';
|
||||
|
||||
export const getUsers = async (params: UserQuery): Promise<UserDTO[]> => {
|
||||
return request.get('/api/system/users', { params });
|
||||
};
|
||||
|
||||
export const createUser = async (data: Partial<UserDTO>): Promise<UserDTO> => {
|
||||
return request.post('/api/system/users', data);
|
||||
};
|
||||
|
||||
export const updateUser = async (id: number, data: Partial<UserDTO>): Promise<UserDTO> => {
|
||||
return request.put(`/api/system/users/${id}`, data);
|
||||
};
|
||||
|
||||
export const deleteUser = async (id: number): Promise<void> => {
|
||||
return request.delete(`/api/system/users/${id}`);
|
||||
};
|
||||
|
||||
export const resetPassword = async (id: number, password: string): Promise<void> => {
|
||||
return request.post(`/api/system/users/${id}/reset-password`, { password });
|
||||
};
|
||||
|
||||
export const getUserRoleIds = async (userId: number): Promise<number[]> => {
|
||||
return request.get(`/api/system/users/${userId}/roles`);
|
||||
};
|
||||
|
||||
export const updateUserRoles = async (userId: number, roleIds: number[]): Promise<void> => {
|
||||
return request.put(`/api/system/users/${userId}/roles`, roleIds);
|
||||
};
|
||||
21
frontend/src/pages/System/User/types.ts
Normal file
21
frontend/src/pages/System/User/types.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export interface UserDTO {
|
||||
id: number;
|
||||
username: string;
|
||||
nickname?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
enabled: boolean;
|
||||
deptId?: number;
|
||||
deptName?: string;
|
||||
version?: number;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
export interface UserQuery {
|
||||
page: number;
|
||||
size: number;
|
||||
keyword?: string;
|
||||
deptId?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
126
frontend/src/router/index.tsx
Normal file
126
frontend/src/router/index.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Spin } from 'antd';
|
||||
import Login from '../pages/Login';
|
||||
import BasicLayout from '../layouts/BasicLayout';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from '../store';
|
||||
|
||||
// 懒加载组件
|
||||
const Dashboard = lazy(() => import('../pages/Dashboard'));
|
||||
const Department = lazy(() => import('../pages/System/Department'));
|
||||
const Role = lazy(() => import('../pages/System/Role'));
|
||||
const User = lazy(() => import('../pages/System/User'));
|
||||
const Menu = lazy(() => import('../pages/System/Menu'));
|
||||
const Tenant = lazy(() => import('../pages/System/Tenant'));
|
||||
const Repository = lazy(() => import('../pages/System/Repository'));
|
||||
const Jenkins = lazy(() => import('../pages/System/Jenkins'));
|
||||
|
||||
// 加载中组件
|
||||
const LoadingComponent = () => (
|
||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const PrivateRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const token = useSelector((state: RootState) => state.user.token);
|
||||
|
||||
if (!token) {
|
||||
return <Navigate to="/login" />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/login',
|
||||
element: <Login />
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
<PrivateRoute>
|
||||
<BasicLayout />
|
||||
</PrivateRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
element: <Navigate to="/dashboard" />
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
element: (
|
||||
<Suspense fallback={<LoadingComponent />}>
|
||||
<Dashboard />
|
||||
</Suspense>
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'system',
|
||||
children: [
|
||||
{
|
||||
path: 'tenant',
|
||||
element: (
|
||||
<Suspense fallback={<LoadingComponent />}>
|
||||
<Tenant />
|
||||
</Suspense>
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'department',
|
||||
element: (
|
||||
<Suspense fallback={<LoadingComponent />}>
|
||||
<Department />
|
||||
</Suspense>
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'role',
|
||||
element: (
|
||||
<Suspense fallback={<LoadingComponent />}>
|
||||
<Role />
|
||||
</Suspense>
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'user',
|
||||
element: (
|
||||
<Suspense fallback={<LoadingComponent />}>
|
||||
<User />
|
||||
</Suspense>
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'menu',
|
||||
element: (
|
||||
<Suspense fallback={<LoadingComponent />}>
|
||||
<Menu />
|
||||
</Suspense>
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'repository',
|
||||
element: (
|
||||
<Suspense fallback={<LoadingComponent />}>
|
||||
<Repository />
|
||||
</Suspense>
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'jenkins',
|
||||
element: (
|
||||
<Suspense fallback={<LoadingComponent />}>
|
||||
<Jenkins />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
export default router;
|
||||
36
frontend/src/services/weather.ts
Normal file
36
frontend/src/services/weather.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import axios from 'axios';
|
||||
|
||||
interface WeatherData {
|
||||
temp: string;
|
||||
weather: string;
|
||||
city: string;
|
||||
}
|
||||
|
||||
// 使用高德地图 API 获取天气
|
||||
export const getWeather = async (): Promise<WeatherData> => {
|
||||
try {
|
||||
// 先获取 IP 定位
|
||||
const ipUrl = 'https://restapi.amap.com/v3/ip';
|
||||
const weatherUrl = 'https://restapi.amap.com/v3/weather/weatherInfo';
|
||||
const key = '46a02c7d20534596f7386caf02204caa'; // 需要替换成你自己的 key
|
||||
|
||||
const ipResponse = await axios.get(`${ipUrl}?key=${key}`);
|
||||
const { adcode } = ipResponse.data;
|
||||
|
||||
const weatherResponse = await axios.get(`${weatherUrl}?key=${key}&city=${adcode}`);
|
||||
const weatherData = weatherResponse.data.lives[0];
|
||||
|
||||
return {
|
||||
temp: weatherData.temperature,
|
||||
weather: weatherData.weather,
|
||||
city: weatherData.city
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取天气信息失败:', error);
|
||||
return {
|
||||
temp: '--',
|
||||
weather: '未知',
|
||||
city: '未知'
|
||||
};
|
||||
}
|
||||
};
|
||||
13
frontend/src/store/index.ts
Normal file
13
frontend/src/store/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import userReducer from './userSlice';
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
user: userReducer,
|
||||
},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
||||
export default store;
|
||||
50
frontend/src/store/userSlice.ts
Normal file
50
frontend/src/store/userSlice.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { MenuDTO } from '@/pages/System/Menu/types';
|
||||
|
||||
interface UserInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
nickname?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
interface UserState {
|
||||
token: string | null;
|
||||
userInfo: UserInfo | null;
|
||||
menus: MenuDTO[];
|
||||
}
|
||||
|
||||
const initialState: UserState = {
|
||||
token: localStorage.getItem('token'),
|
||||
userInfo: localStorage.getItem('userInfo') ? JSON.parse(localStorage.getItem('userInfo')!) : null,
|
||||
menus: []
|
||||
};
|
||||
|
||||
const userSlice = createSlice({
|
||||
name: 'user',
|
||||
initialState,
|
||||
reducers: {
|
||||
setToken: (state, action: PayloadAction<string>) => {
|
||||
state.token = action.payload;
|
||||
localStorage.setItem('token', action.payload);
|
||||
},
|
||||
setUserInfo: (state, action: PayloadAction<UserInfo>) => {
|
||||
state.userInfo = action.payload;
|
||||
localStorage.setItem('userInfo', JSON.stringify(action.payload));
|
||||
},
|
||||
setMenus: (state, action: PayloadAction<MenuDTO[]>) => {
|
||||
state.menus = action.payload;
|
||||
},
|
||||
logout: (state) => {
|
||||
state.token = null;
|
||||
state.userInfo = null;
|
||||
state.menus = [];
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userInfo');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const { setToken, setUserInfo, setMenus, logout } = userSlice.actions;
|
||||
export default userSlice.reducer;
|
||||
5
frontend/src/types/api.ts
Normal file
5
frontend/src/types/api.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export interface BaseResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
4
frontend/src/types/style.d.ts
vendored
Normal file
4
frontend/src/types/style.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: { [key: string]: string };
|
||||
export default classes;
|
||||
}
|
||||
22
frontend/src/types/user.ts
Normal file
22
frontend/src/types/user.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export interface LoginParams {
|
||||
username: string;
|
||||
password: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
username: string;
|
||||
nickname?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
token: string;
|
||||
userInfo: UserInfo;
|
||||
}
|
||||
|
||||
export interface UserState {
|
||||
token: string | null;
|
||||
userInfo: LoginResult | null;
|
||||
}
|
||||
55
frontend/src/utils/request.ts
Normal file
55
frontend/src/utils/request.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { message } from 'antd';
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '',
|
||||
timeout: 300000,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
request.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
const tenantId = localStorage.getItem('tenantId');
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (tenantId) {
|
||||
config.headers['X-Tenant-ID'] = tenantId;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
request.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResponse<any>>) => {
|
||||
const res = response.data;
|
||||
if (res.code !== 200) {
|
||||
message.error(res.message || '请求失败');
|
||||
return Promise.reject(new Error(res.message || '请求失败'));
|
||||
}
|
||||
return Promise.resolve(res.data);
|
||||
},
|
||||
(error) => {
|
||||
message.error(error.response?.data?.message || '请求失败');
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default request;
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
22
frontend/vite.config.ts
Normal file
22
frontend/vite.config.ts
Normal file
@ -0,0 +1,22 @@
|
||||
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: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user