调整结构

This commit is contained in:
马宝龙
2026-07-28 12:52:36 +08:00
parent f9dcf890b2
commit 94dd560331
61 changed files with 4243 additions and 906 deletions

View File

@@ -2,19 +2,19 @@
# 当前环境标识。项目连接外部 MySQL不通过 Docker 创建数据库。
APP_ENV=development
# 外部 MySQL 连接配置。Spring Boot 会读取这一组变量
MYSQL_HOST=24.233.2.106
MYSQL_PORT=13322
# 外部 MySQL 连接配置。请在本地 .env 或部署 Secret 中填写真实值
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_DATABASE=training
MYSQL_USER=admin
MYSQL_PASSWORD=13518200336LsD@@
MYSQL_ROOT_PASSWORD=root_password
MYSQL_USER=training
MYSQL_PASSWORD=change-this-password
MYSQL_ROOT_PASSWORD=change-this-root-password
# Spring Boot 运行配置。
SERVER_PORT=8080
SPRING_JPA_HIBERNATE_DDL_AUTO=update
SPRING_JPA_SHOW_SQL=true
SPRING_JPA_HIBERNATE_DDL_AUTO=none
SPRING_JPA_SHOW_SQL=false
SPRING_JPA_OPEN_IN_VIEW=false
SPRING_JPA_PROPERTIES_HIBERNATE_FORMAT_SQL=true
SPRING_JPA_PROPERTIES_HIBERNATE_FORMAT_SQL=false
LOGGING_LEVEL_COM_EXAMPLE_TRAINING=DEBUG
LOGGING_LEVEL_ORG_HIBERNATE_SQL=DEBUG

108
README.md
View File

