iT邦幫忙

2025 iThome 鐵人賽

DAY 28
0
Modern Web

30 天製作工作室 SaaS 產品 (前端篇)系列 第 28

Day 28: 30天打造SaaS產品前端篇-即時儀表板與資料視覺化

  • 分享至 

  • xImage
  •  

前情提要

經過 Day 27 的即時通知系統建置,我們已經可以即時推送訊息給使用者。今天我們要實作 即時儀表板與資料視覺化系統,這是 SaaS 產品的核心功能之一。一個好的儀表板不僅要美觀,更要能讓用戶快速理解資料、發現問題、做出決策。我們將使用 Recharts 打造企業級的視覺化系統,並整合 react-grid-layout 實現自訂拖拽佈局。

儀表板架構設計

/**
 * 儀表板完整架構
 *
 * ┌──────────────────────────────────────────────┐
 * │         Dashboard Architecture              │
 * └──────────────────────────────────────────────┘
 *
 * 1. Layout System (佈局系統)
 * ┌─────────────────────────────────────┐
 * │  react-grid-layout                  │
 * │  ├─ Responsive Grid                 │
 * │  ├─ Drag & Drop                     │
 * │  ├─ Resize                          │
 * │  └─ Persist Layout                  │
 * └─────────────────────────────────────┘
 *
 * 2. Widget System (組件系統)
 * ┌─────────────────────────────────────┐
 * │  Widget Types                       │
 * │  ├─ KPI Card                        │
 * │  │  └─ 單一指標顯示                 │
 * │  ├─ Line Chart                      │
 * │  │  └─ 時間序列趨勢                 │
 * │  ├─ Bar Chart                       │
 * │  │  └─ 分類比較                     │
 * │  ├─ Pie/Donut Chart                │
 * │  │  └─ 比例分佈                     │
 * │  ├─ Area Chart                      │
 * │  │  └─ 累積趨勢                     │
 * │  └─ Table Widget                    │
 * │     └─ 詳細資料列表                 │
 * └─────────────────────────────────────┘
 *
 * 3. Data Flow (資料流)
 * ┌─────────────────────────────────────┐
 * │  WebSocket (即時)                   │
 * │  ↓                                  │
 * │  TanStack Query (快取)              │
 * │  ↓                                  │
 * │  Zustand Store (狀態)               │
 * │  ↓                                  │
 * │  Recharts (渲染)                    │
 * └─────────────────────────────────────┘
 *
 * 4. Features (功能)
 * ┌─────────────────────────────────────┐
 * │  ✅ 即時資料更新                     │
 * │  ✅ 時間範圍選擇                     │
 * │  ✅ 資料匯出 (CSV, Excel, PDF)      │
 * │  ✅ 自訂佈局                         │
 * │  ✅ 響應式設計                       │
 * │  ✅ 深色模式                         │
 * │  ✅ 載入骨架屏                       │
 * │  ✅ 錯誤邊界                         │
 * └─────────────────────────────────────┘
 *
 * 圖表庫比較:
 * - Recharts: 聲明式、React 原生、輕量
 * - Chart.js: 功能豐富、社群大、需封裝
 * - Victory: 動畫豐富、體積較大
 * - Kyo 選擇: Recharts (React 友善、足夠強大)
 */

儀表板資料模型

// src/types/dashboard.ts
import { ReactNode } from 'react';

/**
 * Widget 類型
 */
export type WidgetType =
  | 'kpi'
  | 'line-chart'
  | 'bar-chart'
  | 'pie-chart'
  | 'area-chart'
  | 'table';

/**
 * 時間範圍
 */
export type TimeRange =
  | '1h'
  | '6h'
  | '24h'
  | '7d'
  | '30d'
  | 'custom';

/**
 * Widget 配置
 */
export interface WidgetConfig {
  id: string;
  type: WidgetType;
  title: string;
  description?: string;
  dataSource: string; // API endpoint
  refreshInterval?: number; // 秒
  layout: {
    x: number;
    y: number;
    w: number;
    h: number;
    minW?: number;
    minH?: number;
    maxW?: number;
    maxH?: number;
  };
  config?: Record<string, any>;
}

/**
 * 儀表板配置
 */
