diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d75e728..dd9bb12 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1528,6 +1528,7 @@ model ESignRecord { employeeId String employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) scene String @default("CONTRACT") // CONTRACT | RESIGNATION | POLICY | PAYSLIP | ONBOARDING + signMethod String @default("ELECTRONIC") // ELECTRONIC=电子签署 | PAPER=线下手签 flowId String? // 易签宝流程ID documentTitle String // 文件标题 documentContent String? // 文件内容(HTML/PDF base64) @@ -1537,12 +1538,19 @@ model ESignRecord { initiatedBy String // 发起人(HR用户ID) completedAt DateTime? expiredAt DateTime? - callbackData Json? // 易签宝回调数据 + callbackData Json? // 易签宝回调数据 / 线下手签证据 remark String? createdBy String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + // 线下手签专用字段 + signedAt DateTime? // 线下签署日期 + signedLocation String? // 签署地点 + witnessName String? // 见证人姓名 + witnessPhone String? // 见证人手机号 + scanFileUrls Json? // 线下签署扫描件URL列表 [{name, url}] + @@index([orgId, status]) @@index([employeeId]) @@index([contractId]) diff --git a/backend/src/routes/esign.routes.ts b/backend/src/routes/esign.routes.ts index 133592a..9c05b56 100644 --- a/backend/src/routes/esign.routes.ts +++ b/backend/src/routes/esign.routes.ts @@ -11,6 +11,9 @@ */ import { Router, Response, NextFunction } from 'express' import { z } from 'zod' +import multer from 'multer' +import path from 'path' +import fs from 'fs' import prisma from '../lib/prisma' import { authMiddleware, AuthRequest } from '../middleware/auth' import { auditLog } from '../middleware/auditLog' @@ -317,4 +320,161 @@ router.post('/:id/cancel', async (req: AuthRequest, res: Response, next: NextFun } catch (err) { next(err) } }) +// ========== 线下手签登记 ========== + +/** 线下签署扫描件上传目录 */ +const paperSignDir = path.join(process.cwd(), 'uploads', 'paper-sign') +if (!fs.existsSync(paperSignDir)) fs.mkdirSync(paperSignDir, { recursive: true }) + +const paperSignUpload = multer({ + storage: multer.diskStorage({ + destination: paperSignDir, + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname) + cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`) + }, + }), + limits: { fileSize: 10 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + const allowed = ['.jpg', '.jpeg', '.png', '.pdf', '.bmp', '.webp', '.tiff', '.tif'] + const ext = path.extname(file.originalname).toLowerCase() + if (allowed.includes(ext)) cb(null, true) + else cb(new Error('仅支持 JPG/PNG/PDF/BMP/WEBP/TIFF 格式')) + }, +}) + +/** + * 上传线下签署扫描件 + * 支持多文件上传,返回文件URL列表 + */ +router.post('/paper-upload', authMiddleware, paperSignUpload.array('files', 10), async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const files = req.files as Express.Multer.File[] + if (!files || files.length === 0) { + return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请选择文件' } }) + } + const fileUrls = files.map(f => ({ + name: f.originalname, + url: `/uploads/paper-sign/${f.filename}`, + size: f.size, + })) + res.json({ success: true, data: fileUrls }) + } catch (err) { next(err) } +}) + +/** 线下手签登记 Schema */ +const paperSignSchema = z.object({ + employeeId: z.string().min(1), + contractId: z.string().optional(), + scene: z.string().default('CONTRACT'), + documentTitle: z.string().min(1), + signedAt: z.string().min(1), // 签署日期 + signedLocation: z.string().optional(), // 签署地点 + witnessName: z.string().optional(), // 见证人姓名 + witnessPhone: z.string().optional(), // 见证人手机号 + scanFileUrls: z.array(z.object({ + name: z.string(), + url: z.string(), + })).min(1, '至少上传一份签署扫描件'), + remark: z.string().optional(), +}) + +/** + * 线下手签登记 + * - 创建 ESignRecord(signMethod=PAPER, status=COMPLETED) + * - 保存签署信息(签署日期/地点/见证人/扫描件) + * - 创建证据链(线下签署登记事件) + * - 回写合同签署方式 + */ +router.post('/paper-sign', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const data = paperSignSchema.parse(req.body) + + // 获取员工信息 + const employee = await prisma.employee.findFirst({ + where: { id: data.employeeId, orgId: req.user!.orgId }, + select: { id: true, name: true, department: true }, + }) + if (!employee) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } }) + } + + // 创建签署记录(线下手签直接为 COMPLETED 状态) + const record = await prisma.eSignRecord.create({ + data: { + orgId: req.user!.orgId, + contractId: data.contractId || null, + employeeId: data.employeeId, + scene: data.scene, + signMethod: 'PAPER', + documentTitle: data.documentTitle, + status: 'COMPLETED', + initiatedBy: req.user!.id, + createdBy: req.user!.id, + remark: data.remark || null, + completedAt: new Date(data.signedAt), + expiredAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 线下签署记录保留1年 + // 线下手签专用字段 + signedAt: new Date(data.signedAt), + signedLocation: data.signedLocation || null, + witnessName: data.witnessName || null, + witnessPhone: data.witnessPhone || null, + scanFileUrls: data.scanFileUrls as any, + // 签署证据 + callbackData: { + signMethod: 'PAPER', + signedAt: data.signedAt, + signedLocation: data.signedLocation, + witnessName: data.witnessName, + witnessPhone: data.witnessPhone, + scanFileCount: data.scanFileUrls.length, + registeredBy: req.user!.id, + registeredAt: new Date().toISOString(), + } as any, + }, + }) + + // 创建证据链 — 线下签署登记 + await createEvidence({ + orgId: req.user!.orgId, + category: 'CONTRACT_SIGN', + refId: record.id, + employeeId: data.employeeId, + events: [{ + action: `线下手签登记:${data.documentTitle}`, + timestamp: new Date().toISOString(), + ip: req.ip, + userAgent: req.headers['user-agent'] as string, + location: `签署日期:${data.signedAt.slice(0, 10)},签署地点:${data.signedLocation || '未填写'},见证人:${data.witnessName || '无'},扫描件:${data.scanFileUrls.length}份`, + }], + createdBy: req.user!.id, + }).catch(() => {}) + + // 回写合同签署方式 + if (data.contractId) { + await prisma.laborContract.update({ + where: { id: data.contractId }, + data: { + signMethod: 'PAPER', + // 第一份扫描件作为合同附件 + attachmentUrl: data.scanFileUrls[0]?.url || null, + }, + }) + } + + await auditLog(req, 'PAPER_SIGN_REGISTER', 'ESIGN_RECORD', record.id, { + employeeId: data.employeeId, + documentTitle: data.documentTitle, + signedAt: data.signedAt, + scanFileCount: data.scanFileUrls.length, + }) + + res.json({ + success: true, + data: record, + message: '线下手签登记成功,证据链已记录', + }) + } catch (err) { next(err) } +}) + export default router diff --git a/backend/src/services/esign.service.ts b/backend/src/services/esign.service.ts index 69deb4b..ef24dba 100644 --- a/backend/src/services/esign.service.ts +++ b/backend/src/services/esign.service.ts @@ -74,6 +74,7 @@ export async function autoCreateEsignRecord(params: { contractId: contractId || null, employeeId, scene, + signMethod: 'ELECTRONIC', documentTitle, documentContent: documentContent || null, status: 'PENDING', diff --git a/frontend/src/lib/api-services.ts b/frontend/src/lib/api-services.ts index 89c3657..a6dee1c 100644 --- a/frontend/src/lib/api-services.ts +++ b/frontend/src/lib/api-services.ts @@ -651,6 +651,15 @@ export const esignApi = { get(`/esign/${id}/evidence`).then(unwrap()), cancel: (id: string) => post(`/esign/${id}/cancel`), + /** 上传线下签署扫描件 */ + uploadPaperSign: (files: File[]) => { + const formData = new FormData() + files.forEach(f => formData.append('files', f)) + return post('/esign/paper-upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(unwrap()) + }, + /** 线下手签登记 */ + paperSign: (data: Record) => + post('/esign/paper-sign', data).then(unwrap()), } // ========== 离职相关 ========== diff --git a/frontend/src/pages/ESign.tsx b/frontend/src/pages/ESign.tsx index 69545be..4d05742 100644 --- a/frontend/src/pages/ESign.tsx +++ b/frontend/src/pages/ESign.tsx @@ -1,14 +1,15 @@ /** * 电子签署管理页面 - * - 发起签署(场景选择 + 模板自动渲染 + 组织开关校验) - * - 签署记录列表(状态/场景筛选) + * - 发起电子签署(场景选择 + 模板自动渲染 + 组织开关校验) + * - 线下手签登记(上传扫描件 + 签署信息 + 证据链) + * - 签署记录列表(状态/场景/签署方式筛选) * - 签署详情(含证据链查看) * - 取消签署 */ -import { useState } from 'react' +import { useState, useRef } from 'react' import { toast } from 'sonner' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { PenTool, Plus, RefreshCw, ExternalLink, FileText, AlertCircle, Shield, ChevronLeft, Clock, CheckCircle2, XCircle, Eye } from 'lucide-react' +import { PenTool, Plus, RefreshCw, ExternalLink, FileText, AlertCircle, Shield, ChevronLeft, Clock, CheckCircle2, XCircle, Eye, Upload, FileCheck } from 'lucide-react' import { esignApi, employeeApi } from '../lib/api-services' import PageGuide from '../components/ui/PageGuide' import Card from '../components/ui/Card' @@ -34,11 +35,17 @@ const SCENE_CONFIG: Record = { ONBOARDING: { label: '入职文件', color: 'bg-purple-50 text-purple-600 border border-purple-200' }, } +const SIGN_METHOD_CONFIG: Record = { + ELECTRONIC: { label: '电子签', color: 'bg-blue-50 text-blue-600' }, + PAPER: { label: '线下手签', color: 'bg-orange-50 text-orange-600' }, +} + export default function ESign() { const queryClient = useQueryClient() const [filterStatus, setFilterStatus] = useState('') const [filterScene, setFilterScene] = useState('') const [showCreate, setShowCreate] = useState(false) + const [showPaperSign, setShowPaperSign] = useState(false) const [detailId, setDetailId] = useState(null) const [formData, setFormData] = useState({ employeeId: '', @@ -145,9 +152,14 @@ export default function ESign() { {Object.entries(SCENE_CONFIG).map(([k, v]) => )} - +
+ + +
{/* 签署记录列表 */} @@ -164,6 +176,7 @@ export default function ESign() { 文件标题 签署人 部门 + 签署方式 状态 发起时间 完成时间 @@ -189,6 +202,11 @@ export default function ESign() { {r.employee?.name || '—'} {r.employee?.department || '—'} + + + {(SIGN_METHOD_CONFIG[r.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).label} + + {statusCfg.icon}{statusCfg.label} @@ -207,6 +225,12 @@ export default function ESign() { PDF )} + {r.status === 'COMPLETED' && r.signMethod === 'PAPER' && (r as any).scanFileUrls && ( + + 扫描件 + + )} {(r.status === 'PENDING' || r.status === 'SIGNING') && ( <> +
支持 JPG/PNG/PDF/BMP/WEBP/TIFF,单个最大10MB
+ + {scanFiles.length > 0 && ( +
+ {scanFiles.map((f, i) => ( +
+ + {f.name} + +
+ ))} +
+ )} + +
+ + setForm({ ...form, remark: e.target.value })} + placeholder="可选" /> +
+
+ + +
+ + + ) +} + // ===== 签署详情组件 ===== function ESignDetail({ id, onBack }: { id: string; onBack: () => void }) { const { data: detail, isLoading } = useQuery({ @@ -351,6 +573,9 @@ function ESignDetail({ id, onBack }: { id: string; onBack: () => void }) {

{detail.documentTitle}

{sceneCfg.label} + + {(SIGN_METHOD_CONFIG[detail.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).label} +
签署人:{detail.employee?.name} · {detail.employee?.department}
@@ -368,14 +593,33 @@ function ESignDetail({ id, onBack }: { id: string; onBack: () => void }) {
完成时间
{detail.completedAt ? new Date(detail.completedAt).toLocaleString('zh-CN') : '—'}
-
-
过期时间
-
{detail.expiredAt ? new Date(detail.expiredAt).toLocaleString('zh-CN') : '—'}
-
-
-
员工手机
-
{detail.employee?.phone ? detail.employee.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : '—'}
-
+ {detail.signMethod === 'PAPER' ? ( + <> +
+
签署日期
+
{detail.signedAt ? new Date(detail.signedAt).toLocaleDateString('zh-CN') : '—'}
+
+
+
签署地点
+
{detail.signedLocation || '—'}
+
+
+
见证人
+
{detail.witnessName || '—'} {detail.witnessPhone ? `· ${detail.witnessPhone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')}` : ''}
+
+ + ) : ( + <> +
+
过期时间
+
{detail.expiredAt ? new Date(detail.expiredAt).toLocaleString('zh-CN') : '—'}
+
+
+
员工手机
+
{detail.employee?.phone ? detail.employee.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : '—'}
+
+ + )} {detail.remark && (
备注
@@ -385,6 +629,25 @@ function ESignDetail({ id, onBack }: { id: string; onBack: () => void }) {
+ {/* 线下手签扫描件 */} + {detail.signMethod === 'PAPER' && detail.scanFileUrls && (detail.scanFileUrls as any[]).length > 0 && ( + +
+ 签署扫描件 + ({(detail.scanFileUrls as any[]).length}份) +
+
+ {(detail.scanFileUrls as any[]).map((f: any, i: number) => ( +
+ + {f.name} + +
+ ))} +
+
+ )} + {/* 文件内容预览 */} {detail.documentContent && ( diff --git a/frontend/src/pages/portal/MyEsign.tsx b/frontend/src/pages/portal/MyEsign.tsx index b57366c..74a9c1b 100644 --- a/frontend/src/pages/portal/MyEsign.tsx +++ b/frontend/src/pages/portal/MyEsign.tsx @@ -35,6 +35,11 @@ const SCENE_LABELS: Record = { ONBOARDING: { label: '入职文件', color: 'bg-purple-50 text-purple-600 border border-purple-200' }, } +const SIGN_METHOD_LABELS: Record = { + ELECTRONIC: { label: '电子签', color: 'bg-blue-50 text-blue-600' }, + PAPER: { label: '线下手签', color: 'bg-orange-50 text-orange-600' }, +} + export default function MyEsign() { const queryClient = useQueryClient() const [selectedId, setSelectedId] = useState(null) @@ -92,7 +97,8 @@ export default function MyEsign() { // ===== 详情页 ===== if (selectedId) { const st = detail ? STATUS_MAP[detail.status] || STATUS_MAP.PENDING : null - const canSign = detail?.status === 'PENDING' + const canSign = detail?.status === 'PENDING' && detail?.signMethod !== 'PAPER' + const isPaperSign = detail?.signMethod === 'PAPER' return (
@@ -156,6 +162,40 @@ export default function MyEsign() { )} + {/* 线下手签信息 */} + {isPaperSign && ( + +
+ 线下手签信息 +
+
+
+
签署日期
+
{detail.signedAt ? new Date(detail.signedAt).toLocaleDateString('zh-CN') : '—'}
+
+
+
签署地点
+
{detail.signedLocation || '—'}
+
+
+
见证人
+
{detail.witnessName || '—'}
+
+
+ {detail.scanFileUrls && (detail.scanFileUrls as any[]).length > 0 && ( +
+
签署扫描件({(detail.scanFileUrls as any[]).length}份)
+ {(detail.scanFileUrls as any[]).map((f: any, i: number) => ( +
+ + {f.name} +
+ ))} +
+ )} +
+ )} + {/* 签署操作 */} {canSign ? ( @@ -212,10 +252,17 @@ export default function MyEsign() { ) : detail.status === 'COMPLETED' ? (
-
签署已完成
-
- 完成时间:{detail.completedAt ? new Date(detail.completedAt).toLocaleString('zh-CN') : '—'} +
+ {isPaperSign ? '线下手签已登记' : '签署已完成'}
+
+ {isPaperSign + ? `签署日期:${detail.signedAt ? new Date(detail.signedAt).toLocaleDateString('zh-CN') : '—'}` + : `完成时间:${detail.completedAt ? new Date(detail.completedAt).toLocaleString('zh-CN') : '—'}`} +
+ {isPaperSign && detail.signedLocation && ( +
签署地点:{detail.signedLocation}
+ )}
) : (
@@ -272,6 +319,11 @@ export default function MyEsign() { {r.scene && SCENE_LABELS[r.scene] && ( {SCENE_LABELS[r.scene].label} )} + {r.signMethod === 'PAPER' && ( + + {(SIGN_METHOD_LABELS[r.signMethod] || SIGN_METHOD_LABELS.ELECTRONIC).label} + + )}
{new Date(r.createdAt).toLocaleDateString('zh-CN')}