init
This commit is contained in:
45
.dockerignore
Normal file
45
.dockerignore
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# 依赖目录
|
||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# 构建输出
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
|
||||||
|
# 环境文件
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# 测试相关
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# 编辑器文件
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# 系统文件
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Docker相关
|
||||||
|
.dockerignore
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
|
||||||
|
# 日志文件
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# 临时文件
|
||||||
|
tmpclaude-*
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
37
Dockerfile
Normal file
37
Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# 构建阶段
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制package.json和package-lock.json
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
RUN npm ci --only=production --ignore-scripts && \
|
||||||
|
npm ci --only=development --ignore-scripts
|
||||||
|
|
||||||
|
# 复制源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 构建参数:API基础URL(默认为相对路径 /api,通过Nginx代理)
|
||||||
|
ARG VITE_API_BASE_URL=/api
|
||||||
|
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||||
|
|
||||||
|
# 构建应用
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 运行时阶段 - 使用Nginx提供静态文件
|
||||||
|
FROM nginx:alpine AS runtime
|
||||||
|
|
||||||
|
# 复制Nginx配置
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# 从构建阶段复制构建产物
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# 启动Nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>员工月度绩效考核系统</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
47
nginx.conf
Normal file
47
nginx.conf
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# 根目录
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# 启用gzip压缩
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||||
|
|
||||||
|
# 静态文件缓存
|
||||||
|
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2|ttf|svg|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA路由支持 - 所有非静态文件请求重定向到index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 健康检查端点
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 'healthy\n';
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API代理到后端服务
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://backend:3001;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# 超时设置
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
}
|
||||||
3136
package-lock.json
generated
Normal file
3136
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "employee-performance-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^5.2.6",
|
||||||
|
"antd": "^5.12.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"echarts": "^5.4.3",
|
||||||
|
"echarts-for-react": "^3.0.2",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.21.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.45",
|
||||||
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"typescript": "^5.3.2",
|
||||||
|
"vite": "^5.0.8"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/App.tsx
Normal file
25
src/App.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { ConfigProvider } from 'antd';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
import { AuthProvider } from './context/AuthContext';
|
||||||
|
import AppRouter from './router';
|
||||||
|
|
||||||
|
// 配置 dayjs 使用中文
|
||||||
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
|
const App: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<ConfigProvider locale={zhCN}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRouter />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
28
src/api/auth.ts
Normal file
28
src/api/auth.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import http from './http';
|
||||||
|
import type { UserInfo } from '../context/AuthContext';
|
||||||
|
|
||||||
|
interface LoginResponse {
|
||||||
|
token: string;
|
||||||
|
userInfo: UserInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: async (
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
role: string
|
||||||
|
): Promise<LoginResponse> => {
|
||||||
|
const { data } = await http.post<ApiResponse<LoginResponse>>('/api/user/login', {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
return data.data; // 返回 data.data,因为后端包装了一层
|
||||||
|
},
|
||||||
|
};
|
||||||
37
src/api/http.ts
Normal file
37
src/api/http.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'auth_token';
|
||||||
|
|
||||||
|
const http = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE_URL || 'https://pe-system-d.gogodao.net',
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor — attach token automatically
|
||||||
|
http.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor — handle 401/403
|
||||||
|
http.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
const status = error?.response?.status;
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
// Clear stored credentials and redirect to login
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem('auth_user');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default http;
|
||||||
122
src/api/performance.ts
Normal file
122
src/api/performance.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import http from './http';
|
||||||
|
|
||||||
|
export interface AttendanceData {
|
||||||
|
leave: number;
|
||||||
|
late: number;
|
||||||
|
absent: number;
|
||||||
|
lackCard: number;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PerformanceItemDTO {
|
||||||
|
itemName: string;
|
||||||
|
weight: number;
|
||||||
|
userContent: string;
|
||||||
|
selfScore: number;
|
||||||
|
evidence?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PerformanceSubmitDTO {
|
||||||
|
month: string;
|
||||||
|
status: 'draft' | 'submitted';
|
||||||
|
workSummary: string;
|
||||||
|
attendance: AttendanceData;
|
||||||
|
performanceItems: PerformanceItemDTO[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIScoreItem {
|
||||||
|
itemName: string;
|
||||||
|
weight: number;
|
||||||
|
aiScore: number;
|
||||||
|
scoreExplanation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIResult {
|
||||||
|
aiId: number;
|
||||||
|
perfId: number;
|
||||||
|
aiScoreDetail: AIScoreItem[];
|
||||||
|
aiTotalScore: number;
|
||||||
|
aiProblems: string[];
|
||||||
|
aiSuggestions: string[];
|
||||||
|
createTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PerformanceRecord {
|
||||||
|
perfId: number;
|
||||||
|
userId: number;
|
||||||
|
month: string;
|
||||||
|
status: 'draft' | 'submitted' | 'under_review' | 'completed' | 'rejected';
|
||||||
|
selfScore?: number;
|
||||||
|
aiScore?: number;
|
||||||
|
managerScore?: number;
|
||||||
|
totalScore?: number;
|
||||||
|
level?: 'excellent' | 'qualified' | 'need_motivation' | 'unqualified';
|
||||||
|
rewardPunish?: string;
|
||||||
|
workSummary?: string;
|
||||||
|
submitTime?: string;
|
||||||
|
reviewTime?: string;
|
||||||
|
reviewOpinion?: string;
|
||||||
|
performanceItems?: PerformanceItemDetail[];
|
||||||
|
attendance?: AttendanceData & { attendanceScore?: number };
|
||||||
|
aiResult?: AIResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PerformanceItemDetail extends PerformanceItemDTO {
|
||||||
|
itemId: number;
|
||||||
|
itemCategory: 'business' | 'comprehensive';
|
||||||
|
aiScore?: number;
|
||||||
|
aiExplanation?: string;
|
||||||
|
managerScore?: number;
|
||||||
|
managerExplanation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageResult<T> {
|
||||||
|
list: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const performanceApi = {
|
||||||
|
submit: async (data: PerformanceSubmitDTO): Promise<{ perfId: number }> => {
|
||||||
|
const { data: res } = await http.post('/api/performance/submit', data);
|
||||||
|
return res.data; // 解包 data.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getMyList: async (params?: {
|
||||||
|
month?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}): Promise<PageResult<PerformanceRecord>> => {
|
||||||
|
const { data } = await http.get('/api/performance/employee/get', { params });
|
||||||
|
// 后端返回格式: { code, message, data: { total, records } }
|
||||||
|
// 转换为前端期望的格式: { list, total, page, pageSize }
|
||||||
|
return {
|
||||||
|
list: data.data.records || [],
|
||||||
|
total: data.data.total || 0,
|
||||||
|
page: params?.page || 1,
|
||||||
|
pageSize: params?.pageSize || 10,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getDetail: async (perfId: number): Promise<PerformanceRecord> => {
|
||||||
|
const { data } = await http.get(`/api/performance/employee/get`, {
|
||||||
|
params: { perfId },
|
||||||
|
});
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
requestModification: async (perfId: number, reason: string): Promise<void> => {
|
||||||
|
await http.post('/api/performance/request-modification', { perfId, reason });
|
||||||
|
},
|
||||||
|
|
||||||
|
// 查询 AI 评分是否完成
|
||||||
|
checkAIResult: async (perfId: number): Promise<{ done: boolean; aiScore?: number }> => {
|
||||||
|
const { data } = await http.get('/api/performance/employee/get', { params: { perfId } });
|
||||||
|
const rec = data.data;
|
||||||
|
return {
|
||||||
|
done: rec?.aiScore != null,
|
||||||
|
aiScore: rec?.aiScore,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
55
src/context/AuthContext.tsx
Normal file
55
src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
userId: number;
|
||||||
|
name: string;
|
||||||
|
role: 'employee' | 'manager' | 'generalManager';
|
||||||
|
department: string;
|
||||||
|
position: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
token: string | null;
|
||||||
|
userInfo: UserInfo | null;
|
||||||
|
login: (token: string, userInfo: UserInfo) => void;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'auth_token';
|
||||||
|
const USER_KEY = 'auth_user';
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
|
||||||
|
const [userInfo, setUserInfo] = useState<UserInfo | null>(() => {
|
||||||
|
const stored = localStorage.getItem(USER_KEY);
|
||||||
|
return stored ? JSON.parse(stored) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const login = useCallback((newToken: string, newUserInfo: UserInfo) => {
|
||||||
|
localStorage.setItem(TOKEN_KEY, newToken);
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(newUserInfo));
|
||||||
|
setToken(newToken);
|
||||||
|
setUserInfo(newUserInfo);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
setToken(null);
|
||||||
|
setUserInfo(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ token, userInfo, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = (): AuthContextValue => {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
13
src/hooks/useBreakpoint.ts
Normal file
13
src/hooks/useBreakpoint.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setIsMobile(window.innerWidth < 768);
|
||||||
|
window.addEventListener('resize', handler);
|
||||||
|
return () => window.removeEventListener('resize', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
BIN
src/img/logo.png
Normal file
BIN
src/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
BIN
src/img/logo2.png
Normal file
BIN
src/img/logo2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import 'antd/dist/reset.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
179
src/pages/Login.tsx
Normal file
179
src/pages/Login.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Form, Input, Button, Select, Alert, Typography } from 'antd';
|
||||||
|
import { UserOutlined, LockOutlined, TeamOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { authApi } from '../api/auth';
|
||||||
|
import logo from '../img/logo2.png';
|
||||||
|
|
||||||
|
const { Option } = Select;
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface LoginFormValues {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
role: 'employee' | 'manager' | 'generalManager';
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleRedirect: Record<string, string> = {
|
||||||
|
employee: '/employee',
|
||||||
|
manager: '/manager',
|
||||||
|
generalManager: '/gm',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoginPage: React.FC = () => {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [alertInfo, setAlertInfo] = useState<{ type: 'success' | 'error'; msg: string } | null>(null);
|
||||||
|
|
||||||
|
const onFinish = async (values: LoginFormValues) => {
|
||||||
|
setLoading(true);
|
||||||
|
setAlertInfo(null);
|
||||||
|
try {
|
||||||
|
const { token, userInfo } = await authApi.login(values.username, values.password, values.role);
|
||||||
|
login(token, userInfo);
|
||||||
|
setAlertInfo({ type: 'success', msg: `欢迎回来,${userInfo.name}!正在跳转...` });
|
||||||
|
setTimeout(() => navigate(roleRedirect[userInfo.role], { replace: true }), 800);
|
||||||
|
} catch (err: any) {
|
||||||
|
setAlertInfo({ type: 'error', msg: err?.response?.data?.message || '登录失败,请检查用户名、密码和角色' });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.bg}>
|
||||||
|
{/* 光晕装饰 */}
|
||||||
|
<div style={{ ...styles.circle, width: 500, height: 500, top: -150, left: -150 }} />
|
||||||
|
<div style={{ ...styles.circle, width: 400, height: 400, bottom: -100, right: -100 }} />
|
||||||
|
<div style={{ ...styles.circle, width: 300, height: 300, top: '40%', left: '60%' }} />
|
||||||
|
|
||||||
|
{/* 双层纹理叠层:点阵 + 斜线 */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, zIndex: 0,
|
||||||
|
backgroundImage: `
|
||||||
|
radial-gradient(circle, rgba(99,102,241,0.08) 1px, transparent 1px),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
transparent,
|
||||||
|
transparent 20px,
|
||||||
|
rgba(99,102,241,0.03) 20px,
|
||||||
|
rgba(99,102,241,0.03) 21px
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
backgroundSize: '24px 24px, 100% 100%',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<div style={styles.card}>
|
||||||
|
{/* Logo + 标题 */}
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 28 }}>
|
||||||
|
<img src={logo} alt="logo" style={{ height: 48, objectFit: 'contain', marginBottom: 12 }} />
|
||||||
|
<div style={{ fontSize: 13, color: '#8c8c8c', letterSpacing: 1 }}>员工月度绩效考核系统</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 提示信息 */}
|
||||||
|
{alertInfo && (
|
||||||
|
<Alert
|
||||||
|
type={alertInfo.type}
|
||||||
|
message={alertInfo.msg}
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16, borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form<LoginFormValues>
|
||||||
|
name="login"
|
||||||
|
onFinish={onFinish}
|
||||||
|
initialValues={{ role: 'employee' }}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||||
|
<Input
|
||||||
|
prefix={<UserOutlined style={{ color: '#bfbfbf' }} />}
|
||||||
|
placeholder="用户名(工号)"
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<LockOutlined style={{ color: '#bfbfbf' }} />}
|
||||||
|
placeholder="密码"
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="role" rules={[{ required: true, message: '请选择角色' }]}>
|
||||||
|
<Select placeholder="选择角色" style={styles.input} suffixIcon={<TeamOutlined style={{ color: '#bfbfbf' }} />}>
|
||||||
|
<Option value="employee">员工</Option>
|
||||||
|
<Option value="manager">管理层</Option>
|
||||||
|
<Option value="generalManager">总经理</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item style={{ marginBottom: 0 }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
block
|
||||||
|
loading={loading}
|
||||||
|
style={styles.btn}
|
||||||
|
>
|
||||||
|
登 录
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<div style={{ textAlign: 'center', marginTop: 20 }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>优一科技 © 2026</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
|
bg: {
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'linear-gradient(135deg, #f0f7ff 0%, #e8f0fe 35%, #fce4ec 70%, #f3e5f5 100%)',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
circle: {
|
||||||
|
position: 'absolute',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'radial-gradient(circle, rgba(99,102,241,0.12) 0%, transparent 70%)',
|
||||||
|
filter: 'blur(40px)',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 400,
|
||||||
|
background: '#ffffff',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: '36px 32px 28px',
|
||||||
|
boxShadow: '0 4px 24px rgba(99,102,241,0.12), 0 1px 4px rgba(0,0,0,0.06)',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
border: '1px solid rgba(99,102,241,0.08)',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderRadius: 8,
|
||||||
|
height: 44,
|
||||||
|
},
|
||||||
|
btn: {
|
||||||
|
height: 46,
|
||||||
|
borderRadius: 10,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
background: 'linear-gradient(90deg, #6366f1, #8b5cf6)',
|
||||||
|
border: 'none',
|
||||||
|
letterSpacing: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
108
src/pages/employee/Dashboard.tsx
Normal file
108
src/pages/employee/Dashboard.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { Layout, Menu, Typography, Button } from 'antd';
|
||||||
|
import { HomeOutlined, FormOutlined, HistoryOutlined, LogoutOutlined } from '@ant-design/icons';
|
||||||
|
import PerformanceForm from './PerformanceForm';
|
||||||
|
import PerformanceHistory from './PerformanceHistory';
|
||||||
|
import { useIsMobile } from '../../hooks/useBreakpoint';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import logo from '../../img/logo.png';
|
||||||
|
|
||||||
|
const { Header, Content, Sider } = Layout;
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ key: '/employee', icon: <HomeOutlined />, label: <Link to="/employee">首页</Link>, mobileLabel: '首页' },
|
||||||
|
{ key: '/employee/form', icon: <FormOutlined />, label: <Link to="/employee/form">绩效填报</Link>, mobileLabel: '填报' },
|
||||||
|
{ key: '/employee/history', icon: <HistoryOutlined />, label: <Link to="/employee/history">绩效历史</Link>, mobileLabel: '历史' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EmployeeDashboard: React.FC = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const { logout } = useAuth();
|
||||||
|
|
||||||
|
const selectedKey = menuItems.find(item =>
|
||||||
|
item.key !== '/employee' ? location.pathname.startsWith(item.key) : location.pathname === item.key
|
||||||
|
)?.key ?? '/employee';
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
|
<Header style={{ background: '#001529', padding: '0 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<img src={logo} alt="logo" style={{ height: 32, objectFit: 'contain' }} />
|
||||||
|
{!isMobile && <Title level={4} style={{ color: 'white', margin: 0 }}>绩效考核系统</Title>}
|
||||||
|
</div>
|
||||||
|
<Button type="text" icon={<LogoutOutlined />} style={{ color: 'white' }} onClick={handleLogout}>
|
||||||
|
{!isMobile && '退出'}
|
||||||
|
</Button>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Layout style={{ paddingBottom: isMobile ? 56 : 0 }}>
|
||||||
|
{!isMobile && (
|
||||||
|
<Sider width={200} style={{ background: '#fff' }}>
|
||||||
|
<Menu
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={[selectedKey]}
|
||||||
|
style={{ height: '100%', borderRight: 0 }}
|
||||||
|
items={menuItems.map(({ key, icon, label }) => ({ key, icon, label }))}
|
||||||
|
/>
|
||||||
|
</Sider>
|
||||||
|
)}
|
||||||
|
<Layout style={{ padding: isMobile ? '12px 12px 0' : '0 24px 24px' }}>
|
||||||
|
<Content style={{ background: '#fff', padding: isMobile ? 12 : 24, minHeight: 280 }}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/form" element={<PerformanceForm />} />
|
||||||
|
<Route path="/history" element={<PerformanceHistory />} />
|
||||||
|
</Routes>
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
{/* 移动端底部导航 */}
|
||||||
|
{isMobile && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', bottom: 0, left: 0, right: 0, height: 56,
|
||||||
|
background: '#fff', borderTop: '1px solid #f0f0f0',
|
||||||
|
display: 'flex', zIndex: 1000,
|
||||||
|
}}>
|
||||||
|
{menuItems.map(item => (
|
||||||
|
<Link
|
||||||
|
key={item.key}
|
||||||
|
to={item.key}
|
||||||
|
style={{
|
||||||
|
flex: 1, display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center', gap: 2,
|
||||||
|
color: selectedKey === item.key ? '#1677ff' : '#8c8c8c',
|
||||||
|
fontSize: 10, textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 20 }}>{item.icon}</span>
|
||||||
|
<span>{item.mobileLabel}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const HomePage: React.FC = () => (
|
||||||
|
<div>
|
||||||
|
<Title level={3}>欢迎使用员工绩效考核系统</Title>
|
||||||
|
<Typography.Paragraph>请从菜单选择功能:</Typography.Paragraph>
|
||||||
|
<ul>
|
||||||
|
<li>绩效填报:填写月度绩效考核内容</li>
|
||||||
|
<li>绩效历史:查看历史绩效记录和详情</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default EmployeeDashboard;
|
||||||
427
src/pages/employee/PerformanceForm.tsx
Normal file
427
src/pages/employee/PerformanceForm.tsx
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
Tag,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Alert,
|
||||||
|
DatePicker,
|
||||||
|
Result,
|
||||||
|
Spin,
|
||||||
|
} from 'antd';
|
||||||
|
import { SaveOutlined, SendOutlined, LoadingOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { performanceApi, PerformanceSubmitDTO } from '../../api/performance';
|
||||||
|
import { ALL_PERFORMANCE_ITEMS } from './performanceItems';
|
||||||
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
interface FormValues {
|
||||||
|
assessmentMonth: Dayjs;
|
||||||
|
workSummary: string;
|
||||||
|
leave: number;
|
||||||
|
late: number;
|
||||||
|
absent: number;
|
||||||
|
lackCard: number;
|
||||||
|
attendanceRemark?: string;
|
||||||
|
items: Array<{
|
||||||
|
userContent: string;
|
||||||
|
selfScore: number;
|
||||||
|
evidence?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PerformanceForm: React.FC = () => {
|
||||||
|
const { userInfo } = useAuth();
|
||||||
|
const [form] = Form.useForm<FormValues>();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const [aiWaiting, setAiWaiting] = useState(false);
|
||||||
|
const [aiDone, setAiDone] = useState(false);
|
||||||
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||||
|
|
||||||
|
// 清理轮询定时器
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (pollTimerRef.current) clearInterval(pollTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 轮询 AI 结果
|
||||||
|
const pollAIResult = (perfId: number) => {
|
||||||
|
setAiWaiting(true);
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 40; // 最多轮询 40 次(约 2 分钟)
|
||||||
|
|
||||||
|
pollTimerRef.current = setInterval(async () => {
|
||||||
|
attempts++;
|
||||||
|
try {
|
||||||
|
const result = await performanceApi.checkAIResult(perfId);
|
||||||
|
if (result.done) {
|
||||||
|
clearInterval(pollTimerRef.current!);
|
||||||
|
setAiWaiting(false);
|
||||||
|
setAiDone(true);
|
||||||
|
} else if (attempts >= maxAttempts) {
|
||||||
|
clearInterval(pollTimerRef.current!);
|
||||||
|
setAiWaiting(false);
|
||||||
|
setAiDone(true); // 超时也跳转,让用户去历史记录查看
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略轮询错误,继续重试
|
||||||
|
}
|
||||||
|
}, 3000); // 每 3 秒轮询一次
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取本月作为默认值
|
||||||
|
const getPreviousMonth = (): Dayjs => {
|
||||||
|
return dayjs();
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDTO = (values: FormValues, status: 'draft' | 'submitted'): PerformanceSubmitDTO => ({
|
||||||
|
month: values.assessmentMonth.format('YYYY-MM'),
|
||||||
|
status,
|
||||||
|
workSummary: values.workSummary || '',
|
||||||
|
attendance: {
|
||||||
|
leave: values.leave ?? 0,
|
||||||
|
late: values.late ?? 0,
|
||||||
|
absent: values.absent ?? 0,
|
||||||
|
lackCard: values.lackCard ?? 0,
|
||||||
|
remark: values.attendanceRemark,
|
||||||
|
},
|
||||||
|
performanceItems: ALL_PERFORMANCE_ITEMS.map((item, idx) => ({
|
||||||
|
itemName: item.itemName,
|
||||||
|
weight: item.weight,
|
||||||
|
userContent: values.items?.[idx]?.userContent || '',
|
||||||
|
selfScore: values.items?.[idx]?.selfScore ?? 0,
|
||||||
|
evidence: values.items?.[idx]?.evidence,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const values = form.getFieldsValue();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await performanceApi.submit(buildDTO(values, 'draft'));
|
||||||
|
message.success('草稿已暂存');
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.response?.data?.message || '暂存失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (values: FormValues) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await performanceApi.submit(buildDTO(values, 'submitted'));
|
||||||
|
setSubmitted(true);
|
||||||
|
form.resetFields();
|
||||||
|
// 提交成功后开始轮询 AI 结果
|
||||||
|
if (result?.perfId) {
|
||||||
|
pollAIResult(result.perfId);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.response?.data?.message || '提交失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (submitted) {
|
||||||
|
// AI 分析完成
|
||||||
|
if (aiDone) {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
icon={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
|
||||||
|
title="AI 分析完成!"
|
||||||
|
subTitle="绩效已提交,AI 评分已生成,可前往历史记录查看详情。"
|
||||||
|
style={{ margin: 24 }}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" onClick={() => { setSubmitted(false); setAiDone(false); }}>
|
||||||
|
重新填报
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 分析中
|
||||||
|
if (aiWaiting) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: '80px 24px' }}>
|
||||||
|
<Spin indicator={<LoadingOutlined style={{ fontSize: 48 }} spin />} />
|
||||||
|
<div style={{ marginTop: 24 }}>
|
||||||
|
<Typography.Title level={4}>AI 正在分析中,请稍等!</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
系统正在对您的绩效数据进行 AI 智能评分,通常需要 30 秒至 1 分钟...
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交成功但还未开始轮询(过渡状态)
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
type="success"
|
||||||
|
message="提交成功"
|
||||||
|
description="您的绩效已成功提交,AI 评分将在后台自动完成,管理层将在审核期内完成审核。"
|
||||||
|
showIcon
|
||||||
|
style={{ margin: 24 }}
|
||||||
|
action={
|
||||||
|
<Button onClick={() => setSubmitted(false)}>重新填报</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: isMobile ? 8 : 24, maxWidth: 900, margin: '0 auto' }}>
|
||||||
|
<Title level={isMobile ? 4 : 3}>绩效填报</Title>
|
||||||
|
|
||||||
|
{/* 基础信息 */}
|
||||||
|
<Card style={{ marginBottom: 16 }}>
|
||||||
|
{isMobile ? (
|
||||||
|
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||||
|
<div><Text type="secondary">姓名:</Text><Text strong>{userInfo?.name}</Text></div>
|
||||||
|
<div><Text type="secondary">部门:</Text><Text strong>{userInfo?.department}</Text></div>
|
||||||
|
<div><Text type="secondary">岗位:</Text><Text strong>{userInfo?.position}</Text></div>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Row gutter={24}>
|
||||||
|
<Col span={8}><Text type="secondary">姓名:</Text><Text strong>{userInfo?.name}</Text></Col>
|
||||||
|
<Col span={8}><Text type="secondary">部门:</Text><Text strong>{userInfo?.department}</Text></Col>
|
||||||
|
<Col span={8}><Text type="secondary">岗位:</Text><Text strong>{userInfo?.position}</Text></Col>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleSubmit}
|
||||||
|
initialValues={{
|
||||||
|
assessmentMonth: getPreviousMonth(),
|
||||||
|
leave: 0, late: 0, absent: 0, lackCard: 0,
|
||||||
|
items: ALL_PERFORMANCE_ITEMS.map(() => ({ selfScore: 0 })),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 考核月份选择 */}
|
||||||
|
<Card style={{ marginBottom: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="assessmentMonth"
|
||||||
|
label="考核月份"
|
||||||
|
rules={[{ required: true, message: '请选择考核月份' }]}
|
||||||
|
>
|
||||||
|
<DatePicker
|
||||||
|
picker="month"
|
||||||
|
format="YYYY-MM"
|
||||||
|
placeholder="请选择考核月份"
|
||||||
|
style={{ width: 200 }}
|
||||||
|
disabledDate={(current) => {
|
||||||
|
// 不能选择未来的月份
|
||||||
|
return current && current > dayjs().endOf('month');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Card>
|
||||||
|
{/* 考核指标 */}
|
||||||
|
<Card title="考核指标填写" style={{ marginBottom: 16 }}>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
message="请逐项填写各考核指标的完成情况描述和自评分数(0-100分)"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 业务素质 */}
|
||||||
|
<Title level={5}>
|
||||||
|
业务素质考评 <Tag color="blue">占总分 70%</Tag>
|
||||||
|
</Title>
|
||||||
|
{ALL_PERFORMANCE_ITEMS.filter(i => i.category === 'business').map((item, idx) => (
|
||||||
|
<Card
|
||||||
|
key={item.itemName}
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: 12, background: '#fafafa' }}
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<Text strong>{item.itemName}</Text>
|
||||||
|
<Tag color="geekblue">权重 {item.weight} 分</Tag>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
{item.description}
|
||||||
|
</Text>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={16}>
|
||||||
|
<Form.Item
|
||||||
|
name={['items', idx, 'userContent']}
|
||||||
|
label="完成情况描述"
|
||||||
|
rules={[{ required: true, message: '请填写完成情况描述' }]}
|
||||||
|
>
|
||||||
|
<TextArea rows={2} placeholder="请描述本月该项指标的完成情况" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} md={4}>
|
||||||
|
<Form.Item
|
||||||
|
name={['items', idx, 'selfScore']}
|
||||||
|
label="自评分数"
|
||||||
|
rules={[{ required: true, message: '请填写自评分' }]}
|
||||||
|
>
|
||||||
|
<InputNumber min={0} max={100} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} md={4}>
|
||||||
|
<Form.Item name={['items', idx, 'evidence']} label="佐证材料(可选)">
|
||||||
|
<Input placeholder="链接或说明" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* 综合素质 */}
|
||||||
|
<Title level={5}>
|
||||||
|
综合素质考评 <Tag color="green">占总分 30%</Tag>
|
||||||
|
</Title>
|
||||||
|
{ALL_PERFORMANCE_ITEMS.filter(i => i.category === 'comprehensive').map((item) => {
|
||||||
|
const idx = ALL_PERFORMANCE_ITEMS.findIndex(x => x.itemName === item.itemName);
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={item.itemName}
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: 12, background: '#fafafa' }}
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<Text strong>{item.itemName}</Text>
|
||||||
|
<Tag color="green">权重 {item.weight} 分</Tag>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
{item.description}
|
||||||
|
</Text>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={16}>
|
||||||
|
<Form.Item
|
||||||
|
name={['items', idx, 'userContent']}
|
||||||
|
label="完成情况描述"
|
||||||
|
rules={[{ required: true, message: '请填写完成情况描述' }]}
|
||||||
|
>
|
||||||
|
<TextArea rows={2} placeholder="请描述本月该项指标的完成情况" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} md={4}>
|
||||||
|
<Form.Item
|
||||||
|
name={['items', idx, 'selfScore']}
|
||||||
|
label="自评分数"
|
||||||
|
rules={[{ required: true, message: '请填写自评分' }]}
|
||||||
|
>
|
||||||
|
<InputNumber min={0} max={100} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} md={4}>
|
||||||
|
<Form.Item name={['items', idx, 'evidence']} label="佐证材料(可选)">
|
||||||
|
<Input placeholder="链接或说明" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 考勤数据 */}
|
||||||
|
<Card title="考勤数据" style={{ marginBottom: 16 }}>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
message="考勤满分 10 分,事假每天扣 5 分,迟到/缺卡每次扣 2 分,最低 0 分"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Form.Item name="leave" label="事假(天)">
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Form.Item name="late" label="迟到(次)">
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Form.Item name="absent" label="旷工(天)">
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Form.Item name="lackCard" label="缺卡(次)">
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Form.Item name="attendanceRemark" label="考勤备注(可选)">
|
||||||
|
<TextArea rows={2} placeholder="如有特殊情况请说明" />
|
||||||
|
</Form.Item>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 工作汇总 */}
|
||||||
|
<Card title="工作汇总" style={{ marginBottom: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="workSummary"
|
||||||
|
label="工作汇总"
|
||||||
|
rules={[{ required: true, message: '请填写工作汇总' }, { min: 10, message: '工作汇总至少填写 10 个字' }]}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
rows={5}
|
||||||
|
placeholder="请填写本月工作总结,包括主要工作内容、成果、遇到的问题及解决方案等"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', paddingBottom: 16 }}>
|
||||||
|
<Button
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={saving}
|
||||||
|
size="large"
|
||||||
|
style={isMobile ? { flex: 1 } : {}}
|
||||||
|
>
|
||||||
|
暂存草稿
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
htmlType="submit"
|
||||||
|
loading={submitting}
|
||||||
|
size="large"
|
||||||
|
style={isMobile ? { flex: 1 } : {}}
|
||||||
|
>
|
||||||
|
提交绩效
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PerformanceForm;
|
||||||
418
src/pages/employee/PerformanceHistory.tsx
Normal file
418
src/pages/employee/PerformanceHistory.tsx
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Button,
|
||||||
|
DatePicker,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
Drawer,
|
||||||
|
Descriptions,
|
||||||
|
List,
|
||||||
|
Divider,
|
||||||
|
message,
|
||||||
|
Empty,
|
||||||
|
Card,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
} from 'antd';
|
||||||
|
import { EyeOutlined } from '@ant-design/icons';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { performanceApi, PerformanceRecord } from '../../api/performance';
|
||||||
|
import { useIsMobile } from '../../hooks/useBreakpoint';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||||
|
draft: { label: '草稿', color: 'default' },
|
||||||
|
submitted: { label: '已提交', color: 'processing' },
|
||||||
|
under_review: { label: '审核中', color: 'warning' },
|
||||||
|
completed: { label: '已完成', color: 'success' },
|
||||||
|
rejected: { label: '已驳回', color: 'error' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEVEL_MAP: Record<string, { label: string; color: string }> = {
|
||||||
|
excellent: { label: '优秀', color: 'gold' },
|
||||||
|
qualified: { label: '合格', color: 'green' },
|
||||||
|
need_motivation: { label: '需激励', color: 'orange' },
|
||||||
|
unqualified: { label: '不合格', color: 'red' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PerformanceHistory: React.FC = () => {
|
||||||
|
const [records, setRecords] = useState<PerformanceRecord[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize] = useState(10);
|
||||||
|
const [filterMonth, setFilterMonth] = useState<string | undefined>();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<PerformanceRecord | null>(null);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const loadList = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await performanceApi.getMyList({ month: filterMonth, page, pageSize });
|
||||||
|
setRecords(res.list);
|
||||||
|
setTotal(res.total);
|
||||||
|
} catch {
|
||||||
|
message.error('加载绩效记录失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterMonth, page, pageSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadList();
|
||||||
|
}, [loadList]);
|
||||||
|
|
||||||
|
const viewDetail = async (record: PerformanceRecord) => {
|
||||||
|
setDrawerOpen(true);
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const detail = await performanceApi.getDetail(record.perfId);
|
||||||
|
setSelected(detail);
|
||||||
|
} catch {
|
||||||
|
// fallback to list data
|
||||||
|
setSelected(record);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnsType<PerformanceRecord> = [
|
||||||
|
{
|
||||||
|
title: '考核月份',
|
||||||
|
dataIndex: 'month',
|
||||||
|
key: 'month',
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (status: string) => {
|
||||||
|
const s = STATUS_MAP[status] || { label: status, color: 'default' };
|
||||||
|
return <Tag color={s.color}>{s.label}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '自评分',
|
||||||
|
dataIndex: 'selfScore',
|
||||||
|
key: 'selfScore',
|
||||||
|
width: 90,
|
||||||
|
render: (v?: number) => v != null ? Number(v).toFixed(1) : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'AI 评分',
|
||||||
|
dataIndex: 'aiScore',
|
||||||
|
key: 'aiScore',
|
||||||
|
width: 90,
|
||||||
|
render: (v?: number) => v != null ? Number(v).toFixed(1) : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最终总分',
|
||||||
|
dataIndex: 'totalScore',
|
||||||
|
key: 'totalScore',
|
||||||
|
width: 90,
|
||||||
|
render: (v?: number, record?: PerformanceRecord) => {
|
||||||
|
if (v != null) {
|
||||||
|
return <Text strong>{Number(v).toFixed(1)}</Text>;
|
||||||
|
}
|
||||||
|
// 如果已提交但还没有最终总分,显示待审核
|
||||||
|
if (record?.status === 'submitted' || record?.status === 'under_review') {
|
||||||
|
return <Tag color="processing">待审核</Tag>;
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '绩效等级',
|
||||||
|
dataIndex: 'level',
|
||||||
|
key: 'level',
|
||||||
|
width: 100,
|
||||||
|
render: (level?: string, record?: PerformanceRecord) => {
|
||||||
|
if (level) {
|
||||||
|
const l = LEVEL_MAP[level] || { label: level, color: 'default' };
|
||||||
|
return <Tag color={l.color}>{l.label}</Tag>;
|
||||||
|
}
|
||||||
|
// 如果已提交但还没有等级,显示待审核
|
||||||
|
if (record?.status === 'submitted' || record?.status === 'under_review') {
|
||||||
|
return <Tag color="processing">待审核</Tag>;
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '提交时间',
|
||||||
|
dataIndex: 'submitTime',
|
||||||
|
key: 'submitTime',
|
||||||
|
render: (v?: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 80,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => viewDetail(record)}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: isMobile ? 0 : 24 }}>
|
||||||
|
<Title level={3}>个人绩效历史</Title>
|
||||||
|
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<DatePicker
|
||||||
|
picker="month"
|
||||||
|
placeholder="筛选月份"
|
||||||
|
onChange={(_, dateStr) => {
|
||||||
|
setFilterMonth(dateStr as string || undefined);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button onClick={loadList}>刷新</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{/* 移动端卡片列表 */}
|
||||||
|
{isMobile ? (
|
||||||
|
<div>
|
||||||
|
{loading && <div style={{ textAlign: 'center', padding: 24 }}>加载中...</div>}
|
||||||
|
{!loading && records.length === 0 && <Empty description="暂无绩效记录" style={{ padding: 40 }} />}
|
||||||
|
{records.map(record => {
|
||||||
|
const s = STATUS_MAP[record.status] || { label: record.status, color: 'default' };
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={record.perfId}
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
onClick={() => viewDetail(record)}
|
||||||
|
hoverable
|
||||||
|
>
|
||||||
|
<Row justify="space-between" align="middle">
|
||||||
|
<Col><Typography.Text strong>{record.month}</Typography.Text></Col>
|
||||||
|
<Col><Tag color={s.color}>{s.label}</Tag></Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={8} style={{ marginTop: 8 }}>
|
||||||
|
<Col span={8}><Typography.Text type="secondary" style={{ fontSize: 12 }}>自评分</Typography.Text><br /><Typography.Text>{record.selfScore != null ? Number(record.selfScore).toFixed(1) : '-'}</Typography.Text></Col>
|
||||||
|
<Col span={8}><Typography.Text type="secondary" style={{ fontSize: 12 }}>AI评分</Typography.Text><br /><Typography.Text>{record.aiScore != null ? Number(record.aiScore).toFixed(1) : '-'}</Typography.Text></Col>
|
||||||
|
<Col span={8}><Typography.Text type="secondary" style={{ fontSize: 12 }}>最终总分</Typography.Text><br />
|
||||||
|
{record.totalScore != null
|
||||||
|
? <Typography.Text strong>{Number(record.totalScore).toFixed(1)}</Typography.Text>
|
||||||
|
: (record.status === 'submitted' || record.status === 'under_review')
|
||||||
|
? <Tag color="processing" style={{ fontSize: 11 }}>待审核</Tag>
|
||||||
|
: <Typography.Text>-</Typography.Text>
|
||||||
|
}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{record.submitTime && (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 11, marginTop: 4, display: 'block' }}>
|
||||||
|
提交:{dayjs(record.submitTime).format('YYYY-MM-DD HH:mm')}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{total > pageSize && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '8px 0' }}>
|
||||||
|
<Space>
|
||||||
|
<Button size="small" disabled={page === 1} onClick={() => setPage(p => p - 1)}>上一页</Button>
|
||||||
|
<Typography.Text type="secondary">{page} / {Math.ceil(total / pageSize)}</Typography.Text>
|
||||||
|
<Button size="small" disabled={page >= Math.ceil(total / pageSize)} onClick={() => setPage(p => p + 1)}>下一页</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
rowKey="perfId"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={records}
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
onChange: setPage,
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
}}
|
||||||
|
locale={{ emptyText: <Empty description="暂无绩效记录" /> }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title={`绩效详情 — ${selected?.month ?? ''}`}
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
width={isMobile ? '100%' : 720}
|
||||||
|
placement={isMobile ? 'bottom' : 'right'}
|
||||||
|
height={isMobile ? '90%' : undefined}
|
||||||
|
loading={detailLoading}
|
||||||
|
>
|
||||||
|
{selected && <DetailContent record={selected} />}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DetailContent: React.FC<{ record: PerformanceRecord }> = ({ record }) => {
|
||||||
|
const status = STATUS_MAP[record.status] || { label: record.status, color: 'default' };
|
||||||
|
const level = record.level ? LEVEL_MAP[record.level] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Descriptions bordered column={2} size="small">
|
||||||
|
<Descriptions.Item label="考核月份">{record.month}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
<Tag color={status.color}>{status.label}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="自评总分">{record.selfScore != null ? Number(record.selfScore).toFixed(1) : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="AI 评分">{record.aiScore != null ? Number(record.aiScore).toFixed(1) : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="管理层评分">{record.managerScore != null ? Number(record.managerScore).toFixed(1) : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="最终总分">
|
||||||
|
<Text strong>{record.totalScore != null ? Number(record.totalScore).toFixed(1) : '-'}</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
{level && (
|
||||||
|
<Descriptions.Item label="绩效等级">
|
||||||
|
<Tag color={level.color}>{level.label}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
{record.rewardPunish && (
|
||||||
|
<Descriptions.Item label="奖惩说明" span={2}>
|
||||||
|
{record.rewardPunish}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
{record.reviewOpinion && (
|
||||||
|
<Descriptions.Item label="审核意见" span={2}>
|
||||||
|
{record.reviewOpinion}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
{/* 工作汇总 */}
|
||||||
|
{record.workSummary && (
|
||||||
|
<>
|
||||||
|
<Divider orientation="left">工作汇总</Divider>
|
||||||
|
<Card size="small">
|
||||||
|
<Text>{record.workSummary}</Text>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 考勤信息 */}
|
||||||
|
{record.attendance && (
|
||||||
|
<>
|
||||||
|
<Divider orientation="left">考勤数据</Divider>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={6}><Text type="secondary">事假:</Text><Text>{record.attendance.leave} 天</Text></Col>
|
||||||
|
<Col span={6}><Text type="secondary">迟到:</Text><Text>{record.attendance.late} 次</Text></Col>
|
||||||
|
<Col span={6}><Text type="secondary">旷工:</Text><Text>{record.attendance.absent} 天</Text></Col>
|
||||||
|
<Col span={6}><Text type="secondary">缺卡:</Text><Text>{record.attendance.lackCard} 次</Text></Col>
|
||||||
|
</Row>
|
||||||
|
{record.attendance.attendanceScore != null && (
|
||||||
|
<Text type="secondary" style={{ marginTop: 8, display: 'block' }}>
|
||||||
|
考勤得分:<Text strong>{record.attendance.attendanceScore}</Text> 分
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI 建议 */}
|
||||||
|
{record.aiResult && (
|
||||||
|
<>
|
||||||
|
<Divider orientation="left">AI 建议</Divider>
|
||||||
|
|
||||||
|
{record.aiResult.aiProblems?.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text strong style={{ display: 'block', marginBottom: 4 }}>
|
||||||
|
核心问题:
|
||||||
|
</Text>
|
||||||
|
<List
|
||||||
|
size="small"
|
||||||
|
dataSource={record.aiResult.aiProblems}
|
||||||
|
renderItem={(item, i) => (
|
||||||
|
<List.Item>
|
||||||
|
<Text>{i + 1}. {item}</Text>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{record.aiResult.aiSuggestions?.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text strong style={{ display: 'block', marginTop: 12, marginBottom: 4 }}>
|
||||||
|
改进建议:
|
||||||
|
</Text>
|
||||||
|
<List
|
||||||
|
size="small"
|
||||||
|
dataSource={record.aiResult.aiSuggestions}
|
||||||
|
renderItem={(item, i) => (
|
||||||
|
<List.Item>
|
||||||
|
<Text>{i + 1}. {item}</Text>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 考核项明细 */}
|
||||||
|
{record.performanceItems && record.performanceItems.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider orientation="left">考核项明细</Divider>
|
||||||
|
{record.performanceItems.map((item) => (
|
||||||
|
<Card
|
||||||
|
key={item.itemName}
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<Text strong>{item.itemName}</Text>
|
||||||
|
<Tag>{item.weight} 分</Tag>
|
||||||
|
<Tag color={item.itemCategory === 'business' ? 'blue' : 'green'}>
|
||||||
|
{item.itemCategory === 'business' ? '业务素质' : '综合素质'}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Descriptions column={3} size="small">
|
||||||
|
<Descriptions.Item label="自评分">{item.selfScore ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="AI 评分">{item.aiScore ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="管理层评分">{item.managerScore ?? '-'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
{item.userContent && (
|
||||||
|
<Text type="secondary" style={{ display: 'block', marginTop: 4 }}>
|
||||||
|
填报内容:{item.userContent}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{item.aiExplanation && (
|
||||||
|
<Text type="secondary" style={{ display: 'block', marginTop: 4 }}>
|
||||||
|
AI 说明:{item.aiExplanation}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{item.managerExplanation && (
|
||||||
|
<Text type="secondary" style={{ display: 'block', marginTop: 4 }}>
|
||||||
|
管理层说明:{item.managerExplanation}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PerformanceHistory;
|
||||||
33
src/pages/employee/performanceItems.ts
Normal file
33
src/pages/employee/performanceItems.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export interface ItemConfig {
|
||||||
|
itemName: string;
|
||||||
|
weight: number;
|
||||||
|
category: 'business' | 'comprehensive';
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 业务素质 9 项(共 70 分)
|
||||||
|
const businessItems: ItemConfig[] = [
|
||||||
|
{ itemName: '工作目标完成情况', weight: 15, category: 'business', description: '本月工作目标的完成程度' },
|
||||||
|
{ itemName: '工作质量', weight: 10, category: 'business', description: '工作成果的质量和准确性' },
|
||||||
|
{ itemName: '工作效率', weight: 10, category: 'business', description: '完成工作任务的速度和效率' },
|
||||||
|
{ itemName: '业务能力', weight: 10, category: 'business', description: '专业技能和业务知识水平' },
|
||||||
|
{ itemName: '创新能力', weight: 5, category: 'business', description: '提出创新方案和改进建议的能力' },
|
||||||
|
{ itemName: '问题解决能力', weight: 5, category: 'business', description: '发现和解决工作中问题的能力' },
|
||||||
|
{ itemName: '项目推进能力', weight: 5, category: 'business', description: '推动项目按计划进行的能力' },
|
||||||
|
{ itemName: '客户服务', weight: 5, category: 'business', description: '对内外部客户的服务质量' },
|
||||||
|
{ itemName: '成本控制', weight: 5, category: 'business', description: '合理控制工作成本的意识和行动' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 综合素质 8 项(共 30 分,含考勤 10 分)
|
||||||
|
const comprehensiveItems: ItemConfig[] = [
|
||||||
|
{ itemName: '团队协作', weight: 5, category: 'comprehensive', description: '与团队成员协作配合的能力' },
|
||||||
|
{ itemName: '沟通能力', weight: 5, category: 'comprehensive', description: '与他人有效沟通的能力' },
|
||||||
|
{ itemName: '学习成长', weight: 5, category: 'comprehensive', description: '主动学习和自我提升的意愿与行动' },
|
||||||
|
{ itemName: '责任心', weight: 5, category: 'comprehensive', description: '对工作认真负责的态度' },
|
||||||
|
{ itemName: '执行力', weight: 5, category: 'comprehensive', description: '按时按质完成任务的能力' },
|
||||||
|
{ itemName: '职业素养', weight: 5, category: 'comprehensive', description: '职业道德和行为规范' },
|
||||||
|
{ itemName: '工作态度', weight: 5, category: 'comprehensive', description: '对工作的积极性和主动性' },
|
||||||
|
// 考勤由系统自动计算,不在此列表中手动填写
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ALL_PERFORMANCE_ITEMS: ItemConfig[] = [...businessItems, ...comprehensiveItems];
|
||||||
175
src/pages/gm/CompanyOverview.tsx
Normal file
175
src/pages/gm/CompanyOverview.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Card, Row, Col, Statistic, DatePicker, Button, Space, Typography, message, Spin, Select } from 'antd';
|
||||||
|
import { TeamOutlined, TrophyOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
|
import http from '../../api/http';
|
||||||
|
import { useIsMobile } from '../../hooks/useBreakpoint';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
interface DepartmentStat {
|
||||||
|
department: string;
|
||||||
|
averageScore: number;
|
||||||
|
totalCount: number;
|
||||||
|
levelDistribution: { excellent: number; qualified: number; need_motivation: number; unqualified: number };
|
||||||
|
}
|
||||||
|
interface PositionStat { position: string; averageScore: number; totalCount: number; }
|
||||||
|
interface CompanyStats {
|
||||||
|
month: string; totalCount: number; averageScore: number;
|
||||||
|
departmentStats: DepartmentStat[]; positionStats: PositionStat[];
|
||||||
|
levelDistribution: { excellent: number; qualified: number; need_motivation: number; unqualified: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const CompanyOverview: React.FC = () => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [stats, setStats] = useState<CompanyStats | null>(null);
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState<Dayjs>(dayjs());
|
||||||
|
const [selectedDimension, setSelectedDimension] = useState<'department' | 'position'>('department');
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const loadStats = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await http.get('/api/statistics/company', { params: { month: selectedMonth.format('YYYY-MM') } });
|
||||||
|
setStats(data.data || data);
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.response?.data?.message || '加载公司统计数据失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedMonth]);
|
||||||
|
|
||||||
|
useEffect(() => { loadStats(); }, [loadStats]);
|
||||||
|
|
||||||
|
const emptyOption = (title: string) => ({
|
||||||
|
title: { text: title, left: 'center' },
|
||||||
|
graphic: [{ type: 'text', left: 'center', top: 'middle', style: { text: '暂无数据', fontSize: 14, fill: '#999' } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const getLevelPieOption = () => {
|
||||||
|
if (!stats) return emptyOption('绩效等级分布');
|
||||||
|
const names = { excellent: '优秀', qualified: '合格', need_motivation: '需激励', unqualified: '不合格' };
|
||||||
|
const colors = { excellent: '#faad14', qualified: '#52c41a', need_motivation: '#fa8c16', unqualified: '#f5222d' };
|
||||||
|
return {
|
||||||
|
title: { text: '绩效等级分布', left: 'center' },
|
||||||
|
tooltip: { trigger: 'item', formatter: '{b}: {c}人 ({d}%)' },
|
||||||
|
legend: { orient: isMobile ? 'horizontal' : 'vertical', left: isMobile ? 'center' : 'left', bottom: isMobile ? 0 : undefined },
|
||||||
|
series: [{
|
||||||
|
name: '绩效等级', type: 'pie', radius: isMobile ? '45%' : '50%',
|
||||||
|
center: isMobile ? ['50%', '45%'] : ['50%', '50%'],
|
||||||
|
data: Object.entries(stats.levelDistribution).map(([k, v]) => ({
|
||||||
|
name: names[k as keyof typeof names], value: v,
|
||||||
|
itemStyle: { color: colors[k as keyof typeof colors] },
|
||||||
|
})),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBarOption = (title: string, categories: string[], values: number[], color: string) => ({
|
||||||
|
title: { text: title, left: 'center', textStyle: { fontSize: isMobile ? 12 : 14 } },
|
||||||
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
|
grid: { left: isMobile ? '5%' : '3%', right: '4%', bottom: isMobile ? '20%' : '15%', containLabel: true },
|
||||||
|
xAxis: { type: 'category', data: categories, axisLabel: { interval: 0, rotate: isMobile ? 45 : 30, fontSize: isMobile ? 10 : 12 } },
|
||||||
|
yAxis: { type: 'value', name: '平均分', min: 0, max: 100 },
|
||||||
|
series: [{ type: 'bar', data: values, itemStyle: { color }, label: { show: !isMobile, position: 'top' } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const getDepartmentLevelStackOption = () => {
|
||||||
|
if (!stats || stats.departmentStats.length === 0) return emptyOption('各部门绩效等级分布');
|
||||||
|
const depts = stats.departmentStats.map(d => d.department);
|
||||||
|
return {
|
||||||
|
title: { text: '各部门绩效等级分布', left: 'center', textStyle: { fontSize: isMobile ? 12 : 14 } },
|
||||||
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
|
legend: { data: ['优秀', '合格', '需激励', '不合格'], bottom: 0, textStyle: { fontSize: isMobile ? 10 : 12 } },
|
||||||
|
grid: { left: '3%', right: '4%', bottom: isMobile ? '25%' : '15%', containLabel: true },
|
||||||
|
xAxis: { type: 'category', data: depts, axisLabel: { interval: 0, rotate: isMobile ? 45 : 30, fontSize: isMobile ? 10 : 12 } },
|
||||||
|
yAxis: { type: 'value', name: '人数' },
|
||||||
|
series: [
|
||||||
|
{ name: '优秀', type: 'bar', stack: 'total', data: stats.departmentStats.map(d => d.levelDistribution.excellent), itemStyle: { color: '#faad14' } },
|
||||||
|
{ name: '合格', type: 'bar', stack: 'total', data: stats.departmentStats.map(d => d.levelDistribution.qualified), itemStyle: { color: '#52c41a' } },
|
||||||
|
{ name: '需激励', type: 'bar', stack: 'total', data: stats.departmentStats.map(d => d.levelDistribution.need_motivation), itemStyle: { color: '#fa8c16' } },
|
||||||
|
{ name: '不合格', type: 'bar', stack: 'total', data: stats.departmentStats.map(d => d.levelDistribution.unqualified), itemStyle: { color: '#f5222d' } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !stats) return <div style={{ textAlign: 'center', padding: 100 }}><Spin size="large" /></div>;
|
||||||
|
|
||||||
|
const excellentRate = (stats && stats.totalCount > 0)
|
||||||
|
? ((stats.levelDistribution.excellent / stats.totalCount) * 100).toFixed(1) : '0';
|
||||||
|
const chartH = isMobile ? 260 : 400;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: isMobile ? 0 : 24 }}>
|
||||||
|
<Title level={isMobile ? 4 : 3}>全公司绩效总览</Title>
|
||||||
|
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<DatePicker picker="month" value={selectedMonth} onChange={(d) => d && setSelectedMonth(d)} format="YYYY-MM" />
|
||||||
|
<Select value={selectedDimension} onChange={setSelectedDimension} style={{ width: isMobile ? 130 : 150 }}>
|
||||||
|
<Option value="department">按部门统计</Option>
|
||||||
|
<Option value="position">按岗位统计</Option>
|
||||||
|
</Select>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={loadStats} loading={loading}>刷新</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<>
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<Row gutter={[8, 8]} style={{ marginBottom: 12 }}>
|
||||||
|
{[
|
||||||
|
{ title: '总人数', value: stats.totalCount, suffix: '人', prefix: <TeamOutlined />, color: undefined },
|
||||||
|
{ title: '平均分', value: Number(stats.averageScore).toFixed(1), suffix: '', prefix: null, color: '#3f8600' },
|
||||||
|
{ title: '优秀人数', value: stats.levelDistribution.excellent, suffix: '人', prefix: <TrophyOutlined />, color: '#faad14' },
|
||||||
|
{ title: '优秀率', value: excellentRate, suffix: '%', prefix: null, color: '#cf1322' },
|
||||||
|
].map((item, i) => (
|
||||||
|
<Col xs={12} sm={6} key={i}>
|
||||||
|
<Card bodyStyle={{ padding: isMobile ? '10px 8px' : 20 }}>
|
||||||
|
<Statistic title={item.title} value={item.value} suffix={item.suffix}
|
||||||
|
prefix={item.prefix ?? undefined} valueStyle={{ color: item.color, fontSize: isMobile ? 18 : 24 }} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* 图表 */}
|
||||||
|
<Row gutter={[8, 8]} style={{ marginBottom: 12 }}>
|
||||||
|
<Col xs={24} sm={12}>
|
||||||
|
<Card bodyStyle={{ padding: isMobile ? 8 : 24 }}>
|
||||||
|
<ReactECharts option={getLevelPieOption()} style={{ height: chartH }} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12}>
|
||||||
|
<Card bodyStyle={{ padding: isMobile ? 8 : 24 }}>
|
||||||
|
<ReactECharts
|
||||||
|
option={selectedDimension === 'department'
|
||||||
|
? getBarOption('各部门绩效平均分', stats.departmentStats.map(d => d.department), stats.departmentStats.map(d => d.averageScore), '#1890ff')
|
||||||
|
: getBarOption('各岗位绩效平均分', stats.positionStats.map(p => p.position), stats.positionStats.map(p => p.averageScore), '#52c41a')
|
||||||
|
}
|
||||||
|
style={{ height: chartH }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{selectedDimension === 'department' && (
|
||||||
|
<Card style={{ marginBottom: 12 }} bodyStyle={{ padding: isMobile ? 8 : 24 }}>
|
||||||
|
<ReactECharts option={getDepartmentLevelStackOption()} style={{ height: chartH }} />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card size="small">
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
统计范围:{selectedMonth.format('YYYY年MM月')} 已完成审核的全公司员工绩效数据
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!stats && !loading && <Card><Text type="secondary">暂无统计数据</Text></Card>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CompanyOverview;
|
||||||
217
src/pages/gm/ConfigManagement.tsx
Normal file
217
src/pages/gm/ConfigManagement.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Card, Row, Col, Button, Space, Typography, message, Spin, Form, InputNumber, Divider, DatePicker, Modal } from 'antd';
|
||||||
|
import { DownloadOutlined, SaveOutlined, ReloadOutlined, SettingOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
||||||
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
|
import http from '../../api/http';
|
||||||
|
import { useIsMobile } from '../../hooks/useBreakpoint';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
interface RuleResponse {
|
||||||
|
ruleId: number; ruleKey: string; ruleValue: any;
|
||||||
|
description: string | null; effectiveCycle: string | null;
|
||||||
|
updatedBy: number | null; updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规则字段配置
|
||||||
|
const RULE_FIELDS = {
|
||||||
|
business: [
|
||||||
|
{ name: 'business_completion_weight', label: '工作完成度', max: 70 },
|
||||||
|
{ name: 'business_quality_weight', label: '工作质量', max: 70 },
|
||||||
|
{ name: 'business_efficiency_weight', label: '工作效率', max: 70 },
|
||||||
|
{ name: 'business_skill_weight', label: '专业技能', max: 70 },
|
||||||
|
{ name: 'business_innovation_weight', label: '创新能力', max: 70 },
|
||||||
|
{ name: 'business_problem_solving_weight', label: '问题解决', max: 70 },
|
||||||
|
{ name: 'business_customer_satisfaction_weight', label: '客户满意度', max: 70 },
|
||||||
|
{ name: 'business_teamwork_weight', label: '团队协作', max: 70 },
|
||||||
|
{ name: 'business_goal_achievement_weight', label: '目标达成', max: 70 },
|
||||||
|
],
|
||||||
|
comprehensive: [
|
||||||
|
{ name: 'comprehensive_responsibility_weight', label: '责任心', max: 30 },
|
||||||
|
{ name: 'comprehensive_initiative_weight', label: '主动性', max: 30 },
|
||||||
|
{ name: 'comprehensive_learning_weight', label: '学习能力', max: 30 },
|
||||||
|
{ name: 'comprehensive_communication_weight', label: '沟通能力', max: 30 },
|
||||||
|
{ name: 'comprehensive_execution_weight', label: '执行力', max: 30 },
|
||||||
|
{ name: 'comprehensive_discipline_weight', label: '纪律性', max: 30 },
|
||||||
|
{ name: 'comprehensive_team_spirit_weight', label: '团队精神', max: 30 },
|
||||||
|
{ name: 'comprehensive_attendance_weight', label: '考勤', max: 30 },
|
||||||
|
],
|
||||||
|
reward: [
|
||||||
|
{ name: 'reward_excellent', label: '优秀奖励(≥90分)', unit: '元' },
|
||||||
|
{ name: 'punish_qualified_high', label: '合格扣款(80-89分)', unit: '元' },
|
||||||
|
{ name: 'punish_qualified_low', label: '合格扣款(70-79分)', unit: '元' },
|
||||||
|
{ name: 'punish_need_motivation', label: '需激励扣款(60-69分)', unit: '元' },
|
||||||
|
{ name: 'punish_unqualified', label: '不合格扣款(<60分)', unit: '元' },
|
||||||
|
],
|
||||||
|
attendance: [
|
||||||
|
{ name: 'attendance_leave_deduct', label: '事假扣分(每天)', unit: '分' },
|
||||||
|
{ name: 'attendance_late_deduct', label: '迟到扣分(每次)', unit: '分' },
|
||||||
|
{ name: 'attendance_lack_card_deduct', label: '缺卡扣分(每次)', unit: '分' },
|
||||||
|
{ name: 'attendance_base_score', label: '考勤基础分', unit: '分' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ConfigManagement: React.FC = () => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [rules, setRules] = useState<RuleResponse[]>([]);
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState<Dayjs>(dayjs());
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const loadRules = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await http.get('/api/config/rules');
|
||||||
|
const ruleList: RuleResponse[] = data.data || data;
|
||||||
|
setRules(ruleList);
|
||||||
|
const formValues: Record<string, any> = {};
|
||||||
|
ruleList.forEach((rule) => { formValues[rule.ruleKey] = rule.ruleValue; });
|
||||||
|
form.setFieldsValue(formValues);
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.response?.data?.message || '加载配置规则失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { loadRules(); }, []);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认修改考核规则',
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
content: '修改后的规则将应用于后续考核周期,是否确认?',
|
||||||
|
okText: '确认', cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await http.put('/api/config/rules', {
|
||||||
|
rules: Object.entries(values).map(([ruleKey, ruleValue]) => ({ ruleKey, ruleValue })),
|
||||||
|
});
|
||||||
|
message.success('规则更新成功');
|
||||||
|
await loadRules();
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.response?.data?.message || '规则更新失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch { message.error('请检查表单输入'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const month = selectedMonth.format('YYYY-MM');
|
||||||
|
const response = await http.get('/api/performance/export', { params: { month, type: 'all' }, responseType: 'blob' });
|
||||||
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', `全公司绩效数据_${month}.xlsx`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
message.success('导出成功');
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.response?.data?.message || '导出失败');
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && rules.length === 0) return <div style={{ textAlign: 'center', padding: 100 }}><Spin size="large" /></div>;
|
||||||
|
|
||||||
|
// 移动端每行2列,桌面端每行3或4列
|
||||||
|
const colSpan = isMobile ? 12 : 8;
|
||||||
|
const colSpan4 = isMobile ? 12 : 6;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: isMobile ? 0 : 24 }}>
|
||||||
|
<Title level={isMobile ? 4 : 3}>系统配置与数据导出</Title>
|
||||||
|
|
||||||
|
{/* 数据导出 */}
|
||||||
|
<Card title={<Space><DownloadOutlined /><span>全量数据导出</span></Space>} style={{ marginBottom: 16 }}
|
||||||
|
bodyStyle={{ padding: isMobile ? 12 : 24 }}>
|
||||||
|
<Text style={{ display: 'block', marginBottom: 8 }}>选择考核月份,导出全公司所有员工的绩效数据(Excel 格式)</Text>
|
||||||
|
<Space wrap>
|
||||||
|
<DatePicker picker="month" value={selectedMonth} onChange={(d) => d && setSelectedMonth(d)} format="YYYY-MM" />
|
||||||
|
<Button type="primary" icon={<DownloadOutlined />} onClick={handleExport} loading={exporting}>
|
||||||
|
导出全量数据
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 考核规则配置 */}
|
||||||
|
<Card
|
||||||
|
title={<Space><SettingOutlined /><span>考核规则配置</span></Space>}
|
||||||
|
bodyStyle={{ padding: isMobile ? 12 : 24 }}
|
||||||
|
extra={
|
||||||
|
<Space size={4}>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={loadRules} loading={loading} size={isMobile ? 'small' : 'middle'}>刷新</Button>
|
||||||
|
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving} size={isMobile ? 'small' : 'middle'}>保存</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Divider orientation="left" style={{ fontSize: isMobile ? 13 : 14 }}>业务素质考核指标权重(总计70分)</Divider>
|
||||||
|
<Row gutter={[8, 0]}>
|
||||||
|
{RULE_FIELDS.business.map(f => (
|
||||||
|
<Col span={colSpan} key={f.name}>
|
||||||
|
<Form.Item label={f.label} name={f.name} rules={[{ required: true, message: '请输入' }]}>
|
||||||
|
<InputNumber min={0} max={f.max} style={{ width: '100%' }} addonAfter="分" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Divider orientation="left" style={{ fontSize: isMobile ? 13 : 14 }}>综合素质考核指标权重(总计30分)</Divider>
|
||||||
|
<Row gutter={[8, 0]}>
|
||||||
|
{RULE_FIELDS.comprehensive.map(f => (
|
||||||
|
<Col span={colSpan} key={f.name}>
|
||||||
|
<Form.Item label={f.label} name={f.name} rules={[{ required: true, message: '请输入' }]}>
|
||||||
|
<InputNumber min={0} max={f.max} style={{ width: '100%' }} addonAfter="分" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Divider orientation="left" style={{ fontSize: isMobile ? 13 : 14 }}>奖惩金额配置</Divider>
|
||||||
|
<Row gutter={[8, 0]}>
|
||||||
|
{RULE_FIELDS.reward.map(f => (
|
||||||
|
<Col span={colSpan} key={f.name}>
|
||||||
|
<Form.Item label={f.label} name={f.name} rules={[{ required: true, message: '请输入' }]}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} addonAfter={f.unit} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Divider orientation="left" style={{ fontSize: isMobile ? 13 : 14 }}>考勤扣分规则</Divider>
|
||||||
|
<Row gutter={[8, 0]}>
|
||||||
|
{RULE_FIELDS.attendance.map(f => (
|
||||||
|
<Col span={colSpan4} key={f.name}>
|
||||||
|
<Form.Item label={f.label} name={f.name} rules={[{ required: true, message: '请输入' }]}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} addonAfter={f.unit} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Card size="small" style={{ marginTop: 12, background: '#fffbe6' }}>
|
||||||
|
<Text type="warning" style={{ fontSize: 12 }}>
|
||||||
|
<ExclamationCircleOutlined /> 注意:修改考核规则后,将自动应用于后续考核周期。已完成的历史绩效数据不受影响。
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfigManagement;
|
||||||
107
src/pages/gm/Dashboard.tsx
Normal file
107
src/pages/gm/Dashboard.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { Layout, Menu, Typography, Button } from 'antd';
|
||||||
|
import { HomeOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined } from '@ant-design/icons';
|
||||||
|
import CompanyOverview from './CompanyOverview';
|
||||||
|
import ConfigManagement from './ConfigManagement';
|
||||||
|
import { useIsMobile } from '../../hooks/useBreakpoint';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import logo from '../../img/logo.png';
|
||||||
|
|
||||||
|
const { Header, Content, Sider } = Layout;
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ key: '/gm', icon: <HomeOutlined />, label: <Link to="/gm">首页</Link>, mobileLabel: '首页' },
|
||||||
|
{ key: '/gm/overview', icon: <BarChartOutlined />, label: <Link to="/gm/overview">绩效总览</Link>, mobileLabel: '总览' },
|
||||||
|
{ key: '/gm/config', icon: <SettingOutlined />, label: <Link to="/gm/config">系统配置</Link>, mobileLabel: '配置' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GMDashboard: React.FC = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const { logout } = useAuth();
|
||||||
|
|
||||||
|
const getSelectedKey = () => {
|
||||||
|
if (location.pathname.startsWith('/gm/overview')) return '/gm/overview';
|
||||||
|
if (location.pathname.startsWith('/gm/config')) return '/gm/config';
|
||||||
|
return '/gm';
|
||||||
|
};
|
||||||
|
const selectedKey = getSelectedKey();
|
||||||
|
|
||||||
|
const handleLogout = () => { logout(); navigate('/login'); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
|
<Header style={{ background: '#001529', padding: '0 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<img src={logo} alt="logo" style={{ height: 32, objectFit: 'contain' }} />
|
||||||
|
{!isMobile && <Title level={4} style={{ color: 'white', margin: 0 }}>绩效考核系统 - 总经理</Title>}
|
||||||
|
</div>
|
||||||
|
<Button type="text" icon={<LogoutOutlined />} style={{ color: 'white' }} onClick={handleLogout}>
|
||||||
|
{!isMobile && '退出'}
|
||||||
|
</Button>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Layout style={{ paddingBottom: isMobile ? 56 : 0 }}>
|
||||||
|
{!isMobile && (
|
||||||
|
<Sider width={200} style={{ background: '#fff' }}>
|
||||||
|
<Menu
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={[selectedKey]}
|
||||||
|
style={{ height: '100%', borderRight: 0 }}
|
||||||
|
items={menuItems.map(({ key, icon, label }) => ({ key, icon, label }))}
|
||||||
|
/>
|
||||||
|
</Sider>
|
||||||
|
)}
|
||||||
|
<Layout style={{ padding: isMobile ? '12px 12px 0' : '0 24px 24px' }}>
|
||||||
|
<Content style={{ background: '#fff', padding: isMobile ? 12 : 24, minHeight: 280 }}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/overview" element={<CompanyOverview />} />
|
||||||
|
<Route path="/config" element={<ConfigManagement />} />
|
||||||
|
</Routes>
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
{isMobile && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', bottom: 0, left: 0, right: 0, height: 56,
|
||||||
|
background: '#fff', borderTop: '1px solid #f0f0f0',
|
||||||
|
display: 'flex', zIndex: 1000,
|
||||||
|
}}>
|
||||||
|
{menuItems.map(item => (
|
||||||
|
<Link
|
||||||
|
key={item.key}
|
||||||
|
to={item.key}
|
||||||
|
style={{
|
||||||
|
flex: 1, display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center', gap: 2,
|
||||||
|
color: selectedKey === item.key ? '#1677ff' : '#8c8c8c',
|
||||||
|
fontSize: 10, textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 20 }}>{item.icon}</span>
|
||||||
|
<span>{item.mobileLabel}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const HomePage: React.FC = () => (
|
||||||
|
<div>
|
||||||
|
<Title level={3}>欢迎使用总经理工作台</Title>
|
||||||
|
<Typography.Paragraph>请从菜单选择功能:</Typography.Paragraph>
|
||||||
|
<ul>
|
||||||
|
<li>全公司绩效总览:查看各部门、各岗位的绩效统计数据</li>
|
||||||
|
<li>系统配置:导出全量绩效数据、配置考核规则</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default GMDashboard;
|
||||||
114
src/pages/manager/Dashboard.tsx
Normal file
114
src/pages/manager/Dashboard.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { Layout, Menu, Typography, Button } from 'antd';
|
||||||
|
import { HomeOutlined, TeamOutlined, BarChartOutlined, LogoutOutlined, UserAddOutlined } from '@ant-design/icons';
|
||||||
|
import SubordinateList from './SubordinateList';
|
||||||
|
import PerformanceReview from './PerformanceReview';
|
||||||
|
import TeamStatistics from './TeamStatistics';
|
||||||
|
import EmployeeManagement from './EmployeeManagement';
|
||||||
|
import logo from '../../img/logo.png';
|
||||||
|
import { useIsMobile } from '../../hooks/useBreakpoint';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
|
||||||
|
const { Header, Content, Sider } = Layout;
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ key: '/manager', icon: <HomeOutlined />, label: <Link to="/manager">首页</Link>, mobileLabel: '首页' },
|
||||||
|
{ key: '/manager/subordinates', icon: <TeamOutlined />, label: <Link to="/manager/subordinates">下属绩效</Link>, mobileLabel: '下属' },
|
||||||
|
{ key: '/manager/statistics', icon: <BarChartOutlined />, label: <Link to="/manager/statistics">团队统计</Link>, mobileLabel: '统计' },
|
||||||
|
{ key: '/manager/employees', icon: <UserAddOutlined />, label: <Link to="/manager/employees">员工管理</Link>, mobileLabel: '员工' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ManagerDashboard: React.FC = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const { logout } = useAuth();
|
||||||
|
|
||||||
|
const getSelectedKey = () => {
|
||||||
|
if (location.pathname.startsWith('/manager/review')) return '/manager/subordinates';
|
||||||
|
if (location.pathname.startsWith('/manager/statistics')) return '/manager/statistics';
|
||||||
|
if (location.pathname.startsWith('/manager/subordinates')) return '/manager/subordinates';
|
||||||
|
if (location.pathname.startsWith('/manager/employees')) return '/manager/employees';
|
||||||
|
return '/manager';
|
||||||
|
};
|
||||||
|
const selectedKey = getSelectedKey();
|
||||||
|
|
||||||
|
const handleLogout = () => { logout(); navigate('/login'); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
|
<Header style={{ background: '#001529', padding: '0 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<img src={logo} alt="logo" style={{ height: 32, objectFit: 'contain' }} />
|
||||||
|
{!isMobile && <Title level={4} style={{ color: 'white', margin: 0 }}>绩效考核系统 - 管理层</Title>}
|
||||||
|
</div>
|
||||||
|
<Button type="text" icon={<LogoutOutlined />} style={{ color: 'white' }} onClick={handleLogout}>
|
||||||
|
{!isMobile && '退出'}
|
||||||
|
</Button>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Layout style={{ paddingBottom: isMobile ? 56 : 0 }}>
|
||||||
|
{!isMobile && (
|
||||||
|
<Sider width={200} style={{ background: '#fff' }}>
|
||||||
|
<Menu
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={[selectedKey]}
|
||||||
|
style={{ height: '100%', borderRight: 0 }}
|
||||||
|
items={menuItems.map(({ key, icon, label }) => ({ key, icon, label }))}
|
||||||
|
/>
|
||||||
|
</Sider>
|
||||||
|
)}
|
||||||
|
<Layout style={{ padding: isMobile ? '12px 12px 0' : '0 24px 24px' }}>
|
||||||
|
<Content style={{ background: '#fff', padding: isMobile ? 12 : 24, minHeight: 280 }}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/subordinates" element={<SubordinateList />} />
|
||||||
|
<Route path="/review/:perfId" element={<PerformanceReview />} />
|
||||||
|
<Route path="/statistics" element={<TeamStatistics />} />
|
||||||
|
<Route path="/employees" element={<EmployeeManagement />} />
|
||||||
|
</Routes>
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
{isMobile && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', bottom: 0, left: 0, right: 0, height: 56,
|
||||||
|
background: '#fff', borderTop: '1px solid #f0f0f0',
|
||||||
|
display: 'flex', zIndex: 1000,
|
||||||
|
}}>
|
||||||
|
{menuItems.map(item => (
|
||||||
|
<Link
|
||||||
|
key={item.key}
|
||||||
|
to={item.key}
|
||||||
|
style={{
|
||||||
|
flex: 1, display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center', gap: 2,
|
||||||
|
color: selectedKey === item.key ? '#1677ff' : '#8c8c8c',
|
||||||
|
fontSize: 10, textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 20 }}>{item.icon}</span>
|
||||||
|
<span>{item.mobileLabel}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const HomePage: React.FC = () => (
|
||||||
|
<div>
|
||||||
|
<Title level={3}>欢迎使用管理层工作台</Title>
|
||||||
|
<Typography.Paragraph>请从菜单选择功能:</Typography.Paragraph>
|
||||||
|
<ul>
|
||||||
|
<li>下属绩效:查看和审核下属员工的绩效记录</li>
|
||||||
|
<li>团队统计:查看团队绩效统计数据和导出报表</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ManagerDashboard;
|
||||||
226
src/pages/manager/EmployeeManagement.tsx
Normal file
226
src/pages/manager/EmployeeManagement.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Table, Button, Modal, Form, Input, Space, Typography,
|
||||||
|
message, Popconfirm, Tag, Card,
|
||||||
|
} from 'antd';
|
||||||
|
import { PlusOutlined, DeleteOutlined, ReloadOutlined, StopOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import http from '../../api/http';
|
||||||
|
import { useIsMobile } from '../../hooks/useBreakpoint';
|
||||||
|
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
interface Employee {
|
||||||
|
user_id: number;
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
department: string;
|
||||||
|
position: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmployeeManagement: React.FC = () => {
|
||||||
|
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const loadList = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await http.get('/api/employee/list');
|
||||||
|
setEmployees(data.data || []);
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.response?.data?.message || '加载员工列表失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadList(); }, [loadList]);
|
||||||
|
|
||||||
|
const handleCreate = async (values: any) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await http.post('/api/employee/create', values);
|
||||||
|
message.success('员工账号创建成功');
|
||||||
|
setModalOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
loadList();
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.response?.data?.message || '创建失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (userId: number) => {
|
||||||
|
try {
|
||||||
|
await http.delete(`/api/employee/${userId}`);
|
||||||
|
message.success('员工账号已删除');
|
||||||
|
loadList();
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.response?.data?.message || '删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleStatus = async (userId: number, currentStatus: string) => {
|
||||||
|
const newStatus = currentStatus === 'active' ? 'inactive' : 'active';
|
||||||
|
try {
|
||||||
|
await http.patch(`/api/employee/${userId}/status`, { status: newStatus });
|
||||||
|
message.success(newStatus === 'inactive' ? '员工账号已禁用' : '员工账号已启用');
|
||||||
|
loadList();
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.response?.data?.message || '操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnsType<Employee> = [
|
||||||
|
{ title: '用户名', dataIndex: 'username', key: 'username', width: 110 },
|
||||||
|
{ title: '姓名', dataIndex: 'name', key: 'name', width: 90 },
|
||||||
|
{ title: '部门', dataIndex: 'department', key: 'department', width: 100 },
|
||||||
|
{ title: '岗位', dataIndex: 'position', key: 'position', width: 120 },
|
||||||
|
{
|
||||||
|
title: '状态', dataIndex: 'status', key: 'status', width: 80,
|
||||||
|
render: (s: string) => <Tag color={s === 'active' ? 'green' : 'default'}>{s === 'active' ? '在职' : '停用'}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 150,
|
||||||
|
render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作', key: 'action', width: 140, fixed: 'right',
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<Popconfirm
|
||||||
|
title={record.status === 'active' ? '确认禁用该员工账号?' : '确认启用该员工账号?'}
|
||||||
|
description={record.status === 'active' ? '禁用后该员工将无法登录系统。' : '启用后该员工可正常登录。'}
|
||||||
|
onConfirm={() => handleToggleStatus(record.user_id, record.status)}
|
||||||
|
okText="确认"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{ danger: record.status === 'active' }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={record.status === 'active' ? <StopOutlined /> : <CheckCircleOutlined />}
|
||||||
|
danger={record.status === 'active'}
|
||||||
|
style={{ color: record.status !== 'active' ? '#52c41a' : undefined }}
|
||||||
|
>
|
||||||
|
{record.status === 'active' ? '禁用' : '启用'}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除该员工账号?"
|
||||||
|
description="删除后无法恢复,如有绩效记录将无法删除。"
|
||||||
|
onConfirm={() => handleDelete(record.user_id)}
|
||||||
|
okText="确认删除"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
>
|
||||||
|
<Button type="link" danger icon={<DeleteOutlined />} size="small">删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: isMobile ? 0 : 24 }}>
|
||||||
|
<Title level={3}>员工账号管理</Title>
|
||||||
|
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
|
||||||
|
新建员工账号
|
||||||
|
</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={loadList} loading={loading}>刷新</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{isMobile ? (
|
||||||
|
<div>
|
||||||
|
{employees.map(emp => (
|
||||||
|
<Card key={emp.user_id} size="small" style={{ marginBottom: 10 }}>
|
||||||
|
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
||||||
|
<Space>
|
||||||
|
<Typography.Text strong>{emp.name}</Typography.Text>
|
||||||
|
<Typography.Text type="secondary">@{emp.username}</Typography.Text>
|
||||||
|
<Tag color={emp.status === 'active' ? 'green' : 'default'}>{emp.status === 'active' ? '在职' : '停用'}</Tag>
|
||||||
|
</Space>
|
||||||
|
<Typography.Text type="secondary">{emp.department} · {emp.position}</Typography.Text>
|
||||||
|
<Space size={8}>
|
||||||
|
<Popconfirm
|
||||||
|
title={emp.status === 'active' ? '确认禁用?' : '确认启用?'}
|
||||||
|
onConfirm={() => handleToggleStatus(emp.user_id, emp.status)}
|
||||||
|
okText="确认" cancelText="取消" okButtonProps={{ danger: emp.status === 'active' }}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small"
|
||||||
|
icon={emp.status === 'active' ? <StopOutlined /> : <CheckCircleOutlined />}
|
||||||
|
danger={emp.status === 'active'}
|
||||||
|
style={{ padding: 0, color: emp.status !== 'active' ? '#52c41a' : undefined }}
|
||||||
|
>
|
||||||
|
{emp.status === 'active' ? '禁用' : '启用'}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除该员工账号?"
|
||||||
|
description="删除后无法恢复,如有绩效记录将无法删除。"
|
||||||
|
onConfirm={() => handleDelete(emp.user_id)}
|
||||||
|
okText="确认删除" cancelText="取消" okButtonProps={{ danger: true }}
|
||||||
|
>
|
||||||
|
<Button type="link" danger icon={<DeleteOutlined />} size="small" style={{ padding: 0 }}>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
rowKey="user_id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={employees}
|
||||||
|
loading={loading}
|
||||||
|
scroll={{ x: 700 }}
|
||||||
|
pagination={{ pageSize: 20, showTotal: t => `共 ${t} 人` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="新建员工账号"
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => { setModalOpen(false); form.resetFields(); }}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
confirmLoading={submitting}
|
||||||
|
okText="创建"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={handleCreate} style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item name="username" label="用户名(工号)" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||||
|
<Input placeholder="如:emp007" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="password" label="初始密码"
|
||||||
|
rules={[{ required: true, message: '请输入密码' }, { min: 6, message: '密码至少6位' }]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder="至少6位" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
|
||||||
|
<Input placeholder="员工真实姓名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="department" label="部门" rules={[{ required: true, message: '请输入部门' }]}>
|
||||||
|
<Input placeholder="如:技术部" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="position" label="岗位" rules={[{ required: true, message: '请输入岗位' }]}>
|
||||||
|
<Input placeholder="如:前端工程师" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmployeeManagement;
|
||||||
452
src/pages/manager/PerformanceReview.tsx
Normal file
452
src/pages/manager/PerformanceReview.tsx
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Descriptions,
|
||||||
|
Tag,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
Spin,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Divider,
|
||||||
|
Form,
|
||||||
|
InputNumber,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Alert,
|
||||||
|
List,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
CheckOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { PerformanceRecord, PerformanceItemDetail } from '../../api/performance';
|
||||||
|
import http from '../../api/http';
|
||||||
|
import { useIsMobile } from '../../hooks/useBreakpoint';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||||
|
draft: { label: '草稿', color: 'default' },
|
||||||
|
submitted: { label: '已提交', color: 'processing' },
|
||||||
|
under_review: { label: '审核中', color: 'warning' },
|
||||||
|
completed: { label: '已完成', color: 'success' },
|
||||||
|
rejected: { label: '已驳回', color: 'error' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEVEL_MAP: Record<string, { label: string; color: string }> = {
|
||||||
|
excellent: { label: '优秀', color: 'gold' },
|
||||||
|
qualified: { label: '合格', color: 'green' },
|
||||||
|
need_motivation: { label: '需激励', color: 'orange' },
|
||||||
|
unqualified: { label: '不合格', color: 'red' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ItemScoreForm {
|
||||||
|
managerScore: number;
|
||||||
|
scoreExplanation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewFormData {
|
||||||
|
reviewOpinion: string;
|
||||||
|
itemScores: Record<string, ItemScoreForm>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PerformanceReview: React.FC = () => {
|
||||||
|
const { perfId } = useParams<{ perfId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [form] = Form.useForm<ReviewFormData>();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [record, setRecord] = useState<PerformanceRecord | null>(null);
|
||||||
|
const [rejectModalOpen, setRejectModalOpen] = useState(false);
|
||||||
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDetail();
|
||||||
|
}, [perfId]);
|
||||||
|
|
||||||
|
const loadDetail = async () => {
|
||||||
|
if (!perfId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await http.get(
|
||||||
|
`/api/performance/manager/detail/${perfId}`
|
||||||
|
);
|
||||||
|
const record = data.data || data;
|
||||||
|
setRecord(record);
|
||||||
|
|
||||||
|
// Initialize form with existing manager scores if available
|
||||||
|
const itemScores: Record<string, ItemScoreForm> = {};
|
||||||
|
data.performanceItems?.forEach((item: PerformanceItemDetail) => {
|
||||||
|
itemScores[item.itemName] = {
|
||||||
|
managerScore: item.managerScore ?? item.aiScore ?? item.selfScore ?? 0,
|
||||||
|
scoreExplanation: item.managerExplanation ?? '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
form.setFieldsValue({
|
||||||
|
reviewOpinion: data.reviewOpinion ?? '',
|
||||||
|
itemScores,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.response?.data?.message || '加载绩效详情失败');
|
||||||
|
navigate('/manager/subordinates');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApprove = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
const itemScores = Object.entries(values.itemScores).map(
|
||||||
|
([itemName, score]) => ({
|
||||||
|
itemName,
|
||||||
|
managerScore: score.managerScore,
|
||||||
|
managerExplanation: score.scoreExplanation || '',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await http.post('/api/performance/manager/review', {
|
||||||
|
perfId: Number(perfId),
|
||||||
|
reviewOpinion: values.reviewOpinion,
|
||||||
|
itemScores,
|
||||||
|
});
|
||||||
|
|
||||||
|
message.success('审核通过,绩效已归档');
|
||||||
|
navigate('/manager/subordinates');
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.errorFields) {
|
||||||
|
message.error('请完成所有必填项');
|
||||||
|
} else {
|
||||||
|
message.error(err?.response?.data?.message || '审核失败,请重试');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = async () => {
|
||||||
|
if (!rejectReason.trim()) {
|
||||||
|
message.error('请填写驳回原因');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await http.post('/api/performance/manager/reject', {
|
||||||
|
perfId: Number(perfId),
|
||||||
|
reason: rejectReason,
|
||||||
|
});
|
||||||
|
message.success('绩效已驳回,员工将收到通知');
|
||||||
|
navigate('/manager/subordinates');
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.response?.data?.message || '驳回失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
setRejectModalOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: 100 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = STATUS_MAP[record.status] || {
|
||||||
|
label: record.status,
|
||||||
|
color: 'default',
|
||||||
|
};
|
||||||
|
const level = record.level ? LEVEL_MAP[record.level] : null;
|
||||||
|
const isCompleted = record.status === 'completed';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: isMobile ? 12 : 24, maxWidth: 1200, margin: '0 auto' }}>
|
||||||
|
<Space style={{ marginBottom: 12 }}>
|
||||||
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/manager/subordinates')} size={isMobile ? 'small' : 'middle'}>
|
||||||
|
返回列表
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Title level={isMobile ? 4 : 3}>
|
||||||
|
绩效审核 — {record.month}
|
||||||
|
{isCompleted && <Tag color="success" style={{ marginLeft: 8 }}>已完成</Tag>}
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
{/* 基础信息 */}
|
||||||
|
<Card style={{ marginBottom: 12 }} bodyStyle={{ padding: isMobile ? 12 : 24 }}>
|
||||||
|
{isMobile ? (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px 16px' }}>
|
||||||
|
{[
|
||||||
|
{ label: '员工姓名', value: (record as any).userName || '-' },
|
||||||
|
{ label: '部门', value: (record as any).userDepartment || '-' },
|
||||||
|
{ label: '岗位', value: (record as any).userPosition || '-' },
|
||||||
|
{ label: '考核月份', value: record.month },
|
||||||
|
{ label: '状态', value: <Tag color={status.color}>{status.label}</Tag> },
|
||||||
|
{ label: '提交时间', value: record.submitTime ? dayjs(record.submitTime).format('YYYY-MM-DD HH:mm') : '-' },
|
||||||
|
{ label: '自评总分', value: record.selfScore != null ? Number(record.selfScore).toFixed(1) : '-' },
|
||||||
|
{ label: 'AI 评分', value: record.aiScore != null ? Number(record.aiScore).toFixed(1) : '-' },
|
||||||
|
{ label: '最终总分', value: <Text strong>{record.totalScore != null ? Number(record.totalScore).toFixed(1) : '-'}</Text> },
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<div key={label}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>{label}</Text>
|
||||||
|
<div>{value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{level && (
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>绩效等级</Text>
|
||||||
|
<div><Tag color={level.color}>{level.label}</Tag></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isCompleted && record.reviewOpinion && (
|
||||||
|
<div style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>审核意见</Text>
|
||||||
|
<div><Text>{record.reviewOpinion}</Text></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Descriptions bordered column={3} size="small">
|
||||||
|
<Descriptions.Item label="员工姓名">{(record as any).userName || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="部门">{(record as any).userDepartment || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="岗位">{(record as any).userPosition || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="考核月份">{record.month}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态"><Tag color={status.color}>{status.label}</Tag></Descriptions.Item>
|
||||||
|
<Descriptions.Item label="提交时间">{record.submitTime ? dayjs(record.submitTime).format('YYYY-MM-DD HH:mm') : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="自评总分">{record.selfScore != null ? Number(record.selfScore).toFixed(1) : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="AI 评分">{record.aiScore != null ? Number(record.aiScore).toFixed(1) : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="最终总分"><Text strong>{record.totalScore != null ? Number(record.totalScore).toFixed(1) : '-'}</Text></Descriptions.Item>
|
||||||
|
{level && <Descriptions.Item label="绩效等级"><Tag color={level.color}>{level.label}</Tag></Descriptions.Item>}
|
||||||
|
{record.rewardPunish && <Descriptions.Item label="奖惩说明" span={2}>{record.rewardPunish}</Descriptions.Item>}
|
||||||
|
{isCompleted && record.reviewOpinion && (
|
||||||
|
<Descriptions.Item label="审核意见" span={3}>{record.reviewOpinion}</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 工作汇总 */}
|
||||||
|
{record.workSummary && (
|
||||||
|
<Card title="工作汇总" style={{ marginBottom: 16 }}>
|
||||||
|
<Text>{record.workSummary}</Text>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 考勤信息 */}
|
||||||
|
{record.attendance && (
|
||||||
|
<Card title="考勤数据" style={{ marginBottom: 12 }} bodyStyle={{ padding: isMobile ? 12 : 24 }}>
|
||||||
|
<Row gutter={[8, 8]}>
|
||||||
|
<Col xs={12} sm={6}><Text type="secondary">事假:</Text><Text>{record.attendance.leave} 天</Text></Col>
|
||||||
|
<Col xs={12} sm={6}><Text type="secondary">迟到:</Text><Text>{record.attendance.late} 次</Text></Col>
|
||||||
|
<Col xs={12} sm={6}><Text type="secondary">旷工:</Text><Text>{record.attendance.absent} 天</Text></Col>
|
||||||
|
<Col xs={12} sm={6}><Text type="secondary">缺卡:</Text><Text>{record.attendance.lackCard} 次</Text></Col>
|
||||||
|
</Row>
|
||||||
|
{record.attendance.attendanceScore != null && (
|
||||||
|
<Text type="secondary" style={{ marginTop: 8, display: 'block' }}>
|
||||||
|
考勤得分:<Text strong>{record.attendance.attendanceScore}</Text> 分
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI 评分反馈 */}
|
||||||
|
{record.aiResult && (
|
||||||
|
<Card title="AI 评分反馈" style={{ marginBottom: 16 }}>
|
||||||
|
<Descriptions bordered column={1} size="small">
|
||||||
|
<Descriptions.Item label="AI 总分">
|
||||||
|
<Text strong>{record.aiResult.aiTotalScore != null ? Number(record.aiResult.aiTotalScore).toFixed(1) : '-'}</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
{record.aiResult.aiProblems?.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider orientation="left">AI 总结的核心问题</Divider>
|
||||||
|
<List
|
||||||
|
size="small"
|
||||||
|
dataSource={record.aiResult.aiProblems}
|
||||||
|
renderItem={(item, i) => (
|
||||||
|
<List.Item>
|
||||||
|
<Text>
|
||||||
|
{i + 1}. {item}
|
||||||
|
</Text>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{record.aiResult.aiSuggestions?.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider orientation="left">AI 改进建议</Divider>
|
||||||
|
<List
|
||||||
|
size="small"
|
||||||
|
dataSource={record.aiResult.aiSuggestions}
|
||||||
|
renderItem={(item, i) => (
|
||||||
|
<List.Item>
|
||||||
|
<Text>
|
||||||
|
{i + 1}. {item}
|
||||||
|
</Text>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 考核项评分 */}
|
||||||
|
<Card title="考核项评分与审核" style={{ marginBottom: 16 }}>
|
||||||
|
{!isCompleted && (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
message="请为每个考核项填写管理层评分,系统将自动计算最终总分和绩效等级"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form form={form} layout="vertical" disabled={isCompleted}>
|
||||||
|
{record.performanceItems?.map((item) => (
|
||||||
|
<Card
|
||||||
|
key={item.itemName}
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: 12, background: '#fafafa' }}
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<Text strong>{item.itemName}</Text>
|
||||||
|
<Tag>{item.weight} 分</Tag>
|
||||||
|
<Tag
|
||||||
|
color={
|
||||||
|
item.itemCategory === 'business' ? 'blue' : 'green'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.itemCategory === 'business'
|
||||||
|
? '业务素质'
|
||||||
|
: '综合素质'}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isMobile ? (
|
||||||
|
// 移动端简化展示
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>{item.userContent || '-'}</Text>
|
||||||
|
<Row gutter={8} style={{ marginTop: 8 }}>
|
||||||
|
<Col span={8}><Text type="secondary" style={{ fontSize: 11 }}>自评</Text><br /><Text>{item.selfScore ?? '-'}</Text></Col>
|
||||||
|
<Col span={8}><Text type="secondary" style={{ fontSize: 11 }}>AI评分</Text><br /><Text>{item.aiScore ?? '-'}</Text></Col>
|
||||||
|
<Col span={8}><Text type="secondary" style={{ fontSize: 11 }}>管理层</Text><br /><Text>{item.managerScore ?? '-'}</Text></Col>
|
||||||
|
</Row>
|
||||||
|
{item.aiExplanation && <Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 4 }}>AI说明:{item.aiExplanation}</Text>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Descriptions column={3} size="small" bordered>
|
||||||
|
<Descriptions.Item label="员工填报内容" span={3}>{item.userContent || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="自评分">{item.selfScore ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="AI 评分">{item.aiScore ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="管理层评分">{item.managerScore ?? '-'}</Descriptions.Item>
|
||||||
|
{item.aiExplanation && <Descriptions.Item label="AI 评分说明" span={3}>{item.aiExplanation}</Descriptions.Item>}
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isCompleted && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Form.Item
|
||||||
|
name={['itemScores', item.itemName, 'managerScore']}
|
||||||
|
label="管理层评分"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请填写评分' },
|
||||||
|
{ type: 'number', min: 0, max: 100, message: '评分范围 0-100' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber min={0} max={100} style={{ width: 120 }} placeholder="0-100" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCompleted && item.managerExplanation && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Text type="secondary">管理层评分说明:</Text>
|
||||||
|
<Text>{item.managerExplanation}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 审核意见 */}
|
||||||
|
<Divider />
|
||||||
|
<Form.Item
|
||||||
|
name="reviewOpinion"
|
||||||
|
label="审核意见"
|
||||||
|
rules={[{ required: !isCompleted, message: '请填写审核意见' }]}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
rows={4}
|
||||||
|
placeholder="请填写对员工本月绩效的整体评价和改进建议"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
{!isCompleted && (
|
||||||
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', paddingBottom: 24 }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
icon={<CheckOutlined />}
|
||||||
|
onClick={handleApprove}
|
||||||
|
loading={submitting}
|
||||||
|
style={isMobile ? { flex: 1 } : {}}
|
||||||
|
>
|
||||||
|
审核通过
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
size="large"
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
onClick={() => setRejectModalOpen(true)}
|
||||||
|
loading={submitting}
|
||||||
|
style={isMobile ? { flex: 1 } : {}}
|
||||||
|
>
|
||||||
|
驳回
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 驳回弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title="驳回绩效"
|
||||||
|
open={rejectModalOpen}
|
||||||
|
onOk={handleReject}
|
||||||
|
onCancel={() => setRejectModalOpen(false)}
|
||||||
|
confirmLoading={submitting}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
rows={4}
|
||||||
|
placeholder="请填写驳回原因,员工将收到此通知"
|
||||||
|
value={rejectReason}
|
||||||
|
onChange={(e) => setRejectReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PerformanceReview;
|
||||||
266
src/pages/manager/SubordinateList.tsx
Normal file
266
src/pages/manager/SubordinateList.tsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Button,
|
||||||
|
DatePicker,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
Empty,
|
||||||
|
Card,
|
||||||
|
} from 'antd';
|
||||||
|
import { EyeOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { PerformanceRecord } from '../../api/performance';
|
||||||
|
import http from '../../api/http';
|
||||||
|
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||||
|
draft: { label: '草稿', color: 'default' },
|
||||||
|
submitted: { label: '已提交', color: 'processing' },
|
||||||
|
under_review: { label: '审核中', color: 'warning' },
|
||||||
|
completed: { label: '已完成', color: 'success' },
|
||||||
|
rejected: { label: '已驳回', color: 'error' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEVEL_MAP: Record<string, { label: string; color: string }> = {
|
||||||
|
excellent: { label: '优秀', color: 'gold' },
|
||||||
|
qualified: { label: '合格', color: 'green' },
|
||||||
|
need_motivation: { label: '需激励', color: 'orange' },
|
||||||
|
unqualified: { label: '不合格', color: 'red' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SubordinateRecord extends PerformanceRecord {
|
||||||
|
userName?: string;
|
||||||
|
userDepartment?: string;
|
||||||
|
userPosition?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const SubordinateList: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [records, setRecords] = useState<SubordinateRecord[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize] = useState(10);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [filterMonth, setFilterMonth] = useState<string | undefined>();
|
||||||
|
const [filterDepartment, setFilterDepartment] = useState<string | undefined>();
|
||||||
|
const [filterName, setFilterName] = useState<string | undefined>();
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const loadList = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: any = { page, pageSize };
|
||||||
|
if (filterMonth) params.month = filterMonth;
|
||||||
|
if (filterDepartment) params.department = filterDepartment;
|
||||||
|
if (filterName) params.name = filterName;
|
||||||
|
if (filterStatus) params.status = filterStatus;
|
||||||
|
|
||||||
|
const { data } = await http.get(
|
||||||
|
'/api/performance/manager/list',
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
// 后端返回格式: { code, message, data: { total, records } }
|
||||||
|
const result = data.data || data;
|
||||||
|
const list = result.records || result.list || [];
|
||||||
|
setRecords(list.map((rec: any) => ({
|
||||||
|
...rec,
|
||||||
|
userName: rec.employeeName || rec.userName,
|
||||||
|
userDepartment: rec.department || rec.userDepartment,
|
||||||
|
userPosition: rec.position || rec.userPosition,
|
||||||
|
})));
|
||||||
|
setTotal(result.total || 0);
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.response?.data?.message || '加载下属绩效列表失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, filterMonth, filterDepartment, filterName, filterStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadList();
|
||||||
|
}, [loadList]);
|
||||||
|
|
||||||
|
const handleReview = (record: SubordinateRecord) => {
|
||||||
|
navigate(`/manager/review/${record.perfId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnsType<SubordinateRecord> = [
|
||||||
|
{
|
||||||
|
title: '员工姓名',
|
||||||
|
dataIndex: 'userName',
|
||||||
|
key: 'userName',
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '部门',
|
||||||
|
dataIndex: 'userDepartment',
|
||||||
|
key: 'userDepartment',
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '岗位',
|
||||||
|
dataIndex: 'userPosition',
|
||||||
|
key: 'userPosition',
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '考核月份',
|
||||||
|
dataIndex: 'month',
|
||||||
|
key: 'month',
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (status: string) => {
|
||||||
|
const s = STATUS_MAP[status] || { label: status, color: 'default' };
|
||||||
|
return <Tag color={s.color}>{s.label}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '自评分',
|
||||||
|
dataIndex: 'selfScore',
|
||||||
|
key: 'selfScore',
|
||||||
|
width: 80,
|
||||||
|
render: (v?: number) => (v != null ? v.toFixed(1) : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'AI 评分',
|
||||||
|
dataIndex: 'aiScore',
|
||||||
|
key: 'aiScore',
|
||||||
|
width: 80,
|
||||||
|
render: (v?: number) => (v != null ? v.toFixed(1) : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最终总分',
|
||||||
|
dataIndex: 'totalScore',
|
||||||
|
key: 'totalScore',
|
||||||
|
width: 90,
|
||||||
|
render: (v?: number) => (v != null ? <strong>{v.toFixed(1)}</strong> : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '绩效等级',
|
||||||
|
dataIndex: 'level',
|
||||||
|
key: 'level',
|
||||||
|
width: 100,
|
||||||
|
render: (level?: string) => {
|
||||||
|
if (!level) return '-';
|
||||||
|
const l = LEVEL_MAP[level] || { label: level, color: 'default' };
|
||||||
|
return <Tag color={l.color}>{l.label}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '提交时间',
|
||||||
|
dataIndex: 'submitTime',
|
||||||
|
key: 'submitTime',
|
||||||
|
width: 150,
|
||||||
|
render: (v?: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 100,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, record) => (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => handleReview(record)}
|
||||||
|
disabled={record.status === 'draft'}
|
||||||
|
>
|
||||||
|
{record.status === 'completed' ? '查看' : '审核'}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Title level={3}>下属绩效列表</Title>
|
||||||
|
|
||||||
|
<Card style={{ marginBottom: 16 }}>
|
||||||
|
<Space wrap>
|
||||||
|
<DatePicker
|
||||||
|
picker="month"
|
||||||
|
placeholder="筛选月份"
|
||||||
|
onChange={(_, dateStr) => {
|
||||||
|
setFilterMonth((dateStr as string) || undefined);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
style={{ width: 150 }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="部门"
|
||||||
|
value={filterDepartment}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFilterDepartment(e.target.value || undefined);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
style={{ width: 150 }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="员工姓名"
|
||||||
|
value={filterName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFilterName(e.target.value || undefined);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
style={{ width: 150 }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="状态"
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={(value) => {
|
||||||
|
setFilterStatus(value || undefined);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
style={{ width: 150 }}
|
||||||
|
allowClear
|
||||||
|
>
|
||||||
|
<Select.Option value="submitted">已提交</Select.Option>
|
||||||
|
<Select.Option value="under_review">审核中</Select.Option>
|
||||||
|
<Select.Option value="completed">已完成</Select.Option>
|
||||||
|
<Select.Option value="rejected">已驳回</Select.Option>
|
||||||
|
</Select>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={loadList}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="perfId"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={records}
|
||||||
|
loading={loading}
|
||||||
|
scroll={{ x: 1200 }}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
onChange: setPage,
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
locale={{ emptyText: <Empty description="暂无下属绩效记录" /> }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SubordinateList;
|
||||||
160
src/pages/manager/TeamStatistics.tsx
Normal file
160
src/pages/manager/TeamStatistics.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Card, Row, Col, DatePicker, Button, Space, Typography, message, Spin, Progress } from 'antd';
|
||||||
|
import { TeamOutlined, TrophyOutlined, CheckCircleOutlined, WarningOutlined, DownloadOutlined, ReloadOutlined, CloseCircleOutlined, BarChartOutlined } from '@ant-design/icons';
|
||||||
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
|
import http from '../../api/http';
|
||||||
|
import { useIsMobile } from '../../hooks/useBreakpoint';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
interface TeamStats {
|
||||||
|
averageScore: number;
|
||||||
|
excellentCount: number;
|
||||||
|
qualifiedCount: number;
|
||||||
|
needMotivationCount: number;
|
||||||
|
unqualifiedCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamStatistics: React.FC = () => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [stats, setStats] = useState<TeamStats | null>(null);
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState<Dayjs>(dayjs());
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const loadStats = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await http.get('/api/statistics/team', {
|
||||||
|
params: { month: selectedMonth.format('YYYY-MM') },
|
||||||
|
});
|
||||||
|
// 后端返回 { code, message, data: stats },需要解包
|
||||||
|
const raw = data.data || data;
|
||||||
|
setStats({
|
||||||
|
averageScore: Number(raw.averageScore) || 0,
|
||||||
|
excellentCount: Number(raw.excellentCount) || 0,
|
||||||
|
qualifiedCount: Number(raw.qualifiedCount) || 0,
|
||||||
|
needMotivationCount: Number(raw.needMotivationCount) || 0,
|
||||||
|
unqualifiedCount: Number(raw.unqualifiedCount) || 0,
|
||||||
|
totalCount: Number(raw.totalCount) || 0,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.response?.data?.message || '加载团队统计数据失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedMonth]);
|
||||||
|
|
||||||
|
useEffect(() => { loadStats(); }, [loadStats]);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const month = selectedMonth.format('YYYY-MM');
|
||||||
|
const response = await http.get('/api/performance/export', {
|
||||||
|
params: { month, type: 'team' }, responseType: 'blob',
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', `团队绩效_${month}.xlsx`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
message.success('导出成功');
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.response?.data?.message || '导出失败');
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pct = (count: number) =>
|
||||||
|
safeStats.totalCount > 0 ? Math.round((count / safeStats.totalCount) * 100) : 0;
|
||||||
|
|
||||||
|
if (loading && !stats) {
|
||||||
|
return <div style={{ textAlign: 'center', padding: 100 }}><Spin size="large" /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeStats: TeamStats = stats ?? {
|
||||||
|
averageScore: 0, excellentCount: 0, qualifiedCount: 0,
|
||||||
|
needMotivationCount: 0, unqualifiedCount: 0, totalCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const levels = [
|
||||||
|
{ label: '优秀', desc: '≥ 90 分', count: safeStats.excellentCount, percent: pct(safeStats.excellentCount), color: '#faad14', bg: '#fffbe6', icon: <TrophyOutlined style={{ color: '#faad14', fontSize: 20 }} /> },
|
||||||
|
{ label: '合格', desc: '60-89 分', count: safeStats.qualifiedCount, percent: pct(safeStats.qualifiedCount), color: '#52c41a', bg: '#f6ffed', icon: <CheckCircleOutlined style={{ color: '#52c41a', fontSize: 20 }} /> },
|
||||||
|
{ label: '需激励', desc: '60-69 分', count: safeStats.needMotivationCount, percent: pct(safeStats.needMotivationCount), color: '#fa8c16', bg: '#fff7e6', icon: <WarningOutlined style={{ color: '#fa8c16', fontSize: 20 }} /> },
|
||||||
|
{ label: '不合格', desc: '< 60 分', count: safeStats.unqualifiedCount, percent: pct(safeStats.unqualifiedCount), color: '#f5222d', bg: '#fff1f0', icon: <CloseCircleOutlined style={{ color: '#f5222d', fontSize: 20 }} /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: isMobile ? 0 : 24 }}>
|
||||||
|
<Title level={isMobile ? 4 : 3}>团队绩效统计</Title>
|
||||||
|
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<DatePicker picker="month" value={selectedMonth} onChange={(d) => d && setSelectedMonth(d)} format="YYYY-MM" />
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={loadStats} loading={loading}>刷新</Button>
|
||||||
|
<Button type="primary" icon={<DownloadOutlined />} onClick={handleExport} loading={exporting}>导出 Excel</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{/* 总体统计 */}
|
||||||
|
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||||
|
{[
|
||||||
|
{ icon: <TeamOutlined style={{ fontSize: 18, color: '#1677ff' }} />, value: safeStats.totalCount, unit: '人', label: '总人数', color: undefined },
|
||||||
|
{ icon: <BarChartOutlined style={{ fontSize: 18, color: '#3f8600' }} />, value: safeStats.averageScore.toFixed(1), unit: '分', label: '平均分', color: '#3f8600' },
|
||||||
|
{ icon: <TrophyOutlined style={{ fontSize: 18, color: '#cf1322' }} />, value: `${pct(safeStats.excellentCount)}%`, unit: '', label: '优秀率', color: '#cf1322' },
|
||||||
|
].map((item, i) => (
|
||||||
|
<Col xs={8} key={i}>
|
||||||
|
<Card bodyStyle={{ padding: isMobile ? '10px 8px' : 20, textAlign: 'center' }}>
|
||||||
|
{item.icon && <div>{item.icon}</div>}
|
||||||
|
<div style={{ fontSize: isMobile ? 20 : 26, fontWeight: 700, color: item.color, lineHeight: 1.3 }}>
|
||||||
|
{item.value}{item.unit && <span style={{ fontSize: 12, fontWeight: 400 }}>{item.unit}</span>}
|
||||||
|
</div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>{item.label}</Text>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* 等级分布 */}
|
||||||
|
<Card title="绩效等级分布" bodyStyle={{ padding: isMobile ? 12 : 24 }}>
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
{levels.map(item => (
|
||||||
|
<Col xs={12} key={item.label}>
|
||||||
|
<Card size="small" style={{ background: item.bg, borderRadius: 8 }} bodyStyle={{ padding: 12 }}>
|
||||||
|
<Space align="center" style={{ marginBottom: 8 }}>
|
||||||
|
{item.icon}
|
||||||
|
<div>
|
||||||
|
<Text strong style={{ fontSize: isMobile ? 13 : 14 }}>{item.label}</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>{item.desc}</Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
<Row align="middle" justify="space-between" wrap={false}>
|
||||||
|
<Col>
|
||||||
|
<span style={{ fontSize: isMobile ? 20 : 24, fontWeight: 700 }}>{item.count}</span>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}> 人</Text>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Progress type="circle" percent={item.percent} width={isMobile ? 44 : 56} strokeColor={item.color} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card size="small" style={{ marginTop: 12 }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
统计范围:{selectedMonth.format('YYYY年MM月')} 已完成审核的下属员工绩效数据
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TeamStatistics;
|
||||||
84
src/router/index.tsx
Normal file
84
src/router/index.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
// Lazy-loaded placeholders — real pages will be added in tasks 13-15
|
||||||
|
const EmployeeDashboard = React.lazy(() => import('../pages/employee/Dashboard'));
|
||||||
|
const ManagerDashboard = React.lazy(() => import('../pages/manager/Dashboard'));
|
||||||
|
const GMDashboard = React.lazy(() => import('../pages/gm/Dashboard'));
|
||||||
|
const LoginPage = React.lazy(() => import('../pages/Login'));
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
allowedRole: 'employee' | 'manager' | 'generalManager';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children, allowedRole }) => {
|
||||||
|
const { token, userInfo } = useAuth();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userInfo?.role !== allowedRole) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AppRouter: React.FC = () => {
|
||||||
|
const { userInfo, token } = useAuth();
|
||||||
|
|
||||||
|
// Redirect root to role-specific home or login
|
||||||
|
const getRootRedirect = () => {
|
||||||
|
if (!token || !userInfo) return '/login';
|
||||||
|
if (userInfo.role === 'employee') return '/employee';
|
||||||
|
if (userInfo.role === 'manager') return '/manager';
|
||||||
|
if (userInfo.role === 'generalManager') return '/gm';
|
||||||
|
return '/login';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Suspense fallback={<div>加载中...</div>}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|
||||||
|
{/* 员工路由 */}
|
||||||
|
<Route
|
||||||
|
path="/employee/*"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute allowedRole="employee">
|
||||||
|
<EmployeeDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 管理层路由 */}
|
||||||
|
<Route
|
||||||
|
path="/manager/*"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute allowedRole="manager">
|
||||||
|
<ManagerDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 总经理路由 */}
|
||||||
|
<Route
|
||||||
|
path="/gm/*"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute allowedRole="generalManager">
|
||||||
|
<GMDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route path="/" element={<Navigate to={getRootRedirect()} replace />} />
|
||||||
|
<Route path="*" element={<Navigate to={getRootRedirect()} replace />} />
|
||||||
|
</Routes>
|
||||||
|
</React.Suspense>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppRouter;
|
||||||
9
src/vite-env.d.ts
vendored
Normal file
9
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
1
tmpclaude-37f2-cwd
Normal file
1
tmpclaude-37f2-cwd
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/c/Users/99095/Desktop/优一科技/performance-evaluation-system/frontend
|
||||||
1
tmpclaude-3b05-cwd
Normal file
1
tmpclaude-3b05-cwd
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/c/Users/99095/Desktop/优一科技/performance-evaluation-system/frontend
|
||||||
1
tmpclaude-4b38-cwd
Normal file
1
tmpclaude-4b38-cwd
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/c/Users/99095/Desktop/优一科技/performance-evaluation-system/frontend
|
||||||
1
tmpclaude-930e-cwd
Normal file
1
tmpclaude-930e-cwd
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/c/Users/99095/Desktop/优一科技/performance-evaluation-system/frontend
|
||||||
1
tmpclaude-96c1-cwd
Normal file
1
tmpclaude-96c1-cwd
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/c/Users/99095/Desktop/优一科技/performance-evaluation-system/frontend
|
||||||
1
tmpclaude-9780-cwd
Normal file
1
tmpclaude-9780-cwd
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/c/Users/99095/Desktop/优一科技/performance-evaluation-system/frontend
|
||||||
1
tmpclaude-a31e-cwd
Normal file
1
tmpclaude-a31e-cwd
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/c/Users/99095/Desktop/优一科技/performance-evaluation-system/frontend
|
||||||
1
tmpclaude-e993-cwd
Normal file
1
tmpclaude-e993-cwd
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/c/Users/99095/Desktop/优一科技/performance-evaluation-system/frontend
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
15
vite.config.ts
Normal file
15
vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user