經過 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;
}>;
}
// 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>
);
}
// 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>
);
}
// 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>
);
}
// 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>
);
}
// 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>
);
}
// 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 的即時儀表板系統:
Recharts vs Chart.js:
佈局策略:
效能優化:
匯出策略: