跳到主要内容

API 集成

提示

本指南介绍如何在 AgileJS Web 编辑器中集成后端 API,具体实现需业务自行扩展。

数据格式

图形数据

interface GraphData {
nodes: NodeData[];
edges: EdgeData[];
metadata?: {
version: string;
createdAt: string;
updatedAt: string;
};
}

interface NodeData {
id: string;
shape: string;
position: { x: number; y: number };
size: { width: number; height: number };
rotation?: number;
zIndex?: number;
data?: Record<string, any>;
}

interface EdgeData {
id: string;
shape: string;
source: string;
target: string;
sourcePortId?: string;
targetPortId?: string;
points?: Point[];
data?: Record<string, any>;
}

API 服务

基础配置

// src/service/api.ts
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';

interface ApiResponse<T> {
code: number;
message: string;
data: T;
}

async function request<T>(
url: string,
options?: RequestInit
): Promise<T> {
const response = await fetch(`${API_BASE_URL}${url}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

const result: ApiResponse<T> = await response.json();

if (result.code !== 0) {
throw new Error(result.message);
}

return result.data;
}

图形 API

// src/service/graphApi.ts
export const graphApi = {
// 获取图形列表
async getGraphList(): Promise<GraphData[]> {
return request<GraphData[]>('/api/graphs');
},

// 获取单个图形
async getGraph(id: string): Promise<GraphData> {
return request<GraphData>(`/api/graphs/${id}`);
},

// 创建图形
async createGraph(data: GraphData): Promise<{ id: string }> {
return request<{ id: string }>('/api/graphs', {
method: 'POST',
body: JSON.stringify(data),
});
},

// 更新图形
async updateGraph(id: string, data: GraphData): Promise<void> {
return request<void>(`/api/graphs/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
},

// 删除图形
async deleteGraph(id: string): Promise<void> {
return request<void>(`/api/graphs/${id}`, {
method: 'DELETE',
});
},
};

加载数据

加载远程数据

import { graphApi } from '@/service/graphApi';
import { fromJSON } from '@fnt-agilejs/core';

async function loadGraphFromServer(
engine: CanvasEngine,
graphId: string
) {
try {
// 显示加载提示
showLoading();

// 获取数据
const data = await graphApi.getGraph(graphId);

// 清空现有数据
engine.graph.clear();

// 加载数据到引擎
const scene = fromJSON(data);
scene.nodes.forEach(node => engine.graph.addNode(node));
scene.edges.forEach(edge => engine.graph.addEdge(edge));

// 适配视图
engine.fitContent();

showSuccess('加载成功');
} catch (error) {
console.error('Failed to load graph:', error);
showError('加载失败');
} finally {
hideLoading();
}
}

Hook 封装

// src/hooks/useGraphData.ts
import { useState, useEffect } from 'react';

interface UseGraphDataResult {
data: GraphData | null;
loading: boolean;
error: Error | null;
refresh: () => Promise<void>;
}

function useGraphData(graphId: string): UseGraphDataResult {
const [data, setData] = useState<GraphData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);

const fetchData = async () => {
setLoading(true);
setError(null);

try {
const result = await graphApi.getGraph(graphId);
setData(result);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
};

useEffect(() => {
if (graphId) {
fetchData();
}
}, [graphId]);

return {
data,
loading,
error,
refresh: fetchData,
};
}

export default useGraphData;

使用示例

const Editor: React.FC = () => {
const { graphId } = useParams();
const { data, loading, error } = useGraphData(graphId);
const engineRef = useRef<CanvasEngine | null>(null);

// 加载数据到引擎
useEffect(() => {
if (!data || !engineRef.current) return;

const engine = engineRef.current;
engine.graph.clear();

data.nodes.forEach(node => engine.graph.addNode(node));
data.edges.forEach(edge => engine.graph.addEdge(edge));

engine.fitContent();
}, [data]);

if (loading) return <div>加载中...</div>;
if (error) return <div>加载失败: {error.message}</div>;

return <div ref={containerRef} />;
};

保存数据

手动保存

async function saveGraphToServer(
engine: CanvasEngine,
graphId?: string
) {
try {
showLoading('保存中...');

// 序列化数据
const data: GraphData = {
nodes: engine.graph.getNodes(),
edges: engine.graph.getEdges(),
metadata: {
version: '1.0.0',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
};

// 保存到服务器
if (graphId) {
await graphApi.updateGraph(graphId, data);
} else {
const result = await graphApi.createGraph(data);
// 更新 URL
window.history.pushState({}, '', `/editor/${result.id}`);
}

showSuccess('保存成功');
} catch (error) {
console.error('Failed to save graph:', error);
showError('保存失败');
} finally {
hideLoading();
}
}

// 使用
<button onClick={() => saveGraphToServer(engine, graphId)}>
保存
</button>

自动保存

import { debounce } from 'lodash';

function useAutoSave(
engine: CanvasEngine | null,
graphId: string | undefined,
interval = 30000 // 30秒
) {
const [isDirty, setIsDirty] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);

// 防抖保存
const debouncedSave = useMemo(
() => debounce(async () => {
if (!engine || !graphId) return;

try {
const data: GraphData = {
nodes: engine.graph.getNodes(),
edges: engine.graph.getEdges(),
};

await graphApi.updateGraph(graphId, data);

setIsDirty(false);
setLastSaved(new Date());
showSuccess('自动保存成功', { duration: 1000 });
} catch (error) {
console.error('Auto-save failed:', error);
}
}, 2000),
[engine, graphId]
);

// 监听图形变化
useEffect(() => {
if (!engine) return;

const handler = () => {
setIsDirty(true);
debouncedSave();
};

engine.events.on('graph:change', handler);
return () => {
engine.events.off('graph:change', handler);
debouncedSave.cancel();
};
}, [engine, debouncedSave]);

// 定时保存
useEffect(() => {
if (!engine || !graphId || !isDirty) return;

const timer = setInterval(() => {
debouncedSave.flush();
}, interval);

return () => clearInterval(timer);
}, [engine, graphId, isDirty, interval, debouncedSave]);

return { isDirty, lastSaved };
}

// 使用
const { isDirty, lastSaved } = useAutoSave(engine, graphId);

<div className="status-bar">
{isDirty ? '未保存' : '已保存'}
{lastSaved && ` - ${formatTime(lastSaved)}`}
</div>

导入/导出

导出 JSON

function exportGraphAsJSON(engine: CanvasEngine) {
const data: GraphData = {
nodes: engine.graph.getNodes(),
edges: engine.graph.getEdges(),
metadata: {
version: '1.0.0',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
};

const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);

const a = document.createElement('a');
a.href = url;
a.download = `graph-${Date.now()}.json`;
a.click();

URL.revokeObjectURL(url);
}

导入 JSON

function importGraphFromJSON(
engine: CanvasEngine,
file: File
) {
const reader = new FileReader();

reader.onload = (e) => {
try {
const json = e.target?.result as string;
const data: GraphData = JSON.parse(json);

// 验证数据
if (!data.nodes || !data.edges) {
throw new Error('Invalid graph data');
}

// 清空并加载
engine.graph.clear();
data.nodes.forEach(node => engine.graph.addNode(node));
data.edges.forEach(edge => engine.graph.addEdge(edge));

engine.fitContent();
showSuccess('导入成功');
} catch (error) {
console.error('Import failed:', error);
showError('导入失败');
}
};

reader.readAsText(file);
}

// 文件选择器
<input
type="file"
accept=".json"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
importGraphFromJSON(engine, file);
}
}}
/>

导出图片

async function exportGraphAsImage(
engine: CanvasEngine,
format: 'png' | 'jpeg' = 'png'
) {
try {
// 获取 Canvas 元素
const canvas = engine.canvas;

// 转换为 Blob
const blob = await new Promise<Blob>((resolve) => {
canvas.toBlob((blob) => resolve(blob!), `image/${format}`);
});

// 下载
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `graph-${Date.now()}.${format}`;
a.click();

URL.revokeObjectURL(url);
showSuccess('导出成功');
} catch (error) {
console.error('Export failed:', error);
showError('导出失败');
}
}

错误处理

错误拦截

// src/service/api.ts
class ApiError extends Error {
constructor(
public code: number,
message: string,
public details?: any
) {
super(message);
this.name = 'ApiError';
}
}

async function request<T>(url: string, options?: RequestInit): Promise<T> {
try {
const response = await fetch(`${API_BASE_URL}${url}`, options);

if (!response.ok) {
throw new ApiError(
response.status,
`HTTP ${response.status}: ${response.statusText}`
);
}

const result = await response.json();

if (result.code !== 0) {
throw new ApiError(result.code, result.message, result.data);
}

return result.data;
} catch (error) {
if (error instanceof ApiError) {
throw error;
}

throw new ApiError(
0,
error instanceof Error ? error.message : 'Unknown error'
);
}
}

全局错误处理

// src/utils/errorHandler.ts
export function handleApiError(error: unknown) {
if (error instanceof ApiError) {
switch (error.code) {
case 401:
// 未授权,跳转登录
window.location.href = '/login';
break;
case 403:
showError('无权限访问');
break;
case 404:
showError('资源不存在');
break;
case 500:
showError('服务器错误');
break;
default:
showError(error.message);
}
} else {
showError('未知错误');
}

// 上报错误
reportError(error);
}

认证授权

Token 管理

// src/utils/auth.ts
const TOKEN_KEY = 'auth_token';

export const auth = {
getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
},

setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
},

removeToken(): void {
localStorage.removeItem(TOKEN_KEY);
},

isAuthenticated(): boolean {
return !!this.getToken();
},
};

// 请求拦截
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const token = auth.getToken();

return fetch(`${API_BASE_URL}${url}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...options?.headers,
},
});
}

路由守卫

// src/components/ProtectedRoute.tsx
interface ProtectedRouteProps {
children: React.ReactNode;
}

const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const isAuthenticated = auth.isAuthenticated();

if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}

return <>{children}</>;
};

// 使用
<Route
path="/editor"
element={
<ProtectedRoute>
<Editor />
</ProtectedRoute>
}
/>

最佳实践

1. 请求取消

function useGraphData(graphId: string) {
const [data, setData] = useState(null);

useEffect(() => {
const abortController = new AbortController();

fetch(`/api/graphs/${graphId}`, {
signal: abortController.signal
})
.then(res => res.json())
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
});

return () => abortController.abort();
}, [graphId]);

return data;
}

2. 请求缓存

const cache = new Map<string, any>();

async function cachedRequest<T>(url: string): Promise<T> {
if (cache.has(url)) {
return cache.get(url);
}

const data = await request<T>(url);
cache.set(url, data);

return data;
}

3. 重试机制

async function retryRequest<T>(
fn: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
if (i === maxRetries - 1) throw error;
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
}
}
throw new Error('Max retries exceeded');
}

4. 数据验证

import { z } from 'zod';

const NodeDataSchema = z.object({
id: z.string(),
shape: z.string(),
position: z.object({
x: z.number(),
y: z.number(),
}),
size: z.object({
width: z.number(),
height: z.number(),
}),
});

function validateNodeData(data: unknown): NodeData {
return NodeDataSchema.parse(data);
}