feat: 电子签署导航调整+员工手机端电子签署功能

- 管理端导航:电子签署移到首页分组(工作日历下)
- 员工端新增电子签署页面(MyEsign.tsx):
  - 查看自己的签署记录列表,支持待签/已签/已取消状态
  - 查看签署详情,含文件内容、关联合同标识
  - 确认签署操作(框架阶段,对接易签宝后跳转签署页面)
- 员工端导航(PortalNav)增加电子签署入口
- 员工首页(EmployeeHome)增加电子签署快捷入口
- 员工首页待办事项增加待签署文件提醒
- 后端portal路由新增3个接口:GET /esign、GET /esign/:id、POST /esign/:id/sign
This commit is contained in:
freedakgmail
2026-08-05 07:39:11 +08:00
parent b9240ffe9b
commit e8cd0f472b
7 changed files with 271 additions and 3 deletions
+64
View File
@@ -705,6 +705,14 @@ router.get('/home/overview', portalAuth, async (req: any, res, next) => {
if (unreadPolicies.length > 0) {
pendingTasks.push({ severity: 'medium', message: `您有 ${unreadPolicies.length} 份制度待阅读确认` })
}
// 待签署文件
const pendingEsign = await prisma.eSignRecord.findMany({
where: { employeeId, orgId, status: 'PENDING' },
select: { id: true, documentTitle: true },
})
if (pendingEsign.length > 0) {
pendingTasks.push({ severity: 'high', message: `您有 ${pendingEsign.length} 份文件待签署(${pendingEsign.map(e => e.documentTitle).join('、')}` })
}
res.json({
success: true,
@@ -903,4 +911,60 @@ router.post('/leaves/:id/cancel', portalAuth, async (req: any, res, next) => {
} catch (err) { next(err) }
})
// ========== 员工端:电子签署 ==========
// 查看自己的签署记录列表
router.get('/esign', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const records = await prisma.eSignRecord.findMany({
where: { employeeId, orgId },
orderBy: { createdAt: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
// 查看签署详情
router.get('/esign/:id', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const record = await prisma.eSignRecord.findFirst({
where: { id: req.params.id, employeeId, orgId },
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
// 员工签署操作(预留:对接易签宝后跳转到签署页面或提交签署结果)
router.post('/esign/:id/sign', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const record = await prisma.eSignRecord.findFirst({
where: { id: req.params.id, employeeId, orgId, status: 'PENDING' },
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已处理' } })
// 预留:对接易签宝后,此处应跳转到易签宝签署页面或接收签署结果
// 当前框架阶段:直接标记为已签署
const updated = await prisma.eSignRecord.update({
where: { id: record.id },
data: {
status: 'COMPLETED',
completedAt: new Date(),
},
})
// 如果关联了合同,更新合同签署信息
if (record.contractId) {
await prisma.laborContract.update({
where: { id: record.contractId },
data: { signMethod: 'ELECTRONIC' },
})
}
res.json({ success: true, data: updated, message: '签署成功' })
} catch (err) { next(err) }
})
export default router
+2
View File
@@ -50,6 +50,7 @@ const LeaveApproval = lazy(() => import('./pages/LeaveApproval'))
const EmployeeHome = lazy(() => import('./pages/portal/EmployeeHome'))
const OnboardingProgress = lazy(() => import('./pages/portal/OnboardingProgress'))
const ResignationApply = lazy(() => import('./pages/portal/ResignationApply'))
const MyEsign = lazy(() => import('./pages/portal/MyEsign'))
const RiskCenter = lazy(() => import('./pages/compliance/RiskCenter'))
const SalaryDashboard = lazy(() => import('./pages/SalaryDashboard'))
const CommercialInsurance = lazy(() => import('./pages/CommercialInsurance'))
@@ -224,6 +225,7 @@ export default function App() {
<Route path="/portal/home" element={<PortalLayoutWrapper><EmployeeHome /></PortalLayoutWrapper>} />
<Route path="/portal/onboarding-progress" element={<PortalLayoutWrapper><OnboardingProgress /></PortalLayoutWrapper>} />
<Route path="/portal/resignation" element={<PortalLayoutWrapper><ResignationApply /></PortalLayoutWrapper>} />
<Route path="/portal/esign" element={<PortalLayoutWrapper><MyEsign /></PortalLayoutWrapper>} />
{/* 兜底 */}
<Route path="*" element={<Navigate to="/" replace />} />
@@ -37,6 +37,7 @@ const navGroups: NavGroup[] = [
items: [
{ path: '/', label: '工作台', icon: LayoutDashboard },
{ path: '/calendar', label: '工作日历', icon: CalendarDays },
{ path: '/esign', label: '电子签署', icon: PenTool },
],
},
{
@@ -46,7 +47,6 @@ const navGroups: NavGroup[] = [
{ path: '/work-process', label: '用工办理', icon: ClipboardList },
{ path: '/termination', label: '离职管理', icon: UserX },
{ path: '/special-status', label: '特殊员工', icon: Heart },
{ path: '/esign', label: '电子签署', icon: PenTool },
],
},
{
+9
View File
@@ -1011,4 +1011,13 @@ export const portalApi = {
/** 撤回休假申请 */
cancelLeave: (id: string) =>
portalPost(`/leaves/${id}/cancel`).then(unwrap<any>()),
/** 我的电子签署列表 */
myEsignList: () =>
portalGet('/esign').then(unwrap<any[]>()),
/** 电子签署详情 */
esignDetail: (id: string) =>
portalGet(`/esign/${id}`).then(unwrap<any>()),
/** 签署操作 */
signEsign: (id: string) =>
portalPost(`/esign/${id}/sign`).then(unwrap<any>()),
}
+2 -1
View File
@@ -6,7 +6,7 @@ import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import {
DollarSign, FileText, CalendarCheck, ScrollText, CalendarClock,
AlertCircle, ChevronRight, UserX,
AlertCircle, ChevronRight, UserX, PenTool,
} from 'lucide-react'
import { portalApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
@@ -19,6 +19,7 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
const QUICK_ACTIONS = [
{ path: '/portal/payslip', label: '工资条', icon: DollarSign, color: 'bg-emerald-50 text-emerald-600' },
{ path: '/portal/contract', label: '我的合同', icon: FileText, color: 'bg-blue-50 text-blue-600' },
{ path: '/portal/esign', label: '电子签署', icon: PenTool, color: 'bg-violet-50 text-violet-600' },
{ path: '/portal/attendance', label: '我的考勤', icon: CalendarCheck, color: 'bg-purple-50 text-purple-600' },
{ path: '/portal/leave', label: '休假申请', icon: CalendarClock, color: 'bg-cyan-50 text-cyan-600' },
{ path: '/portal/policies', label: '规章制度', icon: ScrollText, color: 'bg-amber-50 text-amber-600' },
+191
View File
@@ -0,0 +1,191 @@
/**
* 员工端 — 电子签署页面
* 查看自己的待签/已签文件,进行签署操作
*/
import { useState } 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 { portalApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
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" /> },
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" /> },
}
export default function MyEsign() {
const queryClient = useQueryClient()
const [selectedId, setSelectedId] = useState<string | null>(null)
const { data: list = [], isLoading } = useQuery<any[]>({
queryKey: ['portal-esign'],
queryFn: () => portalApi.myEsignList(),
})
const { data: detail, isLoading: detailLoading } = useQuery<any>({
queryKey: ['portal-esign-detail', selectedId],
queryFn: () => portalApi.esignDetail(selectedId!),
enabled: !!selectedId,
})
const signMutation = useMutation({
mutationFn: (id: string) => portalApi.signEsign(id),
onSuccess: () => {
toast.success('签署成功')
queryClient.invalidateQueries({ queryKey: ['portal-esign'] })
queryClient.invalidateQueries({ queryKey: ['portal-esign-detail', selectedId] })
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '签署失败'),
})
const pendingCount = list.filter((r: any) => r.status === 'PENDING').length
// 详情页
if (selectedId) {
const st = detail ? STATUS_MAP[detail.status] || STATUS_MAP.PENDING : null
return (
<div className="space-y-4">
<button
onClick={() => setSelectedId(null)}
className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm py-2"
>
<ChevronLeft className="w-4 h-4" />
</button>
{detailLoading ? (
<Card className="p-6">
<div className="animate-pulse space-y-3">
<div className="h-6 bg-gray-100 rounded w-2/3" />
<div className="h-4 bg-gray-100 rounded w-1/3" />
<div className="h-20 bg-gray-100 rounded w-full" />
</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')}
</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>
{detail.remark && (
<div className="mb-4 px-3 py-2 rounded-md bg-gray-50 text-xs text-gray-600">
{detail.remark}
</div>
)}
{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>
)}
{/* 签署操作 */}
<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>
) : detail.status === 'COMPLETED' ? (
<div className="text-center text-xs text-gray-500">
{detail.completedAt ? new Date(detail.completedAt).toLocaleString('zh-CN') : '—'}
</div>
) : (
<div className="text-center text-xs text-gray-400">
{st?.label}
</div>
)}
</div>
</Card>
) : (
<Card className="p-6">
<EmptyState title="记录不存在" description="该签署记录可能已被删除" />
</Card>
)}
</div>
)
}
// 列表页
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<PenTool className="w-5 h-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">
<Clock className="w-3 h-3" />{pendingCount}
</span>
)}
</div>
{isLoading ? (
<div className="text-center py-12 text-gray-400 text-sm">...</div>
) : list.length === 0 ? (
<Card className="p-6">
<EmptyState title="暂无签署任务" description="没有需要您签署的文件" />
</Card>
) : (
<div className="space-y-2">
{list.map((r: any) => {
const st = STATUS_MAP[r.status] || STATUS_MAP.PENDING
return (
<Card key={r.id} className="cursor-pointer hover:shadow-md transition-shadow">
<div onClick={() => setSelectedId(r.id)} className="flex items-center gap-3 p-4">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${
r.status === 'PENDING' ? 'bg-amber-100' : r.status === 'COMPLETED' ? 'bg-green-100' : 'bg-gray-100'
}`}>
<FileText className={`w-5 h-5 ${
r.status === 'PENDING' ? 'text-amber-600' : r.status === 'COMPLETED' ? 'text-green-600' : 'text-gray-400'
}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium truncate min-w-0">{r.documentTitle}</span>
{r.contractId && (
<span className="px-1.5 py-0.5 rounded text-xs bg-blue-50 text-blue-600 border border-blue-200 flex-shrink-0"></span>
)}
</div>
<div className="text-xs text-gray-500 mt-1">
{new Date(r.createdAt).toLocaleDateString('zh-CN')}
</div>
</div>
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs flex-shrink-0 ${st.color}`}>
{st.icon}{st.label}
</span>
</div>
</Card>
)
})}
</div>
)}
</div>
)
}
+2 -1
View File
@@ -3,11 +3,12 @@
*/
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { DollarSign, FileText, ScrollText, LogOut } from 'lucide-react'
import { DollarSign, FileText, ScrollText, LogOut, PenTool } from 'lucide-react'
const navItems = [
{ path: '/portal/payslip', label: '工资条', icon: DollarSign },
{ path: '/portal/contract', label: '我的合同', icon: FileText },
{ path: '/portal/esign', label: '电子签署', icon: PenTool },
{ path: '/portal/policies', label: '规章制度', icon: ScrollText },
]