0df8aa77d9
- 员工花名册管理(加密存储、导入导出) - 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条) - 社保公积金(多城市配置、版本管理、基数调整) - 解聘管理(6步流程、证据链、工作交接) - AI 助手(合同审查、风险预测、RAG 知识库) - Dashboard 仪表盘 - 设置与通知
889 lines
26 KiB
Markdown
889 lines
26 KiB
Markdown
# 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 条数据滚动流畅
|