Skip to content

Latest commit

 

History

History
888 lines (772 loc) · 21.5 KB

frontend.md

File metadata and controls

888 lines (772 loc) · 21.5 KB

前端技术方案

1. 技术选型

1.1 核心技术栈

技术 版本 用途
React 18.x UI 框架
TypeScript 5.x 类型安全
Vite 5.x 构建工具
Ant Design 5.x UI 组件库
Ant Design Pro 6.x 企业级脚手架
Zustand 4.x 状态管理
React Query 5.x 服务端状态管理
React Router 6.x 路由管理
Monaco Editor 0.45.x SQL 编辑器
ReactFlow 11.x DAG 可视化
ECharts 5.x 图表可视化
Axios 1.x HTTP 请求

1.2 开发工具

工具 用途
ESLint 代码规范检查
Prettier 代码格式化
Husky Git Hooks
lint-staged 暂存文件检查
Vitest 单元测试
Playwright E2E 测试

2. 项目结构

datajump-web/
├── public/                    # 静态资源
├── src/
│   ├── components/           # 通用组件
│   │   └── layout/          # 布局组件
│   │       └── MainLayout.tsx
│   ├── pages/               # 页面组件
│   │   ├── dashboard/       # 首页仪表盘
│   │   ├── DataDevelop/     # 数据开发
│   │   │   ├── index.tsx    #   DAG 列表页 + 发布管理 Tab
│   │   │   ├── DagEditor.tsx#   DAG 画布编辑器(含版本/发布按钮)
│   │   │   ├── SqlEditor.tsx#   SQL IDE(含版本创建/发布)
│   │   │   ├── VersionHistory.tsx  # 版本历史 Drawer
│   │   │   ├── DiffViewer.tsx      # Diff 查看器 Modal
│   │   │   └── ReleaseManagement.tsx # 发布管理页
│   │   ├── DataIntegration/ # 数据集成
│   │   │   ├── index.tsx    #   同步任务管理
│   │   │   ├── FieldMapping.tsx  # 字段映射配置
│   │   │   └── SyncMonitor.tsx   # 同步监控面板
│   │   ├── DataQuality/     # 数据质量
│   │   ├── metadata/        # 元数据管理
│   │   ├── OpsCenter/       # 运维中心
│   │   ├── Settings/        # 系统设置(用户管理)
│   │   └── Project/         # 项目管理
│   ├── router/              # 路由配置
│   │   └── index.tsx
│   ├── services/            # API 服务
│   │   └── api.ts           # 统一 API 调用(DAG/SQL/版本/发布/用户等)
│   ├── stores/              # 状态管理
│   │   └── useAppStore.ts   # Zustand 应用状态
│   ├── types/               # TypeScript 类型定义
│   │   └── index.ts
│   ├── App.tsx
│   ├── main.tsx
│   └── vite-env.d.ts
├── tsconfig.json
├── vite.config.ts
└── package.json

3. 状态管理

3.1 Zustand Store 设计

// stores/userStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface User {
  id: number;
  username: string;
  email: string;
  avatar: string;
  roles: string[];
}

interface UserState {
  user: User | null;
  token: string | null;
  isAuthenticated: boolean;
  login: (username: string, password: string) => Promise<void>;
  logout: () => void;
  updateUser: (user: Partial<User>) => void;
}

export const useUserStore = create<UserState>()(
  persist(
    (set, get) => ({
      user: null,
      token: null,
      isAuthenticated: false,

      login: async (username, password) => {
        const response = await authService.login(username, password);
        set({
          user: response.user,
          token: response.token,
          isAuthenticated: true,
        });
      },

      logout: () => {
        set({
          user: null,
          token: null,
          isAuthenticated: false,
        });
      },

      updateUser: (userData) => {
        const currentUser = get().user;
        if (currentUser) {
          set({ user: { ...currentUser, ...userData } });
        }
      },
    }),
    {
      name: 'user-storage',
      partialize: (state) => ({ token: state.token }),
    }
  )
);

// stores/projectStore.ts
interface Project {
  id: number;
  name: string;
  description: string;
}

interface ProjectState {
  currentProject: Project | null;
  projects: Project[];
  setCurrentProject: (project: Project) => void;
  fetchProjects: () => Promise<void>;
}

