增加前端代码。

This commit is contained in:
dengqichen 2024-11-28 09:57:54 +08:00
parent f11564fa50
commit c90e2526fe
50 changed files with 8885 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/frontend/.idea/
/backend/.idea/
/.idea/

13
frontend/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

35
frontend/package.json Normal file
View 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
View File

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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: '未知'
};
}
};

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

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

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

@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: { [key: string]: string };
export default classes;
}

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

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

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