Files
TurboHR/docs/ui-ux-optimization-plan.md
T
selfrelease 0df8aa77d9 feat: AIHR 智能人力资源管理系统初始提交
- 员工花名册管理(加密存储、导入导出)
- 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条)
- 社保公积金(多城市配置、版本管理、基数调整)
- 解聘管理(6步流程、证据链、工作交接)
- AI 助手(合同审查、风险预测、RAG 知识库)
- Dashboard 仪表盘
- 设置与通知
2026-07-24 13:53:11 +08:00

26 KiB
Raw Blame History

AIHR 前端 UI/UX 优化实施方案

配套文档:docs/ui-ux-review.md(现状梳理 + 竞品标杆 + 问题诊断)

本文档为可执行的实施计划,包含具体文件修改清单、代码示例和验收标准。

日期:2026-07-24


目录


依赖安装清单

# Phase 1 — Toast
npm install sonner

# Phase 4 — 数据可视化
npm install recharts

# Phase 5 — 虚拟列表
npm install @tanstack/react-virtual

Phase 1:基础体验修复(1-2 天)

P1.1 全局字号提升

目标:正文 12px → 14px,页面标题 → 18px,辅助文字保持 12px。

文件 修改内容 行号参考
src/index.css .btn text-xs → text-sm.input text-xs → text-sm.label text-xs → text-sm L24, L39, L42
src/index.css h1 text-base → text-lgh2 text-sm → text-base L17-18
src/components/ui/Button.tsx size md: text-xs → text-smlg: text-sm → text-base L18-20
src/components/ui/Input.tsx Input/Select text-xs → text-sm L10, L25
src/components/ui/Pagination.tsx text-xs → text-sm(页码、条数信息) L42, L69, L74
src/components/ui/EmptyState.tsx title text-sm → text-basedescription text-xs → text-sm L19-20

验收标准:正文内容 14px,页面标题 18px,辅助文字 12px,按钮 14px。


P1.2 卡片间距增大

目标:增加呼吸感,信息密度从"紧凑"到"舒适"。

文件 当前 目标
src/components/ui/Card.tsx p-3 p-4
src/index.css .card p-3 p-4
src/pages/Dashboard.tsx space-y-3 / gap-2 space-y-4 / gap-3
src/pages/Money.tsx space-y-3 space-y-4
src/pages/Roster.tsx 列表行 py-1.5 py-2.5
src/pages/SocialInsurance.tsx space-y-3 space-y-4
src/pages/Settings.tsx space-y-3 space-y-4

验收标准:卡片内边距 16px,页面模块间距 16px,表格行高 ≥ 40px。


P1.3 引入 Toastsonner

安装npm install sonner

修改文件清单37 处 alert/confirm):

文件 alert 数量 confirm 数量 行号参考
src/App.tsx 顶层添加 <Toaster>
src/pages/AIAssistant.tsx 6 0 L137, L433, L435, L585, L587, L605, L607
src/pages/Money.tsx 5 3 L284, L342, L352, L354, L358, L493, L506, L883, L1216, L1255
src/pages/Roster.tsx 4 1 L400, L507, L985, L993, L1930, L1937
src/pages/SocialInsurance.tsx 7 0 L122, L132, L168, L182, L191, L200, L294
src/pages/Settings.tsx 6 0 搜索结果
src/pages/portal/ContractConfirm.tsx 1 0

App.tsx 修改

import { Toaster } from 'sonner'

export default function App() {
  return (
    <>
      <Routes>...</Routes>
      <Toaster position="top-center" richColors closeButton />
    </>
  )
}

各页面替换规则

// 旧:alert('已保存到员工档案')
// 新:toast.success('已保存到员工档案')

// 旧:alert('保存失败:' + msg)
// 新:toast.error('保存失败:' + msg)

// 旧:alert('不支持的文件格式')
// 新:toast.error('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式')

验收标准:全局 grep -r "alert(" src/ 返回 0 结果,所有操作反馈通过 toast。


P1.4 批量操作二次确认组件

新增文件src/components/ui/ConfirmDialog.tsx

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>
  )
}

替换清单(所有 confirm() 调用):

