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