export interface DashboardConfig {
  id: string;
  name: string;
  description?: string;
  widgets: WidgetConfig[];
  timeRange: TimeRange;
  customTimeRange?: {
    start: Date;
    end: Date;
  };
  autoRefresh: boolean;
  refreshInterval: number; // 秒
}

/**
 * KPI 資料
 */
export interface KPIData {
  value: number;
  unit?: string;
  trend?: {
    direction: 'up' | 'down' | 'stable';
    percentage: number;
    period: string;
  };
  target?: number;
}

/**
 * 時間序列資料點
 */
export interface TimeSeriesDataPoint {
  timestamp: Date | string;
  value: number;
  label?: string;
}

/**
 * 圖表資料
 */
export interface ChartData {
  labels: string[];
  datasets: Array<{
    label: string;
    data: number[];
    color?: string;
  }>;
}

Dashboard Layout Container

// src/components/Dashboard/DashboardLayout.tsx
import { useState, useCallback, useMemo } from 'react';
import { Responsive, WidthProvider, Layout } from 'react-grid-layout';
import {
  Container,
  Group,
  Button,
  ActionIcon,
  Select,
  Menu,
  Stack,
  Title,
  Text,
} from '@mantine/core';
import {
  IconPlus,
  IconSettings,
  IconDownload,
  IconRefresh,
  IconEdit,
} from '@tabler/icons-react';
import { DashboardConfig, WidgetConfig } from '../../types/dashboard';
import { WidgetContainer } from './WidgetContainer';
import { useDashboard } from '../../hooks/useDashboard';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';

const ResponsiveGridLayout = WidthProvider(Responsive);

interface DashboardLayoutProps {
  dashboardId: string;
}

export function DashboardLayout({ dashboardId }: DashboardLayoutProps) {
  const {
    config,
    updateLayout,
    addWidget,
    removeWidget,
    refreshAll,
    exportDashboard,
  } = useDashboard(dashboardId);

  const [isEditing, setIsEditing] = useState(false);

  /**
   * 處理佈局變更
   */
  const handleLayoutChange = useCallback(
    (layout: Layout[]) => {
      if (!isEditing) return;

      const updatedWidgets = config.widgets.map((widget) => {
        const layoutItem = layout.find((l) => l.i === widget.id);
        if (!layoutItem) return widget;

        return {
          ...widget,
          layout: {
            x: layoutItem.x,
            y: layoutItem.y,
            w: layoutItem.w,
            h: layoutItem.h,
          },
        };
      });

      updateLayout(updatedWidgets);
    },
    [isEditing, config.widgets, updateLayout]
  );

  /**
   * Grid Layout 配置
   */
  const layouts = useMemo(() => {
    return {
      lg: config.widgets.map((widget) => ({
        i: widget.id,
        x: widget.layout.x,
        y: widget.layout.y,
        w: widget.layout.w,
        h: widget.layout.h,
        minW: widget.layout.minW,
        minH: widget.layout.minH,
        maxW: widget.layout.maxW,
        maxH: widget.layout.maxH,
      })),
    };
  }, [config.widgets]);

  return (
    <Container size="xl" my="xl">
      <Stack spacing="xl">
        {/* 標題列 */}
        <Group position="apart">
          <div>
            <Title order={2}>{config.name}</Title>
            {config.description && (
              <Text size="sm" color="dimmed">
                {config.description}
              </Text>
            )}
          </div>

          <Group spacing="xs">
            {/* 時間範圍選擇 */}
            <Select
              value={config.timeRange}
              data={[
                { value: '1h', label: '過去 1 小時' },
                { value: '6h', label: '過去 6 小時' },
                { value: '24h', label: '過去 24 小時' },
                { value: '7d', label: '過去 7 天' },
                { value: '30d', label: '過去 30 天' },
                { value: 'custom', label: '自訂範圍' },
              ]}
              style={{ width: 150 }}
            />

            {/* 重新整理 */}
            <ActionIcon
              variant="subtle"
              size="lg"
              onClick={refreshAll}
              title="重新整理"
            >
              <IconRefresh size={20} />
            </ActionIcon>

            {/* 編輯模式 */}
            <Button
              variant={isEditing ? 'filled' : 'light'}
              leftIcon={<IconEdit size={18} />}
              onClick={() => setIsEditing(!isEditing)}
            >
              {isEditing ? '完成編輯' : '編輯佈局'}
            </Button>

            {/* 更多選項 */}
            <Menu position="bottom-end" shadow="md">
              <Menu.Target>
                <ActionIcon variant="subtle" size="lg">
                  <IconSettings size={20} />
                </ActionIcon>
              </Menu.Target>

              <Menu.Dropdown>
                <Menu.Label>Widget 管理</Menu.Label>
                <Menu.Item icon={<IconPlus size={14} />} onClick={() => {}}>
                  新增 Widget
                </Menu.Item>

                <Menu.Divider />

                <Menu.Label>匯出</Menu.Label>
                <Menu.Item
                  icon={<IconDownload size={14} />}
                  onClick={() => exportDashboard('pdf')}
                >
                  匯出為 PDF
                </Menu.Item>
                <Menu.Item
                  icon={<IconDownload size={14} />}
                  onClick={() => exportDashboard('png')}
                >
                  匯出為圖片
                </Menu.Item>
              </Menu.Dropdown>
            </Menu>
          </Group>
        </Group>

        {/* Grid Layout */}
        <ResponsiveGridLayout
          className="layout"
          layouts={layouts}
          breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
          cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
          rowHeight={100}
          isDraggable={isEditing}
          isResizable={isEditing}
          onLayoutChange={handleLayoutChange}
          draggableHandle=".widget-drag-handle"
          compactType="vertical"
          preventCollision={false}
        >
          {config.widgets.map((widget) => (
            <div key={widget.id}>
              <WidgetContainer
                widget={widget}
                isEditing={isEditing}
                onRemove={() => removeWidget(widget.id)}
                timeRange={config.timeRange}
              />
            </div>
          ))}
        </ResponsiveGridLayout>

        {/* 空狀態 */}
        {config.widgets.length === 0 && (
          <Stack align="center" spacing="md" py={60}>
            <Text size="lg" color="dimmed">
              尚未新增任何 Widget
            </Text>
            <Button leftIcon={<IconPlus />} onClick={() => {}}>
              新增第一個 Widget
            </Button>
          </Stack>
        )}
      </Stack>
    </Container>
  );
}