@@ -1,62 +1,114 @@
# Training Full-Stack Project
前后端分离的全栈项目基础工程
前后端分离的培训项目
- `frontend`: React 18 + TypeScript + Vite
- `backend`: Spring Boot + Java 21 + Maven + MyBatis-Plus
- `MySQL`: 外部 MySQL 8 数据库,由环境变量提供连接配置
- `frontend`React 18TypeScriptVite、React Router、Zustand、Tailwind CSS、SCSS Modules
- `backend`Spring Boot 3.4.5、Java 21MavenMyBatis-Plus
- `MySQL`外部 MySQL 8 数据库,应用不会自动创建或修改表结构
## 环境要求
- Node.js 20+
- JDK 21+
- Maven 3.9+(或使用项目中的 Maven Wrapper
- Maven 3.9+
- 可访问目标 MySQL 的网络和账号
## 配置数据库
## 配置环境
项目不会创建或启动 MySQL。请先确保 `.env.example` 中配置的 MySQL 服务已经运行并允许当前机器访问。
Spring Boot 启动时按以下顺序读取配置:根目录 `.env`,然后是根目录 `.env.example`。同名变量以 `.env` 为准;没有 `.env` 时直接使用已配置的 `.env.example`
建议将 `.env.example` 复制为本地 `.env`,实际 `.env` 不提交到仓库:
使用 `.env.example` 作为本地配置参考,并将真实值写入未提交的根目录 `.env`
```powershell
cp .env.example .env
Copy-Item .env.example .env
```
生产环境使用 `.env.production.example` 作为 `.env` 模板,或由部署平台注入同名环境变量:
生产环境使用 `.env.production.example`,或由部署平台注入同名 Secret。不要将真实密码、令牌或数据库地址写入示例文件、日志或提交内容。
```powershell
cp .env.production.example .env
## 数据库初始化与迁移
SQL 文件只保存在根目录 `sql/`
- `sql/USERS.sql`:新环境的最终建表语句,包含表和字段中文注释。
- `sql/migrations/V1__rename_users_to_USERS.sql`:保留既有 `users` 数据的人工迁移脚本。
迁移前只读检查目标库和影响范围:
```sql
SHOW TABLES LIKE 'users';
SHOW TABLES LIKE 'USERS';
```
需要确认 `MYSQL_HOST``MYSQL_PORT``MYSQL_DATABASE``MYSQL_USER``MYSQL_PASSWORD` 均已配置。生产环境建议使用部署平台的 Secret 管理能力。
确认备份、目标数据库和回滚方式后人工执行迁移脚本。MySQL 的 `RENAME TABLE` 会隐式提交,回滚使用:
```sql
RENAME TABLE USERS TO users;
```
应用启动不会自动执行 SQL迁移和建表必须由数据库管理员按环境流程执行。
## 启动后端
```bash
```powershell
mvn -f backend/pom.xml spring-boot:run
```
后端默认运行在 `http://localhost:8080`,健康检查地址为 `GET /api/health`
后端默认运行在 `http://127.0.0.1:8080`,健康检查地址为 `GET /api/v1/health`每次请求支持并返回 `X-Request-Id`
## 启动前端
```bash
cd frontend
npm install
npm run dev
```powershell
Set-Location frontend
npm ci
npm run dev -- --host 127.0.0.1
```
前端默认运行在 `http://localhost:5173`,开发服务器`/api` 请求代理到后端。
前端默认运行在 `http://127.0.0.1:5173`,开发服务器将 `/api` 请求代理到后端。API 基础路径为 `/api/v1`
## 构建检查
## API 示例
```bash
cd frontend
用户列表使用 1-based 分页:
```text
GET /api/v1/users?page=1&pageSize=50&keyword=&role=&status=
GET /api/v1/users/summary
```
成功响应统一为:
```json
{
"code": 200,
"message": "success",
"data": {}
}
```
## 构建与测试
```powershell
Set-Location frontend
npm ci
npm run test
npm run build
cd ../backend
mvn clean verify
Set-Location ..
mvn -f backend/pom.xml test
mvn -f backend/pom.xml verify
```
## 全栈只读验证
从项目根目录执行:
```powershell
python .codex/skills/fullstack-start-verify/scripts/start_and_verify.py `
--backend-cmd "mvn -f backend/pom.xml spring-boot:run" `
--backend-dir . `
--backend-url http://127.0.0.1:8080/api/v1/health `
--frontend-cmd "npm run dev -- --host 127.0.0.1" `
--frontend-dir frontend `
--frontend-url http://127.0.0.1:5173/ `
--check-url summary=http://127.0.0.1:8080/api/v1/users/summary `
--check-url "users=http://127.0.0.1:8080/api/v1/users?page=1& pageSize=50"
```
验证期间只执行健康检查和 GET 查询,不执行新增、修改、删除或数据库迁移。脚本默认清理本次启动的进程;排查启动失败时增加 `--keep-logs` 保留日志,并确认日志中没有敏感信息。

View File

@@ -0,0 +1,17 @@
package com.example.training.common;
/**
* 统一接口响应结构。
*
* @param <T> 响应数据类型
*/
public record ApiResponse<T>(int code, String message, T data) {
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(200, "success", data);
}
public static <T> ApiResponse<T> failure(int code, String message) {
return new ApiResponse<>(code, message, null);
}
}

View File

@@ -15,4 +15,5 @@ public class MybatisPlusConfig {
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}

View File

@@ -0,0 +1,33 @@
package com.example.training.config;
import java.io.IOException;
import java.util.UUID;
import java.util.regex.Pattern;
import com.example.training.util.RequestIdContext;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;
public class RequestIdFilter extends OncePerRequestFilter {
private static final Pattern SAFE_REQUEST_ID = Pattern.compile("[A-Za-z0-9._-]{1,64}");
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String requestId = request.getHeader(RequestIdContext.HEADER_NAME);
if (requestId == null || !SAFE_REQUEST_ID.matcher(requestId).matches()) {
requestId = UUID.randomUUID().toString();
}
RequestIdContext.set(requestId);
response.setHeader(RequestIdContext.HEADER_NAME, requestId);
try {
filterChain.doFilter(request, response);
} finally {
RequestIdContext.clear();
}
}
}

View File

@@ -0,0 +1,18 @@
package com.example.training.config;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean<RequestIdFilter> requestIdFilter() {
FilterRegistrationBean<RequestIdFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new RequestIdFilter());
registration.addUrlPatterns("/*");
registration.setOrder(-100);
return registration;
}
}

View File

@@ -0,0 +1,25 @@
package com.example.training.constant;
public enum ErrorCode {
VALIDATION_FAILED(400001, "请求参数校验失败"),
USER_NOT_FOUND(404001, "用户不存在"),
DUPLICATE_EMAIL(409001, "该邮箱已被使用,请更换后重试"),
INTERNAL_ERROR(500001, "服务暂时不可用,请稍后重试");
private final int code;
private final String message;
ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
}

View File

@@ -3,17 +3,18 @@ package com.example.training.controller;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import com.example.training.common.ApiResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/health")
@RequestMapping("/api/v1/health")
public class HealthController {
@GetMapping
public HealthResponse health() {
return new HealthResponse("UP", "training-backend", OffsetDateTime.now(ZoneOffset.UTC));
public ApiResponse<HealthResponse> health() {
return ApiResponse.success(new HealthResponse("UP", "training-backend", OffsetDateTime.now(ZoneOffset.UTC)));
}
public record HealthResponse(String status, String service, OffsetDateTime timestamp) {

View File

@@ -1,22 +1,22 @@
package com.example.training.controller;
import java.time.LocalDateTime;
import java.util.List;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.training.domain.User;
import com.example.training.common.ApiResponse;
import com.example.training.constant.ErrorCode;
import com.example.training.domain.dto.BulkStatusRequest;
import com.example.training.domain.dto.UserRequest;
import com.example.training.domain.entity.User;
import com.example.training.domain.vo.BulkStatusVo;
import com.example.training.domain.vo.UserPageVo;
import com.example.training.domain.vo.UserSummaryVo;
import com.example.training.domain.vo.UserVo;
import com.example.training.service.UserService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -29,7 +29,7 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/users")
@RequestMapping("/api/v1/users")
public class UserController {
private final UserService userService;
@@ -39,91 +39,55 @@ public class UserController {
}
@GetMapping
public UserPage list(
public ApiResponse<UserPageVo> list(
@RequestParam(defaultValue = "") String keyword,
@RequestParam(defaultValue = "") String role,
@RequestParam(defaultValue = "") String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
int safePage = Math.max(page, 0);
int safeSize = Math.min(Math.max(size, 1), 100);
Page<User> resultPage = userService.pageUsers(keyword, role, status, safePage, safeSize);
return new UserPage(resultPage.getRecords().stream().map(UserResponse::from).toList(),
resultPage.getTotal(), (int) resultPage.getCurrent() - 1, (int) resultPage.getSize());
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "50") int pageSize) {
int safePage = Math.max(page, 1);
int safePageSize = Math.min(Math.max(pageSize, 1), 100);
Page<User> resultPage = userService.pageUsers(keyword, role, status, safePage, safePageSize);
UserPageVo data = new UserPageVo(resultPage.getRecords().stream().map(UserVo::from).toList(),
resultPage.getTotal(), safePage, safePageSize);
return ApiResponse.success(data);
}
@GetMapping("/summary")
public UserSummary summary() {
public ApiResponse<UserSummaryVo> summary() {
UserService.UserSummary summary = userService.summarizeUsers();
return new UserSummary(summary.total(), summary.active(), summary.inactive(), summary.admins());
return ApiResponse.success(new UserSummaryVo(summary.total(), summary.active(),
summary.inactive(), summary.admins()));
}
@PatchMapping("/status")
public BulkStatusResponse updateStatuses(@Valid @RequestBody BulkStatusRequest request) {
public ApiResponse<BulkStatusVo> updateStatuses(@Valid @RequestBody BulkStatusRequest request) {
int updated = userService.updateStatuses(request.ids(), request.status());
return new BulkStatusResponse(updated, request.status());
return ApiResponse.success(new BulkStatusVo(updated, request.status()));
}
@PostMapping
public ResponseEntity<UserResponse> create(@Valid @RequestBody UserRequest request) {
public ResponseEntity<ApiResponse<UserVo>> create(@Valid @RequestBody UserRequest request) {
User user = userService.createUser(request.fullName(), request.email(),
request.role(), request.status());
return ResponseEntity.status(HttpStatus.CREATED).body(UserResponse.from(user));
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(UserVo.from(user)));
}
@PutMapping("/{id}")
public UserResponse update(@PathVariable Long id, @Valid @RequestBody UserRequest request) {
public ApiResponse<UserVo> update(@PathVariable Long id, @Valid @RequestBody UserRequest request) {
User user = userService.updateUser(id, request.fullName(), request.email(),
request.role(), request.status());
if (user == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "用户不存在");
throw new ResponseStatusException(HttpStatus.NOT_FOUND, ErrorCode.USER_NOT_FOUND.getMessage());
}
return UserResponse.from(user);
return ApiResponse.success(UserVo.from(user));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
public ApiResponse<Void> delete(@PathVariable Long id) {
if (!userService.deleteUser(id)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "用户不存在");
throw new ResponseStatusException(HttpStatus.NOT_FOUND, ErrorCode.USER_NOT_FOUND.getMessage());
}
return ResponseEntity.noContent().build();
}
@ExceptionHandler(DataIntegrityViolationException.class)
ResponseEntity<ApiError> handleDuplicateEmail() {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ApiError("该邮箱已被使用,请更换后重试"));
}
public record UserRequest(
@NotBlank @Size(max = 80) String fullName,
@NotBlank @Email @Size(max = 160) String email,
@NotBlank @Size(max = 40) String role,
@NotBlank @Size(max = 20) String status) {
}
public record BulkStatusRequest(
@NotEmpty @Size(max = 100) List<Long> ids,
@NotBlank @Pattern(regexp = "ACTIVE|INACTIVE") String status) {
}
public record UserPage(List<UserResponse> items, long total, int page, int size) {
}
public record UserSummary(long total, long active, long inactive, long admins) {
}
public record BulkStatusResponse(int updated, String status) {
}
public record UserResponse(Long id, String fullName, String email, String role,
String status, LocalDateTime createdAt, LocalDateTime updatedAt) {
static UserResponse from(User user) {
return new UserResponse(user.getId(), user.getFullName(), user.getEmail(),
user.getRole(), user.getStatus(), user.getCreatedAt(), user.getUpdatedAt());
}
}
public record ApiError(String message) {
return ApiResponse.success(null);
}
}

View File

@@ -1,57 +1,16 @@
package com.example.training.domain;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
@TableName("users")
public class User {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField("full_name")
private String fullName;
private String email;
private String role;
private String status;
@TableField("created_at")
private LocalDateTime createdAt;
@TableField("updated_at")
private LocalDateTime updatedAt;
/**
* 兼容旧包路径的过渡类型,业务代码统一使用 domain.entity.User。
*/
@Deprecated
public class User extends com.example.training.domain.entity.User {
public User() {
super();
}
public User(String fullName, String email, String role, String status) {
LocalDateTime now = LocalDateTime.now();
this.fullName = fullName;
this.email = email;
this.role = role;
this.status = status;
this.createdAt = now;
this.updatedAt = now;
}
public Long getId() { return id; }
public String getFullName() { return fullName; }
public String getEmail() { return email; }
public String getRole() { return role; }
public String getStatus() { return status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void update(String fullName, String email, String role, String status) {
this.fullName = fullName;
this.email = email;
this.role = role;
this.status = status;
this.updatedAt = LocalDateTime.now();
super(fullName, email, role, status);
}
}

View File

@@ -0,0 +1,13 @@
package com.example.training.domain.dto;
import java.util.List;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record BulkStatusRequest(
@NotEmpty(message = "用户 ID 不能为空") @Size(max = 100, message = "单次最多更新 100 个用户") List<Long> ids,
@NotBlank(message = "状态不能为空") @Pattern(regexp = "ACTIVE|INACTIVE", message = "状态不合法") String status) {
}

View File

@@ -0,0 +1,13 @@
package com.example.training.domain.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record UserRequest(
@NotBlank(message = "姓名不能为空") @Size(max = 80, message = "姓名长度不能超过 80 个字符") String fullName,
@NotBlank(message = "邮箱不能为空") @Email(message = "邮箱格式不正确") @Size(max = 160, message = "邮箱长度不能超过 160 个字符") String email,
@NotBlank(message = "角色不能为空") @Pattern(regexp = "Admin|Manager|Member|Viewer", message = "角色不合法") String role,
@NotBlank(message = "状态不能为空") @Pattern(regexp = "ACTIVE|INACTIVE", message = "状态不合法") String status) {
}

View File

@@ -0,0 +1,77 @@
package com.example.training.domain.entity;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
@TableName("USERS")
public class User {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField("full_name")
private String fullName;
private String email;
private String role;
private String status;
@TableField("created_at")
private LocalDateTime createdAt;
@TableField("updated_at")
private LocalDateTime updatedAt;
public User() {
}
public User(String fullName, String email, String role, String status) {
LocalDateTime now = LocalDateTime.now();
this.fullName = fullName;
this.email = email;
this.role = role;
this.status = status;
this.createdAt = now;
this.updatedAt = now;
}
public Long getId() {
return id;
}
public String getFullName() {
return fullName;
}
public String getEmail() {
return email;
}
public String getRole() {
return role;
}
public String getStatus() {
return status;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void update(String fullName, String email, String role, String status) {
this.fullName = fullName;
this.email = email;
this.role = role;
this.status = status;
this.updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,4 @@
package com.example.training.domain.vo;
public record BulkStatusVo(int updated, String status) {
}

View File

@@ -0,0 +1,6 @@
package com.example.training.domain.vo;
import java.util.List;
public record UserPageVo(List<UserVo> items, long total, int page, int pageSize) {
}

View File

@@ -0,0 +1,4 @@
package com.example.training.domain.vo;
public record UserSummaryVo(long total, long active, long inactive, long admins) {
}

View File

@@ -0,0 +1,14 @@
package com.example.training.domain.vo;
import java.time.LocalDateTime;
import com.example.training.domain.entity.User;
public record UserVo(Long id, String fullName, String email, String role,
String status, LocalDateTime createdAt, LocalDateTime updatedAt) {
public static UserVo from(User user) {
return new UserVo(user.getId(), user.getFullName(), user.getEmail(), user.getRole(),
user.getStatus(), user.getCreatedAt(), user.getUpdatedAt());
}
}

View File

@@ -0,0 +1,60 @@
package com.example.training.exception;
import com.example.training.common.ApiResponse;
import com.example.training.constant.ErrorCode;
import com.example.training.util.RequestIdContext;
import jakarta.validation.ConstraintViolationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.server.ResponseStatusException;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException exception) {
String message = exception.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(error -> error.getDefaultMessage())
.orElse(ErrorCode.VALIDATION_FAILED.getMessage());
return ResponseEntity.badRequest().body(ApiResponse.failure(
ErrorCode.VALIDATION_FAILED.getCode(), message));
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResponse<Void>> handleConstraintViolation() {
return ResponseEntity.badRequest().body(ApiResponse.failure(
ErrorCode.VALIDATION_FAILED.getCode(), ErrorCode.VALIDATION_FAILED.getMessage()));
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ApiResponse<Void>> handleDataIntegrityViolation() {
return ResponseEntity.status(409).body(ApiResponse.failure(
ErrorCode.DUPLICATE_EMAIL.getCode(), ErrorCode.DUPLICATE_EMAIL.getMessage()));
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ApiResponse<Void>> handleResponseStatus(ResponseStatusException exception) {
int code = exception.getStatusCode().value() == 404
? ErrorCode.USER_NOT_FOUND.getCode()
: ErrorCode.VALIDATION_FAILED.getCode();
String message = exception.getStatusCode().value() == 404
? ErrorCode.USER_NOT_FOUND.getMessage()
: exception.getReason();
return ResponseEntity.status(exception.getStatusCode()).body(ApiResponse.failure(code, message));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleUnexpectedException(Exception exception) {
log.error("请求处理失败requestId={}", RequestIdContext.current(), exception);
return ResponseEntity.internalServerError().body(ApiResponse.failure(
ErrorCode.INTERNAL_ERROR.getCode(), ErrorCode.INTERNAL_ERROR.getMessage()));
}
}

View File

@@ -1,7 +1,7 @@
package com.example.training.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.training.domain.User;
import com.example.training.domain.entity.User;
import org.apache.ibatis.annotations.Mapper;
@Mapper

View File

@@ -1,7 +1,7 @@
package com.example.training.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.training.domain.User;
import com.example.training.domain.entity.User;
import java.util.List;

View File

@@ -3,9 +3,12 @@ package com.example.training.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.training.domain.User;
import com.example.training.domain.entity.User;
import com.example.training.mapper.UserMapper;
import com.example.training.service.UserService;
import com.example.training.util.RequestIdContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -15,6 +18,8 @@ import java.util.List;
@Service
public class UserServiceImpl implements UserService {
private static final Logger log = LoggerFactory.getLogger(UserServiceImpl.class);
private final UserMapper userMapper;
public UserServiceImpl(UserMapper userMapper) {
@@ -24,7 +29,7 @@ public class UserServiceImpl implements UserService {
@Override
@Transactional(readOnly = true)
public Page<User> pageUsers(String keyword, String role, String status, int page, int size) {
Page<User> requestPage = new Page<>(page + 1L, size);
Page<User> requestPage = new Page<>(Math.max(page, 1), Math.min(Math.max(size, 1), 100));
LambdaQueryWrapper<User> query = new LambdaQueryWrapper<>();
if (keyword != null && !keyword.isBlank()) {
String trimmedKeyword = keyword.trim();
@@ -65,7 +70,10 @@ public class UserServiceImpl implements UserService {
update.in(User::getId, ids)
.set(User::getStatus, status)
.set(User::getUpdatedAt, LocalDateTime.now());
return userMapper.update(null, update);
int updated = userMapper.update(null, update);
log.info("批量更新用户状态requestId={}updated={}status={}",
RequestIdContext.current(), updated, status);
return updated;
}
@Override
@@ -73,6 +81,7 @@ public class UserServiceImpl implements UserService {
public User createUser(String fullName, String email, String role, String status) {
User user = new User(normalize(fullName), normalizeEmail(email), role, status);
userMapper.insert(user);
log.info("创建用户requestId={}userId={}", RequestIdContext.current(), user.getId());
return user;
}
@@ -85,6 +94,7 @@ public class UserServiceImpl implements UserService {
}
user.update(normalize(fullName), normalizeEmail(email), role, status);
userMapper.updateById(user);
log.info("更新用户requestId={}userId={}", RequestIdContext.current(), id);
return user;
}
@@ -94,7 +104,10 @@ public class UserServiceImpl implements UserService {
if (userMapper.selectById(id) == null) {
return false;
}
return userMapper.deleteById(id) > 0;
boolean deleted = userMapper.deleteById(id) > 0;
log.info("删除用户requestId={}userId={}deleted={}",
RequestIdContext.current(), id, deleted);
return deleted;
}
private static String normalize(String value) {

View File

@@ -0,0 +1,24 @@
package com.example.training.util;
import org.slf4j.MDC;
public final class RequestIdContext {
public static final String HEADER_NAME = "X-Request-Id";
private static final String MDC_KEY = "requestId";
private RequestIdContext() {
}
public static void set(String requestId) {
MDC.put(MDC_KEY, requestId);
}
public static String current() {
return MDC.get(MDC_KEY);
}
public static void clear() {
MDC.remove(MDC_KEY);
}
}

View File

@@ -15,8 +15,7 @@ spring:
validation-timeout: ${MYSQL_POOL_VALIDATION_TIMEOUT_MS:5000}
sql:
init:
mode: always
continue-on-error: false
mode: never
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
@@ -36,3 +35,5 @@ logging:
level:
com.example.training: ${LOGGING_LEVEL_COM_EXAMPLE_TRAINING:INFO}
com.baomidou.mybatisplus: INFO
pattern:
level: "%5p [requestId:%X{requestId}]"

View File

@@ -1,13 +0,0 @@
CREATE TABLE IF NOT EXISTS users (
id BIGINT NOT NULL AUTO_INCREMENT,
full_name VARCHAR(80) NOT NULL,
email VARCHAR(160) NOT NULL,
role VARCHAR(40) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_users_email (email),
KEY idx_users_full_name (full_name),
KEY idx_users_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -7,13 +7,17 @@ import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import com.example.training.controller.HealthController;
import com.example.training.config.WebConfig;
import org.springframework.context.annotation.Import;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
@WebMvcTest(HealthController.class)
@ActiveProfiles("test")
@Import(WebConfig.class)
class TrainingApplicationTests {
@Autowired
@@ -21,9 +25,12 @@ class TrainingApplicationTests {
@Test
void healthEndpointReturnsUp() throws Exception {
mockMvc.perform(get("/api/health"))
mockMvc.perform(get("/api/v1/health").header("X-Request-Id", "health-test-1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("UP"))
.andExpect(jsonPath("$.service").value("training-backend"));
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.message").value("success"))
.andExpect(jsonPath("$.data.status").value("UP"))
.andExpect(jsonPath("$.data.service").value("training-backend"))
.andExpect(header().string("X-Request-Id", "health-test-1"));
}
}

View File

@@ -31,39 +31,53 @@ class UserControllerTests {
@Test
void listPassesRoleAndStatusFilters() throws Exception {
when(userService.pageUsers("", "Admin", "ACTIVE", 0, 50)).thenReturn(new Page<>(1, 50));
when(userService.pageUsers("", "Admin", "ACTIVE", 1, 50)).thenReturn(new Page<>(1, 50));
mockMvc.perform(get("/api/users")
mockMvc.perform(get("/api/v1/users")
.param("keyword", "")
.param("role", "Admin")
.param("status", "ACTIVE"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.total").value(0));
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.total").value(0))
.andExpect(jsonPath("$.data.page").value(1))
.andExpect(jsonPath("$.data.pageSize").value(50));
verify(userService).pageUsers("", "Admin", "ACTIVE", 0, 50);
verify(userService).pageUsers("", "Admin", "ACTIVE", 1, 50);
}
@Test
void summaryReturnsOverviewCounts() throws Exception {
when(userService.summarizeUsers()).thenReturn(new UserService.UserSummary(12, 9, 3, 2));
mockMvc.perform(get("/api/users/summary"))
mockMvc.perform(get("/api/v1/users/summary"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.total").value(12))
.andExpect(jsonPath("$.active").value(9))
.andExpect(jsonPath("$.inactive").value(3))
.andExpect(jsonPath("$.admins").value(2));
.andExpect(jsonPath("$.data.total").value(12))
.andExpect(jsonPath("$.data.active").value(9))
.andExpect(jsonPath("$.data.inactive").value(3))
.andExpect(jsonPath("$.data.admins").value(2));
}
@Test
void bulkStatusUpdatesSelectedUsers() throws Exception {
when(userService.updateStatuses(anyList(), eq("INACTIVE"))).thenReturn(2);
mockMvc.perform(patch("/api/users/status")
mockMvc.perform(patch("/api/v1/users/status")
.contentType("application/json")
.content("{\"ids\":[4,7],\"status\":\"INACTIVE\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.updated").value(2))
.andExpect(jsonPath("$.status").value("INACTIVE"));
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.updated").value(2))
.andExpect(jsonPath("$.data.status").value("INACTIVE"));
}
@Test
void invalidUserRequestReturnsStandardError() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/v1/users")
.contentType("application/json")
.content("{\"fullName\":\"\",\"email\":\"invalid\",\"role\":\"Unknown\",\"status\":\"BROKEN\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400001))
.andExpect(jsonPath("$.data").doesNotExist());
}
}

View File

@@ -1 +1 @@
VITE_API_BASE_URL=/api
VITE_API_BASE_URL=/api/v1

File diff suppressed because it is too large Load Diff

View File

@@ -6,17 +6,28 @@
"scripts": {
"dev": "vite",
"build": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"classnames": "^2.5.1",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"jsdom": "^25.0.1",
"postcss": "^8.4.49",
"sass": "^1.83.4",
"tailwindcss": "^3.4.17",
"typescript": "^5.6.3",
"vite": "^5.4.10"
"vite": "^5.4.10",
"vitest": "^2.1.8"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,331 +1,7 @@
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'
import { RouterProvider } from 'react-router-dom'
type Status = 'ACTIVE' | 'INACTIVE'
import { router } from './routes'
type User = {
id: number
fullName: string
email: string
role: string
status: Status
createdAt: string
updatedAt: string
export default function App() {
return <RouterProvider router={router} />
}
type UserForm = Omit<User, 'id' | 'createdAt' | 'updatedAt'>
type UserSummary = {
total: number
active: number
inactive: number
admins: number
}
type UserPage = {
items: User[]
total: number
}
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '/api'
const roleOptions = ['Admin', 'Manager', 'Member', 'Viewer']
const emptyForm: UserForm = { fullName: '', email: '', role: 'Member', status: 'ACTIVE' }
function roleLabel(role: string) {
return ({ Admin: '管理员', Manager: '经理', Member: '成员', Viewer: '只读成员' }[role] ?? role)
}
function statusLabel(status: Status) {
return status === 'ACTIVE' ? '活跃' : '未激活'
}
function formatDate(value: string) {
return new Date(value).toLocaleDateString('zh-CN', { year: 'numeric', month: 'short', day: 'numeric' })
}
function initials(name: string) {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length > 1) return `${parts[0].charAt(0)}${parts[parts.length - 1].charAt(0)}`.toUpperCase()
return name.trim().slice(0, 2).toUpperCase()
}
async function readError(response: Response, fallback: string) {
const body = await response.json().catch(() => null) as { message?: string } | null
return body?.message ?? fallback
}
function App() {
const [users, setUsers] = useState<User[]>([])
const [total, setTotal] = useState(0)
const [summary, setSummary] = useState<UserSummary>({ total: 0, active: 0, inactive: 0, admins: 0 })
const [keyword, setKeyword] = useState('')
const [search, setSearch] = useState('')
const [roleFilter, setRoleFilter] = useState('')
const [statusFilter, setStatusFilter] = useState<'' | Status>('')
const [form, setForm] = useState<UserForm>(emptyForm)
const [editingId, setEditingId] = useState<number | null>(null)
const [isFormOpen, setIsFormOpen] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [isBulkUpdating, setIsBulkUpdating] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const loadUsers = useCallback(async () => {
setIsLoading(true)
setError('')
try {
const params = new URLSearchParams({ keyword: search, role: roleFilter, status: statusFilter, page: '0', size: '50' })
const response = await fetch(`${apiBaseUrl}/users?${params}`)
if (!response.ok) throw new Error(await readError(response, '无法加载用户列表'))
const data = await response.json() as UserPage
setUsers(data.items)
setTotal(data.total)
setSelectedIds(new Set())
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '请求失败,请稍后重试')
} finally {
setIsLoading(false)
}
}, [roleFilter, search, statusFilter])
const loadSummary = useCallback(async () => {
try {
const response = await fetch(`${apiBaseUrl}/users/summary`)
if (!response.ok) throw new Error(await readError(response, '无法加载用户概览'))
setSummary(await response.json() as UserSummary)
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '无法加载用户概览')
}
}, [])
const refreshData = useCallback(async () => {
await Promise.all([loadUsers(), loadSummary()])
}, [loadSummary, loadUsers])
useEffect(() => { void refreshData() }, [refreshData])
useEffect(() => {
if (!notice) return undefined
const timer = window.setTimeout(() => setNotice(''), 3200)
return () => window.clearTimeout(timer)
}, [notice])
function openCreate() {
setEditingId(null)
setForm(emptyForm)
setError('')
setIsFormOpen(true)
}
function openEdit(user: User) {
setEditingId(user.id)
setForm({ fullName: user.fullName, email: user.email, role: user.role, status: user.status })
setError('')
setIsFormOpen(true)
}
async function submitForm(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setIsSaving(true)
setError('')
try {
const response = await fetch(`${apiBaseUrl}/users${editingId ? `/${editingId}` : ''}`, {
method: editingId ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
if (!response.ok) throw new Error(await readError(response, '保存失败,请检查输入'))
setIsFormOpen(false)
setNotice(editingId ? '用户信息已更新' : '用户已创建')
await refreshData()
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '保存失败,请稍后重试')
} finally {
setIsSaving(false)
}
}
async function removeUser(user: User) {
if (!window.confirm(`确定删除用户“${user.fullName}”吗?此操作不可撤销。`)) return
setError('')
try {
const response = await fetch(`${apiBaseUrl}/users/${user.id}`, { method: 'DELETE' })
if (!response.ok) throw new Error(await readError(response, '删除失败,请稍后重试'))
setNotice('用户已删除')
await refreshData()
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '删除失败,请稍后重试')
}
}
async function updateSelectedStatus(status: Status) {
if (selectedIds.size === 0) return
const label = statusLabel(status)
if (!window.confirm(`确定将选中的 ${selectedIds.size} 位用户设为“${label}”吗?`)) return
setIsBulkUpdating(true)
setError('')
try {
const response = await fetch(`${apiBaseUrl}/users/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: [...selectedIds], status }),
})
if (!response.ok) throw new Error(await readError(response, '批量更新失败,请稍后重试'))
setNotice(`已将选中的用户设为${label}`)
await refreshData()
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '批量更新失败,请稍后重试')
} finally {
setIsBulkUpdating(false)
}
}
function submitSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setSearch(keyword.trim())
}
function clearFilters() {
setKeyword('')
setSearch('')
setRoleFilter('')
setStatusFilter('')
}
function toggleSelected(id: number) {
setSelectedIds((current) => {
const next = new Set(current)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function toggleAll() {
setSelectedIds((current) => current.size === users.length ? new Set() : new Set(users.map((user) => user.id)))
}
const allSelected = users.length > 0 && selectedIds.size === users.length
const activeFilters = Boolean(search || roleFilter || statusFilter)
const summaryCards = useMemo(() => [
{ label: '全部用户', value: summary.total, detail: '当前组织账号', icon: '◎', tone: 'teal' },
{ label: '活跃用户', value: summary.active, detail: '可以正常访问', icon: '✓', tone: 'green' },
{ label: '未激活', value: summary.inactive, detail: '等待重新启用', icon: '—', tone: 'amber' },
{ label: '管理员', value: summary.admins, detail: '拥有管理权限', icon: '◆', tone: 'navy' },
], [summary])
return (
<main className="app-shell">
<header className="topbar">
<div className="topbar-inner">
<div className="brand-lockup">
<div className="brand-mark">U</div>
<div>
<p className="brand-name"></p>
<p className="brand-context"></p>
</div>
</div>
<div className="topbar-meta"><span className="status-dot" /><span className="topbar-divider" /></div>
</div>
</header>
<section className="content-wrap" aria-labelledby="page-title">
<div className="breadcrumb"><span></span><span>/</span><strong></strong></div>
<div className="page-heading">
<div>
<p className="eyebrow"> / DIRECTORY</p>
<h1 id="page-title"></h1>
<p className="page-summary">访</p>
</div>
<button className="primary-button" type="button" onClick={openCreate}><span className="button-icon">+</span></button>
</div>
<section className="summary-grid" aria-label="用户概览">
{summaryCards.map((card) => (
<article className={`summary-card ${card.tone}`} key={card.label}>
<div className="summary-card-top"><span>{card.label}</span><b aria-hidden="true">{card.icon}</b></div>
<strong>{card.value}</strong>
<small>{card.detail}</small>
</article>
))}
</section>
<div className="section-heading"><div><h2></h2><p></p></div><span className="result-count"> {total} </span></div>
<section className="directory-section">
<div className="filter-toolbar">
<form className="search-form" onSubmit={submitSearch}>
<label className="sr-only" htmlFor="user-search"></label>
<span className="search-icon" aria-hidden="true"></span>
<input id="user-search" value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索姓名或邮箱" />
<button type="submit" className="secondary-button"></button>
</form>
<div className="filter-group">
<label className="sr-only" htmlFor="role-filter"></label>
<select id="role-filter" value={roleFilter} onChange={(event) => setRoleFilter(event.target.value)}>
<option value=""></option>
{roleOptions.map((role) => <option value={role} key={role}>{roleLabel(role)}</option>)}
</select>
{activeFilters && <button className="clear-button" type="button" onClick={clearFilters}></button>}
</div>
</div>
<div className="status-tabs" role="group" aria-label="按状态筛选">
{[['', '全部'], ['ACTIVE', '活跃'], ['INACTIVE', '未激活']].map(([value, label]) => (
<button className={statusFilter === value ? 'active' : ''} type="button" key={value} onClick={() => setStatusFilter(value as '' | Status)}>{label}</button>
))}
</div>
{selectedIds.size > 0 && <div className="bulk-toolbar"><span> <strong>{selectedIds.size}</strong> </span><div><button type="button" className="bulk-button" disabled={isBulkUpdating} onClick={() => void updateSelectedStatus('ACTIVE')}></button><button type="button" className="bulk-button" disabled={isBulkUpdating} onClick={() => void updateSelectedStatus('INACTIVE')}></button></div></div>}
{error && <div className="error-banner" role="alert"><span>{error}</span><button type="button" onClick={() => setError('')} aria-label="关闭错误提示">×</button></div>}
<div className="table-frame" aria-live="polite">
{isLoading ? <div className="empty-state"><span className="loading-ring" /></div> : users.length === 0 ? (
<div className="empty-state"><span className="empty-icon"></span><strong></strong><span></span>{activeFilters && <button className="secondary-button" type="button" onClick={clearFilters}></button>}</div>
) : (
<div className="table-scroll">
<table>
<thead><tr><th className="check-column"><input type="checkbox" aria-label="选择全部用户" checked={allSelected} onChange={toggleAll} /></th><th></th><th></th><th></th><th></th><th></th><th><span className="sr-only"></span></th></tr></thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className={selectedIds.has(user.id) ? 'selected-row' : ''}>
<td className="check-column" data-label="选择"><input type="checkbox" aria-label={`选择${user.fullName}`} checked={selectedIds.has(user.id)} onChange={() => toggleSelected(user.id)} /></td>
<td data-label="用户"><div className="user-cell"><span className="avatar">{initials(user.fullName)}</span><span><strong>{user.fullName}</strong><small>{user.email}</small></span></div></td>
<td data-label="角色"><span className="role-label">{roleLabel(user.role)}</span></td>
<td data-label="状态"><span className={`status-label ${user.status.toLowerCase()}`}><span className="status-mini-dot" />{statusLabel(user.status)}</span></td>
<td data-label="加入时间" className="muted-cell">{formatDate(user.createdAt)}</td>
<td data-label="最近更新" className="muted-cell">{formatDate(user.updatedAt)}</td>
<td data-label="操作"><div className="row-actions"><button className="icon-button" type="button" title="编辑用户" aria-label={`编辑${user.fullName}`} onClick={() => openEdit(user)}><span aria-hidden="true"></span></button><button className="icon-button danger-action" type="button" title="删除用户" aria-label={`删除${user.fullName}`} onClick={() => void removeUser(user)}><span aria-hidden="true"></span></button></div></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
</section>
{notice && <div className="toast" role="status"><span className="toast-icon"></span>{notice}</div>}
{isFormOpen && (
<div className="drawer-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setIsFormOpen(false) }}>
<aside className="drawer" role="dialog" aria-modal="true" aria-labelledby="form-title">
<div className="drawer-heading"><div><p className="eyebrow">USER RECORD / </p><h2 id="form-title">{editingId ? '编辑用户' : '新增用户'}</h2><p>访</p></div><button className="close-button" type="button" aria-label="关闭" onClick={() => setIsFormOpen(false)}>×</button></div>
<form onSubmit={submitForm}>
{error && <div className="drawer-error" role="alert">{error}</div>}
<label><input required maxLength={80} value={form.fullName} onChange={(event) => setForm({ ...form, fullName: event.target.value })} placeholder="例如:林晓晨" /></label>
<label><input required type="email" maxLength={160} value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} placeholder="name@company.com" /></label>
<div className="form-grid"><label><select value={form.role} onChange={(event) => setForm({ ...form, role: event.target.value })}>{roleOptions.map((role) => <option key={role}>{role}</option>)}</select></label><label><select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value as Status })}><option value="ACTIVE"></option><option value="INACTIVE"></option></select></label></div>
<div className="form-note"><span>i</span><p>访</p></div>
<div className="drawer-actions"><button type="button" className="secondary-button" onClick={() => setIsFormOpen(false)}></button><button disabled={isSaving} type="submit" className="primary-button">{isSaving ? '保存中…' : editingId ? '保存修改' : '创建用户'}</button></div>
</form>
</aside>
</div>
)}
</main>
)
}
export default App

