新增教练管理,优化预约人数计数方式
This commit is contained in:
+83
-70
@@ -33,10 +33,6 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
|||||||
@Query("UPDATE group_course SET status = '1', updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
|
@Query("UPDATE group_course SET status = '1', updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
|
||||||
Mono<Integer> cancelCourse(Long id, LocalDateTime updatedAt);
|
Mono<Integer> cancelCourse(Long id, LocalDateTime updatedAt);
|
||||||
|
|
||||||
@Modifying
|
|
||||||
@Query("UPDATE group_course SET current_members = current_members + :delta, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
|
|
||||||
Mono<Integer> updateCurrentMembers(Long id, Integer delta, LocalDateTime updatedAt);
|
|
||||||
|
|
||||||
@Modifying
|
@Modifying
|
||||||
@Query("UPDATE group_course SET deleted_at = :deletedAt WHERE id = :id")
|
@Query("UPDATE group_course SET deleted_at = :deletedAt WHERE id = :id")
|
||||||
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
|
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
|
||||||
@@ -47,46 +43,64 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
|||||||
|
|
||||||
Flux<GroupCourseEntity> findByCourseTypeAndDeletedAtIsNull(Long courseType);
|
Flux<GroupCourseEntity> findByCourseTypeAndDeletedAtIsNull(Long courseType);
|
||||||
|
|
||||||
|
// ==================== 教练相关查询 ====================
|
||||||
|
|
||||||
|
Flux<GroupCourseEntity> findByCoachIdAndDeletedAtIsNull(Long coachId);
|
||||||
|
|
||||||
|
Flux<GroupCourseEntity> findByCoachIdAndDeletedAtIsNull(Long coachId, Sort sort);
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM group_course WHERE coach_id = :coachId AND status = :status AND deleted_at IS NULL")
|
||||||
|
Mono<Long> countByCoachIdAndStatus(Long coachId, String status);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE group_course SET status = '1', updated_at = :updatedAt WHERE coach_id = :coachId AND status != :excludeStatus AND deleted_at IS NULL")
|
||||||
|
Mono<Integer> cancelCoursesByCoachIdExceptStatus(Long coachId, String excludeStatus, LocalDateTime updatedAt);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 多条件查询团课(使用 DatabaseClient 构建动态 SQL)
|
* 多条件查询团课(使用 DatabaseClient 构建动态 SQL)
|
||||||
*/
|
*/
|
||||||
default Flux<GroupCourseEntity> searchGroupCourses(DatabaseClient databaseClient, GroupCourseQueryDto query) {
|
default Flux<GroupCourseEntity> searchGroupCourses(DatabaseClient databaseClient, GroupCourseQueryDto query) {
|
||||||
StringBuilder sql = new StringBuilder("SELECT * FROM group_course WHERE deleted_at IS NULL");
|
StringBuilder sql = new StringBuilder(
|
||||||
|
"SELECT gc.*, COALESCE(b.cnt, 0) AS current_members " +
|
||||||
|
"FROM group_course gc " +
|
||||||
|
"LEFT JOIN (SELECT course_id, COUNT(*) AS cnt FROM group_course_booking WHERE status = '0' GROUP BY course_id) b " +
|
||||||
|
"ON gc.id = b.course_id " +
|
||||||
|
"WHERE gc.deleted_at IS NULL");
|
||||||
List<String> conditions = new ArrayList<>();
|
List<String> conditions = new ArrayList<>();
|
||||||
|
|
||||||
// 默认不查询可预约团课(status = '0' 且未过期)
|
// 默认不查询可预约团课(status = '0' 且未过期)
|
||||||
conditions.add("status = '0'");
|
conditions.add("gc.status = '0'");
|
||||||
conditions.add("end_time > NOW()");
|
conditions.add("gc.end_time > NOW()");
|
||||||
|
|
||||||
// 1. 团课名称模糊查询
|
// 1. 团课名称模糊查询
|
||||||
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
|
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
|
||||||
conditions.add("course_name ILIKE :courseName");
|
conditions.add("gc.course_name ILIKE :courseName");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 基于团课类型查询
|
// 2. 基于团课类型查询
|
||||||
if (query.getCourseType() != null) {
|
if (query.getCourseType() != null) {
|
||||||
conditions.add("course_type = :courseType");
|
conditions.add("gc.course_type = :courseType");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 基于日期时间段查询
|
// 3. 基于日期时间段查询
|
||||||
if (query.getStartDate() != null) {
|
if (query.getStartDate() != null) {
|
||||||
conditions.add("start_time >= :startDate");
|
conditions.add("gc.start_time >= :startDate");
|
||||||
}
|
}
|
||||||
if (query.getEndDate() != null) {
|
if (query.getEndDate() != null) {
|
||||||
conditions.add("start_time <= :endDate");
|
conditions.add("gc.start_time <= :endDate");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 基于早晨/下午/夜晚时间段查询
|
// 4. 基于早晨/下午/夜晚时间段查询
|
||||||
if (query.getTimePeriod() != null && !query.getTimePeriod().isEmpty()) {
|
if (query.getTimePeriod() != null && !query.getTimePeriod().isEmpty()) {
|
||||||
switch (query.getTimePeriod().toLowerCase()) {
|
switch (query.getTimePeriod().toLowerCase()) {
|
||||||
case "morning":
|
case "morning":
|
||||||
conditions.add("EXTRACT(HOUR FROM start_time) >= 6 AND EXTRACT(HOUR FROM start_time) < 12");
|
conditions.add("EXTRACT(HOUR FROM gc.start_time) >= 6 AND EXTRACT(HOUR FROM gc.start_time) < 12");
|
||||||
break;
|
break;
|
||||||
case "afternoon":
|
case "afternoon":
|
||||||
conditions.add("EXTRACT(HOUR FROM start_time) >= 12 AND EXTRACT(HOUR FROM start_time) < 18");
|
conditions.add("EXTRACT(HOUR FROM gc.start_time) >= 12 AND EXTRACT(HOUR FROM gc.start_time) < 18");
|
||||||
break;
|
break;
|
||||||
case "evening":
|
case "evening":
|
||||||
conditions.add("EXTRACT(HOUR FROM start_time) >= 18 AND EXTRACT(HOUR FROM start_time) < 24");
|
conditions.add("EXTRACT(HOUR FROM gc.start_time) >= 18 AND EXTRACT(HOUR FROM gc.start_time) < 24");
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
@@ -104,20 +118,20 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
|||||||
List<String> orderClauses = new ArrayList<>();
|
List<String> orderClauses = new ArrayList<>();
|
||||||
|
|
||||||
if (hasRemainingMost) {
|
if (hasRemainingMost) {
|
||||||
orderClauses.add(" (max_members - current_members) DESC");
|
orderClauses.add(" (gc.max_members - COALESCE(b.cnt, 0)) DESC");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasPriceSort) {
|
if (hasPriceSort) {
|
||||||
if ("asc".equalsIgnoreCase(query.getPriceSort())) {
|
if ("asc".equalsIgnoreCase(query.getPriceSort())) {
|
||||||
orderClauses.add(" stored_value_amount ASC");
|
orderClauses.add(" gc.stored_value_amount ASC");
|
||||||
} else if ("desc".equalsIgnoreCase(query.getPriceSort())) {
|
} else if ("desc".equalsIgnoreCase(query.getPriceSort())) {
|
||||||
orderClauses.add(" stored_value_amount DESC");
|
orderClauses.add(" gc.stored_value_amount DESC");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sql.append(String.join(",", orderClauses));
|
sql.append(String.join(",", orderClauses));
|
||||||
} else {
|
} else {
|
||||||
sql.append(" ORDER BY start_time ASC");
|
sql.append(" ORDER BY gc.start_time ASC");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分页
|
// 分页
|
||||||
@@ -145,30 +159,7 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
|||||||
spec = spec.bind("limit", size);
|
spec = spec.bind("limit", size);
|
||||||
spec = spec.bind("offset", offset);
|
spec = spec.bind("offset", offset);
|
||||||
|
|
||||||
return spec.map((row, meta) -> {
|
return spec.map((row, meta) -> mapRowToEntity(row)).all();
|
||||||
GroupCourseEntity entity = new GroupCourseEntity();
|
|
||||||
entity.setId(row.get("id", Long.class));
|
|
||||||
entity.setCourseName(row.get("course_name", String.class));
|
|
||||||
entity.setCoachId(row.get("coach_id", Long.class));
|
|
||||||
entity.setCourseType(row.get("course_type", Long.class));
|
|
||||||
entity.setStartTime(row.get("start_time", LocalDateTime.class));
|
|
||||||
entity.setEndTime(row.get("end_time", LocalDateTime.class));
|
|
||||||
entity.setMaxMembers(row.get("max_members", Integer.class));
|
|
||||||
entity.setCurrentMembers(row.get("current_members", Integer.class));
|
|
||||||
String statusStr = row.get("status", String.class);
|
|
||||||
entity.setStatus(statusStr != null ? Long.parseLong(statusStr) : null);
|
|
||||||
entity.setLocation(row.get("location", String.class));
|
|
||||||
entity.setCoverImage(row.get("cover_image", String.class));
|
|
||||||
entity.setDescription(row.get("description", String.class));
|
|
||||||
entity.setStoredValueAmount(row.get("stored_value_amount", java.math.BigDecimal.class));
|
|
||||||
entity.setQrCodePath(row.get("qr_code_path", String.class));
|
|
||||||
entity.setCreateBy(row.get("create_by", String.class));
|
|
||||||
entity.setUpdateBy(row.get("update_by", String.class));
|
|
||||||
entity.setCreatedAt(row.get("created_at", LocalDateTime.class));
|
|
||||||
entity.setUpdatedAt(row.get("updated_at", LocalDateTime.class));
|
|
||||||
entity.setDeletedAt(row.get("deleted_at", LocalDateTime.class));
|
|
||||||
return entity;
|
|
||||||
}).all();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -233,14 +224,19 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
|||||||
* 分页查询团课(支持 keyword 和 status 过滤)
|
* 分页查询团课(支持 keyword 和 status 过滤)
|
||||||
*/
|
*/
|
||||||
default Flux<GroupCourseEntity> findByPageFiltered(DatabaseClient databaseClient, PageRequest pageRequest) {
|
default Flux<GroupCourseEntity> findByPageFiltered(DatabaseClient databaseClient, PageRequest pageRequest) {
|
||||||
StringBuilder sql = new StringBuilder("SELECT * FROM group_course WHERE deleted_at IS NULL");
|
StringBuilder sql = new StringBuilder(
|
||||||
|
"SELECT gc.*, COALESCE(b.cnt, 0) AS current_members " +
|
||||||
|
"FROM group_course gc " +
|
||||||
|
"LEFT JOIN (SELECT course_id, COUNT(*) AS cnt FROM group_course_booking WHERE status = '0' GROUP BY course_id) b " +
|
||||||
|
"ON gc.id = b.course_id " +
|
||||||
|
"WHERE gc.deleted_at IS NULL");
|
||||||
List<String> conditions = new ArrayList<>();
|
List<String> conditions = new ArrayList<>();
|
||||||
|
|
||||||
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
|
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
|
||||||
conditions.add("course_name ILIKE :keyword");
|
conditions.add("gc.course_name ILIKE :keyword");
|
||||||
}
|
}
|
||||||
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
|
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
|
||||||
conditions.add("status = :status");
|
conditions.add("gc.status = :status");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!conditions.isEmpty()) {
|
if (!conditions.isEmpty()) {
|
||||||
@@ -249,7 +245,7 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
|||||||
|
|
||||||
String sort = pageRequest.getSort() != null ? pageRequest.getSort() : "id";
|
String sort = pageRequest.getSort() != null ? pageRequest.getSort() : "id";
|
||||||
String order = "desc".equalsIgnoreCase(pageRequest.getOrder()) ? "DESC" : "ASC";
|
String order = "desc".equalsIgnoreCase(pageRequest.getOrder()) ? "DESC" : "ASC";
|
||||||
sql.append(" ORDER BY ").append(sort).append(" ").append(order);
|
sql.append(" ORDER BY gc.").append(sort).append(" ").append(order);
|
||||||
|
|
||||||
int size = pageRequest.getSize();
|
int size = pageRequest.getSize();
|
||||||
if (size < 1) size = 10;
|
if (size < 1) size = 10;
|
||||||
@@ -268,30 +264,7 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
|||||||
spec = spec.bind("limit", size);
|
spec = spec.bind("limit", size);
|
||||||
spec = spec.bind("offset", offset);
|
spec = spec.bind("offset", offset);
|
||||||
|
|
||||||
return spec.map((row, meta) -> {
|
return spec.map((row, meta) -> mapRowToEntity(row)).all();
|
||||||
GroupCourseEntity entity = new GroupCourseEntity();
|
|
||||||
entity.setId(row.get("id", Long.class));
|
|
||||||
entity.setCourseName(row.get("course_name", String.class));
|
|
||||||
entity.setCoachId(row.get("coach_id", Long.class));
|
|
||||||
entity.setCourseType(row.get("course_type", Long.class));
|
|
||||||
entity.setStartTime(row.get("start_time", LocalDateTime.class));
|
|
||||||
entity.setEndTime(row.get("end_time", LocalDateTime.class));
|
|
||||||
entity.setMaxMembers(row.get("max_members", Integer.class));
|
|
||||||
entity.setCurrentMembers(row.get("current_members", Integer.class));
|
|
||||||
String statusStr = row.get("status", String.class);
|
|
||||||
entity.setStatus(statusStr != null ? Long.parseLong(statusStr) : null);
|
|
||||||
entity.setLocation(row.get("location", String.class));
|
|
||||||
entity.setCoverImage(row.get("cover_image", String.class));
|
|
||||||
entity.setDescription(row.get("description", String.class));
|
|
||||||
entity.setStoredValueAmount(row.get("stored_value_amount", java.math.BigDecimal.class));
|
|
||||||
entity.setQrCodePath(row.get("qr_code_path", String.class));
|
|
||||||
entity.setCreateBy(row.get("create_by", String.class));
|
|
||||||
entity.setUpdateBy(row.get("update_by", String.class));
|
|
||||||
entity.setCreatedAt(row.get("created_at", LocalDateTime.class));
|
|
||||||
entity.setUpdatedAt(row.get("updated_at", LocalDateTime.class));
|
|
||||||
entity.setDeletedAt(row.get("deleted_at", LocalDateTime.class));
|
|
||||||
return entity;
|
|
||||||
}).all();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -323,4 +296,44 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
|||||||
|
|
||||||
return spec.map((row, meta) -> row.get(0, Long.class)).one();
|
return spec.map((row, meta) -> row.get(0, Long.class)).one();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计单门课程的有效预约人数(status='0')
|
||||||
|
*/
|
||||||
|
default Mono<Long> countValidBookings(DatabaseClient databaseClient, Long courseId) {
|
||||||
|
return databaseClient.sql("SELECT COUNT(*) FROM group_course_booking WHERE course_id = :id AND status = '0'")
|
||||||
|
.bind("id", courseId)
|
||||||
|
.map((row, meta) -> row.get(0, Long.class))
|
||||||
|
.one()
|
||||||
|
.defaultIfEmpty(0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将数据库行映射为 GroupCourseEntity
|
||||||
|
*/
|
||||||
|
private GroupCourseEntity mapRowToEntity(io.r2dbc.spi.Row row) {
|
||||||
|
GroupCourseEntity entity = new GroupCourseEntity();
|
||||||
|
entity.setId(row.get("id", Long.class));
|
||||||
|
entity.setCourseName(row.get("course_name", String.class));
|
||||||
|
entity.setCoachId(row.get("coach_id", Long.class));
|
||||||
|
entity.setCourseType(row.get("course_type", Long.class));
|
||||||
|
entity.setStartTime(row.get("start_time", LocalDateTime.class));
|
||||||
|
entity.setEndTime(row.get("end_time", LocalDateTime.class));
|
||||||
|
entity.setMaxMembers(row.get("max_members", Integer.class));
|
||||||
|
Integer cm = row.get("current_members", Integer.class);
|
||||||
|
entity.setCurrentMembers(cm != null ? cm : 0);
|
||||||
|
String statusStr = row.get("status", String.class);
|
||||||
|
entity.setStatus(statusStr != null ? Long.parseLong(statusStr) : null);
|
||||||
|
entity.setLocation(row.get("location", String.class));
|
||||||
|
entity.setCoverImage(row.get("cover_image", String.class));
|
||||||
|
entity.setDescription(row.get("description", String.class));
|
||||||
|
entity.setStoredValueAmount(row.get("stored_value_amount", java.math.BigDecimal.class));
|
||||||
|
entity.setQrCodePath(row.get("qr_code_path", String.class));
|
||||||
|
entity.setCreateBy(row.get("create_by", String.class));
|
||||||
|
entity.setUpdateBy(row.get("update_by", String.class));
|
||||||
|
entity.setCreatedAt(row.get("created_at", LocalDateTime.class));
|
||||||
|
entity.setUpdatedAt(row.get("updated_at", LocalDateTime.class));
|
||||||
|
entity.setDeletedAt(row.get("deleted_at", LocalDateTime.class));
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+11
@@ -21,6 +21,9 @@ public class GroupCourseDetail extends BaseDomain {
|
|||||||
@Schema(description = "教练ID", example = "1")
|
@Schema(description = "教练ID", example = "1")
|
||||||
private Long coachId;
|
private Long coachId;
|
||||||
|
|
||||||
|
@Schema(description = "教练名称", example = "张教练")
|
||||||
|
private String coachName;
|
||||||
|
|
||||||
@Schema(description = "课程类型ID", example = "1")
|
@Schema(description = "课程类型ID", example = "1")
|
||||||
private Long courseType;
|
private Long courseType;
|
||||||
|
|
||||||
@@ -99,6 +102,14 @@ public class GroupCourseDetail extends BaseDomain {
|
|||||||
this.coachId = coachId;
|
this.coachId = coachId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getCoachName() {
|
||||||
|
return coachName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCoachName(String coachName) {
|
||||||
|
this.coachName = coachName;
|
||||||
|
}
|
||||||
|
|
||||||
public Long getCourseType() {
|
public Long getCourseType() {
|
||||||
return courseType;
|
return courseType;
|
||||||
}
|
}
|
||||||
|
|||||||
-56
@@ -2,8 +2,6 @@ package cn.novalon.gym.manage.groupcourse.handler;
|
|||||||
|
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
|
||||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository;
|
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository;
|
||||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
|
|
||||||
import cn.novalon.gym.manage.groupcourse.service.impl.GroupCourseRedisService;
|
|
||||||
import cn.novalon.gym.manage.member.entity.MemberCard;
|
import cn.novalon.gym.manage.member.entity.MemberCard;
|
||||||
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
|
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
|
||||||
import cn.novalon.gym.manage.member.enums.MemberCardType;
|
import cn.novalon.gym.manage.member.enums.MemberCardType;
|
||||||
@@ -34,11 +32,9 @@ import java.util.List;
|
|||||||
public class BookingSagaHandler {
|
public class BookingSagaHandler {
|
||||||
|
|
||||||
private final IGroupCourseBookingRepository bookingRepository;
|
private final IGroupCourseBookingRepository bookingRepository;
|
||||||
private final IGroupCourseRepository courseRepository;
|
|
||||||
private final IMemberCardRecordService memberCardRecordService;
|
private final IMemberCardRecordService memberCardRecordService;
|
||||||
private final IMemberStoredCardService memberStoredCardService;
|
private final IMemberStoredCardService memberStoredCardService;
|
||||||
private final MemberCardRepository memberCardRepository;
|
private final MemberCardRepository memberCardRepository;
|
||||||
private final GroupCourseRedisService redisService;
|
|
||||||
|
|
||||||
private static final double DEFAULT_GROUP_COURSE_PRICE = 50.0;
|
private static final double DEFAULT_GROUP_COURSE_PRICE = 50.0;
|
||||||
|
|
||||||
@@ -72,24 +68,6 @@ public class BookingSagaHandler {
|
|||||||
steps.add(step2);
|
steps.add(step2);
|
||||||
rollbackSteps.add(0, step2);
|
rollbackSteps.add(0, step2);
|
||||||
|
|
||||||
// 步骤3:更新课程当前人数
|
|
||||||
SagaStep step3 = new SagaStep(
|
|
||||||
"更新课程当前人数",
|
|
||||||
incrementCourseCurrentMembers(booking.getCourseId()),
|
|
||||||
Mono.defer(() -> decrementCourseCurrentMembers(booking.getCourseId()))
|
|
||||||
);
|
|
||||||
steps.add(step3);
|
|
||||||
rollbackSteps.add(0, step3);
|
|
||||||
|
|
||||||
// 步骤4:更新Redis预约计数
|
|
||||||
SagaStep step4 = new SagaStep(
|
|
||||||
"更新Redis预约计数",
|
|
||||||
incrementRedisBookingCount(booking.getCourseId()),
|
|
||||||
Mono.defer(() -> decrementRedisBookingCount(booking.getCourseId()))
|
|
||||||
);
|
|
||||||
steps.add(step4);
|
|
||||||
rollbackSteps.add(0, step4);
|
|
||||||
|
|
||||||
return executeSaga(steps, rollbackSteps)
|
return executeSaga(steps, rollbackSteps)
|
||||||
.then(Mono.just(booking));
|
.then(Mono.just(booking));
|
||||||
}
|
}
|
||||||
@@ -225,24 +203,6 @@ public class BookingSagaHandler {
|
|||||||
steps.add(step2);
|
steps.add(step2);
|
||||||
rollbackSteps.add(0, step2);
|
rollbackSteps.add(0, step2);
|
||||||
|
|
||||||
// 步骤3:减少课程当前人数
|
|
||||||
SagaStep step3 = new SagaStep(
|
|
||||||
"减少课程当前人数",
|
|
||||||
decrementCourseCurrentMembers(courseId),
|
|
||||||
Mono.defer(() -> incrementCourseCurrentMembers(courseId))
|
|
||||||
);
|
|
||||||
steps.add(step3);
|
|
||||||
rollbackSteps.add(0, step3);
|
|
||||||
|
|
||||||
// 步骤4:更新Redis预约计数
|
|
||||||
SagaStep step4 = new SagaStep(
|
|
||||||
"更新Redis预约计数",
|
|
||||||
decrementRedisBookingCount(courseId),
|
|
||||||
Mono.defer(() -> incrementRedisBookingCount(courseId))
|
|
||||||
);
|
|
||||||
steps.add(step4);
|
|
||||||
rollbackSteps.add(0, step4);
|
|
||||||
|
|
||||||
return executeSaga(steps, rollbackSteps)
|
return executeSaga(steps, rollbackSteps)
|
||||||
.then(bookingRepository.findById(bookingId));
|
.then(bookingRepository.findById(bookingId));
|
||||||
}
|
}
|
||||||
@@ -301,22 +261,6 @@ public class BookingSagaHandler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<Void> incrementCourseCurrentMembers(Long courseId) {
|
|
||||||
return courseRepository.updateCurrentMembers(courseId, 1).then();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Mono<Void> decrementCourseCurrentMembers(Long courseId) {
|
|
||||||
return courseRepository.updateCurrentMembers(courseId, -1).then();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Mono<Void> incrementRedisBookingCount(Long courseId) {
|
|
||||||
return redisService.incrementBookingCount(courseId).then();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Mono<Void> decrementRedisBookingCount(Long courseId) {
|
|
||||||
return redisService.decrementBookingCount(courseId).then();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Mono<Void> executeSaga(List<SagaStep> steps, List<SagaStep> rollbackSteps) {
|
private Mono<Void> executeSaga(List<SagaStep> steps, List<SagaStep> rollbackSteps) {
|
||||||
List<SagaStep> completedSteps = new ArrayList<>();
|
List<SagaStep> completedSteps = new ArrayList<>();
|
||||||
return executeStep(steps, 0, completedSteps);
|
return executeStep(steps, 0, completedSteps);
|
||||||
|
|||||||
+57
-4
@@ -7,21 +7,27 @@ import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
|||||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
|
||||||
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
|
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
|
||||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
|
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.vo.GroupCourseVO;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.validation.Validator;
|
import jakarta.validation.Validator;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@Tag(name="团课管理",description = "团课相关操作")
|
@Tag(name="团课管理",description = "团课相关操作")
|
||||||
public class GroupCourseHandler {
|
public class GroupCourseHandler {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(GroupCourseHandler.class);
|
||||||
private final IGroupCourseService groupCourseService;
|
private final IGroupCourseService groupCourseService;
|
||||||
private final Validator validator;
|
private final Validator validator;
|
||||||
private final RedisUtil redisUtil;
|
private final RedisUtil redisUtil;
|
||||||
@@ -37,11 +43,12 @@ public class GroupCourseHandler {
|
|||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "获取所有团课", description = "获取系统中所有团课列表")
|
@Operation(summary = "获取所有团课", description = "获取系统中所有团课列表(含教练昵称)")
|
||||||
public Mono<ServerResponse> getAllGroupCourse(ServerRequest request){
|
public Mono<ServerResponse> getAllGroupCourse(ServerRequest request){
|
||||||
boolean includeDeleted = Boolean.valueOf(request.queryParam("includeDeleted").orElse("false"));
|
boolean includeDeleted = Boolean.valueOf(request.queryParam("includeDeleted").orElse("false"));
|
||||||
return ServerResponse.ok()
|
return groupCourseService.findAllAsVO(includeDeleted)
|
||||||
.body(groupCourseService.findAll(includeDeleted), GroupCourse.class);
|
.collectList()
|
||||||
|
.flatMap(list -> ServerResponse.ok().bodyValue(list));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "分页获取团课", description = "根据分页参数获取团课列表")
|
@Operation(summary = "分页获取团课", description = "根据分页参数获取团课列表")
|
||||||
@@ -65,7 +72,7 @@ public class GroupCourseHandler {
|
|||||||
pageRequest.setOrder("asc");
|
pageRequest.setOrder("asc");
|
||||||
}
|
}
|
||||||
|
|
||||||
return groupCourseService.findByPage(pageRequest, includeDeleted)
|
return groupCourseService.findByPageAsVO(pageRequest, includeDeleted)
|
||||||
.flatMap(response -> ServerResponse.ok().bodyValue(response));
|
.flatMap(response -> ServerResponse.ok().bodyValue(response));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -289,4 +296,50 @@ public class GroupCourseHandler {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "检查教练时间冲突", description = "检查教练在指定时间段内是否有时间冲突的团课")
|
||||||
|
public Mono<ServerResponse> checkCoachConflict(ServerRequest request) {
|
||||||
|
return request.bodyToMono(Map.class)
|
||||||
|
.flatMap(body -> {
|
||||||
|
if (body == null) {
|
||||||
|
Map<String, Object> error = new HashMap<>();
|
||||||
|
error.put("success", false);
|
||||||
|
error.put("message", "请求体不能为空");
|
||||||
|
return ServerResponse.badRequest().bodyValue(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object coachIdObj = body.get("coachId");
|
||||||
|
Object startTimeStr = body.get("startTime");
|
||||||
|
Object endTimeStr = body.get("endTime");
|
||||||
|
Object excludeCourseIdObj = body.get("excludeCourseId");
|
||||||
|
|
||||||
|
if (coachIdObj == null || startTimeStr == null || endTimeStr == null) {
|
||||||
|
Map<String, Object> error = new HashMap<>();
|
||||||
|
error.put("success", false);
|
||||||
|
error.put("message", "coachId、startTime、endTime 不能为空");
|
||||||
|
return ServerResponse.badRequest().bodyValue(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
Long coachId = Long.valueOf(String.valueOf(coachIdObj));
|
||||||
|
LocalDateTime startTime = LocalDateTime.parse(String.valueOf(startTimeStr));
|
||||||
|
LocalDateTime endTime = LocalDateTime.parse(String.valueOf(endTimeStr));
|
||||||
|
Long excludeCourseId = excludeCourseIdObj != null ? Long.valueOf(String.valueOf(excludeCourseIdObj)) : null;
|
||||||
|
|
||||||
|
return groupCourseService.checkCoachConflict(coachId, startTime, endTime, excludeCourseId)
|
||||||
|
.flatMap(conflicts -> {
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("hasConflict", !conflicts.isEmpty());
|
||||||
|
response.put("conflicts", conflicts);
|
||||||
|
return ServerResponse.ok().bodyValue(response);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.onErrorResume(error -> {
|
||||||
|
logger.warn("检查教练时间冲突失败: {}", error.getMessage());
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", false);
|
||||||
|
response.put("message", "检查失败: " + error.getMessage());
|
||||||
|
return ServerResponse.badRequest().bodyValue(response);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-2
@@ -27,9 +27,14 @@ public interface IGroupCourseRepository {
|
|||||||
|
|
||||||
Mono<Void> deleteById(Long id);
|
Mono<Void> deleteById(Long id);
|
||||||
|
|
||||||
Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta);
|
|
||||||
|
|
||||||
Flux<GroupCourse> findByCourseType(Long courseType);
|
Flux<GroupCourse> findByCourseType(Long courseType);
|
||||||
|
|
||||||
Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query);
|
Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query);
|
||||||
|
|
||||||
|
// ==================== 教练相关 ====================
|
||||||
|
|
||||||
|
Flux<GroupCourse> findByCoachId(Long coachId);
|
||||||
|
Flux<GroupCourse> findByCoachId(Long coachId, Sort sort);
|
||||||
|
Mono<Long> countByCoachIdAndStatus(Long coachId, Long status);
|
||||||
|
Mono<Integer> cancelCoursesByCoachIdExceptStatus(Long coachId, Long excludeStatus);
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-12
@@ -119,7 +119,6 @@ public class GroupCourseRepository implements IGroupCourseRepository {
|
|||||||
entity.setCreatedAt(LocalDateTime.now());
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
entity.setUpdatedAt(LocalDateTime.now());
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
entity.setStatus(0L);
|
entity.setStatus(0L);
|
||||||
entity.setCurrentMembers(0);
|
|
||||||
|
|
||||||
return groupCourseDao.save(entity)
|
return groupCourseDao.save(entity)
|
||||||
.map(groupCourseConverter::toDomain);
|
.map(groupCourseConverter::toDomain);
|
||||||
@@ -151,17 +150,6 @@ public class GroupCourseRepository implements IGroupCourseRepository {
|
|||||||
.then();
|
.then();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta) {
|
|
||||||
return groupCourseDao.updateCurrentMembers(id, delta, LocalDateTime.now())
|
|
||||||
.flatMap(updated -> {
|
|
||||||
if (updated > 0) {
|
|
||||||
return findByIdAndDeletedAtIsNull(id);
|
|
||||||
}
|
|
||||||
return Mono.empty();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Flux<GroupCourse> findByCourseType(Long courseType) {
|
public Flux<GroupCourse> findByCourseType(Long courseType) {
|
||||||
return groupCourseDao.findByCourseTypeAndDeletedAtIsNull(courseType)
|
return groupCourseDao.findByCourseTypeAndDeletedAtIsNull(courseType)
|
||||||
@@ -190,4 +178,28 @@ public class GroupCourseRepository implements IGroupCourseRepository {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 教练相关 ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<GroupCourse> findByCoachId(Long coachId) {
|
||||||
|
return groupCourseDao.findByCoachIdAndDeletedAtIsNull(coachId)
|
||||||
|
.map(groupCourseConverter::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<GroupCourse> findByCoachId(Long coachId, Sort sort) {
|
||||||
|
return groupCourseDao.findByCoachIdAndDeletedAtIsNull(coachId, sort)
|
||||||
|
.map(groupCourseConverter::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Long> countByCoachIdAndStatus(Long coachId, Long status) {
|
||||||
|
return groupCourseDao.countByCoachIdAndStatus(coachId, String.valueOf(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Integer> cancelCoursesByCoachIdExceptStatus(Long coachId, Long excludeStatus) {
|
||||||
|
return groupCourseDao.cancelCoursesByCoachIdExceptStatus(coachId, String.valueOf(excludeStatus), LocalDateTime.now());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
@@ -6,14 +6,20 @@ import cn.novalon.gym.manage.common.dto.PageResponse;
|
|||||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
|
||||||
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
|
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.vo.GroupCourseVO;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public interface IGroupCourseService {
|
public interface IGroupCourseService {
|
||||||
Mono<GroupCourse> findById(Long id);
|
Mono<GroupCourse> findById(Long id);
|
||||||
Mono<GroupCourseDetail> findDetailById(Long id);
|
Mono<GroupCourseDetail> findDetailById(Long id);
|
||||||
Flux<GroupCourse> findAll();
|
Flux<GroupCourse> findAll();
|
||||||
Flux<GroupCourse> findAll(boolean includeDeleted);
|
Flux<GroupCourse> findAll(boolean includeDeleted);
|
||||||
|
Flux<GroupCourseVO> findAllAsVO(boolean includeDeleted);
|
||||||
|
Mono<PageResponse<GroupCourseVO>> findByPageAsVO(PageRequest pageRequest, boolean includeDeleted);
|
||||||
|
|
||||||
Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest, boolean includeDeleted);
|
Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest, boolean includeDeleted);
|
||||||
|
|
||||||
@@ -28,4 +34,14 @@ public interface IGroupCourseService {
|
|||||||
Mono<Void> delete(Long id);
|
Mono<Void> delete(Long id);
|
||||||
|
|
||||||
Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query);
|
Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查教练在指定时间段内是否有时间冲突的团课
|
||||||
|
* @param coachId 教练ID
|
||||||
|
* @param startTime 开始时间
|
||||||
|
* @param endTime 结束时间
|
||||||
|
* @param excludeCourseId 排除的课程ID(编辑时排除自身)
|
||||||
|
* @return 冲突的课程列表
|
||||||
|
*/
|
||||||
|
Mono<List<GroupCourse>> checkCoachConflict(Long coachId, LocalDateTime startTime, LocalDateTime endTime, Long excludeCourseId);
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-54
@@ -72,8 +72,16 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
|
|||||||
return Mono.error(new RuntimeException("系统繁忙,请稍后重试"));
|
return Mono.error(new RuntimeException("系统繁忙,请稍后重试"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 尝试从缓存获取课程信息,如果缓存不存在则从数据库获取
|
// 2. 从缓存或数据库获取课程信息,并用实时预约数覆盖currentMembers
|
||||||
return getCourseWithCache(courseId)
|
return getCourseWithCache(courseId)
|
||||||
|
.flatMap(course ->
|
||||||
|
bookingRepository.countValidBookings(courseId)
|
||||||
|
.map(count -> {
|
||||||
|
course.setCurrentMembers(count.intValue());
|
||||||
|
return course;
|
||||||
|
})
|
||||||
|
.defaultIfEmpty(course)
|
||||||
|
)
|
||||||
.flatMap(course -> {
|
.flatMap(course -> {
|
||||||
// 3. 验证课程状态
|
// 3. 验证课程状态
|
||||||
Long courseStatus = course.getStatus();
|
Long courseStatus = course.getStatus();
|
||||||
@@ -150,35 +158,20 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
|
|||||||
if (saved.getId() == null) {
|
if (saved.getId() == null) {
|
||||||
return Mono.error(new RuntimeException("保存预约记录失败"));
|
return Mono.error(new RuntimeException("保存预约记录失败"));
|
||||||
}
|
}
|
||||||
// 10. 更新课程当前人数
|
// 10. 释放锁
|
||||||
return courseRepository.updateCurrentMembers(courseId, 1)
|
return redisService.releaseLock(courseId, requestId)
|
||||||
.flatMap(updatedCourse -> {
|
.then(Mono.just(saved));
|
||||||
// 11. 更新Redis预约计数
|
})
|
||||||
return redisService.incrementBookingCount(courseId)
|
.doOnSuccess(savedBooking -> {
|
||||||
.flatMap(count -> {
|
logger.info("预约成功:bookingId={}, courseId={}, memberId={}",
|
||||||
// 12. 释放锁
|
savedBooking.getId(), courseId, memberId);
|
||||||
return redisService.releaseLock(courseId, requestId)
|
// 发布预约成功事件
|
||||||
.then(Mono.just(saved));
|
bookingReminderEventPublisher.publishBookingSuccessEvent(
|
||||||
});
|
savedBooking.getId(),
|
||||||
})
|
savedBooking.getMemberId(),
|
||||||
.doOnSuccess(savedBooking -> {
|
savedBooking.getCourseName(),
|
||||||
logger.info("预约成功:bookingId={}, courseId={}, memberId={}",
|
savedBooking.getCourseStartTime().toString()
|
||||||
saved.getId(), courseId, memberId);
|
);
|
||||||
// 发布预约成功事件
|
|
||||||
bookingReminderEventPublisher.publishBookingSuccessEvent(
|
|
||||||
saved.getId(),
|
|
||||||
saved.getMemberId(),
|
|
||||||
saved.getCourseName(),
|
|
||||||
saved.getCourseStartTime().toString()
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.doOnError(error -> {
|
|
||||||
// 失败时回滚Redis计数和课程人数
|
|
||||||
redisService.decrementBookingCount(courseId).subscribe();
|
|
||||||
courseRepository.updateCurrentMembers(courseId, -1).subscribe();
|
|
||||||
logger.error("预约失败:courseId={}, memberId={}, error={}",
|
|
||||||
courseId, memberId, error.getMessage());
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -273,30 +266,22 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
|
|||||||
if (rows == 0) {
|
if (rows == 0) {
|
||||||
return Mono.error(new RuntimeException("更新预约状态失败"));
|
return Mono.error(new RuntimeException("更新预约状态失败"));
|
||||||
}
|
}
|
||||||
// 6. 减少课程当前人数
|
// 6. 释放锁
|
||||||
return courseRepository.updateCurrentMembers(booking.getCourseId(), -1)
|
return redisService.releaseLock(bookingId, requestId)
|
||||||
.flatMap(updatedCourse -> {
|
.then(bookingRepository.findById(bookingId));
|
||||||
// 7. 更新Redis预约计数
|
})
|
||||||
return redisService.decrementBookingCount(booking.getCourseId())
|
.doOnSuccess(updatedBooking -> {
|
||||||
.flatMap(count -> {
|
logger.info("取消预约成功:bookingId={}, memberId={}", bookingId, memberId);
|
||||||
// 8. 释放锁
|
// 发布预约取消事件
|
||||||
return redisService.releaseLock(bookingId, requestId)
|
bookingReminderEventPublisher.publishBookingCancelEvent(
|
||||||
.then(bookingRepository.findById(bookingId));
|
updatedBooking.getId(),
|
||||||
});
|
updatedBooking.getMemberId(),
|
||||||
})
|
updatedBooking.getCourseName()
|
||||||
.doOnSuccess(updatedBooking -> {
|
);
|
||||||
logger.info("取消预约成功:bookingId={}, memberId={}", bookingId, memberId);
|
})
|
||||||
// 发布预约取消事件
|
.doOnError(error -> {
|
||||||
bookingReminderEventPublisher.publishBookingCancelEvent(
|
logger.error("取消预约失败:bookingId={}, memberId={}, error={}",
|
||||||
updatedBooking.getId(),
|
bookingId, memberId, error.getMessage());
|
||||||
updatedBooking.getMemberId(),
|
|
||||||
updatedBooking.getCourseName()
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.doOnError(error -> {
|
|
||||||
logger.error("取消预约失败:bookingId={}, memberId={}, error={}",
|
|
||||||
bookingId, memberId, error.getMessage());
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.onErrorResume(error -> {
|
.onErrorResume(error -> {
|
||||||
|
|||||||
-42
@@ -149,46 +149,4 @@ public class GroupCourseRedisService {
|
|||||||
})
|
})
|
||||||
.defaultIfEmpty(false);
|
.defaultIfEmpty(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取课程预约人数(缓存)
|
|
||||||
*/
|
|
||||||
public Mono<Integer> getBookingCount(Long courseId) {
|
|
||||||
String key = "booking_count:" + courseId;
|
|
||||||
return reactiveRedisTemplate.opsForValue()
|
|
||||||
.get(key)
|
|
||||||
.map(obj -> (Integer) obj)
|
|
||||||
.defaultIfEmpty(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 增加课程预约人数
|
|
||||||
*/
|
|
||||||
public Mono<Long> incrementBookingCount(Long courseId) {
|
|
||||||
String key = "booking_count:" + courseId;
|
|
||||||
return reactiveRedisTemplate.opsForValue()
|
|
||||||
.increment(key)
|
|
||||||
.doOnSuccess(count -> logger.debug("预约人数增加:courseId={}, count={}", courseId, count));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 减少课程预约人数
|
|
||||||
*/
|
|
||||||
public Mono<Long> decrementBookingCount(Long courseId) {
|
|
||||||
String key = "booking_count:" + courseId;
|
|
||||||
return reactiveRedisTemplate.opsForValue()
|
|
||||||
.decrement(key)
|
|
||||||
.doOnSuccess(count -> logger.debug("预约人数减少:courseId={}, count={}", courseId, count));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置课程预约人数(用于从数据库同步)
|
|
||||||
*/
|
|
||||||
public Mono<Void> setBookingCount(Long courseId, Integer count) {
|
|
||||||
String key = "booking_count:" + courseId;
|
|
||||||
return reactiveRedisTemplate.opsForValue()
|
|
||||||
.set(key, count)
|
|
||||||
.doOnSuccess(result -> logger.debug("预约人数已设置:courseId={}, count={}", courseId, count))
|
|
||||||
.then();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+208
-7
@@ -19,12 +19,15 @@ import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
|
|||||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
|
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
|
||||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
|
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
|
||||||
import cn.novalon.gym.manage.groupcourse.util.QRCodeUtil;
|
import cn.novalon.gym.manage.groupcourse.util.QRCodeUtil;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.vo.GroupCourseVO;
|
||||||
import cn.novalon.gym.manage.file.core.service.ISysFileService;
|
import cn.novalon.gym.manage.file.core.service.ISysFileService;
|
||||||
import cn.novalon.gym.manage.member.entity.MemberCard;
|
import cn.novalon.gym.manage.member.entity.MemberCard;
|
||||||
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
|
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
|
||||||
import cn.novalon.gym.manage.member.enums.MemberCardType;
|
import cn.novalon.gym.manage.member.enums.MemberCardType;
|
||||||
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
|
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
|
||||||
import cn.novalon.gym.manage.member.service.IMemberCardRecordService;
|
import cn.novalon.gym.manage.member.service.IMemberCardRecordService;
|
||||||
|
import cn.novalon.gym.manage.sys.core.domain.SysUser;
|
||||||
|
import cn.novalon.gym.manage.sys.core.repository.ISysUserRepository;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -35,7 +38,9 @@ import reactor.core.publisher.Flux;
|
|||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -53,6 +58,7 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
private final GroupCourseStateMachine stateMachine;
|
private final GroupCourseStateMachine stateMachine;
|
||||||
private final DatabaseClient databaseClient;
|
private final DatabaseClient databaseClient;
|
||||||
private final ISysFileService fileService;
|
private final ISysFileService fileService;
|
||||||
|
private final ISysUserRepository sysUserRepository;
|
||||||
|
|
||||||
private static final String CACHE_KEY_PREFIX = "group_course:page:";
|
private static final String CACHE_KEY_PREFIX = "group_course:page:";
|
||||||
private static final String CACHE_KEY_ID_PREFIX = "group_course:id:";
|
private static final String CACHE_KEY_ID_PREFIX = "group_course:id:";
|
||||||
@@ -71,7 +77,8 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
GroupCourseStateMachine stateMachine,
|
GroupCourseStateMachine stateMachine,
|
||||||
DatabaseClient databaseClient,
|
DatabaseClient databaseClient,
|
||||||
ISysFileService fileService){
|
ISysFileService fileService,
|
||||||
|
ISysUserRepository sysUserRepository){
|
||||||
this.groupCourseRepository = groupCourseRepository;
|
this.groupCourseRepository = groupCourseRepository;
|
||||||
this.bookingRepository = bookingRepository;
|
this.bookingRepository = bookingRepository;
|
||||||
this.groupCourseTypeRepository = groupCourseTypeRepository;
|
this.groupCourseTypeRepository = groupCourseTypeRepository;
|
||||||
@@ -83,6 +90,7 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
this.stateMachine = stateMachine;
|
this.stateMachine = stateMachine;
|
||||||
this.databaseClient = databaseClient;
|
this.databaseClient = databaseClient;
|
||||||
this.fileService = fileService;
|
this.fileService = fileService;
|
||||||
|
this.sysUserRepository = sysUserRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -128,6 +136,8 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
})
|
})
|
||||||
.switchIfEmpty(Mono.just(buildDetail(course, null)));
|
.switchIfEmpty(Mono.just(buildDetail(course, null)));
|
||||||
})
|
})
|
||||||
|
.flatMap(this::enrichCoachName)
|
||||||
|
.flatMap(this::enrichCurrentMembers)
|
||||||
.flatMap(detail -> {
|
.flatMap(detail -> {
|
||||||
try {
|
try {
|
||||||
String jsonData = objectMapper.writeValueAsString(detail);
|
String jsonData = objectMapper.writeValueAsString(detail);
|
||||||
@@ -173,6 +183,52 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
return detail;
|
return detail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为团课详情对象填充教练名称
|
||||||
|
*/
|
||||||
|
private Mono<GroupCourseDetail> enrichCoachName(GroupCourseDetail detail) {
|
||||||
|
Long coachId = detail.getCoachId();
|
||||||
|
if (coachId == null) {
|
||||||
|
return Mono.just(detail);
|
||||||
|
}
|
||||||
|
return sysUserRepository.findByIdIncludingDeleted(coachId)
|
||||||
|
.map(user -> {
|
||||||
|
String name = user.getNickname() != null ? user.getNickname() : user.getUsername();
|
||||||
|
detail.setCoachName(name);
|
||||||
|
logger.debug("enrichCoachName: courseId={}, coachId={}, coachName={}", detail.getId(), coachId, name);
|
||||||
|
return detail;
|
||||||
|
})
|
||||||
|
.doOnNext(d -> {}) // 避免空doOnNext告警
|
||||||
|
.defaultIfEmpty(detail)
|
||||||
|
.doOnDiscard(GroupCourseDetail.class, d ->
|
||||||
|
logger.warn("enrichCoachName: coach not found, coachId={}, courseId={}", coachId, detail.getId())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为团课详情对象填充实时预约人数
|
||||||
|
*/
|
||||||
|
private Mono<GroupCourseDetail> enrichCurrentMembers(GroupCourseDetail detail) {
|
||||||
|
return bookingRepository.countValidBookings(detail.getId())
|
||||||
|
.map(count -> {
|
||||||
|
detail.setCurrentMembers(count.intValue());
|
||||||
|
return detail;
|
||||||
|
})
|
||||||
|
.defaultIfEmpty(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为团课对象填充实时预约人数
|
||||||
|
*/
|
||||||
|
private Mono<GroupCourse> enrichCurrentMembers(GroupCourse course) {
|
||||||
|
return bookingRepository.countValidBookings(course.getId())
|
||||||
|
.map(count -> {
|
||||||
|
course.setCurrentMembers(count.intValue());
|
||||||
|
return course;
|
||||||
|
})
|
||||||
|
.defaultIfEmpty(course);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<GroupCourse> findById(Long id) {
|
public Mono<GroupCourse> findById(Long id) {
|
||||||
String cacheKey = CACHE_KEY_ID_PREFIX + id;
|
String cacheKey = CACHE_KEY_ID_PREFIX + id;
|
||||||
@@ -206,7 +262,8 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.doOnSubscribe(sub -> logger.debug("缓存未命中,查询数据库 - findById: id={}", id))
|
.doOnSubscribe(sub -> logger.debug("缓存未命中,查询数据库 - findById: id={}", id))
|
||||||
);
|
)
|
||||||
|
.flatMap(this::enrichCurrentMembers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -223,6 +280,47 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<GroupCourseVO> findAllAsVO(boolean includeDeleted) {
|
||||||
|
return findAll(includeDeleted)
|
||||||
|
.flatMap(this::enrichCurrentMembers)
|
||||||
|
.collectList()
|
||||||
|
.flatMapMany(courses -> {
|
||||||
|
if (courses.isEmpty()) {
|
||||||
|
logger.info("findAllAsVO: no courses found");
|
||||||
|
return Flux.empty();
|
||||||
|
}
|
||||||
|
var coachIds = courses.stream()
|
||||||
|
.map(GroupCourse::getCoachId)
|
||||||
|
.filter(id -> id != null)
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
logger.info("findAllAsVO: {} courses, {} unique coach IDs: {}", courses.size(), coachIds.size(), coachIds);
|
||||||
|
|
||||||
|
if (coachIds.isEmpty()) {
|
||||||
|
logger.info("findAllAsVO: no coach IDs, returning VOs with null coachName");
|
||||||
|
return Flux.fromIterable(courses.stream()
|
||||||
|
.map(c -> GroupCourseVO.from(c, null))
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Flux.fromIterable(coachIds)
|
||||||
|
.flatMap(sysUserRepository::findByIdIncludingDeleted)
|
||||||
|
.doOnNext(u -> logger.info("findAllAsVO: found coach id={}, nickname={}, username={}", u.getId(), u.getNickname(), u.getUsername()))
|
||||||
|
.collectMap(SysUser::getId, u -> u.getNickname() != null ? u.getNickname() : u.getUsername())
|
||||||
|
.doOnNext(m -> logger.info("findAllAsVO: coachNameMap size={}, keys={}", m.size(), m.keySet()))
|
||||||
|
.flatMapMany(coachNameMap ->
|
||||||
|
Flux.fromIterable(courses.stream()
|
||||||
|
.map(c -> {
|
||||||
|
String name = coachNameMap.get(c.getCoachId());
|
||||||
|
logger.debug("findAllAsVO: courseId={}, coachId={}, coachName={}", c.getId(), c.getCoachId(), name);
|
||||||
|
return GroupCourseVO.from(c, name);
|
||||||
|
})
|
||||||
|
.toList()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest, boolean includeDeleted) {
|
public Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest, boolean includeDeleted) {
|
||||||
int page = pageRequest.getPage();
|
int page = pageRequest.getPage();
|
||||||
@@ -275,6 +373,73 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<PageResponse<GroupCourseVO>> findByPageAsVO(PageRequest pageRequest, boolean includeDeleted) {
|
||||||
|
return findByPage(pageRequest, includeDeleted)
|
||||||
|
.flatMap(pageResponse -> {
|
||||||
|
List<GroupCourse> courses = pageResponse.getContent();
|
||||||
|
if (courses.isEmpty()) {
|
||||||
|
PageResponse<GroupCourseVO> voResponse = new PageResponse<>(
|
||||||
|
Collections.emptyList(),
|
||||||
|
pageResponse.getTotalPages(),
|
||||||
|
pageResponse.getTotalElements(),
|
||||||
|
pageResponse.getCurrentPage(),
|
||||||
|
pageResponse.getPageSize()
|
||||||
|
);
|
||||||
|
return Mono.just(voResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用实时预约人数覆盖缓存/DB中的currentMembers
|
||||||
|
return Flux.fromIterable(courses)
|
||||||
|
.flatMap(this::enrichCurrentMembers)
|
||||||
|
.collectList()
|
||||||
|
.flatMap(enrichedCourses -> {
|
||||||
|
var coachIds = enrichedCourses.stream()
|
||||||
|
.map(GroupCourse::getCoachId)
|
||||||
|
.filter(id -> id != null)
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
logger.info("findByPageAsVO: {} courses, {} unique coach IDs: {}", enrichedCourses.size(), coachIds.size(), coachIds);
|
||||||
|
|
||||||
|
if (coachIds.isEmpty()) {
|
||||||
|
List<GroupCourseVO> voList = enrichedCourses.stream()
|
||||||
|
.map(c -> GroupCourseVO.from(c, null))
|
||||||
|
.toList();
|
||||||
|
PageResponse<GroupCourseVO> voResponse = new PageResponse<>(
|
||||||
|
voList,
|
||||||
|
pageResponse.getTotalPages(),
|
||||||
|
pageResponse.getTotalElements(),
|
||||||
|
pageResponse.getCurrentPage(),
|
||||||
|
pageResponse.getPageSize()
|
||||||
|
);
|
||||||
|
return Mono.just(voResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Flux.fromIterable(coachIds)
|
||||||
|
.flatMap(sysUserRepository::findByIdIncludingDeleted)
|
||||||
|
.doOnNext(u -> logger.info("findByPageAsVO: found coach id={}, nickname={}", u.getId(), u.getNickname()))
|
||||||
|
.collectMap(SysUser::getId, u -> u.getNickname() != null ? u.getNickname() : u.getUsername())
|
||||||
|
.map(coachNameMap -> {
|
||||||
|
List<GroupCourseVO> voList = enrichedCourses.stream()
|
||||||
|
.map(c -> {
|
||||||
|
String name = coachNameMap.get(c.getCoachId());
|
||||||
|
logger.debug("findByPageAsVO: courseId={}, coachId={}, coachName={}", c.getId(), c.getCoachId(), name);
|
||||||
|
return GroupCourseVO.from(c, name);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
return new PageResponse<>(
|
||||||
|
voList,
|
||||||
|
pageResponse.getTotalPages(),
|
||||||
|
pageResponse.getTotalElements(),
|
||||||
|
pageResponse.getCurrentPage(),
|
||||||
|
pageResponse.getPageSize()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<GroupCourse> create(GroupCourse groupCourse) {
|
public Mono<GroupCourse> create(GroupCourse groupCourse) {
|
||||||
return groupCourseRepository.save(groupCourse)
|
return groupCourseRepository.save(groupCourse)
|
||||||
@@ -491,6 +656,15 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
public Mono<GroupCourse> signIn(Long courseId, Long memberId) {
|
public Mono<GroupCourse> signIn(Long courseId, Long memberId) {
|
||||||
return groupCourseRepository.findByIdAndDeletedAtIsNull(courseId)
|
return groupCourseRepository.findByIdAndDeletedAtIsNull(courseId)
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在或已删除")))
|
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在或已删除")))
|
||||||
|
.flatMap(course ->
|
||||||
|
// 用实时预约人数覆盖currentMembers
|
||||||
|
bookingRepository.countValidBookings(courseId)
|
||||||
|
.map(count -> {
|
||||||
|
course.setCurrentMembers(count.intValue());
|
||||||
|
return course;
|
||||||
|
})
|
||||||
|
.defaultIfEmpty(course)
|
||||||
|
)
|
||||||
.flatMap(course -> {
|
.flatMap(course -> {
|
||||||
// 校验1:团课已取消
|
// 校验1:团课已取消
|
||||||
if (course.getStatus().equals(CourseStatus.CANCELLED.getValue())) {
|
if (course.getStatus().equals(CourseStatus.CANCELLED.getValue())) {
|
||||||
@@ -521,11 +695,9 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
return bookingRepository.findValidBooking(courseId, memberId)
|
return bookingRepository.findValidBooking(courseId, memberId)
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("您未预约此团课")))
|
.switchIfEmpty(Mono.error(new RuntimeException("您未预约此团课")))
|
||||||
.flatMap(booking -> {
|
.flatMap(booking -> {
|
||||||
return groupCourseRepository.updateCurrentMembers(courseId, 1)
|
// 更新预约状态为已出席(2),不再手动计数
|
||||||
.flatMap(updatedCourse -> {
|
return bookingRepository.updateStatus(booking.getId(), "2")
|
||||||
return bookingRepository.updateStatus(booking.getId(), "2")
|
.thenReturn(course);
|
||||||
.thenReturn(updatedCourse);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.doOnSuccess(course -> logger.info("团课签到成功 - courseId={}, memberId={}", courseId, memberId))
|
.doOnSuccess(course -> logger.info("团课签到成功 - courseId={}, memberId={}", courseId, memberId))
|
||||||
@@ -571,4 +743,33 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
.then(redisUtil.deleteByPattern(CACHE_KEY_ID_PREFIX + "*"))
|
.then(redisUtil.deleteByPattern(CACHE_KEY_ID_PREFIX + "*"))
|
||||||
.then(redisUtil.deleteByPattern(CACHE_KEY_DETAIL_PREFIX + "*")).then();
|
.then(redisUtil.deleteByPattern(CACHE_KEY_DETAIL_PREFIX + "*")).then();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<List<GroupCourse>> checkCoachConflict(Long coachId, LocalDateTime startTime, LocalDateTime endTime, Long excludeCourseId) {
|
||||||
|
if (coachId == null || startTime == null || endTime == null) {
|
||||||
|
return Mono.just(Collections.emptyList());
|
||||||
|
}
|
||||||
|
return groupCourseRepository.findByCoachId(coachId)
|
||||||
|
.filter(course -> {
|
||||||
|
// 排除已取消的课程
|
||||||
|
if (course.getStatus() != null && course.getStatus().equals(CourseStatus.CANCELLED.getValue())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 排除自身(编辑时)
|
||||||
|
if (excludeCourseId != null && course.getId() != null && course.getId().equals(excludeCourseId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 检查时间是否重叠:startTime < course.endTime AND course.startTime < endTime
|
||||||
|
if (course.getStartTime() == null || course.getEndTime() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return startTime.isBefore(course.getEndTime()) && course.getStartTime().isBefore(endTime);
|
||||||
|
})
|
||||||
|
.collectList()
|
||||||
|
.doOnNext(conflicts -> {
|
||||||
|
if (!conflicts.isEmpty()) {
|
||||||
|
logger.info("checkCoachConflict: coachId={}, conflicts={} courses", coachId, conflicts.size());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
package cn.novalon.gym.manage.groupcourse.vo;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 团课视图对象(含教练昵称)
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-19
|
||||||
|
*/
|
||||||
|
public class GroupCourseVO {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String courseName;
|
||||||
|
private Long coachId;
|
||||||
|
private String coachName;
|
||||||
|
private Long courseType;
|
||||||
|
private LocalDateTime startTime;
|
||||||
|
private LocalDateTime endTime;
|
||||||
|
private Integer maxMembers;
|
||||||
|
private Integer currentMembers;
|
||||||
|
private Long status;
|
||||||
|
private String location;
|
||||||
|
private String coverImage;
|
||||||
|
private String description;
|
||||||
|
private BigDecimal storedValueAmount;
|
||||||
|
private String qrCodePath;
|
||||||
|
private String createBy;
|
||||||
|
private String updateBy;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public GroupCourseVO() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从领域对象和教练名称构建 VO
|
||||||
|
*/
|
||||||
|
public static GroupCourseVO from(GroupCourse course, String coachName) {
|
||||||
|
GroupCourseVO vo = new GroupCourseVO();
|
||||||
|
vo.setId(course.getId());
|
||||||
|
vo.setCourseName(course.getCourseName());
|
||||||
|
vo.setCoachId(course.getCoachId());
|
||||||
|
vo.setCoachName(coachName);
|
||||||
|
vo.setCourseType(course.getCourseType());
|
||||||
|
vo.setStartTime(course.getStartTime());
|
||||||
|
vo.setEndTime(course.getEndTime());
|
||||||
|
vo.setMaxMembers(course.getMaxMembers());
|
||||||
|
vo.setCurrentMembers(course.getCurrentMembers());
|
||||||
|
vo.setStatus(course.getStatus());
|
||||||
|
vo.setLocation(course.getLocation());
|
||||||
|
vo.setCoverImage(course.getCoverImage());
|
||||||
|
vo.setDescription(course.getDescription());
|
||||||
|
vo.setStoredValueAmount(course.getStoredValueAmount());
|
||||||
|
vo.setQrCodePath(course.getQrCodePath());
|
||||||
|
vo.setCreateBy(course.getCreateBy());
|
||||||
|
vo.setUpdateBy(course.getUpdateBy());
|
||||||
|
vo.setCreatedAt(course.getCreatedAt());
|
||||||
|
vo.setUpdatedAt(course.getUpdatedAt());
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- getters / setters --
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public void setId(Long id) { this.id = id; }
|
||||||
|
|
||||||
|
public String getCourseName() { return courseName; }
|
||||||
|
public void setCourseName(String courseName) { this.courseName = courseName; }
|
||||||
|
|
||||||
|
public Long getCoachId() { return coachId; }
|
||||||
|
public void setCoachId(Long coachId) { this.coachId = coachId; }
|
||||||
|
|
||||||
|
public String getCoachName() { return coachName; }
|
||||||
|
public void setCoachName(String coachName) { this.coachName = coachName; }
|
||||||
|
|
||||||
|
public Long getCourseType() { return courseType; }
|
||||||
|
public void setCourseType(Long courseType) { this.courseType = courseType; }
|
||||||
|
|
||||||
|
public LocalDateTime getStartTime() { return startTime; }
|
||||||
|
public void setStartTime(LocalDateTime startTime) { this.startTime = startTime; }
|
||||||
|
|
||||||
|
public LocalDateTime getEndTime() { return endTime; }
|
||||||
|
public void setEndTime(LocalDateTime endTime) { this.endTime = endTime; }
|
||||||
|
|
||||||
|
public Integer getMaxMembers() { return maxMembers; }
|
||||||
|
public void setMaxMembers(Integer maxMembers) { this.maxMembers = maxMembers; }
|
||||||
|
|
||||||
|
public Integer getCurrentMembers() { return currentMembers; }
|
||||||
|
public void setCurrentMembers(Integer currentMembers) { this.currentMembers = currentMembers; }
|
||||||
|
|
||||||
|
public Long getStatus() { return status; }
|
||||||
|
public void setStatus(Long status) { this.status = status; }
|
||||||
|
|
||||||
|
public String getLocation() { return location; }
|
||||||
|
public void setLocation(String location) { this.location = location; }
|
||||||
|
|
||||||
|
public String getCoverImage() { return coverImage; }
|
||||||
|
public void setCoverImage(String coverImage) { this.coverImage = coverImage; }
|
||||||
|
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public void setDescription(String description) { this.description = description; }
|
||||||
|
|
||||||
|
public BigDecimal getStoredValueAmount() { return storedValueAmount; }
|
||||||
|
public void setStoredValueAmount(BigDecimal storedValueAmount) { this.storedValueAmount = storedValueAmount; }
|
||||||
|
|
||||||
|
public String getQrCodePath() { return qrCodePath; }
|
||||||
|
public void setQrCodePath(String qrCodePath) { this.qrCodePath = qrCodePath; }
|
||||||
|
|
||||||
|
public String getCreateBy() { return createBy; }
|
||||||
|
public void setCreateBy(String createBy) { this.createBy = createBy; }
|
||||||
|
|
||||||
|
public String getUpdateBy() { return updateBy; }
|
||||||
|
public void setUpdateBy(String updateBy) { this.updateBy = updateBy; }
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
}
|
||||||
+11
-1
@@ -20,6 +20,7 @@ import cn.novalon.gym.manage.notify.handler.BannerHandler;
|
|||||||
import cn.novalon.gym.manage.notify.handler.SysNoticeHandler;
|
import cn.novalon.gym.manage.notify.handler.SysNoticeHandler;
|
||||||
import cn.novalon.gym.manage.notify.handler.SysUserMessageHandler;
|
import cn.novalon.gym.manage.notify.handler.SysUserMessageHandler;
|
||||||
import cn.novalon.gym.manage.payment.handler.PaymentHandler;
|
import cn.novalon.gym.manage.payment.handler.PaymentHandler;
|
||||||
|
import cn.novalon.gym.manage.app.handler.CoachHandler;
|
||||||
import cn.novalon.gym.manage.sys.handler.auth.PasswordDiagnosticHandler;
|
import cn.novalon.gym.manage.sys.handler.auth.PasswordDiagnosticHandler;
|
||||||
import cn.novalon.gym.manage.sys.handler.auth.SysAuthHandler;
|
import cn.novalon.gym.manage.sys.handler.auth.SysAuthHandler;
|
||||||
import cn.novalon.gym.manage.sys.handler.config.SysConfigHandler;
|
import cn.novalon.gym.manage.sys.handler.config.SysConfigHandler;
|
||||||
@@ -84,7 +85,8 @@ public class SystemRouter {
|
|||||||
CheckInHandler checkInHandler,
|
CheckInHandler checkInHandler,
|
||||||
DataStatisticsHandler dataStatisticsHandler,
|
DataStatisticsHandler dataStatisticsHandler,
|
||||||
PhoneAuthHandler phoneAuthHandler,
|
PhoneAuthHandler phoneAuthHandler,
|
||||||
PaymentHandler paymentHandler) {
|
PaymentHandler paymentHandler,
|
||||||
|
CoachHandler coachHandler) {
|
||||||
|
|
||||||
return route()
|
return route()
|
||||||
// ========== 诊断路由 ==========
|
// ========== 诊断路由 ==========
|
||||||
@@ -118,6 +120,13 @@ public class SystemRouter {
|
|||||||
.GET("/api/users/{id}/roles", userHandler::getUserRoles)
|
.GET("/api/users/{id}/roles", userHandler::getUserRoles)
|
||||||
.POST("/api/users/{id}/roles", userHandler::assignRoles)
|
.POST("/api/users/{id}/roles", userHandler::assignRoles)
|
||||||
|
|
||||||
|
// ========== 教练路由 ==========
|
||||||
|
.GET("/api/coach/list", coachHandler::getAllCoaches)
|
||||||
|
.POST("/api/coach", coachHandler::createCoach)
|
||||||
|
.PUT("/api/coach/{id}", coachHandler::updateCoach)
|
||||||
|
.POST("/api/coach/{id}/disable", coachHandler::disableCoach)
|
||||||
|
.GET("/api/coach/{id}/courses", coachHandler::getCoachCourses)
|
||||||
|
|
||||||
// ========== 菜单路由 ==========
|
// ========== 菜单路由 ==========
|
||||||
.GET("/api/menus", menuHandler::getAllMenus)
|
.GET("/api/menus", menuHandler::getAllMenus)
|
||||||
.GET("/api/menus/tree", menuHandler::getMenuTree)
|
.GET("/api/menus/tree", menuHandler::getMenuTree)
|
||||||
@@ -362,6 +371,7 @@ public class SystemRouter {
|
|||||||
.POST("/api/groupCourse/{id}/cancel", groupCourseHandler::cancelGroupCourse)
|
.POST("/api/groupCourse/{id}/cancel", groupCourseHandler::cancelGroupCourse)
|
||||||
.POST("/api/groupCourse/signin/{memberId}", groupCourseHandler::signIn)
|
.POST("/api/groupCourse/signin/{memberId}", groupCourseHandler::signIn)
|
||||||
.POST("/api/groupCourse/search", groupCourseHandler::searchGroupCourses)
|
.POST("/api/groupCourse/search", groupCourseHandler::searchGroupCourses)
|
||||||
|
.POST("/api/groupCourse/check-coach-conflict", groupCourseHandler::checkCoachConflict)
|
||||||
|
|
||||||
// ========= 签到模块路由 ==========
|
// ========= 签到模块路由 ==========
|
||||||
// ===== 签到核心功能 =====
|
// ===== 签到核心功能 =====
|
||||||
|
|||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
package cn.novalon.gym.manage.app.handler;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.app.service.CoachService;
|
||||||
|
import cn.novalon.gym.manage.sys.core.domain.SysUser;
|
||||||
|
import cn.novalon.gym.manage.sys.dto.request.CoachCreateRequest;
|
||||||
|
import cn.novalon.gym.manage.sys.dto.request.CoachUpdateRequest;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Validator;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教练处理器(放在 manage-app 中避免模块循环依赖)
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-19
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Tag(name = "教练管理", description = "教练相关操作")
|
||||||
|
public class CoachHandler {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(CoachHandler.class);
|
||||||
|
private final CoachService coachService;
|
||||||
|
private final Validator validator;
|
||||||
|
|
||||||
|
public CoachHandler(CoachService coachService, Validator validator) {
|
||||||
|
this.coachService = coachService;
|
||||||
|
this.validator = validator;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "获取所有教练", description = "获取系统中所有教练列表")
|
||||||
|
public Mono<ServerResponse> getAllCoaches(ServerRequest request) {
|
||||||
|
return ServerResponse.ok()
|
||||||
|
.body(coachService.getAllCoaches(), SysUser.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "创建教练", description = "创建新教练(创建用户并分配教练角色)")
|
||||||
|
public Mono<ServerResponse> createCoach(ServerRequest request) {
|
||||||
|
return request.bodyToMono(CoachCreateRequest.class)
|
||||||
|
.flatMap(req -> {
|
||||||
|
var violations = validator.validate(req);
|
||||||
|
if (!violations.isEmpty()) {
|
||||||
|
Map<String, String> errors = new HashMap<>();
|
||||||
|
violations.forEach(v -> errors.put(v.getPropertyPath().toString(), v.getMessage()));
|
||||||
|
return ServerResponse.badRequest().bodyValue(errors);
|
||||||
|
}
|
||||||
|
return coachService.createCoach(
|
||||||
|
req.getUsername(), req.getPassword(),
|
||||||
|
req.getNickname(), req.getEmail(), req.getPhone())
|
||||||
|
.flatMap(user -> ServerResponse.status(HttpStatus.CREATED).bodyValue(user))
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
logger.error("创建教练失败", e);
|
||||||
|
return ServerResponse.badRequest()
|
||||||
|
.bodyValue(Map.of("error", e.getMessage()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "更新教练信息", description = "更新教练基本信息(昵称、邮箱、手机号)")
|
||||||
|
public Mono<ServerResponse> updateCoach(ServerRequest request) {
|
||||||
|
Long id = Long.valueOf(request.pathVariable("id"));
|
||||||
|
return request.bodyToMono(CoachUpdateRequest.class)
|
||||||
|
.flatMap(req -> coachService.updateCoach(id, req.getNickname(), req.getEmail(), req.getPhone())
|
||||||
|
.flatMap(user -> ServerResponse.ok().bodyValue(user))
|
||||||
|
.switchIfEmpty(ServerResponse.notFound().build())
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
logger.error("更新教练失败", e);
|
||||||
|
return ServerResponse.badRequest()
|
||||||
|
.bodyValue(Map.of("error", e.getMessage()));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "禁用教练", description = "禁用教练账号,自动取消其所有非进行中的团课")
|
||||||
|
public Mono<ServerResponse> disableCoach(ServerRequest request) {
|
||||||
|
Long id = Long.valueOf(request.pathVariable("id"));
|
||||||
|
return coachService.disableCoach(id)
|
||||||
|
.then(ServerResponse.ok().bodyValue(Map.of("message", "教练已禁用")))
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
logger.error("禁用教练失败: {}", e.getMessage());
|
||||||
|
return ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "获取教练的团课", description = "获取指定教练教授的所有团课")
|
||||||
|
public Mono<ServerResponse> getCoachCourses(ServerRequest request) {
|
||||||
|
Long id = Long.valueOf(request.pathVariable("id"));
|
||||||
|
return coachService.getCoachCourses(id)
|
||||||
|
.collectList()
|
||||||
|
.flatMap(courses -> ServerResponse.ok().bodyValue(courses))
|
||||||
|
.switchIfEmpty(ServerResponse.ok().bodyValue(List.of()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
package cn.novalon.gym.manage.app.service;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.util.StatusConstants;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
|
||||||
|
import cn.novalon.gym.manage.sys.core.domain.SysRole;
|
||||||
|
import cn.novalon.gym.manage.sys.core.domain.SysUser;
|
||||||
|
import cn.novalon.gym.manage.sys.core.domain.UserRole;
|
||||||
|
import cn.novalon.gym.manage.sys.core.repository.ISysRoleRepository;
|
||||||
|
import cn.novalon.gym.manage.sys.core.repository.ISysUserRepository;
|
||||||
|
import cn.novalon.gym.manage.sys.core.repository.IUserRoleRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教练服务(跨模块编排,放在 manage-app 中避免模块循环依赖)
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-19
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class CoachService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(CoachService.class);
|
||||||
|
private static final String COACH_ROLE_NAME = "教练";
|
||||||
|
|
||||||
|
private final ISysUserRepository userRepository;
|
||||||
|
private final ISysRoleRepository roleRepository;
|
||||||
|
private final IUserRoleRepository userRoleRepository;
|
||||||
|
private final IGroupCourseRepository groupCourseRepository;
|
||||||
|
private final IGroupCourseBookingRepository bookingRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
public CoachService(ISysUserRepository userRepository,
|
||||||
|
ISysRoleRepository roleRepository,
|
||||||
|
IUserRoleRepository userRoleRepository,
|
||||||
|
IGroupCourseRepository groupCourseRepository,
|
||||||
|
IGroupCourseBookingRepository bookingRepository,
|
||||||
|
PasswordEncoder passwordEncoder) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.roleRepository = roleRepository;
|
||||||
|
this.userRoleRepository = userRoleRepository;
|
||||||
|
this.groupCourseRepository = groupCourseRepository;
|
||||||
|
this.bookingRepository = bookingRepository;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Flux<SysUser> getAllCoaches() {
|
||||||
|
return getCoachRoleId()
|
||||||
|
.flatMapMany(roleId -> userRoleRepository.findByRoleId(roleId)
|
||||||
|
.map(UserRole::getUserId)
|
||||||
|
.collectList()
|
||||||
|
.flatMapMany(userIds -> {
|
||||||
|
if (userIds.isEmpty()) {
|
||||||
|
return Flux.empty();
|
||||||
|
}
|
||||||
|
return Flux.fromIterable(userIds)
|
||||||
|
.flatMap(userRepository::findById)
|
||||||
|
.filter(user -> user.getDeletedAt() == null);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<SysUser> createCoach(String username, String password, String nickname, String email, String phone) {
|
||||||
|
return getCoachRoleId().flatMap(coachRoleId -> {
|
||||||
|
SysUser user = new SysUser();
|
||||||
|
user.generateId();
|
||||||
|
user.setUsername(username);
|
||||||
|
user.setPassword(passwordEncoder.encode(password));
|
||||||
|
user.setNickname(nickname);
|
||||||
|
user.setEmail(email);
|
||||||
|
user.setPhone(phone);
|
||||||
|
user.setStatus(StatusConstants.ENABLED);
|
||||||
|
|
||||||
|
return userRepository.save(user)
|
||||||
|
.flatMap(saved -> {
|
||||||
|
UserRole userRole = new UserRole();
|
||||||
|
userRole.setUserId(saved.getId());
|
||||||
|
userRole.setRoleId(coachRoleId);
|
||||||
|
return userRoleRepository.save(userRole).thenReturn(saved);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<SysUser> updateCoach(Long id, String nickname, String email, String phone) {
|
||||||
|
return userRepository.findById(id)
|
||||||
|
.switchIfEmpty(Mono.error(new RuntimeException("教练不存在")))
|
||||||
|
.flatMap(user -> {
|
||||||
|
if (nickname != null) user.setNickname(nickname);
|
||||||
|
if (email != null) user.setEmail(email);
|
||||||
|
if (phone != null) user.setPhone(phone);
|
||||||
|
user.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return userRepository.update(user);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(transactionManager = "connectionFactoryTransactionManager")
|
||||||
|
public Mono<Void> disableCoach(Long id) {
|
||||||
|
return userRepository.findById(id)
|
||||||
|
.switchIfEmpty(Mono.error(new RuntimeException("教练不存在")))
|
||||||
|
.flatMap(user ->
|
||||||
|
groupCourseRepository.countByCoachIdAndStatus(id, 3L)
|
||||||
|
.flatMap(inProgressCount -> {
|
||||||
|
if (inProgressCount > 0) {
|
||||||
|
return Mono.error(new RuntimeException(
|
||||||
|
"该教练有 " + inProgressCount + " 门正在进行中的团课,无法禁用"));
|
||||||
|
}
|
||||||
|
return groupCourseRepository.cancelCoursesByCoachIdExceptStatus(id, 3L)
|
||||||
|
.then();
|
||||||
|
})
|
||||||
|
.then(Mono.defer(() -> {
|
||||||
|
user.setStatus(StatusConstants.DISABLED);
|
||||||
|
user.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return userRepository.update(user).then();
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Flux<GroupCourse> getCoachCourses(Long coachId) {
|
||||||
|
return groupCourseRepository.findByCoachId(coachId, Sort.by(Sort.Direction.DESC, "startTime"))
|
||||||
|
.flatMap(course ->
|
||||||
|
bookingRepository.countValidBookings(course.getId())
|
||||||
|
.map(count -> {
|
||||||
|
course.setCurrentMembers(count.intValue());
|
||||||
|
return course;
|
||||||
|
})
|
||||||
|
.defaultIfEmpty(course)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Long> getCoachRoleId() {
|
||||||
|
return roleRepository.findByRoleName(COACH_ROLE_NAME)
|
||||||
|
.map(SysRole::getId)
|
||||||
|
.switchIfEmpty(Mono.error(new RuntimeException("教练角色未找到,请先执行数据库迁移")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,15 @@
|
|||||||
-- ============================================
|
-- ============================================
|
||||||
-- V20: 新增轮播图管理菜单
|
-- V20: 新增轮播图管理菜单按钮权限
|
||||||
-- 描述: 为首页轮播图管理功能创建菜单项
|
-- 注意: sys_menu(id=19, 轮播图管理) 已在 V19 中创建
|
||||||
-- ============================================
|
-- ============================================
|
||||||
|
|
||||||
-- 轮播图管理子菜单(页面),归属系统管理目录
|
|
||||||
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
|
||||||
(19, '轮播图管理', 0, 8, 'C', 'system:banner:list', 'banner/index', 1, NOW(), NOW());
|
|
||||||
|
|
||||||
-- 轮播图管理按钮权限
|
-- 轮播图管理按钮权限
|
||||||
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
(191, '轮播图查询', 19, 1, 'F', 'system:banner:query', NULL, 1, NOW(), NOW()),
|
(191, '轮播图查询', 19, 1, 'F', 'system:banner:query', NULL, 1, NOW(), NOW()),
|
||||||
(192, '轮播图新增', 19, 2, 'F', 'system:banner:add', NULL, 1, NOW(), NOW()),
|
(192, '轮播图新增', 19, 2, 'F', 'system:banner:add', NULL, 1, NOW(), NOW()),
|
||||||
(193, '轮播图修改', 19, 3, 'F', 'system:banner:edit', NULL, 1, NOW(), NOW()),
|
(193, '轮播图修改', 19, 3, 'F', 'system:banner:edit', NULL, 1, NOW(), NOW()),
|
||||||
(194, '轮播图删除', 19, 4, 'F', 'system:banner:remove', NULL, 1, NOW(), NOW());
|
(194, '轮播图删除', 19, 4, 'F', 'system:banner:remove', NULL, 1, NOW(), NOW())
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- 重置菜单序列
|
-- 重置菜单序列
|
||||||
SELECT setval('sys_menu_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_menu));
|
SELECT setval('sys_menu_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_menu));
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- V22: 教练管理 - 角色/权限/菜单
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 1. 教练角色
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO sys_role (id, role_name, role_key, role_sort, status, create_by, update_by, created_at, updated_at)
|
||||||
|
VALUES (5, '教练', 'coach', 5, 1, 'system', 'system', NOW(), NOW())
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
SELECT setval('sys_role_id_seq', COALESCE((SELECT MAX(id) FROM sys_role), 1));
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 2. 教练管理权限
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO sys_permission (permission_name, permission_code, resource, action, description, status, create_by, update_by, created_at, updated_at) VALUES
|
||||||
|
('教练查看', 'system:coach:view', '/api/coach', 'GET', '查看教练列表', 1, 'system', 'system', NOW(), NOW()),
|
||||||
|
('教练创建', 'system:coach:create', '/api/coach', 'POST', '创建教练', 1, 'system', 'system', NOW(), NOW()),
|
||||||
|
('教练编辑', 'system:coach:edit', '/api/coach', 'PUT', '编辑教练信息', 1, 'system', 'system', NOW(), NOW()),
|
||||||
|
('教练删除', 'system:coach:delete', '/api/coach', 'DELETE', '删除(禁用)教练', 1, 'system', 'system', NOW(), NOW());
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 3. 为超级管理员角色分配教练权限
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO sys_role_permission (role_id, permission_id, create_by, update_by, created_at, updated_at)
|
||||||
|
SELECT 1, id, 'system', 'system', NOW(), NOW() FROM sys_permission
|
||||||
|
WHERE permission_code LIKE 'system:coach:%'
|
||||||
|
AND id NOT IN (SELECT permission_id FROM sys_role_permission WHERE role_id = 1);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 4. 教练管理菜单(顶级菜单)
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
|
(20, '教练管理', 0, 9, 'C', 'system:coach:list', 'system/coach/index', 1, NOW(), NOW());
|
||||||
|
|
||||||
|
-- 教练管理按钮权限
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
|
(201, '教练查询', 20, 1, 'F', 'system:coach:query', NULL, 1, NOW(), NOW()),
|
||||||
|
(202, '教练新增', 20, 2, 'F', 'system:coach:add', NULL, 1, NOW(), NOW()),
|
||||||
|
(203, '教练修改', 20, 3, 'F', 'system:coach:edit', NULL, 1, NOW(), NOW()),
|
||||||
|
(204, '教练删除', 20, 4, 'F', 'system:coach:remove', NULL, 1, NOW(), NOW());
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 5. 重置序列
|
||||||
|
-- ============================================
|
||||||
|
SELECT setval('sys_menu_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_menu));
|
||||||
|
SELECT setval('sys_permission_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_permission));
|
||||||
|
SELECT setval('sys_role_permission_id_seq',(SELECT COALESCE(MAX(id), 1) FROM sys_role_permission));
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
package cn.novalon.gym.manage.sys.dto.request;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教练创建请求DTO
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-19
|
||||||
|
*/
|
||||||
|
@Schema(description = "教练创建请求")
|
||||||
|
public class CoachCreateRequest {
|
||||||
|
|
||||||
|
@Schema(description = "用户名", example = "coach01")
|
||||||
|
@NotBlank(message = "用户名不能为空")
|
||||||
|
@Size(min = 3, max = 50, message = "用户名长度必须在3-50之间")
|
||||||
|
@Pattern(regexp = "^[a-zA-Z0-9_-]+$", message = "用户名只能包含字母、数字、下划线和横线")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@Schema(description = "昵称", example = "张教练")
|
||||||
|
@Size(max = 100, message = "昵称长度不能超过100")
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
@Schema(description = "密码", example = "Coach@123")
|
||||||
|
@NotBlank(message = "密码不能为空")
|
||||||
|
@Size(min = 8, max = 20, message = "密码长度必须在8-20之间")
|
||||||
|
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$", message = "密码必须包含大小写字母和数字")
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@Schema(description = "邮箱", example = "coach@example.com")
|
||||||
|
@NotBlank(message = "邮箱不能为空")
|
||||||
|
@Email(message = "邮箱格式不正确")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Schema(description = "手机号", example = "13800138001")
|
||||||
|
@NotBlank(message = "手机号不能为空")
|
||||||
|
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
public String getUsername() { return username; }
|
||||||
|
public void setUsername(String username) { this.username = username; }
|
||||||
|
public String getNickname() { return nickname; }
|
||||||
|
public void setNickname(String nickname) { this.nickname = nickname; }
|
||||||
|
public String getPassword() { return password; }
|
||||||
|
public void setPassword(String password) { this.password = password; }
|
||||||
|
public String getEmail() { return email; }
|
||||||
|
public void setEmail(String email) { this.email = email; }
|
||||||
|
public String getPhone() { return phone; }
|
||||||
|
public void setPhone(String phone) { this.phone = phone; }
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package cn.novalon.gym.manage.sys.dto.request;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教练更新请求DTO
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-07-19
|
||||||
|
*/
|
||||||
|
@Schema(description = "教练更新请求")
|
||||||
|
public class CoachUpdateRequest {
|
||||||
|
|
||||||
|
@Schema(description = "昵称", example = "张教练")
|
||||||
|
@Size(max = 100, message = "昵称长度不能超过100")
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
@Schema(description = "邮箱", example = "coach@example.com")
|
||||||
|
@Email(message = "邮箱格式不正确")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Schema(description = "手机号", example = "13800138001")
|
||||||
|
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
public String getNickname() { return nickname; }
|
||||||
|
public void setNickname(String nickname) { this.nickname = nickname; }
|
||||||
|
public String getEmail() { return email; }
|
||||||
|
public void setEmail(String email) { this.email = email; }
|
||||||
|
public String getPhone() { return phone; }
|
||||||
|
public void setPhone(String phone) { this.phone = phone; }
|
||||||
|
}
|
||||||
@@ -294,10 +294,16 @@
|
|||||||
goBack() { uni.navigateBack() },
|
goBack() { uni.navigateBack() },
|
||||||
async handleBooking() {
|
async handleBooking() {
|
||||||
if (!this.course.canBook || this.submitting) return
|
if (!this.course.canBook || this.submitting) return
|
||||||
|
if (!store.isLoggedIn) {
|
||||||
|
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||||
|
setTimeout(() => { uni.reLaunch({ url: '/pages/login/login' }) }, 1000)
|
||||||
|
return
|
||||||
|
}
|
||||||
const memberInfo = store.getMemberInfo()
|
const memberInfo = store.getMemberInfo()
|
||||||
const memberId = memberInfo ? (memberInfo.memberId || memberInfo.id) : null
|
const memberId = memberInfo ? (memberInfo.memberId || memberInfo.id) : null
|
||||||
if (!memberId) {
|
if (!memberId) {
|
||||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||||
|
setTimeout(() => { uni.reLaunch({ url: '/pages/login/login' }) }, 1000)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.submitting = true
|
this.submitting = true
|
||||||
|
|||||||
@@ -42,10 +42,11 @@ const store = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 更新会员信息 */
|
/** 更新会员信息(合并而非覆盖,保留 memberId 等关键字段) */
|
||||||
updateMemberInfo(memberInfo) {
|
updateMemberInfo(memberInfo) {
|
||||||
try {
|
try {
|
||||||
uni.setStorageSync(MEMBER_KEY, memberInfo)
|
const existing = uni.getStorageSync(MEMBER_KEY) || {}
|
||||||
|
uni.setStorageSync(MEMBER_KEY, { ...existing, ...memberInfo })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('更新会员信息失败:', e)
|
console.error('更新会员信息失败:', e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
export { type GroupCourse } from '@/api/groupCourse.api'
|
||||||
|
|
||||||
|
export interface Coach {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
nickname: string
|
||||||
|
email: string
|
||||||
|
phone: string
|
||||||
|
avatar: string
|
||||||
|
status: number
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoachCreateRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
nickname: string
|
||||||
|
email: string
|
||||||
|
phone: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoachUpdateRequest {
|
||||||
|
nickname?: string
|
||||||
|
email?: string
|
||||||
|
phone?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const coachApi = {
|
||||||
|
/** 获取所有教练列表 */
|
||||||
|
getAll: () =>
|
||||||
|
request.get<Coach[]>('/coach/list'),
|
||||||
|
|
||||||
|
/** 创建教练(自动分配教练角色) */
|
||||||
|
create: (data: CoachCreateRequest) =>
|
||||||
|
request.post<Coach>('/coach', data),
|
||||||
|
|
||||||
|
/** 更新教练信息 */
|
||||||
|
update: (id: string, data: CoachUpdateRequest) =>
|
||||||
|
request.put<Coach>(`/coach/${id}`, data),
|
||||||
|
|
||||||
|
/** 禁用教练 */
|
||||||
|
disable: (id: string) =>
|
||||||
|
request.post<void>(`/coach/${id}/disable`),
|
||||||
|
|
||||||
|
/** 获取教练教授的团课列表 */
|
||||||
|
getCourses: (id: string) =>
|
||||||
|
request.get<GroupCourse[]>(`/coach/${id}/courses`),
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@ import request from '@/utils/request'
|
|||||||
export interface GroupCourse {
|
export interface GroupCourse {
|
||||||
id?: number
|
id?: number
|
||||||
courseName: string
|
courseName: string
|
||||||
coachId?: number
|
coachId?: number | string
|
||||||
|
coachName?: string
|
||||||
courseType?: number
|
courseType?: number
|
||||||
startTime: string
|
startTime: string
|
||||||
endTime: string
|
endTime: string
|
||||||
@@ -31,7 +32,7 @@ export interface GroupCoursePageRequest {
|
|||||||
keyword?: string
|
keyword?: string
|
||||||
status?: string
|
status?: string
|
||||||
courseType?: number
|
courseType?: number
|
||||||
coachId?: number
|
coachId?: number | string
|
||||||
startTime?: string
|
startTime?: string
|
||||||
endTime?: string
|
endTime?: string
|
||||||
sortBy?: string
|
sortBy?: string
|
||||||
@@ -75,6 +76,18 @@ export const groupCourseApi = {
|
|||||||
|
|
||||||
search: (params: any) =>
|
search: (params: any) =>
|
||||||
request.post<GroupCourse[]>('/groupCourse/search', params),
|
request.post<GroupCourse[]>('/groupCourse/search', params),
|
||||||
|
|
||||||
|
checkCoachConflict: (params: {
|
||||||
|
coachId: number | string
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
excludeCourseId?: number | string
|
||||||
|
}) =>
|
||||||
|
request.post<{
|
||||||
|
success: boolean
|
||||||
|
hasConflict: boolean
|
||||||
|
conflicts: GroupCourse[]
|
||||||
|
}>('/groupCourse/check-coach-conflict', params),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 团课类型 ====================
|
// ==================== 团课类型 ====================
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/system/UserManagement.vue'),
|
component: () => import('@/views/system/UserManagement.vue'),
|
||||||
meta: { title: '用户管理' }
|
meta: { title: '用户管理' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'coach',
|
||||||
|
name: 'CoachManagement',
|
||||||
|
component: () => import('@/views/system/CoachManagement.vue'),
|
||||||
|
meta: { title: '教练管理' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'roles',
|
path: 'roles',
|
||||||
name: 'RoleManagement',
|
name: 'RoleManagement',
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ function transformMenuData(backendMenus: BackendMenuItem[]): MenuItem[] {
|
|||||||
'system/user/index': '/users',
|
'system/user/index': '/users',
|
||||||
'system/role/index': '/roles',
|
'system/role/index': '/roles',
|
||||||
'system/menu/index': '/menus',
|
'system/menu/index': '/menus',
|
||||||
|
'system/coach/index': '/coach',
|
||||||
'system/dict/index': '/dict',
|
'system/dict/index': '/dict',
|
||||||
'system/config/index': '/sys/config',
|
'system/config/index': '/sys/config',
|
||||||
'system/notice/index': '/notice',
|
'system/notice/index': '/notice',
|
||||||
@@ -92,6 +93,7 @@ function getMenuIcon(menuName: string): string {
|
|||||||
'审计日志': 'Document',
|
'审计日志': 'Document',
|
||||||
'系统监控': 'Monitor',
|
'系统监控': 'Monitor',
|
||||||
'用户管理': 'User',
|
'用户管理': 'User',
|
||||||
|
'教练管理': 'Medal',
|
||||||
'角色管理': 'UserFilled',
|
'角色管理': 'UserFilled',
|
||||||
'菜单管理': 'Menu',
|
'菜单管理': 'Menu',
|
||||||
'字典管理': 'Collection',
|
'字典管理': 'Collection',
|
||||||
|
|||||||
@@ -64,6 +64,11 @@
|
|||||||
{{ getCourseTypeName(row.courseType) }}
|
{{ getCourseTypeName(row.courseType) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="教练" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ getCoachDisplayName(row.coachId) || row.coachName || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="90">
|
<el-table-column label="状态" width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag
|
<el-tag
|
||||||
@@ -150,6 +155,28 @@
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="教练" prop="coachId">
|
||||||
|
<el-select
|
||||||
|
v-model="formState.coachId"
|
||||||
|
placeholder="请选择教练"
|
||||||
|
clearable
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="coach in coaches"
|
||||||
|
:key="coach.id"
|
||||||
|
:value="coach.id"
|
||||||
|
:label="`${coach.nickname || coach.username} (${coach.username})`"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<div v-if="coachConflictWarning" style="margin-top: 6px; color: #e6a23c; font-size: 12px; line-height: 1.5;">
|
||||||
|
<el-icon style="vertical-align: middle;"><WarningFilled /></el-icon>
|
||||||
|
{{ coachConflictWarning }}
|
||||||
|
</div>
|
||||||
|
<div v-if="coachConflictChecking" style="margin-top: 4px; color: #909399; font-size: 12px;">
|
||||||
|
正在检查教练排期...
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="开始时间" prop="startTime">
|
<el-form-item label="开始时间" prop="startTime">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="formState.startTime"
|
v-model="formState.startTime"
|
||||||
@@ -246,8 +273,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, watch, onMounted } from 'vue'
|
import { ref, reactive, watch, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Search, Plus } from '@element-plus/icons-vue'
|
import { Search, Plus, WarningFilled } from '@element-plus/icons-vue'
|
||||||
import { groupCourseApi, groupCourseTypeApi, type GroupCourse, type GroupCourseType } from '@/api/groupCourse.api'
|
import { groupCourseApi, groupCourseTypeApi, type GroupCourse, type GroupCourseType } from '@/api/groupCourse.api'
|
||||||
|
import { coachApi, type Coach } from '@/api/coach.api'
|
||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
import { formatDateTime } from '@/utils/dateFormat'
|
import { formatDateTime } from '@/utils/dateFormat'
|
||||||
|
|
||||||
@@ -256,6 +284,7 @@ const dataSource = ref<GroupCourse[]>([])
|
|||||||
const searchKeyword = ref('')
|
const searchKeyword = ref('')
|
||||||
const searchStatus = ref('')
|
const searchStatus = ref('')
|
||||||
const courseTypes = ref<GroupCourseType[]>([])
|
const courseTypes = ref<GroupCourseType[]>([])
|
||||||
|
const coaches = ref<Coach[]>([])
|
||||||
|
|
||||||
const statusMap: Record<string, { type: string; label: string }> = {
|
const statusMap: Record<string, { type: string; label: string }> = {
|
||||||
'0': { type: 'success', label: '正常' },
|
'0': { type: 'success', label: '正常' },
|
||||||
@@ -279,9 +308,12 @@ const sortInfo = reactive({
|
|||||||
const modalVisible = ref(false)
|
const modalVisible = ref(false)
|
||||||
const modalTitle = ref('')
|
const modalTitle = ref('')
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
|
const coachConflictWarning = ref('')
|
||||||
|
const coachConflictChecking = ref(false)
|
||||||
const formState = reactive<GroupCourse>({
|
const formState = reactive<GroupCourse>({
|
||||||
courseName: '',
|
courseName: '',
|
||||||
courseType: undefined,
|
courseType: undefined,
|
||||||
|
coachId: undefined,
|
||||||
startTime: '',
|
startTime: '',
|
||||||
endTime: '',
|
endTime: '',
|
||||||
maxMembers: 20,
|
maxMembers: 20,
|
||||||
@@ -342,12 +374,70 @@ const fetchCourseTypes = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchCoaches = async () => {
|
||||||
|
try {
|
||||||
|
coaches.value = await coachApi.getAll()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getCourseTypeName = (typeId: number | undefined): string => {
|
const getCourseTypeName = (typeId: number | undefined): string => {
|
||||||
if (typeId === undefined || typeId === null) return '-'
|
if (typeId === undefined || typeId === null) return '-'
|
||||||
const type = courseTypes.value.find(t => t.id === typeId)
|
const type = courseTypes.value.find(t => t.id === typeId)
|
||||||
return type?.typeName || String(typeId)
|
return type?.typeName || String(typeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getCoachDisplayName = (coachId: number | string | undefined): string => {
|
||||||
|
if (coachId === undefined || coachId === null) return ''
|
||||||
|
const coach = coaches.value.find(c => String(c.id) === String(coachId))
|
||||||
|
if (!coach) return ''
|
||||||
|
return coach.nickname || coach.username
|
||||||
|
}
|
||||||
|
|
||||||
|
const getComputedEndTime = (): string => {
|
||||||
|
if (!formState.startTime || !(formState as any).duration) return ''
|
||||||
|
const start = new Date(formState.startTime)
|
||||||
|
if (isNaN(start.getTime())) return ''
|
||||||
|
start.setMinutes(start.getMinutes() + (formState as any).duration)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(start.getDate())}T${pad(start.getHours())}:${pad(start.getMinutes())}:${pad(start.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkCoachConflict = async () => {
|
||||||
|
const coachId = formState.coachId
|
||||||
|
const startTime = formState.startTime
|
||||||
|
const durationVal = (formState as any).duration
|
||||||
|
if (!coachId || !startTime || !durationVal) {
|
||||||
|
coachConflictWarning.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const endTime = getComputedEndTime()
|
||||||
|
if (!endTime) {
|
||||||
|
coachConflictWarning.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
coachConflictChecking.value = true
|
||||||
|
try {
|
||||||
|
const res = await groupCourseApi.checkCoachConflict({
|
||||||
|
coachId,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
excludeCourseId: formState.id,
|
||||||
|
})
|
||||||
|
if ((res as any).hasConflict && (res as any).conflicts?.length > 0) {
|
||||||
|
const names = (res as any).conflicts.map((c: any) => c.courseName || '未知课程').join('、')
|
||||||
|
coachConflictWarning.value = `该教练在此时间段已有团课:${names}`
|
||||||
|
} else {
|
||||||
|
coachConflictWarning.value = ''
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
coachConflictWarning.value = ''
|
||||||
|
} finally {
|
||||||
|
coachConflictChecking.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -522,12 +612,25 @@ const loadCoverPreview = async (val: string) => {
|
|||||||
// Watch coverImage changes to auto-load preview (e.g. when editing an existing course)
|
// Watch coverImage changes to auto-load preview (e.g. when editing an existing course)
|
||||||
watch(() => formState.coverImage, (val) => loadCoverPreview(val || ''), { immediate: true })
|
watch(() => formState.coverImage, (val) => loadCoverPreview(val || ''), { immediate: true })
|
||||||
|
|
||||||
|
// 监听教练选择和时间变化,检查冲突
|
||||||
|
watch(() => [formState.coachId, formState.startTime, (formState as any).duration] as const, () => {
|
||||||
|
checkCoachConflict()
|
||||||
|
}, { deep: false })
|
||||||
|
|
||||||
|
// 打开弹窗时清除冲突提示
|
||||||
|
watch(modalVisible, (val) => {
|
||||||
|
if (val) {
|
||||||
|
coachConflictWarning.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
modalTitle.value = '新增团课'
|
modalTitle.value = '新增团课'
|
||||||
Object.assign(formState, {
|
Object.assign(formState, {
|
||||||
id: undefined,
|
id: undefined,
|
||||||
courseName: '',
|
courseName: '',
|
||||||
courseType: undefined,
|
courseType: undefined,
|
||||||
|
coachId: undefined,
|
||||||
startTime: '',
|
startTime: '',
|
||||||
endTime: '',
|
endTime: '',
|
||||||
maxMembers: 20,
|
maxMembers: 20,
|
||||||
@@ -591,12 +694,35 @@ const handleModalOk = async () => {
|
|||||||
try {
|
try {
|
||||||
await formRef.value.validate()
|
await formRef.value.validate()
|
||||||
// Auto-calculate endTime from startTime + duration
|
// Auto-calculate endTime from startTime + duration
|
||||||
if (formState.startTime && formState.duration) {
|
if (formState.startTime && (formState as any).duration) {
|
||||||
const start = new Date(formState.startTime)
|
const start = new Date(formState.startTime)
|
||||||
start.setMinutes(start.getMinutes() + formState.duration)
|
start.setMinutes(start.getMinutes() + (formState as any).duration)
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
formState.endTime = `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(start.getDate())}T${pad(start.getHours())}:${pad(start.getMinutes())}:${pad(start.getSeconds())}`
|
formState.endTime = `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(start.getDate())}T${pad(start.getHours())}:${pad(start.getMinutes())}:${pad(start.getSeconds())}`
|
||||||
}
|
}
|
||||||
|
// 最终教练冲突检查
|
||||||
|
if (formState.coachId && formState.startTime && formState.endTime) {
|
||||||
|
try {
|
||||||
|
const res = await groupCourseApi.checkCoachConflict({
|
||||||
|
coachId: formState.coachId,
|
||||||
|
startTime: formState.startTime,
|
||||||
|
endTime: formState.endTime,
|
||||||
|
excludeCourseId: formState.id,
|
||||||
|
})
|
||||||
|
if ((res as any).hasConflict && (res as any).conflicts?.length > 0) {
|
||||||
|
const names = (res as any).conflicts.map((c: any) => c.courseName || '未知课程').join('、')
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`该教练在此时间段已有团课:${names},确定继续吗?`,
|
||||||
|
'时间冲突警告',
|
||||||
|
{ confirmButtonText: '继续保存', cancelButtonText: '取消', type: 'warning' }
|
||||||
|
)
|
||||||
|
// 用户点击了"继续保存",继续执行
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 用户取消了冲突确认对话框,中止保存
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if (formState.id) {
|
if (formState.id) {
|
||||||
await groupCourseApi.update(formState.id, formState)
|
await groupCourseApi.update(formState.id, formState)
|
||||||
} else {
|
} else {
|
||||||
@@ -612,8 +738,8 @@ const handleModalOk = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
fetchCourseTypes()
|
await Promise.all([fetchCourseTypes(), fetchCoaches()])
|
||||||
fetchData()
|
fetchData()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
<template>
|
||||||
|
<div class="coach-management">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="search-section">
|
||||||
|
<el-input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索教练用户名或昵称"
|
||||||
|
clearable
|
||||||
|
style="width: 300px"
|
||||||
|
@clear="handleSearch"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-button type="primary" @click="handleSearch">
|
||||||
|
搜索
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" @click="handleAdd">
|
||||||
|
新增教练
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="filteredData" style="width: 100%">
|
||||||
|
<el-table-column prop="username" label="用户名" />
|
||||||
|
<el-table-column prop="nickname" label="昵称" />
|
||||||
|
<el-table-column prop="email" label="邮箱" />
|
||||||
|
<el-table-column prop="phone" label="手机号" />
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
:type="row.status === 1 ? 'success' : 'danger'"
|
||||||
|
effect="dark"
|
||||||
|
>
|
||||||
|
{{ row.status === 1 ? '正常' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createdAt" label="创建时间">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDateTime(row.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="300">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link @click="handleEdit(row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button type="info" link @click="handleViewCourses(row)">
|
||||||
|
查看团课
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.status === 1"
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
@click="handleDisable(row)"
|
||||||
|
>
|
||||||
|
禁用
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 新增/编辑教练弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="modalVisible"
|
||||||
|
:title="modalTitle"
|
||||||
|
width="500px"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formState"
|
||||||
|
:rules="formRules"
|
||||||
|
label-width="80px"
|
||||||
|
>
|
||||||
|
<el-form-item label="用户名" prop="username" required>
|
||||||
|
<el-input v-model="formState.username" :disabled="!!formState.id" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="!formState.id" label="密码" prop="password" required>
|
||||||
|
<el-input v-model="formState.password" type="password" show-password />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="昵称">
|
||||||
|
<el-input v-model="formState.nickname" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="邮箱">
|
||||||
|
<el-input v-model="formState.email" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="手机号">
|
||||||
|
<el-input v-model="formState.phone" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="handleModalCancel">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleModalOk">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 查看团课弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="coursesDialogVisible"
|
||||||
|
:title="'教练团课 - ' + selectedCoachName"
|
||||||
|
width="900px"
|
||||||
|
>
|
||||||
|
<el-table v-loading="coursesLoading" :data="coachCourses" style="width: 100%" max-height="400">
|
||||||
|
<el-table-column prop="id" label="ID" width="60" />
|
||||||
|
<el-table-column prop="courseName" label="课程名称" />
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getCourseStatusTag(row.status)" effect="dark">
|
||||||
|
{{ getCourseStatusText(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="开课时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDateTime(row.startTime) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="结课时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDateTime(row.endTime) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="预约人数" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.currentMembers }} / {{ row.maxMembers }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="location" label="上课地点" />
|
||||||
|
</el-table>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="coursesDialogVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Search } from '@element-plus/icons-vue'
|
||||||
|
import { coachApi, type Coach, type CoachCreateRequest, type CoachUpdateRequest, type GroupCourse } from '@/api/coach.api'
|
||||||
|
import { handleApiError } from '@/utils/errorHandler'
|
||||||
|
import { formatDateTime } from '@/utils/dateFormat'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const dataSource = ref<Coach[]>([])
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
|
||||||
|
const filteredData = computed(() => {
|
||||||
|
if (!searchKeyword.value) return dataSource.value
|
||||||
|
const keyword = searchKeyword.value.toLowerCase()
|
||||||
|
return dataSource.value.filter(
|
||||||
|
(c) =>
|
||||||
|
c.username.toLowerCase().includes(keyword) ||
|
||||||
|
c.nickname.toLowerCase().includes(keyword) ||
|
||||||
|
c.email.toLowerCase().includes(keyword)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 新增/编辑表单
|
||||||
|
const modalVisible = ref(false)
|
||||||
|
const modalTitle = ref('')
|
||||||
|
const formRef = ref()
|
||||||
|
const formState = reactive<CoachCreateRequest & { id?: string }>({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
nickname: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
username: [
|
||||||
|
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||||
|
{ min: 3, max: 50, message: '用户名长度在 3 到 50 个字符', trigger: 'blur' },
|
||||||
|
{ pattern: /^[a-zA-Z0-9_-]+$/, message: '用户名只能包含字母、数字、下划线和横线', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
password: [
|
||||||
|
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||||
|
{ min: 8, max: 20, message: '密码长度在 8 到 20 个字符', trigger: 'blur' },
|
||||||
|
{ pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/, message: '密码必须包含大小写字母和数字', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
email: [
|
||||||
|
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||||
|
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
phone: [
|
||||||
|
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||||
|
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// 团课查看
|
||||||
|
const coursesDialogVisible = ref(false)
|
||||||
|
const coursesLoading = ref(false)
|
||||||
|
const coachCourses = ref<GroupCourse[]>([])
|
||||||
|
const selectedCoachName = ref('')
|
||||||
|
|
||||||
|
// 团课状态映射
|
||||||
|
const courseStatusMap: Record<number, { text: string; tag: string }> = {
|
||||||
|
0: { text: '正常', tag: 'success' },
|
||||||
|
1: { text: '已取消', tag: 'info' },
|
||||||
|
2: { text: '已结束', tag: 'warning' },
|
||||||
|
3: { text: '进行中', tag: 'danger' },
|
||||||
|
4: { text: '超时', tag: 'info' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCourseStatusText(status: number) {
|
||||||
|
return courseStatusMap[status]?.text || '未知'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCourseStatusTag(status: number) {
|
||||||
|
return courseStatusMap[status]?.tag || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
dataSource.value = await coachApi.getAll()
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
// 前端过滤,无需重新请求
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
modalTitle.value = '新增教练'
|
||||||
|
Object.assign(formState, {
|
||||||
|
id: undefined,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
nickname: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
})
|
||||||
|
modalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row: Coach) => {
|
||||||
|
modalTitle.value = '编辑教练'
|
||||||
|
Object.assign(formState, {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
nickname: row.nickname,
|
||||||
|
email: row.email,
|
||||||
|
phone: row.phone,
|
||||||
|
})
|
||||||
|
modalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDisable = async (row: Coach) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要禁用教练「${row.nickname || row.username}」吗?\n\n禁用后:\n1. 该教练正在进行中的团课不会被影响\n2. 该教练非进行中的团课将被自动取消\n3. 该教练将无法再被关联到团课`,
|
||||||
|
'禁用教练确认',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定禁用',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
dangerouslyUseHTMLString: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await coachApi.disable(row.id)
|
||||||
|
ElMessage.success('教练已禁用')
|
||||||
|
fetchData()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
handleApiError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleModalOk = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
|
||||||
|
if (formState.id) {
|
||||||
|
const updateData: CoachUpdateRequest = {
|
||||||
|
nickname: formState.nickname,
|
||||||
|
email: formState.email,
|
||||||
|
phone: formState.phone,
|
||||||
|
}
|
||||||
|
await coachApi.update(formState.id, updateData)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
} else {
|
||||||
|
const createData: CoachCreateRequest = {
|
||||||
|
username: formState.username,
|
||||||
|
password: formState.password,
|
||||||
|
nickname: formState.nickname,
|
||||||
|
email: formState.email,
|
||||||
|
phone: formState.phone,
|
||||||
|
}
|
||||||
|
await coachApi.create(createData)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
modalVisible.value = false
|
||||||
|
fetchData()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
handleApiError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleModalCancel = () => {
|
||||||
|
modalVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleViewCourses = async (row: Coach) => {
|
||||||
|
selectedCoachName.value = row.nickname || row.username
|
||||||
|
coursesDialogVisible.value = true
|
||||||
|
coursesLoading.value = true
|
||||||
|
try {
|
||||||
|
coachCourses.value = await coachApi.getCourses(row.id)
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error)
|
||||||
|
} finally {
|
||||||
|
coursesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="css">
|
||||||
|
.coach-management {
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user