first commit

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

View File

@@ -0,0 +1,729 @@
# 设计文档 - 员工月度绩效考核系统
## 概述
员工月度绩效考核系统是一个基于 Web 的全栈应用,采用前后端分离架构。系统集成 FastGPT AI 评分能力实现员工绩效填报、AI 自动评分、管理层审核、数据统计分析的完整闭环。
### 技术栈选择
**后端:**
- Node.js + TypeScript
- Express.js 框架
- MySQL 数据库
- JWT 身份认证
- AxiosFastGPT API 调用)
**前端:**
- React + TypeScript
- Ant Design UI 组件库
- React Router路由管理
- AxiosHTTP 请求)
- 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 导出格式验证
- 分页查询边界条件(第一页、最后一页、空结果)

View 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 调用的安全性,防止敏感信息泄露

View File

@@ -0,0 +1,192 @@
# 实施计划 - 员工月度绩效考核系统
## 概述
按照前后端分离架构,分阶段实施:先搭建后端基础设施和核心业务逻辑,再实现前端各角色页面,最后集成 AI 评分和统计分析功能。
## 任务
- [x] 1. 项目初始化与基础设施搭建
- 初始化后端 Node.js + TypeScript + Express 项目结构
- 初始化前端 React + TypeScript + Ant Design 项目结构
- 配置 MySQL 数据库连接(使用 mysql2 驱动)
- 创建数据库初始化脚本6张表的 DDL
- 配置环境变量(.envDB连接、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
View File

@@ -0,0 +1,2 @@
{
}

0
README.md Normal file
View File

18
backend/.env.example Normal file
View 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
View File

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

21
backend/backfill-ai.ts Normal file
View 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);

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

39
backend/package.json Normal file
View 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"
}
}

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

View File

@@ -0,0 +1,2 @@
export const JWT_SECRET = process.env.JWT_SECRET || 'change_this_secret_in_production';
export const JWT_EXPIRES_IN = '24h';

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

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

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

View 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
View 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-MMNULL表示立即生效',
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
View 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
View 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
View 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;

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

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

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

View 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: '访问令牌无效或已过期' });
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

View 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('用户名或密码错误');
});
});

View File

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

View 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
View 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
View 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
View 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"]
}

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

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

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

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

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>员工月度绩效考核系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3136
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "employee-performance-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"@ant-design/icons": "^5.2.6",
"antd": "^5.12.0",
"axios": "^1.6.0",
"echarts": "^5.4.3",
"echarts-for-react": "^3.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.0"
},
"devDependencies": {
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.2",
"vite": "^5.0.8"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
}
}

25
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,25 @@
import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import { AuthProvider } from './context/AuthContext';
import AppRouter from './router';
// 配置 dayjs 使用中文
dayjs.locale('zh-cn');
const App: React.FC = () => {
return (
<ConfigProvider locale={zhCN}>
<BrowserRouter>
<AuthProvider>
<AppRouter />
</AuthProvider>
</BrowserRouter>
</ConfigProvider>
);
};
export default App;

28
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,28 @@
import http from './http';
import type { UserInfo } from '../context/AuthContext';
interface LoginResponse {
token: string;
userInfo: UserInfo;
}
interface ApiResponse<T> {
code: number;
message: string;
data: T;
}
export const authApi = {
login: async (
username: string,
password: string,
role: string
): Promise<LoginResponse> => {
const { data } = await http.post<ApiResponse<LoginResponse>>('/api/user/login', {
username,
password,
role,
});
return data.data; // 返回 data.data因为后端包装了一层
},
};

37
frontend/src/api/http.ts Normal file
View File