View File

@@ -0,0 +1,3 @@
# 静态资源目录
图片、字体和其他前端静态资源放在此目录,并在确定资源来源后再接入页面。

View File

@@ -0,0 +1,3 @@
# 通用组件目录
跨路由复用的组件放在此目录;仅用户管理页面使用的组件保留在对应路由目录中。

View File

@@ -0,0 +1,24 @@
import type { Role, Status, UserForm } from '../interfaces/user/model'
export const ROLE_OPTIONS: Role[] = ['Admin', 'Manager', 'Member', 'Viewer']
export const STATUS_OPTIONS: Array<{ value: Status | ''; label: string }> = [
{ value: '', label: '全部' },
{ value: 'ACTIVE', label: '活跃' },
{ value: 'INACTIVE', label: '未激活' },
]
export const EMPTY_USER_FORM: UserForm = {
fullName: '',
email: '',
role: 'Member',
status: 'ACTIVE',
}
export function roleLabel(role: string): string {
return ({ Admin: '管理员', Manager: '经理', Member: '成员', Viewer: '只读成员' })[role] ?? role
}
export function statusLabel(status: Status): string {
return status === 'ACTIVE' ? '活跃' : '未激活'
}

View File

@@ -0,0 +1,3 @@
# 自定义 Hook 目录
跨组件复用的 React Hook 放在此目录,页面专属逻辑优先保留在对应路由模块中。

