feat: 电子签署改为「待签合同」,按员工聚合+催办功能
1. 菜单「电子签署」改名为「待签合同」
2. 页面重构为两个 Tab:
- 待签合同:按员工聚合展示所有未签文件(EsignRecord
PENDING/SIGNING + LaborContract signDate 为空),展开可
查看该员工名下所有待签文件详情
- 签署记录:原有全部签署记录列表(保留筛选功能)
3. 催办功能:点击催办生成一次性自动登录链接(24h有效),
指向员工端签署页,弹窗展示二维码+可复制链接,HR 发给
员工扫码直接进入签署
4. 后端新增接口:
- GET /esign/pending 待签合同按员工聚合列表
- POST /esign/remind 催办生成自动登录链接
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import { z } from 'zod'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import multer from 'multer'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
@@ -23,6 +24,8 @@ import { renderTemplate, getTemplateById } from '../services/template.service'
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
const AUTO_LOGIN_SECRET = process.env.JWT_SECRET || 'dev-secret'
|
||||
|
||||
/** 场景与组织电子签开关的映射 */
|
||||
const SCENE_ORG_FLAG_MAP: Record<string, string | null> = {
|
||||
CONTRACT: null, // 合同签署不需要额外开关(默认允许)
|
||||
@@ -74,6 +77,141 @@ router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
/**
|
||||
* 待签合同列表(按员工聚合)
|
||||
* GET /esign/pending
|
||||
*
|
||||
* 汇总两类未签署记录,按员工聚合:
|
||||
* 1. EsignRecord 中 status 为 PENDING/SIGNING 的(所有场景)
|
||||
* 2. LaborContract 中 signDate 为空且员工在职的(纸质合同未登记签署日期)
|
||||
*
|
||||
* 返回格式:[{ employeeId, name, department, phone, pendingItems: [{ type, title, scene, signMethod, status, createdAt, recordId, contractId }] }]
|
||||
*/
|
||||
router.get('/pending', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
// 1. 查询未完成的 EsignRecord
|
||||
const pendingEsign = await prisma.eSignRecord.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
status: { in: ['PENDING', 'SIGNING'] },
|
||||
},
|
||||
include: {
|
||||
employee: { select: { id: true, name: true, department: true, phone: true, status: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
// 2. 查询 signDate 为空的 LaborContract(且员工在职)
|
||||
const pendingContracts = await prisma.laborContract.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
signDate: null,
|
||||
employee: { status: 'ACTIVE' },
|
||||
},
|
||||
include: {
|
||||
employee: { select: { id: true, name: true, department: true, phone: true, status: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
// 3. 按员工聚合
|
||||
const employeeMap = new Map<string, {
|
||||
employeeId: string
|
||||
name: string
|
||||
department: string | null
|
||||
phone: string | null
|
||||
pendingItems: any[]
|
||||
}>()
|
||||
|
||||
// 辅助函数:添加员工到 map
|
||||
const ensureEmployee = (emp: { id: string; name: string; department: string | null; phone: string | null }) => {
|
||||
if (!employeeMap.has(emp.id)) {
|
||||
employeeMap.set(emp.id, {
|
||||
employeeId: emp.id,
|
||||
name: emp.name,
|
||||
department: emp.department,
|
||||
phone: emp.phone,
|
||||
pendingItems: [],
|
||||
})
|
||||
}
|
||||
return employeeMap.get(emp.id)!
|
||||
}
|
||||
|
||||
// 汇总 EsignRecord
|
||||
for (const r of pendingEsign) {
|
||||
const emp = ensureEmployee(r.employee)
|
||||
emp.pendingItems.push({
|
||||
type: 'esign',
|
||||
recordId: r.id,
|
||||
contractId: r.contractId,
|
||||
title: r.documentTitle,
|
||||
scene: r.scene,
|
||||
signMethod: r.signMethod,
|
||||
status: r.status,
|
||||
createdAt: r.createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
// 汇总 LaborContract(排除已有 EsignRecord 关联的,避免重复)
|
||||
const esignContractIds = new Set(pendingEsign.filter(r => r.contractId).map(r => r.contractId))
|
||||
for (const c of pendingContracts) {
|
||||
if (esignContractIds.has(c.id)) continue // 已有电子签署记录的不重复
|
||||
const emp = ensureEmployee(c.employee)
|
||||
emp.pendingItems.push({
|
||||
type: 'contract',
|
||||
recordId: null,
|
||||
contractId: c.id,
|
||||
title: `${c.contractType}合同`,
|
||||
scene: 'CONTRACT',
|
||||
signMethod: c.signMethod,
|
||||
status: 'PENDING',
|
||||
createdAt: c.createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
// 转为数组,按待签数量降序、姓名排序
|
||||
const result = Array.from(employeeMap.values()).sort((a, b) => {
|
||||
if (b.pendingItems.length !== a.pendingItems.length) return b.pendingItems.length - a.pendingItems.length
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
/**
|
||||
* 催办 — 生成员工一次性自动登录链接(指向员工端签署页)
|
||||
* POST /esign/remind body: { employeeId }
|
||||
*
|
||||
* 返回自动登录 URL,HR 可复制或生成二维码发给员工
|
||||
*/
|
||||
router.post('/remind', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { employeeId } = req.body
|
||||
if (!employeeId) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 employeeId' } })
|
||||
}
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: employeeId, orgId: req.user!.orgId, status: 'ACTIVE' },
|
||||
select: { id: true, name: true, phone: true, orgId: true },
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在或已离职' } })
|
||||
}
|
||||
// 生成一次性 token(24 小时有效,给员工充足时间签署)
|
||||
const token = jwt.sign(
|
||||
{ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE_AUTO', name: employee.name },
|
||||
AUTO_LOGIN_SECRET,
|
||||
{ expiresIn: '24h' },
|
||||
)
|
||||
const url = `${process.env.PORTAL_BASE_URL || ''}/portal/auto-login?token=${token}&redirect=/portal/esign`
|
||||
await auditLog(req, 'REMIND', 'ESIGN', employeeId, { employeeName: employee.name })
|
||||
res.json({ success: true, data: { url, token, employeeName: employee.name, phone: employee.phone } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
/**
|
||||
* 发起签署
|
||||
* - 校验组织电子签开关
|
||||
|
||||
@@ -37,7 +37,7 @@ const navGroups: NavGroup[] = [
|
||||
items: [
|
||||
{ path: '/', label: '工作台', icon: LayoutDashboard },
|
||||
{ path: '/calendar', label: '工作日历', icon: CalendarDays },
|
||||
{ path: '/esign', label: '电子签署', icon: PenTool },
|
||||
{ path: '/esign', label: '待签合同', icon: PenTool },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -644,6 +644,12 @@ export const benefitApi = {
|
||||
export const esignApi = {
|
||||
list: (params?: { status?: string; scene?: string }) =>
|
||||
get('/esign', { params: params || {} }).then(unwrap<any[]>()),
|
||||
/** 待签合同列表(按员工聚合) */
|
||||
pending: () =>
|
||||
get('/esign/pending').then(unwrap<any[]>()),
|
||||
/** 催办(生成自动登录链接) */
|
||||
remind: (employeeId: string) =>
|
||||
post('/esign/remind', { employeeId }).then(unwrap<any>()),
|
||||
create: (data: { contractId?: string; employeeId: string; documentTitle: string; documentContent?: string; remark?: string; scene?: string; templateId?: string; templateVars?: Record<string, string> }) =>
|
||||
post('/esign/create', data),
|
||||
detail: (id: string) =>
|
||||
|
||||
+228
-23
@@ -1,16 +1,18 @@
|
||||
/**
|
||||
* 电子签署管理页面
|
||||
* 待签合同 / 电子签署管理页面
|
||||
* - Tab1 待签合同:按员工聚合展示所有未签署文件,支持催办(生成二维码/链接)
|
||||
* - Tab2 签署记录:全部签署记录列表(状态/场景/签署方式筛选)
|
||||
* - 发起电子签署(场景选择 + 模板自动渲染 + 组织开关校验)
|
||||
* - 线下手签登记(上传扫描件 + 签署信息 + 证据链)
|
||||
* - 签署记录列表(状态/场景/签署方式筛选)
|
||||
* - 签署详情(含证据链查看)
|
||||
* - 取消签署
|
||||
*/
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, Fragment } 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 { QRCodeSVG } from 'qrcode.react'
|
||||
import { PenTool, Plus, RefreshCw, ExternalLink, FileText, AlertCircle, Shield, ChevronLeft, Clock, CheckCircle2, XCircle, Eye, Upload, FileCheck, Bell, Copy, Check, ChevronDown, ChevronRight, User } from 'lucide-react'
|
||||
import { esignApi, employeeApi } from '../lib/api-services'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
import Card from '../components/ui/Card'
|
||||
@@ -43,6 +45,7 @@ const SIGN_METHOD_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
|
||||
export default function ESign() {
|
||||
const queryClient = useQueryClient()
|
||||
const [activeTab, setActiveTab] = useState<'pending' | 'records'>('pending')
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
const [filterScene, setFilterScene] = useState('')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
@@ -83,6 +86,26 @@ export default function ESign() {
|
||||
},
|
||||
})
|
||||
|
||||
/** 待签合同列表(按员工聚合) */
|
||||
const { data: pendingList = [], isLoading: pendingLoading } = useQuery<any[]>({
|
||||
queryKey: ['esign-pending'],
|
||||
queryFn: async () => {
|
||||
return await esignApi.pending()
|
||||
},
|
||||
})
|
||||
|
||||
/** 催办(生成自动登录链接) */
|
||||
const remindMutation = useMutation({
|
||||
mutationFn: (employeeId: string) => esignApi.remind(employeeId) as any,
|
||||
onSuccess: (data: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['esign-pending'] })
|
||||
toast.success(`催办链接已生成,可复制或展示二维码给员工`, {
|
||||
description: data?.phone ? `员工手机:${data.phone}` : '',
|
||||
})
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '催办失败'),
|
||||
})
|
||||
|
||||
const { data: rosterData = [] } = useQuery<any[]>({
|
||||
queryKey: ['employees-for-esign'],
|
||||
queryFn: async () => {
|
||||
@@ -135,26 +158,51 @@ export default function ESign() {
|
||||
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>
|
||||
<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>
|
||||
{/* Tab 切换 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1 border-b">
|
||||
<button
|
||||
onClick={() => setActiveTab('pending')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'pending' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
待签合同
|
||||
{pendingList.length > 0 && <span className="ml-1.5 px-1.5 py-0.5 rounded-full text-xs bg-yellow-100 text-yellow-700">{pendingList.length}</span>}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('records')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'records' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
签署记录
|
||||
</button>
|
||||
</div>
|
||||
</InlineAlert>
|
||||
<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>
|
||||
|
||||
{/* ===== Tab1: 待签合同(按员工聚合) ===== */}
|
||||
{activeTab === 'pending' && (
|
||||
<PendingTab pendingList={pendingList} loading={pendingLoading} onRemind={(empId) => remindMutation.mutate(empId)} remindLoading={remindMutation.isPending} remindData={remindMutation.data} onDetail={(id) => setDetailId(id)} />
|
||||
)}
|
||||
|
||||
{/* ===== Tab2: 签署记录 ===== */}
|
||||
{activeTab === 'records' && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
@@ -174,14 +222,6 @@ export default function ESign() {
|
||||
{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>
|
||||
|
||||
{/* 签署记录列表 */}
|
||||
@@ -274,6 +314,8 @@ export default function ESign() {
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 发起签署 Modal */}
|
||||
{showCreate && (
|
||||
@@ -734,3 +776,166 @@ function ESignDetail({ id, onBack }: { id: string; onBack: () => void }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 待签合同 Tab — 按员工聚合展示待签文件,支持催办
|
||||
*/
|
||||
function PendingTab({ pendingList, loading, onRemind, remindLoading, remindData, onDetail }: {
|
||||
pendingList: any[]
|
||||
loading: boolean
|
||||
onRemind: (employeeId: string) => void
|
||||
remindLoading: boolean
|
||||
remindData: any
|
||||
onDetail: (id: string) => void
|
||||
}) {
|
||||
const [expandedEmp, setExpandedEmp] = useState<string | null>(null)
|
||||
const [remindEmpId, setRemindEmpId] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = (url: string) => {
|
||||
navigator.clipboard?.writeText(url)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Card><div className="text-center py-8 text-gray-400">加载中...</div></Card>
|
||||
}
|
||||
|
||||
if (pendingList.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-8 text-gray-400 text-sm">
|
||||
<CheckCircle2 className="w-8 h-8 mx-auto mb-2 text-green-400" />
|
||||
所有合同均已签署,暂无待签项
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<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 w-8"></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-center">待签数</th>
|
||||
<th className="py-2 px-3 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pendingList.map((emp: any) => {
|
||||
const expanded = expandedEmp === emp.employeeId
|
||||
return (
|
||||
<Fragment key={emp.employeeId}>
|
||||
<tr className="border-b last:border-0 hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => setExpandedEmp(expanded ? null : emp.employeeId)}
|
||||
>
|
||||
<td className="py-2 px-3">
|
||||
{expanded ? <ChevronDown className="w-4 h-4 text-gray-400" /> : <ChevronRight className="w-4 h-4 text-gray-400" />}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-3.5 h-3.5 text-primary" />
|
||||
</div>
|
||||
<span className="font-medium">{emp.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-gray-500">{emp.department || '—'}</td>
|
||||
<td className="py-2 px-3 text-gray-500">{emp.phone || '—'}</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
<span className="inline-flex items-center justify-center px-2 py-0.5 rounded-full text-xs bg-yellow-100 text-yellow-700 font-medium">
|
||||
{emp.pendingItems.length}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setRemindEmpId(emp.employeeId)
|
||||
onRemind(emp.employeeId)
|
||||
}}
|
||||
disabled={remindLoading && remindEmpId === emp.employeeId}
|
||||
>
|
||||
<Bell className="w-3.5 h-3.5 mr-1" />
|
||||
{remindLoading && remindEmpId === emp.employeeId ? '生成中...' : '催办'}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{/* 展开行:待签文件列表 */}
|
||||
{expanded && (
|
||||
<tr className="bg-gray-50/50">
|
||||
<td></td>
|
||||
<td colSpan={5} className="py-2 px-3">
|
||||
<div className="space-y-1.5">
|
||||
{emp.pendingItems.map((item: any, idx: number) => (
|
||||
<div key={idx} className="flex items-center gap-2 px-3 py-2 bg-white rounded-md border text-xs">
|
||||
<FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<span className="font-medium">{item.title}</span>
|
||||
{SCENE_CONFIG[item.scene] && (
|
||||
<span className={`px-1.5 py-0.5 rounded ${SCENE_CONFIG[item.scene].color}`}>{SCENE_CONFIG[item.scene].label}</span>
|
||||
)}
|
||||
<span className={`px-1.5 py-0.5 rounded ${(SIGN_METHOD_CONFIG[item.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).color}`}>
|
||||
{(SIGN_METHOD_CONFIG[item.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).label}
|
||||
</span>
|
||||
<span className="text-gray-400">{new Date(item.createdAt).toLocaleDateString('zh-CN')}</span>
|
||||
{item.type === 'esign' && item.recordId && (
|
||||
<button className="text-primary hover:underline ml-auto" onClick={() => onDetail(item.recordId)}>
|
||||
查看详情
|
||||
</button>
|
||||
)}
|
||||
{item.type === 'contract' && (
|
||||
<span className="text-gray-400 ml-auto">纸质合同未登记签署日期</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 催办结果弹窗:二维码 + 链接 */}
|
||||
{remindData && remindEmpId && (
|
||||
<Modal open onClose={() => { setRemindEmpId(null); }} title="催办 — 发送给员工" size="sm">
|
||||
<div className="py-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="p-4 bg-white rounded-xl border-2 border-gray-100 shadow-sm">
|
||||
<QRCodeSVG value={remindData.url} size={200} level="M" />
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-gray-600 text-center">
|
||||
员工 <span className="font-medium">{remindData.employeeName}</span> 扫码后自动登录员工端签署页面
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-400">链接 24 小时内有效</p>
|
||||
<div className="mt-3 w-full">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-gray-50 rounded-lg">
|
||||
<span className="text-xs text-gray-500 flex-1 truncate">{remindData.url}</span>
|
||||
<button
|
||||
onClick={() => handleCopy(remindData.url)}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 flex-shrink-0"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user