@@ -0,0 +1,37 @@
import axios from 'axios';
const TOKEN_KEY = 'auth_token';
const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001',
timeout: 15000,
});
// Request interceptor — attach token automatically
http.interceptors.request.use(
(config) => {
const token = localStorage.getItem(TOKEN_KEY);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor — handle 401/403
http.interceptors.response.use(
(response) => response,
(error) => {
const status = error?.response?.status;
if (status === 401 || status === 403) {
// Clear stored credentials and redirect to login
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem('auth_user');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default http;

View File

@@ -0,0 +1,122 @@
import http from './http';
export interface AttendanceData {
leave: number;
late: number;
absent: number;
lackCard: number;
remark?: string;
}
export interface PerformanceItemDTO {
itemName: string;
weight: number;
userContent: string;
selfScore: number;
evidence?: string;
}
export interface PerformanceSubmitDTO {
month: string;
status: 'draft' | 'submitted';
workSummary: string;
attendance: AttendanceData;
performanceItems: PerformanceItemDTO[];
}
export interface AIScoreItem {
itemName: string;
weight: number;
aiScore: number;
scoreExplanation: string;
}
export interface AIResult {
aiId: number;
perfId: number;
aiScoreDetail: AIScoreItem[];
aiTotalScore: number;
aiProblems: string[];
aiSuggestions: string[];
createTime: string;
}
export interface PerformanceRecord {
perfId: number;
userId: number;
month: string;
status: 'draft' | 'submitted' | 'under_review' | 'completed' | 'rejected';
selfScore?: number;
aiScore?: number;
managerScore?: number;
totalScore?: number;
level?: 'excellent' | 'qualified' | 'need_motivation' | 'unqualified';
rewardPunish?: string;
workSummary?: string;
submitTime?: string;
reviewTime?: string;
reviewOpinion?: string;
performanceItems?: PerformanceItemDetail[];
attendance?: AttendanceData & { attendanceScore?: number };
aiResult?: AIResult;
}
export interface PerformanceItemDetail extends PerformanceItemDTO {
itemId: number;
itemCategory: 'business' | 'comprehensive';
aiScore?: number;
aiExplanation?: string;
managerScore?: number;
managerExplanation?: string;
}
export interface PageResult<T> {
list: T[];
total: number;
page: number;
pageSize: number;
}
export const performanceApi = {
submit: async (data: PerformanceSubmitDTO): Promise<{ perfId: number }> => {
const { data: res } = await http.post('/api/performance/submit', data);
return res.data; // 解包 data.data
},
getMyList: async (params?: {
month?: string;
page?: number;
pageSize?: number;
}): Promise<PageResult<PerformanceRecord>> => {
const { data } = await http.get('/api/performance/employee/get', { params });
// 后端返回格式: { code, message, data: { total, records } }
// 转换为前端期望的格式: { list, total, page, pageSize }
return {
list: data.data.records || [],
total: data.data.total || 0,
page: params?.page || 1,
pageSize: params?.pageSize || 10,
};
},
getDetail: async (perfId: number): Promise<PerformanceRecord> => {
const { data } = await http.get(`/api/performance/employee/get`, {
params: { perfId },
});
return data.data;
},
requestModification: async (perfId: number, reason: string): Promise<void> => {
await http.post('/api/performance/request-modification', { perfId, reason });
},
// 查询 AI 评分是否完成
checkAIResult: async (perfId: number): Promise<{ done: boolean; aiScore?: number }> => {
const { data } = await http.get('/api/performance/employee/get', { params: { perfId } });
const rec = data.data;
return {
done: rec?.aiScore != null,
aiScore: rec?.aiScore,
};
},
};

View File

@@ -0,0 +1,55 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
export interface UserInfo {
userId: number;
name: string;
role: 'employee' | 'manager' | 'generalManager';
department: string;
position: string;
}
interface AuthContextValue {
token: string | null;
userInfo: UserInfo | null;
login: (token: string, userInfo: UserInfo) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
const TOKEN_KEY = 'auth_token';
const USER_KEY = 'auth_user';
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
const [userInfo, setUserInfo] = useState<UserInfo | null>(() => {
const stored = localStorage.getItem(USER_KEY);
return stored ? JSON.parse(stored) : null;
});
const login = useCallback((newToken: string, newUserInfo: UserInfo) => {
localStorage.setItem(TOKEN_KEY, newToken);
localStorage.setItem(USER_KEY, JSON.stringify(newUserInfo));
setToken(newToken);
setUserInfo(newUserInfo);
}, []);
const logout = useCallback(() => {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
setToken(null);
setUserInfo(null);
}, []);
return (
<AuthContext.Provider value={{ token, userInfo, login, logout }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = (): AuthContextValue => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
};

View File

@@ -0,0 +1,13 @@
import { useState, useEffect } from 'react';
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768);
useEffect(() => {
const handler = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
return isMobile;
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import 'antd/dist/reset.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,179 @@
import React, { useState } from 'react';
import { Form, Input, Button, Select, Alert, Typography } from 'antd';
import { UserOutlined, LockOutlined, TeamOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { authApi } from '../api/auth';
import logo from '../img/logo2.png';
const { Option } = Select;
const { Text } = Typography;
interface LoginFormValues {
username: string;
password: string;
role: 'employee' | 'manager' | 'generalManager';
}
const roleRedirect: Record<string, string> = {
employee: '/employee',
manager: '/manager',
generalManager: '/gm',
};
const LoginPage: React.FC = () => {
const { login } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [alertInfo, setAlertInfo] = useState<{ type: 'success' | 'error'; msg: string } | null>(null);
const onFinish = async (values: LoginFormValues) => {
setLoading(true);
setAlertInfo(null);
try {
const { token, userInfo } = await authApi.login(values.username, values.password, values.role);
login(token, userInfo);
setAlertInfo({ type: 'success', msg: `欢迎回来,${userInfo.name}!正在跳转...` });
setTimeout(() => navigate(roleRedirect[userInfo.role], { replace: true }), 800);
} catch (err: any) {
setAlertInfo({ type: 'error', msg: err?.response?.data?.message || '登录失败,请检查用户名、密码和角色' });
} finally {
setLoading(false);
}
};
return (
<div style={styles.bg}>
{/* 光晕装饰 */}
<div style={{ ...styles.circle, width: 500, height: 500, top: -150, left: -150 }} />
<div style={{ ...styles.circle, width: 400, height: 400, bottom: -100, right: -100 }} />
<div style={{ ...styles.circle, width: 300, height: 300, top: '40%', left: '60%' }} />
{/* 双层纹理叠层:点阵 + 斜线 */}
<div style={{
position: 'absolute', inset: 0, zIndex: 0,
backgroundImage: `
radial-gradient(circle, rgba(99,102,241,0.08) 1px, transparent 1px),
repeating-linear-gradient(
45deg,
transparent,
transparent 20px,
rgba(99,102,241,0.03) 20px,
rgba(99,102,241,0.03) 21px
)
`,
backgroundSize: '24px 24px, 100% 100%',
}} />
<div style={styles.card}>
{/* Logo + 标题 */}
<div style={{ textAlign: 'center', marginBottom: 28 }}>
<img src={logo} alt="logo" style={{ height: 48, objectFit: 'contain', marginBottom: 12 }} />
<div style={{ fontSize: 13, color: '#8c8c8c', letterSpacing: 1 }}></div>
</div>
{/* 提示信息 */}
{alertInfo && (
<Alert
type={alertInfo.type}
message={alertInfo.msg}
showIcon
style={{ marginBottom: 16, borderRadius: 8 }}
/>
)}
<Form<LoginFormValues>
name="login"
onFinish={onFinish}
initialValues={{ role: 'employee' }}
size="large"
>
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input
prefix={<UserOutlined style={{ color: '#bfbfbf' }} />}
placeholder="用户名(工号)"
style={styles.input}
/>
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password
prefix={<LockOutlined style={{ color: '#bfbfbf' }} />}
placeholder="密码"
style={styles.input}
/>
</Form.Item>
<Form.Item name="role" rules={[{ required: true, message: '请选择角色' }]}>
<Select placeholder="选择角色" style={styles.input} suffixIcon={<TeamOutlined style={{ color: '#bfbfbf' }} />}>
<Option value="employee"></Option>
<Option value="manager"></Option>
<Option value="generalManager"></Option>
</Select>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button
type="primary"
htmlType="submit"
block
loading={loading}
style={styles.btn}
>
</Button>
</Form.Item>
</Form>
<div style={{ textAlign: 'center', marginTop: 20 }}>
<Text type="secondary" style={{ fontSize: 12 }}> © 2026</Text>
</div>
</div>
</div>
);
};
const styles: Record<string, React.CSSProperties> = {
bg: {
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #f0f7ff 0%, #e8f0fe 35%, #fce4ec 70%, #f3e5f5 100%)',
position: 'relative',
overflow: 'hidden',
padding: 16,
},
circle: {
position: 'absolute',
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(99,102,241,0.12) 0%, transparent 70%)',
filter: 'blur(40px)',
},
card: {
width: '100%',
maxWidth: 400,
background: '#ffffff',
borderRadius: 20,
padding: '36px 32px 28px',
boxShadow: '0 4px 24px rgba(99,102,241,0.12), 0 1px 4px rgba(0,0,0,0.06)',
position: 'relative',
zIndex: 1,
border: '1px solid rgba(99,102,241,0.08)',
},
input: {
borderRadius: 8,
height: 44,
},
btn: {
height: 46,
borderRadius: 10,
fontSize: 16,
fontWeight: 600,
background: 'linear-gradient(90deg, #6366f1, #8b5cf6)',
border: 'none',
letterSpacing: 2,
},
};
export default LoginPage;

View File

@@ -0,0 +1,108 @@
import React from 'react';
import { Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom';
import { Layout, Menu, Typography, Button } from 'antd';
import { HomeOutlined, FormOutlined, HistoryOutlined, LogoutOutlined } from '@ant-design/icons';
import PerformanceForm from './PerformanceForm';
import PerformanceHistory from './PerformanceHistory';
import { useIsMobile } from '../../hooks/useBreakpoint';
import { useAuth } from '../../context/AuthContext';
import logo from '../../img/logo.png';
const { Header, Content, Sider } = Layout;
const { Title } = Typography;
const menuItems = [
{ key: '/employee', icon: <HomeOutlined />, label: <Link to="/employee"></Link>, mobileLabel: '首页' },
{ key: '/employee/form', icon: <FormOutlined />, label: <Link to="/employee/form"></Link>, mobileLabel: '填报' },
{ key: '/employee/history', icon: <HistoryOutlined />, label: <Link to="/employee/history"></Link>, mobileLabel: '历史' },
];
const EmployeeDashboard: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const isMobile = useIsMobile();
const { logout } = useAuth();
const selectedKey = menuItems.find(item =>
item.key !== '/employee' ? location.pathname.startsWith(item.key) : location.pathname === item.key
)?.key ?? '/employee';
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<Layout style={{ minHeight: '100vh' }}>
<Header style={{ background: '#001529', padding: '0 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<img src={logo} alt="logo" style={{ height: 32, objectFit: 'contain' }} />
{!isMobile && <Title level={4} style={{ color: 'white', margin: 0 }}></Title>}
</div>
<Button type="text" icon={<LogoutOutlined />} style={{ color: 'white' }} onClick={handleLogout}>
{!isMobile && '退出'}
</Button>
</Header>
<Layout style={{ paddingBottom: isMobile ? 56 : 0 }}>
{!isMobile && (
<Sider width={200} style={{ background: '#fff' }}>
<Menu
mode="inline"
selectedKeys={[selectedKey]}
style={{ height: '100%', borderRight: 0 }}
items={menuItems.map(({ key, icon, label }) => ({ key, icon, label }))}
/>
</Sider>
)}
<Layout style={{ padding: isMobile ? '12px 12px 0' : '0 24px 24px' }}>
<Content style={{ background: '#fff', padding: isMobile ? 12 : 24, minHeight: 280 }}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/form" element={<PerformanceForm />} />
<Route path="/history" element={<PerformanceHistory />} />
</Routes>
</Content>
</Layout>
</Layout>
{/* 移动端底部导航 */}
{isMobile && (
<div style={{
position: 'fixed', bottom: 0, left: 0, right: 0, height: 56,
background: '#fff', borderTop: '1px solid #f0f0f0',
display: 'flex', zIndex: 1000,
}}>
{menuItems.map(item => (
<Link
key={item.key}
to={item.key}
style={{
flex: 1, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 2,
color: selectedKey === item.key ? '#1677ff' : '#8c8c8c',
fontSize: 10, textDecoration: 'none',
}}
>
<span style={{ fontSize: 20 }}>{item.icon}</span>
<span>{item.mobileLabel}</span>
</Link>
))}
</div>
)}
</Layout>
);
};
const HomePage: React.FC = () => (
<div>
<Title level={3}>使</Title>
<Typography.Paragraph></Typography.Paragraph>
<ul>
<li></li>
<li></li>
</ul>
</div>
);
export default EmployeeDashboard;

View File

@@ -0,0 +1,427 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Form,
Input,
InputNumber,
Button,
Card,
Divider,
Space,
Typography,
message,
Tag,
Row,
Col,
Alert,
DatePicker,
Result,
Spin,
} from 'antd';
import { SaveOutlined, SendOutlined, LoadingOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { useAuth } from '../../context/AuthContext';
import { performanceApi, PerformanceSubmitDTO } from '../../api/performance';
import { ALL_PERFORMANCE_ITEMS } from './performanceItems';
import dayjs, { Dayjs } from 'dayjs';
const { Title, Text } = Typography;
const { TextArea } = Input;
interface FormValues {
assessmentMonth: Dayjs;
workSummary: string;
leave: number;
late: number;
absent: number;
lackCard: number;
attendanceRemark?: string;
items: Array<{
userContent: string;
selfScore: number;
evidence?: string;
}>;
}
const PerformanceForm: React.FC = () => {
const { userInfo } = useAuth();
const [form] = Form.useForm<FormValues>();
const [submitting, setSubmitting] = useState(false);
const [saving, setSaving] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [aiWaiting, setAiWaiting] = useState(false);
const [aiDone, setAiDone] = useState(false);
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
// 清理轮询定时器
useEffect(() => {
return () => {
if (pollTimerRef.current) clearInterval(pollTimerRef.current);
};
}, []);
// 轮询 AI 结果
const pollAIResult = (perfId: number) => {
setAiWaiting(true);
let attempts = 0;
const maxAttempts = 40; // 最多轮询 40 次(约 2 分钟)
pollTimerRef.current = setInterval(async () => {
attempts++;
try {
const result = await performanceApi.checkAIResult(perfId);
if (result.done) {
clearInterval(pollTimerRef.current!);
setAiWaiting(false);
setAiDone(true);
} else if (attempts >= maxAttempts) {
clearInterval(pollTimerRef.current!);
setAiWaiting(false);
setAiDone(true); // 超时也跳转,让用户去历史记录查看
}
} catch {
// 忽略轮询错误,继续重试
}
}, 3000); // 每 3 秒轮询一次
};
// 获取本月作为默认值
const getPreviousMonth = (): Dayjs => {
return dayjs();
};
const buildDTO = (values: FormValues, status: 'draft' | 'submitted'): PerformanceSubmitDTO => ({
month: values.assessmentMonth.format('YYYY-MM'),
status,
workSummary: values.workSummary || '',
attendance: {
leave: values.leave ?? 0,
late: values.late ?? 0,
absent: values.absent ?? 0,
lackCard: values.lackCard ?? 0,
remark: values.attendanceRemark,
},
performanceItems: ALL_PERFORMANCE_ITEMS.map((item, idx) => ({
itemName: item.itemName,
weight: item.weight,
userContent: values.items?.[idx]?.userContent || '',
selfScore: values.items?.[idx]?.selfScore ?? 0,
evidence: values.items?.[idx]?.evidence,
})),
});
const handleSave = async () => {
const values = form.getFieldsValue();
setSaving(true);
try {
await performanceApi.submit(buildDTO(values, 'draft'));
message.success('草稿已暂存');
} catch (err: any) {
message.error(err?.response?.data?.message || '暂存失败,请重试');
} finally {
setSaving(false);
}
};
const handleSubmit = async (values: FormValues) => {
setSubmitting(true);
try {
const result = await performanceApi.submit(buildDTO(values, 'submitted'));
setSubmitted(true);
form.resetFields();
// 提交成功后开始轮询 AI 结果
if (result?.perfId) {
pollAIResult(result.perfId);
}
} catch (err: any) {
message.error(err?.response?.data?.message || '提交失败,请重试');
} finally {
setSubmitting(false);
}
};
if (submitted) {
// AI 分析完成
if (aiDone) {
return (
<Result
icon={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
title="AI 分析完成!"
subTitle="绩效已提交AI 评分已生成,可前往历史记录查看详情。"
style={{ margin: 24 }}
extra={
<Space>
<Button type="primary" onClick={() => { setSubmitted(false); setAiDone(false); }}>
</Button>
</Space>
}
/>
);
}
// AI 分析中
if (aiWaiting) {
return (
<div style={{ textAlign: 'center', padding: '80px 24px' }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 48 }} spin />} />
<div style={{ marginTop: 24 }}>
<Typography.Title level={4}>AI </Typography.Title>
<Typography.Text type="secondary">
AI 30 1 ...
</Typography.Text>
</div>
</div>
);
}
// 提交成功但还未开始轮询(过渡状态)
return (
<Alert
type="success"
message="提交成功"
description="您的绩效已成功提交AI 评分将在后台自动完成,管理层将在审核期内完成审核。"
showIcon
style={{ margin: 24 }}
action={
<Button onClick={() => setSubmitted(false)}></Button>
}
/>
);
}
return (
<div style={{ padding: isMobile ? 8 : 24, maxWidth: 900, margin: '0 auto' }}>
<Title level={isMobile ? 4 : 3}></Title>
{/* 基础信息 */}
<Card style={{ marginBottom: 16 }}>
{isMobile ? (
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<div><Text type="secondary"></Text><Text strong>{userInfo?.name}</Text></div>
<div><Text type="secondary"></Text><Text strong>{userInfo?.department}</Text></div>
<div><Text type="secondary"></Text><Text strong>{userInfo?.position}</Text></div>
</Space>
) : (
<Row gutter={24}>
<Col span={8}><Text type="secondary"></Text><Text strong>{userInfo?.name}</Text></Col>
<Col span={8}><Text type="secondary"></Text><Text strong>{userInfo?.department}</Text></Col>
<Col span={8}><Text type="secondary"></Text><Text strong>{userInfo?.position}</Text></Col>
</Row>
)}
</Card>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={{
assessmentMonth: getPreviousMonth(),
leave: 0, late: 0, absent: 0, lackCard: 0,
items: ALL_PERFORMANCE_ITEMS.map(() => ({ selfScore: 0 })),
}}
>
{/* 考核月份选择 */}
<Card style={{ marginBottom: 16 }}>
<Form.Item
name="assessmentMonth"
label="考核月份"
rules={[{ required: true, message: '请选择考核月份' }]}
>
<DatePicker
picker="month"
format="YYYY-MM"
placeholder="请选择考核月份"
style={{ width: 200 }}
disabledDate={(current) => {
// 不能选择未来的月份
return current && current > dayjs().endOf('month');
}}
/>
</Form.Item>
</Card>
{/* 考核指标 */}
<Card title="考核指标填写" style={{ marginBottom: 16 }}>
<Alert
type="info"
message="请逐项填写各考核指标的完成情况描述和自评分数0-100分"
style={{ marginBottom: 16 }}
showIcon
/>
{/* 业务素质 */}
<Title level={5}>
<Tag color="blue"> 70%</Tag>
</Title>
{ALL_PERFORMANCE_ITEMS.filter(i => i.category === 'business').map((item, idx) => (
<Card
key={item.itemName}
size="small"
style={{ marginBottom: 12, background: '#fafafa' }}
title={
<Space>
<Text strong>{item.itemName}</Text>
<Tag color="geekblue"> {item.weight} </Tag>
</Space>
}
>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{item.description}
</Text>
<Row gutter={16}>
<Col xs={24} md={16}>
<Form.Item
name={['items', idx, 'userContent']}
label="完成情况描述"
rules={[{ required: true, message: '请填写完成情况描述' }]}
>
<TextArea rows={2} placeholder="请描述本月该项指标的完成情况" />
</Form.Item>
</Col>
<Col xs={12} md={4}>
<Form.Item
name={['items', idx, 'selfScore']}
label="自评分数"
rules={[{ required: true, message: '请填写自评分' }]}
>
<InputNumber min={0} max={100} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col xs={12} md={4}>
<Form.Item name={['items', idx, 'evidence']} label="佐证材料(可选)">
<Input placeholder="链接或说明" />
</Form.Item>
</Col>
</Row>
</Card>
))}
<Divider />
{/* 综合素质 */}
<Title level={5}>
<Tag color="green"> 30%</Tag>
</Title>
{ALL_PERFORMANCE_ITEMS.filter(i => i.category === 'comprehensive').map((item) => {
const idx = ALL_PERFORMANCE_ITEMS.findIndex(x => x.itemName === item.itemName);
return (
<Card
key={item.itemName}
size="small"
style={{ marginBottom: 12, background: '#fafafa' }}
title={
<Space>
<Text strong>{item.itemName}</Text>
<Tag color="green"> {item.weight} </Tag>
</Space>
}
>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{item.description}
</Text>
<Row gutter={16}>
<Col xs={24} md={16}>
<Form.Item
name={['items', idx, 'userContent']}
label="完成情况描述"
rules={[{ required: true, message: '请填写完成情况描述' }]}
>
<TextArea rows={2} placeholder="请描述本月该项指标的完成情况" />
</Form.Item>
</Col>
<Col xs={12} md={4}>
<Form.Item
name={['items', idx, 'selfScore']}
label="自评分数"
rules={[{ required: true, message: '请填写自评分' }]}
>
<InputNumber min={0} max={100} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col xs={12} md={4}>
<Form.Item name={['items', idx, 'evidence']} label="佐证材料(可选)">
<Input placeholder="链接或说明" />
</Form.Item>
</Col>
</Row>
</Card>
);
})}
</Card>
{/* 考勤数据 */}
<Card title="考勤数据" style={{ marginBottom: 16 }}>
<Alert
type="info"
message="考勤满分 10 分,事假每天扣 5 分,迟到/缺卡每次扣 2 分,最低 0 分"
style={{ marginBottom: 16 }}
showIcon
/>
<Row gutter={16}>
<Col xs={12} sm={6}>
<Form.Item name="leave" label="事假(天)">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col xs={12} sm={6}>
<Form.Item name="late" label="迟到(次)">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col xs={12} sm={6}>
<Form.Item name="absent" label="旷工(天)">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col xs={12} sm={6}>
<Form.Item name="lackCard" label="缺卡(次)">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="attendanceRemark" label="考勤备注(可选)">
<TextArea rows={2} placeholder="如有特殊情况请说明" />
</Form.Item>
</Card>
{/* 工作汇总 */}
<Card title="工作汇总" style={{ marginBottom: 16 }}>
<Form.Item
name="workSummary"
label="工作汇总"
rules={[{ required: true, message: '请填写工作汇总' }, { min: 10, message: '工作汇总至少填写 10 个字' }]}
>
<TextArea
rows={5}
placeholder="请填写本月工作总结,包括主要工作内容、成果、遇到的问题及解决方案等"
/>
</Form.Item>
</Card>
{/* 操作按钮 */}
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', paddingBottom: 16 }}>
<Button
icon={<SaveOutlined />}
onClick={handleSave}
loading={saving}
size="large"
style={isMobile ? { flex: 1 } : {}}
>
稿
</Button>
<Button
type="primary"
icon={<SendOutlined />}
htmlType="submit"
loading={submitting}
size="large"
style={isMobile ? { flex: 1 } : {}}
>
</Button>
</div>
</Form>
</div>
);
};
export default PerformanceForm;

View File

@@ -0,0 +1,418 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Table,
Tag,
Button,
DatePicker,
Space,
Typography,
Drawer,
Descriptions,
List,
Divider,
message,
Empty,
Card,
Row,
Col,
} from 'antd';
import { EyeOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { performanceApi, PerformanceRecord } from '../../api/performance';
import { useIsMobile } from '../../hooks/useBreakpoint';
const { Title, Text } = Typography;
const STATUS_MAP: Record<string, { label: string; color: string }> = {
draft: { label: '草稿', color: 'default' },
submitted: { label: '已提交', color: 'processing' },
under_review: { label: '审核中', color: 'warning' },
completed: { label: '已完成', color: 'success' },
rejected: { label: '已驳回', color: 'error' },
};
const LEVEL_MAP: Record<string, { label: string; color: string }> = {
excellent: { label: '优秀', color: 'gold' },
qualified: { label: '合格', color: 'green' },
need_motivation: { label: '需激励', color: 'orange' },
unqualified: { label: '不合格', color: 'red' },
};
const PerformanceHistory: React.FC = () => {
const [records, setRecords] = useState<PerformanceRecord[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(10);
const [filterMonth, setFilterMonth] = useState<string | undefined>();
const [loading, setLoading] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [selected, setSelected] = useState<PerformanceRecord | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const isMobile = useIsMobile();
const loadList = useCallback(async () => {
setLoading(true);
try {
const res = await performanceApi.getMyList({ month: filterMonth, page, pageSize });
setRecords(res.list);
setTotal(res.total);
} catch {
message.error('加载绩效记录失败');
} finally {
setLoading(false);
}
}, [filterMonth, page, pageSize]);
useEffect(() => {
loadList();
}, [loadList]);
const viewDetail = async (record: PerformanceRecord) => {
setDrawerOpen(true);
setDetailLoading(true);
try {
const detail = await performanceApi.getDetail(record.perfId);
setSelected(detail);
} catch {
// fallback to list data
setSelected(record);
} finally {
setDetailLoading(false);
}
};
const columns: ColumnsType<PerformanceRecord> = [
{
title: '考核月份',
dataIndex: 'month',
key: 'month',
width: 120,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: string) => {
const s = STATUS_MAP[status] || { label: status, color: 'default' };
return <Tag color={s.color}>{s.label}</Tag>;
},
},
{
title: '自评分',
dataIndex: 'selfScore',
key: 'selfScore',
width: 90,
render: (v?: number) => v != null ? Number(v).toFixed(1) : '-',
},
{
title: 'AI 评分',
dataIndex: 'aiScore',
key: 'aiScore',
width: 90,
render: (v?: number) => v != null ? Number(v).toFixed(1) : '-',
},
{
title: '最终总分',
dataIndex: 'totalScore',
key: 'totalScore',
width: 90,
render: (v?: number, record?: PerformanceRecord) => {
if (v != null) {
return <Text strong>{Number(v).toFixed(1)}</Text>;
}
// 如果已提交但还没有最终总分,显示待审核
if (record?.status === 'submitted' || record?.status === 'under_review') {
return <Tag color="processing"></Tag>;
}
return '-';
},
},
{
title: '绩效等级',
dataIndex: 'level',
key: 'level',
width: 100,
render: (level?: string, record?: PerformanceRecord) => {
if (level) {
const l = LEVEL_MAP[level] || { label: level, color: 'default' };
return <Tag color={l.color}>{l.label}</Tag>;
}
// 如果已提交但还没有等级,显示待审核
if (record?.status === 'submitted' || record?.status === 'under_review') {
return <Tag color="processing"></Tag>;
}
return '-';
},
},
{
title: '提交时间',
dataIndex: 'submitTime',
key: 'submitTime',
render: (v?: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-',
},
{
title: '操作',
key: 'action',
width: 80,
render: (_, record) => (
<Button
type="link"
icon={<EyeOutlined />}
onClick={() => viewDetail(record)}
>
</Button>
),
},
];
return (
<div style={{ padding: isMobile ? 0 : 24 }}>
<Title level={3}></Title>
<Space style={{ marginBottom: 16 }} wrap>
<DatePicker
picker="month"
placeholder="筛选月份"
onChange={(_, dateStr) => {
setFilterMonth(dateStr as string || undefined);
setPage(1);
}}
/>
<Button onClick={loadList}></Button>
</Space>
{/* 移动端卡片列表 */}
{isMobile ? (
<div>
{loading && <div style={{ textAlign: 'center', padding: 24 }}>...</div>}
{!loading && records.length === 0 && <Empty description="暂无绩效记录" style={{ padding: 40 }} />}
{records.map(record => {
const s = STATUS_MAP[record.status] || { label: record.status, color: 'default' };
return (
<Card
key={record.perfId}
size="small"
style={{ marginBottom: 12 }}
onClick={() => viewDetail(record)}
hoverable
>
<Row justify="space-between" align="middle">
<Col><Typography.Text strong>{record.month}</Typography.Text></Col>
<Col><Tag color={s.color}>{s.label}</Tag></Col>
</Row>
<Row gutter={8} style={{ marginTop: 8 }}>
<Col span={8}><Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text><br /><Typography.Text>{record.selfScore != null ? Number(record.selfScore).toFixed(1) : '-'}</Typography.Text></Col>
<Col span={8}><Typography.Text type="secondary" style={{ fontSize: 12 }}>AI评分</Typography.Text><br /><Typography.Text>{record.aiScore != null ? Number(record.aiScore).toFixed(1) : '-'}</Typography.Text></Col>
<Col span={8}><Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text><br />
{record.totalScore != null
? <Typography.Text strong>{Number(record.totalScore).toFixed(1)}</Typography.Text>
: (record.status === 'submitted' || record.status === 'under_review')
? <Tag color="processing" style={{ fontSize: 11 }}></Tag>
: <Typography.Text>-</Typography.Text>
}
</Col>
</Row>
{record.submitTime && (
<Typography.Text type="secondary" style={{ fontSize: 11, marginTop: 4, display: 'block' }}>
{dayjs(record.submitTime).format('YYYY-MM-DD HH:mm')}
</Typography.Text>
)}
</Card>
);
})}
{total > pageSize && (
<div style={{ textAlign: 'center', padding: '8px 0' }}>
<Space>
<Button size="small" disabled={page === 1} onClick={() => setPage(p => p - 1)}></Button>
<Typography.Text type="secondary">{page} / {Math.ceil(total / pageSize)}</Typography.Text>
<Button size="small" disabled={page >= Math.ceil(total / pageSize)} onClick={() => setPage(p => p + 1)}></Button>
</Space>
</div>
)}
</div>
) : (
<Table
rowKey="perfId"
columns={columns}
dataSource={records}
loading={loading}
pagination={{
current: page,
pageSize,
total,
onChange: setPage,
showTotal: (t) => `${t}`,
}}
locale={{ emptyText: <Empty description="暂无绩效记录" /> }}
/>
)}
<Drawer
title={`绩效详情 — ${selected?.month ?? ''}`}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={isMobile ? '100%' : 720}
placement={isMobile ? 'bottom' : 'right'}
height={isMobile ? '90%' : undefined}
loading={detailLoading}
>
{selected && <DetailContent record={selected} />}
</Drawer>
</div>
);
};
const DetailContent: React.FC<{ record: PerformanceRecord }> = ({ record }) => {
const status = STATUS_MAP[record.status] || { label: record.status, color: 'default' };
const level = record.level ? LEVEL_MAP[record.level] : null;
return (
<>
<Descriptions bordered column={2} size="small">
<Descriptions.Item label="考核月份">{record.month}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={status.color}>{status.label}</Tag>
</Descriptions.Item>
<Descriptions.Item label="自评总分">{record.selfScore != null ? Number(record.selfScore).toFixed(1) : '-'}</Descriptions.Item>
<Descriptions.Item label="AI 评分">{record.aiScore != null ? Number(record.aiScore).toFixed(1) : '-'}</Descriptions.Item>
<Descriptions.Item label="管理层评分">{record.managerScore != null ? Number(record.managerScore).toFixed(1) : '-'}</Descriptions.Item>
<Descriptions.Item label="最终总分">
<Text strong>{record.totalScore != null ? Number(record.totalScore).toFixed(1) : '-'}</Text>
</Descriptions.Item>
{level && (
<Descriptions.Item label="绩效等级">
<Tag color={level.color}>{level.label}</Tag>
</Descriptions.Item>
)}
{record.rewardPunish && (
<Descriptions.Item label="奖惩说明" span={2}>
{record.rewardPunish}
</Descriptions.Item>
)}
{record.reviewOpinion && (
<Descriptions.Item label="审核意见" span={2}>
{record.reviewOpinion}
</Descriptions.Item>
)}
</Descriptions>
{/* 工作汇总 */}
{record.workSummary && (
<>
<Divider orientation="left"></Divider>
<Card size="small">
<Text>{record.workSummary}</Text>
</Card>
</>
)}
{/* 考勤信息 */}
{record.attendance && (
<>
<Divider orientation="left"></Divider>
<Row gutter={16}>
<Col span={6}><Text type="secondary"></Text><Text>{record.attendance.leave} </Text></Col>
<Col span={6}><Text type="secondary"></Text><Text>{record.attendance.late} </Text></Col>
<Col span={6}><Text type="secondary"></Text><Text>{record.attendance.absent} </Text></Col>
<Col span={6}><Text type="secondary"></Text><Text>{record.attendance.lackCard} </Text></Col>
</Row>
{record.attendance.attendanceScore != null && (
<Text type="secondary" style={{ marginTop: 8, display: 'block' }}>
<Text strong>{record.attendance.attendanceScore}</Text>
</Text>
)}
</>
)}
{/* AI 建议 */}
{record.aiResult && (
<>
<Divider orientation="left">AI </Divider>
{record.aiResult.aiProblems?.length > 0 && (
<>
<Text strong style={{ display: 'block', marginBottom: 4 }}>
</Text>
<List
size="small"
dataSource={record.aiResult.aiProblems}
renderItem={(item, i) => (
<List.Item>
<Text>{i + 1}. {item}</Text>
</List.Item>
)}
/>
</>
)}
{record.aiResult.aiSuggestions?.length > 0 && (
<>
<Text strong style={{ display: 'block', marginTop: 12, marginBottom: 4 }}>
</Text>
<List
size="small"
dataSource={record.aiResult.aiSuggestions}
renderItem={(item, i) => (
<List.Item>
<Text>{i + 1}. {item}</Text>
</List.Item>
)}
/>
</>
)}
</>
)}
{/* 考核项明细 */}
{record.performanceItems && record.performanceItems.length > 0 && (
<>
<Divider orientation="left"></Divider>
{record.performanceItems.map((item) => (
<Card
key={item.itemName}
size="small"
style={{ marginBottom: 8 }}
title={
<Space>
<Text strong>{item.itemName}</Text>
<Tag>{item.weight} </Tag>
<Tag color={item.itemCategory === 'business' ? 'blue' : 'green'}>
{item.itemCategory === 'business' ? '业务素质' : '综合素质'}
</Tag>
</Space>
}
>
<Descriptions column={3} size="small">
<Descriptions.Item label="自评分">{item.selfScore ?? '-'}</Descriptions.Item>
<Descriptions.Item label="AI 评分">{item.aiScore ?? '-'}</Descriptions.Item>
<Descriptions.Item label="管理层评分">{item.managerScore ?? '-'}</Descriptions.Item>
</Descriptions>
{item.userContent && (
<Text type="secondary" style={{ display: 'block', marginTop: 4 }}>
{item.userContent}
</Text>
)}
{item.aiExplanation && (
<Text type="secondary" style={{ display: 'block', marginTop: 4 }}>
AI {item.aiExplanation}
</Text>
)}
{item.managerExplanation && (
<Text type="secondary" style={{ display: 'block', marginTop: 4 }}>
{item.managerExplanation}
</Text>
)}
</Card>
))}
</>
)}
</>
);
};
export default PerformanceHistory;

View File

@@ -0,0 +1,33 @@
export interface ItemConfig {
itemName: string;
weight: number;
category: 'business' | 'comprehensive';
description: string;
}
// 业务素质 9 项(共 70 分)
const businessItems: ItemConfig[] = [
{ itemName: '工作目标完成情况', weight: 15, category: 'business', description: '本月工作目标的完成程度' },
{ itemName: '工作质量', weight: 10, category: 'business', description: '工作成果的质量和准确性' },
{ itemName: '工作效率', weight: 10, category: 'business', description: '完成工作任务的速度和效率' },
{ itemName: '业务能力', weight: 10, category: 'business', description: '专业技能和业务知识水平' },
{ itemName: '创新能力', weight: 5, category: 'business', description: '提出创新方案和改进建议的能力' },
{ itemName: '问题解决能力', weight: 5, category: 'business', description: '发现和解决工作中问题的能力' },
{ itemName: '项目推进能力', weight: 5, category: 'business', description: '推动项目按计划进行的能力' },
{ itemName: '客户服务', weight: 5, category: 'business', description: '对内外部客户的服务质量' },
{ itemName: '成本控制', weight: 5, category: 'business', description: '合理控制工作成本的意识和行动' },
];
// 综合素质 8 项(共 30 分,含考勤 10 分)
const comprehensiveItems: ItemConfig[] = [
{ itemName: '团队协作', weight: 5, category: 'comprehensive', description: '与团队成员协作配合的能力' },
{ itemName: '沟通能力', weight: 5, category: 'comprehensive', description: '与他人有效沟通的能力' },
{ itemName: '学习成长', weight: 5, category: 'comprehensive', description: '主动学习和自我提升的意愿与行动' },
{ itemName: '责任心', weight: 5, category: 'comprehensive', description: '对工作认真负责的态度' },
{ itemName: '执行力', weight: 5, category: 'comprehensive', description: '按时按质完成任务的能力' },
{ itemName: '职业素养', weight: 5, category: 'comprehensive', description: '职业道德和行为规范' },
{ itemName: '工作态度', weight: 5, category: 'comprehensive', description: '对工作的积极性和主动性' },
// 考勤由系统自动计算,不在此列表中手动填写
];
export const ALL_PERFORMANCE_ITEMS: ItemConfig[] = [...businessItems, ...comprehensiveItems];

View File

@@ -0,0 +1,175 @@
import React, { useEffect, useState, useCallback } from 'react';
import { Card, Row, Col, Statistic, DatePicker, Button, Space, Typography, message, Spin, Select } from 'antd';
import { TeamOutlined, TrophyOutlined, ReloadOutlined } from '@ant-design/icons';
import ReactECharts from 'echarts-for-react';
import dayjs, { Dayjs } from 'dayjs';
import http from '../../api/http';
import { useIsMobile } from '../../hooks/useBreakpoint';
const { Title, Text } = Typography;
const { Option } = Select;
interface DepartmentStat {
department: string;
averageScore: number;
totalCount: number;
levelDistribution: { excellent: number; qualified: number; need_motivation: number; unqualified: number };
}
interface PositionStat { position: string; averageScore: number; totalCount: number; }
interface CompanyStats {
month: string; totalCount: number; averageScore: number;
departmentStats: DepartmentStat[]; positionStats: PositionStat[];
levelDistribution: { excellent: number; qualified: number; need_motivation: number; unqualified: number };
}
const CompanyOverview: React.FC = () => {
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<CompanyStats | null>(null);
const [selectedMonth, setSelectedMonth] = useState<Dayjs>(dayjs());
const [selectedDimension, setSelectedDimension] = useState<'department' | 'position'>('department');
const isMobile = useIsMobile();
const loadStats = useCallback(async () => {
setLoading(true);
try {
const { data } = await http.get('/api/statistics/company', { params: { month: selectedMonth.format('YYYY-MM') } });
setStats(data.data || data);
} catch (err: any) {
message.error(err?.response?.data?.message || '加载公司统计数据失败');
} finally {
setLoading(false);
}
}, [selectedMonth]);
useEffect(() => { loadStats(); }, [loadStats]);
const emptyOption = (title: string) => ({
title: { text: title, left: 'center' },
graphic: [{ type: 'text', left: 'center', top: 'middle', style: { text: '暂无数据', fontSize: 14, fill: '#999' } }],
});
const getLevelPieOption = () => {
if (!stats) return emptyOption('绩效等级分布');
const names = { excellent: '优秀', qualified: '合格', need_motivation: '需激励', unqualified: '不合格' };
const colors = { excellent: '#faad14', qualified: '#52c41a', need_motivation: '#fa8c16', unqualified: '#f5222d' };
return {
title: { text: '绩效等级分布', left: 'center' },
tooltip: { trigger: 'item', formatter: '{b}: {c}人 ({d}%)' },
legend: { orient: isMobile ? 'horizontal' : 'vertical', left: isMobile ? 'center' : 'left', bottom: isMobile ? 0 : undefined },
series: [{
name: '绩效等级', type: 'pie', radius: isMobile ? '45%' : '50%',
center: isMobile ? ['50%', '45%'] : ['50%', '50%'],
data: Object.entries(stats.levelDistribution).map(([k, v]) => ({
name: names[k as keyof typeof names], value: v,
itemStyle: { color: colors[k as keyof typeof colors] },
})),
}],
};
};
const getBarOption = (title: string, categories: string[], values: number[], color: string) => ({
title: { text: title, left: 'center', textStyle: { fontSize: isMobile ? 12 : 14 } },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: isMobile ? '5%' : '3%', right: '4%', bottom: isMobile ? '20%' : '15%', containLabel: true },
xAxis: { type: 'category', data: categories, axisLabel: { interval: 0, rotate: isMobile ? 45 : 30, fontSize: isMobile ? 10 : 12 } },
yAxis: { type: 'value', name: '平均分', min: 0, max: 100 },
series: [{ type: 'bar', data: values, itemStyle: { color }, label: { show: !isMobile, position: 'top' } }],
});
const getDepartmentLevelStackOption = () => {
if (!stats || stats.departmentStats.length === 0) return emptyOption('各部门绩效等级分布');
const depts = stats.departmentStats.map(d => d.department);
return {
title: { text: '各部门绩效等级分布', left: 'center', textStyle: { fontSize: isMobile ? 12 : 14 } },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: ['优秀', '合格', '需激励', '不合格'], bottom: 0, textStyle: { fontSize: isMobile ? 10 : 12 } },
grid: { left: '3%', right: '4%', bottom: isMobile ? '25%' : '15%', containLabel: true },
xAxis: { type: 'category', data: depts, axisLabel: { interval: 0, rotate: isMobile ? 45 : 30, fontSize: isMobile ? 10 : 12 } },
yAxis: { type: 'value', name: '人数' },
series: [
{ name: '优秀', type: 'bar', stack: 'total', data: stats.departmentStats.map(d => d.levelDistribution.excellent), itemStyle: { color: '#faad14' } },
{ name: '合格', type: 'bar', stack: 'total', data: stats.departmentStats.map(d => d.levelDistribution.qualified), itemStyle: { color: '#52c41a' } },
{ name: '需激励', type: 'bar', stack: 'total', data: stats.departmentStats.map(d => d.levelDistribution.need_motivation), itemStyle: { color: '#fa8c16' } },
{ name: '不合格', type: 'bar', stack: 'total', data: stats.departmentStats.map(d => d.levelDistribution.unqualified), itemStyle: { color: '#f5222d' } },
],
};
};
if (loading && !stats) return <div style={{ textAlign: 'center', padding: 100 }}><Spin size="large" /></div>;
const excellentRate = (stats && stats.totalCount > 0)
? ((stats.levelDistribution.excellent / stats.totalCount) * 100).toFixed(1) : '0';
const chartH = isMobile ? 260 : 400;
return (
<div style={{ padding: isMobile ? 0 : 24 }}>
<Title level={isMobile ? 4 : 3}></Title>
<Space style={{ marginBottom: 16 }} wrap>
<DatePicker picker="month" value={selectedMonth} onChange={(d) => d && setSelectedMonth(d)} format="YYYY-MM" />
<Select value={selectedDimension} onChange={setSelectedDimension} style={{ width: isMobile ? 130 : 150 }}>
<Option value="department"></Option>
<Option value="position"></Option>
</Select>
<Button icon={<ReloadOutlined />} onClick={loadStats} loading={loading}></Button>
</Space>
{stats && (
<>
{/* 统计卡片 */}
<Row gutter={[8, 8]} style={{ marginBottom: 12 }}>
{[
{ title: '总人数', value: stats.totalCount, suffix: '人', prefix: <TeamOutlined />, color: undefined },
{ title: '平均分', value: Number(stats.averageScore).toFixed(1), suffix: '', prefix: null, color: '#3f8600' },
{ title: '优秀人数', value: stats.levelDistribution.excellent, suffix: '人', prefix: <TrophyOutlined />, color: '#faad14' },
{ title: '优秀率', value: excellentRate, suffix: '%', prefix: null, color: '#cf1322' },
].map((item, i) => (
<Col xs={12} sm={6} key={i}>
<Card bodyStyle={{ padding: isMobile ? '10px 8px' : 20 }}>
<Statistic title={item.title} value={item.value} suffix={item.suffix}
prefix={item.prefix ?? undefined} valueStyle={{ color: item.color, fontSize: isMobile ? 18 : 24 }} />
</Card>
</Col>
))}
</Row>
{/* 图表 */}
<Row gutter={[8, 8]} style={{ marginBottom: 12 }}>
<Col xs={24} sm={12}>
<Card bodyStyle={{ padding: isMobile ? 8 : 24 }}>
<ReactECharts option={getLevelPieOption()} style={{ height: chartH }} />
</Card>
</Col>
<Col xs={24} sm={12}>
<Card bodyStyle={{ padding: isMobile ? 8 : 24 }}>
<ReactECharts
option={selectedDimension === 'department'
? getBarOption('各部门绩效平均分', stats.departmentStats.map(d => d.department), stats.departmentStats.map(d => d.averageScore), '#1890ff')
: getBarOption('各岗位绩效平均分', stats.positionStats.map(p => p.position), stats.positionStats.map(p => p.averageScore), '#52c41a')
}
style={{ height: chartH }}
/>
</Card>
</Col>
</Row>
{selectedDimension === 'department' && (
<Card style={{ marginBottom: 12 }} bodyStyle={{ padding: isMobile ? 8 : 24 }}>
<ReactECharts option={getDepartmentLevelStackOption()} style={{ height: chartH }} />
</Card>
)}
<Card size="small">
<Text type="secondary" style={{ fontSize: 12 }}>
{selectedMonth.format('YYYY年MM月')}
</Text>
</Card>
</>
)}
{!stats && !loading && <Card><Text type="secondary"></Text></Card>}
</div>
);
};
export default CompanyOverview;

View File

@@ -0,0 +1,217 @@
import React, { useEffect, useState } from 'react';
import { Card, Row, Col, Button, Space, Typography, message, Spin, Form, InputNumber, Divider, DatePicker, Modal } from 'antd';
import { DownloadOutlined, SaveOutlined, ReloadOutlined, SettingOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import http from '../../api/http';
import { useIsMobile } from '../../hooks/useBreakpoint';
const { Title, Text } = Typography;
interface RuleResponse {
ruleId: number; ruleKey: string; ruleValue: any;
description: string | null; effectiveCycle: string | null;
updatedBy: number | null; updatedAt: string;
}
// 规则字段配置
const RULE_FIELDS = {
business: [
{ name: 'business_completion_weight', label: '工作完成度', max: 70 },
{ name: 'business_quality_weight', label: '工作质量', max: 70 },
{ name: 'business_efficiency_weight', label: '工作效率', max: 70 },
{ name: 'business_skill_weight', label: '专业技能', max: 70 },
{ name: 'business_innovation_weight', label: '创新能力', max: 70 },
{ name: 'business_problem_solving_weight', label: '问题解决', max: 70 },
{ name: 'business_customer_satisfaction_weight', label: '客户满意度', max: 70 },
{ name: 'business_teamwork_weight', label: '团队协作', max: 70 },
{ name: 'business_goal_achievement_weight', label: '目标达成', max: 70 },
],
comprehensive: [
{ name: 'comprehensive_responsibility_weight', label: '责任心', max: 30 },
{ name: 'comprehensive_initiative_weight', label: '主动性', max: 30 },
{ name: 'comprehensive_learning_weight', label: '学习能力', max: 30 },
{ name: 'comprehensive_communication_weight', label: '沟通能力', max: 30 },
{ name: 'comprehensive_execution_weight', label: '执行力', max: 30 },
{ name: 'comprehensive_discipline_weight', label: '纪律性', max: 30 },
{ name: 'comprehensive_team_spirit_weight', label: '团队精神', max: 30 },
{ name: 'comprehensive_attendance_weight', label: '考勤', max: 30 },
],
reward: [
{ name: 'reward_excellent', label: '优秀奖励≥90分', unit: '元' },
{ name: 'punish_qualified_high', label: '合格扣款80-89分', unit: '元' },
{ name: 'punish_qualified_low', label: '合格扣款70-79分', unit: '元' },
{ name: 'punish_need_motivation', label: '需激励扣款60-69分', unit: '元' },
{ name: 'punish_unqualified', label: '不合格扣款(<60分', unit: '元' },
],
attendance: [
{ name: 'attendance_leave_deduct', label: '事假扣分(每天)', unit: '分' },
{ name: 'attendance_late_deduct', label: '迟到扣分(每次)', unit: '分' },
{ name: 'attendance_lack_card_deduct', label: '缺卡扣分(每次)', unit: '分' },
{ name: 'attendance_base_score', label: '考勤基础分', unit: '分' },
],
};
const ConfigManagement: React.FC = () => {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [exporting, setExporting] = useState(false);
const [rules, setRules] = useState<RuleResponse[]>([]);
const [selectedMonth, setSelectedMonth] = useState<Dayjs>(dayjs());
const [form] = Form.useForm();
const isMobile = useIsMobile();
const loadRules = async () => {
setLoading(true);
try {
const { data } = await http.get('/api/config/rules');
const ruleList: RuleResponse[] = data.data || data;
setRules(ruleList);
const formValues: Record<string, any> = {};
ruleList.forEach((rule) => { formValues[rule.ruleKey] = rule.ruleValue; });
form.setFieldsValue(formValues);
} catch (err: any) {
message.error(err?.response?.data?.message || '加载配置规则失败');
} finally {
setLoading(false);
}
};
useEffect(() => { loadRules(); }, []);
const handleSave = async () => {
try {
const values = await form.validateFields();
Modal.confirm({
title: '确认修改考核规则',
icon: <ExclamationCircleOutlined />,
content: '修改后的规则将应用于后续考核周期,是否确认?',
okText: '确认', cancelText: '取消',
onOk: async () => {
setSaving(true);
try {
await http.put('/api/config/rules', {
rules: Object.entries(values).map(([ruleKey, ruleValue]) => ({ ruleKey, ruleValue })),
});
message.success('规则更新成功');
await loadRules();
} catch (err: any) {
message.error(err?.response?.data?.message || '规则更新失败');
} finally {
setSaving(false);
}
},
});
} catch { message.error('请检查表单输入'); }
};
const handleExport = async () => {
setExporting(true);
try {
const month = selectedMonth.format('YYYY-MM');
const response = await http.get('/api/performance/export', { params: { month, type: 'all' }, responseType: 'blob' });
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `全公司绩效数据_${month}.xlsx`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
message.success('导出成功');
} catch (err: any) {
message.error(err?.response?.data?.message || '导出失败');
} finally {
setExporting(false);
}
};
if (loading && rules.length === 0) return <div style={{ textAlign: 'center', padding: 100 }}><Spin size="large" /></div>;
// 移动端每行2列桌面端每行3或4列
const colSpan = isMobile ? 12 : 8;
const colSpan4 = isMobile ? 12 : 6;
return (
<div style={{ padding: isMobile ? 0 : 24 }}>
<Title level={isMobile ? 4 : 3}></Title>
{/* 数据导出 */}
<Card title={<Space><DownloadOutlined /><span></span></Space>} style={{ marginBottom: 16 }}
bodyStyle={{ padding: isMobile ? 12 : 24 }}>
<Text style={{ display: 'block', marginBottom: 8 }}>Excel </Text>
<Space wrap>
<DatePicker picker="month" value={selectedMonth} onChange={(d) => d && setSelectedMonth(d)} format="YYYY-MM" />
<Button type="primary" icon={<DownloadOutlined />} onClick={handleExport} loading={exporting}>
</Button>
</Space>
</Card>
{/* 考核规则配置 */}
<Card
title={<Space><SettingOutlined /><span></span></Space>}
bodyStyle={{ padding: isMobile ? 12 : 24 }}
extra={
<Space size={4}>
<Button icon={<ReloadOutlined />} onClick={loadRules} loading={loading} size={isMobile ? 'small' : 'middle'}></Button>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving} size={isMobile ? 'small' : 'middle'}></Button>
</Space>
}
>
<Form form={form} layout="vertical">
<Divider orientation="left" style={{ fontSize: isMobile ? 13 : 14 }}>70</Divider>
<Row gutter={[8, 0]}>
{RULE_FIELDS.business.map(f => (
<Col span={colSpan} key={f.name}>
<Form.Item label={f.label} name={f.name} rules={[{ required: true, message: '请输入' }]}>
<InputNumber min={0} max={f.max} style={{ width: '100%' }} addonAfter="分" />
</Form.Item>
</Col>
))}
</Row>
<Divider orientation="left" style={{ fontSize: isMobile ? 13 : 14 }}>30</Divider>
<Row gutter={[8, 0]}>
{RULE_FIELDS.comprehensive.map(f => (
<Col span={colSpan} key={f.name}>
<Form.Item label={f.label} name={f.name} rules={[{ required: true, message: '请输入' }]}>
<InputNumber min={0} max={f.max} style={{ width: '100%' }} addonAfter="分" />
</Form.Item>
</Col>
))}
</Row>
<Divider orientation="left" style={{ fontSize: isMobile ? 13 : 14 }}></Divider>
<Row gutter={[8, 0]}>
{RULE_FIELDS.reward.map(f => (
<Col span={colSpan} key={f.name}>
<Form.Item label={f.label} name={f.name} rules={[{ required: true, message: '请输入' }]}>
<InputNumber min={0} style={{ width: '100%' }} addonAfter={f.unit} />
</Form.Item>
</Col>
))}
</Row>
<Divider orientation="left" style={{ fontSize: isMobile ? 13 : 14 }}></Divider>
<Row gutter={[8, 0]}>
{RULE_FIELDS.attendance.map(f => (
<Col span={colSpan4} key={f.name}>
<Form.Item label={f.label} name={f.name} rules={[{ required: true, message: '请输入' }]}>
<InputNumber min={0} style={{ width: '100%' }} addonAfter={f.unit} />
</Form.Item>
</Col>
))}
</Row>
</Form>
<Card size="small" style={{ marginTop: 12, background: '#fffbe6' }}>
<Text type="warning" style={{ fontSize: 12 }}>
<ExclamationCircleOutlined />
</Text>
</Card>
</Card>
</div>
);
};
export default ConfigManagement;

View File

@@ -0,0 +1,107 @@
import React from 'react';
import { Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom';
import { Layout, Menu, Typography, Button } from 'antd';
import { HomeOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined } from '@ant-design/icons';
import CompanyOverview from './CompanyOverview';
import ConfigManagement from './ConfigManagement';
import { useIsMobile } from '../../hooks/useBreakpoint';
import { useAuth } from '../../context/AuthContext';
import logo from '../../img/logo.png';
const { Header, Content, Sider } = Layout;
const { Title } = Typography;
const menuItems = [
{ key: '/gm', icon: <HomeOutlined />, label: <Link to="/gm"></Link>, mobileLabel: '首页' },
{ key: '/gm/overview', icon: <BarChartOutlined />, label: <Link to="/gm/overview"></Link>, mobileLabel: '总览' },
{ key: '/gm/config', icon: <SettingOutlined />, label: <Link to="/gm/config"></Link>, mobileLabel: '配置' },
];
const GMDashboard: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const isMobile = useIsMobile();
const { logout } = useAuth();
const getSelectedKey = () => {
if (location.pathname.startsWith('/gm/overview')) return '/gm/overview';
if (location.pathname.startsWith('/gm/config')) return '/gm/config';
return '/gm';
};
const selectedKey = getSelectedKey();
const handleLogout = () => { logout(); navigate('/login'); };
return (
<Layout style={{ minHeight: '100vh' }}>
<Header style={{ background: '#001529', padding: '0 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<img src={logo} alt="logo" style={{ height: 32, objectFit: 'contain' }} />
{!isMobile && <Title level={4} style={{ color: 'white', margin: 0 }}> - </Title>}
</div>
<Button type="text" icon={<LogoutOutlined />} style={{ color: 'white' }} onClick={handleLogout}>
{!isMobile && '退出'}
</Button>
</Header>
<Layout style={{ paddingBottom: isMobile ? 56 : 0 }}>
{!isMobile && (
<Sider width={200} style={{ background: '#fff' }}>
<Menu
mode="inline"
selectedKeys={[selectedKey]}
style={{ height: '100%', borderRight: 0 }}
items={menuItems.map(({ key, icon, label }) => ({ key, icon, label }))}
/>
</Sider>
)}
<Layout style={{ padding: isMobile ? '12px 12px 0' : '0 24px 24px' }}>
<Content style={{ background: '#fff', padding: isMobile ? 12 : 24, minHeight: 280 }}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/overview" element={<CompanyOverview />} />
<Route path="/config" element={<ConfigManagement />} />
</Routes>
</Content>
</Layout>
</Layout>
{isMobile && (
<div style={{
position: 'fixed', bottom: 0, left: 0, right: 0, height: 56,
background: '#fff', borderTop: '1px solid #f0f0f0',
display: 'flex', zIndex: 1000,
}}>
{menuItems.map(item => (
<Link
key={item.key}
to={item.key}
style={{
flex: 1, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 2,
color: selectedKey === item.key ? '#1677ff' : '#8c8c8c',
fontSize: 10, textDecoration: 'none',
}}
>
<span style={{ fontSize: 20 }}>{item.icon}</span>
<span>{item.mobileLabel}</span>
</Link>
))}
</div>
)}
</Layout>
);
};
const HomePage: React.FC = () => (
<div>
<Title level={3}>使</Title>
<Typography.Paragraph></Typography.Paragraph>
<ul>
<li></li>
<li></li>
</ul>
</div>
);
export default GMDashboard;

View File

@@ -0,0 +1,114 @@
import React from 'react';
import { Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom';
import { Layout, Menu, Typography, Button } from 'antd';
import { HomeOutlined, TeamOutlined, BarChartOutlined, LogoutOutlined, UserAddOutlined } from '@ant-design/icons';
import SubordinateList from './SubordinateList';
import PerformanceReview from './PerformanceReview';
import TeamStatistics from './TeamStatistics';
import EmployeeManagement from './EmployeeManagement';
import logo from '../../img/logo.png';
import { useIsMobile } from '../../hooks/useBreakpoint';
import { useAuth } from '../../context/AuthContext';
const { Header, Content, Sider } = Layout;
const { Title } = Typography;
const menuItems = [
{ key: '/manager', icon: <HomeOutlined />, label: <Link to="/manager"></Link>, mobileLabel: '首页' },
{ key: '/manager/subordinates', icon: <TeamOutlined />, label: <Link to="/manager/subordinates"></Link>, mobileLabel: '下属' },
{ key: '/manager/statistics', icon: <BarChartOutlined />, label: <Link to="/manager/statistics"></Link>, mobileLabel: '统计' },
{ key: '/manager/employees', icon: <UserAddOutlined />, label: <Link to="/manager/employees"></Link>, mobileLabel: '员工' },
];
const ManagerDashboard: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const isMobile = useIsMobile();
const { logout } = useAuth();
const getSelectedKey = () => {
if (location.pathname.startsWith('/manager/review')) return '/manager/subordinates';
if (location.pathname.startsWith('/manager/statistics')) return '/manager/statistics';
if (location.pathname.startsWith('/manager/subordinates')) return '/manager/subordinates';
if (location.pathname.startsWith('/manager/employees')) return '/manager/employees';
return '/manager';
};
const selectedKey = getSelectedKey();
const handleLogout = () => { logout(); navigate('/login'); };
return (
<Layout style={{ minHeight: '100vh' }}>
<Header style={{ background: '#001529', padding: '0 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<img src={logo} alt="logo" style={{ height: 32, objectFit: 'contain' }} />
{!isMobile && <Title level={4} style={{ color: 'white', margin: 0 }}> - </Title>}
</div>
<Button type="text" icon={<LogoutOutlined />} style={{ color: 'white' }} onClick={handleLogout}>
{!isMobile && '退出'}
</Button>
</Header>
<Layout style={{ paddingBottom: isMobile ? 56 : 0 }}>
{!isMobile && (
<Sider width={200} style={{ background: '#fff' }}>
<Menu
mode="inline"
selectedKeys={[selectedKey]}
style={{ height: '100%', borderRight: 0 }}
items={menuItems.map(({ key, icon, label }) => ({ key, icon, label }))}
/>
</Sider>
)}
<Layout style={{ padding: isMobile ? '12px 12px 0' : '0 24px 24px' }}>
<Content style={{ background: '#fff', padding: isMobile ? 12 : 24, minHeight: 280 }}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/subordinates" element={<SubordinateList />} />
<Route path="/review/:perfId" element={<PerformanceReview />} />
<Route path="/statistics" element={<TeamStatistics />} />
<Route path="/employees" element={<EmployeeManagement />} />
</Routes>
</Content>
</Layout>
</Layout>
{isMobile && (
<div style={{
position: 'fixed', bottom: 0, left: 0, right: 0, height: 56,
background: '#fff', borderTop: '1px solid #f0f0f0',
display: 'flex', zIndex: 1000,
}}>
{menuItems.map(item => (
<Link
key={item.key}
to={item.key}
style={{
flex: 1, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 2,
color: selectedKey === item.key ? '#1677ff' : '#8c8c8c',
fontSize: 10, textDecoration: 'none',
}}
>
<span style={{ fontSize: 20 }}>{item.icon}</span>
<span>{item.mobileLabel}</span>
</Link>
))}
</div>
)}
</Layout>
);
};
const HomePage: React.FC = () => (
<div>
<Title level={3}>使</Title>
<Typography.Paragraph></Typography.Paragraph>
<ul>
<li></li>
<li></li>
</ul>
</div>
);
export default ManagerDashboard;

View File

@@ -0,0 +1,226 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Table, Button, Modal, Form, Input, Space, Typography,
message, Popconfirm, Tag, Card,
} from 'antd';
import { PlusOutlined, DeleteOutlined, ReloadOutlined, StopOutlined, CheckCircleOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import http from '../../api/http';
import { useIsMobile } from '../../hooks/useBreakpoint';
const { Title } = Typography;
interface Employee {
user_id: number;
username: string;
name: string;
department: string;
position: string;
status: string;
created_at: string;
}
const EmployeeManagement: React.FC = () => {
const [employees, setEmployees] = useState<Employee[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form] = Form.useForm();
const isMobile = useIsMobile();
const loadList = useCallback(async () => {
setLoading(true);
try {
const { data } = await http.get('/api/employee/list');
setEmployees(data.data || []);
} catch (err: any) {
message.error(err?.response?.data?.message || '加载员工列表失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadList(); }, [loadList]);
const handleCreate = async (values: any) => {
setSubmitting(true);
try {
await http.post('/api/employee/create', values);
message.success('员工账号创建成功');
setModalOpen(false);
form.resetFields();
loadList();
} catch (err: any) {
message.error(err?.response?.data?.message || '创建失败');
} finally {
setSubmitting(false);
}
};
const handleDelete = async (userId: number) => {
try {
await http.delete(`/api/employee/${userId}`);
message.success('员工账号已删除');
loadList();
} catch (err: any) {
message.error(err?.response?.data?.message || '删除失败');
}
};
const handleToggleStatus = async (userId: number, currentStatus: string) => {
const newStatus = currentStatus === 'active' ? 'inactive' : 'active';
try {
await http.patch(`/api/employee/${userId}/status`, { status: newStatus });
message.success(newStatus === 'inactive' ? '员工账号已禁用' : '员工账号已启用');
loadList();
} catch (err: any) {
message.error(err?.response?.data?.message || '操作失败');
}
};
const columns: ColumnsType<Employee> = [
{ title: '用户名', dataIndex: 'username', key: 'username', width: 110 },
{ title: '姓名', dataIndex: 'name', key: 'name', width: 90 },
{ title: '部门', dataIndex: 'department', key: 'department', width: 100 },
{ title: '岗位', dataIndex: 'position', key: 'position', width: 120 },
{
title: '状态', dataIndex: 'status', key: 'status', width: 80,
render: (s: string) => <Tag color={s === 'active' ? 'green' : 'default'}>{s === 'active' ? '在职' : '停用'}</Tag>,
},
{
title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 150,
render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-',
},
{
title: '操作', key: 'action', width: 140, fixed: 'right',
render: (_, record) => (
<Space size={4}>
<Popconfirm
title={record.status === 'active' ? '确认禁用该员工账号?' : '确认启用该员工账号?'}
description={record.status === 'active' ? '禁用后该员工将无法登录系统。' : '启用后该员工可正常登录。'}
onConfirm={() => handleToggleStatus(record.user_id, record.status)}
okText="确认"
cancelText="取消"
okButtonProps={{ danger: record.status === 'active' }}
>
<Button
type="link"
size="small"
icon={record.status === 'active' ? <StopOutlined /> : <CheckCircleOutlined />}
danger={record.status === 'active'}
style={{ color: record.status !== 'active' ? '#52c41a' : undefined }}
>
{record.status === 'active' ? '禁用' : '启用'}
</Button>
</Popconfirm>
<Popconfirm
title="确认删除该员工账号?"
description="删除后无法恢复,如有绩效记录将无法删除。"
onConfirm={() => handleDelete(record.user_id)}
okText="确认删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button type="link" danger icon={<DeleteOutlined />} size="small"></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div style={{ padding: isMobile ? 0 : 24 }}>
<Title level={3}></Title>
<Space style={{ marginBottom: 16 }} wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
</Button>
<Button icon={<ReloadOutlined />} onClick={loadList} loading={loading}></Button>
</Space>
{isMobile ? (
<div>
{employees.map(emp => (
<Card key={emp.user_id} size="small" style={{ marginBottom: 10 }}>
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Space>
<Typography.Text strong>{emp.name}</Typography.Text>
<Typography.Text type="secondary">@{emp.username}</Typography.Text>
<Tag color={emp.status === 'active' ? 'green' : 'default'}>{emp.status === 'active' ? '在职' : '停用'}</Tag>
</Space>
<Typography.Text type="secondary">{emp.department} · {emp.position}</Typography.Text>
<Space size={8}>
<Popconfirm
title={emp.status === 'active' ? '确认禁用?' : '确认启用?'}
onConfirm={() => handleToggleStatus(emp.user_id, emp.status)}
okText="确认" cancelText="取消" okButtonProps={{ danger: emp.status === 'active' }}
>
<Button type="link" size="small"
icon={emp.status === 'active' ? <StopOutlined /> : <CheckCircleOutlined />}
danger={emp.status === 'active'}
style={{ padding: 0, color: emp.status !== 'active' ? '#52c41a' : undefined }}
>
{emp.status === 'active' ? '禁用' : '启用'}
</Button>
</Popconfirm>
<Popconfirm
title="确认删除该员工账号?"
description="删除后无法恢复,如有绩效记录将无法删除。"
onConfirm={() => handleDelete(emp.user_id)}
okText="确认删除" cancelText="取消" okButtonProps={{ danger: true }}
>
<Button type="link" danger icon={<DeleteOutlined />} size="small" style={{ padding: 0 }}></Button>
</Popconfirm>
</Space>
</Space>
</Card>
))}
</div>
) : (
<Table
rowKey="user_id"
columns={columns}
dataSource={employees}
loading={loading}
scroll={{ x: 700 }}
pagination={{ pageSize: 20, showTotal: t => `${t}` }}
/>
)}
<Modal
title="新建员工账号"
open={modalOpen}
onCancel={() => { setModalOpen(false); form.resetFields(); }}
onOk={() => form.submit()}
confirmLoading={submitting}
okText="创建"
cancelText="取消"
>
<Form form={form} layout="vertical" onFinish={handleCreate} style={{ marginTop: 16 }}>
<Form.Item name="username" label="用户名(工号)" rules={[{ required: true, message: '请输入用户名' }]}>
<Input placeholder="如emp007" />
</Form.Item>
<Form.Item
name="password" label="初始密码"
rules={[{ required: true, message: '请输入密码' }, { min: 6, message: '密码至少6位' }]}
>
<Input.Password placeholder="至少6位" />
</Form.Item>
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="员工真实姓名" />
</Form.Item>
<Form.Item name="department" label="部门" rules={[{ required: true, message: '请输入部门' }]}>
<Input placeholder="如:技术部" />
</Form.Item>
<Form.Item name="position" label="岗位" rules={[{ required: true, message: '请输入岗位' }]}>
<Input placeholder="如:前端工程师" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default EmployeeManagement;

View File

@@ -0,0 +1,452 @@
import React, { useEffect, useState } from 'react';
import {
Card,
Descriptions,
Tag,
Button,
Space,
Typography,
message,
Spin,
Row,
Col,
Divider,
Form,
InputNumber,
Input,
Modal,
Alert,
List,
} from 'antd';
import {
CheckOutlined,
CloseOutlined,
ArrowLeftOutlined,
} from '@ant-design/icons';
import { useParams, useNavigate } from 'react-router-dom';
import { PerformanceRecord } from '../../api/performance';
import http from '../../api/http';
import { useIsMobile } from '../../hooks/useBreakpoint';
import dayjs from 'dayjs';
const { Title, Text } = Typography;
const { TextArea } = Input;
const STATUS_MAP: Record<string, { label: string; color: string }> = {
draft: { label: '草稿', color: 'default' },
submitted: { label: '已提交', color: 'processing' },
under_review: { label: '审核中', color: 'warning' },
completed: { label: '已完成', color: 'success' },
rejected: { label: '已驳回', color: 'error' },
};
const LEVEL_MAP: Record<string, { label: string; color: string }> = {
excellent: { label: '优秀', color: 'gold' },
qualified: { label: '合格', color: 'green' },
need_motivation: { label: '需激励', color: 'orange' },
unqualified: { label: '不合格', color: 'red' },
};
interface ItemScoreForm {
managerScore: number;
scoreExplanation: string;
}
interface ReviewFormData {
reviewOpinion: string;
itemScores: Record<string, ItemScoreForm>;
}
const PerformanceReview: React.FC = () => {
const { perfId } = useParams<{ perfId: string }>();
const navigate = useNavigate();
const isMobile = useIsMobile();
const [form] = Form.useForm<ReviewFormData>();
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [record, setRecord] = useState<PerformanceRecord | null>(null);
const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
useEffect(() => {
loadDetail();
}, [perfId]);
const loadDetail = async () => {
if (!perfId) return;
setLoading(true);
try {
const { data } = await http.get(
`/api/performance/manager/detail/${perfId}`
);
const record = data.data || data;
setRecord(record);
// Initialize form with existing manager scores if available
const itemScores: Record<string, ItemScoreForm> = {};
data.performanceItems?.forEach((item) => {
itemScores[item.itemName] = {
managerScore: item.managerScore ?? item.aiScore ?? item.selfScore ?? 0,
scoreExplanation: item.managerExplanation ?? '',
};
});
form.setFieldsValue({
reviewOpinion: data.reviewOpinion ?? '',
itemScores,
});
} catch (err: any) {
message.error(err?.response?.data?.message || '加载绩效详情失败');
navigate('/manager/subordinates');
} finally {
setLoading(false);
}
};
const handleApprove = async () => {
try {
const values = await form.validateFields();
setSubmitting(true);
const itemScores = Object.entries(values.itemScores).map(
([itemName, score]) => ({
itemName,
managerScore: score.managerScore,
managerExplanation: score.scoreExplanation || '',
})
);
await http.post('/api/performance/manager/review', {
perfId: Number(perfId),
reviewOpinion: values.reviewOpinion,
itemScores,
});
message.success('审核通过,绩效已归档');
navigate('/manager/subordinates');
} catch (err: any) {
if (err?.errorFields) {
message.error('请完成所有必填项');
} else {
message.error(err?.response?.data?.message || '审核失败,请重试');
}
} finally {
setSubmitting(false);
}
};
const handleReject = async () => {
if (!rejectReason.trim()) {
message.error('请填写驳回原因');
return;
}
setSubmitting(true);
try {
await http.post('/api/performance/manager/reject', {
perfId: Number(perfId),
reason: rejectReason,
});
message.success('绩效已驳回,员工将收到通知');
navigate('/manager/subordinates');
} catch (err: any) {
message.error(err?.response?.data?.message || '驳回失败,请重试');
} finally {
setSubmitting(false);
setRejectModalOpen(false);
}
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 100 }}>
<Spin size="large" />
</div>
);
}
if (!record) {
return null;
}
const status = STATUS_MAP[record.status] || {
label: record.status,
color: 'default',
};
const level = record.level ? LEVEL_MAP[record.level] : null;
const isCompleted = record.status === 'completed';
return (
<div style={{ padding: isMobile ? 12 : 24, maxWidth: 1200, margin: '0 auto' }}>
<Space style={{ marginBottom: 12 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/manager/subordinates')} size={isMobile ? 'small' : 'middle'}>
</Button>
</Space>
<Title level={isMobile ? 4 : 3}>
{record.month}
{isCompleted && <Tag color="success" style={{ marginLeft: 8 }}></Tag>}
</Title>
{/* 基础信息 */}
<Card style={{ marginBottom: 12 }} bodyStyle={{ padding: isMobile ? 12 : 24 }}>
{isMobile ? (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px 16px' }}>
{[
{ label: '员工姓名', value: (record as any).userName || '-' },
{ label: '部门', value: (record as any).userDepartment || '-' },
{ label: '岗位', value: (record as any).userPosition || '-' },
{ label: '考核月份', value: record.month },
{ label: '状态', value: <Tag color={status.color}>{status.label}</Tag> },
{ label: '提交时间', value: record.submitTime ? dayjs(record.submitTime).format('YYYY-MM-DD HH:mm') : '-' },
{ label: '自评总分', value: record.selfScore != null ? Number(record.selfScore).toFixed(1) : '-' },
{ label: 'AI 评分', value: record.aiScore != null ? Number(record.aiScore).toFixed(1) : '-' },
{ label: '最终总分', value: <Text strong>{record.totalScore != null ? Number(record.totalScore).toFixed(1) : '-'}</Text> },
].map(({ label, value }) => (
<div key={label}>
<Text type="secondary" style={{ fontSize: 12 }}>{label}</Text>
<div>{value}</div>
</div>
))}
{level && (
<div>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<div><Tag color={level.color}>{level.label}</Tag></div>
</div>
)}
{isCompleted && record.reviewOpinion && (
<div style={{ gridColumn: '1 / -1' }}>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<div><Text>{record.reviewOpinion}</Text></div>
</div>
)}
</div>
) : (
<Descriptions bordered column={3} size="small">
<Descriptions.Item label="员工姓名">{(record as any).userName || '-'}</Descriptions.Item>
<Descriptions.Item label="部门">{(record as any).userDepartment || '-'}</Descriptions.Item>
<Descriptions.Item label="岗位">{(record as any).userPosition || '-'}</Descriptions.Item>
<Descriptions.Item label="考核月份">{record.month}</Descriptions.Item>
<Descriptions.Item label="状态"><Tag color={status.color}>{status.label}</Tag></Descriptions.Item>
<Descriptions.Item label="提交时间">{record.submitTime ? dayjs(record.submitTime).format('YYYY-MM-DD HH:mm') : '-'}</Descriptions.Item>
<Descriptions.Item label="自评总分">{record.selfScore != null ? Number(record.selfScore).toFixed(1) : '-'}</Descriptions.Item>
<Descriptions.Item label="AI 评分">{record.aiScore != null ? Number(record.aiScore).toFixed(1) : '-'}</Descriptions.Item>
<Descriptions.Item label="最终总分"><Text strong>{record.totalScore != null ? Number(record.totalScore).toFixed(1) : '-'}</Text></Descriptions.Item>
{level && <Descriptions.Item label="绩效等级"><Tag color={level.color}>{level.label}</Tag></Descriptions.Item>}
{record.rewardPunish && <Descriptions.Item label="奖惩说明" span={2}>{record.rewardPunish}</Descriptions.Item>}
{isCompleted && record.reviewOpinion && (
<Descriptions.Item label="审核意见" span={3}>{record.reviewOpinion}</Descriptions.Item>
)}
</Descriptions>
)}
</Card>
{/* 工作汇总 */}
{record.workSummary && (
<Card title="工作汇总" style={{ marginBottom: 16 }}>
<Text>{record.workSummary}</Text>
</Card>
)}
{/* 考勤信息 */}
{record.attendance && (
<Card title="考勤数据" style={{ marginBottom: 12 }} bodyStyle={{ padding: isMobile ? 12 : 24 }}>
<Row gutter={[8, 8]}>
<Col xs={12} sm={6}><Text type="secondary"></Text><Text>{record.attendance.leave} </Text></Col>
<Col xs={12} sm={6}><Text type="secondary"></Text><Text>{record.attendance.late} </Text></Col>
<Col xs={12} sm={6}><Text type="secondary"></Text><Text>{record.attendance.absent} </Text></Col>
<Col xs={12} sm={6}><Text type="secondary"></Text><Text>{record.attendance.lackCard} </Text></Col>
</Row>
{record.attendance.attendanceScore != null && (
<Text type="secondary" style={{ marginTop: 8, display: 'block' }}>
<Text strong>{record.attendance.attendanceScore}</Text>
</Text>
)}
</Card>
)}
{/* AI 评分反馈 */}
{record.aiResult && (
<Card title="AI 评分反馈" style={{ marginBottom: 16 }}>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="AI 总分">
<Text strong>{record.aiResult.aiTotalScore != null ? Number(record.aiResult.aiTotalScore).toFixed(1) : '-'}</Text>
</Descriptions.Item>
</Descriptions>
{record.aiResult.aiProblems?.length > 0 && (
<>
<Divider orientation="left">AI </Divider>
<List
size="small"
dataSource={record.aiResult.aiProblems}
renderItem={(item, i) => (
<List.Item>
<Text>
{i + 1}. {item}
</Text>
</List.Item>
)}
/>
</>
)}
{record.aiResult.aiSuggestions?.length > 0 && (
<>
<Divider orientation="left">AI </Divider>
<List
size="small"
dataSource={record.aiResult.aiSuggestions}
renderItem={(item, i) => (
<List.Item>
<Text>
{i + 1}. {item}
</Text>
</List.Item>
)}
/>
</>
)}
</Card>
)}
{/* 考核项评分 */}
<Card title="考核项评分与审核" style={{ marginBottom: 16 }}>
{!isCompleted && (
<Alert
type="info"
message="请为每个考核项填写管理层评分,系统将自动计算最终总分和绩效等级"
style={{ marginBottom: 16 }}
showIcon
/>
)}
<Form form={form} layout="vertical" disabled={isCompleted}>
{record.performanceItems?.map((item) => (
<Card
key={item.itemName}
size="small"
style={{ marginBottom: 12, background: '#fafafa' }}
title={
<Space>
<Text strong>{item.itemName}</Text>
<Tag>{item.weight} </Tag>
<Tag
color={
item.itemCategory === 'business' ? 'blue' : 'green'
}
>
{item.itemCategory === 'business'
? '业务素质'
: '综合素质'}
</Tag>
</Space>
}
>
{isMobile ? (
// 移动端简化展示
<div>
<Text type="secondary" style={{ fontSize: 12 }}>{item.userContent || '-'}</Text>
<Row gutter={8} style={{ marginTop: 8 }}>
<Col span={8}><Text type="secondary" style={{ fontSize: 11 }}></Text><br /><Text>{item.selfScore ?? '-'}</Text></Col>
<Col span={8}><Text type="secondary" style={{ fontSize: 11 }}>AI评分</Text><br /><Text>{item.aiScore ?? '-'}</Text></Col>
<Col span={8}><Text type="secondary" style={{ fontSize: 11 }}></Text><br /><Text>{item.managerScore ?? '-'}</Text></Col>
</Row>
{item.aiExplanation && <Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 4 }}>AI说明{item.aiExplanation}</Text>}
</div>
) : (
<Descriptions column={3} size="small" bordered>
<Descriptions.Item label="员工填报内容" span={3}>{item.userContent || '-'}</Descriptions.Item>
<Descriptions.Item label="自评分">{item.selfScore ?? '-'}</Descriptions.Item>
<Descriptions.Item label="AI 评分">{item.aiScore ?? '-'}</Descriptions.Item>
<Descriptions.Item label="管理层评分">{item.managerScore ?? '-'}</Descriptions.Item>
{item.aiExplanation && <Descriptions.Item label="AI 评分说明" span={3}>{item.aiExplanation}</Descriptions.Item>}
</Descriptions>
)}
{!isCompleted && (
<div style={{ marginTop: 12 }}>
<Form.Item
name={['itemScores', item.itemName, 'managerScore']}
label="管理层评分"
rules={[
{ required: true, message: '请填写评分' },
{ type: 'number', min: 0, max: 100, message: '评分范围 0-100' },
]}
>
<InputNumber min={0} max={100} style={{ width: 120 }} placeholder="0-100" />
</Form.Item>
</div>
)}
{isCompleted && item.managerExplanation && (
<div style={{ marginTop: 12 }}>
<Text type="secondary"></Text>
<Text>{item.managerExplanation}</Text>
</div>
)}
</Card>
))}
{/* 审核意见 */}
<Divider />
<Form.Item
name="reviewOpinion"
label="审核意见"
rules={[{ required: !isCompleted, message: '请填写审核意见' }]}
>
<TextArea
rows={4}
placeholder="请填写对员工本月绩效的整体评价和改进建议"
/>
</Form.Item>
</Form>
</Card>
{/* 操作按钮 */}
{!isCompleted && (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', paddingBottom: 24 }}>
<Button
type="primary"
size="large"
icon={<CheckOutlined />}
onClick={handleApprove}
loading={submitting}
style={isMobile ? { flex: 1 } : {}}
>
</Button>
<Button
danger
size="large"
icon={<CloseOutlined />}
onClick={() => setRejectModalOpen(true)}
loading={submitting}
style={isMobile ? { flex: 1 } : {}}
>
</Button>
</div>
)}
{/* 驳回弹窗 */}
<Modal
title="驳回绩效"
open={rejectModalOpen}
onOk={handleReject}
onCancel={() => setRejectModalOpen(false)}
confirmLoading={submitting}
>
<TextArea
rows={4}
placeholder="请填写驳回原因,员工将收到此通知"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
/>
</Modal>
</div>
);
};
export default PerformanceReview;

View File

@@ -0,0 +1,272 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Table,
Tag,
Button,
DatePicker,
Input,
Select,
Space,
Typography,
message,
Empty,
Card,
} from 'antd';
import { EyeOutlined, ReloadOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import { PerformanceRecord } from '../../api/performance';
import http from '../../api/http';
const { Title } = Typography;
const STATUS_MAP: Record<string, { label: string; color: string }> = {
draft: { label: '草稿', color: 'default' },
submitted: { label: '已提交', color: 'processing' },
under_review: { label: '审核中', color: 'warning' },
completed: { label: '已完成', color: 'success' },
rejected: { label: '已驳回', color: 'error' },
};
const LEVEL_MAP: Record<string, { label: string; color: string }> = {
excellent: { label: '优秀', color: 'gold' },
qualified: { label: '合格', color: 'green' },
need_motivation: { label: '需激励', color: 'orange' },
unqualified: { label: '不合格', color: 'red' },
};
interface SubordinateRecord extends PerformanceRecord {
userName?: string;
userDepartment?: string;
userPosition?: string;
}
interface PageResult<T> {
list: T[];
total: number;
page: number;
pageSize: number;
}
const SubordinateList: React.FC = () => {
const navigate = useNavigate();
const [records, setRecords] = useState<SubordinateRecord[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(10);
const [loading, setLoading] = useState(false);
// Filters
const [filterMonth, setFilterMonth] = useState<string | undefined>();
const [filterDepartment, setFilterDepartment] = useState<string | undefined>();
const [filterName, setFilterName] = useState<string | undefined>();
const [filterStatus, setFilterStatus] = useState<string | undefined>();
const loadList = useCallback(async () => {
setLoading(true);
try {
const params: any = { page, pageSize };
if (filterMonth) params.month = filterMonth;
if (filterDepartment) params.department = filterDepartment;
if (filterName) params.name = filterName;
if (filterStatus) params.status = filterStatus;
const { data } = await http.get(
'/api/performance/manager/list',
{ params }
);
// 后端返回格式: { code, message, data: { total, records } }
const result = data.data || data;
const list = result.records || result.list || [];
setRecords(list.map((rec: any) => ({
...rec,
userName: rec.employeeName || rec.userName,
userDepartment: rec.department || rec.userDepartment,
userPosition: rec.position || rec.userPosition,
})));
setTotal(result.total || 0);
} catch (err: any) {
message.error(err?.response?.data?.message || '加载下属绩效列表失败');
} finally {
setLoading(false);
}
}, [page, pageSize, filterMonth, filterDepartment, filterName, filterStatus]);
useEffect(() => {
loadList();
}, [loadList]);
const handleReview = (record: SubordinateRecord) => {
navigate(`/manager/review/${record.perfId}`);
};
const columns: ColumnsType<SubordinateRecord> = [
{
title: '员工姓名',
dataIndex: 'userName',
key: 'userName',
width: 100,
},
{
title: '部门',
dataIndex: 'userDepartment',
key: 'userDepartment',
width: 120,
},
{
title: '岗位',
dataIndex: 'userPosition',
key: 'userPosition',
width: 120,
},
{
title: '考核月份',
dataIndex: 'month',
key: 'month',
width: 100,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: string) => {
const s = STATUS_MAP[status] || { label: status, color: 'default' };
return <Tag color={s.color}>{s.label}</Tag>;
},
},
{
title: '自评分',
dataIndex: 'selfScore',
key: 'selfScore',
width: 80,
render: (v?: number) => (v != null ? v.toFixed(1) : '-'),
},
{
title: 'AI 评分',
dataIndex: 'aiScore',
key: 'aiScore',
width: 80,
render: (v?: number) => (v != null ? v.toFixed(1) : '-'),
},
{
title: '最终总分',
dataIndex: 'totalScore',
key: 'totalScore',
width: 90,
render: (v?: number) => (v != null ? <strong>{v.toFixed(1)}</strong> : '-'),
},
{
title: '绩效等级',
dataIndex: 'level',
key: 'level',
width: 100,
render: (level?: string) => {
if (!level) return '-';
const l = LEVEL_MAP[level] || { label: level, color: 'default' };
return <Tag color={l.color}>{l.label}</Tag>;
},
},
{
title: '提交时间',
dataIndex: 'submitTime',
key: 'submitTime',
width: 150,
render: (v?: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-'),
},
{
title: '操作',
key: 'action',
width: 100,
fixed: 'right',
render: (_, record) => (
<Button
type="link"
icon={<EyeOutlined />}
onClick={() => handleReview(record)}
disabled={record.status === 'draft'}
>
{record.status === 'completed' ? '查看' : '审核'}
</Button>
),
},
];
return (
<div style={{ padding: 24 }}>
<Title level={3}></Title>
<Card style={{ marginBottom: 16 }}>
<Space wrap>
<DatePicker
picker="month"
placeholder="筛选月份"
onChange={(_, dateStr) => {
setFilterMonth((dateStr as string) || undefined);
setPage(1);
}}
style={{ width: 150 }}
/>
<Input
placeholder="部门"
value={filterDepartment}
onChange={(e) => {
setFilterDepartment(e.target.value || undefined);
setPage(1);
}}
style={{ width: 150 }}
allowClear
/>
<Input
placeholder="员工姓名"
value={filterName}
onChange={(e) => {
setFilterName(e.target.value || undefined);
setPage(1);
}}
style={{ width: 150 }}
allowClear
/>
<Select
placeholder="状态"
value={filterStatus}
onChange={(value) => {
setFilterStatus(value || undefined);
setPage(1);
}}
style={{ width: 150 }}
allowClear
>
<Select.Option value="submitted"></Select.Option>
<Select.Option value="under_review"></Select.Option>
<Select.Option value="completed"></Select.Option>
<Select.Option value="rejected"></Select.Option>
</Select>
<Button icon={<ReloadOutlined />} onClick={loadList}>
</Button>
</Space>
</Card>
<Table
rowKey="perfId"
columns={columns}
dataSource={records}
loading={loading}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
total,
onChange: setPage,
showTotal: (t) => `${t}`,
showSizeChanger: false,
}}
locale={{ emptyText: <Empty description="暂无下属绩效记录" /> }}
/>
</div>
);
};
export default SubordinateList;

View File

@@ -0,0 +1,160 @@
import React, { useEffect, useState, useCallback } from 'react';
import { Card, Row, Col, DatePicker, Button, Space, Typography, message, Spin, Progress } from 'antd';
import { TeamOutlined, TrophyOutlined, CheckCircleOutlined, WarningOutlined, DownloadOutlined, ReloadOutlined, CloseCircleOutlined, BarChartOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import http from '../../api/http';
import { useIsMobile } from '../../hooks/useBreakpoint';
const { Title, Text } = Typography;
interface TeamStats {
averageScore: number;
excellentCount: number;
qualifiedCount: number;
needMotivationCount: number;
unqualifiedCount: number;
totalCount: number;
}
const TeamStatistics: React.FC = () => {
const [loading, setLoading] = useState(false);
const [exporting, setExporting] = useState(false);
const [stats, setStats] = useState<TeamStats | null>(null);
const [selectedMonth, setSelectedMonth] = useState<Dayjs>(dayjs());
const isMobile = useIsMobile();
const loadStats = useCallback(async () => {
setLoading(true);
try {
const { data } = await http.get('/api/statistics/team', {
params: { month: selectedMonth.format('YYYY-MM') },
});
// 后端返回 { code, message, data: stats },需要解包
const raw = data.data || data;
setStats({
averageScore: Number(raw.averageScore) || 0,
excellentCount: Number(raw.excellentCount) || 0,
qualifiedCount: Number(raw.qualifiedCount) || 0,
needMotivationCount: Number(raw.needMotivationCount) || 0,
unqualifiedCount: Number(raw.unqualifiedCount) || 0,
totalCount: Number(raw.totalCount) || 0,
});
} catch (err: any) {
message.error(err?.response?.data?.message || '加载团队统计数据失败');
} finally {
setLoading(false);
}
}, [selectedMonth]);
useEffect(() => { loadStats(); }, [loadStats]);
const handleExport = async () => {
setExporting(true);
try {
const month = selectedMonth.format('YYYY-MM');
const response = await http.get('/api/performance/export', {
params: { month, type: 'team' }, responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `团队绩效_${month}.xlsx`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
message.success('导出成功');
} catch (err: any) {
message.error(err?.response?.data?.message || '导出失败');
} finally {
setExporting(false);
}
};
const pct = (count: number) =>
safeStats.totalCount > 0 ? Math.round((count / safeStats.totalCount) * 100) : 0;
if (loading && !stats) {
return <div style={{ textAlign: 'center', padding: 100 }}><Spin size="large" /></div>;
}
const safeStats: TeamStats = stats ?? {
averageScore: 0, excellentCount: 0, qualifiedCount: 0,
needMotivationCount: 0, unqualifiedCount: 0, totalCount: 0,
};
const levels = [
{ label: '优秀', desc: '≥ 90 分', count: safeStats.excellentCount, percent: pct(safeStats.excellentCount), color: '#faad14', bg: '#fffbe6', icon: <TrophyOutlined style={{ color: '#faad14', fontSize: 20 }} /> },
{ label: '合格', desc: '60-89 分', count: safeStats.qualifiedCount, percent: pct(safeStats.qualifiedCount), color: '#52c41a', bg: '#f6ffed', icon: <CheckCircleOutlined style={{ color: '#52c41a', fontSize: 20 }} /> },
{ label: '需激励', desc: '60-69 分', count: safeStats.needMotivationCount, percent: pct(safeStats.needMotivationCount), color: '#fa8c16', bg: '#fff7e6', icon: <WarningOutlined style={{ color: '#fa8c16', fontSize: 20 }} /> },
{ label: '不合格', desc: '< 60 分', count: safeStats.unqualifiedCount, percent: pct(safeStats.unqualifiedCount), color: '#f5222d', bg: '#fff1f0', icon: <CloseCircleOutlined style={{ color: '#f5222d', fontSize: 20 }} /> },
];
return (
<div style={{ padding: isMobile ? 0 : 24 }}>
<Title level={isMobile ? 4 : 3}></Title>
<Space style={{ marginBottom: 16 }} wrap>
<DatePicker picker="month" value={selectedMonth} onChange={(d) => d && setSelectedMonth(d)} format="YYYY-MM" />
<Button icon={<ReloadOutlined />} onClick={loadStats} loading={loading}></Button>
<Button type="primary" icon={<DownloadOutlined />} onClick={handleExport} loading={exporting}> Excel</Button>
</Space>
{/* 总体统计 */}
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
{[
{ icon: <TeamOutlined style={{ fontSize: 18, color: '#1677ff' }} />, value: safeStats.totalCount, unit: '人', label: '总人数', color: undefined },
{ icon: <BarChartOutlined style={{ fontSize: 18, color: '#3f8600' }} />, value: safeStats.averageScore.toFixed(1), unit: '分', label: '平均分', color: '#3f8600' },
{ icon: <TrophyOutlined style={{ fontSize: 18, color: '#cf1322' }} />, value: `${pct(safeStats.excellentCount)}%`, unit: '', label: '优秀率', color: '#cf1322' },
].map((item, i) => (
<Col xs={8} key={i}>
<Card bodyStyle={{ padding: isMobile ? '10px 8px' : 20, textAlign: 'center' }}>
{item.icon && <div>{item.icon}</div>}
<div style={{ fontSize: isMobile ? 20 : 26, fontWeight: 700, color: item.color, lineHeight: 1.3 }}>
{item.value}{item.unit && <span style={{ fontSize: 12, fontWeight: 400 }}>{item.unit}</span>}
</div>
<Text type="secondary" style={{ fontSize: 11 }}>{item.label}</Text>
</Card>
</Col>
))}
</Row>
{/* 等级分布 */}
<Card title="绩效等级分布" bodyStyle={{ padding: isMobile ? 12 : 24 }}>
<Row gutter={[12, 12]}>
{levels.map(item => (
<Col xs={12} key={item.label}>
<Card size="small" style={{ background: item.bg, borderRadius: 8 }} bodyStyle={{ padding: 12 }}>
<Space align="center" style={{ marginBottom: 8 }}>
{item.icon}
<div>
<Text strong style={{ fontSize: isMobile ? 13 : 14 }}>{item.label}</Text>
<br />
<Text type="secondary" style={{ fontSize: 11 }}>{item.desc}</Text>
</div>
</Space>
<Row align="middle" justify="space-between" wrap={false}>
<Col>
<span style={{ fontSize: isMobile ? 20 : 24, fontWeight: 700 }}>{item.count}</span>
<Text type="secondary" style={{ fontSize: 12 }}> </Text>
</Col>
<Col>
<Progress type="circle" percent={item.percent} width={isMobile ? 44 : 56} strokeColor={item.color} />
</Col>
</Row>
</Card>
</Col>
))}
</Row>
</Card>
<Card size="small" style={{ marginTop: 12 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{selectedMonth.format('YYYY年MM月')}
</Text>
</Card>
</div>
);
};
export default TeamStatistics;

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
// Lazy-loaded placeholders — real pages will be added in tasks 13-15
const EmployeeDashboard = React.lazy(() => import('../pages/employee/Dashboard'));
const ManagerDashboard = React.lazy(() => import('../pages/manager/Dashboard'));
const GMDashboard = React.lazy(() => import('../pages/gm/Dashboard'));
const LoginPage = React.lazy(() => import('../pages/Login'));
interface ProtectedRouteProps {
children: React.ReactNode;
allowedRole: 'employee' | 'manager' | 'generalManager';
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children, allowedRole }) => {
const { token, userInfo } = useAuth();
if (!token) {
return <Navigate to="/login" replace />;
}
if (userInfo?.role !== allowedRole) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};
const AppRouter: React.FC = () => {
const { userInfo, token } = useAuth();
// Redirect root to role-specific home or login
const getRootRedirect = () => {
if (!token || !userInfo) return '/login';
if (userInfo.role === 'employee') return '/employee';
if (userInfo.role === 'manager') return '/manager';
if (userInfo.role === 'generalManager') return '/gm';
return '/login';
};
return (
<React.Suspense fallback={<div>...</div>}>
<Routes>
<Route path="/login" element={<LoginPage />} />
{/* 员工路由 */}
<Route
path="/employee/*"
element={
<ProtectedRoute allowedRole="employee">
<EmployeeDashboard />
</ProtectedRoute>
}
/>
{/* 管理层路由 */}
<Route
path="/manager/*"
element={
<ProtectedRoute allowedRole="manager">
<ManagerDashboard />
</ProtectedRoute>
}
/>
{/* 总经理路由 */}
<Route
path="/gm/*"
element={
<ProtectedRoute allowedRole="generalManager">
<GMDashboard />
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to={getRootRedirect()} replace />} />
<Route path="*" element={<Navigate to={getRootRedirect()} replace />} />
</Routes>
</React.Suspense>
);
};
export default AppRouter;

9
frontend/src/vite-env.d.ts vendored Normal file
View File

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

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

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

15
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
});

292
部署文档.md Normal file
View 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/
```