(null);
+
+ // Format-specific options
+ const [quality, setQuality] = useState(90);
+ const [resolution, setResolution] = useState(2);
+ const [pageSize, setPageSize] = useState('A4');
+ const [orientation, setOrientation] = useState<'portrait' | 'landscape'>('landscape');
+ const [sheetName, setSheetName] = useState('Sheet1');
+ const [pptStyle, setPptStyle] = useState('minimal');
+ const [htmlTitle, setHtmlTitle] = useState('DataViz Pro Export');
+
+ const chartIds = useMemo(() => charts.map((c) => c.id), [charts]);
+
+ const handleClose = useCallback(() => {
+ setVisible(false);
+ setSelectedFormat(null);
+ }, [setVisible]);
+
+ const handleDoExport = useCallback(async () => {
+ if (!selectedFormat) {
+ message.warning('请先选择导出格式');
+ return;
+ }
+ if (chartIds.length === 0) {
+ message.warning('当前看板没有图表可供导出');
+ return;
+ }
+
+ try {
+ await handleExport({
+ format: selectedFormat,
+ chartIds,
+ datasetId: activeDataSetId ?? '',
+ fileName: `dataviz-export.${selectedFormat === 'template' ? 'json' : selectedFormat}`,
+ });
+ message.success('导出成功');
+ handleClose();
+ } catch {
+ message.error(error ?? '导出失败,请重试');
+ }
+ }, [selectedFormat, chartIds, activeDataSetId, handleExport, handleClose, error]);
+
+ const renderFormatOptions = () => {
+ if (!selectedFormat) return null;
+
+ const isImage = selectedFormat === 'png' || selectedFormat === 'jpg';
+ const isSvg = selectedFormat === 'svg';
+ const isPdf = selectedFormat === 'pdf';
+ const isExcel = selectedFormat === 'excel';
+ const isPpt = selectedFormat === 'ppt';
+ const isHtml = selectedFormat === 'html';
+
+ return (
+
+
+ 导出选项
+
+
+ {isImage && (
+
+
+ 图片质量: {quality}%
+
+
+
+ 分辨率
+
+
+
+
+ )}
+
+ {isSvg && (
+
+ 分辨率
+
+
+
+ )}
+
+ {isPdf && (
+
+
+ 页面大小
+
+
+
+
+ 页面方向
+
+ setOrientation(e.target.value)}
+ style={{ marginTop: 4 }}
+ >
+ 纵向
+ 横向
+
+
+
+ )}
+
+ {isExcel && (
+
+ 工作表名称
+
+ setSheetName(e.target.value)}
+ placeholder="Sheet1"
+ style={{ width: 200, marginTop: 4 }}
+ maxLength={31}
+ />
+
+ )}
+
+ {isPpt && (
+
+ 演示模板风格
+
+
+
+ )}
+
+ {isHtml && (
+
+ 页面标题
+
+ setHtmlTitle(e.target.value)}
+ placeholder="输入页面标题"
+ style={{ width: 300, marginTop: 4 }}
+ maxLength={100}
+ />
+
+ )}
+
+ );
+ };
+
+ return (
+
+
+
+
+ }
+ destroyOnClose
+ >
+
+
+
+ 选择导出格式(当前 {chartIds.length} 个图表)
+
+
+
+ {FORMATS.map((fmt) => {
+ const isSelected = selectedFormat === fmt.key;
+ return (
+
+ setSelectedFormat(fmt.key)}
+ style={{
+ textAlign: 'center',
+ borderColor: isSelected ? fmt.color : undefined,
+ borderWidth: isSelected ? 2 : 1,
+ background: isSelected ? `${fmt.color}08` : undefined,
+ cursor: 'pointer',
+ }}
+ styles={{
+ body: { padding: '16px 8px' },
+ }}
+ >
+
+ {fmt.icon}
+
+
+ {fmt.name}
+
+
+ {fmt.description}
+
+
+
+ );
+ })}
+
+
+ {renderFormatOptions()}
+
+
+
+ );
+};
diff --git a/frontend/src/frameworks/components/export/index.ts b/frontend/src/frameworks/components/export/index.ts
new file mode 100644
index 0000000..937412d
--- /dev/null
+++ b/frontend/src/frameworks/components/export/index.ts
@@ -0,0 +1 @@
+export { ExportDialog } from './ExportDialog';
diff --git a/frontend/src/frameworks/components/layout/AppShell.tsx b/frontend/src/frameworks/components/layout/AppShell.tsx
new file mode 100644
index 0000000..7db89ba
--- /dev/null
+++ b/frontend/src/frameworks/components/layout/AppShell.tsx
@@ -0,0 +1,190 @@
+'use client';
+
+import { Layout } from 'antd';
+import { useUIStore } from '@/adapters/state/zustand/uiStore';
+
+const { Header, Sider, Content, Footer } = Layout;
+
+export function AppShell() {
+ const leftPanelCollapsed = useUIStore((s) => s.leftPanelCollapsed);
+ const rightPanelCollapsed = useUIStore((s) => s.rightPanelCollapsed);
+ const toggleLeftPanel = useUIStore((s) => s.toggleLeftPanel);
+ const toggleRightPanel = useUIStore((s) => s.toggleRightPanel);
+
+ return (
+
+ {/* Top Toolbar - 48px */}
+
+
+
+ {/* Left Panel - 280px collapsible */}
+
+
+
+ 数据 / 图表 / 字段
+
+
+ 左面板内容区域
+
+
+
+
+ {/* Center Canvas - flex grow */}
+
+
+
+
+ {/* Right Panel - 320px collapsible */}
+
+
+
+ 数据绑定 / 样式 / 交互
+
+
+ 右面板内容区域
+
+
+
+
+
+ {/* Bottom Status Bar - 32px */}
+
+
+ );
+}
diff --git a/frontend/src/frameworks/components/layout/BottomStatusBar.tsx b/frontend/src/frameworks/components/layout/BottomStatusBar.tsx
new file mode 100644
index 0000000..7c9567d
--- /dev/null
+++ b/frontend/src/frameworks/components/layout/BottomStatusBar.tsx
@@ -0,0 +1,56 @@
+'use client';
+
+import { Space, Typography } from 'antd';
+import { useAppSelector } from '@/adapters/state/redux/store';
+import { useThemeStore } from '@/adapters/state/zustand/themeStore';
+import { useInteractionStore } from '@/adapters/state/zustand/interactionStore';
+
+const { Text } = Typography;
+
+export function BottomStatusBar() {
+ const dataSets = useAppSelector((s) => s.data.dataSets);
+ const charts = useAppSelector((s) => s.chart.charts);
+ const currentTheme = useThemeStore((s) => s.currentTheme);
+ const zoomLevel = useInteractionStore((s) => s.zoomLevel);
+
+ const totalRows = dataSets.reduce((sum, ds) => sum + ds.rowCount, 0);
+
+ return (
+
+ {/* Left: dataset info */}
+
+
+ 数据集: {dataSets.length}
+
+
+ 总行数: {totalRows.toLocaleString()}
+
+
+
+ {/* Center: chart count */}
+
+
+ 图表: {charts.length}
+
+
+
+ {/* Right: theme & zoom */}
+
+
+ 主题: {currentTheme === 'light' ? '亮色' : '暗色'}
+
+
+ 缩放: {Math.round(zoomLevel * 100)}%
+
+
+
+ );
+}
diff --git a/frontend/src/frameworks/components/layout/CenterCanvas.tsx b/frontend/src/frameworks/components/layout/CenterCanvas.tsx
new file mode 100644
index 0000000..7b6a492
--- /dev/null
+++ b/frontend/src/frameworks/components/layout/CenterCanvas.tsx
@@ -0,0 +1,97 @@
+'use client';
+
+import React, { useMemo } from 'react';
+import { Responsive } from 'react-grid-layout';
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const WidthProvider = require('react-grid-layout').WidthProvider;
+import { useAppSelector } from '@/adapters/state/redux/store';
+import { useLayout } from '@/frameworks/hooks/useLayout';
+import { ChartWrapper } from '@/frameworks/components/charts/ChartWrapper';
+import { UploadOutlined } from '@ant-design/icons';
+import { useUIStore } from '@/adapters/state/zustand/uiStore';
+
+import 'react-grid-layout/css/styles.css';
+import 'react-resizable/css/styles.css';
+
+const ResponsiveGridLayout = WidthProvider(Responsive);
+
+/** Placeholder shown when the canvas is empty. */
+function DropZonePlaceholder() {
+ const setImportModalVisible = useUIStore((s) => s.setImportModalVisible);
+
+ return (
+ setImportModalVisible(true)}
+ style={{
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: '100%',
+ height: '100%',
+ minHeight: 400,
+ border: '2px dashed var(--border)',
+ borderRadius: 12,
+ cursor: 'pointer',
+ color: 'var(--text-tertiary)',
+ gap: 12,
+ }}
+ >
+
+ 拖拽文件到此处 或 点击导入数据
+
+ );
+}
+
+export function CenterCanvas() {
+ const charts = useAppSelector((s) => s.chart.charts);
+ const { layouts, onLayoutChange } = useLayout();
+
+ const gridLayouts = useMemo(() => {
+ return layouts.map((item) => ({
+ i: item.chartId,
+ x: item.x,
+ y: item.y,
+ w: item.w,
+ h: item.h,
+ }));
+ }, [layouts]);
+
+ if (charts.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
onLayoutChange(layout)}
+ isDraggable
+ isResizable
+ compactType="vertical"
+ >
+ {charts.map((chart) => (
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/frameworks/components/layout/LeftPanel.tsx b/frontend/src/frameworks/components/layout/LeftPanel.tsx
new file mode 100644
index 0000000..59bac51
--- /dev/null
+++ b/frontend/src/frameworks/components/layout/LeftPanel.tsx
@@ -0,0 +1,250 @@
+'use client';
+
+import React, { useMemo } from 'react';
+import { Tabs, Card, Tag, Empty } from 'antd';
+import {
+ DatabaseOutlined,
+ FieldNumberOutlined,
+ FontSizeOutlined,
+ CalendarOutlined,
+ PercentageOutlined,
+ GlobalOutlined,
+ QuestionOutlined,
+ BarChartOutlined,
+} from '@ant-design/icons';
+import { useUIStore } from '@/adapters/state/zustand/uiStore';
+import { useAppSelector, useAppDispatch } from '@/adapters/state/redux/store';
+import { setActiveDataSet } from '@/adapters/state/redux/dataSlice';
+import { FieldListPresenter, type FieldItemVM } from '@/adapters/presenters/FieldListPresenter';
+import {
+ CHART_TYPE_META,
+ type ChartType,
+ type ChartTypeMeta,
+} from '@/domain/valueObjects/ChartType';
+import { type FieldType } from '@/domain/valueObjects/FieldType';
+
+const FIELD_TYPE_ICON: Record = {
+ number: ,
+ text: ,
+ date: ,
+ percentage: ,
+ geo: ,
+};
+
+const FIELD_TYPE_COLOR: Record = {
+ number: 'blue',
+ text: 'green',
+ date: 'orange',
+ percentage: 'purple',
+ geo: 'cyan',
+};
+
+const CATEGORY_LABELS: Record = {
+ basic: '基础图表',
+ statistical: '统计图表',
+ relationship: '关系图表',
+ composition: '构成图表',
+ geo: '地理图表',
+ other: '其他',
+};
+
+/** DataTab: list of imported datasets */
+function DataTab() {
+ const dataSets = useAppSelector((s) => s.data.dataSets);
+ const activeDataSetId = useAppSelector((s) => s.data.activeDataSetId);
+ const dispatch = useAppDispatch();
+
+ if (dataSets.length === 0) {
+ return ;
+ }
+
+ return (
+
+ {dataSets.map((ds) => (
+
dispatch(setActiveDataSet(ds.id))}
+ >
+
+
+
+
+ {ds.fileName}
+ {ds.sheetName ? ` (${ds.sheetName})` : ''}
+
+
+ {ds.columns.length} 列 · {ds.rowCount} 行
+
+
+
+
+ ))}
+
+ );
+}
+
+/** FieldsTab: draggable field list for active dataset */
+function FieldsTab() {
+ const dataSets = useAppSelector((s) => s.data.dataSets);
+ const activeDataSetId = useAppSelector((s) => s.data.activeDataSetId);
+
+ const activeDataSet = useMemo(
+ () => dataSets.find((ds) => ds.id === activeDataSetId),
+ [dataSets, activeDataSetId],
+ );
+
+ const fieldItems: FieldItemVM[] = useMemo(() => {
+ if (!activeDataSet) return [];
+ return FieldListPresenter.toViewModels(activeDataSet.columns);
+ }, [activeDataSet]);
+
+ if (!activeDataSet) {
+ return ;
+ }
+
+ if (fieldItems.length === 0) {
+ return ;
+ }
+
+ const handleDragStart = (e: React.DragEvent, field: FieldItemVM) => {
+ e.dataTransfer.setData(
+ 'application/json',
+ JSON.stringify({ fieldName: field.name, fieldType: field.type }),
+ );
+ e.dataTransfer.effectAllowed = 'copy';
+ };
+
+ return (
+
+ {fieldItems.map((field) => (
+
handleDragStart(e, field)}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: 8,
+ padding: '6px 8px',
+ borderRadius: 4,
+ cursor: 'grab',
+ fontSize: 13,
+ border: '1px solid var(--border-light)',
+ background: 'var(--bg-secondary, transparent)',
+ }}
+ >
+
+ {FIELD_TYPE_ICON[field.type] ?? }
+
+ {field.name}
+
+ {field.type}
+
+
+ ))}
+
+ );
+}
+
+/** ChartsTab: chart type gallery grouped by category */
+function ChartsTab() {
+ const grouped = useMemo(() => {
+ const map: Record = {};
+ for (const meta of Object.values(CHART_TYPE_META)) {
+ if (!map[meta.category]) map[meta.category] = [];
+ map[meta.category].push(meta);
+ }
+ return map;
+ }, []);
+
+ return (
+
+ {Object.entries(grouped).map(([category, charts]) => (
+
+
+ {CATEGORY_LABELS[category] ?? category}
+
+
+ {charts.map((meta) => (
+
+
+ {meta.label}
+
+ ))}
+
+
+ ))}
+
+ );
+}
+
+export function LeftPanel() {
+ const leftPanelTab = useUIStore((s) => s.leftPanelTab);
+ const setLeftPanelTab = useUIStore((s) => s.setLeftPanelTab);
+
+ const tabItems = [
+ {
+ key: 'data',
+ label: '数据',
+ children: ,
+ },
+ {
+ key: 'fields',
+ label: '字段',
+ children: ,
+ },
+ {
+ key: 'charts',
+ label: '图表',
+ children: ,
+ },
+ ];
+
+ return (
+
+ setLeftPanelTab(key as any)}
+ size="small"
+ items={tabItems}
+ style={{ height: '100%' }}
+ />
+
+ );
+}
diff --git a/frontend/src/frameworks/components/layout/RightPanel.tsx b/frontend/src/frameworks/components/layout/RightPanel.tsx
new file mode 100644
index 0000000..6c77bf1
--- /dev/null
+++ b/frontend/src/frameworks/components/layout/RightPanel.tsx
@@ -0,0 +1,89 @@
+'use client';
+
+import { Tabs, Empty } from 'antd';
+import { useUIStore } from '@/adapters/state/zustand/uiStore';
+import { useAppSelector } from '@/adapters/state/redux/store';
+
+/** Placeholder for the DataBinding configuration component. */
+function DataBindingTab() {
+ return (
+
+
+ 数据绑定配置区域 — 将字段拖拽到对应轴位置
+
+
+ );
+}
+
+/** Placeholder for the StyleConfig component. */
+function StyleConfigTab() {
+ return (
+
+
+ 样式配置区域 — 标题、颜色、图例、坐标轴、标签等
+
+
+ );
+}
+
+/** Placeholder for the InteractionConfig component. */
+function InteractionConfigTab() {
+ return (
+
+
+ 交互配置区域 — 工具提示、缩放、筛选联动等
+
+
+ );
+}
+
+export function RightPanel() {
+ const rightPanelTab = useUIStore((s) => s.rightPanelTab);
+ const setRightPanelTab = useUIStore((s) => s.setRightPanelTab);
+ const activeChartId = useAppSelector((s) => s.chart.activeChartId);
+
+ if (!activeChartId) {
+ return (
+
+
+
+ );
+ }
+
+ const tabItems = [
+ {
+ key: 'bindData',
+ label: '数据',
+ children: ,
+ },
+ {
+ key: 'style',
+ label: '样式',
+ children: ,
+ },
+ {
+ key: 'interaction',
+ label: '交互',
+ children: ,
+ },
+ ];
+
+ return (
+
+ setRightPanelTab(key as any)}
+ size="small"
+ items={tabItems}
+ />
+
+ );
+}
diff --git a/frontend/src/frameworks/components/layout/TopToolbar.tsx b/frontend/src/frameworks/components/layout/TopToolbar.tsx
new file mode 100644
index 0000000..c67a6cb
--- /dev/null
+++ b/frontend/src/frameworks/components/layout/TopToolbar.tsx
@@ -0,0 +1,142 @@
+'use client';
+
+import { Button, Dropdown, Space, Switch, Tooltip } from 'antd';
+import {
+ UploadOutlined,
+ PlusOutlined,
+ ExportOutlined,
+ AppstoreOutlined,
+ BulbOutlined,
+} from '@ant-design/icons';
+import { useUIStore } from '@/adapters/state/zustand/uiStore';
+import { useThemeStore } from '@/adapters/state/zustand/themeStore';
+import { useCreateChart } from '@/frameworks/hooks/useCreateChart';
+import {
+ CHART_TYPE_META,
+ type ChartType,
+} from '@/domain/valueObjects/ChartType';
+import type { MenuProps } from 'antd';
+
+const CATEGORY_LABELS: Record = {
+ basic: '基础图表',
+ statistical: '统计图表',
+ relationship: '关系图表',
+ composition: '构成图表',
+ geo: '地理图表',
+ other: '其他',
+};
+
+function buildChartMenuItems(): MenuProps['items'] {
+ const grouped: Record = {};
+ for (const meta of Object.values(CHART_TYPE_META)) {
+ if (!grouped[meta.category]) grouped[meta.category] = [];
+ grouped[meta.category].push({ type: meta.type, label: meta.label });
+ }
+
+ const items: MenuProps['items'] = [];
+ for (const [category, charts] of Object.entries(grouped)) {
+ items.push({
+ key: `group-${category}`,
+ type: 'group',
+ label: CATEGORY_LABELS[category] ?? category,
+ children: charts.map((c) => ({
+ key: c.type,
+ label: c.label,
+ })),
+ });
+ }
+ return items;
+}
+
+export function TopToolbar() {
+ const setImportModalVisible = useUIStore((s) => s.setImportModalVisible);
+ const setExportModalVisible = useUIStore((s) => s.setExportModalVisible);
+ const setTemplateModalVisible = useUIStore((s) => s.setTemplateModalVisible);
+
+ const currentTheme = useThemeStore((s) => s.currentTheme);
+ const setTheme = useThemeStore((s) => s.setTheme);
+
+ const { handleCreate, activeDataSetId, loading: chartCreating } = useCreateChart();
+
+ const handleAddChart = async (chartType: ChartType) => {
+ try {
+ await handleCreate(chartType);
+ } catch {
+ // Error is already captured in the hook's error state
+ }
+ };
+
+ const chartMenuItems = buildChartMenuItems();
+
+ return (
+
+
+ DataViz Pro
+
+
+
+
+ }
+ onClick={() => setImportModalVisible(true)}
+ >
+ 导入数据
+
+
+
+ handleAddChart(key as ChartType),
+ }}
+ trigger={['click']}
+ disabled={chartCreating || !activeDataSetId}
+ >
+ }
+ loading={chartCreating}
+ disabled={chartCreating || !activeDataSetId}
+ >
+ 添加图表
+
+
+
+
+
+
+ setTheme(checked ? 'dark' : 'light')}
+ />
+
+
+
+
+ }
+ onClick={() => setExportModalVisible(true)}
+ >
+ 导出
+
+
+
+
+ }
+ onClick={() => setTemplateModalVisible(true)}
+ >
+ 模板
+
+
+
+
+ );
+}
diff --git a/frontend/src/frameworks/components/layout/index.ts b/frontend/src/frameworks/components/layout/index.ts
new file mode 100644
index 0000000..f51631e
--- /dev/null
+++ b/frontend/src/frameworks/components/layout/index.ts
@@ -0,0 +1,6 @@
+export { AppShell } from './AppShell';
+export { TopToolbar } from './TopToolbar';
+export { LeftPanel } from './LeftPanel';
+export { CenterCanvas } from './CenterCanvas';
+export { RightPanel } from './RightPanel';
+export { BottomStatusBar } from './BottomStatusBar';
diff --git a/frontend/src/frameworks/components/template/TemplateCard.tsx b/frontend/src/frameworks/components/template/TemplateCard.tsx
new file mode 100644
index 0000000..f55be4f
--- /dev/null
+++ b/frontend/src/frameworks/components/template/TemplateCard.tsx
@@ -0,0 +1,108 @@
+'use client';
+
+import React, { useCallback } from 'react';
+import { Card, Button, Typography, Popconfirm, Space, Tag } from 'antd';
+import {
+ AppstoreOutlined,
+ ExportOutlined,
+ DeleteOutlined,
+} from '@ant-design/icons';
+import { type Template } from '@/domain/entities/Template';
+
+const { Text, Paragraph } = Typography;
+
+const themeColors: Record = {
+ default: '#1677ff',
+ dark: '#141414',
+ light: '#f0f2f5',
+ blue: '#1890ff',
+ green: '#52c41a',
+ purple: '#722ed1',
+};
+
+export interface TemplateCardProps {
+ template: Template;
+ onApply: (templateId: string) => void;
+ onExport: (templateId: string) => void;
+ onDelete: (templateId: string) => void;
+}
+
+export const TemplateCard: React.FC = ({
+ template,
+ onApply,
+ onExport,
+ onDelete,
+}) => {
+ const bgColor = themeColors[template.theme] ?? themeColors.default;
+ const createdDate = new Date(template.createdAt).toLocaleDateString('zh-CN');
+
+ const handleApply = useCallback(() => onApply(template.id), [onApply, template.id]);
+ const handleExport = useCallback(() => onExport(template.id), [onExport, template.id]);
+ const handleDelete = useCallback(() => onDelete(template.id), [onDelete, template.id]);
+
+ return (
+
+
+
+ }
+ actions={[
+ ,
+ ,
+
+
+ ,
+ ]}
+ >
+
+
+ {template.name}
+
+ {template.charts.length} 图表
+
+ }
+ description={
+ <>
+
+ {template.description || '暂无描述'}
+
+
+ 创建于 {createdDate}
+
+ >
+ }
+ />
+
+ );
+};
diff --git a/frontend/src/frameworks/components/template/TemplateGallery.tsx b/frontend/src/frameworks/components/template/TemplateGallery.tsx
new file mode 100644
index 0000000..b739202
--- /dev/null
+++ b/frontend/src/frameworks/components/template/TemplateGallery.tsx
@@ -0,0 +1,137 @@
+'use client';
+
+import React, { useMemo, useState, useCallback } from 'react';
+import { Input, Row, Col, Button, Upload, Empty, message } from 'antd';
+import { ImportOutlined } from '@ant-design/icons';
+import type { UploadProps } from 'antd';
+import { useTemplate } from '@/frameworks/hooks/useTemplate';
+import { TemplateCard } from './TemplateCard';
+
+export const TemplateGallery: React.FC = () => {
+ const {
+ templates,
+ deleteTemplate,
+ applyTemplate,
+ exportTemplateJson,
+ importTemplateJson,
+ } = useTemplate();
+
+ const [search, setSearch] = useState('');
+
+ const filtered = useMemo(() => {
+ if (!search.trim()) return templates;
+ const keyword = search.trim().toLowerCase();
+ return templates.filter(
+ (t) =>
+ t.name.toLowerCase().includes(keyword) ||
+ t.description.toLowerCase().includes(keyword),
+ );
+ }, [templates, search]);
+
+ const handleApply = useCallback(
+ (templateId: string) => {
+ try {
+ // Use empty string as dataSetId — caller should bind data separately
+ applyTemplate(templateId, '');
+ message.success('模板已应用');
+ } catch {
+ message.error('应用模板失败');
+ }
+ },
+ [applyTemplate],
+ );
+
+ const handleExport = useCallback(
+ async (templateId: string) => {
+ try {
+ const json = await exportTemplateJson(templateId);
+ if (!json) {
+ message.error('导出失败:模板不存在');
+ return;
+ }
+ const blob = new Blob([json], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ const tpl = templates.find((t) => t.id === templateId);
+ link.download = `${tpl?.name ?? 'template'}.json`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ message.success('模板已导出');
+ } catch {
+ message.error('导出模板失败');
+ }
+ },
+ [exportTemplateJson, templates],
+ );
+
+ const handleDelete = useCallback(
+ (templateId: string) => {
+ try {
+ deleteTemplate(templateId);
+ message.success('模板已删除');
+ } catch {
+ message.error('删除模板失败');
+ }
+ },
+ [deleteTemplate],
+ );
+
+ const uploadProps: UploadProps = {
+ accept: '.json',
+ showUploadList: false,
+ beforeUpload: (file) => {
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ try {
+ const json = e.target?.result as string;
+ importTemplateJson(json);
+ message.success('模板导入成功');
+ } catch {
+ message.error('导入失败:无效的模板文件');
+ }
+ };
+ reader.readAsText(file);
+ return false; // prevent auto upload
+ },
+ };
+
+ return (
+
+
+ setSearch(e.target.value)}
+ style={{ flex: 1 }}
+ />
+
+ }>导入模板
+
+
+
+ {filtered.length === 0 ? (
+
+ ) : (
+
+ {filtered.map((template) => (
+
+
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/frontend/src/frameworks/components/template/TemplateModal.tsx b/frontend/src/frameworks/components/template/TemplateModal.tsx
new file mode 100644
index 0000000..7b26080
--- /dev/null
+++ b/frontend/src/frameworks/components/template/TemplateModal.tsx
@@ -0,0 +1,65 @@
+'use client';
+
+import React, { useCallback, useRef, useState } from 'react';
+import { Modal, Tabs } from 'antd';
+import { useUIStore } from '@/adapters/state/zustand/uiStore';
+import { TemplateGallery } from './TemplateGallery';
+import { TemplateSaveDialog } from './TemplateSaveDialog';
+
+export const TemplateModal: React.FC = () => {
+ const visible = useUIStore((s) => s.templateModalVisible);
+ const setVisible = useUIStore((s) => s.setTemplateModalVisible);
+ const [activeTab, setActiveTab] = useState('gallery');
+ const saveRef = useRef<{ submit: () => void }>(null);
+
+ const handleClose = useCallback(() => {
+ setVisible(false);
+ }, [setVisible]);
+
+ const handleOk = useCallback(() => {
+ if (activeTab === 'save') {
+ saveRef.current?.submit();
+ } else {
+ handleClose();
+ }
+ }, [activeTab, handleClose]);
+
+ const handleSaveComplete = useCallback(() => {
+ setActiveTab('gallery');
+ }, []);
+
+ return (
+
+ ,
+ },
+ {
+ key: 'save',
+ label: '保存模板',
+ children: (
+
+ ),
+ },
+ ]}
+ />
+
+ );
+};
diff --git a/frontend/src/frameworks/components/template/TemplateSaveDialog.tsx b/frontend/src/frameworks/components/template/TemplateSaveDialog.tsx
new file mode 100644
index 0000000..0e9afe1
--- /dev/null
+++ b/frontend/src/frameworks/components/template/TemplateSaveDialog.tsx
@@ -0,0 +1,105 @@
+'use client';
+
+import React, { useCallback } from 'react';
+import { Form, Input, Typography, message } from 'antd';
+import { useTemplate } from '@/frameworks/hooks/useTemplate';
+import { useAppSelector } from '@/adapters/state/redux/store';
+
+const { Text } = Typography;
+
+interface SaveFormValues {
+ name: string;
+ description: string;
+}
+
+export interface TemplateSaveDialogProps {
+ /** Ref-based imperative handle so the parent modal can trigger submit */
+ onSaveComplete?: () => void;
+}
+
+export const TemplateSaveDialog = React.forwardRef<
+ { submit: () => void },
+ TemplateSaveDialogProps
+>(({ onSaveComplete }, ref) => {
+ const [form] = Form.useForm();
+ const { saveTemplate } = useTemplate();
+
+ const charts = useAppSelector((s) => s.chart.charts);
+ const layouts = useAppSelector((s) => s.layout.layouts);
+
+ const handleSubmit = useCallback(async () => {
+ try {
+ const values = await form.validateFields();
+ saveTemplate(
+ values.name.trim(),
+ values.description?.trim() ?? '',
+ charts,
+ layouts,
+ 'default',
+ );
+ message.success('模板保存成功');
+ form.resetFields();
+ onSaveComplete?.();
+ } catch {
+ // validation failed — do nothing, form will show errors
+ }
+ }, [form, saveTemplate, charts, layouts, onSaveComplete]);
+
+ // Expose submit method to parent
+ React.useImperativeHandle(ref, () => ({ submit: handleSubmit }), [handleSubmit]);
+
+ return (
+
+
+
当前看板概览
+
+
+ {charts.length}
+
+ 图表数量
+
+
+ {layouts.length}
+
+ 布局组件
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+});
+
+TemplateSaveDialog.displayName = 'TemplateSaveDialog';
diff --git a/frontend/src/frameworks/components/template/index.ts b/frontend/src/frameworks/components/template/index.ts
new file mode 100644
index 0000000..44fe899
--- /dev/null
+++ b/frontend/src/frameworks/components/template/index.ts
@@ -0,0 +1,4 @@
+export { TemplateCard } from './TemplateCard';
+export { TemplateGallery } from './TemplateGallery';
+export { TemplateSaveDialog } from './TemplateSaveDialog';
+export { TemplateModal } from './TemplateModal';
diff --git a/frontend/src/frameworks/di/container.ts b/frontend/src/frameworks/di/container.ts
new file mode 100644
index 0000000..ab42fb9
--- /dev/null
+++ b/frontend/src/frameworks/di/container.ts
@@ -0,0 +1,48 @@
+// Instantiate all gateway implementations
+// Inject into use cases
+// Export singleton use case instances
+
+import {
+ SheetJSFileParser,
+ EChartsOptionBuilder,
+ CompositeExportGateway,
+ LocalStorageGateway,
+ GeoJsonProvider,
+} from '@/adapters/gateways';
+import {
+ ImportDataUseCase,
+ CreateChartUseCase,
+ ExportUseCase,
+ TemplateUseCase,
+ LayoutUseCase,
+} from '@/application';
+import {
+ DataServiceClient,
+ ChartServiceClient,
+ TemplateServiceClient,
+ ExportServiceClient,
+} from '@/adapters/gateways/api';
+
+// Backend API clients (singletons)
+export const dataServiceClient = new DataServiceClient();
+export const chartServiceClient = new ChartServiceClient();
+export const templateServiceClient = new TemplateServiceClient();
+export const exportServiceClient = new ExportServiceClient();
+
+// Local gateway instances (kept for offline / fallback)
+const fileParser = new SheetJSFileParser();
+const chartRenderer = new EChartsOptionBuilder();
+const exportGateway = new CompositeExportGateway();
+const storageGateway = new LocalStorageGateway();
+const geoProvider = new GeoJsonProvider();
+
+export const importDataUseCase = new ImportDataUseCase(fileParser);
+export const createChartUseCase = new CreateChartUseCase(chartRenderer);
+export const exportUseCase = new ExportUseCase(exportGateway);
+export const templateUseCase = new TemplateUseCase(storageGateway);
+export const layoutUseCase = new LayoutUseCase();
+
+// UpdateChartConfigUseCase requires getChart/setChart callbacks that depend on
+// the Redux store at runtime, so it is instantiated inside the useChartConfig hook.
+
+export { chartRenderer, geoProvider };
diff --git a/frontend/src/frameworks/hooks/index.ts b/frontend/src/frameworks/hooks/index.ts
new file mode 100644
index 0000000..05c4a0c
--- /dev/null
+++ b/frontend/src/frameworks/hooks/index.ts
@@ -0,0 +1,6 @@
+export { useImportData } from './useImportData';
+export { useCreateChart } from './useCreateChart';
+export { useChartConfig } from './useChartConfig';
+export { useExport } from './useExport';
+export { useTemplate } from './useTemplate';
+export { useLayout } from './useLayout';
diff --git a/frontend/src/frameworks/hooks/useChartConfig.ts b/frontend/src/frameworks/hooks/useChartConfig.ts
new file mode 100644
index 0000000..bf7c073
--- /dev/null
+++ b/frontend/src/frameworks/hooks/useChartConfig.ts
@@ -0,0 +1,128 @@
+'use client';
+
+import { useCallback, useMemo } from 'react';
+import { useAppDispatch, useAppSelector } from '@/adapters/state/redux/store';
+import {
+ updateBindings,
+ updateStyle,
+ updateFilters,
+ updateSort,
+ updateTopN,
+} from '@/adapters/state/redux/chartSlice';
+import { chartRenderer, chartServiceClient } from '@/frameworks/di/container';
+import { type FieldBinding } from '@/domain/entities/FieldBinding';
+import { type StyleConfig } from '@/domain/entities/StyleConfig';
+import { type FilterRule } from '@/domain/entities/ChartInstance';
+import { type SortConfig } from '@/domain/valueObjects/SortOrder';
+
+/**
+ * Convert camelCase frontend bindings to snake_case for the backend.
+ */
+function bindingsToBackend(bindings: FieldBinding[]): any[] {
+ return bindings.map((b) => ({
+ axis: b.axis,
+ column_name: b.columnName,
+ aggregation: b.aggregation ?? null,
+ }));
+}
+
+/**
+ * Convert camelCase frontend filters to snake_case for the backend.
+ */
+function filtersToBackend(filters: FilterRule[]): any[] {
+ return filters.map((f) => ({
+ column_name: f.columnName,
+ operator: f.operator,
+ value: f.value,
+ }));
+}
+
+export function useChartConfig() {
+ const dispatch = useAppDispatch();
+
+ const activeChartId = useAppSelector((s) => s.chart.activeChartId);
+ const chart = useAppSelector((s) =>
+ s.chart.charts.find((c) => c.id === s.chart.activeChartId),
+ );
+ const dataSet = useAppSelector((s) =>
+ s.data.dataSets.find((ds) => ds.id === chart?.dataSetId),
+ );
+ const rows = dataSet?.rows ?? [];
+
+ const setBindings = useCallback(
+ (bindings: FieldBinding[]) => {
+ if (!activeChartId) return;
+ // Optimistic Redux update for immediate UI response
+ dispatch(updateBindings({ chartId: activeChartId, bindings }));
+ // Persist to backend (fire-and-forget)
+ chartServiceClient
+ .updateChart(activeChartId, { bindings: bindingsToBackend(bindings) })
+ .catch(() => {/* backend sync best-effort */});
+ },
+ [dispatch, activeChartId],
+ );
+
+ const setStyle = useCallback(
+ (style: Partial) => {
+ if (!activeChartId) return;
+ dispatch(updateStyle({ chartId: activeChartId, style }));
+ chartServiceClient
+ .updateChart(activeChartId, { style })
+ .catch(() => {});
+ },
+ [dispatch, activeChartId],
+ );
+
+ const setFilters = useCallback(
+ (filters: FilterRule[]) => {
+ if (!activeChartId) return;
+ dispatch(updateFilters({ chartId: activeChartId, filters }));
+ chartServiceClient
+ .updateChart(activeChartId, { filters: filtersToBackend(filters) })
+ .catch(() => {});
+ },
+ [dispatch, activeChartId],
+ );
+
+ const setSort = useCallback(
+ (sort: SortConfig | undefined) => {
+ if (!activeChartId) return;
+ dispatch(updateSort({ chartId: activeChartId, sort }));
+ chartServiceClient
+ .updateChart(activeChartId, { sort_config: sort })
+ .catch(() => {});
+ },
+ [dispatch, activeChartId],
+ );
+
+ const setTopN = useCallback(
+ (topN: number | undefined) => {
+ if (!activeChartId) return;
+ dispatch(updateTopN({ chartId: activeChartId, topN }));
+ chartServiceClient
+ .updateChart(activeChartId, { top_n: topN })
+ .catch(() => {});
+ },
+ [dispatch, activeChartId],
+ );
+
+ // Build the ECharts option locally for real-time responsiveness.
+ // Backend getChartOption is available for export-service to use server-side.
+ const echartsOption = useMemo(() => {
+ if (!chart) return null;
+ return chartRenderer.build(chart, rows);
+ }, [chart, rows]);
+
+ return {
+ activeChartId,
+ chart,
+ dataSet,
+ rows,
+ echartsOption,
+ setBindings,
+ setStyle,
+ setFilters,
+ setSort,
+ setTopN,
+ };
+}
diff --git a/frontend/src/frameworks/hooks/useCreateChart.ts b/frontend/src/frameworks/hooks/useCreateChart.ts
new file mode 100644
index 0000000..e6c2882
--- /dev/null
+++ b/frontend/src/frameworks/hooks/useCreateChart.ts
@@ -0,0 +1,86 @@
+'use client';
+
+import { useState, useCallback } from 'react';
+import { chartServiceClient } from '@/frameworks/di/container';
+import { useAppDispatch, useAppSelector } from '@/adapters/state/redux/store';
+import { addChart, setActiveChart } from '@/adapters/state/redux/chartSlice';
+import { addLayoutItem } from '@/adapters/state/redux/layoutSlice';
+import { type ChartType } from '@/domain';
+import { type ChartInstance } from '@/domain/entities/ChartInstance';
+
+/**
+ * Map a backend chart object (snake_case) to frontend ChartInstance (camelCase).
+ */
+function mapBackendChart(raw: any): ChartInstance {
+ return {
+ id: raw.id,
+ type: raw.chart_type ?? raw.type,
+ dataSetId: raw.dataset_id ?? raw.dataSetId,
+ bindings: (raw.bindings ?? []).map((b: any) => ({
+ fieldRole: b.field_role ?? b.fieldRole,
+ columnName: b.column_name ?? b.columnName,
+ })),
+ style: raw.style ?? {},
+ filters: (raw.filters ?? []).map((f: any) => ({
+ columnName: f.column_name ?? f.columnName,
+ operator: f.operator,
+ value: f.value,
+ })),
+ sort: raw.sort_config ?? raw.sort,
+ topN: raw.top_n ?? raw.topN,
+ createdAt: raw.created_at ?? raw.createdAt ?? new Date().toISOString(),
+ updatedAt: raw.updated_at ?? raw.updatedAt ?? new Date().toISOString(),
+ };
+}
+
+export function useCreateChart() {
+ const dispatch = useAppDispatch();
+ const activeDataSetId = useAppSelector((s) => s.data.activeDataSetId);
+ const layoutCount = useAppSelector((s) => s.layout.layouts.length);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleCreate = useCallback(
+ async (chartType?: ChartType) => {
+ if (!activeDataSetId) {
+ throw new Error('No active dataset. Import data first.');
+ }
+
+ setLoading(true);
+ setError(null);
+ try {
+ const rawChart = await chartServiceClient.createChart(
+ activeDataSetId,
+ chartType ?? 'bar',
+ );
+ const chart = mapBackendChart(rawChart);
+
+ dispatch(addChart(chart));
+ dispatch(setActiveChart(chart.id));
+
+ // Default position: stack items in a 2-column grid
+ const col = layoutCount % 2;
+ const row = Math.floor(layoutCount / 2);
+ dispatch(
+ addLayoutItem({
+ chartId: chart.id,
+ x: col * 6,
+ y: row * 4,
+ w: 6,
+ h: 4,
+ }),
+ );
+
+ return chart;
+ } catch (e: any) {
+ setError(e.message ?? 'Failed to create chart');
+ throw e;
+ } finally {
+ setLoading(false);
+ }
+ },
+ [dispatch, activeDataSetId, layoutCount],
+ );
+
+ return { handleCreate, activeDataSetId, loading, error };
+}
diff --git a/frontend/src/frameworks/hooks/useExport.ts b/frontend/src/frameworks/hooks/useExport.ts
new file mode 100644
index 0000000..95aadc4
--- /dev/null
+++ b/frontend/src/frameworks/hooks/useExport.ts
@@ -0,0 +1,50 @@
+'use client';
+
+import { useState, useCallback } from 'react';
+import { exportServiceClient } from '@/frameworks/di/container';
+
+export interface ExportRequest {
+ format: string;
+ chartIds: string[];
+ datasetId: string;
+ fileName?: string;
+}
+
+export function useExport() {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleExport = useCallback(async (options: ExportRequest) => {
+ setLoading(true);
+ setError(null);
+ try {
+ // Create the export job on the backend
+ const { id } = await exportServiceClient.createExport({
+ format: options.format,
+ chart_ids: options.chartIds,
+ dataset_id: options.datasetId,
+ file_name: options.fileName,
+ });
+
+ // Poll until the export is ready, then download the blob
+ const blob = await exportServiceClient.waitForExport(id);
+
+ // Trigger browser download
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = options.fileName ?? `export.${options.format}`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ } catch (e: any) {
+ setError(e.message ?? 'Export failed');
+ throw e;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ return { handleExport, loading, error };
+}
diff --git a/frontend/src/frameworks/hooks/useImportData.ts b/frontend/src/frameworks/hooks/useImportData.ts
new file mode 100644
index 0000000..8970d0b
--- /dev/null
+++ b/frontend/src/frameworks/hooks/useImportData.ts
@@ -0,0 +1,91 @@
+'use client';
+
+import { useState, useCallback } from 'react';
+import { dataServiceClient, chartServiceClient } from '@/frameworks/di/container';
+import { useAppDispatch } from '@/adapters/state/redux/store';
+import { addDataSet, setActiveDataSet } from '@/adapters/state/redux/dataSlice';
+import { type DataSet } from '@/domain/entities/DataSet';
+import { type ChartSuggestion } from '@/application/dto/ChartSuggestion';
+
+/**
+ * Map a backend dataset object (snake_case) to the frontend DataSet entity (camelCase).
+ */
+function mapBackendDataSet(raw: any): DataSet {
+ return {
+ id: raw.id,
+ fileName: raw.file_name ?? raw.fileName ?? '',
+ sheetName: raw.sheet_name ?? raw.sheetName,
+ columns: (raw.columns ?? []).map((col: any) => ({
+ name: col.name,
+ type: col.type ?? col.field_type ?? 'string',
+ sampleValues: col.sample_values ?? col.sampleValues ?? [],
+ })),
+ rows: raw.rows ?? [],
+ rowCount: raw.row_count ?? raw.rowCount ?? (raw.rows?.length ?? 0),
+ dataStructure: raw.data_structure ?? raw.dataStructure,
+ createdAt: raw.created_at ?? raw.createdAt ?? new Date().toISOString(),
+ updatedAt: raw.updated_at ?? raw.updatedAt ?? new Date().toISOString(),
+ };
+}
+
+/**
+ * Map a backend chart recommendation (snake_case) to frontend ChartSuggestion.
+ */
+function mapBackendSuggestion(raw: any): ChartSuggestion {
+ return {
+ chartType: raw.chart_type ?? raw.chartType,
+ label: raw.label ?? raw.chart_type ?? '',
+ isPrimary: raw.is_primary ?? raw.isPrimary ?? false,
+ defaultBindings: (raw.default_bindings ?? raw.defaultBindings ?? []).map(
+ (b: any) => ({
+ fieldRole: b.field_role ?? b.fieldRole,
+ columnName: b.column_name ?? b.columnName,
+ }),
+ ),
+ };
+}
+
+export function useImportData() {
+ const dispatch = useAppDispatch();
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [suggestions, setSuggestions] = useState([]);
+
+ const handleImport = useCallback(
+ async (file: File) => {
+ setLoading(true);
+ setError(null);
+ try {
+ // Upload file to backend data-service
+ const rawDatasets = await dataServiceClient.importFile(file);
+
+ // The backend may return one or more datasets; take the first
+ const rawDataSet = Array.isArray(rawDatasets) ? rawDatasets[0] : rawDatasets;
+ const dataSet = mapBackendDataSet(rawDataSet);
+
+ dispatch(addDataSet(dataSet));
+ dispatch(setActiveDataSet(dataSet.id));
+
+ // Request chart suggestions from backend chart-service
+ try {
+ const rawSuggestions = await chartServiceClient.recommendCharts(dataSet.id);
+ const mapped = (rawSuggestions ?? []).map(mapBackendSuggestion);
+ setSuggestions(mapped);
+ } catch {
+ // Non-critical: recommendations are optional
+ setSuggestions([]);
+ }
+
+ return { dataSet, suggestions };
+ } catch (e: any) {
+ setError(e.message ?? 'Import failed');
+ throw e;
+ } finally {
+ setLoading(false);
+ }
+ },
+ [dispatch],
+ );
+
+ return { handleImport, loading, error, suggestions };
+}
diff --git a/frontend/src/frameworks/hooks/useLayout.ts b/frontend/src/frameworks/hooks/useLayout.ts
new file mode 100644
index 0000000..9ef9d4c
--- /dev/null
+++ b/frontend/src/frameworks/hooks/useLayout.ts
@@ -0,0 +1,39 @@
+'use client';
+
+import { useCallback } from 'react';
+import { useAppDispatch, useAppSelector } from '@/adapters/state/redux/store';
+import { updateLayouts } from '@/adapters/state/redux/layoutSlice';
+import { type LayoutItem } from '@/domain';
+
+export function useLayout() {
+ const dispatch = useAppDispatch();
+ const layouts = useAppSelector((s) => s.layout.layouts);
+ const canvasSize = useAppSelector((s) => s.layout.canvasSize);
+ const snapToGrid = useAppSelector((s) => s.layout.snapToGrid);
+
+ // Handler compatible with react-grid-layout's onLayoutChange callback.
+ // Converts the library's layout objects into our LayoutItem shape.
+ const onLayoutChange = useCallback(
+ (
+ gridLayouts: Array<{
+ i: string;
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ }>,
+ ) => {
+ const items: LayoutItem[] = gridLayouts.map((gl) => ({
+ chartId: gl.i,
+ x: gl.x,
+ y: gl.y,
+ w: gl.w,
+ h: gl.h,
+ }));
+ dispatch(updateLayouts(items));
+ },
+ [dispatch],
+ );
+
+ return { layouts, canvasSize, snapToGrid, onLayoutChange };
+}
diff --git a/frontend/src/frameworks/hooks/useTemplate.ts b/frontend/src/frameworks/hooks/useTemplate.ts
new file mode 100644
index 0000000..288e5ab
--- /dev/null
+++ b/frontend/src/frameworks/hooks/useTemplate.ts
@@ -0,0 +1,233 @@
+'use client';
+
+import { useState, useCallback, useEffect } from 'react';
+import { templateServiceClient } from '@/frameworks/di/container';
+import { useAppDispatch, useAppSelector } from '@/adapters/state/redux/store';
+import {
+ addTemplate,
+ removeTemplate,
+ setTemplates,
+} from '@/adapters/state/redux/templateSlice';
+import { addChart } from '@/adapters/state/redux/chartSlice';
+import { updateLayouts } from '@/adapters/state/redux/layoutSlice';
+import { type ChartInstance, type LayoutItem, type Template } from '@/domain';
+
+/**
+ * Map a backend template (snake_case) to frontend Template entity (camelCase).
+ */
+function mapBackendTemplate(raw: any): Template {
+ return {
+ id: raw.id,
+ name: raw.name,
+ description: raw.description ?? '',
+ charts: (raw.chart_configs ?? raw.charts ?? []).map((c: any) => ({
+ id: c.id,
+ type: c.chart_type ?? c.type,
+ bindings: (c.bindings ?? []).map((b: any) => ({
+ fieldRole: b.field_role ?? b.fieldRole,
+ columnName: b.column_name ?? b.columnName,
+ })),
+ style: c.style ?? {},
+ filters: (c.filters ?? []).map((f: any) => ({
+ columnName: f.column_name ?? f.columnName,
+ operator: f.operator,
+ value: f.value,
+ })),
+ sort: c.sort_config ?? c.sort,
+ topN: c.top_n ?? c.topN,
+ createdAt: c.created_at ?? c.createdAt ?? '',
+ updatedAt: c.updated_at ?? c.updatedAt ?? '',
+ })),
+ layout: (raw.layout ?? []).map((l: any) => ({
+ chartId: l.chart_id ?? l.chartId,
+ x: l.x,
+ y: l.y,
+ w: l.w,
+ h: l.h,
+ })),
+ theme: raw.theme ?? 'default',
+ createdAt: raw.created_at ?? raw.createdAt ?? new Date().toISOString(),
+ };
+}
+
+/**
+ * Convert frontend chart configs to backend snake_case format.
+ */
+function chartsToBackend(charts: Omit[]): any[] {
+ return charts.map((c) => ({
+ id: c.id,
+ chart_type: c.type,
+ bindings: c.bindings.map((b: any) => ({
+ field_role: b.fieldRole,
+ column_name: b.columnName,
+ })),
+ style: c.style,
+ filters: c.filters.map((f: any) => ({
+ column_name: f.columnName,
+ operator: f.operator,
+ value: f.value,
+ })),
+ sort_config: c.sort,
+ top_n: c.topN,
+ }));
+}
+
+function layoutToBackend(layout: LayoutItem[]): any[] {
+ return layout.map((l) => ({
+ chart_id: l.chartId,
+ x: l.x,
+ y: l.y,
+ w: l.w,
+ h: l.h,
+ }));
+}
+
+export function useTemplate() {
+ const dispatch = useAppDispatch();
+ const templates = useAppSelector((s) => s.template.templates);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Load all templates from backend on mount
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ const rawTemplates = await templateServiceClient.listTemplates();
+ if (!cancelled) {
+ dispatch(setTemplates((rawTemplates ?? []).map(mapBackendTemplate)));
+ }
+ } catch {
+ // Silently fail on initial load; templates can be fetched later
+ }
+ })();
+ return () => { cancelled = true; };
+ }, [dispatch]);
+
+ const saveTemplate = useCallback(
+ async (
+ name: string,
+ description: string,
+ charts: ChartInstance[],
+ layout: LayoutItem[],
+ theme: string,
+ ) => {
+ setLoading(true);
+ setError(null);
+ try {
+ const rawTemplate = await templateServiceClient.saveTemplate({
+ name,
+ description,
+ chart_configs: chartsToBackend(charts),
+ layout: layoutToBackend(layout),
+ theme,
+ });
+ const template = mapBackendTemplate(rawTemplate);
+ dispatch(addTemplate(template));
+ return template;
+ } catch (e: any) {
+ setError(e.message ?? 'Failed to save template');
+ throw e;
+ } finally {
+ setLoading(false);
+ }
+ },
+ [dispatch],
+ );
+
+ const deleteTemplate = useCallback(
+ async (templateId: string) => {
+ setLoading(true);
+ setError(null);
+ try {
+ await templateServiceClient.deleteTemplate(templateId);
+ dispatch(removeTemplate(templateId));
+ } catch (e: any) {
+ setError(e.message ?? 'Failed to delete template');
+ throw e;
+ } finally {
+ setLoading(false);
+ }
+ },
+ [dispatch],
+ );
+
+ const applyTemplate = useCallback(
+ (templateId: string, dataSetId: string) => {
+ const template = templates.find((t) => t.id === templateId);
+ if (!template) {
+ throw new Error(`Template not found: ${templateId}`);
+ }
+
+ // Re-hydrate charts with the selected dataset
+ for (const chartDef of template.charts) {
+ const chart: ChartInstance = {
+ ...chartDef,
+ dataSetId,
+ } as ChartInstance;
+ dispatch(addChart(chart));
+ }
+
+ dispatch(updateLayouts(template.layout));
+ return template;
+ },
+ [dispatch, templates],
+ );
+
+ const exportTemplateJson = useCallback(async (templateId: string) => {
+ setLoading(true);
+ setError(null);
+ try {
+ const rawExport = await templateServiceClient.exportTemplate(templateId);
+ const json = JSON.stringify(rawExport, null, 2);
+
+ // Trigger JSON file download in the browser
+ const blob = new Blob([json], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `template-${templateId}.json`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+
+ return json;
+ } catch (e: any) {
+ setError(e.message ?? 'Failed to export template');
+ throw e;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const importTemplateJson = useCallback(
+ async (json: string) => {
+ setLoading(true);
+ setError(null);
+ try {
+ const rawTemplate = await templateServiceClient.importTemplate(json);
+ const template = mapBackendTemplate(rawTemplate);
+ dispatch(addTemplate(template));
+ return template;
+ } catch (e: any) {
+ setError(e.message ?? 'Failed to import template');
+ throw e;
+ } finally {
+ setLoading(false);
+ }
+ },
+ [dispatch],
+ );
+
+ return {
+ templates,
+ loading,
+ error,
+ saveTemplate,
+ deleteTemplate,
+ applyTemplate,
+ exportTemplateJson,
+ importTemplateJson,
+ };
+}
diff --git a/frontend/src/themes/colorPalettes.ts b/frontend/src/themes/colorPalettes.ts
new file mode 100644
index 0000000..335ce00
--- /dev/null
+++ b/frontend/src/themes/colorPalettes.ts
@@ -0,0 +1,36 @@
+export const COLOR_PALETTES: Record = {
+ default: [
+ '#5470c6', '#91cc75', '#fac858', '#ee6666',
+ '#73c0de', '#3ba272', '#fc8452', '#9a60b4',
+ ],
+ pastel: [
+ '#a8d8ea', '#aa96da', '#fcbad3', '#ffffd2',
+ '#b5eaaa', '#f3c4fb', '#c9cba3', '#e8dff5',
+ ],
+ vivid: [
+ '#e6194b', '#3cb44b', '#ffe119', '#4363d8',
+ '#f58231', '#911eb4', '#42d4f4', '#f032e6',
+ ],
+ earth: [
+ '#8d6e63', '#a1887f', '#d7ccc8', '#795548',
+ '#bcaaa4', '#6d4c41', '#4e342e', '#efebe9',
+ ],
+ ocean: [
+ '#0077b6', '#00b4d8', '#90e0ef', '#caf0f8',
+ '#023e8a', '#0096c7', '#48cae4', '#ade8f4',
+ ],
+ sunset: [
+ '#ff6b6b', '#ffa36c', '#ffd93d', '#6bcb77',
+ '#4d96ff', '#ff922b', '#e03131', '#f08c00',
+ ],
+ monochrome: [
+ '#212529', '#495057', '#868e96', '#adb5bd',
+ '#ced4da', '#dee2e6', '#e9ecef', '#f8f9fa',
+ ],
+ contrast: [
+ '#003f5c', '#58508d', '#bc5090', '#ff6361',
+ '#ffa600', '#374c80', '#7a5195', '#ef5675',
+ ],
+};
+
+export const DEFAULT_PALETTE = 'default';
diff --git a/frontend/src/themes/darkTheme.ts b/frontend/src/themes/darkTheme.ts
new file mode 100644
index 0000000..ccecc14
--- /dev/null
+++ b/frontend/src/themes/darkTheme.ts
@@ -0,0 +1,66 @@
+import { COLOR_PALETTES } from './colorPalettes';
+
+export const darkTheme = {
+ color: COLOR_PALETTES.default,
+ backgroundColor: '#1a1a2e',
+ textStyle: {
+ color: '#e0e0e0',
+ fontFamily:
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
+ },
+ title: {
+ textStyle: {
+ color: '#eeeeee',
+ fontSize: 18,
+ fontWeight: 'bold' as const,
+ },
+ subtextStyle: {
+ color: '#aaaaaa',
+ fontSize: 12,
+ },
+ },
+ legend: {
+ textStyle: {
+ color: '#cccccc',
+ },
+ },
+ tooltip: {
+ backgroundColor: 'rgba(30, 30, 50, 0.96)',
+ borderColor: '#444466',
+ borderWidth: 1,
+ textStyle: {
+ color: '#e0e0e0',
+ },
+ extraCssText: 'box-shadow: 0 2px 8px rgba(0,0,0,0.4);',
+ },
+ xAxis: {
+ axisLine: { lineStyle: { color: '#555577' } },
+ axisTick: { lineStyle: { color: '#555577' } },
+ axisLabel: { color: '#aaaaaa' },
+ splitLine: { lineStyle: { color: '#2a2a44', type: 'dashed' as const } },
+ },
+ yAxis: {
+ axisLine: { lineStyle: { color: '#555577' } },
+ axisTick: { lineStyle: { color: '#555577' } },
+ axisLabel: { color: '#aaaaaa' },
+ splitLine: { lineStyle: { color: '#2a2a44', type: 'dashed' as const } },
+ },
+ grid: {
+ left: '3%',
+ right: '4%',
+ bottom: '3%',
+ containLabel: true,
+ },
+ categoryAxis: {
+ axisLine: { show: true, lineStyle: { color: '#555577' } },
+ axisTick: { show: false },
+ axisLabel: { color: '#aaaaaa' },
+ splitLine: { show: false },
+ },
+ valueAxis: {
+ axisLine: { show: false },
+ axisTick: { show: false },
+ axisLabel: { color: '#888888' },
+ splitLine: { lineStyle: { color: '#2a2a44' } },
+ },
+};
diff --git a/frontend/src/themes/index.ts b/frontend/src/themes/index.ts
new file mode 100644
index 0000000..998ce38
--- /dev/null
+++ b/frontend/src/themes/index.ts
@@ -0,0 +1,3 @@
+export { COLOR_PALETTES, DEFAULT_PALETTE } from './colorPalettes';
+export { lightTheme } from './lightTheme';
+export { darkTheme } from './darkTheme';
diff --git a/frontend/src/themes/lightTheme.ts b/frontend/src/themes/lightTheme.ts
new file mode 100644
index 0000000..683132a
--- /dev/null
+++ b/frontend/src/themes/lightTheme.ts
@@ -0,0 +1,66 @@
+import { COLOR_PALETTES } from './colorPalettes';
+
+export const lightTheme = {
+ color: COLOR_PALETTES.default,
+ backgroundColor: '#ffffff',
+ textStyle: {
+ color: '#333333',
+ fontFamily:
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
+ },
+ title: {
+ textStyle: {
+ color: '#333333',
+ fontSize: 18,
+ fontWeight: 'bold' as const,
+ },
+ subtextStyle: {
+ color: '#999999',
+ fontSize: 12,
+ },
+ },
+ legend: {
+ textStyle: {
+ color: '#666666',
+ },
+ },
+ tooltip: {
+ backgroundColor: 'rgba(255, 255, 255, 0.96)',
+ borderColor: '#e0e0e0',
+ borderWidth: 1,
+ textStyle: {
+ color: '#333333',
+ },
+ extraCssText: 'box-shadow: 0 2px 8px rgba(0,0,0,0.12);',
+ },
+ xAxis: {
+ axisLine: { lineStyle: { color: '#cccccc' } },
+ axisTick: { lineStyle: { color: '#cccccc' } },
+ axisLabel: { color: '#666666' },
+ splitLine: { lineStyle: { color: '#f0f0f0', type: 'dashed' as const } },
+ },
+ yAxis: {
+ axisLine: { lineStyle: { color: '#cccccc' } },
+ axisTick: { lineStyle: { color: '#cccccc' } },
+ axisLabel: { color: '#666666' },
+ splitLine: { lineStyle: { color: '#f0f0f0', type: 'dashed' as const } },
+ },
+ grid: {
+ left: '3%',
+ right: '4%',
+ bottom: '3%',
+ containLabel: true,
+ },
+ categoryAxis: {
+ axisLine: { show: true, lineStyle: { color: '#cccccc' } },
+ axisTick: { show: false },
+ axisLabel: { color: '#666666' },
+ splitLine: { show: false },
+ },
+ valueAxis: {
+ axisLine: { show: false },
+ axisTick: { show: false },
+ axisLabel: { color: '#999999' },
+ splitLine: { lineStyle: { color: '#eeeeee' } },
+ },
+};
diff --git a/frontend/src/utils/color.ts b/frontend/src/utils/color.ts
new file mode 100644
index 0000000..b23bb46
--- /dev/null
+++ b/frontend/src/utils/color.ts
@@ -0,0 +1,60 @@
+/**
+ * Convert a hex color string to RGB components.
+ */
+export function hexToRgb(hex: string): { r: number; g: number; b: number } {
+ const cleaned = hex.replace(/^#/, '');
+ const full =
+ cleaned.length === 3
+ ? cleaned
+ .split('')
+ .map((c) => c + c)
+ .join('')
+ : cleaned;
+ const num = parseInt(full, 16);
+ return {
+ r: (num >> 16) & 255,
+ g: (num >> 8) & 255,
+ b: num & 255,
+ };
+}
+
+/**
+ * Convert RGB components to a hex color string.
+ */
+export function rgbToHex(r: number, g: number, b: number): string {
+ const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)));
+ return (
+ '#' +
+ [clamp(r), clamp(g), clamp(b)]
+ .map((v) => v.toString(16).padStart(2, '0'))
+ .join('')
+ );
+}
+
+/**
+ * Lighten a hex color by a given amount (0-1).
+ */
+export function lighten(hex: string, amount: number): string {
+ const { r, g, b } = hexToRgb(hex);
+ return rgbToHex(
+ r + (255 - r) * amount,
+ g + (255 - g) * amount,
+ b + (255 - b) * amount,
+ );
+}
+
+/**
+ * Darken a hex color by a given amount (0-1).
+ */
+export function darken(hex: string, amount: number): string {
+ const { r, g, b } = hexToRgb(hex);
+ return rgbToHex(r * (1 - amount), g * (1 - amount), b * (1 - amount));
+}
+
+/**
+ * Return an rgba() string for a hex color with the given opacity (0-1).
+ */
+export function withOpacity(hex: string, opacity: number): string {
+ const { r, g, b } = hexToRgb(hex);
+ return `rgba(${r}, ${g}, ${b}, ${opacity})`;
+}
diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts
new file mode 100644
index 0000000..4518d07
--- /dev/null
+++ b/frontend/src/utils/format.ts
@@ -0,0 +1,63 @@
+import type { FieldType } from '../domain/valueObjects/FieldType';
+
+/**
+ * Format a number with thousand separators and optional decimal places.
+ */
+export function formatNumber(value: number, decimals = 0): string {
+ if (!Number.isFinite(value)) return String(value);
+ const parts = value.toFixed(decimals).split('.');
+ parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+ return parts.join('.');
+}
+
+/**
+ * Format a number as a percentage string.
+ */
+export function formatPercentage(value: number, decimals = 1): string {
+ if (!Number.isFinite(value)) return String(value);
+ return `${value.toFixed(decimals)}%`;
+}
+
+/**
+ * Simple date formatting.
+ * Supported tokens: YYYY, MM, DD, HH, mm, ss.
+ */
+export function formatDate(
+ value: string | Date,
+ format = 'YYYY-MM-DD',
+): string {
+ const d = value instanceof Date ? value : new Date(value);
+ if (isNaN(d.getTime())) return String(value);
+
+ const pad = (n: number) => String(n).padStart(2, '0');
+ const tokens: Record = {
+ YYYY: String(d.getFullYear()),
+ MM: pad(d.getMonth() + 1),
+ DD: pad(d.getDate()),
+ HH: pad(d.getHours()),
+ mm: pad(d.getMinutes()),
+ ss: pad(d.getSeconds()),
+ };
+
+ return format.replace(/YYYY|MM|DD|HH|mm|ss/g, (match) => tokens[match]);
+}
+
+/**
+ * Dispatch formatting based on FieldType.
+ */
+export function formatValue(value: unknown, type: FieldType): string {
+ if (value == null) return '';
+
+ switch (type) {
+ case 'number':
+ return formatNumber(Number(value), 0);
+ case 'percentage':
+ return formatPercentage(Number(value));
+ case 'date':
+ return formatDate(value as string | Date);
+ case 'text':
+ case 'geo':
+ default:
+ return String(value);
+ }
+}
diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts
new file mode 100644
index 0000000..f8c28f5
--- /dev/null
+++ b/frontend/src/utils/index.ts
@@ -0,0 +1,14 @@
+export {
+ formatNumber,
+ formatPercentage,
+ formatDate,
+ formatValue,
+} from './format';
+
+export {
+ hexToRgb,
+ rgbToHex,
+ lighten,
+ darken,
+ withOpacity,
+} from './color';
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..cf9c65d
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.mts"
+ ],
+ "exclude": ["node_modules"]
+}