第 3 章:工作台——数据看板

本章学习目标

  • 学会设计数据看板类页面的布局
  • 掌握统计卡片组件的实现(数字动效、趋势指标)
  • 集成 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 数据,生产环境走真实接口,通过环境变量切换

下一章预告:我们进入核心业务模块------任务单管理,学习数据模型设计、高级筛选、状态流转等复杂业务场景的实现。

相关推荐
夏天要喝冰可乐1 小时前
Antigravity + Blender MCP(下):3D 智慧仓储数字孪生进阶实战
前端·webgl·three.js
国奉1 小时前
iOS 音频格式转换怎么实现?从 AVAudioFile、AAC、MP3 到 FLAC 与批量转码架构
前端·后端
易朵朵1 小时前
package.json 中的 `vue-router` 详解
前端·vue.js
呃呃呃呃ex1 小时前
3. JavaScript 异步编程:Promise、async/await、Generator 与异步调度
前端·javascript
IT_陈寒1 小时前
Vite的静态资源引用把我坑惨了
前端·人工智能·后端
风骏时光牛马1 小时前
AI工作流全链路自动化落地实践
前端
kyriewen1 小时前
5 次优化让首屏快 3.6 秒,只有 1 次是改代码
前端·javascript·程序员
编程老船长1 小时前
模型中立——把大模型做成"可替换零件",而不是焊死在业务里
java·前端·后端
禁止摆烂_才浅1 小时前
Axios 完整封装合集(鉴权 + 重复拦截 + Loading + 缓存 + 统一错误 + 请求重试|全代码逐行注释)
前端·javascript·axios