Files
TurboHR/frontend/src/pages/ESign.tsx
T
selfrelease 3da61d5a09 feat: 花名册操作联动电子签署 + 修复重复创建
## 修复
- POST /contracts 不再自动创建签署记录(由前端根据signMethod决定),避免与ContractInfo重复创建

## 新增 SignMethodChoice 通用组件
- 花名册操作成功后弹窗选择签署方式:电子签/线下手签/稍后处理
- 电子签:自动创建ESignRecord(PENDING),员工在员工端签署
- 线下手签:跳转签署页面,预填员工/场景/标题,上传扫描件登记
- 稍后处理:跳过,可后续在签署页面手动发起

## 花名册4项操作联动签署方式选择
- 重新入职:操作成功后弹窗(scene=CONTRACT,劳动合同)
- 合同续签:操作成功后弹窗(scene=CONTRACT,续签劳动合同)
- 薪酬变更:操作成功后弹窗(scene=POLICY,薪酬调整确认书)
- 调岗调动:操作成功后弹窗(scene=POLICY,调岗确认书)

## ESign页面支持URL参数
- 读取 ?action=paper-sign 自动打开线下手签登记Modal
- 预填employeeId/scene/documentTitle

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 16:45:53 +08:00

737 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 电子签署管理页面
* - 发起电子签署(场景选择 + 模板自动渲染 + 组织开关校验)
* - 线下手签登记(上传扫描件 + 签署信息 + 证据链)
* - 签署记录列表(状态/场景/签署方式筛选)
* - 签署详情(含证据链查看)
* - 取消签署
*/
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<string, { label: string; color: string; icon: React.ReactNode }> = {
PENDING: { label: '待签署', color: 'bg-yellow-50 text-yellow-700', icon: <Clock className="w-3 h-3" /> },
SIGNING: { label: '签署中', color: 'bg-blue-50 text-blue-700', icon: <Clock className="w-3 h-3" /> },
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe', icon: <CheckCircle2 className="w-3 h-3" /> },
REJECTED: { label: '已拒绝', color: 'bg-red-50 text-danger', icon: <XCircle className="w-3 h-3" /> },
EXPIRED: { label: '已过期', color: 'bg-gray-100 text-gray-500', icon: <XCircle className="w-3 h-3" /> },
CANCELLED: { label: '已取消', color: 'bg-gray-100 text-gray-500', icon: <XCircle className="w-3 h-3" /> },
}
const SCENE_CONFIG: Record<string, { label: string; color: string }> = {
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<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 [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<any[]>({
queryKey: ['esign-records', filterStatus, filterScene],
queryFn: async () => {
return await esignApi.list({ status: filterStatus || undefined, scene: filterScene || undefined })
},
})
const { data: rosterData = [] } = useQuery<any[]>({
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 <ESignDetail id={detailId} onBack={() => setDetailId(null)} />
}
return (
<div className="space-y-4">
<PageGuide>
线IP
</PageGuide>
<div className="flex items-center gap-2">
<PenTool className="h-5 w-5 text-primary" />
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500">线 + </p>
</div>
</div>
<InlineAlert type="info" className="flex items-start gap-2">
<Shield className="w-4 h-4 mt-0.5 shrink-0" />
<div>
<span className="font-medium"></span>
<div className="mt-1 text-xs">
HR在系统发起签署 IP/UA//
</div>
</div>
</InlineAlert>
<div className="flex items-center justify-between">
<div className="flex gap-2">
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="h-9 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"
>
<option value=""></option>
{Object.entries(STATUS_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
<select
value={filterScene}
onChange={(e) => setFilterScene(e.target.value)}
className="h-9 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"
>
<option value=""></option>
{Object.entries(SCENE_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
</div>
<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>
{/* 签署记录列表 */}
<Card>
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : records.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<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>
<th className="py-2 px-3 text-right"></th>
</tr>
</thead>
<tbody>
{records.map((r: any) => {
const statusCfg = STATUS_CONFIG[r.status] || STATUS_CONFIG.PENDING
return (
<tr key={r.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 px-3">
<div className="flex items-center gap-1.5">
<FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<button className="font-medium truncate max-w-[200px] text-left hover:text-primary" onClick={() => setDetailId(r.id)}>
{r.documentTitle}
</button>
{r.scene && SCENE_CONFIG[r.scene] && (
<span className={`px-1.5 py-0.5 rounded text-xs shrink-0 ${SCENE_CONFIG[r.scene].color}`}>{SCENE_CONFIG[r.scene].label}</span>
)}
</div>
{r.remark && <div className="text-xs text-gray-400 mt-0.5">{r.remark}</div>}
</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}
</span>
</td>
<td className="py-2 px-3 text-gray-500 text-xs">{new Date(r.createdAt).toLocaleString('zh-CN')}</td>
<td className="py-2 px-3 text-gray-500 text-xs">{r.completedAt ? new Date(r.completedAt).toLocaleString('zh-CN') : '—'}</td>
<td className="py-2 px-3 text-right">
<div className="flex items-center justify-end gap-1">
<button className="text-xs text-gray-400 hover:text-primary" onClick={() => setDetailId(r.id)} title="查看详情">
<Eye className="w-3.5 h-3.5" />
</button>
{r.status === 'COMPLETED' && r.signedPdfUrl && (
<a href={r.signedPdfUrl} target="_blank" rel="noopener noreferrer"
className="text-xs text-primary hover:underline flex items-center gap-0.5">
<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)}
title="刷新状态">
<RefreshCw className={`w-3.5 h-3.5 ${refreshStatusMutation.isPending ? 'animate-spin' : ''}`} />
</button>
<button className="text-xs text-gray-400 hover:text-danger" onClick={() => {
if (confirm('确定取消此签署任务吗?')) cancelMutation.mutate(r.id)
}}></button>
</>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</Card>
{/* 发起签署 Modal */}
{showCreate && (
<Modal open={true} onClose={() => setShowCreate(false)} 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={formData.employeeId}
onChange={(e) => setFormData({ ...formData, 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={formData.scene}
onChange={(e) => {
const scene = e.target.value
const defaultTitle: Record<string, string> = {
CONTRACT: '劳动合同书',
RESIGNATION: '协商解除劳动合同协议书',
POLICY: '规章制度签收确认书',
PAYSLIP: '工资条确认书',
ONBOARDING: '入职文件签署',
}
setFormData({ ...formData, 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={formData.documentTitle} onChange={(e) => setFormData({ ...formData, documentTitle: e.target.value })}
placeholder="如:2024年度劳动合同" />
</div>
<div>
<Label></Label>
<Input value={formData.remark} onChange={(e) => setFormData({ ...formData, remark: e.target.value })}
placeholder="可选" />
</div>
<div className="text-xs text-gray-400 bg-gray-50 rounded p-2">
<div className="font-medium text-gray-500 mb-1"></div>
<div>1. </div>
<div>2. </div>
<div>3. IP//</div>
{formData.scene === 'CONTRACT' && <div>4. </div>}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={() => setShowCreate(false)}></Button>
<Button size="sm" onClick={handleCreate} disabled={createMutation.isPending}>
{createMutation.isPending ? '创建中...' : '发起签署'}
</Button>
</div>
</div>
</Modal>
)}
{/* 线下手签登记 Modal */}
{showPaperSign && (
<PaperSignModal
initialEmployeeId={formData.employeeId}
initialScene={formData.scene as any}
initialDocumentTitle={formData.documentTitle}
initialRemark={formData.remark}
onClose={() => { setShowPaperSign(false); setFormData({ employeeId: '', scene: 'CONTRACT', documentTitle: '', remark: '' }) }}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
setShowPaperSign(false)
setFormData({ employeeId: '', scene: 'CONTRACT', documentTitle: '', remark: '' })
}}
/>
)}
</div>
)
}
// ===== 线下手签登记 Modal =====
function PaperSignModal({ onClose, onSuccess, initialEmployeeId, initialScene, initialDocumentTitle, initialRemark }: {
onClose: () => void
onSuccess: () => void
initialEmployeeId?: string
initialScene?: string
initialDocumentTitle?: string
initialRemark?: string
}) {
const fileInputRef = useRef<HTMLInputElement>(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<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>({
queryKey: ['esign-detail', id],
queryFn: () => esignApi.detail(id),
})
const { data: evidence = [] } = useQuery<any[]>({
queryKey: ['esign-evidence', id],
queryFn: () => esignApi.evidence(id),
})
if (isLoading) {
return (
<div className="space-y-4">
<button onClick={onBack} className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm py-2">
<ChevronLeft className="w-4 h-4" />
</button>
<Card className="p-6"><div className="text-center text-gray-400">...</div></Card>
</div>
)
}
if (!detail) {
return (
<div className="space-y-4">
<button onClick={onBack} className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm py-2">
<ChevronLeft className="w-4 h-4" />
</button>
<Card className="p-6"><div className="text-center text-gray-400"></div></Card>
</div>
)
}
const statusCfg = STATUS_CONFIG[detail.status] || STATUS_CONFIG.PENDING
const sceneCfg = SCENE_CONFIG[detail.scene] || SCENE_CONFIG.CONTRACT
return (
<div className="space-y-4">
<button onClick={onBack} className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm py-2">
<ChevronLeft className="w-4 h-4" />
</button>
{/* 基本信息 */}
<Card className="p-5">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<FileText className="w-5 h-5 text-primary" />
</div>
<div className="flex-1">
<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>
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded text-xs ${statusCfg.color}`}>
{statusCfg.icon}{statusCfg.label}
</span>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<div className="text-gray-400"></div>
<div className="text-gray-700">{new Date(detail.createdAt).toLocaleString('zh-CN')}</div>
</div>
<div>
<div className="text-gray-400"></div>
<div className="text-gray-700">{detail.completedAt ? new Date(detail.completedAt).toLocaleString('zh-CN') : '—'}</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>
<div className="text-gray-700">{detail.remark}</div>
</div>
)}
</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">
<div className="text-sm font-medium mb-3 flex items-center gap-1.5">
<FileText className="w-4 h-4 text-gray-400" />
</div>
<div className="text-xs text-gray-600 max-h-96 overflow-y-auto bg-gray-50 rounded p-3 whitespace-pre-wrap border">
{detail.documentContent}
</div>
</Card>
)}
{/* 证据链 */}
<Card className="p-5">
<div className="text-sm font-medium mb-3 flex items-center gap-1.5">
<Shield className="w-4 h-4 text-primary" />
<span className="text-xs text-gray-400 font-normal">{evidence.length}</span>
</div>
{evidence.length === 0 ? (
<div className="text-xs text-gray-400 text-center py-4"></div>
) : (
<div className="space-y-3">
{evidence.map((ev: any, idx: number) => {
const events = ev.events as any[]
return (
<div key={ev.id} className="border rounded-md p-3">
<div className="text-xs text-gray-400 mb-2"> #{idx + 1} · {new Date(ev.createdAt).toLocaleString('zh-CN')}</div>
<div className="space-y-2">
{events?.map((event: any, i: number) => (
<div key={i} className="flex items-start gap-2 text-xs">
<div className="w-1.5 h-1.5 rounded-full bg-primary mt-1.5 shrink-0" />
<div className="flex-1">
<div className="text-gray-700 font-medium">{event.action}</div>
<div className="text-gray-400 mt-0.5">
{new Date(event.timestamp).toLocaleString('zh-CN')}
{event.ip && ` · IP: ${event.ip}`}
{event.location && ` · ${event.location}`}
</div>
</div>
</div>
))}
</div>
<div className="text-xs text-gray-300 mt-2 font-mono">hash: {ev.hash?.slice(0, 32)}...</div>
</div>
)
})}
</div>
)}
</Card>
</div>
)
}