文件 行号 当前代码 替换为
Money.tsx L284 confirm('确认删除批次?') <ConfirmDialog>
Money.tsx L493 confirm('确认归档?') <ConfirmDialog>
Money.tsx L506 confirm('确认删除批次?') <ConfirmDialog>
Money.tsx L1255 confirm('确认生成工资条?') <ConfirmDialog>
Roster.tsx L400 confirm('确认撤回离职记录?') <ConfirmDialog>

使用示例

const [confirmOpen, setConfirmOpen] = useState(false)

// 触发
onClick={() => setConfirmOpen(true)}

// 渲染
<ConfirmDialog
  open={confirmOpen}
  title="确认删除"
  message={`确认删除批次「${batch.name}」?此操作不可撤销。`}
  onConfirm={() => { deleteBatchMutation.mutate(batch.id); setConfirmOpen(false) }}
  onCancel={() => setConfirmOpen(false)}
/>

验收标准:危险操作弹出 Dialog 而非浏览器原生 confirm,有明确文案说明后果。


P1.5 对比度修复

目标:所有文字对比度 ≥ 4.5:1(WCAG AA)。

文件 当前 目标 说明
全局 text-gray-400 #9CA3AF (2.5:1) text-gray-500 #6B7280 (4.6:1) 全局替换
src/components/ui/Pagination.tsx L61, L69 text-gray-500 翻页按钮
src/pages/Dashboard.tsx L229 text-gray-500 "等人"文字
src/components/layout/TopNav.tsx L50 text-gray-500 ChevronDown 图标

验收标准:使用 axe DevTools 扫描,0 个对比度违规。


P1.6 内容宽度限制

文件 修改
tailwind.config.js maxWidth: { content: '1280px' }(当前 none

验收标准:1920px 屏幕内容居中,最大宽度 1280px,两侧留白。


Phase 2:信息架构调整(2-3 天)

P2.1 顶部导航精简为 4 入口

修改文件src/components/layout/TopNav.tsx

// 当前 6 个 tab
const tabs = [
  { path: '/', label: '总览' },
  { path: '/roster', label: '花名册' },
  { path: '/money', label: '薪税' },
  { path: '/social', label: '社保公积金' },
  { path: '/termination', label: '解聘补偿' },
  { path: '/ai-assistant', label: 'AI顾问' },
]

// 目标 4 个 tab
const tabs = [
  { path: '/', label: '总览' },
  { path: '/roster', label: '员工管理' },
  { path: '/money', label: '薪税社保' },
  { path: '/ai-assistant', label: 'AI顾问' },
]

右侧操作区修改

import { Settings, Bell } from 'lucide-react'

// 当前:仅用户下拉菜单
// 目标:通知铃铛(badge) + 设置齿轮 + 用户头像
<div className="flex items-center gap-2 shrink-0">
  <Link to="/settings" className="p-1.5 rounded-md hover:bg-gray-100" aria-label="设置">
    <Settings 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>

验收标准:顶部导航 4 个 tab + 设置齿轮 + 通知铃铛 + 用户菜单。


P2.2 移动端底部导航精简为 4

修改文件src/components/layout/MobileTabBar.tsx

import { Home, Users, Calculator, Bot } from 'lucide-react'

// 当前 6 个 → 目标 4 个
const tabs = [
  { path: '/', label: '总览', icon: Home },
  { path: '/roster', label: '员工', icon: Users },
  { path: '/money', label: '薪税', icon: Calculator },
  { path: '/ai-assistant', label: 'AI', icon: Bot },
]

验收标准iPhone SE 上每个 tab ≥ 80px 宽度,图标+文字不挤压。


P2.3 路由调整

修改文件src/App.tsx

变更 说明
/social 路由保留 导航不直接暴露,作为 /money 的子 tab 或页面内跳转
/termination 路由保留 导航不直接暴露,作为 /roster 内的功能入口

修改文件src/pages/Dashboard.tsx

// 移除 payroll tab
type Tab = 'overview' | 'risk' | 'task'  // 移除 'payroll'

const tabs = [
  { key: 'overview' as const, label: '概览', icon: LayoutDashboard, badge: data.stats.todoCount },
  { key: 'risk' as const, label: '风险提醒', icon: AlertTriangle, badge: riskTodos.length },
  { key: 'task' as const, label: '月度任务', icon: ListTodo, badge: taskTodos.length },
]
// 删除 activeTab === 'payroll' 相关的所有 JSX 块
// 删除 payrollSummary / payrollItems / deductionItems 等相关变量

验收标准Dashboard 3 个 tab(概览/风险/任务),无薪税重复入口。


P2.4 合并未挂载页面

操作 源文件 目标文件 说明
Compensation → Money src/pages/Compensation.tsx src/pages/Money.tsx 作为薪税页面的子 tab
Contracts → Roster src/pages/Contracts.tsx src/pages/Roster.tsx Roster 详情已有合同 tab,删除或合并

Money.tsx 修改

type Tab = 'batch' | 'template' | 'overtime' | 'payslip' | 'adjust'

const tabs: { key: Tab; label: string }[] = [
  { key: 'batch', label: '发薪批次' },
  { key: 'template', label: '薪酬模版' },
  { key: 'overtime', label: '加班费计算' },
  { key: 'payslip', label: '工资条管理' },
  { key: 'adjust', label: '薪酬调整' },  // 新增
]

// 渲染
{tab === 'adjust' && <CompensationManager />}

验收标准Contracts/Compensation 功能可访问,无孤立页面。


Phase 3:性能与组件化(2-3 天)

P3.1 路由懒加载

修改文件src/App.tsx

import { lazy, Suspense } from 'react'
import { Loader2 } from 'lucide-react'

const Dashboard = lazy(() => import('./pages/Dashboard'))
const Roster = lazy(() => import('./pages/Roster'))
const Money = lazy(() => import('./pages/Money'))
const SocialInsurance = lazy(() => import('./pages/SocialInsurance'))
const Termination = lazy(() => import('./pages/Termination'))
const AIAssistant = lazy(() => import('./pages/AIAssistant'))
const Settings = lazy(() => import('./pages/Settings'))
const Login = lazy(() => import('./pages/auth/Login'))
const Register = lazy(() => import('./pages/auth/Register'))
const ForgotPassword = lazy(() => import('./pages/auth/ForgotPassword'))
const PortalLogin = lazy(() => import('./pages/portal/PortalLogin'))
const Payslip = lazy(() => import('./pages/portal/Payslip'))
const MyContract = lazy(() => import('./pages/portal/MyContract'))
const Onboarding = lazy(() => import('./pages/portal/Onboarding'))
const ContractConfirm = lazy(() => import('./pages/portal/ContractConfirm'))

function PageSkeleton() {
  return (
    <div className="flex items-center justify-center py-20">
      <Loader2 className="w-6 h-6 text-primary animate-spin" />
    </div>
  )
}

export default function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>...</Routes>
    </Suspense>
  )
}

