init: AI HR Compliance Assistant
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,36 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { Home, FileText, Users, Calculator, UserX, Bot } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const tabs = [
|
||||
{ path: '/', label: '总览', icon: Home },
|
||||
{ path: '/roster', label: '花名册', icon: Users },
|
||||
{ path: '/money', label: '薪税', icon: Calculator },
|
||||
{ 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-400',
|
||||
)}
|
||||
>
|
||||
<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,86 @@
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Building2, AlertCircle, ChevronDown } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const tabs = [
|
||||
{ path: '/', label: '总览' },
|
||||
{ path: '/roster', label: '花名册' },
|
||||
{ path: '/money', 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)
|
||||
|
||||
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 === '/' && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-danger text-white text-xs rounded-full flex items-center justify-center hidden">
|
||||
0
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="flex items-center gap-1 px-2 py-1.5 rounded-md hover:bg-gray-100"
|
||||
>
|
||||
<span className="text-sm text-gray-700 hidden sm:inline">{user?.name || '用户'}</span>
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
|
||||
<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>
|
||||
</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-3 py-1.5 text-sm': size === 'sm',
|
||||
'px-4 py-2 text-sm': size === 'md',
|
||||
'px-6 py-3 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,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-16 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,43 @@
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function Modal({ open, onClose, title, children, className }: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
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="fixed inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className={clsx('relative bg-white rounded-lg shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto', className)}>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200">
|
||||
<h3 className="font-medium text-gray-900">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-5">{children}</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user