feat: add error boundary

This commit is contained in:
gchust 2025-06-24 12:05:39 +08:00
parent 1fc045e45c
commit c4d31e802d
4 changed files with 365 additions and 21 deletions

View File

@ -18,7 +18,12 @@ function Grid({ items }) {
{items.map((item) => { {items.map((item) => {
return ( return (
<div key={item.uid} style={{ marginBottom: 16 }}> <div key={item.uid} style={{ marginBottom: 16 }}>
<FlowModelRenderer model={item} key={item.uid} showFlowSettings={{ showBackground: false }} /> <FlowModelRenderer
model={item}
key={item.uid}
showFlowSettings={{ showBackground: false }}
showErrorFallback
/>
</div> </div>
); );
})} })}

View File

@ -0,0 +1,314 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Button, Result, Typography } from 'antd';
import React, { FC, useState } from 'react';
import { FallbackProps } from 'react-error-boundary';
import { useFlowModel } from '../hooks/useFlowModel';
const { Paragraph, Text } = Typography;
/**
* FlowModel
*/
const FlowErrorFallbackInner: FC<FallbackProps> = ({ error, resetErrorBoundary }) => {
const [loading, setLoading] = useState(false);
const model = useFlowModel(); // 在这里安全地使用 Hook
const handleCopyError = async () => {
setLoading(true);
try {
const errorInfo = {
message: error.message,
stack: error.stack,
modelInfo: model
? {
uid: model.uid,
className: model.constructor.name,
props: model.props,
stepParams: model.stepParams,
}
: null,
timestamp: new Date().toISOString(),
};
await navigator.clipboard.writeText(JSON.stringify(errorInfo, null, 2));
console.log('Error information copied to clipboard');
} catch (err) {
console.error('Failed to copy error information:', err);
}
setLoading(false);
};
// 检查是否可以下载日志
const canDownloadLogs = model?.ctx?.globals?.api;
const handleDownloadLogs = async () => {
if (!canDownloadLogs) {
console.error('API client not available');
return;
}
setLoading(true);
try {
// 从 model.ctx.globals.api 获取 apiClient
const apiClient = model.ctx.globals.api;
// 从 window 对象获取位置信息
const location = {
pathname: window.location.pathname,
search: window.location.search,
hash: window.location.hash,
href: window.location.href,
};
const res = await apiClient.request({
url: 'logger:collect',
method: 'post',
responseType: 'blob',
data: {
error: {
message: error.message,
stack: error.stack,
},
location,
modelInfo: model
? {
uid: model.uid,
className: model.constructor.name,
props: model.props,
stepParams: model.stepParams,
}
: null,
},
});
const url = window.URL.createObjectURL(new Blob([res.data], { type: 'application/gzip' }));
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', 'logs.tar.gz');
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (err) {
console.error('Failed to download logs:', err);
}
setLoading(false);
};
const subTitle = (
<span>
{'This is likely a NocoBase internals bug. Please open an issue at '}
<a href="https://github.com/nocobase/nocobase/issues" target="_blank" rel="noopener noreferrer">
here
</a>
{model && (
<div style={{ marginTop: '8px', fontSize: '12px', color: '#999' }}>
Model: {model.constructor.name} (uid: {model.uid})
</div>
)}
</span>
);
return (
<div style={{ backgroundColor: 'white' }}>
<Result
style={{ maxWidth: '60vw', margin: 'auto' }}
status="error"
title="Render Failed"
subTitle={subTitle}
extra={[
<Button type="primary" key="feedback" href="https://github.com/nocobase/nocobase/issues" target="_blank">
Feedback
</Button>,
canDownloadLogs && (
<Button key="log" loading={loading} onClick={handleDownloadLogs}>
Download logs
</Button>
),
<Button key="copy" loading={loading} onClick={handleCopyError}>
Copy Error Info
</Button>,
resetErrorBoundary && (
<Button key="retry" danger onClick={resetErrorBoundary}>
Try Again
</Button>
),
].filter(Boolean)}
>
<Paragraph copyable={{ text: error.stack }}>
<Text type="danger" style={{ whiteSpace: 'pre-line', textAlign: 'center' }}>
{error.stack}
</Text>
</Paragraph>
</Result>
</div>
);
};
/**
* 退 flow-engine
* ErrorFallback
*/
export const FlowErrorFallback: FC<FallbackProps> & {
Modal: FC<FallbackProps & { children?: React.ReactNode }>;
} = ({ error, resetErrorBoundary }) => {
const [loading, setLoading] = useState(false);
// 尝试渲染内部组件,如果不在 FlowModelProvider 中则显示简化版本
try {
return <FlowErrorFallbackInner error={error} resetErrorBoundary={resetErrorBoundary} />;
} catch {
// 如果不在 FlowModelProvider 中,显示简化版本(不包含 model 信息)
const handleCopyError = async () => {
setLoading(true);
try {
const errorInfo = {
message: error.message,
stack: error.stack,
timestamp: new Date().toISOString(),
};
await navigator.clipboard.writeText(JSON.stringify(errorInfo, null, 2));
console.log('Error information copied to clipboard');
} catch (err) {
console.error('Failed to copy error information:', err);
}
setLoading(false);
};
const subTitle = (
<span>
{'This is likely a NocoBase internals bug. Please open an issue at '}
<a href="https://github.com/nocobase/nocobase/issues" target="_blank" rel="noopener noreferrer">
here
</a>
</span>
);
return (
<div style={{ backgroundColor: 'white' }}>
<Result
style={{ maxWidth: '60vw', margin: 'auto' }}
status="error"
title="Render Failed"
subTitle={subTitle}
extra={[
<Button type="primary" key="feedback" href="https://github.com/nocobase/nocobase/issues" target="_blank">
Feedback
</Button>,
<Button key="copy" loading={loading} onClick={handleCopyError}>
Copy Error Info
</Button>,
resetErrorBoundary && (
<Button key="retry" danger onClick={resetErrorBoundary}>
Try Again
</Button>
),
].filter(Boolean)}
>
<Paragraph copyable={{ text: error.stack }}>
<Text type="danger" style={{ whiteSpace: 'pre-line', textAlign: 'center' }}>
{error.stack}
</Text>
</Paragraph>
</Result>
</div>
);
}
};
/**
* 退
*/
export const FlowErrorFallbackModal: FC<FallbackProps & { children?: React.ReactNode }> = ({
error,
resetErrorBoundary,
children,
}) => {
const [open, setOpen] = useState(false);
const defaultChildren = (
<Paragraph
style={{
display: 'flex',
marginBottom: 0,
}}
copyable={{ text: error.message }}
>
<Text
type="danger"
style={{
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
display: 'inline-block',
maxWidth: '200px',
}}
>
Error: {error.message}
</Text>
</Paragraph>
);
return (
<>
<div onMouseOver={() => setOpen(true)}>{children || defaultChildren}</div>
{open && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10000,
}}
onClick={() => setOpen(false)}
>
<div
style={{
backgroundColor: '#fff',
borderRadius: '8px',
padding: '24px',
maxWidth: '60vw',
maxHeight: '80vh',
overflow: 'auto',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
>
<button
style={{
position: 'absolute',
top: '8px',
right: '12px',
background: 'none',
border: 'none',
fontSize: '18px',
cursor: 'pointer',
color: '#999',
}}
onClick={() => setOpen(false)}
>
×
</button>
<FlowErrorFallback error={error} resetErrorBoundary={resetErrorBoundary} />
</div>
</div>
)}
</>
);
};
FlowErrorFallback.Modal = FlowErrorFallbackModal;