验收标准:首屏仅加载 Dashboard chunk,其他页面按需加载,Network 面板可见独立 chunk。


P3.2 大文件拆分

Roster.tsx140KB → 拆分为 17 个文件)

src/pages/roster/
├── Roster.tsx              # 主页面:列表 + 搜索 + 筛选 + 分页
├── EmployeeDetail.tsx      # 详情面板:tab 切换容器
├── tabs/
│   ├── BasicInfoTab.tsx    # 基本信息
│   ├── ContractTab.tsx     # 合同信息
│   ├── PayslipTab.tsx      # 工资条
│   ├── OvertimeTab.tsx     # 加班记录
│   ├── DisciplinaryTab.tsx # 违纪记录
│   ├── AttendanceTab.tsx   # 考勤记录
│   ├── TrainingTab.tsx     # 培训记录
│   ├── PerformanceTab.tsx  # 绩效记录
│   ├── TerminationTab.tsx  # 解聘记录
│   ├── AttachmentTab.tsx   # 附件管理
│   └── EvidenceTab.tsx     # 仲裁证据链
├── AddEmployeeModal.tsx    # 新增员工弹窗
├── ResignModal.tsx         # 离职弹窗
├── RehireModal.tsx         # 重新入职弹窗
├── SalaryModal.tsx         # 调薪弹窗
├── DeptModal.tsx           # 调岗弹窗
└── BatchRenewModal.tsx     # 批量续签弹窗

Money.tsx63KB → 拆分为 6 个文件)

src/pages/money/
├── Money.tsx               # 主页面:tab 切换
├── BatchManager.tsx        # 发薪批次
├── TemplateManager.tsx     # 薪酬模版
├── OvertimeCalculator.tsx  # 加班费计算
├── PayslipManager.tsx      # 工资条管理
└── CompensationManager.tsx # 薪酬调整(从 Compensation.tsx 合入)

