mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-07-02 03:02:19 +08:00
feat: add error boundary
This commit is contained in:
parent
1fc045e45c
commit
c4d31e802d
@ -18,7 +18,12 @@ function Grid({ items }) {
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
|
314
packages/core/flow-engine/src/components/FlowErrorFallback.tsx
Normal file
314
packages/core/flow-engine/src/components/FlowErrorFallback.tsx
Normal 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;
|
@ -39,10 +39,12 @@ import { observer } from '@formily/reactive-react';
|
||||
import { Spin } from 'antd';
|
||||
import _ from 'lodash';
|
||||
import React, { Suspense, useEffect } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { useApplyAutoFlows, useFlowExtraContext, FlowModelProvider } from '../hooks';
|
||||
import { FlowModel } from '../models';
|
||||
import { FlowsContextMenu } from './settings/wrappers/contextual/FlowsContextMenu';
|
||||
import { FlowsFloatContextMenu } from './settings/wrappers/contextual/FlowsFloatContextMenu';
|
||||
import { FlowErrorFallback } from './FlowErrorFallback';
|
||||
|
||||
interface FlowModelRendererProps {
|
||||
model?: FlowModel;
|
||||
@ -70,6 +72,9 @@ interface FlowModelRendererProps {
|
||||
|
||||
/** 是否为每个组件独立执行 auto flow,默认 false */
|
||||
independentAutoFlowExecution?: boolean; // 默认 false
|
||||
|
||||
/** 是否在最外层包装 FlowErrorFallback 组件,默认 false */
|
||||
showErrorFallback?: boolean; // 默认 false
|
||||
}
|
||||
|
||||
/**
|
||||
@ -83,6 +88,7 @@ const FlowModelRendererWithAutoFlows: React.FC<{
|
||||
extraContext?: Record<string, any>;
|
||||
sharedContext?: Record<string, any>;
|
||||
independentAutoFlowExecution?: boolean;
|
||||
showErrorFallback?: boolean;
|
||||
}> = observer(
|
||||
({
|
||||
model,
|
||||
@ -92,6 +98,7 @@ const FlowModelRendererWithAutoFlows: React.FC<{
|
||||
extraContext,
|
||||
sharedContext,
|
||||
independentAutoFlowExecution,
|
||||
showErrorFallback,
|
||||
}) => {
|
||||
const defaultExtraContext = useFlowExtraContext();
|
||||
useApplyAutoFlows(model, extraContext || defaultExtraContext, !independentAutoFlowExecution);
|
||||
@ -103,6 +110,7 @@ const FlowModelRendererWithAutoFlows: React.FC<{
|
||||
showFlowSettings={showFlowSettings}
|
||||
flowSettingsVariant={flowSettingsVariant}
|
||||
hideRemoveInSettings={hideRemoveInSettings}
|
||||
showErrorFallback={showErrorFallback}
|
||||
/>
|
||||
</FlowModelProvider>
|
||||
);
|
||||
@ -118,18 +126,22 @@ const FlowModelRendererWithoutAutoFlows: React.FC<{
|
||||
flowSettingsVariant: string;
|
||||
hideRemoveInSettings: boolean;
|
||||
sharedContext?: Record<string, any>;
|
||||
}> = observer(({ model, showFlowSettings, flowSettingsVariant, hideRemoveInSettings, sharedContext }) => {
|
||||
return (
|
||||
<FlowModelProvider model={model}>
|
||||
<FlowModelRendererCore
|
||||
model={model}
|
||||
showFlowSettings={showFlowSettings}
|
||||
flowSettingsVariant={flowSettingsVariant}
|
||||
hideRemoveInSettings={hideRemoveInSettings}
|
||||
/>
|
||||
</FlowModelProvider>
|
||||
);
|
||||
});
|
||||
showErrorFallback?: boolean;
|
||||
}> = observer(
|
||||
({ model, showFlowSettings, flowSettingsVariant, hideRemoveInSettings, sharedContext, showErrorFallback }) => {
|
||||
return (
|
||||
<FlowModelProvider model={model}>
|
||||
<FlowModelRendererCore
|
||||
model={model}
|
||||
showFlowSettings={showFlowSettings}
|
||||
flowSettingsVariant={flowSettingsVariant}
|
||||
hideRemoveInSettings={hideRemoveInSettings}
|
||||
showErrorFallback={showErrorFallback}
|
||||
/>
|
||||
</FlowModelProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 核心渲染逻辑组件
|
||||
@ -139,13 +151,22 @@ const FlowModelRendererCore: React.FC<{
|
||||
showFlowSettings: boolean | { showBackground?: boolean; showBorder?: boolean };
|
||||
flowSettingsVariant: string;
|
||||
hideRemoveInSettings: boolean;
|
||||
}> = observer(({ model, showFlowSettings, flowSettingsVariant, hideRemoveInSettings }) => {
|
||||
showErrorFallback?: boolean;
|
||||
}> = observer(({ model, showFlowSettings, flowSettingsVariant, hideRemoveInSettings, showErrorFallback }) => {
|
||||
// 渲染模型内容
|
||||
const modelContent = model.render();
|
||||
|
||||
// 如果不显示流程设置,直接返回模型内容
|
||||
// 包装 ErrorBoundary 的辅助函数
|
||||
const wrapWithErrorBoundary = (children: React.ReactNode) => {
|
||||
if (showErrorFallback) {
|
||||
return <ErrorBoundary FallbackComponent={FlowErrorFallback}>{children}</ErrorBoundary>;
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
// 如果不显示流程设置,直接返回模型内容(可能包装 ErrorBoundary)
|
||||
if (!showFlowSettings) {
|
||||
return modelContent;
|
||||
return wrapWithErrorBoundary(modelContent);
|
||||
}
|
||||
|
||||
// 根据 flowSettingsVariant 包装相应的设置组件
|
||||
@ -158,26 +179,26 @@ const FlowModelRendererCore: React.FC<{
|
||||
showBackground={_.isObject(showFlowSettings) ? showFlowSettings.showBackground : undefined}
|
||||
showBorder={_.isObject(showFlowSettings) ? showFlowSettings.showBorder : undefined}
|
||||
>
|
||||
{modelContent}
|
||||
{wrapWithErrorBoundary(modelContent)}
|
||||
</FlowsFloatContextMenu>
|
||||
);
|
||||
|
||||
case 'contextMenu':
|
||||
return (
|
||||
<FlowsContextMenu model={model} showDeleteButton={!hideRemoveInSettings}>
|
||||
{modelContent}
|
||||
{wrapWithErrorBoundary(modelContent)}
|
||||
</FlowsContextMenu>
|
||||
);
|
||||
|
||||
case 'modal':
|
||||
// TODO: 实现 modal 模式的流程设置
|
||||
console.warn('FlowModelRenderer: modal variant is not implemented yet');
|
||||
return modelContent;
|
||||
return wrapWithErrorBoundary(modelContent);
|
||||
|
||||
case 'drawer':
|
||||
// TODO: 实现 drawer 模式的流程设置
|
||||
console.warn('FlowModelRenderer: drawer variant is not implemented yet');
|
||||
return modelContent;
|
||||
return wrapWithErrorBoundary(modelContent);
|
||||
|
||||
default:
|
||||
console.warn(`FlowModelRenderer: Unknown flowSettingsVariant '${flowSettingsVariant}', falling back to dropdown`);
|
||||
@ -188,7 +209,7 @@ const FlowModelRendererCore: React.FC<{
|
||||
showBackground={_.isObject(showFlowSettings) ? showFlowSettings.showBackground : undefined}
|
||||
showBorder={_.isObject(showFlowSettings) ? showFlowSettings.showBorder : undefined}
|
||||
>
|
||||
{modelContent}
|
||||
{wrapWithErrorBoundary(modelContent)}
|
||||
</FlowsFloatContextMenu>
|
||||
);
|
||||
}
|
||||
@ -221,6 +242,7 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
|
||||
extraContext,
|
||||
sharedContext,
|
||||
independentAutoFlowExecution = false,
|
||||
showErrorFallback = false,
|
||||
}) => {
|
||||
if (!model || typeof model.render !== 'function') {
|
||||
// 可以选择渲染 null 或者一个错误/提示信息
|
||||
@ -242,6 +264,7 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
|
||||
flowSettingsVariant={flowSettingsVariant}
|
||||
hideRemoveInSettings={hideRemoveInSettings}
|
||||
sharedContext={sharedContext}
|
||||
showErrorFallback={showErrorFallback}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
@ -256,6 +279,7 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
|
||||
extraContext={extraContext}
|
||||
sharedContext={sharedContext}
|
||||
independentAutoFlowExecution={independentAutoFlowExecution}
|
||||
showErrorFallback={showErrorFallback}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
@ -11,4 +11,5 @@ export * from './common/FlowSettingsButton';
|
||||
export * from './FlowModelRenderer';
|
||||
export * from './settings';
|
||||
export * from './subModel';
|
||||
export * from './FlowErrorFallback';
|
||||
//
|
||||
|
Loading…
x
Reference in New Issue
Block a user