export const useProjectStore = create<ProjectState>((set) => ({
  currentProject: null,
  projects: [],

  setCurrentProject: (project) => set({ currentProject: project }),

  fetchProjects: async () => {
    const projects = await projectService.getProjects();
    set({ projects });
    if (projects.length > 0 && !get().currentProject) {
      set({ currentProject: projects[0] });
    }
  },
}));

3.2 React Query 配置

// services/queryClient.ts
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5分钟
      gcTime: 10 * 60 * 1000,   // 10分钟
      retry: 3,
      refetchOnWindowFocus: false,
    },
    mutations: {
      retry: 1,
    },
  },
});

// hooks/useDAG.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { dagService } from '@/services/dag';

export function useDAG(dagId: number) {
  return useQuery({
    queryKey: ['dag', dagId],
    queryFn: () => dagService.getDAG(dagId),
    enabled: !!dagId,
  });
}

export function useDAGList(projectId: number) {
  return useQuery({
    queryKey: ['dags', projectId],
    queryFn: () => dagService.getDAGList(projectId),
    enabled: !!projectId,
  });
}

export function useCreateDAG() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: dagService.createDAG,
    onSuccess: (data) => {
      queryClient.invalidateQueries({ queryKey: ['dags'] });
      message.success('DAG 创建成功');
    },
    onError: (error) => {
      message.error(`创建失败: ${error.message}`);
    },
  });
}

4. API 服务层

4.1 Axios 封装

// services/request.ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios';
import { useUserStore } from '@/stores/userStore';
import { message } from 'antd';

const request: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json',
  },
});

// 请求拦截器
request.interceptors.request.use(
  (config) => {
    const token = useUserStore.getState().token;
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }

    // 添加项目ID
    const projectId = useProjectStore.getState().currentProject?.id;
    if (projectId) {
      config.headers['X-Project-Id'] = projectId;
    }

    return config;
  },
  (error) => Promise.reject(error)
);

// 响应拦截器
request.interceptors.response.use(
  (response) => {
    const { data } = response;

    // 业务错误处理
    if (data.code !== 0) {
      message.error(data.message || '请求失败');
      return Promise.reject(new Error(data.message));
    }

    return data.data;
  },
  (error: AxiosError<{ message: string }>) => {
    if (error.response) {
      switch (error.response.status) {
        case 401:
          useUserStore.getState().logout();
          window.location.href = '/login';
          break;
        case 403:
          message.error('没有权限访问');
          break;
        case 404:
          message.error('资源不存在');
          break;
        case 500:
          message.error('服务器错误');
          break;
        default:
          message.error(error.response.data?.message || '请求失败');
      }
    } else if (error.request) {
      message.error('网络异常,请检查网络连接');
    }

    return Promise.reject(error);
  }
);

export default request;

4.2 API 服务定义

// services/dag.ts
import request from './request';
import type { DAG, DAGInstance, Task, CreateDAGParams } from '@/types/dag';

export const dagService = {
  // 获取 DAG 列表
  getDAGList(projectId: number): Promise<DAG[]> {
    return request.get('/api/v1/dags', { params: { projectId } });
  },

  // 获取 DAG 详情
  getDAG(dagId: number): Promise<DAG> {
    return request.get(`/api/v1/dags/${dagId}`);
  },

  // 创建 DAG
  createDAG(params: CreateDAGParams): Promise<DAG> {
    return request.post('/api/v1/dags', params);
  },

  // 更新 DAG
  updateDAG(dagId: number, params: Partial<DAG>): Promise<DAG> {
    return request.put(`/api/v1/dags/${dagId}`, params);
  },

  // 删除 DAG
  deleteDAG(dagId: number): Promise<void> {
    return request.delete(`/api/v1/dags/${dagId}`);
  },

  // 上线 DAG
  onlineDAG(dagId: number): Promise<void> {
    return request.post(`/api/v1/dags/${dagId}/online`);
  },

  // 下线 DAG
  offlineDAG(dagId: number): Promise<void> {
    return request.post(`/api/v1/dags/${dagId}/offline`);
  },

  // 手动触发 DAG
  triggerDAG(dagId: number, params?: { scheduleTime?: string }): Promise<DAGInstance> {
    return request.post(`/api/v1/dags/${dagId}/trigger`, params);
  },

  // 校验 DAG
  validateDAG(dagId: number): Promise<ValidationResult> {
    return request.post(`/api/v1/dags/${dagId}/validate`);
  },
};

