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
@@ -0,0 +1,206 @@
/**
* 统一风险中心 — 汇总展示合同风险、薪酬风险、社保风险、合规风险
* 按风险等级分类,支持快速跳转处理
*/
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import {
ShieldAlert, AlertTriangle, Clock, Users, FileText,
TrendingDown, Calendar, ChevronRight, Filter,
} from 'lucide-react'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { InlineAlert } from '../../components/ui/InlineAlert'
import api from '../../lib/api'
/** 风险等级配置 */
const RISK_LEVELS: Record<string, { label: string; color: string; bg: string }> = {
HIGH: { label: '高风险', color: 'text-rose-600', bg: 'bg-rose-50 border-rose-200' },
MEDIUM: { label: '中风险', color: 'text-amber-600', bg: 'bg-amber-50 border-amber-200' },
LOW: { label: '低风险', color: 'text-blue-600', bg: 'bg-blue-50 border-blue-200' },
}
/** 风险类型配置 */
const RISK_TYPES: Record<string, { label: string; icon: typeof ShieldAlert; link: string }> = {
CONTRACT_EXPIRE: { label: '合同到期', icon: FileText, link: '/roster' },
CONTRACT_UNSIGNED: { label: '未签合同', icon: FileText, link: '/roster' },
PROBATION_EXPIRE: { label: '试用期到期', icon: Clock, link: '/roster' },
SALARY_BELOW_MIN: { label: '工资低于最低标准', icon: TrendingDown, link: '/money' },
SOCIAL_INSURANCE_GAP: { label: '社保断缴', icon: ShieldAlert, link: '/social' },
TERMINATION_RISK: { label: '离职风险', icon: Users, link: '/termination' },
POLICY_UNREAD: { label: '制度未阅读', icon: FileText, link: '/policies' },
}
export default function RiskCenter() {
const [filterLevel, setFilterLevel] = useState<string>('ALL')
const [filterType, setFilterType] = useState<string>('ALL')
/** 获取风险列表 */
const { data: risks = [], isLoading } = useQuery<any[]>({
queryKey: ['risk-center'],
queryFn: async () => {
const res = await api.get('/compliance/risks') as any
return res.data || []
},
})
/** 按级别统计 */
const stats = useMemo(() => {
const high = risks.filter((r: any) => r.level === 'HIGH').length
const medium = risks.filter((r: any) => r.level === 'MEDIUM').length
const low = risks.filter((r: any) => r.level === 'LOW').length
return { high, medium, low, total: risks.length }
}, [risks])
/** 按类型统计 */
const typeStats = useMemo(() => {
const map: Record<string, number> = {}
risks.forEach((r: any) => {
map[r.type] = (map[r.type] || 0) + 1
})
return map
}, [risks])
/** 筛选后的风险列表 */
const filteredRisks = useMemo(() => {
return risks.filter((r: any) => {
if (filterLevel !== 'ALL' && r.level !== filterLevel) return false
if (filterType !== 'ALL' && r.type !== filterType) return false
return true
})
}, [risks, filterLevel, filterType])
return (
<div className="space-y-4">
{/* 页头 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShieldAlert className="h-5 w-5 text-primary" />
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-0.5 text-sm text-gray-500"></p>
</div>
</div>
</div>
{/* 风险概览卡片 */}
<div className="grid grid-cols-4 gap-3">
<Card className="p-3">
<div className="text-xs text-gray-400"></div>
<div className="text-2xl font-bold text-gray-900 mt-1">{stats.total}</div>
</Card>
<Card className="p-3">
<div className="text-xs text-gray-400"></div>
<div className="text-2xl font-bold text-rose-600 mt-1">{stats.high}</div>
</Card>
<Card className="p-3">
<div className="text-xs text-gray-400"></div>
<div className="text-2xl font-bold text-amber-600 mt-1">{stats.medium}</div>
</Card>
<Card className="p-3">
<div className="text-xs text-gray-400"></div>
<div className="text-2xl font-bold text-blue-600 mt-1">{stats.low}</div>
</Card>
</div>
{/* 高风险告警 */}
{stats.high > 0 && (
<InlineAlert type="error">
{stats.high}
</InlineAlert>
)}
{/* 风险类型分布 */}
<Card>
<h2 className="text-sm font-medium mb-3"></h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{Object.entries(RISK_TYPES).map(([key, cfg]) => {
const count = typeStats[key] || 0
if (count === 0) return null
const Icon = cfg.icon
return (
<Link
key={key}
to={cfg.link}
className="flex items-center gap-2 p-2.5 rounded-lg border border-gray-100 hover:border-primary/30 hover:bg-primary/5 transition-colors"
>
<Icon className="w-4 h-4 text-gray-400" />
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-600 truncate">{cfg.label}</div>
<div className="text-sm font-bold text-gray-900">{count}</div>
</div>
<ChevronRight className="w-3 h-3 text-gray-300" />
</Link>
)
})}
{Object.values(typeStats).every((v) => v === 0) && (
<div className="col-span-full text-center py-4 text-sm text-gray-400"></div>
)}
</div>
</Card>
{/* 筛选器 */}
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1 text-sm text-gray-400">
<Filter className="w-4 h-4" />
</div>
<div className="flex gap-1">
<button
className={`px-3 py-1 rounded-md text-xs transition-colors ${filterLevel === 'ALL' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
onClick={() => setFilterLevel('ALL')}
></button>
{Object.entries(RISK_LEVELS).map(([key, cfg]) => (
<button
key={key}
className={`px-3 py-1 rounded-md text-xs transition-colors ${filterLevel === key ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
onClick={() => setFilterLevel(key)}
>{cfg.label}</button>
))}
</div>
</div>
{/* 风险列表 */}
<Card>
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : filteredRisks.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm">
{risks.length === 0 ? '暂无风险项,一切正常' : '当前筛选条件下无匹配项'}
</div>
) : (
<div className="space-y-2">
{filteredRisks.map((r: any, i: number) => {
const levelCfg = RISK_LEVELS[r.level] || RISK_LEVELS.LOW
const typeCfg = RISK_TYPES[r.type] || { label: r.type, icon: AlertTriangle, link: '/' }
const Icon = typeCfg.icon
return (
<Link
key={i}
to={typeCfg.link}
className={`flex items-start gap-3 p-3 rounded-lg border ${levelCfg.bg} hover:shadow-sm transition-shadow`}
>
<Icon className={`w-4 h-4 mt-0.5 shrink-0 ${levelCfg.color}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-xs font-medium ${levelCfg.color}`}>{levelCfg.label}</span>
<span className="text-xs text-gray-400">{typeCfg.label}</span>
</div>
<div className="text-sm text-gray-700 mt-0.5">{r.message}</div>
{r.employeeName && (
<div className="text-xs text-gray-400 mt-0.5">
{r.employeeName} · {r.department || ''}
</div>
)}
</div>
<ChevronRight className="w-4 h-4 text-gray-300 shrink-0 mt-1" />
</Link>
)
})}
</div>
)}
</Card>
</div>
)
}