0.9.1
commit
1712031528
|
|
@ -0,0 +1,30 @@
|
|||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.sublime-*
|
||||
|
||||
# Project storage (user uploaded files)
|
||||
storage/
|
||||
|
||||
# Documentation files (可能是临时的)
|
||||
*.md.backup
|
||||
*.md.tmp
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
|
@ -0,0 +1,337 @@
|
|||
# NEX Docus 数据库设计文档
|
||||
|
||||
## 数据库连接信息
|
||||
- **数据库类型**: MySQL 5.7.5+
|
||||
- **连接地址**: 10.100.51.51:3306
|
||||
- **数据库名**: nex_docus
|
||||
- **字符集**: utf8mb4
|
||||
- **排序规则**: utf8mb4_unicode_ci
|
||||
|
||||
## Redis 缓存
|
||||
- **连接地址**: 10.100.51.51:6379
|
||||
- **用途**: Session 存储、Token 黑名单、文件上传临时缓存
|
||||
|
||||
---
|
||||
|
||||
## 1. 用户认证相关表
|
||||
|
||||
### 1.1 用户表 (`users`)
|
||||
|
||||
存储系统用户基本信息。
|
||||
|
||||
```sql
|
||||
CREATE TABLE `users` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID',
|
||||
`username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名(登录账号)',
|
||||
`password_hash` VARCHAR(255) NOT NULL COMMENT '密码哈希(bcrypt)',
|
||||
`nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称(显示名称)',
|
||||
`email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱',
|
||||
`phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
|
||||
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像URL',
|
||||
`status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
|
||||
`is_superuser` TINYINT DEFAULT 0 COMMENT '是否超级管理员:0-否 1-是',
|
||||
`last_login_at` DATETIME DEFAULT NULL COMMENT '最后登录时间',
|
||||
`last_login_ip` VARCHAR(50) DEFAULT NULL COMMENT '最后登录IP',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX `idx_username` (`username`),
|
||||
INDEX `idx_email` (`email`),
|
||||
INDEX `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
|
||||
```
|
||||
|
||||
### 1.2 角色表 (`roles`)
|
||||
|
||||
定义系统角色。
|
||||
|
||||
```sql
|
||||
CREATE TABLE `roles` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '角色ID',
|
||||
`role_name` VARCHAR(50) NOT NULL UNIQUE COMMENT '角色名称',
|
||||
`role_code` VARCHAR(50) NOT NULL UNIQUE COMMENT '角色编码(如:admin, editor, viewer)',
|
||||
`description` VARCHAR(255) DEFAULT NULL COMMENT '角色描述',
|
||||
`status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
|
||||
`is_system` TINYINT DEFAULT 0 COMMENT '是否系统角色:0-否 1-是(系统角色不可删除)',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX `idx_role_code` (`role_code`),
|
||||
INDEX `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';
|
||||
```
|
||||
|
||||
**预置角色数据**:
|
||||
```sql
|
||||
INSERT INTO `roles` (`role_name`, `role_code`, `description`, `is_system`) VALUES
|
||||
('超级管理员', 'super_admin', '拥有系统所有权限', 1),
|
||||
('项目管理员', 'project_admin', '可以创建和管理项目', 1),
|
||||
('普通用户', 'user', '可以查看和编辑被授权的项目', 1),
|
||||
('访客', 'guest', '只读权限', 1);
|
||||
```
|
||||
|
||||
### 1.3 用户角色关联表 (`user_roles`)
|
||||
|
||||
用户与角色多对多关系。
|
||||
|
||||
```sql
|
||||
CREATE TABLE `user_roles` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID',
|
||||
`user_id` BIGINT NOT NULL COMMENT '用户ID',
|
||||
`role_id` BIGINT NOT NULL COMMENT '角色ID',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
UNIQUE KEY `uk_user_role` (`user_id`, `role_id`),
|
||||
INDEX `idx_user_id` (`user_id`),
|
||||
INDEX `idx_role_id` (`role_id`),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 权限与菜单管理
|
||||
|
||||
### 2.1 系统菜单表 (`system_menus`)
|
||||
|
||||
定义系统功能菜单和权限点。
|
||||
|
||||
```sql
|
||||
CREATE TABLE `system_menus` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '菜单ID',
|
||||
`parent_id` BIGINT DEFAULT 0 COMMENT '父菜单ID(0表示根菜单)',
|
||||
`menu_name` VARCHAR(50) NOT NULL COMMENT '菜单名称',
|
||||
`menu_code` VARCHAR(50) NOT NULL UNIQUE COMMENT '菜单编码(权限标识)',
|
||||
`menu_type` TINYINT NOT NULL COMMENT '菜单类型:1-目录 2-菜单 3-按钮/权限点',
|
||||
`path` VARCHAR(255) DEFAULT NULL COMMENT '路由路径',
|
||||
`component` VARCHAR(255) DEFAULT NULL COMMENT '组件路径',
|
||||
`icon` VARCHAR(100) DEFAULT NULL COMMENT '图标',
|
||||
`sort_order` INT DEFAULT 0 COMMENT '排序号',
|
||||
`visible` TINYINT DEFAULT 1 COMMENT '是否可见:0-隐藏 1-显示',
|
||||
`status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
|
||||
`permission` VARCHAR(100) DEFAULT NULL COMMENT '权限字符串(如:project:create)',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX `idx_parent_id` (`parent_id`),
|
||||
INDEX `idx_menu_code` (`menu_code`),
|
||||
INDEX `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统菜单表';
|
||||
```
|
||||
|
||||
**预置菜单数据**:
|
||||
```sql
|
||||
INSERT INTO `system_menus` (`id`, `parent_id`, `menu_name`, `menu_code`, `menu_type`, `path`, `icon`, `sort_order`, `permission`) VALUES
|
||||
(1, 0, '项目管理', 'project', 1, '/projects', 'FolderOutlined', 1, NULL),
|
||||
(2, 1, '我的项目', 'my_projects', 2, '/projects/my', NULL, 1, 'project:view'),
|
||||
(3, 1, '创建项目', 'create_project', 3, NULL, NULL, 2, 'project:create'),
|
||||
(4, 1, '编辑项目', 'edit_project', 3, NULL, NULL, 3, 'project:edit'),
|
||||
(5, 1, '删除项目', 'delete_project', 3, NULL, NULL, 4, 'project:delete'),
|
||||
(10, 0, '文档管理', 'document', 1, '/documents', 'FileTextOutlined', 2, NULL),
|
||||
(11, 10, '查看文档', 'view_document', 3, NULL, NULL, 1, 'document:view'),
|
||||
(12, 10, '编辑文档', 'edit_document', 3, NULL, NULL, 2, 'document:edit'),
|
||||
(13, 10, '删除文档', 'delete_document', 3, NULL, NULL, 3, 'document:delete'),
|
||||
(20, 0, '系统管理', 'system', 1, '/system', 'SettingOutlined', 3, NULL),
|
||||
(21, 20, '用户管理', 'user_manage', 2, '/system/users', NULL, 1, 'system:user:view'),
|
||||
(22, 20, '角色管理', 'role_manage', 2, '/system/roles', NULL, 2, 'system:role:view');
|
||||
```
|
||||
|
||||
### 2.2 角色菜单授权表 (`role_menus`)
|
||||
|
||||
角色与菜单权限的多对多关系。
|
||||
|
||||
```sql
|
||||
CREATE TABLE `role_menus` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID',
|
||||
`role_id` BIGINT NOT NULL COMMENT '角色ID',
|
||||
`menu_id` BIGINT NOT NULL COMMENT '菜单ID',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
UNIQUE KEY `uk_role_menu` (`role_id`, `menu_id`),
|
||||
INDEX `idx_role_id` (`role_id`),
|
||||
INDEX `idx_menu_id` (`menu_id`),
|
||||
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`menu_id`) REFERENCES `system_menus`(`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色菜单授权表';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 项目与文档管理
|
||||
|
||||
### 3.1 项目表 (`projects`)
|
||||
|
||||
存储项目基本信息。
|
||||
|
||||
```sql
|
||||
CREATE TABLE `projects` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '项目ID',
|
||||
`name` VARCHAR(100) NOT NULL COMMENT '项目名称',
|
||||
`description` VARCHAR(500) DEFAULT NULL COMMENT '项目描述',
|
||||
`storage_key` CHAR(36) NOT NULL COMMENT '磁盘存储UUID(物理文件夹名)',
|
||||
`owner_id` BIGINT NOT NULL COMMENT '项目所有者ID',
|
||||
`is_public` TINYINT DEFAULT 0 COMMENT '是否公开:0-私有 1-公开',
|
||||
`is_template` TINYINT DEFAULT 0 COMMENT '是否模板项目:0-否 1-是',
|
||||
`status` TINYINT DEFAULT 1 COMMENT '状态:0-归档 1-活跃',
|
||||
`cover_image` VARCHAR(255) DEFAULT NULL COMMENT '封面图',
|
||||
`sort_order` INT DEFAULT 0 COMMENT '排序号',
|
||||
`visit_count` INT DEFAULT 0 COMMENT '访问次数',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE KEY `uk_storage_key` (`storage_key`),
|
||||
INDEX `idx_owner_id` (`owner_id`),
|
||||
INDEX `idx_name` (`name`),
|
||||
INDEX `idx_status` (`status`),
|
||||
INDEX `idx_created_at` (`created_at`),
|
||||
FOREIGN KEY (`owner_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目表';
|
||||
```
|
||||
|
||||
### 3.2 项目成员表 (`project_members`)
|
||||
|
||||
项目协作成员管理。
|
||||
|
||||
```sql
|
||||
CREATE TABLE `project_members` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '成员ID',
|
||||
`project_id` BIGINT NOT NULL COMMENT '项目ID',
|
||||
`user_id` BIGINT NOT NULL COMMENT '用户ID',
|
||||
`role` ENUM('admin', 'editor', 'viewer') DEFAULT 'viewer' COMMENT '项目角色:admin-管理员 editor-编辑者 viewer-查看者',
|
||||
`invited_by` BIGINT DEFAULT NULL COMMENT '邀请人ID',
|
||||
`joined_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间',
|
||||
UNIQUE KEY `uk_project_user` (`project_id`, `user_id`),
|
||||
INDEX `idx_project_id` (`project_id`),
|
||||
INDEX `idx_user_id` (`user_id`),
|
||||
INDEX `idx_role` (`role`),
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`invited_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目成员表';
|
||||
```
|
||||
|
||||
### 3.3 文档元数据表 (`document_meta`)
|
||||
|
||||
可选表,用于存储文档的额外元数据(标签、评论数等)。
|
||||
|
||||
```sql
|
||||
CREATE TABLE `document_meta` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '元数据ID',
|
||||
`project_id` BIGINT NOT NULL COMMENT '项目ID',
|
||||
`file_path` VARCHAR(500) NOT NULL COMMENT '文件相对路径',
|
||||
`title` VARCHAR(200) DEFAULT NULL COMMENT '文档标题',
|
||||
`tags` VARCHAR(500) DEFAULT NULL COMMENT '标签(JSON数组)',
|
||||
`author_id` BIGINT DEFAULT NULL COMMENT '作者ID',
|
||||
`word_count` INT DEFAULT 0 COMMENT '字数统计',
|
||||
`view_count` INT DEFAULT 0 COMMENT '浏览次数',
|
||||
`last_editor_id` BIGINT DEFAULT NULL COMMENT '最后编辑者ID',
|
||||
`last_edited_at` DATETIME DEFAULT NULL COMMENT '最后编辑时间',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE KEY `uk_project_path` (`project_id`, `file_path`),
|
||||
INDEX `idx_project_id` (`project_id`),
|
||||
INDEX `idx_author_id` (`author_id`),
|
||||
FULLTEXT KEY `ft_title_tags` (`title`, `tags`),
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON DELETE SET NULL,
|
||||
FOREIGN KEY (`last_editor_id`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文档元数据表';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 操作日志与审计
|
||||
|
||||
### 4.1 操作日志表 (`operation_logs`)
|
||||
|
||||
记录关键操作日志。
|
||||
|
||||
```sql
|
||||
CREATE TABLE `operation_logs` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '日志ID',
|
||||
`user_id` BIGINT DEFAULT NULL COMMENT '操作用户ID',
|
||||
`username` VARCHAR(50) DEFAULT NULL COMMENT '用户名(冗余字段)',
|
||||
`operation_type` VARCHAR(50) NOT NULL COMMENT '操作类型(create, update, delete等)',
|
||||
`resource_type` VARCHAR(50) NOT NULL COMMENT '资源类型(project, document, user等)',
|
||||
`resource_id` BIGINT DEFAULT NULL COMMENT '资源ID',
|
||||
`detail` TEXT DEFAULT NULL COMMENT '操作详情(JSON)',
|
||||
`ip_address` VARCHAR(50) DEFAULT NULL COMMENT 'IP地址',
|
||||
`user_agent` VARCHAR(500) DEFAULT NULL COMMENT '用户代理',
|
||||
`status` TINYINT DEFAULT 1 COMMENT '状态:0-失败 1-成功',
|
||||
`error_message` TEXT DEFAULT NULL COMMENT '错误信息',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
|
||||
INDEX `idx_user_id` (`user_id`),
|
||||
INDEX `idx_resource` (`resource_type`, `resource_id`),
|
||||
INDEX `idx_created_at` (`created_at`),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 文件存储说明
|
||||
|
||||
### 5.1 物理存储结构
|
||||
|
||||
**根目录**: `/data/nex_docus_store/`
|
||||
|
||||
```
|
||||
/data/nex_docus_store/
|
||||
├── projects/
|
||||
│ ├── <project_uuid>/ # 项目文件夹(UUID命名)
|
||||
│ │ ├── README.md # 项目首页
|
||||
│ │ ├── _assets/ # 资源文件夹(图片、附件)
|
||||
│ │ │ ├── images/ # 图片
|
||||
│ │ │ └── files/ # 附件
|
||||
│ │ ├── <folder>/ # 用户创建的文件夹
|
||||
│ │ │ └── *.md # Markdown文档
|
||||
│ │ └── ...
|
||||
└── temp/ # 临时文件(上传缓存)
|
||||
```
|
||||
|
||||
### 5.2 存储规则
|
||||
|
||||
1. **项目隔离**: 每个项目使用独立的 UUID 文件夹
|
||||
2. **路径映射**: `storage_key` 字段存储 UUID,数据库存储显示名称
|
||||
3. **资源管理**: 图片和附件存储在 `_assets` 目录
|
||||
4. **安全控制**: 所有文件访问必须经过权限验证
|
||||
5. **备份策略**: 可直接备份 `/data/nex_docus_store/` 目录
|
||||
|
||||
---
|
||||
|
||||
## 6. 索引优化建议
|
||||
|
||||
1. **复合索引**:
|
||||
- `projects`: (`owner_id`, `status`)
|
||||
- `project_members`: (`project_id`, `role`)
|
||||
- `document_meta`: (`project_id`, `last_edited_at`)
|
||||
|
||||
2. **覆盖索引**: 针对高频查询添加包含查询字段的复合索引
|
||||
|
||||
3. **分区表**: 当 `operation_logs` 数据量大时,可按月份分区
|
||||
|
||||
---
|
||||
|
||||
## 7. 数据库初始化脚本
|
||||
|
||||
创建完整的初始化 SQL 文件:`backend/scripts/init_database.sql`
|
||||
|
||||
执行顺序:
|
||||
1. 创建数据库
|
||||
2. 创建所有表
|
||||
3. 插入初始角色数据
|
||||
4. 插入初始菜单数据
|
||||
5. 创建默认管理员用户
|
||||
|
||||
---
|
||||
|
||||
## 8. 性能优化建议
|
||||
|
||||
1. **连接池配置**: SQLAlchemy 配置合理的连接池大小
|
||||
2. **查询优化**: 使用 `joinedload` 避免 N+1 查询
|
||||
3. **缓存策略**:
|
||||
- 用户信息缓存(5分钟)
|
||||
- 菜单权限缓存(10分钟)
|
||||
- 项目列表缓存(1分钟)
|
||||
4. **读写分离**: 后续可配置主从数据库
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0
|
||||
**最后更新**: 2023-12-20
|
||||
**维护人**: Mula.liu
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
|
||||
# 开发环境说明
|
||||
## Mysql:
|
||||
+ 连接字符串:10.100.51.51:3306
|
||||
+ 数据库名:nex_docus
|
||||
+ 字符集:utf8mb4
|
||||
+ 排序规则:utf8mb4_unicode_ci
|
||||
+ 用户名、密码: root | Unis@123
|
||||
|
||||
## Redis:
|
||||
+ 连接字符串:10.100.51.51:6379
|
||||
+ db:1
|
||||
+ 密码: Unis@123
|
||||
|
||||
## 本地开发环境:
|
||||
frontend: 前端
|
||||
backend: 后端(已经创建虚拟环境venv)
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
# NEX Docus 实施计划
|
||||
|
||||
## Stage 1: 基础架构与数据库设计
|
||||
**Goal**: 完成数据库设计、后端项目初始化、核心数据模型创建
|
||||
**Success Criteria**:
|
||||
- DATABASE.md 文档完成
|
||||
- 后端项目结构搭建完成
|
||||
- 数据库连接测试通过
|
||||
- 所有数据表创建完成
|
||||
**Tests**:
|
||||
- 数据库连接测试
|
||||
- 表结构验证
|
||||
- ORM 模型单元测试
|
||||
**Status**: ✅ Completed
|
||||
|
||||
---
|
||||
|
||||
## Stage 2: 用户认证与权限系统
|
||||
**Goal**: 实现完整的用户认证、角色权限、菜单管理系统
|
||||
**Success Criteria**:
|
||||
- JWT 认证流程完整
|
||||
- 用户注册、登录接口正常工作
|
||||
- RBAC 权限校验中间件实现
|
||||
- 角色-权限-菜单关联关系正确
|
||||
**Tests**:
|
||||
- 登录/注册接口测试
|
||||
- Token 生成和验证测试
|
||||
- 权限校验测试
|
||||
- 角色授权测试
|
||||
**Status**: ✅ Completed
|
||||
|
||||
---
|
||||
|
||||
## Stage 3: 文件存储核心服务
|
||||
**Goal**: 实现安全的文件系统存储管理服务
|
||||
**Success Criteria**:
|
||||
- 路径安全校验机制完成
|
||||
- 文件读写、目录树生成功能正常
|
||||
- 文件上传、下载流式传输实现
|
||||
- UUID 文件夹映射机制正常
|
||||
**Tests**:
|
||||
- 路径注入攻击防御测试
|
||||
- 文件读写性能测试
|
||||
- 大文件上传测试
|
||||
- 目录树生成正确性测试
|
||||
**Status**: ✅ Completed
|
||||
|
||||
---
|
||||
|
||||
## Stage 4: 项目与文档管理 API
|
||||
**Goal**: 实现项目管理、文档 CRUD、协作成员管理的完整 API
|
||||
**Success Criteria**:
|
||||
- 项目创建/列表/详情接口完成
|
||||
- 文档 CRUD 接口完成
|
||||
- 成员邀请/权限管理接口完成
|
||||
- 图片/附件上传接口完成
|
||||
**Tests**:
|
||||
- 项目 CRUD 接口测试
|
||||
- 文档操作接口测试
|
||||
- 成员权限验证测试
|
||||
- 文件上传接口测试
|
||||
**Status**: ✅ Completed
|
||||
|
||||
---
|
||||
|
||||
## Stage 5: 前端整合与联调
|
||||
**Goal**: 整合现有前端代码,适配新后端 API,实现完整业务流程
|
||||
**Success Criteria**:
|
||||
- 前端路由和布局整合完成
|
||||
- API 请求封装完成
|
||||
- 项目列表页面实现
|
||||
- 文档编辑页面实现
|
||||
- 用户登录注册页面实现
|
||||
**Tests**:
|
||||
- 端到端业务流程测试
|
||||
- 前后端联调测试
|
||||
- 用户体验测试
|
||||
**Status**: ✅ Completed
|
||||
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
这是一份为您量身定制的《NEX Docus 产品技术方案设计文档》。
|
||||
|
||||
这份文档整合了我们之前的讨论,采用了**“数据库管理权限 + 文件系统存储内容”**的混合架构,后端确定采用 **FastAPI**,以实现高性能和快速开发。
|
||||
|
||||
---
|
||||
|
||||
# NEX Docus 产品技术方案设计文档 (V1.0)
|
||||
|
||||
**文档状态:** 正式版
|
||||
**最后更新:** 2023-12-20
|
||||
**技术负责人:** [Mula.liu]
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目概述 (Overview)
|
||||
|
||||
### 1.1 项目简介
|
||||
|
||||
**NEX Docus** 是一款面向团队协作的轻量级文档管理平台。它结合了传统文档系统的权限管理便利性和本地文件存储的数据透明性。
|
||||
|
||||
### 1.2 核心设计理念
|
||||
|
||||
* **文件即真理 (File as Truth):** 所有文档内容、图片、附件直接以文件形式存储在服务器磁盘,不存入数据库 `BLOB` 字段。方便备份、迁移及后续支持 Git 版本控制。
|
||||
* **三级架构:** 用户 (User) -> 项目 (Project) -> 文档/文件夹 (File/Folder)。
|
||||
* **严格隔离:** 基于项目的物理文件夹隔离,结合数据库的 RBAC 权限控制。
|
||||
|
||||
### 1.3 功能目标
|
||||
|
||||
1. **多用户/多租户:** 支持用户注册、登录,创建私有项目。
|
||||
2. **项目协作:** 项目拥有者可邀请成员(只读/读写权限)。
|
||||
3. **文档管理:** 支持无限层级目录(物理文件夹映射),支持 Markdown 编辑与预览。
|
||||
4. **资源管理:** 支持图片、附件拖拽上传与引用。
|
||||
|
||||
---
|
||||
|
||||
## 2. 技术选型 (Tech Stack)
|
||||
|
||||
### 2.1 前端 (Frontend)
|
||||
|
||||
* **核心框架:** React 18+
|
||||
* **UI 组件库:** Ant Design 5.0 (企业级交互)
|
||||
* **样式库:** Tailwind CSS (快速排版)
|
||||
* **状态管理:**
|
||||
* **Markdown:**
|
||||
|
||||
### 2.2 后端 (Backend)
|
||||
|
||||
* **核心框架:** **Python 3.9.6+** & **FastAPI** (高性能异步框架)
|
||||
* **WSGI/ASGI:** Uvicorn
|
||||
* **ORM:** SQLAlchemy (配合 Pydantic 做数据校验)
|
||||
* **认证:** PyJWT (OAuth2 with Password Bearer)
|
||||
* **文件操作:** `aiofiles` (异步文件读写) + `shutil`
|
||||
|
||||
### 2.3 数据存储 (Storage)
|
||||
|
||||
* **结构化数据:** MySQL 5.7.5 (存储用户、项目元数据、权限关系)
|
||||
* **非结构化数据:** 本地文件系统 (Local File System)
|
||||
|
||||
---
|
||||
|
||||
## 3. 系统架构设计
|
||||
|
||||
### 3.1 逻辑架构图
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Client[前端 React App] --> |JSON/HTTP| API[FastAPI 后端服务]
|
||||
|
||||
subgraph "后端核心层"
|
||||
API --> |Auth & Meta| DB[(MySQL 数据库)]
|
||||
API --> |IO Stream| FS[文件管理服务]
|
||||
end
|
||||
|
||||
subgraph "存储层"
|
||||
DB -- 存储关系/权限 --> Metadata[元数据表]
|
||||
FS -- 读写 MD/图片 --> Disk[服务器磁盘 /data/projects/]
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
### 3.2 目录存储结构 (物理设计)
|
||||
|
||||
为了避免中文乱码和项目重名问题,**磁盘文件夹名使用 UUID,数据库存储映射关系**。
|
||||
|
||||
**根目录:** `/data/nex_docus_store/`
|
||||
|
||||
```text
|
||||
/data/nex_docus_store/
|
||||
├── projects/
|
||||
│ ├── 550e8400-e29b-41d4-a716-446655440000/ <-- Project A (UUID)
|
||||
│ │ ├── README.md <-- 首页文档
|
||||
│ │ ├── _assets/ <-- 附件资源目录
|
||||
│ │ │ ├── logo.png
|
||||
│ │ │ └── demo.mp4
|
||||
│ │ ├── 01-产品设计/ <-- 普通目录
|
||||
│ │ │ ├── 需求文档.md
|
||||
│ │ │ └── 原型图.md
|
||||
│ │ └── 02-技术方案/
|
||||
│ │ └── 架构.md
|
||||
│ └── 7f8c... (Project B UUID)/
|
||||
└── temp/ <-- 临时上传缓存
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据库设计 (主要表结构)
|
||||
|
||||
|
||||
### 4.1 用户表 (`users`)
|
||||
|
||||
```sql
|
||||
CREATE TABLE `users` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
`username` VARCHAR(50) NOT NULL UNIQUE,
|
||||
`password_hash` VARCHAR(128) NOT NULL,
|
||||
`nickname` VARCHAR(50),
|
||||
`avatar` VARCHAR(255),
|
||||
`status` TINYINT DEFAULT 1
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
### 4.2 项目表 (`projects`)
|
||||
|
||||
```sql
|
||||
CREATE TABLE `projects` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
`name` VARCHAR(100) NOT NULL COMMENT '项目展示名称',
|
||||
`description` VARCHAR(255),
|
||||
`storage_key` CHAR(36) NOT NULL COMMENT '磁盘文件夹UUID名称',
|
||||
`owner_id` BIGINT NOT NULL,
|
||||
`is_public` TINYINT DEFAULT 0,
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uk_storage` (`storage_key`)
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
### 4.3 项目成员表 (`project_members`)
|
||||
|
||||
```sql
|
||||
CREATE TABLE `project_members` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
`project_id` BIGINT NOT NULL,
|
||||
`user_id` BIGINT NOT NULL,
|
||||
`role` ENUM('admin', 'editor', 'viewer') DEFAULT 'viewer',
|
||||
UNIQUE KEY `uk_member` (`project_id`, `user_id`)
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API 接口设计 (FastAPI)
|
||||
|
||||
所有接口前缀: `/api/v1`
|
||||
|
||||
### 5.1 项目管理 (Project)
|
||||
|
||||
* `GET /projects`: 获取我的项目列表(包括创建的和协作的)。
|
||||
* `POST /projects`: 创建新项目。
|
||||
* *逻辑:* 1. DB插入记录; 2. 生成UUID; 3. `os.makedirs` 创建物理文件夹; 4. 创建默认 `README.md`。
|
||||
|
||||
|
||||
* `POST /projects/{id}/members`: 邀请成员。
|
||||
|
||||
### 5.2 文件系统操作 (File System) - **核心**
|
||||
|
||||
* **获取目录树**
|
||||
* `GET /projects/{id}/tree`
|
||||
* *逻辑:* 递归遍历 `storage_key` 对应的目录,忽略 `.` 开头文件,返回 AntD Tree 格式的 JSON。
|
||||
|
||||
|
||||
* **获取文件内容**
|
||||
* `GET /projects/{id}/file`
|
||||
* *Query Param:* `?path=01-产品设计/需求文档.md`
|
||||
* *逻辑:* 读取文件文本内容返回。
|
||||
|
||||
|
||||
* **保存文件**
|
||||
* `POST /projects/{id}/file`
|
||||
* *Body:* `{ "path": "...", "content": "..." }`
|
||||
* *逻辑:* 覆盖写入。如果路径中的文件夹不存在,自动创建。
|
||||
|
||||
|
||||
* **新建/重命名/删除**
|
||||
* `POST /projects/{id}/file/operate`
|
||||
* *Body:* `{ "action": "rename|delete|create_dir", "path": "...", "new_path": "..." }`
|
||||
|
||||
|
||||
|
||||
### 5.3 资源服务 (Assets)
|
||||
|
||||
* `POST /projects/{id}/upload`: 上传图片 -> 存入 `_assets` -> 返回相对路径。
|
||||
* `GET /projects/{id}/assets/{filename}`: 流式返回图片数据 (StreamResponse)。
|
||||
* *注意:* 必须鉴权,防止直接通过 URL 盗链访问私有项目图片。
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键模块实现逻辑 (Python 代码示意)
|
||||
|
||||
### 6.1 路径安全检查 (Security Util)
|
||||
|
||||
这是文件存储系统最重要的部分,防止 `../../etc/passwd` 攻击。
|
||||
|
||||
```python
|
||||
import os
|
||||
from fastapi import HTTPException
|
||||
|
||||
BASE_STORE_PATH = "/data/nex_docus_store/projects"
|
||||
|
||||
def get_secure_path(project_uuid: str, relative_path: str):
|
||||
# 1. 构建项目根目录绝对路径
|
||||
project_root = os.path.abspath(os.path.join(BASE_STORE_PATH, project_uuid))
|
||||
|
||||
# 2. 构建目标文件绝对路径
|
||||
target_path = os.path.abspath(os.path.join(project_root, relative_path))
|
||||
|
||||
# 3. 核心校验: 目标路径必须以项目根目录开头
|
||||
if not target_path.startswith(project_root):
|
||||
raise HTTPException(status_code=403, detail="非法路径访问")
|
||||
|
||||
return target_path
|
||||
|
||||
```
|
||||
|
||||
### 6.2 目录树生成 (Tree Generator)
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
def generate_tree(path, relative_root=""):
|
||||
tree = []
|
||||
# 按名称排序,文件夹在前
|
||||
items = sorted(os.listdir(path), key=lambda x: (not os.path.isdir(os.path.join(path, x)), x))
|
||||
|
||||
for item in items:
|
||||
if item.startswith('.'): continue # 跳过隐藏文件
|
||||
|
||||
full_path = os.path.join(path, item)
|
||||
rel_path = os.path.join(relative_root, item)
|
||||
|
||||
node = {
|
||||
"title": item,
|
||||
"key": rel_path, # 前端用这个路径请求文件
|
||||
}
|
||||
|
||||
if os.path.isdir(full_path):
|
||||
node["isLeaf"] = False
|
||||
node["children"] = generate_tree(full_path, rel_path)
|
||||
else:
|
||||
node["isLeaf"] = True
|
||||
|
||||
tree.append(node)
|
||||
return tree
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 前端实现细节 (React)
|
||||
|
||||
### 7.1 编辑器组件
|
||||
|
||||
建议封装一个 `FileEditor` 组件。
|
||||
|
||||
```jsx
|
||||
// 伪代码
|
||||
const FileEditor = ({ projectId, filePath }) => {
|
||||
const [content, setContent] = useState('');
|
||||
|
||||
// 1. 加载文件
|
||||
useEffect(() => {
|
||||
if(!filePath) return;
|
||||
fetch(`/api/v1/projects/${projectId}/file?path=${filePath}`)
|
||||
.then(res => res.text())
|
||||
.then(text => setContent(text));
|
||||
}, [filePath]);
|
||||
|
||||
// 2. 自动保存/快捷键保存
|
||||
const handleSave = () => {
|
||||
post(`/api/v1/projects/${projectId}/file`, { path: filePath, content });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Toolbar onSave={handleSave} />
|
||||
<MarkdownEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
imageUploadHandler={async (file) => {
|
||||
// 调用后端上传接口,返回 markdown 图片语法
|
||||
// 
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 开发路线图 (Roadmap)
|
||||
|
||||
### 第一阶段:MVP (最小可行性产品)
|
||||
|
||||
1. **后端:** 搭建 FastAPI 基础框架,连接 MySQL。
|
||||
2. **后端:** 实现 `StorageManager`,跑通文件读写和路径安全校验。
|
||||
3. **API:** 实现“项目列表”、“目录树获取”、“读取文档”三个接口。
|
||||
4. **前端:** 首页展示项目列表 -> 点击进入文档页 -> 左侧树展示目录 -> 右侧展示 Markdown。
|
||||
5. **目标:** 能看、能跑通流程,暂不支持在线编辑。
|
||||
|
||||
### 第二阶段:编辑与协作
|
||||
|
||||
1. **API:** 实现文件保存、新建文件/文件夹接口。
|
||||
2. **前端:** 集成编辑器,实现 Cmd+S 保存。
|
||||
3. **功能:** 图片上传接口实现。
|
||||
|
||||
### 第三阶段:权限与优化
|
||||
|
||||
1. **后端:** 完善 `Dependency` 注入,实现严格的 API 权限校验。
|
||||
2. **前端:** 添加“成员管理”模态框。
|
||||
3. **系统:** 增加 Nginx 缓存策略,提升静态资源加载速度。
|
||||
|
||||
---
|
||||
|
||||
## 9. 部署架构建议
|
||||
|
||||
```text
|
||||
Server (Linux)
|
||||
├── Nginx (80/443)
|
||||
│ ├── /api/ --> Proxy Pass to FastAPI (Port 8000)
|
||||
│ └── / --> Static Files (React Build)
|
||||
├── Docker Container: FastAPI App
|
||||
└── Docker Container: MySQL 5.7
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
# NEX Docus 快速启动指南
|
||||
|
||||
欢迎使用 NEX Docus!这是一个完整的快速启动指南,帮助你在 5 分钟内运行整个项目。
|
||||
|
||||
---
|
||||
|
||||
## 📋 前置要求
|
||||
|
||||
确保已安装以下软件:
|
||||
|
||||
- **Python**: 3.9.6+
|
||||
- **Node.js**: 16+
|
||||
- **MySQL**: 5.7.5+
|
||||
- **Redis**: 最新稳定版
|
||||
- **Git**: 最新版本
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速启动(3 步)
|
||||
|
||||
### Step 1: 初始化数据库
|
||||
|
||||
```bash
|
||||
# 1. 连接到 MySQL
|
||||
mysql -h10.100.51.51 -uroot -pUnis@321
|
||||
|
||||
# 2. 执行初始化脚本
|
||||
source backend/scripts/init_database.sql
|
||||
|
||||
# 或使用命令行直接执行
|
||||
mysql -h10.100.51.51 -uroot -pUnis@321 < backend/scripts/init_database.sql
|
||||
```
|
||||
|
||||
### Step 2: 启动后端服务
|
||||
|
||||
```bash
|
||||
# 1. 进入后端目录
|
||||
cd backend
|
||||
|
||||
# 2. 激活虚拟环境
|
||||
source venv/bin/activate # macOS/Linux
|
||||
# 或
|
||||
venv\Scripts\activate # Windows
|
||||
|
||||
# 3. 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 4. 启动服务
|
||||
python main.py
|
||||
```
|
||||
|
||||
后端服务将在 http://localhost:8000 启动
|
||||
|
||||
### Step 3: 启动前端服务
|
||||
|
||||
```bash
|
||||
# 1. 打开新终端,进入前端目录
|
||||
cd forntend
|
||||
|
||||
# 2. 安装依赖
|
||||
npm install
|
||||
# 或
|
||||
pnpm install
|
||||
|
||||
# 3. 启动开发服务器
|
||||
npm run dev
|
||||
```
|
||||
|
||||
前端应用将在 http://localhost:5173 启动
|
||||
|
||||
---
|
||||
|
||||
## 🎉 开始使用
|
||||
|
||||
### 1. 登录系统
|
||||
|
||||
访问 http://localhost:5173,使用默认管理员账号登录:
|
||||
|
||||
- **用户名**: `admin`
|
||||
- **密码**: `admin123`
|
||||
|
||||
### 2. 创建项目
|
||||
|
||||
- 点击「创建项目」按钮
|
||||
- 填写项目名称和描述
|
||||
- 提交创建
|
||||
|
||||
### 3. 编辑文档
|
||||
|
||||
- 点击项目卡片进入项目
|
||||
- 在左侧目录树中选择文件
|
||||
- 在右侧编辑器中编辑 Markdown
|
||||
- 点击「保存」按钮保存更改
|
||||
|
||||
---
|
||||
|
||||
## 📚 目录结构
|
||||
|
||||
```
|
||||
NEX Docus/
|
||||
├── backend/ # 后端服务(FastAPI)
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # API 路由
|
||||
│ │ ├── core/ # 核心配置
|
||||
│ │ ├── models/ # 数据库模型
|
||||
│ │ ├── schemas/ # Pydantic Schemas
|
||||
│ │ ├── services/ # 业务逻辑
|
||||
│ │ └── middleware/ # 中间件
|
||||
│ ├── scripts/ # 脚本文件
|
||||
│ ├── main.py # 应用入口
|
||||
│ └── requirements.txt # 依赖包
|
||||
│
|
||||
├── forntend/ # 前端应用(React + Vite)
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API 请求
|
||||
│ │ ├── components/ # 通用组件
|
||||
│ │ ├── pages/ # 页面组件
|
||||
│ │ ├── stores/ # 状态管理
|
||||
│ │ └── utils/ # 工具函数
|
||||
│ ├── package.json
|
||||
│ └── vite.config.js
|
||||
│
|
||||
├── DATABASE.md # 数据库设计文档
|
||||
├── IMPLEMENTATION_PLAN.md # 实施计划
|
||||
├── PROJECT.md # 项目技术方案
|
||||
└── DEPLOYE.md # 部署配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 常见问题
|
||||
|
||||
### 1. 后端启动失败
|
||||
|
||||
**问题**: `ModuleNotFoundError: No module named 'xxx'`
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
cd backend
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. 数据库连接失败
|
||||
|
||||
**问题**: `Can't connect to MySQL server`
|
||||
|
||||
**解决**:
|
||||
- 检查 `backend/.env` 中的数据库配置
|
||||
- 确认 MySQL 服务已启动
|
||||
- 测试数据库连接:
|
||||
```bash
|
||||
mysql -h10.100.51.51 -uroot -pUnis@321 -e "SELECT 1"
|
||||
```
|
||||
|
||||
### 3. 前端请求 404
|
||||
|
||||
**问题**: API 请求返回 404
|
||||
|
||||
**解决**:
|
||||
- 确认后端服务已启动
|
||||
- 检查 `forntend/.env` 中的 API 地址配置
|
||||
- 检查浏览器控制台的网络请求
|
||||
|
||||
### 4. 文件上传/保存失败
|
||||
|
||||
**问题**: 文件操作失败
|
||||
|
||||
**解决**:
|
||||
- 确保文件存储目录存在并有写权限:
|
||||
```bash
|
||||
mkdir -p /data/nex_docus_store/{projects,temp}
|
||||
chmod 755 /data/nex_docus_store
|
||||
```
|
||||
- 或修改 `backend/.env` 中的存储路径为当前用户有权限的目录
|
||||
|
||||
---
|
||||
|
||||
## 📖 API 文档
|
||||
|
||||
启动后端服务后,访问以下地址查看 API 文档:
|
||||
|
||||
- **Swagger UI**: http://localhost:8000/docs
|
||||
- **ReDoc**: http://localhost:8000/redoc
|
||||
|
||||
---
|
||||
|
||||
## 🔐 安全提醒
|
||||
|
||||
⚠️ **生产环境部署前请务必修改:**
|
||||
|
||||
1. 修改默认管理员密码
|
||||
2. 修改 `backend/.env` 中的 `SECRET_KEY`
|
||||
3. 配置 HTTPS
|
||||
4. 限制 CORS 允许的域名
|
||||
5. 配置防火墙规则
|
||||
|
||||
---
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
如果遇到问题,请查看:
|
||||
|
||||
1. **PROJECT.md** - 完整技术方案
|
||||
2. **DATABASE.md** - 数据库设计文档
|
||||
3. **IMPLEMENTATION_PLAN.md** - 实施计划
|
||||
|
||||
或联系技术负责人:Mula.liu
|
||||
|
||||
---
|
||||
|
||||
## ⚡️ 快速命令参考
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
cd backend && source venv/bin/activate && python main.py
|
||||
|
||||
# 前端
|
||||
cd forntend && npm run dev
|
||||
|
||||
# 数据库初始化
|
||||
mysql -h10.100.51.51 -uroot -pUnis@321 < backend/scripts/init_database.sql
|
||||
|
||||
# 查看日志
|
||||
tail -f backend/logs/app.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**祝你使用愉快!🎊**
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
# NEX Docus 文档管理平台
|
||||
|
||||
<div align="center">
|
||||
|
||||
一个轻量级、高性能的团队协作文档管理平台
|
||||
|
||||
[](LICENSE)
|
||||
[](https://www.python.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://fastapi.tiangolo.com/)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- 🚀 **高性能**: FastAPI 异步后端 + React 18 前端
|
||||
- 📁 **文件存储**: 数据库管理权限 + 文件系统存储内容
|
||||
- 🔐 **权限控制**: 完整的 RBAC 权限体系
|
||||
- 📝 **Markdown 编辑**: 实时预览、图片上传
|
||||
- 👥 **团队协作**: 项目成员管理、角色分配
|
||||
- 🌲 **目录树**: 无限层级目录支持
|
||||
- 💾 **数据安全**: 文件即真理、易于备份和迁移
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 技术架构
|
||||
|
||||
### 后端技术栈
|
||||
- **框架**: FastAPI (Python 3.9+)
|
||||
- **ORM**: SQLAlchemy 2.0 (异步)
|
||||
- **数据库**: MySQL 5.7.5+
|
||||
- **缓存**: Redis
|
||||
- **认证**: JWT (PyJWT)
|
||||
- **文件**: aiofiles (异步 I/O)
|
||||
|
||||
### 前端技术栈
|
||||
- **框架**: React 18
|
||||
- **构建工具**: Vite
|
||||
- **UI 组件**: Ant Design 5
|
||||
- **路由**: React Router v6
|
||||
- **状态管理**: Zustand
|
||||
- **Markdown**: @uiw/react-md-editor
|
||||
- **样式**: Tailwind CSS
|
||||
|
||||
---
|
||||
|
||||
## 📦 项目结构
|
||||
|
||||
```
|
||||
NEX Docus/
|
||||
├── backend/ # FastAPI 后端服务
|
||||
│ ├── app/
|
||||
│ │ ├── api/v1/ # API 路由(v1)
|
||||
│ │ ├── core/ # 核心配置
|
||||
│ │ ├── models/ # 数据库模型
|
||||
│ │ ├── schemas/ # Pydantic Schemas
|
||||
│ │ ├── services/ # 业务逻辑服务
|
||||
│ │ └── middleware/ # 中间件
|
||||
│ ├── scripts/ # 初始化脚本
|
||||
│ └── main.py # 应用入口
|
||||
│
|
||||
├── forntend/ # React 前端应用
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API 封装
|
||||
│ │ ├── components/ # 通用组件
|
||||
│ │ ├── pages/ # 页面组件
|
||||
│ │ ├── stores/ # 状态管理
|
||||
│ │ └── utils/ # 工具函数
|
||||
│ └── package.json
|
||||
│
|
||||
├── DATABASE.md # 数据库设计文档
|
||||
├── PROJECT.md # 技术方案文档
|
||||
├── QUICKSTART.md # 快速启动指南
|
||||
└── IMPLEMENTATION_PLAN.md # 实施计划
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Python 3.9.6+
|
||||
- Node.js 16+
|
||||
- MySQL 5.7.5+
|
||||
- Redis (最新版)
|
||||
|
||||
### 1. 初始化数据库
|
||||
|
||||
```bash
|
||||
mysql -h10.100.51.51 -uroot -pUnis@321 < backend/scripts/init_database.sql
|
||||
```
|
||||
|
||||
### 2. 启动后端
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python main.py
|
||||
```
|
||||
|
||||
后端将运行在 http://localhost:8000
|
||||
|
||||
### 3. 启动前端
|
||||
|
||||
```bash
|
||||
cd forntend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
前端将运行在 http://localhost:5173
|
||||
|
||||
### 4. 登录使用
|
||||
|
||||
- **用户名**: `admin`
|
||||
- **密码**: `admin123`
|
||||
|
||||
---
|
||||
|
||||
## 📖 核心功能
|
||||
|
||||
### 用户认证
|
||||
- ✅ 用户注册/登录
|
||||
- ✅ JWT Token 认证
|
||||
- ✅ 密码加密存储
|
||||
- ✅ 权限角色管理
|
||||
|
||||
### 项目管理
|
||||
- ✅ 创建/编辑/删除项目
|
||||
- ✅ 项目成员管理
|
||||
- ✅ 访问权限控制
|
||||
- ✅ 项目归档
|
||||
|
||||
### 文档编辑
|
||||
- ✅ Markdown 实时预览
|
||||
- ✅ 无限层级目录
|
||||
- ✅ 文件/文件夹操作
|
||||
- ✅ 图片/附件上传
|
||||
- ✅ 自动保存
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ 数据库设计
|
||||
|
||||
### 核心表结构
|
||||
|
||||
- `users` - 用户表
|
||||
- `roles` - 角色表
|
||||
- `user_roles` - 用户角色关联
|
||||
- `system_menus` - 系统菜单
|
||||
- `role_menus` - 角色菜单授权
|
||||
- `projects` - 项目表
|
||||
- `project_members` - 项目成员
|
||||
- `document_meta` - 文档元数据(可选)
|
||||
- `operation_logs` - 操作日志
|
||||
|
||||
详细设计请查看 [DATABASE.md](DATABASE.md)
|
||||
|
||||
---
|
||||
|
||||
## 🔒 安全特性
|
||||
|
||||
- ✅ **密码加密**: bcrypt 加密存储
|
||||
- ✅ **JWT 认证**: 访问令牌机制
|
||||
- ✅ **路径安全**: 防止路径穿越攻击
|
||||
- ✅ **权限校验**: 基于角色的访问控制
|
||||
- ✅ **SQL 注入防护**: ORM 自动防护
|
||||
- ✅ **CORS 配置**: 跨域请求控制
|
||||
|
||||
---
|
||||
|
||||
## 📋 API 文档
|
||||
|
||||
启动后端后访问:
|
||||
|
||||
- **Swagger UI**: http://localhost:8000/docs
|
||||
- **ReDoc**: http://localhost:8000/redoc
|
||||
|
||||
### 主要接口
|
||||
|
||||
**认证**
|
||||
- `POST /api/v1/auth/register` - 注册
|
||||
- `POST /api/v1/auth/login` - 登录
|
||||
- `GET /api/v1/auth/me` - 获取当前用户
|
||||
|
||||
**项目**
|
||||
- `GET /api/v1/projects/` - 项目列表
|
||||
- `POST /api/v1/projects/` - 创建项目
|
||||
- `GET /api/v1/projects/{id}` - 项目详情
|
||||
- `PUT /api/v1/projects/{id}` - 更新项目
|
||||
- `DELETE /api/v1/projects/{id}` - 删除项目
|
||||
|
||||
**文件**
|
||||
- `GET /api/v1/files/{project_id}/tree` - 目录树
|
||||
- `GET /api/v1/files/{project_id}/file` - 文件内容
|
||||
- `POST /api/v1/files/{project_id}/file` - 保存文件
|
||||
- `POST /api/v1/files/{project_id}/upload` - 上传文件
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 开发指南
|
||||
|
||||
### 后端开发
|
||||
|
||||
1. 添加新的数据模型到 `backend/app/models/`
|
||||
2. 定义 Pydantic Schema 到 `backend/app/schemas/`
|
||||
3. 在 `backend/app/api/v1/` 创建路由
|
||||
4. 实现业务逻辑到 `backend/app/services/`
|
||||
|
||||
### 前端开发
|
||||
|
||||
1. 在 `forntend/src/api/` 封装 API 请求
|
||||
2. 在 `forntend/src/pages/` 创建页面组件
|
||||
3. 在 `forntend/src/App.jsx` 添加路由
|
||||
4. 使用 Zustand 管理全局状态
|
||||
|
||||
---
|
||||
|
||||
## 📝 文档
|
||||
|
||||
- [技术方案文档](PROJECT.md) - 完整的技术设计方案
|
||||
- [数据库设计](DATABASE.md) - 数据库表结构设计
|
||||
- [快速启动指南](QUICKSTART.md) - 5分钟快速上手
|
||||
- [实施计划](IMPLEMENTATION_PLAN.md) - 分阶段实施计划
|
||||
|
||||
---
|
||||
|
||||
## 🔄 版本历史
|
||||
|
||||
### v1.0.0 (2023-12-20)
|
||||
|
||||
- ✅ 完整的用户认证系统
|
||||
- ✅ 项目管理功能
|
||||
- ✅ Markdown 文档编辑
|
||||
- ✅ 文件系统管理
|
||||
- ✅ 权限控制体系
|
||||
- ✅ 团队协作功能
|
||||
|
||||
---
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
Copyright © 2023 Mula.liu
|
||||
|
||||
---
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
感谢以下开源项目:
|
||||
|
||||
- [FastAPI](https://fastapi.tiangolo.com/)
|
||||
- [React](https://reactjs.org/)
|
||||
- [Ant Design](https://ant.design/)
|
||||
- [SQLAlchemy](https://www.sqlalchemy.org/)
|
||||
- [Vite](https://vitejs.dev/)
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**Made with ❤️ by Mula.liu**
|
||||
|
||||
</div>
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# Byte-compiled / optimized / DLL files
|
||||
*.pyc
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
|
||||
# Python
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
.venv
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
|
||||
# Build
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
*.egg
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Database
|
||||
/data/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# IDEs
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.project
|
||||
.pydevproject
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Project specific
|
||||
storage/
|
||||
temp/
|
||||
uploads/
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
# NEX Docus Backend
|
||||
|
||||
NEX Docus 后端服务 - 基于 FastAPI 构建的高性能文档管理平台。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**: FastAPI 0.109+
|
||||
- **数据库**: MySQL 5.7.5+ (通过 SQLAlchemy 2.0 异步 ORM)
|
||||
- **缓存**: Redis
|
||||
- **认证**: JWT (python-jose)
|
||||
- **密码加密**: bcrypt (passlib)
|
||||
- **文件处理**: aiofiles (异步文件 I/O)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ └── v1/ # API 路由(v1 版本)
|
||||
│ │ ├── auth.py # 用户认证
|
||||
│ │ ├── projects.py # 项目管理
|
||||
│ │ └── files.py # 文件系统
|
||||
│ ├── core/ # 核心配置
|
||||
│ │ ├── config.py # 应用配置
|
||||
│ │ ├── database.py # 数据库连接
|
||||
│ │ ├── security.py # 安全工具
|
||||
│ │ └── deps.py # 依赖注入
|
||||
│ ├── models/ # 数据库模型
|
||||
│ ├── schemas/ # Pydantic Schemas
|
||||
│ ├── services/ # 业务逻辑
|
||||
│ ├── middleware/ # 中间件
|
||||
│ └── utils/ # 工具函数
|
||||
├── scripts/ # 脚本文件
|
||||
│ ├── init_database.sql # 数据库初始化 SQL
|
||||
│ └── init_db.py # 数据库初始化 Python 脚本
|
||||
├── tests/ # 测试文件
|
||||
├── main.py # 应用入口
|
||||
├── requirements.txt # 依赖包
|
||||
└── .env # 环境配置
|
||||
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
# 激活虚拟环境
|
||||
source venv/bin/activate # macOS/Linux
|
||||
# 或
|
||||
venv\Scripts\activate # Windows
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
编辑 `.env` 文件,配置数据库连接等信息。
|
||||
|
||||
### 3. 初始化数据库
|
||||
|
||||
```bash
|
||||
# 方式一:使用 SQL 脚本(推荐)
|
||||
mysql -h10.100.51.51 -uroot -pUnis@321 < scripts/init_database.sql
|
||||
|
||||
# 方式二:使用 Python 脚本(仅创建表结构)
|
||||
python scripts/init_db.py
|
||||
```
|
||||
|
||||
### 4. 启动服务
|
||||
|
||||
```bash
|
||||
# 开发模式(自动重载)
|
||||
python main.py
|
||||
|
||||
# 或使用 uvicorn
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
服务启动后,访问:
|
||||
- API 文档: http://localhost:8000/docs
|
||||
- 健康检查: http://localhost:8000/health
|
||||
|
||||
## API 接口
|
||||
|
||||
### 认证相关 (`/api/v1/auth`)
|
||||
|
||||
- `POST /register` - 用户注册
|
||||
- `POST /login` - 用户登录
|
||||
- `GET /me` - 获取当前用户信息
|
||||
- `POST /change-password` - 修改密码
|
||||
|
||||
### 项目管理 (`/api/v1/projects`)
|
||||
|
||||
- `GET /` - 获取我的项目列表
|
||||
- `POST /` - 创建新项目
|
||||
- `GET /{project_id}` - 获取项目详情
|
||||
- `PUT /{project_id}` - 更新项目信息
|
||||
- `DELETE /{project_id}` - 删除项目(归档)
|
||||
- `GET /{project_id}/members` - 获取项目成员
|
||||
- `POST /{project_id}/members` - 添加项目成员
|
||||
|
||||
### 文件系统 (`/api/v1/files`)
|
||||
|
||||
- `GET /{project_id}/tree` - 获取项目目录树
|
||||
- `GET /{project_id}/file?path=xxx` - 获取文件内容
|
||||
- `POST /{project_id}/file` - 保存文件内容
|
||||
- `POST /{project_id}/file/operate` - 文件操作(重命名/删除/创建)
|
||||
- `POST /{project_id}/upload` - 上传文件(图片/附件)
|
||||
- `GET /{project_id}/assets/{subfolder}/{filename}` - 获取资源文件
|
||||
|
||||
## 默认账号
|
||||
|
||||
初始化数据库后,会创建默认管理员账号:
|
||||
|
||||
- 用户名: `admin`
|
||||
- 密码: `admin123`
|
||||
|
||||
**⚠️ 生产环境请立即修改默认密码!**
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 代码风格
|
||||
|
||||
遵循 PEP 8 代码规范。
|
||||
|
||||
### 添加新的 API 端点
|
||||
|
||||
1. 在 `app/api/v1/` 下创建新的路由文件
|
||||
2. 在 `app/api/v1/__init__.py` 中注册路由
|
||||
3. 在 `app/schemas/` 中定义 Pydantic Schema
|
||||
4. 在 `app/services/` 中实现业务逻辑
|
||||
|
||||
### 数据库迁移
|
||||
|
||||
使用 Alembic 进行数据库迁移:
|
||||
|
||||
```bash
|
||||
# 生成迁移脚本
|
||||
alembic revision --autogenerate -m "描述"
|
||||
|
||||
# 执行迁移
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
## 安全说明
|
||||
|
||||
1. **路径安全**: 所有文件系统操作都经过路径安全检查,防止路径穿越攻击
|
||||
2. **认证鉴权**: 使用 JWT Token 认证,所有需要登录的接口都受保护
|
||||
3. **权限控制**: 实现了项目级别的权限控制(owner/admin/editor/viewer)
|
||||
4. **密码加密**: 使用 bcrypt 加密存储密码
|
||||
5. **SQL 注入**: 使用 SQLAlchemy ORM,自动防止 SQL 注入
|
||||
|
||||
## 许可证
|
||||
|
||||
Copyright © 2023 Mula.liu
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
"""
|
||||
API v1 路由汇总
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1 import auth, projects, files, menu, dashboard, preview
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# 注册子路由
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["认证"])
|
||||
api_router.include_router(projects.router, prefix="/projects", tags=["项目管理"])
|
||||
api_router.include_router(files.router, prefix="/files", tags=["文件系统"])
|
||||
api_router.include_router(menu.router, prefix="/menu", tags=["权限菜单"])
|
||||
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["管理员仪表盘"])
|
||||
api_router.include_router(preview.router, prefix="/preview", tags=["项目预览"])
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
"""
|
||||
用户认证相关 API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import verify_password, get_password_hash, create_access_token
|
||||
from app.core.deps import get_current_user
|
||||
from app.core.redis_client import TokenCache
|
||||
from app.models.user import User
|
||||
from app.models.role import Role, UserRole
|
||||
from app.schemas.user import UserCreate, UserLogin, UserResponse, Token, ChangePassword, UserUpdate
|
||||
from app.schemas.response import success_response, error_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/register", response_model=dict)
|
||||
async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""用户注册"""
|
||||
# 检查用户名是否存在
|
||||
result = await db.execute(select(User).where(User.username == user_in.username))
|
||||
existing_user = result.scalar_one_or_none()
|
||||
if existing_user:
|
||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||
|
||||
# 检查邮箱是否存在
|
||||
if user_in.email:
|
||||
result = await db.execute(select(User).where(User.email == user_in.email))
|
||||
existing_email = result.scalar_one_or_none()
|
||||
if existing_email:
|
||||
raise HTTPException(status_code=400, detail="邮箱已被注册")
|
||||
|
||||
# 创建用户
|
||||
db_user = User(
|
||||
username=user_in.username,
|
||||
password_hash=get_password_hash(user_in.password),
|
||||
nickname=user_in.nickname or user_in.username,
|
||||
email=user_in.email,
|
||||
phone=user_in.phone,
|
||||
status=1,
|
||||
)
|
||||
db.add(db_user)
|
||||
await db.commit()
|
||||
await db.refresh(db_user)
|
||||
|
||||
# 分配默认角色(普通用户)
|
||||
result = await db.execute(select(Role).where(Role.role_code == "user"))
|
||||
default_role = result.scalar_one_or_none()
|
||||
if default_role:
|
||||
user_role = UserRole(user_id=db_user.id, role_id=default_role.id)
|
||||
db.add(user_role)
|
||||
await db.commit()
|
||||
|
||||
return success_response(
|
||||
data={"user_id": db_user.id, "username": db_user.username},
|
||||
message="注册成功"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=dict)
|
||||
async def login(user_in: UserLogin, db: AsyncSession = Depends(get_db)):
|
||||
"""用户登录"""
|
||||
# 查询用户
|
||||
result = await db.execute(select(User).where(User.username == user_in.username))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not verify_password(user_in.password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
|
||||
if user.status != 1:
|
||||
raise HTTPException(status_code=403, detail="用户已被禁用")
|
||||
|
||||
# 更新最后登录时间
|
||||
user.last_login_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
# 生成 Token(sub 必须是字符串)
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
|
||||
# 保存 token 到 Redis(24小时过期)
|
||||
await TokenCache.save_token(user.id, access_token, expire_seconds=86400)
|
||||
|
||||
# 返回用户信息和 Token
|
||||
user_data = UserResponse.from_orm(user)
|
||||
token_data = Token(access_token=access_token, user=user_data)
|
||||
|
||||
return success_response(data=token_data.dict(), message="登录成功")
|
||||
|
||||
|
||||
@router.get("/me", response_model=dict)
|
||||
async def get_current_user_info(current_user: User = Depends(get_current_user)):
|
||||
"""获取当前用户信息"""
|
||||
user_data = UserResponse.from_orm(current_user)
|
||||
return success_response(data=user_data.dict())
|
||||
|
||||
|
||||
@router.put("/profile", response_model=dict)
|
||||
async def update_profile(
|
||||
profile_in: UserUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""更新用户资料"""
|
||||
# 检查邮箱是否已被其他用户使用
|
||||
if profile_in.email:
|
||||
result = await db.execute(
|
||||
select(User).where(User.email == profile_in.email, User.id != current_user.id)
|
||||
)
|
||||
existing_email = result.scalar_one_or_none()
|
||||
if existing_email:
|
||||
raise HTTPException(status_code=400, detail="邮箱已被其他用户使用")
|
||||
|
||||
# 更新字段
|
||||
update_data = profile_in.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(current_user, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
user_data = UserResponse.from_orm(current_user)
|
||||
return success_response(data=user_data.dict(), message="资料更新成功")
|
||||
|
||||
|
||||
@router.post("/change-password", response_model=dict)
|
||||
async def change_password(
|
||||
password_in: ChangePassword,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""修改密码"""
|
||||
# 验证旧密码
|
||||
if not verify_password(password_in.old_password, current_user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="旧密码错误")
|
||||
|
||||
# 更新密码
|
||||
current_user.password_hash = get_password_hash(password_in.new_password)
|
||||
await db.commit()
|
||||
|
||||
# 密码修改后,删除用户所有 token(强制重新登录)
|
||||
await TokenCache.delete_user_all_tokens(current_user.id)
|
||||
|
||||
return success_response(message="密码修改成功,请重新登录")
|
||||
|
||||
|
||||
@router.post("/logout", response_model=dict)
|
||||
async def logout(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""退出登录"""
|
||||
# 从请求状态中获取 token(已在 get_current_user 中保存)
|
||||
token = getattr(request.state, 'token', None)
|
||||
|
||||
if token:
|
||||
await TokenCache.delete_token(token)
|
||||
logger.info(f"User {current_user.username} logged out")
|
||||
|
||||
return success_response(message="退出成功")
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
"""
|
||||
管理员仪表盘相关 API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
import os
|
||||
import glob
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
from app.models.project import Project, ProjectMember
|
||||
from app.schemas.response import success_response
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/stats", response_model=dict)
|
||||
async def get_dashboard_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取仪表盘统计数据(仅管理员)"""
|
||||
|
||||
# 检查是否为超级管理员
|
||||
if current_user.is_superuser != 1:
|
||||
raise HTTPException(status_code=403, detail="仅管理员可访问")
|
||||
|
||||
# 统计用户数
|
||||
user_count_result = await db.execute(select(func.count(User.id)))
|
||||
user_count = user_count_result.scalar()
|
||||
|
||||
# 统计项目数
|
||||
project_count_result = await db.execute(select(func.count(Project.id)))
|
||||
project_count = project_count_result.scalar()
|
||||
|
||||
# 统计文档数(所有项目中的 .md 文件)
|
||||
document_count = 0
|
||||
if os.path.exists(settings.PROJECTS_PATH):
|
||||
for project_dir in os.listdir(settings.PROJECTS_PATH):
|
||||
project_path = os.path.join(settings.PROJECTS_PATH, project_dir)
|
||||
if os.path.isdir(project_path):
|
||||
md_files = glob.glob(os.path.join(project_path, "**/*.md"), recursive=True)
|
||||
document_count += len(md_files)
|
||||
|
||||
# 获取最近创建的用户
|
||||
recent_users_result = await db.execute(
|
||||
select(User)
|
||||
.order_by(User.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
recent_users = recent_users_result.scalars().all()
|
||||
recent_users_data = [
|
||||
{
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
}
|
||||
for user in recent_users
|
||||
]
|
||||
|
||||
# 获取最近创建的项目(包含所有者信息)
|
||||
recent_projects_result = await db.execute(
|
||||
select(Project, User)
|
||||
.join(User, Project.owner_id == User.id)
|
||||
.order_by(Project.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
recent_projects_rows = recent_projects_result.all()
|
||||
recent_projects_data = [
|
||||
{
|
||||
"id": project.id,
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"owner_name": owner.username,
|
||||
"created_at": project.created_at.isoformat() if project.created_at else None,
|
||||
}
|
||||
for project, owner in recent_projects_rows
|
||||
]
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
"stats": {
|
||||
"user_count": user_count,
|
||||
"project_count": project_count,
|
||||
"document_count": document_count,
|
||||
},
|
||||
"recent_users": recent_users_data,
|
||||
"recent_projects": recent_projects_data,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/personal-stats", response_model=dict)
|
||||
async def get_personal_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取个人桌面统计数据"""
|
||||
|
||||
# 统计个人项目数
|
||||
personal_projects_count_result = await db.execute(
|
||||
select(func.count(Project.id)).where(Project.owner_id == current_user.id)
|
||||
)
|
||||
personal_projects_count = personal_projects_count_result.scalar()
|
||||
|
||||
# 统计个人文档数(个人项目中的 .md 文件)
|
||||
document_count = 0
|
||||
personal_projects_result = await db.execute(
|
||||
select(Project).where(Project.owner_id == current_user.id)
|
||||
)
|
||||
personal_projects = personal_projects_result.scalars().all()
|
||||
|
||||
for project in personal_projects:
|
||||
project_path = os.path.join(settings.PROJECTS_PATH, project.storage_key)
|
||||
if os.path.exists(project_path) and os.path.isdir(project_path):
|
||||
md_files = glob.glob(os.path.join(project_path, "**/*.md"), recursive=True)
|
||||
document_count += len(md_files)
|
||||
|
||||
# 获取最近的个人项目
|
||||
recent_personal_projects_result = await db.execute(
|
||||
select(Project)
|
||||
.where(Project.owner_id == current_user.id)
|
||||
.order_by(Project.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
recent_personal_projects = recent_personal_projects_result.scalars().all()
|
||||
recent_personal_projects_data = [
|
||||
{
|
||||
"id": project.id,
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"created_at": project.created_at.isoformat() if project.created_at else None,
|
||||
}
|
||||
for project in recent_personal_projects
|
||||
]
|
||||
|
||||
# 获取最近的分享项目(从 project_members 表)
|
||||
recent_shared_projects_result = await db.execute(
|
||||
select(Project, ProjectMember)
|
||||
.join(ProjectMember, Project.id == ProjectMember.project_id)
|
||||
.where(ProjectMember.user_id == current_user.id)
|
||||
.where(Project.owner_id != current_user.id)
|
||||
.order_by(ProjectMember.joined_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
recent_shared_projects_rows = recent_shared_projects_result.all()
|
||||
recent_shared_projects_data = [
|
||||
{
|
||||
"id": project.id,
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"role": member.role,
|
||||
"joined_at": member.joined_at.isoformat() if member.joined_at else None,
|
||||
}
|
||||
for project, member in recent_shared_projects_rows
|
||||
]
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
"user_info": {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"email": current_user.email,
|
||||
"created_at": current_user.created_at.isoformat() if current_user.created_at else None,
|
||||
},
|
||||
"stats": {
|
||||
"personal_projects_count": personal_projects_count,
|
||||
"document_count": document_count,
|
||||
},
|
||||
"recent_personal_projects": recent_personal_projects_data,
|
||||
"recent_shared_projects": recent_shared_projects_data,
|
||||
}
|
||||
)
|
||||
|
|
@ -0,0 +1,310 @@
|
|||
"""
|
||||
文件系统操作相关 API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from fastapi.responses import StreamingResponse, FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
import os
|
||||
import zipfile
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, get_user_from_token_or_query
|
||||
from app.models.user import User
|
||||
from app.models.project import Project, ProjectMember
|
||||
from app.schemas.file import (
|
||||
FileTreeNode,
|
||||
FileSaveRequest,
|
||||
FileOperateRequest,
|
||||
FileUploadResponse,
|
||||
)
|
||||
from app.schemas.response import success_response
|
||||
from app.services.storage import storage_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def check_project_access(
|
||||
project_id: int,
|
||||
current_user: User,
|
||||
db: AsyncSession,
|
||||
require_write: bool = False
|
||||
):
|
||||
"""检查项目访问权限"""
|
||||
# 查询项目
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 检查是否是项目所有者
|
||||
if project.owner_id == current_user.id:
|
||||
return project
|
||||
|
||||
# 检查是否是项目成员
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
|
||||
if not member:
|
||||
if project.is_public == 1 and not require_write:
|
||||
return project
|
||||
raise HTTPException(status_code=403, detail="无权访问该项目")
|
||||
|
||||
# 如果需要写权限,检查成员角色
|
||||
if require_write and member.role == "viewer":
|
||||
raise HTTPException(status_code=403, detail="无写入权限")
|
||||
|
||||
return project
|
||||
|
||||
|
||||
@router.get("/{project_id}/tree", response_model=dict)
|
||||
async def get_project_tree(
|
||||
project_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取项目目录树"""
|
||||
project = await check_project_access(project_id, current_user, db)
|
||||
|
||||
# 获取项目根目录
|
||||
project_root = storage_service.get_secure_path(project.storage_key)
|
||||
|
||||
# 生成目录树
|
||||
tree = storage_service.generate_tree(project_root)
|
||||
|
||||
return success_response(data=tree)
|
||||
|
||||
|
||||
@router.get("/{project_id}/file", response_model=dict)
|
||||
async def get_file_content(
|
||||
project_id: int,
|
||||
path: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取文件内容"""
|
||||
project = await check_project_access(project_id, current_user, db)
|
||||
|
||||
# 获取文件路径
|
||||
file_path = storage_service.get_secure_path(project.storage_key, path)
|
||||
|
||||
# 读取文件内容
|
||||
content = await storage_service.read_file(file_path)
|
||||
|
||||
return success_response(data={"path": path, "content": content})
|
||||
|
||||
|
||||
@router.post("/{project_id}/file", response_model=dict)
|
||||
async def save_file(
|
||||
project_id: int,
|
||||
file_data: FileSaveRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""保存文件内容"""
|
||||
project = await check_project_access(project_id, current_user, db, require_write=True)
|
||||
|
||||
# 获取文件路径
|
||||
file_path = storage_service.get_secure_path(project.storage_key, file_data.path)
|
||||
|
||||
# 写入文件内容
|
||||
await storage_service.write_file(file_path, file_data.content)
|
||||
|
||||
return success_response(message="文件保存成功")
|
||||
|
||||
|
||||
@router.post("/{project_id}/file/operate", response_model=dict)
|
||||
async def operate_file(
|
||||
project_id: int,
|
||||
operation: FileOperateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""文件操作(重命名、删除、创建目录、创建文件、移动)"""
|
||||
project = await check_project_access(project_id, current_user, db, require_write=True)
|
||||
|
||||
# 获取当前路径
|
||||
current_path = storage_service.get_secure_path(project.storage_key, operation.path)
|
||||
|
||||
if operation.action == "delete":
|
||||
# 删除文件或文件夹
|
||||
await storage_service.delete_file(current_path)
|
||||
return success_response(message="删除成功")
|
||||
|
||||
elif operation.action == "rename":
|
||||
# 重命名
|
||||
if not operation.new_path:
|
||||
raise HTTPException(status_code=400, detail="缺少新路径参数")
|
||||
new_path = storage_service.get_secure_path(project.storage_key, operation.new_path)
|
||||
await storage_service.rename_file(current_path, new_path)
|
||||
return success_response(message="重命名成功")
|
||||
|
||||
elif operation.action == "move":
|
||||
# 移动文件或文件夹
|
||||
if not operation.new_path:
|
||||
raise HTTPException(status_code=400, detail="缺少目标路径参数")
|
||||
new_path = storage_service.get_secure_path(project.storage_key, operation.new_path)
|
||||
await storage_service.rename_file(current_path, new_path)
|
||||
return success_response(message="移动成功")
|
||||
|
||||
elif operation.action == "create_dir":
|
||||
# 创建目录
|
||||
await storage_service.create_directory(current_path)
|
||||
return success_response(message="目录创建成功")
|
||||
|
||||
elif operation.action == "create_file":
|
||||
# 创建文件
|
||||
content = operation.content or ""
|
||||
await storage_service.write_file(current_path, content)
|
||||
return success_response(message="文件创建成功")
|
||||
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="不支持的操作类型")
|
||||
|
||||
|
||||
@router.post("/{project_id}/upload", response_model=dict)
|
||||
async def upload_file(
|
||||
project_id: int,
|
||||
file: UploadFile = File(...),
|
||||
subfolder: str = "images",
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""上传文件(图片/附件)"""
|
||||
project = await check_project_access(project_id, current_user, db, require_write=True)
|
||||
|
||||
# 上传文件
|
||||
file_info = await storage_service.upload_file(
|
||||
project.storage_key,
|
||||
file,
|
||||
subfolder=subfolder
|
||||
)
|
||||
|
||||
# 构建访问 URL
|
||||
file_info["url"] = f"/api/v1/files/{project_id}/assets/{subfolder}/{file_info['filename']}"
|
||||
|
||||
return success_response(data=file_info, message="文件上传成功")
|
||||
|
||||
|
||||
@router.get("/{project_id}/assets/{subfolder}/{filename}")
|
||||
async def get_asset_file(
|
||||
project_id: int,
|
||||
subfolder: str,
|
||||
filename: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取资源文件(公开访问,支持分享)"""
|
||||
# 验证项目是否存在
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 获取文件路径
|
||||
asset_path = f"_assets/{subfolder}/{filename}"
|
||||
file_path = storage_service.get_secure_path(project.storage_key, asset_path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
|
||||
# 返回文件(流式响应)
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=filename,
|
||||
media_type="application/octet-stream"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/import-documents", response_model=dict)
|
||||
async def import_documents(
|
||||
project_id: int,
|
||||
files: List[UploadFile] = File(...),
|
||||
target_path: str = "",
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""批量导入Markdown文档"""
|
||||
project = await check_project_access(project_id, current_user, db, require_write=True)
|
||||
|
||||
# 验证所有文件都是.md格式
|
||||
for file in files:
|
||||
if not file.filename.endswith('.md'):
|
||||
raise HTTPException(status_code=400, detail=f"文件 {file.filename} 不是Markdown格式")
|
||||
|
||||
# 获取目标目录路径
|
||||
target_dir = storage_service.get_secure_path(project.storage_key, target_path)
|
||||
|
||||
# 确保目标目录存在
|
||||
if not target_dir.exists():
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 保存所有文件
|
||||
imported_files = []
|
||||
for file in files:
|
||||
file_path = target_dir / file.filename
|
||||
content = await file.read()
|
||||
|
||||
# 写入文件
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(content)
|
||||
|
||||
# 构建相对路径
|
||||
relative_path = f"{target_path}/{file.filename}" if target_path else file.filename
|
||||
imported_files.append(relative_path)
|
||||
|
||||
return success_response(
|
||||
data={"imported_files": imported_files},
|
||||
message=f"成功导入 {len(imported_files)} 个文档"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/export-directory")
|
||||
async def export_directory(
|
||||
project_id: int,
|
||||
directory_path: str = "",
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""导出目录为ZIP包"""
|
||||
project = await check_project_access(project_id, current_user, db)
|
||||
|
||||
# 获取目标目录路径
|
||||
source_dir = storage_service.get_secure_path(project.storage_key, directory_path)
|
||||
|
||||
if not source_dir.exists():
|
||||
raise HTTPException(status_code=404, detail="目录不存在")
|
||||
|
||||
# 创建ZIP文件在内存中
|
||||
zip_buffer = io.BytesIO()
|
||||
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
||||
# 遍历目录添加所有文件
|
||||
for file_path in source_dir.rglob('*'):
|
||||
if file_path.is_file():
|
||||
# 计算相对路径
|
||||
arcname = file_path.relative_to(source_dir)
|
||||
zip_file.write(file_path, arcname)
|
||||
|
||||
# 重置buffer位置
|
||||
zip_buffer.seek(0)
|
||||
|
||||
# 生成ZIP文件名
|
||||
zip_filename = f"{project.name}_{directory_path.replace('/', '_') if directory_path else 'root'}.zip"
|
||||
|
||||
return StreamingResponse(
|
||||
zip_buffer,
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={zip_filename}"
|
||||
}
|
||||
)
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
"""
|
||||
权限菜单相关 API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.menu import SystemMenu, RoleMenu
|
||||
from app.models.role import UserRole
|
||||
from app.schemas.response import success_response
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def build_menu_tree(menus: List[SystemMenu], parent_id: int = 0) -> List[Dict[str, Any]]:
|
||||
"""构建菜单树"""
|
||||
result = []
|
||||
for menu in menus:
|
||||
if menu.parent_id == parent_id:
|
||||
menu_dict = {
|
||||
"id": menu.id,
|
||||
"menu_name": menu.menu_name,
|
||||
"menu_code": menu.menu_code,
|
||||
"menu_type": menu.menu_type,
|
||||
"path": menu.path,
|
||||
"component": menu.component,
|
||||
"icon": menu.icon,
|
||||
"sort_order": menu.sort_order,
|
||||
"visible": menu.visible,
|
||||
"permission": menu.permission,
|
||||
}
|
||||
|
||||
# 递归构建子菜单
|
||||
children = build_menu_tree(menus, menu.id)
|
||||
if children:
|
||||
menu_dict["children"] = children
|
||||
|
||||
result.append(menu_dict)
|
||||
|
||||
# 按 sort_order 排序
|
||||
result.sort(key=lambda x: x.get("sort_order", 0))
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/user-menus", response_model=dict)
|
||||
async def get_user_menus(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取当前用户的权限菜单"""
|
||||
|
||||
# 获取用户的角色
|
||||
user_roles_result = await db.execute(
|
||||
select(UserRole.role_id).where(UserRole.user_id == current_user.id)
|
||||
)
|
||||
role_ids = [row[0] for row in user_roles_result.all()]
|
||||
|
||||
if not role_ids:
|
||||
return success_response(data=[])
|
||||
|
||||
# 获取角色的菜单权限
|
||||
role_menus_result = await db.execute(
|
||||
select(RoleMenu.menu_id).where(RoleMenu.role_id.in_(role_ids))
|
||||
)
|
||||
menu_ids = list(set([row[0] for row in role_menus_result.all()]))
|
||||
|
||||
if not menu_ids:
|
||||
return success_response(data=[])
|
||||
|
||||
# 获取菜单详情
|
||||
menus_result = await db.execute(
|
||||
select(SystemMenu)
|
||||
.where(SystemMenu.id.in_(menu_ids))
|
||||
.where(SystemMenu.status == 1)
|
||||
.where(SystemMenu.visible == 1)
|
||||
.order_by(SystemMenu.sort_order)
|
||||
)
|
||||
user_menus = menus_result.scalars().all()
|
||||
|
||||
# 构建菜单树
|
||||
menu_tree = build_menu_tree(user_menus)
|
||||
|
||||
return success_response(data=menu_tree)
|
||||
|
||||
|
||||
@router.get("/user-permissions", response_model=dict)
|
||||
async def get_user_permissions(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取当前用户的权限列表"""
|
||||
|
||||
# 获取用户的角色
|
||||
user_roles_result = await db.execute(
|
||||
select(UserRole.role_id).where(UserRole.user_id == current_user.id)
|
||||
)
|
||||
role_ids = [row[0] for row in user_roles_result.all()]
|
||||
|
||||
if not role_ids:
|
||||
return success_response(data=[])
|
||||
|
||||
# 获取角色的菜单权限
|
||||
role_menus_result = await db.execute(
|
||||
select(RoleMenu.menu_id).where(RoleMenu.role_id.in_(role_ids))
|
||||
)
|
||||
menu_ids = list(set([row[0] for row in role_menus_result.all()]))
|
||||
|
||||
if not menu_ids:
|
||||
return success_response(data=[])
|
||||
|
||||
# 获取权限字符串
|
||||
permissions_result = await db.execute(
|
||||
select(SystemMenu.permission)
|
||||
.where(SystemMenu.id.in_(menu_ids))
|
||||
.where(SystemMenu.status == 1)
|
||||
.where(SystemMenu.permission.isnot(None))
|
||||
)
|
||||
permissions = [row[0] for row in permissions_result.all()]
|
||||
|
||||
return success_response(data=permissions)
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
"""
|
||||
项目预览相关 API(公开访问,支持分享)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Optional
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.project import Project
|
||||
from app.schemas.response import success_response
|
||||
from app.services.storage import storage_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/{project_id}/info", response_model=dict)
|
||||
async def get_preview_info(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取预览项目基本信息(公开访问)"""
|
||||
# 查询项目
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 返回基本信息
|
||||
info = {
|
||||
"id": project.id,
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"has_password": bool(project.access_pass),
|
||||
}
|
||||
|
||||
return success_response(data=info)
|
||||
|
||||
|
||||
@router.post("/{project_id}/verify", response_model=dict)
|
||||
async def verify_access_password(
|
||||
project_id: int,
|
||||
password: str = Header(..., alias="X-Access-Password"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""验证访问密码"""
|
||||
# 查询项目
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 验证密码
|
||||
if not project.access_pass:
|
||||
return success_response(message="该项目无需密码访问")
|
||||
|
||||
if project.access_pass != password:
|
||||
raise HTTPException(status_code=403, detail="访问密码错误")
|
||||
|
||||
return success_response(message="验证成功")
|
||||
|
||||
|
||||
@router.get("/{project_id}/tree", response_model=dict)
|
||||
async def get_preview_tree(
|
||||
project_id: int,
|
||||
password: Optional[str] = Header(None, alias="X-Access-Password"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取预览项目的文档树(公开访问,需验证密码)"""
|
||||
# 查询项目
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 如果设置了密码,需要验证
|
||||
if project.access_pass:
|
||||
if not password or project.access_pass != password:
|
||||
raise HTTPException(status_code=403, detail="需要提供正确的访问密码")
|
||||
|
||||
# 获取文档树
|
||||
project_path = storage_service.get_secure_path(project.storage_key)
|
||||
tree = storage_service.generate_tree(project_path)
|
||||
|
||||
return success_response(data=tree)
|
||||
|
||||
|
||||
@router.get("/{project_id}/file", response_model=dict)
|
||||
async def get_preview_file(
|
||||
project_id: int,
|
||||
path: str,
|
||||
password: Optional[str] = Header(None, alias="X-Access-Password"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取预览项目的文件内容(公开访问,需验证密码)"""
|
||||
# 查询项目
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 如果设置了密码,需要验证
|
||||
if project.access_pass:
|
||||
if not password or project.access_pass != password:
|
||||
raise HTTPException(status_code=403, detail="需要提供正确的访问密码")
|
||||
|
||||
# 获取文件内容
|
||||
file_path = storage_service.get_secure_path(project.storage_key, path)
|
||||
content = await storage_service.read_file(file_path)
|
||||
|
||||
return success_response(data={"content": content})
|
||||
|
|
@ -0,0 +1,340 @@
|
|||
"""
|
||||
项目管理相关 API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, or_
|
||||
from typing import List
|
||||
import uuid
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.project import Project, ProjectMember, ProjectMemberRole
|
||||
from app.schemas.project import (
|
||||
ProjectCreate,
|
||||
ProjectUpdate,
|
||||
ProjectResponse,
|
||||
ProjectMemberAdd,
|
||||
ProjectMemberUpdate,
|
||||
ProjectMemberResponse,
|
||||
ProjectShareSettings,
|
||||
ProjectShareInfo,
|
||||
)
|
||||
from app.schemas.response import success_response
|
||||
from app.services.storage import storage_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=dict)
|
||||
async def get_my_projects(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取我的项目列表(包括创建的和协作的)"""
|
||||
# 查询我创建的项目
|
||||
owned_result = await db.execute(
|
||||
select(Project).where(Project.owner_id == current_user.id, Project.status == 1)
|
||||
)
|
||||
owned_projects = owned_result.scalars().all()
|
||||
|
||||
# 查询我协作的项目
|
||||
member_result = await db.execute(
|
||||
select(Project)
|
||||
.join(ProjectMember, ProjectMember.project_id == Project.id)
|
||||
.where(
|
||||
ProjectMember.user_id == current_user.id,
|
||||
Project.owner_id != current_user.id,
|
||||
Project.status == 1
|
||||
)
|
||||
)
|
||||
member_projects = member_result.scalars().all()
|
||||
|
||||
# 合并结果
|
||||
all_projects = owned_projects + member_projects
|
||||
projects_data = [ProjectResponse.from_orm(p).dict() for p in all_projects]
|
||||
|
||||
return success_response(data=projects_data)
|
||||
|
||||
|
||||
@router.post("/", response_model=dict)
|
||||
async def create_project(
|
||||
project_in: ProjectCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""创建新项目"""
|
||||
# 生成 UUID 作为存储键
|
||||
storage_key = str(uuid.uuid4())
|
||||
|
||||
# 创建项目记录
|
||||
db_project = Project(
|
||||
name=project_in.name,
|
||||
description=project_in.description,
|
||||
storage_key=storage_key,
|
||||
owner_id=current_user.id,
|
||||
is_public=project_in.is_public,
|
||||
status=1,
|
||||
)
|
||||
db.add(db_project)
|
||||
await db.commit()
|
||||
await db.refresh(db_project)
|
||||
|
||||
# 创建物理文件夹结构
|
||||
try:
|
||||
storage_service.create_project_structure(storage_key)
|
||||
except Exception as e:
|
||||
# 如果文件夹创建失败,回滚数据库记录
|
||||
await db.delete(db_project)
|
||||
await db.commit()
|
||||
raise HTTPException(status_code=500, detail=f"项目文件夹创建失败: {str(e)}")
|
||||
|
||||
# 添加项目所有者为管理员成员
|
||||
db_member = ProjectMember(
|
||||
project_id=db_project.id,
|
||||
user_id=current_user.id,
|
||||
role=ProjectMemberRole.ADMIN,
|
||||
)
|
||||
db.add(db_member)
|
||||
await db.commit()
|
||||
|
||||
project_data = ProjectResponse.from_orm(db_project)
|
||||
return success_response(data=project_data.dict(), message="项目创建成功")
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=dict)
|
||||
async def get_project(
|
||||
project_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取项目详情"""
|
||||
# 查询项目
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 检查权限(项目所有者或成员可访问)
|
||||
if project.owner_id != current_user.id:
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member and project.is_public != 1:
|
||||
raise HTTPException(status_code=403, detail="无权访问该项目")
|
||||
|
||||
# 增加访问次数
|
||||
project.visit_count += 1
|
||||
await db.commit()
|
||||
|
||||
project_data = ProjectResponse.from_orm(project)
|
||||
return success_response(data=project_data.dict())
|
||||
|
||||
|
||||
@router.put("/{project_id}", response_model=dict)
|
||||
async def update_project(
|
||||
project_id: int,
|
||||
project_in: ProjectUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""更新项目信息"""
|
||||
# 查询项目
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 只有项目所有者可以更新
|
||||
if project.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="无权修改该项目")
|
||||
|
||||
# 更新字段
|
||||
update_data = project_in.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(project, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(project)
|
||||
|
||||
project_data = ProjectResponse.from_orm(project)
|
||||
return success_response(data=project_data.dict(), message="项目更新成功")
|
||||
|
||||
|
||||
@router.delete("/{project_id}", response_model=dict)
|
||||
async def delete_project(
|
||||
project_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""删除项目(归档)"""
|
||||
# 查询项目
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 只有项目所有者可以删除
|
||||
if project.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="无权删除该项目")
|
||||
|
||||
# 软删除(归档)
|
||||
project.status = 0
|
||||
await db.commit()
|
||||
|
||||
return success_response(message="项目已归档")
|
||||
|
||||
|
||||
@router.get("/{project_id}/members", response_model=dict)
|
||||
async def get_project_members(
|
||||
project_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取项目成员列表"""
|
||||
# 查询项目
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 检查权限
|
||||
if project.owner_id != current_user.id:
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=403, detail="无权访问该项目")
|
||||
|
||||
# 查询成员列表
|
||||
members_result = await db.execute(
|
||||
select(ProjectMember).where(ProjectMember.project_id == project_id)
|
||||
)
|
||||
members = members_result.scalars().all()
|
||||
|
||||
members_data = [ProjectMemberResponse.from_orm(m).dict() for m in members]
|
||||
return success_response(data=members_data)
|
||||
|
||||
|
||||
@router.post("/{project_id}/members", response_model=dict)
|
||||
async def add_project_member(
|
||||
project_id: int,
|
||||
member_in: ProjectMemberAdd,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""添加项目成员"""
|
||||
# 查询项目
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 只有项目所有者和管理员可以添加成员
|
||||
if project.owner_id != current_user.id:
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id,
|
||||
ProjectMember.role == ProjectMemberRole.ADMIN
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=403, detail="无权添加成员")
|
||||
|
||||
# 检查用户是否已是成员
|
||||
existing_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == member_in.user_id
|
||||
)
|
||||
)
|
||||
existing_member = existing_result.scalar_one_or_none()
|
||||
if existing_member:
|
||||
raise HTTPException(status_code=400, detail="用户已是项目成员")
|
||||
|
||||
# 添加成员
|
||||
db_member = ProjectMember(
|
||||
project_id=project_id,
|
||||
user_id=member_in.user_id,
|
||||
role=member_in.role,
|
||||
invited_by=current_user.id,
|
||||
)
|
||||
db.add(db_member)
|
||||
await db.commit()
|
||||
await db.refresh(db_member)
|
||||
|
||||
member_data = ProjectMemberResponse.from_orm(db_member)
|
||||
return success_response(data=member_data.dict(), message="成员添加成功")
|
||||
|
||||
|
||||
@router.get("/{project_id}/share", response_model=dict)
|
||||
async def get_project_share_info(
|
||||
project_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取项目分享信息"""
|
||||
# 查询项目
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 只有项目所有者可以获取分享信息
|
||||
if project.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="只有项目所有者可以查看分享信息")
|
||||
|
||||
# 构建分享链接
|
||||
share_url = f"/preview/{project_id}"
|
||||
|
||||
share_info = ProjectShareInfo(
|
||||
share_url=share_url,
|
||||
has_password=bool(project.access_pass),
|
||||
access_pass=project.access_pass # 返回实际密码给项目所有者
|
||||
)
|
||||
|
||||
return success_response(data=share_info.dict())
|
||||
|
||||
|
||||
@router.post("/{project_id}/share/settings", response_model=dict)
|
||||
async def update_share_settings(
|
||||
project_id: int,
|
||||
settings: ProjectShareSettings,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""更新分享设置(设置或取消访问密码)"""
|
||||
# 查询项目
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 只有项目所有者可以修改分享设置
|
||||
if project.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="只有项目所有者可以修改分享设置")
|
||||
|
||||
# 更新访问密码
|
||||
project.access_pass = settings.access_pass
|
||||
await db.commit()
|
||||
|
||||
message = "访问密码已取消" if not settings.access_pass else "访问密码已设置"
|
||||
return success_response(message=message)
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
"""
|
||||
应用配置管理
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import List
|
||||
from urllib.parse import quote_plus
|
||||
import os
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""应用配置类"""
|
||||
|
||||
# 应用信息
|
||||
APP_NAME: str = "NEX Docus"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = True
|
||||
|
||||
# 服务器配置
|
||||
HOST: str = "0.0.0.0"
|
||||
PORT: int = 8000
|
||||
|
||||
# 数据库配置
|
||||
DB_HOST: str
|
||||
DB_PORT: int = 3306
|
||||
DB_USER: str
|
||||
DB_PASSWORD: str
|
||||
DB_NAME: str
|
||||
DB_CHARSET: str = "utf8mb4"
|
||||
|
||||
@property
|
||||
def DATABASE_URL(self) -> str:
|
||||
"""数据库连接URL"""
|
||||
# URL 编码密码,防止特殊字符导致解析错误
|
||||
password = quote_plus(self.DB_PASSWORD)
|
||||
return f"mysql+aiomysql://{self.DB_USER}:{password}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}?charset={self.DB_CHARSET}"
|
||||
|
||||
@property
|
||||
def SYNC_DATABASE_URL(self) -> str:
|
||||
"""同步数据库连接URL(用于 Alembic)"""
|
||||
# URL 编码密码
|
||||
password = quote_plus(self.DB_PASSWORD)
|
||||
return f"mysql+pymysql://{self.DB_USER}:{password}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}?charset={self.DB_CHARSET}"
|
||||
|
||||
# Redis 配置
|
||||
REDIS_HOST: str
|
||||
REDIS_PORT: int = 6379
|
||||
REDIS_PASSWORD: str
|
||||
REDIS_DB: int = 8
|
||||
|
||||
@property
|
||||
def REDIS_URL(self) -> str:
|
||||
"""Redis 连接URL"""
|
||||
return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
||||
|
||||
# JWT 配置
|
||||
SECRET_KEY: str
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 # 24小时
|
||||
|
||||
# 文件存储配置
|
||||
STORAGE_ROOT: str = "/data/nex_docus_store"
|
||||
PROJECTS_PATH: str = "/data/nex_docus_store/projects"
|
||||
TEMP_PATH: str = "/data/nex_docus_store/temp"
|
||||
|
||||
# 跨域配置
|
||||
CORS_ORIGINS: List[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_PATH: str = "logs"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
# 创建配置实例
|
||||
settings = Settings()
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
"""
|
||||
数据库连接管理
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
from app.core.config import settings
|
||||
|
||||
# 创建异步引擎
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
# 创建异步会话工厂
|
||||
AsyncSessionLocal = sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
# 创建基础模型类
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
async def get_db():
|
||||
"""
|
||||
获取数据库会话(依赖注入)
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
"""
|
||||
认证依赖:获取当前登录用户
|
||||
"""
|
||||
from fastapi import Depends, HTTPException, status, Request
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Optional
|
||||
from app.core.database import get_db
|
||||
from app.core.security import decode_access_token
|
||||
from app.core.redis_client import TokenCache
|
||||
from app.models.user import User
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# HTTP Bearer 认证方案
|
||||
security = HTTPBearer()
|
||||
security_optional = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> User:
|
||||
"""
|
||||
获取当前登录用户(依赖注入)
|
||||
"""
|
||||
token = credentials.credentials
|
||||
logger.info(f"Received token: {token[:20]}...") # 只记录前20个字符
|
||||
|
||||
# 保存 token 到请求状态,供退出登录使用
|
||||
request.state.token = token
|
||||
|
||||
# 先验证 Redis 中是否存在该 token
|
||||
user_id_from_redis = await TokenCache.get_user_id(token)
|
||||
if user_id_from_redis is None:
|
||||
logger.error("Token not found in Redis or expired")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="登录已过期,请重新登录",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# 解码 JWT 验证完整性
|
||||
payload = decode_access_token(token)
|
||||
logger.info(f"Decoded payload: {payload}")
|
||||
|
||||
if payload is None:
|
||||
logger.error("Token decode failed: payload is None")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证凭证",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user_id_str = payload.get("sub")
|
||||
logger.info(f"Extracted user_id (string): {user_id_str}")
|
||||
|
||||
if user_id_str is None:
|
||||
logger.error("user_id is None in payload")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证凭证",
|
||||
)
|
||||
|
||||
# 将字符串转为整数
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
except (ValueError, TypeError):
|
||||
logger.error(f"Invalid user_id format: {user_id_str}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证凭证",
|
||||
)
|
||||
|
||||
# 验证 Redis 中的 user_id 与 JWT 中的是否一致
|
||||
if user_id != user_id_from_redis:
|
||||
logger.error(f"User ID mismatch: JWT={user_id}, Redis={user_id_from_redis}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证凭证",
|
||||
)
|
||||
|
||||
# 查询用户
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
logger.error(f"User not found for user_id: {user_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户不存在",
|
||||
)
|
||||
|
||||
if user.status != 1:
|
||||
logger.error(f"User {user_id} is disabled")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="用户已被禁用",
|
||||
)
|
||||
|
||||
logger.info(f"User authenticated successfully: {user.username}")
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> User:
|
||||
"""
|
||||
获取当前活跃用户
|
||||
"""
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_user_from_token_or_query(
|
||||
request: Request,
|
||||
token: Optional[str] = None, # 从query参数获取
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_optional),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> User:
|
||||
"""
|
||||
获取当前用户(支持从query参数或header获取token)
|
||||
用于图片等资源访问,优先使用header,其次使用query参数
|
||||
"""
|
||||
# 优先从 header 获取 token
|
||||
if credentials:
|
||||
token_str = credentials.credentials
|
||||
elif token:
|
||||
# 从 query 参数获取
|
||||
token_str = token
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="未提供认证凭证",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
logger.info(f"Received token: {token_str[:20]}...")
|
||||
|
||||
# 保存 token 到请求状态
|
||||
request.state.token = token_str
|
||||
|
||||
# 验证 Redis 中是否存在该 token
|
||||
user_id_from_redis = await TokenCache.get_user_id(token_str)
|
||||
if user_id_from_redis is None:
|
||||
logger.error("Token not found in Redis or expired")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="登录已过期,请重新登录",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# 解码 JWT 验证完整性
|
||||
payload = decode_access_token(token_str)
|
||||
logger.info(f"Decoded payload: {payload}")
|
||||
|
||||
if payload is None:
|
||||
logger.error("Token decode failed: payload is None")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证凭证",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user_id_str = payload.get("sub")
|
||||
logger.info(f"Extracted user_id (string): {user_id_str}")
|
||||
|
||||
if user_id_str is None:
|
||||
logger.error("user_id is None in payload")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证凭证",
|
||||
)
|
||||
|
||||
# 将字符串转为整数
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
except (ValueError, TypeError):
|
||||
logger.error(f"Invalid user_id format: {user_id_str}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证凭证",
|
||||
)
|
||||
|
||||
# 验证 Redis 中的 user_id 与 JWT 中的是否一致
|
||||
if user_id != user_id_from_redis:
|
||||
logger.error(f"User ID mismatch: JWT={user_id}, Redis={user_id_from_redis}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证凭证",
|
||||
)
|
||||
|
||||
# 查询用户
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
logger.error(f"User not found for user_id: {user_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户不存在",
|
||||
)
|
||||
|
||||
if user.status != 1:
|
||||
logger.error(f"User {user_id} is disabled")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="用户已被禁用",
|
||||
)
|
||||
|
||||
logger.info(f"User authenticated successfully: {user.username}")
|
||||
return user
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
"""
|
||||
Redis 客户端管理
|
||||
"""
|
||||
import redis.asyncio as aioredis
|
||||
from typing import Optional
|
||||
from app.core.config import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Redis 客户端实例
|
||||
redis_client: aioredis.Redis = None
|
||||
|
||||
|
||||
async def init_redis():
|
||||
"""初始化 Redis 连接"""
|
||||
global redis_client
|
||||
try:
|
||||
redis_client = await aioredis.from_url(
|
||||
settings.REDIS_URL,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
max_connections=10
|
||||
)
|
||||
# 测试连接
|
||||
await redis_client.ping()
|
||||
logger.info("Redis connected successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Redis connection failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def close_redis():
|
||||
"""关闭 Redis 连接"""
|
||||
global redis_client
|
||||
if redis_client:
|
||||
await redis_client.close()
|
||||
logger.info("Redis connection closed")
|
||||
|
||||
|
||||
def get_redis() -> aioredis.Redis:
|
||||
"""获取 Redis 客户端"""
|
||||
return redis_client
|
||||
|
||||
|
||||
# Token 缓存相关操作
|
||||
class TokenCache:
|
||||
"""Token 缓存管理"""
|
||||
|
||||
TOKEN_PREFIX = "token:"
|
||||
USER_TOKEN_PREFIX = "user_tokens:"
|
||||
|
||||
@staticmethod
|
||||
async def save_token(user_id: int, token: str, expire_seconds: int = 86400):
|
||||
"""
|
||||
保存 token 到 Redis
|
||||
:param user_id: 用户ID
|
||||
:param token: JWT token
|
||||
:param expire_seconds: 过期时间(秒),默认24小时
|
||||
"""
|
||||
redis = get_redis()
|
||||
|
||||
# 保存 token -> user_id 映射
|
||||
token_key = f"{TokenCache.TOKEN_PREFIX}{token}"
|
||||
await redis.setex(token_key, expire_seconds, str(user_id))
|
||||
|
||||
# 保存 user_id -> tokens 集合(支持多设备登录)
|
||||
user_tokens_key = f"{TokenCache.USER_TOKEN_PREFIX}{user_id}"
|
||||
await redis.sadd(user_tokens_key, token)
|
||||
await redis.expire(user_tokens_key, expire_seconds)
|
||||
|
||||
logger.info(f"Token saved for user {user_id}, expires in {expire_seconds}s")
|
||||
|
||||
@staticmethod
|
||||
async def get_user_id(token: str) -> Optional[int]:
|
||||
"""
|
||||
根据 token 获取用户ID
|
||||
:param token: JWT token
|
||||
:return: 用户ID 或 None
|
||||
"""
|
||||
redis = get_redis()
|
||||
token_key = f"{TokenCache.TOKEN_PREFIX}{token}"
|
||||
user_id_str = await redis.get(token_key)
|
||||
|
||||
if user_id_str:
|
||||
return int(user_id_str)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def delete_token(token: str):
|
||||
"""
|
||||
删除指定 token
|
||||
:param token: JWT token
|
||||
"""
|
||||
redis = get_redis()
|
||||
|
||||
# 获取 user_id
|
||||
token_key = f"{TokenCache.TOKEN_PREFIX}{token}"
|
||||
user_id_str = await redis.get(token_key)
|
||||
|
||||
if user_id_str:
|
||||
user_id = int(user_id_str)
|
||||
|
||||
# 从用户 tokens 集合中移除
|
||||
user_tokens_key = f"{TokenCache.USER_TOKEN_PREFIX}{user_id}"
|
||||
await redis.srem(user_tokens_key, token)
|
||||
|
||||
# 删除 token
|
||||
await redis.delete(token_key)
|
||||
logger.info(f"Token deleted: {token[:20]}...")
|
||||
|
||||
@staticmethod
|
||||
async def delete_user_all_tokens(user_id: int):
|
||||
"""
|
||||
删除用户的所有 token(用于强制登出)
|
||||
:param user_id: 用户ID
|
||||
"""
|
||||
redis = get_redis()
|
||||
user_tokens_key = f"{TokenCache.USER_TOKEN_PREFIX}{user_id}"
|
||||
|
||||
# 获取所有 tokens
|
||||
tokens = await redis.smembers(user_tokens_key)
|
||||
|
||||
# 删除所有 token
|
||||
for token in tokens:
|
||||
token_key = f"{TokenCache.TOKEN_PREFIX}{token}"
|
||||
await redis.delete(token_key)
|
||||
|
||||
# 删除集合
|
||||
await redis.delete(user_tokens_key)
|
||||
logger.info(f"All tokens deleted for user {user_id}")
|
||||
|
||||
@staticmethod
|
||||
async def extend_token(token: str, expire_seconds: int = 86400):
|
||||
"""
|
||||
延长 token 有效期(用于 token 刷新)
|
||||
:param token: JWT token
|
||||
:param expire_seconds: 延长的过期时间(秒)
|
||||
"""
|
||||
redis = get_redis()
|
||||
token_key = f"{TokenCache.TOKEN_PREFIX}{token}"
|
||||
|
||||
# 延长过期时间
|
||||
await redis.expire(token_key, expire_seconds)
|
||||
|
||||
# 获取 user_id 并延长用户 tokens 集合过期时间
|
||||
user_id_str = await redis.get(token_key)
|
||||
if user_id_str:
|
||||
user_tokens_key = f"{TokenCache.USER_TOKEN_PREFIX}{user_id_str}"
|
||||
await redis.expire(user_tokens_key, expire_seconds)
|
||||
|
||||
logger.info(f"Token extended: {token[:20]}...")
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
"""
|
||||
安全相关工具:密码哈希、JWT Token 生成与验证
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from app.core.config import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 密码加密上下文
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
验证密码
|
||||
"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""
|
||||
生成密码哈希
|
||||
"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""
|
||||
创建 JWT Access Token
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode.update({"exp": expire})
|
||||
logger.info(f"Creating token with payload: {to_encode}")
|
||||
logger.info(f"Using SECRET_KEY: {settings.SECRET_KEY[:10]}...")
|
||||
logger.info(f"Using ALGORITHM: {settings.ALGORITHM}")
|
||||
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
logger.info(f"Generated token: {encoded_jwt[:50]}...")
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> Optional[dict]:
|
||||
"""
|
||||
解码 JWT Token
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Decoding token: {token[:50]}...")
|
||||
logger.info(f"Using SECRET_KEY: {settings.SECRET_KEY[:10]}...")
|
||||
logger.info(f"Using ALGORITHM: {settings.ALGORITHM}")
|
||||
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
logger.info(f"Decoded payload: {payload}")
|
||||
return payload
|
||||
except JWTError as e:
|
||||
logger.error(f"JWT decode error: {e}")
|
||||
return None
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
"""
|
||||
导出所有数据库模型
|
||||
"""
|
||||
from app.models.user import User
|
||||
from app.models.role import Role, UserRole
|
||||
from app.models.menu import SystemMenu, RoleMenu
|
||||
from app.models.project import Project, ProjectMember, ProjectMemberRole
|
||||
from app.models.document import DocumentMeta
|
||||
from app.models.log import OperationLog
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Role",
|
||||
"UserRole",
|
||||
"SystemMenu",
|
||||
"RoleMenu",
|
||||
"Project",
|
||||
"ProjectMember",
|
||||
"ProjectMemberRole",
|
||||
"DocumentMeta",
|
||||
"OperationLog",
|
||||
]
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
"""
|
||||
文档元数据模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, Integer, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class DocumentMeta(Base):
|
||||
"""文档元数据表模型"""
|
||||
|
||||
__tablename__ = "document_meta"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="元数据ID")
|
||||
project_id = Column(BigInteger, nullable=False, index=True, comment="项目ID")
|
||||
file_path = Column(String(500), nullable=False, comment="文件相对路径")
|
||||
title = Column(String(200), comment="文档标题")
|
||||
tags = Column(String(500), comment="标签(JSON数组)")
|
||||
author_id = Column(BigInteger, index=True, comment="作者ID")
|
||||
word_count = Column(Integer, default=0, comment="字数统计")
|
||||
view_count = Column(Integer, default=0, comment="浏览次数")
|
||||
last_editor_id = Column(BigInteger, comment="最后编辑者ID")
|
||||
last_edited_at = Column(DateTime, comment="最后编辑时间")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DocumentMeta(id={self.id}, project_id={self.project_id}, file_path='{self.file_path}')>"
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
"""
|
||||
操作日志模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, Integer, DateTime, SmallInteger, Text
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class OperationLog(Base):
|
||||
"""操作日志表模型"""
|
||||
|
||||
__tablename__ = "operation_logs"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="日志ID")
|
||||
user_id = Column(BigInteger, index=True, comment="操作用户ID")
|
||||
username = Column(String(50), comment="用户名")
|
||||
operation_type = Column(String(50), nullable=False, comment="操作类型")
|
||||
resource_type = Column(String(50), nullable=False, index=True, comment="资源类型")
|
||||
resource_id = Column(BigInteger, index=True, comment="资源ID")
|
||||
detail = Column(Text, comment="操作详情(JSON)")
|
||||
ip_address = Column(String(50), comment="IP地址")
|
||||
user_agent = Column(String(500), comment="用户代理")
|
||||
status = Column(SmallInteger, default=1, comment="状态:0-失败 1-成功")
|
||||
error_message = Column(Text, comment="错误信息")
|
||||
created_at = Column(DateTime, server_default=func.now(), index=True, comment="操作时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<OperationLog(id={self.id}, operation_type='{self.operation_type}')>"
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
"""
|
||||
菜单模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, Integer, DateTime, SmallInteger
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class SystemMenu(Base):
|
||||
"""系统菜单表模型"""
|
||||
|
||||
__tablename__ = "system_menus"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="菜单ID")
|
||||
parent_id = Column(BigInteger, default=0, comment="父菜单ID(0表示根菜单)")
|
||||
menu_name = Column(String(50), nullable=False, comment="菜单名称")
|
||||
menu_code = Column(String(50), nullable=False, unique=True, index=True, comment="菜单编码")
|
||||
menu_type = Column(SmallInteger, nullable=False, comment="菜单类型:1-目录 2-菜单 3-按钮/权限点")
|
||||
path = Column(String(255), comment="路由路径")
|
||||
component = Column(String(255), comment="组件路径")
|
||||
icon = Column(String(100), comment="图标")
|
||||
sort_order = Column(Integer, default=0, comment="排序号")
|
||||
visible = Column(SmallInteger, default=1, comment="是否可见:0-隐藏 1-显示")
|
||||
status = Column(SmallInteger, default=1, index=True, comment="状态:0-禁用 1-启用")
|
||||
permission = Column(String(100), comment="权限字符串")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SystemMenu(id={self.id}, menu_name='{self.menu_name}')>"
|
||||
|
||||
|
||||
class RoleMenu(Base):
|
||||
"""角色菜单授权表模型"""
|
||||
|
||||
__tablename__ = "role_menus"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="关联ID")
|
||||
role_id = Column(BigInteger, nullable=False, index=True, comment="角色ID")
|
||||
menu_id = Column(BigInteger, nullable=False, index=True, comment="菜单ID")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<RoleMenu(role_id={self.role_id}, menu_id={self.menu_id})>"
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"""
|
||||
项目模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, Integer, DateTime, SmallInteger, Enum
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class ProjectMemberRole(str, enum.Enum):
|
||||
"""项目成员角色枚举"""
|
||||
ADMIN = "admin"
|
||||
EDITOR = "editor"
|
||||
VIEWER = "viewer"
|
||||
|
||||
|
||||
class Project(Base):
|
||||
"""项目表模型"""
|
||||
|
||||
__tablename__ = "projects"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="项目ID")
|
||||
name = Column(String(100), nullable=False, index=True, comment="项目名称")
|
||||
description = Column(String(500), comment="项目描述")
|
||||
storage_key = Column(String(36), nullable=False, unique=True, comment="磁盘存储UUID")
|
||||
owner_id = Column(BigInteger, nullable=False, index=True, comment="项目所有者ID")
|
||||
is_public = Column(SmallInteger, default=0, comment="是否公开:0-私有 1-公开")
|
||||
is_template = Column(SmallInteger, default=0, comment="是否模板项目:0-否 1-是")
|
||||
status = Column(SmallInteger, default=1, index=True, comment="状态:0-归档 1-活跃")
|
||||
cover_image = Column(String(255), comment="封面图")
|
||||
sort_order = Column(Integer, default=0, comment="排序号")
|
||||
visit_count = Column(Integer, default=0, comment="访问次数")
|
||||
access_pass = Column(String(100), comment="访问密码(用于分享链接)")
|
||||
created_at = Column(DateTime, server_default=func.now(), index=True, comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Project(id={self.id}, name='{self.name}')>"
|
||||
|
||||
|
||||
class ProjectMember(Base):
|
||||
"""项目成员表模型"""
|
||||
|
||||
__tablename__ = "project_members"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="成员ID")
|
||||
project_id = Column(BigInteger, nullable=False, index=True, comment="项目ID")
|
||||
user_id = Column(BigInteger, nullable=False, index=True, comment="用户ID")
|
||||
role = Column(
|
||||
Enum(ProjectMemberRole),
|
||||
default=ProjectMemberRole.VIEWER,
|
||||
index=True,
|
||||
comment="项目角色"
|
||||
)
|
||||
invited_by = Column(BigInteger, comment="邀请人ID")
|
||||
joined_at = Column(DateTime, server_default=func.now(), comment="加入时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ProjectMember(project_id={self.project_id}, user_id={self.user_id}, role='{self.role}')>"
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
"""
|
||||
角色模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, DateTime, SmallInteger
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Role(Base):
|
||||
"""角色表模型"""
|
||||
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="角色ID")
|
||||
role_name = Column(String(50), nullable=False, unique=True, comment="角色名称")
|
||||
role_code = Column(String(50), nullable=False, unique=True, index=True, comment="角色编码")
|
||||
description = Column(String(255), comment="角色描述")
|
||||
status = Column(SmallInteger, default=1, index=True, comment="状态:0-禁用 1-启用")
|
||||
is_system = Column(SmallInteger, default=0, comment="是否系统角色:0-否 1-是")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Role(id={self.id}, role_code='{self.role_code}')>"
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
"""用户角色关联表模型"""
|
||||
|
||||
__tablename__ = "user_roles"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="关联ID")
|
||||
user_id = Column(BigInteger, nullable=False, index=True, comment="用户ID")
|
||||
role_id = Column(BigInteger, nullable=False, index=True, comment="角色ID")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserRole(user_id={self.user_id}, role_id={self.role_id})>"
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
"""
|
||||
用户模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, Integer, DateTime, SmallInteger
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""用户表模型"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="用户ID")
|
||||
username = Column(String(50), nullable=False, unique=True, index=True, comment="用户名")
|
||||
password_hash = Column(String(255), nullable=False, comment="密码哈希")
|
||||
nickname = Column(String(50), comment="昵称")
|
||||
email = Column(String(100), index=True, comment="邮箱")
|
||||
phone = Column(String(20), comment="手机号")
|
||||
avatar = Column(String(255), comment="头像URL")
|
||||
status = Column(SmallInteger, default=1, index=True, comment="状态:0-禁用 1-启用")
|
||||
is_superuser = Column(SmallInteger, default=0, comment="是否超级管理员:0-否 1-是")
|
||||
last_login_at = Column(DateTime, comment="最后登录时间")
|
||||
last_login_ip = Column(String(50), comment="最后登录IP")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, username='{self.username}')>"
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
"""
|
||||
导出所有 Pydantic Schemas
|
||||
"""
|
||||
from app.schemas.user import (
|
||||
UserBase,
|
||||
UserCreate,
|
||||
UserUpdate,
|
||||
UserResponse,
|
||||
UserLogin,
|
||||
Token,
|
||||
ChangePassword,
|
||||
)
|
||||
from app.schemas.project import (
|
||||
ProjectBase,
|
||||
ProjectCreate,
|
||||
ProjectUpdate,
|
||||
ProjectResponse,
|
||||
ProjectMemberAdd,
|
||||
ProjectMemberUpdate,
|
||||
ProjectMemberResponse,
|
||||
)
|
||||
from app.schemas.file import (
|
||||
FileTreeNode,
|
||||
FileContentRequest,
|
||||
FileSaveRequest,
|
||||
FileOperateRequest,
|
||||
FileUploadResponse,
|
||||
)
|
||||
from app.schemas.response import (
|
||||
Response,
|
||||
PageResponse,
|
||||
success_response,
|
||||
error_response,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# User
|
||||
"UserBase",
|
||||
"UserCreate",
|
||||
"UserUpdate",
|
||||
"UserResponse",
|
||||
"UserLogin",
|
||||
"Token",
|
||||
"ChangePassword",
|
||||
# Project
|
||||
"ProjectBase",
|
||||
"ProjectCreate",
|
||||
"ProjectUpdate",
|
||||
"ProjectResponse",
|
||||
"ProjectMemberAdd",
|
||||
"ProjectMemberUpdate",
|
||||
"ProjectMemberResponse",
|
||||
# File
|
||||
"FileTreeNode",
|
||||
"FileContentRequest",
|
||||
"FileSaveRequest",
|
||||
"FileOperateRequest",
|
||||
"FileUploadResponse",
|
||||
# Response
|
||||
"Response",
|
||||
"PageResponse",
|
||||
"success_response",
|
||||
"error_response",
|
||||
]
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
"""
|
||||
文件系统操作相关的 Pydantic Schema
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class FileTreeNode(BaseModel):
|
||||
"""文件树节点 Schema"""
|
||||
title: str = Field(..., description="节点标题(文件/文件夹名)")
|
||||
key: str = Field(..., description="节点唯一键(相对路径)")
|
||||
isLeaf: bool = Field(..., description="是否叶子节点")
|
||||
children: Optional[List['FileTreeNode']] = Field(None, description="子节点")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# 支持递归引用
|
||||
FileTreeNode.model_rebuild()
|
||||
|
||||
|
||||
class FileContentRequest(BaseModel):
|
||||
"""文件内容请求 Schema"""
|
||||
path: str = Field(..., description="文件相对路径")
|
||||
|
||||
|
||||
class FileSaveRequest(BaseModel):
|
||||
"""文件保存请求 Schema"""
|
||||
path: str = Field(..., description="文件相对路径")
|
||||
content: str = Field(..., description="文件内容")
|
||||
|
||||
|
||||
class FileOperateRequest(BaseModel):
|
||||
"""文件操作请求 Schema"""
|
||||
action: str = Field(..., description="操作类型:rename|delete|create_dir|create_file")
|
||||
path: str = Field(..., description="当前路径")
|
||||
new_path: Optional[str] = Field(None, description="新路径(重命名时使用)")
|
||||
content: Optional[str] = Field(None, description="文件内容(创建文件时使用)")
|
||||
|
||||
|
||||
class FileUploadResponse(BaseModel):
|
||||
"""文件上传响应 Schema"""
|
||||
filename: str = Field(..., description="文件名")
|
||||
path: str = Field(..., description="文件相对路径")
|
||||
url: str = Field(..., description="文件访问URL")
|
||||
size: int = Field(..., description="文件大小(字节)")
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
"""
|
||||
项目相关的 Pydantic Schema
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from app.models.project import ProjectMemberRole
|
||||
|
||||
|
||||
class ProjectBase(BaseModel):
|
||||
"""项目基础 Schema"""
|
||||
name: str = Field(..., min_length=1, max_length=100, description="项目名称")
|
||||
description: Optional[str] = Field(None, max_length=500, description="项目描述")
|
||||
is_public: int = Field(0, description="是否公开:0-私有 1-公开")
|
||||
|
||||
|
||||
class ProjectCreate(ProjectBase):
|
||||
"""创建项目 Schema"""
|
||||
pass
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
"""更新项目 Schema"""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
is_public: Optional[int] = None
|
||||
cover_image: Optional[str] = None
|
||||
status: Optional[int] = None
|
||||
|
||||
|
||||
class ProjectResponse(ProjectBase):
|
||||
"""项目响应 Schema"""
|
||||
id: int
|
||||
storage_key: str
|
||||
owner_id: int
|
||||
is_template: int
|
||||
status: int
|
||||
cover_image: Optional[str] = None
|
||||
visit_count: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProjectMemberAdd(BaseModel):
|
||||
"""添加项目成员 Schema"""
|
||||
user_id: int = Field(..., description="用户ID")
|
||||
role: ProjectMemberRole = Field(ProjectMemberRole.VIEWER, description="项目角色")
|
||||
|
||||
|
||||
class ProjectMemberUpdate(BaseModel):
|
||||
"""更新项目成员 Schema"""
|
||||
role: ProjectMemberRole = Field(..., description="项目角色")
|
||||
|
||||
|
||||
class ProjectMemberResponse(BaseModel):
|
||||
"""项目成员响应 Schema"""
|
||||
id: int
|
||||
project_id: int
|
||||
user_id: int
|
||||
role: ProjectMemberRole
|
||||
joined_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProjectShareSettings(BaseModel):
|
||||
"""项目分享设置 Schema"""
|
||||
access_pass: Optional[str] = Field(None, max_length=100, description="访问密码(None表示取消密码)")
|
||||
|
||||
|
||||
class ProjectShareInfo(BaseModel):
|
||||
"""项目分享信息响应 Schema"""
|
||||
share_url: str = Field(..., description="分享链接")
|
||||
has_password: bool = Field(..., description="是否设置了访问密码")
|
||||
access_pass: Optional[str] = Field(None, description="访问密码(仅项目所有者可见)")
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"""
|
||||
通用响应 Schema
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Generic, TypeVar, Optional, Any
|
||||
|
||||
DataT = TypeVar('DataT')
|
||||
|
||||
|
||||
class Response(BaseModel, Generic[DataT]):
|
||||
"""统一响应格式"""
|
||||
code: int = Field(200, description="状态码")
|
||||
message: str = Field("success", description="响应消息")
|
||||
data: Optional[DataT] = Field(None, description="响应数据")
|
||||
|
||||
|
||||
class PageResponse(BaseModel, Generic[DataT]):
|
||||
"""分页响应格式"""
|
||||
code: int = Field(200, description="状态码")
|
||||
message: str = Field("success", description="响应消息")
|
||||
data: Optional[DataT] = Field(None, description="响应数据列表")
|
||||
total: int = Field(0, description="总记录数")
|
||||
page: int = Field(1, description="当前页码")
|
||||
page_size: int = Field(10, description="每页大小")
|
||||
|
||||
|
||||
def success_response(data: Any = None, message: str = "success") -> dict:
|
||||
"""成功响应"""
|
||||
return {
|
||||
"code": 200,
|
||||
"message": message,
|
||||
"data": data
|
||||
}
|
||||
|
||||
|
||||
def error_response(message: str = "error", code: int = 400, data: Any = None) -> dict:
|
||||
"""错误响应"""
|
||||
return {
|
||||
"code": code,
|
||||
"message": message,
|
||||
"data": data
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"""
|
||||
用户相关的 Pydantic Schema
|
||||
"""
|
||||
from pydantic import BaseModel, Field, EmailStr
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
"""用户基础 Schema"""
|
||||
username: str = Field(..., min_length=3, max_length=50, description="用户名")
|
||||
nickname: Optional[str] = Field(None, max_length=50, description="昵称")
|
||||
email: Optional[EmailStr] = Field(None, description="邮箱")
|
||||
phone: Optional[str] = Field(None, max_length=20, description="手机号")
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
"""创建用户 Schema"""
|
||||
password: str = Field(..., min_length=6, max_length=50, description="密码")
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""更新用户 Schema"""
|
||||
nickname: Optional[str] = None
|
||||
email: Optional[EmailStr] = None
|
||||
phone: Optional[str] = None
|
||||
avatar: Optional[str] = None
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
"""用户响应 Schema"""
|
||||
id: int
|
||||
avatar: Optional[str] = None
|
||||
status: int
|
||||
is_superuser: int
|
||||
last_login_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
"""用户登录 Schema"""
|
||||
username: str = Field(..., description="用户名")
|
||||
password: str = Field(..., description="密码")
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Token 响应 Schema"""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserResponse
|
||||
|
||||
|
||||
class ChangePassword(BaseModel):
|
||||
"""修改密码 Schema"""
|
||||
old_password: str = Field(..., description="旧密码")
|
||||
new_password: str = Field(..., min_length=6, max_length=50, description="新密码")
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
"""
|
||||
文件存储管理服务
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
import aiofiles
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from app.core.config import settings
|
||||
from app.schemas.file import FileTreeNode
|
||||
|
||||
|
||||
class StorageService:
|
||||
"""文件存储服务类"""
|
||||
|
||||
def __init__(self):
|
||||
self.projects_root = Path(settings.PROJECTS_PATH)
|
||||
self.temp_root = Path(settings.TEMP_PATH)
|
||||
|
||||
# 确保根目录存在
|
||||
self.projects_root.mkdir(parents=True, exist_ok=True)
|
||||
self.temp_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def get_secure_path(self, storage_key: str, relative_path: str = "") -> Path:
|
||||
"""
|
||||
获取安全的文件路径(防止路径穿越攻击)
|
||||
|
||||
Args:
|
||||
storage_key: 项目 UUID
|
||||
relative_path: 相对路径
|
||||
|
||||
Returns:
|
||||
Path: 安全的绝对路径
|
||||
|
||||
Raises:
|
||||
HTTPException: 非法路径访问
|
||||
"""
|
||||
# 项目根目录
|
||||
project_root = self.projects_root / storage_key
|
||||
project_root = project_root.resolve()
|
||||
|
||||
# 目标路径
|
||||
if relative_path:
|
||||
target_path = (project_root / relative_path).resolve()
|
||||
else:
|
||||
target_path = project_root
|
||||
|
||||
# 安全检查:目标路径必须在项目根目录下
|
||||
try:
|
||||
target_path.relative_to(project_root)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=403, detail="非法路径访问")
|
||||
|
||||
return target_path
|
||||
|
||||
def create_project_structure(self, storage_key: str) -> None:
|
||||
"""
|
||||
创建项目文件夹结构
|
||||
|
||||
Args:
|
||||
storage_key: 项目 UUID
|
||||
"""
|
||||
project_root = self.projects_root / storage_key
|
||||
project_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 创建 _assets 目录
|
||||
assets_dir = project_root / "_assets"
|
||||
assets_dir.mkdir(exist_ok=True)
|
||||
(assets_dir / "images").mkdir(exist_ok=True)
|
||||
(assets_dir / "files").mkdir(exist_ok=True)
|
||||
|
||||
# 创建默认 README.md
|
||||
readme_path = project_root / "README.md"
|
||||
if not readme_path.exists():
|
||||
with open(readme_path, "w", encoding="utf-8") as f:
|
||||
f.write("# 项目首页\n\n欢迎使用 NEX Docus!\n")
|
||||
|
||||
def generate_tree(self, path: Path, relative_root: str = "") -> List[FileTreeNode]:
|
||||
"""
|
||||
生成目录树结构
|
||||
|
||||
Args:
|
||||
path: 目录路径
|
||||
relative_root: 相对根路径
|
||||
|
||||
Returns:
|
||||
List[FileTreeNode]: 目录树节点列表
|
||||
"""
|
||||
if not path.exists() or not path.is_dir():
|
||||
return []
|
||||
|
||||
tree = []
|
||||
try:
|
||||
# 获取所有文件和文件夹,按类型和名称排序
|
||||
items = sorted(
|
||||
path.iterdir(),
|
||||
key=lambda x: (not x.is_dir(), x.name.lower())
|
||||
)
|
||||
|
||||
for item in items:
|
||||
# 跳过隐藏文件、特殊目录和 _assets 目录
|
||||
if item.name.startswith(".") or item.name == "_assets":
|
||||
continue
|
||||
|
||||
rel_path = str(Path(relative_root) / item.name) if relative_root else item.name
|
||||
|
||||
node_data = {
|
||||
"title": item.name,
|
||||
"key": rel_path,
|
||||
"isLeaf": item.is_file(),
|
||||
}
|
||||
|
||||
if item.is_dir():
|
||||
node_data["children"] = self.generate_tree(item, rel_path)
|
||||
|
||||
tree.append(FileTreeNode(**node_data))
|
||||
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="权限不足")
|
||||
|
||||
return tree
|
||||
|
||||
async def read_file(self, file_path: Path) -> str:
|
||||
"""
|
||||
读取文件内容
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
|
||||
Returns:
|
||||
str: 文件内容
|
||||
|
||||
Raises:
|
||||
HTTPException: 文件不存在或无法读取
|
||||
"""
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=400, detail="不是有效的文件")
|
||||
|
||||
try:
|
||||
async with aiofiles.open(file_path, "r", encoding="utf-8") as f:
|
||||
content = await f.read()
|
||||
return content
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"文件读取失败: {str(e)}")
|
||||
|
||||
async def write_file(self, file_path: Path, content: str) -> None:
|
||||
"""
|
||||
写入文件内容
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
content: 文件内容
|
||||
|
||||
Raises:
|
||||
HTTPException: 写入失败
|
||||
"""
|
||||
try:
|
||||
# 确保父目录存在
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async with aiofiles.open(file_path, "w", encoding="utf-8") as f:
|
||||
await f.write(content)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"文件写入失败: {str(e)}")
|
||||
|
||||
async def delete_file(self, file_path: Path) -> None:
|
||||
"""
|
||||
删除文件或文件夹
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
|
||||
Raises:
|
||||
HTTPException: 删除失败
|
||||
"""
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
|
||||
try:
|
||||
if file_path.is_dir():
|
||||
shutil.rmtree(file_path)
|
||||
else:
|
||||
file_path.unlink()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"删除失败: {str(e)}")
|
||||
|
||||
async def rename_file(self, old_path: Path, new_path: Path) -> None:
|
||||
"""
|
||||
重命名文件或文件夹
|
||||
|
||||
Args:
|
||||
old_path: 旧路径
|
||||
new_path: 新路径
|
||||
|
||||
Raises:
|
||||
HTTPException: 重命名失败
|
||||
"""
|
||||
if not old_path.exists():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
|
||||
if new_path.exists():
|
||||
raise HTTPException(status_code=400, detail="目标路径已存在")
|
||||
|
||||
try:
|
||||
old_path.rename(new_path)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"重命名失败: {str(e)}")
|
||||
|
||||
async def create_directory(self, dir_path: Path) -> None:
|
||||
"""
|
||||
创建目录
|
||||
|
||||
Args:
|
||||
dir_path: 目录路径
|
||||
|
||||
Raises:
|
||||
HTTPException: 创建失败
|
||||
"""
|
||||
if dir_path.exists():
|
||||
raise HTTPException(status_code=400, detail="目录已存在")
|
||||
|
||||
try:
|
||||
dir_path.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"创建目录失败: {str(e)}")
|
||||
|
||||
async def upload_file(
|
||||
self,
|
||||
storage_key: str,
|
||||
file: UploadFile,
|
||||
subfolder: str = "images"
|
||||
) -> dict:
|
||||
"""
|
||||
上传文件到项目资源目录
|
||||
|
||||
Args:
|
||||
storage_key: 项目 UUID
|
||||
file: 上传的文件
|
||||
subfolder: 子文件夹(images 或 files)
|
||||
|
||||
Returns:
|
||||
dict: 文件信息
|
||||
|
||||
Raises:
|
||||
HTTPException: 上传失败
|
||||
"""
|
||||
# 生成唯一文件名
|
||||
file_ext = Path(file.filename).suffix
|
||||
unique_filename = f"{uuid.uuid4().hex}{file_ext}"
|
||||
|
||||
# 目标路径
|
||||
target_dir = self.get_secure_path(storage_key, f"_assets/{subfolder}")
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
target_path = target_dir / unique_filename
|
||||
|
||||
try:
|
||||
# 保存文件
|
||||
async with aiofiles.open(target_path, "wb") as f:
|
||||
content = await file.read()
|
||||
await f.write(content)
|
||||
|
||||
# 返回文件信息
|
||||
relative_path = f"_assets/{subfolder}/{unique_filename}"
|
||||
return {
|
||||
"filename": unique_filename,
|
||||
"original_filename": file.filename,
|
||||
"path": relative_path,
|
||||
"size": len(content),
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"文件上传失败: {str(e)}")
|
||||
|
||||
|
||||
# 创建全局实例
|
||||
storage_service = StorageService()
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
"""
|
||||
FastAPI 主应用
|
||||
"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from app.core.config import settings
|
||||
from app.core.redis_client import init_redis, close_redis
|
||||
from app.api.v1 import api_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理"""
|
||||
# 启动时初始化 Redis
|
||||
await init_redis()
|
||||
yield
|
||||
# 关闭时清理 Redis 连接
|
||||
await close_redis()
|
||||
|
||||
|
||||
# 创建 FastAPI 应用
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
version=settings.APP_VERSION,
|
||||
description="NEX Docus - 团队协作文档管理平台",
|
||||
debug=settings.DEBUG,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# 配置 CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 注册 API 路由
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""根路径"""
|
||||
return {
|
||||
"name": settings.APP_NAME,
|
||||
"version": settings.APP_VERSION,
|
||||
"status": "running"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""健康检查"""
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host=settings.HOST,
|
||||
port=settings.PORT,
|
||||
reload=settings.DEBUG,
|
||||
)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
-- 为projects表添加access_pass字段(访问密码)
|
||||
-- 执行时间:2025-12-20
|
||||
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN access_pass VARCHAR(100) NULL COMMENT '访问密码(用于分享链接)' AFTER visit_count;
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# FastAPI 核心
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
python-multipart==0.0.6
|
||||
email-validator==2.1.0
|
||||
|
||||
# 数据库
|
||||
SQLAlchemy==2.0.25
|
||||
pymysql==1.1.0
|
||||
aiomysql==0.2.0
|
||||
alembic==1.13.1
|
||||
greenlet==3.0.3
|
||||
|
||||
# Redis
|
||||
redis==5.0.1
|
||||
aioredis==2.0.1
|
||||
|
||||
# 认证与安全
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-dotenv==1.0.0
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
|
||||
# 文件处理
|
||||
aiofiles==23.2.1
|
||||
python-magic==0.4.27
|
||||
|
||||
# 工具库
|
||||
PyYAML==6.0.1
|
||||
loguru==0.7.2
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
"""
|
||||
创建 NEX Design 项目
|
||||
"""
|
||||
import pymysql
|
||||
import uuid
|
||||
import os
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# 数据库配置
|
||||
DB_CONFIG = {
|
||||
'host': '10.100.51.51',
|
||||
'port': 3306,
|
||||
'user': 'root',
|
||||
'password': 'Unis@123',
|
||||
'database': 'nex_docus',
|
||||
'charset': 'utf8mb4',
|
||||
}
|
||||
|
||||
# 项目信息
|
||||
PROJECT_INFO = {
|
||||
'name': 'NEX Design',
|
||||
'description': 'NEX 设计文档库',
|
||||
'owner_id': 1, # admin 用户的 ID
|
||||
}
|
||||
|
||||
# 文件存储根目录
|
||||
STORAGE_ROOT = '/data/nex_docus_store/projects'
|
||||
|
||||
def create_project():
|
||||
"""创建项目"""
|
||||
try:
|
||||
# 生成 UUID
|
||||
storage_key = str(uuid.uuid4())
|
||||
print(f"生成项目 UUID: {storage_key}")
|
||||
|
||||
# 连接数据库
|
||||
print("正在连接数据库...")
|
||||
connection = pymysql.connect(**DB_CONFIG)
|
||||
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
# 1. 插入项目记录
|
||||
print("创建项目记录...")
|
||||
insert_project_sql = """
|
||||
INSERT INTO `projects`
|
||||
(`name`, `description`, `storage_key`, `owner_id`, `is_public`, `status`, `created_at`)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
cursor.execute(insert_project_sql, (
|
||||
PROJECT_INFO['name'],
|
||||
PROJECT_INFO['description'],
|
||||
storage_key,
|
||||
PROJECT_INFO['owner_id'],
|
||||
0, # 私有项目
|
||||
1, # 活跃状态
|
||||
datetime.now()
|
||||
))
|
||||
|
||||
project_id = cursor.lastrowid
|
||||
print(f"✓ 项目ID: {project_id}")
|
||||
|
||||
# 2. 添加项目成员(admin 作为管理员)
|
||||
print("添加项目管理员...")
|
||||
insert_member_sql = """
|
||||
INSERT INTO `project_members`
|
||||
(`project_id`, `user_id`, `role`, `joined_at`)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
"""
|
||||
cursor.execute(insert_member_sql, (
|
||||
project_id,
|
||||
PROJECT_INFO['owner_id'],
|
||||
'admin',
|
||||
datetime.now()
|
||||
))
|
||||
print("✓ 管理员已添加")
|
||||
|
||||
# 提交事务
|
||||
connection.commit()
|
||||
print("✓ 数据库记录创建成功")
|
||||
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
# 3. 创建物理文件夹结构
|
||||
print("\n创建项目文件夹...")
|
||||
project_path = Path(STORAGE_ROOT) / storage_key
|
||||
|
||||
try:
|
||||
# 创建项目根目录
|
||||
project_path.mkdir(parents=True, exist_ok=True)
|
||||
print(f"✓ 创建目录: {project_path}")
|
||||
|
||||
# 创建 _assets 目录
|
||||
assets_dir = project_path / "_assets"
|
||||
assets_dir.mkdir(exist_ok=True)
|
||||
(assets_dir / "images").mkdir(exist_ok=True)
|
||||
(assets_dir / "files").mkdir(exist_ok=True)
|
||||
print(f"✓ 创建资源目录")
|
||||
|
||||
# 创建默认 README.md
|
||||
readme_path = project_path / "README.md"
|
||||
with open(readme_path, "w", encoding="utf-8") as f:
|
||||
f.write(f"""# {PROJECT_INFO['name']}
|
||||
|
||||
{PROJECT_INFO['description']}
|
||||
|
||||
## 欢迎使用 NEX Docus!
|
||||
|
||||
这是您的项目首页,您可以在这里编写项目介绍、使用说明等内容。
|
||||
|
||||
### 快速开始
|
||||
|
||||
1. 在左侧目录树中创建文件夹和文档
|
||||
2. 支持 Markdown 语法编写文档
|
||||
3. 支持图片和附件上传
|
||||
|
||||
---
|
||||
|
||||
创建时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||
""")
|
||||
print(f"✓ 创建 README.md")
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ 文件夹创建警告: {e}")
|
||||
print(f" 请确保目录 {STORAGE_ROOT} 存在并有写入权限")
|
||||
print(f" 或者修改 backend/.env 中的 STORAGE_ROOT 配置")
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("✅ 项目创建成功!")
|
||||
print("="*60)
|
||||
print(f"项目名称: {PROJECT_INFO['name']}")
|
||||
print(f"项目ID: {project_id}")
|
||||
print(f"存储路径: {project_path}")
|
||||
print(f"UUID: {storage_key}")
|
||||
print("="*60)
|
||||
print("\n你现在可以:")
|
||||
print("1. 启动后端服务: cd backend && python main.py")
|
||||
print("2. 启动前端服务: cd forntend && npm run dev")
|
||||
print("3. 登录系统 (admin / admin@123)")
|
||||
print("4. 在项目中添加你的文档")
|
||||
print()
|
||||
|
||||
except pymysql.Error as e:
|
||||
print(f"❌ 数据库错误: {e}")
|
||||
except Exception as e:
|
||||
print(f"❌ 未知错误: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("创建 NEX Design 项目")
|
||||
print("=" * 60)
|
||||
print()
|
||||
create_project()
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
"""
|
||||
生成管理员密码哈希
|
||||
"""
|
||||
from passlib.context import CryptContext
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# 生成 admin@123 的密码哈希
|
||||
password = "admin@123"
|
||||
password_hash = pwd_context.hash(password)
|
||||
|
||||
print(f"密码: {password}")
|
||||
print(f"哈希: {password_hash}")
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
-- NEX Docus 数据库初始化脚本
|
||||
-- 创建数据库
|
||||
CREATE DATABASE IF NOT EXISTS `nex_docus` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE `nex_docus`;
|
||||
|
||||
-- 1. 用户表
|
||||
CREATE TABLE IF NOT EXISTS `users` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID',
|
||||
`username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名(登录账号)',
|
||||
`password_hash` VARCHAR(255) NOT NULL COMMENT '密码哈希(bcrypt)',
|
||||
`nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称(显示名称)',
|
||||
`email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱',
|
||||
`phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
|
||||
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像URL',
|
||||
`status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
|
||||
`is_superuser` TINYINT DEFAULT 0 COMMENT '是否超级管理员:0-否 1-是',
|
||||
`last_login_at` DATETIME DEFAULT NULL COMMENT '最后登录时间',
|
||||
`last_login_ip` VARCHAR(50) DEFAULT NULL COMMENT '最后登录IP',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX `idx_username` (`username`),
|
||||
INDEX `idx_email` (`email`),
|
||||
INDEX `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
|
||||
|
||||
-- 2. 角色表
|
||||
CREATE TABLE IF NOT EXISTS `roles` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '角色ID',
|
||||
`role_name` VARCHAR(50) NOT NULL UNIQUE COMMENT '角色名称',
|
||||
`role_code` VARCHAR(50) NOT NULL UNIQUE COMMENT '角色编码',
|
||||
`description` VARCHAR(255) DEFAULT NULL COMMENT '角色描述',
|
||||
`status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
|
||||
`is_system` TINYINT DEFAULT 0 COMMENT '是否系统角色:0-否 1-是',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX `idx_role_code` (`role_code`),
|
||||
INDEX `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';
|
||||
|
||||
-- 3. 用户角色关联表
|
||||
CREATE TABLE IF NOT EXISTS `user_roles` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID',
|
||||
`user_id` BIGINT NOT NULL COMMENT '用户ID',
|
||||
`role_id` BIGINT NOT NULL COMMENT '角色ID',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
UNIQUE KEY `uk_user_role` (`user_id`, `role_id`),
|
||||
INDEX `idx_user_id` (`user_id`),
|
||||
INDEX `idx_role_id` (`role_id`),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表';
|
||||
|
||||
-- 4. 系统菜单表
|
||||
CREATE TABLE IF NOT EXISTS `system_menus` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '菜单ID',
|
||||
`parent_id` BIGINT DEFAULT 0 COMMENT '父菜单ID',
|
||||
`menu_name` VARCHAR(50) NOT NULL COMMENT '菜单名称',
|
||||
`menu_code` VARCHAR(50) NOT NULL UNIQUE COMMENT '菜单编码',
|
||||
`menu_type` TINYINT NOT NULL COMMENT '菜单类型:1-目录 2-菜单 3-按钮',
|
||||
`path` VARCHAR(255) DEFAULT NULL COMMENT '路由路径',
|
||||
`component` VARCHAR(255) DEFAULT NULL COMMENT '组件路径',
|
||||
`icon` VARCHAR(100) DEFAULT NULL COMMENT '图标',
|
||||
`sort_order` INT DEFAULT 0 COMMENT '排序号',
|
||||
`visible` TINYINT DEFAULT 1 COMMENT '是否可见:0-隐藏 1-显示',
|
||||
`status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
|
||||
`permission` VARCHAR(100) DEFAULT NULL COMMENT '权限字符串',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX `idx_parent_id` (`parent_id`),
|
||||
INDEX `idx_menu_code` (`menu_code`),
|
||||
INDEX `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统菜单表';
|
||||
|
||||
-- 5. 角色菜单授权表
|
||||
CREATE TABLE IF NOT EXISTS `role_menus` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID',
|
||||
`role_id` BIGINT NOT NULL COMMENT '角色ID',
|
||||
`menu_id` BIGINT NOT NULL COMMENT '菜单ID',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
UNIQUE KEY `uk_role_menu` (`role_id`, `menu_id`),
|
||||
INDEX `idx_role_id` (`role_id`),
|
||||
INDEX `idx_menu_id` (`menu_id`),
|
||||
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`menu_id`) REFERENCES `system_menus`(`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色菜单授权表';
|
||||
|
||||
-- 6. 项目表
|
||||
CREATE TABLE IF NOT EXISTS `projects` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '项目ID',
|
||||
`name` VARCHAR(100) NOT NULL COMMENT '项目名称',
|
||||
`description` VARCHAR(500) DEFAULT NULL COMMENT '项目描述',
|
||||
`storage_key` CHAR(36) NOT NULL COMMENT '磁盘存储UUID',
|
||||
`owner_id` BIGINT NOT NULL COMMENT '项目所有者ID',
|
||||
`is_public` TINYINT DEFAULT 0 COMMENT '是否公开:0-私有 1-公开',
|
||||
`is_template` TINYINT DEFAULT 0 COMMENT '是否模板项目:0-否 1-是',
|
||||
`status` TINYINT DEFAULT 1 COMMENT '状态:0-归档 1-活跃',
|
||||
`cover_image` VARCHAR(255) DEFAULT NULL COMMENT '封面图',
|
||||
`sort_order` INT DEFAULT 0 COMMENT '排序号',
|
||||
`visit_count` INT DEFAULT 0 COMMENT '访问次数',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE KEY `uk_storage_key` (`storage_key`),
|
||||
INDEX `idx_owner_id` (`owner_id`),
|
||||
INDEX `idx_name` (`name`),
|
||||
INDEX `idx_status` (`status`),
|
||||
INDEX `idx_created_at` (`created_at`),
|
||||
FOREIGN KEY (`owner_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目表';
|
||||
|
||||
-- 7. 项目成员表
|
||||
CREATE TABLE IF NOT EXISTS `project_members` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '成员ID',
|
||||
`project_id` BIGINT NOT NULL COMMENT '项目ID',
|
||||
`user_id` BIGINT NOT NULL COMMENT '用户ID',
|
||||
`role` ENUM('admin', 'editor', 'viewer') DEFAULT 'viewer' COMMENT '项目角色',
|
||||
`invited_by` BIGINT DEFAULT NULL COMMENT '邀请人ID',
|
||||
`joined_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间',
|
||||
UNIQUE KEY `uk_project_user` (`project_id`, `user_id`),
|
||||
INDEX `idx_project_id` (`project_id`),
|
||||
INDEX `idx_user_id` (`user_id`),
|
||||
INDEX `idx_role` (`role`),
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`invited_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目成员表';
|
||||
|
||||
-- 8. 文档元数据表
|
||||
CREATE TABLE IF NOT EXISTS `document_meta` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '元数据ID',
|
||||
`project_id` BIGINT NOT NULL COMMENT '项目ID',
|
||||
`file_path` VARCHAR(500) NOT NULL COMMENT '文件相对路径',
|
||||
`title` VARCHAR(200) DEFAULT NULL COMMENT '文档标题',
|
||||
`tags` VARCHAR(500) DEFAULT NULL COMMENT '标签(JSON数组)',
|
||||
`author_id` BIGINT DEFAULT NULL COMMENT '作者ID',
|
||||
`word_count` INT DEFAULT 0 COMMENT '字数统计',
|
||||
`view_count` INT DEFAULT 0 COMMENT '浏览次数',
|
||||
`last_editor_id` BIGINT DEFAULT NULL COMMENT '最后编辑者ID',
|
||||
`last_edited_at` DATETIME DEFAULT NULL COMMENT '最后编辑时间',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE KEY `uk_project_path` (`project_id`, `file_path`(255)),
|
||||
INDEX `idx_project_id` (`project_id`),
|
||||
INDEX `idx_author_id` (`author_id`),
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON DELETE SET NULL,
|
||||
FOREIGN KEY (`last_editor_id`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文档元数据表';
|
||||
|
||||
-- 9. 操作日志表
|
||||
CREATE TABLE IF NOT EXISTS `operation_logs` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '日志ID',
|
||||
`user_id` BIGINT DEFAULT NULL COMMENT '操作用户ID',
|
||||
`username` VARCHAR(50) DEFAULT NULL COMMENT '用户名',
|
||||
`operation_type` VARCHAR(50) NOT NULL COMMENT '操作类型',
|
||||
`resource_type` VARCHAR(50) NOT NULL COMMENT '资源类型',
|
||||
`resource_id` BIGINT DEFAULT NULL COMMENT '资源ID',
|
||||
`detail` TEXT DEFAULT NULL COMMENT '操作详情(JSON)',
|
||||
`ip_address` VARCHAR(50) DEFAULT NULL COMMENT 'IP地址',
|
||||
`user_agent` VARCHAR(500) DEFAULT NULL COMMENT '用户代理',
|
||||
`status` TINYINT DEFAULT 1 COMMENT '状态:0-失败 1-成功',
|
||||
`error_message` TEXT DEFAULT NULL COMMENT '错误信息',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
|
||||
INDEX `idx_user_id` (`user_id`),
|
||||
INDEX `idx_resource` (`resource_type`, `resource_id`),
|
||||
INDEX `idx_created_at` (`created_at`),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';
|
||||
|
||||
-- 插入初始角色数据
|
||||
INSERT INTO `roles` (`role_name`, `role_code`, `description`, `is_system`) VALUES
|
||||
('超级管理员', 'super_admin', '拥有系统所有权限', 1),
|
||||
('项目管理员', 'project_admin', '可以创建和管理项目', 1),
|
||||
('普通用户', 'user', '可以查看和编辑被授权的项目', 1),
|
||||
('访客', 'guest', '只读权限', 1);
|
||||
|
||||
-- 插入初始菜单数据
|
||||
INSERT INTO `system_menus` (`id`, `parent_id`, `menu_name`, `menu_code`, `menu_type`, `path`, `icon`, `sort_order`, `permission`) VALUES
|
||||
(1, 0, '项目管理', 'project', 1, '/projects', 'FolderOutlined', 1, NULL),
|
||||
(2, 1, '我的项目', 'my_projects', 2, '/projects/my', NULL, 1, 'project:view'),
|
||||
(3, 1, '创建项目', 'create_project', 3, NULL, NULL, 2, 'project:create'),
|
||||
(4, 1, '编辑项目', 'edit_project', 3, NULL, NULL, 3, 'project:edit'),
|
||||
(5, 1, '删除项目', 'delete_project', 3, NULL, NULL, 4, 'project:delete'),
|
||||
(10, 0, '文档管理', 'document', 1, '/documents', 'FileTextOutlined', 2, NULL),
|
||||
(11, 10, '查看文档', 'view_document', 3, NULL, NULL, 1, 'document:view'),
|
||||
(12, 10, '编辑文档', 'edit_document', 3, NULL, NULL, 2, 'document:edit'),
|
||||
(13, 10, '删除文档', 'delete_document', 3, NULL, NULL, 3, 'document:delete'),
|
||||
(20, 0, '系统管理', 'system', 1, '/system', 'SettingOutlined', 3, NULL),
|
||||
(21, 20, '用户管理', 'user_manage', 2, '/system/users', NULL, 1, 'system:user:view'),
|
||||
(22, 20, '角色管理', 'role_manage', 2, '/system/roles', NULL, 2, 'system:role:view');
|
||||
|
||||
-- 创建默认管理员用户(密码: admin@123)
|
||||
INSERT INTO `users` (`username`, `password_hash`, `nickname`, `is_superuser`, `status`) VALUES
|
||||
('admin', '$2b$12$TkyjVycb8PHk/835Py4Kz.r.us7YqPAbF.89NQ7TrU5/r/lTqVAUu', '系统管理员', 1, 1);
|
||||
|
||||
-- 将管理员用户分配超级管理员角色
|
||||
INSERT INTO `user_roles` (`user_id`, `role_id`) VALUES (1, 1);
|
||||
|
||||
-- 为超级管理员角色授权所有菜单
|
||||
INSERT INTO `role_menus` (`role_id`, `menu_id`)
|
||||
SELECT 1, id FROM `system_menus`;
|
||||
|
||||
-- 完成
|
||||
SELECT '数据库初始化完成!' AS message;
|
||||
SELECT CONCAT('默认管理员账号: admin, 密码: admin@123') AS credentials;
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
"""
|
||||
数据库初始化 Python 脚本
|
||||
使用 SQLAlchemy 创建表结构
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base
|
||||
from app.models import * # 导入所有模型
|
||||
from app.core.security import get_password_hash
|
||||
|
||||
|
||||
def init_database():
|
||||
"""初始化数据库"""
|
||||
print("开始初始化数据库...")
|
||||
|
||||
# 创建同步引擎(用于表创建)
|
||||
engine = create_engine(settings.SYNC_DATABASE_URL, echo=True)
|
||||
|
||||
# 创建所有表
|
||||
print("创建数据库表...")
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
print("✓ 数据库表创建完成")
|
||||
print("\n请执行 SQL 脚本插入初始数据:")
|
||||
print(f" mysql -h{settings.DB_HOST} -u{settings.DB_USER} -p{settings.DB_PASSWORD} {settings.DB_NAME} < scripts/init_database.sql")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_database()
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
"""
|
||||
使用 Python 执行数据库初始化脚本
|
||||
"""
|
||||
import pymysql
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 数据库配置
|
||||
DB_CONFIG = {
|
||||
'host': '10.100.51.51',
|
||||
'port': 3306,
|
||||
'user': 'root',
|
||||
'password': 'Unis@123',
|
||||
'charset': 'utf8mb4',
|
||||
}
|
||||
|
||||
def execute_sql_file(sql_file_path):
|
||||
"""执行 SQL 文件"""
|
||||
try:
|
||||
# 读取 SQL 文件
|
||||
with open(sql_file_path, 'r', encoding='utf-8') as f:
|
||||
sql_content = f.read()
|
||||
|
||||
# 连接数据库
|
||||
print("正在连接数据库...")
|
||||
connection = pymysql.connect(**DB_CONFIG)
|
||||
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
# 分割SQL语句(简单分割,按分号)
|
||||
statements = []
|
||||
current_statement = []
|
||||
|
||||
for line in sql_content.split('\n'):
|
||||
# 跳过注释
|
||||
line = line.strip()
|
||||
if line.startswith('--') or not line:
|
||||
continue
|
||||
|
||||
current_statement.append(line)
|
||||
|
||||
# 如果行以分号结尾,表示一条完整的语句
|
||||
if line.endswith(';'):
|
||||
statement = ' '.join(current_statement)
|
||||
if statement.strip():
|
||||
statements.append(statement)
|
||||
current_statement = []
|
||||
|
||||
# 执行所有语句
|
||||
print(f"共 {len(statements)} 条SQL语句...")
|
||||
for i, statement in enumerate(statements, 1):
|
||||
try:
|
||||
cursor.execute(statement)
|
||||
# 如果是SELECT语句,获取结果
|
||||
if statement.strip().upper().startswith('SELECT'):
|
||||
result = cursor.fetchall()
|
||||
if result:
|
||||
print(f" [{i}] {result}")
|
||||
else:
|
||||
print(f" [{i}] 执行成功")
|
||||
except Exception as e:
|
||||
print(f" [{i}] 执行失败: {e}")
|
||||
print(f" SQL: {statement[:100]}...")
|
||||
|
||||
# 提交事务
|
||||
connection.commit()
|
||||
print("\n✅ 数据库初始化完成!")
|
||||
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"❌ SQL 文件不存在: {sql_file_path}")
|
||||
sys.exit(1)
|
||||
except pymysql.Error as e:
|
||||
print(f"❌ 数据库错误: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"❌ 未知错误: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
script_dir = Path(__file__).parent
|
||||
sql_file = script_dir / "init_database.sql"
|
||||
|
||||
print("=" * 60)
|
||||
print("NEX Docus 数据库初始化")
|
||||
print("=" * 60)
|
||||
print(f"SQL 文件: {sql_file}")
|
||||
print(f"数据库地址: {DB_CONFIG['host']}:{DB_CONFIG['port']}")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
execute_sql_file(sql_file)
|
||||
|
|
@ -0,0 +1,370 @@
|
|||
# 文档目录 Docker 部署方案
|
||||
|
||||
## 问题描述
|
||||
|
||||
应用中 `/design` 路由通过 `fetch('/docs/...')` 加载项目根目录下 `docs/` 文件夹中的 Markdown 文档。在 Docker 容器中运行时,需要确保这些文档能够被访问。
|
||||
|
||||
## 解决方案:Docker 卷挂载
|
||||
|
||||
采用 Docker 卷挂载方式,将宿主机的 `docs/` 目录直接映射到容器内,实现文档的实时更新和灵活管理。
|
||||
|
||||
### 配置方式
|
||||
|
||||
#### docker-compose.yml 配置
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
nex-design:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- ./logs:/app/logs # 日志目录挂载
|
||||
- ./docs:/app/dist/docs:ro # 文档目录挂载(只读)
|
||||
```
|
||||
|
||||
**说明:**
|
||||
- `./docs:/app/dist/docs` - 将宿主机当前目录的 `docs` 映射到容器的 `/app/dist/docs`
|
||||
- `:ro` - 只读挂载,提高安全性,防止容器内进程修改文档
|
||||
|
||||
### 方案优势
|
||||
|
||||
✅ **实时更新**
|
||||
- 修改 MD 文件后立即生效
|
||||
- 无需重新构建 Docker 镜像
|
||||
- 无需重启容器
|
||||
|
||||
✅ **方便维护**
|
||||
- 在宿主机直接编辑文档
|
||||
- 使用熟悉的编辑器和工具
|
||||
- 支持版本控制
|
||||
|
||||
✅ **轻量镜像**
|
||||
- Docker 镜像不包含文档内容
|
||||
- 镜像体积更小
|
||||
- 构建速度更快
|
||||
|
||||
✅ **灵活部署**
|
||||
- 可以独立管理文档版本
|
||||
- 支持多环境部署(开发、测试、生产使用不同文档)
|
||||
- 易于更新和回滚
|
||||
|
||||
### 目录结构
|
||||
|
||||
**宿主机:**
|
||||
```
|
||||
nex-design/
|
||||
├── docs/ # 文档源文件(Git 管理)
|
||||
│ ├── DESIGN_COOKBOOK.md
|
||||
│ ├── components/
|
||||
│ │ ├── PageTitleBar.md
|
||||
│ │ ├── ListTable.md
|
||||
│ │ └── ...
|
||||
│ └── pages/
|
||||
├── dist/ # 构建产物(容器内)
|
||||
│ ├── index.html
|
||||
│ └── assets/
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
**容器内:**
|
||||
```
|
||||
/app/
|
||||
├── dist/ # 应用构建产物
|
||||
│ ├── index.html
|
||||
│ ├── assets/
|
||||
│ └── docs/ # 挂载点 → 宿主机 docs/
|
||||
├── logs/ # 日志目录(挂载)
|
||||
└── ecosystem.config.js # PM2 配置
|
||||
```
|
||||
|
||||
### 使用流程
|
||||
|
||||
#### 1. 启动服务
|
||||
|
||||
```bash
|
||||
# 第一次启动(构建镜像)
|
||||
docker-compose up -d --build
|
||||
|
||||
# 后续启动(使用已有镜像)
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
#### 2. 修改文档
|
||||
|
||||
```bash
|
||||
# 在宿主机直接编辑文档
|
||||
vim docs/DESIGN_COOKBOOK.md
|
||||
|
||||
# 或使用 VS Code 等编辑器
|
||||
code docs/components/PageTitleBar.md
|
||||
```
|
||||
|
||||
#### 3. 验证更新
|
||||
|
||||
```bash
|
||||
# 文档修改后立即生效,无需任何操作
|
||||
# 浏览器刷新即可看到最新内容
|
||||
|
||||
# 或使用 curl 验证
|
||||
curl http://localhost:3000/docs/DESIGN_COOKBOOK.md
|
||||
```
|
||||
|
||||
### 验证挂载
|
||||
|
||||
```bash
|
||||
# 检查容器内的挂载情况
|
||||
docker exec nex-design-app ls -la /app/dist/docs/
|
||||
|
||||
# 查看某个文档内容
|
||||
docker exec nex-design-app cat /app/dist/docs/DESIGN_COOKBOOK.md
|
||||
|
||||
# 验证文件同步
|
||||
# 在宿主机修改文件
|
||||
echo "# Test" >> docs/test.md
|
||||
|
||||
# 立即在容器内查看
|
||||
docker exec nex-design-app cat /app/dist/docs/test.md
|
||||
```
|
||||
|
||||
### 部署注意事项
|
||||
|
||||
#### 1. 生产环境部署
|
||||
|
||||
**方式一:携带 docs 目录**
|
||||
```bash
|
||||
# 使用 git clone 或 scp 上传整个项目
|
||||
git clone <repo> /path/to/deploy
|
||||
cd /path/to/deploy
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
**方式二:单独管理文档**
|
||||
```bash
|
||||
# 文档单独部署在某个目录
|
||||
mkdir -p /var/www/nex-design-docs
|
||||
# 上传文档到该目录
|
||||
|
||||
# 修改 docker-compose.yml
|
||||
volumes:
|
||||
- /var/www/nex-design-docs:/app/dist/docs:ro
|
||||
```
|
||||
|
||||
#### 2. 权限管理
|
||||
|
||||
```bash
|
||||
# 确保 docs 目录有正确的权限
|
||||
chmod -R 755 docs/
|
||||
|
||||
# 只读挂载可防止容器内修改,但宿主机权限仍需控制
|
||||
```
|
||||
|
||||
#### 3. 多环境配置
|
||||
|
||||
可以为不同环境创建不同的 docker-compose 文件:
|
||||
|
||||
```bash
|
||||
# docker-compose.dev.yml - 开发环境
|
||||
volumes:
|
||||
- ./docs:/app/dist/docs:ro
|
||||
|
||||
# docker-compose.prod.yml - 生产环境
|
||||
volumes:
|
||||
- /var/www/docs:/app/dist/docs:ro
|
||||
```
|
||||
|
||||
使用时指定配置文件:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 故障排查
|
||||
|
||||
#### 问题 1:文档无法加载
|
||||
|
||||
```bash
|
||||
# 检查挂载是否成功
|
||||
docker inspect nex-design-app | grep -A 10 Mounts
|
||||
|
||||
# 检查容器内文件
|
||||
docker exec nex-design-app ls -la /app/dist/docs/
|
||||
|
||||
# 检查文件权限
|
||||
ls -la docs/
|
||||
```
|
||||
|
||||
#### 问题 2:修改后未生效
|
||||
|
||||
```bash
|
||||
# 确认使用的是卷挂载而不是 COPY
|
||||
docker exec nex-design-app cat /app/dist/docs/DESIGN_COOKBOOK.md
|
||||
|
||||
# 检查浏览器缓存
|
||||
# 使用 Ctrl+Shift+R 强制刷新
|
||||
|
||||
# 检查 serve 是否缓存了静态文件
|
||||
docker-compose restart
|
||||
```
|
||||
|
||||
#### 问题 3:Windows 路径问题
|
||||
|
||||
Windows 下需要注意路径格式:
|
||||
```yaml
|
||||
# 错误
|
||||
volumes:
|
||||
- .\docs:/app/dist/docs:ro
|
||||
|
||||
# 正确
|
||||
volumes:
|
||||
- ./docs:/app/dist/docs:ro
|
||||
```
|
||||
|
||||
### 性能考虑
|
||||
|
||||
#### 1. 卷挂载性能
|
||||
|
||||
- **Linux/macOS**: 性能很好,几乎无损耗
|
||||
- **Windows/macOS + Docker Desktop**: 可能有轻微性能损耗
|
||||
- **生产环境**: 使用 Linux 主机,性能最佳
|
||||
|
||||
#### 2. 优化建议
|
||||
|
||||
如果文档很多且访问频繁,可考虑:
|
||||
|
||||
1. **使用命名卷**:
|
||||
```yaml
|
||||
volumes:
|
||||
docs-data:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /path/to/docs
|
||||
|
||||
services:
|
||||
nex-design:
|
||||
volumes:
|
||||
- docs-data:/app/dist/docs:ro
|
||||
```
|
||||
|
||||
2. **缓存策略**:
|
||||
在 Nginx 反向代理中添加缓存:
|
||||
```nginx
|
||||
location /docs/ {
|
||||
proxy_pass http://nex_design;
|
||||
proxy_cache_valid 200 10m;
|
||||
add_header X-Cache-Status $upstream_cache_status;
|
||||
}
|
||||
```
|
||||
|
||||
## 替代方案对比
|
||||
|
||||
### 方案 A: 构建时复制(未采用)
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
COPY docs /app/dist/docs
|
||||
```
|
||||
|
||||
❌ 缺点:
|
||||
- 修改文档需要重新构建镜像
|
||||
- 镜像体积更大
|
||||
- 更新流程复杂
|
||||
|
||||
✅ 优点:
|
||||
- 镜像自包含
|
||||
- 适合不常修改的场景
|
||||
|
||||
### 方案 B: prebuild 脚本(未采用)
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"prebuild": "cp -r docs public/"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
❌ 缺点:
|
||||
- 需要重新构建才能更新
|
||||
- 增加构建时间
|
||||
- 文档和代码耦合
|
||||
|
||||
### 方案 C: 卷挂载(✅ 当前采用)
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./docs:/app/dist/docs:ro
|
||||
```
|
||||
|
||||
✅ 优点:
|
||||
- 实时更新
|
||||
- 灵活管理
|
||||
- 镜像轻量
|
||||
|
||||
⚠️ 注意:
|
||||
- 需要保持 docs 目录结构
|
||||
- 部署时需要文档文件
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 文档版本管理
|
||||
|
||||
```bash
|
||||
# 使用 Git 管理文档版本
|
||||
cd docs
|
||||
git log DESIGN_COOKBOOK.md
|
||||
|
||||
# 回滚到特定版本
|
||||
git checkout <commit-hash> DESIGN_COOKBOOK.md
|
||||
```
|
||||
|
||||
### 2. 文档自动化部署
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# scripts/update-docs.sh
|
||||
|
||||
echo "更新文档..."
|
||||
cd /path/to/nex-design
|
||||
|
||||
# 拉取最新文档
|
||||
git pull origin main -- docs/
|
||||
|
||||
# 无需重启容器,文档即时生效
|
||||
echo "文档已更新!"
|
||||
```
|
||||
|
||||
### 3. 监控文档访问
|
||||
|
||||
可以在 Nginx 中记录文档访问日志:
|
||||
```nginx
|
||||
location /docs/ {
|
||||
access_log /var/log/nginx/docs-access.log;
|
||||
proxy_pass http://nex_design;
|
||||
}
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
使用 Docker 卷挂载方案是最灵活、最适合本项目的解决方案:
|
||||
|
||||
✅ **实时更新** - 修改即生效
|
||||
✅ **简单维护** - 直接编辑文件
|
||||
✅ **轻量镜像** - 更快的构建和部署
|
||||
✅ **灵活部署** - 支持多种场景
|
||||
|
||||
**核心配置只需一行:**
|
||||
```yaml
|
||||
volumes:
|
||||
- ./docs:/app/dist/docs:ro
|
||||
```
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `docker-compose.yml:16` - 卷挂载配置
|
||||
- `DEPLOYMENT.md:14-35` - 部署文档说明
|
||||
- `QUICKSTART.md` - 快速参考
|
||||
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# 文档迁移说明
|
||||
|
||||
## 📦 按钮扩展组件文档已迁移
|
||||
|
||||
原 `docs/` 目录下的按钮扩展相关文档已整合并迁移至:
|
||||
|
||||
**新位置:** `src/components/docs/ButtonExtensions.md`
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ 迁移的文档
|
||||
|
||||
以下文档已整合到新文档中:
|
||||
|
||||
| 原文档 | 状态 |
|
||||
|--------|------|
|
||||
| ~~ButtonHelpDesign.md~~ | ✅ 已整合 |
|
||||
| ~~ButtonDesignFixes.md~~ | ✅ 已整合 |
|
||||
| ~~ButtonDesignUpdate.md~~ | ✅ 已整合 |
|
||||
| ~~ButtonDesignLatestFixes.md~~ | ✅ 已整合 |
|
||||
| ~~ActionHelpPanelFix.md~~ | ✅ 已整合 |
|
||||
|
||||
---
|
||||
|
||||
## 📖 新文档包含
|
||||
|
||||
- ✅ 5种设计方案完整说明
|
||||
- ✅ 所有组件的API文档
|
||||
- ✅ 详细的使用指南和示例
|
||||
- ✅ 最佳实践和性能优化建议
|
||||
- ✅ 完整的更新日志和变更记录
|
||||
|
||||
---
|
||||
|
||||
## 🔗 快速链接
|
||||
|
||||
- **文档路径:** `/src/components/docs/ButtonExtensions.md`
|
||||
- **在线演示:** http://localhost:5173/design/button-designs
|
||||
- **菜单路径:** 组件设计 → 扩展按钮
|
||||
|
||||
---
|
||||
|
||||
## 📅 迁移时间
|
||||
|
||||
**2025-11-17** - 所有文档已完成整合
|
||||
|
||||
---
|
||||
|
||||
## 💡 文档组织原则
|
||||
|
||||
今后组件文档将遵循以下原则:
|
||||
|
||||
1. **统一位置** - 所有组件文档放在 `src/components/docs/`
|
||||
2. **就近原则** - 文档与组件代码保持近距离
|
||||
3. **单一文档** - 同一功能的文档整合到一个文件
|
||||
4. **版本控制** - 使用版本号和更新日志记录变更
|
||||
|
||||
这样可以:
|
||||
- ✅ 更容易找到和维护文档
|
||||
- ✅ 避免文档分散和重复
|
||||
- ✅ 与代码保持同步更新
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
*.lcov
|
||||
.nyc_output
|
||||
|
||||
# Build
|
||||
build
|
||||
.cache
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
*~
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
# NEX Docus Frontend
|
||||
|
||||
NEX Docus 前端项目 - 基于 React + Vite + Ant Design 构建。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**: React 18+
|
||||
- **构建工具**: Vite 5+
|
||||
- **UI 组件**: Ant Design 5+
|
||||
- **路由**: React Router v6
|
||||
- **HTTP 客户端**: Axios
|
||||
- **状态管理**: Zustand
|
||||
- **Markdown 编辑器**: @uiw/react-md-editor
|
||||
- **样式**: Tailwind CSS + CSS Modules
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
forntend/
|
||||
├── public/ # 静态资源
|
||||
├── src/
|
||||
│ ├── api/ # API 请求封装
|
||||
│ │ ├── auth.js # 用户认证
|
||||
│ │ ├── project.js # 项目管理
|
||||
│ │ └── file.js # 文件系统
|
||||
│ ├── components/ # 通用组件
|
||||
│ │ ├── MainLayout/ # 主布局
|
||||
│ │ └── ProtectedRoute.jsx # 路由守卫
|
||||
│ ├── pages/ # 页面组件
|
||||
│ │ ├── Login/ # 登录页
|
||||
│ │ ├── ProjectList/ # 项目列表页
|
||||
│ │ └── DocumentEditor/ # 文档编辑页
|
||||
│ ├── stores/ # 状态管理
|
||||
│ │ └── userStore.js # 用户状态
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ └── request.js # HTTP 请求封装
|
||||
│ ├── App.jsx # 应用入口
|
||||
│ ├── main.jsx # 渲染入口
|
||||
│ └── index.css # 全局样式
|
||||
├── index.html # HTML 模板
|
||||
├── package.json # 项目配置
|
||||
├── vite.config.js # Vite 配置
|
||||
└── tailwind.config.js # Tailwind 配置
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
# 或
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
编辑 `.env` 文件,配置后端 API 地址:
|
||||
|
||||
```env
|
||||
VITE_API_BASE_URL=http://localhost:8000/api/v1
|
||||
```
|
||||
|
||||
### 3. 启动开发服务器
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
访问:http://localhost:5173
|
||||
|
||||
### 4. 构建生产版本
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建产物在 `dist/` 目录。
|
||||
|
||||
## 主要功能
|
||||
|
||||
### 用户认证
|
||||
- 用户注册
|
||||
- 用户登录
|
||||
- 自动Token刷新
|
||||
- 退出登录
|
||||
|
||||
### 项目管理
|
||||
- 创建项目
|
||||
- 项目列表
|
||||
- 项目详情
|
||||
- 删除项目(归档)
|
||||
|
||||
### 文档编辑
|
||||
- 目录树浏览
|
||||
- Markdown 实时预览
|
||||
- 文件保存
|
||||
- 创建文件/文件夹
|
||||
- 删除文件
|
||||
- 图片上传
|
||||
|
||||
## 默认账号
|
||||
|
||||
- 用户名: `admin`
|
||||
- 密码: `admin123`
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 添加新页面
|
||||
|
||||
1. 在 `src/pages/` 下创建页面组件
|
||||
2. 在 `src/App.jsx` 中添加路由
|
||||
3. 如果需要认证,使用 `<ProtectedRoute>` 包裹
|
||||
|
||||
### 添加新 API
|
||||
|
||||
1. 在 `src/api/` 下创建对应的 API 文件
|
||||
2. 使用 `request` 工具发起请求
|
||||
3. 在组件中调用 API
|
||||
|
||||
### 状态管理
|
||||
|
||||
使用 Zustand 进行状态管理,参考 `src/stores/userStore.js`
|
||||
|
||||
## 许可证
|
||||
|
||||
Copyright © 2023 Mula.liu
|
||||
|
|
@ -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 Docus - 文档管理平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"name": "nex-docus-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@uiw/react-md-editor": "^4.0.4",
|
||||
"antd": "^5.12.0",
|
||||
"axios": "^1.6.2",
|
||||
"dayjs": "^1.11.10",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { ConfigProvider } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import Login from '@/pages/Login/Login'
|
||||
import ProjectList from '@/pages/ProjectList/ProjectList'
|
||||
import DocumentPage from '@/pages/Document/DocumentPage'
|
||||
import DocumentEditor from '@/pages/Document/DocumentEditor'
|
||||
import Dashboard from '@/pages/Dashboard'
|
||||
import Desktop from '@/pages/Desktop'
|
||||
import Constructing from '@/pages/Constructing'
|
||||
import PreviewPage from '@/pages/Preview/PreviewPage'
|
||||
import ProfilePage from '@/pages/Profile/ProfilePage'
|
||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||
import '@/App.css'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
{/* 项目预览(公开访问,无需登录) */}
|
||||
<Route path="/preview/:projectId" element={<PreviewPage />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/desktop"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Desktop />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/projects"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProjectList />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* 文档阅读模式 */}
|
||||
<Route
|
||||
path="/projects/:projectId/docs"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DocumentPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* 文档编辑模式 */}
|
||||
<Route
|
||||
path="/projects/:projectId/editor"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DocumentEditor />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* 功能开发中页面 */}
|
||||
<Route
|
||||
path="/constructing"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Constructing />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* 个人中心 */}
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProfilePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to="/projects" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* 用户认证相关 API
|
||||
*/
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
export function register(data) {
|
||||
return request({
|
||||
url: '/auth/register',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
export function login(data) {
|
||||
return request({
|
||||
url: '/auth/login',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
*/
|
||||
export function getCurrentUser() {
|
||||
return request({
|
||||
url: '/auth/me',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户资料
|
||||
*/
|
||||
export function updateProfile(data) {
|
||||
return request({
|
||||
url: '/auth/profile',
|
||||
method: 'put',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
*/
|
||||
export function changePassword(data) {
|
||||
return request({
|
||||
url: '/auth/change-password',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* 管理员仪表盘相关 API
|
||||
*/
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 获取管理员仪表盘统计数据
|
||||
*/
|
||||
export function getDashboardStats() {
|
||||
return request({
|
||||
url: '/dashboard/stats',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取个人桌面统计数据
|
||||
*/
|
||||
export function getPersonalStats() {
|
||||
return request({
|
||||
url: '/dashboard/personal-stats',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* 文件系统相关 API
|
||||
*/
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 获取项目目录树
|
||||
*/
|
||||
export function getProjectTree(projectId) {
|
||||
return request({
|
||||
url: `/files/${projectId}/tree`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件内容
|
||||
*/
|
||||
export function getFileContent(projectId, path) {
|
||||
return request({
|
||||
url: `/files/${projectId}/file`,
|
||||
method: 'get',
|
||||
params: { path },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存文件内容
|
||||
*/
|
||||
export function saveFile(projectId, data) {
|
||||
return request({
|
||||
url: `/files/${projectId}/file`,
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件操作(重命名/删除/创建)
|
||||
*/
|
||||
export function operateFile(projectId, data) {
|
||||
return request({
|
||||
url: `/files/${projectId}/file/operate`,
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
export function uploadFile(projectId, file, subfolder = 'images') {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return request({
|
||||
url: `/files/${projectId}/upload?subfolder=${subfolder}`,
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源文件 URL(公开访问,支持分享)
|
||||
*/
|
||||
export function getAssetUrl(projectId, subfolder, filename) {
|
||||
return `/api/v1/files/${projectId}/assets/${subfolder}/${filename}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入Markdown文档
|
||||
*/
|
||||
export function importDocuments(projectId, files, targetPath = '') {
|
||||
const formData = new FormData()
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
return request({
|
||||
url: `/files/${projectId}/import-documents?target_path=${targetPath}`,
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出目录为ZIP
|
||||
*/
|
||||
export function exportDirectory(projectId, directoryPath = '') {
|
||||
return request({
|
||||
url: `/files/${projectId}/export-directory`,
|
||||
method: 'get',
|
||||
params: { directory_path: directoryPath },
|
||||
responseType: 'blob',
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* 权限菜单相关 API
|
||||
*/
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 获取当前用户的权限菜单
|
||||
*/
|
||||
export function getUserMenus() {
|
||||
return request({
|
||||
url: '/menu/user-menus',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的权限列表
|
||||
*/
|
||||
export function getUserPermissions() {
|
||||
return request({
|
||||
url: '/menu/user-permissions',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* 项目管理相关 API
|
||||
*/
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 获取我的项目列表
|
||||
*/
|
||||
export function getMyProjects() {
|
||||
return request({
|
||||
url: '/projects/',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建项目
|
||||
*/
|
||||
export function createProject(data) {
|
||||
return request({
|
||||
url: '/projects/',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目详情
|
||||
*/
|
||||
export function getProject(projectId) {
|
||||
return request({
|
||||
url: `/projects/${projectId}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新项目信息
|
||||
*/
|
||||
export function updateProject(projectId, data) {
|
||||
return request({
|
||||
url: `/projects/${projectId}`,
|
||||
method: 'put',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除项目
|
||||
*/
|
||||
export function deleteProject(projectId) {
|
||||
return request({
|
||||
url: `/projects/${projectId}`,
|
||||
method: 'delete',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目成员
|
||||
*/
|
||||
export function getProjectMembers(projectId) {
|
||||
return request({
|
||||
url: `/projects/${projectId}/members`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加项目成员
|
||||
*/
|
||||
export function addProjectMember(projectId, data) {
|
||||
return request({
|
||||
url: `/projects/${projectId}/members`,
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* 项目分享和预览相关 API
|
||||
*/
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 获取项目分享信息
|
||||
*/
|
||||
export function getProjectShareInfo(projectId) {
|
||||
return request({
|
||||
url: `/projects/${projectId}/share`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新分享设置(设置或取消访问密码)
|
||||
*/
|
||||
export function updateShareSettings(projectId, data) {
|
||||
return request({
|
||||
url: `/projects/${projectId}/share/settings`,
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览项目基本信息(公开访问)
|
||||
*/
|
||||
export function getPreviewInfo(projectId) {
|
||||
return request({
|
||||
url: `/preview/${projectId}/info`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证访问密码
|
||||
*/
|
||||
export function verifyAccessPassword(projectId, password) {
|
||||
return request({
|
||||
url: `/preview/${projectId}/verify`,
|
||||
method: 'post',
|
||||
headers: {
|
||||
'X-Access-Password': password,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览项目的文档树
|
||||
*/
|
||||
export function getPreviewTree(projectId, password = null) {
|
||||
return request({
|
||||
url: `/preview/${projectId}/tree`,
|
||||
method: 'get',
|
||||
headers: password ? { 'X-Access-Password': password } : {},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览项目的文件内容
|
||||
*/
|
||||
export function getPreviewFile(projectId, path, password = null) {
|
||||
return request({
|
||||
url: `/preview/${projectId}/file`,
|
||||
method: 'get',
|
||||
params: { path },
|
||||
headers: password ? { 'X-Access-Password': password } : {},
|
||||
})
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
|
|
@ -0,0 +1,259 @@
|
|||
/* 帮助面板样式 */
|
||||
.action-help-panel .ant-drawer-header {
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.help-panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.help-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.help-panel-header-text {
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
/* 操作详情样式 */
|
||||
.help-action-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.help-action-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.help-action-icon {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.help-action-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.help-action-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.help-action-badge {
|
||||
align-self: flex-start;
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 帮助区块样式 */
|
||||
.help-section {
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #1677ff;
|
||||
}
|
||||
|
||||
.help-section-warning {
|
||||
background: #fff7e6;
|
||||
border-left-color: #faad14;
|
||||
}
|
||||
|
||||
.help-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.help-section-content {
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.help-section-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.help-section-list li {
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.help-section-list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.help-section-steps {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
counter-reset: step-counter;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.help-section-steps li {
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
margin-bottom: 12px;
|
||||
padding-left: 12px;
|
||||
position: relative;
|
||||
counter-increment: step-counter;
|
||||
}
|
||||
|
||||
.help-section-steps li:before {
|
||||
content: counter(step-counter);
|
||||
position: absolute;
|
||||
left: -20px;
|
||||
top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #1677ff;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.help-section-steps li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.help-shortcut {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.help-shortcut kbd {
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
|
||||
font-size: 12px;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 操作列表样式 */
|
||||
.help-actions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.help-action-item {
|
||||
padding: 12px;
|
||||
background: white;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.help-action-item:hover {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.help-action-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.help-action-item-icon {
|
||||
font-size: 16px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.help-action-item-title {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.help-action-item-shortcut {
|
||||
padding: 2px 6px;
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.help-action-item-desc {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
/* 折叠面板自定义样式 */
|
||||
.action-help-panel .ant-collapse-ghost > .ant-collapse-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.action-help-panel .ant-collapse-ghost > .ant-collapse-item > .ant-collapse-header {
|
||||
padding: 12px 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-help-panel .ant-collapse-ghost > .ant-collapse-item > .ant-collapse-content {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.action-help-panel .ant-drawer-content-wrapper {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.help-action-header {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.help-section {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Drawer, Collapse, Badge, Tag, Empty } from 'antd'
|
||||
import {
|
||||
QuestionCircleOutlined,
|
||||
BulbOutlined,
|
||||
WarningOutlined,
|
||||
InfoCircleOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import './ActionHelpPanel.css'
|
||||
|
||||
const { Panel } = Collapse
|
||||
|
||||
/**
|
||||
* 操作帮助面板组件
|
||||
* 在页面侧边显示当前操作的详细说明和帮助信息
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.visible - 是否显示面板
|
||||
* @param {Function} props.onClose - 关闭回调
|
||||
* @param {Object} props.currentAction - 当前操作信息
|
||||
* @param {Array} props.allActions - 所有可用操作列表
|
||||
* @param {string} props.placement - 面板位置
|
||||
* @param {Function} props.onActionSelect - 选择操作的回调
|
||||
*/
|
||||
function ActionHelpPanel({
|
||||
visible = false,
|
||||
onClose,
|
||||
currentAction = null,
|
||||
allActions = [],
|
||||
placement = 'right',
|
||||
onActionSelect,
|
||||
}) {
|
||||
const [activeKey, setActiveKey] = useState(['current'])
|
||||
|
||||
// 当 currentAction 变化时,自动展开"当前操作"面板
|
||||
useEffect(() => {
|
||||
if (currentAction && visible) {
|
||||
setActiveKey(['current'])
|
||||
}
|
||||
}, [currentAction, visible])
|
||||
|
||||
// 渲染当前操作详情
|
||||
const renderCurrentAction = () => {
|
||||
if (!currentAction) {
|
||||
return (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="将鼠标悬停在按钮上查看帮助"
|
||||
style={{ padding: '40px 0' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="help-action-detail">
|
||||
{/* 操作标题 */}
|
||||
<div className="help-action-header">
|
||||
<div className="help-action-icon">{currentAction.icon}</div>
|
||||
<div className="help-action-info">
|
||||
<h3 className="help-action-title">{currentAction.title}</h3>
|
||||
{currentAction.badge && (
|
||||
<Tag color={currentAction.badge.color} className="help-action-badge">
|
||||
{currentAction.badge.text}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作描述 */}
|
||||
{currentAction.description && (
|
||||
<div className="help-section">
|
||||
<div className="help-section-title">
|
||||
<InfoCircleOutlined /> 功能说明
|
||||
</div>
|
||||
<div className="help-section-content">{currentAction.description}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 使用场景 */}
|
||||
{currentAction.scenarios && currentAction.scenarios.length > 0 && (
|
||||
<div className="help-section">
|
||||
<div className="help-section-title">
|
||||
<BulbOutlined /> 使用场景
|
||||
</div>
|
||||
<ul className="help-section-list">
|
||||
{currentAction.scenarios.map((scenario, index) => (
|
||||
<li key={index}>{scenario}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作步骤 */}
|
||||
{currentAction.steps && currentAction.steps.length > 0 && (
|
||||
<div className="help-section">
|
||||
<div className="help-section-title">
|
||||
<ThunderboltOutlined /> 操作步骤
|
||||
</div>
|
||||
<ol className="help-section-steps">
|
||||
{currentAction.steps.map((step, index) => (
|
||||
<li key={index}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 注意事项 */}
|
||||
{currentAction.warnings && currentAction.warnings.length > 0 && (
|
||||
<div className="help-section help-section-warning">
|
||||
<div className="help-section-title">
|
||||
<WarningOutlined /> 注意事项
|
||||
</div>
|
||||
<ul className="help-section-list">
|
||||
{currentAction.warnings.map((warning, index) => (
|
||||
<li key={index}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 快捷键 */}
|
||||
{currentAction.shortcut && (
|
||||
<div className="help-section">
|
||||
<div className="help-section-title">⌨️ 快捷键</div>
|
||||
<div className="help-shortcut">
|
||||
<kbd>{currentAction.shortcut}</kbd>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 权限要求 */}
|
||||
{currentAction.permission && (
|
||||
<div className="help-section">
|
||||
<div className="help-section-title">🔐 权限要求</div>
|
||||
<div className="help-section-content">
|
||||
<Tag color="blue">{currentAction.permission}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染所有操作列表
|
||||
const renderAllActions = () => {
|
||||
if (allActions.length === 0) {
|
||||
return <Empty description="暂无操作" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="help-actions-list">
|
||||
{allActions.map((action, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="help-action-item"
|
||||
onClick={() => {
|
||||
if (onActionSelect) {
|
||||
onActionSelect(action)
|
||||
setActiveKey(['current'])
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="help-action-item-header">
|
||||
<span className="help-action-item-icon">{action.icon}</span>
|
||||
<span className="help-action-item-title">{action.title}</span>
|
||||
{action.shortcut && (
|
||||
<kbd className="help-action-item-shortcut">{action.shortcut}</kbd>
|
||||
)}
|
||||
</div>
|
||||
<div className="help-action-item-desc">{action.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<div className="help-panel-title">
|
||||
<QuestionCircleOutlined style={{ marginRight: 8 }} />
|
||||
操作帮助
|
||||
{currentAction && <Badge status="processing" text="实时帮助" />}
|
||||
</div>
|
||||
}
|
||||
placement={placement}
|
||||
width={420}
|
||||
open={visible}
|
||||
onClose={onClose}
|
||||
className="action-help-panel"
|
||||
>
|
||||
<Collapse
|
||||
activeKey={activeKey}
|
||||
onChange={setActiveKey}
|
||||
ghost
|
||||
expandIconPosition="end"
|
||||
>
|
||||
<Panel
|
||||
header={
|
||||
<div className="help-panel-header">
|
||||
<span className="help-panel-header-text">当前操作</span>
|
||||
{currentAction && (
|
||||
<Badge
|
||||
count="实时"
|
||||
style={{
|
||||
backgroundColor: '#52c41a',
|
||||
fontSize: 10,
|
||||
height: 18,
|
||||
lineHeight: '18px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
key="current"
|
||||
>
|
||||
{renderCurrentAction()}
|
||||
</Panel>
|
||||
|
||||
<Panel header="所有可用操作" key="all">
|
||||
{renderAllActions()}
|
||||
</Panel>
|
||||
</Collapse>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionHelpPanel
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
/* 底部提示栏基础样式 */
|
||||
.bottom-hint-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
padding: 12px 24px;
|
||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 主题样式 */
|
||||
.bottom-hint-bar-light {
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.bottom-hint-bar-dark {
|
||||
background: #001529;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.bottom-hint-bar-gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* 容器布局 */
|
||||
.hint-bar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 左侧区域 */
|
||||
.hint-bar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hint-bar-icon {
|
||||
font-size: 24px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.bottom-hint-bar-light .hint-bar-icon {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.hint-bar-title-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hint-bar-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.bottom-hint-bar-light .hint-bar-title {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.hint-bar-badge {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* 中间区域 */
|
||||
.hint-bar-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hint-bar-description,
|
||||
.hint-bar-quick-tip,
|
||||
.hint-bar-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.bottom-hint-bar-light .hint-bar-description,
|
||||
.bottom-hint-bar-light .hint-bar-quick-tip {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.hint-info-icon {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.bottom-hint-bar-light .hint-info-icon {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.hint-tip-icon {
|
||||
font-size: 14px;
|
||||
color: #fadb14;
|
||||
}
|
||||
|
||||
.hint-warning-icon {
|
||||
font-size: 14px;
|
||||
color: #ff7a45;
|
||||
}
|
||||
|
||||
.bottom-hint-bar-light .hint-bar-warning {
|
||||
color: #d46b08;
|
||||
}
|
||||
|
||||
/* 右侧区域 */
|
||||
.hint-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hint-bar-shortcut {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.shortcut-label {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.shortcut-kbd {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
color: inherit;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.bottom-hint-bar-light .shortcut-kbd {
|
||||
background: #f0f0f0;
|
||||
border-color: #d9d9d9;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.hint-bar-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.hint-bar-close:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.bottom-hint-bar-light .hint-bar-close {
|
||||
background: #f0f0f0;
|
||||
border-color: #d9d9d9;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.bottom-hint-bar-light .hint-bar-close:hover {
|
||||
background: #e0e0e0;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
/* 进度指示条 */
|
||||
.hint-bar-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hint-bar-progress::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
animation: progressWave 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.bottom-hint-bar-light .hint-bar-progress {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.bottom-hint-bar-light .hint-bar-progress::after {
|
||||
background: #1677ff;
|
||||
}
|
||||
|
||||
@keyframes progressWave {
|
||||
0%, 100% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 1024px) {
|
||||
.hint-bar-container {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hint-bar-center {
|
||||
flex-basis: 100%;
|
||||
order: 3;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hint-bar-description,
|
||||
.hint-bar-quick-tip,
|
||||
.hint-bar-warning {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bottom-hint-bar {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.hint-bar-left {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hint-bar-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.hint-bar-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.hint-bar-right {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.shortcut-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hint-bar-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.hint-bar-quick-tip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hint-bar-warning {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import { Tag } from 'antd'
|
||||
import {
|
||||
InfoCircleOutlined,
|
||||
BulbOutlined,
|
||||
WarningOutlined,
|
||||
CloseOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import './BottomHintBar.css'
|
||||
|
||||
/**
|
||||
* 底部固定提示栏组件
|
||||
* 在页面底部显示当前悬停按钮的实时说明
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.visible - 是否显示提示栏
|
||||
* @param {Object} props.hintInfo - 当前提示信息
|
||||
* @param {Function} props.onClose - 关闭回调
|
||||
* @param {string} props.theme - 主题:light, dark, gradient
|
||||
*/
|
||||
function BottomHintBar({ visible = false, hintInfo = null, onClose, theme = 'gradient' }) {
|
||||
if (!visible || !hintInfo) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bottom-hint-bar bottom-hint-bar-${theme}`}
|
||||
onMouseEnter={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="hint-bar-container">
|
||||
{/* 左侧:图标和标题 */}
|
||||
<div className="hint-bar-left">
|
||||
<div className="hint-bar-icon">{hintInfo.icon}</div>
|
||||
<div className="hint-bar-title-section">
|
||||
<h4 className="hint-bar-title">{hintInfo.title}</h4>
|
||||
{hintInfo.badge && (
|
||||
<Tag color={hintInfo.badge.color} className="hint-bar-badge">
|
||||
{hintInfo.badge.text}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间:主要信息 */}
|
||||
<div className="hint-bar-center">
|
||||
{/* 描述 */}
|
||||
{hintInfo.description && (
|
||||
<div className="hint-bar-description">
|
||||
<InfoCircleOutlined className="hint-info-icon" />
|
||||
<span>{hintInfo.description}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 快速提示 */}
|
||||
{hintInfo.quickTip && (
|
||||
<div className="hint-bar-quick-tip">
|
||||
<BulbOutlined className="hint-tip-icon" />
|
||||
<span>{hintInfo.quickTip}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 警告 */}
|
||||
{hintInfo.warning && (
|
||||
<div className="hint-bar-warning">
|
||||
<WarningOutlined className="hint-warning-icon" />
|
||||
<span>{hintInfo.warning}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:快捷键和关闭 */}
|
||||
<div className="hint-bar-right">
|
||||
{hintInfo.shortcut && (
|
||||
<div className="hint-bar-shortcut">
|
||||
<span className="shortcut-label">快捷键</span>
|
||||
<kbd className="shortcut-kbd">{hintInfo.shortcut}</kbd>
|
||||
</div>
|
||||
)}
|
||||
{onClose && (
|
||||
<button className="hint-bar-close" onClick={onClose}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度指示条 */}
|
||||
<div className="hint-bar-progress" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BottomHintBar
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
/* 按钮带引导 - 简洁现代设计 */
|
||||
.button-with-guide {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 帮助图标按钮 - 简洁扁平设计 */
|
||||
.guide-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: rgba(0, 0, 0, 0.35);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.guide-icon-btn:hover {
|
||||
background: rgba(22, 119, 255, 0.06);
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.guide-icon-btn:active {
|
||||
background: rgba(22, 119, 255, 0.12);
|
||||
}
|
||||
|
||||
/* 引导弹窗样式 */
|
||||
.button-guide-modal .ant-modal-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.button-guide-modal .ant-modal-body {
|
||||
padding: 24px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.guide-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.guide-modal-icon {
|
||||
font-size: 24px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.guide-modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.guide-modal-badge {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
/* 引导区块样式 */
|
||||
.guide-section {
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #1677ff;
|
||||
}
|
||||
|
||||
.guide-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.guide-section-warning {
|
||||
background: #fff7e6;
|
||||
border-left-color: #faad14;
|
||||
}
|
||||
|
||||
.guide-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.guide-section-icon {
|
||||
font-size: 16px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.guide-section-warning .guide-section-icon {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.guide-section-content {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.guide-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.guide-list li {
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.guide-list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 步骤样式 */
|
||||
.guide-steps {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.guide-steps .ant-steps-item-title {
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.guide-steps .ant-steps-item-description {
|
||||
font-size: 13px !important;
|
||||
line-height: 1.6 !important;
|
||||
color: rgba(0, 0, 0, 0.65) !important;
|
||||
}
|
||||
|
||||
/* 引导底部 */
|
||||
.guide-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.guide-footer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.guide-footer-label {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.guide-footer-kbd {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
|
||||
font-size: 11px;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.button-guide-modal {
|
||||
max-width: calc(100% - 32px);
|
||||
}
|
||||
|
||||
.button-guide-modal .ant-modal-body {
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.guide-footer {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
import { useState } from 'react'
|
||||
import { Button, Modal, Steps, Tag } from 'antd'
|
||||
import {
|
||||
QuestionCircleOutlined,
|
||||
BulbOutlined,
|
||||
WarningOutlined,
|
||||
CheckCircleOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import './ButtonWithGuide.css'
|
||||
|
||||
/**
|
||||
* 带引导的按钮组件 - 简洁现代设计
|
||||
* 在按钮旁边显示一个简洁的帮助图标,点击后显示详细引导
|
||||
*/
|
||||
function ButtonWithGuide({
|
||||
label,
|
||||
icon,
|
||||
type = 'default',
|
||||
danger = false,
|
||||
disabled = false,
|
||||
onClick,
|
||||
guide,
|
||||
size = 'middle',
|
||||
...restProps
|
||||
}) {
|
||||
const [showGuideModal, setShowGuideModal] = useState(false)
|
||||
|
||||
const handleGuideClick = (e) => {
|
||||
e.stopPropagation()
|
||||
if (guide) {
|
||||
setShowGuideModal(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="button-with-guide">
|
||||
<Button
|
||||
type={type}
|
||||
icon={icon}
|
||||
danger={danger}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size={size}
|
||||
{...restProps}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
{guide && !disabled && (
|
||||
<button className="guide-icon-btn" onClick={handleGuideClick} title="查看帮助">
|
||||
<QuestionCircleOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 引导弹窗 */}
|
||||
{guide && (
|
||||
<Modal
|
||||
title={
|
||||
<div className="guide-modal-header">
|
||||
<span className="guide-modal-icon">{guide.icon || icon}</span>
|
||||
<span className="guide-modal-title">{guide.title}</span>
|
||||
{guide.badge && (
|
||||
<Tag color={guide.badge.color} className="guide-modal-badge">
|
||||
{guide.badge.text}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
open={showGuideModal}
|
||||
onCancel={() => setShowGuideModal(false)}
|
||||
footer={[
|
||||
<Button key="close" type="primary" onClick={() => setShowGuideModal(false)}>
|
||||
知道了
|
||||
</Button>,
|
||||
]}
|
||||
width={600}
|
||||
className="button-guide-modal"
|
||||
>
|
||||
{/* 功能描述 */}
|
||||
{guide.description && (
|
||||
<div className="guide-section">
|
||||
<div className="guide-section-title">
|
||||
<InfoCircleOutlined className="guide-section-icon" />
|
||||
功能说明
|
||||
</div>
|
||||
<p className="guide-section-content">{guide.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 使用步骤 */}
|
||||
{guide.steps && guide.steps.length > 0 && (
|
||||
<div className="guide-section">
|
||||
<div className="guide-section-title">
|
||||
<CheckCircleOutlined className="guide-section-icon" />
|
||||
操作步骤
|
||||
</div>
|
||||
<Steps
|
||||
direction="vertical"
|
||||
current={-1}
|
||||
items={guide.steps.map((step, index) => ({
|
||||
title: `步骤 ${index + 1}`,
|
||||
description: step,
|
||||
status: 'wait',
|
||||
}))}
|
||||
className="guide-steps"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 使用场景 */}
|
||||
{guide.scenarios && guide.scenarios.length > 0 && (
|
||||
<div className="guide-section">
|
||||
<div className="guide-section-title">
|
||||
<BulbOutlined className="guide-section-icon" />
|
||||
适用场景
|
||||
</div>
|
||||
<ul className="guide-list">
|
||||
{guide.scenarios.map((scenario, index) => (
|
||||
<li key={index}>{scenario}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 注意事项 */}
|
||||
{guide.warnings && guide.warnings.length > 0 && (
|
||||
<div className="guide-section guide-section-warning">
|
||||
<div className="guide-section-title">
|
||||
<WarningOutlined className="guide-section-icon" />
|
||||
注意事项
|
||||
</div>
|
||||
<ul className="guide-list">
|
||||
{guide.warnings.map((warning, index) => (
|
||||
<li key={index}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 快捷键和权限 */}
|
||||
{(guide.shortcut || guide.permission) && (
|
||||
<div className="guide-footer">
|
||||
{guide.shortcut && (
|
||||
<div className="guide-footer-item">
|
||||
<span className="guide-footer-label">快捷键:</span>
|
||||
<kbd className="guide-footer-kbd">{guide.shortcut}</kbd>
|
||||
</div>
|
||||
)}
|
||||
{guide.permission && (
|
||||
<div className="guide-footer-item">
|
||||
<span className="guide-footer-label">权限要求:</span>
|
||||
<Tag color="blue">{guide.permission}</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ButtonWithGuide
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
.button-guide-badge-wrapper {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 引导徽章样式 - 改为放在右上角外部 */
|
||||
.button-guide-badge-wrapper .ant-badge {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.button-guide-badge-wrapper .ant-badge-count {
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 引导徽章样式 */
|
||||
.guide-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
background: #1677ff;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
animation: pulseBadge 2s ease-in-out infinite;
|
||||
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.4);
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.guide-badge:hover {
|
||||
animation: none;
|
||||
transform: scale(1.2);
|
||||
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.6);
|
||||
}
|
||||
|
||||
.guide-badge-new {
|
||||
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
|
||||
box-shadow: 0 2px 8px rgba(82, 196, 26, 0.4);
|
||||
}
|
||||
|
||||
.guide-badge-new:hover {
|
||||
box-shadow: 0 4px 12px rgba(82, 196, 26, 0.6);
|
||||
}
|
||||
|
||||
.guide-badge-help {
|
||||
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%);
|
||||
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.4);
|
||||
}
|
||||
|
||||
.guide-badge-help:hover {
|
||||
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.6);
|
||||
}
|
||||
|
||||
.guide-badge-warn {
|
||||
background: linear-gradient(135deg, #faad14 0%, #ffc53d 100%);
|
||||
box-shadow: 0 2px 8px rgba(250, 173, 20, 0.4);
|
||||
}
|
||||
|
||||
.guide-badge-warn:hover {
|
||||
box-shadow: 0 4px 12px rgba(250, 173, 20, 0.6);
|
||||
}
|
||||
|
||||
@keyframes pulseBadge {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/* 引导弹窗样式 */
|
||||
.button-guide-modal .ant-modal-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.button-guide-modal .ant-modal-body {
|
||||
padding: 24px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.guide-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.guide-modal-icon {
|
||||
font-size: 24px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.guide-modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.guide-modal-badge {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
/* 引导区块样式 */
|
||||
.guide-section {
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #1677ff;
|
||||
}
|
||||
|
||||
.guide-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.guide-section-warning {
|
||||
background: #fff7e6;
|
||||
border-left-color: #faad14;
|
||||
}
|
||||
|
||||
.guide-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.guide-section-icon {
|
||||
font-size: 16px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.guide-section-warning .guide-section-icon {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.guide-section-content {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.guide-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.guide-list li {
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.guide-list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 步骤样式 */
|
||||
.guide-steps {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.guide-steps .ant-steps-item-title {
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.guide-steps .ant-steps-item-description {
|
||||
font-size: 13px !important;
|
||||
line-height: 1.6 !important;
|
||||
color: rgba(0, 0, 0, 0.65) !important;
|
||||
}
|
||||
|
||||
/* 引导底部 */
|
||||
.guide-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.guide-footer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.guide-footer-label {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.guide-footer-kbd {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
|
||||
font-size: 11px;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.button-guide-modal {
|
||||
max-width: calc(100% - 32px);
|
||||
}
|
||||
|
||||
.button-guide-modal .ant-modal-body {
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.guide-footer {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
import { useState } from 'react'
|
||||
import { Button, Badge, Modal, Steps, Tag, Divider } from 'antd'
|
||||
import {
|
||||
QuestionCircleOutlined,
|
||||
BulbOutlined,
|
||||
WarningOutlined,
|
||||
CheckCircleOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import './ButtonWithGuideBadge.css'
|
||||
|
||||
/**
|
||||
* 智能引导徽章按钮组件
|
||||
* 为新功能或复杂按钮添加脉冲动画的徽章,点击后显示详细引导
|
||||
* @param {Object} props
|
||||
* @param {string} props.label - 按钮文本
|
||||
* @param {ReactNode} props.icon - 按钮图标
|
||||
* @param {string} props.type - 按钮类型
|
||||
* @param {boolean} props.danger - 危险按钮
|
||||
* @param {boolean} props.disabled - 禁用状态
|
||||
* @param {Function} props.onClick - 点击回调
|
||||
* @param {Object} props.guide - 引导配置
|
||||
* @param {boolean} props.showBadge - 是否显示徽章
|
||||
* @param {string} props.badgeType - 徽章类型:new, help, warn
|
||||
* @param {string} props.size - 按钮大小
|
||||
*/
|
||||
function ButtonWithGuideBadge({
|
||||
label,
|
||||
icon,
|
||||
type = 'default',
|
||||
danger = false,
|
||||
disabled = false,
|
||||
onClick,
|
||||
guide,
|
||||
showBadge = true,
|
||||
badgeType = 'help',
|
||||
size = 'middle',
|
||||
...restProps
|
||||
}) {
|
||||
const [showGuideModal, setShowGuideModal] = useState(false)
|
||||
|
||||
const handleBadgeClick = (e) => {
|
||||
e.stopPropagation()
|
||||
if (guide) {
|
||||
setShowGuideModal(true)
|
||||
}
|
||||
}
|
||||
|
||||
const getBadgeConfig = () => {
|
||||
const configs = {
|
||||
new: {
|
||||
text: 'NEW',
|
||||
color: '#52c41a',
|
||||
icon: <InfoCircleOutlined />,
|
||||
},
|
||||
help: {
|
||||
text: '?',
|
||||
color: '#1677ff',
|
||||
icon: <QuestionCircleOutlined />,
|
||||
},
|
||||
warn: {
|
||||
text: '!',
|
||||
color: '#faad14',
|
||||
icon: <WarningOutlined />,
|
||||
},
|
||||
}
|
||||
return configs[badgeType] || configs.help
|
||||
}
|
||||
|
||||
const badgeConfig = getBadgeConfig()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="button-guide-badge-wrapper">
|
||||
{showBadge && guide && !disabled ? (
|
||||
<Badge
|
||||
count={
|
||||
<div
|
||||
className={`guide-badge guide-badge-${badgeType}`}
|
||||
onClick={handleBadgeClick}
|
||||
>
|
||||
{badgeConfig.icon}
|
||||
</div>
|
||||
}
|
||||
offset={[-5, 5]}
|
||||
>
|
||||
<Button
|
||||
type={type}
|
||||
icon={icon}
|
||||
danger={danger}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size={size}
|
||||
{...restProps}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
type={type}
|
||||
icon={icon}
|
||||
danger={danger}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size={size}
|
||||
{...restProps}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 引导弹窗 */}
|
||||
{guide && (
|
||||
<Modal
|
||||
title={
|
||||
<div className="guide-modal-header">
|
||||
<span className="guide-modal-icon">{guide.icon || icon}</span>
|
||||
<span className="guide-modal-title">{guide.title}</span>
|
||||
{guide.badge && (
|
||||
<Tag color={guide.badge.color} className="guide-modal-badge">
|
||||
{guide.badge.text}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
open={showGuideModal}
|
||||
onCancel={() => setShowGuideModal(false)}
|
||||
footer={[
|
||||
<Button key="close" type="primary" onClick={() => setShowGuideModal(false)}>
|
||||
知道了
|
||||
</Button>,
|
||||
]}
|
||||
width={600}
|
||||
className="button-guide-modal"
|
||||
>
|
||||
{/* 功能描述 */}
|
||||
{guide.description && (
|
||||
<div className="guide-section">
|
||||
<div className="guide-section-title">
|
||||
<InfoCircleOutlined className="guide-section-icon" />
|
||||
功能说明
|
||||
</div>
|
||||
<p className="guide-section-content">{guide.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 使用步骤 */}
|
||||
{guide.steps && guide.steps.length > 0 && (
|
||||
<div className="guide-section">
|
||||
<div className="guide-section-title">
|
||||
<CheckCircleOutlined className="guide-section-icon" />
|
||||
操作步骤
|
||||
</div>
|
||||
<Steps
|
||||
direction="vertical"
|
||||
current={-1}
|
||||
items={guide.steps.map((step, index) => ({
|
||||
title: `步骤 ${index + 1}`,
|
||||
description: step,
|
||||
status: 'wait',
|
||||
}))}
|
||||
className="guide-steps"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 使用场景 */}
|
||||
{guide.scenarios && guide.scenarios.length > 0 && (
|
||||
<div className="guide-section">
|
||||
<div className="guide-section-title">
|
||||
<BulbOutlined className="guide-section-icon" />
|
||||
适用场景
|
||||
</div>
|
||||
<ul className="guide-list">
|
||||
{guide.scenarios.map((scenario, index) => (
|
||||
<li key={index}>{scenario}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 注意事项 */}
|
||||
{guide.warnings && guide.warnings.length > 0 && (
|
||||
<div className="guide-section guide-section-warning">
|
||||
<div className="guide-section-title">
|
||||
<WarningOutlined className="guide-section-icon" />
|
||||
注意事项
|
||||
</div>
|
||||
<ul className="guide-list">
|
||||
{guide.warnings.map((warning, index) => (
|
||||
<li key={index}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 快捷键和权限 */}
|
||||
{(guide.shortcut || guide.permission) && (
|
||||
<div className="guide-footer">
|
||||
{guide.shortcut && (
|
||||
<div className="guide-footer-item">
|
||||
<span className="guide-footer-label">快捷键:</span>
|
||||
<kbd className="guide-footer-kbd">{guide.shortcut}</kbd>
|
||||
</div>
|
||||
)}
|
||||
{guide.permission && (
|
||||
<div className="guide-footer-item">
|
||||
<span className="guide-footer-label">权限要求:</span>
|
||||
<Tag color="blue">{guide.permission}</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ButtonWithGuideBadge
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
.button-hover-card-wrapper {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 悬浮卡片 */
|
||||
.hover-info-card {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0;
|
||||
animation: slideInRight 0.3s ease forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hover-info-card-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-50%) translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hover-info-card-content {
|
||||
width: 340px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 12px 28px rgba(0, 0, 0, 0.12),
|
||||
0 6px 12px rgba(0, 0, 0, 0.08),
|
||||
0 0 2px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hover-info-card-content .ant-card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* 卡片头部 */
|
||||
.hover-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.hover-card-title-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hover-card-icon {
|
||||
font-size: 20px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.hover-card-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.hover-card-badge {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 卡片描述 */
|
||||
.hover-card-description {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
/* 卡片区块 */
|
||||
.hover-card-section {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #1677ff;
|
||||
}
|
||||
|
||||
.hover-card-warning {
|
||||
background: #fff7e6;
|
||||
border-left-color: #faad14;
|
||||
}
|
||||
|
||||
.hover-card-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
font-size: 12px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.hover-card-warning .section-icon {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.hover-card-list {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.hover-card-list li {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.hover-card-list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 卡片底部 */
|
||||
.hover-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.footer-label {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.footer-kbd {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
|
||||
font-size: 11px;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.hover-info-card-content {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.hover-info-card {
|
||||
left: 50% !important;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-50%) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(-50%) scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
import { useState, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Button, Card, Tag } from 'antd'
|
||||
import {
|
||||
BulbOutlined,
|
||||
WarningOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import './ButtonWithHoverCard.css'
|
||||
|
||||
/**
|
||||
* 悬浮展开卡片按钮组件
|
||||
* 鼠标悬停时,在按钮旁边展开一个精美的信息卡片
|
||||
* @param {Object} props
|
||||
* @param {string} props.label - 按钮文本
|
||||
* @param {ReactNode} props.icon - 按钮图标
|
||||
* @param {string} props.type - 按钮类型
|
||||
* @param {boolean} props.danger - 危险按钮
|
||||
* @param {boolean} props.disabled - 禁用状态
|
||||
* @param {Function} props.onClick - 点击回调
|
||||
* @param {Object} props.cardInfo - 卡片信息配置
|
||||
* @param {string} props.size - 按钮大小
|
||||
*/
|
||||
function ButtonWithHoverCard({
|
||||
label,
|
||||
icon,
|
||||
type = 'default',
|
||||
danger = false,
|
||||
disabled = false,
|
||||
onClick,
|
||||
cardInfo,
|
||||
size = 'middle',
|
||||
...restProps
|
||||
}) {
|
||||
const [showCard, setShowCard] = useState(false)
|
||||
const [cardPosition, setCardPosition] = useState({ top: 0, left: 0 })
|
||||
const wrapperRef = useRef(null)
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!cardInfo || disabled) return
|
||||
|
||||
if (wrapperRef.current) {
|
||||
const rect = wrapperRef.current.getBoundingClientRect()
|
||||
setCardPosition({
|
||||
top: rect.top + rect.height / 2,
|
||||
left: rect.right + 12,
|
||||
})
|
||||
}
|
||||
setShowCard(true)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setShowCard(false)
|
||||
}
|
||||
|
||||
// 渲染悬浮卡片
|
||||
const renderCard = () => {
|
||||
if (!showCard || !cardInfo) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`hover-info-card ${showCard ? 'hover-info-card-visible' : ''}`}
|
||||
style={{
|
||||
top: cardPosition.top,
|
||||
left: cardPosition.left,
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
size="small"
|
||||
bordered={false}
|
||||
className="hover-info-card-content"
|
||||
>
|
||||
{/* 标题区 */}
|
||||
<div className="hover-card-header">
|
||||
<div className="hover-card-title-wrapper">
|
||||
{cardInfo.icon && (
|
||||
<span className="hover-card-icon">{cardInfo.icon}</span>
|
||||
)}
|
||||
<h4 className="hover-card-title">{cardInfo.title}</h4>
|
||||
</div>
|
||||
{cardInfo.badge && (
|
||||
<Tag color={cardInfo.badge.color} className="hover-card-badge">
|
||||
{cardInfo.badge.text}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
{cardInfo.description && (
|
||||
<div className="hover-card-section">
|
||||
<p className="hover-card-description">{cardInfo.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 使用场景 */}
|
||||
{cardInfo.scenarios && cardInfo.scenarios.length > 0 && (
|
||||
<div className="hover-card-section">
|
||||
<div className="hover-card-section-title">
|
||||
<BulbOutlined className="section-icon" />
|
||||
使用场景
|
||||
</div>
|
||||
<ul className="hover-card-list">
|
||||
{cardInfo.scenarios.slice(0, 2).map((scenario, index) => (
|
||||
<li key={index}>{scenario}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 快速提示 */}
|
||||
{cardInfo.quickTips && cardInfo.quickTips.length > 0 && (
|
||||
<div className="hover-card-section">
|
||||
<div className="hover-card-section-title">
|
||||
<ThunderboltOutlined className="section-icon" />
|
||||
快速提示
|
||||
</div>
|
||||
<ul className="hover-card-list">
|
||||
{cardInfo.quickTips.map((tip, index) => (
|
||||
<li key={index}>{tip}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 注意事项 */}
|
||||
{cardInfo.warnings && cardInfo.warnings.length > 0 && (
|
||||
<div className="hover-card-section hover-card-warning">
|
||||
<div className="hover-card-section-title">
|
||||
<WarningOutlined className="section-icon" />
|
||||
注意
|
||||
</div>
|
||||
<ul className="hover-card-list">
|
||||
{cardInfo.warnings.slice(0, 2).map((warning, index) => (
|
||||
<li key={index}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 快捷键 */}
|
||||
{cardInfo.shortcut && (
|
||||
<div className="hover-card-footer">
|
||||
<span className="footer-label">快捷键</span>
|
||||
<kbd className="footer-kbd">{cardInfo.shortcut}</kbd>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="button-hover-card-wrapper"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<Button
|
||||
type={type}
|
||||
icon={icon}
|
||||
danger={danger}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size={size}
|
||||
{...restProps}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 使用 Portal 渲染悬浮卡片到 body */}
|
||||
{typeof document !== 'undefined' && createPortal(renderCard(), document.body)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ButtonWithHoverCard
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
/* 按钮包裹容器 */
|
||||
.button-with-tip-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.button-with-tip {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 提示指示器 */
|
||||
.button-tip-indicator {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
cursor: help;
|
||||
transition: all 0.3s ease;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.button-with-tip-wrapper:hover .button-tip-indicator {
|
||||
color: #1677ff;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* 脉冲动画 */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 提示框样式 */
|
||||
.button-tip-overlay {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.button-tip-overlay .ant-tooltip-inner {
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.button-tip-overlay .ant-tooltip-arrow {
|
||||
--antd-arrow-background-color: #667eea;
|
||||
}
|
||||
|
||||
.button-tip-overlay .ant-tooltip-arrow-content {
|
||||
background: #667eea;
|
||||
}
|
||||
|
||||
/* 提示内容布局 */
|
||||
.button-tip-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.button-tip-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.button-tip-description {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.button-tip-shortcut {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.tip-label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.tip-kbd {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.button-tip-notes {
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.tip-notes-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tip-notes-list {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.tip-notes-list li {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tip-notes-list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 不同主题的提示框 */
|
||||
.tip-theme-success.button-tip-overlay .ant-tooltip-inner {
|
||||
background: linear-gradient(135deg, #56ab2f 0%, #a8e063 100%);
|
||||
}
|
||||
|
||||
.tip-theme-warning.button-tip-overlay .ant-tooltip-inner {
|
||||
background: linear-gradient(135deg, #f7971e 0%, #ffd200 100%);
|
||||
}
|
||||
|
||||
.tip-theme-danger.button-tip-overlay .ant-tooltip-inner {
|
||||
background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%);
|
||||
}
|
||||
|
||||
.tip-theme-info.button-tip-overlay .ant-tooltip-inner {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.button-tip-overlay {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.button-tip-indicator {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import { Button, Tooltip } from 'antd'
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons'
|
||||
import './ButtonWithTip.css'
|
||||
|
||||
/**
|
||||
* 带有增强提示的按钮组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.label - 按钮文本
|
||||
* @param {ReactNode} props.icon - 按钮图标
|
||||
* @param {string} props.type - 按钮类型
|
||||
* @param {boolean} props.danger - 危险按钮
|
||||
* @param {boolean} props.disabled - 禁用状态
|
||||
* @param {Function} props.onClick - 点击回调
|
||||
* @param {Object} props.tip - 提示配置
|
||||
* @param {string} props.tip.title - 提示标题
|
||||
* @param {string} props.tip.description - 详细描述
|
||||
* @param {string} props.tip.shortcut - 快捷键提示
|
||||
* @param {Array} props.tip.notes - 注意事项列表
|
||||
* @param {string} props.tip.placement - 提示位置
|
||||
* @param {boolean} props.showTipIcon - 是否显示提示图标
|
||||
* @param {string} props.size - 按钮大小
|
||||
*/
|
||||
function ButtonWithTip({
|
||||
label,
|
||||
icon,
|
||||
type = 'default',
|
||||
danger = false,
|
||||
disabled = false,
|
||||
onClick,
|
||||
tip,
|
||||
showTipIcon = true,
|
||||
size = 'middle',
|
||||
...restProps
|
||||
}) {
|
||||
// 如果没有提示配置,直接返回普通按钮
|
||||
if (!tip) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
icon={icon}
|
||||
danger={danger}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size={size}
|
||||
{...restProps}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// 构建提示内容
|
||||
const tooltipContent = (
|
||||
<div className="button-tip-content">
|
||||
{tip.title && <div className="button-tip-title">{tip.title}</div>}
|
||||
{tip.description && <div className="button-tip-description">{tip.description}</div>}
|
||||
{tip.shortcut && (
|
||||
<div className="button-tip-shortcut">
|
||||
<span className="tip-label">快捷键:</span>
|
||||
<kbd className="tip-kbd">{tip.shortcut}</kbd>
|
||||
</div>
|
||||
)}
|
||||
{tip.notes && tip.notes.length > 0 && (
|
||||
<div className="button-tip-notes">
|
||||
<div className="tip-notes-title">注意事项:</div>
|
||||
<ul className="tip-notes-list">
|
||||
{tip.notes.map((note, index) => (
|
||||
<li key={index}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={tooltipContent}
|
||||
placement={tip.placement || 'top'}
|
||||
classNames={{ root: 'button-tip-overlay' }}
|
||||
mouseEnterDelay={0.3}
|
||||
arrow={{ pointAtCenter: true }}
|
||||
>
|
||||
<div className="button-with-tip-wrapper">
|
||||
<Button
|
||||
type={type}
|
||||
icon={icon}
|
||||
danger={danger}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size={size}
|
||||
className="button-with-tip"
|
||||
{...restProps}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
{showTipIcon && !disabled && (
|
||||
<QuestionCircleOutlined className="button-tip-indicator" />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default ButtonWithTip
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/* 图表面板 */
|
||||
.chart-panel {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chart-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chart-panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
margin-bottom: 12px;
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid #1677ff;
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import * as echarts from 'echarts'
|
||||
import './ChartPanel.css'
|
||||
|
||||
/**
|
||||
* 图表面板组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.type - 图表类型: 'line' | 'bar' | 'pie' | 'ring'
|
||||
* @param {string} props.title - 图表标题
|
||||
* @param {Object} props.data - 图表数据
|
||||
* @param {number} props.height - 图表高度,默认 200px
|
||||
* @param {Object} props.option - 自定义 ECharts 配置
|
||||
* @param {string} props.className - 自定义类名
|
||||
*/
|
||||
function ChartPanel({ type = 'line', title, data, height = 200, option = {}, className = '' }) {
|
||||
const chartRef = useRef(null)
|
||||
const chartInstance = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current || !data) return
|
||||
|
||||
// 使用 setTimeout 确保 DOM 完全渲染
|
||||
const timer = setTimeout(() => {
|
||||
// 初始化图表
|
||||
if (!chartInstance.current) {
|
||||
chartInstance.current = echarts.init(chartRef.current)
|
||||
}
|
||||
|
||||
// 根据类型生成配置
|
||||
const chartOption = getChartOption(type, data, option)
|
||||
chartInstance.current.setOption(chartOption, true)
|
||||
}, 0)
|
||||
|
||||
// 窗口大小改变时重绘(使用 passive 选项)
|
||||
const handleResize = () => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.resize()
|
||||
}
|
||||
}
|
||||
|
||||
// 添加被动事件监听器
|
||||
window.addEventListener('resize', handleResize, { passive: true })
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [type, data, option])
|
||||
|
||||
// 组件卸载时销毁图表
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
chartInstance.current?.dispose()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={`chart-panel ${className}`}>
|
||||
{title && <div className="chart-panel-title">{title}</div>}
|
||||
<div ref={chartRef} style={{ width: '100%', height: `${height}px` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据图表类型生成 ECharts 配置
|
||||
*/
|
||||
function getChartOption(type, data, customOption) {
|
||||
const baseOption = {
|
||||
grid: {
|
||||
left: '10%',
|
||||
right: '5%',
|
||||
top: '15%',
|
||||
bottom: '15%',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: type === 'pie' || type === 'ring' ? 'item' : 'axis',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderColor: '#e8e8e8',
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: '#333',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'line':
|
||||
return {
|
||||
...baseOption,
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.xAxis || [],
|
||||
boundaryGap: false,
|
||||
axisLine: { lineStyle: { color: '#e8e8e8' } },
|
||||
axisLabel: { color: '#8c8c8c', fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { lineStyle: { color: '#e8e8e8' } },
|
||||
axisLabel: { color: '#8c8c8c', fontSize: 11 },
|
||||
splitLine: { lineStyle: { color: '#f0f0f0' } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
data: data.series || [],
|
||||
smooth: true,
|
||||
lineStyle: { width: 2, color: '#1677ff' },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(22, 119, 255, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(22, 119, 255, 0.05)' },
|
||||
]),
|
||||
},
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
itemStyle: { color: '#1677ff' },
|
||||
},
|
||||
],
|
||||
...customOption,
|
||||
}
|
||||
|
||||
case 'bar':
|
||||
return {
|
||||
...baseOption,
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.xAxis || [],
|
||||
axisLine: { lineStyle: { color: '#e8e8e8' } },
|
||||
axisLabel: { color: '#8c8c8c', fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { lineStyle: { color: '#e8e8e8' } },
|
||||
axisLabel: { color: '#8c8c8c', fontSize: 11 },
|
||||
splitLine: { lineStyle: { color: '#f0f0f0' } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: data.series || [],
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#4096ff' },
|
||||
{ offset: 1, color: '#1677ff' },
|
||||
]),
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
},
|
||||
barWidth: '50%',
|
||||
},
|
||||
],
|
||||
...customOption,
|
||||
}
|
||||
|
||||
case 'pie':
|
||||
case 'ring':
|
||||
return {
|
||||
...baseOption,
|
||||
grid: undefined,
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
right: '10%',
|
||||
top: 'center',
|
||||
textStyle: { color: '#8c8c8c', fontSize: 12 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: type === 'ring' ? ['40%', '65%'] : '65%',
|
||||
center: ['40%', '50%'],
|
||||
data: data.series || [],
|
||||
label: {
|
||||
fontSize: 11,
|
||||
color: '#8c8c8c',
|
||||
},
|
||||
labelLine: {
|
||||
lineStyle: { color: '#d9d9d9' },
|
||||
},
|
||||
itemStyle: {
|
||||
borderRadius: 4,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2,
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
...customOption,
|
||||
}
|
||||
|
||||
default:
|
||||
return { ...baseOption, ...customOption }
|
||||
}
|
||||
}
|
||||
|
||||
export default ChartPanel
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
/* 详情抽屉容器 */
|
||||
.detail-drawer-content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 顶部信息区域 - 固定不滚动 */
|
||||
.detail-drawer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
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;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 标签页区域 */
|
||||
.detail-drawer-tabs {
|
||||
background: #ffffff;
|
||||
padding: 0;
|
||||
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 0 16px 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: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
/* 扩展信息面板容器 */
|
||||
.extend-info-panel {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 垂直布局(默认) */
|
||||
.extend-info-panel-vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 水平布局 */
|
||||
.extend-info-panel-horizontal {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 信息区块 */
|
||||
.extend-info-section {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 水平布局时区块自适应宽度 */
|
||||
.extend-info-panel-horizontal .extend-info-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.extend-info-section:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 区块头部 */
|
||||
.extend-info-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.extend-info-section-header:hover {
|
||||
background: linear-gradient(135deg, #f0f4ff 0%, #e8f0ff 100%);
|
||||
}
|
||||
|
||||
.extend-info-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.extend-info-section-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.extend-info-section-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #8c8c8c;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.extend-info-section-toggle:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
/* 区块内容 */
|
||||
.extend-info-section-content {
|
||||
padding: 16px 20px;
|
||||
animation: expandContent 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes expandContent {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { useState } from 'react'
|
||||
import { UpOutlined, DownOutlined } from '@ant-design/icons'
|
||||
import './ExtendInfoPanel.css'
|
||||
|
||||
/**
|
||||
* 扩展信息面板组件
|
||||
* @param {Object} props
|
||||
* @param {Array} props.sections - 信息区块配置数组
|
||||
* @param {string} props.sections[].key - 区块唯一键
|
||||
* @param {string} props.sections[].title - 区块标题
|
||||
* @param {ReactNode} props.sections[].icon - 标题图标
|
||||
* @param {ReactNode} props.sections[].content - 区块内容
|
||||
* @param {boolean} props.sections[].defaultCollapsed - 默认是否折叠
|
||||
* @param {boolean} props.sections[].hideTitleBar - 是否隐藏该区块的标题栏(默认 false)
|
||||
* @param {string} props.layout - 布局方式:'vertical'(垂直堆叠)| 'horizontal'(水平排列)
|
||||
* @param {string} props.className - 自定义类名
|
||||
*/
|
||||
function ExtendInfoPanel({ sections = [], layout = 'vertical', className = '' }) {
|
||||
const [collapsedSections, setCollapsedSections] = useState(() => {
|
||||
const initial = {}
|
||||
sections.forEach((section) => {
|
||||
if (section.defaultCollapsed) {
|
||||
initial[section.key] = true
|
||||
}
|
||||
})
|
||||
return initial
|
||||
})
|
||||
|
||||
const toggleSection = (key) => {
|
||||
setCollapsedSections((prev) => ({
|
||||
...prev,
|
||||
[key]: !prev[key],
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`extend-info-panel extend-info-panel-${layout} ${className}`}>
|
||||
{sections.map((section) => {
|
||||
const isCollapsed = collapsedSections[section.key]
|
||||
const hideTitleBar = section.hideTitleBar === true
|
||||
|
||||
return (
|
||||
<div key={section.key} className="extend-info-section">
|
||||
{/* 区块头部 - 可配置隐藏 */}
|
||||
{!hideTitleBar && (
|
||||
<div className="extend-info-section-header" onClick={() => toggleSection(section.key)}>
|
||||
<div className="extend-info-section-title">
|
||||
{section.icon && <span className="extend-info-section-icon">{section.icon}</span>}
|
||||
<span>{section.title}</span>
|
||||
</div>
|
||||
<button className="extend-info-section-toggle" type="button">
|
||||
{isCollapsed ? <DownOutlined /> : <UpOutlined />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 区块内容 - 如果隐藏标题栏则总是显示,否则根据折叠状态 */}
|
||||
{(hideTitleBar || !isCollapsed) && (
|
||||
<div className="extend-info-section-content">{section.content}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExtendInfoPanel
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/* 信息面板 */
|
||||
.info-panel {
|
||||
padding: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* 信息区域容器 */
|
||||
.info-panel > :global(.ant-row) {
|
||||
padding: 24px;
|
||||
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: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: linear-gradient(180deg, #1677ff 0%, #4096ff 100%);
|
||||
border-radius: 2px;
|
||||
transition: all 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: 3px;
|
||||
height: 60%;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
.list-action-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
padding: 16px;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 批量操作区域样式 */
|
||||
.selection-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: #e6f4ff;
|
||||
border: 1px solid #91caff;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.selection-count {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.selection-count strong {
|
||||
color: #1677ff;
|
||||
font-weight: 600;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.all-pages-tag {
|
||||
color: #1677ff;
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.select-all-link,
|
||||
.clear-selection-link {
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.select-all-link:hover,
|
||||
.clear-selection-link:hover {
|
||||
background: rgba(22, 119, 255, 0.1);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
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 {Array} props.batchActions - 批量操作按钮配置数组(仅在有选中项时显示)
|
||||
* @param {Object} props.selectionInfo - 选中信息 { count: 选中数量, total: 总数量, isAllPagesSelected: 是否跨页全选 }
|
||||
* @param {Function} props.onSelectAllPages - 选择所有页回调
|
||||
* @param {Function} props.onClearSelection - 清除选择回调
|
||||
* @param {Object} props.search - 搜索配置
|
||||
* @param {Object} props.filter - 高级筛选配置(可选)
|
||||
* @param {boolean} props.showRefresh - 是否显示刷新按钮
|
||||
* @param {Function} props.onRefresh - 刷新回调
|
||||
*/
|
||||
function ListActionBar({
|
||||
actions = [],
|
||||
batchActions = [],
|
||||
selectionInfo,
|
||||
onSelectAllPages,
|
||||
onClearSelection,
|
||||
search,
|
||||
filter,
|
||||
showRefresh = false,
|
||||
onRefresh,
|
||||
}) {
|
||||
// 是否有选中项
|
||||
const hasSelection = selectionInfo && selectionInfo.count > 0
|
||||
return (
|
||||
<div className="list-action-bar">
|
||||
{/* 左侧操作按钮区 */}
|
||||
<div className="list-action-bar-left">
|
||||
{/* 常规操作按钮(无选中时显示) */}
|
||||
{!hasSelection && 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>
|
||||
))}
|
||||
|
||||
{/* 批量操作区域(有选中时显示) */}
|
||||
{hasSelection && (
|
||||
<Space>
|
||||
{/* 选中信息 */}
|
||||
<div className="selection-info">
|
||||
<span className="selection-count">
|
||||
已选择 <strong>{selectionInfo.count}</strong> 项
|
||||
{selectionInfo.isAllPagesSelected && (
|
||||
<span className="all-pages-tag">(全部页)</span>
|
||||
)}
|
||||
</span>
|
||||
{!selectionInfo.isAllPagesSelected && selectionInfo.total > selectionInfo.count && (
|
||||
<a onClick={onSelectAllPages} className="select-all-link">
|
||||
选择全部 {selectionInfo.total} 项
|
||||
</a>
|
||||
)}
|
||||
<a onClick={onClearSelection} className="clear-selection-link">
|
||||
清除
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* 批量操作按钮 */}
|
||||
{batchActions.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 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
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/* 列表表格容器 */
|
||||
.list-table-container {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
height: 626px;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 行选中样式 */
|
||||
.list-table-container .row-selected {
|
||||
background-color: #e6f7ff;
|
||||
}
|
||||
|
||||
.list-table-container .row-selected:hover > td {
|
||||
background-color: #bae7ff !important;
|
||||
}
|
||||
|
||||
/* 分页器中的选择信息样式 */
|
||||
.table-selection-info {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.selection-count {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.count-highlight {
|
||||
color: #1677ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.selection-action {
|
||||
color: #1677ff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.selection-action:hover {
|
||||
color: #4096ff;
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
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 {boolean} props.isAllPagesSelected - 是否跨页全选所有数据
|
||||
* @param {number} props.totalCount - 总数据量(用于跨页全选)
|
||||
* @param {Function} props.onSelectAllPages - 选择所有页回调
|
||||
* @param {Function} props.onClearSelection - 清除选择回调
|
||||
* @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,
|
||||
isAllPagesSelected = false,
|
||||
totalCount,
|
||||
onSelectAllPages,
|
||||
onClearSelection,
|
||||
pagination = {
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
},
|
||||
scroll = { x: 1200},
|
||||
onRowClick,
|
||||
selectedRow,
|
||||
loading = false,
|
||||
className = '',
|
||||
}) {
|
||||
// 行选择配置
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (newSelectedRowKeys) => {
|
||||
onSelectionChange?.(newSelectedRowKeys)
|
||||
},
|
||||
// 当跨页全选时,禁用单个复选框
|
||||
getCheckboxProps: () => ({
|
||||
disabled: isAllPagesSelected,
|
||||
}),
|
||||
}
|
||||
|
||||
// 合并分页配置,添加选择信息显示
|
||||
const mergedPagination = pagination === false ? false : {
|
||||
...pagination,
|
||||
showTotal: (total) => (
|
||||
<div className="table-selection-info">
|
||||
{isAllPagesSelected ? (
|
||||
<>
|
||||
<span className="selection-count">
|
||||
已选择 <span className="count-highlight">{totalCount || total}</span> 项
|
||||
</span>
|
||||
{onClearSelection && (
|
||||
<a onClick={onClearSelection} className="selection-action">
|
||||
清除选择
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
) : selectedRowKeys.length > 0 ? (
|
||||
<>
|
||||
<span className="selection-count">
|
||||
已选择 <span className="count-highlight">{selectedRowKeys.length}</span> 项
|
||||
</span>
|
||||
{onSelectAllPages && selectedRowKeys.length < (totalCount || total) && (
|
||||
<a onClick={onSelectAllPages} className="selection-action">
|
||||
选择全部 {totalCount || total} 项
|
||||
</a>
|
||||
)}
|
||||
{onClearSelection && (
|
||||
<a onClick={onClearSelection} className="selection-action">
|
||||
清除
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="selection-count">已选择 0 项</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`list-table-container ${className}`}>
|
||||
<Table
|
||||
size="middle"
|
||||
rowSelection={rowSelection}
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
rowKey={rowKey}
|
||||
pagination={mergedPagination}
|
||||
scroll={scroll}
|
||||
loading={loading}
|
||||
onRow={(record) => ({
|
||||
onClick: () => onRowClick?.(record),
|
||||
className: selectedRow?.[rowKey] === record[rowKey] ? 'row-selected' : '',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ListTable
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
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 useUserStore from '@/stores/userStore'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import headerMenuData from '../../data/headerMenuData.json'
|
||||
import './AppHeader.css'
|
||||
|
||||
const { Header } = Layout
|
||||
|
||||
// 图标映射
|
||||
const iconMap = {
|
||||
QuestionCircleOutlined: <QuestionCircleOutlined />,
|
||||
FileTextOutlined: <FileTextOutlined />,
|
||||
CustomerServiceOutlined: <CustomerServiceOutlined />,
|
||||
}
|
||||
|
||||
function AppHeader({ collapsed, onToggle }) {
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useUserStore()
|
||||
|
||||
// 用户下拉菜单
|
||||
const userMenuItems = [
|
||||
{
|
||||
key: 'profile',
|
||||
label: '个人中心',
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: '账户设置',
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
label: '退出登录',
|
||||
},
|
||||
]
|
||||
|
||||
const handleUserMenuClick = ({ key }) => {
|
||||
if (key === 'logout') {
|
||||
logout()
|
||||
Toast.success('退出成功', '您已安全退出')
|
||||
navigate('/login')
|
||||
} else if (key === 'profile') {
|
||||
navigate('/profile')
|
||||
} else if (key === 'settings') {
|
||||
Toast.info('开发中', '账户设置功能正在开发中')
|
||||
}
|
||||
}
|
||||
|
||||
const handleHeaderMenuClick = (key) => {
|
||||
console.log('Header menu clicked:', key)
|
||||
if (key === 'support') {
|
||||
Toast.info('开发中', '支持功能正在开发中')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Header className="app-header">
|
||||
{/* 左侧:Logo + 折叠按钮 */}
|
||||
<div className="header-left">
|
||||
{/* Logo 区域 */}
|
||||
<div className="header-logo">
|
||||
<h2 style={{ margin: 0, color: '#1890ff', fontWeight: 'bold' }}>NEX Docus</h2>
|
||||
</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">{user?.nickname || user?.username || 'User'}</span>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</div>
|
||||
</Header>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppHeader
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Layout, Menu, Badge, message } from 'antd'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
DesktopOutlined,
|
||||
GlobalOutlined,
|
||||
CloudServerOutlined,
|
||||
UserOutlined,
|
||||
AppstoreOutlined,
|
||||
SettingOutlined,
|
||||
BlockOutlined,
|
||||
FolderOutlined,
|
||||
FileTextOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { getUserMenus } from '@/api/menu'
|
||||
import './AppSider.css'
|
||||
|
||||
const { Sider } = Layout
|
||||
|
||||
// 图标映射
|
||||
const iconMap = {
|
||||
DashboardOutlined,
|
||||
DesktopOutlined,
|
||||
GlobalOutlined,
|
||||
CloudServerOutlined,
|
||||
UserOutlined,
|
||||
AppstoreOutlined,
|
||||
SettingOutlined,
|
||||
BlockOutlined,
|
||||
FolderOutlined,
|
||||
FileTextOutlined,
|
||||
}
|
||||
|
||||
function AppSider({ collapsed, onToggle }) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [openKeys, setOpenKeys] = useState([])
|
||||
const [menuData, setMenuData] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// 加载菜单数据
|
||||
useEffect(() => {
|
||||
loadMenus()
|
||||
}, [])
|
||||
|
||||
const loadMenus = async () => {
|
||||
try {
|
||||
const res = await getUserMenus()
|
||||
if (res.data) {
|
||||
setMenuData(res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load menus error:', error)
|
||||
message.error('加载菜单失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据当前路径获取应该打开的父菜单
|
||||
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.menu_code]
|
||||
}
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// 监听路径变化和收拢状态,自动打开父菜单
|
||||
useEffect(() => {
|
||||
if (!collapsed && menuData.length > 0) {
|
||||
const defaultKeys = getDefaultOpenKeys()
|
||||
setOpenKeys(defaultKeys)
|
||||
}
|
||||
}, [location.pathname, collapsed, menuData])
|
||||
|
||||
const handleMenuClick = ({ key }) => {
|
||||
// 查找对应的路径
|
||||
for (const item of menuData) {
|
||||
if (item.menu_code === key && item.path) {
|
||||
navigate(item.path)
|
||||
return
|
||||
}
|
||||
if (item.children) {
|
||||
const child = item.children.find((c) => c.menu_code === key)
|
||||
if (child && child.path) {
|
||||
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.menu_code
|
||||
if (item.children) {
|
||||
const child = item.children.find((c) => c.path === path)
|
||||
if (child) return child.menu_code
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// 生成菜单项配置
|
||||
const getMenuItems = () => {
|
||||
return menuData.map((item) => {
|
||||
const IconComponent = iconMap[item.icon]
|
||||
const icon = IconComponent ? <IconComponent /> : null
|
||||
|
||||
// 如果有子菜单
|
||||
if (item.children && item.children.length > 0) {
|
||||
return {
|
||||
key: item.menu_code,
|
||||
icon: icon,
|
||||
label: item.menu_name,
|
||||
popupClassName: 'sider-submenu-popup',
|
||||
children: item.children.map((child) => ({
|
||||
key: child.menu_code,
|
||||
label: child.menu_name,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// 普通菜单项
|
||||
return {
|
||||
key: item.menu_code,
|
||||
icon: icon,
|
||||
label: item.menu_name,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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"
|
||||
items={getMenuItems()}
|
||||
/>
|
||||
</Sider>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppSider
|
||||
|
|
@ -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: 16px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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'
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
.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);
|
||||
}
|
||||
|
||||
/* 扩展内容区域 */
|
||||
.title-bar-expanded-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(139, 92, 246, 0.1);
|
||||
animation: expandContent 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes expandContent {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式适配 */
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = false,
|
||||
}) {
|
||||
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
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { Navigate } from 'react-router-dom'
|
||||
import useUserStore from '@/stores/userStore'
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
const token = localStorage.getItem('access_token')
|
||||
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export default ProtectedRoute
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
.selection-alert-container {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.selection-alert-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.selection-alert-content span {
|
||||
flex: 1;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.selection-alert-content strong {
|
||||
color: #1677ff;
|
||||
font-weight: 600;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.selection-alert-content a {
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.selection-alert-content a:hover {
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 响应式处理 */
|
||||
@media (max-width: 768px) {
|
||||
.selection-alert-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.selection-alert-content a {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { Alert } from 'antd'
|
||||
import './SelectionAlert.css'
|
||||
|
||||
/**
|
||||
* 全选提示条组件
|
||||
* @param {Object} props
|
||||
* @param {number} props.currentPageCount - 当前页选中数量
|
||||
* @param {number} props.totalCount - 总数据量
|
||||
* @param {boolean} props.isAllPagesSelected - 是否已选择所有页
|
||||
* @param {Function} props.onSelectAllPages - 选择所有页的回调
|
||||
* @param {Function} props.onClearSelection - 清除选择的回调
|
||||
*/
|
||||
function SelectionAlert({
|
||||
currentPageCount,
|
||||
totalCount,
|
||||
isAllPagesSelected,
|
||||
onSelectAllPages,
|
||||
onClearSelection,
|
||||
}) {
|
||||
// 如果没有选中任何项,不显示
|
||||
if (currentPageCount === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 如果已选择所有页
|
||||
if (isAllPagesSelected) {
|
||||
return (
|
||||
<div className="selection-alert-container">
|
||||
<Alert
|
||||
message={
|
||||
<div className="selection-alert-content">
|
||||
<span>
|
||||
已选择全部 <strong>{totalCount}</strong> 条数据
|
||||
</span>
|
||||
<a onClick={onClearSelection}>清除选择</a>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
closable={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 如果只选择了当前页,且总数大于当前页
|
||||
if (currentPageCount > 0 && totalCount > currentPageCount) {
|
||||
return (
|
||||
<div className="selection-alert-container">
|
||||
<Alert
|
||||
message={
|
||||
<div className="selection-alert-content">
|
||||
<span>
|
||||
已选择当前页 <strong>{currentPageCount}</strong> 条数据
|
||||
</span>
|
||||
<a onClick={onSelectAllPages}>
|
||||
选择全部 {totalCount} 条数据
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
type="warning"
|
||||
showIcon
|
||||
closable={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 只选择了部分数据,且总数等于当前页(单页情况)
|
||||
return (
|
||||
<div className="selection-alert-container">
|
||||
<Alert
|
||||
message={
|
||||
<div className="selection-alert-content">
|
||||
<span>
|
||||
已选择 <strong>{currentPageCount}</strong> 条数据
|
||||
</span>
|
||||
<a onClick={onClearSelection}>清除选择</a>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
closable={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectionAlert
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
/* 侧边信息面板容器 */
|
||||
.side-info-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 信息区块 */
|
||||
.side-info-section {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.side-info-section:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 区块头部 */
|
||||
.side-info-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.side-info-section-header:hover {
|
||||
background: linear-gradient(135deg, #f0f4ff 0%, #e8f0ff 100%);
|
||||
}
|
||||
|
||||
.side-info-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.side-info-section-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.side-info-section-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #8c8c8c;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.side-info-section-toggle:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
/* 区块内容 */
|
||||
.side-info-section-content {
|
||||
padding: 16px 20px;
|
||||
animation: expandContent 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes expandContent {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { useState } from 'react'
|
||||
import { UpOutlined, DownOutlined } from '@ant-design/icons'
|
||||
import './SideInfoPanel.css'
|
||||
|
||||
/**
|
||||
* 侧边信息面板组件
|
||||
* @param {Object} props
|
||||
* @param {Array} props.sections - 信息区块配置数组
|
||||
* @param {string} props.className - 自定义类名
|
||||
*/
|
||||
function SideInfoPanel({ sections = [], className = '' }) {
|
||||
const [collapsedSections, setCollapsedSections] = useState(() => {
|
||||
const initial = {}
|
||||
sections.forEach((section) => {
|
||||
if (section.defaultCollapsed) {
|
||||
initial[section.key] = true
|
||||
}
|
||||
})
|
||||
return initial
|
||||
})
|
||||
|
||||
const toggleSection = (key) => {
|
||||
setCollapsedSections((prev) => ({
|
||||
...prev,
|
||||
[key]: !prev[key],
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`side-info-panel ${className}`}>
|
||||
{sections.map((section) => {
|
||||
const isCollapsed = collapsedSections[section.key]
|
||||
|
||||
return (
|
||||
<div key={section.key} className="side-info-section">
|
||||
{/* 区块头部 */}
|
||||
<div className="side-info-section-header" onClick={() => toggleSection(section.key)}>
|
||||
<div className="side-info-section-title">
|
||||
{section.icon && <span className="side-info-section-icon">{section.icon}</span>}
|
||||
<span>{section.title}</span>
|
||||
</div>
|
||||
<button className="side-info-section-toggle" type="button">
|
||||
{isCollapsed ? <DownOutlined /> : <UpOutlined />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 区块内容 */}
|
||||
{!isCollapsed && (
|
||||
<div className="side-info-section-content">{section.content}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SideInfoPanel
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
/* 分栏布局容器 */
|
||||
.split-layout {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* 横向布局(左右分栏) */
|
||||
.split-layout-horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
/* 纵向布局(上下分栏) */
|
||||
.split-layout-vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.split-layout-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 扩展信息区 */
|
||||
.split-layout-extend {
|
||||
flex-shrink: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* 右侧扩展区(横向布局) */
|
||||
.split-layout-extend-right {
|
||||
height: 693px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/* 顶部扩展区(纵向布局) */
|
||||
.split-layout-extend-top {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 滚动条样式(横向布局右侧扩展区) */
|
||||
.split-layout-extend-right::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.split-layout-extend-right::-webkit-scrollbar-track {
|
||||
background: #f5f5f5;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.split-layout-extend-right::-webkit-scrollbar-thumb {
|
||||
background: #d9d9d9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.split-layout-extend-right::-webkit-scrollbar-thumb:hover {
|
||||
background: #bfbfbf;
|
||||
}
|
||||
|
||||
/* 响应式:小屏幕时隐藏右侧扩展区 */
|
||||
@media (max-width: 1200px) {
|
||||
.split-layout-extend-right {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import './SplitLayout.css'
|
||||
|
||||
/**
|
||||
* 主内容区布局组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.direction - 布局方向:'horizontal'(左右)| 'vertical'(上下)
|
||||
* @param {ReactNode} props.mainContent - 主内容区
|
||||
* @param {ReactNode} props.extendContent - 扩展内容区
|
||||
* @param {number} props.extendSize - 扩展区尺寸(horizontal 模式下为宽度,px)
|
||||
* @param {number} props.gap - 主内容与扩展区间距(px)
|
||||
* @param {boolean} props.showExtend - 是否显示扩展区
|
||||
* @param {string} props.extendPosition - 扩展区位置(horizontal: 'right', vertical: 'top')
|
||||
* @param {string} props.className - 自定义类名
|
||||
*
|
||||
* @deprecated 旧参数(向后兼容):leftContent, rightContent, rightWidth, showRight
|
||||
*/
|
||||
function SplitLayout({
|
||||
// 新 API
|
||||
direction = 'horizontal',
|
||||
mainContent,
|
||||
extendContent,
|
||||
extendSize = 360,
|
||||
gap = 16,
|
||||
showExtend = true,
|
||||
extendPosition,
|
||||
className = '',
|
||||
// 旧 API(向后兼容)
|
||||
leftContent,
|
||||
rightContent,
|
||||
rightWidth,
|
||||
showRight,
|
||||
}) {
|
||||
// 向后兼容:如果使用旧 API,转换为新 API
|
||||
const actualMainContent = mainContent || leftContent
|
||||
const actualExtendContent = extendContent || rightContent
|
||||
const actualExtendSize = extendSize !== 360 ? extendSize : (rightWidth || 360)
|
||||
const actualShowExtend = showExtend !== undefined ? showExtend : (showRight !== undefined ? showRight : true)
|
||||
const actualDirection = direction
|
||||
const actualExtendPosition = extendPosition || (actualDirection === 'horizontal' ? 'right' : 'top')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`split-layout split-layout-${actualDirection} ${className}`}
|
||||
style={{ gap: `${gap}px` }}
|
||||
>
|
||||
{/* 纵向布局且扩展区在顶部时,先渲染扩展区 */}
|
||||
{actualDirection === 'vertical' && actualExtendPosition === 'top' && actualShowExtend && actualExtendContent && (
|
||||
<div className="split-layout-extend split-layout-extend-top">
|
||||
{actualExtendContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className="split-layout-main">{actualMainContent}</div>
|
||||
|
||||
{/* 横向布局时,扩展区在右侧 */}
|
||||
{actualDirection === 'horizontal' && actualShowExtend && actualExtendContent && (
|
||||
<div
|
||||
className="split-layout-extend split-layout-extend-right"
|
||||
style={{ width: `${actualExtendSize}px` }}
|
||||
>
|
||||
{actualExtendContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SplitLayout
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue