| 技术 |
版本 |
用途 |
| 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 请求 |
| 工具 |
用途 |
| ESLint |
代码规范检查 |
| Prettier |
代码格式化 |
| Husky |
Git Hooks |
| lint-staged |
暂存文件检查 |
| Vitest |
单元测试 |
| Playwright |
E2E 测试 |
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
// 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] });
}
},
}));
// 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}`);
},
});
}
// 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;
// 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`);
},
};
// 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')),
},
],
},
],
},
]);
components/
└── DAGEditor/
├── index.tsx # 组件入口
├── DAGEditor.tsx # 主组件
├── DAGEditor.less # 样式文件
├── DAGEditor.test.tsx # 测试文件
├── types.ts # 类型定义
├── hooks/ # 组件内部 hooks
│ └── useDAGState.ts
└── components/ # 子组件
├── NodePanel/
├── Canvas/
└── PropertyPanel/
// 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;
// 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,
},
};
// 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);
}
// 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,
},
});
// 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>
);
}
// 使用 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]);
// 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();
});
});
// 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();
});
});
// 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',
},
},
},
};
});