chore: 统一代码风格并配置ESLint和Prettier
配置ESLint和Prettier规则 添加前端和后端的忽略文件 统一代码格式和缩进 修复代码风格问题
This commit is contained in:
+361
-147
@@ -1,7 +1,49 @@
|
||||
import React, { useState, Suspense, lazy } from 'react';
|
||||
import { Layout, Menu, theme, Button, Dropdown, Avatar, message, Space, Divider, ConfigProvider as AntdConfigProvider } from 'antd';
|
||||
import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined, EyeOutlined, BuildOutlined, HomeOutlined, ShoppingCartOutlined, InboxOutlined, ImportOutlined, FileTextOutlined, UserOutlined, LogoutOutlined, HistoryOutlined, AuditOutlined, ToolOutlined, ScheduleOutlined, SettingOutlined, ApiOutlined, PartitionOutlined, CodepenOutlined } from '@ant-design/icons';
|
||||
import { BrowserRouter as Router, Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Layout,
|
||||
Menu,
|
||||
theme,
|
||||
Button,
|
||||
Dropdown,
|
||||
Avatar,
|
||||
message,
|
||||
Space,
|
||||
Divider,
|
||||
ConfigProvider as AntdConfigProvider,
|
||||
} from 'antd';
|
||||
import {
|
||||
BarChartOutlined,
|
||||
DatabaseOutlined,
|
||||
CloudServerOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
MenuFoldOutlined,
|
||||
EyeOutlined,
|
||||
BuildOutlined,
|
||||
HomeOutlined,
|
||||
ShoppingCartOutlined,
|
||||
InboxOutlined,
|
||||
ImportOutlined,
|
||||
FileTextOutlined,
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
HistoryOutlined,
|
||||
AuditOutlined,
|
||||
ToolOutlined,
|
||||
ScheduleOutlined,
|
||||
SettingOutlined,
|
||||
ApiOutlined,
|
||||
PartitionOutlined,
|
||||
CodepenOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
BrowserRouter as Router,
|
||||
Routes,
|
||||
Route,
|
||||
Link,
|
||||
Navigate,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from 'react-router-dom';
|
||||
import { useAuth } from './context/AuthContext';
|
||||
import { ConfigProvider, useConfig } from './context/ConfigContext';
|
||||
import { Scene3DProvider } from './context/Scene3DContext';
|
||||
@@ -31,30 +73,34 @@ const PortManagement = lazy(() => import('./pages/PortManagement'));
|
||||
const { Header, Content, Sider } = Layout;
|
||||
|
||||
const PageLoading = () => (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
background: '#f5f5f5',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
background: '#f5f5f5',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<Spin size="large" />
|
||||
<span style={{ color: '#8c8c8c', fontSize: '14px' }}>正在加载页面...</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AuthLoading = () => (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
background: '#f5f5f5',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
background: '#f5f5f5',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<Spin size="large" />
|
||||
<span style={{ color: '#8c8c8c', fontSize: '14px' }}>正在加载认证状态...</span>
|
||||
</div>
|
||||
@@ -89,7 +135,7 @@ const AppLayout = ({ children }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const designTokens = useDesignTokens();
|
||||
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
message.success('已退出登录');
|
||||
@@ -101,9 +147,21 @@ const AppLayout = ({ children }) => {
|
||||
if (path === '/') return 'dashboard';
|
||||
if (path.startsWith('/visualization-3d')) return 'visualization-3d';
|
||||
if (path.startsWith('/rooms') || path.startsWith('/racks')) return 'room-management';
|
||||
if (path.startsWith('/devices') || path.startsWith('/fields') || path.startsWith('/cables') || path.startsWith('/ports')) return 'asset-management';
|
||||
if (
|
||||
path.startsWith('/devices') ||
|
||||
path.startsWith('/fields') ||
|
||||
path.startsWith('/cables') ||
|
||||
path.startsWith('/ports')
|
||||
)
|
||||
return 'asset-management';
|
||||
if (path.startsWith('/consumables')) return 'consumables-management';
|
||||
if (path.startsWith('/users') || path.startsWith('/login-history') || path.startsWith('/operation-logs') || path.startsWith('/settings')) return 'system-management';
|
||||
if (
|
||||
path.startsWith('/users') ||
|
||||
path.startsWith('/login-history') ||
|
||||
path.startsWith('/operation-logs') ||
|
||||
path.startsWith('/settings')
|
||||
)
|
||||
return 'system-management';
|
||||
if (path.startsWith('/tickets')) return 'ticket-management';
|
||||
return 'dashboard';
|
||||
};
|
||||
@@ -218,28 +276,28 @@ const AppLayout = ({ children }) => {
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'system-management',
|
||||
icon: <UserOutlined style={{ fontSize: '18px' }} />,
|
||||
label: '系统管理',
|
||||
children: [
|
||||
{
|
||||
key: 'users',
|
||||
icon: <UserOutlined style={{ fontSize: '16px' }} />,
|
||||
label: <Link to="/users">用户管理</Link>,
|
||||
},
|
||||
{
|
||||
key: 'system-settings',
|
||||
icon: <SettingOutlined style={{ fontSize: '16px' }} />,
|
||||
label: <Link to="/settings">系统设置</Link>,
|
||||
},
|
||||
],
|
||||
},
|
||||
key: 'system-management',
|
||||
icon: <UserOutlined style={{ fontSize: '18px' }} />,
|
||||
label: '系统管理',
|
||||
children: [
|
||||
{
|
||||
key: 'users',
|
||||
icon: <UserOutlined style={{ fontSize: '16px' }} />,
|
||||
label: <Link to="/users">用户管理</Link>,
|
||||
},
|
||||
{
|
||||
key: 'system-settings',
|
||||
icon: <SettingOutlined style={{ fontSize: '16px' }} />,
|
||||
label: <Link to="/settings">系统设置</Link>,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider
|
||||
width={240}
|
||||
<Sider
|
||||
width={240}
|
||||
collapsedWidth={72}
|
||||
collapsed={collapsed}
|
||||
style={{
|
||||
@@ -252,56 +310,66 @@ const AppLayout = ({ children }) => {
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
zIndex: 100
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
padding: collapsed ? '16px 0' : '16px 20px',
|
||||
borderBottom: `1px solid ${designTokens.colors.sidebar.border}`,
|
||||
minHeight: '64px',
|
||||
background: designTokens.colors.sidebar.bg
|
||||
}}>
|
||||
<div style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
background: designTokens.colors.primary.gradient,
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
padding: collapsed ? '16px 0' : '16px 20px',
|
||||
borderBottom: `1px solid ${designTokens.colors.sidebar.border}`,
|
||||
minHeight: '64px',
|
||||
background: designTokens.colors.sidebar.bg,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
background: designTokens.colors.primary.gradient,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<CloudServerOutlined style={{ fontSize: '18px', color: '#ffffff' }} />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: designTokens.colors.primary.main,
|
||||
lineHeight: 1.2
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: designTokens.colors.primary.main,
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{config.site_name || 'IDC管理'}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: designTokens.colors.sidebar.text,
|
||||
marginTop: '2px'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: designTokens.colors.sidebar.text,
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
数据中心管理平台
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: collapsed ? '12px 0' : '12px 8px',
|
||||
overflowY: 'auto',
|
||||
flex: 1
|
||||
}}>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: collapsed ? '12px 0' : '12px 8px',
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[getSelectedKey()]}
|
||||
@@ -309,16 +377,18 @@ const AppLayout = ({ children }) => {
|
||||
style={{
|
||||
background: 'transparent',
|
||||
borderRight: 0,
|
||||
fontSize: '14px'
|
||||
fontSize: '14px',
|
||||
}}
|
||||
items={menuItems}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
borderTop: `1px solid ${designTokens.colors.sidebar.border}`
|
||||
}}>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '12px',
|
||||
borderTop: `1px solid ${designTokens.colors.sidebar.border}`,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
@@ -332,76 +402,92 @@ const AppLayout = ({ children }) => {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
gap: '8px'
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
{!collapsed && <span style={{ fontSize: '13px', color: designTokens.colors.text.secondary }}>收起菜单</span>}
|
||||
{!collapsed && (
|
||||
<span style={{ fontSize: '13px', color: designTokens.colors.text.secondary }}>
|
||||
收起菜单
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Sider>
|
||||
|
||||
<Layout style={{
|
||||
marginLeft: collapsed ? 72 : 240,
|
||||
transition: 'margin-left 0.2s ease'
|
||||
}}>
|
||||
<Header style={{
|
||||
padding: '0 24px',
|
||||
height: 64,
|
||||
background: designTokens.colors.background.primary,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
boxShadow: designTokens.shadows.small,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 99,
|
||||
overflow: 'visible'
|
||||
}}>
|
||||
|
||||
<Layout
|
||||
style={{
|
||||
marginLeft: collapsed ? 72 : 240,
|
||||
transition: 'margin-left 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<Header
|
||||
style={{
|
||||
padding: '0 24px',
|
||||
height: 64,
|
||||
background: designTokens.colors.background.primary,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
boxShadow: designTokens.shadows.small,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 99,
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
{user && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
height: '100%'
|
||||
}}>
|
||||
<div style={{
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '4px 12px',
|
||||
background: designTokens.colors.background.secondary,
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
height: '40px',
|
||||
boxSizing: 'border-box'
|
||||
}}>
|
||||
<Avatar
|
||||
style={{
|
||||
gap: '12px',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '4px 12px',
|
||||
background: designTokens.colors.background.secondary,
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
height: '40px',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
style={{
|
||||
backgroundColor: designTokens.colors.primary.main,
|
||||
cursor: 'pointer',
|
||||
width: 32,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
icon={<UserOutlined style={{ fontSize: '14px' }} />}
|
||||
/>
|
||||
<span style={{
|
||||
color: designTokens.colors.text.primary,
|
||||
fontSize: 14,
|
||||
fontWeight: 500
|
||||
}}>{user.username}</span>
|
||||
<span
|
||||
style={{
|
||||
color: designTokens.colors.text.primary,
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{user.username}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<LogoutOutlined />}
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
height: 'auto',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
fontSize: 13
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
退出
|
||||
@@ -414,7 +500,7 @@ const AppLayout = ({ children }) => {
|
||||
padding: 24,
|
||||
margin: 0,
|
||||
minHeight: 'calc(100vh - 64px)',
|
||||
background: designTokens.colors.background.secondary
|
||||
background: designTokens.colors.background.secondary,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -433,24 +519,152 @@ const ThemeConfig = () => {
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<PrivateRoute><Dashboard /></PrivateRoute>} />
|
||||
<Route path="/devices" element={<PrivateRoute><DeviceManagement /></PrivateRoute>} />
|
||||
<Route path="/racks" element={<PrivateRoute><RackManagement /></PrivateRoute>} />
|
||||
<Route path="/rooms" element={<PrivateRoute><RoomManagement /></PrivateRoute>} />
|
||||
<Route path="/fields" element={<PrivateRoute><DeviceFieldManagement /></PrivateRoute>} />
|
||||
<Route path="/visualization-3d" element={<PrivateRoute><Scene3DProvider><Rack3DVisualization /></Scene3DProvider></PrivateRoute>} />
|
||||
<Route path="/consumables" element={<PrivateRoute><ConsumableManagement /></PrivateRoute>} />
|
||||
<Route path="/consumables-categories" element={<PrivateRoute><CategoryManagement /></PrivateRoute>} />
|
||||
<Route path="/consumables-stats" element={<PrivateRoute><ConsumableStatistics /></PrivateRoute>} />
|
||||
<Route path="/consumables-logs" element={<PrivateRoute><ConsumableLogs /></PrivateRoute>} />
|
||||
<Route path="/users" element={<PrivateRoute><UserManagement /></PrivateRoute>} />
|
||||
<Route path="/tickets" element={<PrivateRoute><TicketManagement /></PrivateRoute>} />
|
||||
<Route path="/ticket-categories" element={<PrivateRoute><TicketCategoryManagement /></PrivateRoute>} />
|
||||
<Route path="/ticket-statistics" element={<PrivateRoute><TicketStatistics /></PrivateRoute>} />
|
||||
<Route path="/ticket-fields" element={<PrivateRoute><TicketFieldManagement /></PrivateRoute>} />
|
||||
<Route path="/settings" element={<PrivateRoute><SystemSettings /></PrivateRoute>} />
|
||||
<Route path="/cables" element={<PrivateRoute><CableManagement /></PrivateRoute>} />
|
||||
<Route path="/ports" element={<PrivateRoute><PortManagement /></PrivateRoute>} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Dashboard />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/devices"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<DeviceManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/racks"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<RackManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/rooms"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<RoomManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/fields"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<DeviceFieldManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/visualization-3d"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Scene3DProvider>
|
||||
<Rack3DVisualization />
|
||||
</Scene3DProvider>
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/consumables"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<ConsumableManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/consumables-categories"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<CategoryManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/consumables-stats"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<ConsumableStatistics />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/consumables-logs"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<ConsumableLogs />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<UserManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/tickets"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<TicketManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ticket-categories"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<TicketCategoryManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ticket-statistics"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<TicketStatistics />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ticket-fields"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<TicketFieldManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<SystemSettings />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/cables"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<CableManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ports"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<PortManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
@@ -467,4 +681,4 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
+77
-80
@@ -9,7 +9,7 @@ const cacheManager = (() => {
|
||||
return `${method}:${url}:${paramsStr}`;
|
||||
};
|
||||
|
||||
const isExpired = (key) => {
|
||||
const isExpired = key => {
|
||||
const timestamp = cacheTimestamps.get(key);
|
||||
if (!timestamp) return true;
|
||||
const ttl = config.get(key)?.ttl || defaultTTL;
|
||||
@@ -34,7 +34,7 @@ const cacheManager = (() => {
|
||||
return key;
|
||||
};
|
||||
|
||||
const invalidate = (url) => {
|
||||
const invalidate = url => {
|
||||
const keysToDelete = [];
|
||||
cache.forEach((_, key) => {
|
||||
if (key.includes(url)) {
|
||||
@@ -49,7 +49,7 @@ const cacheManager = (() => {
|
||||
return keysToDelete.length;
|
||||
};
|
||||
|
||||
const invalidatePattern = (pattern) => {
|
||||
const invalidatePattern = pattern => {
|
||||
const regex = new RegExp(pattern);
|
||||
const keysToDelete = [];
|
||||
cache.forEach((_, key) => {
|
||||
@@ -78,7 +78,7 @@ const cacheManager = (() => {
|
||||
const getStats = () => {
|
||||
return {
|
||||
size: cache.size,
|
||||
keys: Array.from(cache.keys())
|
||||
keys: Array.from(cache.keys()),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -90,37 +90,29 @@ const cacheManager = (() => {
|
||||
clear,
|
||||
setTTL,
|
||||
getStats,
|
||||
defaultTTL
|
||||
defaultTTL,
|
||||
};
|
||||
})();
|
||||
|
||||
const cacheInterceptor = (api) => {
|
||||
const cacheInterceptor = api => {
|
||||
const requestCache = new Set();
|
||||
const pendingRequests = new Map();
|
||||
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
config => {
|
||||
if (config.method?.toLowerCase() === 'get') {
|
||||
const cacheKey = cacheManager.generateKey(
|
||||
config.method,
|
||||
config.url,
|
||||
config.params
|
||||
);
|
||||
const cacheKey = cacheManager.generateKey(config.method, config.url, config.params);
|
||||
|
||||
if (requestCache.has(cacheKey)) {
|
||||
config.adapter = () => {
|
||||
const cachedData = cacheManager.get(
|
||||
config.method,
|
||||
config.url,
|
||||
config.params
|
||||
);
|
||||
const cachedData = cacheManager.get(config.method, config.url, config.params);
|
||||
if (cachedData) {
|
||||
return Promise.resolve({
|
||||
data: cachedData,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config
|
||||
config,
|
||||
});
|
||||
}
|
||||
requestCache.delete(cacheKey);
|
||||
@@ -130,11 +122,11 @@ const cacheInterceptor = (api) => {
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
error => Promise.reject(error)
|
||||
);
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
response => {
|
||||
if (response.config.method?.toLowerCase() === 'get') {
|
||||
const cacheKey = cacheManager.generateKey(
|
||||
response.config.method,
|
||||
@@ -151,7 +143,7 @@ const cacheInterceptor = (api) => {
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
error => {
|
||||
if (error.config) {
|
||||
const cacheKey = cacheManager.generateKey(
|
||||
error.config.method,
|
||||
@@ -178,108 +170,113 @@ export const cachedAPI = {
|
||||
});
|
||||
},
|
||||
|
||||
post: (url, data) => api.post(url, data).then(data => {
|
||||
cacheManager.invalidate(url);
|
||||
return data;
|
||||
}),
|
||||
post: (url, data) =>
|
||||
api.post(url, data).then(data => {
|
||||
cacheManager.invalidate(url);
|
||||
return data;
|
||||
}),
|
||||
|
||||
put: (url, data) => api.put(url, data).then(data => {
|
||||
cacheManager.invalidate(url);
|
||||
return data;
|
||||
}),
|
||||
put: (url, data) =>
|
||||
api.put(url, data).then(data => {
|
||||
cacheManager.invalidate(url);
|
||||
return data;
|
||||
}),
|
||||
|
||||
delete: (url) => api.delete(url).then(data => {
|
||||
cacheManager.invalidate(url);
|
||||
return data;
|
||||
}),
|
||||
delete: url =>
|
||||
api.delete(url).then(data => {
|
||||
cacheManager.invalidate(url);
|
||||
return data;
|
||||
}),
|
||||
|
||||
invalidate: (url) => cacheManager.invalidate(url),
|
||||
invalidate: url => cacheManager.invalidate(url),
|
||||
|
||||
invalidatePattern: (pattern) => cacheManager.invalidatePattern(pattern),
|
||||
invalidatePattern: pattern => cacheManager.invalidatePattern(pattern),
|
||||
|
||||
clearCache: () => cacheManager.clear(),
|
||||
|
||||
setCacheTTL: (url, ttl) => cacheManager.setTTL(url, ttl),
|
||||
|
||||
getCacheStats: () => cacheManager.getStats()
|
||||
getCacheStats: () => cacheManager.getStats(),
|
||||
};
|
||||
|
||||
export const deviceAPI = {
|
||||
list: (params) => cachedAPI.get('/devices', params),
|
||||
get: (deviceId) => cachedAPI.get(`/devices/${deviceId}`),
|
||||
create: (data) => cachedAPI.post('/devices', data),
|
||||
list: params => cachedAPI.get('/devices', params),
|
||||
get: deviceId => cachedAPI.get(`/devices/${deviceId}`),
|
||||
create: data => cachedAPI.post('/devices', data),
|
||||
update: (deviceId, data) => cachedAPI.put(`/api/devices/${deviceId}`, data),
|
||||
delete: (deviceId) => cachedAPI.delete(`/api/devices/${deviceId}`),
|
||||
batchOffline: (data) => cachedAPI.post('/devices/batch-offline', data),
|
||||
batchDelete: (data) => cachedAPI.delete('/devices/batch-delete', { data }),
|
||||
export: (params) => api.get('/devices/export', { params, responseType: 'blob' }),
|
||||
import: (formData) => api.post('/devices/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
delete: deviceId => cachedAPI.delete(`/api/devices/${deviceId}`),
|
||||
batchOffline: data => cachedAPI.post('/devices/batch-offline', data),
|
||||
batchDelete: data => cachedAPI.delete('/devices/batch-delete', { data }),
|
||||
export: params => api.get('/devices/export', { params, responseType: 'blob' }),
|
||||
import: formData =>
|
||||
api.post('/devices/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}),
|
||||
};
|
||||
|
||||
export const rackAPI = {
|
||||
list: (params) => cachedAPI.get('/racks', params),
|
||||
get: (rackId) => cachedAPI.get(`/racks/${rackId}`),
|
||||
create: (data) => cachedAPI.post('/racks', data),
|
||||
list: params => cachedAPI.get('/racks', params),
|
||||
get: rackId => cachedAPI.get(`/racks/${rackId}`),
|
||||
create: data => cachedAPI.post('/racks', data),
|
||||
update: (rackId, data) => cachedAPI.put(`/racks/${rackId}`, data),
|
||||
delete: (rackId) => cachedAPI.delete(`/racks/${rackId}`),
|
||||
import: (formData) => api.post('/racks/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
delete: rackId => cachedAPI.delete(`/racks/${rackId}`),
|
||||
import: formData =>
|
||||
api.post('/racks/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}),
|
||||
};
|
||||
|
||||
export const roomAPI = {
|
||||
list: (params) => cachedAPI.get('/rooms', params),
|
||||
get: (roomId) => cachedAPI.get(`/rooms/${roomId}`),
|
||||
create: (data) => cachedAPI.post('/rooms', data),
|
||||
list: params => cachedAPI.get('/rooms', params),
|
||||
get: roomId => cachedAPI.get(`/rooms/${roomId}`),
|
||||
create: data => cachedAPI.post('/rooms', data),
|
||||
update: (roomId, data) => cachedAPI.put(`/rooms/${roomId}`, data),
|
||||
delete: (roomId) => cachedAPI.delete(`/rooms/${roomId}`)
|
||||
delete: roomId => cachedAPI.delete(`/rooms/${roomId}`),
|
||||
};
|
||||
|
||||
export const deviceFieldAPI = {
|
||||
list: () => cachedAPI.get('/deviceFields'),
|
||||
get: (fieldId) => cachedAPI.get(`/deviceFields/${fieldId}`),
|
||||
create: (data) => cachedAPI.post('/deviceFields', data),
|
||||
get: fieldId => cachedAPI.get(`/deviceFields/${fieldId}`),
|
||||
create: data => cachedAPI.post('/deviceFields', data),
|
||||
update: (fieldId, data) => cachedAPI.put(`/deviceFields/${fieldId}`, data),
|
||||
delete: (fieldId) => cachedAPI.delete(`/deviceFields/${fieldId}`),
|
||||
updateConfig: (data) => cachedAPI.post('/deviceFields/config', data)
|
||||
delete: fieldId => cachedAPI.delete(`/deviceFields/${fieldId}`),
|
||||
updateConfig: data => cachedAPI.post('/deviceFields/config', data),
|
||||
};
|
||||
|
||||
export const consumableAPI = {
|
||||
list: (params) => cachedAPI.get('/consumables', params),
|
||||
get: (consumableId) => cachedAPI.get(`/consumables/${consumableId}`),
|
||||
create: (data) => cachedAPI.post('/consumables', data),
|
||||
list: params => cachedAPI.get('/consumables', params),
|
||||
get: consumableId => cachedAPI.get(`/consumables/${consumableId}`),
|
||||
create: data => cachedAPI.post('/consumables', data),
|
||||
update: (consumableId, data) => cachedAPI.put(`/consumables/${consumableId}`, data),
|
||||
delete: (consumableId) => cachedAPI.delete(`/consumables/${consumableId}`),
|
||||
import: (data) => cachedAPI.post('/consumables/import', data),
|
||||
quickInOut: (data) => cachedAPI.post('/consumables/quick-inout', data),
|
||||
delete: consumableId => cachedAPI.delete(`/consumables/${consumableId}`),
|
||||
import: data => cachedAPI.post('/consumables/import', data),
|
||||
quickInOut: data => cachedAPI.post('/consumables/quick-inout', data),
|
||||
getStatistics: () => cachedAPI.get('/consumables/statistics/summary'),
|
||||
getLowStock: () => cachedAPI.get('/consumables/low-stock')
|
||||
getLowStock: () => cachedAPI.get('/consumables/low-stock'),
|
||||
};
|
||||
|
||||
export const consumableCategoryAPI = {
|
||||
list: (params) => cachedAPI.get('/consumable-categories', params),
|
||||
getList: (params) => cachedAPI.get('/consumable-categories/list', params),
|
||||
create: (data) => cachedAPI.post('/consumable-categories', data),
|
||||
list: params => cachedAPI.get('/consumable-categories', params),
|
||||
getList: params => cachedAPI.get('/consumable-categories/list', params),
|
||||
create: data => cachedAPI.post('/consumable-categories', data),
|
||||
update: (id, data) => cachedAPI.put(`/consumable-categories/${id}`, data),
|
||||
delete: (id) => cachedAPI.delete(`/consumable-categories/${id}`)
|
||||
delete: id => cachedAPI.delete(`/consumable-categories/${id}`),
|
||||
};
|
||||
|
||||
export const consumableLogAPI = {
|
||||
list: (params) => cachedAPI.get('/consumables/logs', params),
|
||||
create: (data) => cachedAPI.post('/consumables/logs', data),
|
||||
export: (params) => api.get('/consumables/logs/export', { params, responseType: 'blob' }),
|
||||
import: (data) => cachedAPI.post('/consumables/logs/import', data)
|
||||
list: params => cachedAPI.get('/consumables/logs', params),
|
||||
create: data => cachedAPI.post('/consumables/logs', data),
|
||||
export: params => api.get('/consumables/logs/export', { params, responseType: 'blob' }),
|
||||
import: data => cachedAPI.post('/consumables/logs/import', data),
|
||||
};
|
||||
|
||||
export const ticketCategoryAPI = {
|
||||
list: (params) => cachedAPI.get('/ticket-categories', params),
|
||||
create: (data) => cachedAPI.post('/ticket-categories', data),
|
||||
list: params => cachedAPI.get('/ticket-categories', params),
|
||||
create: data => cachedAPI.post('/ticket-categories', data),
|
||||
update: (code, data) => cachedAPI.put(`/ticket-categories/${code}`, data),
|
||||
delete: (code) => cachedAPI.delete(`/ticket-categories/${code}`),
|
||||
delete: code => cachedAPI.delete(`/ticket-categories/${code}`),
|
||||
getTree: () => cachedAPI.get('/ticket-categories/tree'),
|
||||
init: () => cachedAPI.post('/ticket-categories/init')
|
||||
init: () => cachedAPI.post('/ticket-categories/init'),
|
||||
};
|
||||
|
||||
export { cacheManager };
|
||||
|
||||
+48
-48
@@ -6,17 +6,17 @@ const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
config => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
|
||||
// 开发环境下安全日志:过滤敏感字段
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const sensitiveFields = ['password', 'oldPassword', 'newPassword', 'confirmPassword'];
|
||||
@@ -28,26 +28,26 @@ api.interceptors.request.use(
|
||||
}
|
||||
console.log(`[API] ${config.method?.toUpperCase()} ${config.url}`, safeData || '');
|
||||
}
|
||||
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
error => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
response => {
|
||||
return response.data;
|
||||
},
|
||||
(error) => {
|
||||
error => {
|
||||
if (error.response) {
|
||||
const { status, data } = error.response;
|
||||
|
||||
|
||||
if (status === 401) {
|
||||
const currentPath = window.location.pathname;
|
||||
console.log('[API] 401 error, current path:', currentPath);
|
||||
|
||||
|
||||
if (!currentPath.startsWith('/login')) {
|
||||
const savedToken = localStorage.getItem('token');
|
||||
if (savedToken) {
|
||||
@@ -58,96 +58,96 @@ api.interceptors.response.use(
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return Promise.reject(data.message || '请求失败');
|
||||
}
|
||||
|
||||
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
return Promise.reject('请求超时,请稍后重试');
|
||||
}
|
||||
|
||||
|
||||
return Promise.reject('网络错误,请检查网络连接');
|
||||
}
|
||||
);
|
||||
|
||||
export const authAPI = {
|
||||
checkAdmin: () => api.get('/auth/check-admin'),
|
||||
register: (data) => api.post('/auth/register', data),
|
||||
login: (data) => api.post('/auth/login', data),
|
||||
unlock: (data) => api.post('/auth/unlock', data),
|
||||
register: data => api.post('/auth/register', data),
|
||||
login: data => api.post('/auth/login', data),
|
||||
unlock: data => api.post('/auth/unlock', data),
|
||||
getProfile: () => api.get('/auth/profile'),
|
||||
updateProfile: (data) => api.put('/auth/profile', data),
|
||||
changePassword: (data) => api.put('/auth/password', data)
|
||||
updateProfile: data => api.put('/auth/profile', data),
|
||||
changePassword: data => api.put('/auth/password', data),
|
||||
};
|
||||
|
||||
export const userAPI = {
|
||||
list: (params) => api.get('/users', { params }),
|
||||
list: params => api.get('/users', { params }),
|
||||
all: () => api.get('/users/all'),
|
||||
get: (userId) => api.get(`/users/${userId}`),
|
||||
create: (data) => api.post('/users', data),
|
||||
get: userId => api.get(`/users/${userId}`),
|
||||
create: data => api.post('/users', data),
|
||||
update: (userId, data) => api.put(`/users/${userId}`, data),
|
||||
resetPassword: (userId, data) => api.put(`/users/${userId}/password`, data),
|
||||
delete: (userId) => api.delete(`/users/${userId}`),
|
||||
delete: userId => api.delete(`/users/${userId}`),
|
||||
uploadAvatar: (userId, file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
return api.post(`/users/${userId}/avatar`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
},
|
||||
deleteAvatar: (userId) => api.delete(`/users/${userId}/avatar`),
|
||||
approve: (userId) => api.put(`/users/${userId}/approve`),
|
||||
reject: (userId) => api.put(`/users/${userId}/reject`)
|
||||
deleteAvatar: userId => api.delete(`/users/${userId}/avatar`),
|
||||
approve: userId => api.put(`/users/${userId}/approve`),
|
||||
reject: userId => api.put(`/users/${userId}/reject`),
|
||||
};
|
||||
|
||||
export const roleAPI = {
|
||||
list: (params) => api.get('/roles', { params }),
|
||||
list: params => api.get('/roles', { params }),
|
||||
all: () => api.get('/roles/all'),
|
||||
get: (roleId) => api.get(`/roles/${roleId}`),
|
||||
create: (data) => api.post('/roles', data),
|
||||
get: roleId => api.get(`/roles/${roleId}`),
|
||||
create: data => api.post('/roles', data),
|
||||
update: (roleId, data) => api.put(`/roles/${roleId}`, data),
|
||||
delete: (roleId) => api.delete(`/roles/${roleId}`),
|
||||
initRoles: () => api.post('/roles/init-roles')
|
||||
delete: roleId => api.delete(`/roles/${roleId}`),
|
||||
initRoles: () => api.post('/roles/init-roles'),
|
||||
};
|
||||
|
||||
export const loginHistoryAPI = {
|
||||
list: (params) => api.get('/login-history', { params }),
|
||||
list: params => api.get('/login-history', { params }),
|
||||
getByUser: (userId, params) => api.get(`/login-history/user/${userId}`, { params }),
|
||||
delete: (id) => api.delete(`/login-history/${id}`),
|
||||
clear: (data) => api.delete('/login-history', { data })
|
||||
delete: id => api.delete(`/login-history/${id}`),
|
||||
clear: data => api.delete('/login-history', { data }),
|
||||
};
|
||||
|
||||
export const operationLogAPI = {
|
||||
list: (params) => api.get('/operation-logs', { params }),
|
||||
list: params => api.get('/operation-logs', { params }),
|
||||
getActions: () => api.get('/operation-logs/actions'),
|
||||
getModules: () => api.get('/operation-logs/modules'),
|
||||
delete: (id) => api.delete(`/operation-logs/${id}`),
|
||||
clear: (data) => api.delete('/operation-logs', { data })
|
||||
delete: id => api.delete(`/operation-logs/${id}`),
|
||||
clear: data => api.delete('/operation-logs', { data }),
|
||||
};
|
||||
|
||||
export const ticketAPI = {
|
||||
list: (params) => api.get('/tickets', { params }),
|
||||
get: (ticketId) => api.get(`/tickets/${ticketId}`),
|
||||
create: (data) => api.post('/tickets', data),
|
||||
list: params => api.get('/tickets', { params }),
|
||||
get: ticketId => api.get(`/tickets/${ticketId}`),
|
||||
create: data => api.post('/tickets', data),
|
||||
update: (ticketId, data) => api.put(`/tickets/${ticketId}`, data),
|
||||
delete: (ticketId) => api.delete(`/tickets/${ticketId}`),
|
||||
delete: ticketId => api.delete(`/tickets/${ticketId}`),
|
||||
assign: (ticketId, data) => api.put(`/tickets/${ticketId}/assign`, data),
|
||||
transfer: (ticketId, data) => api.put(`/tickets/${ticketId}/transfer`, data),
|
||||
process: (ticketId, data) => api.put(`/tickets/${ticketId}/process`, data),
|
||||
close: (ticketId, data) => api.put(`/tickets/${ticketId}/close`, data),
|
||||
reopen: (ticketId, data) => api.put(`/tickets/${ticketId}/reopen`, data),
|
||||
getOperations: (ticketId) => api.get(`/tickets/${ticketId}/operations`),
|
||||
getStatistics: (params) => api.get('/tickets/statistics', { params })
|
||||
getOperations: ticketId => api.get(`/tickets/${ticketId}/operations`),
|
||||
getStatistics: params => api.get('/tickets/statistics', { params }),
|
||||
};
|
||||
|
||||
export const ticketCategoryAPI = {
|
||||
list: (params) => api.get('/ticket-categories', { params }),
|
||||
get: (code) => api.get(`/ticket-categories/${code}`),
|
||||
create: (data) => api.post('/ticket-categories', data),
|
||||
list: params => api.get('/ticket-categories', { params }),
|
||||
get: code => api.get(`/ticket-categories/${code}`),
|
||||
create: data => api.post('/ticket-categories', data),
|
||||
update: (code, data) => api.put(`/ticket-categories/${code}`, data),
|
||||
delete: (code) => api.delete(`/ticket-categories/${code}`),
|
||||
delete: code => api.delete(`/ticket-categories/${code}`),
|
||||
tree: () => api.get('/ticket-categories/tree'),
|
||||
init: () => api.post('/ticket-categories/init')
|
||||
init: () => api.post('/ticket-categories/init'),
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,12 +5,12 @@ import * as THREE from 'three';
|
||||
export const LOD_LEVELS = {
|
||||
HIGH: 0,
|
||||
MEDIUM: 1,
|
||||
LOW: 2
|
||||
LOW: 2,
|
||||
};
|
||||
|
||||
export const LOD_DISTANCES = {
|
||||
HIGH: 5,
|
||||
MEDIUM: 10
|
||||
MEDIUM: 10,
|
||||
};
|
||||
|
||||
const createSimplifiedDeviceMesh = (device, uHeight, rackDepth, deviceColor, statusColor) => {
|
||||
@@ -34,7 +34,7 @@ const createSimplifiedDeviceMesh = (device, uHeight, rackDepth, deviceColor, sta
|
||||
<boxGeometry args={[chassisWidth, height - 0.002, chassisDepth]} />
|
||||
<meshStandardMaterial color="#333333" roughness={0.9} metalness={0.3} />
|
||||
</mesh>
|
||||
<mesh position={[panelWidth/2 - 0.03, halfHeight - 0.02, frontZ + 0.01]}>
|
||||
<mesh position={[panelWidth / 2 - 0.03, halfHeight - 0.02, frontZ + 0.01]}>
|
||||
<circleGeometry args={[0.006, 16]} />
|
||||
<meshBasicMaterial color={statusColor} toneMapped={false} />
|
||||
</mesh>
|
||||
@@ -65,9 +65,9 @@ const createMediumDeviceMesh = (device, uHeight, rackDepth, deviceColor, statusC
|
||||
<meshStandardMaterial color={deviceColor} roughness={0.8} metalness={0.1} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0, frontZ - panelDepth - chassisDepth / 2]}>
|
||||
<boxGeometry args={[chassisWidth, height - 0.002, chassisDepth]} />
|
||||
<meshStandardMaterial color="#333333" roughness={0.9} metalness={0.3} />
|
||||
</mesh>
|
||||
<boxGeometry args={[chassisWidth, height - 0.002, chassisDepth]} />
|
||||
<meshStandardMaterial color="#333333" roughness={0.9} metalness={0.3} />
|
||||
</mesh>
|
||||
<group position={[-0.18, 0, frontZ + 0.006]}>
|
||||
<mesh position={[-0.02, 0, 0]}>
|
||||
<boxGeometry args={[0.04, height - 0.01, 0.002]} />
|
||||
@@ -86,7 +86,7 @@ const createMediumDeviceMesh = (device, uHeight, rackDepth, deviceColor, statusC
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
<mesh position={[panelWidth/2 - 0.03, halfHeight - 0.02, frontZ + 0.01]}>
|
||||
<mesh position={[panelWidth / 2 - 0.03, halfHeight - 0.02, frontZ + 0.01]}>
|
||||
<circleGeometry args={[0.006, 16]} />
|
||||
<meshBasicMaterial color={statusColor} toneMapped={false} />
|
||||
</mesh>
|
||||
@@ -94,15 +94,15 @@ const createMediumDeviceMesh = (device, uHeight, rackDepth, deviceColor, statusC
|
||||
);
|
||||
};
|
||||
|
||||
const LODManager = ({
|
||||
device,
|
||||
uHeight,
|
||||
rackDepth,
|
||||
position,
|
||||
deviceColor,
|
||||
statusColor,
|
||||
const LODManager = ({
|
||||
device,
|
||||
uHeight,
|
||||
rackDepth,
|
||||
position,
|
||||
deviceColor,
|
||||
statusColor,
|
||||
children,
|
||||
level = LOD_LEVELS.HIGH
|
||||
level = LOD_LEVELS.HIGH,
|
||||
}) => {
|
||||
const groupRef = useRef();
|
||||
const highDetailRef = useRef();
|
||||
@@ -119,26 +119,26 @@ const LODManager = ({
|
||||
|
||||
useFrame(() => {
|
||||
if (!groupRef.current) return;
|
||||
|
||||
|
||||
// 节流:每5帧检查一次
|
||||
frameCount.current++;
|
||||
if (frameCount.current % 5 !== 0) return;
|
||||
|
||||
|
||||
const distance = camera.position.distanceTo(groupRef.current.position);
|
||||
distanceRef.current = distance;
|
||||
|
||||
|
||||
// 添加缓冲避免频繁切换(10% 缓冲)
|
||||
const buffer = 0.1;
|
||||
const highThreshold = LOD_DISTANCES.HIGH * (1 + buffer);
|
||||
const mediumThreshold = LOD_DISTANCES.MEDIUM * (1 + buffer);
|
||||
|
||||
|
||||
let newLevel = LOD_LEVELS.HIGH;
|
||||
if (distance > mediumThreshold) {
|
||||
newLevel = LOD_LEVELS.LOW;
|
||||
} else if (distance > highThreshold) {
|
||||
newLevel = LOD_LEVELS.MEDIUM;
|
||||
}
|
||||
|
||||
|
||||
// 只有当级别变化时才更新
|
||||
if (newLevel !== lodLevelRef.current) {
|
||||
lodLevelRef.current = newLevel;
|
||||
@@ -169,12 +169,12 @@ const LODManager = ({
|
||||
<group ref={highDetailRef} visible={true}>
|
||||
{children}
|
||||
</group>
|
||||
|
||||
|
||||
{/* 中等细节模型 */}
|
||||
<group ref={mediumDetailRef} visible={false}>
|
||||
{createMediumDeviceMesh(device, uHeight, rackDepth, deviceColor, statusColor)}
|
||||
</group>
|
||||
|
||||
|
||||
{/* 低细节模型 */}
|
||||
<group ref={lowDetailRef} visible={false}>
|
||||
{createSimplifiedDeviceMesh(device, uHeight, rackDepth, deviceColor, statusColor)}
|
||||
|
||||
@@ -3,24 +3,24 @@ import * as THREE from 'three';
|
||||
import DeviceModel from './DeviceModel';
|
||||
import LODManager, { LOD_LEVELS } from './LODManager';
|
||||
|
||||
const RackModel = ({
|
||||
rack,
|
||||
devices = [],
|
||||
selectedDeviceId,
|
||||
onDeviceClick,
|
||||
onDeviceLeave,
|
||||
onDeviceHover,
|
||||
onEditDevice,
|
||||
onAddNic,
|
||||
onAddPort,
|
||||
const RackModel = ({
|
||||
rack,
|
||||
devices = [],
|
||||
selectedDeviceId,
|
||||
onDeviceClick,
|
||||
onDeviceLeave,
|
||||
onDeviceHover,
|
||||
onEditDevice,
|
||||
onAddNic,
|
||||
onAddPort,
|
||||
tooltipFields,
|
||||
deviceSlideEnabled = true
|
||||
deviceSlideEnabled = true,
|
||||
}) => {
|
||||
const width = 0.6;
|
||||
const depth = 1.0;
|
||||
const uHeight = 0.04445;
|
||||
const postWidth = 0.05;
|
||||
|
||||
|
||||
const rackHeight = rack?.height || 45;
|
||||
const height = rackHeight * uHeight + 0.2;
|
||||
|
||||
@@ -33,21 +33,21 @@ const RackModel = ({
|
||||
storage: '#8b5cf6',
|
||||
default: '#3b82f6',
|
||||
status: {
|
||||
running: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
offline: '#6b7280'
|
||||
}
|
||||
running: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
offline: '#6b7280',
|
||||
},
|
||||
};
|
||||
|
||||
const getDeviceColor = (type) => {
|
||||
const t = type?.toLowerCase() || '';
|
||||
if (t.includes('server') || t.includes('服务器')) return colors.server;
|
||||
if (t.includes('switch') || t.includes('交换机')) return colors.switch;
|
||||
if (t.includes('router') || t.includes('路由器')) return colors.router;
|
||||
if (t.includes('firewall') || t.includes('防火墙')) return colors.firewall;
|
||||
if (t.includes('storage') || t.includes('存储')) return colors.storage;
|
||||
return colors.default;
|
||||
const getDeviceColor = type => {
|
||||
const t = type?.toLowerCase() || '';
|
||||
if (t.includes('server') || t.includes('服务器')) return colors.server;
|
||||
if (t.includes('switch') || t.includes('交换机')) return colors.switch;
|
||||
if (t.includes('router') || t.includes('路由器')) return colors.router;
|
||||
if (t.includes('firewall') || t.includes('防火墙')) return colors.firewall;
|
||||
if (t.includes('storage') || t.includes('存储')) return colors.storage;
|
||||
return colors.default;
|
||||
};
|
||||
|
||||
// 设备组的Y偏移量(与下方设备渲染的偏移一致)
|
||||
@@ -62,7 +62,7 @@ const RackModel = ({
|
||||
const yPos = (u - 1) * uHeight + uHeight / 2 + deviceGroupOffset;
|
||||
|
||||
// 创建数字纹理 - 白色数字在深色背景上更清晰
|
||||
const createNumberTexture = (num) => {
|
||||
const createNumberTexture = num => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 128;
|
||||
canvas.height = 128;
|
||||
@@ -90,9 +90,9 @@ const RackModel = ({
|
||||
const planeGeometry = new THREE.PlaneGeometry(planeSize, planeSize);
|
||||
|
||||
// 前侧柱子的位置: [-width/2 + postWidth/2, y, depth/2 - postWidth/2]
|
||||
const leftPostX = -width/2 + postWidth/2;
|
||||
const rightPostX = width/2 - postWidth/2;
|
||||
const frontPostZ = depth/2 - postWidth/2;
|
||||
const leftPostX = -width / 2 + postWidth / 2;
|
||||
const rightPostX = width / 2 - postWidth / 2;
|
||||
const frontPostZ = depth / 2 - postWidth / 2;
|
||||
|
||||
// 刻度线颜色:每5U使用醒目的黄色,其他使用灰色
|
||||
const tickColor = isMajorU ? '#fbbf24' : '#6b7280';
|
||||
@@ -102,7 +102,7 @@ const RackModel = ({
|
||||
<group key={`u-label-${u}`}>
|
||||
{/* 左前侧柱子上的U位标识 - 贴在柱子正侧面(面向前方) */}
|
||||
<mesh
|
||||
position={[leftPostX, yPos, frontPostZ + postWidth/2 + 0.001]}
|
||||
position={[leftPostX, yPos, frontPostZ + postWidth / 2 + 0.001]}
|
||||
geometry={planeGeometry}
|
||||
>
|
||||
<meshBasicMaterial
|
||||
@@ -114,7 +114,7 @@ const RackModel = ({
|
||||
</mesh>
|
||||
{/* 右前侧柱子上的U位标识 - 贴在柱子正侧面(面向前方) */}
|
||||
<mesh
|
||||
position={[rightPostX, yPos, frontPostZ + postWidth/2 + 0.001]}
|
||||
position={[rightPostX, yPos, frontPostZ + postWidth / 2 + 0.001]}
|
||||
geometry={planeGeometry}
|
||||
>
|
||||
<meshBasicMaterial
|
||||
@@ -125,12 +125,12 @@ const RackModel = ({
|
||||
/>
|
||||
</mesh>
|
||||
{/* 左前侧柱子上的刻度线 */}
|
||||
<mesh position={[leftPostX, yPos, frontPostZ + postWidth/2 + 0.002]}>
|
||||
<mesh position={[leftPostX, yPos, frontPostZ + postWidth / 2 + 0.002]}>
|
||||
<boxGeometry args={[postWidth, tickHeight, 0.001]} />
|
||||
<meshBasicMaterial color={tickColor} />
|
||||
</mesh>
|
||||
{/* 右前侧柱子上的刻度线 */}
|
||||
<mesh position={[rightPostX, yPos, frontPostZ + postWidth/2 + 0.002]}>
|
||||
<mesh position={[rightPostX, yPos, frontPostZ + postWidth / 2 + 0.002]}>
|
||||
<boxGeometry args={[postWidth, tickHeight, 0.001]} />
|
||||
<meshBasicMaterial color={tickColor} />
|
||||
</mesh>
|
||||
@@ -142,53 +142,47 @@ const RackModel = ({
|
||||
|
||||
// 生成机柜框架
|
||||
const frame = useMemo(() => {
|
||||
const materialProps = { color: "#333", roughness: 0.5, metalness: 0.8 };
|
||||
const materialProps = { color: '#333', roughness: 0.5, metalness: 0.8 };
|
||||
const postArgs = [postWidth, height, postWidth];
|
||||
const topBottomArgs = [width + 0.02, 0.02, depth + 0.02];
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[-width/2 + postWidth/2, height/2, -depth/2 + postWidth/2]}>
|
||||
<mesh position={[-width / 2 + postWidth / 2, height / 2, -depth / 2 + postWidth / 2]}>
|
||||
<boxGeometry args={postArgs} />
|
||||
<meshStandardMaterial {...materialProps} />
|
||||
</mesh>
|
||||
<mesh position={[width/2 - postWidth/2, height/2, -depth/2 + postWidth/2]}>
|
||||
<mesh position={[width / 2 - postWidth / 2, height / 2, -depth / 2 + postWidth / 2]}>
|
||||
<boxGeometry args={postArgs} />
|
||||
<meshStandardMaterial {...materialProps} />
|
||||
</mesh>
|
||||
<mesh position={[-width/2 + postWidth/2, height/2, depth/2 - postWidth/2]}>
|
||||
<mesh position={[-width / 2 + postWidth / 2, height / 2, depth / 2 - postWidth / 2]}>
|
||||
<boxGeometry args={postArgs} />
|
||||
<meshStandardMaterial {...materialProps} />
|
||||
</mesh>
|
||||
<mesh position={[width/2 - postWidth/2, height/2, depth/2 - postWidth/2]}>
|
||||
<mesh position={[width / 2 - postWidth / 2, height / 2, depth / 2 - postWidth / 2]}>
|
||||
<boxGeometry args={postArgs} />
|
||||
<meshStandardMaterial {...materialProps} />
|
||||
</mesh>
|
||||
|
||||
<mesh position={[0, height, 0]}>
|
||||
<boxGeometry args={topBottomArgs} />
|
||||
<meshStandardMaterial {...materialProps} />
|
||||
<boxGeometry args={topBottomArgs} />
|
||||
<meshStandardMaterial {...materialProps} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0, 0]}>
|
||||
<boxGeometry args={topBottomArgs} />
|
||||
<meshStandardMaterial {...materialProps} />
|
||||
<boxGeometry args={topBottomArgs} />
|
||||
<meshStandardMaterial {...materialProps} />
|
||||
</mesh>
|
||||
|
||||
{[-1, 1].map((side) => (
|
||||
<mesh key={`side-${side}`} position={[side * (width/2 - 0.005), height/2, 0]}>
|
||||
<boxGeometry args={[0.01, height - 0.04, depth - 0.04]} />
|
||||
<meshStandardMaterial
|
||||
color="#2d3748"
|
||||
roughness={0.4}
|
||||
metalness={0.7}
|
||||
side={2}
|
||||
/>
|
||||
</mesh>
|
||||
{[-1, 1].map(side => (
|
||||
<mesh key={`side-${side}`} position={[side * (width / 2 - 0.005), height / 2, 0]}>
|
||||
<boxGeometry args={[0.01, height - 0.04, depth - 0.04]} />
|
||||
<meshStandardMaterial color="#2d3748" roughness={0.4} metalness={0.7} side={2} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* U位刻度标识 */}
|
||||
{uLabels}
|
||||
|
||||
</group>
|
||||
);
|
||||
}, [width, height, depth, postWidth, uLabels]);
|
||||
@@ -197,47 +191,47 @@ const RackModel = ({
|
||||
<group position={[0, 0.5, 0]}>
|
||||
{frame}
|
||||
|
||||
<group position={[0, 0.1, 0]}>
|
||||
{devices.map((device) => {
|
||||
const uStart = device.position || device.u_position || 1;
|
||||
const uSize = device.height || device.u_height || 1;
|
||||
const yPos = (uStart - 1) * uHeight + (uSize * uHeight) / 2;
|
||||
|
||||
const deviceColor = getDeviceColor(device.type);
|
||||
const statusColor = colors.status[device.status] || colors.status.running;
|
||||
|
||||
return (
|
||||
<LODManager
|
||||
key={device.id}
|
||||
device={device}
|
||||
uHeight={uHeight}
|
||||
rackDepth={depth}
|
||||
position={[0, yPos, 0]}
|
||||
deviceColor={deviceColor}
|
||||
statusColor={statusColor}
|
||||
level={LOD_LEVELS.HIGH}
|
||||
>
|
||||
<DeviceModel
|
||||
device={device}
|
||||
uHeight={uHeight}
|
||||
rackDepth={depth}
|
||||
position={[0, 0, 0]}
|
||||
isSelected={selectedDeviceId === device.id}
|
||||
onClick={onDeviceClick}
|
||||
onPointerOver={onDeviceHover}
|
||||
onPointerOut={onDeviceLeave}
|
||||
onEdit={onEditDevice}
|
||||
onAddNic={onAddNic}
|
||||
onAddPort={onAddPort}
|
||||
tooltipFields={tooltipFields}
|
||||
slideEnabled={deviceSlideEnabled}
|
||||
/>
|
||||
</LODManager>
|
||||
);
|
||||
<group position={[0, 0.1, 0]}>
|
||||
{devices.map(device => {
|
||||
const uStart = device.position || device.u_position || 1;
|
||||
const uSize = device.height || device.u_height || 1;
|
||||
const yPos = (uStart - 1) * uHeight + (uSize * uHeight) / 2;
|
||||
|
||||
const deviceColor = getDeviceColor(device.type);
|
||||
const statusColor = colors.status[device.status] || colors.status.running;
|
||||
|
||||
return (
|
||||
<LODManager
|
||||
key={device.id}
|
||||
device={device}
|
||||
uHeight={uHeight}
|
||||
rackDepth={depth}
|
||||
position={[0, yPos, 0]}
|
||||
deviceColor={deviceColor}
|
||||
statusColor={statusColor}
|
||||
level={LOD_LEVELS.HIGH}
|
||||
>
|
||||
<DeviceModel
|
||||
device={device}
|
||||
uHeight={uHeight}
|
||||
rackDepth={depth}
|
||||
position={[0, 0, 0]}
|
||||
isSelected={selectedDeviceId === device.id}
|
||||
onClick={onDeviceClick}
|
||||
onPointerOver={onDeviceHover}
|
||||
onPointerOut={onDeviceLeave}
|
||||
onEdit={onEditDevice}
|
||||
onAddNic={onAddNic}
|
||||
onAddPort={onAddPort}
|
||||
tooltipFields={tooltipFields}
|
||||
slideEnabled={deviceSlideEnabled}
|
||||
/>
|
||||
</LODManager>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
export default RackModel;
|
||||
export default RackModel;
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import React, { Suspense, useMemo, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import React, {
|
||||
Suspense,
|
||||
useMemo,
|
||||
useRef,
|
||||
useEffect,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||
import { OrbitControls, PerspectiveCamera, Environment } from '@react-three/drei';
|
||||
const envMapUrl = '/assets/3d/env.hdr';
|
||||
@@ -22,7 +29,7 @@ const Controls = ({ rack, onControlsReady }) => {
|
||||
const { camera } = useThree();
|
||||
// 机柜中心点(中轴线)
|
||||
const rackHeight = rack?.height || 45;
|
||||
const targetY = rackHeight * 0.04445 / 2 + 0.5;
|
||||
const targetY = (rackHeight * 0.04445) / 2 + 0.5;
|
||||
const fixedTarget = useMemo(() => new THREE.Vector3(0, targetY, 0), [targetY]);
|
||||
|
||||
// 根据机柜高度计算合适的相机距离限制
|
||||
@@ -48,7 +55,7 @@ const Controls = ({ rack, onControlsReady }) => {
|
||||
camera.position.copy(initialCameraPosition);
|
||||
controlsRef.current.target.copy(fixedTarget);
|
||||
controlsRef.current.update();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -76,107 +83,122 @@ const Controls = ({ rack, onControlsReady }) => {
|
||||
enableZoom={true}
|
||||
enableRotate={true}
|
||||
mouseButtons={{
|
||||
LEFT: 0, // 左键旋转
|
||||
MIDDLE: 1, // 中键平移
|
||||
RIGHT: 2 // 右键平移
|
||||
LEFT: 0, // 左键旋转
|
||||
MIDDLE: 1, // 中键平移
|
||||
RIGHT: 2, // 右键平移
|
||||
}}
|
||||
touches={{
|
||||
ONE: 1,
|
||||
TWO: 2
|
||||
TWO: 2,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Scene = forwardRef(({ rack, tooltipFields, onDeviceClick, onDeviceHover, onDeviceLeave }, ref) => {
|
||||
// 从 Context 获取3D场景状态
|
||||
const {
|
||||
devices,
|
||||
selectedDevice,
|
||||
deviceSlideEnabled
|
||||
} = useScene3D();
|
||||
const Scene = forwardRef(
|
||||
({ rack, tooltipFields, onDeviceClick, onDeviceHover, onDeviceLeave }, ref) => {
|
||||
// 从 Context 获取3D场景状态
|
||||
const { devices, selectedDevice, deviceSlideEnabled } = useScene3D();
|
||||
|
||||
// 用于存储 controls API
|
||||
const controlsApiRef = useRef(null);
|
||||
// 用于存储 controls API
|
||||
const controlsApiRef = useRef(null);
|
||||
|
||||
// 使用 useImperativeHandle 暴露重置方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
resetView: () => {
|
||||
if (controlsApiRef.current) {
|
||||
controlsApiRef.current.reset();
|
||||
}
|
||||
}
|
||||
}));
|
||||
// 使用 useImperativeHandle 暴露重置方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
resetView: () => {
|
||||
if (controlsApiRef.current) {
|
||||
controlsApiRef.current.reset();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// 使用 useMemo 稳定 props 引用
|
||||
const rackModelProps = useMemo(() => ({
|
||||
rack,
|
||||
devices,
|
||||
selectedDeviceId: selectedDevice?.id,
|
||||
onDeviceClick,
|
||||
onDeviceLeave,
|
||||
onDeviceHover,
|
||||
tooltipFields,
|
||||
deviceSlideEnabled
|
||||
}), [rack, devices, selectedDevice, onDeviceClick, onDeviceLeave, onDeviceHover, tooltipFields, deviceSlideEnabled]);
|
||||
// 使用 useMemo 稳定 props 引用
|
||||
const rackModelProps = useMemo(
|
||||
() => ({
|
||||
rack,
|
||||
devices,
|
||||
selectedDeviceId: selectedDevice?.id,
|
||||
onDeviceClick,
|
||||
onDeviceLeave,
|
||||
onDeviceHover,
|
||||
tooltipFields,
|
||||
deviceSlideEnabled,
|
||||
}),
|
||||
[
|
||||
rack,
|
||||
devices,
|
||||
selectedDevice,
|
||||
onDeviceClick,
|
||||
onDeviceLeave,
|
||||
onDeviceHover,
|
||||
tooltipFields,
|
||||
deviceSlideEnabled,
|
||||
]
|
||||
);
|
||||
|
||||
// 根据机柜高度动态计算相机初始位置
|
||||
const rackHeight = rack?.height || 45;
|
||||
const rackHeightMeters = rackHeight * 0.04445;
|
||||
// 相机位置:确保能完整看到机柜,高度随机柜高度调整
|
||||
const cameraPosition = useMemo(() => {
|
||||
const baseHeight = 2;
|
||||
const heightFactor = rackHeightMeters * 0.6;
|
||||
const distance = Math.max(3, rackHeightMeters * 1.2);
|
||||
return [distance * 0.7, baseHeight + heightFactor * 0.3, distance];
|
||||
}, [rackHeightMeters]);
|
||||
// 根据机柜高度动态计算相机初始位置
|
||||
const rackHeight = rack?.height || 45;
|
||||
const rackHeightMeters = rackHeight * 0.04445;
|
||||
// 相机位置:确保能完整看到机柜,高度随机柜高度调整
|
||||
const cameraPosition = useMemo(() => {
|
||||
const baseHeight = 2;
|
||||
const heightFactor = rackHeightMeters * 0.6;
|
||||
const distance = Math.max(3, rackHeightMeters * 1.2);
|
||||
return [distance * 0.7, baseHeight + heightFactor * 0.3, distance];
|
||||
}, [rackHeightMeters]);
|
||||
|
||||
// 相机目标点(机柜中心)
|
||||
const cameraTarget = useMemo(() => {
|
||||
return [0, rackHeightMeters / 2 + 0.5, 0];
|
||||
}, [rackHeightMeters]);
|
||||
// 相机目标点(机柜中心)
|
||||
const cameraTarget = useMemo(() => {
|
||||
return [0, rackHeightMeters / 2 + 0.5, 0];
|
||||
}, [rackHeightMeters]);
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
shadows
|
||||
dpr={deviceDpr}
|
||||
performance={{ min: 0.5 }}
|
||||
gl={{
|
||||
antialias: true, // 对所有设备开启抗锯齿提升清晰度
|
||||
alpha: true, // 必须开启alpha以支持透明背景
|
||||
powerPreference: 'high-performance'
|
||||
}}
|
||||
style={{ background: 'transparent' }}
|
||||
>
|
||||
<PerspectiveCamera makeDefault position={cameraPosition} fov={45} />
|
||||
return (
|
||||
<Canvas
|
||||
shadows
|
||||
dpr={deviceDpr}
|
||||
performance={{ min: 0.5 }}
|
||||
gl={{
|
||||
antialias: true, // 对所有设备开启抗锯齿提升清晰度
|
||||
alpha: true, // 必须开启alpha以支持透明背景
|
||||
powerPreference: 'high-performance',
|
||||
}}
|
||||
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}
|
||||
/>
|
||||
<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>
|
||||
<Suspense fallback={null}>
|
||||
<Environment files={envMapUrl} blur={0.5} resolution={256} background={false} />
|
||||
</Suspense>
|
||||
|
||||
{/* Models */}
|
||||
<group position={[0, 0, 0]}>
|
||||
<RackModel {...rackModelProps} />
|
||||
</group>
|
||||
{/* Models */}
|
||||
<group position={[0, 0, 0]}>
|
||||
<RackModel {...rackModelProps} />
|
||||
</group>
|
||||
|
||||
{/* Controls - 使用独立组件保持旋转中心固定 */}
|
||||
<Controls rack={rack} onControlsReady={(api) => { controlsApiRef.current = api; }} />
|
||||
</Canvas>
|
||||
);
|
||||
});
|
||||
{/* Controls - 使用独立组件保持旋转中心固定 */}
|
||||
<Controls
|
||||
rack={rack}
|
||||
onControlsReady={api => {
|
||||
controlsApiRef.current = api;
|
||||
}}
|
||||
/>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default Scene;
|
||||
export default Scene;
|
||||
|
||||
@@ -3,27 +3,62 @@ import { MATERIAL_CONFIGS, MATERIAL_TYPES } from './constants.js';
|
||||
|
||||
export const createDeviceChassisMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DEVICE_CHASSIS];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
|
||||
return (
|
||||
<meshStandardMaterial
|
||||
color={config.color}
|
||||
metalness={config.metalness}
|
||||
roughness={config.roughness}
|
||||
{...options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const createDevicePanelMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DEVICE_PANEL];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
|
||||
return (
|
||||
<meshStandardMaterial
|
||||
color={config.color}
|
||||
metalness={config.metalness}
|
||||
roughness={config.roughness}
|
||||
{...options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const createDevicePanelSelectedMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DEVICE_PANEL_SELECTED];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
|
||||
return (
|
||||
<meshStandardMaterial
|
||||
color={config.color}
|
||||
metalness={config.metalness}
|
||||
roughness={config.roughness}
|
||||
{...options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const createDriveTrayMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DRIVE_TRAY];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
|
||||
return (
|
||||
<meshStandardMaterial
|
||||
color={config.color}
|
||||
metalness={config.metalness}
|
||||
roughness={config.roughness}
|
||||
{...options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const createDriveTrayHandleMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DRIVE_TRAY_HANDLE];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
|
||||
return (
|
||||
<meshStandardMaterial
|
||||
color={config.color}
|
||||
metalness={config.metalness}
|
||||
roughness={config.roughness}
|
||||
{...options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const createLedIndicatorMaterial = (color = '#10b981', options = {}) => {
|
||||
@@ -37,32 +72,78 @@ export const createLedErrorMaterial = (options = {}) => {
|
||||
|
||||
export const createSfpPortMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.SFP_PORT];
|
||||
return <meshPhysicalMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} transparent opacity={config.opacity} {...options} />;
|
||||
return (
|
||||
<meshPhysicalMaterial
|
||||
color={config.color}
|
||||
metalness={config.metalness}
|
||||
roughness={config.roughness}
|
||||
transparent
|
||||
opacity={config.opacity}
|
||||
{...options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const createRj45PortMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.RJ45_PORT];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
|
||||
return (
|
||||
<meshStandardMaterial
|
||||
color={config.color}
|
||||
metalness={config.metalness}
|
||||
roughness={config.roughness}
|
||||
{...options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const createVentHoleMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.VENT_HOLE];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
|
||||
return (
|
||||
<meshStandardMaterial
|
||||
color={config.color}
|
||||
metalness={config.metalness}
|
||||
roughness={config.roughness}
|
||||
{...options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const createBackPanelMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.BACK_PANEL];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} transparent opacity={config.opacity} {...options} />;
|
||||
return (
|
||||
<meshStandardMaterial
|
||||
color={config.color}
|
||||
metalness={config.metalness}
|
||||
roughness={config.roughness}
|
||||
transparent
|
||||
opacity={config.opacity}
|
||||
{...options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const createPsuModuleMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.PSU_MODULE];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
|
||||
return (
|
||||
<meshStandardMaterial
|
||||
color={config.color}
|
||||
metalness={config.metalness}
|
||||
roughness={config.roughness}
|
||||
{...options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const createFanModuleMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.FAN_MODULE];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
|
||||
return (
|
||||
<meshStandardMaterial
|
||||
color={config.color}
|
||||
metalness={config.metalness}
|
||||
roughness={config.roughness}
|
||||
{...options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const createConsolePortMaterial = (color = '#facc15') => {
|
||||
|
||||
@@ -3,32 +3,79 @@ import { MATERIAL_CONFIGS, MATERIAL_TYPES } from './constants.js';
|
||||
|
||||
export const createRackFrameMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.RACK_FRAME];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} envMapIntensity={config.envMapIntensity} {...options} />;
|
||||
return (
|
||||
<meshStandardMaterial
|
||||
color={config.color}
|
||||
metalness={config.metalness}
|
||||
roughness={config.roughness}
|
||||
envMapIntensity={config.envMapIntensity}
|
||||
{...options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const createRailMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.RAIL];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} envMapIntensity={config.envMapIntensity} {...options} />;
|
||||
return (
|
||||
<meshStandardMaterial
|
||||
color={config.color}
|
||||
metalness={config.metalness}
|
||||
roughness={config.roughness}
|
||||
envMapIntensity={config.envMapIntensity}
|
||||
{...options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const createRailHoleMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.RAIL_HOLE];
|
||||
return <meshBasicMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
|
||||
return (
|
||||
<meshBasicMaterial
|
||||
color={config.color}
|
||||
metalness={config.metalness}
|
||||
roughness={config.roughness}
|
||||
{...options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const createSidePanelMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.SIDE_PANEL];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} envMapIntensity={config.envMapIntensity} {...options} />;
|
||||
return (
|
||||
<meshStandardMaterial
|
||||
color={config.color}
|
||||
metalness={config.metalness}
|
||||
roughness={config.roughness}
|
||||
envMapIntensity={config.envMapIntensity}
|
||||
{...options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const createTopPlateMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.TOP_PLATE];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} envMapIntensity={config.envMapIntensity} {...options} />;
|
||||
return (
|
||||
<meshStandardMaterial
|
||||
color={config.color}
|
||||
metalness={config.metalness}
|
||||
roughness={config.roughness}
|
||||
envMapIntensity={config.envMapIntensity}
|
||||
{...options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const createBottomPlateMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.BOTTOM_PLATE];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} envMapIntensity={config.envMapIntensity} {...options} />;
|
||||
return (
|
||||
<meshStandardMaterial
|
||||
color={config.color}
|
||||
metalness={config.metalness}
|
||||
roughness={config.roughness}
|
||||
envMapIntensity={config.envMapIntensity}
|
||||
{...options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const createTextLabelMaterial = (color = '#ffffff') => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { MATERIAL_CONFIGS, MATERIAL_TYPES } from './constants.js';
|
||||
|
||||
export const getMaterialConfig = (type) => {
|
||||
export const getMaterialConfig = type => {
|
||||
return MATERIAL_CONFIGS[type] || null;
|
||||
};
|
||||
|
||||
export const getMaterialType = (type) => {
|
||||
export const getMaterialType = type => {
|
||||
return MATERIAL_TYPES[type] || null;
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ const CableCreateModal = ({ visible, onClose, onSuccess, sourceDevice }) => {
|
||||
form.resetFields();
|
||||
if (sourceDevice) {
|
||||
form.setFieldsValue({
|
||||
sourceDeviceId: sourceDevice.deviceId || sourceDevice.id,
|
||||
sourceDeviceId: sourceDevice.deviceId || sourceDevice.id,
|
||||
});
|
||||
fetchDevicePorts(sourceDevice.deviceId || sourceDevice.id, 'source');
|
||||
}
|
||||
@@ -63,12 +63,12 @@ const CableCreateModal = ({ visible, onClose, onSuccess, sourceDevice }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSourceDeviceChange = (deviceId) => {
|
||||
const handleSourceDeviceChange = deviceId => {
|
||||
form.setFieldsValue({ sourcePort: undefined });
|
||||
fetchDevicePorts(deviceId, 'source');
|
||||
};
|
||||
|
||||
const handleTargetDeviceChange = (deviceId) => {
|
||||
const handleTargetDeviceChange = deviceId => {
|
||||
form.setFieldsValue({ targetPort: undefined });
|
||||
fetchDevicePorts(deviceId, 'target');
|
||||
};
|
||||
@@ -85,22 +85,19 @@ const CableCreateModal = ({ visible, onClose, onSuccess, sourceDevice }) => {
|
||||
let payload = { ...values };
|
||||
|
||||
// If Source is NOT Switch AND Target IS Switch, swap them
|
||||
if (sourceDev && targetDev &&
|
||||
sourceDev.type !== 'switch' &&
|
||||
targetDev.type === 'switch') {
|
||||
|
||||
payload = {
|
||||
...values,
|
||||
sourceDeviceId: values.targetDeviceId,
|
||||
sourcePort: values.targetPort,
|
||||
targetDeviceId: values.sourceDeviceId,
|
||||
targetPort: values.sourcePort
|
||||
};
|
||||
console.log('Swapped source/target to ensure Switch is Source');
|
||||
if (sourceDev && targetDev && sourceDev.type !== 'switch' && targetDev.type === 'switch') {
|
||||
payload = {
|
||||
...values,
|
||||
sourceDeviceId: values.targetDeviceId,
|
||||
sourcePort: values.targetPort,
|
||||
targetDeviceId: values.sourceDeviceId,
|
||||
targetPort: values.sourcePort,
|
||||
};
|
||||
console.log('Swapped source/target to ensure Switch is Source');
|
||||
}
|
||||
|
||||
await axios.post('/api/cables', payload);
|
||||
|
||||
|
||||
message.success('接线创建成功');
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
@@ -130,122 +127,125 @@ const CableCreateModal = ({ visible, onClose, onSuccess, sourceDevice }) => {
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
{/* Source Side */}
|
||||
<div style={{ padding: '12px', background: '#f9f9f9', borderRadius: '8px' }}>
|
||||
<div style={{ marginBottom: 12, fontWeight: 500, color: '#666' }}>源设备 (起点)</div>
|
||||
<Form.Item
|
||||
name="sourceDeviceId"
|
||||
label="设备"
|
||||
rules={[{ required: true, message: '请选择源设备' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择源设备"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.children ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
onChange={handleSourceDeviceChange}
|
||||
loading={fetchingDevices}
|
||||
disabled={!!sourceDevice} // Lock source device if provided
|
||||
>
|
||||
{devices.map(d => (
|
||||
<Option key={d.deviceId} value={d.deviceId}>{d.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="sourcePort"
|
||||
label="端口"
|
||||
rules={[{ required: true, message: '请选择源端口' }]}
|
||||
>
|
||||
<Select placeholder="选择源端口" showSearch>
|
||||
{sourcePorts.map(p => (
|
||||
<Option key={p.portId} value={p.portName}>
|
||||
{p.portName} ({p.portType})
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
{/* Source Side */}
|
||||
<div style={{ padding: '12px', background: '#f9f9f9', borderRadius: '8px' }}>
|
||||
<div style={{ marginBottom: 12, fontWeight: 500, color: '#666' }}>源设备 (起点)</div>
|
||||
<Form.Item
|
||||
name="sourceDeviceId"
|
||||
label="设备"
|
||||
rules={[{ required: true, message: '请选择源设备' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择源设备"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.children ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
onChange={handleSourceDeviceChange}
|
||||
loading={fetchingDevices}
|
||||
disabled={!!sourceDevice} // Lock source device if provided
|
||||
>
|
||||
{devices.map(d => (
|
||||
<Option key={d.deviceId} value={d.deviceId}>
|
||||
{d.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="sourcePort"
|
||||
label="端口"
|
||||
rules={[{ required: true, message: '请选择源端口' }]}
|
||||
>
|
||||
<Select placeholder="选择源端口" showSearch>
|
||||
{sourcePorts.map(p => (
|
||||
<Option key={p.portId} value={p.portName}>
|
||||
{p.portName} ({p.portType})
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Target Side */}
|
||||
<div style={{ padding: '12px', background: '#f9f9f9', borderRadius: '8px' }}>
|
||||
<div style={{ marginBottom: 12, fontWeight: 500, color: '#666' }}>目标设备 (终点)</div>
|
||||
<Form.Item
|
||||
name="targetDeviceId"
|
||||
label="设备"
|
||||
rules={[{ required: true, message: '请选择目标设备' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择目标设备"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.children ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
onChange={handleTargetDeviceChange}
|
||||
loading={fetchingDevices}
|
||||
>
|
||||
{devices.filter(d => d.deviceId !== form.getFieldValue('sourceDeviceId')).map(d => (
|
||||
<Option key={d.deviceId} value={d.deviceId}>{d.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="targetPort"
|
||||
label="端口"
|
||||
rules={[{ required: true, message: '请选择目标端口' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择目标端口"
|
||||
showSearch
|
||||
disabled={!form.getFieldValue('targetDeviceId')}
|
||||
>
|
||||
{targetPorts.map(p => (
|
||||
<Option key={p.portId} value={p.portName}>
|
||||
{p.portName} ({p.portType})
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
{/* Target Side */}
|
||||
<div style={{ padding: '12px', background: '#f9f9f9', borderRadius: '8px' }}>
|
||||
<div style={{ marginBottom: 12, fontWeight: 500, color: '#666' }}>目标设备 (终点)</div>
|
||||
<Form.Item
|
||||
name="targetDeviceId"
|
||||
label="设备"
|
||||
rules={[{ required: true, message: '请选择目标设备' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择目标设备"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.children ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
onChange={handleTargetDeviceChange}
|
||||
loading={fetchingDevices}
|
||||
>
|
||||
{devices
|
||||
.filter(d => d.deviceId !== form.getFieldValue('sourceDeviceId'))
|
||||
.map(d => (
|
||||
<Option key={d.deviceId} value={d.deviceId}>
|
||||
{d.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="targetPort"
|
||||
label="端口"
|
||||
rules={[{ required: true, message: '请选择目标端口' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择目标端口"
|
||||
showSearch
|
||||
disabled={!form.getFieldValue('targetDeviceId')}
|
||||
>
|
||||
{targetPorts.map(p => (
|
||||
<Option key={p.portId} value={p.portName}>
|
||||
{p.portName} ({p.portType})
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16 }}>
|
||||
<Form.Item
|
||||
name="cableType"
|
||||
label="线缆类型"
|
||||
initialValue="ethernet"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select>
|
||||
<Option value="ethernet">网线</Option>
|
||||
<Option value="fiber">光纤</Option>
|
||||
<Option value="copper">铜缆</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
initialValue="normal"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select>
|
||||
<Option value="normal">正常</Option>
|
||||
<Option value="fault">故障</Option>
|
||||
<Option value="disconnected">未连接</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="cableLength"
|
||||
label="长度 (米)"
|
||||
>
|
||||
<Input type="number" min={0} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={2} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16 }}>
|
||||
<Form.Item
|
||||
name="cableType"
|
||||
label="线缆类型"
|
||||
initialValue="ethernet"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select>
|
||||
<Option value="ethernet">网线</Option>
|
||||
<Option value="fiber">光纤</Option>
|
||||
<Option value="copper">铜缆</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
initialValue="normal"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select>
|
||||
<Option value="normal">正常</Option>
|
||||
<Option value="fault">故障</Option>
|
||||
<Option value="disconnected">未连接</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="cableLength" label="长度 (米)">
|
||||
<Input type="number" min={0} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { Drawer, Tabs, Tag, Space, Typography, Empty, Card, Tooltip, Button, Popconfirm } from 'antd';
|
||||
import { ApiOutlined, CloudServerOutlined, EnvironmentOutlined, EditOutlined, PlusCircleOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Drawer,
|
||||
Tabs,
|
||||
Tag,
|
||||
Space,
|
||||
Typography,
|
||||
Empty,
|
||||
Card,
|
||||
Tooltip,
|
||||
Button,
|
||||
Popconfirm,
|
||||
} from 'antd';
|
||||
import {
|
||||
ApiOutlined,
|
||||
CloudServerOutlined,
|
||||
EnvironmentOutlined,
|
||||
EditOutlined,
|
||||
PlusCircleOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import NetworkCardPanel from './NetworkCardPanel';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
@@ -10,25 +28,38 @@ const designTokens = {
|
||||
primary: '#667eea',
|
||||
success: '#10b981',
|
||||
error: '#ef4444',
|
||||
warning: '#f59e0b'
|
||||
warning: '#f59e0b',
|
||||
},
|
||||
spacing: {
|
||||
sm: 8,
|
||||
md: 16
|
||||
}
|
||||
md: 16,
|
||||
},
|
||||
};
|
||||
|
||||
function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables, onEdit, onAddNic, onAddPort, onAddCable, onDeleteCable, tooltipFields, refreshTrigger }) {
|
||||
function DeviceDetailDrawer({
|
||||
device,
|
||||
visible,
|
||||
onClose,
|
||||
cables,
|
||||
onRefreshCables,
|
||||
onEdit,
|
||||
onAddNic,
|
||||
onAddPort,
|
||||
onAddCable,
|
||||
onDeleteCable,
|
||||
tooltipFields,
|
||||
refreshTrigger,
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState('ports');
|
||||
|
||||
const deviceCables = useMemo(() => {
|
||||
if (!device || !cables) return [];
|
||||
return cables.filter(c =>
|
||||
c.sourceDeviceId === device.deviceId || c.targetDeviceId === device.deviceId
|
||||
return cables.filter(
|
||||
c => c.sourceDeviceId === device.deviceId || c.targetDeviceId === device.deviceId
|
||||
);
|
||||
}, [device, cables]);
|
||||
|
||||
const getStatusTag = useCallback((status) => {
|
||||
const getStatusTag = useCallback(status => {
|
||||
const config = {
|
||||
running: { color: 'success', text: '运行中' },
|
||||
normal: { color: 'success', text: '正常' },
|
||||
@@ -36,13 +67,13 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables,
|
||||
error: { color: 'error', text: '故障' },
|
||||
fault: { color: 'error', text: '故障' },
|
||||
offline: { color: 'default', text: '离线' },
|
||||
maintenance: { color: 'processing', text: '维护中' }
|
||||
maintenance: { color: 'processing', text: '维护中' },
|
||||
};
|
||||
const { color, text } = config[status] || { color: 'default', text: status };
|
||||
return <Tag color={color}>{text}</Tag>;
|
||||
}, []);
|
||||
|
||||
const getDeviceTypeName = useCallback((type) => {
|
||||
const getDeviceTypeName = useCallback(type => {
|
||||
const typeMap = {
|
||||
server: '服务器',
|
||||
switch: '交换机',
|
||||
@@ -50,41 +81,53 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables,
|
||||
storage: '存储设备',
|
||||
firewall: '防火墙',
|
||||
ups: 'UPS',
|
||||
pdu: 'PDU'
|
||||
pdu: 'PDU',
|
||||
};
|
||||
return typeMap[type?.toLowerCase()] || type || '未知设备';
|
||||
}, []);
|
||||
|
||||
const renderFieldValue = useCallback((field, device) => {
|
||||
const renderFieldValue = useCallback(
|
||||
(field, device) => {
|
||||
const fieldKey = field.field;
|
||||
if (fieldKey === 'status') return getStatusTag(device.status);
|
||||
|
||||
|
||||
// 优先从device对象获取值,如果没有则从customFields中获取
|
||||
let value = device[fieldKey];
|
||||
if ((value === undefined || value === null) && device.customFields && typeof device.customFields === 'object') {
|
||||
value = device.customFields[fieldKey];
|
||||
if (
|
||||
(value === undefined || value === null) &&
|
||||
device.customFields &&
|
||||
typeof device.customFields === 'object'
|
||||
) {
|
||||
value = device.customFields[fieldKey];
|
||||
}
|
||||
|
||||
|
||||
if (fieldKey === 'type') value = getDeviceTypeName(value);
|
||||
else if (fieldKey === 'position') value = `U${device.position} ${device.height ? `(${device.height}U)` : ''}`;
|
||||
|
||||
return <Text strong style={{ fontSize: '14px' }}>{value !== undefined && value !== null ? value : '-'}</Text>;
|
||||
}, [getStatusTag, getDeviceTypeName]);
|
||||
else if (fieldKey === 'position')
|
||||
value = `U${device.position} ${device.height ? `(${device.height}U)` : ''}`;
|
||||
|
||||
return (
|
||||
<Text strong style={{ fontSize: '14px' }}>
|
||||
{value !== undefined && value !== null ? value : '-'}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
[getStatusTag, getDeviceTypeName]
|
||||
);
|
||||
|
||||
const displayFields = useMemo(() => {
|
||||
if (tooltipFields && Object.keys(tooltipFields).length > 0) {
|
||||
return Object.values(tooltipFields).filter(f => f.enabled);
|
||||
}
|
||||
|
||||
// Default fallback fields if no config
|
||||
return [
|
||||
{ field: 'deviceId', label: '设备ID' },
|
||||
{ field: 'type', label: '设备类型' },
|
||||
{ field: 'status', label: '设备状态' },
|
||||
{ field: 'position', label: '位置' },
|
||||
{ field: 'ipAddress', label: 'IP地址' },
|
||||
{ field: 'brand', label: '品牌' }
|
||||
];
|
||||
if (tooltipFields && Object.keys(tooltipFields).length > 0) {
|
||||
return Object.values(tooltipFields).filter(f => f.enabled);
|
||||
}
|
||||
|
||||
// Default fallback fields if no config
|
||||
return [
|
||||
{ field: 'deviceId', label: '设备ID' },
|
||||
{ field: 'type', label: '设备类型' },
|
||||
{ field: 'status', label: '设备状态' },
|
||||
{ field: 'position', label: '位置' },
|
||||
{ field: 'ipAddress', label: 'IP地址' },
|
||||
{ field: 'brand', label: '品牌' },
|
||||
];
|
||||
}, [tooltipFields]);
|
||||
|
||||
const tabItems = [
|
||||
@@ -103,7 +146,7 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables,
|
||||
onRefresh={onRefreshCables}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'cables',
|
||||
@@ -138,38 +181,64 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables,
|
||||
<div style={{ marginBottom: designTokens.spacing.sm }}>
|
||||
<Space direction="vertical" size={4}>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>源设备:</Text>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
源设备:
|
||||
</Text>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{cable.sourceDevice?.name || '-'}
|
||||
<Tag color="blue" style={{ marginLeft: '8px' }}>{cable.sourcePort}</Tag>
|
||||
<Tag color="blue" style={{ marginLeft: '8px' }}>
|
||||
{cable.sourcePort}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>目标设备:</Text>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
目标设备:
|
||||
</Text>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{cable.targetDevice?.name || '-'}
|
||||
<Tag color="green" style={{ marginLeft: '8px' }}>{cable.targetPort}</Tag>
|
||||
<Tag color="green" style={{ marginLeft: '8px' }}>
|
||||
{cable.targetPort}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Space wrap>
|
||||
<Tag color={cable.status === 'normal' ? 'success' : cable.status === 'fault' ? 'error' : 'default'}>
|
||||
{cable.status === 'normal' ? '正常' : cable.status === 'fault' ? '故障' : '未连接'}
|
||||
<Tag
|
||||
color={
|
||||
cable.status === 'normal'
|
||||
? 'success'
|
||||
: cable.status === 'fault'
|
||||
? 'error'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{cable.status === 'normal'
|
||||
? '正常'
|
||||
: cable.status === 'fault'
|
||||
? '故障'
|
||||
: '未连接'}
|
||||
</Tag>
|
||||
<Tag color="purple">
|
||||
{cable.cableType === 'ethernet' ? '网线' : cable.cableType === 'fiber' ? '光纤' : '铜缆'}
|
||||
{cable.cableType === 'ethernet'
|
||||
? '网线'
|
||||
: cable.cableType === 'fiber'
|
||||
? '光纤'
|
||||
: '铜缆'}
|
||||
</Tag>
|
||||
{cable.cableLength && (
|
||||
<Tag color="orange">
|
||||
{cable.cableLength}m
|
||||
</Tag>
|
||||
)}
|
||||
{cable.cableLength && <Tag color="orange">{cable.cableLength}m</Tag>}
|
||||
</Space>
|
||||
|
||||
{cable.description && (
|
||||
<div style={{ marginTop: designTokens.spacing.sm, fontSize: '12px', color: '#666' }}>
|
||||
<div
|
||||
style={{
|
||||
marginTop: designTokens.spacing.sm,
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
{cable.description}
|
||||
</div>
|
||||
)}
|
||||
@@ -178,8 +247,8 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables,
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!device) return null;
|
||||
@@ -189,11 +258,15 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables,
|
||||
title={
|
||||
<Space style={{ maxWidth: '280px', overflow: 'hidden' }}>
|
||||
<CloudServerOutlined style={{ color: designTokens.colors.primary, flexShrink: 0 }} />
|
||||
<span style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>设备详情 - {device.name}</span>
|
||||
<span
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
设备详情 - {device.name}
|
||||
</span>
|
||||
</Space>
|
||||
}
|
||||
placement="right"
|
||||
@@ -206,42 +279,51 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables,
|
||||
<Button icon={<EditOutlined />} onClick={() => onEdit?.(device)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="添加网卡">
|
||||
<Button icon={<PlusCircleOutlined />} onClick={() => onAddNic?.(device)}>加网卡</Button>
|
||||
<Button icon={<PlusCircleOutlined />} onClick={() => onAddNic?.(device)}>
|
||||
加网卡
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="添加端口">
|
||||
<Button icon={<ApiOutlined />} onClick={() => onAddPort?.(device)}>加端口</Button>
|
||||
<Button icon={<ApiOutlined />} onClick={() => onAddPort?.(device)}>
|
||||
加端口
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="添加接线">
|
||||
<Button icon={<EnvironmentOutlined />} onClick={() => onAddCable?.(device)}>加接线</Button>
|
||||
<Button icon={<EnvironmentOutlined />} onClick={() => onAddCable?.(device)}>
|
||||
加接线
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
styles={{ body: { padding: '16px 20px', overflow: 'auto' } }}
|
||||
>
|
||||
<div className="device-info-section" style={{ marginBottom: '20px' }}>
|
||||
<Title level={5} style={{ margin: '0 0 12px 0', color: '#1e293b' }}>基本信息</Title>
|
||||
<div className="info-grid" style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '12px',
|
||||
background: '#f8fafc',
|
||||
padding: '16px',
|
||||
borderRadius: '10px'
|
||||
}}>
|
||||
<Title level={5} style={{ margin: '0 0 12px 0', color: '#1e293b' }}>
|
||||
基本信息
|
||||
</Title>
|
||||
<div
|
||||
className="info-grid"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '12px',
|
||||
background: '#f8fafc',
|
||||
padding: '16px',
|
||||
borderRadius: '10px',
|
||||
}}
|
||||
>
|
||||
{displayFields.map(field => (
|
||||
<div className="info-item" key={field.field}>
|
||||
<Text type="secondary" style={{ fontSize: '12px', display: 'block' }}>{field.label}</Text>
|
||||
<Text type="secondary" style={{ fontSize: '12px', display: 'block' }}>
|
||||
{field.label}
|
||||
</Text>
|
||||
{renderFieldValue(field, device)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={tabItems}
|
||||
/>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ const { TextArea } = Input;
|
||||
const designTokens = {
|
||||
colors: {
|
||||
primary: {
|
||||
main: '#667eea'
|
||||
}
|
||||
main: '#667eea',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
medium: '10px'
|
||||
}
|
||||
medium: '10px',
|
||||
},
|
||||
};
|
||||
|
||||
function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
|
||||
@@ -33,7 +33,7 @@ function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
|
||||
description: values.description,
|
||||
model: values.model,
|
||||
manufacturer: values.manufacturer,
|
||||
status: values.status
|
||||
status: values.status,
|
||||
});
|
||||
|
||||
message.success('网卡创建成功');
|
||||
@@ -77,7 +77,7 @@ function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
status: 'normal'
|
||||
status: 'normal',
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
@@ -92,24 +92,15 @@ function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
|
||||
}
|
||||
rules={[
|
||||
{ required: true, message: '请输入网卡名称' },
|
||||
{ max: 50, message: '名称不能超过50个字符' }
|
||||
{ max: 50, message: '名称不能超过50个字符' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="例如: 网卡1、eth0、LAN1" />
|
||||
</Form.Item>
|
||||
|
||||
<Space style={{ display: 'flex', width: '100%' }}>
|
||||
<Form.Item
|
||||
name="slotNumber"
|
||||
label="插槽编号"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<InputNumber
|
||||
placeholder="可选"
|
||||
min={1}
|
||||
max={100}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Form.Item name="slotNumber" label="插槽编号" style={{ flex: 1 }}>
|
||||
<InputNumber placeholder="可选" min={1} max={100} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
@@ -128,27 +119,16 @@ function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
|
||||
</Space>
|
||||
|
||||
<Space style={{ display: 'flex', width: '100%' }}>
|
||||
<Form.Item
|
||||
name="manufacturer"
|
||||
label="制造商"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Form.Item name="manufacturer" label="制造商" style={{ flex: 1 }}>
|
||||
<Input placeholder="如: Intel、Realtek、Broadcom" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="model"
|
||||
label="型号"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Form.Item name="model" label="型号" style={{ flex: 1 }}>
|
||||
<Input placeholder="如: X520-DA2" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述"
|
||||
>
|
||||
<Form.Item name="description" label="描述">
|
||||
<TextArea rows={2} placeholder="请输入描述信息(可选)" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Button, Space, Tag, Tooltip, Popconfirm, Empty, Spin, Badge, Collapse, Card } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, ReloadOutlined, ApiOutlined, CloudServerOutlined, FolderOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Popconfirm,
|
||||
Empty,
|
||||
Spin,
|
||||
Badge,
|
||||
Collapse,
|
||||
Card,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
ApiOutlined,
|
||||
CloudServerOutlined,
|
||||
FolderOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import PortCreateModal from './PortCreateModal';
|
||||
import NetworkCardCreateModal from './NetworkCardCreateModal';
|
||||
@@ -10,12 +29,12 @@ const { Panel } = Collapse;
|
||||
const designTokens = {
|
||||
colors: {
|
||||
primary: {
|
||||
main: '#667eea'
|
||||
main: '#667eea',
|
||||
},
|
||||
success: '#10b981',
|
||||
error: '#ef4444',
|
||||
warning: '#f59e0b'
|
||||
}
|
||||
warning: '#f59e0b',
|
||||
},
|
||||
};
|
||||
|
||||
function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
|
||||
@@ -34,7 +53,7 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
|
||||
setLoading(true);
|
||||
const [cardsResponse, networkCardsResponse] = await Promise.all([
|
||||
axios.get(`/api/network-cards/device/${deviceId}/with-ports`),
|
||||
axios.get(`/api/network-cards/device/${deviceId}`)
|
||||
axios.get(`/api/network-cards/device/${deviceId}`),
|
||||
]);
|
||||
|
||||
const cardsData = cardsResponse.data || [];
|
||||
@@ -58,27 +77,35 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
|
||||
fetchData();
|
||||
}, [fetchData, refreshTrigger]);
|
||||
|
||||
const handleDeleteCard = useCallback(async (card) => {
|
||||
try {
|
||||
await axios.delete(`/api/network-cards/${card.nicId}`);
|
||||
import('antd').then(({ message }) => message.success('网卡删除成功'));
|
||||
fetchData();
|
||||
onRefresh?.();
|
||||
} catch (error) {
|
||||
import('antd').then(({ message }) => message.error(error.response?.data?.error || '网卡删除失败'));
|
||||
}
|
||||
}, [fetchData, onRefresh]);
|
||||
const handleDeleteCard = useCallback(
|
||||
async card => {
|
||||
try {
|
||||
await axios.delete(`/api/network-cards/${card.nicId}`);
|
||||
import('antd').then(({ message }) => message.success('网卡删除成功'));
|
||||
fetchData();
|
||||
onRefresh?.();
|
||||
} catch (error) {
|
||||
import('antd').then(({ message }) =>
|
||||
message.error(error.response?.data?.error || '网卡删除失败')
|
||||
);
|
||||
}
|
||||
},
|
||||
[fetchData, onRefresh]
|
||||
);
|
||||
|
||||
const handleDeletePort = useCallback(async (port) => {
|
||||
try {
|
||||
await axios.delete(`/api/device-ports/${port.portId}`);
|
||||
import('antd').then(({ message }) => message.success('端口删除成功'));
|
||||
fetchData();
|
||||
onRefresh?.();
|
||||
} catch (error) {
|
||||
import('antd').then(({ message }) => message.error('端口删除失败'));
|
||||
}
|
||||
}, [fetchData, onRefresh]);
|
||||
const handleDeletePort = useCallback(
|
||||
async port => {
|
||||
try {
|
||||
await axios.delete(`/api/device-ports/${port.portId}`);
|
||||
import('antd').then(({ message }) => message.success('端口删除成功'));
|
||||
fetchData();
|
||||
onRefresh?.();
|
||||
} catch (error) {
|
||||
import('antd').then(({ message }) => message.error('端口删除失败'));
|
||||
}
|
||||
},
|
||||
[fetchData, onRefresh]
|
||||
);
|
||||
|
||||
const handleCreateCardSuccess = useCallback(() => {
|
||||
fetchData();
|
||||
@@ -90,7 +117,7 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
|
||||
onRefresh?.();
|
||||
}, [fetchData, onRefresh]);
|
||||
|
||||
const handleExpand = (nicId) => {
|
||||
const handleExpand = nicId => {
|
||||
setExpandedCards(prev => {
|
||||
if (prev.includes(nicId)) {
|
||||
return prev.filter(id => id !== nicId);
|
||||
@@ -99,27 +126,27 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusTag = (status) => {
|
||||
const getStatusTag = status => {
|
||||
const config = {
|
||||
free: { color: 'success', text: '空闲' },
|
||||
occupied: { color: 'processing', text: '占用' },
|
||||
fault: { color: 'error', text: '故障' },
|
||||
normal: { color: 'success', text: '正常' },
|
||||
warning: { color: 'warning', text: '警告' },
|
||||
offline: { color: 'default', text: '离线' }
|
||||
offline: { color: 'default', text: '离线' },
|
||||
};
|
||||
const { color, text } = config[status] || { color: 'default', text: status };
|
||||
return <Tag color={color}>{text}</Tag>;
|
||||
};
|
||||
|
||||
const getTypeTag = (type) => {
|
||||
const getTypeTag = type => {
|
||||
const config = {
|
||||
'RJ45': { color: 'blue', text: 'RJ45' },
|
||||
'SFP': { color: 'green', text: 'SFP' },
|
||||
RJ45: { color: 'blue', text: 'RJ45' },
|
||||
SFP: { color: 'green', text: 'SFP' },
|
||||
'SFP+': { color: 'cyan', text: 'SFP+' },
|
||||
'SFP28': { color: 'purple', text: 'SFP28' },
|
||||
'QSFP': { color: 'orange', text: 'QSFP' },
|
||||
'QSFP28': { color: 'red', text: 'QSFP28' }
|
||||
SFP28: { color: 'purple', text: 'SFP28' },
|
||||
QSFP: { color: 'orange', text: 'QSFP' },
|
||||
QSFP28: { color: 'red', text: 'QSFP28' },
|
||||
};
|
||||
const { color, text } = config[type] || { color: 'default', text: type };
|
||||
return <Tag color={color}>{text}</Tag>;
|
||||
@@ -132,34 +159,34 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
|
||||
dataIndex: 'portName',
|
||||
key: 'portName',
|
||||
width: 120,
|
||||
render: (text) => <span style={{ fontWeight: 500 }}>{text}</span>
|
||||
render: text => <span style={{ fontWeight: 500 }}>{text}</span>,
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'portType',
|
||||
key: 'portType',
|
||||
width: 80,
|
||||
render: (type) => getTypeTag(type)
|
||||
render: type => getTypeTag(type),
|
||||
},
|
||||
{
|
||||
title: '速率',
|
||||
dataIndex: 'portSpeed',
|
||||
key: 'portSpeed',
|
||||
width: 70
|
||||
width: 70,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 70,
|
||||
render: (status) => getStatusTag(status)
|
||||
render: status => getStatusTag(status),
|
||||
},
|
||||
{
|
||||
title: 'VLAN',
|
||||
dataIndex: 'vlanId',
|
||||
key: 'vlanId',
|
||||
width: 60,
|
||||
render: (vlanId) => vlanId || '-'
|
||||
render: vlanId => vlanId || '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -178,8 +205,8 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -194,30 +221,41 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
|
||||
);
|
||||
};
|
||||
|
||||
const renderCardHeader = (card) => {
|
||||
const renderCardHeader = card => {
|
||||
const stats = card.stats || { free: 0, occupied: 0, fault: 0, total: 0 };
|
||||
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '8px',
|
||||
background: card.isUngrouped
|
||||
? 'linear-gradient(135deg, #94a3b8 0%, #64748b 100%)'
|
||||
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '8px',
|
||||
background: card.isUngrouped
|
||||
? 'linear-gradient(135deg, #94a3b8 0%, #64748b 100%)'
|
||||
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{card.isUngrouped ? <FolderOutlined /> : <CloudServerOutlined />}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '14px', color: '#1e293b' }}>
|
||||
{card.name}
|
||||
{card.slotNumber && <span style={{ color: '#94a3b8', marginLeft: 8 }}>插槽 {card.slotNumber}</span>}
|
||||
{card.slotNumber && (
|
||||
<span style={{ color: '#94a3b8', marginLeft: 8 }}>插槽 {card.slotNumber}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b' }}>
|
||||
{card.description || (card.isUngrouped ? '未分配到网卡的端口' : '网卡')}
|
||||
@@ -248,7 +286,7 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setSelectedCard(card);
|
||||
setCreatePortModalVisible(true);
|
||||
@@ -270,26 +308,35 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
|
||||
);
|
||||
}
|
||||
|
||||
const totalStats = cards.reduce((acc, card) => {
|
||||
const stats = card.stats || {};
|
||||
acc.total += stats.total || 0;
|
||||
acc.free += stats.free || 0;
|
||||
acc.occupied += stats.occupied || 0;
|
||||
acc.fault += stats.fault || 0;
|
||||
return acc;
|
||||
}, { total: 0, free: 0, occupied: 0, fault: 0 });
|
||||
const totalStats = cards.reduce(
|
||||
(acc, card) => {
|
||||
const stats = card.stats || {};
|
||||
acc.total += stats.total || 0;
|
||||
acc.free += stats.free || 0;
|
||||
acc.occupied += stats.occupied || 0;
|
||||
acc.fault += stats.fault || 0;
|
||||
return acc;
|
||||
},
|
||||
{ total: 0, free: 0, occupied: 0, fault: 0 }
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="network-card-panel">
|
||||
<div className="panel-header" style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<div
|
||||
className="panel-header"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<div className="stats" style={{ display: 'flex', gap: '24px' }}>
|
||||
<Space size={16}>
|
||||
<Badge count={networkCards.length} style={{ backgroundColor: designTokens.colors.primary.main }} />
|
||||
<Badge
|
||||
count={networkCards.length}
|
||||
style={{ backgroundColor: designTokens.colors.primary.main }}
|
||||
/>
|
||||
<span style={{ color: '#64748b', fontSize: '13px' }}>个网卡</span>
|
||||
<Badge count={totalStats.total} style={{ backgroundColor: '#667eea' }} />
|
||||
<span style={{ color: '#64748b', fontSize: '13px' }}>个端口</span>
|
||||
@@ -334,11 +381,11 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
|
||||
) : (
|
||||
<Collapse
|
||||
activeKey={expandedCards}
|
||||
onChange={(keys) => setExpandedCards(keys)}
|
||||
onChange={keys => setExpandedCards(keys)}
|
||||
expandIconPosition="end"
|
||||
style={{ background: 'transparent' }}
|
||||
>
|
||||
{cards.map((card) => (
|
||||
{cards.map(card => (
|
||||
<Panel
|
||||
key={card.nicId}
|
||||
header={renderCardHeader(card)}
|
||||
@@ -346,7 +393,7 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
|
||||
background: '#fff',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '8px',
|
||||
border: '1px solid #e2e8f0'
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
{card.ports && card.ports.length > 0 ? (
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { Modal, Form, Input, Select, InputNumber, message, Space, Button, Tooltip, Alert, Tag } from 'antd';
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
InputNumber,
|
||||
message,
|
||||
Space,
|
||||
Button,
|
||||
Tooltip,
|
||||
Alert,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
|
||||
@@ -10,12 +22,12 @@ const designTokens = {
|
||||
colors: {
|
||||
primary: {
|
||||
main: '#667eea',
|
||||
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||
}
|
||||
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
medium: '10px'
|
||||
}
|
||||
medium: '10px',
|
||||
},
|
||||
};
|
||||
|
||||
function parsePortRange(portName) {
|
||||
@@ -24,34 +36,34 @@ function parsePortRange(portName) {
|
||||
}
|
||||
|
||||
const trimmed = portName.trim();
|
||||
|
||||
|
||||
if (!trimmed.includes('-')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [startPart, endPart] = trimmed.split('-').map(s => s.trim());
|
||||
|
||||
|
||||
if (!startPart || !endPart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startNumMatch = startPart.match(/(\d+)$/);
|
||||
const endNumMatch = endPart.match(/(\d+)$/);
|
||||
|
||||
|
||||
if (!startNumMatch || !endNumMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startNum = parseInt(startNumMatch[1], 10);
|
||||
const endNum = parseInt(endNumMatch[1], 10);
|
||||
|
||||
|
||||
if (startNum >= endNum || endNum - startNum > 1000) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prefix = startPart.replace(startNumMatch[0], '');
|
||||
const portCount = endNum - startNum + 1;
|
||||
|
||||
|
||||
const ports = [];
|
||||
for (let i = 0; i < portCount; i++) {
|
||||
const num = startNum + i;
|
||||
@@ -64,17 +76,17 @@ function parsePortRange(portName) {
|
||||
startNum,
|
||||
endNum,
|
||||
portCount,
|
||||
ports
|
||||
ports,
|
||||
};
|
||||
}
|
||||
|
||||
function generatePortNames(portName) {
|
||||
const result = parsePortRange(portName);
|
||||
|
||||
|
||||
if (result && result.isRange) {
|
||||
return result.ports;
|
||||
}
|
||||
|
||||
|
||||
return [portName];
|
||||
}
|
||||
|
||||
@@ -92,7 +104,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
||||
setPreviewPorts([]);
|
||||
setShowPreview(false);
|
||||
form.resetFields();
|
||||
|
||||
|
||||
if (defaultNicId) {
|
||||
form.setFieldsValue({ nicId: defaultNicId });
|
||||
}
|
||||
@@ -116,10 +128,10 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
||||
}
|
||||
};
|
||||
|
||||
const handlePortNameChange = useCallback((e) => {
|
||||
const handlePortNameChange = useCallback(e => {
|
||||
const value = e.target.value;
|
||||
const ports = generatePortNames(value);
|
||||
|
||||
|
||||
if (ports.length > 1) {
|
||||
setPreviewPorts(ports.slice(0, 20));
|
||||
setShowPreview(true);
|
||||
@@ -135,7 +147,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
||||
setLoading(true);
|
||||
|
||||
const portNames = generatePortNames(values.portName);
|
||||
|
||||
|
||||
if (portNames.length === 1) {
|
||||
await axios.post('/api/device-ports', {
|
||||
deviceId: device.deviceId,
|
||||
@@ -145,7 +157,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
||||
portSpeed: values.portSpeed,
|
||||
vlanId: values.vlanId,
|
||||
status: values.status,
|
||||
description: values.description
|
||||
description: values.description,
|
||||
});
|
||||
message.success('端口创建成功');
|
||||
} else {
|
||||
@@ -158,7 +170,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
||||
portSpeed: values.portSpeed,
|
||||
vlanId: values.vlanId,
|
||||
status: values.status,
|
||||
description: values.description
|
||||
description: values.description,
|
||||
}));
|
||||
|
||||
await axios.post('/api/device-ports/batch', { ports: portsData });
|
||||
@@ -196,9 +208,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
||||
<Space>
|
||||
<PlusOutlined style={{ color: designTokens.colors.primary.main }} />
|
||||
<span>新增端口 - {device?.name || '设备'}</span>
|
||||
{portCount > 1 && (
|
||||
<Tag color="blue">{portCount} 个端口</Tag>
|
||||
)}
|
||||
{portCount > 1 && <Tag color="blue">{portCount} 个端口</Tag>}
|
||||
</Space>
|
||||
}
|
||||
open={visible}
|
||||
@@ -217,18 +227,11 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
||||
initialValues={{
|
||||
portType: 'RJ45',
|
||||
portSpeed: '1G',
|
||||
status: 'free'
|
||||
status: 'free',
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="deviceId"
|
||||
label="设备"
|
||||
>
|
||||
<Input
|
||||
value={device?.name}
|
||||
disabled
|
||||
placeholder={device?.deviceId}
|
||||
/>
|
||||
<Form.Item name="deviceId" label="设备">
|
||||
<Input value={device?.name} disabled placeholder={device?.deviceId} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
@@ -263,16 +266,19 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
||||
label={
|
||||
<Space>
|
||||
端口名称
|
||||
<Tooltip title="支持单个端口(如 eth0/1)或端口范围(如 1/0/1-1/0/48)" mouseEnterDelay={0.5}>
|
||||
<Tooltip
|
||||
title="支持单个端口(如 eth0/1)或端口范围(如 1/0/1-1/0/48)"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<InfoCircleOutlined style={{ color: '#999' }} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
rules={[
|
||||
{ required: true, message: '请输入端口名称' },
|
||||
{
|
||||
pattern: /^[\w\/:\-]+$/,
|
||||
message: '端口名称格式不正确'
|
||||
{
|
||||
pattern: /^[\w\/:\-]+$/,
|
||||
message: '端口名称格式不正确',
|
||||
},
|
||||
{
|
||||
validator: (_, value) => {
|
||||
@@ -282,13 +288,13 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
||||
return Promise.reject(new Error('单次最多创建1000个端口'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="例如: eth0/1 或 1/0/1-1/0/48"
|
||||
onChange={(e) => {
|
||||
<Input
|
||||
placeholder="例如: eth0/1 或 1/0/1-1/0/48"
|
||||
onChange={e => {
|
||||
// 确保 Form 值更新
|
||||
form.setFieldValue('portName', e.target.value);
|
||||
handlePortNameChange(e);
|
||||
@@ -303,9 +309,12 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Space wrap size={4}>
|
||||
{previewPorts.map((port, index) => (
|
||||
<Tag key={index} color="blue">{port}</Tag>
|
||||
<Tag key={index} color="blue">
|
||||
{port}
|
||||
</Tag>
|
||||
))}
|
||||
{previewPorts.length < parsePortRange(form.getFieldValue('portName'))?.portCount && (
|
||||
{previewPorts.length <
|
||||
parsePortRange(form.getFieldValue('portName'))?.portCount && (
|
||||
<Tag color="default">...等</Tag>
|
||||
)}
|
||||
</Space>
|
||||
@@ -352,17 +361,8 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
||||
</Space>
|
||||
|
||||
<Space style={{ display: 'flex', width: '100%' }}>
|
||||
<Form.Item
|
||||
name="vlanId"
|
||||
label="VLAN ID"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<InputNumber
|
||||
placeholder="1-4094"
|
||||
min={1}
|
||||
max={4094}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Form.Item name="vlanId" label="VLAN ID" style={{ flex: 1 }}>
|
||||
<InputNumber placeholder="1-4094" min={1} max={4094} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
@@ -379,25 +379,30 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述"
|
||||
>
|
||||
<Form.Item name="description" label="描述">
|
||||
<TextArea rows={2} placeholder="请输入描述信息(可选)" />
|
||||
</Form.Item>
|
||||
|
||||
<div style={{
|
||||
background: '#f5f5f5',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
color: '#666'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
background: '#f5f5f5',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
<strong>格式说明:</strong>
|
||||
<ul style={{ margin: '8px 0 0 0', paddingLeft: '20px' }}>
|
||||
<li>单个端口:<code>eth0/1</code>、<code>gigabitethernet1/0/1</code></li>
|
||||
<li>端口范围:<code>1/0/1-1/0/48</code>(创建 1/0/1 到 1/0/48 共48个端口)</li>
|
||||
<li>简单范围:<code>eth1-eth24</code>(创建 eth1 到 eth24 共24个端口)</li>
|
||||
<li>
|
||||
单个端口:<code>eth0/1</code>、<code>gigabitethernet1/0/1</code>
|
||||
</li>
|
||||
<li>
|
||||
端口范围:<code>1/0/1-1/0/48</code>(创建 1/0/1 到 1/0/48 共48个端口)
|
||||
</li>
|
||||
<li>
|
||||
简单范围:<code>eth1-eth24</code>(创建 eth1 到 eth24 共24个端口)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Button, Space, Tag, Tooltip, Popconfirm, Empty, Spin, Badge } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, ApiOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
ApiOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import PortCreateModal from './PortCreateModal';
|
||||
|
||||
const designTokens = {
|
||||
colors: {
|
||||
primary: {
|
||||
main: '#667eea'
|
||||
main: '#667eea',
|
||||
},
|
||||
success: '#10b981',
|
||||
error: '#ef4444',
|
||||
warning: '#f59e0b'
|
||||
}
|
||||
warning: '#f59e0b',
|
||||
},
|
||||
};
|
||||
|
||||
function PortManagementPanel({ deviceId, deviceName, onRefresh }) {
|
||||
@@ -38,40 +44,43 @@ function PortManagementPanel({ deviceId, deviceName, onRefresh }) {
|
||||
fetchPorts();
|
||||
}, [fetchPorts]);
|
||||
|
||||
const handleDelete = useCallback(async (port) => {
|
||||
try {
|
||||
await axios.delete(`/api/device-ports/${port.portId}`);
|
||||
import('antd').then(({ message }) => message.success('端口删除成功'));
|
||||
fetchPorts();
|
||||
onRefresh?.();
|
||||
} catch (error) {
|
||||
import('antd').then(({ message }) => message.error('端口删除失败'));
|
||||
}
|
||||
}, [fetchPorts, onRefresh]);
|
||||
const handleDelete = useCallback(
|
||||
async port => {
|
||||
try {
|
||||
await axios.delete(`/api/device-ports/${port.portId}`);
|
||||
import('antd').then(({ message }) => message.success('端口删除成功'));
|
||||
fetchPorts();
|
||||
onRefresh?.();
|
||||
} catch (error) {
|
||||
import('antd').then(({ message }) => message.error('端口删除失败'));
|
||||
}
|
||||
},
|
||||
[fetchPorts, onRefresh]
|
||||
);
|
||||
|
||||
const handleCreateSuccess = useCallback(() => {
|
||||
fetchPorts();
|
||||
onRefresh?.();
|
||||
}, [fetchPorts, onRefresh]);
|
||||
|
||||
const getStatusTag = (status) => {
|
||||
const getStatusTag = status => {
|
||||
const config = {
|
||||
free: { color: 'success', text: '空闲' },
|
||||
occupied: { color: 'processing', text: '占用' },
|
||||
fault: { color: 'error', text: '故障' }
|
||||
fault: { color: 'error', text: '故障' },
|
||||
};
|
||||
const { color, text } = config[status] || { color: 'default', text: status };
|
||||
return <Tag color={color}>{text}</Tag>;
|
||||
};
|
||||
|
||||
const getTypeTag = (type) => {
|
||||
const getTypeTag = type => {
|
||||
const config = {
|
||||
'RJ45': { color: 'blue', text: 'RJ45' },
|
||||
'SFP': { color: 'green', text: 'SFP' },
|
||||
RJ45: { color: 'blue', text: 'RJ45' },
|
||||
SFP: { color: 'green', text: 'SFP' },
|
||||
'SFP+': { color: 'cyan', text: 'SFP+' },
|
||||
'SFP28': { color: 'purple', text: 'SFP28' },
|
||||
'QSFP': { color: 'orange', text: 'QSFP' },
|
||||
'QSFP28': { color: 'red', text: 'QSFP28' }
|
||||
SFP28: { color: 'purple', text: 'SFP28' },
|
||||
QSFP: { color: 'orange', text: 'QSFP' },
|
||||
QSFP28: { color: 'red', text: 'QSFP28' },
|
||||
};
|
||||
const { color, text } = config[type] || { color: 'default', text: type };
|
||||
return <Tag color={color}>{text}</Tag>;
|
||||
@@ -83,38 +92,38 @@ function PortManagementPanel({ deviceId, deviceName, onRefresh }) {
|
||||
dataIndex: 'portName',
|
||||
key: 'portName',
|
||||
width: 120,
|
||||
render: (text) => (
|
||||
render: text => (
|
||||
<Tooltip title={text}>
|
||||
<span style={{ fontWeight: 500 }}>{text}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'portType',
|
||||
key: 'portType',
|
||||
width: 90,
|
||||
render: (type) => getTypeTag(type)
|
||||
render: type => getTypeTag(type),
|
||||
},
|
||||
{
|
||||
title: '速率',
|
||||
dataIndex: 'portSpeed',
|
||||
key: 'portSpeed',
|
||||
width: 80
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 80,
|
||||
render: (status) => getStatusTag(status)
|
||||
render: status => getStatusTag(status),
|
||||
},
|
||||
{
|
||||
title: 'VLAN',
|
||||
dataIndex: 'vlanId',
|
||||
key: 'vlanId',
|
||||
width: 70,
|
||||
render: (vlanId) => vlanId || '-'
|
||||
render: vlanId => vlanId || '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -129,18 +138,13 @@ function PortManagementPanel({ deviceId, deviceName, onRefresh }) {
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const freeCount = ports.filter(p => p.status === 'free').length;
|
||||
@@ -169,11 +173,7 @@ function PortManagementPanel({ deviceId, deviceName, onRefresh }) {
|
||||
</Space>
|
||||
</div>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={fetchPorts}
|
||||
size="small"
|
||||
>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchPorts} size="small">
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
@@ -182,7 +182,7 @@ function PortManagementPanel({ deviceId, deviceName, onRefresh }) {
|
||||
onClick={() => setCreateModalVisible(true)}
|
||||
style={{
|
||||
background: designTokens.colors.primary.gradient,
|
||||
border: 'none'
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
新增端口
|
||||
|
||||
@@ -2,28 +2,36 @@ import React, { useState } from 'react';
|
||||
import { Tooltip, Badge, Divider, Pagination } from 'antd';
|
||||
import { LinkOutlined, SwapRightOutlined, AimOutlined, NodeIndexOutlined } from '@ant-design/icons';
|
||||
|
||||
const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onPortClick, compact = false }) => {
|
||||
const PortPanel = ({
|
||||
ports,
|
||||
deviceName,
|
||||
deviceId,
|
||||
cables = [],
|
||||
devices = [],
|
||||
onPortClick,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(48); // 默认每页48个端口
|
||||
|
||||
// 按端口名称排序(升序)
|
||||
const sortedPorts = [...ports].sort((a, b) => {
|
||||
// 尝试按数字部分排序,支持格式如:1/0/1, eth0/1, GigabitEthernet1/0/1 等
|
||||
const extractNumbers = (str) => {
|
||||
const extractNumbers = str => {
|
||||
const matches = str.match(/\d+/g);
|
||||
return matches ? matches.map(Number) : [];
|
||||
};
|
||||
|
||||
|
||||
const numsA = extractNumbers(a.portName);
|
||||
const numsB = extractNumbers(b.portName);
|
||||
|
||||
|
||||
// 逐个比较数字部分
|
||||
for (let i = 0; i < Math.min(numsA.length, numsB.length); i++) {
|
||||
if (numsA[i] !== numsB[i]) {
|
||||
return numsA[i] - numsB[i];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 如果数字部分相同,按字符串排序
|
||||
return a.portName.localeCompare(b.portName);
|
||||
});
|
||||
@@ -35,7 +43,7 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
|
||||
const paginatedPorts = sortedPorts.slice(startIndex, endIndex);
|
||||
|
||||
// 获取端口状态颜色
|
||||
const getPortStatusColor = (status) => {
|
||||
const getPortStatusColor = status => {
|
||||
switch (status) {
|
||||
case 'free':
|
||||
return '#6b7280'; // 灰色 - 空闲
|
||||
@@ -51,7 +59,7 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
|
||||
};
|
||||
|
||||
// 获取端口状态文本
|
||||
const getPortStatusText = (status) => {
|
||||
const getPortStatusText = status => {
|
||||
switch (status) {
|
||||
case 'free':
|
||||
return '空闲';
|
||||
@@ -67,7 +75,7 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
|
||||
};
|
||||
|
||||
// 获取端口类型图标 - 使用更真实的端口符号
|
||||
const getPortTypeIcon = (portType) => {
|
||||
const getPortTypeIcon = portType => {
|
||||
switch (portType) {
|
||||
case 'RJ45':
|
||||
return '⬡'; // 六边形表示网口
|
||||
@@ -84,7 +92,7 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
|
||||
};
|
||||
|
||||
// 获取简化端口显示名称(只显示数字)
|
||||
const getPortDisplayName = (portName) => {
|
||||
const getPortDisplayName = portName => {
|
||||
// 提取最后的数字
|
||||
const match = portName.match(/(\d+)$/);
|
||||
if (match) {
|
||||
@@ -95,47 +103,49 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
|
||||
};
|
||||
|
||||
// 获取线缆类型文本
|
||||
const getCableTypeText = (cableType) => {
|
||||
const getCableTypeText = cableType => {
|
||||
const typeMap = {
|
||||
'ethernet': '网线',
|
||||
'fiber': '光纤',
|
||||
'copper': '铜缆',
|
||||
'power': '电源线'
|
||||
ethernet: '网线',
|
||||
fiber: '光纤',
|
||||
copper: '铜缆',
|
||||
power: '电源线',
|
||||
};
|
||||
return typeMap[cableType] || cableType || '未知';
|
||||
};
|
||||
|
||||
// 获取线缆类型颜色
|
||||
const getCableTypeColor = (cableType) => {
|
||||
const getCableTypeColor = cableType => {
|
||||
const colorMap = {
|
||||
'ethernet': '#52c41a',
|
||||
'fiber': '#1890ff',
|
||||
'copper': '#faad14',
|
||||
'power': '#ff4d4f'
|
||||
ethernet: '#52c41a',
|
||||
fiber: '#1890ff',
|
||||
copper: '#faad14',
|
||||
power: '#ff4d4f',
|
||||
};
|
||||
return colorMap[cableType] || '#999';
|
||||
};
|
||||
|
||||
// 查找端口关联的接线
|
||||
const findPortCable = (port) => {
|
||||
const findPortCable = port => {
|
||||
if (!cables || cables.length === 0) return null;
|
||||
|
||||
return cables.find(cable =>
|
||||
(cable.sourceDeviceId === deviceId && cable.sourcePortId === port.portId) ||
|
||||
(cable.targetDeviceId === deviceId && cable.targetPortId === port.portId) ||
|
||||
(cable.sourceDeviceId === deviceId && cable.sourcePort === port.portName) ||
|
||||
(cable.targetDeviceId === deviceId && cable.targetPort === port.portName)
|
||||
|
||||
return cables.find(
|
||||
cable =>
|
||||
(cable.sourceDeviceId === deviceId && cable.sourcePortId === port.portId) ||
|
||||
(cable.targetDeviceId === deviceId && cable.targetPortId === port.portId) ||
|
||||
(cable.sourceDeviceId === deviceId && cable.sourcePort === port.portName) ||
|
||||
(cable.targetDeviceId === deviceId && cable.targetPort === port.portName)
|
||||
);
|
||||
};
|
||||
|
||||
// 获取连接的对端信息
|
||||
const getPeerInfo = (cable, currentPort) => {
|
||||
if (!cable) return null;
|
||||
|
||||
const isSource = cable.sourceDeviceId === deviceId ||
|
||||
(cable.sourcePortId && cable.sourcePortId === currentPort.portId) ||
|
||||
cable.sourcePort === currentPort.portName;
|
||||
|
||||
|
||||
const isSource =
|
||||
cable.sourceDeviceId === deviceId ||
|
||||
(cable.sourcePortId && cable.sourcePortId === currentPort.portId) ||
|
||||
cable.sourcePort === currentPort.portName;
|
||||
|
||||
if (isSource) {
|
||||
// 当前是源端,返回目标端信息
|
||||
const targetDevice = devices.find(d => d.deviceId === cable.targetDeviceId);
|
||||
@@ -143,7 +153,7 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
|
||||
deviceName: targetDevice?.name || cable.targetDeviceId,
|
||||
deviceId: cable.targetDeviceId,
|
||||
portName: cable.targetPort || cable.targetPortId,
|
||||
direction: 'out'
|
||||
direction: 'out',
|
||||
};
|
||||
} else {
|
||||
// 当前是目标端,返回源端信息
|
||||
@@ -152,37 +162,60 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
|
||||
deviceName: sourceDevice?.name || cable.sourceDeviceId,
|
||||
deviceId: cable.sourceDeviceId,
|
||||
portName: cable.sourcePort || cable.sourcePortId,
|
||||
direction: 'in'
|
||||
direction: 'in',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染端口详情提示
|
||||
const renderPortTooltip = (port) => {
|
||||
const renderPortTooltip = port => {
|
||||
const cable = findPortCable(port);
|
||||
const peerInfo = cable ? getPeerInfo(cable, port) : null;
|
||||
|
||||
|
||||
return (
|
||||
<div style={{ padding: '8px 4px', minWidth: '220px' }}>
|
||||
{/* 端口基本信息 */}
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8, borderBottom: '1px solid rgba(255,255,255,0.2)', paddingBottom: 4 }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
borderBottom: '1px solid rgba(255,255,255,0.2)',
|
||||
paddingBottom: 4,
|
||||
}}
|
||||
>
|
||||
<NodeIndexOutlined style={{ marginRight: 6 }} />
|
||||
{port.portName}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, lineHeight: '1.8' }}>
|
||||
<div><span style={{ opacity: 0.7 }}>端口类型:</span> {port.portType}</div>
|
||||
<div><span style={{ opacity: 0.7 }}>端口速率:</span> {port.portSpeed}</div>
|
||||
<div><span style={{ opacity: 0.7 }}>状态:</span>
|
||||
<span style={{
|
||||
color: getPortStatusColor(port.status),
|
||||
marginLeft: 4,
|
||||
fontWeight: 500
|
||||
}}>
|
||||
<div>
|
||||
<span style={{ opacity: 0.7 }}>端口类型:</span> {port.portType}
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ opacity: 0.7 }}>端口速率:</span> {port.portSpeed}
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ opacity: 0.7 }}>状态:</span>
|
||||
<span
|
||||
style={{
|
||||
color: getPortStatusColor(port.status),
|
||||
marginLeft: 4,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{getPortStatusText(port.status)}
|
||||
</span>
|
||||
</div>
|
||||
{port.vlanId && <div><span style={{ opacity: 0.7 }}>VLAN:</span> {port.vlanId}</div>}
|
||||
{port.description && <div><span style={{ opacity: 0.7 }}>描述:</span> {port.description}</div>}
|
||||
{port.vlanId && (
|
||||
<div>
|
||||
<span style={{ opacity: 0.7 }}>VLAN:</span> {port.vlanId}
|
||||
</div>
|
||||
)}
|
||||
{port.description && (
|
||||
<div>
|
||||
<span style={{ opacity: 0.7 }}>描述:</span> {port.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 接线信息 */}
|
||||
@@ -196,62 +229,60 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
|
||||
<div style={{ fontSize: 12, lineHeight: '1.8' }}>
|
||||
{/* 线缆类型和长度 */}
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
background: getCableTypeColor(cable.cableType) + '20',
|
||||
color: getCableTypeColor(cable.cableType),
|
||||
fontSize: '11px',
|
||||
fontWeight: 500
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
background: getCableTypeColor(cable.cableType) + '20',
|
||||
color: getCableTypeColor(cable.cableType),
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{getCableTypeText(cable.cableType)}
|
||||
</span>
|
||||
{cable.cableLength && (
|
||||
<span style={{ marginLeft: 8, opacity: 0.8 }}>
|
||||
{cable.cableLength}m
|
||||
</span>
|
||||
<span style={{ marginLeft: 8, opacity: 0.8 }}>{cable.cableLength}m</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* 连接方向 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: '6px',
|
||||
marginTop: '8px'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: '6px',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
background: peerInfo.direction === 'out' ? '#52c41a20' : '#1890ff20',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
background: peerInfo.direction === 'out' ? '#52c41a20' : '#1890ff20',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{peerInfo.direction === 'out' ? '📤' : '📥'}
|
||||
</div>
|
||||
<div style={{ fontSize: '10px', marginTop: '2px', opacity: 0.6 }}>
|
||||
{peerInfo.direction === 'out' ? '输出' : '输入'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500, color: '#fff' }}>
|
||||
{peerInfo.deviceName}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', opacity: 0.7 }}>
|
||||
端口: {peerInfo.portName}
|
||||
</div>
|
||||
<div style={{ fontSize: '10px', opacity: 0.5 }}>
|
||||
ID: {peerInfo.deviceId}
|
||||
</div>
|
||||
<div style={{ fontWeight: 500, color: '#fff' }}>{peerInfo.deviceName}</div>
|
||||
<div style={{ fontSize: '11px', opacity: 0.7 }}>端口: {peerInfo.portName}</div>
|
||||
<div style={{ fontSize: '10px', opacity: 0.5 }}>ID: {peerInfo.deviceId}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -285,34 +316,40 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'linear-gradient(145deg, #1e293b 0%, #0f172a 100%)',
|
||||
borderRadius: compact ? '12px' : '16px',
|
||||
padding: compact ? '16px' : '24px',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.1)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(145deg, #1e293b 0%, #0f172a 100%)',
|
||||
borderRadius: compact ? '12px' : '16px',
|
||||
padding: compact ? '16px' : '24px',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.1)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
>
|
||||
{/* 设备标题 - compact 模式下隐藏 */}
|
||||
{!compact && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '20px',
|
||||
paddingBottom: '16px',
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.1)'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '20px',
|
||||
paddingBottom: '16px',
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '10px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '10px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px',
|
||||
}}
|
||||
>
|
||||
🔌
|
||||
</div>
|
||||
<div>
|
||||
@@ -324,37 +361,43 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 状态图例 */}
|
||||
<div style={{ display: 'flex', gap: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<div style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: '#6b7280',
|
||||
boxShadow: '0 0 8px #6b7280'
|
||||
}} />
|
||||
<div
|
||||
style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: '#6b7280',
|
||||
boxShadow: '0 0 8px #6b7280',
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: '12px', color: 'rgba(255,255,255,0.6)' }}>空闲</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<div style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: '#10b981',
|
||||
boxShadow: '0 0 8px #10b981'
|
||||
}} />
|
||||
<div
|
||||
style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: '#10b981',
|
||||
boxShadow: '0 0 8px #10b981',
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: '12px', color: 'rgba(255,255,255,0.6)' }}>已连接</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<div style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: '#ef4444',
|
||||
boxShadow: '0 0 8px #ef4444'
|
||||
}} />
|
||||
<div
|
||||
style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: '#ef4444',
|
||||
boxShadow: '0 0 8px #ef4444',
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: '12px', color: 'rgba(255,255,255,0.6)' }}>故障</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,29 +405,31 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
|
||||
)}
|
||||
|
||||
{/* 端口网格 - 固定每行24个端口 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(24, 1fr)',
|
||||
gap: '8px',
|
||||
padding: '16px',
|
||||
background: 'rgba(0, 0, 0, 0.3)',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(255, 255, 255, 0.05)'
|
||||
}}>
|
||||
{paginatedPorts.map((port) => {
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(24, 1fr)',
|
||||
gap: '8px',
|
||||
padding: '16px',
|
||||
background: 'rgba(0, 0, 0, 0.3)',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(255, 255, 255, 0.05)',
|
||||
}}
|
||||
>
|
||||
{paginatedPorts.map(port => {
|
||||
const statusColor = getPortStatusColor(port.status);
|
||||
const isClickable = onPortClick && port.status !== 'disabled';
|
||||
const cable = findPortCable(port);
|
||||
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={port.portId}
|
||||
<Tooltip
|
||||
key={port.portId}
|
||||
title={renderPortTooltip(port)}
|
||||
placement="top"
|
||||
color="#1e293b"
|
||||
overlayStyle={{
|
||||
overlayStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)'
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -397,69 +442,79 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
|
||||
cursor: isClickable ? 'pointer' : 'not-allowed',
|
||||
transition: 'all 0.2s ease',
|
||||
position: 'relative',
|
||||
minWidth: '0'
|
||||
minWidth: '0',
|
||||
}}
|
||||
>
|
||||
{/* LED 指示灯 - 在端口上方 */}
|
||||
<div style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: statusColor,
|
||||
boxShadow: `0 0 6px ${statusColor}, 0 0 12px ${statusColor}50`,
|
||||
marginBottom: '4px',
|
||||
animation: port.status === 'fault' ? 'pulse 1.5s infinite' : 'none'
|
||||
}} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: statusColor,
|
||||
boxShadow: `0 0 6px ${statusColor}, 0 0 12px ${statusColor}50`,
|
||||
marginBottom: '4px',
|
||||
animation: port.status === 'fault' ? 'pulse 1.5s infinite' : 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 端口主体 - 矩形样式 */}
|
||||
<div style={{
|
||||
width: '100%',
|
||||
aspectRatio: '1 / 1.2',
|
||||
background: 'linear-gradient(180deg, #2a3441 0%, #1e2530 100%)',
|
||||
border: `2px solid ${statusColor}`,
|
||||
borderRadius: '2px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
boxShadow: `inset 0 1px 0 rgba(255,255,255,0.1), 0 2px 4px rgba(0,0,0,0.3)`
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: '1 / 1.2',
|
||||
background: 'linear-gradient(180deg, #2a3441 0%, #1e2530 100%)',
|
||||
border: `2px solid ${statusColor}`,
|
||||
borderRadius: '2px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
boxShadow: `inset 0 1px 0 rgba(255,255,255,0.1), 0 2px 4px rgba(0,0,0,0.3)`,
|
||||
}}
|
||||
>
|
||||
{/* 端口内部图标 */}
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: statusColor,
|
||||
opacity: 0.8
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
color: statusColor,
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{getPortTypeIcon(port.portType)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* 接线指示标记 */}
|
||||
{cable && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '1px',
|
||||
right: '1px',
|
||||
width: '4px',
|
||||
height: '4px',
|
||||
borderRadius: '50%',
|
||||
background: getCableTypeColor(cable.cableType),
|
||||
boxShadow: `0 0 3px ${getCableTypeColor(cable.cableType)}`
|
||||
}} />
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '1px',
|
||||
right: '1px',
|
||||
width: '4px',
|
||||
height: '4px',
|
||||
borderRadius: '50%',
|
||||
background: getCableTypeColor(cable.cableType),
|
||||
boxShadow: `0 0 3px ${getCableTypeColor(cable.cableType)}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* 端口名称 - 在端口下方 */}
|
||||
<div style={{
|
||||
fontSize: '9px',
|
||||
fontWeight: 500,
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
textAlign: 'center',
|
||||
marginTop: '3px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '9px',
|
||||
fontWeight: 500,
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
textAlign: 'center',
|
||||
marginTop: '3px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{getPortDisplayName(port.portName)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -470,13 +525,15 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPorts > pageSize && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '16px 0 0 0',
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
marginTop: '16px'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '16px 0 0 0',
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
marginTop: '16px',
|
||||
}}
|
||||
>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
total={totalPorts}
|
||||
@@ -487,11 +544,11 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
|
||||
}}
|
||||
showSizeChanger
|
||||
showQuickJumper
|
||||
showTotal={(total) => `共 ${total} 个端口`}
|
||||
showTotal={total => `共 ${total} 个端口`}
|
||||
pageSizeOptions={['24', '48', '96']}
|
||||
size="small"
|
||||
style={{
|
||||
color: 'rgba(255, 255, 255, 0.8)'
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -9,14 +9,16 @@ const ProtectedRoute = ({ children, requiredPermission }) => {
|
||||
|
||||
if (!initialized) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<Spin size="large" />
|
||||
<span style={{ color: '#8c8c8c', fontSize: '14px' }}>加载中...</span>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
DesktopOutlined,
|
||||
UsbOutlined,
|
||||
MonitorOutlined,
|
||||
SettingOutlined
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import PortPanel from './PortPanel';
|
||||
import axios from 'axios';
|
||||
@@ -23,8 +23,8 @@ const designTokens = {
|
||||
error: '#ef4444',
|
||||
warning: '#f59e0b',
|
||||
metal: { light: '#9ca3af', DEFAULT: '#6b7280', dark: '#4b5563' },
|
||||
slot: { empty: '#d1d5db', occupied: '#3b82f6' }
|
||||
}
|
||||
slot: { empty: '#d1d5db', occupied: '#3b82f6' },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -44,7 +44,7 @@ const ServerBackplanePanel = ({
|
||||
cables,
|
||||
allDevices,
|
||||
onPortClick,
|
||||
onManageNetworkCards
|
||||
onManageNetworkCards,
|
||||
}) => {
|
||||
const [cards, setCards] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -82,9 +82,20 @@ const ServerBackplanePanel = ({
|
||||
const name = (card.name || '').toLowerCase();
|
||||
|
||||
// 判断网卡类型
|
||||
if (name.includes('idrac') || name.includes('ilo') || name.includes('bmc') || name.includes('mgmt') || name.includes('管理')) {
|
||||
if (
|
||||
name.includes('idrac') ||
|
||||
name.includes('ilo') ||
|
||||
name.includes('bmc') ||
|
||||
name.includes('mgmt') ||
|
||||
name.includes('管理')
|
||||
) {
|
||||
management.push({ ...card, type: 'management' });
|
||||
} else if (slotNum === 0 || name.includes('onboard') || name.includes('板载') || name.includes('内置')) {
|
||||
} else if (
|
||||
slotNum === 0 ||
|
||||
name.includes('onboard') ||
|
||||
name.includes('板载') ||
|
||||
name.includes('内置')
|
||||
) {
|
||||
onboard.push({ ...card, type: 'onboard' });
|
||||
} else {
|
||||
expansionSlots.push({ ...card, type: 'expansion', slotIndex: slotNum });
|
||||
@@ -115,7 +126,7 @@ const ServerBackplanePanel = ({
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
border: '2px solid #4b5563',
|
||||
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.1)'
|
||||
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.1)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '10px', color: '#9ca3af', fontWeight: 600 }}>MGMT</div>
|
||||
@@ -134,7 +145,7 @@ const ServerBackplanePanel = ({
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.3)'
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
>
|
||||
<SettingOutlined style={{ fontSize: 16, color: '#10b981' }} />
|
||||
@@ -152,7 +163,7 @@ const ServerBackplanePanel = ({
|
||||
border: '2px dashed #6b7280',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<PlusOutlined style={{ fontSize: 14, color: '#6b7280' }} />
|
||||
@@ -160,11 +171,47 @@ const ServerBackplanePanel = ({
|
||||
)}
|
||||
|
||||
{/* 其他接口占位 */}
|
||||
<div style={{ width: '48px', height: '24px', background: '#1f2937', borderRadius: '2px', border: '1px solid #4b5563' }}>
|
||||
<Text style={{ fontSize: '8px', color: '#6b7280', display: 'block', textAlign: 'center', lineHeight: '22px' }}>VGA</Text>
|
||||
<div
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '24px',
|
||||
background: '#1f2937',
|
||||
borderRadius: '2px',
|
||||
border: '1px solid #4b5563',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '8px',
|
||||
color: '#6b7280',
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
lineHeight: '22px',
|
||||
}}
|
||||
>
|
||||
VGA
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ width: '48px', height: '16px', background: '#1f2937', borderRadius: '2px', border: '1px solid #4b5563' }}>
|
||||
<Text style={{ fontSize: '8px', color: '#6b7280', display: 'block', textAlign: 'center', lineHeight: '14px' }}>USB</Text>
|
||||
<div
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '16px',
|
||||
background: '#1f2937',
|
||||
borderRadius: '2px',
|
||||
border: '1px solid #4b5563',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '8px',
|
||||
color: '#6b7280',
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
lineHeight: '14px',
|
||||
}}
|
||||
>
|
||||
USB
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -182,11 +229,20 @@ const ServerBackplanePanel = ({
|
||||
borderRadius: '4px',
|
||||
padding: '12px',
|
||||
border: '2px solid #6b7280',
|
||||
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.1)'
|
||||
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.1)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<Text style={{ fontSize: '11px', color: '#d1d5db', fontWeight: 600 }}>板载网卡 (Onboard)</Text>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: '11px', color: '#d1d5db', fontWeight: 600 }}>
|
||||
板载网卡 (Onboard)
|
||||
</Text>
|
||||
{onboardCard && (
|
||||
<Badge
|
||||
count={onboardCard.ports?.length || 0}
|
||||
@@ -204,18 +260,21 @@ const ServerBackplanePanel = ({
|
||||
padding: '12px',
|
||||
border: `2px solid ${onboardCard.ports?.length > 0 ? designTokens.colors.primary.main : '#6b7280'}`,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
{/* 4个RJ45端口布局 */}
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}>
|
||||
{[0, 1, 2, 3].map((idx) => {
|
||||
{[0, 1, 2, 3].map(idx => {
|
||||
const port = onboardCard.ports?.[idx];
|
||||
const hasPort = !!port;
|
||||
const isOccupied = hasPort && port.status === 'occupied';
|
||||
|
||||
return (
|
||||
<Tooltip key={idx} title={hasPort ? `${port.portName} - ${port.status}` : '未配置'}>
|
||||
<Tooltip
|
||||
key={idx}
|
||||
title={hasPort ? `${port.portName} - ${port.status}` : '未配置'}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
@@ -224,15 +283,18 @@ const ServerBackplanePanel = ({
|
||||
? 'linear-gradient(180deg, #374151 0%, #1f2937 100%)'
|
||||
: '#374151',
|
||||
borderRadius: '4px',
|
||||
border: `2px solid ${hasPort
|
||||
? (isOccupied ? designTokens.colors.success : designTokens.colors.metal.light)
|
||||
: '#4b5563'
|
||||
border: `2px solid ${
|
||||
hasPort
|
||||
? isOccupied
|
||||
? designTokens.colors.success
|
||||
: designTokens.colors.metal.light
|
||||
: '#4b5563'
|
||||
}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative'
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* LED指示灯 */}
|
||||
@@ -241,13 +303,11 @@ const ServerBackplanePanel = ({
|
||||
width: '4px',
|
||||
height: '4px',
|
||||
borderRadius: '50%',
|
||||
background: hasPort
|
||||
? (isOccupied ? '#10b981' : '#6b7280')
|
||||
: '#374151',
|
||||
background: hasPort ? (isOccupied ? '#10b981' : '#6b7280') : '#374151',
|
||||
position: 'absolute',
|
||||
top: '2px',
|
||||
right: '2px',
|
||||
boxShadow: isOccupied ? '0 0 4px #10b981' : 'none'
|
||||
boxShadow: isOccupied ? '0 0 4px #10b981' : 'none',
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: '8px', color: '#9ca3af' }}>⬡</span>
|
||||
@@ -273,7 +333,7 @@ const ServerBackplanePanel = ({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
gap: '8px'
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<PlusOutlined style={{ fontSize: 20, color: '#6b7280' }} />
|
||||
@@ -303,13 +363,23 @@ const ServerBackplanePanel = ({
|
||||
borderRadius: '4px',
|
||||
padding: '12px',
|
||||
border: '2px solid #6b7280',
|
||||
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.1)'
|
||||
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.1)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: '11px', color: '#d1d5db', fontWeight: 600 }}>PCIe 扩展插槽</Text>
|
||||
<Space size={4}>
|
||||
<Badge count={expansionSlots.length} style={{ backgroundColor: designTokens.colors.primary.main }} />
|
||||
<Badge
|
||||
count={expansionSlots.length}
|
||||
style={{ backgroundColor: designTokens.colors.primary.main }}
|
||||
/>
|
||||
<Text style={{ fontSize: '10px', color: '#9ca3af' }}>/{totalSlots}</Text>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -318,7 +388,9 @@ const ServerBackplanePanel = ({
|
||||
{slots.map(({ slotNumber, card }) => (
|
||||
<Tooltip
|
||||
key={slotNumber}
|
||||
title={card ? `${card.name} (${card.ports?.length || 0}口)` : `插槽 ${slotNumber} (空闲)`}
|
||||
title={
|
||||
card ? `${card.name} (${card.ports?.length || 0}口)` : `插槽 ${slotNumber} (空闲)`
|
||||
}
|
||||
>
|
||||
<div
|
||||
onClick={() => card && setSelectedSlot(card)}
|
||||
@@ -337,15 +409,24 @@ const ServerBackplanePanel = ({
|
||||
padding: '6px',
|
||||
cursor: card ? 'pointer' : 'default',
|
||||
transition: 'all 0.2s',
|
||||
boxShadow: card ? '0 2px 8px rgba(59, 130, 246, 0.3)' : 'none'
|
||||
boxShadow: card ? '0 2px 8px rgba(59, 130, 246, 0.3)' : 'none',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: '9px', color: '#6b7280', fontWeight: 600 }}>Slot {slotNumber}</Text>
|
||||
<Text style={{ fontSize: '9px', color: '#6b7280', fontWeight: 600 }}>
|
||||
Slot {slotNumber}
|
||||
</Text>
|
||||
|
||||
{card ? (
|
||||
<>
|
||||
<CloudServerOutlined style={{ fontSize: 20, color: '#3b82f6' }} />
|
||||
<div style={{ display: 'flex', gap: '2px', flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '2px',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{card.ports?.slice(0, 4).map((port, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
@@ -354,19 +435,30 @@ const ServerBackplanePanel = ({
|
||||
height: '8px',
|
||||
borderRadius: '1px',
|
||||
background: port.status === 'occupied' ? '#10b981' : '#6b7280',
|
||||
boxShadow: port.status === 'occupied' ? '0 0 2px #10b981' : 'none'
|
||||
boxShadow: port.status === 'occupied' ? '0 0 2px #10b981' : 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{card.ports?.length > 4 && (
|
||||
<Text style={{ fontSize: '8px', color: '#9ca3af' }}>+{card.ports.length - 4}</Text>
|
||||
<Text style={{ fontSize: '8px', color: '#9ca3af' }}>
|
||||
+{card.ports.length - 4}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Text style={{ fontSize: '8px', color: '#9ca3af' }}>{card.ports?.length || 0}口</Text>
|
||||
<Text style={{ fontSize: '8px', color: '#9ca3af' }}>
|
||||
{card.ports?.length || 0}口
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ width: '40px', height: '40px', border: '2px dashed #4b5563', borderRadius: '4px' }} />
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: '2px dashed #4b5563',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
/>
|
||||
<Text style={{ fontSize: '8px', color: '#6b7280' }}>空闲</Text>
|
||||
</>
|
||||
)}
|
||||
@@ -390,11 +482,13 @@ const ServerBackplanePanel = ({
|
||||
border: '2px solid #4b5563',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px'
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: '10px', color: '#9ca3af', fontWeight: 600, textAlign: 'center' }}>电源</Text>
|
||||
{[1, 2].map((psu) => (
|
||||
<Text style={{ fontSize: '10px', color: '#9ca3af', fontWeight: 600, textAlign: 'center' }}>
|
||||
电源
|
||||
</Text>
|
||||
{[1, 2].map(psu => (
|
||||
<div
|
||||
key={psu}
|
||||
style={{
|
||||
@@ -406,7 +500,7 @@ const ServerBackplanePanel = ({
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '4px'
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
<ThunderboltOutlined style={{ fontSize: 20, color: '#10b981' }} />
|
||||
@@ -417,7 +511,7 @@ const ServerBackplanePanel = ({
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: '#10b981',
|
||||
boxShadow: '0 0 6px #10b981'
|
||||
boxShadow: '0 0 6px #10b981',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -445,17 +539,24 @@ const ServerBackplanePanel = ({
|
||||
padding: '8px 12px',
|
||||
background: '#f8fafc',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0'
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<Badge count={cards.filter(c => !c.isUngrouped).length} style={{ backgroundColor: designTokens.colors.primary.main }} />
|
||||
<Text type="secondary" style={{ fontSize: '13px' }}>个网卡</Text>
|
||||
<Badge
|
||||
count={cards.filter(c => !c.isUngrouped).length}
|
||||
style={{ backgroundColor: designTokens.colors.primary.main }}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: '13px' }}>
|
||||
个网卡
|
||||
</Text>
|
||||
<Badge
|
||||
count={cards.reduce((acc, card) => acc + (card.ports?.length || 0), 0)}
|
||||
style={{ backgroundColor: '#667eea' }}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: '13px' }}>个端口</Text>
|
||||
<Text type="secondary" style={{ fontSize: '13px' }}>
|
||||
个端口
|
||||
</Text>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={fetchData}>
|
||||
@@ -480,7 +581,7 @@ const ServerBackplanePanel = ({
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
border: '3px solid #374151',
|
||||
boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.2), 0 4px 12px rgba(0,0,0,0.3)'
|
||||
boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.2), 0 4px 12px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
>
|
||||
{/* 服务器标识 */}
|
||||
@@ -492,7 +593,7 @@ const ServerBackplanePanel = ({
|
||||
marginBottom: '12px',
|
||||
padding: '6px 12px',
|
||||
background: 'rgba(0,0,0,0.3)',
|
||||
borderRadius: '4px'
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
<DesktopOutlined style={{ fontSize: 14, color: '#9ca3af', marginRight: 8 }} />
|
||||
@@ -536,16 +637,34 @@ const ServerBackplanePanel = ({
|
||||
>
|
||||
{selectedSlot && (
|
||||
<div>
|
||||
<div style={{ marginBottom: '16px', padding: '12px', background: '#f8fafc', borderRadius: '8px' }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '16px',
|
||||
padding: '12px',
|
||||
background: '#f8fafc',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Text type="secondary">类型: {selectedSlot.type === 'onboard' ? '板载网卡' : selectedSlot.type === 'management' ? '管理口' : '扩展网卡'}</Text>
|
||||
{selectedSlot.description && <Text type="secondary">描述: {selectedSlot.description}</Text>}
|
||||
<Text type="secondary">
|
||||
类型:{' '}
|
||||
{selectedSlot.type === 'onboard'
|
||||
? '板载网卡'
|
||||
: selectedSlot.type === 'management'
|
||||
? '管理口'
|
||||
: '扩展网卡'}
|
||||
</Text>
|
||||
{selectedSlot.description && (
|
||||
<Text type="secondary">描述: {selectedSlot.description}</Text>
|
||||
)}
|
||||
<div>
|
||||
<Text type="secondary">端口统计: </Text>
|
||||
<Space size={8}>
|
||||
<Tag color="success">空闲: {selectedSlot.stats?.free || 0}</Tag>
|
||||
<Tag color="processing">占用: {selectedSlot.stats?.occupied || 0}</Tag>
|
||||
{selectedSlot.stats?.fault > 0 && <Tag color="error">故障: {selectedSlot.stats.fault}</Tag>}
|
||||
{selectedSlot.stats?.fault > 0 && (
|
||||
<Tag color="error">故障: {selectedSlot.stats.fault}</Tag>
|
||||
)}
|
||||
<Tag color="blue">总计: {selectedSlot.ports?.length || 0}</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Button, Empty, Spin, Badge, Typography, Space, Checkbox, Tooltip } from 'antd';
|
||||
import { DownOutlined, UpOutlined, EyeOutlined, EyeInvisibleOutlined, PlusOutlined, CloudServerOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
DownOutlined,
|
||||
UpOutlined,
|
||||
EyeOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
PlusOutlined,
|
||||
CloudServerOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import ServerBackplanePanel from './ServerBackplanePanel';
|
||||
import PortPanel from './PortPanel';
|
||||
|
||||
@@ -9,7 +16,7 @@ const { Text } = Typography;
|
||||
/**
|
||||
* 虚拟设备列表组件
|
||||
* 用于优化大量设备面板的渲染性能
|
||||
*
|
||||
*
|
||||
* @param {Object[]} devices - 设备列表
|
||||
* @param {Object} groupedPorts - 按设备分组的端口数据
|
||||
* @param {Object[]} cables - 接线列表
|
||||
@@ -29,7 +36,7 @@ const VirtualDeviceList = ({
|
||||
onAddPort,
|
||||
onManageNetworkCards,
|
||||
initialVisibleCount = 5,
|
||||
loadMoreCount = 5
|
||||
loadMoreCount = 5,
|
||||
}) => {
|
||||
const [visibleCount, setVisibleCount] = useState(initialVisibleCount);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -54,10 +61,10 @@ const VirtualDeviceList = ({
|
||||
const options = {
|
||||
root: null,
|
||||
rootMargin: '100px',
|
||||
threshold: 0.1
|
||||
threshold: 0.1,
|
||||
};
|
||||
|
||||
observerRef.current = new IntersectionObserver((entries) => {
|
||||
observerRef.current = new IntersectionObserver(entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && !loading && visibleCount < devices.length) {
|
||||
loadMore();
|
||||
@@ -79,7 +86,7 @@ const VirtualDeviceList = ({
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loading || visibleCount >= devices.length) return;
|
||||
|
||||
|
||||
setLoading(true);
|
||||
// 模拟异步加载,实际可以直接同步更新
|
||||
setTimeout(() => {
|
||||
@@ -110,10 +117,10 @@ const VirtualDeviceList = ({
|
||||
setShowAll(false);
|
||||
}, [devices]);
|
||||
|
||||
const toggleDeviceExpand = (deviceId) => {
|
||||
const toggleDeviceExpand = deviceId => {
|
||||
setExpandedDevices(prev => ({
|
||||
...prev,
|
||||
[deviceId]: !prev[deviceId]
|
||||
[deviceId]: !prev[deviceId],
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -121,42 +128,38 @@ const VirtualDeviceList = ({
|
||||
const hasMore = visibleCount < devices.length;
|
||||
|
||||
if (devices.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
description="暂无设备数据"
|
||||
style={{ padding: '60px 0' }}
|
||||
/>
|
||||
);
|
||||
return <Empty description="暂无设备数据" style={{ padding: '60px 0' }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{/* 控制栏 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
background: '#f8fafc',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
background: '#f8fafc',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Space align="center">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Text strong style={{ fontSize: '14px' }}>设备列表</Text>
|
||||
<Badge
|
||||
count={devices.length}
|
||||
style={{ backgroundColor: '#667eea' }}
|
||||
/>
|
||||
<Text strong style={{ fontSize: '14px' }}>
|
||||
设备列表
|
||||
</Text>
|
||||
<Badge count={devices.length} style={{ backgroundColor: '#667eea' }} />
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
显示 {visibleDevices.length} / {devices.length}
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
<Button
|
||||
size="small"
|
||||
icon={showAll ? <UpOutlined /> : <DownOutlined />}
|
||||
onClick={showAll ? handleCollapseAll : handleShowAll}
|
||||
>
|
||||
@@ -166,7 +169,7 @@ const VirtualDeviceList = ({
|
||||
</div>
|
||||
|
||||
{/* 设备面板列表 */}
|
||||
{visibleDevices.map((device) => {
|
||||
{visibleDevices.map(device => {
|
||||
const deviceId = device.deviceId;
|
||||
const data = groupedPorts[deviceId] || { device, ports: [] };
|
||||
const isExpanded = expandedDevices[deviceId];
|
||||
@@ -174,14 +177,14 @@ const VirtualDeviceList = ({
|
||||
const occupiedCount = data.ports?.filter(p => p.status === 'occupied').length || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
key={deviceId}
|
||||
style={{
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
background: '#fff',
|
||||
transition: 'all 0.3s ease'
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{/* 设备标题栏 */}
|
||||
@@ -195,31 +198,37 @@ const VirtualDeviceList = ({
|
||||
background: isExpanded ? '#f1f5f9' : '#fff',
|
||||
cursor: 'pointer',
|
||||
borderBottom: isExpanded ? '1px solid #e2e8f0' : 'none',
|
||||
transition: 'background 0.2s'
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.background = '#f1f5f9';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
onMouseLeave={e => {
|
||||
if (!isExpanded) {
|
||||
e.currentTarget.style.background = '#fff';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '10px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px'
|
||||
}}>
|
||||
{device.type?.toLowerCase()?.includes('server') ? '🖥️' :
|
||||
device.type?.toLowerCase()?.includes('switch') ? '🔀' :
|
||||
device.type?.toLowerCase()?.includes('router') ? '🌐' : '📦'}
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '10px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px',
|
||||
}}
|
||||
>
|
||||
{device.type?.toLowerCase()?.includes('server')
|
||||
? '🖥️'
|
||||
: device.type?.toLowerCase()?.includes('switch')
|
||||
? '🔀'
|
||||
: device.type?.toLowerCase()?.includes('router')
|
||||
? '🌐'
|
||||
: '📦'}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '15px', color: '#1e293b' }}>
|
||||
@@ -233,16 +242,16 @@ const VirtualDeviceList = ({
|
||||
|
||||
<Space size="middle">
|
||||
<Space size="small">
|
||||
<Badge
|
||||
count={occupiedCount}
|
||||
<Badge
|
||||
count={occupiedCount}
|
||||
style={{ backgroundColor: '#3b82f6' }}
|
||||
overflowCount={999}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
已用
|
||||
</Text>
|
||||
<Badge
|
||||
count={portCount}
|
||||
<Badge
|
||||
count={portCount}
|
||||
style={{ backgroundColor: '#10b981' }}
|
||||
overflowCount={999}
|
||||
/>
|
||||
@@ -257,13 +266,13 @@ const VirtualDeviceList = ({
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<CloudServerOutlined />}
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
e.stopPropagation(); // 防止触发折叠
|
||||
onManageNetworkCards && onManageNetworkCards(device);
|
||||
}}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
border: 'none'
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
网卡管理
|
||||
@@ -275,13 +284,13 @@ const VirtualDeviceList = ({
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
e.stopPropagation(); // 防止触发折叠
|
||||
onAddPort && onAddPort(device);
|
||||
}}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
border: 'none'
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
添加端口
|
||||
@@ -318,7 +327,9 @@ const VirtualDeviceList = ({
|
||||
cables={cables}
|
||||
allDevices={allDevices}
|
||||
onPortClick={onPortClick}
|
||||
onManageNetworkCards={() => onManageNetworkCards && onManageNetworkCards(device)}
|
||||
onManageNetworkCards={() =>
|
||||
onManageNetworkCards && onManageNetworkCards(device)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -329,20 +340,18 @@ const VirtualDeviceList = ({
|
||||
|
||||
{/* 加载更多触发器 */}
|
||||
{hasMore && !showAll && (
|
||||
<div
|
||||
<div
|
||||
id="load-more-trigger"
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '20px',
|
||||
color: '#64748b'
|
||||
color: '#64748b',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Spin size="small" tip="加载更多设备..." />
|
||||
) : (
|
||||
<Text type="secondary">
|
||||
向下滚动加载更多 ({devices.length - visibleCount} 个设备)
|
||||
</Text>
|
||||
<Text type="secondary">向下滚动加载更多 ({devices.length - visibleCount} 个设备)</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,49 +9,49 @@ export const designTokens = {
|
||||
main: '#667eea',
|
||||
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
light: '#8b9ff0',
|
||||
dark: '#4f5db8'
|
||||
dark: '#4f5db8',
|
||||
},
|
||||
success: {
|
||||
main: '#10b981',
|
||||
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
light: '#34d399',
|
||||
dark: '#047857'
|
||||
dark: '#047857',
|
||||
},
|
||||
warning: {
|
||||
main: '#f59e0b',
|
||||
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
light: '#fbbf24',
|
||||
dark: '#b45309'
|
||||
dark: '#b45309',
|
||||
},
|
||||
error: {
|
||||
main: '#ef4444',
|
||||
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
|
||||
light: '#f87171',
|
||||
dark: '#b91c1c'
|
||||
dark: '#b91c1c',
|
||||
},
|
||||
text: {
|
||||
primary: '#1e293b',
|
||||
secondary: '#64748b',
|
||||
tertiary: '#94a3b8',
|
||||
inverse: '#ffffff'
|
||||
inverse: '#ffffff',
|
||||
},
|
||||
background: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#f8fafc',
|
||||
tertiary: '#f1f5f9',
|
||||
dark: '#1e293b'
|
||||
dark: '#1e293b',
|
||||
},
|
||||
border: {
|
||||
light: '#e2e8f0',
|
||||
medium: '#cbd5e1',
|
||||
dark: '#94a3b8'
|
||||
dark: '#94a3b8',
|
||||
},
|
||||
device: {
|
||||
server: '#3b82f6',
|
||||
switch: '#22c55e',
|
||||
router: '#f59e0b',
|
||||
storage: '#8b5cf6',
|
||||
other: '#64748b'
|
||||
other: '#64748b',
|
||||
},
|
||||
status: {
|
||||
normal: '#10b981',
|
||||
@@ -60,35 +60,35 @@ export const designTokens = {
|
||||
error: '#ef4444',
|
||||
fault: '#ef4444',
|
||||
offline: '#6b7280',
|
||||
maintenance: '#3b82f6'
|
||||
}
|
||||
maintenance: '#3b82f6',
|
||||
},
|
||||
},
|
||||
shadows: {
|
||||
small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
|
||||
large: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)',
|
||||
xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)',
|
||||
glow: '0 0 20px rgba(102, 126, 234, 0.3)'
|
||||
glow: '0 0 20px rgba(102, 126, 234, 0.3)',
|
||||
},
|
||||
borderRadius: {
|
||||
small: '6px',
|
||||
medium: '10px',
|
||||
large: '16px',
|
||||
xl: '24px',
|
||||
round: '50%'
|
||||
round: '50%',
|
||||
},
|
||||
transitions: {
|
||||
fast: '150ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
normal: '300ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
slow: '500ms cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
slow: '500ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
spacing: {
|
||||
xs: '4px',
|
||||
sm: '8px',
|
||||
md: '16px',
|
||||
lg: '24px',
|
||||
xl: '32px'
|
||||
}
|
||||
xl: '32px',
|
||||
},
|
||||
};
|
||||
|
||||
export default designTokens;
|
||||
|
||||
@@ -12,7 +12,7 @@ export const PAGINATION_CONFIG = {
|
||||
// 显示快速跳转
|
||||
showSizeChanger: true,
|
||||
// 显示总数
|
||||
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条`
|
||||
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条`,
|
||||
};
|
||||
|
||||
// 搜索防抖延迟(毫秒)
|
||||
@@ -21,7 +21,7 @@ export const DEBOUNCE_DELAY = 300;
|
||||
// 表格滚动配置
|
||||
export const TABLE_SCROLL_CONFIG = {
|
||||
x: 'max-content',
|
||||
y: 'calc(100vh - 400px)'
|
||||
y: 'calc(100vh - 400px)',
|
||||
};
|
||||
|
||||
// 默认设备字段配置
|
||||
@@ -32,7 +32,7 @@ export const DEFAULT_DEVICE_FIELDS = [
|
||||
fieldType: 'text',
|
||||
required: true,
|
||||
visible: true,
|
||||
editable: false
|
||||
editable: false,
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
@@ -40,7 +40,7 @@ export const DEFAULT_DEVICE_FIELDS = [
|
||||
fieldType: 'text',
|
||||
required: true,
|
||||
visible: true,
|
||||
editable: true
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
fieldName: 'type',
|
||||
@@ -54,8 +54,8 @@ export const DEFAULT_DEVICE_FIELDS = [
|
||||
{ value: 'switch', label: '交换机' },
|
||||
{ value: 'router', label: '路由器' },
|
||||
{ value: 'storage', label: '存储设备' },
|
||||
{ value: 'other', label: '其他设备' }
|
||||
]
|
||||
{ value: 'other', label: '其他设备' },
|
||||
],
|
||||
},
|
||||
{
|
||||
fieldName: 'model',
|
||||
@@ -63,7 +63,7 @@ export const DEFAULT_DEVICE_FIELDS = [
|
||||
fieldType: 'text',
|
||||
required: false,
|
||||
visible: true,
|
||||
editable: true
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
fieldName: 'serialNumber',
|
||||
@@ -71,7 +71,7 @@ export const DEFAULT_DEVICE_FIELDS = [
|
||||
fieldType: 'text',
|
||||
required: true,
|
||||
visible: true,
|
||||
editable: true
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
fieldName: 'rackId',
|
||||
@@ -79,7 +79,7 @@ export const DEFAULT_DEVICE_FIELDS = [
|
||||
fieldType: 'text',
|
||||
required: true,
|
||||
visible: true,
|
||||
editable: true
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
fieldName: 'position',
|
||||
@@ -87,7 +87,7 @@ export const DEFAULT_DEVICE_FIELDS = [
|
||||
fieldType: 'number',
|
||||
required: true,
|
||||
visible: true,
|
||||
editable: true
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
fieldName: 'height',
|
||||
@@ -95,7 +95,7 @@ export const DEFAULT_DEVICE_FIELDS = [
|
||||
fieldType: 'number',
|
||||
required: true,
|
||||
visible: true,
|
||||
editable: true
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
fieldName: 'powerConsumption',
|
||||
@@ -103,7 +103,7 @@ export const DEFAULT_DEVICE_FIELDS = [
|
||||
fieldType: 'number',
|
||||
required: false,
|
||||
visible: true,
|
||||
editable: true
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
fieldName: 'ipAddress',
|
||||
@@ -111,7 +111,7 @@ export const DEFAULT_DEVICE_FIELDS = [
|
||||
fieldType: 'text',
|
||||
required: false,
|
||||
visible: true,
|
||||
editable: true
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
@@ -124,8 +124,8 @@ export const DEFAULT_DEVICE_FIELDS = [
|
||||
{ value: 'running', label: '运行中' },
|
||||
{ value: 'maintenance', label: '维护中' },
|
||||
{ value: 'offline', label: '离线' },
|
||||
{ value: 'fault', label: '故障' }
|
||||
]
|
||||
{ value: 'fault', label: '故障' },
|
||||
],
|
||||
},
|
||||
{
|
||||
fieldName: 'purchaseDate',
|
||||
@@ -133,7 +133,7 @@ export const DEFAULT_DEVICE_FIELDS = [
|
||||
fieldType: 'date',
|
||||
required: false,
|
||||
visible: true,
|
||||
editable: true
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
fieldName: 'warrantyExpiry',
|
||||
@@ -141,7 +141,7 @@ export const DEFAULT_DEVICE_FIELDS = [
|
||||
fieldType: 'date',
|
||||
required: false,
|
||||
visible: true,
|
||||
editable: true
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
fieldName: 'description',
|
||||
@@ -149,8 +149,8 @@ export const DEFAULT_DEVICE_FIELDS = [
|
||||
fieldType: 'textarea',
|
||||
required: false,
|
||||
visible: true,
|
||||
editable: true
|
||||
}
|
||||
editable: true,
|
||||
},
|
||||
];
|
||||
|
||||
// 基础字段名称列表(用于导入导出时排除自定义字段)
|
||||
@@ -168,7 +168,7 @@ export const BASE_FIELD_NAMES = [
|
||||
'status',
|
||||
'purchaseDate',
|
||||
'warrantyExpiry',
|
||||
'description'
|
||||
'description',
|
||||
];
|
||||
|
||||
// 系统字段列表(不可编辑)
|
||||
@@ -186,7 +186,7 @@ export const IMPORT_CONFIG = {
|
||||
// 单次最大导入条数
|
||||
maxImportCount: 5000,
|
||||
// 编码格式
|
||||
encoding: 'gbk'
|
||||
encoding: 'gbk',
|
||||
};
|
||||
|
||||
// 导出配置
|
||||
@@ -196,7 +196,7 @@ export const EXPORT_CONFIG = {
|
||||
// 日期格式
|
||||
dateFormat: 'YYYY-MM-DD_HH-mm-ss',
|
||||
// 支持的导出格式
|
||||
formats: ['xlsx', 'csv']
|
||||
formats: ['xlsx', 'csv'],
|
||||
};
|
||||
|
||||
// 模态框配置
|
||||
@@ -210,7 +210,7 @@ export const MODAL_CONFIG = {
|
||||
// 导入模态框宽度
|
||||
importModalWidth: 600,
|
||||
// 导出模态框宽度
|
||||
exportModalWidth: 500
|
||||
exportModalWidth: 500,
|
||||
};
|
||||
|
||||
// 统计卡片配置
|
||||
@@ -220,7 +220,7 @@ export const STATS_CONFIG = {
|
||||
// 显示的维护中设备数量上限
|
||||
maxMaintenanceDisplay: 99,
|
||||
// 显示的故障设备数量上限
|
||||
maxFaultDisplay: 99
|
||||
maxFaultDisplay: 99,
|
||||
};
|
||||
|
||||
// 设备类型选项(用于筛选)
|
||||
@@ -230,7 +230,7 @@ export const DEVICE_TYPE_OPTIONS = [
|
||||
{ value: 'switch', label: '交换机' },
|
||||
{ value: 'router', label: '路由器' },
|
||||
{ value: 'storage', label: '存储设备' },
|
||||
{ value: 'other', label: '其他设备' }
|
||||
{ value: 'other', label: '其他设备' },
|
||||
];
|
||||
|
||||
// 设备状态选项(用于筛选)
|
||||
@@ -239,7 +239,7 @@ export const DEVICE_STATUS_OPTIONS = [
|
||||
{ value: 'running', label: '运行中' },
|
||||
{ value: 'maintenance', label: '维护中' },
|
||||
{ value: 'offline', label: '离线' },
|
||||
{ value: 'fault', label: '故障' }
|
||||
{ value: 'fault', label: '故障' },
|
||||
];
|
||||
|
||||
// 表格列宽配置
|
||||
@@ -258,13 +258,13 @@ export const COLUMN_WIDTH_CONFIG = {
|
||||
purchaseDate: 110,
|
||||
warrantyExpiry: 110,
|
||||
description: 200,
|
||||
action: 150
|
||||
action: 150,
|
||||
};
|
||||
|
||||
// 空状态配置
|
||||
export const EMPTY_STATE_CONFIG = {
|
||||
description: '暂无设备数据',
|
||||
image: 'https://gw.alipayobjects.com/zos/antfincdn/ZHrcdLPrvN/empty.svg'
|
||||
image: 'https://gw.alipayobjects.com/zos/antfincdn/ZHrcdLPrvN/empty.svg',
|
||||
};
|
||||
|
||||
// 操作按钮配置
|
||||
@@ -272,7 +272,7 @@ export const ACTION_BUTTON_CONFIG = {
|
||||
// 批量操作阈值(超过此数量显示确认对话框)
|
||||
batchConfirmThreshold: 10,
|
||||
// 批量删除确认消息
|
||||
batchDeleteConfirmMessage: (count) => `确定要删除选中的 ${count} 个设备吗?此操作不可恢复。`,
|
||||
batchDeleteConfirmMessage: count => `确定要删除选中的 ${count} 个设备吗?此操作不可恢复。`,
|
||||
// 单个删除确认消息
|
||||
singleDeleteConfirmMessage: (name) => `确定要删除设备 "${name}" 吗?此操作不可恢复。`
|
||||
singleDeleteConfirmMessage: name => `确定要删除设备 "${name}" 吗?此操作不可恢复。`,
|
||||
};
|
||||
|
||||
@@ -13,13 +13,13 @@ export const useAuth = () => {
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
console.log('[AuthContext] Initializing...');
|
||||
|
||||
|
||||
const savedToken = localStorage.getItem('token');
|
||||
const savedUser = localStorage.getItem('user');
|
||||
|
||||
|
||||
console.log('[AuthContext] Saved token:', savedToken ? 'exists' : 'null');
|
||||
console.log('[AuthContext] Saved user:', savedUser ? 'exists' : 'null');
|
||||
|
||||
|
||||
const [user, setUser] = useState(() => {
|
||||
try {
|
||||
if (savedUser) {
|
||||
@@ -30,7 +30,7 @@ export const AuthProvider = ({ children }) => {
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
const [token, setToken] = useState(() => savedToken);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
@@ -38,7 +38,7 @@ export const AuthProvider = ({ children }) => {
|
||||
useEffect(() => {
|
||||
const currentToken = localStorage.getItem('token');
|
||||
const currentUser = localStorage.getItem('user');
|
||||
|
||||
|
||||
if (currentToken && currentToken === token) {
|
||||
if (currentUser) {
|
||||
try {
|
||||
@@ -97,7 +97,7 @@ export const AuthProvider = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (userData) => {
|
||||
const register = async userData => {
|
||||
try {
|
||||
const response = await authAPI.register(userData);
|
||||
if (response.success) {
|
||||
@@ -123,13 +123,13 @@ export const AuthProvider = ({ children }) => {
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const updateUser = (newUserData) => {
|
||||
const updateUser = newUserData => {
|
||||
const updatedUser = { ...user, ...newUserData };
|
||||
setUser(updatedUser);
|
||||
localStorage.setItem('user', JSON.stringify(updatedUser));
|
||||
};
|
||||
|
||||
const hasPermission = (permission) => {
|
||||
const hasPermission = permission => {
|
||||
if (!user) return false;
|
||||
return true;
|
||||
};
|
||||
@@ -144,14 +144,10 @@ export const AuthProvider = ({ children }) => {
|
||||
logout,
|
||||
updateUser,
|
||||
hasPermission,
|
||||
checkAdmin: () => authAPI.checkAdmin()
|
||||
checkAdmin: () => authAPI.checkAdmin(),
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export default AuthContext;
|
||||
|
||||
@@ -17,7 +17,7 @@ export const ConfigProvider = ({ children }) => {
|
||||
date_format: 'YYYY-MM-DD',
|
||||
session_timeout: 30,
|
||||
max_login_attempts: 5,
|
||||
maintenance_mode: false
|
||||
maintenance_mode: false,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -27,15 +27,15 @@ export const ConfigProvider = ({ children }) => {
|
||||
const response = await axios.get('/api/system-settings');
|
||||
const settings = response.data;
|
||||
const configValues = {};
|
||||
|
||||
|
||||
// 将配置转换为扁平结构
|
||||
Object.entries(settings).forEach(([key, value]) => {
|
||||
configValues[key] = value.value;
|
||||
});
|
||||
|
||||
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
...configValues
|
||||
...configValues,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('加载系统配置失败:', error);
|
||||
@@ -50,10 +50,10 @@ export const ConfigProvider = ({ children }) => {
|
||||
}, []);
|
||||
|
||||
// 更新配置
|
||||
const updateConfig = (newConfig) => {
|
||||
const updateConfig = newConfig => {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
...newConfig
|
||||
...newConfig,
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@ export const Scene3DProvider = ({ children }) => {
|
||||
const [loadingDevices, setLoadingDevices] = useState(false);
|
||||
|
||||
// 使用 useCallback 稳定回调函数
|
||||
const selectDevice = useCallback((device) => {
|
||||
const selectDevice = useCallback(device => {
|
||||
setSelectedDevice(device);
|
||||
}, []);
|
||||
|
||||
const hoverDevice = useCallback((device) => {
|
||||
const hoverDevice = useCallback(device => {
|
||||
setHoveredDevice(device);
|
||||
}, []);
|
||||
|
||||
@@ -29,72 +29,84 @@ export const Scene3DProvider = ({ children }) => {
|
||||
setDeviceSlideEnabled(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const setDeviceSlide = useCallback((enabled) => {
|
||||
const setDeviceSlide = useCallback(enabled => {
|
||||
setDeviceSlideEnabled(enabled);
|
||||
}, []);
|
||||
|
||||
const updateDevices = useCallback((newDevices) => {
|
||||
const updateDevices = useCallback(newDevices => {
|
||||
setDevices(newDevices);
|
||||
}, []);
|
||||
|
||||
const updateRacks = useCallback((newRacks) => {
|
||||
const updateRacks = useCallback(newRacks => {
|
||||
setRacks(newRacks);
|
||||
}, []);
|
||||
|
||||
const selectRack = useCallback((rack) => {
|
||||
const selectRack = useCallback(rack => {
|
||||
setSelectedRack(rack);
|
||||
}, []);
|
||||
|
||||
const updateDeviceCables = useCallback((cables) => {
|
||||
const updateDeviceCables = useCallback(cables => {
|
||||
setDeviceCables(cables);
|
||||
}, []);
|
||||
|
||||
const setLoading = useCallback((loading) => {
|
||||
const setLoading = useCallback(loading => {
|
||||
setLoadingDevices(loading);
|
||||
}, []);
|
||||
|
||||
// 使用 useMemo 缓存 context value,避免不必要的重渲染
|
||||
const value = useMemo(() => ({
|
||||
// 状态
|
||||
devices,
|
||||
selectedDevice,
|
||||
hoveredDevice,
|
||||
deviceSlideEnabled,
|
||||
selectedRack,
|
||||
racks,
|
||||
deviceCables,
|
||||
loadingDevices,
|
||||
// 方法
|
||||
selectDevice,
|
||||
hoverDevice,
|
||||
toggleDeviceSlide,
|
||||
setDeviceSlide,
|
||||
updateDevices,
|
||||
updateRacks,
|
||||
selectRack,
|
||||
updateDeviceCables,
|
||||
setLoading,
|
||||
// 直接设置状态的方法(用于兼容现有代码)
|
||||
setDevices,
|
||||
setSelectedDevice,
|
||||
setHoveredDevice,
|
||||
setDeviceSlideEnabled,
|
||||
setSelectedRack,
|
||||
setRacks,
|
||||
setDeviceCables,
|
||||
setLoadingDevices,
|
||||
}), [
|
||||
devices, selectedDevice, hoveredDevice, deviceSlideEnabled,
|
||||
selectedRack, racks, deviceCables, loadingDevices,
|
||||
selectDevice, hoverDevice, toggleDeviceSlide, setDeviceSlide,
|
||||
updateDevices, updateRacks, selectRack, updateDeviceCables, setLoading
|
||||
]);
|
||||
|
||||
return (
|
||||
<Scene3DContext.Provider value={value}>
|
||||
{children}
|
||||
</Scene3DContext.Provider>
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
// 状态
|
||||
devices,
|
||||
selectedDevice,
|
||||
hoveredDevice,
|
||||
deviceSlideEnabled,
|
||||
selectedRack,
|
||||
racks,
|
||||
deviceCables,
|
||||
loadingDevices,
|
||||
// 方法
|
||||
selectDevice,
|
||||
hoverDevice,
|
||||
toggleDeviceSlide,
|
||||
setDeviceSlide,
|
||||
updateDevices,
|
||||
updateRacks,
|
||||
selectRack,
|
||||
updateDeviceCables,
|
||||
setLoading,
|
||||
// 直接设置状态的方法(用于兼容现有代码)
|
||||
setDevices,
|
||||
setSelectedDevice,
|
||||
setHoveredDevice,
|
||||
setDeviceSlideEnabled,
|
||||
setSelectedRack,
|
||||
setRacks,
|
||||
setDeviceCables,
|
||||
setLoadingDevices,
|
||||
}),
|
||||
[
|
||||
devices,
|
||||
selectedDevice,
|
||||
hoveredDevice,
|
||||
deviceSlideEnabled,
|
||||
selectedRack,
|
||||
racks,
|
||||
deviceCables,
|
||||
loadingDevices,
|
||||
selectDevice,
|
||||
hoverDevice,
|
||||
toggleDeviceSlide,
|
||||
setDeviceSlide,
|
||||
updateDevices,
|
||||
updateRacks,
|
||||
selectRack,
|
||||
updateDeviceCables,
|
||||
setLoading,
|
||||
]
|
||||
);
|
||||
|
||||
return <Scene3DContext.Provider value={value}>{children}</Scene3DContext.Provider>;
|
||||
};
|
||||
|
||||
// 自定义 Hook
|
||||
|
||||
@@ -18,7 +18,7 @@ export const useDesignTokens = () => {
|
||||
primary: {
|
||||
main: primaryColor,
|
||||
gradient: `linear-gradient(135deg, ${primaryColor} 0%, ${secondaryColor} 100%)`,
|
||||
light: '#8b9ff0'
|
||||
light: '#8b9ff0',
|
||||
},
|
||||
success: { main: '#10b981' },
|
||||
warning: { main: '#f59e0b' },
|
||||
@@ -26,15 +26,15 @@ export const useDesignTokens = () => {
|
||||
text: {
|
||||
primary: '#1e293b',
|
||||
secondary: '#64748b',
|
||||
inverse: '#ffffff'
|
||||
inverse: '#ffffff',
|
||||
},
|
||||
background: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#f8fafc',
|
||||
dark: '#1e293b'
|
||||
dark: '#1e293b',
|
||||
},
|
||||
border: {
|
||||
light: '#e2e8f0'
|
||||
light: '#e2e8f0',
|
||||
},
|
||||
sidebar: {
|
||||
bg: '#ffffff',
|
||||
@@ -43,23 +43,23 @@ export const useDesignTokens = () => {
|
||||
text: '#475569',
|
||||
textHover: primaryColor,
|
||||
textActive: primaryColor,
|
||||
border: '#e2e8f0'
|
||||
}
|
||||
border: '#e2e8f0',
|
||||
},
|
||||
},
|
||||
shadows: {
|
||||
small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
|
||||
large: '0 10px 15px -3px rgba(0, 0, 0, 0.1)'
|
||||
large: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
borderRadius: {
|
||||
small: '6px',
|
||||
medium: '10px'
|
||||
medium: '10px',
|
||||
},
|
||||
spacing: {
|
||||
sm: '8px',
|
||||
md: '16px',
|
||||
lg: '24px'
|
||||
}
|
||||
lg: '24px',
|
||||
},
|
||||
};
|
||||
}, [config?.primary_color, config?.secondary_color]);
|
||||
|
||||
@@ -74,16 +74,20 @@ export const useDesignTokens = () => {
|
||||
function hexToRgb(hex) {
|
||||
// 移除 # 号
|
||||
const cleanHex = hex.replace('#', '');
|
||||
|
||||
|
||||
// 处理简写格式 (如: #fff)
|
||||
const fullHex = cleanHex.length === 3
|
||||
? cleanHex.split('').map(c => c + c).join('')
|
||||
: cleanHex;
|
||||
|
||||
const fullHex =
|
||||
cleanHex.length === 3
|
||||
? cleanHex
|
||||
.split('')
|
||||
.map(c => c + c)
|
||||
.join('')
|
||||
: cleanHex;
|
||||
|
||||
const r = parseInt(fullHex.substring(0, 2), 16);
|
||||
const g = parseInt(fullHex.substring(2, 4), 16);
|
||||
const b = parseInt(fullHex.substring(4, 6), 16);
|
||||
|
||||
|
||||
return `${r}, ${g}, ${b}`;
|
||||
}
|
||||
|
||||
|
||||
+14
-8
@@ -5,9 +5,9 @@
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell',
|
||||
'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f5f7fa;
|
||||
@@ -149,19 +149,24 @@ body {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ant-input, .ant-select-selector, .ant-picker {
|
||||
.ant-input,
|
||||
.ant-select-selector,
|
||||
.ant-picker {
|
||||
border-radius: 8px !important;
|
||||
border: 1px solid #d9d9d9 !important;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05) !important;
|
||||
}
|
||||
|
||||
.ant-input:hover, .ant-select-selector:hover, .ant-picker:hover {
|
||||
.ant-input:hover,
|
||||
.ant-select-selector:hover,
|
||||
.ant-picker:hover {
|
||||
border-color: #40a9ff !important;
|
||||
box-shadow: 0 2px 6px rgba(24, 144, 255, 0.15) !important;
|
||||
}
|
||||
|
||||
.ant-input:focus, .ant-input-focused,
|
||||
.ant-input:focus,
|
||||
.ant-input-focused,
|
||||
.ant-select-focused .ant-select-selector,
|
||||
.ant-picker-focused {
|
||||
border-color: #1890ff !important;
|
||||
@@ -188,7 +193,8 @@ body {
|
||||
box-shadow: 0 2px 6px rgba(24, 144, 255, 0.15) !important;
|
||||
}
|
||||
|
||||
.ant-input-affix-wrapper:focus, .ant-input-affix-wrapper-focused {
|
||||
.ant-input-affix-wrapper:focus,
|
||||
.ant-input-affix-wrapper-focused {
|
||||
border-color: #1890ff !important;
|
||||
box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.15) !important;
|
||||
}
|
||||
@@ -881,4 +887,4 @@ body {
|
||||
.welcome-banner p {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,4 +10,4 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Select, message, Card, Space, Popconfirm, Tag, Tooltip, Collapse, Empty, Spin, Upload, Progress, Checkbox } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined, ExportOutlined, ImportOutlined, DownloadOutlined, UploadOutlined as UploadIcon } from '@ant-design/icons';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
message,
|
||||
Card,
|
||||
Space,
|
||||
Popconfirm,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Collapse,
|
||||
Empty,
|
||||
Spin,
|
||||
Upload,
|
||||
Progress,
|
||||
Checkbox,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
ExportOutlined,
|
||||
ImportOutlined,
|
||||
DownloadOutlined,
|
||||
UploadOutlined as UploadIcon,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import * as XLSX from 'xlsx';
|
||||
import Papa from 'papaparse';
|
||||
@@ -14,35 +43,35 @@ const designTokens = {
|
||||
main: '#667eea',
|
||||
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
light: '#8b9ff0',
|
||||
dark: '#4f5db8'
|
||||
dark: '#4f5db8',
|
||||
},
|
||||
success: {
|
||||
main: '#10b981',
|
||||
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
light: '#34d399',
|
||||
dark: '#047857'
|
||||
dark: '#047857',
|
||||
},
|
||||
warning: {
|
||||
main: '#f59e0b',
|
||||
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
light: '#fbbf24',
|
||||
dark: '#b45309'
|
||||
dark: '#b45309',
|
||||
},
|
||||
error: {
|
||||
main: '#ef4444',
|
||||
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
|
||||
light: '#f87171',
|
||||
dark: '#b91c1c'
|
||||
}
|
||||
dark: '#b91c1c',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
small: '6px',
|
||||
medium: '10px',
|
||||
large: '16px'
|
||||
large: '16px',
|
||||
},
|
||||
shadows: {
|
||||
medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)'
|
||||
}
|
||||
medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
};
|
||||
|
||||
function CableManagement() {
|
||||
@@ -55,12 +84,12 @@ function CableManagement() {
|
||||
const [filters, setFilters] = useState({
|
||||
switchDeviceId: '',
|
||||
status: 'all',
|
||||
cableType: 'all'
|
||||
cableType: 'all',
|
||||
});
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingCable, setEditingCable] = useState(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
|
||||
const [importModalVisible, setImportModalVisible] = useState(false);
|
||||
const [importFileList, setImportFileList] = useState([]);
|
||||
const [importPreview, setImportPreview] = useState([]);
|
||||
@@ -88,7 +117,7 @@ function CableManagement() {
|
||||
if (!grouped[switchId]) {
|
||||
grouped[switchId] = {
|
||||
switch: cable.sourceDevice,
|
||||
cables: []
|
||||
cables: [],
|
||||
};
|
||||
}
|
||||
grouped[switchId].cables.push(cable);
|
||||
@@ -128,12 +157,12 @@ function CableManagement() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchDevicePorts = useCallback(async (deviceId) => {
|
||||
const fetchDevicePorts = useCallback(async deviceId => {
|
||||
if (!deviceId) {
|
||||
setDevicePorts(prev => ({ ...prev, [deviceId]: [] }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/api/device-ports/device/${deviceId}`);
|
||||
setDevicePorts(prev => ({ ...prev, [deviceId]: response.data || [] }));
|
||||
@@ -156,7 +185,7 @@ function CableManagement() {
|
||||
setFilters({
|
||||
switchDeviceId: '',
|
||||
status: 'all',
|
||||
cableType: 'all'
|
||||
cableType: 'all',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -166,7 +195,7 @@ function CableManagement() {
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (cable) => {
|
||||
const handleEdit = cable => {
|
||||
setEditingCable(cable);
|
||||
form.setFieldsValue({
|
||||
sourceDeviceId: cable.sourceDeviceId,
|
||||
@@ -176,12 +205,12 @@ function CableManagement() {
|
||||
cableType: cable.cableType,
|
||||
cableLength: cable.cableLength,
|
||||
status: cable.status,
|
||||
description: cable.description
|
||||
description: cable.description,
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (cableId) => {
|
||||
const handleDelete = async cableId => {
|
||||
try {
|
||||
await axios.delete(`/api/cables/${cableId}`);
|
||||
message.success('删除成功');
|
||||
@@ -192,7 +221,7 @@ function CableManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSwitch = async (switchId) => {
|
||||
const handleDeleteSwitch = async switchId => {
|
||||
try {
|
||||
await axios.delete(`/api/devices/${switchId}`);
|
||||
message.success('删除设备成功');
|
||||
@@ -228,7 +257,7 @@ function CableManagement() {
|
||||
sourceDeviceId: values.sourceDeviceId,
|
||||
sourcePort: values.sourcePort,
|
||||
targetDeviceId: values.targetDeviceId,
|
||||
targetPort: values.targetPort
|
||||
targetPort: values.targetPort,
|
||||
});
|
||||
|
||||
if (checkResponse.data.hasConflict) {
|
||||
@@ -247,10 +276,12 @@ function CableManagement() {
|
||||
} catch (error) {
|
||||
if (error.response?.status === 409) {
|
||||
// 冲突错误
|
||||
setConflictInfo([{
|
||||
type: 'unknown',
|
||||
existingCable: error.response.data.existingCable
|
||||
}]);
|
||||
setConflictInfo([
|
||||
{
|
||||
type: 'unknown',
|
||||
existingCable: error.response.data.existingCable,
|
||||
},
|
||||
]);
|
||||
setPendingSubmitValues(values);
|
||||
setConflictModalVisible(true);
|
||||
} else {
|
||||
@@ -269,7 +300,7 @@ function CableManagement() {
|
||||
|
||||
await axios.post('/api/cables', {
|
||||
...pendingSubmitValues,
|
||||
force: true
|
||||
force: true,
|
||||
});
|
||||
|
||||
message.success('接线已强制接管并创建成功');
|
||||
@@ -291,16 +322,16 @@ function CableManagement() {
|
||||
setImportProgress({ current: 0, total: 0 });
|
||||
};
|
||||
|
||||
const handleFileUpload = (info) => {
|
||||
const handleFileUpload = info => {
|
||||
const { file } = info;
|
||||
setImportFileList([file]);
|
||||
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
reader.onload = async e => {
|
||||
try {
|
||||
const data = e.target.result;
|
||||
let parsedData = [];
|
||||
|
||||
|
||||
if (file.name.endsWith('.xlsx') || file.name.endsWith('.xls')) {
|
||||
const workbook = XLSX.read(data, { type: 'binary' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
@@ -310,15 +341,15 @@ function CableManagement() {
|
||||
Papa.parse(data, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: (results) => {
|
||||
complete: results => {
|
||||
parsedData = results.data;
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
message.error('不支持的文件格式,请上传 .xlsx 或 .csv 文件');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const validatedData = await validateImportData(parsedData);
|
||||
setImportPreview(validatedData);
|
||||
setImportProgress({ current: 0, total: validatedData.length });
|
||||
@@ -327,64 +358,64 @@ function CableManagement() {
|
||||
console.error('文件解析失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
reader.readAsBinaryString(file);
|
||||
};
|
||||
|
||||
const validateImportData = async (data) => {
|
||||
const validateImportData = async data => {
|
||||
const validatedData = [];
|
||||
const errors = [];
|
||||
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const row = data[i];
|
||||
const error = await validateCableRow(row, i);
|
||||
|
||||
|
||||
if (error) {
|
||||
errors.push(error);
|
||||
} else {
|
||||
validatedData.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (errors.length > 0) {
|
||||
message.warning(`发现 ${errors.length} 条数据错误,已跳过`);
|
||||
console.log('导入错误:', errors);
|
||||
}
|
||||
|
||||
|
||||
return validatedData;
|
||||
};
|
||||
|
||||
const validateCableRow = async (row, index) => {
|
||||
const errors = [];
|
||||
|
||||
|
||||
if (!row['源设备ID'] || !row['源设备端口']) {
|
||||
return { valid: false, error: `第 ${index + 1} 行:缺少必填字段(源设备ID或源设备端口)` };
|
||||
}
|
||||
|
||||
|
||||
const sourceDevice = devices.find(d => d.deviceId === row['源设备ID']);
|
||||
if (!sourceDevice) {
|
||||
return { valid: false, error: `第 ${index + 1} 行:源设备不存在` };
|
||||
}
|
||||
|
||||
|
||||
const targetDevice = devices.find(d => d.deviceId === row['目标设备ID']);
|
||||
if (!targetDevice) {
|
||||
return { valid: false, error: `第 ${index + 1} 行:目标设备不存在` };
|
||||
}
|
||||
|
||||
|
||||
const validCableTypes = ['网线', '光纤', '铜缆'];
|
||||
if (!validCableTypes.includes(row['线缆类型'])) {
|
||||
return { valid: false, error: `第 ${index + 1} 行:无效的线缆类型` };
|
||||
}
|
||||
|
||||
|
||||
const validStatuses = ['正常', '故障', '未连接'];
|
||||
if (!validStatuses.includes(row['状态'])) {
|
||||
return { valid: false, error: `第 ${index + 1} 行:无效的状态` };
|
||||
}
|
||||
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { valid: false, error: errors.join('; ') };
|
||||
}
|
||||
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
@@ -393,23 +424,23 @@ function CableManagement() {
|
||||
message.warning('请先选择要导入的数据');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setImporting(true);
|
||||
setImportProgress({ current: 0, total: importPreview.length });
|
||||
|
||||
|
||||
try {
|
||||
const cableTypeMap = {
|
||||
'网线': 'ethernet',
|
||||
'光纤': 'fiber',
|
||||
'铜缆': 'copper'
|
||||
网线: 'ethernet',
|
||||
光纤: 'fiber',
|
||||
铜缆: 'copper',
|
||||
};
|
||||
|
||||
|
||||
const statusMap = {
|
||||
'正常': 'normal',
|
||||
'故障': 'fault',
|
||||
'未连接': 'disconnected'
|
||||
正常: 'normal',
|
||||
故障: 'fault',
|
||||
未连接: 'disconnected',
|
||||
};
|
||||
|
||||
|
||||
const cablesData = importPreview.map((row, index) => ({
|
||||
cableId: `CABLE-${Date.now()}-${index}`,
|
||||
sourceDeviceId: row['源设备ID'],
|
||||
@@ -419,22 +450,22 @@ function CableManagement() {
|
||||
cableType: cableTypeMap[row['线缆类型']] || 'ethernet',
|
||||
cableLength: row['线缆长度(米)'],
|
||||
status: statusMap[row['状态']] || 'normal',
|
||||
description: row['描述']
|
||||
description: row['描述'],
|
||||
}));
|
||||
|
||||
|
||||
const response = await axios.post('/api/cables/batch', { cables: cablesData });
|
||||
|
||||
|
||||
const { total, success, failed, errors } = response.data;
|
||||
|
||||
|
||||
setImportProgress({ current: total, total: total });
|
||||
|
||||
|
||||
if (failed > 0) {
|
||||
console.error('导入错误:', errors);
|
||||
message.warning(`导入完成!成功 ${success} 条,失败 ${failed} 条`);
|
||||
} else {
|
||||
message.success(`导入完成!成功 ${success} 条`);
|
||||
}
|
||||
|
||||
|
||||
fetchCables();
|
||||
setImportModalVisible(false);
|
||||
setImportPreview([]);
|
||||
@@ -449,38 +480,38 @@ function CableManagement() {
|
||||
const handleDownloadTemplate = () => {
|
||||
const templateData = [
|
||||
{
|
||||
'源设备ID': 'DEV001',
|
||||
'源设备端口': 'eth0/1',
|
||||
'目标设备ID': 'DEV002',
|
||||
'目标设备端口': 'eth0',
|
||||
'线缆类型': '网线',
|
||||
源设备ID: 'DEV001',
|
||||
源设备端口: 'eth0/1',
|
||||
目标设备ID: 'DEV002',
|
||||
目标设备端口: 'eth0',
|
||||
线缆类型: '网线',
|
||||
'线缆长度(米)': '5',
|
||||
'状态': '正常',
|
||||
'描述': '示例接线'
|
||||
}
|
||||
状态: '正常',
|
||||
描述: '示例接线',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
const worksheet = XLSX.utils.json_to_sheet(templateData);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, '接线数据');
|
||||
XLSX.writeFile(workbook, '接线导入模板.xlsx');
|
||||
};
|
||||
|
||||
const getStatusTag = (status) => {
|
||||
const getStatusTag = status => {
|
||||
const statusMap = {
|
||||
normal: { color: 'success', text: '正常' },
|
||||
fault: { color: 'error', text: '故障' },
|
||||
disconnected: { color: 'default', text: '未连接' }
|
||||
disconnected: { color: 'default', text: '未连接' },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const getCableTypeTag = (type) => {
|
||||
const getCableTypeTag = type => {
|
||||
const typeMap = {
|
||||
'网线': { color: 'blue', text: '网线' },
|
||||
'光纤': { color: 'green', text: '光纤' },
|
||||
'铜缆': { color: 'orange', text: '铜缆' }
|
||||
网线: { color: 'blue', text: '网线' },
|
||||
光纤: { color: 'green', text: '光纤' },
|
||||
铜缆: { color: 'orange', text: '铜缆' },
|
||||
};
|
||||
const config = typeMap[type] || { color: 'default', text: type };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
@@ -494,7 +525,7 @@ function CableManagement() {
|
||||
return {
|
||||
status: cable.status,
|
||||
text: cable.status === 'normal' ? '已连接' : cable.status === 'fault' ? '故障' : '未连接',
|
||||
color: cable.status === 'normal' ? 'success' : cable.status === 'fault' ? 'error' : 'default'
|
||||
color: cable.status === 'normal' ? 'success' : cable.status === 'fault' ? 'error' : 'default',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -503,31 +534,31 @@ function CableManagement() {
|
||||
title: '端口名称',
|
||||
dataIndex: 'portName',
|
||||
key: 'portName',
|
||||
width: 120
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '端口类型',
|
||||
dataIndex: 'portType',
|
||||
key: 'portType',
|
||||
width: 100,
|
||||
render: (type) => {
|
||||
render: type => {
|
||||
const typeMap = {
|
||||
'RJ45': { color: 'blue', text: 'RJ45' },
|
||||
'SFP': { color: 'green', text: 'SFP' },
|
||||
RJ45: { color: 'blue', text: 'RJ45' },
|
||||
SFP: { color: 'green', text: 'SFP' },
|
||||
'SFP+': { color: 'cyan', text: 'SFP+' },
|
||||
'SFP28': { color: 'purple', text: 'SFP28' },
|
||||
'QSFP': { color: 'orange', text: 'QSFP' },
|
||||
'QSFP28': { color: 'red', text: 'QSFP28' }
|
||||
SFP28: { color: 'purple', text: 'SFP28' },
|
||||
QSFP: { color: 'orange', text: 'QSFP' },
|
||||
QSFP28: { color: 'red', text: 'QSFP28' },
|
||||
};
|
||||
const config = typeMap[type] || { color: 'default', text: type };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '端口速率',
|
||||
dataIndex: 'portSpeed',
|
||||
key: 'portSpeed',
|
||||
width: 100
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '连接状态',
|
||||
@@ -537,7 +568,7 @@ function CableManagement() {
|
||||
render: (_, record) => {
|
||||
const status = getPortConnectionStatus(record.portName, record.switchData);
|
||||
return <Tag color={status.color}>{status.text}</Tag>;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '目标设备',
|
||||
@@ -553,7 +584,7 @@ function CableManagement() {
|
||||
<div style={{ fontSize: 12, color: '#999' }}>{cable.targetPort}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '线缆类型',
|
||||
@@ -564,7 +595,7 @@ function CableManagement() {
|
||||
const cable = record.switchData.cables.find(c => c.sourcePort === record.portName);
|
||||
if (!cable) return '-';
|
||||
return getCableTypeTag(cable.cableType);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '长度(米)',
|
||||
@@ -575,7 +606,7 @@ function CableManagement() {
|
||||
const cable = record.switchData.cables.find(c => c.sourcePort === record.portName);
|
||||
if (!cable) return '-';
|
||||
return cable.cableLength ? `${cable.cableLength}m` : '-';
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -602,12 +633,7 @@ function CableManagement() {
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
@@ -615,17 +641,17 @@ function CableManagement() {
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', background: '#f5f5f5', minHeight: '100vh' }}>
|
||||
<Card
|
||||
style={{
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: designTokens.borderRadius.large,
|
||||
boxShadow: designTokens.shadows.medium,
|
||||
marginBottom: 16
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
@@ -634,7 +660,7 @@ function CableManagement() {
|
||||
placeholder="选择交换机"
|
||||
style={{ width: 200 }}
|
||||
value={filters.switchDeviceId || undefined}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, switchDeviceId: value }))}
|
||||
onChange={value => setFilters(prev => ({ ...prev, switchDeviceId: value }))}
|
||||
allowClear
|
||||
showSearch
|
||||
filterOption={(input, option) => {
|
||||
@@ -655,67 +681,65 @@ function CableManagement() {
|
||||
placeholder="线缆类型"
|
||||
style={{ width: 120 }}
|
||||
value={filters.cableType}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, cableType: value }))}
|
||||
onChange={value => setFilters(prev => ({ ...prev, cableType: value }))}
|
||||
>
|
||||
<Option value="all">全部</Option>
|
||||
<Option value="ethernet">网线</Option>
|
||||
<Option value="fiber">光纤</Option>
|
||||
<Option value="copper">铜缆</Option>
|
||||
</Select>
|
||||
|
||||
|
||||
<Select
|
||||
placeholder="连接状态"
|
||||
style={{ width: 120 }}
|
||||
value={filters.status}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
|
||||
onChange={value => setFilters(prev => ({ ...prev, status: value }))}
|
||||
>
|
||||
<Option value="all">全部</Option>
|
||||
<Option value="normal">已连接</Option>
|
||||
<Option value="fault">故障</Option>
|
||||
<Option value="disconnected">未连接</Option>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SearchOutlined />}
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={handleSearch}
|
||||
style={{ background: designTokens.colors.primary.gradient, border: 'none' }}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
|
||||
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAdd}
|
||||
style={{ background: designTokens.colors.primary.gradient, border: 'none' }}
|
||||
>
|
||||
新增接线
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ImportOutlined />}
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ImportOutlined />}
|
||||
onClick={handleImport}
|
||||
style={{ background: designTokens.colors.primary.gradient, border: 'none' }}
|
||||
>
|
||||
批量导入
|
||||
</Button>
|
||||
|
||||
<Button icon={<ExportOutlined />}>
|
||||
导出
|
||||
</Button>
|
||||
|
||||
<Button icon={<ExportOutlined />}>导出</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Spin size="large" tip="加载接线数据中..." />
|
||||
@@ -733,26 +757,37 @@ function CableManagement() {
|
||||
const switchDevice = switchData.switch;
|
||||
const switchPorts = devicePorts[switchId] || [];
|
||||
const connectedCount = switchData.cables.filter(c => c.status === 'normal').length;
|
||||
const disconnectedCount = switchData.cables.filter(c => c.status === 'disconnected').length;
|
||||
const disconnectedCount = switchData.cables.filter(
|
||||
c => c.status === 'disconnected'
|
||||
).length;
|
||||
const faultCount = switchData.cables.filter(c => c.status === 'fault').length;
|
||||
|
||||
|
||||
return (
|
||||
<Panel
|
||||
key={switchId}
|
||||
header={
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
background: designTokens.colors.primary.gradient,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: '18px'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
background: designTokens.colors.primary.gradient,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: '18px',
|
||||
}}
|
||||
>
|
||||
🔀
|
||||
</div>
|
||||
<div>
|
||||
@@ -792,12 +827,7 @@ function CableManagement() {
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除设备
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
@@ -808,7 +838,7 @@ function CableManagement() {
|
||||
columns={portColumns}
|
||||
dataSource={switchPorts.map(port => ({
|
||||
...port,
|
||||
switchData: switchData
|
||||
switchData: switchData,
|
||||
}))}
|
||||
rowKey="portId"
|
||||
pagination={false}
|
||||
@@ -821,7 +851,7 @@ function CableManagement() {
|
||||
</Collapse>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
|
||||
<Modal
|
||||
title={editingCable ? '编辑接线' : '新增接线'}
|
||||
open={modalVisible}
|
||||
@@ -849,7 +879,7 @@ function CableManagement() {
|
||||
const searchText = `${device.name} ${device.deviceId}`.toLowerCase();
|
||||
return searchText.indexOf(input.toLowerCase()) >= 0;
|
||||
}}
|
||||
onChange={(value) => {
|
||||
onChange={value => {
|
||||
fetchDevicePorts(value);
|
||||
form.setFieldsValue({ sourcePort: undefined });
|
||||
}}
|
||||
@@ -874,7 +904,8 @@ function CableManagement() {
|
||||
const ports = devicePorts[form.getFieldValue('sourceDeviceId')] || [];
|
||||
const port = ports.find(p => p.portName === option.value);
|
||||
if (!port) return false;
|
||||
const searchText = `${port.portName} ${port.portType} ${port.portSpeed}`.toLowerCase();
|
||||
const searchText =
|
||||
`${port.portName} ${port.portType} ${port.portSpeed}`.toLowerCase();
|
||||
return searchText.indexOf(input.toLowerCase()) >= 0;
|
||||
}}
|
||||
disabled={!form.getFieldValue('sourceDeviceId')}
|
||||
@@ -901,7 +932,7 @@ function CableManagement() {
|
||||
const searchText = `${device.name} ${device.deviceId}`.toLowerCase();
|
||||
return searchText.indexOf(input.toLowerCase()) >= 0;
|
||||
}}
|
||||
onChange={(value) => {
|
||||
onChange={value => {
|
||||
fetchDevicePorts(value);
|
||||
form.setFieldsValue({ targetPort: undefined });
|
||||
}}
|
||||
@@ -926,7 +957,8 @@ function CableManagement() {
|
||||
const ports = devicePorts[form.getFieldValue('targetDeviceId')] || [];
|
||||
const port = ports.find(p => p.portName === option.value);
|
||||
if (!port) return false;
|
||||
const searchText = `${port.portName} ${port.portType} ${port.portSpeed}`.toLowerCase();
|
||||
const searchText =
|
||||
`${port.portName} ${port.portType} ${port.portSpeed}`.toLowerCase();
|
||||
return searchText.indexOf(input.toLowerCase()) >= 0;
|
||||
}}
|
||||
disabled={!form.getFieldValue('targetDeviceId')}
|
||||
@@ -951,14 +983,11 @@ function CableManagement() {
|
||||
<Option value="copper">铜缆</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="cableLength"
|
||||
label="线缆长度(米)"
|
||||
>
|
||||
|
||||
<Form.Item name="cableLength" label="线缆长度(米)">
|
||||
<Input type="number" placeholder="请输入线缆长度" />
|
||||
</Form.Item>
|
||||
|
||||
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
@@ -971,16 +1000,13 @@ function CableManagement() {
|
||||
<Option value="disconnected">未连接</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述"
|
||||
>
|
||||
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={3} placeholder="请输入描述" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
|
||||
<Modal
|
||||
title="批量导入接线"
|
||||
open={importModalVisible}
|
||||
@@ -994,24 +1020,20 @@ function CableManagement() {
|
||||
<Button key="cancel" onClick={() => setImportModalVisible(false)}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="download"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleDownloadTemplate}
|
||||
>
|
||||
<Button key="download" icon={<DownloadOutlined />} onClick={handleDownloadTemplate}>
|
||||
下载模板
|
||||
</Button>,
|
||||
<Button
|
||||
key="import"
|
||||
type="primary"
|
||||
icon={<ImportOutlined />}
|
||||
<Button
|
||||
key="import"
|
||||
type="primary"
|
||||
icon={<ImportOutlined />}
|
||||
onClick={handleBatchImport}
|
||||
loading={importing}
|
||||
disabled={importPreview.length === 0}
|
||||
style={{ background: designTokens.colors.primary.gradient, border: 'none' }}
|
||||
>
|
||||
开始导入
|
||||
</Button>
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
@@ -1031,26 +1053,29 @@ function CableManagement() {
|
||||
<p className="ant-upload-hint">支持 .xlsx, .xls, .csv 格式</p>
|
||||
</Upload.Dragger>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginBottom: 16 }}>
|
||||
<Checkbox checked={skipExisting} onChange={(e) => setSkipExisting(e.target.checked)}>
|
||||
<Checkbox checked={skipExisting} onChange={e => setSkipExisting(e.target.checked)}>
|
||||
跳过已存在的接线
|
||||
</Checkbox>
|
||||
<Checkbox checked={updateExisting} onChange={(e) => setUpdateExisting(e.target.checked)}>
|
||||
<Checkbox checked={updateExisting} onChange={e => setUpdateExisting(e.target.checked)}>
|
||||
更新已存在的接线
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
|
||||
{importPreview.length > 0 && (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<Text strong>数据预览(前10条)</Text>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleDownloadTemplate}
|
||||
>
|
||||
<Button size="small" icon={<DownloadOutlined />} onClick={handleDownloadTemplate}>
|
||||
下载模板
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1060,46 +1085,46 @@ function CableManagement() {
|
||||
title: '源设备ID',
|
||||
dataIndex: '源设备ID',
|
||||
key: 'sourceDeviceId',
|
||||
width: 150
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '源设备端口',
|
||||
dataIndex: '源设备端口',
|
||||
key: 'sourcePort',
|
||||
width: 120
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '目标设备ID',
|
||||
dataIndex: '目标设备ID',
|
||||
key: 'targetDeviceId',
|
||||
width: 150
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '目标设备端口',
|
||||
dataIndex: '目标设备端口',
|
||||
key: 'targetPort',
|
||||
width: 120
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '线缆类型',
|
||||
dataIndex: '线缆类型',
|
||||
key: 'cableType',
|
||||
width: 100,
|
||||
render: (type) => getCableTypeTag(type)
|
||||
render: type => getCableTypeTag(type),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status) => getStatusTag(status)
|
||||
render: status => getStatusTag(status),
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: '描述',
|
||||
key: 'description',
|
||||
ellipsis: true
|
||||
}
|
||||
ellipsis: true,
|
||||
},
|
||||
]}
|
||||
dataSource={importPreview.slice(0, 10)}
|
||||
rowKey={(record, index) => index}
|
||||
@@ -1108,7 +1133,7 @@ function CableManagement() {
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{importPreview.length > 10 && (
|
||||
<div style={{ textAlign: 'center', marginTop: 8 }}>
|
||||
<Text type="secondary">仅显示前10条数据,共 {importPreview.length} 条</Text>
|
||||
@@ -1116,17 +1141,17 @@ function CableManagement() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{importing && (
|
||||
<div style={{ textAlign: 'center', padding: '24px' }}>
|
||||
<Spin size="large" tip="导入中..." />
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Progress
|
||||
percent={Math.round((importProgress.current / importProgress.total) * 100)}
|
||||
<Progress
|
||||
percent={Math.round((importProgress.current / importProgress.total) * 100)}
|
||||
status="active"
|
||||
strokeColor={{
|
||||
'0%': designTokens.colors.primary.main,
|
||||
'100%': designTokens.colors.success.main
|
||||
'100%': designTokens.colors.success.main,
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
@@ -1135,7 +1160,8 @@ function CableManagement() {
|
||||
</Text>
|
||||
{importProgress.current > 0 && (
|
||||
<Text type="secondary">
|
||||
预计剩余时间:{Math.ceil((importProgress.total - importProgress.current) / 5)} 秒
|
||||
预计剩余时间:{Math.ceil((importProgress.total - importProgress.current) / 5)}{' '}
|
||||
秒
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
@@ -1165,14 +1191,9 @@ function CableManagement() {
|
||||
>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="force"
|
||||
type="primary"
|
||||
danger
|
||||
onClick={handleForceSubmit}
|
||||
>
|
||||
<Button key="force" type="primary" danger onClick={handleForceSubmit}>
|
||||
强制接管
|
||||
</Button>
|
||||
</Button>,
|
||||
]}
|
||||
width={600}
|
||||
>
|
||||
@@ -1190,7 +1211,11 @@ function CableManagement() {
|
||||
>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Tag color="error">
|
||||
{conflict.type === 'source' ? '源端口' : conflict.type === 'target' ? '目标端口' : '端口'}
|
||||
{conflict.type === 'source'
|
||||
? '源端口'
|
||||
: conflict.type === 'target'
|
||||
? '目标端口'
|
||||
: '端口'}
|
||||
</Tag>
|
||||
<span style={{ fontWeight: 500, marginLeft: 8 }}>{conflict.port}</span>
|
||||
</div>
|
||||
@@ -1199,11 +1224,15 @@ function CableManagement() {
|
||||
<div>当前连接:</div>
|
||||
<div style={{ marginTop: 4, paddingLeft: 12 }}>
|
||||
<div>
|
||||
源设备:{conflict.existingCable.sourceDevice?.name || conflict.existingCable.sourceDeviceId}
|
||||
源设备:
|
||||
{conflict.existingCable.sourceDevice?.name ||
|
||||
conflict.existingCable.sourceDeviceId}
|
||||
({conflict.existingCable.sourcePort})
|
||||
</div>
|
||||
<div style={{ marginTop: 2 }}>
|
||||
目标设备:{conflict.existingCable.targetDevice?.name || conflict.existingCable.targetDeviceId}
|
||||
目标设备:
|
||||
{conflict.existingCable.targetDevice?.name ||
|
||||
conflict.existingCable.targetDeviceId}
|
||||
({conflict.existingCable.targetPort})
|
||||
</div>
|
||||
<div style={{ marginTop: 2 }}>
|
||||
@@ -1214,7 +1243,15 @@ function CableManagement() {
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
<div style={{ marginTop: 16, padding: 12, background: '#fff7ed', borderRadius: 6, border: '1px solid #fed7aa' }}>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: '#fff7ed',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #fed7aa',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#ea580c' }}>💡</span>
|
||||
<span style={{ marginLeft: 8, color: '#9a3412' }}>
|
||||
点击"强制接管"将断开原有连接并创建新接线。此操作不可恢复!
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, InputNumber, Select, message, Card, Space, Popconfirm, Tag } from 'antd';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
message,
|
||||
Card,
|
||||
Space,
|
||||
Popconfirm,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
|
||||
@@ -15,7 +28,7 @@ function CategoryManagement() {
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showTotal: (total) => `共 ${total} 条记录`
|
||||
showTotal: total => `共 ${total} 条记录`,
|
||||
});
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
@@ -24,7 +37,7 @@ function CategoryManagement() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get('/api/consumable-categories', {
|
||||
params: { page, pageSize, keyword, status }
|
||||
params: { page, pageSize, keyword, status },
|
||||
});
|
||||
setCategories(response.data.categories);
|
||||
setPagination(prev => ({ ...prev, current: page, pageSize, total: response.data.total }));
|
||||
@@ -56,7 +69,7 @@ function CategoryManagement() {
|
||||
setEditingCategory(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
const handleSubmit = async values => {
|
||||
try {
|
||||
if (editingCategory) {
|
||||
await axios.put(`/api/consumable-categories/${editingCategory.id}`, values);
|
||||
@@ -69,12 +82,14 @@ function CategoryManagement() {
|
||||
fetchCategories();
|
||||
setEditingCategory(null);
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.error || (editingCategory ? '分类更新失败' : '分类创建失败'));
|
||||
message.error(
|
||||
error.response?.data?.error || (editingCategory ? '分类更新失败' : '分类创建失败')
|
||||
);
|
||||
console.error('提交失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
const handleDelete = async id => {
|
||||
try {
|
||||
await axios.delete(`/api/consumable-categories/${id}`);
|
||||
message.success('删除成功');
|
||||
@@ -90,44 +105,44 @@ function CategoryManagement() {
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '分类名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 150
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
width: 200,
|
||||
render: (value) => value || '-'
|
||||
render: value => value || '-',
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sortOrder',
|
||||
key: 'sortOrder',
|
||||
width: 80
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (value) => (
|
||||
render: value => (
|
||||
<Tag color={value === 'active' ? 'green' : 'red'}>
|
||||
{value === 'active' ? '启用' : '停用'}
|
||||
</Tag>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (value) => value ? new Date(value).toLocaleString('zh-CN') : '-'
|
||||
render: value => (value ? new Date(value).toLocaleString('zh-CN') : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -135,26 +150,40 @@ function CategoryManagement() {
|
||||
width: 150,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button type="primary" icon={<EditOutlined />} size="small" onClick={() => showModal(record)}>编辑</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<EditOutlined />}
|
||||
size="small"
|
||||
onClick={() => showModal(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button danger icon={<DeleteOutlined />} size="small">删除</Button>
|
||||
<Button danger icon={<DeleteOutlined />} size="small">
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card title="耗材分类管理" extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>添加分类</Button>
|
||||
}>
|
||||
<Card
|
||||
title="耗材分类管理"
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>
|
||||
添加分类
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Input.Search
|
||||
placeholder="搜索分类名称、描述"
|
||||
style={{ width: 300 }}
|
||||
onSearch={(value) => setKeyword(value)}
|
||||
onSearch={value => setKeyword(value)}
|
||||
allowClear
|
||||
/>
|
||||
<Select value={status} onChange={setStatus} style={{ width: 120 }}>
|
||||
@@ -172,7 +201,7 @@ function CategoryManagement() {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={pagination}
|
||||
onChange={(pagination) => fetchCategories(pagination.current, pagination.pageSize)}
|
||||
onChange={pagination => fetchCategories(pagination.current, pagination.pageSize)}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</Card>
|
||||
@@ -185,7 +214,14 @@ function CategoryManagement() {
|
||||
width={500}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="name" label="分类名称" rules={[{ required: true, message: '请输入分类名称' }, { max: 50, message: '分类名称不能超过50个字符' }]}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="分类名称"
|
||||
rules={[
|
||||
{ required: true, message: '请输入分类名称' },
|
||||
{ max: 50, message: '分类名称不能超过50个字符' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入分类名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
@@ -202,7 +238,9 @@ function CategoryManagement() {
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">{editingCategory ? '更新' : '创建'}</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
{editingCategory ? '更新' : '创建'}
|
||||
</Button>
|
||||
<Button onClick={handleCancel}>取消</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Table, Card, Space, Select, DatePicker, Input, Tag, Button, message, Modal, Upload, Radio, Dropdown, Form, Tooltip, Timeline } from 'antd';
|
||||
import { HistoryOutlined, SearchOutlined, FileTextOutlined, DownloadOutlined, UploadOutlined, FileExcelOutlined, FileOutlined, DownOutlined, EditOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Table,
|
||||
Card,
|
||||
Space,
|
||||
Select,
|
||||
DatePicker,
|
||||
Input,
|
||||
Tag,
|
||||
Button,
|
||||
message,
|
||||
Modal,
|
||||
Upload,
|
||||
Radio,
|
||||
Dropdown,
|
||||
Form,
|
||||
Tooltip,
|
||||
Timeline,
|
||||
} from 'antd';
|
||||
import {
|
||||
HistoryOutlined,
|
||||
SearchOutlined,
|
||||
FileTextOutlined,
|
||||
DownloadOutlined,
|
||||
UploadOutlined,
|
||||
FileExcelOutlined,
|
||||
FileOutlined,
|
||||
DownOutlined,
|
||||
EditOutlined,
|
||||
EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import * as XLSX from 'xlsx';
|
||||
@@ -15,7 +43,7 @@ function ConsumableLogs() {
|
||||
const [filters, setFilters] = useState({
|
||||
operationType: 'all',
|
||||
consumableId: '',
|
||||
dateRange: null
|
||||
dateRange: null,
|
||||
});
|
||||
const [importModalVisible, setImportModalVisible] = useState(false);
|
||||
const [importType, setImportType] = useState('excel');
|
||||
@@ -33,7 +61,7 @@ function ConsumableLogs() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = { page, pageSize };
|
||||
|
||||
|
||||
if (currentFilters.operationType !== 'all') {
|
||||
params.operationType = currentFilters.operationType;
|
||||
}
|
||||
@@ -44,7 +72,7 @@ function ConsumableLogs() {
|
||||
params.startDate = currentFilters.dateRange[0].format('YYYY-MM-DD');
|
||||
params.endDate = currentFilters.dateRange[1].format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
|
||||
const response = await axios.get('/api/consumables/logs', { params });
|
||||
setLogs(response.data.logs);
|
||||
setPagination(prev => ({ ...prev, current: page, total: response.data.total }));
|
||||
@@ -65,7 +93,7 @@ function ConsumableLogs() {
|
||||
fetchLogs(1, pagination.pageSize);
|
||||
};
|
||||
|
||||
const getOperationTag = (type) => {
|
||||
const getOperationTag = type => {
|
||||
const config = {
|
||||
in: { color: 'green', text: '入库' },
|
||||
out: { color: 'red', text: '出库' },
|
||||
@@ -73,7 +101,7 @@ function ConsumableLogs() {
|
||||
update: { color: 'orange', text: '更新' },
|
||||
delete: { color: 'magenta', text: '删除' },
|
||||
adjust: { color: 'purple', text: '调整' },
|
||||
import: { color: 'cyan', text: '导入' }
|
||||
import: { color: 'cyan', text: '导入' },
|
||||
};
|
||||
const { color, text } = config[type] || { color: 'default', text: type };
|
||||
return <Tag color={color}>{text}</Tag>;
|
||||
@@ -86,27 +114,27 @@ function ConsumableLogs() {
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
sorter: (a, b) => new Date(a.createdAt) - new Date(b.createdAt),
|
||||
render: (date) => dayjs(date).format('YYYY-MM-DD HH:mm:ss')
|
||||
render: date => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
{
|
||||
title: '耗材ID',
|
||||
dataIndex: 'consumableId',
|
||||
key: 'consumableId',
|
||||
width: 150,
|
||||
render: (value) => <code>{value}</code>
|
||||
render: value => <code>{value}</code>,
|
||||
},
|
||||
{
|
||||
title: '耗材名称',
|
||||
dataIndex: 'consumableName',
|
||||
key: 'consumableName',
|
||||
width: 150
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '操作类型',
|
||||
dataIndex: 'operationType',
|
||||
key: 'operationType',
|
||||
width: 100,
|
||||
render: (type) => getOperationTag(type)
|
||||
render: type => getOperationTag(type),
|
||||
},
|
||||
{
|
||||
title: '变动数量',
|
||||
@@ -114,46 +142,49 @@ function ConsumableLogs() {
|
||||
key: 'quantity',
|
||||
width: 100,
|
||||
render: (value, record) => (
|
||||
<span style={{
|
||||
color: value > 0 ? '#52c41a' : value < 0 ? '#ff4d4f' : '#888',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{value > 0 ? '+' : ''}{value}
|
||||
<span
|
||||
style={{
|
||||
color: value > 0 ? '#52c41a' : value < 0 ? '#ff4d4f' : '#888',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{value > 0 ? '+' : ''}
|
||||
{value}
|
||||
</span>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作前库存',
|
||||
dataIndex: 'previousStock',
|
||||
key: 'previousStock',
|
||||
width: 100
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '操作后库存',
|
||||
dataIndex: 'currentStock',
|
||||
key: 'currentStock',
|
||||
width: 100
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
dataIndex: 'operator',
|
||||
key: 'operator',
|
||||
width: 120
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '原因',
|
||||
dataIndex: 'reason',
|
||||
key: 'reason',
|
||||
width: 150,
|
||||
render: (value) => value || '-'
|
||||
render: value => value || '-',
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'notes',
|
||||
key: 'notes',
|
||||
width: 200,
|
||||
render: (value) => value || '-',
|
||||
ellipsis: true
|
||||
render: value => value || '-',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -181,8 +212,8 @@ function ConsumableLogs() {
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleExport = async (currentFilters = filters) => {
|
||||
@@ -198,18 +229,18 @@ function ConsumableLogs() {
|
||||
params.startDate = currentFilters.dateRange[0].format('YYYY-MM-DD');
|
||||
params.endDate = currentFilters.dateRange[1].format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
const response = await axios.get('/api/consumables/logs/export', {
|
||||
|
||||
const response = await axios.get('/api/consumables/logs/export', {
|
||||
params,
|
||||
responseType: 'blob'
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
|
||||
const blob = new Blob([response.data], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = `耗材操作日志_${dayjs().format('YYYYMMDD_HHmmss')}.csv`;
|
||||
link.click();
|
||||
|
||||
|
||||
message.success('导出成功');
|
||||
} catch (error) {
|
||||
message.error('导出失败');
|
||||
@@ -230,29 +261,29 @@ function ConsumableLogs() {
|
||||
params.startDate = currentFilters.dateRange[0].format('YYYY-MM-DD');
|
||||
params.endDate = currentFilters.dateRange[1].format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
const response = await axios.get('/api/consumables/logs', {
|
||||
params: { ...params, page: 1, pageSize: 10000 }
|
||||
|
||||
const response = await axios.get('/api/consumables/logs', {
|
||||
params: { ...params, page: 1, pageSize: 10000 },
|
||||
});
|
||||
|
||||
|
||||
const exportData = response.data.logs.map(log => ({
|
||||
'时间': dayjs(log.createdAt).format('YYYY-MM-DD HH:mm:ss'),
|
||||
'耗材ID': log.consumableId,
|
||||
'耗材名称': log.consumableName,
|
||||
'操作类型': getOperationTypeText(log.operationType),
|
||||
'变动数量': log.quantity,
|
||||
'操作前库存': log.previousStock,
|
||||
'操作后库存': log.currentStock,
|
||||
'操作人': log.operator,
|
||||
'原因': log.reason || '',
|
||||
'备注': log.notes || ''
|
||||
时间: dayjs(log.createdAt).format('YYYY-MM-DD HH:mm:ss'),
|
||||
耗材ID: log.consumableId,
|
||||
耗材名称: log.consumableName,
|
||||
操作类型: getOperationTypeText(log.operationType),
|
||||
变动数量: log.quantity,
|
||||
操作前库存: log.previousStock,
|
||||
操作后库存: log.currentStock,
|
||||
操作人: log.operator,
|
||||
原因: log.reason || '',
|
||||
备注: log.notes || '',
|
||||
}));
|
||||
|
||||
|
||||
const ws = XLSX.utils.json_to_sheet(exportData);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, '操作日志');
|
||||
XLSX.writeFile(wb, `耗材操作日志_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`);
|
||||
|
||||
|
||||
message.success('导出Excel成功');
|
||||
} catch (error) {
|
||||
message.error('导出Excel失败');
|
||||
@@ -260,27 +291,27 @@ function ConsumableLogs() {
|
||||
}
|
||||
};
|
||||
|
||||
const getOperationTypeText = (type) => {
|
||||
const getOperationTypeText = type => {
|
||||
const map = {
|
||||
'in': '入库',
|
||||
'out': '出库',
|
||||
'create': '创建',
|
||||
'update': '更新',
|
||||
'delete': '删除',
|
||||
'adjust': '调整',
|
||||
'import': '导入'
|
||||
in: '入库',
|
||||
out: '出库',
|
||||
create: '创建',
|
||||
update: '更新',
|
||||
delete: '删除',
|
||||
adjust: '调整',
|
||||
import: '导入',
|
||||
};
|
||||
return map[type] || type;
|
||||
};
|
||||
|
||||
const handleImport = async (file) => {
|
||||
const handleImport = async file => {
|
||||
setImporting(true);
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
reader.onload = async e => {
|
||||
try {
|
||||
let logItems = [];
|
||||
|
||||
|
||||
if (importType === 'excel') {
|
||||
const workbook = XLSX.read(e.target.result, { type: 'array' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
@@ -290,7 +321,7 @@ function ConsumableLogs() {
|
||||
const text = e.target.result;
|
||||
const lines = text.split('\n').filter(line => line.trim());
|
||||
const headers = lines[0].split(',').map(h => h.replace(/"/g, ''));
|
||||
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const values = lines[i].split(',').map(v => v.replace(/"/g, ''));
|
||||
const item = {};
|
||||
@@ -300,12 +331,12 @@ function ConsumableLogs() {
|
||||
logItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const response = await axios.post('/api/consumables/logs/import', {
|
||||
logs: logItems,
|
||||
operator: '前端导入'
|
||||
operator: '前端导入',
|
||||
});
|
||||
|
||||
|
||||
if (response.data.success > 0) {
|
||||
message.success(`成功导入 ${response.data.success} 条记录`);
|
||||
}
|
||||
@@ -313,7 +344,7 @@ function ConsumableLogs() {
|
||||
message.warning(`导入失败 ${response.data.failed} 条`);
|
||||
response.data.errors.forEach(err => console.error(err));
|
||||
}
|
||||
|
||||
|
||||
setImportModalVisible(false);
|
||||
fetchLogs(1, pagination.pageSize);
|
||||
} catch (err) {
|
||||
@@ -322,7 +353,7 @@ function ConsumableLogs() {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (importType === 'excel') {
|
||||
reader.readAsArrayBuffer(file);
|
||||
} else {
|
||||
@@ -338,16 +369,16 @@ function ConsumableLogs() {
|
||||
const downloadTemplate = () => {
|
||||
const template = [
|
||||
{
|
||||
'耗材ID': 'CON123456',
|
||||
'耗材名称': '示例耗材',
|
||||
'操作类型': '入库',
|
||||
'变动数量': 10,
|
||||
'操作前库存': 100,
|
||||
'操作后库存': 110,
|
||||
'操作人': '管理员',
|
||||
'原因': '示例原因',
|
||||
'备注': '示例备注'
|
||||
}
|
||||
耗材ID: 'CON123456',
|
||||
耗材名称: '示例耗材',
|
||||
操作类型: '入库',
|
||||
变动数量: 10,
|
||||
操作前库存: 100,
|
||||
操作后库存: 110,
|
||||
操作人: '管理员',
|
||||
原因: '示例原因',
|
||||
备注: '示例备注',
|
||||
},
|
||||
];
|
||||
|
||||
const ws = XLSX.utils.json_to_sheet(template);
|
||||
@@ -359,18 +390,18 @@ function ConsumableLogs() {
|
||||
};
|
||||
|
||||
// 编辑日志
|
||||
const handleEdit = (record) => {
|
||||
const handleEdit = record => {
|
||||
setCurrentLog(record);
|
||||
form.setFieldsValue({
|
||||
reason: record.reason,
|
||||
notes: record.notes,
|
||||
modificationReason: ''
|
||||
modificationReason: '',
|
||||
});
|
||||
setEditModalVisible(true);
|
||||
};
|
||||
|
||||
// 提交编辑
|
||||
const handleEditSubmit = async (values) => {
|
||||
const handleEditSubmit = async values => {
|
||||
if (!currentLog) return;
|
||||
|
||||
setEditLoading(true);
|
||||
@@ -379,7 +410,7 @@ function ConsumableLogs() {
|
||||
reason: values.reason,
|
||||
notes: values.notes,
|
||||
operator: values.operator || '管理员',
|
||||
modificationReason: values.modificationReason
|
||||
modificationReason: values.modificationReason,
|
||||
});
|
||||
|
||||
message.success('日志修改成功');
|
||||
@@ -393,7 +424,7 @@ function ConsumableLogs() {
|
||||
};
|
||||
|
||||
// 查看修改历史
|
||||
const handleViewHistory = async (record) => {
|
||||
const handleViewHistory = async record => {
|
||||
setCurrentLog(record);
|
||||
setHistoryModalVisible(true);
|
||||
setHistoryLoading(true);
|
||||
@@ -411,7 +442,7 @@ function ConsumableLogs() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<FileTextOutlined />
|
||||
@@ -425,12 +456,12 @@ function ConsumableLogs() {
|
||||
placeholder="搜索耗材ID"
|
||||
style={{ width: 200 }}
|
||||
allowClear
|
||||
onSearch={(value) => handleFilterChange('consumableId', value)}
|
||||
onSearch={value => handleFilterChange('consumableId', value)}
|
||||
prefix={<SearchOutlined />}
|
||||
/>
|
||||
<Select
|
||||
value={filters.operationType}
|
||||
onChange={(value) => handleFilterChange('operationType', value)}
|
||||
onChange={value => handleFilterChange('operationType', value)}
|
||||
style={{ width: 120 }}
|
||||
>
|
||||
<Option value="all">全部类型</Option>
|
||||
@@ -444,11 +475,11 @@ function ConsumableLogs() {
|
||||
</Select>
|
||||
<RangePicker
|
||||
value={filters.dateRange}
|
||||
onChange={(dates) => handleFilterChange('dateRange', dates)}
|
||||
onChange={dates => handleFilterChange('dateRange', dates)}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
<Button
|
||||
icon={<HistoryOutlined />}
|
||||
<Button
|
||||
icon={<HistoryOutlined />}
|
||||
onClick={() => {
|
||||
setFilters({ operationType: 'all', consumableId: '', dateRange: null });
|
||||
fetchLogs(1, pagination.pageSize);
|
||||
@@ -463,15 +494,15 @@ function ConsumableLogs() {
|
||||
key: 'csv',
|
||||
icon: <FileOutlined />,
|
||||
label: '导出CSV',
|
||||
onClick: () => handleExport(filters)
|
||||
onClick: () => handleExport(filters),
|
||||
},
|
||||
{
|
||||
key: 'excel',
|
||||
icon: <FileExcelOutlined />,
|
||||
label: '导出Excel',
|
||||
onClick: () => handleExportExcel(filters)
|
||||
}
|
||||
]
|
||||
onClick: () => handleExportExcel(filters),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button icon={<DownloadOutlined />}>
|
||||
@@ -491,11 +522,11 @@ function ConsumableLogs() {
|
||||
loading={loading}
|
||||
pagination={{
|
||||
...pagination,
|
||||
showTotal: (total) => `共 ${total} 条记录`,
|
||||
showTotal: total => `共 ${total} 条记录`,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true
|
||||
showQuickJumper: true,
|
||||
}}
|
||||
onChange={(pagination) => fetchLogs(pagination.current, pagination.pageSize)}
|
||||
onChange={pagination => fetchLogs(pagination.current, pagination.pageSize)}
|
||||
scroll={{ x: 1500 }}
|
||||
/>
|
||||
</Card>
|
||||
@@ -511,23 +542,23 @@ function ConsumableLogs() {
|
||||
width={500}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Radio.Group
|
||||
value={importType}
|
||||
onChange={(e) => setImportType(e.target.value)}
|
||||
<Radio.Group
|
||||
value={importType}
|
||||
onChange={e => setImportType(e.target.value)}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Radio.Button value="excel">Excel文件</Radio.Button>
|
||||
<Radio.Button value="csv">CSV文件</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button type="link" onClick={downloadTemplate}>
|
||||
下载导入模板
|
||||
</Button>
|
||||
<span style={{ color: '#888', marginLeft: 8 }}>(建议先下载模板填写)</span>
|
||||
</div>
|
||||
|
||||
|
||||
<Upload
|
||||
accept={importType === 'excel' ? '.xlsx,.xls' : '.csv'}
|
||||
showUploadList={false}
|
||||
@@ -537,7 +568,7 @@ function ConsumableLogs() {
|
||||
选择{importType === 'excel' ? 'Excel' : 'CSV'}文件并导入
|
||||
</Button>
|
||||
</Upload>
|
||||
|
||||
|
||||
<div style={{ marginTop: 16, color: '#888', fontSize: 12 }}>
|
||||
<p>注意事项:</p>
|
||||
<ul>
|
||||
@@ -561,21 +592,11 @@ function ConsumableLogs() {
|
||||
confirmLoading={editLoading}
|
||||
width={600}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleEditSubmit}
|
||||
>
|
||||
<Form.Item
|
||||
label="操作原因"
|
||||
name="reason"
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleEditSubmit}>
|
||||
<Form.Item label="操作原因" name="reason">
|
||||
<Input.TextArea rows={2} placeholder="请输入操作原因" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="备注"
|
||||
name="notes"
|
||||
>
|
||||
<Form.Item label="备注" name="notes">
|
||||
<Input.TextArea rows={3} placeholder="请输入备注信息" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
@@ -585,10 +606,7 @@ function ConsumableLogs() {
|
||||
>
|
||||
<Input.TextArea rows={2} placeholder="请输入修改原因(必填)" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="修改人"
|
||||
name="operator"
|
||||
>
|
||||
<Form.Item label="修改人" name="operator">
|
||||
<Input placeholder="请输入修改人姓名" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
@@ -623,21 +641,38 @@ function ConsumableLogs() {
|
||||
<Tag color={getOperationTag(item.operationType).props.color}>
|
||||
{getOperationTag(item.operationType).props.children}
|
||||
</Tag>
|
||||
{item.modifiedBy && (
|
||||
<Tag color="orange">已修改</Tag>
|
||||
)}
|
||||
{item.modifiedBy && <Tag color="orange">已修改</Tag>}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#666' }}>
|
||||
<p><strong>耗材:</strong> {item.consumableName} ({item.consumableId})</p>
|
||||
<p><strong>操作人:</strong> {item.operator}</p>
|
||||
{item.reason && <p><strong>原因:</strong> {item.reason}</p>}
|
||||
{item.notes && <p><strong>备注:</strong> {item.notes}</p>}
|
||||
<p>
|
||||
<strong>耗材:</strong> {item.consumableName} ({item.consumableId})
|
||||
</p>
|
||||
<p>
|
||||
<strong>操作人:</strong> {item.operator}
|
||||
</p>
|
||||
{item.reason && (
|
||||
<p>
|
||||
<strong>原因:</strong> {item.reason}
|
||||
</p>
|
||||
)}
|
||||
{item.notes && (
|
||||
<p>
|
||||
<strong>备注:</strong> {item.notes}
|
||||
</p>
|
||||
)}
|
||||
{item.modifiedBy && (
|
||||
<>
|
||||
<p><strong>修改人:</strong> {item.modifiedBy}</p>
|
||||
<p><strong>修改时间:</strong> {dayjs(item.modifiedAt).format('YYYY-MM-DD HH:mm:ss')}</p>
|
||||
<p>
|
||||
<strong>修改人:</strong> {item.modifiedBy}
|
||||
</p>
|
||||
<p>
|
||||
<strong>修改时间:</strong>{' '}
|
||||
{dayjs(item.modifiedAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</p>
|
||||
{item.modificationReason && (
|
||||
<p><strong>修改原因:</strong> {item.modificationReason}</p>
|
||||
<p>
|
||||
<strong>修改原因:</strong> {item.modificationReason}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Select, InputNumber, message, Card, Space, Popconfirm, Upload, Table as AntTable, Progress, Checkbox } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ExportOutlined, ImportOutlined, UploadOutlined, FileExcelOutlined, InboxOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
InputNumber,
|
||||
message,
|
||||
Card,
|
||||
Space,
|
||||
Popconfirm,
|
||||
Upload,
|
||||
Table as AntTable,
|
||||
Progress,
|
||||
Checkbox,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
ExportOutlined,
|
||||
ImportOutlined,
|
||||
UploadOutlined,
|
||||
FileExcelOutlined,
|
||||
InboxOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
|
||||
const { Option } = Select;
|
||||
@@ -16,7 +42,7 @@ function ConsumableManagement() {
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showTotal: (total) => `共 ${total} 条记录`
|
||||
showTotal: total => `共 ${total} 条记录`,
|
||||
});
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [category, setCategory] = useState('all');
|
||||
@@ -34,21 +60,24 @@ function ConsumableManagement() {
|
||||
const [stockForm] = Form.useForm();
|
||||
const [maxStockUnlimited, setMaxStockUnlimited] = useState(false);
|
||||
|
||||
const fetchConsumables = useCallback(async (page = 1, pageSize = 10) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get('/api/consumables', {
|
||||
params: { page, pageSize, keyword, category, status }
|
||||
});
|
||||
setConsumables(response.data.consumables);
|
||||
setPagination(prev => ({ ...prev, current: page, pageSize, total: response.data.total }));
|
||||
} catch (error) {
|
||||
message.error('获取耗材列表失败');
|
||||
console.error('获取耗材列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [keyword, category, status]);
|
||||
const fetchConsumables = useCallback(
|
||||
async (page = 1, pageSize = 10) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get('/api/consumables', {
|
||||
params: { page, pageSize, keyword, category, status },
|
||||
});
|
||||
setConsumables(response.data.consumables);
|
||||
setPagination(prev => ({ ...prev, current: page, pageSize, total: response.data.total }));
|
||||
} catch (error) {
|
||||
message.error('获取耗材列表失败');
|
||||
console.error('获取耗材列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[keyword, category, status]
|
||||
);
|
||||
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
@@ -64,94 +93,134 @@ function ConsumableManagement() {
|
||||
fetchCategories();
|
||||
}, [fetchConsumables, fetchCategories]);
|
||||
|
||||
const showModal = useCallback((consumable = null) => {
|
||||
setEditingConsumable(consumable);
|
||||
if (consumable) {
|
||||
const isUnlimited = consumable.maxStock === 0 || consumable.maxStock === null || consumable.maxStock === undefined;
|
||||
setMaxStockUnlimited(isUnlimited);
|
||||
form.setFieldsValue({
|
||||
...consumable,
|
||||
maxStock: isUnlimited ? undefined : consumable.maxStock
|
||||
});
|
||||
} else {
|
||||
setMaxStockUnlimited(true);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
unit: '个',
|
||||
currentStock: 0,
|
||||
minStock: 0,
|
||||
status: 'active',
|
||||
unitPrice: 0
|
||||
});
|
||||
}
|
||||
setModalVisible(true);
|
||||
}, [form]);
|
||||
const showModal = useCallback(
|
||||
(consumable = null) => {
|
||||
setEditingConsumable(consumable);
|
||||
if (consumable) {
|
||||
const isUnlimited =
|
||||
consumable.maxStock === 0 ||
|
||||
consumable.maxStock === null ||
|
||||
consumable.maxStock === undefined;
|
||||
setMaxStockUnlimited(isUnlimited);
|
||||
form.setFieldsValue({
|
||||
...consumable,
|
||||
maxStock: isUnlimited ? undefined : consumable.maxStock,
|
||||
});
|
||||
} else {
|
||||
setMaxStockUnlimited(true);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
unit: '个',
|
||||
currentStock: 0,
|
||||
minStock: 0,
|
||||
status: 'active',
|
||||
unitPrice: 0,
|
||||
});
|
||||
}
|
||||
setModalVisible(true);
|
||||
},
|
||||
[form]
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setModalVisible(false);
|
||||
setEditingConsumable(null);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async (values) => {
|
||||
try {
|
||||
const submitData = {
|
||||
...values,
|
||||
maxStock: maxStockUnlimited ? 0 : values.maxStock,
|
||||
unitPrice: values.unitPrice || 0
|
||||
};
|
||||
if (editingConsumable) {
|
||||
await axios.put(`/api/consumables/${editingConsumable.consumableId}`, submitData);
|
||||
message.success('耗材更新成功');
|
||||
} else {
|
||||
await axios.post('/api/consumables', {
|
||||
...submitData,
|
||||
consumableId: `CON${Date.now()}`
|
||||
});
|
||||
message.success('耗材创建成功');
|
||||
const handleSubmit = useCallback(
|
||||
async values => {
|
||||
try {
|
||||
const submitData = {
|
||||
...values,
|
||||
maxStock: maxStockUnlimited ? 0 : values.maxStock,
|
||||
unitPrice: values.unitPrice || 0,
|
||||
};
|
||||
if (editingConsumable) {
|
||||
await axios.put(`/api/consumables/${editingConsumable.consumableId}`, submitData);
|
||||
message.success('耗材更新成功');
|
||||
} else {
|
||||
await axios.post('/api/consumables', {
|
||||
...submitData,
|
||||
consumableId: `CON${Date.now()}`,
|
||||
});
|
||||
message.success('耗材创建成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
fetchConsumables();
|
||||
setEditingConsumable(null);
|
||||
} catch (error) {
|
||||
message.error(editingConsumable ? '耗材更新失败' : '耗材创建失败');
|
||||
console.error('提交失败:', error);
|
||||
}
|
||||
setModalVisible(false);
|
||||
fetchConsumables();
|
||||
setEditingConsumable(null);
|
||||
} catch (error) {
|
||||
message.error(editingConsumable ? '耗材更新失败' : '耗材创建失败');
|
||||
console.error('提交失败:', error);
|
||||
}
|
||||
}, [editingConsumable, fetchConsumables, maxStockUnlimited]);
|
||||
},
|
||||
[editingConsumable, fetchConsumables, maxStockUnlimited]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(async (consumableId) => {
|
||||
try {
|
||||
await axios.delete(`/api/consumables/${consumableId}`);
|
||||
message.success('删除成功');
|
||||
fetchConsumables();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
console.error('删除失败:', error);
|
||||
}
|
||||
}, [fetchConsumables]);
|
||||
const handleDelete = useCallback(
|
||||
async consumableId => {
|
||||
try {
|
||||
await axios.delete(`/api/consumables/${consumableId}`);
|
||||
message.success('删除成功');
|
||||
fetchConsumables();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
console.error('删除失败:', error);
|
||||
}
|
||||
},
|
||||
[fetchConsumables]
|
||||
);
|
||||
|
||||
const handleSearch = useCallback((value) => {
|
||||
const handleSearch = useCallback(value => {
|
||||
setKeyword(value);
|
||||
}, []);
|
||||
|
||||
const exportToCSV = (data, filename) => {
|
||||
const headers = ['耗材ID', '名称', '分类', '单位', '当前库存', '最小库存', '最大库存', '单价', '供应商', '存放位置', '状态'];
|
||||
const keys = ['consumableId', 'name', 'category', 'unit', 'currentStock', 'minStock', 'maxStock', 'unitPrice', 'supplier', 'location', 'status'];
|
||||
|
||||
const headers = [
|
||||
'耗材ID',
|
||||
'名称',
|
||||
'分类',
|
||||
'单位',
|
||||
'当前库存',
|
||||
'最小库存',
|
||||
'最大库存',
|
||||
'单价',
|
||||
'供应商',
|
||||
'存放位置',
|
||||
'状态',
|
||||
];
|
||||
const keys = [
|
||||
'consumableId',
|
||||
'name',
|
||||
'category',
|
||||
'unit',
|
||||
'currentStock',
|
||||
'minStock',
|
||||
'maxStock',
|
||||
'unitPrice',
|
||||
'supplier',
|
||||
'location',
|
||||
'status',
|
||||
];
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...data.map(row => keys.map(key => {
|
||||
let value = row[key];
|
||||
if (key === 'unitPrice') value = `¥${parseFloat(value || 0).toFixed(2)}`;
|
||||
if (key === 'status') value = value === 'active' ? '启用' : '停用';
|
||||
if (value === null || value === undefined) value = '';
|
||||
const str = String(value);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}).join(','))
|
||||
...data.map(row =>
|
||||
keys
|
||||
.map(key => {
|
||||
let value = row[key];
|
||||
if (key === 'unitPrice') value = `¥${parseFloat(value || 0).toFixed(2)}`;
|
||||
if (key === 'status') value = value === 'active' ? '启用' : '停用';
|
||||
if (value === null || value === undefined) value = '';
|
||||
const str = String(value);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
})
|
||||
.join(',')
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
@@ -166,7 +235,7 @@ function ConsumableManagement() {
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/consumables', {
|
||||
params: { keyword, category, status, pageSize: 1000 }
|
||||
params: { keyword, category, status, pageSize: 1000 },
|
||||
});
|
||||
const consumables = response.data.consumables;
|
||||
exportToCSV(consumables, `consumables_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
@@ -177,21 +246,21 @@ function ConsumableManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
const parseCSV = (text) => {
|
||||
const parseCSV = text => {
|
||||
const lines = text.trim().split('\n');
|
||||
if (lines.length < 2) return [];
|
||||
|
||||
|
||||
const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''));
|
||||
const data = [];
|
||||
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
|
||||
|
||||
let values = [];
|
||||
let inQuotes = false;
|
||||
let current = '';
|
||||
|
||||
|
||||
for (let j = 0; j < line.length; j++) {
|
||||
const char = line[j];
|
||||
if (char === '"') {
|
||||
@@ -204,22 +273,22 @@ function ConsumableManagement() {
|
||||
}
|
||||
}
|
||||
values.push(current.trim().replace(/^"|"$/g, ''));
|
||||
|
||||
|
||||
const row = {};
|
||||
headers.forEach((header, idx) => {
|
||||
row[header] = values[idx] || '';
|
||||
});
|
||||
data.push(row);
|
||||
}
|
||||
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const handleFileChange = (info) => {
|
||||
const handleFileChange = info => {
|
||||
const file = info.fileList[info.fileList.length - 1];
|
||||
if (file && file.originFileObj) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
reader.onload = e => {
|
||||
const text = e.target.result;
|
||||
const parsedData = parseCSV(text);
|
||||
setImportPreview(parsedData.slice(0, 10));
|
||||
@@ -249,57 +318,57 @@ function ConsumableManagement() {
|
||||
message.warning('请先选择文件');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setImporting(true);
|
||||
setImportProgress(0);
|
||||
setImportPhase('正在读取文件...');
|
||||
setImportResult(null);
|
||||
|
||||
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
reader.onload = async e => {
|
||||
const text = e.target.result;
|
||||
setImportProgress(10);
|
||||
setImportPhase('正在读取文件...');
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
setImportProgress(20);
|
||||
setImportPhase('正在解析CSV数据...');
|
||||
}, 100);
|
||||
|
||||
|
||||
const items = parseCSV(text);
|
||||
const totalItems = items.length;
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
setImportProgress(30);
|
||||
setImportPhase(`共解析 ${totalItems} 条记录,准备提交...`);
|
||||
}, 200);
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
setImportProgress(40);
|
||||
setImportPhase('正在连接服务器...');
|
||||
}, 300);
|
||||
|
||||
|
||||
const response = await axios.post('/api/consumables/import', { items });
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
setImportProgress(60);
|
||||
setImportPhase('正在处理服务器响应...');
|
||||
}, 100);
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
setImportProgress(80);
|
||||
setImportPhase('正在更新本地数据...');
|
||||
}, 200);
|
||||
|
||||
|
||||
const results = response.data.results;
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
setImportResult(results);
|
||||
setImportProgress(100);
|
||||
setImportPhase('导入完成');
|
||||
setImporting(false);
|
||||
|
||||
|
||||
if (results.failed > 0) {
|
||||
message.warning(`导入完成,成功 ${results.success} 条,失败 ${results.failed} 条`);
|
||||
} else {
|
||||
@@ -317,7 +386,7 @@ function ConsumableManagement() {
|
||||
imported: 0,
|
||||
failed: 0,
|
||||
errors: [{ row: '-', error: '文件读取失败,请检查文件是否损坏' }],
|
||||
message: '文件读取失败'
|
||||
message: '文件读取失败',
|
||||
});
|
||||
message.error('文件读取失败');
|
||||
};
|
||||
@@ -326,16 +395,16 @@ function ConsumableManagement() {
|
||||
setImporting(false);
|
||||
setImportProgress(0);
|
||||
setImportPhase('导入失败');
|
||||
|
||||
|
||||
let errorMessage = '导入失败,请检查网络连接或服务器状态';
|
||||
let errorDetails = [];
|
||||
|
||||
|
||||
if (error.response && error.response.data) {
|
||||
const { data } = error.response;
|
||||
if (data.errors && Array.isArray(data.errors) && data.errors.length > 0) {
|
||||
errorDetails = data.errors.map((err, index) => ({
|
||||
row: err.row || index + 1,
|
||||
error: err.error || err.message || '数据格式错误'
|
||||
error: err.error || err.message || '数据格式错误',
|
||||
}));
|
||||
errorMessage = `导入失败,共发现 ${errorDetails.length} 处数据错误`;
|
||||
} else if (data.message) {
|
||||
@@ -352,23 +421,24 @@ function ConsumableManagement() {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setImportResult({
|
||||
success: false,
|
||||
total: 0,
|
||||
imported: 0,
|
||||
failed: 0,
|
||||
errors: errorDetails,
|
||||
message: errorMessage
|
||||
message: errorMessage,
|
||||
});
|
||||
|
||||
|
||||
message.error(errorMessage);
|
||||
console.error('导入耗材失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const template = '耗材ID,名称,分类,单位,当前库存,最小库存,最大库存,单价,供应商,存放位置,描述,状态\n,测试耗材,办公用品,个,100,10,500,5.00,XX公司,A柜-01层,测试数据,active';
|
||||
const template =
|
||||
'耗材ID,名称,分类,单位,当前库存,最小库存,最大库存,单价,供应商,存放位置,描述,状态\n,测试耗材,办公用品,个,100,10,500,5.00,XX公司,A柜-01层,测试数据,active';
|
||||
const blob = new Blob([template], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
@@ -380,163 +450,208 @@ function ConsumableManagement() {
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const showStockModal = useCallback((record, type) => {
|
||||
setStockRecord(record);
|
||||
setStockType(type);
|
||||
stockForm.setFieldsValue({
|
||||
consumableId: record.consumableId,
|
||||
consumableName: record.name,
|
||||
quantity: 1,
|
||||
reason: '',
|
||||
notes: ''
|
||||
});
|
||||
setStockModalVisible(true);
|
||||
}, [stockForm]);
|
||||
const showStockModal = useCallback(
|
||||
(record, type) => {
|
||||
setStockRecord(record);
|
||||
setStockType(type);
|
||||
stockForm.setFieldsValue({
|
||||
consumableId: record.consumableId,
|
||||
consumableName: record.name,
|
||||
quantity: 1,
|
||||
reason: '',
|
||||
notes: '',
|
||||
});
|
||||
setStockModalVisible(true);
|
||||
},
|
||||
[stockForm]
|
||||
);
|
||||
|
||||
const handleStockCancel = useCallback(() => {
|
||||
setStockModalVisible(false);
|
||||
setStockRecord(null);
|
||||
}, []);
|
||||
|
||||
const handleStockSubmit = useCallback(async (values) => {
|
||||
try {
|
||||
const response = await axios.post('/api/consumables/quick-inout', {
|
||||
consumableId: stockRecord.consumableId,
|
||||
type: stockType,
|
||||
quantity: values.quantity,
|
||||
operator: values.operator || '系统管理员',
|
||||
reason: values.reason,
|
||||
notes: values.notes
|
||||
});
|
||||
message.success(`${stockType === 'in' ? '入库' : '出库'}操作成功`);
|
||||
setStockModalVisible(false);
|
||||
fetchConsumables();
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.error || `${stockType === 'in' ? '入库' : '出库'}操作失败`);
|
||||
console.error('操作失败:', error);
|
||||
}
|
||||
}, [stockRecord, stockType, fetchConsumables]);
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '单位',
|
||||
dataIndex: 'unit',
|
||||
key: 'unit',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '当前库存',
|
||||
dataIndex: 'currentStock',
|
||||
key: 'currentStock',
|
||||
width: 100,
|
||||
render: (value, record) => {
|
||||
const isLow = value <= record.minStock;
|
||||
return (
|
||||
<span style={{ color: isLow ? '#ff4d4f' : '#52c41a', fontWeight: 'bold' }}>
|
||||
{value}
|
||||
</span>
|
||||
const handleStockSubmit = useCallback(
|
||||
async values => {
|
||||
try {
|
||||
const response = await axios.post('/api/consumables/quick-inout', {
|
||||
consumableId: stockRecord.consumableId,
|
||||
type: stockType,
|
||||
quantity: values.quantity,
|
||||
operator: values.operator || '系统管理员',
|
||||
reason: values.reason,
|
||||
notes: values.notes,
|
||||
});
|
||||
message.success(`${stockType === 'in' ? '入库' : '出库'}操作成功`);
|
||||
setStockModalVisible(false);
|
||||
fetchConsumables();
|
||||
} catch (error) {
|
||||
message.error(
|
||||
error.response?.data?.error || `${stockType === 'in' ? '入库' : '出库'}操作失败`
|
||||
);
|
||||
console.error('操作失败:', error);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '最小库存',
|
||||
dataIndex: 'minStock',
|
||||
key: 'minStock',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '最大库存',
|
||||
dataIndex: 'maxStock',
|
||||
key: 'maxStock',
|
||||
width: 100,
|
||||
render: (value) => value === 0 || value === null || value === undefined ? '无限制' : value
|
||||
},
|
||||
{
|
||||
title: '单价(元)',
|
||||
dataIndex: 'unitPrice',
|
||||
key: 'unitPrice',
|
||||
width: 100,
|
||||
render: (value) => `¥${parseFloat(value || 0).toFixed(2)}`
|
||||
},
|
||||
{
|
||||
title: '供应商',
|
||||
dataIndex: 'supplier',
|
||||
key: 'supplier',
|
||||
width: 150,
|
||||
render: (value) => value || '-'
|
||||
},
|
||||
{
|
||||
title: '位置',
|
||||
dataIndex: 'location',
|
||||
key: 'location',
|
||||
width: 120,
|
||||
render: (value) => value || '-'
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
width: 200,
|
||||
render: (value) => value || '-',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (value) => (
|
||||
<span style={{ color: value === 'active' ? '#52c41a' : '#ff4d4f' }}>
|
||||
{value === 'active' ? '启用' : '停用'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button type="primary" icon={<EditOutlined />} size="small" onClick={() => showModal(record)}>编辑</Button>
|
||||
<Button type="default" icon={<InboxOutlined />} size="small" style={{ background: '#f6ffed', borderColor: '#b7eb8f', color: '#52c41a' }} onClick={() => showStockModal(record, 'in')}>入库</Button>
|
||||
<Button type="default" icon={<ExportOutlined />} size="small" style={{ background: '#fff2f0', borderColor: '#ffccc7', color: '#ff4d4f' }} onClick={() => showStockModal(record, 'out')}>出库</Button>
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(record.consumableId)}>
|
||||
<Button danger icon={<DeleteOutlined />} size="small">删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
], [showModal, showStockModal, handleDelete]);
|
||||
[stockRecord, stockType, fetchConsumables]
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '单位',
|
||||
dataIndex: 'unit',
|
||||
key: 'unit',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '当前库存',
|
||||
dataIndex: 'currentStock',
|
||||
key: 'currentStock',
|
||||
width: 100,
|
||||
render: (value, record) => {
|
||||
const isLow = value <= record.minStock;
|
||||
return (
|
||||
<span style={{ color: isLow ? '#ff4d4f' : '#52c41a', fontWeight: 'bold' }}>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '最小库存',
|
||||
dataIndex: 'minStock',
|
||||
key: 'minStock',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '最大库存',
|
||||
dataIndex: 'maxStock',
|
||||
key: 'maxStock',
|
||||
width: 100,
|
||||
render: value => (value === 0 || value === null || value === undefined ? '无限制' : value),
|
||||
},
|
||||
{
|
||||
title: '单价(元)',
|
||||
dataIndex: 'unitPrice',
|
||||
key: 'unitPrice',
|
||||
width: 100,
|
||||
render: value => `¥${parseFloat(value || 0).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '供应商',
|
||||
dataIndex: 'supplier',
|
||||
key: 'supplier',
|
||||
width: 150,
|
||||
render: value => value || '-',
|
||||
},
|
||||
{
|
||||
title: '位置',
|
||||
dataIndex: 'location',
|
||||
key: 'location',
|
||||
width: 120,
|
||||
render: value => value || '-',
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
width: 200,
|
||||
render: value => value || '-',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: value => (
|
||||
<span style={{ color: value === 'active' ? '#52c41a' : '#ff4d4f' }}>
|
||||
{value === 'active' ? '启用' : '停用'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<EditOutlined />}
|
||||
size="small"
|
||||
onClick={() => showModal(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<InboxOutlined />}
|
||||
size="small"
|
||||
style={{ background: '#f6ffed', borderColor: '#b7eb8f', color: '#52c41a' }}
|
||||
onClick={() => showStockModal(record, 'in')}
|
||||
>
|
||||
入库
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<ExportOutlined />}
|
||||
size="small"
|
||||
style={{ background: '#fff2f0', borderColor: '#ffccc7', color: '#ff4d4f' }}
|
||||
onClick={() => showStockModal(record, 'out')}
|
||||
>
|
||||
出库
|
||||
</Button>
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(record.consumableId)}>
|
||||
<Button danger icon={<DeleteOutlined />} size="small">
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[showModal, showStockModal, handleDelete]
|
||||
);
|
||||
|
||||
const previewColumns = [
|
||||
{ title: '名称', dataIndex: '名称', key: 'name', width: 120 },
|
||||
{ title: '分类', dataIndex: '分类', key: 'category', width: 100 },
|
||||
{ title: '单位', dataIndex: '单位', key: 'unit', width: 80 },
|
||||
{ title: '当前库存', dataIndex: '当前库存', key: 'currentStock', width: 90 },
|
||||
{ title: '单价', dataIndex: '单价', key: 'unitPrice', width: 80 }
|
||||
{ title: '单价', dataIndex: '单价', key: 'unitPrice', width: 80 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card title="耗材管理" extra={
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>添加耗材</Button>
|
||||
<Button icon={<ImportOutlined />} onClick={showImportModal}>导入</Button>
|
||||
<Button icon={<ExportOutlined />} onClick={handleExport}>导出</Button>
|
||||
</Space>
|
||||
}>
|
||||
<Card
|
||||
title="耗材管理"
|
||||
extra={
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>
|
||||
添加耗材
|
||||
</Button>
|
||||
<Button icon={<ImportOutlined />} onClick={showImportModal}>
|
||||
导入
|
||||
</Button>
|
||||
<Button icon={<ExportOutlined />} onClick={handleExport}>
|
||||
导出
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Input.Search
|
||||
@@ -548,7 +663,9 @@ function ConsumableManagement() {
|
||||
<Select value={category} onChange={setCategory} style={{ width: 150 }}>
|
||||
<Option value="all">所有分类</Option>
|
||||
{categories.map(cat => (
|
||||
<Option key={cat.id} value={cat.name}>{cat.name}</Option>
|
||||
<Option key={cat.id} value={cat.name}>
|
||||
{cat.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select value={status} onChange={setStatus} style={{ width: 120 }}>
|
||||
@@ -565,7 +682,7 @@ function ConsumableManagement() {
|
||||
rowKey="consumableId"
|
||||
loading={loading}
|
||||
pagination={pagination}
|
||||
onChange={(pagination) => fetchConsumables(pagination.current, pagination.pageSize)}
|
||||
onChange={pagination => fetchConsumables(pagination.current, pagination.pageSize)}
|
||||
scroll={{ x: 1300 }}
|
||||
/>
|
||||
</Card>
|
||||
@@ -581,31 +698,47 @@ function ConsumableManagement() {
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="请输入耗材名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="category" label="分类" rules={[{ required: true, message: '请选择分类' }]}>
|
||||
<Select
|
||||
placeholder="请选择分类"
|
||||
allowClear
|
||||
>
|
||||
<Form.Item
|
||||
name="category"
|
||||
label="分类"
|
||||
rules={[{ required: true, message: '请选择分类' }]}
|
||||
>
|
||||
<Select placeholder="请选择分类" allowClear>
|
||||
{categories.map(cat => (
|
||||
<Option key={cat.id} value={cat.name}>{cat.name}</Option>
|
||||
<Option key={cat.id} value={cat.name}>
|
||||
{cat.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="unit" label="单位" rules={[{ required: true, message: '请输入单位' }]} initialValue="个">
|
||||
<Form.Item
|
||||
name="unit"
|
||||
label="单位"
|
||||
rules={[{ required: true, message: '请输入单位' }]}
|
||||
initialValue="个"
|
||||
>
|
||||
<Input placeholder="如: 个、盒、卷、箱" />
|
||||
</Form.Item>
|
||||
<Space style={{ width: '100%' }}>
|
||||
<Form.Item name="currentStock" label="当前库存" rules={[{ required: true, message: '请输入当前库存' }]}>
|
||||
<Form.Item
|
||||
name="currentStock"
|
||||
label="当前库存"
|
||||
rules={[{ required: true, message: '请输入当前库存' }]}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="minStock" label="最小库存" rules={[{ required: true, message: '请输入最小库存' }]}>
|
||||
<Form.Item
|
||||
name="minStock"
|
||||
label="最小库存"
|
||||
rules={[{ required: true, message: '请输入最小库存' }]}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="最大库存">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Checkbox
|
||||
<Checkbox
|
||||
checked={maxStockUnlimited}
|
||||
onChange={(e) => {
|
||||
onChange={e => {
|
||||
setMaxStockUnlimited(e.target.checked);
|
||||
if (e.target.checked) {
|
||||
form.setFieldsValue({ maxStock: undefined });
|
||||
@@ -615,7 +748,11 @@ function ConsumableManagement() {
|
||||
无限制
|
||||
</Checkbox>
|
||||
{!maxStockUnlimited && (
|
||||
<Form.Item name="maxStock" noStyle rules={[{ required: true, message: '请输入最大库存' }]}>
|
||||
<Form.Item
|
||||
name="maxStock"
|
||||
noStyle
|
||||
rules={[{ required: true, message: '请输入最大库存' }]}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入最大库存" />
|
||||
</Form.Item>
|
||||
)}
|
||||
@@ -623,7 +760,13 @@ function ConsumableManagement() {
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="unitPrice" label="单价(元)">
|
||||
<InputNumber min={0} step={0.01} precision={2} style={{ width: '100%' }} placeholder="请输入单价" />
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={0.01}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="请输入单价"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="supplier" label="供应商">
|
||||
<Input placeholder="请输入供应商" />
|
||||
@@ -642,7 +785,9 @@ function ConsumableManagement() {
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">{editingConsumable ? '更新' : '创建'}</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
{editingConsumable ? '更新' : '创建'}
|
||||
</Button>
|
||||
<Button onClick={handleCancel}>取消</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
@@ -659,20 +804,17 @@ function ConsumableManagement() {
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Card size="small" style={{ background: '#f5f5f5' }}>
|
||||
<Space>
|
||||
<Button icon={<FileExcelOutlined />} onClick={downloadTemplate}>下载模板</Button>
|
||||
<Button icon={<FileExcelOutlined />} onClick={downloadTemplate}>
|
||||
下载模板
|
||||
</Button>
|
||||
<span style={{ color: '#888', fontSize: 12 }}>请下载模板后填写数据再导入</span>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Upload
|
||||
accept=".csv"
|
||||
maxCount={1}
|
||||
beforeUpload={() => false}
|
||||
onChange={handleFileChange}
|
||||
>
|
||||
|
||||
<Upload accept=".csv" maxCount={1} beforeUpload={() => false} onChange={handleFileChange}>
|
||||
<Button icon={<UploadOutlined />}>选择CSV文件</Button>
|
||||
</Upload>
|
||||
|
||||
|
||||
{importPreview.length > 0 && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 'bold' }}>预览 (前10条):</div>
|
||||
@@ -686,13 +828,13 @@ function ConsumableManagement() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Space>
|
||||
<Button onClick={handleImportCancel}>取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleImport}
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleImport}
|
||||
loading={importing}
|
||||
disabled={!importFile}
|
||||
>
|
||||
@@ -717,7 +859,11 @@ function ConsumableManagement() {
|
||||
<Form.Item name="consumableName" label="耗材名称">
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
<Form.Item name="quantity" label="数量" rules={[{ required: true, message: '请输入数量' }]}>
|
||||
<Form.Item
|
||||
name="quantity"
|
||||
label="数量"
|
||||
rules={[{ required: true, message: '请输入数量' }]}
|
||||
>
|
||||
<InputNumber min={1} style={{ width: '100%' }} placeholder="请输入数量" />
|
||||
</Form.Item>
|
||||
<Form.Item name="operator" label="操作人">
|
||||
@@ -731,7 +877,9 @@ function ConsumableManagement() {
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">{stockType === 'in' ? '确认入库' : '确认出库'}</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
{stockType === 'in' ? '确认入库' : '确认出库'}
|
||||
</Button>
|
||||
<Button onClick={handleStockCancel}>取消</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Row, Col, Statistic, Table, Tag, DatePicker, Space, Select, Progress, message, Button } from 'antd';
|
||||
import { InboxOutlined, ExportOutlined, WarningOutlined, DollarOutlined, ShoppingCartOutlined, ExclamationCircleOutlined, PlusOutlined, BarChartOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
DatePicker,
|
||||
Space,
|
||||
Select,
|
||||
Progress,
|
||||
message,
|
||||
Button,
|
||||
} from 'antd';
|
||||
import {
|
||||
InboxOutlined,
|
||||
ExportOutlined,
|
||||
WarningOutlined,
|
||||
DollarOutlined,
|
||||
ShoppingCartOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
PlusOutlined,
|
||||
BarChartOutlined,
|
||||
DownloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
@@ -12,60 +35,60 @@ const designTokens = {
|
||||
primary: {
|
||||
main: '#667eea',
|
||||
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
light: '#8b9ff0'
|
||||
light: '#8b9ff0',
|
||||
},
|
||||
success: {
|
||||
main: '#10b981',
|
||||
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
},
|
||||
warning: {
|
||||
main: '#f59e0b',
|
||||
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)'
|
||||
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
},
|
||||
error: {
|
||||
main: '#ef4444',
|
||||
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)'
|
||||
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
|
||||
},
|
||||
text: {
|
||||
primary: '#1e293b',
|
||||
secondary: '#64748b',
|
||||
tertiary: '#94a3b8',
|
||||
inverse: '#ffffff'
|
||||
inverse: '#ffffff',
|
||||
},
|
||||
background: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#f8fafc',
|
||||
tertiary: '#f1f5f9'
|
||||
tertiary: '#f1f5f9',
|
||||
},
|
||||
border: {
|
||||
light: '#e2e8f0',
|
||||
medium: '#cbd5e1',
|
||||
dark: '#94a3b8'
|
||||
}
|
||||
dark: '#94a3b8',
|
||||
},
|
||||
},
|
||||
shadows: {
|
||||
small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
large: '0 10px 15px -3px rgba(0, 0, 0, 0.1)'
|
||||
large: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
borderRadius: {
|
||||
small: '6px',
|
||||
medium: '10px',
|
||||
large: '16px'
|
||||
large: '16px',
|
||||
},
|
||||
spacing: {
|
||||
xs: '4px',
|
||||
sm: '8px',
|
||||
md: '16px',
|
||||
lg: '24px',
|
||||
xl: '32px'
|
||||
}
|
||||
xl: '32px',
|
||||
},
|
||||
};
|
||||
|
||||
const pageContainerStyle = {
|
||||
minHeight: '100vh',
|
||||
background: designTokens.colors.background.secondary,
|
||||
padding: designTokens.spacing.lg
|
||||
padding: designTokens.spacing.lg,
|
||||
};
|
||||
|
||||
const headerStyle = {
|
||||
@@ -74,7 +97,7 @@ const headerStyle = {
|
||||
background: designTokens.colors.background.primary,
|
||||
borderRadius: designTokens.borderRadius.large,
|
||||
boxShadow: designTokens.shadows.small,
|
||||
border: `1px solid ${designTokens.colors.border.light}`
|
||||
border: `1px solid ${designTokens.colors.border.light}`,
|
||||
};
|
||||
|
||||
const titleRowStyle = {
|
||||
@@ -83,7 +106,7 @@ const titleRowStyle = {
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: designTokens.spacing.md,
|
||||
flexWrap: 'wrap',
|
||||
gap: designTokens.spacing.md
|
||||
gap: designTokens.spacing.md,
|
||||
};
|
||||
|
||||
const titleStyle = {
|
||||
@@ -92,13 +115,13 @@ const titleStyle = {
|
||||
gap: designTokens.spacing.sm,
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
color: designTokens.colors.text.primary
|
||||
color: designTokens.colors.text.primary,
|
||||
};
|
||||
|
||||
const statsRowStyle = {
|
||||
display: 'flex',
|
||||
gap: designTokens.spacing.md,
|
||||
flexWrap: 'wrap'
|
||||
flexWrap: 'wrap',
|
||||
};
|
||||
|
||||
const statCardStyle = {
|
||||
@@ -108,22 +131,22 @@ const statCardStyle = {
|
||||
boxShadow: designTokens.shadows.small,
|
||||
border: `1px solid ${designTokens.colors.border.light}`,
|
||||
minWidth: '180px',
|
||||
flex: 1
|
||||
flex: 1,
|
||||
};
|
||||
|
||||
const statCardTextStyle = {
|
||||
fontSize: '13px',
|
||||
color: designTokens.colors.text.secondary,
|
||||
marginBottom: designTokens.spacing.xs
|
||||
marginBottom: designTokens.spacing.xs,
|
||||
};
|
||||
|
||||
const statCardValueStyle = {
|
||||
fontSize: '28px',
|
||||
fontWeight: '600',
|
||||
color: designTokens.colors.text.primary
|
||||
color: designTokens.colors.text.primary,
|
||||
};
|
||||
|
||||
const statCardIconStyle = (color) => ({
|
||||
const statCardIconStyle = color => ({
|
||||
fontSize: '28px',
|
||||
color: color,
|
||||
display: 'flex',
|
||||
@@ -132,7 +155,7 @@ const statCardIconStyle = (color) => ({
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
background: `${color}12`
|
||||
background: `${color}12`,
|
||||
});
|
||||
|
||||
const panelStyle = {
|
||||
@@ -140,7 +163,7 @@ const panelStyle = {
|
||||
borderRadius: designTokens.borderRadius.large,
|
||||
boxShadow: designTokens.shadows.small,
|
||||
border: `1px solid ${designTokens.colors.border.light}`,
|
||||
overflow: 'hidden'
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const panelHeaderStyle = {
|
||||
@@ -148,7 +171,7 @@ const panelHeaderStyle = {
|
||||
borderBottom: `1px solid ${designTokens.colors.border.light}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
justifyContent: 'space-between',
|
||||
};
|
||||
|
||||
const panelTitleStyle = {
|
||||
@@ -157,11 +180,11 @@ const panelTitleStyle = {
|
||||
color: designTokens.colors.text.primary,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: designTokens.spacing.sm
|
||||
gap: designTokens.spacing.sm,
|
||||
};
|
||||
|
||||
const panelBodyStyle = {
|
||||
padding: designTokens.spacing.lg
|
||||
padding: designTokens.spacing.lg,
|
||||
};
|
||||
|
||||
const actionButtonStyle = {
|
||||
@@ -171,7 +194,7 @@ const actionButtonStyle = {
|
||||
fontSize: '13px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: designTokens.spacing.xs
|
||||
gap: designTokens.spacing.xs,
|
||||
};
|
||||
|
||||
const primaryActionStyle = {
|
||||
@@ -179,13 +202,19 @@ const primaryActionStyle = {
|
||||
background: designTokens.colors.primary.gradient,
|
||||
border: 'none',
|
||||
color: '#ffffff',
|
||||
boxShadow: designTokens.shadows.small
|
||||
boxShadow: designTokens.shadows.small,
|
||||
};
|
||||
|
||||
function ConsumableStatistics() {
|
||||
const [summary, setSummary] = useState({ total: 0, lowStock: 0, totalValue: 0, byCategory: [] });
|
||||
const [lowStockItems, setLowStockItems] = useState([]);
|
||||
const [stats, setStats] = useState({ inCount: 0, outCount: 0, inQuantity: 0, outQuantity: 0, recentRecords: [] });
|
||||
const [stats, setStats] = useState({
|
||||
inCount: 0,
|
||||
outCount: 0,
|
||||
inQuantity: 0,
|
||||
outQuantity: 0,
|
||||
recentRecords: [],
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dateRange, setDateRange] = useState([]);
|
||||
const [categoryFilter, setCategoryFilter] = useState(null);
|
||||
@@ -228,11 +257,7 @@ function ConsumableStatistics() {
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([
|
||||
fetchSummary(),
|
||||
fetchLowStock(),
|
||||
fetchInOutStats()
|
||||
]);
|
||||
await Promise.all([fetchSummary(), fetchLowStock(), fetchInOutStats()]);
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
@@ -248,96 +273,97 @@ function ConsumableStatistics() {
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 150,
|
||||
render: (text) => (
|
||||
<span style={{ fontWeight: '500', color: designTokens.colors.text.primary }}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
render: text => (
|
||||
<span style={{ fontWeight: '500', color: designTokens.colors.text.primary }}>{text}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
width: 120,
|
||||
render: (category) => (
|
||||
<Tag style={{
|
||||
border: 'none',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
background: `${designTokens.colors.primary.main}15`,
|
||||
color: designTokens.colors.primary.main,
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
render: category => (
|
||||
<Tag
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
background: `${designTokens.colors.primary.main}15`,
|
||||
color: designTokens.colors.primary.main,
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
{category}
|
||||
</Tag>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '当前库存',
|
||||
dataIndex: 'currentStock',
|
||||
key: 'currentStock',
|
||||
width: 100,
|
||||
render: (value) => (
|
||||
<span style={{
|
||||
color: designTokens.colors.error.main,
|
||||
fontWeight: '600',
|
||||
background: `${designTokens.colors.error.main}12`,
|
||||
padding: `2px ${designTokens.spacing.sm}`,
|
||||
borderRadius: designTokens.borderRadius.small
|
||||
}}>
|
||||
render: value => (
|
||||
<span
|
||||
style={{
|
||||
color: designTokens.colors.error.main,
|
||||
fontWeight: '600',
|
||||
background: `${designTokens.colors.error.main}12`,
|
||||
padding: `2px ${designTokens.spacing.sm}`,
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '最小库存',
|
||||
dataIndex: 'minStock',
|
||||
key: 'minStock',
|
||||
width: 100,
|
||||
render: (value) => (
|
||||
<span style={{ color: designTokens.colors.text.secondary }}>
|
||||
{value}
|
||||
</span>
|
||||
)
|
||||
render: value => <span style={{ color: designTokens.colors.text.secondary }}>{value}</span>,
|
||||
},
|
||||
{
|
||||
title: '单位',
|
||||
dataIndex: 'unit',
|
||||
key: 'unit',
|
||||
width: 80,
|
||||
render: (value) => (
|
||||
<span style={{ color: designTokens.colors.text.tertiary }}>
|
||||
{value}
|
||||
</span>
|
||||
)
|
||||
render: value => <span style={{ color: designTokens.colors.text.tertiary }}>{value}</span>,
|
||||
},
|
||||
{
|
||||
title: '充足率',
|
||||
key: 'rate',
|
||||
width: 140,
|
||||
render: (_, record) => {
|
||||
const rate = Math.min(100, Math.round((record.currentStock / (record.maxStock || 100)) * 100));
|
||||
const rate = Math.min(
|
||||
100,
|
||||
Math.round((record.currentStock / (record.maxStock || 100)) * 100)
|
||||
);
|
||||
const status = rate < 30 ? 'exception' : rate < 60 ? 'active' : 'success';
|
||||
return (
|
||||
<Progress
|
||||
percent={rate}
|
||||
size="small"
|
||||
status={status}
|
||||
strokeColor={status === 'exception' ? designTokens.colors.error.main : status === 'active' ? designTokens.colors.warning.main : designTokens.colors.success.main}
|
||||
strokeColor={
|
||||
status === 'exception'
|
||||
? designTokens.colors.error.main
|
||||
: status === 'active'
|
||||
? designTokens.colors.warning.main
|
||||
: designTokens.colors.success.main
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '供应商',
|
||||
dataIndex: 'supplier',
|
||||
key: 'supplier',
|
||||
width: 120,
|
||||
render: (value) => (
|
||||
<span style={{ color: designTokens.colors.text.secondary }}>
|
||||
{value || '-'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
render: value => (
|
||||
<span style={{ color: designTokens.colors.text.secondary }}>{value || '-'}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const recentColumns = [
|
||||
@@ -346,41 +372,41 @@ function ConsumableStatistics() {
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 170,
|
||||
render: (date) => (
|
||||
render: date => (
|
||||
<span style={{ color: designTokens.colors.text.secondary }}>
|
||||
{dayjs(date).format('YYYY-MM-DD HH:mm')}
|
||||
</span>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '耗材名称',
|
||||
dataIndex: ['Consumable', 'name'],
|
||||
key: 'consumableName',
|
||||
width: 140,
|
||||
render: (text) => (
|
||||
<span style={{ fontWeight: '500' }}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
render: text => <span style={{ fontWeight: '500' }}>{text}</span>,
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 90,
|
||||
render: (type) => (
|
||||
render: type => (
|
||||
<Tag
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
background: type === 'in' ? `${designTokens.colors.success.main}15` : `${designTokens.colors.error.main}15`,
|
||||
color: type === 'in' ? designTokens.colors.success.main : designTokens.colors.error.main,
|
||||
fontWeight: '500'
|
||||
background:
|
||||
type === 'in'
|
||||
? `${designTokens.colors.success.main}15`
|
||||
: `${designTokens.colors.error.main}15`,
|
||||
color:
|
||||
type === 'in' ? designTokens.colors.success.main : designTokens.colors.error.main,
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
{type === 'in' ? '入库' : '出库'}
|
||||
</Tag>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '数量',
|
||||
@@ -388,36 +414,38 @@ function ConsumableStatistics() {
|
||||
key: 'quantity',
|
||||
width: 100,
|
||||
render: (value, record) => (
|
||||
<span style={{
|
||||
color: record.type === 'in' ? designTokens.colors.success.main : designTokens.colors.error.main,
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{record.type === 'in' ? '+' : '-'}{value}
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
record.type === 'in'
|
||||
? designTokens.colors.success.main
|
||||
: designTokens.colors.error.main,
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
{record.type === 'in' ? '+' : '-'}
|
||||
{value}
|
||||
</span>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
dataIndex: 'operator',
|
||||
key: 'operator',
|
||||
width: 100,
|
||||
render: (value) => (
|
||||
<span style={{ color: designTokens.colors.text.secondary }}>
|
||||
{value || '-'}
|
||||
</span>
|
||||
)
|
||||
render: value => (
|
||||
<span style={{ color: designTokens.colors.text.secondary }}>{value || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '原因',
|
||||
dataIndex: 'reason',
|
||||
key: 'reason',
|
||||
width: 150,
|
||||
render: (value) => (
|
||||
<span style={{ color: designTokens.colors.text.secondary }}>
|
||||
{value || '-'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
render: value => (
|
||||
<span style={{ color: designTokens.colors.text.secondary }}>{value || '-'}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const categories = summary.byCategory?.map(item => item.category) || [];
|
||||
@@ -441,7 +469,9 @@ function ConsumableStatistics() {
|
||||
onChange={setCategoryFilter}
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<Option key={cat} value={cat}>{cat}</Option>
|
||||
<Option key={cat} value={cat}>
|
||||
{cat}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<RangePicker
|
||||
@@ -468,7 +498,9 @@ function ConsumableStatistics() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ ...statCardStyle, borderLeft: `4px solid ${designTokens.colors.error.main}` }}>
|
||||
<div
|
||||
style={{ ...statCardStyle, borderLeft: `4px solid ${designTokens.colors.error.main}` }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: designTokens.spacing.md }}>
|
||||
<div style={statCardIconStyle(designTokens.colors.error.main)}>
|
||||
<WarningOutlined />
|
||||
@@ -482,7 +514,12 @@ function ConsumableStatistics() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ ...statCardStyle, borderLeft: `4px solid ${designTokens.colors.success.main}` }}>
|
||||
<div
|
||||
style={{
|
||||
...statCardStyle,
|
||||
borderLeft: `4px solid ${designTokens.colors.success.main}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: designTokens.spacing.md }}>
|
||||
<div style={statCardIconStyle(designTokens.colors.success.main)}>
|
||||
<DollarOutlined />
|
||||
@@ -496,15 +533,35 @@ function ConsumableStatistics() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ ...statCardStyle, borderLeft: `4px solid ${netQuantity >= 0 ? designTokens.colors.success.main : designTokens.colors.error.main}` }}>
|
||||
<div
|
||||
style={{
|
||||
...statCardStyle,
|
||||
borderLeft: `4px solid ${netQuantity >= 0 ? designTokens.colors.success.main : designTokens.colors.error.main}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: designTokens.spacing.md }}>
|
||||
<div style={statCardIconStyle(netQuantity >= 0 ? designTokens.colors.success.main : designTokens.colors.error.main)}>
|
||||
<div
|
||||
style={statCardIconStyle(
|
||||
netQuantity >= 0
|
||||
? designTokens.colors.success.main
|
||||
: designTokens.colors.error.main
|
||||
)}
|
||||
>
|
||||
<InboxOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<div style={statCardTextStyle}>净入库量</div>
|
||||
<div style={{ ...statCardValueStyle, color: netQuantity >= 0 ? designTokens.colors.success.main : designTokens.colors.error.main }}>
|
||||
{netQuantity >= 0 ? '+' : ''}{netQuantity}
|
||||
<div
|
||||
style={{
|
||||
...statCardValueStyle,
|
||||
color:
|
||||
netQuantity >= 0
|
||||
? designTokens.colors.success.main
|
||||
: designTokens.colors.error.main,
|
||||
}}
|
||||
>
|
||||
{netQuantity >= 0 ? '+' : ''}
|
||||
{netQuantity}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -525,39 +582,81 @@ function ConsumableStatistics() {
|
||||
<div style={{ padding: designTokens.spacing.lg }}>
|
||||
<Row gutter={designTokens.spacing.md}>
|
||||
<Col span={12}>
|
||||
<div style={{
|
||||
background: `${designTokens.colors.success.main}08`,
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
padding: designTokens.spacing.lg,
|
||||
textAlign: 'center',
|
||||
border: `1px solid ${designTokens.colors.success.main}30`
|
||||
}}>
|
||||
<div style={{ fontSize: '13px', color: designTokens.colors.text.secondary, marginBottom: designTokens.spacing.sm }}>
|
||||
<div
|
||||
style={{
|
||||
background: `${designTokens.colors.success.main}08`,
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
padding: designTokens.spacing.lg,
|
||||
textAlign: 'center',
|
||||
border: `1px solid ${designTokens.colors.success.main}30`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: designTokens.colors.text.secondary,
|
||||
marginBottom: designTokens.spacing.sm,
|
||||
}}
|
||||
>
|
||||
入库次数
|
||||
</div>
|
||||
<div style={{ fontSize: '32px', fontWeight: '600', color: designTokens.colors.success.main }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '32px',
|
||||
fontWeight: '600',
|
||||
color: designTokens.colors.success.main,
|
||||
}}
|
||||
>
|
||||
{stats.inCount}
|
||||
</div>
|
||||
<div style={{ fontSize: '20px', fontWeight: '500', color: designTokens.colors.success.main, marginTop: designTokens.spacing.xs }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '500',
|
||||
color: designTokens.colors.success.main,
|
||||
marginTop: designTokens.spacing.xs,
|
||||
}}
|
||||
>
|
||||
+{stats.inQuantity}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div style={{
|
||||
background: `${designTokens.colors.error.main}08`,
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
padding: designTokens.spacing.lg,
|
||||
textAlign: 'center',
|
||||
border: `1px solid ${designTokens.colors.error.main}30`
|
||||
}}>
|
||||
<div style={{ fontSize: '13px', color: designTokens.colors.text.secondary, marginBottom: designTokens.spacing.sm }}>
|
||||
<div
|
||||
style={{
|
||||
background: `${designTokens.colors.error.main}08`,
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
padding: designTokens.spacing.lg,
|
||||
textAlign: 'center',
|
||||
border: `1px solid ${designTokens.colors.error.main}30`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: designTokens.colors.text.secondary,
|
||||
marginBottom: designTokens.spacing.sm,
|
||||
}}
|
||||
>
|
||||
出库次数
|
||||
</div>
|
||||
<div style={{ fontSize: '32px', fontWeight: '600', color: designTokens.colors.error.main }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '32px',
|
||||
fontWeight: '600',
|
||||
color: designTokens.colors.error.main,
|
||||
}}
|
||||
>
|
||||
{stats.outCount}
|
||||
</div>
|
||||
<div style={{ fontSize: '20px', fontWeight: '500', color: designTokens.colors.error.main, marginTop: designTokens.spacing.xs }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '500',
|
||||
color: designTokens.colors.error.main,
|
||||
marginTop: designTokens.spacing.xs,
|
||||
}}
|
||||
>
|
||||
-{stats.outQuantity}
|
||||
</div>
|
||||
</div>
|
||||
@@ -579,7 +678,14 @@ function ConsumableStatistics() {
|
||||
<div style={{ padding: designTokens.spacing.lg }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: designTokens.spacing.sm }}>
|
||||
{summary.byCategory?.map((item, index) => {
|
||||
const colors = [designTokens.colors.primary.main, designTokens.colors.success.main, designTokens.colors.warning.main, '#8b5cf6', '#06b6d4', '#ec4899'];
|
||||
const colors = [
|
||||
designTokens.colors.primary.main,
|
||||
designTokens.colors.success.main,
|
||||
designTokens.colors.warning.main,
|
||||
'#8b5cf6',
|
||||
'#06b6d4',
|
||||
'#ec4899',
|
||||
];
|
||||
const color = colors[index % colors.length];
|
||||
return (
|
||||
<div
|
||||
@@ -591,15 +697,17 @@ function ConsumableStatistics() {
|
||||
border: `1px solid ${color}30`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: designTokens.spacing.sm
|
||||
gap: designTokens.spacing.sm,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: color
|
||||
}} />
|
||||
<div
|
||||
style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: color,
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: designTokens.colors.text.secondary, fontSize: '13px' }}>
|
||||
{item.category}
|
||||
</span>
|
||||
@@ -610,7 +718,12 @@ function ConsumableStatistics() {
|
||||
);
|
||||
})}
|
||||
{(!summary.byCategory || summary.byCategory.length === 0) && (
|
||||
<div style={{ color: designTokens.colors.text.tertiary, padding: designTokens.spacing.lg }}>
|
||||
<div
|
||||
style={{
|
||||
color: designTokens.colors.text.tertiary,
|
||||
padding: designTokens.spacing.lg,
|
||||
}}
|
||||
>
|
||||
暂无分类数据
|
||||
</div>
|
||||
)}
|
||||
@@ -628,13 +741,15 @@ function ConsumableStatistics() {
|
||||
<ExclamationCircleOutlined style={{ color: designTokens.colors.error.main }} />
|
||||
低库存预警
|
||||
</div>
|
||||
<Tag style={{
|
||||
border: 'none',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
background: `${designTokens.colors.error.main}15`,
|
||||
color: designTokens.colors.error.main,
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
<Tag
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
background: `${designTokens.colors.error.main}15`,
|
||||
color: designTokens.colors.error.main,
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
{lowStockItems.length}项
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
+682
-395
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,32 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Select, message, Card, Space, InputNumber, Switch, Tag, Statistic, Tooltip } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, AppstoreOutlined, FontSizeOutlined, NumberOutlined, CheckCircleOutlined, CalendarOutlined, FileTextOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
message,
|
||||
Card,
|
||||
Space,
|
||||
InputNumber,
|
||||
Switch,
|
||||
Tag,
|
||||
Statistic,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
AppstoreOutlined,
|
||||
FontSizeOutlined,
|
||||
NumberOutlined,
|
||||
CheckCircleOutlined,
|
||||
CalendarOutlined,
|
||||
FileTextOutlined,
|
||||
LockOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
|
||||
const { Option = Select.Option } = Select;
|
||||
@@ -11,35 +37,35 @@ const designTokens = {
|
||||
main: '#667eea',
|
||||
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
light: '#8b9ff0',
|
||||
dark: '#4f5db8'
|
||||
dark: '#4f5db8',
|
||||
},
|
||||
success: {
|
||||
main: '#10b981',
|
||||
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
},
|
||||
warning: {
|
||||
main: '#f59e0b',
|
||||
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)'
|
||||
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
},
|
||||
error: {
|
||||
main: '#ef4444',
|
||||
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)'
|
||||
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
|
||||
},
|
||||
text: {
|
||||
primary: '#1e293b',
|
||||
secondary: '#64748b',
|
||||
tertiary: '#94a3b8',
|
||||
inverse: '#ffffff'
|
||||
inverse: '#ffffff',
|
||||
},
|
||||
background: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#f8fafc',
|
||||
tertiary: '#f1f5f9'
|
||||
tertiary: '#f1f5f9',
|
||||
},
|
||||
border: {
|
||||
light: '#e2e8f0',
|
||||
medium: '#cbd5e1',
|
||||
dark: '#94a3b8'
|
||||
dark: '#94a3b8',
|
||||
},
|
||||
fieldType: {
|
||||
string: '#3b82f6',
|
||||
@@ -47,44 +73,44 @@ const designTokens = {
|
||||
boolean: '#f59e0b',
|
||||
select: '#8b5cf6',
|
||||
date: '#06b6d4',
|
||||
textarea: '#64748b'
|
||||
}
|
||||
textarea: '#64748b',
|
||||
},
|
||||
},
|
||||
shadows: {
|
||||
small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
|
||||
large: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)',
|
||||
glow: '0 0 20px rgba(102, 126, 234, 0.15)'
|
||||
glow: '0 0 20px rgba(102, 126, 234, 0.15)',
|
||||
},
|
||||
borderRadius: {
|
||||
small: '6px',
|
||||
medium: '10px',
|
||||
large: '16px'
|
||||
large: '16px',
|
||||
},
|
||||
transitions: {
|
||||
fast: '150ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
normal: '300ms cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
normal: '300ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
spacing: {
|
||||
xs: '4px',
|
||||
sm: '8px',
|
||||
md: '16px',
|
||||
lg: '24px',
|
||||
xl: '32px'
|
||||
}
|
||||
xl: '32px',
|
||||
},
|
||||
};
|
||||
|
||||
const pageContainerStyle = {
|
||||
minHeight: '100vh',
|
||||
background: designTokens.colors.background.secondary,
|
||||
padding: designTokens.spacing.lg
|
||||
padding: designTokens.spacing.lg,
|
||||
};
|
||||
|
||||
const titleRowStyle = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: designTokens.spacing.lg
|
||||
marginBottom: designTokens.spacing.lg,
|
||||
};
|
||||
|
||||
const titleStyle = {
|
||||
@@ -93,7 +119,7 @@ const titleStyle = {
|
||||
gap: designTokens.spacing.sm,
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
color: designTokens.colors.text.primary
|
||||
color: designTokens.colors.text.primary,
|
||||
};
|
||||
|
||||
const actionButtonStyle = {
|
||||
@@ -103,7 +129,7 @@ const actionButtonStyle = {
|
||||
fontSize: '13px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: designTokens.spacing.xs
|
||||
gap: designTokens.spacing.xs,
|
||||
};
|
||||
|
||||
const primaryActionStyle = {
|
||||
@@ -111,7 +137,7 @@ const primaryActionStyle = {
|
||||
background: designTokens.colors.primary.gradient,
|
||||
border: 'none',
|
||||
color: '#ffffff',
|
||||
boxShadow: designTokens.shadows.small
|
||||
boxShadow: designTokens.shadows.small,
|
||||
};
|
||||
|
||||
const tableCardStyle = {
|
||||
@@ -119,34 +145,34 @@ const tableCardStyle = {
|
||||
borderRadius: designTokens.borderRadius.large,
|
||||
boxShadow: designTokens.shadows.small,
|
||||
border: `1px solid ${designTokens.colors.border.light}`,
|
||||
overflow: 'hidden'
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const tableStyle = {
|
||||
background: designTokens.colors.background.primary
|
||||
background: designTokens.colors.background.primary,
|
||||
};
|
||||
|
||||
const titleIconStyle = {
|
||||
color: designTokens.colors.primary.main
|
||||
color: designTokens.colors.primary.main,
|
||||
};
|
||||
|
||||
const modalTitleStyle = {
|
||||
fontWeight: '600'
|
||||
fontWeight: '600',
|
||||
};
|
||||
|
||||
const formLabelStyle = {
|
||||
fontWeight: '500'
|
||||
fontWeight: '500',
|
||||
};
|
||||
|
||||
const tableCellStyle = {
|
||||
fontWeight: '500',
|
||||
color: designTokens.colors.text.primary
|
||||
color: designTokens.colors.text.primary,
|
||||
};
|
||||
|
||||
const typeTagStyle = {
|
||||
border: 'none',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
fontWeight: '500'
|
||||
fontWeight: '500',
|
||||
};
|
||||
|
||||
const orderBadgeStyle = {
|
||||
@@ -154,44 +180,44 @@ const orderBadgeStyle = {
|
||||
padding: '2px 8px',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
fontWeight: '500',
|
||||
};
|
||||
|
||||
const editButtonStyle = {
|
||||
color: designTokens.colors.primary.main,
|
||||
height: '28px',
|
||||
padding: '0 8px'
|
||||
padding: '0 8px',
|
||||
};
|
||||
|
||||
const deleteButtonStyle = {
|
||||
height: '28px',
|
||||
padding: '0 8px'
|
||||
padding: '0 8px',
|
||||
};
|
||||
|
||||
const formRowStyle = {
|
||||
display: 'flex',
|
||||
gap: designTokens.spacing.md
|
||||
gap: designTokens.spacing.md,
|
||||
};
|
||||
|
||||
const formItemFlexStyle = {
|
||||
flex: 1
|
||||
flex: 1,
|
||||
};
|
||||
|
||||
const textAreaStyle = {
|
||||
fontFamily: 'monospace'
|
||||
fontFamily: 'monospace',
|
||||
};
|
||||
|
||||
const modalBodyStyle = {
|
||||
padding: designTokens.spacing.lg
|
||||
padding: designTokens.spacing.lg,
|
||||
};
|
||||
|
||||
const formActionsStyle = {
|
||||
marginBottom: 0,
|
||||
textAlign: 'right'
|
||||
textAlign: 'right',
|
||||
};
|
||||
|
||||
const modalStyle = {
|
||||
borderRadius: designTokens.borderRadius.large
|
||||
borderRadius: designTokens.borderRadius.large,
|
||||
};
|
||||
|
||||
const FIELD_TYPE_MAP = {
|
||||
@@ -200,7 +226,7 @@ const FIELD_TYPE_MAP = {
|
||||
boolean: { text: '布尔值', color: designTokens.colors.fieldType.boolean },
|
||||
select: { text: '下拉选择', color: designTokens.colors.fieldType.select },
|
||||
date: { text: '日期', color: designTokens.colors.fieldType.date },
|
||||
textarea: { text: '多行文本', color: designTokens.colors.fieldType.textarea }
|
||||
textarea: { text: '多行文本', color: designTokens.colors.fieldType.textarea },
|
||||
};
|
||||
|
||||
const FIELD_TYPE_OPTIONS = [
|
||||
@@ -209,7 +235,7 @@ const FIELD_TYPE_OPTIONS = [
|
||||
{ value: 'boolean', label: '布尔值' },
|
||||
{ value: 'select', label: '下拉选择' },
|
||||
{ value: 'date', label: '日期' },
|
||||
{ value: 'textarea', label: '多行文本' }
|
||||
{ value: 'textarea', label: '多行文本' },
|
||||
];
|
||||
|
||||
function DeviceFieldManagement() {
|
||||
@@ -224,7 +250,7 @@ function DeviceFieldManagement() {
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条 / 共 ${total} 条`
|
||||
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条 / 共 ${total} 条`,
|
||||
});
|
||||
|
||||
const fetchFields = async () => {
|
||||
@@ -249,7 +275,7 @@ function DeviceFieldManagement() {
|
||||
if (field) {
|
||||
const fieldData = {
|
||||
...field,
|
||||
options: field.options ? JSON.stringify(field.options, null, 2) : ''
|
||||
options: field.options ? JSON.stringify(field.options, null, 2) : '',
|
||||
};
|
||||
form.setFieldsValue(fieldData);
|
||||
} else {
|
||||
@@ -263,11 +289,11 @@ function DeviceFieldManagement() {
|
||||
setEditingField(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
const handleSubmit = async values => {
|
||||
try {
|
||||
const fieldData = {
|
||||
...values,
|
||||
options: values.options ? JSON.parse(values.options) : null
|
||||
options: values.options ? JSON.parse(values.options) : null,
|
||||
};
|
||||
|
||||
if (editingField) {
|
||||
@@ -287,7 +313,7 @@ function DeviceFieldManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (fieldId) => {
|
||||
const handleDelete = async fieldId => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个字段吗?',
|
||||
@@ -303,126 +329,149 @@ function DeviceFieldManagement() {
|
||||
message.error('字段删除失败');
|
||||
console.error('字段删除失败:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getFieldTypeIcon = (type) => {
|
||||
const getFieldTypeIcon = type => {
|
||||
const iconMap = {
|
||||
string: <FontSizeOutlined />,
|
||||
number: <NumberOutlined />,
|
||||
boolean: <CheckCircleOutlined />,
|
||||
select: <AppstoreOutlined />,
|
||||
date: <CalendarOutlined />,
|
||||
textarea: <FileTextOutlined />
|
||||
textarea: <FileTextOutlined />,
|
||||
};
|
||||
return iconMap[type] || <FontSizeOutlined />;
|
||||
};
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '字段名称',
|
||||
dataIndex: 'fieldName',
|
||||
key: 'fieldName',
|
||||
width: 150,
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
<span style={tableCellStyle}>{text}</span>
|
||||
{record.isSystem && (
|
||||
<Tooltip title="系统字段,不可删除">
|
||||
<LockOutlined style={{ color: '#f59e0b', fontSize: '14px' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '显示名称',
|
||||
dataIndex: 'displayName',
|
||||
key: 'displayName',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '字段类型',
|
||||
dataIndex: 'fieldType',
|
||||
key: 'fieldType',
|
||||
width: 110,
|
||||
render: (type) => {
|
||||
const config = FIELD_TYPE_MAP[type] || { text: type, color: designTokens.colors.text.tertiary };
|
||||
return (
|
||||
<Tag style={{ ...typeTagStyle, background: `${config.color}15`, color: config.color }}>
|
||||
{getFieldTypeIcon(type)}
|
||||
<span style={{ marginLeft: '4px' }}>{config.text}</span>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '必填',
|
||||
dataIndex: 'required',
|
||||
key: 'required',
|
||||
width: 80,
|
||||
render: (required) => (
|
||||
<span style={{
|
||||
color: required ? designTokens.colors.success.main : designTokens.colors.text.tertiary,
|
||||
...tableCellStyle
|
||||
}}>
|
||||
{required ? '是' : '否'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '可见',
|
||||
dataIndex: 'visible',
|
||||
key: 'visible',
|
||||
width: 80,
|
||||
render: (visible) => (
|
||||
<span style={{
|
||||
color: visible ? designTokens.colors.primary.main : designTokens.colors.text.tertiary,
|
||||
...tableCellStyle
|
||||
}}>
|
||||
{visible ? '是' : '否'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '顺序',
|
||||
dataIndex: 'order',
|
||||
key: 'order',
|
||||
width: 80,
|
||||
render: (order) => <span style={orderBadgeStyle}>{order}</span>
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 160,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => showModal(record)} style={editButtonStyle}>
|
||||
编辑
|
||||
</Button>
|
||||
{record.isSystem ? (
|
||||
<Tooltip title="系统字段不可删除">
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled
|
||||
style={{ ...deleteButtonStyle, opacity: 0.3, cursor: 'not-allowed' }}
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '字段名称',
|
||||
dataIndex: 'fieldName',
|
||||
key: 'fieldName',
|
||||
width: 150,
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
<span style={tableCellStyle}>{text}</span>
|
||||
{record.isSystem && (
|
||||
<Tooltip title="系统字段,不可删除">
|
||||
<LockOutlined style={{ color: '#f59e0b', fontSize: '14px' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '显示名称',
|
||||
dataIndex: 'displayName',
|
||||
key: 'displayName',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '字段类型',
|
||||
dataIndex: 'fieldType',
|
||||
key: 'fieldType',
|
||||
width: 110,
|
||||
render: type => {
|
||||
const config = FIELD_TYPE_MAP[type] || {
|
||||
text: type,
|
||||
color: designTokens.colors.text.tertiary,
|
||||
};
|
||||
return (
|
||||
<Tag style={{ ...typeTagStyle, background: `${config.color}15`, color: config.color }}>
|
||||
{getFieldTypeIcon(type)}
|
||||
<span style={{ marginLeft: '4px' }}>{config.text}</span>
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '必填',
|
||||
dataIndex: 'required',
|
||||
key: 'required',
|
||||
width: 80,
|
||||
render: required => (
|
||||
<span
|
||||
style={{
|
||||
color: required
|
||||
? designTokens.colors.success.main
|
||||
: designTokens.colors.text.tertiary,
|
||||
...tableCellStyle,
|
||||
}}
|
||||
>
|
||||
{required ? '是' : '否'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '可见',
|
||||
dataIndex: 'visible',
|
||||
key: 'visible',
|
||||
width: 80,
|
||||
render: visible => (
|
||||
<span
|
||||
style={{
|
||||
color: visible ? designTokens.colors.primary.main : designTokens.colors.text.tertiary,
|
||||
...tableCellStyle,
|
||||
}}
|
||||
>
|
||||
{visible ? '是' : '否'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '顺序',
|
||||
dataIndex: 'order',
|
||||
key: 'order',
|
||||
width: 80,
|
||||
render: order => <span style={orderBadgeStyle}>{order}</span>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 160,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => showModal(record)}
|
||||
style={editButtonStyle}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
{record.isSystem ? (
|
||||
<Tooltip title="系统字段不可删除">
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled
|
||||
style={{ ...deleteButtonStyle, opacity: 0.3, cursor: 'not-allowed' }}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDelete(record.fieldId)}
|
||||
style={deleteButtonStyle}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.fieldId)} style={deleteButtonStyle}>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={pageContainerStyle}>
|
||||
@@ -431,7 +480,12 @@ function DeviceFieldManagement() {
|
||||
<AppstoreOutlined style={titleIconStyle} />
|
||||
设备字段管理
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()} style={primaryActionStyle}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => showModal()}
|
||||
style={primaryActionStyle}
|
||||
>
|
||||
添加字段
|
||||
</Button>
|
||||
</div>
|
||||
@@ -443,11 +497,11 @@ function DeviceFieldManagement() {
|
||||
rowKey="fieldId"
|
||||
loading={loading}
|
||||
pagination={pagination}
|
||||
onChange={(newPagination) => {
|
||||
onChange={newPagination => {
|
||||
setPagination({
|
||||
...pagination,
|
||||
current: newPagination.current,
|
||||
pageSize: newPagination.pageSize
|
||||
pageSize: newPagination.pageSize,
|
||||
});
|
||||
}}
|
||||
scroll={{ x: 900 }}
|
||||
@@ -488,7 +542,9 @@ function DeviceFieldManagement() {
|
||||
>
|
||||
<Select placeholder="请选择字段类型">
|
||||
{FIELD_TYPE_OPTIONS.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
|
||||
<Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
@@ -526,13 +582,19 @@ function DeviceFieldManagement() {
|
||||
label={<span style={formLabelStyle}>选项配置(JSON格式)</span>}
|
||||
tooltip="格式示例:[{value: 'option1', label: '选项1'}],仅下拉选择类型需要配置"
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder="请输入JSON格式的选项配置,使用单引号" style={textAreaStyle} />
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder="请输入JSON格式的选项配置,使用单引号"
|
||||
style={textAreaStyle}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={formActionsStyle}>
|
||||
<Space>
|
||||
<Button onClick={handleCancel}>取消</Button>
|
||||
<Button type="primary" htmlType="submit">确定</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
确定
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
+1194
-586
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ import {
|
||||
MailOutlined,
|
||||
PhoneOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
RobotOutlined
|
||||
RobotOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
@@ -40,7 +40,7 @@ const Login = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onFinishLogin = async (values) => {
|
||||
const onFinishLogin = async values => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await login(values.username, values.password);
|
||||
@@ -61,7 +61,7 @@ const Login = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onFinishUnlock = async (values) => {
|
||||
const onFinishUnlock = async values => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await authAPI.unlock(values);
|
||||
@@ -78,7 +78,7 @@ const Login = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onFinishRegister = async (values) => {
|
||||
const onFinishRegister = async values => {
|
||||
if (values.password !== values.confirmPassword) {
|
||||
message.error('两次输入的密码不一致');
|
||||
return;
|
||||
@@ -91,7 +91,7 @@ const Login = () => {
|
||||
password: values.password,
|
||||
email: values.email,
|
||||
phone: values.phone,
|
||||
realName: values.realName
|
||||
realName: values.realName,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
@@ -123,14 +123,14 @@ const Login = () => {
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #6B8DD6 100%)',
|
||||
padding: '24px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const backgroundDecorationStyle = {
|
||||
position: 'absolute',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(80px)',
|
||||
opacity: '0.3'
|
||||
opacity: '0.3',
|
||||
};
|
||||
|
||||
const cardStyle = {
|
||||
@@ -140,13 +140,13 @@ const Login = () => {
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.25), 0 8px 20px rgba(0,0,0,0.15)',
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)'
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
};
|
||||
|
||||
const headerStyle = {
|
||||
textAlign: 'center',
|
||||
marginBottom: '32px',
|
||||
paddingTop: '8px'
|
||||
paddingTop: '8px',
|
||||
};
|
||||
|
||||
const iconContainerStyle = {
|
||||
@@ -158,7 +158,7 @@ const Login = () => {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 20px',
|
||||
boxShadow: '0 8px 24px rgba(102, 126, 234, 0.4)'
|
||||
boxShadow: '0 8px 24px rgba(102, 126, 234, 0.4)',
|
||||
};
|
||||
|
||||
const titleStyle = {
|
||||
@@ -167,22 +167,22 @@ const Login = () => {
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
marginBottom: '8px'
|
||||
marginBottom: '8px',
|
||||
};
|
||||
|
||||
const subtitleStyle = {
|
||||
fontSize: '14px',
|
||||
color: '#8c8c8c'
|
||||
color: '#8c8c8c',
|
||||
};
|
||||
|
||||
const formStyle = {
|
||||
marginTop: '24px'
|
||||
marginTop: '24px',
|
||||
};
|
||||
|
||||
const inputStyle = {
|
||||
borderRadius: '8px',
|
||||
height: '48px',
|
||||
border: '1px solid #e8e8e8'
|
||||
border: '1px solid #e8e8e8',
|
||||
};
|
||||
|
||||
const submitButtonStyle = {
|
||||
@@ -194,13 +194,13 @@ const Login = () => {
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
border: 'none',
|
||||
boxShadow: '0 4px 15px rgba(102, 126, 234, 0.4)',
|
||||
transition: 'all 0.3s ease'
|
||||
transition: 'all 0.3s ease',
|
||||
};
|
||||
|
||||
const footerStyle = {
|
||||
textAlign: 'center',
|
||||
marginTop: '24px',
|
||||
paddingBottom: '16px'
|
||||
paddingBottom: '16px',
|
||||
};
|
||||
|
||||
const toggleButtonStyle = {
|
||||
@@ -208,32 +208,36 @@ const Login = () => {
|
||||
fontWeight: '500',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
transition: 'all 0.3s ease'
|
||||
transition: 'all 0.3s ease',
|
||||
};
|
||||
|
||||
const inputPrefixStyle = {
|
||||
color: '#667eea',
|
||||
fontSize: '18px'
|
||||
fontSize: '18px',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={{
|
||||
...backgroundDecorationStyle,
|
||||
width: '400px',
|
||||
height: '400px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
top: '-100px',
|
||||
right: '-100px'
|
||||
}} />
|
||||
<div style={{
|
||||
...backgroundDecorationStyle,
|
||||
width: '300px',
|
||||
height: '300px',
|
||||
background: 'linear-gradient(135deg, #764ba2 0%, #6B8DD6 100%)',
|
||||
bottom: '-50px',
|
||||
left: '-50px'
|
||||
}} />
|
||||
<div
|
||||
style={{
|
||||
...backgroundDecorationStyle,
|
||||
width: '400px',
|
||||
height: '400px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
top: '-100px',
|
||||
right: '-100px',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
...backgroundDecorationStyle,
|
||||
width: '300px',
|
||||
height: '300px',
|
||||
background: 'linear-gradient(135deg, #764ba2 0%, #6B8DD6 100%)',
|
||||
bottom: '-50px',
|
||||
left: '-50px',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Card style={cardStyle}>
|
||||
<div style={headerStyle}>
|
||||
@@ -244,8 +248,11 @@ const Login = () => {
|
||||
{isFirstUser ? '创建管理员账户' : unlockMode ? '账户解锁' : 'IDC设备管理系统'}
|
||||
</Title>
|
||||
<Text style={subtitleStyle}>
|
||||
{isFirstUser ? '首次使用,请创建系统管理员账户' :
|
||||
unlockMode ? '输入账户信息以解锁账户' : '安全登录您的账户'}
|
||||
{isFirstUser
|
||||
? '首次使用,请创建系统管理员账户'
|
||||
: unlockMode
|
||||
? '输入账户信息以解锁账户'
|
||||
: '安全登录您的账户'}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -272,7 +279,7 @@ const Login = () => {
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ min: 3, max: 20, message: '用户名长度必须在3-20个字符之间' },
|
||||
{ pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线' }
|
||||
{ pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
@@ -282,10 +289,7 @@ const Login = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="realName"
|
||||
rules={[{ required: true, message: '请输入真实姓名' }]}
|
||||
>
|
||||
<Form.Item name="realName" rules={[{ required: true, message: '请输入真实姓名' }]}>
|
||||
<Input
|
||||
prefix={<SafetyCertificateOutlined style={inputPrefixStyle} />}
|
||||
placeholder="真实姓名"
|
||||
@@ -297,7 +301,7 @@ const Login = () => {
|
||||
name="email"
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' }
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
@@ -307,9 +311,7 @@ const Login = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="phone"
|
||||
>
|
||||
<Form.Item name="phone">
|
||||
<Input
|
||||
prefix={<PhoneOutlined style={inputPrefixStyle} />}
|
||||
placeholder="手机号(可选)"
|
||||
@@ -321,7 +323,7 @@ const Login = () => {
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '密码长度不能少于6个字符' }
|
||||
{ min: 6, message: '密码长度不能少于6个字符' },
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
@@ -362,11 +364,8 @@ const Login = () => {
|
||||
showIcon
|
||||
style={{ marginBottom: '24px', borderRadius: '8px' }}
|
||||
/>
|
||||
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input
|
||||
prefix={<UserOutlined style={inputPrefixStyle} />}
|
||||
placeholder="用户名"
|
||||
@@ -374,10 +373,7 @@ const Login = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined style={inputPrefixStyle} />}
|
||||
placeholder="密码"
|
||||
@@ -387,10 +383,7 @@ const Login = () => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input
|
||||
prefix={<UserOutlined style={inputPrefixStyle} />}
|
||||
placeholder="用户名"
|
||||
@@ -398,10 +391,7 @@ const Login = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined style={inputPrefixStyle} />}
|
||||
placeholder="密码"
|
||||
@@ -412,12 +402,7 @@ const Login = () => {
|
||||
)}
|
||||
|
||||
<Form.Item style={{ marginBottom: '16px', marginTop: '24px' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
style={submitButtonStyle}
|
||||
>
|
||||
<Button type="primary" htmlType="submit" loading={loading} style={submitButtonStyle}>
|
||||
{registerMode ? '立即注册' : unlockMode ? '解 锁' : '登 录'}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
@@ -435,10 +420,10 @@ const Login = () => {
|
||||
type="link"
|
||||
size="small"
|
||||
style={toggleButtonStyle}
|
||||
onMouseEnter={(e) => {
|
||||
onMouseEnter={e => {
|
||||
e.target.style.background = 'rgba(102, 126, 234, 0.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
onMouseLeave={e => {
|
||||
e.target.style.background = 'transparent';
|
||||
}}
|
||||
onClick={() => setUnlockMode(false)}
|
||||
@@ -452,10 +437,10 @@ const Login = () => {
|
||||
type="link"
|
||||
size="small"
|
||||
style={toggleButtonStyle}
|
||||
onMouseEnter={(e) => {
|
||||
onMouseEnter={e => {
|
||||
e.target.style.background = 'rgba(102, 126, 234, 0.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
onMouseLeave={e => {
|
||||
e.target.style.background = 'transparent';
|
||||
}}
|
||||
onClick={() => setRegisterMode(!registerMode)}
|
||||
@@ -466,10 +451,10 @@ const Login = () => {
|
||||
type="link"
|
||||
size="small"
|
||||
style={toggleButtonStyle}
|
||||
onMouseEnter={(e) => {
|
||||
onMouseEnter={e => {
|
||||
e.target.style.background = 'rgba(102, 126, 234, 0.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
onMouseLeave={e => {
|
||||
e.target.style.background = 'transparent';
|
||||
}}
|
||||
onClick={() => setUnlockMode(true)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,48 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Table, Button, Modal, Form, Input, Select, message, Card, Space,
|
||||
InputNumber, Progress, Drawer, Tag, Tooltip, Dropdown, Badge,
|
||||
Row, Col, Statistic, Typography, Empty, Spin, Alert, Checkbox
|
||||
Table,
|
||||
Button,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
message,
|
||||
Card,
|
||||
Space,
|
||||
InputNumber,
|
||||
Progress,
|
||||
Drawer,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Dropdown,
|
||||
Badge,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Typography,
|
||||
Empty,
|
||||
Spin,
|
||||
Alert,
|
||||
Checkbox,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined,
|
||||
SearchOutlined, FilterOutlined, MoreOutlined, EyeOutlined,
|
||||
CloudOutlined, EnvironmentOutlined, DashboardOutlined,
|
||||
ExpandOutlined, CompressOutlined, CheckCircleOutlined,
|
||||
WarningOutlined, SyncOutlined, DeleteFilled
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
MoreOutlined,
|
||||
EyeOutlined,
|
||||
CloudOutlined,
|
||||
EnvironmentOutlined,
|
||||
DashboardOutlined,
|
||||
ExpandOutlined,
|
||||
CompressOutlined,
|
||||
CheckCircleOutlined,
|
||||
WarningOutlined,
|
||||
SyncOutlined,
|
||||
DeleteFilled,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
|
||||
@@ -21,45 +54,45 @@ const designTokens = {
|
||||
primary: {
|
||||
main: '#1890ff',
|
||||
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
bgGradient: 'linear-gradient(135deg, #667eea15 0%, #764ba208 100%)'
|
||||
bgGradient: 'linear-gradient(135deg, #667eea15 0%, #764ba208 100%)',
|
||||
},
|
||||
success: {
|
||||
main: '#52c41a',
|
||||
gradient: 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)'
|
||||
gradient: 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)',
|
||||
},
|
||||
warning: {
|
||||
main: '#faad14',
|
||||
gradient: 'linear-gradient(135deg, #faad14 0%, #d48806 100%)'
|
||||
gradient: 'linear-gradient(135deg, #faad14 0%, #d48806 100%)',
|
||||
},
|
||||
error: {
|
||||
main: '#ff4d4f',
|
||||
gradient: 'linear-gradient(135deg, #ff4d4f 0%, #cf1322 100%)'
|
||||
gradient: 'linear-gradient(135deg, #ff4d4f 0%, #cf1322 100%)',
|
||||
},
|
||||
text: {
|
||||
primary: '#262626',
|
||||
secondary: '#8c8c8c',
|
||||
tertiary: '#bfbfbf'
|
||||
}
|
||||
tertiary: '#bfbfbf',
|
||||
},
|
||||
},
|
||||
shadows: {
|
||||
small: '0 2px 8px rgba(0, 0, 0, 0.06)',
|
||||
medium: '0 4px 16px rgba(0, 0, 0, 0.08)',
|
||||
large: '0 8px 24px rgba(0, 0, 0, 0.12)'
|
||||
large: '0 8px 24px rgba(0, 0, 0, 0.12)',
|
||||
},
|
||||
borderRadius: {
|
||||
small: '8px',
|
||||
medium: '12px',
|
||||
large: '16px'
|
||||
large: '16px',
|
||||
},
|
||||
transitions: {
|
||||
normal: '0.3s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
}
|
||||
normal: '0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
};
|
||||
|
||||
const containerStyle = {
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(180deg, #f5f7fa 0%, #e8ecf1 100%)',
|
||||
padding: '24px'
|
||||
padding: '24px',
|
||||
};
|
||||
|
||||
const headerStyle = {
|
||||
@@ -68,15 +101,15 @@ const headerStyle = {
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
borderRadius: '20px',
|
||||
color: '#fff',
|
||||
boxShadow: '0 8px 32px rgba(102, 126, 234, 0.3)'
|
||||
boxShadow: '0 8px 32px rgba(102, 126, 234, 0.3)',
|
||||
};
|
||||
|
||||
const statCardStyle = (color) => ({
|
||||
const statCardStyle = color => ({
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
padding: '16px',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
backdropFilter: 'blur(10px)'
|
||||
backdropFilter: 'blur(10px)',
|
||||
});
|
||||
|
||||
const cardStyle = {
|
||||
@@ -84,13 +117,13 @@ const cardStyle = {
|
||||
border: 'none',
|
||||
boxShadow: designTokens.shadows.medium,
|
||||
background: '#fff',
|
||||
overflow: 'hidden'
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const cardHeadStyle = {
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
padding: '16px 24px',
|
||||
background: 'linear-gradient(135deg, #f8f9ff 0%, #ffffff 100%)'
|
||||
background: 'linear-gradient(135deg, #f8f9ff 0%, #ffffff 100%)',
|
||||
};
|
||||
|
||||
const primaryButtonStyle = {
|
||||
@@ -99,25 +132,25 @@ const primaryButtonStyle = {
|
||||
background: designTokens.colors.primary.gradient,
|
||||
border: 'none',
|
||||
boxShadow: '0 4px 16px rgba(102, 126, 234, 0.35)',
|
||||
fontWeight: '500'
|
||||
fontWeight: '500',
|
||||
};
|
||||
|
||||
const actionButtonStyle = {
|
||||
height: '36px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e8e8e8'
|
||||
border: '1px solid #e8e8e8',
|
||||
};
|
||||
|
||||
const searchInputStyle = {
|
||||
borderRadius: '10px',
|
||||
height: '42px',
|
||||
border: '1px solid #e8e8e8'
|
||||
border: '1px solid #e8e8e8',
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
active: { text: '在用', color: 'success', icon: <CheckCircleOutlined /> },
|
||||
maintenance: { text: '维护中', color: 'warning', icon: <SyncOutlined spin /> },
|
||||
inactive: { text: '停用', color: 'default', icon: <WarningOutlined /> }
|
||||
inactive: { text: '停用', color: 'default', icon: <WarningOutlined /> },
|
||||
};
|
||||
|
||||
const CapacityProgress = ({ used, capacity, color }) => {
|
||||
@@ -130,9 +163,7 @@ const CapacityProgress = ({ used, capacity, color }) => {
|
||||
<Text style={{ fontSize: '13px', color: designTokens.colors.text.secondary }}>
|
||||
{used} / {capacity}
|
||||
</Text>
|
||||
<Text style={{ fontSize: '13px', fontWeight: '600', color }}>
|
||||
{percentage.toFixed(1)}%
|
||||
</Text>
|
||||
<Text style={{ fontSize: '13px', fontWeight: '600', color }}>{percentage.toFixed(1)}%</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={percentage}
|
||||
@@ -159,13 +190,20 @@ const RoomCard = ({ room, onEdit, onDelete, onView, selected, onSelect }) => {
|
||||
border: selected ? `2px solid ${designTokens.colors.primary.main}` : '1px solid #f0f0f0',
|
||||
boxShadow: designTokens.shadows.small,
|
||||
transition: `all ${designTokens.transitions.normal}`,
|
||||
cursor: 'pointer'
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => onSelect(room.roomId)}
|
||||
onDoubleClick={() => onView(room)}
|
||||
styles={{ body: { padding: '20px' } }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
|
||||
<CloudOutlined style={{ fontSize: '20px', color: designTokens.colors.primary.main }} />
|
||||
@@ -173,7 +211,9 @@ const RoomCard = ({ room, onEdit, onDelete, onView, selected, onSelect }) => {
|
||||
{room.name}
|
||||
</Text>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: '13px' }}>{room.roomId}</Text>
|
||||
<Text type="secondary" style={{ fontSize: '13px' }}>
|
||||
{room.roomId}
|
||||
</Text>
|
||||
</div>
|
||||
<Tag color={statusInfo.color} icon={statusInfo.icon} style={{ borderRadius: '20px' }}>
|
||||
{statusInfo.text}
|
||||
@@ -191,21 +231,43 @@ const RoomCard = ({ room, onEdit, onDelete, onView, selected, onSelect }) => {
|
||||
<CapacityProgress
|
||||
used={rackCount}
|
||||
capacity={room.capacity}
|
||||
color={capacityUsage >= 90 ? designTokens.colors.error.main : capacityUsage >= 70 ? designTokens.colors.warning.main : designTokens.colors.success.main}
|
||||
color={
|
||||
capacityUsage >= 90
|
||||
? designTokens.colors.error.main
|
||||
: capacityUsage >= 70
|
||||
? designTokens.colors.warning.main
|
||||
: designTokens.colors.success.main
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: '16px' }}>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>面积</Text>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: designTokens.colors.text.primary }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
面积
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: designTokens.colors.text.primary,
|
||||
}}
|
||||
>
|
||||
{room.area} ㎡
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>机柜</Text>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: designTokens.colors.text.primary }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
机柜
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: designTokens.colors.text.primary,
|
||||
}}
|
||||
>
|
||||
{rackCount}
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,7 +277,10 @@ const RoomCard = ({ room, onEdit, onDelete, onView, selected, onSelect }) => {
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={(e) => { e.stopPropagation(); onView(room); }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onView(room);
|
||||
}}
|
||||
style={{ color: designTokens.colors.text.secondary }}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -223,7 +288,10 @@ const RoomCard = ({ room, onEdit, onDelete, onView, selected, onSelect }) => {
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(room); }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onEdit(room);
|
||||
}}
|
||||
style={{ color: designTokens.colors.primary.main }}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -232,7 +300,10 @@ const RoomCard = ({ room, onEdit, onDelete, onView, selected, onSelect }) => {
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
danger
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(room.roomId); }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onDelete(room.roomId);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
@@ -286,7 +357,7 @@ function RoomManagement() {
|
||||
setEditingRoom(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
const handleSubmit = async values => {
|
||||
try {
|
||||
if (editingRoom) {
|
||||
await axios.put(`/api/rooms/${editingRoom.roomId}`, values);
|
||||
@@ -304,7 +375,7 @@ function RoomManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (roomId) => {
|
||||
const handleDelete = async roomId => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个机房吗?删除后无法恢复。',
|
||||
@@ -320,7 +391,7 @@ function RoomManagement() {
|
||||
message.error('机房删除失败');
|
||||
console.error('机房删除失败:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -346,18 +417,19 @@ function RoomManagement() {
|
||||
message.error('批量删除失败');
|
||||
console.error('批量删除失败:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleView = (room) => {
|
||||
const handleView = room => {
|
||||
setViewingRoom(room);
|
||||
setDrawerVisible(true);
|
||||
};
|
||||
|
||||
const filteredRooms = useMemo(() => {
|
||||
return rooms.filter(room => {
|
||||
const matchKeyword = !searchKeyword ||
|
||||
const matchKeyword =
|
||||
!searchKeyword ||
|
||||
room.name?.toLowerCase().includes(searchKeyword.toLowerCase()) ||
|
||||
room.roomId?.toLowerCase().includes(searchKeyword.toLowerCase()) ||
|
||||
room.location?.toLowerCase().includes(searchKeyword.toLowerCase());
|
||||
@@ -368,14 +440,17 @@ function RoomManagement() {
|
||||
});
|
||||
}, [rooms, searchKeyword, statusFilter]);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
total: rooms.length,
|
||||
active: rooms.filter(r => r.status === 'active').length,
|
||||
maintenance: rooms.filter(r => r.status === 'maintenance').length,
|
||||
inactive: rooms.filter(r => r.status === 'inactive').length,
|
||||
totalRacks: rooms.reduce((sum, r) => sum + (r.Racks?.length || 0), 0),
|
||||
totalCapacity: rooms.reduce((sum, r) => sum + (r.capacity || 0), 0)
|
||||
}), [rooms]);
|
||||
const stats = useMemo(
|
||||
() => ({
|
||||
total: rooms.length,
|
||||
active: rooms.filter(r => r.status === 'active').length,
|
||||
maintenance: rooms.filter(r => r.status === 'maintenance').length,
|
||||
inactive: rooms.filter(r => r.status === 'inactive').length,
|
||||
totalRacks: rooms.reduce((sum, r) => sum + (r.Racks?.length || 0), 0),
|
||||
totalCapacity: rooms.reduce((sum, r) => sum + (r.capacity || 0), 0),
|
||||
}),
|
||||
[rooms]
|
||||
);
|
||||
|
||||
const tableColumns = [
|
||||
{
|
||||
@@ -383,15 +458,17 @@ function RoomManagement() {
|
||||
key: 'roomInfo',
|
||||
render: (_, record) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '10px',
|
||||
background: designTokens.colors.primary.bgGradient,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '10px',
|
||||
background: designTokens.colors.primary.bgGradient,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<CloudOutlined style={{ fontSize: '22px', color: designTokens.colors.primary.main }} />
|
||||
</div>
|
||||
<div>
|
||||
@@ -403,18 +480,18 @@ function RoomManagement() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '位置',
|
||||
dataIndex: 'location',
|
||||
key: 'location',
|
||||
render: (location) => (
|
||||
render: location => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<EnvironmentOutlined style={{ color: designTokens.colors.text.tertiary }} />
|
||||
<span>{location}</span>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '面积/容量',
|
||||
@@ -430,13 +507,13 @@ function RoomManagement() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status) => {
|
||||
render: status => {
|
||||
const config = statusConfig[status];
|
||||
return (
|
||||
<Tag color={config.color} icon={config.icon} style={{ borderRadius: '20px' }}>
|
||||
@@ -447,9 +524,9 @@ function RoomManagement() {
|
||||
filters: [
|
||||
{ text: '在用', value: 'active' },
|
||||
{ text: '维护中', value: 'maintenance' },
|
||||
{ text: '停用', value: 'inactive' }
|
||||
{ text: '停用', value: 'inactive' },
|
||||
],
|
||||
onFilter: (value, record) => record.status === value
|
||||
onFilter: (value, record) => record.status === value,
|
||||
},
|
||||
{
|
||||
title: '机柜数',
|
||||
@@ -460,14 +537,14 @@ function RoomManagement() {
|
||||
<span>{record.Racks?.length || 0}</span>
|
||||
</div>
|
||||
),
|
||||
sorter: (a, b) => (a.Racks?.length || 0) - (b.Racks?.length || 0)
|
||||
sorter: (a, b) => (a.Racks?.length || 0) - (b.Racks?.length || 0),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (date) => date ? new Date(date).toLocaleString() : '-',
|
||||
sorter: (a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0)
|
||||
render: date => (date ? new Date(date).toLocaleString() : '-'),
|
||||
sorter: (a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -501,29 +578,44 @@ function RoomManagement() {
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys: selectedRoomIds,
|
||||
onChange: (selectedRowKeys) => {
|
||||
onChange: selectedRowKeys => {
|
||||
setSelectedRoomIds(selectedRowKeys);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={headerStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '16px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: '700', margin: '0 0 4px 0', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
margin: '0 0 4px 0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<CloudOutlined />
|
||||
机房管理
|
||||
</h1>
|
||||
<p style={{ margin: 0, opacity: 0.9, fontSize: '14px' }}>
|
||||
管理和监控所有机房设施
|
||||
</p>
|
||||
<p style={{ margin: 0, opacity: 0.9, fontSize: '14px' }}>管理和监控所有机房设施</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<div style={statCardStyle()}>
|
||||
@@ -532,7 +624,9 @@ function RoomManagement() {
|
||||
</div>
|
||||
<div style={statCardStyle()}>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: '12px' }}>在用机房</Text>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#52c41a' }}>{stats.active}</div>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#52c41a' }}>
|
||||
{stats.active}
|
||||
</div>
|
||||
</div>
|
||||
<div style={statCardStyle()}>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: '12px' }}>总机柜</Text>
|
||||
@@ -543,13 +637,22 @@ function RoomManagement() {
|
||||
</div>
|
||||
|
||||
<Card style={cardStyle} styles={{ header: cardHeadStyle, body: { padding: '20px 24px' } }}>
|
||||
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '16px' }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '12px', flex: 1, maxWidth: '600px' }}>
|
||||
<Input
|
||||
placeholder="搜索机房名称、ID、位置..."
|
||||
prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />}
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onChange={e => setSearchKeyword(e.target.value)}
|
||||
style={searchInputStyle}
|
||||
allowClear
|
||||
/>
|
||||
@@ -625,7 +728,7 @@ function RoomManagement() {
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条记录`
|
||||
showTotal: total => `共 ${total} 条记录`,
|
||||
}}
|
||||
scroll={{ x: 1000 }}
|
||||
rowClassName={() => 'table-row'}
|
||||
@@ -641,7 +744,7 @@ function RoomManagement() {
|
||||
onDelete={handleDelete}
|
||||
onView={handleView}
|
||||
selected={selectedRoomIds.includes(room.roomId)}
|
||||
onSelect={(id) => {
|
||||
onSelect={id => {
|
||||
if (selectedRoomIds.includes(id)) {
|
||||
setSelectedRoomIds(selectedRoomIds.filter(rid => rid !== id));
|
||||
} else {
|
||||
@@ -663,12 +766,14 @@ function RoomManagement() {
|
||||
<Modal
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div style={{
|
||||
width: '4px',
|
||||
height: '20px',
|
||||
background: designTokens.colors.primary.gradient,
|
||||
borderRadius: '2px'
|
||||
}} />
|
||||
<div
|
||||
style={{
|
||||
width: '4px',
|
||||
height: '20px',
|
||||
background: designTokens.colors.primary.gradient,
|
||||
borderRadius: '2px',
|
||||
}}
|
||||
/>
|
||||
{editingRoom ? '编辑机房' : '添加机房'}
|
||||
</div>
|
||||
}
|
||||
@@ -679,7 +784,7 @@ function RoomManagement() {
|
||||
destroyOnHidden
|
||||
styles={{
|
||||
body: { padding: '24px' },
|
||||
header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' }
|
||||
header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' },
|
||||
}}
|
||||
style={{ borderRadius: '16px' }}
|
||||
>
|
||||
@@ -710,7 +815,11 @@ function RoomManagement() {
|
||||
label="位置"
|
||||
rules={[{ required: true, message: '请输入机房位置' }]}
|
||||
>
|
||||
<Input prefix={<EnvironmentOutlined />} placeholder="请输入机房位置" style={{ borderRadius: '8px' }} />
|
||||
<Input
|
||||
prefix={<EnvironmentOutlined />}
|
||||
placeholder="请输入机房位置"
|
||||
style={{ borderRadius: '8px' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={16}>
|
||||
@@ -720,7 +829,12 @@ function RoomManagement() {
|
||||
label="面积(㎡)"
|
||||
rules={[{ required: true, message: '请输入机房面积' }]}
|
||||
>
|
||||
<InputNumber placeholder="请输入机房面积" min={0} step={0.1} style={{ width: '100%', borderRadius: '8px' }} />
|
||||
<InputNumber
|
||||
placeholder="请输入机房面积"
|
||||
min={0}
|
||||
step={0.1}
|
||||
style={{ width: '100%', borderRadius: '8px' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
@@ -729,16 +843,16 @@ function RoomManagement() {
|
||||
label="容量(机柜数)"
|
||||
rules={[{ required: true, message: '请输入机柜容量' }]}
|
||||
>
|
||||
<InputNumber placeholder="请输入机柜容量" min={0} style={{ width: '100%', borderRadius: '8px' }} />
|
||||
<InputNumber
|
||||
placeholder="请输入机柜容量"
|
||||
min={0}
|
||||
style={{ width: '100%', borderRadius: '8px' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
rules={[{ required: true, message: '请选择状态' }]}
|
||||
>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true, message: '请选择状态' }]}>
|
||||
<Select placeholder="请选择状态" style={{ borderRadius: '8px' }}>
|
||||
<Option value="active">在用</Option>
|
||||
<Option value="maintenance">维护中</Option>
|
||||
@@ -750,7 +864,9 @@ function RoomManagement() {
|
||||
<Input.TextArea placeholder="请输入机房描述" rows={3} style={{ borderRadius: '8px' }} />
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', marginTop: '24px' }}>
|
||||
<div
|
||||
style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', marginTop: '24px' }}
|
||||
>
|
||||
<Button onClick={handleCancel} style={{ borderRadius: '8px', height: '42px' }}>
|
||||
取消
|
||||
</Button>
|
||||
@@ -773,21 +889,33 @@ function RoomManagement() {
|
||||
width={480}
|
||||
styles={{
|
||||
header: { borderBottom: '1px solid #f0f0f0' },
|
||||
body: { padding: '24px' }
|
||||
body: { padding: '24px' },
|
||||
}}
|
||||
>
|
||||
{viewingRoom && (
|
||||
<div>
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
background: designTokens.colors.primary.bgGradient,
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
|
||||
<CloudOutlined style={{ fontSize: '32px', color: designTokens.colors.primary.main }} />
|
||||
<div
|
||||
style={{
|
||||
padding: '20px',
|
||||
background: designTokens.colors.primary.bgGradient,
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}
|
||||
>
|
||||
<CloudOutlined
|
||||
style={{ fontSize: '32px', color: designTokens.colors.primary.main }}
|
||||
/>
|
||||
<div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: designTokens.colors.text.primary }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: designTokens.colors.text.primary,
|
||||
}}
|
||||
>
|
||||
{viewingRoom.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: designTokens.colors.text.secondary }}>
|
||||
@@ -801,7 +929,9 @@ function RoomManagement() {
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<Text type="secondary" style={{ fontSize: '13px' }}>位置</Text>
|
||||
<Text type="secondary" style={{ fontSize: '13px' }}>
|
||||
位置
|
||||
</Text>
|
||||
<div style={{ fontSize: '15px', fontWeight: '500', marginTop: '4px' }}>
|
||||
<EnvironmentOutlined style={{ marginRight: '8px' }} />
|
||||
{viewingRoom.location}
|
||||
@@ -811,16 +941,32 @@ function RoomManagement() {
|
||||
<Row gutter={16} style={{ marginBottom: '20px' }}>
|
||||
<Col span={12}>
|
||||
<div style={{ padding: '16px', background: '#fafafa', borderRadius: '10px' }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>面积</Text>
|
||||
<div style={{ fontSize: '20px', fontWeight: '600', color: designTokens.colors.text.primary }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
面积
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
color: designTokens.colors.text.primary,
|
||||
}}
|
||||
>
|
||||
{viewingRoom.area} ㎡
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div style={{ padding: '16px', background: '#fafafa', borderRadius: '10px' }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>容量</Text>
|
||||
<div style={{ fontSize: '20px', fontWeight: '600', color: designTokens.colors.text.primary }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
容量
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
color: designTokens.colors.text.primary,
|
||||
}}
|
||||
>
|
||||
{viewingRoom.capacity} 机柜
|
||||
</div>
|
||||
</div>
|
||||
@@ -828,7 +974,9 @@ function RoomManagement() {
|
||||
</Row>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<Text type="secondary" style={{ fontSize: '13px' }}>机柜使用情况</Text>
|
||||
<Text type="secondary" style={{ fontSize: '13px' }}>
|
||||
机柜使用情况
|
||||
</Text>
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<CapacityProgress
|
||||
used={viewingRoom.Racks?.length || 0}
|
||||
@@ -840,8 +988,17 @@ function RoomManagement() {
|
||||
|
||||
{viewingRoom.description && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<Text type="secondary" style={{ fontSize: '13px' }}>描述</Text>
|
||||
<div style={{ marginTop: '8px', padding: '12px', background: '#fafafa', borderRadius: '8px' }}>
|
||||
<Text type="secondary" style={{ fontSize: '13px' }}>
|
||||
描述
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
padding: '12px',
|
||||
background: '#fafafa',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
{viewingRoom.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Tabs, Form, Input, Switch, Select, Button, Card, Space, message, Modal, Tag, Divider, Descriptions, Alert } from 'antd';
|
||||
import { SettingOutlined, GlobalOutlined, BgColorsOutlined, InfoCircleOutlined, CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Tabs,
|
||||
Form,
|
||||
Input,
|
||||
Switch,
|
||||
Select,
|
||||
Button,
|
||||
Card,
|
||||
Space,
|
||||
message,
|
||||
Modal,
|
||||
Tag,
|
||||
Divider,
|
||||
Descriptions,
|
||||
Alert,
|
||||
} from 'antd';
|
||||
import {
|
||||
SettingOutlined,
|
||||
GlobalOutlined,
|
||||
BgColorsOutlined,
|
||||
InfoCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import { useConfig } from '../context/ConfigContext';
|
||||
|
||||
@@ -26,7 +48,7 @@ const SystemSettings = () => {
|
||||
try {
|
||||
const response = await axios.get('/api/system-settings');
|
||||
setSettings(response.data);
|
||||
|
||||
|
||||
const formValues = {};
|
||||
Object.entries(response.data).forEach(([key, data]) => {
|
||||
formValues[key] = data.value;
|
||||
@@ -48,7 +70,7 @@ const SystemSettings = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSettings = async (values) => {
|
||||
const handleSaveSettings = async values => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const updates = {};
|
||||
@@ -76,7 +98,10 @@ const SystemSettings = () => {
|
||||
title: '前端端口已修改(生产环境)',
|
||||
content: (
|
||||
<div>
|
||||
<p>前端端口已从 <strong>{settings.frontend_port?.value}</strong> 更改为 <strong>{updates.frontend_port}</strong></p>
|
||||
<p>
|
||||
前端端口已从 <strong>{settings.frontend_port?.value}</strong> 更改为{' '}
|
||||
<strong>{updates.frontend_port}</strong>
|
||||
</p>
|
||||
<Alert
|
||||
message="请手动更新服务器配置"
|
||||
description={
|
||||
@@ -93,7 +118,7 @@ const SystemSettings = () => {
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
okText: '知道了'
|
||||
okText: '知道了',
|
||||
});
|
||||
} else {
|
||||
// 开发环境:显示确认对话框并自动重启
|
||||
@@ -102,7 +127,10 @@ const SystemSettings = () => {
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: (
|
||||
<div>
|
||||
<p>前端端口已从 <strong>{settings.frontend_port?.value}</strong> 更改为 <strong>{updates.frontend_port}</strong></p>
|
||||
<p>
|
||||
前端端口已从 <strong>{settings.frontend_port?.value}</strong> 更改为{' '}
|
||||
<strong>{updates.frontend_port}</strong>
|
||||
</p>
|
||||
<p>是否立即重启前端服务以应用新端口?</p>
|
||||
<Alert
|
||||
message="注意"
|
||||
@@ -125,9 +153,13 @@ const SystemSettings = () => {
|
||||
title: '正在重启前端服务',
|
||||
content: (
|
||||
<div>
|
||||
<p>前端服务正在重启,新端口:<strong>{newPort}</strong></p>
|
||||
<p>
|
||||
前端服务正在重启,新端口:<strong>{newPort}</strong>
|
||||
</p>
|
||||
<p>页面将在3秒后自动跳转到新地址...</p>
|
||||
<p>如果跳转失败,请手动访问:<a href={newUrl}>{newUrl}</a></p>
|
||||
<p>
|
||||
如果跳转失败,请手动访问:<a href={newUrl}>{newUrl}</a>
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: '立即跳转',
|
||||
@@ -135,14 +167,18 @@ const SystemSettings = () => {
|
||||
maskClosable: false,
|
||||
onOk: () => {
|
||||
window.location.href = newUrl;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 延迟调用重启API,让用户看到提示
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// 调用重启API(这个请求可能会因为服务重启而失败)
|
||||
await axios.post('/api/system-settings/frontend/restart', {}, { timeout: 5000 });
|
||||
await axios.post(
|
||||
'/api/system-settings/frontend/restart',
|
||||
{},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
} catch (error) {
|
||||
// 忽略错误,因为服务重启会导致连接中断
|
||||
console.log('重启请求已发送,服务正在重启...');
|
||||
@@ -153,7 +189,7 @@ const SystemSettings = () => {
|
||||
setTimeout(() => {
|
||||
window.location.href = newUrl;
|
||||
}, 3000);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (syncError) {
|
||||
@@ -174,7 +210,7 @@ const SystemSettings = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetSetting = (key) => {
|
||||
const handleResetSetting = key => {
|
||||
Modal.confirm({
|
||||
title: '确认重置',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
@@ -189,18 +225,14 @@ const SystemSettings = () => {
|
||||
} catch (error) {
|
||||
message.error('重置失败');
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderFormItem = (key, data) => {
|
||||
if (!data.isEditable) {
|
||||
return (
|
||||
<Form.Item
|
||||
key={key}
|
||||
label={data.description || key}
|
||||
name={key}
|
||||
>
|
||||
<Form.Item key={key} label={data.description || key} name={key}>
|
||||
<Input disabled suffix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
);
|
||||
@@ -209,12 +241,7 @@ const SystemSettings = () => {
|
||||
switch (data.type) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<Form.Item
|
||||
key={key}
|
||||
label={data.description || key}
|
||||
name={key}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Form.Item key={key} label={data.description || key} name={key} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
);
|
||||
@@ -227,56 +254,63 @@ const SystemSettings = () => {
|
||||
name={key}
|
||||
rules={[
|
||||
{ required: false, message: `请输入${data.description || key}` },
|
||||
...(isPortField ? [
|
||||
{ type: 'number', min: 1, max: 65535, message: '端口号必须在 1-65535 之间', transform: value => Number(value) }
|
||||
] : [])
|
||||
...(isPortField
|
||||
? [
|
||||
{
|
||||
type: 'number',
|
||||
min: 1,
|
||||
max: 65535,
|
||||
message: '端口号必须在 1-65535 之间',
|
||||
transform: value => Number(value),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
>
|
||||
<Input type="number" style={{ width: '100%' }} min={isPortField ? 1 : undefined} max={isPortField ? 65535 : undefined} />
|
||||
<Input
|
||||
type="number"
|
||||
style={{ width: '100%' }}
|
||||
min={isPortField ? 1 : undefined}
|
||||
max={isPortField ? 65535 : undefined}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
case 'select':
|
||||
const options = getSelectOptions(key);
|
||||
return (
|
||||
<Form.Item
|
||||
key={key}
|
||||
label={data.description || key}
|
||||
name={key}
|
||||
>
|
||||
<Form.Item key={key} label={data.description || key} name={key}>
|
||||
<Select>
|
||||
{options.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
|
||||
<Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Form.Item
|
||||
key={key}
|
||||
label={data.description || key}
|
||||
name={key}
|
||||
>
|
||||
<Form.Item key={key} label={data.description || key} name={key}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getSelectOptions = (key) => {
|
||||
const getSelectOptions = key => {
|
||||
const optionsMap = {
|
||||
timezone: [
|
||||
{ value: 'Asia/Shanghai', label: '亚洲/上海 (UTC+8)' },
|
||||
{ value: 'Asia/Beijing', label: '亚洲/北京 (UTC+8)' },
|
||||
{ value: 'America/New_York', label: '美洲/纽约 (UTC-5)' },
|
||||
{ value: 'Europe/London', label: '欧洲/伦敦 (UTC+0)' },
|
||||
{ value: 'UTC', label: 'UTC (UTC+0)' }
|
||||
{ value: 'UTC', label: 'UTC (UTC+0)' },
|
||||
],
|
||||
date_format: [
|
||||
{ value: 'YYYY-MM-DD', label: '2024-01-01' },
|
||||
{ value: 'YYYY/MM/DD', label: '2024/01/01' },
|
||||
{ value: 'DD/MM/YYYY', label: '01/01/2024' },
|
||||
{ value: 'MM/DD/YYYY', label: '01/01/2024' }
|
||||
{ value: 'MM/DD/YYYY', label: '01/01/2024' },
|
||||
],
|
||||
primary_color: [
|
||||
{ value: '#667eea', label: '蓝色 (#667eea)' },
|
||||
@@ -288,7 +322,7 @@ const SystemSettings = () => {
|
||||
{ value: '#fee140', label: '黄色 (#fee140)' },
|
||||
{ value: '#00b4db', label: '青色 (#00b4db)' },
|
||||
{ value: '#0083b0', label: '深蓝色 (#0083b0)' },
|
||||
{ value: '#fcb045', label: '橙色 (#fcb045)' }
|
||||
{ value: '#fcb045', label: '橙色 (#fcb045)' },
|
||||
],
|
||||
secondary_color: [
|
||||
{ value: '#764ba2', label: '紫色 (#764ba2)' },
|
||||
@@ -300,36 +334,44 @@ const SystemSettings = () => {
|
||||
{ value: '#00b4db', label: '青色 (#00b4db)' },
|
||||
{ value: '#0083b0', label: '深蓝色 (#0083b0)' },
|
||||
{ value: '#fcb045', label: '橙色 (#fcb045)' },
|
||||
{ value: '#f093fb', label: '粉色 (#f093fb)' }
|
||||
{ value: '#f093fb', label: '粉色 (#f093fb)' },
|
||||
],
|
||||
table_row_height: [
|
||||
{ value: 'small', label: '紧凑 (Small)' },
|
||||
{ value: 'default', label: '默认 (Default)' },
|
||||
{ value: 'middle', label: '中等 (Middle)' },
|
||||
{ value: 'large', label: '宽松 (Large)' }
|
||||
{ value: 'large', label: '宽松 (Large)' },
|
||||
],
|
||||
dark_mode: [
|
||||
{ value: 'false', label: '关闭' },
|
||||
{ value: 'true', label: '开启' }
|
||||
{ value: 'true', label: '开启' },
|
||||
],
|
||||
compact_mode: [
|
||||
{ value: 'false', label: '关闭' },
|
||||
{ value: 'true', label: '开启' }
|
||||
{ value: 'true', label: '开启' },
|
||||
],
|
||||
animation_enabled: [
|
||||
{ value: 'false', label: '关闭' },
|
||||
{ value: 'true', label: '开启' }
|
||||
{ value: 'true', label: '开启' },
|
||||
],
|
||||
sidebar_collapsed: [
|
||||
{ value: 'false', label: '展开' },
|
||||
{ value: 'true', label: '折叠' }
|
||||
{ value: 'true', label: '折叠' },
|
||||
],
|
||||
};
|
||||
return optionsMap[key] || [];
|
||||
};
|
||||
|
||||
const renderGeneralSettings = () => {
|
||||
const generalKeys = ['site_name', 'site_logo', 'timezone', 'date_format', 'session_timeout', 'max_login_attempts', 'maintenance_mode'];
|
||||
const generalKeys = [
|
||||
'site_name',
|
||||
'site_logo',
|
||||
'timezone',
|
||||
'date_format',
|
||||
'session_timeout',
|
||||
'max_login_attempts',
|
||||
'maintenance_mode',
|
||||
];
|
||||
return (
|
||||
<Card title="全局配置" bordered={false}>
|
||||
<Form form={form} layout="vertical" onFinish={handleSaveSettings}>
|
||||
@@ -343,7 +385,9 @@ const SystemSettings = () => {
|
||||
})}
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={saving}>保存设置</Button>
|
||||
<Button type="primary" htmlType="submit" loading={saving}>
|
||||
保存设置
|
||||
</Button>
|
||||
<Button onClick={() => fetchSettings()}>重置表单</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
@@ -353,7 +397,14 @@ const SystemSettings = () => {
|
||||
};
|
||||
|
||||
const renderAppearanceSettings = () => {
|
||||
const appearanceKeys = ['primary_color', 'secondary_color', 'compact_mode', 'sidebar_collapsed', 'table_row_height', 'animation_enabled'];
|
||||
const appearanceKeys = [
|
||||
'primary_color',
|
||||
'secondary_color',
|
||||
'compact_mode',
|
||||
'sidebar_collapsed',
|
||||
'table_row_height',
|
||||
'animation_enabled',
|
||||
];
|
||||
return (
|
||||
<Card title="外观设置" bordered={false}>
|
||||
<Form form={form} layout="vertical" onFinish={handleSaveSettings}>
|
||||
@@ -374,7 +425,9 @@ const SystemSettings = () => {
|
||||
})}
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={saving}>保存设置</Button>
|
||||
<Button type="primary" htmlType="submit" loading={saving}>
|
||||
保存设置
|
||||
</Button>
|
||||
<Button onClick={() => fetchSettings()}>重置表单</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
@@ -386,14 +439,25 @@ const SystemSettings = () => {
|
||||
// 数据备份功能已移除
|
||||
|
||||
const renderAboutPage = () => {
|
||||
const aboutKeys = ['app_version', 'company_name', 'contact_email', 'contact_phone', 'company_address', 'system_description', 'privacy_policy', 'terms_of_service'];
|
||||
const aboutKeys = [
|
||||
'app_version',
|
||||
'company_name',
|
||||
'contact_email',
|
||||
'contact_phone',
|
||||
'company_address',
|
||||
'system_description',
|
||||
'privacy_policy',
|
||||
'terms_of_service',
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card title="关于系统" bordered={false} style={{ marginBottom: 16 }}>
|
||||
<Descriptions column={{ xs: 1, sm: 2, md: 3 }} bordered>
|
||||
<Descriptions.Item label="系统名称">机柜管理系统</Descriptions.Item>
|
||||
<Descriptions.Item label="版本号">{settings.app_version?.value || '1.0.0'}</Descriptions.Item>
|
||||
<Descriptions.Item label="版本号">
|
||||
{settings.app_version?.value || '1.0.0'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="系统状态">
|
||||
<Tag color="success">运行正常</Tag>
|
||||
</Descriptions.Item>
|
||||
@@ -405,7 +469,14 @@ const SystemSettings = () => {
|
||||
{aboutKeys.slice(1).map(key => settings[key] && renderFormItem(key, settings[key]))}
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={saving} onClick={() => form.submit()}>保存信息</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={saving}
|
||||
onClick={() => form.submit()}
|
||||
>
|
||||
保存信息
|
||||
</Button>
|
||||
<Button onClick={() => fetchSettings()}>重置</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
@@ -415,24 +486,42 @@ const SystemSettings = () => {
|
||||
{systemInfo && (
|
||||
<Card title="系统统计信息" bordered={false}>
|
||||
<Descriptions column={{ xs: 1, sm: 2, md: 4 }} bordered size="small">
|
||||
<Descriptions.Item label="设备总数">{systemInfo.statistics?.devices || 0}</Descriptions.Item>
|
||||
<Descriptions.Item label="机柜总数">{systemInfo.statistics?.racks || 0}</Descriptions.Item>
|
||||
<Descriptions.Item label="机房总数">{systemInfo.statistics?.rooms || 0}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户总数">{systemInfo.statistics?.users || 0}</Descriptions.Item>
|
||||
<Descriptions.Item label="设备总数">
|
||||
{systemInfo.statistics?.devices || 0}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="机柜总数">
|
||||
{systemInfo.statistics?.racks || 0}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="机房总数">
|
||||
{systemInfo.statistics?.rooms || 0}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="用户总数">
|
||||
{systemInfo.statistics?.users || 0}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Divider />
|
||||
<Descriptions column={{ xs: 1, sm: 2 }} bordered size="small">
|
||||
<Descriptions.Item label="Node.js 版本">{systemInfo.system?.nodeVersion}</Descriptions.Item>
|
||||
<Descriptions.Item label="运行平台">{systemInfo.system?.platform} ({systemInfo.system?.arch})</Descriptions.Item>
|
||||
<Descriptions.Item label="Node.js 版本">
|
||||
{systemInfo.system?.nodeVersion}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="运行平台">
|
||||
{systemInfo.system?.platform} ({systemInfo.system?.arch})
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="进程 ID">{systemInfo.system?.pid}</Descriptions.Item>
|
||||
<Descriptions.Item label="运行时间">
|
||||
{systemInfo.system?.uptime ? `${Math.floor(systemInfo.system.uptime / 3600)}小时${Math.floor((systemInfo.system.uptime % 3600) / 60)}分钟` : '-'}
|
||||
{systemInfo.system?.uptime
|
||||
? `${Math.floor(systemInfo.system.uptime / 3600)}小时${Math.floor((systemInfo.system.uptime % 3600) / 60)}分钟`
|
||||
: '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="内存使用">
|
||||
{systemInfo.system?.memoryUsage ? `${(systemInfo.system.memoryUsage.heapUsed / 1024 / 1024).toFixed(2)} MB` : '-'}
|
||||
{systemInfo.system?.memoryUsage
|
||||
? `${(systemInfo.system.memoryUsage.heapUsed / 1024 / 1024).toFixed(2)} MB`
|
||||
: '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="系统时间">
|
||||
{systemInfo.timestamp ? new Date(systemInfo.timestamp).toLocaleString('zh-CN') : '-'}
|
||||
{systemInfo.timestamp
|
||||
? new Date(systemInfo.timestamp).toLocaleString('zh-CN')
|
||||
: '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
@@ -445,19 +534,31 @@ const SystemSettings = () => {
|
||||
<div style={{ padding: 24 }}>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||||
<TabPane
|
||||
tab={<span><GlobalOutlined /> 全局配置</span>}
|
||||
tab={
|
||||
<span>
|
||||
<GlobalOutlined /> 全局配置
|
||||
</span>
|
||||
}
|
||||
key="general"
|
||||
>
|
||||
{renderGeneralSettings()}
|
||||
</TabPane>
|
||||
<TabPane
|
||||
tab={<span><BgColorsOutlined /> 外观设置</span>}
|
||||
tab={
|
||||
<span>
|
||||
<BgColorsOutlined /> 外观设置
|
||||
</span>
|
||||
}
|
||||
key="appearance"
|
||||
>
|
||||
{renderAppearanceSettings()}
|
||||
</TabPane>
|
||||
<TabPane
|
||||
tab={<span><InfoCircleOutlined /> 关于</span>}
|
||||
tab={
|
||||
<span>
|
||||
<InfoCircleOutlined /> 关于
|
||||
</span>
|
||||
}
|
||||
key="about"
|
||||
>
|
||||
{renderAboutPage()}
|
||||
|
||||
@@ -49,7 +49,7 @@ function TicketCategoryManagement() {
|
||||
priority: category.priority,
|
||||
defaultPriority: category.defaultPriority,
|
||||
expectedDuration: category.expectedDuration,
|
||||
isActive: category.isActive
|
||||
isActive: category.isActive,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
@@ -62,7 +62,7 @@ function TicketCategoryManagement() {
|
||||
setEditingCategory(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
const handleSubmit = async values => {
|
||||
try {
|
||||
if (editingCategory) {
|
||||
await axios.put(`/api/ticket-categories/${editingCategory.categoryId}`, values);
|
||||
@@ -81,7 +81,7 @@ function TicketCategoryManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (categoryId) => {
|
||||
const handleDelete = async categoryId => {
|
||||
try {
|
||||
await axios.delete(`/api/ticket-categories/${categoryId}`);
|
||||
message.success('分类删除成功');
|
||||
@@ -97,49 +97,47 @@ function TicketCategoryManagement() {
|
||||
title: '分类ID',
|
||||
dataIndex: 'categoryId',
|
||||
key: 'categoryId',
|
||||
width: 150
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '分类名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 180
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '分类说明',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
width: 300,
|
||||
ellipsis: true
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '优先级',
|
||||
dataIndex: 'priority',
|
||||
key: 'priority',
|
||||
width: 80
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '默认优先级',
|
||||
dataIndex: 'defaultPriority',
|
||||
key: 'defaultPriority',
|
||||
width: 100
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '预计时长(分钟)',
|
||||
dataIndex: 'expectedDuration',
|
||||
key: 'expectedDuration',
|
||||
width: 120
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '启用状态',
|
||||
dataIndex: 'isActive',
|
||||
key: 'isActive',
|
||||
width: 100,
|
||||
render: (text) => (
|
||||
<span style={{ color: text ? 'green' : 'red' }}>
|
||||
{text ? '启用' : '禁用'}
|
||||
</span>
|
||||
)
|
||||
render: text => (
|
||||
<span style={{ color: text ? 'green' : 'red' }}>{text ? '启用' : '禁用'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -147,11 +145,7 @@ function TicketCategoryManagement() {
|
||||
width: 150,
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => showModal(record)}
|
||||
>
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => showModal(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
@@ -166,22 +160,25 @@ function TicketCategoryManagement() {
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card title="故障分类管理" extra={
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={initCategories}>
|
||||
初始化分类
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>
|
||||
添加分类
|
||||
</Button>
|
||||
</Space>
|
||||
}>
|
||||
<Card
|
||||
title="故障分类管理"
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={initCategories}>
|
||||
初始化分类
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>
|
||||
添加分类
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={categories}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Select, message, Card, Space, InputNumber, Switch } from 'antd';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
message,
|
||||
Card,
|
||||
Space,
|
||||
InputNumber,
|
||||
Switch,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
|
||||
@@ -34,7 +46,7 @@ function TicketFieldManagement() {
|
||||
if (field) {
|
||||
const fieldData = {
|
||||
...field,
|
||||
options: field.options ? JSON.stringify(field.options, null, 2) : ''
|
||||
options: field.options ? JSON.stringify(field.options, null, 2) : '',
|
||||
};
|
||||
form.setFieldsValue(fieldData);
|
||||
} else {
|
||||
@@ -48,11 +60,11 @@ function TicketFieldManagement() {
|
||||
setEditingField(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
const handleSubmit = async values => {
|
||||
try {
|
||||
const fieldData = {
|
||||
...values,
|
||||
options: values.options ? JSON.parse(values.options || '[]') : null
|
||||
options: values.options ? JSON.parse(values.options || '[]') : null,
|
||||
};
|
||||
|
||||
if (editingField) {
|
||||
@@ -72,7 +84,7 @@ function TicketFieldManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (fieldId) => {
|
||||
const handleDelete = async fieldId => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个字段吗?',
|
||||
@@ -88,7 +100,7 @@ function TicketFieldManagement() {
|
||||
message.error('字段删除失败');
|
||||
console.error('字段删除失败:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -107,7 +119,7 @@ function TicketFieldManagement() {
|
||||
title: '字段类型',
|
||||
dataIndex: 'fieldType',
|
||||
key: 'fieldType',
|
||||
render: (type) => {
|
||||
render: type => {
|
||||
const typeMap = {
|
||||
string: '文本',
|
||||
number: '数字',
|
||||
@@ -116,26 +128,22 @@ function TicketFieldManagement() {
|
||||
date: '日期',
|
||||
datetime: '日期时间',
|
||||
textarea: '多行文本',
|
||||
device: '设备选择'
|
||||
device: '设备选择',
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '必填',
|
||||
dataIndex: 'required',
|
||||
key: 'required',
|
||||
render: (required) => (
|
||||
<Switch checked={required} disabled />
|
||||
)
|
||||
render: required => <Switch checked={required} disabled />,
|
||||
},
|
||||
{
|
||||
title: '可见',
|
||||
dataIndex: 'visible',
|
||||
key: 'visible',
|
||||
render: (visible) => (
|
||||
<Switch checked={visible} disabled />
|
||||
)
|
||||
render: visible => <Switch checked={visible} disabled />,
|
||||
},
|
||||
{
|
||||
title: '顺序',
|
||||
@@ -147,10 +155,20 @@ function TicketFieldManagement() {
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Space size="middle">
|
||||
<Button type="primary" icon={<EditOutlined />} onClick={() => showModal(record)} size="small">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => showModal(record)}
|
||||
size="small"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.fieldId)} size="small">
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDelete(record.fieldId)}
|
||||
size="small"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -160,11 +178,14 @@ function TicketFieldManagement() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card title="工单字段管理" extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>
|
||||
添加字段
|
||||
</Button>
|
||||
}>
|
||||
<Card
|
||||
title="工单字段管理"
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>
|
||||
添加字段
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={fields}
|
||||
@@ -181,11 +202,7 @@ function TicketFieldManagement() {
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
name="fieldName"
|
||||
label="字段名称"
|
||||
@@ -219,17 +236,11 @@ function TicketFieldManagement() {
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="required"
|
||||
label="必填"
|
||||
>
|
||||
<Form.Item name="required" label="必填">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="visible"
|
||||
label="可见"
|
||||
>
|
||||
<Form.Item name="visible" label="可见">
|
||||
<Switch defaultChecked />
|
||||
</Form.Item>
|
||||
|
||||
@@ -246,10 +257,7 @@ function TicketFieldManagement() {
|
||||
label="选项配置(仅下拉选择类型,JSON格式)"
|
||||
tooltip="格式示例:[{value: 'option1', label: '选项1'}]"
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder='[{"value": "option1", "label": "选项1"}]'
|
||||
/>
|
||||
<Input.TextArea rows={3} placeholder='[{"value": "option1", "label": "选项1"}]' />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ textAlign: 'right' }}>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,60 +1,65 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Card, Row, Col, Statistic, Table, DatePicker, Select, Space, Tag, message } from 'antd';
|
||||
import { BarChartOutlined, PieChartOutlined, RiseOutlined, FallOutlined, ClockCircleOutlined, CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
BarChartOutlined,
|
||||
PieChartOutlined,
|
||||
RiseOutlined,
|
||||
FallOutlined,
|
||||
ClockCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
const { Option } = Select;
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const getStatusColor = status => {
|
||||
const colors = {
|
||||
pending: 'orange',
|
||||
assigned: 'blue',
|
||||
in_progress: 'processing',
|
||||
completed: 'green',
|
||||
closed: 'default'
|
||||
closed: 'default',
|
||||
};
|
||||
return colors[status] || 'default';
|
||||
};
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const getStatusText = status => {
|
||||
const texts = {
|
||||
pending: '待处理',
|
||||
assigned: '已分配',
|
||||
in_progress: '处理中',
|
||||
completed: '已完成',
|
||||
closed: '已关闭'
|
||||
closed: '已关闭',
|
||||
};
|
||||
return texts[status] || status;
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority) => {
|
||||
const getPriorityColor = priority => {
|
||||
const colors = {
|
||||
low: 'green',
|
||||
medium: 'orange',
|
||||
high: 'red',
|
||||
urgent: 'magenta'
|
||||
urgent: 'magenta',
|
||||
};
|
||||
return colors[priority] || 'default';
|
||||
};
|
||||
|
||||
const getPriorityText = (priority) => {
|
||||
const getPriorityText = priority => {
|
||||
const texts = {
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
urgent: '紧急'
|
||||
urgent: '紧急',
|
||||
};
|
||||
return texts[priority] || priority;
|
||||
};
|
||||
|
||||
function TicketStatistics() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dateRange, setDateRange] = useState([
|
||||
dayjs().subtract(30, 'days'),
|
||||
dayjs()
|
||||
]);
|
||||
const [dateRange, setDateRange] = useState([dayjs().subtract(30, 'days'), dayjs()]);
|
||||
const [statistics, setStatistics] = useState({
|
||||
total: 0,
|
||||
pending: 0,
|
||||
@@ -66,7 +71,7 @@ function TicketStatistics() {
|
||||
byPriority: [],
|
||||
byStatus: [],
|
||||
byDevice: [],
|
||||
trend: []
|
||||
trend: [],
|
||||
});
|
||||
|
||||
const fetchStatistics = useCallback(async () => {
|
||||
@@ -74,7 +79,7 @@ function TicketStatistics() {
|
||||
setLoading(true);
|
||||
const params = {
|
||||
startDate: dateRange[0].format('YYYY-MM-DD'),
|
||||
endDate: dateRange[1].format('YYYY-MM-DD')
|
||||
endDate: dateRange[1].format('YYYY-MM-DD'),
|
||||
};
|
||||
|
||||
const response = await axios.get('/api/tickets/statistics', { params });
|
||||
@@ -91,192 +96,198 @@ function TicketStatistics() {
|
||||
fetchStatistics();
|
||||
}, [fetchStatistics]);
|
||||
|
||||
const handleDateChange = useCallback((dates) => {
|
||||
const handleDateChange = useCallback(dates => {
|
||||
if (dates) {
|
||||
setDateRange(dates);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const getStatusColor = status => {
|
||||
const colors = {
|
||||
pending: 'orange',
|
||||
assigned: 'blue',
|
||||
in_progress: 'processing',
|
||||
completed: 'green',
|
||||
closed: 'default'
|
||||
closed: 'default',
|
||||
};
|
||||
return colors[status] || 'default';
|
||||
};
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const getStatusText = status => {
|
||||
const texts = {
|
||||
pending: '待处理',
|
||||
assigned: '已分配',
|
||||
in_progress: '处理中',
|
||||
completed: '已完成',
|
||||
closed: '已关闭'
|
||||
closed: '已关闭',
|
||||
};
|
||||
return texts[status] || status;
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority) => {
|
||||
const getPriorityColor = priority => {
|
||||
const colors = {
|
||||
low: 'green',
|
||||
medium: 'orange',
|
||||
high: 'red',
|
||||
urgent: 'magenta'
|
||||
urgent: 'magenta',
|
||||
};
|
||||
return colors[priority] || 'default';
|
||||
};
|
||||
|
||||
const getPriorityText = (priority) => {
|
||||
const getPriorityText = priority => {
|
||||
const texts = {
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
urgent: '紧急'
|
||||
urgent: '紧急',
|
||||
};
|
||||
return texts[priority] || priority;
|
||||
};
|
||||
|
||||
const statusColumns = useMemo(() => [
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 120,
|
||||
render: (status) => (
|
||||
<Tag color={getStatusColor(status)}>
|
||||
{getStatusText(status)}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '工单数量',
|
||||
dataIndex: 'count',
|
||||
key: 'count',
|
||||
width: 120,
|
||||
render: (count) => <Statistic value={count} valueStyle={{ fontSize: 16 }} />
|
||||
},
|
||||
{
|
||||
title: '占比',
|
||||
dataIndex: 'percentage',
|
||||
key: 'percentage',
|
||||
width: 120,
|
||||
render: (pct) => (
|
||||
<span style={{ color: pct > 30 ? '#ff4d4f' : '#52c41a' }}>
|
||||
{pct !== undefined && pct !== null ? `${pct.toFixed(1)}%` : '-'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
], []);
|
||||
const statusColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 120,
|
||||
render: status => <Tag color={getStatusColor(status)}>{getStatusText(status)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '工单数量',
|
||||
dataIndex: 'count',
|
||||
key: 'count',
|
||||
width: 120,
|
||||
render: count => <Statistic value={count} valueStyle={{ fontSize: 16 }} />,
|
||||
},
|
||||
{
|
||||
title: '占比',
|
||||
dataIndex: 'percentage',
|
||||
key: 'percentage',
|
||||
width: 120,
|
||||
render: pct => (
|
||||
<span style={{ color: pct > 30 ? '#ff4d4f' : '#52c41a' }}>
|
||||
{pct !== undefined && pct !== null ? `${pct.toFixed(1)}%` : '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const categoryColumns = useMemo(() => [
|
||||
{
|
||||
title: '故障分类',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '工单数量',
|
||||
dataIndex: 'count',
|
||||
key: 'count',
|
||||
width: 120,
|
||||
render: (count) => <Statistic value={count} valueStyle={{ fontSize: 16 }} />
|
||||
},
|
||||
{
|
||||
title: '占比',
|
||||
dataIndex: 'percentage',
|
||||
key: 'percentage',
|
||||
width: 100,
|
||||
render: (pct) => `${pct !== undefined && pct !== null ? pct.toFixed(1) : 0}%`
|
||||
},
|
||||
{
|
||||
title: '已完成',
|
||||
dataIndex: 'completed',
|
||||
key: 'completed',
|
||||
width: 100,
|
||||
render: (count) => <Tag color="green">{count}</Tag>
|
||||
},
|
||||
{
|
||||
title: '平均处理时间(小时)',
|
||||
dataIndex: 'avgTime',
|
||||
key: 'avgTime',
|
||||
width: 150,
|
||||
render: (time) => time !== undefined && time !== null ? time.toFixed(1) : '-'
|
||||
}
|
||||
], []);
|
||||
const categoryColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '故障分类',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '工单数量',
|
||||
dataIndex: 'count',
|
||||
key: 'count',
|
||||
width: 120,
|
||||
render: count => <Statistic value={count} valueStyle={{ fontSize: 16 }} />,
|
||||
},
|
||||
{
|
||||
title: '占比',
|
||||
dataIndex: 'percentage',
|
||||
key: 'percentage',
|
||||
width: 100,
|
||||
render: pct => `${pct !== undefined && pct !== null ? pct.toFixed(1) : 0}%`,
|
||||
},
|
||||
{
|
||||
title: '已完成',
|
||||
dataIndex: 'completed',
|
||||
key: 'completed',
|
||||
width: 100,
|
||||
render: count => <Tag color="green">{count}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '平均处理时间(小时)',
|
||||
dataIndex: 'avgTime',
|
||||
key: 'avgTime',
|
||||
width: 150,
|
||||
render: time => (time !== undefined && time !== null ? time.toFixed(1) : '-'),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const priorityColumns = useMemo(() => [
|
||||
{
|
||||
title: '优先级',
|
||||
dataIndex: 'priority',
|
||||
key: 'priority',
|
||||
width: 100,
|
||||
render: (priority) => (
|
||||
<Tag color={getPriorityColor(priority)}>
|
||||
{getPriorityText(priority)}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '工单数量',
|
||||
dataIndex: 'count',
|
||||
key: 'count',
|
||||
width: 120,
|
||||
render: (count) => <Statistic value={count} valueStyle={{ fontSize: 16 }} />
|
||||
},
|
||||
{
|
||||
title: '已完成',
|
||||
dataIndex: 'completed',
|
||||
key: 'completed',
|
||||
width: 100,
|
||||
render: (count) => <Tag color="green">{count}</Tag>
|
||||
},
|
||||
{
|
||||
title: '平均处理时间(小时)',
|
||||
dataIndex: 'avgTime',
|
||||
key: 'avgTime',
|
||||
width: 150,
|
||||
render: (time) => time !== undefined && time !== null ? time.toFixed(1) : '-'
|
||||
}
|
||||
], []);
|
||||
const priorityColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '优先级',
|
||||
dataIndex: 'priority',
|
||||
key: 'priority',
|
||||
width: 100,
|
||||
render: priority => (
|
||||
<Tag color={getPriorityColor(priority)}>{getPriorityText(priority)}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '工单数量',
|
||||
dataIndex: 'count',
|
||||
key: 'count',
|
||||
width: 120,
|
||||
render: count => <Statistic value={count} valueStyle={{ fontSize: 16 }} />,
|
||||
},
|
||||
{
|
||||
title: '已完成',
|
||||
dataIndex: 'completed',
|
||||
key: 'completed',
|
||||
width: 100,
|
||||
render: count => <Tag color="green">{count}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '平均处理时间(小时)',
|
||||
dataIndex: 'avgTime',
|
||||
key: 'avgTime',
|
||||
width: 150,
|
||||
render: time => (time !== undefined && time !== null ? time.toFixed(1) : '-'),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const deviceColumns = useMemo(() => [
|
||||
{
|
||||
title: '设备名称',
|
||||
dataIndex: 'deviceName',
|
||||
key: 'deviceName',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '故障次数',
|
||||
dataIndex: 'count',
|
||||
key: 'count',
|
||||
width: 100,
|
||||
render: (count) => <Tag color="red">{count}</Tag>
|
||||
},
|
||||
{
|
||||
title: '最后故障时间',
|
||||
dataIndex: 'lastFaultTime',
|
||||
key: 'lastFaultTime',
|
||||
width: 160,
|
||||
render: (text) => text ? dayjs(text).format('YYYY-MM-DD HH:mm') : '-'
|
||||
},
|
||||
{
|
||||
title: '设备类型',
|
||||
dataIndex: 'deviceType',
|
||||
key: 'deviceType',
|
||||
width: 100
|
||||
}
|
||||
], []);
|
||||
const deviceColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '设备名称',
|
||||
dataIndex: 'deviceName',
|
||||
key: 'deviceName',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '故障次数',
|
||||
dataIndex: 'count',
|
||||
key: 'count',
|
||||
width: 100,
|
||||
render: count => <Tag color="red">{count}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '最后故障时间',
|
||||
dataIndex: 'lastFaultTime',
|
||||
key: 'lastFaultTime',
|
||||
width: 160,
|
||||
render: text => (text ? dayjs(text).format('YYYY-MM-DD HH:mm') : '-'),
|
||||
},
|
||||
{
|
||||
title: '设备类型',
|
||||
dataIndex: 'deviceType',
|
||||
key: 'deviceType',
|
||||
width: 100,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const simpleBarData = [
|
||||
{ name: '待处理', value: statistics.pending },
|
||||
{ name: '处理中', value: statistics.inProgress },
|
||||
{ name: '已完成', value: statistics.completed },
|
||||
{ name: '已关闭', value: statistics.closed }
|
||||
{ name: '已关闭', value: statistics.closed },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -285,11 +296,7 @@ function TicketStatistics() {
|
||||
title="工单统计报表"
|
||||
extra={
|
||||
<Space>
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={handleDateChange}
|
||||
allowClear={false}
|
||||
/>
|
||||
<RangePicker value={dateRange} onChange={handleDateChange} allowClear={false} />
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
@@ -362,7 +369,11 @@ function TicketStatistics() {
|
||||
<Card bordered={false} style={{ background: '#fff1f0' }}>
|
||||
<Statistic
|
||||
title="完成率"
|
||||
value={statistics.total > 0 ? ((statistics.completed / statistics.total) * 100).toFixed(1) : 0}
|
||||
value={
|
||||
statistics.total > 0
|
||||
? ((statistics.completed / statistics.total) * 100).toFixed(1)
|
||||
: 0
|
||||
}
|
||||
suffix="%"
|
||||
prefix={<PieChartOutlined style={{ color: '#ff4d4f' }} />}
|
||||
valueStyle={{ color: '#ff4d4f' }}
|
||||
@@ -373,7 +384,11 @@ function TicketStatistics() {
|
||||
<Card bordered={false} style={{ background: '#f6ffed' }}>
|
||||
<Statistic
|
||||
title="处理中占比"
|
||||
value={statistics.total > 0 ? ((statistics.inProgress / statistics.total) * 100).toFixed(1) : 0}
|
||||
value={
|
||||
statistics.total > 0
|
||||
? ((statistics.inProgress / statistics.total) * 100).toFixed(1)
|
||||
: 0
|
||||
}
|
||||
suffix="%"
|
||||
prefix={<RiseOutlined style={{ color: '#52c41a' }} />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
@@ -446,43 +461,43 @@ function TicketStatistics() {
|
||||
dataIndex: 'date',
|
||||
key: 'date',
|
||||
width: 120,
|
||||
render: (text) => text ? dayjs(text).format('YYYY-MM-DD') : '-'
|
||||
render: text => (text ? dayjs(text).format('YYYY-MM-DD') : '-'),
|
||||
},
|
||||
{
|
||||
title: '新建工单',
|
||||
dataIndex: 'created',
|
||||
key: 'created',
|
||||
width: 100,
|
||||
render: (count) => <Tag color="blue">{count}</Tag>
|
||||
render: count => <Tag color="blue">{count}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '已完成',
|
||||
dataIndex: 'completed',
|
||||
key: 'completed',
|
||||
width: 100,
|
||||
render: (count) => <Tag color="green">{count}</Tag>
|
||||
render: count => <Tag color="green">{count}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '关闭工单',
|
||||
dataIndex: 'closed',
|
||||
key: 'closed',
|
||||
width: 100,
|
||||
render: (count) => <Tag color="default">{count}</Tag>
|
||||
render: count => <Tag color="default">{count}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '当日处理中',
|
||||
dataIndex: 'inProgress',
|
||||
key: 'inProgress',
|
||||
width: 120,
|
||||
render: (count) => <Tag color="processing">{count}</Tag>
|
||||
render: count => <Tag color="processing">{count}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '新增待处理',
|
||||
dataIndex: 'pending',
|
||||
key: 'pending',
|
||||
width: 120,
|
||||
render: (count) => <Tag color="orange">{count}</Tag>
|
||||
}
|
||||
render: count => <Tag color="orange">{count}</Tag>,
|
||||
},
|
||||
]}
|
||||
dataSource={statistics.trend}
|
||||
rowKey="date"
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { Card, Table, Button, Space, Modal, Form, Input, Select, message, Tag, Popconfirm, Avatar, Tooltip, Badge } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, UserOutlined, ReloadOutlined, LockOutlined, CameraOutlined, CheckOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
message,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Avatar,
|
||||
Tooltip,
|
||||
Badge,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
UserOutlined,
|
||||
ReloadOutlined,
|
||||
LockOutlined,
|
||||
CameraOutlined,
|
||||
CheckOutlined,
|
||||
CloseOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { userAPI, roleAPI } from '../api';
|
||||
|
||||
const { Option } = Select;
|
||||
@@ -32,7 +57,7 @@ const UserManagement = () => {
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize
|
||||
pageSize: pagination.pageSize,
|
||||
};
|
||||
if (activeTab !== 'all') {
|
||||
params.status = activeTab;
|
||||
@@ -69,7 +94,7 @@ const UserManagement = () => {
|
||||
setModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleEdit = useCallback((user) => {
|
||||
const handleEdit = useCallback(user => {
|
||||
setEditingUser(user);
|
||||
form.setFieldsValue({
|
||||
username: user.username,
|
||||
@@ -77,55 +102,58 @@ const UserManagement = () => {
|
||||
phone: user.phone,
|
||||
realName: user.realName,
|
||||
status: user.status,
|
||||
roleIds: user.roles?.map(r => r.roleId) || []
|
||||
roleIds: user.roles?.map(r => r.roleId) || [],
|
||||
});
|
||||
setModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleResetPassword = useCallback((user) => {
|
||||
const handleResetPassword = useCallback(user => {
|
||||
setPasswordUser(user);
|
||||
passwordForm.resetFields();
|
||||
setPasswordModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleAvatarClick = useCallback((user) => {
|
||||
const handleAvatarClick = useCallback(user => {
|
||||
setAvatarUser(user);
|
||||
setAvatarModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleAvatarUpload = useCallback(async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const handleAvatarUpload = useCallback(
|
||||
async e => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.match(/image\/(jpeg|png|gif|webp)/)) {
|
||||
message.error('只支持 JPG、PNG、GIF 和 WebP 格式的图片');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
message.error('图片大小不能超过 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadLoading(true);
|
||||
try {
|
||||
const response = await userAPI.uploadAvatar(avatarUser.userId, file);
|
||||
if (response.success) {
|
||||
message.success('头像上传成功');
|
||||
fetchUsers();
|
||||
setAvatarModalVisible(false);
|
||||
} else {
|
||||
message.error(response.message || '上传失败');
|
||||
if (!file.type.match(/image\/(jpeg|png|gif|webp)/)) {
|
||||
message.error('只支持 JPG、PNG、GIF 和 WebP 格式的图片');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('上传失败');
|
||||
} finally {
|
||||
setUploadLoading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
message.error('图片大小不能超过 5MB');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [avatarUser, fetchUsers]);
|
||||
|
||||
setUploadLoading(true);
|
||||
try {
|
||||
const response = await userAPI.uploadAvatar(avatarUser.userId, file);
|
||||
if (response.success) {
|
||||
message.success('头像上传成功');
|
||||
fetchUsers();
|
||||
setAvatarModalVisible(false);
|
||||
} else {
|
||||
message.error(response.message || '上传失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('上传失败');
|
||||
} finally {
|
||||
setUploadLoading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
[avatarUser, fetchUsers]
|
||||
);
|
||||
|
||||
const handleAvatarDelete = useCallback(async () => {
|
||||
try {
|
||||
@@ -142,283 +170,302 @@ const UserManagement = () => {
|
||||
}
|
||||
}, [avatarUser, fetchUsers]);
|
||||
|
||||
const handleDelete = useCallback(async (userId) => {
|
||||
try {
|
||||
const response = await userAPI.delete(userId);
|
||||
if (response.success) {
|
||||
message.success('删除成功');
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(response.message || '删除失败');
|
||||
const handleDelete = useCallback(
|
||||
async userId => {
|
||||
try {
|
||||
const response = await userAPI.delete(userId);
|
||||
if (response.success) {
|
||||
message.success('删除成功');
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(response.message || '删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
}, [fetchUsers]);
|
||||
},
|
||||
[fetchUsers]
|
||||
);
|
||||
|
||||
const handleLockUnlock = useCallback(async (record) => {
|
||||
try {
|
||||
const newStatus = record.status === 'locked' ? 'active' : 'locked';
|
||||
const response = await userAPI.update(record.userId, {
|
||||
status: newStatus
|
||||
});
|
||||
if (response.success) {
|
||||
message.success(record.status === 'locked' ? '解锁成功' : '锁定成功');
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(response.message || '操作失败');
|
||||
const handleLockUnlock = useCallback(
|
||||
async record => {
|
||||
try {
|
||||
const newStatus = record.status === 'locked' ? 'active' : 'locked';
|
||||
const response = await userAPI.update(record.userId, {
|
||||
status: newStatus,
|
||||
});
|
||||
if (response.success) {
|
||||
message.success(record.status === 'locked' ? '解锁成功' : '锁定成功');
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(response.message || '操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
}, [fetchUsers]);
|
||||
},
|
||||
[fetchUsers]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async (values) => {
|
||||
try {
|
||||
let response;
|
||||
if (editingUser) {
|
||||
response = await userAPI.update(editingUser.userId, values);
|
||||
} else {
|
||||
response = await userAPI.create(values);
|
||||
const handleSubmit = useCallback(
|
||||
async values => {
|
||||
try {
|
||||
let response;
|
||||
if (editingUser) {
|
||||
response = await userAPI.update(editingUser.userId, values);
|
||||
} else {
|
||||
response = await userAPI.create(values);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
message.success(editingUser ? '更新成功' : '创建成功');
|
||||
setModalVisible(false);
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(response.message || '操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
},
|
||||
[editingUser, fetchUsers]
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
message.success(editingUser ? '更新成功' : '创建成功');
|
||||
setModalVisible(false);
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(response.message || '操作失败');
|
||||
const handleResetPasswordSubmit = useCallback(
|
||||
async values => {
|
||||
try {
|
||||
const response = await userAPI.resetPassword(passwordUser.userId, values);
|
||||
if (response.success) {
|
||||
message.success('密码重置成功');
|
||||
setPasswordModalVisible(false);
|
||||
} else {
|
||||
message.error(response.message || '重置失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('重置失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
}, [editingUser, fetchUsers]);
|
||||
},
|
||||
[passwordUser]
|
||||
);
|
||||
|
||||
const handleResetPasswordSubmit = useCallback(async (values) => {
|
||||
try {
|
||||
const response = await userAPI.resetPassword(passwordUser.userId, values);
|
||||
if (response.success) {
|
||||
message.success('密码重置成功');
|
||||
setPasswordModalVisible(false);
|
||||
} else {
|
||||
message.error(response.message || '重置失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('重置失败');
|
||||
}
|
||||
}, [passwordUser]);
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const getStatusColor = status => {
|
||||
const colors = {
|
||||
active: 'green',
|
||||
inactive: 'red',
|
||||
locked: 'orange',
|
||||
pending: 'blue'
|
||||
pending: 'blue',
|
||||
};
|
||||
return colors[status] || 'default';
|
||||
};
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const getStatusText = status => {
|
||||
const texts = {
|
||||
active: '正常',
|
||||
inactive: '禁用',
|
||||
locked: '锁定',
|
||||
pending: '待审核'
|
||||
pending: '待审核',
|
||||
};
|
||||
return texts[status] || status;
|
||||
};
|
||||
|
||||
const handleApprove = useCallback(async (userId) => {
|
||||
try {
|
||||
const response = await userAPI.approve(userId);
|
||||
if (response.success) {
|
||||
message.success('审核通过');
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(response.message || '审核失败');
|
||||
const handleApprove = useCallback(
|
||||
async userId => {
|
||||
try {
|
||||
const response = await userAPI.approve(userId);
|
||||
if (response.success) {
|
||||
message.success('审核通过');
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(response.message || '审核失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('审核失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('审核失败');
|
||||
}
|
||||
}, [fetchUsers]);
|
||||
},
|
||||
[fetchUsers]
|
||||
);
|
||||
|
||||
const handleReject = useCallback(async (userId) => {
|
||||
try {
|
||||
const response = await userAPI.reject(userId);
|
||||
if (response.success) {
|
||||
message.success('已拒绝该用户的注册申请');
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(response.message || '操作失败');
|
||||
const handleReject = useCallback(
|
||||
async userId => {
|
||||
try {
|
||||
const response = await userAPI.reject(userId);
|
||||
if (response.success) {
|
||||
message.success('已拒绝该用户的注册申请');
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(response.message || '操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
}, [fetchUsers]);
|
||||
},
|
||||
[fetchUsers]
|
||||
);
|
||||
|
||||
const getAvatarUrl = (user) => {
|
||||
const getAvatarUrl = user => {
|
||||
if (!user?.avatar) return null;
|
||||
return user.avatar;
|
||||
};
|
||||
|
||||
const tableColumns = useMemo(() => [
|
||||
{
|
||||
title: '头像',
|
||||
key: 'avatar',
|
||||
width: 80,
|
||||
render: (_, record) => (
|
||||
<Badge dot={!!record.avatar} color="green" offset={[-5, 35]}>
|
||||
<Avatar
|
||||
size={48}
|
||||
icon={!record.avatar && <UserOutlined />}
|
||||
src={getAvatarUrl(record)}
|
||||
style={{
|
||||
backgroundColor: record.avatar ? 'transparent' : '#1890ff',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => handleAvatarClick(record)}
|
||||
/>
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '用户名',
|
||||
key: 'username',
|
||||
width: 150,
|
||||
render: (_, record) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{record.realName || record.username}</div>
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>@{record.username}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
width: 200,
|
||||
render: (email) => email || '-'
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
width: 130,
|
||||
render: (phone) => phone || '-'
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
key: 'roles',
|
||||
render: (_, record) => (
|
||||
<Space wrap>
|
||||
{record.roles?.map(role => (
|
||||
<Tag key={role.roleId} color={role.roleCode === 'admin' ? 'blue' : 'green'}>
|
||||
{role.roleName}
|
||||
</Tag>
|
||||
)) || '-'}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status) => (
|
||||
<Tag color={getStatusColor(status)}>{getStatusText(status)}</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '最后登录',
|
||||
key: 'lastLogin',
|
||||
render: (_, record) => (
|
||||
<div style={{ fontSize: '12px' }}>
|
||||
<div>{record.lastLoginTime ? new Date(record.lastLoginTime).toLocaleString() : '从未登录'}</div>
|
||||
<div style={{ color: '#999' }}>{record.lastLoginIp || '-'}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
{record.status === 'pending' ? (
|
||||
<>
|
||||
<Popconfirm
|
||||
title="确定要通过该用户的注册申请吗?"
|
||||
onConfirm={() => handleApprove(record.userId)}
|
||||
okText="通过"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Tooltip title="通过">
|
||||
const tableColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '头像',
|
||||
key: 'avatar',
|
||||
width: 80,
|
||||
render: (_, record) => (
|
||||
<Badge dot={!!record.avatar} color="green" offset={[-5, 35]}>
|
||||
<Avatar
|
||||
size={48}
|
||||
icon={!record.avatar && <UserOutlined />}
|
||||
src={getAvatarUrl(record)}
|
||||
style={{
|
||||
backgroundColor: record.avatar ? 'transparent' : '#1890ff',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => handleAvatarClick(record)}
|
||||
/>
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '用户名',
|
||||
key: 'username',
|
||||
width: 150,
|
||||
render: (_, record) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{record.realName || record.username}</div>
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>@{record.username}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
width: 200,
|
||||
render: email => email || '-',
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
width: 130,
|
||||
render: phone => phone || '-',
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
key: 'roles',
|
||||
render: (_, record) => (
|
||||
<Space wrap>
|
||||
{record.roles?.map(role => (
|
||||
<Tag key={role.roleId} color={role.roleCode === 'admin' ? 'blue' : 'green'}>
|
||||
{role.roleName}
|
||||
</Tag>
|
||||
)) || '-'}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: status => <Tag color={getStatusColor(status)}>{getStatusText(status)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '最后登录',
|
||||
key: 'lastLogin',
|
||||
render: (_, record) => (
|
||||
<div style={{ fontSize: '12px' }}>
|
||||
<div>
|
||||
{record.lastLoginTime ? new Date(record.lastLoginTime).toLocaleString() : '从未登录'}
|
||||
</div>
|
||||
<div style={{ color: '#999' }}>{record.lastLoginIp || '-'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
{record.status === 'pending' ? (
|
||||
<>
|
||||
<Popconfirm
|
||||
title="确定要通过该用户的注册申请吗?"
|
||||
onConfirm={() => handleApprove(record.userId)}
|
||||
okText="通过"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Tooltip title="通过">
|
||||
<Button type="text" icon={<CheckOutlined />} style={{ color: '#52c41a' }} />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
<Popconfirm
|
||||
title="确定要拒绝该用户的注册申请吗?"
|
||||
onConfirm={() => handleReject(record.userId)}
|
||||
okText="拒绝"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Tooltip title="拒绝">
|
||||
<Button type="text" icon={<CloseOutlined />} style={{ color: '#ff4d4f' }} />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip title="编辑">
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="重置密码">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CheckOutlined />}
|
||||
style={{ color: '#52c41a' }}
|
||||
icon={<LockOutlined />}
|
||||
onClick={() => handleResetPassword(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
<Popconfirm
|
||||
title="确定要拒绝该用户的注册申请吗?"
|
||||
onConfirm={() => handleReject(record.userId)}
|
||||
okText="拒绝"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Tooltip title="拒绝">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
style={{ color: '#ff4d4f' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip title="编辑">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="重置密码">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LockOutlined />}
|
||||
onClick={() => handleResetPassword(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title={record.status === 'locked' ? '确定要解锁此用户吗?' : '确定要锁定此用户吗?'}
|
||||
onConfirm={() => handleLockUnlock(record)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Tooltip title={record.status === 'locked' ? '解锁' : '锁定'}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={record.status === 'locked' ? <ReloadOutlined /> : <LockOutlined />}
|
||||
style={{ color: record.status === 'locked' ? '#52c41a' : '#faad14' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
<Popconfirm
|
||||
title="确定要删除此用户吗?"
|
||||
onConfirm={() => handleDelete(record.userId)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Tooltip title="删除">
|
||||
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
], [handleAvatarClick, handleEdit, handleResetPassword, handleDelete, handleLockUnlock, handleApprove, handleReject]);
|
||||
<Popconfirm
|
||||
title={
|
||||
record.status === 'locked' ? '确定要解锁此用户吗?' : '确定要锁定此用户吗?'
|
||||
}
|
||||
onConfirm={() => handleLockUnlock(record)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Tooltip title={record.status === 'locked' ? '解锁' : '锁定'}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={record.status === 'locked' ? <ReloadOutlined /> : <LockOutlined />}
|
||||
style={{ color: record.status === 'locked' ? '#52c41a' : '#faad14' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
<Popconfirm
|
||||
title="确定要删除此用户吗?"
|
||||
onConfirm={() => handleDelete(record.userId)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Tooltip title="删除">
|
||||
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
handleAvatarClick,
|
||||
handleEdit,
|
||||
handleResetPassword,
|
||||
handleDelete,
|
||||
handleLockUnlock,
|
||||
handleApprove,
|
||||
handleReject,
|
||||
]
|
||||
);
|
||||
|
||||
const pageHeaderStyle = {
|
||||
marginBottom: '24px',
|
||||
@@ -426,7 +473,7 @@ const UserManagement = () => {
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: '16px'
|
||||
gap: '16px',
|
||||
};
|
||||
|
||||
const titleStyle = {
|
||||
@@ -436,20 +483,20 @@ const UserManagement = () => {
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text'
|
||||
backgroundClip: 'text',
|
||||
};
|
||||
|
||||
const cardStyle = {
|
||||
borderRadius: '16px',
|
||||
border: 'none',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
|
||||
overflow: 'hidden'
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const cardHeadStyle = {
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
padding: '16px 24px',
|
||||
background: 'linear-gradient(135deg, #f8f9ff 0%, #ffffff 100%)'
|
||||
background: 'linear-gradient(135deg, #f8f9ff 0%, #ffffff 100%)',
|
||||
};
|
||||
|
||||
const primaryButtonStyle = {
|
||||
@@ -459,21 +506,21 @@ const UserManagement = () => {
|
||||
border: 'none',
|
||||
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.35)',
|
||||
fontWeight: '500',
|
||||
transition: 'all 0.3s ease'
|
||||
transition: 'all 0.3s ease',
|
||||
};
|
||||
|
||||
const secondaryButtonStyle = {
|
||||
height: '40px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e8e8e8',
|
||||
transition: 'all 0.3s ease'
|
||||
transition: 'all 0.3s ease',
|
||||
};
|
||||
|
||||
const actionButtonStyle = {
|
||||
height: '32px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #e8e8e8',
|
||||
transition: 'all 0.3s ease'
|
||||
transition: 'all 0.3s ease',
|
||||
};
|
||||
|
||||
const modalHeaderStyle = {
|
||||
@@ -481,25 +528,25 @@ const UserManagement = () => {
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '18px',
|
||||
fontWeight: '600'
|
||||
fontWeight: '600',
|
||||
};
|
||||
|
||||
const modalHeaderAccent = {
|
||||
width: '4px',
|
||||
height: '20px',
|
||||
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
|
||||
borderRadius: '2px'
|
||||
borderRadius: '2px',
|
||||
};
|
||||
|
||||
const avatarModalStyle = {
|
||||
textAlign: 'center',
|
||||
padding: '20px 0'
|
||||
padding: '20px 0',
|
||||
};
|
||||
|
||||
const avatarWrapperStyle = {
|
||||
marginBottom: '24px',
|
||||
position: 'relative',
|
||||
display: 'inline-block'
|
||||
display: 'inline-block',
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -507,16 +554,12 @@ const UserManagement = () => {
|
||||
<div style={pageHeaderStyle}>
|
||||
<h1 style={titleStyle}>用户管理</h1>
|
||||
<Space size="middle">
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={fetchUsers}
|
||||
style={secondaryButtonStyle}
|
||||
>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchUsers} style={secondaryButtonStyle}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAdd}
|
||||
style={primaryButtonStyle}
|
||||
>
|
||||
@@ -533,10 +576,10 @@ const UserManagement = () => {
|
||||
{ key: 'pending', tab: '待审核' },
|
||||
{ key: 'active', tab: '正常' },
|
||||
{ key: 'locked', tab: '锁定' },
|
||||
{ key: 'inactive', tab: '禁用' }
|
||||
{ key: 'inactive', tab: '禁用' },
|
||||
]}
|
||||
activeTabKey={activeTab}
|
||||
onTabChange={(key) => {
|
||||
onTabChange={key => {
|
||||
setActiveTab(key);
|
||||
setPagination(prev => ({ ...prev, current: 1 }));
|
||||
}}
|
||||
@@ -550,9 +593,9 @@ const UserManagement = () => {
|
||||
...pagination,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条记录`
|
||||
showTotal: total => `共 ${total} 条记录`,
|
||||
}}
|
||||
onChange={(newPagination) => {
|
||||
onChange={newPagination => {
|
||||
setPagination(prev => ({ ...prev, ...newPagination }));
|
||||
}}
|
||||
rowClassName={() => 'table-row'}
|
||||
@@ -573,22 +616,17 @@ const UserManagement = () => {
|
||||
destroyOnHidden
|
||||
styles={{
|
||||
body: { padding: '24px' },
|
||||
header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' }
|
||||
header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' },
|
||||
}}
|
||||
style={{ borderRadius: '16px', overflow: 'hidden' }}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
style={{ marginTop: '20px' }}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit} style={{ marginTop: '20px' }}>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="用户名"
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ min: 3, max: 20, message: '用户名长度必须在3-20个字符之间' }
|
||||
{ min: 3, max: 20, message: '用户名长度必须在3-20个字符之间' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入用户名" style={{ borderRadius: '8px' }} />
|
||||
@@ -607,7 +645,7 @@ const UserManagement = () => {
|
||||
label="邮箱"
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' }
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入邮箱" style={{ borderRadius: '8px' }} />
|
||||
@@ -637,7 +675,7 @@ const UserManagement = () => {
|
||||
label="初始密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入初始密码' },
|
||||
{ min: 6, message: '密码长度不能少于6个字符' }
|
||||
{ min: 6, message: '密码长度不能少于6个字符' },
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="请输入初始密码" style={{ borderRadius: '8px' }} />
|
||||
@@ -656,9 +694,7 @@ const UserManagement = () => {
|
||||
<Form.Item
|
||||
name="newPassword"
|
||||
label="新密码"
|
||||
rules={[
|
||||
{ min: 6, message: '密码长度不能少于6个字符' }
|
||||
]}
|
||||
rules={[{ min: 6, message: '密码长度不能少于6个字符' }]}
|
||||
>
|
||||
<Input.Password placeholder="留空则不修改密码" style={{ borderRadius: '8px' }} />
|
||||
</Form.Item>
|
||||
@@ -666,21 +702,21 @@ const UserManagement = () => {
|
||||
|
||||
<Form.Item style={{ marginBottom: 0, textAlign: 'right', marginTop: '24px' }}>
|
||||
<Space>
|
||||
<Button
|
||||
<Button
|
||||
onClick={() => setModalVisible(false)}
|
||||
style={{ borderRadius: '8px', height: '40px' }}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
style={{
|
||||
...primaryButtonStyle,
|
||||
width: 'auto',
|
||||
paddingLeft: '24px',
|
||||
paddingRight: '24px'
|
||||
paddingRight: '24px',
|
||||
}}
|
||||
>
|
||||
{editingUser ? '更新' : '创建'}
|
||||
@@ -704,7 +740,7 @@ const UserManagement = () => {
|
||||
destroyOnHidden
|
||||
styles={{
|
||||
body: { padding: '24px' },
|
||||
header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' }
|
||||
header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' },
|
||||
}}
|
||||
style={{ borderRadius: '16px', overflow: 'hidden' }}
|
||||
>
|
||||
@@ -719,7 +755,7 @@ const UserManagement = () => {
|
||||
label="新密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 6, message: '密码长度不能少于6个字符' }
|
||||
{ min: 6, message: '密码长度不能少于6个字符' },
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="请输入新密码" style={{ borderRadius: '8px' }} />
|
||||
@@ -727,21 +763,21 @@ const UserManagement = () => {
|
||||
|
||||
<Form.Item style={{ marginBottom: 0, textAlign: 'right', marginTop: '24px' }}>
|
||||
<Space>
|
||||
<Button
|
||||
<Button
|
||||
onClick={() => setPasswordModalVisible(false)}
|
||||
style={{ borderRadius: '8px', height: '40px' }}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
style={{
|
||||
...primaryButtonStyle,
|
||||
width: 'auto',
|
||||
paddingLeft: '24px',
|
||||
paddingRight: '24px'
|
||||
paddingRight: '24px',
|
||||
}}
|
||||
>
|
||||
重置
|
||||
@@ -765,7 +801,7 @@ const UserManagement = () => {
|
||||
destroyOnHidden
|
||||
styles={{
|
||||
body: { padding: '24px' },
|
||||
header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' }
|
||||
header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' },
|
||||
}}
|
||||
style={{ borderRadius: '16px', overflow: 'hidden' }}
|
||||
>
|
||||
@@ -776,10 +812,10 @@ const UserManagement = () => {
|
||||
size={120}
|
||||
icon={!avatarUser?.avatar && <UserOutlined />}
|
||||
src={getAvatarUrl(avatarUser)}
|
||||
style={{
|
||||
style={{
|
||||
backgroundColor: avatarUser?.avatar ? 'transparent' : '#1890ff',
|
||||
border: '1px solid #f0f0f0',
|
||||
cursor: 'pointer'
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
@@ -794,23 +830,23 @@ const UserManagement = () => {
|
||||
onChange={handleAvatarUpload}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CameraOutlined />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
loading={uploadLoading}
|
||||
block
|
||||
style={{
|
||||
...primaryButtonStyle,
|
||||
height: '44px'
|
||||
height: '44px',
|
||||
}}
|
||||
>
|
||||
{avatarUser?.avatar ? '更换头像' : '上传头像'}
|
||||
</Button>
|
||||
|
||||
{avatarUser?.avatar && (
|
||||
<Button
|
||||
danger
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleAvatarDelete}
|
||||
block
|
||||
|
||||
@@ -11,12 +11,12 @@ const { colors, shadows, borderRadius, transitions, spacing } = designTokens;
|
||||
export const pageContainerStyle = {
|
||||
minHeight: '100vh',
|
||||
background: colors.background.secondary,
|
||||
padding: spacing.lg
|
||||
padding: spacing.lg,
|
||||
};
|
||||
|
||||
// 头部样式
|
||||
export const headerStyle = {
|
||||
marginBottom: spacing.lg
|
||||
marginBottom: spacing.lg,
|
||||
};
|
||||
|
||||
// 标题行样式
|
||||
@@ -26,14 +26,14 @@ export const titleRowStyle = {
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.lg,
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.md
|
||||
gap: spacing.md,
|
||||
};
|
||||
|
||||
// 标题区域样式
|
||||
export const titleSectionStyle = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md
|
||||
gap: spacing.md,
|
||||
};
|
||||
|
||||
// 标题图标样式
|
||||
@@ -45,14 +45,14 @@ export const titleIconStyle = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: shadows.medium
|
||||
boxShadow: shadows.medium,
|
||||
};
|
||||
|
||||
// 标题文本样式
|
||||
export const titleTextStyle = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2px'
|
||||
gap: '2px',
|
||||
};
|
||||
|
||||
// 页面标题样式
|
||||
@@ -61,14 +61,14 @@ export const pageTitleStyle = {
|
||||
fontWeight: '700',
|
||||
margin: 0,
|
||||
color: colors.text.primary,
|
||||
lineHeight: 1.2
|
||||
lineHeight: 1.2,
|
||||
};
|
||||
|
||||
// 页面副标题样式
|
||||
export const pageSubtitleStyle = {
|
||||
fontSize: '13px',
|
||||
color: colors.text.secondary,
|
||||
margin: 0
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
// 操作按钮基础样式
|
||||
@@ -79,7 +79,7 @@ export const actionButtonStyle = {
|
||||
fontWeight: '500',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px'
|
||||
gap: '6px',
|
||||
};
|
||||
|
||||
// 主要操作按钮样式
|
||||
@@ -88,7 +88,7 @@ export const primaryActionStyle = {
|
||||
background: colors.primary.gradient,
|
||||
border: 'none',
|
||||
color: '#ffffff !important',
|
||||
boxShadow: shadows.small
|
||||
boxShadow: shadows.small,
|
||||
};
|
||||
|
||||
// 次要操作按钮样式
|
||||
@@ -96,7 +96,7 @@ export const secondaryActionStyle = {
|
||||
...actionButtonStyle,
|
||||
background: colors.background.primary,
|
||||
border: `1px solid ${colors.border.light}`,
|
||||
color: colors.text.primary
|
||||
color: colors.text.primary,
|
||||
};
|
||||
|
||||
// 危险操作按钮样式
|
||||
@@ -104,7 +104,7 @@ export const dangerActionStyle = {
|
||||
...actionButtonStyle,
|
||||
background: colors.error.main,
|
||||
border: 'none',
|
||||
color: '#ffffff'
|
||||
color: '#ffffff',
|
||||
};
|
||||
|
||||
// 主要按钮样式(大)
|
||||
@@ -118,7 +118,7 @@ export const primaryButtonStyle = {
|
||||
fontWeight: '500',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
justifyContent: 'center',
|
||||
};
|
||||
|
||||
// 统计卡片行样式
|
||||
@@ -126,7 +126,7 @@ export const statsRowStyle = {
|
||||
display: 'flex',
|
||||
gap: spacing.md,
|
||||
marginBottom: spacing.lg,
|
||||
flexWrap: 'wrap'
|
||||
flexWrap: 'wrap',
|
||||
};
|
||||
|
||||
// 统计卡片基础样式
|
||||
@@ -139,7 +139,7 @@ export const statCardStyle = {
|
||||
borderRadius: borderRadius.medium,
|
||||
border: `1px solid ${colors.border.light}`,
|
||||
boxShadow: shadows.small,
|
||||
transition: `all ${transitions.fast}`
|
||||
transition: `all ${transitions.fast}`,
|
||||
};
|
||||
|
||||
// 统计数值样式
|
||||
@@ -147,35 +147,35 @@ export const statValueStyle = {
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: colors.text.primary,
|
||||
lineHeight: 1.2
|
||||
lineHeight: 1.2,
|
||||
};
|
||||
|
||||
// 统计标签样式
|
||||
export const statLabelStyle = {
|
||||
fontSize: '12px',
|
||||
color: colors.text.secondary,
|
||||
marginTop: '4px'
|
||||
marginTop: '4px',
|
||||
};
|
||||
|
||||
// 运行中状态统计卡片样式
|
||||
export const statCardRunningStyle = {
|
||||
...statCardStyle,
|
||||
borderLeft: `3px solid ${colors.success.main}`,
|
||||
background: `${colors.success.main}08`
|
||||
background: `${colors.success.main}08`,
|
||||
};
|
||||
|
||||
// 维护中状态统计卡片样式
|
||||
export const statCardMaintenanceStyle = {
|
||||
...statCardStyle,
|
||||
borderLeft: `3px solid ${colors.warning.main}`,
|
||||
background: `${colors.warning.main}08`
|
||||
background: `${colors.warning.main}08`,
|
||||
};
|
||||
|
||||
// 故障状态统计卡片样式
|
||||
export const statCardFaultStyle = {
|
||||
...statCardStyle,
|
||||
borderLeft: `3px solid ${colors.error.main}`,
|
||||
background: `${colors.error.main}08`
|
||||
background: `${colors.error.main}08`,
|
||||
};
|
||||
|
||||
// 卡片基础样式
|
||||
@@ -184,7 +184,7 @@ export const cardStyle = {
|
||||
border: 'none',
|
||||
boxShadow: shadows.medium,
|
||||
overflow: 'hidden',
|
||||
background: colors.background.primary
|
||||
background: colors.background.primary,
|
||||
};
|
||||
|
||||
// 筛选卡片样式
|
||||
@@ -193,7 +193,7 @@ export const filterCardStyle = {
|
||||
border: 'none',
|
||||
boxShadow: shadows.small,
|
||||
background: colors.background.primary,
|
||||
marginBottom: spacing.lg
|
||||
marginBottom: spacing.lg,
|
||||
};
|
||||
|
||||
// 模态框头部样式
|
||||
@@ -202,7 +202,7 @@ export const modalHeaderStyle = {
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
fontSize: '18px',
|
||||
fontWeight: '600'
|
||||
fontWeight: '600',
|
||||
};
|
||||
|
||||
// 表格样式常量
|
||||
@@ -210,23 +210,23 @@ export const tableStyles = {
|
||||
// 表格容器样式
|
||||
wrapper: {
|
||||
borderRadius: borderRadius.medium,
|
||||
overflow: 'hidden'
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
|
||||
// 空状态样式
|
||||
empty: {
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: colors.text.secondary,
|
||||
fontSize: '15px'
|
||||
fontSize: '15px',
|
||||
},
|
||||
|
||||
|
||||
// 空状态图标样式
|
||||
emptyIcon: {
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
color: colors.border.light
|
||||
}
|
||||
color: colors.border.light,
|
||||
},
|
||||
};
|
||||
|
||||
// 搜索输入框样式
|
||||
@@ -234,24 +234,24 @@ export const searchInputStyle = {
|
||||
width: '280px',
|
||||
borderRadius: borderRadius.medium,
|
||||
border: `1px solid ${colors.border.light}`,
|
||||
transition: `all ${transitions.fast}`
|
||||
transition: `all ${transitions.fast}`,
|
||||
};
|
||||
|
||||
// 选择器样式
|
||||
export const selectStyle = {
|
||||
borderRadius: borderRadius.medium
|
||||
borderRadius: borderRadius.medium,
|
||||
};
|
||||
|
||||
// 下拉菜单样式
|
||||
export const dropdownStyle = {
|
||||
borderRadius: borderRadius.medium
|
||||
borderRadius: borderRadius.medium,
|
||||
};
|
||||
|
||||
// 刷新按钮样式
|
||||
export const refreshButtonStyle = {
|
||||
borderRadius: borderRadius.medium,
|
||||
border: `1px solid ${colors.border.light}`,
|
||||
height: '36px'
|
||||
height: '36px',
|
||||
};
|
||||
|
||||
// 搜索按钮样式
|
||||
@@ -260,14 +260,14 @@ export const searchButtonStyle = {
|
||||
borderRadius: borderRadius.medium,
|
||||
background: colors.primary.gradient,
|
||||
border: 'none',
|
||||
boxShadow: shadows.small
|
||||
boxShadow: shadows.small,
|
||||
};
|
||||
|
||||
// 重置按钮样式
|
||||
export const resetButtonStyle = {
|
||||
height: '36px',
|
||||
borderRadius: borderRadius.medium,
|
||||
border: `1px solid ${colors.border.light}`
|
||||
border: `1px solid ${colors.border.light}`,
|
||||
};
|
||||
|
||||
// 导入模态框样式
|
||||
@@ -278,32 +278,32 @@ export const importModalStyles = {
|
||||
padding: '16px',
|
||||
background: 'linear-gradient(180deg, #fafafa 0%, #ffffff 100%)',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #f0f0f0'
|
||||
border: '1px solid #f0f0f0',
|
||||
},
|
||||
|
||||
|
||||
// 标题样式
|
||||
title: {
|
||||
fontWeight: '600',
|
||||
marginBottom: '8px',
|
||||
color: '#333'
|
||||
color: '#333',
|
||||
},
|
||||
|
||||
|
||||
// 列表样式
|
||||
list: {
|
||||
paddingLeft: '20px',
|
||||
marginBottom: '10px',
|
||||
color: '#666',
|
||||
fontSize: '13px',
|
||||
marginTop: '12px'
|
||||
marginTop: '12px',
|
||||
},
|
||||
|
||||
|
||||
// 进度容器样式
|
||||
progressContainer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px'
|
||||
marginBottom: '16px',
|
||||
},
|
||||
|
||||
|
||||
// 进度图标样式
|
||||
progressIcon: {
|
||||
width: '48px',
|
||||
@@ -315,46 +315,49 @@ export const importModalStyles = {
|
||||
justifyContent: 'center',
|
||||
marginRight: '16px',
|
||||
color: '#fff',
|
||||
fontSize: '20px'
|
||||
fontSize: '20px',
|
||||
},
|
||||
|
||||
|
||||
// 进度信息样式
|
||||
progressInfo: {
|
||||
title: {
|
||||
margin: '0 0 4px 0',
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
fontSize: '16px'
|
||||
fontSize: '16px',
|
||||
},
|
||||
phase: {
|
||||
margin: 0,
|
||||
color: colors.primary.main,
|
||||
fontSize: '14px'
|
||||
}
|
||||
fontSize: '14px',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
// 结果卡片样式
|
||||
resultCard: (type) => ({
|
||||
resultCard: type => ({
|
||||
padding: '12px',
|
||||
background: type === 'total' ? colors.primary.gradient :
|
||||
type === 'success' ? 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)' :
|
||||
'linear-gradient(135deg, #ff4d4f 0%, #cf1322 100%)',
|
||||
background:
|
||||
type === 'total'
|
||||
? colors.primary.gradient
|
||||
: type === 'success'
|
||||
? 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)'
|
||||
: 'linear-gradient(135deg, #ff4d4f 0%, #cf1322 100%)',
|
||||
borderRadius: '8px',
|
||||
color: '#fff',
|
||||
textAlign: 'center'
|
||||
textAlign: 'center',
|
||||
}),
|
||||
|
||||
|
||||
// 结果数值样式
|
||||
resultValue: {
|
||||
fontSize: '24px',
|
||||
fontWeight: '700'
|
||||
fontWeight: '700',
|
||||
},
|
||||
|
||||
|
||||
// 结果标签样式
|
||||
resultLabel: {
|
||||
fontSize: '12px',
|
||||
opacity: 0.9
|
||||
}
|
||||
opacity: 0.9,
|
||||
},
|
||||
};
|
||||
|
||||
// 详情模态框样式
|
||||
@@ -363,27 +366,27 @@ export const detailModalStyles = {
|
||||
infoItem: {
|
||||
label: {
|
||||
fontWeight: '500',
|
||||
color: '#666'
|
||||
color: '#666',
|
||||
},
|
||||
value: {
|
||||
marginLeft: 8,
|
||||
color: '#333'
|
||||
}
|
||||
color: '#333',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
// 描述区域样式
|
||||
description: {
|
||||
marginTop: '16px'
|
||||
marginTop: '16px',
|
||||
},
|
||||
|
||||
|
||||
// 描述内容样式
|
||||
descriptionContent: {
|
||||
marginTop: '8px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#fafafa',
|
||||
borderRadius: '8px',
|
||||
color: '#333'
|
||||
}
|
||||
color: '#333',
|
||||
},
|
||||
};
|
||||
|
||||
// 导出模态框样式
|
||||
@@ -394,17 +397,17 @@ export const exportModalStyles = {
|
||||
overflow: 'auto',
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: '8px',
|
||||
padding: '12px'
|
||||
padding: '12px',
|
||||
},
|
||||
|
||||
|
||||
// 字段项样式
|
||||
fieldItem: {
|
||||
marginBottom: '8px'
|
||||
}
|
||||
marginBottom: '8px',
|
||||
},
|
||||
};
|
||||
|
||||
// CSS-in-JS 样式字符串生成函数
|
||||
export const generateGlobalStyles = (tokens) => `
|
||||
export const generateGlobalStyles = tokens => `
|
||||
.device-modal .ant-modal-close {
|
||||
top: 16px;
|
||||
right: 24px;
|
||||
|
||||
Reference in New Issue
Block a user