feat: 电子签署完整流程 + 证据链体系

## 后端
- esign.routes.ts 重写:
  - 发起签署时校验组织电子签开关(POLICY/PAYSLIP/ONBOARDING)
  - 自动从模板渲染文件内容(CONTRACT→劳动合同模板,RESIGNATION→离职协议模板)
  - 创建签署记录时自动创建证据链(发起签署事件)
  - 新增签署详情接口、证据链查看接口
  - 取消签署追加证据链事件
  - 签署状态查询自动处理过期记录
- portal.routes.ts 员工端 esign 重写:
  - 新增验证码发送接口(/esign/:id/send-code)
  - 签署操作增加验证码校验(5次错误限制)
  - 签署完成追加证据链(IP/UA/时间戳/验证码/签署人)
  - 签署完成回写合同 signMethod + attachmentName(签署证据)
  - 列表和详情接口自动处理过期记录

## 前端
- ESign.tsx 管理端重写:
  - 发起签署增加场景选择(5种场景)
  - 场景选择后自动填充默认文件标题
  - 新增签署详情页(文件内容预览 + 证据链时间线)
  - 签署流程说明
- MyEsign.tsx 员工端重写:
  - 签署操作增加验证码确认流程
  - 60秒倒计时限制
  - 文件内容预览
  - 签署完成状态展示
