0317
commit
b75150dca0
|
|
@ -0,0 +1,11 @@
|
|||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "pms-react-new"
|
||||
|
||||
[setup]
|
||||
script = ""
|
||||
|
||||
[[actions]]
|
||||
name = "运行"
|
||||
icon = "run"
|
||||
command = "npm run dev"
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<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>pms-react-new</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"name": "pms-react-new",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"antd": "^6.3.1",
|
||||
"axios": "^1.13.5",
|
||||
"dayjs": "^1.11.19",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"file-saver": "^2.0.5",
|
||||
"js-cookie": "^3.0.5",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -0,0 +1 @@
|
|||
/* Remove default styles */
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import { BrowserRouter as Router, Routes, Route, Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { Spin } from 'antd';
|
||||
import LoginPage from './pages/Login';
|
||||
import JobMonitorPage from './pages/monitor/JobMonitorPage';
|
||||
import CacheMonitorPage from './pages/monitor/CacheMonitorPage';
|
||||
import LoginLogPage from './pages/monitor/LoginLogPage'; // Import LoginLogPage
|
||||
import OnlineUserPage from './pages/monitor/OnlineUserPage'; // Import OnlineUserPage
|
||||
import OperationLogPage from './pages/monitor/OperationLogPage'; // Import OperationLogPage
|
||||
import ServerMonitorPage from './pages/monitor/ServerMonitorPage'; // Import ServerMonitorPage
|
||||
import CacheListPage from './pages/monitor/CacheListPage'; // Import CacheListPage
|
||||
import UserPage from './pages/system/UserPage'; // Import UserPage
|
||||
import RolePage from './pages/system/RolePage'; // Import RolePage
|
||||
import MenuPage from './pages/system/MenuPage'; // Import MenuPage
|
||||
import DeptPage from './pages/system/DeptPage'; // Import DeptPage
|
||||
import DictPage from './pages/system/DictPage'; // Import DictPage
|
||||
import ConfigPage from './pages/system/ConfigPage'; // Import ConfigPage
|
||||
import ProjectPage from './pages/project/ProjectPage'; // Import ProjectPage
|
||||
import ProjectDetailPage from './pages/project/ProjectDetailPage'; // Import ProjectDetailPage
|
||||
import DemandManagePage from './pages/project/DemandManagePage'; // Import DemandManagePage
|
||||
import TaskSetPage from './pages/workAppraisal/TaskSetPage'; // Import TaskSetPage
|
||||
import ManagerPage from './pages/workAppraisal/ManagerPage';
|
||||
import ManagerUserPage from './pages/workAppraisal/ManagerUserPage';
|
||||
import AppraisalDetailPage from './pages/workAppraisal/AppraisalDetailPage'; // Import AppraisalDetailPage
|
||||
import NormalWorkerPage from './pages/workAppraisal/NormalWorkerPage';
|
||||
import AppraisalDashboardPage from './pages/workAppraisal/AppraisalDashboardPage';
|
||||
import AppraisalModuleDetailPage from './pages/workAppraisal/AppraisalModuleDetailPage';
|
||||
import WorkLogPage from './pages/worklog/WorkLogPage'; // Import WorkLogPage
|
||||
import ProjectExecutionPage from './pages/dashboard/ProjectExecutionPage';
|
||||
import ProjectUserPage from './pages/projectBank/ProjectUserPage';
|
||||
import UserProjectPage from './pages/projectBank/UserProjectPage';
|
||||
import UserScorePage from './pages/projectBank/UserScorePage';
|
||||
import UserScoreDetailPage from './pages/projectBank/UserScoreDetailPage';
|
||||
import ProfilePage from './pages/Profile';
|
||||
import MainLayout from './layout/MainLayout';
|
||||
import './App.css';
|
||||
import { getToken } from './utils/auth';
|
||||
import { PermissionProvider, usePermission } from './contexts/PermissionContext';
|
||||
|
||||
const PrivateRoute = () => {
|
||||
const location = useLocation();
|
||||
const token = getToken();
|
||||
const { ready, loading, canAccessPath } = usePermission();
|
||||
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (!ready || loading) {
|
||||
return (
|
||||
<div style={{ width: '100%', minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canAccessPath(location.pathname)) {
|
||||
return <Navigate to="/index" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<PermissionProvider><PrivateRoute /></PermissionProvider>}>
|
||||
<Route path="/" element={<MainLayout />}>
|
||||
<Route index element={<WorkLogPage />} />
|
||||
<Route path="/index" element={<WorkLogPage />} />
|
||||
<Route path="/dashboard/project-execution" element={<ProjectExecutionPage />} />
|
||||
<Route path="/projectBank/projectProgress" element={<ProjectExecutionPage />} />
|
||||
<Route path="/projectBank/projectUser" element={<ProjectUserPage />} />
|
||||
<Route path="/projectBank/userProject" element={<UserProjectPage />} />
|
||||
<Route path="/projectBank/userScore" element={<UserScorePage />} />
|
||||
<Route path="/projectBank/userScoreDetail" element={<UserScoreDetailPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/monitor/cache" element={<CacheMonitorPage />} />
|
||||
<Route path="/monitor/job" element={<JobMonitorPage />} />
|
||||
<Route path="/monitor/logininfor" element={<LoginLogPage />} />
|
||||
<Route path="/monitor/online" element={<OnlineUserPage />} />
|
||||
<Route path="/monitor/operlog" element={<OperationLogPage />} />
|
||||
<Route path="/monitor/server" element={<ServerMonitorPage />} />
|
||||
<Route path="/monitor/cacheList" element={<CacheListPage />} />
|
||||
<Route path="/system/user" element={<UserPage />} />
|
||||
<Route path="/system/role" element={<RolePage />} />
|
||||
<Route path="/system/menu" element={<MenuPage />} />
|
||||
<Route path="/system/dept" element={<DeptPage />} />
|
||||
<Route path="/system/dict" element={<DictPage />} />
|
||||
<Route path="/system/config" element={<ConfigPage />} />
|
||||
<Route path="/project/list" element={<ProjectPage />} />
|
||||
<Route path="/project/detail" element={<ProjectDetailPage />} />
|
||||
<Route path="/project/demandManage" element={<DemandManagePage />} />
|
||||
<Route path="/demandManage" element={<DemandManagePage />} />
|
||||
<Route path="/workAppraisal/manager" element={<ManagerPage />} />
|
||||
<Route path="/workAppraisal/normalWorker" element={<NormalWorkerPage />} />
|
||||
<Route path="/workAppraisal/managerUser" element={<ManagerUserPage />} />
|
||||
<Route path="/workAppraisal/taskSet" element={<TaskSetPage />} />
|
||||
<Route path="/workAppraisal/detail" element={<AppraisalDetailPage />} />
|
||||
<Route path="/workAppraisal/taskModule" element={<AppraisalDashboardPage />} />
|
||||
<Route path="/workAppraisal/dashboard" element={<AppraisalDashboardPage />} />
|
||||
<Route path="/workAppraisal/moduleDetail" element={<AppraisalModuleDetailPage />} />
|
||||
<Route path="/workAppraisal/myPerformance" element={<UserScorePage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import request from '@/utils/request';
|
||||
|
||||
// ==========================================================
|
||||
// Task Appraisal related functions, from api.js -> taskApi
|
||||
// ==========================================================
|
||||
|
||||
// 获取考核任务列表
|
||||
export function getTaskList(data: any) {
|
||||
return request({
|
||||
url: '/task/get',
|
||||
method: 'get',
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取当前用户考核任务列表(经理端)
|
||||
export function getTaskListSelf(data?: any) {
|
||||
return request({
|
||||
url: '/task/list',
|
||||
method: 'get',
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取普通员工考核任务列表(评分端)
|
||||
export function getTaskListSelfNormal(data?: any) {
|
||||
return request({
|
||||
url: '/task/listSelf',
|
||||
method: 'get',
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 新增考核任务
|
||||
export function addTask(data: any) {
|
||||
return request({
|
||||
url: '/task/add',
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 修改考核任务
|
||||
export function updateTask(data: any) {
|
||||
return request({
|
||||
url: '/task/update',
|
||||
method: 'put',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除考核任务
|
||||
export function delTask(id: string | number) {
|
||||
return request({
|
||||
url: `/task/${id}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
// 获取任务的指标配置
|
||||
export function getTaskSet(id: string | number) {
|
||||
return request({
|
||||
url: `/task/target/${id}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 保存任务的指标配置
|
||||
export function setTaskSet(data: any) {
|
||||
return request({
|
||||
url: `/task/config/update`,
|
||||
method: 'put',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取考核看板列表
|
||||
export function getTaskModel(params?: any) {
|
||||
return request({
|
||||
url: '/examine/template/list',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取考核看板指标详情
|
||||
export function getTaskModelSet(id: string | number) {
|
||||
return request({
|
||||
url: `/examine/template/list/${id}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 删除考核看板
|
||||
export function delTaskModule(id: string | number) {
|
||||
return request({
|
||||
url: `/examine/template/${id}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
// 获取考核用户列表 (for manager view)
|
||||
export function getTaskUserList(data: any) {
|
||||
return request({
|
||||
url: '/examine/user',
|
||||
method: 'get',
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取考核分数详情
|
||||
export function getTaskScoreDetail(data: any) {
|
||||
return request({
|
||||
url: '/examine/detail',
|
||||
method: 'get',
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 保存考核分数
|
||||
export function saveTaskUserScore(data: any) {
|
||||
return request({
|
||||
url: '/examine/detail/batch',
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import request from '@/utils/request';
|
||||
import type { CaptchaResponse, LoginRequest, LoginResponse } from '@/types/api';
|
||||
|
||||
export function login(data: LoginRequest) {
|
||||
return request<LoginResponse, LoginRequest>({
|
||||
url: '/login',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function getCodeImg() {
|
||||
return request<CaptchaResponse>({
|
||||
url: '/captchaImage',
|
||||
method: 'get',
|
||||
headers: { isToken: false },
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import request from '@/utils/request';
|
||||
import type {
|
||||
CacheKeyPayload,
|
||||
CacheNamePayload,
|
||||
CacheMonitorResponse,
|
||||
CacheValueResponse,
|
||||
} from '@/types/api';
|
||||
|
||||
// 查询缓存详细
|
||||
export function getCache() {
|
||||
return request<CacheMonitorResponse>({
|
||||
url: '/monitor/cache',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 查询缓存名称列表
|
||||
export function listCacheName() {
|
||||
return request<CacheNamePayload>({
|
||||
url: '/monitor/cache/getNames',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 查询缓存键名列表
|
||||
export function listCacheKey(cacheName: string) {
|
||||
return request<CacheKeyPayload>({
|
||||
url: '/monitor/cache/getKeys/' + cacheName,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 查询缓存内容
|
||||
export function getCacheValue(cacheName: string, cacheKey: string) {
|
||||
return request<CacheValueResponse>({
|
||||
url: '/monitor/cache/getValue/' + cacheName + '/' + cacheKey,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 清理指定名称缓存
|
||||
export function clearCacheName(cacheName: string) {
|
||||
return request({
|
||||
url: '/monitor/cache/clearCacheName/' + cacheName,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
// 清理指定键名缓存
|
||||
export function clearCacheKey(cacheKey: string) {
|
||||
return request({
|
||||
url: '/monitor/cache/clearCacheKey/' + cacheKey,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
// 清理全部缓存
|
||||
export function clearCacheAll() {
|
||||
return request({
|
||||
url: '/monitor/cache/clearCacheAll',
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import request from '@/utils/request';
|
||||
import type { JobListResponse, JobQueryParams, JobRecord } from '@/types/api';
|
||||
|
||||
export function listJob(query: JobQueryParams) {
|
||||
return request<JobListResponse>({
|
||||
url: '/monitor/job/list',
|
||||
method: 'get',
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
export function getJob(jobId: JobRecord['jobId']) {
|
||||
return request<JobRecord>({
|
||||
url: `/monitor/job/${jobId}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
export function addJob(data: JobRecord) {
|
||||
return request<unknown, JobRecord>({
|
||||
url: '/monitor/job',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateJob(data: JobRecord) {
|
||||
return request<unknown, JobRecord>({
|
||||
url: '/monitor/job',
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function delJob(jobId: string | number) {
|
||||
return request({
|
||||
url: `/monitor/job/${jobId}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
export function changeJobStatus(jobId: JobRecord['jobId'], status: string) {
|
||||
const data = {
|
||||
jobId,
|
||||
status,
|
||||
};
|
||||
return request({
|
||||
url: '/monitor/job/changeStatus',
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function runJob(jobId: JobRecord['jobId'], jobGroup: string) {
|
||||
const data = {
|
||||
jobId,
|
||||
jobGroup,
|
||||
};
|
||||
return request({
|
||||
url: '/monitor/job/run',
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import request from '@/utils/request';
|
||||
import type {
|
||||
LogininforListResponse,
|
||||
LogininforQueryParams,
|
||||
} from '@/types/api';
|
||||
|
||||
export function listLogininfor(query: LogininforQueryParams) {
|
||||
return request<LogininforListResponse>({
|
||||
url: '/monitor/logininfor/list',
|
||||
method: 'get',
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
export function delLogininfor(infoId: string | number) {
|
||||
return request({
|
||||
url: `/monitor/logininfor/${infoId}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
export function unlockLogininfor(userName: string) {
|
||||
return request({
|
||||
url: `/monitor/logininfor/unlock/${userName}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanLogininfor() {
|
||||
return request({
|
||||
url: '/monitor/logininfor/clean',
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import request from '@/utils/request';
|
||||
import type { OnlineListResponse, OnlineQueryParams } from '@/types/api';
|
||||
|
||||
export function listOnline(query: OnlineQueryParams) {
|
||||
return request<OnlineListResponse>({
|
||||
url: '/monitor/online/list',
|
||||
method: 'get',
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
export function forceLogout(tokenId: string) {
|
||||
return request({
|
||||
url: `/monitor/online/${tokenId}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import request from '@/utils/request';
|
||||
import type { OperlogListResponse, OperlogQueryParams } from '@/types/api';
|
||||
|
||||
export function listOperlog(query: OperlogQueryParams) {
|
||||
return request<OperlogListResponse>({
|
||||
url: '/monitor/operlog/list',
|
||||
method: 'get',
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
export function delOperlog(operId: string | number) {
|
||||
return request({
|
||||
url: `/monitor/operlog/${operId}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanOperlog() {
|
||||
return request({
|
||||
url: '/monitor/operlog/clean',
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import request from '@/utils/request';
|
||||
import type { ServerInfoResponse } from '@/types/api';
|
||||
|
||||
export function getServerInfo() {
|
||||
return request<ServerInfoResponse>({
|
||||
url: '/monitor/server',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import request from '@/utils/request';
|
||||
|
||||
export interface RouterNode {
|
||||
path?: string;
|
||||
hidden?: boolean;
|
||||
alwaysShow?: boolean;
|
||||
component?: string;
|
||||
name?: string;
|
||||
meta?: {
|
||||
title?: string;
|
||||
icon?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
children?: RouterNode[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UserInfoResponse {
|
||||
user?: Record<string, unknown>;
|
||||
roles?: Array<string | Record<string, unknown>>;
|
||||
permissions?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function getInfo() {
|
||||
return request<UserInfoResponse>({
|
||||
url: '/getInfo',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
export function getRouters() {
|
||||
return request<RouterNode[]>({
|
||||
url: '/getRouters',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
import request from '@/utils/request';
|
||||
|
||||
// ==========================================================
|
||||
// Project related functions, from api.js
|
||||
// ==========================================================
|
||||
|
||||
// 查询项目列表
|
||||
export function listProject(query: any) {
|
||||
return request({
|
||||
url: '/business/project/list',
|
||||
method: 'get',
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除项目
|
||||
export function deleteProject(id: string | number) {
|
||||
return request({
|
||||
url: `/business/project/${id}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
// 新增项目
|
||||
export function addProject(data: any) {
|
||||
return request({
|
||||
url: '/business/project/add',
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 修改项目
|
||||
export function updateProject(data: any) {
|
||||
return request({
|
||||
url: '/business/project/update',
|
||||
method: 'put',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取项目编号
|
||||
export function getProjectCode() {
|
||||
return request({
|
||||
url: '/business/project/getCode',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 获取项目详情
|
||||
export function getProjectDetail(id: string | number) {
|
||||
return request({
|
||||
url: `/business/project/info/${id}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 获取项目成员
|
||||
export function getProjectUser(id: string | number) {
|
||||
return request({
|
||||
url: `/business/project/${id}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 更新项目成员
|
||||
export function updateProjectUser(data: any) {
|
||||
return request({
|
||||
url: '/business/project/team',
|
||||
method: 'POST',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除项目成员
|
||||
export function deleteProjectUser(id: string | number) {
|
||||
return request({
|
||||
url: `/business/project/team/${id}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
// 检查项目是否有日志数据
|
||||
export function projectHasLogData(data: any) {
|
||||
return request({
|
||||
url: `/business/project/updateCheck`,
|
||||
method: 'POST',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// Demand & Version APIs (provided by user)
|
||||
// ==========================================================
|
||||
|
||||
// 需求列表
|
||||
export function listDemand(query: any) {
|
||||
return request({
|
||||
url: '/demand/list',
|
||||
method: 'get',
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
// 需求编辑
|
||||
export function updateDemand(data: any) {
|
||||
return request({
|
||||
url: '/demand/update',
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 新增需求
|
||||
export function insertDemand(data: any) {
|
||||
return request({
|
||||
url: '/demand/insert',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除需求
|
||||
export function deleteDemand(id: string | number) {
|
||||
return request({
|
||||
url: `/demand/${id}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
// 批量删除需求
|
||||
export function deleteDemandBatch(ids: string) {
|
||||
return request({
|
||||
url: `/demand/remove/batch/${ids}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
// 需求详情
|
||||
export function getDemandDetail(id: string | number) {
|
||||
return request({
|
||||
url: `/demand/${id}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 新增版本
|
||||
export function insertProjectVersion(data: any) {
|
||||
return request({
|
||||
url: '/projectVersion/insert',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 编辑版本
|
||||
export function updateProjectVersion(data: any) {
|
||||
return request({
|
||||
url: '/projectVersion/update',
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除版本
|
||||
export function deleteProjectVersion(id: string | number) {
|
||||
return request({
|
||||
url: `/projectVersion/${id}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
// 版本树/版本列表
|
||||
export function listProjectVersionTree(projectId: string | number) {
|
||||
return request({
|
||||
url: '/projectVersion/tree',
|
||||
method: 'get',
|
||||
params: { projectId },
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import request from '@/utils/request';
|
||||
|
||||
export function listProjectExecution(query: Record<string, unknown>) {
|
||||
return request({
|
||||
url: '/projectBank/projectProgress/list',
|
||||
method: 'get',
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
export function getProjectExecutionInfo(data: Record<string, unknown>) {
|
||||
return request({
|
||||
url: '/business/project/executionInfo',
|
||||
method: 'post',
|
||||
data,
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export function getProjectWorkInfo(data: Record<string, unknown>) {
|
||||
return request({
|
||||
url: '/business/project/workInfo',
|
||||
method: 'post',
|
||||
data,
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import request from '@/utils/request';
|
||||
|
||||
const LIST_FALLBACKS = ['/business/project/scoreInfo', '/business/project/userScore'];
|
||||
const DETAIL_FALLBACKS = ['/business/project/scoreDetail', '/business/project/userScoreDetail'];
|
||||
|
||||
const callWithFallback = async <T>(
|
||||
urls: string[],
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<T> => {
|
||||
let lastError: unknown;
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
return await request<T>({
|
||||
url,
|
||||
method: 'post',
|
||||
data: payload,
|
||||
timeout: 20000,
|
||||
});
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
try {
|
||||
return await request<T>({
|
||||
url,
|
||||
method: 'get',
|
||||
params: payload,
|
||||
timeout: 20000,
|
||||
});
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error('接口请求失败');
|
||||
};
|
||||
|
||||
export function getProjectUserScoreList(payload: Record<string, unknown>) {
|
||||
return callWithFallback(LIST_FALLBACKS, payload);
|
||||
}
|
||||
|
||||
export function getProjectUserScoreDetail(payload: Record<string, unknown>) {
|
||||
return callWithFallback(DETAIL_FALLBACKS, payload);
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import request from '@/utils/request';
|
||||
|
||||
// 查询参数列表
|
||||
export function listConfig(query: any) {
|
||||
return request({
|
||||
url: '/system/config/list',
|
||||
method: 'get',
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
// 查询参数详细
|
||||
export function getConfig(configId: string | number) {
|
||||
return request({
|
||||
url: '/system/config/' + configId,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 根据参数键名查询参数值
|
||||
export function getConfigKey(configKey: string) {
|
||||
return request({
|
||||
url: '/system/config/configKey/' + configKey,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 新增参数配置
|
||||
export function addConfig(data: any) {
|
||||
return request({
|
||||
url: '/system/config',
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 修改参数配置
|
||||
export function updateConfig(data: any) {
|
||||
return request({
|
||||
url: '/system/config',
|
||||
method: 'put',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除参数配置
|
||||
export function delConfig(configId: string | number) {
|
||||
return request({
|
||||
url: '/system/config/' + configId,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
// 刷新参数缓存
|
||||
export function refreshCache() {
|
||||
return request({
|
||||
url: '/system/config/refreshCache',
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import request from '@/utils/request';
|
||||
|
||||
// 查询部门列表
|
||||
export function listDept(query?: any) {
|
||||
return request({
|
||||
url: '/system/dept/list',
|
||||
method: 'get',
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
// 查询部门列表(排除节点)
|
||||
export function listDeptExcludeChild(deptId: string | number) {
|
||||
return request({
|
||||
url: '/system/dept/list/exclude/' + deptId,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 查询部门详细
|
||||
export function getDept(deptId: string | number) {
|
||||
return request({
|
||||
url: '/system/dept/' + deptId,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 新增部门
|
||||
export function addDept(data: any) {
|
||||
return request({
|
||||
url: '/system/dept',
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 修改部门
|
||||
export function updateDept(data: any) {
|
||||
return request({
|
||||
url: '/system/dept',
|
||||
method: 'put',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除部门
|
||||
export function delDept(deptId: string | number) {
|
||||
return request({
|
||||
url: '/system/dept/' + deptId,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import request from '@/utils/request';
|
||||
|
||||
// 查询字典类型列表
|
||||
export function listDictType(query: any) {
|
||||
return request({
|
||||
url: '/system/dict/type/list',
|
||||
method: 'get',
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
// 查询字典类型详细
|
||||
export function getDictType(dictId: string | number) {
|
||||
return request({
|
||||
url: '/system/dict/type/' + dictId,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 新增字典类型
|
||||
export function addDictType(data: any) {
|
||||
return request({
|
||||
url: '/system/dict/type',
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 修改字典类型
|
||||
export function updateDictType(data: any) {
|
||||
return request({
|
||||
url: '/system/dict/type',
|
||||
method: 'put',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除字典类型
|
||||
export function delDictType(dictId: string | number) {
|
||||
return request({
|
||||
url: '/system/dict/type/' + dictId,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
// 刷新字典缓存
|
||||
export function refreshCache() {
|
||||
return request({
|
||||
url: '/system/dict/type/refreshCache',
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
// 获取字典选择框列表
|
||||
export function optionselect() {
|
||||
return request({
|
||||
url: '/system/dict/type/optionselect',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// DictData related functions, from api/system/dict/data.js
|
||||
// ==========================================================
|
||||
|
||||
// 查询字典数据列表
|
||||
export function listDictData(query: any) {
|
||||
return request({
|
||||
url: '/system/dict/data/list',
|
||||
method: 'get',
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
// 查询字典数据详细
|
||||
export function getDictData(dictCode: string | number) {
|
||||
return request({
|
||||
url: '/system/dict/data/' + dictCode,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 根据字典类型查询字典数据信息
|
||||
export function getDicts(dictType: string) {
|
||||
return request({
|
||||
url: '/system/dict/data/type/' + dictType,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 新增字典数据
|
||||
export function addDictData(data: any) {
|
||||
return request({
|
||||
url: '/system/dict/data',
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 修改字典数据
|
||||
export function updateDictData(data: any) {
|
||||
return request({
|
||||
url: '/system/dict/data',
|
||||
method: 'put',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除字典数据
|
||||
export function delDictData(dictCode: string | number) {
|
||||
return request({
|
||||
url: '/system/dict/data/' + dictCode,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import request from '@/utils/request';
|
||||
|
||||
// 查询菜单列表
|
||||
export function listMenu(query: any) {
|
||||
return request({
|
||||
url: '/system/menu/list',
|
||||
method: 'get',
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
// 查询菜单详细
|
||||
export function getMenu(menuId: string | number) {
|
||||
return request({
|
||||
url: '/system/menu/' + menuId,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 查询菜单下拉树结构
|
||||
export function treeselect() {
|
||||
return request({
|
||||
url: '/system/menu/treeselect',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 根据角色ID查询菜单下拉树结构
|
||||
export function roleMenuTreeselect(roleId: string | number) {
|
||||
return request({
|
||||
url: '/system/menu/roleMenuTreeselect/' + roleId,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 新增菜单
|
||||
export function addMenu(data: any) {
|
||||
return request({
|
||||
url: '/system/menu',
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 修改菜单
|
||||
export function updateMenu(data: any) {
|
||||
return request({
|
||||
url: '/system/menu',
|
||||
method: 'put',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除菜单
|
||||
export function delMenu(menuId: string | number) {
|
||||
return request({
|
||||
url: '/system/menu/' + menuId,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import request from '@/utils/request';
|
||||
|
||||
// 查询角色列表
|
||||
export function listRole(query: any) {
|
||||
return request({
|
||||
url: '/system/role/list',
|
||||
method: 'get',
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
// 查询角色详细
|
||||
export function getRole(roleId: string | number) {
|
||||
return request({
|
||||
url: '/system/role/' + roleId,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 新增角色
|
||||
export function addRole(data: any) {
|
||||
return request({
|
||||
url: '/system/role',
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 修改角色
|
||||
export function updateRole(data: any) {
|
||||
return request({
|
||||
url: '/system/role',
|
||||
method: 'put',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 角色数据权限
|
||||
export function dataScope(data: any) {
|
||||
return request({
|
||||
url: '/system/role/dataScope',
|
||||
method: 'put',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 角色状态修改
|
||||
export function changeRoleStatus(roleId: string | number, status: string) {
|
||||
const data = {
|
||||
roleId,
|
||||
status,
|
||||
};
|
||||
return request({
|
||||
url: '/system/role/changeStatus',
|
||||
method: 'put',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除角色
|
||||
export function delRole(roleId: string | number) {
|
||||
return request({
|
||||
url: '/system/role/' + roleId,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
// 查询角色已授权用户列表
|
||||
export function allocatedUserList(query: any) {
|
||||
return request({
|
||||
url: '/system/role/authUser/allocatedList',
|
||||
method: 'get',
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
// 查询角色未授权用户列表
|
||||
export function unallocatedUserList(query: any) {
|
||||
return request({
|
||||
url: '/system/role/authUser/unallocatedList',
|
||||
method: 'get',
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
// 取消用户授权角色
|
||||
export function authUserCancel(data: any) {
|
||||
return request({
|
||||
url: '/system/role/authUser/cancel',
|
||||
method: 'put',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 批量取消用户授权角色
|
||||
export function authUserCancelAll(data: any) {
|
||||
return request({
|
||||
url: '/system/role/authUser/cancelAll',
|
||||
method: 'put',
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 授权用户选择
|
||||
export function authUserSelectAll(data: any) {
|
||||
return request({
|
||||
url: '/system/role/authUser/selectAll',
|
||||
method: 'put',
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 根据角色ID查询部门树结构
|
||||
export function deptTreeSelect(roleId: string | number) {
|
||||
return request({
|
||||
url: '/system/role/deptTree/' + roleId,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
import request from '@/utils/request';
|
||||
import { parseStrEmpty } from '@/utils/ruoyi';
|
||||
|
||||
// 查询用户列表
|
||||
export function listUser(query: any) {
|
||||
return request({
|
||||
url: '/system/user/list',
|
||||
method: 'get',
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
// 查询用户详细
|
||||
export function getUser(userId: string | number) {
|
||||
return request({
|
||||
url: '/system/user/' + parseStrEmpty(userId),
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 新增用户
|
||||
export function addUser(data: any) {
|
||||
return request({
|
||||
url: '/system/user',
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 修改用户
|
||||
export function updateUser(data: any) {
|
||||
return request({
|
||||
url: '/system/user',
|
||||
method: 'put',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
export function delUser(userId: string | number) {
|
||||
return request({
|
||||
url: '/system/user/' + userId,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
// 用户密码重置
|
||||
export function resetUserPwd(userId: string | number, password: string) {
|
||||
const data = {
|
||||
userId,
|
||||
password,
|
||||
};
|
||||
return request({
|
||||
url: '/system/user/resetPwd',
|
||||
method: 'put',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 用户状态修改
|
||||
export function changeUserStatus(userId: string | number, status: string) {
|
||||
const data = {
|
||||
userId,
|
||||
status,
|
||||
};
|
||||
return request({
|
||||
url: '/system/user/changeStatus',
|
||||
method: 'put',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 查询用户个人信息
|
||||
export function getUserProfile() {
|
||||
return request({
|
||||
url: '/system/user/profile',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 修改用户个人信息
|
||||
export function updateUserProfile(data: any) {
|
||||
return request({
|
||||
url: '/system/user/profile',
|
||||
method: 'put',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 用户密码重置 (Profile page specific)
|
||||
export function updateUserPwd(oldPassword: string, newPassword: string) {
|
||||
const data = {
|
||||
oldPassword,
|
||||
newPassword,
|
||||
};
|
||||
return request({
|
||||
url: '/system/user/profile/updatePwd',
|
||||
method: 'put',
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 用户头像上传
|
||||
export function uploadAvatar(data: any) {
|
||||
return request({
|
||||
url: '/system/user/profile/avatar',
|
||||
method: 'post',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 查询授权角色
|
||||
export function getAuthRole(userId: string | number) {
|
||||
return request({
|
||||
url: '/system/user/authRole/' + userId,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 保存授权角色
|
||||
export function updateAuthRole(data: any) {
|
||||
return request({
|
||||
url: '/system/user/authRole',
|
||||
method: 'put',
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 查询部门下拉树结构
|
||||
export function deptTreeSelect() {
|
||||
return request({
|
||||
url: '/system/user/deptTree',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import request from '@/utils/request';
|
||||
import type {
|
||||
UpdateUserProfilePayload,
|
||||
UserProfileResponse,
|
||||
} from '@/types/api';
|
||||
|
||||
export function getUserProfile() {
|
||||
return request<UserProfileResponse>({
|
||||
url: '/system/user/profile',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
export function updateUserProfile(data: UpdateUserProfilePayload) {
|
||||
return request<unknown, UpdateUserProfilePayload>({
|
||||
url: '/system/user/profile',
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateUserPwd(oldPassword: string, newPassword: string) {
|
||||
const data = {
|
||||
oldPassword,
|
||||
newPassword,
|
||||
};
|
||||
return request({
|
||||
url: '/system/user/profile/updatePwd',
|
||||
method: 'put',
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import request from '@/utils/request';
|
||||
|
||||
// ==========================================================
|
||||
// Work Log related functions, from api.js -> workLogApi
|
||||
// ==========================================================
|
||||
|
||||
// 获取用户项目列表
|
||||
export function userProject(userId: string | number) {
|
||||
return request({
|
||||
url: `/business/work/hour/project/${userId}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 获取日志数据
|
||||
export function getLogData(query: any) {
|
||||
return request({
|
||||
url: '/business/work/hour/calendar',
|
||||
method: 'post',
|
||||
data: query,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取日志详情
|
||||
export function getLogDataDetail(query: any) {
|
||||
return request({
|
||||
url: '/business/work/hour/getInfo',
|
||||
method: 'post',
|
||||
data: query,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取当天可用工时
|
||||
export function getDayTime(query: any) {
|
||||
return request({
|
||||
url: '/business/work/hour/remaining',
|
||||
method: 'post',
|
||||
data: query,
|
||||
});
|
||||
}
|
||||
|
||||
// 新增日志
|
||||
export function addLog(data: any) {
|
||||
return request({
|
||||
url: '/business/work/hour/add',
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 编辑日志
|
||||
export function editLog(data: any) {
|
||||
return request({
|
||||
url: '/business/work/hour/update',
|
||||
method: 'put',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除日志
|
||||
export function delLog(id: string | number) {
|
||||
return request({
|
||||
url: `/business/work/hour/${id}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
// 工作日志版本树(用于日志录入时选择版本/需求)
|
||||
export function getVersionTreeForWorklog(params: any) {
|
||||
return request({
|
||||
url: '/projectVersion/tree',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除项目附件
|
||||
export function deleteProjectFile(id: string | number) {
|
||||
return request({
|
||||
url: `/business/project/file/${id}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
// 批量删除项目附件
|
||||
export function deleteProjectFileBatch(ids: string) {
|
||||
return request({
|
||||
url: `/business/project/file/batch/${ids}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
<base64_decode_as_bytes>g/9j/4AAQSkZJRgABAQEASABIAAD/4QAiRXhpZgAASUkqAAgAAAABABIBAwABAAAABgASAAAAAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEAAQBIAAAAAQABOEJJTQQGAAAAAAAEAAAAAjhCSU0EAgAAAAAABAAAAAD/4Q/gaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjYtYzE0MCA3OS4xNjA0NTEsIDIwMTcvMDUvMDYtMDE6MDg6MjEgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1sbnM6cGRmPSJodHRwOi8vbnMuYWRvYmUuY29tL3BkZi8xLjMvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTggKFdpbmRvd3MpIiBwZGY6UHJvZHVjZXI9IkNvbnZlcnQgZnJvbSBBcHBsZSBCb29rIHRvIFBERiAoQXBwbGUgSW5jLikgLSBRdWFydHogMi4wIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOkZBNjczQjlCQjNGNDExRTk4NjY0QzZERjhDMzYxNTMyIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOkZBNjczQjlBQjNGNDExRTk4NjY0QzZERjhDMzYxNTMyIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6NzIyQjE1NTM4RjM4MTFFOUExM0Q4RjhBRjE3RTBDRDQiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6NzIyQjE1NTQ4RjM4MTFFOUExM0Q4RjhBRjE3RTBDRDQiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7/4gxYSUNDX1BST0ZJTEUAAQEAAAxITGlubwIQAABtbnRyUkdCIFhZWiAOGAAEABgAGgAfABdhY3NwTVNGVAAAAABJRUMgc1JHQgAAAAAAAAAAAAAAAAAA9tYAAQAAAADTLUhQICAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABFjcHJ0AAABUAAAADNkZXNjAAABhAAAAGZ3dHB0AAABwAAAABRia3B0AAAB1AAAABRyWFlaAAAB7AAAABRnWFlaAAACAAAAABRiWFlaAAACFAAAABRyVFJDAAACGAAQAMBjaHJtAAACNAAAACRkbW5kAAACWAAAACRkbWRkAAACfAAAACRsdW1pAAACjAAAAA埬</base64_decode_as_bytes>
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
|
@ -0,0 +1,45 @@
|
|||
import React from 'react';
|
||||
import { Button, Space } from 'antd';
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { removeToken } from '../../utils/auth';
|
||||
import { usePermission } from '@/contexts/PermissionContext';
|
||||
|
||||
interface AppNavbarProps {
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
const AppNavbar: React.FC<AppNavbarProps> = ({ collapsed, onToggle }) => {
|
||||
const navigate = useNavigate();
|
||||
const { userName } = usePermission();
|
||||
|
||||
const handleLogout = () => {
|
||||
removeToken();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingRight: '20px' }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
width: 64,
|
||||
height: 64,
|
||||
}}
|
||||
/>
|
||||
<Space style={{ marginLeft: 'auto' }}>
|
||||
<Button type="text" icon={<UserOutlined />} onClick={() => navigate('/profile')}>
|
||||
{userName || '用户'}
|
||||
</Button>
|
||||
<Button type="link" onClick={handleLogout}>
|
||||
退出登录
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppNavbar;
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import { Button } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface PageBackButtonProps {
|
||||
text?: string;
|
||||
fallbackPath?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PageBackButton = ({ text = '返回', fallbackPath = '/', className }: PageBackButtonProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
navigate(fallbackPath);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={handleBack} className={className}>
|
||||
{text}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageBackButton;
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import React from 'react';
|
||||
import { usePermission } from '@/contexts/PermissionContext';
|
||||
|
||||
interface PermissionProps {
|
||||
permissions?: string | string[];
|
||||
roles?: string | string[];
|
||||
mode?: 'and' | 'or';
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Permission: React.FC<PermissionProps> = ({
|
||||
permissions,
|
||||
roles,
|
||||
mode = 'or',
|
||||
children,
|
||||
fallback = null,
|
||||
}) => {
|
||||
const { hasPermi, hasRole } = usePermission();
|
||||
|
||||
const permiPassed = permissions ? hasPermi(permissions) : true;
|
||||
const rolePassed = roles ? hasRole(roles) : true;
|
||||
|
||||
let passed = true;
|
||||
if (permissions && roles) {
|
||||
passed = mode === 'and' ? permiPassed && rolePassed : permiPassed || rolePassed;
|
||||
} else if (permissions) {
|
||||
passed = permiPassed;
|
||||
} else if (roles) {
|
||||
passed = rolePassed;
|
||||
}
|
||||
|
||||
if (!passed) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default Permission;
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
import React from 'react';
|
||||
import { Menu } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
AreaChartOutlined,
|
||||
AuditOutlined,
|
||||
BarChartOutlined,
|
||||
BookOutlined,
|
||||
CarryOutOutlined,
|
||||
ClockCircleOutlined,
|
||||
CodeSandboxOutlined,
|
||||
ControlOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
DesktopOutlined,
|
||||
EditOutlined,
|
||||
FileTextOutlined,
|
||||
LockOutlined,
|
||||
MonitorOutlined,
|
||||
PieChartOutlined,
|
||||
ProjectOutlined,
|
||||
SettingOutlined,
|
||||
TableOutlined,
|
||||
TeamOutlined,
|
||||
ToolOutlined,
|
||||
UnorderedListOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { usePermission } from '@/contexts/PermissionContext';
|
||||
import type { RouterNode } from '@/api/permission';
|
||||
|
||||
type MenuItem = Required<MenuProps>['items'][number];
|
||||
|
||||
const BACKEND_PATH_TO_APP_PATH: Record<string, string> = {
|
||||
'/project': '/project/list',
|
||||
'/projectBank/userScore': '/workAppraisal/myPerformance',
|
||||
};
|
||||
|
||||
const MENU_ROUTE_ALIASES: Record<string, string[]> = {
|
||||
'/projectBank/projectProgress': ['/dashboard/project-execution'],
|
||||
'/workAppraisal/myPerformance': ['/projectBank/userScore'],
|
||||
};
|
||||
|
||||
const ICON_MAP: Record<string, React.ReactNode> = {
|
||||
edit: <EditOutlined />,
|
||||
chart: <PieChartOutlined />,
|
||||
table: <TableOutlined />,
|
||||
peoples: <TeamOutlined />,
|
||||
dashboard: <DashboardOutlined />,
|
||||
tab: <PieChartOutlined />,
|
||||
excel: <ProjectOutlined />,
|
||||
documentation: <ToolOutlined />,
|
||||
build: <CarryOutOutlined />,
|
||||
log: <AuditOutlined />,
|
||||
druid: <BarChartOutlined />,
|
||||
system: <SettingOutlined />,
|
||||
user: <UserOutlined />,
|
||||
tree: <ControlOutlined />,
|
||||
'tree-table': <AppstoreOutlined />,
|
||||
dict: <BookOutlined />,
|
||||
monitor: <MonitorOutlined />,
|
||||
time: <ClockCircleOutlined />,
|
||||
job: <ClockCircleOutlined />,
|
||||
online: <TeamOutlined />,
|
||||
server: <DesktopOutlined />,
|
||||
cache: <AreaChartOutlined />,
|
||||
form: <FileTextOutlined />,
|
||||
documentation2: <FileTextOutlined />,
|
||||
};
|
||||
|
||||
const FALLBACK_ICON_BY_PATH: Array<{ prefix: string; icon: React.ReactNode }> = [
|
||||
{ prefix: '/index', icon: <DashboardOutlined /> },
|
||||
{ prefix: '/monitor/online', icon: <TeamOutlined /> },
|
||||
{ prefix: '/monitor/job', icon: <ClockCircleOutlined /> },
|
||||
{ prefix: '/monitor/cache', icon: <AreaChartOutlined /> },
|
||||
{ prefix: '/monitor/server', icon: <DesktopOutlined /> },
|
||||
{ prefix: '/monitor/cacheList', icon: <DatabaseOutlined /> },
|
||||
{ prefix: '/monitor/logininfor', icon: <UserOutlined /> },
|
||||
{ prefix: '/monitor/operlog', icon: <FileTextOutlined /> },
|
||||
{ prefix: '/system/user', icon: <UserOutlined /> },
|
||||
{ prefix: '/system/role', icon: <LockOutlined /> },
|
||||
{ prefix: '/system/menu', icon: <AppstoreOutlined /> },
|
||||
{ prefix: '/system/dept', icon: <ControlOutlined /> },
|
||||
{ prefix: '/system/dict', icon: <BookOutlined /> },
|
||||
{ prefix: '/system/config', icon: <ControlOutlined /> },
|
||||
{ prefix: '/project/list', icon: <UnorderedListOutlined /> },
|
||||
{ prefix: '/projectBank/projectProgress', icon: <TableOutlined /> },
|
||||
{ prefix: '/projectBank/projectUser', icon: <TeamOutlined /> },
|
||||
{ prefix: '/projectBank/userProject', icon: <UnorderedListOutlined /> },
|
||||
{ prefix: '/workAppraisal/myPerformance', icon: <PieChartOutlined /> },
|
||||
{ prefix: '/workAppraisal/manager', icon: <CarryOutOutlined /> },
|
||||
{ prefix: '/workAppraisal/normalWorker', icon: <AuditOutlined /> },
|
||||
{ prefix: '/workAppraisal/taskSet', icon: <ToolOutlined /> },
|
||||
{ prefix: '/workAppraisal/taskModule', icon: <BarChartOutlined /> },
|
||||
];
|
||||
|
||||
const normalizePath = (value: string) => {
|
||||
if (!value) {
|
||||
return '/';
|
||||
}
|
||||
const path = value.startsWith('/') ? value : `/${value}`;
|
||||
return path.replace(/\/{2,}/g, '/').replace(/\/$/, '') || '/';
|
||||
};
|
||||
|
||||
const joinPath = (base: string, child: string) => {
|
||||
if (!child) {
|
||||
return normalizePath(base);
|
||||
}
|
||||
if (child.startsWith('/')) {
|
||||
return normalizePath(child);
|
||||
}
|
||||
if (base === '/' || !base) {
|
||||
return normalizePath(`/${child}`);
|
||||
}
|
||||
return normalizePath(`${base}/${child}`);
|
||||
};
|
||||
|
||||
const resolveAppPath = (fullBackendPath: string, node: RouterNode) => {
|
||||
const normalized = normalizePath(fullBackendPath);
|
||||
if (normalized === '/index' || String(node.component ?? '').includes('worklog')) {
|
||||
return '/index';
|
||||
}
|
||||
if (normalized === '/project' || String(node.component ?? '') === 'project/list') {
|
||||
return '/project/list';
|
||||
}
|
||||
return BACKEND_PATH_TO_APP_PATH[normalized] ?? normalized;
|
||||
};
|
||||
|
||||
const getMenuIcon = (icon?: string, resolvedPath?: string) => {
|
||||
const iconKey = String(icon ?? '').trim();
|
||||
if (iconKey && ICON_MAP[iconKey]) {
|
||||
return ICON_MAP[iconKey];
|
||||
}
|
||||
const fallback = FALLBACK_ICON_BY_PATH.find((item) => resolvedPath === item.prefix || String(resolvedPath).startsWith(`${item.prefix}/`));
|
||||
return fallback?.icon ?? <CodeSandboxOutlined />;
|
||||
};
|
||||
|
||||
const isHiddenRoute = (node: RouterNode) => Boolean(node.hidden);
|
||||
|
||||
const getRouteTitle = (node: RouterNode) => String(node.meta?.title ?? node.name ?? '').trim();
|
||||
|
||||
const buildMenuItems = (
|
||||
nodes: RouterNode[],
|
||||
parentPath: string,
|
||||
canAccessPath: (path: string) => boolean,
|
||||
): MenuItem[] => {
|
||||
return nodes.flatMap((node, index) => {
|
||||
if (isHiddenRoute(node)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rawPath = joinPath(parentPath, String(node.path ?? ''));
|
||||
const resolvedPath = resolveAppPath(rawPath, node);
|
||||
const title = getRouteTitle(node);
|
||||
const children = Array.isArray(node.children) ? buildMenuItems(node.children, rawPath, canAccessPath) : [];
|
||||
|
||||
if (children.length > 0) {
|
||||
if (!node.alwaysShow && children.length === 1 && title) {
|
||||
return children;
|
||||
}
|
||||
if (!title) {
|
||||
return children;
|
||||
}
|
||||
const groupKeySeed = String(node.path ?? node.name ?? resolvedPath ?? index);
|
||||
return [{
|
||||
key: `group_${groupKeySeed}_${index}`,
|
||||
icon: getMenuIcon(String(node.meta?.icon ?? ''), resolvedPath),
|
||||
label: title,
|
||||
children,
|
||||
}];
|
||||
}
|
||||
|
||||
if (!title || !resolvedPath || !canAccessPath(resolvedPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{
|
||||
key: resolvedPath,
|
||||
icon: getMenuIcon(String(node.meta?.icon ?? ''), resolvedPath),
|
||||
label: title,
|
||||
}];
|
||||
});
|
||||
};
|
||||
|
||||
const isRouteKey = (key: React.Key): key is string => typeof key === 'string' && key.startsWith('/');
|
||||
|
||||
const findMatchedPath = (menuItems: MenuItem[], pathname: string): string | null => {
|
||||
for (const item of menuItems) {
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const children = item.children as MenuItem[] | undefined;
|
||||
if (Array.isArray(children) && children.length > 0) {
|
||||
const childHit = findMatchedPath(children, pathname);
|
||||
if (childHit) {
|
||||
return childHit;
|
||||
}
|
||||
}
|
||||
|
||||
if (isRouteKey(item.key)) {
|
||||
const aliases = MENU_ROUTE_ALIASES[item.key] ?? [];
|
||||
if (
|
||||
item.key === pathname ||
|
||||
pathname.startsWith(`${item.key}/`) ||
|
||||
aliases.some((alias) => alias === pathname || pathname.startsWith(`${alias}/`))
|
||||
) {
|
||||
return item.key;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const findParentKeys = (
|
||||
menuItems: MenuItem[],
|
||||
targetKey: string,
|
||||
parentKeys: string[] = [],
|
||||
): string[] | null => {
|
||||
for (const item of menuItems) {
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = String(item.key);
|
||||
if (key === targetKey) {
|
||||
return parentKeys;
|
||||
}
|
||||
|
||||
const children = item.children as MenuItem[] | undefined;
|
||||
if (Array.isArray(children) && children.length > 0) {
|
||||
const found = findParentKeys(children, targetKey, [...parentKeys, key]);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const AppSidebar: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { canAccessPath, routers } = usePermission();
|
||||
|
||||
const visibleItems = React.useMemo(
|
||||
() => buildMenuItems(Array.isArray(routers) ? routers : [], '/', canAccessPath),
|
||||
[canAccessPath, routers],
|
||||
);
|
||||
const selectedKey = React.useMemo(
|
||||
() => findMatchedPath(visibleItems, location.pathname) ?? '/index',
|
||||
[location.pathname, visibleItems],
|
||||
);
|
||||
const openKeys = React.useMemo(
|
||||
() => findParentKeys(visibleItems, selectedKey) ?? [],
|
||||
[selectedKey, visibleItems],
|
||||
);
|
||||
const [manualOpenKeys, setManualOpenKeys] = React.useState<string[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setManualOpenKeys((prev) => Array.from(new Set([...prev, ...openKeys])));
|
||||
}, [openKeys]);
|
||||
|
||||
const onClick: MenuProps['onClick'] = (e) => {
|
||||
navigate(e.key);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
openKeys={manualOpenKeys}
|
||||
onOpenChange={(keys) => setManualOpenKeys(keys as string[])}
|
||||
onClick={onClick}
|
||||
items={visibleItems}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppSidebar;
|
||||
|
|
@ -0,0 +1,334 @@
|
|||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { getInfo, getRouters, type RouterNode } from '@/api/permission';
|
||||
import { getUserProfile } from '@/api/user';
|
||||
import { getToken } from '@/utils/auth';
|
||||
|
||||
interface PermissionContextValue {
|
||||
loading: boolean;
|
||||
ready: boolean;
|
||||
userName: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
routers: RouterNode[];
|
||||
refreshPermissions: () => Promise<void>;
|
||||
hasRole: (roles: string | string[]) => boolean;
|
||||
hasPermi: (permissions: string | string[]) => boolean;
|
||||
canAccessPath: (path: string) => boolean;
|
||||
}
|
||||
|
||||
const PermissionContext = createContext<PermissionContextValue | null>(null);
|
||||
|
||||
const ALWAYS_ALLOW_PATHS = new Set([
|
||||
'/',
|
||||
'/index',
|
||||
'/profile',
|
||||
]);
|
||||
const ROUTE_ALIASES: Record<string, string[]> = {
|
||||
'/demandManage': ['/project/demandManage'],
|
||||
'/workAppraisal/dashboard': ['/workAppraisal/taskModule'],
|
||||
'/dashboard/project-execution': ['/projectBank/projectProgress'],
|
||||
'/projectBank/projectProgress': ['/dashboard/project-execution'],
|
||||
'/projectBank/projectUser': ['/project/detail'],
|
||||
'/workAppraisal/myPerformance': ['/projectBank/userScore'],
|
||||
'/projectBank/userScore': ['/workAppraisal/myPerformance'],
|
||||
'/projectBank/userScoreDetail': ['/workAppraisal/myPerformance'],
|
||||
};
|
||||
const SUPER_PERMI = '*:*:*';
|
||||
const ADMIN_ROLE = 'admin';
|
||||
const ADMIN_ROLE_ALIASES = ['admin', '超级管理员', 'superadmin'];
|
||||
|
||||
const normalizePath = (rawPath: string) => {
|
||||
const path = rawPath.split('?')[0]?.split('#')[0] ?? '';
|
||||
if (!path) {
|
||||
return '/';
|
||||
}
|
||||
const withLeadingSlash = path.startsWith('/') ? path : `/${path}`;
|
||||
const normalized = withLeadingSlash.replace(/\/{2,}/g, '/');
|
||||
if (normalized.length > 1 && normalized.endsWith('/')) {
|
||||
return normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const joinPath = (base: string, child: string) => {
|
||||
const normalizedBase = normalizePath(base);
|
||||
if (!child) {
|
||||
return normalizedBase;
|
||||
}
|
||||
if (child.startsWith('/')) {
|
||||
return normalizePath(child);
|
||||
}
|
||||
if (normalizedBase === '/') {
|
||||
return normalizePath(`/${child}`);
|
||||
}
|
||||
return normalizePath(`${normalizedBase}/${child}`);
|
||||
};
|
||||
|
||||
const parseStringList = (input: unknown) => {
|
||||
if (!Array.isArray(input)) {
|
||||
return [] as string[];
|
||||
}
|
||||
return input
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return item.trim();
|
||||
}
|
||||
if (item && typeof item === 'object') {
|
||||
const role = item as Record<string, unknown>;
|
||||
const roleName = role.roleKey ?? role.roleName ?? role.name ?? '';
|
||||
return String(roleName).trim();
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const extractRouteNodes = (payload: unknown): RouterNode[] => {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload as RouterNode[];
|
||||
}
|
||||
|
||||
if (payload && typeof payload === 'object') {
|
||||
const obj = payload as Record<string, unknown>;
|
||||
const candidates = [obj.routes, obj.routers, obj.menus, obj.data, obj.children];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (Array.isArray(candidate)) {
|
||||
return candidate as RouterNode[];
|
||||
}
|
||||
if (candidate && typeof candidate === 'object') {
|
||||
const nested = extractRouteNodes(candidate);
|
||||
if (nested.length > 0) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const isAdminRoleName = (role: string) => {
|
||||
const roleText = role.trim().toLowerCase();
|
||||
return ADMIN_ROLE_ALIASES.some((alias) => roleText === alias.toLowerCase());
|
||||
};
|
||||
|
||||
const flattenRouterPaths = (routes: RouterNode[]) => {
|
||||
const pathSet = new Set<string>();
|
||||
|
||||
const walk = (nodes: RouterNode[], parentPath: string) => {
|
||||
nodes.forEach((node) => {
|
||||
const currentPath = joinPath(parentPath, String(node.path ?? ''));
|
||||
if (currentPath && currentPath !== '/') {
|
||||
pathSet.add(currentPath);
|
||||
}
|
||||
if (Array.isArray(node.children) && node.children.length > 0) {
|
||||
walk(node.children, currentPath);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
walk(routes, '/');
|
||||
return pathSet;
|
||||
};
|
||||
|
||||
const matchPathPattern = (allowedPath: string, actualPath: string) => {
|
||||
if (allowedPath === actualPath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (allowedPath.includes(':')) {
|
||||
const allowedParts = allowedPath.split('/').filter(Boolean);
|
||||
const actualParts = actualPath.split('/').filter(Boolean);
|
||||
if (allowedParts.length !== actualParts.length) {
|
||||
return false;
|
||||
}
|
||||
return allowedParts.every((part, index) => part.startsWith(':') || part === actualParts[index]);
|
||||
}
|
||||
|
||||
return actualPath.startsWith(`${allowedPath}/`);
|
||||
};
|
||||
|
||||
export const PermissionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [userName, setUserName] = useState('');
|
||||
const [roles, setRoles] = useState<string[]>([]);
|
||||
const [permissions, setPermissions] = useState<string[]>([]);
|
||||
const [routers, setRouters] = useState<RouterNode[]>([]);
|
||||
const [allowedPaths, setAllowedPaths] = useState<Set<string>>(new Set());
|
||||
const [routeGuardEnabled, setRouteGuardEnabled] = useState(false);
|
||||
|
||||
const clearPermissionState = useCallback(() => {
|
||||
setUserName('');
|
||||
setRoles([]);
|
||||
setPermissions([]);
|
||||
setRouters([]);
|
||||
setAllowedPaths(new Set());
|
||||
setRouteGuardEnabled(false);
|
||||
}, []);
|
||||
|
||||
const refreshPermissions = useCallback(async () => {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
clearPermissionState();
|
||||
setReady(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setReady(false);
|
||||
|
||||
let nextUserName = '';
|
||||
let nextRoles: string[] = [];
|
||||
let nextPermissions: string[] = [];
|
||||
let nextRouters: RouterNode[] = [];
|
||||
let nextAllowedPaths = new Set<string>();
|
||||
let nextRouteGuardEnabled = false;
|
||||
|
||||
try {
|
||||
try {
|
||||
const info = await getInfo();
|
||||
nextRoles = parseStringList(info.roles);
|
||||
nextPermissions = parseStringList(info.permissions);
|
||||
nextUserName = String((info.user as Record<string, unknown> | undefined)?.userName ?? '');
|
||||
} catch (error) {
|
||||
// Fallback for environments where /getInfo is not available yet.
|
||||
const profile = await getUserProfile();
|
||||
nextUserName = String(profile.user?.userName ?? '');
|
||||
const roleGroup = String((profile as Record<string, unknown>).roleGroup ?? '');
|
||||
nextRoles = roleGroup
|
||||
.split(',')
|
||||
.map((role) => role.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
try {
|
||||
const routersRaw = await getRouters();
|
||||
const routes = extractRouteNodes(routersRaw);
|
||||
nextRouters = routes;
|
||||
nextAllowedPaths = flattenRouterPaths(routes);
|
||||
nextRouteGuardEnabled = true;
|
||||
} catch (routerError) {
|
||||
console.error('Failed to load router permission data:', routerError);
|
||||
const isAdminUser = nextRoles.some((role) => isAdminRoleName(role));
|
||||
nextAllowedPaths = new Set();
|
||||
nextRouteGuardEnabled = !isAdminUser;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load permission data:', error);
|
||||
message.error('加载权限信息失败');
|
||||
clearPermissionState();
|
||||
} finally {
|
||||
setUserName(nextUserName);
|
||||
setRoles(nextRoles);
|
||||
setPermissions(nextPermissions);
|
||||
setRouters(nextRouters);
|
||||
setAllowedPaths(nextAllowedPaths);
|
||||
setRouteGuardEnabled(nextRouteGuardEnabled);
|
||||
setLoading(false);
|
||||
setReady(true);
|
||||
}
|
||||
}, [clearPermissionState]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshPermissions();
|
||||
}, [refreshPermissions]);
|
||||
|
||||
const roleSet = useMemo(() => new Set(roles), [roles]);
|
||||
const permissionSet = useMemo(() => new Set(permissions), [permissions]);
|
||||
|
||||
const isAdmin = useCallback(
|
||||
() => roleSet.has(ADMIN_ROLE) || Array.from(roleSet).some((role) => isAdminRoleName(role)),
|
||||
[roleSet],
|
||||
);
|
||||
|
||||
const hasRole = useCallback(
|
||||
(required: string | string[]) => {
|
||||
if (isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
const targets = Array.isArray(required) ? required : [required];
|
||||
return targets.some((item) => roleSet.has(item));
|
||||
},
|
||||
[isAdmin, roleSet],
|
||||
);
|
||||
|
||||
const hasPermi = useCallback(
|
||||
(required: string | string[]) => {
|
||||
if (isAdmin() || permissionSet.has(SUPER_PERMI)) {
|
||||
return true;
|
||||
}
|
||||
const targets = Array.isArray(required) ? required : [required];
|
||||
return targets.some((item) => permissionSet.has(item));
|
||||
},
|
||||
[isAdmin, permissionSet],
|
||||
);
|
||||
|
||||
const canAccessPath = useCallback(
|
||||
(path: string) => {
|
||||
const normalizedPath = normalizePath(path);
|
||||
if (ALWAYS_ALLOW_PATHS.has(normalizedPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!routeGuardEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (allowedPaths.has(normalizedPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const aliasTargets = ROUTE_ALIASES[normalizedPath] ?? [];
|
||||
if (aliasTargets.some((aliasPath) => allowedPaths.has(aliasPath))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedPath === '/' && allowedPaths.has('/index')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const item of allowedPaths) {
|
||||
if (matchPathPattern(item, normalizedPath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[allowedPaths, isAdmin, permissionSet, roleSet, routeGuardEnabled],
|
||||
);
|
||||
|
||||
const value = useMemo<PermissionContextValue>(
|
||||
() => ({
|
||||
loading,
|
||||
ready,
|
||||
userName,
|
||||
roles,
|
||||
permissions,
|
||||
routers,
|
||||
refreshPermissions,
|
||||
hasRole,
|
||||
hasPermi,
|
||||
canAccessPath,
|
||||
}),
|
||||
[loading, ready, userName, roles, permissions, routers, refreshPermissions, hasRole, hasPermi, canAccessPath],
|
||||
);
|
||||
|
||||
return <PermissionContext.Provider value={value}>{children}</PermissionContext.Provider>;
|
||||
};
|
||||
|
||||
export const usePermission = () => {
|
||||
const context = useContext(PermissionContext);
|
||||
if (!context) {
|
||||
throw new Error('usePermission must be used within PermissionProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Layout, theme } from 'antd';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import AppSidebar from '../components/Sidebar/index';
|
||||
import AppNavbar from '../components/Navbar';
|
||||
import './layout.css';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
const MainLayout: React.FC = () => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const {
|
||||
token: { colorBgContainer, borderRadiusLG },
|
||||
} = theme.useToken();
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider trigger={null} collapsible collapsed={collapsed}>
|
||||
<div className="logo" />
|
||||
<AppSidebar />
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header style={{ padding: 0, background: colorBgContainer }}>
|
||||
<AppNavbar collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
|
||||
</Header>
|
||||
<Content
|
||||
style={{
|
||||
margin: '24px 16px',
|
||||
padding: 24,
|
||||
minHeight: 280,
|
||||
background: colorBgContainer,
|
||||
borderRadius: borderRadiusLG,
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
.logo {
|
||||
height: 32px;
|
||||
margin: 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
text-align: center;
|
||||
line-height: 32px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import 'antd/dist/reset.css';
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
.home-container {
|
||||
font-family: "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
color: #676a6c;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.home-container .notification {
|
||||
padding: 10px 20px;
|
||||
margin: 0 0 20px;
|
||||
font-size: 14px;
|
||||
border-left: 5px solid #eee;
|
||||
}
|
||||
|
||||
.home-container ul {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.home-container h4 {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.home-container h2 {
|
||||
margin-top: 10px;
|
||||
font-size: 26px;
|
||||
font-weight: 100;
|
||||
}
|
||||
|
||||
.home-container p {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.home-container .ant-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { Row, Col, Typography, Button, Tag, Divider, Card, Collapse } from 'antd';
|
||||
import './home.css';
|
||||
import payImg from '../../assets/pay.png';
|
||||
|
||||
const { Title, Text, Link } = Typography;
|
||||
|
||||
const HomePage = () => {
|
||||
const version = "3.8.8"; // From original file
|
||||
|
||||
const goTarget = (href: string) => {
|
||||
window.open(href, "_blank");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="home-container">
|
||||
<Row gutter={[20, 20]}>
|
||||
<Col span={24}>
|
||||
<div className="notification">
|
||||
领取阿里云通用云产品1888优惠券<br />
|
||||
<Link href="https://www.aliyun.com/minisite/goods?userCode=brki8iof" target="_blank">
|
||||
https://www.aliyun.com/minisite/goods?userCode=brki8iof
|
||||
</Link>
|
||||
<br />
|
||||
领取腾讯云通用云产品2860优惠券<br />
|
||||
<Link href="https://cloud.tencent.com/redirect.php?redirect=1025&cps_key=198c8df2ed259157187173bc7f4f32fd&from=console" target="_blank">
|
||||
https://cloud.tencent.com/redirect.php?redirect=1025&cps_key=198c8df2ed259157187173bc7f4f32fd&from=console
|
||||
</Link>
|
||||
<br />
|
||||
阿里云服务器折扣区
|
||||
<Link href="http://aly.ruoyi.vip" target="_blank"> >☛☛点我进入☚☚</Link>
|
||||
腾讯云服务器秒杀区
|
||||
<Link href="http://txy.ruoyi.vip" target="_blank"> >☛☛点我进入☚☚</Link>
|
||||
<br />
|
||||
<Text type="danger" strong>
|
||||
云产品通用红包,可叠加官网常规优惠使用。(仅限新用户)
|
||||
</Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<Divider />
|
||||
<Row gutter={[20, 20]}>
|
||||
<Col xs={24} sm={24} md={12}>
|
||||
<Title level={2}>若依后台管理框架</Title>
|
||||
<p>
|
||||
一直想做一款后台管理系统,看了很多优秀的开源项目但是发现没有合适自己的。于是利用空闲休息时间开始自己写一套后台系统。如此有了若依管理系统...
|
||||
</p>
|
||||
<p><Text strong>当前版本:</Text> <span>v{version}</span></p>
|
||||
<p><Tag color="red">¥免费开源</Tag></p>
|
||||
<p>
|
||||
<Button onClick={() => goTarget('https://gitee.com/y_project/RuoYi-Vue')}>访问码云</Button>
|
||||
<Button style={{ marginLeft: 8 }} onClick={() => goTarget('http://ruoyi.vip')}>访问主页</Button>
|
||||
</p>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12}>
|
||||
<Title level={2}>技术选型</Title>
|
||||
<Row>
|
||||
<Col span={12}>
|
||||
<h4>后端技术</h4>
|
||||
<ul><li>SpringBoot</li><li>Spring Security</li><li>JWT</li><li>MyBatis</li><li>...</li></ul>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<h4>前端技术</h4>
|
||||
<ul><li>Vue</li><li>Vuex</li><li>Element-ui</li><li>Axios</li><li>...</li></ul>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
<Divider />
|
||||
<Row gutter={[20, 20]}>
|
||||
<Col xs={24} sm={24} md={12} lg={8}>
|
||||
<Card title="联系信息">
|
||||
<p><Link href="http://www.ruoyi.vip" target="_blank">官网:http://www.ruoyi.vip</Link></p>
|
||||
<p>QQ群:<Link href="#" target="_blank">151450850</Link></p>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={8}>
|
||||
<Card title="更新日志">
|
||||
<Collapse accordion items={[
|
||||
{
|
||||
key: '1',
|
||||
label: 'v3.8.8 - 2024-06-30',
|
||||
children: <ol><li>菜单管理新增路由名称</li><li>新增数据脱敏过滤注解</li><li>...</li></ol>
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: 'v3.8.7 - 2023-12-08',
|
||||
children: <ol><li>操作日志记录部门名称</li><li>...</li></ol>
|
||||
}
|
||||
]} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={8}>
|
||||
<Card title="捐赠支持">
|
||||
<img src={payImg} alt="donate" style={{ width: '100%' }}/>
|
||||
<Text>你可以请作者喝杯咖啡表示鼓励</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Form, Input, Button, Checkbox, message } from 'antd';
|
||||
import { UserOutlined, LockOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Cookies from 'js-cookie';
|
||||
import { login, getCodeImg } from '../../api/login';
|
||||
import { TokenKey } from '../../utils/auth';
|
||||
import type { LoginRequest } from '@/types/api';
|
||||
import './login.css';
|
||||
|
||||
interface LoginFormValues {
|
||||
username: string;
|
||||
password: string;
|
||||
code?: string;
|
||||
rememberMe?: boolean;
|
||||
}
|
||||
|
||||
const LoginPage = () => {
|
||||
const [form] = Form.useForm<LoginFormValues>();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [codeUrl, setCodeUrl] = useState('');
|
||||
const [captchaEnabled, setCaptchaEnabled] = useState(true);
|
||||
const [uuid, setUuid] = useState('');
|
||||
|
||||
const fetchCode = () => {
|
||||
getCodeImg()
|
||||
.then((res) => {
|
||||
const { img, uuid: captchaUuid, captchaEnabled: enabled } = res;
|
||||
const shouldShowCaptcha = enabled === undefined ? true : enabled;
|
||||
setCaptchaEnabled(shouldShowCaptcha);
|
||||
if (shouldShowCaptcha && img) {
|
||||
setCodeUrl(`data:image/gif;base64,${img}`);
|
||||
setUuid(captchaUuid);
|
||||
} else {
|
||||
setCodeUrl('');
|
||||
setUuid('');
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error('Failed to load captcha image:', error);
|
||||
message.error('Failed to load captcha image, please refresh.');
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchCode();
|
||||
}, []);
|
||||
|
||||
const handleLogin = (values: LoginFormValues) => {
|
||||
setLoading(true);
|
||||
const data: LoginRequest = { ...values, uuid };
|
||||
login(data)
|
||||
.then((res) => {
|
||||
message.success('Login successful!');
|
||||
const tokenToSet = res.token ?? 'mock_token';
|
||||
Cookies.set(TokenKey, tokenToSet);
|
||||
navigate('/');
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Login failed.';
|
||||
message.error(errorMessage);
|
||||
if (captchaEnabled) {
|
||||
void fetchCode();
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<Form
|
||||
form={form}
|
||||
name="loginForm"
|
||||
className="login-form"
|
||||
initialValues={{ rememberMe: true }}
|
||||
onFinish={handleLogin}
|
||||
>
|
||||
<h3 className="title">新光线平台</h3>
|
||||
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入您的账号!' }]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="账号" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入您的密码!' }]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
|
||||
</Form.Item>
|
||||
|
||||
{captchaEnabled && (
|
||||
<Form.Item>
|
||||
<Form.Item
|
||||
name="code"
|
||||
rules={[{ required: true, message: '请输入验证码!' }]}
|
||||
style={{ display: 'inline-block', width: 'calc(67% - 8px)', marginRight: '8px' }}
|
||||
>
|
||||
<Input prefix={<SafetyOutlined />} placeholder="验证码" />
|
||||
</Form.Item>
|
||||
<div className="login-code">
|
||||
{codeUrl ? <img src={codeUrl} onClick={() => void fetchCode()} alt="Captcha" /> : null}
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item>
|
||||
<Form.Item name="rememberMe" valuePropName="checked" noStyle>
|
||||
<Checkbox>记住密码</Checkbox>
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" className="login-form-button" loading={loading} style={{ width: '100%' }}>
|
||||
{loading ? '登 录 中...' : '登 录'}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div className="el-login-footer">
|
||||
<span>unissense.tech</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
.login {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background-image: url("../../assets/login-background.jpg");
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0px auto 30px auto;
|
||||
text-align: center;
|
||||
color: #707070;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
width: 400px;
|
||||
padding: 25px 25px 5px 25px;
|
||||
}
|
||||
|
||||
.login-form .ant-input-affix-wrapper {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.login-form .ant-input-prefix {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.login-tip {
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
.login-code {
|
||||
width: 33%;
|
||||
height: 40px;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.login-code img {
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.el-login-footer {
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { Form, Input, Button, message } from 'antd';
|
||||
import { updateUserPwd } from '../../api/user';
|
||||
|
||||
interface ResetPasswordValues {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
const ResetPassword = () => {
|
||||
const [form] = Form.useForm<ResetPasswordValues>();
|
||||
|
||||
const onFinish = (values: ResetPasswordValues) => {
|
||||
updateUserPwd(values.oldPassword, values.newPassword).then(() => {
|
||||
message.success('修改成功,请重新登录');
|
||||
form.resetFields();
|
||||
}).catch(() => {
|
||||
message.error('修改失败');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form form={form} onFinish={onFinish} labelCol={{ span: 4 }} wrapperCol={{ span: 16 }}>
|
||||
<Form.Item
|
||||
label="旧密码"
|
||||
name="oldPassword"
|
||||
rules={[{ required: true, message: '旧密码不能为空' }]}
|
||||
>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="新密码"
|
||||
name="newPassword"
|
||||
rules={[
|
||||
{ required: true, message: '新密码不能为空' },
|
||||
{ min: 6, message: '长度不能小于6位' },
|
||||
]}
|
||||
>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="确认密码"
|
||||
name="confirmPassword"
|
||||
dependencies={['newPassword']}
|
||||
hasFeedback
|
||||
rules={[
|
||||
{ required: true, message: '请确认新密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('newPassword') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致!'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item wrapperCol={{ offset: 4, span: 16 }}>
|
||||
<Button type="primary" htmlType="submit">
|
||||
保存
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPassword;
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { useEffect } from 'react';
|
||||
import { Form, Input, Button, Radio, message } from 'antd';
|
||||
import { updateUserProfile } from '../../api/user';
|
||||
import type { UpdateUserProfilePayload, UserProfileUser } from '@/types/api';
|
||||
|
||||
interface UserInfoProps {
|
||||
user: UserProfileUser;
|
||||
}
|
||||
|
||||
const UserInfo = ({ user }: UserInfoProps) => {
|
||||
const [form] = Form.useForm<UpdateUserProfilePayload>();
|
||||
|
||||
useEffect(() => {
|
||||
// Set form fields when user data is available
|
||||
form.setFieldsValue({
|
||||
nickName: user.nickName,
|
||||
phonenumber: user.phonenumber,
|
||||
email: user.email,
|
||||
sex: user.sex,
|
||||
});
|
||||
}, [user, form]);
|
||||
|
||||
const onFinish = (values: UpdateUserProfilePayload) => {
|
||||
updateUserProfile(values)
|
||||
.then(() => {
|
||||
message.success('修改成功');
|
||||
})
|
||||
.catch(() => {
|
||||
message.error('修改失败');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form form={form} onFinish={onFinish} labelCol={{ span: 4 }} wrapperCol={{ span: 16 }}>
|
||||
<Form.Item
|
||||
label="用户昵称"
|
||||
name="nickName"
|
||||
rules={[{ required: true, message: '用户昵称不能为空' }]}
|
||||
>
|
||||
<Input maxLength={30} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="手机号码"
|
||||
name="phonenumber"
|
||||
rules={[
|
||||
{ required: true, message: '手机号码不能为空' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={11} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="邮箱"
|
||||
name="email"
|
||||
rules={[
|
||||
{ required: true, message: '邮箱地址不能为空' },
|
||||
{ type: 'email', message: '请输入正确的邮箱地址' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={50} />
|
||||
</Form.Item>
|
||||
<Form.Item label="性别" name="sex">
|
||||
<Radio.Group>
|
||||
<Radio value="0">男</Radio>
|
||||
<Radio value="1">女</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item wrapperCol={{ offset: 4, span: 16 }}>
|
||||
<Button type="primary" htmlType="submit">
|
||||
保存
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserInfo;
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Row, Col, Card, Tabs, Spin, List, message } from 'antd';
|
||||
import { UserOutlined, PhoneOutlined, MailOutlined, HomeOutlined, TeamOutlined, CalendarOutlined } from '@ant-design/icons';
|
||||
import { getUserProfile } from '../../api/user';
|
||||
import UserInfo from './UserInfo';
|
||||
import ResetPassword from './ResetPassword';
|
||||
import type { UserProfileUser } from '@/types/api';
|
||||
import './profile.css';
|
||||
|
||||
const { TabPane } = Tabs;
|
||||
|
||||
const ProfilePage = () => {
|
||||
const [user, setUser] = useState<UserProfileUser | null>(null);
|
||||
const [roleGroup, setRoleGroup] = useState('');
|
||||
const [postGroup, setPostGroup] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getUserProfile()
|
||||
.then((response) => {
|
||||
setUser(response.user);
|
||||
setRoleGroup(response.roleGroup);
|
||||
setPostGroup(response.postGroup);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error('Failed to load user profile:', error);
|
||||
message.error('Failed to load user profile.');
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading || !user) {
|
||||
return <Spin tip="Loading..." style={{ display: 'block', marginTop: '50px' }} />;
|
||||
}
|
||||
|
||||
const profileItems = [
|
||||
{ icon: <UserOutlined />, label: '用户名称', value: user.userName ?? '' },
|
||||
{ icon: <PhoneOutlined />, label: '手机号码', value: user.phonenumber ?? '' },
|
||||
{ icon: <MailOutlined />, label: '用户邮箱', value: user.email ?? '' },
|
||||
{ icon: <HomeOutlined />, label: '所属部门', value: user.dept ? `${user.dept.deptName ?? ''} / ${postGroup}` : '' },
|
||||
{ icon: <TeamOutlined />, label: '所属角色', value: roleGroup },
|
||||
{ icon: <CalendarOutlined />, label: '创建日期', value: user.createTime ?? '' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="profile-app-container">
|
||||
<Row gutter={20}>
|
||||
<Col span={6} xs={24}>
|
||||
<Card title="个人信息">
|
||||
<div style={{ textAlign: 'center', marginBottom: '20px' }}>
|
||||
<img src={user.avatar || ''} alt="avatar" style={{width: 120, height: 120, borderRadius: '50%'}}/>
|
||||
</div>
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={profileItems}
|
||||
renderItem={item => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
avatar={item.icon}
|
||||
title={item.label}
|
||||
description={<div className="pull-right">{item.value}</div>}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={18} xs={24}>
|
||||
<Card>
|
||||
<Tabs defaultActiveKey="userinfo">
|
||||
<TabPane tab="基本资料" key="userinfo">
|
||||
<UserInfo user={user} />
|
||||
</TabPane>
|
||||
<TabPane tab="修改密码" key="resetPwd">
|
||||
<ResetPassword />
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
.profile-app-container .pull-right {
|
||||
float: right;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.profile-app-container .ant-list-item-meta-description {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -0,0 +1,787 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, DatePicker, Empty, Form, Input, message, Progress, Select, Space, Table, Tooltip } from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
FieldTimeOutlined,
|
||||
ProjectOutlined,
|
||||
ReloadOutlined,
|
||||
RiseOutlined,
|
||||
SearchOutlined,
|
||||
TeamOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import zhCN from 'antd/es/date-picker/locale/zh_CN';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { parseTime } from '@/utils/ruoyi';
|
||||
import { listProject } from '@/api/project';
|
||||
import { listProjectExecution } from '@/api/projectExecution';
|
||||
import { getDicts } from '@/api/system/dict';
|
||||
import './project-execution.css';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
// Back-end endpoint /projectBank/projectProgress/list may be unavailable in some environments.
|
||||
// Keep it disabled by default to avoid 404 noise in browser/devtools.
|
||||
const ENABLE_PROJECT_EXECUTION_API = false;
|
||||
const PERIOD_SWITCH_DAYS = 62;
|
||||
|
||||
interface BoardRow {
|
||||
projectId?: string | number;
|
||||
projectName?: string;
|
||||
projectCode?: string;
|
||||
projectLeaderName?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
projectState?: string;
|
||||
teamNum?: number;
|
||||
budgetDate?: number | string;
|
||||
planProgress?: number;
|
||||
actualProgress?: number;
|
||||
progressDeviation?: number;
|
||||
delayDays?: number;
|
||||
riskLevel?: 'normal' | 'warning' | 'delay';
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface QueryParams {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
projectName?: string;
|
||||
projectLeaderName?: string;
|
||||
projectState?: string;
|
||||
beginTime?: string;
|
||||
endTime?: string;
|
||||
}
|
||||
|
||||
interface PeriodMeta {
|
||||
key: string;
|
||||
label: string;
|
||||
subLabel: string;
|
||||
start: Dayjs;
|
||||
end: Dayjs;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
interface PeriodMetric {
|
||||
active: boolean;
|
||||
planPercent: number;
|
||||
actualPercent: number;
|
||||
delay: boolean;
|
||||
}
|
||||
|
||||
const getDefaultRange = (): [Dayjs, Dayjs] => [dayjs().startOf('year'), dayjs().endOf('year')];
|
||||
|
||||
const toNumber = (value: unknown, fallback = 0) => {
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : fallback;
|
||||
};
|
||||
|
||||
const clampPercent = (value: number) => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
if (value < 0) return 0;
|
||||
if (value > 100) return 100;
|
||||
return Number(value.toFixed(1));
|
||||
};
|
||||
|
||||
const toDate = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null;
|
||||
}
|
||||
const parsed = dayjs(value as dayjs.ConfigType);
|
||||
return parsed.isValid() ? parsed.startOf('day') : null;
|
||||
};
|
||||
|
||||
const laterDate = (left: Dayjs, right: Dayjs) => (left.isAfter(right) ? left : right);
|
||||
|
||||
const earlierDate = (left: Dayjs, right: Dayjs) => (left.isBefore(right) ? left : right);
|
||||
|
||||
const calcPlanProgress = (startDate?: string, endDate?: string) => {
|
||||
const start = toDate(startDate);
|
||||
const end = toDate(endDate);
|
||||
if (!start || !end || end.isBefore(start, 'day')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const totalDays = end.diff(start, 'day') + 1;
|
||||
const today = dayjs().startOf('day');
|
||||
const elapsedDays = today.isBefore(start, 'day') ? 0 : today.diff(start, 'day') + 1;
|
||||
return clampPercent((elapsedDays / Math.max(totalDays, 1)) * 100);
|
||||
};
|
||||
|
||||
const inferActualProgress = (row: Record<string, unknown>, planProgress: number) => {
|
||||
const directFields = [
|
||||
'actualProgress',
|
||||
'execProgress',
|
||||
'projectProgress',
|
||||
'progress',
|
||||
'finishRate',
|
||||
'completeRate',
|
||||
];
|
||||
for (const key of directFields) {
|
||||
if (row[key] !== undefined && row[key] !== null && row[key] !== '') {
|
||||
return clampPercent(toNumber(row[key], 0));
|
||||
}
|
||||
}
|
||||
|
||||
const rawState = String(row.projectState ?? '').toLowerCase();
|
||||
if (rawState === '2' || rawState.includes('完成')) return 100;
|
||||
if (rawState === '0' || rawState.includes('待')) return 0;
|
||||
if (rawState === '1' || rawState.includes('进行')) return clampPercent(planProgress * 0.9);
|
||||
return clampPercent(planProgress * 0.75);
|
||||
};
|
||||
|
||||
const calcDelayDays = (row: Record<string, unknown>) => {
|
||||
const end = toDate(row.endDate);
|
||||
const rawState = String(row.projectState ?? '').toLowerCase();
|
||||
if (!end) {
|
||||
return 0;
|
||||
}
|
||||
if (rawState === '2' || rawState.includes('完成')) {
|
||||
return 0;
|
||||
}
|
||||
if (dayjs().isAfter(end, 'day')) {
|
||||
return dayjs().startOf('day').diff(end, 'day');
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const normalizeRows = (rows: unknown[]): BoardRow[] => {
|
||||
return rows
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
.map((item) => {
|
||||
const row = item as Record<string, unknown>;
|
||||
const planProgress = clampPercent(
|
||||
row.planProgress !== undefined
|
||||
? toNumber(row.planProgress)
|
||||
: calcPlanProgress(String(row.startDate ?? ''), String(row.endDate ?? '')),
|
||||
);
|
||||
const actualProgress = inferActualProgress(row, planProgress);
|
||||
const progressDeviation = Number((actualProgress - planProgress).toFixed(1));
|
||||
const delayDays = row.delayDays !== undefined ? toNumber(row.delayDays) : calcDelayDays(row);
|
||||
const riskLevel: BoardRow['riskLevel'] =
|
||||
delayDays > 0 ? 'delay' : progressDeviation < -15 ? 'warning' : 'normal';
|
||||
|
||||
return {
|
||||
...(row as BoardRow),
|
||||
projectId: row.projectId as string | number | undefined,
|
||||
projectName: String(row.projectName ?? ''),
|
||||
projectCode: String(row.projectCode ?? ''),
|
||||
projectLeaderName: String(row.projectLeaderName ?? row.projectLeader ?? ''),
|
||||
startDate: row.startDate ? String(row.startDate) : undefined,
|
||||
endDate: row.endDate ? String(row.endDate) : undefined,
|
||||
planProgress,
|
||||
actualProgress,
|
||||
progressDeviation,
|
||||
delayDays,
|
||||
riskLevel,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const extractRows = (response: unknown): { rows: unknown[]; total: number } => {
|
||||
if (response && typeof response === 'object') {
|
||||
const payload = response as Record<string, unknown>;
|
||||
if (Array.isArray(payload.rows)) {
|
||||
return { rows: payload.rows, total: toNumber(payload.total, payload.rows.length) };
|
||||
}
|
||||
if (payload.data && typeof payload.data === 'object') {
|
||||
const dataObj = payload.data as Record<string, unknown>;
|
||||
if (Array.isArray(dataObj.rows)) {
|
||||
return { rows: dataObj.rows, total: toNumber(dataObj.total, dataObj.rows.length) };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(response)) {
|
||||
return { rows: response, total: response.length };
|
||||
}
|
||||
return { rows: [], total: 0 };
|
||||
};
|
||||
|
||||
const overlapDays = (rangeStart: Dayjs, rangeEnd: Dayjs, blockStart: Dayjs, blockEnd: Dayjs) => {
|
||||
const start = laterDate(rangeStart, blockStart);
|
||||
const end = earlierDate(rangeEnd, blockEnd);
|
||||
if (end.isBefore(start, 'day')) {
|
||||
return 0;
|
||||
}
|
||||
return end.diff(start, 'day') + 1;
|
||||
};
|
||||
|
||||
const buildPeriods = (rangeStart: Dayjs, rangeEnd: Dayjs): PeriodMeta[] => {
|
||||
if (!rangeStart.isValid() || !rangeEnd.isValid() || rangeEnd.isBefore(rangeStart, 'day')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const periods: PeriodMeta[] = [];
|
||||
const totalDays = rangeEnd.diff(rangeStart, 'day');
|
||||
const useWeekView = totalDays <= PERIOD_SWITCH_DAYS;
|
||||
const today = dayjs();
|
||||
|
||||
if (useWeekView) {
|
||||
let cursor = rangeStart.startOf('day');
|
||||
let index = 0;
|
||||
while (!cursor.isAfter(rangeEnd, 'day')) {
|
||||
const start = cursor;
|
||||
const end = earlierDate(cursor.add(6, 'day').endOf('day'), rangeEnd.endOf('day'));
|
||||
periods.push({
|
||||
key: `week-${index}`,
|
||||
label: `${start.format('MM/DD')}-${end.format('MM/DD')}`,
|
||||
subLabel: `${start.format('YYYY')} 第${index + 1}段`,
|
||||
start,
|
||||
end,
|
||||
current: !today.isBefore(start.startOf('day')) && !today.isAfter(end.endOf('day')),
|
||||
});
|
||||
cursor = end.add(1, 'day').startOf('day');
|
||||
index += 1;
|
||||
}
|
||||
return periods;
|
||||
}
|
||||
|
||||
let cursor = rangeStart.startOf('month');
|
||||
while (!cursor.isAfter(rangeEnd, 'day')) {
|
||||
const start = laterDate(cursor.startOf('month'), rangeStart.startOf('day'));
|
||||
const end = earlierDate(cursor.endOf('month'), rangeEnd.endOf('day'));
|
||||
periods.push({
|
||||
key: cursor.format('YYYY-MM'),
|
||||
label: cursor.format('MM月'),
|
||||
subLabel: cursor.format('YYYY'),
|
||||
start,
|
||||
end,
|
||||
current: !today.isBefore(start.startOf('day')) && !today.isAfter(end.endOf('day')),
|
||||
});
|
||||
cursor = cursor.add(1, 'month').startOf('month');
|
||||
}
|
||||
return periods;
|
||||
};
|
||||
|
||||
const getPeriodMetric = (row: BoardRow, period: PeriodMeta): PeriodMetric => {
|
||||
const start = toDate(row.startDate);
|
||||
const end = toDate(row.endDate);
|
||||
if (!start || !end || end.isBefore(start, 'day')) {
|
||||
return { active: false, planPercent: 0, actualPercent: 0, delay: false };
|
||||
}
|
||||
|
||||
const totalDays = end.diff(start, 'day') + 1;
|
||||
const planDays = overlapDays(start, end, period.start, period.end);
|
||||
const planPercent = clampPercent((planDays / totalDays) * 100);
|
||||
const actualDaysTotal = Math.round((clampPercent(toNumber(row.actualProgress, 0)) / 100) * totalDays);
|
||||
const actualEnd = actualDaysTotal > 0 ? start.add(actualDaysTotal - 1, 'day') : start.subtract(1, 'day');
|
||||
const actualDays = actualDaysTotal > 0 ? overlapDays(start, actualEnd, period.start, period.end) : 0;
|
||||
const actualPercent = clampPercent((actualDays / totalDays) * 100);
|
||||
|
||||
return {
|
||||
active: planDays > 0,
|
||||
planPercent,
|
||||
actualPercent,
|
||||
delay: toNumber(row.delayDays, 0) > 0 && period.current,
|
||||
};
|
||||
};
|
||||
|
||||
const getStateTone = (label: string, riskLevel: BoardRow['riskLevel']) => {
|
||||
if (riskLevel === 'delay') {
|
||||
return 'is-delay';
|
||||
}
|
||||
if (riskLevel === 'warning') {
|
||||
return 'is-warning';
|
||||
}
|
||||
if (label.includes('完成')) {
|
||||
return 'is-finished';
|
||||
}
|
||||
if (label.includes('进行')) {
|
||||
return 'is-active';
|
||||
}
|
||||
return 'is-pending';
|
||||
};
|
||||
|
||||
const getProgressTone = (deviation: number) => {
|
||||
if (deviation < -15) {
|
||||
return '#ff4d4f';
|
||||
}
|
||||
if (deviation < 0) {
|
||||
return '#fa8c16';
|
||||
}
|
||||
return '#16a34a';
|
||||
};
|
||||
|
||||
const formatPercent = (value: number) => `${clampPercent(value)}%`;
|
||||
|
||||
const ProjectExecutionPage = () => {
|
||||
const [queryForm] = Form.useForm();
|
||||
const navigate = useNavigate();
|
||||
const defaultRange = useMemo(() => getDefaultRange(), []);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [statusOptions, setStatusOptions] = useState<Array<{ dictValue: string; dictLabel: string }>>([]);
|
||||
const [rows, setRows] = useState<BoardRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const fallbackTipShownRef = useRef(false);
|
||||
const [queryParams, setQueryParams] = useState<QueryParams>({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
projectName: undefined,
|
||||
projectLeaderName: undefined,
|
||||
projectState: undefined,
|
||||
beginTime: defaultRange[0].format('YYYY-MM-DD'),
|
||||
endTime: defaultRange[1].format('YYYY-MM-DD'),
|
||||
});
|
||||
|
||||
const rangeStart = useMemo(
|
||||
() => toDate(queryParams.beginTime) ?? defaultRange[0],
|
||||
[defaultRange, queryParams.beginTime],
|
||||
);
|
||||
const rangeEnd = useMemo(
|
||||
() => toDate(queryParams.endTime) ?? defaultRange[1],
|
||||
[defaultRange, queryParams.endTime],
|
||||
);
|
||||
const periods = useMemo(() => buildPeriods(rangeStart, rangeEnd), [rangeEnd, rangeStart]);
|
||||
const statusMap = useMemo(
|
||||
() => new Map(statusOptions.map((item) => [String(item.dictValue), item.dictLabel])),
|
||||
[statusOptions],
|
||||
);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const totalCount = rows.length;
|
||||
const executingCount = rows.filter((row) => {
|
||||
const statusLabel = statusMap.get(String(row.projectState ?? '')) ?? String(row.projectState ?? '');
|
||||
return statusLabel.includes('进行') || String(row.projectState ?? '') === '1';
|
||||
}).length;
|
||||
const delayCount = rows.filter((row) => toNumber(row.delayDays, 0) > 0).length;
|
||||
const avgActual = rows.length
|
||||
? Number((rows.reduce((sum, row) => sum + toNumber(row.actualProgress), 0) / rows.length).toFixed(1))
|
||||
: 0;
|
||||
const totalMembers = rows.reduce((sum, row) => sum + toNumber(row.teamNum, 0), 0);
|
||||
|
||||
return { totalCount, executingCount, delayCount, avgActual, totalMembers };
|
||||
}, [rows, statusMap]);
|
||||
|
||||
const fetchStatusDict = useCallback(async () => {
|
||||
try {
|
||||
const response = await getDicts('business_projectstate');
|
||||
const list = Array.isArray(response)
|
||||
? response
|
||||
: response && typeof response === 'object' && Array.isArray((response as Record<string, unknown>).data)
|
||||
? ((response as Record<string, unknown>).data as Array<{ dictValue: string; dictLabel: string }>)
|
||||
: [];
|
||||
setStatusOptions(list);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch project state dict:', error);
|
||||
setStatusOptions([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let response: unknown;
|
||||
let fallbackUsed = false;
|
||||
if (ENABLE_PROJECT_EXECUTION_API) {
|
||||
try {
|
||||
response = await listProjectExecution(queryParams as unknown as Record<string, unknown>);
|
||||
} catch {
|
||||
fallbackUsed = true;
|
||||
response = await listProject(queryParams as unknown as Record<string, unknown>);
|
||||
}
|
||||
} else {
|
||||
fallbackUsed = true;
|
||||
response = await listProject(queryParams as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
const { rows: rawRows, total: rawTotal } = extractRows(response);
|
||||
setRows(normalizeRows(rawRows));
|
||||
setTotal(rawTotal);
|
||||
|
||||
if (ENABLE_PROJECT_EXECUTION_API && fallbackUsed && !fallbackTipShownRef.current) {
|
||||
fallbackTipShownRef.current = true;
|
||||
message.info('项目执行表接口未就绪,已自动使用项目列表估算执行进度');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch project execution list:', error);
|
||||
message.error('获取项目执行表失败');
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [queryParams]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchStatusDict();
|
||||
}, [fetchStatusDict]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchList();
|
||||
}, [fetchList]);
|
||||
|
||||
const openProject = useCallback(
|
||||
(row: BoardRow) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('projectId', String(row.projectId ?? ''));
|
||||
params.set('projectName', row.projectName ?? '');
|
||||
|
||||
const rowStart = toDate(row.startDate);
|
||||
const rowEnd = toDate(row.endDate);
|
||||
if (rowStart) {
|
||||
params.set('startDate', String(rowStart.valueOf()));
|
||||
}
|
||||
if (rowEnd) {
|
||||
params.set('endDate', String(rowEnd.endOf('day').valueOf()));
|
||||
}
|
||||
|
||||
navigate(`/projectBank/projectUser?${params.toString()}`);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const handleQuery = () => {
|
||||
const values = queryForm.getFieldsValue();
|
||||
const selectedRange = values.dateRange as [Dayjs, Dayjs] | undefined;
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
pageNum: 1,
|
||||
projectName: values.projectName ?? undefined,
|
||||
projectLeaderName: values.projectLeaderName ?? undefined,
|
||||
projectState: values.projectState ?? undefined,
|
||||
beginTime: selectedRange?.[0]?.format('YYYY-MM-DD') ?? defaultRange[0].format('YYYY-MM-DD'),
|
||||
endTime: selectedRange?.[1]?.format('YYYY-MM-DD') ?? defaultRange[1].format('YYYY-MM-DD'),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const nextRange = getDefaultRange();
|
||||
queryForm.resetFields();
|
||||
queryForm.setFieldsValue({ dateRange: nextRange });
|
||||
setQueryParams({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
projectName: undefined,
|
||||
projectLeaderName: undefined,
|
||||
projectState: undefined,
|
||||
beginTime: nextRange[0].format('YYYY-MM-DD'),
|
||||
endTime: nextRange[1].format('YYYY-MM-DD'),
|
||||
});
|
||||
};
|
||||
|
||||
const columns = useMemo<TableColumnsType<BoardRow>>(() => {
|
||||
const baseColumns: TableColumnsType<BoardRow> = [
|
||||
{
|
||||
title: '项目名称',
|
||||
key: 'projectName',
|
||||
dataIndex: 'projectName',
|
||||
width: 260,
|
||||
fixed: 'left',
|
||||
render: (value: unknown, row) => (
|
||||
<div className="project-name-cell">
|
||||
<Button type="link" className="project-name-link" onClick={() => openProject(row)}>
|
||||
{String(value ?? '-') || '-'}
|
||||
</Button>
|
||||
<div className="project-name-meta">
|
||||
<span>{row.projectCode || '未配置项目编号'}</span>
|
||||
<span>
|
||||
{parseTime(row.startDate, 'YYYY-MM-DD') || '--'} 至 {parseTime(row.endDate, 'YYYY-MM-DD') || '--'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '负责人',
|
||||
dataIndex: 'projectLeaderName',
|
||||
width: 120,
|
||||
fixed: 'left',
|
||||
render: (value: unknown) => String(value ?? '-') || '-',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'projectState',
|
||||
width: 112,
|
||||
fixed: 'left',
|
||||
render: (value: unknown, row) => {
|
||||
const label = statusMap.get(String(value ?? '')) ?? (String(value ?? '') || '未配置');
|
||||
return <span className={`project-state-pill ${getStateTone(label, row.riskLevel)}`}>{label}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '执行概览',
|
||||
key: 'overview',
|
||||
width: 240,
|
||||
fixed: 'left',
|
||||
render: (_value, row) => {
|
||||
const plan = clampPercent(toNumber(row.planProgress, 0));
|
||||
const actual = clampPercent(toNumber(row.actualProgress, 0));
|
||||
const deviation = toNumber(row.progressDeviation, 0);
|
||||
return (
|
||||
<div className="project-overview-card">
|
||||
<div className="project-overview-head">
|
||||
<span className="project-overview-title">总进度</span>
|
||||
<span className="project-overview-diff" style={{ color: getProgressTone(deviation) }}>
|
||||
{`${deviation > 0 ? '+' : ''}${deviation}%`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="project-overview-bar-item">
|
||||
<div className="project-overview-bar-label">
|
||||
<span>计划</span>
|
||||
<span>{formatPercent(plan)}</span>
|
||||
</div>
|
||||
<Progress percent={plan} showInfo={false} size="small" strokeColor="#8fbef8" railColor="#edf3fb" />
|
||||
</div>
|
||||
<div className="project-overview-bar-item">
|
||||
<div className="project-overview-bar-label">
|
||||
<span>执行</span>
|
||||
<span>{formatPercent(actual)}</span>
|
||||
</div>
|
||||
<Progress percent={actual} showInfo={false} size="small" strokeColor="#ff9e3d" railColor="#fdf0df" />
|
||||
</div>
|
||||
<div className="project-overview-footer">
|
||||
<span>{`团队 ${toNumber(row.teamNum, 0)} 人`}</span>
|
||||
<span>{`工时 ${String(row.budgetDate ?? '-')}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const periodColumns: TableColumnsType<BoardRow> = periods.map((period) => ({
|
||||
title: (
|
||||
<div className={`period-header${period.current ? ' is-current' : ''}`}>
|
||||
<span className="period-header-label">{period.label}</span>
|
||||
<span className="period-header-sub-label">{period.subLabel}</span>
|
||||
</div>
|
||||
),
|
||||
key: period.key,
|
||||
width: 164,
|
||||
className: period.current ? 'period-column-current' : undefined,
|
||||
render: (_value, row) => {
|
||||
const metric = getPeriodMetric(row, period);
|
||||
if (!metric.active) {
|
||||
return (
|
||||
<div className="period-cell is-empty">
|
||||
<span className="period-cell-tip">无排期</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`period-cell${period.current ? ' is-current' : ''}`}>
|
||||
<div className="period-cell-values">
|
||||
<span>计划 {formatPercent(metric.planPercent)}</span>
|
||||
<span>执行 {formatPercent(metric.actualPercent)}</span>
|
||||
</div>
|
||||
<div className="period-bar-track">
|
||||
<div className="period-bar-plan" style={{ width: `${metric.planPercent}%` }} />
|
||||
<div className="period-bar-actual" style={{ width: `${metric.actualPercent}%` }} />
|
||||
</div>
|
||||
<span className={`period-cell-tip${metric.delay ? ' is-delay' : ''}`}>
|
||||
{metric.delay ? '当前节点延期' : metric.actualPercent >= metric.planPercent ? '执行平稳' : '待追赶'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
return [...baseColumns, ...periodColumns];
|
||||
}, [openProject, periods, statusMap]);
|
||||
|
||||
const stats = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'total',
|
||||
label: '项目总数',
|
||||
value: summary.totalCount,
|
||||
extra: '纳入当前筛选范围',
|
||||
icon: <ProjectOutlined />,
|
||||
tone: 'blue',
|
||||
},
|
||||
{
|
||||
key: 'active',
|
||||
label: '进行中项目',
|
||||
value: summary.executingCount,
|
||||
extra: `总成员 ${summary.totalMembers} 人`,
|
||||
icon: <RiseOutlined />,
|
||||
tone: 'orange',
|
||||
},
|
||||
{
|
||||
key: 'delay',
|
||||
label: '延期项目',
|
||||
value: summary.delayCount,
|
||||
extra: '需要重点跟踪',
|
||||
icon: <FieldTimeOutlined />,
|
||||
tone: 'red',
|
||||
},
|
||||
{
|
||||
key: 'avg',
|
||||
label: '平均执行率',
|
||||
value: `${summary.avgActual}%`,
|
||||
extra: periods.length > 0 ? `${periods.length} 个阶段窗口` : '暂无阶段',
|
||||
icon: <TeamOutlined />,
|
||||
tone: 'teal',
|
||||
},
|
||||
],
|
||||
[periods.length, summary],
|
||||
);
|
||||
|
||||
const scrollX = useMemo(() => 260 + 120 + 112 + 240 + periods.length * 164, [periods.length]);
|
||||
const viewLabel = periods.length > 0 && rangeEnd.diff(rangeStart, 'day') <= PERIOD_SWITCH_DAYS ? '周视图' : '月视图';
|
||||
|
||||
return (
|
||||
<div className="project-progress-page">
|
||||
<section className="project-progress-hero">
|
||||
<div className="project-progress-hero-main">
|
||||
<div className="project-progress-title-row">
|
||||
<div>
|
||||
<h2>项目执行表</h2>
|
||||
<p>按项目维度对齐计划与实际执行,保留旧系统横向时间轴查看逻辑,并提升信息层次与可读性。</p>
|
||||
</div>
|
||||
<div className="project-progress-range-chip">
|
||||
<CalendarOutlined />
|
||||
<span>{`${rangeStart.format('YYYY.MM.DD')} - ${rangeEnd.format('YYYY.MM.DD')}`}</span>
|
||||
<strong>{viewLabel}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="project-progress-stats">
|
||||
{stats.map((item) => (
|
||||
<div key={item.key} className={`project-progress-stat-card is-${item.tone}`}>
|
||||
<div className="project-progress-stat-icon">{item.icon}</div>
|
||||
<div className="project-progress-stat-body">
|
||||
<span className="project-progress-stat-label">{item.label}</span>
|
||||
<strong className="project-progress-stat-value">{item.value}</strong>
|
||||
<span className="project-progress-stat-extra">{item.extra}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="project-progress-filter-card">
|
||||
<Form
|
||||
form={queryForm}
|
||||
layout="inline"
|
||||
className="project-progress-filter-form"
|
||||
initialValues={{ dateRange: defaultRange }}
|
||||
onFinish={handleQuery}
|
||||
>
|
||||
<Form.Item label="项目名称" name="projectName">
|
||||
<Input placeholder="请输入项目名称" allowClear style={{ width: 220 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="负责人" name="projectLeaderName">
|
||||
<Input placeholder="请输入负责人" allowClear style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="项目状态" name="projectState">
|
||||
<Select
|
||||
placeholder="请选择状态"
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
options={statusOptions.map((item) => ({
|
||||
label: item.dictLabel,
|
||||
value: item.dictValue,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="统计周期" name="dateRange">
|
||||
<RangePicker locale={zhCN} allowClear={false} style={{ width: 280 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
|
||||
查询
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</section>
|
||||
|
||||
<section className="project-progress-table-card">
|
||||
<div className="project-progress-table-head">
|
||||
<div>
|
||||
<h3>执行矩阵</h3>
|
||||
<p>点击项目名称可进入项目下钻页面;左侧信息固定,右侧阶段横向滚动查看。</p>
|
||||
</div>
|
||||
<Tooltip title="日期跨度 62 天以内自动切换为周视图,超过则按月汇总。">
|
||||
<span className="project-progress-view-tip">{viewLabel}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Table<BoardRow>
|
||||
rowKey={(row) => String(row.projectId ?? row.projectCode ?? row.projectName ?? '')}
|
||||
className="project-progress-table"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
scroll={{ x: scrollX }}
|
||||
locale={{ emptyText: <Empty description="当前条件下暂无项目执行数据" /> }}
|
||||
pagination={{
|
||||
current: queryParams.pageNum,
|
||||
pageSize: queryParams.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (value) => `共 ${value} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
pageNum: page,
|
||||
pageSize,
|
||||
}));
|
||||
},
|
||||
}}
|
||||
summary={(pageData) => {
|
||||
const pageAvgActual = pageData.length
|
||||
? Number((pageData.reduce((sum, row) => sum + toNumber(row.actualProgress), 0) / pageData.length).toFixed(1))
|
||||
: 0;
|
||||
const pageDelay = pageData.filter((row) => toNumber(row.delayDays, 0) > 0).length;
|
||||
|
||||
return (
|
||||
<Table.Summary fixed>
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0}>
|
||||
<span className="project-progress-summary-title">当前页汇总</span>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1}>{`${pageData.length} 项`}</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={2}>{`${pageDelay} 延期`}</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={3}>
|
||||
<div className="project-progress-summary-overview">
|
||||
<span>{`平均执行 ${pageAvgActual}%`}</span>
|
||||
<span>{`平均计划 ${
|
||||
pageData.length
|
||||
? Number(
|
||||
(
|
||||
pageData.reduce((sum, row) => sum + toNumber(row.planProgress), 0) / pageData.length
|
||||
).toFixed(1),
|
||||
)
|
||||
: 0
|
||||
}%`}</span>
|
||||
</div>
|
||||
</Table.Summary.Cell>
|
||||
{periods.map((period, index) => {
|
||||
const metrics = pageData.map((row) => getPeriodMetric(row, period)).filter((item) => item.active);
|
||||
const avgPlan = metrics.length
|
||||
? Number((metrics.reduce((sum, item) => sum + item.planPercent, 0) / metrics.length).toFixed(1))
|
||||
: 0;
|
||||
const avgActual = metrics.length
|
||||
? Number((metrics.reduce((sum, item) => sum + item.actualPercent, 0) / metrics.length).toFixed(1))
|
||||
: 0;
|
||||
return (
|
||||
<Table.Summary.Cell key={period.key} index={4 + index}>
|
||||
<div className="project-progress-summary-cell">
|
||||
<span>{`计划 ${avgPlan}%`}</span>
|
||||
<span>{`执行 ${avgActual}%`}</span>
|
||||
</div>
|
||||
</Table.Summary.Cell>
|
||||
);
|
||||
})}
|
||||
</Table.Summary.Row>
|
||||
</Table.Summary>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectExecutionPage;
|
||||
|
|
@ -0,0 +1,488 @@
|
|||
.project-progress-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
min-height: 100%;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.project-progress-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 26px;
|
||||
padding: 26px 28px;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(255, 171, 77, 0.14), transparent 32%),
|
||||
radial-gradient(circle at left center, rgba(59, 130, 246, 0.12), transparent 28%),
|
||||
linear-gradient(135deg, #f8fbff 0%, #eef5ff 46%, #fff9f1 100%);
|
||||
border: 1px solid #e8eef8;
|
||||
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.project-progress-hero::before,
|
||||
.project-progress-hero::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: 999px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.project-progress-hero::before {
|
||||
top: -88px;
|
||||
right: -52px;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
|
||||
.project-progress-hero::after {
|
||||
left: -42px;
|
||||
bottom: -96px;
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
background: rgba(255, 138, 0, 0.08);
|
||||
}
|
||||
|
||||
.project-progress-hero-main {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.project-progress-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.project-progress-title-row h2 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.15;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.project-progress-title-row p {
|
||||
margin: 10px 0 0;
|
||||
max-width: 720px;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.project-progress-range-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
|
||||
white-space: nowrap;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.project-progress-range-chip strong {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.project-progress-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.project-progress-stat-card {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 16px 18px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border: 1px solid rgba(233, 238, 248, 0.95);
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.05);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.project-progress-stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex: none;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.project-progress-stat-card.is-blue .project-progress-stat-icon {
|
||||
color: #2563eb;
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.project-progress-stat-card.is-orange .project-progress-stat-icon {
|
||||
color: #ea580c;
|
||||
background: rgba(234, 88, 12, 0.12);
|
||||
}
|
||||
|
||||
.project-progress-stat-card.is-red .project-progress-stat-icon {
|
||||
color: #dc2626;
|
||||
background: rgba(220, 38, 38, 0.12);
|
||||
}
|
||||
|
||||
.project-progress-stat-card.is-teal .project-progress-stat-icon {
|
||||
color: #0f766e;
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
}
|
||||
|
||||
.project-progress-stat-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-progress-stat-label {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.project-progress-stat-value {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.project-progress-stat-extra {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.project-progress-filter-card,
|
||||
.project-progress-table-card {
|
||||
background: #fff;
|
||||
border-radius: 24px;
|
||||
border: 1px solid #e9eef5;
|
||||
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.project-progress-filter-card {
|
||||
padding: 20px 22px 8px;
|
||||
}
|
||||
|
||||
.project-progress-filter-form {
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.project-progress-filter-form .ant-form-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.project-progress-table-card {
|
||||
padding: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.project-progress-table-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.project-progress-table-head h3 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.project-progress-table-head p {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.project-progress-view-tip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 7px 12px;
|
||||
border-radius: 999px;
|
||||
background: #f5f8fd;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid #e8eef8;
|
||||
}
|
||||
|
||||
.project-progress-table .ant-table {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.project-progress-table .ant-table-container {
|
||||
border: 1px solid #ebf0f7;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.project-progress-table .ant-table-thead > tr > th {
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
background: #f6f8fc;
|
||||
color: #1e293b;
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid #e6ebf3;
|
||||
}
|
||||
|
||||
.project-progress-table .ant-table-tbody > tr > td {
|
||||
padding: 16px 12px;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.project-progress-table .ant-table-tbody > tr:hover > td,
|
||||
.project-progress-table .ant-table-tbody > tr.ant-table-row:hover > td {
|
||||
background: #fcfdff;
|
||||
}
|
||||
|
||||
.project-progress-table .ant-table-cell-fix-left,
|
||||
.project-progress-table .ant-table-cell-fix-right {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.project-progress-table .ant-table-ping-left .ant-table-cell-fix-left-last::after {
|
||||
box-shadow: inset 12px 0 14px -12px rgba(15, 23, 42, 0.15);
|
||||
}
|
||||
|
||||
.project-progress-table .ant-table-summary > tr > td {
|
||||
background: #eef2f7;
|
||||
border-top: 1px solid #dde4ee;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.project-name-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-name-link.ant-btn-link {
|
||||
padding: 0;
|
||||
height: auto;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.project-name-link.ant-btn-link:hover {
|
||||
color: #0958d9;
|
||||
}
|
||||
|
||||
.project-name-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.project-state-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 76px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.project-state-pill.is-pending {
|
||||
color: #475569;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.project-state-pill.is-active {
|
||||
color: #b45309;
|
||||
background: #fff3e0;
|
||||
}
|
||||
|
||||
.project-state-pill.is-finished {
|
||||
color: #047857;
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
.project-state-pill.is-warning {
|
||||
color: #c2410c;
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.project-state-pill.is-delay {
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.project-overview-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 196px;
|
||||
}
|
||||
|
||||
.project-overview-head,
|
||||
.project-overview-bar-label,
|
||||
.project-overview-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.project-overview-title {
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.project-overview-diff {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.project-overview-bar-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.project-overview-bar-label {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.project-overview-footer {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.period-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.period-header.is-current .period-header-label {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.period-header-label {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.period-header-sub-label {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.period-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
min-height: 88px;
|
||||
}
|
||||
|
||||
.period-cell.is-empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.period-cell-values {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.period-bar-track {
|
||||
position: relative;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: #eef3f8;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.period-bar-plan,
|
||||
.period-bar-actual {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.period-bar-plan {
|
||||
background: rgba(100, 149, 237, 0.45);
|
||||
}
|
||||
|
||||
.period-bar-actual {
|
||||
background: linear-gradient(90deg, #ffbe63 0%, #ff8a00 100%);
|
||||
}
|
||||
|
||||
.period-cell-tip {
|
||||
align-self: flex-start;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f4f7fb;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.period-cell-tip.is-delay {
|
||||
background: #fff1f2;
|
||||
color: #be123c;
|
||||
}
|
||||
|
||||
.project-progress-summary-title {
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.project-progress-summary-overview,
|
||||
.project-progress-summary-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.project-progress-stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.project-progress-title-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.project-progress-stats {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.project-progress-filter-card,
|
||||
.project-progress-table-card,
|
||||
.project-progress-hero {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, Col, Row, message, Table, Button, Input, Form, Space } from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { ReloadOutlined, DeleteOutlined, KeyOutlined, FileTextOutlined, AppstoreOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
listCacheName,
|
||||
listCacheKey,
|
||||
getCacheValue,
|
||||
clearCacheName,
|
||||
clearCacheKey,
|
||||
clearCacheAll,
|
||||
} from '../../api/monitor/cache';
|
||||
import type {
|
||||
CacheKeyPayload,
|
||||
CacheKeyRecord,
|
||||
CacheNamePayload,
|
||||
CacheNameRecord,
|
||||
} from '@/types/api';
|
||||
import './cache-list.css';
|
||||
|
||||
interface CacheForm {
|
||||
cacheName: string;
|
||||
cacheKey: string;
|
||||
cacheValue: string;
|
||||
}
|
||||
|
||||
const defaultForm: CacheForm = {
|
||||
cacheName: '',
|
||||
cacheKey: '',
|
||||
cacheValue: '',
|
||||
};
|
||||
|
||||
const stringifyValue = (value: unknown): string => {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const toCacheNameRecord = (item: string | CacheNameRecord): CacheNameRecord => {
|
||||
if (typeof item === 'string') {
|
||||
return { cacheName: item, remark: `缓存 ${item}` };
|
||||
}
|
||||
return {
|
||||
cacheName: item.cacheName,
|
||||
remark: item.remark ?? `缓存 ${item.cacheName}`,
|
||||
};
|
||||
};
|
||||
|
||||
const toCacheKeyString = (item: string | CacheKeyRecord): string => {
|
||||
return typeof item === 'string' ? item : item.cacheKey;
|
||||
};
|
||||
|
||||
const extractCacheNames = (payload: CacheNamePayload): CacheNameRecord[] => {
|
||||
if (!payload) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
return payload.map((item) => toCacheNameRecord(item));
|
||||
}
|
||||
|
||||
if ('cacheNames' in payload && Array.isArray(payload.cacheNames)) {
|
||||
return payload.cacheNames.map((item) => toCacheNameRecord(item));
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const extractCacheKeys = (payload: CacheKeyPayload): string[] => {
|
||||
if (!payload) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
return payload.map((item) => toCacheKeyString(item));
|
||||
}
|
||||
|
||||
if ('cacheKeys' in payload && Array.isArray(payload.cacheKeys)) {
|
||||
return payload.cacheKeys.map((item) => toCacheKeyString(item));
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const CacheListPage: React.FC = () => {
|
||||
const [cacheNames, setCacheNames] = useState<CacheNameRecord[]>([]);
|
||||
const [cacheKeys, setCacheKeys] = useState<string[]>([]);
|
||||
const [cacheForm, setCacheForm] = useState<CacheForm>(defaultForm);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [subLoading, setSubLoading] = useState(false);
|
||||
const [nowCacheName, setNowCacheName] = useState('');
|
||||
const [tableHeight, setTableHeight] = useState(window.innerHeight - 200);
|
||||
|
||||
const getCacheKeys = useCallback(
|
||||
async (cacheName?: string) => {
|
||||
const targetCacheName = cacheName ?? nowCacheName;
|
||||
if (!targetCacheName) {
|
||||
setCacheKeys([]);
|
||||
setCacheForm(defaultForm);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubLoading(true);
|
||||
try {
|
||||
const response = await listCacheKey(targetCacheName);
|
||||
const keys = extractCacheKeys(response);
|
||||
setCacheKeys(keys);
|
||||
setNowCacheName(targetCacheName);
|
||||
setCacheForm({
|
||||
cacheName: targetCacheName,
|
||||
cacheKey: '',
|
||||
cacheValue: '',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to fetch cache keys:', error);
|
||||
message.error('获取缓存键名列表失败');
|
||||
} finally {
|
||||
setSubLoading(false);
|
||||
}
|
||||
},
|
||||
[nowCacheName],
|
||||
);
|
||||
|
||||
const getCacheNames = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await listCacheName();
|
||||
const names = extractCacheNames(response);
|
||||
setCacheNames(names);
|
||||
|
||||
if (names.length === 0) {
|
||||
setNowCacheName('');
|
||||
setCacheKeys([]);
|
||||
setCacheForm(defaultForm);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetCacheName =
|
||||
nowCacheName && names.some((item) => item.cacheName === nowCacheName)
|
||||
? nowCacheName
|
||||
: names[0].cacheName;
|
||||
|
||||
await getCacheKeys(targetCacheName);
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to fetch cache names:', error);
|
||||
message.error('获取缓存名称列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [getCacheKeys, nowCacheName]);
|
||||
|
||||
const handleCacheValue = useCallback(
|
||||
async (fullCacheKey: string) => {
|
||||
if (!nowCacheName || !fullCacheKey) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await getCacheValue(nowCacheName, fullCacheKey);
|
||||
setCacheForm({
|
||||
cacheName: nowCacheName,
|
||||
cacheKey: fullCacheKey,
|
||||
cacheValue: stringifyValue(response),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to fetch cache value:', error);
|
||||
message.error('获取缓存内容失败');
|
||||
}
|
||||
},
|
||||
[nowCacheName],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void getCacheNames();
|
||||
const handleResize = () => {
|
||||
setTableHeight(window.innerHeight - 200);
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [getCacheNames]);
|
||||
|
||||
const refreshCacheNames = async () => {
|
||||
await getCacheNames();
|
||||
message.success('刷新缓存列表成功');
|
||||
};
|
||||
|
||||
const handleClearCacheName = async (row: CacheNameRecord) => {
|
||||
try {
|
||||
await clearCacheName(row.cacheName);
|
||||
message.success(`清理缓存名称[${row.cacheName}]成功`);
|
||||
await getCacheNames();
|
||||
} catch {
|
||||
message.error('清理缓存名称失败');
|
||||
}
|
||||
};
|
||||
|
||||
const refreshCacheKeys = async () => {
|
||||
await getCacheKeys();
|
||||
message.success('刷新键名列表成功');
|
||||
};
|
||||
|
||||
const handleClearCacheKey = async (fullCacheKey: string) => {
|
||||
if (!nowCacheName) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await clearCacheKey(fullCacheKey);
|
||||
message.success(`清理缓存键名[${fullCacheKey}]成功`);
|
||||
await getCacheKeys(nowCacheName);
|
||||
setCacheForm({ cacheName: nowCacheName, cacheKey: '', cacheValue: '' });
|
||||
} catch {
|
||||
message.error('清理缓存键名失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearCacheAll = async () => {
|
||||
try {
|
||||
await clearCacheAll();
|
||||
message.success('清理全部缓存成功');
|
||||
await getCacheNames();
|
||||
} catch {
|
||||
message.error('清理全部缓存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const nameFormatter = (cacheName: string) => {
|
||||
return cacheName.endsWith(':') ? cacheName.slice(0, -1) : cacheName;
|
||||
};
|
||||
|
||||
const keyFormatter = (fullCacheKey: string) => {
|
||||
const prefix = nowCacheName.endsWith(':') ? nowCacheName : `${nowCacheName}:`;
|
||||
return fullCacheKey.replace(prefix, '');
|
||||
};
|
||||
|
||||
const cacheNamesColumns: TableColumnsType<CacheNameRecord> = [
|
||||
{
|
||||
title: '序号',
|
||||
key: 'index',
|
||||
width: 60,
|
||||
render: (_text, _record, index) => index + 1,
|
||||
},
|
||||
{
|
||||
title: '缓存名称',
|
||||
dataIndex: 'cacheName',
|
||||
ellipsis: true,
|
||||
render: (text) => nameFormatter(String(text ?? '')),
|
||||
},
|
||||
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'operation',
|
||||
width: 60,
|
||||
render: (_text, record) => (
|
||||
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => void handleClearCacheName(record)} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const cacheKeysColumns: TableColumnsType<{ key: string; cacheKey: string }> = [
|
||||
{
|
||||
title: '序号',
|
||||
key: 'index',
|
||||
width: 60,
|
||||
render: (_text, _record, index) => index + 1,
|
||||
},
|
||||
{
|
||||
title: '缓存键名',
|
||||
dataIndex: 'cacheKey',
|
||||
ellipsis: true,
|
||||
render: (text) => keyFormatter(String(text ?? '')),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'operation',
|
||||
width: 60,
|
||||
render: (_text, record) => (
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => void handleClearCacheKey(record.cacheKey)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-container cache-list-container">
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={8}>
|
||||
<Card
|
||||
title={<Space><AppstoreOutlined /> 缓存列表</Space>}
|
||||
extra={<Button type="text" icon={<ReloadOutlined />} onClick={() => void refreshCacheNames()} />}
|
||||
style={{ height: tableHeight + 50, overflow: 'auto' }}
|
||||
>
|
||||
<Table
|
||||
columns={cacheNamesColumns}
|
||||
dataSource={cacheNames}
|
||||
rowKey="cacheName"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
onRow={(record) => ({
|
||||
onClick: () => void getCacheKeys(record.cacheName),
|
||||
})}
|
||||
scroll={{ y: tableHeight - 60 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={8}>
|
||||
<Card
|
||||
title={<Space><KeyOutlined /> 键名列表</Space>}
|
||||
extra={
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => void refreshCacheKeys()}
|
||||
disabled={!nowCacheName}
|
||||
/>
|
||||
}
|
||||
style={{ height: tableHeight + 50, overflow: 'auto' }}
|
||||
>
|
||||
<Table
|
||||
columns={cacheKeysColumns}
|
||||
dataSource={cacheKeys.map((key) => ({ key, cacheKey: key }))}
|
||||
rowKey="key"
|
||||
loading={subLoading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
onRow={(record) => ({
|
||||
onClick: () => void handleCacheValue(record.cacheKey),
|
||||
})}
|
||||
scroll={{ y: tableHeight - 60 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={8}>
|
||||
<Card
|
||||
title={<Space><FileTextOutlined /> 缓存内容</Space>}
|
||||
extra={<Button type="text" danger onClick={() => void handleClearCacheAll()}>清理全部</Button>}
|
||||
style={{ height: tableHeight + 50, overflow: 'auto' }}
|
||||
>
|
||||
<Form layout="vertical" initialValues={cacheForm}>
|
||||
<Form.Item label="缓存名称:">
|
||||
<Input value={nameFormatter(cacheForm.cacheName)} readOnly />
|
||||
</Form.Item>
|
||||
<Form.Item label="缓存键名:">
|
||||
<Input value={cacheForm.cacheKey} readOnly />
|
||||
</Form.Item>
|
||||
<Form.Item label="缓存内容:">
|
||||
<Input.TextArea value={cacheForm.cacheValue} rows={8} readOnly />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CacheListPage;
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Row, Col, Card, Descriptions, Spin, message, Typography } from 'antd';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts/core';
|
||||
import { macarons } from '../../themes/macarons';
|
||||
import { getCache } from '../../api/monitor/cache';
|
||||
import type { CacheMonitorResponse } from '@/types/api';
|
||||
import './cache-monitor.css';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
echarts.registerTheme('macarons', macarons);
|
||||
|
||||
const defaultCacheData: CacheMonitorResponse = {
|
||||
info: {},
|
||||
dbSize: 0,
|
||||
commandStats: [],
|
||||
};
|
||||
|
||||
const toDisplayText = (value: string | number | undefined): string => {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const CacheMonitorPage = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [cacheData, setCacheData] = useState<CacheMonitorResponse>(defaultCacheData);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getCache()
|
||||
.then((response) => {
|
||||
setCacheData(response);
|
||||
})
|
||||
.catch(() => {
|
||||
const errorMsg = '加载缓存数据失败,请确认后端服务是否正常。';
|
||||
setError(errorMsg);
|
||||
message.error(errorMsg);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <Spin spinning={true} description="正在加载缓存监控数据..." style={{ display: 'block', marginTop: '50px' }} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div style={{ textAlign: 'center', marginTop: '50px' }}><Text type="danger">{error}</Text></div>;
|
||||
}
|
||||
|
||||
const commandStatsOptions = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b} : {c} ({d}%)',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '命令',
|
||||
type: 'pie',
|
||||
roseType: 'radius',
|
||||
radius: [15, 95],
|
||||
center: ['50%', '38%'],
|
||||
data: cacheData.commandStats || [],
|
||||
animationEasing: 'cubicInOut',
|
||||
animationDuration: 1000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const usedMemoryOptions = {
|
||||
tooltip: {
|
||||
formatter: `{b} <br/>{a} : ${toDisplayText(cacheData.info?.used_memory_human)}`,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '峰值',
|
||||
type: 'gauge',
|
||||
min: 0,
|
||||
max: 1000,
|
||||
detail: {
|
||||
formatter: toDisplayText(cacheData.info?.used_memory_human),
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: Number.parseFloat(toDisplayText(cacheData.info?.used_memory_human)) || 0,
|
||||
name: '内存消耗',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cache-monitor-container">
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={24} className="card-box">
|
||||
<Card title="基本信息">
|
||||
<Descriptions bordered column={4}>
|
||||
<Descriptions.Item label="Redis版本">{toDisplayText(cacheData.info?.redis_version)}</Descriptions.Item>
|
||||
<Descriptions.Item label="运行模式">
|
||||
{cacheData.info?.redis_mode === 'standalone' ? '单机' : toDisplayText(cacheData.info?.redis_mode)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="端口">{toDisplayText(cacheData.info?.tcp_port)}</Descriptions.Item>
|
||||
<Descriptions.Item label="客户端数">{toDisplayText(cacheData.info?.connected_clients)}</Descriptions.Item>
|
||||
<Descriptions.Item label="运行时间(天)">{toDisplayText(cacheData.info?.uptime_in_days)}</Descriptions.Item>
|
||||
<Descriptions.Item label="使用内存">{toDisplayText(cacheData.info?.used_memory_human)}</Descriptions.Item>
|
||||
<Descriptions.Item label="使用CPU">
|
||||
{cacheData.info?.used_cpu_user_children
|
||||
? Number.parseFloat(toDisplayText(cacheData.info.used_cpu_user_children)).toFixed(2)
|
||||
: ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="内存配置">{toDisplayText(cacheData.info?.maxmemory_human)}</Descriptions.Item>
|
||||
<Descriptions.Item label="AOF是否开启">
|
||||
{cacheData.info?.aof_enabled === '0' ? '否' : (cacheData.info?.aof_enabled ? '是' : '')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="RDB是否成功">{toDisplayText(cacheData.info?.rdb_last_bgsave_status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Key数量">{cacheData.dbSize || ''}</Descriptions.Item>
|
||||
<Descriptions.Item label="网络入口/出口">
|
||||
{cacheData.info?.instantaneous_input_kbps
|
||||
? `${cacheData.info.instantaneous_input_kbps}kps/${cacheData.info.instantaneous_output_kbps}kps`
|
||||
: ''}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12} className="card-box">
|
||||
<Card title="命令统计">
|
||||
<ReactECharts theme="macarons" option={commandStatsOptions} style={{ height: '420px' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12} className="card-box">
|
||||
<Card title="内存信息">
|
||||
<ReactECharts theme="macarons" option={usedMemoryOptions} style={{ height: '420px' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CacheMonitorPage;
|
||||
|
|
@ -0,0 +1,585 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Key } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
Dropdown,
|
||||
Modal,
|
||||
message,
|
||||
Space,
|
||||
Tag,
|
||||
Switch,
|
||||
Row,
|
||||
Col,
|
||||
} from 'antd';
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
SyncOutlined,
|
||||
QuestionCircleOutlined,
|
||||
DownOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { TableColumnsType, MenuProps } from 'antd';
|
||||
import {
|
||||
listJob,
|
||||
getJob,
|
||||
delJob,
|
||||
addJob,
|
||||
updateJob,
|
||||
runJob,
|
||||
changeJobStatus,
|
||||
} from '../../api/monitor/job';
|
||||
import { saveAs } from 'file-saver';
|
||||
import dayjs from 'dayjs';
|
||||
import type { JobQueryParams, JobRecord } from '@/types/api';
|
||||
import './job-monitor.css';
|
||||
|
||||
const sysJobGroupDict = [
|
||||
{ value: 'DEFAULT', label: '默认' },
|
||||
{ value: 'SYSTEM', label: '系统' },
|
||||
];
|
||||
|
||||
const sysJobStatusDict = [
|
||||
{ value: '0', label: '正常' },
|
||||
{ value: '1', label: '暂停' },
|
||||
];
|
||||
|
||||
const parseTime = (time?: string | number | Date, pattern = 'YYYY-MM-DD HH:mm:ss'): string => {
|
||||
return time ? dayjs(time).format(pattern) : '';
|
||||
};
|
||||
|
||||
const escapeCsvCell = (value: unknown): string => {
|
||||
const raw = value === undefined || value === null ? '' : String(value);
|
||||
return `"${raw.replace(/"/g, '""')}"`;
|
||||
};
|
||||
|
||||
const normalizeRowKey = (value: Key): string | number => {
|
||||
return typeof value === 'bigint' ? value.toString() : value;
|
||||
};
|
||||
|
||||
const JobMonitorPage = () => {
|
||||
const [form] = Form.useForm<JobRecord>();
|
||||
const [queryForm] = Form.useForm<JobQueryParams>();
|
||||
const [jobList, setJobList] = useState<JobRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [modalTitle, setModalTitle] = useState('');
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [cronGenModalVisible, setCronGenModalVisible] = useState(false);
|
||||
const [currentJobDetail, setCurrentJobDetail] = useState<JobRecord>({});
|
||||
|
||||
const [queryParams, setQueryParams] = useState<JobQueryParams>({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
jobName: undefined,
|
||||
jobGroup: undefined,
|
||||
status: undefined,
|
||||
});
|
||||
|
||||
const getList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await listJob(queryParams);
|
||||
setJobList(response.rows ?? []);
|
||||
setTotal(Number(response.total ?? 0));
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to fetch job list:', error);
|
||||
message.error('获取定时任务列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [queryParams]);
|
||||
|
||||
useEffect(() => {
|
||||
void getList();
|
||||
}, [getList]);
|
||||
|
||||
const handleQuery = () => {
|
||||
const values = queryForm.getFieldsValue();
|
||||
setQueryParams((prev) => ({ ...prev, ...values, pageNum: 1 }));
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
queryForm.resetFields();
|
||||
setQueryParams({ pageNum: 1, pageSize: 10, jobName: undefined, jobGroup: undefined, status: undefined });
|
||||
};
|
||||
|
||||
const handleSelectionChange = (selectedKeys: Key[]) => {
|
||||
setSelectedRowKeys(selectedKeys);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
form.resetFields();
|
||||
setModalTitle('添加任务');
|
||||
setModalVisible(true);
|
||||
setCurrentJobDetail({ misfirePolicy: '1', concurrent: '1', status: '0' });
|
||||
};
|
||||
|
||||
const handleUpdate = async (record?: JobRecord) => {
|
||||
form.resetFields();
|
||||
const selectedKey = record?.jobId ?? selectedRowKeys[0];
|
||||
const jobId = selectedKey === undefined ? undefined : normalizeRowKey(selectedKey);
|
||||
if (jobId === undefined) {
|
||||
message.warning('请选择要修改的任务');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getJob(jobId);
|
||||
setCurrentJobDetail(response);
|
||||
form.setFieldsValue({
|
||||
...response,
|
||||
status: response.status?.toString(),
|
||||
misfirePolicy: response.misfirePolicy?.toString(),
|
||||
concurrent: response.concurrent?.toString(),
|
||||
});
|
||||
setModalTitle('修改任务');
|
||||
setModalVisible(true);
|
||||
} catch {
|
||||
message.error('获取任务详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (record?: JobRecord) => {
|
||||
const rawIds = record?.jobId !== undefined ? [record.jobId] : selectedRowKeys;
|
||||
const jobIds = rawIds.map((item) => normalizeRowKey(item));
|
||||
if (jobIds.length === 0) {
|
||||
message.warning('请选择要删除的任务');
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `是否确认删除定时任务编号为"${jobIds.join(',')}"的数据项?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await delJob(jobIds.join(','));
|
||||
message.success('删除成功');
|
||||
setSelectedRowKeys([]);
|
||||
void getList();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleStatusChange = async (record: JobRecord) => {
|
||||
if (record.jobId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalStatus = String(record.status ?? '1');
|
||||
const newStatus = originalStatus === '0' ? '1' : '0';
|
||||
try {
|
||||
await changeJobStatus(record.jobId, newStatus);
|
||||
message.success('状态修改成功');
|
||||
void getList();
|
||||
} catch {
|
||||
message.error('状态修改失败');
|
||||
setJobList((prev) =>
|
||||
prev.map((job) =>
|
||||
job.jobId === record.jobId
|
||||
? {
|
||||
...job,
|
||||
status: originalStatus,
|
||||
}
|
||||
: job,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRun = async (record: JobRecord) => {
|
||||
if (record.jobId === undefined || !record.jobGroup) {
|
||||
message.warning('当前任务信息不完整,无法执行');
|
||||
return;
|
||||
}
|
||||
const { jobId, jobGroup } = record;
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认执行',
|
||||
content: `确认要立即执行一次"${record.jobName ?? ''}"任务吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await runJob(jobId, jobGroup);
|
||||
message.success('执行成功');
|
||||
} catch {
|
||||
message.error('执行失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
const hide = message.loading('正在导出数据...', 0);
|
||||
try {
|
||||
const response = await listJob({ ...queryParams, pageNum: undefined, pageSize: undefined });
|
||||
const header = ['任务编号', '任务名称', '任务组名', '调用目标字符串', 'cron执行表达式', '状态'];
|
||||
const rows = response.rows.map((job) => [
|
||||
job.jobId,
|
||||
job.jobName,
|
||||
jobGroupFormat(job),
|
||||
job.invokeTarget,
|
||||
job.cronExpression,
|
||||
sysJobStatusDict.find((dict) => dict.value === String(job.status ?? ''))?.label ?? '',
|
||||
]);
|
||||
|
||||
const csvContent = [header, ...rows]
|
||||
.map((row) => row.map((cell) => escapeCsvCell(cell)).join(','))
|
||||
.join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
saveAs(blob, `job_${dayjs().format('YYYYMMDDHHmmss')}.csv`);
|
||||
hide();
|
||||
message.success('导出成功');
|
||||
} catch {
|
||||
hide();
|
||||
message.error('导出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleJobLog = (record?: JobRecord) => {
|
||||
const jobId = record?.jobId ?? 0;
|
||||
message.info(`跳转到任务日志页面 (Job ID: ${jobId})`);
|
||||
};
|
||||
|
||||
const handleView = async (record: JobRecord) => {
|
||||
if (record.jobId === undefined) {
|
||||
message.warning('任务编号缺失');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getJob(record.jobId);
|
||||
setCurrentJobDetail(response);
|
||||
setDetailModalVisible(true);
|
||||
} catch {
|
||||
message.error('获取任务详细信息失败');
|
||||
}
|
||||
};
|
||||
|
||||
const submitForm = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const jobData: JobRecord = { ...currentJobDetail, ...values };
|
||||
if (jobData.jobId !== undefined) {
|
||||
await updateJob(jobData);
|
||||
message.success('修改成功');
|
||||
} else {
|
||||
await addJob(jobData);
|
||||
message.success('新增成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
void getList();
|
||||
} catch (error: unknown) {
|
||||
console.error('Submit job form failed:', error);
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const jobGroupFormat = (record: JobRecord): string => {
|
||||
const value = String(record.jobGroup ?? '');
|
||||
return sysJobGroupDict.find((dict) => dict.value === value)?.label ?? value;
|
||||
};
|
||||
|
||||
const columns: TableColumnsType<JobRecord> = [
|
||||
Table.SELECTION_COLUMN,
|
||||
{ title: '任务编号', dataIndex: 'jobId', align: 'center', width: 100 },
|
||||
{ title: '任务名称', dataIndex: 'jobName', align: 'center', ellipsis: true },
|
||||
{
|
||||
title: '任务组名',
|
||||
dataIndex: 'jobGroup',
|
||||
align: 'center',
|
||||
render: (_value, record) => jobGroupFormat(record),
|
||||
},
|
||||
{ title: '调用目标字符串', dataIndex: 'invokeTarget', align: 'center', ellipsis: true },
|
||||
{ title: 'cron执行表达式', dataIndex: 'cronExpression', align: 'center', ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
align: 'center',
|
||||
render: (_value, record) => (
|
||||
<Switch
|
||||
checked={String(record.status ?? '') === '0'}
|
||||
checkedChildren="正常"
|
||||
unCheckedChildren="暂停"
|
||||
onChange={() => void handleStatusChange(record)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'operation',
|
||||
align: 'center',
|
||||
width: 300,
|
||||
render: (_value, record) => {
|
||||
const moreItems: MenuProps['items'] = [
|
||||
{ key: 'runOnce', label: '执行一次' },
|
||||
{ key: 'taskDetail', label: '任务详情' },
|
||||
{ key: 'scheduleTask', label: '调度任务' },
|
||||
];
|
||||
|
||||
const handleMoreMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
if (key === 'runOnce') {
|
||||
void handleRun(record);
|
||||
return;
|
||||
}
|
||||
if (key === 'taskDetail') {
|
||||
void handleView(record);
|
||||
return;
|
||||
}
|
||||
if (key === 'scheduleTask') {
|
||||
handleJobLog(record);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space size="small">
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => void handleUpdate(record)}>
|
||||
修改
|
||||
</Button>
|
||||
<Button type="link" icon={<DeleteOutlined />} danger onClick={() => void handleDelete(record)}>
|
||||
删除
|
||||
</Button>
|
||||
<Dropdown menu={{ items: moreItems, onClick: handleMoreMenuClick }} trigger={['click']}>
|
||||
<Button type="link">
|
||||
更多 <DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-container job-monitor-container">
|
||||
<Form form={queryForm} layout="inline" className="search-form" onFinish={handleQuery} initialValues={queryParams}>
|
||||
<Form.Item label="任务名称" name="jobName">
|
||||
<Input placeholder="请输入任务名称" allowClear={true} onPressEnter={handleQuery} />
|
||||
</Form.Item>
|
||||
<Form.Item label="任务组名" name="jobGroup">
|
||||
<Select placeholder="请选择任务组名" allowClear={true} style={{ width: 120 }}>
|
||||
{sysJobGroupDict.map((dict) => (
|
||||
<Select.Option key={dict.value} value={dict.value}>
|
||||
{dict.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="任务状态" name="status">
|
||||
<Select placeholder="请选择任务状态" allowClear={true} style={{ width: 120 }}>
|
||||
{sysJobStatusDict.map((dict) => (
|
||||
<Select.Option key={dict.value} value={dict.value}>
|
||||
{dict.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
搜索
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetQuery} style={{ marginLeft: 8 }}>
|
||||
重置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Space className="mb8">
|
||||
<Button type="primary" ghost icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
新增
|
||||
</Button>
|
||||
<Button type="primary" ghost icon={<EditOutlined />} disabled={selectedRowKeys.length !== 1} onClick={() => void handleUpdate()}>
|
||||
修改
|
||||
</Button>
|
||||
<Button type="primary" danger ghost icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0} onClick={() => void handleDelete()}>
|
||||
删除
|
||||
</Button>
|
||||
<Button type="default" ghost icon={<DownloadOutlined />} onClick={() => void handleExport()}>
|
||||
导出
|
||||
</Button>
|
||||
<Button type="default" ghost icon={<SyncOutlined />} onClick={() => handleJobLog()}>
|
||||
日志
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={jobList}
|
||||
rowKey="jobId"
|
||||
loading={loading}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: handleSelectionChange,
|
||||
}}
|
||||
pagination={{
|
||||
current: queryParams.pageNum,
|
||||
pageSize: queryParams.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (count) => `共 ${count} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setQueryParams((prev) => ({ ...prev, pageNum: page, pageSize }));
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={modalTitle}
|
||||
open={modalVisible}
|
||||
onOk={submitForm}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={800}
|
||||
forceRender
|
||||
>
|
||||
<Form form={form} labelCol={{ span: 5 }} wrapperCol={{ span: 16 }} initialValues={currentJobDetail}>
|
||||
<Row>
|
||||
<Col span={12}>
|
||||
<Form.Item label="任务名称" name="jobName" rules={[{ required: true, message: '请输入任务名称' }]}>
|
||||
<Input placeholder="请输入任务名称" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="任务分组" name="jobGroup" rules={[{ required: true, message: '请选择任务分组' }]}>
|
||||
<Select placeholder="请选择任务分组">
|
||||
{sysJobGroupDict.map((dict) => (
|
||||
<Select.Option key={dict.value} value={dict.value}>
|
||||
{dict.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item label="调用方法" name="invokeTarget" rules={[{ required: true, message: '请输入调用目标字符串' }]}>
|
||||
<Input
|
||||
placeholder="请输入调用目标字符串"
|
||||
suffix={
|
||||
<QuestionCircleOutlined title="Bean调用示例:ryTask.ryParams('ry') Class类调用示例:com.ruoyi.quartz.task.RyTask.ryParams('ry') 参数说明:支持字符串,布尔类型,长整型,浮点型,整型" />
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item label="cron表达式" name="cronExpression" rules={[{ required: true, message: '请输入cron执行表达式' }]}>
|
||||
<Space.Compact>
|
||||
<Input placeholder="请输入cron执行表达式" />
|
||||
<Button type="primary" onClick={() => setCronGenModalVisible(true)}>
|
||||
生成表达式
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{currentJobDetail.jobId !== undefined && (
|
||||
<Col span={24}>
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select>
|
||||
{sysJobStatusDict.map((dict) => (
|
||||
<Select.Option key={dict.value} value={dict.value}>
|
||||
{dict.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
<Col span={12}>
|
||||
<Form.Item label="执行策略" name="misfirePolicy">
|
||||
<Select>
|
||||
<Select.Option value="1">立即执行</Select.Option>
|
||||
<Select.Option value="2">执行一次</Select.Option>
|
||||
<Select.Option value="3">放弃执行</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="是否并发" name="concurrent">
|
||||
<Select>
|
||||
<Select.Option value="0">允许</Select.Option>
|
||||
<Select.Option value="1">禁止</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="Cron表达式生成器"
|
||||
open={cronGenModalVisible}
|
||||
onCancel={() => setCronGenModalVisible(false)}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
<p>Cron Expression Generator component goes here.</p>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="任务详细"
|
||||
open={detailModalVisible}
|
||||
onCancel={() => setDetailModalVisible(false)}
|
||||
footer={[<Button key="close" onClick={() => setDetailModalVisible(false)}>关 闭</Button>]}
|
||||
width={700}
|
||||
>
|
||||
<Form labelCol={{ span: 6 }} wrapperCol={{ span: 16 }} className="job-detail-form">
|
||||
<Row>
|
||||
<Col span={12}>
|
||||
<Form.Item label="任务编号:">{currentJobDetail.jobId}</Form.Item>
|
||||
<Form.Item label="任务名称:">{currentJobDetail.jobName}</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="任务分组:">{jobGroupFormat(currentJobDetail)}</Form.Item>
|
||||
<Form.Item label="创建时间:">{parseTime(currentJobDetail.createTime)}</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="cron表达式:">{currentJobDetail.cronExpression}</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="下次执行时间:">{parseTime(currentJobDetail.nextValidTime)}</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item label="调用目标方法:">{currentJobDetail.invokeTarget}</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="任务状态:">
|
||||
{String(currentJobDetail.status ?? '') === '0' ? <Tag color="green">正常</Tag> : <Tag color="red">暂停</Tag>}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="是否并发:">
|
||||
{String(currentJobDetail.concurrent ?? '') === '0' ? <Tag color="green">允许</Tag> : <Tag color="red">禁止</Tag>}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="执行策略:">
|
||||
{String(currentJobDetail.misfirePolicy ?? '') === '0'
|
||||
? '默认策略'
|
||||
: String(currentJobDetail.misfirePolicy ?? '') === '1'
|
||||
? '立即执行'
|
||||
: String(currentJobDetail.misfirePolicy ?? '') === '2'
|
||||
? '执行一次'
|
||||
: String(currentJobDetail.misfirePolicy ?? '') === '3'
|
||||
? '放弃执行'
|
||||
: ''}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobMonitorPage;
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Key } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
Modal,
|
||||
message,
|
||||
Space,
|
||||
Tag,
|
||||
DatePicker,
|
||||
} from 'antd';
|
||||
import type { TableColumnsType, TableProps } from 'antd';
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
UnlockOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
listLogininfor,
|
||||
delLogininfor,
|
||||
cleanLogininfor,
|
||||
unlockLogininfor,
|
||||
} from '../../api/monitor/logininfor';
|
||||
import { saveAs } from 'file-saver';
|
||||
import dayjs from 'dayjs';
|
||||
import type { LogininforQueryParams, LogininforRecord } from '@/types/api';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
type DateRange = [dayjs.Dayjs | null, dayjs.Dayjs | null] | null;
|
||||
|
||||
type SortDirection = 'ascending' | 'descending';
|
||||
|
||||
const sysCommonStatusDict = [
|
||||
{ value: '0', label: '正常', type: 'success' },
|
||||
{ value: '1', label: '失败', type: 'error' },
|
||||
] as const;
|
||||
|
||||
const defaultQueryParams: LogininforQueryParams = {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
ipaddr: undefined,
|
||||
userName: undefined,
|
||||
status: undefined,
|
||||
orderByColumn: 'loginTime',
|
||||
isAsc: 'descending',
|
||||
};
|
||||
|
||||
const parseTime = (time?: string | number | Date, pattern = 'YYYY-MM-DD HH:mm:ss'): string => {
|
||||
return time ? dayjs(time).format(pattern) : '';
|
||||
};
|
||||
|
||||
const escapeCsvCell = (value: unknown): string => {
|
||||
const raw = value === undefined || value === null ? '' : String(value);
|
||||
return `"${raw.replace(/"/g, '""')}"`;
|
||||
};
|
||||
|
||||
const LoginLogPage = () => {
|
||||
const [queryForm] = Form.useForm<LogininforQueryParams>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [list, setList] = useState<LogininforRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
||||
const [selectedRows, setSelectedRows] = useState<LogininforRecord[]>([]);
|
||||
const [dateRange, setDateRange] = useState<DateRange>(null);
|
||||
const [queryParams, setQueryParams] = useState<LogininforQueryParams>(defaultQueryParams);
|
||||
|
||||
const getList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const formattedQueryParams: LogininforQueryParams = { ...queryParams };
|
||||
if (dateRange?.[0] && dateRange?.[1]) {
|
||||
formattedQueryParams.beginTime = dateRange[0].format('YYYY-MM-DD HH:mm:ss');
|
||||
formattedQueryParams.endTime = dateRange[1].format('YYYY-MM-DD HH:mm:ss');
|
||||
} else {
|
||||
formattedQueryParams.beginTime = undefined;
|
||||
formattedQueryParams.endTime = undefined;
|
||||
}
|
||||
|
||||
const response = await listLogininfor(formattedQueryParams);
|
||||
setList(response.rows ?? []);
|
||||
setTotal(Number(response.total ?? 0));
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to fetch login logs:', error);
|
||||
message.error('获取登录日志列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dateRange, queryParams]);
|
||||
|
||||
useEffect(() => {
|
||||
void getList();
|
||||
}, [getList]);
|
||||
|
||||
const handleQuery = () => {
|
||||
const values = queryForm.getFieldsValue();
|
||||
setQueryParams((prev) => ({ ...prev, ...values, pageNum: 1 }));
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
queryForm.resetFields();
|
||||
setDateRange(null);
|
||||
setQueryParams(defaultQueryParams);
|
||||
};
|
||||
|
||||
const handleSelectionChange = (selectedKeys: Key[], rows: LogininforRecord[]) => {
|
||||
setSelectedRowKeys(selectedKeys);
|
||||
setSelectedRows(rows);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const infoIds = selectedRowKeys.map((key) => String(key)).join(',');
|
||||
if (infoIds.length === 0) {
|
||||
message.warning('请选择要删除的登录日志');
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `是否确认删除访问编号为"${infoIds}"的数据项?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await delLogininfor(infoIds);
|
||||
message.success('删除成功');
|
||||
setSelectedRowKeys([]);
|
||||
setSelectedRows([]);
|
||||
void getList();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleClean = async () => {
|
||||
Modal.confirm({
|
||||
title: '确认清空',
|
||||
content: '是否确认清空所有登录日志数据项?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await cleanLogininfor();
|
||||
message.success('清空成功');
|
||||
setSelectedRowKeys([]);
|
||||
setSelectedRows([]);
|
||||
void getList();
|
||||
} catch {
|
||||
message.error('清空失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnlock = async () => {
|
||||
const userNames = selectedRows
|
||||
.map((row) => row.userName)
|
||||
.filter((name): name is string => typeof name === 'string' && name.length > 0);
|
||||
|
||||
if (userNames.length !== 1) {
|
||||
message.warning('请选择一个用户进行解锁');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUserName = userNames[0];
|
||||
Modal.confirm({
|
||||
title: '确认解锁',
|
||||
content: `是否确认解锁用户"${targetUserName}"的登录状态?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await unlockLogininfor(targetUserName);
|
||||
message.success(`用户"${targetUserName}"解锁成功`);
|
||||
void getList();
|
||||
} catch {
|
||||
message.error('解锁失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
const hide = message.loading('正在导出数据...', 0);
|
||||
try {
|
||||
const response = await listLogininfor({
|
||||
...queryParams,
|
||||
pageNum: undefined,
|
||||
pageSize: undefined,
|
||||
});
|
||||
|
||||
const header = [
|
||||
'访问编号',
|
||||
'用户名称',
|
||||
'登录地址',
|
||||
'登录地点',
|
||||
'浏览器',
|
||||
'操作系统',
|
||||
'登录状态',
|
||||
'操作信息',
|
||||
'登录日期',
|
||||
];
|
||||
|
||||
const data = response.rows.map((log) => [
|
||||
log.infoId,
|
||||
log.userName,
|
||||
log.ipaddr,
|
||||
log.loginLocation,
|
||||
log.browser,
|
||||
log.os,
|
||||
sysCommonStatusDict.find((item) => item.value === String(log.status ?? ''))?.label ?? '',
|
||||
log.msg,
|
||||
parseTime(log.loginTime),
|
||||
]);
|
||||
|
||||
const csvContent = [header, ...data]
|
||||
.map((row) => row.map((cell) => escapeCsvCell(cell)).join(','))
|
||||
.join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
saveAs(blob, `logininfor_${dayjs().format('YYYYMMDDHHmmss')}.csv`);
|
||||
hide();
|
||||
message.success('导出成功');
|
||||
} catch {
|
||||
hide();
|
||||
message.error('导出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableChange: TableProps<LogininforRecord>['onChange'] = (pagination, _filters, sorter) => {
|
||||
const activeSorter = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
const orderByColumn =
|
||||
typeof activeSorter?.field === 'string' && activeSorter.field.length > 0
|
||||
? activeSorter.field
|
||||
: 'loginTime';
|
||||
|
||||
const isAsc: SortDirection = activeSorter?.order === 'ascend' ? 'ascending' : 'descending';
|
||||
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
orderByColumn,
|
||||
isAsc,
|
||||
pageNum: pagination.current ?? 1,
|
||||
pageSize: pagination.pageSize ?? 10,
|
||||
}));
|
||||
};
|
||||
|
||||
const statusFormat = (status?: string) => {
|
||||
const dict = sysCommonStatusDict.find((item) => item.value === String(status ?? ''));
|
||||
return dict ? <Tag color={dict.type === 'success' ? 'green' : 'red'}>{dict.label}</Tag> : String(status ?? '');
|
||||
};
|
||||
|
||||
const columns: TableColumnsType<LogininforRecord> = [
|
||||
Table.SELECTION_COLUMN,
|
||||
{ title: '访问编号', dataIndex: 'infoId', align: 'center', width: 90 },
|
||||
{ title: '用户名称', dataIndex: 'userName', align: 'center', ellipsis: true, sorter: true },
|
||||
{ title: '登录地址', dataIndex: 'ipaddr', align: 'center', ellipsis: true },
|
||||
{ title: '登录地点', dataIndex: 'loginLocation', align: 'center', ellipsis: true },
|
||||
{ title: '浏览器', dataIndex: 'browser', align: 'center', ellipsis: true },
|
||||
{ title: '操作系统', dataIndex: 'os', align: 'center', ellipsis: true },
|
||||
{
|
||||
title: '登录状态',
|
||||
dataIndex: 'status',
|
||||
align: 'center',
|
||||
render: (text) => statusFormat(typeof text === 'string' ? text : undefined),
|
||||
},
|
||||
{ title: '操作信息', dataIndex: 'msg', align: 'center', ellipsis: true },
|
||||
{
|
||||
title: '登录日期',
|
||||
dataIndex: 'loginTime',
|
||||
align: 'center',
|
||||
width: 180,
|
||||
sorter: true,
|
||||
render: (text) => parseTime(typeof text === 'string' ? text : undefined),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<Form form={queryForm} layout="inline" className="search-form" onFinish={handleQuery} initialValues={queryParams}>
|
||||
<Form.Item label="登录地址" name="ipaddr">
|
||||
<Input placeholder="请输入登录地址" allowClear onPressEnter={handleQuery} style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="用户名称" name="userName">
|
||||
<Input placeholder="请输入用户名称" allowClear onPressEnter={handleQuery} style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select placeholder="登录状态" allowClear style={{ width: 240 }}>
|
||||
{sysCommonStatusDict.map((dict) => (
|
||||
<Select.Option key={dict.value} value={dict.value}>
|
||||
{dict.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="登录时间">
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={(dates) => setDateRange(dates ? [dates[0], dates[1]] : null)}
|
||||
showTime={{
|
||||
defaultValue: [dayjs('00:00:00', 'HH:mm:ss'), dayjs('23:59:59', 'HH:mm:ss')],
|
||||
}}
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
搜索
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetQuery} style={{ marginLeft: 8 }}>
|
||||
重置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Space className="mb8">
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
ghost
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
onClick={() => void handleDelete()}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<Button type="primary" danger ghost icon={<DeleteOutlined />} onClick={() => void handleClean()}>
|
||||
清空
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<UnlockOutlined />}
|
||||
disabled={selectedRowKeys.length !== 1}
|
||||
onClick={() => void handleUnlock()}
|
||||
>
|
||||
解锁
|
||||
</Button>
|
||||
<Button type="default" ghost icon={<DownloadOutlined />} onClick={() => void handleExport()}>
|
||||
导出
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
rowKey="infoId"
|
||||
loading={loading}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: handleSelectionChange,
|
||||
}}
|
||||
pagination={{
|
||||
current: queryParams.pageNum,
|
||||
pageSize: queryParams.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (count) => `共 ${count} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setQueryParams((prev) => ({ ...prev, pageNum: page, pageSize }));
|
||||
},
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginLogPage;
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Table, Form, Input, Button, Modal, message } from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { SearchOutlined, ReloadOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { listOnline, forceLogout } from '../../api/monitor/online';
|
||||
import dayjs from 'dayjs';
|
||||
import type { OnlineQueryParams, OnlineRecord } from '@/types/api';
|
||||
|
||||
const defaultQueryParams: OnlineQueryParams = {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
ipaddr: undefined,
|
||||
userName: undefined,
|
||||
};
|
||||
|
||||
const parseTime = (time?: string | number | Date, pattern = 'YYYY-MM-DD HH:mm:ss'): string => {
|
||||
return time ? dayjs(time).format(pattern) : '';
|
||||
};
|
||||
|
||||
const normalizeDateValue = (value: unknown): string | number | Date | undefined => {
|
||||
if (typeof value === 'string' || typeof value === 'number' || value instanceof Date) {
|
||||
return value;
|
||||
}
|
||||
if (dayjs.isDayjs(value)) {
|
||||
return value.toDate();
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const OnlineUserPage = () => {
|
||||
const [queryForm] = Form.useForm<OnlineQueryParams>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [list, setList] = useState<OnlineRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const [queryParams, setQueryParams] = useState<OnlineQueryParams>(defaultQueryParams);
|
||||
|
||||
const getList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await listOnline(queryParams);
|
||||
setList(response.rows ?? []);
|
||||
setTotal(Number(response.total ?? 0));
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to fetch online users:', error);
|
||||
message.error('获取在线用户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [queryParams]);
|
||||
|
||||
useEffect(() => {
|
||||
void getList();
|
||||
}, [getList]);
|
||||
|
||||
const handleQuery = () => {
|
||||
const values = queryForm.getFieldsValue();
|
||||
setQueryParams((prev) => ({ ...prev, ...values, pageNum: 1 }));
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
queryForm.resetFields();
|
||||
setQueryParams(defaultQueryParams);
|
||||
};
|
||||
|
||||
const handleForceLogout = (row: OnlineRecord) => {
|
||||
if (!row.tokenId) {
|
||||
message.warning('当前会话缺少 tokenId,无法强退');
|
||||
return;
|
||||
}
|
||||
const tokenId = row.tokenId;
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认强退',
|
||||
content: `是否确认强退名称为"${row.userName ?? ''}"的用户?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await forceLogout(tokenId);
|
||||
message.success('强退成功');
|
||||
void getList();
|
||||
} catch {
|
||||
message.error('强退失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns: TableColumnsType<OnlineRecord> = [
|
||||
{
|
||||
title: '序号',
|
||||
key: 'serial',
|
||||
align: 'center',
|
||||
width: 60,
|
||||
render: (_value, _record, index) => {
|
||||
const pageNum = queryParams.pageNum ?? 1;
|
||||
const pageSize = queryParams.pageSize ?? 10;
|
||||
return (pageNum - 1) * pageSize + index + 1;
|
||||
},
|
||||
},
|
||||
{ title: '会话编号', dataIndex: 'tokenId', align: 'center', ellipsis: true },
|
||||
{ title: '登录名称', dataIndex: 'userName', align: 'center', ellipsis: true },
|
||||
{ title: '部门名称', dataIndex: 'deptName', align: 'center', ellipsis: true },
|
||||
{ title: '主机', dataIndex: 'ipaddr', align: 'center', ellipsis: true },
|
||||
{ title: '登录地点', dataIndex: 'loginLocation', align: 'center', ellipsis: true },
|
||||
{ title: '浏览器', dataIndex: 'browser', align: 'center', ellipsis: true },
|
||||
{ title: '操作系统', dataIndex: 'os', align: 'center', ellipsis: true },
|
||||
{
|
||||
title: '登录时间',
|
||||
dataIndex: 'loginTime',
|
||||
align: 'center',
|
||||
width: 180,
|
||||
render: (_text, record) => parseTime(normalizeDateValue(record.loginTime)),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'operation',
|
||||
align: 'center',
|
||||
width: 80,
|
||||
render: (_value, record) => (
|
||||
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleForceLogout(record)}>
|
||||
强退
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<Form form={queryForm} layout="inline" className="search-form" onFinish={handleQuery} initialValues={queryParams}>
|
||||
<Form.Item label="登录地址" name="ipaddr">
|
||||
<Input placeholder="请输入登录地址" allowClear onPressEnter={handleQuery} />
|
||||
</Form.Item>
|
||||
<Form.Item label="用户名称" name="userName">
|
||||
<Input placeholder="请输入用户名称" allowClear onPressEnter={handleQuery} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
搜索
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetQuery} style={{ marginLeft: 8 }}>
|
||||
重置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
rowKey="tokenId"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: queryParams.pageNum,
|
||||
pageSize: queryParams.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (count) => `共 ${count} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setQueryParams((prev) => ({ ...prev, pageNum: page, pageSize }));
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnlineUserPage;
|
||||
|
|
@ -0,0 +1,463 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Key } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
Modal,
|
||||
message,
|
||||
Space,
|
||||
Tag,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
} from 'antd';
|
||||
import type { TableColumnsType, TableProps } from 'antd';
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { listOperlog, delOperlog, cleanOperlog } from '../../api/monitor/operlog';
|
||||
import { saveAs } from 'file-saver';
|
||||
import dayjs from 'dayjs';
|
||||
import type { OperlogQueryParams, OperlogRecord } from '@/types/api';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
type DateRange = [dayjs.Dayjs | null, dayjs.Dayjs | null] | null;
|
||||
|
||||
type SortDirection = 'ascending' | 'descending';
|
||||
|
||||
const sysOperTypeDict = [
|
||||
{ value: '0', label: '其它', color: 'default' },
|
||||
{ value: '1', label: '新增', color: 'success' },
|
||||
{ value: '2', label: '修改', color: 'warning' },
|
||||
{ value: '3', label: '删除', color: 'red' },
|
||||
{ value: '4', label: '授权', color: 'processing' },
|
||||
{ value: '5', label: '导出', color: 'processing' },
|
||||
{ value: '6', label: '导入', color: 'processing' },
|
||||
{ value: '7', label: '强退', color: 'red' },
|
||||
{ value: '8', label: '生成代码', color: 'processing' },
|
||||
{ value: '9', label: '清空数据', color: 'red' },
|
||||
] as const;
|
||||
|
||||
const sysCommonStatusDict = [
|
||||
{ value: '0', label: '正常', color: 'success' },
|
||||
{ value: '1', label: '异常', color: 'error' },
|
||||
] as const;
|
||||
|
||||
const defaultQueryParams: OperlogQueryParams = {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
operIp: undefined,
|
||||
title: undefined,
|
||||
operName: undefined,
|
||||
businessType: undefined,
|
||||
status: undefined,
|
||||
orderByColumn: 'operTime',
|
||||
isAsc: 'descending',
|
||||
};
|
||||
|
||||
const parseTime = (time?: string | number | Date, pattern = 'YYYY-MM-DD HH:mm:ss'): string => {
|
||||
return time ? dayjs(time).format(pattern) : '';
|
||||
};
|
||||
|
||||
const normalizeDateValue = (value: unknown): string | number | Date | undefined => {
|
||||
if (typeof value === 'string' || typeof value === 'number' || value instanceof Date) {
|
||||
return value;
|
||||
}
|
||||
if (dayjs.isDayjs(value)) {
|
||||
return value.toDate();
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const escapeCsvCell = (value: unknown): string => {
|
||||
const raw = value === undefined || value === null ? '' : String(value);
|
||||
return `"${raw.replace(/"/g, '""')}"`;
|
||||
};
|
||||
|
||||
const OperationLogPage = () => {
|
||||
const [queryForm] = Form.useForm<OperlogQueryParams>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [list, setList] = useState<OperlogRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
||||
const [dateRange, setDateRange] = useState<DateRange>(null);
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [currentOperlogDetail, setCurrentOperlogDetail] = useState<OperlogRecord>({});
|
||||
const [queryParams, setQueryParams] = useState<OperlogQueryParams>(defaultQueryParams);
|
||||
|
||||
const getList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const formattedQueryParams: OperlogQueryParams = { ...queryParams };
|
||||
if (dateRange?.[0] && dateRange?.[1]) {
|
||||
formattedQueryParams.beginTime = dateRange[0].format('YYYY-MM-DD HH:mm:ss');
|
||||
formattedQueryParams.endTime = dateRange[1].format('YYYY-MM-DD HH:mm:ss');
|
||||
} else {
|
||||
formattedQueryParams.beginTime = undefined;
|
||||
formattedQueryParams.endTime = undefined;
|
||||
}
|
||||
|
||||
const response = await listOperlog(formattedQueryParams);
|
||||
setList(response.rows ?? []);
|
||||
setTotal(Number(response.total ?? 0));
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to fetch operation logs:', error);
|
||||
message.error('获取操作日志列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dateRange, queryParams]);
|
||||
|
||||
useEffect(() => {
|
||||
void getList();
|
||||
}, [getList]);
|
||||
|
||||
const handleQuery = () => {
|
||||
const values = queryForm.getFieldsValue();
|
||||
setQueryParams((prev) => ({ ...prev, ...values, pageNum: 1 }));
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
queryForm.resetFields();
|
||||
setDateRange(null);
|
||||
setQueryParams(defaultQueryParams);
|
||||
};
|
||||
|
||||
const handleSelectionChange = (selectedKeys: Key[]) => {
|
||||
setSelectedRowKeys(selectedKeys);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const operIds = selectedRowKeys.map((key) => String(key)).join(',');
|
||||
if (!operIds) {
|
||||
message.warning('请选择要删除的操作日志');
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `是否确认删除日志编号为"${operIds}"的数据项?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await delOperlog(operIds);
|
||||
message.success('删除成功');
|
||||
setSelectedRowKeys([]);
|
||||
void getList();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleClean = async () => {
|
||||
Modal.confirm({
|
||||
title: '确认清空',
|
||||
content: '是否确认清空所有操作日志数据项?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await cleanOperlog();
|
||||
message.success('清空成功');
|
||||
setSelectedRowKeys([]);
|
||||
void getList();
|
||||
} catch {
|
||||
message.error('清空失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const operTypeLabel = (type?: string): string => {
|
||||
const dict = sysOperTypeDict.find((item) => item.value === String(type ?? ''));
|
||||
return dict?.label ?? String(type ?? '');
|
||||
};
|
||||
|
||||
const operStatusLabel = (status?: string): string => {
|
||||
const dict = sysCommonStatusDict.find((item) => item.value === String(status ?? ''));
|
||||
return dict?.label ?? String(status ?? '');
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
const hide = message.loading('正在导出数据...', 0);
|
||||
try {
|
||||
const response = await listOperlog({
|
||||
...queryParams,
|
||||
pageNum: undefined,
|
||||
pageSize: undefined,
|
||||
});
|
||||
|
||||
const header = [
|
||||
'日志编号',
|
||||
'系统模块',
|
||||
'操作类型',
|
||||
'操作人员',
|
||||
'操作地址',
|
||||
'操作地点',
|
||||
'操作状态',
|
||||
'操作日期',
|
||||
'消耗时间',
|
||||
'请求方式',
|
||||
'请求地址',
|
||||
];
|
||||
|
||||
const data = response.rows.map((log) => [
|
||||
log.operId,
|
||||
log.title,
|
||||
operTypeLabel(typeof log.businessType === 'string' ? log.businessType : undefined),
|
||||
log.operName,
|
||||
log.operIp,
|
||||
log.operLocation,
|
||||
operStatusLabel(typeof log.status === 'string' ? log.status : undefined),
|
||||
parseTime(normalizeDateValue(log.operTime)),
|
||||
log.costTime ? `${log.costTime}毫秒` : '',
|
||||
log.requestMethod,
|
||||
log.operUrl,
|
||||
]);
|
||||
|
||||
const csvContent = [header, ...data]
|
||||
.map((row) => row.map((cell) => escapeCsvCell(cell)).join(','))
|
||||
.join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
saveAs(blob, `operlog_${dayjs().format('YYYYMMDDHHmmss')}.csv`);
|
||||
hide();
|
||||
message.success('导出成功');
|
||||
} catch {
|
||||
hide();
|
||||
message.error('导出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleView = (record: OperlogRecord) => {
|
||||
setCurrentOperlogDetail(record);
|
||||
setDetailModalVisible(true);
|
||||
};
|
||||
|
||||
const operTypeFormat = (type?: string) => {
|
||||
const dict = sysOperTypeDict.find((item) => item.value === String(type ?? ''));
|
||||
return dict ? <Tag color={dict.color}>{dict.label}</Tag> : String(type ?? '');
|
||||
};
|
||||
|
||||
const operStatusFormat = (status?: string) => {
|
||||
const dict = sysCommonStatusDict.find((item) => item.value === String(status ?? ''));
|
||||
return dict ? <Tag color={dict.color}>{dict.label}</Tag> : String(status ?? '');
|
||||
};
|
||||
|
||||
const handleTableChange: TableProps<OperlogRecord>['onChange'] = (pagination, _filters, sorter) => {
|
||||
const activeSorter = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
const orderByColumn =
|
||||
typeof activeSorter?.field === 'string' && activeSorter.field.length > 0
|
||||
? activeSorter.field
|
||||
: 'operTime';
|
||||
const isAsc: SortDirection = activeSorter?.order === 'ascend' ? 'ascending' : 'descending';
|
||||
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
orderByColumn,
|
||||
isAsc,
|
||||
pageNum: pagination.current ?? 1,
|
||||
pageSize: pagination.pageSize ?? 10,
|
||||
}));
|
||||
};
|
||||
|
||||
const columns: TableColumnsType<OperlogRecord> = [
|
||||
Table.SELECTION_COLUMN,
|
||||
{ title: '日志编号', dataIndex: 'operId', align: 'center', width: 90 },
|
||||
{ title: '系统模块', dataIndex: 'title', align: 'center', ellipsis: true },
|
||||
{
|
||||
title: '操作类型',
|
||||
dataIndex: 'businessType',
|
||||
align: 'center',
|
||||
render: (text) => operTypeFormat(typeof text === 'string' ? text : undefined),
|
||||
},
|
||||
{ title: '操作人员', dataIndex: 'operName', align: 'center', ellipsis: true, sorter: true },
|
||||
{ title: '操作地址', dataIndex: 'operIp', align: 'center', ellipsis: true },
|
||||
{ title: '操作地点', dataIndex: 'operLocation', align: 'center', ellipsis: true },
|
||||
{
|
||||
title: '操作状态',
|
||||
dataIndex: 'status',
|
||||
align: 'center',
|
||||
render: (text) => operStatusFormat(typeof text === 'string' ? text : undefined),
|
||||
},
|
||||
{
|
||||
title: '操作日期',
|
||||
dataIndex: 'operTime',
|
||||
align: 'center',
|
||||
width: 180,
|
||||
sorter: true,
|
||||
render: (text) => parseTime(normalizeDateValue(text)),
|
||||
},
|
||||
{
|
||||
title: '消耗时间',
|
||||
dataIndex: 'costTime',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
sorter: true,
|
||||
render: (text) => (typeof text === 'number' && text > 0 ? `${text}毫秒` : ''),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'operation',
|
||||
align: 'center',
|
||||
width: 80,
|
||||
render: (_text, record) => (
|
||||
<Button type="link" icon={<EyeOutlined />} onClick={() => handleView(record)}>
|
||||
详细
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<Form form={queryForm} layout="inline" className="search-form" onFinish={handleQuery} initialValues={queryParams}>
|
||||
<Form.Item label="操作地址" name="operIp">
|
||||
<Input placeholder="请输入操作地址" allowClear onPressEnter={handleQuery} style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="系统模块" name="title">
|
||||
<Input placeholder="请输入系统模块" allowClear onPressEnter={handleQuery} style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="操作人员" name="operName">
|
||||
<Input placeholder="请输入操作人员" allowClear onPressEnter={handleQuery} style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="操作类型" name="businessType">
|
||||
<Select placeholder="操作类型" allowClear style={{ width: 240 }}>
|
||||
{sysOperTypeDict.map((dict) => (
|
||||
<Select.Option key={dict.value} value={dict.value}>
|
||||
{dict.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="操作状态" name="status">
|
||||
<Select placeholder="操作状态" allowClear style={{ width: 240 }}>
|
||||
{sysCommonStatusDict.map((dict) => (
|
||||
<Select.Option key={dict.value} value={dict.value}>
|
||||
{dict.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="操作时间">
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={(dates) => setDateRange(dates ? [dates[0], dates[1]] : null)}
|
||||
showTime={{
|
||||
defaultValue: [dayjs('00:00:00', 'HH:mm:ss'), dayjs('23:59:59', 'HH:mm:ss')],
|
||||
}}
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
搜索
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetQuery} style={{ marginLeft: 8 }}>
|
||||
重置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Space className="mb8">
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
ghost
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
onClick={() => void handleDelete()}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<Button type="primary" danger ghost icon={<DeleteOutlined />} onClick={() => void handleClean()}>
|
||||
清空
|
||||
</Button>
|
||||
<Button type="default" ghost icon={<DownloadOutlined />} onClick={() => void handleExport()}>
|
||||
导出
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
rowKey="operId"
|
||||
loading={loading}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: handleSelectionChange,
|
||||
}}
|
||||
pagination={{
|
||||
current: queryParams.pageNum,
|
||||
pageSize: queryParams.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (count) => `共 ${count} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setQueryParams((prev) => ({ ...prev, pageNum: page, pageSize }));
|
||||
},
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="操作日志详细"
|
||||
open={detailModalVisible}
|
||||
onCancel={() => setDetailModalVisible(false)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setDetailModalVisible(false)}>
|
||||
关 闭
|
||||
</Button>,
|
||||
]}
|
||||
width={800}
|
||||
>
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="操作模块">
|
||||
{currentOperlogDetail.title} /{' '}
|
||||
{operTypeFormat(
|
||||
typeof currentOperlogDetail.businessType === 'string' ? currentOperlogDetail.businessType : undefined,
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="登录信息">
|
||||
{currentOperlogDetail.operName} / {currentOperlogDetail.operIp} / {currentOperlogDetail.operLocation}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="请求地址">{currentOperlogDetail.operUrl}</Descriptions.Item>
|
||||
<Descriptions.Item label="请求方式">{currentOperlogDetail.requestMethod}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作方法" span={2}>
|
||||
{currentOperlogDetail.method}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="请求参数" span={2}>
|
||||
{currentOperlogDetail.operParam}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="返回参数" span={2}>
|
||||
{currentOperlogDetail.jsonResult}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="操作状态">
|
||||
{operStatusFormat(typeof currentOperlogDetail.status === 'string' ? currentOperlogDetail.status : undefined)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="消耗时间">
|
||||
{typeof currentOperlogDetail.costTime === 'number' ? `${currentOperlogDetail.costTime}毫秒` : ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="操作时间">
|
||||
{parseTime(normalizeDateValue(currentOperlogDetail.operTime))}
|
||||
</Descriptions.Item>
|
||||
{String(currentOperlogDetail.status ?? '') === '1' && (
|
||||
<Descriptions.Item label="异常信息" span={2}>
|
||||
{currentOperlogDetail.errorMsg}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OperationLogPage;
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Card, Col, Row, message, Typography, Descriptions, Progress, Table, Space } from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import {
|
||||
SettingOutlined,
|
||||
FileTextOutlined,
|
||||
DesktopOutlined,
|
||||
CoffeeOutlined,
|
||||
HddOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { getServerInfo } from '../../api/monitor/server';
|
||||
import type { ServerDiskInfo, ServerInfoResponse } from '@/types/api';
|
||||
import './server-monitor.css';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const defaultServerInfo: ServerInfoResponse = {
|
||||
cpu: {},
|
||||
mem: {},
|
||||
jvm: {},
|
||||
sys: {},
|
||||
sysFiles: [],
|
||||
};
|
||||
|
||||
const parseTime = (time?: string | number | Date, pattern = 'YYYY-MM-DD HH:mm:ss'): string => {
|
||||
return time ? dayjs(time).format(pattern) : '';
|
||||
};
|
||||
|
||||
const toNumber = (value: unknown): number => {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const renderUsageProgress = (usage: unknown) => {
|
||||
const percent = Math.floor(toNumber(usage));
|
||||
let status: 'normal' | 'exception' | 'active' | 'success' = 'normal';
|
||||
if (percent > 80) {
|
||||
status = 'exception';
|
||||
} else if (percent > 60) {
|
||||
status = 'active';
|
||||
}
|
||||
return <Progress percent={percent} status={status} />;
|
||||
};
|
||||
|
||||
const ServerMonitorPage = () => {
|
||||
const [serverInfo, setServerInfo] = useState<ServerInfoResponse>(defaultServerInfo);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const getList = async () => {
|
||||
setLoading(true);
|
||||
const hide = message.loading('正在加载服务监控数据,请稍候!', 0);
|
||||
try {
|
||||
const response = await getServerInfo();
|
||||
setServerInfo(response);
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to fetch server info:', error);
|
||||
message.error('获取服务监控数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
void getList();
|
||||
}, []);
|
||||
|
||||
const diskColumns: TableColumnsType<ServerDiskInfo> = [
|
||||
{ title: '盘符路径', dataIndex: 'dirName', align: 'center' },
|
||||
{ title: '文件系统', dataIndex: 'sysTypeName', align: 'center' },
|
||||
{ title: '盘符类型', dataIndex: 'typeName', align: 'center' },
|
||||
{ title: '总大小', dataIndex: 'total', align: 'center' },
|
||||
{ title: '可用大小', dataIndex: 'free', align: 'center' },
|
||||
{ title: '已用大小', dataIndex: 'used', align: 'center' },
|
||||
{
|
||||
title: '已用百分比',
|
||||
dataIndex: 'usage',
|
||||
align: 'center',
|
||||
render: (usage) => (
|
||||
<Text type={toNumber(usage) > 80 ? 'danger' : 'secondary'}>
|
||||
{toNumber(usage)}% {renderUsageProgress(usage)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-container server-monitor-container">
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={12} className="card-box">
|
||||
<Card title={<Space><SettingOutlined /> CPU</Space>} loading={loading}>
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="核心数">{serverInfo.cpu?.cpuNum}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户使用率">
|
||||
{serverInfo.cpu?.used}% {renderUsageProgress(serverInfo.cpu?.used)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="系统使用率">
|
||||
{serverInfo.cpu?.sys}% {renderUsageProgress(serverInfo.cpu?.sys)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="当前空闲率">
|
||||
{serverInfo.cpu?.free}% {renderUsageProgress(serverInfo.cpu?.free)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12} className="card-box">
|
||||
<Card title={<Space><FileTextOutlined /> 内存</Space>} loading={loading}>
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="属性">总内存</Descriptions.Item>
|
||||
<Descriptions.Item label="内存">{serverInfo.mem?.total}G</Descriptions.Item>
|
||||
<Descriptions.Item label="JVM">{serverInfo.jvm?.total}M</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="已用内存">
|
||||
<Text type={toNumber(serverInfo.mem?.usage) > 80 ? 'danger' : 'secondary'}>
|
||||
{serverInfo.mem?.used}G
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="已用内存">
|
||||
<Text type={toNumber(serverInfo.jvm?.usage) > 80 ? 'danger' : 'secondary'}>
|
||||
{serverInfo.jvm?.used}M
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="剩余内存">{serverInfo.mem?.free}G</Descriptions.Item>
|
||||
<Descriptions.Item label="剩余内存">{serverInfo.jvm?.free}M</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="使用率">{renderUsageProgress(serverInfo.mem?.usage)}</Descriptions.Item>
|
||||
<Descriptions.Item label="使用率">{renderUsageProgress(serverInfo.jvm?.usage)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={24} className="card-box">
|
||||
<Card title={<Space><DesktopOutlined /> 服务器信息</Space>} loading={loading}>
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="服务器名称">{serverInfo.sys?.computerName}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作系统">{serverInfo.sys?.osName}</Descriptions.Item>
|
||||
<Descriptions.Item label="服务器IP">{serverInfo.sys?.computerIp}</Descriptions.Item>
|
||||
<Descriptions.Item label="系统架构">{serverInfo.sys?.osArch}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={24} className="card-box">
|
||||
<Card title={<Space><CoffeeOutlined /> Java虚拟机信息</Space>} loading={loading}>
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="Java名称">{serverInfo.jvm?.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="Java版本">{serverInfo.jvm?.version}</Descriptions.Item>
|
||||
<Descriptions.Item label="启动时间">{parseTime(serverInfo.jvm?.startTime)}</Descriptions.Item>
|
||||
<Descriptions.Item label="运行时长">{serverInfo.jvm?.runTime}</Descriptions.Item>
|
||||
<Descriptions.Item label="安装路径" span={2}>{serverInfo.jvm?.home}</Descriptions.Item>
|
||||
<Descriptions.Item label="项目路径" span={2}>{serverInfo.sys?.userDir}</Descriptions.Item>
|
||||
<Descriptions.Item label="运行参数" span={2}>{serverInfo.jvm?.inputArgs}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={24} className="card-box">
|
||||
<Card title={<Space><HddOutlined /> 磁盘状态</Space>} loading={loading}>
|
||||
<Table
|
||||
dataSource={serverInfo.sysFiles ?? []}
|
||||
rowKey={(record) =>
|
||||
`${record.dirName ?? 'disk'}-${record.sysTypeName ?? ''}-${record.typeName ?? ''}`
|
||||
}
|
||||
pagination={false}
|
||||
size="small"
|
||||
bordered
|
||||
columns={diskColumns}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerMonitorPage;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
.cache-list-container .ant-card-body {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.cache-list-container .ant-table-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
.card-box {
|
||||
padding-right: 15px;
|
||||
padding-left: 15px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
.job-monitor-container .ant-form-item {
|
||||
margin-bottom: 0px; /* Reduce vertical space in search form */
|
||||
}
|
||||
|
||||
.job-monitor-container .ant-descriptions-item-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.job-monitor-container .ant-descriptions-item-label {
|
||||
width: 120px; /* Adjust label width in details dialog */
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.login-log-container .ant-form-item {
|
||||
margin-bottom: 0px; /* Reduce vertical space in search form */
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.online-user-container .ant-form-item {
|
||||
margin-bottom: 0px; /* Reduce vertical space in search form */
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.operation-log-container .ant-form-item {
|
||||
margin-bottom: 0px; /* Reduce vertical space in search form */
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
.server-monitor-container .card-box {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.server-monitor-container .ant-descriptions-row > th,
|
||||
.server-monitor-container .ant-descriptions-row > td {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.server-monitor-container .ant-descriptions-item-label {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.server-monitor-container .ant-progress {
|
||||
min-width: 100px;
|
||||
max-width: 100%;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,135 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Form, Input, Button, message, Card, Row, Col, DatePicker, InputNumber } from 'antd';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { getProjectDetail, addProject, updateProject, getProjectCode } from '../../api/project';
|
||||
import dayjs from 'dayjs';
|
||||
import { UserOutlined } from '@ant-design/icons';
|
||||
import PageBackButton from '@/components/PageBackButton';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
const ProjectDetailPage: React.FC = () => {
|
||||
const [form] = Form.useForm();
|
||||
const { id: pathId } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const id = pathId ?? searchParams.get('id') ?? undefined;
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isEdit = !!id;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProjectData = async () => {
|
||||
if (isEdit) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getProjectDetail(id as string);
|
||||
const projectData = ((response as Record<string, unknown>).data ?? response) as Record<string, unknown>;
|
||||
const startValue = projectData.startDate as dayjs.ConfigType | undefined;
|
||||
const endValue = projectData.endDate as dayjs.ConfigType | undefined;
|
||||
form.setFieldsValue({
|
||||
...projectData,
|
||||
startDate: startValue ? dayjs(startValue) : null,
|
||||
endDate: endValue ? dayjs(endValue) : null,
|
||||
});
|
||||
} catch (error) {
|
||||
message.error('获取项目详情失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
// Fetch project code for new project
|
||||
try {
|
||||
const response = await getProjectCode();
|
||||
form.setFieldsValue({ projectCode: ((response as Record<string, unknown>).data ?? response) as string });
|
||||
} catch(error) {
|
||||
message.error('获取项目编号失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchProjectData();
|
||||
}, [id, isEdit, form]);
|
||||
|
||||
const onFinish = async (values: any) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload = {
|
||||
...values,
|
||||
startDate: values.startDate ? values.startDate.format('YYYY-MM-DD HH:mm:ss') : null,
|
||||
endDate: values.endDate ? values.endDate.format('YYYY-MM-DD HH:mm:ss') : null,
|
||||
};
|
||||
if (isEdit) {
|
||||
await updateProject({ ...payload, projectId: id });
|
||||
message.success('修改成功');
|
||||
} else {
|
||||
await addProject(payload);
|
||||
message.success('新增成功');
|
||||
}
|
||||
navigate('/project/list');
|
||||
} catch (error) {
|
||||
message.error(isEdit ? '修改失败' : '新增失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PageBackButton text="返回项目列表" fallbackPath="/project/list" />
|
||||
</div>
|
||||
<Card title={isEdit ? '编辑项目' : '新建项目'}>
|
||||
<Form form={form} layout="vertical" onFinish={onFinish}>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="项目名称" name="projectName" rules={[{ required: true, message: '请输入项目名称' }]}>
|
||||
<Input placeholder="请输入项目名称" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="项目编号" name="projectCode" rules={[{ required: true, message: '请输入项目编号' }]}>
|
||||
<Input placeholder="请输入项目编号" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="负责人" name="projectLeaderName" rules={[{ required: true, message: '请选择负责人' }]}>
|
||||
<Input placeholder="请选择负责人" suffix={<UserOutlined />} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="预计工时(天)" name="budgetDate">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="开始时间" name="startDate" rules={[{ required: true, message: '请选择开始时间' }]}>
|
||||
<DatePicker showTime format="YYYY-MM-DD HH:mm:ss" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="结束时间" name="endDate" rules={[{ required: true, message: '请选择结束时间' }]}>
|
||||
<DatePicker showTime format="YYYY-MM-DD HH:mm:ss" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item label="项目描述" name="description">
|
||||
<TextArea rows={4} placeholder="请输入项目描述" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
{isEdit ? '保 存' : '创 建'}
|
||||
</Button>
|
||||
<Button style={{ marginLeft: 8 }} onClick={() => navigate('/project/list')}>
|
||||
返 回
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectDetailPage;
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table, Form, Input, Select, Button, message, Space, Tag, Popconfirm
|
||||
} from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import {
|
||||
SearchOutlined, ReloadOutlined, PlusOutlined, EditOutlined, DeleteOutlined, UserOutlined, UnorderedListOutlined
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
listProject, deleteProject
|
||||
} from '../../api/project';
|
||||
import { getDicts } from '../../api/system/dict'; // To get dict data for project status
|
||||
import { parseTime } from '../../utils/ruoyi';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const ProjectPage: React.FC = () => {
|
||||
const [queryForm] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [projectList, setProjectList] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [statusList, setStatusList] = useState<any[]>([]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [queryParams, setQueryParams] = useState({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
projectName: undefined,
|
||||
projectLeaderName: undefined,
|
||||
projectLeader: undefined,
|
||||
projectState: undefined,
|
||||
});
|
||||
|
||||
const extractRows = (response: any): { rows: any[]; total: number } => {
|
||||
if (response && typeof response === 'object') {
|
||||
if (Array.isArray(response.rows)) {
|
||||
return { rows: response.rows, total: response.total ?? response.rows.length };
|
||||
}
|
||||
if (response.data && typeof response.data === 'object' && Array.isArray(response.data.rows)) {
|
||||
return { rows: response.data.rows, total: response.data.total ?? response.data.rows.length };
|
||||
}
|
||||
}
|
||||
if (Array.isArray(response)) {
|
||||
return { rows: response, total: response.length };
|
||||
}
|
||||
return { rows: [], total: 0 };
|
||||
};
|
||||
|
||||
const extractDictOptions = (response: any): any[] => {
|
||||
if (Array.isArray(response)) {
|
||||
return response;
|
||||
}
|
||||
if (response && typeof response === 'object' && Array.isArray(response.data)) {
|
||||
return response.data;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const getList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await listProject(queryParams);
|
||||
const { rows, total } = extractRows(response);
|
||||
setProjectList(rows);
|
||||
setTotal(total);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch project list:', error);
|
||||
message.error('获取项目列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [queryParams]);
|
||||
|
||||
const getDictData = useCallback(async () => {
|
||||
try {
|
||||
const response = await getDicts('business_projectstate');
|
||||
setStatusList(extractDictOptions(response));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch project status dictionary:', error);
|
||||
message.error('获取项目状态字典失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getDictData();
|
||||
getList();
|
||||
}, [getDictData, getList]);
|
||||
|
||||
const handleQuery = () => {
|
||||
const values = queryForm.getFieldsValue();
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
pageNum: 1,
|
||||
projectName: values.projectName ?? undefined,
|
||||
projectLeaderName: values.projectLeaderName ?? undefined,
|
||||
// Backward compatibility: some backends use projectLeader as query field.
|
||||
projectLeader: values.projectLeaderName ?? undefined,
|
||||
projectState: values.projectState ?? undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
queryForm.resetFields();
|
||||
setQueryParams({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
projectName: undefined,
|
||||
projectLeaderName: undefined,
|
||||
projectLeader: undefined,
|
||||
projectState: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddProject = () => {
|
||||
navigate('/project/detail');
|
||||
};
|
||||
|
||||
const handleDemand = (row: any) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('id', String(row.projectId));
|
||||
params.set('projectName', row.projectName ?? '');
|
||||
if (row.startDate) {
|
||||
params.set('startDate', String(new Date(row.startDate).getTime()));
|
||||
}
|
||||
if (row.endDate) {
|
||||
params.set('endDate', String(new Date(row.endDate).getTime()));
|
||||
}
|
||||
navigate(`/project/demandManage?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleEdit = (row: any) => {
|
||||
navigate(`/project/detail?id=${row.projectId}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (row: any) => {
|
||||
try {
|
||||
await deleteProject(row.projectId);
|
||||
message.success('删除成功');
|
||||
getList();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusTag = (value: string) => {
|
||||
const status = statusList.find(item => item.dictValue === value);
|
||||
if (!status) return <Tag>{value}</Tag>;
|
||||
|
||||
let color = '#333';
|
||||
switch (status.dictLabel) {
|
||||
case "待启动": color = "#999999"; break;
|
||||
case "进行中": color = "#FF7D00"; break;
|
||||
case "已完成": color = "#50B6AA"; break;
|
||||
default: color = "#333"; break;
|
||||
}
|
||||
return <Tag color={color}>{status.dictLabel}</Tag>;
|
||||
};
|
||||
|
||||
const columns: TableColumnsType<any> = [
|
||||
{ title: '项目名称', dataIndex: 'projectName', key: 'projectName', width: 300, ellipsis: true },
|
||||
{ title: '项目编号', dataIndex: 'projectCode', key: 'projectCode', width: 200, ellipsis: true },
|
||||
{ title: '负责人', dataIndex: 'projectLeaderName', key: 'projectLeaderName' },
|
||||
{ title: '预计工时(天)', dataIndex: 'budgetDate', key: 'budgetDate' },
|
||||
{ title: '开始时间', dataIndex: 'startDate', key: 'startDate', render: (text) => parseTime(text, 'YYYY-MM-DD') },
|
||||
{ title: '结束时间', dataIndex: 'endDate', key: 'endDate', render: (text) => parseTime(text, 'YYYY-MM-DD') },
|
||||
{ title: '项目状态', dataIndex: 'projectState', key: 'projectState', render: (value) => getStatusTag(value) },
|
||||
{ title: '参与项目人数', dataIndex: 'teamNum', key: 'teamNum', width: 120 },
|
||||
{ title: '项目创建人', dataIndex: 'createByName', key: 'createByName' },
|
||||
{
|
||||
title: '操作', key: 'operation', width: 250, fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Button type="link" icon={<UnorderedListOutlined />} onClick={() => handleDemand(record)}>需求管理</Button>
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||
<Popconfirm
|
||||
title="是否确认删除该项目?"
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText="是"
|
||||
cancelText="否"
|
||||
>
|
||||
<Button type="link" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<Form form={queryForm} layout="inline" className="search-form" onFinish={handleQuery}>
|
||||
<Form.Item label="项目名称" name="projectName">
|
||||
<Input placeholder="项目名称" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item label="负责人" name="projectLeaderName">
|
||||
<Input placeholder="负责人" allowClear suffix={<UserOutlined />} />
|
||||
</Form.Item>
|
||||
<Form.Item label="项目状态" name="projectState">
|
||||
<Select placeholder="项目状态" allowClear>
|
||||
{statusList.map(item => (
|
||||
<Select.Option key={item.dictValue} value={item.dictValue}>{item.dictLabel}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">查询</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetQuery} style={{ marginLeft: 8 }}>重置</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Space className="mb8" style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddProject}>新建项目</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={projectList}
|
||||
rowKey="projectId"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: queryParams.pageNum,
|
||||
pageSize: queryParams.pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setQueryParams(prev => ({ ...prev, pageNum: page, pageSize: pageSize }));
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 1500 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectPage;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
.project-page-container .search-form .ant-form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.project-page-container .mb8 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
|
@ -0,0 +1,415 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, DatePicker, Empty, Select, Spin, Table, message } from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { CalendarOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import zhCN from 'antd/es/date-picker/locale/zh_CN';
|
||||
import PageBackButton from '@/components/PageBackButton';
|
||||
import { getProjectDetail, listProject } from '@/api/project';
|
||||
import { getProjectExecutionInfo, getProjectWorkInfo } from '@/api/projectExecution';
|
||||
import { parseTime } from '@/utils/ruoyi';
|
||||
import './project-user.css';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
const MAX_RANGE_DAYS = 90;
|
||||
|
||||
interface ProjectUserItem {
|
||||
userId?: string | number;
|
||||
userName?: string;
|
||||
workTime?: string | number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ProjectDetailData {
|
||||
projectId?: string | number;
|
||||
projectName?: string;
|
||||
projectCode?: string;
|
||||
projectLeaderName?: string;
|
||||
projectState?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
budgetDate?: string | number;
|
||||
teamNum?: string | number;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ProjectOption {
|
||||
projectId: string | number;
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const normalizeResponseData = (response: unknown) =>
|
||||
isObject(response) && response.data !== undefined ? response.data : response;
|
||||
|
||||
const toNumber = (value: unknown, fallback = 0) => {
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : fallback;
|
||||
};
|
||||
|
||||
const extractProjectList = (payload: unknown): ProjectOption[] => {
|
||||
const data = normalizeResponseData(payload);
|
||||
const list = Array.isArray(data)
|
||||
? data
|
||||
: isObject(data) && Array.isArray(data.rows)
|
||||
? data.rows
|
||||
: [];
|
||||
return list
|
||||
.filter(isObject)
|
||||
.map((item) => ({
|
||||
projectId: item.projectId as string | number,
|
||||
projectName: String(item.projectName ?? ''),
|
||||
}))
|
||||
.filter((item) => item.projectId !== undefined && item.projectId !== null && item.projectName);
|
||||
};
|
||||
|
||||
const extractProjectRows = (payload: unknown): Record<string, unknown>[] => {
|
||||
const data = normalizeResponseData(payload);
|
||||
if (Array.isArray(data)) {
|
||||
return data.filter(isObject);
|
||||
}
|
||||
if (isObject(data) && Array.isArray(data.rows)) {
|
||||
return data.rows.filter(isObject);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const extractDetailList = (payload: unknown): ProjectUserItem[][] => {
|
||||
const data = normalizeResponseData(payload);
|
||||
if (isObject(data) && Array.isArray(data.detailList)) {
|
||||
return data.detailList.map((item) => (Array.isArray(item) ? item.filter(isObject) as ProjectUserItem[] : []));
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const normalizeProjectDetail = (payload: unknown): ProjectDetailData => {
|
||||
const data = normalizeResponseData(payload);
|
||||
if (isObject(data)) {
|
||||
const candidates = [data.project, data.info, data.data];
|
||||
for (const candidate of candidates) {
|
||||
if (isObject(candidate)) {
|
||||
return candidate as ProjectDetailData;
|
||||
}
|
||||
}
|
||||
return data as ProjectDetailData;
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const normalizeDateParam = (value: string | null) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (/^\d+$/.test(value)) {
|
||||
const parsed = dayjs(Number(value));
|
||||
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : null;
|
||||
}
|
||||
const parsed = dayjs(value);
|
||||
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : null;
|
||||
};
|
||||
|
||||
const ProjectUserPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const projectId = searchParams.get('projectId') ?? searchParams.get('id') ?? '';
|
||||
const queryStartDate = normalizeDateParam(searchParams.get('startDate'));
|
||||
const queryEndDate = normalizeDateParam(searchParams.get('endDate'));
|
||||
const defaultStartDate = queryStartDate ?? dayjs().startOf('month').format('YYYY-MM-DD');
|
||||
const defaultEndDate = queryEndDate ?? dayjs().endOf('month').format('YYYY-MM-DD');
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [projectList, setProjectList] = useState<ProjectOption[]>([]);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | number | undefined>(projectId || undefined);
|
||||
const [routePresetApplied, setRoutePresetApplied] = useState(false);
|
||||
const [projectDetail, setProjectDetail] = useState<ProjectDetailData>({});
|
||||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([
|
||||
dayjs(defaultStartDate, 'YYYY-MM-DD'),
|
||||
dayjs(defaultEndDate, 'YYYY-MM-DD'),
|
||||
]);
|
||||
const [detailList, setDetailList] = useState<ProjectUserItem[][]>([]);
|
||||
|
||||
const openUserLog = (row: ProjectUserItem, queryDate?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('projectId', String(selectedProjectId ?? ''));
|
||||
params.set('userId', String(row.userId ?? ''));
|
||||
params.set('nickName', String(row.userName ?? ''));
|
||||
if (queryDate) {
|
||||
params.set('queryDate', queryDate);
|
||||
}
|
||||
navigate(`/?${params.toString()}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const start = dateRange[0];
|
||||
const end = dateRange[1];
|
||||
const diffDays = end.endOf('day').diff(start.startOf('day'), 'day');
|
||||
if (diffDays <= MAX_RANGE_DAYS) {
|
||||
return;
|
||||
}
|
||||
const nextEnd = start.add(MAX_RANGE_DAYS, 'day');
|
||||
setDateRange([start, nextEnd]);
|
||||
}, [dateRange]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProjectList = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let nextProjectList: ProjectOption[] = [];
|
||||
|
||||
try {
|
||||
const response = await getProjectExecutionInfo({
|
||||
startDate: `${dateRange[0].format('YYYY-MM-DD')} 00:00:00`,
|
||||
endDate: `${dateRange[1].format('YYYY-MM-DD')} 23:59:59`,
|
||||
});
|
||||
nextProjectList = extractProjectList(response);
|
||||
} catch (error) {
|
||||
console.warn('Project execution info timeout, fallback to project list:', error);
|
||||
const response = await listProject({ pageNum: 1, pageSize: 1000 });
|
||||
nextProjectList = extractProjectRows(response).map((item) => ({
|
||||
projectId: item.projectId as string | number,
|
||||
projectName: String(item.projectName ?? ''),
|
||||
})).filter((item) => item.projectId !== undefined && item.projectId !== null && item.projectName);
|
||||
}
|
||||
|
||||
setProjectList(nextProjectList);
|
||||
if (nextProjectList.length === 0) {
|
||||
setSelectedProjectId(undefined);
|
||||
setProjectDetail({});
|
||||
setDetailList([]);
|
||||
return;
|
||||
}
|
||||
setSelectedProjectId((prev) => {
|
||||
if (prev && nextProjectList.some((item) => String(item.projectId) === String(prev))) {
|
||||
return prev;
|
||||
}
|
||||
if (projectId) {
|
||||
const matched = nextProjectList.find((item) => String(item.projectId) === String(projectId));
|
||||
if (matched) {
|
||||
return matched.projectId;
|
||||
}
|
||||
}
|
||||
return nextProjectList[0].projectId;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch project list:', error);
|
||||
message.error('获取项目列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void fetchProjectList();
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProjectData = async () => {
|
||||
if (!selectedProjectId) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const detailResponse = await getProjectDetail(selectedProjectId);
|
||||
const detail = normalizeProjectDetail(detailResponse);
|
||||
setProjectDetail(detail);
|
||||
|
||||
const useRoutePreset = !routePresetApplied && queryStartDate && queryEndDate;
|
||||
const rangeStart = useRoutePreset
|
||||
? queryStartDate
|
||||
: detail.startDate?.split(' ')[0] ?? dateRange[0].format('YYYY-MM-DD');
|
||||
const rangeEnd = useRoutePreset
|
||||
? queryEndDate
|
||||
: detail.endDate?.split(' ')[0] ?? dateRange[1].format('YYYY-MM-DD');
|
||||
const nextStart = dayjs(rangeStart, 'YYYY-MM-DD');
|
||||
let nextEnd = dayjs(rangeEnd, 'YYYY-MM-DD');
|
||||
if (nextEnd.endOf('day').diff(nextStart.startOf('day'), 'day') > MAX_RANGE_DAYS) {
|
||||
nextEnd = nextStart.add(MAX_RANGE_DAYS, 'day');
|
||||
}
|
||||
setDateRange((prev) => {
|
||||
const sameStart = prev[0].isSame(nextStart, 'day');
|
||||
const sameEnd = prev[1].isSame(nextEnd, 'day');
|
||||
if (sameStart && sameEnd) {
|
||||
return prev;
|
||||
}
|
||||
return [nextStart, nextEnd];
|
||||
});
|
||||
if (useRoutePreset) {
|
||||
setRoutePresetApplied(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch project detail:', error);
|
||||
message.error('获取项目详情失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void fetchProjectData();
|
||||
}, [queryEndDate, queryStartDate, routePresetApplied, selectedProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchWorkInfo = async () => {
|
||||
if (!selectedProjectId) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getProjectWorkInfo({
|
||||
startDate: `${dateRange[0].format('YYYY-MM-DD')} 00:00:00`,
|
||||
endDate: `${dateRange[1].format('YYYY-MM-DD')} 23:59:59`,
|
||||
projectId: selectedProjectId,
|
||||
});
|
||||
setDetailList(extractDetailList(response));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch project work info:', error);
|
||||
message.error('获取项目人员表失败');
|
||||
setDetailList([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void fetchWorkInfo();
|
||||
}, [dateRange, selectedProjectId]);
|
||||
|
||||
const columns = useMemo<TableColumnsType<Record<string, unknown>>>(() => {
|
||||
return dateRange[0]
|
||||
.startOf('day')
|
||||
.toDate() && (() => {
|
||||
const list: TableColumnsType<Record<string, unknown>> = [];
|
||||
let cursor = dateRange[0].startOf('day');
|
||||
let index = 0;
|
||||
while (!cursor.isAfter(dateRange[1], 'day')) {
|
||||
const columnIndex = index;
|
||||
const key = cursor.format('M/D');
|
||||
const weekLabel = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][cursor.day()];
|
||||
const queryDate = cursor.format('YYYY-MM-DD 00:00:00');
|
||||
list.push({
|
||||
title: <span className="project-user-table-header">{`${weekLabel}\n${key}`}</span>,
|
||||
key,
|
||||
dataIndex: key,
|
||||
width: 118,
|
||||
render: (_value, _row) => {
|
||||
const users = detailList[columnIndex] ?? [];
|
||||
if (!users.length) {
|
||||
return <span className="project-user-empty-cell">-</span>;
|
||||
}
|
||||
return (
|
||||
<div className="project-user-button-group">
|
||||
{users.map((item, itemIndex) => (
|
||||
<Button
|
||||
key={`${String(item.userId ?? item.userName ?? itemIndex)}-${columnIndex}`}
|
||||
type="link"
|
||||
size="small"
|
||||
className="project-user-log-button"
|
||||
onClick={() => openUserLog(item, queryDate)}
|
||||
>
|
||||
{`${String(item.userName ?? '-') }(${toNumber(item.workTime, 0)}天)`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
cursor = cursor.add(1, 'day');
|
||||
index += 1;
|
||||
}
|
||||
return list;
|
||||
})();
|
||||
}, [dateRange, detailList]);
|
||||
|
||||
const selectedProjectName = useMemo(() => {
|
||||
const hit = projectList.find((item) => String(item.projectId) === String(selectedProjectId ?? ''));
|
||||
return hit?.projectName ?? '';
|
||||
}, [projectList, selectedProjectId]);
|
||||
|
||||
return (
|
||||
<div className="project-user-page">
|
||||
<div className="project-user-back-row">
|
||||
<PageBackButton text="返回项目执行表" fallbackPath="/projectBank/projectProgress" />
|
||||
</div>
|
||||
<div className="project-user-layout">
|
||||
<section className="project-user-left">
|
||||
<h2 className="project-user-section-title">项目人员表</h2>
|
||||
<div className="project-user-info-list">
|
||||
<div className="project-user-info-item">
|
||||
<span className="project-user-info-label">选择项目</span>
|
||||
<Select
|
||||
value={selectedProjectId}
|
||||
placeholder="请选择项目"
|
||||
options={projectList.map((item) => ({
|
||||
label: item.projectName,
|
||||
value: item.projectId,
|
||||
}))}
|
||||
onChange={(value) => setSelectedProjectId(value)}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</div>
|
||||
<div className="project-user-info-item">
|
||||
<span className="project-user-info-label">项目名称</span>
|
||||
<strong>{projectDetail.projectName || selectedProjectName || '-'}</strong>
|
||||
</div>
|
||||
<div className="project-user-info-item">
|
||||
<span className="project-user-info-label">项目编码</span>
|
||||
<strong>{projectDetail.projectCode || '-'}</strong>
|
||||
</div>
|
||||
<div className="project-user-info-item">
|
||||
<span className="project-user-info-label">预计工时</span>
|
||||
<strong>{projectDetail.budgetDate ? `${projectDetail.budgetDate} 天` : '-'}</strong>
|
||||
</div>
|
||||
<div className="project-user-info-item">
|
||||
<span className="project-user-info-label">项目开始时间</span>
|
||||
<strong>{parseTime(projectDetail.startDate, 'YYYY-MM-DD') || '-'}</strong>
|
||||
</div>
|
||||
<div className="project-user-info-item">
|
||||
<span className="project-user-info-label">项目结束时间</span>
|
||||
<strong>{parseTime(projectDetail.endDate, 'YYYY-MM-DD') || '-'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="project-user-right">
|
||||
<div className="project-user-toolbar">
|
||||
<div className="project-user-range-label">
|
||||
<CalendarOutlined />
|
||||
<span>统计时间:</span>
|
||||
</div>
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={(values) => {
|
||||
if (!values || values.length !== 2 || !values[0] || !values[1]) {
|
||||
return;
|
||||
}
|
||||
let nextEnd = values[1];
|
||||
if (values[1].endOf('day').diff(values[0].startOf('day'), 'day') > MAX_RANGE_DAYS) {
|
||||
nextEnd = values[0].add(MAX_RANGE_DAYS, 'day');
|
||||
message.warning('统计时间最多选择 3 个月,已自动调整结束日期');
|
||||
}
|
||||
setDateRange([values[0], nextEnd]);
|
||||
}}
|
||||
allowClear={false}
|
||||
locale={zhCN}
|
||||
format="YYYY-MM-DD"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<Table<Record<string, unknown>>
|
||||
className="project-user-table"
|
||||
rowKey={() => 'project-user-row'}
|
||||
columns={columns}
|
||||
dataSource={[{}]}
|
||||
pagination={false}
|
||||
locale={{ emptyText: <Empty description="暂无人员记录" /> }}
|
||||
scroll={{ x: Math.max(columns.length * 118, 960) }}
|
||||
/>
|
||||
</Spin>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectUserPage;
|
||||
|
|
@ -0,0 +1,529 @@
|
|||
import { useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, DatePicker, Empty, Input, Modal, Spin, Table, Tree, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { UserOutlined } from '@ant-design/icons';
|
||||
import zhCN from 'antd/es/date-picker/locale/zh_CN';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageBackButton from '@/components/PageBackButton';
|
||||
import { getProjectExecutionInfo } from '@/api/projectExecution';
|
||||
import { deptTreeSelect, getUserProfile, listUser } from '@/api/system/user';
|
||||
import './user-project.css';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
interface RoleRow {
|
||||
roleName?: string;
|
||||
}
|
||||
|
||||
interface UserRow {
|
||||
_rowKey?: string;
|
||||
userId?: string | number;
|
||||
nickName?: string;
|
||||
userName?: string;
|
||||
phonenumber?: string;
|
||||
dept?: { deptName?: string };
|
||||
roles?: RoleRow[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ProjectExecutionRow {
|
||||
projectId?: string | number;
|
||||
projectName?: string;
|
||||
allWorkTime?: string | number;
|
||||
detailList?: Array<string | number | null | undefined>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
interface DeptTreeNode {
|
||||
key: string;
|
||||
title: string;
|
||||
rawId: string | number;
|
||||
children?: DeptTreeNode[];
|
||||
}
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const normalizeResponseData = (response: unknown) =>
|
||||
isObject(response) && response.data !== undefined ? response.data : response;
|
||||
|
||||
const toNumber = (value: unknown, fallback = 0) => {
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : fallback;
|
||||
};
|
||||
|
||||
const getDefaultRange = (): [Dayjs, Dayjs] => [dayjs().startOf('month'), dayjs().endOf('month')];
|
||||
|
||||
const normalizeExecutionRows = (payload: unknown): ProjectExecutionRow[] => {
|
||||
const data = normalizeResponseData(payload);
|
||||
const rows = Array.isArray(data)
|
||||
? data
|
||||
: isObject(data) && Array.isArray(data.rows)
|
||||
? data.rows
|
||||
: [];
|
||||
return rows.filter(isObject) as ProjectExecutionRow[];
|
||||
};
|
||||
|
||||
const normalizeUserRows = (payload: unknown): { rows: UserRow[]; total: number } => {
|
||||
const data = normalizeResponseData(payload);
|
||||
const rows = Array.isArray(data)
|
||||
? data
|
||||
: isObject(data) && Array.isArray(data.rows)
|
||||
? data.rows
|
||||
: [];
|
||||
const total = isObject(data) ? toNumber(data.total, rows.length) : rows.length;
|
||||
return {
|
||||
rows: rows.filter(isObject).map((row, index) => ({
|
||||
...(row as UserRow),
|
||||
_rowKey: String((row as UserRow).userId ?? (row as UserRow).userName ?? `user_${index}`),
|
||||
})) as UserRow[],
|
||||
total,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeDeptTreeNodes = (payload: unknown): DeptTreeNode[] => {
|
||||
const data = normalizeResponseData(payload);
|
||||
if (!Array.isArray(data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const mapNodes = (nodes: unknown[]): DeptTreeNode[] =>
|
||||
nodes.flatMap((node, index) => {
|
||||
if (!isObject(node)) {
|
||||
return [];
|
||||
}
|
||||
const rawId = node.id ?? node.deptId ?? node.value ?? `dept_${index}`;
|
||||
const normalizedId = typeof rawId === 'string' || typeof rawId === 'number' ? rawId : String(rawId);
|
||||
return [
|
||||
{
|
||||
key: String(normalizedId),
|
||||
rawId: normalizedId,
|
||||
title: String(node.label ?? node.title ?? node.deptName ?? normalizedId),
|
||||
children: Array.isArray(node.children) ? mapNodes(node.children) : undefined,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return mapNodes(data);
|
||||
};
|
||||
|
||||
const collectDeptKeys = (nodes: DeptTreeNode[]): string[] =>
|
||||
nodes.flatMap((node) => [node.key, ...(Array.isArray(node.children) ? collectDeptKeys(node.children) : [])]);
|
||||
|
||||
const getUserDisplayName = (user?: UserRow | null) => String(user?.nickName ?? user?.userName ?? '-');
|
||||
|
||||
const matchesUserKeyword = (user: UserRow, keyword: string) => {
|
||||
const normalizedKeyword = keyword.trim();
|
||||
if (!normalizedKeyword) {
|
||||
return true;
|
||||
}
|
||||
return [user.nickName, user.userName, user.phonenumber].some((field) =>
|
||||
String(field ?? '')
|
||||
.toLowerCase()
|
||||
.includes(normalizedKeyword.toLowerCase()),
|
||||
);
|
||||
};
|
||||
|
||||
const UserProjectPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [userLoading, setUserLoading] = useState(false);
|
||||
const [deptLoading, setDeptLoading] = useState(false);
|
||||
const [userModalOpen, setUserModalOpen] = useState(false);
|
||||
const [userKeyword, setUserKeyword] = useState('');
|
||||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>(getDefaultRange());
|
||||
const [selectedUser, setSelectedUser] = useState<UserRow | null>(null);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | number>('');
|
||||
const [selectedUserName, setSelectedUserName] = useState('');
|
||||
const [pendingUserId, setPendingUserId] = useState<string | number>('');
|
||||
const [executionData, setExecutionData] = useState<ProjectExecutionRow[]>([]);
|
||||
const [userListData, setUserListData] = useState<UserRow[]>([]);
|
||||
const [deptTree, setDeptTree] = useState<DeptTreeNode[]>([]);
|
||||
const [expandedDeptKeys, setExpandedDeptKeys] = useState<string[]>([]);
|
||||
const [selectedDeptId, setSelectedDeptId] = useState<string>('');
|
||||
const [userPageNum, setUserPageNum] = useState(1);
|
||||
const [userPageSize, setUserPageSize] = useState(10);
|
||||
const [userTotal, setUserTotal] = useState(0);
|
||||
const deferredUserKeyword = useDeferredValue(userKeyword);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const response = await getUserProfile();
|
||||
const payload = normalizeResponseData(response);
|
||||
const user = isObject(payload) && isObject(payload.user) ? payload.user : payload;
|
||||
if (!isObject(user)) {
|
||||
return;
|
||||
}
|
||||
const nextUser: UserRow = {
|
||||
userId: user.userId as string | number | undefined,
|
||||
nickName: user.nickName as string | undefined,
|
||||
userName: user.userName as string | undefined,
|
||||
};
|
||||
setSelectedUser(nextUser);
|
||||
setSelectedUserId(nextUser.userId ?? '');
|
||||
setPendingUserId(nextUser.userId ?? '');
|
||||
setSelectedUserName(getUserDisplayName(nextUser));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch current user profile for user project page:', error);
|
||||
message.error('获取当前用户失败');
|
||||
}
|
||||
};
|
||||
void fetchCurrentUser();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserProject = async () => {
|
||||
if (!selectedUserId || !dateRange[0] || !dateRange[1]) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getProjectExecutionInfo({
|
||||
startDate: `${dateRange[0].format('YYYY-MM-DD')} 00:00:00`,
|
||||
endDate: `${dateRange[1].format('YYYY-MM-DD')} 00:00:00`,
|
||||
userId: selectedUserId,
|
||||
});
|
||||
setExecutionData(normalizeExecutionRows(response));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user project table:', error);
|
||||
message.error('获取人员项目表失败');
|
||||
setExecutionData([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void fetchUserProject();
|
||||
}, [dateRange, selectedUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userModalOpen) {
|
||||
return;
|
||||
}
|
||||
const fetchDeptTree = async () => {
|
||||
setDeptLoading(true);
|
||||
try {
|
||||
const response = await deptTreeSelect();
|
||||
const treeNodes = normalizeDeptTreeNodes(response);
|
||||
setDeptTree(treeNodes);
|
||||
setExpandedDeptKeys(collectDeptKeys(treeNodes));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch department tree for user project page:', error);
|
||||
setDeptTree([]);
|
||||
setExpandedDeptKeys([]);
|
||||
} finally {
|
||||
setDeptLoading(false);
|
||||
}
|
||||
};
|
||||
void fetchDeptTree();
|
||||
}, [userModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userModalOpen) {
|
||||
return;
|
||||
}
|
||||
const fetchUserList = async () => {
|
||||
setUserLoading(true);
|
||||
try {
|
||||
const normalizedKeyword = deferredUserKeyword.trim();
|
||||
const response = await listUser({
|
||||
pageNum: userPageNum,
|
||||
pageSize: normalizedKeyword ? 1000 : userPageSize,
|
||||
deptId: selectedDeptId || undefined,
|
||||
});
|
||||
const { rows, total } = normalizeUserRows(response);
|
||||
const filteredRows = normalizedKeyword ? rows.filter((item) => matchesUserKeyword(item, normalizedKeyword)) : rows;
|
||||
setUserListData(filteredRows);
|
||||
setUserTotal(normalizedKeyword ? filteredRows.length : total);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user list for user project page:', error);
|
||||
message.error('获取用户列表失败');
|
||||
setUserListData([]);
|
||||
setUserTotal(0);
|
||||
} finally {
|
||||
setUserLoading(false);
|
||||
}
|
||||
};
|
||||
void fetchUserList();
|
||||
}, [deferredUserKeyword, selectedDeptId, userModalOpen, userPageNum, userPageSize]);
|
||||
|
||||
const pendingUser = useMemo(
|
||||
() => userListData.find((item) => String(item.userId ?? '') === String(pendingUserId ?? '')) ?? selectedUser,
|
||||
[pendingUserId, selectedUser, userListData],
|
||||
);
|
||||
|
||||
|
||||
const openUserModal = () => {
|
||||
setPendingUserId(selectedUserId);
|
||||
setUserPageNum(1);
|
||||
setSelectedDeptId('');
|
||||
setUserModalOpen(true);
|
||||
};
|
||||
|
||||
const applySelectedUser = (user?: UserRow | null) => {
|
||||
setSelectedUser(user ?? null);
|
||||
setSelectedUserId(user?.userId ?? '');
|
||||
setSelectedUserName(getUserDisplayName(user));
|
||||
setPendingUserId(user?.userId ?? '');
|
||||
setUserModalOpen(false);
|
||||
};
|
||||
|
||||
const dynamicColumns = useMemo<ColumnsType<ProjectExecutionRow>>(() => {
|
||||
const columns: ColumnsType<ProjectExecutionRow> = [
|
||||
{
|
||||
title: '项目',
|
||||
dataIndex: 'projectName',
|
||||
key: 'projectName',
|
||||
fixed: 'left',
|
||||
width: 180,
|
||||
render: (value: unknown, row) => (
|
||||
<Button
|
||||
type="link"
|
||||
className="user-project-link"
|
||||
onClick={() => navigate(`/project/detail?id=${String(row.projectId ?? '')}`)}
|
||||
>
|
||||
{String(value ?? '-')}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '统计工时\n(天)',
|
||||
dataIndex: 'allWorkTime',
|
||||
key: 'allWorkTime',
|
||||
fixed: 'left',
|
||||
width: 140,
|
||||
render: (value: unknown) => toNumber(value, 0),
|
||||
},
|
||||
];
|
||||
|
||||
let cursor = dateRange[0].startOf('day');
|
||||
let index = 0;
|
||||
while (!cursor.isAfter(dateRange[1], 'day')) {
|
||||
const columnIndex = index;
|
||||
const key = cursor.format('M/D');
|
||||
const weekLabel = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][cursor.day()];
|
||||
columns.push({
|
||||
title: <span className="user-project-header">{`${weekLabel}\n${key}`}</span>,
|
||||
key: `detail-${key}`,
|
||||
dataIndex: 'detailList',
|
||||
width: 100,
|
||||
render: (value: unknown) => {
|
||||
const detailList = Array.isArray(value) ? value : [];
|
||||
return detailList[columnIndex] ?? '';
|
||||
},
|
||||
});
|
||||
cursor = cursor.add(1, 'day');
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return columns;
|
||||
}, [dateRange, navigate]);
|
||||
|
||||
const summaryValues = useMemo(() => {
|
||||
const dayCount = dateRange[1].startOf('day').diff(dateRange[0].startOf('day'), 'day') + 1;
|
||||
const totalWorkTime = executionData.reduce((sum, row) => sum + toNumber(row.allWorkTime, 0), 0);
|
||||
const dayTotals = Array.from({ length: Math.max(dayCount, 0) }, (_item, index) =>
|
||||
Number(
|
||||
executionData
|
||||
.reduce((sum, row) => sum + toNumber(Array.isArray(row.detailList) ? row.detailList[index] : 0, 0), 0)
|
||||
.toFixed(2),
|
||||
),
|
||||
);
|
||||
return { totalWorkTime: Number(totalWorkTime.toFixed(2)), dayTotals };
|
||||
}, [dateRange, executionData]);
|
||||
|
||||
return (
|
||||
<div className="user-project-page">
|
||||
<div className="user-project-back-row">
|
||||
<PageBackButton text="返回看板统计" fallbackPath="/projectBank/projectProgress" />
|
||||
</div>
|
||||
|
||||
<div className="user-project-shell">
|
||||
<div className="user-project-header-row">
|
||||
<h2 className="user-project-title">人员项目表</h2>
|
||||
<div className="user-project-toolbar">
|
||||
<div className="user-project-user-box">
|
||||
<span>选择人员</span>
|
||||
<Input
|
||||
value={selectedUserName}
|
||||
placeholder="请选择用户"
|
||||
readOnly
|
||||
suffix={<UserOutlined onClick={openUserModal} />}
|
||||
onClick={openUserModal}
|
||||
/>
|
||||
</div>
|
||||
<div className="user-project-range-box">
|
||||
<span>统计时间:</span>
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={(values) => {
|
||||
if (!values || values.length !== 2 || !values[0] || !values[1]) {
|
||||
return;
|
||||
}
|
||||
setDateRange([values[0], values[1]]);
|
||||
}}
|
||||
allowClear={false}
|
||||
locale={zhCN}
|
||||
format="YYYY-MM-DD"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<Table<ProjectExecutionRow>
|
||||
className="user-project-table"
|
||||
rowKey={(row) => String(row.projectId ?? row.projectName ?? '')}
|
||||
columns={dynamicColumns}
|
||||
dataSource={executionData}
|
||||
pagination={false}
|
||||
locale={{ emptyText: <Empty description="暂无项目数据" /> }}
|
||||
scroll={{ x: Math.max(dynamicColumns.length * 100, 960), y: 600 }}
|
||||
summary={() => (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0}>合计工时(天)</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1}>{summaryValues.totalWorkTime}</Table.Summary.Cell>
|
||||
{summaryValues.dayTotals.map((item, index) => (
|
||||
<Table.Summary.Cell index={index + 2} key={`sum-${index}`}>
|
||||
{item}
|
||||
</Table.Summary.Cell>
|
||||
))}
|
||||
</Table.Summary.Row>
|
||||
)}
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="选择人员"
|
||||
open={userModalOpen}
|
||||
onCancel={() => setUserModalOpen(false)}
|
||||
width={980}
|
||||
wrapClassName="user-select-dialog"
|
||||
destroyOnHidden
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => setUserModalOpen(false)}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="ok"
|
||||
type="primary"
|
||||
disabled={!pendingUserId}
|
||||
onClick={() => {
|
||||
const hit =
|
||||
userListData.find((item) => String(item.userId ?? '') === String(pendingUserId ?? '')) ?? pendingUser;
|
||||
applySelectedUser(hit);
|
||||
}}
|
||||
>
|
||||
确认
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div className="user-select-modal user-select-modal-layout">
|
||||
<div className="user-select-dept-panel">
|
||||
<Spin spinning={deptLoading}>
|
||||
<Tree
|
||||
blockNode
|
||||
showLine
|
||||
className="user-select-dept-tree"
|
||||
expandedKeys={expandedDeptKeys}
|
||||
selectedKeys={selectedDeptId ? [selectedDeptId] : []}
|
||||
treeData={deptTree}
|
||||
onExpand={(keys) => setExpandedDeptKeys(keys as string[])}
|
||||
onSelect={(keys) => {
|
||||
setSelectedDeptId(String(keys[0] ?? ''));
|
||||
setUserPageNum(1);
|
||||
}}
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
<div className="user-select-table-panel">
|
||||
<div className="user-select-query-bar">
|
||||
<Input
|
||||
placeholder="请输入姓名或手机号"
|
||||
value={userKeyword}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setUserPageNum(1);
|
||||
setUserKeyword(nextValue);
|
||||
}}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<Table<UserRow>
|
||||
rowKey={(row) => String(row._rowKey ?? row.userId ?? row.userName ?? '')}
|
||||
loading={userLoading}
|
||||
dataSource={userListData}
|
||||
size="middle"
|
||||
scroll={{ y: 420 }}
|
||||
rowClassName={(record) =>
|
||||
String(record.userId ?? '') === String(pendingUserId ?? '') ? 'user-select-row is-active' : 'user-select-row'
|
||||
}
|
||||
onRow={(record) => ({
|
||||
onClick: () => {
|
||||
setPendingUserId(record.userId ?? '');
|
||||
},
|
||||
onDoubleClick: () => {
|
||||
applySelectedUser(record);
|
||||
},
|
||||
})}
|
||||
locale={{ emptyText: <Empty description="暂无人员数据" /> }}
|
||||
pagination={{
|
||||
current: userPageNum,
|
||||
pageSize: userPageSize,
|
||||
total: userTotal,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setUserPageNum(page);
|
||||
setUserPageSize(pageSize);
|
||||
},
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: '序号',
|
||||
key: 'index',
|
||||
width: 90,
|
||||
render: (_value, _row, index) => (userPageNum - 1) * userPageSize + index + 1,
|
||||
},
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'nickName',
|
||||
key: 'nickName',
|
||||
width: 180,
|
||||
render: (_value, row) => (
|
||||
<div className="user-select-name-cell">
|
||||
<strong>{getUserDisplayName(row)}</strong>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '部门',
|
||||
dataIndex: ['dept', 'deptName'],
|
||||
key: 'deptName',
|
||||
width: 220,
|
||||
render: (value: unknown) => String(value ?? '-'),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'roles',
|
||||
key: 'roles',
|
||||
render: (value: unknown) =>
|
||||
Array.isArray(value) && value.length
|
||||
? value.map((item) => String((item as RoleRow)?.roleName ?? '')).filter(Boolean).join('、')
|
||||
: '-',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProjectPage;
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Empty, Spin, Table, Tabs, Tag, Typography, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import PageBackButton from '@/components/PageBackButton';
|
||||
import { getTaskScoreDetail } from '@/api/appraisal';
|
||||
import '@/pages/workAppraisal/appraisal-detail.css';
|
||||
import './user-score.css';
|
||||
|
||||
interface DetailItem {
|
||||
_rowKey?: string;
|
||||
id?: string | number;
|
||||
reviewCategory?: string;
|
||||
reviewItem?: string;
|
||||
remarks?: string;
|
||||
score?: number | string;
|
||||
weight?: number | string;
|
||||
remark?: string;
|
||||
sortNum?: number | string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ExamineTask {
|
||||
taskName?: string;
|
||||
templateName?: string;
|
||||
endTime?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ExamineUser {
|
||||
userName?: string;
|
||||
score?: number | string;
|
||||
selfScore?: number | string;
|
||||
manageScore?: number | string;
|
||||
judgeContent?: string;
|
||||
selfJudgeContent?: string;
|
||||
examineStatus?: string | number;
|
||||
examineStatusSelf?: string | number;
|
||||
manageUserName?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface RemarkItem {
|
||||
reviewCategory?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
interface DetailGroup {
|
||||
category: string;
|
||||
remarkCate: string;
|
||||
items: DetailItem[];
|
||||
}
|
||||
|
||||
interface DetailDataset {
|
||||
groups: DetailGroup[];
|
||||
examineTask: ExamineTask;
|
||||
examineUser: ExamineUser;
|
||||
}
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
|
||||
|
||||
const normalizePayload = (response: unknown) => (isObject(response) && response.data !== undefined ? response.data : response);
|
||||
|
||||
const toNumber = (value: unknown, fallback = 0) => {
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : fallback;
|
||||
};
|
||||
|
||||
const clampScore = (value: number) => Math.min(10, Math.max(0, Math.round(value)));
|
||||
|
||||
const groupDetailRows = (items: DetailItem[], remarks: RemarkItem[] = []) => {
|
||||
const remarkMap = new Map<string, string>();
|
||||
remarks.forEach((item) => {
|
||||
const key = String(item.reviewCategory ?? '').trim();
|
||||
if (key) {
|
||||
remarkMap.set(key, String(item.remark ?? ''));
|
||||
}
|
||||
});
|
||||
|
||||
const map = new Map<string, DetailItem[]>();
|
||||
items
|
||||
.slice()
|
||||
.sort((left, right) => toNumber(left.sortNum, 0) - toNumber(right.sortNum, 0))
|
||||
.forEach((item, index) => {
|
||||
const category = String(item.reviewCategory ?? '未分类');
|
||||
const list = map.get(category) ?? [];
|
||||
list.push({
|
||||
...item,
|
||||
_rowKey: String(item.id ?? `${category}_${index}`),
|
||||
});
|
||||
map.set(category, list);
|
||||
});
|
||||
|
||||
return Array.from(map.entries()).map(([category, list]) => ({
|
||||
category,
|
||||
remarkCate: remarkMap.get(category) ?? '',
|
||||
items: list,
|
||||
}));
|
||||
};
|
||||
|
||||
const buildDataset = (response: unknown): DetailDataset => {
|
||||
const payload = normalizePayload(response);
|
||||
if (!isObject(payload)) {
|
||||
return { groups: [], examineTask: {}, examineUser: {} };
|
||||
}
|
||||
const items = Array.isArray(payload.examineConfigDetailVoList) ? (payload.examineConfigDetailVoList as DetailItem[]) : [];
|
||||
const remarks = Array.isArray(payload.remark) ? (payload.remark as RemarkItem[]) : [];
|
||||
return {
|
||||
groups: groupDetailRows(items, remarks),
|
||||
examineTask: isObject(payload.examineTask) ? (payload.examineTask as ExamineTask) : {},
|
||||
examineUser: isObject(payload.examineUser) ? (payload.examineUser as ExamineUser) : {},
|
||||
};
|
||||
};
|
||||
|
||||
const ScoreBar = ({ value }: { value: number }) => {
|
||||
const normalized = clampScore(value);
|
||||
const bubblePosition = normalized * 10;
|
||||
const bubbleClass =
|
||||
normalized === 0 ? 'detail-score-bubble is-start' : normalized === 10 ? 'detail-score-bubble is-end' : 'detail-score-bubble';
|
||||
|
||||
return (
|
||||
<div className="detail-score-wrap">
|
||||
<div className="detail-score-top">
|
||||
<span>0</span>
|
||||
</div>
|
||||
<div className="detail-score-track is-readonly">
|
||||
<div className="detail-score-fill" style={{ width: `${bubblePosition}%` }} />
|
||||
<div className={bubbleClass} style={{ left: `${bubblePosition}%` }}>
|
||||
{normalized}
|
||||
</div>
|
||||
</div>
|
||||
{normalized === 0 && <div className="statusText">暂未打分</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStatusMeta = (user: ExamineUser) => {
|
||||
const selfStatus = String(user.examineStatusSelf ?? '').trim();
|
||||
const manageStatus = String(user.examineStatus ?? '').trim();
|
||||
if (manageStatus === '1') {
|
||||
return { text: '已评分', color: 'success' as const };
|
||||
}
|
||||
if (selfStatus === '1') {
|
||||
return { text: '待评分', color: 'warning' as const };
|
||||
}
|
||||
return { text: '待自评', color: 'processing' as const };
|
||||
};
|
||||
|
||||
const getUnifiedScore = (user: ExamineUser) => user.score ?? user.manageScore ?? user.selfScore ?? '-';
|
||||
|
||||
const UserScoreDetailPage = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const title = searchParams.get('title') ?? '绩效详情';
|
||||
const userName = searchParams.get('userName') ?? '';
|
||||
const taskId = searchParams.get('taskId') ?? '';
|
||||
const deptId = searchParams.get('deptId') ?? '';
|
||||
const examineTaskId = searchParams.get('examineTaskId') ?? searchParams.get('taskId') ?? '';
|
||||
const examineId = searchParams.get('examineId') ?? '';
|
||||
const userId = searchParams.get('userId') ?? '';
|
||||
const pageNum = searchParams.get('pageNum') ?? '1';
|
||||
const pageSize = searchParams.get('pageSize') ?? '10';
|
||||
const routeReviewType = searchParams.get('reviewType') ?? '';
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selfDataset, setSelfDataset] = useState<DetailDataset>({ groups: [], examineTask: {}, examineUser: {} });
|
||||
const [manageDataset, setManageDataset] = useState<DetailDataset>({ groups: [], examineTask: {}, examineUser: {} });
|
||||
const [activeKey, setActiveKey] = useState('0');
|
||||
|
||||
useEffect(() => {
|
||||
const loadDetail = async () => {
|
||||
if (!examineTaskId || !examineId) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const [selfResponse, manageResponse] = await Promise.all([
|
||||
getTaskScoreDetail({ examineTaskId, examineId, userId, reviewType: '1' }),
|
||||
getTaskScoreDetail({ examineTaskId, examineId, userId, reviewType: '0' }),
|
||||
]);
|
||||
const nextSelfDataset = buildDataset(selfResponse);
|
||||
const nextManageDataset = buildDataset(manageResponse);
|
||||
setSelfDataset(nextSelfDataset);
|
||||
setManageDataset(nextManageDataset);
|
||||
|
||||
const manageFinished = String(nextManageDataset.examineUser.examineStatus ?? '') === '1';
|
||||
if (routeReviewType === '0' || routeReviewType === '1') {
|
||||
setActiveKey(routeReviewType);
|
||||
} else {
|
||||
setActiveKey(manageFinished ? '1' : '0');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user score detail:', error);
|
||||
message.error('获取绩效详情失败');
|
||||
setSelfDataset({ groups: [], examineTask: {}, examineUser: {} });
|
||||
setManageDataset({ groups: [], examineTask: {}, examineUser: {} });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void loadDetail();
|
||||
}, [examineId, examineTaskId, routeReviewType, userId]);
|
||||
|
||||
const summaryTask = manageDataset.examineTask.taskName ? manageDataset.examineTask : selfDataset.examineTask;
|
||||
const summaryUser = manageDataset.examineUser.userName ? manageDataset.examineUser : selfDataset.examineUser;
|
||||
const statusMeta = getStatusMeta(summaryUser);
|
||||
|
||||
const columns: ColumnsType<DetailItem> = useMemo(() => [
|
||||
{
|
||||
title: '考核项',
|
||||
dataIndex: 'reviewItem',
|
||||
key: 'reviewItem',
|
||||
width: 220,
|
||||
render: (value: unknown) => String(value ?? '-'),
|
||||
},
|
||||
{
|
||||
title: '评分标准',
|
||||
dataIndex: 'remarks',
|
||||
key: 'remarks',
|
||||
render: (value: unknown) => String(value ?? '-'),
|
||||
},
|
||||
{
|
||||
title: '评分',
|
||||
dataIndex: 'score',
|
||||
key: 'score',
|
||||
width: 360,
|
||||
render: (value: unknown, row) => (
|
||||
row.reviewCategory === '发展与协作'
|
||||
? <span className="user-score-detail-empty">详见自评总结</span>
|
||||
: <ScoreBar value={toNumber(value, 0)} />
|
||||
),
|
||||
},
|
||||
], []);
|
||||
|
||||
const renderGroups = (dataset: DetailDataset, mode: '0' | '1') => {
|
||||
const overallText = mode === '1'
|
||||
? String(dataset.examineUser.judgeContent ?? '')
|
||||
: String(dataset.examineUser.selfJudgeContent ?? '');
|
||||
|
||||
if (!dataset.groups.length) {
|
||||
return <Empty description="暂无绩效详情" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="user-score-detail-groups">
|
||||
{dataset.groups.map((group) => (
|
||||
<div key={`${mode}_${group.category}`} className="user-score-detail-block">
|
||||
<div className="user-score-detail-block-title">{group.category}</div>
|
||||
<Table<DetailItem>
|
||||
rowKey={(row) => String(row._rowKey ?? row.id ?? row.reviewItem ?? '')}
|
||||
columns={columns}
|
||||
dataSource={group.items}
|
||||
pagination={false}
|
||||
/>
|
||||
{group.items.some((item) => String(item.remark ?? '').trim()) && (
|
||||
<div className="user-score-detail-remark-box">
|
||||
<div className="user-score-detail-remark-title">自评总结</div>
|
||||
{group.items
|
||||
.filter((item) => String(item.remark ?? '').trim())
|
||||
.map((item) => (
|
||||
<div key={`${mode}_${group.category}_${item.id ?? item.reviewItem}`} className="user-score-detail-remark-item">
|
||||
<strong>{item.reviewItem}</strong>
|
||||
<p>{String(item.remark ?? '')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{group.remarkCate && (
|
||||
<div className="user-score-detail-remark-box">
|
||||
<div className="user-score-detail-remark-title">大类评价</div>
|
||||
<p>{group.remarkCate}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{overallText && (
|
||||
<div className="user-score-detail-block">
|
||||
<div className="user-score-detail-block-title">{mode === '1' ? '总体评价' : '个人总体评价'}</div>
|
||||
<div className="user-score-detail-remark-box">
|
||||
<p>{overallText}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: '0',
|
||||
label: '自评情况',
|
||||
children: renderGroups(selfDataset, '0'),
|
||||
},
|
||||
{
|
||||
key: '1',
|
||||
label: '主管评分',
|
||||
children: renderGroups(manageDataset, '1'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="user-score-detail-page appraisal-detail-page">
|
||||
<div className="user-score-back-row">
|
||||
<PageBackButton
|
||||
text="返回人员绩效表"
|
||||
fallbackPath={`/workAppraisal/myPerformance?userId=${encodeURIComponent(userId)}&userName=${encodeURIComponent(
|
||||
userName,
|
||||
)}&taskId=${encodeURIComponent(taskId)}&deptId=${encodeURIComponent(
|
||||
deptId,
|
||||
)}&pageNum=${encodeURIComponent(pageNum)}&pageSize=${encodeURIComponent(pageSize)}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(!examineTaskId || !examineId) && <Alert type="warning" showIcon message="缺少详情参数,无法加载绩效详情" />}
|
||||
|
||||
<div className="user-score-shell">
|
||||
<div className="user-score-detail-header">
|
||||
<div>
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>{summaryTask.taskName ?? title}</Typography.Title>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
{(summaryUser.userName ?? userName) ? `${summaryUser.userName ?? userName} 的绩效详情` : '绩效详情'}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<div className="user-score-total-wrap">
|
||||
<div className="user-score-total-card is-final">
|
||||
<span>考核评分</span>
|
||||
<strong>{getUnifiedScore(summaryUser)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-score-meta-row">
|
||||
<Tag color={statusMeta.color}>{statusMeta.text}</Tag>
|
||||
<span>评分人:{String(summaryUser.manageUserName ?? '-')}</span>
|
||||
<span>模板:{String(summaryTask.templateName ?? '-')}</span>
|
||||
<span>截止时间:{String(summaryTask.endTime ?? '-').split(' ')[0] || '-'}</span>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<Tabs activeKey={activeKey} onChange={setActiveKey} items={tabItems} />
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserScoreDetailPage;
|
||||
|
|
@ -0,0 +1,419 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Empty, Select, Spin, Table, Tag, Tree, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import PageBackButton from '@/components/PageBackButton';
|
||||
import { getTaskList, getTaskUserList } from '@/api/appraisal';
|
||||
import { deptTreeSelect, listUser } from '@/api/system/user';
|
||||
import './user-score.css';
|
||||
|
||||
interface DeptTreeNode {
|
||||
key: string;
|
||||
title: string;
|
||||
rawId: string | number;
|
||||
children?: DeptTreeNode[];
|
||||
}
|
||||
|
||||
interface UserScoreRow {
|
||||
_rowKey?: string;
|
||||
id?: string | number;
|
||||
userId?: string | number;
|
||||
taskId?: string | number;
|
||||
examineId?: string | number;
|
||||
examineTaskId?: string | number;
|
||||
userName?: string;
|
||||
score?: string | number;
|
||||
manageScore?: string | number;
|
||||
selfScore?: string | number;
|
||||
examineStatus?: string | number;
|
||||
examineStatusSelf?: string | number;
|
||||
status?: string | number;
|
||||
manageUserName?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TaskRow {
|
||||
id?: string | number;
|
||||
taskName?: string;
|
||||
endTime?: string;
|
||||
createTime?: string;
|
||||
taskStatus?: string | number;
|
||||
userIdList?: Array<string | number>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UserRow {
|
||||
userId?: string | number;
|
||||
dept?: { deptName?: string };
|
||||
deptId?: string | number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
|
||||
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||
|
||||
const normalizeResponseData = (response: unknown) =>
|
||||
isObject(response) && response.data !== undefined ? response.data : response;
|
||||
|
||||
const toNumber = (value: unknown, fallback = 0) => {
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : fallback;
|
||||
};
|
||||
|
||||
const normalizeDeptTreeNodes = (payload: unknown): DeptTreeNode[] => {
|
||||
const data = normalizeResponseData(payload);
|
||||
if (!Array.isArray(data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const mapNodes = (nodes: unknown[]): DeptTreeNode[] =>
|
||||
nodes.flatMap((node, index) => {
|
||||
if (!isObject(node)) {
|
||||
return [];
|
||||
}
|
||||
const rawId = node.id ?? node.deptId ?? node.value ?? `dept_${index}`;
|
||||
const normalizedId = typeof rawId === 'string' || typeof rawId === 'number' ? rawId : String(rawId);
|
||||
return [{
|
||||
key: String(normalizedId),
|
||||
rawId: normalizedId,
|
||||
title: String(node.label ?? node.title ?? node.deptName ?? normalizedId),
|
||||
children: Array.isArray(node.children) ? mapNodes(node.children) : undefined,
|
||||
}];
|
||||
});
|
||||
|
||||
return mapNodes(data);
|
||||
};
|
||||
|
||||
const collectDeptKeys = (nodes: DeptTreeNode[]): string[] =>
|
||||
nodes.flatMap((node) => [node.key, ...(Array.isArray(node.children) ? collectDeptKeys(node.children) : [])]);
|
||||
|
||||
const normalizeTaskRows = (payload: unknown): TaskRow[] => {
|
||||
const data = normalizeResponseData(payload);
|
||||
const rows = Array.isArray(data)
|
||||
? data
|
||||
: isObject(data) && Array.isArray(data.rows)
|
||||
? data.rows
|
||||
: isObject(data) && Array.isArray(data.list)
|
||||
? data.list
|
||||
: [];
|
||||
return rows.filter(isObject) as TaskRow[];
|
||||
};
|
||||
|
||||
const normalizeScoreRows = (payload: unknown): UserScoreRow[] => {
|
||||
const data = normalizeResponseData(payload);
|
||||
const rows = Array.isArray(data)
|
||||
? data
|
||||
: isObject(data) && Array.isArray(data.rows)
|
||||
? data.rows
|
||||
: isObject(data) && Array.isArray(data.list)
|
||||
? data.list
|
||||
: [];
|
||||
|
||||
return rows.filter(isObject).map((row, index) => ({
|
||||
...(row as UserScoreRow),
|
||||
_rowKey: String((row as UserScoreRow).id ?? (row as UserScoreRow).userId ?? `score_${index}`),
|
||||
})) as UserScoreRow[];
|
||||
};
|
||||
|
||||
const normalizeUserRows = (payload: unknown): UserRow[] => {
|
||||
const data = normalizeResponseData(payload);
|
||||
const rows = Array.isArray(data)
|
||||
? data
|
||||
: isObject(data) && Array.isArray(data.rows)
|
||||
? data.rows
|
||||
: [];
|
||||
return rows.filter(isObject) as UserRow[];
|
||||
};
|
||||
|
||||
const getStatusMeta = (row: UserScoreRow) => {
|
||||
const selfStatus = String(row.examineStatusSelf ?? '').trim();
|
||||
const manageStatus = String(row.examineStatus ?? row.status ?? '').trim();
|
||||
|
||||
if (manageStatus === '1') {
|
||||
return { text: '已完成', color: 'success' as const };
|
||||
}
|
||||
if (selfStatus === '1') {
|
||||
return { text: '待评分', color: 'warning' as const };
|
||||
}
|
||||
return { text: '待自评', color: 'processing' as const };
|
||||
};
|
||||
|
||||
const getScoreValue = (row: UserScoreRow) => row.score ?? row.manageScore ?? row.selfScore ?? '-';
|
||||
|
||||
const UserScorePage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const routeTaskId = searchParams.get('taskId') ?? '';
|
||||
const routeDeptId = searchParams.get('deptId') ?? '';
|
||||
const routePageNum = Math.max(1, toNumber(searchParams.get('pageNum'), 1));
|
||||
const requestedPageSize = toNumber(searchParams.get('pageSize'), 10);
|
||||
const routePageSize = PAGE_SIZE_OPTIONS.includes(requestedPageSize) ? requestedPageSize : 10;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deptLoading, setDeptLoading] = useState(false);
|
||||
const [taskList, setTaskList] = useState<TaskRow[]>([]);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string>(routeTaskId);
|
||||
const [deptTree, setDeptTree] = useState<DeptTreeNode[]>([]);
|
||||
const [expandedDeptKeys, setExpandedDeptKeys] = useState<string[]>([]);
|
||||
const [selectedDeptId, setSelectedDeptId] = useState<string>(routeDeptId);
|
||||
const [allRows, setAllRows] = useState<UserScoreRow[]>([]);
|
||||
const [deptUserIds, setDeptUserIds] = useState<Set<string>>(new Set());
|
||||
const [pageNum, setPageNum] = useState(routePageNum);
|
||||
const [pageSize, setPageSize] = useState(routePageSize);
|
||||
|
||||
useEffect(() => {
|
||||
const loadBaseData = async () => {
|
||||
setDeptLoading(true);
|
||||
try {
|
||||
const [taskResponse, deptResponse] = await Promise.all([
|
||||
getTaskList({ pageNum: 1, pageSize: 100000 }),
|
||||
deptTreeSelect(),
|
||||
]);
|
||||
const tasks = normalizeTaskRows(taskResponse).sort((left, right) =>
|
||||
String(right.endTime ?? right.createTime ?? '').localeCompare(String(left.endTime ?? left.createTime ?? '')),
|
||||
);
|
||||
const treeNodes = normalizeDeptTreeNodes(deptResponse);
|
||||
setTaskList(tasks);
|
||||
setDeptTree(treeNodes);
|
||||
setExpandedDeptKeys(collectDeptKeys(treeNodes));
|
||||
if (!routeTaskId && tasks[0]?.id !== undefined) {
|
||||
setSelectedTaskId(String(tasks[0].id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch base data for user score page:', error);
|
||||
message.error('获取人员绩效基础数据失败');
|
||||
} finally {
|
||||
setDeptLoading(false);
|
||||
}
|
||||
};
|
||||
void loadBaseData();
|
||||
}, [routeTaskId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTaskId) {
|
||||
return;
|
||||
}
|
||||
const loadScoreRows = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const currentTask = taskList.find((task) => String(task.id ?? '') === selectedTaskId);
|
||||
const response = await getTaskUserList({
|
||||
isAsc: 'desc',
|
||||
sortFiled: 'all',
|
||||
taskId: selectedTaskId,
|
||||
pageNum: 1,
|
||||
pageSize: 1000,
|
||||
});
|
||||
const rows = normalizeScoreRows(response)
|
||||
.map((row) => ({
|
||||
...row,
|
||||
taskId: row.taskId ?? currentTask?.id,
|
||||
examineTaskId: row.examineTaskId ?? currentTask?.id,
|
||||
status: row.status ?? currentTask?.taskStatus,
|
||||
}))
|
||||
.sort((left, right) => toNumber(right.score ?? right.manageScore, -1) - toNumber(left.score ?? left.manageScore, -1));
|
||||
setAllRows(rows);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user score rows:', error);
|
||||
setAllRows([]);
|
||||
message.error('获取人员绩效列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void loadScoreRows();
|
||||
}, [selectedTaskId, taskList]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDeptId) {
|
||||
setDeptUserIds(new Set());
|
||||
return;
|
||||
}
|
||||
const loadDeptUsers = async () => {
|
||||
try {
|
||||
const response = await listUser({
|
||||
pageNum: 1,
|
||||
pageSize: 1000,
|
||||
deptId: selectedDeptId,
|
||||
});
|
||||
const rows = normalizeUserRows(response);
|
||||
setDeptUserIds(new Set(rows.map((row) => String(row.userId ?? '')).filter(Boolean)));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch department users for user score page:', error);
|
||||
setDeptUserIds(new Set());
|
||||
message.error('获取部门人员失败');
|
||||
}
|
||||
};
|
||||
void loadDeptUsers();
|
||||
}, [selectedDeptId]);
|
||||
|
||||
const currentTask = useMemo(
|
||||
() => taskList.find((item) => String(item.id ?? '') === selectedTaskId),
|
||||
[selectedTaskId, taskList],
|
||||
);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (!selectedDeptId || deptUserIds.size === 0) {
|
||||
return allRows;
|
||||
}
|
||||
return allRows.filter((row) => deptUserIds.has(String(row.userId ?? '')));
|
||||
}, [allRows, deptUserIds, selectedDeptId]);
|
||||
|
||||
const pagedRows = useMemo(() => {
|
||||
const start = (pageNum - 1) * pageSize;
|
||||
return filteredRows.slice(start, start + pageSize);
|
||||
}, [filteredRows, pageNum, pageSize]);
|
||||
|
||||
const resetFilters = () => {
|
||||
setSelectedDeptId('');
|
||||
setDeptUserIds(new Set());
|
||||
setPageNum(1);
|
||||
if (taskList[0]?.id !== undefined) {
|
||||
setSelectedTaskId(String(taskList[0].id));
|
||||
}
|
||||
};
|
||||
|
||||
const openDetail = (row: UserScoreRow) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('userId', String(row.userId ?? ''));
|
||||
params.set('userName', String(row.userName ?? ''));
|
||||
params.set('pageNum', String(pageNum));
|
||||
params.set('pageSize', String(pageSize));
|
||||
params.set('taskId', String(row.taskId ?? selectedTaskId));
|
||||
params.set('examineTaskId', String(row.examineTaskId ?? row.taskId ?? selectedTaskId));
|
||||
params.set('examineId', String(row.examineId ?? row.id ?? ''));
|
||||
params.set('reviewType', String(row.examineStatus ?? '') === '1' ? '1' : '0');
|
||||
params.set('title', String(currentTask?.taskName ?? '绩效详情'));
|
||||
if (selectedDeptId) {
|
||||
params.set('deptId', selectedDeptId);
|
||||
}
|
||||
navigate(`/projectBank/userScoreDetail?${params.toString()}`);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<UserScoreRow> = [
|
||||
{
|
||||
title: '序号',
|
||||
key: 'index',
|
||||
width: 90,
|
||||
render: (_value, _row, index) => (pageNum - 1) * pageSize + index + 1,
|
||||
},
|
||||
{
|
||||
title: '考核人员',
|
||||
dataIndex: 'userName',
|
||||
key: 'userName',
|
||||
render: (value: unknown) => String(value ?? '-'),
|
||||
},
|
||||
{
|
||||
title: '考核评分',
|
||||
key: 'score',
|
||||
sorter: (left, right) => toNumber(left.score ?? left.manageScore, -1) - toNumber(right.score ?? right.manageScore, -1),
|
||||
render: (_value, row) => <span className="user-score-grade">{getScoreValue(row)}</span>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 150,
|
||||
render: (_value, row) => {
|
||||
const meta = getStatusMeta(row);
|
||||
return <Tag color={meta.color}>{meta.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 160,
|
||||
render: (_value, row) => (
|
||||
<Button type="link" onClick={() => openDetail(row)}>
|
||||
查看详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="user-score-page user-score-board-page">
|
||||
<div className="user-score-back-row">
|
||||
<PageBackButton text="返回看板统计" fallbackPath="/projectBank/projectProgress" />
|
||||
</div>
|
||||
|
||||
<div className="user-score-board-shell">
|
||||
<div className="user-score-board-side">
|
||||
<div className="user-score-side-title">组织架构</div>
|
||||
<Spin spinning={deptLoading}>
|
||||
<Tree
|
||||
blockNode
|
||||
showLine
|
||||
className="user-score-board-tree"
|
||||
expandedKeys={expandedDeptKeys}
|
||||
selectedKeys={selectedDeptId ? [selectedDeptId] : []}
|
||||
treeData={deptTree}
|
||||
onExpand={(keys) => setExpandedDeptKeys(keys as string[])}
|
||||
onSelect={(keys) => {
|
||||
setSelectedDeptId(String(keys[0] ?? ''));
|
||||
setPageNum(1);
|
||||
}}
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
|
||||
<div className="user-score-board-main">
|
||||
<div className="user-score-board-toolbar">
|
||||
<div className="user-score-board-filter">
|
||||
<span>统计任务</span>
|
||||
<Select
|
||||
value={selectedTaskId || undefined}
|
||||
className="user-score-task-select"
|
||||
placeholder="请选择统计任务"
|
||||
options={taskList.map((task) => ({
|
||||
value: String(task.id ?? ''),
|
||||
label: String(task.taskName ?? '-'),
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setSelectedTaskId(value);
|
||||
setPageNum(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={resetFilters}>重置</Button>
|
||||
</div>
|
||||
|
||||
<div className="user-score-board-table-wrap">
|
||||
<div className="user-score-board-caption">
|
||||
<div className="user-score-board-title">{currentTask?.taskName ?? '人员绩效表'}</div>
|
||||
<div className="user-score-board-meta">
|
||||
<span>共 {filteredRows.length} 人</span>
|
||||
<span>截止时间:{String(currentTask?.endTime ?? '-').split(' ')[0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<Table<UserScoreRow>
|
||||
className="user-score-table user-score-board-table"
|
||||
rowKey={(row) => String(row._rowKey ?? row.id ?? row.userId ?? '')}
|
||||
columns={columns}
|
||||
dataSource={pagedRows}
|
||||
pagination={{
|
||||
current: pageNum,
|
||||
pageSize,
|
||||
total: filteredRows.length,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: PAGE_SIZE_OPTIONS.map(String),
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (nextPage, nextSize) => {
|
||||
setPageNum(nextPage);
|
||||
setPageSize(nextSize);
|
||||
},
|
||||
}}
|
||||
locale={{ emptyText: <Empty description="暂无绩效数据" /> }}
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserScorePage;
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
.project-user-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.project-user-back-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.project-user-layout {
|
||||
display: flex;
|
||||
min-height: 78vh;
|
||||
background: #fff;
|
||||
border-radius: 22px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.project-user-left,
|
||||
.project-user-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.project-user-left {
|
||||
width: 380px;
|
||||
flex: none;
|
||||
padding: 28px 24px 24px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||
border-right: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.project-user-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 24px 24px 24px 0;
|
||||
}
|
||||
|
||||
.project-user-section-title {
|
||||
margin: 0 0 24px;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
color: #0f172a;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.project-user-info-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.project-user-info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.project-user-info-label {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.project-user-info-item strong {
|
||||
font-size: 15px;
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.project-user-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 0 0 16px 20px;
|
||||
}
|
||||
|
||||
.project-user-range-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #334155;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.project-user-table {
|
||||
height: 100%;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.project-user-table .ant-table {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.project-user-table .ant-table-thead > tr > th {
|
||||
text-align: center;
|
||||
background: #f5f7fa;
|
||||
color: #606266;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.project-user-table .ant-table-tbody > tr > td {
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
padding: 14px 8px;
|
||||
}
|
||||
|
||||
.project-user-table-header {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.project-user-button-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.project-user-log-button.ant-btn-link {
|
||||
padding: 0;
|
||||
height: auto;
|
||||
font-weight: 600;
|
||||
white-space: normal;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.project-user-empty-cell {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.project-user-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.project-user-left {
|
||||
width: 100%;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.project-user-right {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.project-user-toolbar,
|
||||
.project-user-table {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.project-user-left,
|
||||
.project-user-right {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.project-user-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
.user-project-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-project-back-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-project-shell {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 22px;
|
||||
background: #fff;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.06);
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.user-project-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.user-project-title {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.user-project-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.user-project-user-box,
|
||||
.user-project-range-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-project-user-box > span,
|
||||
.user-project-range-box > span {
|
||||
color: #475569;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-project-user-box .ant-input-affix-wrapper {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.user-project-table .ant-table-thead > tr > th {
|
||||
text-align: center;
|
||||
background: #f5f7fa;
|
||||
color: #606266;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.user-project-table .ant-table-tbody > tr > td,
|
||||
.user-project-table .ant-table-summary > tr > td {
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.user-project-header {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.user-project-link.ant-btn-link {
|
||||
padding: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
.user-select-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.user-select-modal-layout {
|
||||
min-height: 520px;
|
||||
flex-direction: row;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.user-select-dept-panel {
|
||||
width: 220px;
|
||||
flex: 0 0 220px;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.user-select-dept-tree {
|
||||
height: 100%;
|
||||
max-height: 470px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.user-select-dept-tree .ant-tree-node-content-wrapper {
|
||||
min-height: 32px;
|
||||
line-height: 32px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.user-select-table-panel {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.user-select-query-bar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 300px);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.user-select-query-bar .ant-input-affix-wrapper {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.user-select-modal .ant-table-wrapper {
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-select-modal .ant-table-thead > tr > th {
|
||||
background: #f8fafc;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.user-select-modal .ant-table-pagination {
|
||||
margin: 16px 16px 0;
|
||||
}
|
||||
|
||||
.user-select-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-select-row.is-active > td {
|
||||
background: #4b97f0 !important;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.user-select-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-select-name-cell strong {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.user-select-row.is-active .user-select-name-cell strong {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.user-select-dialog .ant-modal-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.user-project-header-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.user-project-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.user-project-user-box,
|
||||
.user-project-range-box {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.user-project-user-box .ant-input-affix-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-select-modal-layout {
|
||||
min-height: unset;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-select-dept-panel {
|
||||
width: 100%;
|
||||
flex: none;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-right: 0;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.user-select-query-bar {
|
||||
grid-template-columns: 1fr;
|
||||
justify-content: stretch;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,455 @@
|
|||
.user-score-page,
|
||||
.user-score-detail-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-score-board-page {
|
||||
min-height: calc(100vh - 140px);
|
||||
}
|
||||
|
||||
.user-score-back-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-score-shell {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.user-score-board-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
min-height: calc(100vh - 220px);
|
||||
border: 1px solid #dfe5ec;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.user-score-board-side {
|
||||
border-right: 1px solid #dfe5ec;
|
||||
background: #f4f6f8;
|
||||
padding: 18px 0;
|
||||
}
|
||||
|
||||
.user-score-side-title {
|
||||
padding: 0 20px 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.user-score-board-tree {
|
||||
padding: 0 12px 0 20px;
|
||||
}
|
||||
|
||||
.user-score-board-tree .ant-tree-node-content-wrapper {
|
||||
min-height: 36px;
|
||||
line-height: 36px;
|
||||
border-radius: 0;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.user-score-board-tree .ant-tree-node-selected,
|
||||
.user-score-board-tree .ant-tree-node-content-wrapper.ant-tree-node-selected {
|
||||
background: #e5eef9 !important;
|
||||
color: #1677ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-score-board-main {
|
||||
padding: 22px 28px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-score-board-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.user-score-board-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.user-score-board-filter > span {
|
||||
color: #4b5563;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-score-task-select {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.user-score-board-table-wrap {
|
||||
border: 1px solid #dfe5ec;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.user-score-board-caption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.user-score-board-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.user-score-board-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.user-score-header-row,
|
||||
.user-score-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.user-score-title {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.user-score-subtitle {
|
||||
margin-top: 6px;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-score-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.user-score-user-box,
|
||||
.user-score-range-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-score-user-box > span,
|
||||
.user-score-range-box > span {
|
||||
color: #475569;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-score-user-box .ant-input-affix-wrapper {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.user-score-table .ant-table-thead > tr > th {
|
||||
background: #fafafa;
|
||||
color: #374151;
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.user-score-board-table .ant-table {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.user-score-board-table .ant-table-tbody > tr > td {
|
||||
height: 54px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
.user-score-board-table .ant-table-tbody > tr:hover > td {
|
||||
background: #eef5ff !important;
|
||||
}
|
||||
|
||||
.user-score-board-table .ant-table-pagination {
|
||||
padding: 18px 8px 6px;
|
||||
}
|
||||
|
||||
.user-score-grade {
|
||||
color: #4b5563;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-score-total-value {
|
||||
color: #f97316;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.user-score-detail-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-score-total-wrap {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.user-score-total-card {
|
||||
min-width: 132px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
color: #1d4ed8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.user-score-total-card.is-accent {
|
||||
background: linear-gradient(135deg, #fff7ed 0%, #fed7aa 100%);
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.user-score-total-card.is-final {
|
||||
background: linear-gradient(135deg, #ecfdf5 0%, #bbf7d0 100%);
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.user-score-total-card strong {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.user-score-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 14px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.user-score-detail-block {
|
||||
padding: 18px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.user-score-detail-block-title {
|
||||
margin-bottom: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.user-score-detail-remark-box {
|
||||
margin-top: 14px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.user-score-detail-remark-title {
|
||||
margin-bottom: 10px;
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-score-detail-remark-box p {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
color: #475569;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.user-score-detail-remark-item + .user-score-detail-remark-item {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.user-score-detail-remark-item strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.user-score-detail-empty {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.user-score-select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.user-score-select-layout {
|
||||
min-height: 520px;
|
||||
flex-direction: row;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.user-score-dept-panel {
|
||||
width: 220px;
|
||||
flex: 0 0 220px;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.user-score-dept-tree {
|
||||
height: 100%;
|
||||
max-height: 470px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.user-score-dept-tree .ant-tree-node-content-wrapper {
|
||||
min-height: 32px;
|
||||
line-height: 32px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.user-score-table-panel {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.user-score-query-bar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 320px);
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.user-score-select .ant-table-wrapper {
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-score-select .ant-table-thead > tr > th {
|
||||
background: #f8fafc;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.user-score-select-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-score-select-row.is-active > td {
|
||||
background: #4b97f0 !important;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.user-score-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-score-name-cell strong {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.user-score-select-row.is-active .user-score-name-cell strong {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.user-score-dialog .ant-modal-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.user-score-board-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.user-score-board-side {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #dfe5ec;
|
||||
}
|
||||
|
||||
.user-score-board-main {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.user-score-board-toolbar,
|
||||
.user-score-board-caption {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.user-score-task-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-score-header-row,
|
||||
.user-score-detail-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.user-score-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.user-score-user-box,
|
||||
.user-score-range-box {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.user-score-user-box .ant-input-affix-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-score-select-layout {
|
||||
min-height: unset;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-score-dept-panel {
|
||||
width: 100%;
|
||||
flex: none;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-right: 0;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.user-score-query-bar {
|
||||
grid-template-columns: 1fr;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.user-score-total-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table, Form, Input, Select, Button, Modal, message, Space, Tag, DatePicker, Popconfirm, Radio
|
||||
} from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import {
|
||||
SearchOutlined, ReloadOutlined, PlusOutlined, EditOutlined, DeleteOutlined, DownloadOutlined, ExclamationCircleOutlined, SyncOutlined
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
listConfig, getConfig, addConfig, updateConfig, delConfig, refreshCache
|
||||
} from '../../api/system/config';
|
||||
import { saveAs } from 'file-saver';
|
||||
import dayjs from 'dayjs';
|
||||
import { parseTime } from '../../utils/ruoyi';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
// Mock Dictionaries
|
||||
const sysYesNoDict = [
|
||||
{ value: 'Y', label: '是' },
|
||||
{ value: 'N', label: '否' },
|
||||
];
|
||||
|
||||
const ConfigPage: React.FC = () => {
|
||||
const [queryForm] = Form.useForm();
|
||||
const [configForm] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [configList, setConfigList] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
|
||||
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [modalTitle, setModalTitle] = useState('');
|
||||
const [currentConfig, setCurrentConfig] = useState<any>({});
|
||||
|
||||
const [queryParams, setQueryParams] = useState({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
configName: undefined,
|
||||
configKey: undefined,
|
||||
configType: undefined,
|
||||
});
|
||||
|
||||
const getList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const formattedQueryParams = { ...queryParams };
|
||||
if (dateRange && dateRange.length === 2) {
|
||||
formattedQueryParams['beginTime'] = dateRange[0].format('YYYY-MM-DD');
|
||||
formattedQueryParams['endTime'] = dateRange[1].format('YYYY-MM-DD');
|
||||
} else {
|
||||
formattedQueryParams['beginTime'] = undefined;
|
||||
formattedQueryParams['endTime'] = undefined;
|
||||
}
|
||||
const response = await listConfig(formattedQueryParams);
|
||||
setConfigList(response.rows);
|
||||
setTotal(response.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config list:', error);
|
||||
message.error('获取参数列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [queryParams, dateRange]);
|
||||
|
||||
useEffect(() => {
|
||||
getList();
|
||||
}, [getList]);
|
||||
|
||||
const handleQuery = () => {
|
||||
setQueryParams(prev => ({ ...prev, pageNum: 1 }));
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
queryForm.resetFields();
|
||||
setDateRange(null);
|
||||
setQueryParams({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
configName: undefined,
|
||||
configKey: undefined,
|
||||
configType: undefined,
|
||||
});
|
||||
getList();
|
||||
};
|
||||
|
||||
const resetConfigForm = () => {
|
||||
configForm.resetFields();
|
||||
setCurrentConfig({});
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
resetConfigForm();
|
||||
setModalTitle('添加参数');
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleUpdate = async (row: any) => {
|
||||
resetConfigForm();
|
||||
try {
|
||||
const configId = row.configId || selectedRowKeys[0];
|
||||
const response = await getConfig(configId);
|
||||
const detail = (response && typeof response === 'object' && 'data' in response)
|
||||
? response.data ?? {}
|
||||
: response ?? {};
|
||||
setCurrentConfig(detail);
|
||||
configForm.setFieldsValue({
|
||||
...detail,
|
||||
});
|
||||
setModalTitle('修改参数');
|
||||
setModalVisible(true);
|
||||
} catch (error) {
|
||||
message.error('获取参数详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (row?: any) => {
|
||||
const configIds = row?.configId ? [row.configId] : selectedRowKeys;
|
||||
if (configIds.length === 0) {
|
||||
message.warning('请选择要删除的参数');
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: `是否确认删除参数编号为"${configIds.join(',')}"的数据项?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await delConfig(configIds.join(','));
|
||||
message.success('删除成功');
|
||||
setSelectedRowKeys([]);
|
||||
getList();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
const hide = message.loading('正在导出数据...', 0);
|
||||
try {
|
||||
const response = await listConfig({ ...queryParams, pageNum: undefined, pageSize: undefined });
|
||||
const header = ['参数主键', '参数名称', '参数键名', '参数键值', '系统内置', '备注', '创建时间'];
|
||||
const data = response.rows.map((config: any) => [
|
||||
config.configId, config.configName, config.configKey, config.configValue,
|
||||
sysYesNoDict.find((d) => d.value === config.configType)?.label ?? '',
|
||||
config.remark, parseTime(config.createTime),
|
||||
]);
|
||||
|
||||
const csvContent = [header, ...data]
|
||||
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
||||
.join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
saveAs(blob, `config_${dayjs().format('YYYYMMDDHHmmss')}.csv`);
|
||||
hide();
|
||||
message.success('导出成功');
|
||||
} catch {
|
||||
hide();
|
||||
message.error('导出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshCache = async () => {
|
||||
try {
|
||||
await refreshCache();
|
||||
message.success('刷新缓存成功');
|
||||
} catch (error) {
|
||||
message.error('刷新缓存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const submitConfigForm = async (values: any) => {
|
||||
try {
|
||||
const configData = { ...currentConfig, ...values };
|
||||
if (configData.configId !== undefined) {
|
||||
await updateConfig(configData);
|
||||
message.success('修改成功');
|
||||
} else {
|
||||
await addConfig(configData);
|
||||
message.success('新增成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
getList();
|
||||
} catch (error) {
|
||||
console.error('Submit config form failed:', error);
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: TableColumnsType<any> = [
|
||||
{ title: '参数主键', dataIndex: 'configId', align: 'center' },
|
||||
{ title: '参数名称', dataIndex: 'configName', align: 'center', ellipsis: true },
|
||||
{ title: '参数键名', dataIndex: 'configKey', align: 'center', ellipsis: true },
|
||||
{ title: '参数键值', dataIndex: 'configValue', align: 'center', ellipsis: true },
|
||||
{
|
||||
title: '系统内置', dataIndex: 'configType', align: 'center',
|
||||
render: (text: string) => <Tag color={text === 'Y' ? 'blue' : 'gray'}>{sysYesNoDict.find(d => d.value === text)?.label}</Tag>,
|
||||
},
|
||||
{ title: '备注', dataIndex: 'remark', align: 'center', ellipsis: true },
|
||||
{ title: '创建时间', dataIndex: 'createTime', align: 'center', width: 180, render: (text: string) => parseTime(text) },
|
||||
{
|
||||
title: '操作', key: 'operation', align: 'center', width: 180,
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => handleUpdate(record)}>修改</Button>
|
||||
<Popconfirm
|
||||
title="是否确认删除该参数?"
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText="是"
|
||||
cancelText="否"
|
||||
>
|
||||
<Button type="link" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<Form form={queryForm} layout="inline" className="search-form" onFinish={handleQuery} initialValues={queryParams}>
|
||||
<Form.Item label="参数名称" name="configName">
|
||||
<Input placeholder="请输入参数名称" allowClear onPressEnter={handleQuery} style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="参数键名" name="configKey">
|
||||
<Input placeholder="请输入参数键名" allowClear onPressEnter={handleQuery} style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="系统内置" name="configType">
|
||||
<Select placeholder="系统内置" allowClear style={{ width: 240 }}>
|
||||
{sysYesNoDict.map(dict => (
|
||||
<Select.Option key={dict.value} value={dict.value}>{dict.label}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="创建时间">
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={(dates) => setDateRange(dates)}
|
||||
format="YYYY-MM-DD"
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">搜索</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetQuery} style={{ marginLeft: 8 }}>重置</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Space className="mb8">
|
||||
<Button type="primary" ghost icon={<PlusOutlined />} onClick={handleAdd}>新增</Button>
|
||||
<Button type="primary" ghost icon={<EditOutlined />} disabled={selectedRowKeys.length !== 1} onClick={() => handleUpdate(selectedRowKeys)}>修改</Button>
|
||||
<Button type="danger" ghost icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0} onClick={() => handleDelete()}>删除</Button>
|
||||
<Button type="warning" ghost icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
|
||||
<Button type="danger" ghost icon={<SyncOutlined />} onClick={handleRefreshCache}>刷新缓存</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={configList}
|
||||
rowKey="configId"
|
||||
loading={loading}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys),
|
||||
}}
|
||||
pagination={{
|
||||
current: queryParams.pageNum,
|
||||
pageSize: queryParams.pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setQueryParams(prev => ({ ...prev, pageNum: page, pageSize: pageSize }));
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Add/Edit Config Modal */}
|
||||
<Modal
|
||||
title={modalTitle}
|
||||
open={modalVisible}
|
||||
onOk={configForm.submit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={500}
|
||||
forceRender
|
||||
>
|
||||
<Form form={configForm} labelCol={{ span: 5 }} wrapperCol={{ span: 16 }} onFinish={submitConfigForm} initialValues={{ configType: 'Y' }}>
|
||||
<Form.Item name="configId" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="参数名称" name="configName" rules={[{ required: true, message: '请输入参数名称' }]}>
|
||||
<Input placeholder="请输入参数名称" />
|
||||
</Form.Item>
|
||||
<Form.Item label="参数键名" name="configKey" rules={[{ required: true, message: '请输入参数键名' }]}>
|
||||
<Input placeholder="请输入参数键名" />
|
||||
</Form.Item>
|
||||
<Form.Item label="参数键值" name="configValue" rules={[{ required: true, message: '请输入参数键值' }]}>
|
||||
<Input placeholder="请输入参数键值" />
|
||||
</Form.Item>
|
||||
<Form.Item label="系统内置" name="configType">
|
||||
<Radio.Group>
|
||||
{sysYesNoDict.map(dict => (
|
||||
<Radio key={dict.value} value={dict.value}>{dict.label}</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item label="备注" name="remark">
|
||||
<Input.TextArea placeholder="请输入内容" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigPage;
|
||||
|
|
@ -0,0 +1,336 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table, Form, Input, Select, Button, Modal, message, Space, Tag, InputNumber, TreeSelect, Popconfirm, Radio
|
||||
} from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import {
|
||||
SearchOutlined, ReloadOutlined, PlusOutlined, EditOutlined, DeleteOutlined, ExclamationCircleOutlined, SortAscendingOutlined
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
listDept, getDept, addDept, updateDept, delDept, listDeptExcludeChild
|
||||
} from '../../api/system/dept';
|
||||
import { parseTime, handleTree } from '../../utils/ruoyi'; // Using handleTree utility
|
||||
|
||||
// Mock Dictionaries
|
||||
const sysNormalDisableDict = [
|
||||
{ value: '0', label: '正常' },
|
||||
{ value: '1', label: '停用' },
|
||||
];
|
||||
|
||||
const DeptPage: React.FC = () => {
|
||||
const [queryForm] = Form.useForm();
|
||||
const [deptForm] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deptList, setDeptList] = useState<any[]>([]);
|
||||
const [deptOptions, setDeptOptions] = useState<any[]>([]);
|
||||
const [isExpandAll, setIsExpandAll] = useState(true);
|
||||
const [refreshTable, setRefreshTable] = useState(true);
|
||||
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [modalTitle, setModalTitle] = useState('');
|
||||
const [currentDept, setCurrentDept] = useState<any>({});
|
||||
|
||||
const [queryParams, setQueryParams] = useState({
|
||||
deptName: undefined,
|
||||
status: undefined,
|
||||
});
|
||||
|
||||
const extractListData = (response: any): any[] => {
|
||||
if (Array.isArray(response)) {
|
||||
return response;
|
||||
}
|
||||
if (!response || typeof response !== 'object') {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(response.data)) {
|
||||
return response.data;
|
||||
}
|
||||
if (Array.isArray(response.rows)) {
|
||||
return response.rows;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const extractTreeData = (response: any): any[] => {
|
||||
if (Array.isArray(response)) {
|
||||
return response;
|
||||
}
|
||||
if (!response || typeof response !== 'object') {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(response.depts)) {
|
||||
return response.depts;
|
||||
}
|
||||
if (Array.isArray(response.data)) {
|
||||
return response.data;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const normalizeTreeSelectNodes = (nodes: any[]): any[] => {
|
||||
return (nodes ?? [])
|
||||
.map((node) => {
|
||||
const id = node?.id ?? node?.deptId ?? node?.value;
|
||||
if (id === undefined || id === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
label: node?.label ?? node?.deptName ?? String(id),
|
||||
children: normalizeTreeSelectNodes(node?.children ?? []),
|
||||
};
|
||||
})
|
||||
.filter((node) => node !== null);
|
||||
};
|
||||
|
||||
const extractDetailData = (response: any): any => {
|
||||
if (response && typeof response === 'object' && 'data' in response) {
|
||||
return response.data ?? {};
|
||||
}
|
||||
return response ?? {};
|
||||
};
|
||||
|
||||
const getList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await listDept(queryParams);
|
||||
const data = handleTree(extractListData(response), 'deptId');
|
||||
setDeptList(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch department list:', error);
|
||||
message.error('获取部门列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [queryParams]);
|
||||
|
||||
const getTreeselect = useCallback(async (excludeNodeId?: string | number) => {
|
||||
try {
|
||||
const response = excludeNodeId ? await listDeptExcludeChild(excludeNodeId) : await listDept();
|
||||
const dept = { id: 0, label: '主部门', children: [] as any[] };
|
||||
dept.children = normalizeTreeSelectNodes(extractTreeData(response));
|
||||
setDeptOptions([dept]);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch department tree select:', error);
|
||||
message.error('获取部门树失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getList();
|
||||
}, [getList]);
|
||||
|
||||
const handleQuery = () => {
|
||||
const values = queryForm.getFieldsValue();
|
||||
setQueryParams({
|
||||
deptName: values.deptName,
|
||||
status: values.status,
|
||||
});
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
queryForm.resetFields();
|
||||
setQueryParams({
|
||||
deptName: undefined,
|
||||
status: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const resetDeptForm = () => {
|
||||
deptForm.resetFields();
|
||||
setCurrentDept({});
|
||||
};
|
||||
|
||||
const handleAdd = async (row?: any) => {
|
||||
resetDeptForm();
|
||||
await getTreeselect();
|
||||
if (row != null && row.deptId) {
|
||||
deptForm.setFieldsValue({ parentId: row.deptId });
|
||||
} else {
|
||||
deptForm.setFieldsValue({ parentId: 0 });
|
||||
}
|
||||
setModalTitle('添加部门');
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleUpdate = async (row: any) => {
|
||||
resetDeptForm();
|
||||
await getTreeselect(row.deptId);
|
||||
try {
|
||||
const response = await getDept(row.deptId);
|
||||
const detail = extractDetailData(response);
|
||||
setCurrentDept(detail);
|
||||
deptForm.setFieldsValue({
|
||||
...detail,
|
||||
parentId: detail.parentId || 0,
|
||||
});
|
||||
setModalTitle('修改部门');
|
||||
setModalVisible(true);
|
||||
} catch (error) {
|
||||
message.error('获取部门详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (row: any) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: `是否确认删除名称为"${row.deptName}"的数据项?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await delDept(row.deptId);
|
||||
message.success('删除成功');
|
||||
getList();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const toggleExpandAll = () => {
|
||||
setRefreshTable(false);
|
||||
setIsExpandAll(!isExpandAll);
|
||||
setTimeout(() => {
|
||||
setRefreshTable(true);
|
||||
}, 10);
|
||||
};
|
||||
|
||||
const submitDeptForm = async (values: any) => {
|
||||
try {
|
||||
const deptData = { ...currentDept, ...values };
|
||||
if (deptData.deptId !== undefined) {
|
||||
await updateDept(deptData);
|
||||
message.success('修改成功');
|
||||
} else {
|
||||
await addDept(deptData);
|
||||
message.success('新增成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
getList();
|
||||
} catch (error) {
|
||||
console.error('Submit department form failed:', error);
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: TableColumnsType<any> = [
|
||||
{ title: '部门名称', dataIndex: 'deptName', key: 'deptName', width: 260, ellipsis: true },
|
||||
{ title: '排序', dataIndex: 'orderNum', key: 'orderNum', align: 'center', width: 80 },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', align: 'center', width: 100,
|
||||
render: (text: string) => <Tag color={text === '0' ? 'green' : 'red'}>{sysNormalDisableDict.find(d => d.value === text)?.label}</Tag>,
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime', align: 'center', width: 180, render: (text: string) => parseTime(text) },
|
||||
{
|
||||
title: '操作', key: 'operation', align: 'center', width: 200,
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => handleUpdate(record)}>修改</Button>
|
||||
<Button type="link" icon={<PlusOutlined />} onClick={() => handleAdd(record)}>新增</Button>
|
||||
{record.parentId !== 0 && (
|
||||
<Popconfirm
|
||||
title="是否确认删除该部门?"
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText="是"
|
||||
cancelText="否"
|
||||
>
|
||||
<Button type="link" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<Form form={queryForm} layout="inline" className="search-form" onFinish={handleQuery} initialValues={queryParams}>
|
||||
<Form.Item label="部门名称" name="deptName">
|
||||
<Input placeholder="请输入部门名称" allowClear onPressEnter={handleQuery} style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select placeholder="部门状态" allowClear style={{ width: 240 }}>
|
||||
{sysNormalDisableDict.map(dict => (
|
||||
<Select.Option key={dict.value} value={dict.value}>{dict.label}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">搜索</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetQuery} style={{ marginLeft: 8 }}>重置</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Space className="mb8">
|
||||
<Button type="primary" ghost icon={<PlusOutlined />} onClick={() => handleAdd()}>新增</Button>
|
||||
<Button type="info" ghost icon={<SortAscendingOutlined />} onClick={toggleExpandAll}>展开/折叠</Button>
|
||||
</Space>
|
||||
|
||||
{refreshTable && (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={deptList}
|
||||
rowKey="deptId"
|
||||
loading={loading}
|
||||
expandable={{
|
||||
defaultExpandAllRows: isExpandAll,
|
||||
rowExpandable: (record) => Array.isArray(record.children) && record.children.length > 0,
|
||||
}}
|
||||
pagination={false}
|
||||
scroll={{ y: 'calc(100vh - 250px)' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Department Modal */}
|
||||
<Modal
|
||||
title={modalTitle}
|
||||
open={modalVisible}
|
||||
onOk={deptForm.submit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={600}
|
||||
forceRender
|
||||
>
|
||||
<Form form={deptForm} labelCol={{ span: 5 }} wrapperCol={{ span: 16 }} onFinish={submitDeptForm} initialValues={{ orderNum: 0, status: '0' }}>
|
||||
<Form.Item label="上级部门" name="parentId">
|
||||
<TreeSelect
|
||||
treeData={deptOptions}
|
||||
fieldNames={{ label: 'label', value: 'id', children: 'children' }}
|
||||
showSearch
|
||||
style={{ width: '100%' }}
|
||||
styles={{ popup: { root: { maxHeight: 400, overflow: 'auto' } } }}
|
||||
treeNodeFilterProp="label"
|
||||
placeholder="选择上级部门"
|
||||
allowClear
|
||||
treeDefaultExpandAll
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="部门名称" name="deptName" rules={[{ required: true, message: '请输入部门名称' }]}>
|
||||
<Input placeholder="请输入部门名称" />
|
||||
</Form.Item>
|
||||
<Form.Item label="显示排序" name="orderNum" rules={[{ required: true, message: '请输入显示排序' }]}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="负责人" name="leader">
|
||||
<Input placeholder="请输入负责人" />
|
||||
</Form.Item>
|
||||
<Form.Item label="联系电话" name="phone" rules={[{ pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/, message: "请输入正确的手机号码" }]}>
|
||||
<Input placeholder="请输入联系电话" />
|
||||
</Form.Item>
|
||||
<Form.Item label="邮箱" name="email" rules={[{ type: 'email', message: "请输入正确的邮箱地址" }]}>
|
||||
<Input placeholder="请输入邮箱" />
|
||||
</Form.Item>
|
||||
<Form.Item label="部门状态" name="status">
|
||||
<Radio.Group>
|
||||
{sysNormalDisableDict.map(dict => (
|
||||
<Radio key={dict.value} value={dict.value}>{dict.label}</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeptPage;
|
||||
|
|
@ -0,0 +1,327 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table, Form, Input, Select, Button, Modal, message, Space, Tag, DatePicker, Popconfirm, Radio
|
||||
} from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import {
|
||||
SearchOutlined, ReloadOutlined, PlusOutlined, EditOutlined, DeleteOutlined, DownloadOutlined, ExclamationCircleOutlined, SyncOutlined
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
listDictType, getDictType, addDictType, updateDictType, delDictType, refreshCache
|
||||
} from '../../api/system/dict';
|
||||
import { saveAs } from 'file-saver';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import zhCN from 'antd/es/date-picker/locale/zh_CN';
|
||||
import { parseTime } from '../../utils/ruoyi';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
// Mock Dictionaries
|
||||
const sysNormalDisableDict = [
|
||||
{ value: '0', label: '正常' },
|
||||
{ value: '1', label: '停用' },
|
||||
];
|
||||
|
||||
const DictPage: React.FC = () => {
|
||||
const [queryForm] = Form.useForm();
|
||||
const [dictForm] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [typeList, setTypeList] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
|
||||
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [modalTitle, setModalTitle] = useState('');
|
||||
const [currentDict, setCurrentDict] = useState<any>({});
|
||||
|
||||
const [queryParams, setQueryParams] = useState({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
dictName: undefined,
|
||||
dictType: undefined,
|
||||
status: undefined,
|
||||
});
|
||||
|
||||
const getList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const formattedQueryParams = { ...queryParams };
|
||||
if (dateRange && dateRange.length === 2) {
|
||||
formattedQueryParams['beginTime'] = dateRange[0].format('YYYY-MM-DD');
|
||||
formattedQueryParams['endTime'] = dateRange[1].format('YYYY-MM-DD');
|
||||
} else {
|
||||
formattedQueryParams['beginTime'] = undefined;
|
||||
formattedQueryParams['endTime'] = undefined;
|
||||
}
|
||||
const response = await listDictType(formattedQueryParams);
|
||||
setTypeList(response.rows);
|
||||
setTotal(response.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dictionary type list:', error);
|
||||
message.error('获取字典类型列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [queryParams, dateRange]);
|
||||
|
||||
useEffect(() => {
|
||||
getList();
|
||||
}, [getList]);
|
||||
|
||||
const handleQuery = () => {
|
||||
setQueryParams(prev => ({ ...prev, pageNum: 1 }));
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
queryForm.resetFields();
|
||||
setDateRange(null);
|
||||
setQueryParams({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
dictName: undefined,
|
||||
dictType: undefined,
|
||||
status: undefined,
|
||||
});
|
||||
getList();
|
||||
};
|
||||
|
||||
const resetDictForm = () => {
|
||||
dictForm.resetFields();
|
||||
setCurrentDict({});
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
resetDictForm();
|
||||
setModalTitle('添加字典类型');
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleUpdate = async (row: any) => {
|
||||
resetDictForm();
|
||||
try {
|
||||
const dictId = row.dictId || selectedRowKeys[0];
|
||||
const response = await getDictType(dictId);
|
||||
const detail = (response && typeof response === 'object' && 'data' in response)
|
||||
? response.data ?? {}
|
||||
: response ?? {};
|
||||
setCurrentDict(detail);
|
||||
dictForm.setFieldsValue({
|
||||
...detail,
|
||||
});
|
||||
setModalTitle('修改字典类型');
|
||||
setModalVisible(true);
|
||||
} catch (error) {
|
||||
message.error('获取字典类型详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (row?: any) => {
|
||||
const dictIds = row?.dictId ? [row.dictId] : selectedRowKeys;
|
||||
if (dictIds.length === 0) {
|
||||
message.warning('请选择要删除的字典类型');
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: `是否确认删除字典编号为"${dictIds.join(',')}"的数据项?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await delDictType(dictIds.join(','));
|
||||
message.success('删除成功');
|
||||
setSelectedRowKeys([]);
|
||||
getList();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
const hide = message.loading('正在导出数据...', 0);
|
||||
try {
|
||||
const response = await listDictType({ ...queryParams, pageNum: undefined, pageSize: undefined });
|
||||
const header = ['字典编号', '字典名称', '字典类型', '状态', '备注', '创建时间'];
|
||||
const data = response.rows.map((dict: any) => [
|
||||
dict.dictId, dict.dictName, dict.dictType,
|
||||
sysNormalDisableDict.find((d) => d.value === String(dict.status ?? ''))?.label ?? '',
|
||||
dict.remark, parseTime(dict.createTime),
|
||||
]);
|
||||
|
||||
const csvContent = [header, ...data]
|
||||
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
||||
.join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
saveAs(blob, `dict_type_${dayjs().format('YYYYMMDDHHmmss')}.csv`);
|
||||
hide();
|
||||
message.success('导出成功');
|
||||
} catch {
|
||||
hide();
|
||||
message.error('导出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshCache = async () => {
|
||||
try {
|
||||
await refreshCache();
|
||||
message.success('刷新缓存成功');
|
||||
} catch (error) {
|
||||
message.error('刷新缓存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const submitDictForm = async (values: any) => {
|
||||
try {
|
||||
const dictData = { ...currentDict, ...values };
|
||||
if (dictData.dictId !== undefined) {
|
||||
await updateDictType(dictData);
|
||||
message.success('修改成功');
|
||||
} else {
|
||||
await addDictType(dictData);
|
||||
message.success('新增成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
getList();
|
||||
} catch (error) {
|
||||
console.error('Submit dictionary type form failed:', error);
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: TableColumnsType<any> = [
|
||||
{ title: '字典编号', dataIndex: 'dictId', align: 'center' },
|
||||
{ title: '字典名称', dataIndex: 'dictName', align: 'center', ellipsis: true },
|
||||
{
|
||||
title: '字典类型', dataIndex: 'dictType', align: 'center', ellipsis: true,
|
||||
render: (text: string, record: any) => (
|
||||
<Link to={`/system/dict-data/index/${record.dictId}`}>{text}</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'status', align: 'center',
|
||||
render: (text: string) => <Tag color={text === '0' ? 'green' : 'red'}>{sysNormalDisableDict.find(d => d.value === text)?.label}</Tag>,
|
||||
},
|
||||
{ title: '备注', dataIndex: 'remark', align: 'center', ellipsis: true },
|
||||
{ title: '创建时间', dataIndex: 'createTime', align: 'center', width: 180, render: (text: string) => parseTime(text) },
|
||||
{
|
||||
title: '操作', key: 'operation', align: 'center', width: 180,
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => handleUpdate(record)}>修改</Button>
|
||||
<Popconfirm
|
||||
title="是否确认删除该字典类型?"
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText="是"
|
||||
cancelText="否"
|
||||
>
|
||||
<Button type="link" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<Form form={queryForm} layout="inline" className="search-form" onFinish={handleQuery} initialValues={queryParams}>
|
||||
<Form.Item label="字典名称" name="dictName">
|
||||
<Input placeholder="请输入字典名称" allowClear onPressEnter={handleQuery} style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="字典类型" name="dictType">
|
||||
<Input placeholder="请输入字典类型" allowClear onPressEnter={handleQuery} style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select placeholder="字典状态" allowClear style={{ width: 240 }}>
|
||||
{sysNormalDisableDict.map(dict => (
|
||||
<Select.Option key={dict.value} value={dict.value}>{dict.label}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="创建时间">
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={(dates) => setDateRange(dates)}
|
||||
locale={zhCN}
|
||||
format="YYYY年MM月DD日"
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">搜索</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetQuery} style={{ marginLeft: 8 }}>重置</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Space className="mb8">
|
||||
<Button type="primary" ghost icon={<PlusOutlined />} onClick={handleAdd}>新增</Button>
|
||||
<Button type="primary" ghost icon={<EditOutlined />} disabled={selectedRowKeys.length !== 1} onClick={() => handleUpdate(selectedRowKeys)}>修改</Button>
|
||||
<Button type="danger" ghost icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0} onClick={() => handleDelete()}>删除</Button>
|
||||
<Button type="warning" ghost icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
|
||||
<Button type="danger" ghost icon={<SyncOutlined />} onClick={handleRefreshCache}>刷新缓存</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={typeList}
|
||||
rowKey="dictId"
|
||||
loading={loading}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys),
|
||||
}}
|
||||
pagination={{
|
||||
current: queryParams.pageNum,
|
||||
pageSize: queryParams.pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setQueryParams(prev => ({ ...prev, pageNum: page, pageSize: pageSize }));
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Add/Edit Dictionary Type Modal */}
|
||||
<Modal
|
||||
title={modalTitle}
|
||||
open={modalVisible}
|
||||
onOk={dictForm.submit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={500}
|
||||
forceRender
|
||||
>
|
||||
<Form form={dictForm} labelCol={{ span: 5 }} wrapperCol={{ span: 16 }} onFinish={submitDictForm} initialValues={{ status: '0' }}>
|
||||
<Form.Item name="dictId" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="字典名称" name="dictName" rules={[{ required: true, message: '请输入字典名称' }]}>
|
||||
<Input placeholder="请输入字典名称" />
|
||||
</Form.Item>
|
||||
<Form.Item label="字典类型" name="dictType" rules={[{ required: true, message: '请输入字典类型' }]}>
|
||||
<Input placeholder="请输入字典类型" />
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status">
|
||||
<Radio.Group>
|
||||
{sysNormalDisableDict.map(dict => (
|
||||
<Radio key={dict.value} value={dict.value}>{dict.label}</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item label="备注" name="remark">
|
||||
<Input.TextArea placeholder="请输入内容" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DictPage;
|
||||
|
|
@ -0,0 +1,728 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Radio,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
TreeSelect,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
MinusSquareOutlined,
|
||||
AppstoreOutlined,
|
||||
AreaChartOutlined,
|
||||
AuditOutlined,
|
||||
BarChartOutlined,
|
||||
BookOutlined,
|
||||
CaretDownFilled,
|
||||
CaretRightFilled,
|
||||
PlusOutlined,
|
||||
PlusSquareOutlined,
|
||||
CarryOutOutlined,
|
||||
ClockCircleOutlined,
|
||||
CodeSandboxOutlined,
|
||||
ControlOutlined,
|
||||
DashboardOutlined,
|
||||
DesktopOutlined,
|
||||
EditOutlined as EditMenuIcon,
|
||||
FileTextOutlined,
|
||||
LockOutlined,
|
||||
MonitorOutlined,
|
||||
PieChartOutlined,
|
||||
ProjectOutlined,
|
||||
QuestionCircleOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
TableOutlined,
|
||||
TeamOutlined,
|
||||
ToolOutlined,
|
||||
UnorderedListOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
addMenu,
|
||||
delMenu,
|
||||
getMenu,
|
||||
listMenu,
|
||||
treeselect,
|
||||
updateMenu,
|
||||
} from '../../api/system/menu';
|
||||
import { handleTree, parseTime } from '../../utils/ruoyi';
|
||||
import './menu.css';
|
||||
|
||||
interface MenuRecord {
|
||||
menuId?: number;
|
||||
parentId?: number;
|
||||
menuName?: string;
|
||||
icon?: string;
|
||||
orderNum?: number;
|
||||
perms?: string;
|
||||
component?: string;
|
||||
status?: string;
|
||||
visible?: string;
|
||||
menuType?: string;
|
||||
path?: string;
|
||||
query?: string;
|
||||
isFrame?: string;
|
||||
isCache?: string;
|
||||
createTime?: string;
|
||||
children?: MenuRecord[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface MenuTreeOption {
|
||||
id: number | string;
|
||||
label: string;
|
||||
children?: MenuTreeOption[];
|
||||
}
|
||||
|
||||
const sysNormalDisableDict = [
|
||||
{ value: '0', label: '正常', color: 'success' },
|
||||
{ value: '1', label: '停用', color: 'error' },
|
||||
];
|
||||
|
||||
const sysShowHideDict = [
|
||||
{ value: '0', label: '显示', color: 'processing' },
|
||||
{ value: '1', label: '隐藏', color: 'default' },
|
||||
];
|
||||
|
||||
const menuTypeDict = [
|
||||
{ value: 'M', label: '目录', color: 'blue' },
|
||||
{ value: 'C', label: '菜单', color: 'green' },
|
||||
{ value: 'F', label: '按钮', color: 'gold' },
|
||||
];
|
||||
|
||||
const defaultMenuValues = {
|
||||
parentId: 0,
|
||||
menuType: 'M',
|
||||
isFrame: '1',
|
||||
isCache: '0',
|
||||
visible: '0',
|
||||
status: '0',
|
||||
orderNum: 0,
|
||||
};
|
||||
|
||||
const iconNodeMap: Record<string, React.ReactNode> = {
|
||||
edit: <EditMenuIcon />,
|
||||
chart: <PieChartOutlined />,
|
||||
table: <TableOutlined />,
|
||||
peoples: <TeamOutlined />,
|
||||
dashboard: <UnorderedListOutlined />,
|
||||
tab: <PieChartOutlined />,
|
||||
excel: <ProjectOutlined />,
|
||||
documentation: <ToolOutlined />,
|
||||
build: <CarryOutOutlined />,
|
||||
log: <AuditOutlined />,
|
||||
druid: <BarChartOutlined />,
|
||||
system: <SettingOutlined />,
|
||||
user: <UserOutlined />,
|
||||
tree: <ControlOutlined />,
|
||||
'tree-table': <AppstoreOutlined />,
|
||||
dict: <BookOutlined />,
|
||||
monitor: <MonitorOutlined />,
|
||||
tool: <ToolOutlined />,
|
||||
time: <ClockCircleOutlined />,
|
||||
online: <TeamOutlined />,
|
||||
server: <DesktopOutlined />,
|
||||
cache: <AreaChartOutlined />,
|
||||
form: <FileTextOutlined />,
|
||||
role: <LockOutlined />,
|
||||
};
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
|
||||
|
||||
const extractListData = (response: unknown): MenuRecord[] => {
|
||||
if (Array.isArray(response)) {
|
||||
return response as MenuRecord[];
|
||||
}
|
||||
if (!isObject(response)) {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(response.data)) {
|
||||
return response.data as MenuRecord[];
|
||||
}
|
||||
if (Array.isArray(response.rows)) {
|
||||
return response.rows as MenuRecord[];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const extractTreeData = (response: unknown): MenuTreeOption[] => {
|
||||
if (Array.isArray(response)) {
|
||||
return response as MenuTreeOption[];
|
||||
}
|
||||
if (!isObject(response)) {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(response.menus)) {
|
||||
return response.menus as MenuTreeOption[];
|
||||
}
|
||||
if (Array.isArray(response.data)) {
|
||||
return response.data as MenuTreeOption[];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const extractDetailData = (response: unknown): MenuRecord => {
|
||||
if (isObject(response) && 'data' in response && isObject(response.data)) {
|
||||
return response.data as MenuRecord;
|
||||
}
|
||||
return (isObject(response) ? response : {}) as MenuRecord;
|
||||
};
|
||||
|
||||
const normalizeTreeSelectNodes = (nodes: MenuTreeOption[]): MenuTreeOption[] =>
|
||||
(nodes ?? [])
|
||||
.map((node) => {
|
||||
const id = node?.id;
|
||||
if (id === undefined || id === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
label: String(node.label ?? id),
|
||||
children: normalizeTreeSelectNodes(node.children ?? []),
|
||||
};
|
||||
})
|
||||
.filter((node): node is MenuTreeOption => Boolean(node));
|
||||
|
||||
const collectExpandedKeys = (nodes: MenuRecord[]): React.Key[] =>
|
||||
nodes.flatMap((node) => [
|
||||
node.menuId ?? '',
|
||||
...(Array.isArray(node.children) ? collectExpandedKeys(node.children) : []),
|
||||
]).filter(Boolean);
|
||||
|
||||
const findDict = (dict: Array<{ value: string; label: string; color?: string }>, value?: string) =>
|
||||
dict.find((item) => item.value === String(value ?? ''));
|
||||
|
||||
const renderIconPreview = (icon?: string) => {
|
||||
if (!icon) {
|
||||
return <span className="menu-icon-empty">-</span>;
|
||||
}
|
||||
return (
|
||||
<span className="menu-icon-preview-node">{iconNodeMap[icon] ?? <CodeSandboxOutlined />}</span>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuPage: React.FC = () => {
|
||||
const [queryForm] = Form.useForm();
|
||||
const [menuForm] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [menuList, setMenuList] = useState<MenuRecord[]>([]);
|
||||
const [menuOptions, setMenuOptions] = useState<MenuTreeOption[]>([]);
|
||||
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>([]);
|
||||
const [isExpandAll, setIsExpandAll] = useState(true);
|
||||
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [modalTitle, setModalTitle] = useState('');
|
||||
const [currentMenu, setCurrentMenu] = useState<MenuRecord>({});
|
||||
|
||||
const [queryParams, setQueryParams] = useState({
|
||||
menuName: undefined as string | undefined,
|
||||
status: undefined as string | undefined,
|
||||
});
|
||||
|
||||
const getList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await listMenu(queryParams);
|
||||
const list = handleTree(extractListData(response), 'menuId') as MenuRecord[];
|
||||
setMenuList(list);
|
||||
setExpandedRowKeys(isExpandAll ? collectExpandedKeys(list) : []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch menu list:', error);
|
||||
message.error('获取菜单列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isExpandAll, queryParams]);
|
||||
|
||||
const getTreeselect = useCallback(async () => {
|
||||
try {
|
||||
const response = await treeselect();
|
||||
const rootNode: MenuTreeOption = {
|
||||
id: 0,
|
||||
label: '主类目',
|
||||
children: normalizeTreeSelectNodes(extractTreeData(response)),
|
||||
};
|
||||
setMenuOptions([rootNode]);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch menu tree select:', error);
|
||||
message.error('获取菜单树失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void getList();
|
||||
}, [getList]);
|
||||
|
||||
const menuType = Form.useWatch('menuType', menuForm) ?? defaultMenuValues.menuType;
|
||||
|
||||
const resetMenuForm = () => {
|
||||
setCurrentMenu({});
|
||||
menuForm.resetFields();
|
||||
menuForm.setFieldsValue(defaultMenuValues);
|
||||
};
|
||||
|
||||
const handleQuery = () => {
|
||||
const values = queryForm.getFieldsValue();
|
||||
setQueryParams({
|
||||
menuName: values.menuName,
|
||||
status: values.status,
|
||||
});
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
queryForm.resetFields();
|
||||
setQueryParams({
|
||||
menuName: undefined,
|
||||
status: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAdd = async (row?: MenuRecord) => {
|
||||
resetMenuForm();
|
||||
await getTreeselect();
|
||||
menuForm.setFieldsValue({
|
||||
...defaultMenuValues,
|
||||
parentId: row?.menuId ?? 0,
|
||||
});
|
||||
setModalTitle(row?.menuId ? '新增下级菜单' : '新增菜单');
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleUpdate = async (row: MenuRecord) => {
|
||||
resetMenuForm();
|
||||
await getTreeselect();
|
||||
try {
|
||||
const response = await getMenu(row.menuId as number);
|
||||
const detail = extractDetailData(response);
|
||||
setCurrentMenu(detail);
|
||||
menuForm.setFieldsValue({
|
||||
...defaultMenuValues,
|
||||
...detail,
|
||||
parentId: detail.parentId ?? 0,
|
||||
});
|
||||
setModalTitle('修改菜单');
|
||||
setModalVisible(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch menu detail:', error);
|
||||
message.error('获取菜单详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (row: MenuRecord) => {
|
||||
try {
|
||||
await delMenu(row.menuId as number);
|
||||
message.success('删除成功');
|
||||
void getList();
|
||||
} catch (error) {
|
||||
console.error('Delete menu failed:', error);
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExpandAll = () => {
|
||||
if (isExpandAll) {
|
||||
setExpandedRowKeys([]);
|
||||
setIsExpandAll(false);
|
||||
return;
|
||||
}
|
||||
const keys = collectExpandedKeys(menuList);
|
||||
setExpandedRowKeys(keys);
|
||||
setIsExpandAll(true);
|
||||
};
|
||||
|
||||
const submitMenuForm = async (values: MenuRecord) => {
|
||||
try {
|
||||
const payload = { ...currentMenu, ...values };
|
||||
if (payload.menuId !== undefined) {
|
||||
await updateMenu(payload);
|
||||
message.success('修改成功');
|
||||
} else {
|
||||
await addMenu(payload);
|
||||
message.success('新增成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
resetMenuForm();
|
||||
void getList();
|
||||
} catch (error) {
|
||||
console.error('Submit menu form failed:', error);
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: TableColumnsType<MenuRecord> = useMemo(() => [
|
||||
{
|
||||
title: '菜单名称',
|
||||
dataIndex: 'menuName',
|
||||
key: 'menuName',
|
||||
width: 240,
|
||||
ellipsis: true,
|
||||
render: (value: unknown, record) => (
|
||||
<div className="menu-name-cell">
|
||||
<span className="menu-name-text">{String(value ?? '-')}</span>
|
||||
{record.icon ? <Tag className="menu-icon-tag">{String(record.icon)}</Tag> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '图标',
|
||||
dataIndex: 'icon',
|
||||
key: 'icon',
|
||||
width: 180,
|
||||
render: (value: string) => renderIconPreview(value),
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'orderNum',
|
||||
key: 'orderNum',
|
||||
align: 'center',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '权限标识',
|
||||
dataIndex: 'perms',
|
||||
key: 'perms',
|
||||
ellipsis: true,
|
||||
render: (value: unknown) => String(value ?? '-') || '-',
|
||||
},
|
||||
{
|
||||
title: '组件路径',
|
||||
dataIndex: 'component',
|
||||
key: 'component',
|
||||
ellipsis: true,
|
||||
render: (value: unknown) => String(value ?? '-') || '-',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
align: 'center',
|
||||
width: 90,
|
||||
render: (value: string) => {
|
||||
const dict = findDict(sysNormalDisableDict, value);
|
||||
return <Tag color={dict?.color}>{dict?.label ?? '-'}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
key: 'createTime',
|
||||
align: 'center',
|
||||
width: 180,
|
||||
render: (value: string) => parseTime(value) || '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'operation',
|
||||
align: 'center',
|
||||
width: 220,
|
||||
render: (_value, record) => (
|
||||
<Space size={4} wrap>
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => handleUpdate(record)}>
|
||||
修改
|
||||
</Button>
|
||||
<Button type="link" icon={<PlusOutlined />} onClick={() => handleAdd(record)}>
|
||||
新增
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`确认删除 "${record.menuName}" 吗?`}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
onConfirm={() => void handleDelete(record)}
|
||||
>
|
||||
<Button type="link" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
], [menuList]);
|
||||
|
||||
return (
|
||||
<div className="app-container menu-page-container">
|
||||
<Form
|
||||
form={queryForm}
|
||||
layout="inline"
|
||||
className="search-form"
|
||||
onFinish={handleQuery}
|
||||
initialValues={queryParams}
|
||||
>
|
||||
<Form.Item label="菜单名称" name="menuName">
|
||||
<Input placeholder="请输入菜单名称" allowClear onPressEnter={handleQuery} style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select placeholder="菜单状态" allowClear style={{ width: 220 }}>
|
||||
{sysNormalDisableDict.map((dict) => (
|
||||
<Select.Option key={dict.value} value={dict.value}>
|
||||
{dict.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
搜索
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetQuery}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div className="table-toolbar">
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => void handleAdd()}>
|
||||
新增
|
||||
</Button>
|
||||
<Button
|
||||
icon={isExpandAll ? <MinusSquareOutlined /> : <PlusSquareOutlined />}
|
||||
onClick={toggleExpandAll}
|
||||
>
|
||||
{isExpandAll ? '折叠' : '展开'}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div className="menu-table-wrap">
|
||||
<Table<MenuRecord>
|
||||
columns={columns}
|
||||
dataSource={menuList}
|
||||
rowKey="menuId"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
expandable={{
|
||||
expandedRowKeys,
|
||||
onExpandedRowsChange: (keys) => {
|
||||
setExpandedRowKeys(keys);
|
||||
setIsExpandAll(keys.length > 0);
|
||||
},
|
||||
expandIcon: ({ expanded, onExpand, record }) => {
|
||||
const hasChildren = Array.isArray(record.children) && record.children.length > 0;
|
||||
if (!hasChildren) {
|
||||
return <span className="menu-expand-spacer" />;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="menu-expand-trigger"
|
||||
onClick={(event) => onExpand(record, event)}
|
||||
aria-label={expanded ? '收起' : '展开'}
|
||||
>
|
||||
{expanded ? <CaretDownFilled /> : <CaretRightFilled />}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}}
|
||||
scroll={{ y: 'calc(100vh - 320px)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={modalTitle}
|
||||
open={modalVisible}
|
||||
onOk={() => menuForm.submit()}
|
||||
onCancel={() => {
|
||||
setModalVisible(false);
|
||||
resetMenuForm();
|
||||
}}
|
||||
width={760}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
form={menuForm}
|
||||
layout="vertical"
|
||||
onFinish={submitMenuForm}
|
||||
initialValues={defaultMenuValues}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="上级菜单" name="parentId">
|
||||
<TreeSelect
|
||||
treeData={menuOptions}
|
||||
fieldNames={{ label: 'label', value: 'id', children: 'children' }}
|
||||
showSearch
|
||||
allowClear
|
||||
treeDefaultExpandAll
|
||||
treeNodeFilterProp="label"
|
||||
placeholder="请选择上级菜单"
|
||||
styles={{ popup: { root: { maxHeight: 400, overflow: 'auto' } } }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="菜单类型" name="menuType">
|
||||
<Radio.Group optionType="button" buttonStyle="solid">
|
||||
<Radio value="M">目录</Radio>
|
||||
<Radio value="C">菜单</Radio>
|
||||
<Radio value="F">按钮</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
{menuType !== 'F' && (
|
||||
<Col span={12}>
|
||||
<Form.Item label="菜单图标" name="icon">
|
||||
<Input placeholder="请输入图标名称,例如:user、system、chart" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
<Col span={12}>
|
||||
<Form.Item label="菜单名称" name="menuName" rules={[{ required: true, message: '请输入菜单名称' }]}>
|
||||
<Input placeholder="请输入菜单名称" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="显示排序" name="orderNum" rules={[{ required: true, message: '请输入显示排序' }]}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{menuType !== 'F' && (
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={(
|
||||
<span>
|
||||
是否外链
|
||||
<Tooltip title="选择是外链则路由地址需要以 http(s):// 开头">
|
||||
<QuestionCircleOutlined className="menu-help-icon" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
name="isFrame"
|
||||
>
|
||||
<Radio.Group>
|
||||
<Radio value="0">是</Radio>
|
||||
<Radio value="1">否</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{menuType !== 'F' && (
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="路由地址" name="path" rules={[{ required: true, message: '请输入路由地址' }]}>
|
||||
<Input placeholder="请输入路由地址,例如:menu、user" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{menuType === 'C' && (
|
||||
<Col span={12}>
|
||||
<Form.Item label="组件路径" name="component" rules={[{ required: true, message: '请输入组件路径' }]}>
|
||||
<Input placeholder="请输入组件路径,例如:system/menu/index" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Row gutter={16}>
|
||||
{menuType !== 'M' && (
|
||||
<Col span={12}>
|
||||
<Form.Item label="权限标识" name="perms">
|
||||
<Input placeholder="请输入权限标识,例如:system:menu:list" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
{menuType === 'C' && (
|
||||
<Col span={12}>
|
||||
<Form.Item label="路由参数" name="query">
|
||||
<Input placeholder="请输入路由参数,例如:id=1" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{menuType === 'C' && (
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={(
|
||||
<span>
|
||||
是否缓存
|
||||
<Tooltip title="开启缓存需要组件 name 与路由保持一致">
|
||||
<QuestionCircleOutlined className="menu-help-icon" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
name="isCache"
|
||||
>
|
||||
<Radio.Group>
|
||||
<Radio value="0">缓存</Radio>
|
||||
<Radio value="1">不缓存</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="显示状态" name="visible">
|
||||
<Radio.Group>
|
||||
{sysShowHideDict.map((dict) => (
|
||||
<Radio key={dict.value} value={dict.value}>
|
||||
{dict.label}
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{menuType === 'M' && (
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="显示状态" name="visible">
|
||||
<Radio.Group>
|
||||
{sysShowHideDict.map((dict) => (
|
||||
<Radio key={dict.value} value={dict.value}>
|
||||
{dict.label}
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="菜单状态" name="status">
|
||||
<Radio.Group>
|
||||
{sysNormalDisableDict.map((dict) => (
|
||||
<Radio key={dict.value} value={dict.value}>
|
||||
{dict.label}
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuPage;
|
||||
|
|
@ -0,0 +1,716 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table, Form, Input, Select, Button, Modal, message, Space, Tag, DatePicker, Switch, Dropdown, Menu, InputNumber, Tree, Tooltip, Checkbox, Popconfirm, Row, Col
|
||||
} from 'antd';
|
||||
import type { TableColumnsType, MenuProps, GetProps } from 'antd';
|
||||
import {
|
||||
SearchOutlined, ReloadOutlined, PlusOutlined, EditOutlined, DeleteOutlined, DownloadOutlined,
|
||||
KeyOutlined, CheckCircleOutlined, DownOutlined, ExclamationCircleOutlined, QuestionCircleOutlined, UserOutlined
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
listRole, getRole, addRole, updateRole, dataScope, changeRoleStatus, delRole, deptTreeSelect
|
||||
} from '../../api/system/role';
|
||||
import { treeselect as menuTreeselect, roleMenuTreeselect } from '../../api/system/menu';
|
||||
import { saveAs } from 'file-saver';
|
||||
import dayjs from 'dayjs';
|
||||
import { parseTime } from '../../utils/ruoyi'; // Custom utility
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
// Mock Dictionaries
|
||||
const sysNormalDisableDict = [
|
||||
{ value: '0', label: '正常' },
|
||||
{ value: '1', label: '停用' },
|
||||
];
|
||||
|
||||
const dataScopeOptions = [
|
||||
{ value: '1', label: '全部数据权限' },
|
||||
{ value: '2', label: '自定数据权限' },
|
||||
{ value: '3', label: '本部门数据权限' },
|
||||
{ value: '4', label: '本部门及以下数据权限' },
|
||||
{ value: '5', label: '仅本人数据权限' },
|
||||
];
|
||||
|
||||
const RolePage: React.FC = () => {
|
||||
const [queryForm] = Form.useForm();
|
||||
const [roleForm] = Form.useForm();
|
||||
const [dataScopeForm] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [roleList, setRoleList] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [selectedRows, setSelectedRows] = useState<any[]>([]); // Added to store selected rows
|
||||
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
|
||||
|
||||
const [addEditModalVisible, setAddEditModalVisible] = useState(false);
|
||||
const [addEditModalTitle, setAddEditModalTitle] = useState('');
|
||||
const [formType, setFormType] = useState<'add' | 'edit'>('add');
|
||||
|
||||
const [dataScopeModalVisible, setDataScopeModalVisible] = useState(false);
|
||||
const [dataScopeModalTitle, setDataScopeModalTitle] = useState('');
|
||||
|
||||
const [menuOptions, setMenuOptions] = useState<any[]>([]);
|
||||
const [deptOptions, setDeptOptions] = useState<any[]>([]);
|
||||
|
||||
const [menuExpand, setMenuExpand] = useState(false);
|
||||
const [menuNodeAll, setMenuNodeAll] = useState(false);
|
||||
const [deptExpand, setDeptExpand] = useState(false);
|
||||
const [deptNodeAll, setDeptNodeAll] = useState(false);
|
||||
const [menuExpandedKeys, setMenuExpandedKeys] = useState<React.Key[]>([]);
|
||||
const [deptExpandedKeys, setDeptExpandedKeys] = useState<React.Key[]>([]);
|
||||
const [menuCheckedKeys, setMenuCheckedKeys] = useState<React.Key[]>([]);
|
||||
const [menuHalfCheckedKeys, setMenuHalfCheckedKeys] = useState<React.Key[]>([]);
|
||||
const [deptCheckedKeys, setDeptCheckedKeys] = useState<React.Key[]>([]);
|
||||
const [deptHalfCheckedKeys, setDeptHalfCheckedKeys] = useState<React.Key[]>([]);
|
||||
const [menuCheckStrictly, setMenuCheckStrictly] = useState(true); // For menu tree linkage
|
||||
const [deptCheckStrictly, setDeptCheckStrictly] = useState(true); // For dept tree linkage
|
||||
|
||||
const [currentRole, setCurrentRole] = useState<any>({}); // For storing current role in modals
|
||||
|
||||
const [queryParams, setQueryParams] = useState({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
roleName: undefined,
|
||||
roleKey: undefined,
|
||||
status: undefined,
|
||||
});
|
||||
|
||||
const extractResponseData = (response: any) => {
|
||||
if (response && typeof response === 'object' && 'data' in response) {
|
||||
return response.data ?? {};
|
||||
}
|
||||
return response ?? {};
|
||||
};
|
||||
|
||||
const extractTreeNodes = (response: any, key: 'menus' | 'depts') => {
|
||||
if (!response || typeof response !== 'object') {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(response[key])) {
|
||||
return response[key];
|
||||
}
|
||||
if (Array.isArray(response.data)) {
|
||||
return response.data;
|
||||
}
|
||||
if (response.data && typeof response.data === 'object' && Array.isArray(response.data[key])) {
|
||||
return response.data[key];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const extractCheckedKeys = (response: any): React.Key[] => {
|
||||
if (!response || typeof response !== 'object') {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(response.checkedKeys)) {
|
||||
return response.checkedKeys;
|
||||
}
|
||||
if (response.data && typeof response.data === 'object' && Array.isArray(response.data.checkedKeys)) {
|
||||
return response.data.checkedKeys;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const collectTreeKeys = (nodes: any[]): React.Key[] => {
|
||||
const keys: React.Key[] = [];
|
||||
const visit = (list: any[]) => {
|
||||
list.forEach((node) => {
|
||||
const key = node?.id ?? node?.key;
|
||||
if (key !== undefined) {
|
||||
keys.push(key);
|
||||
}
|
||||
if (Array.isArray(node?.children) && node.children.length > 0) {
|
||||
visit(node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
visit(nodes ?? []);
|
||||
return keys;
|
||||
};
|
||||
|
||||
const getList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const formattedQueryParams = { ...queryParams };
|
||||
if (dateRange && dateRange.length === 2) {
|
||||
formattedQueryParams['beginTime'] = dateRange[0].format('YYYY-MM-DD');
|
||||
formattedQueryParams['endTime'] = dateRange[1].format('YYYY-MM-DD');
|
||||
} else {
|
||||
formattedQueryParams['beginTime'] = undefined;
|
||||
formattedQueryParams['endTime'] = undefined;
|
||||
}
|
||||
const response = await listRole(formattedQueryParams);
|
||||
setRoleList(response.rows);
|
||||
setTotal(response.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch role list:', error);
|
||||
message.error('获取角色列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [queryParams, dateRange]);
|
||||
|
||||
const getMenuTreeselect = useCallback(async (roleId?: string | number) => {
|
||||
try {
|
||||
const response = roleId !== undefined ? await roleMenuTreeselect(roleId) : await menuTreeselect();
|
||||
setMenuOptions(extractTreeNodes(response, 'menus'));
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch menu tree:', error);
|
||||
message.error('获取菜单树失败');
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getDeptTreeselect = useCallback(async (roleId?: string | number) => {
|
||||
try {
|
||||
const response = await deptTreeSelect(roleId);
|
||||
setDeptOptions(extractTreeNodes(response, 'depts'));
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch department tree:', error);
|
||||
message.error('获取部门树失败');
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getList();
|
||||
}, [getList]);
|
||||
|
||||
|
||||
const handleQuery = () => {
|
||||
setQueryParams(prev => ({ ...prev, pageNum: 1 }));
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
queryForm.resetFields();
|
||||
setDateRange(null);
|
||||
setQueryParams({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
roleName: undefined,
|
||||
roleKey: undefined,
|
||||
status: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectionChange = (selectedKeys: React.Key[], selectedRows: any[]) => {
|
||||
setSelectedRowKeys(selectedKeys);
|
||||
setSelectedRows(selectedRows);
|
||||
};
|
||||
|
||||
const handleStatusChange = async (record: any) => {
|
||||
const newStatus = record.status === '0' ? '1' : '0';
|
||||
const text = record.status === '0' ? '停用' : '启用';
|
||||
Modal.confirm({
|
||||
title: '确认操作',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: `确认要"${text}"角色"${record.roleName}"吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await changeRoleStatus(record.roleId, newStatus);
|
||||
message.success(`${text}成功`);
|
||||
getList();
|
||||
} catch (error) {
|
||||
message.error(`${text}失败`);
|
||||
setRoleList(prev => prev.map(role => role.roleId === record.roleId ? { ...role, status: record.status } : role));
|
||||
}
|
||||
},
|
||||
onCancel() {
|
||||
setRoleList(prev => prev.map(role => role.roleId === record.roleId ? { ...role, status: record.status } : role));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const resetRoleForm = () => {
|
||||
roleForm.resetFields();
|
||||
dataScopeForm.resetFields();
|
||||
setMenuOptions([]); // Clear menu options
|
||||
setDeptOptions([]); // Clear dept options
|
||||
setCurrentRole({}); // Clear current role
|
||||
setMenuExpand(false);
|
||||
setMenuNodeAll(false);
|
||||
setDeptExpand(false);
|
||||
setDeptNodeAll(false);
|
||||
setMenuExpandedKeys([]);
|
||||
setDeptExpandedKeys([]);
|
||||
setMenuCheckedKeys([]);
|
||||
setMenuHalfCheckedKeys([]);
|
||||
setDeptCheckedKeys([]);
|
||||
setDeptHalfCheckedKeys([]);
|
||||
setMenuCheckStrictly(true);
|
||||
setDeptCheckStrictly(true);
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
resetRoleForm();
|
||||
await getMenuTreeselect(); // Get full menu tree
|
||||
setAddEditModalTitle('添加角色');
|
||||
setFormType('add');
|
||||
setAddEditModalVisible(true);
|
||||
roleForm.setFieldsValue({
|
||||
roleSort: 0,
|
||||
status: '0',
|
||||
menuCheckStrictly: true,
|
||||
deptCheckStrictly: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdate = async (record?: any) => {
|
||||
resetRoleForm();
|
||||
const roleId = record?.roleId || selectedRowKeys[0];
|
||||
if (roleId === undefined) {
|
||||
message.warning('请选择要修改的角色');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const roleMenuResponse = await getMenuTreeselect(roleId); // Get menu tree with selected keys
|
||||
const roleResponse = await getRole(roleId);
|
||||
const checkedKeys = extractCheckedKeys(roleMenuResponse);
|
||||
|
||||
const roleData = extractResponseData(roleResponse);
|
||||
setCurrentRole(roleData);
|
||||
setMenuCheckStrictly(roleData.menuCheckStrictly === undefined ? true : !!roleData.menuCheckStrictly);
|
||||
setDeptCheckStrictly(roleData.deptCheckStrictly === undefined ? true : !!roleData.deptCheckStrictly);
|
||||
setMenuCheckedKeys(checkedKeys);
|
||||
setMenuHalfCheckedKeys([]);
|
||||
setAddEditModalTitle('修改角色');
|
||||
setFormType('edit');
|
||||
|
||||
roleForm.setFieldsValue({
|
||||
...roleData,
|
||||
status: roleData.status?.toString() ?? '0',
|
||||
});
|
||||
|
||||
setAddEditModalVisible(true);
|
||||
} catch (error) {
|
||||
message.error('获取角色详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (record?: any) => {
|
||||
const roleIds = record?.roleId ? [record.roleId] : selectedRowKeys;
|
||||
if (roleIds.length === 0) {
|
||||
message.warning('请选择要删除的角色');
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: `是否确认删除角色编号为"${roleIds.join(',')}"的数据项?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await delRole(roleIds.join(','));
|
||||
message.success('删除成功');
|
||||
setSelectedRowKeys([]);
|
||||
getList();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
const hide = message.loading('正在导出数据...', 0);
|
||||
try {
|
||||
const response = await listRole({ ...queryParams, pageNum: undefined, pageSize: undefined });
|
||||
const header = ['角色编号', '角色名称', '权限字符', '显示顺序', '状态', '创建时间'];
|
||||
const data = response.rows.map((role: any) => [
|
||||
role.roleId, role.roleName, role.roleKey, role.roleSort,
|
||||
sysNormalDisableDict.find((d) => d.value === String(role.status ?? ''))?.label ?? '',
|
||||
parseTime(role.createTime),
|
||||
]);
|
||||
|
||||
const csvContent = [header, ...data]
|
||||
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
||||
.join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
saveAs(blob, `role_${dayjs().format('YYYYMMDDHHmmss')}.csv`);
|
||||
hide();
|
||||
message.success('导出成功');
|
||||
} catch {
|
||||
hide();
|
||||
message.error('导出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommand: MenuProps['onClick'] = ({ key }, record: any) => {
|
||||
switch (key) {
|
||||
case 'handleDataScope':
|
||||
handleDataScope(record);
|
||||
break;
|
||||
case 'handleAuthUser':
|
||||
handleAuthUser(record);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDataScope = async (record: any) => {
|
||||
resetRoleForm();
|
||||
const roleId = record.roleId;
|
||||
try {
|
||||
const deptTreeResponse = await getDeptTreeselect(roleId); // Get dept tree with selected keys
|
||||
const roleResponse = await getRole(roleId);
|
||||
const roleData = extractResponseData(roleResponse);
|
||||
setCurrentRole(roleData);
|
||||
setDataScopeModalTitle('分配数据权限');
|
||||
setDataScopeModalVisible(true);
|
||||
dataScopeForm.setFieldsValue({
|
||||
...roleData,
|
||||
dataScope: roleData.dataScope?.toString(),
|
||||
});
|
||||
setDeptCheckedKeys(extractCheckedKeys(deptTreeResponse));
|
||||
setDeptHalfCheckedKeys([]);
|
||||
} catch (error) {
|
||||
message.error('获取数据权限信息失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthUser = (record: any) => {
|
||||
message.info(`跳转到角色"${record.roleName}"的用户分配页面`);
|
||||
// navigate(`/system/role-auth/user/${record.roleId}`); // Uncomment when this page is migrated
|
||||
};
|
||||
|
||||
const submitRoleForm = async (values: any) => {
|
||||
try {
|
||||
const menuIds = [...menuCheckedKeys, ...menuHalfCheckedKeys];
|
||||
const roleData = { ...currentRole, ...values, menuIds, menuCheckStrictly };
|
||||
|
||||
if (formType === 'edit') {
|
||||
await updateRole(roleData);
|
||||
message.success('修改成功');
|
||||
} else {
|
||||
await addRole(roleData);
|
||||
message.success('新增成功');
|
||||
}
|
||||
setAddEditModalVisible(false);
|
||||
getList();
|
||||
} catch (error) {
|
||||
console.error('Submit role form failed:', error);
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const submitDataScopeForm = async (values: any) => {
|
||||
try {
|
||||
let deptIds: React.Key[] = [];
|
||||
if (values.dataScope === '2') {
|
||||
deptIds = [...deptCheckedKeys, ...deptHalfCheckedKeys];
|
||||
}
|
||||
const roleData = { ...currentRole, ...values, deptIds, deptCheckStrictly };
|
||||
|
||||
await dataScope(roleData);
|
||||
message.success('分配数据权限成功');
|
||||
setDataScopeModalVisible(false);
|
||||
getList();
|
||||
} catch (error) {
|
||||
message.error('分配数据权限失败');
|
||||
}
|
||||
};
|
||||
|
||||
const dataScopeSelectChange = (value: string) => {
|
||||
dataScopeForm.setFieldValue('dataScope', value);
|
||||
if (value !== '2') {
|
||||
setDeptCheckedKeys([]);
|
||||
setDeptHalfCheckedKeys([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckedTreeExpand = (value: boolean, type: 'menu' | 'dept') => {
|
||||
if (type === 'menu') {
|
||||
setMenuExpand(value);
|
||||
setMenuExpandedKeys(value ? collectTreeKeys(menuOptions) : []);
|
||||
return;
|
||||
}
|
||||
setDeptExpand(value);
|
||||
setDeptExpandedKeys(value ? collectTreeKeys(deptOptions) : []);
|
||||
};
|
||||
|
||||
const handleCheckedTreeNodeAll = (value: boolean, type: 'menu' | 'dept') => {
|
||||
if (type === 'menu') {
|
||||
const keys = value ? collectTreeKeys(menuOptions) : [];
|
||||
setMenuCheckedKeys(keys);
|
||||
setMenuHalfCheckedKeys([]);
|
||||
setMenuNodeAll(value);
|
||||
return;
|
||||
}
|
||||
const keys = value ? collectTreeKeys(deptOptions) : [];
|
||||
setDeptCheckedKeys(keys);
|
||||
setDeptHalfCheckedKeys([]);
|
||||
setDeptNodeAll(value);
|
||||
};
|
||||
|
||||
const handleMenuCheck = (checked: any, info: any) => {
|
||||
if (Array.isArray(checked)) {
|
||||
setMenuCheckedKeys(checked);
|
||||
setMenuHalfCheckedKeys(info?.halfCheckedKeys ?? []);
|
||||
return;
|
||||
}
|
||||
setMenuCheckedKeys(checked?.checked ?? []);
|
||||
setMenuHalfCheckedKeys(checked?.halfChecked ?? []);
|
||||
};
|
||||
|
||||
const handleDeptCheck = (checked: any, info: any) => {
|
||||
if (Array.isArray(checked)) {
|
||||
setDeptCheckedKeys(checked);
|
||||
setDeptHalfCheckedKeys(info?.halfCheckedKeys ?? []);
|
||||
return;
|
||||
}
|
||||
setDeptCheckedKeys(checked?.checked ?? []);
|
||||
setDeptHalfCheckedKeys(checked?.halfChecked ?? []);
|
||||
};
|
||||
|
||||
const handleCheckedTreeConnect = (value: boolean, type: 'menu' | 'dept') => {
|
||||
if (type === 'menu') setMenuCheckStrictly(value);
|
||||
if (type === 'dept') setDeptCheckStrictly(value);
|
||||
};
|
||||
|
||||
const columns: TableColumnsType<any> = [
|
||||
Table.SELECTION_COLUMN,
|
||||
{ title: '角色编号', dataIndex: 'roleId', align: 'center', width: 100 },
|
||||
{ title: '角色名称', dataIndex: 'roleName', align: 'center', ellipsis: true },
|
||||
{ title: '权限字符', dataIndex: 'roleKey', align: 'center', ellipsis: true },
|
||||
{ title: '显示顺序', dataIndex: 'roleSort', align: 'center', width: 80 },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', align: 'center', width: 100,
|
||||
render: (text: string, record: any) => (
|
||||
<Switch
|
||||
checked={text === '0'}
|
||||
checkedChildren="正常"
|
||||
unCheckedChildren="停用"
|
||||
onChange={() => handleStatusChange(record)}
|
||||
disabled={record.roleId === 1} // Disable for admin role
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'createTime', align: 'center', width: 180, render: (text: string) => parseTime(text) },
|
||||
{
|
||||
title: '操作', key: 'operation', align: 'center', width: 220,
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => handleUpdate(record)} disabled={record.roleId === 1}>修改</Button>
|
||||
<Popconfirm
|
||||
title="是否确认删除该角色?"
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText="是"
|
||||
cancelText="否"
|
||||
disabled={record.roleId === 1} // Disable delete for admin role
|
||||
>
|
||||
<Button type="link" danger icon={<DeleteOutlined />} disabled={record.roleId === 1}>删除</Button>
|
||||
</Popconfirm>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'handleDataScope', icon: <CheckCircleOutlined />, label: '数据权限' },
|
||||
{ key: 'handleAuthUser', icon: <UserOutlined />, label: '分配用户' },
|
||||
],
|
||||
onClick: ({ key }) => handleCommand({ key }, record),
|
||||
}}
|
||||
trigger={['click']}
|
||||
disabled={record.roleId === 1} // Disable dropdown for admin role
|
||||
>
|
||||
<Button type="link" icon={<DownOutlined />} disabled={record.roleId === 1}>更多</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<Form form={queryForm} layout="inline" className="search-form" onFinish={handleQuery} initialValues={queryParams}>
|
||||
<Form.Item label="角色名称" name="roleName">
|
||||
<Input placeholder="请输入角色名称" allowClear onPressEnter={handleQuery} style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="权限字符" name="roleKey">
|
||||
<Input placeholder="请输入权限字符" allowClear onPressEnter={handleQuery} style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select placeholder="角色状态" allowClear style={{ width: 240 }}>
|
||||
{sysNormalDisableDict.map(dict => (
|
||||
<Select.Option key={dict.value} value={dict.value}>{dict.label}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="创建时间">
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={(dates) => setDateRange(dates)}
|
||||
format="YYYY-MM-DD"
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">搜索</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetQuery} style={{ marginLeft: 8 }}>重置</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Space className="mb8">
|
||||
<Button type="primary" ghost icon={<PlusOutlined />} onClick={handleAdd}>新增</Button>
|
||||
<Button type="primary" ghost icon={<EditOutlined />} disabled={selectedRowKeys.length !== 1} onClick={() => handleUpdate()}>修改</Button>
|
||||
<Button type="danger" ghost icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0} onClick={() => handleDelete()}>删除</Button>
|
||||
<Button type="warning" ghost icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={roleList}
|
||||
rowKey="roleId"
|
||||
loading={loading}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: handleSelectionChange,
|
||||
}}
|
||||
pagination={{
|
||||
current: queryParams.pageNum,
|
||||
pageSize: queryParams.pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setQueryParams(prev => ({ ...prev, pageNum: page, pageSize: pageSize }));
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Add/Edit Role Modal */}
|
||||
<Modal
|
||||
title={addEditModalTitle}
|
||||
open={addEditModalVisible}
|
||||
onOk={roleForm.submit}
|
||||
onCancel={() => setAddEditModalVisible(false)}
|
||||
width={500}
|
||||
forceRender
|
||||
>
|
||||
<Form form={roleForm} labelCol={{ span: 5 }} wrapperCol={{ span: 16 }} onFinish={submitRoleForm} initialValues={currentRole}>
|
||||
<Form.Item label="角色名称" name="roleName" rules={[{ required: true, message: '请输入角色名称' }]}>
|
||||
<Input placeholder="请输入角色名称" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<Space>
|
||||
<Tooltip title="控制器中定义的权限字符,如:@PreAuthorize(`@ss.hasRole('admin')`)">
|
||||
<QuestionCircleOutlined />
|
||||
</Tooltip>
|
||||
权限字符
|
||||
</Space>
|
||||
}
|
||||
name="roleKey"
|
||||
rules={[{ required: true, message: '请输入权限字符' }]} // Corrected escaping for "
|
||||
>
|
||||
<Input placeholder="请输入权限字符" />
|
||||
</Form.Item>
|
||||
<Form.Item label="角色顺序" name="roleSort" rules={[{ required: true, message: '请输入角色顺序' }]}>
|
||||
<InputNumber min={0} controls={false} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select>
|
||||
{sysNormalDisableDict.map(dict => (
|
||||
<Select.Option key={dict.value} value={dict.value}>{dict.label}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="菜单权限">
|
||||
<Row gutter={[8, 8]}>
|
||||
<Col>
|
||||
<Checkbox checked={menuExpand} onChange={(e) => handleCheckedTreeExpand(e.target.checked, 'menu')}>展开/折叠</Checkbox>
|
||||
</Col>
|
||||
<Col>
|
||||
<Checkbox checked={menuNodeAll} onChange={(e) => handleCheckedTreeNodeAll(e.target.checked, 'menu')}>全选/全不选</Checkbox>
|
||||
</Col>
|
||||
<Col>
|
||||
<Checkbox checked={menuCheckStrictly} onChange={(e) => handleCheckedTreeConnect(e.target.checked, 'menu')}>父子联动</Checkbox>
|
||||
</Col>
|
||||
</Row>
|
||||
<Tree
|
||||
checkable
|
||||
treeData={menuOptions}
|
||||
fieldNames={{ title: 'label', key: 'id' }}
|
||||
checkStrictly={!menuCheckStrictly}
|
||||
checkedKeys={
|
||||
!menuCheckStrictly
|
||||
? { checked: menuCheckedKeys, halfChecked: menuHalfCheckedKeys }
|
||||
: menuCheckedKeys
|
||||
}
|
||||
onCheck={handleMenuCheck}
|
||||
expandedKeys={menuExpandedKeys}
|
||||
onExpand={(expandedKeys) => setMenuExpandedKeys(expandedKeys)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="备注" name="remark">
|
||||
<Input.TextArea placeholder="请输入内容" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Assign Data Scope Modal */}
|
||||
<Modal
|
||||
title={dataScopeModalTitle}
|
||||
open={dataScopeModalVisible}
|
||||
onOk={dataScopeForm.submit}
|
||||
onCancel={() => setDataScopeModalVisible(false)}
|
||||
width={500}
|
||||
forceRender
|
||||
>
|
||||
<Form form={dataScopeForm} labelCol={{ span: 5 }} wrapperCol={{ span: 16 }} onFinish={submitDataScopeForm} initialValues={currentRole}>
|
||||
<Form.Item label="角色名称">
|
||||
<Input value={currentRole?.roleName ?? ''} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item label="权限字符">
|
||||
<Input value={currentRole?.roleKey ?? ''} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item label="权限范围" name="dataScope">
|
||||
<Select onChange={dataScopeSelectChange}>
|
||||
{dataScopeOptions.map(option => (
|
||||
<Select.Option key={option.value} value={option.value}>{option.label}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate={(prevValues, currentValues) => prevValues.dataScope !== currentValues.dataScope}>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue('dataScope') === '2' ? (
|
||||
<Form.Item label="数据权限">
|
||||
<Row gutter={[8, 8]}>
|
||||
<Col>
|
||||
<Checkbox checked={deptExpand} onChange={(e) => handleCheckedTreeExpand(e.target.checked, 'dept')}>展开/折叠</Checkbox>
|
||||
</Col>
|
||||
<Col>
|
||||
<Checkbox checked={deptNodeAll} onChange={(e) => handleCheckedTreeNodeAll(e.target.checked, 'dept')}>全选/全不选</Checkbox>
|
||||
</Col>
|
||||
<Col>
|
||||
<Checkbox checked={deptCheckStrictly} onChange={(e) => handleCheckedTreeConnect(e.target.checked, 'dept')}>父子联动</Checkbox>
|
||||
</Col>
|
||||
</Row>
|
||||
<Tree
|
||||
checkable
|
||||
treeData={deptOptions}
|
||||
fieldNames={{ title: 'label', key: 'id' }}
|
||||
checkStrictly={!deptCheckStrictly}
|
||||
checkedKeys={
|
||||
!deptCheckStrictly
|
||||
? { checked: deptCheckedKeys, halfChecked: deptHalfCheckedKeys }
|
||||
: deptCheckedKeys
|
||||
}
|
||||
onCheck={handleDeptCheck}
|
||||
expandedKeys={deptExpandedKeys}
|
||||
onExpand={(expandedKeys) => setDeptExpandedKeys(expandedKeys)}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RolePage;
|
||||
|
|
@ -0,0 +1,480 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Key } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
Modal,
|
||||
message,
|
||||
Space,
|
||||
Switch,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
KeyOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
listUser,
|
||||
getUser,
|
||||
addUser,
|
||||
updateUser,
|
||||
delUser,
|
||||
resetUserPwd,
|
||||
changeUserStatus,
|
||||
} from '../../api/system/user';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface UserDept {
|
||||
deptName?: string;
|
||||
}
|
||||
|
||||
interface UserRow {
|
||||
userId?: number | string;
|
||||
userName?: string;
|
||||
nickName?: string;
|
||||
phonenumber?: string;
|
||||
email?: string;
|
||||
status?: string;
|
||||
sex?: string;
|
||||
createTime?: string | number | Date;
|
||||
dept?: UserDept;
|
||||
}
|
||||
|
||||
interface UserFormValues {
|
||||
userId?: number | string;
|
||||
userName: string;
|
||||
nickName: string;
|
||||
phonenumber?: string;
|
||||
email?: string;
|
||||
status?: string;
|
||||
sex?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface UserQueryParams {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
userName?: string;
|
||||
phonenumber?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
const defaultQueryParams: UserQueryParams = {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
userName: undefined,
|
||||
phonenumber: undefined,
|
||||
status: undefined,
|
||||
};
|
||||
|
||||
const parseTime = (time?: string | number | Date, pattern = 'YYYY-MM-DD HH:mm:ss'): string => {
|
||||
return time ? dayjs(time).format(pattern) : '';
|
||||
};
|
||||
|
||||
const normalizeRowKeyToId = (value: Key | number | string | undefined): string | number | undefined => {
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const sysNormalDisableDict = [
|
||||
{ value: '0', label: '正常' },
|
||||
{ value: '1', label: '停用' },
|
||||
] as const;
|
||||
|
||||
const getStatusTag = (status?: string) => {
|
||||
if (status === '0') {
|
||||
return <Tag color="green">正常</Tag>;
|
||||
}
|
||||
if (status === '1') {
|
||||
return <Tag color="red">停用</Tag>;
|
||||
}
|
||||
return <Tag>{status ?? ''}</Tag>;
|
||||
};
|
||||
|
||||
const UserPage = () => {
|
||||
const [queryForm] = Form.useForm<UserQueryParams>();
|
||||
const [userForm] = Form.useForm<UserFormValues>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [userList, setUserList] = useState<UserRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [modalTitle, setModalTitle] = useState('');
|
||||
const [queryParams, setQueryParams] = useState<UserQueryParams>(defaultQueryParams);
|
||||
|
||||
const getList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = (await listUser(queryParams)) as { rows?: UserRow[]; total?: number };
|
||||
setUserList(response.rows ?? []);
|
||||
setTotal(Number(response.total ?? 0));
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to fetch user list:', error);
|
||||
message.error('获取用户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [queryParams]);
|
||||
|
||||
useEffect(() => {
|
||||
void getList();
|
||||
}, [getList]);
|
||||
|
||||
const handleQuery = () => {
|
||||
const values = queryForm.getFieldsValue();
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
...values,
|
||||
pageNum: 1,
|
||||
}));
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
queryForm.resetFields();
|
||||
setQueryParams(defaultQueryParams);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
userForm.resetFields();
|
||||
userForm.setFieldsValue({
|
||||
status: '0',
|
||||
sex: '0',
|
||||
});
|
||||
setModalTitle('添加用户');
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleUpdate = async (record?: UserRow) => {
|
||||
const targetUserId = normalizeRowKeyToId(record?.userId ?? selectedRowKeys[0]);
|
||||
if (targetUserId === undefined) {
|
||||
message.warning('请选择要修改的用户');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = (await getUser(targetUserId)) as UserRow | { data?: UserRow };
|
||||
const detail = (typeof response === 'object' && response !== null && 'data' in response
|
||||
? response.data ?? {}
|
||||
: response) as UserRow;
|
||||
|
||||
userForm.setFieldsValue({
|
||||
userId: detail.userId,
|
||||
userName: detail.userName ?? '',
|
||||
nickName: detail.nickName ?? '',
|
||||
phonenumber: detail.phonenumber ?? '',
|
||||
email: detail.email ?? '',
|
||||
status: detail.status ?? '0',
|
||||
sex: detail.sex ?? '0',
|
||||
});
|
||||
setModalTitle('修改用户');
|
||||
setModalVisible(true);
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to load user detail:', error);
|
||||
message.error('获取用户详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (record?: UserRow) => {
|
||||
const ids = record?.userId !== undefined ? [record.userId] : selectedRowKeys;
|
||||
if (ids.length === 0) {
|
||||
message.warning('请选择要删除的用户');
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: `是否确认删除用户编号为"${ids.join(',')}"的数据项?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await delUser(ids.join(','));
|
||||
message.success('删除成功');
|
||||
setSelectedRowKeys([]);
|
||||
void getList();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleStatusChange = (record: UserRow) => {
|
||||
if (record.userId === undefined) {
|
||||
return;
|
||||
}
|
||||
const userId = record.userId;
|
||||
|
||||
const originalStatus = String(record.status ?? '1');
|
||||
const nextStatus = originalStatus === '0' ? '1' : '0';
|
||||
const actionText = nextStatus === '0' ? '启用' : '停用';
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认操作',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: `确认要${actionText}用户"${record.userName ?? ''}"吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await changeUserStatus(userId, nextStatus);
|
||||
message.success(`${actionText}成功`);
|
||||
void getList();
|
||||
} catch {
|
||||
message.error(`${actionText}失败`);
|
||||
setUserList((prev) =>
|
||||
prev.map((item) =>
|
||||
item.userId === userId
|
||||
? {
|
||||
...item,
|
||||
status: originalStatus,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleResetPwd = (record: UserRow) => {
|
||||
if (record.userId === undefined) {
|
||||
message.warning('用户编号缺失,无法重置密码');
|
||||
return;
|
||||
}
|
||||
const userId = record.userId;
|
||||
|
||||
Modal.confirm({
|
||||
title: '重置密码',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: `是否确认重置用户"${record.userName ?? ''}"的密码?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
const defaultPassword = '123456';
|
||||
await resetUserPwd(userId, defaultPassword);
|
||||
message.success(`重置密码成功,新密码是:${defaultPassword}`);
|
||||
} catch {
|
||||
message.error('重置密码失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const submitForm = async () => {
|
||||
try {
|
||||
const values = await userForm.validateFields();
|
||||
const payload: UserFormValues = {
|
||||
...values,
|
||||
};
|
||||
|
||||
if (payload.userId !== undefined) {
|
||||
await updateUser(payload);
|
||||
message.success('修改成功');
|
||||
} else {
|
||||
if (!payload.password) {
|
||||
payload.password = '123456';
|
||||
}
|
||||
await addUser(payload);
|
||||
message.success('新增成功');
|
||||
}
|
||||
|
||||
setModalVisible(false);
|
||||
void getList();
|
||||
} catch (error: unknown) {
|
||||
if (error && typeof error === 'object' && 'errorFields' in error) {
|
||||
return;
|
||||
}
|
||||
message.error('保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: TableColumnsType<UserRow> = [
|
||||
Table.SELECTION_COLUMN,
|
||||
{ title: '用户编号', dataIndex: 'userId', align: 'center', width: 100 },
|
||||
{ title: '用户名称', dataIndex: 'userName', align: 'center', ellipsis: true },
|
||||
{ title: '用户昵称', dataIndex: 'nickName', align: 'center', ellipsis: true },
|
||||
{
|
||||
title: '部门',
|
||||
dataIndex: 'dept',
|
||||
align: 'center',
|
||||
render: (_value, record) => record.dept?.deptName ?? '',
|
||||
},
|
||||
{ title: '手机号码', dataIndex: 'phonenumber', align: 'center', ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
align: 'center',
|
||||
render: (_value, record) => (
|
||||
<Switch
|
||||
checked={String(record.status ?? '') === '0'}
|
||||
checkedChildren="正常"
|
||||
unCheckedChildren="停用"
|
||||
onChange={() => handleStatusChange(record)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
align: 'center',
|
||||
width: 180,
|
||||
render: (text) => parseTime(text as string | number | Date | undefined),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'operation',
|
||||
align: 'center',
|
||||
width: 280,
|
||||
render: (_value, record) => (
|
||||
<Space size="small">
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => void handleUpdate(record)}>
|
||||
修改
|
||||
</Button>
|
||||
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record)}>
|
||||
删除
|
||||
</Button>
|
||||
<Button type="link" icon={<KeyOutlined />} onClick={() => handleResetPwd(record)}>
|
||||
重置密码
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<Form form={queryForm} layout="inline" className="search-form" onFinish={handleQuery} initialValues={queryParams}>
|
||||
<Form.Item label="用户名称" name="userName">
|
||||
<Input placeholder="请输入用户名称" allowClear onPressEnter={handleQuery} />
|
||||
</Form.Item>
|
||||
<Form.Item label="手机号码" name="phonenumber">
|
||||
<Input placeholder="请输入手机号码" allowClear onPressEnter={handleQuery} />
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select placeholder="请选择状态" allowClear style={{ width: 140 }}>
|
||||
{sysNormalDisableDict.map((item) => (
|
||||
<Select.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
搜索
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetQuery} style={{ marginLeft: 8 }}>
|
||||
重置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Space className="mb8">
|
||||
<Button type="primary" ghost icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
新增
|
||||
</Button>
|
||||
<Button type="primary" ghost icon={<EditOutlined />} disabled={selectedRowKeys.length !== 1} onClick={() => void handleUpdate()}>
|
||||
修改
|
||||
</Button>
|
||||
<Button type="primary" danger ghost icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0} onClick={() => handleDelete()}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={userList}
|
||||
rowKey="userId"
|
||||
loading={loading}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys),
|
||||
}}
|
||||
pagination={{
|
||||
current: queryParams.pageNum,
|
||||
pageSize: queryParams.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (count) => `共 ${count} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setQueryParams((prev) => ({ ...prev, pageNum: page, pageSize }));
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={modalTitle}
|
||||
open={modalVisible}
|
||||
onOk={submitForm}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={680}
|
||||
forceRender
|
||||
>
|
||||
<Form form={userForm} labelCol={{ span: 6 }} wrapperCol={{ span: 16 }}>
|
||||
<Form.Item name="userId" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="用户名称" name="userName" rules={[{ required: true, message: '请输入用户名称' }]}>
|
||||
<Input placeholder="请输入用户名称" />
|
||||
</Form.Item>
|
||||
<Form.Item label="用户昵称" name="nickName" rules={[{ required: true, message: '请输入用户昵称' }]}>
|
||||
<Input placeholder="请输入用户昵称" />
|
||||
</Form.Item>
|
||||
<Form.Item label="手机号码" name="phonenumber">
|
||||
<Input placeholder="请输入手机号码" />
|
||||
</Form.Item>
|
||||
<Form.Item label="邮箱" name="email" rules={[{ type: 'email', message: '请输入正确邮箱' }]}>
|
||||
<Input placeholder="请输入邮箱" />
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status" initialValue="0">
|
||||
<Select>
|
||||
{sysNormalDisableDict.map((item) => (
|
||||
<Select.Option key={item.value} value={item.value}>
|
||||
{getStatusTag(item.value).props.children}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="性别" name="sex" initialValue="0">
|
||||
<Select>
|
||||
<Select.Option value="0">男</Select.Option>
|
||||
<Select.Option value="1">女</Select.Option>
|
||||
<Select.Option value="2">未知</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="密码"
|
||||
name="password"
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
const userId = getFieldValue('userId');
|
||||
if (userId !== undefined || value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('新增用户时请输入密码'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="新增用户时必填,修改用户可留空" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserPage;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
.config-page-container .search-form .ant-form-item {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.config-page-container .mb8 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
.dept-page-container .search-form .ant-form-item {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.dept-page-container .mb8 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dept-page-container .ant-table-row-expand-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
.dict-page-container .search-form .ant-form-item {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.dict-page-container .mb8 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dict-page-container .link-type {
|
||||
color: #1890ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dict-page-container .link-type:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
.menu-page-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 4px 2px 8px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(64, 158, 255, 0.06), transparent 22%),
|
||||
linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||
}
|
||||
|
||||
.menu-page-container .search-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 0;
|
||||
padding: 16px 18px 4px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border: 1px solid #e5eaf3;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.menu-page-container .ant-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.menu-page-container .search-form .ant-input,
|
||||
.menu-page-container .search-form .ant-select-selector {
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.table-toolbar .ant-btn {
|
||||
border-radius: 10px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.menu-table-wrap {
|
||||
background: #fff;
|
||||
border: 1px solid #e5eaf3;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.menu-table-wrap .ant-table {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.menu-table-wrap .ant-table-thead > tr > th {
|
||||
background: linear-gradient(180deg, #fbfcfe 0%, #f3f6fb 100%);
|
||||
color: #334155;
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
padding-top: 14px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.menu-table-wrap .ant-table-tbody > tr > td {
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.menu-table-wrap .ant-table-tbody > tr:hover > td {
|
||||
background: #f8fbff !important;
|
||||
}
|
||||
|
||||
.menu-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.menu-name-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #172033;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu-icon-tag {
|
||||
margin-inline-end: 0;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.menu-icon-preview {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.menu-icon-preview-node {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #edf4ff 100%);
|
||||
border: 1px solid #dbe7fb;
|
||||
color: #3b82f6;
|
||||
font-size: 14px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.menu-icon-empty {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.menu-help-icon {
|
||||
margin-left: 6px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.menu-table-wrap .ant-table-row-expand-icon {
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
|
||||
.menu-expand-trigger {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #909399;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.menu-expand-trigger:hover {
|
||||
background: #eef5ff;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.menu-expand-spacer {
|
||||
display: inline-block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.menu-table-wrap .ant-btn-link {
|
||||
padding-inline: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.menu-page-container .ant-modal .ant-form-vertical .ant-form-item-label {
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.menu-page-container .ant-modal .ant-modal-content {
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menu-page-container .ant-modal .ant-modal-header {
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
.menu-page-container .ant-modal .ant-modal-body {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.menu-page-container .ant-modal .ant-radio-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.menu-page-container .ant-modal .ant-input,
|
||||
.menu-page-container .ant-modal .ant-input-number,
|
||||
.menu-page-container .ant-modal .ant-select-selector,
|
||||
.menu-page-container .ant-modal .ant-tree-select {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.menu-page-container .ant-form-inline {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menu-page-container .ant-form-item {
|
||||
display: flex;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.menu-page-container .ant-form-item-control,
|
||||
.menu-page-container .ant-select,
|
||||
.menu-page-container .ant-input-affix-wrapper,
|
||||
.menu-page-container .ant-input {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.menu-page-container .search-form {
|
||||
padding: 14px 14px 2px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
.role-page-container .search-form .ant-form-item {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.role-page-container .mb8 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.role-page-container .ant-tree-checkbox-inner {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.role-page-container .ant-tree-checkbox + span {
|
||||
width: calc(100% - 24px); /* Adjust label width for checkbox in tree */
|
||||
}
|
||||
|
||||
.role-page-container .tree-border {
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
min-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
.user-page-container .search-form .ant-form-item {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.user-page-container .mb8 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.user-page-container .ant-card-head {
|
||||
padding: 0 16px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.user-page-container .ant-card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Adjust Ant Design TreeSelect for deptOptions */
|
||||
.user-page-container .ant-select-tree {
|
||||
max-height: 250px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
|
@ -0,0 +1,502 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
message,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { ReloadOutlined, SearchOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { delTaskModule, getTaskModel } from '@/api/appraisal';
|
||||
import { listUser } from '@/api/system/user';
|
||||
import { parseTime } from '@/utils/ruoyi';
|
||||
import './appraisal-dashboard.css';
|
||||
|
||||
interface BoardRow {
|
||||
_rowKey: string;
|
||||
id?: string | number;
|
||||
templateName?: string;
|
||||
templateType?: string | number;
|
||||
createBy?: string | number;
|
||||
createByName?: string;
|
||||
createTime?: string | number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UserRole {
|
||||
roleName?: string;
|
||||
}
|
||||
|
||||
interface UserRow {
|
||||
_rowKey: string;
|
||||
userId?: string | number;
|
||||
userName?: string;
|
||||
nickName?: string;
|
||||
dept?: { deptName?: string };
|
||||
roles?: UserRole[];
|
||||
}
|
||||
|
||||
interface QueryFormValues {
|
||||
templateName?: string;
|
||||
templateType?: string;
|
||||
createBy?: string | number;
|
||||
createByName?: string;
|
||||
}
|
||||
|
||||
const TEMPLATE_TYPE_OPTIONS = [
|
||||
{ label: '年度考核', value: '0' },
|
||||
{ label: '季度考核', value: '1' },
|
||||
{ label: '月度考核', value: '2' },
|
||||
];
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const toNumber = (value: unknown, fallback = 0) => {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : fallback;
|
||||
};
|
||||
|
||||
const normalizeResponseData = (response: unknown) =>
|
||||
isObject(response) && response.data !== undefined ? response.data : response;
|
||||
|
||||
const extractRowsAndTotal = (response: unknown): { rows: Record<string, unknown>[]; total: number } => {
|
||||
const source = normalizeResponseData(response);
|
||||
if (isObject(source) && Array.isArray(source.rows)) {
|
||||
return {
|
||||
rows: source.rows as Record<string, unknown>[],
|
||||
total: toNumber(source.total, source.rows.length),
|
||||
};
|
||||
}
|
||||
if (Array.isArray(source)) {
|
||||
return {
|
||||
rows: source as Record<string, unknown>[],
|
||||
total: source.length,
|
||||
};
|
||||
}
|
||||
return {
|
||||
rows: [],
|
||||
total: 0,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeUserRows = (response: unknown): { rows: UserRow[]; total: number } => {
|
||||
const source = normalizeResponseData(response);
|
||||
if (isObject(source) && Array.isArray(source.rows)) {
|
||||
const rows = source.rows as Record<string, unknown>[];
|
||||
return {
|
||||
rows: rows.map((row, index) => {
|
||||
const userId = row.userId ?? row.id ?? `user_${index}`;
|
||||
return {
|
||||
...row,
|
||||
_rowKey: String(userId),
|
||||
} as UserRow;
|
||||
}),
|
||||
total: toNumber(source.total, source.rows.length),
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(source)) {
|
||||
return {
|
||||
rows: (source as Record<string, unknown>[]).map((row, index) => ({
|
||||
...row,
|
||||
_rowKey: String(row.userId ?? row.id ?? `user_${index}`),
|
||||
})) as UserRow[],
|
||||
total: source.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
rows: [],
|
||||
total: 0,
|
||||
};
|
||||
};
|
||||
|
||||
const getTemplateTypeLabel = (value: unknown) => {
|
||||
const target = TEMPLATE_TYPE_OPTIONS.find((item) => item.value === String(value ?? ''));
|
||||
return target?.label ?? '-';
|
||||
};
|
||||
|
||||
const getUserDisplayName = (user?: UserRow | null) =>
|
||||
String(user?.nickName ?? user?.userName ?? user?.userId ?? '');
|
||||
|
||||
const AppraisalDashboardPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [queryForm] = Form.useForm<QueryFormValues>();
|
||||
const [userQueryForm] = Form.useForm<{ userName?: string }>();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [boardList, setBoardList] = useState<BoardRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const [queryParams, setQueryParams] = useState({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
templateName: undefined as string | undefined,
|
||||
templateType: undefined as string | undefined,
|
||||
createBy: undefined as string | number | undefined,
|
||||
});
|
||||
|
||||
const [userModalOpen, setUserModalOpen] = useState(false);
|
||||
const [userLoading, setUserLoading] = useState(false);
|
||||
const [userListData, setUserListData] = useState<UserRow[]>([]);
|
||||
const [userTotal, setUserTotal] = useState(0);
|
||||
const [userPageNum, setUserPageNum] = useState(1);
|
||||
const [userPageSize, setUserPageSize] = useState(10);
|
||||
const [userKeyword, setUserKeyword] = useState('');
|
||||
const [selectedCreator, setSelectedCreator] = useState<UserRow | null>(null);
|
||||
|
||||
const selectedCreatorRowKeys = useMemo(
|
||||
() => (selectedCreator ? [selectedCreator._rowKey] : []),
|
||||
[selectedCreator],
|
||||
);
|
||||
|
||||
const fetchBoardList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getTaskModel(queryParams);
|
||||
const { rows, total: nextTotal } = extractRowsAndTotal(response);
|
||||
const normalizedRows = rows.map((row, index) => {
|
||||
const fallback = `${String(row.templateName ?? 'board')}_${String(row.createTime ?? '')}_${index}`;
|
||||
return {
|
||||
...row,
|
||||
_rowKey: String(row.id ?? fallback),
|
||||
} as BoardRow;
|
||||
});
|
||||
setBoardList(normalizedRows);
|
||||
setTotal(nextTotal);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch appraisal dashboard list:', error);
|
||||
message.error('获取考核看板列表失败');
|
||||
setBoardList([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [queryParams]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchBoardList();
|
||||
}, [fetchBoardList]);
|
||||
|
||||
const fetchUserList = useCallback(async () => {
|
||||
if (!userModalOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUserLoading(true);
|
||||
try {
|
||||
const response = await listUser({
|
||||
pageNum: userPageNum,
|
||||
pageSize: userPageSize,
|
||||
userName: userKeyword || undefined,
|
||||
});
|
||||
|
||||
const { rows, total: nextTotal } = normalizeUserRows(response);
|
||||
setUserListData(rows);
|
||||
setUserTotal(nextTotal);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users for dashboard filter:', error);
|
||||
message.error('获取创建人列表失败');
|
||||
setUserListData([]);
|
||||
setUserTotal(0);
|
||||
} finally {
|
||||
setUserLoading(false);
|
||||
}
|
||||
}, [userKeyword, userModalOpen, userPageNum, userPageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchUserList();
|
||||
}, [fetchUserList]);
|
||||
|
||||
const handleQuery = () => {
|
||||
const values = queryForm.getFieldsValue();
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
pageNum: 1,
|
||||
templateName: String(values.templateName ?? '').trim() || undefined,
|
||||
templateType: values.templateType || undefined,
|
||||
createBy: values.createBy || undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
queryForm.resetFields();
|
||||
setSelectedCreator(null);
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
pageNum: 1,
|
||||
templateName: undefined,
|
||||
templateType: undefined,
|
||||
createBy: undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
const openUserSelectDialog = () => {
|
||||
setUserModalOpen(true);
|
||||
setUserPageNum(1);
|
||||
setUserKeyword('');
|
||||
userQueryForm.resetFields();
|
||||
|
||||
const currentCreatorId = queryForm.getFieldValue('createBy');
|
||||
const currentCreatorName = queryForm.getFieldValue('createByName');
|
||||
if (currentCreatorId !== undefined && currentCreatorId !== null && currentCreatorId !== '') {
|
||||
setSelectedCreator({
|
||||
_rowKey: String(currentCreatorId),
|
||||
userId: currentCreatorId,
|
||||
nickName: currentCreatorName,
|
||||
userName: currentCreatorName,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserQuery = () => {
|
||||
const values = userQueryForm.getFieldsValue();
|
||||
setUserKeyword(String(values.userName ?? '').trim());
|
||||
setUserPageNum(1);
|
||||
};
|
||||
|
||||
const handleUserReset = () => {
|
||||
userQueryForm.resetFields();
|
||||
setUserKeyword('');
|
||||
setUserPageNum(1);
|
||||
};
|
||||
|
||||
const handleUserSelectConfirm = () => {
|
||||
queryForm.setFieldsValue({
|
||||
createBy: selectedCreator?.userId,
|
||||
createByName: getUserDisplayName(selectedCreator),
|
||||
});
|
||||
setUserModalOpen(false);
|
||||
};
|
||||
|
||||
const handleDeleteBoard = async (row: BoardRow) => {
|
||||
const id = row.id;
|
||||
if (id === undefined || id === null) {
|
||||
message.warning('看板ID缺失,无法删除');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await delTaskModule(id);
|
||||
message.success('删除成功');
|
||||
void fetchBoardList();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete appraisal module:', error);
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetail = (row: BoardRow) => {
|
||||
const id = row.id;
|
||||
if (id === undefined || id === null) {
|
||||
message.warning('看板ID缺失,无法查看详情');
|
||||
return;
|
||||
}
|
||||
|
||||
const search = new URLSearchParams({
|
||||
id: String(id),
|
||||
moduleName: String(row.templateName ?? ''),
|
||||
moduleType: String(row.templateType ?? ''),
|
||||
});
|
||||
|
||||
navigate(`/workAppraisal/moduleDetail?${search.toString()}`);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<BoardRow> = [
|
||||
{
|
||||
title: '看板名称',
|
||||
dataIndex: 'templateName',
|
||||
key: 'templateName',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '考核类型',
|
||||
dataIndex: 'templateType',
|
||||
key: 'templateType',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
render: (value: unknown) => getTemplateTypeLabel(value),
|
||||
},
|
||||
{
|
||||
title: '创建人',
|
||||
dataIndex: 'createByName',
|
||||
key: 'createByName',
|
||||
width: 160,
|
||||
align: 'center',
|
||||
render: (value: unknown) => String(value ?? '-'),
|
||||
},
|
||||
{
|
||||
title: '创建日期',
|
||||
dataIndex: 'createTime',
|
||||
key: 'createTime',
|
||||
width: 130,
|
||||
align: 'center',
|
||||
render: (value: unknown) => parseTime(value as string | number | Date, 'YYYY-MM-DD') || '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'operation',
|
||||
width: 180,
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<Space size={4} className="dashboard-ops">
|
||||
<Button type="link" onClick={() => handleViewDetail(record)}>
|
||||
看板详情
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="是否确认删除该条看板"
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
onConfirm={() => handleDeleteBoard(record)}
|
||||
>
|
||||
<Button type="link" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-container appraisal-dashboard-page">
|
||||
<div className="dashboard-card">
|
||||
<Form form={queryForm} layout="inline" className="dashboard-search" onFinish={handleQuery}>
|
||||
<Form.Item label="看板名称" name="templateName">
|
||||
<Input placeholder="看板名称" allowClear />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="创建人" name="createByName">
|
||||
<Input
|
||||
placeholder="创建人"
|
||||
readOnly
|
||||
className="dashboard-user-picker"
|
||||
onClick={openUserSelectDialog}
|
||||
suffix={<UserOutlined onClick={openUserSelectDialog} />}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="createBy" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="考核类型" name="templateType">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="考核类型"
|
||||
options={TEMPLATE_TYPE_OPTIONS}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
|
||||
查询
|
||||
</Button>
|
||||
<Button style={{ marginLeft: 8 }} icon={<ReloadOutlined />} onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table<BoardRow>
|
||||
rowKey="_rowKey"
|
||||
columns={columns}
|
||||
dataSource={boardList}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: queryParams.pageNum,
|
||||
pageSize: queryParams.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (page, pageSize) => {
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
pageNum: page,
|
||||
pageSize,
|
||||
}));
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="选择创建人"
|
||||
open={userModalOpen}
|
||||
onOk={handleUserSelectConfirm}
|
||||
onCancel={() => setUserModalOpen(false)}
|
||||
width={860}
|
||||
destroyOnHidden
|
||||
forceRender
|
||||
>
|
||||
<Form form={userQueryForm} layout="inline" onFinish={handleUserQuery} style={{ marginBottom: 12 }}>
|
||||
<Form.Item label="姓名" name="userName">
|
||||
<Input placeholder="请输入姓名" allowClear style={{ width: 220 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
|
||||
查询
|
||||
</Button>
|
||||
<Button style={{ marginLeft: 8 }} icon={<ReloadOutlined />} onClick={handleUserReset}>
|
||||
重置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table<UserRow>
|
||||
rowKey="_rowKey"
|
||||
size="small"
|
||||
loading={userLoading}
|
||||
dataSource={userListData}
|
||||
rowSelection={{
|
||||
type: 'radio',
|
||||
selectedRowKeys: selectedCreatorRowKeys,
|
||||
onChange: (_keys, selectedRows) => {
|
||||
setSelectedCreator(selectedRows[0] ?? null);
|
||||
},
|
||||
}}
|
||||
pagination={{
|
||||
current: userPageNum,
|
||||
pageSize: userPageSize,
|
||||
total: userTotal,
|
||||
showSizeChanger: true,
|
||||
onChange: (page, pageSize) => {
|
||||
setUserPageNum(page);
|
||||
setUserPageSize(pageSize);
|
||||
},
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'nickName',
|
||||
width: 160,
|
||||
render: (_value, row) => getUserDisplayName(row) || '-',
|
||||
},
|
||||
{
|
||||
title: '部门',
|
||||
dataIndex: ['dept', 'deptName'],
|
||||
width: 180,
|
||||
render: (value) => String(value ?? '-'),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'roles',
|
||||
render: (value: unknown) =>
|
||||
Array.isArray(value) && value.length
|
||||
? value.map((item) => String((item as UserRole).roleName ?? '')).join(',')
|
||||
: '-',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppraisalDashboardPage;
|
||||
|
|
@ -0,0 +1,650 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Empty,
|
||||
Input,
|
||||
message,
|
||||
Modal,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { UserOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { getTaskScoreDetail, saveTaskUserScore } from '@/api/appraisal';
|
||||
import PageBackButton from '@/components/PageBackButton';
|
||||
import './appraisal-detail.css';
|
||||
|
||||
interface DetailItem {
|
||||
_rowKey?: string;
|
||||
id?: string | number;
|
||||
reviewCategory?: string;
|
||||
reviewItem?: string;
|
||||
remarks?: string;
|
||||
score?: number;
|
||||
weight?: number;
|
||||
remark?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface GroupRow {
|
||||
category: string;
|
||||
items: DetailItem[];
|
||||
remarkCate: string;
|
||||
}
|
||||
|
||||
interface ExamineTask {
|
||||
taskName?: string;
|
||||
templateId?: string | number;
|
||||
templateType?: string | number;
|
||||
}
|
||||
|
||||
interface ExamineUser {
|
||||
id?: string | number;
|
||||
userName?: string;
|
||||
manageScore?: string | number;
|
||||
judgeContent?: string;
|
||||
selfJudgeContent?: string;
|
||||
examineStatus?: string | number;
|
||||
examineStatusSelf?: string | number;
|
||||
}
|
||||
|
||||
interface RemarkRow {
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const toNumber = (value: unknown, fallback = 0) => {
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : fallback;
|
||||
};
|
||||
|
||||
const clampScore = (value: number) => Math.min(10, Math.max(0, Math.round(value)));
|
||||
|
||||
const toBoolean = (value: unknown) => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value === 1;
|
||||
}
|
||||
const raw = String(value ?? '').toLowerCase().trim();
|
||||
return raw === '1' || raw === 'true' || raw === 'yes';
|
||||
};
|
||||
|
||||
const normalizePayload = (response: unknown) =>
|
||||
isObject(response) && response.data !== undefined ? response.data : response;
|
||||
|
||||
const groupDetailRows = (items: DetailItem[], remarks: RemarkRow[] = []) => {
|
||||
const map = new Map<string, DetailItem[]>();
|
||||
items.forEach((item) => {
|
||||
const key = String(item.reviewCategory ?? '未分类');
|
||||
const list = map.get(key) ?? [];
|
||||
const fallbackKey = `${key}_${list.length}`;
|
||||
list.push({
|
||||
...item,
|
||||
_rowKey: String(item.id ?? fallbackKey),
|
||||
score: toNumber(item.score, 0),
|
||||
weight: toNumber(item.weight, 0),
|
||||
remark: String(item.remark ?? ''),
|
||||
});
|
||||
map.set(key, list);
|
||||
});
|
||||
|
||||
const groups: GroupRow[] = [];
|
||||
Array.from(map.entries()).forEach(([category, list], index) => {
|
||||
groups.push({
|
||||
category,
|
||||
items: list,
|
||||
remarkCate: String(remarks[index]?.remark ?? ''),
|
||||
});
|
||||
});
|
||||
return groups;
|
||||
};
|
||||
|
||||
interface ScoreBarProps {
|
||||
value: number;
|
||||
editable: boolean;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
const ScoreBar = ({ value, editable, onChange }: ScoreBarProps) => {
|
||||
const normalized = clampScore(value);
|
||||
|
||||
const updateByPercent = (percent: number) => {
|
||||
onChange(clampScore(percent * 10));
|
||||
};
|
||||
|
||||
const handleTrackClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
if (rect.width <= 0) {
|
||||
return;
|
||||
}
|
||||
const percent = (event.clientX - rect.left) / rect.width;
|
||||
updateByPercent(percent);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
onChange(clampScore(normalized - 1));
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
onChange(clampScore(normalized + 1));
|
||||
}
|
||||
};
|
||||
|
||||
const bubblePosition = normalized * 10;
|
||||
const bubbleClass =
|
||||
normalized === 0 ? 'detail-score-bubble is-start' : normalized === 10 ? 'detail-score-bubble is-end' : 'detail-score-bubble';
|
||||
|
||||
return (
|
||||
<div className="detail-score-wrap">
|
||||
<div className="detail-score-top">
|
||||
<span>0</span>
|
||||
</div>
|
||||
<div
|
||||
className={`detail-score-track ${editable ? 'is-editable' : 'is-readonly'}`}
|
||||
onClick={handleTrackClick}
|
||||
role={editable ? 'slider' : undefined}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={10}
|
||||
aria-valuenow={normalized}
|
||||
tabIndex={editable ? 0 : -1}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="detail-score-fill" style={{ width: `${bubblePosition}%` }} />
|
||||
<div className={bubbleClass} style={{ left: `${bubblePosition}%` }}>
|
||||
{normalized}
|
||||
</div>
|
||||
</div>
|
||||
{normalized === 0 && <div className="statusText">暂未打分</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AppraisalDetailPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const isNormal = toBoolean(searchParams.get('isNormal'));
|
||||
const [isEdit, setIsEdit] = useState(toBoolean(searchParams.get('edit')));
|
||||
const examineTaskId = searchParams.get('examineTaskId') ?? searchParams.get('taskId') ?? '';
|
||||
const reviewType = searchParams.get('reviewType') ?? (isNormal ? '1' : '0');
|
||||
const routeExamineId = searchParams.get('examineId') ?? '';
|
||||
const routeUserId = searchParams.get('userId') ?? '';
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [examineTask, setExamineTask] = useState<ExamineTask>({});
|
||||
const [examineUser, setExamineUser] = useState<ExamineUser>({});
|
||||
const [groups, setGroups] = useState<GroupRow[]>([]);
|
||||
const [manageScore, setManageScore] = useState<number>(0);
|
||||
const [judgeContent, setJudgeContent] = useState('');
|
||||
|
||||
const [remarkModalOpen, setRemarkModalOpen] = useState(false);
|
||||
const [editingCell, setEditingCell] = useState<{ groupIndex: number; rowIndex: number } | null>(null);
|
||||
const [remarkDraft, setRemarkDraft] = useState('');
|
||||
|
||||
const templateType = useMemo(() => String(examineTask.templateType ?? ''), [examineTask.templateType]);
|
||||
const isTemplateZero = templateType === '0';
|
||||
|
||||
const recalcManageScore = useCallback((nextGroups: GroupRow[]) => {
|
||||
const score = nextGroups
|
||||
.flatMap((group) => group.items)
|
||||
.reduce((sum, row) => sum + toNumber(row.score) * toNumber(row.weight), 0) / 10;
|
||||
setManageScore(Number(score.toFixed(2)));
|
||||
}, []);
|
||||
|
||||
const loadDetail = useCallback(async () => {
|
||||
if (!examineTaskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
examineTaskId,
|
||||
reviewType,
|
||||
};
|
||||
if (routeExamineId) {
|
||||
params.examineId = routeExamineId;
|
||||
}
|
||||
if (routeUserId) {
|
||||
params.userId = routeUserId;
|
||||
}
|
||||
|
||||
const response = await getTaskScoreDetail(params);
|
||||
const payload = normalizePayload(response);
|
||||
|
||||
if (!isObject(payload)) {
|
||||
message.error('评分详情返回格式异常');
|
||||
setGroups([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const detailRows = Array.isArray(payload.examineConfigDetailVoList)
|
||||
? (payload.examineConfigDetailVoList as DetailItem[])
|
||||
: [];
|
||||
const remarks = Array.isArray(payload.remark) ? (payload.remark as RemarkRow[]) : [];
|
||||
const nextGroups = groupDetailRows(detailRows, remarks);
|
||||
setGroups(nextGroups);
|
||||
|
||||
const task = isObject(payload.examineTask) ? (payload.examineTask as ExamineTask) : {};
|
||||
const user = isObject(payload.examineUser) ? (payload.examineUser as ExamineUser) : {};
|
||||
setExamineTask(task);
|
||||
setExamineUser(user);
|
||||
|
||||
if (isNormal && String(task.templateType ?? '') === '0') {
|
||||
setJudgeContent(String(user.selfJudgeContent ?? user.judgeContent ?? ''));
|
||||
} else {
|
||||
setJudgeContent(String(user.judgeContent ?? ''));
|
||||
}
|
||||
|
||||
const currentManageScore = toNumber(user.manageScore);
|
||||
if (currentManageScore > 0) {
|
||||
setManageScore(currentManageScore);
|
||||
} else if (!isNormal) {
|
||||
recalcManageScore(nextGroups);
|
||||
}
|
||||
|
||||
if (String(user.examineStatusSelf ?? '') === '1' && String(user.examineStatus ?? '') === '1') {
|
||||
setIsEdit(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch score detail:', error);
|
||||
message.error('获取评分详情失败');
|
||||
setGroups([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [examineTaskId, isNormal, recalcManageScore, reviewType, routeExamineId, routeUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDetail();
|
||||
}, [loadDetail]);
|
||||
|
||||
const updateItemScore = (groupIndex: number, rowIndex: number, value: number) => {
|
||||
setGroups((prev) => {
|
||||
const next = prev.map((group, gIndex) => {
|
||||
if (gIndex !== groupIndex) {
|
||||
return group;
|
||||
}
|
||||
return {
|
||||
...group,
|
||||
items: group.items.map((row, rIndex) => (rIndex === rowIndex ? { ...row, score: value } : row)),
|
||||
};
|
||||
});
|
||||
if (!isNormal) {
|
||||
recalcManageScore(next);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateGroupRemark = (groupIndex: number, value: string) => {
|
||||
setGroups((prev) =>
|
||||
prev.map((group, idx) =>
|
||||
idx === groupIndex
|
||||
? {
|
||||
...group,
|
||||
remarkCate: value,
|
||||
}
|
||||
: group,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const openRemarkModal = (groupIndex: number, rowIndex: number) => {
|
||||
const row = groups[groupIndex]?.items[rowIndex];
|
||||
setEditingCell({ groupIndex, rowIndex });
|
||||
setRemarkDraft(String(row?.remark ?? ''));
|
||||
setRemarkModalOpen(true);
|
||||
};
|
||||
|
||||
const saveRemarkModal = () => {
|
||||
if (!editingCell) {
|
||||
setRemarkModalOpen(false);
|
||||
return;
|
||||
}
|
||||
if (remarkDraft.length > 200) {
|
||||
message.warning('自评总结限制200个字符');
|
||||
return;
|
||||
}
|
||||
setGroups((prev) =>
|
||||
prev.map((group, gIndex) => {
|
||||
if (gIndex !== editingCell.groupIndex) {
|
||||
return group;
|
||||
}
|
||||
return {
|
||||
...group,
|
||||
items: group.items.map((row, rIndex) =>
|
||||
rIndex === editingCell.rowIndex
|
||||
? {
|
||||
...row,
|
||||
remark: remarkDraft,
|
||||
}
|
||||
: row,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
setRemarkModalOpen(false);
|
||||
};
|
||||
|
||||
const buildPayload = (submitStatus: 0 | 1) => {
|
||||
const allItems = groups.flatMap((group) => group.items);
|
||||
const detailList = allItems.map((item) => ({
|
||||
score: toNumber(item.score, 0),
|
||||
configId: item.id,
|
||||
remark: String(item.remark ?? ''),
|
||||
reviewCategory: item.reviewCategory,
|
||||
}));
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
examineId: examineUser.id ?? routeExamineId ?? '',
|
||||
taskId: examineTaskId,
|
||||
reviewType,
|
||||
examineDetailList: detailList,
|
||||
examineRemarkList: [],
|
||||
manageScore: manageScore,
|
||||
judgeContent,
|
||||
};
|
||||
|
||||
if (isNormal) {
|
||||
payload.examineStatusSelf = submitStatus;
|
||||
} else {
|
||||
payload.examineStatus = submitStatus;
|
||||
}
|
||||
|
||||
if (isNormal && !isTemplateZero) {
|
||||
payload.examineRemarkList = groups
|
||||
.filter((group) => group.category !== '发展与协作')
|
||||
.map((group) => ({
|
||||
reviewCategory: group.category,
|
||||
remark: group.remarkCate,
|
||||
}));
|
||||
}
|
||||
|
||||
if (isNormal && isTemplateZero) {
|
||||
payload.selfJudgeContent = judgeContent;
|
||||
payload.judgeContent = '';
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
const validateSubmit = (submitStatus: 0 | 1) => {
|
||||
if (submitStatus === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const allItems = groups.flatMap((group) => group.items);
|
||||
const hasUnscored = allItems.some(
|
||||
(item) => !toNumber(item.score, 0) && (!isNormal || item.reviewCategory !== '发展与协作'),
|
||||
);
|
||||
if (hasUnscored) {
|
||||
message.warning('存在未评分绩效项,请完善后再试');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isNormal) {
|
||||
const devRows = allItems.filter((item) => item.reviewCategory === '发展与协作');
|
||||
const hasEmptyDevRemark = devRows.some((item) => !String(item.remark ?? '').trim());
|
||||
if (hasEmptyDevRemark) {
|
||||
message.warning('发展与协作下的自评总结为必填,请完善后再试');
|
||||
return false;
|
||||
}
|
||||
const hasShortDevRemark = devRows.some(
|
||||
(item) => String(item.remark ?? '').trim().length > 0 && String(item.remark ?? '').trim().length < 100,
|
||||
);
|
||||
if (hasShortDevRemark) {
|
||||
message.warning('发展与协作下的自评总结最少100个字符,请完善后再试');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isNormal && isTemplateZero && judgeContent.length > 300) {
|
||||
message.warning('总体评价限制300个字符');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isNormal && !judgeContent.trim()) {
|
||||
message.warning('总体评价为必填');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isNormal && isTemplateZero && !judgeContent.trim()) {
|
||||
message.warning('个人总体评价为必填');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isNormal && !isTemplateZero) {
|
||||
const cateRemarks = groups.filter((group) => group.category !== '发展与协作').map((group) => group.remarkCate);
|
||||
if (cateRemarks.some((remark) => !String(remark).trim())) {
|
||||
message.warning('存在未填写大类评价,请完善后再试');
|
||||
return false;
|
||||
}
|
||||
if (cateRemarks.some((remark) => String(remark).trim().length < 100)) {
|
||||
message.warning('大类评价最少100个字符,请完善后再试');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const submitScore = async (submitStatus: 0 | 1) => {
|
||||
if (!validateSubmit(submitStatus)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const doSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload = buildPayload(submitStatus);
|
||||
await saveTaskUserScore(payload);
|
||||
message.success('操作成功');
|
||||
navigate(-1);
|
||||
} catch (error) {
|
||||
console.error('Failed to save score:', error);
|
||||
message.error('保存失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (submitStatus === 1) {
|
||||
Modal.confirm({
|
||||
title: '确认提交绩效评分',
|
||||
content: '提交后将无法修改,该操作不可逆,请确认后再试',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
onOk: doSubmit,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await doSubmit();
|
||||
};
|
||||
|
||||
const buildColumns = (group: GroupRow, groupIndex: number): ColumnsType<DetailItem> => {
|
||||
const cols: ColumnsType<DetailItem> = [
|
||||
{
|
||||
title: '考核项',
|
||||
dataIndex: 'reviewItem',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: '评分标准',
|
||||
dataIndex: 'remarks',
|
||||
},
|
||||
];
|
||||
|
||||
if (!isNormal && examineTask.templateId && group.category === '发展与协作' && !isTemplateZero) {
|
||||
cols.push({
|
||||
title: '员工自评',
|
||||
dataIndex: 'remark',
|
||||
render: (_, row) => <Input.TextArea value={String(row.remark ?? '')} autoSize={{ minRows: 3 }} readOnly />,
|
||||
});
|
||||
}
|
||||
|
||||
if ((isNormal && group.category !== '发展与协作') || !isNormal) {
|
||||
cols.push({
|
||||
title: '评分',
|
||||
dataIndex: 'score',
|
||||
width: 380,
|
||||
render: (_, row, rowIndex) => (
|
||||
<ScoreBar
|
||||
value={toNumber(row.score, 0)}
|
||||
editable={isEdit}
|
||||
onChange={(nextScore) => updateItemScore(groupIndex, rowIndex, nextScore)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (isNormal && ((examineTask.templateId && group.category === '发展与协作') || !examineTask.templateId)) {
|
||||
cols.push({
|
||||
title: '自评总结',
|
||||
dataIndex: 'remark',
|
||||
width: 140,
|
||||
render: (_, row, rowIndex) => (
|
||||
<Button type="link" onClick={() => openRemarkModal(groupIndex, rowIndex)}>
|
||||
{String(row.remark ?? '').trim() ? '查看' : '暂未评价'}
|
||||
</Button>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return cols;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-container appraisal-detail-page">
|
||||
{!examineTaskId && <Alert type="warning" showIcon message="缺少 examineTaskId 参数,无法加载评分详情" />}
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<div className="conetentBox">
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PageBackButton
|
||||
fallbackPath={isNormal ? '/workAppraisal/normalWorker' : '/workAppraisal/manager'}
|
||||
/>
|
||||
</div>
|
||||
<div className="titleBox">
|
||||
<div className="titleMain">
|
||||
<span className="block" />
|
||||
<span>{examineTask.taskName ?? '绩效考核详情'}</span>
|
||||
</div>
|
||||
{isEdit && (
|
||||
<Space size={20}>
|
||||
<Button style={{ width: 90 }} onClick={() => submitScore(0)} loading={submitting}>
|
||||
保存
|
||||
</Button>
|
||||
<Button style={{ width: 90 }} type="primary" onClick={() => submitScore(1)} loading={submitting}>
|
||||
提交
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="headerBox">
|
||||
<div className="userInfo">
|
||||
<UserOutlined />
|
||||
<span>{examineUser.userName ?? '-'}</span>
|
||||
</div>
|
||||
{!isNormal && (
|
||||
<div className="totalBox">
|
||||
<span>考核评分:</span>
|
||||
<span className="scoreTotal">{manageScore}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="tableBox">
|
||||
{groups.length === 0 ? (
|
||||
<Empty description="暂无评分项" />
|
||||
) : (
|
||||
groups.map((group, groupIndex) => (
|
||||
<div className="tableRow detail-group" key={`${group.category}_${groupIndex}`}>
|
||||
<div className="userBox detail-group-title">{group.category}</div>
|
||||
<Table<DetailItem>
|
||||
rowKey="_rowKey"
|
||||
columns={buildColumns(group, groupIndex)}
|
||||
dataSource={group.items}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
/>
|
||||
|
||||
{isNormal && !isTemplateZero && group.category !== '发展与协作' && (
|
||||
<div className="detail-group-remark">
|
||||
<div className="detail-subtitle">评价</div>
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 4 }}
|
||||
maxLength={300}
|
||||
showCount
|
||||
value={group.remarkCate}
|
||||
onChange={(event) => updateGroupRemark(groupIndex, event.target.value)}
|
||||
readOnly={!isEdit}
|
||||
placeholder="0/300"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{((isNormal && isTemplateZero) || !isNormal) && (
|
||||
<div className="detail-overall">
|
||||
<div className="userBox detail-group-title">总体评价</div>
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 4 }}
|
||||
maxLength={300}
|
||||
showCount
|
||||
value={judgeContent}
|
||||
onChange={(event) => setJudgeContent(event.target.value)}
|
||||
readOnly={!isEdit}
|
||||
placeholder="0/300"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
<Modal
|
||||
title="自评总结"
|
||||
open={remarkModalOpen}
|
||||
onOk={isEdit ? saveRemarkModal : () => setRemarkModalOpen(false)}
|
||||
onCancel={() => setRemarkModalOpen(false)}
|
||||
okButtonProps={{ style: { display: isEdit ? 'inline-flex' : 'none' } }}
|
||||
okText="确定"
|
||||
cancelText={isEdit ? '取消' : '关闭'}
|
||||
>
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 4 }}
|
||||
maxLength={200}
|
||||
showCount
|
||||
value={remarkDraft}
|
||||
onChange={(event) => setRemarkDraft(event.target.value)}
|
||||
readOnly={!isEdit}
|
||||
placeholder="0/200"
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppraisalDetailPage;
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, Row, Col, message, Tabs, Tag, Empty } from 'antd';
|
||||
import { getTaskListSelf } from '../../api/appraisal';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import './appraisal.css'; // Assuming a shared CSS for appraisal pages
|
||||
|
||||
const { TabPane } = Tabs;
|
||||
|
||||
const AppraisalManagerPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState('进行中');
|
||||
const [taskList, setTaskList] = useState<{ [key: string]: any[] }>({});
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getTasks = useCallback(async () => {
|
||||
try {
|
||||
const response = await getTaskListSelf();
|
||||
setTaskList(response.data || {});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch task list:', error);
|
||||
message.error('获取考核任务列表失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getTasks();
|
||||
}, [getTasks]);
|
||||
|
||||
const viewDetails = (task: any, isEdit: number) => {
|
||||
if (!task.taskEditFlag) {
|
||||
message.warning('分数正在计算中,请等待计算完成');
|
||||
return;
|
||||
}
|
||||
navigate(`/workAppraisal/managerUser?taskId=${task.id}&isEdit=${isEdit}`);
|
||||
};
|
||||
|
||||
const renderTaskCards = (tasks: any[], isEdit: number) => {
|
||||
if (!tasks || tasks.length === 0) {
|
||||
return <Empty description="暂无数据" />;
|
||||
}
|
||||
return (
|
||||
<Row gutter={[16, 24]}>
|
||||
{tasks.map(task => (
|
||||
<Col key={task.id} xs={24} sm={12} md={8} lg={6}>
|
||||
<Card className="task-card">
|
||||
<div className="card-header">
|
||||
<img src="/src/assets/task/titleIcon.png" alt="icon" style={{ width: 32, height: 34 }} />
|
||||
<div className="task-name">{task.taskName}</div>
|
||||
<Tag color={isEdit ? 'warning' : 'default'}>{isEdit ? '进行中' : '已过期'}</Tag>
|
||||
</div>
|
||||
<div className="task-deadline">截止时间:{task.endTime.split(" ")[0]}</div>
|
||||
<div className="card-footer">
|
||||
<div className="people-count">考核人数:{task.peopleNumber}</div>
|
||||
<div className={`action-button ${!task.taskEditFlag ? 'disabled' : ''}`} onClick={() => viewDetails(task, isEdit)}>
|
||||
{isEdit ? '考核评分' : '查看详情'}
|
||||
<img src="/src/assets/task/right.png" alt=">" style={{ width: 16, height: 16, marginLeft: 8 }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-container appraisal-manager-page">
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||||
<TabPane tab="进行中" key="进行中">
|
||||
<div className="assessment-container">
|
||||
{renderTaskCards(taskList['0'] || [], 1)}
|
||||
</div>
|
||||
</TabPane>
|
||||
<TabPane tab="已过期" key="已过期">
|
||||
<div className="assessment-container">
|
||||
{renderTaskCards(taskList['2'] || [], 0)}
|
||||
</div>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppraisalManagerPage;
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Form, Input, Select, Button, message, Space, Tag } from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { getTaskUserList } from '../../api/appraisal';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
const AppraisalManagerUserPage: React.FC = () => {
|
||||
const [queryForm] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [userList, setUserList] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const queryParamsFromUrl = new URLSearchParams(location.search);
|
||||
const taskId = queryParamsFromUrl.get('taskId');
|
||||
const isEdit = queryParamsFromUrl.get('isEdit');
|
||||
|
||||
const [queryParams, setQueryParams] = useState({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
userName: undefined,
|
||||
examineStatus: undefined,
|
||||
taskId: taskId,
|
||||
isAsc: '',
|
||||
sortFiled: 'manageScore',
|
||||
});
|
||||
|
||||
const statusList = [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "待评分", value: "0" },
|
||||
{ label: "已完成", value: "1" },
|
||||
];
|
||||
|
||||
const getList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getTaskUserList(queryParams);
|
||||
setUserList(response.rows);
|
||||
setTotal(response.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch task user list:', error);
|
||||
message.error('获取考核用户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [queryParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (taskId) {
|
||||
getList();
|
||||
}
|
||||
}, [getList, taskId]);
|
||||
|
||||
const handleQuery = () => {
|
||||
setQueryParams(prev => ({ ...prev, pageNum: 1 }));
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
queryForm.resetFields();
|
||||
setQueryParams(prev => ({
|
||||
...prev,
|
||||
pageNum: 1,
|
||||
userName: undefined,
|
||||
examineStatus: undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEdit = (row: any, edit: number) => {
|
||||
navigate(`/workAppraisal/detail?examineTaskId=${taskId}&examineId=${row.id}&reviewType=0&edit=${edit}`);
|
||||
};
|
||||
|
||||
const handleTableChange = (pagination: any, filters: any, sorter: any) => {
|
||||
if (sorter.field === 'manageScore') {
|
||||
setQueryParams(prev => ({
|
||||
...prev,
|
||||
isAsc: sorter.order === 'ascend' ? 'asc' : sorter.order === 'descend' ? 'desc' : '',
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const columns: TableColumnsType<any> = [
|
||||
{ title: '考核人员', dataIndex: 'userName', key: 'userName' },
|
||||
{ title: '考核评分', dataIndex: 'manageScore', key: 'manageScore', sorter: true },
|
||||
{
|
||||
title: '状态', dataIndex: 'examineStatus', key: 'examineStatus',
|
||||
render: (value) => <Tag color={value === '0' ? '#EA741D' : '#999'}>{value === '0' ? '待评分' : '已完成'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'operation',
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
{isEdit === '1' && record.examineStatus === '0' ? (
|
||||
<Button type="link" onClick={() => handleEdit(record, 1)}>去评分</Button>
|
||||
) : null}
|
||||
{record.examineStatus === '1' || isEdit === '0' ? (
|
||||
<Button type="link" onClick={() => handleEdit(record, 0)}>查看详情</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<Form form={queryForm} layout="inline" onFinish={handleQuery}>
|
||||
<Form.Item label="考核人员" name="userName">
|
||||
<Input placeholder="考核人员" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="examineStatus">
|
||||
<Select placeholder="状态" allowClear style={{ width: 120 }}>
|
||||
{statusList.map(item => <Select.Option key={item.value} value={item.value}>{item.label}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">查询</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetQuery} style={{ marginLeft: 8 }}>重置</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={userList}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: queryParams.pageNum,
|
||||
pageSize: queryParams.pageSize,
|
||||
total: total,
|
||||
onChange: (page, pageSize) => setQueryParams(prev => ({ ...prev, pageNum: page, pageSize: pageSize })),
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppraisalManagerUserPage;
|
||||
|
|
@ -0,0 +1,355 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Collapse, Empty, Form, Input, Select, Spin, message } from 'antd';
|
||||
import type { CollapseProps } from 'antd';
|
||||
import { getTaskModelSet } from '@/api/appraisal';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import PageBackButton from '@/components/PageBackButton';
|
||||
import './appraisal-module-detail.css';
|
||||
|
||||
interface ScoreConfigItem {
|
||||
_itemKey: string;
|
||||
id?: string | number;
|
||||
reviewType?: string | number;
|
||||
reviewCategory?: string;
|
||||
reviewItem?: string;
|
||||
remarks?: string;
|
||||
weight?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ScoreCategory {
|
||||
key: string;
|
||||
title: string;
|
||||
type: string;
|
||||
rightArr: ScoreConfigItem[];
|
||||
weight: number;
|
||||
}
|
||||
|
||||
interface ScoreGroup {
|
||||
type: string;
|
||||
title: string;
|
||||
list: ScoreCategory[];
|
||||
weight: number;
|
||||
}
|
||||
|
||||
const REVIEW_GROUP_META = [
|
||||
{ type: '0', title: '组长评估绩效指标' },
|
||||
{ type: '1', title: '个人自评绩效指标' },
|
||||
{ type: '2', title: '系统核算绩效指标' },
|
||||
];
|
||||
|
||||
const TEMPLATE_TYPE_OPTIONS = [
|
||||
{ label: '年度考核', value: '0' },
|
||||
{ label: '季度考核', value: '1' },
|
||||
{ label: '月度考核', value: '2' },
|
||||
];
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const toNumber = (value: unknown, fallback = 0) => {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : fallback;
|
||||
};
|
||||
|
||||
const clampWeight = (value: unknown) => Math.min(20, Math.max(0, Math.round(toNumber(value, 0))));
|
||||
|
||||
const normalizeResponseData = (response: unknown) =>
|
||||
isObject(response) && response.data !== undefined ? response.data : response;
|
||||
|
||||
const normalizeScoreList = (response: unknown): ScoreConfigItem[] => {
|
||||
const source = normalizeResponseData(response);
|
||||
if (!Array.isArray(source)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return source
|
||||
.filter((item) => isObject(item))
|
||||
.map((item, index) => ({
|
||||
...item,
|
||||
_itemKey: String(item.id ?? `cfg_${index}`),
|
||||
weight: clampWeight(item.weight),
|
||||
})) as ScoreConfigItem[];
|
||||
};
|
||||
|
||||
const buildScoreGroups = (items: ScoreConfigItem[]): ScoreGroup[] => {
|
||||
const typeMap = new Map<string, ScoreConfigItem[]>();
|
||||
|
||||
items.forEach((item) => {
|
||||
const type = String(item.reviewType ?? '');
|
||||
const current = typeMap.get(type) ?? [];
|
||||
current.push(item);
|
||||
typeMap.set(type, current);
|
||||
});
|
||||
|
||||
return REVIEW_GROUP_META.map(({ type, title }) => {
|
||||
const categoryMap = new Map<string, ScoreConfigItem[]>();
|
||||
|
||||
(typeMap.get(type) ?? []).forEach((item) => {
|
||||
const categoryTitle = String(item.reviewCategory ?? '未分类');
|
||||
const current = categoryMap.get(categoryTitle) ?? [];
|
||||
current.push(item);
|
||||
categoryMap.set(categoryTitle, current);
|
||||
});
|
||||
|
||||
const list: ScoreCategory[] = Array.from(categoryMap.entries()).map(([categoryTitle, rightArr]) => ({
|
||||
key: `${type}_${categoryTitle}`,
|
||||
title: categoryTitle,
|
||||
type,
|
||||
rightArr,
|
||||
weight: rightArr.reduce((sum, cfg) => sum + clampWeight(cfg.weight), 0),
|
||||
}));
|
||||
|
||||
return {
|
||||
type,
|
||||
title,
|
||||
list,
|
||||
weight: list.reduce((sum, category) => sum + category.weight, 0),
|
||||
};
|
||||
}).filter((group) => group.list.length > 0);
|
||||
};
|
||||
|
||||
interface WeightBarProps {
|
||||
value: number;
|
||||
}
|
||||
|
||||
const WeightBar = ({ value }: WeightBarProps) => {
|
||||
const normalized = clampWeight(value);
|
||||
const leftPercent = normalized * 5;
|
||||
const bubbleClass =
|
||||
normalized === 0
|
||||
? 'module-score-text is-start'
|
||||
: normalized === 20
|
||||
? 'module-score-text is-end'
|
||||
: 'module-score-text';
|
||||
|
||||
return (
|
||||
<div className="module-score-wrap">
|
||||
<div className="module-score-scale">
|
||||
<span>0</span>
|
||||
{normalized !== 20 && <span>20</span>}
|
||||
</div>
|
||||
<div className="module-score-track">
|
||||
<div className="module-score-fill" style={{ width: `${leftPercent}%` }} />
|
||||
{normalized > 0 && (
|
||||
<div className={bubbleClass} style={{ left: `${leftPercent}%` }}>
|
||||
{normalized}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AppraisalModuleDetailPage = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const moduleId = searchParams.get('id') ?? '';
|
||||
|
||||
const [moduleName, setModuleName] = useState('');
|
||||
const [moduleType, setModuleType] = useState<string>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [scoreList, setScoreList] = useState<ScoreGroup[]>([]);
|
||||
const [activeGroupTitle, setActiveGroupTitle] = useState('');
|
||||
const [selectedCategoryKey, setSelectedCategoryKey] = useState('');
|
||||
const [viewMode, setViewMode] = useState<'group' | 'category'>('group');
|
||||
|
||||
useEffect(() => {
|
||||
setModuleName(searchParams.get('moduleName') ?? '');
|
||||
const currentType = String(searchParams.get('moduleType') ?? '').trim();
|
||||
setModuleType(currentType || undefined);
|
||||
}, [searchParams]);
|
||||
|
||||
const loadDetail = useCallback(async () => {
|
||||
if (!moduleId) {
|
||||
setScoreList([]);
|
||||
setActiveGroupTitle('');
|
||||
setSelectedCategoryKey('');
|
||||
setViewMode('group');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getTaskModelSet(moduleId);
|
||||
const groups = buildScoreGroups(normalizeScoreList(response));
|
||||
setScoreList(groups);
|
||||
|
||||
if (groups.length > 0) {
|
||||
setActiveGroupTitle(groups[0].title);
|
||||
setSelectedCategoryKey(groups[0].list[0]?.key ?? '');
|
||||
setViewMode('group');
|
||||
} else {
|
||||
setActiveGroupTitle('');
|
||||
setSelectedCategoryKey('');
|
||||
setViewMode('group');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch appraisal module detail:', error);
|
||||
message.error('获取考核看板详情失败');
|
||||
setScoreList([]);
|
||||
setActiveGroupTitle('');
|
||||
setSelectedCategoryKey('');
|
||||
setViewMode('group');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [moduleId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDetail();
|
||||
}, [loadDetail]);
|
||||
|
||||
const totalWeight = useMemo(
|
||||
() => scoreList.reduce((sum, group) => sum + toNumber(group.weight, 0), 0),
|
||||
[scoreList],
|
||||
);
|
||||
|
||||
const activeGroup = useMemo(() => {
|
||||
if (scoreList.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return scoreList.find((group) => group.title === activeGroupTitle) ?? scoreList[0];
|
||||
}, [activeGroupTitle, scoreList]);
|
||||
|
||||
const selectedCategory = useMemo(() => {
|
||||
if (!activeGroup) {
|
||||
return undefined;
|
||||
}
|
||||
return activeGroup.list.find((category) => category.key === selectedCategoryKey) ?? activeGroup.list[0];
|
||||
}, [activeGroup, selectedCategoryKey]);
|
||||
|
||||
const rightCategoryList = useMemo(() => {
|
||||
if (!activeGroup) {
|
||||
return [] as ScoreCategory[];
|
||||
}
|
||||
if (viewMode === 'group') {
|
||||
return activeGroup.list;
|
||||
}
|
||||
return selectedCategory ? [selectedCategory] : [];
|
||||
}, [activeGroup, selectedCategory, viewMode]);
|
||||
|
||||
const handleCollapseChange: CollapseProps['onChange'] = (key) => {
|
||||
const keyText = Array.isArray(key) ? String(key[0] ?? '') : String(key ?? '');
|
||||
const nextTitle = keyText || scoreList[0]?.title || '';
|
||||
setActiveGroupTitle(nextTitle);
|
||||
setViewMode('group');
|
||||
|
||||
const group = scoreList.find((item) => item.title === nextTitle);
|
||||
if (!group || group.list.length === 0) {
|
||||
setSelectedCategoryKey('');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedCategoryKey((previous) =>
|
||||
group.list.some((category) => category.key === previous) ? previous : group.list[0].key,
|
||||
);
|
||||
};
|
||||
|
||||
const collapseItems: CollapseProps['items'] = scoreList.map((group) => ({
|
||||
key: group.title,
|
||||
label: (
|
||||
<div className="module-content-title">
|
||||
<span className="module-set-title">{group.title}</span>
|
||||
<span className="module-status-text">{group.weight}%</span>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
{group.list.map((category) => {
|
||||
const selected = activeGroup?.title === group.title && selectedCategoryKey === category.key;
|
||||
return (
|
||||
<button
|
||||
key={category.key}
|
||||
type="button"
|
||||
className={`module-left-sub ${selected ? 'is-selected' : ''}`}
|
||||
onClick={() => {
|
||||
setActiveGroupTitle(group.title);
|
||||
setSelectedCategoryKey(category.key);
|
||||
setViewMode('category');
|
||||
}}
|
||||
>
|
||||
<span className="module-left-sub-title">{category.title}</span>
|
||||
<span className="module-status-text">{category.weight}%</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="app-container appraisal-module-detail-page">
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PageBackButton fallbackPath="/workAppraisal/taskModule" />
|
||||
</div>
|
||||
|
||||
<div className="module-detail-header">
|
||||
<Form layout="inline">
|
||||
<Form.Item label="看板名称">
|
||||
<Input value={moduleName} readOnly style={{ width: 300 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="看板类型">
|
||||
<Select
|
||||
value={moduleType}
|
||||
disabled
|
||||
style={{ width: 200 }}
|
||||
placeholder="看板类型"
|
||||
options={TEMPLATE_TYPE_OPTIONS}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{!moduleId && <Alert type="warning" showIcon message="缺少看板ID参数,无法加载看板详情" style={{ marginBottom: 12 }} />}
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<div className="module-detail-layout">
|
||||
<div className="module-detail-left">
|
||||
<div className="module-set-text">累计权重</div>
|
||||
<Collapse
|
||||
accordion
|
||||
activeKey={activeGroup?.title}
|
||||
onChange={handleCollapseChange}
|
||||
items={collapseItems}
|
||||
className="module-collapse"
|
||||
/>
|
||||
<div className="module-total-box">
|
||||
<span className="module-set-title">总计</span>
|
||||
<span className="module-status-text">{totalWeight}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="module-detail-right">
|
||||
{rightCategoryList.length === 0 ? (
|
||||
<Empty description="暂无指标配置" />
|
||||
) : (
|
||||
rightCategoryList.map((category) => (
|
||||
<div key={category.key} className="module-category-box">
|
||||
<div className="module-category-title">{category.title}</div>
|
||||
|
||||
<div className="module-set-header">
|
||||
<div className="module-header-item is-name">考核项</div>
|
||||
<div className="module-header-item is-remark">评分标准</div>
|
||||
<div className="module-header-item is-weight">权重占比</div>
|
||||
</div>
|
||||
|
||||
{category.rightArr.map((item) => (
|
||||
<div key={item._itemKey} className="module-content-row">
|
||||
<div className="module-row-item is-name">{String(item.reviewItem ?? '-')}</div>
|
||||
<div className="module-row-item is-remark">{String(item.remarks ?? '-')}</div>
|
||||
<div className="module-row-item is-weight">
|
||||
<WeightBar value={toNumber(item.weight, 0)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppraisalModuleDetailPage;
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Empty, message, Spin, Tabs, Tag } from 'antd';
|
||||
import { CarryOutOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getTaskListSelf } from '@/api/appraisal';
|
||||
import './manager.css';
|
||||
|
||||
type TaskStatusKey = '0' | '2';
|
||||
|
||||
interface TaskItem {
|
||||
id?: string | number;
|
||||
taskName?: string;
|
||||
endTime?: string | number;
|
||||
peopleNumber?: number | string;
|
||||
taskStatus?: string | number;
|
||||
taskEditFlag?: boolean | string | number;
|
||||
}
|
||||
|
||||
type TaskBuckets = Record<TaskStatusKey, TaskItem[]>;
|
||||
|
||||
const EMPTY_BUCKETS: TaskBuckets = {
|
||||
'0': [],
|
||||
'2': [],
|
||||
};
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const asArray = (value: unknown): TaskItem[] => (Array.isArray(value) ? (value as TaskItem[]) : []);
|
||||
|
||||
const normalizeStatus = (value: unknown): TaskStatusKey => {
|
||||
const raw = String(value ?? '').trim();
|
||||
return raw === '2' ? '2' : '0';
|
||||
};
|
||||
|
||||
const splitBucketsByStatus = (list: TaskItem[]): TaskBuckets => {
|
||||
const next: TaskBuckets = {
|
||||
'0': [],
|
||||
'2': [],
|
||||
};
|
||||
list.forEach((item) => {
|
||||
next[normalizeStatus(item.taskStatus)].push(item);
|
||||
});
|
||||
return next;
|
||||
};
|
||||
|
||||
const normalizeTaskBuckets = (response: unknown): TaskBuckets => {
|
||||
const source = isObject(response) && response.data !== undefined ? response.data : response;
|
||||
|
||||
if (isObject(source)) {
|
||||
const ongoing = asArray(source['0']);
|
||||
const expired = asArray(source['2']);
|
||||
if ('0' in source || '2' in source) {
|
||||
return { '0': ongoing, '2': expired };
|
||||
}
|
||||
|
||||
const rows = asArray(source.rows);
|
||||
if (rows.length) {
|
||||
return splitBucketsByStatus(rows);
|
||||
}
|
||||
|
||||
const list = asArray(source.list);
|
||||
if (list.length) {
|
||||
return splitBucketsByStatus(list);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(source)) {
|
||||
return splitBucketsByStatus(source as TaskItem[]);
|
||||
}
|
||||
|
||||
return EMPTY_BUCKETS;
|
||||
};
|
||||
|
||||
const formatEndDate = (value: unknown) => {
|
||||
if (typeof value === 'string' && value.includes(' ')) {
|
||||
return value.split(' ')[0];
|
||||
}
|
||||
const parsed = dayjs(value as dayjs.ConfigType);
|
||||
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : '-';
|
||||
};
|
||||
|
||||
const isTaskEditable = (value: unknown) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return true;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value === 1;
|
||||
}
|
||||
const raw = String(value).trim().toLowerCase();
|
||||
return raw === '1' || raw === 'true' || raw === 'yes';
|
||||
};
|
||||
|
||||
const ManagerPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<TaskStatusKey>('0');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [taskBuckets, setTaskBuckets] = useState<TaskBuckets>(EMPTY_BUCKETS);
|
||||
|
||||
const fetchTaskList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getTaskListSelf();
|
||||
setTaskBuckets(normalizeTaskBuckets(response));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch appraisal tasks:', error);
|
||||
message.error('获取考核任务失败');
|
||||
setTaskBuckets(EMPTY_BUCKETS);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchTaskList();
|
||||
}, [fetchTaskList]);
|
||||
|
||||
const tabMap = useMemo(
|
||||
() => ({
|
||||
'0': { label: '进行中', actionText: '考核评分', isEdit: '1', tagColor: 'warning' },
|
||||
'2': { label: '已过期', actionText: '查看详情', isEdit: '0', tagColor: 'default' },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleView = (task: TaskItem, status: TaskStatusKey) => {
|
||||
if (!isTaskEditable(task.taskEditFlag)) {
|
||||
message.warning('分数正在计算中,请等待计算完成');
|
||||
return;
|
||||
}
|
||||
|
||||
const taskId = task.id;
|
||||
if (taskId === undefined || taskId === null) {
|
||||
message.warning('任务ID缺失,无法打开详情');
|
||||
return;
|
||||
}
|
||||
|
||||
const search = new URLSearchParams({
|
||||
taskId: String(taskId),
|
||||
isEdit: tabMap[status].isEdit,
|
||||
});
|
||||
navigate(`/workAppraisal/managerUser?${search.toString()}`);
|
||||
};
|
||||
|
||||
const renderTaskList = (status: TaskStatusKey) => {
|
||||
const list = taskBuckets[status] ?? [];
|
||||
if (!list.length) {
|
||||
return <Empty description="暂无数据" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="appraisal-manager-grid">
|
||||
{list.map((task, index) => {
|
||||
const editable = isTaskEditable(task.taskEditFlag);
|
||||
return (
|
||||
<div className="appraisal-task-card" key={`${status}_${task.id ?? index}`}>
|
||||
<div className="appraisal-task-card-head">
|
||||
<div className="appraisal-task-icon">
|
||||
<CarryOutOutlined />
|
||||
</div>
|
||||
<Tag color={tabMap[status].tagColor}>{tabMap[status].label}</Tag>
|
||||
</div>
|
||||
<div className="appraisal-task-name" title={task.taskName ?? '-'}>
|
||||
{task.taskName ?? '-'}
|
||||
</div>
|
||||
<div className="appraisal-task-meta">截止时间:{formatEndDate(task.endTime)}</div>
|
||||
<div className="appraisal-task-foot">
|
||||
<span className="appraisal-task-people">考核人数:{task.peopleNumber ?? 0}</span>
|
||||
<Button
|
||||
type="link"
|
||||
className={`appraisal-task-action ${editable ? '' : 'is-disabled'}`}
|
||||
onClick={() => handleView(task, status)}
|
||||
>
|
||||
{tabMap[status].actionText}
|
||||
<RightOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="appraisal-manager-page">
|
||||
<Spin spinning={loading}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key as TaskStatusKey)}
|
||||
items={[
|
||||
{
|
||||
key: '0',
|
||||
label: '进行中',
|
||||
children: renderTaskList('0'),
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: '已过期',
|
||||
children: renderTaskList('2'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManagerPage;
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
message,
|
||||
Space,
|
||||
Table,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { getTaskUserList } from '@/api/appraisal';
|
||||
|
||||
interface UserScoreRow {
|
||||
_rowKey?: string;
|
||||
id?: string | number;
|
||||
userId?: string | number;
|
||||
userName?: string;
|
||||
nickName?: string;
|
||||
name?: string;
|
||||
deptName?: string;
|
||||
departmentName?: string;
|
||||
postName?: string;
|
||||
post?: string;
|
||||
examineStatus?: string | number;
|
||||
score?: number | string;
|
||||
totalScore?: number | string;
|
||||
sumScore?: number | string;
|
||||
status?: string | number;
|
||||
dept?: {
|
||||
deptName?: string;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const extractRowsAndTotal = (response: unknown): { rows: UserScoreRow[]; total: number } => {
|
||||
if (isObject(response)) {
|
||||
if (Array.isArray(response.rows)) {
|
||||
return { rows: response.rows as UserScoreRow[], total: Number(response.total ?? response.rows.length) };
|
||||
}
|
||||
if (isObject(response.data) && Array.isArray(response.data.rows)) {
|
||||
const rows = response.data.rows as UserScoreRow[];
|
||||
return { rows, total: Number(response.data.total ?? rows.length) };
|
||||
}
|
||||
if (Array.isArray(response.list)) {
|
||||
return { rows: response.list as UserScoreRow[], total: Number(response.total ?? response.list.length) };
|
||||
}
|
||||
}
|
||||
if (Array.isArray(response)) {
|
||||
return { rows: response as UserScoreRow[], total: response.length };
|
||||
}
|
||||
return { rows: [], total: 0 };
|
||||
};
|
||||
|
||||
const getUserName = (row: UserScoreRow) => row.userName ?? row.nickName ?? row.name ?? '-';
|
||||
|
||||
const getDeptName = (row: UserScoreRow) => row.deptName ?? row.departmentName ?? row.dept?.deptName ?? '-';
|
||||
|
||||
const getScoreValue = (row: UserScoreRow) => row.score ?? row.totalScore ?? row.sumScore ?? '-';
|
||||
|
||||
const attachRowKey = (rows: UserScoreRow[], taskId: string, pageNum: number) =>
|
||||
rows.map((row, index) => ({
|
||||
...row,
|
||||
_rowKey: String(row.userId ?? row.id ?? `${taskId}_${pageNum}_${index}`),
|
||||
}));
|
||||
|
||||
const ManagerUserPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const taskId = searchParams.get('taskId');
|
||||
const isEdit = searchParams.get('isEdit') === '1';
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [rows, setRows] = useState<UserScoreRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [pageNum, setPageNum] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
if (!taskId) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getTaskUserList({ taskId, pageNum, pageSize });
|
||||
const { rows: nextRows, total: nextTotal } = extractRowsAndTotal(response);
|
||||
setRows(attachRowKey(nextRows, taskId, pageNum));
|
||||
setTotal(nextTotal);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch task users:', error);
|
||||
message.error('获取考核人员列表失败');
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pageNum, pageSize, taskId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadList();
|
||||
}, [loadList]);
|
||||
|
||||
const navigateToDetail = (record: UserScoreRow, edit: 0 | 1) => {
|
||||
if (!taskId) {
|
||||
message.warning('缺少 taskId 参数,无法打开详情');
|
||||
return;
|
||||
}
|
||||
const examineId = record.id ?? record.userId;
|
||||
if (examineId === undefined || examineId === null) {
|
||||
message.warning('缺少 examineId,无法打开详情');
|
||||
return;
|
||||
}
|
||||
|
||||
const search = new URLSearchParams({
|
||||
examineTaskId: String(taskId),
|
||||
examineId: String(examineId),
|
||||
reviewType: '0',
|
||||
edit: String(edit),
|
||||
});
|
||||
navigate(`/workAppraisal/detail?${search.toString()}`);
|
||||
};
|
||||
|
||||
const columns: TableColumnsType<UserScoreRow> = [
|
||||
{
|
||||
title: '考核对象',
|
||||
key: 'name',
|
||||
render: (_, record) => getUserName(record),
|
||||
},
|
||||
{
|
||||
title: '部门',
|
||||
key: 'dept',
|
||||
render: (_, record) => getDeptName(record),
|
||||
},
|
||||
{
|
||||
title: '岗位',
|
||||
key: 'post',
|
||||
render: (_, record) => record.postName ?? record.post ?? '-',
|
||||
},
|
||||
{
|
||||
title: '得分',
|
||||
key: 'score',
|
||||
render: (_, record) => getScoreValue(record),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 160,
|
||||
render: (_, record) => (
|
||||
<Space size={4}>
|
||||
{isEdit && String(record.examineStatus ?? '') === '0' && (
|
||||
<Button type="link" onClick={() => navigateToDetail(record, 1)}>
|
||||
去评分
|
||||
</Button>
|
||||
)}
|
||||
{(String(record.examineStatus ?? '') === '1' || !isEdit) && (
|
||||
<Button type="link" onClick={() => navigateToDetail(record, 0)}>
|
||||
查看详情
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/workAppraisal/manager')}>
|
||||
返回
|
||||
</Button>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
{isEdit ? '考核评分' : '考核详情'}
|
||||
</Typography.Title>
|
||||
</Space>
|
||||
|
||||
{!taskId && <Alert type="warning" showIcon message="缺少 taskId 参数,无法加载考核人员列表" />}
|
||||
|
||||
<Card>
|
||||
<Table<UserScoreRow>
|
||||
rowKey="_rowKey"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
pagination={{
|
||||
current: pageNum,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (value) => `共 ${value} 条`,
|
||||
onChange: (page, size) => {
|
||||
setPageNum(page);
|
||||
setPageSize(size);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManagerUserPage;
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Empty, message, Spin, Tabs, Tag } from 'antd';
|
||||
import { CarryOutOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getTaskListSelfNormal } from '@/api/appraisal';
|
||||
import './manager.css';
|
||||
|
||||
type TaskStatusKey = '0' | '2';
|
||||
|
||||
interface TaskItem {
|
||||
id?: string | number;
|
||||
taskName?: string;
|
||||
endTime?: string | number;
|
||||
taskStatus?: string | number;
|
||||
taskEditFlag?: boolean | string | number;
|
||||
examineStatusSelf?: string | number;
|
||||
}
|
||||
|
||||
type TaskBuckets = Record<TaskStatusKey, TaskItem[]>;
|
||||
|
||||
const EMPTY_BUCKETS: TaskBuckets = {
|
||||
'0': [],
|
||||
'2': [],
|
||||
};
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const asArray = (value: unknown): TaskItem[] => (Array.isArray(value) ? (value as TaskItem[]) : []);
|
||||
|
||||
const normalizeStatus = (value: unknown): TaskStatusKey => (String(value ?? '').trim() === '2' ? '2' : '0');
|
||||
|
||||
const splitBucketsByStatus = (list: TaskItem[]): TaskBuckets => {
|
||||
const next: TaskBuckets = {
|
||||
'0': [],
|
||||
'2': [],
|
||||
};
|
||||
list.forEach((item) => {
|
||||
next[normalizeStatus(item.taskStatus)].push(item);
|
||||
});
|
||||
return next;
|
||||
};
|
||||
|
||||
const normalizeTaskBuckets = (response: unknown): TaskBuckets => {
|
||||
const source = isObject(response) && response.data !== undefined ? response.data : response;
|
||||
|
||||
if (isObject(source)) {
|
||||
if ('0' in source || '2' in source) {
|
||||
return {
|
||||
'0': asArray(source['0']),
|
||||
'2': asArray(source['2']),
|
||||
};
|
||||
}
|
||||
|
||||
const rows = asArray(source.rows);
|
||||
if (rows.length) {
|
||||
return splitBucketsByStatus(rows);
|
||||
}
|
||||
|
||||
const list = asArray(source.list);
|
||||
if (list.length) {
|
||||
return splitBucketsByStatus(list);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(source)) {
|
||||
return splitBucketsByStatus(source as TaskItem[]);
|
||||
}
|
||||
|
||||
return EMPTY_BUCKETS;
|
||||
};
|
||||
|
||||
const formatEndDate = (value: unknown) => {
|
||||
if (typeof value === 'string' && value.includes(' ')) {
|
||||
return value.split(' ')[0];
|
||||
}
|
||||
const parsed = dayjs(value as dayjs.ConfigType);
|
||||
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : '-';
|
||||
};
|
||||
|
||||
const isTaskEditable = (value: unknown) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return true;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value === 1;
|
||||
}
|
||||
const raw = String(value).trim().toLowerCase();
|
||||
return raw === '1' || raw === 'true' || raw === 'yes';
|
||||
};
|
||||
|
||||
const isSelfSubmitted = (value: unknown) => String(value ?? '') === '1';
|
||||
|
||||
const NormalWorkerPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<TaskStatusKey>('0');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [taskBuckets, setTaskBuckets] = useState<TaskBuckets>(EMPTY_BUCKETS);
|
||||
|
||||
const fetchTaskList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getTaskListSelfNormal();
|
||||
setTaskBuckets(normalizeTaskBuckets(response));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch normal worker tasks:', error);
|
||||
message.error('获取考核评分任务失败');
|
||||
setTaskBuckets(EMPTY_BUCKETS);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchTaskList();
|
||||
}, [fetchTaskList]);
|
||||
|
||||
const tabMap = useMemo(
|
||||
() => ({
|
||||
'0': { label: '进行中', tagColor: 'warning' },
|
||||
'2': { label: '已过期', tagColor: 'default' },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const goDetail = (task: TaskItem, edit: '0' | '1') => {
|
||||
if (!isTaskEditable(task.taskEditFlag)) {
|
||||
message.warning('分数正在计算中,请等待计算完成');
|
||||
return;
|
||||
}
|
||||
|
||||
if (task.id === undefined || task.id === null) {
|
||||
message.warning('任务ID缺失,无法进入评分页');
|
||||
return;
|
||||
}
|
||||
|
||||
const search = new URLSearchParams({
|
||||
edit,
|
||||
isNormal: 'true',
|
||||
examineTaskId: String(task.id),
|
||||
reviewType: '1',
|
||||
});
|
||||
navigate(`/workAppraisal/detail?${search.toString()}`);
|
||||
};
|
||||
|
||||
const renderOngoingAction = (task: TaskItem) => {
|
||||
const submitted = isSelfSubmitted(task.examineStatusSelf);
|
||||
const actionText = submitted ? '查看详情' : '考核评分';
|
||||
const editFlag: '0' | '1' = submitted ? '0' : '1';
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="link"
|
||||
className={`appraisal-task-action ${isTaskEditable(task.taskEditFlag) ? '' : 'is-disabled'}`}
|
||||
onClick={() => goDetail(task, editFlag)}
|
||||
>
|
||||
{actionText}
|
||||
<RightOutlined />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderExpiredAction = (task: TaskItem) => (
|
||||
<Button
|
||||
type="link"
|
||||
className={`appraisal-task-action ${isTaskEditable(task.taskEditFlag) ? '' : 'is-disabled'}`}
|
||||
onClick={() => goDetail(task, '0')}
|
||||
>
|
||||
查看详情
|
||||
<RightOutlined />
|
||||
</Button>
|
||||
);
|
||||
|
||||
const renderTaskList = (status: TaskStatusKey) => {
|
||||
const list = taskBuckets[status] ?? [];
|
||||
if (!list.length) {
|
||||
return <Empty description="暂无数据" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="appraisal-manager-grid">
|
||||
{list.map((task, index) => (
|
||||
<div className="appraisal-task-card" key={`${status}_${task.id ?? index}`}>
|
||||
<div className="appraisal-task-card-head">
|
||||
<div className="appraisal-task-icon">
|
||||
<CarryOutOutlined />
|
||||
</div>
|
||||
<Tag color={tabMap[status].tagColor}>{tabMap[status].label}</Tag>
|
||||
</div>
|
||||
<div className="appraisal-task-name" title={task.taskName ?? '-'}>
|
||||
{task.taskName ?? '-'}
|
||||
</div>
|
||||
<div className="appraisal-task-meta">截止时间:{formatEndDate(task.endTime)}</div>
|
||||
<div className="appraisal-task-foot">
|
||||
<span />
|
||||
{status === '0' ? renderOngoingAction(task) : renderExpiredAction(task)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="appraisal-manager-page">
|
||||
<Spin spinning={loading}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key as TaskStatusKey)}
|
||||
items={[
|
||||
{
|
||||
key: '0',
|
||||
label: '进行中',
|
||||
children: renderTaskList('0'),
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: '已过期',
|
||||
children: renderTaskList('2'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NormalWorkerPage;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,57 @@
|
|||
.appraisal-dashboard-page {
|
||||
padding: 20px;
|
||||
background-color: #fff;
|
||||
height: calc(100vh - 120px);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.appraisal-dashboard-page .dashboard-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.appraisal-dashboard-page .dashboard-search {
|
||||
margin-bottom: 18px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.appraisal-dashboard-page .dashboard-search .ant-form-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.appraisal-dashboard-page .dashboard-search .ant-input,
|
||||
.appraisal-dashboard-page .dashboard-search .ant-select {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.appraisal-dashboard-page .dashboard-search .dashboard-user-picker {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.appraisal-dashboard-page .dashboard-search .ant-input-suffix .anticon {
|
||||
cursor: pointer;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.appraisal-dashboard-page .dashboard-ops .ant-btn-link {
|
||||
padding: 0 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.appraisal-dashboard-page .ant-table-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.appraisal-dashboard-page .ant-table-pagination {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.appraisal-dashboard-page .ant-table-thead > tr > th,
|
||||
.appraisal-dashboard-page .ant-table-tbody > tr > td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue