feat(shared): 添加操作帮助面板组件

- 实现 ActionHelpPanel 组件,提供操作详情和帮助信息展示
- 添加完整的 CSS 样式文件,支持响应式布局和主题适配
- 集成 Ant Design 的 Drawer 和 Collapse 组件
- 支持当前操作和所有可用操作的分类展示
- 实现操作步骤、注意事项、快捷键等功能说明
- 添加图标、标签、权限要求等信息展示
- 支持操作列表点击切换和实时预览功能
master
chenhao 2026-02-10 17:48:44 +08:00
parent a7a2bc87de
commit bf537d6074
195 changed files with 20668 additions and 436 deletions

View File

@ -0,0 +1,200 @@
# AGENTS.mdBackend
## 一、项目定位
这是一个 **智能语音识别与总结系统的后台服务**,主要职责包括:
* 后台管理(用户 / 角色 / 权限)
* 设备接入与管理
* 任务调度与数据管理
* 对接外部 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
```
规则:
* 35 个阶段
* 未完成前不得删除
* 未规划禁止直接写实现
---
### 5.2 实现循环TDD Only
严格顺序:
1. 理解
* 查找 ≥3 个相似实现
* 遵循现有项目约定
2. 测试Red
* 先写失败测试
* 只描述行为
3. 实现Green
* 最小代码通过
* 拒绝过度设计
4. 重构Refactor
* 在测试保护下清理
---
### 5.3 三次机会规则
同一问题最多尝试 **3 次**
若失败,必须停止并输出:
* 已尝试操作
* 完整错误
* 23 个相似方案
* 根本性反思
---
## 六、质量关卡DoD
交付前必须:
* 可编译
* 通过全部测试
* 新功能必有测试
* 无警告
* 不得随意引入新依赖
---
## 七、后端设计准则
* 显式优于隐式
* 数据流可追踪
* 依赖可替换
* 行为可测试
* 错误可观测
**禁止:**
* 魔法单例
* 全局状态
* 过早抽象
* 与技术栈冲突的框架
---
## 八、接口与安全规范
* 统一返回:`Result<T>`
* 必须参数校验
* 认证JWT
* 权限Spring Security
* 日志:结构化
* 异常:统一处理
---
**一句话原则:**
> 用最朴素的设计 + 最小的改动 + 最确定的测试,
> 构建显而易见正确的 Java 后端。

View File

@ -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. 后续扩展建议
- 添加审计日志落库策略
- 任务管理模块完善
- 权限树缓存与增量刷新策略

94
backend/pom.xml 100644
View File

@ -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>

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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("系统异常");
}
}

View File

@ -0,0 +1,9 @@
package com.imeeting.common;
import lombok.Data;
@Data
public class PageResult<T> {
private long total;
private T records;
}

View File

@ -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;
}
}

View File

@ -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);
}
};
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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));
}
}

View File

@ -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;
}
}

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -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);
}
}

View File

@ -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<>();
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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> {}

View File

@ -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> {}

View File

@ -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);
}

View File

@ -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> {}

View File

@ -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> {}

View File

@ -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> {}

View File

@ -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> {}

View File

@ -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);
}

View File

@ -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> {}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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> {}

View File

@ -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> {}

View File

@ -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;
}
}
}

View File

@ -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 {}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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

View File

@ -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;
}
}

View File

@ -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

View File

@ -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%;
}
}

View File

@ -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

View File

@ -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;
}
}

View File

@ -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

View File

@ -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;
}
}

View File

@ -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

View File

@ -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);
}
}
}

View File

@ -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

View File

@ -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;
}
}

View File

@ -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

View File

@ -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;
}

View File

@ -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

View File

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

View File

@ -0,0 +1,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;
}

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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

View File

@ -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;
}

View File

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

View File

@ -0,0 +1,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;
}
}

View File

@ -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

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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

View File

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

View File

@ -0,0 +1,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

View File

@ -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%;
}

View File

@ -0,0 +1,31 @@
import { useState } from 'react'
import { Layout } from 'antd'
import AppSider from './AppSider'
import AppHeader from './AppHeader'
import './MainLayout.css'
const { Content } = Layout
function MainLayout({ children }) {
const [collapsed, setCollapsed] = useState(false)
const toggleCollapsed = () => {
setCollapsed(!collapsed)
}
return (
<Layout className="main-layout" 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

View File

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

View File

@ -0,0 +1,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 */
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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

View File

@ -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%;
}

View File

@ -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

View File

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

View File

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

View File

@ -0,0 +1,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;
}
}

View File

@ -0,0 +1,53 @@
import { useState } from 'react'
import { UpOutlined, DownOutlined } from '@ant-design/icons'
import './PageTitleBar.css'
function PageTitleBar({
title,
badge,
description,
actions,
showToggle = false,
onToggle,
defaultExpanded = 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

View File

@ -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

View File

@ -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;
}
}

View File

@ -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

View File

@ -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);
}
}

View File

@ -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