- api-services.ts:
  - esignApi 增加 detail/evidence 接口
  - portalApi 增加 esignSendCode 接口
  - signEsign 增加 verifyCode 参数

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:19:37 +08:00
parent d216e4a57a
commit b6fc93cc61
5 changed files with 748 additions and 133 deletions
+211 -24
View File
@@ -1,22 +1,29 @@
/**
* 电子签署管理页面
* - 发起签署(场景选择 + 模板自动渲染 + 组织开关校验)
* - 签署记录列表(状态/场景筛选)
* - 签署详情(含证据链查看)
* - 取消签署
*/
import { useState } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { PenTool, Plus, X, RefreshCw, ExternalLink, FileText, AlertCircle } from 'lucide-react'
import { PenTool, Plus, RefreshCw, ExternalLink, FileText, AlertCircle, Shield, ChevronLeft, Clock, CheckCircle2, XCircle, Eye } 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 } from '../components/ui/Input'
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 }> = {
PENDING: { label: '待签署', color: 'bg-yellow-50 text-yellow-700' },
SIGNING: { label: '签署中', color: 'bg-blue-50 text-blue-700' },
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe' },
REJECTED: { label: '已拒绝', color: 'bg-red-50 text-danger' },
EXPIRED: { label: '已过期', color: 'bg-gray-100 text-gray-500' },
CANCELLED: { label: '已取消', color: 'bg-gray-100 text-gray-500' },
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 }> = {
@@ -32,8 +39,10 @@ export default function ESign() {
const [filterStatus, setFilterStatus] = useState('')
const [filterScene, setFilterScene] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [detailId, setDetailId] = useState<string | null>(null)
const [formData, setFormData] = useState({
employeeId: '',
scene: 'CONTRACT',
documentTitle: '',
remark: '',
})
@@ -54,15 +63,17 @@ export default function ESign() {
})
const createMutation = useMutation({
mutationFn: async (data: { employeeId: string; documentTitle: string; remark?: string }) =>
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: '', documentTitle: '', remark: '' })
toast.success('签署记录已创建,待对接易签宝后将自动发送签署链接')
setFormData({ employeeId: '', scene: 'CONTRACT', documentTitle: '', remark: '' })
toast.success('签署记录已创建,员工可在员工端查看并签署')
},
onError: (err: any) => {
toast.error(err?.response?.data?.error?.message || '创建失败')
},
onError: () => toast.error('创建失败'),
})
const cancelMutation = useMutation({
@@ -87,27 +98,30 @@ export default function ESign() {
createMutation.mutate(formData)
}
// ===== 签署详情视图 =====
if (detailId) {
return <ESignDetail id={detailId} onBack={() => setDetailId(null)} />
}
return (
<div className="space-y-4">
<PageGuide>
线PDF文件
<span className="text-amber-600"> API后将自动启用在线签署功能</span>
线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>
<p className="mt-1 text-sm text-gray-500">线 + </p>
</div>
</div>
<InlineAlert type="info" className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
<Shield className="w-4 h-4 mt-0.5 shrink-0" />
<div>
<span className="font-medium">API</span>
<span className="font-medium"></span>
<div className="mt-1 text-xs">
AppIdAppSecret API
HR在系统发起签署 PDF
HR在系统发起签署 IP/UA//
</div>
</div>
</InlineAlert>
@@ -164,7 +178,9 @@ export default function ESign() {
<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" />
<span className="font-medium truncate max-w-[200px]">{r.documentTitle}</span>
<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>
)}
@@ -174,16 +190,21 @@ export default function ESign() {
<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-2 py-0.5 rounded text-xs ${statusCfg.color}`}>{statusCfg.label}</span>
<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
<ExternalLink className="w-3 h-3" />PDF
</a>
)}
{(r.status === 'PENDING' || r.status === 'SIGNING') && (
@@ -213,7 +234,7 @@ export default function ESign() {
<Modal open={true} onClose={() => setShowCreate(false)} title="发起电子签署" size="md">
<div className="space-y-3">
<InlineAlert type="info">
</InlineAlert>
<div>
<Label> *</Label>
@@ -228,6 +249,26 @@ export default function ESign() {
))}
</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 })}
@@ -238,6 +279,13 @@ export default function ESign() {
<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}>
@@ -250,3 +298,142 @@ export default function ESign() {
</div>
)
}
// ===== 签署详情组件 =====
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>
</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>
<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.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>
)
}
+142 -55
View File
@@ -1,18 +1,27 @@
/**
* 员工端 — 电子签署页面
* 查看自己的待签/已签文件,进行签署操作
* 查看自己的待签/已签文件,通过手机验证码确认签署
*
* 签署流程:
* 1. 查看待签文件内容
* 2. 点击「获取验证码」→ 系统发送验证码到登记手机号
* 3. 输入验证码 → 点击「确认签署」
* 4. 签署完成,自动记录证据链
*/
import { useState } from 'react'
import { useState, useRef, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { PenTool, FileText, CheckCircle2, Clock, XCircle, ChevronLeft } from 'lucide-react'
import { PenTool, FileText, CheckCircle2, Clock, XCircle, ChevronLeft, Shield, Phone } from 'lucide-react'
import { portalApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { Input, Label } from '../../components/ui/Input'
import { InlineAlert } from '../../components/ui/InlineAlert'
import EmptyState from '../../components/ui/EmptyState'
const STATUS_MAP: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
PENDING: { label: '待签署', color: 'bg-amber-50 text-amber-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" /> },
CANCELLED: { label: '已取消', color: 'bg-gray-100 text-gray-400', icon: <XCircle className="w-3 h-3" /> },
EXPIRED: { label: '已过期', color: 'bg-red-50 text-red-600', icon: <XCircle className="w-3 h-3" /> },
@@ -29,6 +38,9 @@ const SCENE_LABELS: Record<string, { label: string; color: string }> = {
export default function MyEsign() {
const queryClient = useQueryClient()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [verifyCode, setVerifyCode] = useState('')
const [countdown, setCountdown] = useState(0)
const codeInputRef = useRef<HTMLInputElement>(null)
const { data: list = [], isLoading } = useQuery<any[]>({
queryKey: ['portal-esign'],
@@ -41,25 +53,51 @@ export default function MyEsign() {
enabled: !!selectedId,
})
/** 发送验证码 */
const sendCodeMutation = useMutation({
mutationFn: (id: string) => portalApi.esignSendCode(id),
onSuccess: (res: any) => {
toast.success(`验证码已发送至 ${res.phone || '登记手机号'}`)
setCountdown(60)
// 开发阶段直接显示验证码
if (res.code) {
toast.info(`开发模式验证码:${res.code}`, { duration: 10000 })
}
setTimeout(() => codeInputRef.current?.focus(), 100)
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '验证码发送失败'),
})
/** 签署确认 */
const signMutation = useMutation({
mutationFn: (id: string) => portalApi.signEsign(id),
mutationFn: ({ id, code }: { id: string; code: string }) => portalApi.signEsign(id, code),
onSuccess: () => {
toast.success('签署成功')
queryClient.invalidateQueries({ queryKey: ['portal-esign'] })
queryClient.invalidateQueries({ queryKey: ['portal-esign-detail', selectedId] })
setVerifyCode('')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '签署失败'),
})
/** 倒计时 */
useEffect(() => {
if (countdown <= 0) return
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
return () => clearTimeout(timer)
}, [countdown])
const pendingCount = list.filter((r: any) => r.status === 'PENDING').length
// 详情页
// ===== 详情页 =====
if (selectedId) {
const st = detail ? STATUS_MAP[detail.status] || STATUS_MAP.PENDING : null
const canSign = detail?.status === 'PENDING'
return (
<div className="space-y-4">
<button
onClick={() => setSelectedId(null)}
onClick={() => { setSelectedId(null); setVerifyCode('') }}
className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm py-2"
>
<ChevronLeft className="w-4 h-4" />
@@ -74,69 +112,118 @@ export default function MyEsign() {
</div>
</Card>
) : detail ? (
<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 min-w-0">
<h1 className="text-base font-semibold truncate">{detail.documentTitle}</h1>
<div className="text-xs text-gray-500 mt-0.5">
{new Date(detail.createdAt).toLocaleString('zh-CN')}
<>
<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 min-w-0">
<h1 className="text-base font-semibold truncate">{detail.documentTitle}</h1>
<div className="text-xs text-gray-500 mt-0.5">
{new Date(detail.createdAt).toLocaleString('zh-CN')}
</div>
</div>
{st && (
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs ${st.color}`}>
{st.icon}{st.label}
</span>
)}
</div>
{st && (
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs ${st.color}`}>
{st.icon}{st.label}
</span>
{detail.remark && (
<div className="mb-4 px-3 py-2 rounded-md bg-gray-50 text-xs text-gray-600">
{detail.remark}
</div>
)}
</div>
{detail.remark && (
<div className="mb-4 px-3 py-2 rounded-md bg-gray-50 text-xs text-gray-600">
{detail.remark}
</div>
)}
{detail.scene && SCENE_LABELS[detail.scene] && (
<div className="mb-3">
<span className={`px-2 py-0.5 rounded text-xs ${SCENE_LABELS[detail.scene].color}`}>{SCENE_LABELS[detail.scene].label}</span>
</div>
)}
</Card>
{/* 文件内容 */}
{detail.documentContent && (
<div className="mb-4 text-sm text-gray-700 whitespace-pre-wrap leading-relaxed border rounded-md p-3 max-h-60 overflow-y-auto">
{detail.documentContent}
</div>
)}
{detail.contractId && (
<div className="mb-4 text-xs text-blue-600 flex items-center gap-1">
<FileText className="w-3.5 h-3.5" />
</div>
)}
{detail.scene && SCENE_LABELS[detail.scene] && (
<div className="mb-4">
<span className={`px-2 py-0.5 rounded text-xs ${SCENE_LABELS[detail.scene].color}`}>{SCENE_LABELS[detail.scene].label}</span>
</div>
<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-sm text-gray-700 whitespace-pre-wrap leading-relaxed border rounded-md p-3 max-h-96 overflow-y-auto bg-gray-50/50">
{detail.documentContent}
</div>
</Card>
)}
{/* 签署操作 */}
<div className="border-t pt-4">
{detail.status === 'PENDING' ? (
<Button
className="w-full"
onClick={() => signMutation.mutate(detail.id)}
disabled={signMutation.isPending}
>
<PenTool className="w-4 h-4 mr-1" />
{signMutation.isPending ? '签署中...' : '确认签署'}
</Button>
<Card className="p-5">
{canSign ? (
<div className="space-y-4">
<InlineAlert type="info" className="flex items-start gap-2">
<Shield className="w-4 h-4 mt-0.5 shrink-0" />
<div className="text-xs">
<div className="font-medium"></div>
<div className="mt-1">IP</div>
</div>
</InlineAlert>
<div>
<Label></Label>
<div className="flex gap-2">
<Input
ref={codeInputRef}
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="请输入6位验证码"
maxLength={6}
className="flex-1"
onKeyDown={(e) => {
if (e.key === 'Enter' && verifyCode.length === 6) {
signMutation.mutate({ id: detail.id, code: verifyCode })
}
}}
/>
<Button
variant="secondary"
size="sm"
onClick={() => sendCodeMutation.mutate(detail.id)}
disabled={sendCodeMutation.isPending || countdown > 0}
className="whitespace-nowrap"
>
{countdown > 0 ? `${countdown}s` : '获取验证码'}
</Button>
</div>
<div className="text-xs text-gray-400 mt-1 flex items-center gap-1">
<Phone className="w-3 h-3" />
</div>
</div>
<Button
className="w-full"
onClick={() => signMutation.mutate({ id: detail.id, code: verifyCode })}
disabled={signMutation.isPending || verifyCode.length !== 6}
>
<PenTool className="w-4 h-4 mr-1" />
{signMutation.isPending ? '签署中...' : '确认签署'}
</Button>
</div>
) : detail.status === 'COMPLETED' ? (
<div className="text-center text-xs text-gray-500">
{detail.completedAt ? new Date(detail.completedAt).toLocaleString('zh-CN') : '—'}
<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>
</div>
) : (
<div className="text-center text-xs text-gray-400">
{st?.label}
</div>
)}
</div>
</Card>
</Card>
</>
) : (
<Card className="p-6">
<EmptyState title="记录不存在" description="该签署记录可能已被删除" />
@@ -146,11 +233,11 @@ export default function MyEsign() {
)
}
// 列表页
// ===== 列表页 =====
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<PenTool className="w-5 h-5 text-primary" />
<PenTool className="w-5 w-5 text-primary" />
<h1 className="text-base font-bold"></h1>
{pendingCount > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-amber-100 text-amber-700">