diff --git a/backend/design/AGENTS.md b/backend/design/AGENTS.md new file mode 100644 index 0000000..cc8847e --- /dev/null +++ b/backend/design/AGENTS.md @@ -0,0 +1,200 @@ +# AGENTS.md(Backend) + +## 一、项目定位 + +这是一个 **智能语音识别与总结系统的后台服务**,主要职责包括: + +* 后台管理(用户 / 角色 / 权限) +* 设备接入与管理 +* 任务调度与数据管理 +* 对接外部 AI 转录服务(仅接口调用,不实现 AI) + +本模块为 **Java 后端服务**,不包含前端页面逻辑。 + +--- + +## 二、技术栈(必须遵守) + +* Java: **17** +* Spring Boot: **3.x** +* Web: Spring MVC +* Security: **Spring Security + JWT** +* ORM: **MyBatis / MyBatis-Plus(禁止 Hibernate / JPA)** +* Database: **PostgreSQL** +* Cache: Redis +* Build Tool: Maven + +⚠️ 禁止引入与以上技术选型冲突的框架与中间件。 + +--- + +## 三、架构与包结构约定 + +### 基础包结构 + +``` +com.xxx.project +├── common # 通用工具、常量、异常 +├── config # Spring / 安全 / Web 配置 +├── security # JWT、Filter、Security 配置 +├── auth # 登录、鉴权 +├── user # 用户管理 +├── role # 角色管理 +├── permission # 权限管理 +├── device # 设备管理 +├── dict # 字典/配置 +└── task # 转录/业务任务 +``` + +### 分层规范 + +* Controller:仅负责协议与参数校验 +* Service:业务编排与事务边界 +* Mapper:只写数据库访问 +* DTO/VO:显式数据模型,不透传实体 +* 禁止 Controller 直接调用 Mapper + +--- + +## 四、角色与定位 + +你是一位**务实型后端开发者 Agent**,只修改后端文件,不修改前端,目标是: + +> 以最清晰、最朴素、最可验证的方式交付可工作的 Java 服务。 + +### 核心理念 + +* 清晰的意图胜于巧妙的代码 +* 显而易见 > 精妙复杂 +* 奥卡姆剃刀:不应无必要地增加复杂度 +* 组合优于继承 +* 接口优于单例 +* 显式数据流优于隐式魔法 + +### 风格约束 + +* 准确、简洁、可维护 +* 小修改**不输出摘要** +* 不炫技、不做“聪明设计” + +--- + +## 五、工作流程(强制) + +### 5.1 规划阶段(复杂任务必需) + +### 行为约束 +1. 在执行任何修改前,必须**阅读并遵守**本项目的设计文档(位于 `docs/design/`)。 +2. 所有功能改动都必须更新设计文档 +3. 遵循代码风格、目录结构和 Git 工作流规则 + +中大型需求必须先创建: + +`IMPLEMENTATION_PLAN.md` + +``` +## Stage N: [Name] + +Goal: +- 明确可交付物 + +Success Criteria: +- 可测试的验收标准 + +Tests: +- 具体测试用例 + +Status: +- Not Started | In Progress | Complete +``` + +规则: + +* 3–5 个阶段 +* 未完成前不得删除 +* 未规划禁止直接写实现 + +--- + +### 5.2 实现循环(TDD Only) + +严格顺序: + +1. 理解 + + * 查找 ≥3 个相似实现 + * 遵循现有项目约定 + +2. 测试(Red) + + * 先写失败测试 + * 只描述行为 + +3. 实现(Green) + + * 最小代码通过 + * 拒绝过度设计 + +4. 重构(Refactor) + + * 在测试保护下清理 + +--- + +### 5.3 三次机会规则 + +同一问题最多尝试 **3 次**: + +若失败,必须停止并输出: + +* 已尝试操作 +* 完整错误 +* 2–3 个相似方案 +* 根本性反思 + +--- + +## 六、质量关卡(DoD) + +交付前必须: + +* 可编译 +* 通过全部测试 +* 新功能必有测试 +* 无警告 +* 不得随意引入新依赖 + +--- + +## 七、后端设计准则 + +* 显式优于隐式 +* 数据流可追踪 +* 依赖可替换 +* 行为可测试 +* 错误可观测 + +**禁止:** + +* 魔法单例 +* 全局状态 +* 过早抽象 +* 与技术栈冲突的框架 + +--- + +## 八、接口与安全规范 + +* 统一返回:`Result` +* 必须参数校验 +* 认证:JWT +* 权限:Spring Security +* 日志:结构化 +* 异常:统一处理 + +--- + +**一句话原则:** + +> 用最朴素的设计 + 最小的改动 + 最确定的测试, +> 构建显而易见正确的 Java 后端。 diff --git a/backend/design/project_design.md b/backend/design/project_design.md new file mode 100644 index 0000000..eaa86be --- /dev/null +++ b/backend/design/project_design.md @@ -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` +- Controller 不直接调用 Mapper +- 前端禁止在页面内直接调用 axios + +## 9. 后续扩展建议 +- 添加审计日志落库策略 +- 任务管理模块完善 +- 权限树缓存与增量刷新策略 + diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..c499ad8 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,94 @@ + + 4.0.0 + + com.imeeting + imeeting-backend + 0.1.0 + imeeting-backend + Admin and Web API for imeeting + + + org.springframework.boot + spring-boot-starter-parent + 3.2.2 + + + + + 17 + 3.5.6 + 0.11.5 + 1.6.2 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-data-redis + + + com.baomidou + mybatis-plus-spring-boot3-starter + ${mybatis-plus.version} + + + org.postgresql + postgresql + runtime + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + com.github.whvcse + easy-captcha + ${easycaptcha.version} + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/backend/src/main/java/com/imeeting/ImeetingApplication.java b/backend/src/main/java/com/imeeting/ImeetingApplication.java new file mode 100644 index 0000000..ffe62df --- /dev/null +++ b/backend/src/main/java/com/imeeting/ImeetingApplication.java @@ -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); + } +} diff --git a/backend/src/main/java/com/imeeting/auth/JwtAuthenticationFilter.java b/backend/src/main/java/com/imeeting/auth/JwtAuthenticationFilter.java new file mode 100644 index 0000000..7c41ef0 --- /dev/null +++ b/backend/src/main/java/com/imeeting/auth/JwtAuthenticationFilter.java @@ -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); + } +} diff --git a/backend/src/main/java/com/imeeting/auth/JwtTokenProvider.java b/backend/src/main/java/com/imeeting/auth/JwtTokenProvider.java new file mode 100644 index 0000000..f8cc728 --- /dev/null +++ b/backend/src/main/java/com/imeeting/auth/JwtTokenProvider.java @@ -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 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(); + } +} diff --git a/backend/src/main/java/com/imeeting/auth/dto/CaptchaResponse.java b/backend/src/main/java/com/imeeting/auth/dto/CaptchaResponse.java new file mode 100644 index 0000000..9a19ec8 --- /dev/null +++ b/backend/src/main/java/com/imeeting/auth/dto/CaptchaResponse.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/auth/dto/DeviceCodeRequest.java b/backend/src/main/java/com/imeeting/auth/dto/DeviceCodeRequest.java new file mode 100644 index 0000000..29aaf62 --- /dev/null +++ b/backend/src/main/java/com/imeeting/auth/dto/DeviceCodeRequest.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/auth/dto/LoginRequest.java b/backend/src/main/java/com/imeeting/auth/dto/LoginRequest.java new file mode 100644 index 0000000..5b3f712 --- /dev/null +++ b/backend/src/main/java/com/imeeting/auth/dto/LoginRequest.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/auth/dto/RefreshRequest.java b/backend/src/main/java/com/imeeting/auth/dto/RefreshRequest.java new file mode 100644 index 0000000..2bb995b --- /dev/null +++ b/backend/src/main/java/com/imeeting/auth/dto/RefreshRequest.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/auth/dto/TokenResponse.java b/backend/src/main/java/com/imeeting/auth/dto/TokenResponse.java new file mode 100644 index 0000000..85ab970 --- /dev/null +++ b/backend/src/main/java/com/imeeting/auth/dto/TokenResponse.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/common/ApiResponse.java b/backend/src/main/java/com/imeeting/common/ApiResponse.java new file mode 100644 index 0000000..8a9b39b --- /dev/null +++ b/backend/src/main/java/com/imeeting/common/ApiResponse.java @@ -0,0 +1,22 @@ +package com.imeeting.common; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ApiResponse { + private String code; + private String msg; + private T data; + + public static ApiResponse ok(T data) { + return new ApiResponse<>("0", "OK", data); + } + + public static ApiResponse error(String msg) { + return new ApiResponse<>("-1", msg, null); + } +} diff --git a/backend/src/main/java/com/imeeting/common/GlobalExceptionHandler.java b/backend/src/main/java/com/imeeting/common/GlobalExceptionHandler.java new file mode 100644 index 0000000..c7494c6 --- /dev/null +++ b/backend/src/main/java/com/imeeting/common/GlobalExceptionHandler.java @@ -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 handleIllegalArgument(IllegalArgumentException ex) { + log.warn("Business error: {}", ex.getMessage()); + return ApiResponse.error(ex.getMessage()); + } + + @ExceptionHandler(Exception.class) + public ApiResponse handleGeneric(Exception ex) { + log.error("Unhandled exception", ex); + return ApiResponse.error("系统异常"); + } +} diff --git a/backend/src/main/java/com/imeeting/common/PageResult.java b/backend/src/main/java/com/imeeting/common/PageResult.java new file mode 100644 index 0000000..c77de57 --- /dev/null +++ b/backend/src/main/java/com/imeeting/common/PageResult.java @@ -0,0 +1,9 @@ +package com.imeeting.common; + +import lombok.Data; + +@Data +public class PageResult { + private long total; + private T records; +} diff --git a/backend/src/main/java/com/imeeting/common/RedisKeys.java b/backend/src/main/java/com/imeeting/common/RedisKeys.java new file mode 100644 index 0000000..8cc3388 --- /dev/null +++ b/backend/src/main/java/com/imeeting/common/RedisKeys.java @@ -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; + } +} diff --git a/backend/src/main/java/com/imeeting/config/MybatisPlusConfig.java b/backend/src/main/java/com/imeeting/config/MybatisPlusConfig.java new file mode 100644 index 0000000..ec50eed --- /dev/null +++ b/backend/src/main/java/com/imeeting/config/MybatisPlusConfig.java @@ -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); + } + }; + } +} diff --git a/backend/src/main/java/com/imeeting/config/SecurityConfig.java b/backend/src/main/java/com/imeeting/config/SecurityConfig.java new file mode 100644 index 0000000..0fb6bcf --- /dev/null +++ b/backend/src/main/java/com/imeeting/config/SecurityConfig.java @@ -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; + } +} diff --git a/backend/src/main/java/com/imeeting/controller/AuthController.java b/backend/src/main/java/com/imeeting/controller/AuthController.java new file mode 100644 index 0000000..104951e --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/AuthController.java @@ -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 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 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 login(@Valid @RequestBody LoginRequest request) { + return ApiResponse.ok(authService.login(request)); + } + + @PostMapping("/refresh") + public ApiResponse refresh(@Valid @RequestBody RefreshRequest request) { + return ApiResponse.ok(authService.refresh(request.getRefreshToken())); + } + + @PostMapping("/logout") + public ApiResponse 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); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/DeviceController.java b/backend/src/main/java/com/imeeting/controller/DeviceController.java new file mode 100644 index 0000000..6c78616 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/DeviceController.java @@ -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() { + return ApiResponse.ok(deviceService.list()); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(deviceService.getById(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody Device device) { + return ApiResponse.ok(deviceService.save(device)); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody Device device) { + device.setDeviceId(id); + return ApiResponse.ok(deviceService.updateById(device)); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id) { + return ApiResponse.ok(deviceService.removeById(id)); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/PermissionController.java b/backend/src/main/java/com/imeeting/controller/PermissionController.java new file mode 100644 index 0000000..3165328 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/PermissionController.java @@ -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(@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> myPermissions(@RequestHeader("Authorization") String authorization) { + Long userId = resolveUserId(authorization); + return ApiResponse.ok(sysPermissionService.listByUserId(userId)); + } + + @GetMapping("/tree") + public ApiResponse> 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> myTree(@RequestHeader("Authorization") String authorization) { + Long userId = resolveUserId(authorization); + return ApiResponse.ok(buildTree(sysPermissionService.listByUserId(userId))); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(sysPermissionService.getById(id)); + } + + @PostMapping + public ApiResponse 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 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 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 buildTree(List list) { + Map map = new HashMap<>(); + List 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 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; + } +} diff --git a/backend/src/main/java/com/imeeting/controller/RoleController.java b/backend/src/main/java/com/imeeting/controller/RoleController.java new file mode 100644 index 0000000..796e482 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/RoleController.java @@ -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() { + return ApiResponse.ok(sysRoleService.list()); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(sysRoleService.getById(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody SysRole role) { + return ApiResponse.ok(sysRoleService.save(role)); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody SysRole role) { + role.setRoleId(id); + return ApiResponse.ok(sysRoleService.updateById(role)); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id) { + return ApiResponse.ok(sysRoleService.removeById(id)); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/SysParamController.java b/backend/src/main/java/com/imeeting/controller/SysParamController.java new file mode 100644 index 0000000..7f6ee1e --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/SysParamController.java @@ -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() { + return ApiResponse.ok(sysParamService.list()); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(sysParamService.getById(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody SysParam param) { + return ApiResponse.ok(sysParamService.save(param)); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody SysParam param) { + param.setParamId(id); + return ApiResponse.ok(sysParamService.updateById(param)); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id) { + return ApiResponse.ok(sysParamService.removeById(id)); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/UserController.java b/backend/src/main/java/com/imeeting/controller/UserController.java new file mode 100644 index 0000000..e97c53c --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/UserController.java @@ -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() { + return ApiResponse.ok(sysUserService.list()); + } + + @GetMapping("/me") + public ApiResponse 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 get(@PathVariable Long id) { + return ApiResponse.ok(sysUserService.getById(id)); + } + + @PostMapping + public ApiResponse 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 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 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); + } +} diff --git a/backend/src/main/java/com/imeeting/dto/PermissionNode.java b/backend/src/main/java/com/imeeting/dto/PermissionNode.java new file mode 100644 index 0000000..4ef177f --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/PermissionNode.java @@ -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 children = new ArrayList<>(); +} diff --git a/backend/src/main/java/com/imeeting/dto/UserProfile.java b/backend/src/main/java/com/imeeting/dto/UserProfile.java new file mode 100644 index 0000000..b2fc6a6 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/UserProfile.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/entity/BaseEntity.java b/backend/src/main/java/com/imeeting/entity/BaseEntity.java new file mode 100644 index 0000000..8ea44c1 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/BaseEntity.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/entity/Device.java b/backend/src/main/java/com/imeeting/entity/Device.java new file mode 100644 index 0000000..86bc039 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/Device.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/entity/SysParam.java b/backend/src/main/java/com/imeeting/entity/SysParam.java new file mode 100644 index 0000000..904ef6e --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/SysParam.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/entity/SysPermission.java b/backend/src/main/java/com/imeeting/entity/SysPermission.java new file mode 100644 index 0000000..ca9eeb7 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/SysPermission.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/entity/SysRole.java b/backend/src/main/java/com/imeeting/entity/SysRole.java new file mode 100644 index 0000000..fba0134 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/SysRole.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/entity/SysRolePermission.java b/backend/src/main/java/com/imeeting/entity/SysRolePermission.java new file mode 100644 index 0000000..28633ca --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/SysRolePermission.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/entity/SysUser.java b/backend/src/main/java/com/imeeting/entity/SysUser.java new file mode 100644 index 0000000..8df4850 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/SysUser.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/entity/SysUserRole.java b/backend/src/main/java/com/imeeting/entity/SysUserRole.java new file mode 100644 index 0000000..9fb8e79 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/SysUserRole.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/mapper/DeviceMapper.java b/backend/src/main/java/com/imeeting/mapper/DeviceMapper.java new file mode 100644 index 0000000..0ef7005 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/DeviceMapper.java @@ -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 {} diff --git a/backend/src/main/java/com/imeeting/mapper/SysParamMapper.java b/backend/src/main/java/com/imeeting/mapper/SysParamMapper.java new file mode 100644 index 0000000..46722c3 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/SysParamMapper.java @@ -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 {} diff --git a/backend/src/main/java/com/imeeting/mapper/SysPermissionMapper.java b/backend/src/main/java/com/imeeting/mapper/SysPermissionMapper.java new file mode 100644 index 0000000..e0e2aa8 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/SysPermissionMapper.java @@ -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 { + @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 selectByUserId(@Param("userId") Long userId); +} diff --git a/backend/src/main/java/com/imeeting/mapper/SysRoleMapper.java b/backend/src/main/java/com/imeeting/mapper/SysRoleMapper.java new file mode 100644 index 0000000..8394c1e --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/SysRoleMapper.java @@ -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 {} diff --git a/backend/src/main/java/com/imeeting/mapper/SysRolePermissionMapper.java b/backend/src/main/java/com/imeeting/mapper/SysRolePermissionMapper.java new file mode 100644 index 0000000..8bf271e --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/SysRolePermissionMapper.java @@ -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 {} diff --git a/backend/src/main/java/com/imeeting/mapper/SysUserMapper.java b/backend/src/main/java/com/imeeting/mapper/SysUserMapper.java new file mode 100644 index 0000000..27fbb1c --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/SysUserMapper.java @@ -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 {} diff --git a/backend/src/main/java/com/imeeting/mapper/SysUserRoleMapper.java b/backend/src/main/java/com/imeeting/mapper/SysUserRoleMapper.java new file mode 100644 index 0000000..5ccfaf7 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/SysUserRoleMapper.java @@ -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 {} diff --git a/backend/src/main/java/com/imeeting/service/AuthService.java b/backend/src/main/java/com/imeeting/service/AuthService.java new file mode 100644 index 0000000..a2efa3f --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/AuthService.java @@ -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); +} diff --git a/backend/src/main/java/com/imeeting/service/DeviceService.java b/backend/src/main/java/com/imeeting/service/DeviceService.java new file mode 100644 index 0000000..7ba6150 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/DeviceService.java @@ -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 {} diff --git a/backend/src/main/java/com/imeeting/service/SysParamService.java b/backend/src/main/java/com/imeeting/service/SysParamService.java new file mode 100644 index 0000000..6d84ba6 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/SysParamService.java @@ -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 { + String getParamValue(String key, String defaultValue); +} diff --git a/backend/src/main/java/com/imeeting/service/SysPermissionService.java b/backend/src/main/java/com/imeeting/service/SysPermissionService.java new file mode 100644 index 0000000..f4064eb --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/SysPermissionService.java @@ -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 { + List listByUserId(Long userId); +} diff --git a/backend/src/main/java/com/imeeting/service/SysRoleService.java b/backend/src/main/java/com/imeeting/service/SysRoleService.java new file mode 100644 index 0000000..e517220 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/SysRoleService.java @@ -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 {} diff --git a/backend/src/main/java/com/imeeting/service/SysUserService.java b/backend/src/main/java/com/imeeting/service/SysUserService.java new file mode 100644 index 0000000..62efe5f --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/SysUserService.java @@ -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 {} diff --git a/backend/src/main/java/com/imeeting/service/impl/AuthServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/AuthServiceImpl.java new file mode 100644 index 0000000..2960bda --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/impl/AuthServiceImpl.java @@ -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() + .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() + .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() + .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 accessClaims = new HashMap<>(); + accessClaims.put("tokenType", "access"); + accessClaims.put("userId", user.getUserId()); + accessClaims.put("username", user.getUsername()); + accessClaims.put("deviceCode", deviceCode); + + Map 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; + } + } +} diff --git a/backend/src/main/java/com/imeeting/service/impl/DeviceServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/DeviceServiceImpl.java new file mode 100644 index 0000000..31ca4f1 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/impl/DeviceServiceImpl.java @@ -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 implements DeviceService {} diff --git a/backend/src/main/java/com/imeeting/service/impl/SysParamServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/SysParamServiceImpl.java new file mode 100644 index 0000000..328d42d --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/impl/SysParamServiceImpl.java @@ -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 implements SysParamService { + @Override + public String getParamValue(String key, String defaultValue) { + SysParam param = getOne(new LambdaQueryWrapper().eq(SysParam::getParamKey, key)); + return param == null ? defaultValue : param.getParamValue(); + } +} diff --git a/backend/src/main/java/com/imeeting/service/impl/SysPermissionServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/SysPermissionServiceImpl.java new file mode 100644 index 0000000..17aad13 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/impl/SysPermissionServiceImpl.java @@ -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 implements SysPermissionService { + @Override + public List listByUserId(Long userId) { + if (userId == null) { + return List.of(); + } + if (userId != null && userId == 1L) { + return list(); + } + return baseMapper.selectByUserId(userId); + } +} diff --git a/backend/src/main/java/com/imeeting/service/impl/SysRoleServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/SysRoleServiceImpl.java new file mode 100644 index 0000000..cdfbbc9 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/impl/SysRoleServiceImpl.java @@ -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 implements SysRoleService {} diff --git a/backend/src/main/java/com/imeeting/service/impl/SysUserServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/SysUserServiceImpl.java new file mode 100644 index 0000000..cbdefe6 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/impl/SysUserServiceImpl.java @@ -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 implements SysUserService {} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..6e268b3 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -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 diff --git a/components/ActionHelpPanel/ActionHelpPanel.css b/components/ActionHelpPanel/ActionHelpPanel.css new file mode 100644 index 0000000..bc039f7 --- /dev/null +++ b/components/ActionHelpPanel/ActionHelpPanel.css @@ -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; + } +} diff --git a/components/ActionHelpPanel/ActionHelpPanel.jsx b/components/ActionHelpPanel/ActionHelpPanel.jsx new file mode 100644 index 0000000..a3cde11 --- /dev/null +++ b/components/ActionHelpPanel/ActionHelpPanel.jsx @@ -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 ( + + ) + } + + return ( +
+ {/* 操作标题 */} +
+
{currentAction.icon}
+
+

{currentAction.title}

+ {currentAction.badge && ( + + {currentAction.badge.text} + + )} +
+
+ + {/* 操作描述 */} + {currentAction.description && ( +
+
+ 功能说明 +
+
{currentAction.description}
+
+ )} + + {/* 使用场景 */} + {currentAction.scenarios && currentAction.scenarios.length > 0 && ( +
+
+ 使用场景 +
+
    + {currentAction.scenarios.map((scenario, index) => ( +
  • {scenario}
  • + ))} +
+
+ )} + + {/* 操作步骤 */} + {currentAction.steps && currentAction.steps.length > 0 && ( +
+
+ 操作步骤 +
+
    + {currentAction.steps.map((step, index) => ( +
  1. {step}
  2. + ))} +
+
+ )} + + {/* 注意事项 */} + {currentAction.warnings && currentAction.warnings.length > 0 && ( +
+
+ 注意事项 +
+
    + {currentAction.warnings.map((warning, index) => ( +
  • {warning}
  • + ))} +
+
+ )} + + {/* 快捷键 */} + {currentAction.shortcut && ( +
+
⌨️ 快捷键
+
+ {currentAction.shortcut} +
+
+ )} + + {/* 权限要求 */} + {currentAction.permission && ( +
+
🔐 权限要求
+
+ {currentAction.permission} +
+
+ )} +
+ ) + } + + // 渲染所有操作列表 + const renderAllActions = () => { + if (allActions.length === 0) { + return + } + + return ( +
+ {allActions.map((action, index) => ( +
{ + if (onActionSelect) { + onActionSelect(action) + setActiveKey(['current']) + } + }} + > +
+ {action.icon} + {action.title} + {action.shortcut && ( + {action.shortcut} + )} +
+
{action.description}
+
+ ))} +
+ ) + } + + return ( + + + 操作帮助 + {currentAction && } + + } + placement={placement} + width={420} + open={visible} + onClose={onClose} + className="action-help-panel" + > + + + 当前操作 + {currentAction && ( + + )} + + } + key="current" + > + {renderCurrentAction()} + + + + {renderAllActions()} + + + + ) +} + +export default ActionHelpPanel diff --git a/components/BottomHintBar/BottomHintBar.css b/components/BottomHintBar/BottomHintBar.css new file mode 100644 index 0000000..36de3a3 --- /dev/null +++ b/components/BottomHintBar/BottomHintBar.css @@ -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%; + } +} diff --git a/components/BottomHintBar/BottomHintBar.jsx b/components/BottomHintBar/BottomHintBar.jsx new file mode 100644 index 0000000..9efe937 --- /dev/null +++ b/components/BottomHintBar/BottomHintBar.jsx @@ -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 ( +
e.stopPropagation()} + > +
+ {/* 左侧:图标和标题 */} +
+
{hintInfo.icon}
+
+

{hintInfo.title}

