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

refactor(ProtectedRoute): 调整加载状态布局和样式
feat(DeviceFieldManagement): 添加设计令牌和样式优化
feat(App): 重构侧边栏和顶部导航栏样式
feat(ConsumableStatistics): 重新设计统计卡片和表格样式
style: 统一多个组件的设计语言和配色方案
This commit is contained in:
zhang1106
2026-01-20 11:28:53 +08:00
parent 25c1f5f905
commit 88e000a41a
11 changed files with 4476 additions and 1526 deletions
@@ -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设备管理系统的性能、安全性和可维护性,为用户提供更好的使用体验。
+33
View File
@@ -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. 优化数据解析逻辑
### 预期效果
修复后,仪表盘将能够成功获取所有统计数据,包括:
- 总设备数
- 总机柜数
- 总机房数
- 故障设备数
- 用户总数
- 待处理工单
同时,页面将显示更友好的错误提示,便于开发人员定位问题。
+338 -186
View File
@@ -27,6 +27,55 @@ const SystemSettings = lazy(() => import('./pages/SystemSettings'));
const { Header, Content, Sider } = Layout; 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 PrivateRoute = ({ children }) => {
const { token, initialized, loading } = useAuth(); const { token, initialized, loading } = useAuth();
const location = useLocation(); const location = useLocation();
@@ -35,12 +84,15 @@ const PrivateRoute = ({ children }) => {
return ( return (
<div style={{ <div style={{
display: 'flex', display: 'flex',
flexDirection: 'column',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
height: '100vh', height: '100vh',
background: '#f5f5f5' background: '#f5f5f5',
gap: '16px'
}}> }}>
<Spin size="large" tip="正在加载认证状态..." /> <Spin size="large" />
<span style={{ color: '#8c8c8c', fontSize: '14px' }}>正在加载认证状态...</span>
</div> </div>
); );
} }
@@ -54,12 +106,15 @@ const PrivateRoute = ({ children }) => {
fallback={ fallback={
<div style={{ <div style={{
display: 'flex', display: 'flex',
flexDirection: 'column',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
height: '100vh', height: '100vh',
background: '#f5f5f5' background: '#f5f5f5',
gap: '16px'
}}> }}>
<Spin size="large" tip="正在加载页面..." /> <Spin size="large" />
<span style={{ color: '#8c8c8c', fontSize: '14px' }}>正在加载页面...</span>
</div> </div>
} }
> >
@@ -72,11 +127,10 @@ const PrivateRoute = ({ children }) => {
const AppLayout = ({ children }) => { const AppLayout = ({ children }) => {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [activeKey, setActiveKey] = useState('dashboard');
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { const location = useLocation();
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken();
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
@@ -84,225 +138,320 @@ const AppLayout = ({ children }) => {
navigate('/login'); 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', key: 'dashboard',
icon: <LogoutOutlined />, icon: <BarChartOutlined style={{ fontSize: '18px' }} />,
label: '退出登录', label: <Link to="/">仪表盘</Link>,
onClick: handleLogout },
} {
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 ( return (
<Layout> <Layout style={{ minHeight: '100vh' }}>
<Sider <Sider
width={220} width={240}
collapsedWidth={80} collapsedWidth={72}
collapsed={collapsed} collapsed={collapsed}
style={{ style={{
backgroundColor: colorBgContainer, background: designTokens.colors.sidebar.bg,
boxShadow: '2px 0 8px rgba(0,0,0,0.08)', boxShadow: '2px 0 8px rgba(0, 0, 0, 0.06)',
borderRight: `1px solid ${designTokens.colors.sidebar.border}`,
display: 'flex', display: 'flex',
flexDirection: 'column' flexDirection: 'column',
position: 'fixed',
left: 0,
top: 0,
bottom: 0,
zIndex: 100
}} }}
> >
<div style={{ <div style={{
display: 'flex', display: 'flex',
justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
height: 64, justifyContent: collapsed ? 'center' : 'flex-start',
padding: '0 12px', padding: collapsed ? '16px 0' : '16px 20px',
borderBottom: '1px solid #f0f0f0', borderBottom: `1px solid ${designTokens.colors.sidebar.border}`,
marginBottom: 8 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 <Button
type="text" type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />} icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)} onClick={() => setCollapsed(!collapsed)}
style={{ style={{
fontSize: 18, width: '100%',
padding: '8px', height: '40px',
borderRadius: 4 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> </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> </Sider>
<Layout style={{ padding: '0 24px 24px' }}>
<Layout style={{
marginLeft: collapsed ? 72 : 240,
transition: 'margin-left 0.2s ease'
}}>
<Header style={{ <Header style={{
padding: '0 16px', padding: '0 24px',
height: 56, height: 64,
background: colorBgContainer, background: designTokens.colors.background.primary,
marginBottom: 24,
borderRadius: borderRadiusLG,
display: 'flex', display: 'flex',
justifyContent: 'flex-end', justifyContent: 'flex-end',
alignItems: 'center', alignItems: 'center',
boxShadow: '0 1px 4px rgba(0,0,0,0.08)' boxShadow: designTokens.shadows.small,
position: 'sticky',
top: 0,
zIndex: 99
}}> }}>
{user && ( {user && (
<Space size={12}> <div style={{
<Avatar display: 'flex',
style={{ backgroundColor: '#1890ff', cursor: 'pointer' }} alignItems: 'center',
icon={<UserOutlined />} gap: '12px'
/> }}>
<span style={{ color: '#666', fontSize: 14 }}>{user.username}</span> <div style={{
<Divider type="vertical" style={{ margin: 0 }} /> 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 <Button
type="text" type="text"
danger danger
icon={<LogoutOutlined />} icon={<LogoutOutlined />}
onClick={handleLogout} onClick={handleLogout}
style={{ padding: '4px 8px' }} style={{
padding: '8px 12px',
height: 'auto',
borderRadius: designTokens.borderRadius.small,
fontSize: 13
}}
> >
退出 退出
</Button> </Button>
</Space> </div>
)} )}
</Header> </Header>
<Content <Content
style={{ style={{
padding: 24, padding: 24,
margin: 0, margin: 0,
minHeight: 280, minHeight: 'calc(100vh - 64px)',
background: colorBgContainer, background: designTokens.colors.background.secondary
borderRadius: borderRadiusLG,
}} }}
> >
{children} {children}
@@ -321,12 +470,15 @@ function App() {
fallback={ fallback={
<div style={{ <div style={{
display: 'flex', display: 'flex',
flexDirection: 'column',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
height: '100vh', height: '100vh',
background: '#f5f5f5' background: '#f5f5f5',
gap: '16px'
}}> }}>
<Spin size="large" tip="正在加载登录页面..." /> <Spin size="large" />
<span style={{ color: '#8c8c8c', fontSize: '14px' }}>正在加载登录页面...</span>
</div> </div>
} }
> >
+5 -2
View File
@@ -11,11 +11,14 @@ const ProtectedRoute = ({ children, requiredPermission }) => {
return ( return (
<div style={{ <div style={{
display: 'flex', display: 'flex',
flexDirection: 'column',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
height: '100vh' height: '100vh',
gap: '16px'
}}> }}>
<Spin size="large" tip="加载中..." /> <Spin size="large" />
<span style={{ color: '#8c8c8c', fontSize: '14px' }}>加载中...</span>
</div> </div>
); );
} }
+521 -135
View File
@@ -1,10 +1,186 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Card, Row, Col, Statistic, Table, Tag, DatePicker, Space, Select, Progress, message } from 'antd'; import { Card, Row, Col, Statistic, Table, Tag, DatePicker, Space, Select, Progress, message, Button } from 'antd';
import { InboxOutlined, ExportOutlined, WarningOutlined, DollarOutlined, ShoppingCartOutlined, ExclamationCircleOutlined } from '@ant-design/icons'; import { InboxOutlined, ExportOutlined, WarningOutlined, DollarOutlined, ShoppingCartOutlined, ExclamationCircleOutlined, PlusOutlined, BarChartOutlined, DownloadOutlined } from '@ant-design/icons';
import axios from 'axios'; import axios from 'axios';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
const { RangePicker } = DatePicker; 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() { function ConsumableStatistics() {
const [summary, setSummary] = useState({ total: 0, lowStock: 0, totalValue: 0, byCategory: [] }); 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 [stats, setStats] = useState({ inCount: 0, outCount: 0, inQuantity: 0, outQuantity: 0, recentRecords: [] });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [dateRange, setDateRange] = useState([]); const [dateRange, setDateRange] = useState([]);
const [categoryFilter, setCategoryFilter] = useState(null);
const fetchSummary = async () => { const fetchSummary = async () => {
try { try {
@@ -38,6 +215,9 @@ function ConsumableStatistics() {
params.startDate = dateRange[0].toISOString(); params.startDate = dateRange[0].toISOString();
params.endDate = dateRange[1].toISOString(); params.endDate = dateRange[1].toISOString();
} }
if (categoryFilter) {
params.category = categoryFilter;
}
const response = await axios.get('/api/consumable-records/statistics', { params }); const response = await axios.get('/api/consumable-records/statistics', { params });
setStats(response.data); setStats(response.data);
} catch (error) { } catch (error) {
@@ -53,20 +233,36 @@ function ConsumableStatistics() {
useEffect(() => { useEffect(() => {
fetchInOutStats(); fetchInOutStats();
}, [dateRange]); }, [dateRange, categoryFilter]);
const lowStockColumns = [ const lowStockColumns = [
{ {
title: '耗材名称', title: '耗材名称',
dataIndex: 'name', dataIndex: 'name',
key: 'name', key: 'name',
width: 150 width: 150,
render: (text) => (
<span style={{ fontWeight: '500', color: designTokens.colors.text.primary }}>
{text}
</span>
)
}, },
{ {
title: '分类', title: '分类',
dataIndex: 'category', dataIndex: 'category',
key: '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: '当前库存', title: '当前库存',
@@ -74,37 +270,66 @@ function ConsumableStatistics() {
key: 'currentStock', key: 'currentStock',
width: 100, width: 100,
render: (value) => ( 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: '最小库存', title: '最小库存',
dataIndex: 'minStock', dataIndex: 'minStock',
key: 'minStock', key: 'minStock',
width: 100 width: 100,
render: (value) => (
<span style={{ color: designTokens.colors.text.secondary }}>
{value}
</span>
)
}, },
{ {
title: '单位', title: '单位',
dataIndex: 'unit', dataIndex: 'unit',
key: 'unit', key: 'unit',
width: 80 width: 80,
render: (value) => (
<span style={{ color: designTokens.colors.text.tertiary }}>
{value}
</span>
)
}, },
{ {
title: '库存充足率', title: '充足率',
key: 'rate', key: 'rate',
width: 150, width: 140,
render: (_, record) => { 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'; 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: '供应商', title: '供应商',
dataIndex: 'supplier', dataIndex: 'supplier',
key: 'supplier', key: 'supplier',
width: 150, width: 120,
render: (value) => value || '-' render: (value) => (
<span style={{ color: designTokens.colors.text.secondary }}>
{value || '-'}
</span>
)
} }
]; ];
@@ -113,22 +338,39 @@ function ConsumableStatistics() {
title: '时间', title: '时间',
dataIndex: 'createdAt', dataIndex: 'createdAt',
key: 'createdAt', key: 'createdAt',
width: 180, width: 170,
render: (date) => dayjs(date).format('YYYY-MM-DD HH:mm:ss') render: (date) => (
<span style={{ color: designTokens.colors.text.secondary }}>
{dayjs(date).format('YYYY-MM-DD HH:mm')}
</span>
)
}, },
{ {
title: '耗材名称', title: '耗材名称',
dataIndex: ['Consumable', 'name'], dataIndex: ['Consumable', 'name'],
key: 'consumableName', key: 'consumableName',
width: 150 width: 140,
render: (text) => (
<span style={{ fontWeight: '500' }}>
{text}
</span>
)
}, },
{ {
title: '类型', title: '类型',
dataIndex: 'type', dataIndex: 'type',
key: 'type', key: 'type',
width: 100, width: 90,
render: (type) => ( 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' ? '入库' : '出库'} {type === 'in' ? '入库' : '出库'}
</Tag> </Tag>
) )
@@ -139,7 +381,10 @@ function ConsumableStatistics() {
key: 'quantity', key: 'quantity',
width: 100, width: 100,
render: (value, record) => ( 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} {record.type === 'in' ? '+' : '-'}{value}
</span> </span>
) )
@@ -148,138 +393,279 @@ function ConsumableStatistics() {
title: '操作人', title: '操作人',
dataIndex: 'operator', dataIndex: 'operator',
key: 'operator', key: 'operator',
width: 120 width: 100,
render: (value) => (
<span style={{ color: designTokens.colors.text.secondary }}>
{value || '-'}
</span>
)
}, },
{ {
title: '原因', title: '原因',
dataIndex: 'reason', dataIndex: 'reason',
key: 'reason', key: 'reason',
width: 150, 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 ( return (
<div> <div style={pageContainerStyle}>
<Row gutter={16} style={{ marginBottom: 16 }}> <div style={headerStyle}>
<Col span={6}> <div style={titleRowStyle}>
<Card> <div style={titleStyle}>
<Statistic <BarChartOutlined style={{ color: designTokens.colors.primary.main }} />
title="耗材种类总数" 耗材统计
value={summary.total} </div>
prefix={<ShoppingCartOutlined style={{ color: '#1890ff' }} />} <Space wrap>
/> <Select
</Card> placeholder="选择分类"
</Col> allowClear
<Col span={6}> style={{ width: 140 }}
<Card> value={categoryFilter}
<Statistic onChange={setCategoryFilter}
title="低库存预警" >
value={summary.lowStock} {categories.map(cat => (
prefix={<WarningOutlined style={{ color: summary.lowStock > 0 ? '#ff4d4f' : '#52c41a' }} />} <Option key={cat} value={cat}>{cat}</Option>
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>
))} ))}
</Space> </Select>
</Card> <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> </Col>
</Row> </Row>
<Row gutter={16}> <Row gutter={designTokens.spacing.md}>
<Col span={12}> <Col span={12}>
<Card <div style={panelStyle}>
title={<span><ExclamationCircleOutlined style={{ color: '#ff4d4f', marginRight: 8 }} />低库存预警</span>} <div style={panelHeaderStyle}>
extra={<Tag color="red">{lowStockItems.length}</Tag>} <div style={panelTitleStyle}>
> <ExclamationCircleOutlined style={{ color: designTokens.colors.error.main }} />
<Table 低库存预警
columns={lowStockColumns} </div>
dataSource={lowStockItems} <Tag style={{
rowKey="consumableId" border: 'none',
pagination={false} borderRadius: designTokens.borderRadius.small,
size="small" background: `${designTokens.colors.error.main}15`,
scroll={{ x: 800 }} color: designTokens.colors.error.main,
/> fontWeight: '500'
</Card> }}>
{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>
<Col span={12}> <Col span={12}>
<Card title="最近出入库记录"> <div style={panelStyle}>
<Table <div style={panelHeaderStyle}>
columns={recentColumns} <div style={panelTitleStyle}>
dataSource={stats.recentRecords} <InboxOutlined style={{ color: designTokens.colors.text.secondary }} />
rowKey="recordId" 最近出入库记录
pagination={false} </div>
size="small" <Tag>最近</Tag>
scroll={{ x: 900 }} </div>
/> <div style={{ padding: 0 }}>
</Card> <Table
columns={recentColumns}
dataSource={stats.recentRecords}
rowKey="recordId"
pagination={false}
size="small"
scroll={{ x: 850 }}
loading={loading}
/>
</div>
</div>
</Col> </Col>
</Row> </Row>
</div> </div>
File diff suppressed because it is too large Load Diff
+276 -54
View File
@@ -1,9 +1,130 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Table, Button, Modal, Form, Input, Select, message, Card, Space, InputNumber, Switch } from 'antd'; import { Table, Button, Modal, Form, Input, Select, message, Card, Space, InputNumber, Switch, Tag, Statistic } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; import { PlusOutlined, EditOutlined, DeleteOutlined, AppstoreOutlined, FontSizeOutlined, NumberOutlined, CheckCircleOutlined, CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
import axios from 'axios'; 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() { function DeviceFieldManagement() {
const [fields, setFields] = useState([]); const [fields, setFields] = useState([]);
@@ -12,7 +133,6 @@ function DeviceFieldManagement() {
const [editingField, setEditingField] = useState(null); const [editingField, setEditingField] = useState(null);
const [form] = Form.useForm(); const [form] = Form.useForm();
// 获取所有字段配置
const fetchFields = async () => { const fetchFields = async () => {
try { try {
setLoading(true); setLoading(true);
@@ -30,11 +150,9 @@ function DeviceFieldManagement() {
fetchFields(); fetchFields();
}, []); }, []);
// 打开模态框
const showModal = (field = null) => { const showModal = (field = null) => {
setEditingField(field); setEditingField(field);
if (field) { if (field) {
// 将options对象转换为JSON字符串以便在TextArea中显示
const fieldData = { const fieldData = {
...field, ...field,
options: field.options ? JSON.stringify(field.options, null, 2) : '' options: field.options ? JSON.stringify(field.options, null, 2) : ''
@@ -46,27 +164,22 @@ function DeviceFieldManagement() {
setModalVisible(true); setModalVisible(true);
}; };
// 关闭模态框
const handleCancel = () => { const handleCancel = () => {
setModalVisible(false); setModalVisible(false);
setEditingField(null); setEditingField(null);
}; };
// 提交表单
const handleSubmit = async (values) => { const handleSubmit = async (values) => {
try { try {
// 处理选项配置,将JSON字符串转换为对象
const fieldData = { const fieldData = {
...values, ...values,
options: values.options ? JSON.parse(values.options) : null options: values.options ? JSON.parse(values.options) : null
}; };
if (editingField) { if (editingField) {
// 更新字段
await axios.put(`/api/deviceFields/${editingField.fieldId}`, fieldData); await axios.put(`/api/deviceFields/${editingField.fieldId}`, fieldData);
message.success('字段更新成功'); message.success('字段更新成功');
} else { } else {
// 创建字段
await axios.post('/api/deviceFields', fieldData); await axios.post('/api/deviceFields', fieldData);
message.success('字段创建成功'); message.success('字段创建成功');
} }
@@ -80,7 +193,6 @@ function DeviceFieldManagement() {
} }
}; };
// 删除字段
const handleDelete = async (fieldId) => { const handleDelete = async (fieldId) => {
Modal.confirm({ Modal.confirm({
title: '确认删除', 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 = [ const columns = [
{ {
title: '字段名称', title: '字段名称',
dataIndex: 'fieldName', dataIndex: 'fieldName',
key: 'fieldName', key: 'fieldName',
width: 150,
render: (text) => (
<span style={{ fontWeight: '500', color: designTokens.colors.text.primary }}>
{text}
</span>
)
}, },
{ {
title: '显示名称', title: '显示名称',
dataIndex: 'displayName', dataIndex: 'displayName',
key: 'displayName', key: 'displayName',
width: 120,
}, },
{ {
title: '字段类型', title: '字段类型',
dataIndex: 'fieldType', dataIndex: 'fieldType',
key: 'fieldType', key: 'fieldType',
width: 110,
render: (type) => { render: (type) => {
const typeMap = { const typeMap = {
string: '文本', string: { text: '文本', color: designTokens.colors.fieldType.string },
number: '数字', number: { text: '数字', color: designTokens.colors.fieldType.number },
boolean: '布尔值', boolean: { text: '布尔值', color: designTokens.colors.fieldType.boolean },
select: '下拉选择', select: { text: '下拉选择', color: designTokens.colors.fieldType.select },
date: '日期', date: { text: '日期', color: designTokens.colors.fieldType.date },
textarea: '多行文本' 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: '必填', title: '必填',
dataIndex: 'required', dataIndex: 'required',
key: 'required', key: 'required',
width: 80,
render: (required) => ( render: (required) => (
<Switch checked={required} disabled /> <span style={{
color: required ? designTokens.colors.success.main : designTokens.colors.text.tertiary,
fontWeight: '500'
}}>
{required ? '是' : '否'}
</span>
) )
}, },
{ {
title: '可见', title: '可见',
dataIndex: 'visible', dataIndex: 'visible',
key: 'visible', key: 'visible',
width: 80,
render: (visible) => ( render: (visible) => (
<Switch checked={visible} disabled /> <span style={{
color: visible ? designTokens.colors.primary.main : designTokens.colors.text.tertiary,
fontWeight: '500'
}}>
{visible ? '是' : '否'}
</span>
) )
}, },
{ {
title: '顺序', title: '顺序',
dataIndex: 'order', dataIndex: 'order',
key: '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: '操作', title: '操作',
key: 'action', key: 'action',
width: 160,
fixed: 'right',
render: (_, record) => ( render: (_, record) => (
<Space size="middle"> <Space size="small">
<Button type="primary" icon={<EditOutlined />} onClick={() => showModal(record)} size="small"> <Button
type="text"
icon={<EditOutlined />}
onClick={() => showModal(record)}
style={{
color: designTokens.colors.primary.main,
height: '28px',
padding: '0 8px'
}}
>
编辑 编辑
</Button> </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> </Button>
</Space> </Space>
@@ -167,27 +356,55 @@ function DeviceFieldManagement() {
]; ];
return ( return (
<div> <div style={pageContainerStyle}>
<Card title="设备字段管理" extra={ <div style={titleRowStyle}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}> <div style={titleStyle}>
<AppstoreOutlined style={{ color: designTokens.colors.primary.main }} />
设备字段管理
</div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => showModal()}
style={primaryActionStyle}
>
添加字段 添加字段
</Button> </Button>
}> </div>
<div style={tableCardStyle}>
<Table <Table
columns={columns} columns={columns}
dataSource={fields} dataSource={fields}
rowKey="fieldId" rowKey="fieldId"
loading={loading} 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 <Modal
title={editingField ? '编辑字段' : '添加字段'} title={
<span style={{ fontWeight: '600' }}>
{editingField ? '编辑字段' : '添加字段'}
</span>
}
open={modalVisible} open={modalVisible}
onCancel={handleCancel} onCancel={handleCancel}
footer={null} footer={null}
width={600} width={600}
styles={{
body: { padding: designTokens.spacing.lg }
}}
style={{
borderRadius: designTokens.borderRadius.large
}}
> >
<Form <Form
form={form} form={form}
@@ -196,7 +413,7 @@ function DeviceFieldManagement() {
> >
<Form.Item <Form.Item
name="fieldName" name="fieldName"
label="字段名称" label={<span style={{ fontWeight: '500' }}>字段名称</span>}
rules={[{ required: true, message: '请输入字段名称' }]} rules={[{ required: true, message: '请输入字段名称' }]}
> >
<Input placeholder="请输入字段名称(英文,如:deviceId)" /> <Input placeholder="请输入字段名称(英文,如:deviceId)" />
@@ -204,7 +421,7 @@ function DeviceFieldManagement() {
<Form.Item <Form.Item
name="displayName" name="displayName"
label="显示名称" label={<span style={{ fontWeight: '500' }}>显示名称</span>}
rules={[{ required: true, message: '请输入显示名称' }]} rules={[{ required: true, message: '请输入显示名称' }]}
> >
<Input placeholder="请输入显示名称(中文,如:设备ID)" /> <Input placeholder="请输入显示名称(中文,如:设备ID)" />
@@ -212,7 +429,7 @@ function DeviceFieldManagement() {
<Form.Item <Form.Item
name="fieldType" name="fieldType"
label="字段类型" label={<span style={{ fontWeight: '500' }}>字段类型</span>}
rules={[{ required: true, message: '请选择字段类型' }]} rules={[{ required: true, message: '请选择字段类型' }]}
> >
<Select placeholder="请选择字段类型"> <Select placeholder="请选择字段类型">
@@ -225,23 +442,29 @@ function DeviceFieldManagement() {
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item <div style={{ display: 'flex', gap: designTokens.spacing.md }}>
name="required" <Form.Item
label="必填" name="required"
> label={<span style={{ fontWeight: '500' }}>必填</span>}
<Switch /> valuePropName="checked"
</Form.Item> style={{ flex: 1 }}
>
<Switch />
</Form.Item>
<Form.Item <Form.Item
name="visible" name="visible"
label="可见" label={<span style={{ fontWeight: '500' }}>可见</span>}
> valuePropName="checked"
<Switch defaultChecked /> style={{ flex: 1 }}
</Form.Item> >
<Switch defaultChecked />
</Form.Item>
</div>
<Form.Item <Form.Item
name="order" name="order"
label="显示顺序" label={<span style={{ fontWeight: '500' }}>显示顺序</span>}
rules={[{ required: true, message: '请输入显示顺序' }]} rules={[{ required: true, message: '请输入显示顺序' }]}
> >
<InputNumber placeholder="请输入显示顺序" min={0} style={{ width: '100%' }} /> <InputNumber placeholder="请输入显示顺序" min={0} style={{ width: '100%' }} />
@@ -249,21 +472,20 @@ function DeviceFieldManagement() {
<Form.Item <Form.Item
name="options" name="options"
label="选项配置(仅下拉选择类型,JSON格式)" label={<span style={{ fontWeight: '500' }}>选项配置JSON格式</span>}
tooltip="格式示例:[{value: 'option1', label: '选项1'}]使用单引号" tooltip="格式示例:[{value: 'option1', label: '选项1'}]仅下拉选择类型需要配置"
> >
<Input.TextArea <Input.TextArea
rows={3} rows={3}
placeholder="请输入JSON格式的选项配置,使用单引号" placeholder="请输入JSON格式的选项配置,使用单引号"
style={{ fontFamily: 'monospace' }}
/> />
</Form.Item> </Form.Item>
<Form.Item style={{ textAlign: 'right' }}> <Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space> <Space>
<Button onClick={handleCancel}>取消</Button> <Button onClick={handleCancel}>取消</Button>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">确定</Button>
确定
</Button>
</Space> </Space>
</Form.Item> </Form.Item>
</Form> </Form>
+427 -147
View File
@@ -7,6 +7,94 @@ import dayjs from 'dayjs';
const { Option } = Select; const { Option } = Select;
const { RangePicker } = DatePicker; 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 // 防抖 Hook
function useDebounce(value, delay) { function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value); const [debouncedValue, setDebouncedValue] = useState(value);
@@ -1161,68 +1249,184 @@ function DeviceManagement() {
message.success('字段配置已重置为默认值'); message.success('字段配置已重置为默认值');
}; };
const pageHeaderStyle = { const pageContainerStyle = {
marginBottom: '24px', minHeight: '100vh',
display: 'flex', background: designTokens.colors.background.secondary,
justifyContent: 'space-between', padding: designTokens.spacing.lg
alignItems: 'center',
flexWrap: 'wrap',
gap: '12px'
}; };
const titleStyle = { const headerStyle = {
fontSize: '24px', 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', fontWeight: '700',
margin: 0, margin: 0,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', color: designTokens.colors.text.primary,
WebkitBackgroundClip: 'text', lineHeight: 1.2
WebkitTextFillColor: 'transparent',
backgroundClip: 'text'
}; };
const cardStyle = { const pageSubtitleStyle = {
borderRadius: '16px', 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', border: 'none',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)', color: '#ffffff',
overflow: 'hidden' 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 = { const primaryButtonStyle = {
height: '40px', height: '40px',
borderRadius: '8px', borderRadius: designTokens.borderRadius.small,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', background: designTokens.colors.primary.gradient,
border: 'none', border: 'none',
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.35)', color: '#ffffff',
boxShadow: designTokens.shadows.small,
fontWeight: '500', fontWeight: '500',
transition: 'all 0.3s ease' display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center'
}; };
const secondaryButtonStyle = { const statsRowStyle = {
height: '40px', display: 'flex',
borderRadius: '8px', gap: designTokens.spacing.md,
border: '1px solid #e8e8e8', marginBottom: designTokens.spacing.lg,
transition: 'all 0.3s ease' flexWrap: 'wrap'
}; };
const searchCardStyle = { const statCardStyle = {
borderRadius: '12px', flex: 1,
border: '1px solid #f0f0f0', minWidth: '140px',
background: 'linear-gradient(180deg, #fafafa 0%, #ffffff 100%)', maxWidth: '200px',
marginBottom: '20px' 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 = { const modalHeaderStyle = {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '8px', gap: designTokens.spacing.sm,
fontSize: '18px', fontSize: '18px',
fontWeight: '600' fontWeight: '600'
}; };
return ( return (
<div style={{ padding: '24px' }}> <div style={pageContainerStyle}>
<style>{` <style>{`
/* 表格自适应换行样式 */
.device-table-wrapper { .device-table-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1255,9 +1459,12 @@ function DeviceManagement() {
white-space: normal !important; white-space: normal !important;
word-break: break-word !important; word-break: break-word !important;
font-size: 14px !important; font-size: 14px !important;
font-weight: 500 !important; font-weight: 600 !important;
line-height: 1.4 !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 { .device-table-wrapper .ant-table-tbody {
@@ -1270,7 +1477,8 @@ function DeviceManagement() {
word-break: break-word !important; word-break: break-word !important;
line-height: 1.6 !important; line-height: 1.6 !important;
max-width: 250px !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, .device-table-wrapper .ant-table-tbody > tr > td .ant-typography,
@@ -1284,29 +1492,26 @@ function DeviceManagement() {
word-break: break-word !important; word-break: break-word !important;
} }
/* 斑马纹样式 */
.device-table-wrapper .ant-table-row-even { .device-table-wrapper .ant-table-row-even {
background-color: #fafafa; background-color: ${designTokens.colors.background.primary};
} }
.device-table-wrapper .ant-table-row-odd { .device-table-wrapper .ant-table-row-odd {
background-color: #ffffff; background-color: ${designTokens.colors.background.secondary};
} }
.device-table-wrapper .ant-table-row-selected { .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 { .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 { .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 { .device-table-wrapper .ant-table-selection-column {
position: sticky !important; position: sticky !important;
left: 0 !important; left: 0 !important;
@@ -1314,20 +1519,35 @@ function DeviceManagement() {
background: inherit !important; background: inherit !important;
} }
/* 操作列样式 */
.device-table-wrapper .ant-table-tbody > tr > td:last-child { .device-table-wrapper .ant-table-tbody > tr > td:last-child {
min-width: 120px !important; min-width: 120px !important;
max-width: 150px !important; max-width: 150px !important;
} }
/* 分页器样式 */
.device-table-wrapper .ant-pagination { .device-table-wrapper .ant-pagination {
margin: 16px 0 !important; margin: 16px 0 !important;
flex-wrap: wrap !important; flex-wrap: wrap !important;
justify-content: center !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) { @media screen and (max-width: 768px) {
.device-table-wrapper .ant-table-tbody > tr > td { .device-table-wrapper .ant-table-tbody > tr > td {
max-width: 150px !important; max-width: 150px !important;
@@ -1338,97 +1558,133 @@ function DeviceManagement() {
font-size: 13px !important; 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> `}</style>
<div style={pageHeaderStyle}> <div style={headerStyle}>
<h1 style={titleStyle}> <div style={titleRowStyle}>
<CloudServerOutlined style={{ marginRight: '12px' }} /> <div style={titleSectionStyle}>
设备管理 <div style={titleIconStyle}>
</h1> <CloudServerOutlined style={{ fontSize: '20px', color: '#ffffff' }} />
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}> </div>
<Button <div style={titleTextStyle}>
style={primaryButtonStyle} <h1 style={pageTitleStyle}>设备管理</h1>
icon={<PlusOutlined />} <p style={pageSubtitleStyle}>管理您的IT设备资产</p>
onClick={() => showModal()} </div>
> </div>
添加设备
</Button> <div style={{ display: 'flex', gap: designTokens.spacing.sm, flexWrap: 'wrap' }}>
<Button <Button
style={secondaryButtonStyle} style={primaryActionStyle}
icon={<UploadOutlined />} icon={<PlusOutlined />}
onClick={() => setImportModalVisible(true)} onClick={() => showModal()}
> >
导入设备 添加设备
</Button> </Button>
<Button <Button
style={secondaryButtonStyle} style={secondaryActionStyle}
icon={<SettingOutlined />} icon={<UploadOutlined />}
onClick={() => setFieldConfigModalVisible(true)} onClick={() => setImportModalVisible(true)}
> >
字段配置 导入
</Button> </Button>
<Button <Button
style={secondaryButtonStyle} style={secondaryActionStyle}
icon={<UndoOutlined />} icon={<ExportOutlined />}
onClick={resetColumnWidths} onClick={showExportModal}
title="重置列宽" >
> 导出
重置列宽 </Button>
</Button> <Button
<Button style={secondaryActionStyle}
style={{ icon={<SettingOutlined />}
...secondaryButtonStyle, onClick={() => setFieldConfigModalVisible(true)}
color: selectedDevices.length > 0 ? '#52c41a' : undefined, >
borderColor: selectedDevices.length > 0 ? '#52c41a' : undefined 字段配置
}} </Button>
icon={<ReloadOutlined />} <Button
disabled={selectedDevices.length === 0} style={{
onClick={showBatchStatusModal} ...secondaryActionStyle,
> color: selectedDevices.length > 0 ? designTokens.colors.success.main : undefined,
状态变更 ({selectedDevices.length}) borderColor: selectedDevices.length > 0 ? designTokens.colors.success.main : undefined
</Button> }}
<Button icon={<ReloadOutlined />}
style={{ disabled={selectedDevices.length === 0}
...secondaryButtonStyle, onClick={showBatchStatusModal}
color: selectedDevices.length > 0 ? '#ff4d4f' : undefined, >
borderColor: selectedDevices.length > 0 ? '#ff4d4f' : undefined 状态变更 ({selectedDevices.length})
}} </Button>
danger <Button
icon={<DeleteOutlined />} danger
disabled={selectedDevices.length === 0} style={{
onClick={handleBatchDelete} ...secondaryActionStyle,
> color: selectedDevices.length > 0 ? designTokens.colors.error.main : undefined,
批量删除 ({selectedDevices.length}) borderColor: selectedDevices.length > 0 ? designTokens.colors.error.main : undefined
</Button> }}
icon={<DeleteOutlined />}
disabled={selectedDevices.length === 0}
onClick={handleBatchDelete}
>
批量删除 ({selectedDevices.length})
</Button>
</div>
</div> </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
form={searchForm} form={searchForm}
layout="inline" layout="inline"
onFinish={handleSearch} 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 <Input
placeholder="搜索设备ID、名称、类型、型号、序列号、IP、描述..." placeholder="搜索设备..."
prefix={<SearchOutlined style={{ color: '#667eea' }} />} prefix={<SearchOutlined style={{ color: designTokens.colors.primary.main }} />}
style={{ style={{
width: 320, width: '280px',
borderRadius: '8px', borderRadius: designTokens.borderRadius.medium,
border: '1px solid #d9d9d9' border: `1px solid ${designTokens.colors.border.light}`,
transition: `all ${designTokens.transitions.fast}`
}} }}
value={keyword} value={keyword}
onChange={(e) => setKeyword(e.target.value)} onChange={(e) => setKeyword(e.target.value)}
/> />
</Form.Item> </Form.Item>
<Form.Item name="status"> <Form.Item name="status" style={{ margin: 0 }}>
<Select <Select
value={status} value={status}
onChange={setStatus} 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="all">所有状态</Option>
<Option value="running">运行中</Option> <Option value="running">运行中</Option>
@@ -1438,11 +1694,12 @@ function DeviceManagement() {
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item name="type"> <Form.Item name="type" style={{ margin: 0 }}>
<Select <Select
value={type} value={type}
onChange={setType} 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="all">所有类型</Option>
<Option value="server">服务器</Option> <Option value="server">服务器</Option>
@@ -1453,37 +1710,50 @@ function DeviceManagement() {
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item> <Form.Item style={{ margin: 0 }}>
<Space> <Space>
<Button <Button
type="primary" type="primary"
htmlType="submit" style={{
height: '36px',
borderRadius: designTokens.borderRadius.medium,
background: designTokens.colors.primary.gradient,
border: 'none',
boxShadow: designTokens.shadows.small
}}
icon={<SearchOutlined />} icon={<SearchOutlined />}
style={primaryButtonStyle} htmlType="submit"
loading={searching}
> >
搜索 搜索
</Button> </Button>
<Button <Button
style={{
height: '36px',
borderRadius: designTokens.borderRadius.medium,
border: `1px solid ${designTokens.colors.border.light}`
}}
onClick={handleReset} onClick={handleReset}
style={secondaryButtonStyle}
> >
重置 重置
</Button> </Button>
</Space> </Space>
</Form.Item> </Form.Item>
</Form> </Form>
<Button
style={{ <div style={{ display: 'flex', alignItems: 'center', gap: designTokens.spacing.sm }}>
...secondaryButtonStyle, <Button
color: '#fa8c16', icon={<ReloadOutlined />}
borderColor: '#fa8c16', onClick={() => fetchDevices(1, pagination.pageSize, true)}
whiteSpace: 'nowrap' style={{
}} borderRadius: designTokens.borderRadius.medium,
icon={<ExportOutlined />} border: `1px solid ${designTokens.colors.border.light}`,
onClick={showExportModal} height: '36px'
> }}
增强导出 >
</Button> 刷新
</Button>
</div>
</Card> </Card>
<Card style={cardStyle}> <Card style={cardStyle}>
@@ -1491,21 +1761,31 @@ function DeviceManagement() {
<div style={{ <div style={{
textAlign: 'center', textAlign: 'center',
padding: '60px 20px', padding: '60px 20px',
color: '#999', color: designTokens.colors.text.secondary,
fontSize: '15px' fontSize: '15px'
}}> }}>
<SearchOutlined style={{ fontSize: '48px', marginBottom: '16px', color: '#d9d9d9' }} /> <SearchOutlined style={{ fontSize: '48px', marginBottom: '16px', color: designTokens.colors.border.light }} />
<p>暂无设备数据</p> <p>暂无设备数据</p>
</div> </div>
)} )}
{filteredDevicesMemo.length > 0 && ( {filteredDevicesMemo.length > 0 && (
<div className="device-table-wrapper"> <div className="device-table-wrapper" style={{
borderRadius: designTokens.borderRadius.medium,
overflow: 'hidden'
}}>
<Table <Table
columns={columns} columns={columns}
dataSource={filteredDevicesMemo} dataSource={filteredDevicesMemo}
rowKey="deviceId" rowKey="deviceId"
loading={loading || searching} 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} onChange={handleTableChange}
scroll={{ y: 'calc(100vh - 380px)', scrollToFirstRowOnChange: true }} scroll={{ y: 'calc(100vh - 380px)', scrollToFirstRowOnChange: true }}
virtual virtual
@@ -1640,7 +1920,7 @@ function DeviceManagement() {
<Form.Item style={{ textAlign: 'right', marginTop: '24px' }}> <Form.Item style={{ textAlign: 'right', marginTop: '24px' }}>
<Space> <Space>
<Button onClick={handleCancel} style={secondaryButtonStyle}>取消</Button> <Button onClick={handleCancel} style={secondaryActionStyle}>取消</Button>
<Button type="primary" htmlType="submit" style={primaryButtonStyle}>确定</Button> <Button type="primary" htmlType="submit" style={primaryButtonStyle}>确定</Button>
</Space> </Space>
</Form.Item> </Form.Item>
@@ -1687,8 +1967,8 @@ function DeviceManagement() {
<Form.Item style={{ textAlign: 'right', marginTop: '20px' }}> <Form.Item style={{ textAlign: 'right', marginTop: '20px' }}>
<Space> <Space>
<Button onClick={() => setFieldConfigModalVisible(false)} style={secondaryButtonStyle}>取消</Button> <Button onClick={() => setFieldConfigModalVisible(false)} style={secondaryActionStyle}>取消</Button>
<Button onClick={handleResetFieldConfig} style={secondaryButtonStyle}>重置默认</Button> <Button onClick={handleResetFieldConfig} style={secondaryActionStyle}>重置默认</Button>
<Button type="primary" htmlType="submit" style={primaryButtonStyle}>保存</Button> <Button type="primary" htmlType="submit" style={primaryButtonStyle}>保存</Button>
</Space> </Space>
</Form.Item> </Form.Item>
@@ -1733,7 +2013,7 @@ function DeviceManagement() {
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<a href="/api/devices/import-template" download="设备导入模板.csv"> <a href="/api/devices/import-template" download="设备导入模板.csv">
<Button icon={<DownloadOutlined />} style={secondaryButtonStyle}> <Button icon={<DownloadOutlined />} style={secondaryActionStyle}>
下载导入模板 下载导入模板
</Button> </Button>
</a> </a>
@@ -1861,7 +2141,7 @@ function DeviceManagement() {
<Button key="close" onClick={() => { <Button key="close" onClick={() => {
setDetailModalVisible(false); setDetailModalVisible(false);
setSelectedDevice(null); setSelectedDevice(null);
}} style={secondaryButtonStyle}> }} style={secondaryActionStyle}>
关闭 关闭
</Button>, </Button>,
<Button key="edit" type="primary" onClick={() => { <Button key="edit" type="primary" onClick={() => {
@@ -1989,7 +2269,7 @@ function DeviceManagement() {
open={batchStatusModalVisible} open={batchStatusModalVisible}
onCancel={() => setBatchStatusModalVisible(false)} onCancel={() => setBatchStatusModalVisible(false)}
footer={[ footer={[
<Button key="cancel" onClick={() => setBatchStatusModalVisible(false)} style={secondaryButtonStyle}> <Button key="cancel" onClick={() => setBatchStatusModalVisible(false)} style={secondaryActionStyle}>
取消 取消
</Button>, </Button>,
<Button key="submit" type="primary" loading={batchStatusLoading} onClick={handleBatchStatusChange} style={primaryButtonStyle}> <Button key="submit" type="primary" loading={batchStatusLoading} onClick={handleBatchStatusChange} style={primaryButtonStyle}>
@@ -2028,7 +2308,7 @@ function DeviceManagement() {
open={exportModalVisible} open={exportModalVisible}
onCancel={() => setExportModalVisible(false)} onCancel={() => setExportModalVisible(false)}
footer={[ footer={[
<Button key="cancel" onClick={() => setExportModalVisible(false)} style={secondaryButtonStyle}> <Button key="cancel" onClick={() => setExportModalVisible(false)} style={secondaryActionStyle}>
取消 取消
</Button>, </Button>,
<Button key="submit" type="primary" loading={exportLoading} onClick={handleEnhancedExport} style={primaryButtonStyle}> <Button key="submit" type="primary" loading={exportLoading} onClick={handleEnhancedExport} style={primaryButtonStyle}>
File diff suppressed because it is too large Load Diff
+332 -130
View File
@@ -1,26 +1,174 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; 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 { Card, Select, Button, Space, message, Tooltip, Modal, Form, Switch, Checkbox, Input, Badge, Typography, Row, Col, Empty, Spin } from 'antd';
import { import {
ReloadOutlined, ReloadOutlined, ZoomInOutlined, ZoomOutOutlined,
ZoomInOutlined, RotateRightOutlined, CloudServerOutlined, SwitcherOutlined,
ZoomOutOutlined, DatabaseOutlined, CloudOutlined, LaptopOutlined,
RotateRightOutlined, MobileOutlined, PrinterOutlined, SettingOutlined,
CloudServerOutlined, SearchOutlined, ClearOutlined, EnvironmentOutlined,
SwitcherOutlined, FilterOutlined, AppstoreOutlined, UnorderedListOutlined,
DatabaseOutlined, FullscreenOutlined, CompressOutlined, EyeOutlined
CloudOutlined,
LaptopOutlined,
MobileOutlined,
PrinterOutlined,
SettingOutlined,
SearchOutlined,
ClearOutlined,
EnvironmentOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import axios from 'axios'; import axios from 'axios';
const { Option } = Select; 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) => { const getDeviceIcon = (deviceType) => {
@@ -339,6 +487,30 @@ const initAnimationStyles = () => {
white-space: nowrap; white-space: nowrap;
font-family: 'JetBrains Mono', 'Roboto Mono', monospace; 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); document.head.appendChild(style);
return style; return style;
@@ -398,56 +570,6 @@ class ErrorBoundary extends React.Component {
} }
function RackVisualization() { 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 [racks, setRacks] = useState([]);
const [selectedRack, setSelectedRack] = useState(null); const [selectedRack, setSelectedRack] = useState(null);
const [selectedRoom, setSelectedRoom] = useState(null); const [selectedRoom, setSelectedRoom] = useState(null);
@@ -1166,24 +1288,69 @@ function RackVisualization() {
// 无需额外的初始化,CSS 3D变换直接在JSX中实现 // 无需额外的初始化,CSS 3D变换直接在JSX中实现
return ( return (
<div style={{ padding: '24px' }}> <div style={pageContainerStyle}>
<div style={pageHeaderStyle}> <div style={headerStyle}>
<h1 style={titleStyle}> <div style={{
<CloudServerOutlined style={{ marginRight: '12px' }} /> display: 'flex',
机柜可视化 justifyContent: 'space-between',
</h1> alignItems: 'center',
<Space size={12} wrap> 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 <Input
placeholder="搜索设备名称、ID、IP..." placeholder="搜索设备名称、ID、IP..."
value={searchKeyword} value={searchKeyword}
onChange={(e) => handleSearch(e.target.value)} onChange={(e) => handleSearch(e.target.value)}
onPressEnter={(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 allowClear
prefix={<SearchOutlined />} prefix={<SearchOutlined />}
suffix={ suffix={
searchMatchCount > 0 ? ( searchMatchCount > 0 ? (
<Badge count={searchMatchCount} size="small" style={{ backgroundColor: '#52c41a' }} /> <Badge count={searchMatchCount} size="small" style={{ backgroundColor: designTokens.colors.success.main }} />
) : null ) : null
} }
/> />
@@ -1197,7 +1364,7 @@ function RackVisualization() {
)} )}
<Select <Select
placeholder="选择机房" placeholder="选择机房"
style={{ width: 180, height: '40px' }} style={{ width: 180, height: '40px', borderRadius: designTokens.borderRadius.medium }}
value={selectedRoom} value={selectedRoom}
onChange={handleRoomChange} onChange={handleRoomChange}
loading={loading} loading={loading}
@@ -1212,7 +1379,7 @@ function RackVisualization() {
</Select> </Select>
<Select <Select
placeholder="选择机柜" placeholder="选择机柜"
style={{ width: 200, height: '40px' }} style={{ width: 200, height: '40px', borderRadius: designTokens.borderRadius.medium }}
value={selectedRack?.rackId} value={selectedRack?.rackId}
onChange={handleRackChange} onChange={handleRackChange}
loading={loading || loadingDevices} loading={loading || loadingDevices}
@@ -1242,31 +1409,35 @@ function RackVisualization() {
> >
字段配置 字段配置
</Button> </Button>
</Space> </div>
</div> </Card>
{searchKeyword && ( {searchKeyword && (
<Card size="small" style={searchCardStyle} styles={{ body: { padding: '16px 20px' } }}> <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={{ <div style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '12px', gap: designTokens.spacing.md,
flexWrap: 'wrap' flexWrap: 'wrap'
}}> }}>
<SearchOutlined style={{ <SearchOutlined style={{
fontSize: 18, fontSize: 20,
color: searchResults.length > 0 ? '#52c41a' : '#ff4d4f' color: '#fff'
}} /> }} />
<Text strong style={{ color: searchResults.length > 0 ? '#135200' : '#cf1322', margin: 0 }}> <Text strong style={{ color: '#fff', margin: 0, fontSize: '14px' }}>
{searchResults.length > 0 {searchResults.length > 0
? `找到 ${searchResults.length} 个设备` ? `找到 ${searchResults.length} 个设备`
: searching ? '搜索中...' : '未找到匹配的设备'} : searching ? '搜索中...' : '未找到匹配的设备'}
</Text> </Text>
{searching && ( {searching && (
<Badge status="processing" text="正在搜索所有机柜..." /> <Badge status="processing" text="正在搜索所有机柜..." style={{ color: '#fff' }} />
)} )}
{searchResults.length > 0 && ( {searchResults.length > 0 && (
<Space wrap size={4}> <Space wrap size={designTokens.spacing.sm}>
{searchResults.slice(0, 5).map(result => { {searchResults.slice(0, 5).map(result => {
const isCurrentRack = selectedRack && result.rackId === selectedRack.rackId; const isCurrentRack = selectedRack && result.rackId === selectedRack.rackId;
return ( return (
@@ -1293,10 +1464,11 @@ function RackVisualization() {
setHighlightedDevice(result.deviceId); setHighlightedDevice(result.deviceId);
}} }}
style={{ style={{
borderColor: highlightedDevice === result.deviceId ? '#667eea' : undefined, borderColor: 'rgba(255,255,255,0.5)',
background: highlightedDevice === result.deviceId ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : undefined, background: highlightedDevice === result.deviceId ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.1)',
color: '#fff',
height: '32px', height: '32px',
borderRadius: '6px' borderRadius: designTokens.borderRadius.small
}} }}
> >
<Tooltip title={`机柜: ${result.rackName}${result.roomName ? ` | 机房: ${result.roomName}` : ''} | U${result.position || '?'}`}> <Tooltip title={`机柜: ${result.rackName}${result.roomName ? ` | 机房: ${result.roomName}` : ''} | U${result.position || '?'}`}>
@@ -1306,7 +1478,7 @@ function RackVisualization() {
U{result.position || '?'} U{result.position || '?'}
</Text> </Text>
{!isCurrentRack && ( {!isCurrentRack && (
<EnvironmentOutlined style={{ marginLeft: 4, color: '#faad14' }} /> <EnvironmentOutlined style={{ marginLeft: 4, color: '#fbbf24' }} />
)} )}
</span> </span>
</Tooltip> </Tooltip>
@@ -1314,7 +1486,7 @@ function RackVisualization() {
); );
})} })}
{searchResults.length > 5 && ( {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> </Space>
)} )}
@@ -1322,15 +1494,15 @@ function RackVisualization() {
</Card> </Card>
)} )}
<Card style={cardStyle} styles={{ body: { padding: '16px 20px' } }}> <Card style={cardStyle} styles={{ body: { padding: `${designTokens.spacing.md}px ${designTokens.spacing.lg}px` } }}>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: designTokens.spacing.md }}>
<Space wrap size={12}> <Space wrap size={designTokens.spacing.sm}>
<Button style={secondaryButtonStyle} icon={<ZoomInOutlined />} onClick={handleZoomIn}>放大</Button> <Button style={secondaryButtonStyle} icon={<ZoomInOutlined />} onClick={handleZoomIn}>放大</Button>
<Button style={secondaryButtonStyle} icon={<ZoomOutOutlined />} onClick={handleZoomOut}>缩小</Button> <Button style={secondaryButtonStyle} icon={<ZoomOutOutlined />} onClick={handleZoomOut}>缩小</Button>
<Button style={secondaryButtonStyle} icon={<RotateRightOutlined />} onClick={handleResetView}>重置视角</Button> <Button style={secondaryButtonStyle} icon={<RotateRightOutlined />} onClick={handleResetView}>重置视角</Button>
<Select <Select
placeholder="选择背景类型" placeholder="选择背景类型"
style={{ width: 150, height: '40px' }} style={{ width: 150, height: '40px', borderRadius: designTokens.borderRadius.medium }}
value={backgroundType} value={backgroundType}
onChange={async (type) => { onChange={async (type) => {
setBackgroundType(type); setBackgroundType(type);
@@ -1341,11 +1513,18 @@ function RackVisualization() {
<Option value="image">自定义图片</Option> <Option value="image">自定义图片</Option>
</Select> </Select>
{backgroundType === 'image' && ( {backgroundType === 'image' && (
<Space size={8}> <Space size={designTokens.spacing.sm}>
<input <input
type="text" type="text"
placeholder="输入图片URL" 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) => { onChange={async (e) => {
const image = e.target.value; const image = e.target.value;
setBackgroundImage(image); setBackgroundImage(image);
@@ -1392,7 +1571,7 @@ function RackVisualization() {
<Button style={secondaryButtonStyle} onClick={() => document.getElementById('backgroundFileInput').click()} loading={uploading}>上传图片</Button> <Button style={secondaryButtonStyle} onClick={() => document.getElementById('backgroundFileInput').click()} loading={uploading}>上传图片</Button>
<Select <Select
placeholder="图片大小" placeholder="图片大小"
style={{ width: 100, height: '40px' }} style={{ width: 100, height: '40px', borderRadius: designTokens.borderRadius.medium }}
value={backgroundSize} value={backgroundSize}
onChange={async (size) => { onChange={async (size) => {
setBackgroundSize(size); setBackgroundSize(size);
@@ -2219,40 +2398,50 @@ function RackVisualization() {
{/* 设备详情字段配置模态框 */} {/* 设备详情字段配置模态框 */}
<Modal <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} open={showTooltipConfig}
onCancel={() => setShowTooltipConfig(false)} onCancel={() => setShowTooltipConfig(false)}
footer={null} footer={null}
width={500} width={520}
styles={{ body: { padding: designTokens.spacing.lg } }}
> >
{loadingTooltipFields ? ( {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> </div>
) : Object.keys(tooltipFields).length === 0 ? ( ) : 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> <div>
<div style={{ maxHeight: 400, overflowY: 'auto' }}> <div style={{ maxHeight: 360, overflowY: 'auto' }}>
<div style={{ color: '#64748b', fontSize: '12px', marginBottom: '12px' }}> <div style={{ color: designTokens.colors.text.secondary, fontSize: '13px', marginBottom: designTokens.spacing.md }}>
选择要在设备详情中显示的字段 ({Object.keys(tooltipFields).length} 个字段) 选择要在设备详情中显示的字段 ({Object.keys(tooltipFields).length} 个字段)
</div> </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]) => ( {Object.entries(tooltipFields).map(([key, field]) => (
<div <div
key={key} key={key}
style={{ style={{
padding: '8px 12px', padding: `${designTokens.spacing.sm}px ${designTokens.spacing.md}px`,
borderRadius: '6px', borderRadius: designTokens.borderRadius.medium,
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '8px', gap: designTokens.spacing.sm,
background: field.enabled ? 'rgba(56, 189, 248, 0.1)' : 'transparent', background: field.enabled ? `${designTokens.colors.primary.main}10` : 'transparent',
border: field.enabled ? '1px solid rgba(56, 189, 248, 0.3)' : '1px solid transparent', border: field.enabled ? `1px solid ${designTokens.colors.primary.main}30` : `1px solid ${designTokens.colors.border.light}`,
transition: 'all 0.2s ease' transition: `all ${designTokens.transitions.fast}`
}} }}
onClick={() => { onClick={() => {
setTooltipFields(prev => ({ setTooltipFields(prev => ({
@@ -2262,32 +2451,45 @@ function RackVisualization() {
}} }}
> >
<Checkbox checked={field.enabled} /> <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>
</div> </div>
<div style={{ marginTop: '20px', textAlign: 'right' }}> <div style={{ marginTop: designTokens.spacing.lg, textAlign: 'right' }}>
<Space> <Space>
<Button onClick={() => { <Button
const allEnabled = Object.values(tooltipFields).every(f => f.enabled); onClick={() => {
setTooltipFields(prev => { const allEnabled = Object.values(tooltipFields).every(f => f.enabled);
const newFields = { ...prev }; setTooltipFields(prev => {
Object.keys(newFields).forEach(key => { const newFields = { ...prev };
newFields[key] = { ...newFields[key], enabled: !allEnabled }; 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>
<Button onClick={() => setShowTooltipConfig(false)}> <Button
onClick={() => setShowTooltipConfig(false)}
style={{ borderRadius: designTokens.borderRadius.medium }}
>
取消 取消
</Button> </Button>
<Button type="primary" onClick={async () => { <Button
await saveTooltipConfig(); type="primary"
}} loading={savingTooltipConfig}> onClick={async () => {
await saveTooltipConfig();
}}
loading={savingTooltipConfig}
style={primaryButtonStyle}
>
保存 保存
</Button> </Button>
</Space> </Space>
File diff suppressed because it is too large Load Diff