Widget Container

// src/components/Dashboard/WidgetContainer.tsx
import { Suspense, lazy } from 'react';
import {
  Card,
  Group,
  Text,
  ActionIcon,
  Menu,
  Loader,
  Center,
  Box,
} from '@mantine/core';
import {
  IconGripVertical,
  IconDots,
  IconRefresh,
  IconTrash,
  IconSettings,
  IconDownload,
} from '@tabler/icons-react';
import { WidgetConfig, TimeRange } from '../../types/dashboard';
import { useWidgetData } from '../../hooks/useWidgetData';
import { ErrorBoundary } from '../ErrorBoundary';

// Lazy load widget components
const KPIWidget = lazy(() => import('./widgets/KPIWidget'));
const LineChartWidget = lazy(() => import('./widgets/LineChartWidget'));
const BarChartWidget = lazy(() => import('./widgets/BarChartWidget'));
const PieChartWidget = lazy(() => import('./widgets/PieChartWidget'));
const AreaChartWidget = lazy(() => import('./widgets/AreaChartWidget'));
const TableWidget = lazy(() => import('./widgets/TableWidget'));

interface WidgetContainerProps {
  widget: WidgetConfig;
  isEditing: boolean;
  onRemove: () => void;
  timeRange: TimeRange;
}

export function WidgetContainer({
  widget,
  isEditing,
  onRemove,
  timeRange,
}: WidgetContainerProps) {
  const { data, isLoading, error, refetch } = useWidgetData(
    widget.dataSource,
    timeRange,
    widget.refreshInterval
  );

  /**
   * 渲染對應的 Widget
   */
  const renderWidget = () => {
    const commonProps = { data, config: widget.config };

    switch (widget.type) {
      case 'kpi':
        return <KPIWidget {...commonProps} />;
      case 'line-chart':
        return <LineChartWidget {...commonProps} />;
      case 'bar-chart':
        return <BarChartWidget {...commonProps} />;
      case 'pie-chart':
        return <PieChartWidget {...commonProps} />;
      case 'area-chart':
        return <AreaChartWidget {...commonProps} />;
      case 'table':
        return <TableWidget {...commonProps} />;
      default:
        return <Text>Unknown widget type: {widget.type}</Text>;
    }
  };

  return (
    <Card
      shadow="sm"
      p="md"
      radius="md"
      withBorder
      style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
    >
      {/* Header */}
      <Group position="apart" mb="xs">
        <Group spacing="xs">
          {isEditing && (
            <Box className="widget-drag-handle" style={{ cursor: 'grab' }}>
              <IconGripVertical size={18} color="gray" />
            </Box>
          )}
          <div>
            <Text weight={500} size="sm">
              {widget.title}
            </Text>
            {widget.description && (
              <Text size="xs" color="dimmed">
                {widget.description}
              </Text>
            )}
          </div>
        </Group>

        <Group spacing={4}>
          <ActionIcon size="sm" variant="subtle" onClick={() => refetch()}>
            <IconRefresh size={16} />
          </ActionIcon>

          <Menu position="bottom-end" shadow="sm">
            <Menu.Target>
              <ActionIcon size="sm" variant="subtle">
                <IconDots size={16} />
              </ActionIcon>
            </Menu.Target>

            <Menu.Dropdown>
              <Menu.Item icon={<IconRefresh size={14} />} onClick={() => refetch()}>
                重新整理
              </Menu.Item>
              <Menu.Item icon={<IconDownload size={14} />} onClick={() => {}}>
                匯出資料
              </Menu.Item>
              <Menu.Item icon={<IconSettings size={14} />} onClick={() => {}}>
                設定
              </Menu.Item>

              {isEditing && (
                <>
                  <Menu.Divider />
                  <Menu.Item
                    icon={<IconTrash size={14} />}
                    color="red"
                    onClick={onRemove}
                  >
                    移除
                  </Menu.Item>
                </>
              )}
            </Menu.Dropdown>
          </Menu>
        </Group>
      </Group>

      {/* Content */}
      <Box style={{ flex: 1, minHeight: 0 }}>
        <ErrorBoundary>
          <Suspense
            fallback={
              <Center style={{ height: '100%' }}>
                <Loader size="md" />
              </Center>
            }
          >
            {isLoading ? (
              <Center style={{ height: '100%' }}>
                <Loader size="md" />
              </Center>
            ) : error ? (
              <Center style={{ height: '100%' }}>
                <Text color="red" size="sm">
                  載入失敗: {error.message}
                </Text>
              </Center>
            ) : (
              renderWidget()
            )}
          </Suspense>
        </ErrorBoundary>
      </Box>
    </Card>
  );
}

KPI Widget

// src/components/Dashboard/widgets/KPIWidget.tsx
import { Stack, Group, Text, ThemeIcon, RingProgress, Box } from '@mantine/core';
import { IconTrendingUp, IconTrendingDown, IconMinus } from '@tabler/icons-react';
import { KPIData } from '../../../types/dashboard';

interface KPIWidgetProps {
  data: KPIData;
  config?: {
    format?: 'number' | 'currency' | 'percentage';
    showProgress?: boolean;
  };
}

export default function KPIWidget({ data, config }: KPIWidgetProps) {
  const format = config?.format || 'number';
  const showProgress = config?.showProgress ?? true;

  /**
   * 格式化值
   */
  const formatValue = (value: number): string => {
    switch (format) {
      case 'currency':
        return new Intl.NumberFormat('zh-TW', {
          style: 'currency',
          currency: 'TWD',
        }).format(value);
      case 'percentage':
        return `${value.toFixed(1)}%`;
      default:
        return new Intl.NumberFormat('zh-TW').format(value);
    }
  };

  /**
   * 計算進度百分比
   */
  const progressPercentage = data.target
    ? Math.min((data.value / data.target) * 100, 100)
    : 0;

  /**
   * 趨勢圖示與顏色
   */
  const getTrendIcon = () => {
    if (!data.trend) return null;

    const iconProps = { size: 20 };

    switch (data.trend.direction) {
      case 'up':
        return <IconTrendingUp {...iconProps} color="green" />;
      case 'down':
        return <IconTrendingDown {...iconProps} color="red" />;
      case 'stable':
        return <IconMinus {...iconProps} color="gray" />;
    }
  };

  const getTrendColor = () => {
    if (!data.trend) return 'gray';

    switch (data.trend.direction) {
      case 'up':
        return 'green';
      case 'down':
        return 'red';
      case 'stable':
        return 'gray';
    }
  };

  return (
    <Stack spacing="md" style={{ height: '100%' }} justify="center">
      {/* 主要數值 */}
      <Group position="apart" align="flex-start">
        <div>
          <Text size={48} weight={700} style={{ lineHeight: 1 }}>
            {formatValue(data.value)}
          </Text>
          {data.unit && (
            <Text size="sm" color="dimmed" mt={4}>
              {data.unit}
            </Text>
          )}
        </div>

        {/* 進度環 (如果有目標值) */}
        {showProgress && data.target && (
          <RingProgress
            size={80}
            thickness={8}
            sections={[{ value: progressPercentage, color: 'blue' }]}
            label={
              <Text size="xs" align="center" weight={700}>
                {progressPercentage.toFixed(0)}%
              </Text>
            }
          />
        )}
      </Group>

      {/* 趨勢資訊 */}
      {data.trend && (
        <Group spacing="xs">
          <ThemeIcon
            variant="light"
            color={getTrendColor()}
            size="sm"
            radius="xl"
          >
            {getTrendIcon()}
          </ThemeIcon>
          <Text size="sm" color={getTrendColor()}>
            {data.trend.direction === 'up' ? '+' : data.trend.direction === 'down' ? '-' : ''}
            {data.trend.percentage.toFixed(1)}%
          </Text>
          <Text size="sm" color="dimmed">
            {data.trend.period}
          </Text>
        </Group>
      )}

      {/* 目標資訊 */}
      {data.target && (
        <Text size="xs" color="dimmed">
          目標: {formatValue(data.target)}
        </Text>
      )}
    </Stack>
  );
}