5. 路由配置

// router/index.tsx
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { lazy, Suspense } from 'react';
import MainLayout from '@/components/layout/MainLayout';
import AuthGuard from '@/components/guards/AuthGuard';
import PageLoading from '@/components/common/PageLoading';

const lazyLoad = (factory: () => Promise<{ default: React.ComponentType }>) => {
  const Component = lazy(factory);
  return (
    <Suspense fallback={<PageLoading />}>
      <Component />
    </Suspense>
  );
};

export const router = createBrowserRouter([
  {
    path: '/login',
    element: lazyLoad(() => import('@/pages/login')),
  },
  {
    path: '/',
    element: (
      <AuthGuard>
        <MainLayout />
      </AuthGuard>
    ),
    children: [
      {
        index: true,
        element: <Navigate to="/dashboard" replace />,
      },
      {
        path: 'dashboard',
        element: lazyLoad(() => import('@/pages/dashboard')),
      },
      {
        path: 'data-integration',
        children: [
          {
            path: 'datasources',
            element: lazyLoad(() => import('@/pages/data-integration/datasources')),
          },
          {
            path: 'batch-sync',
            element: lazyLoad(() => import('@/pages/data-integration/batch-sync')),
          },
          {
            path: 'realtime-sync',
            element: lazyLoad(() => import('@/pages/data-integration/realtime-sync')),
          },
        ],
      },
      {
        path: 'data-development',
        children: [
          {
            path: 'sql-ide',
            element: lazyLoad(() => import('@/pages/data-development/sql-ide')),
          },
          {
            path: 'dag-editor/:dagId?',
            element: lazyLoad(() => import('@/pages/data-development/dag-editor')),
          },
          {
            path: 'release',
            element: lazyLoad(() => import('@/pages/data-development/release')),
          },
        ],
      },
      {
        path: 'metadata',
        children: [
          {
            path: 'assets',
            element: lazyLoad(() => import('@/pages/metadata/assets')),
          },
          {
            path: 'lineage',
            element: lazyLoad(() => import('@/pages/metadata/lineage')),
          },
          {
            path: 'quality',
            element: lazyLoad(() => import('@/pages/metadata/quality')),
          },
        ],
      },
      {
        path: 'ops-monitor',
        children: [
          {
            path: 'dashboard',
            element: lazyLoad(() => import('@/pages/ops-monitor/dashboard')),
          },
          {
            path: 'alerts',
            element: lazyLoad(() => import('@/pages/ops-monitor/alerts')),
          },
          {
            path: 'logs',
            element: lazyLoad(() => import('@/pages/ops-monitor/logs')),
          },
        ],
      },
    ],
  },
]);

6. 组件设计规范

6.1 组件目录结构

components/
└── DAGEditor/
    ├── index.tsx           # 组件入口
    ├── DAGEditor.tsx       # 主组件
    ├── DAGEditor.less      # 样式文件
    ├── DAGEditor.test.tsx  # 测试文件
    ├── types.ts            # 类型定义
    ├── hooks/              # 组件内部 hooks
    │   └── useDAGState.ts
    └── components/         # 子组件
        ├── NodePanel/
        ├── Canvas/
        └── PropertyPanel/

6.2 组件编写规范

// components/DAGEditor/DAGEditor.tsx
import React, { useCallback, useState } from 'react';
import { useDAGState } from './hooks/useDAGState';
import NodePanel from './components/NodePanel';
import Canvas from './components/Canvas';
import PropertyPanel from './components/PropertyPanel';
import type { DAGEditorProps, Node, Edge } from './types';
import styles from './DAGEditor.less';

/**
 * DAG 可视化编辑器
 *
 * @example
 * ```tsx
 * <DAGEditor
 *   dag={dag}
 *   onChange={handleChange}
 *   onSave={handleSave}
 * />
 * ```
 */
