first commit
This commit is contained in:
729
.kiro/specs/employee-performance-system/design.md
Normal file
729
.kiro/specs/employee-performance-system/design.md
Normal file
@@ -0,0 +1,729 @@
|
||||
# 设计文档 - 员工月度绩效考核系统
|
||||
|
||||
## 概述
|
||||
|
||||
员工月度绩效考核系统是一个基于 Web 的全栈应用,采用前后端分离架构。系统集成 FastGPT AI 评分能力,实现员工绩效填报、AI 自动评分、管理层审核、数据统计分析的完整闭环。
|
||||
|
||||
### 技术栈选择
|
||||
|
||||
**后端:**
|
||||
- Node.js + TypeScript
|
||||
- Express.js 框架
|
||||
- MySQL 数据库
|
||||
- JWT 身份认证
|
||||
- Axios(FastGPT API 调用)
|
||||
|
||||
**前端:**
|
||||
- React + TypeScript
|
||||
- Ant Design UI 组件库
|
||||
- React Router(路由管理)
|
||||
- Axios(HTTP 请求)
|
||||
- ECharts(数据可视化)
|
||||
|
||||
### 核心设计原则
|
||||
|
||||
1. **角色权限分离**: 严格按照员工、管理层、总经理三种角色进行权限控制
|
||||
2. **数据安全**: 敏感数据加密存储,操作日志完整记录
|
||||
3. **流程自动化**: 绩效提交自动触发 AI 评分,审核完成自动归档
|
||||
4. **可扩展性**: 模块化设计,便于后续功能扩展
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 系统架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "前端层"
|
||||
A[员工端页面]
|
||||
B[管理层端页面]
|
||||
C[总经理端页面]
|
||||
end
|
||||
|
||||
subgraph "API 网关层"
|
||||
D[Express API Server]
|
||||
E[JWT 认证中间件]
|
||||
F[权限验证中间件]
|
||||
end
|
||||
|
||||
subgraph "业务逻辑层"
|
||||
G[用户服务]
|
||||
H[绩效服务]
|
||||
I[AI 评分服务]
|
||||
J[统计分析服务]
|
||||
end
|
||||
|
||||
subgraph "数据访问层"
|
||||
K[用户 DAO]
|
||||
L[绩效 DAO]
|
||||
M[AI 结果 DAO]
|
||||
end
|
||||
|
||||
subgraph "外部服务"
|
||||
N[FastGPT API]
|
||||
end
|
||||
|
||||
subgraph "数据存储层"
|
||||
O[(MySQL 数据库)]
|
||||
end
|
||||
|
||||
A --> D
|
||||
B --> D
|
||||
C --> D
|
||||
D --> E
|
||||
E --> F
|
||||
F --> G
|
||||
F --> H
|
||||
F --> J
|
||||
H --> I
|
||||
I --> N
|
||||
G --> K
|
||||
H --> L
|
||||
I --> M
|
||||
K --> O
|
||||
L --> O
|
||||
M --> O
|
||||
```
|
||||
|
||||
### 数据流设计
|
||||
|
||||
**绩效提交流程:**
|
||||
```
|
||||
员工填报 → 前端验证 → API 提交 → 保存数据库 → 触发 AI 评分 → 调用 FastGPT → 解析结果 → 存储 AI 结果 → 返回成功
|
||||
```
|
||||
|
||||
**绩效审核流程:**
|
||||
```
|
||||
管理层查看 → 加载绩效+AI结果 → 调整评分 → 填写意见 → 提交审核 → 计算等级奖惩 → 归档数据 → 通知员工
|
||||
```
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 后端核心模块
|
||||
|
||||
#### 1. 用户认证模块 (AuthService)
|
||||
|
||||
**职责**: 处理用户登录、令牌生成与验证
|
||||
|
||||
**接口:**
|
||||
```typescript
|
||||
interface AuthService {
|
||||
// 用户登录
|
||||
login(username: string, password: string, role: string): Promise<LoginResult>;
|
||||
|
||||
// 验证令牌
|
||||
verifyToken(token: string): Promise<UserInfo>;
|
||||
|
||||
// 刷新令牌
|
||||
refreshToken(token: string): Promise<string>;
|
||||
}
|
||||
|
||||
interface LoginResult {
|
||||
token: string;
|
||||
userInfo: UserInfo;
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
userId: number;
|
||||
name: string;
|
||||
role: 'employee' | 'manager' | 'generalManager';
|
||||
department: string;
|
||||
position: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 绩效管理模块 (PerformanceService)
|
||||
|
||||
**职责**: 处理绩效填报、查询、审核等核心业务逻辑
|
||||
|
||||
**接口:**
|
||||
```typescript
|
||||
interface PerformanceService {
|
||||
// 提交绩效(暂存或提交)
|
||||
submitPerformance(data: PerformanceSubmitDTO): Promise<PerformanceResult>;
|
||||
|
||||
// 查询员工个人绩效
|
||||
getEmployeePerformance(userId: number, month?: string, page?: PageInfo): Promise<PerformanceListResult>;
|
||||
|
||||
// 查询管理层下属绩效
|
||||
getManagerSubordinates(managerId: number, filters: PerformanceFilter, page: PageInfo): Promise<PerformanceListResult>;
|
||||
|
||||
// 审核绩效
|
||||
reviewPerformance(perfId: number, reviewData: ReviewDTO): Promise<void>;
|
||||
|
||||
// 驳回绩效
|
||||
rejectPerformance(perfId: number, reason: string): Promise<void>;
|
||||
|
||||
// 申请修改绩效
|
||||
requestModification(perfId: number, reason: string): Promise<void>;
|
||||
|
||||
// 批准修改申请
|
||||
approveModification(perfId: number): Promise<void>;
|
||||
}
|
||||
|
||||
interface PerformanceSubmitDTO {
|
||||
userId: number;
|
||||
month: string;
|
||||
status: 'draft' | 'submit';
|
||||
selfScore: number;
|
||||
attendance: AttendanceData;
|
||||
workSummary: string;
|
||||
performanceItems: PerformanceItemDTO[];
|
||||
}
|
||||
|
||||
interface PerformanceItemDTO {
|
||||
itemName: string;
|
||||
weight: number;
|
||||
userContent: string;
|
||||
selfScore: number;
|
||||
evidence?: string;
|
||||
}
|
||||
|
||||
interface AttendanceData {
|
||||
leave: number;
|
||||
late: number;
|
||||
absent: number;
|
||||
lackCard: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
interface ReviewDTO {
|
||||
perfId: number;
|
||||
managerScore: number;
|
||||
reviewOpinion: string;
|
||||
itemScores: ItemScoreDTO[];
|
||||
}
|
||||
|
||||
interface ItemScoreDTO {
|
||||
itemName: string;
|
||||
managerScore: number;
|
||||
scoreExplanation: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. AI 评分模块 (AIEvaluationService)
|
||||
|
||||
**职责**: 调用 FastGPT API 进行自动评分和反馈生成
|
||||
|
||||
**接口:**
|
||||
```typescript
|
||||
interface AIEvaluationService {
|
||||
// 执行 AI 评分
|
||||
evaluatePerformance(perfId: number): Promise<AIResult>;
|
||||
|
||||
// 构建 AI 请求 Prompt
|
||||
buildPrompt(performance: PerformanceRecord): string;
|
||||
|
||||
// 解析 AI 响应
|
||||
parseAIResponse(response: string): AIScoreData;
|
||||
|
||||
// 重试机制
|
||||
retryEvaluation(perfId: number, maxRetries: number): Promise<AIResult>;
|
||||
}
|
||||
|
||||
interface AIResult {
|
||||
aiId: number;
|
||||
perfId: number;
|
||||
aiScoreDetail: AIScoreItem[];
|
||||
aiTotalScore: number;
|
||||
aiProblems: string[];
|
||||
aiSuggestions: string[];
|
||||
createTime: Date;
|
||||
}
|
||||
|
||||
interface AIScoreItem {
|
||||
itemName: string;
|
||||
weight: number;
|
||||
aiScore: number;
|
||||
scoreExplanation: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 统计分析模块 (StatisticsService)
|
||||
|
||||
**职责**: 提供多维度数据统计和报表生成
|
||||
|
||||
**接口:**
|
||||
```typescript
|
||||
interface StatisticsService {
|
||||
// 获取团队统计
|
||||
getTeamStatistics(managerId: number, month: string): Promise<TeamStats>;
|
||||
|
||||
// 获取全公司统计
|
||||
getCompanyStatistics(month: string): Promise<CompanyStats>;
|
||||
|
||||
// 多维度统计
|
||||
getMultiDimensionStats(filters: StatFilter): Promise<MultiDimensionStats>;
|
||||
|
||||
// 导出 Excel
|
||||
exportToExcel(filters: ExportFilter): Promise<Buffer>;
|
||||
}
|
||||
|
||||
interface TeamStats {
|
||||
averageScore: number;
|
||||
excellentCount: number;
|
||||
qualifiedCount: number;
|
||||
needMotivationCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
interface CompanyStats {
|
||||
departmentStats: DepartmentStat[];
|
||||
positionStats: PositionStat[];
|
||||
levelDistribution: LevelDistribution;
|
||||
}
|
||||
```
|
||||
|
||||
### 前端核心组件
|
||||
|
||||
#### 1. 员工端组件
|
||||
|
||||
```typescript
|
||||
// 绩效填报组件
|
||||
interface PerformanceFormComponent {
|
||||
props: {
|
||||
month: string;
|
||||
userInfo: UserInfo;
|
||||
};
|
||||
state: {
|
||||
formData: PerformanceFormData;
|
||||
isDraft: boolean;
|
||||
};
|
||||
methods: {
|
||||
handleItemChange(index: number, field: string, value: any): void;
|
||||
handleSave(): Promise<void>;
|
||||
handleSubmit(): Promise<void>;
|
||||
uploadEvidence(file: File): Promise<string>;
|
||||
};
|
||||
}
|
||||
|
||||
// 个人绩效查看组件
|
||||
interface PerformanceHistoryComponent {
|
||||
props: {
|
||||
userId: number;
|
||||
};
|
||||
state: {
|
||||
performanceList: PerformanceRecord[];
|
||||
selectedMonth: string;
|
||||
pagination: PaginationState;
|
||||
};
|
||||
methods: {
|
||||
loadPerformanceList(): Promise<void>;
|
||||
viewDetail(perfId: number): void;
|
||||
filterByMonth(month: string): void;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 管理层端组件
|
||||
|
||||
```typescript
|
||||
// 下属绩效列表组件
|
||||
interface SubordinateListComponent {
|
||||
props: {
|
||||
managerId: number;
|
||||
};
|
||||
state: {
|
||||
subordinateList: PerformanceRecord[];
|
||||
filters: PerformanceFilter;
|
||||
pagination: PaginationState;
|
||||
};
|
||||
methods: {
|
||||
loadSubordinateList(): Promise<void>;
|
||||
applyFilters(filters: PerformanceFilter): void;
|
||||
viewDetail(perfId: number): void;
|
||||
};
|
||||
}
|
||||
|
||||
// 绩效审核组件
|
||||
interface PerformanceReviewComponent {
|
||||
props: {
|
||||
perfId: number;
|
||||
};
|
||||
state: {
|
||||
performanceData: PerformanceRecord;
|
||||
aiResult: AIResult;
|
||||
reviewForm: ReviewFormData;
|
||||
};
|
||||
methods: {
|
||||
loadPerformanceDetail(): Promise<void>;
|
||||
adjustScore(itemName: string, score: number): void;
|
||||
submitReview(): Promise<void>;
|
||||
rejectPerformance(reason: string): Promise<void>;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 总经理端组件
|
||||
|
||||
```typescript
|
||||
// 全局统计组件
|
||||
interface CompanyStatisticsComponent {
|
||||
props: {
|
||||
month: string;
|
||||
};
|
||||
state: {
|
||||
companyStats: CompanyStats;
|
||||
chartData: ChartData;
|
||||
selectedDimension: string;
|
||||
};
|
||||
methods: {
|
||||
loadStatistics(): Promise<void>;
|
||||
switchDimension(dimension: string): void;
|
||||
exportData(): Promise<void>;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 数据库表设计
|
||||
|
||||
#### 1. 用户表 (user)
|
||||
|
||||
```sql
|
||||
CREATE TABLE user (
|
||||
user_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID',
|
||||
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名(工号)',
|
||||
password VARCHAR(255) NOT NULL COMMENT '密码(加密存储)',
|
||||
name VARCHAR(50) NOT NULL COMMENT '姓名',
|
||||
role ENUM('employee', 'manager', 'generalManager') NOT NULL COMMENT '角色',
|
||||
department VARCHAR(50) NOT NULL COMMENT '部门',
|
||||
position VARCHAR(50) NOT NULL COMMENT '岗位',
|
||||
manager_id INT COMMENT '直属管理层ID',
|
||||
status ENUM('active', 'inactive') DEFAULT 'active' COMMENT '状态',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_role (role),
|
||||
INDEX idx_manager (manager_id),
|
||||
FOREIGN KEY (manager_id) REFERENCES user(user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
|
||||
```
|
||||
|
||||
#### 2. 绩效主表 (performance_month)
|
||||
|
||||
```sql
|
||||
CREATE TABLE performance_month (
|
||||
perf_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '绩效记录ID',
|
||||
user_id INT NOT NULL COMMENT '员工ID',
|
||||
month VARCHAR(7) NOT NULL COMMENT '考核月份(YYYY-MM)',
|
||||
status ENUM('draft', 'submitted', 'under_review', 'completed', 'rejected') NOT NULL COMMENT '状态',
|
||||
self_score DECIMAL(5,2) COMMENT '员工自评总分',
|
||||
ai_score DECIMAL(5,2) COMMENT 'AI评分总分',
|
||||
manager_score DECIMAL(5,2) COMMENT '管理层审核总分',
|
||||
total_score DECIMAL(5,2) COMMENT '最终总分',
|
||||
level ENUM('excellent', 'qualified', 'need_motivation', 'unqualified') COMMENT '绩效等级',
|
||||
reward_punish VARCHAR(255) COMMENT '奖惩说明',
|
||||
work_summary TEXT COMMENT '工作汇总',
|
||||
submit_time TIMESTAMP COMMENT '提交时间',
|
||||
review_time TIMESTAMP COMMENT '审核时间',
|
||||
review_opinion TEXT COMMENT '审核意见',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_user_month (user_id, month),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_month (month),
|
||||
FOREIGN KEY (user_id) REFERENCES user(user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='绩效主表';
|
||||
```
|
||||
|
||||
#### 3. 绩效项明细表 (perf_item)
|
||||
|
||||
```sql
|
||||
CREATE TABLE perf_item (
|
||||
item_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '绩效项ID',
|
||||
perf_id INT NOT NULL COMMENT '绩效记录ID',
|
||||
item_name VARCHAR(100) NOT NULL COMMENT '考核项名称',
|
||||
item_category ENUM('business', 'comprehensive') NOT NULL COMMENT '考核项类别',
|
||||
weight INT NOT NULL COMMENT '权重(分数)',
|
||||
user_content TEXT COMMENT '员工填写内容',
|
||||
self_score DECIMAL(5,2) COMMENT '员工自评分',
|
||||
ai_score DECIMAL(5,2) COMMENT 'AI评分',
|
||||
ai_explanation TEXT COMMENT 'AI评分说明',
|
||||
manager_score DECIMAL(5,2) COMMENT '管理层评分',
|
||||
manager_explanation TEXT COMMENT '管理层评分说明',
|
||||
evidence_url VARCHAR(500) COMMENT '佐证材料URL',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_perf (perf_id),
|
||||
FOREIGN KEY (perf_id) REFERENCES performance_month(perf_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='绩效项明细表';
|
||||
```
|
||||
|
||||
#### 4. 考勤表 (attendance)
|
||||
|
||||
```sql
|
||||
CREATE TABLE attendance (
|
||||
attendance_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '考勤ID',
|
||||
perf_id INT NOT NULL COMMENT '绩效记录ID',
|
||||
leave_days INT DEFAULT 0 COMMENT '事假天数',
|
||||
late_times INT DEFAULT 0 COMMENT '迟到次数',
|
||||
absent_days INT DEFAULT 0 COMMENT '旷工天数',
|
||||
lack_card_times INT DEFAULT 0 COMMENT '缺卡次数',
|
||||
attendance_score DECIMAL(5,2) COMMENT '考勤得分',
|
||||
remark TEXT COMMENT '备注',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_perf (perf_id),
|
||||
FOREIGN KEY (perf_id) REFERENCES performance_month(perf_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考勤表';
|
||||
```
|
||||
|
||||
#### 5. AI 结果表 (ai_result)
|
||||
|
||||
```sql
|
||||
CREATE TABLE ai_result (
|
||||
ai_id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'AI结果ID',
|
||||
perf_id INT NOT NULL COMMENT '绩效记录ID',
|
||||
ai_score_json TEXT NOT NULL COMMENT 'AI评分详情(JSON格式)',
|
||||
ai_total_score DECIMAL(5,2) NOT NULL COMMENT 'AI总分',
|
||||
problems TEXT COMMENT 'AI总结的问题(JSON数组)',
|
||||
suggestions TEXT COMMENT 'AI改进建议(JSON数组)',
|
||||
api_response TEXT COMMENT 'FastGPT原始响应',
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '生成时间',
|
||||
UNIQUE KEY uk_perf (perf_id),
|
||||
FOREIGN KEY (perf_id) REFERENCES performance_month(perf_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI结果表';
|
||||
```
|
||||
|
||||
#### 6. 操作日志表 (operation_log)
|
||||
|
||||
```sql
|
||||
CREATE TABLE operation_log (
|
||||
log_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '日志ID',
|
||||
user_id INT NOT NULL COMMENT '操作人ID',
|
||||
operation_type VARCHAR(50) NOT NULL COMMENT '操作类型',
|
||||
target_type VARCHAR(50) COMMENT '目标类型',
|
||||
target_id INT COMMENT '目标ID',
|
||||
operation_detail TEXT COMMENT '操作详情',
|
||||
ip_address VARCHAR(50) COMMENT 'IP地址',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_created (created_at),
|
||||
FOREIGN KEY (user_id) REFERENCES user(user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';
|
||||
```
|
||||
|
||||
### 数据关系图
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
USER ||--o{ PERFORMANCE_MONTH : "has"
|
||||
USER ||--o{ USER : "manages"
|
||||
PERFORMANCE_MONTH ||--|{ PERF_ITEM : "contains"
|
||||
PERFORMANCE_MONTH ||--|| ATTENDANCE : "has"
|
||||
PERFORMANCE_MONTH ||--|| AI_RESULT : "has"
|
||||
USER ||--o{ OPERATION_LOG : "performs"
|
||||
|
||||
USER {
|
||||
int user_id PK
|
||||
string username
|
||||
string password
|
||||
string name
|
||||
enum role
|
||||
string department
|
||||
string position
|
||||
int manager_id FK
|
||||
}
|
||||
|
||||
PERFORMANCE_MONTH {
|
||||
int perf_id PK
|
||||
int user_id FK
|
||||
string month
|
||||
enum status
|
||||
decimal self_score
|
||||
decimal ai_score
|
||||
decimal manager_score
|
||||
decimal total_score
|
||||
enum level
|
||||
}
|
||||
|
||||
PERF_ITEM {
|
||||
int item_id PK
|
||||
int perf_id FK
|
||||
string item_name
|
||||
enum item_category
|
||||
int weight
|
||||
text user_content
|
||||
decimal self_score
|
||||
decimal ai_score
|
||||
decimal manager_score
|
||||
}
|
||||
|
||||
ATTENDANCE {
|
||||
int attendance_id PK
|
||||
int perf_id FK
|
||||
int leave_days
|
||||
int late_times
|
||||
int absent_days
|
||||
int lack_card_times
|
||||
decimal attendance_score
|
||||
}
|
||||
|
||||
AI_RESULT {
|
||||
int ai_id PK
|
||||
int perf_id FK
|
||||
text ai_score_json
|
||||
decimal ai_total_score
|
||||
text problems
|
||||
text suggestions
|
||||
}
|
||||
```
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*正确性属性是系统应该在所有有效执行中保持为真的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性作为人类可读规范和机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### 属性 1: 认证正确性
|
||||
|
||||
*对于任意* 用户凭据(用户名、密码、角色),认证结果应与凭据有效性严格一致——有效凭据必须返回 token,无效凭据必须被拒绝,不存在中间状态。
|
||||
|
||||
**Validates: Requirements 1.1, 1.2**
|
||||
|
||||
---
|
||||
|
||||
### 属性 2: 权限隔离不变量
|
||||
|
||||
*对于任意* 员工用户,使用其 token 查询其他员工的绩效数据时,系统应始终返回 403 权限不足错误,不泄露任何数据。
|
||||
|
||||
**Validates: Requirements 1.5**
|
||||
|
||||
---
|
||||
|
||||
### 属性 3: 草稿暂存往返一致性
|
||||
|
||||
*对于任意* 绩效填报数据,暂存后再读取,返回的数据应与暂存时提交的数据完全一致(往返属性)。
|
||||
|
||||
**Validates: Requirements 2.5**
|
||||
|
||||
---
|
||||
|
||||
### 属性 4: 提交幂等性
|
||||
|
||||
*对于任意* 已提交状态的绩效记录,再次尝试提交同一用户同一月份的绩效时,系统应拒绝并返回错误,绩效记录状态保持不变。
|
||||
|
||||
**Validates: Requirements 2.6**
|
||||
|
||||
---
|
||||
|
||||
### 属性 5: 绩效等级与奖惩计算正确性
|
||||
|
||||
*对于任意* 最终总分(0-100),系统计算出的绩效等级和奖惩金额应严格符合以下规则:
|
||||
- 分数 >= 90 → 优秀,奖励
|
||||
- 80 <= 分数 <= 89 → 合格,扣 100 元
|
||||
- 70 <= 分数 <= 79 → 合格,扣 200 元
|
||||
- 60 <= 分数 <= 69 → 需激励,扣 300 元
|
||||
- 分数 < 60 → 不合格,扣 600 元
|
||||
|
||||
**Validates: Requirements 5.1, 5.2, 5.3, 5.4, 5.5**
|
||||
|
||||
---
|
||||
|
||||
### 属性 6: 连续低分预警正确性
|
||||
|
||||
*对于任意* 员工,若其连续 N 个月(N >= 2)的最终总分均低于 60 分,系统应正确标记对应的预警状态(N=2 书面警告,N>=3 劝退处理)。
|
||||
|
||||
**Validates: Requirements 5.6, 5.7**
|
||||
|
||||
---
|
||||
|
||||
### 属性 7: 绩效记录查询完整性
|
||||
|
||||
*对于任意* 已提交或已完成的绩效记录,员工查询个人历史绩效时,该记录必须出现在结果列表中,且内容与提交时一致(插入/查询往返属性)。
|
||||
|
||||
**Validates: Requirements 6.1, 6.2**
|
||||
|
||||
---
|
||||
|
||||
### 属性 8: 考勤分数计算正确性
|
||||
|
||||
*对于任意* 考勤数据(事假天数、迟到次数、缺卡次数),系统计算的考勤分数应满足:
|
||||
- 基础分 10 分
|
||||
- 每天事假扣 5 分
|
||||
- 每次迟到/缺卡扣 2 分
|
||||
- 最终分数不低于 0 分(下限保护)
|
||||
|
||||
**Validates: Requirements 11.1, 11.2, 11.3, 11.5**
|
||||
|
||||
---
|
||||
|
||||
### 属性 9: AI 响应 JSON 解析往返一致性
|
||||
|
||||
*对于任意* 符合规范的 AI 评分 JSON 字符串,系统解析后再序列化,应得到语义等价的对象(解析往返属性)。
|
||||
|
||||
**Validates: Requirements 3.6, 13.3**
|
||||
|
||||
---
|
||||
|
||||
### 属性 10: AI 输出格式约束
|
||||
|
||||
*对于任意* AI 评分结果,`ai_problems` 和 `ai_suggestions` 数组的长度应在 3 到 5 之间(含边界值)。
|
||||
|
||||
**Validates: Requirements 3.3, 3.4**
|
||||
|
||||
---
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 错误码规范
|
||||
|
||||
| 状态码 | 含义 | 场景 |
|
||||
|--------|------|------|
|
||||
| 200 | 成功 | 正常响应 |
|
||||
| 400 | 参数错误 | 请求参数缺失或格式错误 |
|
||||
| 401 | 未登录/Token 失效 | Token 过期或未携带 |
|
||||
| 403 | 权限不足 | 越权访问他人数据 |
|
||||
| 500 | 服务器异常 | 内部错误 |
|
||||
|
||||
### 关键错误场景处理
|
||||
|
||||
**AI 调用失败:**
|
||||
- 超时(>10s):记录错误日志,绩效状态标记为 `ai_failed`,管理员可手动触发重试
|
||||
- 返回格式异常:记录原始响应,尝试降级解析,失败则通知管理员
|
||||
|
||||
**数据库操作失败:**
|
||||
- 使用事务保证绩效提交的原子性(绩效主表 + 绩效项 + 考勤数据同时写入)
|
||||
- 失败时回滚并返回 500 错误
|
||||
|
||||
**并发提交冲突:**
|
||||
- 利用 `UNIQUE KEY uk_user_month (user_id, month)` 数据库约束防止重复提交
|
||||
- 捕获唯一键冲突异常,返回友好提示
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 双重测试方法
|
||||
|
||||
系统采用单元测试和属性测试相结合的方式,两者互补:
|
||||
|
||||
- **单元测试**: 验证具体示例、边界条件和错误场景
|
||||
- **属性测试**: 验证跨所有输入的通用属性
|
||||
|
||||
### 属性测试配置
|
||||
|
||||
- 使用 **fast-check**(TypeScript 属性测试库)
|
||||
- 每个属性测试最少运行 **100 次迭代**
|
||||
- 每个测试用注释标注对应的设计属性编号
|
||||
- 标注格式: `// Feature: employee-performance-system, Property N: <属性描述>`
|
||||
|
||||
### 属性测试覆盖
|
||||
|
||||
| 属性编号 | 测试内容 | 测试类型 |
|
||||
|----------|----------|----------|
|
||||
| 属性 1 | 认证凭据有效性 | property |
|
||||
| 属性 2 | 权限隔离 | property |
|
||||
| 属性 3 | 草稿暂存往返 | property |
|
||||
| 属性 4 | 提交幂等性 | property |
|
||||
| 属性 5 | 等级奖惩计算 | property |
|
||||
| 属性 6 | 连续低分预警 | property |
|
||||
| 属性 7 | 绩效记录查询 | property |
|
||||
| 属性 8 | 考勤分数计算 | property |
|
||||
| 属性 9 | AI JSON 解析往返 | property |
|
||||
| 属性 10 | AI 输出格式约束 | property |
|
||||
|
||||
### 单元测试覆盖
|
||||
|
||||
- 用户登录接口(具体示例:正确凭据、错误密码、不存在用户)
|
||||
- 绩效提交接口(具体示例:暂存、提交、重复提交)
|
||||
- 审核流程(具体示例:通过、驳回、修改申请)
|
||||
- Excel 导出格式验证
|
||||
- 分页查询边界条件(第一页、最后一页、空结果)
|
||||
|
||||
188
.kiro/specs/employee-performance-system/requirements.md
Normal file
188
.kiro/specs/employee-performance-system/requirements.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# 需求文档 - 员工月度绩效考核系统
|
||||
|
||||
## 简介
|
||||
|
||||
优一科技员工月度绩效考核系统是一个用于规范化、自动化管理员工月度绩效考核的系统。系统覆盖员工、管理层、总经理三类角色,以月度为周期进行绩效考核,集成 AI 自动评分功能,实现绩效填报、审核、统计分析的全流程管理。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **System**: 员工月度绩效考核系统
|
||||
- **Employee**: 员工角色,负责填报个人绩效
|
||||
- **Manager**: 管理层角色,负责审核下属员工绩效
|
||||
- **General_Manager**: 总经理角色,负责全公司绩效查看和统计分析
|
||||
- **Performance_Record**: 绩效记录,包含员工某月的完整绩效数据
|
||||
- **AI_Evaluator**: AI 评分模块,基于 FastGPT 实现自动打分和建议生成
|
||||
- **Business_Quality**: 业务素质考评,占总分 70%
|
||||
- **Comprehensive_Quality**: 综合素质考评,占总分 30%
|
||||
- **Performance_Cycle**: 绩效考核周期,为自然月
|
||||
- **Submission_Period**: 提交期,每月 1-5 日
|
||||
- **Review_Period**: 审核期,每月 6-10 日
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1: 用户认证与权限管理
|
||||
|
||||
**用户故事:** 作为系统用户,我希望能够安全登录系统并根据我的角色访问相应的功能,以便保护数据安全和隐私。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 用户提供有效的用户名、密码和角色信息 THEN THE System SHALL 验证凭据并返回有效的访问令牌
|
||||
2. WHEN 用户提供无效的登录凭据 THEN THE System SHALL 拒绝访问并返回明确的错误信息
|
||||
3. WHEN 用户访问需要权限的资源 THEN THE System SHALL 验证用户角色并仅允许授权访问
|
||||
4. WHEN 访问令牌过期(24小时后)THEN THE System SHALL 要求用户重新登录
|
||||
5. THE System SHALL 确保员工仅能访问个人数据,管理层仅能访问下属数据,总经理可访问全量数据
|
||||
|
||||
### 需求 2: 员工绩效填报
|
||||
|
||||
**用户故事:** 作为员工,我希望能够在每月 1-5 日填写上月绩效内容,以便完成月度绩效考核。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 员工在提交期(每月 1-5 日)访问填报页面 THEN THE System SHALL 显示当前考核月份和员工基础信息
|
||||
2. WHEN 员工填写绩效内容 THEN THE System SHALL 要求填写 17 项考核指标(业务素质 9 项 + 综合素质 8 项)的完成情况描述和自评分数
|
||||
3. WHEN 员工填写考勤数据 THEN THE System SHALL 记录事假、病假、迟到、旷工、缺卡的具体次数及说明
|
||||
4. WHEN 员工上传佐证材料 THEN THE System SHALL 支持上传文件(截图、文档、代码片段等)作为可选附件
|
||||
5. WHEN 员工选择暂存 THEN THE System SHALL 保存草稿并允许后续继续编辑
|
||||
6. WHEN 员工选择提交 THEN THE System SHALL 锁定表单并触发 AI 评分流程
|
||||
7. WHEN 员工提交后需要修改 THEN THE System SHALL 要求员工向管理层申请退回修改
|
||||
|
||||
### 需求 3: AI 自动评分与反馈
|
||||
|
||||
**用户故事:** 作为系统,我需要在员工提交绩效后自动调用 AI 进行评分和反馈生成,以便提供客观的初步评估。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 员工提交绩效记录 THEN THE System SHALL 自动调用 FastGPT API 进行 AI 评分
|
||||
2. WHEN AI 评分完成 THEN THE System SHALL 存储每个考核项的 AI 评分、评分说明、总分(业务素质 70% + 综合素质 30%)
|
||||
3. WHEN AI 分析完成 THEN THE System SHALL 生成 3-5 条核心问题总结
|
||||
4. WHEN AI 分析完成 THEN THE System SHALL 生成 3-5 条具体可落地的改进建议
|
||||
5. WHEN AI 调用失败或超时(>10秒)THEN THE System SHALL 记录错误并通知管理员
|
||||
6. THE System SHALL 确保 AI 返回的数据格式为标准 JSON 并可被系统正确解析
|
||||
|
||||
### 需求 4: 管理层绩效审核
|
||||
|
||||
**用户故事:** 作为管理层,我希望能够在每月 6-10 日审核下属员工的绩效,以便完成最终评分和反馈。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 管理层在审核期(每月 6-10 日)访问审核页面 THEN THE System SHALL 显示所有下属员工的绩效提交列表
|
||||
2. WHEN 管理层查看员工绩效 THEN THE System SHALL 显示员工填报内容、自评分数、AI 评分及 AI 反馈
|
||||
3. WHEN 管理层调整评分 THEN THE System SHALL 允许修改每个考核项的分数并要求填写调整原因
|
||||
4. WHEN 管理层填写审核意见 THEN THE System SHALL 记录详细的审核意见说明
|
||||
5. WHEN 管理层审核通过 THEN THE System SHALL 计算最终总分、确定绩效等级和奖惩说明,并归档绩效记录
|
||||
6. WHEN 管理层驳回绩效 THEN THE System SHALL 要求填写驳回原因并通知员工修改
|
||||
7. WHEN 管理层审核完成 THEN THE System SHALL 锁定绩效记录不可随意修改
|
||||
|
||||
### 需求 5: 绩效等级与奖惩计算
|
||||
|
||||
**用户故事:** 作为系统,我需要根据最终总分自动计算绩效等级和奖惩金额,以便明确员工的考核结果。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 最终总分 >= 90 分 THEN THE System SHALL 标记为"优秀"等级并说明按公司规定给予奖励
|
||||
2. WHEN 最终总分在 80-89 分 THEN THE System SHALL 标记为"合格"等级并说明扣除当月绩效工资 100 元
|
||||
3. WHEN 最终总分在 70-79 分 THEN THE System SHALL 标记为"合格"等级并说明扣除当月绩效工资 200 元
|
||||
4. WHEN 最终总分在 60-69 分 THEN THE System SHALL 标记为"需激励"等级并说明扣除当月绩效工资 300 元
|
||||
5. WHEN 最终总分 < 60 分 THEN THE System SHALL 标记为"不合格"等级并说明扣除当月绩效工资 600 元
|
||||
6. WHEN 员工连续 2 个月考核分数低于 60 分 THEN THE System SHALL 标记需要书面警告
|
||||
7. WHEN 员工连续 3 个月考核分数低于 60 分 THEN THE System SHALL 标记需要劝退处理
|
||||
|
||||
### 需求 6: 个人绩效查看
|
||||
|
||||
**用户故事:** 作为员工,我希望能够查看我的历史绩效记录和 AI 反馈,以便了解自己的工作表现和改进方向。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 员工访问个人绩效页面 THEN THE System SHALL 显示所有历史月份的绩效记录列表
|
||||
2. WHEN 员工查看某月绩效详情 THEN THE System SHALL 显示填报内容、自评分数、AI 评分、管理层评分、最终总分和等级
|
||||
3. WHEN 员工查看 AI 反馈 THEN THE System SHALL 显示 AI 总结的问题和改进建议
|
||||
4. WHEN 员工查看奖惩说明 THEN THE System SHALL 显示当月绩效对应的奖惩情况
|
||||
5. THE System SHALL 支持按月份筛选和分页查看历史绩效
|
||||
|
||||
### 需求 7: 管理层数据查看与导出
|
||||
|
||||
**用户故事:** 作为管理层,我希望能够查看和导出下属员工的绩效数据,以便进行团队管理和统计分析。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 管理层访问团队绩效页面 THEN THE System SHALL 显示下属员工的绩效列表
|
||||
2. WHEN 管理层筛选数据 THEN THE System SHALL 支持按考核月份、部门、员工姓名、绩效状态进行筛选
|
||||
3. WHEN 管理层导出数据 THEN THE System SHALL 生成 Excel 格式的绩效表
|
||||
4. WHEN 管理层查看团队统计 THEN THE System SHALL 显示团队绩效平均分
|
||||
5. WHEN 管理层查看团队统计 THEN THE System SHALL 显示优秀、合格、需激励员工的人数及占比
|
||||
6. THE System SHALL 支持导出单个员工的历史绩效或所有下属的当月/指定时间段绩效
|
||||
|
||||
### 需求 8: 总经理全局管理
|
||||
|
||||
**用户故事:** 作为总经理,我希望能够查看全公司的绩效数据并进行多维度统计分析,以便掌握公司整体绩效水平。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 总经理访问全局绩效页面 THEN THE System SHALL 显示各部门、各岗位的月度绩效整体情况
|
||||
2. WHEN 总经理进行统计分析 THEN THE System SHALL 支持按考核月份、部门、岗位、绩效等级等维度进行统计
|
||||
3. WHEN 总经理查看统计报表 THEN THE System SHALL 生成可视化图表(柱状图、饼图等)
|
||||
4. WHEN 总经理导出数据 THEN THE System SHALL 支持导出全公司所有员工的所有月份绩效数据
|
||||
5. WHEN 总经理配置考核规则 THEN THE System SHALL 允许修改考核指标权重、评分标准、奖惩金额
|
||||
6. WHEN 考核规则修改后 THEN THE System SHALL 自动应用于后续考核周期
|
||||
|
||||
### 需求 9: 数据持久化与历史追溯
|
||||
|
||||
**用户故事:** 作为系统管理员,我需要确保所有绩效数据被安全存储并可追溯,以便满足审计和合规要求。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 绩效数据生成或修改 THEN THE System SHALL 将数据持久化存储到 MySQL 数据库
|
||||
2. WHEN 用户查询历史数据 THEN THE System SHALL 能够检索任意历史月份的绩效记录
|
||||
3. WHEN 绩效数据被修改 THEN THE System SHALL 记录修改人、修改时间、修改内容
|
||||
4. WHEN 管理层审核完成 THEN THE System SHALL 将绩效数据归档并标记为不可随意修改
|
||||
5. THE System SHALL 对员工个人信息、绩效奖惩等敏感数据进行加密存储
|
||||
6. THE System SHALL 支持数据备份与恢复功能
|
||||
|
||||
### 需求 10: 系统性能与稳定性
|
||||
|
||||
**用户故事:** 作为系统用户,我希望系统运行稳定、响应迅速,以便高效完成绩效考核工作。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 用户访问页面 THEN THE System SHALL 在 2 秒内完成页面加载
|
||||
2. WHEN 员工提交绩效 THEN THE System SHALL 确保提交成功率 >= 99%
|
||||
3. WHEN AI 评分被触发 THEN THE System SHALL 在 10 秒内返回评分结果
|
||||
4. WHEN 管理层审核绩效 THEN THE System SHALL 确保审核操作成功率 >= 99%
|
||||
5. THE System SHALL 支持主流 PC 浏览器(Chrome、Edge、Firefox)
|
||||
6. WHEN 系统出现异常 THEN THE System SHALL 记录错误日志并提供友好的错误提示
|
||||
|
||||
### 需求 11: 考勤数据处理
|
||||
|
||||
**用户故事:** 作为系统,我需要根据员工填写的考勤数据自动计算考勤分数,以便准确评估综合素质。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 员工全勤(无事假、迟到、缺卡、旷工)THEN THE System SHALL 给予考勤满分 10 分
|
||||
2. WHEN 员工有事假记录 THEN THE System SHALL 每天扣除 5 分
|
||||
3. WHEN 员工有迟到或缺卡记录 THEN THE System SHALL 每次扣除 2 分
|
||||
4. WHEN 员工有旷工记录 THEN THE System SHALL 按公司制度另行处理
|
||||
5. THE System SHALL 确保考勤分数不低于 0 分
|
||||
|
||||
### 需求 12: 绩效修改申请流程
|
||||
|
||||
**用户故事:** 作为员工,当我提交绩效后发现需要修改时,我希望能够向管理层申请退回修改,以便更正错误信息。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 员工提交绩效后需要修改 THEN THE System SHALL 提供"申请修改"功能
|
||||
2. WHEN 员工发起修改申请 THEN THE System SHALL 通知所属管理层
|
||||
3. WHEN 管理层同意修改申请 THEN THE System SHALL 解锁绩效表单并允许员工重新编辑
|
||||
4. WHEN 管理层拒绝修改申请 THEN THE System SHALL 通知员工并说明拒绝原因
|
||||
5. WHEN 员工修改完成后 THEN THE System SHALL 要求重新提交并触发新的 AI 评分
|
||||
|
||||
### 需求 13: FastGPT 集成
|
||||
|
||||
**用户故事:** 作为系统开发者,我需要正确集成 FastGPT API,以便实现 AI 自动评分功能。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 系统调用 FastGPT API THEN THE System SHALL 使用正确的 API-Key 和模型配置
|
||||
2. WHEN 构建 AI 请求 THEN THE System SHALL 包含员工岗位、考核月份、填报内容、考核规则等完整参数
|
||||
3. WHEN 接收 AI 响应 THEN THE System SHALL 验证返回的 JSON 格式是否符合预期结构
|
||||
4. WHEN AI 返回无效数据 THEN THE System SHALL 记录错误并提供降级处理方案
|
||||
5. THE System SHALL 确保 AI 调用的安全性,防止敏感信息泄露
|
||||
192
.kiro/specs/employee-performance-system/tasks.md
Normal file
192
.kiro/specs/employee-performance-system/tasks.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# 实施计划 - 员工月度绩效考核系统
|
||||
|
||||
## 概述
|
||||
|
||||
按照前后端分离架构,分阶段实施:先搭建后端基础设施和核心业务逻辑,再实现前端各角色页面,最后集成 AI 评分和统计分析功能。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 项目初始化与基础设施搭建
|
||||
- 初始化后端 Node.js + TypeScript + Express 项目结构
|
||||
- 初始化前端 React + TypeScript + Ant Design 项目结构
|
||||
- 配置 MySQL 数据库连接(使用 mysql2 驱动)
|
||||
- 创建数据库初始化脚本(6张表的 DDL)
|
||||
- 配置环境变量(.env:DB连接、JWT密钥、FastGPT API Key)
|
||||
- _Requirements: 9.1, 10.5_
|
||||
|
||||
- [x] 2. 用户认证模块
|
||||
- [x] 2.1 实现用户登录接口 `/api/user/login`
|
||||
- 查询用户表验证用户名、密码(bcrypt 比对)和角色
|
||||
- 生成 JWT token(有效期 24 小时),返回 userInfo
|
||||
- _Requirements: 1.1, 1.2_
|
||||
- [x] 2.2 实现 JWT 认证中间件
|
||||
- 验证请求头中的 token,解析用户信息注入 req.user
|
||||
- token 过期或无效时返回 401
|
||||
- _Requirements: 1.4_
|
||||
- [x] 2.3 实现角色权限中间件
|
||||
- 根据路由配置允许的角色列表,验证当前用户角色
|
||||
- 权限不足时返回 403
|
||||
- _Requirements: 1.3, 1.5_
|
||||
- [x] 2.4 编写认证模块属性测试
|
||||
|
||||
- **属性 1: 认证正确性** — 对任意凭据,认证结果与凭据有效性一致
|
||||
- **属性 2: 权限隔离不变量** — 员工无法访问他人数据
|
||||
- **Validates: Requirements 1.1, 1.2, 1.5**
|
||||
|
||||
- [x] 3. 数据库访问层(DAO)
|
||||
- [x] 3.1 实现 UserDAO
|
||||
- `findByUsername(username)`: 按用户名查询
|
||||
- `findSubordinates(managerId)`: 查询下属列表
|
||||
- _Requirements: 1.1, 4.1_
|
||||
- [x] 3.2 实现 PerformanceDAO
|
||||
- `upsert(data)`: 创建或更新绩效记录(支持暂存/提交)
|
||||
- `findByUserAndMonth(userId, month)`: 查询指定月份绩效
|
||||
- `findByManagerId(managerId, filters, page)`: 查询下属绩效列表
|
||||
- `updateStatus(perfId, status)`: 更新绩效状态
|
||||
- `updateReview(perfId, reviewData)`: 更新审核结果
|
||||
- _Requirements: 2.5, 2.6, 4.1, 4.5, 9.1_
|
||||
- [x] 3.3 实现 AIResultDAO
|
||||
- `save(aiResult)`: 保存 AI 评分结果
|
||||
- `findByPerfId(perfId)`: 查询绩效对应的 AI 结果
|
||||
- _Requirements: 3.2, 3.3_
|
||||
|
||||
- [x] 4. 考勤分数计算与绩效等级模块
|
||||
- [x] 4.1 实现考勤分数计算函数 `calculateAttendanceScore`
|
||||
- 基础分 10 分,事假每天扣 5 分,迟到/缺卡每次扣 2 分,下限 0 分
|
||||
- _Requirements: 11.1, 11.2, 11.3, 11.5_
|
||||
- [x] 4.2 实现绩效等级与奖惩计算函数 `calculateLevelAndReward`
|
||||
- 根据总分返回等级(excellent/qualified/need_motivation/unqualified)和奖惩说明
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_
|
||||
- [x] 4.3 实现连续低分预警检测函数 `checkConsecutiveLowScore`
|
||||
- 查询员工最近 N 个月记录,检测连续低于 60 分的情况
|
||||
- _Requirements: 5.6, 5.7_
|
||||
- [x] 4.4 编写计算模块属性测试
|
||||
|
||||
- **属性 5: 绩效等级与奖惩计算正确性** — 对任意分数,等级和奖惩符合规则
|
||||
- **属性 6: 连续低分预警正确性** — 连续低分正确触发预警
|
||||
- **属性 8: 考勤分数计算正确性** — 对任意考勤数据,分数计算正确且不低于 0
|
||||
- **Validates: Requirements 5.1-5.7, 11.1-11.5**
|
||||
|
||||
- [x] 5. 检查点 — 确保所有测试通过,如有问题请告知
|
||||
|
||||
- [x] 6. 员工端绩效填报接口
|
||||
- [x] 6.1 实现绩效提交接口 `/api/performance/submit`
|
||||
- 支持 draft(暂存)和 submit(提交)两种状态
|
||||
- 使用数据库事务同时写入 performance_month、perf_item、attendance 三张表
|
||||
- 提交时触发异步 AI 评分(不阻塞响应)
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.5, 2.6_
|
||||
- [x] 6.2 实现个人绩效查询接口 `/api/performance/employee/get`
|
||||
- 支持按月份筛选和分页
|
||||
- 返回绩效记录含 AI 结果、考勤、审核意见
|
||||
- _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5_
|
||||
- [x] 6.3 实现修改申请接口 `/api/performance/request-modification`
|
||||
- 员工发起申请,记录申请原因,通知管理层
|
||||
- _Requirements: 12.1, 12.2_
|
||||
- [x] 6.4 编写员工端接口属性测试
|
||||
|
||||
- **属性 3: 草稿暂存往返一致性** — 暂存后读取数据一致
|
||||
- **属性 4: 提交幂等性** — 同一用户同月不可重复提交
|
||||
- **属性 7: 绩效记录查询完整性** — 提交后查询必能找到
|
||||
- **Validates: Requirements 2.5, 2.6, 6.1**
|
||||
|
||||
- [x] 7. AI 评分模块
|
||||
- [x] 7.1 实现 FastGPT 调用服务 `AIEvaluationService`
|
||||
- 构建包含岗位、月份、填报内容、考核规则的 Prompt
|
||||
- 调用 FastGPT API(超时 10s)
|
||||
- _Requirements: 13.1, 13.2_
|
||||
- [x] 7.2 实现 AI 响应解析函数 `parseAIResponse`
|
||||
- 解析 JSON 格式响应,提取 ai_score_detail、ai_total_score、ai_problems、ai_suggestions
|
||||
- 验证格式合法性,异常时记录原始响应并抛出错误
|
||||
- _Requirements: 3.2, 3.3, 3.4, 13.3, 13.4_
|
||||
- [x] 7.3 实现重试机制(最多 3 次)和错误降级处理
|
||||
- _Requirements: 3.5_
|
||||
- [x] 7.4 编写 AI 模块属性测试
|
||||
|
||||
- **属性 9: AI 响应 JSON 解析往返一致性** — 解析后序列化得到等价对象
|
||||
- **属性 10: AI 输出格式约束** — problems 和 suggestions 数组长度在 3-5 之间
|
||||
- **Validates: Requirements 3.3, 3.4, 3.6**
|
||||
|
||||
- [x] 8. 管理层审核接口
|
||||
- [x] 8.1 实现下属绩效列表接口 `/api/performance/manager/list`
|
||||
- 支持按月份、部门、员工姓名、状态筛选和分页
|
||||
- _Requirements: 4.1, 7.1, 7.2_
|
||||
- [x] 8.2 实现绩效审核接口 `/api/performance/manager/review`
|
||||
- 更新各考核项管理层评分和说明
|
||||
- 计算最终总分、等级、奖惩,归档绩效记录(状态改为 completed)
|
||||
- _Requirements: 4.3, 4.4, 4.5, 4.7_
|
||||
- [x] 8.3 实现绩效驳回接口 `/api/performance/manager/reject`
|
||||
- 记录驳回原因,状态改为 rejected
|
||||
- _Requirements: 4.6_
|
||||
- [x] 8.4 实现修改申请审批接口 `/api/performance/manager/approve-modification`
|
||||
- 同意时解锁绩效记录,拒绝时通知员工
|
||||
- _Requirements: 12.3, 12.4_
|
||||
|
||||
- [x] 9. 检查点 — 确保所有测试通过,如有问题请告知
|
||||
|
||||
- [x] 10. 统计分析与导出接口
|
||||
- [x] 10.1 实现团队统计接口 `/api/statistics/team`
|
||||
- 统计下属团队平均分、优秀/合格/需激励人数及占比
|
||||
- _Requirements: 7.4, 7.5_
|
||||
- [x] 10.2 实现全公司统计接口 `/api/statistics/company`
|
||||
- 按部门、岗位、绩效等级多维度统计
|
||||
- _Requirements: 8.1, 8.2_
|
||||
- [x] 10.3 实现 Excel 导出接口 `/api/performance/export`
|
||||
- 使用 exceljs 生成 Excel 文件,支持单员工历史和团队批量导出
|
||||
- _Requirements: 7.3, 7.6, 8.4_
|
||||
|
||||
- [x] 11. 总经理端接口
|
||||
- [x] 11.1 实现考核规则配置接口 `/api/config/rules`
|
||||
- 支持修改考核指标权重、评分标准、奖惩金额
|
||||
- 修改后记录操作日志,应用于后续考核周期
|
||||
- _Requirements: 8.5, 8.6_
|
||||
|
||||
- [x] 12. 前端基础框架搭建
|
||||
- [x] 12.1 配置 React Router,定义三类角色的路由结构
|
||||
- 员工路由:`/employee/*`,管理层路由:`/manager/*`,总经理路由:`/gm/*`
|
||||
- _Requirements: 1.3_
|
||||
- [x] 12.2 实现登录页面和 AuthContext
|
||||
- 登录表单(用户名、密码、角色选择),登录成功后存储 token 跳转对应角色首页
|
||||
- _Requirements: 1.1, 1.2_
|
||||
- [x] 12.3 实现 Axios 请求拦截器
|
||||
- 自动携带 token,处理 401/403 响应(跳转登录页)
|
||||
- _Requirements: 1.4_
|
||||
|
||||
- [x] 13. 员工端前端页面
|
||||
- [x] 13.1 实现绩效填报页面 `PerformanceForm`
|
||||
- 自动带出考核月份、员工基础信息
|
||||
- 17 项考核指标逐项填写(完成情况描述 + 自评分 + 可选佐证上传)
|
||||
- 考勤数据填写区域、工作汇总文本区域、暂存和提交按钮
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6_
|
||||
- [x] 13.2 实现个人绩效历史页面 `PerformanceHistory`
|
||||
- 绩效记录列表(含状态、总分、等级),月份筛选和分页
|
||||
- 点击查看详情(含 AI 评分、AI 问题/建议、审核意见、奖惩说明)
|
||||
- _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5_
|
||||
|
||||
- [x] 14. 管理层端前端页面
|
||||
- [x] 14.1 实现下属绩效列表页面 `SubordinateList`
|
||||
- 列表展示员工绩效状态,筛选条件(月份、部门、姓名、状态)
|
||||
- _Requirements: 4.1, 7.1, 7.2_
|
||||
- [x] 14.2 实现绩效审核页面 `PerformanceReview`
|
||||
- 展示员工填报内容、自评分、AI 评分及 AI 反馈
|
||||
- 可调整各考核项分数并填写说明,提交审核通过或驳回
|
||||
- _Requirements: 4.2, 4.3, 4.4, 4.5, 4.6_
|
||||
- [x] 14.3 实现团队统计页面 `TeamStatistics`
|
||||
- 显示团队平均分、各等级人数及占比,Excel 导出按钮
|
||||
- _Requirements: 7.3, 7.4, 7.5_
|
||||
|
||||
- [x] 15. 总经理端前端页面
|
||||
- [x] 15.1 实现全公司绩效总览页面 `CompanyOverview`
|
||||
- 各部门、各岗位绩效整体情况,ECharts 柱状图和饼图,多维度筛选
|
||||
- _Requirements: 8.1, 8.2, 8.3_
|
||||
- [x] 15.2 实现全量数据导出和考核规则配置页面
|
||||
- 全量 Excel 导出,考核指标权重和奖惩金额配置表单
|
||||
- _Requirements: 8.4, 8.5, 8.6_
|
||||
|
||||
- [x] 16. 最终检查点 — 确保所有测试通过,前后端联调完成,如有问题请告知
|
||||
|
||||
## 备注
|
||||
|
||||
- 标有 `*` 的任务为可选测试任务,可跳过以加快 MVP 交付
|
||||
- 每个任务引用了具体的需求条目,便于追溯
|
||||
- 检查点任务确保每个阶段的增量验证
|
||||
- 属性测试使用 fast-check 库,每个属性最少运行 100 次迭代
|
||||
2
.vscode/settings.json
vendored
Normal file
2
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
18
backend/.env.example
Normal file
18
backend/.env.example
Normal file
@@ -0,0 +1,18 @@
|
||||
# 数据库配置
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=your_password_here
|
||||
DB_NAME=employee_performance
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET=your_jwt_secret_here_change_in_production
|
||||
|
||||
# FastGPT API 配置
|
||||
FASTGPT_API_KEY=your_fastgpt_api_key_here
|
||||
FASTGPT_API_URL=https://api.fastgpt.in/api/v1/chat/completions
|
||||
FASTGPT_MODEL=gpt-4
|
||||
|
||||
# 服务器配置
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
5
backend/.gitignore
vendored
Normal file
5
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
coverage/
|
||||
21
backend/backfill-ai.ts
Normal file
21
backend/backfill-ai.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
|
||||
async function run() {
|
||||
const [results] = await pool.query<any[]>('SELECT ai_id, perf_id, ai_score_json FROM ai_result');
|
||||
for (const row of results) {
|
||||
const items = JSON.parse(row.ai_score_json);
|
||||
for (const item of items) {
|
||||
await pool.query(
|
||||
'UPDATE perf_item SET ai_score = ?, ai_explanation = ? WHERE perf_id = ? AND item_name = ?',
|
||||
[item.aiScore, item.scoreExplanation, row.perf_id, item.itemName]
|
||||
);
|
||||
}
|
||||
console.log(`回写 perfId=${row.perf_id},共 ${items.length} 项`);
|
||||
}
|
||||
await pool.end();
|
||||
console.log('完成');
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
38
backend/backfill-self-score.ts
Normal file
38
backend/backfill-self-score.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
|
||||
async function run() {
|
||||
// 找出所有 self_score 为空但有 perf_item 的记录
|
||||
const [perfs] = await pool.query<any[]>(
|
||||
`SELECT perf_id FROM performance_month WHERE self_score IS NULL OR self_score = 0`
|
||||
);
|
||||
|
||||
for (const perf of perfs) {
|
||||
const [items] = await pool.query<any[]>(
|
||||
'SELECT weight, self_score FROM perf_item WHERE perf_id = ? AND self_score IS NOT NULL',
|
||||
[perf.perf_id]
|
||||
);
|
||||
if (items.length === 0) continue;
|
||||
|
||||
let weightedSum = 0;
|
||||
let totalWeight = 0;
|
||||
for (const item of items) {
|
||||
weightedSum += Number(item.self_score) * Number(item.weight);
|
||||
totalWeight += Number(item.weight);
|
||||
}
|
||||
if (totalWeight === 0) continue;
|
||||
|
||||
const selfScore = parseFloat((weightedSum / totalWeight).toFixed(2));
|
||||
await pool.query(
|
||||
'UPDATE performance_month SET self_score = ? WHERE perf_id = ?',
|
||||
[selfScore, perf.perf_id]
|
||||
);
|
||||
console.log(`perfId=${perf.perf_id} 自评总分=${selfScore}`);
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
console.log('完成');
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
20
backend/check-ai-data.ts
Normal file
20
backend/check-ai-data.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
|
||||
async function run() {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
'SELECT perf_id, ai_total_score, problems, suggestions FROM ai_result ORDER BY ai_id DESC LIMIT 2'
|
||||
);
|
||||
for (const row of rows) {
|
||||
console.log('=== perfId:', row.perf_id, '===');
|
||||
console.log('ai_total_score:', row.ai_total_score);
|
||||
console.log('problems raw:', row.problems);
|
||||
console.log('suggestions raw:', row.suggestions);
|
||||
console.log('problems parsed:', JSON.parse(row.problems || '[]'));
|
||||
console.log('suggestions parsed:', JSON.parse(row.suggestions || '[]'));
|
||||
}
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
23
backend/check-ai-raw.ts
Normal file
23
backend/check-ai-raw.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
import * as AIResultDAO from './src/dao/AIResultDAO';
|
||||
|
||||
async function run() {
|
||||
// 直接查原始数据
|
||||
const [rows] = await pool.query<any[]>('SELECT * FROM ai_result WHERE perf_id = 9');
|
||||
console.log('原始数据库行:', rows[0]);
|
||||
console.log('problems 类型:', typeof rows[0]?.problems);
|
||||
console.log('suggestions 类型:', typeof rows[0]?.suggestions);
|
||||
|
||||
// 通过 DAO 查
|
||||
const result = await AIResultDAO.findByPerfId(9);
|
||||
console.log('\nDAO 返回:');
|
||||
console.log(' aiTotalScore:', result?.aiTotalScore, typeof result?.aiTotalScore);
|
||||
console.log(' aiProblems:', result?.aiProblems);
|
||||
console.log(' aiSuggestions:', result?.aiSuggestions);
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
52
backend/check-ai-results.ts
Normal file
52
backend/check-ai-results.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function checkAIResults() {
|
||||
try {
|
||||
const conn = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'employee_performance',
|
||||
});
|
||||
|
||||
console.log('=== AI评分结果 ===');
|
||||
const [aiRows] = await conn.query(`
|
||||
SELECT ai_id, perf_id, ai_total_score,
|
||||
LEFT(problems, 100) as problems_preview,
|
||||
LEFT(suggestions, 100) as suggestions_preview,
|
||||
create_time
|
||||
FROM ai_result
|
||||
`);
|
||||
console.table(aiRows);
|
||||
|
||||
console.log('\n=== 绩效记录状态 ===');
|
||||
const [perfRows] = await conn.query(`
|
||||
SELECT perf_id, user_id, month, status, ai_score, submit_time
|
||||
FROM performance_month
|
||||
ORDER BY perf_id DESC
|
||||
`);
|
||||
console.table(perfRows);
|
||||
|
||||
// 检查是否有AI评分但没有更新到performance_month表
|
||||
console.log('\n=== 检查数据一致性 ===');
|
||||
const [inconsistent] = await conn.query(`
|
||||
SELECT pm.perf_id, pm.month, pm.ai_score as perf_ai_score, ar.ai_total_score as ai_result_score
|
||||
FROM performance_month pm
|
||||
LEFT JOIN ai_result ar ON pm.perf_id = ar.perf_id
|
||||
WHERE pm.status = 'submitted'
|
||||
`);
|
||||
console.table(inconsistent);
|
||||
|
||||
await conn.end();
|
||||
process.exit(0);
|
||||
} catch (error: any) {
|
||||
console.error('错误:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
checkAIResults();
|
||||
43
backend/check-api-response.ts
Normal file
43
backend/check-api-response.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
import axios from 'axios';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
async function run() {
|
||||
// 找最新有 ai_result 的记录和对应 user_id
|
||||
const [rows] = await pool.query<any[]>(
|
||||
`SELECT pm.perf_id, pm.user_id, pm.month, ar.ai_total_score, ar.problems, ar.suggestions
|
||||
FROM performance_month pm
|
||||
JOIN ai_result ar ON pm.perf_id = ar.perf_id
|
||||
ORDER BY ar.ai_id DESC LIMIT 1`
|
||||
);
|
||||
const rec = rows[0];
|
||||
console.log('测试记录:', rec.perf_id, 'userId:', rec.user_id, 'month:', rec.month);
|
||||
|
||||
// 获取该用户信息
|
||||
const [users] = await pool.query<any[]>('SELECT username, role, name FROM user WHERE user_id = ?', [rec.user_id]);
|
||||
const user = users[0];
|
||||
console.log('用户:', user);
|
||||
|
||||
const token = jwt.sign(
|
||||
{ userId: rec.user_id, username: user.username, role: user.role, name: user.name },
|
||||
process.env.JWT_SECRET!,
|
||||
{ expiresIn: '1h' }
|
||||
);
|
||||
|
||||
const { data } = await axios.get('http://localhost:3001/api/performance/employee/get', {
|
||||
params: { perfId: rec.perf_id },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
const aiResult = data.data?.aiResult;
|
||||
console.log('\naiResult:');
|
||||
console.log(' aiTotalScore:', aiResult?.aiTotalScore, typeof aiResult?.aiTotalScore);
|
||||
console.log(' aiProblems:', aiResult?.aiProblems);
|
||||
console.log(' aiSuggestions:', aiResult?.aiSuggestions);
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
16
backend/check-completed.ts
Normal file
16
backend/check-completed.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
|
||||
async function run() {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
`SELECT pm.perf_id, pm.user_id, pm.month, pm.status, pm.total_score, pm.level, pm.manager_score,
|
||||
u.manager_id
|
||||
FROM performance_month pm
|
||||
JOIN user u ON pm.user_id = u.user_id
|
||||
ORDER BY pm.perf_id`
|
||||
);
|
||||
console.table(rows);
|
||||
await pool.end();
|
||||
}
|
||||
run().catch(console.error);
|
||||
50
backend/check-config.ts
Normal file
50
backend/check-config.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
|
||||
async function run() {
|
||||
const [rows] = await pool.query<any[]>('SELECT COUNT(*) as cnt FROM performance_rules');
|
||||
console.log('performance_rules 记录数:', rows[0].cnt);
|
||||
|
||||
if (rows[0].cnt === 0) {
|
||||
console.log('表为空,插入默认规则...');
|
||||
const defaults = [
|
||||
['business_completion_weight', '15', '工作完成度权重'],
|
||||
['business_quality_weight', '10', '工作质量权重'],
|
||||
['business_efficiency_weight', '10', '工作效率权重'],
|
||||
['business_skill_weight', '10', '专业技能权重'],
|
||||
['business_innovation_weight', '5', '创新能力权重'],
|
||||
['business_problem_solving_weight', '5', '问题解决权重'],
|
||||
['business_customer_satisfaction_weight', '5', '客户满意度权重'],
|
||||
['business_teamwork_weight', '5', '团队协作权重'],
|
||||
['business_goal_achievement_weight', '5', '目标达成权重'],
|
||||
['comprehensive_responsibility_weight', '5', '责任心权重'],
|
||||
['comprehensive_initiative_weight', '4', '主动性权重'],
|
||||
['comprehensive_learning_weight', '4', '学习能力权重'],
|
||||
['comprehensive_communication_weight', '4', '沟通能力权重'],
|
||||
['comprehensive_execution_weight', '4', '执行力权重'],
|
||||
['comprehensive_discipline_weight', '4', '纪律性权重'],
|
||||
['comprehensive_team_spirit_weight', '5', '团队精神权重'],
|
||||
['comprehensive_attendance_weight', '10', '考勤权重'],
|
||||
['reward_excellent', '500', '优秀奖励金额'],
|
||||
['punish_qualified_high', '0', '合格(80-89)扣款'],
|
||||
['punish_qualified_low', '100', '合格(70-79)扣款'],
|
||||
['punish_need_motivation', '200', '需激励扣款'],
|
||||
['punish_unqualified', '500', '不合格扣款'],
|
||||
['attendance_leave_deduct', '5', '事假每天扣分'],
|
||||
['attendance_late_deduct', '2', '迟到每次扣分'],
|
||||
['attendance_lack_card_deduct', '2', '缺卡每次扣分'],
|
||||
['attendance_base_score', '10', '考勤基础分'],
|
||||
];
|
||||
for (const [key, value, desc] of defaults) {
|
||||
await pool.query(
|
||||
'INSERT INTO performance_rules (rule_key, rule_value, description) VALUES (?, ?, ?)',
|
||||
[key, value, desc]
|
||||
);
|
||||
}
|
||||
console.log('默认规则插入完成');
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
run().catch(console.error);
|
||||
31
backend/check-db.ts
Normal file
31
backend/check-db.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function checkDatabase() {
|
||||
try {
|
||||
const connection = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'employee_performance',
|
||||
});
|
||||
|
||||
console.log('✓ 已连接到数据库\n');
|
||||
|
||||
// 查看所有表
|
||||
const [tables] = await connection.query('SHOW TABLES');
|
||||
console.log('数据库中的表:');
|
||||
console.log(tables);
|
||||
|
||||
await connection.end();
|
||||
process.exit(0);
|
||||
} catch (error: any) {
|
||||
console.error('❌ 错误:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
checkDatabase();
|
||||
19
backend/check-manager-relation.ts
Normal file
19
backend/check-manager-relation.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
|
||||
async function run() {
|
||||
const [users] = await pool.query<any[]>(
|
||||
'SELECT user_id, username, name, role, manager_id FROM user ORDER BY role, user_id'
|
||||
);
|
||||
console.table(users);
|
||||
|
||||
const [perfs] = await pool.query<any[]>(
|
||||
'SELECT pm.perf_id, pm.user_id, u.name, u.manager_id, pm.month, pm.status FROM performance_month pm JOIN user u ON pm.user_id = u.user_id'
|
||||
);
|
||||
console.log('\n绩效记录与管理层关联:');
|
||||
console.table(perfs);
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
run().catch(console.error);
|
||||
48
backend/check-records.ts
Normal file
48
backend/check-records.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function checkRecords() {
|
||||
try {
|
||||
const conn = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'employee_performance',
|
||||
});
|
||||
|
||||
console.log('=== 所有绩效记录 ===');
|
||||
const [rows] = await conn.query(`
|
||||
SELECT perf_id, user_id, month, status, self_score, ai_score, submit_time, created_at
|
||||
FROM performance_month
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
console.table(rows);
|
||||
|
||||
console.log('\n=== 员工用户信息 ===');
|
||||
const [users] = await conn.query(`
|
||||
SELECT user_id, username, name, role, department, position
|
||||
FROM user
|
||||
WHERE role = 'employee'
|
||||
`);
|
||||
console.table(users);
|
||||
|
||||
console.log('\n=== 绩效项数量统计 ===');
|
||||
const [itemCounts] = await conn.query(`
|
||||
SELECT perf_id, COUNT(*) as item_count
|
||||
FROM perf_item
|
||||
GROUP BY perf_id
|
||||
`);
|
||||
console.table(itemCounts);
|
||||
|
||||
await conn.end();
|
||||
process.exit(0);
|
||||
} catch (error: any) {
|
||||
console.error('错误:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
checkRecords();
|
||||
49
backend/clear-test-data.ts
Normal file
49
backend/clear-test-data.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
|
||||
async function run() {
|
||||
console.log('开始清理测试数据...');
|
||||
|
||||
// 1. 清理 AI 结果
|
||||
const [ai] = await pool.query<any>('DELETE FROM ai_result');
|
||||
console.log(`删除 ai_result: ${ai.affectedRows} 条`);
|
||||
|
||||
// 2. 清理操作日志
|
||||
const [logs] = await pool.query<any>('DELETE FROM operation_log');
|
||||
console.log(`删除 operation_log: ${logs.affectedRows} 条`);
|
||||
|
||||
// 3. 清理考勤数据
|
||||
const [att] = await pool.query<any>('DELETE FROM attendance');
|
||||
console.log(`删除 attendance: ${att.affectedRows} 条`);
|
||||
|
||||
// 4. 清理绩效项明细
|
||||
const [items] = await pool.query<any>('DELETE FROM perf_item');
|
||||
console.log(`删除 perf_item: ${items.affectedRows} 条`);
|
||||
|
||||
// 5. 清理绩效主表
|
||||
const [perf] = await pool.query<any>('DELETE FROM performance_month');
|
||||
console.log(`删除 performance_month: ${perf.affectedRows} 条`);
|
||||
|
||||
// 6. 清理测试员工账号(保留管理层和总经理账号)
|
||||
const [users] = await pool.query<any>("DELETE FROM user WHERE role = 'employee'");
|
||||
console.log(`删除测试员工账号: ${users.affectedRows} 条`);
|
||||
|
||||
// 重置自增ID
|
||||
await pool.query('ALTER TABLE ai_result AUTO_INCREMENT = 1');
|
||||
await pool.query('ALTER TABLE operation_log AUTO_INCREMENT = 1');
|
||||
await pool.query('ALTER TABLE attendance AUTO_INCREMENT = 1');
|
||||
await pool.query('ALTER TABLE perf_item AUTO_INCREMENT = 1');
|
||||
await pool.query('ALTER TABLE performance_month AUTO_INCREMENT = 1');
|
||||
await pool.query('ALTER TABLE user AUTO_INCREMENT = 1');
|
||||
|
||||
// 查看剩余账号
|
||||
const [remaining] = await pool.query<any[]>('SELECT user_id, username, name, role FROM user ORDER BY role');
|
||||
console.log('\n剩余账号:');
|
||||
console.table(remaining);
|
||||
|
||||
await pool.end();
|
||||
console.log('\n清理完成!');
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
38
backend/clear-wyy.ts
Normal file
38
backend/clear-wyy.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
|
||||
async function run() {
|
||||
// 查找 wyy 用户
|
||||
const [users] = await pool.query<any[]>("SELECT * FROM user WHERE username LIKE '%wyy%' OR name LIKE '%wyy%'");
|
||||
console.log('找到用户:', users);
|
||||
|
||||
if (users.length === 0) {
|
||||
console.log('未找到 wyy 用户,查看所有员工账号:');
|
||||
const [all] = await pool.query<any[]>("SELECT user_id, username, name, role FROM user WHERE role = 'employee'");
|
||||
console.table(all);
|
||||
await pool.end();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const user of users) {
|
||||
const userId = user.user_id;
|
||||
|
||||
// 找到该用户的绩效记录
|
||||
const [perfs] = await pool.query<any[]>('SELECT perf_id FROM performance_month WHERE user_id = ?', [userId]);
|
||||
for (const perf of perfs) {
|
||||
await pool.query('DELETE FROM ai_result WHERE perf_id = ?', [perf.perf_id]);
|
||||
await pool.query('DELETE FROM attendance WHERE perf_id = ?', [perf.perf_id]);
|
||||
await pool.query('DELETE FROM perf_item WHERE perf_id = ?', [perf.perf_id]);
|
||||
}
|
||||
await pool.query('DELETE FROM performance_month WHERE user_id = ?', [userId]);
|
||||
await pool.query('DELETE FROM operation_log WHERE user_id = ?', [userId]);
|
||||
await pool.query('DELETE FROM user WHERE user_id = ?', [userId]);
|
||||
console.log(`已删除用户 ${user.username}(${user.name}) 及其所有数据`);
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
console.log('完成');
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
14
backend/create-db-user.sql
Normal file
14
backend/create-db-user.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- 创建新的数据库用户(如果你能以 root 身份登录 MySQL)
|
||||
-- 在 MySQL 命令行中执行此脚本
|
||||
|
||||
-- 创建用户(密码设置为 123456)
|
||||
CREATE USER IF NOT EXISTS 'emp_user'@'localhost' IDENTIFIED BY '123456';
|
||||
|
||||
-- 授予权限
|
||||
GRANT ALL PRIVILEGES ON employee_performance.* TO 'emp_user'@'localhost';
|
||||
|
||||
-- 刷新权限
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
-- 显示用户
|
||||
SELECT User, Host FROM mysql.user WHERE User = 'emp_user';
|
||||
48
backend/init-config-rules.ts
Normal file
48
backend/init-config-rules.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
|
||||
const defaults: [string, string, string][] = [
|
||||
['business_completion_weight', '15', '工作完成度权重'],
|
||||
['business_quality_weight', '10', '工作质量权重'],
|
||||
['business_efficiency_weight', '10', '工作效率权重'],
|
||||
['business_skill_weight', '10', '专业技能权重'],
|
||||
['business_innovation_weight', '5', '创新能力权重'],
|
||||
['business_problem_solving_weight', '5', '问题解决权重'],
|
||||
['business_customer_satisfaction_weight', '5', '客户满意度权重'],
|
||||
['business_teamwork_weight', '5', '团队协作权重'],
|
||||
['business_goal_achievement_weight', '5', '目标达成权重'],
|
||||
['comprehensive_responsibility_weight', '5', '责任心权重'],
|
||||
['comprehensive_initiative_weight', '4', '主动性权重'],
|
||||
['comprehensive_learning_weight', '4', '学习能力权重'],
|
||||
['comprehensive_communication_weight', '4', '沟通能力权重'],
|
||||
['comprehensive_execution_weight', '4', '执行力权重'],
|
||||
['comprehensive_discipline_weight', '4', '纪律性权重'],
|
||||
['comprehensive_team_spirit_weight', '5', '团队精神权重'],
|
||||
['comprehensive_attendance_weight', '10', '考勤权重'],
|
||||
['reward_excellent', '500', '优秀奖励金额'],
|
||||
['punish_qualified_high', '0', '合格(80-89)扣款'],
|
||||
['punish_qualified_low', '100', '合格(70-79)扣款'],
|
||||
['punish_need_motivation', '200', '需激励扣款'],
|
||||
['punish_unqualified', '500', '不合格扣款'],
|
||||
['attendance_leave_deduct', '5', '事假每天扣分'],
|
||||
['attendance_late_deduct', '2', '迟到每次扣分'],
|
||||
['attendance_lack_card_deduct', '2', '缺卡每次扣分'],
|
||||
['attendance_base_score', '10', '考勤基础分'],
|
||||
];
|
||||
|
||||
async function run() {
|
||||
for (const [key, value, desc] of defaults) {
|
||||
await pool.query(
|
||||
`INSERT INTO performance_rules (rule_key, rule_value, description)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE description = VALUES(description)`,
|
||||
[key, value, desc]
|
||||
);
|
||||
}
|
||||
const [rows] = await pool.query<any[]>('SELECT COUNT(*) as cnt FROM performance_rules');
|
||||
console.log('规则总数:', rows[0].cnt);
|
||||
await pool.end();
|
||||
console.log('完成');
|
||||
}
|
||||
run().catch(console.error);
|
||||
74
backend/init-db-v2.ts
Normal file
74
backend/init-db-v2.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function initDatabase() {
|
||||
try {
|
||||
console.log('开始初始化数据库...\n');
|
||||
|
||||
// 连接到 MySQL
|
||||
const connection = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
multipleStatements: true, // 允许执行多条语句
|
||||
});
|
||||
|
||||
console.log('✓ 已连接到 MySQL 服务器');
|
||||
|
||||
// 创建数据库
|
||||
await connection.query(`CREATE DATABASE IF NOT EXISTS ${process.env.DB_NAME || 'employee_performance'} DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci`);
|
||||
console.log('✓ 数据库已创建');
|
||||
|
||||
// 切换到目标数据库
|
||||
await connection.query(`USE ${process.env.DB_NAME || 'employee_performance'}`);
|
||||
|
||||
// 读取并执行初始化脚本
|
||||
const initSql = fs.readFileSync(path.join(__dirname, 'src/db/init.sql'), 'utf-8');
|
||||
|
||||
// 移除 CREATE DATABASE 和 USE 语句
|
||||
const cleanedSql = initSql
|
||||
.split('\n')
|
||||
.filter(line => !line.trim().startsWith('CREATE DATABASE') && !line.trim().startsWith('USE '))
|
||||
.join('\n');
|
||||
|
||||
await connection.query(cleanedSql);
|
||||
console.log('✓ 表结构已创建');
|
||||
|
||||
// 读取并执行种子数据脚本
|
||||
const seedSql = fs.readFileSync(path.join(__dirname, 'src/db/seed.sql'), 'utf-8');
|
||||
const cleanedSeedSql = seedSql
|
||||
.split('\n')
|
||||
.filter(line => !line.trim().startsWith('USE '))
|
||||
.join('\n');
|
||||
|
||||
await connection.query(cleanedSeedSql);
|
||||
console.log('✓ 测试数据已插入');
|
||||
|
||||
// 验证表是否创建成功
|
||||
const [tables] = await connection.query('SHOW TABLES');
|
||||
console.log('\n创建的表:', tables);
|
||||
|
||||
await connection.end();
|
||||
|
||||
console.log('\n✅ 数据库初始化完成!');
|
||||
console.log('\n测试账号信息:');
|
||||
console.log('总经理: gm001 / 123456');
|
||||
console.log('技术部经理: mgr001 / 123456');
|
||||
console.log('销售部经理: mgr002 / 123456');
|
||||
console.log('技术部员工: emp001, emp002, emp003 / 123456');
|
||||
console.log('销售部员工: emp004, emp005 / 123456');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error: any) {
|
||||
console.error('\n❌ 数据库初始化失败:', error.message);
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
initDatabase();
|
||||
74
backend/init-db.ts
Normal file
74
backend/init-db.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function initDatabase() {
|
||||
try {
|
||||
console.log('开始初始化数据库...\n');
|
||||
|
||||
// 先连接到 MySQL(不指定数据库)
|
||||
const connection = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
});
|
||||
|
||||
console.log('✓ 已连接到 MySQL 服务器');
|
||||
|
||||
// 创建数据库
|
||||
await connection.query(`CREATE DATABASE IF NOT EXISTS ${process.env.DB_NAME || 'employee_performance'} DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci`);
|
||||
console.log('✓ 数据库已创建');
|
||||
|
||||
// 切换到目标数据库
|
||||
await connection.query(`USE ${process.env.DB_NAME || 'employee_performance'}`);
|
||||
|
||||
// 读取并执行初始化脚本
|
||||
const initSql = fs.readFileSync(path.join(__dirname, 'src/db/init.sql'), 'utf-8');
|
||||
const statements = initSql
|
||||
.split(';')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0 && !s.startsWith('--') && !s.startsWith('CREATE DATABASE') && !s.startsWith('USE'));
|
||||
|
||||
for (const statement of statements) {
|
||||
if (statement.trim()) {
|
||||
await connection.query(statement);
|
||||
}
|
||||
}
|
||||
console.log('✓ 表结构已创建');
|
||||
|
||||
// 读取并执行种子数据脚本
|
||||
const seedSql = fs.readFileSync(path.join(__dirname, 'src/db/seed.sql'), 'utf-8');
|
||||
const seedStatements = seedSql
|
||||
.split(';')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0 && !s.startsWith('--') && !s.startsWith('USE'));
|
||||
|
||||
for (const statement of seedStatements) {
|
||||
if (statement.trim()) {
|
||||
await connection.query(statement);
|
||||
}
|
||||
}
|
||||
console.log('✓ 测试数据已插入');
|
||||
|
||||
await connection.end();
|
||||
|
||||
console.log('\n✅ 数据库初始化完成!');
|
||||
console.log('\n测试账号信息:');
|
||||
console.log('总经理: gm001 / 123456');
|
||||
console.log('技术部经理: mgr001 / 123456');
|
||||
console.log('销售部经理: mgr002 / 123456');
|
||||
console.log('技术部员工: emp001, emp002, emp003 / 123456');
|
||||
console.log('销售部员工: emp004, emp005 / 123456');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error: any) {
|
||||
console.error('\n❌ 数据库初始化失败:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
initDatabase();
|
||||
95
backend/insert-mock-ai-result.ts
Normal file
95
backend/insert-mock-ai-result.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function insertMockAIResult() {
|
||||
try {
|
||||
const conn = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'employee_performance',
|
||||
});
|
||||
|
||||
// 为每个已提交的绩效记录创建模拟AI评分
|
||||
const [perfRecords] = await conn.query(`
|
||||
SELECT perf_id FROM performance_month WHERE status = 'submitted'
|
||||
`);
|
||||
|
||||
for (const record of perfRecords as any[]) {
|
||||
const perfId = record.perf_id;
|
||||
|
||||
// 模拟AI评分详情
|
||||
const aiScoreDetail = [
|
||||
{ itemName: '工作目标完成情况', weight: 15, aiScore: 85, scoreExplanation: '工作目标基本完成,但部分细节需要改进' },
|
||||
{ itemName: '工作质量', weight: 10, aiScore: 88, scoreExplanation: '工作质量良好,符合标准' },
|
||||
{ itemName: '工作效率', weight: 10, aiScore: 82, scoreExplanation: '效率有待提升' },
|
||||
{ itemName: '业务能力', weight: 10, aiScore: 86, scoreExplanation: '业务能力扎实' },
|
||||
{ itemName: '创新能力', weight: 5, aiScore: 80, scoreExplanation: '有一定创新意识' },
|
||||
{ itemName: '问题解决能力', weight: 5, aiScore: 84, scoreExplanation: '能够独立解决问题' },
|
||||
{ itemName: '项目推进能力', weight: 5, aiScore: 83, scoreExplanation: '项目推进较为顺利' },
|
||||
{ itemName: '客户服务', weight: 5, aiScore: 87, scoreExplanation: '客户反馈良好' },
|
||||
{ itemName: '成本控制', weight: 5, aiScore: 85, scoreExplanation: '成本控制意识较强' },
|
||||
{ itemName: '团队协作', weight: 5, aiScore: 89, scoreExplanation: '团队协作能力突出' },
|
||||
{ itemName: '沟通能力', weight: 5, aiScore: 86, scoreExplanation: '沟通顺畅' },
|
||||
{ itemName: '学习成长', weight: 5, aiScore: 88, scoreExplanation: '学习态度积极' },
|
||||
{ itemName: '责任心', weight: 5, aiScore: 90, scoreExplanation: '责任心强' },
|
||||
{ itemName: '执行力', weight: 5, aiScore: 87, scoreExplanation: '执行力较好' },
|
||||
{ itemName: '职业素养', weight: 5, aiScore: 89, scoreExplanation: '职业素养良好' },
|
||||
{ itemName: '工作态度', weight: 5, aiScore: 91, scoreExplanation: '工作态度端正' },
|
||||
];
|
||||
|
||||
const aiTotalScore = 85.5;
|
||||
|
||||
const problems = [
|
||||
'工作效率有待提升,部分任务完成时间较长',
|
||||
'创新能力需要加强,建议多尝试新方法',
|
||||
'项目文档记录不够完善,需要改进'
|
||||
];
|
||||
|
||||
const suggestions = [
|
||||
'建议制定更详细的工作计划,提高时间管理能力',
|
||||
'多参加技术分享和培训,拓展知识面',
|
||||
'加强项目文档编写,形成良好的工作习惯',
|
||||
'主动承担更多挑战性任务,提升综合能力'
|
||||
];
|
||||
|
||||
// 插入AI评分结果
|
||||
await conn.query(`
|
||||
INSERT INTO ai_result (perf_id, ai_score_json, ai_total_score, problems, suggestions, api_response)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
ai_score_json = VALUES(ai_score_json),
|
||||
ai_total_score = VALUES(ai_total_score),
|
||||
problems = VALUES(problems),
|
||||
suggestions = VALUES(suggestions)
|
||||
`, [
|
||||
perfId,
|
||||
JSON.stringify(aiScoreDetail),
|
||||
aiTotalScore,
|
||||
JSON.stringify(problems),
|
||||
JSON.stringify(suggestions),
|
||||
'模拟AI评分结果'
|
||||
]);
|
||||
|
||||
// 更新performance_month表的ai_score
|
||||
await conn.query(`
|
||||
UPDATE performance_month SET ai_score = ? WHERE perf_id = ?
|
||||
`, [aiTotalScore, perfId]);
|
||||
|
||||
console.log(`✓ 已为绩效记录 ${perfId} 创建模拟AI评分`);
|
||||
}
|
||||
|
||||
console.log('\n✅ 模拟AI评分数据插入完成!');
|
||||
|
||||
await conn.end();
|
||||
process.exit(0);
|
||||
} catch (error: any) {
|
||||
console.error('错误:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
insertMockAIResult();
|
||||
7
backend/jest.config.js
Normal file
7
backend/jest.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: ['**/*.test.ts'],
|
||||
collectCoverageFrom: ['src/**/*.ts', '!src/index.ts'],
|
||||
};
|
||||
12
backend/list-accounts.ts
Normal file
12
backend/list-accounts.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
|
||||
async function run() {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
"SELECT username, name, role, department, position FROM user WHERE role IN ('manager','generalManager') ORDER BY role"
|
||||
);
|
||||
console.table(rows);
|
||||
await pool.end();
|
||||
}
|
||||
run().catch(console.error);
|
||||
6469
backend/package-lock.json
generated
Normal file
6469
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
backend/package.json
Normal file
39
backend/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "employee-performance-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "员工月度绩效考核系统后端",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"test": "jest --runInBand",
|
||||
"test:watch": "jest --watch",
|
||||
"db:seed": "ts-node src/db/seed.ts",
|
||||
"db:test": "ts-node test-db.ts",
|
||||
"db:init": "ts-node init-db.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"exceljs": "^4.4.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mysql2": "^3.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/node": "^20.10.0",
|
||||
"fast-check": "^3.15.0",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.3.2"
|
||||
}
|
||||
}
|
||||
18
backend/src/config/database.ts
Normal file
18
backend/src/config/database.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'employee_performance',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
timezone: '+08:00',
|
||||
});
|
||||
|
||||
export default pool;
|
||||
2
backend/src/config/jwt.ts
Normal file
2
backend/src/config/jwt.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const JWT_SECRET = process.env.JWT_SECRET || 'change_this_secret_in_production';
|
||||
export const JWT_EXPIRES_IN = '24h';
|
||||
93
backend/src/dao/AIResultDAO.ts
Normal file
93
backend/src/dao/AIResultDAO.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import pool from '../config/database';
|
||||
import { ResultSetHeader } from 'mysql2';
|
||||
|
||||
export interface AIScoreItem {
|
||||
itemName: string;
|
||||
weight: number;
|
||||
aiScore: number;
|
||||
scoreExplanation: string;
|
||||
}
|
||||
|
||||
export interface AIResultRow {
|
||||
ai_id: number;
|
||||
perf_id: number;
|
||||
ai_score_json: string;
|
||||
ai_total_score: number;
|
||||
problems: string | null;
|
||||
suggestions: string | null;
|
||||
api_response: string | null;
|
||||
create_time: Date;
|
||||
}
|
||||
|
||||
export interface SaveAIResultData {
|
||||
perfId: number;
|
||||
aiScoreDetail: AIScoreItem[];
|
||||
aiTotalScore: number;
|
||||
aiProblems: string[];
|
||||
aiSuggestions: string[];
|
||||
apiResponse?: string;
|
||||
}
|
||||
|
||||
export interface AIResult {
|
||||
aiId: number;
|
||||
perfId: number;
|
||||
aiScoreDetail: AIScoreItem[];
|
||||
aiTotalScore: number;
|
||||
aiProblems: string[];
|
||||
aiSuggestions: string[];
|
||||
createTime: Date;
|
||||
}
|
||||
|
||||
/** Save AI evaluation result. Replaces any existing result for the same perf_id. */
|
||||
export async function save(data: SaveAIResultData): Promise<number> {
|
||||
const [result] = await pool.query<ResultSetHeader>(
|
||||
`INSERT INTO ai_result (perf_id, ai_score_json, ai_total_score, problems, suggestions, api_response)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
ai_score_json = VALUES(ai_score_json),
|
||||
ai_total_score = VALUES(ai_total_score),
|
||||
problems = VALUES(problems),
|
||||
suggestions = VALUES(suggestions),
|
||||
api_response = VALUES(api_response)`,
|
||||
[
|
||||
data.perfId,
|
||||
JSON.stringify(data.aiScoreDetail),
|
||||
data.aiTotalScore,
|
||||
JSON.stringify(data.aiProblems),
|
||||
JSON.stringify(data.aiSuggestions),
|
||||
data.apiResponse ?? null,
|
||||
]
|
||||
);
|
||||
|
||||
if (result.insertId !== 0) {
|
||||
return result.insertId;
|
||||
}
|
||||
|
||||
// On UPDATE, fetch the existing ai_id
|
||||
const [rows] = await pool.query<any[]>(
|
||||
'SELECT ai_id FROM ai_result WHERE perf_id = ? LIMIT 1',
|
||||
[data.perfId]
|
||||
);
|
||||
return rows[0].ai_id;
|
||||
}
|
||||
|
||||
/** Retrieve the AI result for a given performance record. */
|
||||
export async function findByPerfId(perfId: number): Promise<AIResult | null> {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
'SELECT * FROM ai_result WHERE perf_id = ? LIMIT 1',
|
||||
[perfId]
|
||||
);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const row: AIResultRow = rows[0];
|
||||
return {
|
||||
aiId: row.ai_id,
|
||||
perfId: row.perf_id,
|
||||
aiScoreDetail: JSON.parse(row.ai_score_json) as AIScoreItem[],
|
||||
aiTotalScore: Number(row.ai_total_score),
|
||||
aiProblems: row.problems ? (JSON.parse(row.problems) as string[]) : [],
|
||||
aiSuggestions: row.suggestions ? (JSON.parse(row.suggestions) as string[]) : [],
|
||||
createTime: row.create_time,
|
||||
};
|
||||
}
|
||||
53
backend/src/dao/ConfigDAO.ts
Normal file
53
backend/src/dao/ConfigDAO.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import pool from '../config/database';
|
||||
|
||||
export interface RuleRow {
|
||||
rule_id: number;
|
||||
rule_key: string;
|
||||
rule_value: string; // JSON string
|
||||
description: string | null;
|
||||
effective_cycle: string | null;
|
||||
updated_by: number | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/** Fetch all rules */
|
||||
export async function findAllRules(): Promise<RuleRow[]> {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
'SELECT rule_id, rule_key, rule_value, description, effective_cycle, updated_by, created_at, updated_at FROM performance_rules ORDER BY rule_key'
|
||||
);
|
||||
return rows as RuleRow[];
|
||||
}
|
||||
|
||||
/** Fetch a single rule by key */
|
||||
export async function findRuleByKey(ruleKey: string): Promise<RuleRow | null> {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
'SELECT rule_id, rule_key, rule_value, description, effective_cycle, updated_by, created_at, updated_at FROM performance_rules WHERE rule_key = ? LIMIT 1',
|
||||
[ruleKey]
|
||||
);
|
||||
return rows.length > 0 ? (rows[0] as RuleRow) : null;
|
||||
}
|
||||
|
||||
export interface UpsertRuleData {
|
||||
ruleKey: string;
|
||||
ruleValue: unknown; // will be JSON-serialised
|
||||
description?: string;
|
||||
effectiveCycle?: string;
|
||||
updatedBy: number;
|
||||
}
|
||||
|
||||
/** Insert or update a rule (upsert by rule_key) */
|
||||
export async function upsertRule(data: UpsertRuleData): Promise<void> {
|
||||
const valueJson = JSON.stringify(data.ruleValue);
|
||||
await pool.query(
|
||||
`INSERT INTO performance_rules (rule_key, rule_value, description, effective_cycle, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
rule_value = VALUES(rule_value),
|
||||
description = VALUES(description),
|
||||
effective_cycle = VALUES(effective_cycle),
|
||||
updated_by = VALUES(updated_by),
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
[data.ruleKey, valueJson, data.description ?? null, data.effectiveCycle ?? null, data.updatedBy]
|
||||
);
|
||||
}
|
||||
319
backend/src/dao/PerformanceDAO.ts
Normal file
319
backend/src/dao/PerformanceDAO.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import pool from '../config/database';
|
||||
import { ResultSetHeader } from 'mysql2';
|
||||
|
||||
export type PerformanceStatus = 'draft' | 'submitted' | 'under_review' | 'completed' | 'rejected';
|
||||
export type PerformanceLevel = 'excellent' | 'qualified' | 'need_motivation' | 'unqualified';
|
||||
|
||||
export interface PerformanceRow {
|
||||
perf_id: number;
|
||||
user_id: number;
|
||||
month: string;
|
||||
status: PerformanceStatus;
|
||||
self_score: number | null;
|
||||
ai_score: number | null;
|
||||
manager_score: number | null;
|
||||
total_score: number | null;
|
||||
level: PerformanceLevel | null;
|
||||
reward_punish: string | null;
|
||||
work_summary: string | null;
|
||||
submit_time: Date | null;
|
||||
review_time: Date | null;
|
||||
review_opinion: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface PerfItemRow {
|
||||
item_id?: number;
|
||||
perf_id: number;
|
||||
item_name: string;
|
||||
item_category: 'business' | 'comprehensive';
|
||||
weight: number;
|
||||
user_content: string | null;
|
||||
self_score: number | null;
|
||||
ai_score: number | null;
|
||||
ai_explanation: string | null;
|
||||
manager_score: number | null;
|
||||
manager_explanation: string | null;
|
||||
evidence_url: string | null;
|
||||
}
|
||||
|
||||
export interface AttendanceRow {
|
||||
attendance_id?: number;
|
||||
perf_id: number;
|
||||
leave_days: number;
|
||||
late_times: number;
|
||||
absent_days: number;
|
||||
lack_card_times: number;
|
||||
attendance_score: number | null;
|
||||
remark: string | null;
|
||||
}
|
||||
|
||||
export interface UpsertPerformanceData {
|
||||
userId: number;
|
||||
month: string;
|
||||
status: PerformanceStatus;
|
||||
selfScore?: number;
|
||||
workSummary?: string;
|
||||
items?: Omit<PerfItemRow, 'perf_id' | 'item_id'>[];
|
||||
attendance?: Omit<AttendanceRow, 'perf_id' | 'attendance_id'>;
|
||||
}
|
||||
|
||||
export interface PerformanceFilter {
|
||||
month?: string;
|
||||
department?: string;
|
||||
employeeName?: string;
|
||||
status?: PerformanceStatus;
|
||||
}
|
||||
|
||||
export interface PageInfo {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface ReviewData {
|
||||
managerScore: number;
|
||||
reviewOpinion: string;
|
||||
totalScore: number;
|
||||
level: PerformanceLevel;
|
||||
rewardPunish: string;
|
||||
itemScores: { itemName: string; managerScore: number; managerExplanation: string }[];
|
||||
}
|
||||
|
||||
export interface PerformanceListResult {
|
||||
total: number;
|
||||
records: PerformanceRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a performance record along with its items and attendance.
|
||||
* Uses INSERT ... ON DUPLICATE KEY UPDATE to handle the unique constraint on (user_id, month).
|
||||
*/
|
||||
export async function upsert(data: UpsertPerformanceData): Promise<number> {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
// Upsert performance_month
|
||||
const submitTime = data.status === 'submitted' ? new Date() : null;
|
||||
const [result] = await conn.query<ResultSetHeader>(
|
||||
`INSERT INTO performance_month (user_id, month, status, self_score, work_summary, submit_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = VALUES(status),
|
||||
self_score = VALUES(self_score),
|
||||
work_summary = VALUES(work_summary),
|
||||
submit_time = COALESCE(VALUES(submit_time), submit_time)`,
|
||||
[data.userId, data.month, data.status, data.selfScore ?? null, data.workSummary ?? null, submitTime]
|
||||
);
|
||||
|
||||
// Resolve perf_id (insertId is 0 on UPDATE, so fetch it)
|
||||
let perfId: number = result.insertId;
|
||||
if (perfId === 0) {
|
||||
const [rows] = await conn.query<any[]>(
|
||||
'SELECT perf_id FROM performance_month WHERE user_id = ? AND month = ?',
|
||||
[data.userId, data.month]
|
||||
);
|
||||
perfId = rows[0].perf_id;
|
||||
}
|
||||
|
||||
// Upsert perf_items
|
||||
if (data.items && data.items.length > 0) {
|
||||
// Delete existing items and re-insert for simplicity
|
||||
await conn.query('DELETE FROM perf_item WHERE perf_id = ?', [perfId]);
|
||||
for (const item of data.items) {
|
||||
await conn.query(
|
||||
`INSERT INTO perf_item (perf_id, item_name, item_category, weight, user_content, self_score, evidence_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[perfId, item.item_name, item.item_category, item.weight, item.user_content ?? null, item.self_score ?? null, item.evidence_url ?? null]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert attendance
|
||||
if (data.attendance) {
|
||||
const att = data.attendance;
|
||||
await conn.query(
|
||||
`INSERT INTO attendance (perf_id, leave_days, late_times, absent_days, lack_card_times, attendance_score, remark)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
leave_days = VALUES(leave_days),
|
||||
late_times = VALUES(late_times),
|
||||
absent_days = VALUES(absent_days),
|
||||
lack_card_times = VALUES(lack_card_times),
|
||||
attendance_score = VALUES(attendance_score),
|
||||
remark = VALUES(remark)`,
|
||||
[perfId, att.leave_days, att.late_times, att.absent_days, att.lack_card_times, att.attendance_score ?? null, att.remark ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
await conn.commit();
|
||||
return perfId;
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Query a performance record for a specific user and month, including items and attendance. */
|
||||
export async function findByUserAndMonth(userId: number, month: string): Promise<PerformanceRow | null> {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
'SELECT * FROM performance_month WHERE user_id = ? AND month = ? LIMIT 1',
|
||||
[userId, month]
|
||||
);
|
||||
return rows.length > 0 ? (rows[0] as PerformanceRow) : null;
|
||||
}
|
||||
|
||||
/** Query subordinates' performance records with optional filters and pagination. */
|
||||
export async function findByManagerId(
|
||||
managerId: number,
|
||||
filters: PerformanceFilter,
|
||||
page: PageInfo
|
||||
): Promise<PerformanceListResult> {
|
||||
const conditions: string[] = ['u.manager_id = ?'];
|
||||
const params: any[] = [managerId];
|
||||
|
||||
if (filters.month) {
|
||||
conditions.push('pm.month = ?');
|
||||
params.push(filters.month);
|
||||
}
|
||||
if (filters.department) {
|
||||
conditions.push('u.department = ?');
|
||||
params.push(filters.department);
|
||||
}
|
||||
if (filters.employeeName) {
|
||||
conditions.push('u.name LIKE ?');
|
||||
params.push(`%${filters.employeeName}%`);
|
||||
}
|
||||
if (filters.status) {
|
||||
conditions.push('pm.status = ?');
|
||||
params.push(filters.status);
|
||||
}
|
||||
|
||||
const where = conditions.join(' AND ');
|
||||
const offset = (page.page - 1) * page.pageSize;
|
||||
|
||||
const [countRows] = await pool.query<any[]>(
|
||||
`SELECT COUNT(*) AS total
|
||||
FROM performance_month pm
|
||||
JOIN user u ON pm.user_id = u.user_id
|
||||
WHERE ${where}`,
|
||||
params
|
||||
);
|
||||
const total: number = countRows[0].total;
|
||||
|
||||
const [rows] = await pool.query<any[]>(
|
||||
`SELECT pm.*
|
||||
FROM performance_month pm
|
||||
JOIN user u ON pm.user_id = u.user_id
|
||||
WHERE ${where}
|
||||
ORDER BY pm.month DESC, pm.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, page.pageSize, offset]
|
||||
);
|
||||
|
||||
return { total, records: rows as PerformanceRow[] };
|
||||
}
|
||||
|
||||
/** Update only the status of a performance record. */
|
||||
export async function updateStatus(perfId: number, status: PerformanceStatus): Promise<void> {
|
||||
await pool.query(
|
||||
'UPDATE performance_month SET status = ? WHERE perf_id = ?',
|
||||
[status, perfId]
|
||||
);
|
||||
}
|
||||
|
||||
/** Query performance records for a specific employee with optional month filter and pagination. */
|
||||
export async function findByUserId(
|
||||
userId: number,
|
||||
month: string | undefined,
|
||||
page: PageInfo
|
||||
): Promise<PerformanceListResult> {
|
||||
const conditions: string[] = ['pm.user_id = ?'];
|
||||
const params: any[] = [userId];
|
||||
|
||||
if (month) {
|
||||
conditions.push('pm.month = ?');
|
||||
params.push(month);
|
||||
}
|
||||
|
||||
const where = conditions.join(' AND ');
|
||||
const offset = (page.page - 1) * page.pageSize;
|
||||
|
||||
const [countRows] = await pool.query<any[]>(
|
||||
`SELECT COUNT(*) AS total FROM performance_month pm WHERE ${where}`,
|
||||
params
|
||||
);
|
||||
const total: number = countRows[0].total;
|
||||
|
||||
const [rows] = await pool.query<any[]>(
|
||||
`SELECT pm.* FROM performance_month pm WHERE ${where}
|
||||
ORDER BY pm.month DESC, pm.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, page.pageSize, offset]
|
||||
);
|
||||
|
||||
return { total, records: rows as PerformanceRow[] };
|
||||
}
|
||||
|
||||
/** Query full detail of a performance record including items and attendance. */
|
||||
export async function findDetailByPerfId(perfId: number): Promise<{
|
||||
performance: PerformanceRow;
|
||||
items: PerfItemRow[];
|
||||
attendance: AttendanceRow | null;
|
||||
} | null> {
|
||||
const [perfRows] = await pool.query<any[]>(
|
||||
'SELECT * FROM performance_month WHERE perf_id = ? LIMIT 1',
|
||||
[perfId]
|
||||
);
|
||||
if (perfRows.length === 0) return null;
|
||||
|
||||
const [itemRows] = await pool.query<any[]>(
|
||||
'SELECT * FROM perf_item WHERE perf_id = ?',
|
||||
[perfId]
|
||||
);
|
||||
|
||||
const [attRows] = await pool.query<any[]>(
|
||||
'SELECT * FROM attendance WHERE perf_id = ? LIMIT 1',
|
||||
[perfId]
|
||||
);
|
||||
|
||||
return {
|
||||
performance: perfRows[0] as PerformanceRow,
|
||||
items: itemRows as PerfItemRow[],
|
||||
attendance: attRows.length > 0 ? (attRows[0] as AttendanceRow) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Update manager review results and archive the performance record. */
|
||||
export async function updateReview(perfId: number, reviewData: ReviewData): Promise<void> {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
await conn.query(
|
||||
`UPDATE performance_month
|
||||
SET manager_score = ?, review_opinion = ?, total_score = ?, level = ?,
|
||||
reward_punish = ?, status = 'completed', review_time = NOW()
|
||||
WHERE perf_id = ?`,
|
||||
[reviewData.managerScore, reviewData.reviewOpinion, reviewData.totalScore, reviewData.level, reviewData.rewardPunish, perfId]
|
||||
);
|
||||
|
||||
for (const item of reviewData.itemScores) {
|
||||
await conn.query(
|
||||
`UPDATE perf_item SET manager_score = ?, manager_explanation = ?
|
||||
WHERE perf_id = ? AND item_name = ?`,
|
||||
[item.managerScore, item.managerExplanation, perfId, item.itemName]
|
||||
);
|
||||
}
|
||||
|
||||
await conn.commit();
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
30
backend/src/dao/UserDAO.ts
Normal file
30
backend/src/dao/UserDAO.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import pool from '../config/database';
|
||||
import { UserRole } from '../types';
|
||||
|
||||
export interface UserRow {
|
||||
user_id: number;
|
||||
username: string;
|
||||
password: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
department: string;
|
||||
position: string;
|
||||
manager_id: number | null;
|
||||
status: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
export async function findByUsername(username: string): Promise<UserRow | null> {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
'SELECT user_id, username, password, name, role, department, position, manager_id, status FROM user WHERE username = ? LIMIT 1',
|
||||
[username]
|
||||
);
|
||||
return rows.length > 0 ? (rows[0] as UserRow) : null;
|
||||
}
|
||||
|
||||
export async function findSubordinates(managerId: number): Promise<UserRow[]> {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
'SELECT user_id, username, name, role, department, position, manager_id, status FROM user WHERE manager_id = ? AND status = ?',
|
||||
[managerId, 'active']
|
||||
);
|
||||
return rows as UserRow[];
|
||||
}
|
||||
127
backend/src/db/init.sql
Normal file
127
backend/src/db/init.sql
Normal file
@@ -0,0 +1,127 @@
|
||||
-- 员工月度绩效考核系统数据库初始化脚本
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS employee_performance
|
||||
DEFAULT CHARACTER SET utf8mb4
|
||||
DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE employee_performance;
|
||||
|
||||
-- 1. 用户表
|
||||
CREATE TABLE IF NOT EXISTS user (
|
||||
user_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID',
|
||||
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名(工号)',
|
||||
password VARCHAR(255) NOT NULL COMMENT '密码(bcrypt加密)',
|
||||
name VARCHAR(50) NOT NULL COMMENT '姓名',
|
||||
role ENUM('employee', 'manager', 'generalManager') NOT NULL COMMENT '角色',
|
||||
department VARCHAR(50) NOT NULL COMMENT '部门',
|
||||
position VARCHAR(50) NOT NULL COMMENT '岗位',
|
||||
manager_id INT NULL COMMENT '直属管理层ID',
|
||||
status ENUM('active', 'inactive') DEFAULT 'active' COMMENT '状态',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_role (role),
|
||||
INDEX idx_manager (manager_id),
|
||||
FOREIGN KEY (manager_id) REFERENCES user(user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
|
||||
|
||||
-- 2. 绩效主表
|
||||
CREATE TABLE IF NOT EXISTS performance_month (
|
||||
perf_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '绩效记录ID',
|
||||
user_id INT NOT NULL COMMENT '员工ID',
|
||||
month VARCHAR(7) NOT NULL COMMENT '考核月份(YYYY-MM)',
|
||||
status ENUM('draft', 'submitted', 'under_review', 'completed', 'rejected') NOT NULL DEFAULT 'draft' COMMENT '状态',
|
||||
self_score DECIMAL(5,2) NULL COMMENT '员工自评总分',
|
||||
ai_score DECIMAL(5,2) NULL COMMENT 'AI评分总分',
|
||||
manager_score DECIMAL(5,2) NULL COMMENT '管理层审核总分',
|
||||
total_score DECIMAL(5,2) NULL COMMENT '最终总分',
|
||||
level ENUM('excellent', 'qualified', 'need_motivation', 'unqualified') NULL COMMENT '绩效等级',
|
||||
reward_punish VARCHAR(255) NULL COMMENT '奖惩说明',
|
||||
work_summary TEXT NULL COMMENT '工作汇总',
|
||||
submit_time TIMESTAMP NULL COMMENT '提交时间',
|
||||
review_time TIMESTAMP NULL COMMENT '审核时间',
|
||||
review_opinion TEXT NULL COMMENT '审核意见',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_user_month (user_id, month),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_month (month),
|
||||
FOREIGN KEY (user_id) REFERENCES user(user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='绩效主表';
|
||||
|
||||
-- 3. 绩效项明细表
|
||||
CREATE TABLE IF NOT EXISTS perf_item (
|
||||
item_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '绩效项ID',
|
||||
perf_id INT NOT NULL COMMENT '绩效记录ID',
|
||||
item_name VARCHAR(100) NOT NULL COMMENT '考核项名称',
|
||||
item_category ENUM('business', 'comprehensive') NOT NULL COMMENT '考核项类别',
|
||||
weight INT NOT NULL COMMENT '权重(分数)',
|
||||
user_content TEXT NULL COMMENT '员工填写内容',
|
||||
self_score DECIMAL(5,2) NULL COMMENT '员工自评分',
|
||||
ai_score DECIMAL(5,2) NULL COMMENT 'AI评分',
|
||||
ai_explanation TEXT NULL COMMENT 'AI评分说明',
|
||||
manager_score DECIMAL(5,2) NULL COMMENT '管理层评分',
|
||||
manager_explanation TEXT NULL COMMENT '管理层评分说明',
|
||||
evidence_url VARCHAR(500) NULL COMMENT '佐证材料URL',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_perf (perf_id),
|
||||
FOREIGN KEY (perf_id) REFERENCES performance_month(perf_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='绩效项明细表';
|
||||
|
||||
-- 4. 考勤表
|
||||
CREATE TABLE IF NOT EXISTS attendance (
|
||||
attendance_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '考勤ID',
|
||||
perf_id INT NOT NULL COMMENT '绩效记录ID',
|
||||
leave_days INT DEFAULT 0 COMMENT '事假天数',
|
||||
late_times INT DEFAULT 0 COMMENT '迟到次数',
|
||||
absent_days INT DEFAULT 0 COMMENT '旷工天数',
|
||||
lack_card_times INT DEFAULT 0 COMMENT '缺卡次数',
|
||||
attendance_score DECIMAL(5,2) NULL COMMENT '考勤得分',
|
||||
remark TEXT NULL COMMENT '备注',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_perf (perf_id),
|
||||
FOREIGN KEY (perf_id) REFERENCES performance_month(perf_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考勤表';
|
||||
|
||||
-- 5. AI 结果表
|
||||
CREATE TABLE IF NOT EXISTS ai_result (
|
||||
ai_id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'AI结果ID',
|
||||
perf_id INT NOT NULL COMMENT '绩效记录ID',
|
||||
ai_score_json TEXT NOT NULL COMMENT 'AI评分详情(JSON格式)',
|
||||
ai_total_score DECIMAL(5,2) NOT NULL COMMENT 'AI总分',
|
||||
problems TEXT NULL COMMENT 'AI总结的问题(JSON数组)',
|
||||
suggestions TEXT NULL COMMENT 'AI改进建议(JSON数组)',
|
||||
api_response TEXT NULL COMMENT 'FastGPT原始响应',
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '生成时间',
|
||||
UNIQUE KEY uk_perf (perf_id),
|
||||
FOREIGN KEY (perf_id) REFERENCES performance_month(perf_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI结果表';
|
||||
|
||||
-- 6. 考核规则配置表
|
||||
CREATE TABLE IF NOT EXISTS performance_rules (
|
||||
rule_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '规则ID',
|
||||
rule_key VARCHAR(100) NOT NULL UNIQUE COMMENT '规则键名',
|
||||
rule_value TEXT NOT NULL COMMENT '规则值(JSON格式)',
|
||||
description VARCHAR(255) NULL COMMENT '规则描述',
|
||||
effective_cycle VARCHAR(7) NULL COMMENT '生效周期(YYYY-MM,NULL表示立即生效)',
|
||||
updated_by INT NULL COMMENT '最后修改人ID',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (updated_by) REFERENCES user(user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考核规则配置表';
|
||||
|
||||
-- 7. 操作日志表
|
||||
CREATE TABLE IF NOT EXISTS operation_log (
|
||||
log_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '日志ID',
|
||||
user_id INT NOT NULL COMMENT '操作人ID',
|
||||
operation_type VARCHAR(50) NOT NULL COMMENT '操作类型',
|
||||
target_type VARCHAR(50) NULL COMMENT '目标类型',
|
||||
target_id INT NULL COMMENT '目标ID',
|
||||
operation_detail TEXT NULL COMMENT '操作详情',
|
||||
ip_address VARCHAR(50) NULL COMMENT 'IP地址',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_created (created_at),
|
||||
FOREIGN KEY (user_id) REFERENCES user(user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';
|
||||
43
backend/src/db/seed.sql
Normal file
43
backend/src/db/seed.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
-- 测试数据插入脚本
|
||||
USE employee_performance;
|
||||
|
||||
-- 插入测试用户(密码都是 123456,已用 bcrypt 加密)
|
||||
-- bcrypt hash for '123456': $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
|
||||
|
||||
-- 1. 总经理
|
||||
INSERT INTO user (username, password, name, role, department, position, manager_id, status)
|
||||
VALUES ('gm001', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', '张总', 'generalManager', '管理层', '总经理', NULL, 'active')
|
||||
ON DUPLICATE KEY UPDATE username=username;
|
||||
|
||||
-- 2. 部门经理(技术部)
|
||||
INSERT INTO user (username, password, name, role, department, position, manager_id, status)
|
||||
VALUES ('mgr001', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', '李经理', 'manager', '技术部', '技术经理', 1, 'active')
|
||||
ON DUPLICATE KEY UPDATE username=username;
|
||||
|
||||
-- 3. 部门经理(销售部)
|
||||
INSERT INTO user (username, password, name, role, department, position, manager_id, status)
|
||||
VALUES ('mgr002', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', '王经理', 'manager', '销售部', '销售经理', 1, 'active')
|
||||
ON DUPLICATE KEY UPDATE username=username;
|
||||
|
||||
-- 4. 员工(技术部)
|
||||
INSERT INTO user (username, password, name, role, department, position, manager_id, status)
|
||||
VALUES
|
||||
('emp001', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', '张三', 'employee', '技术部', '前端工程师', 2, 'active'),
|
||||
('emp002', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', '李四', 'employee', '技术部', '后端工程师', 2, 'active'),
|
||||
('emp003', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', '王五', 'employee', '技术部', '测试工程师', 2, 'active')
|
||||
ON DUPLICATE KEY UPDATE username=username;
|
||||
|
||||
-- 5. 员工(销售部)
|
||||
INSERT INTO user (username, password, name, role, department, position, manager_id, status)
|
||||
VALUES
|
||||
('emp004', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', '赵六', 'employee', '销售部', '销售专员', 3, 'active'),
|
||||
('emp005', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', '孙七', 'employee', '销售部', '销售专员', 3, 'active')
|
||||
ON DUPLICATE KEY UPDATE username=username;
|
||||
|
||||
-- 插入默认考核规则配置
|
||||
INSERT INTO performance_rules (rule_key, rule_value, description)
|
||||
VALUES
|
||||
('item_weights', '{"business": 70, "comprehensive": 30}', '考核项权重配置'),
|
||||
('reward_punish', '{"excellent": "按公司规定给予奖励", "qualified_80_89": "扣除当月绩效工资100元", "qualified_70_79": "扣除当月绩效工资200元", "need_motivation": "扣除当月绩效工资300元", "unqualified": "扣除当月绩效工资600元"}', '奖惩金额配置')
|
||||
ON DUPLICATE KEY UPDATE rule_key=rule_key;
|
||||
|
||||
40
backend/src/db/seed.ts
Normal file
40
backend/src/db/seed.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import pool from '../config/database';
|
||||
|
||||
async function runSeed() {
|
||||
try {
|
||||
console.log('开始执行数据库种子数据插入...');
|
||||
|
||||
const sqlFile = path.join(__dirname, 'seed.sql');
|
||||
const sql = fs.readFileSync(sqlFile, 'utf-8');
|
||||
|
||||
// 分割SQL语句(按分号分割,但要注意处理注释)
|
||||
const statements = sql
|
||||
.split(';')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0 && !s.startsWith('--'));
|
||||
|
||||
for (const statement of statements) {
|
||||
if (statement.trim()) {
|
||||
await pool.query(statement);
|
||||
console.log('✓ 执行成功:', statement.substring(0, 50) + '...');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n✅ 数据库种子数据插入完成!');
|
||||
console.log('\n测试账号信息:');
|
||||
console.log('总经理: gm001 / 123456');
|
||||
console.log('技术部经理: mgr001 / 123456');
|
||||
console.log('销售部经理: mgr002 / 123456');
|
||||
console.log('技术部员工: emp001, emp002, emp003 / 123456');
|
||||
console.log('销售部员工: emp004, emp005 / 123456');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ 数据库种子数据插入失败:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runSeed();
|
||||
32
backend/src/index.ts
Normal file
32
backend/src/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import authRouter from './routes/auth';
|
||||
import performanceRouter from './routes/performance';
|
||||
import statisticsRouter from './routes/statistics';
|
||||
import configRouter from './routes/config';
|
||||
import employeeRouter from './routes/employee';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
app.use('/api/user', authRouter);
|
||||
app.use('/api/performance', performanceRouter);
|
||||
app.use('/api/statistics', statisticsRouter);
|
||||
app.use('/api/config', configRouter);
|
||||
app.use('/api/employee', employeeRouter);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
|
||||
export default app;
|
||||
77
backend/src/middlewares/__tests__/authenticate.test.ts
Normal file
77
backend/src/middlewares/__tests__/authenticate.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { authenticate } from '../authenticate';
|
||||
import { JWT_SECRET } from '../../config/jwt';
|
||||
import { UserInfo } from '../../types';
|
||||
|
||||
const mockUser: UserInfo = {
|
||||
userId: 1,
|
||||
name: '张三',
|
||||
role: 'employee',
|
||||
department: '研发部',
|
||||
position: '工程师',
|
||||
};
|
||||
|
||||
function makeReq(authHeader?: string): Partial<Request> {
|
||||
return { headers: authHeader ? { authorization: authHeader } : {} } as any;
|
||||
}
|
||||
|
||||
function makeRes(): { res: Partial<Response>; getStatus: () => number; getBody: () => any } {
|
||||
let statusCode = 200;
|
||||
let body: any = null;
|
||||
const res: Partial<Response> = {
|
||||
status(code: number) { statusCode = code; return this as Response; },
|
||||
json(data: any) { body = data; return this as Response; },
|
||||
};
|
||||
return { res, getStatus: () => statusCode, getBody: () => body };
|
||||
}
|
||||
|
||||
describe('authenticate middleware', () => {
|
||||
it('valid token sets req.user and calls next', () => {
|
||||
const token = jwt.sign(mockUser, JWT_SECRET, { expiresIn: '1h' });
|
||||
const req = makeReq(`Bearer ${token}`) as Request;
|
||||
const { res, getStatus } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
authenticate(req, res as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.user?.userId).toBe(1);
|
||||
expect(getStatus()).toBe(200);
|
||||
});
|
||||
|
||||
it('missing token returns 401', () => {
|
||||
const req = makeReq() as Request;
|
||||
const { res, getStatus, getBody } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
authenticate(req, res as Response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(getStatus()).toBe(401);
|
||||
expect(getBody().code).toBe(401);
|
||||
});
|
||||
|
||||
it('invalid token returns 401', () => {
|
||||
const req = makeReq('Bearer invalidtoken') as Request;
|
||||
const { res, getStatus } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
authenticate(req, res as Response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(getStatus()).toBe(401);
|
||||
});
|
||||
|
||||
it('expired token returns 401', () => {
|
||||
const token = jwt.sign(mockUser, JWT_SECRET, { expiresIn: '-1s' });
|
||||
const req = makeReq(`Bearer ${token}`) as Request;
|
||||
const { res, getStatus } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
authenticate(req, res as Response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(getStatus()).toBe(401);
|
||||
});
|
||||
});
|
||||
158
backend/src/middlewares/__tests__/authorize.property.test.ts
Normal file
158
backend/src/middlewares/__tests__/authorize.property.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as fc from 'fast-check';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { authenticate } from '../authenticate';
|
||||
import { authorize } from '../authorize';
|
||||
import { JWT_SECRET } from '../../config/jwt';
|
||||
import { UserInfo, UserRole } from '../../types';
|
||||
|
||||
// Feature: employee-performance-system, Property 2: 权限隔离不变量
|
||||
// For any employee token, accessing another employee's data always returns 403.
|
||||
|
||||
const ROLES: UserRole[] = ['employee', 'manager', 'generalManager'];
|
||||
|
||||
function makeRes(): { res: Partial<Response>; getStatus: () => number; getBody: () => any } {
|
||||
let statusCode = 200;
|
||||
let body: any = null;
|
||||
const res: Partial<Response> = {
|
||||
status(code: number) { statusCode = code; return this as Response; },
|
||||
json(data: any) { body = data; return this as Response; },
|
||||
};
|
||||
return { res, getStatus: () => statusCode, getBody: () => body };
|
||||
}
|
||||
|
||||
function makeUserInfo(overrides: Partial<UserInfo> = {}): UserInfo {
|
||||
return {
|
||||
userId: 1,
|
||||
name: '测试用户',
|
||||
role: 'employee',
|
||||
department: '研发部',
|
||||
position: '工程师',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Property 2: 权限隔离不变量', () => {
|
||||
it('employee token is always rejected when accessing manager-only routes', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
name: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
department: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
position: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
}),
|
||||
({ userId, name, department, position }) => {
|
||||
const employeeInfo = makeUserInfo({ userId, name, department, position, role: 'employee' });
|
||||
const token = jwt.sign(employeeInfo, JWT_SECRET, { expiresIn: '1h' });
|
||||
|
||||
const req = { headers: { authorization: `Bearer ${token}` } } as Request;
|
||||
const { res: authRes } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
// First pass through authenticate
|
||||
authenticate(req, authRes as Response, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.user?.role).toBe('employee');
|
||||
|
||||
// Then attempt to access manager-only route
|
||||
const { res: authzRes, getStatus, getBody } = makeRes();
|
||||
const authzNext = jest.fn() as NextFunction;
|
||||
authorize('manager', 'generalManager')(req, authzRes as Response, authzNext);
|
||||
|
||||
expect(authzNext).not.toHaveBeenCalled();
|
||||
expect(getStatus()).toBe(403);
|
||||
expect(getBody().code).toBe(403);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('employee token is always rejected when accessing generalManager-only routes', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
}),
|
||||
({ userId }) => {
|
||||
const employeeInfo = makeUserInfo({ userId, role: 'employee' });
|
||||
const token = jwt.sign(employeeInfo, JWT_SECRET, { expiresIn: '1h' });
|
||||
|
||||
const req = { headers: { authorization: `Bearer ${token}` } } as Request;
|
||||
const { res: authRes } = makeRes();
|
||||
const authNext = jest.fn() as NextFunction;
|
||||
authenticate(req, authRes as Response, authNext);
|
||||
|
||||
const { res, getStatus, getBody } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
authorize('generalManager')(req, res as Response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(getStatus()).toBe(403);
|
||||
expect(getBody().code).toBe(403);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('a role is always allowed access to its own permitted routes and denied from others', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
role: fc.constantFrom<UserRole>(...ROLES),
|
||||
targetRole: fc.constantFrom<UserRole>(...ROLES),
|
||||
}).filter(({ role, targetRole }) => role !== targetRole),
|
||||
({ userId, role, targetRole }) => {
|
||||
const userInfo = makeUserInfo({ userId, role });
|
||||
const token = jwt.sign(userInfo, JWT_SECRET, { expiresIn: '1h' });
|
||||
|
||||
const req = { headers: { authorization: `Bearer ${token}` } } as Request;
|
||||
const { res: authRes } = makeRes();
|
||||
const authNext = jest.fn() as NextFunction;
|
||||
authenticate(req, authRes as Response, authNext);
|
||||
|
||||
// Accessing a route that only allows targetRole (which differs from user's role)
|
||||
const { res, getStatus } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
authorize(targetRole)(req, res as Response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(getStatus()).toBe(403);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('any role is always allowed when its role is in the permitted list', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
role: fc.constantFrom<UserRole>(...ROLES),
|
||||
}),
|
||||
({ userId, role }) => {
|
||||
const userInfo = makeUserInfo({ userId, role });
|
||||
const token = jwt.sign(userInfo, JWT_SECRET, { expiresIn: '1h' });
|
||||
|
||||
const req = { headers: { authorization: `Bearer ${token}` } } as Request;
|
||||
const { res: authRes } = makeRes();
|
||||
const authNext = jest.fn() as NextFunction;
|
||||
authenticate(req, authRes as Response, authNext);
|
||||
|
||||
// Route allows all roles
|
||||
const { res, getStatus } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
authorize(...ROLES)(req, res as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(getStatus()).toBe(200); // unchanged — no error set
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
65
backend/src/middlewares/__tests__/authorize.test.ts
Normal file
65
backend/src/middlewares/__tests__/authorize.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { authorize } from '../authorize';
|
||||
import { UserInfo } from '../../types';
|
||||
|
||||
function makeReq(user?: UserInfo): Partial<Request> {
|
||||
return { user } as any;
|
||||
}
|
||||
|
||||
function makeRes(): { res: Partial<Response>; getStatus: () => number; getBody: () => any } {
|
||||
let statusCode = 200;
|
||||
let body: any = null;
|
||||
const res: Partial<Response> = {
|
||||
status(code: number) { statusCode = code; return this as Response; },
|
||||
json(data: any) { body = data; return this as Response; },
|
||||
};
|
||||
return { res, getStatus: () => statusCode, getBody: () => body };
|
||||
}
|
||||
|
||||
const employee: UserInfo = { userId: 1, name: '张三', role: 'employee', department: '研发部', position: '工程师' };
|
||||
const manager: UserInfo = { userId: 2, name: '李四', role: 'manager', department: '研发部', position: '经理' };
|
||||
|
||||
describe('authorize middleware', () => {
|
||||
it('allowed role calls next', () => {
|
||||
const req = makeReq(employee) as Request;
|
||||
const { res } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
authorize('employee')(req, res as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disallowed role returns 403', () => {
|
||||
const req = makeReq(employee) as Request;
|
||||
const { res, getStatus, getBody } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
authorize('manager')(req, res as Response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(getStatus()).toBe(403);
|
||||
expect(getBody().code).toBe(403);
|
||||
});
|
||||
|
||||
it('multiple allowed roles — matching role calls next', () => {
|
||||
const req = makeReq(manager) as Request;
|
||||
const { res } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
authorize('manager', 'generalManager')(req, res as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('no user on request returns 401', () => {
|
||||
const req = makeReq(undefined) as Request;
|
||||
const { res, getStatus } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
authorize('employee')(req, res as Response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(getStatus()).toBe(401);
|
||||
});
|
||||
});
|
||||
24
backend/src/middlewares/authenticate.ts
Normal file
24
backend/src/middlewares/authenticate.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { JWT_SECRET } from '../config/jwt';
|
||||
import { UserInfo } from '../types';
|
||||
|
||||
export function authenticate(req: Request, res: Response, next: NextFunction): void {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.startsWith('Bearer ')
|
||||
? authHeader.slice(7)
|
||||
: null;
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({ code: 401, message: '未提供访问令牌' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as UserInfo;
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ code: 401, message: '访问令牌无效或已过期' });
|
||||
}
|
||||
}
|
||||
20
backend/src/middlewares/authorize.ts
Normal file
20
backend/src/middlewares/authorize.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '../types';
|
||||
|
||||
export function authorize(...allowedRoles: UserRole[]) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const user = req.user;
|
||||
|
||||
if (!user) {
|
||||
res.status(401).json({ code: 401, message: '未认证' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allowedRoles.includes(user.role)) {
|
||||
res.status(403).json({ code: 403, message: '权限不足' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
368
backend/src/routes/__tests__/performance.property.test.ts
Normal file
368
backend/src/routes/__tests__/performance.property.test.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import * as fc from 'fast-check';
|
||||
import * as PerformanceDAO from '../../dao/PerformanceDAO';
|
||||
import * as AIResultDAO from '../../dao/AIResultDAO';
|
||||
import { UpsertPerformanceData, PerformanceRow, PerformanceStatus } from '../../dao/PerformanceDAO';
|
||||
|
||||
jest.mock('../../dao/PerformanceDAO');
|
||||
jest.mock('../../dao/AIResultDAO');
|
||||
|
||||
const mockUpsert = PerformanceDAO.upsert as jest.MockedFunction<typeof PerformanceDAO.upsert>;
|
||||
const mockFindByUserAndMonth = PerformanceDAO.findByUserAndMonth as jest.MockedFunction<typeof PerformanceDAO.findByUserAndMonth>;
|
||||
const mockFindByUserId = PerformanceDAO.findByUserId as jest.MockedFunction<typeof PerformanceDAO.findByUserId>;
|
||||
const mockFindDetailByPerfId = PerformanceDAO.findDetailByPerfId as jest.MockedFunction<typeof PerformanceDAO.findDetailByPerfId>;
|
||||
const mockFindAIByPerfId = AIResultDAO.findByPerfId as jest.MockedFunction<typeof AIResultDAO.findByPerfId>;
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Arbitraries
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const monthArb = fc.tuple(
|
||||
fc.integer({ min: 2020, max: 2030 }),
|
||||
fc.integer({ min: 1, max: 12 })
|
||||
).map(([y, m]) => `${y}-${String(m).padStart(2, '0')}`);
|
||||
|
||||
const perfItemArb = fc.record({
|
||||
item_name: fc.string({ minLength: 1, maxLength: 30 }),
|
||||
item_category: fc.constantFrom<'business' | 'comprehensive'>('business', 'comprehensive'),
|
||||
weight: fc.integer({ min: 1, max: 20 }),
|
||||
user_content: fc.string({ minLength: 1, maxLength: 200 }),
|
||||
self_score: fc.integer({ min: 0, max: 100 }),
|
||||
ai_score: fc.constant(null),
|
||||
ai_explanation: fc.constant(null),
|
||||
manager_score: fc.constant(null),
|
||||
manager_explanation: fc.constant(null),
|
||||
evidence_url: fc.constant(null),
|
||||
});
|
||||
|
||||
const attendanceArb = fc.record({
|
||||
leave_days: fc.integer({ min: 0, max: 5 }),
|
||||
late_times: fc.integer({ min: 0, max: 5 }),
|
||||
absent_days: fc.integer({ min: 0, max: 3 }),
|
||||
lack_card_times: fc.integer({ min: 0, max: 5 }),
|
||||
attendance_score: fc.constant(null),
|
||||
remark: fc.constant(null),
|
||||
});
|
||||
|
||||
const draftDataArb = fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
month: monthArb,
|
||||
selfScore: fc.integer({ min: 0, max: 100 }),
|
||||
workSummary: fc.string({ minLength: 1, maxLength: 500 }),
|
||||
items: fc.array(perfItemArb, { minLength: 1, maxLength: 5 }),
|
||||
attendance: attendanceArb,
|
||||
});
|
||||
|
||||
function makePerformanceRow(overrides: Partial<PerformanceRow> & { user_id: number; month: string; perf_id: number }): PerformanceRow {
|
||||
return {
|
||||
perf_id: overrides.perf_id,
|
||||
user_id: overrides.user_id,
|
||||
month: overrides.month,
|
||||
status: overrides.status ?? 'draft',
|
||||
self_score: overrides.self_score ?? null,
|
||||
ai_score: null,
|
||||
manager_score: null,
|
||||
total_score: null,
|
||||
level: null,
|
||||
reward_punish: null,
|
||||
work_summary: overrides.work_summary ?? null,
|
||||
submit_time: null,
|
||||
review_time: null,
|
||||
review_opinion: null,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Feature: employee-performance-system, Property 3: 草稿暂存往返一致性
|
||||
// For any performance draft data, saving as draft then reading back should
|
||||
// return data consistent with what was saved.
|
||||
// Validates: Requirements 2.5
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Property 3: 草稿暂存往返一致性', () => {
|
||||
it('draft saved then read back returns consistent userId, month, status, selfScore, and workSummary', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
draftDataArb,
|
||||
async ({ userId, month, selfScore, workSummary, items, attendance }) => {
|
||||
const perfId = Math.floor(Math.random() * 9000) + 1;
|
||||
|
||||
// Simulate upsert returning a perfId
|
||||
mockUpsert.mockResolvedValue(perfId);
|
||||
|
||||
// Simulate reading back the saved draft
|
||||
const savedRow = makePerformanceRow({
|
||||
perf_id: perfId,
|
||||
user_id: userId,
|
||||
month,
|
||||
status: 'draft',
|
||||
self_score: selfScore,
|
||||
work_summary: workSummary,
|
||||
});
|
||||
mockFindByUserAndMonth.mockResolvedValue(savedRow);
|
||||
|
||||
// Step 1: Save draft
|
||||
const upsertData: UpsertPerformanceData = {
|
||||
userId,
|
||||
month,
|
||||
status: 'draft',
|
||||
selfScore,
|
||||
workSummary,
|
||||
items,
|
||||
attendance,
|
||||
};
|
||||
const returnedPerfId = await PerformanceDAO.upsert(upsertData);
|
||||
|
||||
// Step 2: Read back
|
||||
const readBack = await PerformanceDAO.findByUserAndMonth(userId, month);
|
||||
|
||||
// Round-trip consistency checks
|
||||
expect(readBack).not.toBeNull();
|
||||
expect(readBack!.user_id).toBe(userId);
|
||||
expect(readBack!.month).toBe(month);
|
||||
expect(readBack!.status).toBe('draft');
|
||||
expect(readBack!.self_score).toBe(selfScore);
|
||||
expect(readBack!.work_summary).toBe(workSummary);
|
||||
expect(readBack!.perf_id).toBe(returnedPerfId);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('draft status is preserved (not auto-promoted to submitted)', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
month: monthArb,
|
||||
}),
|
||||
async ({ userId, month }) => {
|
||||
const perfId = 42;
|
||||
mockUpsert.mockResolvedValue(perfId);
|
||||
mockFindByUserAndMonth.mockResolvedValue(
|
||||
makePerformanceRow({ perf_id: perfId, user_id: userId, month, status: 'draft' })
|
||||
);
|
||||
|
||||
await PerformanceDAO.upsert({ userId, month, status: 'draft' });
|
||||
const readBack = await PerformanceDAO.findByUserAndMonth(userId, month);
|
||||
|
||||
expect(readBack!.status).toBe('draft');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Feature: employee-performance-system, Property 4: 提交幂等性
|
||||
// For any already-submitted performance record, attempting to submit again for
|
||||
// the same user and month must be rejected — the record status stays unchanged.
|
||||
// Validates: Requirements 2.6
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Property 4: 提交幂等性', () => {
|
||||
const alreadySubmittedStatuses: PerformanceStatus[] = ['submitted', 'under_review', 'completed'];
|
||||
|
||||
it('re-submitting an already-submitted record is rejected for all terminal statuses', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
month: monthArb,
|
||||
existingStatus: fc.constantFrom<PerformanceStatus>(...alreadySubmittedStatuses),
|
||||
}),
|
||||
async ({ userId, month, existingStatus }) => {
|
||||
const perfId = 99;
|
||||
|
||||
// Existing record is already in a submitted/terminal state
|
||||
mockFindByUserAndMonth.mockResolvedValue(
|
||||
makePerformanceRow({ perf_id: perfId, user_id: userId, month, status: existingStatus })
|
||||
);
|
||||
|
||||
// The route checks findByUserAndMonth before calling upsert
|
||||
const existing = await PerformanceDAO.findByUserAndMonth(userId, month);
|
||||
|
||||
// Idempotency guard: if already submitted/under_review/completed, reject
|
||||
const shouldReject =
|
||||
existing !== null &&
|
||||
(existing.status === 'submitted' ||
|
||||
existing.status === 'under_review' ||
|
||||
existing.status === 'completed');
|
||||
|
||||
expect(shouldReject).toBe(true);
|
||||
|
||||
// upsert must NOT have been called (the route returns 400 before calling upsert)
|
||||
expect(mockUpsert).not.toHaveBeenCalled();
|
||||
|
||||
// The existing record status must remain unchanged
|
||||
expect(existing!.status).toBe(existingStatus);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('submitting when no prior record exists is always allowed', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
month: monthArb,
|
||||
}),
|
||||
async ({ userId, month }) => {
|
||||
// No existing record
|
||||
mockFindByUserAndMonth.mockResolvedValue(null);
|
||||
mockUpsert.mockResolvedValue(1);
|
||||
|
||||
const existing = await PerformanceDAO.findByUserAndMonth(userId, month);
|
||||
|
||||
// No existing record → submission is allowed
|
||||
const shouldReject =
|
||||
existing !== null &&
|
||||
(existing.status === 'submitted' ||
|
||||
existing.status === 'under_review' ||
|
||||
existing.status === 'completed');
|
||||
|
||||
expect(shouldReject).toBe(false);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('a draft record does not block a new submission', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
month: monthArb,
|
||||
}),
|
||||
async ({ userId, month }) => {
|
||||
// Existing record is only a draft
|
||||
mockFindByUserAndMonth.mockResolvedValue(
|
||||
makePerformanceRow({ perf_id: 1, user_id: userId, month, status: 'draft' })
|
||||
);
|
||||
|
||||
const existing = await PerformanceDAO.findByUserAndMonth(userId, month);
|
||||
|
||||
const shouldReject =
|
||||
existing !== null &&
|
||||
(existing.status === 'submitted' ||
|
||||
existing.status === 'under_review' ||
|
||||
existing.status === 'completed');
|
||||
|
||||
// Draft should NOT block submission
|
||||
expect(shouldReject).toBe(false);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Feature: employee-performance-system, Property 7: 绩效记录查询完整性
|
||||
// For any submitted or completed performance record, querying the employee's
|
||||
// history must include that record (insert/query round-trip).
|
||||
// Validates: Requirements 6.1
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Property 7: 绩效记录查询完整性', () => {
|
||||
const queryableStatuses: PerformanceStatus[] = ['submitted', 'completed', 'under_review'];
|
||||
|
||||
it('a submitted record always appears in the employee query result', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
month: monthArb,
|
||||
status: fc.constantFrom<PerformanceStatus>(...queryableStatuses),
|
||||
selfScore: fc.integer({ min: 0, max: 100 }),
|
||||
}),
|
||||
async ({ userId, month, status, selfScore }) => {
|
||||
const perfId = Math.floor(Math.random() * 9000) + 1;
|
||||
|
||||
// Simulate the record existing after submission
|
||||
const submittedRow = makePerformanceRow({
|
||||
perf_id: perfId,
|
||||
user_id: userId,
|
||||
month,
|
||||
status,
|
||||
self_score: selfScore,
|
||||
});
|
||||
|
||||
mockFindByUserId.mockResolvedValue({ total: 1, records: [submittedRow] });
|
||||
|
||||
// Query the employee's performance list
|
||||
const result = await PerformanceDAO.findByUserId(userId, undefined, { page: 1, pageSize: 10 });
|
||||
|
||||
// The submitted record must appear in the results
|
||||
expect(result.total).toBeGreaterThanOrEqual(1);
|
||||
const found = result.records.find((r) => r.perf_id === perfId);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.user_id).toBe(userId);
|
||||
expect(found!.month).toBe(month);
|
||||
expect(found!.status).toBe(status);
|
||||
expect(found!.self_score).toBe(selfScore);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('querying by month filter returns only records matching that month', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
month: monthArb,
|
||||
}),
|
||||
async ({ userId, month }) => {
|
||||
const perfId = Math.floor(Math.random() * 9000) + 1;
|
||||
const row = makePerformanceRow({ perf_id: perfId, user_id: userId, month, status: 'submitted' });
|
||||
|
||||
mockFindByUserId.mockResolvedValue({ total: 1, records: [row] });
|
||||
|
||||
const result = await PerformanceDAO.findByUserId(userId, month, { page: 1, pageSize: 10 });
|
||||
|
||||
// All returned records must match the queried month
|
||||
for (const rec of result.records) {
|
||||
expect(rec.month).toBe(month);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('total count is consistent with the number of returned records', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
recordCount: fc.integer({ min: 0, max: 10 }),
|
||||
}),
|
||||
async ({ userId, recordCount }) => {
|
||||
const records = Array.from({ length: recordCount }, (_, i) =>
|
||||
makePerformanceRow({
|
||||
perf_id: i + 1,
|
||||
user_id: userId,
|
||||
month: `2024-${String((i % 12) + 1).padStart(2, '0')}`,
|
||||
status: 'submitted',
|
||||
})
|
||||
);
|
||||
|
||||
mockFindByUserId.mockResolvedValue({ total: recordCount, records });
|
||||
|
||||
const result = await PerformanceDAO.findByUserId(userId, undefined, { page: 1, pageSize: 20 });
|
||||
|
||||
expect(result.total).toBe(recordCount);
|
||||
expect(result.records.length).toBe(recordCount);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
27
backend/src/routes/auth.ts
Normal file
27
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { login } from '../services/AuthService';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// POST /api/user/login
|
||||
router.post('/login', async (req: Request, res: Response) => {
|
||||
console.log('收到登录请求:', req.body);
|
||||
const { username, password, role } = req.body;
|
||||
|
||||
if (!username || !password || !role) {
|
||||
console.log('参数验证失败');
|
||||
return res.status(400).json({ code: 400, message: '用户名、密码和角色不能为空' });
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('调用登录服务...');
|
||||
const result = await login(username, password, role);
|
||||
console.log('登录成功:', result.userInfo);
|
||||
return res.json({ code: 200, message: '登录成功', data: result });
|
||||
} catch (err: any) {
|
||||
console.error('登录失败:', err.message);
|
||||
return res.status(401).json({ code: 401, message: err.message || '登录失败' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
125
backend/src/routes/config.ts
Normal file
125
backend/src/routes/config.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { authenticate } from '../middlewares/authenticate';
|
||||
import { authorize } from '../middlewares/authorize';
|
||||
import { getAllRules, getRuleByKey, updateRule, updateRules } from '../services/ConfigService';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
// ─── GET /api/config/rules ────────────────────────────────────────────────────
|
||||
// Returns all current performance evaluation rules.
|
||||
// Requirements: 8.5
|
||||
router.get(
|
||||
'/rules',
|
||||
authorize('generalManager'),
|
||||
async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const rules = await getAllRules();
|
||||
return res.json({ code: 200, message: '查询成功', data: rules });
|
||||
} catch (err) {
|
||||
console.error('[config/rules GET]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── GET /api/config/rules/:ruleKey ──────────────────────────────────────────
|
||||
// Returns a single rule by its key.
|
||||
// Requirements: 8.5
|
||||
router.get(
|
||||
'/rules/:ruleKey',
|
||||
authorize('generalManager'),
|
||||
async (req: Request, res: Response) => {
|
||||
const { ruleKey } = req.params;
|
||||
try {
|
||||
const rule = await getRuleByKey(ruleKey);
|
||||
if (!rule) {
|
||||
return res.status(404).json({ code: 404, message: '规则不存在' });
|
||||
}
|
||||
return res.json({ code: 200, message: '查询成功', data: rule });
|
||||
} catch (err) {
|
||||
console.error('[config/rules/:ruleKey GET]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── PUT /api/config/rules ────────────────────────────────────────────────────
|
||||
// Batch update multiple rules. Logs each change and applies to subsequent cycles.
|
||||
// Body: { rules: Array<{ ruleKey, ruleValue, description?, effectiveCycle? }> }
|
||||
// Requirements: 8.5, 8.6
|
||||
router.put(
|
||||
'/rules',
|
||||
authorize('generalManager'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const { rules } = req.body as { rules?: unknown[] };
|
||||
|
||||
if (!Array.isArray(rules) || rules.length === 0) {
|
||||
return res.status(400).json({ code: 400, message: 'rules 数组不能为空' });
|
||||
}
|
||||
|
||||
// Validate each rule entry
|
||||
for (const item of rules) {
|
||||
const r = item as Record<string, unknown>;
|
||||
if (!r.ruleKey || typeof r.ruleKey !== 'string') {
|
||||
return res.status(400).json({ code: 400, message: '每条规则必须包含有效的 ruleKey' });
|
||||
}
|
||||
if (r.ruleValue === undefined) {
|
||||
return res.status(400).json({ code: 400, message: `规则 ${r.ruleKey} 缺少 ruleValue` });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await updateRules(
|
||||
(rules as Array<Record<string, unknown>>).map((r) => ({
|
||||
ruleKey: r.ruleKey as string,
|
||||
ruleValue: r.ruleValue,
|
||||
description: r.description as string | undefined,
|
||||
effectiveCycle: r.effectiveCycle as string | undefined,
|
||||
})),
|
||||
user.userId
|
||||
);
|
||||
return res.json({ code: 200, message: '规则更新成功', data: updated });
|
||||
} catch (err) {
|
||||
console.error('[config/rules PUT]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── PUT /api/config/rules/:ruleKey ──────────────────────────────────────────
|
||||
// Update a single rule by key. Logs the change and applies to subsequent cycles.
|
||||
// Body: { ruleValue, description?, effectiveCycle? }
|
||||
// Requirements: 8.5, 8.6
|
||||
router.put(
|
||||
'/rules/:ruleKey',
|
||||
authorize('generalManager'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const { ruleKey } = req.params;
|
||||
const { ruleValue, description, effectiveCycle } = req.body as {
|
||||
ruleValue?: unknown;
|
||||
description?: string;
|
||||
effectiveCycle?: string;
|
||||
};
|
||||
|
||||
if (ruleValue === undefined) {
|
||||
return res.status(400).json({ code: 400, message: 'ruleValue 不能为空' });
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await updateRule(
|
||||
{ ruleKey, ruleValue, description, effectiveCycle },
|
||||
user.userId
|
||||
);
|
||||
return res.json({ code: 200, message: '规则更新成功', data: updated });
|
||||
} catch (err) {
|
||||
console.error('[config/rules/:ruleKey PUT]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
129
backend/src/routes/employee.ts
Normal file
129
backend/src/routes/employee.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { authenticate } from '../middlewares/authenticate';
|
||||
import { authorize } from '../middlewares/authorize';
|
||||
import pool from '../config/database';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate);
|
||||
|
||||
// GET /api/employee/list — 获取下属员工列表(管理层)
|
||||
router.get('/list', authorize('manager', 'generalManager'), async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
try {
|
||||
let rows: any[];
|
||||
if (user.role === 'generalManager') {
|
||||
[rows] = await pool.query<any[]>(
|
||||
`SELECT user_id, username, name, department, position, status, created_at
|
||||
FROM user WHERE role = 'employee' ORDER BY department, name`
|
||||
);
|
||||
} else {
|
||||
[rows] = await pool.query<any[]>(
|
||||
`SELECT user_id, username, name, department, position, status, created_at
|
||||
FROM user WHERE role = 'employee' AND manager_id = ? ORDER BY name`,
|
||||
[user.userId]
|
||||
);
|
||||
}
|
||||
return res.json({ code: 200, message: '查询成功', data: rows });
|
||||
} catch (err) {
|
||||
console.error('[employee/list]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/employee/create — 新建员工账号
|
||||
router.post('/create', authorize('manager', 'generalManager'), async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const { username, password, name, department, position } = req.body;
|
||||
|
||||
if (!username || !password || !name || !department || !position) {
|
||||
return res.status(400).json({ code: 400, message: '用户名、密码、姓名、部门、岗位均为必填' });
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查用户名是否已存在
|
||||
const [existing] = await pool.query<any[]>('SELECT user_id FROM user WHERE username = ?', [username]);
|
||||
if (existing.length > 0) {
|
||||
return res.status(400).json({ code: 400, message: '用户名已存在' });
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
const managerId = user.role === 'manager' ? user.userId : null;
|
||||
|
||||
const [result] = await pool.query<any>(
|
||||
`INSERT INTO user (username, password, name, role, department, position, manager_id, status)
|
||||
VALUES (?, ?, ?, 'employee', ?, ?, ?, 'active')`,
|
||||
[username, hashedPassword, name, department, position, managerId]
|
||||
);
|
||||
|
||||
return res.json({ code: 200, message: '员工账号创建成功', data: { userId: result.insertId } });
|
||||
} catch (err) {
|
||||
console.error('[employee/create]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/employee/:userId — 删除员工账号
|
||||
router.delete('/:userId', authorize('manager', 'generalManager'), async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const targetId = parseInt(req.params.userId, 10);
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
'SELECT user_id, manager_id, role FROM user WHERE user_id = ?', [targetId]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ code: 404, message: '员工不存在' });
|
||||
}
|
||||
if (rows[0].role !== 'employee') {
|
||||
return res.status(400).json({ code: 400, message: '只能删除员工账号' });
|
||||
}
|
||||
// 管理层只能删除自己的下属
|
||||
if (user.role === 'manager' && rows[0].manager_id !== user.userId) {
|
||||
return res.status(403).json({ code: 403, message: '权限不足,该员工不是您的下属' });
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM user WHERE user_id = ?', [targetId]);
|
||||
return res.json({ code: 200, message: '员工账号已删除' });
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ER_ROW_IS_REFERENCED_2') {
|
||||
return res.status(400).json({ code: 400, message: '该员工有绩效记录,无法直接删除,请先归档相关数据' });
|
||||
}
|
||||
console.error('[employee/delete]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/employee/:userId/status — 禁用/启用员工账号
|
||||
router.patch('/:userId/status', authorize('manager', 'generalManager'), async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const targetId = parseInt(req.params.userId, 10);
|
||||
const { status } = req.body; // 'active' | 'inactive'
|
||||
|
||||
if (status !== 'active' && status !== 'inactive') {
|
||||
return res.status(400).json({ code: 400, message: 'status 必须为 active 或 inactive' });
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
'SELECT user_id, manager_id, role FROM user WHERE user_id = ?', [targetId]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ code: 404, message: '员工不存在' });
|
||||
}
|
||||
if (rows[0].role !== 'employee') {
|
||||
return res.status(400).json({ code: 400, message: '只能操作员工账号' });
|
||||
}
|
||||
if (user.role === 'manager' && rows[0].manager_id !== user.userId) {
|
||||
return res.status(403).json({ code: 403, message: '权限不足,该员工不是您的下属' });
|
||||
}
|
||||
|
||||
await pool.query('UPDATE user SET status = ? WHERE user_id = ?', [status, targetId]);
|
||||
return res.json({ code: 200, message: status === 'inactive' ? '员工账号已禁用' : '员工账号已启用' });
|
||||
} catch (err) {
|
||||
console.error('[employee/status]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
801
backend/src/routes/performance.ts
Normal file
801
backend/src/routes/performance.ts
Normal file
@@ -0,0 +1,801 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { authenticate } from '../middlewares/authenticate';
|
||||
import { authorize } from '../middlewares/authorize';
|
||||
import * as PerformanceDAO from '../dao/PerformanceDAO';
|
||||
import { calculateAttendanceScore, calculateLevelAndReward } from '../services/CalculationService';
|
||||
import { exportPerformanceExcel } from '../services/ExportService';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All performance routes require authentication
|
||||
router.use(authenticate);
|
||||
|
||||
// ─── 6.1 POST /api/performance/submit ────────────────────────────────────────
|
||||
// Supports draft (暂存) and submit (提交) states.
|
||||
// Writes performance_month, perf_item, attendance in a transaction.
|
||||
// On submit, triggers async AI evaluation (non-blocking).
|
||||
router.post(
|
||||
'/submit',
|
||||
authorize('employee'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const { month, status, selfScore, workSummary, items, performanceItems, attendance } = req.body;
|
||||
|
||||
if (!month || !status) {
|
||||
return res.status(400).json({ code: 400, message: '月份和状态不能为空' });
|
||||
}
|
||||
if (status !== 'draft' && status !== 'submitted') {
|
||||
return res.status(400).json({ code: 400, message: '状态值无效,必须为 draft 或 submitted' });
|
||||
}
|
||||
|
||||
// If submitting, check for existing submitted/completed record (idempotency guard)
|
||||
if (status === 'submitted') {
|
||||
const existing = await PerformanceDAO.findByUserAndMonth(user.userId, month);
|
||||
if (existing && (existing.status === 'submitted' || existing.status === 'completed' || existing.status === 'under_review')) {
|
||||
return res.status(400).json({ code: 400, message: '该月份绩效已提交,不可重复提交' });
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate attendance score if attendance data provided
|
||||
let attendanceData: PerformanceDAO.AttendanceRow | undefined;
|
||||
if (attendance) {
|
||||
const score = calculateAttendanceScore({
|
||||
leaveDays: attendance.leave ?? attendance.leave_days ?? 0,
|
||||
lateTimes: attendance.late ?? attendance.late_times ?? 0,
|
||||
lackCardTimes: attendance.lackCard ?? attendance.lack_card_times ?? 0,
|
||||
});
|
||||
attendanceData = {
|
||||
perf_id: 0, // will be set by DAO
|
||||
leave_days: attendance.leave ?? attendance.leave_days ?? 0,
|
||||
late_times: attendance.late ?? attendance.late_times ?? 0,
|
||||
absent_days: attendance.absent ?? attendance.absent_days ?? 0,
|
||||
lack_card_times: attendance.lackCard ?? attendance.lack_card_times ?? 0,
|
||||
attendance_score: score,
|
||||
remark: attendance.remark ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// Convert performanceItems to items format if needed
|
||||
const itemsData = performanceItems || items;
|
||||
const formattedItems = itemsData?.map((item: any) => ({
|
||||
item_name: item.itemName || item.item_name,
|
||||
item_category: item.itemCategory || item.item_category || 'business',
|
||||
weight: item.weight,
|
||||
user_content: item.userContent || item.user_content,
|
||||
self_score: item.selfScore || item.self_score,
|
||||
evidence_url: item.evidence || item.evidence_url,
|
||||
}));
|
||||
|
||||
// 计算自评总分:各项 self_score * weight 加权平均
|
||||
let calculatedSelfScore: number | undefined;
|
||||
if (formattedItems && formattedItems.length > 0) {
|
||||
let weightedSum = 0;
|
||||
let totalWeight = 0;
|
||||
for (const item of formattedItems) {
|
||||
if (item.self_score != null && item.weight != null) {
|
||||
weightedSum += Number(item.self_score) * Number(item.weight);
|
||||
totalWeight += Number(item.weight);
|
||||
}
|
||||
}
|
||||
if (totalWeight > 0) {
|
||||
calculatedSelfScore = parseFloat((weightedSum / totalWeight).toFixed(2));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const perfId = await PerformanceDAO.upsert({
|
||||
userId: user.userId,
|
||||
month,
|
||||
status,
|
||||
selfScore: calculatedSelfScore,
|
||||
workSummary,
|
||||
items: formattedItems,
|
||||
attendance: attendanceData,
|
||||
});
|
||||
|
||||
// Trigger async AI evaluation on submit (non-blocking)
|
||||
if (status === 'submitted') {
|
||||
triggerAIEvaluation(perfId).catch((err) => {
|
||||
console.error(`[AI] Evaluation failed for perfId=${perfId}:`, err);
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ code: 200, message: status === 'draft' ? '暂存成功' : '提交成功', data: { perfId } });
|
||||
} catch (err: any) {
|
||||
// Duplicate key = already submitted
|
||||
if (err.code === 'ER_DUP_ENTRY') {
|
||||
return res.status(400).json({ code: 400, message: '该月份绩效已存在' });
|
||||
}
|
||||
console.error('[submit]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── 6.2 GET /api/performance/employee/get ───────────────────────────────────
|
||||
// Returns the authenticated employee's performance records.
|
||||
// Supports month filter and pagination. Includes AI result, attendance, review opinion.
|
||||
router.get(
|
||||
'/employee/get',
|
||||
authorize('employee'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const { month, page = '1', pageSize = '10', perfId } = req.query as Record<string, string>;
|
||||
|
||||
// 如果提供了 perfId,返回单条记录详情
|
||||
if (perfId) {
|
||||
try {
|
||||
const { findDetailByPerfId } = await import('../dao/PerformanceDAO');
|
||||
const { findByPerfId: findAIByPerfId } = await import('../dao/AIResultDAO');
|
||||
|
||||
const detail = await findDetailByPerfId(parseInt(perfId, 10));
|
||||
if (!detail || detail.performance.user_id !== user.userId) {
|
||||
return res.status(404).json({ code: 404, message: '绩效记录不存在' });
|
||||
}
|
||||
|
||||
const aiResult = await findAIByPerfId(parseInt(perfId, 10));
|
||||
const rec = detail.performance;
|
||||
|
||||
// 转换为驼峰格式,确保数值字段为 number 类型
|
||||
const result = {
|
||||
perfId: rec.perf_id,
|
||||
userId: rec.user_id,
|
||||
month: rec.month,
|
||||
status: rec.status,
|
||||
selfScore: rec.self_score != null ? Number(rec.self_score) : undefined,
|
||||
aiScore: rec.ai_score != null ? Number(rec.ai_score) : undefined,
|
||||
managerScore: rec.manager_score != null ? Number(rec.manager_score) : undefined,
|
||||
totalScore: rec.total_score != null ? Number(rec.total_score) : undefined,
|
||||
level: rec.level,
|
||||
rewardPunish: rec.reward_punish,
|
||||
workSummary: rec.work_summary,
|
||||
submitTime: rec.submit_time,
|
||||
reviewTime: rec.review_time,
|
||||
reviewOpinion: rec.review_opinion,
|
||||
performanceItems: detail.items?.map(item => ({
|
||||
itemId: item.item_id,
|
||||
perfId: item.perf_id,
|
||||
itemName: item.item_name,
|
||||
itemCategory: item.item_category,
|
||||
weight: Number(item.weight),
|
||||
userContent: item.user_content,
|
||||
selfScore: item.self_score != null ? Number(item.self_score) : undefined,
|
||||
aiScore: item.ai_score != null ? Number(item.ai_score) : undefined,
|
||||
aiExplanation: item.ai_explanation,
|
||||
managerScore: item.manager_score != null ? Number(item.manager_score) : undefined,
|
||||
managerExplanation: item.manager_explanation,
|
||||
evidenceUrl: item.evidence_url,
|
||||
})) ?? [],
|
||||
attendance: detail.attendance ? {
|
||||
leave: Number(detail.attendance.leave_days),
|
||||
late: Number(detail.attendance.late_times),
|
||||
absent: Number(detail.attendance.absent_days),
|
||||
lackCard: Number(detail.attendance.lack_card_times),
|
||||
attendanceScore: detail.attendance.attendance_score != null ? Number(detail.attendance.attendance_score) : undefined,
|
||||
remark: detail.attendance.remark,
|
||||
} : null,
|
||||
aiResult: aiResult ? {
|
||||
aiId: aiResult.aiId,
|
||||
perfId: aiResult.perfId,
|
||||
aiScoreDetail: aiResult.aiScoreDetail,
|
||||
aiTotalScore: aiResult.aiTotalScore,
|
||||
aiProblems: aiResult.aiProblems,
|
||||
aiSuggestions: aiResult.aiSuggestions,
|
||||
createTime: aiResult.createTime,
|
||||
} : null,
|
||||
};
|
||||
|
||||
return res.json({
|
||||
code: 200,
|
||||
message: '查询成功',
|
||||
data: result,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[employee/get detail]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
|
||||
// 否则返回列表
|
||||
const pageInfo: PerformanceDAO.PageInfo = {
|
||||
page: Math.max(1, parseInt(page, 10) || 1),
|
||||
pageSize: Math.min(100, Math.max(1, parseInt(pageSize, 10) || 10)),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await PerformanceDAO.findByUserId(user.userId, month, pageInfo);
|
||||
|
||||
// Enrich each record with attendance and AI result
|
||||
const { findDetailByPerfId } = await import('../dao/PerformanceDAO');
|
||||
const { findByPerfId: findAIByPerfId } = await import('../dao/AIResultDAO');
|
||||
|
||||
const enriched = await Promise.all(
|
||||
result.records.map(async (rec) => {
|
||||
const detail = await findDetailByPerfId(rec.perf_id);
|
||||
const aiResult = await findAIByPerfId(rec.perf_id);
|
||||
|
||||
// 转换为驼峰格式,确保数值字段为 number 类型
|
||||
return {
|
||||
perfId: rec.perf_id,
|
||||
userId: rec.user_id,
|
||||
month: rec.month,
|
||||
status: rec.status,
|
||||
selfScore: rec.self_score != null ? Number(rec.self_score) : undefined,
|
||||
aiScore: rec.ai_score != null ? Number(rec.ai_score) : undefined,
|
||||
managerScore: rec.manager_score != null ? Number(rec.manager_score) : undefined,
|
||||
totalScore: rec.total_score != null ? Number(rec.total_score) : undefined,
|
||||
level: rec.level,
|
||||
rewardPunish: rec.reward_punish,
|
||||
workSummary: rec.work_summary,
|
||||
submitTime: rec.submit_time,
|
||||
reviewTime: rec.review_time,
|
||||
reviewOpinion: rec.review_opinion,
|
||||
performanceItems: detail?.items?.map(item => ({
|
||||
itemId: item.item_id,
|
||||
perfId: item.perf_id,
|
||||
itemName: item.item_name,
|
||||
itemCategory: item.item_category,
|
||||
weight: Number(item.weight),
|
||||
userContent: item.user_content,
|
||||
selfScore: item.self_score != null ? Number(item.self_score) : undefined,
|
||||
aiScore: item.ai_score != null ? Number(item.ai_score) : undefined,
|
||||
aiExplanation: item.ai_explanation,
|
||||
managerScore: item.manager_score != null ? Number(item.manager_score) : undefined,
|
||||
managerExplanation: item.manager_explanation,
|
||||
evidenceUrl: item.evidence_url,
|
||||
})) ?? [],
|
||||
attendance: detail?.attendance ? {
|
||||
leave: Number(detail.attendance.leave_days),
|
||||
late: Number(detail.attendance.late_times),
|
||||
absent: Number(detail.attendance.absent_days),
|
||||
lackCard: Number(detail.attendance.lack_card_times),
|
||||
attendanceScore: detail.attendance.attendance_score != null ? Number(detail.attendance.attendance_score) : undefined,
|
||||
remark: detail.attendance.remark,
|
||||
} : null,
|
||||
aiResult: aiResult ? {
|
||||
aiId: aiResult.aiId,
|
||||
perfId: aiResult.perfId,
|
||||
aiScoreDetail: aiResult.aiScoreDetail,
|
||||
aiTotalScore: aiResult.aiTotalScore,
|
||||
aiProblems: aiResult.aiProblems,
|
||||
aiSuggestions: aiResult.aiSuggestions,
|
||||
createTime: aiResult.createTime,
|
||||
} : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return res.json({
|
||||
code: 200,
|
||||
message: '查询成功',
|
||||
data: { total: result.total, records: enriched },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[employee/get]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── 6.3 POST /api/performance/request-modification ──────────────────────────
|
||||
// Employee requests modification of a submitted performance record.
|
||||
// Records the reason and notifies the manager (logged for now).
|
||||
router.post(
|
||||
'/request-modification',
|
||||
authorize('employee'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const { perfId, reason } = req.body;
|
||||
|
||||
if (!perfId || !reason) {
|
||||
return res.status(400).json({ code: 400, message: '绩效ID和申请原因不能为空' });
|
||||
}
|
||||
|
||||
try {
|
||||
const detail = await PerformanceDAO.findDetailByPerfId(Number(perfId));
|
||||
if (!detail) {
|
||||
return res.status(404).json({ code: 404, message: '绩效记录不存在' });
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (detail.performance.user_id !== user.userId) {
|
||||
return res.status(403).json({ code: 403, message: '权限不足' });
|
||||
}
|
||||
|
||||
// Only submitted records can request modification
|
||||
if (detail.performance.status !== 'submitted' && detail.performance.status !== 'under_review') {
|
||||
return res.status(400).json({ code: 400, message: '当前状态不允许申请修改' });
|
||||
}
|
||||
|
||||
// Record the modification request in operation_log
|
||||
const pool = (await import('../config/database')).default;
|
||||
await pool.query(
|
||||
`INSERT INTO operation_log (user_id, operation_type, target_type, target_id, operation_detail)
|
||||
VALUES (?, 'request_modification', 'performance', ?, ?)`,
|
||||
[user.userId, perfId, JSON.stringify({ reason })]
|
||||
);
|
||||
|
||||
// Notify manager: in a real system this would send a notification;
|
||||
// here we log it as a manager-targeted operation log entry.
|
||||
console.log(`[修改申请] 员工 ${user.name}(${user.userId}) 申请修改绩效 ${perfId},原因:${reason}`);
|
||||
|
||||
return res.json({ code: 200, message: '修改申请已提交,等待管理层审批' });
|
||||
} catch (err) {
|
||||
console.error('[request-modification]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── 8.1 GET /api/performance/manager/list ───────────────────────────────────
|
||||
// Returns subordinates' performance list with optional filters and pagination.
|
||||
// Supports filtering by month, department, employee name, and status.
|
||||
// Requirements: 4.1, 7.1, 7.2
|
||||
router.get(
|
||||
'/manager/list',
|
||||
authorize('manager', 'generalManager'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const {
|
||||
month,
|
||||
department,
|
||||
employeeName,
|
||||
status,
|
||||
page = '1',
|
||||
pageSize = '10',
|
||||
} = req.query as Record<string, string>;
|
||||
|
||||
const pageInfo: PerformanceDAO.PageInfo = {
|
||||
page: Math.max(1, parseInt(page, 10) || 1),
|
||||
pageSize: Math.min(100, Math.max(1, parseInt(pageSize, 10) || 10)),
|
||||
};
|
||||
|
||||
const filters: PerformanceDAO.PerformanceFilter = {
|
||||
month: month || undefined,
|
||||
department: department || undefined,
|
||||
employeeName: employeeName || undefined,
|
||||
status: (status as PerformanceDAO.PerformanceStatus) || undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await PerformanceDAO.findByManagerId(user.userId, filters, pageInfo);
|
||||
|
||||
// Enrich each record with employee info
|
||||
const pool = (await import('../config/database')).default;
|
||||
const enriched = await Promise.all(
|
||||
result.records.map(async (rec) => {
|
||||
const [userRows] = await pool.query<any[]>(
|
||||
'SELECT name, department, position FROM user WHERE user_id = ? LIMIT 1',
|
||||
[rec.user_id]
|
||||
);
|
||||
return {
|
||||
perfId: rec.perf_id,
|
||||
userId: rec.user_id,
|
||||
month: rec.month,
|
||||
status: rec.status,
|
||||
selfScore: rec.self_score != null ? Number(rec.self_score) : null,
|
||||
aiScore: rec.ai_score != null ? Number(rec.ai_score) : null,
|
||||
managerScore: rec.manager_score != null ? Number(rec.manager_score) : null,
|
||||
totalScore: rec.total_score != null ? Number(rec.total_score) : null,
|
||||
level: rec.level,
|
||||
submitTime: rec.submit_time,
|
||||
reviewTime: rec.review_time,
|
||||
// 员工信息
|
||||
userName: userRows[0]?.name ?? null,
|
||||
userDepartment: userRows[0]?.department ?? null,
|
||||
userPosition: userRows[0]?.position ?? null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return res.json({
|
||||
code: 200,
|
||||
message: '查询成功',
|
||||
data: { total: result.total, records: enriched },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[manager/list]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── GET /api/performance/manager/detail/:perfId ─────────────────────────────
|
||||
// Returns full detail of a subordinate's performance record for manager review.
|
||||
router.get(
|
||||
'/manager/detail/:perfId',
|
||||
authorize('manager', 'generalManager'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const perfId = parseInt(req.params.perfId, 10);
|
||||
|
||||
try {
|
||||
const detail = await PerformanceDAO.findDetailByPerfId(perfId);
|
||||
if (!detail) {
|
||||
return res.status(404).json({ code: 404, message: '绩效记录不存在' });
|
||||
}
|
||||
|
||||
// Verify subordinate relationship (generalManager can view all)
|
||||
if (user.role !== 'generalManager') {
|
||||
const pool = (await import('../config/database')).default;
|
||||
const [empRows] = await pool.query<any[]>(
|
||||
'SELECT manager_id, name, department, position FROM user WHERE user_id = ? LIMIT 1',
|
||||
[detail.performance.user_id]
|
||||
);
|
||||
if (!empRows[0] || empRows[0].manager_id !== user.userId) {
|
||||
return res.status(403).json({ code: 403, message: '权限不足' });
|
||||
}
|
||||
}
|
||||
|
||||
const pool = (await import('../config/database')).default;
|
||||
const [empRows] = await pool.query<any[]>(
|
||||
'SELECT name, department, position FROM user WHERE user_id = ? LIMIT 1',
|
||||
[detail.performance.user_id]
|
||||
);
|
||||
|
||||
const { findByPerfId: findAIByPerfId } = await import('../dao/AIResultDAO');
|
||||
const aiResult = await findAIByPerfId(perfId);
|
||||
const rec = detail.performance;
|
||||
|
||||
const result = {
|
||||
perfId: rec.perf_id,
|
||||
userId: rec.user_id,
|
||||
month: rec.month,
|
||||
status: rec.status,
|
||||
selfScore: rec.self_score != null ? Number(rec.self_score) : null,
|
||||
aiScore: rec.ai_score != null ? Number(rec.ai_score) : null,
|
||||
managerScore: rec.manager_score != null ? Number(rec.manager_score) : null,
|
||||
totalScore: rec.total_score != null ? Number(rec.total_score) : null,
|
||||
level: rec.level,
|
||||
rewardPunish: rec.reward_punish,
|
||||
workSummary: rec.work_summary,
|
||||
submitTime: rec.submit_time,
|
||||
reviewTime: rec.review_time,
|
||||
reviewOpinion: rec.review_opinion,
|
||||
// 员工信息
|
||||
userName: empRows[0]?.name ?? null,
|
||||
userDepartment: empRows[0]?.department ?? null,
|
||||
userPosition: empRows[0]?.position ?? null,
|
||||
performanceItems: detail.items?.map(item => ({
|
||||
itemId: item.item_id,
|
||||
itemName: item.item_name,
|
||||
itemCategory: item.item_category,
|
||||
weight: Number(item.weight),
|
||||
userContent: item.user_content,
|
||||
selfScore: item.self_score != null ? Number(item.self_score) : null,
|
||||
aiScore: item.ai_score != null ? Number(item.ai_score) : null,
|
||||
aiExplanation: item.ai_explanation,
|
||||
managerScore: item.manager_score != null ? Number(item.manager_score) : null,
|
||||
managerExplanation: item.manager_explanation,
|
||||
evidenceUrl: item.evidence_url,
|
||||
})) ?? [],
|
||||
attendance: detail.attendance ? {
|
||||
leave: Number(detail.attendance.leave_days),
|
||||
late: Number(detail.attendance.late_times),
|
||||
absent: Number(detail.attendance.absent_days),
|
||||
lackCard: Number(detail.attendance.lack_card_times),
|
||||
attendanceScore: detail.attendance.attendance_score != null ? Number(detail.attendance.attendance_score) : null,
|
||||
remark: detail.attendance.remark,
|
||||
} : null,
|
||||
aiResult: aiResult ? {
|
||||
aiTotalScore: aiResult.aiTotalScore,
|
||||
aiProblems: aiResult.aiProblems,
|
||||
aiSuggestions: aiResult.aiSuggestions,
|
||||
} : null,
|
||||
};
|
||||
|
||||
return res.json({ code: 200, message: '查询成功', data: result });
|
||||
} catch (err) {
|
||||
console.error('[manager/detail]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── 8.2 POST /api/performance/manager/review ────────────────────────────────
|
||||
// Manager reviews a performance record: updates item scores, calculates final
|
||||
// total score, level, reward/punishment, and archives the record (status → completed).
|
||||
// Requirements: 4.3, 4.4, 4.5, 4.7
|
||||
router.post(
|
||||
'/manager/review',
|
||||
authorize('manager', 'generalManager'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const { perfId, reviewOpinion, itemScores } = req.body;
|
||||
|
||||
if (!perfId || !reviewOpinion || !Array.isArray(itemScores)) {
|
||||
return res.status(400).json({ code: 400, message: '绩效ID、审核意见和考核项评分不能为空' });
|
||||
}
|
||||
|
||||
try {
|
||||
const detail = await PerformanceDAO.findDetailByPerfId(Number(perfId));
|
||||
if (!detail) {
|
||||
return res.status(404).json({ code: 404, message: '绩效记录不存在' });
|
||||
}
|
||||
|
||||
// Verify the performance belongs to a subordinate of this manager
|
||||
const pool = (await import('../config/database')).default;
|
||||
const [empRows] = await pool.query<any[]>(
|
||||
'SELECT manager_id FROM user WHERE user_id = ? LIMIT 1',
|
||||
[detail.performance.user_id]
|
||||
);
|
||||
if (!empRows[0] || empRows[0].manager_id !== user.userId) {
|
||||
return res.status(403).json({ code: 403, message: '权限不足,该员工不是您的下属' });
|
||||
}
|
||||
|
||||
// Only submitted/under_review records can be reviewed
|
||||
if (
|
||||
detail.performance.status !== 'submitted' &&
|
||||
detail.performance.status !== 'under_review'
|
||||
) {
|
||||
return res.status(400).json({ code: 400, message: '当前状态不允许审核' });
|
||||
}
|
||||
|
||||
// Build a map of item scores provided by manager
|
||||
const scoreMap = new Map<string, { managerScore: number; managerExplanation: string }>();
|
||||
for (const s of itemScores) {
|
||||
if (typeof s.itemName !== 'string' || typeof s.managerScore !== 'number') {
|
||||
return res.status(400).json({ code: 400, message: '考核项评分格式无效' });
|
||||
}
|
||||
scoreMap.set(s.itemName, {
|
||||
managerScore: s.managerScore,
|
||||
managerExplanation: s.managerExplanation ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate manager total score: weighted average of item manager scores
|
||||
let weightedSum = 0;
|
||||
let totalWeight = 0;
|
||||
for (const item of detail.items) {
|
||||
const scored = scoreMap.get(item.item_name);
|
||||
const score = scored ? scored.managerScore : (item.ai_score ?? item.self_score ?? 0);
|
||||
weightedSum += score * item.weight;
|
||||
totalWeight += item.weight;
|
||||
}
|
||||
const managerScore = totalWeight > 0 ? weightedSum / totalWeight : 0;
|
||||
|
||||
// Get attendance score
|
||||
const attendanceScore = detail.attendance?.attendance_score ?? 0;
|
||||
|
||||
// Final total score = manager score (already weighted across items) + attendance contribution
|
||||
// The items already include attendance-category items; use managerScore directly as total
|
||||
const totalScore = Math.min(100, Math.max(0, parseFloat(managerScore.toFixed(2))));
|
||||
|
||||
const { level, rewardPunish } = calculateLevelAndReward(totalScore);
|
||||
|
||||
const reviewData: PerformanceDAO.ReviewData = {
|
||||
managerScore: parseFloat(managerScore.toFixed(2)),
|
||||
reviewOpinion,
|
||||
totalScore,
|
||||
level,
|
||||
rewardPunish,
|
||||
itemScores: detail.items.map((item) => {
|
||||
const scored = scoreMap.get(item.item_name);
|
||||
return {
|
||||
itemName: item.item_name,
|
||||
managerScore: scored ? scored.managerScore : (item.ai_score ?? item.self_score ?? 0),
|
||||
managerExplanation: scored ? scored.managerExplanation : '',
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
await PerformanceDAO.updateReview(Number(perfId), reviewData);
|
||||
|
||||
// Log the review action
|
||||
await pool.query(
|
||||
`INSERT INTO operation_log (user_id, operation_type, target_type, target_id, operation_detail)
|
||||
VALUES (?, 'review_performance', 'performance', ?, ?)`,
|
||||
[user.userId, perfId, JSON.stringify({ totalScore, level, rewardPunish })]
|
||||
);
|
||||
|
||||
return res.json({
|
||||
code: 200,
|
||||
message: '审核完成',
|
||||
data: { totalScore, level, rewardPunish },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[manager/review]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── 8.3 POST /api/performance/manager/reject ────────────────────────────────
|
||||
// Manager rejects a performance record, recording the reason and setting status to rejected.
|
||||
// Requirements: 4.6
|
||||
router.post(
|
||||
'/manager/reject',
|
||||
authorize('manager', 'generalManager'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const { perfId, reason } = req.body;
|
||||
|
||||
if (!perfId || !reason) {
|
||||
return res.status(400).json({ code: 400, message: '绩效ID和驳回原因不能为空' });
|
||||
}
|
||||
|
||||
try {
|
||||
const detail = await PerformanceDAO.findDetailByPerfId(Number(perfId));
|
||||
if (!detail) {
|
||||
return res.status(404).json({ code: 404, message: '绩效记录不存在' });
|
||||
}
|
||||
|
||||
// Verify subordinate relationship
|
||||
const pool = (await import('../config/database')).default;
|
||||
const [empRows] = await pool.query<any[]>(
|
||||
'SELECT manager_id FROM user WHERE user_id = ? LIMIT 1',
|
||||
[detail.performance.user_id]
|
||||
);
|
||||
if (!empRows[0] || empRows[0].manager_id !== user.userId) {
|
||||
return res.status(403).json({ code: 403, message: '权限不足,该员工不是您的下属' });
|
||||
}
|
||||
|
||||
if (
|
||||
detail.performance.status !== 'submitted' &&
|
||||
detail.performance.status !== 'under_review'
|
||||
) {
|
||||
return res.status(400).json({ code: 400, message: '当前状态不允许驳回' });
|
||||
}
|
||||
|
||||
// Update status to rejected and record the reason in review_opinion
|
||||
await pool.query(
|
||||
`UPDATE performance_month
|
||||
SET status = 'rejected', review_opinion = ?, review_time = NOW()
|
||||
WHERE perf_id = ?`,
|
||||
[reason, perfId]
|
||||
);
|
||||
|
||||
// Log the rejection
|
||||
await pool.query(
|
||||
`INSERT INTO operation_log (user_id, operation_type, target_type, target_id, operation_detail)
|
||||
VALUES (?, 'reject_performance', 'performance', ?, ?)`,
|
||||
[user.userId, perfId, JSON.stringify({ reason })]
|
||||
);
|
||||
|
||||
console.log(`[驳回] 管理层 ${user.name}(${user.userId}) 驳回绩效 ${perfId},原因:${reason}`);
|
||||
|
||||
return res.json({ code: 200, message: '驳回成功,已通知员工' });
|
||||
} catch (err) {
|
||||
console.error('[manager/reject]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── 8.4 POST /api/performance/manager/approve-modification ──────────────────
|
||||
// Manager approves or rejects an employee's modification request.
|
||||
// Approve: unlocks the performance record (status → draft).
|
||||
// Reject: notifies employee (logged).
|
||||
// Requirements: 12.3, 12.4
|
||||
router.post(
|
||||
'/manager/approve-modification',
|
||||
authorize('manager', 'generalManager'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const { perfId, approved, rejectReason } = req.body;
|
||||
|
||||
if (!perfId || typeof approved !== 'boolean') {
|
||||
return res.status(400).json({ code: 400, message: '绩效ID和审批结果不能为空' });
|
||||
}
|
||||
if (!approved && !rejectReason) {
|
||||
return res.status(400).json({ code: 400, message: '拒绝时必须填写拒绝原因' });
|
||||
}
|
||||
|
||||
try {
|
||||
const detail = await PerformanceDAO.findDetailByPerfId(Number(perfId));
|
||||
if (!detail) {
|
||||
return res.status(404).json({ code: 404, message: '绩效记录不存在' });
|
||||
}
|
||||
|
||||
// Verify subordinate relationship
|
||||
const pool = (await import('../config/database')).default;
|
||||
const [empRows] = await pool.query<any[]>(
|
||||
'SELECT manager_id, name FROM user WHERE user_id = ? LIMIT 1',
|
||||
[detail.performance.user_id]
|
||||
);
|
||||
if (!empRows[0] || empRows[0].manager_id !== user.userId) {
|
||||
return res.status(403).json({ code: 403, message: '权限不足,该员工不是您的下属' });
|
||||
}
|
||||
|
||||
if (approved) {
|
||||
// Unlock: reset status to draft so employee can re-edit
|
||||
await pool.query(
|
||||
`UPDATE performance_month SET status = 'draft' WHERE perf_id = ?`,
|
||||
[perfId]
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO operation_log (user_id, operation_type, target_type, target_id, operation_detail)
|
||||
VALUES (?, 'approve_modification', 'performance', ?, ?)`,
|
||||
[user.userId, perfId, JSON.stringify({ approved: true })]
|
||||
);
|
||||
|
||||
console.log(`[修改审批] 管理层 ${user.name}(${user.userId}) 同意员工 ${empRows[0].name} 修改绩效 ${perfId}`);
|
||||
return res.json({ code: 200, message: '已同意修改申请,绩效表单已解锁' });
|
||||
} else {
|
||||
// Reject: log the rejection reason as notification to employee
|
||||
await pool.query(
|
||||
`INSERT INTO operation_log (user_id, operation_type, target_type, target_id, operation_detail)
|
||||
VALUES (?, 'reject_modification', 'performance', ?, ?)`,
|
||||
[user.userId, perfId, JSON.stringify({ approved: false, rejectReason })]
|
||||
);
|
||||
|
||||
console.log(`[修改审批] 管理层 ${user.name}(${user.userId}) 拒绝员工 ${empRows[0].name} 修改绩效 ${perfId},原因:${rejectReason}`);
|
||||
return res.json({ code: 200, message: '已拒绝修改申请,已通知员工' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[manager/approve-modification]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── 10.3 GET /api/performance/export ────────────────────────────────────────
|
||||
// Exports performance records as an Excel file.
|
||||
// Query params:
|
||||
// - userId: export a single employee's history (employee self or manager/GM)
|
||||
// - month: restrict to a specific month
|
||||
// - scope: 'team' (manager's subordinates) | 'company' (GM, all employees)
|
||||
// Requirements: 7.3, 7.6, 8.4
|
||||
router.get(
|
||||
'/export',
|
||||
authorize('employee', 'manager', 'generalManager'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const { userId, month, scope } = req.query as Record<string, string>;
|
||||
|
||||
try {
|
||||
let filter: Parameters<typeof exportPerformanceExcel>[0] = {};
|
||||
|
||||
if (user.role === 'employee') {
|
||||
// Employees can only export their own data
|
||||
filter.userId = user.userId;
|
||||
if (month) filter.month = month;
|
||||
} else if (user.role === 'manager') {
|
||||
if (userId) {
|
||||
// Export a specific subordinate's history — verify relationship
|
||||
const pool = (await import('../config/database')).default;
|
||||
const [empRows] = await pool.query<any[]>(
|
||||
'SELECT manager_id FROM user WHERE user_id = ? LIMIT 1',
|
||||
[Number(userId)]
|
||||
);
|
||||
if (!empRows[0] || empRows[0].manager_id !== user.userId) {
|
||||
return res.status(403).json({ code: 403, message: '权限不足,该员工不是您的下属' });
|
||||
}
|
||||
filter.userId = Number(userId);
|
||||
} else {
|
||||
// Export entire team
|
||||
filter.managerId = user.userId;
|
||||
}
|
||||
if (month) filter.month = month;
|
||||
} else if (user.role === 'generalManager') {
|
||||
if (userId) {
|
||||
filter.userId = Number(userId);
|
||||
} else if (scope === 'company') {
|
||||
filter.allEmployees = true;
|
||||
} else {
|
||||
filter.allEmployees = true;
|
||||
}
|
||||
if (month) filter.month = month;
|
||||
}
|
||||
|
||||
const buffer = await exportPerformanceExcel(filter);
|
||||
|
||||
const filename = encodeURIComponent(`绩效数据_${month ?? '全部'}.xlsx`);
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${filename}`);
|
||||
return res.send(buffer);
|
||||
} catch (err) {
|
||||
console.error('[performance/export]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Internal helper ──────────────────────────────────────────────────────────
|
||||
async function triggerAIEvaluation(perfId: number): Promise<void> {
|
||||
const { evaluatePerformance } = await import('../services/AIEvaluationService');
|
||||
await evaluatePerformance(perfId);
|
||||
}
|
||||
|
||||
export default router;
|
||||
58
backend/src/routes/statistics.ts
Normal file
58
backend/src/routes/statistics.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { authenticate } from '../middlewares/authenticate';
|
||||
import { authorize } from '../middlewares/authorize';
|
||||
import { getTeamStatistics, getCompanyStatistics } from '../services/StatisticsService';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
// ─── 10.1 GET /api/statistics/team ───────────────────────────────────────────
|
||||
// Returns team statistics for the authenticated manager's subordinates.
|
||||
// Includes average score, count and rate per performance level.
|
||||
// Requirements: 7.4, 7.5
|
||||
router.get(
|
||||
'/team',
|
||||
authorize('manager', 'generalManager'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const { month } = req.query as Record<string, string>;
|
||||
|
||||
if (!month) {
|
||||
return res.status(400).json({ code: 400, message: '月份参数不能为空' });
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await getTeamStatistics(user.userId, month);
|
||||
return res.json({ code: 200, message: '查询成功', data: stats });
|
||||
} catch (err) {
|
||||
console.error('[statistics/team]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── 10.2 GET /api/statistics/company ────────────────────────────────────────
|
||||
// Returns company-wide statistics broken down by department, position, and level.
|
||||
// Requirements: 8.1, 8.2
|
||||
router.get(
|
||||
'/company',
|
||||
authorize('generalManager'),
|
||||
async (req: Request, res: Response) => {
|
||||
const { month } = req.query as Record<string, string>;
|
||||
|
||||
if (!month) {
|
||||
return res.status(400).json({ code: 400, message: '月份参数不能为空' });
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await getCompanyStatistics(month);
|
||||
return res.json({ code: 200, message: '查询成功', data: stats });
|
||||
} catch (err) {
|
||||
console.error('[statistics/company]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
377
backend/src/services/AIEvaluationService.ts
Normal file
377
backend/src/services/AIEvaluationService.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import axios from 'axios';
|
||||
import * as PerformanceDAO from '../dao/PerformanceDAO';
|
||||
import * as AIResultDAO from '../dao/AIResultDAO';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AIScoreData {
|
||||
aiScoreDetail: AIResultDAO.AIScoreItem[];
|
||||
aiTotalScore: number;
|
||||
aiProblems: string[];
|
||||
aiSuggestions: string[];
|
||||
}
|
||||
|
||||
// ─── Prompt Builder ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the prompt sent to FastGPT.
|
||||
* Includes employee position, month, performance items, attendance, and scoring rules.
|
||||
* Requirements: 13.2
|
||||
*/
|
||||
export function buildPrompt(
|
||||
performance: PerformanceDAO.PerformanceRow,
|
||||
items: PerformanceDAO.PerfItemRow[],
|
||||
attendance: PerformanceDAO.AttendanceRow | null,
|
||||
position: string
|
||||
): string {
|
||||
const itemsText = items
|
||||
.map(
|
||||
(item, idx) =>
|
||||
`${idx + 1}. 【${item.item_name}】(${item.item_category === 'business' ? '业务素质' : '综合素质'},权重${item.weight}分)\n` +
|
||||
` 员工填写内容:${item.user_content ?? '(未填写)'}\n` +
|
||||
` 员工自评分:${item.self_score ?? '(未填写)'}`
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
const attendanceText = attendance
|
||||
? `事假${attendance.leave_days}天,迟到${attendance.late_times}次,缺卡${attendance.lack_card_times}次,旷工${attendance.absent_days}天`
|
||||
: '无考勤数据';
|
||||
|
||||
return `你是一名专业的HR绩效评估专家,请根据以下员工绩效填报内容进行客观评分。
|
||||
|
||||
## 员工信息
|
||||
- 岗位:${position}
|
||||
- 考核月份:${performance.month}
|
||||
|
||||
## 考核项目及填报内容
|
||||
${itemsText}
|
||||
|
||||
## 考勤情况
|
||||
${attendanceText}
|
||||
|
||||
## 工作汇总
|
||||
${performance.work_summary ?? '(未填写)'}
|
||||
|
||||
## 评分规则
|
||||
- 业务素质考评占总分70%,综合素质考评占总分30%
|
||||
- 每个考核项按0-100分评分,最终加权计算总分
|
||||
- 请根据员工填写内容的质量、完整性和实际工作表现进行评分
|
||||
|
||||
## 输出要求
|
||||
请严格按照以下JSON格式输出,不要包含任何其他内容:
|
||||
{
|
||||
"ai_score_detail": [
|
||||
{
|
||||
"itemName": "考核项名称",
|
||||
"weight": 权重分值,
|
||||
"aiScore": 评分(0-100),
|
||||
"scoreExplanation": "评分说明"
|
||||
}
|
||||
],
|
||||
"ai_total_score": 加权总分(0-100),
|
||||
"ai_problems": ["问题1", "问题2", "问题3"],
|
||||
"ai_suggestions": ["建议1", "建议2", "建议3"]
|
||||
}
|
||||
|
||||
注意:
|
||||
- ai_problems 和 ai_suggestions 各需要3到5条
|
||||
- ai_total_score 为各项加权分数之和,业务素质项权重之和为70,综合素质项权重之和为30
|
||||
- 所有字段必须存在且格式正确`;
|
||||
}
|
||||
|
||||
// ─── Response Parser ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse and validate the AI JSON response.
|
||||
* Extracts ai_score_detail, ai_total_score, ai_problems, ai_suggestions.
|
||||
* Throws on invalid format, recording the raw response.
|
||||
* Requirements: 3.2, 3.3, 3.4, 13.3, 13.4
|
||||
*/
|
||||
export function parseAIResponse(rawResponse: string): AIScoreData {
|
||||
let parsed: any;
|
||||
|
||||
// Extract JSON from the response (model may wrap it in markdown code blocks)
|
||||
const jsonMatch = rawResponse.match(/```(?:json)?\s*([\s\S]*?)```/) ||
|
||||
rawResponse.match(/(\{[\s\S]*\})/);
|
||||
|
||||
const jsonStr = jsonMatch ? jsonMatch[1].trim() : rawResponse.trim();
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(jsonStr);
|
||||
} catch (e) {
|
||||
throw new Error(`AI响应JSON解析失败,原始响应:${rawResponse}`);
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!Array.isArray(parsed.ai_score_detail)) {
|
||||
throw new Error(`AI响应缺少 ai_score_detail 字段,原始响应:${rawResponse}`);
|
||||
}
|
||||
if (typeof parsed.ai_total_score !== 'number') {
|
||||
throw new Error(`AI响应缺少有效的 ai_total_score 字段,原始响应:${rawResponse}`);
|
||||
}
|
||||
if (!Array.isArray(parsed.ai_problems) || parsed.ai_problems.length < 1) {
|
||||
throw new Error(`AI响应缺少 ai_problems 字段,原始响应:${rawResponse}`);
|
||||
}
|
||||
if (!Array.isArray(parsed.ai_suggestions) || parsed.ai_suggestions.length < 1) {
|
||||
throw new Error(`AI响应缺少 ai_suggestions 字段,原始响应:${rawResponse}`);
|
||||
}
|
||||
|
||||
// Validate and map score detail items
|
||||
const aiScoreDetail: AIResultDAO.AIScoreItem[] = parsed.ai_score_detail.map((item: any, idx: number) => {
|
||||
if (typeof item.itemName !== 'string' || typeof item.weight !== 'number' ||
|
||||
typeof item.aiScore !== 'number' || typeof item.scoreExplanation !== 'string') {
|
||||
throw new Error(`AI响应 ai_score_detail[${idx}] 格式无效,原始响应:${rawResponse}`);
|
||||
}
|
||||
return {
|
||||
itemName: item.itemName,
|
||||
weight: item.weight,
|
||||
aiScore: item.aiScore,
|
||||
scoreExplanation: item.scoreExplanation,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
aiScoreDetail,
|
||||
aiTotalScore: parsed.ai_total_score,
|
||||
aiProblems: parsed.ai_problems as string[],
|
||||
aiSuggestions: parsed.ai_suggestions as string[],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── FastGPT API Call ─────────────────────────────────────────────────────────
|
||||
|
||||
const FASTGPT_API_URL = process.env.FASTGPT_API_URL || 'https://api.fastgpt.in/api/v1/chat/completions';
|
||||
const FASTGPT_API_KEY = process.env.FASTGPT_API_KEY || '';
|
||||
const AI_TIMEOUT_MS = 60_000; // 60秒超时
|
||||
|
||||
// 读取系统代理配置(支持 HTTP_PROXY / HTTPS_PROXY 环境变量)
|
||||
function getProxyConfig() {
|
||||
const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || process.env.https_proxy || process.env.http_proxy;
|
||||
if (proxyUrl) {
|
||||
try {
|
||||
const url = new URL(proxyUrl);
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: parseInt(url.port, 10),
|
||||
protocol: url.protocol.replace(':', '') as 'http' | 'https',
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call FastGPT workflow API with structured variables.
|
||||
* FastGPT workflow expects: variables (position, month) + userChatInput (绩效内容)
|
||||
*/
|
||||
async function callFastGPT(
|
||||
position: string,
|
||||
month: string,
|
||||
contentText: string
|
||||
): Promise<AIScoreData> {
|
||||
console.log(`[AI] 调用 FastGPT API: ${FASTGPT_API_URL}`);
|
||||
|
||||
const proxyConfig = getProxyConfig();
|
||||
if (proxyConfig) {
|
||||
console.log(`[AI] 使用代理: ${proxyConfig.host}:${proxyConfig.port}`);
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
FASTGPT_API_URL,
|
||||
{
|
||||
messages: [{ role: 'user', content: contentText }],
|
||||
variables: {
|
||||
zslh34AG: position, // 员工岗位
|
||||
month: month, // 考核月份
|
||||
},
|
||||
stream: false,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${FASTGPT_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: AI_TIMEOUT_MS,
|
||||
proxy: proxyConfig,
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`[AI] FastGPT 响应状态: ${response.status}`);
|
||||
|
||||
// FastGPT workflow 返回的最终结果在 choices[0].message.content 里
|
||||
// 内容是 system_rawResponse 对象(JSON字符串或对象)
|
||||
const content = response.data?.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error(`FastGPT API 返回内容为空,响应: ${JSON.stringify(response.data).substring(0, 300)}`);
|
||||
}
|
||||
|
||||
console.log(`[AI] FastGPT 原始返回:`, typeof content === 'string' ? content.substring(0, 300) : JSON.stringify(content).substring(0, 300));
|
||||
|
||||
// content 可能是带 markdown 代码块的字符串,或纯 JSON 字符串,或对象
|
||||
let parsed: any;
|
||||
if (typeof content === 'object') {
|
||||
parsed = content;
|
||||
} else {
|
||||
// 先尝试去掉 markdown 代码块 ```json ... ```
|
||||
let jsonStr = content.trim();
|
||||
const codeBlockMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
if (codeBlockMatch) {
|
||||
jsonStr = codeBlockMatch[1].trim();
|
||||
} else {
|
||||
// 直接提取第一个完整 JSON 对象
|
||||
const objMatch = jsonStr.match(/\{[\s\S]*\}/);
|
||||
if (objMatch) {
|
||||
jsonStr = objMatch[0];
|
||||
}
|
||||
}
|
||||
try {
|
||||
parsed = JSON.parse(jsonStr);
|
||||
} catch (e: any) {
|
||||
throw new Error(`FastGPT 返回内容无法解析为 JSON: ${e.message},内容片段: ${jsonStr.substring(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 校验必要字段
|
||||
if (!Array.isArray(parsed.ai_score_detail)) {
|
||||
throw new Error(`响应缺少 ai_score_detail,内容: ${JSON.stringify(parsed).substring(0, 300)}`);
|
||||
}
|
||||
if (typeof parsed.ai_total_score !== 'number') {
|
||||
throw new Error(`响应缺少有效的 ai_total_score,内容: ${JSON.stringify(parsed).substring(0, 300)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
aiScoreDetail: parsed.ai_score_detail.map((item: any) => ({
|
||||
itemName: item.itemName,
|
||||
weight: item.weight,
|
||||
aiScore: item.aiScore,
|
||||
scoreExplanation: item.scoreExplanation,
|
||||
})),
|
||||
aiTotalScore: parsed.ai_total_score,
|
||||
aiProblems: Array.isArray(parsed.ai_problems) ? parsed.ai_problems : [],
|
||||
aiSuggestions: Array.isArray(parsed.ai_suggestions) ? parsed.ai_suggestions : [],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Core Evaluation ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Perform a single AI evaluation attempt for the given performance record.
|
||||
*/
|
||||
async function evaluateOnce(
|
||||
perfId: number,
|
||||
performance: PerformanceDAO.PerformanceRow,
|
||||
items: PerformanceDAO.PerfItemRow[],
|
||||
attendance: PerformanceDAO.AttendanceRow | null,
|
||||
position: string
|
||||
): Promise<AIResultDAO.AIResult> {
|
||||
// 构建员工绩效内容文本(作为 userChatInput 传入 workflow)
|
||||
const itemsText = items
|
||||
.map(
|
||||
(item, idx) =>
|
||||
`${idx + 1}. 【${item.item_name}】(权重${item.weight}分)\n` +
|
||||
` 员工填写:${item.user_content ?? '(未填写)'}\n` +
|
||||
` 自评分:${item.self_score ?? '(未填写)'}`
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
const attendanceText = attendance
|
||||
? `事假${attendance.leave_days}天,迟到${attendance.late_times}次,缺卡${attendance.lack_card_times}次,旷工${attendance.absent_days}天`
|
||||
: '无考勤数据';
|
||||
|
||||
const contentText = `考勤情况:${attendanceText}\n\n工作汇总:${performance.work_summary ?? '(未填写)'}\n\n考核项目:\n${itemsText}`;
|
||||
|
||||
const scoreData = await callFastGPT(position, performance.month, contentText);
|
||||
|
||||
const aiId = await AIResultDAO.save({
|
||||
perfId,
|
||||
aiScoreDetail: scoreData.aiScoreDetail,
|
||||
aiTotalScore: scoreData.aiTotalScore,
|
||||
aiProblems: scoreData.aiProblems,
|
||||
aiSuggestions: scoreData.aiSuggestions,
|
||||
apiResponse: JSON.stringify(scoreData),
|
||||
});
|
||||
|
||||
return {
|
||||
aiId,
|
||||
perfId,
|
||||
aiScoreDetail: scoreData.aiScoreDetail,
|
||||
aiTotalScore: scoreData.aiTotalScore,
|
||||
aiProblems: scoreData.aiProblems,
|
||||
aiSuggestions: scoreData.aiSuggestions,
|
||||
createTime: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Retry + Degradation ──────────────────────────────────────────────────────
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
/**
|
||||
* Evaluate performance with up to MAX_RETRIES attempts.
|
||||
* On all failures, logs the error and updates the performance status to 'ai_failed' (degradation).
|
||||
* Requirements: 3.5
|
||||
*/
|
||||
export async function evaluatePerformance(perfId: number): Promise<AIResultDAO.AIResult> {
|
||||
// Load full performance detail
|
||||
const detail = await PerformanceDAO.findDetailByPerfId(perfId);
|
||||
if (!detail) {
|
||||
throw new Error(`绩效记录不存在: perfId=${perfId}`);
|
||||
}
|
||||
|
||||
// Load employee position from user table
|
||||
const pool = (await import('../config/database')).default;
|
||||
const [userRows] = await pool.query<any[]>(
|
||||
'SELECT position FROM user WHERE user_id = ? LIMIT 1',
|
||||
[detail.performance.user_id]
|
||||
);
|
||||
const position: string = userRows[0]?.position ?? '未知岗位';
|
||||
|
||||
let lastError: Error = new Error('未知错误');
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const result = await evaluateOnce(
|
||||
perfId,
|
||||
detail.performance,
|
||||
detail.items,
|
||||
detail.attendance,
|
||||
position
|
||||
);
|
||||
|
||||
// Update ai_score on performance_month
|
||||
await pool.query(
|
||||
'UPDATE performance_month SET ai_score = ? WHERE perf_id = ?',
|
||||
[result.aiTotalScore, perfId]
|
||||
);
|
||||
|
||||
// 回写 AI 评分到 perf_item 各考核项
|
||||
for (const scoreItem of result.aiScoreDetail) {
|
||||
await pool.query(
|
||||
`UPDATE perf_item SET ai_score = ?, ai_explanation = ?
|
||||
WHERE perf_id = ? AND item_name = ?`,
|
||||
[scoreItem.aiScore, scoreItem.scoreExplanation, perfId, scoreItem.itemName]
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[AI] 评分完成 perfId=${perfId},总分=${result.aiTotalScore}(第${attempt}次尝试)`);
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
lastError = err;
|
||||
console.error(`[AI] 第${attempt}次评分失败 perfId=${perfId}:`, err.message);
|
||||
if (attempt < MAX_RETRIES) {
|
||||
// Brief delay before retry (exponential backoff: 1s, 2s)
|
||||
await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted — degradation: log error, notify admin via console
|
||||
console.error(`[AI] 评分最终失败 perfId=${perfId},已重试${MAX_RETRIES}次。错误:${lastError.message}`);
|
||||
|
||||
// Note: We don't log to operation_log here because there's no user_id context
|
||||
// Admin can check console logs for AI evaluation failures
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
39
backend/src/services/AuthService.ts
Normal file
39
backend/src/services/AuthService.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { findByUsername } from '../dao/UserDAO';
|
||||
import { JWT_SECRET, JWT_EXPIRES_IN } from '../config/jwt';
|
||||
import { LoginResult, UserInfo, UserRole } from '../types';
|
||||
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string,
|
||||
role: string
|
||||
): Promise<LoginResult> {
|
||||
const user = await findByUsername(username);
|
||||
|
||||
if (!user || user.status !== 'active') {
|
||||
throw new Error('用户名或密码错误');
|
||||
}
|
||||
|
||||
const passwordMatch = await bcrypt.compare(password, user.password);
|
||||
if (!passwordMatch) {
|
||||
throw new Error('用户名或密码错误');
|
||||
}
|
||||
|
||||
if (user.role !== role) {
|
||||
throw new Error('角色不匹配');
|
||||
}
|
||||
|
||||
const userInfo: UserInfo = {
|
||||
userId: user.user_id,
|
||||
name: user.name,
|
||||
role: user.role as UserRole,
|
||||
department: user.department,
|
||||
position: user.position,
|
||||
managerId: user.manager_id,
|
||||
};
|
||||
|
||||
const token = jwt.sign(userInfo, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
||||
|
||||
return { token, userInfo };
|
||||
}
|
||||
83
backend/src/services/CalculationService.ts
Normal file
83
backend/src/services/CalculationService.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import pool from '../config/database';
|
||||
import { PerformanceLevel } from '../dao/PerformanceDAO';
|
||||
|
||||
export interface AttendanceInput {
|
||||
leaveDays: number;
|
||||
lateTimes: number;
|
||||
lackCardTimes: number;
|
||||
}
|
||||
|
||||
export interface LevelAndReward {
|
||||
level: PerformanceLevel;
|
||||
rewardPunish: string;
|
||||
}
|
||||
|
||||
export interface ConsecutiveLowScoreResult {
|
||||
consecutiveMonths: number;
|
||||
warning: 'none' | 'written_warning' | 'dismissal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate attendance score.
|
||||
* Base: 10 points. Deduct 5 per leave day, 2 per late/lack-card occurrence. Minimum 0.
|
||||
* Requirements: 11.1, 11.2, 11.3, 11.5
|
||||
*/
|
||||
export function calculateAttendanceScore(input: AttendanceInput): number {
|
||||
const { leaveDays, lateTimes, lackCardTimes } = input;
|
||||
const deduction = leaveDays * 5 + lateTimes * 2 + lackCardTimes * 2;
|
||||
return Math.max(0, 10 - deduction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate performance level and reward/punishment description based on total score.
|
||||
* Requirements: 5.1, 5.2, 5.3, 5.4, 5.5
|
||||
*/
|
||||
export function calculateLevelAndReward(totalScore: number): LevelAndReward {
|
||||
if (totalScore >= 90) {
|
||||
return { level: 'excellent', rewardPunish: '优秀,按公司规定给予奖励' };
|
||||
} else if (totalScore >= 80) {
|
||||
return { level: 'qualified', rewardPunish: '合格,扣除当月绩效工资100元' };
|
||||
} else if (totalScore >= 70) {
|
||||
return { level: 'qualified', rewardPunish: '合格,扣除当月绩效工资200元' };
|
||||
} else if (totalScore >= 60) {
|
||||
return { level: 'need_motivation', rewardPunish: '需激励,扣除当月绩效工资300元' };
|
||||
} else {
|
||||
return { level: 'unqualified', rewardPunish: '不合格,扣除当月绩效工资600元' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check consecutive low score warning for an employee.
|
||||
* Queries the most recent completed performance records and counts consecutive months below 60.
|
||||
* Requirements: 5.6, 5.7
|
||||
*/
|
||||
export async function checkConsecutiveLowScore(
|
||||
userId: number,
|
||||
lookbackMonths: number = 3
|
||||
): Promise<ConsecutiveLowScoreResult> {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
`SELECT total_score FROM performance_month
|
||||
WHERE user_id = ? AND status = 'completed' AND total_score IS NOT NULL
|
||||
ORDER BY month DESC
|
||||
LIMIT ?`,
|
||||
[userId, lookbackMonths]
|
||||
);
|
||||
|
||||
let consecutiveMonths = 0;
|
||||
for (const row of rows) {
|
||||
if (row.total_score < 60) {
|
||||
consecutiveMonths++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let warning: ConsecutiveLowScoreResult['warning'] = 'none';
|
||||
if (consecutiveMonths >= 3) {
|
||||
warning = 'dismissal';
|
||||
} else if (consecutiveMonths >= 2) {
|
||||
warning = 'written_warning';
|
||||
}
|
||||
|
||||
return { consecutiveMonths, warning };
|
||||
}
|
||||
94
backend/src/services/ConfigService.ts
Normal file
94
backend/src/services/ConfigService.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import pool from '../config/database';
|
||||
import { findAllRules, findRuleByKey, upsertRule, RuleRow } from '../dao/ConfigDAO';
|
||||
|
||||
export interface RuleDTO {
|
||||
ruleKey: string;
|
||||
ruleValue: unknown;
|
||||
description?: string;
|
||||
effectiveCycle?: string;
|
||||
}
|
||||
|
||||
export interface RuleResponse {
|
||||
ruleId: number;
|
||||
ruleKey: string;
|
||||
ruleValue: unknown;
|
||||
description: string | null;
|
||||
effectiveCycle: string | null;
|
||||
updatedBy: number | null;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
function toResponse(row: RuleRow): RuleResponse {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(row.rule_value);
|
||||
} catch {
|
||||
parsed = row.rule_value;
|
||||
}
|
||||
return {
|
||||
ruleId: row.rule_id,
|
||||
ruleKey: row.rule_key,
|
||||
ruleValue: parsed,
|
||||
description: row.description,
|
||||
effectiveCycle: row.effective_cycle,
|
||||
updatedBy: row.updated_by,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** Get all current rules */
|
||||
export async function getAllRules(): Promise<RuleResponse[]> {
|
||||
const rows = await findAllRules();
|
||||
return rows.map(toResponse);
|
||||
}
|
||||
|
||||
/** Get a single rule by key */
|
||||
export async function getRuleByKey(ruleKey: string): Promise<RuleResponse | null> {
|
||||
const row = await findRuleByKey(ruleKey);
|
||||
return row ? toResponse(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update (or create) a rule and record the change in operation_log.
|
||||
* Requirements: 8.5, 8.6
|
||||
*/
|
||||
export async function updateRule(dto: RuleDTO, operatorId: number): Promise<RuleResponse> {
|
||||
// Persist the rule
|
||||
await upsertRule({
|
||||
ruleKey: dto.ruleKey,
|
||||
ruleValue: dto.ruleValue,
|
||||
description: dto.description,
|
||||
effectiveCycle: dto.effectiveCycle,
|
||||
updatedBy: operatorId,
|
||||
});
|
||||
|
||||
// Record operation log so changes are traceable (Requirement 8.6)
|
||||
await pool.query(
|
||||
`INSERT INTO operation_log (user_id, operation_type, target_type, operation_detail)
|
||||
VALUES (?, 'update_rule', 'config', ?)`,
|
||||
[
|
||||
operatorId,
|
||||
JSON.stringify({
|
||||
ruleKey: dto.ruleKey,
|
||||
ruleValue: dto.ruleValue,
|
||||
effectiveCycle: dto.effectiveCycle ?? null,
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
const updated = await findRuleByKey(dto.ruleKey);
|
||||
return toResponse(updated!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch update multiple rules in a single operation.
|
||||
* Requirements: 8.5, 8.6
|
||||
*/
|
||||
export async function updateRules(rules: RuleDTO[], operatorId: number): Promise<RuleResponse[]> {
|
||||
const results: RuleResponse[] = [];
|
||||
for (const dto of rules) {
|
||||
const result = await updateRule(dto, operatorId);
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
136
backend/src/services/ExportService.ts
Normal file
136
backend/src/services/ExportService.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
import pool from '../config/database';
|
||||
|
||||
export interface ExportFilter {
|
||||
/** Export a single employee's history */
|
||||
userId?: number;
|
||||
/** Export all subordinates of a manager */
|
||||
managerId?: number;
|
||||
/** Restrict to a specific month (YYYY-MM) */
|
||||
month?: string;
|
||||
/** For GM: export all employees */
|
||||
allEmployees?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and return an Excel workbook buffer for performance records.
|
||||
* Supports:
|
||||
* - Single employee history (userId)
|
||||
* - Team export for a manager (managerId)
|
||||
* - Full company export (allEmployees = true)
|
||||
* Requirements: 7.3, 7.6, 8.4
|
||||
*/
|
||||
export async function exportPerformanceExcel(filter: ExportFilter): Promise<Buffer> {
|
||||
// Build query conditions
|
||||
const conditions: string[] = ["pm.status = 'completed'"];
|
||||
const params: any[] = [];
|
||||
|
||||
if (filter.userId) {
|
||||
conditions.push('pm.user_id = ?');
|
||||
params.push(filter.userId);
|
||||
} else if (filter.managerId) {
|
||||
conditions.push('u.manager_id = ?');
|
||||
params.push(filter.managerId);
|
||||
}
|
||||
// allEmployees: no extra condition needed
|
||||
|
||||
if (filter.month) {
|
||||
conditions.push('pm.month = ?');
|
||||
params.push(filter.month);
|
||||
}
|
||||
|
||||
const where = conditions.join(' AND ');
|
||||
|
||||
const [rows] = await pool.query<any[]>(
|
||||
`SELECT
|
||||
pm.perf_id,
|
||||
pm.month,
|
||||
pm.status,
|
||||
pm.self_score,
|
||||
pm.ai_score,
|
||||
pm.manager_score,
|
||||
pm.total_score,
|
||||
pm.level,
|
||||
pm.reward_punish,
|
||||
pm.work_summary,
|
||||
pm.submit_time,
|
||||
pm.review_time,
|
||||
pm.review_opinion,
|
||||
u.name AS employee_name,
|
||||
u.department,
|
||||
u.position
|
||||
FROM performance_month pm
|
||||
JOIN user u ON pm.user_id = u.user_id
|
||||
WHERE ${where}
|
||||
ORDER BY u.department, u.name, pm.month DESC`,
|
||||
params
|
||||
);
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = '员工绩效考核系统';
|
||||
workbook.created = new Date();
|
||||
|
||||
const sheet = workbook.addWorksheet('绩效数据');
|
||||
|
||||
// Header row
|
||||
sheet.columns = [
|
||||
{ header: '姓名', key: 'employee_name', width: 12 },
|
||||
{ header: '部门', key: 'department', width: 16 },
|
||||
{ header: '岗位', key: 'position', width: 16 },
|
||||
{ header: '考核月份', key: 'month', width: 12 },
|
||||
{ header: '自评分', key: 'self_score', width: 10 },
|
||||
{ header: 'AI评分', key: 'ai_score', width: 10 },
|
||||
{ header: '管理层评分', key: 'manager_score', width: 12 },
|
||||
{ header: '最终总分', key: 'total_score', width: 12 },
|
||||
{ header: '绩效等级', key: 'level', width: 14 },
|
||||
{ header: '奖惩说明', key: 'reward_punish', width: 30 },
|
||||
{ header: '工作汇总', key: 'work_summary', width: 40 },
|
||||
{ header: '审核意见', key: 'review_opinion', width: 40 },
|
||||
{ header: '提交时间', key: 'submit_time', width: 20 },
|
||||
{ header: '审核时间', key: 'review_time', width: 20 },
|
||||
];
|
||||
|
||||
// Style header row
|
||||
const headerRow = sheet.getRow(1);
|
||||
headerRow.font = { bold: true };
|
||||
headerRow.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFD9E1F2' },
|
||||
};
|
||||
headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||
|
||||
const levelLabels: Record<string, string> = {
|
||||
excellent: '优秀',
|
||||
qualified: '合格',
|
||||
need_motivation: '需激励',
|
||||
unqualified: '不合格',
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
sheet.addRow({
|
||||
employee_name: row.employee_name,
|
||||
department: row.department,
|
||||
position: row.position,
|
||||
month: row.month,
|
||||
self_score: row.self_score ?? '',
|
||||
ai_score: row.ai_score ?? '',
|
||||
manager_score: row.manager_score ?? '',
|
||||
total_score: row.total_score ?? '',
|
||||
level: row.level ? (levelLabels[row.level] ?? row.level) : '',
|
||||
reward_punish: row.reward_punish ?? '',
|
||||
work_summary: row.work_summary ?? '',
|
||||
review_opinion: row.review_opinion ?? '',
|
||||
submit_time: row.submit_time ? new Date(row.submit_time).toLocaleString('zh-CN') : '',
|
||||
review_time: row.review_time ? new Date(row.review_time).toLocaleString('zh-CN') : '',
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-fit row heights for wrapped text columns
|
||||
sheet.getColumn('work_summary').alignment = { wrapText: true };
|
||||
sheet.getColumn('review_opinion').alignment = { wrapText: true };
|
||||
sheet.getColumn('reward_punish').alignment = { wrapText: true };
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
180
backend/src/services/StatisticsService.ts
Normal file
180
backend/src/services/StatisticsService.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import pool from '../config/database';
|
||||
import { PerformanceLevel } from '../dao/PerformanceDAO';
|
||||
|
||||
export interface TeamStats {
|
||||
averageScore: number;
|
||||
totalCount: number;
|
||||
excellentCount: number;
|
||||
qualifiedCount: number;
|
||||
needMotivationCount: number;
|
||||
unqualifiedCount: number;
|
||||
excellentRate: number;
|
||||
qualifiedRate: number;
|
||||
needMotivationRate: number;
|
||||
unqualifiedRate: number;
|
||||
}
|
||||
|
||||
export interface DepartmentStat {
|
||||
department: string;
|
||||
averageScore: number;
|
||||
totalCount: number;
|
||||
levelDistribution: Record<PerformanceLevel, number>;
|
||||
}
|
||||
|
||||
export interface PositionStat {
|
||||
position: string;
|
||||
averageScore: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface CompanyStats {
|
||||
month: string;
|
||||
totalCount: number;
|
||||
averageScore: number;
|
||||
departmentStats: DepartmentStat[];
|
||||
positionStats: PositionStat[];
|
||||
levelDistribution: Record<PerformanceLevel, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get team statistics for a manager's subordinates for a given month.
|
||||
* Requirements: 7.4, 7.5
|
||||
*/
|
||||
export async function getTeamStatistics(managerId: number, month: string): Promise<TeamStats> {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
`SELECT pm.total_score, pm.level
|
||||
FROM performance_month pm
|
||||
JOIN user u ON pm.user_id = u.user_id
|
||||
WHERE u.manager_id = ? AND pm.month = ? AND pm.status = 'completed' AND pm.total_score IS NOT NULL`,
|
||||
[managerId, month]
|
||||
);
|
||||
|
||||
const totalCount = rows.length;
|
||||
if (totalCount === 0) {
|
||||
return {
|
||||
averageScore: 0,
|
||||
totalCount: 0,
|
||||
excellentCount: 0,
|
||||
qualifiedCount: 0,
|
||||
needMotivationCount: 0,
|
||||
unqualifiedCount: 0,
|
||||
excellentRate: 0,
|
||||
qualifiedRate: 0,
|
||||
needMotivationRate: 0,
|
||||
unqualifiedRate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let sumScore = 0;
|
||||
let excellentCount = 0;
|
||||
let qualifiedCount = 0;
|
||||
let needMotivationCount = 0;
|
||||
let unqualifiedCount = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
sumScore += Number(row.total_score);
|
||||
switch (row.level as PerformanceLevel) {
|
||||
case 'excellent':
|
||||
excellentCount++;
|
||||
break;
|
||||
case 'qualified':
|
||||
qualifiedCount++;
|
||||
break;
|
||||
case 'need_motivation':
|
||||
needMotivationCount++;
|
||||
break;
|
||||
case 'unqualified':
|
||||
unqualifiedCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const averageScore = parseFloat((sumScore / totalCount).toFixed(2));
|
||||
const toRate = (n: number) => parseFloat(((n / totalCount) * 100).toFixed(2));
|
||||
|
||||
return {
|
||||
averageScore,
|
||||
totalCount,
|
||||
excellentCount,
|
||||
qualifiedCount,
|
||||
needMotivationCount,
|
||||
unqualifiedCount,
|
||||
excellentRate: toRate(excellentCount),
|
||||
qualifiedRate: toRate(qualifiedCount),
|
||||
needMotivationRate: toRate(needMotivationCount),
|
||||
unqualifiedRate: toRate(unqualifiedCount),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get company-wide statistics for a given month, broken down by department and position.
|
||||
* Requirements: 8.1, 8.2
|
||||
*/
|
||||
export async function getCompanyStatistics(month: string): Promise<CompanyStats> {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
`SELECT pm.total_score, pm.level, u.department, u.position
|
||||
FROM performance_month pm
|
||||
JOIN user u ON pm.user_id = u.user_id
|
||||
WHERE pm.month = ? AND pm.status = 'completed' AND pm.total_score IS NOT NULL`,
|
||||
[month]
|
||||
);
|
||||
|
||||
const totalCount = rows.length;
|
||||
const levelDistribution: Record<PerformanceLevel, number> = {
|
||||
excellent: 0,
|
||||
qualified: 0,
|
||||
need_motivation: 0,
|
||||
unqualified: 0,
|
||||
};
|
||||
|
||||
// Aggregate by department
|
||||
const deptMap = new Map<string, { scores: number[]; levels: Record<PerformanceLevel, number> }>();
|
||||
// Aggregate by position
|
||||
const posMap = new Map<string, number[]>();
|
||||
|
||||
let totalSum = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const score = Number(row.total_score);
|
||||
const level = row.level as PerformanceLevel;
|
||||
const dept = row.department as string;
|
||||
const pos = row.position as string;
|
||||
|
||||
totalSum += score;
|
||||
if (level in levelDistribution) levelDistribution[level]++;
|
||||
|
||||
// Department
|
||||
if (!deptMap.has(dept)) {
|
||||
deptMap.set(dept, { scores: [], levels: { excellent: 0, qualified: 0, need_motivation: 0, unqualified: 0 } });
|
||||
}
|
||||
const deptEntry = deptMap.get(dept)!;
|
||||
deptEntry.scores.push(score);
|
||||
if (level in deptEntry.levels) deptEntry.levels[level]++;
|
||||
|
||||
// Position
|
||||
if (!posMap.has(pos)) posMap.set(pos, []);
|
||||
posMap.get(pos)!.push(score);
|
||||
}
|
||||
|
||||
const departmentStats: DepartmentStat[] = Array.from(deptMap.entries()).map(([department, data]) => ({
|
||||
department,
|
||||
averageScore: parseFloat((data.scores.reduce((a, b) => a + b, 0) / data.scores.length).toFixed(2)),
|
||||
totalCount: data.scores.length,
|
||||
levelDistribution: data.levels,
|
||||
}));
|
||||
|
||||
const positionStats: PositionStat[] = Array.from(posMap.entries()).map(([position, scores]) => ({
|
||||
position,
|
||||
averageScore: parseFloat((scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2)),
|
||||
totalCount: scores.length,
|
||||
}));
|
||||
|
||||
return {
|
||||
month,
|
||||
totalCount,
|
||||
averageScore: totalCount > 0 ? parseFloat((totalSum / totalCount).toFixed(2)) : 0,
|
||||
departmentStats,
|
||||
positionStats,
|
||||
levelDistribution,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import * as fc from 'fast-check';
|
||||
import { parseAIResponse, AIScoreData } from '../AIEvaluationService';
|
||||
import type { AIScoreItem } from '../../dao/AIResultDAO';
|
||||
|
||||
// ─── Arbitraries ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Generate a valid AIScoreItem */
|
||||
const aiScoreItemArb: fc.Arbitrary<AIScoreItem> = fc.record({
|
||||
itemName: fc.string({ minLength: 1, maxLength: 50 }),
|
||||
weight: fc.integer({ min: 1, max: 30 }),
|
||||
aiScore: fc.float({ min: 0, max: 100, noNaN: true }),
|
||||
scoreExplanation: fc.string({ minLength: 1, maxLength: 200 }),
|
||||
});
|
||||
|
||||
/** Generate a valid AIScoreData object */
|
||||
const aiScoreDataArb: fc.Arbitrary<AIScoreData> = fc.record({
|
||||
aiScoreDetail: fc.array(aiScoreItemArb, { minLength: 1, maxLength: 17 }),
|
||||
aiTotalScore: fc.float({ min: 0, max: 100, noNaN: true }),
|
||||
aiProblems: fc.array(fc.string({ minLength: 1, maxLength: 100 }), { minLength: 3, maxLength: 5 }),
|
||||
aiSuggestions: fc.array(fc.string({ minLength: 1, maxLength: 100 }), { minLength: 3, maxLength: 5 }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Serialize an AIScoreData object into the JSON string format that
|
||||
* parseAIResponse expects (matching the FastGPT output schema).
|
||||
*/
|
||||
function serializeToAIJson(data: AIScoreData): string {
|
||||
return JSON.stringify({
|
||||
ai_score_detail: data.aiScoreDetail.map((item) => ({
|
||||
itemName: item.itemName,
|
||||
weight: item.weight,
|
||||
aiScore: item.aiScore,
|
||||
scoreExplanation: item.scoreExplanation,
|
||||
})),
|
||||
ai_total_score: data.aiTotalScore,
|
||||
ai_problems: data.aiProblems,
|
||||
ai_suggestions: data.aiSuggestions,
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Feature: employee-performance-system, Property 9: AI 响应 JSON 解析往返一致性
|
||||
// For any valid AI score JSON string, parsing then re-serializing should
|
||||
// produce a semantically equivalent object.
|
||||
// Validates: Requirements 3.6, 13.3
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Property 9: AI 响应 JSON 解析往返一致性', () => {
|
||||
it('parse then re-serialize produces equivalent object', () => {
|
||||
fc.assert(
|
||||
fc.property(aiScoreDataArb, (original) => {
|
||||
const jsonStr = serializeToAIJson(original);
|
||||
const parsed = parseAIResponse(jsonStr);
|
||||
|
||||
// Round-trip: re-serialize and parse again
|
||||
const roundTripped = parseAIResponse(serializeToAIJson(parsed));
|
||||
|
||||
// The re-parsed result must be semantically equivalent to the first parse
|
||||
expect(roundTripped.aiTotalScore).toBeCloseTo(parsed.aiTotalScore, 5);
|
||||
expect(roundTripped.aiProblems).toEqual(parsed.aiProblems);
|
||||
expect(roundTripped.aiSuggestions).toEqual(parsed.aiSuggestions);
|
||||
expect(roundTripped.aiScoreDetail.length).toBe(parsed.aiScoreDetail.length);
|
||||
|
||||
roundTripped.aiScoreDetail.forEach((item, idx) => {
|
||||
expect(item.itemName).toBe(parsed.aiScoreDetail[idx].itemName);
|
||||
expect(item.weight).toBe(parsed.aiScoreDetail[idx].weight);
|
||||
expect(item.aiScore).toBeCloseTo(parsed.aiScoreDetail[idx].aiScore, 5);
|
||||
expect(item.scoreExplanation).toBe(parsed.aiScoreDetail[idx].scoreExplanation);
|
||||
});
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('parsed result preserves all score detail fields from original JSON', () => {
|
||||
fc.assert(
|
||||
fc.property(aiScoreDataArb, (original) => {
|
||||
const jsonStr = serializeToAIJson(original);
|
||||
const parsed = parseAIResponse(jsonStr);
|
||||
|
||||
expect(parsed.aiScoreDetail.length).toBe(original.aiScoreDetail.length);
|
||||
parsed.aiScoreDetail.forEach((item, idx) => {
|
||||
expect(item.itemName).toBe(original.aiScoreDetail[idx].itemName);
|
||||
expect(item.weight).toBe(original.aiScoreDetail[idx].weight);
|
||||
expect(item.scoreExplanation).toBe(original.aiScoreDetail[idx].scoreExplanation);
|
||||
});
|
||||
expect(parsed.aiProblems).toEqual(original.aiProblems);
|
||||
expect(parsed.aiSuggestions).toEqual(original.aiSuggestions);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('parseAIResponse handles JSON wrapped in markdown code blocks', () => {
|
||||
fc.assert(
|
||||
fc.property(aiScoreDataArb, (original) => {
|
||||
const jsonStr = serializeToAIJson(original);
|
||||
const wrapped = `\`\`\`json\n${jsonStr}\n\`\`\``;
|
||||
const parsed = parseAIResponse(wrapped);
|
||||
|
||||
expect(parsed.aiProblems).toEqual(original.aiProblems);
|
||||
expect(parsed.aiSuggestions).toEqual(original.aiSuggestions);
|
||||
expect(parsed.aiScoreDetail.length).toBe(original.aiScoreDetail.length);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Feature: employee-performance-system, Property 10: AI 输出格式约束
|
||||
// For any valid AI response, ai_problems and ai_suggestions arrays must each
|
||||
// have a length between 3 and 5 (inclusive).
|
||||
// Validates: Requirements 3.3, 3.4
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Property 10: AI 输出格式约束', () => {
|
||||
it('parsed ai_problems length is always between 3 and 5', () => {
|
||||
fc.assert(
|
||||
fc.property(aiScoreDataArb, (original) => {
|
||||
const parsed = parseAIResponse(serializeToAIJson(original));
|
||||
expect(parsed.aiProblems.length).toBeGreaterThanOrEqual(3);
|
||||
expect(parsed.aiProblems.length).toBeLessThanOrEqual(5);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('parsed ai_suggestions length is always between 3 and 5', () => {
|
||||
fc.assert(
|
||||
fc.property(aiScoreDataArb, (original) => {
|
||||
const parsed = parseAIResponse(serializeToAIJson(original));
|
||||
expect(parsed.aiSuggestions.length).toBeGreaterThanOrEqual(3);
|
||||
expect(parsed.aiSuggestions.length).toBeLessThanOrEqual(5);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('parseAIResponse rejects ai_problems with fewer than 3 items', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
detail: fc.array(aiScoreItemArb, { minLength: 1, maxLength: 5 }),
|
||||
totalScore: fc.float({ min: 0, max: 100, noNaN: true }),
|
||||
// 0, 1, or 2 problems — all invalid
|
||||
problems: fc.array(fc.string({ minLength: 1 }), { minLength: 0, maxLength: 2 }),
|
||||
suggestions: fc.array(fc.string({ minLength: 1 }), { minLength: 3, maxLength: 5 }),
|
||||
}),
|
||||
({ detail, totalScore, problems, suggestions }) => {
|
||||
const json = JSON.stringify({
|
||||
ai_score_detail: detail,
|
||||
ai_total_score: totalScore,
|
||||
ai_problems: problems,
|
||||
ai_suggestions: suggestions,
|
||||
});
|
||||
expect(() => parseAIResponse(json)).toThrow();
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('parseAIResponse rejects ai_suggestions with more than 5 items', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
detail: fc.array(aiScoreItemArb, { minLength: 1, maxLength: 5 }),
|
||||
totalScore: fc.float({ min: 0, max: 100, noNaN: true }),
|
||||
problems: fc.array(fc.string({ minLength: 1 }), { minLength: 3, maxLength: 5 }),
|
||||
// 6 or more suggestions — all invalid
|
||||
suggestions: fc.array(fc.string({ minLength: 1 }), { minLength: 6, maxLength: 10 }),
|
||||
}),
|
||||
({ detail, totalScore, problems, suggestions }) => {
|
||||
const json = JSON.stringify({
|
||||
ai_score_detail: detail,
|
||||
ai_total_score: totalScore,
|
||||
ai_problems: problems,
|
||||
ai_suggestions: suggestions,
|
||||
});
|
||||
expect(() => parseAIResponse(json)).toThrow();
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
139
backend/src/services/__tests__/AuthService.property.test.ts
Normal file
139
backend/src/services/__tests__/AuthService.property.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import * as fc from 'fast-check';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { login } from '../AuthService';
|
||||
import * as UserDAO from '../../dao/UserDAO';
|
||||
import { JWT_SECRET } from '../../config/jwt';
|
||||
import { UserRole } from '../../types';
|
||||
|
||||
jest.mock('../../dao/UserDAO');
|
||||
const mockFindByUsername = UserDAO.findByUsername as jest.MockedFunction<typeof UserDAO.findByUsername>;
|
||||
|
||||
// Feature: employee-performance-system, Property 1: 认证正确性
|
||||
// For any credentials, the authentication result is strictly consistent with credential validity.
|
||||
describe('Property 1: 认证正确性', () => {
|
||||
const ROLES: UserRole[] = ['employee', 'manager', 'generalManager'];
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('valid credentials always succeed and return a verifiable token', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
username: fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0),
|
||||
password: fc.string({ minLength: 6, maxLength: 30 }),
|
||||
role: fc.constantFrom<UserRole>(...ROLES),
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
name: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
department: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
position: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
}),
|
||||
async ({ username, password, role, userId, name, department, position }) => {
|
||||
const hashedPassword = bcrypt.hashSync(password, 1); // cost 1 for speed
|
||||
const userRow: UserDAO.UserRow = {
|
||||
user_id: userId,
|
||||
username,
|
||||
password: hashedPassword,
|
||||
name,
|
||||
role,
|
||||
department,
|
||||
position,
|
||||
manager_id: null,
|
||||
status: 'active',
|
||||
};
|
||||
mockFindByUsername.mockResolvedValue(userRow);
|
||||
|
||||
const result = await login(username, password, role);
|
||||
|
||||
// Must return a token
|
||||
expect(result.token).toBeTruthy();
|
||||
// Token must be verifiable and carry correct userId
|
||||
const decoded = jwt.verify(result.token, JWT_SECRET) as any;
|
||||
expect(decoded.userId).toBe(userId);
|
||||
expect(decoded.role).toBe(role);
|
||||
// userInfo must match
|
||||
expect(result.userInfo.userId).toBe(userId);
|
||||
expect(result.userInfo.role).toBe(role);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('invalid password always throws and never returns a token', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
username: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
correctPassword: fc.string({ minLength: 6, maxLength: 30 }),
|
||||
wrongPassword: fc.string({ minLength: 1, maxLength: 30 }),
|
||||
role: fc.constantFrom<UserRole>(...ROLES),
|
||||
}).filter(({ correctPassword, wrongPassword }) => correctPassword !== wrongPassword),
|
||||
async ({ username, correctPassword, wrongPassword, role }) => {
|
||||
const hashedPassword = bcrypt.hashSync(correctPassword, 1);
|
||||
mockFindByUsername.mockResolvedValue({
|
||||
user_id: 1,
|
||||
username,
|
||||
password: hashedPassword,
|
||||
name: '测试',
|
||||
role,
|
||||
department: '部门',
|
||||
position: '职位',
|
||||
manager_id: null,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await expect(login(username, wrongPassword, role)).rejects.toThrow();
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('non-existent user always throws', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
username: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
password: fc.string({ minLength: 1, maxLength: 30 }),
|
||||
role: fc.constantFrom<UserRole>(...ROLES),
|
||||
}),
|
||||
async ({ username, password, role }) => {
|
||||
mockFindByUsername.mockResolvedValue(null);
|
||||
await expect(login(username, password, role)).rejects.toThrow();
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('role mismatch always throws', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
username: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
password: fc.string({ minLength: 6, maxLength: 30 }),
|
||||
storedRole: fc.constantFrom<UserRole>(...ROLES),
|
||||
requestedRole: fc.constantFrom<UserRole>(...ROLES),
|
||||
}).filter(({ storedRole, requestedRole }) => storedRole !== requestedRole),
|
||||
async ({ username, password, storedRole, requestedRole }) => {
|
||||
const hashedPassword = bcrypt.hashSync(password, 1);
|
||||
mockFindByUsername.mockResolvedValue({
|
||||
user_id: 1,
|
||||
username,
|
||||
password: hashedPassword,
|
||||
name: '测试',
|
||||
role: storedRole,
|
||||
department: '部门',
|
||||
position: '职位',
|
||||
manager_id: null,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await expect(login(username, password, requestedRole)).rejects.toThrow();
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
56
backend/src/services/__tests__/AuthService.test.ts
Normal file
56
backend/src/services/__tests__/AuthService.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { login } from '../AuthService';
|
||||
import * as UserDAO from '../../dao/UserDAO';
|
||||
import { JWT_SECRET } from '../../config/jwt';
|
||||
|
||||
jest.mock('../../dao/UserDAO');
|
||||
const mockFindByUsername = UserDAO.findByUsername as jest.MockedFunction<typeof UserDAO.findByUsername>;
|
||||
|
||||
const baseUser: UserDAO.UserRow = {
|
||||
user_id: 1,
|
||||
username: 'emp001',
|
||||
password: bcrypt.hashSync('password123', 10),
|
||||
name: '张三',
|
||||
role: 'employee',
|
||||
department: '研发部',
|
||||
position: '工程师',
|
||||
manager_id: 2,
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
describe('AuthService.login', () => {
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('valid credentials return token and userInfo', async () => {
|
||||
mockFindByUsername.mockResolvedValue(baseUser);
|
||||
const result = await login('emp001', 'password123', 'employee');
|
||||
|
||||
expect(result.token).toBeTruthy();
|
||||
expect(result.userInfo.userId).toBe(1);
|
||||
expect(result.userInfo.role).toBe('employee');
|
||||
|
||||
const decoded = jwt.verify(result.token, JWT_SECRET) as any;
|
||||
expect(decoded.userId).toBe(1);
|
||||
});
|
||||
|
||||
it('wrong password throws error', async () => {
|
||||
mockFindByUsername.mockResolvedValue(baseUser);
|
||||
await expect(login('emp001', 'wrongpass', 'employee')).rejects.toThrow('用户名或密码错误');
|
||||
});
|
||||
|
||||
it('non-existent user throws error', async () => {
|
||||
mockFindByUsername.mockResolvedValue(null);
|
||||
await expect(login('nobody', 'pass', 'employee')).rejects.toThrow('用户名或密码错误');
|
||||
});
|
||||
|
||||
it('role mismatch throws error', async () => {
|
||||
mockFindByUsername.mockResolvedValue(baseUser);
|
||||
await expect(login('emp001', 'password123', 'manager')).rejects.toThrow('角色不匹配');
|
||||
});
|
||||
|
||||
it('inactive user throws error', async () => {
|
||||
mockFindByUsername.mockResolvedValue({ ...baseUser, status: 'inactive' });
|
||||
await expect(login('emp001', 'password123', 'employee')).rejects.toThrow('用户名或密码错误');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
import * as fc from 'fast-check';
|
||||
import {
|
||||
calculateAttendanceScore,
|
||||
calculateLevelAndReward,
|
||||
AttendanceInput,
|
||||
} from '../CalculationService';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Feature: employee-performance-system, Property 5: 绩效等级与奖惩计算正确性
|
||||
// For any total score (0-100), the level and reward/punishment must strictly
|
||||
// follow the defined rules.
|
||||
// Validates: Requirements 5.1, 5.2, 5.3, 5.4, 5.5
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Property 5: 绩效等级与奖惩计算正确性', () => {
|
||||
it('score >= 90 → excellent with reward description', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 90, max: 100 }),
|
||||
(score) => {
|
||||
const result = calculateLevelAndReward(score);
|
||||
expect(result.level).toBe('excellent');
|
||||
expect(result.rewardPunish).toContain('奖励');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('80 <= score < 90 → qualified, deduct 100', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
// Use integer scores in [80, 89] to stay within 32-bit float constraints
|
||||
fc.integer({ min: 80, max: 89 }),
|
||||
(score) => {
|
||||
const result = calculateLevelAndReward(score);
|
||||
expect(result.level).toBe('qualified');
|
||||
expect(result.rewardPunish).toContain('100');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('70 <= score < 80 → qualified, deduct 200', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 70, max: 79 }),
|
||||
(score) => {
|
||||
const result = calculateLevelAndReward(score);
|
||||
expect(result.level).toBe('qualified');
|
||||
expect(result.rewardPunish).toContain('200');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('60 <= score < 70 → need_motivation, deduct 300', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 60, max: 69 }),
|
||||
(score) => {
|
||||
const result = calculateLevelAndReward(score);
|
||||
expect(result.level).toBe('need_motivation');
|
||||
expect(result.rewardPunish).toContain('300');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('score < 60 → unqualified, deduct 600', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: 59 }),
|
||||
(score) => {
|
||||
const result = calculateLevelAndReward(score);
|
||||
expect(result.level).toBe('unqualified');
|
||||
expect(result.rewardPunish).toContain('600');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('every score in [0,100] produces a non-empty level and rewardPunish', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: 100 }),
|
||||
(score) => {
|
||||
const result = calculateLevelAndReward(score);
|
||||
expect(result.level).toBeTruthy();
|
||||
expect(result.rewardPunish.length).toBeGreaterThan(0);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Feature: employee-performance-system, Property 6: 连续低分预警正确性
|
||||
// For any sequence of completed performance scores, consecutive months below 60
|
||||
// must trigger the correct warning level.
|
||||
// Validates: Requirements 5.6, 5.7
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Property 6: 连续低分预警正确性', () => {
|
||||
/**
|
||||
* We test the pure logic by extracting it from checkConsecutiveLowScore.
|
||||
* The function queries the DB, so we replicate the counting logic here and
|
||||
* verify it against the same rules the implementation uses.
|
||||
*/
|
||||
function deriveWarning(scores: number[]): { consecutiveMonths: number; warning: string } {
|
||||
let consecutiveMonths = 0;
|
||||
for (const score of scores) {
|
||||
if (score < 60) {
|
||||
consecutiveMonths++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let warning = 'none';
|
||||
if (consecutiveMonths >= 3) warning = 'dismissal';
|
||||
else if (consecutiveMonths >= 2) warning = 'written_warning';
|
||||
return { consecutiveMonths, warning };
|
||||
}
|
||||
|
||||
it('0 or 1 consecutive low scores → no warning', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
// Scores where the first element is >= 60 (no consecutive low streak)
|
||||
fc.array(fc.float({ min: 0, max: 100, noNaN: true }), { minLength: 1, maxLength: 6 }).map(
|
||||
(arr) => [arr[0] >= 60 ? arr[0] : arr[0] + 60, ...arr.slice(1)]
|
||||
),
|
||||
(scores) => {
|
||||
const { warning } = deriveWarning(scores);
|
||||
expect(warning).toBe('none');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('exactly 2 consecutive low scores → written_warning', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
low1: fc.integer({ min: 0, max: 59 }),
|
||||
low2: fc.integer({ min: 0, max: 59 }),
|
||||
// Third score is >= 60 to stop the streak at exactly 2
|
||||
third: fc.integer({ min: 60, max: 100 }),
|
||||
}),
|
||||
({ low1, low2, third }) => {
|
||||
const scores = [low1, low2, third];
|
||||
const { consecutiveMonths, warning } = deriveWarning(scores);
|
||||
expect(consecutiveMonths).toBe(2);
|
||||
expect(warning).toBe('written_warning');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('3 or more consecutive low scores → dismissal', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.integer({ min: 0, max: 59 }), { minLength: 3, maxLength: 6 }),
|
||||
(lowScores) => {
|
||||
const { warning } = deriveWarning(lowScores);
|
||||
expect(warning).toBe('dismissal');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('a high score resets the consecutive streak', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
highScore: fc.integer({ min: 60, max: 100 }),
|
||||
lowScores: fc.array(fc.integer({ min: 0, max: 59 }), { minLength: 1, maxLength: 5 }),
|
||||
}),
|
||||
({ highScore, lowScores }) => {
|
||||
// High score first, then low scores — streak should be 0
|
||||
const scores = [highScore, ...lowScores];
|
||||
const { consecutiveMonths, warning } = deriveWarning(scores);
|
||||
expect(consecutiveMonths).toBe(0);
|
||||
expect(warning).toBe('none');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Feature: employee-performance-system, Property 8: 考勤分数计算正确性
|
||||
// For any attendance data, the score must follow the deduction rules and never
|
||||
// fall below 0.
|
||||
// Validates: Requirements 11.1, 11.2, 11.3, 11.5
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Property 8: 考勤分数计算正确性', () => {
|
||||
const attendanceArb = fc.record<AttendanceInput>({
|
||||
leaveDays: fc.integer({ min: 0, max: 10 }),
|
||||
lateTimes: fc.integer({ min: 0, max: 10 }),
|
||||
lackCardTimes: fc.integer({ min: 0, max: 10 }),
|
||||
});
|
||||
|
||||
it('score is always >= 0 (floor protection)', () => {
|
||||
fc.assert(
|
||||
fc.property(attendanceArb, (input) => {
|
||||
expect(calculateAttendanceScore(input)).toBeGreaterThanOrEqual(0);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('perfect attendance (all zeros) → full score of 10', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constant<AttendanceInput>({ leaveDays: 0, lateTimes: 0, lackCardTimes: 0 }),
|
||||
(input) => {
|
||||
expect(calculateAttendanceScore(input)).toBe(10);
|
||||
}
|
||||
),
|
||||
{ numRuns: 1 }
|
||||
);
|
||||
});
|
||||
|
||||
it('each leave day deducts exactly 5 points (when no other deductions)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: 2 }), // keep within range where score stays >= 0
|
||||
(leaveDays) => {
|
||||
const score = calculateAttendanceScore({ leaveDays, lateTimes: 0, lackCardTimes: 0 });
|
||||
const expected = Math.max(0, 10 - leaveDays * 5);
|
||||
expect(score).toBe(expected);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('each late/lack-card occurrence deducts exactly 2 points (when no other deductions)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: 5 }),
|
||||
fc.integer({ min: 0, max: 5 }),
|
||||
(lateTimes, lackCardTimes) => {
|
||||
const score = calculateAttendanceScore({ leaveDays: 0, lateTimes, lackCardTimes });
|
||||
const expected = Math.max(0, 10 - lateTimes * 2 - lackCardTimes * 2);
|
||||
expect(score).toBe(expected);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('score is always <= 10 (cannot exceed base score)', () => {
|
||||
fc.assert(
|
||||
fc.property(attendanceArb, (input) => {
|
||||
expect(calculateAttendanceScore(input)).toBeLessThanOrEqual(10);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('more absences never produce a higher score (monotone deduction)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
leaveDays: fc.integer({ min: 0, max: 5 }),
|
||||
lateTimes: fc.integer({ min: 0, max: 5 }),
|
||||
lackCardTimes: fc.integer({ min: 0, max: 5 }),
|
||||
extraLeave: fc.integer({ min: 1, max: 3 }),
|
||||
}),
|
||||
({ leaveDays, lateTimes, lackCardTimes, extraLeave }) => {
|
||||
const base = calculateAttendanceScore({ leaveDays, lateTimes, lackCardTimes });
|
||||
const worse = calculateAttendanceScore({ leaveDays: leaveDays + extraLeave, lateTimes, lackCardTimes });
|
||||
expect(worse).toBeLessThanOrEqual(base);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
24
backend/src/types/index.ts
Normal file
24
backend/src/types/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export type UserRole = 'employee' | 'manager' | 'generalManager';
|
||||
|
||||
export interface UserInfo {
|
||||
userId: number;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
department: string;
|
||||
position: string;
|
||||
managerId?: number | null;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
token: string;
|
||||
userInfo: UserInfo;
|
||||
}
|
||||
|
||||
// Extend Express Request to carry authenticated user
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: UserInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
42
backend/test-db.ts
Normal file
42
backend/test-db.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function testConnection() {
|
||||
console.log('测试数据库连接...');
|
||||
console.log('配置信息:');
|
||||
console.log(' Host:', process.env.DB_HOST);
|
||||
console.log(' Port:', process.env.DB_PORT);
|
||||
console.log(' User:', process.env.DB_USER);
|
||||
console.log(' Password:', process.env.DB_PASSWORD ? '***' : '(空)');
|
||||
console.log(' Database:', process.env.DB_NAME);
|
||||
|
||||
try {
|
||||
const connection = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'employee_performance',
|
||||
});
|
||||
|
||||
console.log('\n✅ 数据库连接成功!');
|
||||
|
||||
// 测试查询
|
||||
const [rows] = await connection.query('SELECT COUNT(*) as count FROM user');
|
||||
console.log('用户表记录数:', (rows as any)[0].count);
|
||||
|
||||
await connection.end();
|
||||
process.exit(0);
|
||||
} catch (error: any) {
|
||||
console.error('\n❌ 数据库连接失败:', error.message);
|
||||
console.error('\n请检查:');
|
||||
console.error('1. MySQL 服务是否正在运行');
|
||||
console.error('2. 用户名和密码是否正确');
|
||||
console.error('3. 数据库是否已创建');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
testConnection();
|
||||
51
backend/test-fastgpt.ts
Normal file
51
backend/test-fastgpt.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import axios from 'axios';
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
const FASTGPT_API_URL = process.env.FASTGPT_API_URL!;
|
||||
const FASTGPT_API_KEY = process.env.FASTGPT_API_KEY!;
|
||||
const HTTPS_PROXY = process.env.HTTPS_PROXY;
|
||||
|
||||
async function test() {
|
||||
console.log('API URL:', FASTGPT_API_URL);
|
||||
console.log('代理:', HTTPS_PROXY || '无');
|
||||
console.log('正在发送请求...\n');
|
||||
|
||||
const contentText = `考勤情况:全勤,无迟到缺卡\n\n工作汇总:本月完成了前端页面开发,项目顺利交付。\n\n考核项目:\n1. 【工作目标完成情况】(权重15分)\n 员工填写:已完成所有既定目标\n 自评分:90`;
|
||||
|
||||
const proxyConfig = HTTPS_PROXY ? (() => {
|
||||
const url = new URL(HTTPS_PROXY);
|
||||
return { host: url.hostname, port: parseInt(url.port, 10), protocol: 'http' as const };
|
||||
})() : undefined;
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
FASTGPT_API_URL,
|
||||
{
|
||||
messages: [{ role: 'user', content: contentText }],
|
||||
variables: { zslh34AG: '前端工程师', month: '2026-04' },
|
||||
stream: false,
|
||||
},
|
||||
{
|
||||
headers: { Authorization: `Bearer ${FASTGPT_API_KEY}`, 'Content-Type': 'application/json' },
|
||||
timeout: 60_000,
|
||||
proxy: proxyConfig,
|
||||
}
|
||||
);
|
||||
|
||||
console.log('状态码:', response.status);
|
||||
const content = response.data?.choices?.[0]?.message?.content;
|
||||
console.log('content 类型:', typeof content);
|
||||
console.log('content 内容:', typeof content === 'string' ? content.substring(0, 800) : JSON.stringify(content).substring(0, 800));
|
||||
} catch (err: any) {
|
||||
if (err.response) {
|
||||
console.error('HTTP 状态码:', err.response.status);
|
||||
console.error('响应体:', JSON.stringify(err.response.data, null, 2));
|
||||
} else {
|
||||
console.error('错误码:', err.code);
|
||||
console.error('错误信息:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test();
|
||||
19
backend/tsconfig.json
Normal file
19
backend/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
34
backend/update-accounts.ts
Normal file
34
backend/update-accounts.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
async function run() {
|
||||
const hash = await bcrypt.hash('123456', 10);
|
||||
|
||||
// 删除 mgr002
|
||||
await pool.query('DELETE FROM user WHERE username = ?', ['mgr002']);
|
||||
console.log('删除 mgr002');
|
||||
|
||||
// 更新 gm001 → lister / 李总 / 总经理
|
||||
await pool.query(
|
||||
'UPDATE user SET username = ?, name = ?, password = ? WHERE username = ?',
|
||||
['lister', '李总', hash, 'gm001']
|
||||
);
|
||||
console.log('更新 gm001 → lister');
|
||||
|
||||
// 更新 mgr001 → xinxin / 孙薪薪 / 管理层
|
||||
await pool.query(
|
||||
'UPDATE user SET username = ?, name = ?, password = ? WHERE username = ?',
|
||||
['xinxin', '孙薪薪', hash, 'mgr001']
|
||||
);
|
||||
console.log('更新 mgr001 → xinxin');
|
||||
|
||||
const [rows] = await pool.query<any[]>('SELECT user_id, username, name, role, department, position FROM user ORDER BY role');
|
||||
console.log('\n当前账号:');
|
||||
console.table(rows);
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
19
backend/update-passwords-final.ts
Normal file
19
backend/update-passwords-final.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
async function run() {
|
||||
const xinxinHash = await bcrypt.hash('sxx980623', 10);
|
||||
const listerHash = await bcrypt.hash('lister123', 10);
|
||||
|
||||
await pool.query('UPDATE user SET password = ? WHERE username = ?', [xinxinHash, 'xinxin']);
|
||||
await pool.query('UPDATE user SET password = ? WHERE username = ?', [listerHash, 'lister']);
|
||||
|
||||
console.log('xinxin 密码已更新为 sxx980623');
|
||||
console.log('lister 密码已更新为 lister123');
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
44
backend/update-passwords.ts
Normal file
44
backend/update-passwords.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function updatePasswords() {
|
||||
try {
|
||||
const conn = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'employee_performance',
|
||||
});
|
||||
|
||||
console.log('生成新的密码哈希...');
|
||||
const hash = await bcrypt.hash('123456', 10);
|
||||
console.log('新哈希:', hash);
|
||||
|
||||
// 测试旧哈希
|
||||
const oldHash = '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy';
|
||||
const matchOld = await bcrypt.compare('123456', oldHash);
|
||||
console.log('旧哈希验证:', matchOld);
|
||||
|
||||
// 更新所有用户密码
|
||||
await conn.query('UPDATE user SET password = ?', [hash]);
|
||||
console.log('✓ 所有用户密码已更新');
|
||||
|
||||
// 验证更新
|
||||
const [rows] = await conn.query('SELECT username, password FROM user LIMIT 1');
|
||||
const user = (rows as any)[0];
|
||||
const matchNew = await bcrypt.compare('123456', user.password);
|
||||
console.log('新密码验证:', matchNew);
|
||||
|
||||
await conn.end();
|
||||
process.exit(0);
|
||||
} catch (error: any) {
|
||||
console.error('错误:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
updatePasswords();
|
||||
4
frontend/.gitignore
vendored
Normal file
4
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>员工月度绩效考核系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3136
frontend/package-lock.json
generated
Normal file
3136
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "employee-performance-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"antd": "^5.12.0",
|
||||
"axios": "^1.6.0",
|
||||
"echarts": "^5.4.3",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.3.2",
|
||||
"vite": "^5.0.8"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
}
|
||||
}
|
||||
25
frontend/src/App.tsx
Normal file
25
frontend/src/App.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import AppRouter from './router';
|
||||
|
||||
// 配置 dayjs 使用中文
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRouter />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
28
frontend/src/api/auth.ts
Normal file
28
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import http from './http';
|
||||
import type { UserInfo } from '../context/AuthContext';
|
||||
|
||||
interface LoginResponse {
|
||||
token: string;
|
||||
userInfo: UserInfo;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
login: async (
|
||||
username: string,
|
||||
password: string,
|
||||
role: string
|
||||
): Promise<LoginResponse> => {
|
||||
const { data } = await http.post<ApiResponse<LoginResponse>>('/api/user/login', {
|
||||
username,
|
||||
password,
|
||||
role,
|
||||
});
|
||||
return data.data; // 返回 data.data,因为后端包装了一层
|
||||
},
|
||||
};
|
||||
37
frontend/src/api/http.ts
Normal file
37
frontend/src/api/http.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '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;
|
||||
122
frontend/src/api/performance.ts
Normal file
122
frontend/src/api/performance.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import http from './http';
|
||||
|
||||
export interface AttendanceData {
|
||||
leave: number;
|
||||
late: number;
|
||||
absent: number;
|
||||
lackCard: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface PerformanceItemDTO {
|
||||
itemName: string;
|
||||
weight: number;
|
||||
userContent: string;
|
||||
selfScore: number;
|
||||
evidence?: string;
|
||||
}
|
||||
|
||||
export interface PerformanceSubmitDTO {
|
||||
month: string;
|
||||
status: 'draft' | 'submitted';
|
||||
workSummary: string;
|
||||
attendance: AttendanceData;
|
||||
performanceItems: PerformanceItemDTO[];
|
||||
}
|
||||
|
||||
export interface AIScoreItem {
|
||||
itemName: string;
|
||||
weight: number;
|
||||
aiScore: number;
|
||||
scoreExplanation: string;
|
||||
}
|
||||
|
||||
export interface AIResult {
|
||||
aiId: number;
|
||||
perfId: number;
|
||||
aiScoreDetail: AIScoreItem[];
|
||||
aiTotalScore: number;
|
||||
aiProblems: string[];
|
||||
aiSuggestions: string[];
|
||||
createTime: string;
|
||||
}
|
||||
|
||||
export interface PerformanceRecord {
|
||||
perfId: number;
|
||||
userId: number;
|
||||
month: string;
|
||||
status: 'draft' | 'submitted' | 'under_review' | 'completed' | 'rejected';
|
||||
selfScore?: number;
|
||||
aiScore?: number;
|
||||
managerScore?: number;
|
||||
totalScore?: number;
|
||||
level?: 'excellent' | 'qualified' | 'need_motivation' | 'unqualified';
|
||||
rewardPunish?: string;
|
||||
workSummary?: string;
|
||||
submitTime?: string;
|
||||
reviewTime?: string;
|
||||
reviewOpinion?: string;
|
||||
performanceItems?: PerformanceItemDetail[];
|
||||
attendance?: AttendanceData & { attendanceScore?: number };
|
||||
aiResult?: AIResult;
|
||||
}
|
||||
|
||||
export interface PerformanceItemDetail extends PerformanceItemDTO {
|
||||
itemId: number;
|
||||
itemCategory: 'business' | 'comprehensive';
|
||||
aiScore?: number;
|
||||
aiExplanation?: string;
|
||||
managerScore?: number;
|
||||
managerExplanation?: string;
|
||||
}
|
||||
|
||||
export interface PageResult<T> {
|
||||
list: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export const performanceApi = {
|
||||
submit: async (data: PerformanceSubmitDTO): Promise<{ perfId: number }> => {
|
||||
const { data: res } = await http.post('/api/performance/submit', data);
|
||||
return res.data; // 解包 data.data
|
||||
},
|
||||
|
||||
getMyList: async (params?: {
|
||||
month?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<PageResult<PerformanceRecord>> => {
|
||||
const { data } = await http.get('/api/performance/employee/get', { params });
|
||||
// 后端返回格式: { code, message, data: { total, records } }
|
||||
// 转换为前端期望的格式: { list, total, page, pageSize }
|
||||
return {
|
||||
list: data.data.records || [],
|
||||
total: data.data.total || 0,
|
||||
page: params?.page || 1,
|
||||
pageSize: params?.pageSize || 10,
|
||||
};
|
||||
},
|
||||
|
||||
getDetail: async (perfId: number): Promise<PerformanceRecord> => {
|
||||
const { data } = await http.get(`/api/performance/employee/get`, {
|
||||
params: { perfId },
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
|
||||
requestModification: async (perfId: number, reason: string): Promise<void> => {
|
||||
await http.post('/api/performance/request-modification', { perfId, reason });
|
||||
},
|
||||
|
||||
// 查询 AI 评分是否完成
|
||||
checkAIResult: async (perfId: number): Promise<{ done: boolean; aiScore?: number }> => {
|
||||
const { data } = await http.get('/api/performance/employee/get', { params: { perfId } });
|
||||
const rec = data.data;
|
||||
return {
|
||||
done: rec?.aiScore != null,
|
||||
aiScore: rec?.aiScore,
|
||||
};
|
||||
},
|
||||
};
|
||||
55
frontend/src/context/AuthContext.tsx
Normal file
55
frontend/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
|
||||
export interface UserInfo {
|
||||
userId: number;
|
||||
name: string;
|
||||
role: 'employee' | 'manager' | 'generalManager';
|
||||
department: string;
|
||||
position: string;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
token: string | null;
|
||||
userInfo: UserInfo | null;
|
||||
login: (token: string, userInfo: UserInfo) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
const USER_KEY = 'auth_user';
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
|
||||
const [userInfo, setUserInfo] = useState<UserInfo | null>(() => {
|
||||
const stored = localStorage.getItem(USER_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
});
|
||||
|
||||
const login = useCallback((newToken: string, newUserInfo: UserInfo) => {
|
||||
localStorage.setItem(TOKEN_KEY, newToken);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(newUserInfo));
|
||||
setToken(newToken);
|
||||
setUserInfo(newUserInfo);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setToken(null);
|
||||
setUserInfo(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ token, userInfo, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = (): AuthContextValue => {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
};
|
||||
13
frontend/src/hooks/useBreakpoint.ts
Normal file
13
frontend/src/hooks/useBreakpoint.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useIsMobile(): boolean {
|
||||
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setIsMobile(window.innerWidth < 768);
|
||||
window.addEventListener('resize', handler);
|
||||
return () => window.removeEventListener('resize', handler);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
BIN
frontend/src/img/logo.png
Normal file
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
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
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
179
frontend/src/pages/Login.tsx
Normal file
179
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Form, Input, Button, Select, Alert, Typography } from 'antd';
|
||||
import { UserOutlined, LockOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { authApi } from '../api/auth';
|
||||
import logo from '../img/logo2.png';
|
||||
|
||||
const { Option } = Select;
|
||||
const { Text } = Typography;
|
||||
|
||||
interface LoginFormValues {
|
||||
username: string;
|
||||
password: string;
|
||||
role: 'employee' | 'manager' | 'generalManager';
|
||||
}
|
||||
|
||||
const roleRedirect: Record<string, string> = {
|
||||
employee: '/employee',
|
||||
manager: '/manager',
|
||||
generalManager: '/gm',
|
||||
};
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [alertInfo, setAlertInfo] = useState<{ type: 'success' | 'error'; msg: string } | null>(null);
|
||||
|
||||
const onFinish = async (values: LoginFormValues) => {
|
||||
setLoading(true);
|
||||
setAlertInfo(null);
|
||||
try {
|
||||
const { token, userInfo } = await authApi.login(values.username, values.password, values.role);
|
||||
login(token, userInfo);
|
||||
setAlertInfo({ type: 'success', msg: `欢迎回来,${userInfo.name}!正在跳转...` });
|
||||
setTimeout(() => navigate(roleRedirect[userInfo.role], { replace: true }), 800);
|
||||
} catch (err: any) {
|
||||
setAlertInfo({ type: 'error', msg: err?.response?.data?.message || '登录失败,请检查用户名、密码和角色' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.bg}>
|
||||
{/* 光晕装饰 */}
|
||||
<div style={{ ...styles.circle, width: 500, height: 500, top: -150, left: -150 }} />
|
||||
<div style={{ ...styles.circle, width: 400, height: 400, bottom: -100, right: -100 }} />
|
||||
<div style={{ ...styles.circle, width: 300, height: 300, top: '40%', left: '60%' }} />
|
||||
|
||||
{/* 双层纹理叠层:点阵 + 斜线 */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, zIndex: 0,
|
||||
backgroundImage: `
|
||||
radial-gradient(circle, rgba(99,102,241,0.08) 1px, transparent 1px),
|
||||
repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 20px,
|
||||
rgba(99,102,241,0.03) 20px,
|
||||
rgba(99,102,241,0.03) 21px
|
||||
)
|
||||
`,
|
||||
backgroundSize: '24px 24px, 100% 100%',
|
||||
}} />
|
||||
|
||||
<div style={styles.card}>
|
||||
{/* Logo + 标题 */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 28 }}>
|
||||
<img src={logo} alt="logo" style={{ height: 48, objectFit: 'contain', marginBottom: 12 }} />
|
||||
<div style={{ fontSize: 13, color: '#8c8c8c', letterSpacing: 1 }}>员工月度绩效考核系统</div>
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
{alertInfo && (
|
||||
<Alert
|
||||
type={alertInfo.type}
|
||||
message={alertInfo.msg}
|
||||
showIcon
|
||||
style={{ marginBottom: 16, borderRadius: 8 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form<LoginFormValues>
|
||||
name="login"
|
||||
onFinish={onFinish}
|
||||
initialValues={{ role: 'employee' }}
|
||||
size="large"
|
||||
>
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input
|
||||
prefix={<UserOutlined style={{ color: '#bfbfbf' }} />}
|
||||
placeholder="用户名(工号)"
|
||||
style={styles.input}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined style={{ color: '#bfbfbf' }} />}
|
||||
placeholder="密码"
|
||||
style={styles.input}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="role" rules={[{ required: true, message: '请选择角色' }]}>
|
||||
<Select placeholder="选择角色" style={styles.input} suffixIcon={<TeamOutlined style={{ color: '#bfbfbf' }} />}>
|
||||
<Option value="employee">员工</Option>
|
||||
<Option value="manager">管理层</Option>
|
||||
<Option value="generalManager">总经理</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
block
|
||||
loading={loading}
|
||||
style={styles.btn}
|
||||
>
|
||||
登 录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 20 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>优一科技 © 2026</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
bg: {
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #f0f7ff 0%, #e8f0fe 35%, #fce4ec 70%, #f3e5f5 100%)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
padding: 16,
|
||||
},
|
||||
circle: {
|
||||
position: 'absolute',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(99,102,241,0.12) 0%, transparent 70%)',
|
||||
filter: 'blur(40px)',
|
||||
},
|
||||
card: {
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
background: '#ffffff',
|
||||
borderRadius: 20,
|
||||
padding: '36px 32px 28px',
|
||||
boxShadow: '0 4px 24px rgba(99,102,241,0.12), 0 1px 4px rgba(0,0,0,0.06)',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
border: '1px solid rgba(99,102,241,0.08)',
|
||||
},
|
||||
input: {
|
||||
borderRadius: 8,
|
||||
height: 44,
|
||||
},
|
||||
btn: {
|
||||
height: 46,
|
||||
borderRadius: 10,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
background: 'linear-gradient(90deg, #6366f1, #8b5cf6)',
|
||||
border: 'none',
|
||||
letterSpacing: 2,
|
||||
},
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
108
frontend/src/pages/employee/Dashboard.tsx
Normal file
108
frontend/src/pages/employee/Dashboard.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Layout, Menu, Typography, Button } from 'antd';
|
||||
import { HomeOutlined, FormOutlined, HistoryOutlined, LogoutOutlined } from '@ant-design/icons';
|
||||
import PerformanceForm from './PerformanceForm';
|
||||
import PerformanceHistory from './PerformanceHistory';
|
||||
import { useIsMobile } from '../../hooks/useBreakpoint';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import logo from '../../img/logo.png';
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
const { Title } = Typography;
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/employee', icon: <HomeOutlined />, label: <Link to="/employee">首页</Link>, mobileLabel: '首页' },
|
||||
{ key: '/employee/form', icon: <FormOutlined />, label: <Link to="/employee/form">绩效填报</Link>, mobileLabel: '填报' },
|
||||
{ key: '/employee/history', icon: <HistoryOutlined />, label: <Link to="/employee/history">绩效历史</Link>, mobileLabel: '历史' },
|
||||
];
|
||||
|
||||
const EmployeeDashboard: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const { logout } = useAuth();
|
||||
|
||||
const selectedKey = menuItems.find(item =>
|
||||
item.key !== '/employee' ? location.pathname.startsWith(item.key) : location.pathname === item.key
|
||||
)?.key ?? '/employee';
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Header style={{ background: '#001529', padding: '0 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<img src={logo} alt="logo" style={{ height: 32, objectFit: 'contain' }} />
|
||||
{!isMobile && <Title level={4} style={{ color: 'white', margin: 0 }}>绩效考核系统</Title>}
|
||||
</div>
|
||||
<Button type="text" icon={<LogoutOutlined />} style={{ color: 'white' }} onClick={handleLogout}>
|
||||
{!isMobile && '退出'}
|
||||
</Button>
|
||||
</Header>
|
||||
|
||||
<Layout style={{ paddingBottom: isMobile ? 56 : 0 }}>
|
||||
{!isMobile && (
|
||||
<Sider width={200} style={{ background: '#fff' }}>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
style={{ height: '100%', borderRight: 0 }}
|
||||
items={menuItems.map(({ key, icon, label }) => ({ key, icon, label }))}
|
||||
/>
|
||||
</Sider>
|
||||
)}
|
||||
<Layout style={{ padding: isMobile ? '12px 12px 0' : '0 24px 24px' }}>
|
||||
<Content style={{ background: '#fff', padding: isMobile ? 12 : 24, minHeight: 280 }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/form" element={<PerformanceForm />} />
|
||||
<Route path="/history" element={<PerformanceHistory />} />
|
||||
</Routes>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
|
||||
{/* 移动端底部导航 */}
|
||||
{isMobile && (
|
||||
<div style={{
|
||||
position: 'fixed', bottom: 0, left: 0, right: 0, height: 56,
|
||||
background: '#fff', borderTop: '1px solid #f0f0f0',
|
||||
display: 'flex', zIndex: 1000,
|
||||
}}>
|
||||
{menuItems.map(item => (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.key}
|
||||
style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center', gap: 2,
|
||||
color: selectedKey === item.key ? '#1677ff' : '#8c8c8c',
|
||||
fontSize: 10, textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 20 }}>{item.icon}</span>
|
||||
<span>{item.mobileLabel}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
const HomePage: React.FC = () => (
|
||||
<div>
|
||||
<Title level={3}>欢迎使用员工绩效考核系统</Title>
|
||||
<Typography.Paragraph>请从菜单选择功能:</Typography.Paragraph>
|
||||
<ul>
|
||||
<li>绩效填报:填写月度绩效考核内容</li>
|
||||
<li>绩效历史:查看历史绩效记录和详情</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default EmployeeDashboard;
|
||||
427
frontend/src/pages/employee/PerformanceForm.tsx
Normal file
427
frontend/src/pages/employee/PerformanceForm.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Space,
|
||||
Typography,
|
||||
message,
|
||||
Tag,
|
||||
Row,
|
||||
Col,
|
||||
Alert,
|
||||
DatePicker,
|
||||
Result,
|
||||
Spin,
|
||||
} from 'antd';
|
||||
import { SaveOutlined, SendOutlined, LoadingOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { performanceApi, PerformanceSubmitDTO } from '../../api/performance';
|
||||
import { ALL_PERFORMANCE_ITEMS } from './performanceItems';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface FormValues {
|
||||
assessmentMonth: Dayjs;
|
||||
workSummary: string;
|
||||
leave: number;
|
||||
late: number;
|
||||
absent: number;
|
||||
lackCard: number;
|
||||
attendanceRemark?: string;
|
||||
items: Array<{
|
||||
userContent: string;
|
||||
selfScore: number;
|
||||
evidence?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const PerformanceForm: React.FC = () => {
|
||||
const { userInfo } = useAuth();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [aiWaiting, setAiWaiting] = useState(false);
|
||||
const [aiDone, setAiDone] = useState(false);
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
// 清理轮询定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollTimerRef.current) clearInterval(pollTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 轮询 AI 结果
|
||||
const pollAIResult = (perfId: number) => {
|
||||
setAiWaiting(true);
|
||||
let attempts = 0;
|
||||
const maxAttempts = 40; // 最多轮询 40 次(约 2 分钟)
|
||||
|
||||
pollTimerRef.current = setInterval(async () => {
|
||||
attempts++;
|
||||
try {
|
||||
const result = await performanceApi.checkAIResult(perfId);
|
||||
if (result.done) {
|
||||
clearInterval(pollTimerRef.current!);
|
||||
setAiWaiting(false);
|
||||
setAiDone(true);
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(pollTimerRef.current!);
|
||||
setAiWaiting(false);
|
||||
setAiDone(true); // 超时也跳转,让用户去历史记录查看
|
||||
}
|
||||
} catch {
|
||||
// 忽略轮询错误,继续重试
|
||||
}
|
||||
}, 3000); // 每 3 秒轮询一次
|
||||
};
|
||||
|
||||
// 获取本月作为默认值
|
||||
const getPreviousMonth = (): Dayjs => {
|
||||
return dayjs();
|
||||
};
|
||||
|
||||
const buildDTO = (values: FormValues, status: 'draft' | 'submitted'): PerformanceSubmitDTO => ({
|
||||
month: values.assessmentMonth.format('YYYY-MM'),
|
||||
status,
|
||||
workSummary: values.workSummary || '',
|
||||
attendance: {
|
||||
leave: values.leave ?? 0,
|
||||
late: values.late ?? 0,
|
||||
absent: values.absent ?? 0,
|
||||
lackCard: values.lackCard ?? 0,
|
||||
remark: values.attendanceRemark,
|
||||
},
|
||||
performanceItems: ALL_PERFORMANCE_ITEMS.map((item, idx) => ({
|
||||
itemName: item.itemName,
|
||||
weight: item.weight,
|
||||
userContent: values.items?.[idx]?.userContent || '',
|
||||
selfScore: values.items?.[idx]?.selfScore ?? 0,
|
||||
evidence: values.items?.[idx]?.evidence,
|
||||
})),
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = form.getFieldsValue();
|
||||
setSaving(true);
|
||||
try {
|
||||
await performanceApi.submit(buildDTO(values, 'draft'));
|
||||
message.success('草稿已暂存');
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || '暂存失败,请重试');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: FormValues) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await performanceApi.submit(buildDTO(values, 'submitted'));
|
||||
setSubmitted(true);
|
||||
form.resetFields();
|
||||
// 提交成功后开始轮询 AI 结果
|
||||
if (result?.perfId) {
|
||||
pollAIResult(result.perfId);
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || '提交失败,请重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (submitted) {
|
||||
// AI 分析完成
|
||||
if (aiDone) {
|
||||
return (
|
||||
<Result
|
||||
icon={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
|
||||
title="AI 分析完成!"
|
||||
subTitle="绩效已提交,AI 评分已生成,可前往历史记录查看详情。"
|
||||
style={{ margin: 24 }}
|
||||
extra={
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => { setSubmitted(false); setAiDone(false); }}>
|
||||
重新填报
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// AI 分析中
|
||||
if (aiWaiting) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '80px 24px' }}>
|
||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 48 }} spin />} />
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Typography.Title level={4}>AI 正在分析中,请稍等!</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
系统正在对您的绩效数据进行 AI 智能评分,通常需要 30 秒至 1 分钟...
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 提交成功但还未开始轮询(过渡状态)
|
||||
return (
|
||||
<Alert
|
||||
type="success"
|
||||
message="提交成功"
|
||||
description="您的绩效已成功提交,AI 评分将在后台自动完成,管理层将在审核期内完成审核。"
|
||||
showIcon
|
||||
style={{ margin: 24 }}
|
||||
action={
|
||||
<Button onClick={() => setSubmitted(false)}>重新填报</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 8 : 24, maxWidth: 900, margin: '0 auto' }}>
|
||||
<Title level={isMobile ? 4 : 3}>绩效填报</Title>
|
||||
|
||||
{/* 基础信息 */}
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
{isMobile ? (
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<div><Text type="secondary">姓名:</Text><Text strong>{userInfo?.name}</Text></div>
|
||||
<div><Text type="secondary">部门:</Text><Text strong>{userInfo?.department}</Text></div>
|
||||
<div><Text type="secondary">岗位:</Text><Text strong>{userInfo?.position}</Text></div>
|
||||
</Space>
|
||||
) : (
|
||||
<Row gutter={24}>
|
||||
<Col span={8}><Text type="secondary">姓名:</Text><Text strong>{userInfo?.name}</Text></Col>
|
||||
<Col span={8}><Text type="secondary">部门:</Text><Text strong>{userInfo?.department}</Text></Col>
|
||||
<Col span={8}><Text type="secondary">岗位:</Text><Text strong>{userInfo?.position}</Text></Col>
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
initialValues={{
|
||||
assessmentMonth: getPreviousMonth(),
|
||||
leave: 0, late: 0, absent: 0, lackCard: 0,
|
||||
items: ALL_PERFORMANCE_ITEMS.map(() => ({ selfScore: 0 })),
|
||||
}}
|
||||
>
|
||||
{/* 考核月份选择 */}
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Form.Item
|
||||
name="assessmentMonth"
|
||||
label="考核月份"
|
||||
rules={[{ required: true, message: '请选择考核月份' }]}
|
||||
>
|
||||
<DatePicker
|
||||
picker="month"
|
||||
format="YYYY-MM"
|
||||
placeholder="请选择考核月份"
|
||||
style={{ width: 200 }}
|
||||
disabledDate={(current) => {
|
||||
// 不能选择未来的月份
|
||||
return current && current > dayjs().endOf('month');
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
{/* 考核指标 */}
|
||||
<Card title="考核指标填写" style={{ marginBottom: 16 }}>
|
||||
<Alert
|
||||
type="info"
|
||||
message="请逐项填写各考核指标的完成情况描述和自评分数(0-100分)"
|
||||
style={{ marginBottom: 16 }}
|
||||
showIcon
|
||||
/>
|
||||
|
||||
{/* 业务素质 */}
|
||||
<Title level={5}>
|
||||
业务素质考评 <Tag color="blue">占总分 70%</Tag>
|
||||
</Title>
|
||||
{ALL_PERFORMANCE_ITEMS.filter(i => i.category === 'business').map((item, idx) => (
|
||||
<Card
|
||||
key={item.itemName}
|
||||
size="small"
|
||||
style={{ marginBottom: 12, background: '#fafafa' }}
|
||||
title={
|
||||
<Space>
|
||||
<Text strong>{item.itemName}</Text>
|
||||
<Tag color="geekblue">权重 {item.weight} 分</Tag>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
{item.description}
|
||||
</Text>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={16}>
|
||||
<Form.Item
|
||||
name={['items', idx, 'userContent']}
|
||||
label="完成情况描述"
|
||||
rules={[{ required: true, message: '请填写完成情况描述' }]}
|
||||
>
|
||||
<TextArea rows={2} placeholder="请描述本月该项指标的完成情况" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} md={4}>
|
||||
<Form.Item
|
||||
name={['items', idx, 'selfScore']}
|
||||
label="自评分数"
|
||||
rules={[{ required: true, message: '请填写自评分' }]}
|
||||
>
|
||||
<InputNumber min={0} max={100} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} md={4}>
|
||||
<Form.Item name={['items', idx, 'evidence']} label="佐证材料(可选)">
|
||||
<Input placeholder="链接或说明" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 综合素质 */}
|
||||
<Title level={5}>
|
||||
综合素质考评 <Tag color="green">占总分 30%</Tag>
|
||||
</Title>
|
||||
{ALL_PERFORMANCE_ITEMS.filter(i => i.category === 'comprehensive').map((item) => {
|
||||
const idx = ALL_PERFORMANCE_ITEMS.findIndex(x => x.itemName === item.itemName);
|
||||
return (
|
||||
<Card
|
||||
key={item.itemName}
|
||||
size="small"
|
||||
style={{ marginBottom: 12, background: '#fafafa' }}
|
||||
title={
|
||||
<Space>
|
||||
<Text strong>{item.itemName}</Text>
|
||||
<Tag color="green">权重 {item.weight} 分</Tag>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
{item.description}
|
||||
</Text>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={16}>
|
||||
<Form.Item
|
||||
name={['items', idx, 'userContent']}
|
||||
label="完成情况描述"
|
||||
rules={[{ required: true, message: '请填写完成情况描述' }]}
|
||||
>
|
||||
<TextArea rows={2} placeholder="请描述本月该项指标的完成情况" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} md={4}>
|
||||
<Form.Item
|
||||
name={['items', idx, 'selfScore']}
|
||||
label="自评分数"
|
||||
rules={[{ required: true, message: '请填写自评分' }]}
|
||||
>
|
||||
<InputNumber min={0} max={100} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} md={4}>
|
||||
<Form.Item name={['items', idx, 'evidence']} label="佐证材料(可选)">
|
||||
<Input placeholder="链接或说明" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
|
||||
{/* 考勤数据 */}
|
||||
<Card title="考勤数据" style={{ marginBottom: 16 }}>
|
||||
<Alert
|
||||
type="info"
|
||||
message="考勤满分 10 分,事假每天扣 5 分,迟到/缺卡每次扣 2 分,最低 0 分"
|
||||
style={{ marginBottom: 16 }}
|
||||
showIcon
|
||||
/>
|
||||
<Row gutter={16}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Form.Item name="leave" label="事假(天)">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Form.Item name="late" label="迟到(次)">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Form.Item name="absent" label="旷工(天)">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Form.Item name="lackCard" label="缺卡(次)">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="attendanceRemark" label="考勤备注(可选)">
|
||||
<TextArea rows={2} placeholder="如有特殊情况请说明" />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 工作汇总 */}
|
||||
<Card title="工作汇总" style={{ marginBottom: 16 }}>
|
||||
<Form.Item
|
||||
name="workSummary"
|
||||
label="工作汇总"
|
||||
rules={[{ required: true, message: '请填写工作汇总' }, { min: 10, message: '工作汇总至少填写 10 个字' }]}
|
||||
>
|
||||
<TextArea
|
||||
rows={5}
|
||||
placeholder="请填写本月工作总结,包括主要工作内容、成果、遇到的问题及解决方案等"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', paddingBottom: 16 }}>
|
||||
<Button
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
size="large"
|
||||
style={isMobile ? { flex: 1 } : {}}
|
||||
>
|
||||
暂存草稿
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
htmlType="submit"
|
||||
loading={submitting}
|
||||
size="large"
|
||||
style={isMobile ? { flex: 1 } : {}}
|
||||
>
|
||||
提交绩效
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PerformanceForm;
|
||||
418
frontend/src/pages/employee/PerformanceHistory.tsx
Normal file
418
frontend/src/pages/employee/PerformanceHistory.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Tag,
|
||||
Button,
|
||||
DatePicker,
|
||||
Space,
|
||||
Typography,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
List,
|
||||
Divider,
|
||||
message,
|
||||
Empty,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
} from 'antd';
|
||||
import { EyeOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { performanceApi, PerformanceRecord } from '../../api/performance';
|
||||
import { useIsMobile } from '../../hooks/useBreakpoint';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||
draft: { label: '草稿', color: 'default' },
|
||||
submitted: { label: '已提交', color: 'processing' },
|
||||
under_review: { label: '审核中', color: 'warning' },
|
||||
completed: { label: '已完成', color: 'success' },
|
||||
rejected: { label: '已驳回', color: 'error' },
|
||||
};
|
||||
|
||||
const LEVEL_MAP: Record<string, { label: string; color: string }> = {
|
||||
excellent: { label: '优秀', color: 'gold' },
|
||||
qualified: { label: '合格', color: 'green' },
|
||||
need_motivation: { label: '需激励', color: 'orange' },
|
||||
unqualified: { label: '不合格', color: 'red' },
|
||||
};
|
||||
|
||||
const PerformanceHistory: React.FC = () => {
|
||||
const [records, setRecords] = useState<PerformanceRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
const [filterMonth, setFilterMonth] = useState<string | undefined>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<PerformanceRecord | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await performanceApi.getMyList({ month: filterMonth, page, pageSize });
|
||||
setRecords(res.list);
|
||||
setTotal(res.total);
|
||||
} catch {
|
||||
message.error('加载绩效记录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterMonth, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
loadList();
|
||||
}, [loadList]);
|
||||
|
||||
const viewDetail = async (record: PerformanceRecord) => {
|
||||
setDrawerOpen(true);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const detail = await performanceApi.getDetail(record.perfId);
|
||||
setSelected(detail);
|
||||
} catch {
|
||||
// fallback to list data
|
||||
setSelected(record);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<PerformanceRecord> = [
|
||||
{
|
||||
title: '考核月份',
|
||||
dataIndex: 'month',
|
||||
key: 'month',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status: string) => {
|
||||
const s = STATUS_MAP[status] || { label: status, color: 'default' };
|
||||
return <Tag color={s.color}>{s.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '自评分',
|
||||
dataIndex: 'selfScore',
|
||||
key: 'selfScore',
|
||||
width: 90,
|
||||
render: (v?: number) => v != null ? Number(v).toFixed(1) : '-',
|
||||
},
|
||||
{
|
||||
title: 'AI 评分',
|
||||
dataIndex: 'aiScore',
|
||||
key: 'aiScore',
|
||||
width: 90,
|
||||
render: (v?: number) => v != null ? Number(v).toFixed(1) : '-',
|
||||
},
|
||||
{
|
||||
title: '最终总分',
|
||||
dataIndex: 'totalScore',
|
||||
key: 'totalScore',
|
||||
width: 90,
|
||||
render: (v?: number, record?: PerformanceRecord) => {
|
||||
if (v != null) {
|
||||
return <Text strong>{Number(v).toFixed(1)}</Text>;
|
||||
}
|
||||
// 如果已提交但还没有最终总分,显示待审核
|
||||
if (record?.status === 'submitted' || record?.status === 'under_review') {
|
||||
return <Tag color="processing">待审核</Tag>;
|
||||
}
|
||||
return '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '绩效等级',
|
||||
dataIndex: 'level',
|
||||
key: 'level',
|
||||
width: 100,
|
||||
render: (level?: string, record?: PerformanceRecord) => {
|
||||
if (level) {
|
||||
const l = LEVEL_MAP[level] || { label: level, color: 'default' };
|
||||
return <Tag color={l.color}>{l.label}</Tag>;
|
||||
}
|
||||
// 如果已提交但还没有等级,显示待审核
|
||||
if (record?.status === 'submitted' || record?.status === 'under_review') {
|
||||
return <Tag color="processing">待审核</Tag>;
|
||||
}
|
||||
return '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '提交时间',
|
||||
dataIndex: 'submitTime',
|
||||
key: 'submitTime',
|
||||
render: (v?: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
render: (_, record) => (
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => viewDetail(record)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 0 : 24 }}>
|
||||
<Title level={3}>个人绩效历史</Title>
|
||||
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<DatePicker
|
||||
picker="month"
|
||||
placeholder="筛选月份"
|
||||
onChange={(_, dateStr) => {
|
||||
setFilterMonth(dateStr as string || undefined);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Button onClick={loadList}>刷新</Button>
|
||||
</Space>
|
||||
|
||||
{/* 移动端卡片列表 */}
|
||||
{isMobile ? (
|
||||
<div>
|
||||
{loading && <div style={{ textAlign: 'center', padding: 24 }}>加载中...</div>}
|
||||
{!loading && records.length === 0 && <Empty description="暂无绩效记录" style={{ padding: 40 }} />}
|
||||
{records.map(record => {
|
||||
const s = STATUS_MAP[record.status] || { label: record.status, color: 'default' };
|
||||
return (
|
||||
<Card
|
||||
key={record.perfId}
|
||||
size="small"
|
||||
style={{ marginBottom: 12 }}
|
||||
onClick={() => viewDetail(record)}
|
||||
hoverable
|
||||
>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col><Typography.Text strong>{record.month}</Typography.Text></Col>
|
||||
<Col><Tag color={s.color}>{s.label}</Tag></Col>
|
||||
</Row>
|
||||
<Row gutter={8} style={{ marginTop: 8 }}>
|
||||
<Col span={8}><Typography.Text type="secondary" style={{ fontSize: 12 }}>自评分</Typography.Text><br /><Typography.Text>{record.selfScore != null ? Number(record.selfScore).toFixed(1) : '-'}</Typography.Text></Col>
|
||||
<Col span={8}><Typography.Text type="secondary" style={{ fontSize: 12 }}>AI评分</Typography.Text><br /><Typography.Text>{record.aiScore != null ? Number(record.aiScore).toFixed(1) : '-'}</Typography.Text></Col>
|
||||
<Col span={8}><Typography.Text type="secondary" style={{ fontSize: 12 }}>最终总分</Typography.Text><br />
|
||||
{record.totalScore != null
|
||||
? <Typography.Text strong>{Number(record.totalScore).toFixed(1)}</Typography.Text>
|
||||
: (record.status === 'submitted' || record.status === 'under_review')
|
||||
? <Tag color="processing" style={{ fontSize: 11 }}>待审核</Tag>
|
||||
: <Typography.Text>-</Typography.Text>
|
||||
}
|
||||
</Col>
|
||||
</Row>
|
||||
{record.submitTime && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11, marginTop: 4, display: 'block' }}>
|
||||
提交:{dayjs(record.submitTime).format('YYYY-MM-DD HH:mm')}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{total > pageSize && (
|
||||
<div style={{ textAlign: 'center', padding: '8px 0' }}>
|
||||
<Space>
|
||||
<Button size="small" disabled={page === 1} onClick={() => setPage(p => p - 1)}>上一页</Button>
|
||||
<Typography.Text type="secondary">{page} / {Math.ceil(total / pageSize)}</Typography.Text>
|
||||
<Button size="small" disabled={page >= Math.ceil(total / pageSize)} onClick={() => setPage(p => p + 1)}>下一页</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
rowKey="perfId"
|
||||
columns={columns}
|
||||
dataSource={records}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
onChange: setPage,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
locale={{ emptyText: <Empty description="暂无绩效记录" /> }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Drawer
|
||||
title={`绩效详情 — ${selected?.month ?? ''}`}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={isMobile ? '100%' : 720}
|
||||
placement={isMobile ? 'bottom' : 'right'}
|
||||
height={isMobile ? '90%' : undefined}
|
||||
loading={detailLoading}
|
||||
>
|
||||
{selected && <DetailContent record={selected} />}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DetailContent: React.FC<{ record: PerformanceRecord }> = ({ record }) => {
|
||||
const status = STATUS_MAP[record.status] || { label: record.status, color: 'default' };
|
||||
const level = record.level ? LEVEL_MAP[record.level] : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="考核月份">{record.month}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={status.color}>{status.label}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="自评总分">{record.selfScore != null ? Number(record.selfScore).toFixed(1) : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="AI 评分">{record.aiScore != null ? Number(record.aiScore).toFixed(1) : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="管理层评分">{record.managerScore != null ? Number(record.managerScore).toFixed(1) : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="最终总分">
|
||||
<Text strong>{record.totalScore != null ? Number(record.totalScore).toFixed(1) : '-'}</Text>
|
||||
</Descriptions.Item>
|
||||
{level && (
|
||||
<Descriptions.Item label="绩效等级">
|
||||
<Tag color={level.color}>{level.label}</Tag>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{record.rewardPunish && (
|
||||
<Descriptions.Item label="奖惩说明" span={2}>
|
||||
{record.rewardPunish}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{record.reviewOpinion && (
|
||||
<Descriptions.Item label="审核意见" span={2}>
|
||||
{record.reviewOpinion}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
|
||||
{/* 工作汇总 */}
|
||||
{record.workSummary && (
|
||||
<>
|
||||
<Divider orientation="left">工作汇总</Divider>
|
||||
<Card size="small">
|
||||
<Text>{record.workSummary}</Text>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 考勤信息 */}
|
||||
{record.attendance && (
|
||||
<>
|
||||
<Divider orientation="left">考勤数据</Divider>
|
||||
<Row gutter={16}>
|
||||
<Col span={6}><Text type="secondary">事假:</Text><Text>{record.attendance.leave} 天</Text></Col>
|
||||
<Col span={6}><Text type="secondary">迟到:</Text><Text>{record.attendance.late} 次</Text></Col>
|
||||
<Col span={6}><Text type="secondary">旷工:</Text><Text>{record.attendance.absent} 天</Text></Col>
|
||||
<Col span={6}><Text type="secondary">缺卡:</Text><Text>{record.attendance.lackCard} 次</Text></Col>
|
||||
</Row>
|
||||
{record.attendance.attendanceScore != null && (
|
||||
<Text type="secondary" style={{ marginTop: 8, display: 'block' }}>
|
||||
考勤得分:<Text strong>{record.attendance.attendanceScore}</Text> 分
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* AI 建议 */}
|
||||
{record.aiResult && (
|
||||
<>
|
||||
<Divider orientation="left">AI 建议</Divider>
|
||||
|
||||
{record.aiResult.aiProblems?.length > 0 && (
|
||||
<>
|
||||
<Text strong style={{ display: 'block', marginBottom: 4 }}>
|
||||
核心问题:
|
||||
</Text>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={record.aiResult.aiProblems}
|
||||
renderItem={(item, i) => (
|
||||
<List.Item>
|
||||
<Text>{i + 1}. {item}</Text>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{record.aiResult.aiSuggestions?.length > 0 && (
|
||||
<>
|
||||
<Text strong style={{ display: 'block', marginTop: 12, marginBottom: 4 }}>
|
||||
改进建议:
|
||||
</Text>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={record.aiResult.aiSuggestions}
|
||||
renderItem={(item, i) => (
|
||||
<List.Item>
|
||||
<Text>{i + 1}. {item}</Text>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 考核项明细 */}
|
||||
{record.performanceItems && record.performanceItems.length > 0 && (
|
||||
<>
|
||||
<Divider orientation="left">考核项明细</Divider>
|
||||
{record.performanceItems.map((item) => (
|
||||
<Card
|
||||
key={item.itemName}
|
||||
size="small"
|
||||
style={{ marginBottom: 8 }}
|
||||
title={
|
||||
<Space>
|
||||
<Text strong>{item.itemName}</Text>
|
||||
<Tag>{item.weight} 分</Tag>
|
||||
<Tag color={item.itemCategory === 'business' ? 'blue' : 'green'}>
|
||||
{item.itemCategory === 'business' ? '业务素质' : '综合素质'}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Descriptions column={3} size="small">
|
||||
<Descriptions.Item label="自评分">{item.selfScore ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="AI 评分">{item.aiScore ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="管理层评分">{item.managerScore ?? '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{item.userContent && (
|
||||
<Text type="secondary" style={{ display: 'block', marginTop: 4 }}>
|
||||
填报内容:{item.userContent}
|
||||
</Text>
|
||||
)}
|
||||
{item.aiExplanation && (
|
||||
<Text type="secondary" style={{ display: 'block', marginTop: 4 }}>
|
||||
AI 说明:{item.aiExplanation}
|
||||
</Text>
|
||||
)}
|
||||
{item.managerExplanation && (
|
||||
<Text type="secondary" style={{ display: 'block', marginTop: 4 }}>
|
||||
管理层说明:{item.managerExplanation}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PerformanceHistory;
|
||||
33
frontend/src/pages/employee/performanceItems.ts
Normal file
33
frontend/src/pages/employee/performanceItems.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface ItemConfig {
|
||||
itemName: string;
|
||||
weight: number;
|
||||
category: 'business' | 'comprehensive';
|
||||
description: string;
|
||||
}
|
||||
|
||||
// 业务素质 9 项(共 70 分)
|
||||
const businessItems: ItemConfig[] = [
|
||||
{ itemName: '工作目标完成情况', weight: 15, category: 'business', description: '本月工作目标的完成程度' },
|
||||
{ itemName: '工作质量', weight: 10, category: 'business', description: '工作成果的质量和准确性' },
|
||||
{ itemName: '工作效率', weight: 10, category: 'business', description: '完成工作任务的速度和效率' },
|
||||
{ itemName: '业务能力', weight: 10, category: 'business', description: '专业技能和业务知识水平' },
|
||||
{ itemName: '创新能力', weight: 5, category: 'business', description: '提出创新方案和改进建议的能力' },
|
||||
{ itemName: '问题解决能力', weight: 5, category: 'business', description: '发现和解决工作中问题的能力' },
|
||||
{ itemName: '项目推进能力', weight: 5, category: 'business', description: '推动项目按计划进行的能力' },
|
||||
{ itemName: '客户服务', weight: 5, category: 'business', description: '对内外部客户的服务质量' },
|
||||
{ itemName: '成本控制', weight: 5, category: 'business', description: '合理控制工作成本的意识和行动' },
|
||||
];
|
||||
|
||||
// 综合素质 8 项(共 30 分,含考勤 10 分)
|
||||
const comprehensiveItems: ItemConfig[] = [
|
||||
{ itemName: '团队协作', weight: 5, category: 'comprehensive', description: '与团队成员协作配合的能力' },
|
||||
{ itemName: '沟通能力', weight: 5, category: 'comprehensive', description: '与他人有效沟通的能力' },
|
||||
{ itemName: '学习成长', weight: 5, category: 'comprehensive', description: '主动学习和自我提升的意愿与行动' },
|
||||
{ itemName: '责任心', weight: 5, category: 'comprehensive', description: '对工作认真负责的态度' },
|
||||
{ itemName: '执行力', weight: 5, category: 'comprehensive', description: '按时按质完成任务的能力' },
|
||||
{ itemName: '职业素养', weight: 5, category: 'comprehensive', description: '职业道德和行为规范' },
|
||||
{ itemName: '工作态度', weight: 5, category: 'comprehensive', description: '对工作的积极性和主动性' },
|
||||
// 考勤由系统自动计算,不在此列表中手动填写
|
||||
];
|
||||
|
||||
export const ALL_PERFORMANCE_ITEMS: ItemConfig[] = [...businessItems, ...comprehensiveItems];
|
||||
175
frontend/src/pages/gm/CompanyOverview.tsx
Normal file
175
frontend/src/pages/gm/CompanyOverview.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { Card, Row, Col, Statistic, DatePicker, Button, Space, Typography, message, Spin, Select } from 'antd';
|
||||
import { TeamOutlined, TrophyOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import http from '../../api/http';
|
||||
import { useIsMobile } from '../../hooks/useBreakpoint';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
interface DepartmentStat {
|
||||
department: string;
|
||||
averageScore: number;
|
||||
totalCount: number;
|
||||
levelDistribution: { excellent: number; qualified: number; need_motivation: number; unqualified: number };
|
||||
}
|
||||
interface PositionStat { position: string; averageScore: number; totalCount: number; }
|
||||
interface CompanyStats {
|
||||
month: string; totalCount: number; averageScore: number;
|
||||
departmentStats: DepartmentStat[]; positionStats: PositionStat[];
|
||||
levelDistribution: { excellent: number; qualified: number; need_motivation: number; unqualified: number };
|
||||
}
|
||||
|
||||
const CompanyOverview: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState<CompanyStats | null>(null);
|
||||
const [selectedMonth, setSelectedMonth] = useState<Dayjs>(dayjs());
|
||||
const [selectedDimension, setSelectedDimension] = useState<'department' | 'position'>('department');
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await http.get('/api/statistics/company', { params: { month: selectedMonth.format('YYYY-MM') } });
|
||||
setStats(data.data || data);
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || '加载公司统计数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedMonth]);
|
||||
|
||||
useEffect(() => { loadStats(); }, [loadStats]);
|
||||
|
||||
const emptyOption = (title: string) => ({
|
||||
title: { text: title, left: 'center' },
|
||||
graphic: [{ type: 'text', left: 'center', top: 'middle', style: { text: '暂无数据', fontSize: 14, fill: '#999' } }],
|
||||
});
|
||||
|
||||
const getLevelPieOption = () => {
|
||||
if (!stats) return emptyOption('绩效等级分布');
|
||||
const names = { excellent: '优秀', qualified: '合格', need_motivation: '需激励', unqualified: '不合格' };
|
||||
const colors = { excellent: '#faad14', qualified: '#52c41a', need_motivation: '#fa8c16', unqualified: '#f5222d' };
|
||||
return {
|
||||
title: { text: '绩效等级分布', left: 'center' },
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c}人 ({d}%)' },
|
||||
legend: { orient: isMobile ? 'horizontal' : 'vertical', left: isMobile ? 'center' : 'left', bottom: isMobile ? 0 : undefined },
|
||||
series: [{
|
||||
name: '绩效等级', type: 'pie', radius: isMobile ? '45%' : '50%',
|
||||
center: isMobile ? ['50%', '45%'] : ['50%', '50%'],
|
||||
data: Object.entries(stats.levelDistribution).map(([k, v]) => ({
|
||||
name: names[k as keyof typeof names], value: v,
|
||||
itemStyle: { color: colors[k as keyof typeof colors] },
|
||||
})),
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const getBarOption = (title: string, categories: string[], values: number[], color: string) => ({
|
||||
title: { text: title, left: 'center', textStyle: { fontSize: isMobile ? 12 : 14 } },
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
grid: { left: isMobile ? '5%' : '3%', right: '4%', bottom: isMobile ? '20%' : '15%', containLabel: true },
|
||||
xAxis: { type: 'category', data: categories, axisLabel: { interval: 0, rotate: isMobile ? 45 : 30, fontSize: isMobile ? 10 : 12 } },
|
||||
yAxis: { type: 'value', name: '平均分', min: 0, max: 100 },
|
||||
series: [{ type: 'bar', data: values, itemStyle: { color }, label: { show: !isMobile, position: 'top' } }],
|
||||
});
|
||||
|
||||
const getDepartmentLevelStackOption = () => {
|
||||
if (!stats || stats.departmentStats.length === 0) return emptyOption('各部门绩效等级分布');
|
||||
const depts = stats.departmentStats.map(d => d.department);
|
||||
return {
|
||||
title: { text: '各部门绩效等级分布', left: 'center', textStyle: { fontSize: isMobile ? 12 : 14 } },
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
legend: { data: ['优秀', '合格', '需激励', '不合格'], bottom: 0, textStyle: { fontSize: isMobile ? 10 : 12 } },
|
||||
grid: { left: '3%', right: '4%', bottom: isMobile ? '25%' : '15%', containLabel: true },
|
||||
xAxis: { type: 'category', data: depts, axisLabel: { interval: 0, rotate: isMobile ? 45 : 30, fontSize: isMobile ? 10 : 12 } },
|
||||
yAxis: { type: 'value', name: '人数' },
|
||||
series: [
|
||||
{ name: '优秀', type: 'bar', stack: 'total', data: stats.departmentStats.map(d => d.levelDistribution.excellent), itemStyle: { color: '#faad14' } },
|
||||
{ name: '合格', type: 'bar', stack: 'total', data: stats.departmentStats.map(d => d.levelDistribution.qualified), itemStyle: { color: '#52c41a' } },
|
||||
{ name: '需激励', type: 'bar', stack: 'total', data: stats.departmentStats.map(d => d.levelDistribution.need_motivation), itemStyle: { color: '#fa8c16' } },
|
||||
{ name: '不合格', type: 'bar', stack: 'total', data: stats.departmentStats.map(d => d.levelDistribution.unqualified), itemStyle: { color: '#f5222d' } },
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
if (loading && !stats) return <div style={{ textAlign: 'center', padding: 100 }}><Spin size="large" /></div>;
|
||||
|
||||
const excellentRate = (stats && stats.totalCount > 0)
|
||||
? ((stats.levelDistribution.excellent / stats.totalCount) * 100).toFixed(1) : '0';
|
||||
const chartH = isMobile ? 260 : 400;
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 0 : 24 }}>
|
||||
<Title level={isMobile ? 4 : 3}>全公司绩效总览</Title>
|
||||
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<DatePicker picker="month" value={selectedMonth} onChange={(d) => d && setSelectedMonth(d)} format="YYYY-MM" />
|
||||
<Select value={selectedDimension} onChange={setSelectedDimension} style={{ width: isMobile ? 130 : 150 }}>
|
||||
<Option value="department">按部门统计</Option>
|
||||
<Option value="position">按岗位统计</Option>
|
||||
</Select>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadStats} loading={loading}>刷新</Button>
|
||||
</Space>
|
||||
|
||||
{stats && (
|
||||
<>
|
||||
{/* 统计卡片 */}
|
||||
<Row gutter={[8, 8]} style={{ marginBottom: 12 }}>
|
||||
{[
|
||||
{ title: '总人数', value: stats.totalCount, suffix: '人', prefix: <TeamOutlined />, color: undefined },
|
||||
{ title: '平均分', value: Number(stats.averageScore).toFixed(1), suffix: '', prefix: null, color: '#3f8600' },
|
||||
{ title: '优秀人数', value: stats.levelDistribution.excellent, suffix: '人', prefix: <TrophyOutlined />, color: '#faad14' },
|
||||
{ title: '优秀率', value: excellentRate, suffix: '%', prefix: null, color: '#cf1322' },
|
||||
].map((item, i) => (
|
||||
<Col xs={12} sm={6} key={i}>
|
||||
<Card bodyStyle={{ padding: isMobile ? '10px 8px' : 20 }}>
|
||||
<Statistic title={item.title} value={item.value} suffix={item.suffix}
|
||||
prefix={item.prefix ?? undefined} valueStyle={{ color: item.color, fontSize: isMobile ? 18 : 24 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{/* 图表 */}
|
||||
<Row gutter={[8, 8]} style={{ marginBottom: 12 }}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Card bodyStyle={{ padding: isMobile ? 8 : 24 }}>
|
||||
<ReactECharts option={getLevelPieOption()} style={{ height: chartH }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Card bodyStyle={{ padding: isMobile ? 8 : 24 }}>
|
||||
<ReactECharts
|
||||
option={selectedDimension === 'department'
|
||||
? getBarOption('各部门绩效平均分', stats.departmentStats.map(d => d.department), stats.departmentStats.map(d => d.averageScore), '#1890ff')
|
||||
: getBarOption('各岗位绩效平均分', stats.positionStats.map(p => p.position), stats.positionStats.map(p => p.averageScore), '#52c41a')
|
||||
}
|
||||
style={{ height: chartH }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{selectedDimension === 'department' && (
|
||||
<Card style={{ marginBottom: 12 }} bodyStyle={{ padding: isMobile ? 8 : 24 }}>
|
||||
<ReactECharts option={getDepartmentLevelStackOption()} style={{ height: chartH }} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card size="small">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
统计范围:{selectedMonth.format('YYYY年MM月')} 已完成审核的全公司员工绩效数据
|
||||
</Text>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!stats && !loading && <Card><Text type="secondary">暂无统计数据</Text></Card>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompanyOverview;
|
||||
217
frontend/src/pages/gm/ConfigManagement.tsx
Normal file
217
frontend/src/pages/gm/ConfigManagement.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Row, Col, Button, Space, Typography, message, Spin, Form, InputNumber, Divider, DatePicker, Modal } from 'antd';
|
||||
import { DownloadOutlined, SaveOutlined, ReloadOutlined, SettingOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import http from '../../api/http';
|
||||
import { useIsMobile } from '../../hooks/useBreakpoint';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface RuleResponse {
|
||||
ruleId: number; ruleKey: string; ruleValue: any;
|
||||
description: string | null; effectiveCycle: string | null;
|
||||
updatedBy: number | null; updatedAt: string;
|
||||
}
|
||||
|
||||
// 规则字段配置
|
||||
const RULE_FIELDS = {
|
||||
business: [
|
||||
{ name: 'business_completion_weight', label: '工作完成度', max: 70 },
|
||||
{ name: 'business_quality_weight', label: '工作质量', max: 70 },
|
||||
{ name: 'business_efficiency_weight', label: '工作效率', max: 70 },
|
||||
{ name: 'business_skill_weight', label: '专业技能', max: 70 },
|
||||
{ name: 'business_innovation_weight', label: '创新能力', max: 70 },
|
||||
{ name: 'business_problem_solving_weight', label: '问题解决', max: 70 },
|
||||
{ name: 'business_customer_satisfaction_weight', label: '客户满意度', max: 70 },
|
||||
{ name: 'business_teamwork_weight', label: '团队协作', max: 70 },
|
||||
{ name: 'business_goal_achievement_weight', label: '目标达成', max: 70 },
|
||||
],
|
||||
comprehensive: [
|
||||
{ name: 'comprehensive_responsibility_weight', label: '责任心', max: 30 },
|
||||
{ name: 'comprehensive_initiative_weight', label: '主动性', max: 30 },
|
||||
{ name: 'comprehensive_learning_weight', label: '学习能力', max: 30 },
|
||||
{ name: 'comprehensive_communication_weight', label: '沟通能力', max: 30 },
|
||||
{ name: 'comprehensive_execution_weight', label: '执行力', max: 30 },
|
||||
{ name: 'comprehensive_discipline_weight', label: '纪律性', max: 30 },
|
||||
{ name: 'comprehensive_team_spirit_weight', label: '团队精神', max: 30 },
|
||||
{ name: 'comprehensive_attendance_weight', label: '考勤', max: 30 },
|
||||
],
|
||||
reward: [
|
||||
{ name: 'reward_excellent', label: '优秀奖励(≥90分)', unit: '元' },
|
||||
{ name: 'punish_qualified_high', label: '合格扣款(80-89分)', unit: '元' },
|
||||
{ name: 'punish_qualified_low', label: '合格扣款(70-79分)', unit: '元' },
|
||||
{ name: 'punish_need_motivation', label: '需激励扣款(60-69分)', unit: '元' },
|
||||
{ name: 'punish_unqualified', label: '不合格扣款(<60分)', unit: '元' },
|
||||
],
|
||||
attendance: [
|
||||
{ name: 'attendance_leave_deduct', label: '事假扣分(每天)', unit: '分' },
|
||||
{ name: 'attendance_late_deduct', label: '迟到扣分(每次)', unit: '分' },
|
||||
{ name: 'attendance_lack_card_deduct', label: '缺卡扣分(每次)', unit: '分' },
|
||||
{ name: 'attendance_base_score', label: '考勤基础分', unit: '分' },
|
||||
],
|
||||
};
|
||||
|
||||
const ConfigManagement: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [rules, setRules] = useState<RuleResponse[]>([]);
|
||||
const [selectedMonth, setSelectedMonth] = useState<Dayjs>(dayjs());
|
||||
const [form] = Form.useForm();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const loadRules = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await http.get('/api/config/rules');
|
||||
const ruleList: RuleResponse[] = data.data || data;
|
||||
setRules(ruleList);
|
||||
const formValues: Record<string, any> = {};
|
||||
ruleList.forEach((rule) => { formValues[rule.ruleKey] = rule.ruleValue; });
|
||||
form.setFieldsValue(formValues);
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || '加载配置规则失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadRules(); }, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
Modal.confirm({
|
||||
title: '确认修改考核规则',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: '修改后的规则将应用于后续考核周期,是否确认?',
|
||||
okText: '确认', cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await http.put('/api/config/rules', {
|
||||
rules: Object.entries(values).map(([ruleKey, ruleValue]) => ({ ruleKey, ruleValue })),
|
||||
});
|
||||
message.success('规则更新成功');
|
||||
await loadRules();
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || '规则更新失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch { message.error('请检查表单输入'); }
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const month = selectedMonth.format('YYYY-MM');
|
||||
const response = await http.get('/api/performance/export', { params: { month, type: 'all' }, responseType: 'blob' });
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `全公司绩效数据_${month}.xlsx`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
message.success('导出成功');
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || '导出失败');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && rules.length === 0) return <div style={{ textAlign: 'center', padding: 100 }}><Spin size="large" /></div>;
|
||||
|
||||
// 移动端每行2列,桌面端每行3或4列
|
||||
const colSpan = isMobile ? 12 : 8;
|
||||
const colSpan4 = isMobile ? 12 : 6;
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 0 : 24 }}>
|
||||
<Title level={isMobile ? 4 : 3}>系统配置与数据导出</Title>
|
||||
|
||||
{/* 数据导出 */}
|
||||
<Card title={<Space><DownloadOutlined /><span>全量数据导出</span></Space>} style={{ marginBottom: 16 }}
|
||||
bodyStyle={{ padding: isMobile ? 12 : 24 }}>
|
||||
<Text style={{ display: 'block', marginBottom: 8 }}>选择考核月份,导出全公司所有员工的绩效数据(Excel 格式)</Text>
|
||||
<Space wrap>
|
||||
<DatePicker picker="month" value={selectedMonth} onChange={(d) => d && setSelectedMonth(d)} format="YYYY-MM" />
|
||||
<Button type="primary" icon={<DownloadOutlined />} onClick={handleExport} loading={exporting}>
|
||||
导出全量数据
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* 考核规则配置 */}
|
||||
<Card
|
||||
title={<Space><SettingOutlined /><span>考核规则配置</span></Space>}
|
||||
bodyStyle={{ padding: isMobile ? 12 : 24 }}
|
||||
extra={
|
||||
<Space size={4}>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadRules} loading={loading} size={isMobile ? 'small' : 'middle'}>刷新</Button>
|
||||
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving} size={isMobile ? 'small' : 'middle'}>保存</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Divider orientation="left" style={{ fontSize: isMobile ? 13 : 14 }}>业务素质考核指标权重(总计70分)</Divider>
|
||||
<Row gutter={[8, 0]}>
|
||||
{RULE_FIELDS.business.map(f => (
|
||||
<Col span={colSpan} key={f.name}>
|
||||
<Form.Item label={f.label} name={f.name} rules={[{ required: true, message: '请输入' }]}>
|
||||
<InputNumber min={0} max={f.max} style={{ width: '100%' }} addonAfter="分" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Divider orientation="left" style={{ fontSize: isMobile ? 13 : 14 }}>综合素质考核指标权重(总计30分)</Divider>
|
||||
<Row gutter={[8, 0]}>
|
||||
{RULE_FIELDS.comprehensive.map(f => (
|
||||
<Col span={colSpan} key={f.name}>
|
||||
<Form.Item label={f.label} name={f.name} rules={[{ required: true, message: '请输入' }]}>
|
||||
<InputNumber min={0} max={f.max} style={{ width: '100%' }} addonAfter="分" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Divider orientation="left" style={{ fontSize: isMobile ? 13 : 14 }}>奖惩金额配置</Divider>
|
||||
<Row gutter={[8, 0]}>
|
||||
{RULE_FIELDS.reward.map(f => (
|
||||
<Col span={colSpan} key={f.name}>
|
||||
<Form.Item label={f.label} name={f.name} rules={[{ required: true, message: '请输入' }]}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} addonAfter={f.unit} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Divider orientation="left" style={{ fontSize: isMobile ? 13 : 14 }}>考勤扣分规则</Divider>
|
||||
<Row gutter={[8, 0]}>
|
||||
{RULE_FIELDS.attendance.map(f => (
|
||||
<Col span={colSpan4} key={f.name}>
|
||||
<Form.Item label={f.label} name={f.name} rules={[{ required: true, message: '请输入' }]}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} addonAfter={f.unit} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Form>
|
||||
|
||||
<Card size="small" style={{ marginTop: 12, background: '#fffbe6' }}>
|
||||
<Text type="warning" style={{ fontSize: 12 }}>
|
||||
<ExclamationCircleOutlined /> 注意:修改考核规则后,将自动应用于后续考核周期。已完成的历史绩效数据不受影响。
|
||||
</Text>
|
||||
</Card>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigManagement;
|
||||
107
frontend/src/pages/gm/Dashboard.tsx
Normal file
107
frontend/src/pages/gm/Dashboard.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Layout, Menu, Typography, Button } from 'antd';
|
||||
import { HomeOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined } from '@ant-design/icons';
|
||||
import CompanyOverview from './CompanyOverview';
|
||||
import ConfigManagement from './ConfigManagement';
|
||||
import { useIsMobile } from '../../hooks/useBreakpoint';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import logo from '../../img/logo.png';
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
const { Title } = Typography;
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/gm', icon: <HomeOutlined />, label: <Link to="/gm">首页</Link>, mobileLabel: '首页' },
|
||||
{ key: '/gm/overview', icon: <BarChartOutlined />, label: <Link to="/gm/overview">绩效总览</Link>, mobileLabel: '总览' },
|
||||
{ key: '/gm/config', icon: <SettingOutlined />, label: <Link to="/gm/config">系统配置</Link>, mobileLabel: '配置' },
|
||||
];
|
||||
|
||||
const GMDashboard: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const { logout } = useAuth();
|
||||
|
||||
const getSelectedKey = () => {
|
||||
if (location.pathname.startsWith('/gm/overview')) return '/gm/overview';
|
||||
if (location.pathname.startsWith('/gm/config')) return '/gm/config';
|
||||
return '/gm';
|
||||
};
|
||||
const selectedKey = getSelectedKey();
|
||||
|
||||
const handleLogout = () => { logout(); navigate('/login'); };
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Header style={{ background: '#001529', padding: '0 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<img src={logo} alt="logo" style={{ height: 32, objectFit: 'contain' }} />
|
||||
{!isMobile && <Title level={4} style={{ color: 'white', margin: 0 }}>绩效考核系统 - 总经理</Title>}
|
||||
</div>
|
||||
<Button type="text" icon={<LogoutOutlined />} style={{ color: 'white' }} onClick={handleLogout}>
|
||||
{!isMobile && '退出'}
|
||||
</Button>
|
||||
</Header>
|
||||
|
||||
<Layout style={{ paddingBottom: isMobile ? 56 : 0 }}>
|
||||
{!isMobile && (
|
||||
<Sider width={200} style={{ background: '#fff' }}>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
style={{ height: '100%', borderRight: 0 }}
|
||||
items={menuItems.map(({ key, icon, label }) => ({ key, icon, label }))}
|
||||
/>
|
||||
</Sider>
|
||||
)}
|
||||
<Layout style={{ padding: isMobile ? '12px 12px 0' : '0 24px 24px' }}>
|
||||
<Content style={{ background: '#fff', padding: isMobile ? 12 : 24, minHeight: 280 }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/overview" element={<CompanyOverview />} />
|
||||
<Route path="/config" element={<ConfigManagement />} />
|
||||
</Routes>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
|
||||
{isMobile && (
|
||||
<div style={{
|
||||
position: 'fixed', bottom: 0, left: 0, right: 0, height: 56,
|
||||
background: '#fff', borderTop: '1px solid #f0f0f0',
|
||||
display: 'flex', zIndex: 1000,
|
||||
}}>
|
||||
{menuItems.map(item => (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.key}
|
||||
style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center', gap: 2,
|
||||
color: selectedKey === item.key ? '#1677ff' : '#8c8c8c',
|
||||
fontSize: 10, textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 20 }}>{item.icon}</span>
|
||||
<span>{item.mobileLabel}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
const HomePage: React.FC = () => (
|
||||
<div>
|
||||
<Title level={3}>欢迎使用总经理工作台</Title>
|
||||
<Typography.Paragraph>请从菜单选择功能:</Typography.Paragraph>
|
||||
<ul>
|
||||
<li>全公司绩效总览:查看各部门、各岗位的绩效统计数据</li>
|
||||
<li>系统配置:导出全量绩效数据、配置考核规则</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default GMDashboard;
|
||||
114
frontend/src/pages/manager/Dashboard.tsx
Normal file
114
frontend/src/pages/manager/Dashboard.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Layout, Menu, Typography, Button } from 'antd';
|
||||
import { HomeOutlined, TeamOutlined, BarChartOutlined, LogoutOutlined, UserAddOutlined } from '@ant-design/icons';
|
||||
import SubordinateList from './SubordinateList';
|
||||
import PerformanceReview from './PerformanceReview';
|
||||
import TeamStatistics from './TeamStatistics';
|
||||
import EmployeeManagement from './EmployeeManagement';
|
||||
import logo from '../../img/logo.png';
|
||||
import { useIsMobile } from '../../hooks/useBreakpoint';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
const { Title } = Typography;
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/manager', icon: <HomeOutlined />, label: <Link to="/manager">首页</Link>, mobileLabel: '首页' },
|
||||
{ key: '/manager/subordinates', icon: <TeamOutlined />, label: <Link to="/manager/subordinates">下属绩效</Link>, mobileLabel: '下属' },
|
||||
{ key: '/manager/statistics', icon: <BarChartOutlined />, label: <Link to="/manager/statistics">团队统计</Link>, mobileLabel: '统计' },
|
||||
{ key: '/manager/employees', icon: <UserAddOutlined />, label: <Link to="/manager/employees">员工管理</Link>, mobileLabel: '员工' },
|
||||
];
|
||||
|
||||
const ManagerDashboard: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const { logout } = useAuth();
|
||||
|
||||
const getSelectedKey = () => {
|
||||
if (location.pathname.startsWith('/manager/review')) return '/manager/subordinates';
|
||||
if (location.pathname.startsWith('/manager/statistics')) return '/manager/statistics';
|
||||
if (location.pathname.startsWith('/manager/subordinates')) return '/manager/subordinates';
|
||||
if (location.pathname.startsWith('/manager/employees')) return '/manager/employees';
|
||||
return '/manager';
|
||||
};
|
||||
const selectedKey = getSelectedKey();
|
||||
|
||||
const handleLogout = () => { logout(); navigate('/login'); };
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Header style={{ background: '#001529', padding: '0 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<img src={logo} alt="logo" style={{ height: 32, objectFit: 'contain' }} />
|
||||
{!isMobile && <Title level={4} style={{ color: 'white', margin: 0 }}>绩效考核系统 - 管理层</Title>}
|
||||
</div>
|
||||
<Button type="text" icon={<LogoutOutlined />} style={{ color: 'white' }} onClick={handleLogout}>
|
||||
{!isMobile && '退出'}
|
||||
</Button>
|
||||
</Header>
|
||||
|
||||
<Layout style={{ paddingBottom: isMobile ? 56 : 0 }}>
|
||||
{!isMobile && (
|
||||
<Sider width={200} style={{ background: '#fff' }}>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
style={{ height: '100%', borderRight: 0 }}
|
||||
items={menuItems.map(({ key, icon, label }) => ({ key, icon, label }))}
|
||||
/>
|
||||
</Sider>
|
||||
)}
|
||||
<Layout style={{ padding: isMobile ? '12px 12px 0' : '0 24px 24px' }}>
|
||||
<Content style={{ background: '#fff', padding: isMobile ? 12 : 24, minHeight: 280 }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/subordinates" element={<SubordinateList />} />
|
||||
<Route path="/review/:perfId" element={<PerformanceReview />} />
|
||||
<Route path="/statistics" element={<TeamStatistics />} />
|
||||
<Route path="/employees" element={<EmployeeManagement />} />
|
||||
</Routes>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
|
||||
{isMobile && (
|
||||
<div style={{
|
||||
position: 'fixed', bottom: 0, left: 0, right: 0, height: 56,
|
||||
background: '#fff', borderTop: '1px solid #f0f0f0',
|
||||
display: 'flex', zIndex: 1000,
|
||||
}}>
|
||||
{menuItems.map(item => (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.key}
|
||||
style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center', gap: 2,
|
||||
color: selectedKey === item.key ? '#1677ff' : '#8c8c8c',
|
||||
fontSize: 10, textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 20 }}>{item.icon}</span>
|
||||
<span>{item.mobileLabel}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
const HomePage: React.FC = () => (
|
||||
<div>
|
||||
<Title level={3}>欢迎使用管理层工作台</Title>
|
||||
<Typography.Paragraph>请从菜单选择功能:</Typography.Paragraph>
|
||||
<ul>
|
||||
<li>下属绩效:查看和审核下属员工的绩效记录</li>
|
||||
<li>团队统计:查看团队绩效统计数据和导出报表</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ManagerDashboard;
|
||||
226
frontend/src/pages/manager/EmployeeManagement.tsx
Normal file
226
frontend/src/pages/manager/EmployeeManagement.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Table, Button, Modal, Form, Input, Space, Typography,
|
||||
message, Popconfirm, Tag, Card,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, ReloadOutlined, StopOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import http from '../../api/http';
|
||||
import { useIsMobile } from '../../hooks/useBreakpoint';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
interface Employee {
|
||||
user_id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
department: string;
|
||||
position: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const EmployeeManagement: React.FC = () => {
|
||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await http.get('/api/employee/list');
|
||||
setEmployees(data.data || []);
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || '加载员工列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadList(); }, [loadList]);
|
||||
|
||||
const handleCreate = async (values: any) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await http.post('/api/employee/create', values);
|
||||
message.success('员工账号创建成功');
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
loadList();
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || '创建失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (userId: number) => {
|
||||
try {
|
||||
await http.delete(`/api/employee/${userId}`);
|
||||
message.success('员工账号已删除');
|
||||
loadList();
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (userId: number, currentStatus: string) => {
|
||||
const newStatus = currentStatus === 'active' ? 'inactive' : 'active';
|
||||
try {
|
||||
await http.patch(`/api/employee/${userId}/status`, { status: newStatus });
|
||||
message.success(newStatus === 'inactive' ? '员工账号已禁用' : '员工账号已启用');
|
||||
loadList();
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Employee> = [
|
||||
{ title: '用户名', dataIndex: 'username', key: 'username', width: 110 },
|
||||
{ title: '姓名', dataIndex: 'name', key: 'name', width: 90 },
|
||||
{ title: '部门', dataIndex: 'department', key: 'department', width: 100 },
|
||||
{ title: '岗位', dataIndex: 'position', key: 'position', width: 120 },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 80,
|
||||
render: (s: string) => <Tag color={s === 'active' ? 'green' : 'default'}>{s === 'active' ? '在职' : '停用'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 150,
|
||||
render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-',
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 140, fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space size={4}>
|
||||
<Popconfirm
|
||||
title={record.status === 'active' ? '确认禁用该员工账号?' : '确认启用该员工账号?'}
|
||||
description={record.status === 'active' ? '禁用后该员工将无法登录系统。' : '启用后该员工可正常登录。'}
|
||||
onConfirm={() => handleToggleStatus(record.user_id, record.status)}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: record.status === 'active' }}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={record.status === 'active' ? <StopOutlined /> : <CheckCircleOutlined />}
|
||||
danger={record.status === 'active'}
|
||||
style={{ color: record.status !== 'active' ? '#52c41a' : undefined }}
|
||||
>
|
||||
{record.status === 'active' ? '禁用' : '启用'}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm
|
||||
title="确认删除该员工账号?"
|
||||
description="删除后无法恢复,如有绩效记录将无法删除。"
|
||||
onConfirm={() => handleDelete(record.user_id)}
|
||||
okText="确认删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button type="link" danger icon={<DeleteOutlined />} size="small">删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 0 : 24 }}>
|
||||
<Title level={3}>员工账号管理</Title>
|
||||
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
|
||||
新建员工账号
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadList} loading={loading}>刷新</Button>
|
||||
</Space>
|
||||
|
||||
{isMobile ? (
|
||||
<div>
|
||||
{employees.map(emp => (
|
||||
<Card key={emp.user_id} size="small" style={{ marginBottom: 10 }}>
|
||||
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<Typography.Text strong>{emp.name}</Typography.Text>
|
||||
<Typography.Text type="secondary">@{emp.username}</Typography.Text>
|
||||
<Tag color={emp.status === 'active' ? 'green' : 'default'}>{emp.status === 'active' ? '在职' : '停用'}</Tag>
|
||||
</Space>
|
||||
<Typography.Text type="secondary">{emp.department} · {emp.position}</Typography.Text>
|
||||
<Space size={8}>
|
||||
<Popconfirm
|
||||
title={emp.status === 'active' ? '确认禁用?' : '确认启用?'}
|
||||
onConfirm={() => handleToggleStatus(emp.user_id, emp.status)}
|
||||
okText="确认" cancelText="取消" okButtonProps={{ danger: emp.status === 'active' }}
|
||||
>
|
||||
<Button type="link" size="small"
|
||||
icon={emp.status === 'active' ? <StopOutlined /> : <CheckCircleOutlined />}
|
||||
danger={emp.status === 'active'}
|
||||
style={{ padding: 0, color: emp.status !== 'active' ? '#52c41a' : undefined }}
|
||||
>
|
||||
{emp.status === 'active' ? '禁用' : '启用'}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm
|
||||
title="确认删除该员工账号?"
|
||||
description="删除后无法恢复,如有绩效记录将无法删除。"
|
||||
onConfirm={() => handleDelete(emp.user_id)}
|
||||
okText="确认删除" cancelText="取消" okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button type="link" danger icon={<DeleteOutlined />} size="small" style={{ padding: 0 }}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
rowKey="user_id"
|
||||
columns={columns}
|
||||
dataSource={employees}
|
||||
loading={loading}
|
||||
scroll={{ x: 700 }}
|
||||
pagination={{ pageSize: 20, showTotal: t => `共 ${t} 人` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title="新建员工账号"
|
||||
open={modalOpen}
|
||||
onCancel={() => { setModalOpen(false); form.resetFields(); }}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={submitting}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleCreate} style={{ marginTop: 16 }}>
|
||||
<Form.Item name="username" label="用户名(工号)" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input placeholder="如:emp007" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password" label="初始密码"
|
||||
rules={[{ required: true, message: '请输入密码' }, { min: 6, message: '密码至少6位' }]}
|
||||
>
|
||||
<Input.Password placeholder="至少6位" />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
|
||||
<Input placeholder="员工真实姓名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="department" label="部门" rules={[{ required: true, message: '请输入部门' }]}>
|
||||
<Input placeholder="如:技术部" />
|
||||
</Form.Item>
|
||||
<Form.Item name="position" label="岗位" rules={[{ required: true, message: '请输入岗位' }]}>
|
||||
<Input placeholder="如:前端工程师" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmployeeManagement;
|
||||
452
frontend/src/pages/manager/PerformanceReview.tsx
Normal file
452
frontend/src/pages/manager/PerformanceReview.tsx
Normal file
@@ -0,0 +1,452 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Descriptions,
|
||||
Tag,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
message,
|
||||
Spin,
|
||||
Row,
|
||||
Col,
|
||||
Divider,
|
||||
Form,
|
||||
InputNumber,
|
||||
Input,
|
||||
Modal,
|
||||
Alert,
|
||||
List,
|
||||
} from 'antd';
|
||||
import {
|
||||
CheckOutlined,
|
||||
CloseOutlined,
|
||||
ArrowLeftOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { PerformanceRecord } 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;
|
||||
272
frontend/src/pages/manager/SubordinateList.tsx
Normal file
272
frontend/src/pages/manager/SubordinateList.tsx
Normal 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;
|
||||
160
frontend/src/pages/manager/TeamStatistics.tsx
Normal file
160
frontend/src/pages/manager/TeamStatistics.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { Card, Row, Col, DatePicker, Button, Space, Typography, message, Spin, Progress } from 'antd';
|
||||
import { TeamOutlined, TrophyOutlined, CheckCircleOutlined, WarningOutlined, DownloadOutlined, ReloadOutlined, CloseCircleOutlined, BarChartOutlined } from '@ant-design/icons';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import http from '../../api/http';
|
||||
import { useIsMobile } from '../../hooks/useBreakpoint';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface TeamStats {
|
||||
averageScore: number;
|
||||
excellentCount: number;
|
||||
qualifiedCount: number;
|
||||
needMotivationCount: number;
|
||||
unqualifiedCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const TeamStatistics: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [stats, setStats] = useState<TeamStats | null>(null);
|
||||
const [selectedMonth, setSelectedMonth] = useState<Dayjs>(dayjs());
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await http.get('/api/statistics/team', {
|
||||
params: { month: selectedMonth.format('YYYY-MM') },
|
||||
});
|
||||
// 后端返回 { code, message, data: stats },需要解包
|
||||
const raw = data.data || data;
|
||||
setStats({
|
||||
averageScore: Number(raw.averageScore) || 0,
|
||||
excellentCount: Number(raw.excellentCount) || 0,
|
||||
qualifiedCount: Number(raw.qualifiedCount) || 0,
|
||||
needMotivationCount: Number(raw.needMotivationCount) || 0,
|
||||
unqualifiedCount: Number(raw.unqualifiedCount) || 0,
|
||||
totalCount: Number(raw.totalCount) || 0,
|
||||
});
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || '加载团队统计数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedMonth]);
|
||||
|
||||
useEffect(() => { loadStats(); }, [loadStats]);
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const month = selectedMonth.format('YYYY-MM');
|
||||
const response = await http.get('/api/performance/export', {
|
||||
params: { month, type: 'team' }, responseType: 'blob',
|
||||
});
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `团队绩效_${month}.xlsx`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
message.success('导出成功');
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || '导出失败');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pct = (count: number) =>
|
||||
safeStats.totalCount > 0 ? Math.round((count / safeStats.totalCount) * 100) : 0;
|
||||
|
||||
if (loading && !stats) {
|
||||
return <div style={{ textAlign: 'center', padding: 100 }}><Spin size="large" /></div>;
|
||||
}
|
||||
|
||||
const safeStats: TeamStats = stats ?? {
|
||||
averageScore: 0, excellentCount: 0, qualifiedCount: 0,
|
||||
needMotivationCount: 0, unqualifiedCount: 0, totalCount: 0,
|
||||
};
|
||||
|
||||
const levels = [
|
||||
{ label: '优秀', desc: '≥ 90 分', count: safeStats.excellentCount, percent: pct(safeStats.excellentCount), color: '#faad14', bg: '#fffbe6', icon: <TrophyOutlined style={{ color: '#faad14', fontSize: 20 }} /> },
|
||||
{ label: '合格', desc: '60-89 分', count: safeStats.qualifiedCount, percent: pct(safeStats.qualifiedCount), color: '#52c41a', bg: '#f6ffed', icon: <CheckCircleOutlined style={{ color: '#52c41a', fontSize: 20 }} /> },
|
||||
{ label: '需激励', desc: '60-69 分', count: safeStats.needMotivationCount, percent: pct(safeStats.needMotivationCount), color: '#fa8c16', bg: '#fff7e6', icon: <WarningOutlined style={{ color: '#fa8c16', fontSize: 20 }} /> },
|
||||
{ label: '不合格', desc: '< 60 分', count: safeStats.unqualifiedCount, percent: pct(safeStats.unqualifiedCount), color: '#f5222d', bg: '#fff1f0', icon: <CloseCircleOutlined style={{ color: '#f5222d', fontSize: 20 }} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 0 : 24 }}>
|
||||
<Title level={isMobile ? 4 : 3}>团队绩效统计</Title>
|
||||
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<DatePicker picker="month" value={selectedMonth} onChange={(d) => d && setSelectedMonth(d)} format="YYYY-MM" />
|
||||
<Button icon={<ReloadOutlined />} onClick={loadStats} loading={loading}>刷新</Button>
|
||||
<Button type="primary" icon={<DownloadOutlined />} onClick={handleExport} loading={exporting}>导出 Excel</Button>
|
||||
</Space>
|
||||
|
||||
{/* 总体统计 */}
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||
{[
|
||||
{ icon: <TeamOutlined style={{ fontSize: 18, color: '#1677ff' }} />, value: safeStats.totalCount, unit: '人', label: '总人数', color: undefined },
|
||||
{ icon: <BarChartOutlined style={{ fontSize: 18, color: '#3f8600' }} />, value: safeStats.averageScore.toFixed(1), unit: '分', label: '平均分', color: '#3f8600' },
|
||||
{ icon: <TrophyOutlined style={{ fontSize: 18, color: '#cf1322' }} />, value: `${pct(safeStats.excellentCount)}%`, unit: '', label: '优秀率', color: '#cf1322' },
|
||||
].map((item, i) => (
|
||||
<Col xs={8} key={i}>
|
||||
<Card bodyStyle={{ padding: isMobile ? '10px 8px' : 20, textAlign: 'center' }}>
|
||||
{item.icon && <div>{item.icon}</div>}
|
||||
<div style={{ fontSize: isMobile ? 20 : 26, fontWeight: 700, color: item.color, lineHeight: 1.3 }}>
|
||||
{item.value}{item.unit && <span style={{ fontSize: 12, fontWeight: 400 }}>{item.unit}</span>}
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>{item.label}</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{/* 等级分布 */}
|
||||
<Card title="绩效等级分布" bodyStyle={{ padding: isMobile ? 12 : 24 }}>
|
||||
<Row gutter={[12, 12]}>
|
||||
{levels.map(item => (
|
||||
<Col xs={12} key={item.label}>
|
||||
<Card size="small" style={{ background: item.bg, borderRadius: 8 }} bodyStyle={{ padding: 12 }}>
|
||||
<Space align="center" style={{ marginBottom: 8 }}>
|
||||
{item.icon}
|
||||
<div>
|
||||
<Text strong style={{ fontSize: isMobile ? 13 : 14 }}>{item.label}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>{item.desc}</Text>
|
||||
</div>
|
||||
</Space>
|
||||
<Row align="middle" justify="space-between" wrap={false}>
|
||||
<Col>
|
||||
<span style={{ fontSize: isMobile ? 20 : 24, fontWeight: 700 }}>{item.count}</span>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}> 人</Text>
|
||||
</Col>
|
||||
<Col>
|
||||
<Progress type="circle" percent={item.percent} width={isMobile ? 44 : 56} strokeColor={item.color} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card size="small" style={{ marginTop: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
统计范围:{selectedMonth.format('YYYY年MM月')} 已完成审核的下属员工绩效数据
|
||||
</Text>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamStatistics;
|
||||
84
frontend/src/router/index.tsx
Normal file
84
frontend/src/router/index.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
// Lazy-loaded placeholders — real pages will be added in tasks 13-15
|
||||
const EmployeeDashboard = React.lazy(() => import('../pages/employee/Dashboard'));
|
||||
const ManagerDashboard = React.lazy(() => import('../pages/manager/Dashboard'));
|
||||
const GMDashboard = React.lazy(() => import('../pages/gm/Dashboard'));
|
||||
const LoginPage = React.lazy(() => import('../pages/Login'));
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
allowedRole: 'employee' | 'manager' | 'generalManager';
|
||||
}
|
||||
|
||||
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children, allowedRole }) => {
|
||||
const { token, userInfo } = useAuth();
|
||||
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (userInfo?.role !== allowedRole) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const AppRouter: React.FC = () => {
|
||||
const { userInfo, token } = useAuth();
|
||||
|
||||
// Redirect root to role-specific home or login
|
||||
const getRootRedirect = () => {
|
||||
if (!token || !userInfo) return '/login';
|
||||
if (userInfo.role === 'employee') return '/employee';
|
||||
if (userInfo.role === 'manager') return '/manager';
|
||||
if (userInfo.role === 'generalManager') return '/gm';
|
||||
return '/login';
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={<div>加载中...</div>}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* 员工路由 */}
|
||||
<Route
|
||||
path="/employee/*"
|
||||
element={
|
||||
<ProtectedRoute allowedRole="employee">
|
||||
<EmployeeDashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 管理层路由 */}
|
||||
<Route
|
||||
path="/manager/*"
|
||||
element={
|
||||
<ProtectedRoute allowedRole="manager">
|
||||
<ManagerDashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 总经理路由 */}
|
||||
<Route
|
||||
path="/gm/*"
|
||||
element={
|
||||
<ProtectedRoute allowedRole="generalManager">
|
||||
<GMDashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="/" element={<Navigate to={getRootRedirect()} replace />} />
|
||||
<Route path="*" element={<Navigate to={getRootRedirect()} replace />} />
|
||||
</Routes>
|
||||
</React.Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppRouter;
|
||||
9
frontend/src/vite-env.d.ts
vendored
Normal file
9
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
292
部署文档.md
Normal file
292
部署文档.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# 优一科技员工月度绩效考核系统 — 部署文档
|
||||
|
||||
## 一、系统概述
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 系统名称 | 员工月度绩效考核系统 |
|
||||
| 前端技术 | React 18 + TypeScript + Ant Design 5 + Vite |
|
||||
| 后端技术 | Node.js + Express + TypeScript |
|
||||
| 数据库 | MySQL 8.0+ |
|
||||
| AI 服务 | FastGPT(云端 API) |
|
||||
|
||||
---
|
||||
|
||||
## 二、环境要求
|
||||
|
||||
| 软件 | 版本要求 |
|
||||
|------|----------|
|
||||
| Node.js | >= 18.x |
|
||||
| npm | >= 9.x |
|
||||
| MySQL | >= 8.0 |
|
||||
| 操作系统 | Linux(推荐 Ubuntu 22.04)/ Windows Server |
|
||||
| Nginx | >= 1.20(用于前端静态文件托管及反向代理) |
|
||||
|
||||
---
|
||||
|
||||
## 三、数据库初始化
|
||||
|
||||
### 3.1 创建数据库
|
||||
|
||||
```sql
|
||||
CREATE DATABASE employee_performance
|
||||
DEFAULT CHARACTER SET utf8mb4
|
||||
DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
### 3.2 创建数据库用户(可选,建议生产环境不使用 root)
|
||||
|
||||
```sql
|
||||
CREATE USER 'perf_user'@'localhost' IDENTIFIED BY '你的密码';
|
||||
GRANT ALL PRIVILEGES ON employee_performance.* TO 'perf_user'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
### 3.3 初始化表结构
|
||||
|
||||
将 `backend/src/db/init.sql` 导入数据库:
|
||||
|
||||
```bash
|
||||
mysql -u root -p employee_performance < backend/src/db/init.sql
|
||||
```
|
||||
|
||||
### 3.4 初始化配置规则数据
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npx ts-node init-config-rules.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、后端部署
|
||||
|
||||
### 4.1 安装依赖
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
```
|
||||
|
||||
### 4.2 配置环境变量
|
||||
|
||||
复制示例文件并修改:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
编辑 `.env`,填写以下配置:
|
||||
|
||||
```env
|
||||
# 数据库配置
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=perf_user
|
||||
DB_PASSWORD=你的数据库密码
|
||||
DB_NAME=employee_performance
|
||||
|
||||
# JWT 配置(生产环境请使用随机长字符串)
|
||||
JWT_SECRET=请替换为随机长字符串_至少32位
|
||||
|
||||
# FastGPT API 配置
|
||||
FASTGPT_API_KEY=你的FastGPT密钥
|
||||
FASTGPT_API_URL=https://cloud.fastgpt.cn/api/v1/chat/completions
|
||||
FASTGPT_MODEL=gpt-4
|
||||
|
||||
# 代理配置(如服务器需要代理才能访问 FastGPT,填写代理地址;否则删除此行)
|
||||
HTTPS_PROXY=http://127.0.0.1:7890
|
||||
|
||||
# 服务器配置
|
||||
PORT=3001
|
||||
NODE_ENV=production
|
||||
```
|
||||
|
||||
### 4.3 编译 TypeScript
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
编译产物在 `backend/dist/` 目录。
|
||||
|
||||
### 4.4 启动服务
|
||||
|
||||
**方式一:直接启动(测试用)**
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
**方式二:使用 PM2 守护进程(推荐生产环境)**
|
||||
|
||||
```bash
|
||||
# 安装 PM2
|
||||
npm install -g pm2
|
||||
|
||||
# 启动
|
||||
pm2 start dist/index.js --name "perf-backend"
|
||||
|
||||
# 设置开机自启
|
||||
pm2 startup
|
||||
pm2 save
|
||||
```
|
||||
|
||||
**常用 PM2 命令:**
|
||||
|
||||
```bash
|
||||
pm2 status # 查看状态
|
||||
pm2 logs perf-backend # 查看日志
|
||||
pm2 restart perf-backend # 重启
|
||||
pm2 stop perf-backend # 停止
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、前端部署
|
||||
|
||||
### 5.1 安装依赖
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### 5.2 配置 API 地址
|
||||
|
||||
如果后端不在同一台服务器,需要修改 `frontend/src/api/http.ts` 中的 `baseURL`:
|
||||
|
||||
```typescript
|
||||
const http = axios.create({
|
||||
baseURL: 'http://你的后端服务器IP:3001',
|
||||
timeout: 15000,
|
||||
});
|
||||
```
|
||||
|
||||
或者通过环境变量配置,在 `frontend/` 目录创建 `.env.production`:
|
||||
|
||||
```env
|
||||
VITE_API_BASE_URL=http://你的后端服务器IP:3001
|
||||
```
|
||||
|
||||
### 5.3 构建静态文件
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建产物在 `frontend/dist/` 目录。
|
||||
|
||||
### 5.4 Nginx 配置
|
||||
|
||||
将 `frontend/dist/` 目录内容部署到 Nginx,参考配置:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name 你的域名或IP;
|
||||
|
||||
# 前端静态文件
|
||||
root /var/www/perf-system/dist;
|
||||
index index.html;
|
||||
|
||||
# React Router 支持(所有路由返回 index.html)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 反向代理后端 API
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# 静态资源缓存
|
||||
location /assets/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
重载 Nginx:
|
||||
|
||||
```bash
|
||||
nginx -t && nginx -s reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、初始账号
|
||||
|
||||
| 用户名 | 密码 | 角色 |
|
||||
|--------|------|------|
|
||||
| lister | lister123 | 总经理 |
|
||||
| xinxin | sxx980623 | 管理层 |
|
||||
|
||||
> 员工账号由管理层在系统内「员工管理」页面创建。
|
||||
|
||||
---
|
||||
|
||||
## 七、目录结构说明
|
||||
|
||||
```
|
||||
项目根目录/
|
||||
├── backend/ # 后端源码
|
||||
│ ├── src/
|
||||
│ │ ├── routes/ # API 路由
|
||||
│ │ ├── services/ # 业务逻辑
|
||||
│ │ ├── dao/ # 数据访问层
|
||||
│ │ ├── db/ # SQL 初始化脚本
|
||||
│ │ └── index.ts # 入口文件
|
||||
│ ├── dist/ # 编译产物(npm run build 后生成)
|
||||
│ └── .env # 环境变量(不提交 Git)
|
||||
└── frontend/ # 前端源码
|
||||
├── src/
|
||||
└── dist/ # 构建产物(npm run build 后生成)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、常见问题
|
||||
|
||||
**Q: 后端启动报数据库连接失败**
|
||||
- 检查 `.env` 中 `DB_HOST`、`DB_PORT`、`DB_USER`、`DB_PASSWORD` 是否正确
|
||||
- 确认 MySQL 服务已启动:`systemctl status mysql`
|
||||
|
||||
**Q: AI 评分不工作**
|
||||
- 检查 `FASTGPT_API_KEY` 和 `FASTGPT_API_URL` 是否正确
|
||||
- 如服务器无法直接访问 FastGPT,配置 `HTTPS_PROXY`
|
||||
- 查看后端日志中 `[AI]` 开头的日志排查问题
|
||||
|
||||
**Q: 前端页面空白或 404**
|
||||
- 确认 Nginx 配置了 `try_files $uri /index.html`
|
||||
- 确认 `frontend/dist/` 已正确部署到 Nginx root 目录
|
||||
|
||||
**Q: 登录后 401 错误**
|
||||
- 检查 `JWT_SECRET` 是否与生成 token 时一致
|
||||
- 清除浏览器 localStorage 后重新登录
|
||||
|
||||
---
|
||||
|
||||
## 九、更新部署流程
|
||||
|
||||
```bash
|
||||
# 1. 拉取最新代码
|
||||
git pull
|
||||
|
||||
# 2. 重新构建后端
|
||||
cd backend && npm run build
|
||||
|
||||
# 3. 重启后端服务
|
||||
pm2 restart perf-backend
|
||||
|
||||
# 4. 重新构建前端
|
||||
cd ../frontend && npm run build
|
||||
|
||||
# 5. 将 dist/ 同步到 Nginx 目录
|
||||
cp -r dist/* /var/www/perf-system/dist/
|
||||
```
|
||||
Reference in New Issue
Block a user