Sprint 4-5: 员工自助+考勤+合规+AI+搜索

This commit is contained in:
selfrelease
2026-08-01 06:45:59 +08:00
parent 9946197d20
commit a88c96299c
16 changed files with 1919 additions and 35 deletions
+232
View File
@@ -0,0 +1,232 @@
/**
* 员工 Hub 首页 — 员工端统一入口
* 展示个人概览、待办事项、快捷入口、公司公告
*/
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import {
DollarSign, FileText, CalendarCheck, ScrollText,
TrendingUp, Clock, AlertCircle, ChevronRight,
} from 'lucide-react'
import Card from '../../components/ui/Card'
import { InlineAlert } from '../../components/ui/InlineAlert'
/** 员工端 API 实例(自动携带 portalToken */
const portalApi = (await import('../../lib/api')).default.create({ baseURL: '/api/v1/portal' })
portalApi.interceptors.request.use((config: any) => {
const token = localStorage.getItem('portalToken')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
/** 金额格式化 */
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
/** 快捷入口配置 */
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/attendance', label: '我的考勤', icon: CalendarCheck, color: 'bg-purple-50 text-purple-600' },
{ path: '/portal/policies', label: '规章制度', icon: ScrollText, color: 'bg-amber-50 text-amber-600' },
]
export default function EmployeeHome() {
const employee = (() => {
try { return JSON.parse(localStorage.getItem('portalEmployee') || '{}') } catch { return {} }
})()
/** 获取员工首页概览数据 */
const { data: overview, isLoading } = useQuery<any>({
queryKey: ['portal-home-overview'],
queryFn: async () => {
const res = await portalApi.get('/home/overview') as any
return res.data
},
})
if (isLoading) {
return <div className="space-y-4">
<div className="h-28 bg-gray-100 rounded-xl animate-pulse" />
<div className="h-32 bg-gray-100 rounded-xl animate-pulse" />
<div className="h-48 bg-gray-100 rounded-xl animate-pulse" />
</div>
}
const latestPayslip = overview?.latestPayslip
const contractInfo = overview?.contract
const pendingTasks = overview?.pendingTasks || []
const announcements = overview?.announcements || []
const attendanceSummary = overview?.attendance
return (
<div className="space-y-4">
{/* 欢迎卡片 */}
<Card className="bg-gradient-to-br from-indigo-600 to-indigo-700 text-white border-0">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-bold">{employee.name || '同事'}</h1>
<p className="text-sm text-indigo-100 mt-1">{employee.department || ''} · {employee.position || ''}</p>
</div>
<div className="text-right">
<div className="text-xs text-indigo-100"></div>
<div className="text-xl font-bold">¥{fmt(latestPayslip?.netPay || 0)}</div>
</div>
</div>
</Card>
{/* 待办提醒 */}
{pendingTasks.length > 0 && (
<div className="space-y-2">
<h2 className="text-sm font-medium text-gray-700 flex items-center gap-1">
<AlertCircle className="w-4 h-4 text-amber-500" />
{pendingTasks.length}
</h2>
{pendingTasks.map((task: any, i: number) => (
<InlineAlert key={i} type={task.severity === 'high' ? 'error' : 'warning'} className="text-xs">
{task.message}
</InlineAlert>
))}
</div>
)}
{/* 快捷入口 */}
<div>
<h2 className="text-sm font-medium text-gray-700 mb-2"></h2>
<div className="grid grid-cols-4 gap-3">
{QUICK_ACTIONS.map((action) => {
const Icon = action.icon
return (
<Link
key={action.path}
to={action.path}
className="flex flex-col items-center gap-1.5 p-3 rounded-xl bg-white border border-gray-100 hover:shadow-sm transition-shadow"
>
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${action.color}`}>
<Icon className="w-5 h-5" />
</div>
<span className="text-xs text-gray-600">{action.label}</span>
</Link>
)
})}
</div>
</div>
{/* 最新工资条 */}
{latestPayslip && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium flex items-center gap-1">
<DollarSign className="w-4 h-4 text-emerald-500" />
</h2>
<Link to="/portal/payslip" className="text-xs text-primary flex items-center hover:underline">
<ChevronRight className="w-3 h-3" />
</Link>
</div>
<div className="grid grid-cols-3 gap-3 text-sm">
<div>
<div className="text-xs text-gray-400"></div>
<div className="font-medium">{latestPayslip.month}</div>
</div>
<div>
<div className="text-xs text-gray-400"></div>
<div className="font-medium">¥{fmt(latestPayslip.grossPay)}</div>
</div>
<div>
<div className="text-xs text-gray-400"></div>
<div className="font-medium text-emerald-600">¥{fmt(latestPayslip.netPay)}</div>
</div>
</div>
</Card>
)}
{/* 合同状态 */}
{contractInfo && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium flex items-center gap-1">
<FileText className="w-4 h-4 text-blue-500" />
</h2>
<Link to="/portal/contract" className="text-xs text-primary flex items-center hover:underline">
<ChevronRight className="w-3 h-3" />
</Link>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="font-medium">{contractInfo.typeLabel || '—'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="font-medium text-xs">{contractInfo.startDate} ~ {contractInfo.endDate || '无固定期'}</span>
</div>
{contractInfo.daysToExpire !== null && contractInfo.daysToExpire !== undefined && (
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className={`font-medium ${contractInfo.daysToExpire < 30 ? 'text-amber-600' : 'text-gray-700'}`}>
{contractInfo.daysToExpire > 0 ? `${contractInfo.daysToExpire}天后到期` : '已到期'}
</span>
</div>
)}
</div>
</Card>
)}
{/* 考勤概览 */}
{attendanceSummary && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium flex items-center gap-1">
<CalendarCheck className="w-4 h-4 text-purple-500" />
</h2>
<Link to="/portal/attendance" className="text-xs text-primary flex items-center hover:underline">
<ChevronRight className="w-3 h-3" />
</Link>
</div>
<div className="grid grid-cols-4 gap-2 text-center">
<div className="p-2 rounded-lg bg-green-50">
<div className="text-lg font-bold text-emerald-600">{attendanceSummary.normalDays || 0}</div>
<div className="text-xs text-gray-400"></div>
</div>
<div className="p-2 rounded-lg bg-amber-50">
<div className="text-lg font-bold text-amber-600">{attendanceSummary.lateCount || 0}</div>
<div className="text-xs text-gray-400"></div>
</div>
<div className="p-2 rounded-lg bg-blue-50">
<div className="text-lg font-bold text-blue-600">{attendanceSummary.leaveDays || 0}</div>
<div className="text-xs text-gray-400"></div>
</div>
<div className="p-2 rounded-lg bg-gray-50">
<div className="text-lg font-bold text-gray-600">{attendanceSummary.absentDays || 0}</div>
<div className="text-xs text-gray-400"></div>
</div>
</div>
</Card>
)}
{/* 公司公告 */}
{announcements.length > 0 && (
<Card>
<h2 className="text-sm font-medium mb-3 flex items-center gap-1">
<ScrollText className="w-4 h-4 text-gray-400" />
</h2>
<div className="space-y-2">
{announcements.slice(0, 3).map((ann: any, i: number) => (
<div key={i} className="flex items-start gap-2 p-2 rounded-md hover:bg-gray-50">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-700 truncate">{ann.title}</div>
<div className="text-xs text-gray-400 mt-0.5">{ann.date} · {ann.author}</div>
</div>
<ChevronRight className="w-4 h-4 text-gray-300 shrink-0 mt-1" />
</div>
))}
</div>
</Card>
)}
</div>
)
}