Compare commits

...

2 Commits

Author SHA1 Message Date
mula.liu 3665022575 v 1.0.0 2025-11-05 14:30:39 +08:00
mula.liu c163adf178 0.1.1 2025-11-05 14:30:39 +08:00
65 changed files with 22476 additions and 0 deletions

21
.eslintrc.cjs 100644
View File

@ -0,0 +1,21 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
settings: { react: { version: '18.2' } },
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'react/prop-types': 'off',
},
}

39
.gitignore vendored 100644
View File

@ -0,0 +1,39 @@
# 依赖文件
node_modules/
.pnp
.pnp.js
# 构建产物
dist/
build/
*.local
# 日志
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# 编辑器
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# 环境变量
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# 临时文件
.cache
.temp
*.tmp

146
README.md 100644
View File

@ -0,0 +1,146 @@
# Nex Design
> 面向 React + Ant Design + Tailwind CSS 的前端设计语言规范
## 项目简介
Nex Design 是为开发团队打造的一套标准化设计语言系统,提供统一、高效、易维护的前端设计规范和页面模板。
## 技术栈
- **框架**: React 18+
- **构建工具**: Vite 5.x
- **组件库**: Ant Design 5.x
- **样式方案**: Tailwind CSS 3.x
- **路由**: React Router v6
- **包管理**: Yarn
- **运行时**: Node.js 16+
## 快速开始
### 环境要求
- Node.js >= 16.0.0
- Yarn >= 1.22.0
### 安装依赖
```bash
yarn install
```
### 开发模式
```bash
yarn dev
```
访问 http://localhost:5173 查看项目
### 构建生产版本
```bash
yarn build
```
### 预览生产版本
```bash
yarn preview
```
## 项目结构
```
Nex Design/
├── docs/ # 文档目录
│ └── pages/ # 页面设计文档
├── public/ # 静态资源
├── src/ # 源代码
│ ├── assets/ # 资源文件(图片、字体等)
│ ├── components/ # 公共组件
│ ├── pages/ # 页面组件
│ ├── styles/ # 全局样式
│ ├── utils/ # 工具函数
│ └── constants/ # 常量定义
├── DESIGN_GUIDE.md # 设计规范指南
├── package.json # 项目配置
└── README.md # 项目说明
```
## 设计规范
详细的设计规范请查看 [DESIGN_GUIDE.md](./DESIGN_GUIDE.md),包含:
- 设计原则
- 颜色系统
- 排版规范
- 间距系统
- 组件规范
- 布局规范
- 交互规范
- 响应式设计
- 页面模板
## 页面模板
项目将提供以下标准页面模板:
- [ ] 首页 (Dashboard) - 数据概览、快捷入口
- [ ] 主框架页面 - 导航布局、侧边栏、顶部栏
- [ ] 列表页 - 数据表格、筛选、操作
- [ ] 详情页 - 信息展示、关联数据
- [ ] 表单页 - 数据录入、验证、提交
- [ ] 设置页 - 配置管理、偏好设置
每个模板都会提供完整的设计规范说明和代码实现。
## 开发规范
### 代码风格
- 使用 ESLint 进行代码检查
- 组件命名使用大驼峰 (PascalCase)
- 文件命名与组件同名
- 优先使用函数式组件和 Hooks
### CSS 使用规范
1. 优先使用 Tailwind CSS 工具类
2. 通过 Ant Design 主题配置进行定制
3. 仅在必要时编写自定义样式
### 组件开发规范
- 保持组件的单一职责
- 合理拆分组件,提高复用性
- 使用 PropTypes 或 TypeScript 进行类型检查
- 编写必要的注释和文档
## 参与贡献
1. Fork 本仓库
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 开启 Pull Request
## 版本历史
- **v1.0.0** (2024-11-04)
- 初始化项目
- 创建基础设计规范文档
- 建立项目结构
## 许可证
MIT License
## 联系方式
如有问题或建议,请联系开发团队。
---
**维护团队**: Nex Design Team
**最后更新**: 2024-11-04

View File