View File

@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiRequestError, request } from './client'
afterEach(() => vi.restoreAllMocks())
function response(body: unknown, status = 200, requestId = 'request-test') {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', 'X-Request-Id': requestId },
})
}
describe('HTTP 请求封装', () => {
it('解析统一成功响应并传递请求 ID', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(response({ code: 200, message: 'success', data: { value: 1 } }))
await expect(request<{ value: number }>('/health')).resolves.toEqual({ value: 1 })
expect(fetchMock).toHaveBeenCalledOnce()
expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get('X-Request-Id')).toBeTruthy()
})
it('GET 网络失败时只重试一次,写操作不重试', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network'))
await expect(request('/health')).rejects.toThrow('network')
expect(fetchMock).toHaveBeenCalledTimes(2)
fetchMock.mockClear()
await expect(request('/users', { method: 'POST' })).rejects.toThrow('network')
expect(fetchMock).toHaveBeenCalledOnce()
})
it('将统一错误转换为 ApiRequestError', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(response({ code: 404001, message: '用户不存在', data: null }, 404))
await expect(request('/users/1')).rejects.toBeInstanceOf(ApiRequestError)
})
})

View File

@@ -0,0 +1,59 @@
import type { ApiResponse } from '../interfaces/user/api'
const apiBaseUrl = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? '/api/v1'
export class ApiRequestError extends Error {
readonly code: number
readonly requestId: string | null
constructor(message: string, code: number, requestId: string | null) {
super(message)
this.name = 'ApiRequestError'
this.code = code
this.requestId = requestId
}
}
function requestId(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID()
}
return `web-${Date.now()}`
}
async function parseResponse<T>(response: Response, currentRequestId: string): Promise<T> {
const payload = await response.json().catch(() => null) as ApiResponse<T> | null
const responseRequestId = response.headers.get('X-Request-Id') ?? currentRequestId
if (!response.ok || !payload || payload.code !== 200) {
throw new ApiRequestError(payload?.message ?? '请求失败,请稍后重试', payload?.code ?? response.status, responseRequestId)
}
return payload.data as T
}
export async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const method = (init.method ?? 'GET').toUpperCase()
const retryable = method === 'GET'
let lastError: unknown
for (let attempt = 0; attempt <= (retryable ? 1 : 0); attempt += 1) {
const currentRequestId = requestId()
try {
const response = await fetch(`${apiBaseUrl}${path}`, {
...init,
headers: {
Accept: 'application/json',
'X-Request-Id': currentRequestId,
...init.headers,
},
})
return await parseResponse<T>(response, currentRequestId)
} catch (error) {
lastError = error
if (error instanceof ApiRequestError || attempt >= (retryable ? 1 : 0)) {
throw error
}
}
}
throw lastError instanceof Error ? lastError : new Error('请求失败,请稍后重试')
}