View File

@ -39,10 +39,12 @@ import { observer } from '@formily/reactive-react';
import { Spin } from 'antd'; import { Spin } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import React, { Suspense, useEffect } from 'react'; import React, { Suspense, useEffect } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { useApplyAutoFlows, useFlowExtraContext, FlowModelProvider } from '../hooks'; import { useApplyAutoFlows, useFlowExtraContext, FlowModelProvider } from '../hooks';
import { FlowModel } from '../models'; import { FlowModel } from '../models';
import { FlowsContextMenu } from './settings/wrappers/contextual/FlowsContextMenu'; import { FlowsContextMenu } from './settings/wrappers/contextual/FlowsContextMenu';
import { FlowsFloatContextMenu } from './settings/wrappers/contextual/FlowsFloatContextMenu'; import { FlowsFloatContextMenu } from './settings/wrappers/contextual/FlowsFloatContextMenu';
import { FlowErrorFallback } from './FlowErrorFallback';
interface FlowModelRendererProps { interface FlowModelRendererProps {
model?: FlowModel; model?: FlowModel;
@ -70,6 +72,9 @@ interface FlowModelRendererProps {
/** 是否为每个组件独立执行 auto flow默认 false */ /** 是否为每个组件独立执行 auto flow默认 false */
independentAutoFlowExecution?: boolean; // 默认 false independentAutoFlowExecution?: boolean; // 默认 false
/** 是否在最外层包装 FlowErrorFallback 组件,默认 false */
showErrorFallback?: boolean; // 默认 false
} }
/** /**
@ -83,6 +88,7 @@ const FlowModelRendererWithAutoFlows: React.FC<{
extraContext?: Record<string, any>; extraContext?: Record<string, any>;
sharedContext?: Record<string, any>; sharedContext?: Record<string, any>;
independentAutoFlowExecution?: boolean; independentAutoFlowExecution?: boolean;
showErrorFallback?: boolean;
}> = observer( }> = observer(
({ ({
model, model,
@ -92,6 +98,7 @@ const FlowModelRendererWithAutoFlows: React.FC<{
extraContext, extraContext,
sharedContext, sharedContext,
independentAutoFlowExecution, independentAutoFlowExecution,
showErrorFallback,
}) => { }) => {
const defaultExtraContext = useFlowExtraContext(); const defaultExtraContext = useFlowExtraContext();
useApplyAutoFlows(model, extraContext || defaultExtraContext, !independentAutoFlowExecution); useApplyAutoFlows(model, extraContext || defaultExtraContext, !independentAutoFlowExecution);
@ -103,6 +110,7 @@ const FlowModelRendererWithAutoFlows: React.FC<{
showFlowSettings={showFlowSettings} showFlowSettings={showFlowSettings}
flowSettingsVariant={flowSettingsVariant} flowSettingsVariant={flowSettingsVariant}
hideRemoveInSettings={hideRemoveInSettings} hideRemoveInSettings={hideRemoveInSettings}
showErrorFallback={showErrorFallback}
/> />
</FlowModelProvider> </FlowModelProvider>
); );
@ -118,18 +126,22 @@ const FlowModelRendererWithoutAutoFlows: React.FC<{
flowSettingsVariant: string; flowSettingsVariant: string;
hideRemoveInSettings: boolean; hideRemoveInSettings: boolean;
sharedContext?: Record<string, any>; sharedContext?: Record<string, any>;
}> = observer(({ model, showFlowSettings, flowSettingsVariant, hideRemoveInSettings, sharedContext }) => { showErrorFallback?: boolean;
return ( }> = observer(
<FlowModelProvider model={model}> ({ model, showFlowSettings, flowSettingsVariant, hideRemoveInSettings, sharedContext, showErrorFallback }) => {
<FlowModelRendererCore return (
model={model} <FlowModelProvider model={model}>
showFlowSettings={showFlowSettings} <FlowModelRendererCore
flowSettingsVariant={flowSettingsVariant} model={model}
hideRemoveInSettings={hideRemoveInSettings} showFlowSettings={showFlowSettings}
/> flowSettingsVariant={flowSettingsVariant}
</FlowModelProvider> hideRemoveInSettings={hideRemoveInSettings}
); showErrorFallback={showErrorFallback}
}); />
</FlowModelProvider>
);
},
);
/** /**
* *
@ -139,13 +151,22 @@ const FlowModelRendererCore: React.FC<{
showFlowSettings: boolean | { showBackground?: boolean; showBorder?: boolean }; showFlowSettings: boolean | { showBackground?: boolean; showBorder?: boolean };
flowSettingsVariant: string; flowSettingsVariant: string;
hideRemoveInSettings: boolean; hideRemoveInSettings: boolean;
}> = observer(({ model, showFlowSettings, flowSettingsVariant, hideRemoveInSettings }) => { showErrorFallback?: boolean;
}> = observer(({ model, showFlowSettings, flowSettingsVariant, hideRemoveInSettings, showErrorFallback }) => {
// 渲染模型内容 // 渲染模型内容
const modelContent = model.render(); const modelContent = model.render();
// 如果不显示流程设置,直接返回模型内容 // 包装 ErrorBoundary 的辅助函数
const wrapWithErrorBoundary = (children: React.ReactNode) => {
if (showErrorFallback) {
return <ErrorBoundary FallbackComponent={FlowErrorFallback}>{children}</ErrorBoundary>;
}
return children;
};
// 如果不显示流程设置,直接返回模型内容(可能包装 ErrorBoundary
if (!showFlowSettings) { if (!showFlowSettings) {
return modelContent; return wrapWithErrorBoundary(modelContent);
} }
// 根据 flowSettingsVariant 包装相应的设置组件 // 根据 flowSettingsVariant 包装相应的设置组件
@ -158,26 +179,26 @@ const FlowModelRendererCore: React.FC<{
showBackground={_.isObject(showFlowSettings) ? showFlowSettings.showBackground : undefined} showBackground={_.isObject(showFlowSettings) ? showFlowSettings.showBackground : undefined}
showBorder={_.isObject(showFlowSettings) ? showFlowSettings.showBorder : undefined} showBorder={_.isObject(showFlowSettings) ? showFlowSettings.showBorder : undefined}
> >
{modelContent} {wrapWithErrorBoundary(modelContent)}
</FlowsFloatContextMenu> </FlowsFloatContextMenu>
); );
case 'contextMenu': case 'contextMenu':
return ( return (
<FlowsContextMenu model={model} showDeleteButton={!hideRemoveInSettings}> <FlowsContextMenu model={model} showDeleteButton={!hideRemoveInSettings}>
{modelContent} {wrapWithErrorBoundary(modelContent)}
</FlowsContextMenu> </FlowsContextMenu>
); );
case 'modal': case 'modal':
// TODO: 实现 modal 模式的流程设置 // TODO: 实现 modal 模式的流程设置
console.warn('FlowModelRenderer: modal variant is not implemented yet'); console.warn('FlowModelRenderer: modal variant is not implemented yet');
return modelContent; return wrapWithErrorBoundary(modelContent);
case 'drawer': case 'drawer':
// TODO: 实现 drawer 模式的流程设置 // TODO: 实现 drawer 模式的流程设置
console.warn('FlowModelRenderer: drawer variant is not implemented yet'); console.warn('FlowModelRenderer: drawer variant is not implemented yet');
return modelContent; return wrapWithErrorBoundary(modelContent);
default: default:
console.warn(`FlowModelRenderer: Unknown flowSettingsVariant '${flowSettingsVariant}', falling back to dropdown`); console.warn(`FlowModelRenderer: Unknown flowSettingsVariant '${flowSettingsVariant}', falling back to dropdown`);
@ -188,7 +209,7 @@ const FlowModelRendererCore: React.FC<{
showBackground={_.isObject(showFlowSettings) ? showFlowSettings.showBackground : undefined} showBackground={_.isObject(showFlowSettings) ? showFlowSettings.showBackground : undefined}
showBorder={_.isObject(showFlowSettings) ? showFlowSettings.showBorder : undefined} showBorder={_.isObject(showFlowSettings) ? showFlowSettings.showBorder : undefined}
> >
{modelContent} {wrapWithErrorBoundary(modelContent)}
</FlowsFloatContextMenu> </FlowsFloatContextMenu>
); );
} }
@ -221,6 +242,7 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
extraContext, extraContext,
sharedContext, sharedContext,
independentAutoFlowExecution = false, independentAutoFlowExecution = false,
showErrorFallback = false,
}) => { }) => {
if (!model || typeof model.render !== 'function') { if (!model || typeof model.render !== 'function') {
// 可以选择渲染 null 或者一个错误/提示信息 // 可以选择渲染 null 或者一个错误/提示信息
@ -242,6 +264,7 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
flowSettingsVariant={flowSettingsVariant} flowSettingsVariant={flowSettingsVariant}
hideRemoveInSettings={hideRemoveInSettings} hideRemoveInSettings={hideRemoveInSettings}
sharedContext={sharedContext} sharedContext={sharedContext}
showErrorFallback={showErrorFallback}
/> />
</Suspense> </Suspense>
); );
@ -256,6 +279,7 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
extraContext={extraContext} extraContext={extraContext}
sharedContext={sharedContext} sharedContext={sharedContext}
independentAutoFlowExecution={independentAutoFlowExecution} independentAutoFlowExecution={independentAutoFlowExecution}
showErrorFallback={showErrorFallback}
/> />
</Suspense> </Suspense>
); );

View File

@ -11,4 +11,5 @@ export * from './common/FlowSettingsButton';
export * from './FlowModelRenderer'; export * from './FlowModelRenderer';
export * from './settings'; export * from './settings';
export * from './subModel'; export * from './subModel';
export * from './FlowErrorFallback';
// //