/** * 电子签署管理页面 * - 发起电子签署(场景选择 + 模板自动渲染 + 组织开关校验) * - 线下手签登记(上传扫描件 + 签署信息 + 证据链) * - 签署记录列表(状态/场景/签署方式筛选) * - 签署详情(含证据链查看) * - 取消签署 */ import { useState, useRef, useEffect } from 'react' import { useSearchParams } from 'react-router-dom' 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, Upload, FileCheck } from 'lucide-react' import { esignApi, employeeApi } from '../lib/api-services' import PageGuide from '../components/ui/PageGuide' import Card from '../components/ui/Card' import Button from '../components/ui/Button' import { Input, Label, Select } from '../components/ui/Input' import { InlineAlert } from '../components/ui/InlineAlert' import Modal from '../components/ui/Modal' const STATUS_CONFIG: Record = { PENDING: { label: '待签署', color: 'bg-yellow-50 text-yellow-700', icon: }, SIGNING: { label: '签署中', color: 'bg-blue-50 text-blue-700', icon: }, COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe', icon: }, REJECTED: { label: '已拒绝', color: 'bg-red-50 text-danger', icon: }, EXPIRED: { label: '已过期', color: 'bg-gray-100 text-gray-500', icon: }, CANCELLED: { label: '已取消', color: 'bg-gray-100 text-gray-500', icon: }, } const SCENE_CONFIG: Record = { CONTRACT: { label: '劳动合同', color: 'bg-blue-50 text-blue-600 border border-blue-200' }, RESIGNATION: { label: '离职协议', color: 'bg-orange-50 text-orange-600 border border-orange-200' }, POLICY: { label: '规章制度', color: 'bg-amber-50 text-amber-600 border border-amber-200' }, PAYSLIP: { label: '工资条', color: 'bg-emerald-50 text-emerald-600 border border-emerald-200' }, 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 [searchParams] = useSearchParams() const [formData, setFormData] = useState({ employeeId: '', scene: 'CONTRACT', documentTitle: '', remark: '', }) // 从 URL 参数读取,自动打开线下手签登记 useEffect(() => { const action = searchParams.get('action') if (action === 'paper-sign') { setShowPaperSign(true) // 预填表单 const employeeId = searchParams.get('employeeId') const scene = searchParams.get('scene') as string const documentTitle = searchParams.get('documentTitle') as string if (employeeId || scene || documentTitle) { setFormData({ employeeId: employeeId || '', scene: scene || 'CONTRACT', documentTitle: documentTitle || '', remark: '花名册操作跳转登记', }) } } }, [searchParams]) const { data: records = [], isLoading } = useQuery({ queryKey: ['esign-records', filterStatus, filterScene], queryFn: async () => { return await esignApi.list({ status: filterStatus || undefined, scene: filterScene || undefined }) }, }) const { data: rosterData = [] } = useQuery({ queryKey: ['employees-for-esign'], queryFn: async () => { return await employeeApi.allLite({ status: 'ACTIVE' }) }, enabled: showCreate, }) const createMutation = useMutation({ mutationFn: async (data: { employeeId: string; scene: string; documentTitle: string; remark?: string }) => esignApi.create(data) as any, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['esign-records'] }) setShowCreate(false) setFormData({ employeeId: '', scene: 'CONTRACT', documentTitle: '', remark: '' }) toast.success('签署记录已创建,员工可在员工端查看并签署') }, onError: (err: any) => { toast.error(err?.response?.data?.error?.message || '创建失败') }, }) const cancelMutation = useMutation({ mutationFn: (id: string) => esignApi.cancel(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['esign-records'] }) toast.success('已取消签署') }, }) const refreshStatusMutation = useMutation({ mutationFn: (id: string) => esignApi.status(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['esign-records'] }) toast.success('状态已刷新') }, }) const handleCreate = () => { if (!formData.employeeId) { toast.error('请选择员工'); return } if (!formData.documentTitle.trim()) { toast.error('请填写文件标题'); return } createMutation.mutate(formData) } // ===== 签署详情视图 ===== if (detailId) { return setDetailId(null)} /> } return (
通过电子签署模块发起合同、离职协议、规章制度等文件的在线签署。员工在员工端通过手机验证码确认签署,签署全流程自动记录证据链(IP、时间戳、验证码),可作为劳动仲裁举证材料。

电子签署

在线合同签署,验证码确认 + 证据链留存

签署流程
① HR在系统发起签署(自动渲染模板文件) → ② 员工在员工端查看待签文件 → ③ 员工获取手机验证码 → ④ 验证码确认签署 → ⑤ 自动记录证据链(IP/UA/时间戳/验证码) → ⑥ 合同自动回写签署方式
{/* 签署记录列表 */} {isLoading ? (
加载中...
) : records.length === 0 ? (
暂无签署记录,点击「发起签署」创建
) : (
{records.map((r: any) => { const statusCfg = STATUS_CONFIG[r.status] || STATUS_CONFIG.PENDING return ( ) })}
文件标题 签署人 部门 签署方式 状态 发起时间 完成时间 操作
{r.scene && SCENE_CONFIG[r.scene] && ( {SCENE_CONFIG[r.scene].label} )}
{r.remark &&
{r.remark}
}
{r.employee?.name || '—'} {r.employee?.department || '—'} {(SIGN_METHOD_CONFIG[r.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).label} {statusCfg.icon}{statusCfg.label} {new Date(r.createdAt).toLocaleString('zh-CN')} {r.completedAt ? new Date(r.completedAt).toLocaleString('zh-CN') : '—'}
{r.status === 'COMPLETED' && r.signedPdfUrl && ( PDF )} {r.status === 'COMPLETED' && r.signMethod === 'PAPER' && (r as any).scanFileUrls && ( 扫描件 )} {(r.status === 'PENDING' || r.status === 'SIGNING') && ( <> )}
)}
{/* 发起签署 Modal */} {showCreate && ( setShowCreate(false)} title="发起电子签署" size="md">
选择员工和签署场景,系统将自动从模板渲染文件内容,创建签署记录并发起证据链。员工可在员工端查看文件并通过验证码签署。
setFormData({ ...formData, documentTitle: e.target.value })} placeholder="如:2024年度劳动合同" />
setFormData({ ...formData, remark: e.target.value })} placeholder="可选" />
签署流程说明
1. 创建后员工在员工端「我的电子签署」中看到待签文件
2. 员工点击「签署」→ 获取手机验证码 → 输入验证码确认
3. 签署完成自动记录证据链(IP/时间戳/验证码),可在此详情页查看
{formData.scene === 'CONTRACT' &&
4. 关联合同的签署完成后自动回写合同签署方式为「电子签署」
}
)} {/* 线下手签登记 Modal */} {showPaperSign && ( { setShowPaperSign(false); setFormData({ employeeId: '', scene: 'CONTRACT', documentTitle: '', remark: '' }) }} onSuccess={() => { queryClient.invalidateQueries({ queryKey: ['esign-records'] }) setShowPaperSign(false) setFormData({ employeeId: '', scene: 'CONTRACT', documentTitle: '', remark: '' }) }} /> )}
) } // ===== 线下手签登记 Modal ===== function PaperSignModal({ onClose, onSuccess, initialEmployeeId, initialScene, initialDocumentTitle, initialRemark }: { onClose: () => void onSuccess: () => void initialEmployeeId?: string initialScene?: string initialDocumentTitle?: string initialRemark?: string }) { const fileInputRef = useRef(null) const [form, setForm] = useState({ employeeId: initialEmployeeId || '', scene: initialScene || 'CONTRACT', documentTitle: initialDocumentTitle || '', signedAt: new Date().toISOString().slice(0, 10), signedLocation: '', witnessName: '', witnessPhone: '', remark: initialRemark || '', }) const [scanFiles, setScanFiles] = useState>([]) const [uploading, setUploading] = useState(false) const { data: rosterData = [] } = useQuery({ queryKey: ['employees-for-paper-sign'], queryFn: () => employeeApi.allLite({ status: 'ACTIVE' }), }) const paperSignMutation = useMutation({ mutationFn: (data: any) => esignApi.paperSign(data), onSuccess: () => { toast.success('线下手签登记成功,证据链已记录') onSuccess() }, onError: (err: any) => toast.error(err?.response?.data?.error?.message || '登记失败'), }) const handleFileUpload = async (e: React.ChangeEvent) => { const files = e.target.files if (!files || files.length === 0) return const allowedExts = ['.jpg', '.jpeg', '.png', '.pdf', '.bmp', '.webp', '.tiff', '.tif'] const validFiles: File[] = [] for (const file of Array.from(files)) { const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.')) if (!allowedExts.includes(ext)) { toast.error(`不支持的格式: ${file.name}`) continue } if (file.size > 10 * 1024 * 1024) { toast.error(`文件过大: ${file.name}(最大10MB)`) continue } validFiles.push(file) } if (validFiles.length === 0) return setUploading(true) try { const res = await esignApi.uploadPaperSign(validFiles) setScanFiles(prev => [...prev, ...res]) toast.success(`已上传 ${res.length} 个文件`) } catch (err: any) { toast.error(err?.response?.data?.error?.message || '上传失败') } finally { setUploading(false) } e.target.value = '' } const handleSubmit = () => { if (!form.employeeId) { toast.error('请选择员工'); return } if (!form.documentTitle.trim()) { toast.error('请填写文件标题'); return } if (!form.signedAt) { toast.error('请选择签署日期'); return } if (scanFiles.length === 0) { toast.error('请至少上传一份签署扫描件'); return } paperSignMutation.mutate({ ...form, scanFileUrls: scanFiles }) } return (
登记员工线下手签的合同/协议,上传签署后的纸质文件扫描件。系统自动记录证据链(签署日期、地点、见证人、扫描件),与电子签署具有同等证据效力。
setForm({ ...form, documentTitle: e.target.value })} placeholder="如:2024年度劳动合同" />
setForm({ ...form, signedAt: e.target.value })} />
setForm({ ...form, signedLocation: e.target.value })} placeholder="如:公司会议室" />
setForm({ ...form, witnessName: e.target.value })} placeholder="可选" />
setForm({ ...form, witnessPhone: e.target.value })} placeholder="可选" />
支持 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({ queryKey: ['esign-detail', id], queryFn: () => esignApi.detail(id), }) const { data: evidence = [] } = useQuery({ queryKey: ['esign-evidence', id], queryFn: () => esignApi.evidence(id), }) if (isLoading) { return (
加载中...
) } if (!detail) { return (
记录不存在
) } const statusCfg = STATUS_CONFIG[detail.status] || STATUS_CONFIG.PENDING const sceneCfg = SCENE_CONFIG[detail.scene] || SCENE_CONFIG.CONTRACT return (
{/* 基本信息 */}

{detail.documentTitle}

{sceneCfg.label} {(SIGN_METHOD_CONFIG[detail.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).label}
签署人:{detail.employee?.name} · {detail.employee?.department}
{statusCfg.icon}{statusCfg.label}
发起时间
{new Date(detail.createdAt).toLocaleString('zh-CN')}
完成时间
{detail.completedAt ? new Date(detail.completedAt).toLocaleString('zh-CN') : '—'}
{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 && (
备注
{detail.remark}
)}
{/* 线下手签扫描件 */} {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) => ( ))}
)} {/* 文件内容预览 */} {detail.documentContent && (
文件内容
{detail.documentContent}
)} {/* 证据链 */}
证据链记录 ({evidence.length}条证据链)
{evidence.length === 0 ? (
暂无证据链记录
) : (
{evidence.map((ev: any, idx: number) => { const events = ev.events as any[] return (
证据链 #{idx + 1} · 创建于 {new Date(ev.createdAt).toLocaleString('zh-CN')}
{events?.map((event: any, i: number) => (
{event.action}
{new Date(event.timestamp).toLocaleString('zh-CN')} {event.ip && ` · IP: ${event.ip}`} {event.location && ` · ${event.location}`}
))}
hash: {ev.hash?.slice(0, 32)}...
) })}
)}
) }