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

889 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 5a11y 与细节打磨(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-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` | 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 引入 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 修改**
```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.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`
```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 5a11y 与细节打磨(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 条数据滚动流畅