feat(backup): 实现自动备份功能及错误边界处理
This commit is contained in:
+67
-21
@@ -53,6 +53,7 @@ import useIdleTimeout from './hooks/useIdleTimeout';
|
||||
import { SWRConfig, swrConfig } from './hooks/useSWR';
|
||||
import axios from 'axios';
|
||||
import { Spin } from 'antd';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
const DeviceManagement = lazy(() => import('./pages/DeviceManagement'));
|
||||
@@ -76,6 +77,9 @@ const PortManagement = lazy(() => import('./pages/PortManagement'));
|
||||
const InventoryManagement = lazy(() => import('./pages/InventoryManagement'));
|
||||
const InventoryTaskExecution = lazy(() => import('./pages/InventoryTaskExecution'));
|
||||
const PendingDeviceManagement = lazy(() => import('./pages/PendingDeviceManagement'));
|
||||
const BackupManagement = lazy(() => import('./pages/BackupManagement'));
|
||||
const AutoBackupSettings = lazy(() => import('./pages/AutoBackupSettings'));
|
||||
const ErrorBoundaryTest = lazy(() => import('./pages/ErrorBoundaryTest'));
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
|
||||
@@ -198,7 +202,8 @@ const AppLayout = ({ children }) => {
|
||||
path.startsWith('/users') ||
|
||||
path.startsWith('/login-history') ||
|
||||
path.startsWith('/operation-logs') ||
|
||||
path.startsWith('/settings')
|
||||
path.startsWith('/settings') ||
|
||||
path.startsWith('/backup')
|
||||
)
|
||||
return 'system-management';
|
||||
if (path.startsWith('/tickets')) return 'ticket-management';
|
||||
@@ -347,6 +352,11 @@ const AppLayout = ({ children }) => {
|
||||
icon: <SettingOutlined style={{ fontSize: '16px' }} />,
|
||||
label: <Link to="/settings">系统设置</Link>,
|
||||
},
|
||||
{
|
||||
key: 'backup',
|
||||
icon: <DatabaseOutlined style={{ fontSize: '16px' }} />,
|
||||
label: <Link to="/backup">数据备份</Link>,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -583,43 +593,79 @@ const routeConfig = [
|
||||
{ path: '/inventory/execution', component: InventoryTaskExecution },
|
||||
{ path: '/pending-devices', component: PendingDeviceManagement },
|
||||
{ path: '/ports', component: PortManagement },
|
||||
{ path: '/backup', component: BackupManagement },
|
||||
{ path: '/auto-backup-settings', component: AutoBackupSettings },
|
||||
{ path: '/error-boundary-test', component: ErrorBoundaryTest },
|
||||
];
|
||||
|
||||
const ThemeConfig = () => {
|
||||
const designTokens = useDesignTokens();
|
||||
|
||||
const renderRoute = (path, Component, needsErrorBoundary = true) => {
|
||||
const element = (
|
||||
<PrivateRoute>
|
||||
<Component />
|
||||
</PrivateRoute>
|
||||
);
|
||||
|
||||
if (needsErrorBoundary) {
|
||||
return (
|
||||
<Route
|
||||
key={path}
|
||||
path={path}
|
||||
element={
|
||||
<ErrorBoundary
|
||||
fullPage={false}
|
||||
title="页面加载失败"
|
||||
subTitle="该页面在加载过程中遇到了错误,请尝试重新加载"
|
||||
>
|
||||
{element}
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Route key={path} path={path} element={element} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<AntdConfigProvider theme={{ token: designTokens }}>
|
||||
<SWRConfig value={swrConfig}>
|
||||
<Router>
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
{routeConfig.map(({ path, component: Component }) => (
|
||||
<Route
|
||||
key={path}
|
||||
path={path}
|
||||
path="/login"
|
||||
element={
|
||||
<ErrorBoundary fullPage>
|
||||
<Login />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
{routeConfig.map(({ path, component: Component }) =>
|
||||
renderRoute(path, Component)
|
||||
)}
|
||||
<Route
|
||||
path="/visualization-3d"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Component />
|
||||
<ErrorBoundary
|
||||
fullPage={false}
|
||||
title="3D 可视化加载失败"
|
||||
subTitle="3D 场景在加载过程中遇到错误,可能是浏览器不支持 WebGL 或模型文件加载失败"
|
||||
>
|
||||
<Scene3DProvider>
|
||||
<Rack3DVisualization />
|
||||
</Scene3DProvider>
|
||||
</ErrorBoundary>
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Route
|
||||
path="/visualization-3d"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Scene3DProvider>
|
||||
<Rack3DVisualization />
|
||||
</Scene3DProvider>
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Router>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Router>
|
||||
</SWRConfig>
|
||||
</AntdConfigProvider>
|
||||
);
|
||||
|
||||
@@ -153,4 +153,21 @@ export const ticketCategoryAPI = {
|
||||
init: () => api.post('/ticket-categories/init'),
|
||||
};
|
||||
|
||||
export const backupAPI = {
|
||||
list: () => api.get('/backup/list'),
|
||||
create: (data = {}) => api.post('/backup', data),
|
||||
validate: filename => api.get(`/backup/validate/${filename}`),
|
||||
restore: (filename, options = {}) => api.post('/backup/restore', { filename, options }),
|
||||
download: filename => `/api/backup/download/${filename}`,
|
||||
delete: filename => api.delete(`/backup/${filename}`),
|
||||
upload: file => {
|
||||
const formData = new FormData();
|
||||
formData.append('backup', file);
|
||||
return api.post('/backup/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
},
|
||||
info: () => api.get('/backup/info'),
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -12,6 +12,7 @@ const envMapUrl = '/assets/3d/env.hdr';
|
||||
import RackModel from './RackModel';
|
||||
import { useScene3D } from '../../context/Scene3DContext';
|
||||
import * as THREE from 'three';
|
||||
import ErrorBoundary from '../ErrorBoundary';
|
||||
|
||||
// 检测是否为移动设备
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||
@@ -153,50 +154,56 @@ const Scene = forwardRef(
|
||||
}, [rackHeightMeters]);
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
shadows
|
||||
dpr={deviceDpr}
|
||||
performance={{ min: 0.5 }}
|
||||
gl={{
|
||||
antialias: true, // 对所有设备开启抗锯齿提升清晰度
|
||||
alpha: true, // 必须开启alpha以支持透明背景
|
||||
powerPreference: 'high-performance',
|
||||
}}
|
||||
style={{ background: 'transparent' }}
|
||||
<ErrorBoundary
|
||||
fullPage={false}
|
||||
title="3D 场景渲染失败"
|
||||
subTitle="3D 场景在渲染过程中遇到错误,请检查浏览器是否支持 WebGL"
|
||||
>
|
||||
<PerspectiveCamera makeDefault position={cameraPosition} fov={45} />
|
||||
|
||||
<ambientLight intensity={0.5} color="#ffffff" />
|
||||
<pointLight position={[5, 8, 5]} intensity={2} color="#ffffff" castShadow />
|
||||
<directionalLight
|
||||
position={[10, 10, 5]}
|
||||
intensity={1}
|
||||
castShadow
|
||||
shadow-mapSize={[2048, 2048]}
|
||||
shadow-camera-far={20}
|
||||
shadow-camera-left={-10}
|
||||
shadow-camera-right={10}
|
||||
shadow-camera-top={10}
|
||||
shadow-camera-bottom={-10}
|
||||
/>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<Environment files={envMapUrl} blur={0.5} resolution={256} background={false} />
|
||||
</Suspense>
|
||||
|
||||
{/* Models */}
|
||||
<group position={[0, 0, 0]}>
|
||||
<RackModel {...rackModelProps} />
|
||||
</group>
|
||||
|
||||
{/* Controls - 使用独立组件保持旋转中心固定 */}
|
||||
<Controls
|
||||
rack={rack}
|
||||
onControlsReady={api => {
|
||||
controlsApiRef.current = api;
|
||||
<Canvas
|
||||
shadows
|
||||
dpr={deviceDpr}
|
||||
performance={{ min: 0.5 }}
|
||||
gl={{
|
||||
antialias: true, // 对所有设备开启抗锯齿提升清晰度
|
||||
alpha: true, // 必须开启 alpha 以支持透明背景
|
||||
powerPreference: 'high-performance',
|
||||
}}
|
||||
/>
|
||||
</Canvas>
|
||||
style={{ background: 'transparent' }}
|
||||
>
|
||||
<PerspectiveCamera makeDefault position={cameraPosition} fov={45} />
|
||||
|
||||
<ambientLight intensity={0.5} color="#ffffff" />
|
||||
<pointLight position={[5, 8, 5]} intensity={2} color="#ffffff" castShadow />
|
||||
<directionalLight
|
||||
position={[10, 10, 5]}
|
||||
intensity={1}
|
||||
castShadow
|
||||
shadow-mapSize={[2048, 2048]}
|
||||
shadow-camera-far={20}
|
||||
shadow-camera-left={-10}
|
||||
shadow-camera-right={10}
|
||||
shadow-camera-top={10}
|
||||
shadow-camera-bottom={-10}
|
||||
/>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<Environment files={envMapUrl} blur={0.5} resolution={256} background={false} />
|
||||
</Suspense>
|
||||
|
||||
{/* Models */}
|
||||
<group position={[0, 0, 0]}>
|
||||
<RackModel {...rackModelProps} />
|
||||
</group>
|
||||
|
||||
{/* Controls - 使用独立组件保持旋转中心固定 */}
|
||||
<Controls
|
||||
rack={rack}
|
||||
onControlsReady={api => {
|
||||
controlsApiRef.current = api;
|
||||
}}
|
||||
/>
|
||||
</Canvas>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Button, Result, Space, Collapse, Typography } from 'antd';
|
||||
import {
|
||||
WarningOutlined,
|
||||
ReloadOutlined,
|
||||
HomeOutlined,
|
||||
BugOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
const { Panel } = Collapse;
|
||||
|
||||
class ErrorBoundary extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo,
|
||||
});
|
||||
|
||||
console.error('错误边界捕获到错误:', error, errorInfo);
|
||||
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
handleGoHome = () => {
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: this.props.fullPage ? '100vh' : '400px',
|
||||
background: this.props.fullPage ? '#f5f5f5' : 'transparent',
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
<Result
|
||||
status="error"
|
||||
title={this.props.title || '组件加载失败'}
|
||||
subTitle={
|
||||
this.props.subTitle ||
|
||||
'抱歉,该组件在加载过程中遇到了错误。您可以尝试重新加载或返回首页。'
|
||||
}
|
||||
icon={
|
||||
<WarningOutlined
|
||||
style={{
|
||||
fontSize: '64px',
|
||||
color: '#ff4d4f',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
extra={
|
||||
<Space size="middle">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={this.handleReload}
|
||||
>
|
||||
重新加载
|
||||
</Button>
|
||||
<Button
|
||||
icon={<HomeOutlined />}
|
||||
onClick={this.handleGoHome}
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{this.state.error && (
|
||||
<div style={{ textAlign: 'left', marginTop: '24px' }}>
|
||||
<Collapse
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: 'error-details',
|
||||
label: (
|
||||
<Space>
|
||||
<BugOutlined />
|
||||
<Text type="secondary">查看错误详情(开发环境)</Text>
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
<Paragraph
|
||||
code
|
||||
style={{
|
||||
background: '#f5f5f5',
|
||||
padding: '12px',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{this.state.error.toString()}
|
||||
</Paragraph>
|
||||
{this.state.errorInfo?.componentStack && (
|
||||
<Paragraph
|
||||
code
|
||||
style={{
|
||||
background: '#f5f5f5',
|
||||
padding: '12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
overflow: 'auto',
|
||||
maxHeight: '200px',
|
||||
}}
|
||||
>
|
||||
{this.state.errorInfo.componentStack}
|
||||
</Paragraph>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Result>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
ErrorBoundary.defaultProps = {
|
||||
fallback: null,
|
||||
fullPage: false,
|
||||
title: null,
|
||||
subTitle: null,
|
||||
onError: null,
|
||||
};
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -103,7 +103,7 @@ const DeviceFormModal = ({
|
||||
style={inputStyle}
|
||||
className="form-input-enhanced"
|
||||
>
|
||||
{field.options &&
|
||||
{Array.isArray(field.options) &&
|
||||
field.options.map((option) => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
|
||||
@@ -2,12 +2,15 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
<ErrorBoundary fullPage>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Button,
|
||||
Switch,
|
||||
InputNumber,
|
||||
Input,
|
||||
Space,
|
||||
Tag,
|
||||
Alert,
|
||||
Modal,
|
||||
message,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Typography,
|
||||
Spin,
|
||||
Radio,
|
||||
} from 'antd';
|
||||
import {
|
||||
ClockCircleOutlined,
|
||||
DatabaseOutlined,
|
||||
CheckCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
SaveOutlined,
|
||||
SettingOutlined,
|
||||
InfoCircleOutlined,
|
||||
ArrowLeftOutlined,
|
||||
ThunderboltOutlined,
|
||||
SafetyOutlined,
|
||||
CloudDownloadOutlined,
|
||||
FileProtectOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
const AutoBackupSettings = () => {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [settings, setSettings] = useState({
|
||||
enabled: false,
|
||||
hour: 2,
|
||||
minute: 0,
|
||||
includeFiles: true,
|
||||
compress: true,
|
||||
maxCount: 30,
|
||||
maxAgeDays: 90,
|
||||
});
|
||||
const [modified, setModified] = useState(false);
|
||||
const isInitialMount = useRef(true);
|
||||
const isFetching = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
fetchStatus();
|
||||
isInitialMount.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
if (isFetching.current) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
isFetching.current = true;
|
||||
const response = await axios.get('/api/backup/auto/status');
|
||||
if (response.data?.success) {
|
||||
const data = response.data.data;
|
||||
setStatus(data);
|
||||
|
||||
// 解析 Cron 表达式获取小时和分钟
|
||||
if (data.cronExpression) {
|
||||
const parts = data.cronExpression.split(' ');
|
||||
const minute = parseInt(parts[0]);
|
||||
const hour = parseInt(parts[1]);
|
||||
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
enabled: data.enabled || false,
|
||||
hour: isNaN(hour) ? 2 : hour,
|
||||
minute: isNaN(minute) ? 0 : minute,
|
||||
includeFiles: data.includeFiles !== undefined ? data.includeFiles : true,
|
||||
compress: data.compress !== undefined ? data.compress : true,
|
||||
maxCount: data.maxCount || 30,
|
||||
maxAgeDays: data.maxAgeDays || 90,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取自动备份状态失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
isFetching.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.post('/api/backup/auto/settings', {
|
||||
enabled: settings.enabled,
|
||||
hour: settings.hour,
|
||||
minute: settings.minute,
|
||||
includeFiles: settings.includeFiles,
|
||||
compress: settings.compress,
|
||||
maxCount: settings.maxCount,
|
||||
maxAgeDays: settings.maxAgeDays,
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
message.success('自动备份设置已保存');
|
||||
setModified(false);
|
||||
// 不需要立即刷新,因为保存成功后状态已经是最新的
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('保存失败:' + (error.response?.data?.message || error.message));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecuteNow = async () => {
|
||||
Modal.confirm({
|
||||
title: (
|
||||
<Space>
|
||||
<div style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '12px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<PlayCircleOutlined style={{ color: '#fff', fontSize: 20 }} />
|
||||
</div>
|
||||
<span>立即执行备份</span>
|
||||
</Space>
|
||||
),
|
||||
content: '确定要立即执行一次备份吗?这将在后台创建一个新的备份文件。',
|
||||
okText: '立即备份',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.post('/api/backup/auto/execute', {
|
||||
description: `手动触发 - ${new Date().toLocaleString('zh-CN')}`,
|
||||
includeFiles: settings.includeFiles,
|
||||
compress: settings.compress,
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
message.success('备份执行成功!');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('执行失败:' + (error.response?.data?.message || error.message));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (hour, minute) => {
|
||||
return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const formatNextRun = (nextRun) => {
|
||||
if (!nextRun) return '未知';
|
||||
return new Date(nextRun).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (!status) return 'default';
|
||||
if (status.enabled && status.isActive) return 'success';
|
||||
if (status.enabled && !status.isActive) return 'warning';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (!status) return '加载中...';
|
||||
if (status.enabled && status.isActive) return '自动备份运行中';
|
||||
if (status.enabled && !status.isActive) return '自动备份已启用但未运行';
|
||||
return '自动备份已禁用';
|
||||
};
|
||||
|
||||
// 状态卡片组件
|
||||
const StatusCard = ({ icon, title, value, subtitle, gradient }) => (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
borderRadius: '16px',
|
||||
background: gradient,
|
||||
color: '#fff',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
minHeight: '120px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<div style={{ position: 'absolute', right: -20, top: -20, opacity: 0.1 }}>
|
||||
{React.cloneElement(icon, { style: { fontSize: 120 } })}
|
||||
</div>
|
||||
<div>
|
||||
<Text style={{ fontSize: 13, opacity: 0.9, fontWeight: 500 }}>{title}</Text>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, marginTop: 8 }}>{value}</div>
|
||||
</div>
|
||||
{subtitle && <Text style={{ fontSize: 12, opacity: 0.8 }}>{subtitle}</Text>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 设置项组件
|
||||
const SettingItem = ({ title, description, children, bordered = true }) => (
|
||||
<div style={{
|
||||
padding: '16px 0',
|
||||
borderBottom: bordered ? '1px solid #f0f0f0' : 'none',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500, fontSize: 14, color: '#1f2937', marginBottom: 4 }}>
|
||||
{title}
|
||||
</div>
|
||||
{description && (
|
||||
<Paragraph style={{ margin: 0, fontSize: 12, color: '#6b7280', lineHeight: 1.5 }}>
|
||||
{description}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading && !status) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
}}>
|
||||
<Spin size="large" tip="加载设置中..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
padding: '24px',
|
||||
}}>
|
||||
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
|
||||
{/* 页面头部 */}
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/backup')}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
返回备份管理
|
||||
</Button>
|
||||
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
padding: '24px 32px',
|
||||
borderRadius: '20px',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.05)',
|
||||
marginBottom: 24,
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}>
|
||||
<div>
|
||||
<Title level={2} style={{
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '28px',
|
||||
fontWeight: 700,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}}>
|
||||
<SettingOutlined style={{ marginRight: 12, verticalAlign: 'middle' }} />
|
||||
自动备份设置
|
||||
</Title>
|
||||
<Paragraph style={{ margin: 0, fontSize: 14, color: '#6b7280' }}>
|
||||
配置定时自动备份,确保数据安全无忧
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Space size="middle">
|
||||
<Button
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={handleExecuteNow}
|
||||
loading={loading}
|
||||
size="large"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
fontWeight: 500,
|
||||
borderRadius: '12px',
|
||||
padding: '8px 24px',
|
||||
}}
|
||||
>
|
||||
立即备份
|
||||
</Button>
|
||||
<Button
|
||||
icon={<SaveOutlined />}
|
||||
type="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!modified || loading}
|
||||
loading={loading}
|
||||
size="large"
|
||||
style={{
|
||||
borderRadius: '12px',
|
||||
padding: '8px 24px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
保存设置
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态概览 */}
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<Title level={4} style={{ margin: '0 0 16px 0', color: '#1f2937', fontWeight: 600 }}>
|
||||
<ThunderboltOutlined style={{ marginRight: 8 }} />
|
||||
状态概览
|
||||
</Title>
|
||||
<Row gutter={[20, 20]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<StatusCard
|
||||
icon={<CheckCircleOutlined />}
|
||||
title="当前状态"
|
||||
value={getStatusText()}
|
||||
gradient={status?.enabled && status.isActive ?
|
||||
'linear-gradient(135deg, #10b981 0%, #34d399 100%)' :
|
||||
'linear-gradient(135deg, #6b7280 0%, #9ca3af 100%)'}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<StatusCard
|
||||
icon={<ClockCircleOutlined />}
|
||||
title="下次执行时间"
|
||||
value={formatNextRun(status?.nextRun)}
|
||||
gradient="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<StatusCard
|
||||
icon={<CloudDownloadOutlined />}
|
||||
title="备份时间"
|
||||
value={formatTime(settings.hour, settings.minute)}
|
||||
gradient="linear-gradient(135deg, #10b981 0%, #34d399 100%)"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<StatusCard
|
||||
icon={<FileProtectOutlined />}
|
||||
title="保留策略"
|
||||
value={`${settings.maxCount}个 / ${settings.maxAgeDays}天`}
|
||||
gradient="linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
{/* 设置卡片 */}
|
||||
<Row gutter={[24, 24]}>
|
||||
{/* 基本设置 */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<div style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '10px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<SettingOutlined style={{ color: '#fff', fontSize: 18 }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 16, fontWeight: 600 }}>基本设置</span>
|
||||
</Space>
|
||||
}
|
||||
style={{
|
||||
borderRadius: '20px',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.05)',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<SettingItem
|
||||
title="启用自动备份"
|
||||
description="开启后系统会按照设定时间自动执行备份任务"
|
||||
>
|
||||
<Switch
|
||||
checked={settings.enabled}
|
||||
onChange={(checked) => {
|
||||
setSettings({ ...settings, enabled: checked });
|
||||
setModified(true);
|
||||
}}
|
||||
size="default"
|
||||
checkedChildren="开启"
|
||||
unCheckedChildren="关闭"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
title="备份时间"
|
||||
description="每天自动执行备份的时间,建议设置在业务低峰期"
|
||||
>
|
||||
<Space>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={23}
|
||||
value={settings.hour}
|
||||
onChange={(value) => {
|
||||
setSettings({ ...settings, hour: value });
|
||||
setModified(true);
|
||||
}}
|
||||
addonAfter="时"
|
||||
disabled={!settings.enabled}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={59}
|
||||
value={settings.minute}
|
||||
onChange={(value) => {
|
||||
setSettings({ ...settings, minute: value });
|
||||
setModified(true);
|
||||
}}
|
||||
addonAfter="分"
|
||||
disabled={!settings.enabled}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</Space>
|
||||
</SettingItem>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 高级设置 */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<div style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '10px',
|
||||
background: 'linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<ThunderboltOutlined style={{ color: '#fff', fontSize: 18 }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 16, fontWeight: 600 }}>高级设置</span>
|
||||
</Space>
|
||||
}
|
||||
style={{
|
||||
borderRadius: '20px',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.05)',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<SettingItem
|
||||
title="包含文件"
|
||||
description="备份时包含上传的文件(如用户头像等)"
|
||||
>
|
||||
<Switch
|
||||
checked={settings.includeFiles}
|
||||
onChange={(checked) => {
|
||||
setSettings({ ...settings, includeFiles: checked });
|
||||
setModified(true);
|
||||
}}
|
||||
disabled={!settings.enabled}
|
||||
checkedChildren="包含"
|
||||
unCheckedChildren="不包含"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
title="压缩备份"
|
||||
description="使用 gzip 压缩备份文件,可节省约 60-80% 磁盘空间"
|
||||
>
|
||||
<Switch
|
||||
checked={settings.compress}
|
||||
onChange={(checked) => {
|
||||
setSettings({ ...settings, compress: checked });
|
||||
setModified(true);
|
||||
}}
|
||||
disabled={!settings.enabled}
|
||||
checkedChildren="压缩"
|
||||
unCheckedChildren="不压缩"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
title="最大备份数量"
|
||||
description="保留最近的备份数量,超过后会自动删除旧备份"
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={100}
|
||||
value={settings.maxCount}
|
||||
onChange={(value) => {
|
||||
setSettings({ ...settings, maxCount: value });
|
||||
setModified(true);
|
||||
}}
|
||||
addonAfter="个"
|
||||
disabled={!settings.enabled}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
title="最长保存天数"
|
||||
description="备份文件的最长保存天数,超过后会自动删除"
|
||||
bordered={false}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={365}
|
||||
value={settings.maxAgeDays}
|
||||
onChange={(value) => {
|
||||
setSettings({ ...settings, maxAgeDays: value });
|
||||
setModified(true);
|
||||
}}
|
||||
addonAfter="天"
|
||||
disabled={!settings.enabled}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</SettingItem>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Alert
|
||||
message={
|
||||
<Space>
|
||||
<InfoCircleOutlined style={{ color: '#3b82f6' }} />
|
||||
<span style={{ fontWeight: 500 }}>最佳实践建议</span>
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<CheckCircleOutlined style={{ color: '#10b981', marginTop: 2, flexShrink: 0 }} />
|
||||
<Text style={{ color: '#4b5563' }}>
|
||||
<strong>备份时间:</strong>建议设置在凌晨 2-4 点业务低峰期,避免影响正常使用
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<CheckCircleOutlined style={{ color: '#10b981', marginTop: 2, flexShrink: 0 }} />
|
||||
<Text style={{ color: '#4b5563' }}>
|
||||
<strong>保留策略:</strong>开发环境建议 7 个/7 天,生产环境建议 30 个/90 天
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<CheckCircleOutlined style={{ color: '#10b981', marginTop: 2, flexShrink: 0 }} />
|
||||
<Text style={{ color: '#4b5563' }}>
|
||||
<strong>压缩备份:</strong>强烈建议开启,可显著减少磁盘空间占用
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<CheckCircleOutlined style={{ color: '#10b981', marginTop: 2, flexShrink: 0 }} />
|
||||
<Text style={{ color: '#4b5563' }}>
|
||||
<strong>定期测试:</strong>每月至少手动执行一次备份,验证功能正常
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon={false}
|
||||
style={{
|
||||
borderRadius: '16px',
|
||||
border: '1px solid #dbeafe',
|
||||
background: 'linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 安全提示 */}
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Alert
|
||||
message={
|
||||
<Space>
|
||||
<SafetyOutlined style={{ color: '#f59e0b' }} />
|
||||
<span style={{ fontWeight: 500 }}>安全提示</span>
|
||||
</Space>
|
||||
}
|
||||
description="自动备份功能需要服务器持续运行。如果服务器重启,自动备份任务会自动恢复。请确保服务器时间设置正确。"
|
||||
type="warning"
|
||||
showIcon={false}
|
||||
style={{
|
||||
borderRadius: '16px',
|
||||
border: '1px solid #fef3c7',
|
||||
background: 'linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoBackupSettings;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -245,16 +245,17 @@ function ConsumableLogs() {
|
||||
key: 'snList',
|
||||
width: 150,
|
||||
render: (snList, record) => {
|
||||
if (!snList || snList.length === 0) {
|
||||
const snListArray = Array.isArray(snList) ? snList : [];
|
||||
if (!snListArray || snListArray.length === 0) {
|
||||
return '-';
|
||||
}
|
||||
if (record.operationType !== 'in' && record.operationType !== 'out') {
|
||||
return '-';
|
||||
}
|
||||
if (snList.length <= 3) {
|
||||
if (snListArray.length <= 3) {
|
||||
return (
|
||||
<Space size={2} wrap>
|
||||
{snList.map((sn, index) => (
|
||||
{snListArray.map((sn, index) => (
|
||||
<Tag key={index} color="purple" style={{ fontSize: '11px' }}>
|
||||
{sn}
|
||||
</Tag>
|
||||
@@ -264,7 +265,7 @@ function ConsumableLogs() {
|
||||
}
|
||||
const content = (
|
||||
<div style={{ maxHeight: '200px', overflowY: 'auto', maxWidth: '300px' }}>
|
||||
{snList.map((sn, index) => (
|
||||
{snListArray.map((sn, index) => (
|
||||
<Tag key={index} color="purple" style={{ marginBottom: '4px', fontSize: '11px' }}>
|
||||
{sn}
|
||||
</Tag>
|
||||
@@ -272,9 +273,9 @@ function ConsumableLogs() {
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Popover content={content} title={`SN列表 (${snList.length}个)`} trigger="click">
|
||||
<Popover content={content} title={`SN列表 (${snListArray.length}个)`} trigger="click">
|
||||
<Tag color="purple" style={{ cursor: 'pointer' }}>
|
||||
{snList.length} 个SN 🔍
|
||||
{snListArray.length} 个SN 🔍
|
||||
</Tag>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -715,11 +715,11 @@ function ConsumableManagement() {
|
||||
render: text => <Text type="secondary">{text}</Text>,
|
||||
},
|
||||
{
|
||||
title: 'SN数量',
|
||||
title: 'SN 数量',
|
||||
key: 'snCount',
|
||||
width: 120,
|
||||
render: (_, record) => {
|
||||
const snList = record.snList || [];
|
||||
const snList = Array.isArray(record.snList) ? record.snList : [];
|
||||
const snCount = snList.length;
|
||||
const stock = record.currentStock || 0;
|
||||
|
||||
@@ -744,7 +744,7 @@ function ConsumableManagement() {
|
||||
return (
|
||||
<Popover
|
||||
content={snContent}
|
||||
title={`SN列表 (${snCount}个)`}
|
||||
title={`SN 列表 (${snCount}个)`}
|
||||
trigger="click"
|
||||
placement="right"
|
||||
>
|
||||
@@ -1954,11 +1954,12 @@ function ConsumableManagement() {
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const filteredSnList = stockRecord.snList.filter(sn =>
|
||||
const snListArray = Array.isArray(stockRecord.snList) ? stockRecord.snList : [];
|
||||
const filteredSnList = snListArray.filter(sn =>
|
||||
sn.toLowerCase().includes(snSearchKeyword.toLowerCase())
|
||||
);
|
||||
if (filteredSnList.length === 0) {
|
||||
return <Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '16px 0' }}>无匹配的SN</Text>;
|
||||
return <Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '16px 0' }}>无匹配的 SN</Text>;
|
||||
}
|
||||
return filteredSnList.map((sn, index) => (
|
||||
<Tag.CheckableTag
|
||||
@@ -2090,7 +2091,7 @@ function ConsumableManagement() {
|
||||
padding: '8px'
|
||||
}}
|
||||
>
|
||||
{snList.map((sn, index) => (
|
||||
{(Array.isArray(snList) ? snList : []).map((sn, index) => (
|
||||
<Tag
|
||||
key={index}
|
||||
closable
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DatabaseOutlined, CloudServerOutlined, WarningOutlined, HomeOutlined, T
|
||||
import api from '../api';
|
||||
import { designTokens } from '../config/theme';
|
||||
import { useFetch } from '../hooks/useSWR';
|
||||
import ErrorBoundary from '../components/ErrorBoundary';
|
||||
import {
|
||||
AnimatedCounter,
|
||||
CircularProgress,
|
||||
@@ -380,14 +381,30 @@ function Dashboard() {
|
||||
gap: '24px',
|
||||
}}
|
||||
>
|
||||
<CircularProgress
|
||||
percentage={parseFloat(stats.onlineRate)}
|
||||
size={140}
|
||||
strokeWidth={12}
|
||||
color={designTokens.colors.success.main}
|
||||
label="在线率"
|
||||
/>
|
||||
<PowerGauge value={stats.powerUsage} maxValue={10000} />
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
|
||||
在线率图表加载失败
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CircularProgress
|
||||
percentage={parseFloat(stats.onlineRate)}
|
||||
size={140}
|
||||
strokeWidth={12}
|
||||
color={designTokens.colors.success.main}
|
||||
label="在线率"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
|
||||
功率仪表加载失败
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PowerGauge value={stats.powerUsage} maxValue={10000} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -403,7 +420,15 @@ function Dashboard() {
|
||||
周设备趋势
|
||||
</Title>
|
||||
<div style={chartContainerStyle}>
|
||||
<DeviceTrendChart data={deviceTrendData} />
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
|
||||
趋势图表加载失败
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DeviceTrendChart data={deviceTrendData} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -308,8 +308,8 @@ function DeviceManagement() {
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const response = await axios.delete('/api/devices/batch-delete', {
|
||||
data: { deviceIds: selectedDevices },
|
||||
const response = await axios.post('/api/devices/batch-delete', {
|
||||
deviceIds: selectedDevices,
|
||||
});
|
||||
message.success(response.data.message || '批量删除成功');
|
||||
setSelectedDevices([]);
|
||||
@@ -318,6 +318,10 @@ function DeviceManagement() {
|
||||
} catch (error) {
|
||||
message.error('批量删除失败');
|
||||
console.error('批量删除设备失败:', error);
|
||||
// 显示更具体的错误信息
|
||||
if (error.response?.data?.error) {
|
||||
console.error('后端错误详情:', error.response.data.error);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import React from 'react';
|
||||
import { Button, Card, Space } from 'antd';
|
||||
import ErrorBoundary from '../components/ErrorBoundary';
|
||||
|
||||
// 测试用的会抛出错误的组件
|
||||
const BrokenComponent = () => {
|
||||
throw new Error('这是一个测试错误!');
|
||||
};
|
||||
|
||||
// 测试用的会抛出错误的类组件
|
||||
class BrokenClassComponent extends React.Component {
|
||||
render() {
|
||||
throw new Error('这是一个类组件的测试错误!');
|
||||
}
|
||||
}
|
||||
|
||||
// 测试用的正常组件
|
||||
const NormalComponent = () => (
|
||||
<Card style={{ marginBottom: '16px' }}>
|
||||
<h3>✅ 正常组件</h3>
|
||||
<p>这个组件正常渲染,没有错误</p>
|
||||
</Card>
|
||||
);
|
||||
|
||||
function ErrorBoundaryTest() {
|
||||
const [showError, setShowError] = React.useState(false);
|
||||
|
||||
const handleError = (error, errorInfo) => {
|
||||
console.error('错误边界捕获到错误:', error, errorInfo);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<Card
|
||||
style={{
|
||||
marginBottom: '24px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: '24px', marginBottom: '8px' }}>
|
||||
🧪 错误边界测试页面
|
||||
</h1>
|
||||
<p style={{ opacity: 0.9 }}>
|
||||
用于测试 React 错误边界(Error Boundary)功能,确保单个组件错误不会导致整个页面崩溃
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<Card title="测试 1:根级别错误边界" size="small">
|
||||
<p>这个测试会触发整个应用级别的错误边界(在 main.jsx 中定义)</p>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
onClick={() => {
|
||||
throw new Error('根级别测试错误!');
|
||||
}}
|
||||
>
|
||||
触发根级别错误
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<Card title="测试 2:页面级别错误边界" size="small">
|
||||
<p>这个测试会触发页面级别的错误边界</p>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
onClick={() => setShowError(true)}
|
||||
>
|
||||
触发页面错误
|
||||
</Button>
|
||||
{showError && <BrokenComponent />}
|
||||
</Card>
|
||||
|
||||
<Card title="测试 3:组件级别错误边界(带自定义错误处理)" size="small">
|
||||
<ErrorBoundary
|
||||
fullPage={false}
|
||||
title="组件渲染失败"
|
||||
subTitle="这个组件在渲染时遇到了错误"
|
||||
onError={handleError}
|
||||
>
|
||||
<BrokenComponent />
|
||||
</ErrorBoundary>
|
||||
</Card>
|
||||
|
||||
<Card title="测试 4:组件级别错误边界(带自定义 fallback)" size="small">
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div
|
||||
style={{
|
||||
padding: '20px',
|
||||
background: '#fff2f0',
|
||||
border: '1px solid #ffccc7',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ color: '#ff4d4f', margin: '0 0 8px 0' }}>
|
||||
⚠️ 自定义错误提示
|
||||
</h3>
|
||||
<p style={{ margin: 0, color: '#666' }}>
|
||||
这是自定义的 fallback UI,当组件出错时会显示这个界面
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<BrokenClassComponent />
|
||||
</ErrorBoundary>
|
||||
</Card>
|
||||
|
||||
<Card title="测试 5:混合正常和错误组件" size="small">
|
||||
<p>验证错误边界只影响出错的组件,不影响其他组件</p>
|
||||
<NormalComponent />
|
||||
<ErrorBoundary>
|
||||
<BrokenComponent />
|
||||
</ErrorBoundary>
|
||||
<NormalComponent />
|
||||
</Card>
|
||||
|
||||
<Card title="测试 6:3D 可视化错误边界" size="small">
|
||||
<p>模拟 3D 场景加载失败的情况</p>
|
||||
<ErrorBoundary
|
||||
fullPage={false}
|
||||
title="3D 场景加载失败"
|
||||
subTitle="3D 场景在加载过程中遇到错误,可能是浏览器不支持 WebGL 或模型文件加载失败"
|
||||
>
|
||||
<BrokenComponent />
|
||||
</ErrorBoundary>
|
||||
</Card>
|
||||
|
||||
<Card title="使用说明" size="small">
|
||||
<ul style={{ lineHeight: '2' }}>
|
||||
<li>
|
||||
<strong>错误边界(Error Boundary)</strong>:
|
||||
是 React 提供的错误处理机制,可以捕获子组件树中的 JavaScript 错误
|
||||
</li>
|
||||
<li>
|
||||
<strong>作用</strong>:
|
||||
防止单个组件的错误导致整个应用崩溃,提供友好的错误提示界面
|
||||
</li>
|
||||
<li>
|
||||
<strong>实现方式</strong>:
|
||||
使用 React.Component 的 componentDidCatch 和 getDerivedStateFromError 生命周期方法
|
||||
</li>
|
||||
<li>
|
||||
<strong>注意事项</strong>:
|
||||
错误边界无法捕获事件处理器、异步代码、服务端渲染、自身抛出的错误
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorBoundaryTest;
|
||||
@@ -392,7 +392,7 @@ const PendingDeviceManagement = () => {
|
||||
rules={required ? [{ required: true, message: `请选择${displayName}` }] : []}
|
||||
>
|
||||
<Select placeholder={`请选择${displayName}`}>
|
||||
{(options || [
|
||||
{(Array.isArray(options) ? options : [
|
||||
{ value: 'server', label: '服务器' },
|
||||
{ value: 'switch', label: '交换机' },
|
||||
{ value: 'router', label: '路由器' },
|
||||
@@ -417,7 +417,7 @@ const PendingDeviceManagement = () => {
|
||||
rules={required ? [{ required: true, message: `请选择${displayName}` }] : []}
|
||||
>
|
||||
<Select placeholder={`请选择${displayName}`} allowClear>
|
||||
{(options || []).map(opt => (
|
||||
{(Array.isArray(options) ? options : []).map(opt => (
|
||||
<Select.Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Select.Option>
|
||||
|
||||
Reference in New Issue
Block a user