feat(UI): 优化多个页面的UI设计和交互体验

refactor(ProtectedRoute): 调整加载状态布局和样式
feat(DeviceFieldManagement): 添加设计令牌和样式优化
feat(App): 重构侧边栏和顶部导航栏样式
feat(ConsumableStatistics): 重新设计统计卡片和表格样式
style: 统一多个组件的设计语言和配色方案
This commit is contained in:
zhang1106
2026-01-20 11:28:53 +08:00
parent 25c1f5f905
commit 88e000a41a
11 changed files with 4476 additions and 1526 deletions
+339 -187
View File
@@ -27,6 +27,55 @@ const SystemSettings = lazy(() => import('./pages/SystemSettings'));
const { Header, Content, Sider } = Layout;
const designTokens = {
colors: {
primary: {
main: '#667eea',
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
light: '#8b9ff0'
},
success: { main: '#10b981' },
warning: { main: '#f59e0b' },
error: { main: '#ef4444' },
text: {
primary: '#1e293b',
secondary: '#64748b',
inverse: '#ffffff'
},
background: {
primary: '#ffffff',
secondary: '#f8fafc',
dark: '#1e293b'
},
border: {
light: '#e2e8f0'
},
sidebar: {
bg: '#ffffff',
bgHover: 'rgba(102, 126, 234, 0.08)',
bgActive: 'rgba(102, 126, 234, 0.15)',
text: '#475569',
textHover: '#667eea',
textActive: '#667eea',
border: '#e2e8f0'
}
},
shadows: {
small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
large: '0 10px 15px -3px rgba(0, 0, 0, 0.1)'
},
borderRadius: {
small: '6px',
medium: '10px'
},
spacing: {
sm: '8px',
md: '16px',
lg: '24px'
}
};
const PrivateRoute = ({ children }) => {
const { token, initialized, loading } = useAuth();
const location = useLocation();
@@ -35,12 +84,15 @@ const PrivateRoute = ({ children }) => {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
background: '#f5f5f5'
background: '#f5f5f5',
gap: '16px'
}}>
<Spin size="large" tip="正在加载认证状态..." />
<Spin size="large" />
<span style={{ color: '#8c8c8c', fontSize: '14px' }}>正在加载认证状态...</span>
</div>
);
}
@@ -54,12 +106,15 @@ const PrivateRoute = ({ children }) => {
fallback={
<div style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
background: '#f5f5f5'
background: '#f5f5f5',
gap: '16px'
}}>
<Spin size="large" tip="正在加载页面..." />
<Spin size="large" />
<span style={{ color: '#8c8c8c', fontSize: '14px' }}>正在加载页面...</span>
</div>
}
>
@@ -72,237 +127,331 @@ const PrivateRoute = ({ children }) => {
const AppLayout = ({ children }) => {
const [collapsed, setCollapsed] = useState(false);
const [activeKey, setActiveKey] = useState('dashboard');
const { user, logout } = useAuth();
const navigate = useNavigate();
const {
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken();
const location = useLocation();
const handleLogout = () => {
logout();
message.success('已退出登录');
navigate('/login');
};
const userMenuItems = [
const getSelectedKey = () => {
const path = location.pathname;
if (path === '/') return 'dashboard';
if (path.startsWith('/rooms') || path.startsWith('/racks') || path.startsWith('/visualization')) return 'room-management';
if (path.startsWith('/devices') || path.startsWith('/fields')) return 'asset-management';
if (path.startsWith('/consumables')) return 'consumables-management';
if (path.startsWith('/users') || path.startsWith('/login-history') || path.startsWith('/operation-logs') || path.startsWith('/settings')) return 'system-management';
if (path.startsWith('/tickets')) return 'ticket-management';
return 'dashboard';
};
const menuItems = [
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: handleLogout
}
key: 'dashboard',
icon: <BarChartOutlined style={{ fontSize: '18px' }} />,
label: <Link to="/">仪表盘</Link>,
},
{
key: 'room-management',
icon: <HomeOutlined style={{ fontSize: '18px' }} />,
label: '机房管理',
children: [
{
key: 'rooms',
icon: <HomeOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/rooms">机房管理</Link>,
},
{
key: 'racks',
icon: <DatabaseOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/racks">机柜管理</Link>,
},
{
key: 'visualization',
icon: <EyeOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/visualization">机柜可视化</Link>,
},
],
},
{
key: 'asset-management',
icon: <BuildOutlined style={{ fontSize: '18px' }} />,
label: '资产管理',
children: [
{
key: 'devices',
icon: <CloudServerOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/devices">设备管理</Link>,
},
{
key: 'fields',
icon: <DatabaseOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/fields">字段管理</Link>,
},
],
},
{
key: 'consumables-management',
icon: <ShoppingCartOutlined style={{ fontSize: '18px' }} />,
label: '耗材管理',
children: [
{
key: 'consumables-stats',
icon: <BarChartOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/consumables-stats">耗材统计</Link>,
},
{
key: 'consumables',
icon: <DatabaseOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/consumables">耗材列表</Link>,
},
{
key: 'consumables-categories',
icon: <ImportOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/consumables-categories">分类管理</Link>,
},
{
key: 'consumables-logs',
icon: <FileTextOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/consumables-logs">操作日志</Link>,
},
],
},
{
key: 'system-management',
icon: <UserOutlined style={{ fontSize: '18px' }} />,
label: '系统管理',
children: [
{
key: 'users',
icon: <UserOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/users">用户管理</Link>,
},
{
key: 'login-history',
icon: <HistoryOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/login-history">登录历史</Link>,
},
{
key: 'operation-logs',
icon: <AuditOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/operation-logs">操作日志</Link>,
},
{
key: 'system-settings',
icon: <SettingOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/settings">系统设置</Link>,
},
],
},
{
key: 'ticket-management',
icon: <ToolOutlined style={{ fontSize: '18px' }} />,
label: '工单管理',
children: [
{
key: 'tickets',
icon: <ScheduleOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/tickets">工单列表</Link>,
},
{
key: 'ticket-categories',
icon: <InboxOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/ticket-categories">故障分类</Link>,
},
{
key: 'ticket-statistics',
icon: <BarChartOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/ticket-statistics">统计报表</Link>,
},
{
key: 'ticket-fields',
icon: <DatabaseOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/ticket-fields">字段管理</Link>,
},
],
},
];
return (
<Layout>
<Layout style={{ minHeight: '100vh' }}>
<Sider
width={220}
collapsedWidth={80}
width={240}
collapsedWidth={72}
collapsed={collapsed}
style={{
backgroundColor: colorBgContainer,
boxShadow: '2px 0 8px rgba(0,0,0,0.08)',
background: designTokens.colors.sidebar.bg,
boxShadow: '2px 0 8px rgba(0, 0, 0, 0.06)',
borderRight: `1px solid ${designTokens.colors.sidebar.border}`,
display: 'flex',
flexDirection: 'column'
flexDirection: 'column',
position: 'fixed',
left: 0,
top: 0,
bottom: 0,
zIndex: 100
}}
>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
height: 64,
padding: '0 12px',
borderBottom: '1px solid #f0f0f0',
marginBottom: 8
justifyContent: collapsed ? 'center' : 'flex-start',
padding: collapsed ? '16px 0' : '16px 20px',
borderBottom: `1px solid ${designTokens.colors.sidebar.border}`,
minHeight: '64px',
background: designTokens.colors.sidebar.bg
}}>
<div style={{
width: '36px',
height: '36px',
borderRadius: designTokens.borderRadius.small,
background: designTokens.colors.primary.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}>
<CloudServerOutlined style={{ fontSize: '18px', color: '#ffffff' }} />
</div>
{!collapsed && (
<div>
<div style={{
fontSize: '15px',
fontWeight: '600',
color: designTokens.colors.primary.main,
lineHeight: 1.2
}}>
IDC管理
</div>
<div style={{
fontSize: '11px',
color: designTokens.colors.sidebar.text,
marginTop: '2px'
}}>
数据中心管理平台
</div>
</div>
)}
</div>
<div style={{
padding: collapsed ? '12px 0' : '12px 8px',
overflowY: 'auto',
flex: 1
}}>
<Menu
mode="inline"
selectedKeys={[getSelectedKey()]}
defaultOpenKeys={['room-management', 'asset-management', 'consumables-management', 'system-management', 'ticket-management']}
style={{
background: 'transparent',
borderRight: 0,
fontSize: '14px'
}}
items={menuItems}
/>
</div>
<div style={{
padding: '12px',
borderTop: `1px solid ${designTokens.colors.sidebar.border}`
}}>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
style={{
fontSize: 18,
padding: '8px',
borderRadius: 4
width: '100%',
height: '40px',
color: designTokens.colors.text.secondary,
fontSize: '16px',
borderRadius: designTokens.borderRadius.small,
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'flex-start',
gap: '8px'
}}
/>
>
{!collapsed && <span style={{ fontSize: '13px', color: designTokens.colors.text.secondary }}>收起菜单</span>}
</Button>
</div>
<Menu
mode="inline"
defaultSelectedKeys={['dashboard']}
style={{
flex: 1,
borderRight: 0,
backgroundColor: 'transparent'
}}
items={[
{
key: 'dashboard',
icon: <BarChartOutlined />,
label: <Link to="/">仪表盘</Link>,
},
{
key: 'room-management',
icon: <HomeOutlined />,
label: '机房管理',
children: [
{
key: 'rooms',
icon: <HomeOutlined />,
label: <Link to="/rooms">机房管理</Link>,
},
{
key: 'racks',
icon: <DatabaseOutlined />,
label: <Link to="/racks">机柜管理</Link>,
},
{
key: 'visualization',
icon: <EyeOutlined />,
label: <Link to="/visualization">机柜可视化</Link>,
},
],
},
{
key: 'asset-management',
icon: <BuildOutlined />,
label: '资产管理',
children: [
{
key: 'devices',
icon: <CloudServerOutlined />,
label: <Link to="/devices">设备管理</Link>,
},
{
key: 'fields',
icon: <DatabaseOutlined />,
label: <Link to="/fields">字段管理</Link>,
},
],
},
{
key: 'consumables-management',
icon: <ShoppingCartOutlined />,
label: '耗材管理',
children: [
{
key: 'consumables-stats',
icon: <BarChartOutlined />,
label: <Link to="/consumables-stats">耗材统计</Link>,
},
{
key: 'consumables',
icon: <DatabaseOutlined />,
label: <Link to="/consumables">耗材列表</Link>,
},
{
key: 'consumables-categories',
icon: <ImportOutlined />,
label: <Link to="/consumables-categories">分类管理</Link>,
},
{
key: 'consumables-logs',
icon: <FileTextOutlined />,
label: <Link to="/consumables-logs">操作日志</Link>,
},
],
},
{
key: 'system-management',
icon: <UserOutlined />,
label: '系统管理',
children: [
{
key: 'users',
icon: <UserOutlined />,
label: <Link to="/users">用户管理</Link>,
},
{
key: 'login-history',
icon: <HistoryOutlined />,
label: <Link to="/login-history">登录历史</Link>,
},
{
key: 'operation-logs',
icon: <AuditOutlined />,
label: <Link to="/operation-logs">操作日志</Link>,
},
{
key: 'system-settings',
icon: <SettingOutlined />,
label: <Link to="/settings">系统设置</Link>,
},
],
},
{
key: 'ticket-management',
icon: <ToolOutlined />,
label: '工单管理',
children: [
{
key: 'tickets',
icon: <ScheduleOutlined />,
label: <Link to="/tickets">工单列表</Link>,
},
{
key: 'ticket-categories',
icon: <InboxOutlined />,
label: <Link to="/ticket-categories">故障分类</Link>,
},
{
key: 'ticket-statistics',
icon: <BarChartOutlined />,
label: <Link to="/ticket-statistics">统计报表</Link>,
},
{
key: 'ticket-fields',
icon: <DatabaseOutlined />,
label: <Link to="/ticket-fields">字段管理</Link>,
},
],
},
]}
/>
</Sider>
<Layout style={{ padding: '0 24px 24px' }}>
<Layout style={{
marginLeft: collapsed ? 72 : 240,
transition: 'margin-left 0.2s ease'
}}>
<Header style={{
padding: '0 16px',
height: 56,
background: colorBgContainer,
marginBottom: 24,
borderRadius: borderRadiusLG,
padding: '0 24px',
height: 64,
background: designTokens.colors.background.primary,
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
boxShadow: '0 1px 4px rgba(0,0,0,0.08)'
boxShadow: designTokens.shadows.small,
position: 'sticky',
top: 0,
zIndex: 99
}}>
{user && (
<Space size={12}>
<Avatar
style={{ backgroundColor: '#1890ff', cursor: 'pointer' }}
icon={<UserOutlined />}
/>
<span style={{ color: '#666', fontSize: 14 }}>{user.username}</span>
<Divider type="vertical" style={{ margin: 0 }} />
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '6px 12px',
background: designTokens.colors.background.secondary,
borderRadius: designTokens.borderRadius.medium
}}>
<Avatar
style={{
backgroundColor: designTokens.colors.primary.main,
cursor: 'pointer',
width: 32,
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
icon={<UserOutlined style={{ fontSize: '14px' }} />}
/>
<span style={{
color: designTokens.colors.text.primary,
fontSize: 14,
fontWeight: 500
}}>{user.username}</span>
</div>
<Button
type="text"
danger
icon={<LogoutOutlined />}
onClick={handleLogout}
style={{ padding: '4px 8px' }}
style={{
padding: '8px 12px',
height: 'auto',
borderRadius: designTokens.borderRadius.small,
fontSize: 13
}}
>
退出
</Button>
</Space>
</div>
)}
</Header>
<Content
style={{
padding: 24,
margin: 0,
minHeight: 280,
background: colorBgContainer,
borderRadius: borderRadiusLG,
minHeight: 'calc(100vh - 64px)',
background: designTokens.colors.background.secondary
}}
>
{children}
@@ -321,12 +470,15 @@ function App() {
fallback={
<div style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
background: '#f5f5f5'
background: '#f5f5f5',
gap: '16px'
}}>
<Spin size="large" tip="正在加载登录页面..." />
<Spin size="large" />
<span style={{ color: '#8c8c8c', fontSize: '14px' }}>正在加载登录页面...</span>
</div>
}
>
+5 -2
View File
@@ -11,11 +11,14 @@ const ProtectedRoute = ({ children, requiredPermission }) => {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '100vh'
height: '100vh',
gap: '16px'
}}>
<Spin size="large" tip="加载中..." />
<Spin size="large" />
<span style={{ color: '#8c8c8c', fontSize: '14px' }}>加载中...</span>
</div>
);
}
+521 -135
View File
@@ -1,10 +1,186 @@
import React, { useState, useEffect } from 'react';
import { Card, Row, Col, Statistic, Table, Tag, DatePicker, Space, Select, Progress, message } from 'antd';
import { InboxOutlined, ExportOutlined, WarningOutlined, DollarOutlined, ShoppingCartOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
import { Card, Row, Col, Statistic, Table, Tag, DatePicker, Space, Select, Progress, message, Button } from 'antd';
import { InboxOutlined, ExportOutlined, WarningOutlined, DollarOutlined, ShoppingCartOutlined, ExclamationCircleOutlined, PlusOutlined, BarChartOutlined, DownloadOutlined } from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
const { RangePicker } = DatePicker;
const { Option } = Select;
const designTokens = {
colors: {
primary: {
main: '#667eea',
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
light: '#8b9ff0'
},
success: {
main: '#10b981',
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
},
warning: {
main: '#f59e0b',
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)'
},
error: {
main: '#ef4444',
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)'
},
text: {
primary: '#1e293b',
secondary: '#64748b',
tertiary: '#94a3b8',
inverse: '#ffffff'
},
background: {
primary: '#ffffff',
secondary: '#f8fafc',
tertiary: '#f1f5f9'
},
border: {
light: '#e2e8f0',
medium: '#cbd5e1',
dark: '#94a3b8'
}
},
shadows: {
small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
large: '0 10px 15px -3px rgba(0, 0, 0, 0.1)'
},
borderRadius: {
small: '6px',
medium: '10px',
large: '16px'
},
spacing: {
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px',
xl: '32px'
}
};
const pageContainerStyle = {
minHeight: '100vh',
background: designTokens.colors.background.secondary,
padding: designTokens.spacing.lg
};
const headerStyle = {
marginBottom: designTokens.spacing.lg,
padding: `${designTokens.spacing.lg}px ${designTokens.spacing.xl}px`,
background: designTokens.colors.background.primary,
borderRadius: designTokens.borderRadius.large,
boxShadow: designTokens.shadows.small,
border: `1px solid ${designTokens.colors.border.light}`
};
const titleRowStyle = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: designTokens.spacing.md,
flexWrap: 'wrap',
gap: designTokens.spacing.md
};
const titleStyle = {
display: 'flex',
alignItems: 'center',
gap: designTokens.spacing.sm,
fontSize: '20px',
fontWeight: '600',
color: designTokens.colors.text.primary
};
const statsRowStyle = {
display: 'flex',
gap: designTokens.spacing.md,
flexWrap: 'wrap'
};
const statCardStyle = {
background: designTokens.colors.background.primary,
borderRadius: designTokens.borderRadius.medium,
padding: `${designTokens.spacing.md}px ${designTokens.spacing.lg}px`,
boxShadow: designTokens.shadows.small,
border: `1px solid ${designTokens.colors.border.light}`,
minWidth: '180px',
flex: 1
};
const statCardTextStyle = {
fontSize: '13px',
color: designTokens.colors.text.secondary,
marginBottom: designTokens.spacing.xs
};
const statCardValueStyle = {
fontSize: '28px',
fontWeight: '600',
color: designTokens.colors.text.primary
};
const statCardIconStyle = (color) => ({
fontSize: '28px',
color: color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '48px',
height: '48px',
borderRadius: designTokens.borderRadius.medium,
background: `${color}12`
});
const panelStyle = {
background: designTokens.colors.background.primary,
borderRadius: designTokens.borderRadius.large,
boxShadow: designTokens.shadows.small,
border: `1px solid ${designTokens.colors.border.light}`,
overflow: 'hidden'
};
const panelHeaderStyle = {
padding: `${designTokens.spacing.md}px ${designTokens.spacing.lg}px`,
borderBottom: `1px solid ${designTokens.colors.border.light}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
};
const panelTitleStyle = {
fontSize: '15px',
fontWeight: '600',
color: designTokens.colors.text.primary,
display: 'flex',
alignItems: 'center',
gap: designTokens.spacing.sm
};
const panelBodyStyle = {
padding: designTokens.spacing.lg
};
const actionButtonStyle = {
height: '36px',
padding: `0 ${designTokens.spacing.md}px`,
borderRadius: designTokens.borderRadius.small,
fontSize: '13px',
display: 'flex',
alignItems: 'center',
gap: designTokens.spacing.xs
};
const primaryActionStyle = {
...actionButtonStyle,
background: designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
boxShadow: designTokens.shadows.small
};
function ConsumableStatistics() {
const [summary, setSummary] = useState({ total: 0, lowStock: 0, totalValue: 0, byCategory: [] });
@@ -12,6 +188,7 @@ function ConsumableStatistics() {
const [stats, setStats] = useState({ inCount: 0, outCount: 0, inQuantity: 0, outQuantity: 0, recentRecords: [] });
const [loading, setLoading] = useState(true);
const [dateRange, setDateRange] = useState([]);
const [categoryFilter, setCategoryFilter] = useState(null);
const fetchSummary = async () => {
try {
@@ -38,6 +215,9 @@ function ConsumableStatistics() {
params.startDate = dateRange[0].toISOString();
params.endDate = dateRange[1].toISOString();
}
if (categoryFilter) {
params.category = categoryFilter;
}
const response = await axios.get('/api/consumable-records/statistics', { params });
setStats(response.data);
} catch (error) {
@@ -53,20 +233,36 @@ function ConsumableStatistics() {
useEffect(() => {
fetchInOutStats();
}, [dateRange]);
}, [dateRange, categoryFilter]);
const lowStockColumns = [
{
title: '耗材名称',
dataIndex: 'name',
key: 'name',
width: 150
width: 150,
render: (text) => (
<span style={{ fontWeight: '500', color: designTokens.colors.text.primary }}>
{text}
</span>
)
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
width: 120
width: 120,
render: (category) => (
<Tag style={{
border: 'none',
borderRadius: designTokens.borderRadius.small,
background: `${designTokens.colors.primary.main}15`,
color: designTokens.colors.primary.main,
fontWeight: '500'
}}>
{category}
</Tag>
)
},
{
title: '当前库存',
@@ -74,37 +270,66 @@ function ConsumableStatistics() {
key: 'currentStock',
width: 100,
render: (value) => (
<span style={{ color: '#ff4d4f', fontWeight: 'bold' }}>{value}</span>
<span style={{
color: designTokens.colors.error.main,
fontWeight: '600',
background: `${designTokens.colors.error.main}12`,
padding: `2px ${designTokens.spacing.sm}`,
borderRadius: designTokens.borderRadius.small
}}>
{value}
</span>
)
},
{
title: '最小库存',
dataIndex: 'minStock',
key: 'minStock',
width: 100
width: 100,
render: (value) => (
<span style={{ color: designTokens.colors.text.secondary }}>
{value}
</span>
)
},
{
title: '单位',
dataIndex: 'unit',
key: 'unit',
width: 80
width: 80,
render: (value) => (
<span style={{ color: designTokens.colors.text.tertiary }}>
{value}
</span>
)
},
{
title: '库存充足率',
title: '充足率',
key: 'rate',
width: 150,
width: 140,
render: (_, record) => {
const rate = Math.min(100, Math.round((record.currentStock / record.maxStock) * 100));
const rate = Math.min(100, Math.round((record.currentStock / (record.maxStock || 100)) * 100));
const status = rate < 30 ? 'exception' : rate < 60 ? 'active' : 'success';
return <Progress percent={rate} size="small" status={status} />;
return (
<Progress
percent={rate}
size="small"
status={status}
strokeColor={status === 'exception' ? designTokens.colors.error.main : status === 'active' ? designTokens.colors.warning.main : designTokens.colors.success.main}
/>
);
}
},
{
title: '供应商',
dataIndex: 'supplier',
key: 'supplier',
width: 150,
render: (value) => value || '-'
width: 120,
render: (value) => (
<span style={{ color: designTokens.colors.text.secondary }}>
{value || '-'}
</span>
)
}
];
@@ -113,22 +338,39 @@ function ConsumableStatistics() {
title: '时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (date) => dayjs(date).format('YYYY-MM-DD HH:mm:ss')
width: 170,
render: (date) => (
<span style={{ color: designTokens.colors.text.secondary }}>
{dayjs(date).format('YYYY-MM-DD HH:mm')}
</span>
)
},
{
title: '耗材名称',
dataIndex: ['Consumable', 'name'],
key: 'consumableName',
width: 150
width: 140,
render: (text) => (
<span style={{ fontWeight: '500' }}>
{text}
</span>
)
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 100,
width: 90,
render: (type) => (
<Tag color={type === 'in' ? 'green' : 'red'}>
<Tag
style={{
border: 'none',
borderRadius: designTokens.borderRadius.small,
background: type === 'in' ? `${designTokens.colors.success.main}15` : `${designTokens.colors.error.main}15`,
color: type === 'in' ? designTokens.colors.success.main : designTokens.colors.error.main,
fontWeight: '500'
}}
>
{type === 'in' ? '入库' : '出库'}
</Tag>
)
@@ -139,7 +381,10 @@ function ConsumableStatistics() {
key: 'quantity',
width: 100,
render: (value, record) => (
<span style={{ color: record.type === 'in' ? '#52c41a' : '#ff4d4f' }}>
<span style={{
color: record.type === 'in' ? designTokens.colors.success.main : designTokens.colors.error.main,
fontWeight: '600'
}}>
{record.type === 'in' ? '+' : '-'}{value}
</span>
)
@@ -148,138 +393,279 @@ function ConsumableStatistics() {
title: '操作人',
dataIndex: 'operator',
key: 'operator',
width: 120
width: 100,
render: (value) => (
<span style={{ color: designTokens.colors.text.secondary }}>
{value || '-'}
</span>
)
},
{
title: '原因',
dataIndex: 'reason',
key: 'reason',
width: 150,
render: (value) => value || '-'
render: (value) => (
<span style={{ color: designTokens.colors.text.secondary }}>
{value || '-'}
</span>
)
}
];
const categories = summary.byCategory?.map(item => item.category) || [];
const netQuantity = stats.inQuantity - stats.outQuantity;
return (
<div>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card>
<Statistic
title="耗材种类总数"
value={summary.total}
prefix={<ShoppingCartOutlined style={{ color: '#1890ff' }} />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="低库存预警"
value={summary.lowStock}
prefix={<WarningOutlined style={{ color: summary.lowStock > 0 ? '#ff4d4f' : '#52c41a' }} />}
valueStyle={{ color: summary.lowStock > 0 ? '#ff4d4f' : '#52c41a' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="库存总价值"
value={parseFloat(summary.totalValue || 0)}
prefix={<DollarOutlined style={{ color: '#52c41a' }} />}
precision={2}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="净入库量"
value={stats.inQuantity - stats.outQuantity}
prefix={<InboxOutlined style={{ color: stats.inQuantity - stats.outQuantity >= 0 ? '#52c41a' : '#ff4d4f' }} />}
valueStyle={{ color: stats.inQuantity - stats.outQuantity >= 0 ? '#52c41a' : '#ff4d4f' }}
/>
</Card>
</Col>
</Row>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={12}>
<Card title="入库出库统计" extra={
<RangePicker value={dateRange} onChange={setDateRange} />
}>
<Row gutter={16}>
<Col span={12}>
<Card size="small" style={{ background: '#f6ffed', borderColor: '#b7eb8f' }}>
<Statistic
title="入库次数"
value={stats.inCount}
prefix={<InboxOutlined style={{ color: '#52c41a' }} />}
/>
<div style={{ marginTop: 8, color: '#52c41a', fontSize: 20, fontWeight: 'bold' }}>
+{stats.inQuantity}
</div>
</Card>
</Col>
<Col span={12}>
<Card size="small" style={{ background: '#fff1f0', borderColor: '#ffa39e' }}>
<Statistic
title="出库次数"
value={stats.outCount}
prefix={<ExportOutlined style={{ color: '#ff4d4f' }} />}
/>
<div style={{ marginTop: 8, color: '#ff4d4f', fontSize: 20, fontWeight: 'bold' }}>
-{stats.outQuantity}
</div>
</Card>
</Col>
</Row>
</Card>
</Col>
<Col span={12}>
<Card title="分类统计">
<Space wrap>
{summary.byCategory?.map(item => (
<Card size="small" key={item.category} style={{ width: 140 }}>
<Statistic
title={item.category}
value={item.count}
valueStyle={{ fontSize: 24 }}
/>
</Card>
<div style={pageContainerStyle}>
<div style={headerStyle}>
<div style={titleRowStyle}>
<div style={titleStyle}>
<BarChartOutlined style={{ color: designTokens.colors.primary.main }} />
耗材统计
</div>
<Space wrap>
<Select
placeholder="选择分类"
allowClear
style={{ width: 140 }}
value={categoryFilter}
onChange={setCategoryFilter}
>
{categories.map(cat => (
<Option key={cat} value={cat}>{cat}</Option>
))}
</Space>
</Card>
</Select>
<RangePicker
value={dateRange}
onChange={setDateRange}
style={{ borderRadius: designTokens.borderRadius.small }}
/>
<Button icon={<DownloadOutlined />} style={actionButtonStyle}>
导出报表
</Button>
</Space>
</div>
<div style={statsRowStyle}>
<div style={statCardStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: designTokens.spacing.md }}>
<div style={statCardIconStyle(designTokens.colors.primary.main)}>
<ShoppingCartOutlined />
</div>
<div>
<div style={statCardTextStyle}>耗材种类</div>
<div style={statCardValueStyle}>{summary.total}</div>
</div>
</div>
</div>
<div style={{ ...statCardStyle, borderLeft: `4px solid ${designTokens.colors.error.main}` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: designTokens.spacing.md }}>
<div style={statCardIconStyle(designTokens.colors.error.main)}>
<WarningOutlined />
</div>
<div>
<div style={statCardTextStyle}>低库存预警</div>
<div style={{ ...statCardValueStyle, color: designTokens.colors.error.main }}>
{summary.lowStock}
</div>
</div>
</div>
</div>
<div style={{ ...statCardStyle, borderLeft: `4px solid ${designTokens.colors.success.main}` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: designTokens.spacing.md }}>
<div style={statCardIconStyle(designTokens.colors.success.main)}>
<DollarOutlined />
</div>
<div>
<div style={statCardTextStyle}>库存总价值</div>
<div style={{ ...statCardValueStyle, color: designTokens.colors.success.main }}>
¥{parseFloat(summary.totalValue || 0).toLocaleString()}
</div>
</div>
</div>
</div>
<div style={{ ...statCardStyle, borderLeft: `4px solid ${netQuantity >= 0 ? designTokens.colors.success.main : designTokens.colors.error.main}` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: designTokens.spacing.md }}>
<div style={statCardIconStyle(netQuantity >= 0 ? designTokens.colors.success.main : designTokens.colors.error.main)}>
<InboxOutlined />
</div>
<div>
<div style={statCardTextStyle}>净入库量</div>
<div style={{ ...statCardValueStyle, color: netQuantity >= 0 ? designTokens.colors.success.main : designTokens.colors.error.main }}>
{netQuantity >= 0 ? '+' : ''}{netQuantity}
</div>
</div>
</div>
</div>
</div>
</div>
<Row gutter={designTokens.spacing.md} style={{ marginBottom: designTokens.spacing.md }}>
<Col span={12}>
<div style={panelStyle}>
<div style={panelHeaderStyle}>
<div style={panelTitleStyle}>
<InboxOutlined style={{ color: designTokens.colors.success.main }} />
入库出库统计
</div>
<Tag color={designTokens.colors.success.main}>近30天</Tag>
</div>
<div style={{ padding: designTokens.spacing.lg }}>
<Row gutter={designTokens.spacing.md}>
<Col span={12}>
<div style={{
background: `${designTokens.colors.success.main}08`,
borderRadius: designTokens.borderRadius.medium,
padding: designTokens.spacing.lg,
textAlign: 'center',
border: `1px solid ${designTokens.colors.success.main}30`
}}>
<div style={{ fontSize: '13px', color: designTokens.colors.text.secondary, marginBottom: designTokens.spacing.sm }}>
入库次数
</div>
<div style={{ fontSize: '32px', fontWeight: '600', color: designTokens.colors.success.main }}>
{stats.inCount}
</div>
<div style={{ fontSize: '20px', fontWeight: '500', color: designTokens.colors.success.main, marginTop: designTokens.spacing.xs }}>
+{stats.inQuantity}
</div>
</div>
</Col>
<Col span={12}>
<div style={{
background: `${designTokens.colors.error.main}08`,
borderRadius: designTokens.borderRadius.medium,
padding: designTokens.spacing.lg,
textAlign: 'center',
border: `1px solid ${designTokens.colors.error.main}30`
}}>
<div style={{ fontSize: '13px', color: designTokens.colors.text.secondary, marginBottom: designTokens.spacing.sm }}>
出库次数
</div>
<div style={{ fontSize: '32px', fontWeight: '600', color: designTokens.colors.error.main }}>
{stats.outCount}
</div>
<div style={{ fontSize: '20px', fontWeight: '500', color: designTokens.colors.error.main, marginTop: designTokens.spacing.xs }}>
-{stats.outQuantity}
</div>
</div>
</Col>
</Row>
</div>
</div>
</Col>
<Col span={12}>
<div style={panelStyle}>
<div style={panelHeaderStyle}>
<div style={panelTitleStyle}>
<BarChartOutlined style={{ color: designTokens.colors.primary.main }} />
分类统计
</div>
<Tag color={designTokens.colors.primary.main}>{categories.length}</Tag>
</div>
<div style={{ padding: designTokens.spacing.lg }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: designTokens.spacing.sm }}>
{summary.byCategory?.map((item, index) => {
const colors = [designTokens.colors.primary.main, designTokens.colors.success.main, designTokens.colors.warning.main, '#8b5cf6', '#06b6d4', '#ec4899'];
const color = colors[index % colors.length];
return (
<div
key={item.category}
style={{
background: `${color}10`,
borderRadius: designTokens.borderRadius.medium,
padding: `${designTokens.spacing.sm}px ${designTokens.spacing.md}px`,
border: `1px solid ${color}30`,
display: 'flex',
alignItems: 'center',
gap: designTokens.spacing.sm
}}
>
<div style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: color
}} />
<span style={{ color: designTokens.colors.text.secondary, fontSize: '13px' }}>
{item.category}
</span>
<span style={{ color: color, fontWeight: '600', fontSize: '15px' }}>
{item.count}
</span>
</div>
);
})}
{(!summary.byCategory || summary.byCategory.length === 0) && (
<div style={{ color: designTokens.colors.text.tertiary, padding: designTokens.spacing.lg }}>
暂无分类数据
</div>
)}
</div>
</div>
</div>
</Col>
</Row>
<Row gutter={16}>
<Row gutter={designTokens.spacing.md}>
<Col span={12}>
<Card
title={<span><ExclamationCircleOutlined style={{ color: '#ff4d4f', marginRight: 8 }} />低库存预警</span>}
extra={<Tag color="red">{lowStockItems.length}</Tag>}
>
<Table
columns={lowStockColumns}
dataSource={lowStockItems}
rowKey="consumableId"
pagination={false}
size="small"
scroll={{ x: 800 }}
/>
</Card>
<div style={panelStyle}>
<div style={panelHeaderStyle}>
<div style={panelTitleStyle}>
<ExclamationCircleOutlined style={{ color: designTokens.colors.error.main }} />
低库存预警
</div>
<Tag style={{
border: 'none',
borderRadius: designTokens.borderRadius.small,
background: `${designTokens.colors.error.main}15`,
color: designTokens.colors.error.main,
fontWeight: '500'
}}>
{lowStockItems.length}
</Tag>
</div>
<div style={{ padding: 0 }}>
<Table
columns={lowStockColumns}
dataSource={lowStockItems}
rowKey="consumableId"
pagination={false}
size="small"
scroll={{ x: 850 }}
loading={loading}
/>
</div>
</div>
</Col>
<Col span={12}>
<Card title="最近出入库记录">
<Table
columns={recentColumns}
dataSource={stats.recentRecords}
rowKey="recordId"
pagination={false}
size="small"
scroll={{ x: 900 }}
/>
</Card>
<div style={panelStyle}>
<div style={panelHeaderStyle}>
<div style={panelTitleStyle}>
<InboxOutlined style={{ color: designTokens.colors.text.secondary }} />
最近出入库记录
</div>
<Tag>最近</Tag>
</div>
<div style={{ padding: 0 }}>
<Table
columns={recentColumns}
dataSource={stats.recentRecords}
rowKey="recordId"
pagination={false}
size="small"
scroll={{ x: 850 }}
loading={loading}
/>
</div>
</div>
</Col>
</Row>
</div>
File diff suppressed because it is too large Load Diff
+279 -57
View File
@@ -1,9 +1,130 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Modal, Form, Input, Select, message, Card, Space, InputNumber, Switch } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { Table, Button, Modal, Form, Input, Select, message, Card, Space, InputNumber, Switch, Tag, Statistic } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, AppstoreOutlined, FontSizeOutlined, NumberOutlined, CheckCircleOutlined, CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
import axios from 'axios';
const { Option } = Select;
const { Option = Select.Option } = Select;
const designTokens = {
colors: {
primary: {
main: '#667eea',
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
light: '#8b9ff0',
dark: '#4f5db8'
},
success: {
main: '#10b981',
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
},
warning: {
main: '#f59e0b',
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)'
},
error: {
main: '#ef4444',
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)'
},
text: {
primary: '#1e293b',
secondary: '#64748b',
tertiary: '#94a3b8',
inverse: '#ffffff'
},
background: {
primary: '#ffffff',
secondary: '#f8fafc',
tertiary: '#f1f5f9'
},
border: {
light: '#e2e8f0',
medium: '#cbd5e1',
dark: '#94a3b8'
},
fieldType: {
string: '#3b82f6',
number: '#10b981',
boolean: '#f59e0b',
select: '#8b5cf6',
date: '#06b6d4',
textarea: '#64748b'
}
},
shadows: {
small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
large: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)',
glow: '0 0 20px rgba(102, 126, 234, 0.15)'
},
borderRadius: {
small: '6px',
medium: '10px',
large: '16px'
},
transitions: {
fast: '150ms cubic-bezier(0.4, 0, 0.2, 1)',
normal: '300ms cubic-bezier(0.4, 0, 0.2, 1)'
},
spacing: {
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px',
xl: '32px'
}
};
const pageContainerStyle = {
minHeight: '100vh',
background: designTokens.colors.background.secondary,
padding: designTokens.spacing.lg
};
const titleRowStyle = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: designTokens.spacing.lg
};
const titleStyle = {
display: 'flex',
alignItems: 'center',
gap: designTokens.spacing.sm,
fontSize: '20px',
fontWeight: '600',
color: designTokens.colors.text.primary
};
const actionButtonStyle = {
height: '36px',
padding: `0 ${designTokens.spacing.md}px`,
borderRadius: designTokens.borderRadius.small,
fontSize: '13px',
display: 'flex',
alignItems: 'center',
gap: designTokens.spacing.xs
};
const primaryActionStyle = {
...actionButtonStyle,
background: designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
boxShadow: designTokens.shadows.small
};
const tableCardStyle = {
background: designTokens.colors.background.primary,
borderRadius: designTokens.borderRadius.large,
boxShadow: designTokens.shadows.small,
border: `1px solid ${designTokens.colors.border.light}`,
overflow: 'hidden'
};
const tableStyle = {
background: designTokens.colors.background.primary
};
function DeviceFieldManagement() {
const [fields, setFields] = useState([]);
@@ -12,7 +133,6 @@ function DeviceFieldManagement() {
const [editingField, setEditingField] = useState(null);
const [form] = Form.useForm();
// 获取所有字段配置
const fetchFields = async () => {
try {
setLoading(true);
@@ -30,11 +150,9 @@ function DeviceFieldManagement() {
fetchFields();
}, []);
// 打开模态框
const showModal = (field = null) => {
setEditingField(field);
if (field) {
// 将options对象转换为JSON字符串以便在TextArea中显示
const fieldData = {
...field,
options: field.options ? JSON.stringify(field.options, null, 2) : ''
@@ -46,27 +164,22 @@ function DeviceFieldManagement() {
setModalVisible(true);
};
// 关闭模态框
const handleCancel = () => {
setModalVisible(false);
setEditingField(null);
};
// 提交表单
const handleSubmit = async (values) => {
try {
// 处理选项配置,将JSON字符串转换为对象
const fieldData = {
...values,
options: values.options ? JSON.parse(values.options) : null
};
if (editingField) {
// 更新字段
await axios.put(`/api/deviceFields/${editingField.fieldId}`, fieldData);
message.success('字段更新成功');
} else {
// 创建字段
await axios.post('/api/deviceFields', fieldData);
message.success('字段创建成功');
}
@@ -80,7 +193,6 @@ function DeviceFieldManagement() {
}
};
// 删除字段
const handleDelete = async (fieldId) => {
Modal.confirm({
title: '确认删除',
@@ -101,64 +213,141 @@ function DeviceFieldManagement() {
});
};
// 表格列配置
const getFieldTypeIcon = (type) => {
const iconMap = {
string: <FontSizeOutlined />,
number: <NumberOutlined />,
boolean: <CheckCircleOutlined />,
select: <AppstoreOutlined />,
date: <CalendarOutlined />,
textarea: <FileTextOutlined />
};
return iconMap[type] || <FontSizeOutlined />;
};
const columns = [
{
title: '字段名称',
dataIndex: 'fieldName',
key: 'fieldName',
width: 150,
render: (text) => (
<span style={{ fontWeight: '500', color: designTokens.colors.text.primary }}>
{text}
</span>
)
},
{
title: '显示名称',
dataIndex: 'displayName',
key: 'displayName',
width: 120,
},
{
title: '字段类型',
dataIndex: 'fieldType',
key: 'fieldType',
width: 110,
render: (type) => {
const typeMap = {
string: '文本',
number: '数字',
boolean: '布尔值',
select: '下拉选择',
date: '日期',
textarea: '多行文本'
string: { text: '文本', color: designTokens.colors.fieldType.string },
number: { text: '数字', color: designTokens.colors.fieldType.number },
boolean: { text: '布尔值', color: designTokens.colors.fieldType.boolean },
select: { text: '下拉选择', color: designTokens.colors.fieldType.select },
date: { text: '日期', color: designTokens.colors.fieldType.date },
textarea: { text: '多行文本', color: designTokens.colors.fieldType.textarea }
};
return typeMap[type] || type;
const config = typeMap[type] || { text: type, color: designTokens.colors.text.tertiary };
return (
<Tag
style={{
border: 'none',
borderRadius: designTokens.borderRadius.small,
background: `${config.color}15`,
color: config.color,
fontWeight: '500'
}}
>
{getFieldTypeIcon(type)}
<span style={{ marginLeft: '4px' }}>{config.text}</span>
</Tag>
);
}
},
{
title: '必填',
dataIndex: 'required',
key: 'required',
width: 80,
render: (required) => (
<Switch checked={required} disabled />
<span style={{
color: required ? designTokens.colors.success.main : designTokens.colors.text.tertiary,
fontWeight: '500'
}}>
{required ? '是' : '否'}
</span>
)
},
{
title: '可见',
dataIndex: 'visible',
key: 'visible',
width: 80,
render: (visible) => (
<Switch checked={visible} disabled />
<span style={{
color: visible ? designTokens.colors.primary.main : designTokens.colors.text.tertiary,
fontWeight: '500'
}}>
{visible ? '是' : '否'}
</span>
)
},
{
title: '顺序',
dataIndex: 'order',
key: 'order',
width: 80,
render: (order) => (
<span style={{
background: designTokens.colors.background.tertiary,
padding: `2px ${designTokens.spacing.sm}`,
borderRadius: designTokens.borderRadius.small,
fontSize: '12px',
fontWeight: '500'
}}>
{order}
</span>
)
},
{
title: '操作',
key: 'action',
width: 160,
fixed: 'right',
render: (_, record) => (
<Space size="middle">
<Button type="primary" icon={<EditOutlined />} onClick={() => showModal(record)} size="small">
<Space size="small">
<Button
type="text"
icon={<EditOutlined />}
onClick={() => showModal(record)}
style={{
color: designTokens.colors.primary.main,
height: '28px',
padding: '0 8px'
}}
>
编辑
</Button>
<Button danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.fieldId)} size="small">
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(record.fieldId)}
style={{
height: '28px',
padding: '0 8px'
}}
>
删除
</Button>
</Space>
@@ -167,27 +356,55 @@ function DeviceFieldManagement() {
];
return (
<div>
<Card title="设备字段管理" extra={
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>
<div style={pageContainerStyle}>
<div style={titleRowStyle}>
<div style={titleStyle}>
<AppstoreOutlined style={{ color: designTokens.colors.primary.main }} />
设备字段管理
</div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => showModal()}
style={primaryActionStyle}
>
添加字段
</Button>
}>
</div>
<div style={tableCardStyle}>
<Table
columns={columns}
dataSource={fields}
rowKey="fieldId"
loading={loading}
pagination={{ pageSize: 10 }}
pagination={{
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) => `${range[0]}-${range[1]} 条 / 共 ${total}`
}}
scroll={{ x: 900 }}
style={tableStyle}
/>
</Card>
</div>
<Modal
title={editingField ? '编辑字段' : '添加字段'}
title={
<span style={{ fontWeight: '600' }}>
{editingField ? '编辑字段' : '添加字段'}
</span>
}
open={modalVisible}
onCancel={handleCancel}
footer={null}
width={600}
styles={{
body: { padding: designTokens.spacing.lg }
}}
style={{
borderRadius: designTokens.borderRadius.large
}}
>
<Form
form={form}
@@ -196,7 +413,7 @@ function DeviceFieldManagement() {
>
<Form.Item
name="fieldName"
label="字段名称"
label={<span style={{ fontWeight: '500' }}>字段名称</span>}
rules={[{ required: true, message: '请输入字段名称' }]}
>
<Input placeholder="请输入字段名称(英文,如:deviceId)" />
@@ -204,7 +421,7 @@ function DeviceFieldManagement() {
<Form.Item
name="displayName"
label="显示名称"
label={<span style={{ fontWeight: '500' }}>显示名称</span>}
rules={[{ required: true, message: '请输入显示名称' }]}
>
<Input placeholder="请输入显示名称(中文,如:设备ID)" />
@@ -212,7 +429,7 @@ function DeviceFieldManagement() {
<Form.Item
name="fieldType"
label="字段类型"
label={<span style={{ fontWeight: '500' }}>字段类型</span>}
rules={[{ required: true, message: '请选择字段类型' }]}
>
<Select placeholder="请选择字段类型">
@@ -225,23 +442,29 @@ function DeviceFieldManagement() {
</Select>
</Form.Item>
<Form.Item
name="required"
label="必填"
>
<Switch />
</Form.Item>
<div style={{ display: 'flex', gap: designTokens.spacing.md }}>
<Form.Item
name="required"
label={<span style={{ fontWeight: '500' }}>必填</span>}
valuePropName="checked"
style={{ flex: 1 }}
>
<Switch />
</Form.Item>
<Form.Item
name="visible"
label="可见"
>
<Switch defaultChecked />
</Form.Item>
<Form.Item
name="visible"
label={<span style={{ fontWeight: '500' }}>可见</span>}
valuePropName="checked"
style={{ flex: 1 }}
>
<Switch defaultChecked />
</Form.Item>
</div>
<Form.Item
name="order"
label="显示顺序"
label={<span style={{ fontWeight: '500' }}>显示顺序</span>}
rules={[{ required: true, message: '请输入显示顺序' }]}
>
<InputNumber placeholder="请输入显示顺序" min={0} style={{ width: '100%' }} />
@@ -249,21 +472,20 @@ function DeviceFieldManagement() {
<Form.Item
name="options"
label="选项配置(仅下拉选择类型,JSON格式)"
tooltip="格式示例:[{value: 'option1', label: '选项1'}]使用单引号"
label={<span style={{ fontWeight: '500' }}>选项配置JSON格式</span>}
tooltip="格式示例:[{value: 'option1', label: '选项1'}]仅下拉选择类型需要配置"
>
<Input.TextArea
rows={3}
<Input.TextArea
rows={3}
placeholder="请输入JSON格式的选项配置,使用单引号"
style={{ fontFamily: 'monospace' }}
/>
</Form.Item>
<Form.Item style={{ textAlign: 'right' }}>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button onClick={handleCancel}>取消</Button>
<Button type="primary" htmlType="submit">
确定
</Button>
<Button type="primary" htmlType="submit">确定</Button>
</Space>
</Form.Item>
</Form>
@@ -272,4 +494,4 @@ function DeviceFieldManagement() {
);
}
export default DeviceFieldManagement;
export default DeviceFieldManagement;
+427 -147
View File
@@ -7,6 +7,94 @@ import dayjs from 'dayjs';
const { Option } = Select;
const { RangePicker } = DatePicker;
const designTokens = {
colors: {
primary: {
main: '#667eea',
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
light: '#8b9ff0',
dark: '#4f5db8'
},
success: {
main: '#10b981',
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
light: '#34d399',
dark: '#047857'
},
warning: {
main: '#f59e0b',
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
light: '#fbbf24',
dark: '#b45309'
},
error: {
main: '#ef4444',
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
light: '#f87171',
dark: '#b91c1c'
},
text: {
primary: '#1e293b',
secondary: '#64748b',
tertiary: '#94a3b8',
inverse: '#ffffff'
},
background: {
primary: '#ffffff',
secondary: '#f8fafc',
tertiary: '#f1f5f9',
dark: '#1e293b'
},
border: {
light: '#e2e8f0',
medium: '#cbd5e1',
dark: '#94a3b8'
},
device: {
server: '#3b82f6',
switch: '#22c55e',
router: '#f59e0b',
storage: '#8b5cf6',
other: '#64748b'
},
status: {
normal: '#10b981',
running: '#10b981',
warning: '#f59e0b',
error: '#ef4444',
fault: '#ef4444',
offline: '#6b7280',
maintenance: '#3b82f6'
}
},
shadows: {
small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
large: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)',
xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)',
glow: '0 0 20px rgba(102, 126, 234, 0.3)'
},
borderRadius: {
small: '6px',
medium: '10px',
large: '16px',
xl: '24px',
round: '50%'
},
transitions: {
fast: '150ms cubic-bezier(0.4, 0, 0.2, 1)',
normal: '300ms cubic-bezier(0.4, 0, 0.2, 1)',
slow: '500ms cubic-bezier(0.4, 0, 0.2, 1)'
},
spacing: {
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px',
xl: '32px'
}
};
// 防抖 Hook
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
@@ -1161,68 +1249,184 @@ function DeviceManagement() {
message.success('字段配置已重置为默认值');
};
const pageHeaderStyle = {
marginBottom: '24px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: '12px'
const pageContainerStyle = {
minHeight: '100vh',
background: designTokens.colors.background.secondary,
padding: designTokens.spacing.lg
};
const titleStyle = {
fontSize: '24px',
const headerStyle = {
marginBottom: designTokens.spacing.lg
};
const titleRowStyle = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: designTokens.spacing.lg,
flexWrap: 'wrap',
gap: designTokens.spacing.md
};
const titleSectionStyle = {
display: 'flex',
alignItems: 'center',
gap: designTokens.spacing.md
};
const titleIconStyle = {
width: '44px',
height: '44px',
borderRadius: designTokens.borderRadius.medium,
background: designTokens.colors.primary.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: designTokens.shadows.medium
};
const titleTextStyle = {
display: 'flex',
flexDirection: 'column',
gap: '2px'
};
const pageTitleStyle = {
fontSize: '22px',
fontWeight: '700',
margin: 0,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text'
color: designTokens.colors.text.primary,
lineHeight: 1.2
};
const cardStyle = {
borderRadius: '16px',
const pageSubtitleStyle = {
fontSize: '13px',
color: designTokens.colors.text.secondary,
margin: 0
};
const actionButtonStyle = {
height: '36px',
borderRadius: designTokens.borderRadius.small,
fontSize: '13px',
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
gap: '6px'
};
const primaryActionStyle = {
...actionButtonStyle,
background: designTokens.colors.primary.gradient,
border: 'none',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
overflow: 'hidden'
color: '#ffffff',
boxShadow: designTokens.shadows.small
};
const secondaryActionStyle = {
...actionButtonStyle,
background: designTokens.colors.background.primary,
border: `1px solid ${designTokens.colors.border.light}`,
color: designTokens.colors.text.primary
};
const dangerActionStyle = {
...actionButtonStyle,
background: designTokens.colors.error.main,
border: 'none',
color: '#ffffff'
};
const primaryButtonStyle = {
height: '40px',
borderRadius: '8px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
borderRadius: designTokens.borderRadius.small,
background: designTokens.colors.primary.gradient,
border: 'none',
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.35)',
color: '#ffffff',
boxShadow: designTokens.shadows.small,
fontWeight: '500',
transition: 'all 0.3s ease'
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center'
};
const secondaryButtonStyle = {
height: '40px',
borderRadius: '8px',
border: '1px solid #e8e8e8',
transition: 'all 0.3s ease'
const statsRowStyle = {
display: 'flex',
gap: designTokens.spacing.md,
marginBottom: designTokens.spacing.lg,
flexWrap: 'wrap'
};
const searchCardStyle = {
borderRadius: '12px',
border: '1px solid #f0f0f0',
background: 'linear-gradient(180deg, #fafafa 0%, #ffffff 100%)',
marginBottom: '20px'
const statCardStyle = {
flex: 1,
minWidth: '140px',
maxWidth: '200px',
padding: `${designTokens.spacing.md}px ${designTokens.spacing.lg}px`,
background: designTokens.colors.background.primary,
borderRadius: designTokens.borderRadius.medium,
border: `1px solid ${designTokens.colors.border.light}`,
boxShadow: designTokens.shadows.small,
transition: `all ${designTokens.transitions.fast}`
};
const statValueStyle = {
fontSize: '24px',
fontWeight: '700',
color: designTokens.colors.text.primary,
lineHeight: 1.2
};
const statLabelStyle = {
fontSize: '12px',
color: designTokens.colors.text.secondary,
marginTop: '4px'
};
const statCardRunningStyle = {
...statCardStyle,
borderLeft: `3px solid ${designTokens.colors.success.main}`,
background: `${designTokens.colors.success.main}08`
};
const statCardMaintenanceStyle = {
...statCardStyle,
borderLeft: `3px solid ${designTokens.colors.warning.main}`,
background: `${designTokens.colors.warning.main}08`
};
const statCardFaultStyle = {
...statCardStyle,
borderLeft: `3px solid ${designTokens.colors.error.main}`,
background: `${designTokens.colors.error.main}08`
};
const cardStyle = {
borderRadius: designTokens.borderRadius.large,
border: 'none',
boxShadow: designTokens.shadows.medium,
overflow: 'hidden',
background: designTokens.colors.background.primary
};
const filterCardStyle = {
borderRadius: designTokens.borderRadius.medium,
border: 'none',
boxShadow: designTokens.shadows.small,
background: designTokens.colors.background.primary,
marginBottom: designTokens.spacing.lg
};
const modalHeaderStyle = {
display: 'flex',
alignItems: 'center',
gap: '8px',
gap: designTokens.spacing.sm,
fontSize: '18px',
fontWeight: '600'
};
return (
<div style={{ padding: '24px' }}>
<div style={pageContainerStyle}>
<style>{`
/* 表格自适应换行样式 */
.device-table-wrapper {
display: flex;
flex-direction: column;
@@ -1255,9 +1459,12 @@ function DeviceManagement() {
white-space: normal !important;
word-break: break-word !important;
font-size: 14px !important;
font-weight: 500 !important;
font-weight: 600 !important;
line-height: 1.4 !important;
padding: 12px 8px !important;
padding: 14px 12px !important;
background: ${designTokens.colors.background.tertiary} !important;
color: ${designTokens.colors.text.primary} !important;
border-bottom: 1px solid ${designTokens.colors.border.light} !important;
}
.device-table-wrapper .ant-table-tbody {
@@ -1270,7 +1477,8 @@ function DeviceManagement() {
word-break: break-word !important;
line-height: 1.6 !important;
max-width: 250px !important;
padding: 12px 8px !important;
padding: 12px !important;
border-bottom: 1px solid ${designTokens.colors.border.light} !important;
}
.device-table-wrapper .ant-table-tbody > tr > td .ant-typography,
@@ -1284,29 +1492,26 @@ function DeviceManagement() {
word-break: break-word !important;
}
/* 斑马纹样式 */
.device-table-wrapper .ant-table-row-even {
background-color: #fafafa;
background-color: ${designTokens.colors.background.primary};
}
.device-table-wrapper .ant-table-row-odd {
background-color: #ffffff;
background-color: ${designTokens.colors.background.secondary};
}
.device-table-wrapper .ant-table-row-selected {
background-color: #e6f7ff !important;
background-color: ${designTokens.colors.primary.main}15 !important;
}
.device-table-wrapper .ant-table-row-selected:hover > td {
background-color: #bae7ff !important;
background-color: ${designTokens.colors.primary.main}25 !important;
}
/* 表格行悬停效果 */
.device-table-wrapper .ant-table-tbody > tr:hover > td {
background-color: #f5f5f5 !important;
background-color: ${designTokens.colors.background.tertiary} !important;
}
/* 复选框列固定 */
.device-table-wrapper .ant-table-selection-column {
position: sticky !important;
left: 0 !important;
@@ -1314,20 +1519,35 @@ function DeviceManagement() {
background: inherit !important;
}
/* 操作列样式 */
.device-table-wrapper .ant-table-tbody > tr > td:last-child {
min-width: 120px !important;
max-width: 150px !important;
}
/* 分页器样式 */
.device-table-wrapper .ant-pagination {
margin: 16px 0 !important;
flex-wrap: wrap !important;
justify-content: center !important;
padding: 12px 16px !important;
background: ${designTokens.colors.background.primary};
border-radius: ${designTokens.borderRadius.medium};
margin-top: 16px !important;
}
.device-table-wrapper .ant-pagination-item-active {
background: ${designTokens.colors.primary.gradient} !important;
border: none !important;
}
.device-table-wrapper .ant-pagination-item-active a {
color: #ffffff !important;
}
.device-table-wrapper .ant-table-cell-fix-left,
.device-table-wrapper .ant-table-cell-fix-right {
background: inherit !important;
}
/* 响应式调整 */
@media screen and (max-width: 768px) {
.device-table-wrapper .ant-table-tbody > tr > td {
max-width: 150px !important;
@@ -1338,97 +1558,133 @@ function DeviceManagement() {
font-size: 13px !important;
}
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start !important;
gap: 16px !important;
}
.stat-cards {
flex-wrap: wrap;
}
.stat-card {
min-width: calc(50% - 8px) !important;
}
}
@media (max-width: 480px) {
.stat-card {
min-width: 100% !important;
}
.filter-form .ant-form-item {
margin-bottom: 12px !important;
}
}
`}</style>
<div style={pageHeaderStyle}>
<h1 style={titleStyle}>
<CloudServerOutlined style={{ marginRight: '12px' }} />
设备管理
</h1>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
<Button
style={primaryButtonStyle}
icon={<PlusOutlined />}
onClick={() => showModal()}
>
添加设备
</Button>
<Button
style={secondaryButtonStyle}
icon={<UploadOutlined />}
onClick={() => setImportModalVisible(true)}
>
导入设备
</Button>
<Button
style={secondaryButtonStyle}
icon={<SettingOutlined />}
onClick={() => setFieldConfigModalVisible(true)}
>
字段配置
</Button>
<Button
style={secondaryButtonStyle}
icon={<UndoOutlined />}
onClick={resetColumnWidths}
title="重置列宽"
>
重置列宽
</Button>
<Button
style={{
...secondaryButtonStyle,
color: selectedDevices.length > 0 ? '#52c41a' : undefined,
borderColor: selectedDevices.length > 0 ? '#52c41a' : undefined
}}
icon={<ReloadOutlined />}
disabled={selectedDevices.length === 0}
onClick={showBatchStatusModal}
>
状态变更 ({selectedDevices.length})
</Button>
<Button
style={{
...secondaryButtonStyle,
color: selectedDevices.length > 0 ? '#ff4d4f' : undefined,
borderColor: selectedDevices.length > 0 ? '#ff4d4f' : undefined
}}
danger
icon={<DeleteOutlined />}
disabled={selectedDevices.length === 0}
onClick={handleBatchDelete}
>
批量删除 ({selectedDevices.length})
</Button>
<div style={headerStyle}>
<div style={titleRowStyle}>
<div style={titleSectionStyle}>
<div style={titleIconStyle}>
<CloudServerOutlined style={{ fontSize: '20px', color: '#ffffff' }} />
</div>
<div style={titleTextStyle}>
<h1 style={pageTitleStyle}>设备管理</h1>
<p style={pageSubtitleStyle}>管理您的IT设备资产</p>
</div>
</div>
<div style={{ display: 'flex', gap: designTokens.spacing.sm, flexWrap: 'wrap' }}>
<Button
style={primaryActionStyle}
icon={<PlusOutlined />}
onClick={() => showModal()}
>
添加设备
</Button>
<Button
style={secondaryActionStyle}
icon={<UploadOutlined />}
onClick={() => setImportModalVisible(true)}
>
导入
</Button>
<Button
style={secondaryActionStyle}
icon={<ExportOutlined />}
onClick={showExportModal}
>
导出
</Button>
<Button
style={secondaryActionStyle}
icon={<SettingOutlined />}
onClick={() => setFieldConfigModalVisible(true)}
>
字段配置
</Button>
<Button
style={{
...secondaryActionStyle,
color: selectedDevices.length > 0 ? designTokens.colors.success.main : undefined,
borderColor: selectedDevices.length > 0 ? designTokens.colors.success.main : undefined
}}
icon={<ReloadOutlined />}
disabled={selectedDevices.length === 0}
onClick={showBatchStatusModal}
>
状态变更 ({selectedDevices.length})
</Button>
<Button
danger
style={{
...secondaryActionStyle,
color: selectedDevices.length > 0 ? designTokens.colors.error.main : undefined,
borderColor: selectedDevices.length > 0 ? designTokens.colors.error.main : undefined
}}
icon={<DeleteOutlined />}
disabled={selectedDevices.length === 0}
onClick={handleBatchDelete}
>
批量删除 ({selectedDevices.length})
</Button>
</div>
</div>
</div>
<Card size="small" style={searchCardStyle} styles={{ body: { padding: '16px 20px', display: 'flex', alignItems: 'center', gap: '16px' } }}>
<Card size="small" style={filterCardStyle} styles={{ body: { padding: `${designTokens.spacing.md}px ${designTokens.spacing.lg}px`, display: 'flex', alignItems: 'center', gap: designTokens.spacing.md, flexWrap: 'wrap' } }}>
<Form
form={searchForm}
layout="inline"
onFinish={handleSearch}
style={{ flex: 1 }}
style={{ flex: 1, display: 'flex', flexWrap: 'wrap', gap: designTokens.spacing.md }}
className="filter-form"
>
<Form.Item name="keyword">
<Form.Item name="keyword" style={{ margin: 0 }}>
<Input
placeholder="搜索设备ID、名称、类型、型号、序列号、IP、描述..."
prefix={<SearchOutlined style={{ color: '#667eea' }} />}
placeholder="搜索设备..."
prefix={<SearchOutlined style={{ color: designTokens.colors.primary.main }} />}
style={{
width: 320,
borderRadius: '8px',
border: '1px solid #d9d9d9'
width: '280px',
borderRadius: designTokens.borderRadius.medium,
border: `1px solid ${designTokens.colors.border.light}`,
transition: `all ${designTokens.transitions.fast}`
}}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
</Form.Item>
<Form.Item name="status">
<Form.Item name="status" style={{ margin: 0 }}>
<Select
value={status}
onChange={setStatus}
style={{ width: 150, borderRadius: '8px' }}
style={{ width: '140px', borderRadius: designTokens.borderRadius.medium }}
dropdownStyle={{ borderRadius: designTokens.borderRadius.medium }}
>
<Option value="all">所有状态</Option>
<Option value="running">运行中</Option>
@@ -1438,11 +1694,12 @@ function DeviceManagement() {
</Select>
</Form.Item>
<Form.Item name="type">
<Form.Item name="type" style={{ margin: 0 }}>
<Select
value={type}
onChange={setType}
style={{ width: 150, borderRadius: '8px' }}
style={{ width: '140px', borderRadius: designTokens.borderRadius.medium }}
dropdownStyle={{ borderRadius: designTokens.borderRadius.medium }}
>
<Option value="all">所有类型</Option>
<Option value="server">服务器</Option>
@@ -1453,37 +1710,50 @@ function DeviceManagement() {
</Select>
</Form.Item>
<Form.Item>
<Form.Item style={{ margin: 0 }}>
<Space>
<Button
type="primary"
htmlType="submit"
style={{
height: '36px',
borderRadius: designTokens.borderRadius.medium,
background: designTokens.colors.primary.gradient,
border: 'none',
boxShadow: designTokens.shadows.small
}}
icon={<SearchOutlined />}
style={primaryButtonStyle}
htmlType="submit"
loading={searching}
>
搜索
</Button>
<Button
style={{
height: '36px',
borderRadius: designTokens.borderRadius.medium,
border: `1px solid ${designTokens.colors.border.light}`
}}
onClick={handleReset}
style={secondaryButtonStyle}
>
重置
</Button>
</Space>
</Form.Item>
</Form>
<Button
style={{
...secondaryButtonStyle,
color: '#fa8c16',
borderColor: '#fa8c16',
whiteSpace: 'nowrap'
}}
icon={<ExportOutlined />}
onClick={showExportModal}
>
增强导出
</Button>
<div style={{ display: 'flex', alignItems: 'center', gap: designTokens.spacing.sm }}>
<Button
icon={<ReloadOutlined />}
onClick={() => fetchDevices(1, pagination.pageSize, true)}
style={{
borderRadius: designTokens.borderRadius.medium,
border: `1px solid ${designTokens.colors.border.light}`,
height: '36px'
}}
>
刷新
</Button>
</div>
</Card>
<Card style={cardStyle}>
@@ -1491,21 +1761,31 @@ function DeviceManagement() {
<div style={{
textAlign: 'center',
padding: '60px 20px',
color: '#999',
color: designTokens.colors.text.secondary,
fontSize: '15px'
}}>
<SearchOutlined style={{ fontSize: '48px', marginBottom: '16px', color: '#d9d9d9' }} />
<SearchOutlined style={{ fontSize: '48px', marginBottom: '16px', color: designTokens.colors.border.light }} />
<p>暂无设备数据</p>
</div>
)}
{filteredDevicesMemo.length > 0 && (
<div className="device-table-wrapper">
<div className="device-table-wrapper" style={{
borderRadius: designTokens.borderRadius.medium,
overflow: 'hidden'
}}>
<Table
columns={columns}
dataSource={filteredDevicesMemo}
rowKey="deviceId"
loading={loading || searching}
pagination={pagination}
pagination={{
...pagination,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '30', '50', '100'],
showTotal: (total) => `${total} 条记录`,
style: { marginTop: '16px' }
}}
onChange={handleTableChange}
scroll={{ y: 'calc(100vh - 380px)', scrollToFirstRowOnChange: true }}
virtual
@@ -1640,7 +1920,7 @@ function DeviceManagement() {
<Form.Item style={{ textAlign: 'right', marginTop: '24px' }}>
<Space>
<Button onClick={handleCancel} style={secondaryButtonStyle}>取消</Button>
<Button onClick={handleCancel} style={secondaryActionStyle}>取消</Button>
<Button type="primary" htmlType="submit" style={primaryButtonStyle}>确定</Button>
</Space>
</Form.Item>
@@ -1687,8 +1967,8 @@ function DeviceManagement() {
<Form.Item style={{ textAlign: 'right', marginTop: '20px' }}>
<Space>
<Button onClick={() => setFieldConfigModalVisible(false)} style={secondaryButtonStyle}>取消</Button>
<Button onClick={handleResetFieldConfig} style={secondaryButtonStyle}>重置默认</Button>
<Button onClick={() => setFieldConfigModalVisible(false)} style={secondaryActionStyle}>取消</Button>
<Button onClick={handleResetFieldConfig} style={secondaryActionStyle}>重置默认</Button>
<Button type="primary" htmlType="submit" style={primaryButtonStyle}>保存</Button>
</Space>
</Form.Item>
@@ -1733,7 +2013,7 @@ function DeviceManagement() {
<div style={{ marginBottom: '20px' }}>
<a href="/api/devices/import-template" download="设备导入模板.csv">
<Button icon={<DownloadOutlined />} style={secondaryButtonStyle}>
<Button icon={<DownloadOutlined />} style={secondaryActionStyle}>
下载导入模板
</Button>
</a>
@@ -1861,7 +2141,7 @@ function DeviceManagement() {
<Button key="close" onClick={() => {
setDetailModalVisible(false);
setSelectedDevice(null);
}} style={secondaryButtonStyle}>
}} style={secondaryActionStyle}>
关闭
</Button>,
<Button key="edit" type="primary" onClick={() => {
@@ -1989,7 +2269,7 @@ function DeviceManagement() {
open={batchStatusModalVisible}
onCancel={() => setBatchStatusModalVisible(false)}
footer={[
<Button key="cancel" onClick={() => setBatchStatusModalVisible(false)} style={secondaryButtonStyle}>
<Button key="cancel" onClick={() => setBatchStatusModalVisible(false)} style={secondaryActionStyle}>
取消
</Button>,
<Button key="submit" type="primary" loading={batchStatusLoading} onClick={handleBatchStatusChange} style={primaryButtonStyle}>
@@ -2028,7 +2308,7 @@ function DeviceManagement() {
open={exportModalVisible}
onCancel={() => setExportModalVisible(false)}
footer={[
<Button key="cancel" onClick={() => setExportModalVisible(false)} style={secondaryButtonStyle}>
<Button key="cancel" onClick={() => setExportModalVisible(false)} style={secondaryActionStyle}>
取消
</Button>,
<Button key="submit" type="primary" loading={exportLoading} onClick={handleEnhancedExport} style={primaryButtonStyle}>
File diff suppressed because it is too large Load Diff
+347 -145
View File
@@ -1,26 +1,174 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Card, Select, Button, Space, message, Tooltip, Modal, Form, Switch, Checkbox, Input, Badge, Typography } from 'antd';
import {
ReloadOutlined,
ZoomInOutlined,
ZoomOutOutlined,
RotateRightOutlined,
CloudServerOutlined,
SwitcherOutlined,
DatabaseOutlined,
CloudOutlined,
LaptopOutlined,
MobileOutlined,
PrinterOutlined,
SettingOutlined,
SearchOutlined,
ClearOutlined,
EnvironmentOutlined
import { Card, Select, Button, Space, message, Tooltip, Modal, Form, Switch, Checkbox, Input, Badge, Typography, Row, Col, Empty, Spin } from 'antd';
import {
ReloadOutlined, ZoomInOutlined, ZoomOutOutlined,
RotateRightOutlined, CloudServerOutlined, SwitcherOutlined,
DatabaseOutlined, CloudOutlined, LaptopOutlined,
MobileOutlined, PrinterOutlined, SettingOutlined,
SearchOutlined, ClearOutlined, EnvironmentOutlined,
FilterOutlined, AppstoreOutlined, UnorderedListOutlined,
FullscreenOutlined, CompressOutlined, EyeOutlined
} from '@ant-design/icons';
import axios from 'axios';
const { Option } = Select;
const { Text } = Typography;
const { Text, Title } = Typography;
const designTokens = {
colors: {
primary: {
main: '#667eea',
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
light: '#8b9ff0',
dark: '#4f5db8'
},
success: {
main: '#10b981',
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
light: '#34d399',
dark: '#047857'
},
warning: {
main: '#f59e0b',
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
light: '#fbbf24',
dark: '#b45309'
},
error: {
main: '#ef4444',
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
light: '#f87171',
dark: '#b91c1c'
},
purple: {
main: '#8b5cf6',
gradient: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)'
},
cyan: {
main: '#06b6d4',
gradient: 'linear-gradient(135deg, #06b6d4 0%, #0891b2 100%)'
},
text: {
primary: '#1e293b',
secondary: '#64748b',
tertiary: '#94a3b8',
inverse: '#ffffff'
},
background: {
primary: '#ffffff',
secondary: '#f8fafc',
tertiary: '#f1f5f9',
dark: '#1e293b'
},
border: {
light: '#e2e8f0',
medium: '#cbd5e1',
dark: '#94a3b8'
},
device: {
server: '#3b82f6',
switch: '#22c55e',
router: '#f59e0b',
storage: '#8b5cf6',
firewall: '#ef4444',
ups: '#14b8a6',
pdu: '#64748b',
other: '#94a3b8'
},
status: {
normal: '#10b981',
running: '#10b981',
warning: '#f59e0b',
error: '#ef4444',
fault: '#ef4444',
offline: '#6b7280',
maintenance: '#3b82f6'
}
},
shadows: {
small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
large: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)',
xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)',
glow: '0 0 20px rgba(102, 126, 234, 0.3)'
},
borderRadius: {
small: '6px',
medium: '10px',
large: '16px',
xl: '24px',
round: '50%'
},
transitions: {
fast: '150ms cubic-bezier(0.4, 0, 0.2, 1)',
normal: '300ms cubic-bezier(0.4, 0, 0.2, 1)',
slow: '500ms cubic-bezier(0.4, 0, 0.2, 1)'
},
spacing: {
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px',
xl: '32px'
}
};
const pageContainerStyle = {
minHeight: '100vh',
background: designTokens.colors.background.secondary,
padding: designTokens.spacing.lg
};
const headerStyle = {
marginBottom: designTokens.spacing.lg,
padding: `${designTokens.spacing.lg}px ${designTokens.spacing.xl}px`,
background: designTokens.colors.primary.gradient,
borderRadius: designTokens.borderRadius.large,
boxShadow: designTokens.shadows.large,
color: designTokens.colors.text.inverse
};
const cardStyle = {
borderRadius: designTokens.borderRadius.large,
border: 'none',
boxShadow: designTokens.shadows.medium,
background: designTokens.colors.background.primary,
overflow: 'hidden'
};
const filterCardStyle = {
borderRadius: designTokens.borderRadius.medium,
border: 'none',
boxShadow: designTokens.shadows.small,
background: designTokens.colors.background.primary,
marginBottom: designTokens.spacing.lg
};
const primaryButtonStyle = {
height: '40px',
borderRadius: designTokens.borderRadius.medium,
background: designTokens.colors.primary.gradient,
border: 'none',
boxShadow: designTokens.shadows.medium,
fontWeight: '500',
transition: `all ${designTokens.transitions.normal}`
};
const secondaryButtonStyle = {
height: '40px',
borderRadius: designTokens.borderRadius.medium,
border: `1px solid ${designTokens.colors.border.light}`,
background: designTokens.colors.background.primary,
transition: `all ${designTokens.transitions.fast}`
};
const statCardStyle = () => ({
background: 'rgba(255, 255, 255, 0.15)',
borderRadius: designTokens.borderRadius.medium,
padding: `${designTokens.spacing.md}px`,
border: '1px solid rgba(255, 255, 255, 0.2)',
backdropFilter: 'blur(10px)'
});
// 工具函数提取到组件外部,避免每次渲染重复创建
const getDeviceIcon = (deviceType) => {
@@ -339,6 +487,30 @@ const initAnimationStyles = () => {
white-space: nowrap;
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
}
@media (max-width: 768px) {
.device-tooltip {
font-size: 10px;
padding: 6px 10px;
}
.device-count-badge {
padding: 4px 10px;
font-size: 11px;
}
}
@media (max-width: 480px) {
.device-tooltip {
font-size: 9px;
padding: 4px 8px;
}
.device-count-badge {
padding: 3px 8px;
font-size: 10px;
}
}
`;
document.head.appendChild(style);
return style;
@@ -398,56 +570,6 @@ class ErrorBoundary extends React.Component {
}
function RackVisualization() {
const pageHeaderStyle = {
marginBottom: '24px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: '16px'
};
const titleStyle = {
fontSize: '24px',
fontWeight: '700',
margin: 0,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text'
};
const primaryButtonStyle = {
height: '40px',
borderRadius: '8px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.35)',
fontWeight: '500',
transition: 'all 0.3s ease'
};
const secondaryButtonStyle = {
height: '40px',
borderRadius: '8px',
border: '1px solid #e8e8e8',
transition: 'all 0.3s ease'
};
const cardStyle = {
borderRadius: '16px',
border: 'none',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
overflow: 'hidden'
};
const searchCardStyle = {
borderRadius: '12px',
border: 'none',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)',
marginBottom: '20px'
};
const [racks, setRacks] = useState([]);
const [selectedRack, setSelectedRack] = useState(null);
const [selectedRoom, setSelectedRoom] = useState(null);
@@ -1166,24 +1288,69 @@ function RackVisualization() {
// 无需额外的初始化,CSS 3D变换直接在JSX中实现
return (
<div style={{ padding: '24px' }}>
<div style={pageHeaderStyle}>
<h1 style={titleStyle}>
<CloudServerOutlined style={{ marginRight: '12px' }} />
机柜可视化
</h1>
<Space size={12} wrap>
<div style={pageContainerStyle}>
<div style={headerStyle}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: designTokens.spacing.lg
}}>
<div>
<Title level={3} style={{ margin: 0, color: '#fff', fontWeight: 700 }}>
<CloudServerOutlined style={{ marginRight: designTokens.spacing.sm, fontSize: '28px' }} />
机柜可视化
</Title>
<Text style={{ color: 'rgba(255,255,255,0.8)', marginTop: designTokens.spacing.xs, display: 'block', fontSize: '14px' }}>
实时监控机房机柜设备分布与状态
</Text>
</div>
<Row gutter={[designTokens.spacing.md, designTokens.spacing.md]} style={{ textAlign: 'center' }}>
<Col>
<div style={statCardStyle()}>
<Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: '13px', display: 'block' }}>机柜总数</Text>
<div style={{ fontSize: '28px', fontWeight: 700, color: '#fff' }}>{racks.length}</div>
</div>
</Col>
<Col>
<div style={statCardStyle()}>
<Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: '13px', display: 'block' }}>设备总数</Text>
<div style={{ fontSize: '28px', fontWeight: 700, color: '#fff' }}>
{racks.reduce((sum, rack) => sum + (rack.deviceCount || 0), 0)}
</div>
</div>
</Col>
<Col>
<div style={statCardStyle()}>
<Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: '13px', display: 'block' }}>在线设备</Text>
<div style={{ fontSize: '28px', fontWeight: 700, color: '#fff' }}>
{devices.filter(d => d.status === 'running' || d.status === 'normal').length}
</div>
</div>
</Col>
</Row>
</div>
</div>
<Card style={filterCardStyle} styles={{ body: { padding: `${designTokens.spacing.md}px ${designTokens.spacing.lg}px` } }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: designTokens.spacing.md,
flexWrap: 'wrap'
}}>
<Input
placeholder="搜索设备名称、ID、IP..."
value={searchKeyword}
onChange={(e) => handleSearch(e.target.value)}
onPressEnter={(e) => handleSearch(e.target.value)}
style={{ width: 220, height: '40px', borderRadius: '8px' }}
style={{ width: 240, height: '40px', borderRadius: designTokens.borderRadius.medium }}
allowClear
prefix={<SearchOutlined />}
suffix={
searchMatchCount > 0 ? (
<Badge count={searchMatchCount} size="small" style={{ backgroundColor: '#52c41a' }} />
<Badge count={searchMatchCount} size="small" style={{ backgroundColor: designTokens.colors.success.main }} />
) : null
}
/>
@@ -1197,7 +1364,7 @@ function RackVisualization() {
)}
<Select
placeholder="选择机房"
style={{ width: 180, height: '40px' }}
style={{ width: 180, height: '40px', borderRadius: designTokens.borderRadius.medium }}
value={selectedRoom}
onChange={handleRoomChange}
loading={loading}
@@ -1212,7 +1379,7 @@ function RackVisualization() {
</Select>
<Select
placeholder="选择机柜"
style={{ width: 200, height: '40px' }}
style={{ width: 200, height: '40px', borderRadius: designTokens.borderRadius.medium }}
value={selectedRack?.rackId}
onChange={handleRackChange}
loading={loading || loadingDevices}
@@ -1225,52 +1392,56 @@ function RackVisualization() {
</Option>
))}
</Select>
<Button
<Button
style={secondaryButtonStyle}
icon={<ReloadOutlined />}
icon={<ReloadOutlined />}
onClick={handleReload}
loading={loading}
disabled={loading}
>
刷新
</Button>
<Button
<Button
style={showTooltipConfig ? primaryButtonStyle : secondaryButtonStyle}
icon={<SettingOutlined />}
icon={<SettingOutlined />}
onClick={handleOpenTooltipConfig}
type={showTooltipConfig ? 'primary' : 'default'}
>
字段配置
</Button>
</Space>
</div>
</div>
</Card>
{searchKeyword && (
<Card size="small" style={searchCardStyle} styles={{ body: { padding: '16px 20px' } }}>
<div style={{
<Card style={{
...filterCardStyle,
background: designTokens.colors.success.gradient,
borderRadius: designTokens.borderRadius.medium
}} styles={{ body: { padding: `${designTokens.spacing.md}px ${designTokens.spacing.lg}px` } }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
gap: designTokens.spacing.md,
flexWrap: 'wrap'
}}>
<SearchOutlined style={{
fontSize: 18,
color: searchResults.length > 0 ? '#52c41a' : '#ff4d4f'
<SearchOutlined style={{
fontSize: 20,
color: '#fff'
}} />
<Text strong style={{ color: searchResults.length > 0 ? '#135200' : '#cf1322', margin: 0 }}>
{searchResults.length > 0
? `找到 ${searchResults.length} 个设备`
<Text strong style={{ color: '#fff', margin: 0, fontSize: '14px' }}>
{searchResults.length > 0
? `找到 ${searchResults.length} 个设备`
: searching ? '搜索中...' : '未找到匹配的设备'}
</Text>
{searching && (
<Badge status="processing" text="正在搜索所有机柜..." />
<Badge status="processing" text="正在搜索所有机柜..." style={{ color: '#fff' }} />
)}
{searchResults.length > 0 && (
<Space wrap size={4}>
<Space wrap size={designTokens.spacing.sm}>
{searchResults.slice(0, 5).map(result => {
const isCurrentRack = selectedRack && result.rackId === selectedRack.rackId;
return (
<Button
<Button
key={result.deviceId}
size="small"
type={highlightedDevice === result.deviceId ? 'primary' : 'default'}
@@ -1292,11 +1463,12 @@ function RackVisualization() {
}
setHighlightedDevice(result.deviceId);
}}
style={{
borderColor: highlightedDevice === result.deviceId ? '#667eea' : undefined,
background: highlightedDevice === result.deviceId ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : undefined,
style={{
borderColor: 'rgba(255,255,255,0.5)',
background: highlightedDevice === result.deviceId ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.1)',
color: '#fff',
height: '32px',
borderRadius: '6px'
borderRadius: designTokens.borderRadius.small
}}
>
<Tooltip title={`机柜: ${result.rackName}${result.roomName ? ` | 机房: ${result.roomName}` : ''} | U${result.position || '?'}`}>
@@ -1306,7 +1478,7 @@ function RackVisualization() {
U{result.position || '?'}
</Text>
{!isCurrentRack && (
<EnvironmentOutlined style={{ marginLeft: 4, color: '#faad14' }} />
<EnvironmentOutlined style={{ marginLeft: 4, color: '#fbbf24' }} />
)}
</span>
</Tooltip>
@@ -1314,7 +1486,7 @@ function RackVisualization() {
);
})}
{searchResults.length > 5 && (
<Text type="secondary">+ 还有 {searchResults.length - 5} </Text>
<Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: '13px' }}>+ 还有 {searchResults.length - 5} </Text>
)}
</Space>
)}
@@ -1322,15 +1494,15 @@ function RackVisualization() {
</Card>
)}
<Card style={cardStyle} styles={{ body: { padding: '16px 20px' } }}>
<div style={{ marginBottom: 16 }}>
<Space wrap size={12}>
<Card style={cardStyle} styles={{ body: { padding: `${designTokens.spacing.md}px ${designTokens.spacing.lg}px` } }}>
<div style={{ marginBottom: designTokens.spacing.md }}>
<Space wrap size={designTokens.spacing.sm}>
<Button style={secondaryButtonStyle} icon={<ZoomInOutlined />} onClick={handleZoomIn}>放大</Button>
<Button style={secondaryButtonStyle} icon={<ZoomOutOutlined />} onClick={handleZoomOut}>缩小</Button>
<Button style={secondaryButtonStyle} icon={<RotateRightOutlined />} onClick={handleResetView}>重置视角</Button>
<Select
placeholder="选择背景类型"
style={{ width: 150, height: '40px' }}
style={{ width: 150, height: '40px', borderRadius: designTokens.borderRadius.medium }}
value={backgroundType}
onChange={async (type) => {
setBackgroundType(type);
@@ -1341,11 +1513,18 @@ function RackVisualization() {
<Option value="image">自定义图片</Option>
</Select>
{backgroundType === 'image' && (
<Space size={8}>
<Space size={designTokens.spacing.sm}>
<input
type="text"
placeholder="输入图片URL"
style={{ width: 200, padding: '8px 12px', borderRadius: '8px', border: '1px solid #d9d9d9', height: '40px', boxSizing: 'border-box' }}
style={{
width: 200,
padding: `8px ${designTokens.spacing.md}px`,
borderRadius: designTokens.borderRadius.medium,
border: `1px solid ${designTokens.colors.border.light}`,
height: '40px',
boxSizing: 'border-box'
}}
onChange={async (e) => {
const image = e.target.value;
setBackgroundImage(image);
@@ -1364,13 +1543,13 @@ function RackVisualization() {
const formData = new FormData();
formData.append('file', file);
formData.append('type', 'background');
const response = await axios.post('/api/background/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
if (response.data && response.data.path) {
setBackgroundImage(response.data.path);
message.success('背景图片上传成功');
@@ -1392,7 +1571,7 @@ function RackVisualization() {
<Button style={secondaryButtonStyle} onClick={() => document.getElementById('backgroundFileInput').click()} loading={uploading}>上传图片</Button>
<Select
placeholder="图片大小"
style={{ width: 100, height: '40px' }}
style={{ width: 100, height: '40px', borderRadius: designTokens.borderRadius.medium }}
value={backgroundSize}
onChange={async (size) => {
setBackgroundSize(size);
@@ -2219,40 +2398,50 @@ function RackVisualization() {
{/* 设备详情字段配置模态框 */}
<Modal
title="设备详情字段配置"
title={
<div style={{ display: 'flex', alignItems: 'center', gap: designTokens.spacing.sm }}>
<SettingOutlined style={{ color: designTokens.colors.primary.main }} />
<span style={{ fontWeight: 600 }}>设备详情字段配置</span>
</div>
}
open={showTooltipConfig}
onCancel={() => setShowTooltipConfig(false)}
footer={null}
width={500}
width={520}
styles={{ body: { padding: designTokens.spacing.lg } }}
>
{loadingTooltipFields ? (
<div style={{ padding: '40px', textAlign: 'center', color: '#94a3b8' }}>
加载字段配置中...
<div style={{ padding: designTokens.spacing.xl, textAlign: 'center', color: designTokens.colors.text.tertiary }}>
<Spin size="large" />
<div style={{ marginTop: designTokens.spacing.md }}>加载字段配置中...</div>
</div>
) : Object.keys(tooltipFields).length === 0 ? (
<div style={{ padding: '40px', textAlign: 'center', color: '#94a3b8' }}>
暂无字段配置请先在设备字段管理中添加字段
<div style={{ padding: designTokens.spacing.xl, textAlign: 'center', color: designTokens.colors.text.tertiary }}>
<Empty description="暂无字段配置" />
<Text style={{ color: designTokens.colors.text.tertiary, display: 'block', marginTop: designTokens.spacing.sm }}>
请先在设备字段管理中添加字段
</Text>
</div>
) : (
<div>
<div style={{ maxHeight: 400, overflowY: 'auto' }}>
<div style={{ color: '#64748b', fontSize: '12px', marginBottom: '12px' }}>
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
<div style={{ color: designTokens.colors.text.secondary, fontSize: '13px', marginBottom: designTokens.spacing.md }}>
选择要在设备详情中显示的字段 ({Object.keys(tooltipFields).length} 个字段)
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '8px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: designTokens.spacing.sm }}>
{Object.entries(tooltipFields).map(([key, field]) => (
<div
key={key}
style={{
padding: '8px 12px',
borderRadius: '6px',
padding: `${designTokens.spacing.sm}px ${designTokens.spacing.md}px`,
borderRadius: designTokens.borderRadius.medium,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
background: field.enabled ? 'rgba(56, 189, 248, 0.1)' : 'transparent',
border: field.enabled ? '1px solid rgba(56, 189, 248, 0.3)' : '1px solid transparent',
transition: 'all 0.2s ease'
gap: designTokens.spacing.sm,
background: field.enabled ? `${designTokens.colors.primary.main}10` : 'transparent',
border: field.enabled ? `1px solid ${designTokens.colors.primary.main}30` : `1px solid ${designTokens.colors.border.light}`,
transition: `all ${designTokens.transitions.fast}`
}}
onClick={() => {
setTooltipFields(prev => ({
@@ -2262,32 +2451,45 @@ function RackVisualization() {
}}
>
<Checkbox checked={field.enabled} />
{field.label}
<Text style={{ color: field.enabled ? designTokens.colors.text.primary : designTokens.colors.text.tertiary }}>
{field.label}
</Text>
</div>
))}
</div>
</div>
<div style={{ marginTop: '20px', textAlign: 'right' }}>
<div style={{ marginTop: designTokens.spacing.lg, textAlign: 'right' }}>
<Space>
<Button onClick={() => {
const allEnabled = Object.values(tooltipFields).every(f => f.enabled);
setTooltipFields(prev => {
const newFields = { ...prev };
Object.keys(newFields).forEach(key => {
newFields[key] = { ...newFields[key], enabled: !allEnabled };
<Button
onClick={() => {
const allEnabled = Object.values(tooltipFields).every(f => f.enabled);
setTooltipFields(prev => {
const newFields = { ...prev };
Object.keys(newFields).forEach(key => {
newFields[key] = { ...newFields[key], enabled: !allEnabled };
});
return newFields;
});
return newFields;
});
}}>
全选/取消全选
}}
style={{ borderRadius: designTokens.borderRadius.medium }}
>
{Object.values(tooltipFields).every(f => f.enabled) ? '取消全选' : '全选'}
</Button>
<Button onClick={() => setShowTooltipConfig(false)}>
<Button
onClick={() => setShowTooltipConfig(false)}
style={{ borderRadius: designTokens.borderRadius.medium }}
>
取消
</Button>
<Button type="primary" onClick={async () => {
await saveTooltipConfig();
}} loading={savingTooltipConfig}>
<Button
type="primary"
onClick={async () => {
await saveTooltipConfig();
}}
loading={savingTooltipConfig}
style={primaryButtonStyle}
>
保存
</Button>
</Space>
File diff suppressed because it is too large Load Diff