feat: 线下手签完整流程 + 证据链体系

## 数据库
- ESignRecord 增加 signMethod 字段(ELECTRONIC/PAPER)
- 增加线下手签专用字段:signedAt、signedLocation、witnessName、witnessPhone、scanFileUrls

## 后端
- esign.routes.ts 新增线下手签接口:
  - POST /esign/paper-upload:上传签署扫描件(multer多文件,10MB限制)
  - POST /esign/paper-sign:线下手签登记(创建COMPLETED记录+证据链+回写合同)
- esign.service.ts:autoCreateEsignRecord 设置 signMethod=ELECTRONIC
- 静态文件服务复用 /uploads 统一映射

## 前端管理端
- ESign.tsx 新增"线下手签登记"按钮和 Modal:
  - 选择员工、文件类型、签署日期、地点、见证人
  - 上传签署扫描件(多文件)
  - 登记后自动创建证据链
- 列表增加"签署方式"列(电子签/线下手签标签)
- 详情页增加签署方式标签 + 线下手签信息卡片 + 扫描件列表

## 前端员工端
- MyEsign.tsx 列表增加线下手签标签
- 详情页增加线下手签信息卡片(签署日期/地点/见证人/扫描件)
- 签署操作区域:线下手签记录显示"线下手签已登记",不显示验证码签署

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-15 16:36:46 +08:00
parent 12f1cde7c5
commit 434c63c6d3
6 changed files with 513 additions and 20 deletions
+278 -15
View File
@@ -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<string, { label: string; color: string }> = {
ONBOARDING: { label: '入职文件', color: 'bg-purple-50 text-purple-600 border border-purple-200' },
}
const SIGN_METHOD_CONFIG: Record<string, { label: string; color: string }> = {
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<string | null>(null)
const [formData, setFormData] = useState({
employeeId: '',
@@ -145,9 +152,14 @@ export default function ESign() {
{Object.entries(SCENE_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
</div>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
<div className="flex gap-2">
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" variant="secondary" onClick={() => setShowPaperSign(true)}>
<FileCheck className="w-4 h-4 mr-1" />线
</Button>
</div>
</div>
{/* 签署记录列表 */}
@@ -164,6 +176,7 @@ export default function ESign() {
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
@@ -189,6 +202,11 @@ export default function ESign() {
</td>
<td className="py-2 px-3">{r.employee?.name || '—'}</td>
<td className="py-2 px-3 text-gray-500">{r.employee?.department || '—'}</td>
<td className="py-2 px-3">
<span className={`px-1.5 py-0.5 rounded text-xs ${(SIGN_METHOD_CONFIG[r.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).color}`}>
{(SIGN_METHOD_CONFIG[r.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).label}
</span>
</td>
<td className="py-2 px-3">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs ${statusCfg.color}`}>
{statusCfg.icon}{statusCfg.label}
@@ -207,6 +225,12 @@ export default function ESign() {
<ExternalLink className="w-3 h-3" />PDF
</a>
)}
{r.status === 'COMPLETED' && r.signMethod === 'PAPER' && (r as any).scanFileUrls && (
<a href={(r as any).scanFileUrls[0]?.url} target="_blank" rel="noopener noreferrer"
className="text-xs text-primary hover:underline flex items-center gap-0.5">
<ExternalLink className="w-3 h-3" />
</a>
)}
{(r.status === 'PENDING' || r.status === 'SIGNING') && (
<>
<button className="text-xs text-gray-400 hover:text-primary" onClick={() => refreshStatusMutation.mutate(r.id)}
@@ -295,10 +319,208 @@ export default function ESign() {
</div>
</Modal>
)}
{/* 线下手签登记 Modal */}
{showPaperSign && (
<PaperSignModal
onClose={() => setShowPaperSign(false)}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
setShowPaperSign(false)
}}
/>
)}
</div>
)
}
// ===== 线下手签登记 Modal =====
function PaperSignModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [form, setForm] = useState({
employeeId: '',
scene: 'CONTRACT',
documentTitle: '',
signedAt: new Date().toISOString().slice(0, 10),
signedLocation: '',
witnessName: '',
witnessPhone: '',
remark: '',
})
const [scanFiles, setScanFiles] = useState<Array<{ name: string; url: string }>>([])
const [uploading, setUploading] = useState(false)
const { data: rosterData = [] } = useQuery<any[]>({
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<HTMLInputElement>) => {
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 (
<Modal open={true} onClose={onClose} title="线下手签登记" size="md">
<div className="space-y-3">
<InlineAlert type="info">
线/
</InlineAlert>
<div>
<Label> *</Label>
<select
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={form.employeeId}
onChange={(e) => setForm({ ...form, employeeId: e.target.value })}
>
<option value=""></option>
{rosterData.map((emp: any) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
))}
</select>
</div>
<div>
<Label> *</Label>
<select
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={form.scene}
onChange={(e) => {
const scene = e.target.value
const defaultTitle: Record<string, string> = {
CONTRACT: '劳动合同书',
RESIGNATION: '解除劳动合同协议书',
POLICY: '规章制度签收确认书',
PAYSLIP: '工资条确认书',
ONBOARDING: '入职文件',
}
setForm({ ...form, scene, documentTitle: defaultTitle[scene] || '' })
}}
>
{Object.entries(SCENE_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
</div>
<div>
<Label> *</Label>
<Input value={form.documentTitle} onChange={(e) => setForm({ ...form, documentTitle: e.target.value })}
placeholder="如:2024年度劳动合同" />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label> *</Label>
<Input type="date" value={form.signedAt} onChange={(e) => setForm({ ...form, signedAt: e.target.value })} />
</div>
<div>
<Label></Label>
<Input value={form.signedLocation} onChange={(e) => setForm({ ...form, signedLocation: e.target.value })}
placeholder="如:公司会议室" />
</div>
<div>
<Label></Label>
<Input value={form.witnessName} onChange={(e) => setForm({ ...form, witnessName: e.target.value })}
placeholder="可选" />
</div>
<div>
<Label></Label>
<Input value={form.witnessPhone} onChange={(e) => setForm({ ...form, witnessPhone: e.target.value })}
placeholder="可选" />
</div>
</div>
<div>
<Label> *</Label>
<div className="border-2 border-dashed border-gray-200 rounded-md p-4 text-center">
<input
ref={fileInputRef}
type="file"
multiple
accept=".jpg,.jpeg,.png,.pdf,.bmp,.webp,.tiff,.tif"
onChange={handleFileUpload}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="text-sm text-primary hover:underline"
>
<Upload className="w-4 h-4 inline mr-1" />
{uploading ? '上传中...' : '点击上传扫描件'}
</button>
<div className="text-xs text-gray-400 mt-1"> JPG/PNG/PDF/BMP/WEBP/TIFF10MB</div>
</div>
{scanFiles.length > 0 && (
<div className="mt-2 space-y-1">
{scanFiles.map((f, i) => (
<div key={i} className="flex items-center gap-2 text-xs bg-gray-50 rounded px-2 py-1.5">
<FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<a href={f.url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex-1 truncate">{f.name}</a>
<button
onClick={() => setScanFiles(prev => prev.filter((_, idx) => idx !== i))}
className="text-gray-400 hover:text-danger"
></button>
</div>
))}
</div>
)}
</div>
<div>
<Label></Label>
<Input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })}
placeholder="可选" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={onClose}></Button>
<Button size="sm" onClick={handleSubmit} disabled={paperSignMutation.isPending || uploading}>
{paperSignMutation.isPending ? '登记中...' : '确认登记'}
</Button>
</div>
</div>
</Modal>
)
}
// ===== 签署详情组件 =====
function ESignDetail({ id, onBack }: { id: string; onBack: () => void }) {
const { data: detail, isLoading } = useQuery<any>({
@@ -351,6 +573,9 @@ function ESignDetail({ id, onBack }: { id: string; onBack: () => void }) {
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold">{detail.documentTitle}</h2>
<span className={`px-1.5 py-0.5 rounded text-xs ${sceneCfg.color}`}>{sceneCfg.label}</span>
<span className={`px-1.5 py-0.5 rounded text-xs ${(SIGN_METHOD_CONFIG[detail.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).color}`}>
{(SIGN_METHOD_CONFIG[detail.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).label}
</span>
</div>
<div className="text-xs text-gray-400 mt-0.5">{detail.employee?.name} · {detail.employee?.department}</div>
</div>
@@ -368,14 +593,33 @@ function ESignDetail({ id, onBack }: { id: string; onBack: () => void }) {
<div className="text-gray-400"></div>
<div className="text-gray-700">{detail.completedAt ? new Date(detail.completedAt).toLocaleString('zh-CN') : '—'}</div>
</div>
<div>
<div className="text-gray-400"></div>
<div className="text-gray-700">{detail.expiredAt ? new Date(detail.expiredAt).toLocaleString('zh-CN') : '—'}</div>
</div>
<div>
<div className="text-gray-400"></div>
<div className="text-gray-700">{detail.employee?.phone ? detail.employee.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : '—'}</div>
</div>
{detail.signMethod === 'PAPER' ? (
<>
<div>
<div className="text-gray-400"></div>
<div className="text-gray-700">{detail.signedAt ? new Date(detail.signedAt).toLocaleDateString('zh-CN') : '—'}</div>
</div>
<div>
<div className="text-gray-400"></div>
<div className="text-gray-700">{detail.signedLocation || '—'}</div>
</div>
<div>
<div className="text-gray-400"></div>
<div className="text-gray-700">{detail.witnessName || '—'} {detail.witnessPhone ? `· ${detail.witnessPhone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')}` : ''}</div>
</div>
</>
) : (
<>
<div>
<div className="text-gray-400"></div>
<div className="text-gray-700">{detail.expiredAt ? new Date(detail.expiredAt).toLocaleString('zh-CN') : '—'}</div>
</div>
<div>
<div className="text-gray-400"></div>
<div className="text-gray-700">{detail.employee?.phone ? detail.employee.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : '—'}</div>
</div>
</>
)}
{detail.remark && (
<div className="col-span-2">
<div className="text-gray-400"></div>
@@ -385,6 +629,25 @@ function ESignDetail({ id, onBack }: { id: string; onBack: () => void }) {
</div>
</Card>
{/* 线下手签扫描件 */}
{detail.signMethod === 'PAPER' && detail.scanFileUrls && (detail.scanFileUrls as any[]).length > 0 && (
<Card className="p-5">
<div className="text-sm font-medium mb-3 flex items-center gap-1.5">
<FileCheck className="w-4 h-4 text-orange-500" />
<span className="text-xs text-gray-400 font-normal">{(detail.scanFileUrls as any[]).length}</span>
</div>
<div className="space-y-2">
{(detail.scanFileUrls as any[]).map((f: any, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs bg-gray-50 rounded px-3 py-2">
<FileText className="w-4 h-4 text-gray-400 shrink-0" />
<a href={f.url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex-1 truncate">{f.name}</a>
<ExternalLink className="w-3 h-3 text-gray-400" />
</div>
))}
</div>
</Card>
)}
{/* 文件内容预览 */}
{detail.documentContent && (
<Card className="p-5">
+56 -4
View File
@@ -35,6 +35,11 @@ const SCENE_LABELS: Record<string, { label: string; color: string }> = {
ONBOARDING: { label: '入职文件', color: 'bg-purple-50 text-purple-600 border border-purple-200' },
}
const SIGN_METHOD_LABELS: Record<string, { label: string; color: string }> = {
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<string | null>(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 (
<div className="space-y-4">
@@ -156,6 +162,40 @@ export default function MyEsign() {
</Card>
)}
{/* 线下手签信息 */}
{isPaperSign && (
<Card className="p-5">
<div className="text-sm font-medium mb-3 flex items-center gap-1.5">
<FileText className="w-4 h-4 text-orange-500" />线
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<div className="text-gray-400"></div>
<div className="text-gray-700">{detail.signedAt ? new Date(detail.signedAt).toLocaleDateString('zh-CN') : '—'}</div>
</div>
<div>
<div className="text-gray-400"></div>
<div className="text-gray-700">{detail.signedLocation || '—'}</div>
</div>
<div>
<div className="text-gray-400"></div>
<div className="text-gray-700">{detail.witnessName || '—'}</div>
</div>
</div>
{detail.scanFileUrls && (detail.scanFileUrls as any[]).length > 0 && (
<div className="mt-3 space-y-2">
<div className="text-xs text-gray-400">{(detail.scanFileUrls as any[]).length}</div>
{(detail.scanFileUrls as any[]).map((f: any, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs bg-gray-50 rounded px-3 py-2">
<FileText className="w-4 h-4 text-gray-400 shrink-0" />
<a href={f.url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex-1 truncate">{f.name}</a>
</div>
))}
</div>
)}
</Card>
)}
{/* 签署操作 */}
<Card className="p-5">
{canSign ? (
@@ -212,10 +252,17 @@ export default function MyEsign() {
) : detail.status === 'COMPLETED' ? (
<div className="text-center space-y-2">
<CheckCircle2 className="w-10 h-10 text-safe mx-auto" />
<div className="text-sm font-medium text-gray-700"></div>
<div className="text-xs text-gray-400">
{detail.completedAt ? new Date(detail.completedAt).toLocaleString('zh-CN') : '—'}
<div className="text-sm font-medium text-gray-700">
{isPaperSign ? '线下手签已登记' : '签署已完成'}
</div>
<div className="text-xs text-gray-400">
{isPaperSign
? `签署日期:${detail.signedAt ? new Date(detail.signedAt).toLocaleDateString('zh-CN') : '—'}`
: `完成时间:${detail.completedAt ? new Date(detail.completedAt).toLocaleString('zh-CN') : '—'}`}
</div>
{isPaperSign && detail.signedLocation && (
<div className="text-xs text-gray-400">{detail.signedLocation}</div>
)}
</div>
) : (
<div className="text-center text-xs text-gray-400">
@@ -272,6 +319,11 @@ export default function MyEsign() {
{r.scene && SCENE_LABELS[r.scene] && (
<span className={`px-1.5 py-0.5 rounded text-xs flex-shrink-0 ${SCENE_LABELS[r.scene].color}`}>{SCENE_LABELS[r.scene].label}</span>
)}
{r.signMethod === 'PAPER' && (
<span className={`px-1.5 py-0.5 rounded text-xs flex-shrink-0 ${(SIGN_METHOD_LABELS[r.signMethod] || SIGN_METHOD_LABELS.ELECTRONIC).color}`}>
{(SIGN_METHOD_LABELS[r.signMethod] || SIGN_METHOD_LABELS.ELECTRONIC).label}
</span>
)}
</div>
<div className="text-xs text-gray-500 mt-1">
{new Date(r.createdAt).toLocaleDateString('zh-CN')}