Termination.tsx55KB → 拆分为 6 个文件)

src/pages/termination/
├── Termination.tsx         # 主页面:向导容器
├── StepSelectEmployee.tsx  # 步骤1:选择员工
├── StepSelectReason.tsx    # 步骤2:解聘方式
├── StepCompliance.tsx      # 步骤3:合规检查
├── StepSettlement.tsx      # 步骤4:费用结算
└── StepConfirm.tsx         # 步骤5:确认完成

Settings.tsx46KB → 拆分为 6 个文件)

src/pages/settings/
├── Settings.tsx            # 主页面:section 切换
├── OrgSettings.tsx         # 企业信息
├── UserSettings.tsx        # 用户管理
├── PlanSettings.tsx        # 套餐
├── NotificationSettings.tsx # 通知设置
└── ImportSettings.tsx      # 数据导入

验收标准:单个文件不超过 500 行,每个子组件独立可测。


P3.3 骨架屏组件

新增文件src/components/ui/Skeleton.tsx

export function TableSkeleton({ rows = 5 }: { rows?: number }) {
  return (
    <div className="space-y-2">
      {Array.from({ length: rows }).map((_, i) => (
        <div key={i} className="h-10 bg-gray-100 rounded animate-pulse" />
      ))}
    </div>
  )
}

export function CardSkeleton() {
  return (
    <div className="p-4 bg-white rounded-lg border border-gray-200">
      <div className="h-4 bg-gray-100 rounded w-1/3 mb-3 animate-pulse" />
      <div className="h-8 bg-gray-100 rounded w-1/2 animate-pulse" />
    </div>
  )
}

export function DetailSkeleton() {
  return (
    <div className="space-y-3">
      <div className="h-6 bg-gray-100 rounded w-1/4 animate-pulse" />
      <div className="h-4 bg-gray-100 rounded w-full animate-pulse" />
      <div className="h-4 bg-gray-100 rounded w-3/4 animate-pulse" />
    </div>
  )
}

替换清单13 处 "加载中..."):

文件 行号 替换为
Dashboard.tsx L112 <TableSkeleton rows={4} />
Roster.tsx L249, L702 <TableSkeleton />
Money.tsx L239, L371, L719, L933, L1268 <TableSkeleton />
SocialInsurance.tsx L314, L623 <TableSkeleton rows={3} />
AIAssistant.tsx L773 <TableSkeleton />
portal/Payslip.tsx L131 <CardSkeleton />
portal/MyContract.tsx L70 <CardSkeleton />
portal/ContractConfirm.tsx L85 <CardSkeleton />
Contracts.tsx L82 <TableSkeleton />

验收标准grep -r "加载中" src/ 返回 0 结果,加载时显示骨架屏动画。


P3.4 搜索防抖

修改文件src/pages/Roster.tsx

import { useDeferredValue } from 'react'

const [search, setSearch] = useState('')
const deferredSearch = useDeferredValue(search)

// queryKey 使用 deferredSearch 而非 search
const { data: rosterData } = useQuery({
  queryKey: ['roster', page, pageSize, deferredSearch, filterStatus, filterContractStatus],
  queryFn: async () => {
    const params: any = { page, pageSize }
    if (deferredSearch) params.search = deferredSearch
    // ...
  },
})

验收标准:快速输入时不会每次按键触发 API 请求,停止输入 ~200ms 后才发请求。


Phase 4:视觉与数据可视化(2-3 天)

P4.1 主色调暖

修改文件tailwind.config.js

// 当前:冷蓝
primary: { DEFAULT: '#2563EB', light: '#3B82F6', dark: '#1D4ED8' }

// 目标:indigo-600(略带紫调,专业且亲和)
primary: { DEFAULT: '#4F46E5', light: '#6366F1', dark: '#4338CA' }

影响范围:所有使用 text-primarybg-primaryborder-primary 的组件自动生效。

验收标准:主色从冷蓝变为 indigo,与 Tailwind indigo-600 色卡一致。


P4.2 Dashboard 数据可视化

安装npm install recharts

修改文件src/pages/Dashboard.tsx

在概览 tab 的统计卡片下方增加:

import { LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip, PieChart, Pie, Cell } from 'recharts'

