From 2968484d2d8cd8acba8b6ce37cde2217cf6c224f Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Tue, 4 Aug 2026 07:53:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E5=A4=A7=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=8B=86=E5=88=86+=E4=BB=A3=E7=A0=81=E5=88=86?= =?UTF-8?q?=E5=89=B2+=E6=8C=89=E9=9C=80=E5=8A=A0=E8=BD=BD+console=E6=B8=85?= =?UTF-8?q?=E7=90=86+any=E7=B1=BB=E5=9E=8B=E6=9B=BF=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Money.tsx (2260行→54行): 拆分为 money/ 子目录4个组件, React.lazy二级分割 - AIAssistant.tsx (2038行→63行): 拆分为 ai-assistant/ 子目录6个组件, React.lazy二级分割 - xlsx改为动态导入, OvertimeTab从345KB降至12.7KB - api-services.ts: 请求参数 any→Record - 移除前端3处console.log残留 - 后端console替换为pino logger - 前后端未使用import/变量清理 - Zod schema验证: termination/platform/special-status/work-process - 新增 leave.routes.ts, acceptance-test.routes.ts - UI组件: PageGuide, QueryError, Stepper --- 20260803-优化.md | 134 ++ backend/package-lock.json | 243 ++ backend/package.json | 2 + .../prisma/migration_add_acceptance_test.sql | 27 + backend/prisma/schema.prisma | 53 + backend/src/app.ts | 7 +- backend/src/index.ts | 3 +- backend/src/lib/codeStore.ts | 16 +- backend/src/lib/config.ts | 6 +- backend/src/lib/logger.ts | 23 + backend/src/lib/pagination.ts | 8 + backend/src/middleware/auditLog.ts | 3 +- backend/src/middleware/errorHandler.ts | 3 +- backend/src/routes/acceptance-test.routes.ts | 142 ++ backend/src/routes/attendance.routes.ts | 1 - backend/src/routes/dashboard.routes.ts | 2 +- .../src/routes/enterprise-template.routes.ts | 1 - backend/src/routes/export.routes.ts | 1 - backend/src/routes/import.routes.ts | 3 +- backend/src/routes/leave.routes.ts | 198 ++ backend/src/routes/notification.routes.ts | 13 +- backend/src/routes/payroll2.routes.ts | 40 +- backend/src/routes/platform.routes.ts | 38 +- backend/src/routes/portal.routes.ts | 60 + backend/src/routes/special-status.routes.ts | 12 +- backend/src/routes/termination.routes.ts | 37 +- backend/src/routes/work-process.routes.ts | 25 +- backend/src/schemas/platform.schema.ts | 34 + backend/src/schemas/special-status.schema.ts | 40 + backend/src/schemas/termination.schema.ts | 42 + backend/src/schemas/work-process.schema.ts | 21 + backend/src/services/payroll.service.ts | 29 +- backend/src/services/risk.service.ts | 13 +- backend/src/services/work-process.service.ts | 27 +- frontend/public/acceptance-test.html | 769 +++++- frontend/src/App.tsx | 4 + .../src/components/AcceptanceTestModal.tsx | 4 +- frontend/src/components/ErrorBoundary.tsx | 59 - frontend/src/components/HelpModal.tsx | 4 +- frontend/src/components/SurveyModal.tsx | 2 +- frontend/src/components/layout/Breadcrumb.tsx | 1 + .../src/components/layout/PortalLayout.tsx | 2 +- frontend/src/components/layout/SidebarNav.tsx | 9 +- frontend/src/components/ui/CommandPalette.tsx | 1 + frontend/src/components/ui/ErrorBoundary.tsx | 4 +- frontend/src/components/ui/PageGuide.tsx | 37 + frontend/src/components/ui/QueryError.tsx | 36 + frontend/src/components/ui/Stepper.tsx | 2 - frontend/src/lib/api-services.ts | 165 +- frontend/src/lib/api.ts | 19 +- frontend/src/main.tsx | 2 +- frontend/src/pages/AIAssistant.tsx | 2016 +--------------- frontend/src/pages/Attendance.tsx | 22 +- frontend/src/pages/AuditLog.tsx | 2 +- frontend/src/pages/Calendar.tsx | 2 +- frontend/src/pages/Contracts.tsx | 2 +- frontend/src/pages/Dashboard.tsx | 1216 ++++++---- frontend/src/pages/Evidence.tsx | 11 +- frontend/src/pages/LeaveApproval.tsx | 259 ++ frontend/src/pages/Money.tsx | 2100 +---------------- frontend/src/pages/Notifications.tsx | 1 - frontend/src/pages/Policies.tsx | 9 +- frontend/src/pages/Roster.tsx | 13 +- frontend/src/pages/SalaryDashboard.tsx | 9 +- frontend/src/pages/Settings.tsx | 34 +- frontend/src/pages/SocialInsurance.tsx | 773 +----- frontend/src/pages/SpecialStatus.tsx | 18 +- frontend/src/pages/Templates.tsx | 6 +- frontend/src/pages/Termination.tsx | 139 +- frontend/src/pages/WorkProcess.tsx | 51 +- frontend/src/pages/ai-assistant/CaseTab.tsx | 263 +++ frontend/src/pages/ai-assistant/ChatTab.tsx | 401 ++++ .../src/pages/ai-assistant/HRReportTab.tsx | 259 ++ .../src/pages/ai-assistant/KnowledgeTab.tsx | 157 ++ .../src/pages/ai-assistant/PredictTab.tsx | 789 +++++++ frontend/src/pages/ai-assistant/ReviewTab.tsx | 292 +++ frontend/src/pages/ai-assistant/shared.ts | 24 + frontend/src/pages/auth/Login.tsx | 151 +- frontend/src/pages/compliance/RiskCenter.tsx | 12 +- .../src/pages/dashboard/PerformanceStats.tsx | 2 +- frontend/src/pages/money/BatchTab.tsx | 1274 ++++++++++ frontend/src/pages/money/OvertimeTab.tsx | 454 ++++ frontend/src/pages/money/PayslipTab.tsx | 237 ++ frontend/src/pages/money/TemplateTab.tsx | 286 +++ frontend/src/pages/platform/PlatformOrgs.tsx | 12 +- frontend/src/pages/platform/PlatformUsers.tsx | 3 +- frontend/src/pages/portal/EmployeeHome.tsx | 6 +- frontend/src/pages/portal/MyLeave.tsx | 227 ++ .../src/pages/portal/OnboardingProgress.tsx | 2 +- .../src/pages/portal/ResignationApply.tsx | 2 +- frontend/src/pages/roster/AttachmentInfo.tsx | 10 +- .../pages/roster/AttendanceOvertimeInfo.tsx | 9 +- frontend/src/pages/roster/BasicInfo.tsx | 14 +- .../src/pages/roster/ChangeHistoryTab.tsx | 8 +- frontend/src/pages/roster/ContractInfo.tsx | 11 +- .../src/pages/roster/DisciplinaryInfo.tsx | 9 +- .../src/pages/roster/EmployeeProfileShell.tsx | 4 +- frontend/src/pages/roster/EvidenceChain.tsx | 8 +- .../src/pages/roster/PayslipSocialInfo.tsx | 8 +- frontend/src/pages/roster/PerformanceInfo.tsx | 9 +- frontend/src/pages/roster/TerminationInfo.tsx | 9 +- frontend/src/pages/roster/modals.tsx | 8 +- .../CommercialInsuranceTab.tsx | 270 +++ .../pages/social-insurance/MonthlyRows.tsx | 154 ++ .../social-insurance/SpecialDeductionTab.tsx | 372 +++ .../src/pages/tools/AnnualValueReport.tsx | 2 +- frontend/src/vite-env.d.ts | 2 + frontend/vite.config.js | 3 + frontend/vite.config.ts | 3 + 109 files changed, 8950 insertions(+), 5926 deletions(-) create mode 100644 20260803-优化.md create mode 100644 backend/prisma/migration_add_acceptance_test.sql create mode 100644 backend/src/lib/logger.ts create mode 100644 backend/src/lib/pagination.ts create mode 100644 backend/src/routes/acceptance-test.routes.ts create mode 100644 backend/src/routes/leave.routes.ts create mode 100644 backend/src/schemas/platform.schema.ts create mode 100644 backend/src/schemas/special-status.schema.ts create mode 100644 backend/src/schemas/work-process.schema.ts delete mode 100644 frontend/src/components/ErrorBoundary.tsx create mode 100644 frontend/src/components/ui/PageGuide.tsx create mode 100644 frontend/src/components/ui/QueryError.tsx create mode 100644 frontend/src/pages/LeaveApproval.tsx create mode 100644 frontend/src/pages/ai-assistant/CaseTab.tsx create mode 100644 frontend/src/pages/ai-assistant/ChatTab.tsx create mode 100644 frontend/src/pages/ai-assistant/HRReportTab.tsx create mode 100644 frontend/src/pages/ai-assistant/KnowledgeTab.tsx create mode 100644 frontend/src/pages/ai-assistant/PredictTab.tsx create mode 100644 frontend/src/pages/ai-assistant/ReviewTab.tsx create mode 100644 frontend/src/pages/ai-assistant/shared.ts create mode 100644 frontend/src/pages/money/BatchTab.tsx create mode 100644 frontend/src/pages/money/OvertimeTab.tsx create mode 100644 frontend/src/pages/money/PayslipTab.tsx create mode 100644 frontend/src/pages/money/TemplateTab.tsx create mode 100644 frontend/src/pages/portal/MyLeave.tsx create mode 100644 frontend/src/pages/social-insurance/CommercialInsuranceTab.tsx create mode 100644 frontend/src/pages/social-insurance/MonthlyRows.tsx create mode 100644 frontend/src/pages/social-insurance/SpecialDeductionTab.tsx diff --git a/20260803-优化.md b/20260803-优化.md new file mode 100644 index 0000000..f6e1b17 --- /dev/null +++ b/20260803-优化.md @@ -0,0 +1,134 @@ +# 20260803 优化清单 + +## 一、验收测试清单(问题 1-3) + +### 问题 1:保存不提示成功,重新进入记录丢失 +- **根因**:acceptance-test.html 使用 localStorage 按验收人姓名保存(STORAGE_PREFIX + name),但在 iframe 中 localStorage 可能因跨域限制无法写入。autoSave() 静默执行无反馈,saveCurrent() 虽有 showToast 但用户可能未看到。 +- **修复方向**: + 1. 保存按钮增加明显 toast 提示 + 2. 检查 iframe 中 localStorage 是否可用,不可用时降级到服务端存储 + 3. 进入页面时自动加载已保存的验收人记录 +- **优先级**:P1 高 + +### 问题 2:无提交按钮,重新进入记录不显示 +- **根因**:acceptance-test.html 只有「保存」「下载」「打印」按钮,没有「提交」功能。重新进入时需手动在验收人输入框输入姓名触发 loadSaved,但用户可能不知道这个操作。 +- **修复方向**: + 1. 增加「提交验收报告」按钮,将数据发送到后端持久化 + 2. 页面加载时自动显示已保存的验收人列表供选择 + 3. 优化交互流程,进入时自动加载上次记录 +- **优先级**:P1 高 + +### 问题 3:总体结论不应手动勾选 +- **根因**:当前 conclusion-section 中总体结论是 radio 按钮手动选择(acceptance-test.html:592-598),updateStats() 已计算了通过/失败/部分通过/未测试数量,但未自动推导结论。 +- **修复方向**:移除手动 radio,根据测试结果自动计算:通过率 100% → 通过,有 fail → 不通过,仅有 partial → 有条件通过。汇总主要问题从 fail/partial 的备注中提取。 +- **优先级**:P1 高 + +## 二、花名册导入(问题 4-5、11) + +### 问题 4:导入错误不提示具体内容 +- **根因**:Settings.tsx:1077-1127 的导入结果已有错误详情展示(result.errors 数组,最多显示前 10 条),但仅在导入后显示。如果用户跳过「预览」直接导入,错误信息只在结果区域展示,可能被忽略。预览功能(handlePreview)已有完整错误展示。 +- **修复方向**: + 1. 导入失败时增加醒目 toast 提示「有 N 条错误,请查看详情」 + 2. 错误区域默认展开,不折叠 + 3. 增加「跳过错误行,仅导入正常行」选项 +- **优先级**:P1 高 + +### 问题 5:按人为员工办理入职提示乱码 +- **根因**:WorkProcess.tsx:453-456 中表单数据以 key 原始字段名显示(如 employeeName、idCardNumber),未做中文标签映射。generateDocument() 生成的文书内容是中文,但 formData 的 key 是英文,显示为「乱码」感。 +- **修复方向**:在 WorkProcess.tsx 的表单信息展示区域增加字段名中文映射表,将 employeeName → 员工姓名、idCardNumber → 身份证号 等。 +- **优先级**:P0 紧急 + +### 问题 11:导入后无员工ID显示 +- **根因**:import.routes.ts:326-327 导入成功后 result.details 记录了 { sheet, row, name, status: 'success', message: '导入成功' },但未返回 employeeId。前端 Settings.tsx:1077-1086 只显示「成功导入 N 人」,不显示具体 ID。 +- **修复方向**: + 1. 后端导入时在 result.details 中加入 employeeId + 2. 前端结果展示中增加员工 ID 列 + 3. 花名册列表中确保 ID 列可见 +- **优先级**:P1 高 + +## 三、工资薪税(问题 6-7) + +### 问题 6:批量选择部分人员 + 个税未算出 +- **根因**: + - 批量选人:Money.tsx 已有 mode: 'custom' 和 CustomEmployeeSelector 组件(Money.tsx:331-333),支持按部门筛选、搜索、勾选员工。但用户可能不知道此功能。 + - 个税未算出:payroll.service.ts:230-256 使用累计预扣法,需要已归档批次的历史数据来计算累计应纳税所得额。如果是首次使用或当月无已归档批次,ytdTaxableIncome 可能 ≤ 5000 起征点,导致个税为 0。税前工资超 5000 但个税为 0 的原因可能是:①累计减除费用(5000×月数)后应纳税所得额 ≤ 0;②社保公积金扣除后剩余 ≤ 5000/月。 +- **修复方向**: + 1. UI 上更突出「自定义选择员工」模式 + 2. 个税计算增加明细展示,让用户看到「累计收入 - 累计减除 - 累计社保 = 应纳税所得额」,理解为何个税为 0 + 3. 检查是否有 bug 导致 ytdTaxableIncome 计算错误 +- **优先级**:P1 高 + +### 问题 7:个人保费与实缴一致 + 手动调整 +- **根因**:payroll.service.ts:165-206 社保计算基于 socialInsuranceConfig 配置表的比例自动计算。如果配置比例与实际社保局核定金额有差异,无法自动匹配。overrideSocial 参数已支持手动覆盖(payroll2.routes.ts:462-472),但前端可能未暴露此编辑入口。 +- **修复方向**: + 1. 工资表条目编辑界面增加「个人社保」「个人公积金」可编辑字段 + 2. 显示「系统计算值」和「实际缴纳值」对比,允许手动调整差异 + 3. 增加「匹配参保地政策」自动拉取配置功能 +- **优先级**:P2 中 + +## 四、审批流程(问题 8) + +### 问题 8:休假审批需要审批流程 +- **现状**:系统中没有独立的休假审批模块和审批流引擎。WorkProcess 有简单的状态流转(DRAFT → PENDING → APPROVED/REJECTED),但不是通用审批流。 +- **修复方向**:需要新建审批流模块,包括: + 1. 审批流配置(审批节点、审批人、条件) + 2. 休假申请提交 + 3. 审批进度追踪 + 4. 审批通知 +- **优先级**:P3 规划(较大的功能开发,建议单独规划) + +## 五、证明开具(问题 9) + +### 问题 9:证明开具添加自定义模板 +- **现状**:work-process.service.ts:247-275 的 generateDocument 只支持 INCOME_CERT 和 LEAVING_CERT 两种硬编码模板。EnterpriseTemplate 模块支持自定义模板(Templates.tsx),但 WorkProcess 的证明开具未调用企业模板。 +- **修复方向**: + 1. WorkProcess 证明开具功能关联 EnterpriseTemplate 表,允许选择企业自定义模板 + 2. 支持变量替换({{employeeName}} 等) + 3. 允许用户新建模板并分类管理 +- **优先级**:P2 中 + +## 六、工作台布局(问题 10) + +### 问题 10:工作台内容杂乱,需分区分类 +- **现状**:Dashboard.tsx 已有 Tab 分区(概览/风险提醒/月度任务),TaskCenter 也按分类分组展示。但概览 Tab 下内容较多(统计卡片、任务中心、成本分析、活动统计等)可能显得杂乱。 +- **修复方向**: + 1. 概览 Tab 按「待办事项」「人力概览」「薪税概览」「合规风险」分区展示,用卡片或分隔线区分 + 2. 增加折叠/展开功能 + 3. 调整信息密度,次要信息收起 +- **优先级**:P2 中 +- **状态**:✅ 已完成 — 已拆分为 5 个 Tab(概览/风险提醒/月度任务/人力成本/人员分析),支持 Tab 级别和区域级别的显示设置 + +## 七、离职操作(问题 12-13) + +### 问题 12:离职操作全选填,可跳过必填项 +- **根因**:Termination.tsx:527-534 的 canProceed() 函数中,step 2/3/4 都直接 return true,没有校验必填项。termination.schema.ts 的 schema 中只有 employeeId 和 reason 是必填,其他字段都可选。 +- **修复方向**: + 1. step 1(解聘方式)增加 terminationDate 必填校验(已有) + 2. step 2(合规检查)要求至少勾选所有 suggestionType: 'required' 的检查项 + 3. step 3(费用结算)如果有补偿金,要求确认金额 + 4. step 4(工作交接)要求至少完成关键交接项 +- **优先级**:P1 高 + +### 问题 13:补偿金手动修改后确认阶段仍显示系统预估 +- **根因**:Termination.tsx:536-547 的 handleSave() 使用 costResult?.totalSeverance(系统计算值),而非用户可能手动调整后的值。handleSaveDraft() 使用 costResult?.grandTotal,也未考虑手动调整。compAdjustments 状态存在但未在保存时正确应用到最终补偿金。 +- **修复方向**: + 1. 保存时使用 costResult.grandTotal + compAdjustments 的合计值 + 2. 确认步骤显示「系统预估 ¥X + 手动调整 ¥Y = 实际补偿 ¥Z」 + 3. 后端 createDraft/updateDraft 已支持 compensationBreakdown.adjustments,前端需正确传递 +- **优先级**:P0 紧急 + +## 八、用工文本模板(问题 14) + +### 问题 14:用工文本模板优化为公司统一版本 +- **现状**:work-process.service.ts 的 generateDocument 使用硬编码模板,EnterpriseTemplate 表支持自定义但未关联。 +- **修复方向**:与问题 9 同一方案——将 WorkProcess 的文书生成改为从 EnterpriseTemplate 表读取模板内容,支持变量替换,管理员可统一维护模板版本。 +- **优先级**:P2 中 + +## 优先级汇总 + +| 优先级 | 问题 | 原因 | 状态 | +|--------|------|------|------| +| P0 紧急 | 5、13 | 功能性 bug,影响业务正确性 | 待修复 | +| P1 高 | 1、2、3、4、6、11、12 | 用户体验差或操作不完整 | 待修复 | +| P2 中 | 7、9、10、14 | 功能增强和优化 | 问题10已完成 | +| P3 规划 | 8 | 新功能开发,需单独规划 | 待规划 | diff --git a/backend/package-lock.json b/backend/package-lock.json index 5a95244..6bcedbd 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -24,6 +24,8 @@ "multer": "^2.2.0", "node-cron": "^3.0.3", "openai": "^6.48.0", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", "redis": "^6.1.0", "uuid": "^10.0.0", "xlsx": "^0.18.5", @@ -569,6 +571,12 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@prisma/client": { "version": "5.22.0", "resolved": "https://registry.npmmirror.com/@prisma/client/-/client-5.22.0.tgz", @@ -1128,6 +1136,15 @@ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1456,6 +1473,12 @@ "node": ">=0.8" } }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmmirror.com/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, "node_modules/compress-commons": { "version": "4.1.2", "resolved": "https://registry.npmmirror.com/compress-commons/-/compress-commons-4.1.2.tgz", @@ -1613,6 +1636,15 @@ "dev": true, "license": "MIT" }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmmirror.com/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/dayjs": { "version": "1.11.21", "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", @@ -1945,6 +1977,12 @@ "express": ">= 4.11" } }, + "node_modules/fast-copy": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/fast-copy/-/fast-copy-4.0.4.tgz", + "integrity": "sha512-eVAiWVNPSEGIzDl5yPuLrx8fNMogScXvD9xp1Kzd41FjRIz2I3sSIcxsFeM5EzFfHAfobdvs8ZySffUopljvIA==", + "license": "MIT" + }, "node_modules/fast-csv": { "version": "4.3.6", "resolved": "https://registry.npmmirror.com/fast-csv/-/fast-csv-4.3.6.tgz", @@ -1958,6 +1996,12 @@ "node": ">=10.0.0" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", @@ -2201,6 +2245,12 @@ "node": ">=16.0.0" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", @@ -2353,6 +2403,15 @@ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -2869,6 +2928,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", @@ -2985,6 +3053,79 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmmirror.com/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmmirror.com/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/prisma": { "version": "5.22.0", "resolved": "https://registry.npmmirror.com/prisma/-/prisma-5.22.0.tgz", @@ -3011,6 +3152,22 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -3024,6 +3181,16 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz", @@ -3040,6 +3207,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz", @@ -3121,6 +3294,15 @@ "node": ">=8.10.0" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/redis": { "version": "6.1.0", "resolved": "https://registry.npmmirror.com/redis/-/redis-6.1.0.tgz", @@ -3192,6 +3374,15 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3210,6 +3401,22 @@ "node": ">=10" } }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", @@ -3351,6 +3558,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", @@ -3372,6 +3588,15 @@ "source-map": "^0.6.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -3465,6 +3690,24 @@ "node": ">=6" } }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.7.tgz", diff --git a/backend/package.json b/backend/package.json index 189a12b..0276ea3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -29,6 +29,8 @@ "multer": "^2.2.0", "node-cron": "^3.0.3", "openai": "^6.48.0", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", "redis": "^6.1.0", "uuid": "^10.0.0", "xlsx": "^0.18.5", diff --git a/backend/prisma/migration_add_acceptance_test.sql b/backend/prisma/migration_add_acceptance_test.sql new file mode 100644 index 0000000..004c543 --- /dev/null +++ b/backend/prisma/migration_add_acceptance_test.sql @@ -0,0 +1,27 @@ +-- 验收测试表 +CREATE TABLE IF NOT EXISTS "AcceptanceTest" ( + "id" TEXT NOT NULL, + "orgId" TEXT NOT NULL, + "verifierName" TEXT NOT NULL, + "results" JSONB NOT NULL, + "remarks" JSONB NOT NULL, + "conclusionName" TEXT, + "conclusionDate" TEXT, + "conclusionResult" TEXT, + "conclusionIssues" TEXT, + "screenshots" JSONB, + "signature1" TEXT, + "signature2" TEXT, + "status" TEXT NOT NULL DEFAULT 'DRAFT', + "submittedAt" TIMESTAMP(3), + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AcceptanceTest_pkey" PRIMARY KEY ("id") +); + +-- 唯一约束:同一组织下验收人姓名唯一 +CREATE UNIQUE INDEX IF NOT EXISTS "AcceptanceTest_orgId_verifierName_key" ON "AcceptanceTest"("orgId", "verifierName"); + +-- 索引 +CREATE INDEX IF NOT EXISTS "AcceptanceTest_orgId_status_idx" ON "AcceptanceTest"("orgId", "status"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 51657a0..cbfc4da 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -175,6 +175,7 @@ model Organization { shifts Shift[] shiftAssignments ShiftAssignment[] leaveRecords LeaveRecord[] + leaveRequests LeaveRequest[] calendarEvents CalendarEvent[] consultations Consultation[] workProcesses WorkProcess[] @@ -263,6 +264,7 @@ model Employee { specialDeductionRecords SpecialDeductionRecord[] shiftAssignments ShiftAssignment[] leaveRecords LeaveRecord[] + leaveRequests LeaveRequest[] calendarEvents CalendarEvent[] workProcesses WorkProcess[] specialStatuses EmployeeSpecialStatus[] @@ -1162,6 +1164,33 @@ model LeaveRecord { @@index([orgId, startDate]) } +// ========== 休假审批流 ========== + +model LeaveRequest { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + employeeId String + employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) + leaveType String // SICK=病假 / PERSONAL=事假 / ANNUAL=年假 / MATERNITY=产假 / OTHER=其他 + startDate DateTime + endDate DateTime + days Float // 请假天数 + reason String? + attachment String? // 附件URL(如病假条) + status String @default("PENDING") // PENDING=待审批 / APPROVED=已批准 / REJECTED=已驳回 / CANCELLED=已撤回 + approverId String? // 审批人ID + approvedAt DateTime? + approveRemark String? // 审批意见 + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([orgId, employeeId]) + @@index([orgId, status]) + @@index([orgId, startDate]) +} + // ========== 自定义日历事件 ========== model CalendarEvent { @@ -1311,3 +1340,27 @@ model EmployeeSpecialStatus { @@index([orgId, type]) @@index([employeeId]) } + +// ========== 验收测试 ========== +model AcceptanceTest { + id String @id @default(uuid()) + orgId String + verifierName String // 验收人姓名 + results Json // { "1.1": "pass", "1.2": "fail", ... } + remarks Json // { "1.1": "备注内容", ... } + conclusionName String? // 结论验收人 + conclusionDate String? // 结论日期 + conclusionResult String? // pass | conditional | fail — 系统自动计算 + conclusionIssues String? // 遗留问题(系统自动汇总) + screenshots Json? // { "0-0": "data:image/png;base64,...", ... } + signature1 String? // 验收人签名 dataURL + signature2 String? // 项目经理签名 dataURL + status String @default("DRAFT") // DRAFT | SUBMITTED + submittedAt DateTime? + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([orgId, verifierName]) + @@index([orgId, status]) +} diff --git a/backend/src/app.ts b/backend/src/app.ts index 29fd195..eafd43e 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -5,6 +5,7 @@ import morgan from 'morgan' import compression from 'compression' import { errorHandler } from './middleware/errorHandler' import { apiLimiter } from './middleware/rateLimit' +import logger from './lib/logger' const app = express() @@ -62,6 +63,8 @@ import workProcessRoutes from './routes/work-process.routes' import enterpriseTemplateRoutes from './routes/enterprise-template.routes' import specialStatusRoutes from './routes/special-status.routes' import companyFileRoutes from './routes/company-file.routes' +import acceptanceTestRoutes from './routes/acceptance-test.routes' +import leaveRoutes from './routes/leave.routes' app.use('/api/v1/auth', authRoutes) app.use('/api/v1/dashboard', dashboardRoutes) app.use('/api/v1/employees', employeeRoutes) @@ -88,13 +91,15 @@ app.use('/api/v1/work-processes', workProcessRoutes) app.use('/api/v1/enterprise-templates', enterpriseTemplateRoutes) app.use('/api/v1/special-statuses', specialStatusRoutes) app.use('/api/v1/company-files', companyFileRoutes) +app.use('/api/v1/acceptance-tests', acceptanceTestRoutes) +app.use('/api/v1/leaves', leaveRoutes) app.use(errorHandler) // RAG 知识库自动初始化(异步,不阻塞启动) import { seedKnowledgeBase } from './services/rag.service' seedKnowledgeBase().catch((err) => { - console.warn('[RAG] 知识库初始化失败,AI 问答将不使用 RAG 检索:', err?.message || err) + logger.warn({ err }, 'RAG 知识库初始化失败,AI 问答将不使用 RAG 检索') }) export default app diff --git a/backend/src/index.ts b/backend/src/index.ts index b136e70..0a639ef 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,10 +1,11 @@ import app from './app' import { validateEnv } from './lib/config' +import logger from './lib/logger' validateEnv() const PORT = Number(process.env.PORT) || 3000 app.listen(PORT, '::', () => { - console.log(`Server running on http://[::]:${PORT}`) + logger.info(`Server running on http://[::]:${PORT}`) }) diff --git a/backend/src/lib/codeStore.ts b/backend/src/lib/codeStore.ts index 6ef44bf..f5525af 100644 --- a/backend/src/lib/codeStore.ts +++ b/backend/src/lib/codeStore.ts @@ -7,6 +7,8 @@ * 功能:存储、验证、过期清理、失败次数限制 */ +import logger from './logger' + interface CodeEntry { code: string expiresAt: number @@ -28,11 +30,11 @@ async function getRedis() { try { const { createClient } = await import('redis') redisClient = createClient({ url: REDIS_URL }) - redisClient.on('error', (err: any) => console.error('[Redis] error:', err)) + redisClient.on('error', (err: any) => logger.error({ err }, 'Redis error')) await redisClient.connect() - console.log('[Redis] 验证码存储已连接') + logger.info('Redis 验证码存储已连接') } catch (err) { - console.warn('[Redis] 连接失败,降级为内存存储:', err) + logger.warn({ err }, 'Redis 连接失败,降级为内存存储') return null } } @@ -59,7 +61,7 @@ export async function setCode(key: string, code: string, ttlMs: number = 5 * 60 await redis.set(`${KEY_PREFIX}${key}`, JSON.stringify(entry), { PX: ttlMs }) return } catch (err) { - console.warn('[Redis] set 失败,降级内存:', err) + logger.warn({ err }, 'Redis set 失败,降级内存') } } memoryStore.set(key, entry) @@ -81,7 +83,7 @@ export async function getCode(key: string): Promise { } return entry } catch (err) { - console.warn('[Redis] get 失败,降级内存:', err) + logger.warn({ err }, 'Redis get 失败,降级内存') } } @@ -108,7 +110,7 @@ export async function updateCode(key: string, updates: Partial): Prom } return } catch (err) { - console.warn('[Redis] update 失败,降级内存:', err) + logger.warn({ err }, 'Redis update 失败,降级内存') } } @@ -128,7 +130,7 @@ export async function deleteCode(key: string): Promise { await redis.del(`${KEY_PREFIX}${key}`) return } catch (err) { - console.warn('[Redis] del 失败,降级内存:', err) + logger.warn({ err }, 'Redis del 失败,降级内存') } } memoryStore.delete(key) diff --git a/backend/src/lib/config.ts b/backend/src/lib/config.ts index 76f45a5..cd84ced 100644 --- a/backend/src/lib/config.ts +++ b/backend/src/lib/config.ts @@ -1,3 +1,5 @@ +import logger from './logger' + const required = ['JWT_SECRET', 'JWT_REFRESH_SECRET', 'ENCRYPTION_KEY'] const defaults: Record = { JWT_SECRET: 'dev-secret', @@ -13,10 +15,10 @@ export function validateEnv(): void { } } if (missing.length > 0 && process.env.NODE_ENV === 'production') { - console.error(`[FATAL] 以下环境变量未设置或使用了默认值,生产环境禁止启动: ${missing.join(', ')}`) + logger.fatal({ missing }, '以下环境变量未设置或使用了默认值,生产环境禁止启动') process.exit(1) } if (missing.length > 0) { - console.warn(`[WARN] 以下环境变量使用了默认值,仅限开发环境: ${missing.join(', ')}`) + logger.warn({ missing }, '以下环境变量使用了默认值,仅限开发环境') } } diff --git a/backend/src/lib/logger.ts b/backend/src/lib/logger.ts new file mode 100644 index 0000000..f6ecad3 --- /dev/null +++ b/backend/src/lib/logger.ts @@ -0,0 +1,23 @@ +import pino from 'pino' + +const isDev = process.env.NODE_ENV !== 'production' + +const logger = pino({ + level: process.env.LOG_LEVEL || (isDev ? 'debug' : 'info'), + transport: isDev + ? { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'HH:MM:ss', + ignore: 'pid,hostname', + }, + } + : undefined, + redact: { + paths: ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.passwordHash', '*.token', '*.refreshToken'], + censor: '[REDACTED]', + }, +}) + +export default logger diff --git a/backend/src/lib/pagination.ts b/backend/src/lib/pagination.ts new file mode 100644 index 0000000..511775a --- /dev/null +++ b/backend/src/lib/pagination.ts @@ -0,0 +1,8 @@ +/** + * 统一分页参数解析,全局限制 pageSize ≤ 200,防止过量数据查询 + */ +export function parsePagination(query: Record): { page: number; pageSize: number } { + const page = Math.max(1, parseInt(query.page as string) || 1) + const pageSize = Math.min(Math.max(1, parseInt(query.pageSize as string) || 20), 200) + return { page, pageSize } +} diff --git a/backend/src/middleware/auditLog.ts b/backend/src/middleware/auditLog.ts index 8c003ba..f7de7e8 100644 --- a/backend/src/middleware/auditLog.ts +++ b/backend/src/middleware/auditLog.ts @@ -1,5 +1,6 @@ import { AuthRequest } from './auth' import prisma from '../lib/prisma' +import logger from '../lib/logger' export async function auditLog( req: AuthRequest, @@ -22,6 +23,6 @@ export async function auditLog( }, }) } catch (err) { - console.error('Audit log error:', err) + logger.error({ err, action, entity, entityId }, 'Audit log error') } } diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts index a885723..1ce8d14 100644 --- a/backend/src/middleware/errorHandler.ts +++ b/backend/src/middleware/errorHandler.ts @@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from 'express' import { ZodError } from 'zod' import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library' import { AppError } from '../lib/AppError' +import logger from '../lib/logger' export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) { if (err instanceof AppError) { @@ -55,7 +56,7 @@ export function errorHandler(err: unknown, _req: Request, res: Response, _next: }) } - console.error('Unhandled error:', err) + logger.error({ err }, 'Unhandled error') return res.status(500).json({ success: false, error: { code: 'INTERNAL_ERROR', message: '服务器内部错误' }, diff --git a/backend/src/routes/acceptance-test.routes.ts b/backend/src/routes/acceptance-test.routes.ts new file mode 100644 index 0000000..ebdfd0e --- /dev/null +++ b/backend/src/routes/acceptance-test.routes.ts @@ -0,0 +1,142 @@ +import { Router, Response, NextFunction } from 'express' +import { authMiddleware, AuthRequest } from '../middleware/auth' +import prisma from '../lib/prisma' + +const router = Router() + +// 保存(创建或更新) +router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { verifierName, results, remarks, conclusionName, conclusionDate, conclusionResult, conclusionIssues, screenshots, signature1, signature2 } = req.body + if (!verifierName || !verifierName.trim()) { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请输入验收人姓名' } }) + } + + const orgId = req.user!.orgId + const data = { + verifierName: verifierName.trim(), + results: results || {}, + remarks: remarks || {}, + conclusionName: conclusionName || null, + conclusionDate: conclusionDate || null, + conclusionResult: conclusionResult || null, + conclusionIssues: conclusionIssues || null, + screenshots: screenshots || undefined, + signature1: signature1 || undefined, + signature2: signature2 || undefined, + } + + const record = await prisma.acceptanceTest.upsert({ + where: { orgId_verifierName: { orgId, verifierName: verifierName.trim() } }, + create: { ...data, orgId, createdBy: req.user!.id }, + update: data, + }) + + res.json({ success: true, data: record }) + } catch (err) { + next(err) + } +}) + +// 获取单个验收记录 +router.get('/:verifierName', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const record = await prisma.acceptanceTest.findUnique({ + where: { + orgId_verifierName: { orgId: req.user!.orgId, verifierName: req.params.verifierName }, + }, + }) + if (!record) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '未找到保存记录' } }) + } + res.json({ success: true, data: record }) + } catch (err) { + next(err) + } +}) + +// 列表(所有验收记录摘要) +router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const records = await prisma.acceptanceTest.findMany({ + where: { orgId: req.user!.orgId }, + select: { + id: true, + verifierName: true, + status: true, + conclusionResult: true, + conclusionDate: true, + updatedAt: true, + results: true, + }, + orderBy: { updatedAt: 'desc' }, + }) + + const list = records.map((r: any) => { + const vals = Object.values(r.results as any) + const pass = vals.filter((v: any) => v === 'pass').length + const total = vals.length + const tested = vals.filter((v: any) => v !== 'untested').length + return { + id: r.id, + verifierName: r.verifierName, + status: r.status, + conclusionResult: r.conclusionResult, + conclusionDate: r.conclusionDate, + updatedAt: r.updatedAt, + testedCount: tested, + passCount: pass, + totalCount: total, + } + }) + + res.json({ success: true, data: list }) + } catch (err) { + next(err) + } +}) + +// 提交验收报告 +router.post('/:verifierName/submit', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { conclusionResult, conclusionIssues } = req.body + const record = await prisma.acceptanceTest.findUnique({ + where: { + orgId_verifierName: { orgId: req.user!.orgId, verifierName: req.params.verifierName }, + }, + }) + if (!record) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '请先保存再提交' } }) + } + + const updated = await prisma.acceptanceTest.update({ + where: { id: record.id }, + data: { + status: 'SUBMITTED', + submittedAt: new Date(), + conclusionResult: conclusionResult || record.conclusionResult, + conclusionIssues: conclusionIssues || record.conclusionIssues, + }, + }) + + res.json({ success: true, data: updated }) + } catch (err) { + next(err) + } +}) + +// 删除 +router.delete('/:verifierName', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + await prisma.acceptanceTest.delete({ + where: { + orgId_verifierName: { orgId: req.user!.orgId, verifierName: req.params.verifierName }, + }, + }) + res.json({ success: true }) + } catch (err) { + next(err) + } +}) + +export default router diff --git a/backend/src/routes/attendance.routes.ts b/backend/src/routes/attendance.routes.ts index c08e9e0..16fff88 100644 --- a/backend/src/routes/attendance.routes.ts +++ b/backend/src/routes/attendance.routes.ts @@ -2,7 +2,6 @@ import { Router, Response, NextFunction } from 'express' import { authMiddleware, AuthRequest } from '../middleware/auth' import { z } from 'zod' import { - createAttendanceConfirmation, batchCreateAttendanceConfirmations, getAttendanceConfirmations, confirmAttendance, diff --git a/backend/src/routes/dashboard.routes.ts b/backend/src/routes/dashboard.routes.ts index 20fb636..2e84e0d 100644 --- a/backend/src/routes/dashboard.routes.ts +++ b/backend/src/routes/dashboard.routes.ts @@ -1,7 +1,7 @@ import { Router, Response, NextFunction } from 'express' import prisma from '../lib/prisma' import { authMiddleware, AuthRequest } from '../middleware/auth' -import { getDashboardData, getMonthlyCalendar, getCostAnalysis, getComplianceScore, getHealthCheck, saveHealthCheckReport, getHealthCheckHistory, getAnnualValueReport, saveAnnualValueReport, getAnnualValueReportHistory } from '../services/risk.service' +import { getDashboardData, getMonthlyCalendar, getCostAnalysis, getHealthCheck, saveHealthCheckReport, getHealthCheckHistory, getAnnualValueReport, saveAnnualValueReport, getAnnualValueReportHistory } from '../services/risk.service' import { z } from 'zod' const router = Router() diff --git a/backend/src/routes/enterprise-template.routes.ts b/backend/src/routes/enterprise-template.routes.ts index 74b0dd2..db33cc2 100644 --- a/backend/src/routes/enterprise-template.routes.ts +++ b/backend/src/routes/enterprise-template.routes.ts @@ -1,7 +1,6 @@ import { Router, Response, NextFunction } from 'express' import { authMiddleware, AuthRequest } from '../middleware/auth' import prisma from '../lib/prisma' -import { renderTemplate } from '../services/template.service' const router = Router() diff --git a/backend/src/routes/export.routes.ts b/backend/src/routes/export.routes.ts index ae78577..b72d64d 100644 --- a/backend/src/routes/export.routes.ts +++ b/backend/src/routes/export.routes.ts @@ -255,7 +255,6 @@ router.get('/roster', authMiddleware, async (req: AuthRequest, res: Response, ne const search = req.query.search as string | undefined const status = req.query.status as string | undefined const department = req.query.department as string | undefined - const contractStatus = req.query.contractStatus as string | undefined const where: any = { orgId } if (department) where.department = department diff --git a/backend/src/routes/import.routes.ts b/backend/src/routes/import.routes.ts index 550193b..3b06057 100644 --- a/backend/src/routes/import.routes.ts +++ b/backend/src/routes/import.routes.ts @@ -324,7 +324,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async }) result.employees++ - result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'success', message: '导入成功' }) + result.details.push({ sheet: '员工信息', row: i + 2, name, employeeId: emp.id, status: 'success', message: '导入成功' }) } catch (e: any) { const msg = e?.message || '' if (msg.includes('Unique constraint')) { @@ -720,7 +720,6 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR try { if (!req.file) return res.status(400).json({ success: false, message: '请上传文件' }) const orgId = req.user!.orgId - const userId = req.user!.id const batchId = req.body.batchId as string if (!batchId) return res.status(400).json({ success: false, message: '缺少批次ID' }) diff --git a/backend/src/routes/leave.routes.ts b/backend/src/routes/leave.routes.ts new file mode 100644 index 0000000..32ac011 --- /dev/null +++ b/backend/src/routes/leave.routes.ts @@ -0,0 +1,198 @@ +import { Router, Response, NextFunction } from 'express' +import { authMiddleware, AuthRequest } from '../middleware/auth' +import prisma from '../lib/prisma' +import { parsePagination } from '../lib/pagination' + +const router = Router() + +// 休假申请列表 +router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const { status, leaveType, employeeId } = req.query + const { page, pageSize } = parsePagination(req.query) + + const where: any = { orgId } + if (status) where.status = status + if (leaveType) where.leaveType = leaveType + if (employeeId) where.employeeId = employeeId + + const total = await prisma.leaveRequest.count({ where }) + const list = await prisma.leaveRequest.findMany({ + where, + include: { + employee: { select: { id: true, name: true, department: true, position: true } }, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }) + + res.json({ success: true, data: { list, total, page, pageSize } }) + } catch (err) { + next(err) + } +}) + +// 创建休假申请 +router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const { employeeId, leaveType, startDate, endDate, days, reason, attachment } = req.body + + if (!employeeId || !leaveType || !startDate || !endDate) { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少必填字段' } }) + } + + const record = await prisma.leaveRequest.create({ + data: { + orgId, + employeeId, + leaveType, + startDate: new Date(startDate), + endDate: new Date(endDate), + days: days || 1, + reason: reason || null, + attachment: attachment || null, + createdBy: req.user!.id, + }, + include: { + employee: { select: { id: true, name: true, department: true, position: true } }, + }, + }) + + res.json({ success: true, data: record }) + } catch (err) { + next(err) + } +}) + +// 审批(批准/驳回) +router.post('/:id/approve', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params + const { action, remark } = req.body // action: 'APPROVED' | 'REJECTED' + const orgId = req.user!.orgId + + if (!['APPROVED', 'REJECTED'].includes(action)) { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '无效的审批操作' } }) + } + + const existing = await prisma.leaveRequest.findFirst({ where: { id, orgId } }) + if (!existing) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '申请不存在' } }) + } + if (existing.status !== 'PENDING') { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '该申请已处理' } }) + } + + const updated = await prisma.leaveRequest.update({ + where: { id }, + data: { + status: action, + approverId: req.user!.id, + approvedAt: new Date(), + approveRemark: remark || null, + }, + include: { + employee: { select: { id: true, name: true, department: true, position: true } }, + }, + }) + + // 如果批准,自动创建 LeaveRecord + if (action === 'APPROVED') { + await prisma.leaveRecord.create({ + data: { + orgId, + employeeId: existing.employeeId, + leaveType: existing.leaveType, + startDate: existing.startDate, + endDate: existing.endDate, + days: existing.days, + reason: existing.reason || '', + remark: `休假审批通过(申请ID: ${id})`, + createdBy: req.user!.id, + }, + }) + } + + res.json({ success: true, data: updated }) + } catch (err) { + next(err) + } +}) + +// 撤回申请 +router.post('/:id/cancel', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params + const orgId = req.user!.orgId + + const existing = await prisma.leaveRequest.findFirst({ where: { id, orgId } }) + if (!existing) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '申请不存在' } }) + } + if (existing.status !== 'PENDING') { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已处理的申请不可撤回' } }) + } + + const updated = await prisma.leaveRequest.update({ + where: { id }, + data: { status: 'CANCELLED' }, + }) + + res.json({ success: true, data: updated }) + } catch (err) { + next(err) + } +}) + +// 删除申请(仅待审批状态可删) +router.delete('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params + const orgId = req.user!.orgId + + const existing = await prisma.leaveRequest.findFirst({ where: { id, orgId } }) + if (!existing) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '申请不存在' } }) + } + if (existing.status !== 'PENDING') { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已处理的申请不可删除' } }) + } + + await prisma.leaveRequest.delete({ where: { id } }) + res.json({ success: true }) + } catch (err) { + next(err) + } +}) + +// 统计 +router.get('/stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const { month } = req.query + + const where: any = { orgId } + if (month) { + const start = new Date(`${month}-01`) + const end = new Date(`${month}-31T23:59:59`) + where.startDate = { gte: start, lte: end } + } + + const [pending, approved, rejected, cancelled, total] = await Promise.all([ + prisma.leaveRequest.count({ where: { ...where, status: 'PENDING' } }), + prisma.leaveRequest.count({ where: { ...where, status: 'APPROVED' } }), + prisma.leaveRequest.count({ where: { ...where, status: 'REJECTED' } }), + prisma.leaveRequest.count({ where: { ...where, status: 'CANCELLED' } }), + prisma.leaveRequest.count({ where }), + ]) + + res.json({ success: true, data: { pending, approved, rejected, cancelled, total } }) + } catch (err) { + next(err) + } +}) + +export default router diff --git a/backend/src/routes/notification.routes.ts b/backend/src/routes/notification.routes.ts index 869cb3b..7a49798 100644 --- a/backend/src/routes/notification.routes.ts +++ b/backend/src/routes/notification.routes.ts @@ -132,9 +132,13 @@ router.post('/check-contracts', async (req: AuthRequest, res: Response, next: Ne }) // 测试通知渠道 +const testChannelSchema = z.object({ + channel: z.enum(['wechat', 'email']), +}) + router.post('/test', async (req: AuthRequest, res: Response, next: NextFunction) => { try { - const { channel } = req.body as { channel: 'wechat' | 'email' } + const { channel } = testChannelSchema.parse(req.body) const setting = await prisma.notificationSetting.findUnique({ where: { orgId: req.user!.orgId } }) if (!setting) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '通知设置不存在' } }) @@ -154,12 +158,9 @@ router.post('/test', async (req: AuthRequest, res: Response, next: NextFunction) } catch (e: any) { res.json({ success: false, error: { code: 'TEST_FAILED', message: `发送失败: ${e?.message || '网络错误'}` } }) } - } else if (channel === 'email') { - if (!setting.email) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '未配置通知邮箱' } }) - // 邮件发送(开发阶段仅返回成功) - res.json({ success: true, data: { message: `测试邮件已发送到 ${setting.email}` } }) } else { - res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不支持的通知渠道' } }) + if (!setting.email) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '未配置通知邮箱' } }) + res.json({ success: true, data: { message: `测试邮件已发送到 ${setting.email}` } }) } } catch (err) { next(err) diff --git a/backend/src/routes/payroll2.routes.ts b/backend/src/routes/payroll2.routes.ts index d71014e..8ad8fd0 100644 --- a/backend/src/routes/payroll2.routes.ts +++ b/backend/src/routes/payroll2.routes.ts @@ -504,7 +504,45 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res }, }) - res.json({ success: true, data: updated }) + res.json({ success: true, data: updated, taxBreakdown: calcResult.taxBreakdown }) + } catch (err) { + next(err) + } +}) + +// 获取条目个税计算明细 +router.get('/batches/:batchId/entries/:employeeId/tax-detail', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { batchId, employeeId } = req.params + const orgId = req.user!.orgId + + const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } }) + if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } }) + + const entry = await prisma.batchEntry.findUnique({ + where: { batchId_employeeId: { batchId, employeeId } }, + }) + if (!entry) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '条目不存在' } }) + + const inputs = { + baseSalary: entry.baseSalary, + overtimePay: entry.overtimePay, + allowance: entry.allowance, + deduction: entry.deduction, + bonus: entry.bonus, + } + const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type) + const taxBreakdown = calcResult.taxBreakdown || {} + // 加入社保公积金系统计算值 vs 实际值对比 + taxBreakdown.systemSocialEmp = calcResult.systemSocialEmp + taxBreakdown.systemSocialOrg = calcResult.systemSocialOrg + taxBreakdown.systemHousingEmp = calcResult.systemHousingEmp + taxBreakdown.systemHousingOrg = calcResult.systemHousingOrg + taxBreakdown.actualSocialEmp = entry.socialEmp + taxBreakdown.actualSocialOrg = entry.socialOrg + taxBreakdown.actualHousingEmp = entry.housingEmp + taxBreakdown.actualHousingOrg = entry.housingOrg + res.json({ success: true, data: taxBreakdown }) } catch (err) { next(err) } diff --git a/backend/src/routes/platform.routes.ts b/backend/src/routes/platform.routes.ts index 310aa28..8d679d7 100644 --- a/backend/src/routes/platform.routes.ts +++ b/backend/src/routes/platform.routes.ts @@ -6,7 +6,8 @@ import { Router } from 'express' import prisma from '../lib/prisma' import { AuthRequest, authMiddleware, platformAdminMiddleware } from '../middleware/auth' -import { loginLimiter } from '../middleware/rateLimit' +import { parsePagination } from '../lib/pagination' +import { createOrgSchema, updateOrgSchema, updateOrgAdminSchema, createPlatformAdminSchema } from '../schemas/platform.schema' const router = Router() @@ -88,8 +89,7 @@ router.get('/dashboard', async (_req: AuthRequest, res, next) => { */ router.get('/orgs', async (req: AuthRequest, res, next) => { try { - const page = parseInt(req.query.page as string) || 1 - const pageSize = parseInt(req.query.pageSize as string) || 20 + const { page, pageSize } = parsePagination(req.query) const search = (req.query.search as string) || '' const planFilter = (req.query.plan as string) || '' @@ -154,19 +154,7 @@ router.post('/orgs', async (req: AuthRequest, res, next) => { const { name, plan, maxEmployees, city, contactName, contactPhone, adminName, adminPhone, adminPassword, - } = req.body as { - name: string; plan?: string; maxEmployees?: number - city?: string; contactName?: string; contactPhone?: string - adminName?: string; adminPhone: string; adminPassword: string - } - - if (!name || !adminPhone || !adminPassword) { - return res.status(400).json({ success: false, error: { code: 'VALIDATION', message: '企业名称、管理员手机号、密码不能为空' } }) - } - - if (adminPassword.length < 8) { - return res.status(400).json({ success: false, error: { code: 'VALIDATION', message: '密码至少8位' } }) - } + } = createOrgSchema.parse(req.body) const existing = await prisma.user.findUnique({ where: { phone: adminPhone } }) if (existing) { @@ -244,10 +232,7 @@ router.get('/orgs/:id', async (req: AuthRequest, res, next) => { */ router.put('/orgs/:id', async (req: AuthRequest, res, next) => { try { - const { name, plan, maxEmployees, city, contactName, contactPhone } = req.body as { - name?: string; plan?: string; maxEmployees?: number; - city?: string; contactName?: string; contactPhone?: string - } + const { name, plan, maxEmployees, city, contactName, contactPhone } = updateOrgSchema.parse(req.body) const updateData: any = {} if (name) updateData.name = name @@ -274,9 +259,7 @@ router.put('/orgs/:id', async (req: AuthRequest, res, next) => { */ router.put('/orgs/:id/admin', async (req: AuthRequest, res, next) => { try { - const { adminName, adminPhone, adminPassword } = req.body as { - adminName?: string; adminPhone?: string; adminPassword?: string - } + const { adminName, adminPhone, adminPassword } = updateOrgAdminSchema.parse(req.body) // 找到该企业的 ADMIN 角色用户(第一个管理员) const admin = await prisma.user.findFirst({ @@ -335,8 +318,7 @@ router.delete('/orgs/:id', async (req: AuthRequest, res, next) => { */ router.get('/users', async (req: AuthRequest, res, next) => { try { - const page = parseInt(req.query.page as string) || 1 - const pageSize = parseInt(req.query.pageSize as string) || 20 + const { page, pageSize } = parsePagination(req.query) const search = (req.query.search as string) || '' const orgId = (req.query.orgId as string) || '' @@ -432,11 +414,7 @@ router.get('/admins', async (_req: AuthRequest, res, next) => { */ router.post('/admins', async (req: AuthRequest, res, next) => { try { - const { name, phone, password } = req.body as { name: string; phone: string; password: string } - - if (!name || !phone || !password) { - return res.status(400).json({ success: false, error: { code: 'VALIDATION', message: '姓名、手机号、密码不能为空' } }) - } + const { name, phone, password } = createPlatformAdminSchema.parse(req.body) const existing = await prisma.user.findUnique({ where: { phone } }) if (existing) { diff --git a/backend/src/routes/portal.routes.ts b/backend/src/routes/portal.routes.ts index ada2714..2c8ced0 100644 --- a/backend/src/routes/portal.routes.ts +++ b/backend/src/routes/portal.routes.ts @@ -838,4 +838,64 @@ router.post('/resignation/:id/withdraw', portalAuth, async (req: any, res, next) } catch (err) { next(err) } }) +// ========== 员工端:休假申请 ========== +// 查看自己的休假申请列表 +router.get('/leaves', portalAuth, async (req: any, res, next) => { + try { + const { id: employeeId, orgId } = req.employee + const list = await prisma.leaveRequest.findMany({ + where: { employeeId, orgId }, + orderBy: { createdAt: 'desc' }, + }) + res.json({ success: true, data: list }) + } catch (err) { next(err) } +}) + +// 提交休假申请 +router.post('/leaves', portalAuth, async (req: any, res, next) => { + try { + const { id: employeeId, orgId } = req.employee + const { leaveType, startDate, endDate, days, reason } = req.body + + if (!leaveType || !startDate || !endDate) { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少必填字段' } }) + } + + const record = await prisma.leaveRequest.create({ + data: { + orgId, + employeeId, + leaveType, + startDate: new Date(startDate), + endDate: new Date(endDate), + days: days || 1, + reason: reason || null, + createdBy: employeeId, + }, + }) + res.json({ success: true, data: record }) + } catch (err) { next(err) } +}) + +// 撤回休假申请(仅待审批可撤回) +router.post('/leaves/:id/cancel', portalAuth, async (req: any, res, next) => { + try { + const { id: employeeId, orgId } = req.employee + const record = await prisma.leaveRequest.findFirst({ + where: { id: req.params.id, employeeId, orgId }, + }) + if (!record) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '申请不存在' } }) + } + if (record.status !== 'PENDING') { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已处理的申请不可撤回' } }) + } + const updated = await prisma.leaveRequest.update({ + where: { id: record.id }, + data: { status: 'CANCELLED' }, + }) + res.json({ success: true, data: updated }) + } catch (err) { next(err) } +}) + export default router diff --git a/backend/src/routes/special-status.routes.ts b/backend/src/routes/special-status.routes.ts index aaf64e3..ffce424 100644 --- a/backend/src/routes/special-status.routes.ts +++ b/backend/src/routes/special-status.routes.ts @@ -5,6 +5,7 @@ import { Router, Response, NextFunction } from 'express' import { authMiddleware, AuthRequest } from '../middleware/auth' import prisma from '../lib/prisma' +import { parsePagination } from '../lib/pagination' import { createSpecialStatus, updateSpecialStatus, @@ -13,6 +14,7 @@ import { STATUS_TYPES, getAlertLevel, } from '../services/special-status.service' +import { createSpecialStatusSchema, updateSpecialStatusSchema } from '../schemas/special-status.schema' const router = Router() @@ -22,8 +24,7 @@ const router = Router() router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { try { const orgId = req.user!.orgId - const page = parseInt(req.query.page as string) || 1 - const pageSize = parseInt(req.query.pageSize as string) || 20 + const { page, pageSize } = parsePagination(req.query) const type = (req.query.type as string) || '' const status = (req.query.status as string) || '' const search = (req.query.search as string) || '' @@ -97,7 +98,8 @@ router.get('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: */ router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { try { - const record = await createSpecialStatus(req.user!.orgId, req.user!.id, req.body) + const data = createSpecialStatusSchema.parse(req.body) + const record = await createSpecialStatus(req.user!.orgId, req.user!.id, data) res.json({ success: true, data: record }) } catch (err: any) { if (err.code) { @@ -112,7 +114,8 @@ router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: N */ router.put('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { try { - const record = await updateSpecialStatus(req.user!.orgId, req.user!.id, req.params.id, req.body) + const data = updateSpecialStatusSchema.parse(req.body) + const record = await updateSpecialStatus(req.user!.orgId, req.user!.id, req.params.id, data) res.json({ success: true, data: record }) } catch (err: any) { if (err.code) { @@ -167,7 +170,6 @@ router.get('/stats/overview', authMiddleware, async (req: AuthRequest, res: Resp ]) // 预警统计 - const now = new Date() const soon7 = new Date() soon7.setDate(soon7.getDate() + 7) const soon30 = new Date() diff --git a/backend/src/routes/termination.routes.ts b/backend/src/routes/termination.routes.ts index ae029f0..6c120a8 100644 --- a/backend/src/routes/termination.routes.ts +++ b/backend/src/routes/termination.routes.ts @@ -1,7 +1,7 @@ import { Router } from 'express' import { authMiddleware, AuthRequest } from '../middleware/auth' import { auditLog } from '../middleware/auditLog' -import { terminationChecklistSchema } from '../schemas/termination.schema' +import { terminationChecklistSchema, resignationSchema, batchTerminatePreviewSchema, batchTerminateSchema, createTerminationDraftSchema, updateTerminationDraftSchema } from '../schemas/termination.schema' import { createTermination, createResignation, revokeTermination, getTerminations, getChecklistForReason, assessRisk, batchTerminatePreview, batchTerminate, createDraft, updateDraft, submitForApproval, approveTermination, rejectTermination, executeTermination, cancelTermination, getDrafts, getTerminationDetail, getDefaultHandoverItems, validateTerminationStep } from '../services/termination.service' import prisma from '../lib/prisma' import { createEvidence } from '../services/evidence.service' @@ -80,10 +80,7 @@ router.post('/', authMiddleware, async (req: AuthRequest, res, next) => { router.post('/resignation', authMiddleware, async (req: AuthRequest, res, next) => { try { - const { employeeId, terminationDate, resignationReason, remark } = req.body - if (!employeeId || !terminationDate) { - return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '缺少必填字段' } }) - } + const { employeeId, terminationDate, resignationReason, remark } = resignationSchema.parse(req.body) const result = await createResignation(req.user!.orgId, req.user!.id, { employeeId, terminationDate, resignationReason, remark }) await auditLog(req, 'RESIGN', 'EMPLOYEE', employeeId, { resignationReason }) res.json({ success: true, data: result }) @@ -120,12 +117,7 @@ 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 { items } = batchTerminatePreviewSchema.parse(req.body) 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) { @@ -136,12 +128,7 @@ router.post('/batch/preview', authMiddleware, async (req: AuthRequest, res, next // 批量解聘执行 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 { items } = batchTerminateSchema.parse(req.body) 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 }) @@ -192,15 +179,16 @@ router.get('/handover-template', authMiddleware, async (req: AuthRequest, res) = // 创建草稿 router.post('/draft', authMiddleware, async (req: AuthRequest, res, next) => { try { - const result = await createDraft(req.user!.orgId, req.user!.id, req.body) - const emp = await prisma.employee.findFirst({ where: { id: req.body.employeeId }, select: { name: true, department: true } }) + const data = createTerminationDraftSchema.parse(req.body) + const result = await createDraft(req.user!.orgId, req.user!.id, data) + const emp = await prisma.employee.findFirst({ where: { id: data.employeeId }, select: { name: true, department: true } }) await auditLog(req, 'CREATE_DRAFT', 'TERMINATION_RECORD', result.id, { employeeName: emp?.name || '', department: emp?.department || '', - reason: req.body.reason || '', - type: req.body.type || 'TERMINATION', - terminationDate: req.body.terminationDate || '', - compensation: req.body.compensation || 0, + reason: data.reason || '', + type: data.type || 'TERMINATION', + terminationDate: data.terminationDate || '', + compensation: data.compensation || 0, }) res.json({ success: true, data: result }) } catch (err: any) { @@ -214,7 +202,8 @@ router.post('/draft', authMiddleware, async (req: AuthRequest, res, next) => { // 更新草稿 router.put('/draft/:id', authMiddleware, async (req: AuthRequest, res, next) => { try { - const result = await updateDraft(req.user!.orgId, req.params.id, req.user!.id, req.body) + const data = updateTerminationDraftSchema.parse(req.body) + const result = await updateDraft(req.user!.orgId, req.params.id, req.user!.id, data) res.json({ success: true, data: result }) } catch (err: any) { if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') { diff --git a/backend/src/routes/work-process.routes.ts b/backend/src/routes/work-process.routes.ts index 6386b2b..c5ea1be 100644 --- a/backend/src/routes/work-process.routes.ts +++ b/backend/src/routes/work-process.routes.ts @@ -1,17 +1,17 @@ import { Router, Response, NextFunction } from 'express' import { authMiddleware, AuthRequest } from '../middleware/auth' import prisma from '../lib/prisma' +import { parsePagination } from '../lib/pagination' import { executeWorkProcess, generateDocument, PROCESS_TYPES, PROCESS_STATUS } from '../services/work-process.service' +import { createWorkProcessSchema, updateWorkProcessSchema } from '../schemas/work-process.schema' const router = Router() // 创建办理(含草稿) router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { try { - const { type, title, employeeId, formData, status = 'DRAFT', remark } = req.body - if (!type || !PROCESS_TYPES[type]) { - return res.status(400).json({ success: false, error: { code: 'INVALID_TYPE', message: '无效的流程类型' } }) - } + const data = createWorkProcessSchema.parse(req.body) + const { type, title, employeeId, formData, status, remark } = data const process = await (prisma as any).workProcess.create({ data: { orgId: req.user!.orgId, @@ -33,7 +33,8 @@ router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: N // 列表查询 router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { try { - const { type, status, page = '1', pageSize = '20' } = req.query + const { type, status } = req.query + const { page, pageSize } = parsePagination(req.query) const where: any = { orgId: req.user!.orgId } if (type) where.type = type if (status) where.status = status @@ -42,10 +43,10 @@ router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: Ne where, include: { employee: { select: { id: true, name: true, department: true } } }, orderBy: { createdAt: 'desc' }, - skip: (Number(page) - 1) * Number(pageSize), - take: Number(pageSize), + skip: (page - 1) * pageSize, + take: pageSize, }) - res.json({ success: true, data: { items, total, page: Number(page), pageSize: Number(pageSize) } }) + res.json({ success: true, data: { items, total, page, pageSize } }) } catch (err) { next(err) } @@ -79,7 +80,7 @@ router.patch('/:id', authMiddleware, async (req: AuthRequest, res: Response, nex if (existing.status !== 'DRAFT') { return res.status(400).json({ success: false, error: { code: 'NOT_DRAFT', message: '仅草稿状态可编辑' } }) } - const { title, employeeId, formData, remark } = req.body + const { title, employeeId, formData, remark } = updateWorkProcessSchema.parse(req.body) const updated = await (prisma as any).workProcess.update({ where: { id: req.params.id }, data: { @@ -116,7 +117,7 @@ router.post('/:id/submit', authMiddleware, async (req: AuthRequest, res: Respons } // 生成文书 const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } }) - const doc = generateDocument(process.type, process.formData, org?.name || '') + const doc = await generateDocument(process.type, process.formData, org?.name || '') const documents = doc.content ? [doc] : [] const updated = await (prisma as any).workProcess.update({ where: { id: process.id }, @@ -151,7 +152,7 @@ router.post('/:id/approve', authMiddleware, async (req: AuthRequest, res: Respon return res.status(400).json({ success: false, error: { code: 'EXEC_FAILED', message: `执行失败:${execErr?.message || '未知错误'}` } }) } const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } }) - const doc = generateDocument(process.type, process.formData, org?.name || '') + const doc = await generateDocument(process.type, process.formData, org?.name || '') const documents = doc.content ? [doc] : [] const updated = await (prisma as any).workProcess.update({ where: { id: process.id }, @@ -247,7 +248,7 @@ router.get('/:id/preview', authMiddleware, async (req: AuthRequest, res: Respons return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } }) } const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } }) - const doc = generateDocument(process.type, process.formData, org?.name || '') + const doc = await generateDocument(process.type, process.formData, org?.name || '') res.json({ success: true, data: doc }) } catch (err) { next(err) diff --git a/backend/src/schemas/platform.schema.ts b/backend/src/schemas/platform.schema.ts new file mode 100644 index 0000000..cf228f1 --- /dev/null +++ b/backend/src/schemas/platform.schema.ts @@ -0,0 +1,34 @@ +import { z } from 'zod' + +export const createOrgSchema = z.object({ + name: z.string().min(2, '企业名称至少2个字').max(50, '企业名称最多50个字'), + plan: z.enum(['FREE', 'PRO', 'ENTERPRISE']).default('FREE'), + maxEmployees: z.number().int().min(1).max(100000).default(20), + city: z.string().max(50).optional(), + contactName: z.string().max(30).optional(), + contactPhone: z.string().regex(/^1[3-9]\d{9}$/).optional(), + adminName: z.string().max(30).optional(), + adminPhone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'), + adminPassword: z.string().min(8, '密码至少8位').max(32, '密码最多32位'), +}) + +export const updateOrgSchema = z.object({ + name: z.string().min(2).max(50).optional(), + plan: z.enum(['FREE', 'PRO', 'ENTERPRISE']).optional(), + maxEmployees: z.number().int().min(1).max(100000).optional(), + city: z.string().max(50).optional().nullable(), + contactName: z.string().max(30).optional().nullable(), + contactPhone: z.string().regex(/^1[3-9]\d{9}$/).optional().nullable(), +}) + +export const updateOrgAdminSchema = z.object({ + adminName: z.string().max(30).optional(), + adminPhone: z.string().regex(/^1[3-9]\d{9}$/).optional(), + adminPassword: z.string().min(8, '密码至少8位').max(32).optional(), +}) + +export const createPlatformAdminSchema = z.object({ + name: z.string().min(1, '姓名不能为空').max(30), + phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'), + password: z.string().min(8, '密码至少8位').max(32, '密码最多32位'), +}) diff --git a/backend/src/schemas/special-status.schema.ts b/backend/src/schemas/special-status.schema.ts new file mode 100644 index 0000000..fa0e205 --- /dev/null +++ b/backend/src/schemas/special-status.schema.ts @@ -0,0 +1,40 @@ +import { z } from 'zod' + +export const createSpecialStatusSchema = z.object({ + employeeId: z.string().min(1, '员工ID不能为空'), + type: z.enum([ + 'PREGNANCY', 'WORK_INJURY', 'MEDICAL_PERIOD', 'OTHER', + ], { errorMap: () => ({ message: '无效的特殊状态类型' }) }), + status: z.enum(['ACTIVE', 'PENDING', 'RESOLVED']).default('ACTIVE'), + startDate: z.string().optional().nullable(), + endDate: z.string().optional().nullable(), + expectedDueDate: z.string().optional().nullable(), + injuryDate: z.string().optional().nullable(), + injuryDescription: z.string().max(500).optional().nullable(), + certificationDate: z.string().optional().nullable(), + certificationNo: z.string().max(50).optional().nullable(), + disabilityLevel: z.string().max(20).optional().nullable(), + assessmentDate: z.string().optional().nullable(), + medicalMonths: z.number().int().min(1).max(36).optional().nullable(), + description: z.string().max(500).optional().nullable(), + attachments: z.array(z.any()).optional().nullable(), + reminderDate: z.string().optional().nullable(), +}) + +export const updateSpecialStatusSchema = z.object({ + type: z.enum(['PREGNANCY', 'WORK_INJURY', 'MEDICAL_PERIOD', 'OTHER']).optional(), + status: z.enum(['ACTIVE', 'PENDING', 'RESOLVED']).optional(), + startDate: z.string().optional().nullable(), + endDate: z.string().optional().nullable(), + expectedDueDate: z.string().optional().nullable(), + injuryDate: z.string().optional().nullable(), + injuryDescription: z.string().max(500).optional().nullable(), + certificationDate: z.string().optional().nullable(), + certificationNo: z.string().max(50).optional().nullable(), + disabilityLevel: z.string().max(20).optional().nullable(), + assessmentDate: z.string().optional().nullable(), + medicalMonths: z.number().int().min(1).max(36).optional().nullable(), + description: z.string().max(500).optional().nullable(), + attachments: z.array(z.any()).optional().nullable(), + reminderDate: z.string().optional().nullable(), +}) diff --git a/backend/src/schemas/termination.schema.ts b/backend/src/schemas/termination.schema.ts index 64857e7..826cf00 100644 --- a/backend/src/schemas/termination.schema.ts +++ b/backend/src/schemas/termination.schema.ts @@ -13,3 +13,45 @@ export const terminationQuerySchema = z.object({ page: z.coerce.number().min(1).default(1), pageSize: z.coerce.number().min(1).max(50).default(20), }) + +export const resignationSchema = z.object({ + employeeId: z.string().min(1, '员工ID不能为空'), + terminationDate: z.string().min(1, '离职日期不能为空'), + resignationReason: z.string().max(200).optional(), + remark: z.string().max(500).optional(), +}) + +export const batchTerminatePreviewSchema = z.object({ + items: z.array(z.object({ + employeeId: z.string().min(1), + reason: z.string().min(1, '解聘原因不能为空'), + terminationDate: z.string().min(1, '解聘日期不能为空'), + })).min(1, '至少选择一名员工'), +}) + +export const batchTerminateSchema = z.object({ + items: z.array(z.object({ + employeeId: z.string().min(1), + reason: z.string().min(1, '解聘原因不能为空'), + terminationDate: z.string().min(1, '解聘日期不能为空'), + compensation: z.number().min(0).optional(), + })).min(1, '至少选择一名员工'), +}) + +export const createTerminationDraftSchema = z.object({ + employeeId: z.string().min(1, '员工ID不能为空'), + type: z.enum(['TERMINATION', 'RESIGNATION']).optional(), + reason: z.string().max(200).optional(), + terminationDate: z.string().optional(), + compensation: z.number().min(0).optional(), + handoverItems: z.array(z.any()).optional(), + remark: z.string().max(500).optional(), +}) + +export const updateTerminationDraftSchema = z.object({ + reason: z.string().max(200).optional(), + terminationDate: z.string().optional(), + compensation: z.number().min(0).optional(), + handoverItems: z.array(z.any()).optional(), + remark: z.string().max(500).optional(), +}) diff --git a/backend/src/schemas/work-process.schema.ts b/backend/src/schemas/work-process.schema.ts new file mode 100644 index 0000000..7d62a3c --- /dev/null +++ b/backend/src/schemas/work-process.schema.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' + +export const createWorkProcessSchema = z.object({ + type: z.enum([ + 'HIRE', 'ONBOARD', 'CUSTOM_CONTRACT', 'INFO_SUBMIT', 'CONFIRM', + 'CHANGE', 'RENEW', 'SUSPEND', 'INCOME_CERT', 'TERMINATE', + 'RESCIND', 'LEAVING_CERT', 'FLEXIBLE', + ]), + title: z.string().max(100).optional(), + employeeId: z.string().optional().nullable(), + formData: z.record(z.any()).optional(), + status: z.enum(['DRAFT', 'COMPLETED']).default('DRAFT'), + remark: z.string().max(500).optional().nullable(), +}) + +export const updateWorkProcessSchema = z.object({ + title: z.string().max(100).optional(), + employeeId: z.string().optional().nullable(), + formData: z.record(z.any()).optional(), + remark: z.string().max(500).optional().nullable(), +}) diff --git a/backend/src/services/payroll.service.ts b/backend/src/services/payroll.service.ts index f5e9148..ffdbdb8 100644 --- a/backend/src/services/payroll.service.ts +++ b/backend/src/services/payroll.service.ts @@ -205,6 +205,12 @@ export async function calcBatchEntry( housingOrg = Math.max(0, fullHousingOrg - deductedHousingOrg) } + // 保存系统计算值(覆盖前) + const systemSocialEmp = socialEmp + const systemSocialOrg = socialOrg + const systemHousingEmp = housingEmp + const systemHousingOrg = housingOrg + // 手动覆盖社保值 if (options?.overrideSocial) { if (options.overrideSocial.socialEmp !== undefined) socialEmp = options.overrideSocial.socialEmp @@ -229,9 +235,11 @@ export async function calcBatchEntry( // 个税计算 let tax = 0 + let taxBreakdown: any = null if (batchType === 'BONUS') { // 年终奖单独计税 tax = calcBonusTax(inputs.bonus) + taxBreakdown = { method: '单独计税(年终奖)', bonus: inputs.bonus, tax } } else { // 累计预扣法(补偿金也走累计预扣,但无社保公积金扣除) const year = month.slice(0, 4) @@ -252,8 +260,22 @@ export async function calcBatchEntry( const ytdHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp, 0) + housingEmp const ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7)) const ytdTaxDeducted = archivedEntries.reduce((s, e) => s + e.tax, 0) - const ytdTaxableIncome = Math.max(0, ytdIncome - 5000 * Number(month.slice(5, 7)) - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction) + const deductionAmount = 5000 * Number(month.slice(5, 7)) + const ytdTaxableIncome = Math.max(0, ytdIncome - deductionAmount - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction) tax = calcCumulativeTax(ytdTaxableIncome, ytdTaxDeducted) + taxBreakdown = { + method: '累计预扣法', + month: Number(month.slice(5, 7)), + ytdIncome, + deductionAmount, + ytdSocialEmp, + ytdHousingEmp, + ytdSpecialDeduction, + ytdTaxableIncome, + ytdTaxDeducted, + currentMonthTax: tax, + archivedCount: archivedEntries.length, + } } const netPay = totalPay - socialEmp - housingEmp - tax @@ -263,7 +285,12 @@ export async function calcBatchEntry( socialOrg: Math.round(socialOrg * 100) / 100, housingEmp: Math.round(housingEmp * 100) / 100, housingOrg: Math.round(housingOrg * 100) / 100, + systemSocialEmp: Math.round(systemSocialEmp * 100) / 100, + systemSocialOrg: Math.round(systemSocialOrg * 100) / 100, + systemHousingEmp: Math.round(systemHousingEmp * 100) / 100, + systemHousingOrg: Math.round(systemHousingOrg * 100) / 100, tax, + taxBreakdown, totalPay: Math.round(totalPay * 100) / 100, netPay: Math.round(netPay * 100) / 100, } diff --git a/backend/src/services/risk.service.ts b/backend/src/services/risk.service.ts index 727fa34..bda1022 100644 --- a/backend/src/services/risk.service.ts +++ b/backend/src/services/risk.service.ts @@ -666,7 +666,7 @@ export async function getDashboardData(orgId: string) { const yearEnd = new Date(now.getFullYear(), 11, 31, 23, 59, 59) const [ - employeeCount, highRisks, pendingRisks, riskItems, resolvedItems, + employeeCount, _highRisks, _pendingRisks, riskItems, resolvedItems, overtimeRecords, payslips, batchEntries, socialConfig, housingConfig, monthContracts, monthTerminations, monthDisciplinary, monthAttendance, monthSeverancePay, @@ -1217,17 +1217,13 @@ export async function getCostAnalysis(orgId: string, month: string) { const monthNum = parseInt(month.slice(5, 7)) // 当月数据 - const currentMonthStart = new Date(year, monthNum - 1, 1) - const currentMonthEnd = new Date(year, monthNum, 0, 23, 59, 59) // 上月(环比) const prevMonthStart = new Date(year, monthNum - 2, 1) - const prevMonthEnd = new Date(year, monthNum - 1, 0, 23, 59, 59) const prevMonthStr = `${prevMonthStart.getFullYear()}-${String(prevMonthStart.getMonth() + 1).padStart(2, '0')}` // 去年同月(同比) const lastYearMonthStart = new Date(year - 1, monthNum - 1, 1) - const lastYearMonthEnd = new Date(year - 1, monthNum, 0, 23, 59, 59) const lastYearMonthStr = `${lastYearMonthStart.getFullYear()}-${String(lastYearMonthStart.getMonth() + 1).padStart(2, '0')}` // 获取各月归档批次汇总(按员工去重,与 getDashboardData 口径一致) @@ -1802,7 +1798,7 @@ export async function getAnnualValueReport(orgId: string, year: number) { const [ risksResolved, - lossAvoidedAgg, + _lossAvoidedAgg, aiConversations, aiReviews, contractsSigned, @@ -2043,11 +2039,6 @@ export async function getAnnualValueReport(orgId: string, year: number) { // 总价值 = 规避损失 + 节约成本 const totalValue = adjustedLossAvoided + costSaved - // ROI = 总价值 / 系统成本(年费 12000 元),上限 9999% - const systemCost = 12000 - const rawRoi = systemCost > 0 ? Math.round((totalValue / systemCost) * 100) : 0 - const roi = Math.min(rawRoi, 9999) - const metrics = { risksResolved, lossAvoided, diff --git a/backend/src/services/work-process.service.ts b/backend/src/services/work-process.service.ts index db7133d..712f563 100644 --- a/backend/src/services/work-process.service.ts +++ b/backend/src/services/work-process.service.ts @@ -1,8 +1,7 @@ import prisma from '../lib/prisma' import { encrypt } from '../lib/crypto' -import crypto from 'crypto' -import { createDraft as createTerminationDraft, executeTermination, createResignation } from './termination.service' -import { createEmployee, addContract, batchRenew } from './contract.service' +import { createDraft as createTerminationDraft, executeTermination } from './termination.service' +import { createEmployee, addContract } from './contract.service' import { runRiskDetection } from './risk.service' // 13类流程定义 @@ -67,7 +66,7 @@ export async function executeWorkProcess(processId: string, type: string, formDa return { employeeId } } case 'CONFIRM': { - const { employeeId, confirmDate, regularSalary } = formData + const { employeeId, regularSalary } = formData if (employeeId) { if (regularSalary) { await prisma.employee.update({ @@ -196,7 +195,7 @@ export async function executeWorkProcess(processId: string, type: string, formDa return {} } case 'FLEXIBLE': { - const { name, phone, idCardNumber, department, agreementStartDate, agreementEndDate, payMethod } = formData + const { name, phone, idCardNumber, department, agreementStartDate, agreementEndDate } = formData const empResult = await createEmployee(orgId, userId, { name, department: department || '灵活用工', @@ -244,7 +243,23 @@ export async function executeWorkProcess(processId: string, type: string, formDa } // 生成文书预览 -export function generateDocument(type: string, formData: any, orgName: string): { name: string; content: string } { +export async function generateDocument(type: string, formData: any, orgName: string): Promise<{ name: string; content: string }> { + // 如果指定了企业自定义模板,使用企业模板渲染 + if (formData.enterpriseTemplateId) { + const tpl = await (prisma as any).enterpriseTemplate.findFirst({ + where: { id: formData.enterpriseTemplateId }, + }) + if (tpl) { + let content = tpl.content + // 替换变量 {{var}} + const allVars: Record = { ...formData, companyName: orgName } + for (const [key, value] of Object.entries(allVars)) { + content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(value ?? '')) + } + return { name: `${tpl.name}.doc`, content } + } + } + const templates: Record string> = { INCOME_CERT: (data, org) => `收入证明 diff --git a/frontend/public/acceptance-test.html b/frontend/public/acceptance-test.html index 0131e73..6a10738 100644 --- a/frontend/public/acceptance-test.html +++ b/frontend/public/acceptance-test.html @@ -3,6 +3,9 @@ + + + TurboHR 验收测试清单 @@ -221,11 +291,11 @@ body { + - - + @@ -239,6 +309,25 @@ body {
完成率 0%
+ +
+ + +
+ + +
+ + + + +
+ +
+ + 快捷键: P=通过 F=失败 W=部分通过 U=未测试 ↑↓=切换行 +
+
@@ -516,10 +605,68 @@ const DOMAINS = [ { id:"24.2", func:"命令面板", steps:"按Cmd+K/Ctrl+K", expected:"弹出命令面板,可快速跳转" }, { id:"24.3", func:"搜索无结果", steps:"输入不存在的关键词", expected:"显示未找到相关结果" }, ]}, + { name: "25. 合同管理", items: [ + { id:"25.1", func:"合同列表", steps:"访问合同管理页面", expected:"显示合同列表,含员工/类型/起止日/状态" }, + { id:"25.2", func:"合同分页", steps:"翻页查看合同列表", expected:"分页正常,每页显示对应数量" }, + { id:"25.3", func:"合同搜索", steps:"搜索员工姓名", expected:"列表过滤显示匹配合同" }, + { id:"25.4", func:"合同筛选(状态)", steps:"筛选即将到期/已过期", expected:"仅显示对应状态合同" }, + { id:"25.5", func:"合同详情", steps:"点击合同查看详情", expected:"显示合同完整信息" }, + { id:"25.6", func:"添加合同", steps:"点击添加→填写信息→保存", expected:"新合同创建成功" }, + { id:"25.7", func:"编辑合同", steps:"编辑已有合同→保存", expected:"合同信息更新" }, + { id:"25.8", func:"删除合同", steps:"删除某合同", expected:"合同从列表消失" }, + { id:"25.9", func:"合同到期预警", steps:"查看即将到期合同", expected:"显示到期天数提醒" }, + ]}, + { name: "26. 薪酬模版管理", items: [ + { id:"26.1", func:"薪酬模版列表", steps:"访问薪酬模版页面", expected:"显示模版项列表" }, + { id:"26.2", func:"创建薪酬模版", steps:"填写名称/类型/公式→保存", expected:"新模版项创建成功" }, + { id:"26.3", func:"编辑薪酬模版", steps:"编辑已有模版→保存", expected:"模版信息更新" }, + { id:"26.4", func:"删除薪酬模版", steps:"删除某模版项", expected:"模版项从列表消失" }, + { id:"26.5", func:"模版分类筛选", steps:"按类型筛选(收入/扣除)", expected:"仅显示对应类型模版" }, + ]}, + { name: "27. 年度价值报告", items: [ + { id:"27.1", func:"年度价值报告", steps:"查看首页年度价值报告", expected:"显示年度价值报告卡片" }, + { id:"27.2", func:"价值报告详情", steps:"点击查看详情", expected:"显示年度人力成本/效率/风险等指标" }, + ]}, + { name: "28. 验收测试", items: [ + { id:"28.1", func:"验收测试保存", steps:"填写验收人→标记测试项→保存", expected:"保存成功,toast提示" }, + { id:"28.2", func:"验收测试加载", steps:"重新进入→选择已保存验收人", expected:"恢复之前保存的测试结果" }, + { id:"28.3", func:"验收测试提交", steps:"完成测试→点击提交报告", expected:"状态变为已提交" }, + { id:"28.4", func:"验收结论自动计算", steps:"标记测试项结果", expected:"总体结论根据结果自动计算" }, + { id:"28.5", func:"验收测试筛选", steps:"点击未测试/失败筛选", expected:"仅显示对应结果测试项" }, + { id:"28.6", func:"验收测试搜索", steps:"输入关键词搜索", expected:"仅显示匹配的测试项" }, + { id:"28.7", func:"验收测试导出", steps:"下载CSV/Excel/PDF", expected:"文件下载成功,内容完整" }, + { id:"28.8", func:"验收测试截图", steps:"点击截图列→上传图片", expected:"截图显示在单元格中" }, + { id:"28.9", func:"验收测试签字", steps:"在签名区域手写签名", expected:"签名显示在画布上" }, + ]}, ]; const RESULT_LABELS = { pass: "✅", fail: "❌", partial: "⚠️", untested: "🚫" }; -const STORAGE_PREFIX = "turbohr_acceptance_"; + +// ========== API 工具 ========== +function getAuthToken() { + try { + const raw = window.parent.localStorage.getItem('auth-storage'); + if (raw) return JSON.parse(raw).state?.accessToken || ''; + } catch {} + return ''; +} + +const API_BASE = (() => { + try { + const loc = window.parent.location; + if (loc.port === '5173' || loc.port === '3001') return 'http://localhost:3000/api/v1'; + return loc.origin + '/api/v1'; + } catch { return '/api/v1'; } +})(); + +async function apiCall(method, path, body) { + const token = getAuthToken(); + const opts = { method, headers: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '' } }; + if (body) opts.body = JSON.stringify(body); + const res = await fetch(`${API_BASE}${path}`, opts); + if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error?.message || `请求失败(${res.status})`); } + return res.json(); +} // ========== 渲染 ========== function renderAll() { @@ -538,11 +685,12 @@ function renderAll() { #功能 操作步骤预期结果 结果备注 + 截图 `; domain.items.forEach((item, ii) => { const uid = `${di}-${ii}`; - html += ` + html += ` ${item.id} ${item.func} ${item.steps} @@ -556,6 +704,7 @@ function renderAll() { +
+
`; }); html += ``; @@ -584,24 +733,41 @@ function renderAll() { `; html += ``; - // Conclusion + // Conclusion (auto-calculated) html += `
-

