main
kangwenjing 2026-03-17 15:18:07 +08:00
commit b75150dca0
118 changed files with 26264 additions and 0 deletions

View File

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

24
.gitignore vendored 100644
View File

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

73
README.md 100644
View File

@ -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...
},
},
])
```

23
eslint.config.js 100644
View File

@ -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,
},
},
])

13
index.html 100644
View File

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

4658
package-lock.json generated 100644

File diff suppressed because it is too large Load Diff

39
package.json 100644
View File

@ -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"
}
}

1
public/vite.svg 100644
View File

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

1
src/App.css 100644
View File

@ -0,0 +1 @@
/* Remove default styles */

113
src/App.tsx 100644
View File

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

View File

@ -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,
});
}

18
src/api/login.ts 100644
View File

@ -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 },
});
}

View File

@ -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'
});
}

View File

@ -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,
});
}

View File

@ -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',
});
}

View File

@ -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',
});
}

View File

@ -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',
});
}

View File

@ -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',
});
}

View File

@ -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',
});
}

180
src/api/project.ts 100644
View File

@ -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 },
});
}

View File

@ -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,
});
}

View File

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

View File

@ -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',
});
}

View File

@ -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',
});
}

View File

@ -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',
});
}

View File

@ -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',
});
}

View File

@ -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',
});
}

View File

@ -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',
});
}

32
src/api/user.ts 100644
View File

@ -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,
});
}

91
src/api/worklog.ts 100644
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

9
src/index.css 100644
View File

@ -0,0 +1,9 @@
html,
body,
#root {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
}

View File

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

View File

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

11
src/main.tsx 100644
View File

@ -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>,
)

View File

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

View File

@ -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"> &gt;</Link>
&nbsp;&nbsp;&nbsp;
<Link href="http://txy.ruoyi.vip" target="_blank"> &gt;</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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
.profile-app-container .pull-right {
float: right;
color: #999;
}
.profile-app-container .ant-list-item-meta-description {
width: 100%;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
.cache-list-container .ant-card-body {
padding-top: 8px;
}
.cache-list-container .ant-table-row {
cursor: pointer;
}

View File

@ -0,0 +1,5 @@
.card-box {
padding-right: 15px;
padding-left: 15px;
margin-bottom: 10px;
}

View File

@ -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 */
}

View File

@ -0,0 +1,3 @@
.login-log-container .ant-form-item {
margin-bottom: 0px; /* Reduce vertical space in search form */
}

View File

@ -0,0 +1,3 @@
.online-user-container .ant-form-item {
margin-bottom: 0px; /* Reduce vertical space in search form */
}

View File

@ -0,0 +1,3 @@
.operation-log-container .ant-form-item {
margin-bottom: 0px; /* Reduce vertical space in search form */
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
.project-page-container .search-form .ant-form-item {
margin-bottom: 20px;
}
.project-page-container .mb8 {
margin-bottom: 8px;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
.config-page-container .search-form .ant-form-item {
margin-bottom: 0px;
}
.config-page-container .mb8 {
margin-bottom: 8px;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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