first commit

This commit is contained in:
2026-04-11 11:51:54 +08:00
commit b12a84e388
99 changed files with 19620 additions and 0 deletions

4
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.log

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

3136
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View 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
frontend/src/App.tsx Normal file
View 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
frontend/src/api/auth.ts Normal file
View 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
frontend/src/api/http.ts Normal file
View 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 || 'http://localhost:3001',
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;

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

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

View 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
frontend/src/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
frontend/src/img/logo2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

10
frontend/src/main.tsx Normal file
View 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>
);

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,272 @@
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;
}
interface PageResult<T> {
list: T[];
total: number;
page: number;
pageSize: number;
}
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;

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

View 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
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

21
frontend/tsconfig.json Normal file
View 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" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

15
frontend/vite.config.ts Normal file
View 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,
},
},
},
});