Line Chart Widget

// src/components/Dashboard/widgets/LineChartWidget.tsx
import {
  LineChart,
  Line,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  Legend,
  ResponsiveContainer,
} from 'recharts';
import { useTheme } from '@mantine/core';
import { TimeSeriesDataPoint } from '../../../types/dashboard';
import { format } from 'date-fns';
import { zhTW } from 'date-fns/locale';

interface LineChartWidgetProps {
  data: TimeSeriesDataPoint[];
  config?: {
    dataKey?: string;
    xAxisKey?: string;
    showGrid?: boolean;
    showLegend?: boolean;
    color?: string;
  };
}

export default function LineChartWidget({ data, config }: LineChartWidgetProps) {
  const theme = useTheme();

  const dataKey = config?.dataKey || 'value';
  const xAxisKey = config?.xAxisKey || 'timestamp';
  const showGrid = config?.showGrid ?? true;
  const showLegend = config?.showLegend ?? true;
  const color = config?.color || theme.colors.blue[6];

  /**
   * 格式化資料
   */
  const formattedData = data.map((point) => ({
    ...point,
    timestamp: typeof point.timestamp === 'string'
      ? point.timestamp
      : format(new Date(point.timestamp), 'HH:mm', { locale: zhTW }),
  }));

  return (
    <ResponsiveContainer width="100%" height="100%">
      <LineChart data={formattedData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
        {showGrid && (
          <CartesianGrid
            strokeDasharray="3 3"
            stroke={theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3]}
          />
        )}
        <XAxis
          dataKey={xAxisKey}
          stroke={theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.colors.gray[7]}
          style={{ fontSize: 12 }}
        />
        <YAxis
          stroke={theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.colors.gray[7]}
          style={{ fontSize: 12 }}
        />
        <Tooltip
          contentStyle={{
            backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
            border: `1px solid ${theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3]}`,
            borderRadius: theme.radius.sm,
          }}
        />
        {showLegend && <Legend />}
        <Line
          type="monotone"
          dataKey={dataKey}
          stroke={color}
          strokeWidth={2}
          dot={{ fill: color, r: 4 }}
          activeDot={{ r: 6 }}
          name="數值"
        />
      </LineChart>
    </ResponsiveContainer>
  );
}