@ -0,0 +1,607 @@
# Nex Design 设计规范指南
> 面向 React + Ant Design + Tailwind CSS 的前端设计语言规范
## 目录
- [概述](#概述)
- [设计原则](#设计原则)
- [颜色系统](#颜色系统)
- [排版规范](#排版规范)
- [间距系统](#间距系统)
- [组件规范](#组件规范)
- [布局规范](#布局规范)
- [交互规范](#交互规范)
- [响应式设计](#响应式设计)
- [页面模板](#页面模板)
---
## 概述
Nex Design 是为开发团队打造的一套标准化设计语言系统,旨在提供统一、高效、易维护的前端设计规范。
### 技术栈
- **框架**: React 18+
- **组件库**: Ant Design 5.x
- **样式方案**: Tailwind CSS 3.x
- **包管理**: Yarn
- **运行时**: Node.js 16+
### 设计目标
1. **一致性**: 确保所有页面和组件保持视觉与交互的一致性
2. **高效性**: 提供可复用的设计模式,加快开发速度
3. **可维护性**: 建立清晰的规范体系,降低维护成本
4. **用户体验**: 优先考虑用户体验,打造直观易用的界面
---
## 设计原则
### 1. 清晰明确
界面设计应当清晰直观,避免歧义,让用户能够快速理解和操作。
### 2. 一致性优先
保持视觉、交互、用词的一致性,减少用户学习成本。
### 3. 效率至上
优化操作流程,减少用户的操作步骤,提高工作效率。
### 4. 反馈及时
对用户的每一个操作都应给予明确的反馈,包括成功、失败、加载等状态。
### 5. 容错友好
预防用户错误,在错误发生时提供清晰的提示和解决方案。
---
## 颜色系统
### 主色调
```css
/* 品牌主色 - Primary (基于 NEX Logo 紫红色) */
--primary-50: #fce7f6;
--primary-100: #f5bae6;
--primary-200: #ee8dd6;
--primary-300: #e760c6;
--primary-400: #e033b6;
--primary-500: #b8178d; /* 主色 - 匹配 NEX Logo */
--primary-600: #9c1477;
--primary-700: #801161;
--primary-800: #640d4b;
--primary-900: #480a35;
```
### 辅助色系
```css
/* 蓝色 - Blue (用于信息提示) */
--blue-50: #e6f4ff;
--blue-100: #bae0ff;
--blue-200: #91caff;
--blue-300: #69b1ff;
--blue-400: #4096ff;
--blue-500: #1677ff; /* 信息色 */
--blue-600: #0958d9;
--blue-700: #003eb3;
--blue-800: #002c8c;
--blue-900: #001d66;
```
### 功能色
```css
/* 成功 - Success */
--success: #52c41a;
--success-bg: #f6ffed;
--success-border: #b7eb8f;
/* 警告 - Warning */
--warning: #faad14;
--warning-bg: #fffbe6;
--warning-border: #ffe58f;
/* 错误 - Error */
--error: #ff4d4f;
--error-bg: #fff2f0;
--error-border: #ffccc7;
/* 信息 - Info */
--info: #1677ff;
--info-bg: #e6f4ff;
--info-border: #91caff;
```
### 中性色
```css
/* 文本颜色 */
--text-primary: rgba(0, 0, 0, 0.88); /* 主要文本 */
--text-secondary: rgba(0, 0, 0, 0.65); /* 次要文本 */
--text-tertiary: rgba(0, 0, 0, 0.45); /* 辅助文本 */
--text-disabled: rgba(0, 0, 0, 0.25); /* 禁用文本 */
/* 背景颜色 */
--bg-primary: #ffffff;
--bg-secondary: #fafafa;
--bg-tertiary: #f5f5f5;
--bg-disabled: #f0f0f0;
/* 边框颜色 */
--border-primary: #d9d9d9;
--border-secondary: #f0f0f0;
```
### 颜色使用规范
1. **主色使用**: 主要用于关键操作按钮、重要信息高亮、链接等
2. **功能色使用**: 严格按照语义使用,不可混淆
3. **中性色使用**: 用于文本、背景、边框等基础元素
4. **对比度**: 确保文本与背景的对比度符合 WCAG 2.0 标准(至少 4.5:1
---
## 排版规范
### 字体家族
```css
/* 默认字体栈 */
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
/* 等宽字体(代码、数字) */
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code',
'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', monospace;
```
### 字号规范
| 用途 | 字号 | 行高 | Tailwind Class | 使用场景 |
|------|------|------|----------------|----------|
| 特大标题 | 32px | 1.35 | `text-4xl` | 页面主标题 |
| 大标题 | 24px | 1.35 | `text-2xl` | 区块标题 |
| 中标题 | 20px | 1.4 | `text-xl` | 卡片标题 |
| 小标题 | 16px | 1.5 | `text-base` | 表单标签 |
| 正文 | 14px | 1.5714 | `text-sm` | 正文内容 |
| 辅助文字 | 12px | 1.6667 | `text-xs` | 说明文字 |
### 字重规范
- **Regular (400)**: 正文内容
- **Medium (500)**: 表单标签、列表项
- **Semibold (600)**: 小标题、强调文本
- **Bold (700)**: 标题、重要信息
### 文本颜色使用
```jsx
// 主要文本
<p className="text-gray-900">主要内容</p>
// 次要文本
<p className="text-gray-600">次要内容</p>
// 辅助文本
<p className="text-gray-400">辅助说明</p>
// 禁用文本
<p className="text-gray-300">禁用状态</p>
```
---
## 间距系统
### 基础间距单位
基于 **8px** 网格系统,所有间距应为 8 的倍数。
| 名称 | 数值 | Tailwind Class | 用途 |
|------|------|----------------|------|
| xs | 4px | `space-1` / `p-1` / `m-1` | 紧凑间距 |
| sm | 8px | `space-2` / `p-2` / `m-2` | 小间距 |
| md | 16px | `space-4` / `p-4` / `m-4` | 标准间距 |
| lg | 24px | `space-6` / `p-6` / `m-6` | 大间距 |
| xl | 32px | `space-8` / `p-8` / `m-8` | 超大间距 |
| 2xl | 48px | `space-12` / `p-12` / `m-12` | 区块间距 |
### 间距使用场景
1. **组件内边距**: 通常使用 12px (p-3) 或 16px (p-4)
2. **组件外边距**: 标准使用 16px (m-4) 或 24px (m-6)
3. **区块间距**: 使用 32px (mb-8) 或 48px (mb-12)
4. **栅格间距**: 标准使用 16px 或 24px
---
## 组件规范
### 按钮 (Button)
#### 类型与场景
```jsx
import { Button } from 'antd';
// 主要按钮 - 用于主要操作
<Button type="primary">确定</Button>
// 次要按钮 - 用于次要操作
<Button>取消</Button>
// 文本按钮 - 用于辅助操作
<Button type="text">详情</Button>
// 链接按钮 - 用于跳转
<Button type="link">查看更多</Button>
// 危险按钮 - 用于删除等危险操作
<Button danger>删除</Button>
```
#### 尺寸规范
- **Large**: 高度 40px用于页面主要操作
- **Middle**: 高度 32px默认尺寸
- **Small**: 高度 24px用于紧凑场景
#### 使用规范
1. 一个操作区域最多只有一个主要按钮
2. 按钮文字应简洁明确,建议不超过 4 个字
3. 危险操作必须使用二次确认
4. 按钮组内按钮间距为 8px
### 表单 (Form)
#### 布局规范
```jsx
import { Form, Input } from 'antd';
<Form layout="vertical" className="max-w-2xl">
<Form.Item
label="用户名"
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input placeholder="请输入用户名" />
</Form.Item>
</Form>
```
#### 表单规范
1. **标签对齐**: 默认使用垂直布局 (vertical)
2. **必填标识**: 使用红色星号 (*) 标识
3. **字段宽度**: 根据内容长度设置合理宽度
4. **错误提示**: 实时验证,错误信息显示在字段下方
5. **表单间距**: 表单项之间间距 24px
### 表格 (Table)
#### 基础配置
```jsx
import { Table } from 'antd';
<Table
columns={columns}
dataSource={data}
pagination={{
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `共 ${total} 条`,
}}
scroll={{ x: 1200 }}
/>
```
#### 表格规范
1. **分页**: 默认每页 10 条,提供分页器
2. **行高**: 标准行高 54px (middle 模式)
3. **操作列**: 固定在右侧,宽度根据操作数量调整
4. **空状态**: 使用统一的空状态提示
5. **加载状态**: 使用 loading 属性显示加载状态
### 卡片 (Card)
```jsx
import { Card } from 'antd';
<Card
title="卡片标题"
extra={<Button type="link">更多</Button>}
className="mb-4"
>
<p>卡片内容</p>
</Card>
```
#### 卡片规范
1. **内边距**: 标准 24px
2. **圆角**: 8px (rounded-lg)
3. **阴影**: 默认使用 `shadow-sm`
4. **间距**: 卡片之间间距 16px
---
## 布局规范
### 页面布局结构
```
┌─────────────────────────────────────┐
│ Header (64px) │
├─────────────────────────────────────┤
│ Sider │ Content Area │
│ (200px)│ │
│ │ │
│ │ │
└─────────────────────────────────────┘
```
### 栅格系统
采用 24 栏栅格系统,基于 Ant Design Grid 组件。
```jsx
import { Row, Col } from 'antd';
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={8} lg={6} xl={4}>
内容
</Col>
</Row>
```
### 布局规范
1. **页面内边距**: 24px
2. **内容最大宽度**: 1200px (根据实际需求调整)
3. **侧边栏宽度**: 200px (收起后 64px)
4. **顶部导航高度**: 64px
5. **栅格间距**: 水平 16px垂直 16px
---
## 交互规范
### 反馈
#### 全局提示 (Message)
```jsx
import { message } from 'antd';
// 成功提示
message.success('操作成功');
// 错误提示
message.error('操作失败,请重试');
// 警告提示
message.warning('请注意数据安全');
// 加载提示
const hide = message.loading('加载中...');
```
#### 通知提醒 (Notification)
```jsx
import { notification } from 'antd';
notification.open({
message: '系统通知',
description: '您有新的消息,请及时查看',
duration: 4.5,
});
```
#### 模态对话框 (Modal)
```jsx
import { Modal } from 'antd';
// 确认对话框
Modal.confirm({
title: '确认删除',
content: '删除后数据无法恢复,确定要删除吗?',
okText: '确定',
cancelText: '取消',
onOk() {
// 处理确认
},
});
```
### 加载状态
1. **局部加载**: 使用 Spin 组件
2. **按钮加载**: 设置 loading 属性
3. **页面加载**: 骨架屏 (Skeleton)
4. **表格加载**: Table 的 loading 属性
### 动画效果
1. **过渡时间**: 标准 300ms
2. **缓动函数**: ease-in-out
3. **使用场景**: 展开/收起、显示/隐藏、页面切换
---
## 响应式设计
### 断点定义
| 断点 | 屏幕宽度 | 设备类型 | Tailwind 前缀 |
|------|----------|----------|---------------|
| xs | < 576px | | - |
| sm | ≥ 576px | 手机横屏 | `sm:` |
| md | ≥ 768px | 平板 | `md:` |
| lg | ≥ 992px | 桌面显示器 | `lg:` |
| xl | ≥ 1200px | 大桌面显示器 | `xl:` |
| 2xl | ≥ 1600px | 超大显示器 | `2xl:` |
### 响应式策略
1. **移动优先**: 先设计移动端,再扩展到桌面端
2. **弹性布局**: 使用 Flexbox 和 Grid
3. **自适应图片**: 使用相对单位和 max-width
4. **触摸友好**: 移动端可点击区域不小于 44x44px
---
## 页面模板
### 通用页面结构
```jsx
import { Layout, Breadcrumb } from 'antd';
const { Content } = Layout;
function PageTemplate() {
return (
<Content className="p-6">
{/* 面包屑导航 */}
<Breadcrumb className="mb-4">
<Breadcrumb.Item>首页</Breadcrumb.Item>
<Breadcrumb.Item>当前页面</Breadcrumb.Item>
</Breadcrumb>
{/* 页面标题区 */}
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900">页面标题</h1>
<p className="text-sm text-gray-500 mt-1">页面描述信息</p>
</div>
{/* 主要内容区 */}
<div className="bg-white rounded-lg shadow-sm p-6">
{/* 页面内容 */}
</div>
</Content>
);
}
```
### 已实现的页面模板
#### 1. 主框架页面 ✓
完整的应用框架布局,包含:
- 侧边菜单栏(支持收起/展开,两级菜单)
- 顶部导航栏(搜索、消息、用户信息)
- 内容区域(可滚动)
**详细文档**: [主框架页面设计规范](../docs/pages/main-layout.md)
**主要特性**
- 基于 NEX Logo 的品牌配色 (#b8178d)
- 菜单数据 JSON 配置
- 响应式布局
- 徽章系统HOT/NEW
- 用户下拉菜单
**使用示例**
```jsx
import MainLayout from './components/MainLayout'
function App() {
return (
<MainLayout>
{/* 页面内容 */}
</MainLayout>
)
}
```
#### 2. 概览页 (Dashboard) ✓
数据概览页面,展示:
- 统计卡片(证书总量、资产数量等)
- 环形进度图表
- 数据走势图
**主要组件**
- 响应式栅格布局 (Row/Col)
- 统计卡片 (Statistic)
- 进度条 (Progress)
- 图表区域
### 待完善的页面模板
后续将添加以下页面模板:
- [ ] **列表页**: 数据表格、筛选、操作
- [ ] **详情页**: 信息展示、关联数据
- [ ] **表单页**: 数据录入、验证、提交
- [ ] **设置页**: 配置管理、偏好设置
---
## 开发规范
### 代码组织
```
src/
├── components/ # 公共组件
│ ├── Button/
│ ├── Card/
│ └── ...
├── pages/ # 页面组件
│ ├── Dashboard/
│ ├── List/
│ └── ...
├── styles/ # 样式文件
│ ├── globals.css
│ └── variables.css
├── utils/ # 工具函数
└── constants/ # 常量定义
```
### 命名规范
1. **组件命名**: 大驼峰 (PascalCase),如 `UserList`
2. **文件命名**: 与组件同名,如 `UserList.jsx`
3. **样式类命名**: 小写短横线 (kebab-case)
4. **常量命名**: 全大写下划线 (UPPER_SNAKE_CASE)
### CSS 使用规范
1. **优先使用 Tailwind**: 布局、间距、颜色等
2. **Ant Design 定制**: 通过主题配置实现
3. **自定义样式**: 仅在必要时使用,放在组件目录下
---
## 版本记录
| 版本 | 日期 | 说明 |
|------|------|------|
| 1.0.0 | 2024-11-04 | 初始版本,建立基础设计规范 |
| 1.1.0 | 2024-11-04 | 更新品牌配色,完成主框架页面和概览页 |
---
## 参考资源
- [Ant Design 官方文档](https://ant.design/)
- [Tailwind CSS 官方文档](https://tailwindcss.com/)
- [React 官方文档](https://react.dev/)
- [Material Design 设计指南](https://material.io/design)
---
**维护者**: Nex Design Team
**最后更新**: 2024-11-04

View File

@ -0,0 +1,330 @@
# ConfirmDialog 组件
## 组件说明
确认对话框组件,基于 Ant Design Modal 封装,提供统一的确认对话框样式和交互。支持单个删除、批量删除、警告确认和通用确认等多种场景。
## 组件位置
```
src/components/ConfirmDialog/ConfirmDialog.jsx
```
## API 方法
组件以静态方法的形式提供,无需实例化,直接调用即可。
### ConfirmDialog.delete()
显示单个项目删除确认对话框。
#### 参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| title | string | 否 | '确认删除' | 对话框标题 |
| itemName | string | 是 | - | 要删除的项目名称 |
| itemInfo | string | 否 | - | 项目附加信息 |
| onOk | function | 否 | - | 确认回调,支持返回 Promise |
| onCancel | function | 否 | - | 取消回调 |
#### 使用示例
```jsx
import ConfirmDialog from '../components/ConfirmDialog/ConfirmDialog'
const handleDeleteUser = (record) => {
ConfirmDialog.delete({
itemName: `用户名:${record.userName}`,
itemInfo: `姓名:${record.name}`,
onOk() {
return new Promise((resolve) => {
setTimeout(() => {
// 执行删除操作
deleteUser(record.id)
resolve()
Toast.success('删除成功', `用户 "${record.userName}" 已成功删除`)
}, 1000)
})
},
})
}
```
### ConfirmDialog.batchDelete()
显示批量删除确认对话框。
#### 参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| count | number | 是 | - | 要删除的项目数量 |
| items | Array<ItemInfo> | 是 | - | 项目列表 |
| onOk | function | 否 | - | 确认回调,支持返回 Promise |
| onCancel | function | 否 | - | 取消回调 |
##### ItemInfo 对象
| 属性名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| name | string | 是 | 项目名称 |
| info | string | 否 | 项目附加信息 |
#### 使用示例
```jsx
const handleBatchDelete = () => {
const selectedUsers = filteredUsers.filter((u) => selectedRowKeys.includes(u.id))
const items = selectedUsers.map((user) => ({
name: user.userName,
info: user.name,
}))
ConfirmDialog.batchDelete({
count: selectedRowKeys.length,
items,
onOk() {
return new Promise((resolve) => {
setTimeout(() => {
const count = selectedRowKeys.length
const newUsers = filteredUsers.filter((u) => !selectedRowKeys.includes(u.id))
setFilteredUsers(newUsers)
setSelectedRowKeys([])
resolve()
Toast.success('批量删除成功', `已成功删除 ${count} 个用户`)
}, 1000)
})
},
})
}
```
### ConfirmDialog.warning()
显示警告确认对话框。
#### 参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| title | string | 是 | - | 对话框标题 |
| content | string\|ReactNode | 是 | - | 对话框内容 |
| okText | string | 否 | '确定' | 确认按钮文字 |
| cancelText | string | 否 | '取消' | 取消按钮文字 |
| onOk | function | 否 | - | 确认回调 |
| onCancel | function | 否 | - | 取消回调 |
#### 使用示例
```jsx
const handleRiskyOperation = () => {
ConfirmDialog.warning({
title: '操作警告',
content: '此操作可能影响系统稳定性,确定要继续吗?',
okText: '继续操作',
onOk() {
// 执行危险操作
performRiskyOperation()
},
})
}
```
### ConfirmDialog.confirm()
显示通用确认对话框。
#### 参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| title | string | 是 | - | 对话框标题 |
| content | string\|ReactNode | 是 | - | 对话框内容 |
| okText | string | 否 | '确定' | 确认按钮文字 |
| cancelText | string | 否 | '取消' | 取消按钮文字 |
| okType | string | 否 | 'primary' | 确认按钮类型primary/danger |
| onOk | function | 否 | - | 确认回调 |
| onCancel | function | 否 | - | 取消回调 |
#### 使用示例
```jsx
const handleSubmit = () => {
ConfirmDialog.confirm({
title: '提交确认',
content: '确定要提交当前表单吗?',
okText: '提交',
onOk() {
// 执行提交操作
submitForm()
},
})
}
```
## 完整使用示例
### 单个删除
```jsx
import ConfirmDialog from '../components/ConfirmDialog/ConfirmDialog'
import Toast from '../components/Toast/Toast'
function UserListPage() {
const handleDeleteUser = (record) => {
ConfirmDialog.delete({
itemName: `用户名:${record.userName}`,
itemInfo: `姓名:${record.name}`,
onOk() {
return new Promise((resolve) => {
setTimeout(() => {
const newUsers = filteredUsers.filter((u) => u.id !== record.id)
setFilteredUsers(newUsers)
resolve()
Toast.success('删除成功', `用户 "${record.userName}" 已成功删除`)
}, 1000)
})
},
})
}
return (
<Button onClick={() => handleDeleteUser(user)} danger>
删除
</Button>
)
}
```
### 批量删除
```jsx
function UserListPage() {
const [selectedRowKeys, setSelectedRowKeys] = useState([])
const handleBatchDelete = () => {
const selectedUsers = filteredUsers.filter((u) => selectedRowKeys.includes(u.id))
const items = selectedUsers.map((user) => ({
name: user.userName,
info: user.name,
}))
ConfirmDialog.batchDelete({
count: selectedRowKeys.length,
items,
onOk() {
return new Promise((resolve) => {
setTimeout(() => {
const count = selectedRowKeys.length
const newUsers = filteredUsers.filter((u) => !selectedRowKeys.includes(u.id))
setFilteredUsers(newUsers)
setSelectedRowKeys([])
resolve()
Toast.success('批量删除成功', `已成功删除 ${count} 个用户`)
}, 1000)
})
},
})
}
return (
<Button
onClick={handleBatchDelete}
disabled={selectedRowKeys.length === 0}
danger
>
批量删除
</Button>
)
}
```
### 自定义内容
```jsx
ConfirmDialog.confirm({
title: '重置密码',
content: (
<div>
<p>确定要重置用户 <strong>{user.userName}</strong> 的密码吗?</p>
<p style={{ color: '#faad14' }}>新密码将发送到用户邮箱</p>
</div>
),
okText: '确认重置',
okType: 'primary',
onOk() {
resetPassword(user.id)
},
})
```
### 异步操作处理
```jsx
const handleDeleteUser = (record) => {
ConfirmDialog.delete({
itemName: `用户名:${record.userName}`,
itemInfo: `姓名:${record.name}`,
async onOk() {
try {
// 调用 API 删除用户
await api.deleteUser(record.id)
// 更新本地数据
const newUsers = filteredUsers.filter((u) => u.id !== record.id)
setFilteredUsers(newUsers)
Toast.success('删除成功', `用户 "${record.userName}" 已成功删除`)
} catch (error) {
Toast.error('删除失败', error.message)
throw error // 阻止对话框关闭
}
},
})
}
```
## 特性说明
### 删除确认样式
- 红色危险图标
- 高亮显示要删除的项目信息
- 红色警告提示"此操作不可恢复,请谨慎操作!"
- 危险样式的确认按钮
### 批量删除列表
- 最多显示 200px 高度的滚动列表
- 每个项目显示名称和附加信息
- 项目间用分隔线隔开
### Promise 支持
- `onOk` 回调支持返回 Promise
- 异步操作进行时,确认按钮显示 loading 状态
- Promise reject 时,对话框不会关闭
- 适合调用 API 等异步操作
### 居中显示
- 所有对话框默认垂直居中显示(`centered: true`
## 使用场景
1. **删除确认** - 删除用户、设备、订单等数据前的确认
2. **批量删除** - 批量删除多条数据前的确认
3. **危险操作警告** - 执行可能影响系统的操作前的警告
4. **通用确认** - 提交表单、保存设置等操作的确认
5. **重要操作二次确认** - 任何需要用户明确确认的操作
## 注意事项
1. 删除操作统一使用红色危险样式,提醒用户谨慎操作
2. `onOk` 回调支持同步和异步(返回 Promise两种方式
3. 批量删除时,`items` 数组会在对话框中完整展示,注意数量控制
4. 对话框内容支持字符串和 React 节点,可以自定义复杂内容
5. 确认按钮文字建议明确操作类型,如"确认删除"、"确认提交"等
6. 配合 Toast 组件使用,提供操作结果反馈
7. 异步操作失败时throw error 可以阻止对话框关闭

View File

@ -0,0 +1,469 @@
# DetailDrawer 组件
## 组件说明
详情抽屉组件,用于从页面右侧滑出显示详细信息。支持自定义标题、操作按钮、标签页等功能,内容区域可滚动,顶部标题栏固定。
## 组件位置
```
src/components/DetailDrawer/DetailDrawer.jsx
src/components/DetailDrawer/DetailDrawer.css
```
## 参数说明
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| visible | boolean | 是 | - | 是否显示抽屉 |
| onClose | function | 是 | - | 关闭抽屉回调 |
| title | TitleConfig | 否 | - | 标题配置对象 |
| headerActions | Array<ActionConfig> | 否 | [] | 顶部操作按钮数组 |
| width | number | 否 | 1080 | 抽屉宽度(像素) |
| children | ReactNode | 否 | - | 主要内容区域 |
| tabs | Array<TabConfig> | 否 | - | 标签页配置数组 |
### 1. 小型抽屉 (Small) - 480px
**适用场景:**
- 简单的信息展示
- 少量字段的表单1-3个字段
- 快速操作面板
- 通知详情
**示例:**
```jsx
<Drawer width={480} ... />
```
### 2. 中型抽屉 (Medium) - 720px
**适用场景:**
- 详细信息展示(如主机详情)
- 中等复杂度的表单4-10个字段
- 数据编辑面板
- 配置设置
**示例:**
```jsx
<Drawer width={720} ... />
```
**当前主机列表页面使用此宽度模式**
### 3. 大型抽屉 (Large) - 1080px
**适用场景:**
- 复杂的多步骤表单
- 需要并排展示多列信息
- 包含图表或复杂可视化内容
- 嵌套子表格或列表
**示例:**
```jsx
<Drawer width={1080} ... />
```
### TitleConfig 配置项
| 属性名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| text | string | 是 | 标题文本 |
| badge | ReactNode | 否 | 状态徽标(如 Tag、Badge 组件) |
| icon | ReactNode | 否 | 标题图标 |
### ActionConfig 配置项
| 属性名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| key | string | 是 | 按钮唯一标识 |
| label | string | 是 | 按钮文本 |
| icon | ReactNode | 否 | 按钮图标 |
| type | string | 否 | 按钮类型primary/default/dashed/text/link |
| danger | boolean | 否 | 是否为危险按钮 |
| disabled | boolean | 否 | 是否禁用 |
| onClick | function | 否 | 点击回调函数 |
### TabConfig 配置项
| 属性名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| key | string | 是 | 标签页唯一标识 |
| label | ReactNode | 是 | 标签页标题(支持图标+文字) |
| content | ReactNode | 是 | 标签页内容 |
## 使用示例
### 基础用法
```jsx
import { useState } from 'react'
import DetailDrawer from '../components/DetailDrawer/DetailDrawer'
function MyPage() {
const [showDrawer, setShowDrawer] = useState(false)
const [selectedItem, setSelectedItem] = useState(null)
return (
<>
<Button onClick={() => setShowDrawer(true)}>查看详情</Button>
<DetailDrawer
visible={showDrawer}
onClose={() => setShowDrawer(false)}
title={{
text: selectedItem?.name || '详情',
}}
>
<div style={{ padding: 24 }}>
<p>详情内容</p>
</div>
</DetailDrawer>
</>
)
}
```
### 带状态徽标
```jsx
import { Tag, Badge } from 'antd'
<DetailDrawer
visible={showDetailDrawer}
onClose={() => setShowDetailDrawer(false)}
title={{
text: selectedUser?.userName || '',
badge: (
<Tag color={selectedUser?.status === 'enabled' ? 'green' : 'default'}>
{selectedUser?.status === 'enabled' ? '启用' : '停用'}
</Tag>
),
}}
>
{/* 内容 */}
</DetailDrawer>
```
### 带操作按钮
```jsx
import { EditOutlined, DeleteOutlined } from '@ant-design/icons'
<DetailDrawer
visible={showDetailDrawer}
onClose={() => setShowDetailDrawer(false)}
title={{
text: selectedUser?.userName || '',
}}
headerActions={[
{
key: 'edit',
label: '编辑',
icon: <EditOutlined />,
onClick: () => {
setEditMode('edit')
setShowEditDrawer(true)
setShowDetailDrawer(false)
},
},
{
key: 'delete',
label: '删除',
icon: <DeleteOutlined />,
danger: true,
onClick: () => {
setShowDetailDrawer(false)
handleDelete(selectedUser)
},
},
]}
>
{/* 内容 */}
</DetailDrawer>
```
### 使用 InfoPanel 显示信息
```jsx
import DetailDrawer from '../components/DetailDrawer/DetailDrawer'
import InfoPanel from '../components/InfoPanel/InfoPanel'
const userFields = [
{ key: 'userName', label: '用户名', span: 6 },
{ key: 'group', label: '用户分组', span: 6 },
{ key: 'name', label: '姓名', span: 6 },
{ key: 'userType', label: '用户类型', span: 6 },
{
key: 'status',
label: '状态',
span: 6,
render: (value) => (
<Tag color={value === 'enabled' ? 'green' : 'default'}>
{value === 'enabled' ? '启用' : '停用'}
</Tag>
),
},
]
<DetailDrawer
visible={showDetailDrawer}
onClose={() => setShowDetailDrawer(false)}
title={{
text: selectedUser?.userName || '',
}}
>
<InfoPanel
data={selectedUser}
fields={userFields}
actions={[
{ key: 'reset', label: '重置密码', onClick: () => console.log('重置密码') },
{ key: 'disable', label: '停用', onClick: () => console.log('停用') },
]}
/>
</DetailDrawer>
```
### 带标签页
```jsx
import { DatabaseOutlined, UserOutlined } from '@ant-design/icons'
<DetailDrawer
visible={showDetailDrawer}
onClose={() => setShowDetailDrawer(false)}
title={{
text: selectedHost?.name || '',
badge: (
<Badge
status={selectedHost?.status === 'online' ? 'success' : 'default'}
text={selectedHost?.status === 'online' ? '在线' : '离线'}
/>
),
}}
headerActions={[
{
key: 'edit',
label: '编辑',
icon: <EditOutlined />,
onClick: handleEdit,
},
]}
width={1080}
tabs={[
{
key: 'images',
label: (
<span>
<DatabaseOutlined style={{ marginRight: 8 }} />
终端镜像
</span>
),
content: (
<div className="card-list">
{/* 镜像列表内容 */}
</div>
),
},
{
key: 'users',
label: (
<span>
<UserOutlined style={{ marginRight: 8 }} />
终端用户
</span>
),
content: (
<div className="card-list">
{/* 用户列表内容 */}
</div>
),
},
]}
>
<InfoPanel data={selectedHost} fields={hostFields} />
</DetailDrawer>
```
### 完整示例
```jsx
import { useState } from 'react'
import DetailDrawer from '../components/DetailDrawer/DetailDrawer'
import InfoPanel from '../components/InfoPanel/InfoPanel'
import { Tag, Badge, Card } from 'antd'
import { EditOutlined, DeleteOutlined, DatabaseOutlined, UserOutlined } from '@ant-design/icons'
function UserListPage() {
const [showDetailDrawer, setShowDetailDrawer] = useState(false)
const [selectedUser, setSelectedUser] = useState(null)
const userFields = [
{ key: 'userName', label: '用户名', span: 6 },
{ key: 'group', label: '用户分组', span: 6 },
{ key: 'name', label: '姓名', span: 6 },
{ key: 'grantedImages', label: '授权镜像', span: 6 },
{ key: 'userType', label: '用户类型', span: 6 },
{ key: 'grantedTerminals', label: '授权终端', span: 6 },
{
key: 'status',
label: '启停用',
span: 6,
render: (value) => (
<Tag color={value === 'enabled' ? 'green' : 'default'}>
{value === 'enabled' ? '启用' : '停用'}
</Tag>
),
},
]
const detailTabs = [
{
key: 'terminals',
label: (
<span>
<DesktopOutlined style={{ marginRight: 8 }} />
授权终端
</span>
),
content: (
<div className="card-list">
{selectedUser?.terminals?.map((terminal) => (
<Card key={terminal.id}>
<h4>{terminal.name}</h4>
<p>IP: {terminal.ip}</p>
</Card>
))}
</div>
),
},
{
key: 'images',
label: (
<span>
<DatabaseOutlined style={{ marginRight: 8 }} />
授权镜像
</span>
),
content: (
<div className="card-list">
{selectedUser?.images?.map((image) => (
<Card key={image.id}>
<h4>{image.name}</h4>
<p>系统: {image.os}</p>
</Card>
))}
</div>
),
},
]
return (
<>
{/* 列表页面 */}
<ListTable
columns={columns}
dataSource={users}
onRowClick={(record) => {
setSelectedUser(record)
setShowDetailDrawer(true)
}}
/>
{/* 详情抽屉 */}
<DetailDrawer
visible={showDetailDrawer}
onClose={() => setShowDetailDrawer(false)}
title={{
text: selectedUser?.userName || '',
badge: (
<Tag color={selectedUser?.status === 'enabled' ? 'green' : 'default'}>
{selectedUser?.status === 'enabled' ? '启用' : '停用'}
</Tag>
),
}}
headerActions={[
{
key: 'edit',
label: '编辑',
icon: <EditOutlined />,
onClick: () => {
setShowDetailDrawer(false)
handleEdit(selectedUser)
},
},
{
key: 'delete',
label: '删除',
icon: <DeleteOutlined />,
danger: true,
onClick: () => {
setShowDetailDrawer(false)
handleDelete(selectedUser)
},
},
]}
width={1080}
tabs={detailTabs}
>
<InfoPanel
data={selectedUser}
fields={userFields}
actions={[
{ key: 'move', label: '转移分组', type: 'primary', onClick: () => console.log('转移分组') },
{ key: 'reset', label: '重置密码', onClick: () => console.log('重置密码') },
{ key: 'disable', label: '停用', onClick: () => console.log('停用') },
]}
/>
</DetailDrawer>
</>
)
}
```
## 布局结构
抽屉采用固定头部、可滚动内容的布局:
```
┌─────────────────────────────────────┐
│ 标题栏(固定,不滚动) │
│ [关闭] [标题] [徽标] [操作按钮] │
├─────────────────────────────────────┤
│ │
│ 内容区域(可滚动) │
│ - children 主要内容 │
│ - tabs 标签页(可选) │
│ │
└─────────────────────────────────────┘
```
## 样式定制
组件提供以下 CSS 类名供自定义样式:
- `.detail-drawer-content` - 抽屉内容容器
- `.detail-drawer-header` - 顶部标题栏
- `.detail-drawer-header-left` - 标题栏左侧区域
- `.detail-drawer-header-right` - 标题栏右侧区域
- `.detail-drawer-close-button` - 关闭按钮
- `.detail-drawer-header-info` - 标题信息容器
- `.detail-drawer-title-icon` - 标题图标
- `.detail-drawer-title` - 标题文本
- `.detail-drawer-badge` - 徽标容器
- `.detail-drawer-scrollable-content` - 可滚动内容区域
- `.detail-drawer-tabs` - 标签页容器
- `.detail-drawer-tab-content` - 标签页内容
## 使用场景
1. **查看详细信息** - 点击列表行显示详细信息
2. **多标签页详情** - 在详情中展示不同类型的关联数据
3. **带快捷操作的详情** - 在详情顶部提供编辑、删除等操作
4. **复杂数据展示** - 配合 InfoPanel、Card 等组件展示复杂信息
## 注意事项
1. 抽屉宽度默认 1080px可根据内容调整建议取值范围720-1200px
2. 标题栏固定在顶部,不随内容滚动,确保操作按钮始终可见
3. `children` 内容区域会自动应用内边距,`tabs` 内容需要自行控制样式
4. 操作按钮数量不宜过多,建议不超过 3 个
5. 使用 `tabs` 时,第一个标签页默认激活
6. 关闭抽屉时建议清空选中状态,避免下次打开时显示旧数据
7. 配合 InfoPanel 使用时InfoPanel 会自动处理内边距

View File

@ -0,0 +1,305 @@
# InfoPanel 组件
## 组件说明
信息展示面板组件,用于以网格布局展示数据的字段信息,支持自定义字段渲染和操作按钮。常用于详情页面或抽屉中展示结构化数据。
## 组件位置
```
src/components/InfoPanel/InfoPanel.jsx
src/components/InfoPanel/InfoPanel.css
```
## 参数说明
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| data | Object | 是 | - | 数据源对象 |
| fields | Array<FieldConfig> | 否 | [] | 字段配置数组 |
| actions | Array<ActionConfig> | 否 | [] | 操作按钮配置数组 |
| gutter | Array<number> | 否 | [24, 16] | Grid 网格间距 [水平, 垂直] |
### FieldConfig 配置项
| 属性名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| key | string | 是 | 数据字段名 |
| label | string | 是 | 字段显示标签 |
| span | number | 否 | 网格占位份数24栅格系统默认 6 |
| render | function(value, data) | 否 | 自定义渲染函数 |
### ActionConfig 配置项
| 属性名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| key | string | 是 | 按钮唯一标识 |
| label | string | 是 | 按钮文本 |
| icon | ReactNode | 否 | 按钮图标 |
| type | string | 否 | 按钮类型primary/default/dashed/text/link |
| danger | boolean | 否 | 是否为危险按钮 |
| disabled | boolean | 否 | 是否禁用 |
| onClick | function | 否 | 点击回调函数 |
## 使用示例
### 基础用法
```jsx
import InfoPanel from '../components/InfoPanel/InfoPanel'
function UserDetail() {
const userData = {
userName: 'admin',
name: '系统管理员',
group: '管理员组',
userType: '管理员',
status: 'enabled',
grantedTerminals: 8,
grantedImages: 21,
}
const userFields = [
{ key: 'userName', label: '用户名', span: 6 },
{ key: 'name', label: '姓名', span: 6 },
{ key: 'group', label: '用户分组', span: 6 },
{ key: 'userType', label: '用户类型', span: 6 },
{ key: 'grantedTerminals', label: '授权终端', span: 6 },
{ key: 'grantedImages', label: '授权镜像', span: 6 },
]
return <InfoPanel data={userData} fields={userFields} />
}
```
### 自定义字段渲染
```jsx
import { Tag } from 'antd'
const userFields = [
{ key: 'userName', label: '用户名', span: 6 },
{ key: 'name', label: '姓名', span: 6 },
{
key: 'status',
label: '状态',
span: 6,
render: (value) => (
<Tag color={value === 'enabled' ? 'green' : 'default'}>
{value === 'enabled' ? '启用' : '停用'}
</Tag>
),
},
{
key: 'description',
label: '描述',
span: 18,
render: (value) => value || '--', // 空值显示默认占位符
},
]
<InfoPanel data={userData} fields={userFields} />
```
### 带操作按钮
```jsx
import { LockOutlined } from '@ant-design/icons'
<InfoPanel
data={userData}
fields={userFields}
actions={[
{
key: 'move',
label: '转移分组',
type: 'primary',
onClick: () => console.log('转移分组'),
},
{
key: 'blacklist',
label: '加入黑名单',
onClick: () => console.log('加入黑名单'),
},
{
key: 'reset',
label: '重置密码',
onClick: () => console.log('重置密码'),
},
{
key: 'disable',
label: '停用',
icon: <LockOutlined />,
onClick: () => console.log('停用'),
},
]}
/>
```
### 配合 DetailDrawer 使用
```jsx
import DetailDrawer from '../components/DetailDrawer/DetailDrawer'
import InfoPanel from '../components/InfoPanel/InfoPanel'
import { Tag } from 'antd'
function UserListPage() {
const [showDetailDrawer, setShowDetailDrawer] = useState(false)
const [selectedUser, setSelectedUser] = useState(null)
const userFields = [
{ key: 'userName', label: '用户名', span: 6 },
{ key: 'group', label: '用户分组', span: 6 },
{ key: 'name', label: '姓名', span: 6 },
{ key: 'grantedImages', label: '授权镜像', span: 6 },
{ key: 'userType', label: '用户类型', span: 6 },
{ key: 'grantedTerminals', label: '授权终端', span: 6 },
{
key: 'status',
label: '启停用',
span: 6,
render: (value) => (
<Tag color={value === 'enabled' ? 'green' : 'default'}>
{value === 'enabled' ? '启用' : '停用'}
</Tag>
),
},
{ key: 'description', label: '描述', span: 18, render: () => '--' },
]
return (
<DetailDrawer
visible={showDetailDrawer}
onClose={() => setShowDetailDrawer(false)}
title={{
text: selectedUser?.userName || '',
}}
>
<InfoPanel
data={selectedUser}
fields={userFields}
actions={[
{ key: 'move', label: '转移分组', type: 'primary', onClick: () => console.log('转移分组') },
{ key: 'blacklist', label: '加入黑名单', onClick: () => console.log('加入黑名单') },
{ key: 'reset', label: '重置密码', onClick: () => console.log('重置密码') },
{ key: 'disable', label: '停用', onClick: () => console.log('停用') },
]}
/>
</DetailDrawer>
)
}
```
### 不同 span 值的布局
```jsx
// 24栅格系统一行总共24份
const fields = [
{ key: 'field1', label: '字段1', span: 6 }, // 占1/4宽度
{ key: 'field2', label: '字段2', span: 6 }, // 占1/4宽度
{ key: 'field3', label: '字段3', span: 6 }, // 占1/4宽度
{ key: 'field4', label: '字段4', span: 6 }, // 占1/4宽度满一行
{ key: 'field5', label: '字段5', span: 8 }, // 占1/3宽度
{ key: 'field6', label: '字段6', span: 8 }, // 占1/3宽度
{ key: 'field7', label: '字段7', span: 8 }, // 占1/3宽度满一行
{ key: 'field8', label: '字段8', span: 12 }, // 占1/2宽度
{ key: 'field9', label: '字段9', span: 12 }, // 占1/2宽度满一行
{ key: 'description', label: '描述', span: 24 }, // 占满整行
]
<InfoPanel data={data} fields={fields} />
```
### 访问完整数据对象
```jsx
const fields = [
{ key: 'userName', label: '用户名', span: 6 },
{
key: 'status',
label: '状态信息',
span: 18,
// render 函数的第二个参数是完整的数据对象
render: (value, data) => (
<div>
<Tag color={value === 'enabled' ? 'green' : 'default'}>
{value === 'enabled' ? '启用' : '停用'}
</Tag>
<span style={{ marginLeft: 8 }}>
授权终端:{data.grantedTerminals} 台
</span>
</div>
),
},
]
<InfoPanel data={userData} fields={fields} />
```
### 自定义网格间距
```jsx
// 默认间距 [24, 16]
<InfoPanel
data={userData}
fields={userFields}
gutter={[32, 24]} // 更大的间距
/>
// 紧凑布局
<InfoPanel
data={userData}
fields={userFields}
gutter={[16, 12]} // 更小的间距
/>
```
## 布局说明
组件使用 Ant Design 的 24 栅格系统:
```
┌─────────────────────────────────────────────────┐
│ span=6 span=6 span=6 span=6 │ 一行4列
├─────────────────────────────────────────────────┤
│ span=8 span=8 span=8 │ 一行3列
├─────────────────────────────────────────────────┤
│ span=12 span=12 │ 一行2列
├─────────────────────────────────────────────────┤
│ span=24 │ 占满一行
└─────────────────────────────────────────────────┘
```
常用 span 值:
- `span=6` - 一行 4 列
- `span=8` - 一行 3 列
- `span=12` - 一行 2 列
- `span=24` - 占满整行(适合描述、备注等长文本字段)
## 样式定制
组件提供以下 CSS 类名供自定义样式:
- `.info-panel` - 组件根容器
- `.info-panel-item` - 单个字段容器
- `.info-panel-label` - 字段标签
- `.info-panel-value` - 字段值
- `.info-panel-actions` - 操作按钮区域
## 使用场景
1. **详情页信息展示** - 在详情抽屉或页面中展示对象的属性信息
2. **用户信息展示** - 展示用户的基本信息和状态
3. **设备信息展示** - 展示设备的配置和参数
4. **订单信息展示** - 展示订单的详细信息
5. **任何结构化数据展示** - 以标签-值形式展示的数据
## 注意事项
1. `data``null``undefined` 时组件不渲染任何内容
2. 字段 `span` 值总和建议为 24 的倍数以保持布局整齐
3. 长文本字段(如描述、备注)建议使用 `span=24``span=18`
4. `render` 函数可以返回任何 React 节点包括组件、文本、HTML 等
5. 操作按钮会显示在所有字段下方,建议不超过 6 个按钮
6. 使用 `render` 函数时,第一个参数是字段值,第二个参数是完整数据对象
7. 网格间距 `gutter` 的第一个值是水平间距,第二个值是垂直间距

View File

@ -0,0 +1,249 @@
# ListActionBar 组件
## 组件说明
列表操作栏组件,提供统一的操作按钮区、搜索框、高级筛选和刷新功能。常用于列表页面的顶部操作区域。
## 组件位置
```
src/components/ListActionBar/ListActionBar.jsx
src/components/ListActionBar/ListActionBar.css
```
## 参数说明
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| actions | Array<ActionConfig> | 否 | [] | 左侧操作按钮配置数组 |
| search | SearchConfig | 否 | - | 搜索框配置对象 |
| filter | FilterConfig | 否 | - | 高级筛选配置对象 |
| showRefresh | boolean | 否 | false | 是否显示刷新按钮 |
| onRefresh | function | 否 | - | 刷新按钮点击回调 |
### ActionConfig 配置项
| 属性名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| key | string | 是 | 按钮唯一标识 |
| label | string | 是 | 按钮文本 |
| icon | ReactNode | 否 | 按钮图标 |
| type | string | 否 | 按钮类型primary/default/dashed/text/link |
| disabled | boolean | 否 | 是否禁用 |
| danger | boolean | 否 | 是否为危险按钮 |
| onClick | function | 否 | 点击回调函数 |
### SearchConfig 配置项
| 属性名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| placeholder | string | 否 | 搜索框占位文本 |
| width | number | 否 | 搜索框宽度,默认 280 |
| value | string | 否 | 搜索框值(受控模式) |
| onSearch | function(value: string) | 否 | 搜索回调 |
| onChange | function(value: string) | 否 | 输入变化回调 |
### FilterConfig 配置项
| 属性名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| content | ReactNode | 是 | 筛选面板内容 |
| title | string | 否 | 筛选面板标题 |
| visible | boolean | 否 | 控制面板显示/隐藏 |
| onVisibleChange | function(visible: boolean) | 否 | 显示状态变化回调 |
| selectedLabel | string | 否 | 筛选按钮显示的标签文本 |
| isActive | boolean | 否 | 是否处于激活状态(高亮显示) |
## 使用示例
### 基础用法(仅操作按钮)
```jsx
import ListActionBar from '../components/ListActionBar/ListActionBar'
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons'
<ListActionBar
actions={[
{
key: 'add',
label: '新增',
icon: <PlusOutlined />,
type: 'primary',
onClick: () => console.log('新增'),
},
{
key: 'delete',
label: '批量删除',
icon: <DeleteOutlined />,
danger: true,
disabled: selectedRowKeys.length === 0,
onClick: handleBatchDelete,
},
]}
/>
```
### 带搜索功能
```jsx
import { useState } from 'react'
function MyPage() {
const [searchKeyword, setSearchKeyword] = useState('')
return (
<ListActionBar
actions={[
{
key: 'add',
label: '新增用户',
icon: <PlusOutlined />,
type: 'primary',
onClick: () => console.log('新增'),
},
]}
search={{
placeholder: '搜索用户名或姓名',
value: searchKeyword,
onSearch: (value) => {
console.log('搜索:', value)
setSearchKeyword(value)
},
onChange: (value) => setSearchKeyword(value),
}}
/>
)
}
```
### 带高级筛选
```jsx
import { useState } from 'react'
import TreeFilterPanel from '../components/TreeFilterPanel/TreeFilterPanel'
function MyPage() {
const [showFilterPopover, setShowFilterPopover] = useState(false)
const [selectedGroup, setSelectedGroup] = useState(null)
const [selectedGroupName, setSelectedGroupName] = useState('')
return (
<ListActionBar
actions={[
{
key: 'add',
label: '新增',
icon: <PlusOutlined />,
type: 'primary',
onClick: () => console.log('新增'),
},
]}
search={{
placeholder: '搜索关键词',
onSearch: handleSearch,
}}
filter={{
content: (
<TreeFilterPanel
treeData={treeData}
selectedKey={selectedGroup}
onConfirm={handleConfirmFilter}
onClear={handleClearFilter}
/>
),
title: '高级筛选',
visible: showFilterPopover,
onVisibleChange: setShowFilterPopover,
selectedLabel: selectedGroupName,
isActive: !!selectedGroup,
}}
/>
)
}
```
### 完整示例(所有功能)
```jsx
import { useState } from 'react'
import ListActionBar from '../components/ListActionBar/ListActionBar'
import TreeFilterPanel from '../components/TreeFilterPanel/TreeFilterPanel'
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons'
function UserListPage() {
const [selectedRowKeys, setSelectedRowKeys] = useState([])
const [searchKeyword, setSearchKeyword] = useState('')
const [showFilterPopover, setShowFilterPopover] = useState(false)
const [selectedGroup, setSelectedGroup] = useState(null)
const [selectedGroupName, setSelectedGroupName] = useState('')
return (
<ListActionBar
actions={[
{
key: 'add',
label: '新增用户',
icon: <PlusOutlined />,
type: 'primary',
onClick: handleAdd,
},
{
key: 'batchDelete',
label: '批量删除',
icon: <DeleteOutlined />,
danger: true,
disabled: selectedRowKeys.length === 0,
onClick: handleBatchDelete,
},
]}
search={{
placeholder: '搜索用户名或姓名',
value: searchKeyword,
onSearch: handleSearch,
onChange: handleSearch,
}}
filter={{
content: (
<TreeFilterPanel
treeData={treeData}
selectedKey={selectedGroup}
onConfirm={handleConfirmFilter}
onClear={handleClearFilter}
/>
),
title: '高级筛选',
visible: showFilterPopover,
onVisibleChange: setShowFilterPopover,
selectedLabel: selectedGroupName,
isActive: !!selectedGroup,
}}
showRefresh
onRefresh={() => console.log('刷新')}
/>
)
}
```
## 样式定制
组件提供以下 CSS 类名供自定义样式:
- `.list-action-bar` - 组件根容器
- `.list-action-bar-left` - 左侧操作按钮区域
- `.list-action-bar-right` - 右侧搜索筛选区域
- `.filter-popover` - 筛选弹出层容器
## 使用场景
1. **列表页面顶部操作区** - 提供新增、批量操作等功能
2. **带搜索的列表** - 快速搜索列表内容
3. **带分组筛选的列表** - 通过树形结构筛选数据
4. **需要刷新的列表** - 提供手动刷新功能
## 注意事项
1. `actions` 数组中的每个按钮必须提供唯一的 `key`
2. 搜索框支持受控和非受控两种模式,推荐使用受控模式以便更好地管理状态
3. 筛选功能的 `content` 可以是任意 React 组件,常配合 `TreeFilterPanel` 使用
4. 当筛选激活时(`isActive: true`),筛选按钮会显示为 primary 类型以提示用户
5. 左侧操作按钮不宜过多,建议不超过 5 个,过多的操作可以通过下拉菜单组织

View File

@ -0,0 +1,385 @@
# ListTable 组件
## 组件说明
列表表格组件,基于 Ant Design Table 组件封装,提供统一的表格样式、行选择、分页、滚动和行点击等功能。
## 组件位置
```
src/components/ListTable/ListTable.jsx
src/components/ListTable/ListTable.css
```
## 参数说明
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| columns | Array<ColumnConfig> | 是 | - | 表格列配置数组 |
| dataSource | Array<Object> | 是 | - | 表格数据源 |
| rowKey | string | 否 | 'id' | 行数据的唯一标识字段名 |
| selectedRowKeys | Array<string\|number> | 否 | [] | 选中行的 key 数组 |
| onSelectionChange | function(keys: Array) | 否 | - | 行选择变化回调 |
| pagination | Object\|false | 否 | 默认配置 | 分页配置false 表示不分页 |
| scroll | Object | 否 | { x: 1200 } | 表格滚动配置 |
| onRowClick | function(record: Object) | 否 | - | 行点击回调 |
| selectedRow | Object | 否 | - | 当前选中的行数据对象 |
| loading | boolean | 否 | false | 表格加载状态 |
| className | string | 否 | '' | 自定义类名 |
### ColumnConfig 列配置
继承自 Ant Design Table 的列配置,常用属性:
| 属性名 | 类型 | 说明 |
|--------|------|------|
| title | string\|ReactNode | 列标题 |
| dataIndex | string | 数据字段名 |
| key | string | 列唯一标识 |
| width | number | 列宽度 |
| align | 'left'\|'center'\|'right' | 对齐方式 |
| fixed | 'left'\|'right' | 固定列 |
| render | function(value, record, index) | 自定义渲染函数 |
### 默认分页配置
```javascript
{
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `共 ${total} 条`,
}
```
## 使用示例
### 基础用法
```jsx
import ListTable from '../components/ListTable/ListTable'
function MyPage() {
const columns = [
{
title: '序号',
dataIndex: 'id',
key: 'id',
width: 80,
align: 'center',
},
{
title: '用户名',
dataIndex: 'userName',
key: 'userName',
width: 150,
},
{
title: '姓名',
dataIndex: 'name',
key: 'name',
width: 120,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status) => (
<Tag color={status === 'enabled' ? 'green' : 'default'}>
{status === 'enabled' ? '启用' : '停用'}
</Tag>
),
},
]
const dataSource = [
{ id: 1, userName: 'admin', name: '管理员', status: 'enabled' },
{ id: 2, userName: 'user', name: '张三', status: 'disabled' },
]
return (
<ListTable
columns={columns}
dataSource={dataSource}
/>
)
}
```
### 带行选择
```jsx
import { useState } from 'react'
import ListTable from '../components/ListTable/ListTable'
function UserListPage() {
const [selectedRowKeys, setSelectedRowKeys] = useState([])
return (
<div>
{/* 显示选中的数量 */}
<div>已选择 {selectedRowKeys.length} 项</div>
<ListTable
columns={columns}
dataSource={dataSource}
selectedRowKeys={selectedRowKeys}
onSelectionChange={setSelectedRowKeys}
/>
</div>
)
}
```
### 带行点击和高亮
```jsx
import { useState } from 'react'
import ListTable from '../components/ListTable/ListTable'
function UserListPage() {
const [selectedUser, setSelectedUser] = useState(null)
const handleRowClick = (record) => {
setSelectedUser(record)
// 打开详情抽屉等操作
setShowDetailDrawer(true)
}
return (
<ListTable
columns={columns}
dataSource={dataSource}
onRowClick={handleRowClick}
selectedRow={selectedUser}
/>
)
}
```
### 自定义分页
```jsx
<ListTable
columns={columns}
dataSource={dataSource}
pagination={{
pageSize: 20,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `总计 ${total} 条记录`,
pageSizeOptions: ['10', '20', '50', '100'],
}}
/>
```
### 禁用分页
```jsx
<ListTable
columns={columns}
dataSource={dataSource}
pagination={false}
/>
```
### 带加载状态
```jsx
import { useState, useEffect } from 'react'
function UserListPage() {
const [loading, setLoading] = useState(false)
const [dataSource, setDataSource] = useState([])
const fetchData = async () => {
setLoading(true)
try {
const data = await api.fetchUsers()
setDataSource(data)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchData()
}, [])
return (
<ListTable
columns={columns}
dataSource={dataSource}
loading={loading}
/>
)
}
```
### 横向滚动(列较多时)
```jsx
<ListTable
columns={columns}
dataSource={dataSource}
scroll={{ x: 1600 }} // 内容宽度超过容器时出现横向滚动
/>
```
### 固定列
```jsx
const columns = [
{
title: '序号',
dataIndex: 'id',
key: 'id',
width: 80,
fixed: 'left', // 固定在左侧
},
// ... 其他列
{
title: '操作',
key: 'action',
width: 200,
fixed: 'right', // 固定在右侧
render: (_, record) => (
<Space>
<Button type="link" size="small">编辑</Button>
<Button type="link" size="small" danger>删除</Button>
</Space>
),
},
]
<ListTable
columns={columns}
dataSource={dataSource}
scroll={{ x: 1600 }}
/>
```
### 完整示例
```jsx
import { useState } from 'react'
import ListTable from '../components/ListTable/ListTable'
import { Tag, Button, Space } from 'antd'
import { EditOutlined, DeleteOutlined } from '@ant-design/icons'
function UserListPage() {
const [selectedRowKeys, setSelectedRowKeys] = useState([])
const [selectedUser, setSelectedUser] = useState(null)
const [showDetailDrawer, setShowDetailDrawer] = useState(false)
const columns = [
{
title: '序号',
dataIndex: 'id',
key: 'id',
width: 80,
align: 'center',
},
{
title: '用户名',
dataIndex: 'userName',
key: 'userName',
width: 150,
},
{
title: '姓名',
dataIndex: 'name',
key: 'name',
width: 120,
},
{
title: '用户分组',
dataIndex: 'group',
key: 'group',
width: 150,
render: (text) => <Tag color="blue">{text}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
align: 'center',
render: (status) => (
<Tag color={status === 'enabled' ? 'green' : 'default'}>
{status === 'enabled' ? '启用' : '停用'}
</Tag>
),
},
{
title: '操作',
key: 'action',
width: 180,
fixed: 'right',
render: (_, record) => (
<Space size="small" onClick={(e) => e.stopPropagation()}>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
编辑
</Button>
<Button
type="link"
size="small"
icon={<DeleteOutlined />}
danger
onClick={() => handleDelete(record)}
>
删除
</Button>
</Space>
),
},
]
const handleRowClick = (record) => {
setSelectedUser(record)
setShowDetailDrawer(true)
}
return (
<ListTable
columns={columns}
dataSource={filteredUsers}
selectedRowKeys={selectedRowKeys}
onSelectionChange={setSelectedRowKeys}
onRowClick={handleRowClick}
selectedRow={selectedUser}
scroll={{ x: 1400 }}
/>
)
}
```
## 样式定制
组件提供以下 CSS 类名供自定义样式:
- `.list-table-container` - 表格容器
- `.row-selected` - 选中行的类名
## 使用场景
1. **用户列表** - 显示和管理用户数据
2. **设备列表** - 显示和管理设备信息
3. **订单列表** - 显示订单数据
4. **任何需要表格展示的数据列表**
## 注意事项
1. `columns` 配置中的 `key` 必须唯一
2. `dataSource` 中的每条数据必须有 `rowKey` 指定的唯一标识字段(默认为 `id`
3. 操作列中的点击事件需要使用 `e.stopPropagation()` 阻止事件冒泡,避免触发行点击
4. 当列数较多时,建议设置合适的 `scroll.x` 值并固定首尾列
5. `selectedRow` 用于高亮显示,`selectedRowKeys` 用于多选
6. 分页的 `total` 值会自动根据 `dataSource.length` 计算
7. 使用 `render` 函数时,要注意性能,避免在渲染函数中进行复杂计算

View File

@ -0,0 +1,131 @@
# PageTitleBar 组件
## 组件说明
页面标题栏组件,用于显示页面的标题、描述信息、操作按钮和可选的展开/收起控制按钮。
## 组件位置
```
src/components/PageTitleBar/PageTitleBar.jsx
src/components/PageTitleBar/PageTitleBar.css
```
## 参数说明
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| title | string | 是 | - | 页面标题文本 |
| badge | ReactNode | 否 | - | 标题右侧的徽章内容(如状态标签等) |
| description | string | 否 | - | 页面描述文本,显示在标题下方 |
| actions | ReactNode | 否 | - | 右侧操作按钮区域内容 |
| showToggle | boolean | 否 | false | 是否显示展开/收起按钮 |
| onToggle | function(expanded: boolean) | 否 | - | 展开/收起状态变化时的回调函数 |
| defaultExpanded | boolean | 否 | true | 默认展开状态 |
## 使用示例
### 基础用法
```jsx
import PageTitleBar from '../components/PageTitleBar/PageTitleBar'
function MyPage() {
return (
<div>
<PageTitleBar
title="用户列表"
description="管理系统用户,包括用户信息、权限和授权管理"
/>
{/* 页面内容 */}
</div>
)
}
```
### 带徽章的标题
```jsx
import { Tag } from 'antd'
<PageTitleBar
title="主机详情"
badge={<Tag color="green">在线</Tag>}
description="查看主机的详细信息和运行状态"
/>
```
### 带展开/收起功能
```jsx
import { useState } from 'react'
import PageTitleBar from '../components/PageTitleBar/PageTitleBar'
function MyPage() {
const [showStatsPanel, setShowStatsPanel] = useState(true)
return (
<div>
<PageTitleBar
title="主机列表"
description="查看和管理所有接入的主机终端"
showToggle={true}
onToggle={(expanded) => setShowStatsPanel(expanded)}
/>
{/* 可展开/收起的内容区域 */}
{showStatsPanel && (
<div className="stats-panel">
{/* 统计面板内容 */}
</div>
)}
</div>
)
}
```
### 带操作按钮
```jsx
import { Button } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
<PageTitleBar
title="用户列表"
description="管理系统用户"
actions={
<Button type="primary" icon={<PlusOutlined />}>
新增用户
</Button>
}
/>
```
## 样式定制
组件提供以下 CSS 类名供自定义样式:
- `.page-title-bar` - 组件根容器
- `.title-bar-content` - 内容容器
- `.title-bar-left` - 左侧内容区域
- `.title-bar-right` - 右侧内容区域
- `.title-group` - 标题和徽章组合
- `.page-title` - 标题文本
- `.title-badge` - 徽章容器
- `.page-description` - 描述文本
- `.title-actions` - 操作按钮区域
- `.toggle-button` - 展开/收起按钮
## 使用场景
1. **列表页面** - 显示列表页面的标题和描述
2. **详情页面** - 显示详情页的标题和状态标签
3. **带统计面板的页面** - 配合展开/收起功能控制统计信息的显示
4. **需要快捷操作的页面** - 通过 actions 参数在标题栏添加常用操作按钮
## 注意事项
1. `title` 参数为必填项,建议简洁明了
2. 当使用 `showToggle` 时,建议同时提供 `onToggle` 回调以响应状态变化
3. `badge` 参数支持任何 React 节点,常用的如 Ant Design 的 Tag、Badge 组件
4. `actions` 区域不建议放置过多按钮,以保持界面简洁

View File

@ -0,0 +1,149 @@
# 组件文档目录
## 概述
本目录包含 Nex Design 系统所有主要组件的详细文档,包括组件说明、参数配置、使用示例等。
## 组件列表
### 页面布局组件
1. **[PageTitleBar](./PageTitleBar.md)** - 页面标题栏组件
- 显示页面标题、描述和操作按钮
- 支持展开/收起功能
- 适用于所有页面的顶部区域
### 列表相关组件
2. **[ListActionBar](./ListActionBar.md)** - 列表操作栏组件
- 提供操作按钮、搜索、筛选功能
- 适用于列表页面的顶部操作区
3. **[TreeFilterPanel](./TreeFilterPanel.md)** - 树形筛选面板组件
- 树形结构的数据筛选
- 支持搜索和多级展开
- 配合 ListActionBar 使用
4. **[ListTable](./ListTable.md)** - 列表表格组件
- 统一的表格样式和交互
- 支持行选择、分页、排序
- 适用于所有列表页面
### 详情展示组件
5. **[DetailDrawer](./DetailDrawer.md)** - 详情抽屉组件
- 从右侧滑出的详情面板
- 支持标签页和操作按钮
- 固定头部,内容可滚动
6. **[InfoPanel](./InfoPanel.md)** - 信息展示面板组件
- 网格布局展示结构化数据
- 支持自定义字段渲染
- 配合 DetailDrawer 使用
### 交互反馈组件
7. **[ConfirmDialog](./ConfirmDialog.md)** - 确认对话框组件
- 提供统一的确认对话框样式
- 支持删除、警告、通用确认等场景
- 支持异步操作
8. **[Toast](./Toast.md)** - 通知反馈组件
- 操作完成后的提示信息
- 支持成功、错误、警告、信息四种类型
- 从右上角滑出,自动消失
## 组件关系图
```
页面结构层次:
┌─────────────────────────────────────────┐
│ PageTitleBar (页面标题栏) │
├─────────────────────────────────────────┤
│ ListActionBar (操作栏) │
│ ├─ 操作按钮 │
│ ├─ 搜索框 │
│ └─ TreeFilterPanel (筛选面板) │
├─────────────────────────────────────────┤
│ ListTable (数据表格) │
│ └─ 点击行 → DetailDrawer │
├─────────────────────────────────────────┤
│ DetailDrawer (详情抽屉) │
│ ├─ InfoPanel (基本信息) │
│ └─ Tabs (关联数据标签页) │
└─────────────────────────────────────────┘
交互反馈:
操作 → ConfirmDialog (确认) → Toast (结果反馈)
```
## 组件组合示例
### 标准列表页面
```jsx
<PageTitleBar title="用户列表" description="..." />
<ListActionBar
actions={[...]}
search={{...}}
filter={{
content: <TreeFilterPanel {...} />
}}
/>
<ListTable
columns={columns}
dataSource={data}
onRowClick={showDetail}
/>
<DetailDrawer visible={showDrawer}>
<InfoPanel data={selectedItem} fields={fields} />
</DetailDrawer>
```
### 删除操作流程
```jsx
// 1. 点击删除按钮
<Button onClick={() => handleDelete(record)}>删除</Button>
// 2. 显示确认对话框
ConfirmDialog.delete({
itemName: record.name,
onOk: async () => {
// 3. 执行删除
await api.delete(record.id)
// 4. 显示结果反馈
Toast.success('删除成功')
}
})
```
## 使用指南
### 开始使用
1. 查看对应组件的详细文档
2. 了解组件的参数配置
3. 参考示例代码
4. 根据实际需求调整参数
### 设计原则
- **一致性** - 所有组件使用统一的设计语言
- **可复用** - 组件高度封装,易于复用
- **可配置** - 提供丰富的配置选项
- **易用性** - API 设计简洁直观
### 技术栈
- React 18
- Ant Design 5.x
- CSS Modules
## 更新记录
- 2025-11-04: 初始版本,包含 8 个核心组件文档

View File

@ -0,0 +1,398 @@
# Toast 组件
## 组件说明
通知反馈组件,基于 Ant Design notification 封装,用于操作完成后向用户展示反馈信息。通知从右上角滑出,默认 3 秒后自动消失,最多同时显示 3 条通知。
## 组件位置
```
src/components/Toast/Toast.jsx
```
## 全局配置
```javascript
notification.config({
placement: 'topRight', // 通知位置:右上角
top: 24, // 距离顶部 24px
duration: 3, // 默认显示 3 秒
maxCount: 3, // 最多同时显示 3 条
})
```
## API 方法
组件以静态方法的形式提供,无需实例化,直接调用即可。
### Toast.success()
显示成功通知(绿色图标)。
#### 参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| message | string | 是 | - | 主要消息内容 |
| description | string | 否 | '' | 详细描述 |
| duration | number | 否 | 3 | 显示时长0 表示不自动关闭 |
#### 使用示例
```jsx
import Toast from '../components/Toast/Toast'
// 简单成功提示
Toast.success('操作成功')
// 带详细描述
Toast.success('删除成功', '用户 "admin" 已成功删除')
// 自定义显示时长5秒
Toast.success('保存成功', '您的设置已保存', 5)
// 不自动关闭
Toast.success('操作成功', '请注意后续操作', 0)
```
### Toast.error()
显示错误通知(红色图标)。
#### 参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| message | string | 是 | - | 主要消息内容 |
| description | string | 否 | '' | 详细描述 |
| duration | number | 否 | 3 | 显示时长(秒) |
#### 使用示例
```jsx
// 简单错误提示
Toast.error('操作失败')
// 带错误详情
Toast.error('删除失败', '该用户下还有关联数据,无法删除')
// API 错误处理
try {
await api.deleteUser(userId)
Toast.success('删除成功')
} catch (error) {
Toast.error('删除失败', error.message)
}
```
### Toast.warning()
显示警告通知(橙色图标)。
#### 参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| message | string | 是 | - | 主要消息内容 |
| description | string | 否 | '' | 详细描述 |
| duration | number | 否 | 3 | 显示时长(秒) |
#### 使用示例
```jsx
// 警告提示
Toast.warning('操作警告', '此操作可能影响系统稳定性')
// 权限警告
Toast.warning('权限不足', '您没有执行此操作的权限')
// 数据警告
Toast.warning('数据异常', '检测到部分数据可能不完整')
```
### Toast.info()
显示信息通知(蓝色图标)。
#### 参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| message | string | 是 | - | 主要消息内容 |
| description | string | 否 | '' | 详细描述 |
| duration | number | 否 | 3 | 显示时长(秒) |
#### 使用示例
```jsx
// 信息提示
Toast.info('系统提示', '系统将在5分钟后进行维护')
// 操作提示
Toast.info('导入中', '正在导入数据,请稍候...')
// 功能提示
Toast.info('新功能上线', '我们上线了新的数据导出功能')
```
### Toast.custom()
显示自定义通知,支持所有 Ant Design notification 配置。
#### 参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| config | Object | 是 | 完整的 notification 配置对象 |
#### 使用示例
```jsx
import { Button } from 'antd'
// 带操作按钮的通知
Toast.custom({
message: '新版本可用',
description: '发现新版本 v2.0.0,是否立即更新?',
duration: 0,
btn: (
<Button type="primary" size="small" onClick={() => {
console.log('更新')
notification.close('update-key')
}}>
立即更新
</Button>
),
key: 'update-key',
})
// 自定义图标和样式
Toast.custom({
message: '自定义通知',
description: '这是一个自定义样式的通知',
icon: <StarOutlined style={{ color: '#faad14' }} />,
style: {
background: '#fffbe6',
},
})
```
## 完整使用示例
### 配合 ConfirmDialog 使用
```jsx
import ConfirmDialog from '../components/ConfirmDialog/ConfirmDialog'
import Toast from '../components/Toast/Toast'
const handleDeleteUser = (record) => {
ConfirmDialog.delete({
itemName: `用户名:${record.userName}`,
itemInfo: `姓名:${record.name}`,
onOk() {
return new Promise((resolve) => {
setTimeout(() => {
const newUsers = filteredUsers.filter((u) => u.id !== record.id)
setFilteredUsers(newUsers)
resolve()
Toast.success('删除成功', `用户 "${record.userName}" 已成功删除`)
}, 1000)
})
},
})
}
```
### 批量操作反馈
```jsx
const handleBatchDelete = () => {
ConfirmDialog.batchDelete({
count: selectedRowKeys.length,
items: selectedUsers,
onOk() {
return new Promise((resolve) => {
setTimeout(() => {
const count = selectedRowKeys.length
const newUsers = filteredUsers.filter((u) => !selectedRowKeys.includes(u.id))
setFilteredUsers(newUsers)
setSelectedRowKeys([])
resolve()
Toast.success('批量删除成功', `已成功删除 ${count} 个用户`)
}, 1000)
})
},
})
}
```
### 异步操作反馈
```jsx
const handleSaveUser = async (userData) => {
try {
setLoading(true)
await api.updateUser(userData)
setShowEditDrawer(false)
Toast.success('保存成功', '用户信息已更新')
fetchUsers() // 重新加载列表
} catch (error) {
Toast.error('保存失败', error.message)
} finally {
setLoading(false)
}
}
```
### 多步骤操作反馈
```jsx
const handleImportData = async () => {
try {
Toast.info('导入开始', '正在验证数据格式...')
await api.validateData()
Toast.info('验证通过', '正在导入数据...')
const result = await api.importData()
Toast.success('导入完成', `成功导入 ${result.count} 条数据`)
} catch (error) {
if (error.type === 'validation') {
Toast.warning('验证失败', error.message)
} else {
Toast.error('导入失败', error.message)
}
}
}
```
### 表单提交反馈
```jsx
const handleSubmit = async (values) => {
try {
await api.createUser(values)
Toast.success('创建成功', `用户 "${values.userName}" 已成功创建`)
setShowEditDrawer(false)
fetchUsers()
} catch (error) {
if (error.code === 'DUPLICATE') {
Toast.warning('用户名已存在', '请使用其他用户名')
} else {
Toast.error('创建失败', error.message)
}
}
}
```
### 带按钮的持久通知
```jsx
const showUpdateNotification = () => {
const key = `update-${Date.now()}`
Toast.custom({
message: '发现新版本',
description: '系统发现新版本 v2.0.0,建议立即更新',
duration: 0, // 不自动关闭
key,
btn: (
<Space>
<Button
type="link"
size="small"
onClick={() => notification.close(key)}
>
稍后
</Button>
<Button
type="primary"
size="small"
onClick={() => {
notification.close(key)
handleUpdate()
}}
>
立即更新
</Button>
</Space>
),
})
}
```
## 使用场景
1. **操作成功反馈** - 增删改操作成功后的提示
2. **操作失败反馈** - 操作失败时显示错误信息
3. **警告提示** - 权限不足、数据异常等警告
4. **信息通知** - 系统公告、功能提示等
5. **进度通知** - 多步骤操作的进度反馈
6. **版本更新通知** - 带操作按钮的持久通知
## 最佳实践
### 消息文案
```jsx
// 好的做法:简洁明了
Toast.success('删除成功', '用户已删除')
// 避免:冗余重复
Toast.success('删除成功', '删除用户成功') // ❌
```
### 显示时长
```jsx
// 简单提示3秒默认
Toast.success('保存成功')
// 重要信息5秒
Toast.warning('权限不足', '请联系管理员', 5)
// 需要用户操作:不自动关闭
Toast.custom({
message: '需要您的确认',
description: '...',
duration: 0,
btn: <Button>...</Button>,
})
```
### 类型选择
```jsx
// 成功:操作完成
Toast.success('保存成功')
// 错误:操作失败、异常
Toast.error('保存失败', error.message)
// 警告:权限、数据问题
Toast.warning('权限不足')
// 信息:提示、公告
Toast.info('系统维护通知')
```
## 样式特性
- **圆角设计**8px 圆角,现代化视觉效果
- **阴影效果**0 4px 12px rgba(0, 0, 0, 0.15)
- **彩色图标**
- 成功:绿色 (#52c41a)
- 错误:红色 (#ff4d4f)
- 警告:橙色 (#faad14)
- 信息:蓝色 (#1677ff)
## 注意事项
1. 通知从右上角滑出,默认 3 秒后自动消失
2. 最多同时显示 3 条通知,超出的会排队等待
3. `duration: 0` 表示通知不自动关闭,需手动关闭或调用 API 关闭
4. 通知内容不宜过长,建议 message 不超过 20 字description 不超过 50 字
5. 频繁操作时避免连续显示大量通知,可以考虑合并提示
6. 配合 ConfirmDialog 使用时,在 `onOk` 回调中显示操作结果
7. 错误通知建议显示具体错误信息,帮助用户排查问题

View File

@ -0,0 +1,297 @@
# TreeFilterPanel 组件
## 组件说明
树形筛选面板组件,用于在弹出层中展示树形结构的筛选选项,支持树形选择、搜索、确认和清除操作。常配合 ListActionBar 组件使用。
## 组件位置
```
src/components/TreeFilterPanel/TreeFilterPanel.jsx
src/components/TreeFilterPanel/TreeFilterPanel.css
```
## 参数说明
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| treeData | Array<TreeNode> | 是 | - | 树形数据数组 |
| selectedKey | string | 否 | - | 当前选中的节点 key已确认的选择 |
| tempSelectedKey | string | 否 | - | 临时选中的节点 key未确认 |
| treeTitle | string | 否 | '分组筛选' | 树形选择器的标题 |
| onSelect | function(key: string) | 否 | - | 节点选择变化回调 |
| onConfirm | function | 否 | - | 确认筛选按钮回调 |
| onClear | function | 否 | - | 清除筛选按钮回调 |
| placeholder | string | 否 | '请选择分组进行筛选' | 未选择时的占位提示文本 |
### TreeNode 数据结构
| 属性名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| key | string | 是 | 节点唯一标识 |
| title | string | 是 | 节点显示文本 |
| children | Array<TreeNode> | 否 | 子节点数组 |
## 使用示例
### 基础用法
```jsx
import { useState } from 'react'
import TreeFilterPanel from '../components/TreeFilterPanel/TreeFilterPanel'
function MyPage() {
const [selectedGroup, setSelectedGroup] = useState(null)
const [tempSelectedGroup, setTempSelectedGroup] = useState(null)
const treeData = [
{
key: '1',
title: '全部用户',
children: [
{
key: '1-1',
title: '管理员组',
children: [
{ key: '1-1-1', title: '系统管理员' },
{ key: '1-1-2', title: '安全管理员' },
],
},
{
key: '1-2',
title: '部门用户',
children: [
{ key: '1-2-1', title: '研发部' },
{ key: '1-2-2', title: '产品部' },
],
},
],
},
]
const handleConfirm = () => {
setSelectedGroup(tempSelectedGroup)
// 执行筛选逻辑
filterData(tempSelectedGroup)
}
const handleClear = () => {
setTempSelectedGroup(null)
setSelectedGroup(null)
// 清除筛选
filterData(null)
}
return (
<TreeFilterPanel
treeData={treeData}
selectedKey={selectedGroup}
tempSelectedKey={tempSelectedGroup}
treeTitle="用户分组"
onSelect={setTempSelectedGroup}
onConfirm={handleConfirm}
onClear={handleClear}
placeholder="请选择用户分组进行筛选"
/>
)
}
```
### 配合 ListActionBar 使用
```jsx
import { useState } from 'react'
import ListActionBar from '../components/ListActionBar/ListActionBar'
import TreeFilterPanel from '../components/TreeFilterPanel/TreeFilterPanel'
function UserListPage() {
const [showFilterPopover, setShowFilterPopover] = useState(false)
const [selectedGroup, setSelectedGroup] = useState(null)
const [tempSelectedGroup, setTempSelectedGroup] = useState(null)
const [selectedGroupName, setSelectedGroupName] = useState('')
// 将原始数据转换为树形数据格式
const convertTreeData = (nodes) => {
return nodes.map((node) => ({
title: node.name,
key: node.id,
children: node.children ? convertTreeData(node.children) : undefined,
}))
}
const treeData = convertTreeData(userData.userGroups)
const handleConfirmFilter = () => {
setSelectedGroup(tempSelectedGroup)
// 查找节点名称
const findGroupName = (nodes, id) => {
for (const node of nodes) {
if (node.id === id) return node.name
if (node.children) {
const found = findGroupName(node.children, id)
if (found) return found
}
}
return null
}
const groupName = tempSelectedGroup
? findGroupName(userData.userGroups, tempSelectedGroup)
: ''
setSelectedGroupName(groupName)
// 执行筛选
filterUsers(searchKeyword, tempSelectedGroup)
setShowFilterPopover(false)
}
const handleClearFilter = () => {
setTempSelectedGroup(null)
setSelectedGroup(null)
setSelectedGroupName('')
filterUsers(searchKeyword, null)
setShowFilterPopover(false)
}
return (
<ListActionBar
actions={[
{
key: 'add',
label: '新增用户',
type: 'primary',
onClick: handleAdd,
},
]}
search={{
placeholder: '搜索用户',
onSearch: handleSearch,
}}
filter={{
content: (
<TreeFilterPanel
treeData={treeData}
selectedKey={selectedGroup}
tempSelectedKey={tempSelectedGroup}
treeTitle="用户分组"
onSelect={setTempSelectedGroup}
onConfirm={handleConfirmFilter}
onClear={handleClearFilter}
placeholder="请选择用户分组进行筛选"
/>
),
title: '高级筛选',
visible: showFilterPopover,
onVisibleChange: (visible) => {
setShowFilterPopover(visible)
if (visible) {
// 打开时同步临时选择
setTempSelectedGroup(selectedGroup)
}
},
selectedLabel: selectedGroupName,
isActive: !!selectedGroup,
}}
/>
)
}
```
### 从 JSON 数据转换树形结构
```jsx
// userData.json 中的数据格式
{
"userGroups": [
{
"id": "1",
"name": "全部用户",
"children": [
{
"id": "1-1",
"name": "管理员组",
"children": [
{ "id": "1-1-1", "name": "系统管理员" },
{ "id": "1-1-2", "name": "安全管理员" }
]
}
]
}
]
}
// 转换函数
const convertTreeData = (nodes) => {
return nodes.map((node) => ({
title: node.name, // 映射 name 到 title
key: node.id, // 映射 id 到 key
children: node.children ? convertTreeData(node.children) : undefined,
}))
}
const treeData = convertTreeData(userData.userGroups)
```
## 组件特性
### 自动展开所有节点
组件会在数据加载时自动展开所有树节点,方便用户查看完整的层级结构。
```javascript
// 组件内部实现
useEffect(() => {
if (treeData && treeData.length > 0) {
setExpandedKeys(getAllKeys(treeData))
}
}, [treeData])
```
### 显示当前选择
组件顶部会显示当前选中的节点,并提供关闭按钮快速清除选择:
```jsx
{tempSelectedKey ? (
<div className="tree-filter-tag">
<span className="tree-filter-label">已选择分组:</span>
<Tag color="blue" closable onClose={() => onSelect?.(null)}>
{findNodeName(treeData, tempSelectedKey)}
</Tag>
</div>
) : (
<div className="tree-filter-placeholder">
<span>{placeholder}</span>
</div>
)}
```
## 样式定制
组件提供以下 CSS 类名供自定义样式:
- `.tree-filter-panel` - 组件根容器
- `.tree-filter-selected` - 已选择区域
- `.tree-filter-tag` - 选中标签容器
- `.tree-filter-label` - 标签文本
- `.tree-filter-placeholder` - 占位提示
- `.tree-filter-container` - 树形选择器容器
- `.tree-filter-header` - 树形选择器标题
- `.tree-filter-actions` - 操作按钮区域
## 使用场景
1. **用户分组筛选** - 按用户组织架构筛选用户列表
2. **终端分组筛选** - 按终端分组筛选设备列表
3. **部门筛选** - 按部门层级筛选数据
4. **分类筛选** - 任何需要树形层级筛选的场景
## 注意事项
1. `treeData` 必须符合 Ant Design Tree 组件的数据格式要求
2. 需要区分 `selectedKey`(已确认)和 `tempSelectedKey`(临时选择),点击确认后才更新 `selectedKey`
3. 建议在打开筛选面板时,将 `selectedKey` 同步到 `tempSelectedKey`,以便用户看到当前的选择状态
4. 点击清除按钮会同时清除临时选择和已确认选择
5. 组件会自动展开所有节点,如果数据量很大,可能需要考虑性能优化
6. 树节点的 `key` 必须唯一,通常使用 ID 字段

View File

@ -0,0 +1,495 @@
# 主框架页面设计规范
> Nex Design 主框架布局设计文档
## 概述
主框架页面是应用的基础布局结构,包含侧边菜单栏、顶部导航栏和内容区域。本文档详细说明了主框架的设计规范、交互逻辑和实现要点。
---
## 页面结构
```
┌─────────────────────────────────────────────────────┐
│ 顶部导航栏 (64px) │
├──────────┬──────────────────────────────────────────┤
│ │ │
│ 侧边栏 │ 内容区域 │
│ (200px) │ (可向下滚动) │
│ │ │
│ │ │
└──────────┴──────────────────────────────────────────┘
```
---
## 1. 侧边栏 (Sider)
### 1.1 基础规范
| 属性 | 展开状态 | 收起状态 |
|------|---------|---------|
| 宽度 | 200px | 64px |
| 背景色 | #001529 (深色) | #001529 |
| 位置 | 固定左侧 | 固定左侧 |
| 层级 | z-index: 10 | z-index: 10 |
### 1.2 Logo 区域
**设计规范**
- 高度64px
- 背景rgba(255, 255, 255, 0.05)
- 底部边框1px solid rgba(255, 255, 255, 0.05)
- 内边距12px 16px
- 对齐:居中
**展开状态**
- 显示完整 Logologo-full.png
- Logo 高度40px宽度自适应
**收起状态**
- 显示方形 Logologo-small.png
- Logo 尺寸40px × 40px
- 圆角8px
### 1.3 菜单系统
#### 菜单层级
支持**两级菜单**结构:
- **一级菜单**:带图标,可展开/收起
- **二级菜单**:文字列表,可带徽章标识
#### 菜单数据格式
```json
[
{
"key": "overview",
"label": "概览",
"icon": "DashboardOutlined",
"path": "/overview"
},
{
"key": "certificate",
"label": "证书管理",
"icon": "SafetyCertificateOutlined",
"children": [
{
"key": "ssl-cert",
"label": "SSL证书管理",
"path": "/certificate/ssl"
}
]
}
]
```
#### 菜单状态
**正常状态**
- 背景色:透明
- 文字颜色rgba(255, 255, 255, 0.65)
**悬停状态**
- 背景色rgba(184, 23, 141, 0.2)
- 文字颜色:#fff
**选中状态**
- 背景色:#b8178d (品牌主色)
- 文字颜色:#fff
- 左侧边框3px solid #b8178d
**展开状态**
- 二级菜单背景rgba(0, 0, 0, 0.15)
- 二级菜单内边距:左侧 48px
#### 徽章系统
支持在菜单项上显示徽章标识:
- **HOT 徽章**
- 背景色:#ff4d4f (红色)
- 文字:白色
- 尺寸18px 高度,圆角 9px
- **NEW 徽章**
- 背景色:#52c41a (绿色)
- 文字:白色
- 尺寸18px 高度,圆角 9px
**注意**:收起状态下徽章自动隐藏。
#### 收起状态行为
- 仅显示一级菜单图标
- 鼠标悬停时不展开子菜单
- 点击跳转到该分类的默认页面
### 1.4 滚动条样式
```css
宽度6px
轨道:透明
滑块rgba(255, 255, 255, 0.2)
滑块悬停rgba(255, 255, 255, 0.3)
圆角3px
```
---
## 2. 顶部导航栏 (Header)
### 2.1 基础规范
| 属性 | 值 |
|------|---|
| 高度 | 64px |
| 背景色 | #ffffff |
| 阴影 | 0 1px 4px rgba(0, 21, 41, 0.08) |
| 位置 | stickytop: 0 |
| 层级 | z-index: 9 |
| 内边距 | 0 24px |
### 2.2 左侧区域
**折叠按钮**
- 图标尺寸18px
- 颜色rgba(0, 0, 0, 0.65)
- 悬停色:#b8178d (品牌主色)
- 悬停背景rgba(0, 0, 0, 0.03)
- 内边距8px
- 圆角4px
- 功能:切换侧边栏展开/收起状态
**工作台标识**
- 字号14px
- 字重500 (Medium)
- 颜色rgba(0, 0, 0, 0.88)
- 左侧间距16px
### 2.3 右侧区域
从左到右依次包含:
1. **搜索框**
- 宽度200px
- 高度32px
- 圆角16px (胶囊形)
- 占位文字:"搜索..."
- 前缀图标SearchOutlined
2. **帮助图标**
- 图标QuestionCircleOutlined
- 尺寸16px
- 颜色rgba(0, 0, 0, 0.65)
- 悬停色:#b8178d
3. **功能链接**ICP 备案、企业、支持)
- 字号14px
- 颜色rgba(0, 0, 0, 0.65)
- 悬停色:#b8178d
- 悬停背景rgba(0, 0, 0, 0.03)
- 内边距4px 8px
- 圆角4px
4. **工单图标**
- 图标SettingOutlined
- 样式同帮助图标
5. **消息中心**
- 图标BellOutlined
- 带徽章Badge count={5}
- 徽章位置右上角offset: [-3, 3]
- 徽章尺寸small
6. **用户信息**
- 头像32px × 32px 圆形
- 用户名14pxMedium 字重
- 颜色rgba(0, 0, 0, 0.88)
- 整体内边距4px 8px
- 悬停背景rgba(0, 0, 0, 0.03)
- 点击显示下拉菜单
**间距**:各元素之间间距 16-20px
---
## 3. 内容区域 (Content)
### 3.1 基础规范
| 属性 | 值 |
|------|---|
| 背景色 | #f5f5f5 |
| 内边距 | 24px |
| 高度 | calc(100vh - 64px) |
| 滚动 | overflow-y: auto |
### 3.2 内容容器
- 最大宽度:根据业务需求,建议 1200-1600px
- 内边距24px
- 背景色:根据内容类型,卡片为 #fff
### 3.3 滚动行为
- 仅内容区域可滚动
- 顶部导航栏和侧边栏保持固定
- 滚动条样式与全局一致
---
## 4. 响应式适配
### 4.1 断点规则
| 断点 | 行为 |
|------|------|
| < 768px | |
| ≥ 768px | 侧边栏可正常展开/收起 |
| ≥ 1200px | 建议默认展开侧边栏 |
### 4.2 移动端优化
- 侧边栏改为抽屉模式 (Drawer)
- 顶部搜索框宽度减小或移至下拉菜单
- 功能链接收起至"更多"菜单
- 用户信息简化显示
---
## 5. 主题配色
### 5.1 品牌主色
基于 NEX Logo 的紫红色:
```css
--primary-color: #b8178d;
--primary-hover: #9c1477;
--primary-active: #801161;
```
### 5.2 辅助色
- **蓝色**(信息色):#1677ff
- **绿色**(成功):#52c41a
- **红色**(错误/警告):#ff4d4f
- **橙色**(警告):#faad14
### 5.3 中性色
```css
--text-primary: rgba(0, 0, 0, 0.88);
--text-secondary: rgba(0, 0, 0, 0.65);
--text-tertiary: rgba(0, 0, 0, 0.45);
--bg-primary: #ffffff;
--bg-secondary: #fafafa;
--bg-tertiary: #f5f5f5;
--border-color: #d9d9d9;
```
---
## 6. 交互规范
### 6.1 侧边栏折叠
**触发方式**
- 点击顶部折叠按钮
- 可选:在设置中保存用户偏好
**动画**
- 过渡时间200ms
- 缓动函数ease-in-out
- 影响元素侧边栏宽度、Logo、菜单文字
**状态保持**
- 使用 localStorage 保存用户折叠状态
- 页面刷新后保持用户选择
### 6.2 菜单导航
**展开逻辑**
- 点击一级菜单展开/收起二级菜单
- 默认展开当前路由所在的菜单组
- 支持手风琴模式(可选)
**高亮逻辑**
- 根据当前路由自动高亮对应菜单项
- 二级菜单选中时,一级菜单也显示激活状态
**跳转方式**
- 使用 React Router 进行路由跳转
- 支持浏览器前进/后退
### 6.3 用户下拉菜单
**菜单项**
- 个人中心
- 账户设置
- 分割线
- 退出登录
**交互**
- 点击用户信息区域展开
- 点击菜单项执行对应操作
- 点击外部区域关闭
---
## 7. 代码实现
### 7.1 组件结构
```
MainLayout/
├── MainLayout.jsx # 主布局组件
├── MainLayout.css # 布局样式
├── AppSider.jsx # 侧边栏组件
├── AppSider.css # 侧边栏样式
├── AppHeader.jsx # 顶部栏组件
├── AppHeader.css # 顶部栏样式
└── index.js # 导出文件
```
### 7.2 菜单数据配置
菜单数据独立维护在 `src/constants/menuData.json`,便于更新和管理。
### 7.3 关键技术点
1. **状态管理**
- collapsed 状态通过 props 传递
- 菜单展开状态 (openKeys) 在 AppSider 内部管理
2. **路由集成**
- 使用 useNavigate 进行路由跳转
- 使用 useLocation 获取当前路由
3. **图标映射**
- 通过 iconMap 对象将字符串转换为图标组件
4. **主题定制**
- 在 src/main.jsx 中配置 Ant Design 主题
- 使用 ConfigProvider 包裹应用
---
## 8. 示例页面
### 8.1 概览页 (Overview)
作为主框架的示例页面,展示了:
- 统计卡片布局
- 图表展示
- 数据可视化
- 响应式栅格系统
详细设计见:[概览页设计文档](./overview.md)
---
## 9. 可访问性
### 9.1 键盘导航
- 支持 Tab 键在可交互元素间切换
- 支持 Enter 键激活菜单项
- 支持方向键在菜单间导航
### 9.2 语义化标签
- 使用 nav 标签包裹导航菜单
- 使用 header 标签包裹顶部栏
- 使用 main 标签包裹主内容区
### 9.3 对比度
- 所有文本与背景对比度 ≥ 4.5:1
- 图标与背景对比度 ≥ 3:1
---
## 10. 性能优化
### 10.1 懒加载
- 页面组件使用 React.lazy 懒加载
- 减少首屏加载时间
### 10.2 防抖优化
- 搜索框输入使用防抖处理
- 窗口大小变化使用节流处理
### 10.3 虚拟滚动
- 菜单项较多时考虑虚拟滚动
- 长列表使用虚拟化技术
---
## 11. 开发指南
### 11.1 添加新菜单
1. 编辑 `src/constants/menuData.json`
2. 添加菜单项配置
3. 如需新图标,在 `AppSider.jsx` 的 iconMap 中添加映射
4. 创建对应的页面组件
5. 在 `App.jsx` 中添加路由
### 11.2 自定义主题
1. 编辑 `src/main.jsx` 中的 theme 配置
2. 修改 `tailwind.config.js` 中的颜色系统
3. 更新 `src/styles/globals.css` 中的 CSS 变量
### 11.3 扩展功能
- 添加面包屑导航
- 添加页签 (Tabs) 功能
- 添加全局设置抽屉
- 添加主题切换(亮色/暗色)
---
## 12. 常见问题
### Q1: 如何修改侧边栏默认展开状态?
`MainLayout.jsx` 中修改 `collapsed` 的初始值:
```jsx
const [collapsed, setCollapsed] = useState(false) // false 为展开
```
### Q2: 如何添加三级菜单?
当前设计仅支持两级菜单。如需三级菜单,需要:
1. 修改 menuData.json 数据结构
2. 修改 AppSider.jsx 中的 getMenuItems 函数
3. 考虑 UI 空间和用户体验
### Q3: 如何实现菜单权限控制?
建议:
1. 在菜单数据中添加 `roles``permissions` 字段
2. 在渲染菜单前根据用户权限过滤
3. 在路由层面也要做权限校验
---
## 版本记录
| 版本 | 日期 | 说明 |
|------|------|------|
| 1.0.0 | 2024-11-04 | 初始版本,完成主框架设计 |
---
**维护者**: Nex Design Team
**最后更新**: 2024-11-04

13
index.html 100644
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nex Design - 设计规范系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

8013
package-lock.json generated 100644

File diff suppressed because it is too large Load Diff

40
package.json 100644
View File

@ -0,0 +1,40 @@
{
"name": "nex-design",
"version": "1.0.0",
"description": "面向 React + Ant Design + Tailwind CSS 的前端设计语言规范",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@ant-design/icons": "^5.2.6",
"antd": "^5.12.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.20.0",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"vite": "^5.0.8"
},
"engines": {
"node": ">=16.0.0",
"yarn": ">=1.22.0"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

25
src/App.jsx 100644
View File

@ -0,0 +1,25 @@
import { Routes, Route } from 'react-router-dom'
import MainLayout from './components/MainLayout'
import OverviewPage from './pages/OverviewPage'
import HostListPage from './pages/HostListPage'
import UserListPage from './pages/UserListPage'
import ImageListPage from './pages/ImageListPage'
import DocsPage from './pages/DocsPage'
function App() {
return (
<MainLayout>
<Routes>
<Route path="/" element={<OverviewPage />} />
<Route path="/overview" element={<OverviewPage />} />
<Route path="/host/list" element={<HostListPage />} />
<Route path="/user/list" element={<UserListPage />} />
<Route path="/image/list" element={<ImageListPage />} />
<Route path="/design" element={<DocsPage />} />
{/* 其他路由将在后续添加 */}
</Routes>
</MainLayout>
)
}
export default App

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,138 @@
import { Modal } from 'antd'
import { ExclamationCircleOutlined, DeleteOutlined } from '@ant-design/icons'
/**
* 标准确认对话框组件
* @param {Object} options - 对话框配置
* @param {string} options.title - 标题
* @param {string|ReactNode} options.content - 内容
* @param {string} options.okText - 确认按钮文字
* @param {string} options.cancelText - 取消按钮文字
* @param {string} options.type - 类型: 'warning', 'danger', 'info'
* @param {Function} options.onOk - 确认回调
* @param {Function} options.onCancel - 取消回调
*/
const ConfirmDialog = {
/**
* 显示删除确认对话框单个项目
*/
delete: ({ title = '确认删除', itemName, itemInfo, onOk, onCancel }) => {
Modal.confirm({
title,
content: (
<div>
<p>您确定要删除以下项目吗</p>
<div style={{ marginTop: 12, padding: 12, background: '#f5f5f5', borderRadius: 6 }}>
<p style={{ margin: 0, fontWeight: 500 }}>{itemName}</p>
{itemInfo && (
<p style={{ margin: '4px 0 0 0', fontSize: 13, color: '#666' }}>{itemInfo}</p>
)}
</div>
<p style={{ marginTop: 12, color: '#ff4d4f', fontSize: 13 }}>
此操作不可恢复请谨慎操作
</p>
</div>
),
okText: '确认删除',
cancelText: '取消',
okType: 'danger',
centered: true,
icon: <DeleteOutlined style={{ color: '#ff4d4f' }} />,
onOk,
onCancel,
})
},
/**
* 显示批量删除确认对话框
*/
batchDelete: ({ count, items, onOk, onCancel }) => {
Modal.confirm({
title: '批量删除确认',
content: (
<div>
<p>您确定要删除选中的 {count} 个项目吗</p>
<div
style={{
marginTop: 12,
padding: 12,
background: '#f5f5f5',
borderRadius: 6,
maxHeight: 200,
overflowY: 'auto',
}}
>
{items.map((item, index) => (
<div
key={index}
style={{
padding: '6px 0',
borderBottom: index < items.length - 1 ? '1px solid #e8e8e8' : 'none',
}}
>
<span style={{ fontWeight: 500 }}>{item.name}</span>
{item.info && (
<span style={{ marginLeft: 12, fontSize: 13, color: '#666' }}>
({item.info})
</span>
)}
</div>
))}
</div>
<p style={{ marginTop: 12, color: '#ff4d4f', fontSize: 13 }}>
此操作不可恢复请谨慎操作
</p>
</div>
),
okText: '确认删除',
cancelText: '取消',
okType: 'danger',
centered: true,
icon: <DeleteOutlined style={{ color: '#ff4d4f' }} />,
onOk,
onCancel,
})
},
/**
* 显示警告确认对话框
*/
warning: ({ title, content, okText = '确定', cancelText = '取消', onOk, onCancel }) => {
Modal.confirm({
title,
content,
okText,
cancelText,
centered: true,
icon: <ExclamationCircleOutlined style={{ color: '#faad14' }} />,
onOk,
onCancel,
})
},
/**
* 显示通用确认对话框
*/
confirm: ({
title,
content,
okText = '确定',
cancelText = '取消',
okType = 'primary',
onOk,
onCancel,
}) => {
Modal.confirm({
title,
content,
okText,
cancelText,
okType,
centered: true,
onOk,
onCancel,
})
},
}
export default ConfirmDialog

View File

@ -0,0 +1,120 @@
/* 详情抽屉容器 */
.detail-drawer-content {
height: 100%;
display: flex;
flex-direction: column;
}
/* 顶部信息区域 - 固定不滚动 */
.detail-drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
}
.detail-drawer-header-left {
display: flex;
align-items: center;
gap: 16px;
}
.detail-drawer-close-button {
font-size: 18px;
color: #666;
}
.detail-drawer-close-button:hover {
color: #1677ff;
}
.detail-drawer-header-info {
display: flex;
align-items: center;
gap: 12px;
}
.detail-drawer-title-icon {
font-size: 18px;
color: #1677ff;
}
.detail-drawer-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.detail-drawer-badge {
display: flex;
align-items: center;
}
.detail-drawer-header-right {
flex: 1;
display: flex;
justify-content: flex-end;
}
/* 可滚动内容区域 */
.detail-drawer-scrollable-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* 标签页区域 */
.detail-drawer-tabs {
background: #ffffff;
padding-top: 16px;
padding-left: 12px;
min-height: 400px;
}
.detail-drawer-tabs :global(.ant-tabs) {
height: 100%;
}
.detail-drawer-tabs :global(.ant-tabs-content-holder) {
overflow: visible;
}
.detail-drawer-tabs :global(.ant-tabs-nav) {
padding: 0;
margin: 0 24px;
margin-bottom: 0;
background: transparent;
}
.detail-drawer-tabs :global(.ant-tabs-nav::before) {
border-bottom: 1px solid #f0f0f0;
}
.detail-drawer-tabs :global(.ant-tabs-tab) {
padding: 12px 0;
margin: 0 32px 0 0;
font-size: 14px;
font-weight: 500;
}
.detail-drawer-tabs :global(.ant-tabs-tab:first-child) {
margin-left: 0;
}
.detail-drawer-tabs :global(.ant-tabs-tab-active .ant-tabs-tab-btn) {
color: #d946ef;
}
.detail-drawer-tabs :global(.ant-tabs-ink-bar) {
background: #d946ef;
height: 3px;
}
.detail-drawer-tab-content {
padding: 24px;
background: #ffffff;
}

View File

@ -0,0 +1,97 @@
import { Drawer, Button, Space, Tabs } from 'antd'
import { CloseOutlined } from '@ant-design/icons'
import './DetailDrawer.css'
/**
* 详情抽屉组件
* @param {Object} props
* @param {boolean} props.visible - 是否显示抽屉
* @param {Function} props.onClose - 关闭回调
* @param {Object} props.title - 标题配置
* @param {string} props.title.text - 标题文本
* @param {ReactNode} props.title.badge - 状态徽标可选
* @param {ReactNode} props.title.icon - 图标可选
* @param {Array} props.headerActions - 顶部操作按钮
* @param {number} props.width - 抽屉宽度
* @param {ReactNode} props.children - 主要内容
* @param {Array} props.tabs - 标签页配置可选
*/
function DetailDrawer({
visible,
onClose,
title,
headerActions = [],
width = 1080,
children,
tabs,
}) {
return (
<Drawer
title={null}
placement="right"
width={width}
onClose={onClose}
open={visible}
closable={false}
styles={{ body: { padding: 0 } }}
>
<div className="detail-drawer-content">
{/* 顶部标题栏 - 固定不滚动 */}
<div className="detail-drawer-header">
<div className="detail-drawer-header-left">
<Button
type="text"
icon={<CloseOutlined />}
onClick={onClose}
className="detail-drawer-close-button"
/>
<div className="detail-drawer-header-info">
{title?.icon && <span className="detail-drawer-title-icon">{title.icon}</span>}
<h2 className="detail-drawer-title">{title?.text}</h2>
{title?.badge && <span className="detail-drawer-badge">{title.badge}</span>}
</div>
</div>
<div className="detail-drawer-header-right">
<Space size="middle">
{headerActions.map((action) => (
<Button
key={action.key}
type={action.type || 'default'}
icon={action.icon}
danger={action.danger}
disabled={action.disabled}
onClick={action.onClick}
>
{action.label}
</Button>
))}
</Space>
</div>
</div>
{/* 可滚动内容区域 */}
<div className="detail-drawer-scrollable-content">
{children}
{/* 可选的标签页区域 */}
{tabs && tabs.length > 0 && (
<div className="detail-drawer-tabs">
<Tabs
defaultActiveKey={tabs[0].key}
type="line"
size="large"
items={tabs.map((tab) => ({
key: tab.key,
label: tab.label,
children: <div className="detail-drawer-tab-content">{tab.content}</div>,
}))}
/>
</div>
)}
</div>
</div>
</Drawer>
)
}
export default DetailDrawer

View File

@ -0,0 +1,94 @@
/* 信息面板 */
.info-panel {
padding: 6px 8px;
background: #ffffff;
}
/* 信息区域容器 */
.info-panel > :global(.ant-row) {
padding: 32px;
background: #ffffff;
border-bottom: 1px solid #f0f0f0;
}
.info-panel-item {
display: flex;
flex-direction: column;
gap: 5px;
padding: 10px 0;
border-bottom: 1px solid #f0f0f0;
transition: all 0.2s ease;
position: relative;
}
.info-panel-item:last-child {
border-bottom: none;
}
/* 添加底部装饰条 */
.info-panel-item::before {
content: '';
position: absolute;
left: 3px;
bottom: -1px;
width: 0;
height: 3px;
background: linear-gradient(90deg, #1677ff 0%, #4096ff 100%);
border-radius: 2px;
transition: width 0.3s ease;
}
.info-panel-item:hover {
background: linear-gradient(90deg, #f0f7ff 0%, transparent 100%);
padding-left: 10px;
padding-right: 16px;
margin-left: -12px;
margin-right: -16px;
border-radius: 8px;
border-bottom-color: transparent;
}
.info-panel-item:hover::before {
width: 60px;
}
.info-panel-label {
color: rgba(0, 0, 0, 0.45);
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
}
.info-panel-value {
color: rgba(0, 0, 0, 0.88);
font-size: 15px;
font-weight: 500;
word-break: break-all;
line-height: 1.6;
}
/* 操作按钮区 */
.info-panel-actions {
padding: 24px 32px;
background: linear-gradient(to bottom, #fafafa 0%, #f5f5f5 100%);
border-top: 2px solid #e8e8e8;
position: relative;
}
/* 操作区域顶部装饰线 */
.info-panel-actions::before {
content: '';
position: absolute;
top: -2px;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, #1677ff 0%, transparent 50%, #1677ff 100%);
opacity: 0.3;
}

View File

@ -0,0 +1,58 @@
import { Row, Col, Space, Button } from 'antd'
import './InfoPanel.css'
/**
* 信息展示面板组件
* @param {Object} props
* @param {Object} props.data - 数据源
* @param {Array} props.fields - 字段配置数组
* @param {Array} props.actions - 操作按钮配置可选
* @param {Array} props.gutter - Grid间距配置
*/
function InfoPanel({ data, fields = [], actions = [], gutter = [24, 16] }) {
if (!data) {
return null
}
return (
<div className="info-panel">
<Row gutter={gutter}>
{fields.map((field) => {
const value = data[field.key]
const displayValue = field.render ? field.render(value, data) : value
return (
<Col key={field.key} span={field.span || 6}>
<div className="info-panel-item">
<div className="info-panel-label">{field.label}</div>
<div className="info-panel-value">{displayValue}</div>
</div>
</Col>
)
})}
</Row>
{/* 可选的操作按钮区 */}
{actions && actions.length > 0 && (
<div className="info-panel-actions">
<Space size="middle">
{actions.map((action) => (
<Button
key={action.key}
type={action.type || 'default'}
icon={action.icon}
disabled={action.disabled}
danger={action.danger}
onClick={action.onClick}
>
{action.label}
</Button>
))}
</Space>
</div>
)}
</div>
)
}
export default InfoPanel

View File

@ -0,0 +1,49 @@
.list-action-bar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 16px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.list-action-bar-left,
.list-action-bar-right {
display: flex;
gap: 12px;
align-items: center;
}
/* 搜索和筛选组合 */
.list-action-bar-right :global(.ant-space-compact) {
display: flex;
}
.list-action-bar-right :global(.ant-space-compact .ant-input-search) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.list-action-bar-right :global(.ant-space-compact > .ant-btn) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
/* 响应式 */
@media (max-width: 768px) {
.list-action-bar {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.list-action-bar-left,
.list-action-bar-right {
flex-wrap: wrap;
}
}

View File

@ -0,0 +1,86 @@
import { Button, Input, Space, Popover } from 'antd'
import { ReloadOutlined, FilterOutlined } from '@ant-design/icons'
import './ListActionBar.css'
const { Search } = Input
/**
* 列表操作栏组件
* @param {Object} props
* @param {Array} props.actions - 左侧操作按钮配置数组
* @param {Object} props.search - 搜索配置
* @param {Object} props.filter - 高级筛选配置可选
* @param {boolean} props.showRefresh - 是否显示刷新按钮
* @param {Function} props.onRefresh - 刷新回调
*/
function ListActionBar({
actions = [],
search,
filter,
showRefresh = false,
onRefresh,
}) {
return (
<div className="list-action-bar">
{/* 左侧操作按钮区 */}
<div className="list-action-bar-left">
{actions.map((action) => (
<Button
key={action.key}
type={action.type || 'default'}
icon={action.icon}
disabled={action.disabled}
danger={action.danger}
onClick={action.onClick}
>
{action.label}
</Button>
))}
</div>
{/* 右侧搜索筛选区 */}
<div className="list-action-bar-right">
<Space.Compact>
<Search
placeholder={search?.placeholder || '请输入搜索关键词'}
allowClear
style={{ width: search?.width || 280 }}
onSearch={search?.onSearch}
onChange={(e) => search?.onChange?.(e.target.value)}
value={search?.value}
/>
{filter && (
<Popover
content={filter.content}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<FilterOutlined />
<span>{filter.title || '高级筛选'}</span>
</div>
}
trigger="click"
open={filter.visible}
onOpenChange={filter.onVisibleChange}
placement="bottomRight"
overlayClassName="filter-popover"
>
<Button
icon={<FilterOutlined />}
type={filter.isActive ? 'primary' : 'default'}
>
{filter.selectedLabel || '筛选'}
</Button>
</Popover>
)}
</Space.Compact>
{showRefresh && (
<Button icon={<ReloadOutlined />} onClick={onRefresh}>
刷新
</Button>
)}
</div>
</div>
)
}
export default ListActionBar

View File

@ -0,0 +1,43 @@
/* 列表表格容器 */
.list-table-container {
background: #ffffff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
/* 表格行样式 */
.list-table-container :global(.ant-table-row) {
cursor: pointer;
transition: all 0.3s;
}
.list-table-container :global(.ant-table-row:hover) {
background: #f5f5f5;
}
.list-table-container :global(.ant-table-row.row-selected) {
background: #e6f4ff;
}
/* 操作列样式 - 重新设计 */
.list-table-container :global(.ant-table-thead > tr > th:last-child) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
color: #ffffff !important;
font-weight: 600;
border-left: 2px solid #e8e8e8;
}
.list-table-container :global(.ant-table-tbody > tr > td:last-child) {
background: #f8f9ff !important;
border-left: 2px solid #e8e8e8;
box-shadow: -2px 0 4px rgba(0, 0, 0, 0.02);
}
.list-table-container :global(.ant-table-tbody > tr:hover > td:last-child) {
background: #eef0ff !important;
}
.list-table-container :global(.ant-table-tbody > tr.row-selected > td:last-child) {
background: #e1e6ff !important;
}

View File

@ -0,0 +1,67 @@
import { Table } from 'antd'
import './ListTable.css'
/**
* 列表表格组件
* @param {Object} props
* @param {Array} props.columns - 表格列配置
* @param {Array} props.dataSource - 数据源
* @param {string} props.rowKey - 行唯一标识字段
* @param {Array} props.selectedRowKeys - 选中的行
* @param {Function} props.onSelectionChange - 选择变化回调
* @param {Object} props.pagination - 分页配置
* @param {Object} props.scroll - 表格滚动配置
* @param {Function} props.onRowClick - 行点击回调
* @param {Object} props.selectedRow - 当前选中的行
* @param {boolean} props.loading - 加载状态
* @param {string} props.className - 自定义类名
*/
function ListTable({
columns,
dataSource,
rowKey = 'id',
selectedRowKeys = [],
onSelectionChange,
pagination = {
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
},
scroll = { x: 1200 },
onRowClick,
selectedRow,
loading = false,
className = '',
}) {
//
const rowSelection = {
selectedRowKeys,
onChange: (newSelectedRowKeys) => {
onSelectionChange?.(newSelectedRowKeys)
},
}
return (
<div className={`list-table-container ${className}`}>
<Table
rowSelection={rowSelection}
columns={columns}
dataSource={dataSource}
rowKey={rowKey}
pagination={{
...pagination,
total: dataSource?.length || 0,
}}
scroll={scroll}
loading={loading}
onRow={(record) => ({
onClick: () => onRowClick?.(record),
className: selectedRow?.[rowKey] === record[rowKey] ? 'row-selected' : '',
})}
/>
</div>
)
}
export default ListTable

View File

@ -0,0 +1,129 @@
.app-header {
background: #fff;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
height: 64px;
border-bottom: 1px solid #f0f0f0;
}
/* 左侧区域 */
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
/* Logo 区域 */
.header-logo {
display: flex;
align-items: center;
justify-content: center;
width: 168px;
transition: width 0.2s;
}
.logo-small {
width: 40px;
height: 40px;
border-radius: 8px;
transition: all 0.2s;
}
.logo-full {
height: 32px;
width: auto;
transition: all 0.2s;
}
.trigger {
font-size: 18px;
cursor: pointer;
transition: color 0.3s;
padding: 8px;
border-radius: 4px;
color: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
}
.trigger:hover {
color: #b8178d;
background: rgba(184, 23, 141, 0.06);
}
/* 右侧区域 */
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.header-search {
border-radius: 16px;
}
.header-actions {
display: flex;
align-items: center;
}
.header-icon {
font-size: 16px;
color: rgba(0, 0, 0, 0.65);
cursor: pointer;
transition: all 0.3s;
padding: 8px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.header-icon:hover {
color: #b8178d;
background: rgba(184, 23, 141, 0.06);
}
.header-link {
font-size: 14px;
color: rgba(0, 0, 0, 0.65);
cursor: pointer;
transition: all 0.3s;
padding: 6px 12px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 4px;
}
.header-link:hover {
color: #b8178d;
background: rgba(184, 23, 141, 0.06);
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.3s;
}
.user-info:hover {
background: rgba(184, 23, 141, 0.06);
}
.username {
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
font-weight: 500;
}
.ml-1 {
margin-left: 4px;
}

View File

@ -0,0 +1,128 @@
import { Layout, Input, Badge, Avatar, Dropdown, Space } from 'antd'
import { useNavigate } from 'react-router-dom'
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
SearchOutlined,
BellOutlined,
QuestionCircleOutlined,
FileTextOutlined,
CustomerServiceOutlined,
UserOutlined,
} from '@ant-design/icons'
import headerMenuData from '../../constants/headerMenuData.json'
import logoFull from '../../assets/logo-full.png'
import './AppHeader.css'
const { Header } = Layout
//
const iconMap = {
QuestionCircleOutlined: <QuestionCircleOutlined />,
FileTextOutlined: <FileTextOutlined />,
CustomerServiceOutlined: <CustomerServiceOutlined />,
}
function AppHeader({ collapsed, onToggle }) {
const navigate = useNavigate()
//
const userMenuItems = [
{
key: 'profile',
label: '个人中心',
},
{
key: 'settings',
label: '账户设置',
},
{
type: 'divider',
},
{
key: 'logout',
label: '退出登录',
},
]
const handleUserMenuClick = ({ key }) => {
if (key === 'logout') {
console.log('退出登录')
}
}
const handleHeaderMenuClick = (key) => {
console.log('Header menu clicked:', key)
//
if (key === 'docs') {
navigate('/design')
}
}
return (
<Header className="app-header">
{/* 左侧Logo + 折叠按钮 */}
<div className="header-left">
{/* Logo 区域 */}
<div className="header-logo">
<img src={logoFull} alt="NEX Console" className="logo-full" />
</div>
{/* 折叠按钮 */}
<div className="trigger" onClick={onToggle}>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
</div>
{/* 右侧:搜索 + 功能按钮 + 用户信息 */}
<div className="header-right">
{/* 搜索框 */}
<Input
className="header-search"
placeholder="搜索..."
prefix={<SearchOutlined />}
style={{ width: 200 }}
/>
{/* 功能图标 */}
<Space size={16} className="header-actions">
{/* 动态渲染 header 菜单 */}
{headerMenuData.map((item) => (
<div
key={item.key}
className="header-link"
title={item.label}
onClick={() => handleHeaderMenuClick(item.key)}
>
{iconMap[item.icon]}
<span className="ml-1">{item.label}</span>
</div>
))}
{/* 消息中心 */}
<Badge count={5} size="small" offset={[-3, 3]}>
<div className="header-icon" title="消息中心">
<BellOutlined />
</div>
</Badge>
{/* 用户下拉菜单 */}
<Dropdown
menu={{
items: userMenuItems,
onClick: handleUserMenuClick,
}}
placement="bottomRight"
>
<div className="user-info">
<Avatar size={32} icon={<UserOutlined />} />
<span className="username">Admin</span>
</div>
</Dropdown>
</Space>
</div>
</Header>
)
}
export default AppHeader

View File

@ -0,0 +1,96 @@
.app-sider {
height: 100%;
overflow: auto;
background: #fafafa;
border-right: 1px solid #f0f0f0;
transition: all 0.2s;
}
.app-sider::-webkit-scrollbar {
width: 6px;
}
.app-sider::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 3px;
}
.app-sider::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.2);
}
/* 菜单样式 */
.sider-menu {
border-right: none;
padding-top: 8px;
background: #fafafa;
}
/* 收起状态下的图标放大 */
:global(.ant-layout-sider-collapsed) .sider-menu :global(.ant-menu-item) {
padding: 0 !important;
display: flex;
align-items: center;
justify-content: center;
height: 56px;
margin: 8px 0;
}
/* 收起状态下的 SubMenu 样式 */
:global(.ant-layout-sider-collapsed) .sider-menu :global(.ant-menu-submenu) {
padding: 0 !important;
}
:global(.ant-layout-sider-collapsed) .sider-menu :global(.ant-menu-submenu-title) {
padding: 0 !important;
display: flex;
align-items: center;
justify-content: center;
height: 56px;
margin: 8px 0;
}
:global(.ant-layout-sider-collapsed) .sider-menu :global(.anticon) {
font-size: 24px;
margin: 0;
}
/* 收起状态下的 Tooltip */
:global(.ant-layout-sider-collapsed) .sider-menu :global(.ant-menu-item-icon) {
font-size: 24px;
}
:global(.ant-layout-sider-collapsed) .sider-menu :global(.ant-menu-submenu-title) :global(.anticon) {
font-size: 24px;
margin: 0;
}
/* 菜单项徽章 */
.menu-item-with-badge {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.menu-badge {
font-size: 10px;
height: 18px;
line-height: 18px;
border-radius: 9px;
padding: 0 6px;
margin-left: 8px;
}
.badge-hot :global(.ant-badge-count) {
background: #ff4d4f;
}
.badge-new :global(.ant-badge-count) {
background: #52c41a;
}
/* 收起状态下隐藏徽章 */
:global(.ant-layout-sider-collapsed) .menu-badge {
display: none;
}

View File

@ -0,0 +1,172 @@
import { useState, useEffect } from 'react'
import { Layout, Menu, Badge, Tooltip } from 'antd'
import { useNavigate, useLocation } from 'react-router-dom'
import {
DashboardOutlined,
GlobalOutlined,
CloudServerOutlined,
UserOutlined,
AppstoreOutlined,
SettingOutlined,
} from '@ant-design/icons'
import menuData from '../../constants/menuData.json'
import './AppSider.css'
const { Sider } = Layout
const { SubMenu } = Menu
//
const iconMap = {
DashboardOutlined: DashboardOutlined,
GlobalOutlined: GlobalOutlined,
CloudServerOutlined: CloudServerOutlined,
UserOutlined: UserOutlined,
AppstoreOutlined: AppstoreOutlined,
SettingOutlined: SettingOutlined,
}
function AppSider({ collapsed, onToggle }) {
const navigate = useNavigate()
const location = useLocation()
const [openKeys, setOpenKeys] = useState([])
//
const getDefaultOpenKeys = () => {
const path = location.pathname
for (const item of menuData) {
if (item.children) {
const hasChild = item.children.some((c) => c.path === path)
if (hasChild) {
return [item.key]
}
}
}
return []
}
//
useEffect(() => {
if (!collapsed) {
const defaultKeys = getDefaultOpenKeys()
setOpenKeys(defaultKeys)
}
}, [location.pathname, collapsed])
const handleMenuClick = ({ key }) => {
//
for (const item of menuData) {
if (item.key === key && item.path) {
navigate(item.path)
return
}
if (item.children) {
const child = item.children.find((c) => c.key === key)
if (child) {
navigate(child.path)
return
}
}
}
}
const handleOpenChange = (keys) => {
setOpenKeys(keys)
}
//
const getSelectedKey = () => {
const path = location.pathname
for (const item of menuData) {
if (item.path === path) return item.key
if (item.children) {
const child = item.children.find((c) => c.path === path)
if (child) return child.key
}
}
return 'overview'
}
//
const renderMenuItems = () => {
return menuData.map((item) => {
const IconComponent = iconMap[item.icon]
const icon = IconComponent ? <IconComponent /> : null
//
if (item.children) {
// Tooltip SubMenu
const subMenuIcon = collapsed ? (
<Tooltip title={item.label} placement="right">
{icon}
</Tooltip>
) : (
icon
)
return (
<SubMenu
key={item.key}
icon={subMenuIcon}
title={item.label}
popupClassName="sider-submenu-popup"
>
{item.children.map((child) => (
<Menu.Item key={child.key}>
{child.badge ? (
<span className="menu-item-with-badge">
{child.label}
<Badge
count={child.badge}
className={`menu-badge ${child.badge === 'HOT' ? 'badge-hot' : 'badge-new'}`}
/>
</span>
) : (
child.label
)}
</Menu.Item>
))}
</SubMenu>
)
}
// - Tooltip
const menuIcon = collapsed ? (
<Tooltip title={item.label} placement="right">
{icon}
</Tooltip>
) : (
icon
)
return (
<Menu.Item key={item.key} icon={menuIcon} title="">
{item.label}
</Menu.Item>
)
})
}
return (
<Sider
className="app-sider"
collapsed={collapsed}
width={200}
collapsedWidth={64}
trigger={null}
>
{/* 菜单 */}
<Menu
mode="inline"
selectedKeys={[getSelectedKey()]}
openKeys={collapsed ? [] : openKeys}
onOpenChange={handleOpenChange}
onClick={handleMenuClick}
className="sider-menu"
>
{renderMenuItems()}
</Menu>
</Sider>
)
}
export default AppSider

View File

@ -0,0 +1,24 @@
.main-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
background: #fafafa;
}
.main-content-wrapper {
display: flex;
flex: 1;
height: calc(100vh - 64px);
background: #fafafa;
}
.main-content {
background: #f5f5f5;
overflow-y: auto;
flex: 1;
}
.content-wrapper {
padding: 24px;
min-height: 100%;
}

View File

@ -0,0 +1,31 @@
import { useState } from 'react'
import { Layout } from 'antd'
import AppSider from './AppSider'
import AppHeader from './AppHeader'
import './MainLayout.css'
const { Content } = Layout
function MainLayout({ children }) {
const [collapsed, setCollapsed] = useState(false)
const toggleCollapsed = () => {
setCollapsed(!collapsed)
}
return (
<Layout className="main-layout">
<AppHeader collapsed={collapsed} onToggle={toggleCollapsed} />
<Layout className="main-content-wrapper">
<AppSider collapsed={collapsed} onToggle={toggleCollapsed} />
<Content className="main-content">
<div className="content-wrapper">
{children}
</div>
</Content>
</Layout>
</Layout>
)
}
export default MainLayout

View File

@ -0,0 +1,4 @@
export { default } from './MainLayout'
export { default as MainLayout } from './MainLayout'
export { default as AppSider } from './AppSider'
export { default as AppHeader } from './AppHeader'

View File

@ -0,0 +1,109 @@
.page-header-standard {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
padding: 24px 28px;
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
position: relative;
overflow: hidden;
}
.page-header-standard::before {
content: '';
position: absolute;
top: -50%;
right: -10%;
width: 300px;
height: 300px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
}
.page-header-main {
display: flex;
align-items: center;
gap: 16px;
position: relative;
z-index: 1;
}
.back-button {
width: 36px;
height: 36px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
font-size: 16px;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateX(-2px);
}
.page-header-content {
display: flex;
align-items: center;
gap: 16px;
}
.page-header-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.page-header-text {
display: flex;
flex-direction: column;
gap: 4px;
}
.page-header-title {
font-size: 22px;
font-weight: 600;
color: #ffffff;
margin: 0;
letter-spacing: 0.3px;
}
.page-header-description {
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
margin: 0;
line-height: 1.5;
}
.page-header-extra {
position: relative;
z-index: 1;
}
@media (max-width: 768px) {
.page-header-standard {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.page-header-extra {
width: 100%;
}
}

View File

@ -0,0 +1,35 @@
import { ArrowLeftOutlined } from '@ant-design/icons'
import './PageHeader.css'
function PageHeader({
title,
description,
icon,
showBack = false,
onBack,
extra
}) {
return (
<div className="page-header-standard">
<div className="page-header-main">
{showBack && (
<button className="back-button" onClick={onBack}>
<ArrowLeftOutlined />
</button>
)}
<div className="page-header-content">
{icon && <div className="page-header-icon">{icon}</div>}
<div className="page-header-text">
<h1 className="page-header-title">{title}</h1>
{description && (
<p className="page-header-description">{description}</p>
)}
</div>
</div>
</div>
{extra && <div className="page-header-extra">{extra}</div>}
</div>
)
}
export default PageHeader

View File

@ -0,0 +1,165 @@
.page-title-bar {
background: linear-gradient(135deg, #e0e7ff 0%, #f3e8ff 100%);
border-radius: 12px;
padding: 16px 24px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
position: relative;
overflow: hidden;
border: 1px solid rgba(139, 92, 246, 0.1);
}
.page-title-bar::before {
content: '';
position: absolute;
top: -50%;
right: -5%;
width: 200px;
height: 200px;
background: rgba(139, 92, 246, 0.05);
border-radius: 50%;
}
.title-bar-content {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
.title-bar-left {
flex: 1;
}
.title-row {
display: flex;
align-items: center;
gap: 16px;
}
.title-group {
display: flex;
align-items: center;
gap: 12px;
}
.page-title {
font-size: 20px;
font-weight: 600;
color: #1e293b;
margin: 0;
letter-spacing: 0.3px;
}
.title-badge {
background: rgba(139, 92, 246, 0.15);
color: #7c3aed;
padding: 2px 10px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
}
.page-description {
font-size: 13px;
color: #64748b;
margin: 0;
white-space: nowrap;
}
.title-bar-right {
display: flex;
align-items: center;
gap: 12px;
}
.title-actions {
display: flex;
gap: 10px;
}
.title-actions button {
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
border: none;
outline: none;
}
.title-actions button.primary {
background: #7c3aed;
color: #ffffff;
}
.title-actions button.primary:hover {
background: #6d28d9;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.25);
}
.title-actions button.secondary {
background: rgba(139, 92, 246, 0.1);
color: #7c3aed;
border: 1px solid rgba(139, 92, 246, 0.2);
}
.title-actions button.secondary:hover {
background: rgba(139, 92, 246, 0.15);
transform: translateY(-1px);
}
.toggle-button {
width: 32px;
height: 32px;
border-radius: 6px;
background: rgba(139, 92, 246, 0.1);
border: 1px solid rgba(139, 92, 246, 0.2);
color: #7c3aed;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
font-size: 14px;
}
.toggle-button:hover {
background: rgba(139, 92, 246, 0.2);
transform: translateY(-1px);
}
/* 响应式适配 */
@media (max-width: 768px) {
.title-bar-content {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.title-row {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.page-description {
white-space: normal;
}
.title-bar-right {
width: 100%;
justify-content: space-between;
}
.title-actions {
flex: 1;
}
.title-actions button {
flex: 1;
}
}

View File

@ -0,0 +1,53 @@
import { useState } from 'react'
import { UpOutlined, DownOutlined } from '@ant-design/icons'
import './PageTitleBar.css'
function PageTitleBar({
title,
badge,
description,
actions,
showToggle = false,
onToggle,
defaultExpanded = true,
}) {
const [expanded, setExpanded] = useState(defaultExpanded)
const handleToggle = () => {
const newExpanded = !expanded
setExpanded(newExpanded)
if (onToggle) {
onToggle(newExpanded)
}
}
return (
<div className="page-title-bar">
<div className="title-bar-content">
<div className="title-bar-left">
<div className="title-row">
<div className="title-group">
<h1 className="page-title">{title}</h1>
{badge && <span className="title-badge">{badge}</span>}
</div>
{description && <p className="page-description">{description}</p>}
</div>
</div>
<div className="title-bar-right">
{actions && <div className="title-actions">{actions}</div>}
{showToggle && (
<button
className="toggle-button"
onClick={handleToggle}
title={expanded ? '收起信息面板' : '展开信息面板'}
>
{expanded ? <UpOutlined /> : <DownOutlined />}
</button>
)}
</div>
</div>
</div>
)
}
export default PageTitleBar

View File

@ -0,0 +1,114 @@
import { notification } from 'antd'
import {
CheckCircleOutlined,
CloseCircleOutlined,
ExclamationCircleOutlined,
InfoCircleOutlined,
} from '@ant-design/icons'
//
notification.config({
placement: 'topRight',
top: 24,
duration: 3,
maxCount: 3,
})
/**
* 标准通知反馈组件
* 从右上角滑出默认3秒后消失
*/
const Toast = {
/**
* 成功通知
* @param {string} message - 消息内容
* @param {string} description - 详细描述可选
* @param {number} duration - 显示时长默认3秒
*/
success: (message, description = '', duration = 3) => {
notification.success({
message,
description,
duration,
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
style: {
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
},
})
},
/**
* 错误通知
* @param {string} message - 消息内容
* @param {string} description - 详细描述可选
* @param {number} duration - 显示时长默认3秒
*/
error: (message, description = '', duration = 3) => {
notification.error({
message,
description,
duration,
icon: <CloseCircleOutlined style={{ color: '#ff4d4f' }} />,
style: {
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
},
})
},
/**
* 警告通知
* @param {string} message - 消息内容
* @param {string} description - 详细描述可选
* @param {number} duration - 显示时长默认3秒
*/
warning: (message, description = '', duration = 3) => {
notification.warning({
message,
description,
duration,
icon: <ExclamationCircleOutlined style={{ color: '#faad14' }} />,
style: {
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
},
})
},
/**
* 信息通知
* @param {string} message - 消息内容
* @param {string} description - 详细描述可选
* @param {number} duration - 显示时长默认3秒
*/
info: (message, description = '', duration = 3) => {
notification.info({
message,
description,
duration,
icon: <InfoCircleOutlined style={{ color: '#1677ff' }} />,
style: {
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
},
})
},
/**
* 自定义通知
* @param {Object} config - 完整的通知配置对象
*/
custom: (config) => {
notification.open({
...config,
style: {
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
...config.style,
},
})
},
}
export default Toast

View File

@ -0,0 +1,58 @@
/* 树形筛选面板 */
.tree-filter-panel {
width: 320px;
max-height: 500px;
overflow-y: auto;
}
/* 已选择的筛选条件 */
.tree-filter-selected {
min-height: 40px;
padding: 12px;
background: #f5f7fa;
border-radius: 6px;
border: 1px dashed #d9d9d9;
}
.tree-filter-tag {
display: flex;
align-items: center;
gap: 8px;
}
.tree-filter-label {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
font-weight: 500;
}
.tree-filter-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 24px;
}
.tree-filter-placeholder span {
color: #8c8c8c;
font-size: 13px;
}
/* 树形选择器容器 */
.tree-filter-container {
max-height: 280px;
overflow-y: auto;
}
.tree-filter-header {
font-size: 14px;
font-weight: 500;
margin-bottom: 12px;
color: rgba(0, 0, 0, 0.85);
}
/* 操作按钮 */
.tree-filter-actions {
display: flex;
justify-content: flex-end;
}

View File

@ -0,0 +1,119 @@
import { Tree, Tag, Divider, Button, Space } from 'antd'
import { useState, useEffect } from 'react'
import './TreeFilterPanel.css'
/**
* 树形筛选面板组件
* @param {Object} props
* @param {Array} props.treeData - 树形数据
* @param {string} props.selectedKey - 当前选中的节点ID
* @param {string} props.tempSelectedKey - 临时选中的节点ID确认前
* @param {string} props.treeTitle - 树标题
* @param {Function} props.onSelect - 选择变化回调
* @param {Function} props.onConfirm - 确认筛选
* @param {Function} props.onClear - 清除筛选
* @param {string} props.placeholder - 占位提示文本
*/
function TreeFilterPanel({
treeData,
selectedKey,
tempSelectedKey,
treeTitle = '分组筛选',
onSelect,
onConfirm,
onClear,
placeholder = '请选择分组进行筛选',
}) {
// key
const getAllKeys = (nodes) => {
let keys = []
const traverse = (node) => {
keys.push(node.key)
if (node.children) {
node.children.forEach(traverse)
}
}
nodes.forEach(traverse)
return keys
}
const [expandedKeys, setExpandedKeys] = useState([])
//
useEffect(() => {
if (treeData && treeData.length > 0) {
setExpandedKeys(getAllKeys(treeData))
}
}, [treeData])
//
const findNodeName = (nodes, id) => {
for (const node of nodes) {
if (node.key === id) return node.title
if (node.children) {
const found = findNodeName(node.children, id)
if (found) return found
}
}
return ''
}
const handleTreeSelect = (selectedKeys) => {
const key = selectedKeys[0] || null
onSelect?.(key)
}
const handleExpand = (keys) => {
setExpandedKeys(keys)
}
return (
<div className="tree-filter-panel">
{/* 已选择的筛选条件 */}
<div className="tree-filter-selected">
{tempSelectedKey ? (
<div className="tree-filter-tag">
<span className="tree-filter-label">已选择分组</span>
<Tag color="blue" closable onClose={() => onSelect?.(null)}>
{findNodeName(treeData, tempSelectedKey)}
</Tag>
</div>
) : (
<div className="tree-filter-placeholder">
<span>{placeholder}</span>
</div>
)}
</div>
<Divider style={{ margin: '12px 0' }} />
{/* 树形选择器 */}
<div className="tree-filter-container">
<div className="tree-filter-header">{treeTitle}</div>
<Tree
treeData={treeData}
expandedKeys={expandedKeys}
onExpand={handleExpand}
onSelect={handleTreeSelect}
selectedKeys={tempSelectedKey ? [tempSelectedKey] : []}
/>
</div>
<Divider style={{ margin: '12px 0' }} />
{/* 操作按钮 */}
<div className="tree-filter-actions">
<Space>
<Button size="small" onClick={onClear}>
清除筛选
</Button>
<Button size="small" type="primary" onClick={onConfirm}>
确定
</Button>
</Space>
</div>
</div>
)
}
export default TreeFilterPanel

View File

@ -0,0 +1,75 @@
[
{
"key": "design",
"label": "设计规范",
"children": [
{
"key": "design-cookbook",
"label": "设计手册",
"path": "/docs/DESIGN_COOKBOOK.md"
}
]
},
{
"key": "components",
"label": "组件文档",
"children": [
{
"key": "components-overview",
"label": "组件概览",
"path": "/docs/components/README.md"
},
{
"key": "page-title-bar",
"label": "PageTitleBar",
"path": "/docs/components/PageTitleBar.md"
},
{
"key": "list-action-bar",
"label": "ListActionBar",
"path": "/docs/components/ListActionBar.md"
},
{
"key": "tree-filter-panel",
"label": "TreeFilterPanel",
"path": "/docs/components/TreeFilterPanel.md"
},
{
"key": "list-table",
"label": "ListTable",
"path": "/docs/components/ListTable.md"
},
{
"key": "detail-drawer",
"label": "DetailDrawer",
"path": "/docs/components/DetailDrawer.md"
},
{
"key": "info-panel",
"label": "InfoPanel",
"path": "/docs/components/InfoPanel.md"
},
{
"key": "confirm-dialog",
"label": "ConfirmDialog",
"path": "/docs/components/ConfirmDialog.md"
},
{
"key": "toast",
"label": "Toast",
"path": "/docs/components/Toast.md"
}
]
},
{
"key": "pages",
"label": "页面文档",
"children": [
{
"key": "main-layout",
"label": "主布局",
"path": "/docs/pages/main-layout.md"
}
]
}
]

View File

@ -0,0 +1,20 @@
[
{
"type": "link",
"label": "使用帮助",
"key": "help",
"icon": "QuestionCircleOutlined"
},
{
"type": "link",
"label": "设计文档",
"key": "docs",
"icon": "FileTextOutlined"
},
{
"type": "link",
"label": "支持",
"key": "support",
"icon": "CustomerServiceOutlined"
}
]

View File

@ -0,0 +1,128 @@
[
{
"key": "overview",
"label": "平台概览",
"icon": "DashboardOutlined",
"path": "/overview"
},
{
"key": "network",
"label": "网络管理",
"icon": "GlobalOutlined",
"children": [
{
"key": "network-config",
"label": "网络配置",
"path": "/network/config"
},
{
"key": "network-monitor",
"label": "流量监控",
"path": "/network/monitor"
},
{
"key": "network-security",
"label": "安全组",
"path": "/network/security"
},
{
"key": "network-vpc",
"label": "VPC 管理",
"path": "/network/vpc"
}
]
},
{
"key": "host",
"label": "终端管理",
"icon": "CloudServerOutlined",
"children": [
{
"key": "host-list",
"label": "终端列表",
"path": "/host/list"
},
{
"key": "host-monitor",
"label": "终端组管理",
"path": "/host/monitor"
}
]
},
{
"key": "user",
"label": "用户管理",
"icon": "UserOutlined",
"children": [
{
"key": "user-list",
"label": "用户列表",
"path": "/user/list"
},
{
"key": "user-role",
"label": "用户组管理",
"path": "/user/role"
}
]
},
{
"key": "image",
"label": "镜像管理",
"icon": "AppstoreOutlined",
"children": [
{
"key": "image-system",
"label": "系统镜像",
"path": "/image/list"
},
{
"key": "image-vm",
"label": "虚拟机镜像",
"path": "/image/vm"
},
{
"key": "image-desktop",
"label": "桌面镜像",
"path": "/image/desktop"
},
{
"key": "image-tools",
"label": "镜像工具",
"path": "/image/tools"
}
]
},
{
"key": "system",
"label": "系统管理",
"icon": "SettingOutlined",
"children": [
{
"key": "system-menu",
"label": "菜单管理",
"path": "/system/menu"
},
{
"key": "system-permission",
"label": "权限管理",
"path": "/system/permission"
},
{
"key": "system-dict",
"label": "字典管理",
"path": "/system/dict"
},
{
"key": "system-config",
"label": "参数配置",
"path": "/system/config"
},
{
"key": "system-log",
"label": "操作日志",
"path": "/system/log"
}
]
}
]

View File

@ -0,0 +1,198 @@
{
"hostGroups": [
{
"id": "1",
"name": "全部主机",
"children": [
{
"id": "1-1",
"name": "生产环境",
"children": [
{ "id": "1-1-1", "name": "Web服务器" },
{ "id": "1-1-2", "name": "数据库服务器" },
{ "id": "1-1-3", "name": "缓存服务器" }
]
},
{
"id": "1-2",
"name": "测试环境",
"children": [
{ "id": "1-2-1", "name": "测试服务器" },
{ "id": "1-2-2", "name": "开发服务器" }
]
},
{
"id": "1-3",
"name": "办公环境",
"children": [
{ "id": "1-3-1", "name": "行政部门" },
{ "id": "1-3-2", "name": "研发部门" },
{ "id": "1-3-3", "name": "市场部门" }
]
}
]
}
],
"hosts": [
{
"id": "1",
"name": "Web-Server-01",
"mac": "00:1B:44:11:3A:B7",
"group": "Web服务器",
"groupId": "1-1-1",
"status": "online",
"ip": "192.168.1.101",
"dataDisk": "500GB",
"systemDisk": "100GB",
"cpu": "Intel Xeon E5-2680 v4",
"memory": "32GB",
"os": "Ubuntu 20.04 LTS",
"uptime": "15天3小时",
"lastOnline": "2025-01-15 14:23:05"
},
{
"id": "2",
"name": "Web-Server-02",
"mac": "00:1B:44:11:3A:B8",
"group": "Web服务器",
"groupId": "1-1-1",
"status": "online",
"ip": "192.168.1.102",
"dataDisk": "500GB",
"systemDisk": "100GB",
"cpu": "Intel Xeon E5-2680 v4",
"memory": "32GB",
"os": "Ubuntu 20.04 LTS",
"uptime": "15天3小时",
"lastOnline": "2025-01-15 14:23:05"
},
{
"id": "3",
"name": "DB-Master-01",
"mac": "00:1B:44:11:3A:C1",
"group": "数据库服务器",
"groupId": "1-1-2",
"status": "online",
"ip": "192.168.1.201",
"dataDisk": "2TB",
"systemDisk": "200GB",
"cpu": "Intel Xeon Gold 6248R",
"memory": "128GB",
"os": "CentOS 7.9",
"uptime": "45天12小时",
"lastOnline": "2025-01-15 14:23:05"
},
{
"id": "4",
"name": "DB-Slave-01",
"mac": "00:1B:44:11:3A:C2",
"group": "数据库服务器",
"groupId": "1-1-2",
"status": "offline",
"ip": "192.168.1.202",
"dataDisk": "2TB",
"systemDisk": "200GB",
"cpu": "Intel Xeon Gold 6248R",
"memory": "128GB",
"os": "CentOS 7.9",
"uptime": "0天0小时",
"lastOnline": "2025-01-14 18:45:32"
},
{
"id": "5",
"name": "Cache-Server-01",
"mac": "00:1B:44:11:3A:D1",
"group": "缓存服务器",
"groupId": "1-1-3",
"status": "online",
"ip": "192.168.1.301",
"dataDisk": "1TB",
"systemDisk": "100GB",
"cpu": "AMD EPYC 7542",
"memory": "64GB",
"os": "Ubuntu 22.04 LTS",
"uptime": "30天8小时",
"lastOnline": "2025-01-15 14:23:05"
},
{
"id": "6",
"name": "Test-Server-01",
"mac": "00:1B:44:11:3A:E1",
"group": "测试服务器",
"groupId": "1-2-1",
"status": "online",
"ip": "192.168.2.101",
"dataDisk": "200GB",
"systemDisk": "50GB",
"cpu": "Intel Core i7-9700K",
"memory": "16GB",
"os": "Windows Server 2019",
"uptime": "5天18小时",
"lastOnline": "2025-01-15 14:23:05"
},
{
"id": "7",
"name": "Dev-Server-01",
"mac": "00:1B:44:11:3A:F1",
"group": "开发服务器",
"groupId": "1-2-2",
"status": "offline",
"ip": "192.168.2.201",
"dataDisk": "500GB",
"systemDisk": "100GB",
"cpu": "AMD Ryzen 9 5900X",
"memory": "32GB",
"os": "Ubuntu 22.04 LTS",
"uptime": "0天0小时",
"lastOnline": "2025-01-15 09:12:18"
},
{
"id": "8",
"name": "Office-PC-01",
"mac": "00:1B:44:11:3A:G1",
"group": "行政部门",
"groupId": "1-3-1",
"status": "online",
"ip": "192.168.3.101",
"dataDisk": "1TB",
"systemDisk": "256GB",
"cpu": "Intel Core i5-11400",
"memory": "16GB",
"os": "Windows 10 Pro",
"uptime": "2天6小时",
"lastOnline": "2025-01-15 14:23:05"
},
{
"id": "9",
"name": "Dev-PC-01",
"mac": "00:1B:44:11:3A:H1",
"group": "研发部门",
"groupId": "1-3-2",
"status": "online",
"ip": "192.168.3.201",
"dataDisk": "2TB",
"systemDisk": "512GB",
"cpu": "Intel Core i9-12900K",
"memory": "64GB",
"os": "Ubuntu 22.04 LTS",
"uptime": "8天14小时",
"lastOnline": "2025-01-15 14:23:05"
},
{
"id": "10",
"name": "Marketing-PC-01",
"mac": "00:1B:44:11:3A:I1",
"group": "市场部门",
"groupId": "1-3-3",
"status": "offline",
"ip": "192.168.3.301",
"dataDisk": "500GB",
"systemDisk": "256GB",
"cpu": "Intel Core i7-11700",
"memory": "32GB",
"os": "Windows 11 Pro",
"uptime": "0天0小时",
"lastOnline": "2025-01-15 12:30:22"
}
]
}

View File

@ -0,0 +1,94 @@
{
"imageGroups": [
{
"id": "1",
"name": "默认分组"
}
],
"images": [
{
"id": 1,
"name": "uos-desktop-20-professional-hwe-1070-amd64",
"os": "linux",
"version": "202412",
"status": "成功",
"uploadTime": "1759199107299",
"fileName": "uos-desktop-20-professional-hwe-1070-amd64-202412.iso",
"filePath": "/vms/iso/uos-desktop-20-professional-hwe-1070-amd64-202412.iso",
"btPath": "--",
"description": "--"
},
{
"id": 2,
"name": "Kylin-Desktop-V10-SP1-HWE-Release-2303-X86_64",
"os": "linux",
"version": "2303",
"status": "成功",
"uploadTime": "2025-10-09 09:55:41",
"fileName": "Kylin-Desktop-V10-SP1-HWE-Release-2303-X86_64.iso",
"filePath": "/vms/iso/Kylin-Desktop-V10-SP1-HWE-Release-2303-X86_64.iso",
"btPath": "--",
"description": "--"
},
{
"id": 3,
"name": "ky2203",
"os": "linux",
"version": "2203",
"status": "成功",
"uploadTime": "2025-10-13 09:41:50",
"fileName": "ky2203.iso",
"filePath": "/vms/iso/Kylin-Desktop-V10-SP1-General-Release-2203-X86_64.iso",
"btPath": "--",
"description": "--"
},
{
"id": 4,
"name": "windows-10",
"os": "windows",
"version": "1",
"status": "成功",
"uploadTime": "2025-10-14 16:54:38",
"fileName": "windows-10.iso",
"filePath": "/vms/iso/cn_windows_10_business.iso",
"btPath": "--",
"description": "--"
},
{
"id": 5,
"name": "windows-11",
"os": "windows",
"version": "1",
"status": "成功",
"uploadTime": "2025-10-14 16:54:49",
"fileName": "windows-11.iso",
"filePath": "/vms/iso/Windows11.iso",
"btPath": "--",
"description": "--"
},
{
"id": 6,
"name": "Kylin-Desktop-V10-SP1-2403-HWE-Release-2403-x86_64",
"os": "linux",
"version": "2403",
"status": "成功",
"uploadTime": "2025-10-28 10:14:36",
"fileName": "Kylin-Desktop-V10-SP1-2403-HWE-Release-2403-x86_64.iso",
"filePath": "/vms/iso/Kylin-Desktop-V10-SP1-2403-HWE-Release-2403-x86_64.iso",
"btPath": "--",
"description": "--"
},
{
"id": 7,
"name": "Kylin-Desktop-V10-SP1-2503-X86_64",
"os": "linux",
"version": "2503",
"status": "成功",
"uploadTime": "2025-10-28 10:28:32",
"fileName": "Kylin-Desktop-V10-SP1-2503-X86_64.iso",
"filePath": "/vms/iso/Kylin-Desktop-V10-SP1-2503-HWE-Release-2503-X86_64.iso",
"btPath": "--",
"description": "--"
}
]
}

View File

@ -0,0 +1,227 @@
{
"userGroups": [
{
"id": "1",
"name": "全部用户",
"children": [
{
"id": "1-1",
"name": "管理员组",
"children": [
{ "id": "1-1-1", "name": "系统管理员" },
{ "id": "1-1-2", "name": "安全管理员" },
{ "id": "1-1-3", "name": "审计管理员" }
]
},
{
"id": "1-2",
"name": "部门用户",
"children": [
{ "id": "1-2-1", "name": "研发部" },
{ "id": "1-2-2", "name": "产品部" },
{ "id": "1-2-3", "name": "运营部" },
{ "id": "1-2-4", "name": "市场部" },
{ "id": "1-2-5", "name": "人力资源部" }
]
},
{
"id": "1-3",
"name": "外部用户",
"children": [
{ "id": "1-3-1", "name": "供应商" },
{ "id": "1-3-2", "name": "合作伙伴" },
{ "id": "1-3-3", "name": "临时访客" }
]
},
{
"id": "1-4",
"name": "测试用户",
"children": [
{ "id": "1-4-1", "name": "功能测试" },
{ "id": "1-4-2", "name": "性能测试" }
]
},
{
"id": "1-5",
"name": "黑名单"
}
]
}
],
"users": [
{
"id": 1,
"userName": "admin",
"userType": "管理员",
"name": "系统管理员",
"groupId": "1-1-1",
"group": "系统管理员",
"status": "enabled",
"grantedTerminals": 8,
"grantedImages": 21,
"terminals": [
{
"id": 1,
"name": "默认终端",
"group": "默认终端分组",
"mac": "C8:D9:D2:00:B6:4B",
"ip": "10.100.51.207",
"status": "离线",
"grantedImages": 1,
"dataDisk": "316GB",
"systemDisk": "200GB"
},
{
"id": 2,
"name": "默认终端",
"group": "默认终端分组",
"mac": "90:E2:FC:B6:D3:4F",
"ip": "10.100.51.162",
"status": "离线",
"grantedImages": 1,
"dataDisk": "120GB",
"systemDisk": "200GB"
},
{
"id": 3,
"name": "默认终端",
"group": "默认终端分组",
"mac": "68:05:CA:9E:E1:98",
"ip": "10.100.51.166",
"status": "在线",
"grantedImages": 2,
"dataDisk": "316GB",
"systemDisk": "200GB"
}
],
"images": [
{
"id": 1,
"name": "win10开发勾删",
"system": "windows-10",
"os": "Windows",
"createTime": "2025-10-15 01:10:37",
"status": "已下发",
"method": "自动下载"
}
]
},
{
"id": 2,
"userName": "user",
"userType": "个人用户",
"name": "张三",
"groupId": "1-2-1",
"group": "研发部",
"status": "disabled",
"grantedTerminals": 8,
"grantedImages": 9,
"terminals": [],
"images": []
},
{
"id": 3,
"userName": "test_user",
"userType": "个人用户",
"name": "李四",
"groupId": "1-4-1",
"group": "功能测试",
"status": "enabled",
"grantedTerminals": 8,
"grantedImages": 8,
"terminals": [],
"images": []
},
{
"id": 4,
"userName": "nex",
"userType": "个人用户",
"name": "王五",
"groupId": "1-2-2",
"group": "产品部",
"status": "enabled",
"grantedTerminals": 8,
"grantedImages": 10,
"terminals": [],
"images": []
},
{
"id": 5,
"userName": "unis",
"userType": "个人用户",
"name": "赵六",
"groupId": "1-2-3",
"group": "运营部",
"status": "enabled",
"grantedTerminals": 8,
"grantedImages": 10,
"terminals": [],
"images": []
},
{
"id": 6,
"userName": "ceshi123",
"userType": "个人用户",
"name": "孙七",
"groupId": "1-2-4",
"group": "市场部",
"status": "enabled",
"grantedTerminals": 6,
"grantedImages": 9,
"terminals": [],
"images": []
},
{
"id": 7,
"userName": "ceshi12345",
"userType": "个人用户",
"name": "黑名单用户",
"groupId": "1-5",
"group": "黑名单",
"status": "disabled",
"grantedTerminals": 1,
"grantedImages": 4,
"terminals": [],
"images": []
},
{
"id": 8,
"userName": "unis1",
"userType": "管理员",
"name": "安全管理员",
"groupId": "1-1-2",
"group": "安全管理员",
"status": "enabled",
"grantedTerminals": 5,
"grantedImages": 6,
"terminals": [],
"images": []
},
{
"id": 9,
"userName": "abcde",
"userType": "外部用户",
"name": "供应商代表",
"groupId": "1-3-1",
"group": "供应商",
"status": "enabled",
"grantedTerminals": 0,
"grantedImages": 0,
"terminals": [],
"images": []
},
{
"id": 10,
"userName": "qazwsx",
"userType": "外部用户",
"name": "临时访客",
"groupId": "1-3-3",
"group": "临时访客",
"status": "enabled",
"grantedTerminals": 0,
"grantedImages": 0,
"terminals": [],
"images": []
}
]
}

42
src/main.jsx 100644
View File

@ -0,0 +1,42 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import App from './App.jsx'
import './styles/globals.css'
// Ant Design
const theme = {
token: {
colorPrimary: '#b8178d', // - NEX LOGO
colorInfo: '#1677ff', // 使
borderRadius: 8,
fontSize: 14,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif',
},
components: {
Button: {
controlHeight: 32,
controlHeightLG: 40,
controlHeightSM: 24,
},
Input: {
controlHeight: 32,
},
Menu: {
itemHeight: 48,
collapsedIconSize: 24,
},
},
}
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<ConfigProvider locale={zhCN} theme={theme}>
<App />
</ConfigProvider>
</BrowserRouter>
</React.StrictMode>,
)

View File

@ -0,0 +1,232 @@
/* 文档页面容器 */
.docs-page {
width: 100%;
height: 100vh;
overflow: hidden;
}
.docs-layout {
height: 100%;
background: #ffffff;
}
/* 左侧边栏 */
.docs-sider {
border-right: 1px solid #f0f0f0;
height: 100%;
overflow-y: auto;
}
.docs-sider-header {
padding: 24px 24px 16px;
border-bottom: 1px solid #f0f0f0;
}
.docs-sider-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.docs-menu {
border-right: none;
}
.docs-menu :global(.ant-menu-item) {
margin: 4px 8px;
border-radius: 6px;
}
.docs-menu :global(.ant-menu-submenu-title) {
margin: 4px 8px;
border-radius: 6px;
}
/* 右侧内容区 */
.docs-content {
height: 100%;
overflow-y: auto;
background: #ffffff;
}
.docs-content-wrapper {
max-width: 1200px;
margin: 0 auto;
padding: 40px 60px;
}
.docs-loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
/* Markdown 内容样式 */
.markdown-body {
font-size: 15px;
line-height: 1.8;
color: rgba(0, 0, 0, 0.88);
}
/* 标题 */
.markdown-body h1 {
font-size: 32px;
font-weight: 600;
line-height: 1.3;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 2px solid #f0f0f0;
color: rgba(0, 0, 0, 0.88);
}
.markdown-body h2 {
font-size: 24px;
font-weight: 600;
line-height: 1.4;
margin-top: 48px;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
color: rgba(0, 0, 0, 0.88);
}
.markdown-body h3 {
font-size: 20px;
font-weight: 600;
margin-top: 32px;
margin-bottom: 12px;
color: rgba(0, 0, 0, 0.88);
}
.markdown-body h4 {
font-size: 16px;
font-weight: 600;
margin-top: 24px;
margin-bottom: 8px;
color: rgba(0, 0, 0, 0.88);
}
/* 段落 */
.markdown-body p {
margin-bottom: 16px;
}
/* 列表 */
.markdown-body ul,
.markdown-body ol {
margin-bottom: 16px;
padding-left: 24px;
}
.markdown-body li {
margin-bottom: 8px;
}
/* 代码块 */
.markdown-body pre {
background: #f6f8fa;
border: 1px solid #e1e4e8;
border-radius: 6px;
padding: 16px;
margin: 16px 0;
overflow-x: auto;
font-size: 14px;
line-height: 1.6;
}
.markdown-body code {
background: #f6f8fa;
border: 1px solid #e1e4e8;
border-radius: 3px;
padding: 2px 6px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 13px;
}
.markdown-body pre code {
background: transparent;
border: none;
padding: 0;
}
/* 表格 */
.markdown-body table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 14px;
}
.markdown-body table thead {
background: #fafafa;
}
.markdown-body table th {
padding: 12px 16px;
text-align: left;
font-weight: 600;
border: 1px solid #f0f0f0;
color: rgba(0, 0, 0, 0.88);
}
.markdown-body table td {
padding: 12px 16px;
border: 1px solid #f0f0f0;
}
.markdown-body table tr:hover {
background: #fafafa;
}
/* 引用块 */
.markdown-body blockquote {
margin: 16px 0;
padding: 8px 16px;
border-left: 4px solid #1677ff;
background: #f0f7ff;
color: rgba(0, 0, 0, 0.65);
}
/* 链接 */
.markdown-body a {
color: #1677ff;
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
}
/* 分隔线 */
.markdown-body hr {
margin: 32px 0;
border: none;
border-top: 1px solid #f0f0f0;
}
/* 图片 */
.markdown-body img {
max-width: 100%;
margin: 16px 0;
border-radius: 4px;
}
/* 滚动条样式 */
.docs-sider::-webkit-scrollbar,
.docs-content::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.docs-sider::-webkit-scrollbar-thumb,
.docs-content::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.docs-sider::-webkit-scrollbar-thumb:hover,
.docs-content::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}

View File

@ -0,0 +1,190 @@
import { useState, useEffect } from 'react'
import { Layout, Menu, Spin } from 'antd'
import { FileTextOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
import rehypeHighlight from 'rehype-highlight'
import 'highlight.js/styles/github.css'
import './DocsPage.css'
const { Sider, Content } = Layout
//
const docsMenuData = [
{
key: 'design',
label: '设计规范',
children: [
{
key: 'design-cookbook',
label: '设计手册',
path: '/docs/DESIGN_COOKBOOK.md',
},
],
},
{
key: 'components',
label: '组件文档',
children: [
{
key: 'components-overview',
label: '组件概览',
path: '/docs/components/README.md',
},
{
key: 'page-title-bar',
label: 'PageTitleBar',
path: '/docs/components/PageTitleBar.md',
},
{
key: 'list-action-bar',
label: 'ListActionBar',
path: '/docs/components/ListActionBar.md',
},
{
key: 'tree-filter-panel',
label: 'TreeFilterPanel',
path: '/docs/components/TreeFilterPanel.md',
},
{
key: 'list-table',
label: 'ListTable',
path: '/docs/components/ListTable.md',
},
{
key: 'detail-drawer',
label: 'DetailDrawer',
path: '/docs/components/DetailDrawer.md',
},
{
key: 'info-panel',
label: 'InfoPanel',
path: '/docs/components/InfoPanel.md',
},
{
key: 'confirm-dialog',
label: 'ConfirmDialog',
path: '/docs/components/ConfirmDialog.md',
},
{
key: 'toast',
label: 'Toast',
path: '/docs/components/Toast.md',
},
],
},
{
key: 'pages',
label: '页面文档',
children: [
{
key: 'main-layout',
label: '主布局',
path: '/docs/pages/main-layout.md',
},
],
},
]
function DocsPage() {
const [selectedKey, setSelectedKey] = useState('design-cookbook')
const [markdownContent, setMarkdownContent] = useState('')
const [loading, setLoading] = useState(false)
//
const menuItems = docsMenuData.map((group) => ({
key: group.key,
label: group.label,
icon: <FileTextOutlined />,
children: group.children.map((item) => ({
key: item.key,
label: item.label,
})),
}))
// key
const findDocPath = (key) => {
for (const group of docsMenuData) {
const found = group.children.find((item) => item.key === key)
if (found) return found.path
}
return null
}
// markdown
const loadMarkdown = async (key) => {
const path = findDocPath(key)
if (!path) return
setLoading(true)
try {
const response = await fetch(path)
if (response.ok) {
const text = await response.text()
setMarkdownContent(text)
} else {
setMarkdownContent('# 文档加载失败\n\n无法加载该文档请稍后重试。')
}
} catch (error) {
console.error('Error loading markdown:', error)
setMarkdownContent('# 文档加载失败\n\n' + error.message)
} finally {
setLoading(false)
}
}
//
const handleMenuClick = ({ key }) => {
setSelectedKey(key)
loadMarkdown(key)
}
//
useEffect(() => {
loadMarkdown(selectedKey)
}, [])
return (
<div className="docs-page">
<Layout className="docs-layout">
{/* 左侧目录 */}
<Sider width={280} className="docs-sider" theme="light">
<div className="docs-sider-header">
<h2>文档中心</h2>
</div>
<Menu
mode="inline"
selectedKeys={[selectedKey]}
defaultOpenKeys={['design', 'components', 'pages']}
items={menuItems}
onClick={handleMenuClick}
className="docs-menu"
/>
</Sider>
{/* 右侧内容 */}
<Content className="docs-content">
<div className="docs-content-wrapper">
{loading ? (
<div className="docs-loading">
<Spin size="large" tip="加载中..." />
</div>
) : (
<div className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeHighlight]}
>
{markdownContent}
</ReactMarkdown>
</div>
)}
</div>
</Content>
</Layout>
</div>
)
}
export default DocsPage

View File

@ -0,0 +1,192 @@
.host-list-page {
width: 100%;
}
/* 统计面板 - HostListPage 特有 */
.stats-panel {
margin-bottom: 16px;
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
opacity: 0;
max-height: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
max-height: 500px;
transform: translateY(0);
}
}
/* 统计卡片 */
.stat-card-small {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border-radius: 8px;
transition: all 0.3s;
}
.stat-card-small:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
.stat-card-small :global(.ant-statistic-title) {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.stat-card-small :global(.ant-statistic-content) {
font-size: 24px;
font-weight: 600;
}
/* 卡片激活状态 */
.stat-card-active {
border: 2px solid #1677ff;
background: linear-gradient(135deg, #e6f4ff 0%, #f0f5ff 100%);
box-shadow: 0 4px 16px rgba(22, 119, 255, 0.2);
}
.stat-card-active:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(22, 119, 255, 0.3);
}
/* 卡片变暗状态 */
.stat-card-dimmed {
opacity: 0.5;
filter: grayscale(0.3);
}
.stat-card-dimmed:hover {
opacity: 0.7;
}
/* 筛选结果卡片 */
.stat-card-result {
cursor: default !important;
}
.stat-card-result:hover {
transform: none !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06) !important;
}
/* 主机名样式 */
.host-name {
font-weight: 500;
color: rgba(0, 0, 0, 0.88);
}
/* 卡片列表 - 详情抽屉中的镜像和用户卡片 */
.card-list {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 镜像卡片 */
.image-card {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s;
}
.image-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
/* 用户卡片 */
.user-card {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s;
}
.user-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h4 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.card-info-item {
display: flex;
font-size: 13px;
line-height: 1.6;
}
.card-label {
color: rgba(0, 0, 0, 0.65);
white-space: nowrap;
}
.card-value {
color: rgba(0, 0, 0, 0.88);
flex: 1;
}
/* 用户卡片特有样式 */
.user-header-info {
flex: 1;
}
.user-sequence {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-bottom: 4px;
}
.user-name-group {
display: flex;
align-items: center;
font-size: 13px;
}
.user-label {
color: rgba(0, 0, 0, 0.65);
}
.user-value {
color: rgba(0, 0, 0, 0.88);
font-weight: 500;
}
.user-images {
background: #fafafa;
padding: 12px;
border-radius: 6px;
}
.images-header {
display: flex;
justify-content: space-between;
align-items: center;
}
/* 响应式 */
@media (max-width: 1200px) {
.stats-panel :global(.ant-row) {
display: grid;
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.stats-panel :global(.ant-row) {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,908 @@
import { useState } from 'react'
import {
Button,
Tag,
Badge,
Drawer,
Dropdown,
Space,
Checkbox,
Form,
Select,
Input,
Divider,
Card,
Row,
Col,
Statistic,
TreeSelect,
} from 'antd'
import {
SearchOutlined,
PlusOutlined,
ReloadOutlined,
PoweroffOutlined,
EditOutlined,
DeleteOutlined,
MoreOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
DesktopOutlined,
DatabaseOutlined,
UserOutlined,
} from '@ant-design/icons'
import PageTitleBar from '../components/PageTitleBar/PageTitleBar'
import ListActionBar from '../components/ListActionBar/ListActionBar'
import TreeFilterPanel from '../components/TreeFilterPanel/TreeFilterPanel'
import ListTable from '../components/ListTable/ListTable'
import DetailDrawer from '../components/DetailDrawer/DetailDrawer'
import InfoPanel from '../components/InfoPanel/InfoPanel'
import ConfirmDialog from '../components/ConfirmDialog/ConfirmDialog'
import Toast from '../components/Toast/Toast'
import hostData from '../data/hostData.json'
import './HostListPage.css'
function HostListPage() {
const [selectedRowKeys, setSelectedRowKeys] = useState([])
const [selectedHost, setSelectedHost] = useState(null)
const [showFilterPopover, setShowFilterPopover] = useState(false)
const [showDetailDrawer, setShowDetailDrawer] = useState(false)
const [showEditDrawer, setShowEditDrawer] = useState(false)
const [editMode, setEditMode] = useState('add')
const [searchKeyword, setSearchKeyword] = useState('')
const [selectedGroup, setSelectedGroup] = useState(null)
const [selectedGroupName, setSelectedGroupName] = useState('')
const [tempSelectedGroup, setTempSelectedGroup] = useState(null)
const [filteredHosts, setFilteredHosts] = useState(hostData.hosts)
const [showStatsPanel, setShowStatsPanel] = useState(true)
const [statusFilter, setStatusFilter] = useState(null)
//
const columns = [
{
title: '序号',
dataIndex: 'id',
key: 'id',
width: 80,
align: 'center',
},
{
title: '终端名',
dataIndex: 'name',
key: 'name',
width: 180,
render: (text) => (
<Space>
<DesktopOutlined style={{ color: '#1677ff' }} />
<span className="host-name">{text}</span>
</Space>
),
},
{
title: 'MAC地址',
dataIndex: 'mac',
key: 'mac',
width: 160,
},
{
title: '终端分组',
dataIndex: 'group',
key: 'group',
width: 150,
render: (text) => <Tag color="blue">{text}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
align: 'center',
render: (status) => (
<Badge
status={status === 'online' ? 'success' : 'default'}
text={
<span style={{ color: status === 'online' ? '#52c41a' : '#8c8c8c' }}>
{status === 'online' ? '在线' : '离线'}
</span>
}
/>
),
},
{
title: 'IP地址',
dataIndex: 'ip',
key: 'ip',
width: 140,
},
{
title: '数据盘容量',
dataIndex: 'dataDisk',
key: 'dataDisk',
width: 120,
align: 'center',
},
{
title: '系统盘容量',
dataIndex: 'systemDisk',
key: 'systemDisk',
width: 120,
align: 'center',
},
{
title: '上次开机时间',
dataIndex: 'lastOnline',
key: 'lastOnline',
width: 180,
align: 'center',
},
{
title: '操作',
key: 'action',
width: 180,
fixed: 'right',
render: (_, record) => (
<Space size="small" onClick={(e) => e.stopPropagation()}>
<Button
type="link"
size="small"
icon={<PoweroffOutlined />}
disabled={record.status === 'offline'}
>
{record.status === 'online' ? '关机' : '开机'}
</Button>
<Dropdown
menu={{
items: [
{
key: 'edit',
label: '编辑',
icon: <EditOutlined />,
},
{
key: 'restart',
label: '重启',
icon: <ReloadOutlined />,
disabled: record.status === 'offline',
},
{
type: 'divider',
},
{
key: 'delete',
label: '删除',
icon: <DeleteOutlined />,
danger: true,
},
],
onClick: ({ key }) => handleMenuClick(key, record),
}}
>
<Button type="link" size="small" icon={<MoreOutlined />}>
更多
</Button>
</Dropdown>
</Space>
),
},
]
//
const hostFields = [
{ key: 'name', label: '终端名称', span: 6 },
{ key: 'group', label: '终端分组', span: 6 },
{ key: 'mac', label: 'MAC地址', span: 6 },
{ key: 'grantedUsers', label: '授权用户', span: 6, render: () => '3' },
{ key: 'ip', label: 'IP地址', span: 6 },
{ key: 'grantedImages', label: '授权镜像', span: 6, render: () => '1' },
{
key: 'status',
label: '终端状态',
span: 6,
render: (value) => (
<Tag color={value === 'online' ? 'green' : 'orange'}>
{value === 'online' ? '在线' : '离线'}
</Tag>
),
},
{ key: 'systemDisk', label: '系统盘总容量', span: 6 },
{ key: 'dataDisk', label: '数据盘总容量', span: 6 },
{ key: 'description', label: '描述', span: 18, render: () => '--' },
]
//
const filterHosts = (keyword, groupId, status = statusFilter) => {
let filtered = hostData.hosts
if (keyword) {
filtered = filtered.filter(
(host) =>
host.name.toLowerCase().includes(keyword.toLowerCase()) ||
host.ip.includes(keyword) ||
host.mac.includes(keyword)
)
}
if (groupId) {
filtered = filtered.filter((host) => host.groupId === groupId)
}
if (status) {
filtered = filtered.filter((host) => host.status === status)
}
setFilteredHosts(filtered)
}
//
const handleSearch = (value) => {
setSearchKeyword(value)
filterHosts(value, selectedGroup, statusFilter)
}
//
const handleStatusFilterClick = (status) => {
const newStatusFilter = statusFilter === status ? null : status
setStatusFilter(newStatusFilter)
filterHosts(searchKeyword, selectedGroup, newStatusFilter)
}
//
const handleTotalClick = () => {
if (statusFilter !== null) {
setStatusFilter(null)
filterHosts(searchKeyword, selectedGroup, null)
}
}
// -
const handleConfirmFilter = () => {
setSelectedGroup(tempSelectedGroup)
const findGroupName = (nodes, id) => {
for (const node of nodes) {
if (node.id === id) return node.name
if (node.children) {
const found = findGroupName(node.children, id)
if (found) return found
}
}
return null
}
const groupName = tempSelectedGroup
? findGroupName(hostData.hostGroups, tempSelectedGroup)
: ''
setSelectedGroupName(groupName)
filterHosts(searchKeyword, tempSelectedGroup, statusFilter)
setShowFilterPopover(false)
}
// -
const handleClearFilter = () => {
setTempSelectedGroup(null)
setSelectedGroup(null)
setSelectedGroupName('')
filterHosts(searchKeyword, null, statusFilter)
setShowFilterPopover(false)
}
//
const handleDeleteHost = (record) => {
ConfirmDialog.delete({
itemName: `主机名:${record.name}`,
itemInfo: `IP地址${record.ip}`,
onOk() {
return new Promise((resolve) => {
setTimeout(() => {
const newHosts = filteredHosts.filter((h) => h.id !== record.id)
setFilteredHosts(newHosts)
resolve()
Toast.success('删除成功', `主机 "${record.name}" 已成功删除`)
}, 1000)
})
},
})
}
//
const handleBatchDelete = () => {
const selectedHosts = filteredHosts.filter((h) => selectedRowKeys.includes(h.id))
const items = selectedHosts.map((host) => ({
name: host.name,
info: host.ip,
}))
ConfirmDialog.batchDelete({
count: selectedRowKeys.length,
items,
onOk() {
return new Promise((resolve) => {
setTimeout(() => {
const count = selectedRowKeys.length
const newHosts = filteredHosts.filter((h) => !selectedRowKeys.includes(h.id))
setFilteredHosts(newHosts)
setSelectedRowKeys([])
resolve()
Toast.success('批量删除成功', `已成功删除 ${count} 台主机`)
}, 1000)
})
},
})
}
//
const handleMenuClick = (key, record) => {
if (key === 'edit') {
setSelectedHost(record)
setEditMode('edit')
setShowDetailDrawer(false)
setShowEditDrawer(true)
} else if (key === 'delete') {
handleDeleteHost(record)
}
}
//
const handleRowClick = (record) => {
setSelectedHost(record)
setShowEditDrawer(false)
setShowDetailDrawer(true)
}
//
const convertTreeData = (nodes) => {
return nodes.map((node) => ({
title: node.name,
key: node.id,
children: node.children ? convertTreeData(node.children) : undefined,
}))
}
const treeData = convertTreeData(hostData.hostGroups)
//
const detailTabs = [
{
key: 'images',
label: (
<span>
<DatabaseOutlined style={{ marginRight: 8 }} />
终端镜像
</span>
),
content: (
<div className="card-list">
<Card className="image-card" hoverable>
<div className="card-header">
<h4>win10开发勾删</h4>
<Space>
<Button type="link" size="small">
取消授权
</Button>
<Button type="link" size="small" danger>
删除并卸载
</Button>
</Space>
</div>
<Divider style={{ margin: '12px 0' }} />
<Row gutter={[16, 12]}>
<Col span={8}>
<div className="card-info-item">
<span className="card-label">虚拟机规格</span>
<span className="card-value">CPU:4,内存:4GB,系统盘:120GB</span>
</div>
</Col>
<Col span={8}>
<div className="card-info-item">
<span className="card-label">系统镜像</span>
<span className="card-value">windows-10</span>
</div>
</Col>
<Col span={8}>
<div className="card-info-item">
<span className="card-label">操作系统</span>
<span className="card-value">Windows</span>
</div>
</Col>
<Col span={8}>
<div className="card-info-item">
<span className="card-label">版本号</span>
<span className="card-value">win10-64</span>
</div>
</Col>
<Col span={8}>
<div className="card-info-item">
<span className="card-label">创建时间</span>
<span className="card-value">2025-10-15 01:10:37</span>
</div>
</Col>
<Col span={8}>
<div className="card-info-item">
<span className="card-label">下发状态</span>
<span className="card-value">
<Tag color="green">已下发</Tag>
</span>
</div>
</Col>
<Col span={8}>
<div className="card-info-item">
<span className="card-label">下发方式</span>
<span className="card-value">自动下载</span>
</div>
</Col>
<Col span={8}>
<div className="card-info-item">
<span className="card-label">关联用户</span>
<span className="card-value">3</span>
</div>
</Col>
<Col span={24}>
<div className="card-info-item">
<span className="card-label">描述</span>
<span className="card-value">--</span>
</div>
</Col>
</Row>
</Card>
</div>
),
},
{
key: 'users',
label: (
<span>
<UserOutlined style={{ marginRight: 8 }} />
终端用户
</span>
),
content: (
<div className="card-list">
<Card className="user-card" hoverable>
<div className="card-header">
<div className="user-header-info">
<Space>
<Checkbox />
<div>
<div className="user-sequence">序号1</div>
<div className="user-name-group">
<span className="user-label">用户名</span>
<span className="user-value">nex</span>
<span className="user-label" style={{ marginLeft: 24 }}>
姓名
</span>
<span className="user-value">nex</span>
<span className="user-label" style={{ marginLeft: 24 }}>
用户分组
</span>
<span className="user-value">默认组织</span>
<span className="user-label" style={{ marginLeft: 24 }}>
启停用
</span>
<Tag color="blue">启用</Tag>
</div>
</div>
</Space>
</div>
<Button type="link" size="small">
删除
</Button>
</div>
<Divider style={{ margin: '12px 0' }} />
<div className="user-images">
<div className="images-header">
<Space>
<span style={{ fontSize: 13, color: 'rgba(0,0,0,0.65)' }}>绑定镜像</span>
<Button type="link" size="small">
展开
</Button>
</Space>
</div>
<Row gutter={[16, 12]} style={{ marginTop: 8 }}>
<Col span={6}>
<div className="card-info-item">
<span className="card-label">镜像名称</span>
<span className="card-value">win10开发勾删</span>
</div>
</Col>
<Col span={6}>
<div className="card-info-item">
<span className="card-label">系统镜像</span>
<span className="card-value">windows-10</span>
</div>
</Col>
<Col span={6}>
<div className="card-info-item">
<span className="card-label">操作系统</span>
<span className="card-value">Windows</span>
</div>
</Col>
<Col span={6}>
<div className="card-info-item">
<span className="card-label">创建时间</span>
<span className="card-value">2025-10-15 01:10:37</span>
</div>
</Col>
<Col span={6}>
<div className="card-info-item">
<span className="card-label">下发状态</span>
<span className="card-value">
<Tag color="green">已下发</Tag>
</span>
</div>
</Col>
<Col span={6}>
<div className="card-info-item">
<span className="card-label">下发方式</span>
<span className="card-value">自动下载</span>
</div>
</Col>
</Row>
</div>
</Card>
<Card className="user-card" hoverable>
<div className="card-header">
<div className="user-header-info">
<Space>
<Checkbox />
<div>
<div className="user-sequence">序号2</div>
<div className="user-name-group">
<span className="user-label">用户名</span>
<span className="user-value">admin</span>
<span className="user-label" style={{ marginLeft: 24 }}>
姓名
</span>
<span className="user-value">admin</span>
<span className="user-label" style={{ marginLeft: 24 }}>
用户分组
</span>
<span className="user-value">默认组织</span>
<span className="user-label" style={{ marginLeft: 24 }}>
启停用
</span>
<Tag>停用</Tag>
</div>
</div>
</Space>
</div>
<Button type="link" size="small">
删除
</Button>
</div>
</Card>
<Card className="user-card" hoverable>
<div className="card-header">
<div className="user-header-info">
<Space>
<Checkbox />
<div>
<div className="user-sequence">序号3</div>
<div className="user-name-group">
<span className="user-label">用户名</span>
<span className="user-value">user</span>
<span className="user-label" style={{ marginLeft: 24 }}>
姓名
</span>
<span className="user-value">user</span>
<span className="user-label" style={{ marginLeft: 24 }}>
用户分组
</span>
<span className="user-value">默认组织</span>
<span className="user-label" style={{ marginLeft: 24 }}>
启停用
</span>
<Tag>停用</Tag>
</div>
</div>
</Space>
</div>
<Button type="link" size="small">
删除
</Button>
</div>
</Card>
</div>
),
},
]
return (
<div className="host-list-page">
{/* 页面标题栏 */}
<PageTitleBar
title="主机列表"
description="查看和管理所有接入的主机终端,包括服务器和办公设备"
showToggle={true}
onToggle={(expanded) => setShowStatsPanel(expanded)}
/>
{/* 数据统计面板 */}
{showStatsPanel && (
<div className="stats-panel">
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={6}>
<Card
className={`stat-card-small ${statusFilter === null ? '' : 'stat-card-dimmed'}`}
hoverable
onClick={handleTotalClick}
style={{ cursor: 'pointer' }}
>
<Statistic
title="总主机数"
value={hostData.hosts.length}
prefix={<DesktopOutlined />}
valueStyle={{ color: '#1677ff' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card
className={`stat-card-small ${statusFilter === 'online' ? 'stat-card-active' : statusFilter !== null ? 'stat-card-dimmed' : ''}`}
hoverable
onClick={() => handleStatusFilterClick('online')}
style={{ cursor: 'pointer' }}
>
<Statistic
title="在线主机"
value={hostData.hosts.filter((h) => h.status === 'online').length}
prefix={<CheckCircleOutlined />}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card
className={`stat-card-small ${statusFilter === 'offline' ? 'stat-card-active' : statusFilter !== null ? 'stat-card-dimmed' : ''}`}
hoverable
onClick={() => handleStatusFilterClick('offline')}
style={{ cursor: 'pointer' }}
>
<Statistic
title="离线主机"
value={hostData.hosts.filter((h) => h.status === 'offline').length}
prefix={<CloseCircleOutlined />}
valueStyle={{ color: '#8c8c8c' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="stat-card-small stat-card-result">
<Statistic
title="筛选结果"
value={filteredHosts.length}
prefix={<SearchOutlined />}
valueStyle={{ color: '#faad14' }}
/>
</Card>
</Col>
</Row>
</div>
)}
{/* 操作栏 - 使用新组件 */}
<ListActionBar
actions={[
{
key: 'add',
label: '新增主机',
icon: <PlusOutlined />,
type: 'primary',
onClick: () => {
setEditMode('add')
setSelectedHost(null)
setShowDetailDrawer(false)
setShowEditDrawer(true)
},
},
{
key: 'batchPowerOn',
label: '批量开机',
icon: <PoweroffOutlined />,
disabled: selectedRowKeys.length === 0,
onClick: () => console.log('批量开机'),
},
{
key: 'batchPowerOff',
label: '批量关机',
icon: <PoweroffOutlined />,
disabled: selectedRowKeys.length === 0,
onClick: () => console.log('批量关机'),
},
{
key: 'batchDelete',
label: '批量删除',
icon: <DeleteOutlined />,
danger: true,
disabled: selectedRowKeys.length === 0,
onClick: handleBatchDelete,
},
]}
search={{
placeholder: '搜索主机名、IP或MAC地址',
value: searchKeyword,
onSearch: handleSearch,
onChange: handleSearch,
}}
filter={{
content: (
<TreeFilterPanel
treeData={treeData}
selectedKey={selectedGroup}
tempSelectedKey={tempSelectedGroup}
treeTitle="终端分组"
onSelect={setTempSelectedGroup}
onConfirm={handleConfirmFilter}
onClear={handleClearFilter}
placeholder="请选择终端分组进行筛选"
/>
),
title: '高级筛选',
visible: showFilterPopover,
onVisibleChange: (visible) => {
setShowFilterPopover(visible)
if (visible) {
setTempSelectedGroup(selectedGroup)
}
},
selectedLabel: selectedGroupName,
isActive: !!selectedGroup,
}}
showRefresh
onRefresh={() => console.log('刷新')}
/>
{/* 数据表格 - 使用新组件 */}
<ListTable
columns={columns}
dataSource={filteredHosts}
selectedRowKeys={selectedRowKeys}
onSelectionChange={setSelectedRowKeys}
onRowClick={handleRowClick}
selectedRow={selectedHost}
scroll={{ x: 1600 }}
/>
{/* 详情抽屉 - 使用新组件 */}
<DetailDrawer
visible={showDetailDrawer}
onClose={() => setShowDetailDrawer(false)}
title={{
text: selectedHost?.name || '',
badge: (
<Badge
status={selectedHost?.status === 'online' ? 'success' : 'default'}
text={
<span
style={{
color: selectedHost?.status === 'online' ? '#52c41a' : '#8c8c8c',
fontSize: 14,
}}
>
{selectedHost?.status === 'online' ? '在线' : '离线'}
</span>
}
/>
),
}}
headerActions={[
{
key: 'edit',
label: '编辑',
icon: <EditOutlined />,
onClick: () => {
setEditMode('edit')
setShowEditDrawer(true)
setShowDetailDrawer(false)
},
},
{
key: 'delete',
label: '删除',
icon: <DeleteOutlined />,
danger: true,
onClick: () => {
setShowDetailDrawer(false)
if (selectedHost) handleDeleteHost(selectedHost)
},
},
]}
width={1080}
tabs={detailTabs}
>
<InfoPanel
data={selectedHost}
fields={hostFields}
actions={[
{
key: 'power',
label: selectedHost?.status === 'online' ? '关机' : '开机',
type: 'primary',
icon: <PoweroffOutlined />,
disabled: selectedHost?.status === 'offline',
onClick: () => console.log('电源操作'),
},
{
key: 'restart',
label: '重启',
icon: <ReloadOutlined />,
disabled: selectedHost?.status === 'offline',
onClick: () => console.log('重启'),
},
{ key: 'match', label: '镜像用户匹配', onClick: () => console.log('镜像用户匹配') },
{ key: 'blacklist', label: '加入黑名单', onClick: () => console.log('加入黑名单') },
{ key: 'enableUsb', label: '启用USB', onClick: () => console.log('启用USB') },
{ key: 'disableUsb', label: '禁用USB', onClick: () => console.log('禁用USB') },
]}
/>
</DetailDrawer>
{/* 新增/编辑抽屉 */}
<Drawer
title={
<Space>
<DesktopOutlined />
<span>{editMode === 'add' ? '新增主机' : '编辑主机'}</span>
</Space>
}
placement="right"
width={720}
onClose={() => setShowEditDrawer(false)}
open={showEditDrawer}
extra={
<Space>
<Button onClick={() => setShowEditDrawer(false)}>取消</Button>
<Button type="primary">保存</Button>
</Space>
}
>
<Form layout="vertical" initialValues={editMode === 'edit' ? selectedHost : {}}>
<Form.Item label="主机名" name="name" rules={[{ required: true, message: '请输入主机名' }]}>
<Input placeholder="请输入主机名" />
</Form.Item>
<Form.Item label="IP地址" name="ip" rules={[{ required: true, message: '请输入IP地址' }]}>
<Input placeholder="请输入IP地址" />
</Form.Item>
<Form.Item label="MAC地址" name="mac" rules={[{ required: true, message: '请输入MAC地址' }]}>
<Input placeholder="请输入MAC地址" />
</Form.Item>
<Form.Item label="终端分组" name="groupId" rules={[{ required: true, message: '请选择终端分组' }]}>
<TreeSelect
treeData={treeData}
placeholder="请选择终端分组"
treeDefaultExpandAll
showSearch
filterTreeNode={(search, item) =>
item.title.toLowerCase().indexOf(search.toLowerCase()) >= 0
}
/>
</Form.Item>
<Form.Item label="操作系统" name="os">
<Input placeholder="请输入操作系统" />
</Form.Item>
<Form.Item label="CPU" name="cpu">
<Input placeholder="请输入CPU型号" />
</Form.Item>
<Form.Item label="内存" name="memory">
<Input placeholder="请输入内存容量" />
</Form.Item>
<Form.Item label="系统盘容量" name="systemDisk">
<Input placeholder="请输入系统盘容量" />
</Form.Item>
<Form.Item label="数据盘容量" name="dataDisk">
<Input placeholder="请输入数据盘容量" />
</Form.Item>
</Form>
</Drawer>
</div>
)
}
export default HostListPage

View File

@ -0,0 +1,3 @@
.image-list-page {
width: 100%;
}

View File

@ -0,0 +1,257 @@
import { useState } from 'react'
import { Button, Space } from 'antd'
import {
PlusOutlined,
DownloadOutlined,
EyeOutlined,
DeleteOutlined,
} from '@ant-design/icons'
import PageTitleBar from '../components/PageTitleBar/PageTitleBar'
import ListActionBar from '../components/ListActionBar/ListActionBar'
import DetailDrawer from '../components/DetailDrawer/DetailDrawer'
import InfoPanel from '../components/InfoPanel/InfoPanel'
import ListTable from '../components/ListTable/ListTable'
import ConfirmDialog from '../components/ConfirmDialog/ConfirmDialog'
import Toast from '../components/Toast/Toast'
import imageData from '../data/imageData.json'
import './ImageListPage.css'
function ImageListPage() {
const [selectedRowKeys, setSelectedRowKeys] = useState([])
const [selectedImage, setSelectedImage] = useState(null)
const [showDetailDrawer, setShowDetailDrawer] = useState(false)
const [searchKeyword, setSearchKeyword] = useState('')
const [filteredImages, setFilteredImages] = useState(imageData.images)
//
const columns = [
{
title: '序号',
dataIndex: 'id',
key: 'id',
width: 80,
align: 'center',
},
{
title: '名称',
dataIndex: 'name',
key: 'name',
width: 350,
ellipsis: true,
},
{
title: '操作系统',
dataIndex: 'os',
key: 'os',
width: 120,
},
{
title: '镜像版本',
dataIndex: 'version',
key: 'version',
width: 120,
},
{
title: '上传时间',
dataIndex: 'uploadTime',
key: 'uploadTime',
width: 180,
},
{
title: '操作',
key: 'action',
width: 220,
fixed: 'right',
render: (_, record) => (
<Space size="small" onClick={(e) => e.stopPropagation()}>
<Button type="link" size="small" icon={<DownloadOutlined />}>
下载
</Button>
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record)}
>
详情
</Button>
<Button
type="link"
size="small"
icon={<DeleteOutlined />}
danger
onClick={() => handleDeleteImage(record)}
>
删除
</Button>
</Space>
),
},
]
// - InfoPanel
const imageFields = [
{ key: 'name', label: '镜像名称', span: 12 },
{ key: 'fileName', label: '镜像文件', span: 12 },
{ key: 'version', label: '镜像版本', span: 12 },
{ key: 'os', label: '操作系统', span: 12 },
{ key: 'filePath', label: '镜像存放路径', span: 24 },
{ key: 'btPath', label: 'BT路径', span: 24 },
]
//
const handleSearch = (value) => {
setSearchKeyword(value)
if (value) {
const filtered = imageData.images.filter(
(image) =>
image.name.toLowerCase().includes(value.toLowerCase()) ||
image.os.toLowerCase().includes(value.toLowerCase())
)
setFilteredImages(filtered)
} else {
setFilteredImages(imageData.images)
}
}
//
const handleViewDetail = (record) => {
setSelectedImage(record)
setShowDetailDrawer(true)
}
//
const handleRowClick = (record) => {
setSelectedImage(record)
setShowDetailDrawer(true)
}
//
const handleDeleteImage = (record) => {
ConfirmDialog.delete({
itemName: `镜像名称:${record.name}`,
itemInfo: `文件名:${record.fileName}`,
onOk() {
return new Promise((resolve) => {
setTimeout(() => {
const newImages = filteredImages.filter((img) => img.id !== record.id)
setFilteredImages(newImages)
resolve()
Toast.success('删除成功', `镜像 "${record.name}" 已成功删除`)
}, 1000)
})
},
})
}
//
const handleBatchDelete = () => {
const selectedImages = filteredImages.filter((img) => selectedRowKeys.includes(img.id))
const items = selectedImages.map((image) => ({
name: image.name,
info: image.fileName,
}))
ConfirmDialog.batchDelete({
count: selectedRowKeys.length,
items,
onOk() {
return new Promise((resolve) => {
setTimeout(() => {
const count = selectedRowKeys.length
const newImages = filteredImages.filter((img) => !selectedRowKeys.includes(img.id))
setFilteredImages(newImages)
setSelectedRowKeys([])
resolve()
Toast.success('批量删除成功', `已成功删除 ${count} 个镜像`)
}, 1000)
})
},
})
}
return (
<div className="image-list-page">
{/* 页面标题栏 */}
<PageTitleBar
title="系统镜像"
description="管理系统镜像文件,包括上传、下载和删除操作"
showToggle={false}
/>
{/* 操作栏 - 使用新组件 */}
<ListActionBar
actions={[
{
key: 'add',
label: '新建',
icon: <PlusOutlined />,
type: 'primary',
onClick: () => console.log('新建')
},
{
key: 'batchDelete',
label: '批量删除',
icon: <DeleteOutlined />,
danger: true,
disabled: selectedRowKeys.length === 0,
onClick: handleBatchDelete
},
]}
search={{
placeholder: '请输入名称',
value: searchKeyword,
onSearch: handleSearch,
onChange: handleSearch,
}}
/>
{/* 数据表格 - 使用新组件 */}
<ListTable
columns={columns}
dataSource={filteredImages}
selectedRowKeys={selectedRowKeys}
onSelectionChange={setSelectedRowKeys}
onRowClick={handleRowClick}
selectedRow={selectedImage}
scroll={{ x: 1200 }}
/>
{/* 详情抽屉 - 使用新组件 */}
<DetailDrawer
visible={showDetailDrawer}
onClose={() => setShowDetailDrawer(false)}
title={{ text: selectedImage?.name || '' }}
headerActions={[
{
key: 'delete',
label: '删除',
icon: <DeleteOutlined />,
danger: true,
onClick: () => {
setShowDetailDrawer(false)
if (selectedImage) handleDeleteImage(selectedImage)
},
},
]}
width={1080}
>
<InfoPanel
data={selectedImage}
fields={imageFields}
actions={[
{
key: 'download',
label: '下载镜像',
icon: <DownloadOutlined />,
type: 'primary',
onClick: () => console.log('下载镜像'),
},
]}
/>
</DetailDrawer>
</div>
)
}
export default ImageListPage

View File

@ -0,0 +1,145 @@
.overview-page {
width: 100%;
}
/* 统计卡片样式保留 */
.mb-6 {
margin-bottom: 24px;
}
.stat-card {
height: 100%;
}
.stat-card :global(.ant-statistic-title) {
color: rgba(0, 0, 0, 0.65);
font-size: 14px;
margin-bottom: 8px;
}
.stat-card :global(.ant-statistic-content) {
font-size: 24px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.stat-trend {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
display: flex;
align-items: center;
}
.stat-detail {
color: rgba(0, 0, 0, 0.45);
font-size: 12px;
}
.trend-up {
color: #52c41a;
font-size: 13px;
display: flex;
align-items: center;
gap: 4px;
}
/* 快速链接 */
.quick-links {
display: flex;
flex-direction: column;
gap: 16px;
}
.quick-link-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
}
.quick-link-item:hover {
background: #fafafa;
}
.link-icon {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.link-info {
flex: 1;
}
.link-title {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.88);
margin-bottom: 4px;
}
.link-desc {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
/* 活动列表 */
.activity-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.activity-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
}
.activity-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.activity-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 6px;
flex-shrink: 0;
}
.activity-content {
flex: 1;
}
.activity-text {
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
margin-bottom: 4px;
}
.activity-time {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
/* 图表占位 */
.chart-placeholder {
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
border-radius: 8px;
}

View File

@ -0,0 +1,247 @@
import { Card, Row, Col, Statistic, Table, Tag, Badge } from 'antd'
import {
DesktopOutlined,
MobileOutlined,
UserOutlined,
TeamOutlined,
WarningOutlined,
CheckCircleOutlined,
LinkOutlined,
ClockCircleOutlined,
} from '@ant-design/icons'
import PageTitleBar from '../components/PageTitleBar/PageTitleBar'
import './OverviewPage.css'
function OverviewPage() {
//
const errorColumns = [
{
title: '时间',
dataIndex: 'time',
key: 'time',
width: 180,
},
{
title: '错误类型',
dataIndex: 'type',
key: 'type',
width: 120,
render: (type) => (
<Tag color={type === '严重' ? 'red' : type === '警告' ? 'orange' : 'blue'}>
{type}
</Tag>
),
},
{
title: '错误信息',
dataIndex: 'message',
key: 'message',
ellipsis: true,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status) => (
<Badge
status={status === '已处理' ? 'success' : 'processing'}
text={status}
/>
),
},
]
const errorData = [
{
key: '1',
time: '2024-01-15 14:32:05',
type: '严重',
message: '网络配置服务连接超时',
status: '处理中',
},
{
key: '2',
time: '2024-01-15 13:20:18',
type: '警告',
message: '主机 192.168.1.101 CPU 使用率超过 85%',
status: '已处理',
},
{
key: '3',
time: '2024-01-15 12:15:42',
type: '信息',
message: '用户登录异常:连续失败 3 次',
status: '已处理',
},
{
key: '4',
time: '2024-01-15 11:08:30',
type: '警告',
message: '存储空间不足,剩余容量 < 10%',
status: '处理中',
},
]
return (
<div className="overview-page">
{/* 页面标题区 */}
<PageTitleBar
title="平台概览"
badge="实时"
description="查看平台接入的终端、用户情况及系统运行状态"
actions={
<>
<button className="secondary">导出报表</button>
<button className="primary">刷新数据</button>
</>
}
/>
{/* 统计卡片 - 终端和用户情况 */}
<Row gutter={[16, 16]} className="mb-6">
<Col xs={24} sm={12} lg={6}>
<Card className="stat-card">
<Statistic
title="接入终端数"
value={342}
prefix={<DesktopOutlined style={{ color: '#1677ff' }} />}
suffix="台"
/>
<div className="stat-trend">
<span className="stat-detail">PC: 218 | 移动: 124</span>
</div>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="stat-card">
<Statistic
title="在线终端"
value={287}
prefix={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
suffix="台"
/>
<div className="stat-trend">
<span className="stat-detail">在线率: 83.9%</span>
</div>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="stat-card">
<Statistic
title="注册用户"
value={1856}
prefix={<TeamOutlined style={{ color: '#faad14' }} />}
suffix="人"
/>
<div className="stat-trend">
<span className="stat-detail">今日新增: 23</span>
</div>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="stat-card">
<Statistic
title="活跃用户"
value={1247}
prefix={<UserOutlined style={{ color: '#b8178d' }} />}
suffix="人"
/>
<div className="stat-trend">
<span className="stat-detail">活跃率: 67.2%</span>
</div>
</Card>
</Col>
</Row>
{/* 详细数据卡片 */}
<Row gutter={[16, 16]}>
{/* 近期报错信息 */}
<Col xs={24} lg={16}>
<Card
title={
<span>
<WarningOutlined style={{ marginRight: 8, color: '#ff4d4f' }} />
近期报错信息
</span>
}
extra={<a href="#">查看全部</a>}
>
<Table
columns={errorColumns}
dataSource={errorData}
pagination={false}
size="small"
/>
</Card>
</Col>
{/* 快速访问入口 */}
<Col xs={24} lg={8}>
<Card
title={
<span>
<LinkOutlined style={{ marginRight: 8 }} />
快速访问
</span>
}
extra={<a href="#">更多</a>}
>
<div className="quick-links">
<div className="quick-link-item" style={{ cursor: 'pointer' }}>
<div className="link-icon" style={{ background: '#e6f4ff' }}>
<DesktopOutlined style={{ color: '#1677ff' }} />
</div>
<div className="link-info">
<div className="link-title">主机管理</div>
<div className="link-desc">查看和管理接入主机</div>
</div>
</div>
<div className="quick-link-item" style={{ cursor: 'pointer' }}>
<div className="link-icon" style={{ background: '#f6ffed' }}>
<TeamOutlined style={{ color: '#52c41a' }} />
</div>
<div className="link-info">
<div className="link-title">用户管理</div>
<div className="link-desc">管理系统用户信息</div>
</div>
</div>
<div className="quick-link-item" style={{ cursor: 'pointer' }}>
<div className="link-icon" style={{ background: '#fff7e6' }}>
<ClockCircleOutlined style={{ color: '#faad14' }} />
</div>
<div className="link-info">
<div className="link-title">系统日志</div>
<div className="link-desc">查看系统操作日志</div>
</div>
</div>
<div className="quick-link-item" style={{ cursor: 'pointer' }}>
<div className="link-icon" style={{ background: '#fff0f6' }}>
<WarningOutlined style={{ color: '#eb2f96' }} />
</div>
<div className="link-info">
<div className="link-title">告警中心</div>
<div className="link-desc">查看系统告警信息</div>
</div>
</div>
</div>
</Card>
</Col>
</Row>
{/* 图表区域 - 移除或保留作为终端统计 */}
<Card title="终端接入趋势" className="mt-4">
<div className="chart-placeholder">
<p className="text-gray-400 text-center py-12">
图表区域可集成 EChartsRecharts 等图表库展示终端接入趋势
</p>
</div>
</Card>
</div>
)
}
export default OverviewPage

View File

@ -0,0 +1,156 @@
.user-list-page {
width: 100%;
}
/* 统计面板 - UserListPage 特有 */
.stats-panel {
margin-bottom: 16px;
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
opacity: 0;
max-height: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
max-height: 500px;
transform: translateY(0);
}
}
/* 统计卡片 */
.stat-card-small {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border-radius: 8px;
transition: all 0.3s;
}
.stat-card-small:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
.stat-card-small :global(.ant-statistic-title) {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.stat-card-small :global(.ant-statistic-content) {
font-size: 24px;
font-weight: 600;
}
/* 卡片激活状态 */
.stat-card-active {
border: 2px solid #1677ff;
background: linear-gradient(135deg, #e6f4ff 0%, #f0f5ff 100%);
box-shadow: 0 4px 16px rgba(22, 119, 255, 0.2);
}
.stat-card-active:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(22, 119, 255, 0.3);
}
/* 卡片变暗状态 */
.stat-card-dimmed {
opacity: 0.5;
filter: grayscale(0.3);
}
.stat-card-dimmed:hover {
opacity: 0.7;
}
/* 筛选结果卡片 */
.stat-card-result {
cursor: default !important;
}
.stat-card-result:hover {
transform: none !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06) !important;
}
/* 用户名样式 */
.user-name-text {
font-weight: 500;
color: rgba(0, 0, 0, 0.88);
}
/* 卡片列表 - 详情抽屉中的终端和镜像卡片 */
.card-list {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 终端卡片 */
.terminal-card {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s;
}
.terminal-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
/* 镜像卡片 */
.image-card {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s;
}
.image-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h4 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
display: flex;
align-items: center;
}
.card-info-item {
display: flex;
font-size: 13px;
line-height: 1.6;
}
.card-label {
color: rgba(0, 0, 0, 0.65);
white-space: nowrap;
}
.card-value {
color: rgba(0, 0, 0, 0.88);
flex: 1;
}
/* 响应式 */
@media (max-width: 1200px) {
.stats-panel :global(.ant-row) {
display: grid;
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.stats-panel :global(.ant-row) {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,714 @@
import { useState } from 'react'
import {
Button,
Tag,
Space,
Form,
Select,
Input,
Divider,
Card,
Row,
Col,
Statistic,
Switch,
Badge,
Drawer,
TreeSelect,
} from 'antd'
import {
UserOutlined,
SearchOutlined,
PlusOutlined,
ReloadOutlined,
EditOutlined,
DeleteOutlined,
LockOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
DesktopOutlined,
DatabaseOutlined,
} from '@ant-design/icons'
import PageTitleBar from '../components/PageTitleBar/PageTitleBar'
import ListActionBar from '../components/ListActionBar/ListActionBar'
import TreeFilterPanel from '../components/TreeFilterPanel/TreeFilterPanel'
import ListTable from '../components/ListTable/ListTable'
import DetailDrawer from '../components/DetailDrawer/DetailDrawer'
import InfoPanel from '../components/InfoPanel/InfoPanel'
import ConfirmDialog from '../components/ConfirmDialog/ConfirmDialog'
import Toast from '../components/Toast/Toast'
import userData from '../data/userData.json'
import './UserListPage.css'
function UserListPage() {
const [selectedRowKeys, setSelectedRowKeys] = useState([])
const [selectedUser, setSelectedUser] = useState(null)
const [showFilterPopover, setShowFilterPopover] = useState(false)
const [showDetailDrawer, setShowDetailDrawer] = useState(false)
const [showEditDrawer, setShowEditDrawer] = useState(false)
const [editMode, setEditMode] = useState('add')
const [searchKeyword, setSearchKeyword] = useState('')
const [selectedGroup, setSelectedGroup] = useState(null)
const [selectedGroupName, setSelectedGroupName] = useState('')
const [tempSelectedGroup, setTempSelectedGroup] = useState(null)
const [filteredUsers, setFilteredUsers] = useState(userData.users)
const [showStatsPanel, setShowStatsPanel] = useState(true)
const [statusFilter, setStatusFilter] = useState(null)
//
const columns = [
{
title: '序号',
dataIndex: 'id',
key: 'id',
width: 80,
align: 'center',
},
{
title: '用户类型',
dataIndex: 'userType',
key: 'userType',
width: 120,
},
{
title: '用户名',
dataIndex: 'userName',
key: 'userName',
width: 150,
render: (text) => (
<Space>
<UserOutlined style={{ color: '#1677ff' }} />
<span className="user-name-text">{text}</span>
</Space>
),
},
{
title: '姓名',
dataIndex: 'name',
key: 'name',
width: 120,
},
{
title: '用户分组',
dataIndex: 'group',
key: 'group',
width: 150,
render: (text) => <Tag color="blue">{text}</Tag>,
},
{
title: '启停用',
dataIndex: 'status',
key: 'status',
width: 100,
align: 'center',
render: (status) => (
<Switch checked={status === 'enabled'} checkedChildren="启用" unCheckedChildren="停用" />
),
},
{
title: '授权终端',
dataIndex: 'grantedTerminals',
key: 'grantedTerminals',
width: 100,
align: 'center',
},
{
title: '授权镜像',
dataIndex: 'grantedImages',
key: 'grantedImages',
width: 100,
align: 'center',
},
{
title: '操作',
key: 'action',
width: 200,
fixed: 'right',
render: (_, record) => (
<Space size="small" onClick={(e) => e.stopPropagation()}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEditUser(record)}>
编辑
</Button>
<Button type="link" size="small" icon={<LockOutlined />}>
重置密码
</Button>
<Button
type="link"
size="small"
icon={<DeleteOutlined />}
danger
onClick={() => handleDeleteUser(record)}
>
删除
</Button>
</Space>
),
},
]
//
const userFields = [
{ key: 'userName', label: '用户名', span: 6 },
{ key: 'group', label: '用户分组', span: 6 },
{ key: 'name', label: '姓名', span: 6 },
{ key: 'grantedImages', label: '授权镜像', span: 6 },
{ key: 'userType', label: '用户类型', span: 6 },
{ key: 'grantedTerminals', label: '授权终端', span: 6 },
{
key: 'status',
label: '启停用',
span: 6,
render: (value) => (
<Tag color={value === 'enabled' ? 'green' : 'default'}>
{value === 'enabled' ? '启用' : '停用'}
</Tag>
),
},
{ key: 'description', label: '描述', span: 18, render: () => '--' },
]
//
const filterUsers = (keyword, groupId, status = statusFilter) => {
let filtered = userData.users
if (keyword) {
filtered = filtered.filter(
(user) =>
user.userName.toLowerCase().includes(keyword.toLowerCase()) ||
user.name.toLowerCase().includes(keyword.toLowerCase())
)
}
if (groupId) {
filtered = filtered.filter((user) => user.groupId === groupId)
}
if (status) {
filtered = filtered.filter((user) => user.status === status)
}
setFilteredUsers(filtered)
}
//
const handleSearch = (value) => {
setSearchKeyword(value)
filterUsers(value, selectedGroup, statusFilter)
}
//
const handleStatusFilterClick = (status) => {
const newStatusFilter = statusFilter === status ? null : status
setStatusFilter(newStatusFilter)
filterUsers(searchKeyword, selectedGroup, newStatusFilter)
}
//
const handleTotalClick = () => {
if (statusFilter !== null) {
setStatusFilter(null)
filterUsers(searchKeyword, selectedGroup, null)
}
}
// -
const handleConfirmFilter = () => {
setSelectedGroup(tempSelectedGroup)
const findGroupName = (nodes, id) => {
for (const node of nodes) {
if (node.id === id) return node.name
if (node.children) {
const found = findGroupName(node.children, id)
if (found) return found
}
}
return null
}
const groupName = tempSelectedGroup
? findGroupName(userData.userGroups, tempSelectedGroup)
: ''
setSelectedGroupName(groupName)
filterUsers(searchKeyword, tempSelectedGroup, statusFilter)
setShowFilterPopover(false)
}
// -
const handleClearFilter = () => {
setTempSelectedGroup(null)
setSelectedGroup(null)
setSelectedGroupName('')
filterUsers(searchKeyword, null, statusFilter)
setShowFilterPopover(false)
}
//
const handleDeleteUser = (record) => {
ConfirmDialog.delete({
itemName: `用户名:${record.userName}`,
itemInfo: `姓名:${record.name}`,
onOk() {
return new Promise((resolve) => {
setTimeout(() => {
const newUsers = filteredUsers.filter((u) => u.id !== record.id)
setFilteredUsers(newUsers)
resolve()
Toast.success('删除成功', `用户 "${record.userName}" 已成功删除`)
}, 1000)
})
},
})
}
//
const handleBatchDelete = () => {
const selectedUsers = filteredUsers.filter((u) => selectedRowKeys.includes(u.id))
const items = selectedUsers.map((user) => ({
name: user.userName,
info: user.name,
}))
ConfirmDialog.batchDelete({
count: selectedRowKeys.length,
items,
onOk() {
return new Promise((resolve) => {
setTimeout(() => {
const count = selectedRowKeys.length
const newUsers = filteredUsers.filter((u) => !selectedRowKeys.includes(u.id))
setFilteredUsers(newUsers)
setSelectedRowKeys([])
resolve()
Toast.success('批量删除成功', `已成功删除 ${count} 个用户`)
}, 1000)
})
},
})
}
//
const handleRowClick = (record) => {
setSelectedUser(record)
setShowEditDrawer(false)
setShowDetailDrawer(true)
}
//
const handleEditUser = (record) => {
setSelectedUser(record)
setEditMode('edit')
setShowDetailDrawer(false)
setShowEditDrawer(true)
}
//
const convertTreeData = (nodes) => {
return nodes.map((node) => ({
title: node.name,
key: node.id,
children: node.children ? convertTreeData(node.children) : undefined,
}))
}
const treeData = convertTreeData(userData.userGroups)
//
const detailTabs = [
{
key: 'terminals',
label: (
<span>
<DesktopOutlined style={{ marginRight: 8 }} />
授权终端
</span>
),
content: (
<div className="card-list">
{selectedUser?.terminals && selectedUser.terminals.length > 0 ? (
selectedUser.terminals.map((terminal, index) => (
<Card key={terminal.id} className="terminal-card">
<div className="card-header">
<h4>
<DesktopOutlined style={{ marginRight: 8, color: '#1677ff' }} />
{terminal.name}
</h4>
<Badge
status={terminal.status === '在线' ? 'success' : 'default'}
text={terminal.status}
/>
</div>
<Divider style={{ margin: '12px 0' }} />
<Row gutter={[16, 12]}>
<Col span={12}>
<div className="card-info-item">
<span className="card-label">序号</span>
<span className="card-value">{index + 1}</span>
</div>
</Col>
<Col span={12}>
<div className="card-info-item">
<span className="card-label">终端分组</span>
<span className="card-value">{terminal.group}</span>
</div>
</Col>
<Col span={12}>
<div className="card-info-item">
<span className="card-label">MAC地址</span>
<span className="card-value">{terminal.mac}</span>
</div>
</Col>
<Col span={12}>
<div className="card-info-item">
<span className="card-label">IP地址</span>
<span className="card-value">{terminal.ip}</span>
</div>
</Col>
<Col span={12}>
<div className="card-info-item">
<span className="card-label">授权镜像</span>
<span className="card-value">{terminal.grantedImages}</span>
</div>
</Col>
<Col span={12}>
<div className="card-info-item">
<span className="card-label">数据盘容量</span>
<span className="card-value">{terminal.dataDisk}</span>
</div>
</Col>
<Col span={12}>
<div className="card-info-item">
<span className="card-label">系统盘容量</span>
<span className="card-value">{terminal.systemDisk}</span>
</div>
</Col>
</Row>
<Divider style={{ margin: '12px 0' }} />
<Space size="small">
<Button type="link" size="small" icon={<EditOutlined />}>
编辑
</Button>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
移除授权
</Button>
</Space>
</Card>
))
) : (
<p style={{ color: '#8c8c8c', padding: '24px', textAlign: 'center' }}>
该用户暂无授权终端
</p>
)}
</div>
),
},
{
key: 'images',
label: (
<span>
<DatabaseOutlined style={{ marginRight: 8 }} />
授权镜像
</span>
),
content: (
<div className="card-list">
{selectedUser?.images && selectedUser.images.length > 0 ? (
selectedUser.images.map((image) => (
<Card key={image.id} className="image-card">
<div className="card-header">
<h4>
<DatabaseOutlined style={{ marginRight: 8, color: '#52c41a' }} />
{image.name}
</h4>
<Tag color={image.status === '已下发' ? 'success' : 'default'}>
{image.status}
</Tag>
</div>
<Divider style={{ margin: '12px 0' }} />
<Row gutter={[16, 12]}>
<Col span={12}>
<div className="card-info-item">
<span className="card-label">系统镜像</span>
<span className="card-value">{image.system}</span>
</div>
</Col>
<Col span={12}>
<div className="card-info-item">
<span className="card-label">操作系统</span>
<span className="card-value">{image.os}</span>
</div>
</Col>
<Col span={12}>
<div className="card-info-item">
<span className="card-label">创建时间</span>
<span className="card-value">{image.createTime}</span>
</div>
</Col>
<Col span={12}>
<div className="card-info-item">
<span className="card-label">下发方式</span>
<span className="card-value">{image.method}</span>
</div>
</Col>
</Row>
<Divider style={{ margin: '12px 0' }} />
<Space size="small">
<Button type="link" size="small" icon={<EditOutlined />}>
编辑
</Button>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
移除授权
</Button>
</Space>
</Card>
))
) : (
<p style={{ color: '#8c8c8c', padding: '24px', textAlign: 'center' }}>
该用户暂无授权镜像
</p>
)}
</div>
),
},
]
return (
<div className="user-list-page">
{/* 页面标题栏 */}
<PageTitleBar
title="用户列表"
description="管理系统用户,包括用户信息、权限和授权管理"
showToggle={true}
onToggle={(expanded) => setShowStatsPanel(expanded)}
/>
{/* 数据统计面板 */}
{showStatsPanel && (
<div className="stats-panel">
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={6}>
<Card
className={`stat-card-small ${statusFilter === null ? '' : 'stat-card-dimmed'}`}
hoverable
onClick={handleTotalClick}
style={{ cursor: 'pointer' }}
>
<Statistic
title="总用户数"
value={userData.users.length}
prefix={<UserOutlined />}
valueStyle={{ color: '#1677ff' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card
className={`stat-card-small ${statusFilter === 'enabled' ? 'stat-card-active' : statusFilter !== null ? 'stat-card-dimmed' : ''}`}
hoverable
onClick={() => handleStatusFilterClick('enabled')}
style={{ cursor: 'pointer' }}
>
<Statistic
title="启用用户"
value={userData.users.filter((u) => u.status === 'enabled').length}
prefix={<CheckCircleOutlined />}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card
className={`stat-card-small ${statusFilter === 'disabled' ? 'stat-card-active' : statusFilter !== null ? 'stat-card-dimmed' : ''}`}
hoverable
onClick={() => handleStatusFilterClick('disabled')}
style={{ cursor: 'pointer' }}
>
<Statistic
title="停用用户"
value={userData.users.filter((u) => u.status === 'disabled').length}
prefix={<CloseCircleOutlined />}
valueStyle={{ color: '#8c8c8c' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="stat-card-small stat-card-result">
<Statistic
title="筛选结果"
value={filteredUsers.length}
prefix={<SearchOutlined />}
valueStyle={{ color: '#faad14' }}
/>
</Card>
</Col>
</Row>
</div>
)}
{/* 操作栏 - 使用新组件 */}
<ListActionBar
actions={[
{
key: 'add',
label: '新增用户',
icon: <PlusOutlined />,
type: 'primary',
onClick: () => {
setEditMode('add')
setSelectedUser(null)
setShowDetailDrawer(false)
setShowEditDrawer(true)
},
},
{
key: 'batchDelete',
label: '批量删除',
icon: <DeleteOutlined />,
danger: true,
disabled: selectedRowKeys.length === 0,
onClick: handleBatchDelete,
},
]}
search={{
placeholder: '搜索用户名或姓名',
value: searchKeyword,
onSearch: handleSearch,
onChange: handleSearch,
}}
filter={{
content: (
<TreeFilterPanel
treeData={treeData}
selectedKey={selectedGroup}
tempSelectedKey={tempSelectedGroup}
treeTitle="用户分组"
onSelect={setTempSelectedGroup}
onConfirm={handleConfirmFilter}
onClear={handleClearFilter}
placeholder="请选择用户分组进行筛选"
/>
),
title: '高级筛选',
visible: showFilterPopover,
onVisibleChange: (visible) => {
setShowFilterPopover(visible)
if (visible) {
setTempSelectedGroup(selectedGroup)
}
},
selectedLabel: selectedGroupName,
isActive: !!selectedGroup,
}}
showRefresh
onRefresh={() => console.log('刷新')}
/>
{/* 数据表格 - 使用新组件 */}
<ListTable
columns={columns}
dataSource={filteredUsers}
selectedRowKeys={selectedRowKeys}
onSelectionChange={setSelectedRowKeys}
onRowClick={handleRowClick}
selectedRow={selectedUser}
scroll={{ x: 1400 }}
/>
{/* 详情抽屉 - 使用新组件 */}
<DetailDrawer
visible={showDetailDrawer}
onClose={() => setShowDetailDrawer(false)}
title={{
text: selectedUser?.userName || '',
badge: (
<Tag color={selectedUser?.status === 'enabled' ? 'green' : 'default'}>
{selectedUser?.status === 'enabled' ? '启用' : '停用'}
</Tag>
),
}}
headerActions={[
{
key: 'edit',
label: '编辑',
icon: <EditOutlined />,
onClick: () => {
setEditMode('edit')
setShowEditDrawer(true)
setShowDetailDrawer(false)
},
},
{
key: 'delete',
label: '删除',
icon: <DeleteOutlined />,
danger: true,
onClick: () => {
setShowDetailDrawer(false)
if (selectedUser) handleDeleteUser(selectedUser)
},
},
]}
width={1080}
tabs={detailTabs}
>
<InfoPanel
data={selectedUser}
fields={userFields}
actions={[
{ key: 'move', label: '转移分组', type: 'primary', onClick: () => console.log('转移分组') },
{ key: 'blacklist', label: '加入黑名单', onClick: () => console.log('加入黑名单') },
{ key: 'reset', label: '重置密码', onClick: () => console.log('重置密码') },
{ key: 'disable', label: '停用', icon: <LockOutlined />, onClick: () => console.log('停用') },
]}
/>
</DetailDrawer>
{/* 新增/编辑抽屉 */}
<Drawer
title={
<Space>
<UserOutlined />
<span>{editMode === 'add' ? '新增用户' : '编辑用户'}</span>
</Space>
}
placement="right"
width={720}
onClose={() => setShowEditDrawer(false)}
open={showEditDrawer}
extra={
<Space>
<Button onClick={() => setShowEditDrawer(false)}>取消</Button>
<Button type="primary">保存</Button>
</Space>
}
>
<Form layout="vertical" initialValues={editMode === 'edit' ? selectedUser : {}}>
<Form.Item label="用户名" name="userName" rules={[{ required: true, message: '请输入用户名' }]}>
<Input placeholder="请输入用户名" />
</Form.Item>
<Form.Item label="姓名" name="name" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="请输入姓名" />
</Form.Item>
<Form.Item label="用户分组" name="groupId" rules={[{ required: true, message: '请选择用户分组' }]}>
<TreeSelect
treeData={treeData}
placeholder="请选择用户分组"
treeDefaultExpandAll
showSearch
filterTreeNode={(search, item) =>
item.title.toLowerCase().indexOf(search.toLowerCase()) >= 0
}
/>
</Form.Item>
<Form.Item label="用户类型" name="userType">
<Select placeholder="请选择用户类型">
<Select.Option value="管理员">管理员</Select.Option>
<Select.Option value="个人用户">个人用户</Select.Option>
<Select.Option value="外部用户">外部用户</Select.Option>
</Select>
</Form.Item>
</Form>
</Drawer>
</div>
)
}
export default UserListPage

View File

@ -0,0 +1,78 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 全局样式变量 */
:root {
/* 主色调 */
--primary-color: #1677ff;
/* 文本颜色 */
--text-primary: rgba(0, 0, 0, 0.88);
--text-secondary: rgba(0, 0, 0, 0.65);
--text-tertiary: rgba(0, 0, 0, 0.45);
--text-disabled: rgba(0, 0, 0, 0.25);
/* 背景颜色 */
--bg-primary: #ffffff;
--bg-secondary: #fafafa;
--bg-tertiary: #f5f5f5;
/* 边框颜色 */
--border-primary: #d9d9d9;
--border-secondary: #f0f0f0;
/* 阴影 */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02);
--shadow-md: 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
}
/* 基础样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
font-size: 14px;
line-height: 1.5714;
color: var(--text-primary);
background-color: var(--bg-secondary);
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 工具类扩展 */
.content-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
}
.page-header {
margin-bottom: 24px;
}
.card-shadow {
box-shadow: var(--shadow-sm);
}

65
tailwind.config.js 100644
View File

@ -0,0 +1,65 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#fce7f6',
100: '#f5bae6',
200: '#ee8dd6',
300: '#e760c6',
400: '#e033b6',
500: '#b8178d', // 品牌主色 - 匹配 NEX LOGO
600: '#9c1477',
700: '#801161',
800: '#640d4b',
900: '#480a35',
},
// 保留原蓝色作为辅助色
blue: {
50: '#e6f4ff',
100: '#bae0ff',
200: '#91caff',
300: '#69b1ff',
400: '#4096ff',
500: '#1677ff',
600: '#0958d9',
700: '#003eb3',
800: '#002c8c',
900: '#001d66',
},
},
fontFamily: {
sans: [
'-apple-system',
'BlinkMacSystemFont',
'Segoe UI',
'Roboto',
'Helvetica Neue',
'Arial',
'Noto Sans',
'sans-serif',
],
mono: [
'SF Mono',
'Monaco',
'Inconsolata',
'Fira Code',
'Fira Mono',
'Droid Sans Mono',
'Source Code Pro',
'monospace',
],
},
},
},
plugins: [],
// 与 Ant Design 兼容
corePlugins: {
preflight: false,
},
}

21
vite.config.js 100644
View File

@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
open: true,
},
build: {
outDir: 'dist',
sourcemap: false,
},
})

4377
yarn.lock 100644

File diff suppressed because it is too large Load Diff