chore: 统一代码风格并配置ESLint和Prettier

配置ESLint和Prettier规则
添加前端和后端的忽略文件
统一代码格式和缩进
修复代码风格问题
This commit is contained in:
zhang1106
2026-02-10 11:04:01 +08:00
parent f82d82e57e
commit afc372a432
61 changed files with 14976 additions and 6124 deletions
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
root: true,
// 根配置不直接检查文件,而是作为项目入口
// 实际检查由 frontend/ 和 backend/ 各自的配置处理
ignorePatterns: ['frontend/**', 'backend/**', 'node_modules/**', 'dist/**'],
overrides: []
}
+25
View File
@@ -0,0 +1,25 @@
# 依赖
node_modules/
frontend/node_modules/
backend/node_modules/
# 构建输出
dist/
frontend/dist/
backend/dist/
# 日志
logs/
*.log
# 数据库
*.db
*.sqlite
# 上传文件
backend/uploads/
# 其他
.DS_Store
.vscode/
.idea/
+10
View File
@@ -0,0 +1,10 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf"
}
+20
View File
@@ -0,0 +1,20 @@
# 构建输出
dist/
build/
# 依赖
node_modules/
# 日志
logs/
*.log
# 数据库
*.db
*.sqlite
# 上传文件
uploads/
# 其他
.DS_Store
+27
View File
@@ -0,0 +1,27 @@
module.exports = {
root: true,
env: {
node: true,
es2021: true,
jest: true
},
extends: [
'eslint:recommended',
'plugin:prettier/recommended'
],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
},
rules: {
'no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'no-console': ['warn', { allow: ['warn', 'error', 'info'] }],
'no-undef': 'error',
'no-unreachable': 'error',
'no-unused-expressions': 'error',
'eqeqeq': ['error', 'always'],
'curly': ['error', 'all'],
'no-var': 'error',
'prefer-const': 'error'
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf"
}
+2631 -3
View File
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -7,7 +7,11 @@
"dev": "nodemon server.js", "dev": "nodemon server.js",
"create-indexes": "node create_indexes.js", "create-indexes": "node create_indexes.js",
"check-indexes": "node create_indexes.js check", "check-indexes": "node create_indexes.js check",
"drop-indexes": "node create_indexes.js drop" "drop-indexes": "node create_indexes.js drop",
"lint": "eslint . --ext js --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint . --ext js --fix",
"format": "prettier --write \"**/*.js\"",
"format:check": "prettier --check \"**/*.js\""
}, },
"dependencies": { "dependencies": {
"axios": "^1.13.4", "axios": "^1.13.4",
@@ -31,8 +35,15 @@
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.0",
"jest": "^30.2.0", "jest": "^30.2.0",
"nodemon": "^3.0.1", "nodemon": "^3.0.1",
"prettier": "^3.8.1",
"supertest": "^7.1.4" "supertest": "^7.1.4"
} }
} }
+15
View File
@@ -0,0 +1,15 @@
# 构建输出
dist/
build/
# 依赖
node_modules/
# Vite 缓存
.vite/
# 日志
*.log
# 其他
.DS_Store
+26
View File
@@ -0,0 +1,26 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
'plugin:prettier/recommended'
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
settings: { react: { version: '18.2' } },
plugins: ['react-refresh'],
rules: {
'react/jsx-no-target-blank': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true }
],
'no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'no-console': ['warn', { allow: ['warn', 'error'] }],
'react/prop-types': 'off',
'react/display-name': 'off'
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf"
}
+2934 -2
View File
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -6,7 +6,11 @@
"start": "vite", "start": "vite",
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint . --ext js,jsx --fix",
"format": "prettier --write \"src/**/*.{js,jsx,css,json}\"",
"format:check": "prettier --check \"src/**/*.{js,jsx,css,json}\""
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.1.0", "@ant-design/icons": "^6.1.0",
@@ -27,7 +31,14 @@
"@testing-library/react": "^16.3.1", "@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@vitejs/plugin-react": "^4.0.3", "@vitejs/plugin-react": "^4.0.3",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.0",
"jsdom": "^27.3.0", "jsdom": "^27.3.0",
"prettier": "^3.8.1",
"terser": "^5.44.1", "terser": "^5.44.1",
"vite": "^4.4.9", "vite": "^4.4.9",
"vitest": "^4.0.16" "vitest": "^4.0.16"
+351 -137
View File
@@ -1,7 +1,49 @@
import React, { useState, Suspense, lazy } from 'react'; import React, { useState, Suspense, lazy } from 'react';
import { Layout, Menu, theme, Button, Dropdown, Avatar, message, Space, Divider, ConfigProvider as AntdConfigProvider } from 'antd'; import {
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'; Layout,
import { BrowserRouter as Router, Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom'; 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 { useAuth } from './context/AuthContext';
import { ConfigProvider, useConfig } from './context/ConfigContext'; import { ConfigProvider, useConfig } from './context/ConfigContext';
import { Scene3DProvider } from './context/Scene3DContext'; import { Scene3DProvider } from './context/Scene3DContext';
@@ -31,30 +73,34 @@ const PortManagement = lazy(() => import('./pages/PortManagement'));
const { Header, Content, Sider } = Layout; const { Header, Content, Sider } = Layout;
const PageLoading = () => ( const PageLoading = () => (
<div style={{ <div
display: 'flex', style={{
flexDirection: 'column', display: 'flex',
justifyContent: 'center', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
height: '100vh', alignItems: 'center',
background: '#f5f5f5', height: '100vh',
gap: '16px' background: '#f5f5f5',
}}> gap: '16px',
}}
>
<Spin size="large" /> <Spin size="large" />
<span style={{ color: '#8c8c8c', fontSize: '14px' }}>正在加载页面...</span> <span style={{ color: '#8c8c8c', fontSize: '14px' }}>正在加载页面...</span>
</div> </div>
); );
const AuthLoading = () => ( const AuthLoading = () => (
<div style={{ <div
display: 'flex', style={{
flexDirection: 'column', display: 'flex',
justifyContent: 'center', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
height: '100vh', alignItems: 'center',
background: '#f5f5f5', height: '100vh',
gap: '16px' background: '#f5f5f5',
}}> gap: '16px',
}}
>
<Spin size="large" /> <Spin size="large" />
<span style={{ color: '#8c8c8c', fontSize: '14px' }}>正在加载认证状态...</span> <span style={{ color: '#8c8c8c', fontSize: '14px' }}>正在加载认证状态...</span>
</div> </div>
@@ -101,9 +147,21 @@ const AppLayout = ({ children }) => {
if (path === '/') return 'dashboard'; if (path === '/') return 'dashboard';
if (path.startsWith('/visualization-3d')) return 'visualization-3d'; if (path.startsWith('/visualization-3d')) return 'visualization-3d';
if (path.startsWith('/rooms') || path.startsWith('/racks')) return 'room-management'; 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('/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'; if (path.startsWith('/tickets')) return 'ticket-management';
return 'dashboard'; return 'dashboard';
}; };
@@ -218,22 +276,22 @@ const AppLayout = ({ children }) => {
], ],
}, },
{ {
key: 'system-management', key: 'system-management',
icon: <UserOutlined style={{ fontSize: '18px' }} />, icon: <UserOutlined style={{ fontSize: '18px' }} />,
label: '系统管理', label: '系统管理',
children: [ children: [
{ {
key: 'users', key: 'users',
icon: <UserOutlined style={{ fontSize: '16px' }} />, icon: <UserOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/users">用户管理</Link>, label: <Link to="/users">用户管理</Link>,
}, },
{ {
key: 'system-settings', key: 'system-settings',
icon: <SettingOutlined style={{ fontSize: '16px' }} />, icon: <SettingOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/settings">系统设置</Link>, label: <Link to="/settings">系统设置</Link>,
}, },
], ],
}, },
]; ];
return ( return (
@@ -252,56 +310,66 @@ const AppLayout = ({ children }) => {
left: 0, left: 0,
top: 0, top: 0,
bottom: 0, bottom: 0,
zIndex: 100 zIndex: 100,
}} }}
> >
<div style={{ <div
display: 'flex', style={{
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,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: collapsed ? 'center' : 'flex-start',
flexShrink: 0 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' }} /> <CloudServerOutlined style={{ fontSize: '18px', color: '#ffffff' }} />
</div> </div>
{!collapsed && ( {!collapsed && (
<div> <div>
<div style={{ <div
fontSize: '15px', style={{
fontWeight: '600', fontSize: '15px',
color: designTokens.colors.primary.main, fontWeight: '600',
lineHeight: 1.2 color: designTokens.colors.primary.main,
}}> lineHeight: 1.2,
}}
>
{config.site_name || 'IDC管理'} {config.site_name || 'IDC管理'}
</div> </div>
<div style={{ <div
fontSize: '11px', style={{
color: designTokens.colors.sidebar.text, fontSize: '11px',
marginTop: '2px' color: designTokens.colors.sidebar.text,
}}> marginTop: '2px',
}}
>
数据中心管理平台 数据中心管理平台
</div> </div>
</div> </div>
)} )}
</div> </div>
<div style={{ <div
padding: collapsed ? '12px 0' : '12px 8px', style={{
overflowY: 'auto', padding: collapsed ? '12px 0' : '12px 8px',
flex: 1 overflowY: 'auto',
}}> flex: 1,
}}
>
<Menu <Menu
mode="inline" mode="inline"
selectedKeys={[getSelectedKey()]} selectedKeys={[getSelectedKey()]}
@@ -309,16 +377,18 @@ const AppLayout = ({ children }) => {
style={{ style={{
background: 'transparent', background: 'transparent',
borderRight: 0, borderRight: 0,
fontSize: '14px' fontSize: '14px',
}} }}
items={menuItems} items={menuItems}
/> />
</div> </div>
<div style={{ <div
padding: '12px', style={{
borderTop: `1px solid ${designTokens.colors.sidebar.border}` padding: '12px',
}}> borderTop: `1px solid ${designTokens.colors.sidebar.border}`,
}}
>
<Button <Button
type="text" type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />} icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
@@ -332,48 +402,60 @@ const AppLayout = ({ children }) => {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: collapsed ? 'center' : 'flex-start', 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> </Button>
</div> </div>
</Sider> </Sider>
<Layout style={{ <Layout
marginLeft: collapsed ? 72 : 240, style={{
transition: 'margin-left 0.2s ease' marginLeft: collapsed ? 72 : 240,
}}> transition: 'margin-left 0.2s ease',
<Header style={{ }}
padding: '0 24px', >
height: 64, <Header
background: designTokens.colors.background.primary, style={{
display: 'flex', padding: '0 24px',
justifyContent: 'flex-end', height: 64,
alignItems: 'center', background: designTokens.colors.background.primary,
boxShadow: designTokens.shadows.small, display: 'flex',
position: 'sticky', justifyContent: 'flex-end',
top: 0, alignItems: 'center',
zIndex: 99, boxShadow: designTokens.shadows.small,
overflow: 'visible' position: 'sticky',
}}> top: 0,
zIndex: 99,
overflow: 'visible',
}}
>
{user && ( {user && (
<div style={{ <div
display: 'flex', style={{
alignItems: 'center',
gap: '12px',
height: '100%'
}}>
<div style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '8px', gap: '12px',
padding: '4px 12px', height: '100%',
background: designTokens.colors.background.secondary, }}
borderRadius: designTokens.borderRadius.medium, >
height: '40px', <div
boxSizing: 'border-box' 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 <Avatar
style={{ style={{
backgroundColor: designTokens.colors.primary.main, backgroundColor: designTokens.colors.primary.main,
@@ -382,15 +464,19 @@ const AppLayout = ({ children }) => {
height: 32, height: 32,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center' justifyContent: 'center',
}} }}
icon={<UserOutlined style={{ fontSize: '14px' }} />} icon={<UserOutlined style={{ fontSize: '14px' }} />}
/> />
<span style={{ <span
color: designTokens.colors.text.primary, style={{
fontSize: 14, color: designTokens.colors.text.primary,
fontWeight: 500 fontSize: 14,
}}>{user.username}</span> fontWeight: 500,
}}
>
{user.username}
</span>
</div> </div>
<Button <Button
type="text" type="text"
@@ -401,7 +487,7 @@ const AppLayout = ({ children }) => {
padding: '8px 12px', padding: '8px 12px',
height: 'auto', height: 'auto',
borderRadius: designTokens.borderRadius.small, borderRadius: designTokens.borderRadius.small,
fontSize: 13 fontSize: 13,
}} }}
> >
退出 退出
@@ -414,7 +500,7 @@ const AppLayout = ({ children }) => {
padding: 24, padding: 24,
margin: 0, margin: 0,
minHeight: 'calc(100vh - 64px)', minHeight: 'calc(100vh - 64px)',
background: designTokens.colors.background.secondary background: designTokens.colors.background.secondary,
}} }}
> >
{children} {children}
@@ -433,24 +519,152 @@ const ThemeConfig = () => {
<Suspense fallback={<PageLoading />}> <Suspense fallback={<PageLoading />}>
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/" element={<PrivateRoute><Dashboard /></PrivateRoute>} /> <Route
<Route path="/devices" element={<PrivateRoute><DeviceManagement /></PrivateRoute>} /> path="/"
<Route path="/racks" element={<PrivateRoute><RackManagement /></PrivateRoute>} /> element={
<Route path="/rooms" element={<PrivateRoute><RoomManagement /></PrivateRoute>} /> <PrivateRoute>
<Route path="/fields" element={<PrivateRoute><DeviceFieldManagement /></PrivateRoute>} /> <Dashboard />
<Route path="/visualization-3d" element={<PrivateRoute><Scene3DProvider><Rack3DVisualization /></Scene3DProvider></PrivateRoute>} /> </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
<Route path="/consumables-logs" element={<PrivateRoute><ConsumableLogs /></PrivateRoute>} /> path="/devices"
<Route path="/users" element={<PrivateRoute><UserManagement /></PrivateRoute>} /> element={
<Route path="/tickets" element={<PrivateRoute><TicketManagement /></PrivateRoute>} /> <PrivateRoute>
<Route path="/ticket-categories" element={<PrivateRoute><TicketCategoryManagement /></PrivateRoute>} /> <DeviceManagement />
<Route path="/ticket-statistics" element={<PrivateRoute><TicketStatistics /></PrivateRoute>} /> </PrivateRoute>
<Route path="/ticket-fields" element={<PrivateRoute><TicketFieldManagement /></PrivateRoute>} /> }
<Route path="/settings" element={<PrivateRoute><SystemSettings /></PrivateRoute>} /> />
<Route path="/cables" element={<PrivateRoute><CableManagement /></PrivateRoute>} /> <Route
<Route path="/ports" element={<PrivateRoute><PortManagement /></PrivateRoute>} /> 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 />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Suspense> </Suspense>
+77 -80
View File
@@ -9,7 +9,7 @@ const cacheManager = (() => {
return `${method}:${url}:${paramsStr}`; return `${method}:${url}:${paramsStr}`;
}; };
const isExpired = (key) => { const isExpired = key => {
const timestamp = cacheTimestamps.get(key); const timestamp = cacheTimestamps.get(key);
if (!timestamp) return true; if (!timestamp) return true;
const ttl = config.get(key)?.ttl || defaultTTL; const ttl = config.get(key)?.ttl || defaultTTL;
@@ -34,7 +34,7 @@ const cacheManager = (() => {
return key; return key;
}; };
const invalidate = (url) => { const invalidate = url => {
const keysToDelete = []; const keysToDelete = [];
cache.forEach((_, key) => { cache.forEach((_, key) => {
if (key.includes(url)) { if (key.includes(url)) {
@@ -49,7 +49,7 @@ const cacheManager = (() => {
return keysToDelete.length; return keysToDelete.length;
}; };
const invalidatePattern = (pattern) => { const invalidatePattern = pattern => {
const regex = new RegExp(pattern); const regex = new RegExp(pattern);
const keysToDelete = []; const keysToDelete = [];
cache.forEach((_, key) => { cache.forEach((_, key) => {
@@ -78,7 +78,7 @@ const cacheManager = (() => {
const getStats = () => { const getStats = () => {
return { return {
size: cache.size, size: cache.size,
keys: Array.from(cache.keys()) keys: Array.from(cache.keys()),
}; };
}; };
@@ -90,37 +90,29 @@ const cacheManager = (() => {
clear, clear,
setTTL, setTTL,
getStats, getStats,
defaultTTL defaultTTL,
}; };
})(); })();
const cacheInterceptor = (api) => { const cacheInterceptor = api => {
const requestCache = new Set(); const requestCache = new Set();
const pendingRequests = new Map(); const pendingRequests = new Map();
api.interceptors.request.use( api.interceptors.request.use(
(config) => { config => {
if (config.method?.toLowerCase() === 'get') { if (config.method?.toLowerCase() === 'get') {
const cacheKey = cacheManager.generateKey( const cacheKey = cacheManager.generateKey(config.method, config.url, config.params);
config.method,
config.url,
config.params
);
if (requestCache.has(cacheKey)) { if (requestCache.has(cacheKey)) {
config.adapter = () => { config.adapter = () => {
const cachedData = cacheManager.get( const cachedData = cacheManager.get(config.method, config.url, config.params);
config.method,
config.url,
config.params
);
if (cachedData) { if (cachedData) {
return Promise.resolve({ return Promise.resolve({
data: cachedData, data: cachedData,
status: 200, status: 200,
statusText: 'OK', statusText: 'OK',
headers: {}, headers: {},
config config,
}); });
} }
requestCache.delete(cacheKey); requestCache.delete(cacheKey);
@@ -130,11 +122,11 @@ const cacheInterceptor = (api) => {
} }
return config; return config;
}, },
(error) => Promise.reject(error) error => Promise.reject(error)
); );
api.interceptors.response.use( api.interceptors.response.use(
(response) => { response => {
if (response.config.method?.toLowerCase() === 'get') { if (response.config.method?.toLowerCase() === 'get') {
const cacheKey = cacheManager.generateKey( const cacheKey = cacheManager.generateKey(
response.config.method, response.config.method,
@@ -151,7 +143,7 @@ const cacheInterceptor = (api) => {
} }
return response; return response;
}, },
(error) => { error => {
if (error.config) { if (error.config) {
const cacheKey = cacheManager.generateKey( const cacheKey = cacheManager.generateKey(
error.config.method, error.config.method,
@@ -178,108 +170,113 @@ export const cachedAPI = {
}); });
}, },
post: (url, data) => api.post(url, data).then(data => { post: (url, data) =>
cacheManager.invalidate(url); api.post(url, data).then(data => {
return data; cacheManager.invalidate(url);
}), return data;
}),
put: (url, data) => api.put(url, data).then(data => { put: (url, data) =>
cacheManager.invalidate(url); api.put(url, data).then(data => {
return data; cacheManager.invalidate(url);
}), return data;
}),
delete: (url) => api.delete(url).then(data => { delete: url =>
cacheManager.invalidate(url); api.delete(url).then(data => {
return 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(), clearCache: () => cacheManager.clear(),
setCacheTTL: (url, ttl) => cacheManager.setTTL(url, ttl), setCacheTTL: (url, ttl) => cacheManager.setTTL(url, ttl),
getCacheStats: () => cacheManager.getStats() getCacheStats: () => cacheManager.getStats(),
}; };
export const deviceAPI = { export const deviceAPI = {
list: (params) => cachedAPI.get('/devices', params), list: params => cachedAPI.get('/devices', params),
get: (deviceId) => cachedAPI.get(`/devices/${deviceId}`), get: deviceId => cachedAPI.get(`/devices/${deviceId}`),
create: (data) => cachedAPI.post('/devices', data), create: data => cachedAPI.post('/devices', data),
update: (deviceId, data) => cachedAPI.put(`/api/devices/${deviceId}`, data), update: (deviceId, data) => cachedAPI.put(`/api/devices/${deviceId}`, data),
delete: (deviceId) => cachedAPI.delete(`/api/devices/${deviceId}`), delete: deviceId => cachedAPI.delete(`/api/devices/${deviceId}`),
batchOffline: (data) => cachedAPI.post('/devices/batch-offline', data), batchOffline: data => cachedAPI.post('/devices/batch-offline', data),
batchDelete: (data) => cachedAPI.delete('/devices/batch-delete', { data }), batchDelete: data => cachedAPI.delete('/devices/batch-delete', { data }),
export: (params) => api.get('/devices/export', { params, responseType: 'blob' }), export: params => api.get('/devices/export', { params, responseType: 'blob' }),
import: (formData) => api.post('/devices/import', formData, { import: formData =>
headers: { 'Content-Type': 'multipart/form-data' } api.post('/devices/import', formData, {
}) headers: { 'Content-Type': 'multipart/form-data' },
}),
}; };
export const rackAPI = { export const rackAPI = {
list: (params) => cachedAPI.get('/racks', params), list: params => cachedAPI.get('/racks', params),
get: (rackId) => cachedAPI.get(`/racks/${rackId}`), get: rackId => cachedAPI.get(`/racks/${rackId}`),
create: (data) => cachedAPI.post('/racks', data), create: data => cachedAPI.post('/racks', data),
update: (rackId, data) => cachedAPI.put(`/racks/${rackId}`, data), update: (rackId, data) => cachedAPI.put(`/racks/${rackId}`, data),
delete: (rackId) => cachedAPI.delete(`/racks/${rackId}`), delete: rackId => cachedAPI.delete(`/racks/${rackId}`),
import: (formData) => api.post('/racks/import', formData, { import: formData =>
headers: { 'Content-Type': 'multipart/form-data' } api.post('/racks/import', formData, {
}) headers: { 'Content-Type': 'multipart/form-data' },
}),
}; };
export const roomAPI = { export const roomAPI = {
list: (params) => cachedAPI.get('/rooms', params), list: params => cachedAPI.get('/rooms', params),
get: (roomId) => cachedAPI.get(`/rooms/${roomId}`), get: roomId => cachedAPI.get(`/rooms/${roomId}`),
create: (data) => cachedAPI.post('/rooms', data), create: data => cachedAPI.post('/rooms', data),
update: (roomId, data) => cachedAPI.put(`/rooms/${roomId}`, data), update: (roomId, data) => cachedAPI.put(`/rooms/${roomId}`, data),
delete: (roomId) => cachedAPI.delete(`/rooms/${roomId}`) delete: roomId => cachedAPI.delete(`/rooms/${roomId}`),
}; };
export const deviceFieldAPI = { export const deviceFieldAPI = {
list: () => cachedAPI.get('/deviceFields'), list: () => cachedAPI.get('/deviceFields'),
get: (fieldId) => cachedAPI.get(`/deviceFields/${fieldId}`), get: fieldId => cachedAPI.get(`/deviceFields/${fieldId}`),
create: (data) => cachedAPI.post('/deviceFields', data), create: data => cachedAPI.post('/deviceFields', data),
update: (fieldId, data) => cachedAPI.put(`/deviceFields/${fieldId}`, data), update: (fieldId, data) => cachedAPI.put(`/deviceFields/${fieldId}`, data),
delete: (fieldId) => cachedAPI.delete(`/deviceFields/${fieldId}`), delete: fieldId => cachedAPI.delete(`/deviceFields/${fieldId}`),
updateConfig: (data) => cachedAPI.post('/deviceFields/config', data) updateConfig: data => cachedAPI.post('/deviceFields/config', data),
}; };
export const consumableAPI = { export const consumableAPI = {
list: (params) => cachedAPI.get('/consumables', params), list: params => cachedAPI.get('/consumables', params),
get: (consumableId) => cachedAPI.get(`/consumables/${consumableId}`), get: consumableId => cachedAPI.get(`/consumables/${consumableId}`),
create: (data) => cachedAPI.post('/consumables', data), create: data => cachedAPI.post('/consumables', data),
update: (consumableId, data) => cachedAPI.put(`/consumables/${consumableId}`, data), update: (consumableId, data) => cachedAPI.put(`/consumables/${consumableId}`, data),
delete: (consumableId) => cachedAPI.delete(`/consumables/${consumableId}`), delete: consumableId => cachedAPI.delete(`/consumables/${consumableId}`),
import: (data) => cachedAPI.post('/consumables/import', data), import: data => cachedAPI.post('/consumables/import', data),
quickInOut: (data) => cachedAPI.post('/consumables/quick-inout', data), quickInOut: data => cachedAPI.post('/consumables/quick-inout', data),
getStatistics: () => cachedAPI.get('/consumables/statistics/summary'), getStatistics: () => cachedAPI.get('/consumables/statistics/summary'),
getLowStock: () => cachedAPI.get('/consumables/low-stock') getLowStock: () => cachedAPI.get('/consumables/low-stock'),
}; };
export const consumableCategoryAPI = { export const consumableCategoryAPI = {
list: (params) => cachedAPI.get('/consumable-categories', params), list: params => cachedAPI.get('/consumable-categories', params),
getList: (params) => cachedAPI.get('/consumable-categories/list', params), getList: params => cachedAPI.get('/consumable-categories/list', params),
create: (data) => cachedAPI.post('/consumable-categories', data), create: data => cachedAPI.post('/consumable-categories', data),
update: (id, data) => cachedAPI.put(`/consumable-categories/${id}`, 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 = { export const consumableLogAPI = {
list: (params) => cachedAPI.get('/consumables/logs', params), list: params => cachedAPI.get('/consumables/logs', params),
create: (data) => cachedAPI.post('/consumables/logs', data), create: data => cachedAPI.post('/consumables/logs', data),
export: (params) => api.get('/consumables/logs/export', { params, responseType: 'blob' }), export: params => api.get('/consumables/logs/export', { params, responseType: 'blob' }),
import: (data) => cachedAPI.post('/consumables/logs/import', data) import: data => cachedAPI.post('/consumables/logs/import', data),
}; };
export const ticketCategoryAPI = { export const ticketCategoryAPI = {
list: (params) => cachedAPI.get('/ticket-categories', params), list: params => cachedAPI.get('/ticket-categories', params),
create: (data) => cachedAPI.post('/ticket-categories', data), create: data => cachedAPI.post('/ticket-categories', data),
update: (code, data) => cachedAPI.put(`/ticket-categories/${code}`, 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'), getTree: () => cachedAPI.get('/ticket-categories/tree'),
init: () => cachedAPI.post('/ticket-categories/init') init: () => cachedAPI.post('/ticket-categories/init'),
}; };
export { cacheManager }; export { cacheManager };
+41 -41
View File
@@ -6,12 +6,12 @@ const api = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
timeout: 30000, timeout: 30000,
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json',
} },
}); });
api.interceptors.request.use( api.interceptors.request.use(
(config) => { config => {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
@@ -31,16 +31,16 @@ api.interceptors.request.use(
return config; return config;
}, },
(error) => { error => {
return Promise.reject(error); return Promise.reject(error);
} }
); );
api.interceptors.response.use( api.interceptors.response.use(
(response) => { response => {
return response.data; return response.data;
}, },
(error) => { error => {
if (error.response) { if (error.response) {
const { status, data } = error.response; const { status, data } = error.response;
@@ -72,82 +72,82 @@ api.interceptors.response.use(
export const authAPI = { export const authAPI = {
checkAdmin: () => api.get('/auth/check-admin'), checkAdmin: () => api.get('/auth/check-admin'),
register: (data) => api.post('/auth/register', data), register: data => api.post('/auth/register', data),
login: (data) => api.post('/auth/login', data), login: data => api.post('/auth/login', data),
unlock: (data) => api.post('/auth/unlock', data), unlock: data => api.post('/auth/unlock', data),
getProfile: () => api.get('/auth/profile'), getProfile: () => api.get('/auth/profile'),
updateProfile: (data) => api.put('/auth/profile', data), updateProfile: data => api.put('/auth/profile', data),
changePassword: (data) => api.put('/auth/password', data) changePassword: data => api.put('/auth/password', data),
}; };
export const userAPI = { export const userAPI = {
list: (params) => api.get('/users', { params }), list: params => api.get('/users', { params }),
all: () => api.get('/users/all'), all: () => api.get('/users/all'),
get: (userId) => api.get(`/users/${userId}`), get: userId => api.get(`/users/${userId}`),
create: (data) => api.post('/users', data), create: data => api.post('/users', data),
update: (userId, data) => api.put(`/users/${userId}`, data), update: (userId, data) => api.put(`/users/${userId}`, data),
resetPassword: (userId, data) => api.put(`/users/${userId}/password`, 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) => { uploadAvatar: (userId, file) => {
const formData = new FormData(); const formData = new FormData();
formData.append('avatar', file); formData.append('avatar', file);
return api.post(`/users/${userId}/avatar`, formData, { 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`), deleteAvatar: userId => api.delete(`/users/${userId}/avatar`),
approve: (userId) => api.put(`/users/${userId}/approve`), approve: userId => api.put(`/users/${userId}/approve`),
reject: (userId) => api.put(`/users/${userId}/reject`) reject: userId => api.put(`/users/${userId}/reject`),
}; };
export const roleAPI = { export const roleAPI = {
list: (params) => api.get('/roles', { params }), list: params => api.get('/roles', { params }),
all: () => api.get('/roles/all'), all: () => api.get('/roles/all'),
get: (roleId) => api.get(`/roles/${roleId}`), get: roleId => api.get(`/roles/${roleId}`),
create: (data) => api.post('/roles', data), create: data => api.post('/roles', data),
update: (roleId, data) => api.put(`/roles/${roleId}`, data), update: (roleId, data) => api.put(`/roles/${roleId}`, data),
delete: (roleId) => api.delete(`/roles/${roleId}`), delete: roleId => api.delete(`/roles/${roleId}`),
initRoles: () => api.post('/roles/init-roles') initRoles: () => api.post('/roles/init-roles'),
}; };
export const loginHistoryAPI = { 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 }), getByUser: (userId, params) => api.get(`/login-history/user/${userId}`, { params }),
delete: (id) => api.delete(`/login-history/${id}`), delete: id => api.delete(`/login-history/${id}`),
clear: (data) => api.delete('/login-history', { data }) clear: data => api.delete('/login-history', { data }),
}; };
export const operationLogAPI = { export const operationLogAPI = {
list: (params) => api.get('/operation-logs', { params }), list: params => api.get('/operation-logs', { params }),
getActions: () => api.get('/operation-logs/actions'), getActions: () => api.get('/operation-logs/actions'),
getModules: () => api.get('/operation-logs/modules'), getModules: () => api.get('/operation-logs/modules'),
delete: (id) => api.delete(`/operation-logs/${id}`), delete: id => api.delete(`/operation-logs/${id}`),
clear: (data) => api.delete('/operation-logs', { data }) clear: data => api.delete('/operation-logs', { data }),
}; };
export const ticketAPI = { export const ticketAPI = {
list: (params) => api.get('/tickets', { params }), list: params => api.get('/tickets', { params }),
get: (ticketId) => api.get(`/tickets/${ticketId}`), get: ticketId => api.get(`/tickets/${ticketId}`),
create: (data) => api.post('/tickets', data), create: data => api.post('/tickets', data),
update: (ticketId, data) => api.put(`/tickets/${ticketId}`, 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), assign: (ticketId, data) => api.put(`/tickets/${ticketId}/assign`, data),
transfer: (ticketId, data) => api.put(`/tickets/${ticketId}/transfer`, data), transfer: (ticketId, data) => api.put(`/tickets/${ticketId}/transfer`, data),
process: (ticketId, data) => api.put(`/tickets/${ticketId}/process`, data), process: (ticketId, data) => api.put(`/tickets/${ticketId}/process`, data),
close: (ticketId, data) => api.put(`/tickets/${ticketId}/close`, data), close: (ticketId, data) => api.put(`/tickets/${ticketId}/close`, data),
reopen: (ticketId, data) => api.put(`/tickets/${ticketId}/reopen`, data), reopen: (ticketId, data) => api.put(`/tickets/${ticketId}/reopen`, data),
getOperations: (ticketId) => api.get(`/tickets/${ticketId}/operations`), getOperations: ticketId => api.get(`/tickets/${ticketId}/operations`),
getStatistics: (params) => api.get('/tickets/statistics', { params }) getStatistics: params => api.get('/tickets/statistics', { params }),
}; };
export const ticketCategoryAPI = { export const ticketCategoryAPI = {
list: (params) => api.get('/ticket-categories', { params }), list: params => api.get('/ticket-categories', { params }),
get: (code) => api.get(`/ticket-categories/${code}`), get: code => api.get(`/ticket-categories/${code}`),
create: (data) => api.post('/ticket-categories', data), create: data => api.post('/ticket-categories', data),
update: (code, data) => api.put(`/ticket-categories/${code}`, 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'), tree: () => api.get('/ticket-categories/tree'),
init: () => api.post('/ticket-categories/init') init: () => api.post('/ticket-categories/init'),
}; };
export default api; export default api;
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -5,12 +5,12 @@ import * as THREE from 'three';
export const LOD_LEVELS = { export const LOD_LEVELS = {
HIGH: 0, HIGH: 0,
MEDIUM: 1, MEDIUM: 1,
LOW: 2 LOW: 2,
}; };
export const LOD_DISTANCES = { export const LOD_DISTANCES = {
HIGH: 5, HIGH: 5,
MEDIUM: 10 MEDIUM: 10,
}; };
const createSimplifiedDeviceMesh = (device, uHeight, rackDepth, deviceColor, statusColor) => { 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]} /> <boxGeometry args={[chassisWidth, height - 0.002, chassisDepth]} />
<meshStandardMaterial color="#333333" roughness={0.9} metalness={0.3} /> <meshStandardMaterial color="#333333" roughness={0.9} metalness={0.3} />
</mesh> </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]} /> <circleGeometry args={[0.006, 16]} />
<meshBasicMaterial color={statusColor} toneMapped={false} /> <meshBasicMaterial color={statusColor} toneMapped={false} />
</mesh> </mesh>
@@ -65,9 +65,9 @@ const createMediumDeviceMesh = (device, uHeight, rackDepth, deviceColor, statusC
<meshStandardMaterial color={deviceColor} roughness={0.8} metalness={0.1} /> <meshStandardMaterial color={deviceColor} roughness={0.8} metalness={0.1} />
</mesh> </mesh>
<mesh position={[0, 0, frontZ - panelDepth - chassisDepth / 2]}> <mesh position={[0, 0, frontZ - panelDepth - chassisDepth / 2]}>
<boxGeometry args={[chassisWidth, height - 0.002, chassisDepth]} /> <boxGeometry args={[chassisWidth, height - 0.002, chassisDepth]} />
<meshStandardMaterial color="#333333" roughness={0.9} metalness={0.3} /> <meshStandardMaterial color="#333333" roughness={0.9} metalness={0.3} />
</mesh> </mesh>
<group position={[-0.18, 0, frontZ + 0.006]}> <group position={[-0.18, 0, frontZ + 0.006]}>
<mesh position={[-0.02, 0, 0]}> <mesh position={[-0.02, 0, 0]}>
<boxGeometry args={[0.04, height - 0.01, 0.002]} /> <boxGeometry args={[0.04, height - 0.01, 0.002]} />
@@ -86,7 +86,7 @@ const createMediumDeviceMesh = (device, uHeight, rackDepth, deviceColor, statusC
</mesh> </mesh>
))} ))}
</group> </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]} /> <circleGeometry args={[0.006, 16]} />
<meshBasicMaterial color={statusColor} toneMapped={false} /> <meshBasicMaterial color={statusColor} toneMapped={false} />
</mesh> </mesh>
@@ -102,7 +102,7 @@ const LODManager = ({
deviceColor, deviceColor,
statusColor, statusColor,
children, children,
level = LOD_LEVELS.HIGH level = LOD_LEVELS.HIGH,
}) => { }) => {
const groupRef = useRef(); const groupRef = useRef();
const highDetailRef = useRef(); const highDetailRef = useRef();
+70 -76
View File
@@ -14,7 +14,7 @@ const RackModel = ({
onAddNic, onAddNic,
onAddPort, onAddPort,
tooltipFields, tooltipFields,
deviceSlideEnabled = true deviceSlideEnabled = true,
}) => { }) => {
const width = 0.6; const width = 0.6;
const depth = 1.0; const depth = 1.0;
@@ -33,21 +33,21 @@ const RackModel = ({
storage: '#8b5cf6', storage: '#8b5cf6',
default: '#3b82f6', default: '#3b82f6',
status: { status: {
running: '#10b981', running: '#10b981',
warning: '#f59e0b', warning: '#f59e0b',
error: '#ef4444', error: '#ef4444',
offline: '#6b7280' offline: '#6b7280',
} },
}; };
const getDeviceColor = (type) => { const getDeviceColor = type => {
const t = type?.toLowerCase() || ''; const t = type?.toLowerCase() || '';
if (t.includes('server') || t.includes('服务器')) return colors.server; if (t.includes('server') || t.includes('服务器')) return colors.server;
if (t.includes('switch') || t.includes('交换机')) return colors.switch; if (t.includes('switch') || t.includes('交换机')) return colors.switch;
if (t.includes('router') || t.includes('路由器')) return colors.router; if (t.includes('router') || t.includes('路由器')) return colors.router;
if (t.includes('firewall') || t.includes('防火墙')) return colors.firewall; if (t.includes('firewall') || t.includes('防火墙')) return colors.firewall;
if (t.includes('storage') || t.includes('存储')) return colors.storage; if (t.includes('storage') || t.includes('存储')) return colors.storage;
return colors.default; return colors.default;
}; };
// 设备组的Y偏移量(与下方设备渲染的偏移一致) // 设备组的Y偏移量(与下方设备渲染的偏移一致)
@@ -62,7 +62,7 @@ const RackModel = ({
const yPos = (u - 1) * uHeight + uHeight / 2 + deviceGroupOffset; const yPos = (u - 1) * uHeight + uHeight / 2 + deviceGroupOffset;
// 创建数字纹理 - 白色数字在深色背景上更清晰 // 创建数字纹理 - 白色数字在深色背景上更清晰
const createNumberTexture = (num) => { const createNumberTexture = num => {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = 128; canvas.width = 128;
canvas.height = 128; canvas.height = 128;
@@ -90,9 +90,9 @@ const RackModel = ({
const planeGeometry = new THREE.PlaneGeometry(planeSize, planeSize); const planeGeometry = new THREE.PlaneGeometry(planeSize, planeSize);
// 前侧柱子的位置: [-width/2 + postWidth/2, y, depth/2 - postWidth/2] // 前侧柱子的位置: [-width/2 + postWidth/2, y, depth/2 - postWidth/2]
const leftPostX = -width/2 + postWidth/2; const leftPostX = -width / 2 + postWidth / 2;
const rightPostX = width/2 - postWidth/2; const rightPostX = width / 2 - postWidth / 2;
const frontPostZ = depth/2 - postWidth/2; const frontPostZ = depth / 2 - postWidth / 2;
// 刻度线颜色:每5U使用醒目的黄色,其他使用灰色 // 刻度线颜色:每5U使用醒目的黄色,其他使用灰色
const tickColor = isMajorU ? '#fbbf24' : '#6b7280'; const tickColor = isMajorU ? '#fbbf24' : '#6b7280';
@@ -102,7 +102,7 @@ const RackModel = ({
<group key={`u-label-${u}`}> <group key={`u-label-${u}`}>
{/* 左前侧柱子上的U位标识 - 贴在柱子正侧面(面向前方) */} {/* 左前侧柱子上的U位标识 - 贴在柱子正侧面(面向前方) */}
<mesh <mesh
position={[leftPostX, yPos, frontPostZ + postWidth/2 + 0.001]} position={[leftPostX, yPos, frontPostZ + postWidth / 2 + 0.001]}
geometry={planeGeometry} geometry={planeGeometry}
> >
<meshBasicMaterial <meshBasicMaterial
@@ -114,7 +114,7 @@ const RackModel = ({
</mesh> </mesh>
{/* 右前侧柱子上的U位标识 - 贴在柱子正侧面(面向前方) */} {/* 右前侧柱子上的U位标识 - 贴在柱子正侧面(面向前方) */}
<mesh <mesh
position={[rightPostX, yPos, frontPostZ + postWidth/2 + 0.001]} position={[rightPostX, yPos, frontPostZ + postWidth / 2 + 0.001]}
geometry={planeGeometry} geometry={planeGeometry}
> >
<meshBasicMaterial <meshBasicMaterial
@@ -125,12 +125,12 @@ const RackModel = ({
/> />
</mesh> </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]} /> <boxGeometry args={[postWidth, tickHeight, 0.001]} />
<meshBasicMaterial color={tickColor} /> <meshBasicMaterial color={tickColor} />
</mesh> </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]} /> <boxGeometry args={[postWidth, tickHeight, 0.001]} />
<meshBasicMaterial color={tickColor} /> <meshBasicMaterial color={tickColor} />
</mesh> </mesh>
@@ -142,53 +142,47 @@ const RackModel = ({
// 生成机柜框架 // 生成机柜框架
const frame = useMemo(() => { 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 postArgs = [postWidth, height, postWidth];
const topBottomArgs = [width + 0.02, 0.02, depth + 0.02]; const topBottomArgs = [width + 0.02, 0.02, depth + 0.02];
return ( return (
<group> <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} /> <boxGeometry args={postArgs} />
<meshStandardMaterial {...materialProps} /> <meshStandardMaterial {...materialProps} />
</mesh> </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} /> <boxGeometry args={postArgs} />
<meshStandardMaterial {...materialProps} /> <meshStandardMaterial {...materialProps} />
</mesh> </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} /> <boxGeometry args={postArgs} />
<meshStandardMaterial {...materialProps} /> <meshStandardMaterial {...materialProps} />
</mesh> </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} /> <boxGeometry args={postArgs} />
<meshStandardMaterial {...materialProps} /> <meshStandardMaterial {...materialProps} />
</mesh> </mesh>
<mesh position={[0, height, 0]}> <mesh position={[0, height, 0]}>
<boxGeometry args={topBottomArgs} /> <boxGeometry args={topBottomArgs} />
<meshStandardMaterial {...materialProps} /> <meshStandardMaterial {...materialProps} />
</mesh> </mesh>
<mesh position={[0, 0, 0]}> <mesh position={[0, 0, 0]}>
<boxGeometry args={topBottomArgs} /> <boxGeometry args={topBottomArgs} />
<meshStandardMaterial {...materialProps} /> <meshStandardMaterial {...materialProps} />
</mesh> </mesh>
{[-1, 1].map((side) => ( {[-1, 1].map(side => (
<mesh key={`side-${side}`} position={[side * (width/2 - 0.005), height/2, 0]}> <mesh key={`side-${side}`} position={[side * (width / 2 - 0.005), height / 2, 0]}>
<boxGeometry args={[0.01, height - 0.04, depth - 0.04]} /> <boxGeometry args={[0.01, height - 0.04, depth - 0.04]} />
<meshStandardMaterial <meshStandardMaterial color="#2d3748" roughness={0.4} metalness={0.7} side={2} />
color="#2d3748" </mesh>
roughness={0.4}
metalness={0.7}
side={2}
/>
</mesh>
))} ))}
{/* U位刻度标识 */} {/* U位刻度标识 */}
{uLabels} {uLabels}
</group> </group>
); );
}, [width, height, depth, postWidth, uLabels]); }, [width, height, depth, postWidth, uLabels]);
@@ -198,42 +192,42 @@ const RackModel = ({
{frame} {frame}
<group position={[0, 0.1, 0]}> <group position={[0, 0.1, 0]}>
{devices.map((device) => { {devices.map(device => {
const uStart = device.position || device.u_position || 1; const uStart = device.position || device.u_position || 1;
const uSize = device.height || device.u_height || 1; const uSize = device.height || device.u_height || 1;
const yPos = (uStart - 1) * uHeight + (uSize * uHeight) / 2; const yPos = (uStart - 1) * uHeight + (uSize * uHeight) / 2;
const deviceColor = getDeviceColor(device.type); const deviceColor = getDeviceColor(device.type);
const statusColor = colors.status[device.status] || colors.status.running; const statusColor = colors.status[device.status] || colors.status.running;
return ( return (
<LODManager <LODManager
key={device.id} key={device.id}
device={device} device={device}
uHeight={uHeight} uHeight={uHeight}
rackDepth={depth} rackDepth={depth}
position={[0, yPos, 0]} position={[0, yPos, 0]}
deviceColor={deviceColor} deviceColor={deviceColor}
statusColor={statusColor} statusColor={statusColor}
level={LOD_LEVELS.HIGH} level={LOD_LEVELS.HIGH}
> >
<DeviceModel <DeviceModel
device={device} device={device}
uHeight={uHeight} uHeight={uHeight}
rackDepth={depth} rackDepth={depth}
position={[0, 0, 0]} position={[0, 0, 0]}
isSelected={selectedDeviceId === device.id} isSelected={selectedDeviceId === device.id}
onClick={onDeviceClick} onClick={onDeviceClick}
onPointerOver={onDeviceHover} onPointerOver={onDeviceHover}
onPointerOut={onDeviceLeave} onPointerOut={onDeviceLeave}
onEdit={onEditDevice} onEdit={onEditDevice}
onAddNic={onAddNic} onAddNic={onAddNic}
onAddPort={onAddPort} onAddPort={onAddPort}
tooltipFields={tooltipFields} tooltipFields={tooltipFields}
slideEnabled={deviceSlideEnabled} slideEnabled={deviceSlideEnabled}
/> />
</LODManager> </LODManager>
); );
})} })}
</group> </group>
</group> </group>
+109 -87
View File
@@ -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 { Canvas, useFrame, useThree } from '@react-three/fiber';
import { OrbitControls, PerspectiveCamera, Environment } from '@react-three/drei'; import { OrbitControls, PerspectiveCamera, Environment } from '@react-three/drei';
const envMapUrl = '/assets/3d/env.hdr'; const envMapUrl = '/assets/3d/env.hdr';
@@ -22,7 +29,7 @@ const Controls = ({ rack, onControlsReady }) => {
const { camera } = useThree(); const { camera } = useThree();
// 机柜中心点(中轴线) // 机柜中心点(中轴线)
const rackHeight = rack?.height || 45; 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]); const fixedTarget = useMemo(() => new THREE.Vector3(0, targetY, 0), [targetY]);
// 根据机柜高度计算合适的相机距离限制 // 根据机柜高度计算合适的相机距离限制
@@ -48,7 +55,7 @@ const Controls = ({ rack, onControlsReady }) => {
camera.position.copy(initialCameraPosition); camera.position.copy(initialCameraPosition);
controlsRef.current.target.copy(fixedTarget); controlsRef.current.target.copy(fixedTarget);
controlsRef.current.update(); controlsRef.current.update();
} },
}); });
} }
} }
@@ -76,107 +83,122 @@ const Controls = ({ rack, onControlsReady }) => {
enableZoom={true} enableZoom={true}
enableRotate={true} enableRotate={true}
mouseButtons={{ mouseButtons={{
LEFT: 0, // 左键旋转 LEFT: 0, // 左键旋转
MIDDLE: 1, // 中键平移 MIDDLE: 1, // 中键平移
RIGHT: 2 // 右键平移 RIGHT: 2, // 右键平移
}} }}
touches={{ touches={{
ONE: 1, ONE: 1,
TWO: 2 TWO: 2,
}} }}
/> />
); );
}; };
const Scene = forwardRef(({ rack, tooltipFields, onDeviceClick, onDeviceHover, onDeviceLeave }, ref) => { const Scene = forwardRef(
// 从 Context 获取3D场景状态 ({ rack, tooltipFields, onDeviceClick, onDeviceHover, onDeviceLeave }, ref) => {
const { // 从 Context 获取3D场景状态
devices, const { devices, selectedDevice, deviceSlideEnabled } = useScene3D();
selectedDevice,
deviceSlideEnabled
} = useScene3D();
// 用于存储 controls API // 用于存储 controls API
const controlsApiRef = useRef(null); const controlsApiRef = useRef(null);
// 使用 useImperativeHandle 暴露重置方法给父组件 // 使用 useImperativeHandle 暴露重置方法给父组件
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
resetView: () => { resetView: () => {
if (controlsApiRef.current) { if (controlsApiRef.current) {
controlsApiRef.current.reset(); controlsApiRef.current.reset();
} }
} },
})); }));
// 使用 useMemo 稳定 props 引用 // 使用 useMemo 稳定 props 引用
const rackModelProps = useMemo(() => ({ const rackModelProps = useMemo(
rack, () => ({
devices, rack,
selectedDeviceId: selectedDevice?.id, devices,
onDeviceClick, selectedDeviceId: selectedDevice?.id,
onDeviceLeave, onDeviceClick,
onDeviceHover, onDeviceLeave,
tooltipFields, onDeviceHover,
deviceSlideEnabled tooltipFields,
}), [rack, devices, selectedDevice, onDeviceClick, onDeviceLeave, onDeviceHover, tooltipFields, deviceSlideEnabled]); deviceSlideEnabled,
}),
[
rack,
devices,
selectedDevice,
onDeviceClick,
onDeviceLeave,
onDeviceHover,
tooltipFields,
deviceSlideEnabled,
]
);
// 根据机柜高度动态计算相机初始位置 // 根据机柜高度动态计算相机初始位置
const rackHeight = rack?.height || 45; const rackHeight = rack?.height || 45;
const rackHeightMeters = rackHeight * 0.04445; const rackHeightMeters = rackHeight * 0.04445;
// 相机位置:确保能完整看到机柜,高度随机柜高度调整 // 相机位置:确保能完整看到机柜,高度随机柜高度调整
const cameraPosition = useMemo(() => { const cameraPosition = useMemo(() => {
const baseHeight = 2; const baseHeight = 2;
const heightFactor = rackHeightMeters * 0.6; const heightFactor = rackHeightMeters * 0.6;
const distance = Math.max(3, rackHeightMeters * 1.2); const distance = Math.max(3, rackHeightMeters * 1.2);
return [distance * 0.7, baseHeight + heightFactor * 0.3, distance]; return [distance * 0.7, baseHeight + heightFactor * 0.3, distance];
}, [rackHeightMeters]); }, [rackHeightMeters]);
// 相机目标点(机柜中心) // 相机目标点(机柜中心)
const cameraTarget = useMemo(() => { const cameraTarget = useMemo(() => {
return [0, rackHeightMeters / 2 + 0.5, 0]; return [0, rackHeightMeters / 2 + 0.5, 0];
}, [rackHeightMeters]); }, [rackHeightMeters]);
return ( return (
<Canvas <Canvas
shadows shadows
dpr={deviceDpr} dpr={deviceDpr}
performance={{ min: 0.5 }} performance={{ min: 0.5 }}
gl={{ gl={{
antialias: true, // 对所有设备开启抗锯齿提升清晰度 antialias: true, // 对所有设备开启抗锯齿提升清晰度
alpha: true, // 必须开启alpha以支持透明背景 alpha: true, // 必须开启alpha以支持透明背景
powerPreference: 'high-performance' powerPreference: 'high-performance',
}} }}
style={{ background: 'transparent' }} style={{ background: 'transparent' }}
> >
<PerspectiveCamera makeDefault position={cameraPosition} fov={45} /> <PerspectiveCamera makeDefault position={cameraPosition} fov={45} />
<ambientLight intensity={0.5} color="#ffffff" /> <ambientLight intensity={0.5} color="#ffffff" />
<pointLight position={[5, 8, 5]} intensity={2} color="#ffffff" castShadow /> <pointLight position={[5, 8, 5]} intensity={2} color="#ffffff" castShadow />
<directionalLight <directionalLight
position={[10, 10, 5]} position={[10, 10, 5]}
intensity={1} intensity={1}
castShadow castShadow
shadow-mapSize={[2048, 2048]} shadow-mapSize={[2048, 2048]}
shadow-camera-far={20} shadow-camera-far={20}
shadow-camera-left={-10} shadow-camera-left={-10}
shadow-camera-right={10} shadow-camera-right={10}
shadow-camera-top={10} shadow-camera-top={10}
shadow-camera-bottom={-10} shadow-camera-bottom={-10}
/> />
<Suspense fallback={null}> <Suspense fallback={null}>
<Environment files={envMapUrl} blur={0.5} resolution={256} background={false} /> <Environment files={envMapUrl} blur={0.5} resolution={256} background={false} />
</Suspense> </Suspense>
{/* Models */} {/* Models */}
<group position={[0, 0, 0]}> <group position={[0, 0, 0]}>
<RackModel {...rackModelProps} /> <RackModel {...rackModelProps} />
</group> </group>
{/* Controls - 使用独立组件保持旋转中心固定 */} {/* Controls - 使用独立组件保持旋转中心固定 */}
<Controls rack={rack} onControlsReady={(api) => { controlsApiRef.current = api; }} /> <Controls
</Canvas> 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 = {}) => { export const createDeviceChassisMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DEVICE_CHASSIS]; 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 = {}) => { export const createDevicePanelMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DEVICE_PANEL]; 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 = {}) => { export const createDevicePanelSelectedMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DEVICE_PANEL_SELECTED]; 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 = {}) => { export const createDriveTrayMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DRIVE_TRAY]; 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 = {}) => { export const createDriveTrayHandleMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DRIVE_TRAY_HANDLE]; 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 = {}) => { export const createLedIndicatorMaterial = (color = '#10b981', options = {}) => {
@@ -37,32 +72,78 @@ export const createLedErrorMaterial = (options = {}) => {
export const createSfpPortMaterial = (options = {}) => { export const createSfpPortMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.SFP_PORT]; 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 = {}) => { export const createRj45PortMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.RJ45_PORT]; 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 = {}) => { export const createVentHoleMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.VENT_HOLE]; 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 = {}) => { export const createBackPanelMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.BACK_PANEL]; 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 = {}) => { export const createPsuModuleMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.PSU_MODULE]; 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 = {}) => { export const createFanModuleMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.FAN_MODULE]; 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') => { export const createConsolePortMaterial = (color = '#facc15') => {
@@ -3,32 +3,79 @@ import { MATERIAL_CONFIGS, MATERIAL_TYPES } from './constants.js';
export const createRackFrameMaterial = (options = {}) => { export const createRackFrameMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.RACK_FRAME]; 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 = {}) => { export const createRailMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.RAIL]; 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 = {}) => { export const createRailHoleMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.RAIL_HOLE]; 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 = {}) => { export const createSidePanelMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.SIDE_PANEL]; 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 = {}) => { export const createTopPlateMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.TOP_PLATE]; 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 = {}) => { export const createBottomPlateMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.BOTTOM_PLATE]; 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') => { export const createTextLabelMaterial = (color = '#ffffff') => {
@@ -1,10 +1,10 @@
import { MATERIAL_CONFIGS, MATERIAL_TYPES } from './constants.js'; import { MATERIAL_CONFIGS, MATERIAL_TYPES } from './constants.js';
export const getMaterialConfig = (type) => { export const getMaterialConfig = type => {
return MATERIAL_CONFIGS[type] || null; return MATERIAL_CONFIGS[type] || null;
}; };
export const getMaterialType = (type) => { export const getMaterialType = type => {
return MATERIAL_TYPES[type] || null; return MATERIAL_TYPES[type] || null;
}; };
+126 -126
View File
@@ -20,7 +20,7 @@ const CableCreateModal = ({ visible, onClose, onSuccess, sourceDevice }) => {
form.resetFields(); form.resetFields();
if (sourceDevice) { if (sourceDevice) {
form.setFieldsValue({ form.setFieldsValue({
sourceDeviceId: sourceDevice.deviceId || sourceDevice.id, sourceDeviceId: sourceDevice.deviceId || sourceDevice.id,
}); });
fetchDevicePorts(sourceDevice.deviceId || sourceDevice.id, 'source'); 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 }); form.setFieldsValue({ sourcePort: undefined });
fetchDevicePorts(deviceId, 'source'); fetchDevicePorts(deviceId, 'source');
}; };
const handleTargetDeviceChange = (deviceId) => { const handleTargetDeviceChange = deviceId => {
form.setFieldsValue({ targetPort: undefined }); form.setFieldsValue({ targetPort: undefined });
fetchDevicePorts(deviceId, 'target'); fetchDevicePorts(deviceId, 'target');
}; };
@@ -85,18 +85,15 @@ const CableCreateModal = ({ visible, onClose, onSuccess, sourceDevice }) => {
let payload = { ...values }; let payload = { ...values };
// If Source is NOT Switch AND Target IS Switch, swap them // If Source is NOT Switch AND Target IS Switch, swap them
if (sourceDev && targetDev && if (sourceDev && targetDev && sourceDev.type !== 'switch' && targetDev.type === 'switch') {
sourceDev.type !== 'switch' && payload = {
targetDev.type === 'switch') { ...values,
sourceDeviceId: values.targetDeviceId,
payload = { sourcePort: values.targetPort,
...values, targetDeviceId: values.sourceDeviceId,
sourceDeviceId: values.targetDeviceId, targetPort: values.sourcePort,
sourcePort: values.targetPort, };
targetDeviceId: values.sourceDeviceId, console.log('Swapped source/target to ensure Switch is Source');
targetPort: values.sourcePort
};
console.log('Swapped source/target to ensure Switch is Source');
} }
await axios.post('/api/cables', payload); await axios.post('/api/cables', payload);
@@ -130,122 +127,125 @@ const CableCreateModal = ({ visible, onClose, onSuccess, sourceDevice }) => {
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
{/* Source Side */} {/* Source Side */}
<div style={{ padding: '12px', background: '#f9f9f9', borderRadius: '8px' }}> <div style={{ padding: '12px', background: '#f9f9f9', borderRadius: '8px' }}>
<div style={{ marginBottom: 12, fontWeight: 500, color: '#666' }}>源设备 (起点)</div> <div style={{ marginBottom: 12, fontWeight: 500, color: '#666' }}>源设备 (起点)</div>
<Form.Item <Form.Item
name="sourceDeviceId" name="sourceDeviceId"
label="设备" label="设备"
rules={[{ required: true, message: '请选择源设备' }]} rules={[{ required: true, message: '请选择源设备' }]}
> >
<Select <Select
placeholder="选择源设备" placeholder="选择源设备"
showSearch showSearch
filterOption={(input, option) => filterOption={(input, option) =>
(option?.children ?? '').toLowerCase().includes(input.toLowerCase()) (option?.children ?? '').toLowerCase().includes(input.toLowerCase())
} }
onChange={handleSourceDeviceChange} onChange={handleSourceDeviceChange}
loading={fetchingDevices} loading={fetchingDevices}
disabled={!!sourceDevice} // Lock source device if provided disabled={!!sourceDevice} // Lock source device if provided
> >
{devices.map(d => ( {devices.map(d => (
<Option key={d.deviceId} value={d.deviceId}>{d.name}</Option> <Option key={d.deviceId} value={d.deviceId}>
))} {d.name}
</Select> </Option>
</Form.Item> ))}
<Form.Item </Select>
name="sourcePort" </Form.Item>
label="端口" <Form.Item
rules={[{ required: true, message: '请选择源端口' }]} name="sourcePort"
> label="端口"
<Select placeholder="选择源端口" showSearch> rules={[{ required: true, message: '请选择源端口' }]}
{sourcePorts.map(p => ( >
<Option key={p.portId} value={p.portName}> <Select placeholder="选择源端口" showSearch>
{p.portName} ({p.portType}) {sourcePorts.map(p => (
</Option> <Option key={p.portId} value={p.portName}>
))} {p.portName} ({p.portType})
</Select> </Option>
</Form.Item> ))}
</div> </Select>
</Form.Item>
</div>
{/* Target Side */} {/* Target Side */}
<div style={{ padding: '12px', background: '#f9f9f9', borderRadius: '8px' }}> <div style={{ padding: '12px', background: '#f9f9f9', borderRadius: '8px' }}>
<div style={{ marginBottom: 12, fontWeight: 500, color: '#666' }}>目标设备 (终点)</div> <div style={{ marginBottom: 12, fontWeight: 500, color: '#666' }}>目标设备 (终点)</div>
<Form.Item <Form.Item
name="targetDeviceId" name="targetDeviceId"
label="设备" label="设备"
rules={[{ required: true, message: '请选择目标设备' }]} rules={[{ required: true, message: '请选择目标设备' }]}
> >
<Select <Select
placeholder="选择目标设备" placeholder="选择目标设备"
showSearch showSearch
filterOption={(input, option) => filterOption={(input, option) =>
(option?.children ?? '').toLowerCase().includes(input.toLowerCase()) (option?.children ?? '').toLowerCase().includes(input.toLowerCase())
} }
onChange={handleTargetDeviceChange} onChange={handleTargetDeviceChange}
loading={fetchingDevices} loading={fetchingDevices}
> >
{devices.filter(d => d.deviceId !== form.getFieldValue('sourceDeviceId')).map(d => ( {devices
<Option key={d.deviceId} value={d.deviceId}>{d.name}</Option> .filter(d => d.deviceId !== form.getFieldValue('sourceDeviceId'))
))} .map(d => (
</Select> <Option key={d.deviceId} value={d.deviceId}>
</Form.Item> {d.name}
<Form.Item </Option>
name="targetPort" ))}
label="端口" </Select>
rules={[{ required: true, message: '请选择目标端口' }]} </Form.Item>
> <Form.Item
<Select name="targetPort"
placeholder="选择目标端口" label="端口"
showSearch rules={[{ required: true, message: '请选择目标端口' }]}
disabled={!form.getFieldValue('targetDeviceId')} >
> <Select
{targetPorts.map(p => ( placeholder="选择目标端口"
<Option key={p.portId} value={p.portName}> showSearch
{p.portName} ({p.portType}) disabled={!form.getFieldValue('targetDeviceId')}
</Option> >
))} {targetPorts.map(p => (
</Select> <Option key={p.portId} value={p.portName}>
</Form.Item> {p.portName} ({p.portType})
</div> </Option>
))}
</Select>
</Form.Item>
</div>
</div> </div>
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16 }}>
<Form.Item <Form.Item
name="cableType" name="cableType"
label="线缆类型" label="线缆类型"
initialValue="ethernet" initialValue="ethernet"
rules={[{ required: true }]} rules={[{ required: true }]}
> >
<Select> <Select>
<Option value="ethernet">网线</Option> <Option value="ethernet">网线</Option>
<Option value="fiber">光纤</Option> <Option value="fiber">光纤</Option>
<Option value="copper">铜缆</Option> <Option value="copper">铜缆</Option>
</Select> </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> </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> </div>
</Form> </Form>
</Modal> </Modal>
+151 -69
View File
@@ -1,6 +1,24 @@
import React, { useState, useCallback, useMemo } from 'react'; import React, { useState, useCallback, useMemo } from 'react';
import { Drawer, Tabs, Tag, Space, Typography, Empty, Card, Tooltip, Button, Popconfirm } from 'antd'; import {
import { ApiOutlined, CloudServerOutlined, EnvironmentOutlined, EditOutlined, PlusCircleOutlined, DeleteOutlined } from '@ant-design/icons'; 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'; import NetworkCardPanel from './NetworkCardPanel';
const { Text, Title } = Typography; const { Text, Title } = Typography;
@@ -10,25 +28,38 @@ const designTokens = {
primary: '#667eea', primary: '#667eea',
success: '#10b981', success: '#10b981',
error: '#ef4444', error: '#ef4444',
warning: '#f59e0b' warning: '#f59e0b',
}, },
spacing: { spacing: {
sm: 8, 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 [activeTab, setActiveTab] = useState('ports');
const deviceCables = useMemo(() => { const deviceCables = useMemo(() => {
if (!device || !cables) return []; if (!device || !cables) return [];
return cables.filter(c => return cables.filter(
c.sourceDeviceId === device.deviceId || c.targetDeviceId === device.deviceId c => c.sourceDeviceId === device.deviceId || c.targetDeviceId === device.deviceId
); );
}, [device, cables]); }, [device, cables]);
const getStatusTag = useCallback((status) => { const getStatusTag = useCallback(status => {
const config = { const config = {
running: { color: 'success', text: '运行中' }, running: { color: 'success', text: '运行中' },
normal: { color: 'success', text: '正常' }, normal: { color: 'success', text: '正常' },
@@ -36,13 +67,13 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables,
error: { color: 'error', text: '故障' }, error: { color: 'error', text: '故障' },
fault: { color: 'error', text: '故障' }, fault: { color: 'error', text: '故障' },
offline: { color: 'default', text: '离线' }, offline: { color: 'default', text: '离线' },
maintenance: { color: 'processing', text: '维护中' } maintenance: { color: 'processing', text: '维护中' },
}; };
const { color, text } = config[status] || { color: 'default', text: status }; const { color, text } = config[status] || { color: 'default', text: status };
return <Tag color={color}>{text}</Tag>; return <Tag color={color}>{text}</Tag>;
}, []); }, []);
const getDeviceTypeName = useCallback((type) => { const getDeviceTypeName = useCallback(type => {
const typeMap = { const typeMap = {
server: '服务器', server: '服务器',
switch: '交换机', switch: '交换机',
@@ -50,41 +81,53 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables,
storage: '存储设备', storage: '存储设备',
firewall: '防火墙', firewall: '防火墙',
ups: 'UPS', ups: 'UPS',
pdu: 'PDU' pdu: 'PDU',
}; };
return typeMap[type?.toLowerCase()] || type || '未知设备'; return typeMap[type?.toLowerCase()] || type || '未知设备';
}, []); }, []);
const renderFieldValue = useCallback((field, device) => { const renderFieldValue = useCallback(
(field, device) => {
const fieldKey = field.field; const fieldKey = field.field;
if (fieldKey === 'status') return getStatusTag(device.status); if (fieldKey === 'status') return getStatusTag(device.status);
// 优先从device对象获取值,如果没有则从customFields中获取 // 优先从device对象获取值,如果没有则从customFields中获取
let value = device[fieldKey]; let value = device[fieldKey];
if ((value === undefined || value === null) && device.customFields && typeof device.customFields === 'object') { if (
value = device.customFields[fieldKey]; (value === undefined || value === null) &&
device.customFields &&
typeof device.customFields === 'object'
) {
value = device.customFields[fieldKey];
} }
if (fieldKey === 'type') value = getDeviceTypeName(value); if (fieldKey === 'type') value = getDeviceTypeName(value);
else if (fieldKey === 'position') value = `U${device.position} ${device.height ? `(${device.height}U)` : ''}`; 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>; return (
}, [getStatusTag, getDeviceTypeName]); <Text strong style={{ fontSize: '14px' }}>
{value !== undefined && value !== null ? value : '-'}
</Text>
);
},
[getStatusTag, getDeviceTypeName]
);
const displayFields = useMemo(() => { const displayFields = useMemo(() => {
if (tooltipFields && Object.keys(tooltipFields).length > 0) { if (tooltipFields && Object.keys(tooltipFields).length > 0) {
return Object.values(tooltipFields).filter(f => f.enabled); return Object.values(tooltipFields).filter(f => f.enabled);
} }
// Default fallback fields if no config // Default fallback fields if no config
return [ return [
{ field: 'deviceId', label: '设备ID' }, { field: 'deviceId', label: '设备ID' },
{ field: 'type', label: '设备类型' }, { field: 'type', label: '设备类型' },
{ field: 'status', label: '设备状态' }, { field: 'status', label: '设备状态' },
{ field: 'position', label: '位置' }, { field: 'position', label: '位置' },
{ field: 'ipAddress', label: 'IP地址' }, { field: 'ipAddress', label: 'IP地址' },
{ field: 'brand', label: '品牌' } { field: 'brand', label: '品牌' },
]; ];
}, [tooltipFields]); }, [tooltipFields]);
const tabItems = [ const tabItems = [
@@ -103,7 +146,7 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables,
onRefresh={onRefreshCables} onRefresh={onRefreshCables}
refreshTrigger={refreshTrigger} refreshTrigger={refreshTrigger}
/> />
) ),
}, },
{ {
key: 'cables', key: 'cables',
@@ -138,38 +181,64 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables,
<div style={{ marginBottom: designTokens.spacing.sm }}> <div style={{ marginBottom: designTokens.spacing.sm }}>
<Space direction="vertical" size={4}> <Space direction="vertical" size={4}>
<div> <div>
<Text type="secondary" style={{ fontSize: '12px' }}>源设备</Text> <Text type="secondary" style={{ fontSize: '12px' }}>
源设备
</Text>
<div style={{ fontWeight: 500 }}> <div style={{ fontWeight: 500 }}>
{cable.sourceDevice?.name || '-'} {cable.sourceDevice?.name || '-'}
<Tag color="blue" style={{ marginLeft: '8px' }}>{cable.sourcePort}</Tag> <Tag color="blue" style={{ marginLeft: '8px' }}>
{cable.sourcePort}
</Tag>
</div> </div>
</div> </div>
<div> <div>
<Text type="secondary" style={{ fontSize: '12px' }}>目标设备</Text> <Text type="secondary" style={{ fontSize: '12px' }}>
目标设备
</Text>
<div style={{ fontWeight: 500 }}> <div style={{ fontWeight: 500 }}>
{cable.targetDevice?.name || '-'} {cable.targetDevice?.name || '-'}
<Tag color="green" style={{ marginLeft: '8px' }}>{cable.targetPort}</Tag> <Tag color="green" style={{ marginLeft: '8px' }}>
{cable.targetPort}
</Tag>
</div> </div>
</div> </div>
</Space> </Space>
</div> </div>
<Space wrap> <Space wrap>
<Tag color={cable.status === 'normal' ? 'success' : cable.status === 'fault' ? 'error' : 'default'}> <Tag
{cable.status === 'normal' ? '正常' : cable.status === 'fault' ? '故障' : '未连接'} color={
cable.status === 'normal'
? 'success'
: cable.status === 'fault'
? 'error'
: 'default'
}
>
{cable.status === 'normal'
? '正常'
: cable.status === 'fault'
? '故障'
: '未连接'}
</Tag> </Tag>
<Tag color="purple"> <Tag color="purple">
{cable.cableType === 'ethernet' ? '网线' : cable.cableType === 'fiber' ? '光纤' : '铜缆'} {cable.cableType === 'ethernet'
? '网线'
: cable.cableType === 'fiber'
? '光纤'
: '铜缆'}
</Tag> </Tag>
{cable.cableLength && ( {cable.cableLength && <Tag color="orange">{cable.cableLength}m</Tag>}
<Tag color="orange">
{cable.cableLength}m
</Tag>
)}
</Space> </Space>
{cable.description && ( {cable.description && (
<div style={{ marginTop: designTokens.spacing.sm, fontSize: '12px', color: '#666' }}> <div
style={{
marginTop: designTokens.spacing.sm,
fontSize: '12px',
color: '#666',
}}
>
{cable.description} {cable.description}
</div> </div>
)} )}
@@ -178,8 +247,8 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables,
</Space> </Space>
)} )}
</div> </div>
) ),
} },
]; ];
if (!device) return null; if (!device) return null;
@@ -189,11 +258,15 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables,
title={ title={
<Space style={{ maxWidth: '280px', overflow: 'hidden' }}> <Space style={{ maxWidth: '280px', overflow: 'hidden' }}>
<CloudServerOutlined style={{ color: designTokens.colors.primary, flexShrink: 0 }} /> <CloudServerOutlined style={{ color: designTokens.colors.primary, flexShrink: 0 }} />
<span style={{ <span
overflow: 'hidden', style={{
textOverflow: 'ellipsis', overflow: 'hidden',
whiteSpace: 'nowrap' textOverflow: 'ellipsis',
}}>设备详情 - {device.name}</span> whiteSpace: 'nowrap',
}}
>
设备详情 - {device.name}
</span>
</Space> </Space>
} }
placement="right" placement="right"
@@ -206,42 +279,51 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables,
<Button icon={<EditOutlined />} onClick={() => onEdit?.(device)} /> <Button icon={<EditOutlined />} onClick={() => onEdit?.(device)} />
</Tooltip> </Tooltip>
<Tooltip title="添加网卡"> <Tooltip title="添加网卡">
<Button icon={<PlusCircleOutlined />} onClick={() => onAddNic?.(device)}>加网卡</Button> <Button icon={<PlusCircleOutlined />} onClick={() => onAddNic?.(device)}>
加网卡
</Button>
</Tooltip> </Tooltip>
<Tooltip title="添加端口"> <Tooltip title="添加端口">
<Button icon={<ApiOutlined />} onClick={() => onAddPort?.(device)}>加端口</Button> <Button icon={<ApiOutlined />} onClick={() => onAddPort?.(device)}>
加端口
</Button>
</Tooltip> </Tooltip>
<Tooltip title="添加接线"> <Tooltip title="添加接线">
<Button icon={<EnvironmentOutlined />} onClick={() => onAddCable?.(device)}>加接线</Button> <Button icon={<EnvironmentOutlined />} onClick={() => onAddCable?.(device)}>
加接线
</Button>
</Tooltip> </Tooltip>
</Space> </Space>
} }
styles={{ body: { padding: '16px 20px', overflow: 'auto' } }} styles={{ body: { padding: '16px 20px', overflow: 'auto' } }}
> >
<div className="device-info-section" style={{ marginBottom: '20px' }}> <div className="device-info-section" style={{ marginBottom: '20px' }}>
<Title level={5} style={{ margin: '0 0 12px 0', color: '#1e293b' }}>基本信息</Title> <Title level={5} style={{ margin: '0 0 12px 0', color: '#1e293b' }}>
<div className="info-grid" style={{ 基本信息
display: 'grid', </Title>
gridTemplateColumns: 'repeat(2, 1fr)', <div
gap: '12px', className="info-grid"
background: '#f8fafc', style={{
padding: '16px', display: 'grid',
borderRadius: '10px' gridTemplateColumns: 'repeat(2, 1fr)',
}}> gap: '12px',
background: '#f8fafc',
padding: '16px',
borderRadius: '10px',
}}
>
{displayFields.map(field => ( {displayFields.map(field => (
<div className="info-item" key={field.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)} {renderFieldValue(field, device)}
</div> </div>
))} ))}
</div> </div>
</div> </div>
<Tabs <Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
activeKey={activeTab}
onChange={setActiveTab}
items={tabItems}
/>
</Drawer> </Drawer>
); );
} }
@@ -9,12 +9,12 @@ const { TextArea } = Input;
const designTokens = { const designTokens = {
colors: { colors: {
primary: { primary: {
main: '#667eea' main: '#667eea',
} },
}, },
borderRadius: { borderRadius: {
medium: '10px' medium: '10px',
} },
}; };
function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) { function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
@@ -33,7 +33,7 @@ function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
description: values.description, description: values.description,
model: values.model, model: values.model,
manufacturer: values.manufacturer, manufacturer: values.manufacturer,
status: values.status status: values.status,
}); });
message.success('网卡创建成功'); message.success('网卡创建成功');
@@ -77,7 +77,7 @@ function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
form={form} form={form}
layout="vertical" layout="vertical"
initialValues={{ initialValues={{
status: 'normal' status: 'normal',
}} }}
> >
<Form.Item <Form.Item
@@ -92,24 +92,15 @@ function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
} }
rules={[ rules={[
{ required: true, message: '请输入网卡名称' }, { required: true, message: '请输入网卡名称' },
{ max: 50, message: '名称不能超过50个字符' } { max: 50, message: '名称不能超过50个字符' },
]} ]}
> >
<Input placeholder="例如: 网卡1、eth0、LAN1" /> <Input placeholder="例如: 网卡1、eth0、LAN1" />
</Form.Item> </Form.Item>
<Space style={{ display: 'flex', width: '100%' }}> <Space style={{ display: 'flex', width: '100%' }}>
<Form.Item <Form.Item name="slotNumber" label="插槽编号" style={{ flex: 1 }}>
name="slotNumber" <InputNumber placeholder="可选" min={1} max={100} style={{ width: '100%' }} />
label="插槽编号"
style={{ flex: 1 }}
>
<InputNumber
placeholder="可选"
min={1}
max={100}
style={{ width: '100%' }}
/>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
@@ -128,27 +119,16 @@ function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
</Space> </Space>
<Space style={{ display: 'flex', width: '100%' }}> <Space style={{ display: 'flex', width: '100%' }}>
<Form.Item <Form.Item name="manufacturer" label="制造商" style={{ flex: 1 }}>
name="manufacturer"
label="制造商"
style={{ flex: 1 }}
>
<Input placeholder="如: Intel、Realtek、Broadcom" /> <Input placeholder="如: Intel、Realtek、Broadcom" />
</Form.Item> </Form.Item>
<Form.Item <Form.Item name="model" label="型号" style={{ flex: 1 }}>
name="model"
label="型号"
style={{ flex: 1 }}
>
<Input placeholder="如: X520-DA2" /> <Input placeholder="如: X520-DA2" />
</Form.Item> </Form.Item>
</Space> </Space>
<Form.Item <Form.Item name="description" label="描述">
name="description"
label="描述"
>
<TextArea rows={2} placeholder="请输入描述信息(可选)" /> <TextArea rows={2} placeholder="请输入描述信息(可选)" />
</Form.Item> </Form.Item>
</Form> </Form>
+123 -76
View File
@@ -1,6 +1,25 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { Table, Button, Space, Tag, Tooltip, Popconfirm, Empty, Spin, Badge, Collapse, Card } from 'antd'; import {
import { PlusOutlined, DeleteOutlined, ReloadOutlined, ApiOutlined, CloudServerOutlined, FolderOutlined } from '@ant-design/icons'; 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 axios from 'axios';
import PortCreateModal from './PortCreateModal'; import PortCreateModal from './PortCreateModal';
import NetworkCardCreateModal from './NetworkCardCreateModal'; import NetworkCardCreateModal from './NetworkCardCreateModal';
@@ -10,12 +29,12 @@ const { Panel } = Collapse;
const designTokens = { const designTokens = {
colors: { colors: {
primary: { primary: {
main: '#667eea' main: '#667eea',
}, },
success: '#10b981', success: '#10b981',
error: '#ef4444', error: '#ef4444',
warning: '#f59e0b' warning: '#f59e0b',
} },
}; };
function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) { function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
@@ -34,7 +53,7 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
setLoading(true); setLoading(true);
const [cardsResponse, networkCardsResponse] = await Promise.all([ const [cardsResponse, networkCardsResponse] = await Promise.all([
axios.get(`/api/network-cards/device/${deviceId}/with-ports`), 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 || []; const cardsData = cardsResponse.data || [];
@@ -58,27 +77,35 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
fetchData(); fetchData();
}, [fetchData, refreshTrigger]); }, [fetchData, refreshTrigger]);
const handleDeleteCard = useCallback(async (card) => { const handleDeleteCard = useCallback(
try { async card => {
await axios.delete(`/api/network-cards/${card.nicId}`); try {
import('antd').then(({ message }) => message.success('网卡删除成功')); await axios.delete(`/api/network-cards/${card.nicId}`);
fetchData(); import('antd').then(({ message }) => message.success('网卡删除成功'));
onRefresh?.(); fetchData();
} catch (error) { onRefresh?.();
import('antd').then(({ message }) => message.error(error.response?.data?.error || '网卡删除失败')); } catch (error) {
} import('antd').then(({ message }) =>
}, [fetchData, onRefresh]); message.error(error.response?.data?.error || '网卡删除失败')
);
}
},
[fetchData, onRefresh]
);
const handleDeletePort = useCallback(async (port) => { const handleDeletePort = useCallback(
try { async port => {
await axios.delete(`/api/device-ports/${port.portId}`); try {
import('antd').then(({ message }) => message.success('端口删除成功')); await axios.delete(`/api/device-ports/${port.portId}`);
fetchData(); import('antd').then(({ message }) => message.success('端口删除成功'));
onRefresh?.(); fetchData();
} catch (error) { onRefresh?.();
import('antd').then(({ message }) => message.error('端口删除失败')); } catch (error) {
} import('antd').then(({ message }) => message.error('端口删除失败'));
}, [fetchData, onRefresh]); }
},
[fetchData, onRefresh]
);
const handleCreateCardSuccess = useCallback(() => { const handleCreateCardSuccess = useCallback(() => {
fetchData(); fetchData();
@@ -90,7 +117,7 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
onRefresh?.(); onRefresh?.();
}, [fetchData, onRefresh]); }, [fetchData, onRefresh]);
const handleExpand = (nicId) => { const handleExpand = nicId => {
setExpandedCards(prev => { setExpandedCards(prev => {
if (prev.includes(nicId)) { if (prev.includes(nicId)) {
return prev.filter(id => id !== 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 = { const config = {
free: { color: 'success', text: '空闲' }, free: { color: 'success', text: '空闲' },
occupied: { color: 'processing', text: '占用' }, occupied: { color: 'processing', text: '占用' },
fault: { color: 'error', text: '故障' }, fault: { color: 'error', text: '故障' },
normal: { color: 'success', text: '正常' }, normal: { color: 'success', text: '正常' },
warning: { color: 'warning', text: '警告' }, warning: { color: 'warning', text: '警告' },
offline: { color: 'default', text: '离线' } offline: { color: 'default', text: '离线' },
}; };
const { color, text } = config[status] || { color: 'default', text: status }; const { color, text } = config[status] || { color: 'default', text: status };
return <Tag color={color}>{text}</Tag>; return <Tag color={color}>{text}</Tag>;
}; };
const getTypeTag = (type) => { const getTypeTag = type => {
const config = { const config = {
'RJ45': { color: 'blue', text: 'RJ45' }, RJ45: { color: 'blue', text: 'RJ45' },
'SFP': { color: 'green', text: 'SFP' }, SFP: { color: 'green', text: 'SFP' },
'SFP+': { color: 'cyan', text: 'SFP+' }, 'SFP+': { color: 'cyan', text: 'SFP+' },
'SFP28': { color: 'purple', text: 'SFP28' }, SFP28: { color: 'purple', text: 'SFP28' },
'QSFP': { color: 'orange', text: 'QSFP' }, QSFP: { color: 'orange', text: 'QSFP' },
'QSFP28': { color: 'red', text: 'QSFP28' } QSFP28: { color: 'red', text: 'QSFP28' },
}; };
const { color, text } = config[type] || { color: 'default', text: type }; const { color, text } = config[type] || { color: 'default', text: type };
return <Tag color={color}>{text}</Tag>; return <Tag color={color}>{text}</Tag>;
@@ -132,34 +159,34 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
dataIndex: 'portName', dataIndex: 'portName',
key: 'portName', key: 'portName',
width: 120, width: 120,
render: (text) => <span style={{ fontWeight: 500 }}>{text}</span> render: text => <span style={{ fontWeight: 500 }}>{text}</span>,
}, },
{ {
title: '类型', title: '类型',
dataIndex: 'portType', dataIndex: 'portType',
key: 'portType', key: 'portType',
width: 80, width: 80,
render: (type) => getTypeTag(type) render: type => getTypeTag(type),
}, },
{ {
title: '速率', title: '速率',
dataIndex: 'portSpeed', dataIndex: 'portSpeed',
key: 'portSpeed', key: 'portSpeed',
width: 70 width: 70,
}, },
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
key: 'status', key: 'status',
width: 70, width: 70,
render: (status) => getStatusTag(status) render: status => getStatusTag(status),
}, },
{ {
title: 'VLAN', title: 'VLAN',
dataIndex: 'vlanId', dataIndex: 'vlanId',
key: 'vlanId', key: 'vlanId',
width: 60, width: 60,
render: (vlanId) => vlanId || '-' render: vlanId => vlanId || '-',
}, },
{ {
title: '操作', title: '操作',
@@ -178,8 +205,8 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
</Button> </Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
) ),
} },
]; ];
return ( 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 }; const stats = card.stats || { free: 0, occupied: 0, fault: 0, total: 0 };
return ( 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={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ <div
width: '36px', style={{
height: '36px', width: '36px',
borderRadius: '8px', height: '36px',
background: card.isUngrouped borderRadius: '8px',
? 'linear-gradient(135deg, #94a3b8 0%, #64748b 100%)' background: card.isUngrouped
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', ? 'linear-gradient(135deg, #94a3b8 0%, #64748b 100%)'
display: 'flex', : 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
alignItems: 'center', display: 'flex',
justifyContent: 'center', alignItems: 'center',
color: '#fff' justifyContent: 'center',
}}> color: '#fff',
}}
>
{card.isUngrouped ? <FolderOutlined /> : <CloudServerOutlined />} {card.isUngrouped ? <FolderOutlined /> : <CloudServerOutlined />}
</div> </div>
<div> <div>
<div style={{ fontWeight: 600, fontSize: '14px', color: '#1e293b' }}> <div style={{ fontWeight: 600, fontSize: '14px', color: '#1e293b' }}>
{card.name} {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>
<div style={{ fontSize: '12px', color: '#64748b' }}> <div style={{ fontSize: '12px', color: '#64748b' }}>
{card.description || (card.isUngrouped ? '未分配到网卡的端口' : '网卡')} {card.description || (card.isUngrouped ? '未分配到网卡的端口' : '网卡')}
@@ -248,7 +286,7 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
type="primary" type="primary"
size="small" size="small"
icon={<PlusOutlined />} icon={<PlusOutlined />}
onClick={(e) => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
setSelectedCard(card); setSelectedCard(card);
setCreatePortModalVisible(true); setCreatePortModalVisible(true);
@@ -270,26 +308,35 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
); );
} }
const totalStats = cards.reduce((acc, card) => { const totalStats = cards.reduce(
const stats = card.stats || {}; (acc, card) => {
acc.total += stats.total || 0; const stats = card.stats || {};
acc.free += stats.free || 0; acc.total += stats.total || 0;
acc.occupied += stats.occupied || 0; acc.free += stats.free || 0;
acc.fault += stats.fault || 0; acc.occupied += stats.occupied || 0;
return acc; acc.fault += stats.fault || 0;
}, { total: 0, free: 0, occupied: 0, fault: 0 }); return acc;
},
{ total: 0, free: 0, occupied: 0, fault: 0 }
);
return ( return (
<div className="network-card-panel"> <div className="network-card-panel">
<div className="panel-header" style={{ <div
display: 'flex', className="panel-header"
justifyContent: 'space-between', style={{
alignItems: 'center', display: 'flex',
marginBottom: '16px' justifyContent: 'space-between',
}}> alignItems: 'center',
marginBottom: '16px',
}}
>
<div className="stats" style={{ display: 'flex', gap: '24px' }}> <div className="stats" style={{ display: 'flex', gap: '24px' }}>
<Space size={16}> <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> <span style={{ color: '#64748b', fontSize: '13px' }}>个网卡</span>
<Badge count={totalStats.total} style={{ backgroundColor: '#667eea' }} /> <Badge count={totalStats.total} style={{ backgroundColor: '#667eea' }} />
<span style={{ color: '#64748b', fontSize: '13px' }}>个端口</span> <span style={{ color: '#64748b', fontSize: '13px' }}>个端口</span>
@@ -334,11 +381,11 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
) : ( ) : (
<Collapse <Collapse
activeKey={expandedCards} activeKey={expandedCards}
onChange={(keys) => setExpandedCards(keys)} onChange={keys => setExpandedCards(keys)}
expandIconPosition="end" expandIconPosition="end"
style={{ background: 'transparent' }} style={{ background: 'transparent' }}
> >
{cards.map((card) => ( {cards.map(card => (
<Panel <Panel
key={card.nicId} key={card.nicId}
header={renderCardHeader(card)} header={renderCardHeader(card)}
@@ -346,7 +393,7 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
background: '#fff', background: '#fff',
borderRadius: '8px', borderRadius: '8px',
marginBottom: '8px', marginBottom: '8px',
border: '1px solid #e2e8f0' border: '1px solid #e2e8f0',
}} }}
> >
{card.ports && card.ports.length > 0 ? ( {card.ports && card.ports.length > 0 ? (
+59 -54
View File
@@ -1,5 +1,17 @@
import React, { useState, useCallback, useEffect, useMemo } from 'react'; 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 { PlusOutlined, InfoCircleOutlined } from '@ant-design/icons';
import axios from 'axios'; import axios from 'axios';
@@ -10,12 +22,12 @@ const designTokens = {
colors: { colors: {
primary: { primary: {
main: '#667eea', main: '#667eea',
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
} },
}, },
borderRadius: { borderRadius: {
medium: '10px' medium: '10px',
} },
}; };
function parsePortRange(portName) { function parsePortRange(portName) {
@@ -64,7 +76,7 @@ function parsePortRange(portName) {
startNum, startNum,
endNum, endNum,
portCount, portCount,
ports ports,
}; };
} }
@@ -116,7 +128,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
} }
}; };
const handlePortNameChange = useCallback((e) => { const handlePortNameChange = useCallback(e => {
const value = e.target.value; const value = e.target.value;
const ports = generatePortNames(value); const ports = generatePortNames(value);
@@ -145,7 +157,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
portSpeed: values.portSpeed, portSpeed: values.portSpeed,
vlanId: values.vlanId, vlanId: values.vlanId,
status: values.status, status: values.status,
description: values.description description: values.description,
}); });
message.success('端口创建成功'); message.success('端口创建成功');
} else { } else {
@@ -158,7 +170,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
portSpeed: values.portSpeed, portSpeed: values.portSpeed,
vlanId: values.vlanId, vlanId: values.vlanId,
status: values.status, status: values.status,
description: values.description description: values.description,
})); }));
await axios.post('/api/device-ports/batch', { ports: portsData }); await axios.post('/api/device-ports/batch', { ports: portsData });
@@ -196,9 +208,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
<Space> <Space>
<PlusOutlined style={{ color: designTokens.colors.primary.main }} /> <PlusOutlined style={{ color: designTokens.colors.primary.main }} />
<span>新增端口 - {device?.name || '设备'}</span> <span>新增端口 - {device?.name || '设备'}</span>
{portCount > 1 && ( {portCount > 1 && <Tag color="blue">{portCount} 个端口</Tag>}
<Tag color="blue">{portCount} 个端口</Tag>
)}
</Space> </Space>
} }
open={visible} open={visible}
@@ -217,18 +227,11 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
initialValues={{ initialValues={{
portType: 'RJ45', portType: 'RJ45',
portSpeed: '1G', portSpeed: '1G',
status: 'free' status: 'free',
}} }}
> >
<Form.Item <Form.Item name="deviceId" label="设备">
name="deviceId" <Input value={device?.name} disabled placeholder={device?.deviceId} />
label="设备"
>
<Input
value={device?.name}
disabled
placeholder={device?.deviceId}
/>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
@@ -263,7 +266,10 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
label={ label={
<Space> <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' }} /> <InfoCircleOutlined style={{ color: '#999' }} />
</Tooltip> </Tooltip>
</Space> </Space>
@@ -272,7 +278,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
{ required: true, message: '请输入端口名称' }, { required: true, message: '请输入端口名称' },
{ {
pattern: /^[\w\/:\-]+$/, pattern: /^[\w\/:\-]+$/,
message: '端口名称格式不正确' message: '端口名称格式不正确',
}, },
{ {
validator: (_, value) => { validator: (_, value) => {
@@ -282,13 +288,13 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
return Promise.reject(new Error('单次最多创建1000个端口')); return Promise.reject(new Error('单次最多创建1000个端口'));
} }
return Promise.resolve(); return Promise.resolve();
} },
} },
]} ]}
> >
<Input <Input
placeholder="例如: eth0/1 或 1/0/1-1/0/48" placeholder="例如: eth0/1 或 1/0/1-1/0/48"
onChange={(e) => { onChange={e => {
// 确保 Form 值更新 // 确保 Form 值更新
form.setFieldValue('portName', e.target.value); form.setFieldValue('portName', e.target.value);
handlePortNameChange(e); handlePortNameChange(e);
@@ -303,9 +309,12 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
<Space wrap size={4}> <Space wrap size={4}>
{previewPorts.map((port, index) => ( {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> <Tag color="default">...</Tag>
)} )}
</Space> </Space>
@@ -352,17 +361,8 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
</Space> </Space>
<Space style={{ display: 'flex', width: '100%' }}> <Space style={{ display: 'flex', width: '100%' }}>
<Form.Item <Form.Item name="vlanId" label="VLAN ID" style={{ flex: 1 }}>
name="vlanId" <InputNumber placeholder="1-4094" min={1} max={4094} style={{ width: '100%' }} />
label="VLAN ID"
style={{ flex: 1 }}
>
<InputNumber
placeholder="1-4094"
min={1}
max={4094}
style={{ width: '100%' }}
/>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
@@ -379,25 +379,30 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
</Form.Item> </Form.Item>
</Space> </Space>
<Form.Item <Form.Item name="description" label="描述">
name="description"
label="描述"
>
<TextArea rows={2} placeholder="请输入描述信息(可选)" /> <TextArea rows={2} placeholder="请输入描述信息(可选)" />
</Form.Item> </Form.Item>
<div style={{ <div
background: '#f5f5f5', style={{
padding: '12px 16px', background: '#f5f5f5',
borderRadius: '8px', padding: '12px 16px',
fontSize: '12px', borderRadius: '8px',
color: '#666' fontSize: '12px',
}}> color: '#666',
}}
>
<strong>格式说明</strong> <strong>格式说明</strong>
<ul style={{ margin: '8px 0 0 0', paddingLeft: '20px' }}> <ul style={{ margin: '8px 0 0 0', paddingLeft: '20px' }}>
<li>单个端口<code>eth0/1</code><code>gigabitethernet1/0/1</code></li> <li>
<li>端口范围<code>1/0/1-1/0/48</code>创建 1/0/1 1/0/48 共48个端口</li> 单个端口<code>eth0/1</code><code>gigabitethernet1/0/1</code>
<li>简单范围<code>eth1-eth24</code>创建 eth1 eth24 共24个端口</li> </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> </ul>
</div> </div>
</Form> </Form>
+42 -42
View File
@@ -1,18 +1,24 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { Table, Button, Space, Tag, Tooltip, Popconfirm, Empty, Spin, Badge } from 'antd'; 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 axios from 'axios';
import PortCreateModal from './PortCreateModal'; import PortCreateModal from './PortCreateModal';
const designTokens = { const designTokens = {
colors: { colors: {
primary: { primary: {
main: '#667eea' main: '#667eea',
}, },
success: '#10b981', success: '#10b981',
error: '#ef4444', error: '#ef4444',
warning: '#f59e0b' warning: '#f59e0b',
} },
}; };
function PortManagementPanel({ deviceId, deviceName, onRefresh }) { function PortManagementPanel({ deviceId, deviceName, onRefresh }) {
@@ -38,40 +44,43 @@ function PortManagementPanel({ deviceId, deviceName, onRefresh }) {
fetchPorts(); fetchPorts();
}, [fetchPorts]); }, [fetchPorts]);
const handleDelete = useCallback(async (port) => { const handleDelete = useCallback(
try { async port => {
await axios.delete(`/api/device-ports/${port.portId}`); try {
import('antd').then(({ message }) => message.success('端口删除成功')); await axios.delete(`/api/device-ports/${port.portId}`);
fetchPorts(); import('antd').then(({ message }) => message.success('端口删除成功'));
onRefresh?.(); fetchPorts();
} catch (error) { onRefresh?.();
import('antd').then(({ message }) => message.error('端口删除失败')); } catch (error) {
} import('antd').then(({ message }) => message.error('端口删除失败'));
}, [fetchPorts, onRefresh]); }
},
[fetchPorts, onRefresh]
);
const handleCreateSuccess = useCallback(() => { const handleCreateSuccess = useCallback(() => {
fetchPorts(); fetchPorts();
onRefresh?.(); onRefresh?.();
}, [fetchPorts, onRefresh]); }, [fetchPorts, onRefresh]);
const getStatusTag = (status) => { const getStatusTag = status => {
const config = { const config = {
free: { color: 'success', text: '空闲' }, free: { color: 'success', text: '空闲' },
occupied: { color: 'processing', text: '占用' }, occupied: { color: 'processing', text: '占用' },
fault: { color: 'error', text: '故障' } fault: { color: 'error', text: '故障' },
}; };
const { color, text } = config[status] || { color: 'default', text: status }; const { color, text } = config[status] || { color: 'default', text: status };
return <Tag color={color}>{text}</Tag>; return <Tag color={color}>{text}</Tag>;
}; };
const getTypeTag = (type) => { const getTypeTag = type => {
const config = { const config = {
'RJ45': { color: 'blue', text: 'RJ45' }, RJ45: { color: 'blue', text: 'RJ45' },
'SFP': { color: 'green', text: 'SFP' }, SFP: { color: 'green', text: 'SFP' },
'SFP+': { color: 'cyan', text: 'SFP+' }, 'SFP+': { color: 'cyan', text: 'SFP+' },
'SFP28': { color: 'purple', text: 'SFP28' }, SFP28: { color: 'purple', text: 'SFP28' },
'QSFP': { color: 'orange', text: 'QSFP' }, QSFP: { color: 'orange', text: 'QSFP' },
'QSFP28': { color: 'red', text: 'QSFP28' } QSFP28: { color: 'red', text: 'QSFP28' },
}; };
const { color, text } = config[type] || { color: 'default', text: type }; const { color, text } = config[type] || { color: 'default', text: type };
return <Tag color={color}>{text}</Tag>; return <Tag color={color}>{text}</Tag>;
@@ -83,38 +92,38 @@ function PortManagementPanel({ deviceId, deviceName, onRefresh }) {
dataIndex: 'portName', dataIndex: 'portName',
key: 'portName', key: 'portName',
width: 120, width: 120,
render: (text) => ( render: text => (
<Tooltip title={text}> <Tooltip title={text}>
<span style={{ fontWeight: 500 }}>{text}</span> <span style={{ fontWeight: 500 }}>{text}</span>
</Tooltip> </Tooltip>
) ),
}, },
{ {
title: '类型', title: '类型',
dataIndex: 'portType', dataIndex: 'portType',
key: 'portType', key: 'portType',
width: 90, width: 90,
render: (type) => getTypeTag(type) render: type => getTypeTag(type),
}, },
{ {
title: '速率', title: '速率',
dataIndex: 'portSpeed', dataIndex: 'portSpeed',
key: 'portSpeed', key: 'portSpeed',
width: 80 width: 80,
}, },
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
key: 'status', key: 'status',
width: 80, width: 80,
render: (status) => getStatusTag(status) render: status => getStatusTag(status),
}, },
{ {
title: 'VLAN', title: 'VLAN',
dataIndex: 'vlanId', dataIndex: 'vlanId',
key: 'vlanId', key: 'vlanId',
width: 70, width: 70,
render: (vlanId) => vlanId || '-' render: vlanId => vlanId || '-',
}, },
{ {
title: '操作', title: '操作',
@@ -129,18 +138,13 @@ function PortManagementPanel({ deviceId, deviceName, onRefresh }) {
okText="确定" okText="确定"
cancelText="取消" cancelText="取消"
> >
<Button <Button type="link" size="small" danger icon={<DeleteOutlined />}>
type="link"
size="small"
danger
icon={<DeleteOutlined />}
>
删除 删除
</Button> </Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
) ),
} },
]; ];
const freeCount = ports.filter(p => p.status === 'free').length; const freeCount = ports.filter(p => p.status === 'free').length;
@@ -169,11 +173,7 @@ function PortManagementPanel({ deviceId, deviceName, onRefresh }) {
</Space> </Space>
</div> </div>
<Space> <Space>
<Button <Button icon={<ReloadOutlined />} onClick={fetchPorts} size="small">
icon={<ReloadOutlined />}
onClick={fetchPorts}
size="small"
>
刷新 刷新
</Button> </Button>
<Button <Button
@@ -182,7 +182,7 @@ function PortManagementPanel({ deviceId, deviceName, onRefresh }) {
onClick={() => setCreateModalVisible(true)} onClick={() => setCreateModalVisible(true)}
style={{ style={{
background: designTokens.colors.primary.gradient, background: designTokens.colors.primary.gradient,
border: 'none' border: 'none',
}} }}
> >
新增端口 新增端口
+250 -193
View File
@@ -2,14 +2,22 @@ import React, { useState } from 'react';
import { Tooltip, Badge, Divider, Pagination } from 'antd'; import { Tooltip, Badge, Divider, Pagination } from 'antd';
import { LinkOutlined, SwapRightOutlined, AimOutlined, NodeIndexOutlined } from '@ant-design/icons'; 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 [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(48); // 默认每页48个端口 const [pageSize, setPageSize] = useState(48); // 默认每页48个端口
// 按端口名称排序(升序) // 按端口名称排序(升序)
const sortedPorts = [...ports].sort((a, b) => { const sortedPorts = [...ports].sort((a, b) => {
// 尝试按数字部分排序,支持格式如:1/0/1, eth0/1, GigabitEthernet1/0/1 等 // 尝试按数字部分排序,支持格式如:1/0/1, eth0/1, GigabitEthernet1/0/1 等
const extractNumbers = (str) => { const extractNumbers = str => {
const matches = str.match(/\d+/g); const matches = str.match(/\d+/g);
return matches ? matches.map(Number) : []; return matches ? matches.map(Number) : [];
}; };
@@ -35,7 +43,7 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
const paginatedPorts = sortedPorts.slice(startIndex, endIndex); const paginatedPorts = sortedPorts.slice(startIndex, endIndex);
// 获取端口状态颜色 // 获取端口状态颜色
const getPortStatusColor = (status) => { const getPortStatusColor = status => {
switch (status) { switch (status) {
case 'free': case 'free':
return '#6b7280'; // 灰色 - 空闲 return '#6b7280'; // 灰色 - 空闲
@@ -51,7 +59,7 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
}; };
// 获取端口状态文本 // 获取端口状态文本
const getPortStatusText = (status) => { const getPortStatusText = status => {
switch (status) { switch (status) {
case 'free': case 'free':
return '空闲'; return '空闲';
@@ -67,7 +75,7 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
}; };
// 获取端口类型图标 - 使用更真实的端口符号 // 获取端口类型图标 - 使用更真实的端口符号
const getPortTypeIcon = (portType) => { const getPortTypeIcon = portType => {
switch (portType) { switch (portType) {
case 'RJ45': case 'RJ45':
return '⬡'; // 六边形表示网口 return '⬡'; // 六边形表示网口
@@ -84,7 +92,7 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
}; };
// 获取简化端口显示名称(只显示数字) // 获取简化端口显示名称(只显示数字)
const getPortDisplayName = (portName) => { const getPortDisplayName = portName => {
// 提取最后的数字 // 提取最后的数字
const match = portName.match(/(\d+)$/); const match = portName.match(/(\d+)$/);
if (match) { if (match) {
@@ -95,36 +103,37 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
}; };
// 获取线缆类型文本 // 获取线缆类型文本
const getCableTypeText = (cableType) => { const getCableTypeText = cableType => {
const typeMap = { const typeMap = {
'ethernet': '网线', ethernet: '网线',
'fiber': '光纤', fiber: '光纤',
'copper': '铜缆', copper: '铜缆',
'power': '电源线' power: '电源线',
}; };
return typeMap[cableType] || cableType || '未知'; return typeMap[cableType] || cableType || '未知';
}; };
// 获取线缆类型颜色 // 获取线缆类型颜色
const getCableTypeColor = (cableType) => { const getCableTypeColor = cableType => {
const colorMap = { const colorMap = {
'ethernet': '#52c41a', ethernet: '#52c41a',
'fiber': '#1890ff', fiber: '#1890ff',
'copper': '#faad14', copper: '#faad14',
'power': '#ff4d4f' power: '#ff4d4f',
}; };
return colorMap[cableType] || '#999'; return colorMap[cableType] || '#999';
}; };
// 查找端口关联的接线 // 查找端口关联的接线
const findPortCable = (port) => { const findPortCable = port => {
if (!cables || cables.length === 0) return null; if (!cables || cables.length === 0) return null;
return cables.find(cable => return cables.find(
(cable.sourceDeviceId === deviceId && cable.sourcePortId === port.portId) || cable =>
(cable.targetDeviceId === deviceId && cable.targetPortId === port.portId) || (cable.sourceDeviceId === deviceId && cable.sourcePortId === port.portId) ||
(cable.sourceDeviceId === deviceId && cable.sourcePort === port.portName) || (cable.targetDeviceId === deviceId && cable.targetPortId === port.portId) ||
(cable.targetDeviceId === deviceId && cable.targetPort === port.portName) (cable.sourceDeviceId === deviceId && cable.sourcePort === port.portName) ||
(cable.targetDeviceId === deviceId && cable.targetPort === port.portName)
); );
}; };
@@ -132,9 +141,10 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
const getPeerInfo = (cable, currentPort) => { const getPeerInfo = (cable, currentPort) => {
if (!cable) return null; if (!cable) return null;
const isSource = cable.sourceDeviceId === deviceId || const isSource =
(cable.sourcePortId && cable.sourcePortId === currentPort.portId) || cable.sourceDeviceId === deviceId ||
cable.sourcePort === currentPort.portName; (cable.sourcePortId && cable.sourcePortId === currentPort.portId) ||
cable.sourcePort === currentPort.portName;
if (isSource) { if (isSource) {
// 当前是源端,返回目标端信息 // 当前是源端,返回目标端信息
@@ -143,7 +153,7 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
deviceName: targetDevice?.name || cable.targetDeviceId, deviceName: targetDevice?.name || cable.targetDeviceId,
deviceId: cable.targetDeviceId, deviceId: cable.targetDeviceId,
portName: cable.targetPort || cable.targetPortId, portName: cable.targetPort || cable.targetPortId,
direction: 'out' direction: 'out',
}; };
} else { } else {
// 当前是目标端,返回源端信息 // 当前是目标端,返回源端信息
@@ -152,37 +162,60 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
deviceName: sourceDevice?.name || cable.sourceDeviceId, deviceName: sourceDevice?.name || cable.sourceDeviceId,
deviceId: cable.sourceDeviceId, deviceId: cable.sourceDeviceId,
portName: cable.sourcePort || cable.sourcePortId, portName: cable.sourcePort || cable.sourcePortId,
direction: 'in' direction: 'in',
}; };
} }
}; };
// 渲染端口详情提示 // 渲染端口详情提示
const renderPortTooltip = (port) => { const renderPortTooltip = port => {
const cable = findPortCable(port); const cable = findPortCable(port);
const peerInfo = cable ? getPeerInfo(cable, port) : null; const peerInfo = cable ? getPeerInfo(cable, port) : null;
return ( return (
<div style={{ padding: '8px 4px', minWidth: '220px' }}> <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 }} /> <NodeIndexOutlined style={{ marginRight: 6 }} />
{port.portName} {port.portName}
</div> </div>
<div style={{ fontSize: 12, lineHeight: '1.8' }}> <div style={{ fontSize: 12, lineHeight: '1.8' }}>
<div><span style={{ opacity: 0.7 }}>端口类型:</span> {port.portType}</div> <div>
<div><span style={{ opacity: 0.7 }}>端口速率:</span> {port.portSpeed}</div> <span style={{ opacity: 0.7 }}>端口类型:</span> {port.portType}
<div><span style={{ opacity: 0.7 }}>状态:</span> </div>
<span style={{ <div>
color: getPortStatusColor(port.status), <span style={{ opacity: 0.7 }}>端口速率:</span> {port.portSpeed}
marginLeft: 4, </div>
fontWeight: 500 <div>
}}> <span style={{ opacity: 0.7 }}>状态:</span>
<span
style={{
color: getPortStatusColor(port.status),
marginLeft: 4,
fontWeight: 500,
}}
>
{getPortStatusText(port.status)} {getPortStatusText(port.status)}
</span> </span>
</div> </div>
{port.vlanId && <div><span style={{ opacity: 0.7 }}>VLAN:</span> {port.vlanId}</div>} {port.vlanId && (
{port.description && <div><span style={{ opacity: 0.7 }}>描述:</span> {port.description}</div>} <div>
<span style={{ opacity: 0.7 }}>VLAN:</span> {port.vlanId}
</div>
)}
{port.description && (
<div>
<span style={{ opacity: 0.7 }}>描述:</span> {port.description}
</div>
)}
</div> </div>
{/* 接线信息 */} {/* 接线信息 */}
@@ -196,45 +229,49 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
<div style={{ fontSize: 12, lineHeight: '1.8' }}> <div style={{ fontSize: 12, lineHeight: '1.8' }}>
{/* 线缆类型和长度 */} {/* 线缆类型和长度 */}
<div style={{ marginBottom: 6 }}> <div style={{ marginBottom: 6 }}>
<span style={{ <span
display: 'inline-block', style={{
padding: '2px 8px', display: 'inline-block',
borderRadius: '4px', padding: '2px 8px',
background: getCableTypeColor(cable.cableType) + '20', borderRadius: '4px',
color: getCableTypeColor(cable.cableType), background: getCableTypeColor(cable.cableType) + '20',
fontSize: '11px', color: getCableTypeColor(cable.cableType),
fontWeight: 500 fontSize: '11px',
}}> fontWeight: 500,
}}
>
{getCableTypeText(cable.cableType)} {getCableTypeText(cable.cableType)}
</span> </span>
{cable.cableLength && ( {cable.cableLength && (
<span style={{ marginLeft: 8, opacity: 0.8 }}> <span style={{ marginLeft: 8, opacity: 0.8 }}>{cable.cableLength}m</span>
{cable.cableLength}m
</span>
)} )}
</div> </div>
{/* 连接方向 */} {/* 连接方向 */}
<div style={{ <div
display: 'flex', style={{
alignItems: 'center', display: 'flex',
gap: '8px', alignItems: 'center',
padding: '8px', gap: '8px',
background: 'rgba(255,255,255,0.05)', padding: '8px',
borderRadius: '6px', background: 'rgba(255,255,255,0.05)',
marginTop: '8px' borderRadius: '6px',
}}> marginTop: '8px',
}}
>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<div style={{ <div
width: '32px', style={{
height: '32px', width: '32px',
borderRadius: '50%', height: '32px',
background: peerInfo.direction === 'out' ? '#52c41a20' : '#1890ff20', borderRadius: '50%',
display: 'flex', background: peerInfo.direction === 'out' ? '#52c41a20' : '#1890ff20',
alignItems: 'center', display: 'flex',
justifyContent: 'center', alignItems: 'center',
fontSize: '14px' justifyContent: 'center',
}}> fontSize: '14px',
}}
>
{peerInfo.direction === 'out' ? '📤' : '📥'} {peerInfo.direction === 'out' ? '📤' : '📥'}
</div> </div>
<div style={{ fontSize: '10px', marginTop: '2px', opacity: 0.6 }}> <div style={{ fontSize: '10px', marginTop: '2px', opacity: 0.6 }}>
@@ -243,15 +280,9 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ fontWeight: 500, color: '#fff' }}> <div style={{ fontWeight: 500, color: '#fff' }}>{peerInfo.deviceName}</div>
{peerInfo.deviceName} <div style={{ fontSize: '11px', opacity: 0.7 }}>端口: {peerInfo.portName}</div>
</div> <div style={{ fontSize: '10px', opacity: 0.5 }}>ID: {peerInfo.deviceId}</div>
<div style={{ fontSize: '11px', opacity: 0.7 }}>
端口: {peerInfo.portName}
</div>
<div style={{ fontSize: '10px', opacity: 0.5 }}>
ID: {peerInfo.deviceId}
</div>
</div> </div>
</div> </div>
@@ -285,34 +316,40 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
}; };
return ( return (
<div style={{ <div
background: 'linear-gradient(145deg, #1e293b 0%, #0f172a 100%)', style={{
borderRadius: compact ? '12px' : '16px', background: 'linear-gradient(145deg, #1e293b 0%, #0f172a 100%)',
padding: compact ? '16px' : '24px', borderRadius: compact ? '12px' : '16px',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.1)', padding: compact ? '16px' : '24px',
border: '1px solid rgba(255, 255, 255, 0.1)' 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 模式下隐藏 */}
{!compact && ( {!compact && (
<div style={{ <div
display: 'flex', style={{
alignItems: 'center', display: 'flex',
justifyContent: 'space-between', alignItems: 'center',
marginBottom: '20px', justifyContent: 'space-between',
paddingBottom: '16px', marginBottom: '20px',
borderBottom: '1px solid rgba(255, 255, 255, 0.1)' paddingBottom: '16px',
}}> borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ <div
width: '40px', style={{
height: '40px', width: '40px',
borderRadius: '10px', height: '40px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', borderRadius: '10px',
display: 'flex', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
alignItems: 'center', display: 'flex',
justifyContent: 'center', alignItems: 'center',
fontSize: '20px' justifyContent: 'center',
}}> fontSize: '20px',
}}
>
🔌 🔌
</div> </div>
<div> <div>
@@ -328,33 +365,39 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
{/* 状态图例 */} {/* 状态图例 */}
<div style={{ display: 'flex', gap: '16px' }}> <div style={{ display: 'flex', gap: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ <div
width: '8px', style={{
height: '8px', width: '8px',
borderRadius: '50%', height: '8px',
background: '#6b7280', borderRadius: '50%',
boxShadow: '0 0 8px #6b7280' background: '#6b7280',
}} /> boxShadow: '0 0 8px #6b7280',
}}
/>
<span style={{ fontSize: '12px', color: 'rgba(255,255,255,0.6)' }}>空闲</span> <span style={{ fontSize: '12px', color: 'rgba(255,255,255,0.6)' }}>空闲</span>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ <div
width: '8px', style={{
height: '8px', width: '8px',
borderRadius: '50%', height: '8px',
background: '#10b981', borderRadius: '50%',
boxShadow: '0 0 8px #10b981' background: '#10b981',
}} /> boxShadow: '0 0 8px #10b981',
}}
/>
<span style={{ fontSize: '12px', color: 'rgba(255,255,255,0.6)' }}>已连接</span> <span style={{ fontSize: '12px', color: 'rgba(255,255,255,0.6)' }}>已连接</span>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ <div
width: '8px', style={{
height: '8px', width: '8px',
borderRadius: '50%', height: '8px',
background: '#ef4444', borderRadius: '50%',
boxShadow: '0 0 8px #ef4444' background: '#ef4444',
}} /> boxShadow: '0 0 8px #ef4444',
}}
/>
<span style={{ fontSize: '12px', color: 'rgba(255,255,255,0.6)' }}>故障</span> <span style={{ fontSize: '12px', color: 'rgba(255,255,255,0.6)' }}>故障</span>
</div> </div>
</div> </div>
@@ -362,16 +405,18 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
)} )}
{/* 端口网格 - 固定每行24个端口 */} {/* 端口网格 - 固定每行24个端口 */}
<div style={{ <div
display: 'grid', style={{
gridTemplateColumns: 'repeat(24, 1fr)', display: 'grid',
gap: '8px', gridTemplateColumns: 'repeat(24, 1fr)',
padding: '16px', gap: '8px',
background: 'rgba(0, 0, 0, 0.3)', padding: '16px',
borderRadius: '12px', background: 'rgba(0, 0, 0, 0.3)',
border: '1px solid rgba(255, 255, 255, 0.05)' borderRadius: '12px',
}}> border: '1px solid rgba(255, 255, 255, 0.05)',
{paginatedPorts.map((port) => { }}
>
{paginatedPorts.map(port => {
const statusColor = getPortStatusColor(port.status); const statusColor = getPortStatusColor(port.status);
const isClickable = onPortClick && port.status !== 'disabled'; const isClickable = onPortClick && port.status !== 'disabled';
const cable = findPortCable(port); const cable = findPortCable(port);
@@ -384,7 +429,7 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
color="#1e293b" color="#1e293b"
overlayStyle={{ overlayStyle={{
borderRadius: '8px', borderRadius: '8px',
border: '1px solid rgba(255, 255, 255, 0.1)' border: '1px solid rgba(255, 255, 255, 0.1)',
}} }}
> >
<div <div
@@ -397,69 +442,79 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
cursor: isClickable ? 'pointer' : 'not-allowed', cursor: isClickable ? 'pointer' : 'not-allowed',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
position: 'relative', position: 'relative',
minWidth: '0' minWidth: '0',
}} }}
> >
{/* LED 指示灯 - 在端口上方 */} {/* LED 指示灯 - 在端口上方 */}
<div style={{ <div
width: '6px', style={{
height: '6px', width: '6px',
borderRadius: '50%', height: '6px',
background: statusColor, borderRadius: '50%',
boxShadow: `0 0 6px ${statusColor}, 0 0 12px ${statusColor}50`, background: statusColor,
marginBottom: '4px', boxShadow: `0 0 6px ${statusColor}, 0 0 12px ${statusColor}50`,
animation: port.status === 'fault' ? 'pulse 1.5s infinite' : 'none' marginBottom: '4px',
}} /> animation: port.status === 'fault' ? 'pulse 1.5s infinite' : 'none',
}}
/>
{/* 端口主体 - 矩形样式 */} {/* 端口主体 - 矩形样式 */}
<div style={{ <div
width: '100%', style={{
aspectRatio: '1 / 1.2', width: '100%',
background: 'linear-gradient(180deg, #2a3441 0%, #1e2530 100%)', aspectRatio: '1 / 1.2',
border: `2px solid ${statusColor}`, background: 'linear-gradient(180deg, #2a3441 0%, #1e2530 100%)',
borderRadius: '2px', border: `2px solid ${statusColor}`,
display: 'flex', borderRadius: '2px',
alignItems: 'center', display: 'flex',
justifyContent: 'center', alignItems: 'center',
position: 'relative', justifyContent: 'center',
boxShadow: `inset 0 1px 0 rgba(255,255,255,0.1), 0 2px 4px rgba(0,0,0,0.3)` position: 'relative',
}}> boxShadow: `inset 0 1px 0 rgba(255,255,255,0.1), 0 2px 4px rgba(0,0,0,0.3)`,
}}
>
{/* 端口内部图标 */} {/* 端口内部图标 */}
<div style={{ <div
fontSize: '10px', style={{
color: statusColor, fontSize: '10px',
opacity: 0.8 color: statusColor,
}}> opacity: 0.8,
}}
>
{getPortTypeIcon(port.portType)} {getPortTypeIcon(port.portType)}
</div> </div>
{/* 接线指示标记 */} {/* 接线指示标记 */}
{cable && ( {cable && (
<div style={{ <div
position: 'absolute', style={{
top: '1px', position: 'absolute',
right: '1px', top: '1px',
width: '4px', right: '1px',
height: '4px', width: '4px',
borderRadius: '50%', height: '4px',
background: getCableTypeColor(cable.cableType), borderRadius: '50%',
boxShadow: `0 0 3px ${getCableTypeColor(cable.cableType)}` background: getCableTypeColor(cable.cableType),
}} /> boxShadow: `0 0 3px ${getCableTypeColor(cable.cableType)}`,
}}
/>
)} )}
</div> </div>
{/* 端口名称 - 在端口下方 */} {/* 端口名称 - 在端口下方 */}
<div style={{ <div
fontSize: '9px', style={{
fontWeight: 500, fontSize: '9px',
color: 'rgba(255, 255, 255, 0.7)', fontWeight: 500,
textAlign: 'center', color: 'rgba(255, 255, 255, 0.7)',
marginTop: '3px', textAlign: 'center',
whiteSpace: 'nowrap', marginTop: '3px',
overflow: 'hidden', whiteSpace: 'nowrap',
textOverflow: 'ellipsis', overflow: 'hidden',
maxWidth: '100%' textOverflow: 'ellipsis',
}}> maxWidth: '100%',
}}
>
{getPortDisplayName(port.portName)} {getPortDisplayName(port.portName)}
</div> </div>
</div> </div>
@@ -470,13 +525,15 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
{/* 分页 */} {/* 分页 */}
{totalPorts > pageSize && ( {totalPorts > pageSize && (
<div style={{ <div
display: 'flex', style={{
justifyContent: 'center', display: 'flex',
padding: '16px 0 0 0', justifyContent: 'center',
borderTop: '1px solid rgba(255, 255, 255, 0.1)', padding: '16px 0 0 0',
marginTop: '16px' borderTop: '1px solid rgba(255, 255, 255, 0.1)',
}}> marginTop: '16px',
}}
>
<Pagination <Pagination
current={currentPage} current={currentPage}
total={totalPorts} total={totalPorts}
@@ -487,11 +544,11 @@ const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onP
}} }}
showSizeChanger showSizeChanger
showQuickJumper showQuickJumper
showTotal={(total) => `${total} 个端口`} showTotal={total => `${total} 个端口`}
pageSizeOptions={['24', '48', '96']} pageSizeOptions={['24', '48', '96']}
size="small" size="small"
style={{ style={{
color: 'rgba(255, 255, 255, 0.8)' color: 'rgba(255, 255, 255, 0.8)',
}} }}
/> />
</div> </div>
+10 -8
View File
@@ -9,14 +9,16 @@ const ProtectedRoute = ({ children, requiredPermission }) => {
if (!initialized) { if (!initialized) {
return ( return (
<div style={{ <div
display: 'flex', style={{
flexDirection: 'column', display: 'flex',
justifyContent: 'center', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
height: '100vh', alignItems: 'center',
gap: '16px' height: '100vh',
}}> gap: '16px',
}}
>
<Spin size="large" /> <Spin size="large" />
<span style={{ color: '#8c8c8c', fontSize: '14px' }}>加载中...</span> <span style={{ color: '#8c8c8c', fontSize: '14px' }}>加载中...</span>
</div> </div>
+173 -54
View File
@@ -9,7 +9,7 @@ import {
DesktopOutlined, DesktopOutlined,
UsbOutlined, UsbOutlined,
MonitorOutlined, MonitorOutlined,
SettingOutlined SettingOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import PortPanel from './PortPanel'; import PortPanel from './PortPanel';
import axios from 'axios'; import axios from 'axios';
@@ -23,8 +23,8 @@ const designTokens = {
error: '#ef4444', error: '#ef4444',
warning: '#f59e0b', warning: '#f59e0b',
metal: { light: '#9ca3af', DEFAULT: '#6b7280', dark: '#4b5563' }, metal: { light: '#9ca3af', DEFAULT: '#6b7280', dark: '#4b5563' },
slot: { empty: '#d1d5db', occupied: '#3b82f6' } slot: { empty: '#d1d5db', occupied: '#3b82f6' },
} },
}; };
/** /**
@@ -44,7 +44,7 @@ const ServerBackplanePanel = ({
cables, cables,
allDevices, allDevices,
onPortClick, onPortClick,
onManageNetworkCards onManageNetworkCards,
}) => { }) => {
const [cards, setCards] = useState([]); const [cards, setCards] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -82,9 +82,20 @@ const ServerBackplanePanel = ({
const name = (card.name || '').toLowerCase(); 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' }); 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' }); onboard.push({ ...card, type: 'onboard' });
} else { } else {
expansionSlots.push({ ...card, type: 'expansion', slotIndex: slotNum }); expansionSlots.push({ ...card, type: 'expansion', slotIndex: slotNum });
@@ -115,7 +126,7 @@ const ServerBackplanePanel = ({
alignItems: 'center', alignItems: 'center',
gap: '6px', gap: '6px',
border: '2px solid #4b5563', 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> <div style={{ fontSize: '10px', color: '#9ca3af', fontWeight: 600 }}>MGMT</div>
@@ -134,7 +145,7 @@ const ServerBackplanePanel = ({
justifyContent: 'center', justifyContent: 'center',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s', 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' }} /> <SettingOutlined style={{ fontSize: 16, color: '#10b981' }} />
@@ -152,7 +163,7 @@ const ServerBackplanePanel = ({
border: '2px dashed #6b7280', border: '2px dashed #6b7280',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center' justifyContent: 'center',
}} }}
> >
<PlusOutlined style={{ fontSize: 14, color: '#6b7280' }} /> <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' }}> <div
<Text style={{ fontSize: '8px', color: '#6b7280', display: 'block', textAlign: 'center', lineHeight: '22px' }}>VGA</Text> 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>
<div style={{ width: '48px', height: '16px', background: '#1f2937', borderRadius: '2px', border: '1px solid #4b5563' }}> <div
<Text style={{ fontSize: '8px', color: '#6b7280', display: 'block', textAlign: 'center', lineHeight: '14px' }}>USB</Text> 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>
</div> </div>
); );
@@ -182,11 +229,20 @@ const ServerBackplanePanel = ({
borderRadius: '4px', borderRadius: '4px',
padding: '12px', padding: '12px',
border: '2px solid #6b7280', 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
<Text style={{ fontSize: '11px', color: '#d1d5db', fontWeight: 600 }}>板载网卡 (Onboard)</Text> style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '8px',
}}
>
<Text style={{ fontSize: '11px', color: '#d1d5db', fontWeight: 600 }}>
板载网卡 (Onboard)
</Text>
{onboardCard && ( {onboardCard && (
<Badge <Badge
count={onboardCard.ports?.length || 0} count={onboardCard.ports?.length || 0}
@@ -204,18 +260,21 @@ const ServerBackplanePanel = ({
padding: '12px', padding: '12px',
border: `2px solid ${onboardCard.ports?.length > 0 ? designTokens.colors.primary.main : '#6b7280'}`, border: `2px solid ${onboardCard.ports?.length > 0 ? designTokens.colors.primary.main : '#6b7280'}`,
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s' transition: 'all 0.2s',
}} }}
> >
{/* 4个RJ45端口布局 */} {/* 4个RJ45端口布局 */}
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}> <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 port = onboardCard.ports?.[idx];
const hasPort = !!port; const hasPort = !!port;
const isOccupied = hasPort && port.status === 'occupied'; const isOccupied = hasPort && port.status === 'occupied';
return ( return (
<Tooltip key={idx} title={hasPort ? `${port.portName} - ${port.status}` : '未配置'}> <Tooltip
key={idx}
title={hasPort ? `${port.portName} - ${port.status}` : '未配置'}
>
<div <div
style={{ style={{
width: '40px', width: '40px',
@@ -224,15 +283,18 @@ const ServerBackplanePanel = ({
? 'linear-gradient(180deg, #374151 0%, #1f2937 100%)' ? 'linear-gradient(180deg, #374151 0%, #1f2937 100%)'
: '#374151', : '#374151',
borderRadius: '4px', borderRadius: '4px',
border: `2px solid ${hasPort border: `2px solid ${
? (isOccupied ? designTokens.colors.success : designTokens.colors.metal.light) hasPort
: '#4b5563' ? isOccupied
? designTokens.colors.success
: designTokens.colors.metal.light
: '#4b5563'
}`, }`,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
position: 'relative' position: 'relative',
}} }}
> >
{/* LED指示灯 */} {/* LED指示灯 */}
@@ -241,13 +303,11 @@ const ServerBackplanePanel = ({
width: '4px', width: '4px',
height: '4px', height: '4px',
borderRadius: '50%', borderRadius: '50%',
background: hasPort background: hasPort ? (isOccupied ? '#10b981' : '#6b7280') : '#374151',
? (isOccupied ? '#10b981' : '#6b7280')
: '#374151',
position: 'absolute', position: 'absolute',
top: '2px', top: '2px',
right: '2px', right: '2px',
boxShadow: isOccupied ? '0 0 4px #10b981' : 'none' boxShadow: isOccupied ? '0 0 4px #10b981' : 'none',
}} }}
/> />
<span style={{ fontSize: '8px', color: '#9ca3af' }}></span> <span style={{ fontSize: '8px', color: '#9ca3af' }}></span>
@@ -273,7 +333,7 @@ const ServerBackplanePanel = ({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
cursor: 'pointer', cursor: 'pointer',
gap: '8px' gap: '8px',
}} }}
> >
<PlusOutlined style={{ fontSize: 20, color: '#6b7280' }} /> <PlusOutlined style={{ fontSize: 20, color: '#6b7280' }} />
@@ -303,13 +363,23 @@ const ServerBackplanePanel = ({
borderRadius: '4px', borderRadius: '4px',
padding: '12px', padding: '12px',
border: '2px solid #6b7280', 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> <Text style={{ fontSize: '11px', color: '#d1d5db', fontWeight: 600 }}>PCIe 扩展插槽</Text>
<Space size={4}> <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> <Text style={{ fontSize: '10px', color: '#9ca3af' }}>/{totalSlots}</Text>
</Space> </Space>
</div> </div>
@@ -318,7 +388,9 @@ const ServerBackplanePanel = ({
{slots.map(({ slotNumber, card }) => ( {slots.map(({ slotNumber, card }) => (
<Tooltip <Tooltip
key={slotNumber} key={slotNumber}
title={card ? `${card.name} (${card.ports?.length || 0}口)` : `插槽 ${slotNumber} (空闲)`} title={
card ? `${card.name} (${card.ports?.length || 0}口)` : `插槽 ${slotNumber} (空闲)`
}
> >
<div <div
onClick={() => card && setSelectedSlot(card)} onClick={() => card && setSelectedSlot(card)}
@@ -337,15 +409,24 @@ const ServerBackplanePanel = ({
padding: '6px', padding: '6px',
cursor: card ? 'pointer' : 'default', cursor: card ? 'pointer' : 'default',
transition: 'all 0.2s', 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 ? ( {card ? (
<> <>
<CloudServerOutlined style={{ fontSize: 20, color: '#3b82f6' }} /> <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) => ( {card.ports?.slice(0, 4).map((port, idx) => (
<div <div
key={idx} key={idx}
@@ -354,19 +435,30 @@ const ServerBackplanePanel = ({
height: '8px', height: '8px',
borderRadius: '1px', borderRadius: '1px',
background: port.status === 'occupied' ? '#10b981' : '#6b7280', 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 && ( {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> </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> <Text style={{ fontSize: '8px', color: '#6b7280' }}>空闲</Text>
</> </>
)} )}
@@ -390,11 +482,13 @@ const ServerBackplanePanel = ({
border: '2px solid #4b5563', border: '2px solid #4b5563',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '8px' gap: '8px',
}} }}
> >
<Text style={{ fontSize: '10px', color: '#9ca3af', fontWeight: 600, textAlign: 'center' }}>电源</Text> <Text style={{ fontSize: '10px', color: '#9ca3af', fontWeight: 600, textAlign: 'center' }}>
{[1, 2].map((psu) => ( 电源
</Text>
{[1, 2].map(psu => (
<div <div
key={psu} key={psu}
style={{ style={{
@@ -406,7 +500,7 @@ const ServerBackplanePanel = ({
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: '4px' gap: '4px',
}} }}
> >
<ThunderboltOutlined style={{ fontSize: 20, color: '#10b981' }} /> <ThunderboltOutlined style={{ fontSize: 20, color: '#10b981' }} />
@@ -417,7 +511,7 @@ const ServerBackplanePanel = ({
height: '6px', height: '6px',
borderRadius: '50%', borderRadius: '50%',
background: '#10b981', background: '#10b981',
boxShadow: '0 0 6px #10b981' boxShadow: '0 0 6px #10b981',
}} }}
/> />
</div> </div>
@@ -445,17 +539,24 @@ const ServerBackplanePanel = ({
padding: '8px 12px', padding: '8px 12px',
background: '#f8fafc', background: '#f8fafc',
borderRadius: '8px', borderRadius: '8px',
border: '1px solid #e2e8f0' border: '1px solid #e2e8f0',
}} }}
> >
<Space> <Space>
<Badge count={cards.filter(c => !c.isUngrouped).length} style={{ backgroundColor: designTokens.colors.primary.main }} /> <Badge
<Text type="secondary" style={{ fontSize: '13px' }}>个网卡</Text> count={cards.filter(c => !c.isUngrouped).length}
style={{ backgroundColor: designTokens.colors.primary.main }}
/>
<Text type="secondary" style={{ fontSize: '13px' }}>
个网卡
</Text>
<Badge <Badge
count={cards.reduce((acc, card) => acc + (card.ports?.length || 0), 0)} count={cards.reduce((acc, card) => acc + (card.ports?.length || 0), 0)}
style={{ backgroundColor: '#667eea' }} style={{ backgroundColor: '#667eea' }}
/> />
<Text type="secondary" style={{ fontSize: '13px' }}>个端口</Text> <Text type="secondary" style={{ fontSize: '13px' }}>
个端口
</Text>
</Space> </Space>
<Space> <Space>
<Button size="small" icon={<ReloadOutlined />} onClick={fetchData}> <Button size="small" icon={<ReloadOutlined />} onClick={fetchData}>
@@ -480,7 +581,7 @@ const ServerBackplanePanel = ({
borderRadius: '8px', borderRadius: '8px',
padding: '16px', padding: '16px',
border: '3px solid #374151', 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', marginBottom: '12px',
padding: '6px 12px', padding: '6px 12px',
background: 'rgba(0,0,0,0.3)', background: 'rgba(0,0,0,0.3)',
borderRadius: '4px' borderRadius: '4px',
}} }}
> >
<DesktopOutlined style={{ fontSize: 14, color: '#9ca3af', marginRight: 8 }} /> <DesktopOutlined style={{ fontSize: 14, color: '#9ca3af', marginRight: 8 }} />
@@ -536,16 +637,34 @@ const ServerBackplanePanel = ({
> >
{selectedSlot && ( {selectedSlot && (
<div> <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%' }}> <Space direction="vertical" size={4} style={{ width: '100%' }}>
<Text type="secondary">类型: {selectedSlot.type === 'onboard' ? '板载网卡' : selectedSlot.type === 'management' ? '管理口' : '扩展网卡'}</Text> <Text type="secondary">
{selectedSlot.description && <Text type="secondary">描述: {selectedSlot.description}</Text>} 类型:{' '}
{selectedSlot.type === 'onboard'
? '板载网卡'
: selectedSlot.type === 'management'
? '管理口'
: '扩展网卡'}
</Text>
{selectedSlot.description && (
<Text type="secondary">描述: {selectedSlot.description}</Text>
)}
<div> <div>
<Text type="secondary">端口统计: </Text> <Text type="secondary">端口统计: </Text>
<Space size={8}> <Space size={8}>
<Tag color="success">空闲: {selectedSlot.stats?.free || 0}</Tag> <Tag color="success">空闲: {selectedSlot.stats?.free || 0}</Tag>
<Tag color="processing">占用: {selectedSlot.stats?.occupied || 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> <Tag color="blue">总计: {selectedSlot.ports?.length || 0}</Tag>
</Space> </Space>
</div> </div>
+62 -53
View File
@@ -1,6 +1,13 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Button, Empty, Spin, Badge, Typography, Space, Checkbox, Tooltip } from 'antd'; 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 ServerBackplanePanel from './ServerBackplanePanel';
import PortPanel from './PortPanel'; import PortPanel from './PortPanel';
@@ -29,7 +36,7 @@ const VirtualDeviceList = ({
onAddPort, onAddPort,
onManageNetworkCards, onManageNetworkCards,
initialVisibleCount = 5, initialVisibleCount = 5,
loadMoreCount = 5 loadMoreCount = 5,
}) => { }) => {
const [visibleCount, setVisibleCount] = useState(initialVisibleCount); const [visibleCount, setVisibleCount] = useState(initialVisibleCount);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -54,10 +61,10 @@ const VirtualDeviceList = ({
const options = { const options = {
root: null, root: null,
rootMargin: '100px', rootMargin: '100px',
threshold: 0.1 threshold: 0.1,
}; };
observerRef.current = new IntersectionObserver((entries) => { observerRef.current = new IntersectionObserver(entries => {
entries.forEach(entry => { entries.forEach(entry => {
if (entry.isIntersecting && !loading && visibleCount < devices.length) { if (entry.isIntersecting && !loading && visibleCount < devices.length) {
loadMore(); loadMore();
@@ -110,10 +117,10 @@ const VirtualDeviceList = ({
setShowAll(false); setShowAll(false);
}, [devices]); }, [devices]);
const toggleDeviceExpand = (deviceId) => { const toggleDeviceExpand = deviceId => {
setExpandedDevices(prev => ({ setExpandedDevices(prev => ({
...prev, ...prev,
[deviceId]: !prev[deviceId] [deviceId]: !prev[deviceId],
})); }));
}; };
@@ -121,33 +128,29 @@ const VirtualDeviceList = ({
const hasMore = visibleCount < devices.length; const hasMore = visibleCount < devices.length;
if (devices.length === 0) { if (devices.length === 0) {
return ( return <Empty description="暂无设备数据" style={{ padding: '60px 0' }} />;
<Empty
description="暂无设备数据"
style={{ padding: '60px 0' }}
/>
);
} }
return ( return (
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}> <div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* 控制栏 */} {/* 控制栏 */}
<div style={{ <div
display: 'flex', style={{
justifyContent: 'space-between', display: 'flex',
alignItems: 'center', justifyContent: 'space-between',
padding: '12px 16px', alignItems: 'center',
background: '#f8fafc', padding: '12px 16px',
borderRadius: '8px', background: '#f8fafc',
border: '1px solid #e2e8f0' borderRadius: '8px',
}}> border: '1px solid #e2e8f0',
}}
>
<Space align="center"> <Space align="center">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Text strong style={{ fontSize: '14px' }}>设备列表</Text> <Text strong style={{ fontSize: '14px' }}>
<Badge 设备列表
count={devices.length} </Text>
style={{ backgroundColor: '#667eea' }} <Badge count={devices.length} style={{ backgroundColor: '#667eea' }} />
/>
</div> </div>
<Text type="secondary" style={{ fontSize: '12px' }}> <Text type="secondary" style={{ fontSize: '12px' }}>
显示 {visibleDevices.length} / {devices.length} 显示 {visibleDevices.length} / {devices.length}
@@ -166,7 +169,7 @@ const VirtualDeviceList = ({
</div> </div>
{/* 设备面板列表 */} {/* 设备面板列表 */}
{visibleDevices.map((device) => { {visibleDevices.map(device => {
const deviceId = device.deviceId; const deviceId = device.deviceId;
const data = groupedPorts[deviceId] || { device, ports: [] }; const data = groupedPorts[deviceId] || { device, ports: [] };
const isExpanded = expandedDevices[deviceId]; const isExpanded = expandedDevices[deviceId];
@@ -181,7 +184,7 @@ const VirtualDeviceList = ({
borderRadius: '12px', borderRadius: '12px',
overflow: 'hidden', overflow: 'hidden',
background: '#fff', background: '#fff',
transition: 'all 0.3s ease' transition: 'all 0.3s ease',
}} }}
> >
{/* 设备标题栏 */} {/* 设备标题栏 */}
@@ -195,31 +198,37 @@ const VirtualDeviceList = ({
background: isExpanded ? '#f1f5f9' : '#fff', background: isExpanded ? '#f1f5f9' : '#fff',
cursor: 'pointer', cursor: 'pointer',
borderBottom: isExpanded ? '1px solid #e2e8f0' : 'none', borderBottom: isExpanded ? '1px solid #e2e8f0' : 'none',
transition: 'background 0.2s' transition: 'background 0.2s',
}} }}
onMouseEnter={(e) => { onMouseEnter={e => {
e.currentTarget.style.background = '#f1f5f9'; e.currentTarget.style.background = '#f1f5f9';
}} }}
onMouseLeave={(e) => { onMouseLeave={e => {
if (!isExpanded) { if (!isExpanded) {
e.currentTarget.style.background = '#fff'; e.currentTarget.style.background = '#fff';
} }
}} }}
> >
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ <div
width: '40px', style={{
height: '40px', width: '40px',
borderRadius: '10px', height: '40px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', borderRadius: '10px',
display: 'flex', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
alignItems: 'center', display: 'flex',
justifyContent: 'center', alignItems: 'center',
fontSize: '20px' justifyContent: 'center',
}}> fontSize: '20px',
{device.type?.toLowerCase()?.includes('server') ? '🖥️' : }}
device.type?.toLowerCase()?.includes('switch') ? '🔀' : >
device.type?.toLowerCase()?.includes('router') ? '🌐' : '📦'} {device.type?.toLowerCase()?.includes('server')
? '🖥️'
: device.type?.toLowerCase()?.includes('switch')
? '🔀'
: device.type?.toLowerCase()?.includes('router')
? '🌐'
: '📦'}
</div> </div>
<div> <div>
<div style={{ fontWeight: 600, fontSize: '15px', color: '#1e293b' }}> <div style={{ fontWeight: 600, fontSize: '15px', color: '#1e293b' }}>
@@ -257,13 +266,13 @@ const VirtualDeviceList = ({
type="primary" type="primary"
size="small" size="small"
icon={<CloudServerOutlined />} icon={<CloudServerOutlined />}
onClick={(e) => { onClick={e => {
e.stopPropagation(); // 防止触发折叠 e.stopPropagation(); // 防止触发折叠
onManageNetworkCards && onManageNetworkCards(device); onManageNetworkCards && onManageNetworkCards(device);
}} }}
style={{ style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none' border: 'none',
}} }}
> >
网卡管理 网卡管理
@@ -275,13 +284,13 @@ const VirtualDeviceList = ({
type="primary" type="primary"
size="small" size="small"
icon={<PlusOutlined />} icon={<PlusOutlined />}
onClick={(e) => { onClick={e => {
e.stopPropagation(); // 防止触发折叠 e.stopPropagation(); // 防止触发折叠
onAddPort && onAddPort(device); onAddPort && onAddPort(device);
}} }}
style={{ style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
border: 'none' border: 'none',
}} }}
> >
添加端口 添加端口
@@ -318,7 +327,9 @@ const VirtualDeviceList = ({
cables={cables} cables={cables}
allDevices={allDevices} allDevices={allDevices}
onPortClick={onPortClick} onPortClick={onPortClick}
onManageNetworkCards={() => onManageNetworkCards && onManageNetworkCards(device)} onManageNetworkCards={() =>
onManageNetworkCards && onManageNetworkCards(device)
}
/> />
)} )}
</div> </div>
@@ -334,15 +345,13 @@ const VirtualDeviceList = ({
style={{ style={{
textAlign: 'center', textAlign: 'center',
padding: '20px', padding: '20px',
color: '#64748b' color: '#64748b',
}} }}
> >
{loading ? ( {loading ? (
<Spin size="small" tip="加载更多设备..." /> <Spin size="small" tip="加载更多设备..." />
) : ( ) : (
<Text type="secondary"> <Text type="secondary">向下滚动加载更多 ({devices.length - visibleCount} 个设备)</Text>
向下滚动加载更多 ({devices.length - visibleCount} 个设备)
</Text>
)} )}
</div> </div>
)} )}
+15 -15
View File
@@ -9,49 +9,49 @@ export const designTokens = {
main: '#667eea', main: '#667eea',
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
light: '#8b9ff0', light: '#8b9ff0',
dark: '#4f5db8' dark: '#4f5db8',
}, },
success: { success: {
main: '#10b981', main: '#10b981',
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
light: '#34d399', light: '#34d399',
dark: '#047857' dark: '#047857',
}, },
warning: { warning: {
main: '#f59e0b', main: '#f59e0b',
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)', gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
light: '#fbbf24', light: '#fbbf24',
dark: '#b45309' dark: '#b45309',
}, },
error: { error: {
main: '#ef4444', main: '#ef4444',
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)', gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
light: '#f87171', light: '#f87171',
dark: '#b91c1c' dark: '#b91c1c',
}, },
text: { text: {
primary: '#1e293b', primary: '#1e293b',
secondary: '#64748b', secondary: '#64748b',
tertiary: '#94a3b8', tertiary: '#94a3b8',
inverse: '#ffffff' inverse: '#ffffff',
}, },
background: { background: {
primary: '#ffffff', primary: '#ffffff',
secondary: '#f8fafc', secondary: '#f8fafc',
tertiary: '#f1f5f9', tertiary: '#f1f5f9',
dark: '#1e293b' dark: '#1e293b',
}, },
border: { border: {
light: '#e2e8f0', light: '#e2e8f0',
medium: '#cbd5e1', medium: '#cbd5e1',
dark: '#94a3b8' dark: '#94a3b8',
}, },
device: { device: {
server: '#3b82f6', server: '#3b82f6',
switch: '#22c55e', switch: '#22c55e',
router: '#f59e0b', router: '#f59e0b',
storage: '#8b5cf6', storage: '#8b5cf6',
other: '#64748b' other: '#64748b',
}, },
status: { status: {
normal: '#10b981', normal: '#10b981',
@@ -60,35 +60,35 @@ export const designTokens = {
error: '#ef4444', error: '#ef4444',
fault: '#ef4444', fault: '#ef4444',
offline: '#6b7280', offline: '#6b7280',
maintenance: '#3b82f6' maintenance: '#3b82f6',
} },
}, },
shadows: { shadows: {
small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', 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)', 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)', 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)', 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: { borderRadius: {
small: '6px', small: '6px',
medium: '10px', medium: '10px',
large: '16px', large: '16px',
xl: '24px', xl: '24px',
round: '50%' round: '50%',
}, },
transitions: { transitions: {
fast: '150ms cubic-bezier(0.4, 0, 0.2, 1)', 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)',
slow: '500ms cubic-bezier(0.4, 0, 0.2, 1)' slow: '500ms cubic-bezier(0.4, 0, 0.2, 1)',
}, },
spacing: { spacing: {
xs: '4px', xs: '4px',
sm: '8px', sm: '8px',
md: '16px', md: '16px',
lg: '24px', lg: '24px',
xl: '32px' xl: '32px',
} },
}; };
export default designTokens; export default designTokens;
@@ -12,7 +12,7 @@ export const PAGINATION_CONFIG = {
// 显示快速跳转 // 显示快速跳转
showSizeChanger: true, 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 = { export const TABLE_SCROLL_CONFIG = {
x: 'max-content', x: 'max-content',
y: 'calc(100vh - 400px)' y: 'calc(100vh - 400px)',
}; };
// 默认设备字段配置 // 默认设备字段配置
@@ -32,7 +32,7 @@ export const DEFAULT_DEVICE_FIELDS = [
fieldType: 'text', fieldType: 'text',
required: true, required: true,
visible: true, visible: true,
editable: false editable: false,
}, },
{ {
fieldName: 'name', fieldName: 'name',
@@ -40,7 +40,7 @@ export const DEFAULT_DEVICE_FIELDS = [
fieldType: 'text', fieldType: 'text',
required: true, required: true,
visible: true, visible: true,
editable: true editable: true,
}, },
{ {
fieldName: 'type', fieldName: 'type',
@@ -54,8 +54,8 @@ export const DEFAULT_DEVICE_FIELDS = [
{ value: 'switch', label: '交换机' }, { value: 'switch', label: '交换机' },
{ value: 'router', label: '路由器' }, { value: 'router', label: '路由器' },
{ value: 'storage', label: '存储设备' }, { value: 'storage', label: '存储设备' },
{ value: 'other', label: '其他设备' } { value: 'other', label: '其他设备' },
] ],
}, },
{ {
fieldName: 'model', fieldName: 'model',
@@ -63,7 +63,7 @@ export const DEFAULT_DEVICE_FIELDS = [
fieldType: 'text', fieldType: 'text',
required: false, required: false,
visible: true, visible: true,
editable: true editable: true,
}, },
{ {
fieldName: 'serialNumber', fieldName: 'serialNumber',
@@ -71,7 +71,7 @@ export const DEFAULT_DEVICE_FIELDS = [
fieldType: 'text', fieldType: 'text',
required: true, required: true,
visible: true, visible: true,
editable: true editable: true,
}, },
{ {
fieldName: 'rackId', fieldName: 'rackId',
@@ -79,7 +79,7 @@ export const DEFAULT_DEVICE_FIELDS = [
fieldType: 'text', fieldType: 'text',
required: true, required: true,
visible: true, visible: true,
editable: true editable: true,
}, },
{ {
fieldName: 'position', fieldName: 'position',
@@ -87,7 +87,7 @@ export const DEFAULT_DEVICE_FIELDS = [
fieldType: 'number', fieldType: 'number',
required: true, required: true,
visible: true, visible: true,
editable: true editable: true,
}, },
{ {
fieldName: 'height', fieldName: 'height',
@@ -95,7 +95,7 @@ export const DEFAULT_DEVICE_FIELDS = [
fieldType: 'number', fieldType: 'number',
required: true, required: true,
visible: true, visible: true,
editable: true editable: true,
}, },
{ {
fieldName: 'powerConsumption', fieldName: 'powerConsumption',
@@ -103,7 +103,7 @@ export const DEFAULT_DEVICE_FIELDS = [
fieldType: 'number', fieldType: 'number',
required: false, required: false,
visible: true, visible: true,
editable: true editable: true,
}, },
{ {
fieldName: 'ipAddress', fieldName: 'ipAddress',
@@ -111,7 +111,7 @@ export const DEFAULT_DEVICE_FIELDS = [
fieldType: 'text', fieldType: 'text',
required: false, required: false,
visible: true, visible: true,
editable: true editable: true,
}, },
{ {
fieldName: 'status', fieldName: 'status',
@@ -124,8 +124,8 @@ export const DEFAULT_DEVICE_FIELDS = [
{ value: 'running', label: '运行中' }, { value: 'running', label: '运行中' },
{ value: 'maintenance', label: '维护中' }, { value: 'maintenance', label: '维护中' },
{ value: 'offline', label: '离线' }, { value: 'offline', label: '离线' },
{ value: 'fault', label: '故障' } { value: 'fault', label: '故障' },
] ],
}, },
{ {
fieldName: 'purchaseDate', fieldName: 'purchaseDate',
@@ -133,7 +133,7 @@ export const DEFAULT_DEVICE_FIELDS = [
fieldType: 'date', fieldType: 'date',
required: false, required: false,
visible: true, visible: true,
editable: true editable: true,
}, },
{ {
fieldName: 'warrantyExpiry', fieldName: 'warrantyExpiry',
@@ -141,7 +141,7 @@ export const DEFAULT_DEVICE_FIELDS = [
fieldType: 'date', fieldType: 'date',
required: false, required: false,
visible: true, visible: true,
editable: true editable: true,
}, },
{ {
fieldName: 'description', fieldName: 'description',
@@ -149,8 +149,8 @@ export const DEFAULT_DEVICE_FIELDS = [
fieldType: 'textarea', fieldType: 'textarea',
required: false, required: false,
visible: true, visible: true,
editable: true editable: true,
} },
]; ];
// 基础字段名称列表(用于导入导出时排除自定义字段) // 基础字段名称列表(用于导入导出时排除自定义字段)
@@ -168,7 +168,7 @@ export const BASE_FIELD_NAMES = [
'status', 'status',
'purchaseDate', 'purchaseDate',
'warrantyExpiry', 'warrantyExpiry',
'description' 'description',
]; ];
// 系统字段列表(不可编辑) // 系统字段列表(不可编辑)
@@ -186,7 +186,7 @@ export const IMPORT_CONFIG = {
// 单次最大导入条数 // 单次最大导入条数
maxImportCount: 5000, maxImportCount: 5000,
// 编码格式 // 编码格式
encoding: 'gbk' encoding: 'gbk',
}; };
// 导出配置 // 导出配置
@@ -196,7 +196,7 @@ export const EXPORT_CONFIG = {
// 日期格式 // 日期格式
dateFormat: 'YYYY-MM-DD_HH-mm-ss', dateFormat: 'YYYY-MM-DD_HH-mm-ss',
// 支持的导出格式 // 支持的导出格式
formats: ['xlsx', 'csv'] formats: ['xlsx', 'csv'],
}; };
// 模态框配置 // 模态框配置
@@ -210,7 +210,7 @@ export const MODAL_CONFIG = {
// 导入模态框宽度 // 导入模态框宽度
importModalWidth: 600, importModalWidth: 600,
// 导出模态框宽度 // 导出模态框宽度
exportModalWidth: 500 exportModalWidth: 500,
}; };
// 统计卡片配置 // 统计卡片配置
@@ -220,7 +220,7 @@ export const STATS_CONFIG = {
// 显示的维护中设备数量上限 // 显示的维护中设备数量上限
maxMaintenanceDisplay: 99, maxMaintenanceDisplay: 99,
// 显示的故障设备数量上限 // 显示的故障设备数量上限
maxFaultDisplay: 99 maxFaultDisplay: 99,
}; };
// 设备类型选项(用于筛选) // 设备类型选项(用于筛选)
@@ -230,7 +230,7 @@ export const DEVICE_TYPE_OPTIONS = [
{ value: 'switch', label: '交换机' }, { value: 'switch', label: '交换机' },
{ value: 'router', label: '路由器' }, { value: 'router', label: '路由器' },
{ value: 'storage', label: '存储设备' }, { value: 'storage', label: '存储设备' },
{ value: 'other', label: '其他设备' } { value: 'other', label: '其他设备' },
]; ];
// 设备状态选项(用于筛选) // 设备状态选项(用于筛选)
@@ -239,7 +239,7 @@ export const DEVICE_STATUS_OPTIONS = [
{ value: 'running', label: '运行中' }, { value: 'running', label: '运行中' },
{ value: 'maintenance', label: '维护中' }, { value: 'maintenance', label: '维护中' },
{ value: 'offline', label: '离线' }, { value: 'offline', label: '离线' },
{ value: 'fault', label: '故障' } { value: 'fault', label: '故障' },
]; ];
// 表格列宽配置 // 表格列宽配置
@@ -258,13 +258,13 @@ export const COLUMN_WIDTH_CONFIG = {
purchaseDate: 110, purchaseDate: 110,
warrantyExpiry: 110, warrantyExpiry: 110,
description: 200, description: 200,
action: 150 action: 150,
}; };
// 空状态配置 // 空状态配置
export const EMPTY_STATE_CONFIG = { export const EMPTY_STATE_CONFIG = {
description: '暂无设备数据', 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, batchConfirmThreshold: 10,
// 批量删除确认消息 // 批量删除确认消息
batchDeleteConfirmMessage: (count) => `确定要删除选中的 ${count} 个设备吗?此操作不可恢复。`, batchDeleteConfirmMessage: count => `确定要删除选中的 ${count} 个设备吗?此操作不可恢复。`,
// 单个删除确认消息 // 单个删除确认消息
singleDeleteConfirmMessage: (name) => `确定要删除设备 "${name}" 吗?此操作不可恢复。` singleDeleteConfirmMessage: name => `确定要删除设备 "${name}" 吗?此操作不可恢复。`,
}; };
+5 -9
View File
@@ -97,7 +97,7 @@ export const AuthProvider = ({ children }) => {
} }
}; };
const register = async (userData) => { const register = async userData => {
try { try {
const response = await authAPI.register(userData); const response = await authAPI.register(userData);
if (response.success) { if (response.success) {
@@ -123,13 +123,13 @@ export const AuthProvider = ({ children }) => {
setUser(null); setUser(null);
}, []); }, []);
const updateUser = (newUserData) => { const updateUser = newUserData => {
const updatedUser = { ...user, ...newUserData }; const updatedUser = { ...user, ...newUserData };
setUser(updatedUser); setUser(updatedUser);
localStorage.setItem('user', JSON.stringify(updatedUser)); localStorage.setItem('user', JSON.stringify(updatedUser));
}; };
const hasPermission = (permission) => { const hasPermission = permission => {
if (!user) return false; if (!user) return false;
return true; return true;
}; };
@@ -144,14 +144,10 @@ export const AuthProvider = ({ children }) => {
logout, logout,
updateUser, updateUser,
hasPermission, hasPermission,
checkAdmin: () => authAPI.checkAdmin() checkAdmin: () => authAPI.checkAdmin(),
}; };
return ( return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}; };
export default AuthContext; export default AuthContext;
+4 -4
View File
@@ -17,7 +17,7 @@ export const ConfigProvider = ({ children }) => {
date_format: 'YYYY-MM-DD', date_format: 'YYYY-MM-DD',
session_timeout: 30, session_timeout: 30,
max_login_attempts: 5, max_login_attempts: 5,
maintenance_mode: false maintenance_mode: false,
}); });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -35,7 +35,7 @@ export const ConfigProvider = ({ children }) => {
setConfig(prev => ({ setConfig(prev => ({
...prev, ...prev,
...configValues ...configValues,
})); }));
} catch (error) { } catch (error) {
console.error('加载系统配置失败:', error); console.error('加载系统配置失败:', error);
@@ -50,10 +50,10 @@ export const ConfigProvider = ({ children }) => {
}, []); }, []);
// 更新配置 // 更新配置
const updateConfig = (newConfig) => { const updateConfig = newConfig => {
setConfig(prev => ({ setConfig(prev => ({
...prev, ...prev,
...newConfig ...newConfig,
})); }));
}; };
+60 -48
View File
@@ -17,11 +17,11 @@ export const Scene3DProvider = ({ children }) => {
const [loadingDevices, setLoadingDevices] = useState(false); const [loadingDevices, setLoadingDevices] = useState(false);
// 使用 useCallback 稳定回调函数 // 使用 useCallback 稳定回调函数
const selectDevice = useCallback((device) => { const selectDevice = useCallback(device => {
setSelectedDevice(device); setSelectedDevice(device);
}, []); }, []);
const hoverDevice = useCallback((device) => { const hoverDevice = useCallback(device => {
setHoveredDevice(device); setHoveredDevice(device);
}, []); }, []);
@@ -29,72 +29,84 @@ export const Scene3DProvider = ({ children }) => {
setDeviceSlideEnabled(prev => !prev); setDeviceSlideEnabled(prev => !prev);
}, []); }, []);
const setDeviceSlide = useCallback((enabled) => { const setDeviceSlide = useCallback(enabled => {
setDeviceSlideEnabled(enabled); setDeviceSlideEnabled(enabled);
}, []); }, []);
const updateDevices = useCallback((newDevices) => { const updateDevices = useCallback(newDevices => {
setDevices(newDevices); setDevices(newDevices);
}, []); }, []);
const updateRacks = useCallback((newRacks) => { const updateRacks = useCallback(newRacks => {
setRacks(newRacks); setRacks(newRacks);
}, []); }, []);
const selectRack = useCallback((rack) => { const selectRack = useCallback(rack => {
setSelectedRack(rack); setSelectedRack(rack);
}, []); }, []);
const updateDeviceCables = useCallback((cables) => { const updateDeviceCables = useCallback(cables => {
setDeviceCables(cables); setDeviceCables(cables);
}, []); }, []);
const setLoading = useCallback((loading) => { const setLoading = useCallback(loading => {
setLoadingDevices(loading); setLoadingDevices(loading);
}, []); }, []);
// 使用 useMemo 缓存 context value,避免不必要的重渲染 // 使用 useMemo 缓存 context value,避免不必要的重渲染
const value = useMemo(() => ({ const value = useMemo(
// 状态 () => ({
devices, // 状态
selectedDevice, devices,
hoveredDevice, selectedDevice,
deviceSlideEnabled, hoveredDevice,
selectedRack, deviceSlideEnabled,
racks, selectedRack,
deviceCables, racks,
loadingDevices, deviceCables,
// 方法 loadingDevices,
selectDevice, // 方法
hoverDevice, selectDevice,
toggleDeviceSlide, hoverDevice,
setDeviceSlide, toggleDeviceSlide,
updateDevices, setDeviceSlide,
updateRacks, updateDevices,
selectRack, updateRacks,
updateDeviceCables, selectRack,
setLoading, updateDeviceCables,
// 直接设置状态的方法(用于兼容现有代码) setLoading,
setDevices, // 直接设置状态的方法(用于兼容现有代码)
setSelectedDevice, setDevices,
setHoveredDevice, setSelectedDevice,
setDeviceSlideEnabled, setHoveredDevice,
setSelectedRack, setDeviceSlideEnabled,
setRacks, setSelectedRack,
setDeviceCables, setRacks,
setLoadingDevices, setDeviceCables,
}), [ setLoadingDevices,
devices, selectedDevice, hoveredDevice, deviceSlideEnabled, }),
selectedRack, racks, deviceCables, loadingDevices, [
selectDevice, hoverDevice, toggleDeviceSlide, setDeviceSlide, devices,
updateDevices, updateRacks, selectRack, updateDeviceCables, setLoading selectedDevice,
]); hoveredDevice,
deviceSlideEnabled,
return ( selectedRack,
<Scene3DContext.Provider value={value}> racks,
{children} deviceCables,
</Scene3DContext.Provider> loadingDevices,
selectDevice,
hoverDevice,
toggleDeviceSlide,
setDeviceSlide,
updateDevices,
updateRacks,
selectRack,
updateDeviceCables,
setLoading,
]
); );
return <Scene3DContext.Provider value={value}>{children}</Scene3DContext.Provider>;
}; };
// 自定义 Hook // 自定义 Hook
+17 -13
View File
@@ -18,7 +18,7 @@ export const useDesignTokens = () => {
primary: { primary: {
main: primaryColor, main: primaryColor,
gradient: `linear-gradient(135deg, ${primaryColor} 0%, ${secondaryColor} 100%)`, gradient: `linear-gradient(135deg, ${primaryColor} 0%, ${secondaryColor} 100%)`,
light: '#8b9ff0' light: '#8b9ff0',
}, },
success: { main: '#10b981' }, success: { main: '#10b981' },
warning: { main: '#f59e0b' }, warning: { main: '#f59e0b' },
@@ -26,15 +26,15 @@ export const useDesignTokens = () => {
text: { text: {
primary: '#1e293b', primary: '#1e293b',
secondary: '#64748b', secondary: '#64748b',
inverse: '#ffffff' inverse: '#ffffff',
}, },
background: { background: {
primary: '#ffffff', primary: '#ffffff',
secondary: '#f8fafc', secondary: '#f8fafc',
dark: '#1e293b' dark: '#1e293b',
}, },
border: { border: {
light: '#e2e8f0' light: '#e2e8f0',
}, },
sidebar: { sidebar: {
bg: '#ffffff', bg: '#ffffff',
@@ -43,23 +43,23 @@ export const useDesignTokens = () => {
text: '#475569', text: '#475569',
textHover: primaryColor, textHover: primaryColor,
textActive: primaryColor, textActive: primaryColor,
border: '#e2e8f0' border: '#e2e8f0',
} },
}, },
shadows: { shadows: {
small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', 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)', 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: { borderRadius: {
small: '6px', small: '6px',
medium: '10px' medium: '10px',
}, },
spacing: { spacing: {
sm: '8px', sm: '8px',
md: '16px', md: '16px',
lg: '24px' lg: '24px',
} },
}; };
}, [config?.primary_color, config?.secondary_color]); }, [config?.primary_color, config?.secondary_color]);
@@ -76,9 +76,13 @@ function hexToRgb(hex) {
const cleanHex = hex.replace('#', ''); const cleanHex = hex.replace('#', '');
// 处理简写格式 (如: #fff) // 处理简写格式 (如: #fff)
const fullHex = cleanHex.length === 3 const fullHex =
? cleanHex.split('').map(c => c + c).join('') cleanHex.length === 3
: cleanHex; ? cleanHex
.split('')
.map(c => c + c)
.join('')
: cleanHex;
const r = parseInt(fullHex.substring(0, 2), 16); const r = parseInt(fullHex.substring(0, 2), 16);
const g = parseInt(fullHex.substring(2, 4), 16); const g = parseInt(fullHex.substring(2, 4), 16);
+13 -7
View File
@@ -5,9 +5,9 @@
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', font-family:
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell',
sans-serif; 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
background-color: #f5f7fa; background-color: #f5f7fa;
@@ -149,19 +149,24 @@ body {
transform: translateY(-1px); transform: translateY(-1px);
} }
.ant-input, .ant-select-selector, .ant-picker { .ant-input,
.ant-select-selector,
.ant-picker {
border-radius: 8px !important; border-radius: 8px !important;
border: 1px solid #d9d9d9 !important; border: 1px solid #d9d9d9 !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !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; 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; border-color: #40a9ff !important;
box-shadow: 0 2px 6px rgba(24, 144, 255, 0.15) !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-select-focused .ant-select-selector,
.ant-picker-focused { .ant-picker-focused {
border-color: #1890ff !important; border-color: #1890ff !important;
@@ -188,7 +193,8 @@ body {
box-shadow: 0 2px 6px rgba(24, 144, 255, 0.15) !important; 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; border-color: #1890ff !important;
box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.15) !important; box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.15) !important;
} }
+183 -146
View File
@@ -1,6 +1,35 @@
import React, { useState, useEffect, useCallback } from 'react'; 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 {
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined, ExportOutlined, ImportOutlined, DownloadOutlined, UploadOutlined as UploadIcon } from '@ant-design/icons'; 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 axios from 'axios';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import Papa from 'papaparse'; import Papa from 'papaparse';
@@ -14,35 +43,35 @@ const designTokens = {
main: '#667eea', main: '#667eea',
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
light: '#8b9ff0', light: '#8b9ff0',
dark: '#4f5db8' dark: '#4f5db8',
}, },
success: { success: {
main: '#10b981', main: '#10b981',
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
light: '#34d399', light: '#34d399',
dark: '#047857' dark: '#047857',
}, },
warning: { warning: {
main: '#f59e0b', main: '#f59e0b',
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)', gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
light: '#fbbf24', light: '#fbbf24',
dark: '#b45309' dark: '#b45309',
}, },
error: { error: {
main: '#ef4444', main: '#ef4444',
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)', gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
light: '#f87171', light: '#f87171',
dark: '#b91c1c' dark: '#b91c1c',
} },
}, },
borderRadius: { borderRadius: {
small: '6px', small: '6px',
medium: '10px', medium: '10px',
large: '16px' large: '16px',
}, },
shadows: { 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() { function CableManagement() {
@@ -55,7 +84,7 @@ function CableManagement() {
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
switchDeviceId: '', switchDeviceId: '',
status: 'all', status: 'all',
cableType: 'all' cableType: 'all',
}); });
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const [editingCable, setEditingCable] = useState(null); const [editingCable, setEditingCable] = useState(null);
@@ -88,7 +117,7 @@ function CableManagement() {
if (!grouped[switchId]) { if (!grouped[switchId]) {
grouped[switchId] = { grouped[switchId] = {
switch: cable.sourceDevice, switch: cable.sourceDevice,
cables: [] cables: [],
}; };
} }
grouped[switchId].cables.push(cable); grouped[switchId].cables.push(cable);
@@ -128,7 +157,7 @@ function CableManagement() {
} }
}, []); }, []);
const fetchDevicePorts = useCallback(async (deviceId) => { const fetchDevicePorts = useCallback(async deviceId => {
if (!deviceId) { if (!deviceId) {
setDevicePorts(prev => ({ ...prev, [deviceId]: [] })); setDevicePorts(prev => ({ ...prev, [deviceId]: [] }));
return; return;
@@ -156,7 +185,7 @@ function CableManagement() {
setFilters({ setFilters({
switchDeviceId: '', switchDeviceId: '',
status: 'all', status: 'all',
cableType: 'all' cableType: 'all',
}); });
}; };
@@ -166,7 +195,7 @@ function CableManagement() {
setModalVisible(true); setModalVisible(true);
}; };
const handleEdit = (cable) => { const handleEdit = cable => {
setEditingCable(cable); setEditingCable(cable);
form.setFieldsValue({ form.setFieldsValue({
sourceDeviceId: cable.sourceDeviceId, sourceDeviceId: cable.sourceDeviceId,
@@ -176,12 +205,12 @@ function CableManagement() {
cableType: cable.cableType, cableType: cable.cableType,
cableLength: cable.cableLength, cableLength: cable.cableLength,
status: cable.status, status: cable.status,
description: cable.description description: cable.description,
}); });
setModalVisible(true); setModalVisible(true);
}; };
const handleDelete = async (cableId) => { const handleDelete = async cableId => {
try { try {
await axios.delete(`/api/cables/${cableId}`); await axios.delete(`/api/cables/${cableId}`);
message.success('删除成功'); message.success('删除成功');
@@ -192,7 +221,7 @@ function CableManagement() {
} }
}; };
const handleDeleteSwitch = async (switchId) => { const handleDeleteSwitch = async switchId => {
try { try {
await axios.delete(`/api/devices/${switchId}`); await axios.delete(`/api/devices/${switchId}`);
message.success('删除设备成功'); message.success('删除设备成功');
@@ -228,7 +257,7 @@ function CableManagement() {
sourceDeviceId: values.sourceDeviceId, sourceDeviceId: values.sourceDeviceId,
sourcePort: values.sourcePort, sourcePort: values.sourcePort,
targetDeviceId: values.targetDeviceId, targetDeviceId: values.targetDeviceId,
targetPort: values.targetPort targetPort: values.targetPort,
}); });
if (checkResponse.data.hasConflict) { if (checkResponse.data.hasConflict) {
@@ -247,10 +276,12 @@ function CableManagement() {
} catch (error) { } catch (error) {
if (error.response?.status === 409) { if (error.response?.status === 409) {
// 冲突错误 // 冲突错误
setConflictInfo([{ setConflictInfo([
type: 'unknown', {
existingCable: error.response.data.existingCable type: 'unknown',
}]); existingCable: error.response.data.existingCable,
},
]);
setPendingSubmitValues(values); setPendingSubmitValues(values);
setConflictModalVisible(true); setConflictModalVisible(true);
} else { } else {
@@ -269,7 +300,7 @@ function CableManagement() {
await axios.post('/api/cables', { await axios.post('/api/cables', {
...pendingSubmitValues, ...pendingSubmitValues,
force: true force: true,
}); });
message.success('接线已强制接管并创建成功'); message.success('接线已强制接管并创建成功');
@@ -291,12 +322,12 @@ function CableManagement() {
setImportProgress({ current: 0, total: 0 }); setImportProgress({ current: 0, total: 0 });
}; };
const handleFileUpload = (info) => { const handleFileUpload = info => {
const { file } = info; const { file } = info;
setImportFileList([file]); setImportFileList([file]);
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async (e) => { reader.onload = async e => {
try { try {
const data = e.target.result; const data = e.target.result;
let parsedData = []; let parsedData = [];
@@ -310,9 +341,9 @@ function CableManagement() {
Papa.parse(data, { Papa.parse(data, {
header: true, header: true,
skipEmptyLines: true, skipEmptyLines: true,
complete: (results) => { complete: results => {
parsedData = results.data; parsedData = results.data;
} },
}); });
} else { } else {
message.error('不支持的文件格式,请上传 .xlsx 或 .csv 文件'); message.error('不支持的文件格式,请上传 .xlsx 或 .csv 文件');
@@ -331,7 +362,7 @@ function CableManagement() {
reader.readAsBinaryString(file); reader.readAsBinaryString(file);
}; };
const validateImportData = async (data) => { const validateImportData = async data => {
const validatedData = []; const validatedData = [];
const errors = []; const errors = [];
@@ -399,15 +430,15 @@ function CableManagement() {
try { try {
const cableTypeMap = { const cableTypeMap = {
'网线': 'ethernet', 网线: 'ethernet',
'光纤': 'fiber', 光纤: 'fiber',
'铜缆': 'copper' 铜缆: 'copper',
}; };
const statusMap = { const statusMap = {
'正常': 'normal', 正常: 'normal',
'故障': 'fault', 故障: 'fault',
'未连接': 'disconnected' 未连接: 'disconnected',
}; };
const cablesData = importPreview.map((row, index) => ({ const cablesData = importPreview.map((row, index) => ({
@@ -419,7 +450,7 @@ function CableManagement() {
cableType: cableTypeMap[row['线缆类型']] || 'ethernet', cableType: cableTypeMap[row['线缆类型']] || 'ethernet',
cableLength: row['线缆长度(米)'], cableLength: row['线缆长度(米)'],
status: statusMap[row['状态']] || 'normal', status: statusMap[row['状态']] || 'normal',
description: row['描述'] description: row['描述'],
})); }));
const response = await axios.post('/api/cables/batch', { cables: cablesData }); const response = await axios.post('/api/cables/batch', { cables: cablesData });
@@ -449,15 +480,15 @@ function CableManagement() {
const handleDownloadTemplate = () => { const handleDownloadTemplate = () => {
const templateData = [ const templateData = [
{ {
'源设备ID': 'DEV001', 源设备ID: 'DEV001',
'源设备端口': 'eth0/1', 源设备端口: 'eth0/1',
'目标设备ID': 'DEV002', 目标设备ID: 'DEV002',
'目标设备端口': 'eth0', 目标设备端口: 'eth0',
'线缆类型': '网线', 线缆类型: '网线',
'线缆长度(米)': '5', '线缆长度(米)': '5',
'状态': '正常', 状态: '正常',
'描述': '示例接线' 描述: '示例接线',
} },
]; ];
const worksheet = XLSX.utils.json_to_sheet(templateData); const worksheet = XLSX.utils.json_to_sheet(templateData);
@@ -466,21 +497,21 @@ function CableManagement() {
XLSX.writeFile(workbook, '接线导入模板.xlsx'); XLSX.writeFile(workbook, '接线导入模板.xlsx');
}; };
const getStatusTag = (status) => { const getStatusTag = status => {
const statusMap = { const statusMap = {
normal: { color: 'success', text: '正常' }, normal: { color: 'success', text: '正常' },
fault: { color: 'error', text: '故障' }, fault: { color: 'error', text: '故障' },
disconnected: { color: 'default', text: '未连接' } disconnected: { color: 'default', text: '未连接' },
}; };
const config = statusMap[status] || { color: 'default', text: status }; const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>; return <Tag color={config.color}>{config.text}</Tag>;
}; };
const getCableTypeTag = (type) => { const getCableTypeTag = type => {
const typeMap = { const typeMap = {
'网线': { color: 'blue', text: '网线' }, 网线: { color: 'blue', text: '网线' },
'光纤': { color: 'green', text: '光纤' }, 光纤: { color: 'green', text: '光纤' },
'铜缆': { color: 'orange', text: '铜缆' } 铜缆: { color: 'orange', text: '铜缆' },
}; };
const config = typeMap[type] || { color: 'default', text: type }; const config = typeMap[type] || { color: 'default', text: type };
return <Tag color={config.color}>{config.text}</Tag>; return <Tag color={config.color}>{config.text}</Tag>;
@@ -494,7 +525,7 @@ function CableManagement() {
return { return {
status: cable.status, status: cable.status,
text: cable.status === 'normal' ? '已连接' : cable.status === 'fault' ? '故障' : '未连接', 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: '端口名称', title: '端口名称',
dataIndex: 'portName', dataIndex: 'portName',
key: 'portName', key: 'portName',
width: 120 width: 120,
}, },
{ {
title: '端口类型', title: '端口类型',
dataIndex: 'portType', dataIndex: 'portType',
key: 'portType', key: 'portType',
width: 100, width: 100,
render: (type) => { render: type => {
const typeMap = { const typeMap = {
'RJ45': { color: 'blue', text: 'RJ45' }, RJ45: { color: 'blue', text: 'RJ45' },
'SFP': { color: 'green', text: 'SFP' }, SFP: { color: 'green', text: 'SFP' },
'SFP+': { color: 'cyan', text: 'SFP+' }, 'SFP+': { color: 'cyan', text: 'SFP+' },
'SFP28': { color: 'purple', text: 'SFP28' }, SFP28: { color: 'purple', text: 'SFP28' },
'QSFP': { color: 'orange', text: 'QSFP' }, QSFP: { color: 'orange', text: 'QSFP' },
'QSFP28': { color: 'red', text: 'QSFP28' } QSFP28: { color: 'red', text: 'QSFP28' },
}; };
const config = typeMap[type] || { color: 'default', text: type }; const config = typeMap[type] || { color: 'default', text: type };
return <Tag color={config.color}>{config.text}</Tag>; return <Tag color={config.color}>{config.text}</Tag>;
} },
}, },
{ {
title: '端口速率', title: '端口速率',
dataIndex: 'portSpeed', dataIndex: 'portSpeed',
key: 'portSpeed', key: 'portSpeed',
width: 100 width: 100,
}, },
{ {
title: '连接状态', title: '连接状态',
@@ -537,7 +568,7 @@ function CableManagement() {
render: (_, record) => { render: (_, record) => {
const status = getPortConnectionStatus(record.portName, record.switchData); const status = getPortConnectionStatus(record.portName, record.switchData);
return <Tag color={status.color}>{status.text}</Tag>; return <Tag color={status.color}>{status.text}</Tag>;
} },
}, },
{ {
title: '目标设备', title: '目标设备',
@@ -553,7 +584,7 @@ function CableManagement() {
<div style={{ fontSize: 12, color: '#999' }}>{cable.targetPort}</div> <div style={{ fontSize: 12, color: '#999' }}>{cable.targetPort}</div>
</div> </div>
); );
} },
}, },
{ {
title: '线缆类型', title: '线缆类型',
@@ -564,7 +595,7 @@ function CableManagement() {
const cable = record.switchData.cables.find(c => c.sourcePort === record.portName); const cable = record.switchData.cables.find(c => c.sourcePort === record.portName);
if (!cable) return '-'; if (!cable) return '-';
return getCableTypeTag(cable.cableType); return getCableTypeTag(cable.cableType);
} },
}, },
{ {
title: '长度(米)', title: '长度(米)',
@@ -575,7 +606,7 @@ function CableManagement() {
const cable = record.switchData.cables.find(c => c.sourcePort === record.portName); const cable = record.switchData.cables.find(c => c.sourcePort === record.portName);
if (!cable) return '-'; if (!cable) return '-';
return cable.cableLength ? `${cable.cableLength}m` : '-'; return cable.cableLength ? `${cable.cableLength}m` : '-';
} },
}, },
{ {
title: '操作', title: '操作',
@@ -602,12 +633,7 @@ function CableManagement() {
okText="确定" okText="确定"
cancelText="取消" cancelText="取消"
> >
<Button <Button type="link" size="small" danger icon={<DeleteOutlined />}>
type="link"
size="small"
danger
icon={<DeleteOutlined />}
>
删除 删除
</Button> </Button>
</Popconfirm> </Popconfirm>
@@ -615,8 +641,8 @@ function CableManagement() {
)} )}
</Space> </Space>
); );
} },
} },
]; ];
return ( return (
@@ -625,7 +651,7 @@ function CableManagement() {
style={{ style={{
borderRadius: designTokens.borderRadius.large, borderRadius: designTokens.borderRadius.large,
boxShadow: designTokens.shadows.medium, boxShadow: designTokens.shadows.medium,
marginBottom: 16 marginBottom: 16,
}} }}
> >
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
@@ -634,7 +660,7 @@ function CableManagement() {
placeholder="选择交换机" placeholder="选择交换机"
style={{ width: 200 }} style={{ width: 200 }}
value={filters.switchDeviceId || undefined} value={filters.switchDeviceId || undefined}
onChange={(value) => setFilters(prev => ({ ...prev, switchDeviceId: value }))} onChange={value => setFilters(prev => ({ ...prev, switchDeviceId: value }))}
allowClear allowClear
showSearch showSearch
filterOption={(input, option) => { filterOption={(input, option) => {
@@ -655,7 +681,7 @@ function CableManagement() {
placeholder="线缆类型" placeholder="线缆类型"
style={{ width: 120 }} style={{ width: 120 }}
value={filters.cableType} value={filters.cableType}
onChange={(value) => setFilters(prev => ({ ...prev, cableType: value }))} onChange={value => setFilters(prev => ({ ...prev, cableType: value }))}
> >
<Option value="all">全部</Option> <Option value="all">全部</Option>
<Option value="ethernet">网线</Option> <Option value="ethernet">网线</Option>
@@ -667,7 +693,7 @@ function CableManagement() {
placeholder="连接状态" placeholder="连接状态"
style={{ width: 120 }} style={{ width: 120 }}
value={filters.status} value={filters.status}
onChange={(value) => setFilters(prev => ({ ...prev, status: value }))} onChange={value => setFilters(prev => ({ ...prev, status: value }))}
> >
<Option value="all">全部</Option> <Option value="all">全部</Option>
<Option value="normal">已连接</Option> <Option value="normal">已连接</Option>
@@ -710,9 +736,7 @@ function CableManagement() {
批量导入 批量导入
</Button> </Button>
<Button icon={<ExportOutlined />}> <Button icon={<ExportOutlined />}>导出</Button>
导出
</Button>
</Space> </Space>
</div> </div>
@@ -733,26 +757,37 @@ function CableManagement() {
const switchDevice = switchData.switch; const switchDevice = switchData.switch;
const switchPorts = devicePorts[switchId] || []; const switchPorts = devicePorts[switchId] || [];
const connectedCount = switchData.cables.filter(c => c.status === 'normal').length; 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; const faultCount = switchData.cables.filter(c => c.status === 'fault').length;
return ( return (
<Panel <Panel
key={switchId} key={switchId}
header={ 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={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ <div
width: '40px', style={{
height: '40px', width: '40px',
borderRadius: designTokens.borderRadius.medium, height: '40px',
background: designTokens.colors.primary.gradient, borderRadius: designTokens.borderRadius.medium,
display: 'flex', background: designTokens.colors.primary.gradient,
alignItems: 'center', display: 'flex',
justifyContent: 'center', alignItems: 'center',
color: '#fff', justifyContent: 'center',
fontSize: '18px' color: '#fff',
}}> fontSize: '18px',
}}
>
🔀 🔀
</div> </div>
<div> <div>
@@ -792,12 +827,7 @@ function CableManagement() {
okText="确定" okText="确定"
cancelText="取消" cancelText="取消"
> >
<Button <Button type="link" size="small" danger icon={<DeleteOutlined />}>
type="link"
size="small"
danger
icon={<DeleteOutlined />}
>
删除设备 删除设备
</Button> </Button>
</Popconfirm> </Popconfirm>
@@ -808,7 +838,7 @@ function CableManagement() {
columns={portColumns} columns={portColumns}
dataSource={switchPorts.map(port => ({ dataSource={switchPorts.map(port => ({
...port, ...port,
switchData: switchData switchData: switchData,
}))} }))}
rowKey="portId" rowKey="portId"
pagination={false} pagination={false}
@@ -849,7 +879,7 @@ function CableManagement() {
const searchText = `${device.name} ${device.deviceId}`.toLowerCase(); const searchText = `${device.name} ${device.deviceId}`.toLowerCase();
return searchText.indexOf(input.toLowerCase()) >= 0; return searchText.indexOf(input.toLowerCase()) >= 0;
}} }}
onChange={(value) => { onChange={value => {
fetchDevicePorts(value); fetchDevicePorts(value);
form.setFieldsValue({ sourcePort: undefined }); form.setFieldsValue({ sourcePort: undefined });
}} }}
@@ -874,7 +904,8 @@ function CableManagement() {
const ports = devicePorts[form.getFieldValue('sourceDeviceId')] || []; const ports = devicePorts[form.getFieldValue('sourceDeviceId')] || [];
const port = ports.find(p => p.portName === option.value); const port = ports.find(p => p.portName === option.value);
if (!port) return false; 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; return searchText.indexOf(input.toLowerCase()) >= 0;
}} }}
disabled={!form.getFieldValue('sourceDeviceId')} disabled={!form.getFieldValue('sourceDeviceId')}
@@ -901,7 +932,7 @@ function CableManagement() {
const searchText = `${device.name} ${device.deviceId}`.toLowerCase(); const searchText = `${device.name} ${device.deviceId}`.toLowerCase();
return searchText.indexOf(input.toLowerCase()) >= 0; return searchText.indexOf(input.toLowerCase()) >= 0;
}} }}
onChange={(value) => { onChange={value => {
fetchDevicePorts(value); fetchDevicePorts(value);
form.setFieldsValue({ targetPort: undefined }); form.setFieldsValue({ targetPort: undefined });
}} }}
@@ -926,7 +957,8 @@ function CableManagement() {
const ports = devicePorts[form.getFieldValue('targetDeviceId')] || []; const ports = devicePorts[form.getFieldValue('targetDeviceId')] || [];
const port = ports.find(p => p.portName === option.value); const port = ports.find(p => p.portName === option.value);
if (!port) return false; 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; return searchText.indexOf(input.toLowerCase()) >= 0;
}} }}
disabled={!form.getFieldValue('targetDeviceId')} disabled={!form.getFieldValue('targetDeviceId')}
@@ -952,10 +984,7 @@ function CableManagement() {
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item <Form.Item name="cableLength" label="线缆长度(米)">
name="cableLength"
label="线缆长度(米)"
>
<Input type="number" placeholder="请输入线缆长度" /> <Input type="number" placeholder="请输入线缆长度" />
</Form.Item> </Form.Item>
@@ -972,10 +1001,7 @@ function CableManagement() {
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item <Form.Item name="description" label="描述">
name="description"
label="描述"
>
<Input.TextArea rows={3} placeholder="请输入描述" /> <Input.TextArea rows={3} placeholder="请输入描述" />
</Form.Item> </Form.Item>
</Form> </Form>
@@ -994,11 +1020,7 @@ function CableManagement() {
<Button key="cancel" onClick={() => setImportModalVisible(false)}> <Button key="cancel" onClick={() => setImportModalVisible(false)}>
取消 取消
</Button>, </Button>,
<Button <Button key="download" icon={<DownloadOutlined />} onClick={handleDownloadTemplate}>
key="download"
icon={<DownloadOutlined />}
onClick={handleDownloadTemplate}
>
下载模板 下载模板
</Button>, </Button>,
<Button <Button
@@ -1011,7 +1033,7 @@ function CableManagement() {
style={{ background: designTokens.colors.primary.gradient, border: 'none' }} style={{ background: designTokens.colors.primary.gradient, border: 'none' }}
> >
开始导入 开始导入
</Button> </Button>,
]} ]}
> >
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
@@ -1033,10 +1055,10 @@ function CableManagement() {
</div> </div>
<div style={{ display: 'flex', gap: '12px', marginBottom: 16 }}> <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>
<Checkbox checked={updateExisting} onChange={(e) => setUpdateExisting(e.target.checked)}> <Checkbox checked={updateExisting} onChange={e => setUpdateExisting(e.target.checked)}>
更新已存在的接线 更新已存在的接线
</Checkbox> </Checkbox>
</div> </div>
@@ -1044,13 +1066,16 @@ function CableManagement() {
{importPreview.length > 0 && ( {importPreview.length > 0 && (
<> <>
<div style={{ marginBottom: 16 }}> <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> <Text strong>数据预览前10条</Text>
<Button <Button size="small" icon={<DownloadOutlined />} onClick={handleDownloadTemplate}>
size="small"
icon={<DownloadOutlined />}
onClick={handleDownloadTemplate}
>
下载模板 下载模板
</Button> </Button>
</div> </div>
@@ -1060,46 +1085,46 @@ function CableManagement() {
title: '源设备ID', title: '源设备ID',
dataIndex: '源设备ID', dataIndex: '源设备ID',
key: 'sourceDeviceId', key: 'sourceDeviceId',
width: 150 width: 150,
}, },
{ {
title: '源设备端口', title: '源设备端口',
dataIndex: '源设备端口', dataIndex: '源设备端口',
key: 'sourcePort', key: 'sourcePort',
width: 120 width: 120,
}, },
{ {
title: '目标设备ID', title: '目标设备ID',
dataIndex: '目标设备ID', dataIndex: '目标设备ID',
key: 'targetDeviceId', key: 'targetDeviceId',
width: 150 width: 150,
}, },
{ {
title: '目标设备端口', title: '目标设备端口',
dataIndex: '目标设备端口', dataIndex: '目标设备端口',
key: 'targetPort', key: 'targetPort',
width: 120 width: 120,
}, },
{ {
title: '线缆类型', title: '线缆类型',
dataIndex: '线缆类型', dataIndex: '线缆类型',
key: 'cableType', key: 'cableType',
width: 100, width: 100,
render: (type) => getCableTypeTag(type) render: type => getCableTypeTag(type),
}, },
{ {
title: '状态', title: '状态',
dataIndex: '状态', dataIndex: '状态',
key: 'status', key: 'status',
width: 100, width: 100,
render: (status) => getStatusTag(status) render: status => getStatusTag(status),
}, },
{ {
title: '描述', title: '描述',
dataIndex: '描述', dataIndex: '描述',
key: 'description', key: 'description',
ellipsis: true ellipsis: true,
} },
]} ]}
dataSource={importPreview.slice(0, 10)} dataSource={importPreview.slice(0, 10)}
rowKey={(record, index) => index} rowKey={(record, index) => index}
@@ -1126,7 +1151,7 @@ function CableManagement() {
status="active" status="active"
strokeColor={{ strokeColor={{
'0%': designTokens.colors.primary.main, '0%': designTokens.colors.primary.main,
'100%': designTokens.colors.success.main '100%': designTokens.colors.success.main,
}} }}
/> />
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
@@ -1135,7 +1160,8 @@ function CableManagement() {
</Text> </Text>
{importProgress.current > 0 && ( {importProgress.current > 0 && (
<Text type="secondary"> <Text type="secondary">
预计剩余时间{Math.ceil((importProgress.total - importProgress.current) / 5)} 预计剩余时间{Math.ceil((importProgress.total - importProgress.current) / 5)}{' '}
</Text> </Text>
)} )}
</div> </div>
@@ -1165,14 +1191,9 @@ function CableManagement() {
> >
取消 取消
</Button>, </Button>,
<Button <Button key="force" type="primary" danger onClick={handleForceSubmit}>
key="force"
type="primary"
danger
onClick={handleForceSubmit}
>
强制接管 强制接管
</Button> </Button>,
]} ]}
width={600} width={600}
> >
@@ -1190,7 +1211,11 @@ function CableManagement() {
> >
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<Tag color="error"> <Tag color="error">
{conflict.type === 'source' ? '源端口' : conflict.type === 'target' ? '目标端口' : '端口'} {conflict.type === 'source'
? '源端口'
: conflict.type === 'target'
? '目标端口'
: '端口'}
</Tag> </Tag>
<span style={{ fontWeight: 500, marginLeft: 8 }}>{conflict.port}</span> <span style={{ fontWeight: 500, marginLeft: 8 }}>{conflict.port}</span>
</div> </div>
@@ -1199,11 +1224,15 @@ function CableManagement() {
<div>当前连接</div> <div>当前连接</div>
<div style={{ marginTop: 4, paddingLeft: 12 }}> <div style={{ marginTop: 4, paddingLeft: 12 }}>
<div> <div>
源设备{conflict.existingCable.sourceDevice?.name || conflict.existingCable.sourceDeviceId} 源设备
{conflict.existingCable.sourceDevice?.name ||
conflict.existingCable.sourceDeviceId}
({conflict.existingCable.sourcePort}) ({conflict.existingCable.sourcePort})
</div> </div>
<div style={{ marginTop: 2 }}> <div style={{ marginTop: 2 }}>
目标设备{conflict.existingCable.targetDevice?.name || conflict.existingCable.targetDeviceId} 目标设备
{conflict.existingCable.targetDevice?.name ||
conflict.existingCable.targetDeviceId}
({conflict.existingCable.targetPort}) ({conflict.existingCable.targetPort})
</div> </div>
<div style={{ marginTop: 2 }}> <div style={{ marginTop: 2 }}>
@@ -1214,7 +1243,15 @@ function CableManagement() {
)} )}
</Card> </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={{ color: '#ea580c' }}>💡</span>
<span style={{ marginLeft: 8, color: '#9a3412' }}> <span style={{ marginLeft: 8, color: '#9a3412' }}>
点击"强制接管"将断开原有连接并创建新接线此操作不可恢复 点击"强制接管"将断开原有连接并创建新接线此操作不可恢复
+62 -24
View File
@@ -1,5 +1,18 @@
import React, { useState, useEffect } from 'react'; 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 { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons';
import axios from 'axios'; import axios from 'axios';
@@ -15,7 +28,7 @@ function CategoryManagement() {
current: 1, current: 1,
pageSize: 10, pageSize: 10,
total: 0, total: 0,
showTotal: (total) => `${total} 条记录` showTotal: total => `${total} 条记录`,
}); });
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all'); const [status, setStatus] = useState('all');
@@ -24,7 +37,7 @@ function CategoryManagement() {
try { try {
setLoading(true); setLoading(true);
const response = await axios.get('/api/consumable-categories', { const response = await axios.get('/api/consumable-categories', {
params: { page, pageSize, keyword, status } params: { page, pageSize, keyword, status },
}); });
setCategories(response.data.categories); setCategories(response.data.categories);
setPagination(prev => ({ ...prev, current: page, pageSize, total: response.data.total })); setPagination(prev => ({ ...prev, current: page, pageSize, total: response.data.total }));
@@ -56,7 +69,7 @@ function CategoryManagement() {
setEditingCategory(null); setEditingCategory(null);
}; };
const handleSubmit = async (values) => { const handleSubmit = async values => {
try { try {
if (editingCategory) { if (editingCategory) {
await axios.put(`/api/consumable-categories/${editingCategory.id}`, values); await axios.put(`/api/consumable-categories/${editingCategory.id}`, values);
@@ -69,12 +82,14 @@ function CategoryManagement() {
fetchCategories(); fetchCategories();
setEditingCategory(null); setEditingCategory(null);
} catch (error) { } catch (error) {
message.error(error.response?.data?.error || (editingCategory ? '分类更新失败' : '分类创建失败')); message.error(
error.response?.data?.error || (editingCategory ? '分类更新失败' : '分类创建失败')
);
console.error('提交失败:', error); console.error('提交失败:', error);
} }
}; };
const handleDelete = async (id) => { const handleDelete = async id => {
try { try {
await axios.delete(`/api/consumable-categories/${id}`); await axios.delete(`/api/consumable-categories/${id}`);
message.success('删除成功'); message.success('删除成功');
@@ -90,44 +105,44 @@ function CategoryManagement() {
title: 'ID', title: 'ID',
dataIndex: 'id', dataIndex: 'id',
key: 'id', key: 'id',
width: 80 width: 80,
}, },
{ {
title: '分类名称', title: '分类名称',
dataIndex: 'name', dataIndex: 'name',
key: 'name', key: 'name',
width: 150 width: 150,
}, },
{ {
title: '描述', title: '描述',
dataIndex: 'description', dataIndex: 'description',
key: 'description', key: 'description',
width: 200, width: 200,
render: (value) => value || '-' render: value => value || '-',
}, },
{ {
title: '排序', title: '排序',
dataIndex: 'sortOrder', dataIndex: 'sortOrder',
key: 'sortOrder', key: 'sortOrder',
width: 80 width: 80,
}, },
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
key: 'status', key: 'status',
width: 100, width: 100,
render: (value) => ( render: value => (
<Tag color={value === 'active' ? 'green' : 'red'}> <Tag color={value === 'active' ? 'green' : 'red'}>
{value === 'active' ? '启用' : '停用'} {value === 'active' ? '启用' : '停用'}
</Tag> </Tag>
) ),
}, },
{ {
title: '创建时间', title: '创建时间',
dataIndex: 'createdAt', dataIndex: 'createdAt',
key: 'createdAt', key: 'createdAt',
width: 180, width: 180,
render: (value) => value ? new Date(value).toLocaleString('zh-CN') : '-' render: value => (value ? new Date(value).toLocaleString('zh-CN') : '-'),
}, },
{ {
title: '操作', title: '操作',
@@ -135,26 +150,40 @@ function CategoryManagement() {
width: 150, width: 150,
render: (_, record) => ( render: (_, record) => (
<Space> <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)}> <Popconfirm title="确定删除?" onConfirm={() => handleDelete(record.id)}>
<Button danger icon={<DeleteOutlined />} size="small">删除</Button> <Button danger icon={<DeleteOutlined />} size="small">
删除
</Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
) ),
} },
]; ];
return ( return (
<div> <div>
<Card title="耗材分类管理" extra={ <Card
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>添加分类</Button> title="耗材分类管理"
}> extra={
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>
添加分类
</Button>
}
>
<Card size="small" style={{ marginBottom: 16 }}> <Card size="small" style={{ marginBottom: 16 }}>
<Space> <Space>
<Input.Search <Input.Search
placeholder="搜索分类名称、描述" placeholder="搜索分类名称、描述"
style={{ width: 300 }} style={{ width: 300 }}
onSearch={(value) => setKeyword(value)} onSearch={value => setKeyword(value)}
allowClear allowClear
/> />
<Select value={status} onChange={setStatus} style={{ width: 120 }}> <Select value={status} onChange={setStatus} style={{ width: 120 }}>
@@ -172,7 +201,7 @@ function CategoryManagement() {
rowKey="id" rowKey="id"
loading={loading} loading={loading}
pagination={pagination} pagination={pagination}
onChange={(pagination) => fetchCategories(pagination.current, pagination.pageSize)} onChange={pagination => fetchCategories(pagination.current, pagination.pageSize)}
scroll={{ x: 1000 }} scroll={{ x: 1000 }}
/> />
</Card> </Card>
@@ -185,7 +214,14 @@ function CategoryManagement() {
width={500} width={500}
> >
<Form form={form} layout="vertical" onFinish={handleSubmit}> <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="请输入分类名称" /> <Input placeholder="请输入分类名称" />
</Form.Item> </Form.Item>
<Form.Item name="description" label="描述"> <Form.Item name="description" label="描述">
@@ -202,7 +238,9 @@ function CategoryManagement() {
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Space> <Space>
<Button type="primary" htmlType="submit">{editingCategory ? '更新' : '创建'}</Button> <Button type="primary" htmlType="submit">
{editingCategory ? '更新' : '创建'}
</Button>
<Button onClick={handleCancel}>取消</Button> <Button onClick={handleCancel}>取消</Button>
</Space> </Space>
</Form.Item> </Form.Item>
+134 -99
View File
@@ -1,6 +1,34 @@
import React, { useState, useEffect, useRef } from 'react'; 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 {
import { HistoryOutlined, SearchOutlined, FileTextOutlined, DownloadOutlined, UploadOutlined, FileExcelOutlined, FileOutlined, DownOutlined, EditOutlined, EyeOutlined } from '@ant-design/icons'; 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 axios from 'axios';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
@@ -15,7 +43,7 @@ function ConsumableLogs() {
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
operationType: 'all', operationType: 'all',
consumableId: '', consumableId: '',
dateRange: null dateRange: null,
}); });
const [importModalVisible, setImportModalVisible] = useState(false); const [importModalVisible, setImportModalVisible] = useState(false);
const [importType, setImportType] = useState('excel'); const [importType, setImportType] = useState('excel');
@@ -65,7 +93,7 @@ function ConsumableLogs() {
fetchLogs(1, pagination.pageSize); fetchLogs(1, pagination.pageSize);
}; };
const getOperationTag = (type) => { const getOperationTag = type => {
const config = { const config = {
in: { color: 'green', text: '入库' }, in: { color: 'green', text: '入库' },
out: { color: 'red', text: '出库' }, out: { color: 'red', text: '出库' },
@@ -73,7 +101,7 @@ function ConsumableLogs() {
update: { color: 'orange', text: '更新' }, update: { color: 'orange', text: '更新' },
delete: { color: 'magenta', text: '删除' }, delete: { color: 'magenta', text: '删除' },
adjust: { color: 'purple', text: '调整' }, adjust: { color: 'purple', text: '调整' },
import: { color: 'cyan', text: '导入' } import: { color: 'cyan', text: '导入' },
}; };
const { color, text } = config[type] || { color: 'default', text: type }; const { color, text } = config[type] || { color: 'default', text: type };
return <Tag color={color}>{text}</Tag>; return <Tag color={color}>{text}</Tag>;
@@ -86,27 +114,27 @@ function ConsumableLogs() {
key: 'createdAt', key: 'createdAt',
width: 180, width: 180,
sorter: (a, b) => new Date(a.createdAt) - new Date(b.createdAt), 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', title: '耗材ID',
dataIndex: 'consumableId', dataIndex: 'consumableId',
key: 'consumableId', key: 'consumableId',
width: 150, width: 150,
render: (value) => <code>{value}</code> render: value => <code>{value}</code>,
}, },
{ {
title: '耗材名称', title: '耗材名称',
dataIndex: 'consumableName', dataIndex: 'consumableName',
key: 'consumableName', key: 'consumableName',
width: 150 width: 150,
}, },
{ {
title: '操作类型', title: '操作类型',
dataIndex: 'operationType', dataIndex: 'operationType',
key: 'operationType', key: 'operationType',
width: 100, width: 100,
render: (type) => getOperationTag(type) render: type => getOperationTag(type),
}, },
{ {
title: '变动数量', title: '变动数量',
@@ -114,46 +142,49 @@ function ConsumableLogs() {
key: 'quantity', key: 'quantity',
width: 100, width: 100,
render: (value, record) => ( render: (value, record) => (
<span style={{ <span
color: value > 0 ? '#52c41a' : value < 0 ? '#ff4d4f' : '#888', style={{
fontWeight: 'bold' color: value > 0 ? '#52c41a' : value < 0 ? '#ff4d4f' : '#888',
}}> fontWeight: 'bold',
{value > 0 ? '+' : ''}{value} }}
>
{value > 0 ? '+' : ''}
{value}
</span> </span>
) ),
}, },
{ {
title: '操作前库存', title: '操作前库存',
dataIndex: 'previousStock', dataIndex: 'previousStock',
key: 'previousStock', key: 'previousStock',
width: 100 width: 100,
}, },
{ {
title: '操作后库存', title: '操作后库存',
dataIndex: 'currentStock', dataIndex: 'currentStock',
key: 'currentStock', key: 'currentStock',
width: 100 width: 100,
}, },
{ {
title: '操作人', title: '操作人',
dataIndex: 'operator', dataIndex: 'operator',
key: 'operator', key: 'operator',
width: 120 width: 120,
}, },
{ {
title: '原因', title: '原因',
dataIndex: 'reason', dataIndex: 'reason',
key: 'reason', key: 'reason',
width: 150, width: 150,
render: (value) => value || '-' render: value => value || '-',
}, },
{ {
title: '备注', title: '备注',
dataIndex: 'notes', dataIndex: 'notes',
key: 'notes', key: 'notes',
width: 200, width: 200,
render: (value) => value || '-', render: value => value || '-',
ellipsis: true ellipsis: true,
}, },
{ {
title: '操作', title: '操作',
@@ -181,8 +212,8 @@ function ConsumableLogs() {
/> />
</Tooltip> </Tooltip>
</Space> </Space>
) ),
} },
]; ];
const handleExport = async (currentFilters = filters) => { const handleExport = async (currentFilters = filters) => {
@@ -201,7 +232,7 @@ function ConsumableLogs() {
const response = await axios.get('/api/consumables/logs/export', { const response = await axios.get('/api/consumables/logs/export', {
params, params,
responseType: 'blob' responseType: 'blob',
}); });
const blob = new Blob([response.data], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob([response.data], { type: 'text/csv;charset=utf-8;' });
@@ -232,20 +263,20 @@ function ConsumableLogs() {
} }
const response = await axios.get('/api/consumables/logs', { const response = await axios.get('/api/consumables/logs', {
params: { ...params, page: 1, pageSize: 10000 } params: { ...params, page: 1, pageSize: 10000 },
}); });
const exportData = response.data.logs.map(log => ({ const exportData = response.data.logs.map(log => ({
'时间': dayjs(log.createdAt).format('YYYY-MM-DD HH:mm:ss'), 时间: dayjs(log.createdAt).format('YYYY-MM-DD HH:mm:ss'),
'耗材ID': log.consumableId, 耗材ID: log.consumableId,
'耗材名称': log.consumableName, 耗材名称: log.consumableName,
'操作类型': getOperationTypeText(log.operationType), 操作类型: getOperationTypeText(log.operationType),
'变动数量': log.quantity, 变动数量: log.quantity,
'操作前库存': log.previousStock, 操作前库存: log.previousStock,
'操作后库存': log.currentStock, 操作后库存: log.currentStock,
'操作人': log.operator, 操作人: log.operator,
'原因': log.reason || '', 原因: log.reason || '',
'备注': log.notes || '' 备注: log.notes || '',
})); }));
const ws = XLSX.utils.json_to_sheet(exportData); const ws = XLSX.utils.json_to_sheet(exportData);
@@ -260,24 +291,24 @@ function ConsumableLogs() {
} }
}; };
const getOperationTypeText = (type) => { const getOperationTypeText = type => {
const map = { const map = {
'in': '入库', in: '入库',
'out': '出库', out: '出库',
'create': '创建', create: '创建',
'update': '更新', update: '更新',
'delete': '删除', delete: '删除',
'adjust': '调整', adjust: '调整',
'import': '导入' import: '导入',
}; };
return map[type] || type; return map[type] || type;
}; };
const handleImport = async (file) => { const handleImport = async file => {
setImporting(true); setImporting(true);
try { try {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async (e) => { reader.onload = async e => {
try { try {
let logItems = []; let logItems = [];
@@ -303,7 +334,7 @@ function ConsumableLogs() {
const response = await axios.post('/api/consumables/logs/import', { const response = await axios.post('/api/consumables/logs/import', {
logs: logItems, logs: logItems,
operator: '前端导入' operator: '前端导入',
}); });
if (response.data.success > 0) { if (response.data.success > 0) {
@@ -338,16 +369,16 @@ function ConsumableLogs() {
const downloadTemplate = () => { const downloadTemplate = () => {
const template = [ const template = [
{ {
'耗材ID': 'CON123456', 耗材ID: 'CON123456',
'耗材名称': '示例耗材', 耗材名称: '示例耗材',
'操作类型': '入库', 操作类型: '入库',
'变动数量': 10, 变动数量: 10,
'操作前库存': 100, 操作前库存: 100,
'操作后库存': 110, 操作后库存: 110,
'操作人': '管理员', 操作人: '管理员',
'原因': '示例原因', 原因: '示例原因',
'备注': '示例备注' 备注: '示例备注',
} },
]; ];
const ws = XLSX.utils.json_to_sheet(template); const ws = XLSX.utils.json_to_sheet(template);
@@ -359,18 +390,18 @@ function ConsumableLogs() {
}; };
// 编辑日志 // 编辑日志
const handleEdit = (record) => { const handleEdit = record => {
setCurrentLog(record); setCurrentLog(record);
form.setFieldsValue({ form.setFieldsValue({
reason: record.reason, reason: record.reason,
notes: record.notes, notes: record.notes,
modificationReason: '' modificationReason: '',
}); });
setEditModalVisible(true); setEditModalVisible(true);
}; };
// 提交编辑 // 提交编辑
const handleEditSubmit = async (values) => { const handleEditSubmit = async values => {
if (!currentLog) return; if (!currentLog) return;
setEditLoading(true); setEditLoading(true);
@@ -379,7 +410,7 @@ function ConsumableLogs() {
reason: values.reason, reason: values.reason,
notes: values.notes, notes: values.notes,
operator: values.operator || '管理员', operator: values.operator || '管理员',
modificationReason: values.modificationReason modificationReason: values.modificationReason,
}); });
message.success('日志修改成功'); message.success('日志修改成功');
@@ -393,7 +424,7 @@ function ConsumableLogs() {
}; };
// 查看修改历史 // 查看修改历史
const handleViewHistory = async (record) => { const handleViewHistory = async record => {
setCurrentLog(record); setCurrentLog(record);
setHistoryModalVisible(true); setHistoryModalVisible(true);
setHistoryLoading(true); setHistoryLoading(true);
@@ -425,12 +456,12 @@ function ConsumableLogs() {
placeholder="搜索耗材ID" placeholder="搜索耗材ID"
style={{ width: 200 }} style={{ width: 200 }}
allowClear allowClear
onSearch={(value) => handleFilterChange('consumableId', value)} onSearch={value => handleFilterChange('consumableId', value)}
prefix={<SearchOutlined />} prefix={<SearchOutlined />}
/> />
<Select <Select
value={filters.operationType} value={filters.operationType}
onChange={(value) => handleFilterChange('operationType', value)} onChange={value => handleFilterChange('operationType', value)}
style={{ width: 120 }} style={{ width: 120 }}
> >
<Option value="all">全部类型</Option> <Option value="all">全部类型</Option>
@@ -444,7 +475,7 @@ function ConsumableLogs() {
</Select> </Select>
<RangePicker <RangePicker
value={filters.dateRange} value={filters.dateRange}
onChange={(dates) => handleFilterChange('dateRange', dates)} onChange={dates => handleFilterChange('dateRange', dates)}
placeholder={['开始日期', '结束日期']} placeholder={['开始日期', '结束日期']}
/> />
<Button <Button
@@ -463,15 +494,15 @@ function ConsumableLogs() {
key: 'csv', key: 'csv',
icon: <FileOutlined />, icon: <FileOutlined />,
label: '导出CSV', label: '导出CSV',
onClick: () => handleExport(filters) onClick: () => handleExport(filters),
}, },
{ {
key: 'excel', key: 'excel',
icon: <FileExcelOutlined />, icon: <FileExcelOutlined />,
label: '导出Excel', label: '导出Excel',
onClick: () => handleExportExcel(filters) onClick: () => handleExportExcel(filters),
} },
] ],
}} }}
> >
<Button icon={<DownloadOutlined />}> <Button icon={<DownloadOutlined />}>
@@ -491,11 +522,11 @@ function ConsumableLogs() {
loading={loading} loading={loading}
pagination={{ pagination={{
...pagination, ...pagination,
showTotal: (total) => `${total} 条记录`, showTotal: total => `${total} 条记录`,
showSizeChanger: true, showSizeChanger: true,
showQuickJumper: true showQuickJumper: true,
}} }}
onChange={(pagination) => fetchLogs(pagination.current, pagination.pageSize)} onChange={pagination => fetchLogs(pagination.current, pagination.pageSize)}
scroll={{ x: 1500 }} scroll={{ x: 1500 }}
/> />
</Card> </Card>
@@ -513,7 +544,7 @@ function ConsumableLogs() {
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<Radio.Group <Radio.Group
value={importType} value={importType}
onChange={(e) => setImportType(e.target.value)} onChange={e => setImportType(e.target.value)}
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
> >
<Radio.Button value="excel">Excel文件</Radio.Button> <Radio.Button value="excel">Excel文件</Radio.Button>
@@ -561,21 +592,11 @@ function ConsumableLogs() {
confirmLoading={editLoading} confirmLoading={editLoading}
width={600} width={600}
> >
<Form <Form form={form} layout="vertical" onFinish={handleEditSubmit}>
form={form} <Form.Item label="操作原因" name="reason">
layout="vertical"
onFinish={handleEditSubmit}
>
<Form.Item
label="操作原因"
name="reason"
>
<Input.TextArea rows={2} placeholder="请输入操作原因" /> <Input.TextArea rows={2} placeholder="请输入操作原因" />
</Form.Item> </Form.Item>
<Form.Item <Form.Item label="备注" name="notes">
label="备注"
name="notes"
>
<Input.TextArea rows={3} placeholder="请输入备注信息" /> <Input.TextArea rows={3} placeholder="请输入备注信息" />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
@@ -585,10 +606,7 @@ function ConsumableLogs() {
> >
<Input.TextArea rows={2} placeholder="请输入修改原因(必填)" /> <Input.TextArea rows={2} placeholder="请输入修改原因(必填)" />
</Form.Item> </Form.Item>
<Form.Item <Form.Item label="修改人" name="operator">
label="修改人"
name="operator"
>
<Input placeholder="请输入修改人姓名" /> <Input placeholder="请输入修改人姓名" />
</Form.Item> </Form.Item>
</Form> </Form>
@@ -623,21 +641,38 @@ function ConsumableLogs() {
<Tag color={getOperationTag(item.operationType).props.color}> <Tag color={getOperationTag(item.operationType).props.color}>
{getOperationTag(item.operationType).props.children} {getOperationTag(item.operationType).props.children}
</Tag> </Tag>
{item.modifiedBy && ( {item.modifiedBy && <Tag color="orange">已修改</Tag>}
<Tag color="orange">已修改</Tag>
)}
</div> </div>
<div style={{ fontSize: 12, color: '#666' }}> <div style={{ fontSize: 12, color: '#666' }}>
<p><strong>耗材:</strong> {item.consumableName} ({item.consumableId})</p> <p>
<p><strong>操作人:</strong> {item.operator}</p> <strong>耗材:</strong> {item.consumableName} ({item.consumableId})
{item.reason && <p><strong>原因:</strong> {item.reason}</p>} </p>
{item.notes && <p><strong>备注:</strong> {item.notes}</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 && ( {item.modifiedBy && (
<> <>
<p><strong>修改人:</strong> {item.modifiedBy}</p> <p>
<p><strong>修改时间:</strong> {dayjs(item.modifiedAt).format('YYYY-MM-DD HH:mm:ss')}</p> <strong>修改:</strong> {item.modifiedBy}
</p>
<p>
<strong>修改时间:</strong>{' '}
{dayjs(item.modifiedAt).format('YYYY-MM-DD HH:mm:ss')}
</p>
{item.modificationReason && ( {item.modificationReason && (
<p><strong>修改原因:</strong> {item.modificationReason}</p> <p>
<strong>修改原因:</strong> {item.modificationReason}
</p>
)} )}
</> </>
)} )}
+407 -259
View File
@@ -1,6 +1,32 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; 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 {
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ExportOutlined, ImportOutlined, UploadOutlined, FileExcelOutlined, InboxOutlined } from '@ant-design/icons'; 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'; import axios from 'axios';
const { Option } = Select; const { Option } = Select;
@@ -16,7 +42,7 @@ function ConsumableManagement() {
current: 1, current: 1,
pageSize: 10, pageSize: 10,
total: 0, total: 0,
showTotal: (total) => `${total} 条记录` showTotal: total => `${total} 条记录`,
}); });
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [category, setCategory] = useState('all'); const [category, setCategory] = useState('all');
@@ -34,21 +60,24 @@ function ConsumableManagement() {
const [stockForm] = Form.useForm(); const [stockForm] = Form.useForm();
const [maxStockUnlimited, setMaxStockUnlimited] = useState(false); const [maxStockUnlimited, setMaxStockUnlimited] = useState(false);
const fetchConsumables = useCallback(async (page = 1, pageSize = 10) => { const fetchConsumables = useCallback(
try { async (page = 1, pageSize = 10) => {
setLoading(true); try {
const response = await axios.get('/api/consumables', { setLoading(true);
params: { page, pageSize, keyword, category, status } 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 })); setConsumables(response.data.consumables);
} catch (error) { setPagination(prev => ({ ...prev, current: page, pageSize, total: response.data.total }));
message.error('获取耗材列表失败'); } catch (error) {
console.error('获取耗材列表失败:', error); message.error('获取耗材列表失败');
} finally { console.error('获取耗材列表失败:', error);
setLoading(false); } finally {
} setLoading(false);
}, [keyword, category, status]); }
},
[keyword, category, status]
);
const fetchCategories = useCallback(async () => { const fetchCategories = useCallback(async () => {
try { try {
@@ -64,92 +93,132 @@ function ConsumableManagement() {
fetchCategories(); fetchCategories();
}, [fetchConsumables, fetchCategories]); }, [fetchConsumables, fetchCategories]);
const showModal = useCallback((consumable = null) => { const showModal = useCallback(
setEditingConsumable(consumable); (consumable = null) => {
if (consumable) { setEditingConsumable(consumable);
const isUnlimited = consumable.maxStock === 0 || consumable.maxStock === null || consumable.maxStock === undefined; if (consumable) {
setMaxStockUnlimited(isUnlimited); const isUnlimited =
form.setFieldsValue({ consumable.maxStock === 0 ||
...consumable, consumable.maxStock === null ||
maxStock: isUnlimited ? undefined : consumable.maxStock consumable.maxStock === undefined;
}); setMaxStockUnlimited(isUnlimited);
} else { form.setFieldsValue({
setMaxStockUnlimited(true); ...consumable,
form.resetFields(); maxStock: isUnlimited ? undefined : consumable.maxStock,
form.setFieldsValue({ });
unit: '个', } else {
currentStock: 0, setMaxStockUnlimited(true);
minStock: 0, form.resetFields();
status: 'active', form.setFieldsValue({
unitPrice: 0 unit: '个',
}); currentStock: 0,
} minStock: 0,
setModalVisible(true); status: 'active',
}, [form]); unitPrice: 0,
});
}
setModalVisible(true);
},
[form]
);
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
setModalVisible(false); setModalVisible(false);
setEditingConsumable(null); setEditingConsumable(null);
}, []); }, []);
const handleSubmit = useCallback(async (values) => { const handleSubmit = useCallback(
try { async values => {
const submitData = { try {
...values, const submitData = {
maxStock: maxStockUnlimited ? 0 : values.maxStock, ...values,
unitPrice: values.unitPrice || 0 maxStock: maxStockUnlimited ? 0 : values.maxStock,
}; unitPrice: values.unitPrice || 0,
if (editingConsumable) { };
await axios.put(`/api/consumables/${editingConsumable.consumableId}`, submitData); if (editingConsumable) {
message.success('耗材更新成功'); await axios.put(`/api/consumables/${editingConsumable.consumableId}`, submitData);
} else { message.success('耗材更新成功');
await axios.post('/api/consumables', { } else {
...submitData, await axios.post('/api/consumables', {
consumableId: `CON${Date.now()}` ...submitData,
}); consumableId: `CON${Date.now()}`,
message.success('耗材创建成功'); });
message.success('耗材创建成功');
}
setModalVisible(false);
fetchConsumables();
setEditingConsumable(null);
} catch (error) {
message.error(editingConsumable ? '耗材更新失败' : '耗材创建失败');
console.error('提交失败:', error);
} }
setModalVisible(false); },
fetchConsumables(); [editingConsumable, fetchConsumables, maxStockUnlimited]
setEditingConsumable(null); );
} catch (error) {
message.error(editingConsumable ? '耗材更新失败' : '耗材创建失败');
console.error('提交失败:', error);
}
}, [editingConsumable, fetchConsumables, maxStockUnlimited]);
const handleDelete = useCallback(async (consumableId) => { const handleDelete = useCallback(
try { async consumableId => {
await axios.delete(`/api/consumables/${consumableId}`); try {
message.success('删除成功'); await axios.delete(`/api/consumables/${consumableId}`);
fetchConsumables(); message.success('删除成功');
} catch (error) { fetchConsumables();
message.error('删除失败'); } catch (error) {
console.error('删除失败:', error); message.error('删除失败');
} console.error('删除失败:', error);
}, [fetchConsumables]); }
},
[fetchConsumables]
);
const handleSearch = useCallback((value) => { const handleSearch = useCallback(value => {
setKeyword(value); setKeyword(value);
}, []); }, []);
const exportToCSV = (data, filename) => { const exportToCSV = (data, filename) => {
const headers = ['耗材ID', '名称', '分类', '单位', '当前库存', '最小库存', '最大库存', '单价', '供应商', '存放位置', '状态']; const headers = [
const keys = ['consumableId', 'name', 'category', 'unit', 'currentStock', 'minStock', 'maxStock', 'unitPrice', 'supplier', 'location', 'status']; '耗材ID',
'名称',
'分类',
'单位',
'当前库存',
'最小库存',
'最大库存',
'单价',
'供应商',
'存放位置',
'状态',
];
const keys = [
'consumableId',
'name',
'category',
'unit',
'currentStock',
'minStock',
'maxStock',
'unitPrice',
'supplier',
'location',
'status',
];
const csvContent = [ const csvContent = [
headers.join(','), headers.join(','),
...data.map(row => keys.map(key => { ...data.map(row =>
let value = row[key]; keys
if (key === 'unitPrice') value = `¥${parseFloat(value || 0).toFixed(2)}`; .map(key => {
if (key === 'status') value = value === 'active' ? '启用' : '停用'; let value = row[key];
if (value === null || value === undefined) value = ''; if (key === 'unitPrice') value = `¥${parseFloat(value || 0).toFixed(2)}`;
const str = String(value); if (key === 'status') value = value === 'active' ? '启用' : '停用';
if (str.includes(',') || str.includes('"') || str.includes('\n')) { if (value === null || value === undefined) value = '';
return `"${str.replace(/"/g, '""')}"`; const str = String(value);
} if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return str; return `"${str.replace(/"/g, '""')}"`;
}).join(',')) }
return str;
})
.join(',')
),
].join('\n'); ].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
@@ -166,7 +235,7 @@ function ConsumableManagement() {
const handleExport = async () => { const handleExport = async () => {
try { try {
const response = await axios.get('/api/consumables', { const response = await axios.get('/api/consumables', {
params: { keyword, category, status, pageSize: 1000 } params: { keyword, category, status, pageSize: 1000 },
}); });
const consumables = response.data.consumables; const consumables = response.data.consumables;
exportToCSV(consumables, `consumables_${new Date().toISOString().split('T')[0]}.csv`); exportToCSV(consumables, `consumables_${new Date().toISOString().split('T')[0]}.csv`);
@@ -177,7 +246,7 @@ function ConsumableManagement() {
} }
}; };
const parseCSV = (text) => { const parseCSV = text => {
const lines = text.trim().split('\n'); const lines = text.trim().split('\n');
if (lines.length < 2) return []; if (lines.length < 2) return [];
@@ -215,11 +284,11 @@ function ConsumableManagement() {
return data; return data;
}; };
const handleFileChange = (info) => { const handleFileChange = info => {
const file = info.fileList[info.fileList.length - 1]; const file = info.fileList[info.fileList.length - 1];
if (file && file.originFileObj) { if (file && file.originFileObj) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = e => {
const text = e.target.result; const text = e.target.result;
const parsedData = parseCSV(text); const parsedData = parseCSV(text);
setImportPreview(parsedData.slice(0, 10)); setImportPreview(parsedData.slice(0, 10));
@@ -257,7 +326,7 @@ function ConsumableManagement() {
try { try {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async (e) => { reader.onload = async e => {
const text = e.target.result; const text = e.target.result;
setImportProgress(10); setImportProgress(10);
setImportPhase('正在读取文件...'); setImportPhase('正在读取文件...');
@@ -317,7 +386,7 @@ function ConsumableManagement() {
imported: 0, imported: 0,
failed: 0, failed: 0,
errors: [{ row: '-', error: '文件读取失败,请检查文件是否损坏' }], errors: [{ row: '-', error: '文件读取失败,请检查文件是否损坏' }],
message: '文件读取失败' message: '文件读取失败',
}); });
message.error('文件读取失败'); message.error('文件读取失败');
}; };
@@ -335,7 +404,7 @@ function ConsumableManagement() {
if (data.errors && Array.isArray(data.errors) && data.errors.length > 0) { if (data.errors && Array.isArray(data.errors) && data.errors.length > 0) {
errorDetails = data.errors.map((err, index) => ({ errorDetails = data.errors.map((err, index) => ({
row: err.row || index + 1, row: err.row || index + 1,
error: err.error || err.message || '数据格式错误' error: err.error || err.message || '数据格式错误',
})); }));
errorMessage = `导入失败,共发现 ${errorDetails.length} 处数据错误`; errorMessage = `导入失败,共发现 ${errorDetails.length} 处数据错误`;
} else if (data.message) { } else if (data.message) {
@@ -359,7 +428,7 @@ function ConsumableManagement() {
imported: 0, imported: 0,
failed: 0, failed: 0,
errors: errorDetails, errors: errorDetails,
message: errorMessage message: errorMessage,
}); });
message.error(errorMessage); message.error(errorMessage);
@@ -368,7 +437,8 @@ function ConsumableManagement() {
}; };
const downloadTemplate = () => { 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 blob = new Blob([template], { type: 'text/csv;charset=utf-8;' });
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
@@ -380,163 +450,208 @@ function ConsumableManagement() {
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
}; };
const showStockModal = useCallback((record, type) => { const showStockModal = useCallback(
setStockRecord(record); (record, type) => {
setStockType(type); setStockRecord(record);
stockForm.setFieldsValue({ setStockType(type);
consumableId: record.consumableId, stockForm.setFieldsValue({
consumableName: record.name, consumableId: record.consumableId,
quantity: 1, consumableName: record.name,
reason: '', quantity: 1,
notes: '' reason: '',
}); notes: '',
setStockModalVisible(true); });
}, [stockForm]); setStockModalVisible(true);
},
[stockForm]
);
const handleStockCancel = useCallback(() => { const handleStockCancel = useCallback(() => {
setStockModalVisible(false); setStockModalVisible(false);
setStockRecord(null); setStockRecord(null);
}, []); }, []);
const handleStockSubmit = useCallback(async (values) => { const handleStockSubmit = useCallback(
try { async values => {
const response = await axios.post('/api/consumables/quick-inout', { try {
consumableId: stockRecord.consumableId, const response = await axios.post('/api/consumables/quick-inout', {
type: stockType, consumableId: stockRecord.consumableId,
quantity: values.quantity, type: stockType,
operator: values.operator || '系统管理员', quantity: values.quantity,
reason: values.reason, operator: values.operator || '系统管理员',
notes: values.notes reason: values.reason,
}); notes: values.notes,
message.success(`${stockType === 'in' ? '入库' : '出库'}操作成功`); });
setStockModalVisible(false); message.success(`${stockType === 'in' ? '入库' : '出库'}操作成功`);
fetchConsumables(); setStockModalVisible(false);
} catch (error) { fetchConsumables();
message.error(error.response?.data?.error || `${stockType === 'in' ? '入库' : '出库'}操作失败`); } catch (error) {
console.error('操作失败:', error); message.error(
} error.response?.data?.error || `${stockType === 'in' ? '入库' : '出库'}操作失败`
}, [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>
); );
console.error('操作失败:', error);
} }
}, },
{ [stockRecord, stockType, fetchConsumables]
title: '最小库存', );
dataIndex: 'minStock',
key: 'minStock', const columns = useMemo(
width: 100 () => [
}, {
{ title: '名称',
title: '最大库存', dataIndex: 'name',
dataIndex: 'maxStock', key: 'name',
key: 'maxStock', width: 150,
width: 100, },
render: (value) => value === 0 || value === null || value === undefined ? '无限制' : value {
}, title: '分类',
{ dataIndex: 'category',
title: '单价(元)', key: 'category',
dataIndex: 'unitPrice', width: 120,
key: 'unitPrice', },
width: 100, {
render: (value) => `¥${parseFloat(value || 0).toFixed(2)}` title: '单位',
}, dataIndex: 'unit',
{ key: 'unit',
title: '供应商', width: 80,
dataIndex: 'supplier', },
key: 'supplier', {
width: 150, title: '当前库存',
render: (value) => value || '-' dataIndex: 'currentStock',
}, key: 'currentStock',
{ width: 100,
title: '位置', render: (value, record) => {
dataIndex: 'location', const isLow = value <= record.minStock;
key: 'location', return (
width: 120, <span style={{ color: isLow ? '#ff4d4f' : '#52c41a', fontWeight: 'bold' }}>
render: (value) => value || '-' {value}
}, </span>
{ );
title: '描述', },
dataIndex: 'description', },
key: 'description', {
width: 200, title: '最小库存',
render: (value) => value || '-', dataIndex: 'minStock',
ellipsis: true key: 'minStock',
}, width: 100,
{ },
title: '状态', {
dataIndex: 'status', title: '最大库存',
key: 'status', dataIndex: 'maxStock',
width: 100, key: 'maxStock',
render: (value) => ( width: 100,
<span style={{ color: value === 'active' ? '#52c41a' : '#ff4d4f' }}> render: value => (value === 0 || value === null || value === undefined ? '无限制' : value),
{value === 'active' ? '启用' : '停用'} },
</span> {
) title: '单价(元)',
}, dataIndex: 'unitPrice',
{ key: 'unitPrice',
title: '操作', width: 100,
key: 'action', render: value => `¥${parseFloat(value || 0).toFixed(2)}`,
width: 200, },
render: (_, record) => ( {
<Space> title: '供应商',
<Button type="primary" icon={<EditOutlined />} size="small" onClick={() => showModal(record)}>编辑</Button> dataIndex: 'supplier',
<Button type="default" icon={<InboxOutlined />} size="small" style={{ background: '#f6ffed', borderColor: '#b7eb8f', color: '#52c41a' }} onClick={() => showStockModal(record, 'in')}>入库</Button> key: 'supplier',
<Button type="default" icon={<ExportOutlined />} size="small" style={{ background: '#fff2f0', borderColor: '#ffccc7', color: '#ff4d4f' }} onClick={() => showStockModal(record, 'out')}>出库</Button> width: 150,
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(record.consumableId)}> render: value => value || '-',
<Button danger icon={<DeleteOutlined />} size="small">删除</Button> },
</Popconfirm> {
</Space> title: '位置',
) dataIndex: 'location',
} key: 'location',
], [showModal, showStockModal, handleDelete]); 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 = [ const previewColumns = [
{ title: '名称', dataIndex: '名称', key: 'name', width: 120 }, { title: '名称', dataIndex: '名称', key: 'name', width: 120 },
{ title: '分类', dataIndex: '分类', key: 'category', width: 100 }, { title: '分类', dataIndex: '分类', key: 'category', width: 100 },
{ title: '单位', dataIndex: '单位', key: 'unit', width: 80 }, { title: '单位', dataIndex: '单位', key: 'unit', width: 80 },
{ title: '当前库存', dataIndex: '当前库存', key: 'currentStock', width: 90 }, { title: '当前库存', dataIndex: '当前库存', key: 'currentStock', width: 90 },
{ title: '单价', dataIndex: '单价', key: 'unitPrice', width: 80 } { title: '单价', dataIndex: '单价', key: 'unitPrice', width: 80 },
]; ];
return ( return (
<div> <div>
<Card title="耗材管理" extra={ <Card
<Space> title="耗材管理"
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>添加耗材</Button> extra={
<Button icon={<ImportOutlined />} onClick={showImportModal}>导入</Button> <Space>
<Button icon={<ExportOutlined />} onClick={handleExport}>导出</Button> <Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>
</Space> 添加耗材
}> </Button>
<Button icon={<ImportOutlined />} onClick={showImportModal}>
导入
</Button>
<Button icon={<ExportOutlined />} onClick={handleExport}>
导出
</Button>
</Space>
}
>
<Card size="small" style={{ marginBottom: 16 }}> <Card size="small" style={{ marginBottom: 16 }}>
<Space> <Space>
<Input.Search <Input.Search
@@ -548,7 +663,9 @@ function ConsumableManagement() {
<Select value={category} onChange={setCategory} style={{ width: 150 }}> <Select value={category} onChange={setCategory} style={{ width: 150 }}>
<Option value="all">所有分类</Option> <Option value="all">所有分类</Option>
{categories.map(cat => ( {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>
<Select value={status} onChange={setStatus} style={{ width: 120 }}> <Select value={status} onChange={setStatus} style={{ width: 120 }}>
@@ -565,7 +682,7 @@ function ConsumableManagement() {
rowKey="consumableId" rowKey="consumableId"
loading={loading} loading={loading}
pagination={pagination} pagination={pagination}
onChange={(pagination) => fetchConsumables(pagination.current, pagination.pageSize)} onChange={pagination => fetchConsumables(pagination.current, pagination.pageSize)}
scroll={{ x: 1300 }} scroll={{ x: 1300 }}
/> />
</Card> </Card>
@@ -581,31 +698,47 @@ function ConsumableManagement() {
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}> <Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="请输入耗材名称" /> <Input placeholder="请输入耗材名称" />
</Form.Item> </Form.Item>
<Form.Item name="category" label="分类" rules={[{ required: true, message: '请选择分类' }]}> <Form.Item
<Select name="category"
placeholder="请选择分类" label="分类"
allowClear rules={[{ required: true, message: '请选择分类' }]}
> >
<Select placeholder="请选择分类" allowClear>
{categories.map(cat => ( {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>
</Form.Item> </Form.Item>
<Form.Item name="unit" label="单位" rules={[{ required: true, message: '请输入单位' }]} initialValue="个"> <Form.Item
name="unit"
label="单位"
rules={[{ required: true, message: '请输入单位' }]}
initialValue="个"
>
<Input placeholder="如: 个、盒、卷、箱" /> <Input placeholder="如: 个、盒、卷、箱" />
</Form.Item> </Form.Item>
<Space style={{ width: '100%' }}> <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%' }} /> <InputNumber min={0} style={{ width: '100%' }} />
</Form.Item> </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%' }} /> <InputNumber min={0} style={{ width: '100%' }} />
</Form.Item> </Form.Item>
<Form.Item label="最大库存"> <Form.Item label="最大库存">
<Space direction="vertical" style={{ width: '100%' }}> <Space direction="vertical" style={{ width: '100%' }}>
<Checkbox <Checkbox
checked={maxStockUnlimited} checked={maxStockUnlimited}
onChange={(e) => { onChange={e => {
setMaxStockUnlimited(e.target.checked); setMaxStockUnlimited(e.target.checked);
if (e.target.checked) { if (e.target.checked) {
form.setFieldsValue({ maxStock: undefined }); form.setFieldsValue({ maxStock: undefined });
@@ -615,7 +748,11 @@ function ConsumableManagement() {
无限制 无限制
</Checkbox> </Checkbox>
{!maxStockUnlimited && ( {!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="请输入最大库存" /> <InputNumber min={0} style={{ width: '100%' }} placeholder="请输入最大库存" />
</Form.Item> </Form.Item>
)} )}
@@ -623,7 +760,13 @@ function ConsumableManagement() {
</Form.Item> </Form.Item>
</Space> </Space>
<Form.Item name="unitPrice" label="单价(元)"> <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>
<Form.Item name="supplier" label="供应商"> <Form.Item name="supplier" label="供应商">
<Input placeholder="请输入供应商" /> <Input placeholder="请输入供应商" />
@@ -642,7 +785,9 @@ function ConsumableManagement() {
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Space> <Space>
<Button type="primary" htmlType="submit">{editingConsumable ? '更新' : '创建'}</Button> <Button type="primary" htmlType="submit">
{editingConsumable ? '更新' : '创建'}
</Button>
<Button onClick={handleCancel}>取消</Button> <Button onClick={handleCancel}>取消</Button>
</Space> </Space>
</Form.Item> </Form.Item>
@@ -659,17 +804,14 @@ function ConsumableManagement() {
<Space direction="vertical" style={{ width: '100%' }} size="middle"> <Space direction="vertical" style={{ width: '100%' }} size="middle">
<Card size="small" style={{ background: '#f5f5f5' }}> <Card size="small" style={{ background: '#f5f5f5' }}>
<Space> <Space>
<Button icon={<FileExcelOutlined />} onClick={downloadTemplate}>下载模板</Button> <Button icon={<FileExcelOutlined />} onClick={downloadTemplate}>
下载模板
</Button>
<span style={{ color: '#888', fontSize: 12 }}>请下载模板后填写数据再导入</span> <span style={{ color: '#888', fontSize: 12 }}>请下载模板后填写数据再导入</span>
</Space> </Space>
</Card> </Card>
<Upload <Upload accept=".csv" maxCount={1} beforeUpload={() => false} onChange={handleFileChange}>
accept=".csv"
maxCount={1}
beforeUpload={() => false}
onChange={handleFileChange}
>
<Button icon={<UploadOutlined />}>选择CSV文件</Button> <Button icon={<UploadOutlined />}>选择CSV文件</Button>
</Upload> </Upload>
@@ -717,7 +859,11 @@ function ConsumableManagement() {
<Form.Item name="consumableName" label="耗材名称"> <Form.Item name="consumableName" label="耗材名称">
<Input disabled /> <Input disabled />
</Form.Item> </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="请输入数量" /> <InputNumber min={1} style={{ width: '100%' }} placeholder="请输入数量" />
</Form.Item> </Form.Item>
<Form.Item name="operator" label="操作人"> <Form.Item name="operator" label="操作人">
@@ -731,7 +877,9 @@ function ConsumableManagement() {
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Space> <Space>
<Button type="primary" htmlType="submit">{stockType === 'in' ? '确认入库' : '确认出库'}</Button> <Button type="primary" htmlType="submit">
{stockType === 'in' ? '确认入库' : '确认出库'}
</Button>
<Button onClick={handleStockCancel}>取消</Button> <Button onClick={handleStockCancel}>取消</Button>
</Space> </Space>
</Form.Item> </Form.Item>
+265 -150
View File
@@ -1,6 +1,29 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Card, Row, Col, Statistic, Table, Tag, DatePicker, Space, Select, Progress, message, Button } from 'antd'; import {
import { InboxOutlined, ExportOutlined, WarningOutlined, DollarOutlined, ShoppingCartOutlined, ExclamationCircleOutlined, PlusOutlined, BarChartOutlined, DownloadOutlined } from '@ant-design/icons'; 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 axios from 'axios';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -12,60 +35,60 @@ const designTokens = {
primary: { primary: {
main: '#667eea', main: '#667eea',
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
light: '#8b9ff0' light: '#8b9ff0',
}, },
success: { success: {
main: '#10b981', main: '#10b981',
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)' gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
}, },
warning: { warning: {
main: '#f59e0b', main: '#f59e0b',
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
}, },
error: { error: {
main: '#ef4444', main: '#ef4444',
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
}, },
text: { text: {
primary: '#1e293b', primary: '#1e293b',
secondary: '#64748b', secondary: '#64748b',
tertiary: '#94a3b8', tertiary: '#94a3b8',
inverse: '#ffffff' inverse: '#ffffff',
}, },
background: { background: {
primary: '#ffffff', primary: '#ffffff',
secondary: '#f8fafc', secondary: '#f8fafc',
tertiary: '#f1f5f9' tertiary: '#f1f5f9',
}, },
border: { border: {
light: '#e2e8f0', light: '#e2e8f0',
medium: '#cbd5e1', medium: '#cbd5e1',
dark: '#94a3b8' dark: '#94a3b8',
} },
}, },
shadows: { shadows: {
small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1)', 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: { borderRadius: {
small: '6px', small: '6px',
medium: '10px', medium: '10px',
large: '16px' large: '16px',
}, },
spacing: { spacing: {
xs: '4px', xs: '4px',
sm: '8px', sm: '8px',
md: '16px', md: '16px',
lg: '24px', lg: '24px',
xl: '32px' xl: '32px',
} },
}; };
const pageContainerStyle = { const pageContainerStyle = {
minHeight: '100vh', minHeight: '100vh',
background: designTokens.colors.background.secondary, background: designTokens.colors.background.secondary,
padding: designTokens.spacing.lg padding: designTokens.spacing.lg,
}; };
const headerStyle = { const headerStyle = {
@@ -74,7 +97,7 @@ const headerStyle = {
background: designTokens.colors.background.primary, background: designTokens.colors.background.primary,
borderRadius: designTokens.borderRadius.large, borderRadius: designTokens.borderRadius.large,
boxShadow: designTokens.shadows.small, boxShadow: designTokens.shadows.small,
border: `1px solid ${designTokens.colors.border.light}` border: `1px solid ${designTokens.colors.border.light}`,
}; };
const titleRowStyle = { const titleRowStyle = {
@@ -83,7 +106,7 @@ const titleRowStyle = {
justifyContent: 'space-between', justifyContent: 'space-between',
marginBottom: designTokens.spacing.md, marginBottom: designTokens.spacing.md,
flexWrap: 'wrap', flexWrap: 'wrap',
gap: designTokens.spacing.md gap: designTokens.spacing.md,
}; };
const titleStyle = { const titleStyle = {
@@ -92,13 +115,13 @@ const titleStyle = {
gap: designTokens.spacing.sm, gap: designTokens.spacing.sm,
fontSize: '20px', fontSize: '20px',
fontWeight: '600', fontWeight: '600',
color: designTokens.colors.text.primary color: designTokens.colors.text.primary,
}; };
const statsRowStyle = { const statsRowStyle = {
display: 'flex', display: 'flex',
gap: designTokens.spacing.md, gap: designTokens.spacing.md,
flexWrap: 'wrap' flexWrap: 'wrap',
}; };
const statCardStyle = { const statCardStyle = {
@@ -108,22 +131,22 @@ const statCardStyle = {
boxShadow: designTokens.shadows.small, boxShadow: designTokens.shadows.small,
border: `1px solid ${designTokens.colors.border.light}`, border: `1px solid ${designTokens.colors.border.light}`,
minWidth: '180px', minWidth: '180px',
flex: 1 flex: 1,
}; };
const statCardTextStyle = { const statCardTextStyle = {
fontSize: '13px', fontSize: '13px',
color: designTokens.colors.text.secondary, color: designTokens.colors.text.secondary,
marginBottom: designTokens.spacing.xs marginBottom: designTokens.spacing.xs,
}; };
const statCardValueStyle = { const statCardValueStyle = {
fontSize: '28px', fontSize: '28px',
fontWeight: '600', fontWeight: '600',
color: designTokens.colors.text.primary color: designTokens.colors.text.primary,
}; };
const statCardIconStyle = (color) => ({ const statCardIconStyle = color => ({
fontSize: '28px', fontSize: '28px',
color: color, color: color,
display: 'flex', display: 'flex',
@@ -132,7 +155,7 @@ const statCardIconStyle = (color) => ({
width: '48px', width: '48px',
height: '48px', height: '48px',
borderRadius: designTokens.borderRadius.medium, borderRadius: designTokens.borderRadius.medium,
background: `${color}12` background: `${color}12`,
}); });
const panelStyle = { const panelStyle = {
@@ -140,7 +163,7 @@ const panelStyle = {
borderRadius: designTokens.borderRadius.large, borderRadius: designTokens.borderRadius.large,
boxShadow: designTokens.shadows.small, boxShadow: designTokens.shadows.small,
border: `1px solid ${designTokens.colors.border.light}`, border: `1px solid ${designTokens.colors.border.light}`,
overflow: 'hidden' overflow: 'hidden',
}; };
const panelHeaderStyle = { const panelHeaderStyle = {
@@ -148,7 +171,7 @@ const panelHeaderStyle = {
borderBottom: `1px solid ${designTokens.colors.border.light}`, borderBottom: `1px solid ${designTokens.colors.border.light}`,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between' justifyContent: 'space-between',
}; };
const panelTitleStyle = { const panelTitleStyle = {
@@ -157,11 +180,11 @@ const panelTitleStyle = {
color: designTokens.colors.text.primary, color: designTokens.colors.text.primary,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: designTokens.spacing.sm gap: designTokens.spacing.sm,
}; };
const panelBodyStyle = { const panelBodyStyle = {
padding: designTokens.spacing.lg padding: designTokens.spacing.lg,
}; };
const actionButtonStyle = { const actionButtonStyle = {
@@ -171,7 +194,7 @@ const actionButtonStyle = {
fontSize: '13px', fontSize: '13px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: designTokens.spacing.xs gap: designTokens.spacing.xs,
}; };
const primaryActionStyle = { const primaryActionStyle = {
@@ -179,13 +202,19 @@ const primaryActionStyle = {
background: designTokens.colors.primary.gradient, background: designTokens.colors.primary.gradient,
border: 'none', border: 'none',
color: '#ffffff', color: '#ffffff',
boxShadow: designTokens.shadows.small boxShadow: designTokens.shadows.small,
}; };
function ConsumableStatistics() { function ConsumableStatistics() {
const [summary, setSummary] = useState({ total: 0, lowStock: 0, totalValue: 0, byCategory: [] }); const [summary, setSummary] = useState({ total: 0, lowStock: 0, totalValue: 0, byCategory: [] });
const [lowStockItems, setLowStockItems] = useState([]); 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 [loading, setLoading] = useState(true);
const [dateRange, setDateRange] = useState([]); const [dateRange, setDateRange] = useState([]);
const [categoryFilter, setCategoryFilter] = useState(null); const [categoryFilter, setCategoryFilter] = useState(null);
@@ -228,11 +257,7 @@ function ConsumableStatistics() {
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
setLoading(true); setLoading(true);
await Promise.all([ await Promise.all([fetchSummary(), fetchLowStock(), fetchInOutStats()]);
fetchSummary(),
fetchLowStock(),
fetchInOutStats()
]);
setLoading(false); setLoading(false);
}; };
loadData(); loadData();
@@ -248,96 +273,97 @@ function ConsumableStatistics() {
dataIndex: 'name', dataIndex: 'name',
key: 'name', key: 'name',
width: 150, width: 150,
render: (text) => ( render: text => (
<span style={{ fontWeight: '500', color: designTokens.colors.text.primary }}> <span style={{ fontWeight: '500', color: designTokens.colors.text.primary }}>{text}</span>
{text} ),
</span>
)
}, },
{ {
title: '分类', title: '分类',
dataIndex: 'category', dataIndex: 'category',
key: 'category', key: 'category',
width: 120, width: 120,
render: (category) => ( render: category => (
<Tag style={{ <Tag
border: 'none', style={{
borderRadius: designTokens.borderRadius.small, border: 'none',
background: `${designTokens.colors.primary.main}15`, borderRadius: designTokens.borderRadius.small,
color: designTokens.colors.primary.main, background: `${designTokens.colors.primary.main}15`,
fontWeight: '500' color: designTokens.colors.primary.main,
}}> fontWeight: '500',
}}
>
{category} {category}
</Tag> </Tag>
) ),
}, },
{ {
title: '当前库存', title: '当前库存',
dataIndex: 'currentStock', dataIndex: 'currentStock',
key: 'currentStock', key: 'currentStock',
width: 100, width: 100,
render: (value) => ( render: value => (
<span style={{ <span
color: designTokens.colors.error.main, style={{
fontWeight: '600', color: designTokens.colors.error.main,
background: `${designTokens.colors.error.main}12`, fontWeight: '600',
padding: `2px ${designTokens.spacing.sm}`, background: `${designTokens.colors.error.main}12`,
borderRadius: designTokens.borderRadius.small padding: `2px ${designTokens.spacing.sm}`,
}}> borderRadius: designTokens.borderRadius.small,
}}
>
{value} {value}
</span> </span>
) ),
}, },
{ {
title: '最小库存', title: '最小库存',
dataIndex: 'minStock', dataIndex: 'minStock',
key: 'minStock', key: 'minStock',
width: 100, width: 100,
render: (value) => ( render: value => <span style={{ color: designTokens.colors.text.secondary }}>{value}</span>,
<span style={{ color: designTokens.colors.text.secondary }}>
{value}
</span>
)
}, },
{ {
title: '单位', title: '单位',
dataIndex: 'unit', dataIndex: 'unit',
key: 'unit', key: 'unit',
width: 80, width: 80,
render: (value) => ( render: value => <span style={{ color: designTokens.colors.text.tertiary }}>{value}</span>,
<span style={{ color: designTokens.colors.text.tertiary }}>
{value}
</span>
)
}, },
{ {
title: '充足率', title: '充足率',
key: 'rate', key: 'rate',
width: 140, width: 140,
render: (_, record) => { 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'; const status = rate < 30 ? 'exception' : rate < 60 ? 'active' : 'success';
return ( return (
<Progress <Progress
percent={rate} percent={rate}
size="small" size="small"
status={status} 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: '供应商', title: '供应商',
dataIndex: 'supplier', dataIndex: 'supplier',
key: 'supplier', key: 'supplier',
width: 120, width: 120,
render: (value) => ( render: value => (
<span style={{ color: designTokens.colors.text.secondary }}> <span style={{ color: designTokens.colors.text.secondary }}>{value || '-'}</span>
{value || '-'} ),
</span> },
)
}
]; ];
const recentColumns = [ const recentColumns = [
@@ -346,41 +372,41 @@ function ConsumableStatistics() {
dataIndex: 'createdAt', dataIndex: 'createdAt',
key: 'createdAt', key: 'createdAt',
width: 170, width: 170,
render: (date) => ( render: date => (
<span style={{ color: designTokens.colors.text.secondary }}> <span style={{ color: designTokens.colors.text.secondary }}>
{dayjs(date).format('YYYY-MM-DD HH:mm')} {dayjs(date).format('YYYY-MM-DD HH:mm')}
</span> </span>
) ),
}, },
{ {
title: '耗材名称', title: '耗材名称',
dataIndex: ['Consumable', 'name'], dataIndex: ['Consumable', 'name'],
key: 'consumableName', key: 'consumableName',
width: 140, width: 140,
render: (text) => ( render: text => <span style={{ fontWeight: '500' }}>{text}</span>,
<span style={{ fontWeight: '500' }}>
{text}
</span>
)
}, },
{ {
title: '类型', title: '类型',
dataIndex: 'type', dataIndex: 'type',
key: 'type', key: 'type',
width: 90, width: 90,
render: (type) => ( render: type => (
<Tag <Tag
style={{ style={{
border: 'none', border: 'none',
borderRadius: designTokens.borderRadius.small, borderRadius: designTokens.borderRadius.small,
background: type === 'in' ? `${designTokens.colors.success.main}15` : `${designTokens.colors.error.main}15`, background:
color: type === 'in' ? designTokens.colors.success.main : designTokens.colors.error.main, type === 'in'
fontWeight: '500' ? `${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' ? '入库' : '出库'} {type === 'in' ? '入库' : '出库'}
</Tag> </Tag>
) ),
}, },
{ {
title: '数量', title: '数量',
@@ -388,36 +414,38 @@ function ConsumableStatistics() {
key: 'quantity', key: 'quantity',
width: 100, width: 100,
render: (value, record) => ( render: (value, record) => (
<span style={{ <span
color: record.type === 'in' ? designTokens.colors.success.main : designTokens.colors.error.main, style={{
fontWeight: '600' color:
}}> record.type === 'in'
{record.type === 'in' ? '+' : '-'}{value} ? designTokens.colors.success.main
: designTokens.colors.error.main,
fontWeight: '600',
}}
>
{record.type === 'in' ? '+' : '-'}
{value}
</span> </span>
) ),
}, },
{ {
title: '操作人', title: '操作人',
dataIndex: 'operator', dataIndex: 'operator',
key: 'operator', key: 'operator',
width: 100, width: 100,
render: (value) => ( render: value => (
<span style={{ color: designTokens.colors.text.secondary }}> <span style={{ color: designTokens.colors.text.secondary }}>{value || '-'}</span>
{value || '-'} ),
</span>
)
}, },
{ {
title: '原因', title: '原因',
dataIndex: 'reason', dataIndex: 'reason',
key: 'reason', key: 'reason',
width: 150, width: 150,
render: (value) => ( render: value => (
<span style={{ color: designTokens.colors.text.secondary }}> <span style={{ color: designTokens.colors.text.secondary }}>{value || '-'}</span>
{value || '-'} ),
</span> },
)
}
]; ];
const categories = summary.byCategory?.map(item => item.category) || []; const categories = summary.byCategory?.map(item => item.category) || [];
@@ -441,7 +469,9 @@ function ConsumableStatistics() {
onChange={setCategoryFilter} onChange={setCategoryFilter}
> >
{categories.map(cat => ( {categories.map(cat => (
<Option key={cat} value={cat}>{cat}</Option> <Option key={cat} value={cat}>
{cat}
</Option>
))} ))}
</Select> </Select>
<RangePicker <RangePicker
@@ -468,7 +498,9 @@ function ConsumableStatistics() {
</div> </div>
</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={{ display: 'flex', alignItems: 'center', gap: designTokens.spacing.md }}>
<div style={statCardIconStyle(designTokens.colors.error.main)}> <div style={statCardIconStyle(designTokens.colors.error.main)}>
<WarningOutlined /> <WarningOutlined />
@@ -482,7 +514,12 @@ function ConsumableStatistics() {
</div> </div>
</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={{ display: 'flex', alignItems: 'center', gap: designTokens.spacing.md }}>
<div style={statCardIconStyle(designTokens.colors.success.main)}> <div style={statCardIconStyle(designTokens.colors.success.main)}>
<DollarOutlined /> <DollarOutlined />
@@ -496,15 +533,35 @@ function ConsumableStatistics() {
</div> </div>
</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={{ 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 /> <InboxOutlined />
</div> </div>
<div> <div>
<div style={statCardTextStyle}>净入库量</div> <div style={statCardTextStyle}>净入库量</div>
<div style={{ ...statCardValueStyle, color: netQuantity >= 0 ? designTokens.colors.success.main : designTokens.colors.error.main }}> <div
{netQuantity >= 0 ? '+' : ''}{netQuantity} style={{
...statCardValueStyle,
color:
netQuantity >= 0
? designTokens.colors.success.main
: designTokens.colors.error.main,
}}
>
{netQuantity >= 0 ? '+' : ''}
{netQuantity}
</div> </div>
</div> </div>
</div> </div>
@@ -525,39 +582,81 @@ function ConsumableStatistics() {
<div style={{ padding: designTokens.spacing.lg }}> <div style={{ padding: designTokens.spacing.lg }}>
<Row gutter={designTokens.spacing.md}> <Row gutter={designTokens.spacing.md}>
<Col span={12}> <Col span={12}>
<div style={{ <div
background: `${designTokens.colors.success.main}08`, style={{
borderRadius: designTokens.borderRadius.medium, background: `${designTokens.colors.success.main}08`,
padding: designTokens.spacing.lg, borderRadius: designTokens.borderRadius.medium,
textAlign: 'center', padding: designTokens.spacing.lg,
border: `1px solid ${designTokens.colors.success.main}30` 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={{
fontSize: '13px',
color: designTokens.colors.text.secondary,
marginBottom: designTokens.spacing.sm,
}}
>
入库次数 入库次数
</div> </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} {stats.inCount}
</div> </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} +{stats.inQuantity}
</div> </div>
</div> </div>
</Col> </Col>
<Col span={12}> <Col span={12}>
<div style={{ <div
background: `${designTokens.colors.error.main}08`, style={{
borderRadius: designTokens.borderRadius.medium, background: `${designTokens.colors.error.main}08`,
padding: designTokens.spacing.lg, borderRadius: designTokens.borderRadius.medium,
textAlign: 'center', padding: designTokens.spacing.lg,
border: `1px solid ${designTokens.colors.error.main}30` 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={{
fontSize: '13px',
color: designTokens.colors.text.secondary,
marginBottom: designTokens.spacing.sm,
}}
>
出库次数 出库次数
</div> </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} {stats.outCount}
</div> </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} -{stats.outQuantity}
</div> </div>
</div> </div>
@@ -579,7 +678,14 @@ function ConsumableStatistics() {
<div style={{ padding: designTokens.spacing.lg }}> <div style={{ padding: designTokens.spacing.lg }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: designTokens.spacing.sm }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: designTokens.spacing.sm }}>
{summary.byCategory?.map((item, index) => { {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]; const color = colors[index % colors.length];
return ( return (
<div <div
@@ -591,15 +697,17 @@ function ConsumableStatistics() {
border: `1px solid ${color}30`, border: `1px solid ${color}30`,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: designTokens.spacing.sm gap: designTokens.spacing.sm,
}} }}
> >
<div style={{ <div
width: '8px', style={{
height: '8px', width: '8px',
borderRadius: '50%', height: '8px',
background: color borderRadius: '50%',
}} /> background: color,
}}
/>
<span style={{ color: designTokens.colors.text.secondary, fontSize: '13px' }}> <span style={{ color: designTokens.colors.text.secondary, fontSize: '13px' }}>
{item.category} {item.category}
</span> </span>
@@ -610,7 +718,12 @@ function ConsumableStatistics() {
); );
})} })}
{(!summary.byCategory || summary.byCategory.length === 0) && ( {(!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> </div>
)} )}
@@ -628,13 +741,15 @@ function ConsumableStatistics() {
<ExclamationCircleOutlined style={{ color: designTokens.colors.error.main }} /> <ExclamationCircleOutlined style={{ color: designTokens.colors.error.main }} />
低库存预警 低库存预警
</div> </div>
<Tag style={{ <Tag
border: 'none', style={{
borderRadius: designTokens.borderRadius.small, border: 'none',
background: `${designTokens.colors.error.main}15`, borderRadius: designTokens.borderRadius.small,
color: designTokens.colors.error.main, background: `${designTokens.colors.error.main}15`,
fontWeight: '500' color: designTokens.colors.error.main,
}}> fontWeight: '500',
}}
>
{lowStockItems.length} {lowStockItems.length}
</Tag> </Tag>
</div> </div>
File diff suppressed because it is too large Load Diff
+212 -150
View File
@@ -1,6 +1,32 @@
import React, { useState, useEffect, useMemo } from 'react'; 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 {
import { PlusOutlined, EditOutlined, DeleteOutlined, AppstoreOutlined, FontSizeOutlined, NumberOutlined, CheckCircleOutlined, CalendarOutlined, FileTextOutlined, LockOutlined } from '@ant-design/icons'; 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'; import axios from 'axios';
const { Option = Select.Option } = Select; const { Option = Select.Option } = Select;
@@ -11,35 +37,35 @@ const designTokens = {
main: '#667eea', main: '#667eea',
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
light: '#8b9ff0', light: '#8b9ff0',
dark: '#4f5db8' dark: '#4f5db8',
}, },
success: { success: {
main: '#10b981', main: '#10b981',
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)' gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
}, },
warning: { warning: {
main: '#f59e0b', main: '#f59e0b',
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
}, },
error: { error: {
main: '#ef4444', main: '#ef4444',
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
}, },
text: { text: {
primary: '#1e293b', primary: '#1e293b',
secondary: '#64748b', secondary: '#64748b',
tertiary: '#94a3b8', tertiary: '#94a3b8',
inverse: '#ffffff' inverse: '#ffffff',
}, },
background: { background: {
primary: '#ffffff', primary: '#ffffff',
secondary: '#f8fafc', secondary: '#f8fafc',
tertiary: '#f1f5f9' tertiary: '#f1f5f9',
}, },
border: { border: {
light: '#e2e8f0', light: '#e2e8f0',
medium: '#cbd5e1', medium: '#cbd5e1',
dark: '#94a3b8' dark: '#94a3b8',
}, },
fieldType: { fieldType: {
string: '#3b82f6', string: '#3b82f6',
@@ -47,44 +73,44 @@ const designTokens = {
boolean: '#f59e0b', boolean: '#f59e0b',
select: '#8b5cf6', select: '#8b5cf6',
date: '#06b6d4', date: '#06b6d4',
textarea: '#64748b' textarea: '#64748b',
} },
}, },
shadows: { shadows: {
small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', 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)', 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)', 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: { borderRadius: {
small: '6px', small: '6px',
medium: '10px', medium: '10px',
large: '16px' large: '16px',
}, },
transitions: { transitions: {
fast: '150ms cubic-bezier(0.4, 0, 0.2, 1)', 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: { spacing: {
xs: '4px', xs: '4px',
sm: '8px', sm: '8px',
md: '16px', md: '16px',
lg: '24px', lg: '24px',
xl: '32px' xl: '32px',
} },
}; };
const pageContainerStyle = { const pageContainerStyle = {
minHeight: '100vh', minHeight: '100vh',
background: designTokens.colors.background.secondary, background: designTokens.colors.background.secondary,
padding: designTokens.spacing.lg padding: designTokens.spacing.lg,
}; };
const titleRowStyle = { const titleRowStyle = {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
marginBottom: designTokens.spacing.lg marginBottom: designTokens.spacing.lg,
}; };
const titleStyle = { const titleStyle = {
@@ -93,7 +119,7 @@ const titleStyle = {
gap: designTokens.spacing.sm, gap: designTokens.spacing.sm,
fontSize: '20px', fontSize: '20px',
fontWeight: '600', fontWeight: '600',
color: designTokens.colors.text.primary color: designTokens.colors.text.primary,
}; };
const actionButtonStyle = { const actionButtonStyle = {
@@ -103,7 +129,7 @@ const actionButtonStyle = {
fontSize: '13px', fontSize: '13px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: designTokens.spacing.xs gap: designTokens.spacing.xs,
}; };
const primaryActionStyle = { const primaryActionStyle = {
@@ -111,7 +137,7 @@ const primaryActionStyle = {
background: designTokens.colors.primary.gradient, background: designTokens.colors.primary.gradient,
border: 'none', border: 'none',
color: '#ffffff', color: '#ffffff',
boxShadow: designTokens.shadows.small boxShadow: designTokens.shadows.small,
}; };
const tableCardStyle = { const tableCardStyle = {
@@ -119,34 +145,34 @@ const tableCardStyle = {
borderRadius: designTokens.borderRadius.large, borderRadius: designTokens.borderRadius.large,
boxShadow: designTokens.shadows.small, boxShadow: designTokens.shadows.small,
border: `1px solid ${designTokens.colors.border.light}`, border: `1px solid ${designTokens.colors.border.light}`,
overflow: 'hidden' overflow: 'hidden',
}; };
const tableStyle = { const tableStyle = {
background: designTokens.colors.background.primary background: designTokens.colors.background.primary,
}; };
const titleIconStyle = { const titleIconStyle = {
color: designTokens.colors.primary.main color: designTokens.colors.primary.main,
}; };
const modalTitleStyle = { const modalTitleStyle = {
fontWeight: '600' fontWeight: '600',
}; };
const formLabelStyle = { const formLabelStyle = {
fontWeight: '500' fontWeight: '500',
}; };
const tableCellStyle = { const tableCellStyle = {
fontWeight: '500', fontWeight: '500',
color: designTokens.colors.text.primary color: designTokens.colors.text.primary,
}; };
const typeTagStyle = { const typeTagStyle = {
border: 'none', border: 'none',
borderRadius: designTokens.borderRadius.small, borderRadius: designTokens.borderRadius.small,
fontWeight: '500' fontWeight: '500',
}; };
const orderBadgeStyle = { const orderBadgeStyle = {
@@ -154,44 +180,44 @@ const orderBadgeStyle = {
padding: '2px 8px', padding: '2px 8px',
borderRadius: designTokens.borderRadius.small, borderRadius: designTokens.borderRadius.small,
fontSize: '12px', fontSize: '12px',
fontWeight: '500' fontWeight: '500',
}; };
const editButtonStyle = { const editButtonStyle = {
color: designTokens.colors.primary.main, color: designTokens.colors.primary.main,
height: '28px', height: '28px',
padding: '0 8px' padding: '0 8px',
}; };
const deleteButtonStyle = { const deleteButtonStyle = {
height: '28px', height: '28px',
padding: '0 8px' padding: '0 8px',
}; };
const formRowStyle = { const formRowStyle = {
display: 'flex', display: 'flex',
gap: designTokens.spacing.md gap: designTokens.spacing.md,
}; };
const formItemFlexStyle = { const formItemFlexStyle = {
flex: 1 flex: 1,
}; };
const textAreaStyle = { const textAreaStyle = {
fontFamily: 'monospace' fontFamily: 'monospace',
}; };
const modalBodyStyle = { const modalBodyStyle = {
padding: designTokens.spacing.lg padding: designTokens.spacing.lg,
}; };
const formActionsStyle = { const formActionsStyle = {
marginBottom: 0, marginBottom: 0,
textAlign: 'right' textAlign: 'right',
}; };
const modalStyle = { const modalStyle = {
borderRadius: designTokens.borderRadius.large borderRadius: designTokens.borderRadius.large,
}; };
const FIELD_TYPE_MAP = { const FIELD_TYPE_MAP = {
@@ -200,7 +226,7 @@ const FIELD_TYPE_MAP = {
boolean: { text: '布尔值', color: designTokens.colors.fieldType.boolean }, boolean: { text: '布尔值', color: designTokens.colors.fieldType.boolean },
select: { text: '下拉选择', color: designTokens.colors.fieldType.select }, select: { text: '下拉选择', color: designTokens.colors.fieldType.select },
date: { text: '日期', color: designTokens.colors.fieldType.date }, 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 = [ const FIELD_TYPE_OPTIONS = [
@@ -209,7 +235,7 @@ const FIELD_TYPE_OPTIONS = [
{ value: 'boolean', label: '布尔值' }, { value: 'boolean', label: '布尔值' },
{ value: 'select', label: '下拉选择' }, { value: 'select', label: '下拉选择' },
{ value: 'date', label: '日期' }, { value: 'date', label: '日期' },
{ value: 'textarea', label: '多行文本' } { value: 'textarea', label: '多行文本' },
]; ];
function DeviceFieldManagement() { function DeviceFieldManagement() {
@@ -224,7 +250,7 @@ function DeviceFieldManagement() {
pageSizeOptions: ['10', '20', '50', '100'], pageSizeOptions: ['10', '20', '50', '100'],
showSizeChanger: true, showSizeChanger: true,
showQuickJumper: true, showQuickJumper: true,
showTotal: (total, range) => `${range[0]}-${range[1]} 条 / 共 ${total}` showTotal: (total, range) => `${range[0]}-${range[1]} 条 / 共 ${total}`,
}); });
const fetchFields = async () => { const fetchFields = async () => {
@@ -249,7 +275,7 @@ function DeviceFieldManagement() {
if (field) { if (field) {
const fieldData = { const fieldData = {
...field, ...field,
options: field.options ? JSON.stringify(field.options, null, 2) : '' options: field.options ? JSON.stringify(field.options, null, 2) : '',
}; };
form.setFieldsValue(fieldData); form.setFieldsValue(fieldData);
} else { } else {
@@ -263,11 +289,11 @@ function DeviceFieldManagement() {
setEditingField(null); setEditingField(null);
}; };
const handleSubmit = async (values) => { const handleSubmit = async values => {
try { try {
const fieldData = { const fieldData = {
...values, ...values,
options: values.options ? JSON.parse(values.options) : null options: values.options ? JSON.parse(values.options) : null,
}; };
if (editingField) { if (editingField) {
@@ -287,7 +313,7 @@ function DeviceFieldManagement() {
} }
}; };
const handleDelete = async (fieldId) => { const handleDelete = async fieldId => {
Modal.confirm({ Modal.confirm({
title: '确认删除', title: '确认删除',
content: '确定要删除这个字段吗?', content: '确定要删除这个字段吗?',
@@ -303,126 +329,149 @@ function DeviceFieldManagement() {
message.error('字段删除失败'); message.error('字段删除失败');
console.error('字段删除失败:', error); console.error('字段删除失败:', error);
} }
} },
}); });
}; };
const getFieldTypeIcon = (type) => { const getFieldTypeIcon = type => {
const iconMap = { const iconMap = {
string: <FontSizeOutlined />, string: <FontSizeOutlined />,
number: <NumberOutlined />, number: <NumberOutlined />,
boolean: <CheckCircleOutlined />, boolean: <CheckCircleOutlined />,
select: <AppstoreOutlined />, select: <AppstoreOutlined />,
date: <CalendarOutlined />, date: <CalendarOutlined />,
textarea: <FileTextOutlined /> textarea: <FileTextOutlined />,
}; };
return iconMap[type] || <FontSizeOutlined />; return iconMap[type] || <FontSizeOutlined />;
}; };
const columns = useMemo(() => [ const columns = useMemo(
{ () => [
title: '字段名称', {
dataIndex: 'fieldName', title: '字段名称',
key: 'fieldName', dataIndex: 'fieldName',
width: 150, key: 'fieldName',
render: (text, record) => ( width: 150,
<Space> render: (text, record) => (
<span style={tableCellStyle}>{text}</span> <Space>
{record.isSystem && ( <span style={tableCellStyle}>{text}</span>
<Tooltip title="系统字段,不可删除"> {record.isSystem && (
<LockOutlined style={{ color: '#f59e0b', fontSize: '14px' }} /> <Tooltip title="系统字段,不可删除">
</Tooltip> <LockOutlined style={{ color: '#f59e0b', fontSize: '14px' }} />
)} </Tooltip>
</Space> )}
) </Space>
}, ),
{ },
title: '显示名称', {
dataIndex: 'displayName', title: '显示名称',
key: 'displayName', dataIndex: 'displayName',
width: 120, key: 'displayName',
}, width: 120,
{ },
title: '字段类型', {
dataIndex: 'fieldType', title: '字段类型',
key: 'fieldType', dataIndex: 'fieldType',
width: 110, key: 'fieldType',
render: (type) => { width: 110,
const config = FIELD_TYPE_MAP[type] || { text: type, color: designTokens.colors.text.tertiary }; render: type => {
return ( const config = FIELD_TYPE_MAP[type] || {
<Tag style={{ ...typeTagStyle, background: `${config.color}15`, color: config.color }}> text: type,
{getFieldTypeIcon(type)} color: designTokens.colors.text.tertiary,
<span style={{ marginLeft: '4px' }}>{config.text}</span> };
</Tag> 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) => ( title: '必填',
<span style={{ dataIndex: 'required',
color: required ? designTokens.colors.success.main : designTokens.colors.text.tertiary, key: 'required',
...tableCellStyle width: 80,
}}> render: required => (
{required ? '是' : '否'} <span
</span> style={{
) color: required
}, ? designTokens.colors.success.main
{ : designTokens.colors.text.tertiary,
title: '可见', ...tableCellStyle,
dataIndex: 'visible', }}
key: 'visible', >
width: 80, {required ? '是' : '否'}
render: (visible) => ( </span>
<span style={{ ),
color: visible ? designTokens.colors.primary.main : designTokens.colors.text.tertiary, },
...tableCellStyle {
}}> title: '可见',
{visible ? '是' : '否'} dataIndex: 'visible',
</span> key: 'visible',
) width: 80,
}, render: visible => (
{ <span
title: '顺序', style={{
dataIndex: 'order', color: visible ? designTokens.colors.primary.main : designTokens.colors.text.tertiary,
key: 'order', ...tableCellStyle,
width: 80, }}
render: (order) => <span style={orderBadgeStyle}>{order}</span> >
}, {visible ? '是' : '否'}
{ </span>
title: '操作', ),
key: 'action', },
width: 160, {
fixed: 'right', title: '顺序',
render: (_, record) => ( dataIndex: 'order',
<Space size="small"> key: 'order',
<Button type="text" icon={<EditOutlined />} onClick={() => showModal(record)} style={editButtonStyle}> width: 80,
编辑 render: order => <span style={orderBadgeStyle}>{order}</span>,
</Button> },
{record.isSystem ? ( {
<Tooltip title="系统字段不可删除"> 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 <Button
type="text" type="text"
danger danger
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
disabled onClick={() => handleDelete(record.fieldId)}
style={{ ...deleteButtonStyle, opacity: 0.3, cursor: 'not-allowed' }} style={deleteButtonStyle}
> >
删除 删除
</Button> </Button>
</Tooltip> )}
) : ( </Space>
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.fieldId)} style={deleteButtonStyle}> ),
删除 },
</Button> ],
)} []
</Space> );
),
},
], []);
return ( return (
<div style={pageContainerStyle}> <div style={pageContainerStyle}>
@@ -431,7 +480,12 @@ function DeviceFieldManagement() {
<AppstoreOutlined style={titleIconStyle} /> <AppstoreOutlined style={titleIconStyle} />
设备字段管理 设备字段管理
</div> </div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()} style={primaryActionStyle}> <Button
type="primary"
icon={<PlusOutlined />}
onClick={() => showModal()}
style={primaryActionStyle}
>
添加字段 添加字段
</Button> </Button>
</div> </div>
@@ -443,11 +497,11 @@ function DeviceFieldManagement() {
rowKey="fieldId" rowKey="fieldId"
loading={loading} loading={loading}
pagination={pagination} pagination={pagination}
onChange={(newPagination) => { onChange={newPagination => {
setPagination({ setPagination({
...pagination, ...pagination,
current: newPagination.current, current: newPagination.current,
pageSize: newPagination.pageSize pageSize: newPagination.pageSize,
}); });
}} }}
scroll={{ x: 900 }} scroll={{ x: 900 }}
@@ -488,7 +542,9 @@ function DeviceFieldManagement() {
> >
<Select placeholder="请选择字段类型"> <Select placeholder="请选择字段类型">
{FIELD_TYPE_OPTIONS.map(opt => ( {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> </Select>
</Form.Item> </Form.Item>
@@ -526,13 +582,19 @@ function DeviceFieldManagement() {
label={<span style={formLabelStyle}>选项配置JSON格式</span>} label={<span style={formLabelStyle}>选项配置JSON格式</span>}
tooltip="格式示例:[{value: 'option1', label: '选项1'}],仅下拉选择类型需要配置" 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>
<Form.Item style={formActionsStyle}> <Form.Item style={formActionsStyle}>
<Space> <Space>
<Button onClick={handleCancel}>取消</Button> <Button onClick={handleCancel}>取消</Button>
<Button type="primary" htmlType="submit">确定</Button> <Button type="primary" htmlType="submit">
确定
</Button>
</Space> </Space>
</Form.Item> </Form.Item>
</Form> </Form>
File diff suppressed because it is too large Load Diff
+59 -74
View File
@@ -6,7 +6,7 @@ import {
MailOutlined, MailOutlined,
PhoneOutlined, PhoneOutlined,
SafetyCertificateOutlined, SafetyCertificateOutlined,
RobotOutlined RobotOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
@@ -40,7 +40,7 @@ const Login = () => {
} }
}; };
const onFinishLogin = async (values) => { const onFinishLogin = async values => {
setLoading(true); setLoading(true);
try { try {
const result = await login(values.username, values.password); const result = await login(values.username, values.password);
@@ -61,7 +61,7 @@ const Login = () => {
} }
}; };
const onFinishUnlock = async (values) => { const onFinishUnlock = async values => {
setLoading(true); setLoading(true);
try { try {
const response = await authAPI.unlock(values); 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) { if (values.password !== values.confirmPassword) {
message.error('两次输入的密码不一致'); message.error('两次输入的密码不一致');
return; return;
@@ -91,7 +91,7 @@ const Login = () => {
password: values.password, password: values.password,
email: values.email, email: values.email,
phone: values.phone, phone: values.phone,
realName: values.realName realName: values.realName,
}); });
if (result.success) { if (result.success) {
@@ -123,14 +123,14 @@ const Login = () => {
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #6B8DD6 100%)', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #6B8DD6 100%)',
padding: '24px', padding: '24px',
position: 'relative', position: 'relative',
overflow: 'hidden' overflow: 'hidden',
}; };
const backgroundDecorationStyle = { const backgroundDecorationStyle = {
position: 'absolute', position: 'absolute',
borderRadius: '50%', borderRadius: '50%',
filter: 'blur(80px)', filter: 'blur(80px)',
opacity: '0.3' opacity: '0.3',
}; };
const cardStyle = { 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)', 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)', background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(20px)', backdropFilter: 'blur(20px)',
border: '1px solid rgba(255, 255, 255, 0.3)' border: '1px solid rgba(255, 255, 255, 0.3)',
}; };
const headerStyle = { const headerStyle = {
textAlign: 'center', textAlign: 'center',
marginBottom: '32px', marginBottom: '32px',
paddingTop: '8px' paddingTop: '8px',
}; };
const iconContainerStyle = { const iconContainerStyle = {
@@ -158,7 +158,7 @@ const Login = () => {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
margin: '0 auto 20px', 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 = { const titleStyle = {
@@ -167,22 +167,22 @@ const Login = () => {
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
WebkitBackgroundClip: 'text', WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent', WebkitTextFillColor: 'transparent',
marginBottom: '8px' marginBottom: '8px',
}; };
const subtitleStyle = { const subtitleStyle = {
fontSize: '14px', fontSize: '14px',
color: '#8c8c8c' color: '#8c8c8c',
}; };
const formStyle = { const formStyle = {
marginTop: '24px' marginTop: '24px',
}; };
const inputStyle = { const inputStyle = {
borderRadius: '8px', borderRadius: '8px',
height: '48px', height: '48px',
border: '1px solid #e8e8e8' border: '1px solid #e8e8e8',
}; };
const submitButtonStyle = { const submitButtonStyle = {
@@ -194,13 +194,13 @@ const Login = () => {
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none', border: 'none',
boxShadow: '0 4px 15px rgba(102, 126, 234, 0.4)', boxShadow: '0 4px 15px rgba(102, 126, 234, 0.4)',
transition: 'all 0.3s ease' transition: 'all 0.3s ease',
}; };
const footerStyle = { const footerStyle = {
textAlign: 'center', textAlign: 'center',
marginTop: '24px', marginTop: '24px',
paddingBottom: '16px' paddingBottom: '16px',
}; };
const toggleButtonStyle = { const toggleButtonStyle = {
@@ -208,32 +208,36 @@ const Login = () => {
fontWeight: '500', fontWeight: '500',
padding: '4px 8px', padding: '4px 8px',
borderRadius: '4px', borderRadius: '4px',
transition: 'all 0.3s ease' transition: 'all 0.3s ease',
}; };
const inputPrefixStyle = { const inputPrefixStyle = {
color: '#667eea', color: '#667eea',
fontSize: '18px' fontSize: '18px',
}; };
return ( return (
<div style={containerStyle}> <div style={containerStyle}>
<div style={{ <div
...backgroundDecorationStyle, style={{
width: '400px', ...backgroundDecorationStyle,
height: '400px', width: '400px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', height: '400px',
top: '-100px', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
right: '-100px' top: '-100px',
}} /> right: '-100px',
<div style={{ }}
...backgroundDecorationStyle, />
width: '300px', <div
height: '300px', style={{
background: 'linear-gradient(135deg, #764ba2 0%, #6B8DD6 100%)', ...backgroundDecorationStyle,
bottom: '-50px', width: '300px',
left: '-50px' height: '300px',
}} /> background: 'linear-gradient(135deg, #764ba2 0%, #6B8DD6 100%)',
bottom: '-50px',
left: '-50px',
}}
/>
<Card style={cardStyle}> <Card style={cardStyle}>
<div style={headerStyle}> <div style={headerStyle}>
@@ -244,8 +248,11 @@ const Login = () => {
{isFirstUser ? '创建管理员账户' : unlockMode ? '账户解锁' : 'IDC设备管理系统'} {isFirstUser ? '创建管理员账户' : unlockMode ? '账户解锁' : 'IDC设备管理系统'}
</Title> </Title>
<Text style={subtitleStyle}> <Text style={subtitleStyle}>
{isFirstUser ? '首次使用,请创建系统管理员账户' : {isFirstUser
unlockMode ? '输入账户信息以解锁账户' : '安全登录您的账户'} ? '首次使用,请创建系统管理员账户'
: unlockMode
? '输入账户信息以解锁账户'
: '安全登录您的账户'}
</Text> </Text>
</div> </div>
@@ -272,7 +279,7 @@ const Login = () => {
rules={[ rules={[
{ required: true, message: '请输入用户名' }, { required: true, message: '请输入用户名' },
{ min: 3, max: 20, message: '用户名长度必须在3-20个字符之间' }, { min: 3, max: 20, message: '用户名长度必须在3-20个字符之间' },
{ pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线' } { pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线' },
]} ]}
> >
<Input <Input
@@ -282,10 +289,7 @@ const Login = () => {
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item name="realName" rules={[{ required: true, message: '请输入真实姓名' }]}>
name="realName"
rules={[{ required: true, message: '请输入真实姓名' }]}
>
<Input <Input
prefix={<SafetyCertificateOutlined style={inputPrefixStyle} />} prefix={<SafetyCertificateOutlined style={inputPrefixStyle} />}
placeholder="真实姓名" placeholder="真实姓名"
@@ -297,7 +301,7 @@ const Login = () => {
name="email" name="email"
rules={[ rules={[
{ required: true, message: '请输入邮箱' }, { required: true, message: '请输入邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' } { type: 'email', message: '请输入有效的邮箱地址' },
]} ]}
> >
<Input <Input
@@ -307,9 +311,7 @@ const Login = () => {
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item name="phone">
name="phone"
>
<Input <Input
prefix={<PhoneOutlined style={inputPrefixStyle} />} prefix={<PhoneOutlined style={inputPrefixStyle} />}
placeholder="手机号(可选)" placeholder="手机号(可选)"
@@ -321,7 +323,7 @@ const Login = () => {
name="password" name="password"
rules={[ rules={[
{ required: true, message: '请输入密码' }, { required: true, message: '请输入密码' },
{ min: 6, message: '密码长度不能少于6个字符' } { min: 6, message: '密码长度不能少于6个字符' },
]} ]}
> >
<Input.Password <Input.Password
@@ -363,10 +365,7 @@ const Login = () => {
style={{ marginBottom: '24px', borderRadius: '8px' }} style={{ marginBottom: '24px', borderRadius: '8px' }}
/> />
<Form.Item <Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input <Input
prefix={<UserOutlined style={inputPrefixStyle} />} prefix={<UserOutlined style={inputPrefixStyle} />}
placeholder="用户名" placeholder="用户名"
@@ -374,10 +373,7 @@ const Login = () => {
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password <Input.Password
prefix={<LockOutlined style={inputPrefixStyle} />} prefix={<LockOutlined style={inputPrefixStyle} />}
placeholder="密码" placeholder="密码"
@@ -387,10 +383,7 @@ const Login = () => {
</> </>
) : ( ) : (
<> <>
<Form.Item <Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input <Input
prefix={<UserOutlined style={inputPrefixStyle} />} prefix={<UserOutlined style={inputPrefixStyle} />}
placeholder="用户名" placeholder="用户名"
@@ -398,10 +391,7 @@ const Login = () => {
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password <Input.Password
prefix={<LockOutlined style={inputPrefixStyle} />} prefix={<LockOutlined style={inputPrefixStyle} />}
placeholder="密码" placeholder="密码"
@@ -412,12 +402,7 @@ const Login = () => {
)} )}
<Form.Item style={{ marginBottom: '16px', marginTop: '24px' }}> <Form.Item style={{ marginBottom: '16px', marginTop: '24px' }}>
<Button <Button type="primary" htmlType="submit" loading={loading} style={submitButtonStyle}>
type="primary"
htmlType="submit"
loading={loading}
style={submitButtonStyle}
>
{registerMode ? '立即注册' : unlockMode ? '解 锁' : '登 录'} {registerMode ? '立即注册' : unlockMode ? '解 锁' : '登 录'}
</Button> </Button>
</Form.Item> </Form.Item>
@@ -435,10 +420,10 @@ const Login = () => {
type="link" type="link"
size="small" size="small"
style={toggleButtonStyle} style={toggleButtonStyle}
onMouseEnter={(e) => { onMouseEnter={e => {
e.target.style.background = 'rgba(102, 126, 234, 0.1)'; e.target.style.background = 'rgba(102, 126, 234, 0.1)';
}} }}
onMouseLeave={(e) => { onMouseLeave={e => {
e.target.style.background = 'transparent'; e.target.style.background = 'transparent';
}} }}
onClick={() => setUnlockMode(false)} onClick={() => setUnlockMode(false)}
@@ -452,10 +437,10 @@ const Login = () => {
type="link" type="link"
size="small" size="small"
style={toggleButtonStyle} style={toggleButtonStyle}
onMouseEnter={(e) => { onMouseEnter={e => {
e.target.style.background = 'rgba(102, 126, 234, 0.1)'; e.target.style.background = 'rgba(102, 126, 234, 0.1)';
}} }}
onMouseLeave={(e) => { onMouseLeave={e => {
e.target.style.background = 'transparent'; e.target.style.background = 'transparent';
}} }}
onClick={() => setRegisterMode(!registerMode)} onClick={() => setRegisterMode(!registerMode)}
@@ -466,10 +451,10 @@ const Login = () => {
type="link" type="link"
size="small" size="small"
style={toggleButtonStyle} style={toggleButtonStyle}
onMouseEnter={(e) => { onMouseEnter={e => {
e.target.style.background = 'rgba(102, 126, 234, 0.1)'; e.target.style.background = 'rgba(102, 126, 234, 0.1)';
}} }}
onMouseLeave={(e) => { onMouseLeave={e => {
e.target.style.background = 'transparent'; e.target.style.background = 'transparent';
}} }}
onClick={() => setUnlockMode(true)} onClick={() => setUnlockMode(true)}
+189 -136
View File
@@ -1,6 +1,46 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Table, Button, Modal, Form, Input, Select, message, Card, Space, Popconfirm, Tag, Tooltip, InputNumber, Collapse, Empty, Spin, Upload, Progress, Checkbox, Tabs, Badge, List, Typography } from 'antd'; import {
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined, ExportOutlined, ImportOutlined, DownloadOutlined, UploadOutlined as UploadIcon, AppstoreOutlined, UnorderedListOutlined, FilterOutlined, EyeOutlined, CompressOutlined, CloudServerOutlined } from '@ant-design/icons'; Table,
Button,
Modal,
Form,
Input,
Select,
message,
Card,
Space,
Popconfirm,
Tag,
Tooltip,
InputNumber,
Collapse,
Empty,
Spin,
Upload,
Progress,
Checkbox,
Tabs,
Badge,
List,
Typography,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SearchOutlined,
ReloadOutlined,
ExportOutlined,
ImportOutlined,
DownloadOutlined,
UploadOutlined as UploadIcon,
AppstoreOutlined,
UnorderedListOutlined,
FilterOutlined,
EyeOutlined,
CompressOutlined,
CloudServerOutlined,
} from '@ant-design/icons';
import axios from 'axios'; import axios from 'axios';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import Papa from 'papaparse'; import Papa from 'papaparse';
@@ -19,35 +59,35 @@ const designTokens = {
main: '#667eea', main: '#667eea',
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
light: '#8b9ff0', light: '#8b9ff0',
dark: '#4f5db8' dark: '#4f5db8',
}, },
success: { success: {
main: '#10b981', main: '#10b981',
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
light: '#34d399', light: '#34d399',
dark: '#047857' dark: '#047857',
}, },
warning: { warning: {
main: '#f59e0b', main: '#f59e0b',
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)', gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
light: '#fbbf24', light: '#fbbf24',
dark: '#b45309' dark: '#b45309',
}, },
error: { error: {
main: '#ef4444', main: '#ef4444',
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)', gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
light: '#f87171', light: '#f87171',
dark: '#b91c1c' dark: '#b91c1c',
} },
}, },
borderRadius: { borderRadius: {
small: '6px', small: '6px',
medium: '10px', medium: '10px',
large: '16px' large: '16px',
}, },
shadows: { 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 PortManagement() { function PortManagement() {
@@ -60,7 +100,7 @@ function PortManagement() {
deviceId: '', deviceId: '',
status: 'all', status: 'all',
portType: 'all', portType: 'all',
portSpeed: 'all' portSpeed: 'all',
}); });
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const [editingPort, setEditingPort] = useState(null); const [editingPort, setEditingPort] = useState(null);
@@ -81,7 +121,7 @@ function PortManagement() {
const [panelFilters, setPanelFilters] = useState({ const [panelFilters, setPanelFilters] = useState({
deviceType: 'all', deviceType: 'all',
searchText: '', searchText: '',
showOnlyOccupied: false showOnlyOccupied: false,
}); });
const [visibleDeviceCount, setVisibleDeviceCount] = useState(10); const [visibleDeviceCount, setVisibleDeviceCount] = useState(10);
const [expandedDevices, setExpandedDevices] = useState({}); const [expandedDevices, setExpandedDevices] = useState({});
@@ -96,7 +136,7 @@ function PortManagement() {
try { try {
setLoading(true); setLoading(true);
const params = { const params = {
pageSize: 1000 // 获取所有端口,不分页 pageSize: 1000, // 获取所有端口,不分页
}; };
if (filters.deviceId) params.deviceId = filters.deviceId; if (filters.deviceId) params.deviceId = filters.deviceId;
@@ -146,7 +186,7 @@ function PortManagement() {
if (!grouped[deviceId]) { if (!grouped[deviceId]) {
grouped[deviceId] = { grouped[deviceId] = {
device: devices.find(d => d.deviceId === deviceId), device: devices.find(d => d.deviceId === deviceId),
ports: [] ports: [],
}; };
} }
grouped[deviceId].ports.push(port); grouped[deviceId].ports.push(port);
@@ -155,7 +195,7 @@ function PortManagement() {
// 对每个设备的端口按名称升序排序 // 对每个设备的端口按名称升序排序
Object.keys(grouped).forEach(deviceId => { Object.keys(grouped).forEach(deviceId => {
grouped[deviceId].ports.sort((a, b) => { grouped[deviceId].ports.sort((a, b) => {
const extractNumbers = (str) => { const extractNumbers = str => {
const matches = str.match(/\d+/g); const matches = str.match(/\d+/g);
return matches ? matches.map(Number) : []; return matches ? matches.map(Number) : [];
}; };
@@ -182,7 +222,7 @@ function PortManagement() {
deviceId: '', deviceId: '',
status: 'all', status: 'all',
portType: 'all', portType: 'all',
portSpeed: 'all' portSpeed: 'all',
}); });
}; };
@@ -192,24 +232,24 @@ function PortManagement() {
setModalVisible(true); setModalVisible(true);
}; };
const handleAddPortForDevice = (device) => { const handleAddPortForDevice = device => {
setEditingPort(null); setEditingPort(null);
form.resetFields(); form.resetFields();
// 自动选中当前设备 // 自动选中当前设备
form.setFieldsValue({ form.setFieldsValue({
deviceId: device.deviceId deviceId: device.deviceId,
}); });
setModalVisible(true); setModalVisible(true);
}; };
// 打开网卡管理模态框 // 打开网卡管理模态框
const handleManageNetworkCards = (device) => { const handleManageNetworkCards = device => {
setSelectedDeviceForNic(device); setSelectedDeviceForNic(device);
setNetworkCardModalVisible(true); setNetworkCardModalVisible(true);
}; };
// 打开添加网卡模态框 // 打开添加网卡模态框
const handleAddNetworkCard = (device) => { const handleAddNetworkCard = device => {
setSelectedDeviceForNic(device); setSelectedDeviceForNic(device);
setPortCreateModalVisible(true); setPortCreateModalVisible(true);
}; };
@@ -227,7 +267,7 @@ function PortManagement() {
fetchPorts(); fetchPorts();
}; };
const handleEdit = (port) => { const handleEdit = port => {
setEditingPort(port); setEditingPort(port);
form.setFieldsValue({ form.setFieldsValue({
portId: port.portId, portId: port.portId,
@@ -237,12 +277,12 @@ function PortManagement() {
portSpeed: port.portSpeed, portSpeed: port.portSpeed,
status: port.status, status: port.status,
vlanId: port.vlanId, vlanId: port.vlanId,
description: port.description description: port.description,
}); });
setModalVisible(true); setModalVisible(true);
}; };
const handleDelete = async (portId) => { const handleDelete = async portId => {
try { try {
await axios.delete(`/api/device-ports/${portId}`); await axios.delete(`/api/device-ports/${portId}`);
message.success('删除成功'); message.success('删除成功');
@@ -254,14 +294,15 @@ function PortManagement() {
}; };
// 解析端口名称范围,例如 "1/0/1-1/0/48" -> ["1/0/1", "1/0/2", ..., "1/0/48"] // 解析端口名称范围,例如 "1/0/1-1/0/48" -> ["1/0/1", "1/0/2", ..., "1/0/48"]
const parsePortRange = (portName) => { const parsePortRange = portName => {
const rangeMatch = portName.match(/^(.*?)\/(\d+)-\1\/(\d+)$/); const rangeMatch = portName.match(/^(.*?)\/(\d+)-\1\/(\d+)$/);
if (rangeMatch) { if (rangeMatch) {
const prefix = rangeMatch[1]; const prefix = rangeMatch[1];
const start = parseInt(rangeMatch[2]); const start = parseInt(rangeMatch[2]);
const end = parseInt(rangeMatch[3]); const end = parseInt(rangeMatch[3]);
if (start <= end && end - start < 100) { // 限制最多100个端口 if (start <= end && end - start < 100) {
// 限制最多100个端口
return Array.from({ length: end - start + 1 }, (_, i) => `${prefix}/${start + i}`); return Array.from({ length: end - start + 1 }, (_, i) => `${prefix}/${start + i}`);
} }
} }
@@ -289,7 +330,7 @@ function PortManagement() {
portSpeed: values.portSpeed, portSpeed: values.portSpeed,
status: values.status, status: values.status,
vlanId: values.vlanId, vlanId: values.vlanId,
description: values.description description: values.description,
})); }));
const response = await axios.post('/api/device-ports/batch', { ports: portsData }); const response = await axios.post('/api/device-ports/batch', { ports: portsData });
@@ -322,12 +363,12 @@ function PortManagement() {
setImportProgress({ current: 0, total: 0 }); setImportProgress({ current: 0, total: 0 });
}; };
const handleFileUpload = (info) => { const handleFileUpload = info => {
const { file } = info; const { file } = info;
setImportFileList([file]); setImportFileList([file]);
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async (e) => { reader.onload = async e => {
try { try {
const data = e.target.result; const data = e.target.result;
let parsedData = []; let parsedData = [];
@@ -341,9 +382,9 @@ function PortManagement() {
Papa.parse(data, { Papa.parse(data, {
header: true, header: true,
skipEmptyLines: true, skipEmptyLines: true,
complete: (results) => { complete: results => {
parsedData = results.data; parsedData = results.data;
} },
}); });
} else { } else {
message.error('不支持的文件格式,请上传 .xlsx 或 .csv 文件'); message.error('不支持的文件格式,请上传 .xlsx 或 .csv 文件');
@@ -362,7 +403,7 @@ function PortManagement() {
reader.readAsBinaryString(file); reader.readAsBinaryString(file);
}; };
const validateImportData = async (data) => { const validateImportData = async data => {
const validatedData = []; const validatedData = [];
const errors = []; const errors = [];
@@ -430,9 +471,9 @@ function PortManagement() {
try { try {
const statusMap = { const statusMap = {
'空闲': 'free', 空闲: 'free',
'占用': 'occupied', 占用: 'occupied',
'故障': 'fault' 故障: 'fault',
}; };
const portsData = importPreview.map((row, index) => ({ const portsData = importPreview.map((row, index) => ({
@@ -443,7 +484,7 @@ function PortManagement() {
portSpeed: row['端口速率'], portSpeed: row['端口速率'],
status: statusMap[row['状态']] || 'free', status: statusMap[row['状态']] || 'free',
vlanId: row['VLAN ID'], vlanId: row['VLAN ID'],
description: row['描述'] description: row['描述'],
})); }));
const response = await axios.post('/api/device-ports/batch', { ports: portsData }); const response = await axios.post('/api/device-ports/batch', { ports: portsData });
@@ -473,14 +514,14 @@ function PortManagement() {
const handleDownloadTemplate = () => { const handleDownloadTemplate = () => {
const templateData = [ const templateData = [
{ {
'设备ID': 'DEV001', 设备ID: 'DEV001',
'端口名称': 'eth0/1', 端口名称: 'eth0/1',
'端口类型': 'RJ45', 端口类型: 'RJ45',
'端口速率': '1G', 端口速率: '1G',
'状态': '空闲', 状态: '空闲',
'VLAN ID': '100', 'VLAN ID': '100',
'描述': '示例端口' 描述: '示例端口',
} },
]; ];
const worksheet = XLSX.utils.json_to_sheet(templateData); const worksheet = XLSX.utils.json_to_sheet(templateData);
@@ -489,27 +530,27 @@ function PortManagement() {
XLSX.writeFile(workbook, '端口导入模板.xlsx'); XLSX.writeFile(workbook, '端口导入模板.xlsx');
}; };
const getStatusTag = (status) => { const getStatusTag = status => {
const statusMap = { const statusMap = {
'free': { color: 'success', text: '空闲' }, free: { color: 'success', text: '空闲' },
'occupied': { color: 'processing', text: '占用' }, occupied: { color: 'processing', text: '占用' },
'fault': { color: 'error', text: '故障' }, fault: { color: 'error', text: '故障' },
'空闲': { color: 'success', text: '空闲' }, 空闲: { color: 'success', text: '空闲' },
'占用': { color: 'processing', text: '占用' }, 占用: { color: 'processing', text: '占用' },
'故障': { color: 'error', text: '故障' } 故障: { color: 'error', text: '故障' },
}; };
const config = statusMap[status] || { color: 'default', text: status }; const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>; return <Tag color={config.color}>{config.text}</Tag>;
}; };
const getPortTypeTag = (type) => { const getPortTypeTag = type => {
const typeMap = { const typeMap = {
'RJ45': { color: 'blue', text: 'RJ45' }, RJ45: { color: 'blue', text: 'RJ45' },
'SFP': { color: 'green', text: 'SFP' }, SFP: { color: 'green', text: 'SFP' },
'SFP+': { color: 'cyan', text: 'SFP+' }, 'SFP+': { color: 'cyan', text: 'SFP+' },
'SFP28': { color: 'purple', text: 'SFP28' }, SFP28: { color: 'purple', text: 'SFP28' },
'QSFP': { color: 'orange', text: 'QSFP' }, QSFP: { color: 'orange', text: 'QSFP' },
'QSFP28': { color: 'red', text: 'QSFP28' } QSFP28: { color: 'red', text: 'QSFP28' },
}; };
const config = typeMap[type] || { color: 'default', text: type }; const config = typeMap[type] || { color: 'default', text: type };
return <Tag color={config.color}>{config.text}</Tag>; return <Tag color={config.color}>{config.text}</Tag>;
@@ -520,45 +561,45 @@ function PortManagement() {
title: '端口名称', title: '端口名称',
dataIndex: 'portName', dataIndex: 'portName',
key: 'portName', key: 'portName',
width: 120 width: 120,
}, },
{ {
title: '端口类型', title: '端口类型',
dataIndex: 'portType', dataIndex: 'portType',
key: 'portType', key: 'portType',
width: 100, width: 100,
render: (type) => getPortTypeTag(type) render: type => getPortTypeTag(type),
}, },
{ {
title: '端口速率', title: '端口速率',
dataIndex: 'portSpeed', dataIndex: 'portSpeed',
key: 'portSpeed', key: 'portSpeed',
width: 100 width: 100,
}, },
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
key: 'status', key: 'status',
width: 100, width: 100,
render: (status) => getStatusTag(status) render: status => getStatusTag(status),
}, },
{ {
title: 'VLAN ID', title: 'VLAN ID',
dataIndex: 'vlanId', dataIndex: 'vlanId',
key: 'vlanId', key: 'vlanId',
width: 100, width: 100,
render: (vlanId) => vlanId || '-' render: vlanId => vlanId || '-',
}, },
{ {
title: '描述', title: '描述',
dataIndex: 'description', dataIndex: 'description',
key: 'description', key: 'description',
ellipsis: true, ellipsis: true,
render: (text) => ( render: text => (
<Tooltip title={text}> <Tooltip title={text}>
<span>{text || '-'}</span> <span>{text || '-'}</span>
</Tooltip> </Tooltip>
) ),
}, },
{ {
title: '操作', title: '操作',
@@ -581,18 +622,13 @@ function PortManagement() {
okText="确定" okText="确定"
cancelText="取消" cancelText="取消"
> >
<Button <Button type="link" size="small" danger icon={<DeleteOutlined />}>
type="link"
size="small"
danger
icon={<DeleteOutlined />}
>
删除 删除
</Button> </Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
) ),
} },
]; ];
return ( return (
@@ -601,7 +637,7 @@ function PortManagement() {
style={{ style={{
borderRadius: designTokens.borderRadius.large, borderRadius: designTokens.borderRadius.large,
boxShadow: designTokens.shadows.medium, boxShadow: designTokens.shadows.medium,
marginBottom: 16 marginBottom: 16,
}} }}
> >
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
@@ -610,7 +646,7 @@ function PortManagement() {
placeholder="选择设备" placeholder="选择设备"
style={{ width: 200 }} style={{ width: 200 }}
value={filters.deviceId || undefined} value={filters.deviceId || undefined}
onChange={(value) => setFilters(prev => ({ ...prev, deviceId: value }))} onChange={value => setFilters(prev => ({ ...prev, deviceId: value }))}
allowClear allowClear
showSearch showSearch
filterOption={(input, option) => { filterOption={(input, option) => {
@@ -631,7 +667,7 @@ function PortManagement() {
placeholder="端口类型" placeholder="端口类型"
style={{ width: 120 }} style={{ width: 120 }}
value={filters.portType} value={filters.portType}
onChange={(value) => setFilters(prev => ({ ...prev, portType: value }))} onChange={value => setFilters(prev => ({ ...prev, portType: value }))}
> >
<Option value="all">全部</Option> <Option value="all">全部</Option>
<Option value="RJ45">RJ45</Option> <Option value="RJ45">RJ45</Option>
@@ -646,7 +682,7 @@ function PortManagement() {
placeholder="端口速率" placeholder="端口速率"
style={{ width: 120 }} style={{ width: 120 }}
value={filters.portSpeed} value={filters.portSpeed}
onChange={(value) => setFilters(prev => ({ ...prev, portSpeed: value }))} onChange={value => setFilters(prev => ({ ...prev, portSpeed: value }))}
> >
<Option value="all">全部</Option> <Option value="all">全部</Option>
<Option value="100M">100M</Option> <Option value="100M">100M</Option>
@@ -661,7 +697,7 @@ function PortManagement() {
placeholder="状态" placeholder="状态"
style={{ width: 120 }} style={{ width: 120 }}
value={filters.status} value={filters.status}
onChange={(value) => setFilters(prev => ({ ...prev, status: value }))} onChange={value => setFilters(prev => ({ ...prev, status: value }))}
> >
<Option value="all">全部</Option> <Option value="all">全部</Option>
<Option value="free">空闲</Option> <Option value="free">空闲</Option>
@@ -684,7 +720,14 @@ function PortManagement() {
</Space> </Space>
</div> </div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Space> <Space>
<Button <Button
type="primary" type="primary"
@@ -704,9 +747,7 @@ function PortManagement() {
批量导入 批量导入
</Button> </Button>
<Button icon={<ExportOutlined />}> <Button icon={<ExportOutlined />}>导出</Button>
导出
</Button>
</Space> </Space>
<Space> <Space>
@@ -740,13 +781,15 @@ function PortManagement() {
) : viewMode === 'panel' ? ( ) : viewMode === 'panel' ? (
// 面板视图 - 使用虚拟滚动优化 // 面板视图 - 使用虚拟滚动优化
<VirtualDeviceList <VirtualDeviceList
devices={Object.values(groupedPorts).map(g => g.device).filter(Boolean)} devices={Object.values(groupedPorts)
.map(g => g.device)
.filter(Boolean)}
groupedPorts={groupedPorts} groupedPorts={groupedPorts}
cables={cables} cables={cables}
allDevices={devices} allDevices={devices}
onPortClick={(port) => handleEdit(port)} onPortClick={port => handleEdit(port)}
onAddPort={(device) => handleAddPortForDevice(device)} onAddPort={device => handleAddPortForDevice(device)}
onManageNetworkCards={(device) => handleManageNetworkCards(device)} onManageNetworkCards={device => handleManageNetworkCards(device)}
initialVisibleCount={5} initialVisibleCount={5}
loadMoreCount={5} loadMoreCount={5}
/> />
@@ -767,22 +810,35 @@ function PortManagement() {
<Panel <Panel
key={deviceId} key={deviceId}
header={ 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={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ <div
width: '40px', style={{
height: '40px', width: '40px',
borderRadius: designTokens.borderRadius.medium, height: '40px',
background: designTokens.colors.primary.gradient, borderRadius: designTokens.borderRadius.medium,
display: 'flex', background: designTokens.colors.primary.gradient,
alignItems: 'center', display: 'flex',
justifyContent: 'center', alignItems: 'center',
color: '#fff', justifyContent: 'center',
fontSize: '18px' color: '#fff',
}}> fontSize: '18px',
{device?.type?.toLowerCase()?.includes('server') ? '🖥️' : }}
device?.type?.toLowerCase()?.includes('switch') ? '🔀' : >
device?.type?.toLowerCase()?.includes('router') ? '🌐' : '📦'} {device?.type?.toLowerCase()?.includes('server')
? '🖥️'
: device?.type?.toLowerCase()?.includes('switch')
? '🔀'
: device?.type?.toLowerCase()?.includes('router')
? '🌐'
: '📦'}
</div> </div>
<div> <div>
<div style={{ fontWeight: 600, fontSize: '16px', color: '#1e293b' }}> <div style={{ fontWeight: 600, fontSize: '16px', color: '#1e293b' }}>
@@ -804,11 +860,14 @@ function PortManagement() {
type="primary" type="primary"
size="small" size="small"
icon={<CloudServerOutlined />} icon={<CloudServerOutlined />}
onClick={(e) => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
handleManageNetworkCards(device); handleManageNetworkCards(device);
}} }}
style={{ background: designTokens.colors.primary.gradient, border: 'none' }} style={{
background: designTokens.colors.primary.gradient,
border: 'none',
}}
> >
网卡管理 网卡管理
</Button> </Button>
@@ -824,8 +883,8 @@ function PortManagement() {
pagination={{ pagination={{
pageSize: 10, pageSize: 10,
showSizeChanger: true, showSizeChanger: true,
showTotal: (total) => `${total} 个端口`, showTotal: total => `${total} 个端口`,
pageSizeOptions: ['10', '20', '50', '100'] pageSizeOptions: ['10', '20', '50', '100'],
}} }}
size="small" size="small"
scroll={{ x: 1000 }} scroll={{ x: 1000 }}
@@ -877,7 +936,7 @@ function PortManagement() {
name="portName" name="portName"
label="端口名称" label="端口名称"
rules={[{ required: true, message: '请输入端口名称' }]} rules={[{ required: true, message: '请输入端口名称' }]}
extra={!editingPort && "支持批量添加,例如: 1/0/1-1/0/48 将创建 48 个端口"} extra={!editingPort && '支持批量添加,例如: 1/0/1-1/0/48 将创建 48 个端口'}
> >
<Input placeholder="例如: eth0/1 或 1/0/1-1/0/48" /> <Input placeholder="例如: eth0/1 或 1/0/1-1/0/48" />
</Form.Item> </Form.Item>
@@ -927,17 +986,11 @@ function PortManagement() {
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item <Form.Item name="vlanId" label="VLAN ID">
name="vlanId"
label="VLAN ID"
>
<InputNumber placeholder="请输入VLAN ID" min={1} max={4094} /> <InputNumber placeholder="请输入VLAN ID" min={1} max={4094} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item name="description" label="描述">
name="description"
label="描述"
>
<Input.TextArea rows={3} placeholder="请输入描述" /> <Input.TextArea rows={3} placeholder="请输入描述" />
</Form.Item> </Form.Item>
</Form> </Form>
@@ -956,11 +1009,7 @@ function PortManagement() {
<Button key="cancel" onClick={() => setImportModalVisible(false)}> <Button key="cancel" onClick={() => setImportModalVisible(false)}>
取消 取消
</Button>, </Button>,
<Button <Button key="download" icon={<DownloadOutlined />} onClick={handleDownloadTemplate}>
key="download"
icon={<DownloadOutlined />}
onClick={handleDownloadTemplate}
>
下载模板 下载模板
</Button>, </Button>,
<Button <Button
@@ -973,7 +1022,7 @@ function PortManagement() {
style={{ background: designTokens.colors.primary.gradient, border: 'none' }} style={{ background: designTokens.colors.primary.gradient, border: 'none' }}
> >
开始导入 开始导入
</Button> </Button>,
]} ]}
> >
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
@@ -995,10 +1044,10 @@ function PortManagement() {
</div> </div>
<div style={{ display: 'flex', gap: '12px', marginBottom: 16 }}> <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>
<Checkbox checked={updateExisting} onChange={(e) => setUpdateExisting(e.target.checked)}> <Checkbox checked={updateExisting} onChange={e => setUpdateExisting(e.target.checked)}>
更新已存在的端口 更新已存在的端口
</Checkbox> </Checkbox>
</div> </div>
@@ -1006,13 +1055,16 @@ function PortManagement() {
{importPreview.length > 0 && ( {importPreview.length > 0 && (
<> <>
<div style={{ marginBottom: 16 }}> <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> <Text strong>数据预览前10条</Text>
<Button <Button size="small" icon={<DownloadOutlined />} onClick={handleDownloadTemplate}>
size="small"
icon={<DownloadOutlined />}
onClick={handleDownloadTemplate}
>
下载模板 下载模板
</Button> </Button>
</div> </div>
@@ -1022,52 +1074,52 @@ function PortManagement() {
title: '设备ID', title: '设备ID',
dataIndex: '设备ID', dataIndex: '设备ID',
key: 'deviceId', key: 'deviceId',
width: 150 width: 150,
}, },
{ {
title: '端口名称', title: '端口名称',
dataIndex: '端口名称', dataIndex: '端口名称',
key: 'portName', key: 'portName',
width: 120 width: 120,
}, },
{ {
title: '端口类型', title: '端口类型',
dataIndex: '端口类型', dataIndex: '端口类型',
key: 'portType', key: 'portType',
width: 100, width: 100,
render: (type) => getPortTypeTag(type) render: type => getPortTypeTag(type),
}, },
{ {
title: '端口速率', title: '端口速率',
dataIndex: '端口速率', dataIndex: '端口速率',
key: 'portSpeed', key: 'portSpeed',
width: 100 width: 100,
}, },
{ {
title: '状态', title: '状态',
dataIndex: '状态', dataIndex: '状态',
key: 'status', key: 'status',
width: 100, width: 100,
render: (status) => getStatusTag(status) render: status => getStatusTag(status),
}, },
{ {
title: 'VLAN ID', title: 'VLAN ID',
dataIndex: 'VLAN ID', dataIndex: 'VLAN ID',
key: 'vlanId', key: 'vlanId',
width: 100, width: 100,
render: (vlanId) => vlanId || '-' render: vlanId => vlanId || '-',
}, },
{ {
title: '描述', title: '描述',
dataIndex: '描述', dataIndex: '描述',
key: 'description', key: 'description',
ellipsis: true, ellipsis: true,
render: (text) => ( render: text => (
<Tooltip title={text}> <Tooltip title={text}>
<span>{text || '-'}</span> <span>{text || '-'}</span>
</Tooltip> </Tooltip>
) ),
} },
]} ]}
dataSource={importPreview.slice(0, 10)} dataSource={importPreview.slice(0, 10)}
rowKey={(record, index) => index} rowKey={(record, index) => index}
@@ -1094,7 +1146,7 @@ function PortManagement() {
status="active" status="active"
strokeColor={{ strokeColor={{
'0%': designTokens.colors.primary.main, '0%': designTokens.colors.primary.main,
'100%': designTokens.colors.success.main '100%': designTokens.colors.success.main,
}} }}
/> />
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
@@ -1103,7 +1155,8 @@ function PortManagement() {
</Text> </Text>
{importProgress.current > 0 && ( {importProgress.current > 0 && (
<Text type="secondary"> <Text type="secondary">
预计剩余时间{Math.ceil((importProgress.total - importProgress.current) / 5)} 预计剩余时间{Math.ceil((importProgress.total - importProgress.current) / 5)}{' '}
</Text> </Text>
)} )}
</div> </div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+280 -123
View File
@@ -1,15 +1,48 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { import {
Table, Button, Modal, Form, Input, Select, message, Card, Space, Table,
InputNumber, Progress, Drawer, Tag, Tooltip, Dropdown, Badge, Button,
Row, Col, Statistic, Typography, Empty, Spin, Alert, Checkbox Modal,
Form,
Input,
Select,
message,
Card,
Space,
InputNumber,
Progress,
Drawer,
Tag,
Tooltip,
Dropdown,
Badge,
Row,
Col,
Statistic,
Typography,
Empty,
Spin,
Alert,
Checkbox,
} from 'antd'; } from 'antd';
import { import {
PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, PlusOutlined,
SearchOutlined, FilterOutlined, MoreOutlined, EyeOutlined, EditOutlined,
CloudOutlined, EnvironmentOutlined, DashboardOutlined, DeleteOutlined,
ExpandOutlined, CompressOutlined, CheckCircleOutlined, ReloadOutlined,
WarningOutlined, SyncOutlined, DeleteFilled SearchOutlined,
FilterOutlined,
MoreOutlined,
EyeOutlined,
CloudOutlined,
EnvironmentOutlined,
DashboardOutlined,
ExpandOutlined,
CompressOutlined,
CheckCircleOutlined,
WarningOutlined,
SyncOutlined,
DeleteFilled,
} from '@ant-design/icons'; } from '@ant-design/icons';
import axios from 'axios'; import axios from 'axios';
@@ -21,45 +54,45 @@ const designTokens = {
primary: { primary: {
main: '#1890ff', main: '#1890ff',
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', 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: { success: {
main: '#52c41a', main: '#52c41a',
gradient: 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)' gradient: 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)',
}, },
warning: { warning: {
main: '#faad14', main: '#faad14',
gradient: 'linear-gradient(135deg, #faad14 0%, #d48806 100%)' gradient: 'linear-gradient(135deg, #faad14 0%, #d48806 100%)',
}, },
error: { error: {
main: '#ff4d4f', main: '#ff4d4f',
gradient: 'linear-gradient(135deg, #ff4d4f 0%, #cf1322 100%)' gradient: 'linear-gradient(135deg, #ff4d4f 0%, #cf1322 100%)',
}, },
text: { text: {
primary: '#262626', primary: '#262626',
secondary: '#8c8c8c', secondary: '#8c8c8c',
tertiary: '#bfbfbf' tertiary: '#bfbfbf',
} },
}, },
shadows: { shadows: {
small: '0 2px 8px rgba(0, 0, 0, 0.06)', small: '0 2px 8px rgba(0, 0, 0, 0.06)',
medium: '0 4px 16px rgba(0, 0, 0, 0.08)', 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: { borderRadius: {
small: '8px', small: '8px',
medium: '12px', medium: '12px',
large: '16px' large: '16px',
}, },
transitions: { 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 = { const containerStyle = {
minHeight: '100vh', minHeight: '100vh',
background: 'linear-gradient(180deg, #f5f7fa 0%, #e8ecf1 100%)', background: 'linear-gradient(180deg, #f5f7fa 0%, #e8ecf1 100%)',
padding: '24px' padding: '24px',
}; };
const headerStyle = { const headerStyle = {
@@ -68,15 +101,15 @@ const headerStyle = {
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
borderRadius: '20px', borderRadius: '20px',
color: '#fff', 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)', background: 'rgba(255, 255, 255, 0.15)',
borderRadius: designTokens.borderRadius.medium, borderRadius: designTokens.borderRadius.medium,
padding: '16px', padding: '16px',
border: '1px solid rgba(255, 255, 255, 0.2)', border: '1px solid rgba(255, 255, 255, 0.2)',
backdropFilter: 'blur(10px)' backdropFilter: 'blur(10px)',
}); });
const cardStyle = { const cardStyle = {
@@ -84,13 +117,13 @@ const cardStyle = {
border: 'none', border: 'none',
boxShadow: designTokens.shadows.medium, boxShadow: designTokens.shadows.medium,
background: '#fff', background: '#fff',
overflow: 'hidden' overflow: 'hidden',
}; };
const cardHeadStyle = { const cardHeadStyle = {
borderBottom: '1px solid #f0f0f0', borderBottom: '1px solid #f0f0f0',
padding: '16px 24px', padding: '16px 24px',
background: 'linear-gradient(135deg, #f8f9ff 0%, #ffffff 100%)' background: 'linear-gradient(135deg, #f8f9ff 0%, #ffffff 100%)',
}; };
const primaryButtonStyle = { const primaryButtonStyle = {
@@ -99,25 +132,25 @@ const primaryButtonStyle = {
background: designTokens.colors.primary.gradient, background: designTokens.colors.primary.gradient,
border: 'none', border: 'none',
boxShadow: '0 4px 16px rgba(102, 126, 234, 0.35)', boxShadow: '0 4px 16px rgba(102, 126, 234, 0.35)',
fontWeight: '500' fontWeight: '500',
}; };
const actionButtonStyle = { const actionButtonStyle = {
height: '36px', height: '36px',
borderRadius: '8px', borderRadius: '8px',
border: '1px solid #e8e8e8' border: '1px solid #e8e8e8',
}; };
const searchInputStyle = { const searchInputStyle = {
borderRadius: '10px', borderRadius: '10px',
height: '42px', height: '42px',
border: '1px solid #e8e8e8' border: '1px solid #e8e8e8',
}; };
const statusConfig = { const statusConfig = {
active: { text: '在用', color: 'success', icon: <CheckCircleOutlined /> }, active: { text: '在用', color: 'success', icon: <CheckCircleOutlined /> },
maintenance: { text: '维护中', color: 'warning', icon: <SyncOutlined spin /> }, maintenance: { text: '维护中', color: 'warning', icon: <SyncOutlined spin /> },
inactive: { text: '停用', color: 'default', icon: <WarningOutlined /> } inactive: { text: '停用', color: 'default', icon: <WarningOutlined /> },
}; };
const CapacityProgress = ({ used, capacity, color }) => { const CapacityProgress = ({ used, capacity, color }) => {
@@ -130,9 +163,7 @@ const CapacityProgress = ({ used, capacity, color }) => {
<Text style={{ fontSize: '13px', color: designTokens.colors.text.secondary }}> <Text style={{ fontSize: '13px', color: designTokens.colors.text.secondary }}>
{used} / {capacity} {used} / {capacity}
</Text> </Text>
<Text style={{ fontSize: '13px', fontWeight: '600', color }}> <Text style={{ fontSize: '13px', fontWeight: '600', color }}>{percentage.toFixed(1)}%</Text>
{percentage.toFixed(1)}%
</Text>
</div> </div>
<Progress <Progress
percent={percentage} 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', border: selected ? `2px solid ${designTokens.colors.primary.main}` : '1px solid #f0f0f0',
boxShadow: designTokens.shadows.small, boxShadow: designTokens.shadows.small,
transition: `all ${designTokens.transitions.normal}`, transition: `all ${designTokens.transitions.normal}`,
cursor: 'pointer' cursor: 'pointer',
}} }}
onClick={() => onSelect(room.roomId)} onClick={() => onSelect(room.roomId)}
onDoubleClick={() => onView(room)} onDoubleClick={() => onView(room)}
styles={{ body: { padding: '20px' } }} 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>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<CloudOutlined style={{ fontSize: '20px', color: designTokens.colors.primary.main }} /> <CloudOutlined style={{ fontSize: '20px', color: designTokens.colors.primary.main }} />
@@ -173,7 +211,9 @@ const RoomCard = ({ room, onEdit, onDelete, onView, selected, onSelect }) => {
{room.name} {room.name}
</Text> </Text>
</div> </div>
<Text type="secondary" style={{ fontSize: '13px' }}>{room.roomId}</Text> <Text type="secondary" style={{ fontSize: '13px' }}>
{room.roomId}
</Text>
</div> </div>
<Tag color={statusInfo.color} icon={statusInfo.icon} style={{ borderRadius: '20px' }}> <Tag color={statusInfo.color} icon={statusInfo.icon} style={{ borderRadius: '20px' }}>
{statusInfo.text} {statusInfo.text}
@@ -191,21 +231,43 @@ const RoomCard = ({ room, onEdit, onDelete, onView, selected, onSelect }) => {
<CapacityProgress <CapacityProgress
used={rackCount} used={rackCount}
capacity={room.capacity} 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>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: '16px' }}> <div style={{ display: 'flex', gap: '16px' }}>
<div> <div>
<Text type="secondary" style={{ fontSize: '12px' }}>面积</Text> <Text type="secondary" style={{ fontSize: '12px' }}>
<div style={{ fontSize: '14px', fontWeight: '600', color: designTokens.colors.text.primary }}> 面积
</Text>
<div
style={{
fontSize: '14px',
fontWeight: '600',
color: designTokens.colors.text.primary,
}}
>
{room.area} {room.area}
</div> </div>
</div> </div>
<div> <div>
<Text type="secondary" style={{ fontSize: '12px' }}>机柜</Text> <Text type="secondary" style={{ fontSize: '12px' }}>
<div style={{ fontSize: '14px', fontWeight: '600', color: designTokens.colors.text.primary }}> 机柜
</Text>
<div
style={{
fontSize: '14px',
fontWeight: '600',
color: designTokens.colors.text.primary,
}}
>
{rackCount} {rackCount}
</div> </div>
</div> </div>
@@ -215,7 +277,10 @@ const RoomCard = ({ room, onEdit, onDelete, onView, selected, onSelect }) => {
<Button <Button
type="text" type="text"
icon={<EyeOutlined />} icon={<EyeOutlined />}
onClick={(e) => { e.stopPropagation(); onView(room); }} onClick={e => {
e.stopPropagation();
onView(room);
}}
style={{ color: designTokens.colors.text.secondary }} style={{ color: designTokens.colors.text.secondary }}
/> />
</Tooltip> </Tooltip>
@@ -223,7 +288,10 @@ const RoomCard = ({ room, onEdit, onDelete, onView, selected, onSelect }) => {
<Button <Button
type="text" type="text"
icon={<EditOutlined />} icon={<EditOutlined />}
onClick={(e) => { e.stopPropagation(); onEdit(room); }} onClick={e => {
e.stopPropagation();
onEdit(room);
}}
style={{ color: designTokens.colors.primary.main }} style={{ color: designTokens.colors.primary.main }}
/> />
</Tooltip> </Tooltip>
@@ -232,7 +300,10 @@ const RoomCard = ({ room, onEdit, onDelete, onView, selected, onSelect }) => {
type="text" type="text"
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
danger danger
onClick={(e) => { e.stopPropagation(); onDelete(room.roomId); }} onClick={e => {
e.stopPropagation();
onDelete(room.roomId);
}}
/> />
</Tooltip> </Tooltip>
</Space> </Space>
@@ -286,7 +357,7 @@ function RoomManagement() {
setEditingRoom(null); setEditingRoom(null);
}; };
const handleSubmit = async (values) => { const handleSubmit = async values => {
try { try {
if (editingRoom) { if (editingRoom) {
await axios.put(`/api/rooms/${editingRoom.roomId}`, values); await axios.put(`/api/rooms/${editingRoom.roomId}`, values);
@@ -304,7 +375,7 @@ function RoomManagement() {
} }
}; };
const handleDelete = async (roomId) => { const handleDelete = async roomId => {
Modal.confirm({ Modal.confirm({
title: '确认删除', title: '确认删除',
content: '确定要删除这个机房吗?删除后无法恢复。', content: '确定要删除这个机房吗?删除后无法恢复。',
@@ -320,7 +391,7 @@ function RoomManagement() {
message.error('机房删除失败'); message.error('机房删除失败');
console.error('机房删除失败:', error); console.error('机房删除失败:', error);
} }
} },
}); });
}; };
@@ -346,18 +417,19 @@ function RoomManagement() {
message.error('批量删除失败'); message.error('批量删除失败');
console.error('批量删除失败:', error); console.error('批量删除失败:', error);
} }
} },
}); });
}; };
const handleView = (room) => { const handleView = room => {
setViewingRoom(room); setViewingRoom(room);
setDrawerVisible(true); setDrawerVisible(true);
}; };
const filteredRooms = useMemo(() => { const filteredRooms = useMemo(() => {
return rooms.filter(room => { return rooms.filter(room => {
const matchKeyword = !searchKeyword || const matchKeyword =
!searchKeyword ||
room.name?.toLowerCase().includes(searchKeyword.toLowerCase()) || room.name?.toLowerCase().includes(searchKeyword.toLowerCase()) ||
room.roomId?.toLowerCase().includes(searchKeyword.toLowerCase()) || room.roomId?.toLowerCase().includes(searchKeyword.toLowerCase()) ||
room.location?.toLowerCase().includes(searchKeyword.toLowerCase()); room.location?.toLowerCase().includes(searchKeyword.toLowerCase());
@@ -368,14 +440,17 @@ function RoomManagement() {
}); });
}, [rooms, searchKeyword, statusFilter]); }, [rooms, searchKeyword, statusFilter]);
const stats = useMemo(() => ({ const stats = useMemo(
total: rooms.length, () => ({
active: rooms.filter(r => r.status === 'active').length, total: rooms.length,
maintenance: rooms.filter(r => r.status === 'maintenance').length, active: rooms.filter(r => r.status === 'active').length,
inactive: rooms.filter(r => r.status === 'inactive').length, maintenance: rooms.filter(r => r.status === 'maintenance').length,
totalRacks: rooms.reduce((sum, r) => sum + (r.Racks?.length || 0), 0), inactive: rooms.filter(r => r.status === 'inactive').length,
totalCapacity: rooms.reduce((sum, r) => sum + (r.capacity || 0), 0) totalRacks: rooms.reduce((sum, r) => sum + (r.Racks?.length || 0), 0),
}), [rooms]); totalCapacity: rooms.reduce((sum, r) => sum + (r.capacity || 0), 0),
}),
[rooms]
);
const tableColumns = [ const tableColumns = [
{ {
@@ -383,15 +458,17 @@ function RoomManagement() {
key: 'roomInfo', key: 'roomInfo',
render: (_, record) => ( render: (_, record) => (
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ <div
width: '44px', style={{
height: '44px', width: '44px',
borderRadius: '10px', height: '44px',
background: designTokens.colors.primary.bgGradient, borderRadius: '10px',
display: 'flex', background: designTokens.colors.primary.bgGradient,
alignItems: 'center', display: 'flex',
justifyContent: 'center' alignItems: 'center',
}}> justifyContent: 'center',
}}
>
<CloudOutlined style={{ fontSize: '22px', color: designTokens.colors.primary.main }} /> <CloudOutlined style={{ fontSize: '22px', color: designTokens.colors.primary.main }} />
</div> </div>
<div> <div>
@@ -403,18 +480,18 @@ function RoomManagement() {
</div> </div>
</div> </div>
</div> </div>
) ),
}, },
{ {
title: '位置', title: '位置',
dataIndex: 'location', dataIndex: 'location',
key: 'location', key: 'location',
render: (location) => ( render: location => (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<EnvironmentOutlined style={{ color: designTokens.colors.text.tertiary }} /> <EnvironmentOutlined style={{ color: designTokens.colors.text.tertiary }} />
<span>{location}</span> <span>{location}</span>
</div> </div>
) ),
}, },
{ {
title: '面积/容量', title: '面积/容量',
@@ -430,13 +507,13 @@ function RoomManagement() {
/> />
</div> </div>
</div> </div>
) ),
}, },
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
key: 'status', key: 'status',
render: (status) => { render: status => {
const config = statusConfig[status]; const config = statusConfig[status];
return ( return (
<Tag color={config.color} icon={config.icon} style={{ borderRadius: '20px' }}> <Tag color={config.color} icon={config.icon} style={{ borderRadius: '20px' }}>
@@ -447,9 +524,9 @@ function RoomManagement() {
filters: [ filters: [
{ text: '在用', value: 'active' }, { text: '在用', value: 'active' },
{ text: '维护中', value: 'maintenance' }, { text: '维护中', value: 'maintenance' },
{ text: '停用', value: 'inactive' } { text: '停用', value: 'inactive' },
], ],
onFilter: (value, record) => record.status === value onFilter: (value, record) => record.status === value,
}, },
{ {
title: '机柜数', title: '机柜数',
@@ -460,14 +537,14 @@ function RoomManagement() {
<span>{record.Racks?.length || 0}</span> <span>{record.Racks?.length || 0}</span>
</div> </div>
), ),
sorter: (a, b) => (a.Racks?.length || 0) - (b.Racks?.length || 0) sorter: (a, b) => (a.Racks?.length || 0) - (b.Racks?.length || 0),
}, },
{ {
title: '创建时间', title: '创建时间',
dataIndex: 'createdAt', dataIndex: 'createdAt',
key: 'createdAt', key: 'createdAt',
render: (date) => date ? new Date(date).toLocaleString() : '-', render: date => (date ? new Date(date).toLocaleString() : '-'),
sorter: (a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0) sorter: (a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0),
}, },
{ {
title: '操作', title: '操作',
@@ -501,29 +578,44 @@ function RoomManagement() {
/> />
</Tooltip> </Tooltip>
</Space> </Space>
) ),
} },
]; ];
const rowSelection = { const rowSelection = {
selectedRowKeys: selectedRoomIds, selectedRowKeys: selectedRoomIds,
onChange: (selectedRowKeys) => { onChange: selectedRowKeys => {
setSelectedRoomIds(selectedRowKeys); setSelectedRoomIds(selectedRowKeys);
} },
}; };
return ( return (
<div style={containerStyle}> <div style={containerStyle}>
<div style={headerStyle}> <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> <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 /> <CloudOutlined />
机房管理 机房管理
</h1> </h1>
<p style={{ margin: 0, opacity: 0.9, fontSize: '14px' }}> <p style={{ margin: 0, opacity: 0.9, fontSize: '14px' }}>管理和监控所有机房设施</p>
管理和监控所有机房设施
</p>
</div> </div>
<div style={{ display: 'flex', gap: '12px' }}> <div style={{ display: 'flex', gap: '12px' }}>
<div style={statCardStyle()}> <div style={statCardStyle()}>
@@ -532,7 +624,9 @@ function RoomManagement() {
</div> </div>
<div style={statCardStyle()}> <div style={statCardStyle()}>
<Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: '12px' }}>在用机房</Text> <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>
<div style={statCardStyle()}> <div style={statCardStyle()}>
<Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: '12px' }}>总机柜</Text> <Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: '12px' }}>总机柜</Text>
@@ -543,13 +637,22 @@ function RoomManagement() {
</div> </div>
<Card style={cardStyle} styles={{ header: cardHeadStyle, body: { padding: '20px 24px' } }}> <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' }}> <div style={{ display: 'flex', gap: '12px', flex: 1, maxWidth: '600px' }}>
<Input <Input
placeholder="搜索机房名称、ID、位置..." placeholder="搜索机房名称、ID、位置..."
prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />} prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />}
value={searchKeyword} value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)} onChange={e => setSearchKeyword(e.target.value)}
style={searchInputStyle} style={searchInputStyle}
allowClear allowClear
/> />
@@ -625,7 +728,7 @@ function RoomManagement() {
pageSize: 10, pageSize: 10,
showSizeChanger: true, showSizeChanger: true,
showQuickJumper: true, showQuickJumper: true,
showTotal: (total) => `${total} 条记录` showTotal: total => `${total} 条记录`,
}} }}
scroll={{ x: 1000 }} scroll={{ x: 1000 }}
rowClassName={() => 'table-row'} rowClassName={() => 'table-row'}
@@ -641,7 +744,7 @@ function RoomManagement() {
onDelete={handleDelete} onDelete={handleDelete}
onView={handleView} onView={handleView}
selected={selectedRoomIds.includes(room.roomId)} selected={selectedRoomIds.includes(room.roomId)}
onSelect={(id) => { onSelect={id => {
if (selectedRoomIds.includes(id)) { if (selectedRoomIds.includes(id)) {
setSelectedRoomIds(selectedRoomIds.filter(rid => rid !== id)); setSelectedRoomIds(selectedRoomIds.filter(rid => rid !== id));
} else { } else {
@@ -663,12 +766,14 @@ function RoomManagement() {
<Modal <Modal
title={ title={
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div style={{ <div
width: '4px', style={{
height: '20px', width: '4px',
background: designTokens.colors.primary.gradient, height: '20px',
borderRadius: '2px' background: designTokens.colors.primary.gradient,
}} /> borderRadius: '2px',
}}
/>
{editingRoom ? '编辑机房' : '添加机房'} {editingRoom ? '编辑机房' : '添加机房'}
</div> </div>
} }
@@ -679,7 +784,7 @@ function RoomManagement() {
destroyOnHidden destroyOnHidden
styles={{ styles={{
body: { padding: '24px' }, body: { padding: '24px' },
header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' } header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' },
}} }}
style={{ borderRadius: '16px' }} style={{ borderRadius: '16px' }}
> >
@@ -710,7 +815,11 @@ function RoomManagement() {
label="位置" label="位置"
rules={[{ required: true, message: '请输入机房位置' }]} rules={[{ required: true, message: '请输入机房位置' }]}
> >
<Input prefix={<EnvironmentOutlined />} placeholder="请输入机房位置" style={{ borderRadius: '8px' }} /> <Input
prefix={<EnvironmentOutlined />}
placeholder="请输入机房位置"
style={{ borderRadius: '8px' }}
/>
</Form.Item> </Form.Item>
<Row gutter={16}> <Row gutter={16}>
@@ -720,7 +829,12 @@ function RoomManagement() {
label="面积(㎡)" label="面积(㎡)"
rules={[{ required: true, message: '请输入机房面积' }]} 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> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
@@ -729,16 +843,16 @@ function RoomManagement() {
label="容量(机柜数)" label="容量(机柜数)"
rules={[{ required: true, message: '请输入机柜容量' }]} rules={[{ required: true, message: '请输入机柜容量' }]}
> >
<InputNumber placeholder="请输入机柜容量" min={0} style={{ width: '100%', borderRadius: '8px' }} /> <InputNumber
placeholder="请输入机柜容量"
min={0}
style={{ width: '100%', borderRadius: '8px' }}
/>
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Form.Item <Form.Item name="status" label="状态" rules={[{ required: true, message: '请选择状态' }]}>
name="status"
label="状态"
rules={[{ required: true, message: '请选择状态' }]}
>
<Select placeholder="请选择状态" style={{ borderRadius: '8px' }}> <Select placeholder="请选择状态" style={{ borderRadius: '8px' }}>
<Option value="active">在用</Option> <Option value="active">在用</Option>
<Option value="maintenance">维护中</Option> <Option value="maintenance">维护中</Option>
@@ -750,7 +864,9 @@ function RoomManagement() {
<Input.TextArea placeholder="请输入机房描述" rows={3} style={{ borderRadius: '8px' }} /> <Input.TextArea placeholder="请输入机房描述" rows={3} style={{ borderRadius: '8px' }} />
</Form.Item> </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 onClick={handleCancel} style={{ borderRadius: '8px', height: '42px' }}>
取消 取消
</Button> </Button>
@@ -773,21 +889,33 @@ function RoomManagement() {
width={480} width={480}
styles={{ styles={{
header: { borderBottom: '1px solid #f0f0f0' }, header: { borderBottom: '1px solid #f0f0f0' },
body: { padding: '24px' } body: { padding: '24px' },
}} }}
> >
{viewingRoom && ( {viewingRoom && (
<div> <div>
<div style={{ <div
padding: '20px', style={{
background: designTokens.colors.primary.bgGradient, padding: '20px',
borderRadius: designTokens.borderRadius.medium, background: designTokens.colors.primary.bgGradient,
marginBottom: '20px' 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={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}
>
<CloudOutlined
style={{ fontSize: '32px', color: designTokens.colors.primary.main }}
/>
<div> <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} {viewingRoom.name}
</div> </div>
<div style={{ fontSize: '13px', color: designTokens.colors.text.secondary }}> <div style={{ fontSize: '13px', color: designTokens.colors.text.secondary }}>
@@ -801,7 +929,9 @@ function RoomManagement() {
</div> </div>
<div style={{ marginBottom: '20px' }}> <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' }}> <div style={{ fontSize: '15px', fontWeight: '500', marginTop: '4px' }}>
<EnvironmentOutlined style={{ marginRight: '8px' }} /> <EnvironmentOutlined style={{ marginRight: '8px' }} />
{viewingRoom.location} {viewingRoom.location}
@@ -811,16 +941,32 @@ function RoomManagement() {
<Row gutter={16} style={{ marginBottom: '20px' }}> <Row gutter={16} style={{ marginBottom: '20px' }}>
<Col span={12}> <Col span={12}>
<div style={{ padding: '16px', background: '#fafafa', borderRadius: '10px' }}> <div style={{ padding: '16px', background: '#fafafa', borderRadius: '10px' }}>
<Text type="secondary" style={{ fontSize: '12px' }}>面积</Text> <Text type="secondary" style={{ fontSize: '12px' }}>
<div style={{ fontSize: '20px', fontWeight: '600', color: designTokens.colors.text.primary }}> 面积
</Text>
<div
style={{
fontSize: '20px',
fontWeight: '600',
color: designTokens.colors.text.primary,
}}
>
{viewingRoom.area} {viewingRoom.area}
</div> </div>
</div> </div>
</Col> </Col>
<Col span={12}> <Col span={12}>
<div style={{ padding: '16px', background: '#fafafa', borderRadius: '10px' }}> <div style={{ padding: '16px', background: '#fafafa', borderRadius: '10px' }}>
<Text type="secondary" style={{ fontSize: '12px' }}>容量</Text> <Text type="secondary" style={{ fontSize: '12px' }}>
<div style={{ fontSize: '20px', fontWeight: '600', color: designTokens.colors.text.primary }}> 容量
</Text>
<div
style={{
fontSize: '20px',
fontWeight: '600',
color: designTokens.colors.text.primary,
}}
>
{viewingRoom.capacity} 机柜 {viewingRoom.capacity} 机柜
</div> </div>
</div> </div>
@@ -828,7 +974,9 @@ function RoomManagement() {
</Row> </Row>
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<Text type="secondary" style={{ fontSize: '13px' }}>机柜使用情况</Text> <Text type="secondary" style={{ fontSize: '13px' }}>
机柜使用情况
</Text>
<div style={{ marginTop: '12px' }}> <div style={{ marginTop: '12px' }}>
<CapacityProgress <CapacityProgress
used={viewingRoom.Racks?.length || 0} used={viewingRoom.Racks?.length || 0}
@@ -840,8 +988,17 @@ function RoomManagement() {
{viewingRoom.description && ( {viewingRoom.description && (
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<Text type="secondary" style={{ fontSize: '13px' }}>描述</Text> <Text type="secondary" style={{ fontSize: '13px' }}>
<div style={{ marginTop: '8px', padding: '12px', background: '#fafafa', borderRadius: '8px' }}> 描述
</Text>
<div
style={{
marginTop: '8px',
padding: '12px',
background: '#fafafa',
borderRadius: '8px',
}}
>
{viewingRoom.description} {viewingRoom.description}
</div> </div>
</div> </div>
+169 -68
View File
@@ -1,6 +1,28 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Tabs, Form, Input, Switch, Select, Button, Card, Space, message, Modal, Tag, Divider, Descriptions, Alert } from 'antd'; import {
import { SettingOutlined, GlobalOutlined, BgColorsOutlined, InfoCircleOutlined, CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons'; 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 axios from 'axios';
import { useConfig } from '../context/ConfigContext'; import { useConfig } from '../context/ConfigContext';
@@ -48,7 +70,7 @@ const SystemSettings = () => {
} }
}; };
const handleSaveSettings = async (values) => { const handleSaveSettings = async values => {
setSaving(true); setSaving(true);
try { try {
const updates = {}; const updates = {};
@@ -76,7 +98,10 @@ const SystemSettings = () => {
title: '前端端口已修改(生产环境)', title: '前端端口已修改(生产环境)',
content: ( content: (
<div> <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 <Alert
message="请手动更新服务器配置" message="请手动更新服务器配置"
description={ description={
@@ -93,7 +118,7 @@ const SystemSettings = () => {
/> />
</div> </div>
), ),
okText: '知道了' okText: '知道了',
}); });
} else { } else {
// //
@@ -102,7 +127,10 @@ const SystemSettings = () => {
icon: <ExclamationCircleOutlined />, icon: <ExclamationCircleOutlined />,
content: ( content: (
<div> <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> <p>是否立即重启前端服务以应用新端口</p>
<Alert <Alert
message="注意" message="注意"
@@ -125,9 +153,13 @@ const SystemSettings = () => {
title: '正在重启前端服务', title: '正在重启前端服务',
content: ( content: (
<div> <div>
<p>前端服务正在重启新端口<strong>{newPort}</strong></p> <p>
前端服务正在重启新端口<strong>{newPort}</strong>
</p>
<p>页面将在3秒后自动跳转到新地址...</p> <p>页面将在3秒后自动跳转到新地址...</p>
<p>如果跳转失败请手动访问<a href={newUrl}>{newUrl}</a></p> <p>
如果跳转失败请手动访问<a href={newUrl}>{newUrl}</a>
</p>
</div> </div>
), ),
okText: '立即跳转', okText: '立即跳转',
@@ -135,14 +167,18 @@ const SystemSettings = () => {
maskClosable: false, maskClosable: false,
onOk: () => { onOk: () => {
window.location.href = newUrl; window.location.href = newUrl;
} },
}); });
// API // API
setTimeout(async () => { setTimeout(async () => {
try { try {
// API // API
await axios.post('/api/system-settings/frontend/restart', {}, { timeout: 5000 }); await axios.post(
'/api/system-settings/frontend/restart',
{},
{ timeout: 5000 }
);
} catch (error) { } catch (error) {
// //
console.log('重启请求已发送,服务正在重启...'); console.log('重启请求已发送,服务正在重启...');
@@ -153,7 +189,7 @@ const SystemSettings = () => {
setTimeout(() => { setTimeout(() => {
window.location.href = newUrl; window.location.href = newUrl;
}, 3000); }, 3000);
} },
}); });
} }
} catch (syncError) { } catch (syncError) {
@@ -174,7 +210,7 @@ const SystemSettings = () => {
} }
}; };
const handleResetSetting = (key) => { const handleResetSetting = key => {
Modal.confirm({ Modal.confirm({
title: '确认重置', title: '确认重置',
icon: <ExclamationCircleOutlined />, icon: <ExclamationCircleOutlined />,
@@ -189,18 +225,14 @@ const SystemSettings = () => {
} catch (error) { } catch (error) {
message.error('重置失败'); message.error('重置失败');
} }
} },
}); });
}; };
const renderFormItem = (key, data) => { const renderFormItem = (key, data) => {
if (!data.isEditable) { if (!data.isEditable) {
return ( return (
<Form.Item <Form.Item key={key} label={data.description || key} name={key}>
key={key}
label={data.description || key}
name={key}
>
<Input disabled suffix={<LockOutlined />} /> <Input disabled suffix={<LockOutlined />} />
</Form.Item> </Form.Item>
); );
@@ -209,12 +241,7 @@ const SystemSettings = () => {
switch (data.type) { switch (data.type) {
case 'boolean': case 'boolean':
return ( return (
<Form.Item <Form.Item key={key} label={data.description || key} name={key} valuePropName="checked">
key={key}
label={data.description || key}
name={key}
valuePropName="checked"
>
<Switch /> <Switch />
</Form.Item> </Form.Item>
); );
@@ -227,56 +254,63 @@ const SystemSettings = () => {
name={key} name={key}
rules={[ rules={[
{ required: false, message: `请输入${data.description || key}` }, { required: false, message: `请输入${data.description || key}` },
...(isPortField ? [ ...(isPortField
{ type: 'number', min: 1, max: 65535, message: '端口号必须在 1-65535 之间', transform: value => Number(value) } ? [
] : []) {
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> </Form.Item>
); );
case 'select': case 'select':
const options = getSelectOptions(key); const options = getSelectOptions(key);
return ( return (
<Form.Item <Form.Item key={key} label={data.description || key} name={key}>
key={key}
label={data.description || key}
name={key}
>
<Select> <Select>
{options.map(opt => ( {options.map(opt => (
<Option key={opt.value} value={opt.value}>{opt.label}</Option> <Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))} ))}
</Select> </Select>
</Form.Item> </Form.Item>
); );
default: default:
return ( return (
<Form.Item <Form.Item key={key} label={data.description || key} name={key}>
key={key}
label={data.description || key}
name={key}
>
<Input /> <Input />
</Form.Item> </Form.Item>
); );
} }
}; };
const getSelectOptions = (key) => { const getSelectOptions = key => {
const optionsMap = { const optionsMap = {
timezone: [ timezone: [
{ value: 'Asia/Shanghai', label: '亚洲/上海 (UTC+8)' }, { value: 'Asia/Shanghai', label: '亚洲/上海 (UTC+8)' },
{ value: 'Asia/Beijing', label: '亚洲/北京 (UTC+8)' }, { value: 'Asia/Beijing', label: '亚洲/北京 (UTC+8)' },
{ value: 'America/New_York', label: '美洲/纽约 (UTC-5)' }, { value: 'America/New_York', label: '美洲/纽约 (UTC-5)' },
{ value: 'Europe/London', label: '欧洲/伦敦 (UTC+0)' }, { value: 'Europe/London', label: '欧洲/伦敦 (UTC+0)' },
{ value: 'UTC', label: 'UTC (UTC+0)' } { value: 'UTC', label: 'UTC (UTC+0)' },
], ],
date_format: [ date_format: [
{ value: 'YYYY-MM-DD', label: '2024-01-01' }, { value: 'YYYY-MM-DD', label: '2024-01-01' },
{ 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: '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: [ primary_color: [
{ value: '#667eea', label: '蓝色 (#667eea)' }, { value: '#667eea', label: '蓝色 (#667eea)' },
@@ -288,7 +322,7 @@ const SystemSettings = () => {
{ value: '#fee140', label: '黄色 (#fee140)' }, { value: '#fee140', label: '黄色 (#fee140)' },
{ value: '#00b4db', label: '青色 (#00b4db)' }, { value: '#00b4db', label: '青色 (#00b4db)' },
{ value: '#0083b0', label: '深蓝色 (#0083b0)' }, { value: '#0083b0', label: '深蓝色 (#0083b0)' },
{ value: '#fcb045', label: '橙色 (#fcb045)' } { value: '#fcb045', label: '橙色 (#fcb045)' },
], ],
secondary_color: [ secondary_color: [
{ value: '#764ba2', label: '紫色 (#764ba2)' }, { value: '#764ba2', label: '紫色 (#764ba2)' },
@@ -300,36 +334,44 @@ const SystemSettings = () => {
{ value: '#00b4db', label: '青色 (#00b4db)' }, { value: '#00b4db', label: '青色 (#00b4db)' },
{ value: '#0083b0', label: '深蓝色 (#0083b0)' }, { value: '#0083b0', label: '深蓝色 (#0083b0)' },
{ value: '#fcb045', label: '橙色 (#fcb045)' }, { value: '#fcb045', label: '橙色 (#fcb045)' },
{ value: '#f093fb', label: '粉色 (#f093fb)' } { value: '#f093fb', label: '粉色 (#f093fb)' },
], ],
table_row_height: [ table_row_height: [
{ value: 'small', label: '紧凑 (Small)' }, { value: 'small', label: '紧凑 (Small)' },
{ value: 'default', label: '默认 (Default)' }, { value: 'default', label: '默认 (Default)' },
{ value: 'middle', label: '中等 (Middle)' }, { value: 'middle', label: '中等 (Middle)' },
{ value: 'large', label: '宽松 (Large)' } { value: 'large', label: '宽松 (Large)' },
], ],
dark_mode: [ dark_mode: [
{ value: 'false', label: '关闭' }, { value: 'false', label: '关闭' },
{ value: 'true', label: '开启' } { value: 'true', label: '开启' },
], ],
compact_mode: [ compact_mode: [
{ value: 'false', label: '关闭' }, { value: 'false', label: '关闭' },
{ value: 'true', label: '开启' } { value: 'true', label: '开启' },
], ],
animation_enabled: [ animation_enabled: [
{ value: 'false', label: '关闭' }, { value: 'false', label: '关闭' },
{ value: 'true', label: '开启' } { value: 'true', label: '开启' },
], ],
sidebar_collapsed: [ sidebar_collapsed: [
{ value: 'false', label: '展开' }, { value: 'false', label: '展开' },
{ value: 'true', label: '折叠' } { value: 'true', label: '折叠' },
], ],
}; };
return optionsMap[key] || []; return optionsMap[key] || [];
}; };
const renderGeneralSettings = () => { 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 ( return (
<Card title="全局配置" bordered={false}> <Card title="全局配置" bordered={false}>
<Form form={form} layout="vertical" onFinish={handleSaveSettings}> <Form form={form} layout="vertical" onFinish={handleSaveSettings}>
@@ -343,7 +385,9 @@ const SystemSettings = () => {
})} })}
<Form.Item> <Form.Item>
<Space> <Space>
<Button type="primary" htmlType="submit" loading={saving}>保存设置</Button> <Button type="primary" htmlType="submit" loading={saving}>
保存设置
</Button>
<Button onClick={() => fetchSettings()}>重置表单</Button> <Button onClick={() => fetchSettings()}>重置表单</Button>
</Space> </Space>
</Form.Item> </Form.Item>
@@ -353,7 +397,14 @@ const SystemSettings = () => {
}; };
const renderAppearanceSettings = () => { 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 ( return (
<Card title="外观设置" bordered={false}> <Card title="外观设置" bordered={false}>
<Form form={form} layout="vertical" onFinish={handleSaveSettings}> <Form form={form} layout="vertical" onFinish={handleSaveSettings}>
@@ -374,7 +425,9 @@ const SystemSettings = () => {
})} })}
<Form.Item> <Form.Item>
<Space> <Space>
<Button type="primary" htmlType="submit" loading={saving}>保存设置</Button> <Button type="primary" htmlType="submit" loading={saving}>
保存设置
</Button>
<Button onClick={() => fetchSettings()}>重置表单</Button> <Button onClick={() => fetchSettings()}>重置表单</Button>
</Space> </Space>
</Form.Item> </Form.Item>
@@ -386,14 +439,25 @@ const SystemSettings = () => {
// //
const renderAboutPage = () => { 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 ( return (
<div> <div>
<Card title="关于系统" bordered={false} style={{ marginBottom: 16 }}> <Card title="关于系统" bordered={false} style={{ marginBottom: 16 }}>
<Descriptions column={{ xs: 1, sm: 2, md: 3 }} bordered> <Descriptions column={{ xs: 1, sm: 2, md: 3 }} bordered>
<Descriptions.Item label="系统名称">机柜管理系统</Descriptions.Item> <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="系统状态"> <Descriptions.Item label="系统状态">
<Tag color="success">运行正常</Tag> <Tag color="success">运行正常</Tag>
</Descriptions.Item> </Descriptions.Item>
@@ -405,7 +469,14 @@ const SystemSettings = () => {
{aboutKeys.slice(1).map(key => settings[key] && renderFormItem(key, settings[key]))} {aboutKeys.slice(1).map(key => settings[key] && renderFormItem(key, settings[key]))}
<Form.Item> <Form.Item>
<Space> <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> <Button onClick={() => fetchSettings()}>重置</Button>
</Space> </Space>
</Form.Item> </Form.Item>
@@ -415,24 +486,42 @@ const SystemSettings = () => {
{systemInfo && ( {systemInfo && (
<Card title="系统统计信息" bordered={false}> <Card title="系统统计信息" bordered={false}>
<Descriptions column={{ xs: 1, sm: 2, md: 4 }} bordered size="small"> <Descriptions column={{ xs: 1, sm: 2, md: 4 }} bordered size="small">
<Descriptions.Item label="设备总数">{systemInfo.statistics?.devices || 0}</Descriptions.Item> <Descriptions.Item label="设备总数">
<Descriptions.Item label="机柜总数">{systemInfo.statistics?.racks || 0}</Descriptions.Item> {systemInfo.statistics?.devices || 0}
<Descriptions.Item label="机房总数">{systemInfo.statistics?.rooms || 0}</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="用户总数">{systemInfo.statistics?.users || 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> </Descriptions>
<Divider /> <Divider />
<Descriptions column={{ xs: 1, sm: 2 }} bordered size="small"> <Descriptions column={{ xs: 1, sm: 2 }} bordered size="small">
<Descriptions.Item label="Node.js 版本">{systemInfo.system?.nodeVersion}</Descriptions.Item> <Descriptions.Item label="Node.js 版本">
<Descriptions.Item label="运行平台">{systemInfo.system?.platform} ({systemInfo.system?.arch})</Descriptions.Item> {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="进程 ID">{systemInfo.system?.pid}</Descriptions.Item>
<Descriptions.Item label="运行时间"> <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>
<Descriptions.Item label="内存使用"> <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>
<Descriptions.Item label="系统时间"> <Descriptions.Item label="系统时间">
{systemInfo.timestamp ? new Date(systemInfo.timestamp).toLocaleString('zh-CN') : '-'} {systemInfo.timestamp
? new Date(systemInfo.timestamp).toLocaleString('zh-CN')
: '-'}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
</Card> </Card>
@@ -445,19 +534,31 @@ const SystemSettings = () => {
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<Tabs activeKey={activeTab} onChange={setActiveTab}> <Tabs activeKey={activeTab} onChange={setActiveTab}>
<TabPane <TabPane
tab={<span><GlobalOutlined /> 全局配置</span>} tab={
<span>
<GlobalOutlined /> 全局配置
</span>
}
key="general" key="general"
> >
{renderGeneralSettings()} {renderGeneralSettings()}
</TabPane> </TabPane>
<TabPane <TabPane
tab={<span><BgColorsOutlined /> 外观设置</span>} tab={
<span>
<BgColorsOutlined /> 外观设置
</span>
}
key="appearance" key="appearance"
> >
{renderAppearanceSettings()} {renderAppearanceSettings()}
</TabPane> </TabPane>
<TabPane <TabPane
tab={<span><InfoCircleOutlined /> 关于</span>} tab={
<span>
<InfoCircleOutlined /> 关于
</span>
}
key="about" key="about"
> >
{renderAboutPage()} {renderAboutPage()}
+28 -31
View File
@@ -49,7 +49,7 @@ function TicketCategoryManagement() {
priority: category.priority, priority: category.priority,
defaultPriority: category.defaultPriority, defaultPriority: category.defaultPriority,
expectedDuration: category.expectedDuration, expectedDuration: category.expectedDuration,
isActive: category.isActive isActive: category.isActive,
}); });
} else { } else {
form.resetFields(); form.resetFields();
@@ -62,7 +62,7 @@ function TicketCategoryManagement() {
setEditingCategory(null); setEditingCategory(null);
}; };
const handleSubmit = async (values) => { const handleSubmit = async values => {
try { try {
if (editingCategory) { if (editingCategory) {
await axios.put(`/api/ticket-categories/${editingCategory.categoryId}`, values); await axios.put(`/api/ticket-categories/${editingCategory.categoryId}`, values);
@@ -81,7 +81,7 @@ function TicketCategoryManagement() {
} }
}; };
const handleDelete = async (categoryId) => { const handleDelete = async categoryId => {
try { try {
await axios.delete(`/api/ticket-categories/${categoryId}`); await axios.delete(`/api/ticket-categories/${categoryId}`);
message.success('分类删除成功'); message.success('分类删除成功');
@@ -97,49 +97,47 @@ function TicketCategoryManagement() {
title: '分类ID', title: '分类ID',
dataIndex: 'categoryId', dataIndex: 'categoryId',
key: 'categoryId', key: 'categoryId',
width: 150 width: 150,
}, },
{ {
title: '分类名称', title: '分类名称',
dataIndex: 'name', dataIndex: 'name',
key: 'name', key: 'name',
width: 180 width: 180,
}, },
{ {
title: '分类说明', title: '分类说明',
dataIndex: 'description', dataIndex: 'description',
key: 'description', key: 'description',
width: 300, width: 300,
ellipsis: true ellipsis: true,
}, },
{ {
title: '优先级', title: '优先级',
dataIndex: 'priority', dataIndex: 'priority',
key: 'priority', key: 'priority',
width: 80 width: 80,
}, },
{ {
title: '默认优先级', title: '默认优先级',
dataIndex: 'defaultPriority', dataIndex: 'defaultPriority',
key: 'defaultPriority', key: 'defaultPriority',
width: 100 width: 100,
}, },
{ {
title: '预计时长(分钟)', title: '预计时长(分钟)',
dataIndex: 'expectedDuration', dataIndex: 'expectedDuration',
key: 'expectedDuration', key: 'expectedDuration',
width: 120 width: 120,
}, },
{ {
title: '启用状态', title: '启用状态',
dataIndex: 'isActive', dataIndex: 'isActive',
key: 'isActive', key: 'isActive',
width: 100, width: 100,
render: (text) => ( render: text => (
<span style={{ color: text ? 'green' : 'red' }}> <span style={{ color: text ? 'green' : 'red' }}>{text ? '启用' : '禁用'}</span>
{text ? '启用' : '禁用'} ),
</span>
)
}, },
{ {
title: '操作', title: '操作',
@@ -147,11 +145,7 @@ function TicketCategoryManagement() {
width: 150, width: 150,
render: (_, record) => ( render: (_, record) => (
<Space size="small"> <Space size="small">
<Button <Button type="link" icon={<EditOutlined />} onClick={() => showModal(record)}>
type="link"
icon={<EditOutlined />}
onClick={() => showModal(record)}
>
编辑 编辑
</Button> </Button>
<Popconfirm <Popconfirm
@@ -166,22 +160,25 @@ function TicketCategoryManagement() {
</Button> </Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
) ),
} },
]; ];
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<Card title="故障分类管理" extra={ <Card
<Space> title="故障分类管理"
<Button icon={<ReloadOutlined />} onClick={initCategories}> extra={
初始化分类 <Space>
</Button> <Button icon={<ReloadOutlined />} onClick={initCategories}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}> 初始化分类
添加分类 </Button>
</Button> <Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>
</Space> 添加分类
}> </Button>
</Space>
}
>
<Table <Table
columns={columns} columns={columns}
dataSource={categories} dataSource={categories}
+47 -39
View File
@@ -1,5 +1,17 @@
import React, { useState, useEffect } from 'react'; 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 { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import axios from 'axios'; import axios from 'axios';
@@ -34,7 +46,7 @@ function TicketFieldManagement() {
if (field) { if (field) {
const fieldData = { const fieldData = {
...field, ...field,
options: field.options ? JSON.stringify(field.options, null, 2) : '' options: field.options ? JSON.stringify(field.options, null, 2) : '',
}; };
form.setFieldsValue(fieldData); form.setFieldsValue(fieldData);
} else { } else {
@@ -48,11 +60,11 @@ function TicketFieldManagement() {
setEditingField(null); setEditingField(null);
}; };
const handleSubmit = async (values) => { const handleSubmit = async values => {
try { try {
const fieldData = { const fieldData = {
...values, ...values,
options: values.options ? JSON.parse(values.options || '[]') : null options: values.options ? JSON.parse(values.options || '[]') : null,
}; };
if (editingField) { if (editingField) {
@@ -72,7 +84,7 @@ function TicketFieldManagement() {
} }
}; };
const handleDelete = async (fieldId) => { const handleDelete = async fieldId => {
Modal.confirm({ Modal.confirm({
title: '确认删除', title: '确认删除',
content: '确定要删除这个字段吗?', content: '确定要删除这个字段吗?',
@@ -88,7 +100,7 @@ function TicketFieldManagement() {
message.error('字段删除失败'); message.error('字段删除失败');
console.error('字段删除失败:', error); console.error('字段删除失败:', error);
} }
} },
}); });
}; };
@@ -107,7 +119,7 @@ function TicketFieldManagement() {
title: '字段类型', title: '字段类型',
dataIndex: 'fieldType', dataIndex: 'fieldType',
key: 'fieldType', key: 'fieldType',
render: (type) => { render: type => {
const typeMap = { const typeMap = {
string: '文本', string: '文本',
number: '数字', number: '数字',
@@ -116,26 +128,22 @@ function TicketFieldManagement() {
date: '日期', date: '日期',
datetime: '日期时间', datetime: '日期时间',
textarea: '多行文本', textarea: '多行文本',
device: '设备选择' device: '设备选择',
}; };
return typeMap[type] || type; return typeMap[type] || type;
} },
}, },
{ {
title: '必填', title: '必填',
dataIndex: 'required', dataIndex: 'required',
key: 'required', key: 'required',
render: (required) => ( render: required => <Switch checked={required} disabled />,
<Switch checked={required} disabled />
)
}, },
{ {
title: '可见', title: '可见',
dataIndex: 'visible', dataIndex: 'visible',
key: 'visible', key: 'visible',
render: (visible) => ( render: visible => <Switch checked={visible} disabled />,
<Switch checked={visible} disabled />
)
}, },
{ {
title: '顺序', title: '顺序',
@@ -147,10 +155,20 @@ function TicketFieldManagement() {
key: 'action', key: 'action',
render: (_, record) => ( render: (_, record) => (
<Space size="middle"> <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>
<Button danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.fieldId)} size="small"> <Button
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(record.fieldId)}
size="small"
>
删除 删除
</Button> </Button>
</Space> </Space>
@@ -160,11 +178,14 @@ function TicketFieldManagement() {
return ( return (
<div> <div>
<Card title="工单字段管理" extra={ <Card
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}> title="工单字段管理"
添加字段 extra={
</Button> <Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>
}> 添加字段
</Button>
}
>
<Table <Table
columns={columns} columns={columns}
dataSource={fields} dataSource={fields}
@@ -181,11 +202,7 @@ function TicketFieldManagement() {
footer={null} footer={null}
width={600} width={600}
> >
<Form <Form form={form} layout="vertical" onFinish={handleSubmit}>
form={form}
layout="vertical"
onFinish={handleSubmit}
>
<Form.Item <Form.Item
name="fieldName" name="fieldName"
label="字段名称" label="字段名称"
@@ -219,17 +236,11 @@ function TicketFieldManagement() {
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item <Form.Item name="required" label="必填">
name="required"
label="必填"
>
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Form.Item <Form.Item name="visible" label="可见">
name="visible"
label="可见"
>
<Switch defaultChecked /> <Switch defaultChecked />
</Form.Item> </Form.Item>
@@ -246,10 +257,7 @@ function TicketFieldManagement() {
label="选项配置(仅下拉选择类型,JSON格式)" label="选项配置(仅下拉选择类型,JSON格式)"
tooltip="格式示例:[{value: 'option1', label: '选项1'}]" tooltip="格式示例:[{value: 'option1', label: '选项1'}]"
> >
<Input.TextArea <Input.TextArea rows={3} placeholder='[{"value": "option1", "label": "选项1"}]' />
rows={3}
placeholder='[{"value": "option1", "label": "选项1"}]'
/>
</Form.Item> </Form.Item>
<Form.Item style={{ textAlign: 'right' }}> <Form.Item style={{ textAlign: 'right' }}>
File diff suppressed because it is too large Load Diff
+183 -168
View File
@@ -1,60 +1,65 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Card, Row, Col, Statistic, Table, DatePicker, Select, Space, Tag, message } from 'antd'; 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 axios from 'axios';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
const { Option } = Select; const { Option } = Select;
const getStatusColor = (status) => { const getStatusColor = status => {
const colors = { const colors = {
pending: 'orange', pending: 'orange',
assigned: 'blue', assigned: 'blue',
in_progress: 'processing', in_progress: 'processing',
completed: 'green', completed: 'green',
closed: 'default' closed: 'default',
}; };
return colors[status] || 'default'; return colors[status] || 'default';
}; };
const getStatusText = (status) => { const getStatusText = status => {
const texts = { const texts = {
pending: '待处理', pending: '待处理',
assigned: '已分配', assigned: '已分配',
in_progress: '处理中', in_progress: '处理中',
completed: '已完成', completed: '已完成',
closed: '已关闭' closed: '已关闭',
}; };
return texts[status] || status; return texts[status] || status;
}; };
const getPriorityColor = (priority) => { const getPriorityColor = priority => {
const colors = { const colors = {
low: 'green', low: 'green',
medium: 'orange', medium: 'orange',
high: 'red', high: 'red',
urgent: 'magenta' urgent: 'magenta',
}; };
return colors[priority] || 'default'; return colors[priority] || 'default';
}; };
const getPriorityText = (priority) => { const getPriorityText = priority => {
const texts = { const texts = {
low: '低', low: '低',
medium: '中', medium: '中',
high: '高', high: '高',
urgent: '紧急' urgent: '紧急',
}; };
return texts[priority] || priority; return texts[priority] || priority;
}; };
function TicketStatistics() { function TicketStatistics() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [dateRange, setDateRange] = useState([ const [dateRange, setDateRange] = useState([dayjs().subtract(30, 'days'), dayjs()]);
dayjs().subtract(30, 'days'),
dayjs()
]);
const [statistics, setStatistics] = useState({ const [statistics, setStatistics] = useState({
total: 0, total: 0,
pending: 0, pending: 0,
@@ -66,7 +71,7 @@ function TicketStatistics() {
byPriority: [], byPriority: [],
byStatus: [], byStatus: [],
byDevice: [], byDevice: [],
trend: [] trend: [],
}); });
const fetchStatistics = useCallback(async () => { const fetchStatistics = useCallback(async () => {
@@ -74,7 +79,7 @@ function TicketStatistics() {
setLoading(true); setLoading(true);
const params = { const params = {
startDate: dateRange[0].format('YYYY-MM-DD'), 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 }); const response = await axios.get('/api/tickets/statistics', { params });
@@ -91,192 +96,198 @@ function TicketStatistics() {
fetchStatistics(); fetchStatistics();
}, [fetchStatistics]); }, [fetchStatistics]);
const handleDateChange = useCallback((dates) => { const handleDateChange = useCallback(dates => {
if (dates) { if (dates) {
setDateRange(dates); setDateRange(dates);
} }
}, []); }, []);
const getStatusColor = (status) => { const getStatusColor = status => {
const colors = { const colors = {
pending: 'orange', pending: 'orange',
assigned: 'blue', assigned: 'blue',
in_progress: 'processing', in_progress: 'processing',
completed: 'green', completed: 'green',
closed: 'default' closed: 'default',
}; };
return colors[status] || 'default'; return colors[status] || 'default';
}; };
const getStatusText = (status) => { const getStatusText = status => {
const texts = { const texts = {
pending: '待处理', pending: '待处理',
assigned: '已分配', assigned: '已分配',
in_progress: '处理中', in_progress: '处理中',
completed: '已完成', completed: '已完成',
closed: '已关闭' closed: '已关闭',
}; };
return texts[status] || status; return texts[status] || status;
}; };
const getPriorityColor = (priority) => { const getPriorityColor = priority => {
const colors = { const colors = {
low: 'green', low: 'green',
medium: 'orange', medium: 'orange',
high: 'red', high: 'red',
urgent: 'magenta' urgent: 'magenta',
}; };
return colors[priority] || 'default'; return colors[priority] || 'default';
}; };
const getPriorityText = (priority) => { const getPriorityText = priority => {
const texts = { const texts = {
low: '低', low: '低',
medium: '中', medium: '中',
high: '高', high: '高',
urgent: '紧急' urgent: '紧急',
}; };
return texts[priority] || priority; return texts[priority] || priority;
}; };
const statusColumns = useMemo(() => [ const statusColumns = useMemo(
{ () => [
title: '状态', {
dataIndex: 'status', title: '状态',
key: 'status', dataIndex: 'status',
width: 120, key: 'status',
render: (status) => ( width: 120,
<Tag color={getStatusColor(status)}> render: status => <Tag color={getStatusColor(status)}>{getStatusText(status)}</Tag>,
{getStatusText(status)} },
</Tag> {
) title: '工单数量',
}, dataIndex: 'count',
{ key: 'count',
title: '工单数量', width: 120,
dataIndex: 'count', render: count => <Statistic value={count} valueStyle={{ fontSize: 16 }} />,
key: 'count', },
width: 120, {
render: (count) => <Statistic value={count} valueStyle={{ fontSize: 16 }} /> title: '占比',
}, dataIndex: 'percentage',
{ key: 'percentage',
title: '占比', width: 120,
dataIndex: 'percentage', render: pct => (
key: 'percentage', <span style={{ color: pct > 30 ? '#ff4d4f' : '#52c41a' }}>
width: 120, {pct !== undefined && pct !== null ? `${pct.toFixed(1)}%` : '-'}
render: (pct) => ( </span>
<span style={{ color: pct > 30 ? '#ff4d4f' : '#52c41a' }}> ),
{pct !== undefined && pct !== null ? `${pct.toFixed(1)}%` : '-'} },
</span> ],
) []
} );
], []);
const categoryColumns = useMemo(() => [ const categoryColumns = useMemo(
{ () => [
title: '故障分类', {
dataIndex: 'category', title: '故障分类',
key: 'category', dataIndex: 'category',
width: 150 key: 'category',
}, width: 150,
{ },
title: '工单数量', {
dataIndex: 'count', title: '工单数量',
key: 'count', dataIndex: 'count',
width: 120, key: 'count',
render: (count) => <Statistic value={count} valueStyle={{ fontSize: 16 }} /> width: 120,
}, render: count => <Statistic value={count} valueStyle={{ fontSize: 16 }} />,
{ },
title: '占比', {
dataIndex: 'percentage', title: '占比',
key: 'percentage', dataIndex: 'percentage',
width: 100, key: 'percentage',
render: (pct) => `${pct !== undefined && pct !== null ? pct.toFixed(1) : 0}%` width: 100,
}, render: pct => `${pct !== undefined && pct !== null ? pct.toFixed(1) : 0}%`,
{ },
title: '已完成', {
dataIndex: 'completed', title: '已完成',
key: 'completed', dataIndex: 'completed',
width: 100, key: 'completed',
render: (count) => <Tag color="green">{count}</Tag> width: 100,
}, render: count => <Tag color="green">{count}</Tag>,
{ },
title: '平均处理时间(小时)', {
dataIndex: 'avgTime', title: '平均处理时间(小时)',
key: 'avgTime', dataIndex: 'avgTime',
width: 150, key: 'avgTime',
render: (time) => time !== undefined && time !== null ? time.toFixed(1) : '-' width: 150,
} render: time => (time !== undefined && time !== null ? time.toFixed(1) : '-'),
], []); },
],
[]
);
const priorityColumns = useMemo(() => [ const priorityColumns = useMemo(
{ () => [
title: '优先级', {
dataIndex: 'priority', title: '优先级',
key: 'priority', dataIndex: 'priority',
width: 100, key: 'priority',
render: (priority) => ( width: 100,
<Tag color={getPriorityColor(priority)}> render: priority => (
{getPriorityText(priority)} <Tag color={getPriorityColor(priority)}>{getPriorityText(priority)}</Tag>
</Tag> ),
) },
}, {
{ title: '工单数量',
title: '工单数量', dataIndex: 'count',
dataIndex: 'count', key: 'count',
key: 'count', width: 120,
width: 120, render: count => <Statistic value={count} valueStyle={{ fontSize: 16 }} />,
render: (count) => <Statistic value={count} valueStyle={{ fontSize: 16 }} /> },
}, {
{ title: '已完成',
title: '已完成', dataIndex: 'completed',
dataIndex: 'completed', key: 'completed',
key: 'completed', width: 100,
width: 100, render: count => <Tag color="green">{count}</Tag>,
render: (count) => <Tag color="green">{count}</Tag> },
}, {
{ title: '平均处理时间(小时)',
title: '平均处理时间(小时)', dataIndex: 'avgTime',
dataIndex: 'avgTime', key: 'avgTime',
key: 'avgTime', width: 150,
width: 150, render: time => (time !== undefined && time !== null ? time.toFixed(1) : '-'),
render: (time) => time !== undefined && time !== null ? time.toFixed(1) : '-' },
} ],
], []); []
);
const deviceColumns = useMemo(() => [ const deviceColumns = useMemo(
{ () => [
title: '设备名称', {
dataIndex: 'deviceName', title: '设备名称',
key: 'deviceName', dataIndex: 'deviceName',
width: 180 key: 'deviceName',
}, width: 180,
{ },
title: '故障次数', {
dataIndex: 'count', title: '故障次数',
key: 'count', dataIndex: 'count',
width: 100, key: 'count',
render: (count) => <Tag color="red">{count}</Tag> width: 100,
}, render: count => <Tag color="red">{count}</Tag>,
{ },
title: '最后故障时间', {
dataIndex: 'lastFaultTime', title: '最后故障时间',
key: 'lastFaultTime', dataIndex: 'lastFaultTime',
width: 160, key: 'lastFaultTime',
render: (text) => text ? dayjs(text).format('YYYY-MM-DD HH:mm') : '-' width: 160,
}, render: text => (text ? dayjs(text).format('YYYY-MM-DD HH:mm') : '-'),
{ },
title: '设备类型', {
dataIndex: 'deviceType', title: '设备类型',
key: 'deviceType', dataIndex: 'deviceType',
width: 100 key: 'deviceType',
} width: 100,
], []); },
],
[]
);
const simpleBarData = [ const simpleBarData = [
{ name: '待处理', value: statistics.pending }, { name: '待处理', value: statistics.pending },
{ name: '处理中', value: statistics.inProgress }, { name: '处理中', value: statistics.inProgress },
{ name: '已完成', value: statistics.completed }, { name: '已完成', value: statistics.completed },
{ name: '已关闭', value: statistics.closed } { name: '已关闭', value: statistics.closed },
]; ];
return ( return (
@@ -285,11 +296,7 @@ function TicketStatistics() {
title="工单统计报表" title="工单统计报表"
extra={ extra={
<Space> <Space>
<RangePicker <RangePicker value={dateRange} onChange={handleDateChange} allowClear={false} />
value={dateRange}
onChange={handleDateChange}
allowClear={false}
/>
</Space> </Space>
} }
> >
@@ -362,7 +369,11 @@ function TicketStatistics() {
<Card bordered={false} style={{ background: '#fff1f0' }}> <Card bordered={false} style={{ background: '#fff1f0' }}>
<Statistic <Statistic
title="完成率" 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="%" suffix="%"
prefix={<PieChartOutlined style={{ color: '#ff4d4f' }} />} prefix={<PieChartOutlined style={{ color: '#ff4d4f' }} />}
valueStyle={{ color: '#ff4d4f' }} valueStyle={{ color: '#ff4d4f' }}
@@ -373,7 +384,11 @@ function TicketStatistics() {
<Card bordered={false} style={{ background: '#f6ffed' }}> <Card bordered={false} style={{ background: '#f6ffed' }}>
<Statistic <Statistic
title="处理中占比" 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="%" suffix="%"
prefix={<RiseOutlined style={{ color: '#52c41a' }} />} prefix={<RiseOutlined style={{ color: '#52c41a' }} />}
valueStyle={{ color: '#52c41a' }} valueStyle={{ color: '#52c41a' }}
@@ -446,43 +461,43 @@ function TicketStatistics() {
dataIndex: 'date', dataIndex: 'date',
key: 'date', key: 'date',
width: 120, width: 120,
render: (text) => text ? dayjs(text).format('YYYY-MM-DD') : '-' render: text => (text ? dayjs(text).format('YYYY-MM-DD') : '-'),
}, },
{ {
title: '新建工单', title: '新建工单',
dataIndex: 'created', dataIndex: 'created',
key: 'created', key: 'created',
width: 100, width: 100,
render: (count) => <Tag color="blue">{count}</Tag> render: count => <Tag color="blue">{count}</Tag>,
}, },
{ {
title: '已完成', title: '已完成',
dataIndex: 'completed', dataIndex: 'completed',
key: 'completed', key: 'completed',
width: 100, width: 100,
render: (count) => <Tag color="green">{count}</Tag> render: count => <Tag color="green">{count}</Tag>,
}, },
{ {
title: '关闭工单', title: '关闭工单',
dataIndex: 'closed', dataIndex: 'closed',
key: 'closed', key: 'closed',
width: 100, width: 100,
render: (count) => <Tag color="default">{count}</Tag> render: count => <Tag color="default">{count}</Tag>,
}, },
{ {
title: '当日处理中', title: '当日处理中',
dataIndex: 'inProgress', dataIndex: 'inProgress',
key: 'inProgress', key: 'inProgress',
width: 120, width: 120,
render: (count) => <Tag color="processing">{count}</Tag> render: count => <Tag color="processing">{count}</Tag>,
}, },
{ {
title: '新增待处理', title: '新增待处理',
dataIndex: 'pending', dataIndex: 'pending',
key: 'pending', key: 'pending',
width: 120, width: 120,
render: (count) => <Tag color="orange">{count}</Tag> render: count => <Tag color="orange">{count}</Tag>,
} },
]} ]}
dataSource={statistics.trend} dataSource={statistics.trend}
rowKey="date" rowKey="date"
+354 -318
View File
@@ -1,6 +1,31 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; 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 {
import { PlusOutlined, EditOutlined, DeleteOutlined, UserOutlined, ReloadOutlined, LockOutlined, CameraOutlined, CheckOutlined, CloseOutlined } from '@ant-design/icons'; 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'; import { userAPI, roleAPI } from '../api';
const { Option } = Select; const { Option } = Select;
@@ -32,7 +57,7 @@ const UserManagement = () => {
try { try {
const params = { const params = {
page: pagination.current, page: pagination.current,
pageSize: pagination.pageSize pageSize: pagination.pageSize,
}; };
if (activeTab !== 'all') { if (activeTab !== 'all') {
params.status = activeTab; params.status = activeTab;
@@ -69,7 +94,7 @@ const UserManagement = () => {
setModalVisible(true); setModalVisible(true);
}, []); }, []);
const handleEdit = useCallback((user) => { const handleEdit = useCallback(user => {
setEditingUser(user); setEditingUser(user);
form.setFieldsValue({ form.setFieldsValue({
username: user.username, username: user.username,
@@ -77,55 +102,58 @@ const UserManagement = () => {
phone: user.phone, phone: user.phone,
realName: user.realName, realName: user.realName,
status: user.status, status: user.status,
roleIds: user.roles?.map(r => r.roleId) || [] roleIds: user.roles?.map(r => r.roleId) || [],
}); });
setModalVisible(true); setModalVisible(true);
}, []); }, []);
const handleResetPassword = useCallback((user) => { const handleResetPassword = useCallback(user => {
setPasswordUser(user); setPasswordUser(user);
passwordForm.resetFields(); passwordForm.resetFields();
setPasswordModalVisible(true); setPasswordModalVisible(true);
}, []); }, []);
const handleAvatarClick = useCallback((user) => { const handleAvatarClick = useCallback(user => {
setAvatarUser(user); setAvatarUser(user);
setAvatarModalVisible(true); setAvatarModalVisible(true);
}, []); }, []);
const handleAvatarUpload = useCallback(async (e) => { const handleAvatarUpload = useCallback(
const file = e.target.files[0]; async e => {
if (!file) return; const file = e.target.files[0];
if (!file) return;
if (!file.type.match(/image\/(jpeg|png|gif|webp)/)) { if (!file.type.match(/image\/(jpeg|png|gif|webp)/)) {
message.error('只支持 JPG、PNG、GIF 和 WebP 格式的图片'); message.error('只支持 JPG、PNG、GIF 和 WebP 格式的图片');
return; 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 || '上传失败');
} }
} catch (error) {
message.error('上传失败'); if (file.size > 5 * 1024 * 1024) {
} finally { message.error('图片大小不能超过 5MB');
setUploadLoading(false); return;
if (fileInputRef.current) {
fileInputRef.current.value = '';
} }
}
}, [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 () => { const handleAvatarDelete = useCallback(async () => {
try { try {
@@ -142,283 +170,302 @@ const UserManagement = () => {
} }
}, [avatarUser, fetchUsers]); }, [avatarUser, fetchUsers]);
const handleDelete = useCallback(async (userId) => { const handleDelete = useCallback(
try { async userId => {
const response = await userAPI.delete(userId); try {
if (response.success) { const response = await userAPI.delete(userId);
message.success('删除成功'); if (response.success) {
fetchUsers(); message.success('删除成功');
} else { fetchUsers();
message.error(response.message || '删除失败'); } else {
message.error(response.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
} }
} catch (error) { },
message.error('删除失败'); [fetchUsers]
} );
}, [fetchUsers]);
const handleLockUnlock = useCallback(async (record) => { const handleLockUnlock = useCallback(
try { async record => {
const newStatus = record.status === 'locked' ? 'active' : 'locked'; try {
const response = await userAPI.update(record.userId, { const newStatus = record.status === 'locked' ? 'active' : 'locked';
status: newStatus const response = await userAPI.update(record.userId, {
}); status: newStatus,
if (response.success) { });
message.success(record.status === 'locked' ? '解锁成功' : '锁定成功'); if (response.success) {
fetchUsers(); message.success(record.status === 'locked' ? '解锁成功' : '锁定成功');
} else { fetchUsers();
message.error(response.message || '操作失败'); } else {
message.error(response.message || '操作失败');
}
} catch (error) {
message.error('操作失败');
} }
} catch (error) { },
message.error('操作失败'); [fetchUsers]
} );
}, [fetchUsers]);
const handleSubmit = useCallback(async (values) => { const handleSubmit = useCallback(
try { async values => {
let response; try {
if (editingUser) { let response;
response = await userAPI.update(editingUser.userId, values); if (editingUser) {
} else { response = await userAPI.update(editingUser.userId, values);
response = await userAPI.create(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) { const handleResetPasswordSubmit = useCallback(
message.success(editingUser ? '更新成功' : '创建成功'); async values => {
setModalVisible(false); try {
fetchUsers(); const response = await userAPI.resetPassword(passwordUser.userId, values);
} else { if (response.success) {
message.error(response.message || '操作失败'); message.success('密码重置成功');
setPasswordModalVisible(false);
} else {
message.error(response.message || '重置失败');
}
} catch (error) {
message.error('重置失败');
} }
} catch (error) { },
message.error('操作失败'); [passwordUser]
} );
}, [editingUser, fetchUsers]);
const handleResetPasswordSubmit = useCallback(async (values) => { const getStatusColor = status => {
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 colors = { const colors = {
active: 'green', active: 'green',
inactive: 'red', inactive: 'red',
locked: 'orange', locked: 'orange',
pending: 'blue' pending: 'blue',
}; };
return colors[status] || 'default'; return colors[status] || 'default';
}; };
const getStatusText = (status) => { const getStatusText = status => {
const texts = { const texts = {
active: '正常', active: '正常',
inactive: '禁用', inactive: '禁用',
locked: '锁定', locked: '锁定',
pending: '待审核' pending: '待审核',
}; };
return texts[status] || status; return texts[status] || status;
}; };
const handleApprove = useCallback(async (userId) => { const handleApprove = useCallback(
try { async userId => {
const response = await userAPI.approve(userId); try {
if (response.success) { const response = await userAPI.approve(userId);
message.success('审核通过'); if (response.success) {
fetchUsers(); message.success('审核通过');
} else { fetchUsers();
message.error(response.message || '审核失败'); } else {
message.error(response.message || '审核失败');
}
} catch (error) {
message.error('审核失败');
} }
} catch (error) { },
message.error('审核失败'); [fetchUsers]
} );
}, [fetchUsers]);
const handleReject = useCallback(async (userId) => { const handleReject = useCallback(
try { async userId => {
const response = await userAPI.reject(userId); try {
if (response.success) { const response = await userAPI.reject(userId);
message.success('已拒绝该用户的注册申请'); if (response.success) {
fetchUsers(); message.success('已拒绝该用户的注册申请');
} else { fetchUsers();
message.error(response.message || '操作失败'); } 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; if (!user?.avatar) return null;
return user.avatar; return user.avatar;
}; };
const tableColumns = useMemo(() => [ const tableColumns = useMemo(
{ () => [
title: '头像', {
key: 'avatar', title: '头像',
width: 80, key: 'avatar',
render: (_, record) => ( width: 80,
<Badge dot={!!record.avatar} color="green" offset={[-5, 35]}> render: (_, record) => (
<Avatar <Badge dot={!!record.avatar} color="green" offset={[-5, 35]}>
size={48} <Avatar
icon={!record.avatar && <UserOutlined />} size={48}
src={getAvatarUrl(record)} icon={!record.avatar && <UserOutlined />}
style={{ src={getAvatarUrl(record)}
backgroundColor: record.avatar ? 'transparent' : '#1890ff', style={{
cursor: 'pointer' backgroundColor: record.avatar ? 'transparent' : '#1890ff',
}} cursor: 'pointer',
onClick={() => handleAvatarClick(record)} }}
/> onClick={() => handleAvatarClick(record)}
</Badge> />
) </Badge>
}, ),
{ },
title: '用户名', {
key: 'username', title: '用户名',
width: 150, key: 'username',
render: (_, record) => ( width: 150,
<div> render: (_, record) => (
<div style={{ fontWeight: 500 }}>{record.realName || record.username}</div> <div>
<div style={{ fontSize: '12px', color: '#999' }}>@{record.username}</div> <div style={{ fontWeight: 500 }}>{record.realName || record.username}</div>
</div> <div style={{ fontSize: '12px', color: '#999' }}>@{record.username}</div>
) </div>
}, ),
{ },
title: '邮箱', {
dataIndex: 'email', title: '邮箱',
key: 'email', dataIndex: 'email',
width: 200, key: 'email',
render: (email) => email || '-' width: 200,
}, render: email => email || '-',
{ },
title: '手机号', {
dataIndex: 'phone', title: '手机号',
key: 'phone', dataIndex: 'phone',
width: 130, key: 'phone',
render: (phone) => phone || '-' width: 130,
}, render: phone => phone || '-',
{ },
title: '角色', {
key: 'roles', title: '角色',
render: (_, record) => ( key: 'roles',
<Space wrap> render: (_, record) => (
{record.roles?.map(role => ( <Space wrap>
<Tag key={role.roleId} color={role.roleCode === 'admin' ? 'blue' : 'green'}> {record.roles?.map(role => (
{role.roleName} <Tag key={role.roleId} color={role.roleCode === 'admin' ? 'blue' : 'green'}>
</Tag> {role.roleName}
)) || '-'} </Tag>
</Space> )) || '-'}
) </Space>
}, ),
{ },
title: '状态', {
dataIndex: 'status', title: '状态',
key: 'status', dataIndex: 'status',
render: (status) => ( key: 'status',
<Tag color={getStatusColor(status)}>{getStatusText(status)}</Tag> render: status => <Tag color={getStatusColor(status)}>{getStatusText(status)}</Tag>,
) },
}, {
{ title: '最后登录',
title: '最后登录', key: 'lastLogin',
key: 'lastLogin', render: (_, record) => (
render: (_, record) => ( <div style={{ fontSize: '12px' }}>
<div style={{ fontSize: '12px' }}> <div>
<div>{record.lastLoginTime ? new Date(record.lastLoginTime).toLocaleString() : '从未登录'}</div> {record.lastLoginTime ? new Date(record.lastLoginTime).toLocaleString() : '从未登录'}
<div style={{ color: '#999' }}>{record.lastLoginIp || '-'}</div> </div>
</div> <div style={{ color: '#999' }}>{record.lastLoginIp || '-'}</div>
) </div>
}, ),
{ },
title: '操作', {
key: 'action', title: '操作',
render: (_, record) => ( key: 'action',
<Space size="small"> render: (_, record) => (
{record.status === 'pending' ? ( <Space size="small">
<> {record.status === 'pending' ? (
<Popconfirm <>
title="确定要通过该用户的注册申请吗?" <Popconfirm
onConfirm={() => handleApprove(record.userId)} title="确定要通过该用户的注册申请吗?"
okText="通过" onConfirm={() => handleApprove(record.userId)}
cancelText="取消" okText="通过"
> cancelText="取消"
<Tooltip title="通过"> >
<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 <Button
type="text" type="text"
icon={<CheckOutlined />} icon={<LockOutlined />}
style={{ color: '#52c41a' }} onClick={() => handleResetPassword(record)}
/> />
</Tooltip> </Tooltip>
</Popconfirm> <Popconfirm
<Popconfirm title={
title="确定要拒绝该用户的注册申请吗?" record.status === 'locked' ? '确定要解锁此用户吗?' : '确定要锁定此用户吗?'
onConfirm={() => handleReject(record.userId)} }
okText="拒绝" onConfirm={() => handleLockUnlock(record)}
cancelText="取消" okText="确定"
> cancelText="取消"
<Tooltip title="拒绝"> >
<Button <Tooltip title={record.status === 'locked' ? '解锁' : '锁定'}>
type="text" <Button
icon={<CloseOutlined />} type="text"
style={{ color: '#ff4d4f' }} icon={record.status === 'locked' ? <ReloadOutlined /> : <LockOutlined />}
/> style={{ color: record.status === 'locked' ? '#52c41a' : '#faad14' }}
</Tooltip> />
</Popconfirm> </Tooltip>
</> </Popconfirm>
) : ( <Popconfirm
<> title="确定要删除此用户吗?"
<Tooltip title="编辑"> onConfirm={() => handleDelete(record.userId)}
<Button okText="确定"
type="text" cancelText="取消"
icon={<EditOutlined />} >
onClick={() => handleEdit(record)} <Tooltip title="删除">
/> <Button type="text" danger icon={<DeleteOutlined />} />
</Tooltip> </Tooltip>
<Tooltip title="重置密码"> </Popconfirm>
<Button </>
type="text" )}
icon={<LockOutlined />} </Space>
onClick={() => handleResetPassword(record)} ),
/> },
</Tooltip> ],
<Popconfirm [
title={record.status === 'locked' ? '确定要解锁此用户吗?' : '确定要锁定此用户吗?'} handleAvatarClick,
onConfirm={() => handleLockUnlock(record)} handleEdit,
okText="确定" handleResetPassword,
cancelText="取消" handleDelete,
> handleLockUnlock,
<Tooltip title={record.status === 'locked' ? '解锁' : '锁定'}> handleApprove,
<Button handleReject,
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 = { const pageHeaderStyle = {
marginBottom: '24px', marginBottom: '24px',
@@ -426,7 +473,7 @@ const UserManagement = () => {
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
flexWrap: 'wrap', flexWrap: 'wrap',
gap: '16px' gap: '16px',
}; };
const titleStyle = { const titleStyle = {
@@ -436,20 +483,20 @@ const UserManagement = () => {
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
WebkitBackgroundClip: 'text', WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent', WebkitTextFillColor: 'transparent',
backgroundClip: 'text' backgroundClip: 'text',
}; };
const cardStyle = { const cardStyle = {
borderRadius: '16px', borderRadius: '16px',
border: 'none', border: 'none',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)', boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
overflow: 'hidden' overflow: 'hidden',
}; };
const cardHeadStyle = { const cardHeadStyle = {
borderBottom: '1px solid #f0f0f0', borderBottom: '1px solid #f0f0f0',
padding: '16px 24px', padding: '16px 24px',
background: 'linear-gradient(135deg, #f8f9ff 0%, #ffffff 100%)' background: 'linear-gradient(135deg, #f8f9ff 0%, #ffffff 100%)',
}; };
const primaryButtonStyle = { const primaryButtonStyle = {
@@ -459,21 +506,21 @@ const UserManagement = () => {
border: 'none', border: 'none',
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.35)', boxShadow: '0 4px 12px rgba(102, 126, 234, 0.35)',
fontWeight: '500', fontWeight: '500',
transition: 'all 0.3s ease' transition: 'all 0.3s ease',
}; };
const secondaryButtonStyle = { const secondaryButtonStyle = {
height: '40px', height: '40px',
borderRadius: '8px', borderRadius: '8px',
border: '1px solid #e8e8e8', border: '1px solid #e8e8e8',
transition: 'all 0.3s ease' transition: 'all 0.3s ease',
}; };
const actionButtonStyle = { const actionButtonStyle = {
height: '32px', height: '32px',
borderRadius: '6px', borderRadius: '6px',
border: '1px solid #e8e8e8', border: '1px solid #e8e8e8',
transition: 'all 0.3s ease' transition: 'all 0.3s ease',
}; };
const modalHeaderStyle = { const modalHeaderStyle = {
@@ -481,25 +528,25 @@ const UserManagement = () => {
alignItems: 'center', alignItems: 'center',
gap: '8px', gap: '8px',
fontSize: '18px', fontSize: '18px',
fontWeight: '600' fontWeight: '600',
}; };
const modalHeaderAccent = { const modalHeaderAccent = {
width: '4px', width: '4px',
height: '20px', height: '20px',
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)', background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
borderRadius: '2px' borderRadius: '2px',
}; };
const avatarModalStyle = { const avatarModalStyle = {
textAlign: 'center', textAlign: 'center',
padding: '20px 0' padding: '20px 0',
}; };
const avatarWrapperStyle = { const avatarWrapperStyle = {
marginBottom: '24px', marginBottom: '24px',
position: 'relative', position: 'relative',
display: 'inline-block' display: 'inline-block',
}; };
return ( return (
@@ -507,11 +554,7 @@ const UserManagement = () => {
<div style={pageHeaderStyle}> <div style={pageHeaderStyle}>
<h1 style={titleStyle}>用户管理</h1> <h1 style={titleStyle}>用户管理</h1>
<Space size="middle"> <Space size="middle">
<Button <Button icon={<ReloadOutlined />} onClick={fetchUsers} style={secondaryButtonStyle}>
icon={<ReloadOutlined />}
onClick={fetchUsers}
style={secondaryButtonStyle}
>
刷新 刷新
</Button> </Button>
<Button <Button
@@ -533,10 +576,10 @@ const UserManagement = () => {
{ key: 'pending', tab: '待审核' }, { key: 'pending', tab: '待审核' },
{ key: 'active', tab: '正常' }, { key: 'active', tab: '正常' },
{ key: 'locked', tab: '锁定' }, { key: 'locked', tab: '锁定' },
{ key: 'inactive', tab: '禁用' } { key: 'inactive', tab: '禁用' },
]} ]}
activeTabKey={activeTab} activeTabKey={activeTab}
onTabChange={(key) => { onTabChange={key => {
setActiveTab(key); setActiveTab(key);
setPagination(prev => ({ ...prev, current: 1 })); setPagination(prev => ({ ...prev, current: 1 }));
}} }}
@@ -550,9 +593,9 @@ const UserManagement = () => {
...pagination, ...pagination,
showSizeChanger: true, showSizeChanger: true,
showQuickJumper: true, showQuickJumper: true,
showTotal: (total) => `${total} 条记录` showTotal: total => `${total} 条记录`,
}} }}
onChange={(newPagination) => { onChange={newPagination => {
setPagination(prev => ({ ...prev, ...newPagination })); setPagination(prev => ({ ...prev, ...newPagination }));
}} }}
rowClassName={() => 'table-row'} rowClassName={() => 'table-row'}
@@ -573,22 +616,17 @@ const UserManagement = () => {
destroyOnHidden destroyOnHidden
styles={{ styles={{
body: { padding: '24px' }, body: { padding: '24px' },
header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' } header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' },
}} }}
style={{ borderRadius: '16px', overflow: 'hidden' }} style={{ borderRadius: '16px', overflow: 'hidden' }}
> >
<Form <Form form={form} layout="vertical" onFinish={handleSubmit} style={{ marginTop: '20px' }}>
form={form}
layout="vertical"
onFinish={handleSubmit}
style={{ marginTop: '20px' }}
>
<Form.Item <Form.Item
name="username" name="username"
label="用户名" label="用户名"
rules={[ rules={[
{ required: true, message: '请输入用户名' }, { required: true, message: '请输入用户名' },
{ min: 3, max: 20, message: '用户名长度必须在3-20个字符之间' } { min: 3, max: 20, message: '用户名长度必须在3-20个字符之间' },
]} ]}
> >
<Input placeholder="请输入用户名" style={{ borderRadius: '8px' }} /> <Input placeholder="请输入用户名" style={{ borderRadius: '8px' }} />
@@ -607,7 +645,7 @@ const UserManagement = () => {
label="邮箱" label="邮箱"
rules={[ rules={[
{ required: true, message: '请输入邮箱' }, { required: true, message: '请输入邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' } { type: 'email', message: '请输入有效的邮箱地址' },
]} ]}
> >
<Input placeholder="请输入邮箱" style={{ borderRadius: '8px' }} /> <Input placeholder="请输入邮箱" style={{ borderRadius: '8px' }} />
@@ -637,7 +675,7 @@ const UserManagement = () => {
label="初始密码" label="初始密码"
rules={[ rules={[
{ required: true, message: '请输入初始密码' }, { required: true, message: '请输入初始密码' },
{ min: 6, message: '密码长度不能少于6个字符' } { min: 6, message: '密码长度不能少于6个字符' },
]} ]}
> >
<Input.Password placeholder="请输入初始密码" style={{ borderRadius: '8px' }} /> <Input.Password placeholder="请输入初始密码" style={{ borderRadius: '8px' }} />
@@ -656,9 +694,7 @@ const UserManagement = () => {
<Form.Item <Form.Item
name="newPassword" name="newPassword"
label="新密码" label="新密码"
rules={[ rules={[{ min: 6, message: '密码长度不能少于6个字符' }]}
{ min: 6, message: '密码长度不能少于6个字符' }
]}
> >
<Input.Password placeholder="留空则不修改密码" style={{ borderRadius: '8px' }} /> <Input.Password placeholder="留空则不修改密码" style={{ borderRadius: '8px' }} />
</Form.Item> </Form.Item>
@@ -680,7 +716,7 @@ const UserManagement = () => {
...primaryButtonStyle, ...primaryButtonStyle,
width: 'auto', width: 'auto',
paddingLeft: '24px', paddingLeft: '24px',
paddingRight: '24px' paddingRight: '24px',
}} }}
> >
{editingUser ? '更新' : '创建'} {editingUser ? '更新' : '创建'}
@@ -704,7 +740,7 @@ const UserManagement = () => {
destroyOnHidden destroyOnHidden
styles={{ styles={{
body: { padding: '24px' }, body: { padding: '24px' },
header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' } header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' },
}} }}
style={{ borderRadius: '16px', overflow: 'hidden' }} style={{ borderRadius: '16px', overflow: 'hidden' }}
> >
@@ -719,7 +755,7 @@ const UserManagement = () => {
label="新密码" label="新密码"
rules={[ rules={[
{ required: true, message: '请输入新密码' }, { required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度不能少于6个字符' } { min: 6, message: '密码长度不能少于6个字符' },
]} ]}
> >
<Input.Password placeholder="请输入新密码" style={{ borderRadius: '8px' }} /> <Input.Password placeholder="请输入新密码" style={{ borderRadius: '8px' }} />
@@ -741,7 +777,7 @@ const UserManagement = () => {
...primaryButtonStyle, ...primaryButtonStyle,
width: 'auto', width: 'auto',
paddingLeft: '24px', paddingLeft: '24px',
paddingRight: '24px' paddingRight: '24px',
}} }}
> >
重置 重置
@@ -765,7 +801,7 @@ const UserManagement = () => {
destroyOnHidden destroyOnHidden
styles={{ styles={{
body: { padding: '24px' }, body: { padding: '24px' },
header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' } header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' },
}} }}
style={{ borderRadius: '16px', overflow: 'hidden' }} style={{ borderRadius: '16px', overflow: 'hidden' }}
> >
@@ -779,7 +815,7 @@ const UserManagement = () => {
style={{ style={{
backgroundColor: avatarUser?.avatar ? 'transparent' : '#1890ff', backgroundColor: avatarUser?.avatar ? 'transparent' : '#1890ff',
border: '1px solid #f0f0f0', border: '1px solid #f0f0f0',
cursor: 'pointer' cursor: 'pointer',
}} }}
/> />
</Badge> </Badge>
@@ -802,7 +838,7 @@ const UserManagement = () => {
block block
style={{ style={{
...primaryButtonStyle, ...primaryButtonStyle,
height: '44px' height: '44px',
}} }}
> >
{avatarUser?.avatar ? '更换头像' : '上传头像'} {avatarUser?.avatar ? '更换头像' : '上传头像'}
+62 -59
View File
@@ -11,12 +11,12 @@ const { colors, shadows, borderRadius, transitions, spacing } = designTokens;
export const pageContainerStyle = { export const pageContainerStyle = {
minHeight: '100vh', minHeight: '100vh',
background: colors.background.secondary, background: colors.background.secondary,
padding: spacing.lg padding: spacing.lg,
}; };
// 头部样式 // 头部样式
export const headerStyle = { export const headerStyle = {
marginBottom: spacing.lg marginBottom: spacing.lg,
}; };
// 标题行样式 // 标题行样式
@@ -26,14 +26,14 @@ export const titleRowStyle = {
justifyContent: 'space-between', justifyContent: 'space-between',
marginBottom: spacing.lg, marginBottom: spacing.lg,
flexWrap: 'wrap', flexWrap: 'wrap',
gap: spacing.md gap: spacing.md,
}; };
// 标题区域样式 // 标题区域样式
export const titleSectionStyle = { export const titleSectionStyle = {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: spacing.md gap: spacing.md,
}; };
// 标题图标样式 // 标题图标样式
@@ -45,14 +45,14 @@ export const titleIconStyle = {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
boxShadow: shadows.medium boxShadow: shadows.medium,
}; };
// 标题文本样式 // 标题文本样式
export const titleTextStyle = { export const titleTextStyle = {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '2px' gap: '2px',
}; };
// 页面标题样式 // 页面标题样式
@@ -61,14 +61,14 @@ export const pageTitleStyle = {
fontWeight: '700', fontWeight: '700',
margin: 0, margin: 0,
color: colors.text.primary, color: colors.text.primary,
lineHeight: 1.2 lineHeight: 1.2,
}; };
// 页面副标题样式 // 页面副标题样式
export const pageSubtitleStyle = { export const pageSubtitleStyle = {
fontSize: '13px', fontSize: '13px',
color: colors.text.secondary, color: colors.text.secondary,
margin: 0 margin: 0,
}; };
// 操作按钮基础样式 // 操作按钮基础样式
@@ -79,7 +79,7 @@ export const actionButtonStyle = {
fontWeight: '500', fontWeight: '500',
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
gap: '6px' gap: '6px',
}; };
// 主要操作按钮样式 // 主要操作按钮样式
@@ -88,7 +88,7 @@ export const primaryActionStyle = {
background: colors.primary.gradient, background: colors.primary.gradient,
border: 'none', border: 'none',
color: '#ffffff !important', color: '#ffffff !important',
boxShadow: shadows.small boxShadow: shadows.small,
}; };
// 次要操作按钮样式 // 次要操作按钮样式
@@ -96,7 +96,7 @@ export const secondaryActionStyle = {
...actionButtonStyle, ...actionButtonStyle,
background: colors.background.primary, background: colors.background.primary,
border: `1px solid ${colors.border.light}`, border: `1px solid ${colors.border.light}`,
color: colors.text.primary color: colors.text.primary,
}; };
// 危险操作按钮样式 // 危险操作按钮样式
@@ -104,7 +104,7 @@ export const dangerActionStyle = {
...actionButtonStyle, ...actionButtonStyle,
background: colors.error.main, background: colors.error.main,
border: 'none', border: 'none',
color: '#ffffff' color: '#ffffff',
}; };
// 主要按钮样式(大) // 主要按钮样式(大)
@@ -118,7 +118,7 @@ export const primaryButtonStyle = {
fontWeight: '500', fontWeight: '500',
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center' justifyContent: 'center',
}; };
// 统计卡片行样式 // 统计卡片行样式
@@ -126,7 +126,7 @@ export const statsRowStyle = {
display: 'flex', display: 'flex',
gap: spacing.md, gap: spacing.md,
marginBottom: spacing.lg, marginBottom: spacing.lg,
flexWrap: 'wrap' flexWrap: 'wrap',
}; };
// 统计卡片基础样式 // 统计卡片基础样式
@@ -139,7 +139,7 @@ export const statCardStyle = {
borderRadius: borderRadius.medium, borderRadius: borderRadius.medium,
border: `1px solid ${colors.border.light}`, border: `1px solid ${colors.border.light}`,
boxShadow: shadows.small, boxShadow: shadows.small,
transition: `all ${transitions.fast}` transition: `all ${transitions.fast}`,
}; };
// 统计数值样式 // 统计数值样式
@@ -147,35 +147,35 @@ export const statValueStyle = {
fontSize: '24px', fontSize: '24px',
fontWeight: '700', fontWeight: '700',
color: colors.text.primary, color: colors.text.primary,
lineHeight: 1.2 lineHeight: 1.2,
}; };
// 统计标签样式 // 统计标签样式
export const statLabelStyle = { export const statLabelStyle = {
fontSize: '12px', fontSize: '12px',
color: colors.text.secondary, color: colors.text.secondary,
marginTop: '4px' marginTop: '4px',
}; };
// 运行中状态统计卡片样式 // 运行中状态统计卡片样式
export const statCardRunningStyle = { export const statCardRunningStyle = {
...statCardStyle, ...statCardStyle,
borderLeft: `3px solid ${colors.success.main}`, borderLeft: `3px solid ${colors.success.main}`,
background: `${colors.success.main}08` background: `${colors.success.main}08`,
}; };
// 维护中状态统计卡片样式 // 维护中状态统计卡片样式
export const statCardMaintenanceStyle = { export const statCardMaintenanceStyle = {
...statCardStyle, ...statCardStyle,
borderLeft: `3px solid ${colors.warning.main}`, borderLeft: `3px solid ${colors.warning.main}`,
background: `${colors.warning.main}08` background: `${colors.warning.main}08`,
}; };
// 故障状态统计卡片样式 // 故障状态统计卡片样式
export const statCardFaultStyle = { export const statCardFaultStyle = {
...statCardStyle, ...statCardStyle,
borderLeft: `3px solid ${colors.error.main}`, 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', border: 'none',
boxShadow: shadows.medium, boxShadow: shadows.medium,
overflow: 'hidden', overflow: 'hidden',
background: colors.background.primary background: colors.background.primary,
}; };
// 筛选卡片样式 // 筛选卡片样式
@@ -193,7 +193,7 @@ export const filterCardStyle = {
border: 'none', border: 'none',
boxShadow: shadows.small, boxShadow: shadows.small,
background: colors.background.primary, background: colors.background.primary,
marginBottom: spacing.lg marginBottom: spacing.lg,
}; };
// 模态框头部样式 // 模态框头部样式
@@ -202,7 +202,7 @@ export const modalHeaderStyle = {
alignItems: 'center', alignItems: 'center',
gap: spacing.sm, gap: spacing.sm,
fontSize: '18px', fontSize: '18px',
fontWeight: '600' fontWeight: '600',
}; };
// 表格样式常量 // 表格样式常量
@@ -210,7 +210,7 @@ export const tableStyles = {
// 表格容器样式 // 表格容器样式
wrapper: { wrapper: {
borderRadius: borderRadius.medium, borderRadius: borderRadius.medium,
overflow: 'hidden' overflow: 'hidden',
}, },
// 空状态样式 // 空状态样式
@@ -218,15 +218,15 @@ export const tableStyles = {
textAlign: 'center', textAlign: 'center',
padding: '60px 20px', padding: '60px 20px',
color: colors.text.secondary, color: colors.text.secondary,
fontSize: '15px' fontSize: '15px',
}, },
// 空状态图标样式 // 空状态图标样式
emptyIcon: { emptyIcon: {
fontSize: '48px', fontSize: '48px',
marginBottom: '16px', marginBottom: '16px',
color: colors.border.light color: colors.border.light,
} },
}; };
// 搜索输入框样式 // 搜索输入框样式
@@ -234,24 +234,24 @@ export const searchInputStyle = {
width: '280px', width: '280px',
borderRadius: borderRadius.medium, borderRadius: borderRadius.medium,
border: `1px solid ${colors.border.light}`, border: `1px solid ${colors.border.light}`,
transition: `all ${transitions.fast}` transition: `all ${transitions.fast}`,
}; };
// 选择器样式 // 选择器样式
export const selectStyle = { export const selectStyle = {
borderRadius: borderRadius.medium borderRadius: borderRadius.medium,
}; };
// 下拉菜单样式 // 下拉菜单样式
export const dropdownStyle = { export const dropdownStyle = {
borderRadius: borderRadius.medium borderRadius: borderRadius.medium,
}; };
// 刷新按钮样式 // 刷新按钮样式
export const refreshButtonStyle = { export const refreshButtonStyle = {
borderRadius: borderRadius.medium, borderRadius: borderRadius.medium,
border: `1px solid ${colors.border.light}`, border: `1px solid ${colors.border.light}`,
height: '36px' height: '36px',
}; };
// 搜索按钮样式 // 搜索按钮样式
@@ -260,14 +260,14 @@ export const searchButtonStyle = {
borderRadius: borderRadius.medium, borderRadius: borderRadius.medium,
background: colors.primary.gradient, background: colors.primary.gradient,
border: 'none', border: 'none',
boxShadow: shadows.small boxShadow: shadows.small,
}; };
// 重置按钮样式 // 重置按钮样式
export const resetButtonStyle = { export const resetButtonStyle = {
height: '36px', height: '36px',
borderRadius: borderRadius.medium, borderRadius: borderRadius.medium,
border: `1px solid ${colors.border.light}` border: `1px solid ${colors.border.light}`,
}; };
// 导入模态框样式 // 导入模态框样式
@@ -278,14 +278,14 @@ export const importModalStyles = {
padding: '16px', padding: '16px',
background: 'linear-gradient(180deg, #fafafa 0%, #ffffff 100%)', background: 'linear-gradient(180deg, #fafafa 0%, #ffffff 100%)',
borderRadius: '12px', borderRadius: '12px',
border: '1px solid #f0f0f0' border: '1px solid #f0f0f0',
}, },
// 标题样式 // 标题样式
title: { title: {
fontWeight: '600', fontWeight: '600',
marginBottom: '8px', marginBottom: '8px',
color: '#333' color: '#333',
}, },
// 列表样式 // 列表样式
@@ -294,14 +294,14 @@ export const importModalStyles = {
marginBottom: '10px', marginBottom: '10px',
color: '#666', color: '#666',
fontSize: '13px', fontSize: '13px',
marginTop: '12px' marginTop: '12px',
}, },
// 进度容器样式 // 进度容器样式
progressContainer: { progressContainer: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
marginBottom: '16px' marginBottom: '16px',
}, },
// 进度图标样式 // 进度图标样式
@@ -315,7 +315,7 @@ export const importModalStyles = {
justifyContent: 'center', justifyContent: 'center',
marginRight: '16px', marginRight: '16px',
color: '#fff', color: '#fff',
fontSize: '20px' fontSize: '20px',
}, },
// 进度信息样式 // 进度信息样式
@@ -324,37 +324,40 @@ export const importModalStyles = {
margin: '0 0 4px 0', margin: '0 0 4px 0',
fontWeight: '600', fontWeight: '600',
color: '#333', color: '#333',
fontSize: '16px' fontSize: '16px',
}, },
phase: { phase: {
margin: 0, margin: 0,
color: colors.primary.main, color: colors.primary.main,
fontSize: '14px' fontSize: '14px',
} },
}, },
// 结果卡片样式 // 结果卡片样式
resultCard: (type) => ({ resultCard: type => ({
padding: '12px', padding: '12px',
background: type === 'total' ? colors.primary.gradient : background:
type === 'success' ? 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)' : type === 'total'
'linear-gradient(135deg, #ff4d4f 0%, #cf1322 100%)', ? colors.primary.gradient
: type === 'success'
? 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)'
: 'linear-gradient(135deg, #ff4d4f 0%, #cf1322 100%)',
borderRadius: '8px', borderRadius: '8px',
color: '#fff', color: '#fff',
textAlign: 'center' textAlign: 'center',
}), }),
// 结果数值样式 // 结果数值样式
resultValue: { resultValue: {
fontSize: '24px', fontSize: '24px',
fontWeight: '700' fontWeight: '700',
}, },
// 结果标签样式 // 结果标签样式
resultLabel: { resultLabel: {
fontSize: '12px', fontSize: '12px',
opacity: 0.9 opacity: 0.9,
} },
}; };
// 详情模态框样式 // 详情模态框样式
@@ -363,17 +366,17 @@ export const detailModalStyles = {
infoItem: { infoItem: {
label: { label: {
fontWeight: '500', fontWeight: '500',
color: '#666' color: '#666',
}, },
value: { value: {
marginLeft: 8, marginLeft: 8,
color: '#333' color: '#333',
} },
}, },
// 描述区域样式 // 描述区域样式
description: { description: {
marginTop: '16px' marginTop: '16px',
}, },
// 描述内容样式 // 描述内容样式
@@ -382,8 +385,8 @@ export const detailModalStyles = {
padding: '12px', padding: '12px',
backgroundColor: '#fafafa', backgroundColor: '#fafafa',
borderRadius: '8px', borderRadius: '8px',
color: '#333' color: '#333',
} },
}; };
// 导出模态框样式 // 导出模态框样式
@@ -394,17 +397,17 @@ export const exportModalStyles = {
overflow: 'auto', overflow: 'auto',
border: '1px solid #f0f0f0', border: '1px solid #f0f0f0',
borderRadius: '8px', borderRadius: '8px',
padding: '12px' padding: '12px',
}, },
// 字段项样式 // 字段项样式
fieldItem: { fieldItem: {
marginBottom: '8px' marginBottom: '8px',
} },
}; };
// CSS-in-JS 样式字符串生成函数 // CSS-in-JS 样式字符串生成函数
export const generateGlobalStyles = (tokens) => ` export const generateGlobalStyles = tokens => `
.device-modal .ant-modal-close { .device-modal .ant-modal-close {
top: 16px; top: 16px;
right: 24px; right: 24px;