Bar Chart Widget

// src/components/Dashboard/widgets/BarChartWidget.tsx
import {
  BarChart,
  Bar,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  Legend,
  ResponsiveContainer,
} from 'recharts';
import { useTheme } from '@mantine/core';
import { ChartData } from '../../../types/dashboard';

interface BarChartWidgetProps {
  data: ChartData;
  config?: {
    horizontal?: boolean;
    stacked?: boolean;
    showGrid?: boolean;
    showLegend?: boolean;
  };
}

export default function BarChartWidget({ data, config }: BarChartWidgetProps) {
  const theme = useTheme();

  const horizontal = config?.horizontal ?? false;
  const stacked = config?.stacked ?? false;
  const showGrid = config?.showGrid ?? true;
  const showLegend = config?.showLegend ?? true;

  /**
   * 轉換資料格式
   */
  const chartData = data.labels.map((label, index) => {
    const item: any = { name: label };
    data.datasets.forEach((dataset) => {
      item[dataset.label] = dataset.data[index];
    });
    return item;
  });

  const colors = [
    theme.colors.blue[6],
    theme.colors.green[6],
    theme.colors.orange[6],
    theme.colors.red[6],
    theme.colors.violet[6],
  ];

  const ChartComponent = horizontal ? BarChart : BarChart;
  const XAxisComponent = horizontal ? YAxis : XAxis;
  const YAxisComponent = horizontal ? XAxis : YAxis;

  return (
    <ResponsiveContainer width="100%" height="100%">
      <ChartComponent
        data={chartData}
        layout={horizontal ? 'vertical' : 'horizontal'}
        margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
      >
        {showGrid && (
          <CartesianGrid
            strokeDasharray="3 3"
            stroke={theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3]}
          />
        )}
        <XAxisComponent
          dataKey="name"
          stroke={theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.colors.gray[7]}
          style={{ fontSize: 12 }}
        />
        <YAxisComponent
          stroke={theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.colors.gray[7]}
          style={{ fontSize: 12 }}
        />
        <Tooltip
          contentStyle={{
            backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
            border: `1px solid ${theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3]}`,
            borderRadius: theme.radius.sm,
          }}
        />
        {showLegend && <Legend />}
        {data.datasets.map((dataset, index) => (
          <Bar
            key={dataset.label}
            dataKey={dataset.label}
            fill={dataset.color || colors[index % colors.length]}
            stackId={stacked ? 'stack' : undefined}
            radius={[4, 4, 0, 0]}
          />
        ))}
      </ChartComponent>
    </ResponsiveContainer>
  );
}

Pie Chart Widget

// src/components/Dashboard/widgets/PieChartWidget.tsx
import {
  PieChart,
  Pie,
  Cell,
  Tooltip,
  Legend,
  ResponsiveContainer,
} from 'recharts';
import { useTheme } from '@mantine/core';

interface PieChartWidgetProps {
  data: Array<{ name: string; value: number }>;
  config?: {
    donut?: boolean;
    showLabels?: boolean;
    showLegend?: boolean;
  };
}

export default function PieChartWidget({ data, config }: PieChartWidgetProps) {
  const theme = useTheme();

  const donut = config?.donut ?? false;
  const showLabels = config?.showLabels ?? true;
  const showLegend = config?.showLegend ?? true;

  const COLORS = [
    theme.colors.blue[6],
    theme.colors.green[6],
    theme.colors.orange[6],
    theme.colors.red[6],
    theme.colors.violet[6],
    theme.colors.cyan[6],
  ];

  /**
   * 自訂標籤
   */
  const renderLabel = (entry: any) => {
    const percent = ((entry.value / entry.payload.total) * 100).toFixed(1);
    return `${entry.name}: ${percent}%`;
  };

  return (
    <ResponsiveContainer width="100%" height="100%">
      <PieChart>
        <Pie
          data={data}
          cx="50%"
          cy="50%"
          labelLine={showLabels}
          label={showLabels ? renderLabel : false}
          outerRadius="80%"
          innerRadius={donut ? "50%" : "0%"}
          fill="#8884d8"
          dataKey="value"
        >
          {data.map((entry, index) => (
            <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
          ))}
        </Pie>
        <Tooltip
          contentStyle={{
            backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
            border: `1px solid ${theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3]}`,
            borderRadius: theme.radius.sm,
          }}
        />
        {showLegend && <Legend />}
      </PieChart>
    </ResponsiveContainer>
  );
}

