增加前端代码。
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