// 月度薪税趋势迷你折线图
<Card>
  <h2 className="font-medium mb-3">月度薪税趋势</h2>
  <ResponsiveContainer width="100%" height={120}>
    <LineChart data={data.payrollHistory}>
      <XAxis dataKey="month" tick={{ fontSize: 12 }} />
      <YAxis tick={{ fontSize: 12 }} />
      <Tooltip />
      <Line type="monotone" dataKey="totalPay" stroke="#4F46E5" strokeWidth={2} dot={false} />
    </LineChart>
  </ResponsiveContainer>
</Card>

// 风险分布环形图
<Card>
  <h2 className="font-medium mb-3">风险分布</h2>
  <ResponsiveContainer width="100%" height={160}>
    <PieChart>
      <Pie data={riskData} dataKey="count" nameKey="label" cx="50%" cy="50%" innerRadius={40} outerRadius={60}>
        {riskData.map((entry, i) => <Cell key={i} fill={entry.color} />)}
      </Pie>
      <Tooltip />
    </PieChart>
  </ResponsiveContainer>
</Card>

验收标准:Dashboard 概览页有折线图和环形图,图表响应式,tooltip 正常显示。


P4.3 Modal 过渡动画

修改文件src/components/ui/Modal.tsx

import { ReactNode, useEffect, useState } from 'react'
import { X } from 'lucide-react'
import clsx from 'clsx'

export default function Modal({ open, onClose, title, children, className, size = 'md' }: ModalProps) {
  const [show, setShow] = useState(false)

  useEffect(() => {
    if (open) {
      setShow(true)
    } else {
      const timer = setTimeout(() => setShow(false), 200)
      return () => clearTimeout(timer)
    }
  }, [open])

  // body overflow 控制(保持原有逻辑)
  useEffect(() => {
    document.body.style.overflow = open ? 'hidden' : ''
    return () => { document.body.style.overflow = '' }
  }, [open])

  if (!show && !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',
        open ? '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',
        open ? 'scale-100 opacity-100' : 'scale-95 opacity-0',
        sizeClass,
      )}>
        {/* title + children 保持不变 */}
      </div>
    </div>
  )
}

验收标准:弹窗有淡入+缩放动画,关闭有淡出动画,~200ms。


P4.4 移动端表格响应式

新增组件src/components/ui/ResponsiveTable.tsx

import { ReactNode } from 'react'
import clsx from 'clsx'

interface Column<T> {
  key: string
  label: string
  render?: (row: T) => ReactNode
  priority: 'high' | 'medium' | 'low'
  className?: string
}

interface ResponsiveTableProps<T> {
  columns: Column<T>[]
  data: T[]
  rowKey: (row: T) => string
  onRowClick?: (row: T) => void
}

