feat: Sprint 1 — 设计Token系统 + 新导航5分组 + AppShell/PageHeader/FilterBar/DataTable组件 + 花名册导出扩充19字段 + 社保/公积金不缴纳选项 + 个税申报表导出 + 入离职/绩效统计看板
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 应用外壳组件 — 统一页面布局结构
|
||||
* 包含 PageHeader 区域 + 内容区域,配合 SidebarNav 和 TopNav 使用
|
||||
*/
|
||||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用外壳 — 限制内容最大宽度,统一内边距
|
||||
*/
|
||||
export function AppShell({ children, className }: AppShellProps) {
|
||||
return (
|
||||
<div className={clsx('w-full max-w-content mx-auto px-4 md:px-6', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
status?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面头部 — 统一标题、说明、状态和操作按钮区域
|
||||
*/
|
||||
export function PageHeader({ title, description, actions, status, className }: PageHeaderProps) {
|
||||
return (
|
||||
<div className={clsx('flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-4', className)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-semibold text-ink-900 truncate">{title}</h1>
|
||||
{status}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-ink-600 mt-0.5">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { Home, Users, Calculator, UserX, Bot, Shield } from 'lucide-react'
|
||||
import { Home, Users, Calculator, CalendarCheck, 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 },
|
||||
{ path: '/', label: '首页', icon: Home },
|
||||
{ path: '/roster', label: '团队', icon: Users },
|
||||
{ path: '/money', label: '薪酬', icon: Calculator },
|
||||
{ path: '/attendance', label: '时间', icon: CalendarCheck },
|
||||
{ path: '/evidence', label: '合规', icon: Shield },
|
||||
]
|
||||
|
||||
export default function MobileTabBar() {
|
||||
|
||||
@@ -30,31 +30,36 @@ interface NavGroup {
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
title: '工作台',
|
||||
title: '首页',
|
||||
items: [
|
||||
{ path: '/', label: '总览', icon: LayoutDashboard },
|
||||
{ path: '/', label: '工作台', icon: LayoutDashboard },
|
||||
{ path: '/calendar', label: '工作日历', icon: CalendarDays },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '员工管理',
|
||||
title: '团队',
|
||||
items: [
|
||||
{ path: '/roster', label: '花名册', icon: Users },
|
||||
{ path: '/work-process', label: '用工办理', icon: ClipboardList },
|
||||
{ path: '/attendance', label: '考勤确认', icon: CalendarCheck },
|
||||
{ path: '/termination', label: '解聘补偿', icon: UserX },
|
||||
{ path: '/termination', label: '离职管理', icon: UserX },
|
||||
{ path: '/special-status', label: '特殊状态', icon: Heart },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '薪税社保',
|
||||
title: '薪酬',
|
||||
items: [
|
||||
{ path: '/money', label: '薪税管理', icon: Calculator },
|
||||
{ path: '/social', label: '社保公积金', icon: Shield },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '合规风控',
|
||||
title: '时间',
|
||||
items: [
|
||||
{ path: '/attendance', label: '考勤排班', icon: CalendarCheck },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '合规',
|
||||
items: [
|
||||
{ path: '/evidence', label: '证据链', icon: FileSearch },
|
||||
{ path: '/policies', label: '规章制度', icon: FileText },
|
||||
@@ -64,15 +69,10 @@ const navGroups: NavGroup[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'AI 辅助',
|
||||
title: '更多',
|
||||
items: [
|
||||
{ path: '/ai-assistant', label: 'AI 顾问', icon: Bot },
|
||||
{ path: '/templates', label: '文本模板', icon: BookMarked },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统',
|
||||
items: [
|
||||
{ path: '/notifications', label: '通知管理', icon: Bell },
|
||||
{ path: '/audit', label: '操作日志', icon: ScrollText },
|
||||
{ path: '/settings', label: '设置', icon: Settings },
|
||||
@@ -85,7 +85,14 @@ const navGroups: NavGroup[] = [
|
||||
*/
|
||||
export default function SidebarNav({ mobileOpen, onClose }: { mobileOpen: boolean; onClose: () => void }) {
|
||||
const location = useLocation()
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(navGroups.map(g => g.title)))
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') return location.pathname === '/'
|
||||
return location.pathname.startsWith(path)
|
||||
}
|
||||
const activeGroup = navGroups.find(g => g.items.some(item => isActive(item.path)))
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
|
||||
new Set(activeGroup ? [activeGroup.title] : ['首页'])
|
||||
)
|
||||
|
||||
const toggleGroup = (title: string) => {
|
||||
setExpandedGroups(prev => {
|
||||
@@ -96,11 +103,6 @@ export default function SidebarNav({ mobileOpen, onClose }: { mobileOpen: boolea
|
||||
})
|
||||
}
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') return location.pathname === '/'
|
||||
return location.pathname.startsWith(path)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 移动端遮罩 */}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 数据表格组件 — 统一表头、行样式、排序状态和空状态
|
||||
* 支持列定义、行点击、选中行、固定列、对齐和空状态
|
||||
*/
|
||||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
|
||||
|
||||
export interface Column<T> {
|
||||
key: string
|
||||
header: string
|
||||
render?: (row: T) => ReactNode
|
||||
sortable?: boolean
|
||||
align?: 'left' | 'center' | 'right'
|
||||
width?: string
|
||||
fixed?: 'left' | 'right'
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
columns: Column<T>[]
|
||||
data: T[]
|
||||
rowKey: (row: T) => string
|
||||
onRowClick?: (row: T) => void
|
||||
sortBy?: string
|
||||
sortOrder?: 'asc' | 'desc'
|
||||
onSort?: (key: string) => void
|
||||
emptyState?: ReactNode
|
||||
dense?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据表格 — 支持排序、行点击、空状态和密度切换
|
||||
*/
|
||||
export function DataTable<T>({
|
||||
columns,
|
||||
data,
|
||||
rowKey,
|
||||
onRowClick,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onSort,
|
||||
emptyState,
|
||||
dense,
|
||||
className,
|
||||
}: DataTableProps<T>) {
|
||||
const alignClass = (align?: string) =>
|
||||
align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left'
|
||||
|
||||
return (
|
||||
<div className={clsx('overflow-x-auto border border-border-default rounded-card bg-surface-card', className)}>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-default bg-surface-muted">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={clsx(
|
||||
'px-3 font-medium text-ink-600 whitespace-nowrap select-none',
|
||||
dense ? 'py-1.5' : 'py-2.5',
|
||||
alignClass(col.align),
|
||||
col.fixed === 'left' && 'sticky left-0 z-10 bg-surface-muted',
|
||||
col.fixed === 'right' && 'sticky right-0 z-10 bg-surface-muted',
|
||||
onSort && col.sortable && 'cursor-pointer hover:text-ink-900',
|
||||
)}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
onClick={col.sortable && onSort ? () => onSort(col.key) : undefined}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{col.header}
|
||||
{col.sortable && onSort && (
|
||||
<span className="inline-flex">
|
||||
{sortBy === col.key ? (
|
||||
sortOrder === 'asc' ? (
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
)
|
||||
) : (
|
||||
<ChevronsUpDown className="w-3.5 h-3.5 text-ink-400" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="text-center py-12 text-ink-400">
|
||||
{emptyState || '暂无数据'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row) => (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
className={clsx(
|
||||
'border-b border-border-subtle transition-colors',
|
||||
onRowClick && 'cursor-pointer hover:bg-surface-muted',
|
||||
)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={clsx(
|
||||
'px-3 text-ink-900 whitespace-nowrap',
|
||||
dense ? 'py-1.5' : 'py-2.5',
|
||||
alignClass(col.align),
|
||||
col.fixed === 'left' && 'sticky left-0 z-10 bg-surface-card',
|
||||
col.fixed === 'right' && 'sticky right-0 z-10 bg-surface-card',
|
||||
)}
|
||||
>
|
||||
{col.render ? col.render(row) : (row as any)[col.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 筛选栏组件 — 统一搜索、筛选条件展示和工具按钮
|
||||
* 支持搜索框、可移除的筛选条件标签、右侧工具区
|
||||
*/
|
||||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { Search, X } from 'lucide-react'
|
||||
|
||||
interface FilterBarProps {
|
||||
searchValue?: string
|
||||
searchPlaceholder?: string
|
||||
onSearchChange?: (value: string) => void
|
||||
filters?: ReactNode
|
||||
activeChips?: Array<{ label: string; onRemove: () => void }>
|
||||
tools?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 筛选栏 — 搜索 + 筛选条件 + 工具按钮
|
||||
*/
|
||||
export function FilterBar({
|
||||
searchValue,
|
||||
searchPlaceholder = '搜索…',
|
||||
onSearchChange,
|
||||
filters,
|
||||
activeChips,
|
||||
tools,
|
||||
className,
|
||||
}: FilterBarProps) {
|
||||
return (
|
||||
<div className={clsx('flex flex-col gap-2 mb-3', className)}>
|
||||
{/* 第一行:搜索 + 筛选器 + 工具 */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{onSearchChange !== undefined && (
|
||||
<div className="relative flex-1 min-w-[180px] max-w-xs">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-ink-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchValue || ''}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full pl-8 pr-3 py-1.5 rounded-md border border-border-default bg-surface-card text-sm text-ink-900 focus:outline-none focus:ring-2 focus:ring-brand-600/30 focus:border-brand-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{filters && <div className="flex items-center gap-2 flex-wrap">{filters}</div>}
|
||||
{tools && <div className="flex items-center gap-2 ml-auto shrink-0">{tools}</div>}
|
||||
</div>
|
||||
|
||||
{/* 第二行:激活的筛选条件标签 */}
|
||||
{activeChips && activeChips.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{activeChips.map((chip, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-surface-muted text-ink-600"
|
||||
>
|
||||
{chip.label}
|
||||
<button
|
||||
onClick={chip.onRemove}
|
||||
className="hover:text-danger transition-colors"
|
||||
aria-label="移除筛选条件"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+10
-9
@@ -4,9 +4,10 @@
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-surface text-gray-900 antialiased;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
@apply bg-surface-page text-ink-900 antialiased;
|
||||
font-family: Inter, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
@@ -29,22 +30,22 @@
|
||||
@apply inline-flex items-center justify-center px-3 py-1.5 rounded font-medium text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply btn bg-primary text-white hover:bg-primary-dark;
|
||||
@apply btn bg-brand-600 text-white hover:bg-brand-700;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply btn bg-gray-100 text-gray-700 hover:bg-gray-200;
|
||||
@apply btn bg-surface-muted text-ink-700 hover:bg-border-subtle;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply btn bg-danger text-white hover:bg-red-700;
|
||||
@apply btn bg-danger text-white hover:bg-danger-light;
|
||||
}
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-4;
|
||||
@apply bg-surface-card rounded-card border border-border-default;
|
||||
}
|
||||
.input {
|
||||
@apply w-full px-2.5 py-1.5 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm;
|
||||
@apply w-full px-2.5 py-2 rounded-md border border-border-default bg-surface-card text-sm text-ink-900 focus:outline-none focus:ring-2 focus:ring-brand-600/30 focus:border-brand-600 transition-colors;
|
||||
}
|
||||
.label {
|
||||
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||
@apply block text-sm font-medium text-ink-700 mb-1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import Button from '../components/ui/Button'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
import Pagination from '../components/ui/Pagination'
|
||||
import type { DashboardData } from '../types'
|
||||
import TurnoverStats from './dashboard/TurnoverStats'
|
||||
import PerformanceStats from './dashboard/PerformanceStats'
|
||||
|
||||
function fmt(n: number) {
|
||||
return `¥${(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
@@ -679,6 +681,12 @@ export default function Dashboard() {
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 入离职统计 + 绩效统计 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3 mt-3">
|
||||
<TurnoverStats />
|
||||
<PerformanceStats />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 绩效统计看板 — 展示绩效等级分布和部门/周期对比
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, BarChart, Bar, XAxis, YAxis, CartesianGrid, Legend } from 'recharts'
|
||||
import { Award, TrendingUp } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
|
||||
const GRADE_COLORS: Record<string, string> = {
|
||||
'A': '#237A57',
|
||||
'B': '#356A8A',
|
||||
'C': '#A76113',
|
||||
'D': '#B83232',
|
||||
'S': '#C7442E',
|
||||
'未评级': '#9CA39B',
|
||||
}
|
||||
|
||||
export default function PerformanceStats() {
|
||||
const [period, setPeriod] = useState(new Date().getFullYear().toString())
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['performance-stats', period],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/dashboard/performance-stats?period=${period}`)
|
||||
return res.data.data
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-4 bg-surface-muted rounded w-32" />
|
||||
<div className="h-48 bg-surface-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || data.total === 0) {
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<h3 className="text-sm font-medium text-ink-700 mb-2">绩效统计</h3>
|
||||
<div className="flex items-center justify-center h-32 text-ink-400 text-sm">暂无数据</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-ink-700">绩效统计</h3>
|
||||
<select
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(e.target.value)}
|
||||
className="text-xs px-2 py-1 rounded border border-border-default bg-surface-card text-ink-700 focus:outline-none focus:ring-2 focus:ring-brand-600/30"
|
||||
>
|
||||
{[new Date().getFullYear(), new Date().getFullYear() - 1].map(y => (
|
||||
<option key={y} value={y}>{y} 年</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 汇总 */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-ink-500">参评人数</span>
|
||||
<span className="text-lg font-semibold text-ink-900 flex items-center gap-1">
|
||||
<Award className="w-4 h-4" />{data.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-ink-500">平均分</span>
|
||||
<span className="text-lg font-semibold text-info flex items-center gap-1">
|
||||
<TrendingUp className="w-4 h-4" />{data.overallAvgScore}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 等级分布饼图 */}
|
||||
<div>
|
||||
<p className="text-xs text-ink-500 mb-2">等级分布</p>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data.gradeDistribution}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={60}
|
||||
label={({ name, value }: any) => `${name}: ${value}`}
|
||||
labelLine={false}
|
||||
>
|
||||
{data.gradeDistribution.map((entry: any, i: number) => (
|
||||
<Cell key={i} fill={GRADE_COLORS[entry.name] || '#9CA39B'} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 8, border: '1px solid #DDE1DD' }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* 部门平均分柱状图 */}
|
||||
<div>
|
||||
<p className="text-xs text-ink-500 mb-2">部门平均分</p>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={data.departmentDistribution} layout="vertical" margin={{ top: 4, right: 8, bottom: 0, left: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#ECEEEC" horizontal={false} />
|
||||
<XAxis type="number" tick={{ fontSize: 10, fill: '#7A8278' }} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 10, fill: '#7A8278' }} width={60} />
|
||||
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 8, border: '1px solid #DDE1DD' }} />
|
||||
<Bar dataKey="avgScore" name="平均分" fill="#356A8A" radius={[0, 3, 3, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 入离职统计看板 — 展示按月入职/离职趋势和汇总数据
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
||||
import { UserPlus, UserMinus, Users, TrendingDown } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
|
||||
export default function TurnoverStats() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['turnover-stats'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/dashboard/turnover-stats?months=12')
|
||||
return res.data.data
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-4 bg-surface-muted rounded w-32" />
|
||||
<div className="h-48 bg-surface-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || data.monthly.length === 0) {
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<h3 className="text-sm font-medium text-ink-700 mb-2">入离职统计</h3>
|
||||
<div className="flex items-center justify-center h-32 text-ink-400 text-sm">暂无数据</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { monthly, summary } = data
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<h3 className="text-sm font-medium text-ink-700 mb-3">入离职趋势(近 12 个月)</h3>
|
||||
|
||||
{/* 汇总卡片 */}
|
||||
<div className="grid grid-cols-4 gap-3 mb-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-ink-500">入职人数</span>
|
||||
<span className="text-lg font-semibold text-success flex items-center gap-1">
|
||||
<UserPlus className="w-4 h-4" />{summary.totalHired}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-ink-500">离职人数</span>
|
||||
<span className="text-lg font-semibold text-danger flex items-center gap-1">
|
||||
<UserMinus className="w-4 h-4" />{summary.totalLeft}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-ink-500">当前人数</span>
|
||||
<span className="text-lg font-semibold text-ink-900 flex items-center gap-1">
|
||||
<Users className="w-4 h-4" />{summary.currentHeadcount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-ink-500">离职率</span>
|
||||
<span className="text-lg font-semibold text-warning flex items-center gap-1">
|
||||
<TrendingDown className="w-4 h-4" />{summary.turnoverRate}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 趋势图 */}
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={monthly} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#ECEEEC" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tick={{ fontSize: 10, fill: '#7A8278' }}
|
||||
tickFormatter={(v: string) => v.slice(5)}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 10, fill: '#7A8278' }} allowDecimals={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ fontSize: 12, borderRadius: 8, border: '1px solid #DDE1DD' }}
|
||||
formatter={(v: any) => [v, ''] as [any, any]}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Bar dataKey="hired" name="入职" fill="#237A57" radius={[3, 3, 0, 0]} />
|
||||
<Bar dataKey="left" name="离职" fill="#B83232" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,18 +6,82 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#4F46E5',
|
||||
light: '#6366F1',
|
||||
dark: '#4338CA',
|
||||
// 品牌色 — 暖红橙,可信温暖
|
||||
brand: {
|
||||
50: '#FBF0EE',
|
||||
100: '#F5D9D4',
|
||||
200: '#EBB3A9',
|
||||
300: '#E08D7E',
|
||||
400: '#D66853',
|
||||
500: '#C7442E',
|
||||
600: '#C7442E',
|
||||
700: '#A93625',
|
||||
800: '#8B2B1E',
|
||||
900: '#6D2118',
|
||||
},
|
||||
danger: '#DC2626',
|
||||
warning: '#F59E0B',
|
||||
safe: '#16A34A',
|
||||
surface: '#F8FAFC',
|
||||
// 兼容旧 primary 引用,指向 brand-600
|
||||
primary: {
|
||||
DEFAULT: '#C7442E',
|
||||
light: '#D66853',
|
||||
dark: '#A93625',
|
||||
},
|
||||
// 文字色
|
||||
ink: {
|
||||
900: '#202622',
|
||||
700: '#3A4239',
|
||||
600: '#59615C',
|
||||
500: '#7A8278',
|
||||
400: '#9CA39B',
|
||||
},
|
||||
// 表面色
|
||||
surface: {
|
||||
page: '#F7F8F6',
|
||||
card: '#FFFFFF',
|
||||
muted: '#F0F2EF',
|
||||
},
|
||||
// 状态色
|
||||
success: {
|
||||
DEFAULT: '#237A57',
|
||||
light: '#3A9B72',
|
||||
bg: '#E6F4ED',
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: '#A76113',
|
||||
light: '#C98A30',
|
||||
bg: '#FBF0E0',
|
||||
},
|
||||
danger: {
|
||||
DEFAULT: '#B83232',
|
||||
light: '#D94848',
|
||||
bg: '#FCEAEA',
|
||||
},
|
||||
info: {
|
||||
DEFAULT: '#356A8A',
|
||||
light: '#4F8AAB',
|
||||
bg: '#E8F0F5',
|
||||
},
|
||||
// 边框
|
||||
border: {
|
||||
DEFAULT: '#DDE1DD',
|
||||
strong: '#C4C9C4',
|
||||
subtle: '#ECEEEC',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', '"Noto Sans SC"', '"PingFang SC"', '"Microsoft YaHei"', 'sans-serif'],
|
||||
},
|
||||
fontSize: {
|
||||
// 使用 tabular-nums 对齐数字
|
||||
'kpi': ['28px', { lineHeight: '36px', fontWeight: '600' }],
|
||||
},
|
||||
maxWidth: {
|
||||
content: '1280px',
|
||||
content: '1440px',
|
||||
},
|
||||
borderRadius: {
|
||||
card: '8px',
|
||||
},
|
||||
spacing: {
|
||||
'18': '72px',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user