feat: idCardHash匹配、Roster分页过滤、社保基数调整修复、Modal size支持、Termination版本对比、UI优化

This commit is contained in:
freedakgmail
2026-07-23 23:26:10 +08:00
parent 32a57873cf
commit 559a567b9b
38 changed files with 4068 additions and 205 deletions
+111
View File
@@ -0,0 +1,111 @@
# 劳动用工合规助手 — 待实现功能清单
> **文档编号**: 20260723-优化-2.md
> **日期**: 2026-07-23
> **来源**: 对照 `0-req.md` 需求规格说明书完整扫描后得出
---
## 一、部分实现(需完善)
### 1. AI 流式输出
- **现状**: `ai.service.ts` 使用同步 `chat.completions.create`,一次性返回完整回复
- **需求**: DashScope SSE 流式返回,前端打字机效果
- **涉及文件**: `backend/src/services/ai.service.ts``backend/src/routes/ai.routes.ts``frontend/src/pages/AIAssistant.tsx`
- **方案**: 后端改用 `stream: true` + SSE 响应;前端用 `EventSource``fetch + ReadableStream` 逐字渲染
### 2. RAG 知识库
- **现状**: 未实现向量数据库集成
- **需求**: 劳动法/劳动合同法/司法解释/地方条例向量化存储,Supabase pgvector + DashScope text-embedding-v2
- **涉及文件**: 新建 `backend/src/services/rag.service.ts`、schema 新增向量表
- **方案**: 文档分块 → DashScope embedding → 存入 pgvector → 问答时向量检索 → 注入 context
### 3. AI 使用限制
- **现状**: 未实现套餐次数限制
- **需求**: free 10次问答/3次审查/3次案例;pro 100/20/20enterprise 无限
- **涉及文件**: `backend/src/routes/ai.routes.ts``backend/src/services/ai.service.ts`
- **方案**: 每次调用前查询当月已用次数(按 orgId + 类型),超限返回 403
### 4. 顶部导航风险角标
- **现状**: `TopNav.tsx:44` 有角标代码但 `hidden` 固定不显示
- **需求**: 红色角标显示待处理风险总数,点击跳转首页
- **涉及文件**: `frontend/src/components/layout/TopNav.tsx`
- **方案**: 查询 pending 风险数量,动态显示角标数字
### 5. 审计日志写入
- **现状**: `AuditLog` 模型存在于 schema,但无实际写入代码
- **需求**: 关键操作(解聘/合同变更/工资调整)记录审计日志
- **涉及文件**: `backend/src/services/contract.service.ts``termination.service.ts``roster.routes.ts`
- **方案**: 在关键操作后 `prisma.auditLog.create({ orgId, userId, action, target, detail, ipAddress })`
### 6. 数据导出
- **现状**: 仅社保月度有 CSV 导出
- **需求**: 支持导出全部数据为 JSON/Excel
- **涉及文件**: 新建 `backend/src/routes/export.routes.ts`、前端设置页增加导出按钮
- **方案**: 后端打包全量数据为 Excel(exceljs),前端下载
### 7. 二维码生成
- **现状**: 入职填报/合同确认有 token 链接,但无前端二维码图片
- **需求**: HR 端生成二维码图片,可保存通过微信发给员工
- **涉及文件**: `frontend/src/pages/Contracts.tsx``Roster.tsx`
- **方案**: 前端引入 `qrcode.react`,生成二维码图片,支持下载
### 8. 批量续签
- **现状**: 需确认花名册列表是否有全选→批量续签功能
- **需求**: 合同列表支持全选 → 批量续签
- **涉及文件**: `frontend/src/pages/Roster.tsx`
- **方案**: 列表增加 checkbox 全选,批量调用续签 API
---
## 二、未实现(需新建)
### 9. 忘记密码 — 手机验证码重置
- **现状**: `/forgot-password` 路由存在,但功能不完整
- **需求**: 手机号 + 验证码 → 设置新密码
- **涉及文件**: `frontend/src/pages/auth/ForgotPassword.tsx``backend/src/routes/auth.routes.ts`
- **方案**: 复用 portal 的验证码逻辑,验证后允许重置密码
### 10. AI 顾问语音输入(移动端)
- **现状**: 未实现
- **需求**: 移动端支持语音输入问题
- **涉及文件**: `frontend/src/pages/AIAssistant.tsx`
- **方案**: 使用 Web Speech API `SpeechRecognition`,语音转文字后发送
### 11. 解聘记录 PDF 导出
- **现状**: 未实现
- **需求**: 支持导出单条解聘记录为 PDF
- **涉及文件**: `frontend/src/pages/Termination.tsx`
- **方案**: 前端使用 `jspdf` + `html2canvas` 生成 PDF,或后端用 `puppeteer` 生成
### 12. 登录接口速率限制
- **现状**: 未实现
- **需求**: 登录接口限流 5次/分钟,防止暴力破解;密码错误5次锁定30分钟
- **涉及文件**: `backend/src/routes/auth.routes.ts``backend/src/routes/portal.routes.ts`
- **方案**: 使用 `express-rate-limit` 中间件,或基于 Map 的简易限流
### 13. 套餐人数上限校验
- **现状**: 未实现
- **需求**: free 限20人,pro 限200人,enterprise 无限制;添加员工时校验
- **涉及文件**: `backend/src/services/contract.service.ts`createEmployee
- **方案**: 创建员工前查询当前员工数 + 套餐上限,超限返回 403
---
## 三、优先级排序
| 优先级 | 编号 | 功能 | 工作量 |
|--------|------|------|--------|
| P0 | 4 | 顶部导航风险角标 | 小 |
| P0 | 12 | 登录接口速率限制 | 小 |
| P0 | 13 | 套餐人数上限校验 | 小 |
| P1 | 5 | 审计日志写入 | 中 |
| P1 | 1 | AI 流式输出 | 中 |
| P1 | 9 | 忘记密码重置 | 中 |
| P1 | 7 | 二维码生成 | 小 |
| P2 | 3 | AI 使用限制 | 中 |
| P2 | 8 | 批量续签 | 中 |
| P2 | 6 | 数据导出 | 中 |
| P3 | 2 | RAG 知识库 | 大 |
| P3 | 11 | 解聘记录 PDF 导出 | 中 |
| P3 | 10 | AI 语音输入 | 小 |
+210
View File
@@ -0,0 +1,210 @@
# 劳动用工合规 SaaS — 功能层面优化清单
> **文档编号**: 20260723-优化-3.md
> **日期**: 2026-07-23
> **来源**: 对 Money.tsx、Termination.tsx、SocialInsurance.tsx、Roster.tsx 四个核心业务页面深入研究后得出
---
## 一、高优先级(核心业务缺陷)
### 1. Money — 发薪批次创建后无法重命名
**现状**: 批次列表只显示自动生成的名称(如"2026-01 第1批 发薪"),创建后名称固定不可修改。当企业有多个批次(按部门/按职级分批发薪)时,列表难以区分。
**建议**:
- 后端:PUT `/payroll2/batches/:id` 支持更新 `name` 字段
- 前端:在 `BatchDetail` 右上角增加「重命名」按钮,弹出编辑框修改批次名称
**涉及文件**: `backend/src/routes/payroll2.routes.ts``frontend/src/pages/Money.tsx`
---
### 2. Termination — 费用计算与表单完全割裂
**现状**: `costResult` 是纯前端 `useMemo` 计算,但编辑表单字段(解聘日期、解聘原因)时不会实时触发重算。用户必须切到 Step 4 才能看到费用变化,导致操作反馈链路过长。
**建议**:
-`costResult` 的依赖项(`terminationDate``socialAvgWage``reason`)用 `useEffect` 驱动,每次表单变更实时展示费用预览
- 在 Step 1(选择员工)和 Step 2(解聘方式)之间增加一个「实时费用预览区」,显示经济补偿金、赔偿金、代通知金的大致金额,降低误操作风险
**涉及文件**: `frontend/src/pages/Termination.tsx`
---
### 3. Termination — 模拟计算结果被静默覆盖
**现状**: `handleSimulate` 只将数据存入本地 state `savedItems``costResult` 依赖的是表单实时值。当用户修改参数后,之前的模拟结果会被静默覆盖,无法对比不同参数下的补偿金额。
**建议**:
- `savedItems` 每条记录增加 `version` 字段和 `isSimulated: boolean` 标记
- 每次模拟生成新版本而非覆盖,用户可在右侧列表查看多个版本的对比
- 模拟结果与实际保存结果分开展示,避免混淆
**涉及文件**: `frontend/src/pages/Termination.tsx`
---
### 4. Roster — 批量续签无合规预检
**现状**: 批量续签直接提交 `contractIds`,无任何预览或合规检查。用户可能对已连续签订两次固定期限合同的员工续签固定期(法律上应签无固定期限)。
**建议**:
- 选择员工后,先调用后端接口 `GET /employees/contracts/preview-renew` 返回每个员工的合规提示
- 展示预览列表:每个员工一行,显示「可续签固定期」或「应签无固定期限(已连续签订X次)」等提示
- 用户确认后再提交,避免法律风险
**涉及文件**: `frontend/src/pages/Roster.tsx``backend/src/routes/employee.routes.ts`
---
### 5. SocialInsurance — 社保基数调整只能一次性操作
**现状**: `adjustmentDone` 标志为 true 后无法再次调整基数。但实践中基数可能需要多次修正(员工投诉、基数算错、重新申报)。
**建议**:
- 增加「重置调整」接口 `POST /social/config/:id/reset-adjustment`,允许管理员撤销本次调整重新来过
- 或改为记录每次调整的版本历史,支持查看历史调整记录
**涉及文件**: `backend/src/routes/social.routes.ts``frontend/src/pages/SocialInsurance.tsx`
---
## 二、中优先级(高频操作体验)
### 6. Roster — 员工搜索无分页、无法多选过滤
**现状**: 花名册仅支持姓名/部门 substring 搜索,无分页和高级过滤。添加人员到批次时取 `pageSize: 100`,超过 100 人就覆盖不全。
**建议**:
- 花名册搜索增加状态过滤(在职/预入职/离职)、合同状态过滤(正常/即将到期/已过期/未签合同)、合同到期时间范围过滤
- 添加人员到批次改为服务端搜索,支持分页 + 关键词搜索 + 多选,超 100 人场景也能覆盖
**涉及文件**: `frontend/src/pages/Roster.tsx``backend/src/routes/roster.routes.ts``backend/src/routes/payroll2.routes.ts`
---
### 7. Money — 批次列表无月份范围筛选
**现状**: 只有单月筛选,企业要查看历史所有批次只能逐月切换,且无状态(草稿/归档)过滤。
**建议**:
- 批次列表增加月份范围选择器(开始月份 ~ 结束月份)
- 增加状态过滤(全部/草稿/已归档)
- 增加批次类型过滤(全部/常规发薪/离职结算/年终奖/补偿金)
**涉及文件**: `frontend/src/pages/Money.tsx``backend/src/routes/payroll2.routes.ts`
---
### 8. Termination — 无批量解聘能力
**现状**: 只能逐个处理。当企业裁员时(如一次性解除 20 人),需重复操作 20 次,体验极差。
**建议**:
- 在「解聘补偿」页面增加「批量解聘」入口
- 选择员工后批量填写共性参数(解聘日期、解聘原因、社保截止月份),差异项(补偿金金额)可逐个补充或批量默认
- 批量提交后统一生成解聘记录和调薪批次
**涉及文件**: `frontend/src/pages/Termination.tsx``backend/src/services/termination.service.ts`
---
### 9. Roster — 合同到期预警机制缺失
**现状**: 花名册表头显示合同状态标签(`expiring``expired`),但系统无主动预警。用户需主动逐个查看。
**建议**:
- Dashboard 增加合同到期预警卡片,显示 30 天内到期、60 天内到期、90 天内到期的员工数量
- 点击卡片跳转花名册,预设筛选条件为「合同到期时间 ≤ N 天」
- Roster 列表页增加「合同到期时间」列,支持按到期时间排序
**涉及文件**: `frontend/src/pages/Dashboard.tsx``frontend/src/pages/Roster.tsx``backend/src/routes/roster.routes.ts`
---
### 10. Money — 无工资条税率试算预览
**现状**: `PayslipManager` 只能从批次汇总生成工资条,无法单独查看某员工的个税明细和实发金额分解。
**建议**:
- 在批次详情页或员工 profile 的 payslip tab 中,增加「税率试算」功能
- 展示个税计算过程:应发金额 → 社保公积金扣除 → 个税起征点扣除 → 应纳税所得额 → 税率/速算扣除数 → 个税 → 实发金额
- 支持单员工试算,不依赖批次
**涉及文件**: `frontend/src/pages/Money.tsx``frontend/src/pages/Roster.tsx``backend/src/services/payroll.service.ts`
---
## 三、低优先级(功能补全)
### 11. SocialInsurance — 仅支持北京配置,无多城市扩展
**现状**: `newVersion` 硬编码北京配置,版本历史中城市字段存在但无人使用。
**建议**:
- 后续扩展多城市时,社保配置表增加 `cityCode` 字段
- 版本历史按城市分组展示
- 城市列表可配置(新增城市配置时自动出现在下拉)
**涉及文件**: `backend/prisma/schema.prisma``frontend/src/pages/SocialInsurance.tsx`
---
### 12. Termination — 离职与解聘入口分离不清晰
**现状**: `ResignModal`(员工主动离职)和解聘向导(公司主导)是两套流程,但在同一个「解聘补偿」模块中容易让用户困惑。
**建议**:
- 在 Step 1 员工选择后,优先展示「员工主动离职」vs「公司解聘」两个入口
- 选择「主动离职」则弹出简化版离职表单(仅需离职日期和原因)
- 选择「公司解聘」则进入完整解聘向导
**涉及文件**: `frontend/src/pages/Termination.tsx``frontend/src/pages/Roster.tsx`
---
### 13. Roster — 员工附件上传无预览
**现状**: 合同扫描件以 DataURL 形式存储,无文件大小校验,无 PDF/Word 在线预览。
**建议**:
- 附件上传增加文件类型限制(仅 PDF/图片)和大小限制(最大 10MB)
- 员工 profile 附件 tab 增加文件预览功能(图片直接显示,PDF 用 iframe 或第三方预览组件)
- 上传前显示文件大小提示
**涉及文件**: `frontend/src/pages/Roster.tsx`
---
### 14. Money — 加班费 CSV 导入无批量编辑
**现状**: CSV 导入后只能整体确认,无法逐条修改导入数据中的工时数值。
**建议**:
- 导入预览阶段支持逐行编辑工时数据(工作日/休息日/节假日小时数)
- 增加「校验」按钮,对齐员工姓名未匹配的记录高亮提示
- 支持从预览中删除不需要的记录
**涉及文件**: `frontend/src/pages/Money.tsx`OvertimeCalculator 组件)
---
## 四、优先级总览
| 优先级 | 编号 | 功能 | 工作量 |
|--------|------|------|--------|
| P0 | 1 | 发薪批次重命名 | 小 |
| P0 | 2 | 费用计算实时预览 | 中 |
| P0 | 4 | 批量续签合规预检 | 中 |
| P0 | 5 | 社保基数调整可重复操作 | 小 |
| P1 | 3 | 模拟计算版本管理 | 小 |
| P1 | 6 | 员工搜索分页+多选过滤 | 中 |
| P1 | 7 | 批次列表范围筛选 | 小 |
| P1 | 8 | 批量解聘 | 大 |
| P1 | 9 | 合同到期预警 | 中 |
| P1 | 10 | 工资条税率试算 | 中 |
| P2 | 12 | 离职/解聘入口分离 | 小 |
| P2 | 13 | 附件上传预览 | 中 |
| P2 | 14 | 加班费导入批量编辑 | 中 |
| P3 | 11 | 多城市社保配置 | 大 |
+216
View File
@@ -0,0 +1,216 @@
# 劳动用工合规 SaaS — 功能层面优化清单(续)
> **文档编号**: 20260723-优化-4.md
> **日期**: 2026-07-23
> **来源**: 对 Contracts.tsx、Compensation.tsx、Dashboard.tsx、AIAssistant.tsx 及相关后端服务深入研究后得出
---
## 一、高优先级(核心业务缺陷)
### 1. Contracts — 员工详情抽屉无编辑能力
**现状**: `EmployeeDetailDrawer` 只展示员工基本信息、合同历史和附件,无法修改任何字段。员工特殊状态(孕期/医疗期/工伤)只能在「添加员工」时设置,后续无法更新,导致系统记录与实际脱节。
**建议**:
- 在员工详情抽屉增加「编辑」按钮,打开编辑表单
- 特殊状态字段改为可编辑,并记录变更时间
- 支持修改联系方式、部门等基本信息
**涉及文件**: `frontend/src/pages/Contracts.tsx``backend/src/routes/employee.routes.ts`
---
### 2. Compensation — 计算器与 Termination.tsx 重复实现
**现状**: 经济补偿金计算逻辑在 `Compensation.tsx``SeveranceCalculator``Termination.tsx``costResult` 中各实现一遍,且参数略有差异(前者有社平工资封顶,后者没有三倍封顶判断)。维护两套逻辑存在一致性问题。
**建议**:
- 将经济补偿金计算逻辑抽取为共享的计算模块(`shared/compensation.ts`
- 前端统一调用共享模块,后端 `termination.service.ts``calculateCompensation` 也引用同一逻辑
- 或改为调用后端 `/compensation/calculate` 接口,前端只负责展示
**涉及文件**: `frontend/src/pages/Compensation.tsx``frontend/src/pages/Termination.tsx``backend/src/services/termination.service.ts`
---
### 3. AIAssistant — 会话历史完全丢失
**现状**: `ChatTab` 的消息状态只在组件内维护,刷新页面或切换 Tab 后所有对话记录丢失。用户无法回顾之前的 AI 问答,也没法基于历史对话继续追问。
**建议**:
- 后端增加会话历史存储表 `AIConversation`,记录 userId、messages 数组、createdAt
- 前端加载时从 `/ai/conversations` 获取历史会话列表
- 每次新对话自动保存,切换会话可恢复历史上下文
- 增加「新建对话」和「历史会话」下拉列表
**涉及文件**: `frontend/src/pages/AIAssistant.tsx``backend/src/routes/ai.routes.ts``backend/prisma/schema.prisma`
---
### 4. Dashboard — 待办事项无批量操作
**现状**: 待办列表只能逐个「标记完成」或「忽略」。当 HR 需要批量处理同类风险项(如忽略所有合同即将过期的提醒)时,需重复点击 N 次,体验极差。
**建议**:
- 增加「全选」复选框和批量操作栏(批量标记完成 / 批量忽略)
- 增加「按类型批量处理」入口:点击「合同风险」标签,弹出确认框「忽略所有 {N} 项合同风险?」
- 批量操作调用 `PATCH /dashboard/todos/batch-resolve``PATCH /dashboard/todos/batch-ignore`
**涉及文件**: `frontend/src/pages/Dashboard.tsx``backend/src/routes/dashboard.routes.ts`
---
## 二、中优先级(高频操作体验)
### 5. Contracts — 无合同续签入口
**现状**: 员工详情抽屉只展示合同历史记录,没有「续签合同」按钮。当合同即将到期时,用户需到 Roster 页面操作续签,路径不连贯。
**建议**:
-`EmployeeDetailDrawer` 的合同信息区域增加「续签合同」按钮
- 点击后弹出续签表单(合同类型、期限、试用期),与 Roster 页面的续签逻辑复用
- 续签成功后刷新合同历史列表
**涉及文件**: `frontend/src/pages/Contracts.tsx``backend/src/routes/employee.routes.ts`
---
### 6. AIAssistant — 分析结果无法关联员工
**现状**: 合同审查、案例匹配的结果是独立展示的文本,无法直接关联到具体员工 profile。当用户想保存 AI 的合同审查结论时,只能复制粘贴,无法在员工详情页查看历史审查记录。
**建议**:
- 增加 `AIContractReview` 表,记录 employeeId、reviewContent、reviewedAt、reviewerId
- 合同审查完成后,弹出「是否保存到员工档案」选项
-`EmployeeDetailDrawer` 增加「AI 审查记录」tab,展示该员工的历史审查结果
- 案例匹配结果同理,保存到 `AICaseMatch`
**涉及文件**: `frontend/src/pages/AIAssistant.tsx``backend/prisma/schema.prisma``backend/src/routes/ai.routes.ts`
---
### 7. Compensation — 无历史计算记录
**现状**: 计算器每次输入都是新计算,无法查看之前的计算历史。用户想对比同一员工在不同离职日期下的补偿金额变化,只能手动记录或重新输入。
**建议**:
- 后端增加 `CompensationCalculation` 表,记录 employeeId、parameters、result、calculatedAt
- 前端计算完成后自动保存,点击「历史记录」可查看该员工的所有试算结果
- 历史记录支持按日期排序和参数对比视图
**涉及文件**: `frontend/src/pages/Compensation.tsx``backend/prisma/schema.prisma`
---
### 8. Dashboard — 风险分布数据粒度太粗
**现状**: `riskDistribution` 只返回三个维度的数量(contract/salary/termination),用户无法直接看到是哪些员工/哪些合同触发了风险。当 HR 想处理高风险项时,需要跳转到花名册逐个排查。
**建议**:
- `GET /dashboard` 返回值增加 `topRisks` 字段,包含最近 5 条高风险项的摘要(员工名、风险类型、描述)
- 风险分布卡片改为可点击,点击后展开风险列表并支持快捷操作(查看详情 / 标记已处理)
- 增加「高风险员工」快捷入口,跳转到花名册并预设高风险筛选条件
**涉及文件**: `frontend/src/pages/Dashboard.tsx``backend/src/services/risk.service.ts`
---
### 9. AIAssistant — 风险预测无触发条件
**现状**: `PredictTab` 页面加载时自动调用 `/ai/predict`,没有用户输入接口。预测结果是一段文本,用户无法针对性地查看某个员工或某类风险。
**建议**:
- 将风险预测改为用户可选范围的上下文查询:选择「全部员工 / 某部门 / 某员工」,选择「风险类型 / 合同 / 薪酬 / 解聘」
- 预测结果结构化展示:列出每个风险项、风险等级、建议操作
- 支持将预测结果直接转化为待办事项
**涉及文件**: `frontend/src/pages/AIAssistant.tsx``backend/src/routes/ai.routes.ts``backend/src/services/ai.service.ts`
---
## 三、低优先级(功能补全)
### 10. Contracts — 附件上传无类型校验
**现状**: `EmployeeDetailDrawer` 的文件上传没有文件类型和大小限制,用户可以上传任意格式和大小的文件。合同扫描件以 DataURL 存储,过大的文件会影响数据库性能。
**建议**:
- 上传前增加文件类型过滤(仅允许 PDF、JPG、PNG、HEIC),并在界面上显示支持的格式
- 增加大小限制提示(最大 10MB),上传前校验文件大小,超限给出友好提示
- 建议后续改用文件存储服务(如 S3/OSS),避免大文件塞满数据库
**涉及文件**: `frontend/src/pages/Contracts.tsx`
---
### 11. Dashboard — 刷新按钮语义不准确
**现状**: `refresh` 按钮固定显示在顶部,但只有 `overview` tab 下有意义,其他 tabpayroll/risk/task)点击它也会触发 `refetch()`,但用户不清楚刷新的是什么数据。
**建议**:
- 将刷新按钮改为 Tab 级联:只在 `overview``payroll` tab 下显示刷新按钮(这两个 tab 依赖 `dashboard` 查询)
- 或在点击刷新时显示 toast 提示「已刷新 {tab名称} 数据」
- 或者将刷新按钮移到具体数据区域内部,而非全局顶部
**涉及文件**: `frontend/src/pages/Dashboard.tsx`
---
### 12. Compensation — 双倍工资计算逻辑不完整
**现状**: `DoubleSalaryCalculator` 假设入职 1 年内必须签合同,只考虑了「入职第 2 个月起」的双倍工资。实际场景更复杂:续签劳动合同时首份合同到期后未及时续签、合同到期后继续用工但未签新合同等情况也会产生双倍工资。
**建议**:
- 增加「合同到期后续签」场景的支持,输入首份合同到期日期,判断是否应签未签
- 增加「实际用工但未签合同」的日期范围输入
- 将双倍工资计算逻辑同步到后端,支持更复杂的法律判断
**涉及文件**: `frontend/src/pages/Compensation.tsx`
---
### 13. AIAssistant — 合同审查无版本对比
**现状**: 用户粘贴合同文本后审查,审查结果是一段文本。如果同一合同经过修改后再次审查,无法对比两次审查结果的差异。
**建议**:
- 增加「历史审查」列表,展示该合同的所有审查版本及时间
- 选择两个历史版本后,展示新增问题、已解决问题、变化点
- 支持审查结论的结构化存储(问题类型、条款位置、严重程度)
**涉及文件**: `frontend/src/pages/AIAssistant.tsx``backend/prisma/schema.prisma`
---
### 14. Dashboard — 薪税 tab 缺少导出功能
**现状**: 薪税 tab 展示本月工资汇总数据,但没有「导出」功能。企业财务需要这些数据进行账务处理时,只能截图或手动记录。
**建议**:
- 在薪税 tab 右上角增加「导出」按钮
- 支持导出 Excel 格式,包含工资构成明细、扣减项、企业成本等所有展示字段
- 可选导出范围:仅汇总 / 含明细 / 含历史对比
**涉及文件**: `frontend/src/pages/Dashboard.tsx``backend/src/routes/export.routes.ts`
---
## 四、优先级总览
| 优先级 | 编号 | 功能 | 工作量 |
|--------|------|------|--------|
| P0 | 1 | 员工详情可编辑 | 中 |
| P0 | 2 | 计算逻辑统一(避免重复实现) | 小 |
| P0 | 3 | AI 会话历史保存 | 中 |
| P0 | 4 | 待办批量操作 | 小 |
| P1 | 5 | 合同续签入口(详情页) | 小 |
| P1 | 6 | AI 结果关联员工档案 | 中 |
| P1 | 7 | 计算历史记录 | 中 |
| P1 | 8 | 风险分布可下钻 | 中 |
| P1 | 9 | 风险预测上下文查询 | 中 |
| P2 | 10 | 附件上传类型校验 | 小 |
| P2 | 11 | 刷新按钮 Tab 级联 | 小 |
| P2 | 12 | 双倍工资计算补全 | 中 |
| P2 | 13 | 合同审查版本对比 | 中 |
| P2 | 14 | 薪税数据导出 | 中 |
+219
View File
@@ -0,0 +1,219 @@
# 劳动用工合规 SaaS — 功能层面优化清单(续二)
> **文档编号**: 20260723-优化-5.md
> **日期**: 2026-07-23
> **来源**: 对 Settings.tsx、export.routes.ts、import.routes.ts 及相关 Portal 页面深入研究后得出
---
## 一、高优先级(核心业务缺陷)
### 1. Settings — 企业信息表单无初始化数据回填
**现状**: `OrgSettings` 组件的 `form` state 用 `useState` 初始化,但初始化值依赖 `orgData?.data?.name`,而 `useState` 的初始值只在组件首次挂载时读取一次。当 `orgData` 异步加载完成后,state 不会自动更新,导致表单始终为空。
**建议**:
- 使用 `useEffect` 监听 `orgData` 变化,异步回填表单数据
- 或将 `form` 改为受控组件:`value={orgData?.data?.name || ''}`
**涉及文件**: `frontend/src/pages/Settings.tsx`
---
### 2. Settings — 用户管理无编辑和禁用能力
**现状**: `UserSettings` 只展示用户列表和添加用户功能,没有编辑已有用户、禁用用户、修改角色的能力。当员工离职时,管理员无法停用其账号,存在安全风险。
**建议**:
- 用户列表增加「编辑」「禁用」操作按钮
- 编辑 Modal 支持修改用户姓名、手机号、角色
- 禁用后用户无法登录,但保留历史操作记录
- 增加「最近登录」列,显示用户活跃状态
**涉及文件**: `frontend/src/pages/Settings.tsx``backend/src/routes/settings.routes.ts`
---
### 3. Import — 导入预览缺失,无法逐条确认
**现状**: Excel 导入直接上传后端解析,用户无法在提交前预览数据。错误只能在导入完成后看到,且只能看到前 10 条。用户可能上传了错误的 Excel 模板,导致大量数据导入失败后才知晓。
**建议**:
- 改为两阶段导入:上传文件 → 后端解析但不写入 → 前端展示预览列表 → 用户确认后才写入
- 预览阶段支持逐行修改(如修正姓名、部门、工资等)
- 增加「模板校验」接口,上传前先检查 Sheet 结构是否符合预期,不符合给出明确提示
- 预览界面区分「正常数据」「警告数据」「错误数据」,用户可选择只导入正常数据
**涉及文件**: `frontend/src/pages/Settings.tsx``backend/src/routes/import.routes.ts`
---
### 4. Export — 导出格式单一,无选择性导出
**现状**: `export/all` 导出全部数据的 JSON 文件,既没有 Excel 格式选择,也没有按模块选择性导出(只导出员工、只导出社保等)。对于企业财务或法务,只需要部分数据时,导出一个大 JSON 不够实用。
**建议**:
- 增加导出格式选择(JSON / Excel
- 增加按模块选择性导出(员工信息、合同信息、薪税记录、社保记录、离职记录)
- Excel 格式应包含表头和格式化,便于直接查看
- 增加导出时间范围过滤(本月/本季度/本年/自定义)
**涉及文件**: `frontend/src/pages/Settings.tsx``backend/src/routes/export.routes.ts`
---
## 二、中优先级(高频操作体验)
### 5. Import — 身份证号哈希校验缺失
**现状**: `import.routes.ts` 中多处使用 `sha256(idCard)` 匹配员工,但身份证号可能存在格式错误(如 15 位、假号、校验位错误)。脏数据进入数据库后无法关联,且没有前置校验。
**建议**:
- 增加身份证号格式校验函数(18 位正则 + 校验位算法)
- 校验不通过的行在预览阶段标红并给出提示,不写入数据库
- 15 位身份证号自动升级为 18 位(基于出生日期补全)
- 导入完成后给出数据质量报告(格式错误数、重名数等)
**涉及文件**: `backend/src/routes/import.routes.ts`
---
### 6. Settings — 通知设置无测试功能
**现状**: 用户配置了企业微信 Webhook 或邮件通知后,没有「发送测试消息」按钮验证配置是否正确。通知发不出去时用户无法定位问题。
**建议**:
- Webhook 配置行增加「测试」按钮,点击后发送测试消息到配置的地址
- 测试结果(成功/失败/错误信息)实时显示在界面上
- 邮件通知增加同样的测试功能
- 配置页面增加连接状态指示器(已连接/未配置/配置错误)
**涉及文件**: `frontend/src/pages/Settings.tsx``backend/src/routes/notification.routes.ts`
---
### 7. Import — 月度导入覆盖逻辑不清晰
**现状**: 月度导入中「考勤记录」用 `upsert` 覆盖同日记录,「加班记录」用 `increment` 累加。这些行为没有在界面上说明,用户可能误以为所有数据都是覆盖,导致数据异常。
**建议**:
- 导入界面的 Sheet 说明中明确标注每种记录的处理策略(覆盖 / 累加 / 跳过)
- 月度导入前增加「本次导入模式」选择:覆盖 / 累加 / 仅新增
- 导入完成后显示各类型记录的处理方式摘要
**涉及文件**: `frontend/src/pages/Settings.tsx``backend/src/routes/import.routes.ts`
---
### 8. Settings — 套餐升级无实际功能
**现状**: `PlanSettings` 展示三个套餐,但「升级」按钮只有 UI 没有实际逻辑。免费版和专业版的功能差异(如 AI 问答次数限制、合同审查)也未在系统中实际执行。
**建议**:
- 实现套餐切换逻辑(可对接 Stripe/微信支付等)
- 在系统各模块中实际执行用量限制(如 AI 问答次数扣减)
- 免费版用户在试用受限功能时提示升级
- 增加用量统计面板,显示本月已用 AI 次数 / 已用存储空间等
**涉及文件**: `frontend/src/pages/Settings.tsx``backend/src/routes/settings.routes.ts``backend/src/middleware/rateLimit.ts`
---
### 9. Import — 错误日志无导出
**现状**: 导入完成后如果有很多错误,只能看到前 10 条提示。用户需要截取或手动记录错误信息来修正 Excel 后重新导入。
**建议**:
- 导入完成后增加「导出错误日志」按钮,生成 CSV/Excel 文件,列出所有错误行及原因
- 错误日志包含:行号、员工姓名/身份证、错误类型、具体原因
- 错误日志文件名包含导入时间戳,便于管理
**涉及文件**: `frontend/src/pages/Settings.tsx``backend/src/routes/import.routes.ts`
---
## 三、低优先级(功能补全)
### 10. Settings — 数据导出缺少敏感字段脱敏
**现状**: `export.routes.ts` 对工资和身份证号做了解密导出,但没有脱敏处理。导出的 JSON 包含完整的身份证号、银行账号、工资数据,存在数据泄露风险。
**建议**:
- 增加「脱敏导出」模式:身份证号显示前 3 后 4 位(如 `110***********1234`),银行账号显示后 4 位
- 敏感字段脱敏后用 `(hidden)` 占位,便于识别
- 仅管理员可导出完整数据,普通 HR 角色只能导出脱敏版本
- 导出日志记录每次导出的操作人、时间、范围
**涉及文件**: `backend/src/routes/export.routes.ts`
---
### 11. Import — 加班类型字段未使用
**现状**: Excel 模板中加班类型是文本字段("工作日加班/休息日加班/法定节假日加班"),但解析时用 `includes()` 字符串匹配判断类型,这种方式无法准确区分多类型混合的加班记录。
**建议**:
- 改为三列独立填写:工作日加班时长、休息日加班时长、法定节假日加班时长
- 每列只填数值,减少歧义
- 或在解析时按分隔符拆分为数组,逐个判断类型
**涉及文件**: `backend/src/routes/import.routes.ts`
---
### 12. Settings — 通知设置 useMemo 错误使用
**现状**: `NotificationSettings``useMemo` 用于副作用(设置 form state),这违反了 React Hooks 的规则。`useMemo` 不应该在副作用中调用,应该用 `useEffect` 替代。
**建议**:
-`useMemo` 替换为 `useEffect`,正确处理数据加载后的表单回填
**涉及文件**: `frontend/src/pages/Settings.tsx`
---
### 13. Import — 社保/公积金增减员未校验基数范围
**现状**: 社保和公积金变动导入时,只记录用户填写的基数,没有校验基数是否在政策允许的上下限范围内(北京 2024 年社保基数下限 6326、上限 33891)。
**建议**:
- 增加基数上下限校验逻辑(可配置城市参数)
- 超出范围的记录在预览阶段标红提示
- 提供默认值建议(低于下限用下限,高于上限用上限)
**涉及文件**: `backend/src/routes/import.routes.ts``backend/src/routes/social.routes.ts`
---
### 14. Export — 导出无压缩,大数据集超时
**现状**: 全量导出 JSON 时,如果员工数量很多(如 1000+ 人),文件可能很大,导出接口响应时间过长甚至超时。没有分页或流式导出机制。
**建议**:
- 增加分页导出:按员工分批导出,每次最多 500 条
- 大数据集使用 Stream API 流式响应,避免内存溢出
- JSON 导出支持压缩(gzip
- 增加导出进度条,前端可实时看到导出进度
**涉及文件**: `backend/src/routes/export.routes.ts`
---
## 四、优先级总览
| 优先级 | 编号 | 功能 | 工作量 |
|--------|------|------|--------|
| P0 | 1 | 企业信息表单数据回填 | 小 |
| P0 | 2 | 用户管理编辑/禁用 | 中 |
| P0 | 3 | 导入预览+逐行编辑 | 大 |
| P0 | 4 | 选择性导出+格式选择 | 中 |
| P1 | 5 | 身份证号格式校验 | 小 |
| P1 | 6 | 通知渠道测试功能 | 中 |
| P1 | 7 | 导入覆盖逻辑说明 | 小 |
| P1 | 8 | 套餐升级+用量限制 | 大 |
| P1 | 9 | 错误日志导出 | 小 |
| P2 | 10 | 导出敏感字段脱敏 | 小 |
| P2 | 11 | 加班类型字段改进 | 小 |
| P2 | 12 | useMemo 替换为 useEffect | 小 |
| P2 | 13 | 社保基数范围校验 | 小 |
| P2 | 14 | 大数据集分页/流式导出 | 中 |
+219
View File
@@ -0,0 +1,219 @@
# 劳动用工合规 SaaS — 功能层面优化清单(续三)
> **文档编号**: 20260723-优化-6.md
> **日期**: 2026-07-23
> **来源**: 对 Portal 相关页面、AI 服务、RAG 服务深入研究后得出
---
## 一、高优先级(核心业务缺陷)
### 1. Portal — 工资条确认后无反馈机制
**现状**: 员工点击「确认已阅」后只更新 `confirmedAt`,没有通知 HR 已确认。如果 HR 期望所有员工都确认后才能完成工资条审核流程,当前系统无法感知确认状态。
**建议**:
- 工资条确认后通过 WebSocket 或轮询通知 HR
- 在 Money 页面展示各员工的工资条确认状态(已确认 / 未确认)
- 未确认员工超过 N 人时,HR 收到系统通知
- 员工确认后记录 IP 地址(已有),用于审计
**涉及文件**: `frontend/src/pages/Money.tsx``backend/src/routes/portal.routes.ts``frontend/src/pages/portal/Payslip.tsx`
---
### 2. AI — 会话上下文无企业数据关联
**现状**: `buildOrgContext` 只返回员工姓名、部门、入职日期和合同类型的摘要,过于粗略。HR 在问「我们公司有几个试用期还没签合同的员工」时,AI 无法基于这些数据准确回答。
**建议**:
- 增强 `buildOrgContext` 的数据粒度:增加合同状态、即将到期天数、特殊状态(孕期/工伤)等
-`riskItem` 的详细描述也传入,而非只传标题
- 考虑将员工数据以结构化 JSON 传入,而非纯文本,便于 AI 理解
**涉及文件**: `backend/src/routes/ai.routes.ts`
---
### 3. Portal — 合同签署确认无电子签名
**现状**: 员工点击「确认签署」后只更新 `status = 'CONFIRMED'`,没有电子签名或意愿确认机制。法律上电子合同需要可靠的电子签名(CA 证书或人脸识别),当前实现不具备法律效力。
**建议**:
- 增加短信验证码二次确认:员工点击确认后,发送验证码到手机,输入后完成签署
- 或对接第三方电子签名服务(如 e签宝、法大大)
- 签署完成后生成带有时间戳的签署记录 PDF
- 签署记录存储签名证据(IP、设备信息、地理位置),用于后续举证
**涉及文件**: `backend/src/routes/portal.routes.ts``frontend/src/pages/portal/ContractConfirm.tsx`
---
### 4. AI — 用量限制校验逻辑有误
**现状**: `checkUsageLimit` 函数用 `prisma.auditLog``detail` 字段(JSON 序列化后的字符串)做 `count`,但 `JSON.stringify({ month })` 的结果与数据库中 `recordUsage` 时写入的 `detail` 字段格式可能不匹配(后者是对象直接存储)。查询条件无法正确匹配,导致限制失效。
**建议**:
- 统一 `auditLog.detail` 字段的存储格式,要么都用 JSON 字符串,要么都用对象
- 或者用独立的 `aiUsage` 表记录 AI 使用次数,按月统计更准确
- `checkUsageLimit` 应在请求前调用,而非请求后(避免超限后才报错)
**涉及文件**: `backend/src/routes/ai.routes.ts`
---
## 二、中优先级(高频操作体验)
### 5. Portal — 入职填报无文件上传
**现状**: `onboardingSchema` 定义了身份证照片、银行流水等字段,但实际表单只提交文本数据,没有文件上传功能。员工入职时仍需线下提交证件复印件。
**建议**:
- 增加文件上传功能(身份证正反面、学历证明、体检报告等)
- 文件上传到 OSS/S3,返回 URL 后存入 `formData`
- 支持员工端在「我的合同」页面查看已上传的入职材料
- HR 在 Roster 页面可查看员工上传的入职材料
**涉及文件**: `backend/src/routes/portal.routes.ts``frontend/src/pages/portal/Onboarding.tsx`
---
### 6. AI — RAG 知识库无增量更新机制
**现状**: `seedKnowledgeBase` 初始化知识库后,没有提供增量更新接口。劳动法律法规更新后,系统无法自动同步新法规。`addKnowledge` 接口存在但没有在前端暴露入口。
**建议**:
- 增加「知识库管理」页面,HR 可手动添加/编辑法规条文
- 增加法规有效期字段,过期法规自动失效
- 对接权威劳动法数据库(如北大法宝)的增量更新接口(可选)
- 知识库更新后触发向量重索引
**涉及文件**: `backend/src/routes/ai.routes.ts``backend/src/services/rag.service.ts`
---
### 7. Portal — 工资条只能看当前月
**现状**: 员工只能通过月份选择器切换查看历史月份,但无法快速看到工资历史趋势。当员工想对比近半年收入变化时,只能逐月切换。
**建议**:
- 在工资条页面增加「工资趋势」图表(近 6 个月应发金额折线图)
- 增加「收入明细导出」功能,员工可下载自己的历史工资条
- 增加「电子工资条存档」功能,每年自动生成 PDF 年度收入证明(用于贷款、签证等场景)
**涉及文件**: `frontend/src/pages/portal/Payslip.tsx``backend/src/routes/portal.routes.ts`
---
### 8. AI — 对话流异常时 token 不回收
**现状**: `/chat-stream` 在流式响应中途发生错误时,`recordUsage` 可能不会被调用(因为它在 `res.end()` 之后才调用),导致用户使用了 AI 但次数未记录。
**建议**:
-`recordUsage` 移到请求处理开始前,用 `try/finally` 确保无论成功失败都记录
- 或者使用中间件在响应完成后统一记录
- 增加 `aiUsage` 独立表,用事务保证计数准确性
**涉及文件**: `backend/src/routes/ai.routes.ts`
---
## 三、低优先级(功能补全)
### 9. Portal — 验证码登录安全性不足
**现状**: `codeStore` 使用内存 Map 存储验证码,重启服务器后失效,且在多实例部署时无法共享。5 分钟过期时间也较长,存在被暴力破解风险。
**建议**:
- 生产环境使用 Redis 存储验证码,支持多实例共享和自动过期
- 增加验证码错误次数限制(5 次错误后锁定 15 分钟)
- 验证码增加图形验证码或行为验证码(如滑动拼图)防止机器攻击
- 增加登录失败日志记录
**涉及文件**: `backend/src/routes/portal.routes.ts`
---
### 10. AI — 合同审查结果无结构化存储
**现状**: `reviewContract` 返回纯文本审查结果,用户无法按风险类型检索,也无法统计一段时间内的合同合规趋势。
**建议**:
- 将审查结果结构化存储(风险项、条款位置、严重程度、建议)
- 增加 `contractReviewHistory` 表,记录每次审查的时间、内容摘要
- 前端展示审查结果时,按风险等级分类展示,支持按条款搜索
**涉及文件**: `backend/src/routes/ai.routes.ts``backend/prisma/schema.prisma`
---
### 11. Portal — 入职链接无撤回机制
**现状**: HR 生成入职填报链接后无法撤回。如果员工已经收到链接但临时不入职,链接过期前仍然有效,可能被误用。
**建议**:
- 增加「撤销链接」功能,HR 可将已发送的链接置为无效
- 链接撤销后员工访问时提示「该链接已失效,请联系 HR」
- 链接状态增加「已发送」「已使用」「已过期」「已撤销」四种状态
**涉及文件**: `backend/src/routes/employee.routes.ts``backend/prisma/schema.prisma`
---
### 12. AI — 对话未设置超时机制
**现状**: AI 服务调用(特别是 `qwen-max` 模型)可能响应很慢,前端没有超时处理。当 AI 服务不可用时,用户只能等待 30 秒才看到错误。
**建议**:
- 后端设置请求超时(如 30 秒),超时时返回友好的错误提示
- 前端增加加载状态超时提示(如 15 秒无响应时显示「AI 服务响应较慢」)
- 增加 AI 服务健康检查接口,前端可在发送请求前检查服务状态
**涉及文件**: `backend/src/services/ai.service.ts``frontend/src/pages/AIAssistant.tsx`
---
### 13. Portal — 合同确认链接无重发功能
**现状**: 员工收到合同确认邮件/短信后,如果链接过期或未收到,只能让 HR 重新生成一次。员工端没有「重新发送确认链接」的功能。
**建议**:
- 在员工登录 Portal 后,如果存在待确认合同,显示「合同待确认」提示
- 增加「重新发送确认链接」按钮,员工可自行触发重发
- 链接重发记录需要 HR 审批或系统自动发送(根据企业配置)
**涉及文件**: `frontend/src/pages/portal/MyContract.tsx``backend/src/routes/employee.routes.ts`
---
### 14. AI — 案例匹配结果无后续操作
**现状**: `matchCase` 返回的案例分析和建议是纯文本展示,用户无法基于建议快速创建相应的待办事项或调整员工状态。
**建议**:
- 解析案例匹配结果中的「建议」部分,生成可执行的待办事项列表
- 支持用户点击「采纳建议」后,系统自动创建对应操作(如「与员工协商续签」待办)
- 案例匹配结果存入 `AICaseMatch` 表,便于后续审计和分析
**涉及文件**: `backend/src/routes/ai.routes.ts``frontend/src/pages/AIAssistant.tsx``backend/prisma/schema.prisma`
---
## 四、优先级总览
| 优先级 | 编号 | 功能 | 工作量 |
|--------|------|------|--------|
| P0 | 1 | 工资条确认通知 HR | 中 |
| P0 | 2 | AI 会话上下文数据增强 | 小 |
| P0 | 3 | 合同签署电子签名 | 大 |
| P0 | 4 | AI 用量限制校验修复 | 小 |
| P1 | 5 | 入职材料文件上传 | 中 |
| P1 | 6 | RAG 知识库管理界面 | 中 |
| P1 | 7 | 工资趋势图表+导出 | 中 |
| P1 | 8 | AI 用量记录事务保证 | 小 |
| P2 | 9 | 验证码登录安全加固 | 中 |
| P2 | 10 | 合同审查结构化存储 | 中 |
| P2 | 11 | 入职链接撤回功能 | 小 |
| P2 | 12 | AI 服务超时机制 | 小 |
| P2 | 13 | 合同确认链接重发 | 小 |
| P2 | 14 | 案例匹配结果转待办 | 中 |
+209 -14
View File
@@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"@prisma/client": "^5.18.0",
"@types/multer": "^2.2.0",
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
@@ -17,9 +18,11 @@
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^2.2.0",
"node-cron": "^3.0.3",
"openai": "^6.48.0",
"uuid": "^10.0.0",
"xlsx": "^0.18.5",
"zod": "^3.23.0"
},
"devDependencies": {
@@ -628,7 +631,6 @@
"version": "1.19.6",
"resolved": "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
@@ -650,7 +652,6 @@
"version": "3.4.38",
"resolved": "https://registry.npmmirror.com/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -670,7 +671,6 @@
"version": "4.17.25",
"resolved": "https://registry.npmmirror.com/@types/express/-/express-4.17.25.tgz",
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
@@ -683,7 +683,6 @@
"version": "4.19.9",
"resolved": "https://registry.npmmirror.com/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz",
"integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@@ -696,7 +695,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
@@ -714,7 +712,6 @@
"version": "1.3.5",
"resolved": "https://registry.npmmirror.com/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/morgan": {
@@ -734,11 +731,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/@types/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==",
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -755,21 +760,18 @@
"version": "6.15.1",
"resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmmirror.com/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -779,7 +781,6 @@
"version": "1.15.10",
"resolved": "https://registry.npmmirror.com/@types/serve-static/-/serve-static-1.15.10.tgz",
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
@@ -791,7 +792,6 @@
"version": "0.17.6",
"resolved": "https://registry.npmmirror.com/@types/send/-/send-0.17.6.tgz",
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/mime": "^1",
@@ -867,6 +867,15 @@
"node": ">=0.4.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz",
@@ -881,6 +890,12 @@
"node": ">= 8"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmmirror.com/arg/-/arg-4.1.3.tgz",
@@ -996,9 +1011,19 @@
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmmirror.com/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
@@ -1037,6 +1062,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
@@ -1062,6 +1100,15 @@
"fsevents": "~2.3.2"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmmirror.com/compressible/-/compressible-2.0.18.tgz",
@@ -1099,6 +1146,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -1152,6 +1214,18 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/create-require/-/create-require-1.1.1.tgz",
@@ -1433,6 +1507,15 @@
"node": ">= 0.6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz",
@@ -1948,6 +2031,25 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.4.tgz",
@@ -2188,6 +2290,20 @@
"node": ">= 0.8"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
@@ -2419,6 +2535,18 @@
"source-map": "^0.6.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz",
@@ -2428,6 +2556,23 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -2617,6 +2762,12 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmmirror.com/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
@@ -2635,7 +2786,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unpipe": {
@@ -2647,6 +2797,12 @@
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -2685,6 +2841,24 @@
"node": ">= 0.8"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
@@ -2692,6 +2866,27 @@
"dev": true,
"license": "ISC"
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz",
+3
View File
@@ -13,6 +13,7 @@
},
"dependencies": {
"@prisma/client": "^5.18.0",
"@types/multer": "^2.2.0",
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
@@ -21,9 +22,11 @@
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^2.2.0",
"node-cron": "^3.0.3",
"openai": "^6.48.0",
"uuid": "^10.0.0",
"xlsx": "^0.18.5",
"zod": "^3.23.0"
},
"devDependencies": {
+3
View File
@@ -173,6 +173,7 @@ model Employee {
gender String?
phone String?
idCardNumber String? // AES-256 加密存储
idCardHash String? // SHA-256 哈希,用于按身份证号查询匹配
emergencyContact String?
emergencyPhone String?
address String?
@@ -209,6 +210,8 @@ model Employee {
socialInsRecords EmployeeSocialInsRecord[]
housingFundRecords EmployeeHousingFundRecord[]
departmentRecords EmployeeDepartmentRecord[]
@@unique([orgId, idCardHash])
}
model LaborContract {
+10
View File
@@ -39,6 +39,8 @@ import socialRoutes from './routes/social.routes'
import notificationRoutes from './routes/notification.routes'
import attachmentRoutes from './routes/attachment.routes'
import rosterRoutes from './routes/roster.routes'
import exportRoutes from './routes/export.routes'
import importRoutes from './routes/import.routes'
app.use('/api/v1/auth', authRoutes)
app.use('/api/v1/dashboard', dashboardRoutes)
app.use('/api/v1/employees', employeeRoutes)
@@ -52,7 +54,15 @@ app.use('/api/v1/social', socialRoutes)
app.use('/api/v1/notifications', notificationRoutes)
app.use('/api/v1/attachments', attachmentRoutes)
app.use('/api/v1/roster', rosterRoutes)
app.use('/api/v1/export', exportRoutes)
app.use('/api/v1/import', importRoutes)
app.use(errorHandler)
// RAG 知识库自动初始化(异步,不阻塞启动)
import { seedKnowledgeBase } from './services/rag.service'
seedKnowledgeBase().catch((err) => {
console.warn('[RAG] 知识库初始化失败,AI 问答将不使用 RAG 检索:', err?.message || err)
})
export default app
+4
View File
@@ -20,3 +20,7 @@ export function decrypt(encryptedText: string): string {
decrypted += decipher.final('utf8')
return decrypted
}
export function sha256(text: string): string {
return crypto.createHash('sha256').update(text, 'utf8').digest('hex')
}
+107 -1
View File
@@ -1,10 +1,51 @@
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { chat, reviewContract, matchCase, predictRisks } from '../services/ai.service'
import { chat, chatStream, reviewContract, matchCase, predictRisks } from '../services/ai.service'
import { seedKnowledgeBase, addKnowledge, searchKnowledge } from '../services/rag.service'
import prisma from '../lib/prisma'
const router = Router()
const PLAN_LIMITS: Record<string, { chat: number; review: number; case: number }> = {
FREE: { chat: 10, review: 3, case: 3 },
PRO: { chat: 100, review: 20, case: 20 },
ENTERPRISE: { chat: 0, review: 0, case: 0 },
}
async function checkUsageLimit(orgId: string, type: 'chat' | 'review' | 'case'): Promise<void> {
const org = await prisma.organization.findUnique({ where: { id: orgId } })
if (!org) return
const limits = PLAN_LIMITS[org.plan] || PLAN_LIMITS.FREE
const limit = limits[type]
if (limit === 0) return
const month = new Date().toISOString().slice(0, 7)
const count = await prisma.auditLog.count({
where: {
orgId,
action: `AI_${type.toUpperCase()}`,
detail: JSON.stringify({ month }) as any,
},
})
if (count >= limit) {
throw { code: 'USAGE_LIMIT', message: `本月 AI${type === 'chat' ? '问答' : type === 'review' ? '合同审查' : '案例匹配'}次数已达上限(${limit}次),请升级套餐` }
}
}
async function recordUsage(orgId: string, userId: string, type: 'chat' | 'review' | 'case'): Promise<void> {
const month = new Date().toISOString().slice(0, 7)
await prisma.auditLog.create({
data: {
orgId,
userId,
action: `AI_${type.toUpperCase()}`,
entity: 'AI',
entityId: null,
detail: { month, type } as any,
ip: '',
},
})
}
async function buildOrgContext(orgId: string): Promise<string> {
const [employees, risks] = await Promise.all([
prisma.employee.findMany({
@@ -37,21 +78,48 @@ router.post('/chat', authMiddleware, async (req: AuthRequest, res, next) => {
if (!messages || !Array.isArray(messages)) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 messages 参数' } })
}
await checkUsageLimit(req.user!.orgId, 'chat')
const orgContext = await buildOrgContext(req.user!.orgId)
const reply = await chat(messages, orgContext)
await recordUsage(req.user!.orgId, req.user!.id, 'chat')
res.json({ success: true, data: { reply } })
} catch (err) {
next(err)
}
})
router.post('/chat-stream', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { messages } = req.body as { messages: { role: 'user' | 'assistant'; content: string }[] }
if (!messages || !Array.isArray(messages)) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 messages 参数' } })
}
await checkUsageLimit(req.user!.orgId, 'chat')
const orgContext = await buildOrgContext(req.user!.orgId)
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
for await (const delta of chatStream(messages, orgContext)) {
res.write(`data: ${JSON.stringify({ delta })}\n\n`)
}
res.write('data: [DONE]\n\n')
res.end()
await recordUsage(req.user!.orgId, req.user!.id, 'chat')
} catch (err) {
if (!res.headersSent) next(err)
else res.end()
}
})
router.post('/review', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { contractText } = req.body as { contractText: string }
if (!contractText) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少合同文本' } })
}
await checkUsageLimit(req.user!.orgId, 'review')
const result = await reviewContract(contractText)
await recordUsage(req.user!.orgId, req.user!.id, 'review')
res.json({ success: true, data: { result } })
} catch (err) {
next(err)
@@ -64,7 +132,9 @@ router.post('/match-case', authMiddleware, async (req: AuthRequest, res, next) =
if (!scenario) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少争议情形描述' } })
}
await checkUsageLimit(req.user!.orgId, 'case')
const result = await matchCase(scenario)
await recordUsage(req.user!.orgId, req.user!.id, 'case')
res.json({ success: true, data: { result } })
} catch (err) {
next(err)
@@ -81,4 +151,40 @@ router.get('/predict', authMiddleware, async (req: AuthRequest, res, next) => {
}
})
// RAG 知识库管理
router.post('/rag/seed', authMiddleware, async (req: AuthRequest, res, next) => {
try {
await seedKnowledgeBase()
res.json({ success: true, data: { message: '知识库初始化完成' } })
} catch (err) {
next(err)
}
})
router.post('/rag/add', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { title, content, source, category } = req.body
if (!title || !content) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 title 或 content' } })
}
const result = await addKnowledge(title, content, source || '自定义', category || '其他')
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.post('/rag/search', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { query, topK } = req.body
if (!query) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 query' } })
}
const results = await searchKnowledge(query, topK || 5)
res.json({ success: true, data: { results } })
} catch (err) {
next(err)
}
})
export default router
+44 -1
View File
@@ -1,10 +1,14 @@
import { Router } from 'express'
import { registerSchema, loginSchema, refreshSchema, resetPasswordSchema } from '../schemas/auth.schema'
import { registerSchema, loginSchema, refreshSchema, resetPasswordSchema, forgotPasswordSchema, verifyCodeSchema } from '../schemas/auth.schema'
import { register, login, refresh, resetPassword } from '../services/auth.service'
import { authLimiter, loginLimiter } from '../middleware/rateLimit'
import prisma from '../lib/prisma'
import bcrypt from 'bcryptjs'
const router = Router()
const codeStore = new Map<string, { code: string; expiresAt: number }>()
router.post('/register', authLimiter, async (req, res, next) => {
try {
const data = registerSchema.parse(req.body)
@@ -35,6 +39,45 @@ router.post('/refresh', async (req, res, next) => {
}
})
// 发送重置验证码
router.post('/forgot-password/send-code', authLimiter, async (req, res, next) => {
try {
const data = forgotPasswordSchema.parse(req.body)
const user = await prisma.user.findUnique({ where: { phone: data.phone } })
if (!user) {
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '该手机号未注册' } })
}
const code = Math.random().toString().slice(2, 8)
codeStore.set(data.phone, { code, expiresAt: Date.now() + 5 * 60 * 1000 })
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
} catch (err) {
next(err)
}
})
// 验证码重置密码
router.post('/forgot-password/verify', authLimiter, async (req, res, next) => {
try {
const data = verifyCodeSchema.parse(req.body)
const stored = codeStore.get(data.phone)
if (!stored || stored.expiresAt < Date.now()) {
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
}
if (stored.code !== data.code) {
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: '验证码错误' } })
}
codeStore.delete(data.phone)
const passwordHash = await bcrypt.hash(data.newPassword, 10)
await prisma.user.updateMany({
where: { phone: data.phone },
data: { passwordHash },
})
res.json({ success: true, data: { message: '密码重置成功' } })
} catch (err) {
next(err)
}
})
router.post('/reset-password', authLimiter, async (req, res, next) => {
try {
const data = resetPasswordSchema.parse(req.body)
+84
View File
@@ -1,6 +1,7 @@
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import prisma from '../lib/prisma'
import {
createEmployeeSchema,
updateEmployeeSchema,
@@ -91,6 +92,89 @@ router.delete('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
}
})
// 批量续签合规预检
router.post('/contracts/preview-renew', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { contractIds } = req.body as { contractIds: string[] }
if (!contractIds || !Array.isArray(contractIds) || contractIds.length === 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 contractIds' } })
}
const contracts = await prisma.laborContract.findMany({
where: { id: { in: contractIds }, orgId: req.user!.orgId },
include: { employee: true },
orderBy: { startDate: 'asc' },
})
if (contracts.length === 0) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '未找到符合条件的合同' } })
}
// 合规检查:按员工分组,检查历史固定期合同次数
const results = []
for (const contract of contracts) {
const employee = contract.employee
// 查找该员工所有历史固定期合同(按时间正序,用于判断续签次数)
const allFixedContracts = await prisma.laborContract.findMany({
where: {
employeeId: contract.employeeId,
orgId: req.user!.orgId,
contractType: 'FIXED',
},
orderBy: { startDate: 'asc' },
})
// 当前合同是第几次固定期(从1开始计数)
const currentIndex = allFixedContracts.findIndex((c) => c.id === contract.id)
const renewalCount = currentIndex + 1
// 判断是否应签无固定期限:
// 1. 已连续签订2次以上固定期限合同(第3次应签无固定期限)
// 2. 员工连续工作满10年
const shouldBeUnfixed = renewalCount >= 2
const yearsSinceHire = (Date.now() - new Date(employee.hireDate).getTime()) / (365.25 * 24 * 60 * 60 * 1000)
const shouldBeUnfixedByTenure = yearsSinceHire >= 10
let warning: string | null = null
let suggestion: string | null = null
if (shouldBeUnfixed || shouldBeUnfixedByTenure) {
warning = shouldBeUnfixed
? `该员工已有 ${renewalCount} 次固定期限合同续签记录(《劳动合同法》第14条),第三次续签应订立无固定期限劳动合同`
: `该员工在本公司连续工作 ${Math.floor(yearsSinceHire)} 年(《劳动合同法》第14条),应订立无固定期限劳动合同`
suggestion = '建议与员工协商订立无固定期限劳动合同,以规避法律风险'
} else {
suggestion = `可续签固定期限(当前为第 ${renewalCount} 次续签)`
}
results.push({
contractId: contract.id,
employeeId: contract.employeeId,
employeeName: employee.name,
department: employee.department,
currentContractType: contract.contractType,
renewalCount,
yearsSinceHire: Math.floor(yearsSinceHire * 10) / 10,
warning,
suggestion,
canRenewFixed: !warning,
})
}
res.json({
success: true,
data: {
total: results.length,
warnings: results.filter((r) => r.warning).length,
results,
},
})
} catch (err) {
next(err)
}
})
router.post('/contracts/batch-renew', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = batchRenewSchema.parse(req.body)
+52
View File
@@ -0,0 +1,52 @@
import { Router, Response } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
import { decrypt } from '../lib/crypto'
const router = Router()
router.get('/all', authMiddleware, async (req: AuthRequest, res: Response, next) => {
try {
const orgId = req.user!.orgId
const [employees, contracts, terminations, payrollBatches, payslips, socialRecords, housingRecords, riskItems] = await Promise.all([
prisma.employee.findMany({ where: { orgId } }),
prisma.laborContract.findMany({ where: { orgId } }),
prisma.terminationRecord.findMany({ where: { orgId } }),
prisma.payrollBatch.findMany({ where: { orgId } }),
prisma.payslip.findMany({ where: { orgId } }),
prisma.employeeSocialInsRecord.findMany({ where: { orgId } }),
prisma.employeeHousingFundRecord.findMany({ where: { orgId } }),
prisma.riskItem.findMany({ where: { orgId } }),
])
const safeEmployees = employees.map((e) => {
let salary = 0
try { salary = Number(decrypt(e.monthlySalary)) || 0 } catch { salary = Number(e.monthlySalary) || 0 }
let idCard = null
try { if (e.idCardNumber) idCard = decrypt(e.idCardNumber) } catch { idCard = e.idCardNumber }
return { ...e, monthlySalary: salary, idCardNumber: idCard }
})
const data = {
exportedAt: new Date().toISOString(),
orgId,
employees: safeEmployees,
contracts,
terminations,
payrollBatches,
payslips,
socialRecords,
housingRecords,
riskItems,
}
res.setHeader('Content-Type', 'application/json')
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.json"`)
res.json(data)
} catch (err) {
next(err)
}
})
export default router
+418
View File
@@ -0,0 +1,418 @@
import { Router, Response } from 'express'
import multer from 'multer'
import * as XLSX from 'xlsx'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { encrypt, decrypt, sha256 } from '../lib/crypto'
import prisma from '../lib/prisma'
const router = Router()
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
function dateToMonth(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
function parseDate(v: any): Date | null {
if (!v) return null
if (v instanceof Date) return v
if (typeof v === 'number') {
const d = XLSX.SSF.parse_date_code(v)
if (d) return new Date(d.y, d.m - 1, d.d)
}
const s = String(v).trim()
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return new Date(s)
if (/^\d{4}\/\d{2}\/\d{2}/.test(s)) return new Date(s.replace(/\//g, '-'))
return null
}
function val(v: any): string {
if (v == null) return ''
return String(v).trim()
}
function num(v: any): number {
const n = Number(v)
return isNaN(n) ? 0 : n
}
router.post('/excel', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
try {
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
const orgId = req.user!.orgId
const userId = req.user!.id
const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
const result: any = { employees: 0, contracts: 0, overtime: 0, disciplinary: 0, attendance: 0, errors: [] as string[] }
const empSheet = wb.Sheets['员工信息']
if (empSheet) {
const rows = XLSX.utils.sheet_to_json(empSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const name = val(r['姓名'])
if (!name) { result.errors.push(`员工第${i + 2}行:姓名为空,跳过`); continue }
const dept = val(r['部门']) || '未分配'
const hireDate = parseDate(r['入职日期'])
if (!hireDate) { result.errors.push(`员工第${i + 2}行:入职日期格式错误`); continue }
const salary = String(num(r['月工资']))
if (salary === '0') { result.errors.push(`员工第${i + 2}行:月工资为空`); continue }
const emp = await prisma.employee.create({
data: {
orgId, name, department: dept, hireDate,
monthlySalary: encrypt(salary),
gender: val(r['性别']) || null,
phone: val(r['手机号']) || null,
idCardNumber: val(r['身份证号']) ? encrypt(val(r['身份证号'])) : null,
idCardHash: val(r['身份证号']) ? sha256(val(r['身份证号'])) : null,
emergencyContact: val(r['紧急联系人']) || null,
emergencyPhone: val(r['紧急联系电话']) || null,
address: val(r['住址']) || null,
bankName: val(r['开户行']) || null,
bankAccount: val(r['银行账号']) ? encrypt(val(r['银行账号'])) : null,
socialInsBase: num(r['社保基数']) || num(salary),
housingFundBase: num(r['公积金基数']) || num(salary),
specialDeduction: num(r['专项附加扣除']) || 0,
isPregnant: val(r['孕期']) === '是',
isInMedicalPeriod: val(r['医疗期']) === '是',
isWorkInjured: val(r['工伤']) === '是',
socialInsStartMonth: dateToMonth(hireDate),
housingFundStartMonth: dateToMonth(hireDate),
createdBy: userId,
},
})
await prisma.employeeSocialInsRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: num(r['社保基数']) || num(salary), changeType: 'ONBOARDING', createdBy: userId } })
await prisma.employeeHousingFundRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: num(r['公积金基数']) || num(salary), changeType: 'ONBOARDING', createdBy: userId } })
await prisma.salaryChangeRecord.create({ data: { orgId, employeeId: emp.id, oldSalary: 0, newSalary: num(salary), effectiveDate: hireDate, effectiveMonth: dateToMonth(hireDate), endMonth: null, changeType: 'ONBOARDING', createdBy: userId } })
await prisma.employeeDepartmentRecord.create({ data: { orgId, employeeId: emp.id, oldDepartment: '', newDepartment: dept, effectiveMonth: dateToMonth(hireDate), endMonth: null, changeType: 'ONBOARDING', createdBy: userId } })
result.employees++
} catch (e: any) {
result.errors.push(`员工第${i + 2}行:${e?.message || '导入失败'}`)
}
}
}
const contractSheet = wb.Sheets['劳动合同']
if (contractSheet) {
const rows = XLSX.utils.sheet_to_json(contractSheet)
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e.id]))
const empByName = new Map(employees.map(e => [e.name, e.id]))
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const idCard = val(r['身份证号'])
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
if (!empId) { result.errors.push(`合同第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const startDate = parseDate(r['合同开始日期'])
if (!startDate) { result.errors.push(`合同第${i + 2}行:开始日期格式错误`); continue }
const typeMap: any = { '固定期限': 'FIXED', '无固定期限': 'UNFIXED', '未签': 'UNSIGNED' }
const contractType = typeMap[val(r['合同类型'])] || 'FIXED'
if (contractType !== 'UNSIGNED') {
await prisma.laborContract.create({
data: {
orgId, employeeId: empId,
signDate: parseDate(r['签订日期']) || null,
startDate,
endDate: parseDate(r['合同结束日期']) || null,
contractType,
signMethod: val(r['签订方式']) === '电子' ? 'ELECTRONIC' : 'PAPER',
contractYears: num(r['合同年限']) || 3,
probationMonths: num(r['试用期月数']) || 0,
probationSalary: num(r['试用期工资']) || 0,
createdBy: userId,
},
})
result.contracts++
}
} catch (e: any) {
result.errors.push(`合同第${i + 2}行:${e?.message || '导入失败'}`)
}
}
}
const otSheet = wb.Sheets['加班记录']
if (otSheet) {
const rows = XLSX.utils.sheet_to_json(otSheet)
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e.id]))
const empByName = new Map(employees.map(e => [e.name, e.id]))
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const idCard = val(r['身份证号'])
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
if (!empId) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
if (!date) continue
const month = dateToMonth(date)
const hours = num(r['加班时长'])
const otType = val(r['加班类型']) || '工作日加班'
await prisma.overtimeRecord.create({ data: { orgId, employeeId: empId, month, weekdayHours: otType.includes('工作日') ? hours : 0, weekendHours: otType.includes('休息日') ? hours : 0, holidayHours: otType.includes('法定') ? hours : 0, createdBy: userId } as any })
result.overtime++
}
}
const discSheet = wb.Sheets['违纪记录']
if (discSheet) {
const rows = XLSX.utils.sheet_to_json(discSheet)
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e.id]))
const empByName = new Map(employees.map(e => [e.name, e.id]))
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const idCard = val(r['身份证号'])
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
if (!empId) { result.errors.push(`违纪第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
if (!date) continue
const typeMap: any = { '迟到': 'LATE', '旷工': 'ABSENT', '不服从': 'INSUBORDINATION', '违纪': 'MISCONDUCT', '违规': 'VIOLATE_POLICY', '其他': 'OTHER' }
const sevMap: any = { '警告': 'WARNING', '严重': 'SERIOUS', '重度': 'SEVERE' }
const actMap: any = { '口头警告': 'ORAL_WARNING', '书面警告': 'WRITTEN_WARNING', '扣款': 'DEDUCTION', '降级': 'DEMOTION', '辞退': 'TERMINATION' }
await prisma.disciplinaryRecord.create({ data: { orgId, employeeId: empId, violationDate: date, violationType: typeMap[val(r['违纪类型'])] || 'OTHER', description: val(r['描述']), severity: sevMap[val(r['严重程度'])] || 'WARNING', action: actMap[val(r['处罚'])] || 'ORAL_WARNING', createdBy: userId } })
result.disciplinary++
}
}
const attSheet = wb.Sheets['考勤记录']
if (attSheet) {
const rows = XLSX.utils.sheet_to_json(attSheet)
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e.id]))
const empByName = new Map(employees.map(e => [e.name, e.id]))
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const idCard = val(r['身份证号'])
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
if (!empId) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
if (!date) continue
const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' }
await prisma.attendanceRecord.create({ data: { orgId, employeeId: empId, date, status: statusMap[val(r['考勤状态'])] || 'NORMAL', checkInTime: val(r['上班时间']) || null, checkOutTime: val(r['下班时间']) || null, remark: val(r['备注']) || null, createdBy: userId } })
result.attendance++
}
}
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.get('/template', authMiddleware, async (req: AuthRequest, res: Response) => {
const wb = XLSX.utils.book_new()
const empData = [
{ '姓名': '张三', '部门': '技术部', '性别': '男', '手机号': '13800138000', '身份证号': '110101199001011234', '入职日期': '2023-03-01', '月工资': 10000, '社保基数': 10000, '公积金基数': 10000, '专项附加扣除': 1000, '紧急联系人': '李四', '紧急联系电话': '13900139000', '住址': '北京市朝阳区', '开户行': '工商银行', '银行账号': '6222021234567890', '孕期': '否', '医疗期': '否', '工伤': '否' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(empData), '员工信息')
const contractData = [
{ '姓名': '张三', '身份证号': '110101199001011234', '合同类型': '固定期限', '签订日期': '2023-03-01', '合同开始日期': '2023-03-01', '合同结束日期': '2026-03-01', '合同年限': 3, '签订方式': '纸质', '试用期月数': 3, '试用期工资': 8000 },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(contractData), '劳动合同')
const otData = [
{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-01-15', '加班时长': 2, '加班类型': '工作日加班', '倍率': 1.5, '是否审批': '是' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
const discData = [
{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-01-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录')
const attData = [
{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-01-15', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', 'attachment; filename="import-template.xlsx"')
res.send(buf)
})
// ========== 月度导入 ==========
router.post('/monthly', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
try {
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
const orgId = req.user!.orgId
const userId = req.user!.id
const month = val(req.body.month) || dateToMonth(new Date())
if (!/^\d{4}-\d{2}$/.test(month)) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '月份格式应为 YYYY-MM' } })
}
const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
const result: any = { month, attendance: 0, overtime: 0, salaryChanges: 0, socialInsChanges: 0, housingFundChanges: 0, errors: [] as string[] }
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, monthlySalary: true, department: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e]))
const empByName = new Map(employees.map(e => [e.name, e]))
function findEmp(r: any) {
const idCard = val(r['身份证号'])
if (idCard) {
const emp = empByHash.get(sha256(idCard))
if (emp) return emp
}
return empByName.get(val(r['姓名']))
}
// 考勤记录
const attSheet = wb.Sheets['考勤记录']
if (attSheet) {
const rows = XLSX.utils.sheet_to_json(attSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
if (!date) { result.errors.push(`考勤第${i + 2}行:日期格式错误`); continue }
const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' }
await prisma.attendanceRecord.upsert({
where: { employeeId_date: { employeeId: emp.id, date } },
create: { orgId, employeeId: emp.id, date, status: statusMap[val(r['考勤状态'])] || 'NORMAL', checkInTime: val(r['上班时间']) || null, checkOutTime: val(r['下班时间']) || null, remark: val(r['备注']) || null, createdBy: userId },
update: { status: statusMap[val(r['考勤状态'])] || 'NORMAL', checkInTime: val(r['上班时间']) || null, checkOutTime: val(r['下班时间']) || null, remark: val(r['备注']) || null },
})
result.attendance++
} catch (e: any) { result.errors.push(`考勤第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
// 加班记录
const otSheet = wb.Sheets['加班记录']
if (otSheet) {
const rows = XLSX.utils.sheet_to_json(otSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
if (!date) { result.errors.push(`加班第${i + 2}行:日期格式错误`); continue }
const otMonth = dateToMonth(date)
const hours = num(r['加班时长'])
const otType = val(r['加班类型']) || '工作日加班'
await prisma.overtimeRecord.upsert({
where: { employeeId_month: { employeeId: emp.id, month: otMonth } },
create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: otType.includes('工作日') ? hours : 0, weekendHours: otType.includes('休息日') ? hours : 0, holidayHours: otType.includes('法定') ? hours : 0 } as any,
update: {
weekdayHours: { increment: otType.includes('工作日') ? hours : 0 },
weekendHours: { increment: otType.includes('休息日') ? hours : 0 },
holidayHours: { increment: otType.includes('法定') ? hours : 0 },
},
})
result.overtime++
} catch (e: any) { result.errors.push(`加班第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
// 薪资调整
const salarySheet = wb.Sheets['薪资调整']
if (salarySheet) {
const rows = XLSX.utils.sheet_to_json(salarySheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`薪资第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const newSalary = num(r['调整后月薪'])
if (newSalary <= 0) { result.errors.push(`薪资第${i + 2}行:调整后月薪无效`); continue }
const effDate = parseDate(r['生效日期']) || new Date(month + '-01')
const effMonth = dateToMonth(effDate)
let oldSalary = 0
try { oldSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { oldSalary = 0 }
// 关闭之前有效记录
await prisma.salaryChangeRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: effMonth } })
await prisma.salaryChangeRecord.create({ data: { orgId, employeeId: emp.id, oldSalary, newSalary, effectiveDate: effDate, effectiveMonth: effMonth, endMonth: null, changeType: 'SALARY_CHANGE', reason: val(r['调薪原因']) || '月度导入', createdBy: userId } })
await prisma.employee.update({ where: { id: emp.id }, data: { monthlySalary: encrypt(String(newSalary)) } })
result.salaryChanges++
} catch (e: any) { result.errors.push(`薪资第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
// 社保增减员
const socialSheet = wb.Sheets['社保变动']
if (socialSheet) {
const rows = XLSX.utils.sheet_to_json(socialSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`社保第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const changeType = val(r['变动类型'])
const base = num(r['缴费基数'])
if (changeType === '增员' || changeType === '调基') {
// 关闭之前有效记录
await prisma.employeeSocialInsRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month } })
await prisma.employeeSocialInsRecord.create({ data: { orgId, employeeId: emp.id, startMonth: month, endMonth: null, base: base || 0, changeType: changeType === '增员' ? 'ONBOARDING' : 'ADJUST', createdBy: userId } })
await prisma.employee.update({ where: { id: emp.id }, data: { socialInsBase: base || 0, socialInsStartMonth: month, socialInsEndMonth: null } })
} else if (changeType === '减员') {
await prisma.employeeSocialInsRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month, changeType: 'TERMINATION' } })
await prisma.employee.update({ where: { id: emp.id }, data: { socialInsEndMonth: month } })
}
result.socialInsChanges++
} catch (e: any) { result.errors.push(`社保第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
// 公积金增减员
const hfSheet = wb.Sheets['公积金变动']
if (hfSheet) {
const rows = XLSX.utils.sheet_to_json(hfSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`公积金第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const changeType = val(r['变动类型'])
const base = num(r['缴费基数'])
if (changeType === '增员' || changeType === '调基') {
await prisma.employeeHousingFundRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month } })
await prisma.employeeHousingFundRecord.create({ data: { orgId, employeeId: emp.id, startMonth: month, endMonth: null, base: base || 0, changeType: changeType === '增员' ? 'ONBOARDING' : 'ADJUST', createdBy: userId } })
await prisma.employee.update({ where: { id: emp.id }, data: { housingFundBase: base || 0, housingFundStartMonth: month, housingFundEndMonth: null } })
} else if (changeType === '减员') {
await prisma.employeeHousingFundRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month, changeType: 'TERMINATION' } })
await prisma.employee.update({ where: { id: emp.id }, data: { housingFundEndMonth: month } })
}
result.housingFundChanges++
} catch (e: any) { result.errors.push(`公积金第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.get('/monthly-template', authMiddleware, async (req: AuthRequest, res: Response) => {
const wb = XLSX.utils.book_new()
const attData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-01', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录')
const otData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-15', '加班时长': 2, '加班类型': '工作日加班' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
const salaryData = [{ '姓名': '张三', '身份证号': '110101199001011234', '调整后月薪': 12000, '生效日期': '2024-06-01', '调薪原因': '年度调薪' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(salaryData), '薪资调整')
const socialData = [{ '姓名': '张三', '身份证号': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(socialData), '社保变动')
const hfData = [{ '姓名': '张三', '身份证号': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(hfData), '公积金变动')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', 'attachment; filename="monthly-import-template.xlsx"')
res.send(buf)
})
export default router
+29 -1
View File
@@ -144,11 +144,15 @@ router.get('/batches/archived/list', async (req: AuthRequest, res: Response, nex
// 获取批次列表
router.get('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month } = req.query
const { month, monthFrom, monthTo, status, type } = req.query
const batches = await prisma.payrollBatch.findMany({
where: {
orgId: req.user!.orgId,
...(month ? { month: String(month) } : {}),
...(monthFrom ? { month: { gte: String(monthFrom) } } : {}),
...(monthTo ? { month: { lte: String(monthTo) } } : {}),
...(status ? { status: String(status) as any } : {}),
...(type ? { type: String(type) as any } : {}),
},
orderBy: [{ month: 'desc' }, { batchNo: 'asc' }],
})
@@ -179,6 +183,30 @@ router.get('/batches/:id', async (req: AuthRequest, res: Response, next: NextFun
}
})
// 重命名批次
router.put('/batches/:id/name', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { name } = req.body
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '批次名称不能为空' } })
}
const batch = await prisma.payrollBatch.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status === 'ARCHIVED') {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可重命名' } })
}
const updated = await prisma.payrollBatch.update({
where: { id: req.params.id },
data: { name: name.trim() },
})
res.json({ success: true, data: { id: updated.id, name: updated.name } })
} catch (err) {
next(err)
}
})
// 创建批次
const createBatchSchema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/),
+108 -22
View File
@@ -18,30 +18,54 @@ function safeDecrypt(encrypted: string): number {
// ========== 花名册聚合 API ==========
// 花名册列表(含汇总信息)
// 花名册列表(含汇总信息,支持分页和过滤
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const employees = await prisma.employee.findMany({
where: { orgId: req.user!.orgId },
orderBy: { createdAt: 'desc' },
include: {
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
terminations: { orderBy: { terminationDate: 'desc' }, take: 1 },
_count: {
select: {
disciplinaryRecords: true,
attendanceRecords: true,
trainingRecords: true,
performanceRecords: true,
payslips: true,
overtimeRecords: true,
},
},
},
})
const page = parseInt(req.query.page as string) || 1
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100)
const search = req.query.search as string
const status = req.query.status as string // ACTIVE | PRE_HIRE | RESIGNED
const contractStatus = req.query.contractStatus as string // active | expiring | expired | unsigned | etc.
const skip = (page - 1) * pageSize
const today = new Date()
today.setHours(0, 0, 0, 0)
const result = employees.map((e) => {
// 先查询满足 orgId 和搜索条件的员工
const whereBase: any = { orgId: req.user!.orgId }
if (search) {
whereBase.OR = [
{ name: { contains: search } },
{ department: { contains: search } },
]
}
const [total, employees] = await Promise.all([
prisma.employee.count({ where: whereBase }),
prisma.employee.findMany({
where: whereBase,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
include: {
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
terminations: { orderBy: { terminationDate: 'desc' }, take: 1 },
_count: {
select: {
disciplinaryRecords: true,
attendanceRecords: true,
trainingRecords: true,
performanceRecords: true,
payslips: true,
overtimeRecords: true,
},
},
},
}),
])
// 计算动态状态和合同状态
let result = employees.map((e) => {
const latestContract = e.contracts[0] || null
const contractInfo = latestContract
? getContractStatus({
@@ -60,11 +84,12 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
})
const isResigned = e.terminations.some((t) => t.terminationDate <= today)
const isPreHire = !isResigned && e.hireDate > today
const dynamicStatus = isResigned ? 'RESIGNED' : (isPreHire ? 'PRE_HIRE' : 'ACTIVE')
return {
id: e.id,
name: e.name,
department: e.department,
status: isResigned ? 'RESIGNED' : (isPreHire ? 'PRE_HIRE' : 'ACTIVE'),
status: dynamicStatus,
hasTermination: e.terminations.length > 0,
latestTerminationDate: e.terminations[0]?.terminationDate || null,
latestTerminationType: e.terminations[0]?.type || null,
@@ -80,7 +105,25 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
counts: e._count,
}
})
res.json({ success: true, data: result })
// 前端过滤:状态和合同状态(因为合同状态需要后处理,不适合放 Prisma where
if (status) {
result = result.filter((e) => e.status === status)
}
if (contractStatus) {
result = result.filter((e) => e.contractStatus === contractStatus)
}
res.json({
success: true,
data: result,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
})
} catch (err) {
next(err)
}
@@ -701,4 +744,47 @@ router.get('/:id/department-records', authMiddleware, async (req: AuthRequest, r
} catch (err) { next(err) }
})
// 30天内合同到期列表
router.get('/contracts/expiring', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const days = parseInt(req.query.days as string) || 30
const today = new Date()
today.setHours(0, 0, 0, 0)
const future = new Date(today)
future.setDate(future.getDate() + days)
const employees = await prisma.employee.findMany({
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
include: {
contracts: {
where: {
endDate: { gte: today, lte: future },
contractType: 'FIXED',
},
orderBy: { endDate: 'asc' },
take: 1,
},
},
})
const result = employees
.filter(e => e.contracts.length > 0)
.map(e => {
const contract = e.contracts[0]
const endDate = new Date(contract.endDate!)
const daysLeft = Math.ceil((endDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
return {
employeeId: e.id,
employeeName: e.name,
department: e.department,
contractEndDate: contract.endDate,
daysLeft,
}
})
.sort((a, b) => a.daysLeft - b.daysLeft)
res.json({ success: true, data: result })
} catch (err) { next(err) }
})
export default router
+110 -4
View File
@@ -192,10 +192,10 @@ router.get('/config/:id/adjust-preview', async (req: AuthRequest, res: Response,
employeeId: emp.id,
name: emp.name,
department: emp.department,
oldSocialBase,
oldBase: oldSocialBase,
avgSalary,
monthlyWage,
suggestedSocialBase,
suggestedBase: suggestedSocialBase,
}
})
@@ -209,7 +209,7 @@ router.get('/config/:id/adjust-preview', async (req: AuthRequest, res: Response,
const adjustApplySchema = z.object({
items: z.array(z.object({
employeeId: z.string(),
newSocialBase: z.number(),
newBase: z.number(),
})),
})
@@ -234,7 +234,7 @@ router.post('/config/:id/adjust-apply', async (req: AuthRequest, res: Response,
let adjusted = 0
for (const item of items) {
const socialBase = Math.min(Math.max(item.newSocialBase, config.baseMin), config.baseMax)
const socialBase = Math.min(Math.max(item.newBase, config.baseMin), config.baseMax)
// 关闭旧社保记录
await prisma.employeeSocialInsRecord.updateMany({
@@ -274,6 +274,59 @@ router.post('/config/:id/adjust-apply', async (req: AuthRequest, res: Response,
}
})
// 重置社保基数调整(撤销本次调整,重新来过)
router.post('/config/:id/reset-adjustment', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.socialInsuranceConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' })
if (!config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本尚未执行过基数调整,无需重置' })
// 恢复 adjustmentDone 标志
await prisma.socialInsuranceConfig.update({
where: { id },
data: { adjustmentDone: false },
})
// 删除该版本创建的所有社保记录变更
await prisma.employeeSocialInsRecord.deleteMany({
where: {
orgId,
changeType: 'ADJUST',
startMonth: config.effectiveFrom,
},
})
// 恢复员工社保基数为调整前(找到 adjustment 前的最后一条记录)
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
select: { id: true },
})
for (const emp of employees) {
const prevRecord = await prisma.employeeSocialInsRecord.findFirst({
where: { orgId, employeeId: emp.id, startMonth: { lt: config.effectiveFrom } },
orderBy: { startMonth: 'desc' },
})
await prisma.employee.update({
where: { id: emp.id },
data: {
socialInsBase: prevRecord?.base ?? null,
socialInsStartMonth: prevRecord?.startMonth ?? null,
},
})
}
res.json({ success: true, message: '社保基数调整已重置,可以重新调整' })
} catch (err) {
next(err)
}
})
// 社保计算(使用当前版本或指定月份版本)
const calcSchema = z.object({
base: z.number().positive(),
@@ -605,6 +658,59 @@ router.post('/housing-config/:id/adjust-apply', async (req: AuthRequest, res: Re
}
})
// 重置公积金基数调整(撤销本次调整,重新来过)
router.post('/housing-config/:id/reset-adjustment', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.housingFundConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '公积金配置版本不存在' })
if (!config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本尚未执行过基数调整,无需重置' })
// 恢复 adjustmentDone 标志
await prisma.housingFundConfig.update({
where: { id },
data: { adjustmentDone: false },
})
// 删除该版本创建的所有公积金记录变更
await prisma.employeeHousingFundRecord.deleteMany({
where: {
orgId,
changeType: 'ADJUST',
startMonth: config.effectiveFrom,
},
})
// 恢复员工公积金基数为调整前
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
select: { id: true },
})
for (const emp of employees) {
const prevRecord = await prisma.employeeHousingFundRecord.findFirst({
where: { orgId, employeeId: emp.id, startMonth: { lt: config.effectiveFrom } },
orderBy: { startMonth: 'desc' },
})
await prisma.employee.update({
where: { id: emp.id },
data: {
housingFundBase: prevRecord?.base ?? null,
housingFundStartMonth: prevRecord?.startMonth ?? null,
},
})
}
res.json({ success: true, message: '公积金基数调整已重置,可以重新调整' })
} catch (err) {
next(err)
}
})
// ========== 月度增减员 ==========
// 社保月度增减员
+36 -1
View File
@@ -2,7 +2,7 @@ import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import { terminationChecklistSchema } from '../schemas/termination.schema'
import { createTermination, createResignation, revokeTermination, getTerminations, getChecklistForReason, assessRisk, calculateCompensation } from '../services/termination.service'
import { createTermination, createResignation, revokeTermination, getTerminations, getChecklistForReason, assessRisk, batchTerminatePreview, batchTerminate } from '../services/termination.service'
import prisma from '../lib/prisma'
import { decrypt } from '../lib/crypto'
@@ -103,4 +103,39 @@ router.delete('/:id/revoke', authMiddleware, async (req: AuthRequest, res, next)
}
})
// 批量解聘预检
router.post('/batch/preview', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { items } = req.body as {
items: Array<{ employeeId: string; reason: string; terminationDate: string }>
}
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 items' } })
}
const results = await batchTerminatePreview(req.user!.orgId, items)
res.json({ success: true, data: { total: results.length, warnings: results.filter(r => r.warnings.length > 0).length, results } })
} catch (err) {
next(err)
}
})
// 批量解聘执行
router.post('/batch', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { items } = req.body as {
items: Array<{ employeeId: string; reason: string; terminationDate: string; compensation?: number }>
}
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 items' } })
}
const result = await batchTerminate(req.user!.orgId, req.user!.id, items)
for (const id of result.success) {
await auditLog(req, 'TERMINATE', 'EMPLOYEE', id, { batch: true })
}
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
export default router
+6
View File
@@ -27,3 +27,9 @@ export const resetPasswordSchema = z.object({
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
newPassword: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
})
export const verifyCodeSchema = z.object({
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
code: z.string().length(6, '验证码为6位数字'),
newPassword: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
})
+47 -2
View File
@@ -1,4 +1,5 @@
import OpenAI from 'openai'
import { searchKnowledge } from './rag.service'
const apiKey = process.env.DASHSCOPE_API_KEY || ''
const baseURL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
@@ -21,9 +22,20 @@ const SYSTEM_PROMPT = `你是一个专业的劳动用工合规顾问,精通中
- 回答简洁有力,避免冗长`
export async function chat(messages: { role: 'user' | 'assistant'; content: string }[], orgContext?: string) {
const lastUserMsg = messages.filter(m => m.role === 'user').pop()
let ragContext = ''
if (lastUserMsg) {
try {
const knowledge = await searchKnowledge(lastUserMsg.content, 3)
if (knowledge.length > 0) {
ragContext = `\n\n相关法律条文(RAG检索结果):\n${knowledge.join('\n\n')}`
}
} catch { /* RAG not available, continue without */ }
}
const systemMessage = orgContext
? `${SYSTEM_PROMPT}\n\n当前企业数据概览:\n${orgContext}`
: SYSTEM_PROMPT
? `${SYSTEM_PROMPT}\n\n当前企业数据概览:\n${orgContext}${ragContext}`
: `${SYSTEM_PROMPT}${ragContext}`
const response = await client.chat.completions.create({
model: 'qwen-plus',
@@ -38,6 +50,39 @@ export async function chat(messages: { role: 'user' | 'assistant'; content: stri
return response.choices[0]?.message?.content || ''
}
export async function* chatStream(messages: { role: 'user' | 'assistant'; content: string }[], orgContext?: string) {
const lastUserMsg = messages.filter(m => m.role === 'user').pop()
let ragContext = ''
if (lastUserMsg) {
try {
const knowledge = await searchKnowledge(lastUserMsg.content, 3)
if (knowledge.length > 0) {
ragContext = `\n\n相关法律条文(RAG检索结果):\n${knowledge.join('\n\n')}`
}
} catch { /* RAG not available, continue without */ }
}
const systemMessage = orgContext
? `${SYSTEM_PROMPT}\n\n当前企业数据概览:\n${orgContext}${ragContext}`
: `${SYSTEM_PROMPT}${ragContext}`
const stream = await client.chat.completions.create({
model: 'qwen-plus',
messages: [
{ role: 'system', content: systemMessage },
...messages,
],
temperature: 0.7,
max_tokens: 2000,
stream: true,
})
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content
if (delta) yield delta
}
}
export async function reviewContract(contractText: string) {
const prompt = `请审查以下劳动合同文本的合法性,逐条检查并标注风险等级(红/黄/绿),给出修改建议,最后给出合规评分(0-100分)。
+10 -1
View File
@@ -1,5 +1,5 @@
import prisma from '../lib/prisma'
import { encrypt, decrypt } from '../lib/crypto'
import { encrypt, decrypt, sha256 } from '../lib/crypto'
import { runRiskDetection } from './risk.service'
function daysBetween(a: Date, b: Date): number {
@@ -167,6 +167,14 @@ export async function getEmployeeDetail(orgId: string, id: string) {
}
export async function createEmployee(orgId: string, userId: string, data: any) {
const org = await prisma.organization.findUnique({ where: { id: orgId } })
if (org && org.maxEmployees > 0) {
const activeCount = await prisma.employee.count({ where: { orgId, status: 'ACTIVE' } })
if (activeCount >= org.maxEmployees) {
throw { code: 'PLAN_LIMIT', message: `当前套餐人数上限为 ${org.maxEmployees} 人,已达上限,请升级套餐` }
}
}
const hireDate = new Date(data.hireDate)
const hireMonth = dateToMonth(hireDate)
const salaryNum = Number(data.monthlySalary) || 0
@@ -185,6 +193,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
gender: data.gender,
phone: data.phone,
idCardNumber: data.idCardNumber ? encrypt(data.idCardNumber) : null,
idCardHash: data.idCardNumber ? sha256(data.idCardNumber) : null,
isPregnant: data.isPregnant || false,
isInMedicalPeriod: data.isInMedicalPeriod || false,
isWorkInjured: data.isWorkInjured || false,
+97
View File
@@ -0,0 +1,97 @@
import OpenAI from 'openai'
import prisma from '../lib/prisma'
const apiKey = process.env.DASHSCOPE_API_KEY || ''
const baseURL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
const client = new OpenAI({ apiKey, baseURL })
const EMBEDDING_MODEL = 'text-embedding-v2'
interface KnowledgeSeed {
title: string
content: string
source: string
category: string
}
const SEED_DATA: KnowledgeSeed[] = [
{ title: '劳动合同法 第十条 建立劳动关系应当订立书面合同', content: '建立劳动关系,应当订立书面劳动合同。已建立劳动关系,未同时订立书面劳动合同的,应当自用工之日起一个月内订立书面劳动合同。', source: '劳动合同法', category: '合同签订' },
{ title: '劳动合同法 第八十二条 未签书面合同双倍工资', content: '用人单位自用工之日起超过一个月不满一年未与劳动者订立书面劳动合同的,应当向劳动者每月支付二倍的工资。', source: '劳动合同法', category: '合同签订' },
{ title: '劳动合同法 第十四条 无固定期限劳动合同', content: '连续订立二次固定期限劳动合同续订的,应当订立无固定期限劳动合同。劳动者在该用人单位连续工作满十年的,应当订立无固定期限劳动合同。', source: '劳动合同法', category: '合同签订' },
{ title: '劳动合同法 第十九条 试用期期限', content: '三个月以上不满一年试用期不得超过一个月;一年以上不满三年不得超过二个月;三年以上不得超过六个月。同一用人单位与同一劳动者只能约定一次试用期。', source: '劳动合同法', category: '试用期' },
{ title: '劳动合同法 第二十条 试用期工资', content: '试用期工资不得低于本单位相同岗位最低档工资或劳动合同约定工资的百分之八十,并不得低于最低工资标准。', source: '劳动合同法', category: '试用期' },
{ title: '劳动合同法 第三十九条 过失性辞退', content: '严重违反规章制度、严重失职造成重大损害、被依法追究刑事责任等情形,用人单位可以解除劳动合同。', source: '劳动合同法', category: '解除终止' },
{ title: '劳动合同法 第四十条 无过失性辞退', content: '提前三十日书面通知或额外支付一个月工资后可解除:医疗期满不能从事原工作、不能胜任经培训仍不胜任、客观情况重大变化未能协商一致。', source: '劳动合同法', category: '解除终止' },
{ title: '劳动合同法 第四十一条 经济性裁员', content: '裁减二十人以上或占职工总数百分之十以上,需提前三十日向工会说明,方案报劳动行政部门。优先留用长期合同、无固定期限合同、家庭无其他就业人员。', source: '劳动合同法', category: '解除终止' },
{ title: '劳动合同法 第四十二条 不得解除的情形', content: '职业病、因工负伤丧失劳动能力、医疗期内、孕期产期哺乳期、连续工作满十五年距退休不足五年等情形,不得依第四十条第四十一条解除。', source: '劳动合同法', category: '解除终止' },
{ title: '劳动合同法 第四十七条 经济补偿计算', content: '每满一年支付一个月工资。六个月以上不满一年按一年计算;不满六个月支付半个月工资。月工资指解除前十二个月平均工资。高于社平工资三倍的按三倍计,年限最高十二年。', source: '劳动合同法', category: '经济补偿' },
{ title: '劳动合同法 第八十七条 违法解除赔偿金', content: '用人单位违反本法规定解除或终止劳动合同的,应当依照第四十七条经济补偿标准的二倍向劳动者支付赔偿金。', source: '劳动合同法', category: '经济补偿' },
{ title: '劳动法 第四十一条 加班时间上限', content: '一般每日不得超过一小时;特殊原因每日不得超过三小时,每月不得超过三十六小时。', source: '劳动法', category: '加班' },
{ title: '劳动法 第四十四条 加班工资标准', content: '延长工作时间不低于工资150%;休息日加班不能补休的不低于200%;法定休假日不低于300%。', source: '劳动法', category: '加班' },
{ title: '社会保险法 第五十八条 参保登记', content: '用人单位应当自用工之日起三十日内为其职工向社会保险经办机构申请办理社会保险登记。', source: '社会保险法', category: '社保' },
{ title: '劳动合同法 第八十二条 二倍工资起算', content: '用人单位自用工之日起满一年不与劳动者订立书面劳动合同的,视为用人单位与劳动者已订立无固定期限劳动合同。', source: '劳动合同法', category: '合同签订' },
]
let initialized = false
export async function ensureRAGTable() {
if (initialized) return
await prisma.$executeRaw`CREATE EXTENSION IF NOT EXISTS vector`
await prisma.$executeRaw`
CREATE TABLE IF NOT EXISTS rag_knowledge (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
source TEXT NOT NULL,
category TEXT NOT NULL,
embedding vector(1536),
created_at TIMESTAMPTZ DEFAULT now()
)
`
await prisma.$executeRaw`CREATE INDEX IF NOT EXISTS rag_knowledge_embedding_idx ON rag_knowledge USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)`
initialized = true
}
async function getEmbedding(text: string): Promise<number[]> {
const res = await client.embeddings.create({ model: EMBEDDING_MODEL, input: text })
return res.data[0]?.embedding || []
}
export async function seedKnowledgeBase() {
await ensureRAGTable()
const count = await prisma.$queryRaw`SELECT count(*)::int as c FROM rag_knowledge` as any
if (count[0]?.c > 0) return
for (let i = 0; i < SEED_DATA.length; i++) {
const item = SEED_DATA[i]
const embedding = await getEmbedding(`${item.title} ${item.content}`)
await prisma.$executeRaw`
INSERT INTO rag_knowledge (id, title, content, source, category, embedding)
VALUES (${`rag-${String(i).padStart(3, '0')}`}, ${item.title}, ${item.content}, ${item.source}, ${item.category}, ${embedding}::vector)
`
}
}
export async function searchKnowledge(query: string, topK: number = 3): Promise<string[]> {
await ensureRAGTable()
const queryEmbedding = await getEmbedding(query)
const results = await prisma.$queryRaw`
SELECT title, content, source, 1 - (embedding <=> ${queryEmbedding}::vector) as similarity
FROM rag_knowledge
ORDER BY embedding <=> ${queryEmbedding}::vector
LIMIT ${topK}
` as any[]
return results
.filter((r) => r.similarity > 0.3)
.map((r) => `${r.title}\n${r.content}\n(来源:${r.source},相似度:${(r.similarity * 100).toFixed(0)}%`)
}
export async function addKnowledge(title: string, content: string, source: string, category: string) {
await ensureRAGTable()
const embedding = await getEmbedding(`${title} ${content}`)
const id = `rag-${Date.now()}`
await prisma.$executeRaw`
INSERT INTO rag_knowledge (id, title, content, source, category, embedding)
VALUES (${id}, ${title}, ${content}, ${source}, ${category}, ${embedding}::vector)
`
return { id }
}
+146 -1
View File
@@ -1,6 +1,6 @@
import prisma from '../lib/prisma'
import { decrypt } from '../lib/crypto'
import { RiskAssessment } from '@prisma/client'
import { RiskAssessment, TerminationReason } from '@prisma/client'
function daysBetween(a: Date, b: Date): number {
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24))
@@ -376,3 +376,148 @@ export function calculateCompensation(hireDate: Date, leaveDate: Date, monthlyWa
return { years, remainingMonths, compMonths, totalPay: wage * compMonths, capped }
}
// 批量解聘:支持合规预检和执行
export interface BatchTerminatePreview {
employeeId: string
employeeName: string
department: string
reason: string
terminationDate: string
riskLevel: RiskAssessment | null
warnings: string[]
canTerminate: boolean
}
export async function batchTerminatePreview(
orgId: string,
items: Array<{ employeeId: string; reason: string; terminationDate: string }>
): Promise<BatchTerminatePreview[]> {
const results: BatchTerminatePreview[] = []
for (const item of items) {
const employee = await prisma.employee.findFirst({
where: { id: item.employeeId, orgId },
})
if (!employee) {
results.push({
employeeId: item.employeeId,
employeeName: '(未找到)',
department: '',
reason: item.reason,
terminationDate: item.terminationDate,
riskLevel: null,
warnings: ['员工不存在或无权操作'],
canTerminate: false,
})
continue
}
const { level, warnings } = assessRisk(employee, item.reason)
results.push({
employeeId: item.employeeId,
employeeName: employee.name,
department: employee.department,
reason: item.reason,
terminationDate: item.terminationDate,
riskLevel: level,
warnings,
canTerminate: warnings.length === 0,
})
}
return results
}
export interface BatchTerminateResult {
success: string[]
failed: Array<{ employeeId: string; reason: string }>
total: number
}
export async function batchTerminate(
orgId: string,
userId: string,
items: Array<{ employeeId: string; reason: string; terminationDate: string; compensation?: number }>
): Promise<BatchTerminateResult> {
const success: string[] = []
const failed: Array<{ employeeId: string; reason: string }> = []
for (const item of items) {
try {
const termDate = new Date(item.terminationDate)
const termMonth = dateToMonth(termDate)
// 校验:已有离职/解聘记录
const latestTerm = await prisma.terminationRecord.findFirst({
where: { employeeId: item.employeeId },
orderBy: { terminationDate: 'desc' },
})
const employee = await prisma.employee.findFirst({ where: { id: item.employeeId, orgId } })
if (!employee) {
failed.push({ employeeId: item.employeeId, reason: '员工不存在' })
continue
}
if (latestTerm && latestTerm.terminationDate >= employee.hireDate) {
failed.push({ employeeId: item.employeeId, reason: '该员工已有离职/解聘记录' })
continue
}
const { level } = assessRisk(employee, item.reason)
await prisma.terminationRecord.create({
data: {
orgId,
employeeId: item.employeeId,
type: 'TERMINATION',
reason: item.reason as TerminationReason,
terminationDate: termDate,
compensation: item.compensation || 0,
socialInsEndMonth: termMonth,
housingFundEndMonth: termMonth,
riskLevel: level,
checklist: {},
remark: '批量解聘',
createdBy: userId,
},
})
// 关闭社保和公积金
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: item.employeeId, endMonth: null },
data: { endMonth: termMonth },
})
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: item.employeeId, endMonth: null },
data: { endMonth: termMonth },
})
// 更新员工状态
const today = new Date()
today.setHours(0, 0, 0, 0)
const isResigned = termDate <= today
await prisma.employee.update({
where: { id: item.employeeId },
data: {
status: isResigned ? 'RESIGNED' : 'ACTIVE',
socialInsEndMonth: termMonth,
housingFundEndMonth: termMonth,
},
})
// 关闭风险项
await prisma.riskItem.updateMany({
where: { employeeId: item.employeeId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date() },
})
success.push(item.employeeId)
} catch (err: any) {
failed.push({ employeeId: item.employeeId, reason: err.message || '未知错误' })
}
}
return { success, failed, total: items.length }
}
+3 -2
View File
@@ -17,5 +17,6 @@
}
},
"include": ["src/**/*", "prisma/**/*"],
"exclude": ["node_modules", "dist"]
}
"exclude": ["node_modules", "dist"],
"ignoreDeprecations": "6.0"
}
+340
View File
@@ -12,12 +12,14 @@
"@tanstack/react-query": "^5.51.0",
"axios": "^1.7.0",
"clsx": "^2.1.0",
"jspdf": "^4.2.1",
"lucide-react": "^0.428.0",
"qrcode.react": "^4.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.52.0",
"react-router-dom": "^6.26.0",
"xlsx": "^0.18.5",
"zod": "^3.23.0",
"zustand": "^4.5.0"
},
@@ -279,6 +281,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz",
@@ -1298,6 +1309,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -1305,6 +1322,13 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmmirror.com/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/react": {
"version": "18.3.31",
"resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.31.tgz",
@@ -1326,6 +1350,13 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1347,6 +1378,15 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
@@ -1442,6 +1482,16 @@
"proxy-from-env": "^2.1.0"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.1",
"resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz",
@@ -1559,6 +1609,39 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmmirror.com/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
@@ -1606,6 +1689,15 @@
"node": ">=6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -1635,6 +1727,40 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
@@ -1695,6 +1821,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.4.12",
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.12.tgz",
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1840,6 +1976,17 @@
"node": ">= 6"
}
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmmirror.com/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz",
@@ -1850,6 +1997,12 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
@@ -1899,6 +2052,15 @@
"node": ">= 6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz",
@@ -2048,6 +2210,20 @@
"node": ">= 0.4"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -2061,6 +2237,12 @@
"node": ">= 6"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmmirror.com/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -2165,6 +2347,23 @@
"node": ">=6"
}
},
"node_modules/jspdf": {
"version": "4.2.1",
"resolved": "https://registry.npmmirror.com/jspdf/-/jspdf-4.2.1.tgz",
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -2347,6 +2546,22 @@
"node": ">= 6"
}
},
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/pako/-/pako-2.2.0.tgz",
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "(MIT AND Zlib)"
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz",
@@ -2354,6 +2569,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@@ -2596,6 +2818,16 @@
],
"license": "MIT"
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmmirror.com/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz",
@@ -2702,6 +2934,13 @@
"node": ">=8.10.0"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.12.tgz",
@@ -2735,6 +2974,16 @@
"node": ">=0.10.0"
}
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz",
@@ -2833,6 +3082,28 @@
"node": ">=0.10.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmmirror.com/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.1.tgz",
@@ -2869,6 +3140,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmmirror.com/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.4.19.tgz",
@@ -2907,6 +3188,16 @@
"node": ">=14.0.0"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz",
@@ -3059,6 +3350,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz",
@@ -3119,6 +3420,45 @@
}
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
+15 -13
View File
@@ -9,27 +9,29 @@
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0",
"axios": "^1.7.0",
"zustand": "^4.5.0",
"@tanstack/react-query": "^5.51.0",
"react-hook-form": "^7.52.0",
"@hookform/resolvers": "^3.9.0",
"zod": "^3.23.0",
"@tanstack/react-query": "^5.51.0",
"axios": "^1.7.0",
"clsx": "^2.1.0",
"jspdf": "^4.2.1",
"lucide-react": "^0.428.0",
"qrcode.react": "^4.0.1",
"clsx": "^2.1.0"
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.52.0",
"react-router-dom": "^6.26.0",
"xlsx": "^0.18.5",
"zod": "^3.23.0",
"zustand": "^4.5.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.5.0",
"vite": "^5.4.0",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0"
"tailwindcss": "^3.4.0",
"typescript": "^5.5.0",
"vite": "^5.4.0"
}
}
+15 -3
View File
@@ -1,7 +1,9 @@
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { Building2, AlertCircle, ChevronDown } from 'lucide-react'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../../store/authStore'
import api from '../../lib/api'
import clsx from 'clsx'
const tabs = [
@@ -19,6 +21,16 @@ export default function TopNav() {
const { user, logout } = useAuthStore()
const [menuOpen, setMenuOpen] = useState(false)
const { data: dashboardData } = useQuery<any>({
queryKey: ['dashboard'],
queryFn: async () => {
const res = await api.get('/dashboard') as any
return res.data
},
refetchInterval: 60000,
})
const riskCount = dashboardData?.riskSummary?.pending || 0
return (
<header className="sticky top-0 z-50 bg-white border-b border-gray-200">
<div className="max-w-content mx-auto px-4 h-14 flex items-center gap-4">
@@ -40,9 +52,9 @@ export default function TopNav() {
)}
>
{tab.label}
{tab.path === '/' && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-danger text-white text-xs rounded-full flex items-center justify-center hidden">
0
{tab.path === '/' && riskCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-4 h-4 px-1 bg-danger text-white text-xs rounded-full flex items-center justify-center">
{riskCount > 99 ? '99+' : riskCount}
</span>
)}
</Link>
+8 -2
View File
@@ -8,9 +8,10 @@ interface ModalProps {
title?: string
children: ReactNode
className?: string
size?: 'sm' | 'md' | 'lg' | 'xl'
}
export default function Modal({ open, onClose, title, children, className }: ModalProps) {
export default function Modal({ open, onClose, title, children, className, size = 'md' }: ModalProps) {
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden'
@@ -27,7 +28,12 @@ export default function Modal({ open, onClose, title, children, className }: Mod
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/40" onClick={onClose} />
<div className={clsx('relative bg-white rounded-lg shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto', className)}>
<div className={clsx('relative bg-white rounded-lg shadow-xl w-full max-h-[90vh] overflow-y-auto', {
'max-w-md': size === 'sm',
'max-w-lg': size === 'md',
'max-w-2xl': size === 'lg',
'max-w-4xl': size === 'xl',
}, className)}>
{title && (
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-200">
<h3 className="font-medium text-gray-900 text-sm">{title}</h3>
+81 -4
View File
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from 'react'
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2 } from 'lucide-react'
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic } from 'lucide-react'
import api from '../lib/api'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
@@ -65,12 +66,40 @@ function ChatTab() {
])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [recording, setRecording] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const recognitionRef = useRef<any>(null)
useEffect(() => {
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
}, [messages])
const toggleVoice = () => {
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
if (!SpeechRecognition) {
alert('当前浏览器不支持语音输入,请使用 Chrome 或 Edge')
return
}
if (recording) {
recognitionRef.current?.stop()
setRecording(false)
return
}
const recognition = new SpeechRecognition()
recognition.lang = 'zh-CN'
recognition.continuous = false
recognition.interimResults = false
recognition.onresult = (event: any) => {
const transcript = event.results[0]?.[0]?.transcript || ''
setInput((prev) => prev + transcript)
}
recognition.onerror = () => setRecording(false)
recognition.onend = () => setRecording(false)
recognition.start()
recognitionRef.current = recognition
setRecording(true)
}
const send = async (text?: string) => {
const content = text || input.trim()
if (!content || loading) return
@@ -81,10 +110,55 @@ function ChatTab() {
setLoading(true)
try {
const res = await api.post('/ai/chat', { messages: newMessages }) as any
setMessages([...newMessages, { role: 'assistant', content: res.data.reply }])
const token = useAuthStore.getState().accessToken
const response = await fetch('/api/v1/ai/chat-stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ messages: newMessages }),
})
if (!response.ok) {
const errData = await response.json().catch(() => null)
throw new Error(errData?.error?.message || '请求失败')
}
const reader = response.body?.getReader()
const decoder = new TextDecoder()
let accumulated = ''
let buffer = ''
if (reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (data === '[DONE]') continue
try {
const parsed = JSON.parse(data)
if (parsed.delta) {
accumulated += parsed.delta
setMessages([...newMessages, { role: 'assistant', content: accumulated }])
}
} catch {
// ignore parse errors
}
}
}
}
}
if (!accumulated) {
setMessages([...newMessages, { role: 'assistant', content: '(无回复内容)' }])
}
} catch (err: any) {
setMessages([...newMessages, { role: 'assistant', content: `抱歉,出错了:${err.response?.data?.error?.message || '请稍后重试'}` }])
setMessages([...newMessages, { role: 'assistant', content: `抱歉,出错了:${err.message || '请稍后重试'}` }])
} finally {
setLoading(false)
}
@@ -128,6 +202,9 @@ function ChatTab() {
placeholder="输入问题..."
disabled={loading}
/>
<Button variant="secondary" onClick={toggleVoice} disabled={loading} className={recording ? 'text-danger' : ''}>
<Mic className="w-4 h-4" />
</Button>
<Button onClick={() => send()} disabled={loading || !input.trim()}>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
</Button>
+9 -1
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus } from 'lucide-react'
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus, AlertCircle } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -44,6 +44,14 @@ export default function Dashboard() {
},
})
const { data: expiringContracts } = useQuery<any>({
queryKey: ['expiring-contracts'],
queryFn: async () => {
const res = await api.get('/roster/contracts/expiring') as any
return res.data
},
})
const resolveMutation = useMutation({
mutationFn: (id: string) => api.patch(`/dashboard/todos/${id}/resolve`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
+63 -5
View File
@@ -54,6 +54,10 @@ export default function Money() {
function BatchManager() {
const queryClient = useQueryClient()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [monthFrom, setMonthFrom] = useState('')
const [monthTo, setMonthTo] = useState('')
const [filterStatus, setFilterStatus] = useState<string>('')
const [filterType, setFilterType] = useState<string>('')
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [createType, setCreateType] = useState<'REGULAR' | 'TERMINATION' | 'BONUS' | 'SEVERANCE'>('REGULAR')
@@ -71,9 +75,15 @@ function BatchManager() {
})
const { data: batches, isLoading } = useQuery<any[]>({
queryKey: ['batches', month],
queryKey: ['batches', month, monthFrom, monthTo, filterStatus, filterType],
queryFn: async () => {
const res = await api.get('/payroll2/batches', { params: { month } }) as any
const params: any = {}
if (month && !monthFrom && !monthTo) params.month = month
if (monthFrom) params.monthFrom = monthFrom
if (monthTo) params.monthTo = monthTo
if (filterStatus) params.status = filterStatus
if (filterType) params.type = filterType
const res = await api.get('/payroll2/batches', { params }) as any
return res.data
},
})
@@ -104,6 +114,14 @@ function BatchManager() {
},
})
const renameBatchMutation = useMutation({
mutationFn: ({ batchId, name }: { batchId: string; name: string }) =>
api.put(`/payroll2/batches/${batchId}/name`, { name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batches'] })
},
})
if (selectedBatchId) {
return <BatchDetail batchId={selectedBatchId} onBack={() => setSelectedBatchId(null)} />
}
@@ -118,9 +136,36 @@ function BatchManager() {
</div>
)}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3">
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-48" />
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1">
<span className="text-xs text-gray-500"></span>
<Input type="month" value={monthFrom} onChange={(e) => { setMonthFrom(e.target.value); setMonth('') }} className="w-36" />
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-gray-500"></span>
<Input type="month" value={monthTo} onChange={(e) => { setMonthTo(e.target.value); setMonth('') }} className="w-36" />
</div>
{!monthFrom && !monthTo && (
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-36" placeholder="单月" />
)}
<Select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="w-28">
<option value=""></option>
<option value="DRAFT">稿</option>
<option value="ARCHIVED"></option>
</Select>
<Select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="w-28">
<option value=""></option>
<option value="REGULAR"></option>
<option value="TERMINATION"></option>
<option value="BONUS"></option>
<option value="SEVERANCE"></option>
</Select>
{(monthFrom || monthTo || filterStatus || filterType) && (
<button onClick={() => { setMonthFrom(''); setMonthTo(''); setFilterStatus(''); setFilterType(''); setMonth(new Date().toISOString().slice(0, 7)) }} className="text-xs text-gray-400 hover:text-primary">
</button>
)}
{batches && batches.length > 0 && (
<span className="text-xs text-gray-500 whitespace-nowrap flex-shrink-0">{batches.length} </span>
)}
@@ -219,6 +264,19 @@ function BatchManager() {
) : (
<>
<span className="px-2 py-0.5 rounded text-xs bg-amber-50 text-warning">稿</span>
<button
onClick={(e) => {
e.stopPropagation()
const newName = prompt('请输入新的批次名称:', batch.name)
if (newName && newName !== batch.name) {
renameBatchMutation.mutate({ batchId: batch.id, name: newName })
}
}}
className="p-1 rounded hover:bg-blue-50 text-gray-400 hover:text-primary transition-colors"
title="重命名批次"
>
<SettingsIcon className="w-3.5 h-3.5" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
+390 -9
View File
@@ -1,6 +1,7 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus } from 'lucide-react'
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, QrCode } from 'lucide-react'
import { QRCodeSVG } from 'qrcode.react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -12,6 +13,14 @@ import Pagination from '../components/ui/Pagination'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const terminateReasonMap: Record<string, string> = {
NEGOTIATED: '协商解除',
FAULT: '员工过错',
NONFAULT: '非过错解除',
LAYOFF: '经济性裁员',
EXPIRED: '合同到期不续签',
}
type DetailTab = 'basic' | 'contract' | 'payslip' | 'overtime' | 'disciplinary' | 'attendance' | 'training' | 'performance' | 'termination' | 'attachment' | 'evidence'
export default function Roster() {
@@ -28,16 +37,34 @@ export default function Roster() {
const [showDeptModal, setShowDeptModal] = useState(false)
const [deptEmployee, setDeptEmployee] = useState<any>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [pageSize, setPageSize] = useState(20)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showBatchRenewModal, setShowBatchRenewModal] = useState(false)
const [batchRenewYears, setBatchRenewYears] = useState(3)
const [showRenewPreview, setShowRenewPreview] = useState(false)
const [previewData, setPreviewData] = useState<any>(null)
const [filterStatus, setFilterStatus] = useState('')
const [filterContractStatus, setFilterContractStatus] = useState('')
const [showBatchTerminateModal, setShowBatchTerminateModal] = useState(false)
const [batchTerminateDate, setBatchTerminateDate] = useState(() => new Date().toISOString().slice(0, 10))
const [batchTerminateReason, setBatchTerminateReason] = useState('NEGOTIATED')
const [terminatePreviewData, setTerminatePreviewData] = useState<any>(null)
const { data: employees, isLoading } = useQuery<any[]>({
queryKey: ['roster'],
const { data: rosterData, isLoading } = useQuery<any>({
queryKey: ['roster', page, pageSize, search, filterStatus, filterContractStatus],
queryFn: async () => {
const res = await api.get('/roster') as any
return res.data
const params: any = { page, pageSize }
if (search) params.search = search
if (filterStatus) params.status = filterStatus
if (filterContractStatus) params.contractStatus = filterContractStatus
const res = await api.get('/roster', { params }) as any
return res
},
})
const employees = rosterData?.data || []
const pagination = rosterData?.pagination || { page, pageSize, total: 0, totalPages: 0 }
const addMutation = useMutation({
mutationFn: (data: any) => api.post('/employees', data),
onSuccess: () => {
@@ -95,6 +122,72 @@ export default function Roster() {
},
})
const batchRenewMutation = useMutation({
mutationFn: (data: { contractIds: string[]; years: number }) => api.post('/employees/contracts/batch-renew', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
setShowBatchRenewModal(false)
setShowRenewPreview(false)
setSelectedIds(new Set())
setPreviewData(null)
},
})
const previewRenewMutation = useMutation({
mutationFn: (contractIds: string[]) => api.post('/employees/contracts/preview-renew', { contractIds }),
onSuccess: (data: any) => {
setPreviewData(data.data)
setShowRenewPreview(true)
},
})
const previewTerminateMutation = useMutation({
mutationFn: (items: Array<{ employeeId: string; reason: string; terminationDate: string }>) =>
api.post('/termination/batch/preview', { items }),
onSuccess: (data: any) => {
setTerminatePreviewData(data.data)
},
})
const batchTerminateMutation = useMutation({
mutationFn: (items: Array<{ employeeId: string; reason: string; terminationDate: string }>) =>
api.post('/termination/batch', { items }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
setShowBatchTerminateModal(false)
setTerminatePreviewData(null)
setSelectedIds(new Set())
},
})
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = () => {
if (selectedIds.size === employees.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(employees.map((e: any) => e.id)))
}
}
const clearFilters = () => {
setSearch('')
setFilterStatus('')
setFilterContractStatus('')
setPage(1)
}
const hasActiveFilters = search || filterStatus || filterContractStatus
const filtered = employees?.filter((e: any) =>
!search || e.name.includes(search) || e.department.includes(search)
) || []
@@ -112,9 +205,45 @@ export default function Roster() {
<Input
placeholder="搜索姓名/部门"
value={search}
onChange={(e) => setSearch(e.target.value)}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
className="!w-64 shrink-0"
/>
<select
value={filterStatus}
onChange={(e) => { setFilterStatus(e.target.value); setPage(1) }}
className="text-xs border rounded px-2 py-1.5"
>
<option value=""></option>
<option value="ACTIVE"></option>
<option value="PRE_HIRE"></option>
<option value="RESIGNED"></option>
</select>
<select
value={filterContractStatus}
onChange={(e) => { setFilterContractStatus(e.target.value); setPage(1) }}
className="text-xs border rounded px-2 py-1.5"
>
<option value=""></option>
<option value="active"></option>
<option value="expiring"></option>
<option value="expired"></option>
<option value="unsigned"></option>
<option value="unsigned_over_30">(30)</option>
<option value="unsigned_over_year">()</option>
</select>
{hasActiveFilters && (
<button onClick={clearFilters} className="text-xs text-gray-400 hover:text-gray-600"></button>
)}
{selectedIds.size > 0 && (
<>
<Button variant="secondary" onClick={() => setShowBatchRenewModal(true)} className="shrink-0">
<Check className="w-4 h-4 mr-1" />({selectedIds.size})
</Button>
<Button variant="danger" onClick={() => setShowBatchTerminateModal(true)} className="shrink-0">
<UserX className="w-4 h-4 mr-1" />({selectedIds.size})
</Button>
</>
)}
<Button onClick={() => setShowAddModal(true)} className="shrink-0">
<Plus className="w-4 h-4 mr-1" />
</Button>
@@ -127,11 +256,20 @@ export default function Roster() {
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : (
<Card>
<Pagination page={page} pageSize={pageSize} total={filtered.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
<Pagination
page={pagination.page}
pageSize={pagination.pageSize}
total={pagination.total}
onPageChange={(p) => setPage(p)}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
/>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-gray-500">
<th className="py-2 px-3 text-left w-8">
<input type="checkbox" checked={employees.length > 0 && selectedIds.size === employees.length} onChange={toggleSelectAll} />
</th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
@@ -139,6 +277,7 @@ export default function Roster() {
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-center"></th>
<th className="py-2 px-3 text-center"></th>
<th className="py-2 px-3 text-center"></th>
@@ -148,12 +287,15 @@ export default function Roster() {
</tr>
</thead>
<tbody>
{paged.map((e: any) => (
{employees.map((e: any) => (
<tr
key={e.id}
className="border-b last:border-0 cursor-pointer hover:bg-gray-50"
onClick={() => setSelectedId(e.id)}
>
<td className="py-2 px-3" onClick={(ev) => ev.stopPropagation()}>
<input type="checkbox" checked={selectedIds.has(e.id)} onChange={() => toggleSelect(e.id)} />
</td>
<td className="py-2 px-3 font-medium">{e.name}</td>
<td className="py-2 px-3 text-gray-500">{e.department}</td>
<td className="py-2 px-3">
@@ -192,6 +334,21 @@ export default function Roster() {
return <span className={`px-2 py-0.5 rounded text-xs ${style}`}>{e.contractStatusText || '无合同'}</span>
})()}
</td>
<td className="py-2 px-3 text-gray-500">
{(() => {
const endDate = e.latestContract?.endDate
if (!endDate) return <span className="text-gray-300"></span>
const end = new Date(endDate)
const today = new Date()
today.setHours(0, 0, 0, 0)
end.setHours(0, 0, 0, 0)
const diffDays = Math.ceil((end.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays < 0) return <span className="text-danger text-xs"></span>
if (diffDays <= 30) return <span className="text-danger font-medium text-xs">{end.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })} ({diffDays})</span>
if (diffDays <= 90) return <span className="text-amber-600 text-xs">{end.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })} ({diffDays})</span>
return <span className="text-xs">{end.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })}</span>
})()}
</td>
<td className="py-2 px-3 text-center">
{e.counts?.disciplinaryRecords ? (
<span className="text-danger font-medium">{e.counts.disciplinaryRecords}</span>
@@ -323,6 +480,201 @@ export default function Roster() {
error={deptChangeMutation.error as any}
/>
)}
{showBatchRenewModal && (
<Modal open={showBatchRenewModal} onClose={() => { setShowBatchRenewModal(false); setPreviewData(null); }} title="批量续签">
<div className="space-y-3">
{!previewData ? (
<>
<p className="text-xs text-gray-500"> {selectedIds.size} </p>
<div>
<Label></Label>
<Select value={String(batchRenewYears)} onChange={(e) => setBatchRenewYears(Number(e.target.value))}>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="5">5</option>
</Select>
</div>
{previewRenewMutation.error && (
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
{(previewRenewMutation.error as any)?.response?.data?.error?.message || '预检失败'}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => { setShowBatchRenewModal(false); setPreviewData(null); }}></Button>
<Button
onClick={() => {
const contractIds = employees
.filter((e: any) => selectedIds.has(e.id) && e.latestContract?.id)
.map((e: any) => e.latestContract.id)
if (contractIds.length === 0) {
alert('所选员工没有可续签的合同')
return
}
previewRenewMutation.mutate(contractIds)
}}
disabled={previewRenewMutation.isPending}
>
{previewRenewMutation.isPending ? '预检中...' : '合规预检'}
</Button>
</div>
</>
) : (
<>
<div className="flex items-center justify-between">
<p className="text-xs font-medium"></p>
<span className={`text-xs px-2 py-0.5 rounded ${previewData.warnings > 0 ? 'bg-amber-100 text-amber-700' : 'bg-green-100 text-green-700'}`}>
{previewData.warnings > 0 ? `${previewData.warnings} 项风险提示` : '全部通过'}
</span>
</div>
<div className="max-h-64 overflow-y-auto space-y-2">
{previewData.results.map((r: any) => (
<div key={r.contractId} className={`p-2 rounded text-xs ${r.warning ? 'bg-amber-50 border border-amber-200' : 'bg-gray-50'}`}>
<div className="flex items-center justify-between">
<span className="font-medium">{r.employeeName}</span>
<span className="text-gray-400">{r.department}</span>
</div>
<div className="text-gray-500 mt-1">
{r.renewalCount} · {r.yearsSinceHire}
</div>
{r.warning && (
<div className="mt-1 text-amber-600">{r.warning}</div>
)}
<div className="mt-1 text-gray-500">{r.suggestion}</div>
</div>
))}
</div>
{previewData.warnings > 0 && (
<div className="px-3 py-2 rounded-md bg-amber-50 text-amber-700 text-xs">
</div>
)}
{batchRenewMutation.error && (
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
{(batchRenewMutation.error as any)?.response?.data?.error?.message || '续签失败'}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setPreviewData(null)}></Button>
<Button
onClick={() => {
const contractIds = employees
.filter((e: any) => selectedIds.has(e.id) && e.latestContract?.id)
.map((e: any) => e.latestContract.id)
batchRenewMutation.mutate({ contractIds, years: batchRenewYears })
}}
disabled={batchRenewMutation.isPending}
>
{batchRenewMutation.isPending ? '续签中...' : `确认续签 ${selectedIds.size}`}
</Button>
</div>
</>
)}
</div>
</Modal>
)}
{showBatchTerminateModal && (
<Modal open={showBatchTerminateModal} onClose={() => { setShowBatchTerminateModal(false); setTerminatePreviewData(null); }} title="批量解聘" size="lg">
<div className="space-y-3">
{!terminatePreviewData ? (
<>
<p className="text-xs text-gray-500"> {selectedIds.size} </p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="date" value={batchTerminateDate} onChange={(e) => setBatchTerminateDate(e.target.value)} />
</div>
<div>
<Label></Label>
<Select value={batchTerminateReason} onChange={(e) => setBatchTerminateReason(e.target.value)}>
<option value="NEGOTIATED"></option>
<option value="LAYOFF"></option>
<option value="NONFAULT"></option>
<option value="FAULT"></option>
<option value="EXPIRED"></option>
</Select>
</div>
</div>
{previewTerminateMutation.error && (
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
{(previewTerminateMutation.error as any)?.response?.data?.error?.message || '预检失败'}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => { setShowBatchTerminateModal(false); setTerminatePreviewData(null); }}></Button>
<Button
onClick={() => {
const items = Array.from(selectedIds).map(id => ({
employeeId: id,
reason: batchTerminateReason,
terminationDate: batchTerminateDate,
}))
previewTerminateMutation.mutate(items)
}}
disabled={previewTerminateMutation.isPending}
>
{previewTerminateMutation.isPending ? '预检中...' : '合规预检'}
</Button>
</div>
</>
) : (
<>
<div className="flex items-center justify-between">
<p className="text-xs font-medium"></p>
<span className={`text-xs px-2 py-0.5 rounded ${terminatePreviewData.warnings > 0 ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}`}>
{terminatePreviewData.warnings > 0 ? `${terminatePreviewData.warnings} 项风险提示` : '全部通过'}
</span>
</div>
<div className="max-h-64 overflow-y-auto space-y-2">
{terminatePreviewData.results.map((r: any) => (
<div key={r.employeeId} className={`p-2 rounded text-xs ${r.warnings.length > 0 ? 'bg-red-50 border border-red-200' : 'bg-gray-50'}`}>
<div className="flex items-center justify-between">
<span className="font-medium">{r.employeeName}</span>
<span className="text-gray-400">{r.department}</span>
</div>
<div className="text-gray-500 mt-1">
{terminateReasonMap[r.reason] || r.reason} · {r.terminationDate}
</div>
{r.warnings.length > 0 && r.warnings.map((w: string, i: number) => (
<div key={i} className="mt-1 text-red-600">{w}</div>
))}
</div>
))}
</div>
{terminatePreviewData.warnings > 0 && (
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
</div>
)}
{batchTerminateMutation.error && (
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
{(batchTerminateMutation.error as any)?.response?.data?.error?.message || '解聘失败'}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setTerminatePreviewData(null)}></Button>
<Button
variant="danger"
onClick={() => {
const items = Array.from(selectedIds).map(id => ({
employeeId: id,
reason: batchTerminateReason,
terminationDate: batchTerminateDate,
}))
batchTerminateMutation.mutate(items)
}}
disabled={batchTerminateMutation.isPending}
>
{batchTerminateMutation.isPending ? '解聘中...' : `确认解聘 ${selectedIds.size}`}
</Button>
</div>
</>
)}
</div>
</Modal>
)}
</div>
)
}
@@ -583,6 +935,35 @@ function BasicInfo({ profile }: { profile: any }) {
<p className="text-xs text-amber-600 mt-2"> </p>
)}
</div>
{/* 员工端二维码 */}
{!editing && profile.phone && (
<div className="mt-4 pt-4 border-t">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-medium text-gray-600"></h3>
<Button size="sm" variant="secondary" onClick={() => {
const url = `${window.location.origin}/portal/login`
navigator.clipboard?.writeText(url)
}}>
</Button>
</div>
<div className="flex items-center gap-4">
<div className="bg-white p-3 rounded-lg border">
<QRCodeSVG
value={`${window.location.origin}/portal/login`}
size={120}
level="M"
/>
</div>
<div className="text-xs text-gray-500 space-y-1">
<p>使</p>
<p></p>
<p className="text-gray-400">{window.location.origin}/portal/login</p>
</div>
</div>
</div>
)}
</Card>
)
}
+305 -2
View File
@@ -1,7 +1,8 @@
import { useState, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Building2, Users, CreditCard, Plus, Bell } from 'lucide-react'
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet } from 'lucide-react'
import api from '../lib/api'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
@@ -9,7 +10,7 @@ import Modal from '../components/ui/Modal'
export default function Settings() {
const queryClient = useQueryClient()
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications'>('org')
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications' | 'import'>('org')
const { data: orgData } = useQuery<any>({
queryKey: ['org-settings'],
@@ -37,6 +38,7 @@ export default function Settings() {
{ key: 'users' as const, label: '用户管理', icon: Users },
{ key: 'plan' as const, label: '套餐', icon: CreditCard },
{ key: 'notifications' as const, label: '通知设置', icon: Bell },
{ key: 'import' as const, label: '数据导入', icon: FileSpreadsheet },
]
return (
@@ -67,6 +69,7 @@ export default function Settings() {
{activeSection === 'users' && <UserSettings usersData={usersData} />}
{activeSection === 'plan' && <PlanSettings orgData={orgData} />}
{activeSection === 'notifications' && <NotificationSettings />}
{activeSection === 'import' && <ImportSettings />}
</div>
)
}
@@ -109,6 +112,34 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
{saving ? '保存中...' : '保存'}
</Button>
</div>
<div className="mt-6 pt-4 border-t">
<h3 className="text-xs font-medium text-gray-600 mb-3"></h3>
<p className="text-xs text-gray-400 mb-3"> JSON </p>
<Button
variant="secondary"
size="sm"
onClick={async () => {
try {
const token = useAuthStore.getState().accessToken
const res = await fetch('/api/v1/export/all', {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `export-${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
} catch {
alert('导出失败')
}
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
</div>
</Card>
)
}
@@ -381,3 +412,275 @@ function NotificationSettings() {
</div>
)
}
function ImportSettings() {
const [importType, setImportType] = useState<'init' | 'monthly'>('init')
return (
<div className="space-y-3">
<div className="flex gap-1 border-b">
<button
onClick={() => setImportType('init')}
className={`px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${importType === 'init' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
>
</button>
<button
onClick={() => setImportType('monthly')}
className={`px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${importType === 'monthly' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
>
</button>
</div>
{importType === 'init' ? <InitImport /> : <MonthlyImport />}
</div>
)
}
function InitImport() {
const queryClient = useQueryClient()
const [file, setFile] = useState<File | null>(null)
const [result, setResult] = useState<any>(null)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
const handleUpload = async () => {
if (!file) return
setUploading(true)
setError('')
setResult(null)
try {
const token = useAuthStore.getState().accessToken
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/v1/import/excel', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
})
const data = await res.json()
if (!data.success) {
setError(data.error?.message || '导入失败')
} else {
setResult(data.data)
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
}
} catch (e: any) {
setError(e?.message || '上传失败')
} finally {
setUploading(false)
}
}
const handleDownloadTemplate = async () => {
try {
const token = useAuthStore.getState().accessToken
const res = await fetch('/api/v1/import/template', {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'import-template.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch {
setError('下载模板失败')
}
}
return (
<div className="space-y-3">
<Card>
<div className="space-y-4">
<div>
<h3 className="text-xs font-medium text-gray-700 mb-1"></h3>
<p className="text-xs text-gray-400">使//</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={handleDownloadTemplate}>
<Download className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
<FileSpreadsheet className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<input type="file" accept=".xlsx,.xls" onChange={(e) => { setFile(e.target.files?.[0] || null); setResult(null); setError('') }} className="hidden" id="import-file-init" />
<label htmlFor="import-file-init" className="cursor-pointer text-xs text-primary hover:underline">
{file ? file.name : '点击选择 Excel 文件'}
</label>
</div>
{error && <div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">{error}</div>}
{result && (
<div className="px-3 py-2 rounded-md bg-green-50 text-green-700 text-xs space-y-1">
<div className="font-medium"></div>
<div>{result.employees} </div>
<div>{result.contracts} </div>
{result.overtime > 0 && <div>{result.overtime} </div>}
{result.disciplinary > 0 && <div>{result.disciplinary} </div>}
{result.attendance > 0 && <div>{result.attendance} </div>}
{result.errors?.length > 0 && (
<div className="mt-2 pt-2 border-t border-green-200">
<div className="font-medium text-amber-600">{result.errors.length}</div>
{result.errors.slice(0, 10).map((e: string, i: number) => (<div key={i} className="text-amber-600">{e}</div>))}
{result.errors.length > 10 && <div className="text-amber-600">... {result.errors.length - 10} </div>}
</div>
)}
</div>
)}
<div className="flex justify-end">
<Button onClick={handleUpload} disabled={!file || uploading}>
<Upload className="w-4 h-4 mr-1" />{uploading ? '导入中...' : '开始导入'}
</Button>
</div>
</div>
</Card>
<Card>
<div className="text-xs text-gray-500 space-y-1">
<div className="font-medium text-gray-700 mb-1">Sheet </div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b></div>
<div className="mt-2 text-gray-400"> Sheet </div>
</div>
</Card>
</div>
)
}
function MonthlyImport() {
const queryClient = useQueryClient()
const [file, setFile] = useState<File | null>(null)
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [result, setResult] = useState<any>(null)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
const handleUpload = async () => {
if (!file) return
setUploading(true)
setError('')
setResult(null)
try {
const token = useAuthStore.getState().accessToken
const formData = new FormData()
formData.append('file', file)
formData.append('month', month)
const res = await fetch('/api/v1/import/monthly', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
})
const data = await res.json()
if (!data.success) {
setError(data.error?.message || '导入失败')
} else {
setResult(data.data)
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
}
} catch (e: any) {
setError(e?.message || '上传失败')
} finally {
setUploading(false)
}
}
const handleDownloadTemplate = async () => {
try {
const token = useAuthStore.getState().accessToken
const res = await fetch('/api/v1/import/monthly-template', {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'monthly-import-template.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch {
setError('下载模板失败')
}
}
return (
<div className="space-y-3">
<Card>
<div className="space-y-4">
<div>
<h3 className="text-xs font-medium text-gray-700 mb-1"></h3>
<p className="text-xs text-gray-400">/</p>
</div>
<div className="flex items-center gap-3">
<div>
<Label></Label>
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="!w-40" />
</div>
<div className="pt-5">
<Button variant="secondary" size="sm" onClick={handleDownloadTemplate}>
<Download className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
<FileSpreadsheet className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<input type="file" accept=".xlsx,.xls" onChange={(e) => { setFile(e.target.files?.[0] || null); setResult(null); setError('') }} className="hidden" id="import-file-monthly" />
<label htmlFor="import-file-monthly" className="cursor-pointer text-xs text-primary hover:underline">
{file ? file.name : '点击选择 Excel 文件'}
</label>
</div>
{error && <div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">{error}</div>}
{result && (
<div className="px-3 py-2 rounded-md bg-green-50 text-green-700 text-xs space-y-1">
<div className="font-medium">{result.month} </div>
{result.attendance > 0 && <div>{result.attendance} </div>}
{result.overtime > 0 && <div>{result.overtime} </div>}
{result.salaryChanges > 0 && <div>{result.salaryChanges} </div>}
{result.socialInsChanges > 0 && <div>{result.socialInsChanges} </div>}
{result.housingFundChanges > 0 && <div>{result.housingFundChanges} </div>}
{result.errors?.length > 0 && (
<div className="mt-2 pt-2 border-t border-green-200">
<div className="font-medium text-amber-600">{result.errors.length}</div>
{result.errors.slice(0, 10).map((e: string, i: number) => (<div key={i} className="text-amber-600">{e}</div>))}
{result.errors.length > 10 && <div className="text-amber-600">... {result.errors.length - 10} </div>}
</div>
)}
</div>
)}
<div className="flex justify-end">
<Button onClick={handleUpload} disabled={!file || uploading}>
<Upload className="w-4 h-4 mr-1" />{uploading ? '导入中...' : '开始导入'}
</Button>
</div>
</div>
</Card>
<Card>
<div className="text-xs text-gray-500 space-y-1">
<div className="font-medium text-gray-700 mb-1">Sheet </div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b>//</div>
<div>- <b></b>//</div>
<div className="mt-2 text-gray-400"> Sheet </div>
</div>
</Card>
</div>
)
}
+60 -13
View File
@@ -18,6 +18,7 @@ export default function SocialInsurance() {
const [showAdjust, setShowAdjust] = useState(false)
const [adjustData, setAdjustData] = useState<any>(null)
const [editItems, setEditItems] = useState<Record<string, number>>({})
const [editingId, setEditingId] = useState<string | null>(null)
const [monthlyMonth, setMonthlyMonth] = useState(new Date().toISOString().slice(0, 7))
const [newVersion, setNewVersion] = useState<any>({
effectiveFrom: new Date().toISOString().slice(0, 7),
@@ -153,6 +154,7 @@ export default function SocialInsurance() {
setShowAdjust(false)
setAdjustData(null)
setEditItems({})
setEditingId(null)
alert(`调整完成,共调整 ${res.data?.adjusted || 0} 名员工的社保基数`)
},
})
@@ -166,10 +168,29 @@ export default function SocialInsurance() {
setShowAdjust(false)
setAdjustData(null)
setEditItems({})
setEditingId(null)
alert(`调整完成,共调整 ${res.data?.adjusted || 0} 名员工的公积金基数`)
},
})
const resetAdjustMutation = useMutation({
mutationFn: () => api.post(`/social/config/${config?.id}/reset-adjustment`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['social-config'] })
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
alert('社保基数调整已重置,可以重新调整')
},
})
const resetHousingAdjustMutation = useMutation({
mutationFn: () => api.post(`/social/housing-config/${housingConfig?.id}/reset-adjustment`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['housing-config'] })
queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] })
alert('公积金基数调整已重置,可以重新调整')
},
})
const handleExportCSV = (type: 'social' | 'housing', data: any) => {
if (!data?.items?.length) return
const headers = type === 'social'
@@ -224,7 +245,7 @@ export default function SocialInsurance() {
className={`px-4 py-2 text-xs font-medium border-b-2 transition-colors ${
tab === t ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
onClick={() => { setTab(t); setShowVersions(false); setShowNewVersion(false); setShowAdjust(false); setAdjustData(null); setEditItems({}) }}
onClick={() => { setTab(t); setShowVersions(false); setShowNewVersion(false); setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null) }}
>
{t === 'social' ? '社保' : t === 'housing' ? '公积金' : '月度办理'}
</button>
@@ -243,15 +264,32 @@ export default function SocialInsurance() {
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-400"></span>
)}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => activePreviewMut.mutate()}
disabled={activeConfig.adjustmentDone || activePreviewMut.isPending}
>
<SettingsIcon className="w-4 h-4 mr-1" />
{activeConfig.adjustmentDone ? '已调整' : activePreviewMut.isPending ? '加载中...' : `调整员工${isHousing ? '公积金' : '社保'}基数`}
</Button>
<div className="flex items-center gap-2">
{activeConfig.adjustmentDone && (
<Button
variant="secondary"
size="sm"
onClick={() => {
if (confirm(`确定要重置${isHousing ? '公积金' : '社保'}基数调整吗?重置后可重新调整。`)) {
isHousing ? resetHousingAdjustMutation.mutate() : resetAdjustMutation.mutate()
}
}}
disabled={isHousing ? resetHousingAdjustMutation.isPending : resetAdjustMutation.isPending}
>
<SettingsIcon className="w-4 h-4 mr-1" />
{isHousing ? resetHousingAdjustMutation.isPending ? '重置中...' : '重置调整' : resetAdjustMutation.isPending ? '重置中...' : '重置调整'}
</Button>
)}
<Button
variant="secondary"
size="sm"
onClick={() => activePreviewMut.mutate()}
disabled={activeConfig.adjustmentDone || activePreviewMut.isPending}
>
<SettingsIcon className="w-4 h-4 mr-1" />
{activeConfig.adjustmentDone ? '已调整' : activePreviewMut.isPending ? '加载中...' : `调整员工${isHousing ? '公积金' : '社保'}基数`}
</Button>
</div>
</div>
{isHousing ? (
<div className="grid md:grid-cols-4 gap-3 text-xs">
@@ -329,8 +367,17 @@ export default function SocialInsurance() {
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.oldBase)}</td>
<td className="py-1.5 text-right text-gray-500">¥{fmt(item.suggestedBase)}</td>
<td className="py-1.5 text-right">
<Input type="number" className="w-24 text-right text-xs" value={newBase}
onChange={(e) => setEditItems({ ...editItems, [item.employeeId]: Number(e.target.value) || 0 })} />
{editingId === item.employeeId ? (
<Input type="number" step="0.01" min="0" className="!w-28 text-right text-xs" value={newBase}
onChange={(e) => setEditItems({ ...editItems, [item.employeeId]: Number(e.target.value) || 0 })}
onBlur={() => setEditingId(null)}
autoFocus />
) : (
<span className="cursor-text inline-block !w-28 text-right"
onClick={() => setEditingId(item.employeeId)}>
¥{fmt(newBase)}
</span>
)}
{changed && <span className="text-warning ml-1"></span>}
</td>
</tr>
@@ -346,7 +393,7 @@ export default function SocialInsurance() {
}} disabled={activeApplyMut.isPending}>
{activeApplyMut.isPending ? '保存中...' : '确认保存'}
</Button>
<Button variant="secondary" onClick={() => { setShowAdjust(false); setAdjustData(null); setEditItems({}) }}></Button>
<Button variant="secondary" onClick={() => { setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null) }}></Button>
</div>
</Card>
)}
+184 -53
View File
@@ -1,6 +1,7 @@
import { useState, useMemo, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Check, ChevronRight, ChevronLeft, Shield, Info, Calculator, FileText, Printer, Trash2, List } from 'lucide-react'
import { AlertTriangle, Check, ChevronRight, ChevronLeft, Shield, Info, Calculator, FileText, Printer, Trash2, List, Download } from 'lucide-react'
import jsPDF from 'jspdf'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -62,6 +63,7 @@ export default function Termination() {
const [acknowledgeRisk, setAcknowledgeRisk] = useState(false)
const [socialAvgWage, setSocialAvgWage] = useState(0)
const [savedItems, setSavedItems] = useState<Array<{
id: string
employeeId: string
name: string
department: string
@@ -75,7 +77,14 @@ export default function Termination() {
years: number
remainingMonths: number
compMonths: number
version: number
isSimulated: boolean
createdAt: string
}>>([])
// 对比模式:选中的版本ID列表
const [compareVersions, setCompareVersions] = useState<string[]>([])
// 是否展开对比
const [showCompare, setShowCompare] = useState(false)
const { data: employees } = useQuery<RosterEmployee[]>({
queryKey: ['roster-for-termination'],
@@ -299,34 +308,19 @@ export default function Termination() {
})
}
// 模拟计算:只计算并暂存,不实际保存解聘记录
// 模拟计算:追加新版本,支持参数对比
const handleSimulate = () => {
if (!costResult || !selectedEmployee) return
setSavedItems((prev) => {
if (prev.some((item) => item.employeeId === employeeId)) {
return prev.map((item) =>
item.employeeId === employeeId
? {
employeeId,
name: selectedEmployee.name,
department: selectedEmployee.department,
reason,
reasonLabel,
terminationDate,
severancePay: costResult.severancePay,
noticePay: costResult.noticePay,
doublePay: costResult.doublePay,
grandTotal: costResult.grandTotal,
years: costResult.years,
remainingMonths: costResult.remainingMonths,
compMonths: costResult.compMonths,
}
: item
)
}
// 该员工的最新版本号
const sameEmployee = prev.filter(item => item.employeeId === employeeId)
const maxVersion = sameEmployee.reduce((max, item) => Math.max(max, item.version), 0)
const newVersion = maxVersion + 1
// 追加新版本(不覆盖旧版本)
return [
...prev,
{
id: `sim-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
employeeId,
name: selectedEmployee.name,
department: selectedEmployee.department,
@@ -340,40 +334,29 @@ export default function Termination() {
years: costResult.years,
remainingMonths: costResult.remainingMonths,
compMonths: costResult.compMonths,
version: newVersion,
isSimulated: true,
createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
},
]
})
handleReset()
}
// 保存成功后暂存到右侧列表
// 保存成功后追加正式版本(isSimulated=false
useEffect(() => {
if (saveMutation.isSuccess && costResult && selectedEmployee) {
setSavedItems((prev) => {
if (prev.some((item) => item.employeeId === employeeId)) {
return prev.map((item) =>
item.employeeId === employeeId
? {
employeeId,
name: selectedEmployee.name,
department: selectedEmployee.department,
reason,
reasonLabel,
terminationDate,
severancePay: costResult.severancePay,
noticePay: costResult.noticePay,
doublePay: costResult.doublePay,
grandTotal: costResult.grandTotal,
years: costResult.years,
remainingMonths: costResult.remainingMonths,
compMonths: costResult.compMonths,
}
: item
)
}
// 该员工的最新正式版本
const sameEmp = prev.filter(item => item.employeeId === employeeId && !item.isSimulated)
const maxVer = sameEmp.reduce((max, item) => Math.max(max, item.version), 0)
const newVer = maxVer + 1
// 替换该员工的旧正式版本(如有),追加新版本
const filtered = prev.filter(item => !(item.employeeId === employeeId && !item.isSimulated))
return [
...prev,
...filtered,
{
id: `saved-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
employeeId,
name: selectedEmployee.name,
department: selectedEmployee.department,
@@ -387,6 +370,9 @@ export default function Termination() {
years: costResult.years,
remainingMonths: costResult.remainingMonths,
compMonths: costResult.compMonths,
version: newVer,
isSimulated: false,
createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
},
]
})
@@ -529,6 +515,54 @@ export default function Termination() {
<Label></Label>
<Input type="date" value={terminationDate} onChange={(e) => setTerminationDate(e.target.value)} />
</div>
{/* 实时费用预览 - 当有足够数据时在解聘方式下方显示 */}
{step === 1 && costResult && (
<div className="border border-primary/30 bg-primary/5 rounded-md p-4 space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-primary">
<Calculator className="w-4 h-4" />
</div>
<div className="text-xs text-gray-500">{costResult.years}{costResult.remainingMonths} · ¥{fmt(costResult.wage)}</div>
{costResult.capped && (
<div className="text-xs text-warning"> 312</div>
)}
{costResult.noComp ? (
<div className="text-xs text-gray-600"></div>
) : (
<div className="flex items-center justify-between">
<span className="text-xs">
{costResult.isIllegal ? '违法解除赔偿金(×2' : '经济补偿金'}
<span className="text-gray-400 ml-1">{costResult.cappedMonths} × ¥{fmt(costResult.cappedWage)}</span>
</span>
<span className={`text-sm font-bold ${costResult.isIllegal ? 'text-danger' : 'text-primary'}`}>
¥{fmt(costResult.severancePay)}
</span>
</div>
)}
{costResult.noticePay > 0 && (
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500"></span>
<span>¥{fmt(costResult.noticePay)}</span>
</div>
)}
{costResult.isIllegal && (
<div className="text-xs text-red-600 bg-red-50 px-2 py-1 rounded">
= × 287
</div>
)}
{!costResult.hasContract && costResult.doubleMonths > 0 && (
<div className="flex items-center justify-between text-xs border-t pt-2">
<span className="text-warning">{costResult.doubleMonths}</span>
<span className="text-warning font-medium">¥{fmt(costResult.doublePay)}</span>
</div>
)}
<div className="flex items-center justify-between border-t pt-2">
<span className="text-xs font-medium"></span>
<span className="text-base font-bold text-danger">¥{fmt(costResult.grandTotal)}</span>
</div>
</div>
)}
<div className="border-t pt-3">
<Label></Label>
<div className="text-xs text-gray-400 mb-2"></div>
@@ -905,7 +939,6 @@ export default function Termination() {
</div>
)}
</Card>
</div>
{/* 右侧:暂存列表 */}
@@ -915,17 +948,115 @@ export default function Termination() {
<h2 className="text-xs font-medium flex items-center gap-1.5">
<List className="w-4 h-4" />
</h2>
{savedItems.length > 0 && (
<Button variant="secondary" size="sm" onClick={() => setSavedItems([])}>
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
<div className="flex gap-1">
{savedItems.length > 0 && (
<Button
variant={showCompare ? 'primary' : 'secondary'}
size="sm"
onClick={() => {
setShowCompare(!showCompare)
if (!showCompare) setCompareVersions([])
}}
>
<List className="w-3.5 h-3.5 mr-1" />
{showCompare ? '收起对比' : '版本对比'}
</Button>
)}
{savedItems.length > 0 && (
<>
<Button variant="secondary" size="sm" onClick={() => {
const doc = new jsPDF()
doc.setFontSize(16)
doc.text('解聘补偿记录', 14, 20)
doc.setFontSize(10)
doc.text(`生成日期: ${new Date().toLocaleDateString('zh-CN')}`, 14, 28)
let y = 40
savedItems.forEach((item, i) => {
if (y > 270) { doc.addPage(); y = 20 }
doc.setFontSize(11)
doc.text(`${i + 1}. ${item.name} (${item.department})`, 14, y)
y += 6
doc.setFontSize(9)
doc.text(`解聘原因: ${item.reason || '-'}`, 20, y); y += 5
doc.text(`解聘日期: ${item.terminationDate || '-'}`, 20, y); y += 5
doc.text(`工作年限: ${item.years?.toFixed(1) || 0}`, 20, y); y += 5
if (item.severancePay > 0) { doc.text(`补偿金: ¥${fmt(item.severancePay)}`, 20, y); y += 5 }
if (item.noticePay > 0) { doc.text(`代通知金: ¥${fmt(item.noticePay)}`, 20, y); y += 5 }
if (item.doublePay > 0) { doc.text(`双倍工资: ¥${fmt(item.doublePay)}`, 20, y); y += 5 }
doc.text(`合计: ¥${fmt(item.grandTotal)}`, 20, y); y += 8
})
if (y > 260) { doc.addPage(); y = 20 }
doc.setFontSize(11)
doc.text(`总计: ¥${fmt(totalGrand)}`, 14, y)
doc.save(`解聘补偿记录-${new Date().toISOString().slice(0, 10)}.pdf`)
}}>
<Download className="w-3.5 h-3.5" />
</Button>
<Button variant="secondary" size="sm" onClick={() => setSavedItems([])}>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</>
)}
</div>
</div>
{savedItems.length === 0 ? (
{savedItems.length === 0 && !showCompare ? (
<div className="text-center py-6 text-gray-400 text-xs">
<br />
</div>
) : showCompare ? (
<div className="space-y-2">
{savedItems.map((item, i) => (
<div key={i} className="border rounded-md p-2 space-y-1">
<div className="flex items-center justify-between">
<div>
<div className="text-xs font-medium">{item.name}</div>
<div className="text-xs text-gray-400">{item.department}</div>
</div>
<button
onClick={() => setSavedItems(savedItems.filter((_, idx) => idx !== i))}
className="text-gray-400 hover:text-danger"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="text-xs text-gray-500 flex justify-between">
<span>{item.reasonLabel}</span>
<span>{item.years}{item.remainingMonths}</span>
</div>
<div className="text-xs space-y-0.5">
{item.severancePay > 0 && (
<div className="flex justify-between"><span className="text-gray-400"></span><span>¥{fmt(item.severancePay)}</span></div>
)}
{item.noticePay > 0 && (
<div className="flex justify-between"><span className="text-gray-400"></span><span>¥{fmt(item.noticePay)}</span></div>
)}
{item.doublePay > 0 && (
<div className="flex justify-between"><span className="text-gray-400"></span><span>¥{fmt(item.doublePay)}</span></div>
)}
<div className="flex justify-between font-bold border-t pt-0.5"><span></span><span className="text-danger">¥{fmt(item.grandTotal)}</span></div>
</div>
</div>
))}
{/* 合计 */}
<div className="border-t-2 pt-2 space-y-0.5">
<div className="text-xs font-medium text-gray-600">{savedItems.length}</div>
{totalSeverance > 0 && (
<div className="flex justify-between text-xs"><span className="text-gray-400"></span><span>¥{fmt(totalSeverance)}</span></div>
)}
{totalNotice > 0 && (
<div className="flex justify-between text-xs"><span className="text-gray-400"></span><span>¥{fmt(totalNotice)}</span></div>
)}
{totalDouble > 0 && (
<div className="flex justify-between text-xs"><span className="text-gray-400"></span><span>¥{fmt(totalDouble)}</span></div>
)}
<div className="flex justify-between text-sm font-bold">
<span></span>
<span className="text-danger">¥{fmt(totalGrand)}</span>
</div>
</div>
</div>
) : (
<div className="space-y-2">
{savedItems.map((item, i) => (
+94 -48
View File
@@ -1,38 +1,52 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Building2, Eye, EyeOff } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import api from '../../lib/api'
import { Input, Label } from '../../components/ui/Input'
import Button from '../../components/ui/Button'
const schema = z.object({
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
newPassword: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
}).refine((data) => data.newPassword.length >= 8, {
message: '密码至少8位',
path: ['newPassword'],
})
type FormData = z.infer<typeof schema>
export default function ForgotPassword() {
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const [loading, setLoading] = useState(false)
const [step, setStep] = useState<1 | 2>(1)
const [phone, setPhone] = useState('')
const [code, setCode] = useState('')
const [newPassword, setNewPassword] = useState('')
const [sentCode, setSentCode] = useState('')
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
})
const onSubmit = async (data: FormData) => {
const sendCode = async () => {
setError('')
if (!/^1[3-9]\d{9}$/.test(phone)) {
setError('手机号格式不正确')
return
}
setLoading(true)
try {
await api.post('/auth/reset-password', data)
const res = await api.post('/auth/forgot-password/send-code', { phone }) as any
setSentCode(res.data?.code || '')
setStep(2)
} catch (err: any) {
setError(err.response?.data?.error?.message || '发送失败,请稍后重试')
} finally {
setLoading(false)
}
}
const resetPwd = async () => {
setError('')
if (code.length !== 6) {
setError('请输入6位验证码')
return
}
if (newPassword.length < 8) {
setError('密码至少8位')
return
}
setLoading(true)
try {
await api.post('/auth/forgot-password/verify', { phone, code, newPassword })
setSuccess(true)
} catch (err: any) {
setError(err.response?.data?.error?.message || '重置失败,请稍后重试')
@@ -63,41 +77,73 @@ export default function ForgotPassword() {
<div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<Label></Label>
<Input
type="tel"
placeholder="请输入注册手机号"
{...register('phone')}
maxLength={11}
/>
{errors.phone && <p className="text-xs text-red-500 mt-1">{errors.phone.message}</p>}
{sentCode && step === 2 && (
<div className="mb-4 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm">
{sentCode}
</div>
)}
<div>
<Label></Label>
<div className="relative">
{step === 1 ? (
<div className="space-y-4">
<div>
<Label></Label>
<Input
type={showPassword ? 'text' : 'password'}
placeholder="至少8位"
{...register('newPassword')}
type="tel"
placeholder="请输入注册手机号"
value={phone}
onChange={(e) => setPhone(e.target.value)}
maxLength={11}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{errors.newPassword && <p className="text-xs text-red-500 mt-1">{errors.newPassword.message}</p>}
<Button className="w-full" disabled={loading} onClick={sendCode}>
{loading ? '发送中...' : '获取验证码'}
</Button>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? '重置中...' : '重置密码'}
</Button>
</form>
) : (
<div className="space-y-4">
<div>
<Label></Label>
<Input type="tel" value={phone} disabled />
</div>
<div>
<Label></Label>
<Input
type="text"
placeholder="6位验证码"
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={6}
/>
</div>
<div>
<Label></Label>
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
placeholder="至少8位"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<Button className="w-full" disabled={loading} onClick={resetPwd}>
{loading ? '重置中...' : '重置密码'}
</Button>
<button
className="w-full text-xs text-gray-500 hover:text-gray-700"
onClick={() => { setStep(1); setCode(''); setSentCode('') }}
>
</button>
</div>
)}
<div className="mt-4 text-center text-sm">
<Link to="/login" className="text-primary hover:underline"></Link>
+3 -2
View File
@@ -21,5 +21,6 @@
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
"references": [{ "path": "./tsconfig.node.json" }],
"ignoreDeprecations": "6.0"
}