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:
@@ -1,11 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* 电子签署路由 — 管理端
|
||||||
|
*
|
||||||
|
* 完整签署流程:
|
||||||
|
* 1. HR 发起签署(校验组织电子签开关 + 自动渲染模板文件内容 + 创建证据链)
|
||||||
|
* 2. 员工在 portal 端查看待签文件 → 发送验证码 → 验证码确认签署
|
||||||
|
* 3. 签署完成 → 回写合同 + 追加证据链 + 生成签署确认文件
|
||||||
|
* 4. 易签宝回调(对接后启用):更新状态 + 回写PDF
|
||||||
|
*
|
||||||
|
* 证据链事件:发起签署 / 验证码发送 / 签署完成 / 取消签署
|
||||||
|
*/
|
||||||
import { Router, Response, NextFunction } from 'express'
|
import { Router, Response, NextFunction } from 'express'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import prisma from '../lib/prisma'
|
import prisma from '../lib/prisma'
|
||||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||||
|
import { auditLog } from '../middleware/auditLog'
|
||||||
|
import { createEvidence, appendEvidence } from '../services/evidence.service'
|
||||||
|
import { renderTemplate, getTemplateById } from '../services/template.service'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
router.use(authMiddleware)
|
router.use(authMiddleware)
|
||||||
|
|
||||||
|
/** 场景与组织电子签开关的映射 */
|
||||||
|
const SCENE_ORG_FLAG_MAP: Record<string, string | null> = {
|
||||||
|
CONTRACT: null, // 合同签署不需要额外开关(默认允许)
|
||||||
|
RESIGNATION: null, // 离职协议不需要额外开关
|
||||||
|
POLICY: 'esignPolicyEnabled',
|
||||||
|
PAYSLIP: 'esignPayslipEnabled',
|
||||||
|
ONBOARDING: 'esignOnboardingEnabled',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 场景与模板ID的映射(自动渲染文件内容) */
|
||||||
|
const SCENE_TEMPLATE_MAP: Record<string, string | null> = {
|
||||||
|
CONTRACT: 'tpl_fixed_term_contract',
|
||||||
|
RESIGNATION: 'tpl_termination_agreement',
|
||||||
|
POLICY: null,
|
||||||
|
PAYSLIP: null,
|
||||||
|
ONBOARDING: null,
|
||||||
|
}
|
||||||
|
|
||||||
const createSignSchema = z.object({
|
const createSignSchema = z.object({
|
||||||
contractId: z.string().optional(),
|
contractId: z.string().optional(),
|
||||||
employeeId: z.string().min(1),
|
employeeId: z.string().min(1),
|
||||||
@@ -13,9 +45,13 @@ const createSignSchema = z.object({
|
|||||||
documentContent: z.string().optional(),
|
documentContent: z.string().optional(),
|
||||||
remark: z.string().optional(),
|
remark: z.string().optional(),
|
||||||
scene: z.string().optional(),
|
scene: z.string().optional(),
|
||||||
|
templateId: z.string().optional(), // 可指定模板,不传则按 scene 自动匹配
|
||||||
|
templateVars: z.record(z.string()).optional(), // 模板变量
|
||||||
})
|
})
|
||||||
|
|
||||||
// 签署记录列表
|
/**
|
||||||
|
* 签署记录列表
|
||||||
|
*/
|
||||||
router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const status = req.query.status as string | undefined
|
const status = req.query.status as string | undefined
|
||||||
@@ -35,53 +71,126 @@ router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 发起签署
|
/**
|
||||||
// TODO: 对接易签宝 API — 此处为框架预留
|
* 发起签署
|
||||||
// 1. 调用易签宝创建签署流程
|
* - 校验组织电子签开关
|
||||||
// 2. 获取签署链接
|
* - 自动从模板渲染文件内容
|
||||||
// 3. 保存 flowId 和 signUrl
|
* - 创建 ESignRecord
|
||||||
|
* - 创建证据链(发起签署事件)
|
||||||
|
*/
|
||||||
router.post('/create', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
router.post('/create', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const data = createSignSchema.parse(req.body)
|
const data = createSignSchema.parse(req.body)
|
||||||
|
const scene = data.scene || 'CONTRACT'
|
||||||
|
|
||||||
// 预留:调用易签宝 API 创建签署流程
|
// 1. 校验组织电子签开关
|
||||||
// const esignResult = await esignApi.createFlow({
|
const orgFlag = SCENE_ORG_FLAG_MAP[scene]
|
||||||
// title: data.documentTitle,
|
if (orgFlag) {
|
||||||
// signerPhone: employee.phone,
|
const org = await prisma.organization.findUnique({
|
||||||
// signerName: employee.name,
|
where: { id: req.user!.orgId },
|
||||||
// content: data.documentContent,
|
select: { [orgFlag]: true, name: true } as any,
|
||||||
// })
|
})
|
||||||
// const flowId = esignResult.flowId
|
if (org && !(org as any)[orgFlag]) {
|
||||||
// const signUrl = esignResult.signUrl
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: 'ESIGN_DISABLED', message: `组织未开启${scene === 'POLICY' ? '规章制度' : scene === 'PAYSLIP' ? '工资条' : scene === 'ONBOARDING' ? '入职文件' : '该场景'}电子签功能,请在系统设置中开启` },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取员工信息
|
||||||
|
const employee = await prisma.employee.findFirst({
|
||||||
|
where: { id: data.employeeId, orgId: req.user!.orgId },
|
||||||
|
select: { id: true, name: true, phone: true, department: true, idCardNumber: true, position: true, monthlySalary: true, hireDate: true },
|
||||||
|
})
|
||||||
|
if (!employee) {
|
||||||
|
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 自动渲染文件内容(优先使用指定模板,否则按 scene 匹配)
|
||||||
|
let documentContent = data.documentContent || ''
|
||||||
|
if (!documentContent) {
|
||||||
|
const templateId = data.templateId || SCENE_TEMPLATE_MAP[scene]
|
||||||
|
if (templateId) {
|
||||||
|
const template = getTemplateById(templateId)
|
||||||
|
if (template) {
|
||||||
|
// 自动填充模板变量
|
||||||
|
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId }, select: { name: true } })
|
||||||
|
const vars: Record<string, string> = {
|
||||||
|
companyName: org?.name || '',
|
||||||
|
employeeName: employee.name || '',
|
||||||
|
idCard: employee.idCardNumber || '',
|
||||||
|
position: employee.position || '',
|
||||||
|
monthlySalary: String(employee.monthlySalary || ''),
|
||||||
|
...data.templateVars,
|
||||||
|
}
|
||||||
|
documentContent = renderTemplate(templateId, vars) || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 创建签署记录
|
||||||
const record = await prisma.eSignRecord.create({
|
const record = await prisma.eSignRecord.create({
|
||||||
data: {
|
data: {
|
||||||
orgId: req.user!.orgId,
|
orgId: req.user!.orgId,
|
||||||
contractId: data.contractId || null,
|
contractId: data.contractId || null,
|
||||||
employeeId: data.employeeId,
|
employeeId: data.employeeId,
|
||||||
scene: data.scene || 'CONTRACT',
|
scene,
|
||||||
documentTitle: data.documentTitle,
|
documentTitle: data.documentTitle,
|
||||||
documentContent: data.documentContent || null,
|
documentContent: documentContent || null,
|
||||||
// flowId: esignResult.flowId, // TODO: 易签宝对接后启用
|
|
||||||
// signUrl: esignResult.signUrl, // TODO: 易签宝对接后启用
|
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
initiatedBy: req.user!.id,
|
initiatedBy: req.user!.id,
|
||||||
createdBy: req.user!.id,
|
createdBy: req.user!.id,
|
||||||
remark: data.remark || null,
|
remark: data.remark || null,
|
||||||
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30天过期
|
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 5. 创建证据链 — 发起签署事件
|
||||||
|
await createEvidence({
|
||||||
|
orgId: req.user!.orgId,
|
||||||
|
category: 'CONTRACT_SIGN',
|
||||||
|
refId: record.id,
|
||||||
|
employeeId: data.employeeId,
|
||||||
|
events: [{
|
||||||
|
action: `发起电子签署:${data.documentTitle}`,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers['user-agent'] as string,
|
||||||
|
location: `场景:${scene},发起人:${req.user!.id}`,
|
||||||
|
}],
|
||||||
|
createdBy: req.user!.id,
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
|
await auditLog(req, 'ESIGN_CREATE', 'ESIGN_RECORD', record.id, { employeeId: data.employeeId, scene, documentTitle: data.documentTitle })
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: record,
|
data: record,
|
||||||
message: '签署记录已创建。对接易签宝API后,将自动生成签署链接并发送给员工。',
|
message: '签署记录已创建,员工可在员工端查看并签署',
|
||||||
})
|
})
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 查询签署状态
|
/**
|
||||||
// TODO: 对接易签宝 API — 查询流程状态并同步
|
* 签署记录详情
|
||||||
|
*/
|
||||||
|
router.get('/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const record = await prisma.eSignRecord.findFirst({
|
||||||
|
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||||
|
include: {
|
||||||
|
employee: { select: { id: true, name: true, department: true, phone: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
|
||||||
|
res.json({ success: true, data: record })
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询签署状态
|
||||||
|
*/
|
||||||
router.get('/:id/status', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
router.get('/:id/status', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const record = await prisma.eSignRecord.findFirst({
|
const record = await prisma.eSignRecord.findFirst({
|
||||||
@@ -89,22 +198,41 @@ router.get('/:id/status', async (req: AuthRequest, res: Response, next: NextFunc
|
|||||||
})
|
})
|
||||||
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
|
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
|
||||||
|
|
||||||
// 预留:调用易签宝 API 查询流程状态
|
// 检查是否已过期但状态仍为 PENDING
|
||||||
// const esignStatus = await esignApi.getFlowStatus(record.flowId)
|
if (record.status === 'PENDING' && record.expiredAt && record.expiredAt < new Date()) {
|
||||||
// if (esignStatus !== record.status) {
|
const updated = await prisma.eSignRecord.update({
|
||||||
// await prisma.eSignRecord.update({ where: { id: record.id }, data: { status: esignStatus } })
|
where: { id: record.id },
|
||||||
// }
|
data: { status: 'EXPIRED' },
|
||||||
|
})
|
||||||
|
return res.json({ success: true, data: updated })
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ success: true, data: record })
|
res.json({ success: true, data: record })
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 易签宝回调接口(无需认证)
|
/**
|
||||||
|
* 获取签署记录的证据链
|
||||||
|
*/
|
||||||
|
router.get('/:id/evidence', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const evidence = await prisma.evidenceChain.findMany({
|
||||||
|
where: { orgId: req.user!.orgId, refId: req.params.id },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})
|
||||||
|
res.json({ success: true, data: evidence })
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 易签宝回调接口(无需认证)
|
||||||
|
* 对接易签宝后启用:验证签名 + 更新状态 + 回写PDF + 追加证据链
|
||||||
|
*/
|
||||||
router.post('/callback', async (req, res: Response, next: NextFunction) => {
|
router.post('/callback', async (req, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const { flowId, status, signedPdfUrl, ...rest } = req.body
|
const { flowId, status, signedPdfUrl, ...rest } = req.body
|
||||||
|
|
||||||
// TODO: 验证易签宝回调签名
|
// TODO: 对接易签宝后验证回调签名
|
||||||
// if (!verifyEsignCallback(req.headers, req.body)) {
|
// if (!verifyEsignCallback(req.headers, req.body)) {
|
||||||
// return res.status(401).json({ success: false, error: { message: '无效回调' } })
|
// return res.status(401).json({ success: false, error: { message: '无效回调' } })
|
||||||
// }
|
// }
|
||||||
@@ -122,16 +250,26 @@ router.post('/callback', async (req, res: Response, next: NextFunction) => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果关联了合同,更新合同的签署方式和电子合同URL
|
// 签署完成 → 回写合同 + 追加证据链
|
||||||
if (record.contractId && status === 'COMPLETED') {
|
if (status === 'COMPLETED') {
|
||||||
await prisma.laborContract.update({
|
if (record.contractId) {
|
||||||
where: { id: record.contractId },
|
await prisma.laborContract.update({
|
||||||
data: {
|
where: { id: record.contractId },
|
||||||
signMethod: 'ELECTRONIC',
|
data: {
|
||||||
electronicContractUrl: signedPdfUrl || null,
|
signMethod: 'ELECTRONIC',
|
||||||
},
|
electronicContractUrl: signedPdfUrl || null,
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await appendEvidence(record.orgId, '', {
|
||||||
|
action: '易签宝回调:签署完成',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
location: `flowId: ${flowId},PDF: ${signedPdfUrl || '无'}`,
|
||||||
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await auditLog({} as any, 'ESIGN_CALLBACK', 'ESIGN_RECORD', record.id, { flowId, status })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,14 +277,43 @@ router.post('/callback', async (req, res: Response, next: NextFunction) => {
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 取消签署
|
/**
|
||||||
|
* 取消签署
|
||||||
|
* - 更新状态为 CANCELLED
|
||||||
|
* - 追加证据链
|
||||||
|
*/
|
||||||
router.post('/:id/cancel', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
router.post('/:id/cancel', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const record = await prisma.eSignRecord.update({
|
const record = await prisma.eSignRecord.findFirst({
|
||||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||||
|
})
|
||||||
|
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
|
||||||
|
if (record.status === 'COMPLETED') {
|
||||||
|
return res.status(400).json({ success: false, error: { message: '已完成的签署不可取消' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.eSignRecord.update({
|
||||||
|
where: { id: record.id },
|
||||||
data: { status: 'CANCELLED' },
|
data: { status: 'CANCELLED' },
|
||||||
})
|
})
|
||||||
res.json({ success: true, data: record })
|
|
||||||
|
// 追加证据链
|
||||||
|
const evidence = await prisma.evidenceChain.findFirst({
|
||||||
|
where: { orgId: req.user!.orgId, refId: record.id },
|
||||||
|
})
|
||||||
|
if (evidence) {
|
||||||
|
await appendEvidence(req.user!.orgId, evidence.id, {
|
||||||
|
action: '取消签署',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers['user-agent'] as string,
|
||||||
|
location: `操作人:${req.user!.id}`,
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditLog(req, 'ESIGN_CANCEL', 'ESIGN_RECORD', record.id, {})
|
||||||
|
|
||||||
|
res.json({ success: true, data: updated })
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { signAccessToken, verifyAccessToken } from '../lib/jwt'
|
|||||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||||
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema'
|
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema'
|
||||||
import { setCode, getCode, deleteCode, updateCode, checkRateLimit } from '../lib/codeStore'
|
import { setCode, getCode, deleteCode, updateCode, checkRateLimit } from '../lib/codeStore'
|
||||||
import { createEvidence } from '../services/evidence.service'
|
import { createEvidence, appendEvidence } from '../services/evidence.service'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -1029,6 +1029,12 @@ router.post('/leaves/:id/cancel', portalAuth, async (req: any, res, next) => {
|
|||||||
|
|
||||||
// ========== 员工端:电子签署 ==========
|
// ========== 员工端:电子签署 ==========
|
||||||
// 查看自己的签署记录列表
|
// 查看自己的签署记录列表
|
||||||
|
// ========== 员工端:电子签署(验证码确认 + 证据链) ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的电子签署列表
|
||||||
|
* - 自动将过期的 PENDING 记录标记为 EXPIRED
|
||||||
|
*/
|
||||||
router.get('/esign', portalAuth, async (req: any, res, next) => {
|
router.get('/esign', portalAuth, async (req: any, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id: employeeId, orgId } = req.employee
|
const { id: employeeId, orgId } = req.employee
|
||||||
@@ -1036,11 +1042,30 @@ router.get('/esign', portalAuth, async (req: any, res, next) => {
|
|||||||
where: { employeeId, orgId },
|
where: { employeeId, orgId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 自动处理过期:PENDING 且已超过 expiredAt 的记录标记为 EXPIRED
|
||||||
|
const now = new Date()
|
||||||
|
const expiredIds = records
|
||||||
|
.filter(r => r.status === 'PENDING' && r.expiredAt && r.expiredAt < now)
|
||||||
|
.map(r => r.id)
|
||||||
|
if (expiredIds.length > 0) {
|
||||||
|
await prisma.eSignRecord.updateMany({
|
||||||
|
where: { id: { in: expiredIds } },
|
||||||
|
data: { status: 'EXPIRED' },
|
||||||
|
})
|
||||||
|
expiredIds.forEach(id => {
|
||||||
|
const r = records.find(rec => rec.id === id)
|
||||||
|
if (r) r.status = 'EXPIRED'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ success: true, data: records })
|
res.json({ success: true, data: records })
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 查看签署详情
|
/**
|
||||||
|
* 查看签署详情(含文件内容)
|
||||||
|
*/
|
||||||
router.get('/esign/:id', portalAuth, async (req: any, res, next) => {
|
router.get('/esign/:id', portalAuth, async (req: any, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id: employeeId, orgId } = req.employee
|
const { id: employeeId, orgId } = req.employee
|
||||||
@@ -1048,37 +1073,179 @@ router.get('/esign/:id', portalAuth, async (req: any, res, next) => {
|
|||||||
where: { id: req.params.id, employeeId, orgId },
|
where: { id: req.params.id, employeeId, orgId },
|
||||||
})
|
})
|
||||||
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
|
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
|
||||||
|
|
||||||
|
// 检查过期
|
||||||
|
if (record.status === 'PENDING' && record.expiredAt && record.expiredAt < new Date()) {
|
||||||
|
const updated = await prisma.eSignRecord.update({
|
||||||
|
where: { id: record.id },
|
||||||
|
data: { status: 'EXPIRED' },
|
||||||
|
})
|
||||||
|
return res.json({ success: true, data: updated })
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ success: true, data: record })
|
res.json({ success: true, data: record })
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 员工签署操作(预留:对接易签宝后跳转到签署页面或提交签署结果)
|
/**
|
||||||
router.post('/esign/:id/sign', portalAuth, async (req: any, res, next) => {
|
* 发送签署验证码
|
||||||
|
* - 通过手机号发送6位验证码
|
||||||
|
* - 验证码5分钟有效,最多5次错误
|
||||||
|
*/
|
||||||
|
router.post('/esign/:id/send-code', portalAuth, async (req: any, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id: employeeId, orgId } = req.employee
|
const { id: employeeId, orgId } = req.employee
|
||||||
const record = await prisma.eSignRecord.findFirst({
|
const record = await prisma.eSignRecord.findFirst({
|
||||||
where: { id: req.params.id, employeeId, orgId, status: 'PENDING' },
|
where: { id: req.params.id, employeeId, orgId, status: 'PENDING' },
|
||||||
|
include: { employee: { select: { phone: true, name: true } } },
|
||||||
|
})
|
||||||
|
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已处理' } })
|
||||||
|
if (!record.employee.phone) {
|
||||||
|
return res.status(400).json({ success: false, error: { code: 'NO_PHONE', message: '未登记手机号,无法发送验证码' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限流:同一记录60秒内只能发一次
|
||||||
|
const rateLimitKey = `esign-rate-${record.id}`
|
||||||
|
const allowed = await checkRateLimit(rateLimitKey, 60 * 1000)
|
||||||
|
if (!allowed) {
|
||||||
|
return res.status(429).json({ success: false, error: { code: 'RATE_LIMIT', message: '请求过于频繁,请60秒后重试' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = Math.random().toString().slice(2, 8)
|
||||||
|
await setCode(`esign-${record.id}`, code)
|
||||||
|
|
||||||
|
// 追加证据链 — 验证码发送事件
|
||||||
|
const evidence = await prisma.evidenceChain.findFirst({
|
||||||
|
where: { orgId, refId: record.id, category: 'CONTRACT_SIGN' },
|
||||||
|
})
|
||||||
|
if (evidence) {
|
||||||
|
await appendEvidence(orgId, evidence.id, {
|
||||||
|
action: '发送签署验证码',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers['user-agent'] as string,
|
||||||
|
location: `手机号:${record.employee.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')}`,
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
code, // 开发阶段直接返回,生产环境通过短信发送
|
||||||
|
message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)',
|
||||||
|
phone: record.employee.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 员工签署操作(验证码确认 + 证据链 + 回写合同)
|
||||||
|
* - 校验验证码
|
||||||
|
* - 更新签署状态为 COMPLETED
|
||||||
|
* - 追加证据链(签署完成事件,含IP/UA/验证码/时间戳)
|
||||||
|
* - 回写合同 signMethod 和 electronicContractUrl
|
||||||
|
*/
|
||||||
|
router.post('/esign/:id/sign', portalAuth, async (req: any, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id: employeeId, orgId } = req.employee
|
||||||
|
const { verifyCode } = req.body || {}
|
||||||
|
|
||||||
|
const record = await prisma.eSignRecord.findFirst({
|
||||||
|
where: { id: req.params.id, employeeId, orgId, status: 'PENDING' },
|
||||||
|
include: { employee: { select: { name: true, phone: true } } },
|
||||||
})
|
})
|
||||||
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已处理' } })
|
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已处理' } })
|
||||||
|
|
||||||
// 预留:对接易签宝后,此处应跳转到易签宝签署页面或接收签署结果
|
// 检查过期
|
||||||
// 当前框架阶段:直接标记为已签署
|
if (record.expiredAt && record.expiredAt < new Date()) {
|
||||||
|
await prisma.eSignRecord.update({ where: { id: record.id }, data: { status: 'EXPIRED' } })
|
||||||
|
return res.status(400).json({ success: false, error: { code: 'EXPIRED', message: '签署链接已过期' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验验证码
|
||||||
|
if (!verifyCode) {
|
||||||
|
return res.status(400).json({ success: false, error: { code: 'NO_CODE', message: '请输入验证码' } })
|
||||||
|
}
|
||||||
|
const stored = await getCode(`esign-${record.id}`)
|
||||||
|
if (!stored) {
|
||||||
|
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
|
||||||
|
}
|
||||||
|
if (stored.failCount >= 5) {
|
||||||
|
await deleteCode(`esign-${record.id}`)
|
||||||
|
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
|
||||||
|
}
|
||||||
|
if (stored.code !== verifyCode) {
|
||||||
|
await updateCode(`esign-${record.id}`, { failCount: stored.failCount + 1 })
|
||||||
|
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount - 1}次机会)` } })
|
||||||
|
}
|
||||||
|
await deleteCode(`esign-${record.id}`)
|
||||||
|
|
||||||
|
// 签署完成
|
||||||
|
const userAgent = req.headers['user-agent'] || ''
|
||||||
|
const signEvidence = JSON.stringify({
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
verifyCode: true,
|
||||||
|
employeeName: record.employee.name,
|
||||||
|
phone: record.employee.phone,
|
||||||
|
})
|
||||||
|
|
||||||
const updated = await prisma.eSignRecord.update({
|
const updated = await prisma.eSignRecord.update({
|
||||||
where: { id: record.id },
|
where: { id: record.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'COMPLETED',
|
status: 'COMPLETED',
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
|
// 保存签署证据到 callbackData(作为签署凭证)
|
||||||
|
callbackData: { signEvidence, signedAt: new Date().toISOString() } as any,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果关联了合同,更新合同签署信息
|
// 回写合同
|
||||||
if (record.contractId) {
|
if (record.contractId) {
|
||||||
await prisma.laborContract.update({
|
await prisma.laborContract.update({
|
||||||
where: { id: record.contractId },
|
where: { id: record.contractId },
|
||||||
data: { signMethod: 'ELECTRONIC' },
|
data: {
|
||||||
|
signMethod: 'ELECTRONIC',
|
||||||
|
// 签署证据写入 attachmentName
|
||||||
|
attachmentName: `e-sign:${new Date().toISOString()}|evidence:${signEvidence}`,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 追加证据链 — 签署完成事件
|
||||||
|
const evidence = await prisma.evidenceChain.findFirst({
|
||||||
|
where: { orgId, refId: record.id, category: 'CONTRACT_SIGN' },
|
||||||
|
})
|
||||||
|
if (evidence) {
|
||||||
|
await appendEvidence(orgId, evidence.id, {
|
||||||
|
action: `员工签署完成:${record.documentTitle}`,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent,
|
||||||
|
smsCode: verifyCode,
|
||||||
|
location: `签署人:${record.employee.name},验证码已验证`,
|
||||||
|
}).catch(() => {})
|
||||||
|
} else {
|
||||||
|
// 证据链不存在时创建新的
|
||||||
|
await createEvidence({
|
||||||
|
orgId,
|
||||||
|
category: 'CONTRACT_SIGN',
|
||||||
|
refId: record.id,
|
||||||
|
employeeId,
|
||||||
|
events: [{
|
||||||
|
action: `员工签署完成:${record.documentTitle}`,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent,
|
||||||
|
smsCode: verifyCode,
|
||||||
|
location: `签署人:${record.employee.name},验证码已验证`,
|
||||||
|
}],
|
||||||
|
createdBy: employeeId,
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ success: true, data: updated, message: '签署成功' })
|
res.json({ success: true, data: updated, message: '签署成功' })
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -641,10 +641,14 @@ export const benefitApi = {
|
|||||||
export const esignApi = {
|
export const esignApi = {
|
||||||
list: (params?: { status?: string; scene?: string }) =>
|
list: (params?: { status?: string; scene?: string }) =>
|
||||||
get('/esign', { params: params || {} }).then(unwrap<any[]>()),
|
get('/esign', { params: params || {} }).then(unwrap<any[]>()),
|
||||||
create: (data: { contractId?: string; employeeId: string; documentTitle: string; documentContent?: string; remark?: string; scene?: string }) =>
|
create: (data: { contractId?: string; employeeId: string; documentTitle: string; documentContent?: string; remark?: string; scene?: string; templateId?: string; templateVars?: Record<string, string> }) =>
|
||||||
post('/esign/create', data),
|
post('/esign/create', data),
|
||||||
|
detail: (id: string) =>
|
||||||
|
get(`/esign/${id}`).then(unwrap<any>()),
|
||||||
status: (id: string) =>
|
status: (id: string) =>
|
||||||
get(`/esign/${id}/status`).then(unwrap<any>()),
|
get(`/esign/${id}/status`).then(unwrap<any>()),
|
||||||
|
evidence: (id: string) =>
|
||||||
|
get(`/esign/${id}/evidence`).then(unwrap<any[]>()),
|
||||||
cancel: (id: string) =>
|
cancel: (id: string) =>
|
||||||
post(`/esign/${id}/cancel`),
|
post(`/esign/${id}/cancel`),
|
||||||
}
|
}
|
||||||
@@ -1089,9 +1093,12 @@ export const portalApi = {
|
|||||||
/** 电子签署详情 */
|
/** 电子签署详情 */
|
||||||
esignDetail: (id: string) =>
|
esignDetail: (id: string) =>
|
||||||
portalGet(`/esign/${id}`).then(unwrap<any>()),
|
portalGet(`/esign/${id}`).then(unwrap<any>()),
|
||||||
/** 签署操作 */
|
/** 发送签署验证码 */
|
||||||
signEsign: (id: string) =>
|
esignSendCode: (id: string) =>
|
||||||
portalPost(`/esign/${id}/sign`).then(unwrap<any>()),
|
portalPost(`/esign/${id}/send-code`).then(unwrap<any>()),
|
||||||
|
/** 签署操作(需验证码) */
|
||||||
|
signEsign: (id: string, verifyCode: string) =>
|
||||||
|
portalPost(`/esign/${id}/sign`, { verifyCode }).then(unwrap<any>()),
|
||||||
/** 我的培训记录 */
|
/** 我的培训记录 */
|
||||||
myTraining: () =>
|
myTraining: () =>
|
||||||
portalGet('/training').then(unwrap<any[]>()),
|
portalGet('/training').then(unwrap<any[]>()),
|
||||||
|
|||||||
+211
-24
@@ -1,22 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* 电子签署管理页面
|
||||||
|
* - 发起签署(场景选择 + 模板自动渲染 + 组织开关校验)
|
||||||
|
* - 签署记录列表(状态/场景筛选)
|
||||||
|
* - 签署详情(含证据链查看)
|
||||||
|
* - 取消签署
|
||||||
|
*/
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
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 { esignApi, employeeApi } from '../lib/api-services'
|
||||||
import PageGuide from '../components/ui/PageGuide'
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
import Card from '../components/ui/Card'
|
import Card from '../components/ui/Card'
|
||||||
import Button from '../components/ui/Button'
|
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 { InlineAlert } from '../components/ui/InlineAlert'
|
||||||
import Modal from '../components/ui/Modal'
|
import Modal from '../components/ui/Modal'
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
|
||||||
PENDING: { label: '待签署', color: 'bg-yellow-50 text-yellow-700' },
|
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' },
|
SIGNING: { label: '签署中', color: 'bg-blue-50 text-blue-700', icon: <Clock className="w-3 h-3" /> },
|
||||||
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe' },
|
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe', icon: <CheckCircle2 className="w-3 h-3" /> },
|
||||||
REJECTED: { label: '已拒绝', color: 'bg-red-50 text-danger' },
|
REJECTED: { label: '已拒绝', color: 'bg-red-50 text-danger', icon: <XCircle className="w-3 h-3" /> },
|
||||||
EXPIRED: { label: '已过期', color: 'bg-gray-100 text-gray-500' },
|
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' },
|
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 }> = {
|
const SCENE_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
@@ -32,8 +39,10 @@ export default function ESign() {
|
|||||||
const [filterStatus, setFilterStatus] = useState('')
|
const [filterStatus, setFilterStatus] = useState('')
|
||||||
const [filterScene, setFilterScene] = useState('')
|
const [filterScene, setFilterScene] = useState('')
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [detailId, setDetailId] = useState<string | null>(null)
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
employeeId: '',
|
employeeId: '',
|
||||||
|
scene: 'CONTRACT',
|
||||||
documentTitle: '',
|
documentTitle: '',
|
||||||
remark: '',
|
remark: '',
|
||||||
})
|
})
|
||||||
@@ -54,15 +63,17 @@ export default function ESign() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const createMutation = useMutation({
|
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,
|
esignApi.create(data) as any,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
|
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
|
||||||
setShowCreate(false)
|
setShowCreate(false)
|
||||||
setFormData({ employeeId: '', documentTitle: '', remark: '' })
|
setFormData({ employeeId: '', scene: 'CONTRACT', documentTitle: '', remark: '' })
|
||||||
toast.success('签署记录已创建,待对接易签宝后将自动发送签署链接')
|
toast.success('签署记录已创建,员工可在员工端查看并签署')
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error(err?.response?.data?.error?.message || '创建失败')
|
||||||
},
|
},
|
||||||
onError: () => toast.error('创建失败'),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const cancelMutation = useMutation({
|
const cancelMutation = useMutation({
|
||||||
@@ -87,27 +98,30 @@ export default function ESign() {
|
|||||||
createMutation.mutate(formData)
|
createMutation.mutate(formData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 签署详情视图 =====
|
||||||
|
if (detailId) {
|
||||||
|
return <ESignDetail id={detailId} onBack={() => setDetailId(null)} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<PageGuide>
|
<PageGuide>
|
||||||
通过易签宝平台发起电子合同签署,员工在线完成签署后自动回传签署状态和PDF文件。
|
通过电子签署模块发起合同、离职协议、规章制度等文件的在线签署。员工在员工端通过手机验证码确认签署,签署全流程自动记录证据链(IP、时间戳、验证码),可作为劳动仲裁举证材料。
|
||||||
<span className="text-amber-600"> 当前为框架预留阶段,对接易签宝API后将自动启用在线签署功能。</span>
|
|
||||||
</PageGuide>
|
</PageGuide>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<PenTool className="h-5 w-5 text-primary" />
|
<PenTool className="h-5 w-5 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-base font-semibold">电子签署</h1>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InlineAlert type="info" className="flex items-start gap-2">
|
<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>
|
<div>
|
||||||
<span className="font-medium">对接状态:框架已就绪,待接入易签宝API</span>
|
<span className="font-medium">签署流程</span>
|
||||||
<div className="mt-1 text-xs">
|
<div className="mt-1 text-xs">
|
||||||
需要提供易签宝的 AppId、AppSecret 和 API 基础地址。对接后可实现:
|
① HR在系统发起签署(自动渲染模板文件) → ② 员工在员工端查看待签文件 → ③ 员工获取手机验证码 → ④ 验证码确认签署 → ⑤ 自动记录证据链(IP/UA/时间戳/验证码) → ⑥ 合同自动回写签署方式
|
||||||
① HR在系统发起签署 → ② 调用易签宝创建签署流程 → ③ 员工收到签署链接 → ④ 签署完成自动回调更新状态 → ⑤ 合同自动关联电子版PDF
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</InlineAlert>
|
</InlineAlert>
|
||||||
@@ -164,7 +178,9 @@ export default function ESign() {
|
|||||||
<td className="py-2 px-3">
|
<td className="py-2 px-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
<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] && (
|
{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>
|
<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">{r.employee?.name || '—'}</td>
|
||||||
<td className="py-2 px-3 text-gray-500">{r.employee?.department || '—'}</td>
|
<td className="py-2 px-3 text-gray-500">{r.employee?.department || '—'}</td>
|
||||||
<td className="py-2 px-3">
|
<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>
|
||||||
<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">{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-gray-500 text-xs">{r.completedAt ? new Date(r.completedAt).toLocaleString('zh-CN') : '—'}</td>
|
||||||
<td className="py-2 px-3 text-right">
|
<td className="py-2 px-3 text-right">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<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 && (
|
{r.status === 'COMPLETED' && r.signedPdfUrl && (
|
||||||
<a href={r.signedPdfUrl} target="_blank" rel="noopener noreferrer"
|
<a href={r.signedPdfUrl} target="_blank" rel="noopener noreferrer"
|
||||||
className="text-xs text-primary hover:underline flex items-center gap-0.5">
|
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>
|
</a>
|
||||||
)}
|
)}
|
||||||
{(r.status === 'PENDING' || r.status === 'SIGNING') && (
|
{(r.status === 'PENDING' || r.status === 'SIGNING') && (
|
||||||
@@ -213,7 +234,7 @@ export default function ESign() {
|
|||||||
<Modal open={true} onClose={() => setShowCreate(false)} title="发起电子签署" size="md">
|
<Modal open={true} onClose={() => setShowCreate(false)} title="发起电子签署" size="md">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<InlineAlert type="info">
|
<InlineAlert type="info">
|
||||||
选择员工并填写文件标题,系统将创建签署记录。对接易签宝后,将自动生成签署链接并发送给员工。
|
选择员工和签署场景,系统将自动从模板渲染文件内容,创建签署记录并发起证据链。员工可在员工端查看文件并通过验证码签署。
|
||||||
</InlineAlert>
|
</InlineAlert>
|
||||||
<div>
|
<div>
|
||||||
<Label>签署员工 *</Label>
|
<Label>签署员工 *</Label>
|
||||||
@@ -228,6 +249,26 @@ export default function ESign() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<Label>文件标题 *</Label>
|
<Label>文件标题 *</Label>
|
||||||
<Input value={formData.documentTitle} onChange={(e) => setFormData({ ...formData, documentTitle: e.target.value })}
|
<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 })}
|
<Input value={formData.remark} onChange={(e) => setFormData({ ...formData, remark: e.target.value })}
|
||||||
placeholder="可选" />
|
placeholder="可选" />
|
||||||
</div>
|
</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">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button variant="secondary" size="sm" onClick={() => setShowCreate(false)}>取消</Button>
|
<Button variant="secondary" size="sm" onClick={() => setShowCreate(false)}>取消</Button>
|
||||||
<Button size="sm" onClick={handleCreate} disabled={createMutation.isPending}>
|
<Button size="sm" onClick={handleCreate} disabled={createMutation.isPending}>
|
||||||
@@ -250,3 +298,142 @@ export default function ESign() {
|
|||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
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 { portalApi } from '../../lib/api-services'
|
||||||
import Card from '../../components/ui/Card'
|
import Card from '../../components/ui/Card'
|
||||||
import Button from '../../components/ui/Button'
|
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'
|
import EmptyState from '../../components/ui/EmptyState'
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
|
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" /> },
|
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" /> },
|
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" /> },
|
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" /> },
|
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() {
|
export default function MyEsign() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
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[]>({
|
const { data: list = [], isLoading } = useQuery<any[]>({
|
||||||
queryKey: ['portal-esign'],
|
queryKey: ['portal-esign'],
|
||||||
@@ -41,25 +53,51 @@ export default function MyEsign() {
|
|||||||
enabled: !!selectedId,
|
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({
|
const signMutation = useMutation({
|
||||||
mutationFn: (id: string) => portalApi.signEsign(id),
|
mutationFn: ({ id, code }: { id: string; code: string }) => portalApi.signEsign(id, code),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('签署成功')
|
toast.success('签署成功')
|
||||||
queryClient.invalidateQueries({ queryKey: ['portal-esign'] })
|
queryClient.invalidateQueries({ queryKey: ['portal-esign'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['portal-esign-detail', selectedId] })
|
queryClient.invalidateQueries({ queryKey: ['portal-esign-detail', selectedId] })
|
||||||
|
setVerifyCode('')
|
||||||
},
|
},
|
||||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '签署失败'),
|
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
|
const pendingCount = list.filter((r: any) => r.status === 'PENDING').length
|
||||||
|
|
||||||
// 详情页
|
// ===== 详情页 =====
|
||||||
if (selectedId) {
|
if (selectedId) {
|
||||||
const st = detail ? STATUS_MAP[detail.status] || STATUS_MAP.PENDING : null
|
const st = detail ? STATUS_MAP[detail.status] || STATUS_MAP.PENDING : null
|
||||||
|
const canSign = detail?.status === 'PENDING'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<button
|
<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"
|
className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm py-2"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-4 h-4" />返回列表
|
<ChevronLeft className="w-4 h-4" />返回列表
|
||||||
@@ -74,69 +112,118 @@ export default function MyEsign() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
) : detail ? (
|
) : detail ? (
|
||||||
<Card className="p-5">
|
<>
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<Card className="p-5">
|
||||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<FileText className="w-5 h-5 text-primary" />
|
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||||
</div>
|
<FileText className="w-5 h-5 text-primary" />
|
||||||
<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>
|
||||||
|
<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>
|
</div>
|
||||||
{st && (
|
|
||||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs ${st.color}`}>
|
{detail.remark && (
|
||||||
{st.icon}{st.label}
|
<div className="mb-4 px-3 py-2 rounded-md bg-gray-50 text-xs text-gray-600">
|
||||||
</span>
|
{detail.remark}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{detail.remark && (
|
{detail.scene && SCENE_LABELS[detail.scene] && (
|
||||||
<div className="mb-4 px-3 py-2 rounded-md bg-gray-50 text-xs text-gray-600">
|
<div className="mb-3">
|
||||||
{detail.remark}
|
<span className={`px-2 py-0.5 rounded text-xs ${SCENE_LABELS[detail.scene].color}`}>{SCENE_LABELS[detail.scene].label}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 文件内容 */}
|
||||||
{detail.documentContent && (
|
{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">
|
<Card className="p-5">
|
||||||
{detail.documentContent}
|
<div className="text-sm font-medium mb-3 flex items-center gap-1.5">
|
||||||
</div>
|
<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.contractId && (
|
{detail.documentContent}
|
||||||
<div className="mb-4 text-xs text-blue-600 flex items-center gap-1">
|
</div>
|
||||||
<FileText className="w-3.5 h-3.5" />关联合同
|
</Card>
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 签署操作 */}
|
{/* 签署操作 */}
|
||||||
<div className="border-t pt-4">
|
<Card className="p-5">
|
||||||
{detail.status === 'PENDING' ? (
|
{canSign ? (
|
||||||
<Button
|
<div className="space-y-4">
|
||||||
className="w-full"
|
<InlineAlert type="info" className="flex items-start gap-2">
|
||||||
onClick={() => signMutation.mutate(detail.id)}
|
<Shield className="w-4 h-4 mt-0.5 shrink-0" />
|
||||||
disabled={signMutation.isPending}
|
<div className="text-xs">
|
||||||
>
|
<div className="font-medium">签署确认</div>
|
||||||
<PenTool className="w-4 h-4 mr-1" />
|
<div className="mt-1">请仔细阅读上述文件内容,确认无误后通过手机验证码完成签署。签署后将自动记录证据链(IP、时间戳、验证码),具有法律效力。</div>
|
||||||
{signMutation.isPending ? '签署中...' : '确认签署'}
|
</div>
|
||||||
</Button>
|
</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' ? (
|
) : detail.status === 'COMPLETED' ? (
|
||||||
<div className="text-center text-xs text-gray-500">
|
<div className="text-center space-y-2">
|
||||||
已于 {detail.completedAt ? new Date(detail.completedAt).toLocaleString('zh-CN') : '—'} 完成签署
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center text-xs text-gray-400">
|
<div className="text-center text-xs text-gray-400">
|
||||||
当前状态:{st?.label}
|
当前状态:{st?.label}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
</Card>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<EmptyState title="记录不存在" description="该签署记录可能已被删除" />
|
<EmptyState title="记录不存在" description="该签署记录可能已被删除" />
|
||||||
@@ -146,11 +233,11 @@ export default function MyEsign() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 列表页
|
// ===== 列表页 =====
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<h1 className="text-base font-bold">电子签署</h1>
|
||||||
{pendingCount > 0 && (
|
{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">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-amber-100 text-amber-700">
|
||||||
|
|||||||
Reference in New Issue
Block a user