feat(UI): 优化多个页面的UI设计和交互体验
refactor(ProtectedRoute): 调整加载状态布局和样式 feat(DeviceFieldManagement): 添加设计令牌和样式优化 feat(App): 重构侧边栏和顶部导航栏样式 feat(ConsumableStatistics): 重新设计统计卡片和表格样式 style: 统一多个组件的设计语言和配色方案
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
# IDC设备管理系统优化计划
|
||||
|
||||
## 一、项目结构优化
|
||||
|
||||
### 1. 后端路由拆分
|
||||
- **问题**:单个路由文件过大(如devices.js超过1200行),包含重复代码
|
||||
- **优化方案**:将大型路由文件拆分为多个子路由文件,按功能模块组织
|
||||
- 设备基础路由
|
||||
- 设备导入导出
|
||||
- 设备批量操作
|
||||
- 设备增强功能
|
||||
|
||||
### 2. 前端代码分割
|
||||
- **问题**:前端代码未进行懒加载和代码分割,初始加载时间长
|
||||
- **优化方案**:使用React.lazy和Suspense实现组件懒加载,按页面模块分割代码
|
||||
|
||||
## 二、技术栈升级
|
||||
|
||||
### 1. 依赖版本更新
|
||||
- **问题**:部分依赖版本较旧,存在安全隐患
|
||||
- **优化方案**:升级核心依赖
|
||||
- bcryptjs: ^3.0.3 → ^4.0.1
|
||||
- jsonwebtoken: ^9.0.3 → ^9.0.2(最新稳定版)
|
||||
- three: ^0.160.0 → ^0.182.0(保持与后端一致)
|
||||
|
||||
### 2. 数据库优化
|
||||
- **问题**:缺乏索引优化,查询性能可能受影响
|
||||
- **优化方案**:为常用查询字段添加索引
|
||||
- 设备表:deviceId, rackId, status, type
|
||||
- 用户表:username, email
|
||||
- 工单表:status, createdAt
|
||||
|
||||
## 三、代码质量提升
|
||||
|
||||
### 1. 统一错误处理
|
||||
- **问题**:错误处理分散,缺乏统一机制
|
||||
- **优化方案**:实现全局错误处理中间件
|
||||
- 统一错误格式
|
||||
- 错误日志记录
|
||||
- 友好的错误提示
|
||||
|
||||
### 2. 请求参数验证
|
||||
- **问题**:缺乏请求参数验证,容易受到恶意攻击
|
||||
- **优化方案**:使用Zod或Joi实现请求参数验证
|
||||
- 路由层验证
|
||||
- 模型层验证
|
||||
- 自定义验证规则
|
||||
|
||||
### 3. 重复代码消除
|
||||
- **问题**:同一功能在多个地方重复实现(如批量操作)
|
||||
- **优化方案**:提取公共函数和中间件
|
||||
- 批量操作工具函数
|
||||
- 权限验证中间件
|
||||
- 文件处理工具函数
|
||||
|
||||
## 四、安全性增强
|
||||
|
||||
### 1. 密码哈希算法升级
|
||||
- **问题**:使用旧版bcryptjs,安全性较低
|
||||
- **优化方案**:升级到最新版bcryptjs,使用更强的哈希算法
|
||||
|
||||
### 2. API速率限制
|
||||
- **问题**:缺乏API请求频率限制,容易受到DDoS攻击
|
||||
- **优化方案**:实现速率限制中间件
|
||||
- IP级别的速率限制
|
||||
- 用户级别的速率限制
|
||||
- 不同API端点的差异化限制
|
||||
|
||||
### 3. 输入验证和消毒
|
||||
- **问题**:缺乏输入验证和消毒,容易受到XSS和SQL注入攻击
|
||||
- **优化方案**:
|
||||
- 前端输入验证
|
||||
- 后端输入消毒
|
||||
- 使用参数化查询防止SQL注入
|
||||
|
||||
## 五、可维护性提升
|
||||
|
||||
### 1. 代码风格规范
|
||||
- **问题**:缺乏统一的代码风格规范
|
||||
- **优化方案**:配置ESLint和Prettier
|
||||
- 统一代码风格
|
||||
- 自动格式化代码
|
||||
- 代码质量检查
|
||||
|
||||
### 2. API文档
|
||||
- **问题**:缺乏API文档,开发和维护困难
|
||||
- **优化方案**:集成Swagger/OpenAPI
|
||||
- 自动生成API文档
|
||||
- 支持在线测试
|
||||
- 版本管理
|
||||
|
||||
### 3. 测试覆盖
|
||||
- **问题**:缺乏单元测试和集成测试,代码质量难以保证
|
||||
- **优化方案**:
|
||||
- 编写单元测试(Jest)
|
||||
- 编写集成测试(Supertest)
|
||||
- 配置测试覆盖率报告
|
||||
|
||||
## 六、性能优化
|
||||
|
||||
### 1. 前端性能优化
|
||||
- **问题**:页面加载和交互性能有待提升
|
||||
- **优化方案**:
|
||||
- 实现组件懒加载
|
||||
- 优化图片加载(使用WebP格式、懒加载)
|
||||
- 减少不必要的重渲染
|
||||
- 使用React.memo和useMemo优化组件性能
|
||||
|
||||
### 2. 后端性能优化
|
||||
- **问题**:数据库查询性能和API响应速度
|
||||
- **优化方案**:
|
||||
- 添加数据库索引
|
||||
- 实现缓存机制(Redis)
|
||||
- 优化SQL查询,减少N+1问题
|
||||
- 使用连接池管理数据库连接
|
||||
|
||||
### 3. 图片上传优化
|
||||
- **问题**:图片上传和处理效率较低
|
||||
- **优化方案**:
|
||||
- 实现图片压缩
|
||||
- 使用CDN存储图片
|
||||
- 异步处理图片上传
|
||||
|
||||
## 七、实施步骤
|
||||
|
||||
1. **第一阶段**:基础优化
|
||||
- 依赖版本更新
|
||||
- 代码风格规范配置
|
||||
- 统一错误处理实现
|
||||
|
||||
2. **第二阶段**:结构优化
|
||||
- 后端路由拆分
|
||||
- 前端代码分割
|
||||
- API文档集成
|
||||
|
||||
3. **第三阶段**:安全性增强
|
||||
- 密码哈希算法升级
|
||||
- 请求参数验证
|
||||
- API速率限制
|
||||
- 输入验证和消毒
|
||||
|
||||
4. **第四阶段**:性能优化
|
||||
- 数据库索引优化
|
||||
- 前端性能优化
|
||||
- 缓存机制实现
|
||||
|
||||
5. **第五阶段**:测试和CI/CD
|
||||
- 编写单元测试和集成测试
|
||||
- 配置CI/CD流程
|
||||
- 测试覆盖率提升
|
||||
|
||||
## 八、预期效果
|
||||
|
||||
- **性能提升**:页面加载时间减少50%,API响应时间减少30%
|
||||
- **安全性增强**:消除已知安全隐患,提高系统抗攻击能力
|
||||
- **可维护性提升**:代码结构清晰,文档完善,便于开发和维护
|
||||
- **开发效率提升**:统一的代码规范和工具链,减少开发和调试时间
|
||||
- **扩展性增强**:模块化设计,便于后续功能扩展和系统升级
|
||||
|
||||
通过以上优化措施,可以显著提升IDC设备管理系统的性能、安全性和可维护性,为用户提供更好的使用体验。
|
||||
@@ -0,0 +1,33 @@
|
||||
## 仪表盘获取统计数据失败修复方案
|
||||
|
||||
### 问题分析
|
||||
从代码分析,仪表盘 `fetchStats` 函数中存在以下潜在问题:
|
||||
1. 使用了不存在的 API 端点:`/api/devices/count`
|
||||
2. 没有处理 API 响应格式不一致的情况
|
||||
3. 缺少详细的错误日志,难以定位具体失败原因
|
||||
|
||||
### 修复方案
|
||||
修改 `Dashboard.jsx` 中的 `fetchStats` 函数,使用与其他页面一致的 API 调用方式:
|
||||
|
||||
1. **替换不存在的 API 端点**:使用 `/api/devices?status=fault` 代替 `/api/devices/count`
|
||||
2. **统一 API 调用模式**:与其他页面保持一致的 API 调用和数据解析方式
|
||||
3. **增强错误处理**:添加详细的错误信息和日志
|
||||
4. **添加请求超时处理**:防止请求长时间阻塞
|
||||
5. **优化并发请求**:使用 Promise.all 优化多个 API 请求
|
||||
|
||||
### 具体修改点
|
||||
1. 修改 `fetchStats` 函数中的 API 调用逻辑
|
||||
2. 调整错误处理和日志记录
|
||||
3. 添加请求超时配置
|
||||
4. 优化数据解析逻辑
|
||||
|
||||
### 预期效果
|
||||
修复后,仪表盘将能够成功获取所有统计数据,包括:
|
||||
- 总设备数
|
||||
- 总机柜数
|
||||
- 总机房数
|
||||
- 故障设备数
|
||||
- 用户总数
|
||||
- 待处理工单
|
||||
|
||||
同时,页面将显示更友好的错误提示,便于开发人员定位问题。
|
||||
+339
-187
@@ -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>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+729
-212
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user