+ {hintInfo.badge && ( + + {hintInfo.badge.text} + + )} +
+
+ + {/* 中间:主要信息 */} +
+ {/* 描述 */} + {hintInfo.description && ( +
+ + {hintInfo.description} +
+ )} + + {/* 快速提示 */} + {hintInfo.quickTip && ( +
+ + {hintInfo.quickTip} +
+ )} + + {/* 警告 */} + {hintInfo.warning && ( +
+ + {hintInfo.warning} +
+ )} +
+ + {/* 右侧:快捷键和关闭 */} +
+ {hintInfo.shortcut && ( +
+ 快捷键 + {hintInfo.shortcut} +
+ )} + {onClose && ( + + )} +
+
+ + {/* 进度指示条 */} +
+
+ ) +} + +export default BottomHintBar diff --git a/components/ButtonWithGuide/ButtonWithGuide.css b/components/ButtonWithGuide/ButtonWithGuide.css new file mode 100644 index 0000000..0968427 --- /dev/null +++ b/components/ButtonWithGuide/ButtonWithGuide.css @@ -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; + } +} diff --git a/components/ButtonWithGuide/ButtonWithGuide.jsx b/components/ButtonWithGuide/ButtonWithGuide.jsx new file mode 100644 index 0000000..0f30cc3 --- /dev/null +++ b/components/ButtonWithGuide/ButtonWithGuide.jsx @@ -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 ( + <> +
+ + {guide && !disabled && ( + + )} +
+ + {/* 引导弹窗 */} + {guide && ( + + {guide.icon || icon} + {guide.title} + {guide.badge && ( + + {guide.badge.text} + + )} +
+ } + open={showGuideModal} + onCancel={() => setShowGuideModal(false)} + footer={[ + , + ]} + width={600} + className="button-guide-modal" + > + {/* 功能描述 */} + {guide.description && ( +
+
+ + 功能说明 +
+

{guide.description}

+
+ )} + + {/* 使用步骤 */} + {guide.steps && guide.steps.length > 0 && ( +
+
+ + 操作步骤 +
+ ({ + title: `步骤 ${index + 1}`, + description: step, + status: 'wait', + }))} + className="guide-steps" + /> +
+ )} + + {/* 使用场景 */} + {guide.scenarios && guide.scenarios.length > 0 && ( +
+
+ + 适用场景 +
+
    + {guide.scenarios.map((scenario, index) => ( +
  • {scenario}
  • + ))} +
+
+ )} + + {/* 注意事项 */} + {guide.warnings && guide.warnings.length > 0 && ( +
+
+ + 注意事项 +
+
    + {guide.warnings.map((warning, index) => ( +
  • {warning}
  • + ))} +
+
+ )} + + {/* 快捷键和权限 */} + {(guide.shortcut || guide.permission) && ( +
+ {guide.shortcut && ( +
+ 快捷键: + {guide.shortcut} +
+ )} + {guide.permission && ( +
+ 权限要求: + {guide.permission} +
+ )} +
+ )} + + )} + + ) +} + +export default ButtonWithGuide diff --git a/components/ButtonWithGuideBadge/ButtonWithGuideBadge.css b/components/ButtonWithGuideBadge/ButtonWithGuideBadge.css new file mode 100644 index 0000000..334f120 --- /dev/null +++ b/components/ButtonWithGuideBadge/ButtonWithGuideBadge.css @@ -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; + } +} diff --git a/components/ButtonWithGuideBadge/ButtonWithGuideBadge.jsx b/components/ButtonWithGuideBadge/ButtonWithGuideBadge.jsx new file mode 100644 index 0000000..35a8224 --- /dev/null +++ b/components/ButtonWithGuideBadge/ButtonWithGuideBadge.jsx @@ -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: , + }, + help: { + text: '?', + color: '#1677ff', + icon: , + }, + warn: { + text: '!', + color: '#faad14', + icon: , + }, + } + return configs[badgeType] || configs.help + } + + const badgeConfig = getBadgeConfig() + + return ( + <> +
+ {showBadge && guide && !disabled ? ( + + {badgeConfig.icon} +
+ } + offset={[-5, 5]} + > + + + ) : ( + + )} + + + {/* 引导弹窗 */} + {guide && ( + + {guide.icon || icon} + {guide.title} + {guide.badge && ( + + {guide.badge.text} + + )} + + } + open={showGuideModal} + onCancel={() => setShowGuideModal(false)} + footer={[ + , + ]} + width={600} + className="button-guide-modal" + > + {/* 功能描述 */} + {guide.description && ( +
+
+ + 功能说明 +
+

{guide.description}

+
+ )} + + {/* 使用步骤 */} + {guide.steps && guide.steps.length > 0 && ( +
+
+ + 操作步骤 +
+ ({ + title: `步骤 ${index + 1}`, + description: step, + status: 'wait', + }))} + className="guide-steps" + /> +
+ )} + + {/* 使用场景 */} + {guide.scenarios && guide.scenarios.length > 0 && ( +
+
+ + 适用场景 +
+
    + {guide.scenarios.map((scenario, index) => ( +
  • {scenario}
  • + ))} +
+
+ )} + + {/* 注意事项 */} + {guide.warnings && guide.warnings.length > 0 && ( +
+
+ + 注意事项 +
+
    + {guide.warnings.map((warning, index) => ( +
  • {warning}
  • + ))} +
+
+ )} + + {/* 快捷键和权限 */} + {(guide.shortcut || guide.permission) && ( +
+ {guide.shortcut && ( +
+ 快捷键: + {guide.shortcut} +
+ )} + {guide.permission && ( +
+ 权限要求: + {guide.permission} +
+ )} +
+ )} +
+ )} + + ) +} + +export default ButtonWithGuideBadge diff --git a/components/ButtonWithHoverCard/ButtonWithHoverCard.css b/components/ButtonWithHoverCard/ButtonWithHoverCard.css new file mode 100644 index 0000000..eb42451 --- /dev/null +++ b/components/ButtonWithHoverCard/ButtonWithHoverCard.css @@ -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); + } + } +} diff --git a/components/ButtonWithHoverCard/ButtonWithHoverCard.jsx b/components/ButtonWithHoverCard/ButtonWithHoverCard.jsx new file mode 100644 index 0000000..9800e64 --- /dev/null +++ b/components/ButtonWithHoverCard/ButtonWithHoverCard.jsx @@ -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 ( +
+ + {/* 标题区 */} +
+
+ {cardInfo.icon && ( + {cardInfo.icon} + )} +

{cardInfo.title}

+
+ {cardInfo.badge && ( + + {cardInfo.badge.text} + + )} +
+ + {/* 描述 */} + {cardInfo.description && ( +
+

{cardInfo.description}

+
+ )} + + {/* 使用场景 */} + {cardInfo.scenarios && cardInfo.scenarios.length > 0 && ( +
+
+ + 使用场景 +
+
    + {cardInfo.scenarios.slice(0, 2).map((scenario, index) => ( +
  • {scenario}
  • + ))} +
+
+ )} + + {/* 快速提示 */} + {cardInfo.quickTips && cardInfo.quickTips.length > 0 && ( +
+
+ + 快速提示 +
+
    + {cardInfo.quickTips.map((tip, index) => ( +
  • {tip}
  • + ))} +
+
+ )} + + {/* 注意事项 */} + {cardInfo.warnings && cardInfo.warnings.length > 0 && ( +
+
+ + 注意 +
+
    + {cardInfo.warnings.slice(0, 2).map((warning, index) => ( +
  • {warning}
  • + ))} +
+
+ )} + + {/* 快捷键 */} + {cardInfo.shortcut && ( +
+ 快捷键 + {cardInfo.shortcut} +
+ )} +
+
+ ) + } + + return ( + <> +
+ +
+ + {/* 使用 Portal 渲染悬浮卡片到 body */} + {typeof document !== 'undefined' && createPortal(renderCard(), document.body)} + + ) +} + +export default ButtonWithHoverCard diff --git a/components/ButtonWithTip/ButtonWithTip.css b/components/ButtonWithTip/ButtonWithTip.css new file mode 100644 index 0000000..f1d665a --- /dev/null +++ b/components/ButtonWithTip/ButtonWithTip.css @@ -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; + } +} diff --git a/components/ButtonWithTip/ButtonWithTip.jsx b/components/ButtonWithTip/ButtonWithTip.jsx new file mode 100644 index 0000000..f8d4130 --- /dev/null +++ b/components/ButtonWithTip/ButtonWithTip.jsx @@ -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 ( + + ) + } + + // 构建提示内容 + const tooltipContent = ( +
+ {tip.title &&
{tip.title}
} + {tip.description &&
{tip.description}
} + {tip.shortcut && ( +
+ 快捷键: + {tip.shortcut} +
+ )} + {tip.notes && tip.notes.length > 0 && ( +
+
注意事项:
+
    + {tip.notes.map((note, index) => ( +
  • {note}
  • + ))} +
+
+ )} +
+ ) + + return ( + +
+ + {showTipIcon && !disabled && ( + + )} +
+
+ ) +} + +export default ButtonWithTip diff --git a/components/ChartPanel/ChartPanel.css b/components/ChartPanel/ChartPanel.css new file mode 100644 index 0000000..69a0cc2 --- /dev/null +++ b/components/ChartPanel/ChartPanel.css @@ -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; +} diff --git a/components/ChartPanel/ChartPanel.jsx b/components/ChartPanel/ChartPanel.jsx new file mode 100644 index 0000000..5f26c8f --- /dev/null +++ b/components/ChartPanel/ChartPanel.jsx @@ -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 ( +
+ {title &&
{title}
} +
+
+ ) +} + +/** + * 根据图表类型生成 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 diff --git a/components/ConfirmDialog/ConfirmDialog.jsx b/components/ConfirmDialog/ConfirmDialog.jsx new file mode 100644 index 0000000..d7095b0 --- /dev/null +++ b/components/ConfirmDialog/ConfirmDialog.jsx @@ -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: ( +
+

您确定要删除以下项目吗?

+
+

{itemName}

+ {itemInfo && ( +

{itemInfo}

+ )} +
+

+ 此操作不可恢复,请谨慎操作! +

+
+ ), + okText: '确认删除', + cancelText: '取消', + okType: 'danger', + centered: true, + icon: , + onOk, + onCancel, + }) + }, + + /** + * 显示批量删除确认对话框 + */ + batchDelete: ({ count, items, onOk, onCancel }) => { + Modal.confirm({ + title: '批量删除确认', + content: ( +
+

您确定要删除选中的 {count} 个项目吗?

+
+ {items.map((item, index) => ( +
+ {item.name} + {item.info && ( + + ({item.info}) + + )} +
+ ))} +
+

+ 此操作不可恢复,请谨慎操作! +

+
+ ), + okText: '确认删除', + cancelText: '取消', + okType: 'danger', + centered: true, + icon: , + onOk, + onCancel, + }) + }, + + /** + * 显示警告确认对话框 + */ + warning: ({ title, content, okText = '确定', cancelText = '取消', onOk, onCancel }) => { + Modal.confirm({ + title, + content, + okText, + cancelText, + centered: true, + icon: , + 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 diff --git a/components/DetailDrawer/DetailDrawer.css b/components/DetailDrawer/DetailDrawer.css new file mode 100644 index 0000000..c5db2f2 --- /dev/null +++ b/components/DetailDrawer/DetailDrawer.css @@ -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; +} diff --git a/components/DetailDrawer/DetailDrawer.tsx b/components/DetailDrawer/DetailDrawer.tsx new file mode 100644 index 0000000..0286dda --- /dev/null +++ b/components/DetailDrawer/DetailDrawer.tsx @@ -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 ( + +
+
+
+
+
+ + {headerActions.map((action) => ( + + ))} + +
+
+ +
+ {children} + + {tabs && tabs.length > 0 && ( +
+ ({ + key: tab.key, + label: tab.label, + children:
{tab.content}
, + }))} + /> +
+ )} +
+
+
+ ); +} + +export default DetailDrawer; diff --git a/components/ExtendInfoPanel/ExtendInfoPanel.css b/components/ExtendInfoPanel/ExtendInfoPanel.css new file mode 100644 index 0000000..8ea059b --- /dev/null +++ b/components/ExtendInfoPanel/ExtendInfoPanel.css @@ -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); + } +} diff --git a/components/ExtendInfoPanel/ExtendInfoPanel.jsx b/components/ExtendInfoPanel/ExtendInfoPanel.jsx new file mode 100644 index 0000000..03e5166 --- /dev/null +++ b/components/ExtendInfoPanel/ExtendInfoPanel.jsx @@ -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 ( +
+ {sections.map((section) => { + const isCollapsed = collapsedSections[section.key] + const hideTitleBar = section.hideTitleBar === true + + return ( +
+ {/* 区块头部 - 可配置隐藏 */} + {!hideTitleBar && ( +
toggleSection(section.key)}> +
+ {section.icon && {section.icon}} + {section.title} +
+ +
+ )} + + {/* 区块内容 - 如果隐藏标题栏则总是显示,否则根据折叠状态 */} + {(hideTitleBar || !isCollapsed) && ( +
{section.content}
+ )} +
+ ) + })} +
+ ) +} + +export default ExtendInfoPanel diff --git a/components/InfoPanel/InfoPanel.css b/components/InfoPanel/InfoPanel.css new file mode 100644 index 0000000..463adb6 --- /dev/null +++ b/components/InfoPanel/InfoPanel.css @@ -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; +} + + + + diff --git a/components/InfoPanel/InfoPanel.jsx b/components/InfoPanel/InfoPanel.jsx new file mode 100644 index 0000000..c60f2c9 --- /dev/null +++ b/components/InfoPanel/InfoPanel.jsx @@ -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 ( +
+ + {fields.map((field) => { + const value = data[field.key] + const displayValue = field.render ? field.render(value, data) : value + + return ( + +
+
{field.label}
+
{displayValue}
+
+ + ) + })} +
+ + {/* 可选的操作按钮区 */} + {actions && actions.length > 0 && ( +
+ + {actions.map((action) => ( + + ))} + +
+ )} +
+ ) +} + +export default InfoPanel diff --git a/components/ListActionBar/ListActionBar.css b/components/ListActionBar/ListActionBar.css new file mode 100644 index 0000000..eb9cbf7 --- /dev/null +++ b/components/ListActionBar/ListActionBar.css @@ -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; + } +} diff --git a/components/ListActionBar/ListActionBar.jsx b/components/ListActionBar/ListActionBar.jsx new file mode 100644 index 0000000..f1b1c44 --- /dev/null +++ b/components/ListActionBar/ListActionBar.jsx @@ -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 ( +
+ {/* 左侧操作按钮区 */} +
+ {/* 常规操作按钮(无选中时显示) */} + {!hasSelection && actions.map((action) => ( + + ))} + + {/* 批量操作区域(有选中时显示) */} + {hasSelection && ( + + {/* 选中信息 */} +
+ + 已选择 {selectionInfo.count} 项 + {selectionInfo.isAllPagesSelected && ( + (全部页) + )} + + {!selectionInfo.isAllPagesSelected && selectionInfo.total > selectionInfo.count && ( + + 选择全部 {selectionInfo.total} 项 + + )} + + 清除 + +
+ + {/* 批量操作按钮 */} + {batchActions.map((action) => ( + + ))} +
+ )} +
+ + {/* 右侧搜索筛选区 */} +
+ + search?.onChange?.(e.target.value)} + value={search?.value} + /> + {filter && ( + + + {filter.title || '高级筛选'} +
+ } + trigger="click" + open={filter.visible} + onOpenChange={filter.onVisibleChange} + placement="bottomRight" + overlayClassName="filter-popover" + > + + + )} + + {showRefresh && ( + + )} +
+
+ ) +} + +export default ListActionBar diff --git a/components/ListTable/ListTable.css b/components/ListTable/ListTable.css new file mode 100644 index 0000000..0e6cd31 --- /dev/null +++ b/components/ListTable/ListTable.css @@ -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; +} diff --git a/components/ListTable/ListTable.tsx b/components/ListTable/ListTable.tsx new file mode 100644 index 0000000..46f8df4 --- /dev/null +++ b/components/ListTable/ListTable.tsx @@ -0,0 +1,117 @@ +import { Table } from "antd"; +import type { ColumnsType, TablePaginationConfig, TableRowSelection } from "antd/es/table"; +import "./ListTable.css"; + +export type ListTableProps> = { + columns: ColumnsType; + 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>({ + 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) { + const rowSelection: TableRowSelection | undefined = onSelectionChange + ? { + selectedRowKeys, + onChange: (newSelectedRowKeys) => { + onSelectionChange?.(newSelectedRowKeys); + }, + getCheckboxProps: () => ({ + disabled: isAllPagesSelected, + }), + } + : undefined; + + const mergedPagination = + pagination === false + ? false + : { + ...pagination, + showTotal: (total: number) => ( +
+ {isAllPagesSelected ? ( + <> + + 已选择 {totalCount || total} 项 + + {onClearSelection && ( + + 清除选择 + + )} + + ) : selectedRowKeys.length > 0 ? ( + <> + + 已选择 {selectedRowKeys.length} 项 + + {onSelectAllPages && selectedRowKeys.length < (totalCount || total) && ( + + 选择全部 {totalCount || total} 项 + + )} + {onClearSelection && ( + + 清除 + + )} + + ) : ( + 已选择 0 项 + )} +
+ ), + }; + + return ( +
+ ({ + onClick: () => onRowClick?.(record), + className: selectedRow?.[rowKey] === record[rowKey] ? "row-selected" : "", + })} + /> + + ); +} + +export default ListTable; diff --git a/components/MainLayout/AppHeader.css b/components/MainLayout/AppHeader.css new file mode 100644 index 0000000..6429ab2 --- /dev/null +++ b/components/MainLayout/AppHeader.css @@ -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; +} \ No newline at end of file diff --git a/components/MainLayout/AppHeader.jsx b/components/MainLayout/AppHeader.jsx new file mode 100644 index 0000000..ddb3bf7 --- /dev/null +++ b/components/MainLayout/AppHeader.jsx @@ -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 + case 'collaboration': return + default: return + } + } + + const notificationContent = ( +
+
+ 消息通知 + {unreadCount > 0 && ( + + )} +
+ }} + renderItem={(item) => ( + handleNotificationClick(item)} + > + } + title={{item.title}} + description={ +
+
{item.content}
+
{new Date(item.created_at).toLocaleString('zh-CN')}
+
+ } + /> +
+ )} + /> +
+ +
+
+ ) + + return ( +
+ {/* 左侧:Logo + 折叠按钮 */} + {showLogo && ( +
+ {/* Logo 区域 */} +
+ logo +

NexDocus

+
+ + {/* 折叠按钮 */} +
+ {collapsed ? : } +
+
+ )} + {!showLogo &&
} {/* Spacer if left is empty */} + + {/* 右侧:功能按钮 */} +
+ + + {/* 1. 主题切换 */} +
+ {isDarkMode ? : } +
+ + {/* 2. 语言切换 */} + + + {/* 3. 消息通知 */} + { + setPopoverVisible(visible) + if (visible) { + fetchNotifications() + } + }} + placement="bottomRight" + overlayClassName="header-notification-popover" + > +
+ + + +
+
+ +
+
+
+ ) +} + +export default AppHeader \ No newline at end of file diff --git a/components/MainLayout/AppSider.css b/components/MainLayout/AppSider.css new file mode 100644 index 0000000..f4a98aa --- /dev/null +++ b/components/MainLayout/AppSider.css @@ -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; +} diff --git a/components/MainLayout/AppSider.jsx b/components/MainLayout/AppSider.jsx new file mode 100644 index 0000000..4b9df79 --- /dev/null +++ b/components/MainLayout/AppSider.jsx @@ -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: , + DesktopOutlined: , + GlobalOutlined: , + CloudServerOutlined: , + UserOutlined: , + AppstoreOutlined: , + SettingOutlined: , + BlockOutlined: , + FolderOutlined: , + FileTextOutlined: , + SafetyOutlined: , + TeamOutlined: , + ProjectOutlined: , + ReadOutlined: , + 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] || ) : 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] || ) : 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 = ( +
+ logo + {!collapsed && ( + NexDocus + )} +
+ ) + + // 获取用户头像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 ( + + ) +} + +export default AppSider diff --git a/components/MainLayout/MainLayout.css b/components/MainLayout/MainLayout.css new file mode 100644 index 0000000..e71ef55 --- /dev/null +++ b/components/MainLayout/MainLayout.css @@ -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%; +} \ No newline at end of file diff --git a/components/MainLayout/MainLayout.jsx b/components/MainLayout/MainLayout.jsx new file mode 100644 index 0000000..726c328 --- /dev/null +++ b/components/MainLayout/MainLayout.jsx @@ -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 ( + + + + + +
+ {children} +
+
+
+
+ ) +} + +export default MainLayout diff --git a/components/MainLayout/index.js b/components/MainLayout/index.js new file mode 100644 index 0000000..12555fd --- /dev/null +++ b/components/MainLayout/index.js @@ -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' diff --git a/components/ModernSidebar/ModernSidebar.css b/components/ModernSidebar/ModernSidebar.css new file mode 100644 index 0000000..b22ec6b --- /dev/null +++ b/components/ModernSidebar/ModernSidebar.css @@ -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 */ +} \ No newline at end of file diff --git a/components/ModernSidebar/ModernSidebar.jsx b/components/ModernSidebar/ModernSidebar.jsx new file mode 100644 index 0000000..d4ce841 --- /dev/null +++ b/components/ModernSidebar/ModernSidebar.jsx @@ -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 ( + +
handleItemClick(item)} + > +
{item.icon}
+
+
+ ); + } + + return ( +
handleItemClick(item)} + > +
+
{item.icon}
+ {item.label} +
+ {isActive && } +
+ ); + }; + + return ( + + {/* 顶部 Logo 区域 */} +
+
+ {logo} +
+ {/* 折叠按钮 - 悬浮在边缘 */} +
onCollapse && onCollapse(!collapsed)} + > + {collapsed ? : } +
+
+ + {/* 菜单列表区域 */} +
+ {menuGroups.map((group, index) => ( +
+ {!collapsed && group.title && ( +
{group.title}
+ )} +
+ {group.items.map(item => renderMenuItem(item))} +
+
+ ))} +
+ + {/* 底部区域 */} +
+ {/* 帮助支持 */} + {!collapsed && ( +
+ + 帮助支持 +
+ )} + {collapsed && ( +
+ +
+ )} + + {/* 用户卡片 */} +
+
+ + {user?.name?.[0]?.toUpperCase() || 'U'} + + {!collapsed && ( +
+
{user?.name || 'User'}
+
{user?.role || 'Member'}
+
+ )} +
+ {!collapsed && ( +
+ +
+ )} +
+
+
+ ); +}; + +export default ModernSidebar; diff --git a/components/PDFViewer/PDFViewer.css b/components/PDFViewer/PDFViewer.css new file mode 100644 index 0000000..c850dba --- /dev/null +++ b/components/PDFViewer/PDFViewer.css @@ -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; +} diff --git a/components/PDFViewer/PDFViewer.jsx b/components/PDFViewer/PDFViewer.jsx new file mode 100644 index 0000000..e33faaf --- /dev/null +++ b/components/PDFViewer/PDFViewer.jsx @@ -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 ( +
+ {/* 工具栏 */} +
+ + + + + + + + + + + + + {Math.round(scale * 100)}% + + + +
+ + {/* PDF内容区 */} +
+ + +
正在加载PDF...
+
+ } + error={
PDF加载失败,请稍后重试
} + > + + +
正在渲染页面...
+
+ } + /> + + + + ) +} + +export default PDFViewer diff --git a/components/PDFViewer/VirtualPDFViewer.css b/components/PDFViewer/VirtualPDFViewer.css new file mode 100644 index 0000000..e95ef79 --- /dev/null +++ b/components/PDFViewer/VirtualPDFViewer.css @@ -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%; +} \ No newline at end of file diff --git a/components/PDFViewer/VirtualPDFViewer.jsx b/components/PDFViewer/VirtualPDFViewer.jsx new file mode 100644 index 0000000..4978844 --- /dev/null +++ b/components/PDFViewer/VirtualPDFViewer.jsx @@ -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 ( +
+ {/* 工具栏 */} +
+ + + + + + + + + + {Math.round(scale * 100)}% + + + +
+ + {/* PDF内容区 - 自定义虚拟滚动 */} +
+ + +
正在加载PDF...
+
+ } + error={
PDF加载失败,请稍后重试
} + options={pdfOptions} + > + {numPages && ( +
+ {Array.from({ length: numPages }, (_, index) => { + const pageNumber = index + 1 + const isVisible = visiblePages.has(pageNumber) + + return ( +
pageRefs.current[pageNumber] = el} + className="pdf-page-wrapper" + style={{ + position: 'absolute', + top: index * pageHeight, + left: 0, + right: 0, + height: pageHeight, + }} + > + {isVisible ? ( + <> + + +
加载第 {pageNumber} 页...
+
+ } + /> +
第 {pageNumber} 页
+ + ) : ( +
+
第 {pageNumber} 页
+
+ )} +
+ ) + })} +
+ )} + + + + ) +} + +export default VirtualPDFViewer diff --git a/components/PageHeader/PageHeader.css b/components/PageHeader/PageHeader.css new file mode 100644 index 0000000..6ba9202 --- /dev/null +++ b/components/PageHeader/PageHeader.css @@ -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%; + } +} diff --git a/components/PageHeader/PageHeader.jsx b/components/PageHeader/PageHeader.jsx new file mode 100644 index 0000000..b934376 --- /dev/null +++ b/components/PageHeader/PageHeader.jsx @@ -0,0 +1,35 @@ +import { ArrowLeftOutlined } from '@ant-design/icons' +import './PageHeader.css' + +function PageHeader({ + title, + description, + icon, + showBack = false, + onBack, + extra +}) { + return ( +
+
+ {showBack && ( + + )} +
+ {icon &&
{icon}
} +
+

{title}

+ {description && ( +

{description}

+ )} +
+
+
+ {extra &&
{extra}
} +
+ ) +} + +export default PageHeader diff --git a/components/PageTitleBar/PageTitleBar.css b/components/PageTitleBar/PageTitleBar.css new file mode 100644 index 0000000..f362ffb --- /dev/null +++ b/components/PageTitleBar/PageTitleBar.css @@ -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; + } +} diff --git a/components/PageTitleBar/PageTitleBar.jsx b/components/PageTitleBar/PageTitleBar.jsx new file mode 100644 index 0000000..a023b8f --- /dev/null +++ b/components/PageTitleBar/PageTitleBar.jsx @@ -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 ( +
+
+
+
+
+

{title}

+ {badge && {badge}} +
+ {description &&

{description}

} +
+
+
+ {actions &&
{actions}
} + {showToggle && ( + + )} +
+
+
+ ) +} + +export default PageTitleBar diff --git a/components/ProtectedRoute.jsx b/components/ProtectedRoute.jsx new file mode 100644 index 0000000..a6b1512 --- /dev/null +++ b/components/ProtectedRoute.jsx @@ -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 + } + + return children +} + +export default ProtectedRoute diff --git a/components/SelectionAlert/SelectionAlert.css b/components/SelectionAlert/SelectionAlert.css new file mode 100644 index 0000000..bfd2069 --- /dev/null +++ b/components/SelectionAlert/SelectionAlert.css @@ -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; + } +} diff --git a/components/SelectionAlert/SelectionAlert.jsx b/components/SelectionAlert/SelectionAlert.jsx new file mode 100644 index 0000000..82502cd --- /dev/null +++ b/components/SelectionAlert/SelectionAlert.jsx @@ -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 ( +
+ + + 已选择全部 {totalCount} 条数据 + + 清除选择 +
+ } + type="info" + showIcon + closable={false} + /> + + ) + } + + // 如果只选择了当前页,且总数大于当前页 + if (currentPageCount > 0 && totalCount > currentPageCount) { + return ( +
+ + + 已选择当前页 {currentPageCount} 条数据 + + + 选择全部 {totalCount} 条数据 + +
+ } + type="warning" + showIcon + closable={false} + /> + + ) + } + + // 只选择了部分数据,且总数等于当前页(单页情况) + return ( +
+ + + 已选择 {currentPageCount} 条数据 + + 清除选择 +
+ } + type="info" + showIcon + closable={false} + /> + + ) +} + +export default SelectionAlert diff --git a/components/SideInfoPanel/SideInfoPanel.css b/components/SideInfoPanel/SideInfoPanel.css new file mode 100644 index 0000000..f6f38a4 --- /dev/null +++ b/components/SideInfoPanel/SideInfoPanel.css @@ -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); + } +} diff --git a/components/SideInfoPanel/SideInfoPanel.jsx b/components/SideInfoPanel/SideInfoPanel.jsx new file mode 100644 index 0000000..b08e4bc --- /dev/null +++ b/components/SideInfoPanel/SideInfoPanel.jsx @@ -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 ( +
+ {sections.map((section) => { + const isCollapsed = collapsedSections[section.key] + + return ( +
+ {/* 区块头部 */} +
toggleSection(section.key)}> +
+ {section.icon && {section.icon}} + {section.title} +
+ +
+ + {/* 区块内容 */} + {!isCollapsed && ( +
{section.content}
+ )} +
+ ) + })} +
+ ) +} + +export default SideInfoPanel diff --git a/components/SplitLayout/SplitLayout.css b/components/SplitLayout/SplitLayout.css new file mode 100644 index 0000000..097fa77 --- /dev/null +++ b/components/SplitLayout/SplitLayout.css @@ -0,0 +1,72 @@ +/* 分栏布局容器 */ +.split-layout { + display: flex; + width: 100%; + align-items: flex-start; +} + +/* 横向布局(左右分栏) */ +.split-layout-horizontal { + flex-direction: row; +} + +/* 纵向布局(上下分栏) */ +.split-layout-vertical { + flex-direction: column; +} + +/* 主内容区 */ +.split-layout-main { + flex: 1; + min-width: 0; + width: 100%; + display: flex; + flex-direction: column; +} + +/* 扩展信息区 */ +.split-layout-extend { + flex-shrink: 0; + background: #ffffff; +} + +/* 右侧扩展区(横向布局) */ +.split-layout-extend-right { + height: 693px; + overflow-y: auto; + overflow-x: hidden; + position: sticky; + top: 16px; + padding-right: 4px; +} + +/* 顶部扩展区(纵向布局) */ +.split-layout-extend-top { + width: 100%; +} + +/* 滚动条样式(横向布局右侧扩展区) */ +.split-layout-extend-right::-webkit-scrollbar { + width: 6px; +} + +.split-layout-extend-right::-webkit-scrollbar-track { + background: #f5f5f5; + border-radius: 3px; +} + +.split-layout-extend-right::-webkit-scrollbar-thumb { + background: #d9d9d9; + border-radius: 3px; +} + +.split-layout-extend-right::-webkit-scrollbar-thumb:hover { + background: #bfbfbf; +} + +/* 响应式:小屏幕时隐藏右侧扩展区 */ +@media (max-width: 1200px) { + .split-layout-extend-right { + display: none; + } +} diff --git a/components/SplitLayout/SplitLayout.jsx b/components/SplitLayout/SplitLayout.jsx new file mode 100644 index 0000000..b54a2f7 --- /dev/null +++ b/components/SplitLayout/SplitLayout.jsx @@ -0,0 +1,69 @@ +import './SplitLayout.css' + +/** + * 主内容区布局组件 + * @param {Object} props + * @param {string} props.direction - 布局方向:'horizontal'(左右)| 'vertical'(上下) + * @param {ReactNode} props.mainContent - 主内容区 + * @param {ReactNode} props.extendContent - 扩展内容区 + * @param {number} props.extendSize - 扩展区尺寸(horizontal 模式下为宽度,px) + * @param {number} props.gap - 主内容与扩展区间距(px) + * @param {boolean} props.showExtend - 是否显示扩展区 + * @param {string} props.extendPosition - 扩展区位置(horizontal: 'right', vertical: 'top') + * @param {string} props.className - 自定义类名 + * + * @deprecated 旧参数(向后兼容):leftContent, rightContent, rightWidth, showRight + */ +function SplitLayout({ + // 新 API + direction = 'horizontal', + mainContent, + extendContent, + extendSize = 360, + gap = 16, + showExtend = true, + extendPosition, + className = '', + // 旧 API(向后兼容) + leftContent, + rightContent, + rightWidth, + showRight, +}) { + // 向后兼容:如果使用旧 API,转换为新 API + const actualMainContent = mainContent || leftContent + const actualExtendContent = extendContent || rightContent + const actualExtendSize = extendSize !== 360 ? extendSize : (rightWidth || 360) + const actualShowExtend = showExtend !== undefined ? showExtend : (showRight !== undefined ? showRight : true) + const actualDirection = direction + const actualExtendPosition = extendPosition || (actualDirection === 'horizontal' ? 'right' : 'top') + + return ( +
+ {/* 纵向布局且扩展区在顶部时,先渲染扩展区 */} + {actualDirection === 'vertical' && actualExtendPosition === 'top' && actualShowExtend && actualExtendContent && ( +
+ {actualExtendContent} +
+ )} + + {/* 主内容区 */} +
{actualMainContent}
+ + {/* 横向布局时,扩展区在右侧 */} + {actualDirection === 'horizontal' && actualShowExtend && actualExtendContent && ( +
+ {actualExtendContent} +
+ )} +
+ ) +} + +export default SplitLayout diff --git a/components/StatCard/StatCard.css b/components/StatCard/StatCard.css new file mode 100644 index 0000000..509d577 --- /dev/null +++ b/components/StatCard/StatCard.css @@ -0,0 +1,108 @@ +/* 统计卡片 */ +.stat-card { + padding: 16px; + background: #ffffff; + border-radius: 8px; + border: 1px solid #f0f0f0; + transition: all 0.3s ease; +} + +.stat-card:hover { + border-color: #d9d9d9; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +/* 一列布局(默认) */ +.stat-card-column { + /* 继承默认样式 */ +} + +/* 两列布局 */ +.stat-card.stat-card-row { + display: flex; + align-items: center; + gap: 16px; +} + +.stat-card.stat-card-row .stat-card-header { + flex: 1; + margin-bottom: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +.stat-card.stat-card-row .stat-card-body { + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +/* 卡片头部 */ +.stat-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.stat-card-title { + font-size: 13px; + color: rgba(0, 0, 0, 0.65); + font-weight: 500; +} + +.stat-card-icon { + font-size: 18px; + display: flex; + align-items: center; +} + +/* 卡片内容 */ +.stat-card-body { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 8px; +} + +.stat-card-value { + font-size: 24px; + font-weight: 600; + line-height: 1; +} + +.stat-card-suffix { + font-size: 14px; + font-weight: 400; + margin-left: 4px; + color: rgba(0, 0, 0, 0.45); +} + +/* 趋势指示器 */ +.stat-card-trend { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 500; + padding: 2px 6px; + border-radius: 4px; +} + +.stat-card-trend.trend-up { + color: #52c41a; + background: #f6ffed; +} + +.stat-card-trend.trend-down { + color: #ff4d4f; + background: #fff1f0; +} + +.stat-card-trend svg { + font-size: 10px; +} diff --git a/components/StatCard/StatCard.jsx b/components/StatCard/StatCard.jsx new file mode 100644 index 0000000..e333c5c --- /dev/null +++ b/components/StatCard/StatCard.jsx @@ -0,0 +1,78 @@ +import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons' +import './StatCard.css' + +/** + * 统计卡片组件 + * @param {Object} props + * @param {string} props.title - 卡片标题 + * @param {number|string} props.value - 统计值 + * @param {ReactNode} props.icon - 图标 + * @param {string} props.color - 主题颜色,默认 'blue' + * @param {Object} props.trend - 趋势信息 { value: number, direction: 'up' | 'down' } + * @param {string} props.suffix - 后缀单位 + * @param {string} props.layout - 布局模式: 'column' | 'row',默认 'column'(一列) + * @param {string} props.gridColumn - 网格列跨度,如 '1 / -1' 表示占满整行 + * @param {string} props.className - 自定义类名 + * @param {Function} props.onClick - 点击事件处理函数 + * @param {Object} props.style - 自定义样式对象 + */ +function StatCard({ + title, + value, + icon, + color = 'blue', + trend, + suffix = '', + layout = 'column', + gridColumn, + className = '', + onClick, + style: customStyle = {}, +}) { + const colorMap = { + blue: '#1677ff', + green: '#52c41a', + orange: '#faad14', + red: '#ff4d4f', + purple: '#722ed1', + gray: '#8c8c8c', + } + + const themeColor = colorMap[color] || color + + const style = { + ...(gridColumn ? { gridColumn } : {}), + ...customStyle, + } + + return ( +
+
+ {title} + {icon && ( + + {icon} + + )} +
+ +
+
+ {value} + {suffix && {suffix}} +
+ + {trend && ( +
+ {trend.direction === 'up' ? : } + {Math.abs(trend.value)}% +
+ )} +
+
+ ) +} + +export default StatCard diff --git a/components/Toast/Toast.tsx b/components/Toast/Toast.tsx new file mode 100644 index 0000000..70a921c --- /dev/null +++ b/components/Toast/Toast.tsx @@ -0,0 +1,81 @@ +import { notification } from "antd"; +import { + CheckCircleOutlined, + CloseCircleOutlined, + ExclamationCircleOutlined, + InfoCircleOutlined, +} from "@ant-design/icons"; + +notification.config({ + placement: "topRight", + top: 24, + duration: 3, + maxCount: 3, +}); + +const Toast = { + success: (message: string, description = "", duration = 3) => { + notification.success({ + message, + description, + duration, + icon: , + style: { + borderRadius: "8px", + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", + }, + }); + }, + + error: (message: string, description = "", duration = 3) => { + notification.error({ + message, + description, + duration, + icon: , + style: { + borderRadius: "8px", + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", + }, + }); + }, + + warning: (message: string, description = "", duration = 3) => { + notification.warning({ + message, + description, + duration, + icon: , + style: { + borderRadius: "8px", + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", + }, + }); + }, + + info: (message: string, description = "", duration = 3) => { + notification.info({ + message, + description, + duration, + icon: , + style: { + borderRadius: "8px", + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", + }, + }); + }, + + custom: (config: Record) => { + notification.open({ + ...config, + style: { + borderRadius: "8px", + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", + ...config.style, + }, + }); + }, +}; + +export default Toast; diff --git a/components/TreeFilterPanel/TreeFilterPanel.css b/components/TreeFilterPanel/TreeFilterPanel.css new file mode 100644 index 0000000..fd6341c --- /dev/null +++ b/components/TreeFilterPanel/TreeFilterPanel.css @@ -0,0 +1,58 @@ +/* 树形筛选面板 */ +.tree-filter-panel { + width: 320px; + max-height: 500px; + overflow-y: auto; +} + +/* 已选择的筛选条件 */ +.tree-filter-selected { + min-height: 40px; + padding: 12px; + background: #f5f7fa; + border-radius: 6px; + border: 1px dashed #d9d9d9; +} + +.tree-filter-tag { + display: flex; + align-items: center; + gap: 8px; +} + +.tree-filter-label { + font-size: 13px; + color: rgba(0, 0, 0, 0.65); + font-weight: 500; +} + +.tree-filter-placeholder { + display: flex; + align-items: center; + justify-content: center; + min-height: 24px; +} + +.tree-filter-placeholder span { + color: #8c8c8c; + font-size: 13px; +} + +/* 树形选择器容器 */ +.tree-filter-container { + max-height: 280px; + overflow-y: auto; +} + +.tree-filter-header { + font-size: 14px; + font-weight: 500; + margin-bottom: 12px; + color: rgba(0, 0, 0, 0.85); +} + +/* 操作按钮 */ +.tree-filter-actions { + display: flex; + justify-content: flex-end; +} diff --git a/components/TreeFilterPanel/TreeFilterPanel.jsx b/components/TreeFilterPanel/TreeFilterPanel.jsx new file mode 100644 index 0000000..2723c22 --- /dev/null +++ b/components/TreeFilterPanel/TreeFilterPanel.jsx @@ -0,0 +1,119 @@ +import { Tree, Tag, Divider, Button, Space } from 'antd' +import { useState, useEffect } from 'react' +import './TreeFilterPanel.css' + +/** + * 树形筛选面板组件 + * @param {Object} props + * @param {Array} props.treeData - 树形数据 + * @param {string} props.selectedKey - 当前选中的节点ID + * @param {string} props.tempSelectedKey - 临时选中的节点ID(确认前) + * @param {string} props.treeTitle - 树标题 + * @param {Function} props.onSelect - 选择变化回调 + * @param {Function} props.onConfirm - 确认筛选 + * @param {Function} props.onClear - 清除筛选 + * @param {string} props.placeholder - 占位提示文本 + */ +function TreeFilterPanel({ + treeData, + selectedKey, + tempSelectedKey, + treeTitle = '分组筛选', + onSelect, + onConfirm, + onClear, + placeholder = '请选择分组进行筛选', +}) { + // 获取所有节点的key用于默认展开 + const getAllKeys = (nodes) => { + let keys = [] + const traverse = (node) => { + keys.push(node.key) + if (node.children) { + node.children.forEach(traverse) + } + } + nodes.forEach(traverse) + return keys + } + + const [expandedKeys, setExpandedKeys] = useState([]) + + // 初始化时展开所有节点 + useEffect(() => { + if (treeData && treeData.length > 0) { + setExpandedKeys(getAllKeys(treeData)) + } + }, [treeData]) + + // 查找节点名称 + const findNodeName = (nodes, id) => { + for (const node of nodes) { + if (node.key === id) return node.title + if (node.children) { + const found = findNodeName(node.children, id) + if (found) return found + } + } + return '' + } + + const handleTreeSelect = (selectedKeys) => { + const key = selectedKeys[0] || null + onSelect?.(key) + } + + const handleExpand = (keys) => { + setExpandedKeys(keys) + } + + return ( +
+ {/* 已选择的筛选条件 */} +
+ {tempSelectedKey ? ( +
+ 已选择分组: + onSelect?.(null)}> + {findNodeName(treeData, tempSelectedKey)} + +
+ ) : ( +
+ {placeholder} +
+ )} +
+ + + + {/* 树形选择器 */} +
+
{treeTitle}
+ +
+ + + + {/* 操作按钮 */} +
+ + + + +
+
+ ) +} + +export default TreeFilterPanel diff --git a/design/database.md b/design/database.md deleted file mode 100644 index ec2bc83..0000000 --- a/design/database.md +++ /dev/null @@ -1,436 +0,0 @@ -# 数据库设计文档 - -本数据库采用 `MySQL 5.7+`。 - -## 1. 表结构概览 - -- **users**: 用户信息表 -- **roles**: 角色信息表 -- **meetings**: 会议主表 -- **attendees**: 参会人员关联表 -- **tags**: 标签表 -- **audio_files**: 会议音频文件表 -- **attachments**: 会议附件表 (未来扩展) -- **transcript_tasks**: 语音转录任务表 -- **transcript_segments**: 语音转录内容分段表 -- **meeting_summaries**: AI 生成的会议纪要表(由llm_tasks替代) -- **llm_tasks**: AI总结任务表 -- **prompts**: 提示词仓库表 -- **knowledge_bases**: 知识库主表 -- **knowledge_bases_task**: 知识库生成任务表 -- **dict_data**: 字典/码表数据表 -- **client_downloads**: 客户端下载管理表 -- **terminals**: 专用终端设备表 - ---- - -## 2. 表结构详情 - -### 2.1. `users` - 用户表 - -存储用户信息和登录凭证。 - -| 字段名 | 类型 | 约束 | 描述 | -| :--- | :--- | :--- | :--- | -| `user_id` | INT | PRIMARY KEY, AUTO_INCREMENT | 用户唯一ID | -| `role_id` | INT | NOT NULL, FK | 角色ID (关联 `roles` 表) | -| `username` | VARCHAR(50) | UNIQUE, NOT NULL | 登录用户名 | -| `caption` | VARCHAR(50) | NOT NULL | 用户显示昵称 | -| `email` | VARCHAR(100) | UNIQUE, NOT NULL | 用户邮箱 | -| `password_hash` | VARCHAR(255) | NOT NULL | 哈希后的密码 | -| `created_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 账户创建时间 | - -### 2.1.1 'user_voiceprint' - 用户声纹表 - -| 字段名 | 类型 | 说明 | -|--------|------|------| -| vp_id | INT | 主键,自增ID | -| user_id | INT | 用户ID,唯一索引 | -| file_path | VARCHAR(255) | 音频文件相对路径 | -| file_size | INT | 文件大小(字节) | -| duration_seconds | DECIMAL(5,2) | 音频时长(秒),如 10.25 | -| vector_data | TEXT | 声纹向量JSON数组,如 [0.123, 0.456, ...] | -| collected_at | TIMESTAMP | 采集时间 | -| updated_at | TIMESTAMP | 更新时间 | - -### 2.2. `roles` - 角色表 - -存储用户角色信息。 - -| 字段名 | 类型 | 约束 | 描述 | -| :--- | :--- | :--- | :--- | -| `role_id` | INT | PRIMARY KEY, AUTO_INCREMENT | 角色唯一ID | -| `role_name` | VARCHAR(50) | UNIQUE, NOT NULL | 角色名称 (e.g., "admin", "user") | - -### 2.3. `meetings` - 会议表 - -存储会议的核心元数据。 - -| 字段名 | 类型 | 约束 | 描述 | -| :--- | :--- | :--- | :--- | -| `meeting_id` | INT | PRIMARY KEY, AUTO_INCREMENT | 会议唯一ID | -| `user_id` | INT | FK | 会议创建者ID (关联 `users` 表) | -| `title` | VARCHAR(255) | NOT NULL | 会议标题 | -| `meeting_time` | TIMESTAMP | NULL | 会议召开时间 | -| `user_prompt` | TEXT | NULL | 用户输入的提示词 (重复)| -| `summary` | TEXT | NULL | 会议摘要 (Markdown格式) | -| `tags` | VARCHAR(1024) | NULL | 以逗号分隔的标签字符串 | -| `created_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 记录创建时间 | - -### 2.4. `attendees` - 参会人表 - -会议与用户的多对多关联表。 - -| 字段名 | 类型 | 约束 | 描述 | -| :--- | :--- | :--- | :--- | -| `attendee_id` | INT | PRIMARY KEY, AUTO_INCREMENT | 唯一ID | -| `meeting_id` | INT | FK | 会议ID (关联 `meetings` 表) | -| `user_id` | INT | FK | 用户ID (关联 `users` 表) | -| | | UNIQUE (`meeting_id`, `user_id`) | | - -### 2.5. `tags` - 标签表 - -存储所有唯一的标签及其颜色,用于快速检索和维护。 - -| 字段名 | 类型 | 约束 | 描述 | -| :--- | :--- | :--- | :--- | -| `id` | INT | PRIMARY KEY, AUTO_INCREMENT | 标签唯一ID | -| `name` | VARCHAR(255) | UNIQUE, NOT NULL | 标签名称 | -| `color` | VARCHAR(7) | DEFAULT '#409EFF' | 标签颜色 (HEX) | -| `creator_id` INT NOT NULL, COMMENT '创建者用户ID (关联 `users` 表) ' | -| `created_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 | -### 2.6. `audio_files` - 音频文件表 - -存储上传的会议音频文件信息。 - -| 字段名 | 类型 | 约束 | 描述 | -| :--- | :--- | :--- | :--- | -| `audio_id` | INT | PRIMARY KEY, AUTO_INCREMENT | 音频文件唯一ID | -| `meeting_id` | INT | FK | 关联的会议ID | -| `file_name` | VARCHAR(255) | | 原始文件名 | -| `file_path` | VARCHAR(512) | NOT NULL | 文件存储相对路径 | -| `file_size` | BIGINT | NULL | 文件大小 (bytes) | -| `upload_time` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 上传时间 | - -### 2.7. `attachments` - 会议附件表 - -存储会议相关的其他附件,如PPT、PDF等。 - -| 字段名 | 类型 | 约束 | 描述 | -| :--- | :--- | :--- | :--- | -| `attachment_id` | INT | PRIMARY KEY, AUTO_INCREMENT | 附件唯一ID | -| `meeting_id` | INT | FK | 关联的会议ID | -| `file_name` | VARCHAR(255) | NOT NULL | 原始文件名 | -| `file_path` | VARCHAR(512) | NOT NULL | 文件存储路径或URL | -| `file_type` | VARCHAR(100) | | 文件MIME类型 | -| `uploaded_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 上传时间 | - -### 2.8. `transcript_tasks` - 转录任务表 - -记录语音转录任务的状态和元数据。 - -| 字段名 | 类型 | 约束 | 描述 | -| :--- | :--- | :--- | :--- | -| `task_id` | VARCHAR(100) | PRIMARY KEY | 业务任务唯一ID (UUID) | -| `paraformer_task_id`| VARCHAR(100) | NULL | 外部服务 (Dashscope) 的任务ID | -| `meeting_id` | INT | NOT NULL, FK | 关联的会议ID | -| `status` | ENUM(...) | DEFAULT 'pending' | 任务状态: 'pending', 'processing', 'completed', 'failed' | -| `progress` | INT | DEFAULT 0 | 任务进度百分比 (0-100) | -| `created_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 任务创建时间 | -| `completed_at` | TIMESTAMP | NULL | 任务完成时间 | -| `error_message` | TEXT | NULL | 错误信息记录 | - -### 2.9. `transcript_segments` - 转录内容分段表 - -存储转录后的文字内容,按句子和发言人分段。 - -| 字段名 | 类型 | 约束 | 描述 | -| :--- | :--- | :--- | :--- | -| `segment_id` | INT | PRIMARY KEY, AUTO_INCREMENT | 分段唯一ID | -| `meeting_id` | INT | FK | 关联的会议ID | -| `speaker_id` | INT | | AI识别的原始发言人ID (e.g., 0, 1, 2) | -| `speaker_tag` | VARCHAR(50) | NOT NULL | 用户可编辑的发言人标签 (e.g., "张三") | -| `start_time_ms` | INT | NOT NULL | 在音频中的开始时间 (毫秒) | -| `end_time_ms` | INT | NOT NULL | 在音频中的结束时间 (毫秒) | -| `text_content` | TEXT | NOT NULL | 转录的文本内容 | - -### 2.10. `meeting_summaries` - 会议纪要表(作废) - -存储由LLM生成的会议纪要。 - -| 字段名 | 类型 | 约束 | 描述 | -| :--- | :--- | :--- | :--- | -| `id` | INT | PRIMARY KEY, AUTO_INCREMENT | 纪要唯一ID | -| `meeting_id` | INT | FK | 关联的会议ID | -| `summary_content` | TEXT | NULL | 生成的纪要内容 | -| `user_prompt` | TEXT | NULL | 用户输入的额外提示 | -| `prompt_id` | INT | FK | 关联的提示词模版ID | -| `created_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 生成时间 | - -### 2.11. `llm_tasks` - AI总结任务表 - -记录异步生成AI总结的任务状态。 - -| 字段名 | 类型 | 约束 | 描述 | -| :--- | :--- | :--- | :--- | -| `task_id` | VARCHAR(100) | PRIMARY KEY | 业务任务唯一ID (UUID) | -| `meeting_id` | INT | NOT NULL, FK | 关联的会议ID | -| `prompt_id` | INT | NOT NULL, FK | 关联的提示词模版ID | -| `user_prompt` | TEXT | NULL | 用户输入的额外提示 | -| `status` | ENUM(...) | DEFAULT 'pending' | 任务状态: 'pending', 'processing', 'completed', 'failed' | -| `progress` | INT | DEFAULT 0 | 任务进度百分比 (0-100) | -| `result` | TEXT | NULL | 成功时存储生成的纪要内容 | -| `created_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 任务创建时间 | -| `completed_at` | TIMESTAMP | NULL | 任务完成时间 | -| `error_message` | TEXT | NULL | 错误信息记录 | - -### 2.13. `knowledge_bases` - 知识库表 - -存储用户生成和管理的知识库条目。 - -| 字段名 | 类型 | 约束 | 描述 | -| :--- | :--- | :--- | :--- | -| `kb_id` | INT | PRIMARY KEY, AUTO_INCREMENT | 知识库条目唯一ID | -| `title` | VARCHAR(255) | NOT NULL | 标题 | -| `user_prompt` | TEXT | NULL | 用户输入的提示词(重复) | -| `content` | TEXT | NULL | 生成的知识库内容 (Markdown格式) | -| `creator_id` | INT | NOT NULL, FK | 创建者用户ID (关联 `users` 表) | -| `is_shared` | BOOLEAN | NOT NULL, DEFAULT FALSE | 是否为共享知识库 (TRUE: 共享, FALSE: 个人) | -| `source_meeting_ids` | TEXT | NULL | 内容来源的会议ID列表 (逗号分隔) | -| `tags` | VARCHAR(255) | NULL | 逗号分隔的标签 | -| `created_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 | -| `updated_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | 最后更新时间 | - -### 2.14. `knowledge_base_tasks` - 知识库生成任务表 - -记录异步生成知识库内容的AI任务状态。 - -| 字段名 | 类型 | 约束 | 描述 | -| :--- | :--- | :--- | :--- | -| `task_id` | VARCHAR(100) | PRIMARY KEY | 业务任务唯一ID (UUID) | -| `user_id` | INT | NOT NULL, FK | 发起任务的用户ID (关联 `users` 表) | -| `kb_id` | INT | NOT NULL, FK | 关联的知识库条目ID (关联 `knowledge_bases` 表) | -| `prompt_id` | INT | NOT NULL, FK | 关联的提示词模版ID | -| `user_prompt` | TEXT | NULL | 用户输入的提示词 | -| `status` | ENUM('pending', 'processing', 'completed', 'failed') | NOT NULL, DEFAULT 'pending' | 任务状态 | -| `progress` | INT | DEFAULT 0 | 任务进度百分比 (0-100) | -| `error_message` | TEXT | NULL | 任务失败时的错误信息 | -| `created_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 任务创建时间 | -| `updated_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | 任务状态更新时间 | -| `completed_at` | TIMESTAMP | NULL | 任务完成时间 | - -### 2.12. `prompts` - 提示词仓库表 - -存储系统中各种任务使用的大模型提示词模板。每个任务类型(会议任务、知识库任务)可以有多个提示词,但只能有一个默认且启用的提示词。 - -| 字段名 | 类型 | 约束 | 描述 | -| :--- | :--- | :--- | :--- | -| `id` | INT | AUTO_INCREMENT, PRIMARY KEY | 提示词唯一ID | -| `name` | VARCHAR(255) | NOT NULL | 提示词名称 | -| `task_type` | ENUM('MEETING_TASK', 'KNOWLEDGE_TASK') | NOT NULL | 任务类型:MEETING_TASK-会议任务, KNOWLEDGE_TASK-知识库任务 | -| `content` | TEXT | NOT NULL | 完整的提示词内容 | -| `is_default` | BOOLEAN | NOT NULL, DEFAULT FALSE | 是否为该任务类型的默认模板 | -| `is_active` | BOOLEAN | NOT NULL, DEFAULT TRUE | 是否启用(只有启用的提示词才能被使用) | -| `creator_id` | INT | NOT NULL, FK | 创建者用户ID (关联 `users` 表) | -| `created_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 | - -**约束说明:** -- 每个 `task_type` 同一时间只能有一个 `is_default=TRUE` 的提示词 -- 业务逻辑需确保:设置新默认提示词时,自动取消同类型其他提示词的默认状态 - -### 2.15. `prompt_config` - 提示词配置表(已废弃) - -**该表已废弃,功能整合到 `prompts` 表的 `task_type` 和 `is_default` 字段。** - -### 2.16. `dict_data` - 字典/码表数据表 - -存储系统中的码表数据,支持树形结构和扩展属性,用于管理客户端平台类型等配置数据。 - -| 字段名 | 类型 | 约束 | 描述 | -| :--- | :--- | :--- | :--- | -| `id` | BIGINT | PRIMARY KEY, AUTO_INCREMENT | 主键ID | -| `dict_type` | VARCHAR(64) | NOT NULL, DEFAULT 'client_platform' | 字典类型(如 client_platform) | -| `dict_code` | VARCHAR(64) | NOT NULL | 业务编码(唯一Key,如 WIN、IOS、ANDROID) | -| `parent_code` | VARCHAR(64) | NOT NULL, DEFAULT 'ROOT' | 父级编码(ROOT为顶级,支持树形结构) | -| `tree_path` | VARCHAR(255) | NULL | 层级路径(辅助字段,如 0,1,10) | -| `label_cn` | VARCHAR(128) | NOT NULL | 中文名称 | -| `label_en` | VARCHAR(128) | NULL | 英文名称 | -| `sort_order` | INT | DEFAULT 0 | 排序权重 | -| `extension_attr` | JSON | NULL | 扩展属性(JSON格式,存储平台特有属性) | -| `is_default` | TINYINT | DEFAULT 0 | 是否默认选中(0: 否, 1: 是) | -| `status` | TINYINT | DEFAULT 1 | 状态(1: 正常, 0: 停用) | -| `create_time` | DATETIME | DEFAULT CURRENT_TIMESTAMP | 创建时间 | -| | | UNIQUE KEY (`dict_type`, `dict_code`) | 确保编码在同类型下唯一 | - -**扩展属性示例(extension_attr):** -```json -{ - "suffix": ".exe", - "arch_support": ["x86", "x64"], - "icon": "monitor" -} -``` - -**树形结构示例:** -- ROOT - - DESKTOP (桌面端) - - WIN (Windows) - - MAC (macOS) - - LINUX (Linux) - - MOBILE (移动端) - - IOS (苹果iOS) - - ANDROID (安卓) - - TERMINAL (专用终端) - - TERM_STD (通用终端) - - TERM_S100 (中兴终端) - -### 2.17. `client_downloads` - 客户端下载管理表 - -存储各平台客户端版本信息,用于管理客户端下载和版本更新。 - -| 字段名 | 类型 | 约束 | 描述 | -| :--- | :--- | :--- | :--- | -| `id` | INT | PRIMARY KEY, AUTO_INCREMENT | 主键ID | -| `platform_code` | VARCHAR(64) | NOT NULL | 平台编码(关联 `dict_data.dict_code`) | -| `version` | VARCHAR(50) | NOT NULL | 版本号(如 1.0.0) | -| `version_code` | INT | NOT NULL | 版本号数值(用于版本比较) | -| `download_url` | VARCHAR(512) | NOT NULL | 下载链接 | -| `file_size` | BIGINT | NULL | 文件大小(bytes) | -| `release_notes` | TEXT | NULL | 更新说明 | -| `is_active` | BOOLEAN | NOT NULL, DEFAULT TRUE | 是否启用 | -| `is_latest` | BOOLEAN | NOT NULL, DEFAULT FALSE | 是否为最新版本 | -| `min_system_version` | VARCHAR(50) | NULL | 最低系统版本要求 | -| `created_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 | -| `updated_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | 更新时间 | -| `created_by` | INT | NULL, FK | 创建者用户ID(关联 `users` 表) | -| | | INDEX (`platform_code`) | 平台编码索引 | - -**约束说明:** -- 同一 `platform_code` 同一时间只应有一个 `is_latest=TRUE` 的版本 -- `platform_code` 关联 `dict_data` 表的 `dict_code` 字段(client_platform类型) -- 业务逻辑需确保:设置新最新版本时,自动取消同平台其他版本的最新状态 - -### 2.18. `terminals` - 专用终端设备表 - -存储专用终端设备信息(如录音笔、会议平板等),用于设备激活管理和状态监控。 - -| 字段名 | 类型 | 约束 | 描述 | -| :--- | :--- | :--- | :--- | -| `id` | INT | PRIMARY KEY, AUTO_INCREMENT | 主键ID | -| `imei` | VARCHAR(64) | NOT NULL, UNIQUE | IMEI号(设备唯一标识) | -| `terminal_name` | VARCHAR(100) | NULL | 终端名称/设备别名 | -| `terminal_type` | VARCHAR(50) | NOT NULL | 终端类型(关联 `dict_data.dict_code`) | -| `description` | VARCHAR(500) | NULL | 终端说明/备注 | -| `status` | TINYINT(1) | NOT NULL, DEFAULT 1 | 启用状态: 1-启用, 0-停用 | -| `is_activated` | TINYINT(1) | NOT NULL, DEFAULT 0 | 激活状态: 1-已激活, 0-未激活 | -| `activated_at` | DATETIME | NULL | 激活时间 | -| `firmware_version` | VARCHAR(50) | NULL | 当前固件版本 | -| `last_online_at` | DATETIME | NULL | 最后在线/心跳时间 | -| `ip_address` | VARCHAR(50) | NULL | 最近一次连接IP | -| `mac_address` | VARCHAR(64) | NULL | MAC地址 | -| `created_at` | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 录入时间 | -| `updated_at` | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE | 更新时间 | -| `created_by` | INT | NULL, FK | 录入人ID(关联 `users` 表) | -| | | KEY `idx_terminal_type` | 终端类型索引 | -| | | KEY `idx_status` | 状态索引 | - ---- - -## 3. 关系图 (ERD) - -```mermaid -erDiagram - users { - int user_id PK - varchar(50) username - varchar(50) caption - varchar(100) email - varchar(255) password_hash - int role_id FK - timestamp created_at - } - - roles { - int role_id PK - varchar(50) role_name - } - - meetings { - int meeting_id PK - int user_id FK - varchar(255) title - timestamp meeting_time - text summary - varchar(1024) tags - timestamp created_at - } - - tags { - int id PK - varchar(255) name - varchar(7) color - } - - attendees { - int attendee_id PK - int meeting_id FK - int user_id FK - } - - audio_files { - int audio_id PK - int meeting_id FK - varchar(512) file_path - varchar(100) task_id - } - - transcript_tasks { - varchar(100) task_id PK - int meeting_id FK - varchar(100) paraformer_task_id - enum status - int progress - } - - transcript_segments { - int segment_id PK - int meeting_id FK - int speaker_id - varchar(50) speaker_tag - int start_time_ms - int end_time_ms - text text_content - } - - meeting_summaries { - int id PK - int meeting_id FK - text summary_content - } - - llm_tasks { - varchar(100) task_id PK - int meeting_id FK - enum status - int progress - } - - dedicated_terminals { - int id PK - varchar(64) imei - varchar(50) terminal_type - tinyint status - tinyint is_activated - } - - users ||--o{ meetings : "creates" - users ||--o{ attendees : "attends" - users }|..|| roles : "has role" - meetings ||--|{ attendees : "has" - meetings ||--|{ audio_files : "has" - meetings ||--|{ transcript_tasks : "has" - meetings ||--|{ transcript_segments : "has" - meetings ||--|{ meeting_summaries : "has" - meetings ||--|{ llm_tasks : "has" - -``` \ No newline at end of file diff --git a/frontend/IMPLEMENTATION_PLAN.md b/frontend/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..a1d2270 --- /dev/null +++ b/frontend/IMPLEMENTATION_PLAN.md @@ -0,0 +1,37 @@ +# IMPLEMENTATION_PLAN.md - Login Page Optimization + +## Stage 1: Visual and Functional Refinement of Login Page + +Goal: +- Refine the login page UI based on standard high-quality admin dashboard patterns and the provided design reference. +- Improve the interaction for captcha and device code acquisition. + +Success Criteria: +- A more professional and visually balanced layout. +- Clearer separation between standard login and device-bound login. +- Responsive design working across mobile and desktop. + +Tests: +- Verify captcha refreshing on click. +- Verify login flow with and without device code. +- Verify responsive layout at 1200px, 980px, and 640px. + +Status: +- Complete + +## Stage 2: Dashboard Layout and Dynamic Menu + +Goal: +- Implement a professional dashboard layout with stats and recent activities. +- Make the sidebar menu dynamic based on backend permission data. + +Success Criteria: +- Dashboard shows meaningful stats cards and status indicators. +- Sidebar reflects the permissions/menus defined in the backend. + +Tests: +- Verify that changing permission status in backend updates the sidebar. +- Verify dashboard responsiveness. + +Status: +- In Progress diff --git a/frontend/design/AGENTS.md b/frontend/design/AGENTS.md new file mode 100644 index 0000000..ad417a4 --- /dev/null +++ b/frontend/design/AGENTS.md @@ -0,0 +1,198 @@ +# AGENTS.md(Frontend) + +## 一、项目定位 + +本模块为 **后台管理 Web 页面**,用于: + +* 用户 / 角色 / 权限管理 +* 设备管理 +* 任务与状态查看 + +这是一个 **管理后台系统**,不是面向终端用户的产品页面,设计以“效率与稳定”为首要目标。 + +--- + +## 二、技术栈(必须遵守) + +* React: **18** +* Language: **TypeScript** +* UI Library: **Ant Design** +* Router: React Router +* HTTP: Axios +* State: React Hooks(必要时可用 Zustand / Redux) +* Build: Vite 或 CRA(以项目实际为准) + +⚠️ 禁止引入与 Ant Design 冲突的 UI 框架或样式体系。 + +--- + +## 三、目录结构约定 + +``` +src +├── api # 后端接口封装 +├── components # 通用组件 +├── layouts # 页面布局 +├── pages # 页面级组件 +├── routes # 路由定义 +├── hooks # 自定义 hooks +├── utils # 工具函数 +└── types # TS 类型定义 +``` + +--- + +## 四、角色与理念 + +你是一位**务实型前端开发者 Agent**,目标是: + +> 以清晰的数据流和稳定的交互,构建易维护的管理后台。 + +### 核心原则 + +* 清晰的意图胜于技巧性的实现 +* 组件简单直观优于过度抽象 +* 奥卡姆剃刀:不必要的复杂度一律删除 +* 组合优于继承 +* 显式状态优于隐式副作用 + +### 编码风格 + +* 准确简洁,贴近业务语义 +* 小修改不输出摘要 +* 拒绝炫技与过度封装 + +--- + +## 五、开发流程(强制) + +### 行为约束 +1. 在执行任何修改前,必须**阅读并遵守**本项目的设计文档(位于 `docs/design/`)。 +2. 所有功能改动都必须更新设计文档 +3. 遵循代码风格、目录结构和 Git 工作流规则 +### 5.1 规划阶段 + +复杂页面必须先给出实现计划: + +`IMPLEMENTATION_PLAN.md` + +``` +## Stage N: [Name] + +Goal: +- 可交付界面或功能 + +Success Criteria: +- 可验证交互 + +Tests: +- 操作与边界场景 + +Status: +- Not Started | In Progress | Complete +``` + +--- + +### 5.2 实现循环 + +1. **理解** + + * 查找 ≥3 个相似页面 + * 遵循项目交互约定 + +2. **测试/验证** + + * 先定义接口与数据结构 + * 明确边界与异常 + +3. **实现** + + * 最小可用组件 + * 先通再优 + +4. **重构** + + * 保证可读与可复用 + +--- + +### 5.3 三次机会规则 + +同一问题最多尝试 **3 次**: + +若仍失败,必须输出: + +* 已尝试方案 +* 具体错误 +* 类似实现对比 +* 根本性问题反思 + +--- + +## 六、质量关卡(DoD) + +交付前必须: + +* 类型检查通过 +* 无 ESLint 警告 +* 接口异常已处理 +* 表单有校验 +* 交互可回滚 +* 不随意引入新依赖 + +--- + +## 七、UI 与交互准则 + +* 严格使用 Ant Design 组件 + +* 表单必须: + + * 校验 + * 防重复提交 + * 明确错误提示 + +* 表格必须: + + * 分页 + * 加载态 + * 空状态 + +* 接口必须: + + * 统一封装 + * 错误拦截 + * 类型定义 + +--- + +## 八、代码规范 + +* 页面 = 容器 + 组件 + +* 禁止: + + * 页面直调 axios + * any 类型 + * 过度全局状态 + * 组件内写业务接口 + +* 数据流: + API → Hooks → Page → Component + +--- + +## 九、与后端协同 + +* 所有接口走 `src/api` +* 类型以后端契约为准 +* 使用统一 Result 结构 +* 不模拟后端业务逻辑 + +--- + +**一句话原则:** + +> 用最直接的组件 + 最清晰的状态 + 最稳定的交互, +> 构建可长期维护的管理后台。 diff --git a/frontend/design/开发规范.md b/frontend/design/开发规范.md new file mode 100644 index 0000000..cf1748e --- /dev/null +++ b/frontend/design/开发规范.md @@ -0,0 +1,175 @@ +# Nex Design 前端设计规范 + +面向 **React + Ant Design + Tailwind CSS** 的前端设计语言系统,目标: + +- **一致性**:保证视觉与交互统一 +- **高效性**:提供可复用组件与布局模式 +- **可维护性**:清晰规范,便于迭代 +- **用户体验**:直观易用 + +## 技术栈 + +- 框架:React 18+ +- 组件库:Ant Design 5.x +- 样式:Tailwind CSS 3.x +- 包管理:Yarn +- 运行时:Node.js 16+ + +--- + +## 设计原则 + +- **清晰明确**:界面直观,操作易理解 +- **一致性优先**:视觉、交互、用词统一 +- **效率至上**:减少操作步骤,提高效率 +- **反馈及时**:操作有明确状态反馈 +- **容错友好**:预防错误,提示明确 + +--- + +## 颜色系统 + +| 类型 | 用途 | 颜色值 | +|------|------|-------| +| 主色调 | 关键按钮、重要信息、链接 | `#b8178d` | +| 辅助色 | 信息提示 | `#1677ff` | +| 功能色 | Success / Warning / Error / Info | 按语义使用 | +| 中性色 | 文本、背景、边框 | - | + +**使用规范**: + +- 主色:关键按钮、重要信息、链接 +- 功能色:按语义使用,不混淆 +- 中性色:文本、背景、边框 +- 对比度 ≥ 4.5:1 + +--- + +## 排版规范 + +### 字体 + +- 默认:`-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto …` +- 等宽:`'SF Mono', 'Monaco', 'Fira Code', …` + +### 字号 + +| 用途 | 大小 | Tailwind 类 | 场景 | +|------|------|-------------|------| +| 特大标题 | 32px | `text-4xl` | 页面主标题 | +| 大标题 | 24px | `text-2xl` | 区块标题 | +| 中标题 | 20px | `text-xl` | 卡片标题 | +| 小标题 | 16px | `text-base` | 表单标签 | +| 正文 | 14px | `text-sm` | 正文 | +| 辅助文字 | 12px | `text-xs` | 说明 | + +### 字重 + +| 用途 | 字重 | +|------|-----| +| 正文 | Regular 400 | +| 表单标签、列表 | Medium 500 | +| 小标题、强调 | Semibold 600 | +| 标题、重要信息 | Bold 700 | + +--- + +## 间距系统 + +- 基于 **8px 网格**,间距均为 8 的倍数 +- Tailwind 对应类: + +| 尺寸 | Tailwind 类 | 间距 | +|------|------------|------| +| xs | p-1 / m-1 | 4px | +| sm | p-2 / m-2 | 8px | +| md | p-4 / m-4 | 16px | +| lg | p-6 / m-6 | 24px | +| xl | p-8 / m-8 | 32px | +| 2xl | p-12 / m-12 | 48px | + +--- + +## 组件规范 + +### 按钮 (Button) + +- 类型:Primary / Default / Text / Link / Danger +- 尺寸:Large 40px / Middle 32px / Small 24px +- 使用: + - 单区域最多一个主按钮 + - 文字 ≤ 4 个字 + - 危险操作二次确认 + +### 表单 (Form) + +- 布局:vertical +- 必填:红色星号 +- 字段宽度合理,间距 24px +- 错误提示显示在字段下方 + +### 表格 (Table) + +- 分页默认 10 条 +- 行高 54px (middle) +- 操作列固定右侧 +- 加载状态使用 `loading` + +### 卡片 (Card) + +- 内边距 24px +- 圆角 8px +- 阴影 `shadow-sm` +- 卡片间距 16px + +--- + +## 布局规范 + +- 页面:Header 64px, Sider 200px, Content 区域 +- 栅格:24 栏,使用 Ant Design Grid +- 页面内边距 24px,内容最大宽 1200px +- 响应式:Flexbox/Grid + Tailwind 前缀 + +--- + +## 交互规范 + +- **全局提示**:`message.success/error/warning/loading` +- **通知提醒**:`notification.open` +- **模态框**:`Modal.confirm` +- **加载状态**:`Spin / Skeleton / Table loading` +- **动画**:300ms, ease-in-out + +--- + +## 页面模板 + +### 通用结构 + +- 面包屑导航 +- 页面标题区 +- 主要内容区 + +### 已实现 + +- 主框架页面:侧边栏、顶部导航、内容滚动 +- Dashboard:统计卡片、图表 + +### 待完善 + +- 列表页 +- 详情页 +- 表单页 +- 设置页 + +--- + +## 开发规范 + +- **组件命名**:PascalCase +- **文件命名**:与组件同名 +- **样式类命名**:kebab-case +- **常量命名**:UPPER_SNAKE_CASE +- **样式**:Tailwind 优先,Ant Design 主题定制,自定义样式放组件目录 + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..5a7946d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + MeetingAI - 智能会议系统 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..f4b8d4e --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3173 @@ +{ + "name": "imeeting-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "imeeting-frontend", + "version": "0.1.0", + "dependencies": { + "@ant-design/icons": "^6.1.0", + "antd": "^5.13.2", + "axios": "^1.6.7", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.22.3", + "zustand": "^4.5.2" + }, + "devDependencies": { + "@types/react": "^18.2.55", + "@types/react-dom": "^18.2.19", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.3.3", + "vite": "^5.0.12" + } + }, + "node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmmirror.com/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "1.24.0", + "resolved": "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", + "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "classnames": "^2.3.1", + "csstype": "^3.1.3", + "rc-util": "^5.35.0", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", + "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.0", + "@babel/runtime": "^7.23.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/@ant-design/icons/-/icons-6.1.0.tgz", + "integrity": "sha512-KrWMu1fIg3w/1F2zfn+JlfNDU8dDqILfA5Tg85iqs1lf8ooyGlbkA+TkwfOKKgqpUmAiRY1PTFpuOU2DAIgSUg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "license": "MIT" + }, + "node_modules/@ant-design/icons/node_modules/@ant-design/colors": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/@ant-design/colors/-/colors-8.0.1.tgz", + "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.0" + } + }, + "node_modules/@ant-design/icons/node_modules/@ant-design/fast-color": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/@ant-design/fast-color/-/fast-color-3.0.1.tgz", + "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==", + "license": "MIT", + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/react-slick": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@ant-design/react-slick/-/react-slick-1.1.2.tgz", + "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/@rc-component/async-validator/-/async-validator-5.1.0.tgz", + "integrity": "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/@rc-component/color-picker/-/color-picker-2.0.1.tgz", + "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6", + "@babel/runtime": "^7.23.6", + "classnames": "^2.2.6", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@rc-component/mini-decimal/-/mini-decimal-1.1.0.tgz", + "integrity": "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", + "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@rc-component/qrcode/-/qrcode-1.1.1.tgz", + "integrity": "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "1.15.1", + "resolved": "https://registry.npmmirror.com/@rc-component/tour/-/tour-1.15.1.tgz", + "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/portal": "^1.0.0-9", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/trigger": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/@rc-component/trigger/-/trigger-2.3.1.tgz", + "integrity": "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.44.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/util": { + "version": "1.9.0", + "resolved": "https://registry.npmmirror.com/@rc-component/util/-/util-1.9.0.tgz", + "integrity": "sha512-5uW6AfhIigCWeEQDthTozlxiT4Prn6xYQWeO0xokjcaa186OtwPRHBZJ2o0T0FhbjGhZ3vXdbkv0sx3gAYW7Vg==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmmirror.com/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/antd": { + "version": "5.29.3", + "resolved": "https://registry.npmmirror.com/antd/-/antd-5.29.3.tgz", + "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.2.1", + "@ant-design/cssinjs": "^1.23.0", + "@ant-design/cssinjs-utils": "^1.1.3", + "@ant-design/fast-color": "^2.0.6", + "@ant-design/icons": "^5.6.1", + "@ant-design/react-slick": "~1.1.2", + "@babel/runtime": "^7.26.0", + "@rc-component/color-picker": "~2.0.1", + "@rc-component/mutate-observer": "^1.1.0", + "@rc-component/qrcode": "~1.1.0", + "@rc-component/tour": "~1.15.1", + "@rc-component/trigger": "^2.3.0", + "classnames": "^2.5.1", + "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.11", + "rc-cascader": "~3.34.0", + "rc-checkbox": "~3.5.0", + "rc-collapse": "~3.9.0", + "rc-dialog": "~9.6.0", + "rc-drawer": "~7.3.0", + "rc-dropdown": "~4.2.1", + "rc-field-form": "~2.7.1", + "rc-image": "~7.12.0", + "rc-input": "~1.8.0", + "rc-input-number": "~9.5.0", + "rc-mentions": "~2.20.0", + "rc-menu": "~9.16.1", + "rc-motion": "^2.9.5", + "rc-notification": "~5.6.4", + "rc-pagination": "~5.1.0", + "rc-picker": "~4.11.3", + "rc-progress": "~4.0.0", + "rc-rate": "~2.13.1", + "rc-resize-observer": "^1.4.3", + "rc-segmented": "~2.7.0", + "rc-select": "~14.16.8", + "rc-slider": "~11.1.9", + "rc-steps": "~6.0.1", + "rc-switch": "~4.1.0", + "rc-table": "~7.54.0", + "rc-tabs": "~15.7.0", + "rc-textarea": "~1.10.2", + "rc-tooltip": "~6.4.0", + "rc-tree": "~5.13.1", + "rc-tree-select": "~5.27.0", + "rc-upload": "~4.11.0", + "rc-util": "^5.44.4", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/antd/node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmmirror.com/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001769", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "license": "MIT", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/rc-cascader": { + "version": "3.34.0", + "resolved": "https://registry.npmmirror.com/rc-cascader/-/rc-cascader-3.34.0.tgz", + "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "^2.3.1", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-checkbox": { + "version": "3.5.0", + "resolved": "https://registry.npmmirror.com/rc-checkbox/-/rc-checkbox-3.5.0.tgz", + "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.25.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-collapse": { + "version": "3.9.0", + "resolved": "https://registry.npmmirror.com/rc-collapse/-/rc-collapse-3.9.0.tgz", + "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.3.4", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dialog": { + "version": "9.6.0", + "resolved": "https://registry.npmmirror.com/rc-dialog/-/rc-dialog-9.6.0.tgz", + "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.0.0-8", + "classnames": "^2.2.6", + "rc-motion": "^2.3.0", + "rc-util": "^5.21.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-drawer": { + "version": "7.3.0", + "resolved": "https://registry.npmmirror.com/rc-drawer/-/rc-drawer-7.3.0.tgz", + "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@rc-component/portal": "^1.1.1", + "classnames": "^2.2.6", + "rc-motion": "^2.6.1", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dropdown": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/rc-dropdown/-/rc-dropdown-4.2.1.tgz", + "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-util": "^5.44.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/rc-field-form": { + "version": "2.7.1", + "resolved": "https://registry.npmmirror.com/rc-field-form/-/rc-field-form-2.7.1.tgz", + "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/async-validator": "^5.0.3", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-image": { + "version": "7.12.0", + "resolved": "https://registry.npmmirror.com/rc-image/-/rc-image-7.12.0.tgz", + "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/portal": "^1.0.2", + "classnames": "^2.2.6", + "rc-dialog": "~9.6.0", + "rc-motion": "^2.6.2", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-input": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/rc-input/-/rc-input-1.8.0.tgz", + "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.18.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-input-number": { + "version": "9.5.0", + "resolved": "https://registry.npmmirror.com/rc-input-number/-/rc-input-number-9.5.0.tgz", + "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/mini-decimal": "^1.0.1", + "classnames": "^2.2.5", + "rc-input": "~1.8.0", + "rc-util": "^5.40.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-mentions": { + "version": "2.20.0", + "resolved": "https://registry.npmmirror.com/rc-mentions/-/rc-mentions-2.20.0.tgz", + "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.5", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-input": "~1.8.0", + "rc-menu": "~9.16.0", + "rc-textarea": "~1.10.0", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-menu": { + "version": "9.16.1", + "resolved": "https://registry.npmmirror.com/rc-menu/-/rc-menu-9.16.1.tgz", + "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.0.0", + "classnames": "2.x", + "rc-motion": "^2.4.3", + "rc-overflow": "^1.3.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.5", + "resolved": "https://registry.npmmirror.com/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-notification": { + "version": "5.6.4", + "resolved": "https://registry.npmmirror.com/rc-notification/-/rc-notification-5.6.4.tgz", + "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.9.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-overflow": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/rc-overflow/-/rc-overflow-1.5.0.tgz", + "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-pagination": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/rc-pagination/-/rc-pagination-5.1.0.tgz", + "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-picker": { + "version": "4.11.3", + "resolved": "https://registry.npmmirror.com/rc-picker/-/rc-picker-4.11.3.tgz", + "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.1", + "rc-overflow": "^1.3.2", + "rc-resize-observer": "^1.4.0", + "rc-util": "^5.43.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/rc-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/rc-progress/-/rc-progress-4.0.0.tgz", + "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-rate": { + "version": "2.13.1", + "resolved": "https://registry.npmmirror.com/rc-rate/-/rc-rate-2.13.1.tgz", + "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-resize-observer": { + "version": "1.4.3", + "resolved": "https://registry.npmmirror.com/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", + "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.44.1", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-segmented": { + "version": "2.7.1", + "resolved": "https://registry.npmmirror.com/rc-segmented/-/rc-segmented-2.7.1.tgz", + "integrity": "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-select": { + "version": "14.16.8", + "resolved": "https://registry.npmmirror.com/rc-select/-/rc-select-14.16.8.tgz", + "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.1.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-overflow": "^1.3.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-slider": { + "version": "11.1.9", + "resolved": "https://registry.npmmirror.com/rc-slider/-/rc-slider-11.1.9.tgz", + "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-steps": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/rc-steps/-/rc-steps-6.0.1.tgz", + "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.16.7", + "classnames": "^2.2.3", + "rc-util": "^5.16.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/rc-switch/-/rc-switch-4.1.0.tgz", + "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0", + "classnames": "^2.2.1", + "rc-util": "^5.30.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.54.0", + "resolved": "https://registry.npmmirror.com/rc-table/-/rc-table-7.54.0.tgz", + "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/context": "^1.4.0", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.44.3", + "rc-virtual-list": "^3.14.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tabs": { + "version": "15.7.0", + "resolved": "https://registry.npmmirror.com/rc-tabs/-/rc-tabs-15.7.0.tgz", + "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "2.x", + "rc-dropdown": "~4.2.0", + "rc-menu": "~9.16.0", + "rc-motion": "^2.6.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.34.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-textarea": { + "version": "1.10.2", + "resolved": "https://registry.npmmirror.com/rc-textarea/-/rc-textarea-1.10.2.tgz", + "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-input": "~1.8.0", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "6.4.0", + "resolved": "https://registry.npmmirror.com/rc-tooltip/-/rc-tooltip-6.4.0.tgz", + "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.1", + "rc-util": "^5.44.3" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tree": { + "version": "5.13.1", + "resolved": "https://registry.npmmirror.com/rc-tree/-/rc-tree-5.13.1.tgz", + "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-tree-select": { + "version": "5.27.0", + "resolved": "https://registry.npmmirror.com/rc-tree-select/-/rc-tree-select-5.27.0.tgz", + "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "2.x", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-upload": { + "version": "4.11.0", + "resolved": "https://registry.npmmirror.com/rc-upload/-/rc-upload-4.11.0.tgz", + "integrity": "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmmirror.com/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-virtual-list": { + "version": "3.19.2", + "resolved": "https://registry.npmmirror.com/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz", + "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmmirror.com/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8cb4008 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "imeeting-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@ant-design/icons": "^6.1.0", + "antd": "^5.13.2", + "axios": "^1.6.7", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.22.3", + "zustand": "^4.5.2" + }, + "devDependencies": { + "@types/react": "^18.2.55", + "@types/react-dom": "^18.2.19", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.3.3", + "vite": "^5.0.12" + } +} diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg new file mode 100644 index 0000000..37db905 --- /dev/null +++ b/frontend/public/logo.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..a7fa3c9 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,5 @@ +import AppRoutes from "./routes"; + +export default function App() { + return ; +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..22fd2ce --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1 @@ +export * from "./api/index"; diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..8f61203 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,49 @@ +import http from "./http"; + +export interface CaptchaResponse { + captchaId: string; + imageBase64: string; +} + +export interface TokenResponse { + accessToken: string; + refreshToken: string; + accessExpiresInMinutes: number; + refreshExpiresInDays: number; +} + +export interface LoginPayload { + username: string; + password: string; + captchaId: string; + captchaCode: string; + deviceCode?: string; +} + +export interface DeviceCodePayload { + username: string; + password: string; + captchaId: string; + captchaCode: string; + deviceName?: string; +} + +export async function fetchCaptcha() { + const resp = await http.get("/auth/captcha"); + return resp.data.data as CaptchaResponse; +} + +export async function login(payload: LoginPayload) { + const resp = await http.post("/auth/login", payload); + return resp.data.data as TokenResponse; +} + +export async function createDeviceCode(payload: DeviceCodePayload) { + const resp = await http.post("/auth/device-code", payload); + return resp.data.data as string; +} + +export async function refreshToken(refreshToken: string) { + const resp = await http.post("/auth/refresh", { refreshToken }); + return resp.data.data as TokenResponse; +} diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts new file mode 100644 index 0000000..c206071 --- /dev/null +++ b/frontend/src/api/http.ts @@ -0,0 +1,28 @@ +import axios from "axios"; + +const http = axios.create({ + baseURL: "", + timeout: 15000 +}); + +http.interceptors.request.use((config) => { + const token = localStorage.getItem("accessToken"); + if (token) { + config.headers = config.headers || {}; + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +http.interceptors.response.use( + (resp) => { + const body = resp.data; + if (body && body.code !== "0") { + return Promise.reject(new Error(body.msg || "请求失败")); + } + return resp; + }, + (error) => Promise.reject(error) +); + +export default http; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..240ffa7 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,94 @@ +import http from "./http"; +import { DeviceInfo, SysPermission, SysRole, SysUser, UserProfile } from "../types"; + +export async function listUsers() { + const resp = await http.get("/api/users"); + return resp.data.data as SysUser[]; +} + + +export async function createUser(payload: Partial) { + const resp = await http.post("/api/users", payload); + return resp.data.data as boolean; +} + +export async function updateUser(id: number, payload: Partial) { + const resp = await http.put(`/api/users/${id}`, payload); + return resp.data.data as boolean; +} + +export async function deleteUser(id: number) { + const resp = await http.delete(`/api/users/${id}`); + return resp.data.data as boolean; +} + +export async function listRoles() { + const resp = await http.get("/api/roles"); + return resp.data.data as SysRole[]; +} + +export async function createRole(payload: Partial) { + const resp = await http.post("/api/roles", payload); + return resp.data.data as boolean; +} + +export async function updateRole(id: number, payload: Partial) { + const resp = await http.put(`/api/roles/${id}`, payload); + return resp.data.data as boolean; +} + +export async function deleteRole(id: number) { + const resp = await http.delete(`/api/roles/${id}`); + return resp.data.data as boolean; +} + +export async function listPermissions() { + const resp = await http.get("/api/permissions"); + return resp.data.data as SysPermission[]; +} + +export async function listMyPermissions() { + const resp = await http.get("/api/permissions/me"); + return resp.data.data as SysPermission[]; +} + +export async function getCurrentUser() { + const resp = await http.get("/api/users/me"); + return resp.data.data as UserProfile; +} + +export async function createPermission(payload: Partial) { + const resp = await http.post("/api/permissions", payload); + return resp.data.data as boolean; +} + +export async function updatePermission(id: number, payload: Partial) { + const resp = await http.put(`/api/permissions/${id}`, payload); + return resp.data.data as boolean; +} + +export async function deletePermission(id: number) { + const resp = await http.delete(`/api/permissions/${id}`); + return resp.data.data as boolean; +} + +export async function listDevices() { + const resp = await http.get("/api/devices"); + return resp.data.data as DeviceInfo[]; +} + +export async function createDevice(payload: Partial) { + const resp = await http.post("/api/devices", payload); + return resp.data.data as boolean; +} + +export async function updateDevice(id: number, payload: Partial) { + const resp = await http.put(`/api/devices/${id}`, payload); + return resp.data.data as boolean; +} + +export async function deleteDevice(id: number) { + const resp = await http.delete(`/api/devices/${id}`); + return resp.data.data as boolean; +} + diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx new file mode 100644 index 0000000..42a8273 --- /dev/null +++ b/frontend/src/components/AppLayout.tsx @@ -0,0 +1,43 @@ +import { Layout, Menu } from "antd"; +import { Link, Outlet, useLocation, useNavigate } from "react-router-dom"; + +export default function AppLayout() { + const location = useLocation(); + const navigate = useNavigate(); + + const handleLogout = () => { + localStorage.removeItem("accessToken"); + localStorage.removeItem("refreshToken"); + navigate("/login"); + }; + + return ( + + +
+ logo + MeetingAI +
+ 总览 }, + { key: "/users", label: 用户管理 }, + { key: "/roles", label: 权限角色 }, + { key: "/permissions", label: 权限菜单 }, + { key: "/devices", label: 设备管理 }, + { key: "logout", label: 退出 } + ]} + /> + + + + + + + + + ); +} diff --git a/frontend/src/components/shared/ActionHelpPanel/ActionHelpPanel.css b/frontend/src/components/shared/ActionHelpPanel/ActionHelpPanel.css new file mode 100644 index 0000000..bc039f7 --- /dev/null +++ b/frontend/src/components/shared/ActionHelpPanel/ActionHelpPanel.css @@ -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; + } +} diff --git a/frontend/src/components/shared/ActionHelpPanel/ActionHelpPanel.jsx b/frontend/src/components/shared/ActionHelpPanel/ActionHelpPanel.jsx new file mode 100644 index 0000000..a3cde11 --- /dev/null +++ b/frontend/src/components/shared/ActionHelpPanel/ActionHelpPanel.jsx @@ -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 ( + + ) + } + + return ( +
+ {/* 操作标题 */} +
+
{currentAction.icon}
+
+

{currentAction.title}

+ {currentAction.badge && ( + + {currentAction.badge.text} + + )} +
+
+ + {/* 操作描述 */} + {currentAction.description && ( +
+
+ 功能说明 +
+
{currentAction.description}
+
+ )} + + {/* 使用场景 */} + {currentAction.scenarios && currentAction.scenarios.length > 0 && ( +
+
+ 使用场景 +
+
    + {currentAction.scenarios.map((scenario, index) => ( +
  • {scenario}
  • + ))} +
+
+ )} + + {/* 操作步骤 */} + {currentAction.steps && currentAction.steps.length > 0 && ( +
+
+ 操作步骤 +
+
    + {currentAction.steps.map((step, index) => ( +
  1. {step}
  2. + ))} +
+
+ )} + + {/* 注意事项 */} + {currentAction.warnings && currentAction.warnings.length > 0 && ( +
+
+ 注意事项 +
+
    + {currentAction.warnings.map((warning, index) => ( +
  • {warning}
  • + ))} +
+
+ )} + + {/* 快捷键 */} + {currentAction.shortcut && ( +
+
⌨️ 快捷键
+
+ {currentAction.shortcut} +
+
+ )} + + {/* 权限要求 */} + {currentAction.permission && ( +
+
🔐 权限要求
+
+ {currentAction.permission} +
+
+ )} +
+ ) + } + + // 渲染所有操作列表 + const renderAllActions = () => { + if (allActions.length === 0) { + return + } + + return ( +
+ {allActions.map((action, index) => ( +
{ + if (onActionSelect) { + onActionSelect(action) + setActiveKey(['current']) + } + }} + > +
+ {action.icon} + {action.title} + {action.shortcut && ( + {action.shortcut} + )} +
+
{action.description}
+
+ ))} +
+ ) + } + + return ( + + + 操作帮助 + {currentAction && } + + } + placement={placement} + width={420} + open={visible} + onClose={onClose} + className="action-help-panel" + > + + + 当前操作 + {currentAction && ( + + )} + + } + key="current" + > + {renderCurrentAction()} + + + + {renderAllActions()} + + + + ) +} + +export default ActionHelpPanel diff --git a/frontend/src/components/shared/BottomHintBar/BottomHintBar.css b/frontend/src/components/shared/BottomHintBar/BottomHintBar.css new file mode 100644 index 0000000..36de3a3 --- /dev/null +++ b/frontend/src/components/shared/BottomHintBar/BottomHintBar.css @@ -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%; + } +} diff --git a/frontend/src/components/shared/BottomHintBar/BottomHintBar.jsx b/frontend/src/components/shared/BottomHintBar/BottomHintBar.jsx new file mode 100644 index 0000000..9efe937 --- /dev/null +++ b/frontend/src/components/shared/BottomHintBar/BottomHintBar.jsx @@ -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 ( +
e.stopPropagation()} + > +
+ {/* 左侧:图标和标题 */} +
+
{hintInfo.icon}
+
+

