feat: AIHR 智能人力资源管理系统初始提交
- 员工花名册管理(加密存储、导入导出) - 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条) - 社保公积金(多城市配置、版本管理、基数调整) - 解聘管理(6步流程、证据链、工作交接) - AI 助手(合同审查、风险预测、RAG 知识库) - Dashboard 仪表盘 - 设置与通知
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user