View File

@@ -0,0 +1,47 @@
import type { BulkStatusRequest, UserQuery, UserRequest } from '../interfaces/user/api'
import type { User, UserPage, UserSummary } from '../interfaces/user/model'
import { request } from './client'
function queryString(query: UserQuery): string {
const params = new URLSearchParams({
keyword: query.keyword,
role: query.role,
status: query.status,
page: String(query.page),
pageSize: String(query.pageSize),
})
return params.toString()
}
export const userApi = {
list(query: UserQuery): Promise<UserPage> {
return request<UserPage>(`/users?${queryString(query)}`)
},
summary(): Promise<UserSummary> {
return request<UserSummary>('/users/summary')
},
create(payload: UserRequest): Promise<User> {
return request<User>('/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
},
update(id: number, payload: UserRequest): Promise<User> {
return request<User>(`/users/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
},
remove(id: number): Promise<null> {
return request<null>(`/users/${id}`, { method: 'DELETE' })
},
updateStatuses(payload: BulkStatusRequest): Promise<{ updated: number; status: string }> {
return request<{ updated: number; status: string }>('/users/status', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
},
}

View File

@@ -0,0 +1,24 @@
import type { Status, UserForm, UserPage, UserSummary } from './model'
export interface ApiResponse<T> {
code: number
message: string
data: T | null
}
export interface UserQuery {
keyword: string
role: string
status: Status | ''
page: number
pageSize: number
}
export interface BulkStatusRequest {
ids: number[]
status: Status
}
export type UserRequest = UserForm
export type UserListResponse = UserPage
export type UserSummaryResponse = UserSummary

View File

@@ -0,0 +1,29 @@
export type Status = 'ACTIVE' | 'INACTIVE'
export type Role = 'Admin' | 'Manager' | 'Member' | 'Viewer'
export interface User {
id: number
fullName: string
email: string
role: Role
status: Status
createdAt: string
updatedAt: string
}
export type UserForm = Pick<User, 'fullName' | 'email' | 'role' | 'status'>
export interface UserSummary {
total: number
active: number
inactive: number
admins: number
}
export interface UserPage {
items: User[]
total: number
page: number
pageSize: number
}

View File

@@ -0,0 +1,10 @@
import { createBrowserRouter } from 'react-router-dom'
import UserManagementLoader from './user-management/Loader'
export const router = createBrowserRouter([
{
path: '/',
element: <UserManagementLoader />,
},
])

View File

@@ -0,0 +1,11 @@
import { lazy, Suspense } from 'react'
const UserManagementPage = lazy(() => import('./Page'))
export default function UserManagementLoader() {
return (
<Suspense fallback={<main className="app-shell"><div className="empty-state"></div></main>}>
<UserManagementPage />
</Suspense>
)
}

View File

@@ -0,0 +1,113 @@
import type { FormEvent } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Check, Info, Plus, X } from 'lucide-react'
import { statusLabel } from '../../constants/user'
import type { User, UserForm, Status } from '../../interfaces/user/model'
import { useUserStore } from '../../stores/userStore'
import styles from './index.module.scss'
import DirectoryToolbar from './components/DirectoryToolbar'
import SummaryCards from './components/SummaryCards'
import UserDrawer from './components/UserDrawer'
import UserTable from './components/UserTable'
export default function UserManagementPage() {
const [keyword, setKeyword] = useState('')
const [editingUser, setEditingUser] = useState<User | null>(null)
const [isFormOpen, setIsFormOpen] = useState(false)
const [notice, setNotice] = useState('')
const {
users, total, summary, search, roleFilter, statusFilter, selectedIds,
isLoading, isSaving, isBulkUpdating, error,
setSearch, setRoleFilter, setStatusFilter, clearFilters,
loadUsers, loadSummary, createUser, updateUser, deleteUser, updateSelectedStatus,
toggleSelected, toggleAll, clearError,
} = useUserStore()
const refreshData = useCallback(async () => {
await Promise.all([loadUsers(), loadSummary()])
}, [loadSummary, loadUsers])
useEffect(() => { void refreshData() }, [refreshData, search, roleFilter, statusFilter])
useEffect(() => {
if (!notice) return undefined
const timer = window.setTimeout(() => setNotice(''), 3200)
return () => window.clearTimeout(timer)
}, [notice])
function openCreate() {
setEditingUser(null)
clearError()
setIsFormOpen(true)
}
function openEdit(user: User) {
setEditingUser(user)
clearError()
setIsFormOpen(true)
}
async function submitForm(form: UserForm) {
const success = editingUser ? await updateUser(editingUser.id, form) : await createUser(form)
if (!success) return
setIsFormOpen(false)
setNotice(editingUser ? '用户信息已更新' : '用户已创建')
await refreshData()
}
async function removeUser(user: User) {
if (!window.confirm(`确定删除用户“${user.fullName}”吗?此操作不可撤销。`)) return
const success = await deleteUser(user.id)
if (success) {
setNotice('用户已删除')
await refreshData()
}
}
async function updateStatus(status: Status) {
if (selectedIds.size === 0) return
if (!window.confirm(`确定将选中的 ${selectedIds.size} 位用户设为“${statusLabel(status)}”吗?`)) return
const success = await updateSelectedStatus(status)
if (success) {
setNotice(`已将选中的用户设为${statusLabel(status)}`)
await refreshData()
}
}
function submitSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setSearch(keyword.trim())
}
function clearAllFilters() {
setKeyword('')
clearFilters()
}
const allSelected = users.length > 0 && selectedIds.size === users.length
const activeFilters = Boolean(search || roleFilter || statusFilter)
const combinedError = error
return (
<main className={`app-shell ${styles.page}`}>
<header className="topbar"><div className="topbar-inner"><div className="brand-lockup"><div className="brand-mark">U</div><div><p className="brand-name"></p><p className="brand-context"></p></div></div><div className="topbar-meta"><span className="status-dot" /><span className="topbar-divider" /></div></div></header>
<section className="content-wrap" aria-labelledby="page-title">
<div className="breadcrumb"><span></span><span>/</span><strong></strong></div>
<div className="page-heading"><div><p className="eyebrow"> / DIRECTORY</p><h1 id="page-title"></h1><p className="page-summary">访</p></div><button className="primary-button" type="button" onClick={openCreate}><Plus className="button-icon" size={17} /></button></div>
<SummaryCards summary={summary} />
<div className="section-heading"><div><h2></h2><p></p></div><span className="result-count"> {total} </span></div>
<section className="directory-section">
<DirectoryToolbar keyword={keyword} roleFilter={roleFilter} statusFilter={statusFilter} hasFilters={activeFilters} onKeywordChange={setKeyword} onSearch={submitSearch} onRoleChange={setRoleFilter} onStatusChange={setStatusFilter} onClear={clearAllFilters} />
{selectedIds.size > 0 && <div className="bulk-toolbar"><span> <strong>{selectedIds.size}</strong> </span><div><button type="button" className="bulk-button" disabled={isBulkUpdating} onClick={() => void updateStatus('ACTIVE')}></button><button type="button" className="bulk-button" disabled={isBulkUpdating} onClick={() => void updateStatus('INACTIVE')}></button></div></div>}
{combinedError && <div className="error-banner" role="alert"><span>{combinedError}</span><button type="button" onClick={clearError} aria-label="关闭错误提示"><X size={17} /></button></div>}
<div className="table-frame" aria-live="polite">
{isLoading ? <div className="empty-state"><span className="loading-ring" /></div> : users.length === 0 ? <div className="empty-state"><Info className="empty-icon" size={27} /><strong></strong><span></span>{activeFilters && <button className="secondary-button" type="button" onClick={clearAllFilters}></button>}</div> : <UserTable users={users} selectedIds={selectedIds} allSelected={allSelected} onToggleAll={toggleAll} onToggleSelected={toggleSelected} onEdit={openEdit} onDelete={(user) => void removeUser(user)} />}
</div>
</section>
</section>
{notice && <div className="toast" role="status"><span className="toast-icon"><Check size={12} /></span>{notice}</div>}
<UserDrawer user={editingUser} isOpen={isFormOpen} isSaving={isSaving} error={error} onClose={() => setIsFormOpen(false)} onSubmit={(form) => void submitForm(form)} />
</main>
)
}

View File

@@ -0,0 +1,3 @@
.root {
min-width: 0;
}

View File

@@ -0,0 +1,48 @@
import type { FormEvent } from 'react'
import { Search } from 'lucide-react'
import { ROLE_OPTIONS, roleLabel, STATUS_OPTIONS } from '../../../../constants/user'
import type { Role, Status } from '../../../../interfaces/user/model'
import styles from './index.module.scss'
interface DirectoryToolbarProps {
keyword: string
roleFilter: Role | ''
statusFilter: Status | ''
hasFilters: boolean
onKeywordChange: (value: string) => void
onSearch: (event: FormEvent<HTMLFormElement>) => void
onRoleChange: (value: Role | '') => void
onStatusChange: (value: Status | '') => void
onClear: () => void
}
export default function DirectoryToolbar(props: DirectoryToolbarProps) {
return (
<>
<div className={`filter-toolbar ${styles.root}`}>
<form className="search-form" onSubmit={props.onSearch}>
<label className="sr-only" htmlFor="user-search"></label>
<Search className="search-icon" aria-hidden="true" size={18} />
<input id="user-search" value={props.keyword} onChange={(event) => props.onKeywordChange(event.target.value)} placeholder="搜索姓名或邮箱" />
<button type="submit" className="secondary-button"></button>
</form>
<div className="filter-group">
<label className="sr-only" htmlFor="role-filter"></label>
<select id="role-filter" value={props.roleFilter} onChange={(event) => props.onRoleChange(event.target.value as Role | '')}>
<option value=""></option>
{ROLE_OPTIONS.map((role) => <option value={role} key={role}>{roleLabel(role)}</option>)}
</select>
{props.hasFilters && <button className="clear-button" type="button" onClick={props.onClear}></button>}
</div>
</div>
<div className="status-tabs" role="group" aria-label="按状态筛选">
{STATUS_OPTIONS.map((option) => (
<button className={props.statusFilter === option.value ? 'active' : ''} type="button" key={option.value} onClick={() => props.onStatusChange(option.value)}>
{option.label}
</button>
))}
</div>
</>
)
}

View File

@@ -0,0 +1,3 @@
.root {
min-width: 0;
}

View File

@@ -0,0 +1,41 @@
import { ShieldCheck, UserCheck, Users, UserX } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { UserSummary } from '../../../../interfaces/user/model'
import styles from './index.module.scss'
interface SummaryCardsProps {
summary: UserSummary
}
interface SummaryCard {
label: string
value: number
detail: string
icon: LucideIcon
tone: string
}
export default function SummaryCards({ summary }: SummaryCardsProps) {
const cards: SummaryCard[] = [
{ label: '全部用户', value: summary.total, detail: '当前组织账号', icon: Users, tone: 'teal' },
{ label: '活跃用户', value: summary.active, detail: '可以正常访问', icon: UserCheck, tone: 'green' },
{ label: '未激活', value: summary.inactive, detail: '等待重新启用', icon: UserX, tone: 'amber' },
{ label: '管理员', value: summary.admins, detail: '拥有管理权限', icon: ShieldCheck, tone: 'navy' },
]
return (
<section className={`summary-grid ${styles.root}`} aria-label="用户概览">
{cards.map((card) => {
const Icon = card.icon
return (
<article className={`summary-card ${card.tone}`} key={card.label}>
<div className="summary-card-top"><span>{card.label}</span><b aria-hidden="true"><Icon size={15} /></b></div>
<strong>{card.value}</strong>
<small>{card.detail}</small>
</article>
)
})}
</section>
)
}

View File

@@ -0,0 +1,3 @@
.root {
min-width: 0;
}

View File

@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react'
import { Info, X } from 'lucide-react'
import { EMPTY_USER_FORM, ROLE_OPTIONS, roleLabel } from '../../../../constants/user'
import type { User, UserForm, Status } from '../../../../interfaces/user/model'
import styles from './index.module.scss'
interface UserDrawerProps {
user: User | null
isOpen: boolean
isSaving: boolean
error: string
onClose: () => void
onSubmit: (form: UserForm) => void
}
export default function UserDrawer(props: UserDrawerProps) {
const [form, setForm] = useState<UserForm>(EMPTY_USER_FORM)
useEffect(() => {
setForm(props.user ? {
fullName: props.user.fullName,
email: props.user.email,
role: props.user.role,
status: props.user.status,
} : EMPTY_USER_FORM)
}, [props.user, props.isOpen])
if (!props.isOpen) return null
return (
<div className={`drawer-backdrop ${styles.root}`} role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) props.onClose() }}>
<aside className="drawer" role="dialog" aria-modal="true" aria-labelledby="form-title">
<div className="drawer-heading"><div><p className="eyebrow">USER RECORD / </p><h2 id="form-title">{props.user ? '编辑用户' : '新增用户'}</h2><p>访</p></div><button className="close-button" type="button" aria-label="关闭" onClick={props.onClose}><X size={20} /></button></div>
<form onSubmit={(event) => { event.preventDefault(); props.onSubmit(form) }}>
{props.error && <div className="drawer-error" role="alert">{props.error}</div>}
<label><input required maxLength={80} value={form.fullName} onChange={(event) => setForm({ ...form, fullName: event.target.value })} placeholder="例如:林晓晨" /></label>
<label><input required type="email" maxLength={160} value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} placeholder="name@company.com" /></label>
<div className="form-grid"><label><select value={form.role} onChange={(event) => setForm({ ...form, role: event.target.value as UserForm['role'] })}>{ROLE_OPTIONS.map((role) => <option value={role} key={role}>{roleLabel(role)}</option>)}</select></label><label><select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value as Status })}><option value="ACTIVE"></option><option value="INACTIVE"></option></select></label></div>
<div className="form-note"><Info size={17} /><p>访</p></div>
<div className="drawer-actions"><button type="button" className="secondary-button" onClick={props.onClose}></button><button disabled={props.isSaving} type="submit" className="primary-button">{props.isSaving ? '保存中...' : props.user ? '保存修改' : '创建用户'}</button></div>
</form>
</aside>
</div>
)
}

View File

@@ -0,0 +1,3 @@
.root {
min-width: 0;
}

View File

@@ -0,0 +1,40 @@
import classNames from 'classnames'
import { Pencil, Trash2 } from 'lucide-react'
import { roleLabel, statusLabel } from '../../../../constants/user'
import type { User } from '../../../../interfaces/user/model'
import { formatDate, initials } from '../../../../utils/user'
import styles from './index.module.scss'
interface UserTableProps {
users: User[]
selectedIds: Set<number>
allSelected: boolean
onToggleAll: () => void
onToggleSelected: (id: number) => void
onEdit: (user: User) => void
onDelete: (user: User) => void
}
export default function UserTable(props: UserTableProps) {
return (
<div className={`table-scroll ${styles.root}`}>
<table>
<thead><tr><th className="check-column"><input type="checkbox" aria-label="选择全部用户" checked={props.allSelected} onChange={props.onToggleAll} /></th><th></th><th></th><th></th><th></th><th></th><th><span className="sr-only"></span></th></tr></thead>
<tbody>
{props.users.map((user) => (
<tr key={user.id} className={classNames({ 'selected-row': props.selectedIds.has(user.id) })}>
<td className="check-column" data-label="选择"><input type="checkbox" aria-label={`选择${user.fullName}`} checked={props.selectedIds.has(user.id)} onChange={() => props.onToggleSelected(user.id)} /></td>
<td data-label="用户"><div className="user-cell"><span className="avatar">{initials(user.fullName)}</span><span><strong>{user.fullName}</strong><small>{user.email}</small></span></div></td>
<td data-label="角色"><span className="role-label">{roleLabel(user.role)}</span></td>
<td data-label="状态"><span className={`status-label ${user.status.toLowerCase()}`}><span className="status-mini-dot" />{statusLabel(user.status)}</span></td>
<td data-label="加入时间" className="muted-cell">{formatDate(user.createdAt)}</td>
<td data-label="最近更新" className="muted-cell">{formatDate(user.updatedAt)}</td>
<td data-label="操作"><div className="row-actions"><button className="icon-button" type="button" title="编辑用户" aria-label={`编辑${user.fullName}`} onClick={() => props.onEdit(user)}><Pencil size={16} /></button><button className="icon-button danger-action" type="button" title="删除用户" aria-label={`删除${user.fullName}`} onClick={() => props.onDelete(user)}><Trash2 size={16} /></button></div></td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,3 @@
.page {
min-height: 100vh;
}

View File

@@ -0,0 +1,142 @@
import { create } from 'zustand'
import { userApi } from '../http/userApi'
import type { UserQuery, UserRequest } from '../interfaces/user/api'
import type { Role, Status, User, UserSummary } from '../interfaces/user/model'
import { errorMessage } from '../utils/user'
interface UserStore {
users: User[]
total: number
summary: UserSummary
search: string
roleFilter: Role | ''
statusFilter: Status | ''
selectedIds: Set<number>
isLoading: boolean
isSaving: boolean
isBulkUpdating: boolean
error: string
setSearch: (search: string) => void
setRoleFilter: (role: Role | '') => void
setStatusFilter: (status: Status | '') => void
clearFilters: () => void
loadUsers: () => Promise<void>
loadSummary: () => Promise<void>
createUser: (payload: UserRequest) => Promise<boolean>
updateUser: (id: number, payload: UserRequest) => Promise<boolean>
deleteUser: (id: number) => Promise<boolean>
updateSelectedStatus: (status: Status) => Promise<boolean>
toggleSelected: (id: number) => void
toggleAll: () => void
clearError: () => void
}
const emptySummary: UserSummary = { total: 0, active: 0, inactive: 0, admins: 0 }
function queryFromState(state: UserStore): UserQuery {
return {
keyword: state.search,
role: state.roleFilter,
status: state.statusFilter,
page: 1,
pageSize: 50,
}
}
export const useUserStore = create<UserStore>((set, get) => ({
users: [],
total: 0,
summary: emptySummary,
search: '',
roleFilter: '',
statusFilter: '',
selectedIds: new Set<number>(),
isLoading: true,
isSaving: false,
isBulkUpdating: false,
error: '',
setSearch: (search) => set({ search }),
setRoleFilter: (roleFilter) => set({ roleFilter }),
setStatusFilter: (statusFilter) => set({ statusFilter }),
clearFilters: () => set({ search: '', roleFilter: '', statusFilter: '' }),
loadUsers: async () => {
set({ isLoading: true, error: '' })
try {
const data = await userApi.list(queryFromState(get()))
set({ users: data.items, total: data.total, selectedIds: new Set<number>() })
} catch (error) {
set({ error: errorMessage(error, '无法加载用户列表') })
} finally {
set({ isLoading: false })
}
},
loadSummary: async () => {
try {
const summary = await userApi.summary()
set({ summary })
} catch (error) {
set({ error: errorMessage(error, '无法加载用户概览') })
}
},
createUser: async (payload) => {
set({ isSaving: true, error: '' })
try {
await userApi.create(payload)
return true
} catch (error) {
set({ error: errorMessage(error, '保存失败,请检查输入') })
return false
} finally {
set({ isSaving: false })
}
},
updateUser: async (id, payload) => {
set({ isSaving: true, error: '' })
try {
await userApi.update(id, payload)
return true
} catch (error) {
set({ error: errorMessage(error, '保存失败,请检查输入') })
return false
} finally {
set({ isSaving: false })
}
},
deleteUser: async (id) => {
set({ error: '' })
try {
await userApi.remove(id)
return true
} catch (error) {
set({ error: errorMessage(error, '删除失败,请稍后重试') })
return false
}
},
updateSelectedStatus: async (status) => {
const ids = [...get().selectedIds]
if (ids.length === 0) return false
set({ isBulkUpdating: true, error: '' })
try {
await userApi.updateStatuses({ ids, status })
return true
} catch (error) {
set({ error: errorMessage(error, '批量更新失败,请稍后重试') })
return false
} finally {
set({ isBulkUpdating: false })
}
},
toggleSelected: (id) => set((state) => {
const selectedIds = new Set(state.selectedIds)
if (selectedIds.has(id)) selectedIds.delete(id)
else selectedIds.add(id)
return { selectedIds }
}),
toggleAll: () => set((state) => ({
selectedIds: state.users.length > 0 && state.selectedIds.size === state.users.length
? new Set<number>()
: new Set(state.users.map((user) => user.id)),
})),
clearError: () => set({ error: '' }),
}))

View File

@@ -1,3 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #172b36;

View File

@@ -0,0 +1 @@
export {}

View File

@@ -0,0 +1,5 @@
import classNames from 'classnames'
export function cn(...values: Parameters<typeof classNames>): string {
return classNames(...values)
}

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { formatDate, initials } from './user'
describe('用户展示工具', () => {
it('可以生成姓名缩写', () => {
expect(initials('Lin Chen')).toBe('LC')
expect(initials('晓晨')).toBe('晓晨')
})
it('可以格式化日期', () => {
expect(formatDate('2025-01-02T00:00:00Z')).toContain('2025')
})
})

View File

@@ -0,0 +1,21 @@
export function formatDate(value: string): string {
return new Date(value).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
export function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length > 1) {
return `${parts[0].charAt(0)}${parts[parts.length - 1].charAt(0)}`.toUpperCase()
}
return name.trim().slice(0, 2).toUpperCase()
}
export function errorMessage(error: unknown, fallback: string): string {
if (!(error instanceof Error)) return fallback
const requestError = error as Error & { requestId?: string | null }
return requestError.requestId ? `${error.message}(请求编号:${requestError.requestId}` : error.message
}

View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {},
},
plugins: [],
}

11
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true,
},
})

View File

@@ -1,13 +1,15 @@
CREATE TABLE IF NOT EXISTS users (
id BIGINT NOT NULL AUTO_INCREMENT,
full_name VARCHAR(80) NOT NULL,
email VARCHAR(160) NOT NULL,
role VARCHAR(40) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- 用户表最终结构。
-- 执行前请确认目标数据库、影响范围和备份策略;本文件不会由应用自动执行。
CREATE TABLE IF NOT EXISTS USERS (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '用户主键',
full_name VARCHAR(80) NOT NULL COMMENT '用户姓名',
email VARCHAR(160) NOT NULL COMMENT '用户邮箱,必须唯一',
role VARCHAR(40) NOT NULL COMMENT '用户角色Admin、Manager、Member、Viewer',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态ACTIVE 或 INACTIVE',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_users_email (email),
KEY idx_users_full_name (full_name),
KEY idx_users_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='培训项目用户表';