export const DAGEditor: React.FC<DAGEditorProps> = ({
  dag,
  onChange,
  onSave,
  readOnly = false,
}) => {
  const {
    nodes,
    edges,
    selectedNode,
    setNodes,
    setEdges,
    setSelectedNode,
  } = useDAGState(dag);

  const handleNodeAdd = useCallback((type: string, position: { x: number; y: number }) => {
    const newNode: Node = {
      id: `node_${Date.now()}`,
      type: 'taskNode',
      position,
      data: { taskType: type, label: `新${type}任务` },
    };
    setNodes((prev) => [...prev, newNode]);
    onChange?.({ nodes: [...nodes, newNode], edges });
  }, [nodes, edges, onChange, setNodes]);

  const handleConnect = useCallback((connection: Edge) => {
    setEdges((prev) => [...prev, connection]);
    onChange?.({ nodes, edges: [...edges, connection] });
  }, [nodes, edges, onChange, setEdges]);

  const handleNodeSelect = useCallback((node: Node | null) => {
    setSelectedNode(node);
  }, [setSelectedNode]);

  const handleNodeChange = useCallback((nodeId: string, data: Partial<Node['data']>) => {
    setNodes((prev) =>
      prev.map((n) =>
        n.id === nodeId ? { ...n, data: { ...n.data, ...data } } : n
      )
    );
  }, [setNodes]);

  return (
    <div className={styles.dagEditor}>
      {!readOnly && <NodePanel onDragStart={handleNodeAdd} />}
      <Canvas
        nodes={nodes}
        edges={edges}
        onConnect={handleConnect}
        onNodeSelect={handleNodeSelect}
        readOnly={readOnly}
      />
      {selectedNode && (
        <PropertyPanel
          node={selectedNode}
          onChange={(data) => handleNodeChange(selectedNode.id, data)}
          readOnly={readOnly}
        />
      )}
    </div>
  );
};

export default DAGEditor;

7. 主题配置

7.1 Ant Design 主题

// theme/config.ts
import type { ThemeConfig } from 'antd';

export const darkTheme: ThemeConfig = {
  token: {
    colorPrimary: '#1668dc',
    colorBgContainer: '#141414',
    colorBgElevated: '#1f1f1f',
    colorBgLayout: '#000000',
    colorText: 'rgba(255, 255, 255, 0.85)',
    colorTextSecondary: 'rgba(255, 255, 255, 0.65)',
    colorBorder: '#303030',
    borderRadius: 6,
  },
  components: {
    Layout: {
      headerBg: '#141414',
      siderBg: '#141414',
      bodyBg: '#000000',
    },
    Menu: {
      darkItemBg: '#141414',
      darkItemSelectedBg: '#1668dc',
    },
    Table: {
      headerBg: '#1f1f1f',
      rowHoverBg: '#262626',
    },
  },
};

export const lightTheme: ThemeConfig = {
  token: {
    colorPrimary: '#1677ff',
    colorBgContainer: '#ffffff',
    colorBgElevated: '#ffffff',
    colorBgLayout: '#f5f5f5',
    borderRadius: 6,
  },
};

7.2 CSS 变量

// styles/variables.less
:root {
  // 颜色
  --color-primary: #1668dc;
  --color-success: #52c41a;
  --color-warning: #faad14;
  --color-error: #ff4d4f;

  // 背景色
  --bg-container: #141414;
  --bg-elevated: #1f1f1f;
  --bg-layout: #000000;

  // 文字颜色
  --text-primary: rgba(255, 255, 255, 0.85);
  --text-secondary: rgba(255, 255, 255, 0.65);
  --text-tertiary: rgba(255, 255, 255, 0.45);

  // 边框
  --border-color: #303030;
  --border-radius: 6px;

  // 间距
  --spacing-xs: 4px;
  --spacing-sm: 8px;
  --spacing-md: 16px;
  --spacing-lg: 24px;
  --spacing-xl: 32px;

  // 阴影
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
  --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
  --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}

8. 性能优化

8.1 代码分割

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vendor-react': ['react', 'react-dom', 'react-router-dom'],
          'vendor-antd': ['antd', '@ant-design/icons'],
          'vendor-editor': ['monaco-editor'],
          'vendor-flow': ['reactflow'],
          'vendor-charts': ['echarts', 'echarts-for-react'],
        },
      },
    },
    chunkSizeWarningLimit: 1000,
  },
});

