feat: 实现统一日志系统和维护模式功能
This commit is contained in:
@@ -54,6 +54,7 @@ import { SWRConfig, swrConfig } from './hooks/useSWR';
|
||||
import axios from 'axios';
|
||||
import { Spin } from 'antd';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import MaintenanceBanner from './components/MaintenanceBanner';
|
||||
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
const DeviceManagement = lazy(() => import('./pages/DeviceManagement'));
|
||||
@@ -644,6 +645,7 @@ const ThemeConfig = () => {
|
||||
<AntdConfigProvider theme={{ token: designTokens }}>
|
||||
<SWRConfig value={swrConfig}>
|
||||
<Router>
|
||||
<MaintenanceBanner />
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Routes>
|
||||
<Route
|
||||
|
||||
@@ -2,6 +2,12 @@ import axios from 'axios';
|
||||
import { API_CONFIG } from '../config/api';
|
||||
import secureStorage, { TOKEN_KEY } from '../utils/secureStorage';
|
||||
|
||||
let maintenanceCallback = null;
|
||||
|
||||
export function setMaintenanceCallback(callback) {
|
||||
maintenanceCallback = callback;
|
||||
}
|
||||
|
||||
// 给全局 axios 默认实例添加 Token 拦截器
|
||||
// 确保所有页面中直接使用 axios.get/post 的请求也能自动携带 Token
|
||||
axios.interceptors.request.use(config => {
|
||||
@@ -69,13 +75,19 @@ api.interceptors.response.use(
|
||||
if (error.response) {
|
||||
const { status, data } = error.response;
|
||||
|
||||
if (status === 503 && data?.maintenance) {
|
||||
if (maintenanceCallback) {
|
||||
maintenanceCallback(data.maintenance);
|
||||
}
|
||||
return Promise.reject(new Error('系统维护中'));
|
||||
}
|
||||
|
||||
if (status === 401) {
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
if (!currentPath.startsWith('/login')) {
|
||||
secureStorage.remove(TOKEN_KEY);
|
||||
secureStorage.remove('user');
|
||||
// 使用 React Router 导航而非强制刷新,保留 SPA 状态
|
||||
if (window.__navigate) {
|
||||
window.__navigate('/login');
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Alert, Space, Button } from 'antd';
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import { setMaintenanceCallback } from '../api';
|
||||
|
||||
const MAINTENANCE_STORAGE_KEY = 'maintenance_hidden_until';
|
||||
|
||||
/**
|
||||
* 维护模式提示横幅组件
|
||||
* 当系统处于维护模式时,在页面顶部显示提示信息
|
||||
*/
|
||||
function MaintenanceBanner() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [reason, setReason] = useState('系统维护中');
|
||||
const [startTime, setStartTime] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMaintenanceCallback(handleMaintenance);
|
||||
}, []);
|
||||
|
||||
const handleMaintenance = maintenanceData => {
|
||||
const hiddenUntil = localStorage.getItem(MAINTENANCE_STORAGE_KEY);
|
||||
if (hiddenUntil && Date.now() < parseInt(hiddenUntil, 10)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReason(maintenanceData.reason || '系统维护中');
|
||||
setStartTime(maintenanceData.startTime);
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
const hideDuration = 5 * 60 * 1000;
|
||||
localStorage.setItem(MAINTENANCE_STORAGE_KEY, String(Date.now() + hideDuration));
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const formattedTime = startTime ? new Date(startTime).toLocaleString('zh-CN') : null;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
message='系统维护中'
|
||||
description={
|
||||
<Space direction='vertical' size={4} style={{ width: '100%' }}>
|
||||
<div>{reason}</div>
|
||||
{formattedTime && <div style={{ fontSize: 12, color: '#888' }}>开始时间:{formattedTime}</div>}
|
||||
</Space>
|
||||
}
|
||||
type='warning'
|
||||
showIcon
|
||||
closable
|
||||
closeIcon={<CloseOutlined />}
|
||||
onClose={handleClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 9999,
|
||||
borderRadius: 0,
|
||||
borderBottom: '1px solid #faad14',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default MaintenanceBanner;
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Row,
|
||||
Col,
|
||||
Dropdown,
|
||||
Checkbox,
|
||||
} from 'antd';
|
||||
import {
|
||||
CloudDownloadOutlined,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
ClearOutlined,
|
||||
EyeOutlined,
|
||||
TableOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import api, { backupAPI } from '../api';
|
||||
import CloseButton from '../components/CloseButton';
|
||||
@@ -49,6 +51,34 @@ import secureStorage, { TOKEN_KEY } from '../utils/secureStorage';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const TABLE_NAME_MAPPING = {
|
||||
User: '用户',
|
||||
Role: '角色',
|
||||
UserRole: '用户角色关联',
|
||||
Permission: '权限',
|
||||
Room: '机房',
|
||||
Rack: '机柜',
|
||||
Device: '设备',
|
||||
DeviceField: '设备自定义字段',
|
||||
DevicePort: '设备端口',
|
||||
NetworkCard: '网卡',
|
||||
Cable: '线缆',
|
||||
PendingDevice: '待入库设备',
|
||||
FaultCategory: '故障分类',
|
||||
Ticket: '工单',
|
||||
TicketField: '工单自定义字段',
|
||||
TicketOperationRecord: '工单操作记录',
|
||||
ConsumableCategory: '耗材分类',
|
||||
Consumable: '耗材',
|
||||
ConsumableRecord: '耗材记录',
|
||||
ConsumableLog: '耗材操作日志',
|
||||
ConsumableLogArchive: '耗材操作日志归档',
|
||||
InventoryPlan: '盘点计划',
|
||||
InventoryTask: '盘点任务',
|
||||
InventoryRecord: '盘点记录',
|
||||
SystemSetting: '系统设置',
|
||||
};
|
||||
|
||||
const formatBytes = bytes => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
@@ -136,6 +166,8 @@ const BackupManagement = () => {
|
||||
const [backupInfo, setBackupInfo] = useState(null);
|
||||
const [restoreProgress, setRestoreProgress] = useState(0);
|
||||
const [restoreStatus, setRestoreStatus] = useState('');
|
||||
const [skipUserData, setSkipUserData] = useState(false);
|
||||
const [eventSourceRef, setEventSourceRef] = useState(null);
|
||||
|
||||
const fetchBackups = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -624,12 +656,14 @@ const BackupManagement = () => {
|
||||
const options = {
|
||||
overwriteExisting: true,
|
||||
skipFiles: false,
|
||||
skipUserData: skipUserData,
|
||||
};
|
||||
|
||||
const eventSource = new EventSource(
|
||||
`/api/backup/restore-progress/${encodeURIComponent(filename)}?token=${encodeURIComponent(token)}&options=${encodeURIComponent(JSON.stringify(options))}`
|
||||
);
|
||||
|
||||
setEventSourceRef(eventSource);
|
||||
let resultData = null;
|
||||
|
||||
eventSource.onmessage = event => {
|
||||
@@ -642,6 +676,7 @@ const BackupManagement = () => {
|
||||
if (data.stage === 'complete') {
|
||||
resultData = data.result;
|
||||
eventSource.close();
|
||||
setEventSourceRef(null);
|
||||
|
||||
setTimeout(() => {
|
||||
setRestoreVisible(false);
|
||||
@@ -851,6 +886,129 @@ const BackupManagement = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resultData?.skipped && resultData.skipped.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
borderRadius: '16px',
|
||||
border: '1px solid #f59e0b30',
|
||||
marginBottom: 16,
|
||||
animation: 'slideUp 0.5s ease 0.15s forwards',
|
||||
opacity: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '8px',
|
||||
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<SafetyOutlined style={{ color: '#fff', fontSize: 16 }} />
|
||||
</div>
|
||||
<span style={{ fontWeight: 600, color: '#92400e', fontSize: 15 }}>
|
||||
已跳过的数据表
|
||||
</span>
|
||||
</div>
|
||||
<Tag
|
||||
style={{
|
||||
background: '#fff7ed',
|
||||
border: '1px solid #fed7aa',
|
||||
color: '#c2410c',
|
||||
borderRadius: '6px',
|
||||
padding: '2px 8px',
|
||||
}}
|
||||
>
|
||||
共 {resultData.skipped.length} 个表
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: 8,
|
||||
padding: '4px',
|
||||
}}
|
||||
>
|
||||
{resultData.skipped.map((tableName, index) => (
|
||||
<div
|
||||
key={tableName}
|
||||
className="table-card"
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
background: '#ffffff',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
border: '1px solid #fcd34d',
|
||||
transition: 'all 0.25s ease',
|
||||
cursor: 'default',
|
||||
animationDelay: `${index * 0.03}s`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: '#f59e0b',
|
||||
boxShadow: '0 0 8px rgba(245, 158, 11, 0.5)',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: '#78350f',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{TABLE_NAME_MAPPING[tableName] || tableName}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '4px 10px',
|
||||
borderRadius: '6px',
|
||||
background: '#fef3c7',
|
||||
border: '1px solid #fcd34d',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: '#b45309',
|
||||
}}
|
||||
>
|
||||
已跳过
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resultData?.tableDetails && Object.keys(resultData.tableDetails).length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
@@ -1038,8 +1196,43 @@ const BackupManagement = () => {
|
||||
}, 500);
|
||||
}
|
||||
|
||||
if (data.stage === 'stopped') {
|
||||
resultData = data.result;
|
||||
eventSource.close();
|
||||
setEventSourceRef(null);
|
||||
setRestoreLoading(false);
|
||||
setRestoreVisible(false);
|
||||
|
||||
const isRolledBack = data.result?.rolledBack;
|
||||
Modal.info({
|
||||
title: isRolledBack ? '恢复已停止并回滚' : '恢复已停止',
|
||||
content: (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<p>数据恢复已被用户停止。</p>
|
||||
{isRolledBack ? (
|
||||
<p style={{ color: '#52c41a', marginTop: 8 }}>
|
||||
数据已成功回滚到恢复前的状态,无需担心数据完整性。
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p style={{ marginTop: 8, color: '#666' }}>
|
||||
已恢复 {data.result?.tablesRestored || 0} 个表,
|
||||
{data.result?.recordsRestored || 0} 条记录。
|
||||
</p>
|
||||
<p style={{ color: '#f59e0b', marginTop: 8 }}>
|
||||
警告:数据回滚失败,数据可能处于不一致状态,建议检查数据完整性。
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
okText: '知道了',
|
||||
});
|
||||
}
|
||||
|
||||
if (data.stage === 'error') {
|
||||
eventSource.close();
|
||||
setEventSourceRef(null);
|
||||
setRestoreLoading(false);
|
||||
Modal.error({
|
||||
title: '数据恢复失败',
|
||||
@@ -1054,6 +1247,7 @@ const BackupManagement = () => {
|
||||
eventSource.onerror = error => {
|
||||
console.error('SSE 连接错误:', error);
|
||||
eventSource.close();
|
||||
setEventSourceRef(null);
|
||||
setRestoreLoading(false);
|
||||
Modal.error({
|
||||
title: '数据恢复失败',
|
||||
@@ -1062,6 +1256,32 @@ const BackupManagement = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const handleStopRestore = () => {
|
||||
if (eventSourceRef) {
|
||||
console.log('用户请求停止恢复');
|
||||
setRestoreStatus('正在停止恢复...');
|
||||
eventSourceRef.close();
|
||||
setEventSourceRef(null);
|
||||
|
||||
// 设置超时:如果5秒内没有收到 stopped 事件,强制重置状态
|
||||
setTimeout(() => {
|
||||
setRestoreLoading(prev => {
|
||||
if (prev) {
|
||||
console.log('停止恢复超时,强制重置状态');
|
||||
setRestoreVisible(false);
|
||||
Modal.warning({
|
||||
title: '停止恢复',
|
||||
content: '恢复操作已停止,请刷新页面查看最新数据状态。',
|
||||
okText: '知道了',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async filename => {
|
||||
try {
|
||||
const response = await api.delete(`/backup/${filename}`);
|
||||
@@ -1155,6 +1375,7 @@ const BackupManagement = () => {
|
||||
|
||||
const showRestoreConfirm = record => {
|
||||
setSelectedBackup(record);
|
||||
setSkipUserData(false);
|
||||
setRestoreVisible(true);
|
||||
};
|
||||
|
||||
@@ -1992,6 +2213,18 @@ const BackupManagement = () => {
|
||||
>
|
||||
{restoreStatus}
|
||||
</Text>
|
||||
<Button
|
||||
danger
|
||||
icon={<CloseCircleOutlined />}
|
||||
onClick={handleStopRestore}
|
||||
style={{
|
||||
marginTop: 24,
|
||||
...buttonStyles.danger.base,
|
||||
padding: '8px 24px',
|
||||
}}
|
||||
>
|
||||
停止恢复
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -2059,6 +2292,34 @@ const BackupManagement = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: `${designTokens.colors.primary.main}05`,
|
||||
border: `1px solid ${designTokens.colors.primary.main}20`,
|
||||
borderRadius: designTokens.borderRadius.md,
|
||||
padding: '16px',
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={skipUserData}
|
||||
onChange={e => setSkipUserData(e.target.checked)}
|
||||
style={{ fontWeight: 600, color: designTokens.colors.text.primary }}
|
||||
>
|
||||
跳过用户数据(保留当前系统用户账户)
|
||||
</Checkbox>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: designTokens.colors.text.secondary,
|
||||
marginTop: 8,
|
||||
paddingLeft: 24,
|
||||
}}
|
||||
>
|
||||
选择此项后,将不会恢复备份中的用户和用户角色数据,保留当前系统已有的用户账户
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '20px 0' }} />
|
||||
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user