資料匯出功能

// src/utils/export.ts
import { saveAs } from 'file-saver';
import * as XLSX from 'xlsx';
import jsPDF from 'jspdf';
import html2canvas from 'html2canvas';

/**
 * 匯出為 CSV
 */
export function exportToCSV(data: any[], filename: string) {
  const headers = Object.keys(data[0]);
  const csvContent = [
    headers.join(','),
    ...data.map((row) =>
      headers.map((header) => JSON.stringify(row[header] ?? '')).join(',')
    ),
  ].join('\n');

  const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
  saveAs(blob, `${filename}.csv`);
}

/**
 * 匯出為 Excel
 */
export function exportToExcel(data: any[], filename: string) {
  const worksheet = XLSX.utils.json_to_sheet(data);
  const workbook = XLSX.utils.book_new();
  XLSX.utils.book_append_sheet(workbook, worksheet, 'Data');

  // 設定欄寬
  const maxWidth = data.reduce((w: any, r: any) => {
    return Object.keys(r).reduce((acc, key) => {
      const value = r[key] ? r[key].toString() : '';
      acc[key] = Math.max(acc[key] || 10, value.length);
      return acc;
    }, w);
  }, {});

  worksheet['!cols'] = Object.keys(maxWidth).map((key) => ({
    wch: maxWidth[key],
  }));

  XLSX.writeFile(workbook, `${filename}.xlsx`);
}

/**
 * 匯出為 PDF
 */
export async function exportToPDF(elementId: string, filename: string) {
  const element = document.getElementById(elementId);
  if (!element) {
    throw new Error('Element not found');
  }

  const canvas = await html2canvas(element, {
    scale: 2,
    logging: false,
    useCORS: true,
  });

  const imgData = canvas.toDataURL('image/png');
  const pdf = new jsPDF({
    orientation: canvas.width > canvas.height ? 'landscape' : 'portrait',
    unit: 'px',
    format: [canvas.width, canvas.height],
  });

  pdf.addImage(imgData, 'PNG', 0, 0, canvas.width, canvas.height);
  pdf.save(`${filename}.pdf`);
}

今日總結

我們今天完成了 Kyo System 的即時儀表板系統:

核心功能

  1. Layout System: react-grid-layout 拖拽佈局
  2. Widget 系統: KPI, Line, Bar, Pie, Area, Table
  3. 即時更新: WebSocket + TanStack Query
  4. 響應式設計: 自動適應螢幕大小
  5. 資料匯出: CSV, Excel, PDF
  6. 深色模式: 完整支援
  7. 效能優化: Lazy loading, Memo

技術分析

Recharts vs Chart.js:

  • Recharts: React 原生、聲明式
  • Chart.js: 功能強大、需封裝
  • 💡 Kyo 選擇 Recharts (更 React)

佈局策略:

  • Grid-based: 12 欄網格
  • 響應式斷點: lg, md, sm, xs
  • 拖拽與調整大小
  • 💡 平衡彈性與一致性

效能優化:

  • Lazy loading widgets
  • React.memo 避免重渲染
  • 虛擬化長列表
  • 💡 數千個資料點流暢渲染

匯出策略:

  • CSV: 輕量、相容性好
  • Excel: 格式豐富、支援公式
  • PDF: 視覺化保留完整
  • 💡 依需求選擇格式

儀表板檢查清單

  • ✅ Grid Layout 系統
  • ✅ Widget Container
  • ✅ KPI Widget
  • ✅ Line Chart Widget
  • ✅ Bar Chart Widget
  • ✅ Pie Chart Widget
  • ✅ 即時資料更新
  • ✅ 時間範圍選擇
  • ✅ 資料匯出
  • ✅ 響應式設計
  • ✅ 深色模式
  • ✅ 載入與錯誤處理

上一篇
Day 27: 30天打造SaaS產品前端篇-即時通知系統與 WebSocket 整合
系列文
30 天製作工作室 SaaS 產品 (前端篇)28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言