{hintInfo.title}

+ {hintInfo.badge && ( + + {hintInfo.badge.text} + + )} +
+
+ + {/* 中间:主要信息 */} +
+ {/* 描述 */} + {hintInfo.description && ( +
+ + {hintInfo.description} +
+ )} + + {/* 快速提示 */} + {hintInfo.quickTip && ( +
+ + {hintInfo.quickTip} +
+ )} + + {/* 警告 */} + {hintInfo.warning && ( +
+ + {hintInfo.warning} +
+ )} +
+ + {/* 右侧:快捷键和关闭 */} +
+ {hintInfo.shortcut && ( +
+ 快捷键 + {hintInfo.shortcut} +
+ )} + {onClose && ( + + )} +
+
+ + {/* 进度指示条 */} +
+
+ ) +} + +export default BottomHintBar diff --git a/frontend/src/components/shared/ButtonWithGuide/ButtonWithGuide.css b/frontend/src/components/shared/ButtonWithGuide/ButtonWithGuide.css new file mode 100644 index 0000000..0968427 --- /dev/null +++ b/frontend/src/components/shared/ButtonWithGuide/ButtonWithGuide.css @@ -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; + } +} diff --git a/frontend/src/components/shared/ButtonWithGuide/ButtonWithGuide.jsx b/frontend/src/components/shared/ButtonWithGuide/ButtonWithGuide.jsx new file mode 100644 index 0000000..0f30cc3 --- /dev/null +++ b/frontend/src/components/shared/ButtonWithGuide/ButtonWithGuide.jsx @@ -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 ( + <> +
+ + {guide && !disabled && ( + + )} +
+ + {/* 引导弹窗 */} + {guide && ( + + {guide.icon || icon} + {guide.title} + {guide.badge && ( + + {guide.badge.text} + + )} +
+ } + open={showGuideModal} + onCancel={() => setShowGuideModal(false)} + footer={[ + , + ]} + width={600} + className="button-guide-modal" + > + {/* 功能描述 */} + {guide.description && ( +
+
+ + 功能说明 +
+

{guide.description}

+
+ )} + + {/* 使用步骤 */} + {guide.steps && guide.steps.length > 0 && ( +
+
+ + 操作步骤 +
+ ({ + title: `步骤 ${index + 1}`, + description: step, + status: 'wait', + }))} + className="guide-steps" + /> +
+ )} + + {/* 使用场景 */} + {guide.scenarios && guide.scenarios.length > 0 && ( +
+
+ + 适用场景 +
+
    + {guide.scenarios.map((scenario, index) => ( +
  • {scenario}
  • + ))} +
+
+ )} + + {/* 注意事项 */} + {guide.warnings && guide.warnings.length > 0 && ( +
+
+ + 注意事项 +
+
    + {guide.warnings.map((warning, index) => ( +
  • {warning}
  • + ))} +
+
+ )} + + {/* 快捷键和权限 */} + {(guide.shortcut || guide.permission) && ( +
+ {guide.shortcut && ( +
+ 快捷键: + {guide.shortcut} +
+ )} + {guide.permission && ( +
+ 权限要求: + {guide.permission} +
+ )} +
+ )} + + )} + + ) +} + +export default ButtonWithGuide diff --git a/frontend/src/components/shared/ButtonWithGuideBadge/ButtonWithGuideBadge.css b/frontend/src/components/shared/ButtonWithGuideBadge/ButtonWithGuideBadge.css new file mode 100644 index 0000000..334f120 --- /dev/null +++ b/frontend/src/components/shared/ButtonWithGuideBadge/ButtonWithGuideBadge.css @@ -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; + } +} diff --git a/frontend/src/components/shared/ButtonWithGuideBadge/ButtonWithGuideBadge.jsx b/frontend/src/components/shared/ButtonWithGuideBadge/ButtonWithGuideBadge.jsx new file mode 100644 index 0000000..35a8224 --- /dev/null +++ b/frontend/src/components/shared/ButtonWithGuideBadge/ButtonWithGuideBadge.jsx @@ -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: , + }, + help: { + text: '?', + color: '#1677ff', + icon: , + }, + warn: { + text: '!', + color: '#faad14', + icon: , + }, + } + return configs[badgeType] || configs.help + } + + const badgeConfig = getBadgeConfig() + + return ( + <> +
+ {showBadge && guide && !disabled ? ( + + {badgeConfig.icon} +
+ } + offset={[-5, 5]} + > + + + ) : ( + + )} + + + {/* 引导弹窗 */} + {guide && ( + + {guide.icon || icon} + {guide.title} + {guide.badge && ( + + {guide.badge.text} + + )} + + } + open={showGuideModal} + onCancel={() => setShowGuideModal(false)} + footer={[ + , + ]} + width={600} + className="button-guide-modal" + > + {/* 功能描述 */} + {guide.description && ( +
+
+ + 功能说明 +
+

{guide.description}

+
+ )} + + {/* 使用步骤 */} + {guide.steps && guide.steps.length > 0 && ( +
+
+ + 操作步骤 +
+ ({ + title: `步骤 ${index + 1}`, + description: step, + status: 'wait', + }))} + className="guide-steps" + /> +
+ )} + + {/* 使用场景 */} + {guide.scenarios && guide.scenarios.length > 0 && ( +
+
+ + 适用场景 +
+
    + {guide.scenarios.map((scenario, index) => ( +
  • {scenario}
  • + ))} +
+
+ )} + + {/* 注意事项 */} + {guide.warnings && guide.warnings.length > 0 && ( +
+
+ + 注意事项 +
+
    + {guide.warnings.map((warning, index) => ( +
  • {warning}
  • + ))} +
+
+ )} + + {/* 快捷键和权限 */} + {(guide.shortcut || guide.permission) && ( +
+ {guide.shortcut && ( +
+ 快捷键: + {guide.shortcut} +
+ )} + {guide.permission && ( +
+ 权限要求: + {guide.permission} +
+ )} +
+ )} +
+ )} + + ) +} + +export default ButtonWithGuideBadge diff --git a/frontend/src/components/shared/ButtonWithHoverCard/ButtonWithHoverCard.css b/frontend/src/components/shared/ButtonWithHoverCard/ButtonWithHoverCard.css new file mode 100644 index 0000000..eb42451 --- /dev/null +++ b/frontend/src/components/shared/ButtonWithHoverCard/ButtonWithHoverCard.css @@ -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); + } + } +} diff --git a/frontend/src/components/shared/ButtonWithHoverCard/ButtonWithHoverCard.jsx b/frontend/src/components/shared/ButtonWithHoverCard/ButtonWithHoverCard.jsx new file mode 100644 index 0000000..9800e64 --- /dev/null +++ b/frontend/src/components/shared/ButtonWithHoverCard/ButtonWithHoverCard.jsx @@ -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 ( +
+ + {/* 标题区 */} +
+
+ {cardInfo.icon && ( + {cardInfo.icon} + )} +

{cardInfo.title}

+
+ {cardInfo.badge && ( + + {cardInfo.badge.text} + + )} +
+ + {/* 描述 */} + {cardInfo.description && ( +
+

{cardInfo.description}

+
+ )} + + {/* 使用场景 */} + {cardInfo.scenarios && cardInfo.scenarios.length > 0 && ( +
+
+ + 使用场景 +
+
    + {cardInfo.scenarios.slice(0, 2).map((scenario, index) => ( +
  • {scenario}
  • + ))} +
+
+ )} + + {/* 快速提示 */} + {cardInfo.quickTips && cardInfo.quickTips.length > 0 && ( +
+
+ + 快速提示 +
+
    + {cardInfo.quickTips.map((tip, index) => ( +
  • {tip}
  • + ))} +
+
+ )} + + {/* 注意事项 */} + {cardInfo.warnings && cardInfo.warnings.length > 0 && ( +
+
+ + 注意 +
+
    + {cardInfo.warnings.slice(0, 2).map((warning, index) => ( +
  • {warning}
  • + ))} +
+
+ )} + + {/* 快捷键 */} + {cardInfo.shortcut && ( +
+ 快捷键 + {cardInfo.shortcut} +
+ )} +
+
+ ) + } + + return ( + <> +
+ +
+ + {/* 使用 Portal 渲染悬浮卡片到 body */} + {typeof document !== 'undefined' && createPortal(renderCard(), document.body)} + + ) +} + +export default ButtonWithHoverCard diff --git a/frontend/src/components/shared/ButtonWithTip/ButtonWithTip.css b/frontend/src/components/shared/ButtonWithTip/ButtonWithTip.css new file mode 100644 index 0000000..f1d665a --- /dev/null +++ b/frontend/src/components/shared/ButtonWithTip/ButtonWithTip.css @@ -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; + } +} diff --git a/frontend/src/components/shared/ButtonWithTip/ButtonWithTip.jsx b/frontend/src/components/shared/ButtonWithTip/ButtonWithTip.jsx new file mode 100644 index 0000000..f8d4130 --- /dev/null +++ b/frontend/src/components/shared/ButtonWithTip/ButtonWithTip.jsx @@ -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 ( + + ) + } + + // 构建提示内容 + const tooltipContent = ( +
+ {tip.title &&
{tip.title}
} + {tip.description &&
{tip.description}
} + {tip.shortcut && ( +
+ 快捷键: + {tip.shortcut} +
+ )} + {tip.notes && tip.notes.length > 0 && ( +
+
注意事项:
+
    + {tip.notes.map((note, index) => ( +
  • {note}
  • + ))} +
+
+ )} +
+ ) + + return ( + +
+ + {showTipIcon && !disabled && ( + + )} +
+
+ ) +} + +export default ButtonWithTip diff --git a/frontend/src/components/shared/ChartPanel/ChartPanel.css b/frontend/src/components/shared/ChartPanel/ChartPanel.css new file mode 100644 index 0000000..69a0cc2 --- /dev/null +++ b/frontend/src/components/shared/ChartPanel/ChartPanel.css @@ -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; +} diff --git a/frontend/src/components/shared/ChartPanel/ChartPanel.jsx b/frontend/src/components/shared/ChartPanel/ChartPanel.jsx new file mode 100644 index 0000000..5f26c8f --- /dev/null +++ b/frontend/src/components/shared/ChartPanel/ChartPanel.jsx @@ -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 ( +
+ {title &&
{title}
} +
+
+ ) +} + +/** + * 根据图表类型生成 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 diff --git a/frontend/src/components/shared/ConfirmDialog/ConfirmDialog.jsx b/frontend/src/components/shared/ConfirmDialog/ConfirmDialog.jsx new file mode 100644 index 0000000..d7095b0 --- /dev/null +++ b/frontend/src/components/shared/ConfirmDialog/ConfirmDialog.jsx @@ -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: ( +
+

您确定要删除以下项目吗?

+
+

{itemName}

+ {itemInfo && ( +

{itemInfo}

+ )} +
+

+ 此操作不可恢复,请谨慎操作! +

+
+ ), + okText: '确认删除', + cancelText: '取消', + okType: 'danger', + centered: true, + icon: , + onOk, + onCancel, + }) + }, + + /** + * 显示批量删除确认对话框 + */ + batchDelete: ({ count, items, onOk, onCancel }) => { + Modal.confirm({ + title: '批量删除确认', + content: ( +
+

您确定要删除选中的 {count} 个项目吗?

+
+ {items.map((item, index) => ( +
+ {item.name} + {item.info && ( + + ({item.info}) + + )} +
+ ))} +
+

+ 此操作不可恢复,请谨慎操作! +

+
+ ), + okText: '确认删除', + cancelText: '取消', + okType: 'danger', + centered: true, + icon: , + onOk, + onCancel, + }) + }, + + /** + * 显示警告确认对话框 + */ + warning: ({ title, content, okText = '确定', cancelText = '取消', onOk, onCancel }) => { + Modal.confirm({ + title, + content, + okText, + cancelText, + centered: true, + icon: , + 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 diff --git a/frontend/src/components/shared/DetailDrawer/DetailDrawer.css b/frontend/src/components/shared/DetailDrawer/DetailDrawer.css new file mode 100644 index 0000000..c5db2f2 --- /dev/null +++ b/frontend/src/components/shared/DetailDrawer/DetailDrawer.css @@ -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; +} diff --git a/frontend/src/components/shared/DetailDrawer/DetailDrawer.tsx b/frontend/src/components/shared/DetailDrawer/DetailDrawer.tsx new file mode 100644 index 0000000..0286dda --- /dev/null +++ b/frontend/src/components/shared/DetailDrawer/DetailDrawer.tsx @@ -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 ( + +
+
+
+
+
+ + {headerActions.map((action) => ( + + ))} + +
+
+ +
+ {children} + + {tabs && tabs.length > 0 && ( +
+ ({ + key: tab.key, + label: tab.label, + children:
{tab.content}
, + }))} + /> +
+ )} +
+
+
+ ); +} + +export default DetailDrawer; diff --git a/frontend/src/components/shared/ExtendInfoPanel/ExtendInfoPanel.css b/frontend/src/components/shared/ExtendInfoPanel/ExtendInfoPanel.css new file mode 100644 index 0000000..8ea059b --- /dev/null +++ b/frontend/src/components/shared/ExtendInfoPanel/ExtendInfoPanel.css @@ -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); + } +} diff --git a/frontend/src/components/shared/ExtendInfoPanel/ExtendInfoPanel.jsx b/frontend/src/components/shared/ExtendInfoPanel/ExtendInfoPanel.jsx new file mode 100644 index 0000000..03e5166 --- /dev/null +++ b/frontend/src/components/shared/ExtendInfoPanel/ExtendInfoPanel.jsx @@ -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 ( +
+ {sections.map((section) => { + const isCollapsed = collapsedSections[section.key] + const hideTitleBar = section.hideTitleBar === true + + return ( +
+ {/* 区块头部 - 可配置隐藏 */} + {!hideTitleBar && ( +
toggleSection(section.key)}> +
+ {section.icon && {section.icon}} + {section.title} +
+ +
+ )} + + {/* 区块内容 - 如果隐藏标题栏则总是显示,否则根据折叠状态 */} + {(hideTitleBar || !isCollapsed) && ( +
{section.content}
+ )} +
+ ) + })} +
+ ) +} + +export default ExtendInfoPanel diff --git a/frontend/src/components/shared/InfoPanel/InfoPanel.css b/frontend/src/components/shared/InfoPanel/InfoPanel.css new file mode 100644 index 0000000..463adb6 --- /dev/null +++ b/frontend/src/components/shared/InfoPanel/InfoPanel.css @@ -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; +} + + + + diff --git a/frontend/src/components/shared/InfoPanel/InfoPanel.jsx b/frontend/src/components/shared/InfoPanel/InfoPanel.jsx new file mode 100644 index 0000000..c60f2c9 --- /dev/null +++ b/frontend/src/components/shared/InfoPanel/InfoPanel.jsx @@ -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 ( +
+ + {fields.map((field) => { + const value = data[field.key] + const displayValue = field.render ? field.render(value, data) : value + + return ( +
+
+
{field.label}
+
{displayValue}
+
+ + ) + })} + + + {/* 可选的操作按钮区 */} + {actions && actions.length > 0 && ( +
+ + {actions.map((action) => ( + + ))} + +
+ )} + + ) +} + +export default InfoPanel diff --git a/frontend/src/components/shared/ListActionBar/ListActionBar.css b/frontend/src/components/shared/ListActionBar/ListActionBar.css new file mode 100644 index 0000000..eb9cbf7 --- /dev/null +++ b/frontend/src/components/shared/ListActionBar/ListActionBar.css @@ -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; + } +} diff --git a/frontend/src/components/shared/ListActionBar/ListActionBar.jsx b/frontend/src/components/shared/ListActionBar/ListActionBar.jsx new file mode 100644 index 0000000..f1b1c44 --- /dev/null +++ b/frontend/src/components/shared/ListActionBar/ListActionBar.jsx @@ -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 ( +
+ {/* 左侧操作按钮区 */} +
+ {/* 常规操作按钮(无选中时显示) */} + {!hasSelection && actions.map((action) => ( + + ))} + + {/* 批量操作区域(有选中时显示) */} + {hasSelection && ( + + {/* 选中信息 */} +
+ + 已选择 {selectionInfo.count} 项 + {selectionInfo.isAllPagesSelected && ( + (全部页) + )} + + {!selectionInfo.isAllPagesSelected && selectionInfo.total > selectionInfo.count && ( + + 选择全部 {selectionInfo.total} 项 + + )} + + 清除 + +
+ + {/* 批量操作按钮 */} + {batchActions.map((action) => ( + + ))} +
+ )} +
+ + {/* 右侧搜索筛选区 */} +
+ + search?.onChange?.(e.target.value)} + value={search?.value} + /> + {filter && ( + + + {filter.title || '高级筛选'} +
+ } + trigger="click" + open={filter.visible} + onOpenChange={filter.onVisibleChange} + placement="bottomRight" + overlayClassName="filter-popover" + > + + + )} + + {showRefresh && ( + + )} +
+ + ) +} + +export default ListActionBar diff --git a/frontend/src/components/shared/ListTable/ListTable.css b/frontend/src/components/shared/ListTable/ListTable.css new file mode 100644 index 0000000..0e6cd31 --- /dev/null +++ b/frontend/src/components/shared/ListTable/ListTable.css @@ -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; +} diff --git a/frontend/src/components/shared/ListTable/ListTable.tsx b/frontend/src/components/shared/ListTable/ListTable.tsx new file mode 100644 index 0000000..a92a9be --- /dev/null +++ b/frontend/src/components/shared/ListTable/ListTable.tsx @@ -0,0 +1,117 @@ +import { Table } from "antd"; +import type { TablePaginationConfig, TableProps } from "antd"; +import "./ListTable.css"; + +export type ListTableProps> = { + columns: TableProps["columns"]; + 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>({ + 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) { + const rowSelection: TableProps["rowSelection"] = onSelectionChange + ? { + selectedRowKeys, + onChange: (newSelectedRowKeys: React.Key[]) => { + onSelectionChange?.(newSelectedRowKeys); + }, + getCheckboxProps: () => ({ + disabled: isAllPagesSelected, + }), + } + : undefined; + + const mergedPagination = + pagination === false + ? false + : { + ...pagination, + showTotal: (total: number) => ( +
+ {isAllPagesSelected ? ( + <> + + 已选择 {totalCount || total} 项 + + {onClearSelection && ( + + 清除选择 + + )} + + ) : selectedRowKeys.length > 0 ? ( + <> + + 已选择 {selectedRowKeys.length} 项 + + {onSelectAllPages && selectedRowKeys.length < (totalCount || total) && ( + + 选择全部 {totalCount || total} 项 + + )} + {onClearSelection && ( + + 清除 + + )} + + ) : ( + 已选择 0 项 + )} +
+ ), + }; + + return ( +
+
({ + onClick: () => onRowClick?.(record), + className: selectedRow?.[rowKey] === record[rowKey] ? "row-selected" : "", + })} + /> + + ); +} + +export default ListTable; diff --git a/frontend/src/components/shared/MainLayout/AppHeader.css b/frontend/src/components/shared/MainLayout/AppHeader.css new file mode 100644 index 0000000..6429ab2 --- /dev/null +++ b/frontend/src/components/shared/MainLayout/AppHeader.css @@ -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; +} \ No newline at end of file diff --git a/frontend/src/components/shared/MainLayout/AppHeader.jsx b/frontend/src/components/shared/MainLayout/AppHeader.jsx new file mode 100644 index 0000000..ddb3bf7 --- /dev/null +++ b/frontend/src/components/shared/MainLayout/AppHeader.jsx @@ -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 + case 'collaboration': return + default: return + } + } + + const notificationContent = ( +
+
+ 消息通知 + {unreadCount > 0 && ( + + )} +
+ }} + renderItem={(item) => ( + handleNotificationClick(item)} + > + } + title={{item.title}} + description={ +
+
{item.content}
+
{new Date(item.created_at).toLocaleString('zh-CN')}
+
+ } + /> +
+ )} + /> +
+ +
+
+ ) + + return ( +
+ {/* 左侧:Logo + 折叠按钮 */} + {showLogo && ( +
+ {/* Logo 区域 */} +
+ logo +

NexDocus

+
+ + {/* 折叠按钮 */} +
+ {collapsed ? : } +
+
+ )} + {!showLogo &&
} {/* Spacer if left is empty */} + + {/* 右侧:功能按钮 */} +
+ + + {/* 1. 主题切换 */} +
+ {isDarkMode ? : } +
+ + {/* 2. 语言切换 */} + + + {/* 3. 消息通知 */} + { + setPopoverVisible(visible) + if (visible) { + fetchNotifications() + } + }} + placement="bottomRight" + overlayClassName="header-notification-popover" + > +
+ + + +
+
+ +
+
+
+ ) +} + +export default AppHeader \ No newline at end of file diff --git a/frontend/src/components/shared/MainLayout/AppSider.css b/frontend/src/components/shared/MainLayout/AppSider.css new file mode 100644 index 0000000..f4a98aa --- /dev/null +++ b/frontend/src/components/shared/MainLayout/AppSider.css @@ -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; +} diff --git a/frontend/src/components/shared/MainLayout/AppSider.jsx b/frontend/src/components/shared/MainLayout/AppSider.jsx new file mode 100644 index 0000000..4b9df79 --- /dev/null +++ b/frontend/src/components/shared/MainLayout/AppSider.jsx @@ -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: , + DesktopOutlined: , + GlobalOutlined: , + CloudServerOutlined: , + UserOutlined: , + AppstoreOutlined: , + SettingOutlined: , + BlockOutlined: , + FolderOutlined: , + FileTextOutlined: , + SafetyOutlined: , + TeamOutlined: , + ProjectOutlined: , + ReadOutlined: , + 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] || ) : 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] || ) : 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 = ( +
+ logo + {!collapsed && ( + NexDocus + )} +
+ ) + + // 获取用户头像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 ( + + ) +} + +export default AppSider diff --git a/frontend/src/components/shared/MainLayout/MainLayout.css b/frontend/src/components/shared/MainLayout/MainLayout.css new file mode 100644 index 0000000..e71ef55 --- /dev/null +++ b/frontend/src/components/shared/MainLayout/MainLayout.css @@ -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%; +} \ No newline at end of file diff --git a/frontend/src/components/shared/MainLayout/MainLayout.jsx b/frontend/src/components/shared/MainLayout/MainLayout.jsx new file mode 100644 index 0000000..726c328 --- /dev/null +++ b/frontend/src/components/shared/MainLayout/MainLayout.jsx @@ -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 ( + + + + + +
+ {children} +
+
+
+
+ ) +} + +export default MainLayout diff --git a/frontend/src/components/shared/MainLayout/index.js b/frontend/src/components/shared/MainLayout/index.js new file mode 100644 index 0000000..12555fd --- /dev/null +++ b/frontend/src/components/shared/MainLayout/index.js @@ -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' diff --git a/frontend/src/components/shared/ModernSidebar/ModernSidebar.css b/frontend/src/components/shared/ModernSidebar/ModernSidebar.css new file mode 100644 index 0000000..b22ec6b --- /dev/null +++ b/frontend/src/components/shared/ModernSidebar/ModernSidebar.css @@ -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 */ +} \ No newline at end of file diff --git a/frontend/src/components/shared/ModernSidebar/ModernSidebar.jsx b/frontend/src/components/shared/ModernSidebar/ModernSidebar.jsx new file mode 100644 index 0000000..d4ce841 --- /dev/null +++ b/frontend/src/components/shared/ModernSidebar/ModernSidebar.jsx @@ -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 ( + +
handleItemClick(item)} + > +
{item.icon}
+
+
+ ); + } + + return ( +
handleItemClick(item)} + > +
+
{item.icon}
+ {item.label} +
+ {isActive && } +
+ ); + }; + + return ( + + {/* 顶部 Logo 区域 */} +
+
+ {logo} +
+ {/* 折叠按钮 - 悬浮在边缘 */} +
onCollapse && onCollapse(!collapsed)} + > + {collapsed ? : } +
+
+ + {/* 菜单列表区域 */} +
+ {menuGroups.map((group, index) => ( +
+ {!collapsed && group.title && ( +
{group.title}
+ )} +
+ {group.items.map(item => renderMenuItem(item))} +
+
+ ))} +
+ + {/* 底部区域 */} +
+ {/* 帮助支持 */} + {!collapsed && ( +
+ + 帮助支持 +
+ )} + {collapsed && ( +
+ +
+ )} + + {/* 用户卡片 */} +
+
+ + {user?.name?.[0]?.toUpperCase() || 'U'} + + {!collapsed && ( +
+
{user?.name || 'User'}
+
{user?.role || 'Member'}
+
+ )} +
+ {!collapsed && ( +
+ +
+ )} +
+
+
+ ); +}; + +export default ModernSidebar; diff --git a/frontend/src/components/shared/PDFViewer/PDFViewer.css b/frontend/src/components/shared/PDFViewer/PDFViewer.css new file mode 100644 index 0000000..c850dba --- /dev/null +++ b/frontend/src/components/shared/PDFViewer/PDFViewer.css @@ -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; +} diff --git a/frontend/src/components/shared/PDFViewer/PDFViewer.jsx b/frontend/src/components/shared/PDFViewer/PDFViewer.jsx new file mode 100644 index 0000000..e33faaf --- /dev/null +++ b/frontend/src/components/shared/PDFViewer/PDFViewer.jsx @@ -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 ( +
+ {/* 工具栏 */} +
+ + + + + + + + + + + + + {Math.round(scale * 100)}% + + + +
+ + {/* PDF内容区 */} +
+ + +
正在加载PDF...
+
+ } + error={
PDF加载失败,请稍后重试
} + > + + +
正在渲染页面...
+
+ } + /> + + + + ) +} + +export default PDFViewer diff --git a/frontend/src/components/shared/PDFViewer/VirtualPDFViewer.css b/frontend/src/components/shared/PDFViewer/VirtualPDFViewer.css new file mode 100644 index 0000000..e95ef79 --- /dev/null +++ b/frontend/src/components/shared/PDFViewer/VirtualPDFViewer.css @@ -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%; +} \ No newline at end of file diff --git a/frontend/src/components/shared/PDFViewer/VirtualPDFViewer.jsx b/frontend/src/components/shared/PDFViewer/VirtualPDFViewer.jsx new file mode 100644 index 0000000..4978844 --- /dev/null +++ b/frontend/src/components/shared/PDFViewer/VirtualPDFViewer.jsx @@ -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 ( +
+ {/* 工具栏 */} +
+ + + + + + + + + + {Math.round(scale * 100)}% + + + +
+ + {/* PDF内容区 - 自定义虚拟滚动 */} +
+ + +
正在加载PDF...
+
+ } + error={
PDF加载失败,请稍后重试
} + options={pdfOptions} + > + {numPages && ( +
+ {Array.from({ length: numPages }, (_, index) => { + const pageNumber = index + 1 + const isVisible = visiblePages.has(pageNumber) + + return ( +
pageRefs.current[pageNumber] = el} + className="pdf-page-wrapper" + style={{ + position: 'absolute', + top: index * pageHeight, + left: 0, + right: 0, + height: pageHeight, + }} + > + {isVisible ? ( + <> + + +
加载第 {pageNumber} 页...
+
+ } + /> +
第 {pageNumber} 页
+ + ) : ( +
+
第 {pageNumber} 页
+
+ )} +
+ ) + })} +
+ )} + + + + ) +} + +export default VirtualPDFViewer diff --git a/frontend/src/components/shared/PageHeader/PageHeader.css b/frontend/src/components/shared/PageHeader/PageHeader.css new file mode 100644 index 0000000..6ba9202 --- /dev/null +++ b/frontend/src/components/shared/PageHeader/PageHeader.css @@ -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%; + } +} diff --git a/frontend/src/components/shared/PageHeader/PageHeader.jsx b/frontend/src/components/shared/PageHeader/PageHeader.jsx new file mode 100644 index 0000000..b934376 --- /dev/null +++ b/frontend/src/components/shared/PageHeader/PageHeader.jsx @@ -0,0 +1,35 @@ +import { ArrowLeftOutlined } from '@ant-design/icons' +import './PageHeader.css' + +function PageHeader({ + title, + description, + icon, + showBack = false, + onBack, + extra +}) { + return ( +
+
+ {showBack && ( + + )} +
+ {icon &&
{icon}
} +
+

{title}

+ {description && ( +

{description}

+ )} +
+
+
+ {extra &&
{extra}
} +
+ ) +} + +export default PageHeader diff --git a/frontend/src/components/shared/PageTitleBar/PageTitleBar.css b/frontend/src/components/shared/PageTitleBar/PageTitleBar.css new file mode 100644 index 0000000..f362ffb --- /dev/null +++ b/frontend/src/components/shared/PageTitleBar/PageTitleBar.css @@ -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; + } +} diff --git a/frontend/src/components/shared/PageTitleBar/PageTitleBar.jsx b/frontend/src/components/shared/PageTitleBar/PageTitleBar.jsx new file mode 100644 index 0000000..a023b8f --- /dev/null +++ b/frontend/src/components/shared/PageTitleBar/PageTitleBar.jsx @@ -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 ( +
+
+
+
+
+

{title}

+ {badge && {badge}} +
+ {description &&

{description}

} +
+
+
+ {actions &&
{actions}
} + {showToggle && ( + + )} +
+
+
+ ) +} + +export default PageTitleBar diff --git a/frontend/src/components/shared/ProtectedRoute.jsx b/frontend/src/components/shared/ProtectedRoute.jsx new file mode 100644 index 0000000..a6b1512 --- /dev/null +++ b/frontend/src/components/shared/ProtectedRoute.jsx @@ -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 + } + + return children +} + +export default ProtectedRoute diff --git a/frontend/src/components/shared/SelectionAlert/SelectionAlert.css b/frontend/src/components/shared/SelectionAlert/SelectionAlert.css new file mode 100644 index 0000000..bfd2069 --- /dev/null +++ b/frontend/src/components/shared/SelectionAlert/SelectionAlert.css @@ -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; + } +} diff --git a/frontend/src/components/shared/SelectionAlert/SelectionAlert.jsx b/frontend/src/components/shared/SelectionAlert/SelectionAlert.jsx new file mode 100644 index 0000000..82502cd --- /dev/null +++ b/frontend/src/components/shared/SelectionAlert/SelectionAlert.jsx @@ -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 ( +
+ + + 已选择全部 {totalCount} 条数据 + + 清除选择 +
+ } + type="info" + showIcon + closable={false} + /> + + ) + } + + // 如果只选择了当前页,且总数大于当前页 + if (currentPageCount > 0 && totalCount > currentPageCount) { + return ( +
+ + + 已选择当前页 {currentPageCount} 条数据 + + + 选择全部 {totalCount} 条数据 + +
+ } + type="warning" + showIcon + closable={false} + /> + + ) + } + + // 只选择了部分数据,且总数等于当前页(单页情况) + return ( +
+ + + 已选择 {currentPageCount} 条数据 + + 清除选择 +
+ } + type="info" + showIcon + closable={false} + /> + + ) +} + +export default SelectionAlert diff --git a/frontend/src/components/shared/SideInfoPanel/SideInfoPanel.css b/frontend/src/components/shared/SideInfoPanel/SideInfoPanel.css new file mode 100644 index 0000000..f6f38a4 --- /dev/null +++ b/frontend/src/components/shared/SideInfoPanel/SideInfoPanel.css @@ -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); + } +} diff --git a/frontend/src/components/shared/SideInfoPanel/SideInfoPanel.jsx b/frontend/src/components/shared/SideInfoPanel/SideInfoPanel.jsx new file mode 100644 index 0000000..b08e4bc --- /dev/null +++ b/frontend/src/components/shared/SideInfoPanel/SideInfoPanel.jsx @@ -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 ( +
+ {sections.map((section) => { + const isCollapsed = collapsedSections[section.key] + + return ( +
+ {/* 区块头部 */} +
toggleSection(section.key)}> +
+ {section.icon && {section.icon}} + {section.title} +
+ +
+ + {/* 区块内容 */} + {!isCollapsed && ( +
{section.content}
+ )} +
+ ) + })} +
+ ) +} + +export default SideInfoPanel diff --git a/frontend/src/components/shared/SplitLayout/SplitLayout.css b/frontend/src/components/shared/SplitLayout/SplitLayout.css new file mode 100644 index 0000000..097fa77 --- /dev/null +++ b/frontend/src/components/shared/SplitLayout/SplitLayout.css @@ -0,0 +1,72 @@ +/* 分栏布局容器 */ +.split-layout { + display: flex; + width: 100%; + align-items: flex-start; +} + +/* 横向布局(左右分栏) */ +.split-layout-horizontal { + flex-direction: row; +} + +/* 纵向布局(上下分栏) */ +.split-layout-vertical { + flex-direction: column; +} + +/* 主内容区 */ +.split-layout-main { + flex: 1; + min-width: 0; + width: 100%; + display: flex; + flex-direction: column; +} + +/* 扩展信息区 */ +.split-layout-extend { + flex-shrink: 0; + background: #ffffff; +} + +/* 右侧扩展区(横向布局) */ +.split-layout-extend-right { + height: 693px; + overflow-y: auto; + overflow-x: hidden; + position: sticky; + top: 16px; + padding-right: 4px; +} + +/* 顶部扩展区(纵向布局) */ +.split-layout-extend-top { + width: 100%; +} + +/* 滚动条样式(横向布局右侧扩展区) */ +.split-layout-extend-right::-webkit-scrollbar { + width: 6px; +} + +.split-layout-extend-right::-webkit-scrollbar-track { + background: #f5f5f5; + border-radius: 3px; +} + +.split-layout-extend-right::-webkit-scrollbar-thumb { + background: #d9d9d9; + border-radius: 3px; +} + +.split-layout-extend-right::-webkit-scrollbar-thumb:hover { + background: #bfbfbf; +} + +/* 响应式:小屏幕时隐藏右侧扩展区 */ +@media (max-width: 1200px) { + .split-layout-extend-right { + display: none; + } +} diff --git a/frontend/src/components/shared/SplitLayout/SplitLayout.jsx b/frontend/src/components/shared/SplitLayout/SplitLayout.jsx new file mode 100644 index 0000000..b54a2f7 --- /dev/null +++ b/frontend/src/components/shared/SplitLayout/SplitLayout.jsx @@ -0,0 +1,69 @@ +import './SplitLayout.css' + +/** + * 主内容区布局组件 + * @param {Object} props + * @param {string} props.direction - 布局方向:'horizontal'(左右)| 'vertical'(上下) + * @param {ReactNode} props.mainContent - 主内容区 + * @param {ReactNode} props.extendContent - 扩展内容区 + * @param {number} props.extendSize - 扩展区尺寸(horizontal 模式下为宽度,px) + * @param {number} props.gap - 主内容与扩展区间距(px) + * @param {boolean} props.showExtend - 是否显示扩展区 + * @param {string} props.extendPosition - 扩展区位置(horizontal: 'right', vertical: 'top') + * @param {string} props.className - 自定义类名 + * + * @deprecated 旧参数(向后兼容):leftContent, rightContent, rightWidth, showRight + */ +function SplitLayout({ + // 新 API + direction = 'horizontal', + mainContent, + extendContent, + extendSize = 360, + gap = 16, + showExtend = true, + extendPosition, + className = '', + // 旧 API(向后兼容) + leftContent, + rightContent, + rightWidth, + showRight, +}) { + // 向后兼容:如果使用旧 API,转换为新 API + const actualMainContent = mainContent || leftContent + const actualExtendContent = extendContent || rightContent + const actualExtendSize = extendSize !== 360 ? extendSize : (rightWidth || 360) + const actualShowExtend = showExtend !== undefined ? showExtend : (showRight !== undefined ? showRight : true) + const actualDirection = direction + const actualExtendPosition = extendPosition || (actualDirection === 'horizontal' ? 'right' : 'top') + + return ( +
+ {/* 纵向布局且扩展区在顶部时,先渲染扩展区 */} + {actualDirection === 'vertical' && actualExtendPosition === 'top' && actualShowExtend && actualExtendContent && ( +
+ {actualExtendContent} +
+ )} + + {/* 主内容区 */} +
{actualMainContent}
+ + {/* 横向布局时,扩展区在右侧 */} + {actualDirection === 'horizontal' && actualShowExtend && actualExtendContent && ( +
+ {actualExtendContent} +
+ )} +
+ ) +} + +export default SplitLayout diff --git a/frontend/src/components/shared/StatCard/StatCard.css b/frontend/src/components/shared/StatCard/StatCard.css new file mode 100644 index 0000000..509d577 --- /dev/null +++ b/frontend/src/components/shared/StatCard/StatCard.css @@ -0,0 +1,108 @@ +/* 统计卡片 */ +.stat-card { + padding: 16px; + background: #ffffff; + border-radius: 8px; + border: 1px solid #f0f0f0; + transition: all 0.3s ease; +} + +.stat-card:hover { + border-color: #d9d9d9; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +/* 一列布局(默认) */ +.stat-card-column { + /* 继承默认样式 */ +} + +/* 两列布局 */ +.stat-card.stat-card-row { + display: flex; + align-items: center; + gap: 16px; +} + +.stat-card.stat-card-row .stat-card-header { + flex: 1; + margin-bottom: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +.stat-card.stat-card-row .stat-card-body { + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +/* 卡片头部 */ +.stat-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.stat-card-title { + font-size: 13px; + color: rgba(0, 0, 0, 0.65); + font-weight: 500; +} + +.stat-card-icon { + font-size: 18px; + display: flex; + align-items: center; +} + +/* 卡片内容 */ +.stat-card-body { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 8px; +} + +.stat-card-value { + font-size: 24px; + font-weight: 600; + line-height: 1; +} + +.stat-card-suffix { + font-size: 14px; + font-weight: 400; + margin-left: 4px; + color: rgba(0, 0, 0, 0.45); +} + +/* 趋势指示器 */ +.stat-card-trend { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 500; + padding: 2px 6px; + border-radius: 4px; +} + +.stat-card-trend.trend-up { + color: #52c41a; + background: #f6ffed; +} + +.stat-card-trend.trend-down { + color: #ff4d4f; + background: #fff1f0; +} + +.stat-card-trend svg { + font-size: 10px; +} diff --git a/frontend/src/components/shared/StatCard/StatCard.tsx b/frontend/src/components/shared/StatCard/StatCard.tsx new file mode 100644 index 0000000..40348ef --- /dev/null +++ b/frontend/src/components/shared/StatCard/StatCard.tsx @@ -0,0 +1,81 @@ +import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons' +import './StatCard.css' +import { ReactNode } from 'react' + +export interface StatCardProps { + title: string + value: number | string + icon?: ReactNode + color?: string + trend?: { value: number; direction: 'up' | 'down' } + suffix?: string + layout?: 'column' | 'row' + gridColumn?: string + className?: string + onClick?: () => void + style?: React.CSSProperties +} + +/** + * 统计卡片组件 + */ +function StatCard({ + title, + value, + icon, + color = 'blue', + trend, + suffix = '', + layout = 'column', + gridColumn, + className = '', + onClick, + style: customStyle = {}, +}: StatCardProps) { + const colorMap: Record = { + blue: '#1677ff', + green: '#52c41a', + orange: '#faad14', + red: '#ff4d4f', + purple: '#722ed1', + gray: '#8c8c8c', + } + + const themeColor = colorMap[color] || color + + const style = { + ...(gridColumn ? { gridColumn } : {}), + ...customStyle, + } + + return ( +
+
+ {title} + {icon && ( + + {icon} + + )} +
+ +
+
+ {value} + {suffix && {suffix}} +
+ + {trend && ( +
+ {trend.direction === 'up' ? : } + {Math.abs(trend.value)}% +
+ )} +
+
+ ) +} + +export default StatCard \ No newline at end of file diff --git a/frontend/src/components/shared/Toast/Toast.tsx b/frontend/src/components/shared/Toast/Toast.tsx new file mode 100644 index 0000000..e42d996 --- /dev/null +++ b/frontend/src/components/shared/Toast/Toast.tsx @@ -0,0 +1,81 @@ +import { notification } from "antd"; +import { + CheckCircleOutlined, + CloseCircleOutlined, + ExclamationCircleOutlined, + InfoCircleOutlined, +} from "@ant-design/icons"; + +notification.config({ + placement: "topRight", + top: 24, + duration: 3, + maxCount: 3, +}); + +const Toast = { + success: (message: string, description = "", duration = 3) => { + notification.success({ + message, + description, + duration, + icon: , + style: { + borderRadius: "8px", + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", + }, + }); + }, + + error: (message: string, description = "", duration = 3) => { + notification.error({ + message, + description, + duration, + icon: , + style: { + borderRadius: "8px", + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", + }, + }); + }, + + warning: (message: string, description = "", duration = 3) => { + notification.warning({ + message, + description, + duration, + icon: , + style: { + borderRadius: "8px", + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", + }, + }); + }, + + info: (message: string, description = "", duration = 3) => { + notification.info({ + message, + description, + duration, + icon: , + style: { + borderRadius: "8px", + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", + }, + }); + }, + + custom: (config: any) => { + notification.open({ + ...config, + style: { + borderRadius: "8px", + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", + ...config.style, + }, + }); + }, +}; + +export default Toast; diff --git a/frontend/src/components/shared/TreeFilterPanel/TreeFilterPanel.css b/frontend/src/components/shared/TreeFilterPanel/TreeFilterPanel.css new file mode 100644 index 0000000..fd6341c --- /dev/null +++ b/frontend/src/components/shared/TreeFilterPanel/TreeFilterPanel.css @@ -0,0 +1,58 @@ +/* 树形筛选面板 */ +.tree-filter-panel { + width: 320px; + max-height: 500px; + overflow-y: auto; +} + +/* 已选择的筛选条件 */ +.tree-filter-selected { + min-height: 40px; + padding: 12px; + background: #f5f7fa; + border-radius: 6px; + border: 1px dashed #d9d9d9; +} + +.tree-filter-tag { + display: flex; + align-items: center; + gap: 8px; +} + +.tree-filter-label { + font-size: 13px; + color: rgba(0, 0, 0, 0.65); + font-weight: 500; +} + +.tree-filter-placeholder { + display: flex; + align-items: center; + justify-content: center; + min-height: 24px; +} + +.tree-filter-placeholder span { + color: #8c8c8c; + font-size: 13px; +} + +/* 树形选择器容器 */ +.tree-filter-container { + max-height: 280px; + overflow-y: auto; +} + +.tree-filter-header { + font-size: 14px; + font-weight: 500; + margin-bottom: 12px; + color: rgba(0, 0, 0, 0.85); +} + +/* 操作按钮 */ +.tree-filter-actions { + display: flex; + justify-content: flex-end; +} diff --git a/frontend/src/components/shared/TreeFilterPanel/TreeFilterPanel.jsx b/frontend/src/components/shared/TreeFilterPanel/TreeFilterPanel.jsx new file mode 100644 index 0000000..2723c22 --- /dev/null +++ b/frontend/src/components/shared/TreeFilterPanel/TreeFilterPanel.jsx @@ -0,0 +1,119 @@ +import { Tree, Tag, Divider, Button, Space } from 'antd' +import { useState, useEffect } from 'react' +import './TreeFilterPanel.css' + +/** + * 树形筛选面板组件 + * @param {Object} props + * @param {Array} props.treeData - 树形数据 + * @param {string} props.selectedKey - 当前选中的节点ID + * @param {string} props.tempSelectedKey - 临时选中的节点ID(确认前) + * @param {string} props.treeTitle - 树标题 + * @param {Function} props.onSelect - 选择变化回调 + * @param {Function} props.onConfirm - 确认筛选 + * @param {Function} props.onClear - 清除筛选 + * @param {string} props.placeholder - 占位提示文本 + */ +function TreeFilterPanel({ + treeData, + selectedKey, + tempSelectedKey, + treeTitle = '分组筛选', + onSelect, + onConfirm, + onClear, + placeholder = '请选择分组进行筛选', +}) { + // 获取所有节点的key用于默认展开 + const getAllKeys = (nodes) => { + let keys = [] + const traverse = (node) => { + keys.push(node.key) + if (node.children) { + node.children.forEach(traverse) + } + } + nodes.forEach(traverse) + return keys + } + + const [expandedKeys, setExpandedKeys] = useState([]) + + // 初始化时展开所有节点 + useEffect(() => { + if (treeData && treeData.length > 0) { + setExpandedKeys(getAllKeys(treeData)) + } + }, [treeData]) + + // 查找节点名称 + const findNodeName = (nodes, id) => { + for (const node of nodes) { + if (node.key === id) return node.title + if (node.children) { + const found = findNodeName(node.children, id) + if (found) return found + } + } + return '' + } + + const handleTreeSelect = (selectedKeys) => { + const key = selectedKeys[0] || null + onSelect?.(key) + } + + const handleExpand = (keys) => { + setExpandedKeys(keys) + } + + return ( +
+ {/* 已选择的筛选条件 */} +
+ {tempSelectedKey ? ( +
+ 已选择分组: + onSelect?.(null)}> + {findNodeName(treeData, tempSelectedKey)} + +
+ ) : ( +
+ {placeholder} +
+ )} +
+ + + + {/* 树形选择器 */} +
+
{treeTitle}
+ +
+ + + + {/* 操作按钮 */} +
+ + + + +
+
+ ) +} + +export default TreeFilterPanel diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..21d0fc3 --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; + +export function useAuth() { + const [accessToken, setAccessToken] = useState(() => localStorage.getItem("accessToken")); + + useEffect(() => { + const handler = () => setAccessToken(localStorage.getItem("accessToken")); + window.addEventListener("storage", handler); + return () => window.removeEventListener("storage", handler); + }, []); + + const isAuthed = !!accessToken; + const logout = () => { + localStorage.removeItem("accessToken"); + localStorage.removeItem("refreshToken"); + sessionStorage.removeItem("userProfile"); + }; + + return { accessToken, isAuthed, logout }; +} + diff --git a/frontend/src/hooks/usePermission.ts b/frontend/src/hooks/usePermission.ts new file mode 100644 index 0000000..a31b434 --- /dev/null +++ b/frontend/src/hooks/usePermission.ts @@ -0,0 +1,101 @@ +import { create } from "zustand"; +import { getCurrentUser, listMyPermissions } from "../api"; +import { SysPermission } from "../types"; + +const STORAGE_KEY = "permissionCodes"; +const PROFILE_KEY = "userProfile"; + +interface PermissionState { + codes: string[]; + isAdmin: boolean; + setCodes: (codes: string[]) => void; + setIsAdmin: (isAdmin: boolean) => void; + load: () => Promise; +} + +export const usePermissionStore = create((set) => ({ + codes: (() => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : []; + } catch (e) { + return []; + } + })(), + isAdmin: (() => { + try { + const raw = sessionStorage.getItem(PROFILE_KEY); + if (!raw) return false; + const parsed = JSON.parse(raw); + return !!parsed.isAdmin; + } catch (e) { + return false; + } + })(), + setCodes: (codes) => { + set({ codes }); + localStorage.setItem(STORAGE_KEY, JSON.stringify(codes)); + }, + setIsAdmin: (isAdmin) => { + set({ isAdmin }); + }, + load: async () => { + try { + let isAdmin = false; + const cachedProfile = sessionStorage.getItem(PROFILE_KEY); + if (cachedProfile) { + const parsed = JSON.parse(cachedProfile); + isAdmin = !!parsed.isAdmin; + } else { + const profile = await getCurrentUser(); + isAdmin = !!profile.isAdmin; + sessionStorage.setItem(PROFILE_KEY, JSON.stringify(profile)); + } + set({ isAdmin }); + if (isAdmin) { + return; + } + const perms = await listMyPermissions(); + const next = perms.filter((p) => p.status !== 0).map((p) => p.code); + set({ codes: next }); + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch (e) { + console.error("Failed to load permissions", e); + } + }, +})); + +export function usePermission() { + const { codes, load, isAdmin } = usePermissionStore(); + + const can = (perm?: string) => { + if (!perm) return true; + + if (isAdmin) return true; + + if (!codes || codes.length === 0) { + console.log(`Permission check for [${perm}]: codes is empty, returning true`); + return true; + } + + const hasMenuCodes = codes.some((c) => c.startsWith("menu:")); + const hasButtonCodes = codes.some((c) => !c.startsWith("menu:") && c.includes(":")); + + let result = false; + if (perm.startsWith("menu:")) { + result = !hasMenuCodes || codes.includes(perm); + } else { + result = !hasButtonCodes || codes.includes(perm); + } + + console.log(`Permission check for [${perm}]: result=[${result}], hasMenuCodes=[${hasMenuCodes}], hasButtonCodes=[${hasButtonCodes}]`); + return result; + }; + + return { codes, load, can, isAdmin }; +} + +export async function fetchMenuPermissions(): Promise { + const perms = await listMyPermissions(); + return perms.filter((p) => p.permType === "menu" && p.status !== 0 && p.isVisible !== 0); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..ab4bb01 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,41 @@ +body { + margin: 0; + padding: 0; + background-color: #f5f7fa; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, + 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', + 'Noto Color Emoji'; +} + +.ant-layout { + background: transparent !important; +} + +.ant-layout-sider { + background: #fff !important; +} + +.ant-menu-light { + background: transparent !important; +} + +/* Sider animation refinement */ +.app-sider .ant-layout-sider-children { + display: flex; + flex-direction: column; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-thumb { + background: #ccc; + border-radius: 3px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; +} diff --git a/frontend/src/layouts/AppLayout.tsx b/frontend/src/layouts/AppLayout.tsx new file mode 100644 index 0000000..c2b59c3 --- /dev/null +++ b/frontend/src/layouts/AppLayout.tsx @@ -0,0 +1,192 @@ +import { Layout, Menu, Button, Space, Avatar, Dropdown, message, type MenuProps } from "antd"; +import { useEffect, useState } from "react"; +import { Link, Outlet, useLocation, useNavigate } from "react-router-dom"; +import { + DashboardOutlined, + VideoCameraOutlined, + UserOutlined, + TeamOutlined, + SafetyCertificateOutlined, + DesktopOutlined, + LogoutOutlined, + MenuUnfoldOutlined, + MenuFoldOutlined, + BellOutlined, + SettingOutlined +} from "@ant-design/icons"; +import { useAuth } from "../hooks/useAuth"; +import { usePermission } from "../hooks/usePermission"; +import { listMyPermissions } from "../api"; +import { SysPermission } from "../types"; + +const { Header, Sider, Content } = Layout; + +const iconMap: Record = { + "dashboard": , + "meeting": , + "user": , + "role": , + "permission": , + "device": , +}; + +export default function AppLayout() { + const [collapsed, setCollapsed] = useState(false); + const [menus, setMenus] = useState([]); + const location = useLocation(); + const navigate = useNavigate(); + const { logout } = useAuth(); + const { load: loadPermissions } = usePermission(); + + const fetchMenus = async () => { + try { + const data = await listMyPermissions(); + // Load permissions into localStorage as well + await loadPermissions(); + + // Filter visible menus and sort them + const filtered = data + .filter(p => p.permType === 'menu' && p.isVisible === 1 && p.status === 1) + .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)); + setMenus(filtered); + } catch (e) { + message.error("获取菜单失败"); + } + }; + + useEffect(() => { + fetchMenus(); + }, []); + + const handleLogout = () => { + logout(); + navigate("/login"); + }; + + const buildMenuTree = (list: SysPermission[]) => { + const map = new Map(); + const roots: (SysPermission & { children?: SysPermission[] })[] = []; + list.forEach((m) => map.set(m.permId, { ...m, children: [] })); + map.forEach((node) => { + if (node.parentId && map.has(node.parentId)) { + map.get(node.parentId)!.children!.push(node); + } else { + roots.push(node); + } + }); + const sortNodes = (nodes: (SysPermission & { children?: SysPermission[] })[]) => { + nodes.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)); + nodes.forEach((n) => n.children && sortNodes(n.children)); + }; + sortNodes(roots); + return roots; + }; + + const toMenuItems = (nodes: (SysPermission & { children?: SysPermission[] })[]) => + nodes.map((m) => { + const key = m.path || m.code || String(m.permId); + const icon = m.icon ? (iconMap[m.icon] || ) : ; + if (m.children && m.children.length > 0) { + return { + key, + icon, + label: m.name, + children: toMenuItems(m.children), + }; + } + return { + key, + icon, + label: {m.name}, + }; + }); + + const menuItems = toMenuItems(buildMenuTree(menus)); + + const userMenuItems: MenuProps["items"] = [ + { key: 'profile', label: '个人信息', icon: }, + { key: 'settings', label: '系统设置', icon: }, + { type: 'divider' }, + { key: 'logout', label: '退出登录', icon: , onClick: handleLogout }, + ]; + + return ( + + +
+ logo + {!collapsed && ( + + MeetingAI + + )} +
+ + + +
+
+ + + +
+ + ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..e227cd3 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import "antd/dist/reset.css"; +import "./index.css"; +import App from "./App"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + +); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..0f9ba64 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,108 @@ +import { Row, Col, Card, Typography, Table, Tag, Space } from "antd"; +import { + VideoCameraOutlined, + DesktopOutlined, + UserOutlined, + ClockCircleOutlined, + CheckCircleOutlined, + SyncOutlined +} from "@ant-design/icons"; +import StatCard from "../components/shared/StatCard/StatCard"; + +const { Title } = Typography; + +const recentMeetings = [ + { key: '1', name: '产品周会', type: '转录中', time: '2024-02-10 14:00', duration: '45min', status: 'processing' }, + { key: '2', name: '技术分享', type: '已完成', time: '2024-02-10 10:00', duration: '60min', status: 'success' }, + { key: '3', name: '部门早会', type: '已完成', time: '2024-02-10 09:00', duration: '15min', status: 'success' }, + { key: '4', name: '客户会议', type: '待开始', time: '2024-02-10 16:30', duration: '30min', status: 'default' }, +]; + +const columns = [ + { title: '会议名称', dataIndex: 'name', key: 'name' }, + { title: '开始时间', dataIndex: 'time', key: 'time' }, + { title: '时长', dataIndex: 'duration', key: 'duration' }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + render: (status: string, record: any) => { + if (status === 'processing') return } color="processing">转录中; + if (status === 'success') return } color="success">已完成; + return 待开始; + } + } +]; + +export default function Dashboard() { + return ( +
+
+ 系统总览 +
+ + +
+ } + color="blue" + trend={{ value: 8, direction: 'up' }} + /> + + + } + color="green" + trend={{ value: 2, direction: 'up' }} + /> + + + } + color="orange" + trend={{ value: 5, direction: 'down' }} + /> + + + } + color="purple" + trend={{ value: 12, direction: 'up' }} + /> + + + + + + +
+ + + + +
+ + + 图表加载中... + +
+
+ + + + ); +} diff --git a/frontend/src/pages/Devices.tsx b/frontend/src/pages/Devices.tsx new file mode 100644 index 0000000..23130cd --- /dev/null +++ b/frontend/src/pages/Devices.tsx @@ -0,0 +1,162 @@ +import { Button, Form, Input, Modal, Popconfirm, Space, Table, Tag, Select, InputNumber } from "antd"; +import { useEffect, useMemo, useState } from "react"; +import { createDevice, deleteDevice, listDevices, updateDevice } from "../api"; +import type { DeviceInfo } from "../types"; +import { usePermission } from "../hooks/usePermission"; + +export default function Devices() { + const [loading, setLoading] = useState(false); + const [data, setData] = useState([]); + const [query, setQuery] = useState({ userId: "", deviceCode: "", deviceName: "" }); + const [pagination, setPagination] = useState({ current: 1, pageSize: 10 }); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + const { can } = usePermission(); + + const load = async () => { + setLoading(true); + try { + const list = await listDevices(); + setData(list || []); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const filtered = useMemo(() => { + return data.filter((d) => { + const hitUser = query.userId ? String(d.userId).includes(query.userId) : true; + const hitCode = query.deviceCode ? d.deviceCode?.includes(query.deviceCode) : true; + const hitName = query.deviceName ? (d.deviceName || "").includes(query.deviceName) : true; + return hitUser && hitCode && hitName; + }); + }, [data, query]); + + const pageData = useMemo(() => { + const start = (pagination.current - 1) * pagination.pageSize; + return filtered.slice(start, start + pagination.pageSize); + }, [filtered, pagination]); + + const openCreate = () => { + setEditing(null); + form.resetFields(); + setOpen(true); + }; + + const openEdit = (record: DeviceInfo) => { + setEditing(record); + form.setFieldsValue(record); + setOpen(true); + }; + + const submit = async () => { + const values = await form.validateFields(); + const payload: Partial = { + userId: values.userId, + deviceCode: values.deviceCode, + deviceName: values.deviceName, + status: values.status + }; + if (editing) { + await updateDevice(editing.deviceId, payload); + } else { + await createDevice(payload); + } + setOpen(false); + load(); + }; + + const remove = async (id: number) => { + await deleteDevice(id); + load(); + }; + + return ( +
+ + setQuery({ ...query, userId: e.target.value })} + /> + setQuery({ ...query, deviceCode: e.target.value })} + /> + setQuery({ ...query, deviceName: e.target.value })} + /> + {can("device:create") && ( + + )} + + +
setPagination({ current, pageSize }) + }} + columns={[ + { title: "ID", dataIndex: "deviceId" }, + { title: "用户ID", dataIndex: "userId" }, + { title: "设备码", dataIndex: "deviceCode" }, + { title: "设备名", dataIndex: "deviceName" }, + { + title: "状态", + dataIndex: "status", + render: (v) => (v === 1 ? 启用 : 禁用) + }, + { + title: "操作", + render: (_, record) => ( + + {can("device:update") && } + {can("device:delete") && ( + remove(record.deviceId)}> + + + )} + + ) + } + ]} + /> + + setOpen(false)} + destroyOnClose + > +
+ + + + + + + + + + + + + + + + + + +
+ +
+ {captcha ? ( + captcha + ) : ( +
+ )} +
+
+ + +
+ + 记住我 + + 忘记密码? +
+ + + + + + +
+ + 默认账号:admin / 密码:123456 + +
+
+ + + ); +} + diff --git a/frontend/src/pages/Permissions.tsx b/frontend/src/pages/Permissions.tsx new file mode 100644 index 0000000..77f178f --- /dev/null +++ b/frontend/src/pages/Permissions.tsx @@ -0,0 +1,275 @@ +import { Button, Form, Input, Modal, Popconfirm, Space, Table, Tag, Select, InputNumber } from "antd"; +import { useEffect, useMemo, useState } from "react"; +import { createPermission, deletePermission, listMyPermissions, updatePermission } from "../api"; +import type { SysPermission } from "../types"; +import { usePermission } from "../hooks/usePermission"; + +const { Option } = Select; + +type TreePermission = SysPermission & { key: number; children?: TreePermission[] }; + +function buildTree(list: SysPermission[]): TreePermission[] { + const map = new Map(); + const roots: TreePermission[] = []; + + list.forEach((item) => { + map.set(item.permId, { ...item, key: item.permId, children: [] }); + }); + + map.forEach((node) => { + if (node.parentId && map.has(node.parentId)) { + map.get(node.parentId)!.children!.push(node); + } else { + roots.push(node); + } + }); + + const sortChildren = (nodes: TreePermission[]) => { + nodes.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)); + nodes.forEach((n) => n.children && sortChildren(n.children)); + }; + sortChildren(roots); + + return roots; +} + +export default function Permissions() { + const [loading, setLoading] = useState(false); + const [data, setData] = useState([]); + const [query, setQuery] = useState({ name: "", code: "", permType: "" }); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + const { can } = usePermission(); + const level = Form.useWatch("level", form); + + const load = async () => { + setLoading(true); + try { + const list = await listMyPermissions(); + setData(list || []); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const filtered = useMemo(() => { + return data.filter((p) => { + const hitName = query.name ? p.name?.includes(query.name) : true; + const hitCode = query.code ? p.code?.includes(query.code) : true; + const hitType = query.permType ? p.permType === query.permType : true; + return hitName && hitCode && hitType; + }); + }, [data, query]); + + const treeData = useMemo(() => buildTree(filtered), [filtered]); + + const parentOptions = useMemo(() => { + return data + .filter((p) => p.permType === "menu" && (p.level === 1 || !p.parentId)) + .map((p) => ({ value: p.permId, label: p.name })); + }, [data]); + + const openCreate = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ level: 1, permType: "menu", status: 1, isVisible: 1, sortOrder: 0 }); + setOpen(true); + }; + + const openEdit = (record: SysPermission) => { + setEditing(record); + form.setFieldsValue(record); + setOpen(true); + }; + + const submit = async () => { + const values = await form.validateFields(); + const payload: Partial = { + parentId: values.level === 1 ? undefined : values.parentId, + name: values.name, + code: values.code, + permType: values.permType, + level: values.level, + path: values.path, + component: values.component, + icon: values.icon, + sortOrder: values.sortOrder, + isVisible: values.isVisible, + status: values.status, + description: values.description + }; + if (editing) { + await updatePermission(editing.permId, payload); + } else { + await createPermission(payload); + } + setOpen(false); + load(); + }; + + const remove = async (id: number) => { + await deletePermission(id); + load(); + }; + + return ( +
+ + setQuery({ ...query, name: e.target.value })} + /> + setQuery({ ...query, code: e.target.value })} + /> +
(v === 1 ? 显示 : 隐藏) + }, + { + title: "状态", + dataIndex: "status", + width: 80, + render: (v) => (v === 1 ? 启用 : 禁用) + }, + { + title: "操作", + width: 160, + render: (_, record) => ( + + {can("sys_permission:update") && } + {can("sys_permission:delete") && ( + remove(record.permId)}> + + + )} + + ) + } + ]} + /> + + setOpen(false)} + destroyOnClose + > +
{ + if (changed.level === 1) { + form.setFieldsValue({ parentId: undefined }); + form.validateFields(["parentId"]).catch(() => undefined); + } + if (changed.level === 2) { + form.validateFields(["parentId"]).catch(() => undefined); + } + }} + > + + + + ({ + required: getFieldValue("level") === 2, + message: "请选择父级菜单" + }) + ]} + > + + + ({ + required: getFieldValue("permType") === "button", + message: "按钮权限必须填写编码" + }) + ]}> + + + + + + + + + + + + + + + + + + + + + +
+ + ); +} + diff --git a/frontend/src/pages/Roles.tsx b/frontend/src/pages/Roles.tsx new file mode 100644 index 0000000..c39daed --- /dev/null +++ b/frontend/src/pages/Roles.tsx @@ -0,0 +1,161 @@ +import { Button, Form, Input, Modal, Popconfirm, Space, Table, Tag, Select } from "antd"; +import { useEffect, useMemo, useState } from "react"; +import { createRole, deleteRole, listRoles, updateRole } from "../api"; +import type { SysRole } from "../types"; +import { usePermission } from "../hooks/usePermission"; + +export default function Roles() { + const [loading, setLoading] = useState(false); + const [data, setData] = useState([]); + const [query, setQuery] = useState({ roleCode: "", roleName: "" }); + const [pagination, setPagination] = useState({ current: 1, pageSize: 10 }); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + const { can } = usePermission(); + + const load = async () => { + setLoading(true); + try { + const list = await listRoles(); + setData(list || []); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const filtered = useMemo(() => { + return data.filter((r) => { + const hitCode = query.roleCode ? r.roleCode?.includes(query.roleCode) : true; + const hitName = query.roleName ? r.roleName?.includes(query.roleName) : true; + return hitCode && hitName; + }); + }, [data, query]); + + const pageData = useMemo(() => { + const start = (pagination.current - 1) * pagination.pageSize; + return filtered.slice(start, start + pagination.pageSize); + }, [filtered, pagination]); + + const openCreate = () => { + setEditing(null); + form.resetFields(); + setOpen(true); + }; + + const openEdit = (record: SysRole) => { + setEditing(record); + form.setFieldsValue(record); + setOpen(true); + }; + + const submit = async () => { + const values = await form.validateFields(); + const payload: Partial = { + roleCode: values.roleCode, + roleName: values.roleName, + remark: values.remark, + status: values.status + }; + if (editing) { + await updateRole(editing.roleId, payload); + } else { + await createRole(payload); + } + setOpen(false); + load(); + }; + + const remove = async (id: number) => { + await deleteRole(id); + load(); + }; + + return ( +
+ + setQuery({ ...query, roleCode: e.target.value })} + /> + setQuery({ ...query, roleName: e.target.value })} + /> + {can("sys_role:create") && ( + + )} + + +
setPagination({ current, pageSize }) + }} + columns={[ + { title: "ID", dataIndex: "roleId" }, + { title: "编码", dataIndex: "roleCode" }, + { title: "名称", dataIndex: "roleName" }, + { title: "备注", dataIndex: "remark" }, + { + title: "状态", + dataIndex: "status", + render: (v) => (v === 1 ? 启用 : 禁用) + }, + { + title: "操作", + render: (_, record) => ( + + {can("sys_role:update") && } + {can("sys_role:delete") && ( + remove(record.roleId)}> + + + )} + + ) + } + ]} + /> + + setOpen(false)} + destroyOnClose + > +
+ + + + + + + + + + + setQuery({ ...query, username: e.target.value })} + /> + setQuery({ ...query, displayName: e.target.value })} + /> + setQuery({ ...query, phone: e.target.value })} + /> + {can("sys_user:create") && ( + + )} + + +
setPagination({ current, pageSize }) + }} + columns={[ + { title: "ID", dataIndex: "userId" }, + { title: "用户名", dataIndex: "username" }, + { title: "显示名", dataIndex: "displayName" }, + { title: "邮箱", dataIndex: "email" }, + { title: "手机", dataIndex: "phone" }, + { + title: "状态", + dataIndex: "status", + render: (v) => (v === 1 ? 启用 : 禁用) + }, + { + title: "操作", + render: (_, record) => ( + + {can("sys_user:update") && } + {can("sys_user:delete") && ( + remove(record.userId)}> + + + )} + + ) + } + ]} + /> + + setOpen(false)} + destroyOnClose + > + + + + + + + + + + + + + + + + + +