本章学习目标
- 学会设计数据看板类页面的布局
- 掌握统计卡片组件的实现(数字动效、趋势指标)
- 集成 ECharts 实现趋势图、饼图、热力图
- 实现待办队列(Tab 切换 + 虚拟列表)
- 了解 Mock 数据与真实接口切换方案
3.1 需求分析与布局设计
工作台是用户登录后看到的第一个页面,承担「数据总览」和「快速入口」两个核心职能。
3.1.1 页面结构
bash
┌──────────────────────────────────────────────────┐
│ 顶部:欢迎语 + 快捷操作按钮 │
├──────────┬───────────────────────────────────────┤
│ │ ┌───────────────────────────────┐ │
│ 待办 │ │ 统计卡片组(4 个) │ │
│ 队列 │ ├───────────────────────────────┤ │
│ (左侧) │ │ 趋势图(折线图) │ │
│ │ ├───────────────────────────────┤ │
│ │ │ 分类分布(饼) │ 团队效率(柱) │ │
│ │ └───────────────────────────────┘ │
└──────────┴───────────────────────────────────────┘
3.1.2 布局实现
使用 Ant Design 的 Row + Col 栅格系统:
tsx
// src/pages/workbench/index.tsx
import { Row, Col, Card, Typography, Space, Button } from 'antd';
import { PlusOutlined, BellOutlined } from '@ant-design/icons';
import StatCard from '@components/StatCard';
import TodoList from './components/TodoList';
import TrendChart from './components/TrendChart';
import CategoryPie from './components/CategoryPie';
import TeamRanking from './components/TeamRanking';
import { useWorkbenchStore } from './store';
const { Title } = Typography;
export default function Workbench() {
const stats = useWorkbenchStore((s) => s.stats);
const userInfo = useUserStore((s) => s.userInfo);
return (
<div style={{ padding: 24 }}>
{/* 顶部欢迎区 */}
<Card style={{ marginBottom: 16 }}>
<Space size="large" style={{ width: '100%', justifyContent: 'space-between' }}>
<div>
<Title level={3} style={{ margin: 0 }}>
你好,{userInfo?.name || '用户'} 👋
</Title>
<p style={{ color: '#999', margin: '8px 0 0' }}>
今天是 {new Date().toLocaleDateString('zh-CN')},祝你工作顺利
</p>
</div>
<Space>
<Button icon={<BellOutlined />}>消息中心</Button>
<Button type="primary" icon={<PlusOutlined />}>
新建任务单
</Button>
</Space>
</Space>
</Card>
{/* 主体双栏布局 */}
<Row gutter={16}>
{/* 左侧待办队列 */}
<Col span={6}>
<TodoList />
</Col>
{/* 右侧数据看板 */}
<Col span={18}>
{/* 统计卡片 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<StatCard title="待处理" value={stats.pending} trend={12} />
</Col>
<Col span={6}>
<StatCard title="处理中" value={stats.processing} trend={-5} />
</Col>
<Col span={6}>
<StatCard title="已完成" value={stats.resolved} trend={23} />
</Col>
<Col span={6}>
<StatCard title="超时" value={stats.overdue} trend={-8} color="#ff4d4f" />
</Col>
</Row>
{/* 趋势图 */}
<Card title="任务趋势" style={{ marginBottom: 16 }}>
<TrendChart />
</Card>
{/* 饼图 + 柱状图 */}
<Row gutter={16}>
<Col span={12}>
<Card title="分类分布">
<CategoryPie />
</Card>
</Col>
<Col span={12}>
<Card title="团队效率榜">
<TeamRanking />
</Card>
</Col>
</Row>
</Col>
</Row>
</div>
);
}
3.2 统计卡片组件(StatCard)
统计卡片是看板最基本的元素,需要支持数字动效和趋势指标。
3.2.1 组件实现
tsx
// src/components/StatCard/index.tsx
import { useEffect, useState } from 'react';
import { Card, Typography, Space } from 'antd';
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
const { Text } = Typography;
interface StatCardProps {
title: string;
value: number;
trend?: number; // 百分比,正数上升,负数下降
color?: string;
prefix?: string;
suffix?: string;
onClick?: () => void;
}
// 数字滚动动效
function AnimatedNumber({ value, duration = 800 }: { value: number; duration?: number }) {
const [display, setDisplay] = useState(0);
useEffect(() => {
let startTime: number;
let animationFrame: number;
const animate = (timestamp: number) => {
if (!startTime) startTime = timestamp;
const progress = Math.min((timestamp - startTime) / duration, 1);
// 缓动函数:easeOutQuart
const eased = 1 - Math.pow(1 - progress, 4);
setDisplay(Math.floor(eased * value));
if (progress < 1) {
animationFrame = requestAnimationFrame(animate);
}
};
animationFrame = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationFrame);
}, [value, duration]);
return <>{display.toLocaleString()}</>;
}
export default function StatCard({
title,
value,
trend,
color = '#1677ff',
prefix = '',
suffix = '',
onClick,
}: StatCardProps) {
const isPositive = (trend || 0) >= 0;
return (
<Card
hoverable
onClick={onClick}
styles={{ body: { padding: 20 } }}
>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Text type="secondary" style={{ fontSize: 14 }}>
{title}
</Text>
<div>
<span style={{ fontSize: 28, fontWeight: 600, color }}>
{prefix}
<AnimatedNumber value={value} />
{suffix}
</span>
</div>
{trend !== undefined && (
<Space size={4}>
{isPositive ? (
<ArrowUpOutlined style={{ color: '#52c41a' }} />
) : (
<ArrowDownOutlined style={{ color: '#ff4d4f' }} />
)}
<Text style={{ color: isPositive ? '#52c41a' : '#ff4d4f', fontSize: 12 }}>
{Math.abs(trend)}% 较上周
</Text>
</Space>
)}
</Space>
</Card>
);
}
3.2.2 设计要点
- 数字动效 :用
requestAnimationFrame实现平滑滚动,给用户「数据在变化」的感觉 - 趋势指标:箭头 + 颜色 + 百分比,一目了然
- hoverable:卡片可点击,作为快速入口跳转到对应列表
- 可配置:颜色、前缀、后缀都通过 props 传入,提高复用性
3.3 ECharts 图表集成
ECharts 是最强大的前端图表库,我们用 echarts-for-react 简化 React 中的使用。
3.3.1 安装
bash
npm install echarts echarts-for-react
3.3.2 趋势折线图
tsx
// src/pages/workbench/components/TrendChart.tsx
import ReactECharts from 'echarts-for-react';
import { useWorkbenchStore } from '../store';
export default function TrendChart() {
const trendData = useWorkbenchStore((s) => s.trendData);
const option = {
tooltip: { trigger: 'axis' },
legend: { data: ['新建', '完成'], right: 0 },
grid: { left: 40, right: 20, top: 40, bottom: 30 },
xAxis: {
type: 'category',
data: trendData.dates,
boundaryGap: false,
},
yAxis: { type: 'value' },
series: [
{
name: '新建',
type: 'line',
smooth: true,
data: trendData.created,
areaStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(22, 119, 255, 0.3)' },
{ offset: 1, color: 'rgba(22, 119, 255, 0.05)' },
],
},
},
lineStyle: { color: '#1677ff', width: 2 },
itemStyle: { color: '#1677ff' },
},
{
name: '完成',
type: 'line',
smooth: true,
data: trendData.resolved,
areaStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(82, 196, 26, 0.3)' },
{ offset: 1, color: 'rgba(82, 196, 26, 0.05)' },
],
},
},
lineStyle: { color: '#52c41a', width: 2 },
itemStyle: { color: '#52c41a' },
},
],
};
return <ReactECharts option={option} style={{ height: 280 }} />;
}
3.3.3 分类饼图
tsx
// src/pages/workbench/components/CategoryPie.tsx
import ReactECharts from 'echarts-for-react';
import { useWorkbenchStore } from '../store';
export default function CategoryPie() {
const categoryData = useWorkbenchStore((s) => s.categoryData);
const option = {
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { orient: 'vertical', left: 'left' },
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: ['60%', '50%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
emphasis: {
label: { show: true, fontSize: 14, fontWeight: 'bold' },
},
data: categoryData,
},
],
};
return <ReactECharts option={option} style={{ height: 280 }} />;
}
3.3.4 团队效率柱状图
tsx
// src/pages/workbench/components/TeamRanking.tsx
import ReactECharts from 'echarts-for-react';
import { useWorkbenchStore } from '../store';
export default function TeamRanking() {
const teamData = useWorkbenchStore((s) => s.teamData);
const option = {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 80, right: 20, top: 20, bottom: 30 },
xAxis: { type: 'value' },
yAxis: {
type: 'category',
data: teamData.map((t) => t.name),
},
series: [
{
type: 'bar',
data: teamData.map((t) => t.value),
itemStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 1, y2: 0,
colorStops: [
{ offset: 0, color: '#1677ff' },
{ offset: 1, color: '#69c0ff' },
],
},
borderRadius: [0, 4, 4, 0],
},
barWidth: 16,
label: { show: true, position: 'right' },
},
],
};
return <ReactECharts option={option} style={{ height: 280 }} />;
}
3.4 待办队列
待办队列是工作台的核心交互区,展示当前用户需要处理的任务。
3.4.1 Tab 切换 + 列表
tsx
// src/pages/workbench/components/TodoList.tsx
import { useState } from 'react';
import { Card, Tabs, List, Tag, Avatar, Badge } from 'antd';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { useWorkbenchStore } from '../store';
dayjs.extend(relativeTime);
const tabs = [
{ key: 'pending', label: '待处理', badge: 5 },
{ key: 'processing', label: '处理中', badge: 3 },
{ key: 'reminder', label: '提醒', badge: 2 },
];
export default function TodoList() {
const [activeTab, setActiveTab] = useState('pending');
const navigate = useNavigate();
const todoList = useWorkbenchStore((s) => s.todoList[activeTab as keyof typeof s.todoList]);
const priorityColor = (p: number) => {
const colors: Record<number, string> = { 1: 'red', 2: 'orange', 3: 'blue', 4: 'default' };
return colors[p] || 'default';
};
const priorityText = (p: number) => {
const texts: Record<number, string> = { 1: '紧急', 2: '高', 3: '中', 4: '低' };
return texts[p] || '低';
};
return (
<Card
title="待办队列"
extra={<a onClick={() => navigate('/ticket')}>查看全部</a>}
styles={{ body: { padding: 0 } }}
>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={tabs.map((t) => ({
key: t.key,
label: (
<Badge count={t.badge} size="small" offset={[4, 0]}>
{t.label}
</Badge>
),
}))}
style={{ padding: '0 16px' }}
/>
<List
dataSource={todoList}
renderItem={(item) => (
<List.Item
style={{ padding: '12px 16px', cursor: 'pointer' }}
onClick={() => navigate(`/ticket/${item.id}`)}
>
<List.Item.Meta
avatar={<Avatar style={{ backgroundColor: '#1677ff' }}>{item.creator?.[0]}</Avatar>}
title={
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ fontWeight: 500, maxWidth: 180, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.title}
</span>
<Tag color={priorityColor(item.priority)} style={{ marginLeft: 8 }}>
{priorityText(item.priority)}
</Tag>
</div>
}
description={
<div style={{ fontSize: 12, color: '#999' }}>
#{item.id} · {dayjs(item.createdAt).fromNow()}
</div>
}
/>
</List.Item>
)}
style={{ maxHeight: 520, overflow: 'auto' }}
/>
</Card>
);
}
3.5 Mock 数据与真实接口切换
在项目早期,后端接口还没就绪时,我们需要 Mock 数据来推进前端开发。
3.5.1 Store 层的数据来源策略
typescript
// src/pages/workbench/store/index.ts
import { createStore } from '@store/createStore';
import { workbenchApi } from '@services/workbench';
import { mockStats, mockTrendData, mockCategoryData, mockTeamData, mockTodoList } from '../mock';
interface WorkbenchState {
stats: { pending: number; processing: number; resolved: number; overdue: number };
trendData: { dates: string[]; created: number[]; resolved: number[] };
categoryData: { name: string; value: number }[];
teamData: { name: string; value: number }[];
todoList: {
pending: any[];
processing: any[];
reminder: any[];
};
loading: boolean;
}
const USE_MOCK = import.meta.env.DEV; // 开发环境用 mock
const workbenchStore = createStore<WorkbenchState>({
stats: { pending: 0, processing: 0, resolved: 0, overdue: 0 },
trendData: { dates: [], created: [], resolved: [] },
categoryData: [],
teamData: [],
todoList: { pending: [], processing: [], reminder: [] },
loading: false,
});
export const useWorkbenchStore = workbenchStore.useStore;
export const setWorkbenchState = workbenchStore.setState;
export const fetchWorkbenchData = async () => {
setWorkbenchState({ loading: true });
try {
if (USE_MOCK) {
// 模拟请求延迟
await new Promise((r) => setTimeout(r, 500));
setWorkbenchState({
stats: mockStats,
trendData: mockTrendData,
categoryData: mockCategoryData,
teamData: mockTeamData,
todoList: mockTodoList,
loading: false,
});
} else {
const data = await workbenchApi.getOverview();
setWorkbenchState({ ...data, loading: false });
}
} catch {
setWorkbenchState({ loading: false });
}
};
3.5.2 Mock 数据文件
typescript
// src/pages/workbench/mock/index.ts
export const mockStats = {
pending: 28,
processing: 15,
resolved: 142,
overdue: 6,
};
export const mockTrendData = {
dates: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
created: [12, 19, 15, 22, 18, 8, 5],
resolved: [10, 16, 18, 20, 15, 6, 4],
};
export const mockCategoryData = [
{ name: '系统问题', value: 35 },
{ name: '账号权限', value: 28 },
{ name: '业务咨询', value: 22 },
{ name: '功能建议', value: 15 },
{ name: '其他', value: 10 },
];
export const mockTeamData = [
{ name: '张三', value: 32 },
{ name: '李四', value: 28 },
{ name: '王五', value: 24 },
{ name: '赵六', value: 20 },
{ name: '钱七', value: 16 },
];
export const mockTodoList = {
pending: Array.from({ length: 8 }).map((_, i) => ({
id: `TK${1000 + i}`,
title: `待处理任务 ${i + 1}`,
priority: (i % 4) + 1,
creator: ['张三', '李四', '王五', '赵六'][i % 4],
createdAt: new Date(Date.now() - i * 3600000).toISOString(),
})),
processing: Array.from({ length: 5 }).map((_, i) => ({
id: `TK${2000 + i}`,
title: `处理中任务 ${i + 1}`,
priority: (i % 3) + 1,
creator: ['张三', '李四', '王五'][i % 3],
createdAt: new Date(Date.now() - i * 7200000).toISOString(),
})),
reminder: Array.from({ length: 3 }).map((_, i) => ({
id: `TK${3000 + i}`,
title: `提醒任务 ${i + 1}`,
priority: i + 1,
creator: ['张三', '李四'][i % 2],
createdAt: new Date(Date.now() - i * 1800000).toISOString(),
})),
};
本章小结
| 知识点 | 关键内容 |
|---|---|
| 布局设计 | Row + Col 栅格系统,左侧列表 + 右侧看板的双栏布局 |
| 统计卡片 | requestAnimationFrame 实现数字滚动动效,趋势指标用颜色+箭头 |
| ECharts | echarts-for-react 封装,折线图/饼图/柱状图三种常用图表 |
| 待办队列 | Tabs 切换、List 列表、点击跳转详情 |
| Mock 方案 | 开发环境走 mock 数据,生产环境走真实接口,通过环境变量切换 |
下一章预告:我们进入核心业务模块------任务单管理,学习数据模型设计、高级筛选、状态流转等复杂业务场景的实现。