8.2 虚拟列表

// components/VirtualTable.tsx
import { Table } from 'antd';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';

interface VirtualTableProps<T> {
  data: T[];
  columns: ColumnsType<T>;
  rowHeight?: number;
}

export function VirtualTable<T>({ data, columns, rowHeight = 54 }: VirtualTableProps<T>) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: data.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => rowHeight,
    overscan: 10,
  });

  return (
    <div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
      <Table
        columns={columns}
        dataSource={virtualizer.getVirtualItems().map((item) => data[item.index])}
        pagination={false}
        scroll={{ y: 600 }}
      />
    </div>
  );
}

8.3 组件缓存

// 使用 React.memo 缓存组件
const TaskNode = React.memo<TaskNodeProps>(({ data, selected }) => {
  // 组件实现
}, (prevProps, nextProps) => {
  return (
    prevProps.data.label === nextProps.data.label &&
    prevProps.data.status === nextProps.data.status &&
    prevProps.selected === nextProps.selected
  );
});

// 使用 useMemo 缓存计算结果
const filteredData = useMemo(() => {
  return data.filter((item) =>
    item.name.toLowerCase().includes(keyword.toLowerCase())
  );
}, [data, keyword]);

9. 测试规范

9.1 单元测试

// components/DAGEditor/DAGEditor.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import DAGEditor from './DAGEditor';

describe('DAGEditor', () => {
  const mockDAG = {
    id: 1,
    name: 'Test DAG',
    tasks: [],
    edges: [],
  };

  it('should render correctly', () => {
    render(<DAGEditor dag={mockDAG} />);
    expect(screen.getByTestId('dag-canvas')).toBeInTheDocument();
  });

  it('should add node when dragged', () => {
    const onChange = vi.fn();
    render(<DAGEditor dag={mockDAG} onChange={onChange} />);

    // 模拟拖拽添加节点
    fireEvent.dragStart(screen.getByText('SQL'));
    fireEvent.drop(screen.getByTestId('dag-canvas'));

    expect(onChange).toHaveBeenCalled();
  });

  it('should select node when clicked', () => {
    const dagWithNode = {
      ...mockDAG,
      tasks: [{ id: 'task_1', data: { label: 'Task 1' } }],
    };

    render(<DAGEditor dag={dagWithNode} />);
    fireEvent.click(screen.getByText('Task 1'));

    expect(screen.getByTestId('property-panel')).toBeInTheDocument();
  });
});

9.2 E2E 测试

// tests/e2e/dag-editor.spec.ts
import { test, expect } from '@playwright/test';

test.describe('DAG Editor', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/data-development/dag-editor');
  });

  test('should create a new DAG', async ({ page }) => {
    // 点击新建按钮
    await page.click('button:has-text("新建 DAG")');

    // 填写 DAG 名称
    await page.fill('input[name="name"]', 'Test DAG');

    // 拖拽添加 SQL 节点
    const sqlNode = page.locator('.node-panel .sql-node');
    const canvas = page.locator('.dag-canvas');
    await sqlNode.dragTo(canvas);

    // 保存
    await page.click('button:has-text("保存")');

    // 验证保存成功
    await expect(page.locator('.ant-message-success')).toBeVisible();
  });
});

10. 构建配置

// vite.config.ts
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '');

  return {
    plugins: [react()],
    resolve: {
      alias: {
        '@': path.resolve(__dirname, 'src'),
      },
    },
    css: {
      preprocessorOptions: {
        less: {
          javascriptEnabled: true,
          modifyVars: {
            '@primary-color': '#1668dc',
          },
        },
      },
    },
    server: {
      port: 3000,
      proxy: {
        '/api': {
          target: env.VITE_API_BASE_URL,
          changeOrigin: true,
        },
      },
    },
    build: {
      target: 'es2015',
      sourcemap: mode !== 'production',
      minify: 'terser',
      terserOptions: {
        compress: {
          drop_console: mode === 'production',
          drop_debugger: mode === 'production',
        },
      },
    },
  };
});