export default function ResponsiveTable<T>({ columns, data, rowKey, onRowClick }: ResponsiveTableProps<T>) {
  return (
    <>
      {/* 桌面端/平板:表格 */}
      <table className="hidden md:table w-full text-sm">
        <thead>
          <tr className="border-b">
            {columns.map(col => (
              <th key={col.key} className={clsx('text-left py-2 px-3 font-medium text-gray-600', col.className)}>
                {col.label}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {data.map(row => (
            <tr key={rowKey(row)} className="border-b hover:bg-gray-50 cursor-pointer" onClick={() => onRowClick?.(row)}>
              {columns.map(col => (
                <td key={col.key} className="py-2.5 px-3">
                  {col.render ? col.render(row) : (row as any)[col.key]}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>

      {/* 手机端:卡片列表 */}
      <div className="md:hidden space-y-2">
        {data.map(row => {
          const highCols = columns.filter(c => c.priority === 'high')
          return (
            <div key={rowKey(row)} className="bg-white rounded-lg border border-gray-200 p-3" onClick={() => onRowClick?.(row)}>
              {highCols.map(col => (
                <div key={col.key} className="flex justify-between py-1">
                  <span className="text-gray-500 text-sm">{col.label}</span>
                  <span className="text-gray-900 text-sm font-medium">
                    {col.render ? col.render(row) : (row as any)[col.key]}
                  </span>
                </div>
              ))}
            </div>
          )
        })}
      </div>
    </>
  )
}

应用页面Roster、Money(工资条列表)、SocialInsurance(月度申报表)

验收标准:iPhone SE 上列表为卡片模式,iPad 上为表格,桌面端完整表格。


Phase 5a11y 与细节打磨(1-2 天)

P5.1 div onClick → button + aria

修改文件src/components/layout/TopNav.tsx

// 添加 aria 属性
<button
  onClick={() => setMenuOpen(!menuOpen)}
  className="flex items-center gap-1 ..."
  aria-expanded={menuOpen}
  aria-haspopup="menu"
  aria-label="用户菜单"
>

P5.2 aria-label 覆盖

文件 位置 添加
TopNav.tsx Logo Link aria-label="用工合规助手首页"
TopNav.tsx 设置齿轮 aria-label="设置"
TopNav.tsx 通知铃铛 aria-label="通知"
MobileTabBar.tsx 每个 Link aria-label={tab.label}
Modal.tsx 关闭按钮 aria-label="关闭"
Pagination.tsx 上一页按钮 aria-label="上一页"
Pagination.tsx 下一页按钮 aria-label="下一页"

P5.3 focus-visible 样式

修改文件src/index.css

@layer base {
  *:focus-visible {
    @apply outline-none ring-2 ring-primary ring-offset-1;
  }
}

P5.4 表单防离开

新增文件src/hooks/useUnsavedChanges.ts

import { useEffect } from 'react'

/** 表单未保存时阻止页面离开 */
export function useUnsavedChanges(isDirty: boolean) {
  useEffect(() => {
    const handler = (e: BeforeUnloadEvent) => {
      if (isDirty) {
        e.preventDefault()
        e.returnValue = ''
      }
    }
    window.addEventListener('beforeunload', handler)
    return () => window.removeEventListener('beforeunload', handler)
  }, [isDirty])
}

应用:所有表单页面

import { useUnsavedChanges } from '../hooks/useUnsavedChanges'

const form = useForm({ mode: 'onChange' })
useUnsavedChanges(form.formState.isDirty)

P5.5 虚拟列表

安装npm install @tanstack/react-virtual

修改文件src/pages/Roster.tsx(当员工数 > 100 时)

import { useVirtualizer } from '@tanstack/react-virtual'

const parentRef = useRef<HTMLDivElement>(null)

const rowVirtualizer = useVirtualizer({
  count: employees.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 48,
  overscan: 5,
})

验收标准:1000+ 员工时列表滚动流畅,DOM 节点数 < 30。


新增文件清单

文件路径 Phase 说明
src/components/ui/ConfirmDialog.tsx P1.4 二次确认弹窗
src/components/ui/Skeleton.tsx P3.3 骨架屏组件
src/components/ui/ResponsiveTable.tsx P4.4 响应式表格
src/hooks/useUnsavedChanges.ts P5.4 表单防离开 Hook
src/pages/roster/ 目录(17 文件) P3.2 Roster 拆分
src/pages/money/ 目录(6 文件) P3.2 Money 拆分
src/pages/termination/ 目录(6 文件) P3.2 Termination 拆分
src/pages/settings/ 目录(6 文件) P3.2 Settings 拆分

验收检查表

Phase 1

  • grep -r "text-xs" src/components/ui/ 仅出现在 size="sm" 和辅助文字处
  • grep -r "alert(" src/ 返回 0 结果
  • grep -r "confirm(" src/ 返回 0 结果
  • grep -r "text-gray-400" src/ 返回 0 结果(全部替换为 gray-500)
  • 1920px 屏幕内容居中,最大宽度 1280px
  • axe DevTools 扫描 0 个对比度违规

Phase 2

  • 顶部导航 4 个 tab
  • 移动端底部导航 4 个 tab
  • Dashboard 3 个 tab(无薪税)
  • Compensation 功能可通过薪税页面访问
  • Contracts 功能可通过花名册访问

Phase 3

  • 首屏仅加载 Dashboard chunk
  • 单个文件不超过 500 行
  • grep -r "加载中" src/ 返回 0 结果
  • 搜索快速输入时不触发多余请求

Phase 4

  • 主色为 indigo-600 (#4F46E5)
  • Dashboard 有折线图和环形图
  • Modal 有淡入淡出动画
  • iPhone SE 上列表为卡片模式

Phase 5

  • grep -r "div onClick" src/ 返回 0 结果
  • 所有图标按钮有 aria-label
  • 键盘 Tab 导航可见 focus ring
  • 表单填写中关闭页面有浏览器提示
  • 1000 条数据滚动流畅