feat: AIHR 智能人力资源管理系统初始提交

- 员工花名册管理(加密存储、导入导出)
- 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条)
- 社保公积金(多城市配置、版本管理、基数调整)
- 解聘管理(6步流程、证据链、工作交接)
- AI 助手(合同审查、风险预测、RAG 知识库)
- Dashboard 仪表盘
- 设置与通知
This commit is contained in:
selfrelease
2026-07-24 13:53:11 +08:00
commit 0df8aa77d9
109 changed files with 38190 additions and 0 deletions
@@ -0,0 +1,84 @@
import { useState, useEffect } from 'react'
import { X, ArrowRight } from 'lucide-react'
const STORAGE_KEY = 'hr-onboarding-completed'
const steps = [
{
icon: '🏠',
title: '这里看风险',
description: '首页展示企业用工风险总览,红色代表高风险项,点击「去处理」直接跳转操作。',
},
{
icon: '',
title: '这里管花名册',
description: '花名册页面管理员工档案、劳动合同、附件,以及违纪、考勤、培训、绩效记录,可生成仲裁证据链。',
},
{
icon: '💰',
title: '这里算薪税',
description: '薪税页面提供加班费、双倍工资、社保公积金计算器和工资条管理,输入参数实时计算。',
},
]
export default function OnboardingGuide() {
const [visible, setVisible] = useState(false)
const [step, setStep] = useState(0)
useEffect(() => {
const completed = localStorage.getItem(STORAGE_KEY)
if (!completed) {
setVisible(true)
}
}, [])
const close = () => {
localStorage.setItem(STORAGE_KEY, '1')
setVisible(false)
}
if (!visible) return null
const current = steps[step]
const isLast = step === steps.length - 1
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl max-w-sm w-full mx-4 overflow-hidden">
<div className="flex justify-end p-2">
<button onClick={close} className="text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 pb-6">
<div className="text-5xl text-center mb-4">{current.icon}</div>
<h2 className="text-lg font-semibold text-center mb-2">{current.title}</h2>
<p className="text-sm text-gray-600 text-center mb-6">{current.description}</p>
{/* 进度指示器 */}
<div className="flex justify-center gap-1.5 mb-6">
{steps.map((_, i) => (
<div
key={i}
className={`h-1.5 rounded-full transition-all ${i === step ? 'w-6 bg-primary' : 'w-1.5 bg-gray-300'}`}
/>
))}
</div>
<div className="flex justify-between">
{step > 0 ? (
<button onClick={() => setStep(step - 1)} className="text-sm text-gray-500"></button>
) : <span />}
<button
onClick={() => isLast ? close() : setStep(step + 1)}
className="flex items-center gap-1 text-sm font-medium text-primary"
>
{isLast ? '开始使用' : '下一步'}
{!isLast && <ArrowRight className="w-4 h-4" />}
</button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,37 @@
import { Link, useLocation } from 'react-router-dom'
import { Home, Users, Calculator, UserX, Bot, Shield } from 'lucide-react'
import clsx from 'clsx'
const tabs = [
{ path: '/', label: '总览', icon: Home },
{ path: '/roster', label: '员工', icon: Users },
{ path: '/money', label: '薪税', icon: Calculator },
{ path: '/social', label: '社保', icon: Shield },
{ path: '/termination', label: '解聘', icon: UserX },
{ path: '/ai-assistant', label: 'AI', icon: Bot },
]
export default function MobileTabBar() {
const location = useLocation()
return (
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 flex justify-around items-center h-14 z-50">
{tabs.map((tab) => {
const Icon = tab.icon
const active = location.pathname === tab.path
return (
<Link
key={tab.path}
to={tab.path}
className={clsx(
'flex flex-col items-center justify-center gap-0.5 flex-1 h-full',
active ? 'text-primary' : 'text-gray-500',
)}
>
<Icon className="w-5 h-5" />
<span className="text-xs">{tab.label}</span>
</Link>
)
})}
</nav>
)
}
@@ -0,0 +1,10 @@
import { ReactNode } from 'react'
import clsx from 'clsx'
export default function PageContainer({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={clsx('max-w-content mx-auto px-4', className)}>
{children}
</div>
)
}
+115
View File
@@ -0,0 +1,115 @@
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { Building2, ChevronDown, Settings as SettingsIcon, Bell } from 'lucide-react'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../../store/authStore'
import api from '../../lib/api'
import clsx from 'clsx'
const tabs = [
{ path: '/', label: '总览' },
{ path: '/roster', label: '员工管理' },
{ path: '/money', label: '薪税' },
{ path: '/social', label: '社保公积金' },
{ path: '/termination', label: '解聘补偿' },
{ path: '/ai-assistant', label: 'AI顾问' },
]
export default function TopNav() {
const location = useLocation()
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const [menuOpen, setMenuOpen] = useState(false)
const { data: dashboardData } = useQuery<any>({
queryKey: ['dashboard'],
queryFn: async () => {
const res = await api.get('/dashboard') as any
return res.data
},
refetchInterval: 60000,
})
const riskCount = dashboardData?.riskSummary?.pending || 0
return (
<header className="sticky top-0 z-50 bg-white border-b border-gray-200">
<div className="max-w-content mx-auto px-4 h-14 flex items-center gap-4">
<Link to="/" className="flex items-center gap-2 font-bold text-gray-900 shrink-0">
<Building2 className="w-5 h-5 text-primary" />
<span className="hidden sm:inline"></span>
</Link>
<nav className="hidden md:flex items-center gap-1 flex-1">
{tabs.map((tab) => (
<Link
key={tab.path}
to={tab.path}
className={clsx(
'px-3 py-1.5 rounded-md text-sm font-medium transition-colors relative',
location.pathname === tab.path
? 'bg-primary/10 text-primary'
: 'text-gray-600 hover:bg-gray-100',
)}
>
{tab.label}
{tab.path === '/' && riskCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-4 h-4 px-1 bg-danger text-white text-xs rounded-full flex items-center justify-center">
{riskCount > 99 ? '99+' : riskCount}
</span>
)}
</Link>
))}
</nav>
<div className="flex items-center gap-2 shrink-0">
<Link to="/settings" className="p-1.5 rounded-md hover:bg-gray-100" aria-label="设置">
<SettingsIcon className="w-4 h-4 text-gray-600" />
</Link>
<button className="relative p-1.5 rounded-md hover:bg-gray-100" aria-label="通知">
<Bell className="w-4 h-4 text-gray-600" />
{riskCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-4 h-4 px-1 bg-danger text-white text-xs rounded-full flex items-center justify-center">
{riskCount > 99 ? '99+' : riskCount}
</span>
)}
</button>
<div className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
className="flex items-center gap-1 px-2 py-1.5 rounded-md hover:bg-gray-100"
aria-expanded={menuOpen}
aria-haspopup="menu"
aria-label="用户菜单"
>
<span className="text-sm text-gray-700 hidden sm:inline">{user?.name || '用户'}</span>
<ChevronDown className="w-4 h-4 text-gray-500" />
</button>
{menuOpen && (
<>
<button className="fixed inset-0 z-10 cursor-default" onClick={() => setMenuOpen(false)} aria-label="关闭菜单" />
<div className="absolute right-0 mt-1 w-40 bg-white rounded-md shadow-lg border border-gray-200 z-20">
<Link
to="/settings"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setMenuOpen(false)}
>
</Link>
<button
onClick={() => {
logout()
navigate('/login')
}}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
退
</button>
</div>
</>
)}
</div>
</div>
</div>
</header>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { ButtonHTMLAttributes } from 'react'
import clsx from 'clsx'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger'
size?: 'sm' | 'md' | 'lg'
}
export default function Button({ variant = 'primary', size = 'md', className, children, ...props }: ButtonProps) {
return (
<button
className={clsx(
'inline-flex items-center justify-center font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
{
'bg-primary text-white hover:bg-primary-dark': variant === 'primary',
'bg-gray-100 text-gray-700 hover:bg-gray-200': variant === 'secondary',
'bg-danger text-white hover:bg-red-700': variant === 'danger',
'px-2.5 py-1 text-xs': size === 'sm',
'px-4 py-2 text-sm': size === 'md',
'px-5 py-2.5 text-base': size === 'lg',
},
className,
)}
{...props}
>
{children}
</button>
)
}
+10
View File
@@ -0,0 +1,10 @@
import { HTMLAttributes } from 'react'
import clsx from 'clsx'
export default function Card({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div className={clsx('bg-white rounded-lg shadow-sm border border-gray-200 p-4', className)} {...props}>
{children}
</div>
)
}
@@ -0,0 +1,38 @@
import Modal from './Modal'
import Button from './Button'
interface ConfirmDialogProps {
open: boolean
title: string
message: string
confirmLabel?: string
cancelLabel?: string
variant?: 'danger' | 'primary'
onConfirm: () => void
onCancel: () => void
}
/**
* 二次确认弹窗组件
* 用于危险操作(删除、归档、批量操作等)的二次确认
*/
export default function ConfirmDialog({
open,
title,
message,
confirmLabel = '确认',
cancelLabel = '取消',
variant = 'danger',
onConfirm,
onCancel,
}: ConfirmDialogProps) {
return (
<Modal open={open} onClose={onCancel} title={title} size="sm">
<p className="text-sm text-gray-600 mb-4">{message}</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onCancel}>{cancelLabel}</Button>
<Button variant={variant} onClick={onConfirm}>{confirmLabel}</Button>
</div>
</Modal>
)
}
+26
View File
@@ -0,0 +1,26 @@
import { ReactNode } from 'react'
import { Inbox } from 'lucide-react'
import Button from './Button'
interface EmptyStateProps {
icon?: ReactNode
title: string
description?: string
actionLabel?: string
onAction?: () => void
}
export default function EmptyState({ icon, title, description, actionLabel, onAction }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="text-gray-300 mb-4">
{icon || <Inbox className="w-12 h-12" />}
</div>
<h3 className="text-base font-medium text-gray-900 mb-1">{title}</h3>
{description && <p className="text-sm text-gray-500 mb-4">{description}</p>}
{actionLabel && onAction && (
<Button onClick={onAction}>{actionLabel}</Button>
)}
</div>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { InputHTMLAttributes, SelectHTMLAttributes, forwardRef } from 'react'
import clsx from 'clsx'
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
function Input({ className, ...props }, ref) {
return (
<input
ref={ref}
className={clsx(
'w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm',
className,
)}
{...props}
/>
)
}
)
export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSelectElement>>(
function Select({ className, children, ...props }, ref) {
return (
<select
ref={ref}
className={clsx(
'w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm bg-white',
className,
)}
{...props}
>
{children}
</select>
)
}
)
export function Label({ children, className }: { children: React.ReactNode; className?: string }) {
return <label className={clsx('block text-sm font-medium text-gray-700 mb-1', className)}>{children}</label>
}
+62
View File
@@ -0,0 +1,62 @@
import { ReactNode, useEffect, useState } from 'react'
import { X } from 'lucide-react'
import clsx from 'clsx'
interface ModalProps {
open: boolean
onClose: () => void
title?: string
children: ReactNode
className?: string
size?: 'sm' | 'md' | 'lg' | 'xl'
}
export default function Modal({ open, onClose, title, children, className, size = 'md' }: ModalProps) {
const [show, setShow] = useState(false)
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden'
requestAnimationFrame(() => setShow(true))
} else {
document.body.style.overflow = ''
setShow(false)
}
return () => {
document.body.style.overflow = ''
}
}, [open])
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className={clsx('fixed inset-0 bg-black/40 transition-opacity duration-200', show ? 'opacity-100' : 'opacity-0')}
onClick={onClose}
/>
<div
className={clsx(
'relative bg-white rounded-lg shadow-xl w-full max-h-[90vh] overflow-y-auto transition-all duration-200',
show ? 'opacity-100 scale-100' : 'opacity-0 scale-95',
{
'max-w-md': size === 'sm',
'max-w-lg': size === 'md',
'max-w-2xl': size === 'lg',
'max-w-4xl': size === 'xl',
},
className,
)}>
{title && (
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-200">
<h3 className="font-medium text-gray-900 text-sm">{title}</h3>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700" aria-label="关闭">
<X className="w-4 h-4" />
</button>
</div>
)}
<div className="p-4">{children}</div>
</div>
</div>
)
}
+95
View File
@@ -0,0 +1,95 @@
import clsx from 'clsx'
import { ChevronLeft, ChevronRight } from 'lucide-react'
interface PaginationProps {
page: number // 当前页(1-based
pageSize: number // 每页条数
total: number // 总条数
onPageChange: (page: number) => void
onPageSizeChange?: (size: number) => void
pageSizeOptions?: number[]
}
export default function Pagination({
page,
pageSize,
total,
onPageChange,
onPageSizeChange,
pageSizeOptions = [10, 20, 50],
}: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const start = total === 0 ? 0 : (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, total)
// 生成页码按钮(最多显示 7 个)
const pages: (number | '...')[] = []
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i)
} else {
pages.push(1)
if (page > 3) pages.push('...')
const s = Math.max(2, page - 1)
const e = Math.min(totalPages - 1, page + 1)
for (let i = s; i <= e; i++) pages.push(i)
if (page < totalPages - 2) pages.push('...')
pages.push(totalPages)
}
return (
<div className="flex items-center justify-between gap-4 py-2">
{/* 左侧:条数信息 + 每页条数选择 */}
<div className="flex items-center gap-3 text-sm text-gray-500">
<span> {total} </span>
{onPageSizeChange && (
<select
className="border rounded px-1.5 py-0.5 text-sm text-gray-600 focus:outline-none focus:border-primary"
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
>
{pageSizeOptions.map((n) => (
<option key={n} value={n}>{n} /</option>
))}
</select>
)}
<span> {start}-{end} </span>
</div>
{/* 右侧:页码导航 */}
<div className="flex items-center gap-1">
<button
className="p-1 rounded text-gray-500 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
>
<ChevronLeft className="w-4 h-4" />
</button>
{pages.map((p, i) =>
p === '...' ? (
<span key={`ellipsis-${i}`} className="px-2 text-gray-500 text-sm"></span>
) : (
<button
key={p}
className={clsx(
'min-w-[28px] h-7 rounded text-sm font-medium transition-colors',
p === page
? 'bg-primary text-white'
: 'text-gray-600 hover:bg-gray-100',
)}
onClick={() => onPageChange(p)}
>
{p}
</button>
),
)}
<button
className="p-1 rounded text-gray-500 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
import clsx from 'clsx'
type Level = 'high' | 'medium' | 'low' | 'safe'
const colors: Record<Level, string> = {
high: 'bg-danger',
medium: 'bg-warning',
low: 'bg-yellow-400',
safe: 'bg-safe',
}
const labels: Record<Level, string> = {
high: '🔴',
medium: '🟡',
low: '🟡',
safe: '🟢',
}
export default function Signal({ level, label }: { level: Level; label?: string }) {
return (
<span className="inline-flex items-center gap-1.5 text-sm">
<span className={clsx('w-2 h-2 rounded-full', colors[level])} />
{label && <span className="text-gray-700">{label}</span>}
{!label && <span>{labels[level]}</span>}
</span>
)
}
+61
View File
@@ -0,0 +1,61 @@
import clsx from 'clsx'
interface SkeletonProps {
className?: string
lines?: number
}
/**
* 骨架屏组件
* 用于数据加载时的占位显示,减少布局闪烁
*/
export function Skeleton({ className }: SkeletonProps) {
return <div className={clsx('animate-pulse rounded bg-gray-200', className)} />
}
/**
* 多行文本骨架屏
*/
export function SkeletonText({ lines = 3, className }: SkeletonProps) {
return (
<div className={clsx('space-y-2', className)}>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton key={i} className={clsx('h-4', i === lines - 1 && 'w-2/3')} />
))}
</div>
)
}
/**
* 卡片骨架屏
*/
export function SkeletonCard() {
return (
<div className="p-4 rounded-lg border border-gray-200 space-y-3">
<Skeleton className="h-5 w-1/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
)
}
/**
* 页面级骨架屏
*/
export function SkeletonPage() {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<div className="flex gap-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-20" />
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<SkeletonCard key={i} />
))}
</div>
</div>
)
}