优化: 大文件拆分+代码分割+按需加载+console清理+any类型替换

- Money.tsx (2260行→54行): 拆分为 money/ 子目录4个组件, React.lazy二级分割
- AIAssistant.tsx (2038行→63行): 拆分为 ai-assistant/ 子目录6个组件, React.lazy二级分割
- xlsx改为动态导入, OvertimeTab从345KB降至12.7KB
- api-services.ts: 请求参数 any→Record<string,unknown>
- 移除前端3处console.log残留
- 后端console替换为pino logger
- 前后端未使用import/变量清理
- Zod schema验证: termination/platform/special-status/work-process
- 新增 leave.routes.ts, acceptance-test.routes.ts
- UI组件: PageGuide, QueryError, Stepper
This commit is contained in:
freedakgmail
2026-08-04 07:53:37 +08:00
parent 1da385cd5d
commit 2968484d2d
109 changed files with 8950 additions and 5926 deletions
+259
View File
@@ -0,0 +1,259 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { CalendarClock, Check, X, Trash2, Clock, CheckCircle2, XCircle, RotateCcw, FileText, Smartphone } from 'lucide-react'
import { leaveApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
const LEAVE_TYPE_MAP: Record<string, string> = {
SICK: '病假',
PERSONAL: '事假',
ANNUAL: '年假',
MATERNITY: '产假',
OTHER: '其他',
}
const STATUS_MAP: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
PENDING: { label: '待审批', color: 'bg-amber-50 text-warning', icon: <Clock className="w-3 h-3" /> },
APPROVED: { label: '已批准', color: 'bg-green-50 text-safe', icon: <CheckCircle2 className="w-3 h-3" /> },
REJECTED: { label: '已驳回', color: 'bg-red-50 text-danger', icon: <XCircle className="w-3 h-3" /> },
CANCELLED: { label: '已撤回', color: 'bg-gray-100 text-gray-500', icon: <RotateCcw className="w-3 h-3" /> },
}
const fmtDate = (d: string) => new Date(d).toLocaleDateString('zh-CN')
export default function LeaveApproval() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [filterStatus, setFilterStatus] = useState('')
const [filterType, setFilterType] = useState('')
const [approveModal, setApproveModal] = useState<{ id: string; action: string; name: string } | null>(null)
const [approveRemark, setApproveRemark] = useState('')
const { data: stats } = useQuery<any>({
queryKey: ['leave-stats'],
queryFn: () => leaveApi.stats(),
})
const { data: result, isLoading } = useQuery<any>({
queryKey: ['leave-requests', filterStatus, filterType, page, pageSize],
queryFn: () => leaveApi.list({ status: filterStatus || undefined, leaveType: filterType || undefined, page, pageSize }),
})
const list = result?.list || []
const total = result?.total || 0
const approveMutation = useMutation({
mutationFn: ({ id, action, remark }: { id: string; action: string; remark?: string }) =>
leaveApi.approve(id, action, remark),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['leave-requests'] })
queryClient.invalidateQueries({ queryKey: ['leave-stats'] })
toast.success('审批完成')
setApproveModal(null)
setApproveRemark('')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '审批失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => leaveApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['leave-requests'] })
queryClient.invalidateQueries({ queryKey: ['leave-stats'] })
toast.success('已删除')
},
})
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<CalendarClock className="w-5 h-5 text-primary" />
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500"> </p>
</div>
</div>
{/* 流程说明 */}
<div className="bg-blue-50 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1">
<Smartphone className="w-4 h-4 text-blue-600 flex-shrink-0" />
<span className="text-xs font-medium text-blue-700"></span>
</div>
<div className="text-xs text-blue-600 leading-relaxed">
<br/>
<br/>
</div>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<Card className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-blue-50 flex items-center justify-center"><FileText className="w-4 h-4 text-blue-600" /></div>
<div><div className="text-sm font-bold">{stats?.total || 0}</div><div className="text-xs text-gray-500"></div></div>
</Card>
<Card className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-amber-50 flex items-center justify-center"><Clock className="w-4 h-4 text-amber-600" /></div>
<div><div className="text-sm font-bold text-warning">{stats?.pending || 0}</div><div className="text-xs text-gray-500"></div></div>
</Card>
<Card className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-green-50 flex items-center justify-center"><CheckCircle2 className="w-4 h-4 text-green-600" /></div>
<div><div className="text-sm font-bold text-safe">{stats?.approved || 0}</div><div className="text-xs text-gray-500"></div></div>
</Card>
<Card className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-red-50 flex items-center justify-center"><XCircle className="w-4 h-4 text-red-600" /></div>
<div><div className="text-sm font-bold text-danger">{stats?.rejected || 0}</div><div className="text-xs text-gray-500"></div></div>
</Card>
<Card className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-gray-100 flex items-center justify-center"><RotateCcw className="w-4 h-4 text-gray-500" /></div>
<div><div className="text-sm font-bold text-gray-500">{stats?.cancelled || 0}</div><div className="text-xs text-gray-500"></div></div>
</Card>
</div>
{/* 筛选 + 操作 */}
<div className="flex items-center gap-2 flex-wrap">
<Select value={filterStatus} onChange={(e) => { setFilterStatus(e.target.value); setPage(1) }} className="!w-28">
<option value=""></option>
<option value="PENDING"></option>
<option value="APPROVED"></option>
<option value="REJECTED"></option>
<option value="CANCELLED"></option>
</Select>
<Select value={filterType} onChange={(e) => { setFilterType(e.target.value); setPage(1) }} className="!w-28">
<option value=""></option>
{Object.entries(LEAVE_TYPE_MAP).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select>
{total > 0 && <span className="text-xs text-gray-500">{total} </span>}
<div className="flex-1" />
</div>
{/* 列表 */}
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : list.length === 0 ? (
<EmptyState title="暂无休假申请" description="员工在手机端提交的休假申请将显示在此处" />
) : (
<>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-gray-500">
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3 text-right"></th>
</tr>
</thead>
<tbody>
{list.map((item: any) => {
const st = STATUS_MAP[item.status] || STATUS_MAP.PENDING
return (
<tr key={item.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2.5 px-3">
<div className="text-xs font-medium">{item.employee?.name}</div>
<div className="text-xs text-gray-500">{item.employee?.department}</div>
</td>
<td className="py-2.5 px-3 text-xs">{LEAVE_TYPE_MAP[item.leaveType] || item.leaveType}</td>
<td className="py-2.5 px-3 text-xs text-gray-600">
{fmtDate(item.startDate)} ~ {fmtDate(item.endDate)}
</td>
<td className="py-2.5 px-3 text-right text-sm font-medium">{item.days}</td>
<td className="py-2.5 px-3 text-xs text-gray-600 max-w-32 truncate" title={item.reason || ''}>{item.reason || '—'}</td>
<td className="py-2.5 px-3">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs ${st.color}`}>
{st.icon}{st.label}
</span>
</td>
<td className="py-2.5 px-3 text-xs text-gray-500 max-w-32 truncate" title={item.approveRemark || ''}>{item.approveRemark || '—'}</td>
<td className="py-2.5 px-3">
<div className="flex items-center justify-end gap-1">
{item.status === 'PENDING' && (
<>
<button
onClick={() => setApproveModal({ id: item.id, action: 'APPROVED', name: item.employee?.name })}
className="p-1 rounded hover:bg-green-50 text-gray-500 hover:text-safe transition-colors"
title="批准"
>
<Check className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setApproveModal({ id: item.id, action: 'REJECTED', name: item.employee?.name })}
className="p-1 rounded hover:bg-red-50 text-gray-500 hover:text-danger transition-colors"
title="驳回"
>
<X className="w-3.5 h-3.5" />
</button>
<button
onClick={async () => {
if (await confirm({ title: '删除申请', message: '确认删除该休假申请?此操作不可撤销。' })) {
deleteMutation.mutate(item.id)
}
}}
className="p-1 rounded hover:bg-red-50 text-gray-500 hover:text-danger transition-colors"
title="删除"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
</>
)}
{/* 审批弹窗 */}
{approveModal && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setApproveModal(null)}>
<Card className="max-w-md w-full" >
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">
{approveModal.action === 'APPROVED' ? '批准休假申请' : '驳回休假申请'} {approveModal.name}
</h3>
<button onClick={() => setApproveModal(null)} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
</div>
<div className="space-y-3">
<div>
<Label></Label>
<Input value={approveRemark} onChange={(e) => setApproveRemark(e.target.value)} placeholder="请输入审批意见(可选)" />
</div>
<div className="flex gap-2">
<Button
variant={approveModal.action === 'APPROVED' ? 'primary' : 'danger'}
onClick={() => approveMutation.mutate({ id: approveModal.id, action: approveModal.action, remark: approveRemark })}
disabled={approveMutation.isPending}
>
{approveMutation.isPending ? '处理中...' : approveModal.action === 'APPROVED' ? '确认批准' : '确认驳回'}
</Button>
<Button variant="secondary" onClick={() => setApproveModal(null)}></Button>
</div>
</div>
</div>
</Card>
</div>
)}
</div>
)
}