验收结论

-
-
-
-
- - - +

验收结论(系统自动计算)

+
+
+
未测试
+
-
+
+ +
`; + + // Signature + html += `
+

验收签字

+
+
+
验收人签字
+
+ + 清除 +
+
+
+
项目经理签字
+
+ + 清除 +
-
-
`; main.innerHTML = html; updateStats(); + initSignatures(); } // ========== 交互 ========== @@ -612,69 +778,226 @@ function toggleDomain(di) { toggle.textContent = el.classList.contains("collapsed") ? "▶" : "▼"; } +function expandAll() { + DOMAINS.forEach((_, di) => { + const el = document.getElementById(`domain-${di}`); + if (el) { el.classList.remove("collapsed"); el.querySelector(".toggle").textContent = "▼"; } + }); +} + +function collapseAll() { + DOMAINS.forEach((_, di) => { + const el = document.getElementById(`domain-${di}`); + if (el) { el.classList.add("collapsed"); el.querySelector(".toggle").textContent = "▶"; } + }); +} + +// ========== 筛选 ========== +let currentFilter = 'all'; + +function setFilter(filter) { + currentFilter = filter; + ['all','untested','fail','partial'].forEach(f => { + const btn = document.getElementById('filter' + f.charAt(0).toUpperCase() + f.slice(1)); + if (btn) btn.classList.toggle('active', f === filter); + }); + applyFilter(); +} + +function applyFilter() { + const searchQ = (document.getElementById('searchInput')?.value || '').toLowerCase(); + DOMAINS.forEach((domain, di) => { + domain.items.forEach((item, ii) => { + const uid = `${di}-${ii}`; + const row = document.getElementById(`row-${uid}`); + if (!row) return; + + // Get current result + const checked = document.querySelector(`input[name="r-${uid}"]:checked`); + const val = checked ? checked.value : "untested"; + + // Filter by result + let showByFilter = true; + if (currentFilter === 'untested' && val !== 'untested') showByFilter = false; + else if (currentFilter === 'fail' && val !== 'fail') showByFilter = false; + else if (currentFilter === 'partial' && val !== 'partial') showByFilter = false; + + // Filter by search + let showBySearch = true; + if (searchQ) { + const text = (item.func + item.steps + item.expected + item.id).toLowerCase(); + showBySearch = text.includes(searchQ); + } + + row.classList.toggle('filtered-out', !(showByFilter && showBySearch)); + + // Row color + row.classList.remove('row-fail', 'row-partial'); + if (val === 'fail') row.classList.add('row-fail'); + else if (val === 'partial') row.classList.add('row-partial'); + }); + + // Auto-expand domain if it has visible items when filtering + if (currentFilter !== 'all' || searchQ) { + const el = document.getElementById(`domain-${di}`); + const hasVisible = domain.items.some((_, ii) => { + const row = document.getElementById(`row-${di}-${ii}`); + return row && !row.classList.contains('filtered-out'); + }); + if (hasVisible && el) { el.classList.remove("collapsed"); el.querySelector(".toggle").textContent = "▼"; } + } + }); +} + +function onSearch() { applyFilter(); } + +function markAllPass() { + if (!confirm('将所有「未测试」项标记为「通过」?')) return; + DOMAINS.forEach((domain, di) => { + domain.items.forEach((item, ii) => { + const uid = `${di}-${ii}`; + const checked = document.querySelector(`input[name="r-${uid}"]:checked`); + const val = checked ? checked.value : "untested"; + if (val === "untested") { + const radio = document.querySelector(`input[name="r-${uid}"][value="pass"]`); + if (radio) radio.checked = true; + } + }); + }); + updateStats(); + scheduleAutoSave(); + showToast("已将所有未测试项标记为通过"); +} + +// ========== 键盘快捷键 ========== +let currentRowIdx = 0; +const flatItems = []; +function buildFlatItems() { + flatItems.length = 0; + DOMAINS.forEach((domain, di) => { + domain.items.forEach((item, ii) => { + flatItems.push({ di, ii, uid: `${di}-${ii}` }); + }); + }); +} + +document.addEventListener('keydown', (e) => { + // Skip if typing in input + if (e.target.tagName === 'INPUT' && e.target.type === 'text') return; + if (e.target.tagName === 'TEXTAREA') return; + if (e.target.tagName === 'SELECT') return; + + const key = e.key.toLowerCase(); + const resultMap = { p: 'pass', f: 'fail', w: 'partial', u: 'untested' }; + + if (key === 'arrowdown') { + e.preventDefault(); + buildFlatItems(); + currentRowIdx = Math.min(currentRowIdx + 1, flatItems.length - 1); + focusRow(currentRowIdx); + } else if (key === 'arrowup') { + e.preventDefault(); + buildFlatItems(); + currentRowIdx = Math.max(currentRowIdx - 1, 0); + focusRow(currentRowIdx); + } else if (resultMap[key]) { + e.preventDefault(); + buildFlatItems(); + if (currentRowIdx >= 0 && currentRowIdx < flatItems.length) { + const { di, ii, uid } = flatItems[currentRowIdx]; + const radio = document.querySelector(`input[name="r-${uid}"][value="${resultMap[key]}"]`); + if (radio) { + radio.checked = true; + onResultChange(di, ii, resultMap[key]); + // Auto-advance to next row + if (key !== 'u') { + currentRowIdx = Math.min(currentRowIdx + 1, flatItems.length - 1); + setTimeout(() => focusRow(currentRowIdx), 100); + } + } + } + } +}); + +function focusRow(idx) { + buildFlatItems(); + if (idx >= 0 && idx < flatItems.length) { + const { uid } = flatItems[idx]; + const row = document.getElementById(`row-${uid}`); + if (row) { + row.scrollIntoView({ block: 'center', behavior: 'smooth' }); + row.style.transition = 'background 0.3s'; + row.style.background = 'var(--primary-light)'; + setTimeout(() => { row.style.background = ''; }, 600); + } + } +} + +let saveTimer = null; function onResultChange(di, ii, result) { updateStats(); - autoSave(); + scheduleAutoSave(); } function onRemarkChange(di, ii, value) { - autoSave(); + scheduleAutoSave(); } function onVerifierChange() { - updateSavedList(); - autoSave(); showAutocomplete(); } -// ========== 自动补全 ========== +function scheduleAutoSave() { + const name = document.getElementById("verifierName").value.trim(); + if (!name) return; + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(() => autoSave(), 1000); +} + +// ========== 自动补全(从后端加载)========== let acActiveIndex = -1; let acItems = []; +let savedRecords = []; -function getSavedNames() { - const names = []; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key && key.startsWith(STORAGE_PREFIX)) { - const name = key.substring(STORAGE_PREFIX.length); - let meta = ''; - try { - const data = JSON.parse(localStorage.getItem(key) || '{}'); - const vals = Object.values(data.results || {}); - const pass = vals.filter(v => v === 'pass').length; - const total = vals.length; - const tested = vals.filter(v => v !== 'untested').length; - meta = `${tested}/${total} 已测 · ${pass} 通过`; - } catch {} - names.push({ name, meta }); - } - } - return names.sort((a, b) => a.name.localeCompare(b.name)); +async function loadSavedList() { + try { + const res = await apiCall('GET', '/acceptance-tests'); + savedRecords = res.data || []; + updateSavedSelect(); + } catch (err) { savedRecords = []; } +} + +function updateSavedSelect() { + const sel = document.getElementById("savedSelect"); + const current = sel.value; + sel.innerHTML = ''; + savedRecords.forEach(r => { + const tag = r.status === 'SUBMITTED' ? ' [已提交]' : ''; + sel.innerHTML += ``; + }); + if (current) sel.value = current; } function showAutocomplete() { const input = document.getElementById('verifierName'); const list = document.getElementById('autocompleteList'); const q = input.value.trim().toLowerCase(); - const saved = getSavedNames(); - + if (!q) { - // 输入为空时显示全部已保存 - acItems = saved; + acItems = savedRecords.map(r => ({ name: r.verifierName, meta: `${r.testedCount}/${r.totalCount} 已测 · ${r.passCount} 通过${r.status === 'SUBMITTED' ? ' · 已提交' : ''}` })); } else { - // 按字母/拼音模糊匹配 - acItems = saved.filter(s => s.name.toLowerCase().includes(q)); + acItems = savedRecords.filter(r => r.verifierName.toLowerCase().includes(q)).map(r => ({ name: r.verifierName, meta: `${r.testedCount}/${r.totalCount} 已测 · ${r.passCount} 通过${r.status === 'SUBMITTED' ? ' · 已提交' : ''}` })); } - + acActiveIndex = -1; - + if (acItems.length === 0) { list.innerHTML = '
无匹配记录
'; list.classList.add('show'); return; } - - list.innerHTML = acItems.map((item, i) => + + list.innerHTML = acItems.map((item, i) => `
${item.name} @@ -789,35 +1112,117 @@ function updateStats() { document.getElementById("total-partial").textContent = totalPartial; document.getElementById("total-untested").textContent = totalUntested; document.getElementById("total-rate").textContent = rate + "%"; + + // Overall progress bar + const progressEl = document.getElementById("overallProgress"); + if (progressEl) progressEl.style.width = rate + "%"; + + // Row colors + DOMAINS.forEach((domain, di) => { + domain.items.forEach((item, ii) => { + const uid = `${di}-${ii}`; + const row = document.getElementById(`row-${uid}`); + if (!row) return; + const checked2 = document.querySelector(`input[name="r-${uid}"]:checked`); + const val2 = checked2 ? checked2.value : "untested"; + row.classList.remove('row-fail', 'row-partial'); + if (val2 === 'fail') row.classList.add('row-fail'); + else if (val2 === 'partial') row.classList.add('row-partial'); + }); + }); + + // 自动计算结论 + updateConclusion(totalPass, totalFail, totalPartial, totalUntested, totalItems); } -// ========== 保存/加载 ========== -function collectData() { - const results = {}; - const remarks = {}; +function updateConclusion(pass, fail, partial, untested, total) { + const resultEl = document.getElementById("conclusionResult"); + const passRateEl = document.getElementById("conclusionPassRate"); + const issuesEl = document.getElementById("conclusionIssues"); + const nameEl = document.getElementById("conclusionName"); + const dateEl = document.getElementById("conclusionDate"); + if (!resultEl) return; + + const tested = pass + fail + partial; + const passRate = tested > 0 ? Math.round(pass / tested * 100) : 0; + let conclusion = "未测试", color = "var(--gray)"; + + if (tested === 0) { conclusion = "未测试"; color = "var(--gray)"; } + else if (fail === 0 && partial === 0) { conclusion = "✅ 通过"; color = "var(--success)"; } + else if (fail === 0 && partial > 0) { conclusion = "⚠️ 有条件通过"; color = "var(--warning)"; } + else { conclusion = "❌ 不通过"; color = "var(--danger)"; } + + resultEl.textContent = conclusion; + resultEl.style.color = color; + passRateEl.textContent = tested > 0 ? `${passRate}%(${pass}/${tested})` : "-"; + + // 自动汇总主要问题 + const issues = []; DOMAINS.forEach((domain, di) => { domain.items.forEach((item, ii) => { const uid = `${di}-${ii}`; const checked = document.querySelector(`input[name="r-${uid}"]:checked`); - results[item.id] = checked ? checked.value : "untested"; + const val = checked ? checked.value : "untested"; const remarkEl = document.getElementById(`remark-${uid}`); - remarks[item.id] = remarkEl ? remarkEl.value : ""; + const remark = remarkEl ? remarkEl.value.trim() : ""; + if (val === "fail") issues.push(`❌ [${item.id}] ${item.func}${remark ? ` — ${remark}` : ""}`); + else if (val === "partial") issues.push(`⚠️ [${item.id}] ${item.func}${remark ? ` — ${remark}` : ""}`); }); }); + issuesEl.value = issues.length > 0 ? issues.join("\n") : "无主要问题"; + + const verifier = document.getElementById("verifierName").value.trim(); + if (verifier) nameEl.value = verifier; + if (dateEl) dateEl.value = todayStr(); +} + +// ========== 数据收集/恢复 ========== +function collectData() { + const results = {}; + const remarks = {}; + let totalPass=0, totalFail=0, totalPartial=0, totalUntested=0, totalItems=0; + const issuesList = []; + + DOMAINS.forEach((domain, di) => { + domain.items.forEach((item, ii) => { + const uid = `${di}-${ii}`; + const checked = document.querySelector(`input[name="r-${uid}"]:checked`); + const val = checked ? checked.value : "untested"; + results[item.id] = val; + const remarkEl = document.getElementById(`remark-${uid}`); + remarks[item.id] = remarkEl ? remarkEl.value : ""; + + if (val==="pass") totalPass++; + else if (val==="fail") { totalFail++; issuesList.push(`❌ [${item.id}] ${item.func}${remarkEl?.value.trim() ? ` — ${remarkEl.value.trim()}` : ""}`); } + else if (val==="partial") { totalPartial++; issuesList.push(`⚠️ [${item.id}] ${item.func}${remarkEl?.value.trim() ? ` — ${remarkEl.value.trim()}` : ""}`); } + else totalUntested++; + totalItems++; + }); + }); + + const tested = totalPass + totalFail + totalPartial; + let conclusionResult = ""; + if (tested === 0) conclusionResult = ""; + else if (totalFail === 0 && totalPartial === 0) conclusionResult = "pass"; + else if (totalFail === 0 && totalPartial > 0) conclusionResult = "conditional"; + else conclusionResult = "fail"; + return { verifier: document.getElementById("verifierName").value, results, remarks, conclusionName: document.getElementById("conclusionName")?.value || "", conclusionDate: document.getElementById("conclusionDate")?.value || "", - conclusionResult: document.querySelector('input[name="conclusion"]:checked')?.value || "", - conclusionIssues: document.getElementById("conclusionIssues")?.value || "", - savedAt: new Date().toISOString(), + conclusionResult, + conclusionIssues: issuesList.join("\n"), + screenshots, + signature1: getSigData(1), + signature2: getSigData(2), }; } function applyData(data) { if (!data) return; - document.getElementById("verifierName").value = data.verifier || ""; + document.getElementById("verifierName").value = data.verifierName || data.verifier || ""; DOMAINS.forEach((domain, di) => { domain.items.forEach((item, ii) => { const uid = `${di}-${ii}`; @@ -830,87 +1235,109 @@ function applyData(data) { }); if (data.conclusionName) document.getElementById("conclusionName").value = data.conclusionName; if (data.conclusionDate) document.getElementById("conclusionDate").value = data.conclusionDate; - if (data.conclusionResult) { - const r = document.querySelector(`input[name="conclusion"][value="${data.conclusionResult}"]`); - if (r) r.checked = true; + + // Restore screenshots + if (data.screenshots) { + Object.assign(screenshots, data.screenshots); + DOMAINS.forEach((domain, di) => { + domain.items.forEach((item, ii) => { + const uid = `${di}-${ii}`; + if (screenshots[uid]) { + const cell = document.querySelector(`#row-${uid} .screenshot-cell`); + if (cell) cell.innerHTML = ``; + } + }); + }); } - if (data.conclusionIssues) document.getElementById("conclusionIssues").value = data.conclusionIssues; + updateStats(); } -function getStorageKey() { - const name = document.getElementById("verifierName").value.trim(); - return name ? STORAGE_PREFIX + name : ""; -} - -function saveCurrent() { +// ========== 保存/加载/提交(API)========== +async function saveCurrent() { const name = document.getElementById("verifierName").value.trim(); if (!name) { showToast("请先输入验收人姓名"); return; } const data = collectData(); - localStorage.setItem(STORAGE_PREFIX + name, JSON.stringify(data)); - updateSavedList(); - document.getElementById("savedSelect").value = name; - showToast(`已保存:${name}`); + try { + await apiCall('POST', '/acceptance-tests', { + verifierName: name, results: data.results, remarks: data.remarks, + conclusionName: data.conclusionName || name, conclusionDate: data.conclusionDate, + conclusionResult: data.conclusionResult, conclusionIssues: data.conclusionIssues, + screenshots: data.screenshots, signature1: data.signature1, signature2: data.signature2, + }); + await loadSavedList(); + document.getElementById("savedSelect").value = name; + showToast(`✅ 已保存:${name}`); + } catch (err) { showToast(`保存失败:${err.message}`); } } -function autoSave() { +async function autoSave() { const name = document.getElementById("verifierName").value.trim(); if (!name) return; const data = collectData(); - localStorage.setItem(STORAGE_PREFIX + name, JSON.stringify(data)); + try { + await apiCall('POST', '/acceptance-tests', { + verifierName: name, results: data.results, remarks: data.remarks, + conclusionName: data.conclusionName || name, conclusionDate: data.conclusionDate, + conclusionResult: data.conclusionResult, conclusionIssues: data.conclusionIssues, + screenshots: data.screenshots, signature1: data.signature1, signature2: data.signature2, + }); + await loadSavedList(); + } catch (err) { console.warn('自动保存失败:', err.message); } } -function loadSaved(name) { +async function loadSaved(name) { if (!name) return; - const raw = localStorage.getItem(STORAGE_PREFIX + name); - if (!raw) { showToast("未找到保存记录"); return; } - applyData(JSON.parse(raw)); - showToast(`已加载:${name}`); + try { + const res = await apiCall('GET', `/acceptance-tests/${encodeURIComponent(name)}`); + applyData(res.data); + showToast(`已加载:${name}`); + } catch (err) { showToast(`加载失败:${err.message}`); } } -function deleteSaved() { +async function deleteSaved() { const name = document.getElementById("verifierName").value.trim(); const sel = document.getElementById("savedSelect").value; const target = name || sel; if (!target) { showToast("请先选择要删除的记录"); return; } if (!confirm(`确认删除「${target}」的验收记录?`)) return; - localStorage.removeItem(STORAGE_PREFIX + target); - updateSavedList(); - if (target === name) document.getElementById("verifierName").value = ""; - showToast(`已删除:${target}`); + try { + await apiCall('DELETE', `/acceptance-tests/${encodeURIComponent(target)}`); + await loadSavedList(); + if (target === name) document.getElementById("verifierName").value = ""; + showToast(`已删除:${target}`); + } catch (err) { showToast(`删除失败:${err.message}`); } } -function deleteSavedByName(name) { +async function deleteSavedByName(name) { if (!confirm(`确认删除「${name}」的验收记录?`)) return; - localStorage.removeItem(STORAGE_PREFIX + name); - updateSavedList(); - showAutocomplete(); - showToast(`已删除:${name}`); + try { + await apiCall('DELETE', `/acceptance-tests/${encodeURIComponent(name)}`); + await loadSavedList(); + showAutocomplete(); + showToast(`已删除:${name}`); + } catch (err) { showToast(`删除失败:${err.message}`); } } -function clearAllSaved() { - const keys = Object.keys(localStorage).filter(k => k.startsWith(STORAGE_PREFIX)); - if (keys.length === 0) { showToast("无已保存记录"); return; } - if (!confirm(`确认清空全部 ${keys.length} 条验收记录?此操作不可恢复!`)) return; - keys.forEach(k => localStorage.removeItem(k)); - updateSavedList(); - showAutocomplete(); - document.getElementById("verifierName").value = ""; - showToast(`已清空 ${keys.length} 条记录`); -} - -function updateSavedList() { - const sel = document.getElementById("savedSelect"); - const current = sel.value; - sel.innerHTML = ''; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key && key.startsWith(STORAGE_PREFIX)) { - const name = key.substring(STORAGE_PREFIX.length); - sel.innerHTML += ``; - } - } - if (current) sel.value = current; +async function submitReport() { + const name = document.getElementById("verifierName").value.trim(); + if (!name) { showToast("请先输入验收人姓名"); return; } + const data = collectData(); + if (!data.conclusionResult) { showToast("请先完成至少一项测试"); return; } + if (!confirm(`确认提交「${name}」的验收报告?提交后不可再编辑。`)) return; + try { + await apiCall('POST', '/acceptance-tests', { + verifierName: name, results: data.results, remarks: data.remarks, + conclusionName: data.conclusionName || name, conclusionDate: data.conclusionDate, + conclusionResult: data.conclusionResult, conclusionIssues: data.conclusionIssues, + screenshots: data.screenshots, signature1: data.signature1, signature2: data.signature2, + }); + await apiCall('POST', `/acceptance-tests/${encodeURIComponent(name)}/submit`, { + conclusionResult: data.conclusionResult, conclusionIssues: data.conclusionIssues, + }); + await loadSavedList(); + showToast(`✅ 验收报告已提交:${name}`); + } catch (err) { showToast(`提交失败:${err.message}`); } } // ========== 下载 ========== @@ -1003,17 +1430,125 @@ function todayStr() { return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; } +// ========== 截图上传 ========== +const screenshots = {}; // { "di-ii": dataURL } + +function uploadScreenshot(di, ii) { + const uid = `${di}-${ii}`; + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = (e) => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + screenshots[uid] = ev.target.result; + const cell = document.querySelector(`#row-${uid} .screenshot-cell`); + if (cell) cell.innerHTML = ``; + scheduleAutoSave(); + showToast("截图已上传"); + }; + reader.readAsDataURL(file); + }; + input.click(); +} + +function viewScreenshot(uid) { + const data = screenshots[uid]; + if (!data) return; + const w = window.open('', '_blank'); + w.document.write(`截图查看`); +} + +// ========== 手写签名 ========== +let sigCtx1, sigCtx2, sigDrawing = false; + +function initSignatures() { + [1, 2].forEach(n => { + const canvas = document.getElementById(`sigCanvas${n}`); + if (!canvas) return; + const box = document.getElementById(`sigBox${n}`); + canvas.width = box.offsetWidth; + canvas.height = 80; + const ctx = canvas.getContext('2d'); + ctx.strokeStyle = '#1f2937'; + ctx.lineWidth = 2; + ctx.lineCap = 'round'; + + const getPos = (e) => { + const rect = canvas.getBoundingClientRect(); + const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left; + const y = (e.touches ? e.touches[0].clientY : e.clientY) - rect.top; + return { x, y }; + }; + + const start = (e) => { e.preventDefault(); sigDrawing = true; const p = getPos(e); ctx.beginPath(); ctx.moveTo(p.x, p.y); }; + const move = (e) => { if (!sigDrawing) return; e.preventDefault(); const p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); }; + const end = () => { sigDrawing = false; }; + + canvas.addEventListener('mousedown', start); + canvas.addEventListener('mousemove', move); + canvas.addEventListener('mouseup', end); + canvas.addEventListener('mouseleave', end); + canvas.addEventListener('touchstart', start); + canvas.addEventListener('touchmove', move); + canvas.addEventListener('touchend', end); + }); +} + +function clearSig(n) { + const canvas = document.getElementById(`sigCanvas${n}`); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); +} + +function getSigData(n) { + const canvas = document.getElementById(`sigCanvas${n}`); + if (!canvas) return ''; + // Check if canvas is empty + const ctx = canvas.getContext('2d'); + const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data; + const isEmpty = !data.some((v, i) => i % 4 === 3 && v > 0); + return isEmpty ? '' : canvas.toDataURL(); +} + +// ========== PDF 导出(打印优化)========== +function exportPDF() { + const data = collectData(); + if (!data.verifier) { showToast("请先输入验收人姓名"); return; } + + // 临时展开所有折叠的域 + const collapsed = []; + DOMAINS.forEach((_, di) => { + const el = document.getElementById(`domain-${di}`); + if (el && el.classList.contains('collapsed')) { collapsed.push(di); el.classList.remove('collapsed'); } + }); + + // 设置打印模式 + document.body.classList.add('print-mode'); + window.print(); + + // 恢复 + document.body.classList.remove('print-mode'); + collapsed.forEach(di => { + const el = document.getElementById(`domain-${di}`); + if (el) { el.classList.add('collapsed'); el.querySelector(".toggle").textContent = "▶"; } + }); +} + // ========== Toast ========== function showToast(msg) { const t = document.getElementById("toast"); t.textContent = msg; t.classList.add("show"); - setTimeout(() => t.classList.remove("show"), 2000); + setTimeout(() => t.classList.remove("show"), 2500); } // ========== 初始化 ========== renderAll(); -updateSavedList(); +loadSavedList(); // 自动填充验收日期 const dateEl = document.getElementById("conclusionDate"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 34e6fea..35e5f9b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -40,8 +40,10 @@ const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport')) const CalendarPage = lazy(() => import('./pages/Calendar')) const WorkProcess = lazy(() => import('./pages/WorkProcess')) const MyAttendance = lazy(() => import('./pages/portal/MyAttendance')) +const MyLeave = lazy(() => import('./pages/portal/MyLeave')) const SpecialStatus = lazy(() => import('./pages/SpecialStatus')) const CompanyFiles = lazy(() => import('./pages/CompanyFiles')) +const LeaveApproval = lazy(() => import('./pages/LeaveApproval')) // Sprint 4-5 新增页面 const EmployeeHome = lazy(() => import('./pages/portal/EmployeeHome')) @@ -191,6 +193,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> @@ -208,6 +211,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/AcceptanceTestModal.tsx b/frontend/src/components/AcceptanceTestModal.tsx index 63b8f14..8a014de 100644 --- a/frontend/src/components/AcceptanceTestModal.tsx +++ b/frontend/src/components/AcceptanceTestModal.tsx @@ -44,9 +44,9 @@ export default function AcceptanceTestModal({ open, onClose }: { open: boolean;
- {/* iframe 加载验收测试页面 */} + {/* iframe 加载验收测试页面 — 带版本参数防止缓存 */}