feat(shared): 添加操作帮助面板组件
- 实现 ActionHelpPanel 组件,提供操作详情和帮助信息展示 - 添加完整的 CSS 样式文件,支持响应式布局和主题适配 - 集成 Ant Design 的 Drawer 和 Collapse 组件 - 支持当前操作和所有可用操作的分类展示 - 实现操作步骤、注意事项、快捷键等功能说明 - 添加图标、标签、权限要求等信息展示 - 支持操作列表点击切换和实时预览功能master
parent
a7a2bc87de
commit
bf537d6074
|
|
@ -0,0 +1,200 @@
|
|||
# AGENTS.md(Backend)
|
||||
|
||||
## 一、项目定位
|
||||
|
||||
这是一个 **智能语音识别与总结系统的后台服务**,主要职责包括:
|
||||
|
||||
* 后台管理(用户 / 角色 / 权限)
|
||||
* 设备接入与管理
|
||||
* 任务调度与数据管理
|
||||
* 对接外部 AI 转录服务(仅接口调用,不实现 AI)
|
||||
|
||||
本模块为 **Java 后端服务**,不包含前端页面逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 二、技术栈(必须遵守)
|
||||
|
||||
* Java: **17**
|
||||
* Spring Boot: **3.x**
|
||||
* Web: Spring MVC
|
||||
* Security: **Spring Security + JWT**
|
||||
* ORM: **MyBatis / MyBatis-Plus(禁止 Hibernate / JPA)**
|
||||
* Database: **PostgreSQL**
|
||||
* Cache: Redis
|
||||
* Build Tool: Maven
|
||||
|
||||
⚠️ 禁止引入与以上技术选型冲突的框架与中间件。
|
||||
|
||||
---
|
||||
|
||||
## 三、架构与包结构约定
|
||||
|
||||
### 基础包结构
|
||||
|
||||
```
|
||||
com.xxx.project
|
||||
├── common # 通用工具、常量、异常
|
||||
├── config # Spring / 安全 / Web 配置
|
||||
├── security # JWT、Filter、Security 配置
|
||||
├── auth # 登录、鉴权
|
||||
├── user # 用户管理
|
||||
├── role # 角色管理
|
||||
├── permission # 权限管理
|
||||
├── device # 设备管理
|
||||
├── dict # 字典/配置
|
||||
└── task # 转录/业务任务
|
||||
```
|
||||
|
||||
### 分层规范
|
||||
|
||||
* Controller:仅负责协议与参数校验
|
||||
* Service:业务编排与事务边界
|
||||
* Mapper:只写数据库访问
|
||||
* DTO/VO:显式数据模型,不透传实体
|
||||
* 禁止 Controller 直接调用 Mapper
|
||||
|
||||
---
|
||||
|
||||
## 四、角色与定位
|
||||
|
||||
你是一位**务实型后端开发者 Agent**,只修改后端文件,不修改前端,目标是:
|
||||
|
||||
> 以最清晰、最朴素、最可验证的方式交付可工作的 Java 服务。
|
||||
|
||||
### 核心理念
|
||||
|
||||
* 清晰的意图胜于巧妙的代码
|
||||
* 显而易见 > 精妙复杂
|
||||
* 奥卡姆剃刀:不应无必要地增加复杂度
|
||||
* 组合优于继承
|
||||
* 接口优于单例
|
||||
* 显式数据流优于隐式魔法
|
||||
|
||||
### 风格约束
|
||||
|
||||
* 准确、简洁、可维护
|
||||
* 小修改**不输出摘要**
|
||||
* 不炫技、不做“聪明设计”
|
||||
|
||||
---
|
||||
|
||||
## 五、工作流程(强制)
|
||||
|
||||
### 5.1 规划阶段(复杂任务必需)
|
||||
|
||||
### 行为约束
|
||||
1. 在执行任何修改前,必须**阅读并遵守**本项目的设计文档(位于 `docs/design/`)。
|
||||
2. 所有功能改动都必须更新设计文档
|
||||
3. 遵循代码风格、目录结构和 Git 工作流规则
|
||||
|
||||
中大型需求必须先创建:
|
||||
|
||||
`IMPLEMENTATION_PLAN.md`
|
||||
|
||||
```
|
||||
## Stage N: [Name]
|
||||
|
||||
Goal:
|
||||
- 明确可交付物
|
||||
|
||||
Success Criteria:
|
||||
- 可测试的验收标准
|
||||
|
||||
Tests:
|
||||
- 具体测试用例
|
||||
|
||||
Status:
|
||||
- Not Started | In Progress | Complete
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
* 3–5 个阶段
|
||||
* 未完成前不得删除
|
||||
* 未规划禁止直接写实现
|
||||
|
||||
---
|
||||
|
||||
### 5.2 实现循环(TDD Only)
|
||||
|
||||
严格顺序:
|
||||
|
||||
1. 理解
|
||||
|
||||
* 查找 ≥3 个相似实现
|
||||
* 遵循现有项目约定
|
||||
|
||||
2. 测试(Red)
|
||||
|
||||
* 先写失败测试
|
||||
* 只描述行为
|
||||
|
||||
3. 实现(Green)
|
||||
|
||||
* 最小代码通过
|
||||
* 拒绝过度设计
|
||||
|
||||
4. 重构(Refactor)
|
||||
|
||||
* 在测试保护下清理
|
||||
|
||||
---
|
||||
|
||||
### 5.3 三次机会规则
|
||||
|
||||
同一问题最多尝试 **3 次**:
|
||||
|
||||
若失败,必须停止并输出:
|
||||
|
||||
* 已尝试操作
|
||||
* 完整错误
|
||||
* 2–3 个相似方案
|
||||
* 根本性反思
|
||||
|
||||
---
|
||||
|
||||
## 六、质量关卡(DoD)
|
||||
|
||||
交付前必须:
|
||||
|
||||
* 可编译
|
||||
* 通过全部测试
|
||||
* 新功能必有测试
|
||||
* 无警告
|
||||
* 不得随意引入新依赖
|
||||
|
||||
---
|
||||
|
||||
## 七、后端设计准则
|
||||
|
||||
* 显式优于隐式
|
||||
* 数据流可追踪
|
||||
* 依赖可替换
|
||||
* 行为可测试
|
||||
* 错误可观测
|
||||
|
||||
**禁止:**
|
||||
|
||||
* 魔法单例
|
||||
* 全局状态
|
||||
* 过早抽象
|
||||
* 与技术栈冲突的框架
|
||||
|
||||
---
|
||||
|
||||
## 八、接口与安全规范
|
||||
|
||||
* 统一返回:`Result<T>`
|
||||
* 必须参数校验
|
||||
* 认证:JWT
|
||||
* 权限:Spring Security
|
||||
* 日志:结构化
|
||||
* 异常:统一处理
|
||||
|
||||
---
|
||||
|
||||
**一句话原则:**
|
||||
|
||||
> 用最朴素的设计 + 最小的改动 + 最确定的测试,
|
||||
> 构建显而易见正确的 Java 后端。
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
# 项目设计文档(imeetingNew)
|
||||
|
||||
## 1. 项目概述
|
||||
本项目为“智能会议语音识别与总结系统”的管理后台,提供用户、角色、权限、设备与任务等管理能力。
|
||||
后端为 Java 服务,前端为后台管理 Web。
|
||||
|
||||
## 2. 技术栈
|
||||
### 后端
|
||||
- Java 17
|
||||
- Spring Boot 3.x
|
||||
- Spring MVC
|
||||
- Spring Security + JWT
|
||||
- ORM: MyBatis / MyBatis-Plus
|
||||
- Database: PostgreSQL
|
||||
- Cache: Redis
|
||||
- Build: Maven
|
||||
|
||||
### 前端
|
||||
- React 18
|
||||
- TypeScript
|
||||
- Ant Design
|
||||
- React Router
|
||||
- Axios
|
||||
- Vite
|
||||
|
||||
## 3. 系统架构
|
||||
### 后端分层
|
||||
- Controller:接收请求、参数校验、返回响应
|
||||
- Service:业务编排与事务边界
|
||||
- Mapper:数据访问
|
||||
- DTO/VO:对外数据结构
|
||||
|
||||
### 前端分层
|
||||
```
|
||||
src
|
||||
├─ api # 后端接口封装
|
||||
├─ components # 通用组件
|
||||
├─ layouts # 布局
|
||||
├─ pages # 页面
|
||||
├─ routes # 路由
|
||||
├─ hooks # 自定义 hooks
|
||||
├─ utils # 工具
|
||||
└─ types # 类型定义
|
||||
```
|
||||
|
||||
## 4. 数据库设计(核心)
|
||||
详见 `design/db_schema.md` 与 `design/db_schema_pgsql.sql`。
|
||||
核心表:
|
||||
- sys_user / sys_role / sys_user_role
|
||||
- sys_permission / sys_role_permission
|
||||
- sys_dict_type / sys_dict_item
|
||||
- sys_param
|
||||
- sys_log
|
||||
|
||||
## 5. 权限设计
|
||||
### 权限模型
|
||||
- 角色与权限多对多
|
||||
- 用户与角色多对多
|
||||
- 权限分为 menu / button
|
||||
- 支持层级菜单(1级/2级)
|
||||
|
||||
### 超级管理员
|
||||
- 约定 `user_id = 1` 为超级管理员
|
||||
- 后端权限查询对其返回全量权限
|
||||
|
||||
### 接口
|
||||
- `GET /api/permissions`:仅管理员可用(全量)
|
||||
- `GET /api/permissions/me`:当前用户权限
|
||||
- `GET /api/permissions/tree`:管理员权限树
|
||||
- `GET /api/permissions/tree/me`:当前用户权限树
|
||||
|
||||
### 创建/更新校验
|
||||
- level=1 时清空 parentId
|
||||
- level=2 时 parentId 必须存在且为 level=1
|
||||
- button 权限必须填写 code
|
||||
- menu 权限 code 可选
|
||||
|
||||
## 6. 前端权限处理
|
||||
- 登录后调用 `/api/users/me` 获取 `isAdmin`
|
||||
- `isAdmin=true` 时前端不做权限限制
|
||||
- 非管理员通过 `/api/permissions/me` 获取权限码用于按钮控制
|
||||
- 菜单展示按权限树构建
|
||||
|
||||
## 7. 关键流程
|
||||
### 登录
|
||||
1. `/auth/captcha`
|
||||
2. `/auth/login`
|
||||
3. `/api/users/me`(获取用户信息与 isAdmin)
|
||||
|
||||
### 权限菜单渲染
|
||||
1. `/api/permissions/me` 获取权限列表
|
||||
2. 前端构建树形菜单
|
||||
|
||||
## 8. 约束与规范
|
||||
- 后端禁用 JPA/Hibernate
|
||||
- 统一响应 `ApiResponse<T>`
|
||||
- Controller 不直接调用 Mapper
|
||||
- 前端禁止在页面内直接调用 axios
|
||||
|
||||
## 9. 后续扩展建议
|
||||
- 添加审计日志落库策略
|
||||
- 任务管理模块完善
|
||||
- 权限树缓存与增量刷新策略
|
||||
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.imeeting</groupId>
|
||||
<artifactId>imeeting-backend</artifactId>
|
||||
<version>0.1.0</version>
|
||||
<name>imeeting-backend</name>
|
||||
<description>Admin and Web API for imeeting</description>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.2.2</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<mybatis-plus.version>3.5.6</mybatis-plus.version>
|
||||
<jjwt.version>0.11.5</jjwt.version>
|
||||
<easycaptcha.version>1.6.2</easycaptcha.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
<version>${mybatis-plus.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.whvcse</groupId>
|
||||
<artifactId>easy-captcha</artifactId>
|
||||
<version>${easycaptcha.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.imeeting;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class ImeetingApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ImeetingApplication.class, args);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.imeeting.auth;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
|
||||
@Component
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
|
||||
this.jwtTokenProvider = jwtTokenProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7);
|
||||
try {
|
||||
Claims claims = jwtTokenProvider.parseToken(token);
|
||||
String username = claims.get("username", String.class);
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(username, null, Collections.emptyList());
|
||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
} catch (Exception ignored) {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package com.imeeting.auth;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.SignatureAlgorithm;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.Key;
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class JwtTokenProvider {
|
||||
private final Key key;
|
||||
|
||||
public JwtTokenProvider(@Value("${security.jwt.secret}") String secret) {
|
||||
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public String createToken(Map<String, Object> claims, long ttlMillis) {
|
||||
Date now = new Date();
|
||||
Date exp = new Date(now.getTime() + ttlMillis);
|
||||
return Jwts.builder()
|
||||
.setClaims(claims)
|
||||
.setIssuedAt(now)
|
||||
.setExpiration(exp)
|
||||
.signWith(key, SignatureAlgorithm.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims parseToken(String token) {
|
||||
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.imeeting.auth.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class CaptchaResponse {
|
||||
private String captchaId;
|
||||
private String imageBase64;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.imeeting.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class DeviceCodeRequest {
|
||||
@NotBlank
|
||||
private String username;
|
||||
@NotBlank
|
||||
private String password;
|
||||
@NotBlank
|
||||
private String captchaId;
|
||||
@NotBlank
|
||||
private String captchaCode;
|
||||
private String deviceName;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.imeeting.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class LoginRequest {
|
||||
@NotBlank
|
||||
private String username;
|
||||
@NotBlank
|
||||
private String password;
|
||||
@NotBlank
|
||||
private String captchaId;
|
||||
@NotBlank
|
||||
private String captchaCode;
|
||||
private String deviceCode;
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.imeeting.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class RefreshRequest {
|
||||
@NotBlank
|
||||
private String refreshToken;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.imeeting.auth.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class TokenResponse {
|
||||
private String accessToken;
|
||||
private String refreshToken;
|
||||
private long accessExpiresInMinutes;
|
||||
private long refreshExpiresInDays;
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.imeeting.common;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ApiResponse<T> {
|
||||
private String code;
|
||||
private String msg;
|
||||
private T data;
|
||||
|
||||
public static <T> ApiResponse<T> ok(T data) {
|
||||
return new ApiResponse<>("0", "OK", data);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> error(String msg) {
|
||||
return new ApiResponse<>("-1", msg, null);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.imeeting.common;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ApiResponse<Void> handleIllegalArgument(IllegalArgumentException ex) {
|
||||
log.warn("Business error: {}", ex.getMessage());
|
||||
return ApiResponse.error(ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ApiResponse<Void> handleGeneric(Exception ex) {
|
||||
log.error("Unhandled exception", ex);
|
||||
return ApiResponse.error("系统异常");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.imeeting.common;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class PageResult<T> {
|
||||
private long total;
|
||||
private T records;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.imeeting.common;
|
||||
|
||||
public final class RedisKeys {
|
||||
private RedisKeys() {}
|
||||
|
||||
public static String captchaKey(String captchaId) {
|
||||
return "captcha:" + captchaId;
|
||||
}
|
||||
|
||||
public static String captchaAttemptsKey(String captchaId) {
|
||||
return "captcha:attempts:" + captchaId;
|
||||
}
|
||||
|
||||
public static String refreshTokenKey(Long userId, String deviceCode) {
|
||||
return "refresh:" + userId + ":" + deviceCode;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.imeeting.config;
|
||||
|
||||
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
|
||||
import org.apache.ibatis.reflection.MetaObject;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Configuration
|
||||
public class MybatisPlusConfig {
|
||||
@Bean
|
||||
public MetaObjectHandler metaObjectHandler() {
|
||||
return new MetaObjectHandler() {
|
||||
@Override
|
||||
public void insertFill(MetaObject metaObject) {
|
||||
strictInsertFill(metaObject, "createdAt", LocalDateTime::now, LocalDateTime.class);
|
||||
strictInsertFill(metaObject, "updatedAt", LocalDateTime::now, LocalDateTime.class);
|
||||
strictInsertFill(metaObject, "status", () -> 1, Integer.class);
|
||||
strictInsertFill(metaObject, "isDeleted", () -> 0, Integer.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateFill(MetaObject metaObject) {
|
||||
strictUpdateFill(metaObject, "updatedAt", LocalDateTime::now, LocalDateTime.class);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package com.imeeting.config;
|
||||
|
||||
import com.imeeting.auth.JwtAuthenticationFilter;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
public class SecurityConfig {
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
|
||||
http.csrf(csrf -> csrf.disable())
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/auth/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
|
||||
return configuration.getAuthenticationManager();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOriginPatterns(List.of("*"));
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
package com.imeeting.controller;
|
||||
|
||||
import com.imeeting.auth.JwtTokenProvider;
|
||||
import com.imeeting.auth.dto.CaptchaResponse;
|
||||
import com.imeeting.auth.dto.DeviceCodeRequest;
|
||||
import com.imeeting.auth.dto.LoginRequest;
|
||||
import com.imeeting.auth.dto.RefreshRequest;
|
||||
import com.imeeting.auth.dto.TokenResponse;
|
||||
import com.imeeting.common.ApiResponse;
|
||||
import com.imeeting.common.RedisKeys;
|
||||
import com.imeeting.service.AuthService;
|
||||
import com.wf.captcha.SpecCaptcha;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/auth")
|
||||
public class AuthController {
|
||||
private final AuthService authService;
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
@Value("${app.captcha.ttl-seconds:120}")
|
||||
private long captchaTtlSeconds;
|
||||
|
||||
public AuthController(AuthService authService, StringRedisTemplate stringRedisTemplate, JwtTokenProvider jwtTokenProvider) {
|
||||
this.authService = authService;
|
||||
this.stringRedisTemplate = stringRedisTemplate;
|
||||
this.jwtTokenProvider = jwtTokenProvider;
|
||||
}
|
||||
|
||||
@GetMapping("/captcha")
|
||||
public ApiResponse<CaptchaResponse> captcha() {
|
||||
SpecCaptcha captcha = new SpecCaptcha(130, 48, 4);
|
||||
String code = captcha.text();
|
||||
String imageBase64 = captcha.toBase64();
|
||||
String captchaId = UUID.randomUUID().toString().replace("-", "");
|
||||
|
||||
stringRedisTemplate.opsForValue().set(RedisKeys.captchaKey(captchaId), code, Duration.ofSeconds(captchaTtlSeconds));
|
||||
return ApiResponse.ok(new CaptchaResponse(captchaId, imageBase64));
|
||||
}
|
||||
|
||||
@PostMapping("/device-code")
|
||||
public ApiResponse<String> deviceCode(@Valid @RequestBody DeviceCodeRequest request) {
|
||||
LoginRequest loginRequest = new LoginRequest();
|
||||
loginRequest.setUsername(request.getUsername());
|
||||
loginRequest.setPassword(request.getPassword());
|
||||
loginRequest.setCaptchaId(request.getCaptchaId());
|
||||
loginRequest.setCaptchaCode(request.getCaptchaCode());
|
||||
String deviceCode = authService.createDeviceCode(loginRequest, request.getDeviceName());
|
||||
return ApiResponse.ok(deviceCode);
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<TokenResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||
return ApiResponse.ok(authService.login(request));
|
||||
}
|
||||
|
||||
@PostMapping("/refresh")
|
||||
public ApiResponse<TokenResponse> refresh(@Valid @RequestBody RefreshRequest request) {
|
||||
return ApiResponse.ok(authService.refresh(request.getRefreshToken()));
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
public ApiResponse<Void> logout(@RequestHeader("Authorization") String authorization) {
|
||||
String token = authorization.replace("Bearer ", "");
|
||||
var claims = jwtTokenProvider.parseToken(token);
|
||||
Long userId = claims.get("userId", Long.class);
|
||||
String deviceCode = claims.get("deviceCode", String.class);
|
||||
authService.logout(userId, deviceCode);
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.imeeting.controller;
|
||||
|
||||
import com.imeeting.common.ApiResponse;
|
||||
import com.imeeting.entity.Device;
|
||||
import com.imeeting.service.DeviceService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/devices")
|
||||
public class DeviceController {
|
||||
private final DeviceService deviceService;
|
||||
|
||||
public DeviceController(DeviceService deviceService) {
|
||||
this.deviceService = deviceService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<List<Device>> list() {
|
||||
return ApiResponse.ok(deviceService.list());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<Device> get(@PathVariable Long id) {
|
||||
return ApiResponse.ok(deviceService.getById(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<Boolean> create(@RequestBody Device device) {
|
||||
return ApiResponse.ok(deviceService.save(device));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody Device device) {
|
||||
device.setDeviceId(id);
|
||||
return ApiResponse.ok(deviceService.updateById(device));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ApiResponse<Boolean> delete(@PathVariable Long id) {
|
||||
return ApiResponse.ok(deviceService.removeById(id));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
package com.imeeting.controller;
|
||||
|
||||
import com.imeeting.auth.JwtTokenProvider;
|
||||
import com.imeeting.common.ApiResponse;
|
||||
import com.imeeting.dto.PermissionNode;
|
||||
import com.imeeting.entity.SysPermission;
|
||||
import com.imeeting.service.SysPermissionService;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/permissions")
|
||||
public class PermissionController {
|
||||
private final SysPermissionService sysPermissionService;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
public PermissionController(SysPermissionService sysPermissionService, JwtTokenProvider jwtTokenProvider) {
|
||||
this.sysPermissionService = sysPermissionService;
|
||||
this.jwtTokenProvider = jwtTokenProvider;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<List<SysPermission>> list(@RequestHeader("Authorization") String authorization) {
|
||||
Long userId = resolveUserId(authorization);
|
||||
if (userId == null || userId != 1L) {
|
||||
return ApiResponse.error("Forbidden");
|
||||
}
|
||||
return ApiResponse.ok(sysPermissionService.list());
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public ApiResponse<List<SysPermission>> myPermissions(@RequestHeader("Authorization") String authorization) {
|
||||
Long userId = resolveUserId(authorization);
|
||||
return ApiResponse.ok(sysPermissionService.listByUserId(userId));
|
||||
}
|
||||
|
||||
@GetMapping("/tree")
|
||||
public ApiResponse<List<PermissionNode>> tree(@RequestHeader("Authorization") String authorization) {
|
||||
Long userId = resolveUserId(authorization);
|
||||
if (userId == null || userId != 1L) {
|
||||
return ApiResponse.error("Forbidden");
|
||||
}
|
||||
return ApiResponse.ok(buildTree(sysPermissionService.list()));
|
||||
}
|
||||
|
||||
@GetMapping("/tree/me")
|
||||
public ApiResponse<List<PermissionNode>> myTree(@RequestHeader("Authorization") String authorization) {
|
||||
Long userId = resolveUserId(authorization);
|
||||
return ApiResponse.ok(buildTree(sysPermissionService.listByUserId(userId)));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<SysPermission> get(@PathVariable Long id) {
|
||||
return ApiResponse.ok(sysPermissionService.getById(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<Boolean> create(@RequestBody SysPermission perm) {
|
||||
String error = validateParent(perm);
|
||||
if (error != null) {
|
||||
return ApiResponse.error(error);
|
||||
}
|
||||
return ApiResponse.ok(sysPermissionService.save(perm));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysPermission perm) {
|
||||
perm.setPermId(id);
|
||||
String error = validateParent(perm);
|
||||
if (error != null) {
|
||||
return ApiResponse.error(error);
|
||||
}
|
||||
boolean updated = sysPermissionService.updateById(perm);
|
||||
if (perm.getLevel() != null && perm.getLevel() == 1) {
|
||||
sysPermissionService.lambdaUpdate()
|
||||
.set(SysPermission::getParentId, null)
|
||||
.eq(SysPermission::getPermId, id)
|
||||
.update();
|
||||
}
|
||||
return ApiResponse.ok(updated);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ApiResponse<Boolean> delete(@PathVariable Long id) {
|
||||
return ApiResponse.ok(sysPermissionService.removeById(id));
|
||||
}
|
||||
|
||||
private Long resolveUserId(String authorization) {
|
||||
if (authorization == null || !authorization.startsWith("Bearer ")) {
|
||||
return null;
|
||||
}
|
||||
String token = authorization.substring(7);
|
||||
Claims claims = jwtTokenProvider.parseToken(token);
|
||||
return claims.get("userId", Long.class);
|
||||
}
|
||||
|
||||
private String validateParent(SysPermission perm) {
|
||||
if (perm.getLevel() == null) {
|
||||
return null;
|
||||
}
|
||||
if (perm.getPermType() != null && "button".equalsIgnoreCase(perm.getPermType())) {
|
||||
if (perm.getCode() == null || perm.getCode().trim().isEmpty()) {
|
||||
return "Code required for button permission";
|
||||
}
|
||||
}
|
||||
if (perm.getLevel() == 1) {
|
||||
perm.setParentId(null);
|
||||
return null;
|
||||
}
|
||||
if (perm.getLevel() == 2) {
|
||||
if (perm.getParentId() == null) {
|
||||
return "ParentId required for level 2";
|
||||
}
|
||||
SysPermission parent = sysPermissionService.getById(perm.getParentId());
|
||||
if (parent == null) {
|
||||
return "Parent not found";
|
||||
}
|
||||
if (parent.getLevel() == null || parent.getLevel() != 1) {
|
||||
return "Parent must be level 1";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<PermissionNode> buildTree(List<SysPermission> list) {
|
||||
Map<Long, PermissionNode> map = new HashMap<>();
|
||||
List<PermissionNode> roots = new ArrayList<>();
|
||||
for (SysPermission p : list) {
|
||||
PermissionNode node = toNode(p);
|
||||
map.put(node.getPermId(), node);
|
||||
}
|
||||
for (PermissionNode node : map.values()) {
|
||||
Long parentId = node.getParentId();
|
||||
if (parentId != null && map.containsKey(parentId)) {
|
||||
map.get(parentId).getChildren().add(node);
|
||||
} else {
|
||||
roots.add(node);
|
||||
}
|
||||
}
|
||||
sortTree(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
private void sortTree(List<PermissionNode> nodes) {
|
||||
nodes.sort(Comparator.comparingInt(n -> n.getSortOrder() == null ? 0 : n.getSortOrder()));
|
||||
for (PermissionNode node : nodes) {
|
||||
if (node.getChildren() != null && !node.getChildren().isEmpty()) {
|
||||
sortTree(node.getChildren());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private PermissionNode toNode(SysPermission p) {
|
||||
PermissionNode node = new PermissionNode();
|
||||
node.setPermId(p.getPermId());
|
||||
node.setParentId(p.getParentId());
|
||||
node.setName(p.getName());
|
||||
node.setCode(p.getCode());
|
||||
node.setPermType(p.getPermType());
|
||||
node.setLevel(p.getLevel());
|
||||
node.setPath(p.getPath());
|
||||
node.setComponent(p.getComponent());
|
||||
node.setIcon(p.getIcon());
|
||||
node.setSortOrder(p.getSortOrder());
|
||||
node.setIsVisible(p.getIsVisible());
|
||||
node.setStatus(p.getStatus());
|
||||
node.setDescription(p.getDescription());
|
||||
node.setMeta(p.getMeta());
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.imeeting.controller;
|
||||
|
||||
import com.imeeting.common.ApiResponse;
|
||||
import com.imeeting.entity.SysRole;
|
||||
import com.imeeting.service.SysRoleService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/roles")
|
||||
public class RoleController {
|
||||
private final SysRoleService sysRoleService;
|
||||
|
||||
public RoleController(SysRoleService sysRoleService) {
|
||||
this.sysRoleService = sysRoleService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<List<SysRole>> list() {
|
||||
return ApiResponse.ok(sysRoleService.list());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<SysRole> get(@PathVariable Long id) {
|
||||
return ApiResponse.ok(sysRoleService.getById(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<Boolean> create(@RequestBody SysRole role) {
|
||||
return ApiResponse.ok(sysRoleService.save(role));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysRole role) {
|
||||
role.setRoleId(id);
|
||||
return ApiResponse.ok(sysRoleService.updateById(role));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ApiResponse<Boolean> delete(@PathVariable Long id) {
|
||||
return ApiResponse.ok(sysRoleService.removeById(id));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.imeeting.controller;
|
||||
|
||||
import com.imeeting.common.ApiResponse;
|
||||
import com.imeeting.entity.SysParam;
|
||||
import com.imeeting.service.SysParamService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/params")
|
||||
public class SysParamController {
|
||||
private final SysParamService sysParamService;
|
||||
|
||||
public SysParamController(SysParamService sysParamService) {
|
||||
this.sysParamService = sysParamService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<List<SysParam>> list() {
|
||||
return ApiResponse.ok(sysParamService.list());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<SysParam> get(@PathVariable Long id) {
|
||||
return ApiResponse.ok(sysParamService.getById(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<Boolean> create(@RequestBody SysParam param) {
|
||||
return ApiResponse.ok(sysParamService.save(param));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysParam param) {
|
||||
param.setParamId(id);
|
||||
return ApiResponse.ok(sysParamService.updateById(param));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ApiResponse<Boolean> delete(@PathVariable Long id) {
|
||||
return ApiResponse.ok(sysParamService.removeById(id));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package com.imeeting.controller;
|
||||
|
||||
import com.imeeting.auth.JwtTokenProvider;
|
||||
import com.imeeting.common.ApiResponse;
|
||||
import com.imeeting.dto.UserProfile;
|
||||
import com.imeeting.entity.SysUser;
|
||||
import com.imeeting.service.SysUserService;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
public class UserController {
|
||||
private final SysUserService sysUserService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
public UserController(SysUserService sysUserService, PasswordEncoder passwordEncoder, JwtTokenProvider jwtTokenProvider) {
|
||||
this.sysUserService = sysUserService;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtTokenProvider = jwtTokenProvider;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<List<SysUser>> list() {
|
||||
return ApiResponse.ok(sysUserService.list());
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public ApiResponse<UserProfile> me(@RequestHeader("Authorization") String authorization) {
|
||||
Long userId = resolveUserId(authorization);
|
||||
if (userId == null) {
|
||||
return ApiResponse.error("Unauthorized");
|
||||
}
|
||||
SysUser user = sysUserService.getById(userId);
|
||||
if (user == null) {
|
||||
return ApiResponse.error("User not found");
|
||||
}
|
||||
UserProfile profile = new UserProfile();
|
||||
profile.setUserId(user.getUserId());
|
||||
profile.setUsername(user.getUsername());
|
||||
profile.setDisplayName(user.getDisplayName());
|
||||
profile.setEmail(user.getEmail());
|
||||
profile.setPhone(user.getPhone());
|
||||
profile.setStatus(user.getStatus());
|
||||
profile.setAdmin(userId == 1L);
|
||||
return ApiResponse.ok(profile);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<SysUser> get(@PathVariable Long id) {
|
||||
return ApiResponse.ok(sysUserService.getById(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<Boolean> create(@RequestBody SysUser user) {
|
||||
if (user.getPasswordHash() != null && !user.getPasswordHash().isEmpty()) {
|
||||
user.setPasswordHash(passwordEncoder.encode(user.getPasswordHash()));
|
||||
}
|
||||
return ApiResponse.ok(sysUserService.save(user));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysUser user) {
|
||||
user.setUserId(id);
|
||||
if (user.getPasswordHash() != null && !user.getPasswordHash().isEmpty()) {
|
||||
user.setPasswordHash(passwordEncoder.encode(user.getPasswordHash()));
|
||||
}
|
||||
return ApiResponse.ok(sysUserService.updateById(user));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ApiResponse<Boolean> delete(@PathVariable Long id) {
|
||||
return ApiResponse.ok(sysUserService.removeById(id));
|
||||
}
|
||||
|
||||
private Long resolveUserId(String authorization) {
|
||||
if (authorization == null || !authorization.startsWith("Bearer ")) {
|
||||
return null;
|
||||
}
|
||||
String token = authorization.substring(7);
|
||||
Claims claims = jwtTokenProvider.parseToken(token);
|
||||
return claims.get("userId", Long.class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package com.imeeting.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class PermissionNode {
|
||||
private Long permId;
|
||||
private Long parentId;
|
||||
private String name;
|
||||
private String code;
|
||||
private String permType;
|
||||
private Integer level;
|
||||
private String path;
|
||||
private String component;
|
||||
private String icon;
|
||||
private Integer sortOrder;
|
||||
private Integer isVisible;
|
||||
private Integer status;
|
||||
private String description;
|
||||
private String meta;
|
||||
private List<PermissionNode> children = new ArrayList<>();
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.imeeting.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class UserProfile {
|
||||
private Long userId;
|
||||
private String username;
|
||||
private String displayName;
|
||||
private String email;
|
||||
private String phone;
|
||||
private Integer status;
|
||||
private boolean isAdmin;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.imeeting.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class BaseEntity {
|
||||
private Long tenantId;
|
||||
private Integer status;
|
||||
private Integer isDeleted;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.imeeting.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("device_info")
|
||||
public class Device extends BaseEntity {
|
||||
@TableId(value = "device_id", type = IdType.AUTO)
|
||||
private Long deviceId;
|
||||
private Long userId;
|
||||
private String deviceCode;
|
||||
private String deviceName;
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.imeeting.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("sys_param")
|
||||
public class SysParam extends BaseEntity {
|
||||
@TableId(value = "param_id", type = IdType.AUTO)
|
||||
private Long paramId;
|
||||
private String paramKey;
|
||||
private String paramValue;
|
||||
private String paramType;
|
||||
private Integer isSystem;
|
||||
private String description;
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package com.imeeting.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("sys_permission")
|
||||
public class SysPermission extends BaseEntity {
|
||||
@TableId(value = "perm_id", type = IdType.AUTO)
|
||||
private Long permId;
|
||||
private Long parentId;
|
||||
private String name;
|
||||
private String code;
|
||||
private String permType;
|
||||
private Integer level;
|
||||
private String path;
|
||||
private String component;
|
||||
private String icon;
|
||||
private Integer sortOrder;
|
||||
private Integer isVisible;
|
||||
private String description;
|
||||
private String meta;
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.imeeting.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("sys_role")
|
||||
public class SysRole extends BaseEntity {
|
||||
@TableId(value = "role_id", type = IdType.AUTO)
|
||||
private Long roleId;
|
||||
private String roleCode;
|
||||
private String roleName;
|
||||
private String remark;
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.imeeting.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("sys_role_permission")
|
||||
public class SysRolePermission extends BaseEntity {
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long roleId;
|
||||
private Long permId;
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.imeeting.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("sys_user")
|
||||
public class SysUser extends BaseEntity {
|
||||
@TableId(value = "user_id", type = IdType.AUTO)
|
||||
private Long userId;
|
||||
private String username;
|
||||
private String displayName;
|
||||
private String email;
|
||||
private String phone;
|
||||
private String passwordHash;
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.imeeting.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("sys_user_role")
|
||||
public class SysUserRole extends BaseEntity {
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Long roleId;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.imeeting.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.imeeting.entity.Device;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface DeviceMapper extends BaseMapper<Device> {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.imeeting.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.imeeting.entity.SysParam;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface SysParamMapper extends BaseMapper<SysParam> {}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.imeeting.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.imeeting.entity.SysPermission;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface SysPermissionMapper extends BaseMapper<SysPermission> {
|
||||
@Select("""
|
||||
SELECT DISTINCT p.*
|
||||
FROM sys_permission p
|
||||
JOIN sys_role_permission rp ON rp.perm_id = p.perm_id
|
||||
JOIN sys_user_role ur ON ur.role_id = rp.role_id
|
||||
WHERE ur.user_id = #{userId}
|
||||
""")
|
||||
List<SysPermission> selectByUserId(@Param("userId") Long userId);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.imeeting.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.imeeting.entity.SysRole;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface SysRoleMapper extends BaseMapper<SysRole> {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.imeeting.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.imeeting.entity.SysRolePermission;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface SysRolePermissionMapper extends BaseMapper<SysRolePermission> {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.imeeting.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.imeeting.entity.SysUser;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface SysUserMapper extends BaseMapper<SysUser> {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.imeeting.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.imeeting.entity.SysUserRole;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface SysUserRoleMapper extends BaseMapper<SysUserRole> {}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.imeeting.service;
|
||||
|
||||
import com.imeeting.auth.dto.LoginRequest;
|
||||
import com.imeeting.auth.dto.TokenResponse;
|
||||
|
||||
public interface AuthService {
|
||||
TokenResponse login(LoginRequest request);
|
||||
TokenResponse refresh(String refreshToken);
|
||||
void logout(Long userId, String deviceCode);
|
||||
String createDeviceCode(LoginRequest request, String deviceName);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.imeeting.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.imeeting.entity.Device;
|
||||
|
||||
public interface DeviceService extends IService<Device> {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.imeeting.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.imeeting.entity.SysParam;
|
||||
|
||||
public interface SysParamService extends IService<SysParam> {
|
||||
String getParamValue(String key, String defaultValue);
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.imeeting.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.imeeting.entity.SysPermission;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface SysPermissionService extends IService<SysPermission> {
|
||||
List<SysPermission> listByUserId(Long userId);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.imeeting.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.imeeting.entity.SysRole;
|
||||
|
||||
public interface SysRoleService extends IService<SysRole> {}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.imeeting.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.imeeting.entity.SysUser;
|
||||
|
||||
public interface SysUserService extends IService<SysUser> {}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
package com.imeeting.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.imeeting.auth.JwtTokenProvider;
|
||||
import com.imeeting.auth.dto.LoginRequest;
|
||||
import com.imeeting.auth.dto.TokenResponse;
|
||||
import com.imeeting.common.RedisKeys;
|
||||
import com.imeeting.entity.Device;
|
||||
import com.imeeting.entity.SysUser;
|
||||
import com.imeeting.service.AuthService;
|
||||
import com.imeeting.service.DeviceService;
|
||||
import com.imeeting.service.SysParamService;
|
||||
import com.imeeting.service.SysUserService;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class AuthServiceImpl implements AuthService {
|
||||
private final SysUserService sysUserService;
|
||||
private final DeviceService deviceService;
|
||||
private final SysParamService sysParamService;
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
@Value("${app.token.access-default-minutes:30}")
|
||||
private long accessDefaultMinutes;
|
||||
@Value("${app.token.refresh-default-days:7}")
|
||||
private long refreshDefaultDays;
|
||||
@Value("${app.captcha.max-attempts:5}")
|
||||
private int captchaMaxAttempts;
|
||||
|
||||
public AuthServiceImpl(SysUserService sysUserService, DeviceService deviceService, SysParamService sysParamService,
|
||||
StringRedisTemplate stringRedisTemplate, PasswordEncoder passwordEncoder,
|
||||
JwtTokenProvider jwtTokenProvider) {
|
||||
this.sysUserService = sysUserService;
|
||||
this.deviceService = deviceService;
|
||||
this.sysParamService = sysParamService;
|
||||
this.stringRedisTemplate = stringRedisTemplate;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtTokenProvider = jwtTokenProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TokenResponse login(LoginRequest request) {
|
||||
validateCaptcha(request.getCaptchaId(), request.getCaptchaCode());
|
||||
|
||||
SysUser user = sysUserService.getOne(new LambdaQueryWrapper<SysUser>()
|
||||
.eq(SysUser::getUsername, request.getUsername())
|
||||
.eq(SysUser::getIsDeleted, 0)
|
||||
.eq(SysUser::getStatus, 1));
|
||||
if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
|
||||
throw new IllegalArgumentException("用户名或密码错误");
|
||||
}
|
||||
|
||||
String deviceCode = request.getDeviceCode();
|
||||
if (deviceCode != null && !deviceCode.isEmpty()) {
|
||||
Device device = deviceService.getOne(new LambdaQueryWrapper<Device>()
|
||||
.eq(Device::getUserId, user.getUserId())
|
||||
.eq(Device::getDeviceCode, deviceCode)
|
||||
.eq(Device::getIsDeleted, 0)
|
||||
.eq(Device::getStatus, 1));
|
||||
if (device == null) {
|
||||
throw new IllegalArgumentException("设备码无效");
|
||||
}
|
||||
}
|
||||
|
||||
long accessMinutes = parseLong(sysParamService.getParamValue("security.token.access_ttl_minutes",
|
||||
String.valueOf(accessDefaultMinutes)), accessDefaultMinutes);
|
||||
long refreshDays = parseLong(sysParamService.getParamValue("security.token.refresh_ttl_days",
|
||||
String.valueOf(refreshDefaultDays)), refreshDefaultDays);
|
||||
|
||||
if (deviceCode == null || deviceCode.isEmpty()) {
|
||||
deviceCode = "default";
|
||||
}
|
||||
TokenResponse tokens = issueTokens(user, deviceCode, accessMinutes, refreshDays);
|
||||
cacheRefreshToken(user.getUserId(), deviceCode, tokens.getRefreshToken(), refreshDays);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TokenResponse refresh(String refreshToken) {
|
||||
Claims claims = jwtTokenProvider.parseToken(refreshToken);
|
||||
String tokenType = claims.get("tokenType", String.class);
|
||||
if (!"refresh".equals(tokenType)) {
|
||||
throw new IllegalArgumentException("无效的刷新令牌");
|
||||
}
|
||||
Long userId = claims.get("userId", Long.class);
|
||||
String deviceCode = claims.get("deviceCode", String.class);
|
||||
String cached = stringRedisTemplate.opsForValue().get(RedisKeys.refreshTokenKey(userId, deviceCode));
|
||||
if (cached == null || !cached.equals(refreshToken)) {
|
||||
throw new IllegalArgumentException("刷新令牌已失效");
|
||||
}
|
||||
|
||||
long accessMinutes = parseLong(sysParamService.getParamValue("security.token.access_ttl_minutes",
|
||||
String.valueOf(accessDefaultMinutes)), accessDefaultMinutes);
|
||||
long refreshDays = parseLong(sysParamService.getParamValue("security.token.refresh_ttl_days",
|
||||
String.valueOf(refreshDefaultDays)), refreshDefaultDays);
|
||||
|
||||
SysUser user = sysUserService.getById(userId);
|
||||
TokenResponse tokens = issueTokens(user, deviceCode, accessMinutes, refreshDays);
|
||||
cacheRefreshToken(userId, deviceCode, tokens.getRefreshToken(), refreshDays);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void logout(Long userId, String deviceCode) {
|
||||
stringRedisTemplate.delete(RedisKeys.refreshTokenKey(userId, deviceCode));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String createDeviceCode(LoginRequest request, String deviceName) {
|
||||
validateCaptcha(request.getCaptchaId(), request.getCaptchaCode());
|
||||
|
||||
SysUser user = sysUserService.getOne(new LambdaQueryWrapper<SysUser>()
|
||||
.eq(SysUser::getUsername, request.getUsername())
|
||||
.eq(SysUser::getIsDeleted, 0)
|
||||
.eq(SysUser::getStatus, 1));
|
||||
if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
|
||||
throw new IllegalArgumentException("用户名或密码错误");
|
||||
}
|
||||
|
||||
String deviceCode = UUID.randomUUID().toString().replace("-", "");
|
||||
Device device = new Device();
|
||||
device.setUserId(user.getUserId());
|
||||
device.setDeviceCode(deviceCode);
|
||||
device.setDeviceName(deviceName == null ? "default" : deviceName);
|
||||
deviceService.save(device);
|
||||
return deviceCode;
|
||||
}
|
||||
|
||||
private void validateCaptcha(String captchaId, String captchaCode) {
|
||||
String key = RedisKeys.captchaKey(captchaId);
|
||||
String stored = stringRedisTemplate.opsForValue().get(key);
|
||||
if (stored == null) {
|
||||
throw new IllegalArgumentException("验证码已过期");
|
||||
}
|
||||
|
||||
String attemptsKey = RedisKeys.captchaAttemptsKey(captchaId);
|
||||
long attempts = 0;
|
||||
String attemptsStr = stringRedisTemplate.opsForValue().get(attemptsKey);
|
||||
if (attemptsStr != null) {
|
||||
attempts = Long.parseLong(attemptsStr);
|
||||
}
|
||||
if (attempts >= captchaMaxAttempts) {
|
||||
throw new IllegalArgumentException("验证码已失效");
|
||||
}
|
||||
|
||||
if (!stored.equalsIgnoreCase(captchaCode)) {
|
||||
stringRedisTemplate.opsForValue().increment(attemptsKey);
|
||||
stringRedisTemplate.expire(attemptsKey, Duration.ofMinutes(2));
|
||||
throw new IllegalArgumentException("验证码错误");
|
||||
}
|
||||
|
||||
stringRedisTemplate.delete(key);
|
||||
stringRedisTemplate.delete(attemptsKey);
|
||||
}
|
||||
|
||||
private TokenResponse issueTokens(SysUser user, String deviceCode, long accessMinutes, long refreshDays) {
|
||||
Map<String, Object> accessClaims = new HashMap<>();
|
||||
accessClaims.put("tokenType", "access");
|
||||
accessClaims.put("userId", user.getUserId());
|
||||
accessClaims.put("username", user.getUsername());
|
||||
accessClaims.put("deviceCode", deviceCode);
|
||||
|
||||
Map<String, Object> refreshClaims = new HashMap<>();
|
||||
refreshClaims.put("tokenType", "refresh");
|
||||
refreshClaims.put("userId", user.getUserId());
|
||||
refreshClaims.put("deviceCode", deviceCode);
|
||||
|
||||
String access = jwtTokenProvider.createToken(accessClaims, Duration.ofMinutes(accessMinutes).toMillis());
|
||||
String refresh = jwtTokenProvider.createToken(refreshClaims, Duration.ofDays(refreshDays).toMillis());
|
||||
return new TokenResponse(access, refresh, accessMinutes, refreshDays);
|
||||
}
|
||||
|
||||
private void cacheRefreshToken(Long userId, String deviceCode, String refreshToken, long refreshDays) {
|
||||
stringRedisTemplate.opsForValue().set(RedisKeys.refreshTokenKey(userId, deviceCode),
|
||||
refreshToken, Duration.ofDays(refreshDays));
|
||||
}
|
||||
|
||||
private long parseLong(String value, long defaultValue) {
|
||||
try {
|
||||
return Long.parseLong(value);
|
||||
} catch (Exception e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.imeeting.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.imeeting.entity.Device;
|
||||
import com.imeeting.mapper.DeviceMapper;
|
||||
import com.imeeting.service.DeviceService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> implements DeviceService {}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.imeeting.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.imeeting.entity.SysParam;
|
||||
import com.imeeting.mapper.SysParamMapper;
|
||||
import com.imeeting.service.SysParamService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class SysParamServiceImpl extends ServiceImpl<SysParamMapper, SysParam> implements SysParamService {
|
||||
@Override
|
||||
public String getParamValue(String key, String defaultValue) {
|
||||
SysParam param = getOne(new LambdaQueryWrapper<SysParam>().eq(SysParam::getParamKey, key));
|
||||
return param == null ? defaultValue : param.getParamValue();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.imeeting.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.imeeting.entity.SysPermission;
|
||||
import com.imeeting.mapper.SysPermissionMapper;
|
||||
import com.imeeting.service.SysPermissionService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class SysPermissionServiceImpl extends ServiceImpl<SysPermissionMapper, SysPermission> implements SysPermissionService {
|
||||
@Override
|
||||
public List<SysPermission> listByUserId(Long userId) {
|
||||
if (userId == null) {
|
||||
return List.of();
|
||||
}
|
||||
if (userId != null && userId == 1L) {
|
||||
return list();
|
||||
}
|
||||
return baseMapper.selectByUserId(userId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.imeeting.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.imeeting.entity.SysRole;
|
||||
import com.imeeting.mapper.SysRoleMapper;
|
||||
import com.imeeting.service.SysRoleService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class SysRoleServiceImpl extends ServiceImpl<SysRoleMapper, SysRole> implements SysRoleService {}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.imeeting.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.imeeting.entity.SysUser;
|
||||
import com.imeeting.mapper.SysUserMapper;
|
||||
import com.imeeting.service.SysUserService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements SysUserService {}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
server:
|
||||
port: 8080
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:postgresql://10.100.51.51:5432/imeeting
|
||||
username: postgres
|
||||
password: Unis@123
|
||||
data:
|
||||
redis:
|
||||
host: 10.100.51.51
|
||||
port: 6379
|
||||
password: Unis@123
|
||||
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
|
||||
security:
|
||||
jwt:
|
||||
secret: change-me-please-change-me-32bytes
|
||||
|
||||
app:
|
||||
captcha:
|
||||
ttl-seconds: 120
|
||||
max-attempts: 5
|
||||
token:
|
||||
access-default-minutes: 30
|
||||
refresh-default-days: 7
|
||||
|
|
@ -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,113 @@
|
|||
import { Drawer, Button, Space, Tabs } from "antd";
|
||||
import { CloseOutlined } from "@ant-design/icons";
|
||||
import type { ReactNode } from "react";
|
||||
import "./DetailDrawer.css";
|
||||
|
||||
type DrawerTitle = {
|
||||
text: string;
|
||||
badge?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
type HeaderAction = {
|
||||
key: string;
|
||||
label: string;
|
||||
type?: "default" | "primary" | "dashed" | "link" | "text";
|
||||
icon?: ReactNode;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
type DrawerTab = {
|
||||
key: string;
|
||||
label: ReactNode;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
interface DetailDrawerProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
title?: DrawerTitle;
|
||||
headerActions?: HeaderAction[];
|
||||
width?: number;
|
||||
children?: ReactNode;
|
||||
tabs?: DrawerTab[];
|
||||
}
|
||||
|
||||
function DetailDrawer({
|
||||
visible,
|
||||
onClose,
|
||||
title,
|
||||
headerActions = [],
|
||||
width = 1080,
|
||||
children,
|
||||
tabs,
|
||||
}: DetailDrawerProps) {
|
||||
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,96 @@
|
|||
.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: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.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: var(--bg-color-secondary);
|
||||
border: 1px solid var(--link-color);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.selection-count {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.selection-count strong {
|
||||
color: var(--link-color);
|
||||
font-weight: 600;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.all-pages-tag {
|
||||
color: var(--link-color);
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.select-all-link,
|
||||
.clear-selection-link {
|
||||
color: var(--link-color);
|
||||
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,51 @@
|
|||
/* 列表表格容器 */
|
||||
.list-table-container {
|
||||
background: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
height: 626px;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* 行选中样式 */
|
||||
.list-table-container .row-selected {
|
||||
background-color: var(--item-hover-bg);
|
||||
}
|
||||
|
||||
.list-table-container .row-selected:hover > td {
|
||||
background-color: var(--item-hover-bg) !important;
|
||||
}
|
||||
|
||||
/* 分页器中的选择信息样式 */
|
||||
.table-selection-info {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.selection-count {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.count-highlight {
|
||||
color: var(--link-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.selection-action {
|
||||
color: var(--link-color);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.selection-action:hover {
|
||||
color: var(--link-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { Table } from "antd";
|
||||
import type { ColumnsType, TablePaginationConfig, TableRowSelection } from "antd/es/table";
|
||||
import "./ListTable.css";
|
||||
|
||||
export type ListTableProps<T extends Record<string, any>> = {
|
||||
columns: ColumnsType<T>;
|
||||
dataSource: T[];
|
||||
rowKey?: string;
|
||||
selectedRowKeys?: React.Key[];
|
||||
onSelectionChange?: (keys: React.Key[]) => void;
|
||||
isAllPagesSelected?: boolean;
|
||||
totalCount?: number;
|
||||
onSelectAllPages?: () => void;
|
||||
onClearSelection?: () => void;
|
||||
pagination?: TablePaginationConfig | false;
|
||||
scroll?: { x?: number | true | string };
|
||||
onRowClick?: (record: T) => void;
|
||||
selectedRow?: T | null;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function ListTable<T extends Record<string, any>>({
|
||||
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 = "",
|
||||
}: ListTableProps<T>) {
|
||||
const rowSelection: TableRowSelection<T> | undefined = onSelectionChange
|
||||
? {
|
||||
selectedRowKeys,
|
||||
onChange: (newSelectedRowKeys) => {
|
||||
onSelectionChange?.(newSelectedRowKeys);
|
||||
},
|
||||
getCheckboxProps: () => ({
|
||||
disabled: isAllPagesSelected,
|
||||
}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const mergedPagination =
|
||||
pagination === false
|
||||
? false
|
||||
: {
|
||||
...pagination,
|
||||
showTotal: (total: number) => (
|
||||
<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,154 @@
|
|||
.app-header {
|
||||
background: var(--header-bg);
|
||||
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 var(--border-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* 左侧区域 */
|
||||
.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;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-color-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trigger:hover {
|
||||
color: #1677ff;
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
}
|
||||
|
||||
/* 右侧区域 */
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Icon Buttons */
|
||||
.header-icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text-color-secondary);
|
||||
transition: all 0.2s;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.header-icon-btn:hover {
|
||||
background-color: var(--item-hover-bg);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* 通知面板样式 */
|
||||
.header-notification-popover .ant-popover-inner-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.notification-popover {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.popover-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.popover-header .title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
padding: 12px 16px !important;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
background: var(--item-hover-bg);
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
/* Dark mode adjustment for unread */
|
||||
body.dark .notification-item.unread {
|
||||
background: #111d2c;
|
||||
}
|
||||
|
||||
.notification-item.unread:hover {
|
||||
background: #bae7ff;
|
||||
}
|
||||
|
||||
body.dark .notification-item.unread:hover {
|
||||
background: #112a45;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.8;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.popover-footer {
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Layout, Badge, Avatar, Dropdown, Space, Popover, List, Tabs, Button, Empty, Typography, Segmented, Tooltip } from 'antd'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
BellOutlined,
|
||||
ProjectOutlined,
|
||||
TeamOutlined,
|
||||
NotificationOutlined,
|
||||
MoonOutlined,
|
||||
SunOutlined,
|
||||
GlobalOutlined
|
||||
} from '@ant-design/icons'
|
||||
import useUserStore from '@/stores/userStore'
|
||||
import useNotificationStore from '@/stores/notificationStore'
|
||||
import useThemeStore from '@/stores/themeStore'
|
||||
import { getNotifications, getUnreadCount, markAsRead, markAllAsRead } from '@/api/notification'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import './AppHeader.css'
|
||||
|
||||
const { Header } = Layout
|
||||
const { Text } = Typography
|
||||
|
||||
function AppHeader({ collapsed, onToggle, showLogo = true }) {
|
||||
const navigate = useNavigate()
|
||||
const { user } = useUserStore()
|
||||
const { unreadCount, fetchUnreadCount, decrementUnreadCount, resetUnreadCount } = useNotificationStore()
|
||||
const { isDarkMode, toggleTheme } = useThemeStore()
|
||||
|
||||
const [notifications, setNotifications] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [popoverVisible, setPopoverVisible] = useState(false)
|
||||
const [lang, setLang] = useState('zh')
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchUnreadCount()
|
||||
const timer = setInterval(fetchUnreadCount, 120000)
|
||||
return () => clearInterval(timer)
|
||||
}
|
||||
}, [user])
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getNotifications({ page: 1, page_size: 5 })
|
||||
setNotifications(res.data || [])
|
||||
} catch (error) {
|
||||
console.error('Fetch notifications error:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMarkRead = async (id) => {
|
||||
try {
|
||||
await markAsRead(id)
|
||||
setNotifications(notifications.map(n => n.id === id ? { ...n, is_read: true } : n))
|
||||
decrementUnreadCount()
|
||||
} catch (error) {
|
||||
console.error('Mark read error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
await markAllAsRead()
|
||||
setNotifications(notifications.map(n => ({ ...n, is_read: true })))
|
||||
resetUnreadCount()
|
||||
Toast.success('操作成功', '所有通知已标记为已读')
|
||||
} catch (error) {
|
||||
console.error('Mark all read error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNotificationClick = (n) => {
|
||||
if (!n.is_read) {
|
||||
handleMarkRead(n.id)
|
||||
}
|
||||
if (n.link) {
|
||||
navigate(n.link)
|
||||
setPopoverVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getCategoryIcon = (category) => {
|
||||
switch (category) {
|
||||
case 'project': return <ProjectOutlined style={{ color: '#1890ff' }} />
|
||||
case 'collaboration': return <TeamOutlined style={{ color: '#52c41a' }} />
|
||||
default: return <NotificationOutlined style={{ color: '#faad14' }} />
|
||||
}
|
||||
}
|
||||
|
||||
const notificationContent = (
|
||||
<div className="notification-popover">
|
||||
<div className="popover-header">
|
||||
<span className="title">消息通知</span>
|
||||
{unreadCount > 0 && (
|
||||
<Button type="link" size="small" onClick={handleMarkAllRead}>
|
||||
全部已读
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<List
|
||||
className="notification-list"
|
||||
loading={loading}
|
||||
itemLayout="horizontal"
|
||||
dataSource={notifications}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无新消息" /> }}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
className={`notification-item ${!item.is_read ? 'unread' : ''}`}
|
||||
onClick={() => handleNotificationClick(item)}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar icon={getCategoryIcon(item.category)} />}
|
||||
title={<Text strong={!item.is_read}>{item.title}</Text>}
|
||||
description={
|
||||
<div>
|
||||
<div className="content-text">{item.content}</div>
|
||||
<div className="time">{new Date(item.created_at).toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
<div className="popover-footer">
|
||||
<Button type="link" block onClick={() => { navigate('/notifications'); setPopoverVisible(false); }}>
|
||||
查看全部消息
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Header className="app-header" style={{ paddingLeft: showLogo ? 24 : 0 }}>
|
||||
{/* 左侧:Logo + 折叠按钮 */}
|
||||
{showLogo && (
|
||||
<div className="header-left">
|
||||
{/* Logo 区域 */}
|
||||
<div className="header-logo">
|
||||
<img src="/favicon.svg" alt="logo" style={{ width: 32, height: 32, marginRight: 8 }} />
|
||||
<h2 style={{ margin: 0, color: '#1677ff', fontWeight: 'bold' }}>NexDocus</h2>
|
||||
</div>
|
||||
|
||||
{/* 折叠按钮 */}
|
||||
<div className="trigger" onClick={onToggle}>
|
||||
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!showLogo && <div />} {/* Spacer if left is empty */}
|
||||
|
||||
{/* 右侧:功能按钮 */}
|
||||
<div className="header-right">
|
||||
<Space size={16} className="header-actions">
|
||||
|
||||
{/* 1. 主题切换 */}
|
||||
<div className="header-icon-btn" title="切换主题" onClick={toggleTheme}>
|
||||
{isDarkMode ? <SunOutlined style={{ fontSize: 18 }} /> : <MoonOutlined style={{ fontSize: 18 }} />}
|
||||
</div>
|
||||
|
||||
{/* 2. 语言切换 */}
|
||||
<Segmented
|
||||
value={lang}
|
||||
onChange={setLang}
|
||||
options={[
|
||||
{ label: '中', value: 'zh' },
|
||||
{ label: 'EN', value: 'en' },
|
||||
]}
|
||||
style={{ fontWeight: 500 }}
|
||||
/>
|
||||
|
||||
{/* 3. 消息通知 */}
|
||||
<Popover
|
||||
content={notificationContent}
|
||||
trigger="click"
|
||||
open={popoverVisible}
|
||||
onOpenChange={(visible) => {
|
||||
setPopoverVisible(visible)
|
||||
if (visible) {
|
||||
fetchNotifications()
|
||||
}
|
||||
}}
|
||||
placement="bottomRight"
|
||||
overlayClassName="header-notification-popover"
|
||||
>
|
||||
<div className="header-icon-btn" title="消息中心">
|
||||
<Badge count={unreadCount} size="small" offset={[2, -2]}>
|
||||
<BellOutlined style={{ fontSize: 18 }} />
|
||||
</Badge>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
</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,194 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
DesktopOutlined,
|
||||
GlobalOutlined,
|
||||
CloudServerOutlined,
|
||||
UserOutlined,
|
||||
AppstoreOutlined,
|
||||
SettingOutlined,
|
||||
BlockOutlined,
|
||||
FolderOutlined,
|
||||
FileTextOutlined,
|
||||
SafetyOutlined,
|
||||
TeamOutlined,
|
||||
ProjectOutlined,
|
||||
RocketOutlined,
|
||||
ReadOutlined,
|
||||
BookOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { message } from 'antd'
|
||||
import { getUserMenus } from '@/api/menu'
|
||||
import useUserStore from '@/stores/userStore'
|
||||
import ModernSidebar from '../ModernSidebar/ModernSidebar'
|
||||
|
||||
// 图标映射
|
||||
const iconMap = {
|
||||
DashboardOutlined: <DashboardOutlined />,
|
||||
DesktopOutlined: <DesktopOutlined />,
|
||||
GlobalOutlined: <GlobalOutlined />,
|
||||
CloudServerOutlined: <CloudServerOutlined />,
|
||||
UserOutlined: <UserOutlined />,
|
||||
AppstoreOutlined: <AppstoreOutlined />,
|
||||
SettingOutlined: <SettingOutlined />,
|
||||
BlockOutlined: <BlockOutlined />,
|
||||
FolderOutlined: <FolderOutlined />,
|
||||
FileTextOutlined: <FileTextOutlined />,
|
||||
SafetyOutlined: <SafetyOutlined />,
|
||||
TeamOutlined: <TeamOutlined />,
|
||||
ProjectOutlined: <ProjectOutlined />,
|
||||
ReadOutlined: <ReadOutlined />,
|
||||
BookOutlined: <BookOutlined />,
|
||||
}
|
||||
|
||||
function AppSider({ collapsed, onToggle }) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { user, logout } = useUserStore()
|
||||
const [menuGroups, setMenuGroups] = useState([])
|
||||
|
||||
// 加载菜单数据
|
||||
useEffect(() => {
|
||||
loadMenus()
|
||||
}, [])
|
||||
|
||||
const loadMenus = async () => {
|
||||
try {
|
||||
const res = await getUserMenus()
|
||||
if (res.data) {
|
||||
// 过滤菜单:只显示 type=1 (目录) 和 type=2 (菜单)
|
||||
const validMenus = res.data.filter(item => [1, 2].includes(item.menu_type))
|
||||
transformMenuData(validMenus)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load menus error:', error)
|
||||
message.error('加载菜单失败')
|
||||
}
|
||||
}
|
||||
|
||||
const transformMenuData = (data) => {
|
||||
const groups = []
|
||||
|
||||
// 默认组 (用于存放一级菜单即是叶子节点的情况)
|
||||
const defaultGroup = {
|
||||
title: '', // 空标题或 '通用'
|
||||
items: []
|
||||
}
|
||||
|
||||
data.forEach(item => {
|
||||
// 检查是否有子菜单
|
||||
const validChildren = item.children ? item.children.filter(child => [1, 2].includes(child.menu_type)) : []
|
||||
|
||||
if (validChildren.length > 0) {
|
||||
// 一级菜单作为组标题
|
||||
const groupItems = validChildren.map(child => {
|
||||
const icon = typeof child.icon === 'string' ? (iconMap[child.icon] || <AppstoreOutlined />) : child.icon
|
||||
return {
|
||||
key: child.menu_code,
|
||||
label: child.menu_name,
|
||||
icon: icon,
|
||||
path: child.path
|
||||
}
|
||||
})
|
||||
|
||||
groups.push({
|
||||
title: item.menu_name, // e.g. "系统管理"
|
||||
items: groupItems
|
||||
})
|
||||
} else {
|
||||
// 一级菜单是叶子节点,放入默认组
|
||||
const icon = typeof item.icon === 'string' ? (iconMap[item.icon] || <AppstoreOutlined />) : item.icon
|
||||
defaultGroup.items.push({
|
||||
key: item.menu_code,
|
||||
label: item.menu_name,
|
||||
icon: icon,
|
||||
path: item.path
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 如果默认组有内容,放在最前面
|
||||
if (defaultGroup.items.length > 0) {
|
||||
groups.unshift(defaultGroup)
|
||||
}
|
||||
|
||||
setMenuGroups(groups)
|
||||
}
|
||||
|
||||
const handleNavigate = (key, item) => {
|
||||
if (item.path) {
|
||||
navigate(item.path)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const handleProfileClick = () => {
|
||||
navigate('/profile')
|
||||
}
|
||||
|
||||
// 获取当前激活的 key
|
||||
// 简单匹配 path
|
||||
const getActiveKey = () => {
|
||||
const path = location.pathname
|
||||
// 遍历所有 items 找匹配
|
||||
for (const group of menuGroups) {
|
||||
for (const item of group.items) {
|
||||
if (item.path === path) return item.key
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const logoNode = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, paddingLeft: 8 }}>
|
||||
<img src="/favicon.svg" alt="logo" style={{ width: 32, height: 32 }} />
|
||||
{!collapsed && (
|
||||
<span style={{ fontSize: 18, fontWeight: 'bold', color: 'var(--text-color)' }}>NexDocus</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// 获取用户头像URL
|
||||
const getUserAvatarUrl = () => {
|
||||
if (!user?.avatar) return null
|
||||
// avatar 字段存储的是相对路径,如:2/avatar/xxx.jpg
|
||||
// 需要转换为 API 端点: /api/v1/auth/avatar/{user_id}/{filename}
|
||||
// 如果已经是 http 开头(第三方),则直接返回
|
||||
if (user.avatar.startsWith('http')) return user.avatar
|
||||
|
||||
const parts = user.avatar.split('/')
|
||||
if (parts.length >= 3) {
|
||||
const userId = parts[0]
|
||||
const filename = parts[2]
|
||||
return `/api/v1/auth/avatar/${userId}/${filename}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const userObj = user ? {
|
||||
name: user.nickname || user.username,
|
||||
role: user.role_name || 'Admin',
|
||||
avatar: getUserAvatarUrl()
|
||||
} : null
|
||||
|
||||
return (
|
||||
<ModernSidebar
|
||||
logo={logoNode}
|
||||
menuGroups={menuGroups}
|
||||
activeKey={getActiveKey()}
|
||||
onNavigate={handleNavigate}
|
||||
user={userObj}
|
||||
onLogout={handleLogout}
|
||||
onProfileClick={handleProfileClick}
|
||||
collapsed={collapsed}
|
||||
onCollapse={onToggle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppSider
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
.main-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: row; /* Changed to row for Sider-Left layout */
|
||||
background: var(--bg-color-secondary);
|
||||
}
|
||||
|
||||
.main-content-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
height: 100vh;
|
||||
background: var(--bg-color-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
background: var(--bg-color-secondary);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 0;
|
||||
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" hasSider>
|
||||
<AppSider collapsed={collapsed} onToggle={toggleCollapsed} />
|
||||
<Layout className="main-content-wrapper">
|
||||
<AppHeader collapsed={collapsed} onToggle={toggleCollapsed} showLogo={false} />
|
||||
<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,221 @@
|
|||
.modern-sidebar {
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
background: var(--sider-bg) !important;
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modern-sidebar .ant-layout-sider-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.modern-sidebar-header {
|
||||
padding: 24px 20px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Collapse Trigger */
|
||||
.collapse-trigger {
|
||||
position: absolute;
|
||||
right: -12px;
|
||||
top: 32px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
color: var(--text-color-secondary);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.collapse-trigger:hover {
|
||||
color: #1677ff;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Menu Area */
|
||||
.modern-sidebar-menu {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.modern-sidebar-menu::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.modern-sidebar-menu::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Menu Group */
|
||||
.menu-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 12px;
|
||||
color: var(--text-color-secondary);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
padding-left: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Menu Item */
|
||||
.modern-sidebar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 12px; /* Rounded corners */
|
||||
transition: all 0.2s;
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modern-sidebar-item:hover {
|
||||
background-color: var(--item-hover-bg);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.modern-sidebar-item.active {
|
||||
background-color: #2563eb; /* Royal Blue */
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.25);
|
||||
}
|
||||
|
||||
.item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.item-arrow {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Collapsed State */
|
||||
.modern-sidebar-item.collapsed {
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.modern-sidebar-footer {
|
||||
padding: 16px;
|
||||
flex-shrink: 0;
|
||||
background: var(--sider-bg);
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
padding-left: 12px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.footer-link.collapsed {
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* User Card */
|
||||
.user-card {
|
||||
background-color: var(--bg-color-secondary); /* Light gray background */
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 12px;
|
||||
color: var(--text-color-secondary);
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background-color: var(--border-color);
|
||||
color: #ef4444; /* Red for logout */
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Layout, Avatar, Tooltip, Button } from 'antd';
|
||||
import {
|
||||
MenuUnfoldOutlined,
|
||||
MenuFoldOutlined,
|
||||
LogoutOutlined,
|
||||
QuestionCircleOutlined,
|
||||
RightOutlined,
|
||||
LeftOutlined
|
||||
} from '@ant-design/icons';
|
||||
import './ModernSidebar.css';
|
||||
|
||||
const { Sider } = Layout;
|
||||
|
||||
const ModernSidebar = ({
|
||||
logo,
|
||||
menuGroups = [],
|
||||
activeKey,
|
||||
onNavigate,
|
||||
user,
|
||||
onLogout,
|
||||
onProfileClick,
|
||||
collapsed,
|
||||
onCollapse,
|
||||
width = 260,
|
||||
collapsedWidth = 80,
|
||||
className = '',
|
||||
style = {}
|
||||
}) => {
|
||||
|
||||
const handleItemClick = (item) => {
|
||||
if (onNavigate) {
|
||||
onNavigate(item.key, item);
|
||||
}
|
||||
};
|
||||
|
||||
const renderMenuItem = (item) => {
|
||||
const isActive = activeKey === item.key;
|
||||
|
||||
// 如果是折叠状态,只显示图标,并使用Tooltip
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip title={item.label} placement="right" key={item.key}>
|
||||
<div
|
||||
className={`modern-sidebar-item collapsed ${isActive ? 'active' : ''}`}
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
<div className="item-icon">{item.icon}</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`modern-sidebar-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
<div className="item-content">
|
||||
<div className="item-icon">{item.icon}</div>
|
||||
<span className="item-label">{item.label}</span>
|
||||
</div>
|
||||
{isActive && <RightOutlined className="item-arrow" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sider
|
||||
width={width}
|
||||
collapsed={collapsed}
|
||||
collapsedWidth={collapsedWidth}
|
||||
trigger={null}
|
||||
className={`modern-sidebar ${className}`}
|
||||
theme="light"
|
||||
style={style}
|
||||
>
|
||||
{/* 顶部 Logo 区域 */}
|
||||
<div className="modern-sidebar-header">
|
||||
<div className="logo-container">
|
||||
{logo}
|
||||
</div>
|
||||
{/* 折叠按钮 - 悬浮在边缘 */}
|
||||
<div
|
||||
className="collapse-trigger"
|
||||
onClick={() => onCollapse && onCollapse(!collapsed)}
|
||||
>
|
||||
{collapsed ? <RightOutlined /> : <LeftOutlined />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 菜单列表区域 */}
|
||||
<div className="modern-sidebar-menu">
|
||||
{menuGroups.map((group, index) => (
|
||||
<div key={index} className="menu-group">
|
||||
{!collapsed && group.title && (
|
||||
<div className="group-title">{group.title}</div>
|
||||
)}
|
||||
<div className="group-items">
|
||||
{group.items.map(item => renderMenuItem(item))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 底部区域 */}
|
||||
<div className="modern-sidebar-footer">
|
||||
{/* 帮助支持 */}
|
||||
{!collapsed && (
|
||||
<div className="footer-link">
|
||||
<QuestionCircleOutlined />
|
||||
<span>帮助支持</span>
|
||||
</div>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="footer-link collapsed">
|
||||
<QuestionCircleOutlined />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 用户卡片 */}
|
||||
<div className="user-card">
|
||||
<div
|
||||
className="user-info"
|
||||
onClick={onProfileClick}
|
||||
style={{ cursor: onProfileClick ? 'pointer' : 'default' }}
|
||||
>
|
||||
<Avatar
|
||||
size={collapsed ? 32 : 40}
|
||||
src={user?.avatar}
|
||||
style={{ backgroundColor: '#1677ff' }}
|
||||
>
|
||||
{user?.name?.[0]?.toUpperCase() || 'U'}
|
||||
</Avatar>
|
||||
{!collapsed && (
|
||||
<div className="user-details">
|
||||
<div className="user-name">{user?.name || 'User'}</div>
|
||||
<div className="user-role">{user?.role || 'Member'}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="logout-btn" onClick={onLogout} title="退出登录">
|
||||
<LogoutOutlined />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Sider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModernSidebar;
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
.pdf-viewer-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: #f5f5f5;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pdf-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pdf-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.pdf-content .react-pdf__Document {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pdf-content .react-pdf__Page {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
margin-bottom: 20px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.pdf-content .react-pdf__Page canvas {
|
||||
max-width: 100%;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.pdf-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pdf-error {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
color: #f5222d;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import { useState, useMemo } from 'react'
|
||||
import { Document, Page, pdfjs } from 'react-pdf'
|
||||
import { Button, Space, InputNumber, message, Spin } from 'antd'
|
||||
import {
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import 'react-pdf/dist/Page/AnnotationLayer.css'
|
||||
import 'react-pdf/dist/Page/TextLayer.css'
|
||||
import './PDFViewer.css'
|
||||
|
||||
// 配置 PDF.js worker - 使用本地文件
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf-worker/pdf.worker.min.mjs'
|
||||
|
||||
function PDFViewer({ url, filename }) {
|
||||
const [numPages, setNumPages] = useState(null)
|
||||
const [pageNumber, setPageNumber] = useState(1)
|
||||
const [scale, setScale] = useState(1.0)
|
||||
|
||||
// 使用 useMemo 避免不必要的重新加载
|
||||
const fileConfig = useMemo(() => ({ url }), [url])
|
||||
|
||||
const onDocumentLoadSuccess = ({ numPages }) => {
|
||||
setNumPages(numPages)
|
||||
setPageNumber(1)
|
||||
}
|
||||
|
||||
const onDocumentLoadError = (error) => {
|
||||
message.error('PDF文件加载失败')
|
||||
}
|
||||
|
||||
const goToPrevPage = () => {
|
||||
setPageNumber((prev) => Math.max(prev - 1, 1))
|
||||
}
|
||||
|
||||
const goToNextPage = () => {
|
||||
setPageNumber((prev) => Math.min(prev + 1, numPages))
|
||||
}
|
||||
|
||||
const zoomIn = () => {
|
||||
setScale((prev) => Math.min(prev + 0.2, 3.0))
|
||||
}
|
||||
|
||||
const zoomOut = () => {
|
||||
setScale((prev) => Math.max(prev - 0.2, 0.5))
|
||||
}
|
||||
|
||||
const handlePageChange = (value) => {
|
||||
if (value >= 1 && value <= numPages) {
|
||||
setPageNumber(value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pdf-viewer-container">
|
||||
{/* 工具栏 */}
|
||||
<div className="pdf-toolbar">
|
||||
<Space>
|
||||
<Button
|
||||
icon={<LeftOutlined />}
|
||||
onClick={goToPrevPage}
|
||||
disabled={pageNumber <= 1}
|
||||
size="small"
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Space.Compact>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={numPages || 1}
|
||||
value={pageNumber}
|
||||
onChange={handlePageChange}
|
||||
size="small"
|
||||
style={{ width: 60 }}
|
||||
/>
|
||||
<Button size="small" disabled>
|
||||
/ {numPages || 0}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Button
|
||||
icon={<RightOutlined />}
|
||||
onClick={goToNextPage}
|
||||
disabled={pageNumber >= numPages}
|
||||
size="small"
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
<Button icon={<ZoomOutOutlined />} onClick={zoomOut} size="small">
|
||||
缩小
|
||||
</Button>
|
||||
<span style={{ minWidth: 50, textAlign: 'center' }}>
|
||||
{Math.round(scale * 100)}%
|
||||
</span>
|
||||
<Button icon={<ZoomInOutlined />} onClick={zoomIn} size="small">
|
||||
放大
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* PDF内容区 */}
|
||||
<div className="pdf-content">
|
||||
<Document
|
||||
file={fileConfig}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
onLoadError={onDocumentLoadError}
|
||||
loading={
|
||||
<div className="pdf-loading">
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: 16 }}>正在加载PDF...</div>
|
||||
</div>
|
||||
}
|
||||
error={<div className="pdf-error">PDF加载失败,请稍后重试</div>}
|
||||
>
|
||||
<Page
|
||||
pageNumber={pageNumber}
|
||||
scale={scale}
|
||||
renderTextLayer={true}
|
||||
renderAnnotationLayer={true}
|
||||
loading={
|
||||
<div className="pdf-loading">
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: 16 }}>正在渲染页面...</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Document>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PDFViewer
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
.virtual-pdf-viewer-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-color-secondary);
|
||||
}
|
||||
|
||||
.pdf-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: var(--card-bg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
z-index: 10;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.pdf-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pdf-virtual-list {
|
||||
background: var(--bg-color-secondary);
|
||||
}
|
||||
|
||||
.pdf-page-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
background: var(--bg-color-secondary);
|
||||
}
|
||||
|
||||
.pdf-page-wrapper canvas {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
background: white;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pdf-page-number {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pdf-page-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 600px;
|
||||
gap: 8px;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pdf-page-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 600px;
|
||||
background: var(--item-hover-bg);
|
||||
border: 1px dashed var(--border-color);
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pdf-page-skeleton {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
min-height: 800px;
|
||||
}
|
||||
|
||||
.pdf-page-error {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.pdf-page-error p {
|
||||
color: #ff4d4f;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pdf-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.pdf-error {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
color: #ff4d4f;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 文本层样式优化 */
|
||||
.react-pdf__Page__textContent {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* 注释层样式优化 */
|
||||
.react-pdf__Page__annotations {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Document容器样式 - 确保不限制高度 */
|
||||
.react-pdf__Document {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
||||
import { Document, Page, pdfjs } from 'react-pdf'
|
||||
import { Button, Space, InputNumber, message, Spin } from 'antd'
|
||||
import {
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
VerticalAlignTopOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import 'react-pdf/dist/Page/AnnotationLayer.css'
|
||||
import 'react-pdf/dist/Page/TextLayer.css'
|
||||
import './VirtualPDFViewer.css'
|
||||
|
||||
// 配置 PDF.js worker
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf-worker/pdf.worker.min.mjs'
|
||||
|
||||
function VirtualPDFViewer({ url, filename }) {
|
||||
const [numPages, setNumPages] = useState(null)
|
||||
const [scale, setScale] = useState(1.0)
|
||||
const [pdfOriginalSize, setPdfOriginalSize] = useState({ width: 595, height: 842 }) // 默认 A4
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [visiblePages, setVisiblePages] = useState(new Set([1]))
|
||||
const containerRef = useRef(null)
|
||||
const pageRefs = useRef({})
|
||||
|
||||
// 使用 useMemo 避免不必要的重新加载
|
||||
const fileConfig = useMemo(() => ({ url }), [url])
|
||||
|
||||
// Memoize PDF.js options to prevent unnecessary reloads
|
||||
const pdfOptions = useMemo(() => ({
|
||||
cMapUrl: 'https://unpkg.com/pdfjs-dist@5.4.296/cmaps/',
|
||||
cMapPacked: true,
|
||||
standardFontDataUrl: 'https://unpkg.com/pdfjs-dist@5.4.296/standard_fonts/',
|
||||
}), [])
|
||||
|
||||
// 根据 PDF 实际宽高和缩放比例计算页面高度
|
||||
const pageHeight = useMemo(() => {
|
||||
// 计算内容高度:缩放后的 PDF 高度 + 上下 padding (40px) + 页码文字区域 (20px)
|
||||
return Math.ceil(pdfOriginalSize.height * scale) + 60
|
||||
}, [scale, pdfOriginalSize.height])
|
||||
|
||||
const onDocumentLoadError = (error) => {
|
||||
console.error('[PDF] Document load error:', error)
|
||||
message.error('PDF文件加载失败')
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Handle scroll to update visible pages
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!containerRef.current || !numPages) return
|
||||
|
||||
const container = containerRef.current
|
||||
const scrollTop = container.scrollTop
|
||||
const containerHeight = container.clientHeight
|
||||
|
||||
// Calculate which pages are visible
|
||||
// Add small tolerance (1px) to handle browser scroll precision issues
|
||||
const pageIndex = scrollTop / pageHeight
|
||||
let firstVisiblePage = Math.max(1, Math.ceil(pageIndex + 0.001))
|
||||
|
||||
// Special case: if scrolled to bottom, show last page
|
||||
const isAtBottom = scrollTop + containerHeight >= container.scrollHeight - 1
|
||||
if (isAtBottom) {
|
||||
firstVisiblePage = numPages
|
||||
}
|
||||
|
||||
const lastVisiblePage = Math.min(numPages, Math.ceil((scrollTop + containerHeight) / pageHeight))
|
||||
|
||||
|
||||
|
||||
// Add buffer pages (2 before and 2 after)
|
||||
const newVisiblePages = new Set()
|
||||
for (let i = Math.max(1, firstVisiblePage - 2); i <= Math.min(numPages, lastVisiblePage + 2); i++) {
|
||||
newVisiblePages.add(i)
|
||||
}
|
||||
|
||||
setVisiblePages(newVisiblePages)
|
||||
setCurrentPage(firstVisiblePage)
|
||||
}, [numPages, pageHeight])
|
||||
|
||||
const onDocumentLoadSuccess = useCallback(async (pdf) => {
|
||||
setNumPages(pdf.numPages)
|
||||
|
||||
try {
|
||||
// 获取第一页的原始尺寸,用于计算初始缩放
|
||||
const page = await pdf.getPage(1)
|
||||
const viewport = page.getViewport({ scale: 1.0 })
|
||||
const { width, height } = viewport
|
||||
setPdfOriginalSize({ width, height })
|
||||
|
||||
// 自动适应宽度:仅当 PDF 宽度超过容器时才进行缩放
|
||||
if (containerRef.current) {
|
||||
const containerWidth = containerRef.current.clientWidth - 40 // 减去左右内边距
|
||||
if (width > containerWidth) {
|
||||
const autoScale = Math.floor((containerWidth / width) * 10) / 10 // 保留一位小数
|
||||
setScale(Math.min(Math.max(autoScale, 0.5), 1.0)) // 限制缩放比例,最高不超 1.0
|
||||
} else {
|
||||
setScale(1.0) // 宽度足够则保持 100%
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error calculating initial scale:', err)
|
||||
}
|
||||
|
||||
// Initially show first 3 pages
|
||||
setVisiblePages(new Set([1, 2, 3]))
|
||||
|
||||
// Trigger scroll calculation after a short delay to ensure DOM is ready
|
||||
setTimeout(() => {
|
||||
handleScroll()
|
||||
}, 200)
|
||||
}, [handleScroll])
|
||||
|
||||
// Attach scroll listener
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
container.addEventListener('scroll', handleScroll)
|
||||
return () => container.removeEventListener('scroll', handleScroll)
|
||||
}, [handleScroll, numPages, pageHeight])
|
||||
|
||||
const zoomIn = () => {
|
||||
setScale((prev) => Math.min(prev + 0.2, 3.0))
|
||||
}
|
||||
|
||||
const zoomOut = () => {
|
||||
setScale((prev) => Math.max(prev - 0.2, 0.5))
|
||||
}
|
||||
|
||||
const handlePageChange = (value) => {
|
||||
if (value >= 1 && value <= numPages && containerRef.current) {
|
||||
const scrollTop = (value - 1) * pageHeight
|
||||
const container = containerRef.current
|
||||
container.scrollTo({ top: scrollTop, behavior: 'auto' })
|
||||
|
||||
// Manually trigger handleScroll after scrolling to ensure page number updates
|
||||
setTimeout(() => {
|
||||
handleScroll()
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToTop = () => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="virtual-pdf-viewer-container">
|
||||
{/* 工具栏 */}
|
||||
<div className="pdf-toolbar">
|
||||
<Space>
|
||||
<Button
|
||||
icon={<LeftOutlined />}
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
size="small"
|
||||
/>
|
||||
<Space.Compact>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={numPages || 1}
|
||||
value={currentPage}
|
||||
onChange={handlePageChange}
|
||||
size="small"
|
||||
style={{ width: 60 }}
|
||||
/>
|
||||
<Button size="small" disabled>
|
||||
/ {numPages || 0}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Button
|
||||
icon={<RightOutlined />}
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= (numPages || 0)}
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
icon={<VerticalAlignTopOutlined />}
|
||||
onClick={scrollToTop}
|
||||
size="small"
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
回到顶部
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
<Button icon={<ZoomOutOutlined />} onClick={zoomOut} size="small">
|
||||
缩小
|
||||
</Button>
|
||||
<span style={{ minWidth: 50, textAlign: 'center' }}>
|
||||
{Math.round(scale * 100)}%
|
||||
</span>
|
||||
<Button icon={<ZoomInOutlined />} onClick={zoomIn} size="small">
|
||||
放大
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* PDF内容区 - 自定义虚拟滚动 */}
|
||||
<div className="pdf-content" ref={containerRef}>
|
||||
<Document
|
||||
file={fileConfig}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
onLoadError={onDocumentLoadError}
|
||||
loading={
|
||||
<div className="pdf-loading">
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: 16 }}>正在加载PDF...</div>
|
||||
</div>
|
||||
}
|
||||
error={<div className="pdf-error">PDF加载失败,请稍后重试</div>}
|
||||
options={pdfOptions}
|
||||
>
|
||||
{numPages && (
|
||||
<div style={{ height: numPages * pageHeight, position: 'relative' }}>
|
||||
{Array.from({ length: numPages }, (_, index) => {
|
||||
const pageNumber = index + 1
|
||||
const isVisible = visiblePages.has(pageNumber)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={pageNumber}
|
||||
ref={el => pageRefs.current[pageNumber] = el}
|
||||
className="pdf-page-wrapper"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: index * pageHeight,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: pageHeight,
|
||||
}}
|
||||
>
|
||||
{isVisible ? (
|
||||
<>
|
||||
<Page
|
||||
pageNumber={pageNumber}
|
||||
scale={scale}
|
||||
renderTextLayer={true}
|
||||
renderAnnotationLayer={true}
|
||||
loading={
|
||||
<div className="pdf-page-loading">
|
||||
<Spin size="small" />
|
||||
<div>加载第 {pageNumber} 页...</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="pdf-page-number">第 {pageNumber} 页</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="pdf-page-placeholder">
|
||||
<div>第 {pageNumber} 页</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Document>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VirtualPDFViewer
|
||||
|
|
@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue