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

> 配套文档：`docs/ui-ux-review.md`（现状梳理 + 竞品标杆 + 问题诊断）
>
> 本文档为可执行的实施计划，包含具体文件修改清单、代码示例和验收标准。
>
> 日期：2026-07-24

---

## 目录

- [依赖安装清单](#依赖安装清单)
- [Phase 1：基础体验修复（1-2 天）](#phase-1基础体验修复1-2-天)
- [Phase 2：信息架构调整（2-3 天）](#phase-2信息架构调整2-3-天)
- [Phase 3：性能与组件化（2-3 天）](#phase-3性能与组件化2-3-天)
- [Phase 4：视觉与数据可视化（2-3 天）](#phase-4视觉与数据可视化2-3-天)
- [Phase 5：a11y 与细节打磨（1-2 天）](#phase-5a11y-与细节打磨1-2-天)
- [新增文件清单](#新增文件清单)
- [验收检查表](#验收检查表)

---

## 依赖安装清单

```bash
# 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-lg；`h2` text-sm → text-base | L17-18 |
| `src/components/ui/Button.tsx` | size md: text-xs → text-sm；lg: 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-base；description 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` | L157, L197 |
| `src/pages/Money.tsx` | `space-y-3` | `space-y-4` | L28 |
| `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` | L45 |

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

---

### P1.3 引入 Toast（sonner）

**安装**：`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 修改**：

```tsx
import { Toaster } from 'sonner'

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

**各页面替换规则**：

```tsx
// 旧：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`

```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>` |

**使用示例**：

```tsx
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`

```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顾问' },
]
```

**右侧操作区修改**：

```tsx
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`

```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`

```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 修改**：

```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`

```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.tsx（140KB → 拆分为 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.tsx（63KB → 拆分为 6 个文件）

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

#### Termination.tsx（55KB → 拆分为 6 个文件）

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

#### Settings.tsx（46KB → 拆分为 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`

```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`

```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`

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

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

**影响范围**：所有使用 `text-primary`、`bg-primary`、`border-primary` 的组件自动生效。

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

---

### P4.2 Dashboard 数据可视化

**安装**：`npm install recharts`

**修改文件**：`src/pages/Dashboard.tsx`

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

```tsx
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`

```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`

```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 5：a11y 与细节打磨（1-2 天）

### P5.1 div onClick → button + aria

**修改文件**：`src/components/layout/TopNav.tsx`

```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`

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

---

### P5.4 表单防离开

**新增文件**：`src/hooks/useUnsavedChanges.ts`

```tsx
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])
}
```

**应用**：所有表单页面

```tsx
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 时）

```tsx
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 条数据滚动流畅
